448 lines
16 KiB
Markdown
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>
|