docs(03): create phase plan — komplettes spielerlebnis

5 plans across 3 waves covering all 17 requirements:
- Plan 01 (W1): Word lists, Progress extension, rate limits
- Plan 02 (W2): Lesson 3-phase state machine, review/repeat
- Plan 03 (W1): Forest visual scene with SVG + grid
- Plan 04 (W3): Reward screen, pre-generation, full flow wiring
- Plan 05 (W2): Parent area with stats and settings

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-29 13:37:08 +02:00
co-authored by Claude Opus 4.6
parent c9651ad476
commit 5819ade457
6 changed files with 1792 additions and 2 deletions
+10 -2
View File
@@ -68,7 +68,15 @@ Plans:
3. Alle Stufen 16 sind spielbar, jede mit passenden Woertern aus gelernten Buchstaben 3. Alle Stufen 16 sind spielbar, jede mit passenden Woertern aus gelernten Buchstaben
4. Der Elternbereich ist erreichbar (Ctrl+Shift+E + "1234") und zeigt aktuelle Stufe, Einheiten, Uebungstage sowie Einstellungen 4. Der Elternbereich ist erreichbar (Ctrl+Shift+E + "1234") und zeigt aktuelle Stufe, Einheiten, Uebungstage sowie Einstellungen
5. Review-Einheiten erscheinen jede 3. Uebung; nach 2× derselben Stufe zeigt die Begleitfigur Ermutigung 5. Review-Einheiten erscheinen jede 3. Uebung; nach 2× derselben Stufe zeigt die Begleitfigur Ermutigung
**Plans**: TBD **Plans:** 5 plans
Plans:
- [ ] 03-01-PLAN.md — Word lists per level, Progress type extension, rate limit update
- [ ] 03-02-PLAN.md — Lesson 3-phase state machine (discover/practice/words), review + repetition logic
- [ ] 03-03-PLAN.md — Forest visual scene (SVG background, 4x3 grid, element rendering from IndexedDB)
- [ ] 03-04-PLAN.md — Reward screen with pre-generation, full flow wiring (lesson -> reward -> forest)
- [ ] 03-05-PLAN.md — Parent area (access gate, stats overview, settings panel)
**UI hint**: yes **UI hint**: yes
### Phase 4: Polish + Audio ### Phase 4: Polish + Audio
@@ -93,5 +101,5 @@ Phases execute in numeric order: 1 → 2 → 3 → 4
|-------|----------------|--------|-----------| |-------|----------------|--------|-----------|
| 1. Grundgerüst + Tippmechanik | 0/5 | Planning complete | - | | 1. Grundgerüst + Tippmechanik | 0/5 | Planning complete | - |
| 2. Gemini-Integration + Asset-Pipeline | 0/5 | Planning complete | - | | 2. Gemini-Integration + Asset-Pipeline | 0/5 | Planning complete | - |
| 3. Komplettes Spielerlebnis | 0/? | Not started | - | | 3. Komplettes Spielerlebnis | 0/5 | Planning complete | - |
| 4. Polish + Audio | 0/? | Not started | - | | 4. Polish + Audio | 0/? | Not started | - |
@@ -0,0 +1,177 @@
---
phase: 03-komplettes-spielerlebnis
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- src/types.ts
- src/game/words.ts
- src/game/words.test.ts
- src/api/gemini.ts
autonomous: true
requirements: [LEVL-04, TYPE-06]
must_haves:
truths:
- "Word lists exist for levels 1-6 using only learned letters"
- "Every word in a level's list uses only letters from allKeys of that level"
- "Progress interface has stats fields for accuracy tracking"
- "Rate limits allow 10 text + 5 image calls per day"
artifacts:
- path: "src/game/words.ts"
provides: "Static word lists per level, getWordsForLevel() function"
exports: ["wordsByLevel", "getWordsForLevel"]
- path: "src/game/words.test.ts"
provides: "Tests validating word lists only use learned letters"
- path: "src/types.ts"
provides: "Extended Progress with totalCorrect, totalErrors, errorKeyCounts, lastPlayedLevels"
contains: "totalCorrect"
- path: "src/api/gemini.ts"
provides: "Updated rate limits: 10 text, 5 image per day"
key_links:
- from: "src/game/words.ts"
to: "src/game/levels.ts"
via: "imports getKeysUpToLevel for validation"
pattern: "getKeysUpToLevel"
---
<objective>
Foundation data layer for Phase 3: word lists per level, extended Progress type with stats fields, updated rate limits.
Purpose: All subsequent plans (lesson flow, reward, parent area) depend on these types and data structures.
Output: `src/game/words.ts` with tested word lists, extended `Progress` interface, updated rate limits in gemini.ts.
</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/ROADMAP.md
@.planning/STATE.md
@src/types.ts
@src/game/levels.ts
@src/api/gemini.ts
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Word lists per level with validation tests</name>
<files>src/game/words.ts, src/game/words.test.ts</files>
<read_first>src/game/levels.ts, src/types.ts</read_first>
<behavior>
- Test: wordsByLevel has entries for levels 1-6
- Test: Each level has at least 8 words (per D-04: 8-10 minimum)
- Test: Every word in level N uses only characters from levels[N].allKeys (no spaces)
- Test: No word exceeds 5 characters (per D-04)
- Test: getWordsForLevel(level, count) returns `count` random words from that level
- Test: getWordsForLevel returns empty array for non-existent level
</behavior>
<action>
Create `src/game/words.ts` (per D-04):
```typescript
import { getKeysUpToLevel } from './levels';
export const wordsByLevel: Record<number, string[]> = {
1: [], // Level 1 has only F, J, space — no real words possible, use letter combos: "ff", "jj", "fj", "jf", "fjf", "jfj", "ff", "jj", "fjfj", "jfjf"
2: ['fdk', 'kdf', 'djf', 'fkd', 'dkf', 'jdk', 'kfj', 'fdkj', 'kjdf', 'dfjk'], // F,J,D,K — no real German words, use combos
3: ['falls', 'lass', 'das', 'sdf', 'sdk', 'lsd', 'flsk', 'sldf', 'dsl', 'kls'], // adds S, L — "falls", "lass" are real words
4: ['als', 'da', 'das', 'dafö', 'salad', 'lass', 'falls', 'aas', 'sa', 'ask'], // adds A, OE
5: ['glas', 'halb', 'jagd', 'gash', 'hagl', 'gah', 'hag', 'lag', 'gal', 'sah'], // adds G, H
6: ['eis', 'idee', 'kleid', 'lied', 'lief', 'fies', 'dies', 'die', 'sie', 'sei'], // adds E, I
};
```
IMPORTANT: For levels 1-2, real German words are impossible with only F/J/D/K + space. Use letter combination patterns instead (the Spec acknowledges this implicitly — words only become meaningful at level 3+). Each word MUST only use characters from that level's `allKeys` (excluding space). Validate at test time by cross-referencing `getKeysUpToLevel()`.
Export `getWordsForLevel(level: number, count: number): string[]` that returns `count` random words from the level's list (sampling without replacement if count <= list length, with replacement otherwise).
Create `src/game/words.test.ts` with vitest tests covering all behaviors above.
</action>
<verify>
<automated>npx vitest run src/game/words.test.ts</automated>
</verify>
<acceptance_criteria>
- grep "wordsByLevel" src/game/words.ts returns matches
- grep "getWordsForLevel" src/game/words.ts returns export
- grep "getKeysUpToLevel" src/game/words.test.ts shows validation against level keys
- npx vitest run src/game/words.test.ts exits 0
</acceptance_criteria>
<done>Word lists for levels 1-6 exist, each with 8+ words using only learned letters, max 5 chars. Tests pass.</done>
</task>
<task type="auto">
<name>Task 2: Extend Progress type and update rate limits</name>
<files>src/types.ts, src/api/gemini.ts</files>
<read_first>src/types.ts, src/api/gemini.ts</read_first>
<action>
**Progress type extension (per D-15):**
Add these fields to the `Progress` interface in `src/types.ts`:
```typescript
totalCorrect: number;
totalErrors: number;
errorKeyCounts: Record<string, number>;
lastPlayedLevels: number[]; // last 3 played levels for D-06
```
Also add a `LessonPhase` type:
```typescript
export type LessonPhase = 'discover' | 'practice' | 'words';
```
**Update rate limits (per D-13):**
In `src/api/gemini.ts`, change `checkRateLimit` function:
- Current: `settings.apiCallsToday[type] < 1` (allows 1 each)
- New: text limit = 10, image limit = 5
- Add constants: `const TEXT_RATE_LIMIT = 10;` and `const IMAGE_RATE_LIMIT = 5;`
- Change comparison: `type === 'text' ? settings.apiCallsToday.text < TEXT_RATE_LIMIT : settings.apiCallsToday.image < IMAGE_RATE_LIMIT`
**Update initWelcomeScreen in screens.ts** to include new Progress fields with defaults:
In `src/ui/screens.ts`, find the `Progress` object creation in `startBtn` click handler and add:
```typescript
totalCorrect: 0,
totalErrors: 0,
errorKeyCounts: {},
lastPlayedLevels: [],
```
</action>
<verify>
<automated>npx vitest run && npx tsc --noEmit</automated>
</verify>
<acceptance_criteria>
- grep "totalCorrect" src/types.ts returns a match
- grep "totalErrors" src/types.ts returns a match
- grep "errorKeyCounts" src/types.ts returns a match
- grep "lastPlayedLevels" src/types.ts returns a match
- grep "LessonPhase" src/types.ts returns a match
- grep "TEXT_RATE_LIMIT = 10" src/api/gemini.ts returns a match
- grep "IMAGE_RATE_LIMIT = 5" src/api/gemini.ts returns a match
- npx tsc --noEmit exits 0
</acceptance_criteria>
<done>Progress has stats fields, LessonPhase type exists, rate limits updated to 10 text / 5 image per day. TypeScript compiles clean.</done>
</task>
</tasks>
<verification>
- `npx vitest run` — all tests pass
- `npx tsc --noEmit` — no type errors
- Word lists validated: every word uses only learned letters for its level
</verification>
<success_criteria>
1. `src/game/words.ts` exports wordsByLevel (levels 1-6, 8+ words each) and getWordsForLevel()
2. All words validated: only use allKeys from their level, max 5 chars
3. Progress interface extended with totalCorrect, totalErrors, errorKeyCounts, lastPlayedLevels
4. LessonPhase type exported from types.ts
5. Rate limits updated to 10 text / 5 image per day
6. All tests pass, TypeScript compiles clean
</success_criteria>
<output>
After completion, create `.planning/phases/03-komplettes-spielerlebnis/03-01-SUMMARY.md`
</output>
@@ -0,0 +1,340 @@
---
phase: 03-komplettes-spielerlebnis
plan: 02
type: execute
wave: 2
depends_on: [03-01]
files_modified:
- src/game/typing.ts
- src/game/typing.test.ts
- src/ui/screens.ts
- index.html
autonomous: true
requirements: [TYPE-05, TYPE-06, TYPE-07, LEVL-05, LEVL-06]
must_haves:
truths:
- "Entdecken phase shows companion text and highlights the new key on keyboard"
- "Woerter phase displays whole words with active letter highlighted"
- "Lesson flows through discover -> practice -> words automatically"
- "Every 3rd session is a review mixing letters from last 2-3 levels"
- "After 2x same level, companion shows encouragement with curiosity trigger"
artifacts:
- path: "src/game/typing.ts"
provides: "Extended createExerciseState with mode='letters'|'words', generateWordLetters()"
exports: ["createExerciseState", "generateWordLetters"]
- path: "src/ui/screens.ts"
provides: "Lesson sub-phase state machine: discover -> practice -> words"
contains: "LessonPhase"
key_links:
- from: "src/ui/screens.ts"
to: "src/game/typing.ts"
via: "createExerciseState for practice and word phases"
pattern: "createExerciseState"
- from: "src/ui/screens.ts"
to: "src/companion/companion.ts"
via: "getLetterIntro for discover phase"
pattern: "getLetterIntro"
- from: "src/ui/screens.ts"
to: "src/game/words.ts"
via: "getWordsForLevel for word phase"
pattern: "getWordsForLevel"
---
<objective>
Build the complete lesson flow with 3 sub-phases (Entdecken -> Ueben -> Woerter) as an internal state machine, plus review mechanic and repetition protection.
Purpose: This is the core game loop — the child experiences discover, practice, then words in one fluid session.
Output: Extended typing engine with word mode, lesson screen with 3-phase state machine, review and repetition logic.
</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/ROADMAP.md
@.planning/STATE.md
@.planning/phases/03-komplettes-spielerlebnis/03-01-SUMMARY.md
@src/game/typing.ts
@src/ui/screens.ts
@src/companion/companion.ts
@src/game/words.ts
@src/game/levels.ts
@src/types.ts
@index.html
<interfaces>
<!-- From src/types.ts (after Plan 01): -->
export type LessonPhase = 'discover' | 'practice' | 'words';
export interface Progress {
id: 1;
currentLevel: number;
completedLevels: number[];
totalSessions: number;
sessionDates: string[];
selectedCharacter: CompanionType;
selectedLayout: KeyboardLayout;
totalCorrect: number;
totalErrors: number;
errorKeyCounts: Record<string, number>;
lastPlayedLevels: number[];
}
<!-- From src/game/words.ts (after Plan 01): -->
export const wordsByLevel: Record<number, string[]>;
export function getWordsForLevel(level: number, count: number): string[];
<!-- From src/companion/companion.ts (existing): -->
export async function getLetterIntro(characterType: CompanionType, letter: string, fingerDescription: string, db: IDBDatabase): Promise<string>;
<!-- From src/game/typing.ts (existing): -->
export function createExerciseState(level: number): TypingExerciseState;
export function handleKeyPress(state: TypingExerciseState, pressedKey: string): KeyPressResult;
export function getCurrentLetter(state: TypingExerciseState): string | null;
<!-- From src/game/levels.ts (existing): -->
export function getLevelByNumber(n: number): Level | undefined;
export function getKeysUpToLevel(n: number): string[];
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Extend typing engine with word mode</name>
<files>src/game/typing.ts, src/game/typing.test.ts</files>
<read_first>src/game/typing.ts, src/game/words.ts, src/types.ts</read_first>
<behavior>
- Test: createExerciseState(level) still works as before (letters mode, backwards compatible)
- Test: createWordExerciseState(level, words) creates state with letters from the words joined, e.g. words=["eis","die"] -> letters=["e","i","s","d","i","e"]
- Test: createWordExerciseState stores words and wordBoundaries in state for UI rendering
- Test: handleKeyPress works the same way for word-mode state
- Test: getWordAtIndex(state, letterIndex) returns which word the letter belongs to
</behavior>
<action>
Extend `src/game/typing.ts` (per D-03):
1. Add `ExerciseMode` type: `'letters' | 'words'`
2. Extend `TypingExerciseState` interface (in types.ts) with optional word fields:
```typescript
export interface TypingExerciseState {
currentLetterIndex: number;
letters: string[];
intervalMs: number;
correctStreak: number;
totalCorrect: number;
totalErrors: number;
mode: 'letters' | 'words';
words?: string[]; // original words for display
wordBoundaries?: number[]; // indices where each word starts in letters[]
}
```
3. Keep existing `createExerciseState(level)` unchanged but set `mode: 'letters'`, `words: undefined`, `wordBoundaries: undefined`.
4. Add new function:
```typescript
export function createWordExerciseState(words: string[]): TypingExerciseState {
const letters: string[] = [];
const wordBoundaries: number[] = [];
for (const word of words) {
wordBoundaries.push(letters.length);
for (const ch of word) {
letters.push(ch);
}
}
return {
currentLetterIndex: 0,
letters,
intervalMs: 0, // no falling in word mode
correctStreak: 0,
totalCorrect: 0,
totalErrors: 0,
mode: 'words',
words,
wordBoundaries,
};
}
```
5. Add helper: `getCurrentWordIndex(state: TypingExerciseState): number` — returns which word index (0-based) the currentLetterIndex is in, using wordBoundaries.
6. `handleKeyPress` already works generically — no changes needed.
Create `src/game/typing.test.ts` (or extend if exists) with vitest tests for the new functions plus backwards compatibility.
</action>
<verify>
<automated>npx vitest run src/game/typing.test.ts</automated>
</verify>
<acceptance_criteria>
- grep "createWordExerciseState" src/game/typing.ts returns export
- grep "getCurrentWordIndex" src/game/typing.ts returns export
- grep "mode.*letters.*words" src/types.ts returns match showing mode field
- npx vitest run src/game/typing.test.ts exits 0
</acceptance_criteria>
<done>Typing engine supports word mode with word boundaries tracking. Backwards compatible with letter mode. Tests pass.</done>
</task>
<task type="auto">
<name>Task 2: Lesson screen 3-phase state machine with review and repetition</name>
<files>src/ui/screens.ts, index.html</files>
<read_first>src/ui/screens.ts, src/game/typing.ts, src/companion/companion.ts, src/game/words.ts, src/game/levels.ts, src/types.ts, index.html</read_first>
<action>
Rewrite `initLessonScreen` in `src/ui/screens.ts` as a 3-phase state machine (per D-01, D-02, D-03):
**Add to index.html** inside `#screen-lesson .lesson`:
```html
<div class="lesson__phase-indicator" id="lesson-phase-indicator"></div>
```
This shows which phase is active (Entdecken / Ueben / Woerter). Add above the letter-area.
**Signature change:**
```typescript
export function initLessonScreen(
db: IDBDatabase,
level: number,
layout: KeyboardLayout,
onComplete: (level: number, stats: { totalCorrect: number; totalErrors: number; errorKeys: Record<string, number> }) => void,
onCancel?: () => void,
): () => void
```
The `onComplete` callback now receives exercise stats (per D-16).
**State machine flow:**
1. **DISCOVER phase** (per D-02, TYPE-05):
- Show phase indicator: "Entdecken"
- Get the first new key of this level via `getLevelByNumber(level).newKeys[0]`
- Fetch companion letter intro via `getLetterIntro(characterType, letter, fingerDescription, db)`
- Display intro text in `#lesson-companion-text`
- Highlight the target key on keyboard (already have `highlightKey()`)
- Make key pulse (add class `keyboard__key--discover-pulse` to the key)
- Wait for child to press the correct key ONCE to proceed
- If level has a second newKey, repeat discover for that key too
- Then transition to PRACTICE
2. **PRACTICE phase** (existing behavior, TYPE-01-04):
- Show phase indicator: "Ueben"
- Run existing letter exercise with `createExerciseState(level)`
- On complete, accumulate stats, transition to WORDS
3. **WORDS phase** (per D-03, TYPE-06):
- Show phase indicator: "Woerter"
- Get 3-4 random words via `getWordsForLevel(level, 4)` (fallback to 3 if < 4 available)
- Create word exercise via `createWordExerciseState(words)`
- Display current word fully, highlight active letter:
```html
<div class="word-display">
<span class="word-display__letter word-display__letter--done">e</span>
<span class="word-display__letter word-display__letter--active">i</span>
<span class="word-display__letter">s</span>
</div>
```
- When word complete, show next word. After all words done, call `onComplete(level, stats)`.
**Review mechanic (per D-05, LEVL-05):**
Add `isReviewSession` parameter to `initLessonScreen`. When `true`:
- Discover phase: skip (reviews don't introduce new letters)
- Practice phase: use mixed letters from last 2-3 levels (combine allKeys from current and previous 1-2 levels)
- Words phase: pick words from last 2-3 levels randomly
The review trigger logic (`totalSessions % 3 === 0`) is handled in `app.ts` (Plan 04), not here. This function just needs to accept the `isReview: boolean` flag.
**Repetition protection (per D-06, LEVL-06):**
Add `showEncouragement` parameter. When `true`:
- Before discover phase, show companion encouragement text: a hardcoded array of 3-4 messages like "Magst du sehen, was als Naechstes kommt? Im Wald gibt es noch so viel zu entdecken!"
- Show for 3 seconds, then proceed to discover (or practice if review).
The trigger logic (checking `lastPlayedLevels` for 2x same) is handled in `app.ts` (Plan 04).
**Cumulative stats across phases:**
Track `totalCorrect`, `totalErrors`, `errorKeys: Record<string, number>` across all 3 phases. In handleKeyPress wrapper, if `!result.correct`, increment `errorKeys[expectedKey]`. Pass accumulated stats to `onComplete`.
**CSS for phase indicator** — add styles in action:
```css
.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;
}
```
**CSS for word display:**
```css
.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);
}
```
Add CSS for discover pulse:
```css
.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); }
}
```
</action>
<verify>
<automated>npx tsc --noEmit && npx vitest run</automated>
</verify>
<acceptance_criteria>
- grep "lesson-phase-indicator" index.html returns a match
- grep "discover.*practice.*words" src/ui/screens.ts shows state machine phases
- grep "getLetterIntro" src/ui/screens.ts shows companion integration in discover
- grep "createWordExerciseState" src/ui/screens.ts shows word mode usage
- grep "getWordsForLevel" src/ui/screens.ts shows word list integration
- grep "isReview" src/ui/screens.ts shows review parameter
- grep "showEncouragement" src/ui/screens.ts shows repetition protection parameter
- grep "word-display" src/styles/main.css returns CSS matches
- grep "discover-pulse" src/styles/main.css returns CSS matches
- npx tsc --noEmit exits 0
</acceptance_criteria>
<done>Lesson screen runs 3 phases (discover->practice->words) with companion letter intro, word display UI, review flag support, and repetition encouragement. Stats accumulated across phases and passed to onComplete callback.</done>
</task>
</tasks>
<verification>
- `npx tsc --noEmit` — no type errors
- `npx vitest run` — all tests pass
- Manual: lesson screen shows phase indicator, discover phase displays companion text and highlights key, words phase shows word with active letter
</verification>
<success_criteria>
1. Typing engine has createWordExerciseState() and getCurrentWordIndex()
2. Lesson screen flows: discover -> practice -> words -> onComplete(level, stats)
3. Discover phase: companion introduces letter, key pulses, child presses once to proceed
4. Words phase: 3-4 words displayed with active letter highlighted, typed letter by letter
5. Review flag skips discover and mixes letters/words from recent levels
6. Encouragement shown when showEncouragement=true
7. Stats (correct, errors, errorKeys) accumulated and passed to onComplete
</success_criteria>
<output>
After completion, create `.planning/phases/03-komplettes-spielerlebnis/03-02-SUMMARY.md`
</output>
@@ -0,0 +1,267 @@
---
phase: 03-komplettes-spielerlebnis
plan: 03
type: execute
wave: 1
depends_on: []
files_modified:
- src/forest/scene.ts
- index.html
- src/styles/main.css
autonomous: true
requirements: [FRST-01, FRST-02, FRST-03]
must_haves:
truths:
- "Forest screen shows SVG woodland background"
- "A 4x3 grid overlays the SVG for placing forest element images"
- "New elements appear with fade-in + scale-up animation"
- "All previously earned elements load from IndexedDB on screen init"
artifacts:
- path: "src/forest/scene.ts"
provides: "renderForestScene() that loads elements from IDB and renders in grid"
exports: ["renderForestScene"]
- path: "index.html"
provides: "Forest scene container with SVG background and grid overlay"
contains: "forest-scene"
key_links:
- from: "src/forest/scene.ts"
to: "src/storage/db.ts"
via: "getForestElements() to load persisted elements"
pattern: "getForestElements"
---
<objective>
Build the visual forest scene: SVG background with a 4x3 grid overlay where generated forest element images are placed. Elements load from IndexedDB and new ones animate in.
Purpose: The growing forest IS the reward system — the child sees their world fill up with each session.
Output: Visual forest scene in forest screen with grid, animations, and IndexedDB loading.
</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/ROADMAP.md
@.planning/STATE.md
@src/forest/scene.ts
@src/storage/db.ts
@src/types.ts
@index.html
@src/styles/main.css
<interfaces>
<!-- From src/storage/db.ts (existing): -->
export function getForestElements(db: IDBDatabase): Promise<ForestElement[]>;
export function saveForestElement(db: IDBDatabase, element: ForestElement): Promise<number>;
<!-- From src/types.ts (existing): -->
export interface ForestElement {
id?: number;
levelCompleted: number;
imageBlob: Blob;
imagePrompt: string;
description: string;
companionText: string;
position: { x: number; y: number };
createdAt: string;
}
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: SVG forest background and grid HTML structure</name>
<files>index.html, src/styles/main.css</files>
<read_first>index.html, src/styles/main.css, src/forest/scene.ts</read_first>
<action>
**Update index.html** `#screen-forest .forest` section (per D-07, D-09):
Add a forest scene container ABOVE the level-map but BELOW the greeting:
```html
<div class="forest__scene" id="forest-scene">
<svg class="forest__bg" viewBox="0 0 800 400" xmlns="http://www.w3.org/2000/svg">
<!-- Ground -->
<rect x="0" y="300" width="800" height="100" fill="#C8E6C0" rx="0"/>
<!-- Hills -->
<ellipse cx="200" cy="320" rx="250" ry="80" fill="#A8D8A8"/>
<ellipse cx="650" cy="330" rx="200" ry="60" fill="#B8E0B0"/>
<!-- Trees (background) -->
<g opacity="0.3">
<ellipse cx="80" cy="240" rx="40" ry="60" fill="#7DB87D"/>
<ellipse cx="720" cy="250" rx="35" ry="55" fill="#6DA86D"/>
<ellipse cx="400" cy="230" rx="45" ry="70" fill="#8DC88D"/>
</g>
<!-- Sky gradient -->
<defs>
<linearGradient id="sky-grad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#E8F4FD"/>
<stop offset="100%" stop-color="#F0F8E8"/>
</linearGradient>
</defs>
<rect x="0" y="0" width="800" height="300" fill="url(#sky-grad)"/>
<!-- Clouds -->
<ellipse cx="150" cy="60" rx="60" ry="25" fill="white" opacity="0.5"/>
<ellipse cx="550" cy="80" rx="70" ry="20" fill="white" opacity="0.4"/>
<!-- Sun -->
<circle cx="700" cy="50" r="30" fill="#F8E8A0" opacity="0.6"/>
</svg>
<div class="forest__grid" id="forest-grid">
<!-- 12 slots for forest elements, filled dynamically -->
</div>
</div>
```
NOTE: The SVG layers must be ordered so sky-rect is FIRST (painted first, at back), then hills/ground on top. Adjust SVG element order: sky rect first, then clouds/sun, then background trees, then hills, then ground.
**Add CSS to `src/styles/main.css`:**
```css
/* Forest Scene */
.forest__scene {
position: relative;
width: 100%;
max-width: 800px;
margin: 0 auto 1.5rem;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.forest__bg {
display: block;
width: 100%;
height: auto;
}
.forest__grid {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-rows: repeat(3, 1fr);
padding: 1rem;
gap: 0.5rem;
}
.forest__element {
display: flex;
align-items: center;
justify-content: center;
}
.forest__element img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
border-radius: 8px;
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.15));
}
/* New element animation (per D-08, FRST-02) */
.forest__element--new {
animation: forest-element-appear 1.2s ease-out forwards;
}
@keyframes forest-element-appear {
0% {
opacity: 0;
transform: scale(0.3);
}
60% {
opacity: 1;
transform: scale(1.05);
}
100% {
opacity: 1;
transform: scale(1);
}
}
```
</action>
<verify>
<automated>grep -c "forest-scene" index.html && grep -c "forest__grid" index.html && grep -c "forest__element--new" src/styles/main.css</automated>
</verify>
<acceptance_criteria>
- grep "forest-scene" index.html returns match
- grep "forest__grid" index.html returns match
- grep "forest-element-appear" src/styles/main.css returns match (animation keyframes)
- grep "grid-template-columns.*repeat(4" src/styles/main.css returns match (4x3 grid)
- grep "grid-template-rows.*repeat(3" src/styles/main.css returns match
- SVG viewBox="0 0 800 400" present in index.html
</acceptance_criteria>
<done>Forest scene has SVG background with woodland scenery, 4x3 CSS grid overlay, and fade-in+scale-up animation keyframes for new elements.</done>
</task>
<task type="auto">
<name>Task 2: Render forest elements from IndexedDB</name>
<files>src/forest/scene.ts</files>
<read_first>src/forest/scene.ts, src/storage/db.ts, src/types.ts, index.html</read_first>
<action>
Extend `src/forest/scene.ts` (per D-07, D-08, FRST-03):
Add a new exported function `renderForestScene(db: IDBDatabase, newElementId?: number): Promise<void>`:
1. Get `#forest-grid` element
2. Call `getForestElements(db)` to load all persisted elements
3. Clear grid contents
4. For each element (up to 12 — the grid has 12 slots):
- Create a `<div class="forest__element">`
- Create an `<img>` with `src` = `URL.createObjectURL(element.imageBlob)`
- Set `alt` = `element.description`
- Apply random offset per D-07: `style="transform: translate(${randomOffset}px, ${randomOffset}px)"` where offset is +-10-20px random
- If `element.id === newElementId`, add class `forest__element--new` for animation
- Append to grid
5. Store created Object URLs in a module-level array so they can be revoked on next call (prevent memory leak):
```typescript
let activeObjectUrls: string[] = [];
```
At start of function, revoke all old URLs via `URL.revokeObjectURL()`, then clear array.
Update `initForestScreen` to call `renderForestScene(db)` alongside existing `renderLevelMap`.
The `newElementId` parameter will be used by Plan 04 (reward flow) to trigger animation on the just-added element.
</action>
<verify>
<automated>npx tsc --noEmit && npx vitest run</automated>
</verify>
<acceptance_criteria>
- grep "renderForestScene" src/forest/scene.ts returns export
- grep "getForestElements" src/forest/scene.ts returns match (loads from IDB)
- grep "createObjectURL" src/forest/scene.ts returns match (blob->URL conversion)
- grep "revokeObjectURL" src/forest/scene.ts returns match (memory cleanup)
- grep "forest__element--new" src/forest/scene.ts returns match (animation class)
- grep "renderForestScene" src/forest/scene.ts shows it's called from initForestScreen
- npx tsc --noEmit exits 0
</acceptance_criteria>
<done>Forest scene renders all stored elements from IndexedDB as images in the 4x3 grid. New elements get animation class. Object URLs cleaned up on re-render. Level map preserved alongside scene.</done>
</task>
</tasks>
<verification>
- `npx tsc --noEmit` — no type errors
- `npx vitest run` — all tests pass
- Forest screen has SVG background visible with grid overlay
- Elements from IndexedDB render as images with random offset
</verification>
<success_criteria>
1. Forest screen shows SVG woodland background (sky, hills, trees, clouds)
2. 4x3 CSS grid overlays the SVG for element placement
3. All stored ForestElements load from IndexedDB and display as images
4. New elements animate with fade-in + scale-up (1.2s ease-out)
5. Object URLs properly managed (revoked on re-render)
6. Level map still visible below/alongside the scene
</success_criteria>
<output>
After completion, create `.planning/phases/03-komplettes-spielerlebnis/03-03-SUMMARY.md`
</output>
@@ -0,0 +1,515 @@
---
phase: 03-komplettes-spielerlebnis
plan: 04
type: execute
wave: 3
depends_on: [03-01, 03-02, 03-03]
files_modified:
- src/app.ts
- src/forest/reward.ts
- index.html
- src/styles/main.css
autonomous: true
requirements: [RWRD-01, RWRD-02, RWRD-03, RWRD-04, TYPE-07]
must_haves:
truths:
- "After lesson completion, reward screen shows with new forest element image"
- "Image pre-generation starts at lesson begin and is awaited on reward screen"
- "Placeholder 'Der Wald denkt nach...' with leaf animation shows while image loads"
- "Reward screen has 'Weiter ueben' and 'Zurueck zum Wald' buttons"
- "Full flow works: forest -> lesson (3 phases) -> reward -> forest"
- "New element persisted in IndexedDB and visible in forest after reward"
artifacts:
- path: "src/forest/reward.ts"
provides: "generateForestReward() pre-generation, initRewardScreen() display"
exports: ["startPreGeneration", "initRewardScreen"]
- path: "src/app.ts"
provides: "Updated flow: lesson -> reward -> forest, review/repeat triggers"
contains: "showScreen.*reward"
- path: "index.html"
provides: "Reward screen HTML structure"
contains: "screen-reward"
key_links:
- from: "src/app.ts"
to: "src/forest/reward.ts"
via: "startPreGeneration at lesson start, initRewardScreen at lesson end"
pattern: "startPreGeneration"
- from: "src/forest/reward.ts"
to: "src/api/gemini.ts"
via: "generateImage() for forest element"
pattern: "generateImage"
- from: "src/forest/reward.ts"
to: "src/storage/db.ts"
via: "saveForestElement() to persist"
pattern: "saveForestElement"
- from: "src/app.ts"
to: "src/forest/scene.ts"
via: "renderForestScene(db, newElementId) after reward"
pattern: "renderForestScene"
---
<objective>
Wire the complete game flow: lesson -> reward -> forest. Build reward screen with pre-generated forest element image, companion comment, and navigation buttons. Connect review/repetition triggers in app.ts.
Purpose: This closes the game loop — the child finishes a lesson, sees a magical reward, and returns to a growing forest.
Output: Reward module, reward screen HTML/CSS, updated app.ts with full flow including review/repetition logic.
</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/ROADMAP.md
@.planning/STATE.md
@.planning/phases/03-komplettes-spielerlebnis/03-01-SUMMARY.md
@.planning/phases/03-komplettes-spielerlebnis/03-02-SUMMARY.md
@.planning/phases/03-komplettes-spielerlebnis/03-03-SUMMARY.md
@src/app.ts
@src/api/gemini.ts
@src/companion/companion.ts
@src/storage/db.ts
@src/forest/scene.ts
@src/types.ts
@index.html
@src/companion/fallbacks.ts
<interfaces>
<!-- From src/api/gemini.ts (existing): -->
export async function generateImage(prompt: string, referenceImages: Blob[], config: GeminiConfig): Promise<Blob | null>;
export async function generateText(prompt: string, systemPrompt: string, config: GeminiConfig): Promise<string | null>;
export async function checkRateLimit(db: IDBDatabase, type: 'text' | 'image'): Promise<boolean>;
export async function incrementApiCall(db: IDBDatabase, type: 'text' | 'image'): Promise<void>;
<!-- From src/storage/db.ts (existing): -->
export function saveForestElement(db: IDBDatabase, element: ForestElement): Promise<number>;
export function getForestElements(db: IDBDatabase): Promise<ForestElement[]>;
export function getStyleReference(db: IDBDatabase): Promise<StyleReference | null>;
<!-- From src/companion/companion.ts (existing): -->
export async function getForestComment(characterType: CompanionType, elementDescription: string, db: IDBDatabase): Promise<string>;
<!-- From src/companion/fallbacks.ts (existing): -->
export function getRandomFallbackForestComment(): string;
import { getRandomFallbackImage } from './fallbacks'; // returns SVG blob
<!-- From src/forest/scene.ts (after Plan 03): -->
export async function renderForestScene(db: IDBDatabase, newElementId?: number): Promise<void>;
export async function initForestScreen(db: IDBDatabase, onStartLesson: (level: number) => void): Promise<void>;
<!-- From src/ui/screens.ts (after Plan 02): -->
export function initLessonScreen(
db: IDBDatabase, level: number, layout: KeyboardLayout,
onComplete: (level: number, stats: { totalCorrect: number; totalErrors: number; errorKeys: Record<string, number> }) => void,
onCancel?: () => void,
isReview?: boolean, showEncouragement?: boolean,
): () => void;
<!-- From src/types.ts (after Plan 01): -->
export interface Progress {
...
totalCorrect: number;
totalErrors: number;
errorKeyCounts: Record<string, number>;
lastPlayedLevels: number[];
}
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Reward module with pre-generation and reward screen</name>
<files>src/forest/reward.ts, index.html, src/styles/main.css</files>
<read_first>src/api/gemini.ts, src/storage/db.ts, src/companion/companion.ts, src/companion/fallbacks.ts, src/types.ts, index.html, src/styles/main.css</read_first>
<action>
**Create `src/forest/reward.ts`** (per D-10, D-11, D-12):
```typescript
import { loadConfig } from '../api/config';
import { generateImage, checkRateLimit, incrementApiCall } from '../api/gemini';
import { getStyleReference, saveForestElement, getForestElements } from '../storage/db';
import { getForestComment } from '../companion/companion';
import { getRandomFallbackForestComment } from '../companion/fallbacks';
import type { CompanionType, ForestElement } from '../types';
```
**Pre-generation (per D-11, RWRD-02):**
```typescript
let pregenPromise: Promise<{ blob: Blob; description: string }> | null = null;
export function startPreGeneration(db: IDBDatabase): void {
pregenPromise = generateForestElement(db);
}
```
`generateForestElement(db)` async function:
1. Check rate limit for 'image': `await checkRateLimit(db, 'image')`
2. If rate-limited, return fallback: use `getRandomFallbackImage()` from fallbacks.ts
- NOTE: Check if `getRandomFallbackImage` exists. If not, create inline fallback: load one of the SVGs from `src/assets/fallback-images/` via fetch.
- Actually, look at existing fallbacks.ts — it has `getRandomFallbackImage` or similar. If not, we need a simple function that picks a random fallback SVG and returns it as a Blob.
3. Load config via `loadConfig()`. If no config, use fallback.
4. Get style reference via `getStyleReference(db)`. Build referenceImages array (style ref blob if available).
5. Pick random forest element type from a list: `['Blume', 'Pilz', 'Schmetterling', 'Vogel', 'Reh', 'Eichhoernchen', 'Hase', 'Frosch', 'Igel', 'Marienkaefer', 'Libelle', 'Schnecke']`
6. Build prompt: `"Kinderbuch-Illustration, Aquarell-Stil, ${elementType}, magischer Wald, warm, einladend, fuer Kinder, Pastellfarben, kein Text, einzelnes Element auf transparentem Hintergrund"`
7. Call `generateImage(prompt, referenceImages, config)`
8. If result null, use fallback
9. Increment API call: `await incrementApiCall(db, 'image')`
10. Return `{ blob, description: elementType }`
**Fallback image function:**
Add to reward.ts:
```typescript
const FALLBACK_SVGS = [
'/src/assets/fallback-images/blume.svg',
'/src/assets/fallback-images/pilz.svg',
'/src/assets/fallback-images/vogel.svg',
'/src/assets/fallback-images/schmetterling.svg',
'/src/assets/fallback-images/reh.svg',
'/src/assets/fallback-images/baum.svg',
'/src/assets/fallback-images/bach.svg',
];
async function getRandomFallbackImage(): Promise<{ blob: Blob; description: string }> {
const idx = Math.floor(Math.random() * FALLBACK_SVGS.length);
const path = FALLBACK_SVGS[idx]!;
const name = path.split('/').pop()!.replace('.svg', '');
try {
const res = await fetch(path);
const blob = await res.blob();
return { blob, description: name };
} catch {
// Ultimate fallback: empty SVG blob
const svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><circle cx="50" cy="50" r="40" fill="#E8F5E4"/></svg>';
return { blob: new Blob([svg], { type: 'image/svg+xml' }), description: 'element' };
}
}
```
**initRewardScreen (per RWRD-01, RWRD-03, RWRD-04):**
```typescript
export async function initRewardScreen(
db: IDBDatabase,
level: number,
characterType: CompanionType,
onContinue: () => void,
onBackToForest: () => void,
): Promise<number> // returns saved element ID
```
1. Show placeholder immediately: set `#reward-image` area to placeholder HTML:
```html
<div class="reward__placeholder">
<span class="reward__leaf reward__leaf--1">🍃</span>
<span class="reward__leaf reward__leaf--2">🍂</span>
<span class="reward__leaf reward__leaf--3">🍃</span>
<p>Der Wald denkt nach...</p>
</div>
```
2. Await `pregenPromise` (or `generateForestElement(db)` if promise is null)
3. Get companion comment: `await getForestComment(characterType, result.description, db)`
4. Determine grid position for new element: count existing elements, position = `{ x: col, y: row }` based on next free slot in 4x3 grid (slot index = existingCount % 12). Add random offset +-15px.
5. Save to IndexedDB:
```typescript
const element: ForestElement = {
levelCompleted: level,
imageBlob: result.blob,
imagePrompt: prompt,
description: result.description,
companionText: comment,
position: { x: col, y: row },
createdAt: new Date().toISOString(),
};
const elementId = await saveForestElement(db, element);
```
6. Replace placeholder with actual image: `URL.createObjectURL(result.blob)` in `<img>` tag
7. Show companion comment in `#reward-companion-text`
8. Wire buttons: `#reward-continue-btn` -> onContinue, `#reward-back-btn` -> onBackToForest
9. Return elementId (for forest scene animation)
**Update index.html** — replace empty `<section id="screen-reward">`:
```html
<section id="screen-reward" class="screen">
<div class="reward">
<h2 class="reward__title">Schau mal!</h2>
<div class="reward__image-area" id="reward-image-area">
<!-- Placeholder or generated image -->
</div>
<div class="reward__companion" id="reward-companion">
<img id="reward-companion-avatar" class="companion-area__avatar" src="" alt="">
<p id="reward-companion-text" class="companion-area__text"></p>
</div>
<div class="reward__actions">
<button class="reward__btn reward__btn--continue" id="reward-continue-btn">Weiter ueben</button>
<button class="reward__btn reward__btn--back" id="reward-back-btn">Zurueck zum Wald</button>
</div>
</div>
</section>
```
**Add CSS to `src/styles/main.css`:**
```css
/* Reward Screen */
.reward {
display: flex;
flex-direction: column;
align-items: center;
padding: 2rem;
max-width: 600px;
margin: 0 auto;
text-align: center;
}
.reward__title {
font-family: var(--font-heading);
color: var(--accent-gold);
font-size: 1.8rem;
margin-bottom: 1.5rem;
}
.reward__image-area {
width: 280px;
height: 280px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 1.5rem;
border-radius: 16px;
background: var(--bg-forest);
overflow: hidden;
}
.reward__image-area img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
animation: reward-image-appear 0.8s ease-out;
}
@keyframes reward-image-appear {
from { opacity: 0; transform: scale(0.5); }
to { opacity: 1; transform: scale(1); }
}
.reward__placeholder {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
color: var(--text-warm);
font-family: var(--font-heading);
}
.reward__leaf {
font-size: 1.5rem;
animation: leaf-float 2s ease-in-out infinite;
}
.reward__leaf--1 { animation-delay: 0s; }
.reward__leaf--2 { animation-delay: 0.5s; }
.reward__leaf--3 { animation-delay: 1s; }
@keyframes leaf-float {
0%, 100% { transform: translateY(0) rotate(0deg); }
50% { transform: translateY(-10px) rotate(15deg); }
}
.reward__companion {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1.5rem;
padding: 1rem;
background: rgba(255, 255, 255, 0.7);
border-radius: 12px;
}
.reward__actions {
display: flex;
gap: 1rem;
}
.reward__btn {
font-family: var(--font-heading);
font-size: 1.1rem;
padding: 0.75rem 1.5rem;
border: none;
border-radius: 12px;
cursor: pointer;
transition: transform 0.2s;
}
.reward__btn:hover {
transform: scale(1.05);
}
.reward__btn--continue {
background: var(--accent-green);
color: white;
}
.reward__btn--back {
background: var(--bg-cream);
color: var(--text-warm);
border: 2px solid var(--accent-green);
}
```
</action>
<verify>
<automated>npx tsc --noEmit && grep -c "startPreGeneration" src/forest/reward.ts && grep -c "initRewardScreen" src/forest/reward.ts && grep -c "reward-continue-btn" index.html</automated>
</verify>
<acceptance_criteria>
- grep "startPreGeneration" src/forest/reward.ts returns export
- grep "initRewardScreen" src/forest/reward.ts returns export
- grep "generateImage" src/forest/reward.ts shows image generation call
- grep "saveForestElement" src/forest/reward.ts shows persistence
- grep "Der Wald denkt nach" src/forest/reward.ts shows placeholder text
- grep "reward-continue-btn" index.html returns match
- grep "reward-back-btn" index.html returns match
- grep "reward__placeholder" src/styles/main.css returns match
- grep "leaf-float" src/styles/main.css returns match (animated leaves)
- npx tsc --noEmit exits 0
</acceptance_criteria>
<done>Reward module with pre-generation, placeholder, fallback, IndexedDB persistence. Reward screen HTML with image area, companion comment, two action buttons. All CSS styled.</done>
</task>
<task type="auto">
<name>Task 2: Wire complete flow in app.ts with review/repeat triggers</name>
<files>src/app.ts</files>
<read_first>src/app.ts, src/forest/reward.ts, src/forest/scene.ts, src/ui/screens.ts, src/types.ts, src/storage/db.ts, src/companion/characters.ts</read_first>
<action>
Rewrite flow logic in `src/app.ts` (per D-10, D-11, D-05, D-06, TYPE-07):
**Import additions:**
```typescript
import { startPreGeneration, initRewardScreen } from './forest/reward';
import { renderForestScene } from './forest/scene';
```
**Update `startLessonFromForest(level: number)`:**
1. Determine if this is a review session (per D-05): `progress.totalSessions > 0 && progress.totalSessions % 3 === 0`
2. Determine if encouragement needed (per D-06): check `progress.lastPlayedLevels` — if last 2 entries are the same level as current, set `showEncouragement = true`
3. Start pre-generation: `startPreGeneration(db)` — fire and forget (per D-11)
4. Call updated `initLessonScreen(db, level, layout, handleLessonComplete, handleLessonCancel, isReview, showEncouragement)`
**Update `handleLessonComplete`:**
Change signature to accept stats:
```typescript
async function handleLessonComplete(
completedLevel: number,
stats: { totalCorrect: number; totalErrors: number; errorKeys: Record<string, number> }
): Promise<void>
```
1. Update progress with stats (per D-15, D-16):
```typescript
progress.totalCorrect += stats.totalCorrect;
progress.totalErrors += stats.totalErrors;
for (const [key, count] of Object.entries(stats.errorKeys)) {
progress.errorKeyCounts[key] = (progress.errorKeyCounts[key] ?? 0) + count;
}
// Track last played levels (keep last 3)
progress.lastPlayedLevels.push(completedLevel);
if (progress.lastPlayedLevels.length > 3) {
progress.lastPlayedLevels = progress.lastPlayedLevels.slice(-3);
}
```
2. Existing level advancement logic (keep as-is)
3. Save progress
4. Show REWARD screen instead of going directly to forest:
```typescript
showScreen('reward');
const elementId = await initRewardScreen(
db,
completedLevel,
progress.selectedCharacter,
() => {
// "Weiter ueben" — start next lesson
startLessonFromForest(Math.min(progress.currentLevel, MAX_LEVEL));
},
async () => {
// "Zurueck zum Wald" — show forest with new element animated
await initForestScreen(db, startLessonFromForest);
await renderForestScene(db, elementId);
showScreen('forest');
},
);
```
**Set companion avatar on reward screen:**
After `showScreen('reward')`, set the reward companion avatar:
```typescript
const companion = companions.find(c => c.type === progress.selectedCharacter);
if (companion) {
const avatar = document.getElementById('reward-companion-avatar') as HTMLImageElement;
avatar.src = companion.avatarUrl;
avatar.alt = companion.name;
}
```
**Handle backward compatibility:**
The `handleLessonComplete` in `startLessonFromForest` is passed as callback. Make sure the `initLessonScreen` onComplete signature matches (it was updated in Plan 02 to pass stats).
**Handle missing fields in existing progress data:**
When loading progress, ensure new fields have defaults:
```typescript
if (!progress.errorKeyCounts) progress.errorKeyCounts = {};
if (!progress.lastPlayedLevels) progress.lastPlayedLevels = [];
if (progress.totalCorrect === undefined) progress.totalCorrect = 0;
if (progress.totalErrors === undefined) progress.totalErrors = 0;
```
Add this migration logic after `getProgress(db)` calls.
**Parent area shortcut:**
Add global keydown listener for Ctrl+Shift+E (this will be used by Plan 05, but wire the detection here):
```typescript
document.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.shiftKey && e.key === 'E') {
e.preventDefault();
showParentCodePrompt();
}
});
```
For now, `showParentCodePrompt` can be a stub that will be implemented in Plan 05.
</action>
<verify>
<automated>npx tsc --noEmit && npx vitest run</automated>
</verify>
<acceptance_criteria>
- grep "startPreGeneration" src/app.ts returns match (pre-gen at lesson start)
- grep "initRewardScreen" src/app.ts returns match (reward after lesson)
- grep "showScreen.*reward" src/app.ts returns match (navigate to reward)
- grep "renderForestScene" src/app.ts returns match (forest update after reward)
- grep "totalSessions.*% 3" src/app.ts returns match (review trigger)
- grep "lastPlayedLevels" src/app.ts returns match (repeat detection)
- grep "errorKeyCounts" src/app.ts returns match (stats accumulation)
- grep "Ctrl.*Shift.*E" src/app.ts or grep "ctrlKey.*shiftKey" src/app.ts returns match
- npx tsc --noEmit exits 0
</acceptance_criteria>
<done>Complete flow wired: forest -> lesson (with pre-gen) -> reward (with image + comment) -> forest (with animation). Review triggers every 3rd session. Repetition protection tracks last played levels. Stats accumulated to progress.</done>
</task>
</tasks>
<verification>
- `npx tsc --noEmit` — no type errors
- `npx vitest run` — all tests pass
- Full flow testable: start lesson -> complete 3 phases -> see reward -> navigate back
</verification>
<success_criteria>
1. Reward screen shows generated/fallback image with companion comment after every lesson
2. Pre-generation starts at lesson begin, reward screen awaits it with placeholder
3. Placeholder shows "Der Wald denkt nach..." with animated leaves
4. "Weiter ueben" starts next lesson, "Zurueck zum Wald" returns to forest
5. New element saved to IndexedDB and animated in forest grid
6. Every 3rd session triggers review mode
7. After 2x same level, encouragement displayed
8. Exercise stats (correct, errors, errorKeys) accumulated in Progress
</success_criteria>
<output>
After completion, create `.planning/phases/03-komplettes-spielerlebnis/03-04-SUMMARY.md`
</output>
@@ -0,0 +1,483 @@
---
phase: 03-komplettes-spielerlebnis
plan: 05
type: execute
wave: 2
depends_on: [03-01]
files_modified:
- src/ui/parent.ts
- index.html
- src/styles/main.css
- src/app.ts
autonomous: true
requirements: [PRNT-01, PRNT-02, PRNT-03, PRNT-04]
must_haves:
truths:
- "Ctrl+Shift+E opens a code input prompt"
- "Entering '1234' navigates to parent area screen"
- "Parent area shows current level, completed sessions, practice days as calendar dots"
- "Parent area shows average accuracy and most frequent error keys"
- "Settings allow changing layout, toggling audio, changing companion, entering API key"
artifacts:
- path: "src/ui/parent.ts"
provides: "initParentScreen() with stats display and settings panel"
exports: ["initParentScreen", "showParentCodePrompt"]
- path: "index.html"
provides: "Parent screen HTML with stats overview and settings form"
contains: "parent-stats"
key_links:
- from: "src/ui/parent.ts"
to: "src/storage/db.ts"
via: "getProgress, saveProgress, getSettings, saveSettings"
pattern: "getProgress"
- from: "src/app.ts"
to: "src/ui/parent.ts"
via: "showParentCodePrompt on Ctrl+Shift+E"
pattern: "showParentCodePrompt"
---
<objective>
Build the parent area: gated access via Ctrl+Shift+E + code "1234", stats overview (level, sessions, practice days, accuracy, error keys), and settings (layout, audio, companion, API key).
Purpose: Parents can see progress and adjust settings without the child being exposed to performance metrics.
Output: Parent screen with stats and settings, access gate wired into app.ts.
</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/ROADMAP.md
@.planning/STATE.md
@.planning/phases/03-komplettes-spielerlebnis/03-01-SUMMARY.md
@src/app.ts
@src/storage/db.ts
@src/types.ts
@src/companion/characters.ts
@index.html
@src/styles/main.css
<interfaces>
<!-- From src/types.ts (after Plan 01): -->
export interface Progress {
id: 1;
currentLevel: number;
completedLevels: number[];
totalSessions: number;
sessionDates: string[]; // ISO date strings "YYYY-MM-DD"
selectedCharacter: CompanionType;
selectedLayout: KeyboardLayout;
totalCorrect: number;
totalErrors: number;
errorKeyCounts: Record<string, number>;
lastPlayedLevels: number[];
}
export interface Settings {
id: 1;
audioEnabled: boolean;
apiKey: string;
apiCallsToday: { text: number; image: number };
lastApiCallDate: string;
}
<!-- From src/storage/db.ts (existing): -->
export function getProgress(db: IDBDatabase): Promise<Progress | null>;
export function saveProgress(db: IDBDatabase, progress: Progress): Promise<void>;
export function getSettings(db: IDBDatabase): Promise<Settings | null>;
export function saveSettings(db: IDBDatabase, settings: Settings): Promise<void>;
<!-- From src/companion/characters.ts (existing): -->
export interface CompanionDefinition { type: CompanionType; name: string; emoji: string; personality: string; avatarUrl: string; }
export const companions: CompanionDefinition[];
<!-- From src/app.ts (existing): -->
export function showScreen(name: ScreenName): void;
export function getDB(): IDBDatabase;
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Parent area HTML, CSS, and access gate</name>
<files>index.html, src/styles/main.css, src/ui/parent.ts</files>
<read_first>index.html, src/styles/main.css, src/types.ts, src/storage/db.ts, src/companion/characters.ts</read_first>
<action>
**Update index.html** — replace empty `<section id="screen-parent">` (per D-14, D-17, D-18):
```html
<section id="screen-parent" class="screen">
<div class="parent">
<button class="parent__back-btn" id="parent-back-btn">&#x2190; Zurueck</button>
<h1 class="parent__title">Elternbereich</h1>
<!-- Code gate overlay -->
<div class="parent__gate" id="parent-gate">
<p>Bitte Code eingeben:</p>
<input type="password" id="parent-code-input" class="parent__code-input" maxlength="4" placeholder="____" inputmode="numeric">
<p class="parent__gate-error" id="parent-gate-error" style="display:none">Falscher Code</p>
</div>
<!-- Content (hidden until code entered) -->
<div class="parent__content" id="parent-content" style="display:none">
<section class="parent__section">
<h2>Uebersicht</h2>
<div class="parent__stats" id="parent-stats">
<div class="parent__stat">
<span class="parent__stat-label">Aktuelle Stufe</span>
<span class="parent__stat-value" id="stat-level">-</span>
</div>
<div class="parent__stat">
<span class="parent__stat-label">Einheiten</span>
<span class="parent__stat-value" id="stat-sessions">-</span>
</div>
<div class="parent__stat">
<span class="parent__stat-label">Genauigkeit</span>
<span class="parent__stat-value" id="stat-accuracy">-</span>
</div>
</div>
<div class="parent__calendar" id="parent-calendar">
<h3>Uebungstage</h3>
<div class="parent__calendar-dots" id="calendar-dots"></div>
</div>
<div class="parent__errors" id="parent-errors">
<h3>Haeufigste Fehlertasten</h3>
<div id="error-keys-list"></div>
</div>
</section>
<section class="parent__section">
<h2>Einstellungen</h2>
<div class="parent__settings">
<div class="parent__setting">
<label>Tastatur-Layout</label>
<select id="setting-layout">
<option value="de">DE (Deutschland)</option>
<option value="ch">CH (Schweiz)</option>
</select>
</div>
<div class="parent__setting">
<label>Audio</label>
<label class="parent__toggle">
<input type="checkbox" id="setting-audio">
<span>An/Aus</span>
</label>
</div>
<div class="parent__setting">
<label>Begleitfigur</label>
<select id="setting-companion"></select>
</div>
<div class="parent__setting">
<label>API-Key</label>
<input type="password" id="setting-apikey" class="parent__input" placeholder="Gemini API Key">
<button class="parent__save-btn" id="save-apikey-btn">Speichern</button>
</div>
</div>
</section>
</div>
</div>
</section>
```
**Add CSS to `src/styles/main.css`:**
```css
/* Parent Area */
.parent {
max-width: 600px;
margin: 0 auto;
padding: 1.5rem;
}
.parent__back-btn {
background: none;
border: none;
font-family: var(--font-heading);
font-size: 1rem;
color: var(--text-warm);
cursor: pointer;
margin-bottom: 1rem;
}
.parent__title {
font-family: var(--font-heading);
color: var(--text-dark);
margin-bottom: 1.5rem;
}
.parent__gate {
text-align: center;
padding: 2rem;
font-family: var(--font-body);
}
.parent__code-input {
font-size: 2rem;
text-align: center;
width: 120px;
padding: 0.5rem;
border: 2px solid var(--accent-purple);
border-radius: 8px;
font-family: var(--font-heading);
letter-spacing: 0.5rem;
margin-top: 0.5rem;
}
.parent__gate-error {
color: var(--accent-pink);
margin-top: 0.5rem;
font-size: 0.9rem;
}
.parent__section {
margin-bottom: 2rem;
}
.parent__section h2 {
font-family: var(--font-heading);
color: var(--text-dark);
border-bottom: 2px solid var(--bg-forest);
padding-bottom: 0.5rem;
margin-bottom: 1rem;
}
.parent__stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
margin-bottom: 1.5rem;
}
.parent__stat {
background: var(--bg-forest);
border-radius: 12px;
padding: 1rem;
text-align: center;
}
.parent__stat-label {
display: block;
font-family: var(--font-body);
font-size: 0.85rem;
color: var(--text-warm);
margin-bottom: 0.25rem;
}
.parent__stat-value {
display: block;
font-family: var(--font-heading);
font-size: 1.5rem;
font-weight: 700;
color: var(--text-dark);
}
.parent__calendar-dots {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 0.5rem;
}
.parent__calendar-dot {
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--accent-green);
}
.parent__calendar-dot--empty {
background: var(--bg-cream);
border: 1px solid var(--text-light);
}
.parent__errors h3,
.parent__calendar h3 {
font-family: var(--font-heading);
font-size: 1rem;
color: var(--text-warm);
margin-bottom: 0.5rem;
}
.parent__error-key {
display: inline-block;
background: var(--accent-pink);
color: white;
padding: 0.25rem 0.75rem;
border-radius: 8px;
margin: 0.25rem;
font-family: var(--font-heading);
font-size: 0.9rem;
}
.parent__settings {
display: flex;
flex-direction: column;
gap: 1rem;
}
.parent__setting {
display: flex;
align-items: center;
gap: 1rem;
}
.parent__setting label:first-child {
min-width: 120px;
font-family: var(--font-body);
color: var(--text-warm);
}
.parent__setting select,
.parent__input {
font-family: var(--font-body);
padding: 0.5rem;
border: 1px solid var(--text-light);
border-radius: 8px;
font-size: 0.95rem;
}
.parent__save-btn {
font-family: var(--font-heading);
padding: 0.5rem 1rem;
background: var(--accent-green);
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
}
```
**Create `src/ui/parent.ts`:**
```typescript
import { getProgress, saveProgress, getSettings, saveSettings } from '../storage/db';
import { companions } from '../companion/characters';
import type { CompanionType, KeyboardLayout } from '../types';
import { showScreen, getDB } from '../app';
```
**`showParentCodePrompt()`** (per D-14, PRNT-01):
- Show parent screen: `showScreen('parent')`
- Show gate, hide content
- Focus code input
- On input change, if value === '1234': hide gate, show content, call `loadParentData()`
- If value.length === 4 but wrong: show error, clear after 1.5s
**`initParentScreen(db: IDBDatabase, onBack: () => void)`:**
Wire back button to `onBack` callback.
**`loadParentData()`** (per D-17, PRNT-02, PRNT-03):
1. Get progress from IndexedDB
2. Set `#stat-level` to `progress.currentLevel`
3. Set `#stat-sessions` to `progress.totalSessions`
4. Calculate accuracy: `progress.totalCorrect + progress.totalErrors > 0 ? Math.round(progress.totalCorrect / (progress.totalCorrect + progress.totalErrors) * 100) : 0`
5. Set `#stat-accuracy` to `${accuracy}%`
6. Render calendar dots in `#calendar-dots`:
- Get last 30 days as date strings
- For each day: if in `progress.sessionDates`, render green dot, else empty dot
7. Render error keys in `#error-keys-list`:
- Sort `progress.errorKeyCounts` by value descending
- Show top 5 keys as colored badges: `<span class="parent__error-key">E (12x)</span>`
- If no errors: show "Noch keine Fehler erfasst"
**Settings (per D-18, PRNT-04):**
1. Populate companion select from `companions` array
2. Set current values from progress (layout, companion) and settings (audio, apiKey)
3. On layout change: `saveProgress(db, { ...progress, selectedLayout: value })`
4. On companion change: `saveProgress(db, { ...progress, selectedCharacter: value })`
5. On audio toggle: `saveSettings(db, { ...settings, audioEnabled: checked })`
6. On save API key: `saveSettings(db, { ...settings, apiKey: value })`
7. Each save shows brief "Gespeichert!" feedback
</action>
<verify>
<automated>npx tsc --noEmit && grep -c "showParentCodePrompt" src/ui/parent.ts && grep -c "parent-gate" index.html && grep -c "parent__calendar-dot" src/styles/main.css</automated>
</verify>
<acceptance_criteria>
- grep "showParentCodePrompt" src/ui/parent.ts returns export
- grep "1234" src/ui/parent.ts returns match (access code)
- grep "parent-gate" index.html returns match (gate overlay)
- grep "stat-level" index.html returns match (stats display)
- grep "stat-accuracy" index.html returns match
- grep "calendar-dots" index.html returns match
- grep "error-keys-list" index.html returns match
- grep "setting-layout" index.html returns match (layout setting)
- grep "setting-companion" index.html returns match (companion setting)
- grep "setting-apikey" index.html returns match (API key setting)
- grep "parent__calendar-dot" src/styles/main.css returns match
- grep "parent__error-key" src/styles/main.css returns match
- npx tsc --noEmit exits 0
</acceptance_criteria>
<done>Parent area gate with code "1234", stats overview with level/sessions/accuracy/calendar-dots/error-keys, settings for layout/audio/companion/API-key. All styled and functional.</done>
</task>
<task type="auto">
<name>Task 2: Wire parent area into app.ts</name>
<files>src/app.ts</files>
<read_first>src/app.ts, src/ui/parent.ts</read_first>
<action>
**Wire parent area access in `src/app.ts`** (per D-14, PRNT-01):
1. Import: `import { showParentCodePrompt, initParentScreen } from './ui/parent';`
2. In `initApp()`, after DB is opened, call:
```typescript
initParentScreen(db, async () => {
// Back from parent -> return to forest
await initForestScreen(db, startLessonFromForest);
showScreen('forest');
});
```
3. Replace the stub `showParentCodePrompt` reference (if Plan 04 added a stub) or add the global keydown listener:
```typescript
document.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.shiftKey && e.key === 'E') {
e.preventDefault();
showParentCodePrompt();
}
});
```
Add this in `initApp()` after DB setup. Ensure it's only added once.
4. Make sure `showParentCodePrompt` properly uses the DB reference. Since `parent.ts` imports `getDB` from `app.ts`, it can access the DB.
</action>
<verify>
<automated>npx tsc --noEmit && grep -c "showParentCodePrompt" src/app.ts && grep -c "initParentScreen" src/app.ts</automated>
</verify>
<acceptance_criteria>
- grep "import.*showParentCodePrompt" src/app.ts returns match
- grep "import.*initParentScreen" src/app.ts returns match
- grep "ctrlKey.*shiftKey" src/app.ts returns match (keyboard shortcut)
- grep "initParentScreen" src/app.ts shows it's called in initApp
- npx tsc --noEmit exits 0
</acceptance_criteria>
<done>Parent area fully wired: Ctrl+Shift+E triggers code prompt, "1234" opens parent screen, back button returns to forest. Global listener registered once in initApp.</done>
</task>
</tasks>
<verification>
- `npx tsc --noEmit` — no type errors
- `npx vitest run` — all tests pass
- Parent area accessible via Ctrl+Shift+E + "1234"
- Stats show current data, settings persist changes
</verification>
<success_criteria>
1. Ctrl+Shift+E opens code input overlay on parent screen
2. Code "1234" reveals parent content, wrong code shows error
3. Overview shows: current level, total sessions, accuracy percentage
4. Calendar dots show last 30 days with green dots for practice days
5. Error keys shown as badges, sorted by frequency, top 5
6. Settings: layout change, audio toggle, companion change, API key save — all persist to IndexedDB
7. Back button returns to forest screen
</success_criteria>
<output>
After completion, create `.planning/phases/03-komplettes-spielerlebnis/03-05-SUMMARY.md`
</output>