Compare commits
No commits in common. "34bcef5da8695ca4858778c7a4c7c9dba2a03e78" and "3896a6cabbd9395744c03d33a8b88e3c6b8647cf" have entirely different histories.
34bcef5da8
...
3896a6cabb
|
|
@ -11,7 +11,7 @@ Für jede Zielzahl wird sichtbar, wie weit das Kind in den letzten fünf Runden
|
||||||
## Funktionsumfang
|
## Funktionsumfang
|
||||||
|
|
||||||
- Zielzahlen 4 bis 10, einzeln auswählbar
|
- 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
|
- Sound an/aus über Lautsprecher-Knopf
|
||||||
- Highscore-Anzeige je Zielzahl mit Krone für die beste Runde
|
- Highscore-Anzeige je Zielzahl mit Krone für die beste Runde
|
||||||
- Läuft offline und kann als App installiert werden (PWA)
|
- Läuft offline und kann als App installiert werden (PWA)
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,12 @@
|
||||||
--c-rocket-fin: #4a4a6e;
|
--c-rocket-fin: #4a4a6e;
|
||||||
--c-flame-inner: #fff4b3;
|
--c-flame-inner: #fff4b3;
|
||||||
--c-flame-outer: #ff8a3d;
|
--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-correct: #5fd07a;
|
||||||
--c-text: #1f1f3a;
|
--c-text: #1f1f3a;
|
||||||
--c-text-on-dark: #ffffff;
|
--c-text-on-dark: #ffffff;
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
// boost — Raketenschub (Whoosh, niedrig)
|
// boost — Raketenschub (Whoosh, niedrig)
|
||||||
// level — neue Stufe (Akkord aufsteigend)
|
// level — neue Stufe (Akkord aufsteigend)
|
||||||
// countdown — letzte 5 Sekunden (Tick pro Sekunde, höher werdend)
|
// 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 { get } from 'svelte/store';
|
||||||
import { settings } from '../stores/settings';
|
import { settings } from '../stores/settings';
|
||||||
|
|
|
||||||
|
|
@ -9,13 +9,9 @@
|
||||||
|
|
||||||
let wrongValue = $state<number | null>(null);
|
let wrongValue = $state<number | null>(null);
|
||||||
let lockedCorrect = $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) {
|
function handle(value: number) {
|
||||||
if (disabled || coolingDown || lockedCorrect !== null) return;
|
if (disabled || lockedCorrect !== null) return;
|
||||||
if (value === correctAnswer) {
|
if (value === correctAnswer) {
|
||||||
lockedCorrect = value;
|
lockedCorrect = value;
|
||||||
onAnswer(value);
|
onAnswer(value);
|
||||||
|
|
@ -23,34 +19,21 @@
|
||||||
} else {
|
} else {
|
||||||
wrongValue = value;
|
wrongValue = value;
|
||||||
onAnswer(value);
|
onAnswer(value);
|
||||||
coolingDown = true;
|
setTimeout(() => {
|
||||||
if (cooldownTimer) clearTimeout(cooldownTimer);
|
if (wrongValue === value) wrongValue = null;
|
||||||
cooldownTimer = setTimeout(() => {
|
}, 380);
|
||||||
coolingDown = false;
|
|
||||||
wrongValue = null;
|
|
||||||
}, 900);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset NUR bei echtem Aufgabenwechsel. Der Effekt feuert über den choices-Prop auch
|
// Reset bei Aufgabenwechsel
|
||||||
// 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;
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (choices !== prevChoices) {
|
void choices; // dependency
|
||||||
prevChoices = choices;
|
|
||||||
lockedCorrect = null;
|
lockedCorrect = null;
|
||||||
wrongValue = null;
|
wrongValue = null;
|
||||||
coolingDown = false;
|
|
||||||
if (cooldownTimer) {
|
|
||||||
clearTimeout(cooldownTimer);
|
|
||||||
cooldownTimer = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
</script>
|
</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)}
|
{#each choices as value (value)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -59,7 +42,7 @@
|
||||||
class:correct={lockedCorrect === value}
|
class:correct={lockedCorrect === value}
|
||||||
onclick={() => handle(value)}
|
onclick={() => handle(value)}
|
||||||
aria-label={`Antwort ${value}`}
|
aria-label={`Antwort ${value}`}
|
||||||
disabled={disabled || coolingDown}
|
{disabled}
|
||||||
>
|
>
|
||||||
{value}
|
{value}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -83,16 +66,12 @@
|
||||||
color: var(--c-text);
|
color: var(--c-text);
|
||||||
border-radius: 22px;
|
border-radius: 22px;
|
||||||
box-shadow: 0 6px 0 #b9c4dc;
|
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) {
|
.answer:active:not(:disabled) {
|
||||||
transform: translateY(4px);
|
transform: translateY(4px);
|
||||||
box-shadow: 0 2px 0 #b9c4dc;
|
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 {
|
.answer.wrong {
|
||||||
animation: shake 0.36s ease;
|
animation: shake 0.36s ease;
|
||||||
background: #ffd0d0;
|
background: #ffd0d0;
|
||||||
|
|
|
||||||
|
|
@ -57,15 +57,11 @@
|
||||||
position: absolute;
|
position: absolute;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--color);
|
background: var(--color);
|
||||||
/* Grundzustand unsichtbar: selbst falls WebKit den backwards-Fill erst spät anwendet,
|
transform: rotate(var(--angle)) translateY(0);
|
||||||
blitzt nichts auf. `both` setzt dann den 0%-Zustand, die Animation übernimmt. */
|
animation: fly 0.7s ease-out forwards;
|
||||||
opacity: 0;
|
|
||||||
transform: scale(0);
|
|
||||||
will-change: transform, opacity;
|
|
||||||
animation: fly 0.7s ease-out both;
|
|
||||||
}
|
}
|
||||||
@keyframes fly {
|
@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; }
|
100% { transform: rotate(var(--angle)) translateY(calc(var(--distance) * -1)) scale(1); opacity: 0; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,49 +1,18 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import type { Stage } from '../../game/stages';
|
||||||
import Rocket from '../svg/Rocket.svelte';
|
import Rocket from '../svg/Rocket.svelte';
|
||||||
import Balloon from '../svg/Balloon.svelte';
|
import Balloon from '../svg/Balloon.svelte';
|
||||||
import Cloud from '../svg/Cloud.svelte';
|
import Cloud from '../svg/Cloud.svelte';
|
||||||
import Moon from '../svg/Moon.svelte';
|
import Moon from '../svg/Moon.svelte';
|
||||||
import Star from '../svg/Star.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 };
|
// Höhenpositionen 0..1 entlang der Bahn (von unten = 0 nach oben = 1)
|
||||||
let { correct, won }: Props = $props();
|
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]);
|
||||||
// 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 },
|
|
||||||
];
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="track" class:won>
|
<div class="track" class:won>
|
||||||
|
|
@ -53,19 +22,18 @@
|
||||||
<div class="layer sky-high"></div>
|
<div class="layer sky-high"></div>
|
||||||
<div class="layer space"></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 -->
|
<!-- Markierungen -->
|
||||||
<div class="marker" style="bottom: {STAGE_Y[1] * 100}%"><Balloon size={78} /></div>
|
<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={92} /></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={78} /></div>
|
<div class="marker" style="bottom: {STAGE_Y[3] * 100}%; left: 30%"><Moon size={56} /></div>
|
||||||
<!-- Sterne-Stufe: oberhalb der letzten Linie im ganzen oberen Bereich verteilt, einheitliche Farbe. -->
|
<div class="marker stars" style="bottom: {STAGE_Y[4] * 100}%">
|
||||||
{#each STARS as s, i (i)}
|
<Star size={28} />
|
||||||
<div class="star" style="left: {s.l}%; bottom: {s.b}%"><Star size={s.s} /></div>
|
<Star size={20} color="#fff" />
|
||||||
{/each}
|
<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">
|
<div class="rocket" style="bottom: calc({rocketY * 100}% - 60px)" aria-hidden="true">
|
||||||
<Rocket size={120} />
|
<Rocket size={120} />
|
||||||
|
|
@ -92,24 +60,24 @@
|
||||||
.sky-high { bottom: 60%; height: 25%; background: linear-gradient(to top, #4a7ec4, #2a3f7a); }
|
.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); }
|
.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 {
|
.marker {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 18%;
|
left: 18%;
|
||||||
transform: translate(-50%, 50%);
|
transform: translate(-50%, 50%);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
opacity: 0.85;
|
||||||
}
|
}
|
||||||
.star {
|
.marker.stars { display: flex; gap: 6px; left: 60%; }
|
||||||
position: absolute;
|
|
||||||
|
.rainbow {
|
||||||
|
left: 50%;
|
||||||
transform: translate(-50%, 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 {
|
.rocket {
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,8 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Target } from '../../game/tasks';
|
import type { Target } from '../../game/tasks';
|
||||||
import type { Stage } from '../../game/stages';
|
|
||||||
import { progress } from '../../stores/progress';
|
import { progress } from '../../stores/progress';
|
||||||
|
import Rocket from '../svg/Rocket.svelte';
|
||||||
import Crown from '../svg/Crown.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 };
|
type Props = { target: Target; highlightDate?: string | null };
|
||||||
let { target, highlightDate = null }: Props = $props();
|
let { target, highlightDate = null }: Props = $props();
|
||||||
|
|
@ -15,53 +10,21 @@
|
||||||
const top5 = $derived($progress.perTarget[target]?.top5 ?? []);
|
const top5 = $derived($progress.perTarget[target]?.top5 ?? []);
|
||||||
// Slots immer 5 lang darstellen — leere Plätze bleiben grau.
|
// Slots immer 5 lang darstellen — leere Plätze bleiben grau.
|
||||||
const slots = $derived(Array.from({ length: 5 }, (_, i) => top5[i] ?? null));
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="column" aria-label="Beste Flüge">
|
<div class="column" aria-label="Beste Flüge">
|
||||||
{#each slots as slot, i (i)}
|
{#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 slot}
|
||||||
{#if i === 0}
|
{#if i === 0}
|
||||||
<span class="crown"><Crown size={28} /></span>
|
<span class="crown"><Crown size={28} /></span>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="symbol" aria-label={`Stufe ${slot.stage}`}>
|
<Rocket size={44} flameAnimated={false} />
|
||||||
{#if slot.stage === 0}
|
<span class="stage-label" aria-label={`Stufe ${slot.stage}`}>
|
||||||
<Ground size={48} />
|
{#each Array(slot.stage) as _, dotIndex (dotIndex)}
|
||||||
{:else}
|
<span class="stage-dot"></span>
|
||||||
{@const Symbol = SYMBOL[slot.stage as Stage].c}
|
{/each}
|
||||||
<Symbol size={SYMBOL[slot.stage as Stage].size} />
|
|
||||||
{/if}
|
|
||||||
</span>
|
</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}
|
{:else}
|
||||||
<div class="placeholder"></div>
|
<div class="placeholder"></div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -76,26 +39,23 @@
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
height: 100%;
|
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 {
|
.slot {
|
||||||
position: relative;
|
position: relative;
|
||||||
background: rgba(16, 26, 54, 0.92);
|
background: rgba(255, 255, 255, 0.16);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
min-height: 56px;
|
min-height: 56px;
|
||||||
}
|
}
|
||||||
.slot.empty {
|
.slot.empty {
|
||||||
background: rgba(16, 26, 54, 0.6);
|
background: rgba(255, 255, 255, 0.06);
|
||||||
}
|
}
|
||||||
.slot.current {
|
.slot.current {
|
||||||
border-color: var(--c-rocket-window);
|
background: rgba(255, 229, 102, 0.3);
|
||||||
box-shadow: 0 0 0 2px var(--c-rocket-window), 0 0 18px rgba(255, 229, 102, 0.5);
|
box-shadow: 0 0 0 3px var(--c-rocket-window);
|
||||||
}
|
}
|
||||||
.crown {
|
.crown {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
@ -103,25 +63,12 @@
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
}
|
}
|
||||||
.symbol {
|
.stage-label { display: flex; gap: 3px; }
|
||||||
display: flex;
|
.stage-dot {
|
||||||
align-items: center;
|
width: 6px;
|
||||||
justify-content: center;
|
height: 6px;
|
||||||
}
|
border-radius: 50%;
|
||||||
.time {
|
background: var(--c-rocket-window);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
.placeholder {
|
.placeholder {
|
||||||
width: 32px;
|
width: 32px;
|
||||||
|
|
|
||||||
|
|
@ -1,41 +1,41 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Target } from '../../game/tasks';
|
import type { Target } from '../../game/tasks';
|
||||||
import { progress } from '../../stores/progress';
|
import { progress } from '../../stores/progress';
|
||||||
import Ground from '../svg/Ground.svelte';
|
import Rocket from '../svg/Rocket.svelte';
|
||||||
import Balloon from '../svg/Balloon.svelte';
|
import Crown from '../svg/Crown.svelte';
|
||||||
import Cloud from '../svg/Cloud.svelte';
|
|
||||||
import Moon from '../svg/Moon.svelte';
|
|
||||||
import Star from '../svg/Star.svelte';
|
|
||||||
|
|
||||||
type Props = { target: Target; onClick: (t: Target) => void };
|
type Props = { target: Target; onClick: (t: Target) => void };
|
||||||
let { target, onClick }: Props = $props();
|
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);
|
const bestStage = $derived(($progress.perTarget[target]?.top5[0]?.stage ?? 0) as number);
|
||||||
|
const runs = $derived($progress.perTarget[target]?.runs ?? 0);
|
||||||
// Bester Versuch → sein Stufen-Symbol (statt Krone).
|
// Fortschritt 0..1 → Höhe der Mini-Rakete in der Karte
|
||||||
const SYMBOL: Record<number, { c: typeof Balloon; size: number }> = {
|
const progressFraction = $derived(Math.min(bestStage / 5, 1));
|
||||||
0: { c: Ground, size: 60 },
|
const masteredAtRainbow = $derived(bestStage >= 5);
|
||||||
1: { c: Balloon, size: 52 },
|
|
||||||
2: { c: Cloud, size: 72 },
|
|
||||||
3: { c: Moon, size: 56 },
|
|
||||||
4: { c: Star, size: 52 },
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<button class="card" type="button" onclick={() => onClick(target)} aria-label={`Zielzahl ${target}`}>
|
<button class="card" type="button" onclick={() => onClick(target)} aria-label={`Zielzahl ${target}`}>
|
||||||
<span class="number">{target}</span>
|
<div class="number">{target}</div>
|
||||||
<span class="best" aria-hidden="true">
|
<div class="track" aria-hidden="true">
|
||||||
{#if runs > 0}
|
<div class="rocket-wrap" style="bottom: calc({progressFraction * 100}% - 18px)">
|
||||||
{#if bestStage === 2}
|
<Rocket size={36} flameAnimated={false} />
|
||||||
<!-- Auf der hellen Karte braucht die Wolke Farbe/Kontur, sonst weiß auf weiß. -->
|
</div>
|
||||||
<Cloud size={72} fill="#cfe2ff" stroke="#7e97c4" />
|
<div class="dot dot-1"></div>
|
||||||
{:else}
|
<div class="dot dot-2"></div>
|
||||||
{@const Best = SYMBOL[bestStage].c}
|
<div class="dot dot-3"></div>
|
||||||
<Best size={SYMBOL[bestStage].size} />
|
<div class="dot dot-4"></div>
|
||||||
{/if}
|
<div class="dot dot-5"></div>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
{#if masteredAtRainbow}
|
||||||
|
<Crown size={28} />
|
||||||
{/if}
|
{/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>
|
</span>
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|
@ -44,25 +44,64 @@
|
||||||
border-radius: var(--radius-card);
|
border-radius: var(--radius-card);
|
||||||
box-shadow: var(--shadow-soft);
|
box-shadow: var(--shadow-soft);
|
||||||
aspect-ratio: 3 / 4;
|
aspect-ratio: 3 / 4;
|
||||||
width: 100%;
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: 1fr auto;
|
grid-template-rows: auto 1fr auto;
|
||||||
place-items: center;
|
gap: 6px;
|
||||||
transition: transform 0.12s ease;
|
transition: transform 0.12s ease;
|
||||||
}
|
}
|
||||||
.card:active { transform: scale(0.96); }
|
.card:active { transform: scale(0.96); }
|
||||||
|
|
||||||
.number {
|
.number {
|
||||||
font-size: clamp(64px, 13vw, 124px);
|
font-size: clamp(48px, 8vw, 80px);
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
color: var(--c-text);
|
color: var(--c-text);
|
||||||
|
text-align: center;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
.best {
|
|
||||||
display: grid;
|
.track {
|
||||||
place-items: center;
|
position: relative;
|
||||||
min-height: 72px;
|
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>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -1,14 +1,10 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
let {
|
let { size = 80 }: { size?: number } = $props();
|
||||||
size = 80,
|
|
||||||
color = '#ff8aa8',
|
|
||||||
stroke = '#c25578',
|
|
||||||
}: { size?: number; color?: string; stroke?: string } = $props();
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svg width={size} height={size} viewBox="0 0 80 110" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
<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="40" cy="42" rx="30" ry="36" fill="#ff8aa8" stroke="#c25578" stroke-width="2" />
|
||||||
<ellipse cx="30" cy="32" rx="8" ry="14" fill="#ffffff" opacity="0.45" />
|
<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={stroke} />
|
<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" />
|
<path d="M40 84 Q38 95 42 105" stroke="#5c3344" stroke-width="1.5" fill="none" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -1,27 +1,12 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
// variant 0/1 = zwei leicht unterschiedliche Wolkenformen für Abwechslung.
|
let { size = 100, opacity = 1 }: { size?: number; opacity?: number } = $props();
|
||||||
let {
|
|
||||||
size = 100,
|
|
||||||
opacity = 1,
|
|
||||||
variant = 0,
|
|
||||||
fill = 'var(--c-cloud)',
|
|
||||||
stroke = '#cad6e8',
|
|
||||||
}: { size?: number; opacity?: number; variant?: number; fill?: string; stroke?: string } = $props();
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svg width={size} height={size * 0.6} viewBox="0 0 120 70" xmlns="http://www.w3.org/2000/svg" {opacity} aria-hidden="true">
|
<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">
|
<g fill="var(--c-cloud)" stroke="#cad6e8" 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}
|
|
||||||
<ellipse cx="30" cy="42" rx="22" ry="18" />
|
<ellipse cx="30" cy="42" rx="22" ry="18" />
|
||||||
<ellipse cx="60" cy="32" rx="28" ry="22" />
|
<ellipse cx="60" cy="32" rx="28" ry="22" />
|
||||||
<ellipse cx="90" cy="42" rx="22" ry="18" />
|
<ellipse cx="90" cy="42" rx="22" ry="18" />
|
||||||
<rect x="22" y="42" width="76" height="18" rx="9" />
|
<rect x="22" y="42" width="76" height="18" rx="9" />
|
||||||
{/if}
|
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
14
src/lib/components/svg/Rainbow.svelte
Normal file
14
src/lib/components/svg/Rainbow.svelte
Normal 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>
|
||||||
|
|
@ -37,7 +37,7 @@ describe('progress', () => {
|
||||||
|
|
||||||
it('limits top5 to five entries', () => {
|
it('limits top5 to five entries', () => {
|
||||||
let p = emptyProgress();
|
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].top5.length).toBe(5);
|
||||||
expect(p.perTarget[6].runs).toBe(10);
|
expect(p.perTarget[6].runs).toBe(10);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ const SCHEMA_VERSION = 1;
|
||||||
const PROGRESS_KEY = 'zahlzerlegung.progress';
|
const PROGRESS_KEY = 'zahlzerlegung.progress';
|
||||||
const SETTINGS_KEY = 'zahlzerlegung.settings';
|
const SETTINGS_KEY = 'zahlzerlegung.settings';
|
||||||
|
|
||||||
export type RunRecord = { stage: Stage; date: string; timeMs?: number };
|
export type RunRecord = { stage: Stage; date: string };
|
||||||
|
|
||||||
export type TargetProgress = {
|
export type TargetProgress = {
|
||||||
runs: number;
|
runs: number;
|
||||||
|
|
@ -83,15 +83,9 @@ export function saveProgress(p: Progress): void {
|
||||||
writeJson(PROGRESS_KEY, p);
|
writeJson(PROGRESS_KEY, p);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function recordRun(
|
export function recordRun(p: Progress, target: Target, stage: Stage): Progress {
|
||||||
p: Progress,
|
|
||||||
target: Target,
|
|
||||||
stage: Stage,
|
|
||||||
date: string = new Date().toISOString(),
|
|
||||||
timeMs?: number
|
|
||||||
): Progress {
|
|
||||||
const prev = p.perTarget[target] ?? { runs: 0, top5: [] };
|
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) => {
|
const merged = [...prev.top5, next].sort((a, b) => {
|
||||||
if (b.stage !== a.stage) return b.stage - a.stage;
|
if (b.stage !== a.stage) return b.stage - a.stage;
|
||||||
return b.date.localeCompare(a.date);
|
return b.date.localeCompare(a.date);
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
// Schwellen aus BRAINSTORM.md: 2/4/7/10 richtige Aufgaben pro Stufenaufstieg.
|
// 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.
|
// Während der Runde zeigt die Rakete Stufen 0..4:
|
||||||
// Die Sterne sind die höchste Stufe und zugleich der Sieg (isWin).
|
// 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 STAGE_THRESHOLDS = [2, 4, 7, 10] as const;
|
||||||
export const TOTAL_TO_WIN = 10;
|
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> = {
|
export const STAGE_NAMES: Record<Stage, string> = {
|
||||||
0: 'boden',
|
0: 'boden',
|
||||||
|
|
@ -13,6 +15,7 @@ export const STAGE_NAMES: Record<Stage, string> = {
|
||||||
2: 'wolken',
|
2: 'wolken',
|
||||||
3: 'mond',
|
3: 'mond',
|
||||||
4: 'sterne',
|
4: 'sterne',
|
||||||
|
5: 'regenbogenland',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function stageFor(correct: number): Stage {
|
export function stageFor(correct: number): Stage {
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,21 @@
|
||||||
import { describe, it, expect } from 'vitest';
|
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) => {
|
it.each(TARGETS)('produces a valid decomposition for target %i', (target) => {
|
||||||
for (let given = 0; given <= target; given++) {
|
for (let i = 0; i < 50; i++) {
|
||||||
const task = buildTask(target, given);
|
const task = generateTask(target);
|
||||||
expect(task.target).toBe(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);
|
expect(task.given + task.answer).toBe(target);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('always includes the correct answer in choices', () => {
|
it('always includes the correct answer in choices', () => {
|
||||||
for (const target of TARGETS) {
|
for (const target of TARGETS) {
|
||||||
for (let given = 0; given <= target; given++) {
|
for (let i = 0; i < 30; i++) {
|
||||||
const task = buildTask(target, given);
|
const task = generateTask(target);
|
||||||
expect(task.choices).toContain(task.answer);
|
expect(task.choices).toContain(task.answer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -22,75 +23,34 @@ describe('buildTask', () => {
|
||||||
|
|
||||||
it('produces choices without duplicates', () => {
|
it('produces choices without duplicates', () => {
|
||||||
for (const target of TARGETS) {
|
for (const target of TARGETS) {
|
||||||
for (let given = 0; given <= target; given++) {
|
for (let i = 0; i < 30; i++) {
|
||||||
const task = buildTask(target, given);
|
const task = generateTask(target);
|
||||||
expect(new Set(task.choices).size).toBe(task.choices.length);
|
const unique = new Set(task.choices);
|
||||||
|
expect(unique.size).toBe(task.choices.length);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns 4 choices for every target', () => {
|
it('returns 3 choices for targets >= 5', () => {
|
||||||
for (const target of TARGETS) {
|
for (const target of [5, 6, 7, 8, 9, 10] as Target[]) {
|
||||||
const task = buildTask(target, 1);
|
const task = generateTask(target);
|
||||||
expect(task.choices.length).toBe(4);
|
expect(task.choices.length).toBe(3);
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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('distributes givens as evenly as possible (counts differ by at most 1)', () => {
|
it('returns 3 choices for target 4 (3 possible answers total)', () => {
|
||||||
for (const target of TARGETS) {
|
const task = generateTask(4);
|
||||||
const poolSize = target + 1;
|
expect(task.choices.length).toBe(3);
|
||||||
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('uses each given at most twice over a full 10-task run', () => {
|
it('avoids reusing the same given when possible', () => {
|
||||||
// Ein Spieldurchlauf zieht ~10 Aufgaben; der kleinste Pool (Zielzahl 4) hat 5.
|
const target: Target = 7;
|
||||||
for (const target of TARGETS) {
|
const prev = generateTask(target);
|
||||||
for (let trial = 0; trial < 50; trial++) {
|
let differed = 0;
|
||||||
const seq = drawSequence(target, 10);
|
for (let i = 0; i < 50; i++) {
|
||||||
const counts = new Map<number, number>();
|
const t = generateTask(target, prev);
|
||||||
for (const g of seq) counts.set(g, (counts.get(g) ?? 0) + 1);
|
if (t.given !== prev.given) differed++;
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
expect(differed).toBe(50);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,12 @@
|
||||||
// Zahlzerlegungs-Aufgabengenerator.
|
// Zahlzerlegungs-Aufgabengenerator.
|
||||||
//
|
//
|
||||||
// Eine Aufgabe für Zielzahl T besteht aus:
|
// 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)
|
// 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
|
// Für sehr kleine Zielzahlen (T=4) gibt es nur 3 mögliche Antworten gesamt,
|
||||||
// Vorgaben ohne Zurücklegen, mischt nach dem Leeren neu und verhindert direkte
|
// daher ggf. nur 2 oder 3 Choices.
|
||||||
// Wiederholungen. So kommt nie 2x dieselbe Vorgabe hintereinander und die
|
|
||||||
// Häufigkeiten bleiben über den Durchlauf maximal gleichmäßig.
|
|
||||||
|
|
||||||
export type Target = 4 | 5 | 6 | 7 | 8 | 9 | 10;
|
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];
|
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[] {
|
function shuffle<T>(arr: T[]): T[] {
|
||||||
const a = arr.slice();
|
const a = arr.slice();
|
||||||
|
|
@ -32,46 +34,26 @@ function shuffle<T>(arr: T[]): T[] {
|
||||||
return a;
|
return a;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Alle möglichen Vorgabe-Zahlen für eine Zielzahl: 0..target.
|
export function generateTask(target: Target, prev?: Task): Task {
|
||||||
export function givenPool(target: Target): number[] {
|
// Mögliche given-Werte: 1..target-1.
|
||||||
const pool: number[] = [];
|
const candidates: number[] = [];
|
||||||
for (let i = 0; i <= target; i++) pool.push(i);
|
for (let i = 1; i < target; i++) candidates.push(i);
|
||||||
return pool;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Baut die Aufgabe zu einer konkreten Vorgabe (richtige Antwort + 2 zufällige Distraktoren).
|
// Vermeide identisches given wie zuletzt, wenn Auswahl groß genug.
|
||||||
export function buildTask(target: Target, given: number): Task {
|
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 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 numDistractors = Math.min(DESIRED_CHOICES - 1, distractorPool.length);
|
||||||
const distractors = shuffle(distractorPool).slice(0, numDistractors);
|
const distractors = shuffle(distractorPool).slice(0, numDistractors);
|
||||||
|
|
||||||
const choices = shuffle([answer, ...distractors]);
|
const choices = shuffle([answer, ...distractors]);
|
||||||
return { target, given, answer, choices };
|
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 } };
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
import type { Target } from '../game/tasks';
|
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 { goHome, goResult } from '../stores/route';
|
||||||
import { play } from '../audio/soundManager';
|
import { play } from '../audio/soundManager';
|
||||||
import HeightTrack from '../components/game/HeightTrack.svelte';
|
import HeightTrack from '../components/game/HeightTrack.svelte';
|
||||||
|
|
@ -10,7 +10,6 @@
|
||||||
import Timer from '../components/game/Timer.svelte';
|
import Timer from '../components/game/Timer.svelte';
|
||||||
import BurstFx from '../components/game/BurstFx.svelte';
|
import BurstFx from '../components/game/BurstFx.svelte';
|
||||||
import IconButton from '../components/shared/IconButton.svelte';
|
import IconButton from '../components/shared/IconButton.svelte';
|
||||||
import Star from '../components/svg/Star.svelte';
|
|
||||||
|
|
||||||
type Props = { target: Target };
|
type Props = { target: Target };
|
||||||
let { target }: Props = $props();
|
let { target }: Props = $props();
|
||||||
|
|
@ -62,17 +61,17 @@
|
||||||
|
|
||||||
<div class="screen" in:fade={{ duration: 220 }}>
|
<div class="screen" in:fade={{ duration: 220 }}>
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<Timer secondsLeft={$timeLeftSeconds} totalSeconds={Math.max(1, Math.round($game.totalMs / 1000))} />
|
<IconButton label="Zurück" onClick={handleAbort}>
|
||||||
<IconButton label="Startseite" onClick={handleAbort}>
|
|
||||||
<svg width="28" height="28" viewBox="0 0 28 28" aria-hidden="true">
|
<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>
|
</svg>
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
<Timer secondsLeft={$timeLeftSeconds} totalSeconds={Math.max(1, Math.round($game.totalMs / 1000))} />
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="layout">
|
<div class="layout">
|
||||||
<aside class="track-wrap">
|
<aside class="track-wrap">
|
||||||
<HeightTrack correct={$game.correctCount} won={$game.status === 'won'} />
|
<HeightTrack stage={$stage} won={$game.status === 'won'} />
|
||||||
<BurstFx triggerKey={$game.stageBumpKey} />
|
<BurstFx triggerKey={$game.stageBumpKey} />
|
||||||
</aside>
|
</aside>
|
||||||
<main class="play">
|
<main class="play">
|
||||||
|
|
@ -85,7 +84,7 @@
|
||||||
/>
|
/>
|
||||||
{:else if $game.status === 'won'}
|
{:else if $game.status === 'won'}
|
||||||
<div class="end-message">
|
<div class="end-message">
|
||||||
<span class="big"><Star size={200} /></span>
|
<span class="big">🌈</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
|
|
@ -102,8 +101,7 @@
|
||||||
}
|
}
|
||||||
.topbar {
|
.topbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: space-between;
|
||||||
gap: 12px;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
.layout {
|
.layout {
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@
|
||||||
import { play, unlockAudio } from '../audio/soundManager';
|
import { play, unlockAudio } from '../audio/soundManager';
|
||||||
import NumberCard from '../components/home/NumberCard.svelte';
|
import NumberCard from '../components/home/NumberCard.svelte';
|
||||||
import SoundToggle from '../components/shared/SoundToggle.svelte';
|
import SoundToggle from '../components/shared/SoundToggle.svelte';
|
||||||
import FullscreenToggle from '../components/shared/FullscreenToggle.svelte';
|
|
||||||
import IconButton from '../components/shared/IconButton.svelte';
|
import IconButton from '../components/shared/IconButton.svelte';
|
||||||
|
|
||||||
function handlePick(t: Target) {
|
function handlePick(t: Target) {
|
||||||
|
|
@ -19,10 +18,8 @@
|
||||||
|
|
||||||
<div class="screen" in:fade={{ duration: 220 }}>
|
<div class="screen" in:fade={{ duration: 220 }}>
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<h1 class="visually-hidden">Zahlzerlegung</h1>
|
|
||||||
<div class="actions">
|
|
||||||
<SoundToggle />
|
<SoundToggle />
|
||||||
<FullscreenToggle />
|
<h1 class="visually-hidden">Zahlzerlegung</h1>
|
||||||
<IconButton label="Einstellungen" onClick={goSettings}>
|
<IconButton label="Einstellungen" onClick={goSettings}>
|
||||||
<svg width="34" height="34" viewBox="0 0 32 32" aria-hidden="true">
|
<svg width="34" height="34" viewBox="0 0 32 32" aria-hidden="true">
|
||||||
<path
|
<path
|
||||||
|
|
@ -32,12 +29,11 @@
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="cards-grid">
|
<div class="cards-grid">
|
||||||
{#each TARGETS as target (target)}
|
{#each TARGETS as target (target)}
|
||||||
<div class="cell"><NumberCard {target} onClick={handlePick} /></div>
|
<NumberCard {target} onClick={handlePick} />
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -52,12 +48,7 @@
|
||||||
}
|
}
|
||||||
.topbar {
|
.topbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: space-between;
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
.actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
.visually-hidden {
|
.visually-hidden {
|
||||||
|
|
@ -72,14 +63,12 @@
|
||||||
}
|
}
|
||||||
.cards-grid {
|
.cards-grid {
|
||||||
/* Karten haben aspect-ratio 3/4. Bei 7 Karten ergeben sich je nach Spaltenzahl
|
/* 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
|
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.
|
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. */
|
|
||||||
--gap: 14px;
|
--gap: 14px;
|
||||||
--avail-h: calc(100dvh - 130px);
|
--avail-h: calc(100dvh - 130px);
|
||||||
--cols: 4;
|
display: grid;
|
||||||
display: flex;
|
grid-template-columns: repeat(4, 1fr);
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: var(--gap);
|
gap: var(--gap);
|
||||||
align-content: center;
|
align-content: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
@ -89,20 +78,16 @@
|
||||||
margin-inline: auto;
|
margin-inline: auto;
|
||||||
padding-bottom: 10px;
|
padding-bottom: 10px;
|
||||||
}
|
}
|
||||||
.cell {
|
|
||||||
flex: 0 0 calc((100% - (var(--cols) - 1) * var(--gap)) / var(--cols));
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.cards-grid {
|
.cards-grid {
|
||||||
--cols: 3;
|
grid-template-columns: repeat(3, 1fr);
|
||||||
/* 3 Spalten, 3 Reihen */
|
/* 3 Spalten, 3 Reihen */
|
||||||
max-width: calc(var(--avail-h) * 0.75 + var(--gap) * 0.5);
|
max-width: calc(var(--avail-h) * 0.75 + var(--gap) * 0.5);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@media (max-width: 380px) {
|
@media (max-width: 380px) {
|
||||||
.cards-grid {
|
.cards-grid {
|
||||||
--cols: 2;
|
grid-template-columns: repeat(2, 1fr);
|
||||||
/* 2 Spalten, 4 Reihen */
|
/* 2 Spalten, 4 Reihen */
|
||||||
max-width: calc(var(--avail-h) * 0.375 - var(--gap) * 0.125);
|
max-width: calc(var(--avail-h) * 0.375 - var(--gap) * 0.125);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,21 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { fade, fly } from 'svelte/transition';
|
import { fade, fly } from 'svelte/transition';
|
||||||
import type { Target } from '../game/tasks';
|
import type { Target } from '../game/tasks';
|
||||||
import { lastRun } from '../stores/progress';
|
import { progress } from '../stores/progress';
|
||||||
import { startGame } from '../stores/game';
|
import { startGame } from '../stores/game';
|
||||||
import { goHome, goGame } from '../stores/route';
|
import { goHome, goGame } from '../stores/route';
|
||||||
import { play, unlockAudio } from '../audio/soundManager';
|
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 HighscoreColumn from '../components/home/HighscoreColumn.svelte';
|
||||||
import StageAmbience from '../components/home/StageAmbience.svelte';
|
|
||||||
|
|
||||||
type Props = { target: Target };
|
type Props = { target: Target };
|
||||||
let { target }: Props = $props();
|
let { target }: Props = $props();
|
||||||
|
|
||||||
// Genau der gerade gespielte Lauf (kann auch ein nicht-bester Versuch sein).
|
// Aktueller Run = top-Eintrag mit dem letzten Datum
|
||||||
const current = $derived($lastRun?.target === target ? $lastRun : null);
|
const lastRunDate = $derived($progress.perTarget[target]?.top5[0]?.date ?? null);
|
||||||
const lastRunDate = $derived(current?.date ?? null);
|
const lastStage = $derived(($progress.perTarget[target]?.top5[0]?.stage ?? 0) as number);
|
||||||
const lastStage = $derived(current?.stage ?? 0);
|
const isWin = $derived(lastStage >= 4);
|
||||||
|
|
||||||
function retry() {
|
function retry() {
|
||||||
unlockAudio();
|
unlockAudio();
|
||||||
|
|
@ -30,12 +31,26 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="screen" in:fade={{ duration: 240 }}>
|
<div class="screen" in:fade={{ duration: 240 }}>
|
||||||
|
<header>
|
||||||
|
<h2 class="target-label">
|
||||||
|
<span class="num">{target}</span>
|
||||||
|
</h2>
|
||||||
|
</header>
|
||||||
|
|
||||||
<div class="celebration">
|
<div class="celebration">
|
||||||
<StageAmbience stage={lastStage} />
|
{#if isWin}
|
||||||
<div class="scores" in:fly={{ y: 40, duration: 500 }}>
|
<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} />
|
<HighscoreColumn {target} highlightDate={lastRunDate} />
|
||||||
</div>
|
</aside>
|
||||||
</div>
|
|
||||||
|
|
||||||
<footer class="actions">
|
<footer class="actions">
|
||||||
<button class="action retry" type="button" onclick={retry} aria-label="Nochmal">
|
<button class="action retry" type="button" onclick={retry} aria-label="Nochmal">
|
||||||
|
|
@ -56,23 +71,43 @@
|
||||||
.screen {
|
.screen {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: grid;
|
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;
|
padding: 16px;
|
||||||
gap: 12px;
|
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 {
|
.celebration {
|
||||||
|
grid-area: celebration;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
position: relative;
|
position: relative;
|
||||||
min-height: 0;
|
}
|
||||||
|
.rocket-wrap { z-index: 2; }
|
||||||
|
.rainbow-wrap {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 12%;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
.scores {
|
.scores {
|
||||||
position: relative;
|
grid-area: scores;
|
||||||
z-index: 2;
|
width: 110px;
|
||||||
width: 120px;
|
height: 100%;
|
||||||
height: min(82%, 460px);
|
|
||||||
}
|
}
|
||||||
.actions {
|
.actions {
|
||||||
|
grid-area: actions;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,16 @@
|
||||||
<script lang="ts">
|
<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 { settings, ROUND_SECONDS_OPTIONS, setRoundSeconds, toggleSound } from '../stores/settings';
|
||||||
import { resetProgress } from '../stores/progress';
|
|
||||||
import { goHome } from '../stores/route';
|
import { goHome } from '../stores/route';
|
||||||
import { play } from '../audio/soundManager';
|
import { play } from '../audio/soundManager';
|
||||||
import IconButton from '../components/shared/IconButton.svelte';
|
import IconButton from '../components/shared/IconButton.svelte';
|
||||||
|
|
||||||
let confirming = $state(false);
|
|
||||||
|
|
||||||
function doReset() {
|
|
||||||
play('tap');
|
|
||||||
resetProgress();
|
|
||||||
confirming = false;
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="screen" in:fade={{ duration: 220 }}>
|
<div class="screen" in:fade={{ duration: 220 }}>
|
||||||
<header>
|
<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">
|
<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>
|
</svg>
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</header>
|
</header>
|
||||||
|
|
@ -75,112 +66,17 @@
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
||||||
<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>
|
<style>
|
||||||
.screen {
|
.screen {
|
||||||
position: relative;
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: auto 1fr 1fr 1fr;
|
grid-template-rows: auto 1fr 1fr;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
gap: 18px;
|
gap: 18px;
|
||||||
color: var(--c-text-on-dark);
|
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 {
|
.setting {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 80px 1fr;
|
grid-template-columns: 80px 1fr;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { get, writable, derived } from 'svelte/store';
|
import { get, writable, derived } from 'svelte/store';
|
||||||
import { createDeck, drawTask, type Target, type Task, type TaskDeck } from '../game/tasks';
|
import { generateTask, type Target, type Task } from '../game/tasks';
|
||||||
import { isWin, stageFor } from '../game/stages';
|
import { isWin, stageFor, type Stage } from '../game/stages';
|
||||||
import { settings } from './settings';
|
import { settings } from './settings';
|
||||||
import { recordResult } from './progress';
|
import { recordResult } from './progress';
|
||||||
|
|
||||||
|
|
@ -16,7 +16,6 @@ export type GameState = {
|
||||||
totalMs: number;
|
totalMs: number;
|
||||||
lastWasCorrect: boolean | null;
|
lastWasCorrect: boolean | null;
|
||||||
stageBumpKey: number; // monoton wachsend → triggert FX-Animation
|
stageBumpKey: number; // monoton wachsend → triggert FX-Animation
|
||||||
deck: TaskDeck | null; // Ziehbeutel für abwechslungsreiche Aufgabenfolge
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const initial: GameState = {
|
const initial: GameState = {
|
||||||
|
|
@ -29,11 +28,11 @@ const initial: GameState = {
|
||||||
totalMs: 0,
|
totalMs: 0,
|
||||||
lastWasCorrect: null,
|
lastWasCorrect: null,
|
||||||
stageBumpKey: 0,
|
stageBumpKey: 0,
|
||||||
deck: null,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const game = writable<GameState>(initial);
|
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)));
|
export const timeLeftSeconds = derived(game, ($g) => Math.max(0, Math.ceil($g.timeLeftMs / 1000)));
|
||||||
|
|
||||||
let tickHandle: number | null = null;
|
let tickHandle: number | null = null;
|
||||||
|
|
@ -71,15 +70,13 @@ function tick() {
|
||||||
function finalize() {
|
function finalize() {
|
||||||
const g = get(game);
|
const g = get(game);
|
||||||
if (g.target === null) return;
|
if (g.target === null) return;
|
||||||
// Gebrauchte Zeit = verstrichene Zeit bis zum Ende (bei Sieg = Lösezeit für alle 10).
|
recordResult(g.target, stageFor(g.correctCount));
|
||||||
const elapsedMs = Math.max(0, g.totalMs - g.timeLeftMs);
|
|
||||||
recordResult(g.target, stageFor(g.correctCount), elapsedMs);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function startGame(target: Target): void {
|
export function startGame(target: Target): void {
|
||||||
clearTick();
|
clearTick();
|
||||||
const totalMs = get(settings).roundSeconds * 1000;
|
const totalMs = get(settings).roundSeconds * 1000;
|
||||||
const { task: firstTask, deck } = drawTask(createDeck(target));
|
const firstTask = generateTask(target);
|
||||||
game.set({
|
game.set({
|
||||||
status: 'running',
|
status: 'running',
|
||||||
target,
|
target,
|
||||||
|
|
@ -90,7 +87,6 @@ export function startGame(target: Target): void {
|
||||||
totalMs,
|
totalMs,
|
||||||
lastWasCorrect: null,
|
lastWasCorrect: null,
|
||||||
stageBumpKey: 0,
|
stageBumpKey: 0,
|
||||||
deck,
|
|
||||||
});
|
});
|
||||||
lastTickAt = performance.now();
|
lastTickAt = performance.now();
|
||||||
tickHandle = requestAnimationFrame(tick);
|
tickHandle = requestAnimationFrame(tick);
|
||||||
|
|
@ -117,13 +113,12 @@ export function answer(value: number): void {
|
||||||
finalize();
|
finalize();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { task: nextTask, deck } = drawTask(g.deck ?? createDeck(g.target));
|
const nextTask = generateTask(g.target, g.task);
|
||||||
game.update((s) => ({
|
game.update((s) => ({
|
||||||
...s,
|
...s,
|
||||||
correctCount: nextCount,
|
correctCount: nextCount,
|
||||||
prevTask: s.task,
|
prevTask: s.task,
|
||||||
task: nextTask,
|
task: nextTask,
|
||||||
deck,
|
|
||||||
lastWasCorrect: true,
|
lastWasCorrect: true,
|
||||||
stageBumpKey: stageBump ? s.stageBumpKey + 1 : s.stageBumpKey,
|
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 {
|
export function abortGame(): void {
|
||||||
clearTick();
|
clearTick();
|
||||||
game.set(initial);
|
game.set(initial);
|
||||||
|
|
|
||||||
|
|
@ -13,22 +13,10 @@ const initial: Progress = typeof window === 'undefined' ? emptyProgress() : load
|
||||||
|
|
||||||
export const progress = writable<Progress>(initial);
|
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) => {
|
progress.subscribe((p) => {
|
||||||
if (typeof window !== 'undefined') saveProgress(p);
|
if (typeof window !== 'undefined') saveProgress(p);
|
||||||
});
|
});
|
||||||
|
|
||||||
export function recordResult(target: Target, stage: Stage, timeMs?: number): void {
|
export function recordResult(target: Target, stage: Stage): void {
|
||||||
const date = new Date().toISOString();
|
progress.update((p) => recordRun(p, target, stage));
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { writable } from 'svelte/store';
|
import { writable } from 'svelte/store';
|
||||||
import { loadSettings, saveSettings, type Settings } from '../game/persistence';
|
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 =
|
const initial: Settings =
|
||||||
typeof window === 'undefined'
|
typeof window === 'undefined'
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user