docs(01): create phase 1 plan — 5 plans across 4 waves
This commit is contained in:
+10
-2
@@ -29,7 +29,15 @@ Decimal phases appear between their surrounding integers in numeric order.
|
|||||||
3. Stufe 1 (F, J, Leertaste) ist spielbar: Buchstaben erscheinen einzeln, richtiger Tastendruck loest sie auf, falscher Tastendruck tut nichts
|
3. Stufe 1 (F, J, Leertaste) ist spielbar: Buchstaben erscheinen einzeln, richtiger Tastendruck loest sie auf, falscher Tastendruck tut nichts
|
||||||
4. Nach 10 richtigen Buchstaben gilt die Uebung als abgeschlossen und Stufe 2 wird freigeschaltet
|
4. Nach 10 richtigen Buchstaben gilt die Uebung als abgeschlossen und Stufe 2 wird freigeschaltet
|
||||||
5. Die Wald-Uebersicht zeigt den Stufenstatus (locked / current / completed) korrekt an
|
5. Die Wald-Uebersicht zeigt den Stufenstatus (locked / current / completed) korrekt an
|
||||||
**Plans**: TBD
|
**Plans:** 5 plans
|
||||||
|
|
||||||
|
Plans:
|
||||||
|
- [ ] 01-01-PLAN.md — Vite scaffold, project structure, CSS palette, TypeScript interfaces, IndexedDB wrapper
|
||||||
|
- [ ] 01-02-PLAN.md — Screen management, welcome screen, companion/layout selection, onboarding persistence
|
||||||
|
- [ ] 01-03-PLAN.md — QWERTZ keyboard component with finger colors, level definitions 1-6
|
||||||
|
- [ ] 01-04-PLAN.md — Typing exercise engine (TDD), adaptive tempo, lesson screen UI
|
||||||
|
- [ ] 01-05-PLAN.md — Forest overview with level map, full app flow integration, human verification
|
||||||
|
|
||||||
**UI hint**: yes
|
**UI hint**: yes
|
||||||
|
|
||||||
### Phase 2: Gemini-Integration + Asset-Pipeline
|
### Phase 2: Gemini-Integration + Asset-Pipeline
|
||||||
@@ -76,7 +84,7 @@ Phases execute in numeric order: 1 → 2 → 3 → 4
|
|||||||
|
|
||||||
| Phase | Plans Complete | Status | Completed |
|
| Phase | Plans Complete | Status | Completed |
|
||||||
|-------|----------------|--------|-----------|
|
|-------|----------------|--------|-----------|
|
||||||
| 1. Grundgerüst + Tippmechanik | 0/? | Not started | - |
|
| 1. Grundgerüst + Tippmechanik | 0/5 | Planning complete | - |
|
||||||
| 2. Gemini-Integration + Asset-Pipeline | 0/? | Not started | - |
|
| 2. Gemini-Integration + Asset-Pipeline | 0/? | Not started | - |
|
||||||
| 3. Komplettes Spielerlebnis | 0/? | Not started | - |
|
| 3. Komplettes Spielerlebnis | 0/? | Not started | - |
|
||||||
| 4. Polish + Audio | 0/? | Not started | - |
|
| 4. Polish + Audio | 0/? | Not started | - |
|
||||||
|
|||||||
@@ -0,0 +1,384 @@
|
|||||||
|
---
|
||||||
|
phase: 01-grundger-st-tippmechanik
|
||||||
|
plan: 01
|
||||||
|
type: execute
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
files_modified:
|
||||||
|
- package.json
|
||||||
|
- tsconfig.json
|
||||||
|
- vite.config.ts
|
||||||
|
- biome.json
|
||||||
|
- index.html
|
||||||
|
- .gitignore
|
||||||
|
- src/main.ts
|
||||||
|
- src/types.ts
|
||||||
|
- src/storage/db.ts
|
||||||
|
- src/styles/main.css
|
||||||
|
- public/config.example.json
|
||||||
|
autonomous: true
|
||||||
|
requirements:
|
||||||
|
- FNDN-01
|
||||||
|
- FNDN-02
|
||||||
|
- FNDN-03
|
||||||
|
- FNDN-04
|
||||||
|
- PLSH-06
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "npm run dev startet Vite auf 0.0.0.0:5173 ohne Fehler"
|
||||||
|
- "Alle Verzeichnisse aus Spec Abschnitt 13 existieren"
|
||||||
|
- "TypeScript-Interfaces sind exportiert und typisiert"
|
||||||
|
- "IndexedDB oeffnet mit 5 Stores und schliesst nicht bei reload"
|
||||||
|
- "CSS Custom Properties fuer alle Farben und Fonts sind definiert"
|
||||||
|
artifacts:
|
||||||
|
- path: "package.json"
|
||||||
|
provides: "Project dependencies and scripts"
|
||||||
|
contains: "vite"
|
||||||
|
- path: "src/types.ts"
|
||||||
|
provides: "All shared TypeScript interfaces"
|
||||||
|
exports: ["Progress", "ForestElement", "CompanionAssets", "Level", "Settings", "StyleReference"]
|
||||||
|
- path: "src/storage/db.ts"
|
||||||
|
provides: "IndexedDB wrapper with 5 stores"
|
||||||
|
exports: ["openDB", "getProgress", "saveProgress", "getSettings", "saveSettings"]
|
||||||
|
- path: "src/styles/main.css"
|
||||||
|
provides: "CSS palette and typography"
|
||||||
|
contains: "--bg-cream"
|
||||||
|
key_links:
|
||||||
|
- from: "src/storage/db.ts"
|
||||||
|
to: "src/types.ts"
|
||||||
|
via: "imports Progress, Settings, ForestElement etc."
|
||||||
|
pattern: "import.*from.*types"
|
||||||
|
- from: "src/main.ts"
|
||||||
|
to: "src/storage/db.ts"
|
||||||
|
via: "initializes DB on app start"
|
||||||
|
pattern: "openDB"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Scaffold the complete Vite project, define all TypeScript interfaces, create the IndexedDB wrapper, and establish the CSS design system.
|
||||||
|
|
||||||
|
Purpose: Every subsequent plan depends on the project existing, types being defined, storage being available, and styles being set.
|
||||||
|
Output: A runnable Vite dev server with types, IndexedDB, and CSS custom properties — no visible UI yet beyond a blank styled page.
|
||||||
|
</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/01-grundger-st-tippmechanik/01-CONTEXT.md
|
||||||
|
@SPEC.md (sections 6.2, 6.3, 7.2, 13)
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Vite project scaffold + project structure + CSS design system</name>
|
||||||
|
<files>
|
||||||
|
package.json, tsconfig.json, vite.config.ts, biome.json, index.html,
|
||||||
|
.gitignore, src/main.ts, src/styles/main.css, public/config.example.json
|
||||||
|
</files>
|
||||||
|
<read_first>
|
||||||
|
SPEC.md (section 13 for project structure, section 6.2 for tech stack, section 7.2 for CSS palette)
|
||||||
|
.planning/phases/01-grundger-st-tippmechanik/01-CONTEXT.md
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
1. Initialize Vite project with vanilla-ts template:
|
||||||
|
`npm create vite@latest . -- --template vanilla-ts` (or manually create files if directory not empty).
|
||||||
|
|
||||||
|
2. Install dependencies:
|
||||||
|
- `npm install` (vite, typescript)
|
||||||
|
- `npm install -D vitest @biomejs/biome`
|
||||||
|
|
||||||
|
3. Configure vite.config.ts (per D-01, spec section 0):
|
||||||
|
```typescript
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
export default defineConfig({
|
||||||
|
server: { host: '0.0.0.0', port: 5173 },
|
||||||
|
preview: { host: '0.0.0.0', port: 4173 }
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Configure tsconfig.json: `"strict": true`, target ES2020, module ESNext, moduleResolution bundler.
|
||||||
|
|
||||||
|
5. Configure biome.json: enable formatter and linter, indent with tabs, line width 100.
|
||||||
|
|
||||||
|
6. Configure package.json scripts:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"dev": "vite --host",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview --host --port 4173",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"lint": "biome check .",
|
||||||
|
"lint:fix": "biome check --write ."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
7. Create .gitignore with: node_modules, dist, public/config.json, *.local
|
||||||
|
|
||||||
|
8. Create ALL directories from spec section 13:
|
||||||
|
- src/game/, src/forest/, src/companion/, src/api/, src/storage/, src/ui/, src/styles/
|
||||||
|
- src/assets/companions/fee-lila/, src/assets/companions/einhorn-stella/,
|
||||||
|
src/assets/companions/fuchs-finn/, src/assets/companions/eule-elsa/
|
||||||
|
- src/assets/style-reference/, src/assets/fallback-images/
|
||||||
|
- scripts/
|
||||||
|
- public/
|
||||||
|
Place empty .gitkeep in asset directories that have no files yet.
|
||||||
|
|
||||||
|
9. Create public/config.example.json:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"geminiApiKey": "YOUR_API_KEY_HERE",
|
||||||
|
"geminiModel": "gemini-2.5-flash",
|
||||||
|
"imageModel": "gemini-3.1-flash-image-preview"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
10. Create index.html with:
|
||||||
|
- DOCTYPE html, lang="de"
|
||||||
|
- Google Fonts link: Quicksand (400, 600, 700) + Nunito (400, 600)
|
||||||
|
- Link to src/styles/main.css (via Vite)
|
||||||
|
- 5 screen sections per D-01: `<section id="screen-welcome" class="screen">`,
|
||||||
|
`<section id="screen-forest" class="screen">`,
|
||||||
|
`<section id="screen-lesson" class="screen">`,
|
||||||
|
`<section id="screen-reward" class="screen">`,
|
||||||
|
`<section id="screen-parent" class="screen">`
|
||||||
|
- Only screen-welcome gets class `screen--active` initially
|
||||||
|
- `<script type="module" src="/src/main.ts"></script>`
|
||||||
|
|
||||||
|
11. Create src/styles/main.css with ALL CSS custom properties from spec section 7.2:
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
--bg-cream: #FFF8F0;
|
||||||
|
--bg-forest: #E8F5E4;
|
||||||
|
--text-dark: #3D3225;
|
||||||
|
--text-warm: #5C4A3A;
|
||||||
|
--text-light: #8B7D6B;
|
||||||
|
--accent-green: #6DB87D;
|
||||||
|
--accent-gold: #E8B84B;
|
||||||
|
--accent-pink: #E88BAE;
|
||||||
|
--accent-purple: #9B7DC4;
|
||||||
|
--accent-blue: #7DB8D4;
|
||||||
|
--finger-l-pinky: #E8B4C8;
|
||||||
|
--finger-l-ring: #C4A8D4;
|
||||||
|
--finger-l-mid: #A8C4E0;
|
||||||
|
--finger-l-index: #A8D8B8;
|
||||||
|
--finger-r-index: #D4D8A8;
|
||||||
|
--finger-r-mid: #E0C4A8;
|
||||||
|
--finger-r-ring: #D4A8A8;
|
||||||
|
--finger-r-pinky: #C8B4E8;
|
||||||
|
--finger-thumb: #D4CFC8;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Body: font-family 'Nunito', sans-serif; background var(--bg-cream); color var(--text-dark); min font-size 16px; margin 0; padding 0.
|
||||||
|
Headings (h1–h3): font-family 'Quicksand', sans-serif.
|
||||||
|
Screen base: `.screen { display: none; opacity: 0; transition: opacity 0.3s ease; }` and `.screen--active { display: flex; flex-direction: column; align-items: center; opacity: 1; min-height: 100vh; }`.
|
||||||
|
|
||||||
|
12. Create src/main.ts as minimal entry: import './styles/main.css', console.log('Zauberwald loaded').
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/dev/workspace/zauberwald && npm run dev -- --strictPort 2>&1 & sleep 3 && curl -s http://localhost:5173 | grep -q "Zauberwald" || curl -s http://localhost:5173 | grep -q "screen-welcome" && kill %1 && echo "PASS"</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- package.json contains "vite" in devDependencies and scripts "dev", "build", "preview", "test", "lint"
|
||||||
|
- vite.config.ts contains `host: '0.0.0.0'` and `port: 5173`
|
||||||
|
- tsconfig.json contains `"strict": true`
|
||||||
|
- biome.json exists and contains `"formatter"` and `"linter"`
|
||||||
|
- .gitignore contains `public/config.json` and `node_modules`
|
||||||
|
- index.html contains 5 `<section` tags with ids screen-welcome, screen-forest, screen-lesson, screen-reward, screen-parent
|
||||||
|
- index.html contains `class="screen screen--active"` on screen-welcome
|
||||||
|
- src/styles/main.css contains `--bg-cream: #FFF8F0` and `--finger-l-pinky: #E8B4C8` and `--finger-thumb: #D4CFC8`
|
||||||
|
- src/styles/main.css contains `font-family:` with `Quicksand` and `Nunito`
|
||||||
|
- Directories exist: src/game, src/forest, src/companion, src/api, src/storage, src/ui, src/assets/companions
|
||||||
|
- public/config.example.json contains `geminiApiKey` and `geminiModel`
|
||||||
|
- `npm run dev` starts without errors
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>Vite project runs, all directories exist per spec section 13, CSS custom properties match spec section 7.2, index.html has 5 screen sections</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 2: TypeScript interfaces + IndexedDB wrapper</name>
|
||||||
|
<files>
|
||||||
|
src/types.ts, src/storage/db.ts, src/storage/db.test.ts
|
||||||
|
</files>
|
||||||
|
<read_first>
|
||||||
|
SPEC.md (section 6.3 for IndexedDB stores, section 4.1 for level structure, section 5.2 for companion types)
|
||||||
|
src/types.ts (after Task 1 creates it — may be empty placeholder)
|
||||||
|
</read_first>
|
||||||
|
<behavior>
|
||||||
|
- Test: openDB() resolves to an IDBDatabase with 5 object store names
|
||||||
|
- Test: saveProgress({ currentLevel: 1, ... }) then getProgress() returns same data
|
||||||
|
- Test: saveSettings({ audioEnabled: true, ... }) then getSettings() returns same data
|
||||||
|
- Test: getProgress() on empty DB returns null (not crash)
|
||||||
|
- Test: getSettings() on empty DB returns null (not crash)
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
1. Create src/types.ts with ALL interfaces per spec section 6.3 (per FNDN-03):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Companion types
|
||||||
|
export type CompanionType = 'fee' | 'einhorn' | 'fuchs' | 'eule';
|
||||||
|
export type KeyboardLayout = 'de' | 'ch';
|
||||||
|
|
||||||
|
// Level status for the forest map
|
||||||
|
export type LevelStatus = 'locked' | 'current' | 'completed';
|
||||||
|
|
||||||
|
// Progress store
|
||||||
|
export interface Progress {
|
||||||
|
id: 1; // singleton row
|
||||||
|
currentLevel: number;
|
||||||
|
completedLevels: number[];
|
||||||
|
totalSessions: number;
|
||||||
|
sessionDates: string[]; // ISO date strings (days only)
|
||||||
|
selectedCharacter: CompanionType;
|
||||||
|
selectedLayout: KeyboardLayout;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Companion assets store
|
||||||
|
export interface CompanionAssets {
|
||||||
|
characterType: CompanionType;
|
||||||
|
characterSheet: Blob;
|
||||||
|
avatarImage: Blob;
|
||||||
|
generatedAt: string; // ISO date
|
||||||
|
}
|
||||||
|
|
||||||
|
// Style reference store
|
||||||
|
export interface StyleReference {
|
||||||
|
id: 1; // singleton
|
||||||
|
imageBlob: Blob;
|
||||||
|
generatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forest element store
|
||||||
|
export interface ForestElement {
|
||||||
|
id?: number; // auto-increment
|
||||||
|
levelCompleted: number;
|
||||||
|
imageBlob: Blob;
|
||||||
|
imagePrompt: string;
|
||||||
|
description: string;
|
||||||
|
companionText: string;
|
||||||
|
position: { x: number; y: number };
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings store
|
||||||
|
export interface Settings {
|
||||||
|
id: 1; // singleton
|
||||||
|
audioEnabled: boolean;
|
||||||
|
apiKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Level definition (used in game/levels.ts)
|
||||||
|
export interface Level {
|
||||||
|
level: number;
|
||||||
|
newKeys: string[];
|
||||||
|
allKeys: string[]; // cumulative keys up to this level
|
||||||
|
fingerMap: Record<string, string>; // key -> CSS variable name for finger color
|
||||||
|
}
|
||||||
|
|
||||||
|
// Typing exercise state
|
||||||
|
export interface TypingExerciseState {
|
||||||
|
currentLetterIndex: number;
|
||||||
|
letters: string[];
|
||||||
|
intervalMs: number;
|
||||||
|
correctStreak: number;
|
||||||
|
totalCorrect: number;
|
||||||
|
totalErrors: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Screen names
|
||||||
|
export type ScreenName = 'welcome' | 'forest' | 'lesson' | 'reward' | 'parent';
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Create src/storage/db.ts — IndexedDB wrapper (per D-02, all 5 stores created, only progress + settings fully used):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type { Progress, Settings, ForestElement, CompanionAssets, StyleReference } from '../types';
|
||||||
|
|
||||||
|
const DB_NAME = 'zauberwald';
|
||||||
|
const DB_VERSION = 1;
|
||||||
|
|
||||||
|
export function openDB(): Promise<IDBDatabase> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||||
|
request.onupgradeneeded = (event) => {
|
||||||
|
const db = (event.target as IDBOpenDBRequest).result;
|
||||||
|
if (!db.objectStoreNames.contains('progress')) {
|
||||||
|
db.createObjectStore('progress', { keyPath: 'id' });
|
||||||
|
}
|
||||||
|
if (!db.objectStoreNames.contains('companionAssets')) {
|
||||||
|
db.createObjectStore('companionAssets', { keyPath: 'characterType' });
|
||||||
|
}
|
||||||
|
if (!db.objectStoreNames.contains('styleReference')) {
|
||||||
|
db.createObjectStore('styleReference', { keyPath: 'id' });
|
||||||
|
}
|
||||||
|
if (!db.objectStoreNames.contains('forestElements')) {
|
||||||
|
db.createObjectStore('forestElements', { keyPath: 'id', autoIncrement: true });
|
||||||
|
}
|
||||||
|
if (!db.objectStoreNames.contains('settings')) {
|
||||||
|
db.createObjectStore('settings', { keyPath: 'id' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getProgress(db: IDBDatabase): Promise<Progress | null> { ... }
|
||||||
|
export async function saveProgress(db: IDBDatabase, progress: Progress): Promise<void> { ... }
|
||||||
|
export async function getSettings(db: IDBDatabase): Promise<Settings | null> { ... }
|
||||||
|
export async function saveSettings(db: IDBDatabase, settings: Settings): Promise<void> { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
Each get/save function wraps IDB transaction in a Promise. Use 'readonly' for gets, 'readwrite' for saves. Store name matches interface.
|
||||||
|
|
||||||
|
3. Create src/storage/db.test.ts using vitest with fake-indexeddb:
|
||||||
|
- `npm install -D fake-indexeddb`
|
||||||
|
- Import `import 'fake-indexeddb/auto'` at top of test
|
||||||
|
- Test all behaviors listed above
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/dev/workspace/zauberwald && npx vitest run src/storage/db.test.ts</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- src/types.ts exports: Progress, ForestElement, CompanionAssets, Settings, StyleReference, Level, CompanionType, KeyboardLayout, LevelStatus, ScreenName, TypingExerciseState
|
||||||
|
- src/types.ts contains `type CompanionType = 'fee' | 'einhorn' | 'fuchs' | 'eule'`
|
||||||
|
- src/types.ts contains `type KeyboardLayout = 'de' | 'ch'`
|
||||||
|
- src/storage/db.ts exports: openDB, getProgress, saveProgress, getSettings, saveSettings
|
||||||
|
- src/storage/db.ts contains `const DB_NAME = 'zauberwald'`
|
||||||
|
- src/storage/db.ts contains all 5 store names: 'progress', 'companionAssets', 'styleReference', 'forestElements', 'settings'
|
||||||
|
- src/storage/db.test.ts exists and `npx vitest run src/storage/db.test.ts` exits 0
|
||||||
|
- package.json contains `fake-indexeddb` in devDependencies
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>All TypeScript interfaces exported, IndexedDB wrapper creates 5 stores, read/write for progress and settings works, all tests pass</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- `npm run dev` starts without errors on port 5173
|
||||||
|
- `npx vitest run` passes all DB tests
|
||||||
|
- `npx biome check .` shows no errors (or only warnings)
|
||||||
|
- All directories from spec section 13 exist
|
||||||
|
- index.html has 5 screen sections
|
||||||
|
- CSS custom properties match spec section 7.2 exactly
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- Vite dev server accessible at 0.0.0.0:5173
|
||||||
|
- TypeScript compiles without errors in strict mode
|
||||||
|
- IndexedDB wrapper tested and working with 5 stores
|
||||||
|
- CSS design system complete with all palette colors and typography
|
||||||
|
- Project structure matches spec section 13
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/01-grundger-st-tippmechanik/01-01-SUMMARY.md`
|
||||||
|
</output>
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
---
|
||||||
|
phase: 01-grundger-st-tippmechanik
|
||||||
|
plan: 02
|
||||||
|
type: execute
|
||||||
|
wave: 2
|
||||||
|
depends_on: ["01-01"]
|
||||||
|
files_modified:
|
||||||
|
- src/app.ts
|
||||||
|
- src/ui/screens.ts
|
||||||
|
- src/companion/characters.ts
|
||||||
|
- index.html
|
||||||
|
autonomous: true
|
||||||
|
requirements:
|
||||||
|
- FNDN-05
|
||||||
|
- ONBD-01
|
||||||
|
- ONBD-02
|
||||||
|
- ONBD-03
|
||||||
|
- ONBD-04
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "App startet und zeigt den Willkommens-Screen"
|
||||||
|
- "4 Begleitfigur-Karten sind klickbar und zeigen Emoji + Name"
|
||||||
|
- "DE/CH Layout-Toggle ist waehlbar"
|
||||||
|
- "Nach Auswahl + Los gehts wechselt die App zur Wald-Uebersicht"
|
||||||
|
- "Nach Browser-Reload bleibt die Auswahl erhalten und Willkommens-Screen wird uebersprungen"
|
||||||
|
artifacts:
|
||||||
|
- path: "src/app.ts"
|
||||||
|
provides: "Screen management and routing"
|
||||||
|
exports: ["showScreen", "initApp"]
|
||||||
|
- path: "src/ui/screens.ts"
|
||||||
|
provides: "Welcome screen logic and companion selection"
|
||||||
|
exports: ["initWelcomeScreen"]
|
||||||
|
- path: "src/companion/characters.ts"
|
||||||
|
provides: "Companion definitions (name, emoji, personality)"
|
||||||
|
exports: ["companions", "CompanionDefinition"]
|
||||||
|
key_links:
|
||||||
|
- from: "src/app.ts"
|
||||||
|
to: "src/storage/db.ts"
|
||||||
|
via: "checks progress on init to skip welcome"
|
||||||
|
pattern: "getProgress"
|
||||||
|
- from: "src/ui/screens.ts"
|
||||||
|
to: "src/storage/db.ts"
|
||||||
|
via: "saves companion + layout choice"
|
||||||
|
pattern: "saveProgress"
|
||||||
|
- from: "src/main.ts"
|
||||||
|
to: "src/app.ts"
|
||||||
|
via: "calls initApp() on DOMContentLoaded"
|
||||||
|
pattern: "initApp"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Implement screen management (CSS fade transitions between 5 screens) and the complete onboarding flow: companion selection, layout selection, persistence, and navigation to forest.
|
||||||
|
|
||||||
|
Purpose: The user must be able to start the app, choose a companion and layout, and reach the forest overview. This is the entry point for all subsequent gameplay.
|
||||||
|
Output: Working welcome-to-forest flow with IndexedDB persistence.
|
||||||
|
</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/phases/01-grundger-st-tippmechanik/01-CONTEXT.md
|
||||||
|
@.planning/phases/01-grundger-st-tippmechanik/01-01-SUMMARY.md
|
||||||
|
@SPEC.md (sections 5.2, 7.1, 7.2)
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
From src/types.ts:
|
||||||
|
```typescript
|
||||||
|
export type CompanionType = 'fee' | 'einhorn' | 'fuchs' | 'eule';
|
||||||
|
export type KeyboardLayout = 'de' | 'ch';
|
||||||
|
export type ScreenName = 'welcome' | 'forest' | 'lesson' | 'reward' | 'parent';
|
||||||
|
export interface Progress {
|
||||||
|
id: 1;
|
||||||
|
currentLevel: number;
|
||||||
|
completedLevels: number[];
|
||||||
|
totalSessions: number;
|
||||||
|
sessionDates: string[];
|
||||||
|
selectedCharacter: CompanionType;
|
||||||
|
selectedLayout: KeyboardLayout;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
From src/storage/db.ts:
|
||||||
|
```typescript
|
||||||
|
export function openDB(): Promise<IDBDatabase>;
|
||||||
|
export async function getProgress(db: IDBDatabase): Promise<Progress | null>;
|
||||||
|
export async function saveProgress(db: IDBDatabase, progress: Progress): Promise<void>;
|
||||||
|
```
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Screen management + companion definitions</name>
|
||||||
|
<files>
|
||||||
|
src/app.ts, src/companion/characters.ts
|
||||||
|
</files>
|
||||||
|
<read_first>
|
||||||
|
src/types.ts
|
||||||
|
src/storage/db.ts
|
||||||
|
src/styles/main.css
|
||||||
|
index.html
|
||||||
|
SPEC.md (section 5.2 for companion definitions, section 7.1 for screen list)
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
1. Create src/companion/characters.ts with companion definitions (per D-05, emoji placeholders):
|
||||||
|
```typescript
|
||||||
|
import type { CompanionType } from '../types';
|
||||||
|
|
||||||
|
export interface CompanionDefinition {
|
||||||
|
type: CompanionType;
|
||||||
|
name: string;
|
||||||
|
emoji: string;
|
||||||
|
personality: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const companions: CompanionDefinition[] = [
|
||||||
|
{ type: 'fee', name: 'Lila', emoji: '🧚', personality: 'Sanft, ermutigend, ein bisschen vertraeumt' },
|
||||||
|
{ type: 'einhorn', name: 'Stella', emoji: '🦄', personality: 'Froehlich, enthusiastisch, feiert jeden kleinen Erfolg' },
|
||||||
|
{ type: 'fuchs', name: 'Finn', emoji: '🦊', personality: 'Ruhig, weise, humorvoll' },
|
||||||
|
{ type: 'eule', name: 'Elsa', emoji: '🦉', personality: 'Geduldig, warm, grossmuetterlich' },
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Create src/app.ts with screen management (per D-01):
|
||||||
|
```typescript
|
||||||
|
import type { ScreenName } from './types';
|
||||||
|
import { openDB, getProgress } from './storage/db';
|
||||||
|
import { initWelcomeScreen } from './ui/screens';
|
||||||
|
|
||||||
|
let db: IDBDatabase;
|
||||||
|
|
||||||
|
export function showScreen(name: ScreenName): void {
|
||||||
|
document.querySelectorAll('.screen').forEach(el => {
|
||||||
|
el.classList.remove('screen--active');
|
||||||
|
});
|
||||||
|
const target = document.getElementById(`screen-${name}`);
|
||||||
|
if (target) {
|
||||||
|
target.classList.add('screen--active');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDB(): IDBDatabase {
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function initApp(): Promise<void> {
|
||||||
|
db = await openDB();
|
||||||
|
const progress = await getProgress(db);
|
||||||
|
if (progress && progress.selectedCharacter) {
|
||||||
|
// Returning user — skip welcome, go to forest
|
||||||
|
showScreen('forest');
|
||||||
|
} else {
|
||||||
|
// First time — show welcome
|
||||||
|
initWelcomeScreen(db, showScreen);
|
||||||
|
showScreen('welcome');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Update src/main.ts to call initApp:
|
||||||
|
```typescript
|
||||||
|
import './styles/main.css';
|
||||||
|
import { initApp } from './app';
|
||||||
|
document.addEventListener('DOMContentLoaded', initApp);
|
||||||
|
```
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/dev/workspace/zauberwald && npx tsc --noEmit</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- src/companion/characters.ts exports `companions` array with 4 entries (fee, einhorn, fuchs, eule)
|
||||||
|
- src/companion/characters.ts contains emoji strings: '🧚', '🦄', '🦊', '🦉'
|
||||||
|
- src/app.ts exports `showScreen` and `initApp`
|
||||||
|
- src/app.ts imports from `./storage/db` and `./ui/screens`
|
||||||
|
- src/app.ts contains logic: if progress exists -> showScreen('forest'), else showScreen('welcome')
|
||||||
|
- src/main.ts contains `import { initApp } from './app'` and `initApp`
|
||||||
|
- `npx tsc --noEmit` exits 0
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>Screen transitions work via CSS class toggling, companion data defined, app routes to welcome or forest based on saved progress</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Welcome screen UI + onboarding flow</name>
|
||||||
|
<files>
|
||||||
|
src/ui/screens.ts, index.html, src/styles/main.css
|
||||||
|
</files>
|
||||||
|
<read_first>
|
||||||
|
src/app.ts
|
||||||
|
src/companion/characters.ts
|
||||||
|
src/types.ts
|
||||||
|
src/storage/db.ts
|
||||||
|
src/styles/main.css
|
||||||
|
index.html
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
1. Update index.html — populate the welcome screen section with:
|
||||||
|
```html
|
||||||
|
<section id="screen-welcome" class="screen screen--active">
|
||||||
|
<div class="welcome">
|
||||||
|
<h1>Willkommen im Zauberwald!</h1>
|
||||||
|
<p class="welcome__subtitle">Waehle deine Begleitfigur:</p>
|
||||||
|
<div class="welcome__companions" id="companion-grid">
|
||||||
|
<!-- Filled dynamically by screens.ts -->
|
||||||
|
</div>
|
||||||
|
<p class="welcome__layout-label">Tastatur-Layout:</p>
|
||||||
|
<div class="welcome__layout-toggle" id="layout-toggle">
|
||||||
|
<button class="layout-btn layout-btn--active" data-layout="de">DE</button>
|
||||||
|
<button class="layout-btn" data-layout="ch">CH</button>
|
||||||
|
</div>
|
||||||
|
<button class="welcome__start-btn" id="start-btn" disabled>Los geht's!</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
```
|
||||||
|
Keep the other 4 screen sections empty (just the section tag).
|
||||||
|
|
||||||
|
2. Create src/ui/screens.ts:
|
||||||
|
```typescript
|
||||||
|
import type { CompanionType, KeyboardLayout, ScreenName, Progress } from '../types';
|
||||||
|
import { companions } from '../companion/characters';
|
||||||
|
import { saveProgress } from '../storage/db';
|
||||||
|
|
||||||
|
export function initWelcomeScreen(
|
||||||
|
db: IDBDatabase,
|
||||||
|
navigateTo: (screen: ScreenName) => void
|
||||||
|
): void {
|
||||||
|
let selectedCompanion: CompanionType | null = null;
|
||||||
|
let selectedLayout: KeyboardLayout = 'de';
|
||||||
|
|
||||||
|
const grid = document.getElementById('companion-grid')!;
|
||||||
|
const startBtn = document.getElementById('start-btn') as HTMLButtonElement;
|
||||||
|
const layoutToggle = document.getElementById('layout-toggle')!;
|
||||||
|
|
||||||
|
// Render companion cards
|
||||||
|
companions.forEach(c => {
|
||||||
|
const card = document.createElement('button');
|
||||||
|
card.className = 'companion-card';
|
||||||
|
card.dataset.companion = c.type;
|
||||||
|
card.innerHTML = `
|
||||||
|
<span class="companion-card__emoji">${c.emoji}</span>
|
||||||
|
<span class="companion-card__name">${c.name}</span>
|
||||||
|
`;
|
||||||
|
card.addEventListener('click', () => {
|
||||||
|
grid.querySelectorAll('.companion-card').forEach(el =>
|
||||||
|
el.classList.remove('companion-card--selected')
|
||||||
|
);
|
||||||
|
card.classList.add('companion-card--selected');
|
||||||
|
selectedCompanion = c.type;
|
||||||
|
startBtn.disabled = false;
|
||||||
|
});
|
||||||
|
grid.appendChild(card);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Layout toggle
|
||||||
|
layoutToggle.addEventListener('click', (e) => {
|
||||||
|
const btn = (e.target as HTMLElement).closest('.layout-btn') as HTMLButtonElement | null;
|
||||||
|
if (!btn) return;
|
||||||
|
layoutToggle.querySelectorAll('.layout-btn').forEach(el =>
|
||||||
|
el.classList.remove('layout-btn--active')
|
||||||
|
);
|
||||||
|
btn.classList.add('layout-btn--active');
|
||||||
|
selectedLayout = btn.dataset.layout as KeyboardLayout;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start button
|
||||||
|
startBtn.addEventListener('click', async () => {
|
||||||
|
if (!selectedCompanion) return;
|
||||||
|
const progress: Progress = {
|
||||||
|
id: 1,
|
||||||
|
currentLevel: 1,
|
||||||
|
completedLevels: [],
|
||||||
|
totalSessions: 0,
|
||||||
|
sessionDates: [],
|
||||||
|
selectedCharacter: selectedCompanion,
|
||||||
|
selectedLayout: selectedLayout,
|
||||||
|
};
|
||||||
|
await saveProgress(db, progress);
|
||||||
|
navigateTo('forest');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Add CSS for welcome screen in src/styles/main.css:
|
||||||
|
- `.welcome` — centered flex column, padding 2rem, max-width 600px, margin auto
|
||||||
|
- `.welcome h1` — font-family Quicksand, font-size 2rem, color var(--text-dark)
|
||||||
|
- `.welcome__companions` / `#companion-grid` — display grid, grid-template-columns repeat(2, 1fr), gap 1rem
|
||||||
|
- `.companion-card` — background white, border 3px solid transparent, border-radius 16px, padding 1.5rem, cursor pointer, transition border-color 0.2s, text-align center, display flex, flex-direction column, align-items center, gap 0.5rem
|
||||||
|
- `.companion-card:hover` — border-color var(--accent-gold)
|
||||||
|
- `.companion-card--selected` — border-color var(--accent-green), background var(--bg-forest)
|
||||||
|
- `.companion-card__emoji` — font-size 3rem
|
||||||
|
- `.companion-card__name` — font-family Quicksand, font-weight 600, font-size 1.2rem, color var(--text-dark)
|
||||||
|
- `.layout-btn` — padding 0.5rem 1.5rem, border-radius 8px, border 2px solid var(--text-light), background white, cursor pointer, font-family Nunito, font-size 1rem
|
||||||
|
- `.layout-btn--active` — background var(--accent-green), color white, border-color var(--accent-green)
|
||||||
|
- `.welcome__start-btn` — margin-top 1.5rem, padding 1rem 3rem, border-radius 12px, background var(--accent-green), color white, border none, font-family Quicksand, font-weight 700, font-size 1.3rem, cursor pointer, opacity 1, transition opacity 0.2s
|
||||||
|
- `.welcome__start-btn:disabled` — opacity 0.4, cursor not-allowed
|
||||||
|
- `.welcome__layout-toggle` — display flex, gap 0.5rem, margin-top 0.5rem
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/dev/workspace/zauberwald && npx tsc --noEmit && npm run dev -- --strictPort 2>&1 & sleep 3 && curl -s http://localhost:5173 | grep -q "companion-grid" && kill %1 && echo "PASS"</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- index.html contains `id="companion-grid"` and `id="start-btn"` and `id="layout-toggle"`
|
||||||
|
- index.html contains `data-layout="de"` and `data-layout="ch"`
|
||||||
|
- src/ui/screens.ts exports `initWelcomeScreen`
|
||||||
|
- src/ui/screens.ts contains `saveProgress(db, progress)` call
|
||||||
|
- src/ui/screens.ts contains `navigateTo('forest')` after save
|
||||||
|
- src/ui/screens.ts creates 4 companion cards with `.companion-card` class
|
||||||
|
- src/styles/main.css contains `.companion-card`, `.companion-card--selected`, `.welcome__start-btn`
|
||||||
|
- src/styles/main.css contains `.layout-btn--active`
|
||||||
|
- `npx tsc --noEmit` exits 0
|
||||||
|
- Welcome screen renders 4 companion cards and DE/CH toggle in browser
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>Welcome screen shows 4 emoji companion cards, DE/CH layout toggle, and "Los geht's" button. Selecting companion enables start. Clicking start saves to IndexedDB and navigates to forest screen. On reload, app skips welcome and shows forest directly.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- App loads and shows welcome screen with 4 companion cards
|
||||||
|
- Clicking a companion highlights it and enables "Los geht's"
|
||||||
|
- DE/CH toggle switches active state
|
||||||
|
- Clicking "Los geht's" transitions to forest screen (empty for now)
|
||||||
|
- Refreshing the page skips welcome and shows forest directly
|
||||||
|
- Clearing IndexedDB and refreshing shows welcome again
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- Complete onboarding flow: select companion + layout, start, persist, skip on return
|
||||||
|
- Screen transitions use CSS fade (opacity transition)
|
||||||
|
- All companion data matches spec section 5.2 names
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/01-grundger-st-tippmechanik/01-02-SUMMARY.md`
|
||||||
|
</output>
|
||||||
@@ -0,0 +1,407 @@
|
|||||||
|
---
|
||||||
|
phase: 01-grundger-st-tippmechanik
|
||||||
|
plan: 03
|
||||||
|
type: execute
|
||||||
|
wave: 2
|
||||||
|
depends_on: ["01-01"]
|
||||||
|
files_modified:
|
||||||
|
- src/game/levels.ts
|
||||||
|
- src/game/keyboard.ts
|
||||||
|
- src/game/levels.test.ts
|
||||||
|
- src/styles/main.css
|
||||||
|
autonomous: true
|
||||||
|
requirements:
|
||||||
|
- KYBD-01
|
||||||
|
- KYBD-02
|
||||||
|
- KYBD-03
|
||||||
|
- KYBD-04
|
||||||
|
- KYBD-05
|
||||||
|
- LEVL-01
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Bildschirmtastatur zeigt vollstaendiges QWERTZ-Layout mit allen Tasten"
|
||||||
|
- "Gelernte Tasten leuchten in ihrer Fingerfarbe, ungelernte sind grau"
|
||||||
|
- "Eine aktive Taste pulsiert sanft (CSS-Animation)"
|
||||||
|
- "Tastendruck zeigt press-Animation (scale down)"
|
||||||
|
- "Stufen 1-6 sind als Daten definiert mit korrekten Tastenzuordnungen"
|
||||||
|
- "Beide Layouts DE/CH sind als Daten hinterlegt"
|
||||||
|
artifacts:
|
||||||
|
- path: "src/game/levels.ts"
|
||||||
|
provides: "Level definitions 1-6 with key sets and finger mappings"
|
||||||
|
exports: ["levels", "getLevelByNumber", "getKeysUpToLevel"]
|
||||||
|
- path: "src/game/keyboard.ts"
|
||||||
|
provides: "QWERTZ keyboard renderer with finger colors"
|
||||||
|
exports: ["renderKeyboard", "highlightKey", "pressKey", "updateKeyboardForLevel"]
|
||||||
|
key_links:
|
||||||
|
- from: "src/game/keyboard.ts"
|
||||||
|
to: "src/game/levels.ts"
|
||||||
|
via: "reads level data to determine which keys are active"
|
||||||
|
pattern: "import.*levels"
|
||||||
|
- from: "src/game/keyboard.ts"
|
||||||
|
to: "src/styles/main.css"
|
||||||
|
via: "uses finger color CSS variables"
|
||||||
|
pattern: "--finger-"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Create the QWERTZ keyboard component with finger-colored keys and the level definitions (stages 1-6). The keyboard is the primary visual feedback during typing exercises.
|
||||||
|
|
||||||
|
Purpose: The keyboard component is needed by the typing exercise (Plan 04) and the forest overview (Plan 05). Level definitions drive all gameplay.
|
||||||
|
Output: Renderable keyboard with finger colors, pulse/press animations, and complete level 1-6 data.
|
||||||
|
</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
|
||||||
|
@SPEC.md (sections 4.1, 8.1, 8.2, 8.3)
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
From src/types.ts:
|
||||||
|
```typescript
|
||||||
|
export type KeyboardLayout = 'de' | 'ch';
|
||||||
|
|
||||||
|
export interface Level {
|
||||||
|
level: number;
|
||||||
|
newKeys: string[];
|
||||||
|
allKeys: string[];
|
||||||
|
fingerMap: Record<string, string>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 1: Level definitions with key sets and finger mappings</name>
|
||||||
|
<files>
|
||||||
|
src/game/levels.ts, src/game/levels.test.ts
|
||||||
|
</files>
|
||||||
|
<read_first>
|
||||||
|
src/types.ts
|
||||||
|
SPEC.md (section 4.1 for progression table, section 8.3 for finger assignments)
|
||||||
|
</read_first>
|
||||||
|
<behavior>
|
||||||
|
- Test: levels array has exactly 6 entries
|
||||||
|
- Test: Level 1 newKeys = ['f', 'j', ' '] (F, J, Space)
|
||||||
|
- Test: Level 2 newKeys = ['d', 'k']
|
||||||
|
- Test: Level 3 newKeys = ['s', 'l']
|
||||||
|
- Test: Level 4 newKeys = ['a', 'ö']
|
||||||
|
- Test: Level 5 newKeys = ['g', 'h']
|
||||||
|
- Test: Level 6 newKeys = ['e', 'i']
|
||||||
|
- Test: Level 3 allKeys includes all keys from levels 1-3
|
||||||
|
- Test: getLevelByNumber(1) returns level 1 object
|
||||||
|
- Test: getKeysUpToLevel(3) returns cumulative keys for levels 1-3
|
||||||
|
- Test: Each key in allKeys has a fingerMap entry
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
Create src/game/levels.ts with level definitions per spec section 4.1 and finger assignments per section 8.3:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type { Level } from '../types';
|
||||||
|
|
||||||
|
// Finger color CSS variable mapping per spec section 8.3
|
||||||
|
// Left hand: pinky=Q/A/Y/1, ring=W/S/X/2, mid=E/D/C/3, index=R/F/V/T/G/B/4/5
|
||||||
|
// Right hand: index=U/J/M/Z/H/N/6/7, mid=I/K/,/8, ring=O/L/./9, pinky=P/Ö/Ä/Ü/0/ß
|
||||||
|
// Thumb: Space
|
||||||
|
|
||||||
|
const fingerMap: Record<string, string> = {
|
||||||
|
// Left pinky
|
||||||
|
'q': '--finger-l-pinky', 'a': '--finger-l-pinky', 'y': '--finger-l-pinky',
|
||||||
|
// Left ring
|
||||||
|
'w': '--finger-l-ring', 's': '--finger-l-ring', 'x': '--finger-l-ring',
|
||||||
|
// Left middle
|
||||||
|
'e': '--finger-l-mid', 'd': '--finger-l-mid', 'c': '--finger-l-mid',
|
||||||
|
// Left index
|
||||||
|
'r': '--finger-l-index', 'f': '--finger-l-index', 'v': '--finger-l-index',
|
||||||
|
't': '--finger-l-index', 'g': '--finger-l-index', 'b': '--finger-l-index',
|
||||||
|
// Right index
|
||||||
|
'u': '--finger-r-index', 'j': '--finger-r-index', 'm': '--finger-r-index',
|
||||||
|
'z': '--finger-r-index', 'h': '--finger-r-index', 'n': '--finger-r-index',
|
||||||
|
// Right middle
|
||||||
|
'i': '--finger-r-mid', 'k': '--finger-r-mid',
|
||||||
|
// Right ring
|
||||||
|
'o': '--finger-r-ring', 'l': '--finger-r-ring',
|
||||||
|
// Right pinky
|
||||||
|
'p': '--finger-r-pinky', 'ö': '--finger-r-pinky', 'ä': '--finger-r-pinky', 'ü': '--finger-r-pinky',
|
||||||
|
// Thumb
|
||||||
|
' ': '--finger-thumb',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const levels: Level[] = [
|
||||||
|
{ level: 1, newKeys: ['f', 'j', ' '], allKeys: ['f', 'j', ' '], fingerMap },
|
||||||
|
{ level: 2, newKeys: ['d', 'k'], allKeys: ['f', 'j', ' ', 'd', 'k'], fingerMap },
|
||||||
|
{ level: 3, newKeys: ['s', 'l'], allKeys: ['f', 'j', ' ', 'd', 'k', 's', 'l'], fingerMap },
|
||||||
|
{ level: 4, newKeys: ['a', 'ö'], allKeys: ['f', 'j', ' ', 'd', 'k', 's', 'l', 'a', 'ö'], fingerMap },
|
||||||
|
{ level: 5, newKeys: ['g', 'h'], allKeys: ['f', 'j', ' ', 'd', 'k', 's', 'l', 'a', 'ö', 'g', 'h'], fingerMap },
|
||||||
|
{ level: 6, newKeys: ['e', 'i'], allKeys: ['f', 'j', ' ', 'd', 'k', 's', 'l', 'a', 'ö', 'g', 'h', 'e', 'i'], fingerMap },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getLevelByNumber(n: number): Level | undefined {
|
||||||
|
return levels.find(l => l.level === n);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getKeysUpToLevel(n: number): string[] {
|
||||||
|
const level = getLevelByNumber(n);
|
||||||
|
return level ? level.allKeys : [];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Create src/game/levels.test.ts testing all behaviors listed above.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/dev/workspace/zauberwald && npx vitest run src/game/levels.test.ts</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- src/game/levels.ts exports `levels` (array of 6), `getLevelByNumber`, `getKeysUpToLevel`
|
||||||
|
- levels[0].newKeys deep equals ['f', 'j', ' ']
|
||||||
|
- levels[5].newKeys deep equals ['e', 'i']
|
||||||
|
- levels[5].allKeys contains 13 keys (f,j,space,d,k,s,l,a,ö,g,h,e,i)
|
||||||
|
- fingerMap maps 'f' to '--finger-l-index' and 'j' to '--finger-r-index'
|
||||||
|
- fingerMap maps ' ' to '--finger-thumb'
|
||||||
|
- `npx vitest run src/game/levels.test.ts` exits 0
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>Levels 1-6 defined with correct key sets per spec progression table, finger mapping matches spec section 8.3, all tests pass</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: QWERTZ keyboard renderer with finger colors and animations</name>
|
||||||
|
<files>
|
||||||
|
src/game/keyboard.ts, src/styles/main.css
|
||||||
|
</files>
|
||||||
|
<read_first>
|
||||||
|
src/game/levels.ts
|
||||||
|
src/types.ts
|
||||||
|
src/styles/main.css
|
||||||
|
SPEC.md (section 8.1 for keyboard layout, section 8.2 for DE/CH differences)
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
1. Create src/game/keyboard.ts:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type { KeyboardLayout } from '../types';
|
||||||
|
import { getLevelByNumber } from './levels';
|
||||||
|
|
||||||
|
// QWERTZ rows — each row is an array of key labels
|
||||||
|
// For DE and CH layouts (identical for letters, differ on special chars above level 14)
|
||||||
|
const KEYBOARD_ROWS_DE = [
|
||||||
|
['1', '2', '3', '4', '5', '6', '7', '8', '9', '0', 'ß'],
|
||||||
|
['q', 'w', 'e', 'r', 't', 'z', 'u', 'i', 'o', 'p', 'ü'],
|
||||||
|
['a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', 'ö', 'ä'],
|
||||||
|
['y', 'x', 'c', 'v', 'b', 'n', 'm'],
|
||||||
|
[' '], // Spacebar
|
||||||
|
];
|
||||||
|
|
||||||
|
const KEYBOARD_ROWS_CH = [
|
||||||
|
['1', '2', '3', '4', '5', '6', '7', '8', '9', '0'],
|
||||||
|
['q', 'w', 'e', 'r', 't', 'z', 'u', 'i', 'o', 'p', 'ü'],
|
||||||
|
['a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', 'ö', 'ä'],
|
||||||
|
['y', 'x', 'c', 'v', 'b', 'n', 'm'],
|
||||||
|
[' '],
|
||||||
|
];
|
||||||
|
|
||||||
|
// Finger color mapping (shared with levels.ts fingerMap)
|
||||||
|
const fingerColorMap: Record<string, string> = {
|
||||||
|
'q': '--finger-l-pinky', 'a': '--finger-l-pinky', 'y': '--finger-l-pinky', '1': '--finger-l-pinky',
|
||||||
|
'w': '--finger-l-ring', 's': '--finger-l-ring', 'x': '--finger-l-ring', '2': '--finger-l-ring',
|
||||||
|
'e': '--finger-l-mid', 'd': '--finger-l-mid', 'c': '--finger-l-mid', '3': '--finger-l-mid',
|
||||||
|
'r': '--finger-l-index', 'f': '--finger-l-index', 'v': '--finger-l-index', '4': '--finger-l-index',
|
||||||
|
't': '--finger-l-index', 'g': '--finger-l-index', 'b': '--finger-l-index', '5': '--finger-l-index',
|
||||||
|
'z': '--finger-r-index', 'h': '--finger-r-index', 'n': '--finger-r-index', '6': '--finger-r-index',
|
||||||
|
'u': '--finger-r-index', 'j': '--finger-r-index', 'm': '--finger-r-index', '7': '--finger-r-index',
|
||||||
|
'i': '--finger-r-mid', 'k': '--finger-r-mid', '8': '--finger-r-mid',
|
||||||
|
'o': '--finger-r-ring', 'l': '--finger-r-ring', '9': '--finger-r-ring',
|
||||||
|
'p': '--finger-r-pinky', 'ö': '--finger-r-pinky', 'ä': '--finger-r-pinky',
|
||||||
|
'ü': '--finger-r-pinky', '0': '--finger-r-pinky', 'ß': '--finger-r-pinky',
|
||||||
|
' ': '--finger-thumb',
|
||||||
|
};
|
||||||
|
|
||||||
|
let keyboardContainer: HTMLElement | null = null;
|
||||||
|
let keyElements: Map<string, HTMLElement> = new Map();
|
||||||
|
|
||||||
|
export function renderKeyboard(
|
||||||
|
container: HTMLElement,
|
||||||
|
layout: KeyboardLayout,
|
||||||
|
currentLevel: number
|
||||||
|
): void {
|
||||||
|
keyboardContainer = container;
|
||||||
|
keyElements.clear();
|
||||||
|
container.innerHTML = '';
|
||||||
|
container.className = 'keyboard';
|
||||||
|
|
||||||
|
const rows = layout === 'ch' ? KEYBOARD_ROWS_CH : KEYBOARD_ROWS_DE;
|
||||||
|
const level = getLevelByNumber(currentLevel);
|
||||||
|
const activeKeys = level ? level.allKeys : [];
|
||||||
|
|
||||||
|
rows.forEach((row, rowIndex) => {
|
||||||
|
const rowEl = document.createElement('div');
|
||||||
|
rowEl.className = `keyboard__row keyboard__row--${rowIndex}`;
|
||||||
|
|
||||||
|
row.forEach(key => {
|
||||||
|
const keyEl = document.createElement('div');
|
||||||
|
const isActive = activeKeys.includes(key);
|
||||||
|
const colorVar = fingerColorMap[key];
|
||||||
|
|
||||||
|
keyEl.className = 'keyboard__key';
|
||||||
|
if (key === ' ') keyEl.classList.add('keyboard__key--space');
|
||||||
|
if (isActive && colorVar) {
|
||||||
|
keyEl.style.backgroundColor = `var(${colorVar})`;
|
||||||
|
keyEl.classList.add('keyboard__key--active');
|
||||||
|
} else {
|
||||||
|
keyEl.classList.add('keyboard__key--inactive');
|
||||||
|
}
|
||||||
|
|
||||||
|
keyEl.textContent = key === ' ' ? '' : key.toUpperCase();
|
||||||
|
keyEl.dataset.key = key;
|
||||||
|
keyElements.set(key, keyEl);
|
||||||
|
rowEl.appendChild(keyEl);
|
||||||
|
});
|
||||||
|
|
||||||
|
container.appendChild(rowEl);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function highlightKey(key: string): void {
|
||||||
|
// Remove previous highlights
|
||||||
|
keyElements.forEach(el => el.classList.remove('keyboard__key--highlight'));
|
||||||
|
const el = keyElements.get(key.toLowerCase());
|
||||||
|
if (el) el.classList.add('keyboard__key--highlight');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pressKey(key: string): void {
|
||||||
|
const el = keyElements.get(key.toLowerCase());
|
||||||
|
if (el) {
|
||||||
|
el.classList.add('keyboard__key--pressed');
|
||||||
|
setTimeout(() => el.classList.remove('keyboard__key--pressed'), 150);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateKeyboardForLevel(currentLevel: number): void {
|
||||||
|
const level = getLevelByNumber(currentLevel);
|
||||||
|
const activeKeys = level ? level.allKeys : [];
|
||||||
|
keyElements.forEach((el, key) => {
|
||||||
|
const colorVar = fingerColorMap[key];
|
||||||
|
if (activeKeys.includes(key) && colorVar) {
|
||||||
|
el.style.backgroundColor = `var(${colorVar})`;
|
||||||
|
el.classList.remove('keyboard__key--inactive');
|
||||||
|
el.classList.add('keyboard__key--active');
|
||||||
|
} else {
|
||||||
|
el.style.backgroundColor = '';
|
||||||
|
el.classList.remove('keyboard__key--active');
|
||||||
|
el.classList.add('keyboard__key--inactive');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Add keyboard CSS to src/styles/main.css:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Keyboard */
|
||||||
|
.keyboard {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 1rem;
|
||||||
|
background: rgba(255, 255, 255, 0.6);
|
||||||
|
border-radius: 12px;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keyboard__row {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keyboard__key {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-family: 'Quicksand', sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--text-dark);
|
||||||
|
background: #e0e0e0;
|
||||||
|
transition: transform 0.1s ease, background-color 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keyboard__key--space {
|
||||||
|
width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keyboard__key--inactive {
|
||||||
|
background: #e0e0e0;
|
||||||
|
color: var(--text-light);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keyboard__key--active {
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pulse animation for the target key (KYBD-03) */
|
||||||
|
.keyboard__key--highlight {
|
||||||
|
animation: key-pulse 1.2s ease-in-out infinite;
|
||||||
|
box-shadow: 0 0 12px rgba(232, 184, 75, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes key-pulse {
|
||||||
|
0%, 100% { transform: scale(1); }
|
||||||
|
50% { transform: scale(1.08); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Press animation (KYBD-03) */
|
||||||
|
.keyboard__key--pressed {
|
||||||
|
transform: scale(0.9) !important;
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/dev/workspace/zauberwald && npx tsc --noEmit && echo "PASS"</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- src/game/keyboard.ts exports `renderKeyboard`, `highlightKey`, `pressKey`, `updateKeyboardForLevel`
|
||||||
|
- src/game/keyboard.ts contains KEYBOARD_ROWS_DE with 5 rows (numbers, top, home, bottom, space)
|
||||||
|
- src/game/keyboard.ts contains KEYBOARD_ROWS_CH with 5 rows
|
||||||
|
- src/game/keyboard.ts contains fingerColorMap with entries for 'f' -> '--finger-l-index', 'j' -> '--finger-r-index', ' ' -> '--finger-thumb'
|
||||||
|
- src/styles/main.css contains `.keyboard__key--highlight` with `animation: key-pulse`
|
||||||
|
- src/styles/main.css contains `@keyframes key-pulse`
|
||||||
|
- src/styles/main.css contains `.keyboard__key--pressed` with `transform: scale(0.9)`
|
||||||
|
- src/styles/main.css contains `.keyboard__key--inactive` with `opacity: 0.5`
|
||||||
|
- `npx tsc --noEmit` exits 0
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>Full QWERTZ keyboard renders with finger colors for active keys, grayed inactive keys, pulse animation on target key, press animation on keydown. Both DE and CH layouts supported.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- `npx vitest run src/game/levels.test.ts` passes
|
||||||
|
- `npx tsc --noEmit` passes
|
||||||
|
- Keyboard renders correctly when manually instantiated (verified in Plan 05 integration)
|
||||||
|
- Level data is correct per spec progression table
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- Levels 1-6 defined with correct key progressions matching spec section 4.1
|
||||||
|
- Keyboard renders full QWERTZ layout with finger colors from spec section 8.3
|
||||||
|
- Pulse and press animations work via CSS classes
|
||||||
|
- Both DE and CH layout data exist
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/01-grundger-st-tippmechanik/01-03-SUMMARY.md`
|
||||||
|
</output>
|
||||||
@@ -0,0 +1,447 @@
|
|||||||
|
---
|
||||||
|
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>
|
||||||
@@ -0,0 +1,371 @@
|
|||||||
|
---
|
||||||
|
phase: 01-grundger-st-tippmechanik
|
||||||
|
plan: 05
|
||||||
|
type: execute
|
||||||
|
wave: 4
|
||||||
|
depends_on: ["01-02", "01-04"]
|
||||||
|
files_modified:
|
||||||
|
- src/ui/screens.ts
|
||||||
|
- src/forest/scene.ts
|
||||||
|
- index.html
|
||||||
|
- src/styles/main.css
|
||||||
|
- src/app.ts
|
||||||
|
autonomous: false
|
||||||
|
requirements:
|
||||||
|
- FRST-04
|
||||||
|
- FRST-05
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Wald-Uebersicht zeigt Stufenkarte mit Status locked/current/completed"
|
||||||
|
- "Weiter-ueben-Button startet die naechste offene Stufe"
|
||||||
|
- "Nach Lesson-Abschluss kehrt die App zur Wald-Uebersicht zurueck und zeigt aktualisierten Status"
|
||||||
|
- "Gesamter Flow funktioniert: Welcome -> Forest -> Lesson -> Forest (mit Fortschritt)"
|
||||||
|
artifacts:
|
||||||
|
- path: "src/forest/scene.ts"
|
||||||
|
provides: "Forest overview with level map"
|
||||||
|
exports: ["initForestScreen", "renderLevelMap"]
|
||||||
|
key_links:
|
||||||
|
- from: "src/forest/scene.ts"
|
||||||
|
to: "src/storage/db.ts"
|
||||||
|
via: "loads progress to determine level statuses"
|
||||||
|
pattern: "getProgress"
|
||||||
|
- from: "src/forest/scene.ts"
|
||||||
|
to: "src/game/levels.ts"
|
||||||
|
via: "reads level definitions for the map"
|
||||||
|
pattern: "import.*levels"
|
||||||
|
- from: "src/forest/scene.ts"
|
||||||
|
to: "src/app.ts"
|
||||||
|
via: "calls startLesson when user clicks a level or continue button"
|
||||||
|
pattern: "startLesson|navigateTo"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Build the forest overview screen with the level map (Stufenkarte) showing locked/current/completed states, and wire together the complete app flow: welcome -> forest -> lesson -> forest.
|
||||||
|
|
||||||
|
Purpose: This is the final integration plan that connects all pieces into a playable app. The user can see their progress and start lessons from the forest screen.
|
||||||
|
Output: Complete playable Phase 1 app with the core loop working end-to-end.
|
||||||
|
</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-02-SUMMARY.md
|
||||||
|
@.planning/phases/01-grundger-st-tippmechanik/01-03-SUMMARY.md
|
||||||
|
@.planning/phases/01-grundger-st-tippmechanik/01-04-SUMMARY.md
|
||||||
|
@SPEC.md (section 7.1 for screen structure)
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
From src/types.ts:
|
||||||
|
```typescript
|
||||||
|
export type LevelStatus = 'locked' | 'current' | 'completed';
|
||||||
|
export type ScreenName = 'welcome' | 'forest' | 'lesson' | 'reward' | 'parent';
|
||||||
|
export interface Progress {
|
||||||
|
id: 1;
|
||||||
|
currentLevel: number;
|
||||||
|
completedLevels: number[];
|
||||||
|
totalSessions: number;
|
||||||
|
sessionDates: string[];
|
||||||
|
selectedCharacter: CompanionType;
|
||||||
|
selectedLayout: KeyboardLayout;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
From src/game/levels.ts:
|
||||||
|
```typescript
|
||||||
|
export const levels: Level[];
|
||||||
|
```
|
||||||
|
|
||||||
|
From src/app.ts:
|
||||||
|
```typescript
|
||||||
|
export function showScreen(name: ScreenName): void;
|
||||||
|
export function getDB(): IDBDatabase;
|
||||||
|
```
|
||||||
|
|
||||||
|
From src/storage/db.ts:
|
||||||
|
```typescript
|
||||||
|
export async function getProgress(db: IDBDatabase): Promise<Progress | null>;
|
||||||
|
```
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Forest screen with level map and continue button</name>
|
||||||
|
<files>
|
||||||
|
src/forest/scene.ts, index.html, src/styles/main.css
|
||||||
|
</files>
|
||||||
|
<read_first>
|
||||||
|
src/types.ts
|
||||||
|
src/game/levels.ts
|
||||||
|
src/storage/db.ts
|
||||||
|
src/app.ts
|
||||||
|
src/ui/screens.ts
|
||||||
|
index.html
|
||||||
|
src/styles/main.css
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
1. Update index.html — populate forest screen:
|
||||||
|
```html
|
||||||
|
<section id="screen-forest" class="screen">
|
||||||
|
<div class="forest">
|
||||||
|
<h1 class="forest__title">Dein Zauberwald</h1>
|
||||||
|
<div class="forest__level-map" id="level-map">
|
||||||
|
<!-- Level cards rendered dynamically -->
|
||||||
|
</div>
|
||||||
|
<button class="forest__continue-btn" id="continue-btn">Weiter ueben</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Create src/forest/scene.ts:
|
||||||
|
```typescript
|
||||||
|
import type { Progress, LevelStatus, ScreenName } from '../types';
|
||||||
|
import { levels } from '../game/levels';
|
||||||
|
import { getProgress } from '../storage/db';
|
||||||
|
|
||||||
|
export function getLevelStatus(levelNum: number, progress: Progress): LevelStatus {
|
||||||
|
if (progress.completedLevels.includes(levelNum)) return 'completed';
|
||||||
|
if (levelNum === progress.currentLevel) return 'current';
|
||||||
|
return 'locked';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderLevelMap(
|
||||||
|
container: HTMLElement,
|
||||||
|
progress: Progress,
|
||||||
|
onLevelClick: (level: number) => void
|
||||||
|
): void {
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
|
levels.forEach(level => {
|
||||||
|
const status = getLevelStatus(level.level, progress);
|
||||||
|
const card = document.createElement('button');
|
||||||
|
card.className = `level-card level-card--${status}`;
|
||||||
|
card.dataset.level = String(level.level);
|
||||||
|
|
||||||
|
const statusEmoji = status === 'completed' ? '✓' : status === 'current' ? '→' : '🔒';
|
||||||
|
const keysDisplay = level.newKeys
|
||||||
|
.filter(k => k !== ' ')
|
||||||
|
.map(k => k.toUpperCase())
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
|
card.innerHTML = `
|
||||||
|
<span class="level-card__number">Stufe ${level.level}</span>
|
||||||
|
<span class="level-card__keys">${keysDisplay}${level.newKeys.includes(' ') ? ' + Leertaste' : ''}</span>
|
||||||
|
<span class="level-card__status">${statusEmoji}</span>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Only current and completed levels are clickable
|
||||||
|
if (status !== 'locked') {
|
||||||
|
card.addEventListener('click', () => onLevelClick(level.level));
|
||||||
|
} else {
|
||||||
|
card.disabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.appendChild(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function initForestScreen(
|
||||||
|
db: IDBDatabase,
|
||||||
|
onStartLesson: (level: number) => void
|
||||||
|
): Promise<void> {
|
||||||
|
const mapContainer = document.getElementById('level-map')!;
|
||||||
|
const continueBtn = document.getElementById('continue-btn') as HTMLButtonElement;
|
||||||
|
const progress = await getProgress(db);
|
||||||
|
if (!progress) return;
|
||||||
|
|
||||||
|
renderLevelMap(mapContainer, progress, onStartLesson);
|
||||||
|
|
||||||
|
// Continue button starts the current level
|
||||||
|
continueBtn.onclick = () => onStartLesson(progress.currentLevel);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Update src/app.ts to wire the complete flow:
|
||||||
|
- Import `initForestScreen` from `./forest/scene`
|
||||||
|
- Import `initLessonScreen` from `./ui/screens`
|
||||||
|
- In `initApp()`: after checking progress, if user exists, call `initForestScreen(db, startLesson)` before showing forest screen
|
||||||
|
- `startLesson(level)` function: show lesson screen, call initLessonScreen with onComplete callback
|
||||||
|
- `onComplete` callback: update progress (add to completedLevels, increment currentLevel if applicable, increment totalSessions, add today's date), save to DB, re-init forest screen, show forest screen
|
||||||
|
- After welcome screen's "Los geht's" navigates to forest: also call initForestScreen
|
||||||
|
|
||||||
|
The full flow is:
|
||||||
|
- First visit: Welcome -> select companion/layout -> "Los geht's" -> Forest (level 1 = current)
|
||||||
|
- Click "Weiter ueben" or level 1 card -> Lesson screen (type letters)
|
||||||
|
- Complete lesson -> Forest (level 1 = completed, level 2 = current)
|
||||||
|
- Reload -> Forest (skips welcome, shows current state)
|
||||||
|
|
||||||
|
4. Add CSS for forest and level map to src/styles/main.css:
|
||||||
|
```css
|
||||||
|
.forest {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2rem;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--bg-forest);
|
||||||
|
}
|
||||||
|
|
||||||
|
.forest__title {
|
||||||
|
font-family: 'Quicksand', sans-serif;
|
||||||
|
font-size: 2rem;
|
||||||
|
color: var(--text-dark);
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forest__level-map {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 1rem;
|
||||||
|
max-width: 500px;
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
font-family: 'Nunito', sans-serif;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s ease, border-color 0.2s ease;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-card:hover:not(:disabled) {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-card--current {
|
||||||
|
border-color: var(--accent-gold);
|
||||||
|
background: #FFF8E0;
|
||||||
|
box-shadow: 0 2px 8px rgba(232, 184, 75, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-card--completed {
|
||||||
|
border-color: var(--accent-green);
|
||||||
|
background: #E8F5E4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-card--locked {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
background: #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-card__number {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-card__keys {
|
||||||
|
font-family: 'Quicksand', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: var(--accent-purple);
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-card__status {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forest__continue-btn {
|
||||||
|
padding: 1rem 3rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--accent-green);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
font-family: 'Quicksand', sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forest__continue-btn:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/dev/workspace/zauberwald && npx tsc --noEmit && npx vitest run</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- src/forest/scene.ts exports `initForestScreen`, `renderLevelMap`, `getLevelStatus`
|
||||||
|
- src/forest/scene.ts contains `getLevelStatus` returning 'completed', 'current', or 'locked'
|
||||||
|
- src/forest/scene.ts creates 6 level cards with class `level-card`
|
||||||
|
- src/forest/scene.ts contains `level-card--${status}` for dynamic CSS class
|
||||||
|
- index.html contains `id="level-map"` and `id="continue-btn"`
|
||||||
|
- src/app.ts calls `initForestScreen` and `initLessonScreen` in the correct flow
|
||||||
|
- src/app.ts has lesson completion handler that calls `saveProgress` with updated `completedLevels` and `currentLevel`
|
||||||
|
- src/styles/main.css contains `.level-card--current`, `.level-card--completed`, `.level-card--locked`
|
||||||
|
- src/styles/main.css contains `.forest__continue-btn`
|
||||||
|
- `npx tsc --noEmit` exits 0
|
||||||
|
- `npx vitest run` exits 0
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>Forest screen shows 6-level map with correct statuses, continue button starts current level, level cards are clickable for completed/current levels, complete app flow works end-to-end</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="checkpoint:human-verify" gate="blocking">
|
||||||
|
<name>Task 2: Verify complete Phase 1 app flow</name>
|
||||||
|
<files>no files modified (verification only)</files>
|
||||||
|
<action>
|
||||||
|
Human verifies the complete Phase 1 app flow in the browser. No code changes in this task.
|
||||||
|
|
||||||
|
Open the app at http://vps-ip:5173 and verify these 7 steps:
|
||||||
|
|
||||||
|
1. Welcome screen: 4 companion cards with emojis and DE/CH toggle visible. Select a companion, click "Los geht's".
|
||||||
|
2. Forest overview: Level map shows Stufe 1 as current (gold border), Stufen 2-6 as locked (grayed). "Weiter ueben" button visible.
|
||||||
|
3. Click "Weiter ueben": Lesson screen appears with a large letter (F, J, or space symbol) and QWERTZ keyboard below. Target key pulses on keyboard.
|
||||||
|
4. Type the shown letter: Letter dissolves, next letter appears. Type all 8-12 letters correctly.
|
||||||
|
5. After completion: Returns to forest. Stufe 1 now shows checkmark, Stufe 2 is current (gold).
|
||||||
|
6. Reload the page: Skips welcome, goes directly to forest with preserved state.
|
||||||
|
7. Wrong key test: During a lesson, press a wrong key — nothing happens to the letter, correct key continues pulsing.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/dev/workspace/zauberwald && npx tsc --noEmit && npx vitest run && echo "ALL AUTOMATED CHECKS PASS"</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- All 7 manual verification steps pass in browser
|
||||||
|
- `npx tsc --noEmit` exits 0
|
||||||
|
- `npx vitest run` exits 0
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>Human confirmed all 7 verification steps pass: welcome flow, forest map, lesson typing, level progression, persistence across reload, correct error handling</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- Complete flow: Welcome -> Forest -> Lesson -> Forest works
|
||||||
|
- Level map shows correct statuses after completing a level
|
||||||
|
- Typing exercise handles correct/wrong input per spec
|
||||||
|
- Progress persists across page reloads
|
||||||
|
- Keyboard shows finger colors for learned keys, gray for unlearned
|
||||||
|
- All tests pass: `npx vitest run`
|
||||||
|
- TypeScript compiles: `npx tsc --noEmit`
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- Forest overview shows 6 levels with locked/current/completed states
|
||||||
|
- "Weiter ueben" button starts the current level's typing exercise
|
||||||
|
- After completing a lesson, level status updates correctly
|
||||||
|
- Full app loop is playable from fresh start through at least level 1 completion
|
||||||
|
- Progress survives browser reload
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/01-grundger-st-tippmechanik/01-05-SUMMARY.md`
|
||||||
|
</output>
|
||||||
Reference in New Issue
Block a user