Init
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import { route } from './lib/stores/route';
|
||||
import HomeScreen from './lib/screens/HomeScreen.svelte';
|
||||
import GameScreen from './lib/screens/GameScreen.svelte';
|
||||
import ResultScreen from './lib/screens/ResultScreen.svelte';
|
||||
import SettingsScreen from './lib/screens/SettingsScreen.svelte';
|
||||
</script>
|
||||
|
||||
<main>
|
||||
{#if $route.name === 'home'}
|
||||
<HomeScreen />
|
||||
{:else if $route.name === 'game'}
|
||||
<GameScreen target={$route.target} />
|
||||
{:else if $route.name === 'result'}
|
||||
<ResultScreen target={$route.target} />
|
||||
{:else if $route.name === 'settings'}
|
||||
<SettingsScreen />
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<style>
|
||||
main {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
:root {
|
||||
/* Farbpalette: warm, kindgerecht, kontrastreich */
|
||||
--c-sky-top: #1a2c5c;
|
||||
--c-sky-bottom: #6fb3ff;
|
||||
--c-ground: #6cc26c;
|
||||
--c-cloud: #f5f9ff;
|
||||
--c-rocket-body: #ff6b6b;
|
||||
--c-rocket-window: #ffe566;
|
||||
--c-rocket-fin: #4a4a6e;
|
||||
--c-flame-inner: #fff4b3;
|
||||
--c-flame-outer: #ff8a3d;
|
||||
--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;
|
||||
|
||||
--shadow-soft: 0 4px 16px rgba(0, 0, 0, 0.18);
|
||||
--radius-card: 24px;
|
||||
--radius-pill: 999px;
|
||||
--tap-min: 96px;
|
||||
|
||||
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body, #app {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
body {
|
||||
background: linear-gradient(to bottom, var(--c-sky-top) 0%, var(--c-sky-bottom) 100%);
|
||||
color: var(--c-text);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
button:focus-visible {
|
||||
outline: 4px solid var(--c-rocket-window);
|
||||
outline-offset: 4px;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// Generative WebAudio-Sounds — keine externen Dateien nötig, klein und kindgerecht.
|
||||
// Mute global über Settings-Store.
|
||||
//
|
||||
// Sound-Events:
|
||||
// tap — Antwort-Tipp (kurzer Klick)
|
||||
// correct — richtige Antwort (heller Ding)
|
||||
// boost — Raketenschub (Whoosh, niedrig)
|
||||
// level — neue Stufe (Akkord aufsteigend)
|
||||
// countdown — letzte 5 Sekunden (Tick pro Sekunde, höher werdend)
|
||||
// fanfare — Regenbogenland erreicht (kurze Tonfolge)
|
||||
|
||||
import { get } from 'svelte/store';
|
||||
import { settings } from '../stores/settings';
|
||||
|
||||
export type SoundName = 'tap' | 'correct' | 'boost' | 'level' | 'countdown' | 'fanfare';
|
||||
|
||||
let ctx: AudioContext | null = null;
|
||||
|
||||
function getCtx(): AudioContext | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
if (!ctx) {
|
||||
const Ctor = window.AudioContext ?? (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
|
||||
if (!Ctor) return null;
|
||||
ctx = new Ctor();
|
||||
}
|
||||
if (ctx.state === 'suspended') ctx.resume().catch(() => {});
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function tone(freq: number, durationMs: number, type: OscillatorType = 'sine', startGain = 0.18): Promise<void> {
|
||||
const c = getCtx();
|
||||
if (!c) return Promise.resolve();
|
||||
return new Promise((resolve) => {
|
||||
const osc = c.createOscillator();
|
||||
const gain = c.createGain();
|
||||
osc.type = type;
|
||||
osc.frequency.value = freq;
|
||||
gain.gain.setValueAtTime(startGain, c.currentTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, c.currentTime + durationMs / 1000);
|
||||
osc.connect(gain).connect(c.destination);
|
||||
osc.start();
|
||||
osc.stop(c.currentTime + durationMs / 1000);
|
||||
osc.onended = () => resolve();
|
||||
});
|
||||
}
|
||||
|
||||
function noiseBurst(durationMs: number, startGain = 0.18, filterFreq = 800): void {
|
||||
const c = getCtx();
|
||||
if (!c) return;
|
||||
const buffer = c.createBuffer(1, c.sampleRate * (durationMs / 1000), c.sampleRate);
|
||||
const data = buffer.getChannelData(0);
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
data[i] = (Math.random() * 2 - 1) * (1 - i / data.length);
|
||||
}
|
||||
const src = c.createBufferSource();
|
||||
src.buffer = buffer;
|
||||
const filt = c.createBiquadFilter();
|
||||
filt.type = 'lowpass';
|
||||
filt.frequency.value = filterFreq;
|
||||
const gain = c.createGain();
|
||||
gain.gain.value = startGain;
|
||||
src.connect(filt).connect(gain).connect(c.destination);
|
||||
src.start();
|
||||
}
|
||||
|
||||
export function play(name: SoundName): void {
|
||||
if (!get(settings).soundOn) return;
|
||||
switch (name) {
|
||||
case 'tap':
|
||||
void tone(880, 60, 'square', 0.06);
|
||||
break;
|
||||
case 'correct':
|
||||
void tone(660, 100, 'sine', 0.18).then(() => tone(990, 140, 'sine', 0.18));
|
||||
break;
|
||||
case 'boost':
|
||||
noiseBurst(220, 0.14, 1200);
|
||||
break;
|
||||
case 'level': {
|
||||
const c = getCtx();
|
||||
if (!c) return;
|
||||
const notes = [523.25, 659.25, 783.99]; // C5 E5 G5
|
||||
notes.forEach((f, i) => {
|
||||
setTimeout(() => void tone(f, 220, 'triangle', 0.15), i * 90);
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'countdown':
|
||||
void tone(1200, 80, 'square', 0.12);
|
||||
break;
|
||||
case 'fanfare': {
|
||||
const seq = [523.25, 659.25, 783.99, 1046.5];
|
||||
seq.forEach((f, i) => {
|
||||
setTimeout(() => void tone(f, 260, 'triangle', 0.18), i * 130);
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Erlaubt manuelles Aufwecken bei erstem User-Tap (Browser-Policy).
|
||||
export function unlockAudio(): void {
|
||||
getCtx();
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
<script lang="ts">
|
||||
type Props = {
|
||||
choices: number[];
|
||||
correctAnswer: number;
|
||||
disabled?: boolean;
|
||||
onAnswer: (value: number) => void;
|
||||
};
|
||||
let { choices, correctAnswer, disabled = false, onAnswer }: Props = $props();
|
||||
|
||||
let wrongValue = $state<number | null>(null);
|
||||
let lockedCorrect = $state<number | null>(null);
|
||||
|
||||
function handle(value: number) {
|
||||
if (disabled || lockedCorrect !== null) return;
|
||||
if (value === correctAnswer) {
|
||||
lockedCorrect = value;
|
||||
onAnswer(value);
|
||||
// beim nächsten Aufgabenwechsel via $effect wieder zurücksetzen
|
||||
} else {
|
||||
wrongValue = value;
|
||||
onAnswer(value);
|
||||
setTimeout(() => {
|
||||
if (wrongValue === value) wrongValue = null;
|
||||
}, 380);
|
||||
}
|
||||
}
|
||||
|
||||
// Reset bei Aufgabenwechsel
|
||||
$effect(() => {
|
||||
void choices; // dependency
|
||||
lockedCorrect = null;
|
||||
wrongValue = null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="answers" role="group" aria-label="Antwortmöglichkeiten">
|
||||
{#each choices as value (value)}
|
||||
<button
|
||||
type="button"
|
||||
class="answer"
|
||||
class:wrong={wrongValue === value}
|
||||
class:correct={lockedCorrect === value}
|
||||
onclick={() => handle(value)}
|
||||
aria-label={`Antwort ${value}`}
|
||||
{disabled}
|
||||
>
|
||||
{value}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.answers {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.answer {
|
||||
min-width: var(--tap-min);
|
||||
min-height: var(--tap-min);
|
||||
padding: 0 18px;
|
||||
font-size: clamp(48px, 8vw, 84px);
|
||||
font-weight: 900;
|
||||
background: var(--c-cloud);
|
||||
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;
|
||||
}
|
||||
.answer:active:not(:disabled) {
|
||||
transform: translateY(4px);
|
||||
box-shadow: 0 2px 0 #b9c4dc;
|
||||
}
|
||||
.answer.wrong {
|
||||
animation: shake 0.36s ease;
|
||||
background: #ffd0d0;
|
||||
}
|
||||
.answer.correct {
|
||||
background: var(--c-correct);
|
||||
color: white;
|
||||
transform: scale(1.06);
|
||||
}
|
||||
@keyframes shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
20% { transform: translateX(-8px); }
|
||||
40% { transform: translateX(8px); }
|
||||
60% { transform: translateX(-6px); }
|
||||
80% { transform: translateX(6px); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,67 @@
|
||||
<script lang="ts">
|
||||
type Props = { triggerKey: number };
|
||||
let { triggerKey }: Props = $props();
|
||||
|
||||
// Pro Trigger erzeugen wir 14 Partikel mit zufälligen Winkeln/Distanzen.
|
||||
let particles = $state<Array<{ id: number; angle: number; distance: number; color: string; size: number }>>([]);
|
||||
let active = $state(false);
|
||||
let counter = 0;
|
||||
|
||||
const PALETTE = ['#ff5555', '#ffa84a', '#ffe34a', '#6cdc6c', '#4ab3ff', '#b06cff'];
|
||||
|
||||
$effect(() => {
|
||||
if (triggerKey === 0) return;
|
||||
counter++;
|
||||
const seed = counter;
|
||||
particles = Array.from({ length: 16 }, (_, i) => ({
|
||||
id: seed * 100 + i,
|
||||
angle: (i / 16) * 360 + (Math.random() - 0.5) * 22,
|
||||
distance: 80 + Math.random() * 60,
|
||||
color: PALETTE[i % PALETTE.length],
|
||||
size: 8 + Math.random() * 8,
|
||||
}));
|
||||
active = true;
|
||||
const timeout = setTimeout(() => {
|
||||
active = false;
|
||||
}, 700);
|
||||
return () => clearTimeout(timeout);
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if active}
|
||||
<div class="burst" aria-hidden="true">
|
||||
{#each particles as p (p.id)}
|
||||
<span
|
||||
class="particle"
|
||||
style="
|
||||
--angle: {p.angle}deg;
|
||||
--distance: {p.distance}px;
|
||||
--color: {p.color};
|
||||
width: {p.size}px;
|
||||
height: {p.size}px;
|
||||
"
|
||||
></span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.burst {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.particle {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
background: var(--color);
|
||||
transform: rotate(var(--angle)) translateY(0);
|
||||
animation: fly 0.7s ease-out forwards;
|
||||
}
|
||||
@keyframes fly {
|
||||
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>
|
||||
@@ -0,0 +1,93 @@
|
||||
<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';
|
||||
|
||||
type Props = { stage: Stage; won: boolean };
|
||||
let { stage, won }: Props = $props();
|
||||
|
||||
// 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>
|
||||
<div class="layer ground"></div>
|
||||
<div class="layer sky-low"></div>
|
||||
<div class="layer sky-mid"></div>
|
||||
<div class="layer sky-high"></div>
|
||||
<div class="layer space"></div>
|
||||
|
||||
<!-- Markierungen -->
|
||||
<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} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.track {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 18px;
|
||||
overflow: hidden;
|
||||
background: var(--c-sky-top);
|
||||
}
|
||||
.layer {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
.ground { bottom: 0; height: 8%; background: linear-gradient(to top, #4ea34e, var(--c-ground)); }
|
||||
.sky-low { bottom: 8%; height: 22%; background: linear-gradient(to top, #b6e3ff, #6fb3ff); }
|
||||
.sky-mid { bottom: 30%; height: 30%; background: linear-gradient(to top, #6fb3ff, #4a7ec4); }
|
||||
.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); }
|
||||
|
||||
.marker {
|
||||
position: absolute;
|
||||
left: 18%;
|
||||
transform: translate(-50%, 50%);
|
||||
pointer-events: none;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.marker.stars { display: flex; gap: 6px; left: 60%; }
|
||||
|
||||
.rainbow {
|
||||
left: 50%;
|
||||
transform: translate(-50%, 50%);
|
||||
opacity: 0;
|
||||
transition: opacity 0.6s ease, transform 0.6s ease;
|
||||
}
|
||||
.rainbow.visible {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, 50%) scale(1.1);
|
||||
}
|
||||
|
||||
.rocket {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
transition: bottom 0.6s cubic-bezier(0.2, 0.9, 0.2, 1);
|
||||
filter: drop-shadow(0 6px 8px rgba(0, 0, 0, 0.35));
|
||||
}
|
||||
.track.won .rocket {
|
||||
transition: bottom 1.4s cubic-bezier(0.18, 0.7, 0.2, 1);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,64 @@
|
||||
<script lang="ts">
|
||||
import type { Task } from '../../game/tasks';
|
||||
type Props = { task: Task };
|
||||
let { task }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="prompt" aria-label={`${task.target} ist ${task.given} und welche Zahl?`}>
|
||||
<div class="target">
|
||||
<span class="num target-num">{task.target}</span>
|
||||
</div>
|
||||
<div class="equation">
|
||||
<span class="num given">{task.given}</span>
|
||||
<span class="plus">+</span>
|
||||
<span class="placeholder" aria-hidden="true">?</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.prompt {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
color: var(--c-text-on-dark);
|
||||
}
|
||||
.target-num {
|
||||
font-size: clamp(72px, 14vw, 140px);
|
||||
font-weight: 900;
|
||||
text-shadow: 0 4px 0 rgba(0,0,0,0.18);
|
||||
}
|
||||
.target { line-height: 0.9; }
|
||||
.equation {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 18px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.num {
|
||||
font-size: clamp(40px, 7vw, 72px);
|
||||
background: var(--c-cloud);
|
||||
color: var(--c-text);
|
||||
border-radius: 18px;
|
||||
padding: 6px 22px;
|
||||
box-shadow: 0 4px 0 rgba(0,0,0,0.18);
|
||||
min-width: 72px;
|
||||
display: inline-block;
|
||||
}
|
||||
.plus {
|
||||
font-size: clamp(40px, 6vw, 56px);
|
||||
color: var(--c-rocket-window);
|
||||
}
|
||||
.placeholder {
|
||||
font-size: clamp(40px, 7vw, 72px);
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
color: var(--c-rocket-window);
|
||||
border: 4px dashed var(--c-rocket-window);
|
||||
border-radius: 18px;
|
||||
padding: 6px 22px;
|
||||
min-width: 72px;
|
||||
display: inline-block;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script lang="ts">
|
||||
type Props = { secondsLeft: number; totalSeconds: number };
|
||||
let { secondsLeft, totalSeconds }: Props = $props();
|
||||
|
||||
const ratio = $derived(Math.max(0, Math.min(1, secondsLeft / totalSeconds)));
|
||||
const C = 2 * Math.PI * 28; // Umfang
|
||||
const dashoffset = $derived(C * (1 - ratio));
|
||||
const warning = $derived(secondsLeft <= 5);
|
||||
</script>
|
||||
|
||||
<div class="timer" class:warning aria-label={`${secondsLeft} Sekunden übrig`}>
|
||||
<svg width="72" height="72" viewBox="0 0 72 72">
|
||||
<circle cx="36" cy="36" r="28" fill="none" stroke="rgba(255,255,255,0.2)" stroke-width="6" />
|
||||
<circle
|
||||
cx="36"
|
||||
cy="36"
|
||||
r="28"
|
||||
fill="none"
|
||||
stroke="var(--c-rocket-window)"
|
||||
stroke-width="6"
|
||||
stroke-linecap="round"
|
||||
stroke-dasharray={C}
|
||||
stroke-dashoffset={dashoffset}
|
||||
transform="rotate(-90 36 36)"
|
||||
/>
|
||||
</svg>
|
||||
<span class="num">{secondsLeft}</span>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.timer {
|
||||
position: relative;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.timer svg { position: absolute; inset: 0; }
|
||||
.num {
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
color: var(--c-text-on-dark);
|
||||
}
|
||||
.timer.warning .num { color: #ff7373; animation: pulse 0.8s ease infinite; }
|
||||
@keyframes pulse {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.18); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,79 @@
|
||||
<script lang="ts">
|
||||
import type { Target } from '../../game/tasks';
|
||||
import { progress } from '../../stores/progress';
|
||||
import Rocket from '../svg/Rocket.svelte';
|
||||
import Crown from '../svg/Crown.svelte';
|
||||
|
||||
type Props = { target: Target; highlightDate?: string | null };
|
||||
let { target, highlightDate = null }: Props = $props();
|
||||
|
||||
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));
|
||||
</script>
|
||||
|
||||
<div class="column" aria-label="Beste Flüge">
|
||||
{#each slots as slot, i (i)}
|
||||
<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}
|
||||
<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>
|
||||
{:else}
|
||||
<div class="placeholder"></div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.column {
|
||||
display: grid;
|
||||
grid-template-rows: repeat(5, 1fr);
|
||||
gap: 10px;
|
||||
height: 100%;
|
||||
}
|
||||
.slot {
|
||||
position: relative;
|
||||
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(255, 255, 255, 0.06);
|
||||
}
|
||||
.slot.current {
|
||||
background: rgba(255, 229, 102, 0.3);
|
||||
box-shadow: 0 0 0 3px var(--c-rocket-window);
|
||||
}
|
||||
.crown {
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
.stage-label { display: flex; gap: 3px; }
|
||||
.stage-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--c-rocket-window);
|
||||
}
|
||||
.placeholder {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
border: 2px dashed rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,107 @@
|
||||
<script lang="ts">
|
||||
import type { Target } from '../../game/tasks';
|
||||
import { progress } from '../../stores/progress';
|
||||
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 bestStage = $derived(($progress.perTarget[target]?.top5[0]?.stage ?? 0) as number);
|
||||
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}`}>
|
||||
<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>
|
||||
.card {
|
||||
background: linear-gradient(160deg, #ffffff 0%, #e7eefb 100%);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: var(--shadow-soft);
|
||||
aspect-ratio: 3 / 4;
|
||||
min-width: 0;
|
||||
padding: 12px;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
gap: 6px;
|
||||
transition: transform 0.12s ease;
|
||||
}
|
||||
.card:active { transform: scale(0.96); }
|
||||
|
||||
.number {
|
||||
font-size: clamp(48px, 8vw, 80px);
|
||||
font-weight: 800;
|
||||
color: var(--c-text);
|
||||
text-align: center;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.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>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script lang="ts">
|
||||
type Props = {
|
||||
onClick: () => void;
|
||||
label: string;
|
||||
children: import('svelte').Snippet;
|
||||
};
|
||||
let { onClick, label, children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<button class="icon-button" type="button" aria-label={label} onclick={onClick}>
|
||||
{@render children()}
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.icon-button {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
backdrop-filter: blur(6px);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--c-text-on-dark);
|
||||
transition: transform 0.12s ease, background 0.12s ease;
|
||||
}
|
||||
.icon-button:active {
|
||||
transform: scale(0.92);
|
||||
background: rgba(255, 255, 255, 0.32);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import { settings, toggleSound } from '../../stores/settings';
|
||||
</script>
|
||||
|
||||
<button
|
||||
class="sound-toggle"
|
||||
type="button"
|
||||
aria-label={$settings.soundOn ? 'Ton aus' : 'Ton an'}
|
||||
onclick={toggleSound}
|
||||
>
|
||||
{#if $settings.soundOn}
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" aria-hidden="true">
|
||||
<path d="M5 12 L12 12 L20 6 L20 26 L12 20 L5 20 Z" fill="currentColor" />
|
||||
<path d="M23 10 Q28 16 23 22" stroke="currentColor" stroke-width="2.5" fill="none" stroke-linecap="round" />
|
||||
<path d="M26 6 Q33 16 26 26" stroke="currentColor" stroke-width="2.5" fill="none" stroke-linecap="round" />
|
||||
</svg>
|
||||
{:else}
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" aria-hidden="true">
|
||||
<path d="M5 12 L12 12 L20 6 L20 26 L12 20 L5 20 Z" fill="currentColor" />
|
||||
<path d="M22 10 L30 22 M30 10 L22 22" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.sound-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;
|
||||
}
|
||||
.sound-toggle:active { transform: scale(0.92); }
|
||||
</style>
|
||||
@@ -0,0 +1,10 @@
|
||||
<script lang="ts">
|
||||
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="#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>
|
||||
@@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
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="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" />
|
||||
</g>
|
||||
</svg>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
let { size = 36 }: { size?: number } = $props();
|
||||
</script>
|
||||
|
||||
<svg width={size} height={size * 0.75} viewBox="0 0 48 36" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<path
|
||||
d="M4 30 L8 8 L18 20 L24 4 L30 20 L40 8 L44 30 Z"
|
||||
fill="#ffd84a"
|
||||
stroke="#c2a230"
|
||||
stroke-width="2"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<rect x="4" y="28" width="40" height="6" rx="1.5" fill="#c2a230" />
|
||||
<circle cx="12" cy="14" r="2" fill="#ff5555" />
|
||||
<circle cx="24" cy="12" r="2.5" fill="#4ab3ff" />
|
||||
<circle cx="36" cy="14" r="2" fill="#5fd07a" />
|
||||
</svg>
|
||||
@@ -0,0 +1,11 @@
|
||||
<script lang="ts">
|
||||
let { size = 90 }: { size?: number } = $props();
|
||||
</script>
|
||||
|
||||
<svg width={size} height={size} viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<circle cx="50" cy="50" r="40" fill="#ffe9a3" stroke="#c2a230" stroke-width="2" />
|
||||
<circle cx="40" cy="42" r="6" fill="#e6cf7d" opacity="0.6" />
|
||||
<circle cx="62" cy="56" r="8" fill="#e6cf7d" opacity="0.6" />
|
||||
<circle cx="56" cy="36" r="3" fill="#e6cf7d" opacity="0.6" />
|
||||
<circle cx="35" cy="62" r="4" fill="#e6cf7d" opacity="0.6" />
|
||||
</svg>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,50 @@
|
||||
<script lang="ts">
|
||||
type Props = { size?: number; flameAnimated?: boolean };
|
||||
let { size = 120, flameAnimated = true }: Props = $props();
|
||||
</script>
|
||||
|
||||
<svg width={size} height={size} viewBox="0 0 100 140" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<!-- Flamme: 2 Pfade, größer + kleiner, sanft pulsierend -->
|
||||
<g class="flame" class:flame-animated={flameAnimated}>
|
||||
<path
|
||||
class="flame-outer"
|
||||
d="M35 110 Q50 145 65 110 Q60 125 50 132 Q40 125 35 110 Z"
|
||||
fill="var(--c-flame-outer)"
|
||||
/>
|
||||
<path
|
||||
class="flame-inner"
|
||||
d="M42 110 Q50 130 58 110 Q55 120 50 124 Q45 120 42 110 Z"
|
||||
fill="var(--c-flame-inner)"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<!-- Finnen -->
|
||||
<path d="M22 92 L36 75 L36 105 Z" fill="var(--c-rocket-fin)" />
|
||||
<path d="M78 92 L64 75 L64 105 Z" fill="var(--c-rocket-fin)" />
|
||||
|
||||
<!-- Rumpf -->
|
||||
<path
|
||||
d="M50 8 Q72 30 72 70 L72 105 Q60 110 50 110 Q40 110 28 105 L28 70 Q28 30 50 8 Z"
|
||||
fill="var(--c-rocket-body)"
|
||||
stroke="#a83a3a"
|
||||
stroke-width="2"
|
||||
/>
|
||||
|
||||
<!-- Fenster -->
|
||||
<circle cx="50" cy="50" r="11" fill="var(--c-rocket-window)" stroke="#c2a230" stroke-width="2" />
|
||||
<circle cx="46" cy="46" r="3" fill="#fff" opacity="0.7" />
|
||||
|
||||
<!-- Bandage am Rumpf -->
|
||||
<rect x="28" y="78" width="44" height="6" fill="#c2a230" rx="2" />
|
||||
</svg>
|
||||
|
||||
<style>
|
||||
.flame-animated {
|
||||
transform-origin: 50px 110px;
|
||||
animation: flicker 0.32s steps(2) infinite;
|
||||
}
|
||||
@keyframes flicker {
|
||||
0%, 100% { transform: scaleY(1); }
|
||||
50% { transform: scaleY(0.78); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,13 @@
|
||||
<script lang="ts">
|
||||
let { size = 40, color = '#ffe34a' }: { size?: number; color?: string } = $props();
|
||||
</script>
|
||||
|
||||
<svg width={size} height={size} viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<polygon
|
||||
points="20,3 24,15 37,15 27,23 31,36 20,28 9,36 13,23 3,15 16,15"
|
||||
fill={color}
|
||||
stroke="#c2a230"
|
||||
stroke-width="1.5"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
loadProgress,
|
||||
saveProgress,
|
||||
recordRun,
|
||||
emptyProgress,
|
||||
loadSettings,
|
||||
saveSettings,
|
||||
DEFAULT_SETTINGS,
|
||||
} from './persistence';
|
||||
|
||||
|
||||
describe('progress', () => {
|
||||
it('returns empty progress when nothing stored', () => {
|
||||
const p = loadProgress();
|
||||
expect(p.perTarget[7].runs).toBe(0);
|
||||
expect(p.perTarget[7].top5).toEqual([]);
|
||||
});
|
||||
|
||||
it('round-trips saved progress', () => {
|
||||
const p = recordRun(emptyProgress(), 7, 4);
|
||||
saveProgress(p);
|
||||
const loaded = loadProgress();
|
||||
expect(loaded.perTarget[7].runs).toBe(1);
|
||||
expect(loaded.perTarget[7].top5[0].stage).toBe(4);
|
||||
});
|
||||
|
||||
it('keeps top5 sorted by stage descending', () => {
|
||||
let p = emptyProgress();
|
||||
p = recordRun(p, 5, 2);
|
||||
p = recordRun(p, 5, 4);
|
||||
p = recordRun(p, 5, 1);
|
||||
p = recordRun(p, 5, 3);
|
||||
const stages = p.perTarget[5].top5.map((r) => r.stage);
|
||||
expect(stages).toEqual([4, 3, 2, 1]);
|
||||
});
|
||||
|
||||
it('limits top5 to five entries', () => {
|
||||
let p = emptyProgress();
|
||||
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);
|
||||
});
|
||||
|
||||
it('tolerates corrupt JSON in storage', () => {
|
||||
localStorage.setItem('zahlzerlegung.progress', '{not json');
|
||||
const p = loadProgress();
|
||||
expect(p.perTarget[8].runs).toBe(0);
|
||||
});
|
||||
|
||||
it('tolerates partial schema', () => {
|
||||
localStorage.setItem(
|
||||
'zahlzerlegung.progress',
|
||||
JSON.stringify({ perTarget: { 7: { runs: 3 } } })
|
||||
);
|
||||
const p = loadProgress();
|
||||
expect(p.perTarget[7].runs).toBe(3);
|
||||
expect(p.perTarget[7].top5).toEqual([]);
|
||||
expect(p.perTarget[5].runs).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('settings', () => {
|
||||
it('returns defaults when nothing stored', () => {
|
||||
const s = loadSettings();
|
||||
expect(s.roundSeconds).toBe(DEFAULT_SETTINGS.roundSeconds);
|
||||
expect(s.soundOn).toBe(DEFAULT_SETTINGS.soundOn);
|
||||
});
|
||||
|
||||
it('round-trips settings', () => {
|
||||
saveSettings({ schemaVersion: 1, roundSeconds: 90, soundOn: false });
|
||||
const s = loadSettings();
|
||||
expect(s.roundSeconds).toBe(90);
|
||||
expect(s.soundOn).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to defaults on invalid roundSeconds', () => {
|
||||
localStorage.setItem(
|
||||
'zahlzerlegung.settings',
|
||||
JSON.stringify({ roundSeconds: -1, soundOn: 'yes' })
|
||||
);
|
||||
const s = loadSettings();
|
||||
expect(s.roundSeconds).toBe(DEFAULT_SETTINGS.roundSeconds);
|
||||
expect(s.soundOn).toBe(DEFAULT_SETTINGS.soundOn);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { Stage } from './stages';
|
||||
import type { Target } from './tasks';
|
||||
import { TARGETS } from './tasks';
|
||||
|
||||
const SCHEMA_VERSION = 1;
|
||||
const PROGRESS_KEY = 'zahlzerlegung.progress';
|
||||
const SETTINGS_KEY = 'zahlzerlegung.settings';
|
||||
|
||||
export type RunRecord = { stage: Stage; date: string };
|
||||
|
||||
export type TargetProgress = {
|
||||
runs: number;
|
||||
top5: RunRecord[]; // sortiert: höchste Stufe zuerst, bei Gleichstand neueste zuerst
|
||||
};
|
||||
|
||||
export type Progress = {
|
||||
schemaVersion: number;
|
||||
perTarget: Record<Target, TargetProgress>;
|
||||
};
|
||||
|
||||
export type Settings = {
|
||||
schemaVersion: number;
|
||||
roundSeconds: number;
|
||||
soundOn: boolean;
|
||||
};
|
||||
|
||||
export const DEFAULT_SETTINGS: Settings = {
|
||||
schemaVersion: SCHEMA_VERSION,
|
||||
roundSeconds: 60,
|
||||
soundOn: true,
|
||||
};
|
||||
|
||||
export function emptyProgress(): Progress {
|
||||
const perTarget = {} as Record<Target, TargetProgress>;
|
||||
for (const t of TARGETS) perTarget[t] = { runs: 0, top5: [] };
|
||||
return { schemaVersion: SCHEMA_VERSION, perTarget };
|
||||
}
|
||||
|
||||
function readJson<T>(key: string): unknown | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeJson(key: string, value: unknown): void {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
} catch {
|
||||
// Quota überschritten oder Storage deaktiviert — schweigend ignorieren.
|
||||
}
|
||||
}
|
||||
|
||||
export function loadProgress(): Progress {
|
||||
const raw = readJson<Partial<Progress>>(PROGRESS_KEY);
|
||||
const base = emptyProgress();
|
||||
if (!raw || typeof raw !== 'object') return base;
|
||||
|
||||
// Tolerant gegen fehlende oder korrupte Felder.
|
||||
const incoming = raw as Partial<Progress>;
|
||||
if (!incoming.perTarget) return base;
|
||||
for (const t of TARGETS) {
|
||||
const tp = incoming.perTarget[t];
|
||||
if (!tp) continue;
|
||||
base.perTarget[t] = {
|
||||
runs: typeof tp.runs === 'number' ? tp.runs : 0,
|
||||
top5: Array.isArray(tp.top5)
|
||||
? tp.top5
|
||||
.filter(
|
||||
(r) => r && typeof r.stage === 'number' && typeof r.date === 'string'
|
||||
)
|
||||
.slice(0, 5) as RunRecord[]
|
||||
: [],
|
||||
};
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
export function saveProgress(p: Progress): void {
|
||||
writeJson(PROGRESS_KEY, p);
|
||||
}
|
||||
|
||||
export function recordRun(p: Progress, target: Target, stage: Stage): Progress {
|
||||
const prev = p.perTarget[target] ?? { runs: 0, top5: [] };
|
||||
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);
|
||||
});
|
||||
const top5 = merged.slice(0, 5);
|
||||
return {
|
||||
...p,
|
||||
perTarget: {
|
||||
...p.perTarget,
|
||||
[target]: { runs: prev.runs + 1, top5 },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function loadSettings(): Settings {
|
||||
const raw = readJson<Partial<Settings>>(SETTINGS_KEY);
|
||||
if (!raw || typeof raw !== 'object') return { ...DEFAULT_SETTINGS };
|
||||
const incoming = raw as Partial<Settings>;
|
||||
return {
|
||||
schemaVersion: SCHEMA_VERSION,
|
||||
roundSeconds:
|
||||
typeof incoming.roundSeconds === 'number' && incoming.roundSeconds > 0
|
||||
? incoming.roundSeconds
|
||||
: DEFAULT_SETTINGS.roundSeconds,
|
||||
soundOn: typeof incoming.soundOn === 'boolean' ? incoming.soundOn : DEFAULT_SETTINGS.soundOn,
|
||||
};
|
||||
}
|
||||
|
||||
export function saveSettings(s: Settings): void {
|
||||
writeJson(SETTINGS_KEY, s);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { stageFor, isWin, isStageBoundary, STAGE_THRESHOLDS, TOTAL_TO_WIN } from './stages';
|
||||
|
||||
describe('stageFor', () => {
|
||||
it('returns 0 (Boden) for 0 and 1 correct answers', () => {
|
||||
expect(stageFor(0)).toBe(0);
|
||||
expect(stageFor(1)).toBe(0);
|
||||
});
|
||||
|
||||
it('returns 1 (Luftballon) at threshold 2 and below 4', () => {
|
||||
expect(stageFor(2)).toBe(1);
|
||||
expect(stageFor(3)).toBe(1);
|
||||
});
|
||||
|
||||
it('returns 2 (Wolken) for 4..6', () => {
|
||||
expect(stageFor(4)).toBe(2);
|
||||
expect(stageFor(5)).toBe(2);
|
||||
expect(stageFor(6)).toBe(2);
|
||||
});
|
||||
|
||||
it('returns 3 (Mond) for 7..9', () => {
|
||||
expect(stageFor(7)).toBe(3);
|
||||
expect(stageFor(8)).toBe(3);
|
||||
expect(stageFor(9)).toBe(3);
|
||||
});
|
||||
|
||||
it('returns 4 (Sterne) at TOTAL_TO_WIN', () => {
|
||||
expect(stageFor(10)).toBe(4);
|
||||
expect(stageFor(11)).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isWin', () => {
|
||||
it('only returns true once total threshold reached', () => {
|
||||
expect(isWin(9)).toBe(false);
|
||||
expect(isWin(TOTAL_TO_WIN)).toBe(true);
|
||||
expect(isWin(20)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isStageBoundary', () => {
|
||||
it('returns true exactly at the configured thresholds', () => {
|
||||
for (const t of STAGE_THRESHOLDS) expect(isStageBoundary(t)).toBe(true);
|
||||
});
|
||||
it('returns false between thresholds', () => {
|
||||
expect(isStageBoundary(0)).toBe(false);
|
||||
expect(isStageBoundary(1)).toBe(false);
|
||||
expect(isStageBoundary(3)).toBe(false);
|
||||
expect(isStageBoundary(5)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
// Schwellen aus BRAINSTORM.md: 2/4/7/10 richtige Aufgaben pro Stufenaufstieg.
|
||||
// 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 | 5;
|
||||
|
||||
export const STAGE_NAMES: Record<Stage, string> = {
|
||||
0: 'boden',
|
||||
1: 'luftballon',
|
||||
2: 'wolken',
|
||||
3: 'mond',
|
||||
4: 'sterne',
|
||||
5: 'regenbogenland',
|
||||
};
|
||||
|
||||
export function stageFor(correct: number): Stage {
|
||||
if (correct >= STAGE_THRESHOLDS[3]) return 4; // 10+ → Sterne (Win-Übergang)
|
||||
if (correct >= STAGE_THRESHOLDS[2]) return 3; // 7..9 → Mond
|
||||
if (correct >= STAGE_THRESHOLDS[1]) return 2; // 4..6 → Wolken
|
||||
if (correct >= STAGE_THRESHOLDS[0]) return 1; // 2..3 → Luftballon
|
||||
return 0; // 0..1 → Boden
|
||||
}
|
||||
|
||||
export function isWin(correct: number): boolean {
|
||||
return correct >= TOTAL_TO_WIN;
|
||||
}
|
||||
|
||||
// Bei welchen correctCount-Werten findet ein sichtbarer Stufenwechsel statt?
|
||||
export function isStageBoundary(correct: number): boolean {
|
||||
return (STAGE_THRESHOLDS as readonly number[]).includes(correct);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { generateTask, TARGETS, type Target } from './tasks';
|
||||
|
||||
describe('generateTask', () => {
|
||||
it.each(TARGETS)('produces a valid decomposition for target %i', (target) => {
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const task = generateTask(target);
|
||||
expect(task.target).toBe(target);
|
||||
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 i = 0; i < 30; i++) {
|
||||
const task = generateTask(target);
|
||||
expect(task.choices).toContain(task.answer);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('produces choices without duplicates', () => {
|
||||
for (const target of TARGETS) {
|
||||
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 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('returns 3 choices for target 4 (3 possible answers total)', () => {
|
||||
const task = generateTask(4);
|
||||
expect(task.choices.length).toBe(3);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
// Zahlzerlegungs-Aufgabengenerator.
|
||||
//
|
||||
// Eine Aufgabe für Zielzahl T besteht aus:
|
||||
// given: vorgegebene Zerlegungszahl in [1..T-1]
|
||||
// answer: T - given (das Kind muss diese tippen)
|
||||
// choices: 3 große Buttons (richtige Antwort + 2 Distraktoren), gemischt
|
||||
//
|
||||
// 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;
|
||||
|
||||
export type Task = {
|
||||
target: Target;
|
||||
given: number;
|
||||
answer: number;
|
||||
choices: number[];
|
||||
};
|
||||
|
||||
export const TARGETS: Target[] = [4, 5, 6, 7, 8, 9, 10];
|
||||
|
||||
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();
|
||||
for (let i = a.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[a[i], a[j]] = [a[j], a[i]];
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
// 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;
|
||||
|
||||
// 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 };
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
<script lang="ts">
|
||||
import { fade } from 'svelte/transition';
|
||||
import type { Target } from '../game/tasks';
|
||||
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';
|
||||
import TaskPrompt from '../components/game/TaskPrompt.svelte';
|
||||
import AnswerButtons from '../components/game/AnswerButtons.svelte';
|
||||
import Timer from '../components/game/Timer.svelte';
|
||||
import BurstFx from '../components/game/BurstFx.svelte';
|
||||
import IconButton from '../components/shared/IconButton.svelte';
|
||||
|
||||
type Props = { target: Target };
|
||||
let { target }: Props = $props();
|
||||
|
||||
let lastBumpKey = $state(0);
|
||||
let lastCountdownTickAt = $state(0);
|
||||
|
||||
function handleAnswer(value: number) {
|
||||
play('tap');
|
||||
if ($game.task && value === $game.task.answer) {
|
||||
play('correct');
|
||||
play('boost');
|
||||
}
|
||||
answer(value);
|
||||
}
|
||||
|
||||
function handleAbort() {
|
||||
abortGame();
|
||||
goHome();
|
||||
}
|
||||
|
||||
// Stufenaufstieg → level-Sound zusätzlich abspielen
|
||||
$effect(() => {
|
||||
if ($game.stageBumpKey > lastBumpKey) {
|
||||
lastBumpKey = $game.stageBumpKey;
|
||||
if ($game.status === 'won') play('fanfare');
|
||||
else play('level');
|
||||
}
|
||||
});
|
||||
|
||||
// Countdown letzte 5 Sekunden
|
||||
$effect(() => {
|
||||
if ($game.status !== 'running') return;
|
||||
const sec = $timeLeftSeconds;
|
||||
if (sec > 0 && sec <= 5 && sec !== lastCountdownTickAt) {
|
||||
lastCountdownTickAt = sec;
|
||||
play('countdown');
|
||||
}
|
||||
});
|
||||
|
||||
// Bei Spielende → Result-Screen
|
||||
$effect(() => {
|
||||
if ($game.status === 'won' || $game.status === 'timeout') {
|
||||
const t = setTimeout(() => goResult(target), $game.status === 'won' ? 1600 : 600);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="screen" in:fade={{ duration: 220 }}>
|
||||
<header class="topbar">
|
||||
<IconButton label="Zurück" onClick={handleAbort}>
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" aria-hidden="true">
|
||||
<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 stage={$stage} won={$game.status === 'won'} />
|
||||
<BurstFx triggerKey={$game.stageBumpKey} />
|
||||
</aside>
|
||||
<main class="play">
|
||||
{#if $game.task && $game.status === 'running'}
|
||||
<TaskPrompt task={$game.task} />
|
||||
<AnswerButtons
|
||||
choices={$game.task.choices}
|
||||
correctAnswer={$game.task.answer}
|
||||
onAnswer={handleAnswer}
|
||||
/>
|
||||
{:else if $game.status === 'won'}
|
||||
<div class="end-message">
|
||||
<span class="big">🌈</span>
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.screen {
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
padding: 12px;
|
||||
gap: 8px;
|
||||
}
|
||||
.topbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: 30% 1fr;
|
||||
gap: 14px;
|
||||
min-height: 0;
|
||||
}
|
||||
.track-wrap {
|
||||
position: relative;
|
||||
min-height: 0;
|
||||
}
|
||||
.play {
|
||||
display: grid;
|
||||
grid-template-rows: 1fr auto;
|
||||
gap: 24px;
|
||||
align-items: center;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
.end-message {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
height: 100%;
|
||||
}
|
||||
.big {
|
||||
font-size: clamp(80px, 18vw, 200px);
|
||||
animation: pop 0.6s ease;
|
||||
}
|
||||
@keyframes pop {
|
||||
0% { transform: scale(0.4); opacity: 0; }
|
||||
60% { transform: scale(1.15); opacity: 1; }
|
||||
100% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.layout { grid-template-columns: 24% 1fr; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,95 @@
|
||||
<script lang="ts">
|
||||
import { fade } from 'svelte/transition';
|
||||
import { TARGETS, type Target } from '../game/tasks';
|
||||
import { goGame, goSettings } from '../stores/route';
|
||||
import { startGame } from '../stores/game';
|
||||
import { play, unlockAudio } from '../audio/soundManager';
|
||||
import NumberCard from '../components/home/NumberCard.svelte';
|
||||
import SoundToggle from '../components/shared/SoundToggle.svelte';
|
||||
import IconButton from '../components/shared/IconButton.svelte';
|
||||
|
||||
function handlePick(t: Target) {
|
||||
unlockAudio();
|
||||
play('tap');
|
||||
startGame(t);
|
||||
goGame(t);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="screen" in:fade={{ duration: 220 }}>
|
||||
<header class="topbar">
|
||||
<SoundToggle />
|
||||
<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
|
||||
d="M14 3 L18 3 L18.6 6.4 L21.5 7.6 L23.9 5.3 L26.7 8.1 L24.4 10.5 L25.6 13.4 L29 14 L29 18 L25.6 18.6 L24.4 21.5 L26.7 23.9 L23.9 26.7 L21.5 24.4 L18.6 25.6 L18 29 L14 29 L13.4 25.6 L10.5 24.4 L8.1 26.7 L5.3 23.9 L7.6 21.5 L6.4 18.6 L3 18 L3 14 L6.4 13.4 L7.6 10.5 L5.3 8.1 L8.1 5.3 L10.5 7.6 L13.4 6.4 Z M16 12 A4 4 0 1 0 16.001 12 Z"
|
||||
fill="currentColor"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</IconButton>
|
||||
</header>
|
||||
|
||||
<div class="cards-grid">
|
||||
{#each TARGETS as target (target)}
|
||||
<NumberCard {target} onClick={handlePick} />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.screen {
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
padding: 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
.topbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
border: 0;
|
||||
}
|
||||
.cards-grid {
|
||||
/* Karten haben aspect-ratio 3/4. Bei 7 Karten ergeben sich je nach Spaltenzahl
|
||||
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);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: var(--gap);
|
||||
align-content: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
/* 4 Spalten, 2 Reihen */
|
||||
max-width: calc(var(--avail-h) * 1.5 + var(--gap) * 1.5);
|
||||
margin-inline: auto;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.cards-grid {
|
||||
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 {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
/* 2 Spalten, 4 Reihen */
|
||||
max-width: calc(var(--avail-h) * 0.375 - var(--gap) * 0.125);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,128 @@
|
||||
<script lang="ts">
|
||||
import { fade, fly } from 'svelte/transition';
|
||||
import type { Target } from '../game/tasks';
|
||||
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';
|
||||
|
||||
type Props = { target: Target };
|
||||
let { target }: Props = $props();
|
||||
|
||||
// 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();
|
||||
play('tap');
|
||||
startGame(target);
|
||||
goGame(target);
|
||||
}
|
||||
|
||||
function home() {
|
||||
play('tap');
|
||||
goHome();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="screen" in:fade={{ duration: 240 }}>
|
||||
<header>
|
||||
<h2 class="target-label">
|
||||
<span class="num">{target}</span>
|
||||
</h2>
|
||||
</header>
|
||||
|
||||
<div class="celebration">
|
||||
{#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} />
|
||||
</aside>
|
||||
|
||||
<footer class="actions">
|
||||
<button class="action retry" type="button" onclick={retry} aria-label="Nochmal">
|
||||
<svg width="44" height="44" viewBox="0 0 44 44" aria-hidden="true">
|
||||
<path d="M10 22 A12 12 0 1 1 22 34" fill="none" stroke="currentColor" stroke-width="4" stroke-linecap="round" />
|
||||
<path d="M22 34 L16 30 L18 38 Z" fill="currentColor" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="action home" type="button" onclick={home} aria-label="Zurück zur Übersicht">
|
||||
<svg width="44" height="44" viewBox="0 0 44 44" aria-hidden="true">
|
||||
<path d="M6 22 L22 8 L38 22 L34 22 L34 36 L26 36 L26 26 L18 26 L18 36 L10 36 L10 22 Z" fill="currentColor" />
|
||||
</svg>
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.screen {
|
||||
height: 100%;
|
||||
display: grid;
|
||||
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;
|
||||
}
|
||||
.rocket-wrap { z-index: 2; }
|
||||
.rainbow-wrap {
|
||||
position: absolute;
|
||||
bottom: 12%;
|
||||
z-index: 1;
|
||||
}
|
||||
.scores {
|
||||
grid-area: scores;
|
||||
width: 110px;
|
||||
height: 100%;
|
||||
}
|
||||
.actions {
|
||||
grid-area: actions;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 24px;
|
||||
}
|
||||
.action {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
border-radius: 50%;
|
||||
color: white;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
box-shadow: 0 6px 0 rgba(0,0,0,0.18);
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
.action:active { transform: translateY(4px); box-shadow: 0 2px 0 rgba(0,0,0,0.18); }
|
||||
.action.retry { background: var(--c-correct); }
|
||||
.action.home { background: var(--c-rocket-body); }
|
||||
</style>
|
||||
@@ -0,0 +1,120 @@
|
||||
<script lang="ts">
|
||||
import { fade } from 'svelte/transition';
|
||||
import { settings, ROUND_SECONDS_OPTIONS, setRoundSeconds, toggleSound } from '../stores/settings';
|
||||
import { goHome } from '../stores/route';
|
||||
import { play } from '../audio/soundManager';
|
||||
import IconButton from '../components/shared/IconButton.svelte';
|
||||
</script>
|
||||
|
||||
<div class="screen" in:fade={{ duration: 220 }}>
|
||||
<header>
|
||||
<IconButton label="Zurück" onClick={goHome}>
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" aria-hidden="true">
|
||||
<path d="M18 5 L8 14 L18 23" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</IconButton>
|
||||
</header>
|
||||
|
||||
<section class="setting">
|
||||
<div class="label">
|
||||
<svg width="44" height="44" viewBox="0 0 44 44" aria-hidden="true">
|
||||
<circle cx="22" cy="24" r="14" fill="none" stroke="currentColor" stroke-width="3" />
|
||||
<path d="M22 14 L22 24 L29 27" stroke="currentColor" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
<path d="M16 6 L16 10 M28 6 L28 10" stroke="currentColor" stroke-width="3" stroke-linecap="round" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="options">
|
||||
{#each ROUND_SECONDS_OPTIONS as opt (opt)}
|
||||
<button
|
||||
type="button"
|
||||
class="opt"
|
||||
class:selected={$settings.roundSeconds === opt}
|
||||
onclick={() => { play('tap'); setRoundSeconds(opt); }}
|
||||
aria-label={`${opt} Sekunden`}
|
||||
>
|
||||
<span class="opt-num">{opt}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="setting">
|
||||
<div class="label">
|
||||
<svg width="44" height="44" viewBox="0 0 44 44" aria-hidden="true">
|
||||
<path d="M8 16 L16 16 L26 8 L26 36 L16 28 L8 28 Z" fill="currentColor" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="options">
|
||||
<button
|
||||
type="button"
|
||||
class="opt big-toggle"
|
||||
class:selected={$settings.soundOn}
|
||||
onclick={() => { play('tap'); toggleSound(); }}
|
||||
aria-label={$settings.soundOn ? 'Ton aus' : 'Ton an'}
|
||||
>
|
||||
{#if $settings.soundOn}
|
||||
<svg width="40" height="40" viewBox="0 0 40 40" aria-hidden="true">
|
||||
<path d="M14 14 L20 14 L28 8 L28 32 L20 26 L14 26 Z" fill="currentColor" />
|
||||
<path d="M30 12 Q36 20 30 28" stroke="currentColor" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
</svg>
|
||||
{:else}
|
||||
<svg width="40" height="40" viewBox="0 0 40 40" aria-hidden="true">
|
||||
<path d="M14 14 L20 14 L28 8 L28 32 L20 26 L14 26 Z" fill="currentColor" />
|
||||
<path d="M30 14 L38 26 M38 14 L30 26" stroke="currentColor" stroke-width="3" stroke-linecap="round" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.screen {
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr 1fr;
|
||||
padding: 16px;
|
||||
gap: 18px;
|
||||
color: var(--c-text-on-dark);
|
||||
}
|
||||
.setting {
|
||||
display: grid;
|
||||
grid-template-columns: 80px 1fr;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
background: rgba(255,255,255,0.12);
|
||||
border-radius: var(--radius-card);
|
||||
padding: 16px;
|
||||
}
|
||||
.label {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.options {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
.opt {
|
||||
min-width: 84px;
|
||||
min-height: 84px;
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
color: var(--c-text-on-dark);
|
||||
border-radius: 18px;
|
||||
font-weight: 800;
|
||||
font-size: 36px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
transition: transform 0.08s ease, background 0.15s ease;
|
||||
}
|
||||
.opt:active { transform: scale(0.95); }
|
||||
.opt.selected {
|
||||
background: var(--c-rocket-window);
|
||||
color: var(--c-text);
|
||||
box-shadow: 0 0 0 4px rgba(255,229,102,0.4);
|
||||
}
|
||||
.big-toggle { padding: 10px 24px; }
|
||||
.opt-num { display: inline-block; }
|
||||
</style>
|
||||
@@ -0,0 +1,140 @@
|
||||
import { get, writable, derived } from 'svelte/store';
|
||||
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';
|
||||
|
||||
export type GameStatus = 'idle' | 'running' | 'won' | 'timeout';
|
||||
|
||||
export type GameState = {
|
||||
status: GameStatus;
|
||||
target: Target | null;
|
||||
task: Task | null;
|
||||
prevTask: Task | null;
|
||||
correctCount: number;
|
||||
timeLeftMs: number;
|
||||
totalMs: number;
|
||||
lastWasCorrect: boolean | null;
|
||||
stageBumpKey: number; // monoton wachsend → triggert FX-Animation
|
||||
};
|
||||
|
||||
const initial: GameState = {
|
||||
status: 'idle',
|
||||
target: null,
|
||||
task: null,
|
||||
prevTask: null,
|
||||
correctCount: 0,
|
||||
timeLeftMs: 0,
|
||||
totalMs: 0,
|
||||
lastWasCorrect: null,
|
||||
stageBumpKey: 0,
|
||||
};
|
||||
|
||||
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;
|
||||
let lastTickAt = 0;
|
||||
|
||||
function clearTick() {
|
||||
if (tickHandle !== null) {
|
||||
cancelAnimationFrame(tickHandle);
|
||||
tickHandle = null;
|
||||
}
|
||||
}
|
||||
|
||||
function tick() {
|
||||
const now = performance.now();
|
||||
const delta = now - lastTickAt;
|
||||
lastTickAt = now;
|
||||
let ended = false;
|
||||
game.update((g) => {
|
||||
if (g.status !== 'running') return g;
|
||||
const next = Math.max(0, g.timeLeftMs - delta);
|
||||
if (next <= 0) {
|
||||
ended = true;
|
||||
return { ...g, timeLeftMs: 0, status: 'timeout' };
|
||||
}
|
||||
return { ...g, timeLeftMs: next };
|
||||
});
|
||||
if (ended) {
|
||||
clearTick();
|
||||
finalize();
|
||||
return;
|
||||
}
|
||||
tickHandle = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
function finalize() {
|
||||
const g = get(game);
|
||||
if (g.target === null) return;
|
||||
recordResult(g.target, stageFor(g.correctCount));
|
||||
}
|
||||
|
||||
export function startGame(target: Target): void {
|
||||
clearTick();
|
||||
const totalMs = get(settings).roundSeconds * 1000;
|
||||
const firstTask = generateTask(target);
|
||||
game.set({
|
||||
status: 'running',
|
||||
target,
|
||||
task: firstTask,
|
||||
prevTask: null,
|
||||
correctCount: 0,
|
||||
timeLeftMs: totalMs,
|
||||
totalMs,
|
||||
lastWasCorrect: null,
|
||||
stageBumpKey: 0,
|
||||
});
|
||||
lastTickAt = performance.now();
|
||||
tickHandle = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
export function answer(value: number): void {
|
||||
const g = get(game);
|
||||
if (g.status !== 'running' || !g.task || g.target === null) return;
|
||||
const correct = value === g.task.answer;
|
||||
if (correct) {
|
||||
const nextCount = g.correctCount + 1;
|
||||
const prevStage = stageFor(g.correctCount);
|
||||
const newStage = stageFor(nextCount);
|
||||
const stageBump = newStage > prevStage;
|
||||
if (isWin(nextCount)) {
|
||||
clearTick();
|
||||
game.update((s) => ({
|
||||
...s,
|
||||
status: 'won',
|
||||
correctCount: nextCount,
|
||||
lastWasCorrect: true,
|
||||
stageBumpKey: s.stageBumpKey + 1,
|
||||
}));
|
||||
finalize();
|
||||
return;
|
||||
}
|
||||
const nextTask = generateTask(g.target, g.task);
|
||||
game.update((s) => ({
|
||||
...s,
|
||||
correctCount: nextCount,
|
||||
prevTask: s.task,
|
||||
task: nextTask,
|
||||
lastWasCorrect: true,
|
||||
stageBumpKey: stageBump ? s.stageBumpKey + 1 : s.stageBumpKey,
|
||||
}));
|
||||
} else {
|
||||
game.update((s) => ({ ...s, lastWasCorrect: false }));
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import {
|
||||
emptyProgress,
|
||||
loadProgress,
|
||||
recordRun,
|
||||
saveProgress,
|
||||
type Progress,
|
||||
} from '../game/persistence';
|
||||
import type { Target } from '../game/tasks';
|
||||
import type { Stage } from '../game/stages';
|
||||
|
||||
const initial: Progress = typeof window === 'undefined' ? emptyProgress() : loadProgress();
|
||||
|
||||
export const progress = writable<Progress>(initial);
|
||||
|
||||
progress.subscribe((p) => {
|
||||
if (typeof window !== 'undefined') saveProgress(p);
|
||||
});
|
||||
|
||||
export function recordResult(target: Target, stage: Stage): void {
|
||||
progress.update((p) => recordRun(p, target, stage));
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import type { Target } from '../game/tasks';
|
||||
|
||||
export type Route =
|
||||
| { name: 'home' }
|
||||
| { name: 'game'; target: Target }
|
||||
| { name: 'result'; target: Target }
|
||||
| { name: 'settings' };
|
||||
|
||||
export const route = writable<Route>({ name: 'home' });
|
||||
|
||||
export const goHome = () => route.set({ name: 'home' });
|
||||
export const goGame = (target: Target) => route.set({ name: 'game', target });
|
||||
export const goResult = (target: Target) => route.set({ name: 'result', target });
|
||||
export const goSettings = () => route.set({ name: 'settings' });
|
||||
@@ -0,0 +1,23 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import { loadSettings, saveSettings, type Settings } from '../game/persistence';
|
||||
|
||||
export const ROUND_SECONDS_OPTIONS = [30, 60, 90, 120] as const;
|
||||
|
||||
const initial: Settings =
|
||||
typeof window === 'undefined'
|
||||
? { schemaVersion: 1, roundSeconds: 60, soundOn: true }
|
||||
: loadSettings();
|
||||
|
||||
export const settings = writable<Settings>(initial);
|
||||
|
||||
settings.subscribe((s) => {
|
||||
if (typeof window !== 'undefined') saveSettings(s);
|
||||
});
|
||||
|
||||
export function setRoundSeconds(value: number): void {
|
||||
settings.update((s) => ({ ...s, roundSeconds: value }));
|
||||
}
|
||||
|
||||
export function toggleSound(): void {
|
||||
settings.update((s) => ({ ...s, soundOn: !s.soundOn }));
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { mount } from 'svelte';
|
||||
import App from './App.svelte';
|
||||
import './app.css';
|
||||
|
||||
const app = mount(App, { target: document.getElementById('app')! });
|
||||
|
||||
export default app;
|
||||
@@ -0,0 +1,33 @@
|
||||
import { beforeEach } from 'vitest';
|
||||
|
||||
class MemoryStorage implements Storage {
|
||||
private map = new Map<string, string>();
|
||||
get length(): number {
|
||||
return this.map.size;
|
||||
}
|
||||
clear(): void {
|
||||
this.map.clear();
|
||||
}
|
||||
getItem(key: string): string | null {
|
||||
return this.map.has(key) ? this.map.get(key)! : null;
|
||||
}
|
||||
setItem(key: string, value: string): void {
|
||||
this.map.set(key, String(value));
|
||||
}
|
||||
removeItem(key: string): void {
|
||||
this.map.delete(key);
|
||||
}
|
||||
key(index: number): string | null {
|
||||
return Array.from(this.map.keys())[index] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
// Node 25 ships an experimental localStorage that lacks .clear; replace it
|
||||
// before every test so jsdom-style behaviour is reliable.
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
value: new MemoryStorage(),
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
/// <reference types="svelte" />
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user