- Rename all German identifiers, types, mode ids, file names, CSS classes and data-attributes to English throughout tippen/src; only user-facing text (lesson titles, word lists, labels, spoken praise) stays German. - Add a word/nonsense-word list to the "Übung: die Grundstellung" home-row lesson in the curriculum. - Remove the unused HandHint component and speech.ts, and carry forward the in-progress App.tsx/component/generator/progress edits from other sessions. - Refresh the regenerated music-library cache index. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
249 lines
7.9 KiB
TypeScript
249 lines
7.9 KiB
TypeScript
/** What a run earned.
|
|
*
|
|
* The rule this screen exists to enforce: there is no losing screen. A bad run shows
|
|
* fewer stars and a slower animal, and the primary button still says "Nochmal" with the
|
|
* focus already on it. Nothing here ever says "failed", nothing is red, and the pearls
|
|
* always go up - see `pearlsFor` in lib/grading.ts. */
|
|
|
|
import { useEffect, useRef } from "react";
|
|
|
|
import { creatureById } from "../lib/aquarium";
|
|
import type { CreatureId } from "../lib/aquarium";
|
|
import type { RunResult } from "../lib/grading";
|
|
import { STAR_THRESHOLDS, visibleAnimals, animalById, animalIndex, animalProgress } from "../lib/grading";
|
|
import type { AnimalId } from "../lib/grading";
|
|
|
|
interface Props {
|
|
result: RunResult;
|
|
/** Set when this run opened the next lesson. */
|
|
unlockedTitle: string | null;
|
|
/** Set when this run released a creature into the aquarium. */
|
|
newCreature: CreatureId | null;
|
|
isNewBest: boolean;
|
|
/** The fastest animal earned on any lesson so far - decides how much of the ladder
|
|
* may be revealed. */
|
|
bestEver: AnimalId | null;
|
|
onRetry: () => void;
|
|
onContinue: () => void;
|
|
/** Null at the end of the curriculum. */
|
|
continueLabel: string | null;
|
|
}
|
|
|
|
export function ResultSheet({
|
|
result,
|
|
unlockedTitle,
|
|
newCreature,
|
|
isNewBest,
|
|
bestEver,
|
|
onRetry,
|
|
onContinue,
|
|
continueLabel,
|
|
}: Props) {
|
|
const animal = animalById(result.animal);
|
|
const ladder = visibleAnimals(result.animal, bestEver);
|
|
const reached = animalIndex(result.animal);
|
|
const retryButton = useRef<HTMLButtonElement>(null);
|
|
|
|
// Enter repeats the run. Fewest keystrokes between "that was fun" and "again".
|
|
useEffect(() => retryButton.current?.focus(), []);
|
|
|
|
return (
|
|
<div className="overlay backdrop-enter">
|
|
<div
|
|
className="sheet sheet-enter"
|
|
style={{ padding: "30px 38px 28px", width: "min(520px, 100%)", textAlign: "center" }}
|
|
>
|
|
<div style={{ fontSize: 88, lineHeight: 1, animation: "tierEnter 520ms ease-out" }}>
|
|
{animal.emoji}
|
|
</div>
|
|
<div style={{ fontSize: 30, fontWeight: 900, color: "var(--ink)", marginTop: 6 }}>
|
|
{animal.name}
|
|
</div>
|
|
|
|
<div style={{ display: "flex", justifyContent: "center", gap: 8, margin: "14px 0 4px" }}>
|
|
{[1, 2, 3].map((star) => (
|
|
<span
|
|
key={star}
|
|
style={{
|
|
fontSize: 40,
|
|
animation: `sterneEnter 320ms ease-out ${140 + star * 130}ms both`,
|
|
filter: result.stars >= star ? "none" : "grayscale(1)",
|
|
opacity: result.stars >= star ? 1 : 0.25,
|
|
}}
|
|
>
|
|
⭐
|
|
</span>
|
|
))}
|
|
</div>
|
|
|
|
<div style={{ display: "flex", justifyContent: "center", gap: 26, marginTop: 12 }}>
|
|
<Stat label="Richtig" value={`${Math.round(result.accuracy * 100)}%`} />
|
|
<Stat label="Zeichen/Min" value={String(Math.round(result.speed))} />
|
|
<Stat label="Perlen" value={`+${result.pearls}`} />
|
|
</div>
|
|
|
|
{/* How close the next animal is. A near miss is the strongest reason to press
|
|
Nochmal, so it is worth showing explicitly. */}
|
|
<div
|
|
style={{
|
|
height: 9,
|
|
borderRadius: 999,
|
|
background: "oklch(88% 0.02 175)",
|
|
margin: "18px 0 6px",
|
|
overflow: "hidden",
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
width: `${animalProgress(result.points) * 100}%`,
|
|
height: "100%",
|
|
background: "var(--accent)",
|
|
transition: "width 700ms ease-out",
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
<AnimalLadder animals={ladder.animals} reached={reached} moreHidden={ladder.moreHidden} />
|
|
|
|
{isNewBest && (
|
|
<div style={{ color: "var(--accent)", fontWeight: 900, marginTop: 8 }}>
|
|
🏅 Neuer Bestwert!
|
|
</div>
|
|
)}
|
|
{unlockedTitle && (
|
|
<div style={{ color: "var(--ink)", fontWeight: 900, marginTop: 8 }}>
|
|
🔓 Neu: {unlockedTitle}
|
|
</div>
|
|
)}
|
|
{newCreature && (
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
gap: 10,
|
|
color: "var(--ink)",
|
|
fontWeight: 900,
|
|
marginTop: 8,
|
|
}}
|
|
>
|
|
<img
|
|
src={creatureById(newCreature).image}
|
|
alt=""
|
|
style={{ width: 52, height: 52, objectFit: "contain", animation: "tierEnter 520ms ease-out" }}
|
|
/>
|
|
{creatureById(newCreature).article} {creatureById(newCreature).name} ist ins Aquarium gezogen!
|
|
</div>
|
|
)}
|
|
{!result.passed && !unlockedTitle && (
|
|
<div style={{ color: "var(--ink)", opacity: 0.75, fontWeight: 700, marginTop: 10 }}>
|
|
Mit {Math.round(STAR_THRESHOLDS.two * 100)}% Treffern geht es weiter. Fast!
|
|
</div>
|
|
)}
|
|
|
|
<div style={{ display: "flex", gap: 12, marginTop: 22, justifyContent: "center" }}>
|
|
<button
|
|
ref={retryButton}
|
|
onClick={onRetry}
|
|
style={{
|
|
...buttonStyle,
|
|
background: "var(--accent)",
|
|
color: "var(--paper)",
|
|
}}
|
|
>
|
|
Nochmal ⏎
|
|
</button>
|
|
{continueLabel && (
|
|
<button onClick={onContinue} style={{ ...buttonStyle, background: "oklch(90% 0.02 175)", color: "var(--ink)" }}>
|
|
{continueLabel}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** The animal ladder.
|
|
*
|
|
* Everything she has already passed stays in full colour - the run is a climb, and the
|
|
* rungs below are what shows how far she has come. The rungs above are greyed out but
|
|
* still legible, because the next animal has to be visible to be worth aiming at.
|
|
*
|
|
* Above the dolphin the ladder stops: those animals are not shown at all until they are
|
|
* reached. A single "?" says the ladder continues without saying what is on it, which
|
|
* keeps the top end a surprise rather than a distant, discouraging number. */
|
|
function AnimalLadder({
|
|
animals,
|
|
reached,
|
|
moreHidden,
|
|
}: {
|
|
animals: readonly { id: string; name: string; emoji: string }[];
|
|
reached: number;
|
|
moreHidden: boolean;
|
|
}) {
|
|
return (
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
justifyContent: "center",
|
|
alignItems: "flex-end",
|
|
gap: 4,
|
|
flexWrap: "wrap",
|
|
marginTop: 14,
|
|
minHeight: 44,
|
|
}}
|
|
aria-label="Tier-Leiter"
|
|
>
|
|
{animals.map((entry, i) => {
|
|
const achieved = i <= reached;
|
|
const isCurrent = i === reached;
|
|
return (
|
|
<div
|
|
key={entry.id}
|
|
title={entry.name}
|
|
style={{
|
|
fontSize: isCurrent ? 40 : 26,
|
|
lineHeight: 1,
|
|
padding: isCurrent ? "0 5px" : 0,
|
|
filter: achieved ? "none" : "grayscale(1)",
|
|
// Bright enough to read as "this is next", dim enough to read as "not yet".
|
|
opacity: achieved ? 1 : 0.42,
|
|
transform: isCurrent ? "translateY(-3px)" : undefined,
|
|
transition: "all 200ms ease",
|
|
}}
|
|
>
|
|
{entry.emoji}
|
|
</div>
|
|
);
|
|
})}
|
|
{moreHidden && (
|
|
<div
|
|
title="Da geht noch was!"
|
|
style={{ fontSize: 24, opacity: 0.4, marginLeft: 4, filter: "grayscale(1)" }}
|
|
>
|
|
❓
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const buttonStyle: React.CSSProperties = {
|
|
border: "none",
|
|
borderRadius: 999,
|
|
padding: "13px 26px",
|
|
fontSize: 17,
|
|
fontWeight: 900,
|
|
cursor: "pointer",
|
|
};
|
|
|
|
function Stat({ label, value }: { label: string; value: string }) {
|
|
return (
|
|
<div>
|
|
<div style={{ fontSize: 25, fontWeight: 900, color: "var(--ink)" }}>{value}</div>
|
|
<div style={{ fontSize: 12, fontWeight: 800, color: "var(--ink)", opacity: 0.6 }}>{label}</div>
|
|
</div>
|
|
);
|
|
}
|