feat(03-02): lesson screen 3-phase state machine with review and repetition

- Discover phase: companion introduces new key, key pulses, child presses once
- Practice phase: existing letter exercise with cumulative stats
- Words phase: word display with active letter highlighting
- Review mode skips discover, mixes letters/words from recent levels
- Encouragement messages for repetition protection
- Phase indicator UI (Entdecken / Ueben / Woerter)
- CSS for word display, phase indicator, discover pulse animation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-29 13:48:56 +02:00
co-authored by Claude Opus 4.6
parent ab5bab9733
commit 770f566972
3 changed files with 406 additions and 47 deletions
+1
View File
@@ -67,6 +67,7 @@
<section id="screen-lesson" class="screen">
<div class="lesson">
<button class="lesson__back-btn" id="lesson-back-btn">&#x2190; Zurueck</button>
<div class="lesson__phase-indicator" id="lesson-phase-indicator"></div>
<div id="lesson-companion" class="companion-area" style="display:none">
<img id="lesson-companion-avatar" class="companion-area__avatar" src="" alt="">
<p id="lesson-companion-text" class="companion-area__text"></p>
+51
View File
@@ -685,3 +685,54 @@ h3 {
transform: scale(0.9) !important;
animation: none;
}
/* Lesson phase indicator */
.lesson__phase-indicator {
text-align: center;
font-family: var(--font-heading);
font-size: 1rem;
color: var(--text-warm);
margin-bottom: 0.5rem;
opacity: 0.7;
}
/* Word display for Woerter phase */
.word-display {
display: flex;
justify-content: center;
gap: 0.25rem;
font-family: var(--font-heading);
font-size: 2.5rem;
margin: 2rem 0;
}
.word-display__letter {
color: var(--text-light);
transition: color 0.3s;
}
.word-display__letter--active {
color: var(--accent-gold);
font-weight: 700;
}
.word-display__letter--done {
color: var(--accent-green);
}
/* Discover phase key pulse */
.keyboard__key--discover-pulse {
animation: discover-pulse 1.5s ease-in-out infinite;
}
@keyframes discover-pulse {
0%,
100% {
transform: scale(1);
box-shadow: 0 0 0 0 var(--accent-gold);
}
50% {
transform: scale(1.1);
box-shadow: 0 0 12px 4px var(--accent-gold);
}
}
+330 -23
View File
@@ -3,15 +3,22 @@ import type {
KeyboardLayout,
ScreenName,
Progress,
LessonPhase,
TypingExerciseState,
} from "../types";
import { companions } from "../companion/characters";
import { saveProgress, getProgress } from "../storage/db";
import {
createExerciseState,
createWordExerciseState,
getCurrentWordIndex,
handleKeyPress,
getCurrentLetter,
} from "../game/typing";
import { renderKeyboard, highlightKey, pressKey } from "../game/keyboard";
import { getLevelByNumber } from "../game/levels";
import { getLetterIntro } from "../companion/companion";
import { getWordsForLevel } from "../game/words";
export function initWelcomeScreen(
db: IDBDatabase,
@@ -80,91 +87,391 @@ export function initWelcomeScreen(
});
}
// Finger description for companion letter intros
const fingerDescriptions: Record<string, string> = {
f: "linker Zeigefinger",
j: "rechter Zeigefinger",
d: "linker Mittelfinger",
k: "rechter Mittelfinger",
s: "linker Ringfinger",
l: "rechter Ringfinger",
a: "linker kleiner Finger",
ö: "rechter kleiner Finger",
g: "linker Zeigefinger (Streckung)",
h: "rechter Zeigefinger (Streckung)",
e: "linker Mittelfinger (obere Reihe)",
i: "rechter Mittelfinger (obere Reihe)",
" ": "Daumen",
};
// Encouragement messages for repetition protection
const encouragementMessages = [
"Magst du sehen, was als Naechstes kommt? Im Wald gibt es noch so viel zu entdecken!",
"Der Wald wartet auf dich! Jedes Mal, wenn du uebst, waechst etwas Neues.",
"Schau mal, was sich im Wald versteckt! Probier es einfach noch einmal.",
"Dein Wald wird immer schoener. Komm, wir entdecken zusammen weiter!",
];
export function initLessonScreen(
_db: IDBDatabase,
db: IDBDatabase,
level: number,
layout: KeyboardLayout,
onComplete: (level: number) => void,
onComplete: (
level: number,
stats: {
totalCorrect: number;
totalErrors: number;
errorKeys: Record<string, number>;
},
) => void,
onCancel?: () => void,
isReview = false,
showEncouragement = false,
): () => void {
const letterArea = document.getElementById("letter-area")!;
const keyboardContainer = document.getElementById("lesson-keyboard")!;
const backBtn = document.getElementById("lesson-back-btn") as HTMLButtonElement;
const state = createExerciseState(level);
const backBtn = document.getElementById(
"lesson-back-btn",
) as HTMLButtonElement;
const phaseIndicator = document.getElementById("lesson-phase-indicator")!;
const companionArea = document.getElementById("lesson-companion")!;
const companionText = document.getElementById("lesson-companion-text")!;
// Show companion avatar (per ASST-04)
getProgress(_db).then((progress) => {
// Cumulative stats across all phases
let cumulativeCorrect = 0;
let cumulativeErrors = 0;
const errorKeys: Record<string, number> = {};
// Active keydown handler reference for cleanup
let activeKeyDown: ((e: KeyboardEvent) => void) | null = null;
let cancelled = false;
function cleanup(): void {
cancelled = true;
if (activeKeyDown) {
document.removeEventListener("keydown", activeKeyDown);
activeKeyDown = null;
}
letterArea.innerHTML = "";
}
function setPhase(phase: LessonPhase): void {
const labels: Record<LessonPhase, string> = {
discover: "Entdecken",
practice: "Ueben",
words: "Woerter",
};
phaseIndicator.textContent = labels[phase];
}
// Show companion avatar
let characterType: CompanionType = "fee";
const progressPromise = getProgress(db).then((progress) => {
if (!progress) return;
characterType = progress.selectedCharacter;
const companion = companions.find(
(c) => c.type === progress.selectedCharacter,
);
if (!companion) return;
const area = document.getElementById("lesson-companion")!;
const avatar = document.getElementById(
"lesson-companion-avatar",
) as HTMLImageElement;
avatar.src = companion.avatarUrl;
avatar.alt = companion.name;
area.style.display = "flex";
companionArea.style.display = "flex";
});
// Render keyboard
renderKeyboard(keyboardContainer, layout, level);
// Show current letter
// --- Helper: run a letter exercise phase (practice or fallback) ---
function runLetterExercise(
state: TypingExerciseState,
onDone: () => void,
): void {
function showCurrentLetter(): void {
const letter = getCurrentLetter(state);
if (!letter) return;
letterArea.innerHTML = "";
const letterEl = document.createElement("div");
letterEl.className = "falling-letter";
letterEl.textContent = letter === " " ? "\u2423" : letter.toUpperCase();
letterEl.textContent =
letter === " " ? "\u2423" : letter.toUpperCase();
letterArea.appendChild(letterEl);
highlightKey(letter);
}
showCurrentLetter();
// Keyboard handler
function onKeyDown(e: KeyboardEvent): void {
// Ignore modifier keys, function keys, etc.
if (e.key.length > 1 && e.key !== " ") return;
e.preventDefault();
const expectedKey = getCurrentLetter(state);
const result = handleKeyPress(state, e.key);
pressKey(e.key);
if (result.correct) {
// Correct: dissolve letter with animation
cumulativeCorrect++;
const currentEl = letterArea.querySelector(".falling-letter");
if (currentEl) {
currentEl.classList.add("falling-letter--dissolve");
}
if (result.exerciseComplete) {
// Lesson complete
document.removeEventListener("keydown", onKeyDown);
onComplete(level);
activeKeyDown = null;
onDone();
} else {
// Show next letter after brief delay
setTimeout(() => showCurrentLetter(), 300);
}
} else {
cumulativeErrors++;
if (expectedKey) {
errorKeys[expectedKey] = (errorKeys[expectedKey] || 0) + 1;
}
}
// Wrong: do nothing to letter (per D-03), correct key already highlighted
}
if (activeKeyDown) {
document.removeEventListener("keydown", activeKeyDown);
}
activeKeyDown = onKeyDown;
document.addEventListener("keydown", onKeyDown);
}
// --- Helper: run word exercise phase ---
function runWordExercise(
state: TypingExerciseState,
onDone: () => void,
): void {
function renderWordDisplay(): void {
letterArea.innerHTML = "";
if (!state.words || !state.wordBoundaries) return;
const wordIdx = getCurrentWordIndex(state);
const wordStart = state.wordBoundaries[wordIdx]!;
const word = state.words[wordIdx]!;
const container = document.createElement("div");
container.className = "word-display";
for (let i = 0; i < word.length; i++) {
const span = document.createElement("span");
span.className = "word-display__letter";
const globalIdx = wordStart + i;
if (globalIdx < state.currentLetterIndex) {
span.classList.add("word-display__letter--done");
} else if (globalIdx === state.currentLetterIndex) {
span.classList.add("word-display__letter--active");
}
span.textContent = word[i]!.toUpperCase();
container.appendChild(span);
}
letterArea.appendChild(container);
// Highlight expected key on keyboard
const currentLetter = getCurrentLetter(state);
if (currentLetter) highlightKey(currentLetter);
}
renderWordDisplay();
function onKeyDown(e: KeyboardEvent): void {
if (e.key.length > 1 && e.key !== " ") return;
e.preventDefault();
const expectedKey = getCurrentLetter(state);
const result = handleKeyPress(state, e.key);
pressKey(e.key);
if (result.correct) {
cumulativeCorrect++;
if (result.exerciseComplete) {
document.removeEventListener("keydown", onKeyDown);
activeKeyDown = null;
onDone();
} else {
renderWordDisplay();
}
} else {
cumulativeErrors++;
if (expectedKey) {
errorKeys[expectedKey] = (errorKeys[expectedKey] || 0) + 1;
}
}
}
if (activeKeyDown) {
document.removeEventListener("keydown", activeKeyDown);
}
activeKeyDown = onKeyDown;
document.addEventListener("keydown", onKeyDown);
}
// --- Phase flow ---
async function startFlow(): Promise<void> {
await progressPromise;
if (cancelled) return;
// Encouragement message before starting (repetition protection)
if (showEncouragement) {
companionText.textContent =
encouragementMessages[
Math.floor(Math.random() * encouragementMessages.length)
]!;
companionArea.style.display = "flex";
await new Promise<void>((resolve) => setTimeout(resolve, 3000));
if (cancelled) return;
}
// PHASE 1: DISCOVER (skip for reviews)
if (!isReview) {
await runDiscoverPhase();
if (cancelled) return;
}
// PHASE 2: PRACTICE
await runPracticePhase();
if (cancelled) return;
// PHASE 3: WORDS
await runWordsPhase();
if (cancelled) return;
// All phases complete
onComplete(level, {
totalCorrect: cumulativeCorrect,
totalErrors: cumulativeErrors,
errorKeys,
});
}
async function runDiscoverPhase(): Promise<void> {
setPhase("discover");
const levelDef = getLevelByNumber(level);
if (!levelDef) return;
for (const key of levelDef.newKeys) {
if (cancelled) return;
if (key === " ") continue; // Skip space in discover
const fingerDesc = fingerDescriptions[key] || "ein Finger";
const introText = await getLetterIntro(
characterType,
key.toUpperCase(),
fingerDesc,
db,
);
if (cancelled) return;
// Show companion intro text
companionText.textContent = introText;
companionArea.style.display = "flex";
// Show the letter
letterArea.innerHTML = "";
const letterEl = document.createElement("div");
letterEl.className = "falling-letter";
letterEl.textContent = key.toUpperCase();
letterArea.appendChild(letterEl);
// Highlight and pulse the target key
highlightKey(key);
const keyEl = document.querySelector(
`[data-key="${key}"]`,
) as HTMLElement | null;
if (keyEl) {
keyEl.classList.add("keyboard__key--discover-pulse");
}
// Wait for child to press the correct key once
await new Promise<void>((resolve) => {
function onKeyDown(e: KeyboardEvent): void {
if (e.key.length > 1 && e.key !== " ") return;
e.preventDefault();
pressKey(e.key);
if (e.key.toLowerCase() === key.toLowerCase()) {
document.removeEventListener("keydown", onKeyDown);
activeKeyDown = null;
if (keyEl) {
keyEl.classList.remove(
"keyboard__key--discover-pulse",
);
}
resolve();
}
}
if (activeKeyDown) {
document.removeEventListener("keydown", activeKeyDown);
}
activeKeyDown = onKeyDown;
document.addEventListener("keydown", onKeyDown);
});
}
// Clear discover UI
letterArea.innerHTML = "";
companionText.textContent = "";
}
function runPracticePhase(): Promise<void> {
setPhase("practice");
const state = createExerciseState(level);
return new Promise<void>((resolve) => {
runLetterExercise(state, resolve);
});
}
function runWordsPhase(): Promise<void> {
setPhase("words");
const wordCount = 4;
let words: string[];
if (isReview) {
// Mix words from current and previous 1-2 levels
const levelsToMix = [level];
if (level > 1) levelsToMix.push(level - 1);
if (level > 2) levelsToMix.push(level - 2);
words = [];
for (const lvl of levelsToMix) {
words.push(...getWordsForLevel(lvl, 2));
}
// Shuffle and take wordCount
for (let i = words.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[words[i], words[j]] = [words[j]!, words[i]!];
}
words = words.slice(0, wordCount);
} else {
words = getWordsForLevel(level, wordCount);
if (words.length < 3) {
words = getWordsForLevel(level, 3);
}
}
if (words.length === 0) {
// Fallback: skip words phase if no words available
return Promise.resolve();
}
const state = createWordExerciseState(words);
return new Promise<void>((resolve) => {
runWordExercise(state, resolve);
});
}
// Start the flow
startFlow();
// Back button to cancel lesson
backBtn.onclick = () => {
document.removeEventListener("keydown", onKeyDown);
letterArea.innerHTML = "";
cleanup();
if (onCancel) onCancel();
};
// Return cleanup function
return () => {
document.removeEventListener("keydown", onKeyDown);
letterArea.innerHTML = "";
};
return cleanup;
}