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
This commit is contained in:
2026-03-29 13:52:05 +02:00
parent 4f1a2b55d6
commit e599050303
3 changed files with 356 additions and 1 deletions
+218
View File
@@ -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 =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><circle cx="50" cy="50" r="40" fill="#E8F5E4"/></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<number> {
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 = `
<div class="reward__placeholder">
<span class="reward__leaf reward__leaf--1">\uD83C\uDF43</span>
<span class="reward__leaf reward__leaf--2">\uD83C\uDF42</span>
<span class="reward__leaf reward__leaf--3">\uD83C\uDF43</span>
<p>Der Wald denkt nach...</p>
</div>
`;
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;
}
+122
View File
@@ -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;