feat: add sound effects, improved stroke recognition, instant locale switching with scramble text animation
- 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:
+179
-42
@@ -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 }} → {{ 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 }} → {{ 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>
|
||||
|
||||
Reference in New Issue
Block a user