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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user