Merge the typing game into the music player as a tab, with lock/unlock UI
Moves tippen from a standalone app into web/ as a third tab (audio player / smarthome / typing), replacing the old single room-toggle corner button with a vertical icon tab rail. Curriculum and progress now come from the backend (musicmouse/tippen/*) instead of a build-time YAML import and localStorage. Adds reward-driven lock rendering: Cover/BrowseView/AlbumModal show a question mark for locked albums/tracks with a hint on what unlocks them, and ResultSheet gets a new unlock-animation block alongside the existing lesson-unlock and aquarium-creature celebrations. CSS from the two apps is merged carefully: identical rules (bubble/card/ key-cap/view-enter/backdrop-enter and their keyframes) are shared as-is, while rules that bake in each app's own hue are kept separate under a `tp-` prefix and scoped to the typing tab's own .tp-stage wrapper, so neither app's look bleeds into the other's. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -67,7 +67,13 @@ export function AlbumModal({
|
||||
>
|
||||
<div style={{ display: "flex", gap: 20, alignItems: "flex-start" }}>
|
||||
<div style={{ width: 150, flex: "none" }}>
|
||||
<Cover album={album} size={150} radius={book ? "10px 18px 18px 10px" : "16px"} label />
|
||||
<Cover
|
||||
album={album}
|
||||
size={150}
|
||||
radius={book ? "10px 18px 18px 10px" : "16px"}
|
||||
label
|
||||
locked={album.locked}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
@@ -103,13 +109,14 @@ export function AlbumModal({
|
||||
{album.figure && ` · 🧸 ${album.figure}`}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onPlay(0)}
|
||||
onClick={() => !album.locked && onPlay(0)}
|
||||
disabled={album.locked}
|
||||
style={{
|
||||
marginTop: 14,
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
background: "var(--accent)",
|
||||
color: "#fff",
|
||||
cursor: album.locked ? "default" : "pointer",
|
||||
background: album.locked ? "oklch(80% 0.02 210)" : "var(--accent)",
|
||||
color: album.locked ? "oklch(40% 0.03 210)" : "#fff",
|
||||
fontSize: 16,
|
||||
fontWeight: 800,
|
||||
padding: "12px 22px",
|
||||
@@ -117,18 +124,24 @@ export function AlbumModal({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
boxShadow: "0 4px 14px oklch(70% 0.16 340 / .45)",
|
||||
boxShadow: album.locked ? "none" : "0 4px 14px oklch(70% 0.16 340 / .45)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: 14,
|
||||
height: 16,
|
||||
background: "#fff",
|
||||
clipPath: "polygon(6% 0%, 100% 50%, 6% 100%)",
|
||||
}}
|
||||
/>
|
||||
{book ? "Hörbuch abspielen" : "Alle Songs abspielen"}
|
||||
{album.locked ? (
|
||||
"❓ Noch nicht freigeschaltet"
|
||||
) : (
|
||||
<>
|
||||
<span
|
||||
style={{
|
||||
width: 14,
|
||||
height: 16,
|
||||
background: "#fff",
|
||||
clipPath: "polygon(6% 0%, 100% 50%, 6% 100%)",
|
||||
}}
|
||||
/>
|
||||
{book ? "Hörbuch abspielen" : "Alle Songs abspielen"}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
@@ -158,17 +171,19 @@ export function AlbumModal({
|
||||
<button
|
||||
key={index}
|
||||
data-nav-index={index}
|
||||
onClick={() => onPlay(index)}
|
||||
onClick={() => !track.locked && onPlay(index)}
|
||||
disabled={track.locked}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 14,
|
||||
padding: "10px 14px",
|
||||
borderRadius: 14,
|
||||
cursor: "pointer",
|
||||
cursor: track.locked ? "default" : "pointer",
|
||||
border: "none",
|
||||
font: "inherit",
|
||||
textAlign: "left",
|
||||
opacity: track.locked ? 0.55 : 1,
|
||||
background: current
|
||||
? "oklch(70% 0.16 340 / .16)"
|
||||
: "oklch(30% 0.03 210 / .05)",
|
||||
@@ -190,27 +205,42 @@ export function AlbumModal({
|
||||
color: current ? "#fff" : "oklch(35% 0.03 210)",
|
||||
}}
|
||||
>
|
||||
{index + 1}
|
||||
{track.locked ? "❓" : index + 1}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
fontSize: 16,
|
||||
fontWeight: 800,
|
||||
color: current ? "oklch(45% 0.16 340)" : "oklch(24% 0.03 210)",
|
||||
}}
|
||||
>
|
||||
{track.title}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
font: "700 13px ui-monospace, Menlo, monospace",
|
||||
color: "oklch(45% 0.03 210 / .7)",
|
||||
}}
|
||||
>
|
||||
{clock(track.duration)}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 16,
|
||||
fontWeight: 800,
|
||||
color: current ? "oklch(45% 0.16 340)" : "oklch(24% 0.03 210)",
|
||||
}}
|
||||
>
|
||||
{track.locked ? "❓ Noch gesperrt" : track.title}
|
||||
</div>
|
||||
{track.locked && track.unlock_hint && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
color: "oklch(45% 0.03 210 / .8)",
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
Freigeschaltet nach „{track.unlock_hint.lesson_title}" (Welt{" "}
|
||||
{track.unlock_hint.world_number})
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!track.locked && (
|
||||
<div
|
||||
style={{
|
||||
font: "700 13px ui-monospace, Menlo, monospace",
|
||||
color: "oklch(45% 0.03 210 / .7)",
|
||||
}}
|
||||
>
|
||||
{clock(track.duration)}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -425,6 +425,7 @@ export function BrowseView({
|
||||
// Fixed regardless of how many lines the title actually needs, so
|
||||
// every card - and thus every row of covers - is the same height.
|
||||
const titleLines = showArtist ? 2 : 3;
|
||||
const hint = album.locked ? (album.tracks[0]?.unlock_hint ?? null) : null;
|
||||
return (
|
||||
<div
|
||||
key={album.id}
|
||||
@@ -432,6 +433,7 @@ export function BrowseView({
|
||||
data-nav-index={navIndex}
|
||||
data-selected={selected === navIndex}
|
||||
data-current={album.id === currentAlbumId}
|
||||
data-locked={album.locked}
|
||||
style={{
|
||||
background: cardBackground(album),
|
||||
borderRadius: isBook(album) ? "6px 18px 18px 6px" : "16px",
|
||||
@@ -439,26 +441,34 @@ export function BrowseView({
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => onOpenAlbum(album, navIndex)}
|
||||
aria-label={podcast ? `${album.title} abspielen` : `${album.title}: Titel wählen`}
|
||||
onClick={() => !album.locked && onOpenAlbum(album, navIndex)}
|
||||
disabled={album.locked}
|
||||
aria-label={
|
||||
album.locked
|
||||
? `${album.title}: noch nicht freigeschaltet`
|
||||
: podcast
|
||||
? `${album.title} abspielen`
|
||||
: `${album.title}: Titel wählen`
|
||||
}
|
||||
style={{
|
||||
display: "flex",
|
||||
width: "100%",
|
||||
border: "none",
|
||||
padding: 0,
|
||||
cursor: "pointer",
|
||||
cursor: album.locked ? "default" : "pointer",
|
||||
}}
|
||||
>
|
||||
<Cover album={album} size={180} radius="0" label />
|
||||
<Cover album={album} size={180} radius="0" label locked={album.locked} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onPlayAlbum(album, navIndex)}
|
||||
aria-label={`${album.title} abspielen`}
|
||||
onClick={() => !album.locked && onPlayAlbum(album, navIndex)}
|
||||
disabled={album.locked}
|
||||
aria-label={album.locked ? `${album.title}: noch nicht freigeschaltet` : `${album.title} abspielen`}
|
||||
style={{
|
||||
display: "block",
|
||||
width: "100%",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
cursor: album.locked ? "default" : "pointer",
|
||||
textAlign: "left",
|
||||
font: "inherit",
|
||||
background: "none",
|
||||
@@ -466,47 +476,77 @@ export function BrowseView({
|
||||
paddingRight: isBook(album) ? 22 : 12,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 16,
|
||||
fontWeight: 800,
|
||||
color: "oklch(22% 0.03 210)",
|
||||
lineHeight: 1.2,
|
||||
height: `${titleLines * 1.2}em`,
|
||||
display: "-webkit-box",
|
||||
WebkitLineClamp: titleLines,
|
||||
WebkitBoxOrient: "vertical",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{album.title}
|
||||
</div>
|
||||
{showArtist && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
color: "oklch(30% 0.03 210)",
|
||||
marginTop: 2,
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{album.artist}
|
||||
</div>
|
||||
{album.locked ? (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 16,
|
||||
fontWeight: 800,
|
||||
color: "oklch(22% 0.03 210)",
|
||||
lineHeight: 1.2,
|
||||
}}
|
||||
>
|
||||
❓ Geheimnis
|
||||
</div>
|
||||
{hint && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
color: "oklch(40% 0.03 210)",
|
||||
marginTop: 4,
|
||||
lineHeight: 1.3,
|
||||
}}
|
||||
>
|
||||
Freigeschaltet nach „{hint.lesson_title}" (Welt {hint.world_number})
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 16,
|
||||
fontWeight: 800,
|
||||
color: "oklch(22% 0.03 210)",
|
||||
lineHeight: 1.2,
|
||||
height: `${titleLines * 1.2}em`,
|
||||
display: "-webkit-box",
|
||||
WebkitLineClamp: titleLines,
|
||||
WebkitBoxOrient: "vertical",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{album.title}
|
||||
</div>
|
||||
{showArtist && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
color: "oklch(30% 0.03 210)",
|
||||
marginTop: 2,
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{album.artist}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 800,
|
||||
color: "oklch(40% 0.17 340)",
|
||||
marginTop: 6,
|
||||
}}
|
||||
>
|
||||
{podcast ? clock(album.duration) : unitLabel(album, album.tracks.length)}
|
||||
{album.figure && " · 🧸"}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 800,
|
||||
color: "oklch(40% 0.17 340)",
|
||||
marginTop: 6,
|
||||
}}
|
||||
>
|
||||
{podcast ? clock(album.duration) : unitLabel(album, album.tracks.length)}
|
||||
{album.figure && " · 🧸"}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -26,9 +26,12 @@ interface Props {
|
||||
style?: CSSProperties;
|
||||
/** Print the title over generated art. Off for thumbnails, where it would not fit. */
|
||||
label?: boolean;
|
||||
/** A reward-gated album/track not yet earned - shows a question mark instead of the
|
||||
* real art or title, regardless of `has_cover`. */
|
||||
locked?: boolean;
|
||||
}
|
||||
|
||||
export function Cover({ album, size, fit = "width", radius, className, style, label }: Props) {
|
||||
export function Cover({ album, size, fit = "width", radius, className, style, label, locked }: Props) {
|
||||
const book = isBook(album);
|
||||
const defaultRadius = book ? "6px 18px 18px 6px" : "16px";
|
||||
const box: CSSProperties =
|
||||
@@ -49,7 +52,7 @@ export function Cover({ album, size, fit = "width", radius, className, style, la
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{!album.has_cover && label && (
|
||||
{locked ? (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
@@ -57,35 +60,54 @@ export function Cover({ album, size, fit = "width", radius, className, style, la
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: book ? "10% 14% 10% 20%" : "10%",
|
||||
textAlign: "center",
|
||||
fontSize: Math.max(11, Math.round(size / 11)),
|
||||
fontWeight: 900,
|
||||
lineHeight: 1.15,
|
||||
color: "oklch(99% 0 0 / .92)",
|
||||
fontSize: Math.max(22, Math.round(size / 2.2)),
|
||||
color: "oklch(99% 0 0 / .85)",
|
||||
textShadow: "0 2px 8px oklch(15% 0.05 210 / .6)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{album.title}
|
||||
❓
|
||||
</div>
|
||||
)}
|
||||
{album.has_cover && (
|
||||
<img
|
||||
src={coverUrl(album.id)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
// Books keep a sliver of spine showing on the right, so the shelf metaphor
|
||||
// survives contact with real square artwork.
|
||||
clipPath: book ? "inset(0 5% 0 0)" : undefined,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{!album.has_cover && label && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: book ? "10% 14% 10% 20%" : "10%",
|
||||
textAlign: "center",
|
||||
fontSize: Math.max(11, Math.round(size / 11)),
|
||||
fontWeight: 900,
|
||||
lineHeight: 1.15,
|
||||
color: "oklch(99% 0 0 / .92)",
|
||||
textShadow: "0 2px 8px oklch(15% 0.05 210 / .6)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{album.title}
|
||||
</div>
|
||||
)}
|
||||
{album.has_cover && (
|
||||
<img
|
||||
src={coverUrl(album.id)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
// Books keep a sliver of spine showing on the right, so the shelf
|
||||
// metaphor survives contact with real square artwork.
|
||||
clipPath: book ? "inset(0 5% 0 0)" : undefined,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
65
web/src/components/TabRail.tsx
Normal file
65
web/src/components/TabRail.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
/** The tab rail: icon-only, vertically centered on the right edge - audio player and
|
||||
* typing always shown, the smarthome tab only when Home Assistant is configured, same
|
||||
* gate the old single toggle button used. Replaces that button now that there are
|
||||
* three destinations instead of two. */
|
||||
|
||||
import { PAGE_ICON, PAGE_LABEL } from "../lib/theme";
|
||||
import type { UiState } from "../lib/keyboard";
|
||||
|
||||
interface Props {
|
||||
page: UiState["page"];
|
||||
onSelect: (page: UiState["page"]) => void;
|
||||
showRoom: boolean;
|
||||
}
|
||||
|
||||
const PAGES: readonly UiState["page"][] = ["music", "typing", "room"];
|
||||
|
||||
export function TabRail({ page, onSelect, showRoom }: Props) {
|
||||
const pages = PAGES.filter((candidate) => candidate !== "room" || showRoom);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
right: 24,
|
||||
transform: "translateY(-50%)",
|
||||
zIndex: 3,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
{pages.map((candidate) => {
|
||||
const active = candidate === page;
|
||||
return (
|
||||
<button
|
||||
key={candidate}
|
||||
onClick={() => onSelect(candidate)}
|
||||
aria-label={PAGE_LABEL[candidate]}
|
||||
aria-current={active}
|
||||
title={PAGE_LABEL[candidate]}
|
||||
style={{
|
||||
width: 44,
|
||||
height: 44,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
border: "none",
|
||||
borderRadius: 999,
|
||||
background: active ? "var(--accent)" : "oklch(97% 0.01 210 / .95)",
|
||||
boxShadow: "0 6px 18px oklch(15% 0.05 210 / .4)",
|
||||
fontSize: 20,
|
||||
fontWeight: 900,
|
||||
color: active ? "#fff" : "var(--ink)",
|
||||
cursor: "pointer",
|
||||
transition: "background 0.15s ease, color 0.15s ease",
|
||||
}}
|
||||
>
|
||||
{PAGE_ICON[candidate]}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
372
web/src/components/TippenApp.tsx
Normal file
372
web/src/components/TippenApp.tsx
Normal file
@@ -0,0 +1,372 @@
|
||||
/** The typing game, as a tab of the music player rather than its own app.
|
||||
*
|
||||
* Ported from the standalone tippen app's App.tsx - same shape (one state object, one
|
||||
* keydown listener for navigation, four screens) - with two changes: curriculum and
|
||||
* progress are now fetched from the backend instead of a build-time YAML import and
|
||||
* localStorage (see hooks/useTippenCurriculum.ts and hooks/useTippenProgress.ts), and
|
||||
* `onExit` is the new base case for "back" - Escape/the map's own navigation peel one
|
||||
* layer at a time, same as before, but the aquarium screen is no longer the floor: one
|
||||
* more Escape leaves the tab entirely, back to the music player.
|
||||
*
|
||||
* The rule that still matters most: **while a run is going, every key belongs to the
|
||||
* run**. This component's own keydown listener only ever intercepts Escape and F1 - the
|
||||
* run's own listener lives in hooks/useTippenRun.ts. The music player's own global
|
||||
* keydown listener (lib/keyboard.ts) is kept out of this entirely: it bails out
|
||||
* immediately whenever the typing tab is active, so the two never fight over a key. */
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { useTippenCurriculum } from "../hooks/useTippenCurriculum";
|
||||
import { useTippenProgress } from "../hooks/useTippenProgress";
|
||||
import { Aquarium } from "./tippen/Aquarium";
|
||||
import { AppHeader } from "./tippen/AppHeader";
|
||||
import { HelpOverlay } from "./tippen/HelpOverlay";
|
||||
import { LessonMap } from "./tippen/LessonMap";
|
||||
import { ResultSheet } from "./tippen/ResultSheet";
|
||||
import { Stage } from "./tippen/Stage";
|
||||
import { BubblesRun } from "./tippen/modes/BubblesRun";
|
||||
import { DiveRun } from "./tippen/modes/DiveRun";
|
||||
import { FeedRun } from "./tippen/modes/FeedRun";
|
||||
import { JellyfishRun } from "./tippen/modes/JellyfishRun";
|
||||
import { RaceRun } from "./tippen/modes/RaceRun";
|
||||
import type { CreatureId } from "../lib/tippen/aquarium";
|
||||
import type { Lesson, ModeId } from "../lib/tippen/curriculum";
|
||||
import { lessonById, nextLesson } from "../lib/tippen/curriculum";
|
||||
import { letterStream, lineFor, lineText, mulberry32 } from "../lib/tippen/generator";
|
||||
import type { RunResult } from "../lib/tippen/grading";
|
||||
import { playFanfare, playPop } from "../lib/tippen/pop";
|
||||
import { focusKeyFor, overallBestAnimal } from "../lib/tippen/progress";
|
||||
import type { UnlockedReward } from "../lib/tippen/progress";
|
||||
import { bubbleCountFor } from "../lib/tippen/theme";
|
||||
|
||||
type Screen = "aquarium" | "map" | "run";
|
||||
|
||||
/** What a mode is handed to draw - see the original App.tsx for why there are only two
|
||||
* shapes for five modes. */
|
||||
type RunTarget =
|
||||
| { kind: "letters"; letters: readonly string[] }
|
||||
| { kind: "text"; chunks: readonly string[]; text: string; spaceActive: boolean };
|
||||
|
||||
const LETTER_ONLY_MODES: readonly ModeId[] = ["bubbles", "jellyfish"];
|
||||
|
||||
const SHARE_FOR_EMPHASIS: Record<"isolated" | "mixed", number> = { isolated: 0.75, mixed: 0.4 };
|
||||
|
||||
interface Outcome {
|
||||
result: RunResult;
|
||||
unlockedTitle: string | null;
|
||||
newCreature: CreatureId | null;
|
||||
isNewBest: boolean;
|
||||
unlockedReward: UnlockedReward | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
onExit: () => void;
|
||||
}
|
||||
|
||||
export function TippenApp({ onExit }: Props) {
|
||||
const { curriculum, loading: curriculumLoading } = useTippenCurriculum();
|
||||
const configured = curriculum !== null;
|
||||
const { progress, loading: progressLoading, recordRun, saveSettings } = useTippenProgress(configured);
|
||||
|
||||
const [screen, setScreen] = useState<Screen>("aquarium");
|
||||
const [lessonId, setLessonId] = useState<string | null>(null);
|
||||
const [mode, setMode] = useState<ModeId>("dive");
|
||||
const [outcome, setOutcome] = useState<Outcome | null>(null);
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
const [selected, setSelected] = useState(0);
|
||||
/** Bumped to generate a fresh line - a new seed for the same lesson. */
|
||||
const [round, setRound] = useState(0);
|
||||
|
||||
const lesson = lessonId !== null && curriculum ? lessonById(curriculum, lessonId) : null;
|
||||
|
||||
/** The first lesson that is unlocked but not yet passed - where "Weiter üben" goes. */
|
||||
const nextUp = useMemo(() => {
|
||||
if (!curriculum || !progress) return null;
|
||||
const unlocked = curriculum.lessons.filter((l) => progress.lessons[l.id]?.unlocked);
|
||||
return unlocked.find((l) => (progress.lessons[l.id]?.bestStars ?? 0) < 2) ?? unlocked.at(-1) ?? null;
|
||||
}, [curriculum, progress]);
|
||||
|
||||
/** The line for this run. Reproducible from the lesson, the mode and the round
|
||||
* counter, so a re-render never reshuffles the text mid-run. */
|
||||
const run = useMemo((): RunTarget | null => {
|
||||
if (!lesson || !progress) return null;
|
||||
const seed = lesson.number * 1000 + round * 7 + (mode === "bubbles" ? 3 : 0);
|
||||
const rng = mulberry32(seed);
|
||||
const focusKey = focusKeyFor(progress, lesson.activeKeys);
|
||||
// Always a real space, even before the space-bar lesson formally teaches the thumb:
|
||||
// a gap she can see but not type is confusing, not gentle. See generator.ts.
|
||||
const spaceActive = true;
|
||||
|
||||
if (LETTER_ONLY_MODES.includes(mode)) {
|
||||
const share = lesson.emphasis ? SHARE_FOR_EMPHASIS[lesson.emphasis] : undefined;
|
||||
return {
|
||||
kind: "letters",
|
||||
letters: letterStream(
|
||||
lesson.activeKeys,
|
||||
rng,
|
||||
bubbleCountFor(lesson.world),
|
||||
focusKey,
|
||||
lesson.spotlightKeys,
|
||||
share,
|
||||
),
|
||||
};
|
||||
}
|
||||
const chunks = lineFor(lesson, rng, { chunks: lesson.chunks, chunkSize: lesson.chunkSize, focusKey });
|
||||
return { kind: "text", chunks, text: lineText(chunks, spaceActive), spaceActive };
|
||||
// `progress` is deliberately not a dependency - see the original App.tsx.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [lesson, mode, round]);
|
||||
|
||||
const start = useCallback((lesson: Lesson) => {
|
||||
setLessonId(lesson.id);
|
||||
setMode(lesson.primaryMode);
|
||||
setOutcome(null);
|
||||
setRound((r) => r + 1);
|
||||
setScreen("run");
|
||||
}, []);
|
||||
|
||||
const onFinished = useCallback(
|
||||
(result: RunResult) => {
|
||||
if (!lessonId || !progress) return;
|
||||
void recordRun(lessonId, result).then((recorded) => {
|
||||
setOutcome({
|
||||
result,
|
||||
unlockedTitle: recorded.unlockedLessonTitle,
|
||||
newCreature: recorded.newCreature,
|
||||
isNewBest: recorded.isNewBest,
|
||||
unlockedReward: recorded.unlockedReward,
|
||||
});
|
||||
if (
|
||||
progress.settings.sound &&
|
||||
(recorded.unlockedLessonId || recorded.newCreature || recorded.unlockedReward)
|
||||
) {
|
||||
playFanfare();
|
||||
}
|
||||
});
|
||||
},
|
||||
[lessonId, progress, recordRun],
|
||||
);
|
||||
|
||||
const retry = useCallback(() => {
|
||||
setOutcome(null);
|
||||
setRound((r) => r + 1);
|
||||
}, []);
|
||||
|
||||
/** A bonus replay in a mode this lesson didn't gate progress on - never touches the
|
||||
* unlock: `onFinished` still runs underneath, so a great bonus run can only improve
|
||||
* the best score, not change what is unlocked. */
|
||||
const playBonus = useCallback((bonusMode: ModeId) => {
|
||||
setMode(bonusMode);
|
||||
setOutcome(null);
|
||||
setRound((r) => r + 1);
|
||||
}, []);
|
||||
|
||||
const continueAfterResult = useCallback(() => {
|
||||
const next = lessonId && curriculum ? nextLesson(curriculum, lessonId) : null;
|
||||
setOutcome(null);
|
||||
if (next && progress?.lessons[next.id]?.unlocked) start(next);
|
||||
else setScreen("map");
|
||||
}, [lessonId, curriculum, progress, start]);
|
||||
|
||||
const goBack = useCallback(() => {
|
||||
if (outcome) return setOutcome(null);
|
||||
if (screen === "run") return setScreen("map");
|
||||
if (screen === "map") return setScreen("aquarium");
|
||||
if (screen === "aquarium") return onExit();
|
||||
}, [outcome, screen, onExit]);
|
||||
|
||||
// --- navigation keys -----------------------------------------------------
|
||||
|
||||
const latest = useRef({ screen, outcome, selected, goBack, retry, nextUp, start, curriculum });
|
||||
latest.current = { screen, outcome, selected, goBack, retry, nextUp, start, curriculum };
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
const current = latest.current;
|
||||
|
||||
if (event.key === "F1") {
|
||||
event.preventDefault();
|
||||
setShowHelp((open) => !open);
|
||||
return;
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
setShowHelp(false);
|
||||
current.goBack();
|
||||
return;
|
||||
}
|
||||
|
||||
// Enter repeats a finished run; the result sheet's own button has focus, so this
|
||||
// is only a fallback for when focus has been lost.
|
||||
if (current.outcome) {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
current.retry();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Everything below is navigation, and must not fire while typing.
|
||||
if (current.screen === "run") return;
|
||||
if (!current.curriculum) return;
|
||||
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
if (current.screen === "aquarium") {
|
||||
if (current.nextUp) current.start(current.nextUp);
|
||||
} else {
|
||||
const lesson = current.curriculum.lessons[current.selected];
|
||||
if (lesson) current.start(lesson);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (current.screen !== "map") return;
|
||||
// The map is a vertical path, so "next" is down rather than to the right.
|
||||
const step = event.key === "ArrowDown" ? 1 : event.key === "ArrowUp" ? -1 : 0;
|
||||
if (step !== 0) {
|
||||
event.preventDefault();
|
||||
playPop(340);
|
||||
setSelected((index) => Math.min(current.curriculum!.lessons.length - 1, Math.max(0, index + step)));
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, []);
|
||||
|
||||
// Settings keys live outside a run, where they cannot collide with the alphabet.
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (screen === "run" && !outcome) return;
|
||||
if (!progress) return;
|
||||
const key = event.key.toLowerCase();
|
||||
if (key === "m") void saveSettings({ ...progress.settings, sound: !progress.settings.sound });
|
||||
if (key === "h") {
|
||||
void saveSettings({
|
||||
...progress.settings,
|
||||
keyboardHint: progress.settings.keyboardHint === "off" ? "auto" : "off",
|
||||
});
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [screen, outcome, progress, saveSettings]);
|
||||
|
||||
// --- render --------------------------------------------------------------
|
||||
|
||||
if (curriculumLoading || (configured && progressLoading) || !progress) {
|
||||
return (
|
||||
<Stage creatures={[]} dimmed={false}>
|
||||
<AppHeader />
|
||||
<LoadingScreen text="Einen Moment …" />
|
||||
</Stage>
|
||||
);
|
||||
}
|
||||
|
||||
if (!curriculum) {
|
||||
return (
|
||||
<Stage creatures={[]} dimmed={false}>
|
||||
<AppHeader />
|
||||
<LoadingScreen text="Das Tippen-Spiel ist noch nicht eingerichtet." />
|
||||
</Stage>
|
||||
);
|
||||
}
|
||||
|
||||
const next = lessonId ? nextLesson(curriculum, lessonId) : null;
|
||||
|
||||
// Every mode takes the same bundle; only the drawing differs.
|
||||
const shared = { activeKeys: lesson?.activeKeys ?? [], progress, paused: outcome !== null, onFinished };
|
||||
const letterProps = (letters: readonly string[]) => ({ letters, ...shared });
|
||||
const textProps = (r: Extract<RunTarget, { kind: "text" }>) => ({
|
||||
chunks: r.chunks,
|
||||
text: r.text,
|
||||
spaceActive: r.spaceActive,
|
||||
...shared,
|
||||
});
|
||||
|
||||
return (
|
||||
<Stage creatures={progress.aquarium} dimmed={screen === "run"}>
|
||||
<AppHeader
|
||||
title={screen === "run" && lesson ? lesson.title : "Delfin Tippen"}
|
||||
compact={screen === "run"}
|
||||
status={
|
||||
<div style={{ display: "flex", gap: 12, alignItems: "center", color: "var(--paper)", fontWeight: 800 }}>
|
||||
<span>🦪 {progress.pearls}</span>
|
||||
{!progress.settings.sound && <span title="Ton aus">🔇</span>}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{screen === "aquarium" && (
|
||||
<Aquarium
|
||||
worlds={curriculum.worlds}
|
||||
progress={progress}
|
||||
nextLesson={nextUp}
|
||||
onContinue={() => nextUp && start(nextUp)}
|
||||
onOpenMap={() => setScreen("map")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{screen === "map" && (
|
||||
<LessonMap worlds={curriculum.worlds} lessons={curriculum.lessons} progress={progress} selected={selected} onPick={start} />
|
||||
)}
|
||||
|
||||
{screen === "run" && lesson && run && (
|
||||
<>
|
||||
{run.kind === "letters" ? (
|
||||
mode === "jellyfish" ? (
|
||||
<JellyfishRun {...letterProps(run.letters)} />
|
||||
) : (
|
||||
<BubblesRun {...letterProps(run.letters)} />
|
||||
)
|
||||
) : mode === "feed" ? (
|
||||
<FeedRun {...textProps(run)} />
|
||||
) : mode === "race" ? (
|
||||
<RaceRun {...textProps(run)} ghost={progress.lessons[lesson.id]?.ghost ?? null} />
|
||||
) : (
|
||||
<DiveRun {...textProps(run)} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{outcome && lesson && (
|
||||
<ResultSheet
|
||||
result={outcome.result}
|
||||
unlockedTitle={outcome.unlockedTitle}
|
||||
newCreature={outcome.newCreature}
|
||||
unlockedReward={outcome.unlockedReward}
|
||||
isNewBest={outcome.isNewBest}
|
||||
bestEver={overallBestAnimal(progress)}
|
||||
bonusModes={outcome.result.passed ? lesson.bonusModes : []}
|
||||
onPlayBonus={playBonus}
|
||||
onRetry={retry}
|
||||
onContinue={continueAfterResult}
|
||||
continueLabel={next && progress.lessons[next.id]?.unlocked ? `${next.title} ▶` : "Zur Karte"}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showHelp && <HelpOverlay onClose={() => setShowHelp(false)} />}
|
||||
</Stage>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingScreen({ text }: { text: string }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "var(--paper)",
|
||||
fontSize: 18,
|
||||
fontWeight: 800,
|
||||
textAlign: "center",
|
||||
padding: "0 32px",
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
web/src/components/tippen/AppHeader.tsx
Normal file
54
web/src/components/tippen/AppHeader.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
/** The header, matching ../../web/src/components/AppHeader.tsx so the two apps open the
|
||||
* same way. The mascot bobs; `SHOW_BUBBLES` doubles as the decorative-motion switch. */
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
/** Shown on the right - pearls, streak, a back hint. */
|
||||
status?: ReactNode;
|
||||
/** Smaller header while a lesson is running, so the target line gets the room. */
|
||||
compact?: boolean;
|
||||
onMascotClick?: () => void;
|
||||
}
|
||||
|
||||
export function AppHeader({ title = "Delfin Tippen", status, compact = false, onMascotClick }: Props) {
|
||||
const size = compact ? 44 : 64;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 16,
|
||||
padding: compact ? "12px 28px 4px" : "calc(18px + env(safe-area-inset-top)) 32px 6px",
|
||||
flex: "none",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src="/dolphin-mascot.png"
|
||||
alt=""
|
||||
onClick={onMascotClick}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
objectFit: "contain",
|
||||
animation: "dolphinBob 4s ease-in-out infinite",
|
||||
cursor: onMascotClick ? "pointer" : "default",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
fontSize: compact ? 24 : 34,
|
||||
fontWeight: 900,
|
||||
color: "var(--paper)",
|
||||
textShadow: "0 3px 14px oklch(15% 0.05 175 / .5)",
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
{status && <div style={{ position: "absolute", right: 32 }}>{status}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
136
web/src/components/tippen/Aquarium.tsx
Normal file
136
web/src/components/tippen/Aquarium.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
/** The home screen: what she has collected, before she is asked to do anything.
|
||||
*
|
||||
* Deliberately the first thing on opening the app. The reward for finishing a world is a
|
||||
* pet that moves in for good - it swims behind every screen from then on (see
|
||||
* AquariumCreatures.tsx) - and a reward you can see before you start is worth more than
|
||||
* one you are told about afterwards.
|
||||
*
|
||||
* The panel shows every pet there is to earn: the ones at home in colour, the rest as
|
||||
* pale outlines. The same idea as the animal ladder - the next thing has to be visible to
|
||||
* be worth aiming at - while the outline alone keeps a little surprise for the arrival. */
|
||||
|
||||
import { creatureById } from "../../lib/tippen/aquarium";
|
||||
import type { Lesson, World } from "../../lib/tippen/curriculum";
|
||||
import { animalById } from "../../lib/tippen/grading";
|
||||
import { overallBestAnimal } from "../../lib/tippen/progress";
|
||||
import type { Progress } from "../../lib/tippen/progress";
|
||||
|
||||
interface Props {
|
||||
worlds: readonly World[];
|
||||
progress: Progress;
|
||||
/** The lesson the "Weiter üben" button jumps to - the first unfinished one. */
|
||||
nextLesson: Lesson | null;
|
||||
onContinue: () => void;
|
||||
onOpenMap: () => void;
|
||||
}
|
||||
|
||||
export function Aquarium({ worlds, progress, nextLesson, onContinue, onOpenMap }: Props) {
|
||||
const bestAnimal = overallBestAnimal(progress);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="view-enter"
|
||||
style={{
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 22,
|
||||
padding: "0 32px 32px",
|
||||
minHeight: 0,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="tp-glass-panel"
|
||||
style={{ padding: "26px 34px", width: "min(680px, 100%)", textAlign: "center" }}
|
||||
>
|
||||
<div style={{ fontSize: 15, fontWeight: 900, color: "var(--paper)", opacity: 0.8 }}>
|
||||
Dein Aquarium
|
||||
</div>
|
||||
|
||||
<div
|
||||
aria-label="Deine Tiere"
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
flexWrap: "wrap",
|
||||
gap: 18,
|
||||
margin: "16px 0 6px",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{worlds.map((world) => {
|
||||
const creature = creatureById(world.reward);
|
||||
const owned = progress.aquarium.includes(creature.id);
|
||||
return (
|
||||
<img
|
||||
key={creature.id}
|
||||
src={creature.image}
|
||||
alt={owned ? creature.name : `Noch nicht da - Welt ${world.number}`}
|
||||
title={owned ? creature.name : `Welt ${world.number}`}
|
||||
style={{
|
||||
width: 62,
|
||||
height: 62,
|
||||
objectFit: "contain",
|
||||
// Not yet earned: a pale outline of the shape, no colours given away.
|
||||
filter: owned ? "none" : "brightness(0) invert(1)",
|
||||
opacity: owned ? 1 : 0.2,
|
||||
animation: owned ? `dolphinBob ${3 + world.number * 0.4}s ease-in-out infinite` : undefined,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{progress.aquarium.length === 0 && (
|
||||
<div style={{ fontSize: 15, fontWeight: 800, color: "var(--paper)", opacity: 0.65 }}>
|
||||
Schaffe eine ganze Welt und dein erstes Tier zieht ein!
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: "flex", justifyContent: "center", gap: 30, marginTop: 14 }}>
|
||||
<Stat label="Perlen" value={`🦪 ${progress.pearls}`} />
|
||||
<Stat label="Tage am Stück" value={`🔥 ${progress.streak.days}`} />
|
||||
<Stat
|
||||
label="Schnellstes Tier"
|
||||
value={bestAnimal ? `${animalById(bestAnimal).emoji} ${animalById(bestAnimal).name}` : "—"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: 12 }}>
|
||||
{nextLesson && (
|
||||
<button
|
||||
onClick={onContinue}
|
||||
style={{
|
||||
border: "none",
|
||||
borderRadius: 999,
|
||||
padding: "15px 32px",
|
||||
fontSize: 19,
|
||||
fontWeight: 900,
|
||||
cursor: "pointer",
|
||||
background: "var(--accent)",
|
||||
color: "var(--paper)",
|
||||
boxShadow: "0 8px 24px var(--shadow)",
|
||||
}}
|
||||
>
|
||||
▶ {nextLesson.title}
|
||||
</button>
|
||||
)}
|
||||
<button className="tp-pill" onClick={onOpenMap} style={{ fontSize: 16, padding: "15px 26px" }}>
|
||||
🗺️ Alle Lektionen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<div style={{ fontSize: 19, fontWeight: 900, color: "var(--paper)" }}>{value}</div>
|
||||
<div style={{ fontSize: 11, fontWeight: 800, color: "var(--paper)", opacity: 0.6 }}>{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
119
web/src/components/tippen/AquariumCreatures.tsx
Normal file
119
web/src/components/tippen/AquariumCreatures.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
/** The pets, swimming freely behind every screen.
|
||||
*
|
||||
* One requestAnimationFrame loop, started once and never restarted - the same approach as
|
||||
* ../../web/src/components/Ambience.tsx. Swimmers live in a ref and each frame is written
|
||||
* straight to its image's `transform`, so sixty frames a second never touch React. React
|
||||
* renders this component only when a pet moves in or the layer is dimmed.
|
||||
*
|
||||
* The layer sits at z-index -1 inside the stage's own stacking context (`isolation` on
|
||||
* .stage): above the gradient, below every screen. That is also what makes the glass
|
||||
* panels frost a pet that drifts behind one, instead of it swimming over the buttons.
|
||||
*
|
||||
* Pets already home when the app opens start somewhere inside the tank; a pet earned
|
||||
* while the app is open swims in from the side, so its arrival is something to see. */
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { creatureById, createSwimmer, pose, stepSwimmer } from "../../lib/tippen/aquarium";
|
||||
import type { CreatureId, Swimmer } from "../../lib/tippen/aquarium";
|
||||
|
||||
interface Props {
|
||||
creatures: readonly CreatureId[];
|
||||
/** 0..1, eased - the pets fade back while she types. */
|
||||
opacity: number;
|
||||
}
|
||||
|
||||
/** A tab in the background resumes with a gap of minutes. Swimming that in one step would
|
||||
* teleport every pet, so a frame never counts for more than this. */
|
||||
const MAX_STEP_S = 0.1;
|
||||
|
||||
export function AquariumCreatures({ creatures, opacity }: Props) {
|
||||
const tank = useRef<HTMLDivElement>(null);
|
||||
const images = useRef(new Map<CreatureId, HTMLImageElement>());
|
||||
const swimmers = useRef(new Map<CreatureId, Swimmer>());
|
||||
const current = useRef(creatures);
|
||||
current.current = creatures;
|
||||
/** The pets present at first render - everyone after them is a newcomer. */
|
||||
const present = useRef<ReadonlySet<CreatureId>>(new Set(creatures));
|
||||
|
||||
useEffect(() => {
|
||||
// Reduced motion: every pet is still placed and shown, it just holds still.
|
||||
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
let frame = 0;
|
||||
let lastTime = performance.now();
|
||||
|
||||
const step = (now: number) => {
|
||||
const dt = Math.min(MAX_STEP_S, Math.max(0, (now - lastTime) / 1000));
|
||||
lastTime = now;
|
||||
const el = tank.current;
|
||||
if (el) {
|
||||
const size = { width: el.clientWidth, height: el.clientHeight };
|
||||
for (const id of current.current) {
|
||||
const image = images.current.get(id);
|
||||
if (!image) continue;
|
||||
const creature = creatureById(id);
|
||||
const height = creature.size * size.height;
|
||||
const margin = height / 2;
|
||||
const speed = creature.speed * size.width;
|
||||
|
||||
const previous = swimmers.current.get(id);
|
||||
const s = previous
|
||||
? reducedMotion
|
||||
? previous
|
||||
: stepSwimmer(previous, dt, size, margin, speed, Math.random)
|
||||
: createSwimmer(size, margin, Math.random, !reducedMotion && !present.current.has(id));
|
||||
swimmers.current.set(id, s);
|
||||
|
||||
const p = pose(s, creature, speed);
|
||||
image.style.height = `${height}px`;
|
||||
image.style.transform =
|
||||
`translate(${p.x}px, ${p.y}px) translate(-50%, -50%) ` +
|
||||
`rotate(${p.rotation}deg) scaleX(${p.mirror})`;
|
||||
}
|
||||
}
|
||||
frame = requestAnimationFrame(step);
|
||||
};
|
||||
|
||||
frame = requestAnimationFrame(step);
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={tank}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
overflow: "hidden",
|
||||
pointerEvents: "none",
|
||||
zIndex: -1,
|
||||
opacity,
|
||||
transition: "opacity 700ms ease",
|
||||
}}
|
||||
>
|
||||
{creatures.map((id) => (
|
||||
<img
|
||||
key={id}
|
||||
ref={(image) => {
|
||||
if (image) images.current.set(id, image);
|
||||
else images.current.delete(id);
|
||||
}}
|
||||
src={creatureById(id).image}
|
||||
alt=""
|
||||
draggable={false}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
top: 0,
|
||||
height: 0,
|
||||
// Off-stage until the loop has placed it, so nothing flashes in the corner.
|
||||
transform: "translate(-200vw, 0)",
|
||||
willChange: "transform",
|
||||
filter: "drop-shadow(0 10px 16px oklch(15% 0.05 175 / 0.35))",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
41
web/src/components/tippen/Bubbles.tsx
Normal file
41
web/src/components/tippen/Bubbles.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
/** The decorative bubble field, copied from ../../web/src/components/Bubbles.tsx.
|
||||
* Positions are a fixed table rather than random so they do not reshuffle on render. */
|
||||
|
||||
interface Props {
|
||||
count?: number;
|
||||
}
|
||||
|
||||
/** [left %, size px, opacity, seconds, delay seconds] */
|
||||
const BUBBLES: readonly [number, number, number, number, number][] = [
|
||||
[6, 14, 0.18, 13, 0], [17, 9, 0.14, 16, 2.5], [28, 20, 0.12, 11, 5],
|
||||
[39, 7, 0.2, 18, 1.2], [51, 16, 0.13, 14, 7], [62, 11, 0.17, 12, 3.4],
|
||||
[73, 22, 0.1, 17, 6.1], [84, 8, 0.19, 15, 0.8], [93, 13, 0.14, 13, 4.2],
|
||||
[11, 18, 0.11, 19, 8.5], [45, 10, 0.16, 15, 9.3], [68, 6, 0.21, 20, 2],
|
||||
];
|
||||
|
||||
export function Bubbles({ count = BUBBLES.length }: Props) {
|
||||
// The wrapper is load-bearing, not tidiness. Each bubble starts at `bottom: -40px`,
|
||||
// and an absolutely positioned child still counts toward its container's scrollHeight
|
||||
// - so without this, twelve decorative bubbles made `.stage` 40px taller than the
|
||||
// viewport. `.stage` hides that overflow, but a hidden overflow is still scrollable:
|
||||
// focusing the result sheet's button scrolled the whole app up and pushed the header
|
||||
// off the top of the screen. Clipping them here keeps the stage exactly one screen.
|
||||
return (
|
||||
<div style={{ position: "absolute", inset: 0, overflow: "hidden", pointerEvents: "none" }}>
|
||||
{BUBBLES.slice(0, count).map(([left, size, alpha, seconds, delay], i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="bubble"
|
||||
style={{
|
||||
left: `${left}%`,
|
||||
width: size,
|
||||
height: size,
|
||||
background: `oklch(97% 0.01 175 / ${alpha})`,
|
||||
animationDuration: `${seconds}s`,
|
||||
animationDelay: `${delay}s`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
web/src/components/tippen/HelpOverlay.tsx
Normal file
35
web/src/components/tippen/HelpOverlay.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
/** The key sheet, on F1. Mirrors what App.tsx actually binds - if a binding changes
|
||||
* there, it changes here. */
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const KEYS: readonly [string, string][] = [
|
||||
["⏎", "Lektion starten · Runde wiederholen"],
|
||||
["Esc", "Eine Ebene zurück"],
|
||||
["← →", "Lektion auswählen"],
|
||||
["H", "Tastatur-Hilfe ein- und ausblenden"],
|
||||
["M", "Ton an und aus"],
|
||||
["F1", "Diese Übersicht"],
|
||||
];
|
||||
|
||||
export function HelpOverlay({ onClose }: Props) {
|
||||
return (
|
||||
<div className="tp-overlay backdrop-enter" onClick={onClose}>
|
||||
<div className="tp-sheet tp-sheet-enter" style={{ padding: "26px 32px", width: "min(460px, 100%)" }}>
|
||||
<div style={{ fontSize: 24, fontWeight: 900, color: "var(--ink)", marginBottom: 16 }}>
|
||||
⌨️ Zaubertasten
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
{KEYS.map(([key, description]) => (
|
||||
<div key={key} style={{ display: "flex", alignItems: "center", gap: 14 }}>
|
||||
<span className="key-cap">{key}</span>
|
||||
<span style={{ fontWeight: 700, color: "var(--ink)" }}>{description}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
93
web/src/components/tippen/Keyboard.tsx
Normal file
93
web/src/components/tippen/Keyboard.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
/** The on-screen QWERTZ keyboard: the scaffold that teaches itself away.
|
||||
*
|
||||
* Every key is tinted with the hue of the finger that types it (lib/fingers.ts), the
|
||||
* next key is highlighted and pulsing, and keys this lesson does not use yet fade back
|
||||
* without disappearing - a map with holes in it is harder to read than a complete one.
|
||||
*
|
||||
* The important behaviour is the fade. In "auto" mode each key's opacity is driven by
|
||||
* how well she knows it (`mastery`), so the hint quietly withdraws exactly where it is
|
||||
* no longer needed and stays put where it is. That is the difference between a crutch
|
||||
* and a scaffold: looking at the screen has to stop being worth it before looking at the
|
||||
* hands does. `H` forces it back on. */
|
||||
|
||||
import { KEYBOARD_ROWS, fingerOf, keyForChar, keysInRow, needsShift, shiftHandFor } from "../../lib/tippen/fingers";
|
||||
import type { Progress } from "../../lib/tippen/progress";
|
||||
import { mastery } from "../../lib/tippen/progress";
|
||||
|
||||
interface Props {
|
||||
/** Keys this lesson uses - everything else is dimmed. */
|
||||
activeKeys: readonly string[];
|
||||
/** The key to highlight, or null when nothing is expected. */
|
||||
nextKey: string | null;
|
||||
progress: Progress;
|
||||
mode: "auto" | "on" | "off";
|
||||
/** Key size in px; the whole keyboard scales off it. */
|
||||
size?: number;
|
||||
}
|
||||
|
||||
/** Rows are offset the way a real keyboard staggers them. */
|
||||
const ROW_INDENT: Record<string, number> = { top: 0, home: 14, bottom: 30 };
|
||||
|
||||
export function Keyboard({ activeKeys, nextKey, progress, mode, size = 42 }: Props) {
|
||||
if (mode === "off") return null;
|
||||
|
||||
const active = new Set(activeKeys.map((key) => keyForChar(key)));
|
||||
// "?" is Shift+ß - the key to light up is ß, not a key called "?".
|
||||
const next = nextKey === null ? null : keyForChar(nextKey);
|
||||
const shift = nextKey !== null && needsShift(nextKey);
|
||||
const spaceActive = active.has(" ");
|
||||
const shiftHand = shift && nextKey !== null ? shiftHandFor(nextKey) : null;
|
||||
|
||||
return (
|
||||
<div className="tp-kb" style={{ ["--kb-size" as string]: `${size}px` }} aria-hidden="true">
|
||||
{KEYBOARD_ROWS.map((row) => (
|
||||
<div className="tp-kb-row" key={row} style={{ marginLeft: ROW_INDENT[row] ?? 0 }}>
|
||||
{keysInRow(row).map((key) => {
|
||||
const finger = fingerOf(key);
|
||||
const isActive = active.has(key);
|
||||
// A key she has mastered fades out; one she is still learning stays bright.
|
||||
// Only ever applied to active keys - dimming an unlearned key would hide the
|
||||
// very thing she needs to find.
|
||||
const masteryLevel = mode === "auto" && isActive ? mastery(progress, key) : 0;
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="tp-kb-key"
|
||||
data-active={isActive}
|
||||
data-next={key === next}
|
||||
data-finger={finger?.id}
|
||||
data-home={key === "f" || key === "j"}
|
||||
style={{
|
||||
["--finger-hue" as string]: finger?.hue ?? 0,
|
||||
// Never below 0.25: the keyboard stops helping, it does not vanish
|
||||
// mid-lesson and leave her staring at a blank strip.
|
||||
opacity: isActive ? Math.max(0.25, 1 - masteryLevel * 0.75) : undefined,
|
||||
}}
|
||||
>
|
||||
{key.toUpperCase()}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="tp-kb-row" style={{ marginTop: 2, alignItems: "center", gap: 10 }}>
|
||||
{/* Both Shifts are drawn, and the one to use lights up - the opposite hand from
|
||||
the letter, which is the rule world 4 exists to teach. */}
|
||||
<div className="tp-kb-key tp-kb-shift" data-active={shift} data-next={shift && shiftHand === "left"}>
|
||||
⇧
|
||||
</div>
|
||||
<div
|
||||
className="tp-kb-key tp-kb-space"
|
||||
data-active={spaceActive}
|
||||
data-next={next === " "}
|
||||
data-finger="thumb"
|
||||
style={{ ["--finger-hue" as string]: 220 }}
|
||||
/>
|
||||
<div className="tp-kb-key tp-kb-shift" data-active={shift} data-next={shift && shiftHand === "right"}>
|
||||
⇧
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
172
web/src/components/tippen/LessonMap.tsx
Normal file
172
web/src/components/tippen/LessonMap.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
/** The map: a single winding path, one world section at a time.
|
||||
*
|
||||
* Locked lessons are dimmed rather than hidden - seeing that "Große Buchstaben" is
|
||||
* waiting is half the reason to finish the world that is open. Each node carries its own
|
||||
* best animal, star count and a badge for which game it plays, so the map doubles as
|
||||
* both a path forward and a trophy cabinet. */
|
||||
|
||||
import type { Lesson, World } from "../../lib/tippen/curriculum";
|
||||
import { animalById } from "../../lib/tippen/grading";
|
||||
import { NODE_SPACING, pathD, pointFor } from "../../lib/tippen/lessonPath";
|
||||
import { MODE_INFO } from "../../lib/tippen/modeInfo";
|
||||
import type { Progress } from "../../lib/tippen/progress";
|
||||
|
||||
interface Props {
|
||||
worlds: readonly World[];
|
||||
lessons: readonly Lesson[];
|
||||
progress: Progress;
|
||||
/** Which card the keyboard selection is on. */
|
||||
selected: number;
|
||||
onPick: (lesson: Lesson) => void;
|
||||
}
|
||||
|
||||
const NODE_SIZE = 88;
|
||||
/** Half the SVG's viewBox width - wide enough for the path's full swing either side. */
|
||||
const PATH_HALF_WIDTH = 160;
|
||||
|
||||
/** What a consolidation node's key-label line says when it has no keys of its own to
|
||||
* show - a child-friendly word rather than the raw `LessonKind`. */
|
||||
const CONSOLIDATION_LABEL: Record<"fragments" | "words" | "sentences", string> = {
|
||||
fragments: "Wörter",
|
||||
words: "Wörter",
|
||||
sentences: "Sätze",
|
||||
};
|
||||
|
||||
export function LessonMap({ worlds, lessons, progress, selected, onPick }: Props) {
|
||||
return (
|
||||
<div
|
||||
className="view-enter"
|
||||
style={{ flex: 1, overflowY: "auto", padding: "6px 32px 40px", minHeight: 0 }}
|
||||
>
|
||||
<div style={{ maxWidth: 480, margin: "0 auto", display: "flex", flexDirection: "column", gap: 14 }}>
|
||||
{worlds.map((world) => {
|
||||
const worldLessons = lessons.filter((lesson) => lesson.world === world.number);
|
||||
const done = worldLessons.filter((l) => (progress.lessons[l.id]?.bestStars ?? 0) >= 2).length;
|
||||
const height = worldLessons.length * NODE_SPACING;
|
||||
const points = worldLessons.map((_, i) => pointFor(i));
|
||||
|
||||
return (
|
||||
<section key={world.number}>
|
||||
<div
|
||||
className="tp-glass-panel"
|
||||
style={{
|
||||
position: "sticky",
|
||||
top: 0,
|
||||
zIndex: 2,
|
||||
padding: "10px 18px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 22 }}>{world.emoji}</span>
|
||||
<span style={{ fontSize: 17, fontWeight: 900, color: "var(--paper)" }}>
|
||||
Welt {world.number} — {world.title}
|
||||
</span>
|
||||
<span style={{ fontSize: 13, fontWeight: 800, color: "var(--paper)", opacity: 0.6, marginLeft: "auto" }}>
|
||||
{done}/{worldLessons.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ position: "relative", height, margin: "0 auto" }}>
|
||||
<svg
|
||||
style={{ position: "absolute", left: "50%", top: 0, transform: "translateX(-50%)", overflow: "visible" }}
|
||||
width={PATH_HALF_WIDTH * 2}
|
||||
height={height}
|
||||
viewBox={`${-PATH_HALF_WIDTH} 0 ${PATH_HALF_WIDTH * 2} ${height}`}
|
||||
>
|
||||
<path d={pathD(points)} stroke="oklch(97% 0.01 175 / 0.35)" strokeWidth={8} strokeLinecap="round" fill="none" />
|
||||
</svg>
|
||||
|
||||
{worldLessons.map((lesson, i) => {
|
||||
const entry = progress.lessons[lesson.id];
|
||||
const locked = !entry?.unlocked;
|
||||
const animal = entry?.bestAnimal ? animalById(entry.bestAnimal) : null;
|
||||
const index = lessons.indexOf(lesson);
|
||||
const point = points[i]!;
|
||||
const modeInfo = MODE_INFO[lesson.primaryMode];
|
||||
|
||||
return (
|
||||
<button
|
||||
key={lesson.id}
|
||||
className="card tp-glass-panel"
|
||||
data-selected={index === selected}
|
||||
data-locked={locked}
|
||||
disabled={locked}
|
||||
onClick={() => onPick(lesson)}
|
||||
title={lesson.title}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: `calc(50% + ${point.x}px)`,
|
||||
top: point.y,
|
||||
transform: "translate(-50%, -50%)",
|
||||
width: NODE_SIZE,
|
||||
height: NODE_SIZE,
|
||||
borderRadius: "50%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 2,
|
||||
padding: 0,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: -4,
|
||||
right: -4,
|
||||
fontSize: 15,
|
||||
filter: locked ? "grayscale(1)" : "none",
|
||||
opacity: locked ? 0.4 : 0.9,
|
||||
}}
|
||||
title={modeInfo.name}
|
||||
>
|
||||
{modeInfo.emoji}
|
||||
</span>
|
||||
|
||||
<span style={{ fontSize: 24 }}>{locked ? "🔒" : (animal?.emoji ?? "·")}</span>
|
||||
|
||||
{/* The keys themselves: for a pre-reader this is the real label, the
|
||||
title is decoration. A drill has no new keys, so it says so with
|
||||
a symbol instead of showing an empty line. */}
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 800,
|
||||
color: "var(--paper)",
|
||||
opacity: 0.8,
|
||||
letterSpacing: lesson.isDrill ? "normal" : "0.08em",
|
||||
lineHeight: 1.1,
|
||||
}}
|
||||
>
|
||||
{lesson.isDrill
|
||||
? "🔁"
|
||||
: lesson.newKeys.length > 0
|
||||
? lesson.newKeys.map((key) => (key === " " ? "␣" : key === "⇧" ? "⇧" : key.toUpperCase())).join(" ")
|
||||
: lesson.kind === "letters"
|
||||
? "üben"
|
||||
: CONSOLIDATION_LABEL[lesson.kind]}
|
||||
</span>
|
||||
|
||||
<span style={{ fontSize: 9, letterSpacing: "0.04em" }}>
|
||||
{[1, 2, 3].map((star) => (
|
||||
<span key={star} style={{ opacity: (entry?.bestStars ?? 0) >= star ? 1 : 0.22 }}>
|
||||
⭐
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
319
web/src/components/tippen/ResultSheet.tsx
Normal file
319
web/src/components/tippen/ResultSheet.tsx
Normal file
@@ -0,0 +1,319 @@
|
||||
/** 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 { coverUrl } from "../../api/client";
|
||||
import { creatureById } from "../../lib/tippen/aquarium";
|
||||
import type { CreatureId } from "../../lib/tippen/aquarium";
|
||||
import type { ModeId } from "../../lib/tippen/curriculum";
|
||||
import type { RunResult } from "../../lib/tippen/grading";
|
||||
import { STAR_THRESHOLDS, visibleAnimals, animalById, animalIndex, animalProgress } from "../../lib/tippen/grading";
|
||||
import type { AnimalId } from "../../lib/tippen/grading";
|
||||
import { MODE_INFO } from "../../lib/tippen/modeInfo";
|
||||
import type { UnlockedReward } from "../../lib/tippen/progress";
|
||||
|
||||
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;
|
||||
/** Set when this run's lesson named an `unlocks:` target that just became reachable -
|
||||
* the literal track/episode it names, not "one of several". */
|
||||
unlockedReward: UnlockedReward | null;
|
||||
isNewBest: boolean;
|
||||
/** The fastest animal earned on any lesson so far - decides how much of the ladder
|
||||
* may be revealed. */
|
||||
bestEver: AnimalId | null;
|
||||
/** Extra games this lesson didn't gate progress on - feed or race, offered only once
|
||||
* the lesson is passed. Playing one never changes the unlock, only the best score. */
|
||||
bonusModes: readonly ModeId[];
|
||||
onPlayBonus: (mode: ModeId) => void;
|
||||
onRetry: () => void;
|
||||
onContinue: () => void;
|
||||
/** Null at the end of the curriculum. */
|
||||
continueLabel: string | null;
|
||||
}
|
||||
|
||||
export function ResultSheet({
|
||||
result,
|
||||
unlockedTitle,
|
||||
newCreature,
|
||||
unlockedReward,
|
||||
isNewBest,
|
||||
bestEver,
|
||||
bonusModes,
|
||||
onPlayBonus,
|
||||
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="tp-overlay backdrop-enter">
|
||||
<div
|
||||
className="tp-sheet tp-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>
|
||||
)}
|
||||
{unlockedReward && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 10,
|
||||
color: "var(--ink)",
|
||||
fontWeight: 900,
|
||||
marginTop: 8,
|
||||
}}
|
||||
>
|
||||
{unlockedReward.hasCover ? (
|
||||
<img
|
||||
src={coverUrl(unlockedReward.albumId)}
|
||||
alt=""
|
||||
style={{
|
||||
width: 52,
|
||||
height: 52,
|
||||
objectFit: "cover",
|
||||
borderRadius: 8,
|
||||
animation: "tierEnter 520ms ease-out",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span style={{ fontSize: 40, animation: "tierEnter 520ms ease-out" }}>🎁</span>
|
||||
)}
|
||||
🎁 Neu zum Anhören: {unlockedReward.title}
|
||||
</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>
|
||||
|
||||
{/* Extra games this lesson didn't need to pass - a treat, not a requirement, so
|
||||
they only appear once the lesson is already behind her. `.pill` is styled for
|
||||
the dark stage background, not this light sheet, so these get their own
|
||||
(smaller, quieter) version of the sheet's own button look instead. */}
|
||||
{bonusModes.length > 0 && (
|
||||
<div style={{ display: "flex", gap: 8, marginTop: 12, justifyContent: "center", flexWrap: "wrap" }}>
|
||||
{bonusModes.map((mode) => (
|
||||
<button
|
||||
key={mode}
|
||||
onClick={() => onPlayBonus(mode)}
|
||||
style={{
|
||||
border: "none",
|
||||
borderRadius: 999,
|
||||
padding: "8px 16px",
|
||||
fontSize: 14,
|
||||
fontWeight: 800,
|
||||
cursor: "pointer",
|
||||
background: "oklch(90% 0.02 175)",
|
||||
color: "var(--ink)",
|
||||
}}
|
||||
>
|
||||
{MODE_INFO[mode].emoji} {MODE_INFO[mode].name}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
30
web/src/components/tippen/Stage.tsx
Normal file
30
web/src/components/tippen/Stage.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
/** The gradient stage every screen sits on - the app's one full-height container.
|
||||
* `data-blur` is read by app.css to drop the backdrop filters wholesale.
|
||||
*
|
||||
* The pets swim here rather than on the home screen, so they stay with her on the lesson
|
||||
* map and during a run too. */
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import type { CreatureId } from "../../lib/tippen/aquarium";
|
||||
import { SHOW_AQUARIUM_CREATURES, SHOW_BUBBLES, SHOW_GLASS_BLUR } from "../../lib/tippen/theme";
|
||||
import { AquariumCreatures } from "./AquariumCreatures";
|
||||
import { Bubbles } from "./Bubbles";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
/** The pets that have moved in so far. */
|
||||
creatures: readonly CreatureId[];
|
||||
/** While she is typing the pets fade back: still there, not competing with the line. */
|
||||
dimmed: boolean;
|
||||
}
|
||||
|
||||
export function Stage({ children, creatures, dimmed }: Props) {
|
||||
return (
|
||||
<div className="tp-stage" data-blur={SHOW_GLASS_BLUR ? "on" : "off"}>
|
||||
{SHOW_AQUARIUM_CREATURES && <AquariumCreatures creatures={creatures} opacity={dimmed ? 0.25 : 1} />}
|
||||
{SHOW_BUBBLES && <Bubbles />}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
93
web/src/components/tippen/Target.tsx
Normal file
93
web/src/components/tippen/Target.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
/** The line being typed, drawn chunk by chunk.
|
||||
*
|
||||
* Chunks matter more than they look: twenty-four letters in an unbroken row is
|
||||
* something a six-year-old loses her place in, four letters with a gap after them is
|
||||
* not. The gap is a real space to type from the very first lesson, even before the
|
||||
* space-bar lesson formally teaches the thumb. */
|
||||
|
||||
import { chunkOffsets } from "../../lib/tippen/generator";
|
||||
|
||||
interface Props {
|
||||
chunks: readonly string[];
|
||||
spaceActive: boolean;
|
||||
/** Cursor position in the joined text. */
|
||||
index: number;
|
||||
/** True while a wrong key is still being corrected at the cursor. */
|
||||
wrong: boolean;
|
||||
/** Overrides the size picked from the text's own length. */
|
||||
fontSize?: number;
|
||||
}
|
||||
|
||||
/** Type size and column width for a block of `length` characters.
|
||||
*
|
||||
* A round is at least five lines of text, and the whole block has to sit on one screen
|
||||
* above the keyboard without the stage ever scrolling - a line that scrolls out of view
|
||||
* mid-run is worse than no line at all.
|
||||
*
|
||||
* The two move together on purpose. Short blocks (world 1) get the biggest letters *and*
|
||||
* the narrowest column, so they still wrap into five lines instead of three long ones -
|
||||
* and a narrow column is easier for a beginner to keep her place in anyway. Long blocks
|
||||
* (world 5's sentences) get smaller type and a wider column so ten sentences still fit. */
|
||||
function layoutFor(length: number): { size: number; width: number } {
|
||||
if (length <= 40) return { size: 44, width: 560 };
|
||||
if (length <= 80) return { size: 38, width: 600 };
|
||||
if (length <= 130) return { size: 32, width: 760 };
|
||||
if (length <= 220) return { size: 26, width: 880 };
|
||||
return { size: 22, width: 960 };
|
||||
}
|
||||
|
||||
export function Target({ chunks, spaceActive, index, wrong, fontSize }: Props) {
|
||||
const offsets = chunkOffsets(chunks, spaceActive);
|
||||
const length = chunks.join("").length + (spaceActive ? chunks.length - 1 : 0);
|
||||
const layout = layoutFor(length);
|
||||
const size = fontSize ?? layout.size;
|
||||
|
||||
return (
|
||||
<div className="tp-target" style={{ fontSize: size, maxWidth: `min(${layout.width}px, 90vw)` }}>
|
||||
{chunks.map((chunk, chunkIndex) => {
|
||||
const start = offsets[chunkIndex] ?? 0;
|
||||
return (
|
||||
<div className="tp-target-chunk" key={chunkIndex}>
|
||||
{[...chunk].map((char, i) => {
|
||||
const at = start + i;
|
||||
const state = at < index ? "done" : at === index ? "current" : "open";
|
||||
// World 5's targets are whole sentences, so a chunk can contain spaces of
|
||||
// its own. They need the same visible body as the ones between chunks, or
|
||||
// the cursor lands on nothing and looks lost.
|
||||
const isBlank = char === " ";
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
className="tp-target-char"
|
||||
data-state={state}
|
||||
data-blank={isBlank}
|
||||
data-wrong={state === "current" && wrong}
|
||||
>
|
||||
{isBlank ? (state === "current" ? "␣" : "") : char}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{/* The space between chunks is a real character once it is taught, so it
|
||||
needs to be typeable and to show the cursor. Before that it is a gap. */}
|
||||
{spaceActive && chunkIndex < chunks.length - 1 && (
|
||||
<span
|
||||
className="tp-target-char"
|
||||
data-blank="true"
|
||||
data-state={
|
||||
start + chunk.length < index
|
||||
? "done"
|
||||
: start + chunk.length === index
|
||||
? "current"
|
||||
: "open"
|
||||
}
|
||||
data-wrong={start + chunk.length === index && wrong}
|
||||
>
|
||||
{start + chunk.length === index ? "␣" : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
138
web/src/components/tippen/modes/BubblesRun.tsx
Normal file
138
web/src/components/tippen/modes/BubblesRun.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
/** Bubbles mode - the first arcade mode, and the proof that the engine is
|
||||
* mode-agnostic: same `useRun`, same grading, same unlock. Only the drawing differs.
|
||||
*
|
||||
* Each letter of the line is a bubble. The bubble at the cursor is the one nearest the
|
||||
* surface; popping it lets the ones below rise. Position is derived from distance to the
|
||||
* cursor rather than from a clock, which has two consequences worth stating: the rise is
|
||||
* a CSS transition instead of a requestAnimationFrame loop, and - more importantly -
|
||||
* taking your time costs nothing. The plan calls this a soft clock. Speed still shows up
|
||||
* in the animal, because `grade` is measuring the keystrokes either way, but no bubble
|
||||
* ever escapes and nothing is ever lost. At six, "you were too slow" is the fastest way
|
||||
* to end a session. */
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { currentChar } from "../../../lib/tippen/engine";
|
||||
import type { RunEvent } from "../../../lib/tippen/engine";
|
||||
import { fingerOf } from "../../../lib/tippen/fingers";
|
||||
import type { RunResult } from "../../../lib/tippen/grading";
|
||||
import type { Progress } from "../../../lib/tippen/progress";
|
||||
import { useRun } from "../../../hooks/useTippenRun";
|
||||
import { Keyboard } from "../Keyboard";
|
||||
|
||||
interface Props {
|
||||
letters: readonly string[];
|
||||
activeKeys: readonly string[];
|
||||
progress: Progress;
|
||||
paused: boolean;
|
||||
onFinished: (result: RunResult) => void;
|
||||
}
|
||||
|
||||
/** How many bubbles are in the water at once. More than five and the column of letters
|
||||
* reads as a wall of text; fewer and there is nothing to look forward to. */
|
||||
const VISIBLE_COUNT = 5;
|
||||
|
||||
/** Fixed horizontal lanes, so bubbles do not jitter sideways as they rise. */
|
||||
const LANES = [50, 28, 68, 38, 60, 46];
|
||||
|
||||
export function BubblesRun({ letters, activeKeys, progress, paused, onFinished }: Props) {
|
||||
const text = letters.join("");
|
||||
const [popped, setPopped] = useState<number | null>(null);
|
||||
|
||||
const onEvent = (event: RunEvent) => {
|
||||
// Remember which bubble just popped so it can play its burst before disappearing.
|
||||
if (event.type === "correct") setPopped(event.index);
|
||||
};
|
||||
|
||||
const { state, wrong } = useRun({
|
||||
target: text,
|
||||
sound: progress.settings.sound,
|
||||
paused,
|
||||
onFinished,
|
||||
onEvent,
|
||||
});
|
||||
|
||||
const next = currentChar(state);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="view-enter"
|
||||
style={{
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: 18,
|
||||
padding: "0 32px 8px",
|
||||
minHeight: 0,
|
||||
}}
|
||||
>
|
||||
<div style={{ position: "relative", flex: 1, width: "100%", minHeight: 0 }}>
|
||||
{/* The surface line the bubbles rise toward. */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 2,
|
||||
background: "oklch(97% 0.01 175 / 0.25)",
|
||||
}}
|
||||
/>
|
||||
{[...text].map((letter, i) => {
|
||||
const distance = i - state.index;
|
||||
if (distance < 0 || distance >= VISIBLE_COUNT) return null;
|
||||
const finger = fingerOf(letter);
|
||||
const isCurrent = distance === 0;
|
||||
const size = isCurrent ? 104 : 68;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: `${LANES[i % LANES.length]}%`,
|
||||
// Distance from the cursor is distance from the surface. The transition
|
||||
// is what makes popping one visibly lift the rest.
|
||||
top: `${6 + distance * 19}%`,
|
||||
transform: "translate(-50%, 0)",
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: "50%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: size * 0.42,
|
||||
fontWeight: 900,
|
||||
color: isCurrent ? "oklch(25% 0.05 175)" : "var(--paper)",
|
||||
background: isCurrent
|
||||
? "var(--paper)"
|
||||
: `linear-gradient(160deg, oklch(75% 0.12 ${finger?.hue ?? 175} / .45), oklch(50% 0.09 ${finger?.hue ?? 175} / .2))`,
|
||||
border: `2px solid oklch(90% 0.05 ${finger?.hue ?? 175} / ${isCurrent ? 0.9 : 0.35})`,
|
||||
boxShadow: isCurrent ? "0 10px 30px var(--shadow)" : "0 4px 14px var(--shadow)",
|
||||
opacity: 1 - distance * 0.13,
|
||||
transition: "top 380ms cubic-bezier(.2,.7,.3,1), width 240ms ease, height 240ms ease, font-size 240ms ease",
|
||||
animation:
|
||||
popped === i
|
||||
? "correctPop 200ms ease-out"
|
||||
: isCurrent && wrong
|
||||
? "wrongShake 260ms ease"
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
{letter === " " ? "␣" : letter}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Keyboard
|
||||
activeKeys={activeKeys}
|
||||
nextKey={next}
|
||||
progress={progress}
|
||||
mode={progress.settings.keyboardHint}
|
||||
size={38}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
90
web/src/components/tippen/modes/DiveRun.tsx
Normal file
90
web/src/components/tippen/modes/DiveRun.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
/** Dive mode - the plain drill: a line of chunks with a moving cursor above the
|
||||
* keyboard. Most fragments/words/sentences lessons play this by default, though feed
|
||||
* and race periodically take a turn as the required mode instead - any of them can
|
||||
* unlock the next lesson, since `recordRun` doesn't care which mode produced the run. */
|
||||
|
||||
import { currentChar } from "../../../lib/tippen/engine";
|
||||
import type { RunResult } from "../../../lib/tippen/grading";
|
||||
import type { Progress } from "../../../lib/tippen/progress";
|
||||
import { useRun } from "../../../hooks/useTippenRun";
|
||||
import { Keyboard } from "../Keyboard";
|
||||
import { Target } from "../Target";
|
||||
|
||||
interface Props {
|
||||
chunks: readonly string[];
|
||||
text: string;
|
||||
spaceActive: boolean;
|
||||
activeKeys: readonly string[];
|
||||
progress: Progress;
|
||||
paused: boolean;
|
||||
onFinished: (result: RunResult) => void;
|
||||
}
|
||||
|
||||
export function DiveRun({
|
||||
chunks,
|
||||
text,
|
||||
spaceActive,
|
||||
activeKeys,
|
||||
progress,
|
||||
paused,
|
||||
onFinished,
|
||||
}: Props) {
|
||||
const { state, wrong } = useRun({
|
||||
target: text,
|
||||
sound: progress.settings.sound,
|
||||
paused,
|
||||
onFinished,
|
||||
});
|
||||
|
||||
const next = currentChar(state);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="view-enter"
|
||||
style={{
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 26,
|
||||
padding: "0 32px",
|
||||
minHeight: 0,
|
||||
}}
|
||||
>
|
||||
<Target chunks={chunks} spaceActive={spaceActive} index={state.index} wrong={wrong} />
|
||||
<ProgressBar done={state.index} total={text.length} />
|
||||
<Keyboard
|
||||
activeKeys={activeKeys}
|
||||
nextKey={next}
|
||||
progress={progress}
|
||||
mode={progress.settings.keyboardHint}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** How far through the line she is - a bar, not a number, because "18 von 24" is a
|
||||
* reading task and a filling bar is not. */
|
||||
function ProgressBar({ done, total }: { done: number; total: number }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: "min(520px, 80%)",
|
||||
height: 8,
|
||||
borderRadius: 999,
|
||||
background: "oklch(97% 0.01 175 / 0.16)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: `${total === 0 ? 0 : (done / total) * 100}%`,
|
||||
height: "100%",
|
||||
background: "var(--paper)",
|
||||
transition: "width 120ms ease-out",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
177
web/src/components/tippen/modes/FeedRun.tsx
Normal file
177
web/src/components/tippen/modes/FeedRun.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
/** Feed mode - words instead of letters.
|
||||
*
|
||||
* Fish swim past carrying short German words; typing one feeds it to the dolphin. This
|
||||
* is the first mode where the unit is a whole word rather than a key, which is the step
|
||||
* from "I can find the letters" to "I can write something", and it is the reason the
|
||||
* word lists in curriculum.ts are real German words a six-year-old knows rather than
|
||||
* pronounceable nonsense.
|
||||
*
|
||||
* The clock is soft, like bubbles mode: the fish queue up and wait. Nothing swims off
|
||||
* unfed. */
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { currentChar } from "../../../lib/tippen/engine";
|
||||
import type { RunEvent } from "../../../lib/tippen/engine";
|
||||
import { chunkOffsets } from "../../../lib/tippen/generator";
|
||||
import type { RunResult } from "../../../lib/tippen/grading";
|
||||
import type { Progress } from "../../../lib/tippen/progress";
|
||||
import { useRun } from "../../../hooks/useTippenRun";
|
||||
import { Keyboard } from "../Keyboard";
|
||||
|
||||
interface Props {
|
||||
chunks: readonly string[];
|
||||
text: string;
|
||||
spaceActive: boolean;
|
||||
activeKeys: readonly string[];
|
||||
progress: Progress;
|
||||
paused: boolean;
|
||||
onFinished: (result: RunResult) => void;
|
||||
}
|
||||
|
||||
const FISH = ["🐟", "🐠", "🐡", "🦐", "🦀"];
|
||||
|
||||
export function FeedRun({
|
||||
chunks,
|
||||
text,
|
||||
spaceActive,
|
||||
activeKeys,
|
||||
progress,
|
||||
paused,
|
||||
onFinished,
|
||||
}: Props) {
|
||||
const [fed, setFed] = useState(0);
|
||||
|
||||
const { state, wrong } = useRun({
|
||||
target: text,
|
||||
sound: progress.settings.sound,
|
||||
paused,
|
||||
onFinished,
|
||||
onEvent: (event: RunEvent) => {
|
||||
if (event.type === "correct") setFed((n) => n);
|
||||
},
|
||||
});
|
||||
|
||||
const next = currentChar(state);
|
||||
const offsets = chunkOffsets(chunks, spaceActive);
|
||||
|
||||
/** Which word is being typed. A word counts as current until its last letter is done. */
|
||||
const currentIndex = Math.max(
|
||||
0,
|
||||
offsets.findIndex((start, i) => state.index <= start + (chunks[i]?.length ?? 0)),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setFed(currentIndex);
|
||||
}, [currentIndex]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="view-enter"
|
||||
style={{
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: 14,
|
||||
padding: "0 32px 8px",
|
||||
minHeight: 0,
|
||||
}}
|
||||
>
|
||||
<div style={{ position: "relative", flex: 1, width: "100%", minHeight: 0, overflow: "hidden" }}>
|
||||
{/* The dolphin waits on the left; the fish come to it. */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 8,
|
||||
top: "42%",
|
||||
fontSize: 66,
|
||||
animation: "dolphinBob 3.2s ease-in-out infinite",
|
||||
zIndex: 2,
|
||||
}}
|
||||
>
|
||||
🐬
|
||||
</div>
|
||||
|
||||
{chunks.map((word, i) => {
|
||||
const distance = i - currentIndex;
|
||||
// Three fish in the water. A fourth at this spacing runs off the right edge,
|
||||
// and a queue you cannot see the end of is not a queue.
|
||||
if (distance < 0 || distance > 2) return null;
|
||||
const start = offsets[i] ?? 0;
|
||||
const isTarget = distance === 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
position: "absolute",
|
||||
// Distance from the dolphin is distance in the queue. Feeding one lets
|
||||
// the rest swim in, via the transition rather than a rAF loop.
|
||||
left: `${15 + distance * 26}%`,
|
||||
top: `${30 + (i % 3) * 16}%`,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
padding: isTarget ? "10px 18px" : "7px 13px",
|
||||
borderRadius: 999,
|
||||
background: isTarget ? "var(--paper)" : "oklch(97% 0.01 175 / .13)",
|
||||
border: `2px solid oklch(97% 0.01 175 / ${isTarget ? 0.85 : 0.2})`,
|
||||
boxShadow: isTarget ? "0 10px 28px var(--shadow)" : "0 3px 10px var(--shadow)",
|
||||
maxWidth: "34%",
|
||||
opacity: 1 - distance * 0.22,
|
||||
transition: "left 420ms cubic-bezier(.2,.7,.3,1), opacity 300ms ease, padding 200ms ease",
|
||||
animation: isTarget && wrong ? "wrongShake 260ms ease" : undefined,
|
||||
zIndex: isTarget ? 3 : 1,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: isTarget ? 34 : 24 }}>{FISH[i % FISH.length]}</span>
|
||||
<span
|
||||
style={{
|
||||
display: "flex",
|
||||
fontSize: isTarget ? 34 : 22,
|
||||
fontWeight: 800,
|
||||
color: isTarget ? "oklch(25% 0.05 175)" : "var(--paper)",
|
||||
}}
|
||||
>
|
||||
{isTarget
|
||||
? [...word].map((char, j) => {
|
||||
const at = start + j;
|
||||
const charState = at < state.index ? "done" : at === state.index ? "current" : "open";
|
||||
return (
|
||||
<span
|
||||
key={j}
|
||||
style={{
|
||||
color:
|
||||
charState === "done"
|
||||
? "oklch(58% 0.15 150)"
|
||||
: charState === "current"
|
||||
? "var(--accent)"
|
||||
: "oklch(45% 0.03 175)",
|
||||
textDecoration: charState === "current" ? "underline" : undefined,
|
||||
}}
|
||||
>
|
||||
{char === " " ? " " : char}
|
||||
</span>
|
||||
);
|
||||
})
|
||||
: word}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style={{ color: "var(--paper)", fontWeight: 800, opacity: 0.8 }}>
|
||||
{fed} von {chunks.length} gefüttert
|
||||
</div>
|
||||
<Keyboard
|
||||
activeKeys={activeKeys}
|
||||
nextKey={next}
|
||||
progress={progress}
|
||||
mode={progress.settings.keyboardHint}
|
||||
size={34}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
144
web/src/components/tippen/modes/JellyfishRun.tsx
Normal file
144
web/src/components/tippen/modes/JellyfishRun.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
/** Jellyfish mode - pure key location, nothing else.
|
||||
*
|
||||
* Six jellyfish drift in the water, each showing a letter. One of them glows: that is
|
||||
* the one to zap. There is no line to read and no word to spell, so the only thing
|
||||
* being exercised is "where does this letter live" - which is exactly the skill dive
|
||||
* mode hides behind reading.
|
||||
*
|
||||
* The decoys matter. Showing only the target turns this into the bubble mode; showing
|
||||
* five wrong letters next to it means she has to find *her* letter before she can type
|
||||
* it, which is the searching step that eventually goes away. */
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { currentChar } from "../../../lib/tippen/engine";
|
||||
import { fingerOf } from "../../../lib/tippen/fingers";
|
||||
import { mulberry32 } from "../../../lib/tippen/generator";
|
||||
import type { RunResult } from "../../../lib/tippen/grading";
|
||||
import type { Progress } from "../../../lib/tippen/progress";
|
||||
import { useRun } from "../../../hooks/useTippenRun";
|
||||
import { Keyboard } from "../Keyboard";
|
||||
|
||||
interface Props {
|
||||
letters: readonly string[];
|
||||
activeKeys: readonly string[];
|
||||
progress: Progress;
|
||||
paused: boolean;
|
||||
onFinished: (result: RunResult) => void;
|
||||
}
|
||||
|
||||
/** How many jellyfish are in the water at once, target included. */
|
||||
const JELLYFISH_COUNT = 6;
|
||||
|
||||
interface Jellyfish {
|
||||
left: number;
|
||||
top: number;
|
||||
drift: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export function JellyfishRun({ letters, activeKeys, progress, paused, onFinished }: Props) {
|
||||
const text = letters.join("");
|
||||
const { state, wrong } = useRun({
|
||||
target: text,
|
||||
sound: progress.settings.sound,
|
||||
paused,
|
||||
onFinished,
|
||||
});
|
||||
|
||||
const next = currentChar(state);
|
||||
|
||||
// Fixed positions, seeded once: jellyfish that jump to a new spot on every keystroke
|
||||
// would make the searching step impossible rather than merely hard.
|
||||
const spots = useMemo<Jellyfish[]>(() => {
|
||||
const rng = mulberry32(text.length * 31 + 7);
|
||||
return Array.from({ length: JELLYFISH_COUNT }, () => ({
|
||||
left: 10 + rng() * 76,
|
||||
top: 6 + rng() * 66,
|
||||
drift: 3 + rng() * 3,
|
||||
size: 74 + rng() * 26,
|
||||
}));
|
||||
}, [text]);
|
||||
|
||||
/** The decoys shown alongside the target: other active keys, never the target itself,
|
||||
* and stable for as long as the target is. */
|
||||
const decoys = useMemo(() => {
|
||||
if (!next) return [];
|
||||
const rng = mulberry32(state.index * 101 + 13);
|
||||
const others = activeKeys.filter((key) => key !== next && key !== " ");
|
||||
const shuffled = [...others].sort(() => rng() - 0.5);
|
||||
return shuffled.slice(0, JELLYFISH_COUNT - 1);
|
||||
}, [next, state.index, activeKeys]);
|
||||
|
||||
// Which jellyfish carries the target. Moves around so it is not always the same one.
|
||||
const targetSlot = next ? state.index % JELLYFISH_COUNT : -1;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="view-enter"
|
||||
style={{
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: 16,
|
||||
padding: "0 32px 8px",
|
||||
minHeight: 0,
|
||||
}}
|
||||
>
|
||||
<div style={{ position: "relative", flex: 1, width: "100%", minHeight: 0 }}>
|
||||
{spots.map((spot, i) => {
|
||||
const isTarget = i === targetSlot;
|
||||
const letter = isTarget ? next : decoys[i > targetSlot ? i - 1 : i];
|
||||
if (!letter) return null;
|
||||
const finger = fingerOf(letter);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="tp-jellyfish"
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: `${spot.left}%`,
|
||||
top: `${spot.top}%`,
|
||||
width: spot.size,
|
||||
height: spot.size,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: spot.size * (isTarget ? 0.4 : 0.3),
|
||||
fontWeight: 900,
|
||||
borderRadius: "50% 50% 42% 42%",
|
||||
color: isTarget ? "oklch(25% 0.05 175)" : "var(--paper)",
|
||||
background: isTarget
|
||||
? "var(--paper)"
|
||||
: `linear-gradient(160deg, oklch(70% 0.13 ${finger?.hue ?? 175} / .38), oklch(45% 0.09 ${finger?.hue ?? 175} / .16))`,
|
||||
border: `2px solid oklch(88% 0.07 ${finger?.hue ?? 175} / ${isTarget ? 0.9 : 0.3})`,
|
||||
boxShadow: isTarget
|
||||
? "0 0 34px oklch(97% 0.01 175 / .55), 0 10px 26px var(--shadow)"
|
||||
: "0 4px 14px var(--shadow)",
|
||||
opacity: isTarget ? 1 : 0.55,
|
||||
transform: isTarget ? "scale(1.12)" : "scale(1)",
|
||||
animation: `dolphinBob ${spot.drift}s ease-in-out infinite`,
|
||||
transition: "opacity 200ms ease, transform 200ms ease, background 200ms ease",
|
||||
}}
|
||||
>
|
||||
{letter === " " ? "␣" : letter}
|
||||
{isTarget && wrong && (
|
||||
<div style={{ position: "absolute", inset: -6, borderRadius: "50%", animation: "wrongShake 260ms ease" }} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Keyboard
|
||||
activeKeys={activeKeys}
|
||||
nextKey={next}
|
||||
progress={progress}
|
||||
mode={progress.settings.keyboardHint}
|
||||
size={36}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
179
web/src/components/tippen/modes/RaceRun.tsx
Normal file
179
web/src/components/tippen/modes/RaceRun.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
/** Race mode - a race against her own best run.
|
||||
*
|
||||
* The opponent is the ghost of the fastest run she has ever had on this lesson: the
|
||||
* keystroke timings are stored in `progress.lessons[id].ghost` and replayed against the
|
||||
* clock. Racing yourself is the one competitive format that cannot be demoralising - the
|
||||
* opponent is by definition exactly as good as she was, so the race is always close, and
|
||||
* winning means she actually improved.
|
||||
*
|
||||
* Before a ghost exists there is a crab swimming at a gentle fixed pace. It is beatable
|
||||
* on the first try on purpose.
|
||||
*
|
||||
* The ghost is moved by writing to the DOM node directly from a requestAnimationFrame
|
||||
* loop, the way ../../web/src/components/Ambience.tsx does it - React never re-renders
|
||||
* for it, so the animation cannot compete with the keystroke handling. The player's own
|
||||
* dolphin needs no loop: it only moves when a key is pressed, which is a render anyway. */
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { currentChar } from "../../../lib/tippen/engine";
|
||||
import type { RunResult } from "../../../lib/tippen/grading";
|
||||
import type { Progress } from "../../../lib/tippen/progress";
|
||||
import { useRun } from "../../../hooks/useTippenRun";
|
||||
import { Keyboard } from "../Keyboard";
|
||||
import { Target } from "../Target";
|
||||
|
||||
interface Props {
|
||||
chunks: readonly string[];
|
||||
text: string;
|
||||
spaceActive: boolean;
|
||||
activeKeys: readonly string[];
|
||||
progress: Progress;
|
||||
/** The best run's correct keystrokes, or null for a first attempt. */
|
||||
ghost: readonly { key: string; at: number }[] | null;
|
||||
paused: boolean;
|
||||
onFinished: (result: RunResult) => void;
|
||||
}
|
||||
|
||||
/** The pace of the stand-in opponent, in characters per minute. Slow enough that a
|
||||
* careful first-timer beats it - roughly a turtle's pace. */
|
||||
const CRAB_PACE = 30;
|
||||
|
||||
export function RaceRun({
|
||||
chunks,
|
||||
text,
|
||||
spaceActive,
|
||||
activeKeys,
|
||||
progress,
|
||||
ghost,
|
||||
paused,
|
||||
onFinished,
|
||||
}: Props) {
|
||||
const { state, wrong } = useRun({
|
||||
target: text,
|
||||
sound: progress.settings.sound,
|
||||
paused,
|
||||
onFinished,
|
||||
});
|
||||
|
||||
const next = currentChar(state);
|
||||
const track = useRef<HTMLDivElement>(null);
|
||||
const opponent = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Everything the loop reads lives in a ref, so it is started once and never restarted.
|
||||
const latest = useRef({ ghost, startedAt: state.startedAt, length: text.length, finished: state.finishedAt !== null });
|
||||
latest.current = { ghost, startedAt: state.startedAt, length: text.length, finished: state.finishedAt !== null };
|
||||
|
||||
useEffect(() => {
|
||||
let frame = 0;
|
||||
|
||||
const tick = () => {
|
||||
frame = requestAnimationFrame(tick);
|
||||
const current = latest.current;
|
||||
const node = opponent.current;
|
||||
if (!node) return;
|
||||
|
||||
// The race starts on her first keystroke, not when the screen opens - the same
|
||||
// rule the grading uses, so a pause before starting costs nothing here either.
|
||||
if (current.startedAt === null) {
|
||||
node.style.left = "0%";
|
||||
return;
|
||||
}
|
||||
|
||||
const elapsed = performance.now() - current.startedAt;
|
||||
let fraction: number;
|
||||
|
||||
if (current.ghost && current.ghost.length > 1) {
|
||||
const start = current.ghost[0]!.at;
|
||||
// How many of the ghost's keystrokes have come due by now.
|
||||
let typed = 0;
|
||||
while (typed < current.ghost.length && current.ghost[typed]!.at - start <= elapsed) typed++;
|
||||
fraction = typed / current.length;
|
||||
} else {
|
||||
fraction = elapsed / 60000 / (1 / CRAB_PACE) / current.length;
|
||||
}
|
||||
|
||||
node.style.left = `${Math.min(1, Math.max(0, fraction)) * 100}%`;
|
||||
};
|
||||
|
||||
frame = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, []);
|
||||
|
||||
const myFraction = text.length === 0 ? 0 : state.index / text.length;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="view-enter"
|
||||
style={{
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 20,
|
||||
padding: "0 32px",
|
||||
minHeight: 0,
|
||||
}}
|
||||
>
|
||||
<div ref={track} style={{ width: "min(860px, 92%)", display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
<Lane label="Du" color="var(--paper)">
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: `${myFraction * 100}%`,
|
||||
transform: "translateX(-50%)",
|
||||
fontSize: 34,
|
||||
transition: "left 140ms ease-out",
|
||||
}}
|
||||
>
|
||||
🐬
|
||||
</div>
|
||||
</Lane>
|
||||
|
||||
<Lane label={ghost ? "Dein Rekord" : "Die Krabbe"} color="oklch(97% 0.01 175 / .55)">
|
||||
<div ref={opponent} style={{ position: "absolute", left: "0%", transform: "translateX(-50%)", fontSize: 30 }}>
|
||||
{ghost ? "👻" : "🦀"}
|
||||
</div>
|
||||
</Lane>
|
||||
</div>
|
||||
|
||||
<Target chunks={chunks} spaceActive={spaceActive} index={state.index} wrong={wrong} fontSize={34} />
|
||||
<Keyboard
|
||||
activeKeys={activeKeys}
|
||||
nextKey={next}
|
||||
progress={progress}
|
||||
mode={progress.settings.keyboardHint}
|
||||
size={34}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** One lane, with the finish line at the right. */
|
||||
function Lane({ label, color, children }: { label: string; color: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<div style={{ width: 96, flex: "none", fontSize: 13, fontWeight: 800, color, textAlign: "right" }}>
|
||||
{label}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
flex: 1,
|
||||
height: 44,
|
||||
borderRadius: 999,
|
||||
background: "oklch(97% 0.01 175 / .1)",
|
||||
border: "1px solid oklch(97% 0.01 175 / .16)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<div style={{ position: "absolute", inset: 0, display: "flex", alignItems: "center", paddingLeft: 10, paddingRight: 10 }}>
|
||||
<div style={{ position: "relative", width: "100%", display: "flex", alignItems: "center" }}>{children}</div>
|
||||
</div>
|
||||
<div style={{ position: "absolute", right: 8, fontSize: 18, opacity: 0.7 }}>🏁</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user