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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<section id="screen-parent" class="screen">
|
||||||
<div class="parent">
|
<div class="parent">
|
||||||
<button class="parent__back-btn" id="parent-back-btn">← Zurueck</button>
|
<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 Area */
|
||||||
.parent {
|
.parent {
|
||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
|
|||||||
Reference in New Issue
Block a user