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;