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:
+16
-1
@@ -80,7 +80,22 @@
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section id="screen-reward" class="screen"></section>
|
||||
<section id="screen-reward" class="screen">
|
||||
<div class="reward">
|
||||
<h2 class="reward__title">Schau mal!</h2>
|
||||
<div class="reward__image-area" id="reward-image-area">
|
||||
<!-- Placeholder or generated image -->
|
||||
</div>
|
||||
<div class="reward__companion" id="reward-companion">
|
||||
<img id="reward-companion-avatar" class="companion-area__avatar" src="" alt="">
|
||||
<p id="reward-companion-text" class="companion-area__text"></p>
|
||||
</div>
|
||||
<div class="reward__actions">
|
||||
<button class="reward__btn reward__btn--continue" id="reward-continue-btn">Weiter ueben</button>
|
||||
<button class="reward__btn reward__btn--back" id="reward-back-btn">Zurueck zum Wald</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section id="screen-parent" class="screen">
|
||||
<div class="parent">
|
||||
<button class="parent__back-btn" id="parent-back-btn">← Zurueck</button>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user