2026-03-29 11:06:54 +02:00
import type {
CompanionType ,
KeyboardLayout ,
ScreenName ,
Progress ,
2026-03-29 13:48:56 +02:00
LessonPhase ,
TypingExerciseState ,
2026-03-29 11:06:54 +02:00
} from "../types" ;
import { companions } from "../companion/characters" ;
2026-03-29 12:15:45 +02:00
import { saveProgress , getProgress } from "../storage/db" ;
2026-03-29 11:12:13 +02:00
import {
createExerciseState ,
2026-03-29 13:48:56 +02:00
createWordExerciseState ,
getCurrentWordIndex ,
2026-03-29 11:12:13 +02:00
handleKeyPress ,
getCurrentLetter ,
} from "../game/typing" ;
import { renderKeyboard , highlightKey , pressKey } from "../game/keyboard" ;
2026-03-29 13:48:56 +02:00
import { getLevelByNumber } from "../game/levels" ;
import { getLetterIntro } from "../companion/companion" ;
import { getWordsForLevel } from "../game/words" ;
2026-03-29 18:22:40 +02:00
import { playCorrectSound } from "../audio/sounds" ;
2026-03-29 21:26:04 +02:00
import { renderInlineMarkdown } from "../utils/markdown" ;
2026-03-29 11:05:59 +02:00
export function initWelcomeScreen (
2026-03-29 11:06:54 +02:00
db : IDBDatabase ,
navigateTo : ( screen : ScreenName ) => void ,
2026-03-29 11:05:59 +02:00
) : void {
2026-03-29 11:06:54 +02:00
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 = `
2026-03-29 12:14:28 +02:00
<img class="companion-card__avatar" src=" ${ c . avatarUrl } " alt=" ${ c . name } "
onerror="this.style.display='none'; this.nextElementSibling.style.display='block';">
<span class="companion-card__emoji" style="display:none"> ${ c . emoji } </span>
2026-03-29 11:06:54 +02:00
<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 ,
2026-03-29 13:42:32 +02:00
totalCorrect : 0 ,
totalErrors : 0 ,
errorKeyCounts : {},
lastPlayedLevels : [],
2026-03-29 22:57:32 +02:00
currentForestPage : 0 ,
2026-03-29 11:06:54 +02:00
};
await saveProgress ( db , progress );
navigateTo ( "forest" );
});
2026-03-29 11:05:59 +02:00
}
2026-03-29 11:12:13 +02:00
2026-03-29 13:48:56 +02:00
// Finger description for companion letter intros
const fingerDescriptions : Record < string , string > = {
f : "linker Zeigefinger" ,
j : "rechter Zeigefinger" ,
d : "linker Mittelfinger" ,
k : "rechter Mittelfinger" ,
s : "linker Ringfinger" ,
l : "rechter Ringfinger" ,
a : "linker kleiner Finger" ,
ö : "rechter kleiner Finger" ,
g : "linker Zeigefinger (Streckung)" ,
h : "rechter Zeigefinger (Streckung)" ,
e : "linker Mittelfinger (obere Reihe)" ,
i : "rechter Mittelfinger (obere Reihe)" ,
" " : "Daumen" ,
};
// Encouragement messages for repetition protection
const encouragementMessages = [
2026-03-29 21:45:57 +02:00
"Magst du sehen, was als Nächstes kommt? Im Wald gibt es noch so viel zu entdecken!" ,
2026-03-29 20:46:04 +02:00
"Der Wald wartet auf dich! Jedes Mal, wenn du übst, wächst etwas Neues." ,
2026-03-29 13:48:56 +02:00
"Schau mal, was sich im Wald versteckt! Probier es einfach noch einmal." ,
2026-03-29 21:45:57 +02:00
"Dein Wald wird immer schöner. Komm, wir entdecken zusammen weiter!" ,
2026-03-29 13:48:56 +02:00
];
2026-03-29 11:12:13 +02:00
export function initLessonScreen (
2026-03-29 13:48:56 +02:00
db : IDBDatabase ,
2026-03-29 11:12:13 +02:00
level : number ,
layout : KeyboardLayout ,
2026-03-29 13:48:56 +02:00
onComplete : (
level : number ,
stats : {
totalCorrect : number ;
totalErrors : number ;
errorKeys : Record < string , number >;
},
) => void ,
2026-03-29 11:30:55 +02:00
onCancel ?: () => void ,
2026-03-29 13:48:56 +02:00
isReview = false ,
showEncouragement = false ,
2026-03-29 11:12:13 +02:00
) : () => void {
const letterArea = document . getElementById ( "letter-area" ) ! ;
const keyboardContainer = document . getElementById ( "lesson-keyboard" ) ! ;
2026-03-29 13:48:56 +02:00
const backBtn = document . getElementById (
"lesson-back-btn" ,
) as HTMLButtonElement ;
const phaseIndicator = document . getElementById ( "lesson-phase-indicator" ) ! ;
const companionArea = document . getElementById ( "lesson-companion" ) ! ;
const companionText = document . getElementById ( "lesson-companion-text" ) ! ;
2026-03-29 11:12:13 +02:00
2026-03-29 13:48:56 +02:00
// Cumulative stats across all phases
let cumulativeCorrect = 0 ;
let cumulativeErrors = 0 ;
const errorKeys : Record < string , number > = {};
// Active keydown handler reference for cleanup
let activeKeyDown : (( e : KeyboardEvent ) => void ) | null = null ;
let cancelled = false ;
function cleanup () : void {
cancelled = true ;
if ( activeKeyDown ) {
document . removeEventListener ( "keydown" , activeKeyDown );
activeKeyDown = null ;
}
letterArea . innerHTML = "" ;
}
function setPhase ( phase : LessonPhase ) : void {
const labels : Record < LessonPhase , string > = {
discover : "Entdecken" ,
2026-03-29 21:32:41 +02:00
practice :
2026-03-29 21:45:57 +02:00
"Jetzt kannst du üben! Drücke die Taste zu dem Buchstaben, der herunterfällt." ,
words : "Jetzt kommen ganze Wörter! Tippe die Buchstaben nacheinander ein." ,
2026-03-29 13:48:56 +02:00
};
phaseIndicator . textContent = labels [ phase ];
}
// Show companion avatar
let characterType : CompanionType = "fee" ;
const progressPromise = getProgress ( db ). then (( progress ) => {
2026-03-29 12:15:45 +02:00
if ( ! progress ) return ;
2026-03-29 13:48:56 +02:00
characterType = progress . selectedCharacter ;
2026-03-29 12:15:45 +02:00
const companion = companions . find (
( c ) => c . type === progress . selectedCharacter ,
);
if ( ! companion ) return ;
const avatar = document . getElementById (
"lesson-companion-avatar" ,
) as HTMLImageElement ;
avatar . src = companion . avatarUrl ;
avatar . alt = companion . name ;
2026-03-29 13:48:56 +02:00
companionArea . style . display = "flex" ;
2026-03-29 12:15:45 +02:00
});
2026-03-29 11:12:13 +02:00
// Render keyboard
renderKeyboard ( keyboardContainer , layout , level );
2026-03-29 13:48:56 +02:00
// --- Helper: run a letter exercise phase (practice or fallback) ---
function runLetterExercise (
state : TypingExerciseState ,
onDone : () => void ,
) : void {
function showCurrentLetter () : void {
const letter = getCurrentLetter ( state );
if ( ! letter ) return ;
letterArea . innerHTML = "" ;
const letterEl = document . createElement ( "div" );
letterEl . className = "falling-letter" ;
letterEl . textContent =
letter === " " ? "\u2423" : letter . toUpperCase ();
letterArea . appendChild ( letterEl );
highlightKey ( letter );
}
2026-03-29 11:12:13 +02:00
2026-03-29 13:48:56 +02:00
showCurrentLetter ();
2026-03-29 11:12:13 +02:00
2026-03-29 13:48:56 +02:00
function onKeyDown ( e : KeyboardEvent ) : void {
if ( e . key . length > 1 && e . key !== " " ) return ;
e . preventDefault ();
2026-03-29 11:12:13 +02:00
2026-03-29 13:48:56 +02:00
const expectedKey = getCurrentLetter ( state );
const result = handleKeyPress ( state , e . key );
pressKey ( e . key );
2026-03-29 11:12:13 +02:00
2026-03-29 13:48:56 +02:00
if ( result . correct ) {
cumulativeCorrect ++ ;
2026-03-29 18:22:40 +02:00
playCorrectSound ();
2026-03-29 13:48:56 +02:00
const currentEl = letterArea . querySelector ( ".falling-letter" );
if ( currentEl ) {
currentEl . classList . add ( "falling-letter--dissolve" );
}
2026-03-29 11:12:13 +02:00
2026-03-29 13:48:56 +02:00
if ( result . exerciseComplete ) {
document . removeEventListener ( "keydown" , onKeyDown );
activeKeyDown = null ;
onDone ();
} else {
setTimeout (() => showCurrentLetter (), 300 );
}
2026-03-29 11:12:13 +02:00
} else {
2026-03-29 13:48:56 +02:00
cumulativeErrors ++ ;
if ( expectedKey ) {
errorKeys [ expectedKey ] = ( errorKeys [ expectedKey ] || 0 ) + 1 ;
}
2026-03-29 11:12:13 +02:00
}
}
2026-03-29 13:48:56 +02:00
if ( activeKeyDown ) {
document . removeEventListener ( "keydown" , activeKeyDown );
}
activeKeyDown = onKeyDown ;
document . addEventListener ( "keydown" , onKeyDown );
2026-03-29 11:12:13 +02:00
}
2026-03-29 13:48:56 +02:00
// --- Helper: run word exercise phase ---
function runWordExercise (
state : TypingExerciseState ,
onDone : () => void ,
) : void {
function renderWordDisplay () : void {
letterArea . innerHTML = "" ;
if ( ! state . words || ! state . wordBoundaries ) return ;
const wordIdx = getCurrentWordIndex ( state );
const wordStart = state . wordBoundaries [ wordIdx ] ! ;
const word = state . words [ wordIdx ] ! ;
const container = document . createElement ( "div" );
container . className = "word-display" ;
for ( let i = 0 ; i < word . length ; i ++ ) {
const span = document . createElement ( "span" );
span . className = "word-display__letter" ;
const globalIdx = wordStart + i ;
if ( globalIdx < state . currentLetterIndex ) {
span . classList . add ( "word-display__letter--done" );
} else if ( globalIdx === state . currentLetterIndex ) {
span . classList . add ( "word-display__letter--active" );
}
span . textContent = word [ i ] ! . toUpperCase ();
container . appendChild ( span );
}
letterArea . appendChild ( container );
// Highlight expected key on keyboard
const currentLetter = getCurrentLetter ( state );
if ( currentLetter ) highlightKey ( currentLetter );
}
renderWordDisplay ();
function onKeyDown ( e : KeyboardEvent ) : void {
if ( e . key . length > 1 && e . key !== " " ) return ;
e . preventDefault ();
const expectedKey = getCurrentLetter ( state );
const result = handleKeyPress ( state , e . key );
pressKey ( e . key );
if ( result . correct ) {
cumulativeCorrect ++ ;
2026-03-29 18:22:40 +02:00
playCorrectSound ();
2026-03-29 13:48:56 +02:00
if ( result . exerciseComplete ) {
document . removeEventListener ( "keydown" , onKeyDown );
activeKeyDown = null ;
onDone ();
} else {
renderWordDisplay ();
}
} else {
cumulativeErrors ++ ;
if ( expectedKey ) {
errorKeys [ expectedKey ] = ( errorKeys [ expectedKey ] || 0 ) + 1 ;
}
}
}
if ( activeKeyDown ) {
document . removeEventListener ( "keydown" , activeKeyDown );
}
activeKeyDown = onKeyDown ;
document . addEventListener ( "keydown" , onKeyDown );
}
// --- Phase flow ---
async function startFlow () : Promise < void > {
await progressPromise ;
if ( cancelled ) return ;
// Encouragement message before starting (repetition protection)
if ( showEncouragement ) {
companionText . textContent =
encouragementMessages [
Math . floor ( Math . random () * encouragementMessages . length )
] ! ;
companionArea . style . display = "flex" ;
await new Promise < void >(( resolve ) => setTimeout ( resolve , 3000 ));
if ( cancelled ) return ;
}
// PHASE 1: DISCOVER (skip for reviews)
if ( ! isReview ) {
await runDiscoverPhase ();
if ( cancelled ) return ;
}
// PHASE 2: PRACTICE
await runPracticePhase ();
if ( cancelled ) return ;
// PHASE 3: WORDS
await runWordsPhase ();
if ( cancelled ) return ;
// All phases complete
onComplete ( level , {
totalCorrect : cumulativeCorrect ,
totalErrors : cumulativeErrors ,
errorKeys ,
});
}
async function runDiscoverPhase () : Promise < void > {
setPhase ( "discover" );
const levelDef = getLevelByNumber ( level );
if ( ! levelDef ) return ;
for ( const key of levelDef . newKeys ) {
if ( cancelled ) return ;
2026-03-29 21:13:54 +02:00
if ( key === " " ) {
// Special space bar discover step
companionText . textContent =
2026-03-29 21:45:57 +02:00
"Siehst du die große Taste ganz unten? Das ist die Leertaste! Wenn du dieses Zeichen siehst: \u2423 \u2014 dann drückst du mit dem Daumen die Leertaste. Probier es aus!" ;
2026-03-29 21:13:54 +02:00
companionArea . style . display = "flex" ;
letterArea . innerHTML = "" ;
const spaceLetterEl = document . createElement ( "div" );
spaceLetterEl . className = "falling-letter" ;
spaceLetterEl . textContent = "\u2423" ;
letterArea . appendChild ( spaceLetterEl );
const spaceLabelEl = document . createElement ( "div" );
spaceLabelEl . style . cssText =
"text-align: center; font-size: 1.2rem; color: var(--text-secondary, #666); margin-top: 0.5rem; font-family: 'Quicksand', sans-serif;" ;
spaceLabelEl . textContent = "Leertaste" ;
letterArea . appendChild ( spaceLabelEl );
highlightKey ( " " );
const spaceKeyEl = document . querySelector (
'[data-key=" "]' ,
) as HTMLElement | null ;
if ( spaceKeyEl ) {
spaceKeyEl . classList . add ( "keyboard__key--discover-pulse" );
}
await new Promise < void >(( resolve ) => {
function onSpaceDown ( e : KeyboardEvent ) : void {
if ( e . key !== " " ) return ;
e . preventDefault ();
pressKey ( e . key );
document . removeEventListener ( "keydown" , onSpaceDown );
activeKeyDown = null ;
if ( spaceKeyEl ) {
spaceKeyEl . classList . remove (
"keyboard__key--discover-pulse" ,
);
}
resolve ();
}
if ( activeKeyDown ) {
document . removeEventListener ( "keydown" , activeKeyDown );
}
activeKeyDown = onSpaceDown ;
document . addEventListener ( "keydown" , onSpaceDown );
});
continue ;
}
2026-03-29 13:48:56 +02:00
const fingerDesc = fingerDescriptions [ key ] || "ein Finger" ;
const introText = await getLetterIntro (
characterType ,
key . toUpperCase (),
fingerDesc ,
db ,
);
if ( cancelled ) return ;
// Show companion intro text
2026-03-29 21:26:04 +02:00
companionText . innerHTML = renderInlineMarkdown ( introText );
2026-03-29 13:48:56 +02:00
companionArea . style . display = "flex" ;
// Show the letter
letterArea . innerHTML = "" ;
const letterEl = document . createElement ( "div" );
letterEl . className = "falling-letter" ;
letterEl . textContent = key . toUpperCase ();
letterArea . appendChild ( letterEl );
// Highlight and pulse the target key
highlightKey ( key );
const keyEl = document . querySelector (
`[data-key=" ${ key } "]` ,
) as HTMLElement | null ;
if ( keyEl ) {
keyEl . classList . add ( "keyboard__key--discover-pulse" );
}
// Wait for child to press the correct key once
await new Promise < void >(( resolve ) => {
function onKeyDown ( e : KeyboardEvent ) : void {
if ( e . key . length > 1 && e . key !== " " ) return ;
e . preventDefault ();
pressKey ( e . key );
if ( e . key . toLowerCase () === key . toLowerCase ()) {
document . removeEventListener ( "keydown" , onKeyDown );
activeKeyDown = null ;
if ( keyEl ) {
keyEl . classList . remove (
"keyboard__key--discover-pulse" ,
);
}
resolve ();
}
}
if ( activeKeyDown ) {
document . removeEventListener ( "keydown" , activeKeyDown );
}
activeKeyDown = onKeyDown ;
document . addEventListener ( "keydown" , onKeyDown );
});
}
// Clear discover UI
letterArea . innerHTML = "" ;
companionText . textContent = "" ;
}
2026-03-29 21:19:38 +02:00
async function runPracticePhase () : Promise < void > {
2026-03-29 13:48:56 +02:00
setPhase ( "practice" );
const state = createExerciseState ( level );
return new Promise < void >(( resolve ) => {
runLetterExercise ( state , resolve );
});
}
2026-03-29 21:19:38 +02:00
async function runWordsPhase () : Promise < void > {
2026-03-29 13:48:56 +02:00
setPhase ( "words" );
const wordCount = 4 ;
let words : string [];
if ( isReview ) {
// Mix words from current and previous 1-2 levels
const levelsToMix = [ level ];
if ( level > 1 ) levelsToMix . push ( level - 1 );
if ( level > 2 ) levelsToMix . push ( level - 2 );
words = [];
for ( const lvl of levelsToMix ) {
words . push (... getWordsForLevel ( lvl , 2 ));
}
// Shuffle and take wordCount
for ( let i = words . length - 1 ; i > 0 ; i -- ) {
const j = Math . floor ( Math . random () * ( i + 1 ));
[ words [ i ], words [ j ]] = [ words [ j ] ! , words [ i ] ! ];
}
words = words . slice ( 0 , wordCount );
} else {
words = getWordsForLevel ( level , wordCount );
if ( words . length < 3 ) {
words = getWordsForLevel ( level , 3 );
}
}
if ( words . length === 0 ) {
// Fallback: skip words phase if no words available
2026-03-29 21:19:38 +02:00
return ;
2026-03-29 13:48:56 +02:00
}
const state = createWordExerciseState ( words );
return new Promise < void >(( resolve ) => {
runWordExercise ( state , resolve );
});
}
// Start the flow
startFlow ();
2026-03-29 11:12:13 +02:00
2026-03-29 11:30:55 +02:00
// Back button to cancel lesson
backBtn . onclick = () => {
2026-03-29 13:48:56 +02:00
cleanup ();
2026-03-29 11:30:55 +02:00
if ( onCancel ) onCancel ();
};
2026-03-29 11:12:13 +02:00
// Return cleanup function
2026-03-29 13:48:56 +02:00
return cleanup ;
2026-03-29 11:12:13 +02:00
}