First implementation of 10 finger typing

This commit is contained in:
2026-09-11 21:55:45 +02:00
parent 7e5fd5ab75
commit f97de193d8
63 changed files with 8275 additions and 0 deletions

View File

@@ -0,0 +1,248 @@
/** 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 Perlen
* always go up - see `perlenFor` in lib/grading.ts. */
import { useEffect, useRef } from "react";
import { kreaturById } from "../lib/aquarium";
import type { KreaturId } from "../lib/aquarium";
import type { RunResult } from "../lib/grading";
import { STERNE_SCHWELLEN, sichtbareTiers, tierById, tierIndex, tierProgress } from "../lib/grading";
import type { TierId } from "../lib/grading";
interface Props {
result: RunResult;
/** Set when this run opened the next lesson. */
unlockedTitel: string | null;
/** Set when this run released a creature into the aquarium. */
neuesTier: KreaturId | null;
bestseit: boolean;
/** The fastest animal earned on any lesson so far - decides how much of the ladder
* may be revealed. */
bestEver: TierId | null;
onNochmal: () => void;
onWeiter: () => void;
/** Null at the end of the curriculum. */
weiterLabel: string | null;
}
export function ResultSheet({
result,
unlockedTitel,
neuesTier,
bestseit,
bestEver,
onNochmal,
onWeiter,
weiterLabel,
}: Props) {
const tier = tierById(result.tier);
const leiter = sichtbareTiers(result.tier, bestEver);
const erreicht = tierIndex(result.tier);
const nochmal = useRef<HTMLButtonElement>(null);
// Enter repeats the run. Fewest keystrokes between "that was fun" and "again".
useEffect(() => nochmal.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" }}>
{tier.emoji}
</div>
<div style={{ fontSize: 30, fontWeight: 900, color: "var(--ink)", marginTop: 6 }}>
{tier.name}
</div>
<div style={{ display: "flex", justifyContent: "center", gap: 8, margin: "14px 0 4px" }}>
{[1, 2, 3].map((stern) => (
<span
key={stern}
style={{
fontSize: 40,
animation: `sterneEnter 320ms ease-out ${140 + stern * 130}ms both`,
filter: result.sterne >= stern ? "none" : "grayscale(1)",
opacity: result.sterne >= stern ? 1 : 0.25,
}}
>
</span>
))}
</div>
<div style={{ display: "flex", justifyContent: "center", gap: 26, marginTop: 12 }}>
<Stat label="Richtig" value={`${Math.round(result.genauigkeit * 100)}%`} />
<Stat label="Zeichen/Min" value={String(Math.round(result.tempo))} />
<Stat label="Perlen" value={`+${result.perlen}`} />
</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: `${tierProgress(result.punkte) * 100}%`,
height: "100%",
background: "var(--accent)",
transition: "width 700ms ease-out",
}}
/>
</div>
<TierLeiter tiers={leiter.tiers} erreicht={erreicht} mehrVerborgen={leiter.mehrVerborgen} />
{bestseit && (
<div style={{ color: "var(--accent)", fontWeight: 900, marginTop: 8 }}>
🏅 Neuer Bestwert!
</div>
)}
{unlockedTitel && (
<div style={{ color: "var(--ink)", fontWeight: 900, marginTop: 8 }}>
🔓 Neu: {unlockedTitel}
</div>
)}
{neuesTier && (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 10,
color: "var(--ink)",
fontWeight: 900,
marginTop: 8,
}}
>
<img
src={kreaturById(neuesTier).bild}
alt=""
style={{ width: 52, height: 52, objectFit: "contain", animation: "tierEnter 520ms ease-out" }}
/>
{kreaturById(neuesTier).artikel} {kreaturById(neuesTier).name} ist ins Aquarium gezogen!
</div>
)}
{!result.bestanden && !unlockedTitel && (
<div style={{ color: "var(--ink)", opacity: 0.75, fontWeight: 700, marginTop: 10 }}>
Mit {Math.round(STERNE_SCHWELLEN.zwei * 100)}% Treffern geht es weiter. Fast!
</div>
)}
<div style={{ display: "flex", gap: 12, marginTop: 22, justifyContent: "center" }}>
<button
ref={nochmal}
onClick={onNochmal}
style={{
...knopf,
background: "var(--accent)",
color: "var(--paper)",
}}
>
Nochmal
</button>
{weiterLabel && (
<button onClick={onWeiter} style={{ ...knopf, background: "oklch(90% 0.02 175)", color: "var(--ink)" }}>
{weiterLabel}
</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 Delfin 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 TierLeiter({
tiers,
erreicht,
mehrVerborgen,
}: {
tiers: readonly { id: string; name: string; emoji: string }[];
erreicht: number;
mehrVerborgen: boolean;
}) {
return (
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "flex-end",
gap: 4,
flexWrap: "wrap",
marginTop: 14,
minHeight: 44,
}}
aria-label="Tier-Leiter"
>
{tiers.map((eintrag, i) => {
const geschafft = i <= erreicht;
const aktuell = i === erreicht;
return (
<div
key={eintrag.id}
title={eintrag.name}
style={{
fontSize: aktuell ? 40 : 26,
lineHeight: 1,
padding: aktuell ? "0 5px" : 0,
filter: geschafft ? "none" : "grayscale(1)",
// Bright enough to read as "this is next", dim enough to read as "not yet".
opacity: geschafft ? 1 : 0.42,
transform: aktuell ? "translateY(-3px)" : undefined,
transition: "all 200ms ease",
}}
>
{eintrag.emoji}
</div>
);
})}
{mehrVerborgen && (
<div
title="Da geht noch was!"
style={{ fontSize: 24, opacity: 0.4, marginLeft: 4, filter: "grayscale(1)" }}
>
</div>
)}
</div>
);
}
const knopf: 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>
);
}