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:
@@ -22,6 +22,8 @@ import { PlayerBar } from "./components/PlayerBar";
|
||||
import { PlayView } from "./components/PlayView";
|
||||
import { RemoteAssignPopup } from "./components/RemoteAssignPopup";
|
||||
import { RoomView } from "./components/RoomView";
|
||||
import { TabRail } from "./components/TabRail";
|
||||
import { TippenApp } from "./components/TippenApp";
|
||||
import { useGridColumns } from "./hooks/useGridColumns";
|
||||
import { useLibrary } from "./hooks/useLibrary";
|
||||
import { usePlayerState } from "./hooks/usePlayerState";
|
||||
@@ -401,8 +403,8 @@ export function App() {
|
||||
}
|
||||
run(browseBackActions(ui));
|
||||
};
|
||||
const onToggleRoom = () =>
|
||||
setUi((previous) => ({ ...previous, page: previous.page === "room" ? "music" : "room" }));
|
||||
const onSelectPage = (page: UiState["page"]) =>
|
||||
setUi((previous) => ({ ...previous, page: previous.page === page ? "music" : page }));
|
||||
|
||||
return (
|
||||
<div className="stage" data-blur={SHOW_GLASS_BLUR ? undefined : "off"}>
|
||||
@@ -497,6 +499,10 @@ export function App() {
|
||||
|
||||
{ui.page === "room" && haConfig && <RoomView config={haConfig} />}
|
||||
|
||||
{ui.page === "typing" && (
|
||||
<TippenApp onExit={() => setUi((previous) => ({ ...previous, page: "music" }))} />
|
||||
)}
|
||||
|
||||
{openAlbum && state && (
|
||||
<AlbumModal
|
||||
album={openAlbum}
|
||||
@@ -522,15 +528,7 @@ export function App() {
|
||||
</CornerButton>
|
||||
)}
|
||||
|
||||
{haConfig && (
|
||||
<CornerButton
|
||||
side="right"
|
||||
onClick={onToggleRoom}
|
||||
label={ui.page === "room" ? "Musik" : "Mein Zimmer"}
|
||||
>
|
||||
{ui.page === "room" ? "🎵" : "💡"}
|
||||
</CornerButton>
|
||||
)}
|
||||
<TabRail page={ui.page} onSelect={onSelectPage} showRoom={haConfig != null} />
|
||||
|
||||
{ui.showHelp && (
|
||||
<HelpOverlay onClose={() => setUi((previous) => ({ ...previous, showHelp: false }))} />
|
||||
|
||||
@@ -9,6 +9,11 @@ import type {
|
||||
RemoteMapping,
|
||||
RemoteSlotInput,
|
||||
Settings,
|
||||
TippenCurriculum,
|
||||
TippenProgress,
|
||||
TippenRunInput,
|
||||
TippenRunResult,
|
||||
TippenSettings,
|
||||
TrackDetail,
|
||||
} from "./types";
|
||||
|
||||
@@ -53,6 +58,11 @@ const fetchHaConfig = (): Promise<HaConfig | null> => fetchOrNullOn404<HaConfig>
|
||||
/** `null` means the IR remote isn't configured, not an error. */
|
||||
const fetchLircConfig = (): Promise<LircConfig | null> => fetchOrNullOn404<LircConfig>("/lirc");
|
||||
|
||||
/** `null` means the typing game isn't configured, not an error - the tab still shows,
|
||||
* just with a "not set up" placeholder instead of a lesson map. */
|
||||
const fetchTippenCurriculum = (): Promise<TippenCurriculum | null> =>
|
||||
fetchOrNullOn404<TippenCurriculum>("/tippen/curriculum");
|
||||
|
||||
/** `null` covers both "unknown to Home Assistant" and "Home Assistant unreachable
|
||||
* right now" (the backend answers the latter with a 502) - the room page treats a
|
||||
* device with no state the same way either way, rather than crashing on a poll. */
|
||||
@@ -103,6 +113,13 @@ export const api = {
|
||||
remoteMapping: () => request<RemoteMapping>("/remote/mapping"),
|
||||
saveRemoteMapping: (slots: Record<string, RemoteSlotInput>) =>
|
||||
request<RemoteMapping>("/remote/mapping", { method: "PUT", body: JSON.stringify({ slots }) }),
|
||||
|
||||
tippenCurriculum: fetchTippenCurriculum,
|
||||
tippenProgress: () => request<TippenProgress>("/tippen/progress"),
|
||||
saveTippenSettings: (settings: TippenSettings) =>
|
||||
request<TippenSettings>("/tippen/settings", { method: "PUT", body: JSON.stringify(settings) }),
|
||||
recordTippenRun: (body: TippenRunInput) =>
|
||||
request<TippenRunResult>("/tippen/runs", { method: "POST", body: JSON.stringify(body) }),
|
||||
};
|
||||
|
||||
export const coverUrl = (albumId: string) => `/api/albums/${albumId}/cover`;
|
||||
|
||||
@@ -34,11 +34,24 @@ export interface TrackDetail {
|
||||
curve: TrackCurves | null;
|
||||
}
|
||||
|
||||
/** Which typing lesson unlocks a still-locked track - the browse view's "unlocks
|
||||
* when..." hint. Mirrors `UnlockHintOut`. */
|
||||
export interface UnlockHint {
|
||||
lesson_id: string;
|
||||
lesson_title: string;
|
||||
world_number: number;
|
||||
world_title: string;
|
||||
}
|
||||
|
||||
export interface Track {
|
||||
title: string;
|
||||
/** Seconds, read from the file's tags at scan time. */
|
||||
duration: number;
|
||||
analysis: TrackAnalysis | null;
|
||||
/** A typing-reward track not yet earned. Only ever `true` when the typing game is
|
||||
* configured on the backend. */
|
||||
locked: boolean;
|
||||
unlock_hint: UnlockHint | null;
|
||||
}
|
||||
|
||||
export interface Album {
|
||||
@@ -56,6 +69,8 @@ export interface Album {
|
||||
has_cover: boolean;
|
||||
duration: number;
|
||||
tracks: Track[];
|
||||
/** Every track is still locked - show a question mark instead of cover art. */
|
||||
locked: boolean;
|
||||
}
|
||||
|
||||
export interface PlayerState {
|
||||
@@ -135,3 +150,131 @@ export interface RemoteSlotInput {
|
||||
export interface LircConfig {
|
||||
connected: boolean;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- tippen
|
||||
//
|
||||
// The typing game. `null` from `GET /tippen/curriculum` means the whole feature isn't
|
||||
// configured, like `HaConfig` above - the tab still shows, just with nothing behind it.
|
||||
// Mirrors the `Tippen*` schemas in `musicmouse/services/web/schemas.py`.
|
||||
|
||||
export type TippenLessonKind = "letters" | "fragments" | "words" | "sentences";
|
||||
export type TippenModeId = "dive" | "bubbles" | "jellyfish" | "feed" | "race";
|
||||
|
||||
/** What a lesson's `unlocks:` resolves to right now. `resolved: false` means the
|
||||
* configured path matches nothing in the current library. */
|
||||
export interface TippenReward {
|
||||
resolved: boolean;
|
||||
album_id: string | null;
|
||||
has_cover: boolean;
|
||||
kind: "tracks" | "episode" | null;
|
||||
}
|
||||
|
||||
export interface TippenLesson {
|
||||
id: string;
|
||||
world: number;
|
||||
number: number;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
kind: TippenLessonKind;
|
||||
new_keys: string[];
|
||||
spotlight_keys: string[];
|
||||
emphasis: "isolated" | "mixed" | null;
|
||||
active_keys: string[];
|
||||
primary_mode: TippenModeId;
|
||||
bonus_modes: TippenModeId[];
|
||||
words: string[];
|
||||
is_drill: boolean;
|
||||
chunks: number;
|
||||
chunk_size: number;
|
||||
reward: TippenReward;
|
||||
}
|
||||
|
||||
export interface TippenWorld {
|
||||
number: number;
|
||||
title: string;
|
||||
emoji: string;
|
||||
reward: string;
|
||||
}
|
||||
|
||||
export interface TippenCurriculum {
|
||||
worlds: TippenWorld[];
|
||||
lessons: TippenLesson[];
|
||||
}
|
||||
|
||||
export interface TippenGhostStroke {
|
||||
key: string;
|
||||
at: number;
|
||||
}
|
||||
|
||||
export interface TippenLessonProgress {
|
||||
unlocked: boolean;
|
||||
runs: number;
|
||||
best_stars: 0 | 1 | 2 | 3;
|
||||
best_animal: string | null;
|
||||
best_points: number;
|
||||
/** Derived server-side, not stored - two stars, or five attempts regardless. */
|
||||
earned: boolean;
|
||||
ghost: TippenGhostStroke[] | null;
|
||||
}
|
||||
|
||||
export interface TippenKeyStat {
|
||||
ema: number;
|
||||
attempts: number;
|
||||
errors: number;
|
||||
}
|
||||
|
||||
export interface TippenStreak {
|
||||
days: number;
|
||||
last_played: string | null;
|
||||
}
|
||||
|
||||
export interface TippenSettings {
|
||||
sound: boolean;
|
||||
keyboard_hint: "auto" | "on" | "off";
|
||||
}
|
||||
|
||||
export interface TippenProgress {
|
||||
lessons: Record<string, TippenLessonProgress>;
|
||||
key_stats: Record<string, TippenKeyStat>;
|
||||
pearls: number;
|
||||
aquarium: string[];
|
||||
streak: TippenStreak;
|
||||
settings: TippenSettings;
|
||||
}
|
||||
|
||||
export interface TippenStroke {
|
||||
key: string;
|
||||
expected: string;
|
||||
correct: boolean;
|
||||
at: number;
|
||||
}
|
||||
|
||||
/** A run the client already graded - see `lib/tippen/grading.ts`. Grading stays
|
||||
* client-side; the backend only owns progress bookkeeping. */
|
||||
export interface TippenRunInput {
|
||||
lesson_id: string;
|
||||
stars: 0 | 1 | 2 | 3;
|
||||
animal: string;
|
||||
points: number;
|
||||
passed: boolean;
|
||||
pearls: number;
|
||||
strokes: TippenStroke[];
|
||||
}
|
||||
|
||||
/** The literal track/episode this run's lesson names in its own `unlocks:` - what the
|
||||
* unlock animation shows, via the existing `/api/albums/{id}/cover`. */
|
||||
export interface TippenUnlockedReward {
|
||||
album_id: string;
|
||||
title: string;
|
||||
has_cover: boolean;
|
||||
kind: "album" | "book" | "podcast_episode";
|
||||
}
|
||||
|
||||
export interface TippenRunResult {
|
||||
progress: TippenProgress;
|
||||
unlocked_lesson_id: string | null;
|
||||
unlocked_lesson_title: string | null;
|
||||
new_creature: string | null;
|
||||
is_new_best: boolean;
|
||||
unlocked_reward: TippenUnlockedReward | null;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
34
web/src/hooks/useTippenCurriculum.ts
Normal file
34
web/src/hooks/useTippenCurriculum.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/** The typing game's lesson plan, fetched once - see `lib/tippen/curriculum.ts` for the
|
||||
* derived Lesson/World shape everything else in the typing game expects. `null` after
|
||||
* loading means the backend has no `general.tippen` section configured, same as
|
||||
* `useHomeAssistant`'s `config` for the room page. */
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { api } from "../api/client";
|
||||
import type { Curriculum } from "../lib/tippen/curriculum";
|
||||
import { fromApi } from "../lib/tippen/curriculum";
|
||||
|
||||
export interface TippenCurriculumState {
|
||||
curriculum: Curriculum | null;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function useTippenCurriculum(): TippenCurriculumState {
|
||||
const [curriculum, setCurriculum] = useState<Curriculum | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void api.tippenCurriculum().then((loaded) => {
|
||||
if (cancelled) return;
|
||||
setCurriculum(loaded ? fromApi(loaded) : null);
|
||||
setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { curriculum, loading };
|
||||
}
|
||||
57
web/src/hooks/useTippenProgress.ts
Normal file
57
web/src/hooks/useTippenProgress.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/** The typing game's progress: fetched once and updated from each run-recording
|
||||
* response, since the backend is the only place it lives now - there is no local save.
|
||||
* `enabled` mirrors `useHomeAssistant`'s `config` argument: pass whether the curriculum
|
||||
* resolved non-null, so this hook stays a no-op until there is something to fetch. */
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { api } from "../api/client";
|
||||
import type { RunResult } from "../lib/tippen/grading";
|
||||
import type { Progress, RunOutcome, Settings } from "../lib/tippen/progress";
|
||||
import { progressFromApi, runOutcomeFromApi, toRunInput } from "../lib/tippen/progress";
|
||||
|
||||
export interface TippenProgressState {
|
||||
progress: Progress | null;
|
||||
loading: boolean;
|
||||
recordRun: (lessonId: string, result: RunResult) => Promise<RunOutcome>;
|
||||
saveSettings: (settings: Settings) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useTippenProgress(enabled: boolean): TippenProgressState {
|
||||
const [progress, setProgress] = useState<Progress | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
let cancelled = false;
|
||||
void api.tippenProgress().then((loaded) => {
|
||||
if (cancelled) return;
|
||||
setProgress(progressFromApi(loaded));
|
||||
setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [enabled]);
|
||||
|
||||
const recordRun = useCallback(async (lessonId: string, result: RunResult) => {
|
||||
const response = await api.recordTippenRun(toRunInput(lessonId, result));
|
||||
const outcome = runOutcomeFromApi(response);
|
||||
setProgress(outcome.progress);
|
||||
return outcome;
|
||||
}, []);
|
||||
|
||||
const saveSettings = useCallback(async (settings: Settings) => {
|
||||
const saved = await api.saveTippenSettings({
|
||||
sound: settings.sound,
|
||||
keyboard_hint: settings.keyboardHint,
|
||||
});
|
||||
setProgress((previous) =>
|
||||
previous
|
||||
? { ...previous, settings: { sound: saved.sound, keyboardHint: saved.keyboard_hint } }
|
||||
: previous,
|
||||
);
|
||||
}, []);
|
||||
|
||||
return { progress, loading, recordRun, saveSettings };
|
||||
}
|
||||
90
web/src/hooks/useTippenRun.ts
Normal file
90
web/src/hooks/useTippenRun.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/** Mounts the pure engine against real keystrokes.
|
||||
*
|
||||
* This is the thin adapter the plan calls for, and the same shape the music player uses
|
||||
* in ../../web/src/App.tsx: everything the listener reads lives in a ref, so the
|
||||
* listener is installed exactly once and a re-render never reattaches it. That matters
|
||||
* more here than there - a listener that is torn down and rebuilt between keystrokes
|
||||
* drops keys, and dropping a six-year-old's keystroke looks to her like the game is
|
||||
* broken.
|
||||
*
|
||||
* Every mode drives this. They differ in what they draw, not in what typing means. */
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { isTypingKey, press, startRun } from "../lib/tippen/engine";
|
||||
import type { RunEvent, RunState } from "../lib/tippen/engine";
|
||||
import type { RunResult } from "../lib/tippen/grading";
|
||||
import { playWrong, playDone, playCorrect } from "../lib/tippen/pop";
|
||||
|
||||
interface Options {
|
||||
/** The text to type. Changing it restarts the run. */
|
||||
target: string;
|
||||
sound: boolean;
|
||||
/** Called once, when the last character lands. */
|
||||
onFinished: (result: RunResult) => void;
|
||||
/** Extra per-event hook for a mode that needs it (popping a bubble, say). */
|
||||
onEvent?: (event: RunEvent) => void;
|
||||
/** Paused runs ignore keystrokes - used while the result sheet is up. */
|
||||
paused?: boolean;
|
||||
}
|
||||
|
||||
export interface RunHandle {
|
||||
state: RunState;
|
||||
/** True while the cursor is sitting on a key that was just missed. */
|
||||
wrong: boolean;
|
||||
restart: () => void;
|
||||
}
|
||||
|
||||
export function useRun({ target, sound, onFinished, onEvent, paused = false }: Options): RunHandle {
|
||||
const [state, setState] = useState<RunState>(() => startRun(target));
|
||||
const [wrong, setWrong] = useState(false);
|
||||
|
||||
// Everything the listener needs, kept current without reinstalling it.
|
||||
const latest = useRef({ state, sound, onFinished, onEvent, paused });
|
||||
latest.current = { state, sound, onFinished, onEvent, paused };
|
||||
|
||||
const restart = useCallback(() => {
|
||||
setState(startRun(target));
|
||||
setWrong(false);
|
||||
}, [target]);
|
||||
|
||||
// A new target is a new run - the modes swap the line rather than remounting.
|
||||
useEffect(() => restart(), [restart]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
const current = latest.current;
|
||||
if (current.paused) return;
|
||||
// Let the app keep its own keys: Escape leaves, F1 helps, and a browser shortcut
|
||||
// with a modifier held is the browser's business, not the run's.
|
||||
if (event.ctrlKey || event.metaKey || event.altKey) return;
|
||||
if (!isTypingKey(event.key)) return;
|
||||
|
||||
// Space scrolls the page and Tab leaves it; both are typing input here.
|
||||
event.preventDefault();
|
||||
|
||||
const [next, events] = press(current.state, event.key, performance.now());
|
||||
if (events.length === 0) return;
|
||||
|
||||
setState(next);
|
||||
for (const runEvent of events) {
|
||||
if (runEvent.type === "correct") {
|
||||
setWrong(false);
|
||||
if (current.sound) playCorrect(runEvent.streak);
|
||||
} else if (runEvent.type === "wrong") {
|
||||
setWrong(true);
|
||||
if (current.sound) playWrong();
|
||||
} else {
|
||||
if (current.sound) playDone();
|
||||
current.onFinished(runEvent.result);
|
||||
}
|
||||
current.onEvent?.(runEvent);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, []);
|
||||
|
||||
return { state, wrong, restart };
|
||||
}
|
||||
@@ -28,7 +28,8 @@ function album(over: Partial<Album> = {}): Album {
|
||||
colors: ["#111111", "#222222", "#333333"],
|
||||
has_cover: false,
|
||||
duration: 120,
|
||||
tracks: [{ title: "Lied", duration: 60, analysis: null }],
|
||||
locked: false,
|
||||
tracks: [{ title: "Lied", duration: 60, analysis: null, locked: false, unlock_hint: null }],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,9 +17,10 @@ function album(id: string, over: Partial<Album> = {}): Album {
|
||||
colors: ["#111111", "#222222", "#333333"],
|
||||
has_cover: false,
|
||||
duration: 120,
|
||||
locked: false,
|
||||
tracks: [
|
||||
{ title: `Lied ${id}`, duration: 60, analysis: null },
|
||||
{ title: "Zweites Lied", duration: 60, analysis: null },
|
||||
{ title: `Lied ${id}`, duration: 60, analysis: null, locked: false, unlock_hint: null },
|
||||
{ title: "Zweites Lied", duration: 60, analysis: null, locked: false, unlock_hint: null },
|
||||
],
|
||||
...over,
|
||||
};
|
||||
|
||||
@@ -16,6 +16,7 @@ function album(id: string, over: Partial<Album> = {}): Album {
|
||||
colors: ["#111111", "#222222", "#333333"],
|
||||
has_cover: false,
|
||||
duration: 120,
|
||||
locked: false,
|
||||
tracks: [],
|
||||
...over,
|
||||
};
|
||||
|
||||
@@ -25,8 +25,8 @@ export interface UiState {
|
||||
* that row", the way it means "which flat position" everywhere else. */
|
||||
shelfRow: number;
|
||||
/** Which top-level page is showing. Orthogonal to `view`/`search`/`group`/… below,
|
||||
* so toggling to the room and back leaves the music side exactly as it was. */
|
||||
page: "music" | "room";
|
||||
* so switching pages and back leaves the music side exactly as it was. */
|
||||
page: "music" | "room" | "typing";
|
||||
view: "browse" | "play";
|
||||
openAlbumId: string | null;
|
||||
/** Which track is highlighted in the open album's track list. Only meaningful while
|
||||
@@ -232,6 +232,12 @@ export function handleKey(
|
||||
): Action[] {
|
||||
const { key } = event;
|
||||
|
||||
// The typing tab is a second app mounted alongside this one (TippenApp.tsx) with its
|
||||
// own complete keyboard handling, including its own Escape/F1 bindings. It must own
|
||||
// every key while active - even Shift+media and Space below, which would otherwise
|
||||
// hijack a key a lesson happens to be drilling (see TippenApp.tsx's own doc comment).
|
||||
if (state.page === "typing") return [];
|
||||
|
||||
if (event.shiftKey && !event.ctrlKey && !event.metaKey) {
|
||||
const media = SHIFT_MEDIA[key.toUpperCase()];
|
||||
if (media) return media;
|
||||
|
||||
@@ -114,6 +114,9 @@ export function songMatches(query: BrowseQuery): SongHit[] {
|
||||
const hits: SongHit[] = [];
|
||||
for (const album of pool(query.albums, query.group)) {
|
||||
album.tracks.forEach((track, index) => {
|
||||
// A locked track has no real title to search by - it shows as a question mark
|
||||
// wherever it appears, so it has nothing useful to match here either.
|
||||
if (track.locked) return;
|
||||
if (!words.length || matchesWords(normalize(track.title), words)) {
|
||||
hits.push({ album, index, title: track.title, duration: track.duration });
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
import type { Group } from "./search";
|
||||
import type { UiState } from "./keyboard";
|
||||
|
||||
// ---------------------------------------------------------------- toggles --
|
||||
|
||||
@@ -74,6 +75,21 @@ export const GROUP_LABEL: Record<Group, string> = {
|
||||
podcasts: "Podcasts",
|
||||
};
|
||||
|
||||
/** Icon/label for the vertical tab rail - one entry per `UiState["page"]`. The
|
||||
* smarthome tab only renders when Home Assistant is configured (see `App.tsx`); the
|
||||
* other two always show. */
|
||||
export const PAGE_ICON: Record<UiState["page"], string> = {
|
||||
music: "🎵",
|
||||
room: "💡",
|
||||
typing: "⌨️",
|
||||
};
|
||||
|
||||
export const PAGE_LABEL: Record<UiState["page"], string> = {
|
||||
music: "Musik",
|
||||
room: "Mein Zimmer",
|
||||
typing: "Tippen",
|
||||
};
|
||||
|
||||
/** A shelf/row's frosted background and border, tinted with its group's hue - or,
|
||||
* with `SHOW_ROW_TINT` off, `{}` so `.glass-panel`'s own neutral CSS shows through. */
|
||||
export function glassTint(hue: number): CSSProperties {
|
||||
|
||||
92
web/src/lib/tippen/__tests__/aquarium.test.ts
Normal file
92
web/src/lib/tippen/__tests__/aquarium.test.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { CREATURES, creatureFromRaw, creatureById, createSwimmer, pose, stepSwimmer } from "../aquarium";
|
||||
import type { Tank, Swimmer } from "../aquarium";
|
||||
import { mulberry32 } from "../generator";
|
||||
|
||||
const TANK: Tank = { width: 1280, height: 800 };
|
||||
const MARGIN = 60;
|
||||
const SPEED = 55;
|
||||
const DT = 1 / 60;
|
||||
|
||||
function swim(s: Swimmer, seconds: number, tank = TANK, rng = mulberry32(7)): Swimmer {
|
||||
for (let t = 0; t < seconds; t += DT) s = stepSwimmer(s, DT, tank, MARGIN, SPEED, rng);
|
||||
return s;
|
||||
}
|
||||
|
||||
describe("Creatures", () => {
|
||||
it("looks pets up by id", () => {
|
||||
for (const creature of CREATURES) expect(creatureById(creature.id)).toBe(creature);
|
||||
});
|
||||
|
||||
it("reads today's ids and the emoji old saves stored, and nothing else", () => {
|
||||
expect(creatureFromRaw("octopus")).toBe("octopus");
|
||||
expect(creatureFromRaw("🐠")).toBe("clownfish");
|
||||
expect(creatureFromRaw("🧜")).toBe("pearlmussel");
|
||||
expect(creatureFromRaw("🦈")).toBeNull();
|
||||
expect(creatureFromRaw(42)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("stepSwimmer", () => {
|
||||
it("never lets a pet's centre leave the tank", () => {
|
||||
const rng = mulberry32(3);
|
||||
let s = createSwimmer(TANK, MARGIN, rng);
|
||||
// Ten minutes of swimming, checked every frame.
|
||||
for (let t = 0; t < 600; t += DT) {
|
||||
s = stepSwimmer(s, DT, TANK, MARGIN, SPEED, rng);
|
||||
expect(s.x).toBeGreaterThanOrEqual(0);
|
||||
expect(s.x).toBeLessThanOrEqual(TANK.width);
|
||||
expect(s.y).toBeGreaterThanOrEqual(0);
|
||||
expect(s.y).toBeLessThanOrEqual(TANK.height);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps moving rather than settling", () => {
|
||||
const rng = mulberry32(11);
|
||||
const start = createSwimmer(TANK, MARGIN, rng);
|
||||
const later = swim(start, 60, TANK, rng);
|
||||
expect(Math.hypot(later.x - start.x, later.y - start.y)).toBeGreaterThan(0);
|
||||
expect(later.targetX !== start.targetX || later.targetY !== start.targetY).toBe(true);
|
||||
});
|
||||
|
||||
it("turns to face the way it swims", () => {
|
||||
const base: Swimmer = { x: 640, y: 400, vx: 0, vy: 0, targetX: 100, targetY: 400, facing: 1, age: 0 };
|
||||
const left = swim(base, 3);
|
||||
expect(left.vx).toBeLessThan(0);
|
||||
expect(left.facing).toBeLessThan(-0.9);
|
||||
|
||||
const right = swim({ ...base, targetX: 1180, facing: -1 }, 3);
|
||||
expect(right.vx).toBeGreaterThan(0);
|
||||
expect(right.facing).toBeGreaterThan(0.9);
|
||||
});
|
||||
|
||||
it("brings a newly earned pet in from outside the tank", () => {
|
||||
const rng = mulberry32(5);
|
||||
const fresh = createSwimmer(TANK, MARGIN, rng, true);
|
||||
expect(fresh.x < 0 || fresh.x > TANK.width).toBe(true);
|
||||
const inside = swim(fresh, 40, TANK, rng);
|
||||
expect(inside.x).toBeGreaterThan(0);
|
||||
expect(inside.x).toBeLessThan(TANK.width);
|
||||
});
|
||||
|
||||
it("finds a new target when the window shrinks under the old one", () => {
|
||||
const s: Swimmer = { x: 200, y: 200, vx: 0, vy: 0, targetX: 1200, targetY: 700, facing: 1, age: 0 };
|
||||
const small = { width: 500, height: 400 };
|
||||
const next = stepSwimmer(s, DT, small, MARGIN, SPEED, mulberry32(1));
|
||||
expect(next.targetX).toBeLessThanOrEqual(small.width - MARGIN);
|
||||
expect(next.targetY).toBeLessThanOrEqual(small.height - MARGIN);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pose", () => {
|
||||
const s: Swimmer = { x: 10, y: 20, vx: -30, vy: 0, targetX: 0, targetY: 0, facing: -1, age: 0 };
|
||||
|
||||
it("mirrors side-view pets to look where they swim", () => {
|
||||
expect(pose(s, creatureById("clownfish"), SPEED).mirror).toBe(-1);
|
||||
});
|
||||
|
||||
it("never mirrors a front-view pet", () => {
|
||||
expect(pose(s, creatureById("octopus"), SPEED).mirror).toBe(1);
|
||||
expect(pose(s, creatureById("pearlmussel"), SPEED).mirror).toBe(1);
|
||||
});
|
||||
});
|
||||
93
web/src/lib/tippen/__tests__/curriculum.test.ts
Normal file
93
web/src/lib/tippen/__tests__/curriculum.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
/** What's left client-side of the old curriculum.test.ts, now that the lesson plan's
|
||||
* content lives in the backend (see musicmouse/tippen/curriculum.py and its own tests
|
||||
* against the real curriculum file) - just `fromApi`'s wire-to-app-shape conversion and
|
||||
* the navigation helpers. */
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { TippenCurriculum as ApiCurriculum, TippenLesson as ApiLesson } from "../../../api/types";
|
||||
import { firstLessonId, fromApi, lessonById, nextLesson } from "../curriculum";
|
||||
|
||||
function apiLesson(over: Partial<ApiLesson> = {}): ApiLesson {
|
||||
return {
|
||||
id: "l01",
|
||||
world: 1,
|
||||
number: 1,
|
||||
title: "F und J",
|
||||
subtitle: "Die Zeigefinger",
|
||||
kind: "letters",
|
||||
new_keys: ["f", "j"],
|
||||
spotlight_keys: ["f", "j"],
|
||||
emphasis: "isolated",
|
||||
active_keys: ["f", "j"],
|
||||
primary_mode: "bubbles",
|
||||
bonus_modes: [],
|
||||
words: [],
|
||||
is_drill: false,
|
||||
chunks: 24,
|
||||
chunk_size: 3,
|
||||
reward: { resolved: false, album_id: null, has_cover: false, kind: null },
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
const API: ApiCurriculum = {
|
||||
worlds: [{ number: 1, title: "Die Grundstellung", emoji: "🏝️", reward: "clownfish" }],
|
||||
lessons: [apiLesson({ id: "l01", number: 1 }), apiLesson({ id: "l02", number: 2, new_keys: [] })],
|
||||
};
|
||||
|
||||
describe("fromApi", () => {
|
||||
it("converts snake_case wire fields to the app's own camelCase shape", () => {
|
||||
const curriculum = fromApi(API);
|
||||
expect(curriculum.worlds).toEqual([
|
||||
{ number: 1, title: "Die Grundstellung", emoji: "🏝️", reward: "clownfish" },
|
||||
]);
|
||||
const [lesson] = curriculum.lessons;
|
||||
expect(lesson).toMatchObject({
|
||||
id: "l01",
|
||||
newKeys: ["f", "j"],
|
||||
spotlightKeys: ["f", "j"],
|
||||
activeKeys: ["f", "j"],
|
||||
primaryMode: "bubbles",
|
||||
bonusModes: [],
|
||||
isDrill: false,
|
||||
chunkSize: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("carries a resolved reward through unchanged", () => {
|
||||
const api: ApiCurriculum = {
|
||||
worlds: API.worlds,
|
||||
lessons: [
|
||||
apiLesson({
|
||||
reward: { resolved: true, album_id: "abc123", has_cover: true, kind: "tracks" },
|
||||
}),
|
||||
],
|
||||
};
|
||||
expect(fromApi(api).lessons[0]!.reward).toEqual({
|
||||
resolved: true,
|
||||
albumId: "abc123",
|
||||
hasCover: true,
|
||||
kind: "tracks",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("navigation", () => {
|
||||
const curriculum = fromApi(API);
|
||||
|
||||
it("chains every lesson to the next and stops at the end", () => {
|
||||
expect(nextLesson(curriculum, "l01")?.id).toBe("l02");
|
||||
expect(nextLesson(curriculum, "l02")).toBeNull();
|
||||
expect(nextLesson(curriculum, "nope")).toBeNull();
|
||||
});
|
||||
|
||||
it("looks lessons up by id", () => {
|
||||
expect(lessonById(curriculum, "l01")?.number).toBe(1);
|
||||
expect(lessonById(curriculum, "nope")).toBeNull();
|
||||
});
|
||||
|
||||
it("names the first lesson", () => {
|
||||
expect(firstLessonId(curriculum)).toBe("l01");
|
||||
});
|
||||
});
|
||||
105
web/src/lib/tippen/__tests__/engine.test.ts
Normal file
105
web/src/lib/tippen/__tests__/engine.test.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { abandonRun, isTypingKey, press, startRun } from "../engine";
|
||||
import type { RunState } from "../engine";
|
||||
|
||||
/** Type a whole string, one key per 100ms, and hand back the final state. */
|
||||
function typeAll(target: string, keys: string, from = 1000): RunState {
|
||||
let state = startRun(target);
|
||||
[...keys].forEach((key, i) => {
|
||||
[state] = press(state, key, from + i * 100);
|
||||
});
|
||||
return state;
|
||||
}
|
||||
|
||||
describe("press", () => {
|
||||
it("advances on the right key", () => {
|
||||
const [state, events] = press(startRun("asdf"), "a", 0);
|
||||
expect(state.index).toBe(1);
|
||||
expect(events).toEqual([{ type: "correct", key: "a", index: 0, streak: 1 }]);
|
||||
});
|
||||
|
||||
it("does not advance on the wrong key", () => {
|
||||
const [state, events] = press(startRun("asdf"), "x", 0);
|
||||
expect(state.index).toBe(0);
|
||||
expect(events[0]).toMatchObject({ type: "wrong", key: "x", expected: "a", firstAt: true });
|
||||
});
|
||||
|
||||
it("counts a repeated wrong key at one position only once", () => {
|
||||
const state = typeAll("asdf", "xxxxx");
|
||||
expect(state.missed.size).toBe(1);
|
||||
expect(state.strokes).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("counts wrong keys at different positions separately", () => {
|
||||
const state = typeAll("asdf", "xaysz");
|
||||
expect(state.missed.size).toBe(3);
|
||||
});
|
||||
|
||||
it("accepts a capital where a lowercase letter is wanted", () => {
|
||||
const [state] = press(startRun("asdf"), "A", 0);
|
||||
expect(state.index).toBe(1);
|
||||
});
|
||||
|
||||
it("ignores modifiers and named keys", () => {
|
||||
const start = startRun("asdf");
|
||||
for (const key of ["Shift", "Control", "Backspace", "ArrowLeft", "F1", "Enter"]) {
|
||||
const [state, events] = press(start, key, 0);
|
||||
expect(state).toBe(start);
|
||||
expect(events).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it("starts the clock on the first keystroke, not before", () => {
|
||||
const fresh = startRun("as");
|
||||
expect(fresh.startedAt).toBeNull();
|
||||
const [state] = press(fresh, "a", 5000);
|
||||
expect(state.startedAt).toBe(5000);
|
||||
});
|
||||
|
||||
it("finishes on the last character and reports a result", () => {
|
||||
let state = startRun("as");
|
||||
[state] = press(state, "a", 0);
|
||||
const [done, events] = press(state, "s", 1000);
|
||||
expect(done.finishedAt).toBe(1000);
|
||||
const finished = events.find((event) => event.type === "finished");
|
||||
expect(finished).toBeDefined();
|
||||
expect(finished?.type === "finished" && finished.result.characters).toBe(2);
|
||||
});
|
||||
|
||||
it("does nothing once the run is over", () => {
|
||||
const done = typeAll("as", "as");
|
||||
const [state, events] = press(done, "a", 9999);
|
||||
expect(state).toBe(done);
|
||||
expect(events).toEqual([]);
|
||||
});
|
||||
|
||||
it("tracks and resets the streak", () => {
|
||||
expect(typeAll("asdf", "asd").streak).toBe(3);
|
||||
expect(typeAll("asdf", "asx").streak).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isTypingKey", () => {
|
||||
it("accepts single characters including umlauts and space", () => {
|
||||
for (const key of ["a", "ö", "ü", "ß", " ", "A"]) expect(isTypingKey(key)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects named keys", () => {
|
||||
for (const key of ["Enter", "Shift", "Tab", "ArrowUp"]) expect(isTypingKey(key)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("abandonRun", () => {
|
||||
it("grades what was typed so far", () => {
|
||||
const partial = typeAll("asdfjklö", "asdf");
|
||||
const stopped = abandonRun(partial, 2000);
|
||||
expect(stopped.finishedAt).toBe(2000);
|
||||
expect(stopped.index).toBe(4);
|
||||
});
|
||||
|
||||
it("leaves an untouched run alone", () => {
|
||||
const fresh = startRun("asdf");
|
||||
expect(abandonRun(fresh, 100)).toBe(fresh);
|
||||
});
|
||||
});
|
||||
102
web/src/lib/tippen/__tests__/fingers.test.ts
Normal file
102
web/src/lib/tippen/__tests__/fingers.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
FINGERS,
|
||||
HOME_ROW,
|
||||
KEYBOARD_ROWS,
|
||||
fingerOf,
|
||||
handOf,
|
||||
homeKeyOf,
|
||||
keysInRow,
|
||||
rowOf,
|
||||
shiftHandFor,
|
||||
} from "../fingers";
|
||||
|
||||
const ALPHABET = "abcdefghijklmnopqrstuvwxyzäöüß";
|
||||
|
||||
describe("fingerOf", () => {
|
||||
it("maps every German letter to exactly one finger", () => {
|
||||
for (const key of ALPHABET) expect(fingerOf(key), key).not.toBeNull();
|
||||
});
|
||||
|
||||
it("is case-insensitive", () => {
|
||||
for (const key of ALPHABET) {
|
||||
// "ß".toUpperCase() is "SS" - two characters, and not a key. `event.key` never
|
||||
// reports that, so the single-character case is the one that has to hold.
|
||||
const upper = key.toUpperCase();
|
||||
if ([...upper].length !== 1) continue;
|
||||
expect(fingerOf(upper)?.id, upper).toBe(fingerOf(key)?.id);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns null for keys that are not on the layout", () => {
|
||||
for (const key of ["Enter", "F1", "€", ""]) expect(fingerOf(key)).toBeNull();
|
||||
});
|
||||
|
||||
it("assigns the home row to the eight home fingers, left to right", () => {
|
||||
const expected = [
|
||||
"left-pinky",
|
||||
"left-ring",
|
||||
"left-middle",
|
||||
"left-index",
|
||||
"right-index",
|
||||
"right-middle",
|
||||
"right-ring",
|
||||
"right-pinky",
|
||||
];
|
||||
HOME_ROW.forEach((key, i) => expect(fingerOf(key)?.id).toBe(expected[i]));
|
||||
});
|
||||
|
||||
it("gives each finger the home key it actually rests on", () => {
|
||||
for (const key of HOME_ROW) expect(homeKeyOf(key)).toBe(key);
|
||||
expect(homeKeyOf(" ")).toBe(" ");
|
||||
});
|
||||
|
||||
it("sends the two index fingers to their stretch keys", () => {
|
||||
for (const key of "rtfgvb") expect(fingerOf(key)?.id).toBe("left-index");
|
||||
for (const key of "zuhjnm") expect(fingerOf(key)?.id).toBe("right-index");
|
||||
});
|
||||
});
|
||||
|
||||
describe("hands", () => {
|
||||
it("splits the letters into two disjoint, non-empty sets", () => {
|
||||
const left = [...ALPHABET].filter((key) => handOf(key) === "left");
|
||||
const right = [...ALPHABET].filter((key) => handOf(key) === "right");
|
||||
expect(left.length).toBeGreaterThan(0);
|
||||
expect(right.length).toBeGreaterThan(0);
|
||||
expect(left.length + right.length).toBe(ALPHABET.length);
|
||||
expect(left.some((key) => right.includes(key))).toBe(false);
|
||||
});
|
||||
|
||||
it("shifts with the opposite hand", () => {
|
||||
expect(shiftHandFor("a")).toBe("right");
|
||||
expect(shiftHandFor("l")).toBe("left");
|
||||
expect(shiftHandFor("Enter")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("rows", () => {
|
||||
it("places every letter in a row", () => {
|
||||
for (const key of ALPHABET) expect(rowOf(key), key).not.toBeNull();
|
||||
});
|
||||
|
||||
it("has the home row in the home row", () => {
|
||||
for (const key of HOME_ROW) expect(rowOf(key)).toBe("home");
|
||||
});
|
||||
|
||||
it("draws three rows, each with keys", () => {
|
||||
expect(KEYBOARD_ROWS).toHaveLength(3);
|
||||
for (const row of KEYBOARD_ROWS) expect(keysInRow(row).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FINGERS", () => {
|
||||
it("gives every finger a distinct hue so the colours can be named", () => {
|
||||
const hues = Object.values(FINGERS).map((finger) => finger.hue);
|
||||
expect(new Set(hues).size).toBe(hues.length);
|
||||
});
|
||||
|
||||
it("keeps each finger's id and key consistent", () => {
|
||||
for (const [id, finger] of Object.entries(FINGERS)) expect(finger.id).toBe(id);
|
||||
});
|
||||
});
|
||||
236
web/src/lib/tippen/__tests__/generator.test.ts
Normal file
236
web/src/lib/tippen/__tests__/generator.test.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { Lesson } from "../curriculum";
|
||||
import {
|
||||
chunkOffsets,
|
||||
drillChunks,
|
||||
letterStream,
|
||||
lineFor,
|
||||
lineText,
|
||||
mulberry32,
|
||||
wordChunks,
|
||||
} from "../generator";
|
||||
|
||||
const keys = ["a", "s", "d", "f"];
|
||||
|
||||
function lesson(over: Partial<Lesson> = {}): Lesson {
|
||||
return {
|
||||
id: "l01",
|
||||
world: 1,
|
||||
number: 1,
|
||||
title: "Test",
|
||||
subtitle: "",
|
||||
kind: "letters",
|
||||
newKeys: [],
|
||||
spotlightKeys: [],
|
||||
emphasis: null,
|
||||
activeKeys: keys,
|
||||
primaryMode: "dive",
|
||||
bonusModes: [],
|
||||
words: [],
|
||||
isDrill: false,
|
||||
chunks: 10,
|
||||
chunkSize: 4,
|
||||
reward: { resolved: false, albumId: null, hasCover: false, kind: null },
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
// A small spread of shapes - a plain letters lesson, one with real words, and one
|
||||
// whose active-key set differs from the others - standing in for the real curriculum's
|
||||
// variety without depending on its (now backend-fetched) content.
|
||||
const FIXTURE_LESSONS: readonly Lesson[] = [
|
||||
lesson({ id: "l01", number: 1, activeKeys: ["a", "s"] }),
|
||||
lesson({ id: "l02", number: 2, activeKeys: ["a", "s", "d", "f"] }),
|
||||
lesson({
|
||||
id: "l03",
|
||||
number: 3,
|
||||
kind: "words",
|
||||
activeKeys: [..."asdfjklö"],
|
||||
words: ["das", "sass", "fass"],
|
||||
}),
|
||||
];
|
||||
|
||||
describe("mulberry32", () => {
|
||||
it("is deterministic for a seed and different across seeds", () => {
|
||||
expect(drillChunks(keys, mulberry32(7))).toEqual(drillChunks(keys, mulberry32(7)));
|
||||
expect(drillChunks(keys, mulberry32(7))).not.toEqual(drillChunks(keys, mulberry32(8)));
|
||||
});
|
||||
|
||||
it("stays in [0, 1)", () => {
|
||||
const rng = mulberry32(3);
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
const value = rng();
|
||||
expect(value).toBeGreaterThanOrEqual(0);
|
||||
expect(value).toBeLessThan(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("drillChunks", () => {
|
||||
it("only ever emits active keys", () => {
|
||||
for (const fixture of FIXTURE_LESSONS) {
|
||||
const chunks = drillChunks(fixture.activeKeys, mulberry32(fixture.number), { chunks: 20 });
|
||||
const active = new Set(fixture.activeKeys);
|
||||
for (const char of chunks.join("")) expect(active.has(char)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("honours the requested shape", () => {
|
||||
const chunks = drillChunks(keys, mulberry32(1), { chunks: 7, chunkSize: 3 });
|
||||
expect(chunks).toHaveLength(7);
|
||||
for (const chunk of chunks) expect(chunk).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("over-represents the focus key once there are enough keys to spare", () => {
|
||||
const many = [..."asdfjklöei"];
|
||||
const plain = drillChunks(many, mulberry32(42), { chunks: 200 }).join("");
|
||||
const focused = drillChunks(many, mulberry32(42), { chunks: 200, focusKey: "e" }).join("");
|
||||
const count = (text: string) => [...text].filter((char) => char === "e").length;
|
||||
expect(count(focused)).toBeGreaterThan(count(plain));
|
||||
});
|
||||
|
||||
it("never lets one key take over a line", () => {
|
||||
// The failure this guards against: on a fresh profile every key looks equally
|
||||
// unpractised, and an unchecked focus weight drilled `a` for half of lesson 1
|
||||
// while three other fingers went untrained.
|
||||
for (const set of [[..."asdf"], [..."asdfjklö"], [..."asdfjklöei"]]) {
|
||||
for (const focusKey of set) {
|
||||
const text = drillChunks(set, mulberry32(3), { chunks: 200, focusKey }).join("");
|
||||
const share = [...text].filter((char) => char === focusKey).length / text.length;
|
||||
expect(share, `${focusKey} in ${set.join("")}`).toBeLessThanOrEqual(0.35);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("spreads a small key set evenly - every finger gets a turn", () => {
|
||||
const text = drillChunks(keys, mulberry32(11), { chunks: 200, focusKey: "a" }).join("");
|
||||
for (const key of keys) {
|
||||
const share = [...text].filter((char) => char === key).length / text.length;
|
||||
expect(share, key).toBeGreaterThan(0.15);
|
||||
}
|
||||
});
|
||||
|
||||
it("gives every finger a turn inside a single line, not just on average", () => {
|
||||
// The bag draw exists for exactly this. A real sampled line came out as
|
||||
// `saadaaafasaassfssffsafsf` - one `d` in twenty-four characters - which is
|
||||
// acceptable averaged over a hundred lines and useless for the one line she types.
|
||||
for (let seed = 0; seed < 60; seed++) {
|
||||
const text = drillChunks(keys, mulberry32(seed), { chunks: 6, chunkSize: 4 }).join("");
|
||||
for (const key of keys) {
|
||||
const count = [...text].filter((char) => char === key).length;
|
||||
expect(count, `"${key}" in "${text}" (seed ${seed})`).toBeGreaterThanOrEqual(4);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("still varies the order between seeds", () => {
|
||||
const a = drillChunks(keys, mulberry32(1), { chunks: 6 }).join("");
|
||||
const b = drillChunks(keys, mulberry32(2), { chunks: 6 }).join("");
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it("ignores a focus key that is not active, rather than looping", () => {
|
||||
const chunks = drillChunks(keys, mulberry32(1), { chunks: 5, focusKey: "z" });
|
||||
expect(chunks.join("")).not.toContain("z");
|
||||
});
|
||||
|
||||
it("returns nothing when there is nothing to type", () => {
|
||||
expect(drillChunks([], mulberry32(1))).toEqual([]);
|
||||
expect(drillChunks([" "], mulberry32(1))).toEqual([]);
|
||||
});
|
||||
|
||||
it("never emits a space inside a chunk", () => {
|
||||
const withSpace = ["a", "s", " "];
|
||||
expect(drillChunks(withSpace, mulberry32(5), { chunks: 20 }).join("")).not.toContain(" ");
|
||||
});
|
||||
});
|
||||
|
||||
describe("wordChunks", () => {
|
||||
it("returns null when a lesson has no words yet", () => {
|
||||
expect(wordChunks([], mulberry32(1))).toBeNull();
|
||||
});
|
||||
|
||||
it("draws only from the given list", () => {
|
||||
const words = ["die", "ei", "elf"];
|
||||
const chunks = wordChunks(words, mulberry32(2), { chunks: 10 })!;
|
||||
for (const chunk of chunks) expect(words).toContain(chunk);
|
||||
});
|
||||
|
||||
it("survives a single-word list", () => {
|
||||
expect(wordChunks(["ei"], mulberry32(1), { chunks: 4 })).toEqual(["ei", "ei", "ei", "ei"]);
|
||||
});
|
||||
|
||||
it("uses every word once before repeating any, across a long round", () => {
|
||||
// A real ten-sentence round from a four-sentence list showed one sentence four times.
|
||||
const words = ["a1", "b2", "c3", "d4", "e5", "f6", "g7", "h8"];
|
||||
for (let seed = 0; seed < 40; seed++) {
|
||||
const round = wordChunks(words, mulberry32(seed), { chunks: 8 })!;
|
||||
expect(new Set(round).size, `seed ${seed}: ${round.join(" ")}`).toBe(8);
|
||||
}
|
||||
});
|
||||
|
||||
it("never puts the same word twice in a row, even across a bag refill", () => {
|
||||
for (let seed = 0; seed < 60; seed++) {
|
||||
const round = wordChunks(["eins", "zwei", "drei"], mulberry32(seed), { chunks: 30 })!;
|
||||
for (let i = 1; i < round.length; i++) {
|
||||
expect(round[i], `seed ${seed} at ${i}: ${round.join(" ")}`).not.toBe(round[i - 1]);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("lineFor", () => {
|
||||
it("prefers real words once a lesson has them", () => {
|
||||
const withWords = FIXTURE_LESSONS.find((l) => l.words.length > 0)!;
|
||||
const chunks = lineFor(withWords, mulberry32(1));
|
||||
for (const chunk of chunks) expect(withWords.words).toContain(chunk);
|
||||
});
|
||||
|
||||
it("falls back to letters for a lesson with no words", () => {
|
||||
const noWords = FIXTURE_LESSONS[0]!;
|
||||
const chunks = lineFor(noWords, mulberry32(1));
|
||||
expect(chunks.length).toBeGreaterThan(0);
|
||||
for (const char of chunks.join("")) expect(noWords.activeKeys).toContain(char);
|
||||
});
|
||||
|
||||
it("can be asked for letters even in a word lesson", () => {
|
||||
const withWords = FIXTURE_LESSONS.find((l) => l.words.length > 0)!;
|
||||
const chunks = lineFor(withWords, mulberry32(1), { preferWords: false, chunks: 4 });
|
||||
for (const chunk of chunks) expect(withWords.words).not.toContain(chunk);
|
||||
});
|
||||
});
|
||||
|
||||
describe("lineText and chunkOffsets", () => {
|
||||
it("joins with spaces only once the space bar is taught", () => {
|
||||
expect(lineText(["as", "df"], true)).toBe("as df");
|
||||
expect(lineText(["as", "df"], false)).toBe("asdf");
|
||||
});
|
||||
|
||||
it("points each chunk at its own first character", () => {
|
||||
const chunks = ["as", "df", "jk"];
|
||||
for (const spaced of [true, false]) {
|
||||
const text = lineText(chunks, spaced);
|
||||
chunkOffsets(chunks, spaced).forEach((offset, i) => {
|
||||
expect(text.slice(offset, offset + chunks[i]!.length)).toBe(chunks[i]);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("letterStream", () => {
|
||||
it("produces one active letter per bubble", () => {
|
||||
const stream = letterStream(keys, mulberry32(9), 25);
|
||||
expect(stream).toHaveLength(25);
|
||||
for (const letter of stream) expect(keys).toContain(letter);
|
||||
});
|
||||
|
||||
it("shows every active letter across a round of bubbles", () => {
|
||||
const stream = letterStream(keys, mulberry32(9), 20);
|
||||
for (const key of keys) expect(stream).toContain(key);
|
||||
});
|
||||
|
||||
it("is empty when there is nothing to type", () => {
|
||||
expect(letterStream([], mulberry32(1), 10)).toEqual([]);
|
||||
});
|
||||
});
|
||||
184
web/src/lib/tippen/__tests__/grading.test.ts
Normal file
184
web/src/lib/tippen/__tests__/grading.test.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { press, startRun } from "../engine";
|
||||
import type { RunState } from "../engine";
|
||||
import {
|
||||
ANIMALS,
|
||||
SURPRISE_FROM,
|
||||
grade,
|
||||
isBetter,
|
||||
isPassed,
|
||||
visibleAnimals,
|
||||
starsFor,
|
||||
animalFor,
|
||||
animalIndex,
|
||||
animalProgress,
|
||||
} from "../grading";
|
||||
|
||||
/** A run of `target` where `wrong` positions get one wrong key first, paced so the whole
|
||||
* run takes exactly `durationMs`. */
|
||||
function run(target: string, wrongAt: number[] = [], durationMs = 60000): RunState {
|
||||
let state = startRun(target);
|
||||
const steps = target.length + wrongAt.length;
|
||||
const tick = durationMs / Math.max(1, steps - 1);
|
||||
let t = 0;
|
||||
for (let i = 0; i < target.length; i++) {
|
||||
if (wrongAt.includes(i)) {
|
||||
[state] = press(state, target[i] === "x" ? "q" : "x", t);
|
||||
t += tick;
|
||||
}
|
||||
[state] = press(state, target[i]!, t);
|
||||
t += tick;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
describe("grade", () => {
|
||||
it("measures characters per minute over exactly one minute", () => {
|
||||
const result = grade(run("a".repeat(60)));
|
||||
expect(result.characters).toBe(60);
|
||||
expect(result.speed).toBeCloseTo(60, 0);
|
||||
expect(result.accuracy).toBe(1);
|
||||
});
|
||||
|
||||
it("weights accuracy cubically", () => {
|
||||
// 90 correct, 10 wrong -> 90% accuracy, so 0.9^3 = 0.729 of the raw speed survives.
|
||||
const result = grade(run("a".repeat(90), Array.from({ length: 10 }, (_, i) => i)));
|
||||
expect(result.accuracy).toBeCloseTo(0.9, 2);
|
||||
expect(result.points / result.speed).toBeCloseTo(0.729, 3);
|
||||
});
|
||||
|
||||
it("never produces a negative score, however bad the run", () => {
|
||||
const result = grade(run("asdf", [0, 1, 2, 3]));
|
||||
expect(result.points).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it("does not divide by zero on an instant run", () => {
|
||||
let state = startRun("a");
|
||||
[state] = press(state, "a", 1000);
|
||||
const result = grade(state);
|
||||
expect(Number.isFinite(result.speed)).toBe(true);
|
||||
expect(result.speed).toBeLessThanOrEqual(60);
|
||||
});
|
||||
|
||||
it("counts a hammered wrong key once", () => {
|
||||
let state = startRun("as");
|
||||
for (const key of ["x", "x", "x", "a", "s"]) [state] = press(state, key, 0);
|
||||
expect(grade(state).errors).toBe(1);
|
||||
});
|
||||
|
||||
it("awards pearls even for a bad run", () => {
|
||||
expect(grade(run("a".repeat(20), [0, 1, 2, 3, 4, 5, 6, 7])).pearls).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stars", () => {
|
||||
it("uses the documented accuracy thresholds", () => {
|
||||
expect(starsFor(1)).toBe(3);
|
||||
expect(starsFor(0.97)).toBe(3);
|
||||
expect(starsFor(0.969)).toBe(2);
|
||||
expect(starsFor(0.93)).toBe(2);
|
||||
expect(starsFor(0.929)).toBe(1);
|
||||
expect(starsFor(0.85)).toBe(1);
|
||||
expect(starsFor(0.849)).toBe(0);
|
||||
});
|
||||
|
||||
it("gates the unlock at two stars and ignores speed entirely", () => {
|
||||
expect(isPassed(0.93)).toBe(true);
|
||||
expect(isPassed(0.929)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("animalFor", () => {
|
||||
it("returns the right animal at every boundary", () => {
|
||||
for (const animal of ANIMALS) {
|
||||
expect(animalFor(animal.from).id).toBe(animal.id);
|
||||
}
|
||||
});
|
||||
|
||||
it("clamps below the slowest and above the fastest", () => {
|
||||
expect(animalFor(0).id).toBe("snail");
|
||||
expect(animalFor(-5).id).toBe("snail");
|
||||
expect(animalFor(9999).id).toBe("orca");
|
||||
});
|
||||
|
||||
it("is monotone - more points never means a slower animal", () => {
|
||||
let seen = 0;
|
||||
for (let points = 0; points < 200; points += 1) {
|
||||
const index = ANIMALS.findIndex((animal) => animal.id === animalFor(points).id);
|
||||
expect(index).toBeGreaterThanOrEqual(seen);
|
||||
seen = index;
|
||||
}
|
||||
});
|
||||
|
||||
it("reports progress toward the next animal", () => {
|
||||
expect(animalProgress(15)).toBeCloseTo(0, 5);
|
||||
expect(animalProgress(20)).toBeCloseTo(0.5, 5);
|
||||
expect(animalProgress(9999)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ANIMALS", () => {
|
||||
it("gives every animal a distinct name and emoji", () => {
|
||||
expect(new Set(ANIMALS.map((animal) => animal.emoji)).size).toBe(ANIMALS.length);
|
||||
expect(new Set(ANIMALS.map((animal) => animal.name)).size).toBe(ANIMALS.length);
|
||||
expect(new Set(ANIMALS.map((animal) => animal.id)).size).toBe(ANIMALS.length);
|
||||
});
|
||||
|
||||
it("rises in speed with no gaps", () => {
|
||||
for (let i = 1; i < ANIMALS.length; i++) {
|
||||
expect(ANIMALS[i]!.from).toBeGreaterThan(ANIMALS[i - 1]!.from);
|
||||
}
|
||||
expect(ANIMALS[0]!.from).toBe(0);
|
||||
});
|
||||
|
||||
it("does not use the rating star as an animal", () => {
|
||||
for (const animal of ANIMALS) expect(animal.emoji).not.toBe("⭐");
|
||||
});
|
||||
});
|
||||
|
||||
describe("visibleAnimals", () => {
|
||||
it("always shows the ladder up to the dolphin, however slow the run", () => {
|
||||
const { animals } = visibleAnimals("snail", null);
|
||||
expect(animals.at(-1)!.id).toBe(SURPRISE_FROM);
|
||||
expect(animals.map((animal) => animal.id)).toContain("snail");
|
||||
});
|
||||
|
||||
it("keeps the animals above the dolphin hidden until they are reached", () => {
|
||||
const { animals, moreHidden } = visibleAnimals("jellyfish", "dolphin");
|
||||
expect(animals.some((animal) => animal.id === "shark")).toBe(false);
|
||||
expect(animals.some((animal) => animal.id === "orca")).toBe(false);
|
||||
expect(moreHidden).toBe(true);
|
||||
});
|
||||
|
||||
it("reveals a surprise animal once this run earns it", () => {
|
||||
const { animals } = visibleAnimals("shark", null);
|
||||
expect(animals.at(-1)!.id).toBe("shark");
|
||||
});
|
||||
|
||||
it("keeps a surprise animal revealed on later, slower runs", () => {
|
||||
const { animals, moreHidden } = visibleAnimals("crab", "shark");
|
||||
expect(animals.at(-1)!.id).toBe("shark");
|
||||
expect(moreHidden).toBe(true);
|
||||
});
|
||||
|
||||
it("stops promising more once the ladder is complete", () => {
|
||||
expect(visibleAnimals("orca", "orca").moreHidden).toBe(false);
|
||||
});
|
||||
|
||||
it("has something to keep secret in the first place", () => {
|
||||
expect(animalIndex(SURPRISE_FROM)).toBeLessThan(ANIMALS.length - 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isBetter", () => {
|
||||
it("prefers more stars over more points", () => {
|
||||
expect(isBetter({ stars: 3, points: 10 } as never, { stars: 2, points: 500 })).toBe(true);
|
||||
expect(isBetter({ stars: 2, points: 500 } as never, { stars: 3, points: 10 })).toBe(false);
|
||||
});
|
||||
|
||||
it("breaks a tie on points", () => {
|
||||
expect(isBetter({ stars: 2, points: 50 } as never, { stars: 2, points: 49 })).toBe(true);
|
||||
expect(isBetter({ stars: 2, points: 49 } as never, { stars: 2, points: 50 })).toBe(false);
|
||||
});
|
||||
});
|
||||
44
web/src/lib/tippen/__tests__/lessonPath.test.ts
Normal file
44
web/src/lib/tippen/__tests__/lessonPath.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { PATH_AMPLITUDE, pathD, pointFor, xOffsetFor } from "../lessonPath";
|
||||
|
||||
describe("xOffsetFor", () => {
|
||||
it("starts centred", () => {
|
||||
expect(xOffsetFor(0)).toBeCloseTo(0);
|
||||
});
|
||||
|
||||
it("never strays further than the amplitude", () => {
|
||||
for (let i = 0; i < 40; i++) {
|
||||
expect(Math.abs(xOffsetFor(i))).toBeLessThanOrEqual(PATH_AMPLITUDE + 1e-9);
|
||||
}
|
||||
});
|
||||
|
||||
it("is deterministic", () => {
|
||||
expect(xOffsetFor(5)).toBe(xOffsetFor(5));
|
||||
});
|
||||
});
|
||||
|
||||
describe("pointFor", () => {
|
||||
it("places nodes strictly further down as the index grows", () => {
|
||||
let previousY = -Infinity;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const point = pointFor(i);
|
||||
expect(point.y).toBeGreaterThan(previousY);
|
||||
previousY = point.y;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("pathD", () => {
|
||||
it("draws nothing for fewer than two points", () => {
|
||||
expect(pathD([])).toBe("");
|
||||
expect(pathD([{ x: 0, y: 0 }])).toBe("");
|
||||
});
|
||||
|
||||
it("starts at the first point and mentions every point", () => {
|
||||
const points = [pointFor(0), pointFor(1), pointFor(2)];
|
||||
const d = pathD(points);
|
||||
expect(d.startsWith(`M ${points[0]!.x} ${points[0]!.y}`)).toBe(true);
|
||||
for (const point of points) expect(d).toContain(String(point.x));
|
||||
});
|
||||
});
|
||||
67
web/src/lib/tippen/__tests__/progress.test.ts
Normal file
67
web/src/lib/tippen/__tests__/progress.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/** What's left client-side of the old progress.ts test suite, now that
|
||||
* `recordRun`/`freshProgress`/`migrate` live in the backend (see
|
||||
* musicmouse/tippen/progress.py and its own tests) - just the pure derivations that
|
||||
* still run here: which key to drill next, and how worn-in a key looks on the
|
||||
* on-screen keyboard. */
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { focusKeyFor, mastery } from "../progress";
|
||||
import type { Progress } from "../progress";
|
||||
|
||||
function basicProgress(over: Partial<Progress> = {}): Progress {
|
||||
return {
|
||||
lessons: {},
|
||||
keyStats: {},
|
||||
pearls: 0,
|
||||
aquarium: [],
|
||||
streak: { days: 0, lastPlayed: null },
|
||||
settings: { sound: true, keyboardHint: "auto" },
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("focusKeyFor", () => {
|
||||
it("has no focus key on a lesson that has never been played", () => {
|
||||
// Otherwise "pick an unpractised key" picks whichever sorts first and drills it half
|
||||
// the line, starving the other three fingers on lesson 1.
|
||||
expect(focusKeyFor(basicProgress(), ["a", "s", "d", "f"])).toBeNull();
|
||||
});
|
||||
|
||||
it("picks an unpractised key before a merely slow one", () => {
|
||||
const progress = basicProgress({ keyStats: { a: { ema: 5000, attempts: 50, errors: 20 } } });
|
||||
expect(focusKeyFor(progress, ["a", "s"])).toBe("s");
|
||||
});
|
||||
|
||||
it("picks the slowest and most error-prone once all are practised", () => {
|
||||
const progress = basicProgress({
|
||||
keyStats: {
|
||||
a: { ema: 300, attempts: 50, errors: 0 },
|
||||
s: { ema: 900, attempts: 50, errors: 10 },
|
||||
},
|
||||
});
|
||||
expect(focusKeyFor(progress, ["a", "s"])).toBe("s");
|
||||
});
|
||||
|
||||
it("ignores the space bar and copes with an empty lesson", () => {
|
||||
expect(focusKeyFor(basicProgress(), [" "])).toBeNull();
|
||||
expect(focusKeyFor(basicProgress(), [])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("mastery", () => {
|
||||
it("is zero until a key has been seen enough times", () => {
|
||||
const progress = basicProgress({ keyStats: { a: { ema: 200, attempts: 2, errors: 0 } } });
|
||||
expect(mastery(progress, "a")).toBe(0);
|
||||
expect(mastery(progress, "q")).toBe(0);
|
||||
});
|
||||
|
||||
it("rises with speed and accuracy, and stays within 0..1", () => {
|
||||
const stat = (ema: number, errors: number) =>
|
||||
basicProgress({ keyStats: { a: { ema, attempts: 100, errors } } });
|
||||
expect(mastery(stat(1500, 0), "a")).toBe(0);
|
||||
expect(mastery(stat(300, 0), "a")).toBe(1);
|
||||
expect(mastery(stat(900, 0), "a")).toBeCloseTo(0.5, 5);
|
||||
expect(mastery(stat(300, 50), "a")).toBeCloseTo(0.5, 5);
|
||||
});
|
||||
});
|
||||
208
web/src/lib/tippen/aquarium.ts
Normal file
208
web/src/lib/tippen/aquarium.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
/** The aquarium's pets, and how they swim.
|
||||
*
|
||||
* A pet moves in when a world is finished and then stays - not in a list on the home
|
||||
* screen, but swimming around behind every screen of the game. A reward that is always
|
||||
* in view, drifting past while she types, is the strongest version of "the reward
|
||||
* persists", and it costs nothing to look at.
|
||||
*
|
||||
* Pets are illustrations, speed trophies are emoji. The ladder in grading.ts changes
|
||||
* with every run; a pet arrives once and never leaves. Keeping the two in different
|
||||
* visual languages is what lets a turtle be both a speed trophy (🐢) and a pet (the
|
||||
* drawing) without a six-year-old having to work out which is which.
|
||||
*
|
||||
* The swimming lives here rather than in the component because it is the part worth
|
||||
* testing: `stepSwimmer` is a pure step - a swimmer and a time slice in, a swimmer out -
|
||||
* so "never leaves the tank" and "looks where it is going" are checkable without a DOM
|
||||
* or a clock. The component only calls it once per frame and writes the transform. */
|
||||
|
||||
export type CreatureId = "clownfish" | "octopus" | "seahorse" | "turtle" | "pearlmussel";
|
||||
|
||||
export interface Creature {
|
||||
id: CreatureId;
|
||||
name: string;
|
||||
/** For the sentence read aloud on arrival: "Die Krake ist ins Aquarium gezogen!" */
|
||||
article: "Der" | "Die" | "Das";
|
||||
/** Under public/, made from the original drawing by scripts/aquarium-bild.sh. */
|
||||
image: string;
|
||||
/** Height as a fraction of the stage height - so a pet is the same size relative to
|
||||
* the sea on a small laptop and on a big screen. */
|
||||
size: number;
|
||||
/** Cruising speed as a fraction of the stage width per second. Slow on purpose: these
|
||||
* are in the background of a typing drill, and anything darting reads as an event. */
|
||||
speed: number;
|
||||
/** Which way the drawing faces. Side-view creatures are mirrored to look where they
|
||||
* swim; front-view ones never are - an octopus flipping on every turn looks broken. */
|
||||
facing: "side" | "front";
|
||||
}
|
||||
|
||||
export const CREATURES: readonly Creature[] = [
|
||||
{ id: "clownfish", name: "Clownfisch", article: "Der", image: "/aquarium/clownfisch.webp", size: 0.13, speed: 0.045, facing: "side" },
|
||||
{ id: "octopus", name: "Krake", article: "Die", image: "/aquarium/krake.webp", size: 0.17, speed: 0.025, facing: "front" },
|
||||
{ id: "seahorse", name: "Seepferdchen", article: "Das", image: "/aquarium/seepferdchen.webp", size: 0.19, speed: 0.02, facing: "side" },
|
||||
{ id: "turtle", name: "Schildkröte", article: "Die", image: "/aquarium/schildkroete.webp", size: 0.17, speed: 0.032, facing: "side" },
|
||||
{ id: "pearlmussel", name: "Perlmuschel", article: "Die", image: "/aquarium/perlmuschel.webp", size: 0.12, speed: 0.016, facing: "front" },
|
||||
];
|
||||
|
||||
const CREATURE_BY_ID = new Map(CREATURES.map((creature) => [creature.id, creature]));
|
||||
|
||||
export function creatureById(id: CreatureId): Creature {
|
||||
// Every CreatureId is in CREATURES, so this cannot miss; the fallback only satisfies
|
||||
// `noUncheckedIndexedAccess`.
|
||||
return CREATURE_BY_ID.get(id) ?? CREATURES[0]!;
|
||||
}
|
||||
|
||||
/** Saves from before the pets were drawings stored the world's emoji. Each world kept
|
||||
* its slot, so the old emoji map one-to-one onto the creature that now fills it. */
|
||||
const LEGACY_EMOJI: Readonly<Record<string, CreatureId>> = {
|
||||
"🐠": "clownfish",
|
||||
"🐙": "octopus",
|
||||
"🦑": "seahorse",
|
||||
"🐳": "turtle",
|
||||
"🧜": "pearlmussel",
|
||||
};
|
||||
|
||||
/** A stored aquarium entry as a creature, or `null` for anything unrecognisable. */
|
||||
export function creatureFromRaw(raw: unknown): CreatureId | null {
|
||||
if (typeof raw !== "string") return null;
|
||||
if (CREATURE_BY_ID.has(raw as CreatureId)) return raw as CreatureId;
|
||||
return LEGACY_EMOJI[raw] ?? null;
|
||||
}
|
||||
|
||||
// --- swimming ---------------------------------------------------------------
|
||||
|
||||
export interface Tank {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface Swimmer {
|
||||
/** Centre, in px. */
|
||||
x: number;
|
||||
y: number;
|
||||
/** px per second. */
|
||||
vx: number;
|
||||
vy: number;
|
||||
/** Where it is currently drifting towards. */
|
||||
targetX: number;
|
||||
targetY: number;
|
||||
/** -1 looking left … 1 looking right. Eased rather than switched, so a turn is a
|
||||
* visible flip through the middle instead of a jump. */
|
||||
facing: number;
|
||||
/** Seconds swum, for the bob. Started at a random offset so pets do not bob in step. */
|
||||
age: number;
|
||||
}
|
||||
|
||||
/** How long the velocity takes to swing round to a new heading. Over a second, so every
|
||||
* change of course is a lazy curve and nothing ever jerks. */
|
||||
const STEER_TAU = 1.4;
|
||||
/** How long a turn-around takes. */
|
||||
const TURN_TAU = 0.25;
|
||||
/** One gentle bob per this many seconds. */
|
||||
const BOB_PERIOD = 3.6;
|
||||
|
||||
function randomTarget(tank: Tank, margin: number, rng: () => number): { x: number; y: number } {
|
||||
// A tank smaller than the creature (a collapsed window) still needs a valid target:
|
||||
// the middle.
|
||||
const span = (length: number) => Math.max(0, length - 2 * margin);
|
||||
return {
|
||||
x: margin + rng() * span(tank.width),
|
||||
y: margin + rng() * span(tank.height),
|
||||
};
|
||||
}
|
||||
|
||||
/** A new swimmer. `fromOutside` starts it just past a side edge, so a pet that has only
|
||||
* just been earned visibly swims in rather than popping into existence mid-screen. */
|
||||
export function createSwimmer(
|
||||
tank: Tank,
|
||||
margin: number,
|
||||
rng: () => number,
|
||||
fromOutside = false,
|
||||
): Swimmer {
|
||||
const target = randomTarget(tank, margin, rng);
|
||||
const start = fromOutside
|
||||
? { x: rng() < 0.5 ? -margin : tank.width + margin, y: target.y }
|
||||
: randomTarget(tank, margin, rng);
|
||||
return {
|
||||
x: start.x,
|
||||
y: start.y,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
targetX: target.x,
|
||||
targetY: target.y,
|
||||
facing: target.x >= start.x ? 1 : -1,
|
||||
age: rng() * BOB_PERIOD,
|
||||
};
|
||||
}
|
||||
|
||||
/** One time slice of swimming: steer towards the target, pick a new one on arrival, and
|
||||
* turn to face the direction of travel.
|
||||
*
|
||||
* `margin` is half the creature's size - how far its centre stays from the edges - and
|
||||
* `speed` its cruising speed in px/s. */
|
||||
export function stepSwimmer(
|
||||
s: Swimmer,
|
||||
dt: number,
|
||||
tank: Tank,
|
||||
margin: number,
|
||||
speed: number,
|
||||
rng: () => number,
|
||||
): Swimmer {
|
||||
let { targetX, targetY } = s;
|
||||
const inTank = (x: number, y: number) =>
|
||||
x >= margin && x <= tank.width - margin && y >= margin && y <= tank.height - margin;
|
||||
|
||||
// Arrived, or the window shrank and the target is now outside it: drift somewhere new.
|
||||
const dx = targetX - s.x;
|
||||
const dy = targetY - s.y;
|
||||
if (Math.hypot(dx, dy) < Math.max(margin, 24) || !inTank(targetX, targetY)) {
|
||||
const target = randomTarget(tank, margin, rng);
|
||||
targetX = target.x;
|
||||
targetY = target.y;
|
||||
}
|
||||
|
||||
const directionX = targetX - s.x;
|
||||
const directionY = targetY - s.y;
|
||||
const distance = Math.hypot(directionX, directionY) || 1;
|
||||
// Vertical drift at half speed: fish cruise, they do not climb.
|
||||
const targetVx = (directionX / distance) * speed;
|
||||
const targetVy = (directionY / distance) * speed * 0.5;
|
||||
|
||||
const steer = 1 - Math.exp(-dt / STEER_TAU);
|
||||
const vx = s.vx + (targetVx - s.vx) * steer;
|
||||
const vy = s.vy + (targetVy - s.vy) * steer;
|
||||
|
||||
// Only turn round once it is really swimming that way - hovering on the spot must not
|
||||
// make it flicker left and right.
|
||||
const targetFacing = Math.abs(vx) > speed * 0.2 ? Math.sign(vx) : Math.sign(s.facing) || 1;
|
||||
const facing = s.facing + (targetFacing - s.facing) * (1 - Math.exp(-dt / TURN_TAU));
|
||||
|
||||
return {
|
||||
x: s.x + vx * dt,
|
||||
y: s.y + vy * dt,
|
||||
vx,
|
||||
vy,
|
||||
targetX,
|
||||
targetY,
|
||||
facing,
|
||||
age: s.age + dt,
|
||||
};
|
||||
}
|
||||
|
||||
/** What the component draws for a swimmer: the bob and the tilt layered on top of the
|
||||
* position, and the mirroring for side-view drawings. */
|
||||
export function pose(
|
||||
s: Swimmer,
|
||||
creature: Creature,
|
||||
speed: number,
|
||||
): { x: number; y: number; mirror: number; rotation: number } {
|
||||
const bob = Math.sin((s.age / BOB_PERIOD) * 2 * Math.PI);
|
||||
const isSide = creature.facing === "side";
|
||||
// Nose up when rising, down when sinking - a few degrees, in the direction it faces.
|
||||
const tilt = speed > 0 ? Math.max(-1, Math.min(1, s.vy / speed)) : 0;
|
||||
return {
|
||||
x: s.x,
|
||||
y: s.y + bob * 7,
|
||||
mirror: isSide ? s.facing : 1,
|
||||
rotation: isSide ? tilt * 10 * Math.sign(s.facing || 1) : bob * 3,
|
||||
};
|
||||
}
|
||||
109
web/src/lib/tippen/curriculum.ts
Normal file
109
web/src/lib/tippen/curriculum.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/** The lesson plan, fetched from the backend rather than parsed from a YAML file at
|
||||
* build time - see `musicmouse/tippen/curriculum.py` for how it is loaded and
|
||||
* validated, and `musicmouse/tippen/rewards.py` for how a lesson's reward resolves
|
||||
* against the library. `fromApi` is the only place the wire's snake_case shape
|
||||
* (`api/types.ts`) meets this module's own camelCase one, which every other tippen
|
||||
* module (ported near-unchanged from the old standalone app) still expects. */
|
||||
|
||||
import type {
|
||||
TippenCurriculum as ApiCurriculum,
|
||||
TippenLesson as ApiLesson,
|
||||
TippenReward as ApiReward,
|
||||
} from "../../api/types";
|
||||
import type { CreatureId } from "./aquarium";
|
||||
|
||||
export type LessonKind = "letters" | "fragments" | "words" | "sentences";
|
||||
export type ModeId = "dive" | "bubbles" | "jellyfish" | "feed" | "race";
|
||||
|
||||
/** What this lesson unlocks in the music library, if anything - see `rewards.py`.
|
||||
* `resolved: false` means the curriculum names a path that matches nothing right now. */
|
||||
export interface MediaReward {
|
||||
resolved: boolean;
|
||||
albumId: string | null;
|
||||
hasCover: boolean;
|
||||
kind: "tracks" | "episode" | null;
|
||||
}
|
||||
|
||||
export interface Lesson {
|
||||
id: string;
|
||||
world: number;
|
||||
number: number;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
kind: LessonKind;
|
||||
newKeys: readonly string[];
|
||||
spotlightKeys: readonly string[];
|
||||
emphasis: "isolated" | "mixed" | null;
|
||||
activeKeys: readonly string[];
|
||||
primaryMode: ModeId;
|
||||
bonusModes: readonly ModeId[];
|
||||
words: readonly string[];
|
||||
isDrill: boolean;
|
||||
chunks: number;
|
||||
chunkSize: number;
|
||||
reward: MediaReward;
|
||||
}
|
||||
|
||||
export interface World {
|
||||
number: number;
|
||||
title: string;
|
||||
emoji: string;
|
||||
reward: CreatureId;
|
||||
}
|
||||
|
||||
export interface Curriculum {
|
||||
worlds: readonly World[];
|
||||
lessons: readonly Lesson[];
|
||||
}
|
||||
|
||||
function toReward(reward: ApiReward): MediaReward {
|
||||
return { resolved: reward.resolved, albumId: reward.album_id, hasCover: reward.has_cover, kind: reward.kind };
|
||||
}
|
||||
|
||||
function toLesson(lesson: ApiLesson): Lesson {
|
||||
return {
|
||||
id: lesson.id,
|
||||
world: lesson.world,
|
||||
number: lesson.number,
|
||||
title: lesson.title,
|
||||
subtitle: lesson.subtitle,
|
||||
kind: lesson.kind,
|
||||
newKeys: lesson.new_keys,
|
||||
spotlightKeys: lesson.spotlight_keys,
|
||||
emphasis: lesson.emphasis,
|
||||
activeKeys: lesson.active_keys,
|
||||
primaryMode: lesson.primary_mode,
|
||||
bonusModes: lesson.bonus_modes,
|
||||
words: lesson.words,
|
||||
isDrill: lesson.is_drill,
|
||||
chunks: lesson.chunks,
|
||||
chunkSize: lesson.chunk_size,
|
||||
reward: toReward(lesson.reward),
|
||||
};
|
||||
}
|
||||
|
||||
export function fromApi(curriculum: ApiCurriculum): Curriculum {
|
||||
return {
|
||||
worlds: curriculum.worlds.map((world) => ({
|
||||
number: world.number,
|
||||
title: world.title,
|
||||
emoji: world.emoji,
|
||||
reward: world.reward as CreatureId,
|
||||
})),
|
||||
lessons: curriculum.lessons.map(toLesson),
|
||||
};
|
||||
}
|
||||
|
||||
export function lessonById(curriculum: Curriculum, id: string): Lesson | null {
|
||||
return curriculum.lessons.find((lesson) => lesson.id === id) ?? null;
|
||||
}
|
||||
|
||||
export function nextLesson(curriculum: Curriculum, id: string): Lesson | null {
|
||||
const index = curriculum.lessons.findIndex((lesson) => lesson.id === id);
|
||||
if (index < 0) return null;
|
||||
return curriculum.lessons[index + 1] ?? null;
|
||||
}
|
||||
|
||||
export function firstLessonId(curriculum: Curriculum): string | null {
|
||||
return curriculum.lessons[0]?.id ?? null;
|
||||
}
|
||||
124
web/src/lib/tippen/engine.ts
Normal file
124
web/src/lib/tippen/engine.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
/** The typing engine: one keystroke in, a new state and a list of events out.
|
||||
*
|
||||
* Kept pure, the way ../../../web/src/lib/keyboard.ts keeps the player's key map pure -
|
||||
* so every rule below is testable without a DOM, and so the six game modes can all
|
||||
* drive the same logic while differing only in how they *draw* the target.
|
||||
*
|
||||
* Three rules here are deliberate choices for a six-year-old rather than the obvious
|
||||
* implementation, and each one is load-bearing:
|
||||
*
|
||||
* 1. A wrong key does not advance and does not insert. There is no backspace to
|
||||
* manage and no corrupted line to read back; the right key still has to be found.
|
||||
* 2. A wrong key counts once per position. Hammering the same wrong key five times in
|
||||
* a moment of panic is one mistake, not five, so one bad second cannot wreck a run.
|
||||
* 3. The clock starts on the first keystroke, not when the screen opens. Staring at
|
||||
* the screen, getting distracted, or being called away mid-thought is free. */
|
||||
|
||||
import { grade, type RunResult } from "./grading";
|
||||
|
||||
export interface Stroke {
|
||||
/** What was actually pressed, lowercased for letters. */
|
||||
key: string;
|
||||
/** What was wanted at that position. */
|
||||
expected: string;
|
||||
correct: boolean;
|
||||
/** ms timestamp, from the same clock `press` is called with. */
|
||||
at: number;
|
||||
}
|
||||
|
||||
export interface RunState {
|
||||
/** The full line being typed. */
|
||||
target: string;
|
||||
/** How far in we are - always an index into `target`, never past its length. */
|
||||
index: number;
|
||||
strokes: Stroke[];
|
||||
/** Positions where at least one wrong key has already been counted. Rule 2. */
|
||||
missed: ReadonlySet<number>;
|
||||
/** Consecutive correct keys, for the streak sound and the bubble chain. */
|
||||
streak: number;
|
||||
startedAt: number | null;
|
||||
finishedAt: number | null;
|
||||
}
|
||||
|
||||
export type RunEvent =
|
||||
| { type: "correct"; key: string; index: number; streak: number }
|
||||
| { type: "wrong"; key: string; expected: string; index: number; firstAt: boolean }
|
||||
| { type: "finished"; result: RunResult };
|
||||
|
||||
export function startRun(target: string): RunState {
|
||||
return {
|
||||
target,
|
||||
index: 0,
|
||||
strokes: [],
|
||||
missed: new Set(),
|
||||
streak: 0,
|
||||
startedAt: null,
|
||||
finishedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Keys that are never typing input: pressing Shift to reach a capital must not count
|
||||
* as a stroke of its own, and neither must a stray Alt or a browser shortcut's Meta. */
|
||||
const MODIFIERS = new Set(["Shift", "Control", "Alt", "AltGraph", "Meta", "CapsLock"]);
|
||||
|
||||
/** Is this a key the engine should look at at all? Anything longer than one code point
|
||||
* is a named key ("Enter", "ArrowLeft", "F1") and belongs to the app, not the run.
|
||||
* Backspace is swallowed on purpose: rule 1 means there is nothing to delete. */
|
||||
export function isTypingKey(key: string): boolean {
|
||||
if (MODIFIERS.has(key)) return false;
|
||||
return [...key].length === 1;
|
||||
}
|
||||
|
||||
export function isFinished(state: RunState): boolean {
|
||||
return state.finishedAt !== null;
|
||||
}
|
||||
|
||||
export function currentChar(state: RunState): string | null {
|
||||
return state.target[state.index] ?? null;
|
||||
}
|
||||
|
||||
/** Apply one keystroke. Returns the state unchanged (and no events) for anything that
|
||||
* is not typing input, or once the run is over, so the caller can stay dumb. */
|
||||
export function press(state: RunState, key: string, now: number): [RunState, RunEvent[]] {
|
||||
if (isFinished(state) || !isTypingKey(key)) return [state, []];
|
||||
|
||||
const expected = state.target[state.index];
|
||||
if (expected === undefined) return [state, []];
|
||||
|
||||
// The layout is what decides case, not the run: typing "A" where "a" is wanted is
|
||||
// correct. Capitals are their own lesson (world 4), and that lesson's target text
|
||||
// carries the capital, so this comparison still teaches Shift where it matters.
|
||||
const correct = key.toLowerCase() === expected.toLowerCase();
|
||||
const startedAt = state.startedAt ?? now;
|
||||
const stroke: Stroke = { key, expected, correct, at: now };
|
||||
const strokes = [...state.strokes, stroke];
|
||||
|
||||
if (!correct) {
|
||||
const firstAt = !state.missed.has(state.index);
|
||||
const missed = firstAt ? new Set(state.missed).add(state.index) : state.missed;
|
||||
const next: RunState = { ...state, strokes, missed, streak: 0, startedAt };
|
||||
return [next, [{ type: "wrong", key, expected, index: state.index, firstAt }]];
|
||||
}
|
||||
|
||||
const index = state.index + 1;
|
||||
const streak = state.streak + 1;
|
||||
const done = index >= state.target.length;
|
||||
const next: RunState = {
|
||||
...state,
|
||||
index,
|
||||
strokes,
|
||||
streak,
|
||||
startedAt,
|
||||
finishedAt: done ? now : null,
|
||||
};
|
||||
const events: RunEvent[] = [{ type: "correct", key, index: state.index, streak }];
|
||||
if (done) events.push({ type: "finished", result: grade(next) });
|
||||
return [next, events];
|
||||
}
|
||||
|
||||
/** Give up on the rest of the line - what Escape does. The run is still graded on what
|
||||
* was typed, so a half-finished bubbles round still earns its pearls. */
|
||||
export function abandonRun(state: RunState, now: number): RunState {
|
||||
if (isFinished(state) || state.startedAt === null) return state;
|
||||
return { ...state, finishedAt: now };
|
||||
}
|
||||
165
web/src/lib/tippen/fingers.ts
Normal file
165
web/src/lib/tippen/fingers.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
/** The German QWERTZ layout, as a finger map.
|
||||
*
|
||||
* This is the source of truth for three things that must never disagree: which finger
|
||||
* the on-screen keyboard colours a key with, which hand the hint names, and which home
|
||||
* key that finger returns to. Keeping them in one frozen table means a wrong finger
|
||||
* assignment is one edit to fix, not three.
|
||||
*
|
||||
* Keys are stored lowercase and compared lowercase - `event.key` for a capital letter
|
||||
* is "A", but it is still typed with the same finger as "a". */
|
||||
|
||||
export type Hand = "left" | "right";
|
||||
|
||||
/** Finger ids, left pinky through right pinky, thumbs last. The order matters: it is
|
||||
* the left-to-right order the parent screen lists them in. */
|
||||
export type FingerId =
|
||||
| "left-pinky"
|
||||
| "left-ring"
|
||||
| "left-middle"
|
||||
| "left-index"
|
||||
| "right-index"
|
||||
| "right-middle"
|
||||
| "right-ring"
|
||||
| "right-pinky"
|
||||
| "thumb";
|
||||
|
||||
export interface Finger {
|
||||
id: FingerId;
|
||||
hand: Hand;
|
||||
/** What a six-year-old is told out loud: "der kleine Finger links". */
|
||||
label: string;
|
||||
/** The key this finger rests on in the home row. */
|
||||
home: string;
|
||||
/** oklch hue for the keyboard overlay, so "der grüne Finger" is a thing you can say. */
|
||||
hue: number;
|
||||
}
|
||||
|
||||
export const FINGERS: Record<FingerId, Finger> = {
|
||||
"left-pinky": { id: "left-pinky", hand: "left", label: "kleiner Finger links", home: "a", hue: 25 },
|
||||
"left-ring": { id: "left-ring", hand: "left", label: "Ringfinger links", home: "s", hue: 70 },
|
||||
"left-middle": { id: "left-middle", hand: "left", label: "Mittelfinger links", home: "d", hue: 140 },
|
||||
"left-index": { id: "left-index", hand: "left", label: "Zeigefinger links", home: "f", hue: 195 },
|
||||
"right-index": { id: "right-index", hand: "right", label: "Zeigefinger rechts", home: "j", hue: 250 },
|
||||
"right-middle": { id: "right-middle", hand: "right", label: "Mittelfinger rechts", home: "k", hue: 290 },
|
||||
"right-ring": { id: "right-ring", hand: "right", label: "Ringfinger rechts", home: "l", hue: 330 },
|
||||
"right-pinky": { id: "right-pinky", hand: "right", label: "kleiner Finger rechts", home: "ö", hue: 10 },
|
||||
thumb: { id: "thumb", hand: "right", label: "Daumen", home: " ", hue: 220 },
|
||||
};
|
||||
|
||||
/** Which keys each finger owns, in the standard German assignment. The index fingers
|
||||
* carry two columns each (their home column plus the stretch inward), which is why
|
||||
* `left-index` has r/t and `right-index` has z/u. */
|
||||
const OWNED: Record<FingerId, string> = {
|
||||
"left-pinky": "^1qay<",
|
||||
"left-ring": "2wsx",
|
||||
"left-middle": "3edc",
|
||||
"left-index": "45rtfgvb",
|
||||
"right-index": "67zuhjnm",
|
||||
"right-middle": "8ik,",
|
||||
"right-ring": "9ol.",
|
||||
"right-pinky": "0ßpüöä-+#",
|
||||
thumb: " ",
|
||||
};
|
||||
|
||||
/** Which row a key sits in, for the on-screen keyboard's layout and for the lesson
|
||||
* titles ("nach oben", "nach unten"). */
|
||||
export type RowId = "numbers" | "top" | "home" | "bottom" | "space";
|
||||
|
||||
const ROWS: Record<RowId, string> = {
|
||||
numbers: "^1234567890ß",
|
||||
top: "qwertzuiopü+",
|
||||
home: "asdfghjklöä#",
|
||||
bottom: "<yxcvbnm,.-",
|
||||
space: " ",
|
||||
};
|
||||
|
||||
const KEY_TO_FINGER = new Map<string, FingerId>();
|
||||
for (const [finger, keys] of Object.entries(OWNED) as [FingerId, string][]) {
|
||||
for (const key of keys) KEY_TO_FINGER.set(key, finger);
|
||||
}
|
||||
|
||||
const KEY_TO_ROW = new Map<string, RowId>();
|
||||
for (const [row, keys] of Object.entries(ROWS) as [RowId, string][]) {
|
||||
for (const key of keys) KEY_TO_ROW.set(key, row);
|
||||
}
|
||||
|
||||
/** The three letter rows as the on-screen keyboard draws them, top to bottom. */
|
||||
export const KEYBOARD_ROWS: readonly RowId[] = ["top", "home", "bottom"];
|
||||
|
||||
export function keysInRow(row: RowId): readonly string[] {
|
||||
return [...(ROWS[row] ?? "")];
|
||||
}
|
||||
|
||||
/** Characters that need Shift on a German layout, mapped to the physical key that
|
||||
* carries them. Only the ones this course teaches; the rest of the number row can be
|
||||
* added when world 6 exists. */
|
||||
const SHIFTED: Record<string, string> = {
|
||||
"!": "1",
|
||||
'"': "2",
|
||||
"§": "3",
|
||||
$: "4",
|
||||
"%": "5",
|
||||
"&": "6",
|
||||
"/": "7",
|
||||
"(": "8",
|
||||
")": "9",
|
||||
"=": "0",
|
||||
"?": "ß",
|
||||
"*": "+",
|
||||
";": ",",
|
||||
":": ".",
|
||||
_: "-",
|
||||
"'": "#",
|
||||
">": "<",
|
||||
};
|
||||
|
||||
/** The physical key that produces `char`.
|
||||
*
|
||||
* A capital is its own lowercase key plus Shift, and "?" is the ß key plus Shift. The
|
||||
* distinction matters in three places: which key the on-screen keyboard lights up,
|
||||
* which finger the hint names, and whether a lesson can actually type a word. Without
|
||||
* it, "Wo ist der Delfin?" looks untypable and lights up nothing. */
|
||||
export function keyForChar(char: string): string {
|
||||
const lower = char.toLowerCase();
|
||||
if (lower !== char) return lower;
|
||||
return SHIFTED[char] ?? char;
|
||||
}
|
||||
|
||||
/** Whether reaching `char` needs a Shift held. */
|
||||
export function needsShift(char: string): boolean {
|
||||
return char.toLowerCase() !== char || char in SHIFTED;
|
||||
}
|
||||
|
||||
/** The finger that types `key`, or `null` for anything off the layout (Enter, F1, …).
|
||||
* Takes either a physical key or a character it produces - "A", "a" and "?" all
|
||||
* resolve. */
|
||||
export function fingerOf(key: string): Finger | null {
|
||||
const id = KEY_TO_FINGER.get(keyForChar(key));
|
||||
return id ? FINGERS[id] : null;
|
||||
}
|
||||
|
||||
export function handOf(key: string): Hand | null {
|
||||
return fingerOf(key)?.hand ?? null;
|
||||
}
|
||||
|
||||
export function rowOf(key: string): RowId | null {
|
||||
return KEY_TO_ROW.get(keyForChar(key)) ?? null;
|
||||
}
|
||||
|
||||
/** The home key the typing finger came from - what the hint shows as "zurück nach …". */
|
||||
export function homeKeyOf(key: string): string | null {
|
||||
return fingerOf(key)?.home ?? null;
|
||||
}
|
||||
|
||||
/** The home row itself, left to right. `SPACE_KEY` is separate because the thumb
|
||||
* is the one finger that does not rest on a letter. */
|
||||
export const HOME_ROW = ["a", "s", "d", "f", "j", "k", "l", "ö"] as const;
|
||||
export const SPACE_KEY = " ";
|
||||
|
||||
/** A shifted character is typed with the Shift on the *opposite* hand - the single rule
|
||||
* that separates real touch typing from hunt-and-peck with a pinky cramp. */
|
||||
export function shiftHandFor(key: string): Hand | null {
|
||||
const hand = handOf(key);
|
||||
if (hand === null) return null;
|
||||
return hand === "left" ? "right" : "left";
|
||||
}
|
||||
238
web/src/lib/tippen/generator.ts
Normal file
238
web/src/lib/tippen/generator.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
/** What the child actually types: drill lines built from a lesson's active keys.
|
||||
*
|
||||
* Seeded throughout (`mulberry32`), so a line is reproducible - which is what makes it
|
||||
* testable, and what lets the race mode replay a ghost against the identical text.
|
||||
*
|
||||
* Two ideas borrowed from keybr, simplified to what a six-year-old needs:
|
||||
*
|
||||
* - a *focus key* gets roughly double its natural share of the line, so the letter she
|
||||
* is slowest on is the letter she sees most;
|
||||
* - real words beat pseudo-words for motivation, so as soon as a lesson's active keys
|
||||
* can spell something real, the curated `words` list is preferred and `fjfj dkdk`
|
||||
* stops appearing.
|
||||
*
|
||||
* Chunks, not one long string: the line is returned as short groups, because four
|
||||
* letters with a gap after them is something a six-year-old can find her place in and
|
||||
* twenty-four letters in a row is not. The gap is a real space from lesson 1 on, even
|
||||
* before the space-bar lesson formally teaches the thumb - a gap she can see but is
|
||||
* never asked to type would be more confusing, not less. */
|
||||
|
||||
/** A small, fast, seedable PRNG. Identical seed, identical line. */
|
||||
export function mulberry32(seed: number): () => number {
|
||||
let a = seed >>> 0;
|
||||
return () => {
|
||||
a = (a + 0x6d2b79f5) >>> 0;
|
||||
let t = a;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
export type Rng = () => number;
|
||||
|
||||
export interface LineOptions {
|
||||
/** How many chunks the line has. */
|
||||
chunks?: number;
|
||||
/** Letters per chunk. */
|
||||
chunkSize?: number;
|
||||
/** The key to over-represent, if any. */
|
||||
focusKey?: string | null;
|
||||
/** The keys this lesson spotlights. They take the majority of the line; everything
|
||||
* learned earlier keeps appearing as review. */
|
||||
newKeys?: readonly string[];
|
||||
/** How much of the line the spotlighted keys should take, in place of the default
|
||||
* (see `NEW_KEY_SHARE`). An isolated round wants this high; a mixed round - the same
|
||||
* keys again, but blended with everything else - wants it lower. */
|
||||
newKeyShare?: number;
|
||||
}
|
||||
|
||||
/** The most of a line one key may ever occupy. Above this it stops being practice and
|
||||
* starts being a stutter - and on a small key set it starves the other fingers. */
|
||||
const MAX_FOCUS_SHARE = 0.3;
|
||||
|
||||
/** How much of a line the lesson's spotlighted keys should take by default. The rest is
|
||||
* review of everything learned so far, which is what stops the early lessons rotting
|
||||
* while the late ones are learned.
|
||||
*
|
||||
* Without this, lesson 2 ("the right hand") drew evenly from all eight home keys and
|
||||
* spent half the line on the left hand it had already taught - which is not what a
|
||||
* lesson called "the right hand" should drill. */
|
||||
const NEW_KEY_SHARE = 0.6;
|
||||
|
||||
/** Copies of each spotlighted key needed to reach `share` of the pool, clamped so a
|
||||
* lesson with one spotlighted key and many old ones does not bury the review entirely. */
|
||||
function newKeyCopies(newCount: number, oldCount: number, share: number): number {
|
||||
if (newCount === 0 || oldCount === 0) return 1;
|
||||
const exact = (share * oldCount) / (newCount * (1 - share));
|
||||
return Math.max(1, Math.min(6, Math.round(exact)));
|
||||
}
|
||||
|
||||
/** How many extra copies of the focus key to add to a pool of `n` letters without its
|
||||
* share passing `MAX_FOCUS_SHARE`. Small sets get no boost at all: with four active
|
||||
* keys every one of them is already drilled constantly. */
|
||||
function focusKeyCopies(n: number): number {
|
||||
let copies = 0;
|
||||
while (copies < 2 && (1 + copies + 1) / (n + copies + 1) <= MAX_FOCUS_SHARE) copies++;
|
||||
return copies;
|
||||
}
|
||||
|
||||
/** Build a weighted alphabet: every active key at least once, the lesson's new keys
|
||||
* several times, plus a few extra copies of the focus key. Drawing from this is the
|
||||
* whole weighting mechanic - no rejection sampling, and no chance of an endless loop
|
||||
* when the focus key is the only active key. */
|
||||
function weighted(
|
||||
activeKeys: readonly string[],
|
||||
focusKey: string | null | undefined,
|
||||
newKeys: readonly string[] = [],
|
||||
newKeyShare: number = NEW_KEY_SHARE,
|
||||
): string[] {
|
||||
const letters = activeKeys.filter((key) => key !== " ");
|
||||
if (letters.length === 0) return [];
|
||||
|
||||
// Shift and the space bar are taught by the target text, not by the letter pool.
|
||||
const newActive = newKeys.filter((key) => letters.includes(key));
|
||||
const oldActive = letters.filter((key) => !newActive.includes(key));
|
||||
|
||||
const pool = [...letters];
|
||||
if (newActive.length > 0 && oldActive.length > 0) {
|
||||
const copies = newKeyCopies(newActive.length, oldActive.length, newKeyShare);
|
||||
for (const key of newActive) for (let i = 1; i < copies; i++) pool.push(key);
|
||||
}
|
||||
|
||||
if (focusKey && letters.includes(focusKey)) {
|
||||
for (let i = 0; i < focusKeyCopies(letters.length); i++) pool.push(focusKey);
|
||||
}
|
||||
return pool;
|
||||
}
|
||||
|
||||
/** Draw from a shuffled bag rather than sampling independently.
|
||||
*
|
||||
* Independent sampling is lumpy over the length of one line, and lumpy is not a
|
||||
* cosmetic problem here: a real generated lesson-1 line came out as
|
||||
* `saadaaafasaassfssffsafsf` - `d` once in twenty-four characters, so the middle finger
|
||||
* got one repetition while the little finger got nine. Averaged over a hundred lines
|
||||
* that is fine; the child types one line.
|
||||
*
|
||||
* A bag fixes it by construction. Every key is drawn once before any key is drawn
|
||||
* twice, so every finger gets its turn within each pass, and the order inside a pass is
|
||||
* still random. Refilling on empty keeps it going for as long as the line needs. */
|
||||
function bagDraw(pool: readonly string[], count: number, rng: Rng): string[] {
|
||||
const out: string[] = [];
|
||||
let bag: string[] = [];
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
if (bag.length === 0) {
|
||||
bag = [...pool];
|
||||
// Fisher-Yates, so every ordering of the bag is equally likely.
|
||||
for (let j = bag.length - 1; j > 0; j--) {
|
||||
const k = Math.floor(rng() * (j + 1));
|
||||
[bag[j], bag[k]] = [bag[k]!, bag[j]!];
|
||||
}
|
||||
}
|
||||
out.push(bag.pop()!);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A line of pseudo-word chunks over the lesson's active keys. Used by every lesson,
|
||||
* and the only option for world 1 where nothing real can be spelled yet. */
|
||||
export function drillChunks(
|
||||
activeKeys: readonly string[],
|
||||
rng: Rng,
|
||||
options: LineOptions = {},
|
||||
): string[] {
|
||||
const { chunks = 6, chunkSize = 4, focusKey = null, newKeys = [], newKeyShare } = options;
|
||||
const pool = weighted(activeKeys, focusKey, newKeys, newKeyShare);
|
||||
if (pool.length === 0) return [];
|
||||
|
||||
const letters = bagDraw(pool, chunks * chunkSize, rng);
|
||||
const out: string[] = [];
|
||||
for (let i = 0; i < chunks; i++) {
|
||||
out.push(letters.slice(i * chunkSize, (i + 1) * chunkSize).join(""));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A line of real German words (or sentences), or `null` when the lesson has none yet.
|
||||
*
|
||||
* Drawn from the same shuffled bag as the letters. Once a round became twenty-five
|
||||
* words long, independent picks from an eight-word list started clumping: a real
|
||||
* world-5 round came out with "Das Meer ist blau, tief und kalt." four times in ten
|
||||
* sentences. The bag uses every word once before any word twice, so repeats are as far
|
||||
* apart as the list allows. */
|
||||
export function wordChunks(
|
||||
words: readonly string[],
|
||||
rng: Rng,
|
||||
options: LineOptions = {},
|
||||
): string[] | null {
|
||||
if (words.length === 0) return null;
|
||||
const { chunks = 5, focusKey = null } = options;
|
||||
|
||||
// Words containing the focus key go in twice, same trick as the letter pool.
|
||||
const pool = [...words];
|
||||
if (focusKey) {
|
||||
for (const word of words) {
|
||||
if (word.toLowerCase().includes(focusKey.toLowerCase())) pool.push(word);
|
||||
}
|
||||
}
|
||||
|
||||
const out = bagDraw(pool, chunks, rng);
|
||||
// A bag refill can put the last word of one pass straight after itself. Swap it with a
|
||||
// later word when there is one - but never loop, since a one-word list must still work.
|
||||
for (let i = 1; i < out.length; i++) {
|
||||
if (out[i] !== out[i - 1]) continue;
|
||||
const swapIndex = out.findIndex((word, j) => j > i && word !== out[i]);
|
||||
if (swapIndex > 0) [out[i], out[swapIndex]] = [out[swapIndex]!, out[i]!];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The line a lesson should show, given what it can spell. Word lessons alternate:
|
||||
* `preferWords` lets a mode ask for letters even in a late lesson (jellyfish mode is
|
||||
* always single letters) or for words wherever they exist (feed mode). */
|
||||
export function lineFor(
|
||||
lesson: { activeKeys: readonly string[]; words: readonly string[]; newKeys?: readonly string[] },
|
||||
rng: Rng,
|
||||
options: LineOptions & { preferWords?: boolean } = {},
|
||||
): string[] {
|
||||
const { preferWords = true, ...rest } = options;
|
||||
const withNew = { newKeys: lesson.newKeys ?? [], ...rest };
|
||||
if (preferWords) {
|
||||
const words = wordChunks(lesson.words, rng, withNew);
|
||||
if (words) return words;
|
||||
}
|
||||
return drillChunks(lesson.activeKeys, rng, withNew);
|
||||
}
|
||||
|
||||
/** Join chunks into the string the engine types against. */
|
||||
export function lineText(chunks: readonly string[], spaceActive: boolean): string {
|
||||
return chunks.join(spaceActive ? " " : "");
|
||||
}
|
||||
|
||||
/** Where each chunk starts in `lineText(chunks, spaceActive)` - what the Target
|
||||
* component needs to draw the gaps in the right places. */
|
||||
export function chunkOffsets(chunks: readonly string[], spaceActive: boolean): number[] {
|
||||
const offsets: number[] = [];
|
||||
let at = 0;
|
||||
for (const chunk of chunks) {
|
||||
offsets.push(at);
|
||||
at += chunk.length + (spaceActive ? 1 : 0);
|
||||
}
|
||||
return offsets;
|
||||
}
|
||||
|
||||
/** Single letters for the bubbles and jellyfish modes: one key per bubble, drawn from
|
||||
* the same bag, so the arcade modes drill the same spread as the dive mode. */
|
||||
export function letterStream(
|
||||
activeKeys: readonly string[],
|
||||
rng: Rng,
|
||||
count: number,
|
||||
focusKey?: string | null,
|
||||
newKeys: readonly string[] = [],
|
||||
newKeyShare?: number,
|
||||
): string[] {
|
||||
const pool = weighted(activeKeys, focusKey, newKeys, newKeyShare);
|
||||
if (pool.length === 0) return [];
|
||||
return bagDraw(pool, count, rng);
|
||||
}
|
||||
210
web/src/lib/tippen/grading.ts
Normal file
210
web/src/lib/tippen/grading.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
/** Turning a run into a number, a star count and an animal.
|
||||
*
|
||||
* Two decisions here are the whole pedagogy of the game, so they are worth stating:
|
||||
*
|
||||
* *Characters per minute, not words per minute.* German words are long and a
|
||||
* six-year-old types around four words a minute honestly measured. "4" on a results
|
||||
* screen reads as failure; "38 Zeichen pro Minute" reads as a number that visibly
|
||||
* grows. Same data, different message.
|
||||
*
|
||||
* *points = speed × accuracy³, not the textbook net WPM.* The standard formula is
|
||||
* `net = gross − errors/minute`, which goes negative for a beginner - the one result
|
||||
* that must never appear. The multiplicative form cannot: 95% accuracy keeps 86% of the
|
||||
* speed, 90% keeps 73%, 80% keeps 51%. Careful-and-slow beats fast-and-sloppy, which is
|
||||
* the habit worth building at this age.
|
||||
*
|
||||
* Crucially, speed only ever buys the *animal*. The unlock gate below is accuracy-only
|
||||
* (see `isPassed`), so a slow, careful child still reaches the end of the curriculum. */
|
||||
|
||||
import type { RunState, Stroke } from "./engine";
|
||||
|
||||
export interface RunResult {
|
||||
/** Correct keystrokes. */
|
||||
characters: number;
|
||||
/** Wrong keystrokes, counted once per position (see engine.ts rule 2). */
|
||||
errors: number;
|
||||
/** ms from the first keystroke to the last. */
|
||||
duration: number;
|
||||
/** Characters per minute. */
|
||||
speed: number;
|
||||
/** 0..1 */
|
||||
accuracy: number;
|
||||
/** `speed × accuracy³`, the number the animal is read off. */
|
||||
points: number;
|
||||
stars: 0 | 1 | 2 | 3;
|
||||
animal: AnimalId;
|
||||
/** Whether this run unlocks the next lesson on its own. */
|
||||
passed: boolean;
|
||||
/** Pearls earned - the aquarium currency. */
|
||||
pearls: number;
|
||||
/** Kept for the race-mode ghost and the per-key stats. */
|
||||
strokes: readonly Stroke[];
|
||||
}
|
||||
|
||||
export type AnimalId =
|
||||
| "snail"
|
||||
| "crab"
|
||||
| "turtle"
|
||||
| "jellyfish"
|
||||
| "fish"
|
||||
| "penguin"
|
||||
| "seal"
|
||||
| "dolphin"
|
||||
| "shark"
|
||||
| "orca";
|
||||
|
||||
export interface Animal {
|
||||
id: AnimalId;
|
||||
name: string;
|
||||
emoji: string;
|
||||
/** Lower bound in points (characters per minute, accuracy-weighted). */
|
||||
from: number;
|
||||
/** What the speech synthesis says when this animal is reached. */
|
||||
praise: string;
|
||||
}
|
||||
|
||||
/** Nine sea animals, slowest first. Thresholds are calibrated against the "5 WPM pro
|
||||
* Klassenstufe" school rule - roughly 25 characters/min at the end of first grade -
|
||||
* with plenty of headroom above it.
|
||||
*
|
||||
* The dolphin sits deliberately high, at about 24 WPM, one below the top. It is a
|
||||
* realistic second- or third-grade target, which means it stays out of reach and
|
||||
* therefore worth chasing for a long time. Reaching it should feel like an event.
|
||||
*
|
||||
* Every emoji here is checked to render as the animal it names - the first draft used
|
||||
* 🗡️ for a sailfish (it is a dagger) and 🎐 for a jellyfish (it is a wind chime), and
|
||||
* ⭐ for a starfish, which collided with the star rating three lines below it. */
|
||||
export const ANIMALS: readonly Animal[] = [
|
||||
{ id: "snail", name: "Meeresschnecke", emoji: "🐌", from: 0, praise: "Die Schnecke ist losgekrochen!" },
|
||||
{ id: "crab", name: "Krabbe", emoji: "🦀", from: 15, praise: "Eine Krabbe! Die krabbelt schon los." },
|
||||
{ id: "turtle", name: "Schildkröte", emoji: "🐢", from: 25, praise: "Die Schildkröte ist stetig und sicher." },
|
||||
{ id: "jellyfish", name: "Qualle", emoji: "🪼", from: 40, praise: "Die Qualle gleitet dahin!" },
|
||||
{ id: "fish", name: "Fisch", emoji: "🐟", from: 55, praise: "Ein Fisch! Der schwimmt richtig flott." },
|
||||
{ id: "penguin", name: "Pinguin", emoji: "🐧", from: 75, praise: "Ein Pinguin! Der flitzt durchs Wasser." },
|
||||
{ id: "seal", name: "Robbe", emoji: "🦭", from: 95, praise: "Die Robbe ist schnell und geschickt!" },
|
||||
{ id: "dolphin", name: "Delfin", emoji: "🐬", from: 120, praise: "Ein Delfin! Das ist richtig, richtig schnell." },
|
||||
{ id: "shark", name: "Hai", emoji: "🦈", from: 150, praise: "Ein Hai! Unglaublich schnell." },
|
||||
{ id: "orca", name: "Schwertwal", emoji: "🐋", from: 190, praise: "Ein Schwertwal! Schneller wird es im Meer nicht." },
|
||||
];
|
||||
|
||||
/** The last animal shown on the ladder before a run has been fast enough to earn it.
|
||||
* Everything above the dolphin stays hidden until it is actually reached - see
|
||||
* `visibleAnimals`. */
|
||||
export const SURPRISE_FROM: AnimalId = "dolphin";
|
||||
|
||||
export function animalIndex(id: AnimalId): number {
|
||||
return ANIMALS.findIndex((animal) => animal.id === id);
|
||||
}
|
||||
|
||||
/** Which slice of the ladder a result screen may show.
|
||||
*
|
||||
* Everything up to the dolphin is always visible, earned or not: seeing the animals you
|
||||
* have not reached yet is the whole reason to try again, and a six-year-old needs the
|
||||
* next rung to be visible to aim at it. Above the dolphin the ladder goes dark - those
|
||||
* are a surprise, revealed only once they have actually been reached, and then they stay
|
||||
* revealed. `bestEver` is the fastest animal earned on any lesson so far. */
|
||||
export function visibleAnimals(earned: AnimalId, bestEver: AnimalId | null): {
|
||||
animals: readonly Animal[];
|
||||
/** True when faster animals exist that have not been revealed yet. */
|
||||
moreHidden: boolean;
|
||||
} {
|
||||
const boundary = Math.max(
|
||||
animalIndex(SURPRISE_FROM),
|
||||
animalIndex(earned),
|
||||
bestEver ? animalIndex(bestEver) : -1,
|
||||
);
|
||||
return {
|
||||
animals: ANIMALS.slice(0, boundary + 1),
|
||||
moreHidden: boundary < ANIMALS.length - 1,
|
||||
};
|
||||
}
|
||||
|
||||
const ANIMAL_BY_ID = new Map(ANIMALS.map((animal) => [animal.id, animal]));
|
||||
|
||||
export function animalById(id: AnimalId): Animal {
|
||||
// Every AnimalId comes from ANIMALS itself, so this cannot miss - but the map lookup
|
||||
// is typed as possibly-undefined and `noUncheckedIndexedAccess` is on.
|
||||
return ANIMAL_BY_ID.get(id) ?? ANIMALS[0]!;
|
||||
}
|
||||
|
||||
/** The animal for a score. Walks from the fastest down, so the first match wins. */
|
||||
export function animalFor(points: number): Animal {
|
||||
for (let i = ANIMALS.length - 1; i >= 0; i--) {
|
||||
const animal = ANIMALS[i]!;
|
||||
if (points >= animal.from) return animal;
|
||||
}
|
||||
return ANIMALS[0]!;
|
||||
}
|
||||
|
||||
/** How far along the current animal this score is, 0..1 - drives the progress bar that
|
||||
* shows how close the next animal is. The top animal is always full. */
|
||||
export function animalProgress(points: number): number {
|
||||
const index = ANIMALS.findIndex((animal) => animal.id === animalFor(points).id);
|
||||
const next = ANIMALS[index + 1];
|
||||
if (!next) return 1;
|
||||
const floor = ANIMALS[index]!.from;
|
||||
return Math.min(1, Math.max(0, (points - floor) / (next.from - floor)));
|
||||
}
|
||||
|
||||
/** Star thresholds, on accuracy alone. The familiar 1/2/3 pattern from every other
|
||||
* game she will ever play, so it needs no explaining. */
|
||||
export const STAR_THRESHOLDS = { one: 0.85, two: 0.93, three: 0.97 } as const;
|
||||
|
||||
export function starsFor(accuracy: number): 0 | 1 | 2 | 3 {
|
||||
if (accuracy >= STAR_THRESHOLDS.three) return 3;
|
||||
if (accuracy >= STAR_THRESHOLDS.two) return 2;
|
||||
if (accuracy >= STAR_THRESHOLDS.one) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Two stars unlocks the next lesson. No speed condition anywhere - that is the point. */
|
||||
export function isPassed(accuracy: number): boolean {
|
||||
return accuracy >= STAR_THRESHOLDS.two;
|
||||
}
|
||||
|
||||
/** One pearl per five correct keys, plus a bonus per star. Small numbers that go up
|
||||
* every single run, including a bad one - the aquarium should never stall. */
|
||||
function pearlsFor(characters: number, stars: number): number {
|
||||
return Math.floor(characters / 5) + stars * 2;
|
||||
}
|
||||
|
||||
export function grade(state: RunState): RunResult {
|
||||
const characters = state.strokes.filter((stroke) => stroke.correct).length;
|
||||
const errors = state.missed.size;
|
||||
const start = state.startedAt;
|
||||
const end = state.finishedAt ?? state.strokes.at(-1)?.at ?? start;
|
||||
|
||||
// A run of one keystroke has no elapsed time between first and last. Treating that as
|
||||
// "infinitely fast" would hand out a top-tier animal for a single letter, so anything
|
||||
// under a second of real typing is scored as if it took a second.
|
||||
const duration = start !== null && end !== null ? Math.max(0, end - start) : 0;
|
||||
const minutes = Math.max(duration, 1000) / 60000;
|
||||
|
||||
const speed = characters / minutes;
|
||||
const attempts = characters + errors;
|
||||
const accuracy = attempts === 0 ? 0 : characters / attempts;
|
||||
const points = speed * accuracy ** 3;
|
||||
const stars = starsFor(accuracy);
|
||||
|
||||
return {
|
||||
characters,
|
||||
errors,
|
||||
duration,
|
||||
speed,
|
||||
accuracy,
|
||||
points,
|
||||
stars,
|
||||
animal: animalFor(points).id,
|
||||
passed: isPassed(accuracy),
|
||||
pearls: pearlsFor(characters, stars),
|
||||
strokes: state.strokes,
|
||||
};
|
||||
}
|
||||
|
||||
/** Which of two results is the better one, for the per-lesson personal best. Stars come
|
||||
* first, points break the tie - so a careful run is never displaced by a sloppy fast
|
||||
* one, and the best animal on a lesson card can never go down. */
|
||||
export function isBetter(candidate: RunResult, best: { stars: number; points: number }): boolean {
|
||||
if (candidate.stars !== best.stars) return candidate.stars > best.stars;
|
||||
return candidate.points > best.points;
|
||||
}
|
||||
45
web/src/lib/tippen/lessonPath.ts
Normal file
45
web/src/lib/tippen/lessonPath.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/** The geometry of the zigzag lesson path - kept pure and out of the component so it can
|
||||
* be tested the way `aquarium.ts`'s swim physics are. */
|
||||
|
||||
/** How many nodes make one full left-to-right-to-left swing. */
|
||||
export const PATH_PERIOD = 6;
|
||||
|
||||
/** The furthest a node strays from the centre line, in px. */
|
||||
export const PATH_AMPLITUDE = 110;
|
||||
|
||||
/** Vertical spacing between two nodes, in px. */
|
||||
export const NODE_SPACING = 108;
|
||||
|
||||
/** Horizontal offset for the nth node of a world's path, centred on 0. A sine wave
|
||||
* rather than a zigzag of straight segments, so the path reads as one smooth ribbon
|
||||
* instead of a jagged staircase. */
|
||||
export function xOffsetFor(indexInWorld: number): number {
|
||||
return Math.sin((indexInWorld / PATH_PERIOD) * 2 * Math.PI) * PATH_AMPLITUDE;
|
||||
}
|
||||
|
||||
export interface PathPoint {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
/** The centre of the nth node, for both its own placement and the connector line. */
|
||||
export function pointFor(indexInWorld: number): PathPoint {
|
||||
return { x: xOffsetFor(indexInWorld), y: indexInWorld * NODE_SPACING + NODE_SPACING / 2 };
|
||||
}
|
||||
|
||||
/** An SVG path string threading every node centre with a smooth curve - a straight
|
||||
* polyline through a sine wave looks faceted; a vertical Bezier through each segment
|
||||
* does not. Empty/one-point paths draw nothing, which is fine: a one-lesson world needs
|
||||
* no connector. */
|
||||
export function pathD(points: readonly PathPoint[]): string {
|
||||
if (points.length < 2) return "";
|
||||
const [first, ...rest] = points;
|
||||
let d = `M ${first!.x} ${first!.y}`;
|
||||
for (let i = 0; i < rest.length; i++) {
|
||||
const from = points[i]!;
|
||||
const to = rest[i]!;
|
||||
const midY = (from.y + to.y) / 2;
|
||||
d += ` C ${from.x} ${midY}, ${to.x} ${midY}, ${to.x} ${to.y}`;
|
||||
}
|
||||
return d;
|
||||
}
|
||||
12
web/src/lib/tippen/modeInfo.ts
Normal file
12
web/src/lib/tippen/modeInfo.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/** Labels and emoji for each game, shared by the result sheet's bonus buttons and the
|
||||
* lesson map's mode badge. */
|
||||
|
||||
import type { ModeId } from "./curriculum";
|
||||
|
||||
export const MODE_INFO: Record<ModeId, { emoji: string; name: string }> = {
|
||||
dive: { emoji: "🤿", name: "Tauchgang" },
|
||||
bubbles: { emoji: "🫧", name: "Blasenplatzen" },
|
||||
jellyfish: { emoji: "🦑", name: "Quallenalarm" },
|
||||
feed: { emoji: "🐟", name: "Fütterungszeit" },
|
||||
race: { emoji: "🐬", name: "Delfinrennen" },
|
||||
};
|
||||
56
web/src/lib/tippen/pop.ts
Normal file
56
web/src/lib/tippen/pop.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/** Sound feedback, ported verbatim in spirit from ../../web/src/lib/pop.ts - the same
|
||||
* short rising blip the music player uses, so the two apps sound like one family.
|
||||
*
|
||||
* The error sound is the one addition, and it is deliberately not a buzzer: a low, soft,
|
||||
* *falling* blip at a fraction of the volume. A six-year-old who is told "wrong!" twenty
|
||||
* times a minute stops playing. One that hears a quiet "bloop" just tries the next key. */
|
||||
|
||||
let context: AudioContext | null = null;
|
||||
|
||||
function blip(from: number, to: number, gainStart: number, duration: number): void {
|
||||
try {
|
||||
context ??= new AudioContext();
|
||||
const now = context.currentTime;
|
||||
const oscillator = context.createOscillator();
|
||||
const gain = context.createGain();
|
||||
oscillator.type = "sine";
|
||||
oscillator.frequency.setValueAtTime(from, now);
|
||||
oscillator.frequency.exponentialRampToValueAtTime(to, now + duration * 0.55);
|
||||
gain.gain.setValueAtTime(gainStart, now);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, now + duration);
|
||||
oscillator.connect(gain).connect(context.destination);
|
||||
oscillator.start();
|
||||
oscillator.stop(now + duration + 0.01);
|
||||
} catch {
|
||||
// No audio context before the first user gesture, and on some browsers never.
|
||||
}
|
||||
}
|
||||
|
||||
/** The music player's interaction blip: rising, bright. */
|
||||
export function playPop(frequency: number): void {
|
||||
blip(frequency, frequency * 1.8, 0.15, 0.15);
|
||||
}
|
||||
|
||||
/** A correct key. The pitch climbs with the streak, so a good run audibly builds - it
|
||||
* caps at an octave up, past which it just sounds shrill. */
|
||||
export function playCorrect(streak: number): void {
|
||||
const semitones = Math.min(streak, 12);
|
||||
blip(440 * 2 ** (semitones / 12), 660 * 2 ** (semitones / 12), 0.1, 0.09);
|
||||
}
|
||||
|
||||
/** A wrong key: low, falling, quiet. */
|
||||
export function playWrong(): void {
|
||||
blip(200, 150, 0.06, 0.12);
|
||||
}
|
||||
|
||||
/** Finishing a line. */
|
||||
export function playDone(): void {
|
||||
blip(520, 900, 0.16, 0.4);
|
||||
}
|
||||
|
||||
/** A new lesson, a new animal, a new creature in the aquarium. */
|
||||
export function playFanfare(): void {
|
||||
[523, 659, 784, 1047].forEach((frequency, i) => {
|
||||
window.setTimeout(() => blip(frequency, frequency * 1.5, 0.13, 0.28), i * 110);
|
||||
});
|
||||
}
|
||||
185
web/src/lib/tippen/progress.ts
Normal file
185
web/src/lib/tippen/progress.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
/** What stays client-side now that the backend owns `recordRun`/`freshProgress` (see
|
||||
* `musicmouse/tippen/progress.py`): the pure, per-keystroke derivations - which key to
|
||||
* drill next, how worn-in a key looks on the on-screen keyboard, and the fastest animal
|
||||
* earned anywhere, for the aquarium's headline stat. Progress itself is fetched once
|
||||
* per session and updated from each run-recording response; there is no local save. */
|
||||
|
||||
import type {
|
||||
TippenProgress as ApiProgress,
|
||||
TippenRunInput,
|
||||
TippenRunResult as ApiRunResult,
|
||||
TippenStroke,
|
||||
} from "../../api/types";
|
||||
import type { CreatureId } from "./aquarium";
|
||||
import type { AnimalId, RunResult } from "./grading";
|
||||
|
||||
export interface LessonProgress {
|
||||
unlocked: boolean;
|
||||
runs: number;
|
||||
bestStars: 0 | 1 | 2 | 3;
|
||||
bestAnimal: AnimalId | null;
|
||||
bestPoints: number;
|
||||
/** Best-run keystrokes, replayed as the opponent in race mode. */
|
||||
ghost: { key: string; at: number }[] | null;
|
||||
}
|
||||
|
||||
export interface KeyStat {
|
||||
ema: number;
|
||||
attempts: number;
|
||||
errors: number;
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
sound: boolean;
|
||||
keyboardHint: "auto" | "on" | "off";
|
||||
}
|
||||
|
||||
export interface Progress {
|
||||
lessons: Record<string, LessonProgress>;
|
||||
keyStats: Record<string, KeyStat>;
|
||||
pearls: number;
|
||||
/** Pets that have moved into the aquarium, in the order they arrived. */
|
||||
aquarium: CreatureId[];
|
||||
streak: { days: number; lastPlayed: string | null };
|
||||
settings: Settings;
|
||||
}
|
||||
|
||||
export function progressFromApi(progress: ApiProgress): Progress {
|
||||
const lessons: Record<string, LessonProgress> = {};
|
||||
for (const [id, entry] of Object.entries(progress.lessons)) {
|
||||
lessons[id] = {
|
||||
unlocked: entry.unlocked,
|
||||
runs: entry.runs,
|
||||
bestStars: entry.best_stars,
|
||||
bestAnimal: entry.best_animal as AnimalId | null,
|
||||
bestPoints: entry.best_points,
|
||||
ghost: entry.ghost,
|
||||
};
|
||||
}
|
||||
const keyStats: Record<string, KeyStat> = {};
|
||||
for (const [key, stat] of Object.entries(progress.key_stats)) {
|
||||
keyStats[key] = { ema: stat.ema, attempts: stat.attempts, errors: stat.errors };
|
||||
}
|
||||
return {
|
||||
lessons,
|
||||
keyStats,
|
||||
pearls: progress.pearls,
|
||||
aquarium: progress.aquarium as CreatureId[],
|
||||
streak: { days: progress.streak.days, lastPlayed: progress.streak.last_played },
|
||||
settings: { sound: progress.settings.sound, keyboardHint: progress.settings.keyboard_hint },
|
||||
};
|
||||
}
|
||||
|
||||
export function toRunInput(lessonId: string, result: RunResult): TippenRunInput {
|
||||
const strokes: TippenStroke[] = result.strokes.map((stroke) => ({
|
||||
key: stroke.key,
|
||||
expected: stroke.expected,
|
||||
correct: stroke.correct,
|
||||
at: stroke.at,
|
||||
}));
|
||||
return {
|
||||
lesson_id: lessonId,
|
||||
stars: result.stars,
|
||||
animal: result.animal,
|
||||
points: result.points,
|
||||
passed: result.passed,
|
||||
pearls: result.pearls,
|
||||
strokes,
|
||||
};
|
||||
}
|
||||
|
||||
export interface UnlockedReward {
|
||||
albumId: string;
|
||||
title: string;
|
||||
hasCover: boolean;
|
||||
kind: "album" | "book" | "podcast_episode";
|
||||
}
|
||||
|
||||
export interface RunOutcome {
|
||||
progress: Progress;
|
||||
unlockedLessonId: string | null;
|
||||
unlockedLessonTitle: string | null;
|
||||
newCreature: CreatureId | null;
|
||||
isNewBest: boolean;
|
||||
unlockedReward: UnlockedReward | null;
|
||||
}
|
||||
|
||||
export function runOutcomeFromApi(result: ApiRunResult): RunOutcome {
|
||||
return {
|
||||
progress: progressFromApi(result.progress),
|
||||
unlockedLessonId: result.unlocked_lesson_id,
|
||||
unlockedLessonTitle: result.unlocked_lesson_title,
|
||||
newCreature: result.new_creature as CreatureId | null,
|
||||
isNewBest: result.is_new_best,
|
||||
unlockedReward: result.unlocked_reward
|
||||
? {
|
||||
albumId: result.unlocked_reward.album_id,
|
||||
title: result.unlocked_reward.title,
|
||||
hasCover: result.unlocked_reward.has_cover,
|
||||
kind: result.unlocked_reward.kind,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** How many times a key must be typed before its stats mean anything. */
|
||||
const ENOUGH_ATTEMPTS = 3;
|
||||
|
||||
/** The key a lesson should drill hardest - the generator's focus key, or `null` for an
|
||||
* even spread.
|
||||
*
|
||||
* `null` on a brand-new lesson is the important case. Every key starts unpractised, so
|
||||
* "pick an unpractised key" would pick whichever sorted first and drill it half the
|
||||
* line - which on lesson 1 means typing `a` thirteen times out of twenty-four while
|
||||
* three other fingers go untrained. A lesson she has never played gets an even spread;
|
||||
* a focus key only emerges once there is evidence of what she is actually slow at. */
|
||||
export function focusKeyFor(progress: Progress, activeKeys: readonly string[]): string | null {
|
||||
const keys = activeKeys.filter((key) => key !== " ");
|
||||
if (keys.length === 0) return null;
|
||||
|
||||
const practiced = keys.filter((key) => (progress.keyStats[key]?.attempts ?? 0) >= ENOUGH_ATTEMPTS);
|
||||
if (practiced.length === 0) return null;
|
||||
|
||||
// Some keys practised and some not: the gap is the most useful thing to close.
|
||||
const unpracticed = keys.find((key) => (progress.keyStats[key]?.attempts ?? 0) < ENOUGH_ATTEMPTS);
|
||||
if (unpracticed) return unpracticed;
|
||||
|
||||
let worst: string | null = null;
|
||||
let worstScore = -Infinity;
|
||||
for (const key of keys) {
|
||||
const stat = progress.keyStats[key]!;
|
||||
// Errors weigh heavily: a key she gets wrong matters more than one she is merely
|
||||
// slow on, and 3000ms is well past the point where slow becomes a real hesitation.
|
||||
const score = stat.ema + (stat.errors / stat.attempts) * 3000;
|
||||
if (score > worstScore) {
|
||||
worstScore = score;
|
||||
worst = key;
|
||||
}
|
||||
}
|
||||
return worst;
|
||||
}
|
||||
|
||||
/** The fastest animal earned on any lesson so far. Drives the aquarium's headline stat
|
||||
* and, on the result screen, how far up the ladder is allowed to be revealed. */
|
||||
export function overallBestAnimal(progress: Progress): AnimalId | null {
|
||||
let best: AnimalId | null = null;
|
||||
let bestPoints = -1;
|
||||
for (const lesson of Object.values(progress.lessons)) {
|
||||
if (lesson.bestAnimal && lesson.bestPoints > bestPoints) {
|
||||
best = lesson.bestAnimal;
|
||||
bestPoints = lesson.bestPoints;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** How well a key is known, 0..1 - the on-screen keyboard's opacity, so the hint fades
|
||||
* away exactly where she no longer needs it. */
|
||||
export function mastery(progress: Progress, key: string): number {
|
||||
const stat = progress.keyStats[key.toLowerCase()];
|
||||
if (!stat || stat.attempts < 5) return 0;
|
||||
const accuracy = 1 - stat.errors / stat.attempts;
|
||||
// 600ms is about where a six-year-old's key press stops being a search.
|
||||
const speed = Math.max(0, Math.min(1, (1200 - stat.ema) / 600));
|
||||
return Math.max(0, Math.min(1, accuracy * speed));
|
||||
}
|
||||
39
web/src/lib/tippen/theme.ts
Normal file
39
web/src/lib/tippen/theme.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/** Feature toggles and the one hue this app is built on.
|
||||
*
|
||||
* Same role as ../../web/src/lib/theme.ts: flip a switch here rather than hunting
|
||||
* through components. The hue lives in styles/app.css because CSS is where it is used;
|
||||
* it is repeated here only for the canvas, which cannot read a custom property. */
|
||||
|
||||
/** The turquoise lagoon. Music is 210, Hörbücher 55, "Mein Zimmer" 300. */
|
||||
export const HUE = 175;
|
||||
|
||||
/** Frost the glass panels with a real backdrop blur. Expensive on weak GPUs. */
|
||||
export const SHOW_GLASS_BLUR = true;
|
||||
|
||||
/** The decorative rising bubbles behind everything. */
|
||||
export const SHOW_BUBBLES = true;
|
||||
|
||||
/** The earned pets swimming behind every screen. The reward that is always in view - off
|
||||
* only to rule it out when chasing a performance problem. */
|
||||
export const SHOW_AQUARIUM_CREATURES = true;
|
||||
|
||||
/** Fade screens in on entry. */
|
||||
export const ANIMATE_VIEW_TRANSITIONS = true;
|
||||
|
||||
/** Show the on-screen keyboard with the finger colours. "auto" fades it out key by key
|
||||
* as each one is mastered - the scaffold that removes itself, which is the whole point
|
||||
* of teaching touch typing rather than hunt-and-peck. */
|
||||
export const KEYBOARD_HINT_DEFAULT: "auto" | "on" | "off" = "auto";
|
||||
|
||||
/** Read the target letters and words aloud. She is six and still learning to read; a
|
||||
* missing reading skill must never block the typing skill. */
|
||||
export const SPEECH_DEFAULT = true;
|
||||
|
||||
export const SOUND_DEFAULT = true;
|
||||
|
||||
/** How many letters one bubbles or jellyfish round sends up - matched to the dive
|
||||
* mode's length so a mode swap is not also a difficulty swap. Dive-mode line length
|
||||
* lives on the lesson itself (`lengthFor` in lib/curriculum.ts). */
|
||||
export function bubbleCountFor(world: number): number {
|
||||
return world === 1 ? 50 : 100;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
import "./styles/app.css";
|
||||
import "./styles/room.css";
|
||||
import "./styles/tippen.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
|
||||
313
web/src/styles/tippen.css
Normal file
313
web/src/styles/tippen.css
Normal file
@@ -0,0 +1,313 @@
|
||||
/* The typing game's own corner of the Dolphin Beats design system - ported from the
|
||||
standalone tippen app's app.css (see its own header: "the tokens, the glass recipe,
|
||||
the radius scale and the entrance animations are deliberately identical [to
|
||||
app.css], so the two apps read as siblings. What changes is the hue: the music
|
||||
player's sea is 210, this is 175 - a turquoise lagoon, clearly the same ocean and
|
||||
clearly not the same room.").
|
||||
|
||||
Classes here are prefixed `tp-` and colours scoped under `.tp-stage` rather than
|
||||
redefined at `:root`, because app.css already claims the *same* custom property
|
||||
names (--ink, --paper, --accent, --shadow, ...) for the music player's own blue hue -
|
||||
redefining them globally would have silently reskinned one app or the other. Anything
|
||||
below with no hue baked into it - .bubble, .card, .key-cap, .view-enter,
|
||||
.backdrop-enter, and the dolphinBob/bubbleRise/viewEnter/backdropEnter keyframes - is
|
||||
identical between the two apps and reused directly from app.css instead of being
|
||||
duplicated here. */
|
||||
|
||||
.tp-stage {
|
||||
--ink: oklch(30% 0.04 175);
|
||||
--paper: oklch(97% 0.01 175);
|
||||
--accent: oklch(70% 0.16 340);
|
||||
--accent-dim: oklch(78% 0.14 340);
|
||||
--sea-deep: oklch(20% 0.045 175);
|
||||
--shadow: oklch(15% 0.05 175 / 0.35);
|
||||
/* This app's own additions: the feedback colours. Note there is no red - a wrong key
|
||||
is amber and gentle, never an alarm. */
|
||||
--correct: oklch(80% 0.17 150);
|
||||
--wrong: oklch(80% 0.13 75);
|
||||
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
oklch(55% 0.07 175) 0%,
|
||||
oklch(38% 0.06 175) 45%,
|
||||
var(--sea-deep) 100%
|
||||
);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* Its own stacking context, so the swimming pets (AquariumCreatures.tsx) paint above
|
||||
this gradient and below every screen, rather than below the page itself. */
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
/* Locked lessons stay visible, just dimmed - seeing what comes next is half the reason
|
||||
to finish what is open. Extends the shared .card rule from app.css; harmless on the
|
||||
music player's own cards, which never set this attribute. */
|
||||
.card[data-locked="true"] {
|
||||
cursor: default;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
/* None of these five collide with app.css's own keyframes (bubbleRise, dolphinBob,
|
||||
dolphinSwim, viewEnter, sheetEnter, backdropEnter), so they keep their plain names -
|
||||
only classes that would otherwise collide are `tp-`-prefixed. */
|
||||
@keyframes correctPop {
|
||||
0% { transform: scale(1); }
|
||||
40% { transform: scale(1.25); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
|
||||
/* A wrong key. A small wobble, not a buzz: it says "not that one, try again", and
|
||||
deliberately does not say "you failed". */
|
||||
@keyframes wrongShake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
25% { transform: translateX(-5px); }
|
||||
75% { transform: translateX(5px); }
|
||||
}
|
||||
|
||||
@keyframes keyPulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 oklch(97% 0.01 175 / 0.6); }
|
||||
50% { box-shadow: 0 0 0 9px oklch(97% 0.01 175 / 0); }
|
||||
}
|
||||
|
||||
@keyframes tierEnter {
|
||||
0% { opacity: 0; transform: scale(0.4) rotate(-12deg); }
|
||||
60% { opacity: 1; transform: scale(1.15) rotate(4deg); }
|
||||
100% { opacity: 1; transform: scale(1) rotate(0); }
|
||||
}
|
||||
|
||||
@keyframes sterneEnter {
|
||||
0% { opacity: 0; transform: scale(0.2); }
|
||||
100% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
/* Its own duration (240ms vs app.css's 200ms for the shared .sheet-enter) - kept as
|
||||
its own class for exactly that reason, but reuses app.css's identical `sheetEnter`
|
||||
keyframe body. */
|
||||
.tp-sheet-enter { animation: sheetEnter 240ms ease-out; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
/* Keep the colour feedback, drop the movement - covers the shared .bubble/.view-enter/
|
||||
.backdrop-enter classes too, which app.css does not otherwise handle. */
|
||||
.bubble,
|
||||
.view-enter,
|
||||
.tp-sheet-enter,
|
||||
.backdrop-enter {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.tp-glass-panel {
|
||||
background: linear-gradient(160deg, oklch(97% 0.01 175 / 0.14), oklch(97% 0.01 175 / 0.05));
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border: 1px solid oklch(97% 0.01 175 / 0.16);
|
||||
border-radius: 22px;
|
||||
box-shadow:
|
||||
0 8px 24px var(--shadow),
|
||||
inset 0 1px 0 oklch(100% 0 0 / 0.14);
|
||||
}
|
||||
|
||||
.tp-stage[data-blur="off"] .tp-glass-panel,
|
||||
.tp-stage[data-blur="off"] .card,
|
||||
.tp-stage[data-blur="off"] .tp-kb-key {
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
}
|
||||
|
||||
.tp-pill {
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
padding: 9px 15px;
|
||||
border-radius: 999px;
|
||||
white-space: nowrap;
|
||||
background: oklch(97% 0.01 175 / 0.16);
|
||||
color: var(--paper);
|
||||
}
|
||||
|
||||
.tp-pill[data-active="true"] {
|
||||
background: var(--paper);
|
||||
color: oklch(28% 0.04 175);
|
||||
box-shadow: 0 4px 14px var(--shadow);
|
||||
}
|
||||
|
||||
.tp-sheet {
|
||||
background: var(--paper);
|
||||
border-radius: 24px;
|
||||
box-shadow: 0 20px 50px oklch(10% 0.04 175 / 0.5);
|
||||
}
|
||||
|
||||
.tp-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: oklch(15% 0.03 175 / 0.55);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------- the on-screen keyboard -- */
|
||||
|
||||
.tp-kb {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
align-items: center;
|
||||
transition: opacity 400ms ease;
|
||||
}
|
||||
|
||||
.tp-kb-row {
|
||||
display: flex;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.tp-kb-key {
|
||||
position: relative;
|
||||
width: var(--kb-size, 42px);
|
||||
height: var(--kb-size, 42px);
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: calc(var(--kb-size, 42px) * 0.42);
|
||||
font-weight: 800;
|
||||
color: var(--paper);
|
||||
background: oklch(97% 0.01 175 / 0.1);
|
||||
border: 1px solid oklch(97% 0.01 175 / 0.14);
|
||||
backdrop-filter: blur(6px);
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
transform 0.1s ease;
|
||||
}
|
||||
|
||||
/* A key that this lesson does not use yet fades back so the eye goes to the ones that
|
||||
matter. It stays visible: the keyboard is a map, and a map with holes is confusing. */
|
||||
.tp-kb-key[data-active="false"] {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
/* Each finger owns a hue (lib/tippen/fingers.ts), so "der grüne Finger" is something
|
||||
you can say out loud to a six-year-old and have her understand it. Saturated on
|
||||
purpose - it has to survive being read at arm's length against a dark background. */
|
||||
.tp-kb-key[data-finger] {
|
||||
background: linear-gradient(
|
||||
160deg,
|
||||
oklch(68% 0.19 var(--finger-hue) / 0.92),
|
||||
oklch(52% 0.16 var(--finger-hue) / 0.8)
|
||||
);
|
||||
border-color: oklch(82% 0.13 var(--finger-hue) / 0.75);
|
||||
color: oklch(22% 0.05 var(--finger-hue));
|
||||
text-shadow: 0 1px 0 oklch(100% 0 0 / 0.25);
|
||||
}
|
||||
|
||||
.tp-kb-key[data-next="true"] {
|
||||
background: var(--paper);
|
||||
color: oklch(25% 0.05 175);
|
||||
transform: translateY(-3px) scale(1.08);
|
||||
animation: keyPulse 1.3s ease-out infinite;
|
||||
}
|
||||
|
||||
.tp-kb-key[data-home="true"]::after {
|
||||
/* The tactile bump on F and J, drawn so it can be pointed at on screen too. */
|
||||
content: "";
|
||||
position: absolute;
|
||||
transform: translateY(12px);
|
||||
width: 12px;
|
||||
height: 2px;
|
||||
border-radius: 2px;
|
||||
background: currentColor;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.tp-kb-space {
|
||||
width: calc(var(--kb-size, 42px) * 6);
|
||||
}
|
||||
|
||||
/* The two Shift keys, drawn either side of the space bar. Wider than a letter key so
|
||||
they read as the modifier they are, and dim until a capital actually needs one. */
|
||||
.tp-kb-shift {
|
||||
width: calc(var(--kb-size, 42px) * 1.8);
|
||||
background: oklch(97% 0.01 175 / 0.12);
|
||||
font-size: calc(var(--kb-size, 42px) * 0.4);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- the target line -- */
|
||||
|
||||
.tp-target {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 12px 26px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.02em;
|
||||
/* Wrapping beats shrinking: the line must never run into the edges of the screen.
|
||||
Target.tsx overrides this per block length (see `layoutFor`); this is only the
|
||||
fallback for a caller that does not. */
|
||||
max-width: min(900px, 88vw);
|
||||
}
|
||||
|
||||
.tp-target-chunk {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.tp-target-char {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 0.78em;
|
||||
/* Fixed at all times, not just while "current": if the current letter alone grew
|
||||
wider, the whole line would reflow around the cursor as she types, and chunks
|
||||
would visibly jump between lines mid-word. */
|
||||
padding: 0 6px;
|
||||
color: oklch(97% 0.01 175 / 0.45);
|
||||
transition: color 0.12s ease;
|
||||
}
|
||||
|
||||
.tp-target-char[data-state="done"] {
|
||||
color: var(--correct);
|
||||
}
|
||||
|
||||
.tp-target-char[data-state="current"] {
|
||||
color: oklch(25% 0.05 175);
|
||||
background: var(--paper);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 6px 18px var(--shadow);
|
||||
animation: correctPop 200ms ease-out;
|
||||
}
|
||||
|
||||
.tp-target-char[data-state="current"][data-wrong="true"] {
|
||||
background: var(--wrong);
|
||||
animation: wrongShake 260ms ease;
|
||||
}
|
||||
|
||||
/* A space inside the target needs a visible body, or the cursor lands on nothing. Only
|
||||
the space at the cursor shows ␣: marking every upcoming one turned a sentence into
|
||||
"Der␣Delfin␣schwimmt␣sehr␣schnell", which a six-year-old cannot read. */
|
||||
.tp-target-char[data-blank="true"] {
|
||||
min-width: 0.9em;
|
||||
}
|
||||
|
||||
/* A dome plus three trailing tentacles. Without them the jellyfish read as plain
|
||||
circles, and jellyfish mode stops being a picture of anything. Drawn in CSS rather
|
||||
than as an emoji so the letter stays centred and legible inside the dome. */
|
||||
.tp-jellyfish::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: -11px;
|
||||
left: 26%;
|
||||
right: 26%;
|
||||
height: 16px;
|
||||
opacity: 0.55;
|
||||
background:
|
||||
linear-gradient(currentColor, transparent) left / 3px 100% no-repeat,
|
||||
linear-gradient(currentColor, transparent) center / 3px 100% no-repeat,
|
||||
linear-gradient(currentColor, transparent) right / 3px 100% no-repeat;
|
||||
}
|
||||
Reference in New Issue
Block a user