diff --git a/src/styles/main.css b/src/styles/main.css
index 5116078..4035a8c 100644
--- a/src/styles/main.css
+++ b/src/styles/main.css
@@ -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);
+ }
+}
diff --git a/src/ui/screens.ts b/src/ui/screens.ts
index a6ab5ed..b580ffd 100644
--- a/src/ui/screens.ts
+++ b/src/ui/screens.ts
@@ -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
= {
+ 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;
+ },
+ ) => 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 = {};
+
+ // 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 = {
+ 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
- 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();
- letterArea.appendChild(letterEl);
- highlightKey(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();
+ letterArea.appendChild(letterEl);
+ highlightKey(letter);
+ }
- showCurrentLetter();
+ showCurrentLetter();
- // Keyboard handler
- function onKeyDown(e: KeyboardEvent): void {
- // Ignore modifier keys, function keys, etc.
- if (e.key.length > 1 && e.key !== " ") return;
- e.preventDefault();
+ function onKeyDown(e: KeyboardEvent): void {
+ if (e.key.length > 1 && e.key !== " ") return;
+ e.preventDefault();
- const result = handleKeyPress(state, e.key);
- pressKey(e.key);
+ const expectedKey = getCurrentLetter(state);
+ const result = handleKeyPress(state, e.key);
+ pressKey(e.key);
- if (result.correct) {
- // Correct: dissolve letter with animation
- const currentEl = letterArea.querySelector(".falling-letter");
- if (currentEl) {
- currentEl.classList.add("falling-letter--dissolve");
- }
+ if (result.correct) {
+ 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);
+ if (result.exerciseComplete) {
+ document.removeEventListener("keydown", onKeyDown);
+ activeKeyDown = null;
+ onDone();
+ } else {
+ setTimeout(() => showCurrentLetter(), 300);
+ }
} else {
- // Show next letter after brief delay
- setTimeout(() => showCurrentLetter(), 300);
+ 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);
}
- 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 {
+ 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((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 {
+ 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((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 {
+ setPhase("practice");
+ const state = createExerciseState(level);
+ return new Promise((resolve) => {
+ runLetterExercise(state, resolve);
+ });
+ }
+
+ function runWordsPhase(): Promise {
+ 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((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;
}