From e599050303c949a81dddef77e519cacd49c454b6 Mon Sep 17 00:00:00 2001 From: Markus Graf Date: Sun, 29 Mar 2026 13:52:05 +0200 Subject: [PATCH] feat(03-04): add reward module with pre-generation and reward screen - Create src/forest/reward.ts with startPreGeneration() and initRewardScreen() - Image pre-generation starts at lesson begin, placeholder shows while loading - Fallback to static SVGs when rate-limited or API unavailable - Reward screen HTML with image area, companion comment, action buttons - CSS for reward screen with animated leaves placeholder and image appear animation --- index.html | 17 +++- src/forest/reward.ts | 218 +++++++++++++++++++++++++++++++++++++++++++ src/styles/main.css | 122 ++++++++++++++++++++++++ 3 files changed, 356 insertions(+), 1 deletion(-) create mode 100644 src/forest/reward.ts diff --git a/index.html b/index.html index 66a41f6..00ec60e 100644 --- a/index.html +++ b/index.html @@ -80,7 +80,22 @@ -
+
+
+

Schau mal!

+
+ +
+
+ +

+
+
+ + +
+
+
diff --git a/src/forest/reward.ts b/src/forest/reward.ts new file mode 100644 index 0000000..745fcb1 --- /dev/null +++ b/src/forest/reward.ts @@ -0,0 +1,218 @@ +/** + * Reward module: Pre-generates forest element images during lessons + * and displays the reward screen after lesson completion. + * + * Strategy: Start image generation at lesson begin (pre-generation), + * show placeholder while awaiting, display result with companion comment. + */ + +import { loadConfig } from "../api/config"; +import { + generateImage, + checkRateLimit, + incrementApiCall, +} from "../api/gemini"; +import { + getStyleReference, + saveForestElement, + getForestElements, +} from "../storage/db"; +import { getForestComment } from "../companion/companion"; +import { getRandomFallbackForestComment } from "../companion/fallbacks"; +import type { CompanionType, ForestElement } from "../types"; + +// --- Element types for random selection --- + +const ELEMENT_TYPES = [ + "Blume", + "Pilz", + "Schmetterling", + "Vogel", + "Reh", + "Eichhoernchen", + "Hase", + "Frosch", + "Igel", + "Marienkaefer", + "Libelle", + "Schnecke", +]; + +// --- Fallback images (static SVGs) --- + +const FALLBACK_SVGS = [ + "/src/assets/fallback-images/blume.svg", + "/src/assets/fallback-images/pilz.svg", + "/src/assets/fallback-images/vogel.svg", + "/src/assets/fallback-images/schmetterling.svg", + "/src/assets/fallback-images/reh.svg", + "/src/assets/fallback-images/baum.svg", + "/src/assets/fallback-images/bach.svg", +]; + +async function getRandomFallbackImage(): Promise<{ + blob: Blob; + description: string; +}> { + const idx = Math.floor(Math.random() * FALLBACK_SVGS.length); + const path = FALLBACK_SVGS[idx]!; + const name = path.split("/").pop()!.replace(".svg", ""); + try { + const res = await fetch(path); + const blob = await res.blob(); + return { blob, description: name }; + } catch { + // Ultimate fallback: empty SVG blob + const svg = + ''; + return { + blob: new Blob([svg], { type: "image/svg+xml" }), + description: "element", + }; + } +} + +// --- Pre-generation --- + +let pregenPromise: Promise<{ + blob: Blob; + description: string; + prompt: string; +}> | null = null; + +export function startPreGeneration(db: IDBDatabase): void { + pregenPromise = generateForestElement(db); +} + +async function generateForestElement(db: IDBDatabase): Promise<{ + blob: Blob; + description: string; + prompt: string; +}> { + // Check rate limit + const canGenerate = await checkRateLimit(db, "image"); + if (!canGenerate) { + const fallback = await getRandomFallbackImage(); + return { ...fallback, prompt: "fallback" }; + } + + // Load config + const config = await loadConfig(); + if (!config) { + const fallback = await getRandomFallbackImage(); + return { ...fallback, prompt: "fallback" }; + } + + // Get style reference for visual consistency + const styleRef = await getStyleReference(db); + const referenceImages: Blob[] = []; + if (styleRef) { + referenceImages.push(styleRef.imageBlob); + } + + // Pick random element type + const elementType = + ELEMENT_TYPES[Math.floor(Math.random() * ELEMENT_TYPES.length)]!; + + // Build prompt + const prompt = `Kinderbuch-Illustration, Aquarell-Stil, ${elementType}, magischer Wald, warm, einladend, fuer Kinder, Pastellfarben, kein Text, einzelnes Element auf transparentem Hintergrund`; + + // Generate image + const blob = await generateImage(prompt, referenceImages, config); + if (!blob) { + const fallback = await getRandomFallbackImage(); + return { ...fallback, prompt }; + } + + // Track API usage + await incrementApiCall(db, "image"); + + return { blob, description: elementType, prompt }; +} + +// --- Reward screen --- + +export async function initRewardScreen( + db: IDBDatabase, + level: number, + characterType: CompanionType, + onContinue: () => void, + onBackToForest: () => void, +): Promise { + const imageArea = document.getElementById("reward-image-area")!; + const companionTextEl = document.getElementById("reward-companion-text")!; + const continueBtn = document.getElementById( + "reward-continue-btn", + ) as HTMLButtonElement; + const backBtn = document.getElementById( + "reward-back-btn", + ) as HTMLButtonElement; + + // 1. Show placeholder immediately + imageArea.innerHTML = ` +
+ \uD83C\uDF43 + \uD83C\uDF42 + \uD83C\uDF43 +

Der Wald denkt nach...

+
+ `; + companionTextEl.textContent = ""; + + // 2. Await pre-generated image (or generate now if not started) + const result = await (pregenPromise ?? generateForestElement(db)); + pregenPromise = null; + + // 3. Get companion comment + let comment: string; + try { + comment = await getForestComment(characterType, result.description, db); + } catch { + comment = getRandomFallbackForestComment(); + } + + // 4. Determine grid position for new element + const existing = await getForestElements(db); + const slotIndex = existing.length % 12; + const col = slotIndex % 4; + const row = Math.floor(slotIndex / 4); + // Add small random offset for natural feel + const offsetX = (Math.random() - 0.5) * 30; // +-15px + const offsetY = (Math.random() - 0.5) * 30; + + // 5. Save to IndexedDB + const element: ForestElement = { + levelCompleted: level, + imageBlob: result.blob, + imagePrompt: result.prompt, + description: result.description, + companionText: comment, + position: { x: col + offsetX / 100, y: row + offsetY / 100 }, + createdAt: new Date().toISOString(), + }; + const elementId = await saveForestElement(db, element); + + // 6. Replace placeholder with actual image + const objectUrl = URL.createObjectURL(result.blob); + imageArea.innerHTML = ""; + const img = document.createElement("img"); + img.src = objectUrl; + img.alt = result.description; + imageArea.appendChild(img); + + // 7. Show companion comment + companionTextEl.textContent = comment; + + // 8. Wire buttons + continueBtn.onclick = () => { + URL.revokeObjectURL(objectUrl); + onContinue(); + }; + backBtn.onclick = () => { + URL.revokeObjectURL(objectUrl); + onBackToForest(); + }; + + // 9. Return element ID for forest scene animation + return elementId; +} diff --git a/src/styles/main.css b/src/styles/main.css index 4035a8c..0896aef 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -441,6 +441,128 @@ h3 { } } +/* Reward Screen */ +.reward { + display: flex; + flex-direction: column; + align-items: center; + padding: 2rem; + max-width: 600px; + margin: 0 auto; + text-align: center; +} + +.reward__title { + font-family: var(--font-heading); + color: var(--accent-gold); + font-size: 1.8rem; + margin-bottom: 1.5rem; +} + +.reward__image-area { + width: 280px; + height: 280px; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 1.5rem; + border-radius: 16px; + background: var(--bg-forest); + overflow: hidden; +} + +.reward__image-area img { + max-width: 100%; + max-height: 100%; + object-fit: contain; + animation: reward-image-appear 0.8s ease-out; +} + +@keyframes reward-image-appear { + from { + opacity: 0; + transform: scale(0.5); + } + to { + opacity: 1; + transform: scale(1); + } +} + +.reward__placeholder { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + color: var(--text-warm); + font-family: var(--font-heading); +} + +.reward__leaf { + font-size: 1.5rem; + animation: leaf-float 2s ease-in-out infinite; +} + +.reward__leaf--1 { + animation-delay: 0s; +} +.reward__leaf--2 { + animation-delay: 0.5s; +} +.reward__leaf--3 { + animation-delay: 1s; +} + +@keyframes leaf-float { + 0%, + 100% { + transform: translateY(0) rotate(0deg); + } + 50% { + transform: translateY(-10px) rotate(15deg); + } +} + +.reward__companion { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 1.5rem; + padding: 1rem; + background: rgba(255, 255, 255, 0.7); + border-radius: 12px; +} + +.reward__actions { + display: flex; + gap: 1rem; +} + +.reward__btn { + font-family: var(--font-heading); + font-size: 1.1rem; + padding: 0.75rem 1.5rem; + border: none; + border-radius: 12px; + cursor: pointer; + transition: transform 0.2s; +} + +.reward__btn:hover { + transform: scale(1.05); +} + +.reward__btn--continue { + background: var(--accent-green); + color: white; +} + +.reward__btn--back { + background: var(--bg-cream); + color: var(--text-warm); + border: 2px solid var(--accent-green); +} + /* Parent Area */ .parent { max-width: 600px;