Compare commits

..

No commits in common. "34bcef5da8695ca4858778c7a4c7c9dba2a03e78" and "3896a6cabbd9395744c03d33a8b88e3c6b8647cf" have entirely different histories.

28 changed files with 328 additions and 977 deletions

View File

@ -11,7 +11,7 @@ Für jede Zielzahl wird sichtbar, wie weit das Kind in den letzten fünf Runden
## Funktionsumfang
- Zielzahlen 4 bis 10, einzeln auswählbar
- Rundenzeit einstellbar (15 / 30 / 60 Sekunden)
- Rundenzeit einstellbar (30 / 60 / 90 / 120 Sekunden)
- Sound an/aus über Lautsprecher-Knopf
- Highscore-Anzeige je Zielzahl mit Krone für die beste Runde
- Läuft offline und kann als App installiert werden (PWA)

View File

@ -9,7 +9,12 @@
--c-rocket-fin: #4a4a6e;
--c-flame-inner: #fff4b3;
--c-flame-outer: #ff8a3d;
--c-star: #ffe34a;
--c-rainbow-1: #ff5555;
--c-rainbow-2: #ffa84a;
--c-rainbow-3: #ffe34a;
--c-rainbow-4: #6cdc6c;
--c-rainbow-5: #4ab3ff;
--c-rainbow-6: #b06cff;
--c-correct: #5fd07a;
--c-text: #1f1f3a;
--c-text-on-dark: #ffffff;

View File

@ -7,7 +7,7 @@
// boost — Raketenschub (Whoosh, niedrig)
// level — neue Stufe (Akkord aufsteigend)
// countdown — letzte 5 Sekunden (Tick pro Sekunde, höher werdend)
// fanfare — Sterne erreicht / Runde gewonnen (kurze Tonfolge)
// fanfare — Regenbogenland erreicht (kurze Tonfolge)
import { get } from 'svelte/store';
import { settings } from '../stores/settings';

View File

@ -9,13 +9,9 @@
let wrongValue = $state<number | null>(null);
let lockedCorrect = $state<number | null>(null);
// Kurze Denkpause nach falscher Antwort: Knöpfe sind ~0,9s gesperrt und gedimmt.
// Kein Punktverlust — macht blindes Durchtippen nur langsamer als Nachdenken.
let coolingDown = $state(false);
let cooldownTimer: ReturnType<typeof setTimeout> | null = null;
function handle(value: number) {
if (disabled || coolingDown || lockedCorrect !== null) return;
if (disabled || lockedCorrect !== null) return;
if (value === correctAnswer) {
lockedCorrect = value;
onAnswer(value);
@ -23,34 +19,21 @@
} else {
wrongValue = value;
onAnswer(value);
coolingDown = true;
if (cooldownTimer) clearTimeout(cooldownTimer);
cooldownTimer = setTimeout(() => {
coolingDown = false;
wrongValue = null;
}, 900);
setTimeout(() => {
if (wrongValue === value) wrongValue = null;
}, 380);
}
}
// Reset NUR bei echtem Aufgabenwechsel. Der Effekt feuert über den choices-Prop auch
// bei jeder Falsch-Antwort (die den game-Store ändert) — daher per Referenzvergleich
// absichern, sonst würde die Denkpause sofort wieder aufgehoben.
let prevChoices: number[] | null = null;
// Reset bei Aufgabenwechsel
$effect(() => {
if (choices !== prevChoices) {
prevChoices = choices;
void choices; // dependency
lockedCorrect = null;
wrongValue = null;
coolingDown = false;
if (cooldownTimer) {
clearTimeout(cooldownTimer);
cooldownTimer = null;
}
}
});
</script>
<div class="answers" class:cooling={coolingDown} role="group" aria-label="Antwortmöglichkeiten">
<div class="answers" role="group" aria-label="Antwortmöglichkeiten">
{#each choices as value (value)}
<button
type="button"
@ -59,7 +42,7 @@
class:correct={lockedCorrect === value}
onclick={() => handle(value)}
aria-label={`Antwort ${value}`}
disabled={disabled || coolingDown}
{disabled}
>
{value}
</button>
@ -83,16 +66,12 @@
color: var(--c-text);
border-radius: 22px;
box-shadow: 0 6px 0 #b9c4dc;
transition: transform 0.08s ease, background 0.15s ease, box-shadow 0.08s ease, opacity 0.2s ease;
transition: transform 0.08s ease, background 0.15s ease, box-shadow 0.08s ease;
}
.answer:active:not(:disabled) {
transform: translateY(4px);
box-shadow: 0 2px 0 #b9c4dc;
}
/* Während der Denkpause die übrigen Knöpfe sanft dimmen (der falsche bleibt rot sichtbar). */
.answers.cooling .answer:not(.wrong) {
opacity: 0.45;
}
.answer.wrong {
animation: shake 0.36s ease;
background: #ffd0d0;

View File

@ -57,15 +57,11 @@
position: absolute;
border-radius: 50%;
background: var(--color);
/* Grundzustand unsichtbar: selbst falls WebKit den backwards-Fill erst spät anwendet,
blitzt nichts auf. `both` setzt dann den 0%-Zustand, die Animation übernimmt. */
opacity: 0;
transform: scale(0);
will-change: transform, opacity;
animation: fly 0.7s ease-out both;
transform: rotate(var(--angle)) translateY(0);
animation: fly 0.7s ease-out forwards;
}
@keyframes fly {
0% { transform: rotate(var(--angle)) translateY(0) scale(0.5); opacity: 1; }
0% { transform: rotate(var(--angle)) translateY(0) scale(0.6); opacity: 1; }
100% { transform: rotate(var(--angle)) translateY(calc(var(--distance) * -1)) scale(1); opacity: 0; }
}
</style>

View File

@ -1,49 +1,18 @@
<script lang="ts">
import type { Stage } from '../../game/stages';
import Rocket from '../svg/Rocket.svelte';
import Balloon from '../svg/Balloon.svelte';
import Cloud from '../svg/Cloud.svelte';
import Moon from '../svg/Moon.svelte';
import Star from '../svg/Star.svelte';
import Rainbow from '../svg/Rainbow.svelte';
import { STAGE_THRESHOLDS } from '../../game/stages';
type Props = { stage: Stage; won: boolean };
let { stage, won }: Props = $props();
type Props = { correct: number; won: boolean };
let { correct, won }: Props = $props();
// Höhenpositionen 0..1 entlang der Bahn (von unten = 0 nach oben = 1); Sterne sind oben.
const STAGE_Y = [0.05, 0.25, 0.45, 0.65, 0.85] as const;
const WIN_Y = 0.95; // beim Sieg fliegt die Rakete hoch hinauf zu den Sternen
// Rakete bewegt sich kontinuierlich pro richtiger Antwort: bei den Schwellen-Counts
// sitzt sie genau auf der jeweiligen Stufen-Markierung, dazwischen wird interpoliert.
// So steigt sie schon ab der ersten richtigen Antwort sichtbar.
const BREAKPOINTS = [0, ...STAGE_THRESHOLDS] as const; // [0, 2, 4, 7, 10]
function yFor(c: number): number {
if (c <= 0) return STAGE_Y[0];
for (let i = 1; i < BREAKPOINTS.length; i++) {
if (c <= BREAKPOINTS[i]) {
const lo = BREAKPOINTS[i - 1];
const t = (c - lo) / (BREAKPOINTS[i] - lo);
return STAGE_Y[i - 1] + t * (STAGE_Y[i] - STAGE_Y[i - 1]);
}
}
return STAGE_Y[4];
}
const rocketY = $derived(won ? WIN_Y : yFor(correct));
// Sterne der obersten Stufe: oberhalb der letzten Linie (>85%) frei im ganzen
// oberen Bereich verteilt, unterschiedliche Höhen, einheitliche Farbe. l/b in %.
const STARS = [
{ l: 11, b: 97, s: 26 },
{ l: 27, b: 90, s: 32 },
{ l: 45, b: 98, s: 22 },
{ l: 62, b: 91, s: 30 },
{ l: 80, b: 97, s: 24 },
{ l: 89, b: 88, s: 26 },
{ l: 19, b: 87, s: 20 },
{ l: 71, b: 87, s: 22 },
{ l: 38, b: 89, s: 18 },
];
// Höhenpositionen 0..1 entlang der Bahn (von unten = 0 nach oben = 1)
const STAGE_Y = [0.05, 0.25, 0.45, 0.65, 0.85, 0.97] as const;
const rocketY = $derived(won ? STAGE_Y[5] : STAGE_Y[stage]);
</script>
<div class="track" class:won>
@ -53,19 +22,18 @@
<div class="layer sky-high"></div>
<div class="layer space"></div>
<!-- Stufen-Linien: jede erreichbare Stufe über die gesamte Breite -->
{#each [STAGE_Y[1], STAGE_Y[2], STAGE_Y[3], STAGE_Y[4]] as y (y)}
<div class="stage-line" style="bottom: {y * 100}%"></div>
{/each}
<!-- Markierungen -->
<div class="marker" style="bottom: {STAGE_Y[1] * 100}%"><Balloon size={78} /></div>
<div class="marker" style="bottom: {STAGE_Y[2] * 100}%; left: 70%"><Cloud size={92} /></div>
<div class="marker" style="bottom: {STAGE_Y[3] * 100}%; left: 30%"><Moon size={78} /></div>
<!-- Sterne-Stufe: oberhalb der letzten Linie im ganzen oberen Bereich verteilt, einheitliche Farbe. -->
{#each STARS as s, i (i)}
<div class="star" style="left: {s.l}%; bottom: {s.b}%"><Star size={s.s} /></div>
{/each}
<div class="marker" style="bottom: {STAGE_Y[1] * 100}%"><Balloon size={56} /></div>
<div class="marker" style="bottom: {STAGE_Y[2] * 100}%; left: 70%"><Cloud size={64} opacity={0.95} /></div>
<div class="marker" style="bottom: {STAGE_Y[3] * 100}%; left: 30%"><Moon size={56} /></div>
<div class="marker stars" style="bottom: {STAGE_Y[4] * 100}%">
<Star size={28} />
<Star size={20} color="#fff" />
<Star size={24} color="#a8e8ff" />
</div>
<div class="marker rainbow" style="bottom: {STAGE_Y[5] * 100}%" class:visible={won}>
<Rainbow size={140} />
</div>
<div class="rocket" style="bottom: calc({rocketY * 100}% - 60px)" aria-hidden="true">
<Rocket size={120} />
@ -92,24 +60,24 @@
.sky-high { bottom: 60%; height: 25%; background: linear-gradient(to top, #4a7ec4, #2a3f7a); }
.space { bottom: 85%; height: 15%; background: linear-gradient(to top, #2a3f7a, #0a0e2a); }
.stage-line {
position: absolute;
left: 0;
right: 0;
border-top: 2px dotted rgba(255, 255, 255, 0.3);
pointer-events: none;
}
.marker {
position: absolute;
left: 18%;
transform: translate(-50%, 50%);
pointer-events: none;
opacity: 0.85;
}
.star {
position: absolute;
.marker.stars { display: flex; gap: 6px; left: 60%; }
.rainbow {
left: 50%;
transform: translate(-50%, 50%);
pointer-events: none;
opacity: 0;
transition: opacity 0.6s ease, transform 0.6s ease;
}
.rainbow.visible {
opacity: 1;
transform: translate(-50%, 50%) scale(1.1);
}
.rocket {

View File

@ -1,13 +1,8 @@
<script lang="ts">
import type { Target } from '../../game/tasks';
import type { Stage } from '../../game/stages';
import { progress } from '../../stores/progress';
import Rocket from '../svg/Rocket.svelte';
import Crown from '../svg/Crown.svelte';
import Ground from '../svg/Ground.svelte';
import Balloon from '../svg/Balloon.svelte';
import Cloud from '../svg/Cloud.svelte';
import Moon from '../svg/Moon.svelte';
import Star from '../svg/Star.svelte';
type Props = { target: Target; highlightDate?: string | null };
let { target, highlightDate = null }: Props = $props();
@ -15,53 +10,21 @@
const top5 = $derived($progress.perTarget[target]?.top5 ?? []);
// Slots immer 5 lang darstellen — leere Plätze bleiben grau.
const slots = $derived(Array.from({ length: 5 }, (_, i) => top5[i] ?? null));
// Erreichte Stufe → passendes Symbol. Stufe 0 (Boden) hat kein Symbol (siehe Markup).
// Größen pro Symbol leicht angeglichen, da die viewBoxes unterschiedlich proportioniert sind.
const SYMBOL: Record<number, { c: typeof Balloon; size: number }> = {
1: { c: Balloon, size: 38 },
2: { c: Cloud, size: 52 },
3: { c: Moon, size: 40 },
4: { c: Star, size: 34 },
};
// Gebrauchte Zeit nur bei voll geschafften Läufen (Sterne): Sekunden, ab 60s als M:SS.
function fmtTime(ms: number): string {
const s = Math.round(ms / 1000);
if (s < 60) return `${s}`;
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
}
</script>
<div class="column" aria-label="Beste Flüge">
{#each slots as slot, i (i)}
<!-- Krone markiert den Bestwert, der gelbe Rahmen den gerade gespielten Versuch — beides kann zusammenfallen. -->
<div
class="slot"
class:empty={!slot}
class:current={slot && highlightDate && slot.date === highlightDate}
>
<div class="slot" class:empty={!slot} class:current={slot && highlightDate && slot.date === highlightDate}>
{#if slot}
{#if i === 0}
<span class="crown"><Crown size={28} /></span>
{/if}
<span class="symbol" aria-label={`Stufe ${slot.stage}`}>
{#if slot.stage === 0}
<Ground size={48} />
{:else}
{@const Symbol = SYMBOL[slot.stage as Stage].c}
<Symbol size={SYMBOL[slot.stage as Stage].size} />
{/if}
<Rocket size={44} flameAnimated={false} />
<span class="stage-label" aria-label={`Stufe ${slot.stage}`}>
{#each Array(slot.stage) as _, dotIndex (dotIndex)}
<span class="stage-dot"></span>
{/each}
</span>
{#if slot.stage === 4 && slot.timeMs != null}
<span class="time" aria-label={`Zeit ${fmtTime(slot.timeMs)}`}>
<svg width="13" height="13" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="2.4" />
<path d="M12 7 L12 12 L16 14" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<span class="time-num">{fmtTime(slot.timeMs)}</span>
</span>
{/if}
{:else}
<div class="placeholder"></div>
{/if}
@ -76,26 +39,23 @@
gap: 10px;
height: 100%;
}
/* Karten sind bewusst deckend (dunkel): die Hintergrund-Animationen laufen dahinter
und können die Inhalte nie überdecken. Kein backdrop-filter — der Blur zwingt
WebKit/Safari, den Bereich bei jeder bewegten Wolke neu zu rastern (Ruckeln). */
.slot {
position: relative;
background: rgba(16, 26, 54, 0.92);
border: 1px solid rgba(255, 255, 255, 0.16);
background: rgba(255, 255, 255, 0.16);
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 4px 8px;
min-height: 56px;
}
.slot.empty {
background: rgba(16, 26, 54, 0.6);
background: rgba(255, 255, 255, 0.06);
}
.slot.current {
border-color: var(--c-rocket-window);
box-shadow: 0 0 0 2px var(--c-rocket-window), 0 0 18px rgba(255, 229, 102, 0.5);
background: rgba(255, 229, 102, 0.3);
box-shadow: 0 0 0 3px var(--c-rocket-window);
}
.crown {
position: absolute;
@ -103,25 +63,12 @@
left: 50%;
transform: translateX(-50%);
}
.symbol {
display: flex;
align-items: center;
justify-content: center;
}
.time {
position: absolute;
bottom: 5px;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 3px;
color: var(--c-rocket-window);
}
.time-num {
font-size: 13px;
font-weight: 800;
line-height: 1;
.stage-label { display: flex; gap: 3px; }
.stage-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--c-rocket-window);
}
.placeholder {
width: 32px;

View File

@ -1,41 +1,41 @@
<script lang="ts">
import type { Target } from '../../game/tasks';
import { progress } from '../../stores/progress';
import Ground from '../svg/Ground.svelte';
import Balloon from '../svg/Balloon.svelte';
import Cloud from '../svg/Cloud.svelte';
import Moon from '../svg/Moon.svelte';
import Star from '../svg/Star.svelte';
import Rocket from '../svg/Rocket.svelte';
import Crown from '../svg/Crown.svelte';
type Props = { target: Target; onClick: (t: Target) => void };
let { target, onClick }: Props = $props();
const runs = $derived($progress.perTarget[target]?.runs ?? 0);
const bestStage = $derived(($progress.perTarget[target]?.top5[0]?.stage ?? 0) as number);
// Bester Versuch → sein Stufen-Symbol (statt Krone).
const SYMBOL: Record<number, { c: typeof Balloon; size: number }> = {
0: { c: Ground, size: 60 },
1: { c: Balloon, size: 52 },
2: { c: Cloud, size: 72 },
3: { c: Moon, size: 56 },
4: { c: Star, size: 52 },
};
const runs = $derived($progress.perTarget[target]?.runs ?? 0);
// Fortschritt 0..1 → Höhe der Mini-Rakete in der Karte
const progressFraction = $derived(Math.min(bestStage / 5, 1));
const masteredAtRainbow = $derived(bestStage >= 5);
</script>
<button class="card" type="button" onclick={() => onClick(target)} aria-label={`Zielzahl ${target}`}>
<span class="number">{target}</span>
<span class="best" aria-hidden="true">
{#if runs > 0}
{#if bestStage === 2}
<!-- Auf der hellen Karte braucht die Wolke Farbe/Kontur, sonst weiß auf weiß. -->
<Cloud size={72} fill="#cfe2ff" stroke="#7e97c4" />
{:else}
{@const Best = SYMBOL[bestStage].c}
<Best size={SYMBOL[bestStage].size} />
{/if}
<div class="number">{target}</div>
<div class="track" aria-hidden="true">
<div class="rocket-wrap" style="bottom: calc({progressFraction * 100}% - 18px)">
<Rocket size={36} flameAnimated={false} />
</div>
<div class="dot dot-1"></div>
<div class="dot dot-2"></div>
<div class="dot dot-3"></div>
<div class="dot dot-4"></div>
<div class="dot dot-5"></div>
</div>
<div class="footer">
{#if masteredAtRainbow}
<Crown size={28} />
{/if}
<span class="runs" aria-label={`${runs} Runden gespielt`}>
{#each Array(Math.min(runs, 5)) as _, i (i)}
<span class="run-dot"></span>
{/each}
</span>
</div>
</button>
<style>
@ -44,25 +44,64 @@
border-radius: var(--radius-card);
box-shadow: var(--shadow-soft);
aspect-ratio: 3 / 4;
width: 100%;
min-width: 0;
padding: 12px;
display: grid;
grid-template-rows: 1fr auto;
place-items: center;
grid-template-rows: auto 1fr auto;
gap: 6px;
transition: transform 0.12s ease;
}
.card:active { transform: scale(0.96); }
.number {
font-size: clamp(64px, 13vw, 124px);
font-size: clamp(48px, 8vw, 80px);
font-weight: 800;
color: var(--c-text);
text-align: center;
line-height: 1;
}
.best {
display: grid;
place-items: center;
min-height: 72px;
.track {
position: relative;
background: linear-gradient(to top, #6cc26c 0%, #6cc26c 8%, #b6e3ff 30%, #6fb3ff 70%, #1a2c5c 100%);
border-radius: 12px;
overflow: hidden;
}
.rocket-wrap {
position: absolute;
left: 50%;
transform: translateX(-50%);
transition: bottom 0.4s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.dot {
position: absolute;
left: 4px;
width: 4px;
height: 4px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.7);
}
.dot-1 { bottom: 18%; }
.dot-2 { bottom: 38%; }
.dot-3 { bottom: 58%; }
.dot-4 { bottom: 78%; }
.dot-5 { bottom: 92%; background: var(--c-rainbow-3); width: 6px; height: 6px; }
.footer {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 32px;
padding: 0 4px;
}
.runs { display: flex; gap: 3px; }
.run-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--c-rocket-fin);
opacity: 0.5;
}
</style>

View File

@ -1,192 +0,0 @@
<script lang="ts">
import type { Stage } from '../../game/stages';
import Balloon from '../svg/Balloon.svelte';
import Cloud from '../svg/Cloud.svelte';
import Moon from '../svg/Moon.svelte';
import Star from '../svg/Star.svelte';
import Meadow from '../svg/Meadow.svelte';
import Butterfly from '../svg/Butterfly.svelte';
let { stage }: { stage: Stage } = $props();
// Animationen füllen die ganze Fläche hinter den (deckenden) Karten. l/t/b in %,
// s = Größe, d = Dauer (s), dl = Verzögerung (s), flip/rot = Variation, c/sc/cv = Symbol-Variante.
type Deco = {
l: number; t?: number; b?: number; s: number; d: number; dl: number;
flip?: boolean; rot?: number; c?: number; sc?: number; cv?: number;
};
// Ballon-Farbvarianten [Körper, Kontur].
const BALLOON_COLORS = [
['#ff8aa8', '#c25578'],
['#6cc2ff', '#3a82c2'],
['#7ad17a', '#3f8f3f'],
['#ffd24a', '#c79a2a'],
['#c79aff', '#8a5fd0'],
];
const STAR_COLORS = ['#ffe34a', '#ffffff', '#a8e8ff', '#ffd24a'];
const balloons: Deco[] = [
{ l: 5, t: 52, s: 92, c: 0, rot: -5, d: 3.6, dl: 0 },
{ l: 17, t: 12, s: 62, c: 1, flip: true, rot: 7, d: 4.3, dl: 0.7 },
{ l: 31, t: 76, s: 76, c: 2, rot: 3, d: 3.9, dl: 0.3 },
{ l: 58, t: 28, s: 70, c: 3, flip: true, rot: -6, d: 4.6, dl: 1.0 },
{ l: 80, t: 60, s: 96, c: 4, rot: 4, d: 3.4, dl: 0.2 },
{ l: 91, t: 16, s: 58, c: 1, flip: true, rot: -3, d: 4.8, dl: 0.5 },
{ l: 69, t: 86, s: 60, c: 0, rot: 5, d: 4.0, dl: 0.9 },
];
const clouds: Deco[] = [
{ l: 3, t: 16, s: 124, cv: 0, d: 7.5, dl: 0 },
{ l: 24, t: 62, s: 102, cv: 1, flip: true, d: 9, dl: 0.6 },
{ l: 13, t: 82, s: 92, cv: 0, flip: true, d: 8.4, dl: 1.2 },
{ l: 57, t: 22, s: 146, cv: 1, d: 7, dl: 0.3 },
{ l: 79, t: 64, s: 112, cv: 0, flip: true, d: 9.4, dl: 0.9 },
{ l: 88, t: 38, s: 96, cv: 1, d: 8, dl: 1.5 },
];
const stars: Deco[] = [
{ l: 7, t: 15, s: 42, sc: 0, rot: 0, d: 1.8, dl: 0 },
{ l: 19, t: 49, s: 28, sc: 1, rot: 18, d: 2.4, dl: 0.5 },
{ l: 11, t: 79, s: 36, sc: 2, rot: -12, d: 2.0, dl: 1.0 },
{ l: 34, t: 29, s: 24, sc: 0, rot: 8, d: 2.6, dl: 0.3 },
{ l: 29, t: 88, s: 32, sc: 3, rot: -8, d: 2.2, dl: 0.8 },
{ l: 61, t: 13, s: 34, sc: 0, rot: 14, d: 1.9, dl: 0.4 },
{ l: 73, t: 47, s: 48, sc: 1, rot: -10, d: 2.3, dl: 0.1 },
{ l: 66, t: 82, s: 26, sc: 2, rot: 6, d: 2.7, dl: 1.1 },
{ l: 87, t: 23, s: 40, sc: 0, rot: -16, d: 2.0, dl: 0.7 },
{ l: 93, t: 61, s: 30, sc: 3, rot: 10, d: 2.5, dl: 0.2 },
];
function baseStyle(d: Deco): string {
const vert = d.b != null ? `bottom:${d.b}%` : `top:${d.t}%`;
return `left:${d.l}%; ${vert}; transform: scaleX(${d.flip ? -1 : 1}) rotate(${d.rot ?? 0}deg);`;
}
const fx = (d: Deco) => `--dur:${d.d}s; animation-delay:${d.dl}s`;
</script>
<div class="ambience" aria-hidden="true">
{#if stage === 1}
{#each balloons as d, i (i)}
<span class="deco" style={baseStyle(d)}>
<span class="anim float" style={fx(d)}>
<Balloon size={d.s} color={BALLOON_COLORS[d.c ?? 0][0]} stroke={BALLOON_COLORS[d.c ?? 0][1]} />
</span>
</span>
{/each}
{:else if stage === 2}
{#each clouds as d, i (i)}
<span class="deco" style={baseStyle(d)}>
<span class="anim drift" style={fx(d)}><Cloud size={d.s} variant={d.cv ?? 0} opacity={0.95} /></span>
</span>
{/each}
{:else if stage === 3}
<!-- Mond zieht eine Bahn über die ganze Breite, keine Sterne (sonst Verwechslung mit dem Stern-Level). -->
<span class="moon"><Moon size={104} /></span>
{:else if stage >= 4}
{#each stars as d, i (i)}
<span class="deco" style={baseStyle(d)}>
<span class="anim twinkle" style={fx(d)}><Star size={d.s} color={STAR_COLORS[d.sc ?? 0]} /></span>
</span>
{/each}
{:else}
<!-- Boden-Stufe: durchgehende Wiese unten + flatternde Schmetterlinge. -->
<Meadow />
<span class="butterfly b1"><Butterfly size={50} color="#ff8aa8" stroke="#c25578" flap={0.45} /></span>
<span class="butterfly b2"><Butterfly size={40} color="#8ad1ff" stroke="#3a82c2" flap={0.55} /></span>
{/if}
</div>
<style>
.ambience {
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
z-index: 1;
}
.deco {
position: absolute;
}
.anim {
display: inline-block;
/* Auf eine eigene Compositor-Ebene heben, sonst ruckelt die erste Animationsrunde
auf WebKit/Safari, bis die Ebene „aufgewärmt" ist. */
will-change: transform;
}
.float { animation: float var(--dur, 3.6s) ease-in-out infinite alternate; }
/* translate3d/translateZ erzwingen eine echte GPU-Compositor-Ebene — WebKit/Safari
rendert 3D-transformierte Elemente flüssig, 2D-Transforms ruckeln dort sonst anfangs. */
@keyframes float {
0% { transform: translate3d(0, 18px, 0) rotate(-5deg); }
100% { transform: translate3d(0, -30px, 0) rotate(5deg); }
}
.drift { animation: drift var(--dur, 8s) ease-in-out infinite alternate; }
@keyframes drift {
from { transform: translate3d(-55px, 0, 0); }
to { transform: translate3d(55px, 0, 0); }
}
.twinkle { animation: twinkle var(--dur, 2.2s) ease-in-out infinite; }
@keyframes twinkle {
0%, 100% { opacity: 0.25; transform: scale(0.5) translateZ(0); }
50% { opacity: 1; transform: scale(1.35) translateZ(0); }
}
.butterfly { position: absolute; }
.b1 { animation: fly1 9s ease-in-out infinite; }
.b2 { animation: fly2 11s ease-in-out infinite; }
@keyframes fly1 {
0% { left: 12%; top: 62%; }
25% { left: 24%; top: 44%; }
50% { left: 35%; top: 56%; }
75% { left: 20%; top: 50%; }
100% { left: 12%; top: 62%; }
}
@keyframes fly2 {
0% { left: 64%; top: 42%; }
25% { left: 80%; top: 58%; }
50% { left: 88%; top: 46%; }
75% { left: 72%; top: 38%; }
100% { left: 64%; top: 42%; }
}
.moon {
position: absolute;
animation: moonpath 12s linear infinite;
}
/* Leuchten als radialer Verlauf statt drop-shadow-Filter:
Safari/WebKit rendert animierte Filter ausgefranst, ein Gradient bleibt sauber. */
.moon::before {
content: '';
position: absolute;
inset: -45%;
border-radius: 50%;
background: radial-gradient(
circle,
rgba(255, 233, 163, 0.6) 0%,
rgba(255, 233, 163, 0.22) 45%,
transparent 70%
);
z-index: -1;
pointer-events: none;
}
/* Parabel-Bogen: top folgt einer Kurve (kein Knick am Scheitel), left läuft gleichmäßig. */
@keyframes moonpath {
0% { left: -16%; top: 36%; }
12.5% { left: 0%; top: 26.4%; }
25% { left: 16%; top: 19.5%; }
37.5% { left: 32%; top: 15.4%; }
50% { left: 48%; top: 14%; }
62.5% { left: 64%; top: 15.4%; }
75% { left: 80%; top: 19.5%; }
87.5% { left: 96%; top: 26.4%; }
100% { left: 112%; top: 36%; }
}
@media (prefers-reduced-motion: reduce) {
.anim, .moon, .butterfly { animation: none; }
}
</style>

View File

@ -1,72 +0,0 @@
<script lang="ts">
// Vollbild umschalten via Fullscreen-API. Wird ausgeblendet, wo die API fehlt (z. B. iPhone-Safari).
const supported =
typeof document !== 'undefined' && typeof document.documentElement.requestFullscreen === 'function';
let isFullscreen = $state(false);
function sync() {
isFullscreen = !!document.fullscreenElement;
}
$effect(() => {
sync();
document.addEventListener('fullscreenchange', sync);
return () => document.removeEventListener('fullscreenchange', sync);
});
function toggle() {
if (document.fullscreenElement) {
document.exitFullscreen?.();
} else {
document.documentElement.requestFullscreen?.().catch(() => {});
}
}
</script>
{#if supported}
<button
class="fs-toggle"
type="button"
aria-label={isFullscreen ? 'Vollbild beenden' : 'Vollbild'}
onclick={toggle}
>
{#if isFullscreen}
<svg width="30" height="30" viewBox="0 0 32 32" aria-hidden="true">
<path
d="M13 4 L13 13 L4 13 M19 4 L19 13 L28 13 M13 28 L13 19 L4 19 M19 28 L19 19 L28 19"
fill="none"
stroke="currentColor"
stroke-width="2.6"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
{:else}
<svg width="30" height="30" viewBox="0 0 32 32" aria-hidden="true">
<path
d="M4 11 L4 4 L11 4 M28 11 L28 4 L21 4 M4 21 L4 28 L11 28 M28 21 L28 28 L21 28"
fill="none"
stroke="currentColor"
stroke-width="2.6"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
{/if}
</button>
{/if}
<style>
.fs-toggle {
width: 64px;
height: 64px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.18);
color: var(--c-text-on-dark);
display: grid;
place-items: center;
transition: transform 0.12s ease;
}
.fs-toggle:active { transform: scale(0.92); }
</style>

View File

@ -1,14 +1,10 @@
<script lang="ts">
let {
size = 80,
color = '#ff8aa8',
stroke = '#c25578',
}: { size?: number; color?: string; stroke?: string } = $props();
let { size = 80 }: { size?: number } = $props();
</script>
<svg width={size} height={size} viewBox="0 0 80 110" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<ellipse cx="40" cy="42" rx="30" ry="36" fill={color} {stroke} stroke-width="2" />
<ellipse cx="30" cy="32" rx="8" ry="14" fill="#ffffff" opacity="0.45" />
<path d="M36 78 L44 78 L42 84 L38 84 Z" fill={stroke} />
<ellipse cx="40" cy="42" rx="30" ry="36" fill="#ff8aa8" stroke="#c25578" stroke-width="2" />
<ellipse cx="30" cy="32" rx="8" ry="14" fill="#ffd0db" opacity="0.7" />
<path d="M36 78 L44 78 L42 84 L38 84 Z" fill="#c25578" />
<path d="M40 84 Q38 95 42 105" stroke="#5c3344" stroke-width="1.5" fill="none" />
</svg>

View File

@ -1,46 +0,0 @@
<script lang="ts">
// Schmetterling von oben: zwei Flügelpaare, schmaler Körper, Fühler — keine Gesichter.
// Die Flügel flattern (scaleX-Puls um die Körpermitte).
let {
size = 48,
color = '#ff8aa8',
stroke = '#c25578',
flap = 0.5,
}: { size?: number; color?: string; stroke?: string; flap?: number } = $props();
</script>
<svg
width={size}
height={size * 0.85}
viewBox="0 0 64 54"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
style="--flap:{flap}s"
>
<g class="wings" {stroke} stroke-width="1.5">
<ellipse cx="19" cy="19" rx="16" ry="13" fill={color} />
<ellipse cx="45" cy="19" rx="16" ry="13" fill={color} />
<ellipse cx="23" cy="37" rx="11" ry="10" fill={color} />
<ellipse cx="41" cy="37" rx="11" ry="10" fill={color} />
<circle cx="17" cy="18" r="3.5" fill="#ffffff" opacity="0.5" stroke="none" />
<circle cx="47" cy="18" r="3.5" fill="#ffffff" opacity="0.5" stroke="none" />
</g>
<ellipse cx="32" cy="28" rx="2.6" ry="15" fill="#4a3a2a" />
<path d="M32 14 Q28 6 23 5" stroke="#4a3a2a" stroke-width="1.5" fill="none" stroke-linecap="round" />
<path d="M32 14 Q36 6 41 5" stroke="#4a3a2a" stroke-width="1.5" fill="none" stroke-linecap="round" />
</svg>
<style>
.wings {
transform-box: fill-box;
transform-origin: center;
animation: flap var(--flap, 0.5s) ease-in-out infinite;
}
@keyframes flap {
0%, 100% { transform: scaleX(1); }
50% { transform: scaleX(0.55); }
}
@media (prefers-reduced-motion: reduce) {
.wings { animation: none; }
}
</style>

View File

@ -1,27 +1,12 @@
<script lang="ts">
// variant 0/1 = zwei leicht unterschiedliche Wolkenformen für Abwechslung.
let {
size = 100,
opacity = 1,
variant = 0,
fill = 'var(--c-cloud)',
stroke = '#cad6e8',
}: { size?: number; opacity?: number; variant?: number; fill?: string; stroke?: string } = $props();
let { size = 100, opacity = 1 }: { size?: number; opacity?: number } = $props();
</script>
<svg width={size} height={size * 0.6} viewBox="0 0 120 70" xmlns="http://www.w3.org/2000/svg" {opacity} aria-hidden="true">
<g {fill} {stroke} stroke-width="2">
{#if variant === 1}
<ellipse cx="26" cy="44" rx="20" ry="16" />
<ellipse cx="52" cy="30" rx="24" ry="20" />
<ellipse cx="80" cy="36" rx="26" ry="22" />
<ellipse cx="100" cy="46" rx="16" ry="14" />
<rect x="20" y="44" width="84" height="18" rx="9" />
{:else}
<g fill="var(--c-cloud)" stroke="#cad6e8" stroke-width="2">
<ellipse cx="30" cy="42" rx="22" ry="18" />
<ellipse cx="60" cy="32" rx="28" ry="22" />
<ellipse cx="90" cy="42" rx="22" ry="18" />
<rect x="22" y="42" width="76" height="18" rx="9" />
{/if}
</g>
</svg>

View File

@ -1,16 +0,0 @@
<script lang="ts">
let { size = 60 }: { size?: number } = $props();
</script>
<svg width={size} height={size * 0.7} viewBox="0 0 80 56" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<!-- Erde -->
<rect x="4" y="36" width="72" height="18" rx="9" fill="#b07a4a" stroke="#8a5a32" stroke-width="2" />
<!-- Gras mit welliger Oberkante -->
<path
d="M5 46 Q5 34 11 34 Q14 27 19 34 Q23 29 28 34 Q32 27 37 34 Q41 29 46 34 Q50 27 55 34 Q59 29 64 34 Q69 27 74 34 Q75 35 75 46 Z"
fill="#5fb85f"
stroke="#3f8f3f"
stroke-width="2"
stroke-linejoin="round"
/>
</svg>

View File

@ -1,95 +0,0 @@
<script lang="ts">
// Wehende Wiese über die volle Breite: Erdschicht, zweilagige Grasnarbe mit welliger
// Oberkante und darauf verteilte, sich wiegende Grashalme und Blümchen.
type Tuft = {
l: number; type: 'blade' | 'flower'; color: string; h: number; d: number; dl: number; flip: boolean;
};
const tufts: Tuft[] = [
{ l: 5, type: 'blade', color: '#4ea34e', h: 38, d: 2.6, dl: 0, flip: false },
{ l: 13, type: 'flower', color: '#ff7aa8', h: 44, d: 3.0, dl: 0.5, flip: false },
{ l: 23, type: 'blade', color: '#3f8f3f', h: 30, d: 2.4, dl: 0.8, flip: true },
{ l: 33, type: 'flower', color: '#ffd24a', h: 48, d: 3.2, dl: 0.2, flip: false },
{ l: 44, type: 'blade', color: '#4ea34e', h: 34, d: 2.7, dl: 1.0, flip: false },
{ l: 55, type: 'flower', color: '#c79aff', h: 42, d: 2.9, dl: 0.4, flip: true },
{ l: 65, type: 'blade', color: '#3f8f3f', h: 32, d: 2.5, dl: 0.7, flip: false },
{ l: 75, type: 'flower', color: '#ff9d5c', h: 46, d: 3.1, dl: 0.1, flip: false },
{ l: 85, type: 'blade', color: '#4ea34e', h: 36, d: 2.6, dl: 0.9, flip: true },
{ l: 93, type: 'flower', color: '#ff7aa8', h: 38, d: 2.8, dl: 0.3, flip: false },
];
</script>
<div class="meadow" aria-hidden="true">
<div class="soil"></div>
<svg class="grass back" preserveAspectRatio="none" viewBox="0 0 100 24">
<path d="M0 24 L0 13 Q5 6 10 13 Q15 6 20 13 Q25 6 30 13 Q35 6 40 13 Q45 6 50 13 Q55 6 60 13 Q65 6 70 13 Q75 6 80 13 Q85 6 90 13 Q95 6 100 13 L100 24 Z" />
</svg>
<svg class="grass front" preserveAspectRatio="none" viewBox="0 0 100 24">
<path d="M0 24 L0 15 Q4 9 9 15 Q14 9 19 15 Q24 9 29 15 Q34 9 39 15 Q44 9 49 15 Q54 9 59 15 Q64 9 69 15 Q74 9 79 15 Q84 9 89 15 Q94 9 99 15 L100 24 Z" />
</svg>
{#each tufts as t, i (i)}
<span class="tuft" style="left:{t.l}%; transform: scaleX({t.flip ? -1 : 1});">
<span class="sway" style="--dur:{t.d}s; animation-delay:{t.dl}s;">
{#if t.type === 'blade'}
<svg width={t.h * 0.45} height={t.h} viewBox="0 0 18 40" xmlns="http://www.w3.org/2000/svg">
<path d="M9 40 Q3 22 8 2 Q12 22 11 40 Z" fill={t.color} />
</svg>
{:else}
<svg width={t.h * 0.6} height={t.h} viewBox="0 0 24 40" xmlns="http://www.w3.org/2000/svg">
<path d="M12 40 L12 18" stroke="#3f8f3f" stroke-width="2.5" />
<circle cx="12" cy="11" r="6" fill={t.color} />
<circle cx="6" cy="13" r="4.5" fill={t.color} />
<circle cx="18" cy="13" r="4.5" fill={t.color} />
<circle cx="9" cy="6" r="4.5" fill={t.color} />
<circle cx="15" cy="6" r="4.5" fill={t.color} />
<circle cx="12" cy="10" r="3" fill="#fff3b0" />
</svg>
{/if}
</span>
</span>
{/each}
</div>
<style>
.meadow {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 128px;
}
.soil {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 60px;
background: linear-gradient(to top, #6e4527, #9c6a3c);
}
.grass {
position: absolute;
left: 0;
right: 0;
width: 100%;
}
.grass path { fill: currentColor; }
.grass.back { bottom: 52px; height: 44px; color: #3f8f3f; }
.grass.front { bottom: 44px; height: 44px; color: #5fb85f; }
.tuft {
position: absolute;
bottom: 58px;
}
.sway {
display: inline-block;
transform-origin: bottom center;
animation: sway var(--dur, 2.6s) ease-in-out infinite alternate;
}
@keyframes sway {
from { transform: rotate(-9deg); }
to { transform: rotate(9deg); }
}
@media (prefers-reduced-motion: reduce) {
.sway { animation: none; }
}
</style>

View File

@ -0,0 +1,14 @@
<script lang="ts">
let { size = 200 }: { size?: number } = $props();
</script>
<svg width={size} height={size * 0.6} viewBox="0 0 200 120" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<g fill="none" stroke-width="14" stroke-linecap="round">
<path d="M20 110 A80 80 0 0 1 180 110" stroke="var(--c-rainbow-1)" />
<path d="M34 110 A66 66 0 0 1 166 110" stroke="var(--c-rainbow-2)" />
<path d="M48 110 A52 52 0 0 1 152 110" stroke="var(--c-rainbow-3)" />
<path d="M62 110 A38 38 0 0 1 138 110" stroke="var(--c-rainbow-4)" />
<path d="M76 110 A24 24 0 0 1 124 110" stroke="var(--c-rainbow-5)" />
<path d="M90 110 A10 10 0 0 1 110 110" stroke="var(--c-rainbow-6)" />
</g>
</svg>

View File

@ -37,7 +37,7 @@ describe('progress', () => {
it('limits top5 to five entries', () => {
let p = emptyProgress();
for (let i = 0; i < 10; i++) p = recordRun(p, 6, ((i % 4) + 1) as 1 | 2 | 3 | 4);
for (let i = 0; i < 10; i++) p = recordRun(p, 6, ((i % 5) + 1) as 1 | 2 | 3 | 4 | 5);
expect(p.perTarget[6].top5.length).toBe(5);
expect(p.perTarget[6].runs).toBe(10);
});

View File

@ -6,7 +6,7 @@ const SCHEMA_VERSION = 1;
const PROGRESS_KEY = 'zahlzerlegung.progress';
const SETTINGS_KEY = 'zahlzerlegung.settings';
export type RunRecord = { stage: Stage; date: string; timeMs?: number };
export type RunRecord = { stage: Stage; date: string };
export type TargetProgress = {
runs: number;
@ -83,15 +83,9 @@ export function saveProgress(p: Progress): void {
writeJson(PROGRESS_KEY, p);
}
export function recordRun(
p: Progress,
target: Target,
stage: Stage,
date: string = new Date().toISOString(),
timeMs?: number
): Progress {
export function recordRun(p: Progress, target: Target, stage: Stage): Progress {
const prev = p.perTarget[target] ?? { runs: 0, top5: [] };
const next: RunRecord = { stage, date, timeMs };
const next: RunRecord = { stage, date: new Date().toISOString() };
const merged = [...prev.top5, next].sort((a, b) => {
if (b.stage !== a.stage) return b.stage - a.stage;
return b.date.localeCompare(a.date);

View File

@ -1,11 +1,13 @@
// Schwellen aus BRAINSTORM.md: 2/4/7/10 richtige Aufgaben pro Stufenaufstieg.
// Stufen 0..4: 0 Boden, 1 Luftballon, 2 Wolken, 3 Mond, 4 Sterne.
// Die Sterne sind die höchste Stufe und zugleich der Sieg (isWin).
// Während der Runde zeigt die Rakete Stufen 0..4:
// 0 Boden, 1 Luftballon, 2 Wolken, 3 Mond, 4 Sterne.
// Stufe 5 (Regenbogenland) ist die Win-Celebration — wird nicht durch stageFor zurückgegeben,
// sondern durch isWin() signalisiert und im ResultScreen dargestellt.
export const STAGE_THRESHOLDS = [2, 4, 7, 10] as const;
export const TOTAL_TO_WIN = 10;
export type Stage = 0 | 1 | 2 | 3 | 4;
export type Stage = 0 | 1 | 2 | 3 | 4 | 5;
export const STAGE_NAMES: Record<Stage, string> = {
0: 'boden',
@ -13,6 +15,7 @@ export const STAGE_NAMES: Record<Stage, string> = {
2: 'wolken',
3: 'mond',
4: 'sterne',
5: 'regenbogenland',
};
export function stageFor(correct: number): Stage {

View File

@ -1,20 +1,21 @@
import { describe, it, expect } from 'vitest';
import { buildTask, createDeck, drawTask, TARGETS, type Target } from './tasks';
import { generateTask, TARGETS, type Target } from './tasks';
describe('buildTask', () => {
describe('generateTask', () => {
it.each(TARGETS)('produces a valid decomposition for target %i', (target) => {
for (let given = 0; given <= target; given++) {
const task = buildTask(target, given);
for (let i = 0; i < 50; i++) {
const task = generateTask(target);
expect(task.target).toBe(target);
expect(task.given).toBe(given);
expect(task.given).toBeGreaterThanOrEqual(1);
expect(task.given).toBeLessThanOrEqual(target - 1);
expect(task.given + task.answer).toBe(target);
}
});
it('always includes the correct answer in choices', () => {
for (const target of TARGETS) {
for (let given = 0; given <= target; given++) {
const task = buildTask(target, given);
for (let i = 0; i < 30; i++) {
const task = generateTask(target);
expect(task.choices).toContain(task.answer);
}
}
@ -22,75 +23,34 @@ describe('buildTask', () => {
it('produces choices without duplicates', () => {
for (const target of TARGETS) {
for (let given = 0; given <= target; given++) {
const task = buildTask(target, given);
expect(new Set(task.choices).size).toBe(task.choices.length);
for (let i = 0; i < 30; i++) {
const task = generateTask(target);
const unique = new Set(task.choices);
expect(unique.size).toBe(task.choices.length);
}
}
});
it('returns 4 choices for every target', () => {
for (const target of TARGETS) {
const task = buildTask(target, 1);
expect(task.choices.length).toBe(4);
}
});
});
// Zieht n Aufgaben hintereinander aus einem frischen Deck.
function drawSequence(target: Target, n: number): number[] {
let deck = createDeck(target);
const givens: number[] = [];
for (let i = 0; i < n; i++) {
const res = drawTask(deck);
givens.push(res.task.given);
deck = res.deck;
}
return givens;
}
describe('drawTask deck', () => {
it('never repeats the same given twice in a row', () => {
for (const target of TARGETS) {
// Lange Reihe über viele Beutel-Zyklen, auch über die Zyklusgrenzen hinweg.
const seq = drawSequence(target, 200);
for (let i = 1; i < seq.length; i++) {
expect(seq[i]).not.toBe(seq[i - 1]);
}
it('returns 3 choices for targets >= 5', () => {
for (const target of [5, 6, 7, 8, 9, 10] as Target[]) {
const task = generateTask(target);
expect(task.choices.length).toBe(3);
}
});
it('distributes givens as evenly as possible (counts differ by at most 1)', () => {
for (const target of TARGETS) {
const poolSize = target + 1;
const seq = drawSequence(target, poolSize * 7 + 3);
const counts = new Map<number, number>();
for (const g of seq) counts.set(g, (counts.get(g) ?? 0) + 1);
// Jede Vorgabe muss vorgekommen sein.
expect(counts.size).toBe(poolSize);
const values = [...counts.values()];
expect(Math.max(...values) - Math.min(...values)).toBeLessThanOrEqual(1);
}
it('returns 3 choices for target 4 (3 possible answers total)', () => {
const task = generateTask(4);
expect(task.choices.length).toBe(3);
});
it('uses each given at most twice over a full 10-task run', () => {
// Ein Spieldurchlauf zieht ~10 Aufgaben; der kleinste Pool (Zielzahl 4) hat 5.
for (const target of TARGETS) {
for (let trial = 0; trial < 50; trial++) {
const seq = drawSequence(target, 10);
const counts = new Map<number, number>();
for (const g of seq) counts.set(g, (counts.get(g) ?? 0) + 1);
expect(Math.max(...counts.values())).toBeLessThanOrEqual(2);
}
}
});
it('keeps every given within [0..target]', () => {
for (const target of TARGETS) {
for (const g of drawSequence(target, 60)) {
expect(g).toBeGreaterThanOrEqual(0);
expect(g).toBeLessThanOrEqual(target);
}
it('avoids reusing the same given when possible', () => {
const target: Target = 7;
const prev = generateTask(target);
let differed = 0;
for (let i = 0; i < 50; i++) {
const t = generateTask(target, prev);
if (t.given !== prev.given) differed++;
}
expect(differed).toBe(50);
});
});

View File

@ -1,14 +1,12 @@
// Zahlzerlegungs-Aufgabengenerator.
//
// Eine Aufgabe für Zielzahl T besteht aus:
// given: vorgegebene Zerlegungszahl in [0..T] (inkl. der trivialen 0/T-Zerlegung)
// given: vorgegebene Zerlegungszahl in [1..T-1]
// answer: T - given (das Kind muss diese tippen)
// choices: 4 große Buttons (richtige Antwort + 3 Distraktoren), gemischt
// choices: 3 große Buttons (richtige Antwort + 2 Distraktoren), gemischt
//
// Aufgabenfolge pro Spieldurchlauf: ein "Beutel" (TaskDeck) zieht alle möglichen
// Vorgaben ohne Zurücklegen, mischt nach dem Leeren neu und verhindert direkte
// Wiederholungen. So kommt nie 2x dieselbe Vorgabe hintereinander und die
// Häufigkeiten bleiben über den Durchlauf maximal gleichmäßig.
// Für sehr kleine Zielzahlen (T=4) gibt es nur 3 mögliche Antworten gesamt,
// daher ggf. nur 2 oder 3 Choices.
export type Target = 4 | 5 | 6 | 7 | 8 | 9 | 10;
@ -21,7 +19,11 @@ export type Task = {
export const TARGETS: Target[] = [4, 5, 6, 7, 8, 9, 10];
const DESIRED_CHOICES = 4;
const DESIRED_CHOICES = 3;
function randInt(min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
function shuffle<T>(arr: T[]): T[] {
const a = arr.slice();
@ -32,46 +34,26 @@ function shuffle<T>(arr: T[]): T[] {
return a;
}
// Alle möglichen Vorgabe-Zahlen für eine Zielzahl: 0..target.
export function givenPool(target: Target): number[] {
const pool: number[] = [];
for (let i = 0; i <= target; i++) pool.push(i);
return pool;
}
export function generateTask(target: Target, prev?: Task): Task {
// Mögliche given-Werte: 1..target-1.
const candidates: number[] = [];
for (let i = 1; i < target; i++) candidates.push(i);
// Baut die Aufgabe zu einer konkreten Vorgabe (richtige Antwort + 2 zufällige Distraktoren).
export function buildTask(target: Target, given: number): Task {
// Vermeide identisches given wie zuletzt, wenn Auswahl groß genug.
const givenPool =
prev && prev.target === target && candidates.length > 1
? candidates.filter((g) => g !== prev.given)
: candidates;
const given = givenPool[randInt(0, givenPool.length - 1)];
const answer = target - given;
const distractorPool = givenPool(target).filter((n) => n !== answer);
// Distraktoren: alle möglichen Antworten außer der korrekten.
// Mögliche Antworten = 1..target-1 (gleicher Wertebereich).
const distractorPool = candidates.filter((n) => n !== answer);
const numDistractors = Math.min(DESIRED_CHOICES - 1, distractorPool.length);
const distractors = shuffle(distractorPool).slice(0, numDistractors);
const choices = shuffle([answer, ...distractors]);
return { target, given, answer, choices };
}
// Ziehbeutel pro Spieldurchlauf. `bag` = noch nicht gezogene Vorgaben des aktuellen
// Zyklus, `lastGiven` = zuletzt gezogene Vorgabe (für den Schutz an der Zyklusgrenze).
export type TaskDeck = {
target: Target;
bag: number[];
lastGiven: number | null;
};
export function createDeck(target: Target): TaskDeck {
return { target, bag: [], lastGiven: null };
}
// Zieht die nächste Aufgabe und liefert das fortgeschriebene Deck zurück (immutable).
export function drawTask(deck: TaskDeck): { task: Task; deck: TaskDeck } {
let bag = deck.bag.slice();
if (bag.length === 0) {
bag = shuffle(givenPool(deck.target));
// Zyklusgrenze: erste Ziehung darf nicht der letzten des alten Beutels gleichen.
if (deck.lastGiven !== null && bag.length > 1 && bag[0] === deck.lastGiven) {
[bag[0], bag[1]] = [bag[1], bag[0]];
}
}
const given = bag.shift() as number;
const task = buildTask(deck.target, given);
return { task, deck: { target: deck.target, bag, lastGiven: given } };
}

View File

@ -1,7 +1,7 @@
<script lang="ts">
import { fade } from 'svelte/transition';
import type { Target } from '../game/tasks';
import { game, timeLeftSeconds, answer, abortGame } from '../stores/game';
import { game, stage, timeLeftSeconds, answer, abortGame } from '../stores/game';
import { goHome, goResult } from '../stores/route';
import { play } from '../audio/soundManager';
import HeightTrack from '../components/game/HeightTrack.svelte';
@ -10,7 +10,6 @@
import Timer from '../components/game/Timer.svelte';
import BurstFx from '../components/game/BurstFx.svelte';
import IconButton from '../components/shared/IconButton.svelte';
import Star from '../components/svg/Star.svelte';
type Props = { target: Target };
let { target }: Props = $props();
@ -62,17 +61,17 @@
<div class="screen" in:fade={{ duration: 220 }}>
<header class="topbar">
<Timer secondsLeft={$timeLeftSeconds} totalSeconds={Math.max(1, Math.round($game.totalMs / 1000))} />
<IconButton label="Startseite" onClick={handleAbort}>
<IconButton label="Zurück" onClick={handleAbort}>
<svg width="28" height="28" viewBox="0 0 28 28" aria-hidden="true">
<path d="M14 4 L25 14 L22 14 L22 24 L16.5 24 L16.5 17.5 L11.5 17.5 L11.5 24 L6 24 L6 14 L3 14 Z" fill="currentColor" />
<path d="M18 5 L8 14 L18 23" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</IconButton>
<Timer secondsLeft={$timeLeftSeconds} totalSeconds={Math.max(1, Math.round($game.totalMs / 1000))} />
</header>
<div class="layout">
<aside class="track-wrap">
<HeightTrack correct={$game.correctCount} won={$game.status === 'won'} />
<HeightTrack stage={$stage} won={$game.status === 'won'} />
<BurstFx triggerKey={$game.stageBumpKey} />
</aside>
<main class="play">
@ -85,7 +84,7 @@
/>
{:else if $game.status === 'won'}
<div class="end-message">
<span class="big"><Star size={200} /></span>
<span class="big">🌈</span>
</div>
{/if}
</main>
@ -102,8 +101,7 @@
}
.topbar {
display: flex;
justify-content: flex-end;
gap: 12px;
justify-content: space-between;
align-items: center;
}
.layout {

View File

@ -6,7 +6,6 @@
import { play, unlockAudio } from '../audio/soundManager';
import NumberCard from '../components/home/NumberCard.svelte';
import SoundToggle from '../components/shared/SoundToggle.svelte';
import FullscreenToggle from '../components/shared/FullscreenToggle.svelte';
import IconButton from '../components/shared/IconButton.svelte';
function handlePick(t: Target) {
@ -19,10 +18,8 @@
<div class="screen" in:fade={{ duration: 220 }}>
<header class="topbar">
<h1 class="visually-hidden">Zahlzerlegung</h1>
<div class="actions">
<SoundToggle />
<FullscreenToggle />
<h1 class="visually-hidden">Zahlzerlegung</h1>
<IconButton label="Einstellungen" onClick={goSettings}>
<svg width="34" height="34" viewBox="0 0 32 32" aria-hidden="true">
<path
@ -32,12 +29,11 @@
/>
</svg>
</IconButton>
</div>
</header>
<div class="cards-grid">
{#each TARGETS as target (target)}
<div class="cell"><NumberCard {target} onClick={handlePick} /></div>
<NumberCard {target} onClick={handlePick} />
{/each}
</div>
</div>
@ -52,12 +48,7 @@
}
.topbar {
display: flex;
justify-content: flex-end;
align-items: center;
}
.actions {
display: flex;
gap: 10px;
justify-content: space-between;
align-items: center;
}
.visually-hidden {
@ -72,14 +63,12 @@
}
.cards-grid {
/* Karten haben aspect-ratio 3/4. Bei 7 Karten ergeben sich je nach Spaltenzahl
2/3/4 Reihen — Breite wird so begrenzt, dass die Reihen in die verfügbare
Höhe passen. Formel: max-width = C·3·(H - (R-1)·g) / (4R) + (C-1)·g.
Flexbox statt Grid, damit eine nicht volle letzte Reihe zentriert wird. */
2/3/4 Reihen — Grid-Breite wird so begrenzt, dass die Reihen in die verfügbare
Höhe passen. Formel: max-width = C·3·(H - (R-1)·g) / (4R) + (C-1)·g. */
--gap: 14px;
--avail-h: calc(100dvh - 130px);
--cols: 4;
display: flex;
flex-wrap: wrap;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--gap);
align-content: center;
justify-content: center;
@ -89,20 +78,16 @@
margin-inline: auto;
padding-bottom: 10px;
}
.cell {
flex: 0 0 calc((100% - (var(--cols) - 1) * var(--gap)) / var(--cols));
min-width: 0;
}
@media (max-width: 640px) {
.cards-grid {
--cols: 3;
grid-template-columns: repeat(3, 1fr);
/* 3 Spalten, 3 Reihen */
max-width: calc(var(--avail-h) * 0.75 + var(--gap) * 0.5);
}
}
@media (max-width: 380px) {
.cards-grid {
--cols: 2;
grid-template-columns: repeat(2, 1fr);
/* 2 Spalten, 4 Reihen */
max-width: calc(var(--avail-h) * 0.375 - var(--gap) * 0.125);
}

View File

@ -1,20 +1,21 @@
<script lang="ts">
import { fade, fly } from 'svelte/transition';
import type { Target } from '../game/tasks';
import { lastRun } from '../stores/progress';
import { progress } from '../stores/progress';
import { startGame } from '../stores/game';
import { goHome, goGame } from '../stores/route';
import { play, unlockAudio } from '../audio/soundManager';
import Rocket from '../components/svg/Rocket.svelte';
import Rainbow from '../components/svg/Rainbow.svelte';
import HighscoreColumn from '../components/home/HighscoreColumn.svelte';
import StageAmbience from '../components/home/StageAmbience.svelte';
type Props = { target: Target };
let { target }: Props = $props();
// Genau der gerade gespielte Lauf (kann auch ein nicht-bester Versuch sein).
const current = $derived($lastRun?.target === target ? $lastRun : null);
const lastRunDate = $derived(current?.date ?? null);
const lastStage = $derived(current?.stage ?? 0);
// Aktueller Run = top-Eintrag mit dem letzten Datum
const lastRunDate = $derived($progress.perTarget[target]?.top5[0]?.date ?? null);
const lastStage = $derived(($progress.perTarget[target]?.top5[0]?.stage ?? 0) as number);
const isWin = $derived(lastStage >= 4);
function retry() {
unlockAudio();
@ -30,12 +31,26 @@
</script>
<div class="screen" in:fade={{ duration: 240 }}>
<header>
<h2 class="target-label">
<span class="num">{target}</span>
</h2>
</header>
<div class="celebration">
<StageAmbience stage={lastStage} />
<div class="scores" in:fly={{ y: 40, duration: 500 }}>
{#if isWin}
<div class="rainbow-wrap" in:fly={{ y: 30, duration: 600 }}>
<Rainbow size={260} />
</div>
{/if}
<div class="rocket-wrap" in:fly={{ y: 60, duration: 700 }}>
<Rocket size={160} flameAnimated={false} />
</div>
</div>
<aside class="scores">
<HighscoreColumn {target} highlightDate={lastRunDate} />
</div>
</div>
</aside>
<footer class="actions">
<button class="action retry" type="button" onclick={retry} aria-label="Nochmal">
@ -56,23 +71,43 @@
.screen {
height: 100%;
display: grid;
grid-template-rows: 1fr auto;
grid-template-columns: 1fr auto;
grid-template-rows: auto 1fr auto;
grid-template-areas:
'header header'
'celebration scores'
'actions actions';
padding: 16px;
gap: 12px;
}
header { grid-area: header; display: flex; justify-content: center; }
.target-label .num {
font-size: clamp(48px, 8vw, 80px);
font-weight: 900;
color: var(--c-text-on-dark);
background: rgba(255,255,255,0.18);
border-radius: 18px;
padding: 6px 28px;
}
.celebration {
grid-area: celebration;
display: grid;
place-items: center;
position: relative;
min-height: 0;
}
.rocket-wrap { z-index: 2; }
.rainbow-wrap {
position: absolute;
bottom: 12%;
z-index: 1;
}
.scores {
position: relative;
z-index: 2;
width: 120px;
height: min(82%, 460px);
grid-area: scores;
width: 110px;
height: 100%;
}
.actions {
grid-area: actions;
display: flex;
justify-content: center;
gap: 24px;

View File

@ -1,25 +1,16 @@
<script lang="ts">
import { fade, scale } from 'svelte/transition';
import { fade } from 'svelte/transition';
import { settings, ROUND_SECONDS_OPTIONS, setRoundSeconds, toggleSound } from '../stores/settings';
import { resetProgress } from '../stores/progress';
import { goHome } from '../stores/route';
import { play } from '../audio/soundManager';
import IconButton from '../components/shared/IconButton.svelte';
let confirming = $state(false);
function doReset() {
play('tap');
resetProgress();
confirming = false;
}
</script>
<div class="screen" in:fade={{ duration: 220 }}>
<header>
<IconButton label="Startseite" onClick={goHome}>
<IconButton label="Zurück" onClick={goHome}>
<svg width="28" height="28" viewBox="0 0 28 28" aria-hidden="true">
<path d="M14 4 L25 14 L22 14 L22 24 L16.5 24 L16.5 17.5 L11.5 17.5 L11.5 24 L6 24 L6 14 L3 14 Z" fill="currentColor" />
<path d="M18 5 L8 14 L18 23" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</IconButton>
</header>
@ -75,112 +66,17 @@
</button>
</div>
</section>
<section class="setting">
<div class="label">
<svg width="40" height="40" viewBox="0 0 28 28" aria-hidden="true">
<path d="M14 4 L26 25 L2 25 Z" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linejoin="round" />
<rect x="12.8" y="10.5" width="2.4" height="7.5" rx="1.2" fill="currentColor" />
<circle cx="14" cy="21.5" r="1.5" fill="currentColor" />
</svg>
</div>
<div class="options">
<button class="reset-btn" type="button" onclick={() => { play('tap'); confirming = true; }} aria-label="Spielstand zurücksetzen">
<svg width="38" height="38" viewBox="0 0 28 28" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M4 7 H24" />
<path d="M11 7 V4 H17 V7" />
<path d="M6 7 L7.4 24 H20.6 L22 7" />
<path d="M11.5 11 V20 M16.5 11 V20" />
</svg>
</button>
</div>
</section>
</div>
{#if confirming}
<div class="overlay" transition:fade={{ duration: 150 }}>
<div class="dialog" in:scale={{ start: 0.85, duration: 200 }}>
<svg class="warn" width="84" height="84" viewBox="0 0 28 28" aria-hidden="true">
<path d="M14 3 L27 26 L1 26 Z" fill="#ffcf33" stroke="#c79a2a" stroke-width="1.5" stroke-linejoin="round" />
<rect x="12.7" y="9.5" width="2.6" height="8.5" rx="1.3" fill="#5a4500" />
<circle cx="14" cy="22" r="1.7" fill="#5a4500" />
</svg>
<div class="dialog-actions">
<button class="dlg cancel" type="button" onclick={() => { play('tap'); confirming = false; }} aria-label="Abbrechen">
<svg width="40" height="40" viewBox="0 0 40 40" aria-hidden="true">
<path d="M12 12 L28 28 M28 12 L12 28" stroke="currentColor" stroke-width="4" stroke-linecap="round" />
</svg>
</button>
<button class="dlg confirm" type="button" onclick={doReset} aria-label="Endgültig löschen">
<svg width="40" height="40" viewBox="0 0 28 28" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M4 7 H24" />
<path d="M11 7 V4 H17 V7" />
<path d="M6 7 L7.4 24 H20.6 L22 7" />
<path d="M11.5 11 V20 M16.5 11 V20" />
</svg>
</button>
</div>
</div>
</div>
{/if}
<style>
.screen {
position: relative;
height: 100%;
display: grid;
grid-template-rows: auto 1fr 1fr 1fr;
grid-template-rows: auto 1fr 1fr;
padding: 16px;
gap: 18px;
color: var(--c-text-on-dark);
}
header {
display: flex;
justify-content: flex-end;
}
.reset-btn {
display: grid;
place-items: center;
width: 84px;
height: 84px;
border-radius: 18px;
background: rgba(255, 107, 107, 0.85);
color: #fff;
transition: transform 0.08s ease, background 0.15s ease;
}
.reset-btn:active { transform: scale(0.94); }
.overlay {
position: absolute;
inset: 0;
background: rgba(8, 14, 34, 0.72);
display: grid;
place-items: center;
z-index: 10;
}
.dialog {
display: grid;
justify-items: center;
gap: 24px;
padding: 28px;
}
.dialog-actions {
display: flex;
gap: 28px;
}
.dlg {
width: 88px;
height: 88px;
border-radius: 50%;
color: #fff;
display: grid;
place-items: center;
box-shadow: 0 6px 0 rgba(0, 0, 0, 0.22);
transition: transform 0.1s ease;
}
.dlg:active { transform: translateY(4px); box-shadow: 0 2px 0 rgba(0, 0, 0, 0.22); }
.dlg.cancel { background: #6b7a99; }
.dlg.confirm { background: var(--c-rocket-body); }
.setting {
display: grid;
grid-template-columns: 80px 1fr;

View File

@ -1,6 +1,6 @@
import { get, writable, derived } from 'svelte/store';
import { createDeck, drawTask, type Target, type Task, type TaskDeck } from '../game/tasks';
import { isWin, stageFor } from '../game/stages';
import { generateTask, type Target, type Task } from '../game/tasks';
import { isWin, stageFor, type Stage } from '../game/stages';
import { settings } from './settings';
import { recordResult } from './progress';
@ -16,7 +16,6 @@ export type GameState = {
totalMs: number;
lastWasCorrect: boolean | null;
stageBumpKey: number; // monoton wachsend → triggert FX-Animation
deck: TaskDeck | null; // Ziehbeutel für abwechslungsreiche Aufgabenfolge
};
const initial: GameState = {
@ -29,11 +28,11 @@ const initial: GameState = {
totalMs: 0,
lastWasCorrect: null,
stageBumpKey: 0,
deck: null,
};
export const game = writable<GameState>(initial);
export const stage = derived(game, ($g): Stage => stageFor($g.correctCount));
export const timeLeftSeconds = derived(game, ($g) => Math.max(0, Math.ceil($g.timeLeftMs / 1000)));
let tickHandle: number | null = null;
@ -71,15 +70,13 @@ function tick() {
function finalize() {
const g = get(game);
if (g.target === null) return;
// Gebrauchte Zeit = verstrichene Zeit bis zum Ende (bei Sieg = Lösezeit für alle 10).
const elapsedMs = Math.max(0, g.totalMs - g.timeLeftMs);
recordResult(g.target, stageFor(g.correctCount), elapsedMs);
recordResult(g.target, stageFor(g.correctCount));
}
export function startGame(target: Target): void {
clearTick();
const totalMs = get(settings).roundSeconds * 1000;
const { task: firstTask, deck } = drawTask(createDeck(target));
const firstTask = generateTask(target);
game.set({
status: 'running',
target,
@ -90,7 +87,6 @@ export function startGame(target: Target): void {
totalMs,
lastWasCorrect: null,
stageBumpKey: 0,
deck,
});
lastTickAt = performance.now();
tickHandle = requestAnimationFrame(tick);
@ -117,13 +113,12 @@ export function answer(value: number): void {
finalize();
return;
}
const { task: nextTask, deck } = drawTask(g.deck ?? createDeck(g.target));
const nextTask = generateTask(g.target, g.task);
game.update((s) => ({
...s,
correctCount: nextCount,
prevTask: s.task,
task: nextTask,
deck,
lastWasCorrect: true,
stageBumpKey: stageBump ? s.stageBumpKey + 1 : s.stageBumpKey,
}));
@ -132,6 +127,13 @@ export function answer(value: number): void {
}
}
export function nextTaskAfterWrong(): void {
const g = get(game);
if (g.status !== 'running' || !g.task || g.target === null) return;
const nextTask = generateTask(g.target, g.task);
game.update((s) => ({ ...s, prevTask: s.task, task: nextTask, lastWasCorrect: null }));
}
export function abortGame(): void {
clearTick();
game.set(initial);

View File

@ -13,22 +13,10 @@ const initial: Progress = typeof window === 'undefined' ? emptyProgress() : load
export const progress = writable<Progress>(initial);
// Letzter abgeschlossener Lauf — damit der ResultScreen genau diesen Versuch
// hervorheben kann, auch wenn er nicht der beste ist.
export const lastRun = writable<{ target: Target; stage: Stage; date: string } | null>(null);
progress.subscribe((p) => {
if (typeof window !== 'undefined') saveProgress(p);
});
export function recordResult(target: Target, stage: Stage, timeMs?: number): void {
const date = new Date().toISOString();
progress.update((p) => recordRun(p, target, stage, date, timeMs));
lastRun.set({ target, stage, date });
}
// Setzt den kompletten Spielstand (alle Highscores) zurück.
export function resetProgress(): void {
progress.set(emptyProgress());
lastRun.set(null);
export function recordResult(target: Target, stage: Stage): void {
progress.update((p) => recordRun(p, target, stage));
}

View File

@ -1,7 +1,7 @@
import { writable } from 'svelte/store';
import { loadSettings, saveSettings, type Settings } from '../game/persistence';
export const ROUND_SECONDS_OPTIONS = [15, 30, 60] as const;
export const ROUND_SECONDS_OPTIONS = [30, 60, 90, 120] as const;
const initial: Settings =
typeof window === 'undefined'