Files
Zauberwald/src/app.ts
T

243 lines
6.9 KiB
TypeScript
Raw Normal View History

import { speakText } from "./audio/speech";
import { companions } from "./companion/characters";
import { getGreeting } from "./companion/companion";
import { renderInlineMarkdown } from "./utils/markdown";
import { initRewardScreen, startPreGeneration } from "./forest/reward";
import { initForestScreen, renderForestScene } from "./forest/scene";
import { getProgress, openDB, saveProgress } from "./storage/db";
import type { ScreenName } from "./types";
import { initParentScreen, showParentCodePrompt } from "./ui/parent";
import { initLessonScreen, initWelcomeScreen } from "./ui/screens";
let db: IDBDatabase;
let lessonCleanup: (() => void) | null = null;
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;
}
const MAX_LEVEL = 6;
/**
* Ensure progress object has all fields (migration for older saved data).
*/
function migrateProgress(
progress: NonNullable<Awaited<ReturnType<typeof getProgress>>>,
): void {
if (!progress.errorKeyCounts) progress.errorKeyCounts = {};
if (!progress.lastPlayedLevels) progress.lastPlayedLevels = [];
if (progress.totalCorrect === undefined) progress.totalCorrect = 0;
if (progress.totalErrors === undefined) progress.totalErrors = 0;
}
async function handleLessonComplete(
completedLevel: number,
stats: {
totalCorrect: number;
totalErrors: number;
errorKeys: Record<string, number>;
},
): Promise<void> {
// Update progress after lesson completion
const progress = await getProgress(db);
if (!progress) return;
migrateProgress(progress);
// Accumulate exercise stats (D-15, D-16)
progress.totalCorrect += stats.totalCorrect;
progress.totalErrors += stats.totalErrors;
for (const [key, count] of Object.entries(stats.errorKeys)) {
progress.errorKeyCounts[key] =
(progress.errorKeyCounts[key] ?? 0) + count;
}
// Track last played levels (keep last 3) for repetition detection
progress.lastPlayedLevels.push(completedLevel);
if (progress.lastPlayedLevels.length > 3) {
progress.lastPlayedLevels = progress.lastPlayedLevels.slice(-3);
}
// Add level to completedLevels if not already there
if (!progress.completedLevels.includes(completedLevel)) {
progress.completedLevels.push(completedLevel);
}
// Advance to next level if this was the current level (LEVL-02, LEVL-03)
// Cap at MAX_LEVEL so we don't go beyond defined levels
if (
completedLevel === progress.currentLevel &&
completedLevel < MAX_LEVEL
) {
progress.currentLevel = completedLevel + 1;
}
// Increment session count
progress.totalSessions++;
// Add today's date if not already recorded
const today = new Date().toISOString().split("T")[0]!;
if (!progress.sessionDates.includes(today)) {
progress.sessionDates.push(today);
}
await saveProgress(db, progress);
// Show REWARD screen instead of going directly to forest
showScreen("reward");
// Set companion avatar on reward screen
const companion = companions.find(
(c) => c.type === progress.selectedCharacter,
);
if (companion) {
const avatar = document.getElementById(
"reward-companion-avatar",
) as HTMLImageElement;
avatar.src = companion.avatarUrl;
avatar.alt = companion.name;
}
const elementId = await initRewardScreen(
db,
completedLevel,
progress.selectedCharacter,
() => {
// "Weiter ueben" -- start next lesson
startLessonFromForest(
Math.min(progress.currentLevel, MAX_LEVEL),
);
},
async () => {
// "Zurueck zum Wald" -- show forest with new element animated
await initForestScreen(db, startLessonFromForest);
await renderForestScene(db, elementId);
showScreen("forest");
},
);
}
async function handleLessonCancel(): Promise<void> {
await initForestScreen(db, startLessonFromForest);
showScreen("forest");
}
function startLessonFromForest(level: number): void {
// Clean up previous lesson if any
if (lessonCleanup) {
lessonCleanup();
lessonCleanup = null;
}
// Get layout and determine review/encouragement from progress
getProgress(db).then((progress) => {
if (!progress) return;
migrateProgress(progress);
const layout = progress.selectedLayout ?? "de";
// Review every 3rd session (D-05)
const isReview =
progress.totalSessions > 0 &&
progress.totalSessions % 3 === 0;
// Encouragement if last 2 played levels are the same as current (D-06)
const last2 = progress.lastPlayedLevels.slice(-2);
const showEncouragement =
last2.length === 2 &&
last2[0] === level &&
last2[1] === level;
// Start image pre-generation (D-11) -- fire and forget
startPreGeneration(db);
showScreen("lesson");
lessonCleanup = initLessonScreen(
db,
level,
layout,
handleLessonComplete,
handleLessonCancel,
isReview,
showEncouragement,
);
});
}
export async function initApp(): Promise<void> {
db = await openDB();
2026-03-29 13:46:45 +02:00
// Wire parent screen back button
initParentScreen(db, async () => {
await initForestScreen(db, startLessonFromForest);
showScreen("forest");
});
// Global keyboard shortcut for parent area (Ctrl+Shift+E)
document.addEventListener("keydown", (e) => {
if (e.ctrlKey && e.shiftKey && e.key === "E") {
e.preventDefault();
showParentCodePrompt();
}
// Dev shortcut: Ctrl+Shift+X to wipe all data and restart
if (e.ctrlKey && e.shiftKey && e.key === "X") {
e.preventDefault();
if (confirm("DEV: Alle Daten löschen und neu starten?")) {
const delReq = indexedDB.deleteDatabase("zauberwald");
delReq.onsuccess = () => location.reload();
delReq.onerror = () => location.reload();
}
}
2026-03-29 13:46:45 +02:00
});
const progress = await getProgress(db);
if (progress && progress.selectedCharacter) {
migrateProgress(progress);
// Returning user -- init forest and go there
await initForestScreen(db, startLessonFromForest);
showScreen("forest");
// Show companion greeting (per COMP-01, D-14)
const companion = companions.find(
(c) => c.type === progress.selectedCharacter,
);
if (companion) {
const greetingEl = document.getElementById("forest-greeting")!;
const avatarEl = document.getElementById(
"forest-greeting-avatar",
) as HTMLImageElement;
const textEl = document.getElementById("forest-greeting-text")!;
avatarEl.src = companion.avatarUrl;
avatarEl.alt = companion.name;
greetingEl.style.display = "flex";
// Fetch greeting async (shows immediately with avatar, text fills in)
getGreeting(progress.selectedCharacter, db).then((text) => {
textEl.innerHTML = renderInlineMarkdown(text);
speakText(text);
});
}
} else {
// First time -- show welcome, then forest after setup
initWelcomeScreen(db, async (screen: ScreenName) => {
showScreen(screen);
if (screen === "forest") {
await initForestScreen(db, startLessonFromForest);
}
});
showScreen("welcome");
}
}