2026-03-29 18:22:40 +02:00
|
|
|
import { speakText } from "./audio/speech";
|
2026-03-29 12:15:45 +02:00
|
|
|
import { companions } from "./companion/characters";
|
2026-03-29 18:22:40 +02:00
|
|
|
import { getGreeting } from "./companion/companion";
|
2026-03-29 21:26:04 +02:00
|
|
|
import { renderInlineMarkdown } from "./utils/markdown";
|
2026-03-29 18:22:40 +02:00
|
|
|
import { initRewardScreen, startPreGeneration } from "./forest/reward";
|
2026-03-29 23:02:51 +02:00
|
|
|
import { initForestScreen, renderForestScene, SLOTS_PER_PAGE } from "./forest/scene";
|
|
|
|
|
import { getForestElements, getProgress, openDB, saveProgress } from "./storage/db";
|
2026-03-29 18:22:40 +02:00
|
|
|
import type { ScreenName } from "./types";
|
|
|
|
|
import { initParentScreen, showParentCodePrompt } from "./ui/parent";
|
|
|
|
|
import { initLessonScreen, initWelcomeScreen } from "./ui/screens";
|
2026-03-29 11:05:59 +02:00
|
|
|
|
|
|
|
|
let db: IDBDatabase;
|
2026-03-29 11:12:13 +02:00
|
|
|
let lessonCleanup: (() => void) | null = null;
|
2026-03-29 11:05:59 +02:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-29 11:30:55 +02:00
|
|
|
const MAX_LEVEL = 6;
|
|
|
|
|
|
2026-03-29 13:53:23 +02:00
|
|
|
/**
|
|
|
|
|
* 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;
|
2026-03-29 22:57:32 +02:00
|
|
|
if (progress.currentForestPage === undefined) progress.currentForestPage = 0;
|
2026-03-29 13:53:23 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function handleLessonComplete(
|
|
|
|
|
completedLevel: number,
|
|
|
|
|
stats: {
|
|
|
|
|
totalCorrect: number;
|
|
|
|
|
totalErrors: number;
|
|
|
|
|
errorKeys: Record<string, number>;
|
|
|
|
|
},
|
|
|
|
|
): Promise<void> {
|
2026-03-29 11:15:18 +02:00
|
|
|
// Update progress after lesson completion
|
|
|
|
|
const progress = await getProgress(db);
|
|
|
|
|
if (!progress) return;
|
2026-03-29 13:53:23 +02:00
|
|
|
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);
|
|
|
|
|
}
|
2026-03-29 11:15:18 +02:00
|
|
|
|
|
|
|
|
// 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)
|
2026-03-29 13:53:23 +02:00
|
|
|
// Cap at MAX_LEVEL so we don't go beyond defined levels
|
|
|
|
|
if (
|
|
|
|
|
completedLevel === progress.currentLevel &&
|
|
|
|
|
completedLevel < MAX_LEVEL
|
|
|
|
|
) {
|
2026-03-29 11:15:18 +02:00
|
|
|
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);
|
|
|
|
|
|
2026-03-29 13:53:23 +02:00
|
|
|
// 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,
|
|
|
|
|
() => {
|
2026-03-29 21:45:57 +02:00
|
|
|
// "Weiter üben" -- start next lesson
|
2026-03-29 13:53:23 +02:00
|
|
|
startLessonFromForest(
|
|
|
|
|
Math.min(progress.currentLevel, MAX_LEVEL),
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
async () => {
|
2026-03-29 23:02:51 +02:00
|
|
|
// "Zurück zum Wald" -- navigate to page containing the new element
|
|
|
|
|
const elements = await getForestElements(db);
|
|
|
|
|
const newPage = Math.max(0, Math.ceil(elements.length / SLOTS_PER_PAGE) - 1);
|
|
|
|
|
|
|
|
|
|
// Update progress with new page before initForestScreen reads it
|
|
|
|
|
const latestProgress = await getProgress(db);
|
|
|
|
|
if (latestProgress) {
|
|
|
|
|
latestProgress.currentForestPage = newPage;
|
|
|
|
|
await saveProgress(db, latestProgress);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-29 13:53:23 +02:00
|
|
|
await initForestScreen(db, startLessonFromForest);
|
2026-03-29 23:02:51 +02:00
|
|
|
await renderForestScene(db, newPage, elementId);
|
2026-03-29 13:53:23 +02:00
|
|
|
showScreen("forest");
|
|
|
|
|
},
|
|
|
|
|
);
|
2026-03-29 11:15:18 +02:00
|
|
|
}
|
|
|
|
|
|
2026-03-29 11:30:55 +02:00
|
|
|
async function handleLessonCancel(): Promise<void> {
|
|
|
|
|
await initForestScreen(db, startLessonFromForest);
|
|
|
|
|
showScreen("forest");
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-29 11:15:18 +02:00
|
|
|
function startLessonFromForest(level: number): void {
|
2026-03-29 11:12:13 +02:00
|
|
|
// Clean up previous lesson if any
|
|
|
|
|
if (lessonCleanup) {
|
|
|
|
|
lessonCleanup();
|
|
|
|
|
lessonCleanup = null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-29 13:53:23 +02:00
|
|
|
// Get layout and determine review/encouragement from progress
|
2026-03-29 11:15:18 +02:00
|
|
|
getProgress(db).then((progress) => {
|
2026-03-29 13:53:23 +02:00
|
|
|
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);
|
2026-03-29 11:12:13 +02:00
|
|
|
|
2026-03-29 11:15:18 +02:00
|
|
|
showScreen("lesson");
|
2026-03-29 11:12:13 +02:00
|
|
|
|
2026-03-29 11:15:18 +02:00
|
|
|
lessonCleanup = initLessonScreen(
|
|
|
|
|
db,
|
|
|
|
|
level,
|
|
|
|
|
layout,
|
|
|
|
|
handleLessonComplete,
|
2026-03-29 11:30:55 +02:00
|
|
|
handleLessonCancel,
|
2026-03-29 13:53:23 +02:00
|
|
|
isReview,
|
|
|
|
|
showEncouragement,
|
2026-03-29 11:15:18 +02:00
|
|
|
);
|
2026-03-29 11:12:13 +02:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-29 11:05:59 +02:00
|
|
|
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();
|
|
|
|
|
}
|
2026-03-29 21:03:57 +02:00
|
|
|
// 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
|
|
|
});
|
|
|
|
|
|
2026-03-29 11:05:59 +02:00
|
|
|
const progress = await getProgress(db);
|
|
|
|
|
if (progress && progress.selectedCharacter) {
|
2026-03-29 13:53:23 +02:00
|
|
|
migrateProgress(progress);
|
|
|
|
|
|
2026-03-29 11:15:18 +02:00
|
|
|
// Returning user -- init forest and go there
|
|
|
|
|
await initForestScreen(db, startLessonFromForest);
|
2026-03-29 11:05:59 +02:00
|
|
|
showScreen("forest");
|
2026-03-29 12:15:45 +02:00
|
|
|
|
|
|
|
|
// 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) => {
|
2026-03-29 21:26:04 +02:00
|
|
|
textEl.innerHTML = renderInlineMarkdown(text);
|
2026-03-29 18:22:40 +02:00
|
|
|
speakText(text);
|
2026-03-29 12:15:45 +02:00
|
|
|
});
|
|
|
|
|
}
|
2026-03-29 11:05:59 +02:00
|
|
|
} else {
|
2026-03-29 11:15:18 +02:00
|
|
|
// First time -- show welcome, then forest after setup
|
|
|
|
|
initWelcomeScreen(db, async (screen: ScreenName) => {
|
|
|
|
|
showScreen(screen);
|
|
|
|
|
if (screen === "forest") {
|
|
|
|
|
await initForestScreen(db, startLessonFromForest);
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-03-29 11:05:59 +02:00
|
|
|
showScreen("welcome");
|
|
|
|
|
}
|
|
|
|
|
}
|