Files

448 lines
16 KiB
Markdown

---
phase: 01-grundger-st-tippmechanik
plan: 04
type: tdd
wave: 3
depends_on: ["01-02", "01-03"]
files_modified:
- src/game/typing.ts
- src/game/typing.test.ts
- src/styles/main.css
autonomous: true
requirements:
- TYPE-01
- TYPE-02
- TYPE-03
- TYPE-04
- LEVL-02
- LEVL-03
must_haves:
truths:
- "Buchstaben erscheinen einzeln auf dem Bildschirm"
- "Richtiger Tastendruck loest den Buchstaben auf"
- "Falscher Tastendruck tut nichts, richtige Taste blinkt auf Tastatur"
- "Tempo passt sich an: schneller nach 2x richtig, langsamer nach Fehler"
- "Nach 8-12 richtigen Buchstaben ist die Uebung abgeschlossen"
- "Abgeschlossene Stufe schaltet naechste frei"
artifacts:
- path: "src/game/typing.ts"
provides: "Core typing exercise engine"
exports: ["createTypingExercise", "TypingExercise"]
- path: "src/game/typing.test.ts"
provides: "TDD tests for typing logic"
min_lines: 50
key_links:
- from: "src/game/typing.ts"
to: "src/game/levels.ts"
via: "gets key set for current level"
pattern: "getKeysUpToLevel|getLevelByNumber"
- from: "src/game/typing.ts"
to: "src/game/keyboard.ts"
via: "highlights target key, shows press animation"
pattern: "highlightKey|pressKey"
- from: "src/game/typing.ts"
to: "src/storage/db.ts"
via: "saves progress after lesson completion"
pattern: "saveProgress"
---
<objective>
Implement the core typing exercise engine using TDD. This is the heart of the app: letters appear, the child types them, adaptive tempo adjusts, and the lesson completes after 8-12 correct keystrokes.
Purpose: The typing exercise is the core gameplay loop. Getting the logic right (adaptive tempo, correct/wrong handling, completion) is critical and benefits from TDD.
Output: Tested typing engine that manages letter generation, input handling, tempo adaptation, and lesson completion.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/phases/01-grundger-st-tippmechanik/01-CONTEXT.md
@.planning/phases/01-grundger-st-tippmechanik/01-01-SUMMARY.md
@.planning/phases/01-grundger-st-tippmechanik/01-03-SUMMARY.md
@SPEC.md (section 3.1 phase 2 "Ueben", section 6.2 adaptive tempo)
<interfaces>
From src/types.ts:
```typescript
export interface TypingExerciseState {
currentLetterIndex: number;
letters: string[];
intervalMs: number;
correctStreak: number;
totalCorrect: number;
totalErrors: number;
}
export interface Progress {
id: 1;
currentLevel: number;
completedLevels: number[];
totalSessions: number;
sessionDates: string[];
selectedCharacter: CompanionType;
selectedLayout: KeyboardLayout;
}
```
From src/game/levels.ts:
```typescript
export function getLevelByNumber(n: number): Level | undefined;
export function getKeysUpToLevel(n: number): string[];
```
From src/game/keyboard.ts:
```typescript
export function highlightKey(key: string): void;
export function pressKey(key: string): void;
```
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Typing exercise logic (RED-GREEN-REFACTOR)</name>
<files>
src/game/typing.ts, src/game/typing.test.ts
</files>
<read_first>
src/types.ts
src/game/levels.ts
src/game/keyboard.ts
SPEC.md (section 3.1 for exercise structure, section 6.2 for adaptive tempo rules)
.planning/phases/01-grundger-st-tippmechanik/01-CONTEXT.md (D-03, D-04, D-08 for exercise scope and tempo rules)
</read_first>
<behavior>
- Test: generateLetters(level=1) returns 8-12 letters, all from ['f', 'j', ' ']
- Test: generateLetters(level=3) returns letters only from levels 1-3 key set
- Test: Initial interval is 3000ms
- Test: After 2 correct in sequence, interval decreases by 200ms (3000 -> 2800)
- Test: After 1 error, interval increases by 500ms (2800 -> 3300)
- Test: Interval never goes below 1500ms
- Test: Interval never goes above 4000ms
- Test: handleKeyPress with correct key increments currentLetterIndex and totalCorrect
- Test: handleKeyPress with wrong key does NOT increment currentLetterIndex, increments totalErrors
- Test: Exercise is complete when currentLetterIndex equals letters.length
- Test: correctStreak resets to 0 on error
- Test: correctStreak increments on correct key
</behavior>
<action>
**RED phase:** Create src/game/typing.test.ts with all tests above. Tests import from typing.ts which does not exist yet. Run tests — they must fail.
**GREEN phase:** Create src/game/typing.ts implementing:
```typescript
import type { TypingExerciseState } from '../types';
import { getKeysUpToLevel } from './levels';
const MIN_LETTERS = 8;
const MAX_LETTERS = 12;
const INITIAL_INTERVAL = 3000;
const INTERVAL_DECREASE = 200;
const INTERVAL_INCREASE = 500;
const MIN_INTERVAL = 1500;
const MAX_INTERVAL = 4000;
const STREAK_THRESHOLD = 2;
export function generateLetters(level: number): string[] {
const keys = getKeysUpToLevel(level);
// Filter out space for letter-only exercise (space can appear but less frequently)
const letterKeys = keys.filter(k => k !== ' ');
const count = MIN_LETTERS + Math.floor(Math.random() * (MAX_LETTERS - MIN_LETTERS + 1));
const letters: string[] = [];
for (let i = 0; i < count; i++) {
// 80% letters, 20% chance of space if space is in key set
if (keys.includes(' ') && Math.random() < 0.2) {
letters.push(' ');
} else {
letters.push(letterKeys[Math.floor(Math.random() * letterKeys.length)]);
}
}
return letters;
}
export function createExerciseState(level: number): TypingExerciseState {
return {
currentLetterIndex: 0,
letters: generateLetters(level),
intervalMs: INITIAL_INTERVAL,
correctStreak: 0,
totalCorrect: 0,
totalErrors: 0,
};
}
export interface KeyPressResult {
correct: boolean;
exerciseComplete: boolean;
newInterval: number;
}
export function handleKeyPress(
state: TypingExerciseState,
pressedKey: string
): KeyPressResult {
const expectedKey = state.letters[state.currentLetterIndex];
const correct = pressedKey.toLowerCase() === expectedKey.toLowerCase();
if (correct) {
state.currentLetterIndex++;
state.totalCorrect++;
state.correctStreak++;
// Adaptive tempo: after STREAK_THRESHOLD correct in sequence, speed up
if (state.correctStreak >= STREAK_THRESHOLD) {
state.intervalMs = Math.max(MIN_INTERVAL, state.intervalMs - INTERVAL_DECREASE);
state.correctStreak = 0; // Reset streak after speed adjustment
}
} else {
state.totalErrors++;
state.correctStreak = 0;
// Slow down after error
state.intervalMs = Math.min(MAX_INTERVAL, state.intervalMs + INTERVAL_INCREASE);
}
return {
correct,
exerciseComplete: state.currentLetterIndex >= state.letters.length,
newInterval: state.intervalMs,
};
}
export function getCurrentLetter(state: TypingExerciseState): string | null {
if (state.currentLetterIndex >= state.letters.length) return null;
return state.letters[state.currentLetterIndex];
}
export function isExerciseComplete(state: TypingExerciseState): boolean {
return state.currentLetterIndex >= state.letters.length;
}
```
**REFACTOR phase:** Clean up if needed, ensure all exports are clear and constants are well-named.
Note on D-08: Interval starts at 3000ms, -200ms after 2 consecutive correct (min 1500ms), +500ms after 1 error (max 4000ms). Reset to 3000ms at start of each new lesson (handled by createExerciseState).
</action>
<verify>
<automated>cd /home/dev/workspace/zauberwald && npx vitest run src/game/typing.test.ts</automated>
</verify>
<acceptance_criteria>
- src/game/typing.ts exports: generateLetters, createExerciseState, handleKeyPress, getCurrentLetter, isExerciseComplete
- src/game/typing.ts contains `INITIAL_INTERVAL = 3000`
- src/game/typing.ts contains `MIN_INTERVAL = 1500`
- src/game/typing.ts contains `MAX_INTERVAL = 4000`
- src/game/typing.ts contains `INTERVAL_DECREASE = 200`
- src/game/typing.ts contains `INTERVAL_INCREASE = 500`
- src/game/typing.test.ts contains at least 10 test cases
- `npx vitest run src/game/typing.test.ts` exits 0
- handleKeyPress with correct key returns { correct: true }
- handleKeyPress with wrong key returns { correct: false } and does not advance index
</acceptance_criteria>
<done>Typing exercise engine tested and working: generates random letters from level key set, handles correct/wrong input, adapts tempo per D-08 rules, detects exercise completion</done>
</task>
<task type="auto">
<name>Task 2: Lesson screen UI wiring (typing exercise + keyboard + falling letters)</name>
<files>
src/ui/screens.ts, index.html, src/styles/main.css
</files>
<read_first>
src/ui/screens.ts
src/game/typing.ts
src/game/keyboard.ts
src/game/levels.ts
src/types.ts
src/app.ts
src/storage/db.ts
index.html
src/styles/main.css
</read_first>
<action>
1. Update index.html — populate the lesson screen section:
```html
<section id="screen-lesson" class="screen">
<div class="lesson">
<div class="lesson__letter-area" id="letter-area">
<!-- Current letter appears here -->
</div>
<div class="lesson__keyboard" id="lesson-keyboard">
<!-- Keyboard rendered here -->
</div>
</div>
</section>
```
2. Add to src/ui/screens.ts — export a new function `initLessonScreen`:
```typescript
import { createExerciseState, handleKeyPress, getCurrentLetter, isExerciseComplete } from '../game/typing';
import { renderKeyboard, highlightKey, pressKey } from '../game/keyboard';
import { getProgress, saveProgress } from '../storage/db';
import type { Progress, TypingExerciseState, KeyboardLayout } from '../types';
export function initLessonScreen(
db: IDBDatabase,
level: number,
layout: KeyboardLayout,
onComplete: (level: number) => void
): () => void {
// Returns a cleanup function
const letterArea = document.getElementById('letter-area')!;
const keyboardContainer = document.getElementById('lesson-keyboard')!;
const state = createExerciseState(level);
// Render keyboard
renderKeyboard(keyboardContainer, layout, level);
// Show first 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 === ' ' ? '␣' : 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 result = handleKeyPress(state, e.key);
pressKey(e.key);
if (result.correct) {
// Correct: dissolve letter with stardust animation
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);
} else {
// Show next letter after brief delay
setTimeout(() => showCurrentLetter(), 300);
}
} else {
// Wrong: do nothing to letter (per D-03), keyboard already highlights correct key via highlightKey
// The correct key is already highlighted from showCurrentLetter
}
}
document.addEventListener('keydown', onKeyDown);
// Return cleanup function
return () => {
document.removeEventListener('keydown', onKeyDown);
letterArea.innerHTML = '';
};
}
```
3. Update src/app.ts to handle lesson start and completion:
- Add a `startLesson(level: number)` function that calls showScreen('lesson') then initLessonScreen
- The `onComplete` callback should:
a. Update progress: add level to completedLevels if not already there, increment totalSessions, add today's date to sessionDates if not already there, set currentLevel to level+1 if level was the current level (per LEVL-02, LEVL-03)
b. Save progress to IndexedDB
c. Navigate to forest screen (per D-07: skip reward in Phase 1, or show placeholder)
4. Add CSS for falling letter and dissolve animation to src/styles/main.css:
```css
.lesson {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2rem;
padding: 2rem;
min-height: 100vh;
background: var(--bg-cream);
}
.lesson__letter-area {
display: flex;
align-items: center;
justify-content: center;
width: 200px;
height: 200px;
}
.falling-letter {
font-family: 'Quicksand', sans-serif;
font-size: 5rem;
font-weight: 700;
color: var(--accent-green);
animation: letter-float 2s ease-in-out infinite;
}
@keyframes letter-float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10px); }
}
.falling-letter--dissolve {
animation: letter-dissolve 0.3s ease-out forwards;
}
@keyframes letter-dissolve {
0% { transform: scale(1); opacity: 1; }
100% { transform: scale(1.5); opacity: 0; }
}
```
</action>
<verify>
<automated>cd /home/dev/workspace/zauberwald && npx tsc --noEmit && npx vitest run</automated>
</verify>
<acceptance_criteria>
- src/ui/screens.ts exports `initLessonScreen`
- src/ui/screens.ts contains `handleKeyPress(state, e.key)` call
- src/ui/screens.ts contains `renderKeyboard(keyboardContainer, layout, level)` call
- src/ui/screens.ts contains `highlightKey(letter)` call in showCurrentLetter
- src/ui/screens.ts contains `pressKey(e.key)` call in onKeyDown
- src/ui/screens.ts contains `onComplete(level)` call when exercise is complete
- src/app.ts contains function that updates completedLevels and saves progress on lesson complete
- index.html contains `id="letter-area"` and `id="lesson-keyboard"`
- src/styles/main.css contains `.falling-letter` with `font-size: 5rem`
- src/styles/main.css contains `@keyframes letter-dissolve`
- src/styles/main.css contains `.falling-letter--dissolve`
- `npx tsc --noEmit` exits 0
- `npx vitest run` exits 0 (all existing tests still pass)
</acceptance_criteria>
<done>Lesson screen shows letters one at a time, keyboard highlights target key, correct press dissolves letter and advances, wrong press does nothing, lesson completes after all letters typed, progress saved with level completion and next level unlocked</done>
</task>
</tasks>
<verification>
- `npx vitest run` passes all tests (typing engine + DB)
- `npx tsc --noEmit` passes
- In browser: can start a lesson, type letters, see keyboard highlights, complete lesson
- After lesson: progress updated, next level unlocked
</verification>
<success_criteria>
- Typing exercise engine fully tested with TDD (RED-GREEN-REFACTOR)
- Adaptive tempo works: 3s start, -200ms after 2x correct, +500ms after error, bounds 1.5s-4s
- Lesson screen renders falling letter + keyboard, handles input correctly
- Lesson completion updates progress and unlocks next level
</success_criteria>
<output>
After completion, create `.planning/phases/01-grundger-st-tippmechanik/01-04-SUMMARY.md`
</output>