feat: add sound effects, improved stroke recognition, instant locale switching with scramble text animation
Release Build / build-docker (push) Successful in 1m9s
Release Build / build-android-and-release (push) Successful in 7m14s

- Add SoundManager with Web Audio API synthesized sounds (stroke error, drawing complete, session complete)
- Add sound effects toggle in settings (persisted via localStorage)
- Improve stroke recognition: curvature similarity, max point deviation, stroke length ratio checks
- Loosen thresholds for long strokes and first-stroke positioning
- Implement instant locale switching (no save required)
- Add ScrambleText component with character-by-character morphing animation
- Apply ScrambleText globally across all views, widgets, and dialogs
- Add i18n keys for sound effects settings (EN/DE/JA)
This commit is contained in:
2026-04-18 22:19:36 +02:00
parent 979f8fc5e1
commit 049eaa1807
18 changed files with 907 additions and 166 deletions
+75 -30
View File
@@ -12,7 +12,8 @@
.d-flex.flex-column.h-100.pa-4
.d-flex.align-center.mb-6.px-2
img.mr-3(:src="logo" height="32" width="32" alt="Logo")
span.text-h6.font-weight-bold {{ $t('nav.menu') }}
span.text-h6.font-weight-bold
ScrambleText(:text="$t('nav.menu')")
v-list.pa-0(nav bg-color="transparent")
v-list-item.mb-2(
@@ -22,7 +23,8 @@
)
template(v-slot:prepend)
v-icon(color="#00cec9" icon="mdi-view-dashboard")
v-list-item-title.font-weight-bold {{ $t('nav.dashboard') }}
v-list-item-title.font-weight-bold
ScrambleText(:text="$t('nav.dashboard')")
v-list-item.mb-2(
to="/collection"
@@ -31,7 +33,8 @@
)
template(v-slot:prepend)
v-icon(color="#00cec9" icon="mdi-bookshelf")
v-list-item-title.font-weight-bold {{ $t('nav.collection') }}
v-list-item-title.font-weight-bold
ScrambleText(:text="$t('nav.collection')")
v-divider.my-4.border-subtle
@@ -43,7 +46,7 @@
@click="showSettings = true; drawer = false"
)
v-icon(start icon="mdi-cog")
| {{ $t('nav.settings') }}
ScrambleText(:text="$t('nav.settings')")
v-btn.justify-start.px-4(
variant="text"
@@ -53,7 +56,7 @@
@click="manualSync"
)
v-icon(start icon="mdi-sync")
| {{ $t('nav.sync') }}
ScrambleText(:text="$t('nav.sync')")
v-btn.justify-start.px-4.text-red-lighten-2(
variant="text"
@@ -61,7 +64,7 @@
@click="handleLogout"
)
v-icon(start icon="mdi-logout")
| {{ $t('nav.logout') }}
ScrambleText(:text="$t('nav.logout')")
.parallax-bg(
:style="parallaxStyle"
:class="{ 'zooming': viewState === 'zooming' }"
@@ -103,13 +106,15 @@
to="/"
variant="text"
:color="$route.path === '/' ? '#00cec9' : 'grey'"
) {{ $t('nav.dashboard') }}
)
ScrambleText(:text="$t('nav.dashboard')")
v-btn.mx-1(
to="/collection"
variant="text"
:color="$route.path === '/collection' ? '#00cec9' : 'grey'"
) {{ $t('nav.collection') }}
)
ScrambleText(:text="$t('nav.collection')")
v-divider.mx-2.my-auto(vertical length="20" color="grey-darken-2")
@@ -169,8 +174,10 @@
.login-content
img.mb-4(:src="logo" height="64" width="64")
h1.text-h5.font-weight-bold.mb-2 {{ $t('hero.welcome') }}
p.text-grey-lighten-1.text-body-2.mb-6 {{ $t('login.instruction') }}
h1.text-h5.font-weight-bold.mb-2
ScrambleText(:text="$t('hero.welcome')")
p.text-grey-lighten-1.text-body-2.mb-6
ScrambleText(:text="$t('login.instruction')")
v-text-field.mb-2(
v-model="inputKey"
@@ -197,14 +204,17 @@
height="44"
:loading="viewState === 'authenticating'"
@click="triggerLogin"
) {{ $t('login.button') }}
)
ScrambleText(:text="$t('login.button')")
v-dialog(v-model="showSettings" max-width="340")
v-card.rounded-xl.pa-4.border-subtle(color="#1e1e24")
v-card-title.text-center.font-weight-bold {{ $t('settings.title') }}
v-card-title.text-center.font-weight-bold
ScrambleText(:text="$t('settings.title')")
v-card-text.pt-4
.text-caption.text-grey.mb-2 {{ $t('settings.batchSize') }}
.text-caption.text-grey.mb-2
ScrambleText(:text="$t('settings.batchSize')")
v-slider(
v-model="tempBatchSize"
:min="5"
@@ -215,9 +225,12 @@
track-color="grey-darken-3"
)
.text-center.text-h5.font-weight-bold.text-teal-accent-3.mb-6
| {{ tempBatchSize }} {{ $t('settings.items') }}
| {{ tempBatchSize }}
|
ScrambleText(:text="$t('settings.items')")
.text-caption.text-grey.mb-2 {{ $t('settings.drawingTolerance') }}
.text-caption.text-grey.mb-2
ScrambleText(:text="$t('settings.drawingTolerance')")
v-slider(
v-model="tempDrawingAccuracy"
:min="1"
@@ -228,11 +241,16 @@
track-color="grey-darken-3"
)
.d-flex.justify-space-between.text-caption.text-grey-lighten-1.mb-6.px-1
span {{ $t('settings.strict') }} (5)
span
ScrambleText(:text="$t('settings.strict')")
| (5)
span.font-weight-bold.text-body-1(color="#00cec9") {{ tempDrawingAccuracy }}
span {{ $t('settings.loose') }} (20)
span
ScrambleText(:text="$t('settings.loose')")
| (20)
.text-caption.text-grey.mb-2 {{ $t('settings.language') }}
.text-caption.text-grey.mb-2
ScrambleText(:text="$t('settings.language')")
.lang-switch-wrapper.border-subtle.mb-2
.lang-glider(:style="{ transform: `translateX(${localeIndex * 100}%)` }")
@@ -240,15 +258,29 @@
v-for="l in availableLocales"
:key="l"
:class="{ 'active': tempLocale === l }"
@click="tempLocale = l"
@click="switchLocale(l)"
) {{ l.toUpperCase() }}
.d-flex.align-center.justify-space-between.mt-4.px-1
.text-caption.text-grey
ScrambleText(:text="$t('settings.soundEffects')")
v-switch(
v-model="soundEnabled"
color="cyan"
density="compact"
hide-details
inset
)
template(v-slot:label)
ScrambleText(:text="soundEnabled ? $t('settings.on') : $t('settings.off')")
v-card-actions.mt-2
v-btn.text-white(
block
color="#2f3542"
@click="saveSettings"
) {{ $t('settings.save') }}
)
ScrambleText(:text="$t('settings.save')")
v-dialog(v-model="showLogoutDialog" max-width="320")
v-card.rounded-xl.pa-4.border-subtle(color="#1e1e24")
@@ -256,10 +288,11 @@
v-avatar.mb-3(color="red-darken-4" size="48")
v-icon(icon="mdi-logout" size="24" color="red-lighten-1")
h3.text-h6.font-weight-bold.mb-2 {{ $t('nav.logout') }}?
h3.text-h6.font-weight-bold.mb-2
ScrambleText(:text="$t('nav.logout') + '?'")
p.text-body-2.text-grey-lighten-1.px-2.mb-4
| {{ $t('alerts.logoutConfirm') }}
ScrambleText(:text="$t('alerts.logoutConfirm')")
v-card-actions.d-flex.gap-2.pa-0.mt-2
v-btn.flex-grow-1.text-capitalize(
@@ -267,19 +300,22 @@
color="grey"
height="40"
@click="showLogoutDialog = false"
) {{ $t('common.cancel') }}
)
ScrambleText(:text="$t('common.cancel')")
v-btn.flex-grow-1.text-capitalize(
color="red-accent-2"
variant="flat"
height="40"
@click="confirmLogout"
) {{ $t('nav.logout') }}
)
ScrambleText(:text="$t('nav.logout')")
v-snackbar(v-model="snackbar.show" :color="snackbar.color" timeout="3000")
| {{ snackbar.text }}
template(v-slot:actions)
v-btn(variant="text" @click="snackbar.show = false") {{ $t('common.close') }}
v-btn(variant="text" @click="snackbar.show = false")
ScrambleText(:text="$t('common.close')")
</template>
<script setup>
@@ -291,6 +327,7 @@ import { useI18n } from 'vue-i18n';
import { App as CapacitorApp } from '@capacitor/app';
import { useAppStore } from '@/stores/appStore';
import { checkForUpdates } from '@/utils/autoUpdate';
import { SoundManager } from '@/utils/SoundManager';
import logo from '@/assets/icon.svg';
const drawer = ref(false);
@@ -313,6 +350,9 @@ const tempDrawingAccuracy = ref(store.drawingAccuracy);
const availableLocales = ['en', 'de', 'ja'];
const tempLocale = ref(locale.value);
const localeIndex = computed(() => availableLocales.indexOf(tempLocale.value));
const soundEnabled = ref(true);
watch(inputKey, () => { errorMsg.value = ''; });
const backgroundKanjis = [
{
@@ -391,6 +431,7 @@ const handleMouseMove = (e) => {
};
onMounted(() => {
SoundManager.init();
CapacitorApp.addListener('appStateChange', ({ isActive }) => {
if (isActive) checkForUpdates();
});
@@ -421,6 +462,7 @@ watch(showSettings, (isOpen) => {
tempBatchSize.value = store.batchSize;
tempDrawingAccuracy.value = store.drawingAccuracy;
tempLocale.value = locale.value;
soundEnabled.value = !SoundManager.isMuted;
}
});
@@ -464,20 +506,23 @@ async function triggerLogin() {
}
}
function switchLocale(l) {
tempLocale.value = l;
locale.value = l;
localStorage.setItem('zen_locale', l);
}
function saveSettings() {
showSettings.value = false;
setTimeout(() => {
if (locale.value !== tempLocale.value) {
locale.value = tempLocale.value;
localStorage.setItem('zen_locale', locale.value);
}
store.saveSettings({
batchSize: tempBatchSize.value,
drawingAccuracy: tempDrawingAccuracy.value,
});
SoundManager.setMuted(!soundEnabled.value);
store.fetchQueue();
}, 300);
}
+79
View File
@@ -0,0 +1,79 @@
<template lang="pug">
span {{ displayText }}
</template>
<script setup>
import { ref, watch, onBeforeUnmount } from 'vue';
const props = defineProps({
text: { type: String, required: true },
});
const displayText = ref(props.text);
let frameId = null;
const CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZアイウエオカキクケコ漢字書道';
const DURATION = 400; // total animation time in ms
const STEPS_PER_CHAR = 3; // how many random chars before settling
function scrambleTo(newText) {
if (frameId) cancelAnimationFrame(frameId);
const oldText = displayText.value;
const maxLen = Math.max(oldText.length, newText.length);
const startTime = performance.now();
// Each character position settles at a staggered time
const charDelay = DURATION / (maxLen || 1);
const tick = (now) => {
const elapsed = now - startTime;
let result = '';
for (let i = 0; i < maxLen; i++) {
const charSettleTime = i * charDelay;
const charElapsed = elapsed - charSettleTime;
if (i >= newText.length && elapsed > charSettleTime + charDelay) {
// This position should disappear — don't add anything
continue;
}
if (charElapsed >= charDelay) {
// This character has settled — show final
if (i < newText.length) result += newText[i];
} else if (charElapsed > 0) {
// This character is still scrambling
const step = Math.floor((charElapsed / charDelay) * STEPS_PER_CHAR);
if (step >= STEPS_PER_CHAR - 1 && i < newText.length) {
result += newText[i];
} else {
result += CHARS[Math.floor(Math.random() * CHARS.length)];
}
} else {
// Hasn't started yet — show old char or space
result += i < oldText.length ? oldText[i] : ' ';
}
}
displayText.value = result;
if (elapsed < DURATION + charDelay) {
frameId = requestAnimationFrame(tick);
} else {
displayText.value = newText;
frameId = null;
}
};
frameId = requestAnimationFrame(tick);
}
watch(() => props.text, (newVal) => {
scrambleTo(newVal);
});
onBeforeUnmount(() => {
if (frameId) cancelAnimationFrame(frameId);
});
</script>
@@ -14,8 +14,10 @@
div
.text-subtitle-1.font-weight-bold.d-flex.align-center.text-white(
style="line-height: 1.2"
) {{ title }}
.text-caption.text-grey(v-if="subtitle") {{ subtitle }}
)
ScrambleText(:text="title")
.text-caption.text-grey(v-if="subtitle")
ScrambleText(:text="subtitle")
div.d-flex.align-center
slot(name="header-right")
@@ -7,9 +7,11 @@
div
.text-h3.font-weight-bold.text-white {{ accuracyPercent }}%
.text-caption.text-grey.mt-1
| {{ accuracy?.correct || 0 }} {{ $t('stats.correct') }}
| {{ accuracy?.correct || 0 }}
ScrambleText(:text="$t('stats.correct')")
span.mx-1 /
| {{ accuracy?.total || 0 }} {{ $t('stats.total') }}
| {{ accuracy?.total || 0 }}
ScrambleText(:text="$t('stats.total')")
v-progress-circular(
:model-value="accuracyPercent"
@@ -3,7 +3,7 @@
.d-flex.align-center.justify-space-between.mb-4
.text-subtitle-1.font-weight-bold.d-flex.align-center
v-icon(color="secondary" start size="small") mdi-chart-bar
| {{ $t('stats.srsDistribution') }}
ScrambleText(:text="$t('stats.srsDistribution')")
v-chip.font-weight-bold(
size="x-small"
color="white"
@@ -7,7 +7,7 @@
div(v-for="(count, hour) in forecast" :key="hour")
.d-flex.justify-space-between.align-center.mb-2.py-2.border-b-subtle(v-if="count > 0")
span.text-body-2.text-grey-lighten-1
| {{ hour === 0 ? $t('stats.availableNow') : $t('stats.inHours', { n: hour }, hour) }}
ScrambleText(:text="hour === 0 ? $t('stats.availableNow') : $t('stats.inHours', { n: hour }, hour)")
v-chip.font-weight-bold.text-primary(
size="small"
@@ -15,7 +15,7 @@
) {{ count }}
.fill-height.d-flex.align-center.justify-center.text-grey.text-center.pa-4(v-else)
| {{ $t('stats.noIncoming') }}
ScrambleText(:text="$t('stats.noIncoming')")
</template>
<script setup>
@@ -24,7 +24,7 @@ import { computed } from 'vue';
import DashboardWidget from './DashboardWidget.vue';
const props = defineProps({
forecast: { type: Object, default: () => ({}) },
forecast: { type: Array, default: () => [] },
});
const hasUpcoming = computed(() => props.forecast && props.forecast.some((c) => c > 0));
@@ -24,7 +24,7 @@
) {{ ghost.accuracy }}%
.text-center.py-2.text-caption.text-grey(v-else)
| {{ $t('stats.noGhosts') }}
ScrambleText(:text="$t('stats.noGhosts')")
</template>
<script setup>
@@ -17,7 +17,8 @@
)
.text-caption.text-medium-emphasis.mt-2.text-right
| {{ masteredCount }} / {{ totalItems }} {{ $t('stats.items') }}
| {{ masteredCount }} / {{ totalItems }}
ScrambleText(:text="$t('stats.items')")
</template>
<script setup>
@@ -33,7 +34,7 @@ const totalItems = computed(
() => Object.values(props.distribution || {}).reduce((a, b) => a + b, 0),
);
const masteredCount = computed(
() => (props.distribution || {})[6] || 0,
() => [6, 7, 8, 9, 10].reduce((sum, lvl) => sum + ((props.distribution || {})[lvl] || 0), 0),
);
const masteryPercent = computed(
() => (totalItems.value === 0 ? 0 : Math.round((masteredCount.value / totalItems.value) * 100)),
@@ -5,13 +5,15 @@
)
template(#header-right)
.legend-container
span.text-caption.text-medium-emphasis.mr-1 {{ $t('stats.less') }}
span.text-caption.text-medium-emphasis.mr-1
ScrambleText(:text="$t('stats.less')")
.legend-box.level-0
.legend-box.level-1
.legend-box.level-2
.legend-box.level-3
.legend-box.level-4
span.text-caption.text-medium-emphasis.ml-1 {{ $t('stats.more') }}
span.text-caption.text-medium-emphasis.ml-1
ScrambleText(:text="$t('stats.more')")
.heatmap-container
.heatmap-year
@@ -25,7 +27,8 @@
)
.text-center
.font-weight-bold {{ formatDate(day.date) }}
div {{ $t('stats.reviewsCount', { count: day.count }) }}
div
ScrambleText(:text="$t('stats.reviewsCount', { count: day.count })")
</template>
<script setup>
@@ -19,7 +19,8 @@
.d-flex.align-end.mb-3
.text-h3.font-weight-bold.text-white.mr-2(style="line-height: 1")
| {{ streak?.current || 0 }}
.text-body-1.text-grey.mb-1 {{ $t('stats.days') }}
.text-body-1.text-grey.mb-1
ScrambleText(:text="$t('stats.days')")
.d-flex.justify-space-between.align-center.px-1
.d-flex.flex-column.align-center(
@@ -1,9 +1,11 @@
<template lang="pug">
.grid-area-full.text-center.mb-8.zen-wrapper
.text-h3.font-weight-bold.mb-2.text-white.letter-spacing-small {{ $t('hero.welcome') }}
.text-h3.font-weight-bold.mb-2.text-white.letter-spacing-small
ScrambleText(:text="$t('hero.welcome')")
.text-subtitle-1.text-grey-lighten-1.mb-10(
style="max-width: 600px; margin-inline: auto;"
) {{ $t('hero.subtitle') }}
)
ScrambleText(:text="$t('hero.subtitle')")
.d-flex.justify-center.align-stretch.flex-wrap.ga-6.mb-8
@@ -21,9 +23,9 @@
.d-flex.flex-column.align-start.mr-auto
.text-purple-lighten-1.text-h6.font-weight-bold.line-height-tight
| {{ $t('hero.lessons') }}
ScrambleText(:text="$t('hero.lessons')")
.text-purple-accent-1.text-caption.font-weight-regular.opacity-80
| {{ $t('hero.newContent') }}
ScrambleText(:text="$t('hero.newContent')")
.zen-counter.bg-purple-accent-2.text-black.ml-4
| {{ lessonCount }}
@@ -43,9 +45,9 @@
.d-flex.flex-column.align-start.mr-auto
.text-cyan-lighten-1.text-h6.font-weight-bold.line-height-tight
| {{ queueLength > 0 ? $t('hero.start') : $t('hero.noReviews') }}
ScrambleText(:text="queueLength > 0 ? $t('hero.start') : $t('hero.noReviews')")
.text-cyan-accent-1.text-caption.font-weight-regular.opacity-80
| {{ queueLength > 0 ? $t('hero.readyToReview') : $t('hero.allCaughtUp') }}
ScrambleText(:text="queueLength > 0 ? $t('hero.readyToReview') : $t('hero.allCaughtUp')")
v-fade-transition
.zen-counter.bg-cyan-accent-2.text-black.ml-4(v-if="queueLength > 0")
@@ -59,14 +61,16 @@
variant="text"
prepend-icon="mdi-sort-ascending"
:ripple="false"
) {{ $t('hero.prioritize', { count: lowerLevelCount }) }}
)
ScrambleText(:text="$t('hero.prioritize', { count: lowerLevelCount })")
v-fade-transition
.text-caption.text-grey-darken-1.font-weight-medium.d-flex.align-center.mt-2(
v-if="queueLength === 0 && !hasLowerLevels && nextReviewTime"
)
v-icon.mr-2(size="small" icon="mdi-clock-outline")
span {{ $t('hero.nextIn') }} {{ nextReviewTime }}
span
ScrambleText(:text="$t('hero.nextIn') + ' ' + nextReviewTime")
</template>
<script setup>
@@ -79,14 +83,14 @@ const props = defineProps({
lessonCount: { type: Number, default: 0 },
hasLowerLevels: { type: Boolean, default: false },
lowerLevelCount: { type: Number, default: 0 },
forecast: { type: Object, default: () => ({}) },
forecast: { type: Array, default: () => [] },
});
defineEmits(['start']);
const { t } = useI18n();
const nextReviewTime = computed(() => {
if (!props.forecast || Object.keys(props.forecast).length === 0) return null;
if (!props.forecast || props.forecast.length === 0) return null;
try {
const idx = props.forecast.findIndex((c) => c > 0);
if (idx === -1) return null;
+3
View File
@@ -51,8 +51,11 @@ const vuetify = createVuetify({
icons: { defaultSet: 'mdi', aliases, sets: { mdi } },
});
import ScrambleText from '@/components/ScrambleText.vue';
app.use(pinia);
app.use(router);
app.use(vuetify);
app.use(i18n);
app.component('ScrambleText', ScrambleText);
app.mount('#app');
+36
View File
@@ -44,8 +44,13 @@ const messages = {
learned: "You've learned {n} new kanji.",
components: 'Components',
observe: 'Observe Stroke Order',
replay: 'Replay',
trace: 'Trace',
drawStep: 'Draw ({n}/{total})',
sectionMeaning: 'Meaning',
sectionMnemonic: 'Memory Aid',
sectionReadings: 'Readings',
learnedKanji: 'Kanji Learned',
backToDashboard: 'Back to Dashboard',
},
hero: {
@@ -70,6 +75,7 @@ const messages = {
next24: 'Next 24h',
availableNow: 'Available Now',
inHours: 'In {n} hour | In {n} hours',
inDays: 'In {n} day | In {n} days',
noIncoming: 'No reviews incoming for 24 hours.',
items: 'items',
reviewsCount: '{count} reviews',
@@ -96,6 +102,9 @@ const messages = {
strict: 'Strict',
loose: 'Loose',
save: 'Save & Close',
soundEffects: 'Sound Effects',
on: 'On',
off: 'Off',
},
review: {
meaning: 'Meaning',
@@ -109,6 +118,9 @@ const messages = {
next: 'NEXT',
sessionComplete: 'Session Complete!',
levelup: 'You leveled up your Kanji skills.',
levelsUp: '{n} leveled up',
levelsDown: '{n} leveled down',
results: 'Results',
back: 'Back to Collection',
caughtUp: 'All Caught Up!',
noReviews: 'No reviews available right now.',
@@ -174,8 +186,13 @@ const messages = {
learned: 'Du hast {n} neue Kanji gelernt.',
components: 'Komponenten',
observe: 'Strichfolge beobachten',
replay: 'Wiederholen',
trace: 'Nachzeichnen',
drawStep: 'Zeichnen ({n}/{total})',
sectionMeaning: 'Bedeutung',
sectionMnemonic: 'Merkhilfe',
sectionReadings: 'Lesungen',
learnedKanji: 'Gelernte Kanji',
backToDashboard: 'Zurück zur Übersicht',
},
hero: {
@@ -200,6 +217,7 @@ const messages = {
next24: 'Nächste 24h',
availableNow: 'Jetzt verfügbar',
inHours: 'In {n} Stunde | In {n} Stunden',
inDays: 'In {n} Tag | In {n} Tagen',
noIncoming: 'Keine Reviews in den nächsten 24h.',
items: 'Einträge',
reviewsCount: '{count} Reviews',
@@ -226,6 +244,9 @@ const messages = {
strict: 'Strikt',
loose: 'Locker',
save: 'Speichern & Schließen',
soundEffects: 'Soundeffekte',
on: 'An',
off: 'Aus',
},
review: {
meaning: 'Bedeutung',
@@ -239,6 +260,9 @@ const messages = {
next: 'WEITER',
sessionComplete: 'Sitzung beendet!',
levelup: 'Du hast deine Kanji-Skills verbessert.',
levelsUp: '{n} aufgestiegen',
levelsDown: '{n} abgestiegen',
results: 'Ergebnisse',
back: 'Zurück zur Sammlung',
caughtUp: 'Alles erledigt!',
noReviews: 'Gerade keine Reviews verfügbar.',
@@ -304,8 +328,13 @@ const messages = {
learned: '{n}個の新しい漢字を覚えました。',
components: '構成要素',
observe: '書き順を見る',
replay: 'もう一度',
trace: 'なぞる',
drawStep: '書く ({n}/{total})',
sectionMeaning: '意味',
sectionMnemonic: '覚え方',
sectionReadings: '読み方',
learnedKanji: '学んだ漢字',
backToDashboard: 'ダッシュボードに戻る',
},
hero: {
@@ -330,6 +359,7 @@ const messages = {
next24: '今後24時間',
availableNow: '今すぐ可能',
inHours: '{n}時間後',
inDays: '{n}日後',
noIncoming: '24時間以内のレビューはありません。',
items: '個',
reviewsCount: '{count} レビュー',
@@ -356,6 +386,9 @@ const messages = {
strict: '厳しい',
loose: '甘い',
save: '保存して閉じる',
soundEffects: '効果音',
on: 'オン',
off: 'オフ',
},
review: {
meaning: '意味',
@@ -369,6 +402,9 @@ const messages = {
next: '次へ',
sessionComplete: 'セッション完了!',
levelup: '漢字力がアップしました。',
levelsUp: '{n}個昇格',
levelsDown: '{n}個降格',
results: '結果',
back: 'コレクションに戻る',
caughtUp: '完了しました!',
noReviews: '現在レビューするものはありません。',
+168 -18
View File
@@ -1,3 +1,5 @@
import { SoundManager } from './SoundManager.js';
export const KANJI_CONSTANTS = {
BASE_SIZE: 109,
SVG_NS: 'http://www.w3.org/2000/svg',
@@ -11,7 +13,7 @@ export const KANJI_CONSTANTS = {
SAMPLE_POINTS: 60,
VALIDATION: {
SAMPLES: 10,
SAMPLES: 20,
},
COLORS: {
@@ -323,12 +325,16 @@ export class KanjiController {
this.clearCanvas(this.ctx.hint);
if (this.currentStrokeIdx >= this.paths.length) {
SoundManager.playDrawingComplete();
this.onComplete();
} else if (this.autoHint) {
this.showHint();
} else {
if (this.autoHint) {
this.showHint();
}
}
});
} else {
SoundManager.playStrokeError();
this.mistakes += 1;
this.animateErrorFade(this.userPath, () => {
this.redrawAllPerfectStrokes();
@@ -346,27 +352,171 @@ export class KanjiController {
const len = pathEl.getTotalLength();
const { SAMPLES } = KANJI_CONSTANTS.VALIDATION;
const avgDistThreshold = this.accuracy * 0.8;
const startEndThreshold = this.accuracy * 2.5;
const targetStart = pathEl.getPointAtLength(0);
const targetEnd = pathEl.getPointAtLength(len);
const dist = (p1, p2) => Math.hypot(p1.x - p2.x, p1.y - p2.y);
if (dist(userPts[0], targetStart) > startEndThreshold) return false;
if (dist(userPts[userPts.length - 1], targetEnd) > startEndThreshold) return false;
// ── 1. Start / end point check ──────────────────
const targetStart = pathEl.getPointAtLength(0);
const targetEnd = pathEl.getPointAtLength(len);
const userStart = userPts[0];
const userEnd = userPts[userPts.length - 1];
const isFirstStroke = this.currentStrokeIdx === 0;
const startThreshold = this.accuracy * (isFirstStroke ? 3.5 : 2.5);
// End tolerance scales with stroke length — long strokes get more forgiveness
const lengthBonus = Math.min(len / 30, 1.5);
const endThreshold = this.accuracy * (2.5 + lengthBonus);
if (dist(userStart, targetStart) > startThreshold) return false;
if (dist(userEnd, targetEnd) > endThreshold) return false;
// ── 2. Overall direction check ──────────────────
const userAngle = Math.atan2(userEnd.y - userStart.y, userEnd.x - userStart.x);
const targetAngle = Math.atan2(targetEnd.y - targetStart.y, targetEnd.x - targetStart.x);
const angleDiff = KanjiController.normalizeAngle(userAngle - targetAngle);
// Reject if direction is off by more than 90 degrees
if (angleDiff > Math.PI / 2) return false;
// ── 3. Orientation classification guard ─────────
const userOrientation = KanjiController.classifyOrientation(userStart, userEnd);
const targetOrientation = KanjiController.classifyOrientation(targetStart, targetEnd);
// Only block if both strokes are clearly H or V (not diagonal or dot)
if (userOrientation !== 'other' && targetOrientation !== 'other'
&& userOrientation !== targetOrientation) {
return false;
}
// ── 4. Sequential sample comparison ─────────────
const userResampled = KanjiController.resamplePoints(userPts, SAMPLES + 1);
let totalError = 0;
const sampleCount = SAMPLES + 1;
let maxError = 0;
totalError = Array.from({ length: sampleCount }).reduce((acc, _, i) => {
const targetPt = pathEl.getPointAtLength((i / SAMPLES) * len);
const minD = userPts.reduce((min, up) => Math.min(min, dist(targetPt, up)), Infinity);
return acc + minD;
}, 0);
for (let i = 0; i <= SAMPLES; i++) {
const t = i / SAMPLES;
const targetPt = pathEl.getPointAtLength(t * len);
const userPt = userResampled[i];
const d = dist(targetPt, userPt);
totalError += d;
if (d > maxError) maxError = d;
}
return (totalError / sampleCount) < avgDistThreshold;
const avgError = totalError / (SAMPLES + 1);
const avgDistThreshold = this.accuracy * 1.0;
const maxDistThreshold = this.accuracy * 3.5;
if (avgError > avgDistThreshold) return false;
// No single point should be wildly off — catches "right average, wrong shape"
if (maxError > maxDistThreshold) return false;
// ── 4b. Stroke length ratio ─────────────────────
let userLen = 0;
for (let i = 1; i < userPts.length; i++) {
userLen += dist(userPts[i - 1], userPts[i]);
}
const lengthRatio = userLen / len;
// User stroke should be between 30% and 300% of target length
// Long straight strokes especially benefit from the upper bound
if (lengthRatio < 0.3 || lengthRatio > 3.0) return false;
// ── 4c. Curvature similarity ────────────────────
const targetCurvature = KanjiController.measureCurvature(
userResampled.map((_, i) => {
const t = i / SAMPLES;
const pt = pathEl.getPointAtLength(t * len);
return { x: pt.x, y: pt.y };
}),
);
const userCurvature = KanjiController.measureCurvature(userPts);
// If target is clearly curved but user drew straight (or vice versa)
const curvatureDiff = Math.abs(targetCurvature - userCurvature);
// Scale threshold: strict = 0.2, loose = 0.5
const curvatureThreshold = 0.15 + (this.accuracy / 20) * 0.35;
if (curvatureDiff > curvatureThreshold) return false;
// ── 5. Endpoint direction check (hook detection) ─
const tailFraction = 0.2;
const targetTailStart = pathEl.getPointAtLength(len * (1 - tailFraction));
const targetTailAngle = Math.atan2(
targetEnd.y - targetTailStart.y,
targetEnd.x - targetTailStart.x,
);
// Check if target has a significant direction change at the end (hook)
const mainTargetAngle = Math.atan2(
targetEnd.y - targetStart.y,
targetEnd.x - targetStart.x,
);
const targetHookAngle = KanjiController.normalizeAngle(targetTailAngle - mainTargetAngle);
const hasHook = targetHookAngle > Math.PI / 6; // > 30 degrees = hook
if (hasHook) {
// User must also have a direction change in the last portion
const tailIdx = Math.max(0, Math.floor(userPts.length * (1 - tailFraction)));
const userTailStart = userPts[tailIdx];
const userTailAngle = Math.atan2(
userEnd.y - userTailStart.y,
userEnd.x - userTailStart.x,
);
const mainUserAngle = Math.atan2(
userEnd.y - userStart.y,
userEnd.x - userStart.x,
);
const userHookAngle = KanjiController.normalizeAngle(userTailAngle - mainUserAngle);
// User's hook should be at least half as pronounced as the target's
// but use a generous threshold scaled by accuracy
const hookThreshold = Math.max(Math.PI / 12, targetHookAngle * 0.4);
if (userHookAngle < hookThreshold) {
// Only reject on strict accuracy (< 12), forgive on loose settings
if (this.accuracy < 12) return false;
}
}
return true;
}
// ── Static helpers for stroke validation ────────────
static normalizeAngle(angle) {
let a = Math.abs(angle);
if (a > Math.PI) a = 2 * Math.PI - a;
return a;
}
static classifyOrientation(start, end) {
const dx = Math.abs(end.x - start.x);
const dy = Math.abs(end.y - start.y);
const totalDist = Math.hypot(dx, dy);
// Very short strokes (dots) — don't classify
if (totalDist < 5) return 'other';
const ratio = dx / (dy || 0.001);
if (ratio > 2.5) return 'horizontal';
if (ratio < 0.4) return 'vertical';
return 'other'; // diagonal or ambiguous
}
static measureCurvature(points) {
if (points.length < 3) return 0;
// Curvature = ratio of actual path length to straight-line distance
// A perfectly straight line = 0, a semicircle ≈ 0.57
let pathLen = 0;
for (let i = 1; i < points.length; i++) {
pathLen += Math.hypot(points[i].x - points[i - 1].x, points[i].y - points[i - 1].y);
}
const straightDist = Math.hypot(
points[points.length - 1].x - points[0].x,
points[points.length - 1].y - points[0].y,
);
if (straightDist < 1) return 0; // dot-like stroke
// Returns 0 for straight, >0 for curved (ratio - 1)
return Math.max(0, (pathLen / straightDist) - 1);
}
animateErrorFade(userPath, onComplete) {
+177
View File
@@ -0,0 +1,177 @@
/**
* SoundManager — Web Audio API synthesized sounds for ZenKanji.
*
* All sounds are generated programmatically (no external files).
* Designed to be subtle, smooth, and non-annoying.
*/
let audioCtx = null;
let muted = false;
const MASTER_VOLUME = 0.15;
function getCtx() {
if (!audioCtx) {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
}
return audioCtx;
}
function ensureResumed() {
const ctx = getCtx();
if (ctx.state === 'suspended') ctx.resume();
return ctx;
}
/**
* Play a soft "pop" — stroke accepted.
* Short sine wave with quick pitch drop, very gentle.
*/
function playStrokeCorrect() {
if (muted) return;
const ctx = ensureResumed();
const now = ctx.currentTime;
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(880, now);
osc.frequency.exponentialRampToValueAtTime(440, now + 0.08);
gain.gain.setValueAtTime(MASTER_VOLUME * 0.6, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.12);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(now);
osc.stop(now + 0.12);
}
/**
* Play a soft low thud — stroke rejected.
* Very short, low frequency, barely audible. Not jarring.
*/
function playStrokeError() {
if (muted) return;
const ctx = ensureResumed();
const now = ctx.currentTime;
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(200, now);
osc.frequency.exponentialRampToValueAtTime(120, now + 0.15);
gain.gain.setValueAtTime(MASTER_VOLUME * 0.4, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.15);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(now);
osc.stop(now + 0.15);
}
/**
* Play a pleasant ascending two-tone chime — drawing complete (all strokes done).
*/
function playDrawingComplete() {
if (muted) return;
const ctx = ensureResumed();
const now = ctx.currentTime;
[523.25, 659.25].forEach((freq, i) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.value = freq;
const start = now + i * 0.1;
gain.gain.setValueAtTime(0, start);
gain.gain.linearRampToValueAtTime(MASTER_VOLUME * 0.7, start + 0.03);
gain.gain.exponentialRampToValueAtTime(0.001, start + 0.3);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(start);
osc.stop(start + 0.3);
});
}
/**
* Play a gentle three-tone ascending arpeggio — session/lesson complete.
* C5 → E5 → G5, soft and satisfying.
*/
function playSessionComplete() {
if (muted) return;
const ctx = ensureResumed();
const now = ctx.currentTime;
[523.25, 659.25, 783.99].forEach((freq, i) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'triangle';
osc.frequency.value = freq;
const start = now + i * 0.12;
gain.gain.setValueAtTime(0, start);
gain.gain.linearRampToValueAtTime(MASTER_VOLUME * 0.8, start + 0.04);
gain.gain.exponentialRampToValueAtTime(0.001, start + 0.5);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(start);
osc.stop(start + 0.5);
});
}
/**
* Play a subtle click — button press / UI interaction.
*/
function playClick() {
if (muted) return;
const ctx = ensureResumed();
const now = ctx.currentTime;
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.value = 1000;
gain.gain.setValueAtTime(MASTER_VOLUME * 0.3, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.05);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(now);
osc.stop(now + 0.05);
}
export const SoundManager = {
playStrokeCorrect,
playStrokeError,
playDrawingComplete,
playSessionComplete,
playClick,
get isMuted() {
return muted;
},
setMuted(val) {
muted = val;
localStorage.setItem('zen_sound_muted', val ? '1' : '0');
},
init() {
muted = localStorage.getItem('zen_sound_muted') === '1';
},
};
+22 -15
View File
@@ -13,14 +13,17 @@
clearable
)
.text-center.mt-10(v-if="loading") {{ $t('collection.loading') }}
.text-center.mt-10(v-if="loading")
ScrambleText(:text="$t('collection.loading')")
.text-center.mt-10.text-grey-lighten-1(
v-else-if="!loading && Object.keys(groupedItems).length === 0"
)
v-icon.mb-4(size="64" color="grey-darken-2") mdi-text-search-variant
.text-h6 {{ $t('collection.noMatches') }}
.text-body-2 {{ $t('collection.tryDifferent') }}
.text-h6
ScrambleText(:text="$t('collection.noMatches')")
.text-body-2
ScrambleText(:text="$t('collection.tryDifferent')")
.mb-6.fade-slide-up(
v-else
@@ -28,7 +31,7 @@
:key="level"
)
.text-subtitle-2.text-grey.mb-2.font-weight-bold.ml-1
| {{ $t('collection.levelHeader') }} {{ level }}
ScrambleText(:text="$t('collection.levelHeader') + ' ' + level")
.kanji-grid
.kanji-card(
@@ -76,7 +79,8 @@
)
v-card.pa-4.pt-6.text-center.rounded-xl.border-subtle(color="#1e1e24")
.d-flex.justify-space-between.align-center.px-2.mb-2
.text-caption.text-grey {{ $t('review.level') }} {{ selectedItem?.level }}
.text-caption.text-grey
ScrambleText(:text="$t('review.level') + ' ' + selectedItem?.level")
v-chip.font-weight-bold(
size="x-small"
:color="getSRSColor(selectedItem?.srsLevel)"
@@ -94,7 +98,7 @@
.radical-section.mb-4(v-if="selectedItem?.radicals?.length")
.text-caption.text-grey-darken-1.text-uppercase.mb-3.font-weight-bold
|{{ $t('lesson.components') }}
ScrambleText(:text="$t('lesson.components')")
.d-flex.flex-wrap.gap-3.justify-center
v-sheet.radical-chip(
v-for="rad in selectedItem.radicals" :key="rad.meaning")
@@ -106,20 +110,24 @@
.readings-container.mb-4
.reading-group(v-if="hasReading(selectedItem?.onyomi)")
.reading-label {{ $t('collection.onyomi') }}
.reading-label
ScrambleText(:text="$t('collection.onyomi')")
.reading-value {{ selectedItem?.onyomi.join(', ') }}
.reading-group(v-if="hasReading(selectedItem?.kunyomi)")
.reading-label {{ $t('collection.kunyomi') }}
.reading-label
ScrambleText(:text="$t('collection.kunyomi')")
.reading-value {{ selectedItem?.kunyomi.join(', ') }}
.reading-group(v-if="hasReading(selectedItem?.nanori)")
.reading-label {{ $t('collection.nanori') }}
.reading-label
ScrambleText(:text="$t('collection.nanori')")
.reading-value {{ selectedItem?.nanori.join(', ') }}
.px-2.mb-4(v-if="selectedItem?.stats?.total > 0")
.d-flex.justify-space-between.align-center.mb-1
.text-caption.text-grey {{ $t('stats.accuracy') }}
.text-caption.text-grey
ScrambleText(:text="$t('stats.accuracy')")
.text-caption.font-weight-bold {{ accuracyStats.text }}
v-progress-linear(
:model-value="accuracyStats.percent"
@@ -134,16 +142,15 @@
block
color="#2f3542"
@click="showModal = false"
) {{ $t('collection.close') }}
)
ScrambleText(:text="$t('collection.close')")
v-col.pl-2(cols="6")
v-btn.text-black(
block
color="purple-accent-2"
@click="redoLesson"
)
| {{ selectedItem?.srsLevel === 0
| ? $t('collection.startLesson')
| : $t('collection.redoLesson') }}
ScrambleText(:text="selectedItem?.srsLevel === 0 ? $t('collection.startLesson') : $t('collection.redoLesson')")
</template>
<script setup>
@@ -264,6 +271,6 @@ const getNextReviewText = (dateStr, srsLvl) => {
if (diffHrs < 24) return t('stats.inHours', { n: diffHrs }, diffHrs);
const diffDays = Math.floor(diffHrs / 24);
return `Next: ${diffDays}d`;
return t('stats.inDays', { n: diffDays }, diffDays);
};
</script>
+130 -36
View File
@@ -2,7 +2,8 @@
v-container.page-container-center.fill-height
.d-flex.flex-column.justify-center.align-center.fill-height(v-if="loading")
v-progress-circular(indeterminate color="primary" size="64")
.text-body-1.text-grey.mt-4 {{ $t('review.loading') }}
.text-body-1.text-grey.mt-4
ScrambleText(:text="$t('review.loading')")
v-card.lesson-card(
v-else-if="currentItem"
@@ -13,23 +14,39 @@
.text-caption.text-grey-darken-1.font-weight-bold
| {{ sessionDone + 1 }} / {{ sessionTotal }}
v-spacer
v-chip.font-weight-bold(
size="small"
:color="phaseColor"
variant="tonal"
) {{ phaseLabel }}
.stepper-spacer
.lesson-stepper.px-4.py-3
template(v-for="(step, i) in stepperSteps" :key="step.key")
.lesson-stepper__step(:class="{ 'is-active': step.active, 'is-done': step.done }")
.lesson-stepper__dot
v-icon(v-if="step.done" size="14" color="white") mdi-check
span(v-else) {{ i + 1 }}
.lesson-stepper__label {{ step.label }}
.lesson-stepper__line(
v-if="i < stepperSteps.length - 1"
:class="{ 'is-done': stepperSteps[i + 1]?.done || stepperSteps[i + 1]?.active }"
)
v-window.flex-grow-1(v-model="phase")
v-window-item(value="primer")
.d-flex.flex-column.align-center.pa-6
.hero-wrapper.mb-6
.hero-wrapper.mb-4
KanjiSvgViewer(:char="currentItem.char" mode="hero")
.text-h4.font-weight-bold.text-white.mb-2 {{ currentItem.meaning }}
.text-body-1.text-grey.text-center.mb-8(
v-if="currentItem.mnemonic") "{{ currentItem.mnemonic }}"
.radical-section(v-if="currentItem.radicals?.length")
.text-caption.text-grey.text-uppercase.tracking-wide.mb-1
ScrambleText(:text="$t('lesson.sectionMeaning')")
.text-h4.font-weight-bold.text-white.mb-4 {{ currentItem.meaning }}
.mnemonic-box.pa-4.rounded-lg.mb-5(v-if="currentItem.mnemonic")
.text-caption.text-grey.text-uppercase.tracking-wide.mb-2.font-weight-bold
v-icon.mr-1(size="14" color="amber") mdi-lightbulb-outline
ScrambleText(:text="$t('lesson.sectionMnemonic')")
.text-body-2.text-grey-lighten-1.font-italic "{{ currentItem.mnemonic }}"
.radical-section.mb-5(v-if="currentItem.radicals?.length")
.text-caption.text-grey-darken-1.text-uppercase.mb-3.font-weight-bold
|{{ $t('lesson.components') }}
ScrambleText(:text="$t('lesson.components')")
.d-flex.flex-wrap.gap-3.justify-center
v-sheet.radical-chip(
v-for="rad in currentItem.radicals" :key="rad.meaning")
@@ -38,25 +55,50 @@
img(v-if="rad.image" :src="rad.image")
span.text-h6.font-weight-bold.text-white(v-else) {{ rad.char }}
.text-caption.text-grey-lighten-1 {{ rad.meaning }}
.text-caption.text-grey.text-uppercase.tracking-wide.mb-3.font-weight-bold.text-center(
v-if="(currentItem.onyomi && currentItem.onyomi.length > 0) || (currentItem.kunyomi && currentItem.kunyomi.length > 0)"
)
ScrambleText(:text="$t('lesson.sectionReadings')")
.readings-grid(v-if="(currentItem.onyomi && currentItem.onyomi.length > 0) || (currentItem.kunyomi && currentItem.kunyomi.length > 0)")
.reading-box(v-if="currentItem.onyomi && currentItem.onyomi.length > 0")
.label {{ $t('collection.onyomi') }}
.label
ScrambleText(:text="$t('collection.onyomi')")
.val.text-cyan-lighten-3 {{ currentItem.onyomi.join(', ') }}
.reading-box(v-if="currentItem.kunyomi && currentItem.kunyomi.length > 0")
.label {{ $t('collection.kunyomi') }}
.label
ScrambleText(:text="$t('collection.kunyomi')")
.val.text-pink-lighten-3 {{ currentItem.kunyomi.join(', ') }}
v-window-item(value="demo")
.d-flex.flex-column.align-center.justify-center.h-100.pa-6
.text-subtitle-1.text-grey.mb-6 {{ $t('lesson.observe') }}
.text-subtitle-1.text-grey.mb-6
ScrambleText(:text="$t('lesson.observe')")
.canvas-wrapper.lesson-canvas-wrapper
KanjiSvgViewer(ref="demoViewer" :char="currentItem.char" mode="animate")
v-btn.mt-4(
variant="text"
color="cyan"
prepend-icon="mdi-replay"
size="small"
@click="replayDemo"
)
ScrambleText(:text="$t('lesson.replay')")
v-window-item(value="drawing")
.d-flex.flex-column.align-center.justify-center.h-100.pa-6
.text-subtitle-1.text-grey.mb-6
span(v-if="subPhase === 'guided'") {{ $t('lesson.trace') }}
span(v-else) Draw ({{ practiceCount }}/3)
.text-subtitle-1.text-grey.mb-2
span(v-if="subPhase === 'guided'")
ScrambleText(:text="$t('lesson.trace')")
span(v-else)
ScrambleText(:text="$t('lesson.drawStep', { n: Math.min(practiceCount + 1, practiceTarget), total: practiceTarget })")
.practice-dots.mb-4(v-if="subPhase === 'practice'")
.practice-dot(
v-for="i in practiceTarget"
:key="i"
:class="{ 'is-filled': i <= practiceCount }"
)
.transition-opacity(
:style="{ opacity: canvasOpacity }"
@@ -76,31 +118,51 @@
v-if="phase === 'primer'"
@click="phase = 'demo'"
color="cyan" rounded="pill" size="large" block
) {{ $t('lesson.understand') }}
)
ScrambleText(:text="$t('lesson.understand')")
v-btn.action-btn(
v-if="phase === 'demo'"
@click="startDrawing"
color="cyan" rounded="pill" size="large" block
) {{ $t('lesson.ready') }}
)
ScrambleText(:text="$t('lesson.ready')")
.d-flex.justify-center.gap-4.mt-2(v-if="phase === 'drawing' && subPhase === 'practice'")
v-btn(
variant="text" color="amber" @click="useHint"
) {{ $t('lesson.hintAction') }}
v-btn(variant="text" color="grey" @click="phase='demo'") {{ $t('lesson.watchAgain') }}
)
ScrambleText(:text="$t('lesson.hintAction')")
v-btn(variant="text" color="grey" @click="phase='demo'")
ScrambleText(:text="$t('lesson.watchAgain')")
v-card.text-center.pa-8.lesson-card(
v-card.pa-8.lesson-card(
v-else
)
v-icon.mb-6(size="80" color="purple-accent-2") mdi-check-decagram
.text-h4.font-weight-bold.text-white.mb-2 {{ $t('lesson.completeTitle') }}
.text-body-1.text-grey.mb-8 {{ $t('lesson.learned', { n: sessionDone }) }}
.d-flex.flex-column.align-center.mb-6
v-icon.mb-4(size="80" color="purple-accent-2") mdi-check-decagram
.text-h4.font-weight-bold.text-white.mb-2
ScrambleText(:text="$t('lesson.completeTitle')")
.text-body-1.text-grey.mb-2
ScrambleText(:text="$t('lesson.learned', { n: sessionDone })")
.text-caption.text-grey.text-uppercase.tracking-wide.mb-3.text-left.w-100.font-weight-bold
ScrambleText(:text="$t('lesson.learnedKanji')")
.lesson-summary-grid.w-100.mb-6
.lesson-summary-item(
v-for="item in learnedItems"
:key="item.char"
)
.lesson-summary-char {{ item.char }}
.lesson-summary-meaning.text-grey {{ item.meaning }}
v-btn.glow-btn.text-black.font-weight-bold(
to="/"
block
color="cyan"
height="50"
rounded="pill"
) {{ $t('lesson.backToDashboard') }}
)
ScrambleText(:text="$t('lesson.backToDashboard')")
</template>
@@ -115,6 +177,7 @@ import { useRoute } from 'vue-router';
import { useAppStore } from '@/stores/appStore';
import KanjiCanvas from '@/components/kanji/KanjiCanvas.vue';
import KanjiSvgViewer from '@/components/kanji/KanjiSvgViewer.vue';
import { SoundManager } from '@/utils/SoundManager';
const { t } = useI18n();
const store = useAppStore();
@@ -123,29 +186,49 @@ const currentItem = ref(null);
const phase = ref('primer');
const subPhase = ref('guided');
const practiceCount = ref(0);
const practiceTarget = 2;
const sessionDone = ref(0);
const sessionTotal = ref(0);
const canvasOpacity = ref(1);
const loading = ref(true);
const learnedItems = ref([]);
const canvas = ref(null);
const demoViewer = ref(null);
const phaseLabel = computed(() => {
if (phase.value === 'primer') return t('lesson.phasePrimer');
if (phase.value === 'demo') return t('lesson.phaseDemo');
return subPhase.value === 'guided' ? t('lesson.phaseGuided') : t('lesson.phasePractice');
const phaseIndex = computed(() => {
if (phase.value === 'primer') return 0;
if (phase.value === 'demo') return 1;
if (phase.value === 'drawing' && subPhase.value === 'guided') return 2;
return 3;
});
const phaseColor = computed(() => (phase.value === 'primer' ? 'blue' : 'purple'));
const stepperSteps = computed(() => {
const steps = [
{ key: 'primer', label: t('lesson.phasePrimer') },
{ key: 'demo', label: t('lesson.phaseDemo') },
{ key: 'guided', label: t('lesson.phaseGuided') },
{ key: 'practice', label: t('lesson.phasePractice') },
];
return steps.map((s, i) => ({
...s,
active: i === phaseIndex.value,
done: i < phaseIndex.value,
}));
});
watch(phase, (val) => {
if (val === 'demo') nextTick(() => demoViewer.value?.playAnimation());
});
function replayDemo() {
demoViewer.value?.playAnimation();
}
function loadNext() {
if (store.lessonQueue.length === 0) {
currentItem.value = null;
if (sessionDone.value > 0) SoundManager.playSessionComplete();
return;
}
[currentItem.value] = store.lessonQueue;
@@ -175,7 +258,7 @@ async function handleDrawComplete() {
} else {
practiceCount.value += 1;
if (practiceCount.value < 3) {
if (practiceCount.value < practiceTarget) {
canvasOpacity.value = 0;
await new Promise((r) => { setTimeout(r, 300); });
canvas.value?.reset();
@@ -184,9 +267,18 @@ async function handleDrawComplete() {
const isRedo = !!route.query.subjectId;
if (!isRedo) {
await store.submitLesson(currentItem.value.wkSubjectId);
try {
await store.submitLesson(currentItem.value.wkSubjectId);
} catch (e) {
console.error('Failed to submit lesson:', e);
}
}
learnedItems.value.push({
char: currentItem.value.char,
meaning: currentItem.value.meaning,
});
sessionDone.value += 1;
store.lessonQueue.shift();
loadNext();
@@ -195,13 +287,15 @@ async function handleDrawComplete() {
}
function handleMistake() {
if (subPhase.value === 'practice') practiceCount.value = 0;
if (subPhase.value === 'practice') {
practiceCount.value = Math.max(0, practiceCount.value - 1);
}
}
function useHint() {
canvas.value?.showHint();
if (subPhase.value === 'practice') {
practiceCount.value = 0;
practiceCount.value = Math.max(0, practiceCount.value - 1);
}
}
+179 -42
View File
@@ -3,7 +3,8 @@
v-fade-transition(mode="out-in")
.d-flex.flex-column.justify-center.align-center.fill-height(v-if="loading")
v-progress-circular(indeterminate color="primary" size="64")
.text-body-1.text-grey.mt-4 {{ $t('review.loading') }}
.text-body-1.text-grey.mt-4
ScrambleText(:text="$t('review.loading')")
v-card.pa-6.rounded-xl.d-flex.flex-column.align-center.review-card(
v-else-if="currentItem"
@@ -23,23 +24,27 @@
size="small"
:color="getSRSColor(currentItem.srsLevel)"
variant="flat"
) {{ $t('review.level') }} {{ currentItem.srsLevel }}
)
ScrambleText(:text="$t('review.level') + ' ' + currentItem.srsLevel")
.text-center.mb-6
.text-caption.text-grey.text-uppercase.tracking-wide.mb-1
| {{ $t('review.meaning') }}
ScrambleText(:text="$t('review.meaning')")
.text-h3.font-weight-bold.text-white.text-shadow.mb-4
| {{ currentItem.meaning }}
.readings-grid(v-if="(currentItem.onyomi && currentItem.onyomi.length > 0) || (currentItem.kunyomi && currentItem.kunyomi.length > 0)")
.reading-box.reading-box--small(v-if="currentItem.onyomi && currentItem.onyomi.length > 0")
.label {{ $t('collection.onyomi') }}
.label
ScrambleText(:text="$t('collection.onyomi')")
.val.text-cyan-lighten-3 {{ currentItem.onyomi.join(', ') }}
.reading-box.reading-box--small(v-if="currentItem.kunyomi && currentItem.kunyomi.length > 0")
.label {{ $t('collection.kunyomi') }}
.label
ScrambleText(:text="$t('collection.kunyomi')")
.val.text-pink-lighten-3 {{ currentItem.kunyomi.join(', ') }}
.review-canvas-area
KanjiCanvas(
:key="canvasKey"
ref="kanjiCanvasRef"
:char="currentItem.char"
@complete="handleComplete"
@@ -64,18 +69,30 @@
size="small"
:disabled="showNext"
@click="triggerHint"
) {{ $t('review.showHint') }}
)
ScrambleText(:text="$t('review.showHint')")
v-sheet.d-flex.align-center.justify-center(
v-sheet.d-flex.flex-column.align-center.justify-center(
width="100%"
height="48"
min-height="80"
color="transparent"
)
transition(name="fade-slide" mode="out-in")
.status-text.text-h6.font-weight-bold(
:key="statusCode"
:class="getStatusClass(statusCode)"
) {{ $t('review.' + statusCode) }}
)
ScrambleText(:text="$t('review.' + statusCode)")
transition(name="scale")
v-chip.mt-2.font-weight-bold.elevation-2(
v-if="showNext && rankChange"
:color="rankChange.type === 'up' ? 'teal-accent-4' : 'red-darken-1'"
variant="flat"
size="small"
)
v-icon(start size="14") {{ rankChange.type === 'up' ? 'mdi-arrow-up-thick' : 'mdi-arrow-down-thick' }}
| Level {{ currentItem.srsLevel }} &rarr; {{ rankChange.level }}
v-progress-linear.mt-4.progress-bar(
v-model="progressPercent"
@@ -84,23 +101,62 @@
rounded
)
v-card.pa-8.rounded-xl.elevation-10.text-center.review-card(
v-card.pa-8.rounded-xl.elevation-10.review-card(
v-else-if="sessionTotal > 0 && store.queue.length === 0"
)
.mb-6
.d-flex.flex-column.align-center.mb-6
v-avatar(color="rgba(0, 206, 201, 0.1)" size="80")
v-icon(size="40" color="primary") mdi-trophy
.text-h4.font-weight-bold.mb-2 {{ $t('review.sessionComplete') }}
.text-body-1.text-grey.mb-8 {{ $t('review.levelup') }}
.text-h4.font-weight-bold.mt-4.mb-2
ScrambleText(:text="$t('review.sessionComplete')")
v-row.mb-6
v-col.border-r.border-subtle(cols="6")
v-row.mb-4
v-col.text-center.border-r.border-subtle(cols="6")
.text-h3.font-weight-bold.text-white {{ accuracy }}%
.text-caption.text-grey.text-uppercase.mt-1 {{ $t('stats.accuracy') }}
v-col(cols="6")
.text-caption.text-grey.text-uppercase.mt-1
ScrambleText(:text="$t('stats.accuracy')")
v-col.text-center(cols="6")
.text-h3.font-weight-bold.text-white {{ sessionCorrect }}
.text-caption.text-grey.text-uppercase.mt-1 {{ $t('stats.correct') }}
.text-caption.text-grey.text-uppercase.mt-1
ScrambleText(:text="$t('stats.correct')")
.d-flex.justify-center.ga-4.mb-6(v-if="levelUpCount > 0 || levelDownCount > 0")
v-chip.font-weight-bold(
v-if="levelUpCount > 0"
color="teal-accent-4"
variant="tonal"
size="small"
prepend-icon="mdi-arrow-up-thick"
)
ScrambleText(:text="$t('review.levelsUp', { n: levelUpCount })")
v-chip.font-weight-bold(
v-if="levelDownCount > 0"
color="red-darken-1"
variant="tonal"
size="small"
prepend-icon="mdi-arrow-down-thick"
)
ScrambleText(:text="$t('review.levelsDown', { n: levelDownCount })")
.text-caption.text-grey.text-uppercase.tracking-wide.mb-3.text-left.w-100
ScrambleText(:text="$t('review.results')")
.result-list.w-100.mb-6
.result-item(
v-for="(r, i) in sessionResults"
:key="i"
)
.result-char {{ r.char }}
.result-meaning.text-grey {{ r.meaning }}
v-spacer
v-chip.font-weight-bold.mr-2(
:color="r.success ? 'teal-accent-4' : 'red-darken-1'"
variant="tonal"
size="x-small"
)
v-icon(start size="12") {{ r.success ? 'mdi-check' : 'mdi-close' }}
| {{ r.oldLevel }} &rarr; {{ r.newLevel }}
v-btn.text-black.font-weight-bold.glow-btn(
to="/"
@@ -108,17 +164,21 @@
color="primary"
height="50"
rounded="lg"
) {{ $t('review.back') }}
)
ScrambleText(:text="$t('review.back')")
.text-center(v-else)
v-icon.mb-4(size="64" color="grey-darken-2") mdi-check-circle-outline
.text-h4.mb-2.text-white {{ $t('review.caughtUp') }}
.text-grey.mb-6 {{ $t('review.noReviews') }}
.text-h4.mb-2.text-white
ScrambleText(:text="$t('review.caughtUp')")
.text-grey.mb-6
ScrambleText(:text="$t('review.noReviews')")
v-btn.font-weight-bold(
to="/"
color="primary"
variant="tonal"
) {{ $t('review.viewCollection') }}
)
ScrambleText(:text="$t('review.viewCollection')")
</template>
<script setup>
@@ -126,13 +186,13 @@
import {
ref, onMounted, computed, watch,
} from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useRoute } from 'vue-router';
import { useAppStore } from '@/stores/appStore';
import KanjiCanvas from '@/components/kanji/KanjiCanvas.vue';
import { SoundManager } from '@/utils/SoundManager';
const store = useAppStore();
const route = useRoute();
const router = useRouter();
const currentItem = ref(null);
const statusCode = ref('draw');
@@ -144,6 +204,7 @@ const loading = ref(true);
const sessionTotal = ref(0);
const sessionDone = ref(0);
const sessionCorrect = ref(0);
const sessionResults = ref([]);
const accuracy = computed(() => {
if (sessionDone.value === 0) return 100;
@@ -155,19 +216,49 @@ const progressPercent = computed(() => {
return (sessionDone.value / sessionTotal.value) * 100;
});
const levelUpCount = computed(() => sessionResults.value.filter((r) => r.success).length);
const levelDownCount = computed(() => sessionResults.value.filter((r) => !r.success).length);
const submittedItems = ref(new Set());
const canvasKey = ref(0);
function loadNext() {
if (store.queue.length === 0) {
currentItem.value = null;
if (sessionTotal.value > 0) SoundManager.playSessionComplete();
return;
}
const idx = Math.floor(Math.random() * store.queue.length);
let idx = Math.floor(Math.random() * store.queue.length);
if (store.queue.length > 1
&& currentItem.value
&& store.queue[idx]._id === currentItem.value._id
) idx = (idx + 1) % store.queue.length;
currentItem.value = store.queue[idx];
statusCode.value = 'draw';
showNext.value = false;
isFailure.value = false;
canvasKey.value += 1;
}
const rankChange = computed(() => {
if (!currentItem.value || !showNext.value) return null;
if (submittedItems.value.has(currentItem.value._id)) return null;
const current = currentItem.value.srsLevel;
if (!isFailure.value) {
if (current >= 10) return null;
return { type: 'up', level: current + 1 };
}
if (current <= 1) return null;
return { type: 'down', level: current - 1 };
});
onMounted(async () => {
loading.value = true;
const mode = route.query.mode || 'shuffled';
@@ -185,6 +276,8 @@ async function resetSession() {
sessionDone.value = 0;
sessionCorrect.value = 0;
sessionTotal.value = 0;
sessionResults.value = [];
submittedItems.value.clear();
currentItem.value = null;
const mode = route.query.mode || 'shuffled';
await store.fetchQueue(mode);
@@ -223,31 +316,42 @@ function handleComplete() {
async function next() {
if (!currentItem.value) return;
await store.submitReview(currentItem.value.wkSubjectId, !isFailure.value);
const itemId = currentItem.value._id;
const isFirstAttempt = !submittedItems.value.has(itemId);
sessionDone.value += 1;
if (!isFailure.value) sessionCorrect.value += 1;
// eslint-disable-next-line no-underscore-dangle
const index = store.queue.findIndex((i) => i._id === currentItem.value._id);
if (index !== -1) {
store.queue.splice(index, 1);
if (isFirstAttempt) {
const oldLevel = currentItem.value.srsLevel;
const result = await store.submitReview(currentItem.value.wkSubjectId, !isFailure.value);
submittedItems.value.add(itemId);
const newLevel = result?.srsLevel ?? oldLevel;
sessionResults.value.push({
char: currentItem.value.char,
meaning: currentItem.value.meaning,
success: !isFailure.value,
oldLevel,
newLevel,
});
if (!isFailure.value) {
sessionCorrect.value += 1;
}
}
if (!isFailure.value) {
sessionDone.value += 1;
const index = store.queue.findIndex((i) => i._id === itemId);
if (index !== -1) {
store.queue.splice(index, 1);
}
}
loadNext();
}
function redoLesson() {
if (!currentItem.value) return;
router.push({
path: '/lesson',
query: { subjectId: currentItem.value.wkSubjectId },
state: { item: JSON.parse(JSON.stringify(currentItem.value)) },
});
}
const getSRSColor = (lvl) => {
const colors = {
1: '#ff7675', 2: '#fdcb6e', 3: '#55efc4', 4: '#0984e3', 5: '#a29bfe', 6: '#6c5ce7',
7: '#00cec9', 8: '#fd79a8', 9: '#e84393', 10: '#ffd700',
};
return colors[lvl] || 'grey';
};
@@ -262,7 +366,40 @@ const getStatusClass = (status) => {
</script>
<style lang="scss" scoped>
@use '@/styles/abstracts' as *;
.review-view-container {
min-height: 100%;
}
.result-list {
max-height: 16rem;
overflow-y: auto;
@include scrollbar;
border-radius: $radius-md;
background: $bg-glass-subtle;
}
.result-item {
display: flex;
align-items: center;
padding: $spacing-sm $spacing-md;
border-bottom: $border-width-sm solid $color-border;
&:last-child {
border-bottom: none;
}
}
.result-char {
font-size: $font-lg;
font-weight: $weight-bold;
min-width: 2.5rem;
text-align: center;
}
.result-meaning {
font-size: $font-sm;
margin-left: $spacing-sm;
}
</style>