diff --git a/python-backend/.gitignore b/python-backend/.gitignore index 258c6ae..51d5292 100644 --- a/python-backend/.gitignore +++ b/python-backend/.gitignore @@ -1,4 +1,5 @@ config.yml tippen-curriculum.yml +tippen-curriculum-dino.yml tippen-progress.json /.musicmouse-cache diff --git a/web/public/dinosaur-mascot.png b/web/public/dinosaur-mascot.png new file mode 100644 index 0000000..7c602ed Binary files /dev/null and b/web/public/dinosaur-mascot.png differ diff --git a/web/public/dinosaur-remote.png b/web/public/dinosaur-remote.png new file mode 100644 index 0000000..7711b96 Binary files /dev/null and b/web/public/dinosaur-remote.png differ diff --git a/web/public/dinosaur/apple-touch-icon.png b/web/public/dinosaur/apple-touch-icon.png new file mode 100644 index 0000000..0aa3245 Binary files /dev/null and b/web/public/dinosaur/apple-touch-icon.png differ diff --git a/web/public/dinosaur/icon-192.png b/web/public/dinosaur/icon-192.png new file mode 100644 index 0000000..f0880c6 Binary files /dev/null and b/web/public/dinosaur/icon-192.png differ diff --git a/web/public/dinosaur/icon-512.png b/web/public/dinosaur/icon-512.png new file mode 100644 index 0000000..9e613c1 Binary files /dev/null and b/web/public/dinosaur/icon-512.png differ diff --git a/web/public/dinosaur/icon-maskable-512.png b/web/public/dinosaur/icon-maskable-512.png new file mode 100644 index 0000000..544b596 Binary files /dev/null and b/web/public/dinosaur/icon-maskable-512.png differ diff --git a/web/public/dinosaur/manifest.webmanifest b/web/public/dinosaur/manifest.webmanifest new file mode 100644 index 0000000..94106be --- /dev/null +++ b/web/public/dinosaur/manifest.webmanifest @@ -0,0 +1,22 @@ +{ + "name": "Musik Dino", + "short_name": "Musik", + "description": "Musik und Hörbücher für die MusicMouse", + "start_url": "/?theme=dinosaur", + "scope": "/", + "display": "standalone", + "orientation": "any", + "background_color": "#1c311c", + "theme_color": "#1c311c", + "lang": "de", + "icons": [ + { "src": "/dinosaur/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" }, + { "src": "/dinosaur/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }, + { + "src": "/dinosaur/icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/web/public/sw.js b/web/public/sw.js index 22a4f77..56eacc7 100644 --- a/web/public/sw.js +++ b/web/public/sw.js @@ -3,7 +3,7 @@ // work offline - the audio, the library and the state all live on the mouse, and a // cached answer for any of those would be a lie. -const SHELL = "musikdelphin-shell-v1"; +const SHELL = "musikdelphin-shell-v2"; // There is no cover cache any more; `activate` below deletes every cache that is not // SHELL, which retires the two generations of cover cache this worker used to keep. @@ -16,6 +16,7 @@ self.addEventListener("install", (event) => { "/", "/manifest.webmanifest", "/dolphin-mascot.png", + "/dinosaur-mascot.png", "/fonts/nunito.css", "/fonts/nunito-latin.woff2", "/fonts/nunito-latin-ext.woff2", diff --git a/web/src/App.tsx b/web/src/App.tsx index c80ab0f..759ed99 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -40,6 +40,7 @@ import { playPop } from "./lib/pop"; import { targetForAlbum } from "./lib/remote"; import type { Group, Mode, Results, SongHit } from "./lib/search"; import { buildSearchIndex, categoryMatches, groupOf, results as computeResults } from "./lib/search"; +import { SKIN } from "./lib/skins"; import { SHOW_AMBIENCE, SHOW_CARD_BLUR, SHOW_GLASS_BLUR } from "./lib/theme"; // The room page and the typing game are each a tab away from the player and together a @@ -557,7 +558,7 @@ export function App() { fontWeight: 800, padding: "10px 20px", borderRadius: 999, - boxShadow: "0 8px 24px oklch(15% 0.05 210 / .4)", + boxShadow: "0 8px 24px oklch(15% 0.05 var(--hue) / .4)", }} > {assignStatus} @@ -654,7 +655,7 @@ export function App() { border: "none", borderRadius: 999, cursor: "pointer", - background: "oklch(15% 0.03 210 / .35)", + background: "oklch(15% 0.03 var(--hue) / .35)", color: "#fff", opacity: 0.5, }} @@ -680,7 +681,7 @@ export function App() { border: "none", borderRadius: 999, cursor: "pointer", - background: "oklch(15% 0.03 210 / .35)", + background: "oklch(15% 0.03 var(--hue) / .35)", color: "#fff", opacity: 0.5, }} @@ -734,10 +735,10 @@ function CornerButton({ display: "flex", alignItems: "center", justifyContent: "center", - background: "oklch(97% 0.01 210 / .95)", + background: "oklch(97% 0.01 var(--hue) / .95)", border: "none", borderRadius: 999, - boxShadow: "0 6px 18px oklch(15% 0.05 210 / .4)", + boxShadow: "0 6px 18px oklch(15% 0.05 var(--hue) / .4)", fontSize: 20, fontWeight: 900, color: "var(--ink)", @@ -762,7 +763,7 @@ function ConnectionDot({ online, state }: { online: boolean; state: { active_fig fontSize: 14, fontWeight: 800, color: online ? "var(--paper)" : "oklch(75% 0.16 30)", - background: "oklch(97% 0.01 210 / .16)", + background: "oklch(97% 0.01 var(--hue) / .16)", padding: "6px 14px", borderRadius: 999, }} @@ -776,15 +777,15 @@ function Splash({ error }: { error: string | null }) { return (
- {error ? "Der Delphin ist nicht erreichbar …" : "Einen Moment …"} + {error ? `${SKIN.mascotName} ist nicht erreichbar …` : "Einen Moment …"}
); diff --git a/web/src/components/AlbumModal.tsx b/web/src/components/AlbumModal.tsx index 3ec3e89..977383a 100644 --- a/web/src/components/AlbumModal.tsx +++ b/web/src/components/AlbumModal.tsx @@ -53,7 +53,7 @@ export function AlbumModal({ className={ANIMATE_VIEW_TRANSITIONS ? "overlay backdrop-enter" : "overlay"} style={{ zIndex: 4, - background: "oklch(15% 0.03 210 / .6)", + background: "oklch(15% 0.03 var(--hue) / .6)", ...(phone && { alignItems: "flex-end", padding: "calc(40px + env(safe-area-inset-top)) 0 0" }), }} onClick={onClose} @@ -63,14 +63,14 @@ export function AlbumModal({ className={ANIMATE_VIEW_TRANSITIONS ? "sheet-enter" : undefined} onClick={(event) => event.stopPropagation()} style={{ - background: "oklch(96% 0.012 210)", + background: "oklch(96% 0.012 var(--hue))", borderRadius: phone ? "26px 26px 0 0" : 26, padding: phone ? "20px 16px calc(20px + env(safe-area-inset-bottom))" : 26, width: phone ? "100%" : 640, maxWidth: "100%", maxHeight: "100%", overflow: "auto", - boxShadow: "0 24px 60px oklch(10% 0.04 210 / .55)", + boxShadow: "0 24px 60px oklch(10% 0.04 var(--hue) / .55)", }} >
@@ -88,7 +88,7 @@ export function AlbumModal({ style={{ fontSize: phone ? 20 : 26, fontWeight: 900, - color: "oklch(22% 0.03 210)", + color: "oklch(22% 0.03 var(--hue))", lineHeight: 1.15, }} > @@ -98,7 +98,7 @@ export function AlbumModal({ style={{ fontSize: 15, fontWeight: 700, - color: "oklch(45% 0.03 210 / .85)", + color: "oklch(45% 0.03 var(--hue) / .85)", marginTop: 2, }} > @@ -108,7 +108,7 @@ export function AlbumModal({ style={{ fontSize: 13, fontWeight: 700, - color: "oklch(50% 0.03 210 / .7)", + color: "oklch(50% 0.03 var(--hue) / .7)", marginTop: 2, }} > @@ -123,8 +123,8 @@ export function AlbumModal({ marginTop: 14, border: "none", cursor: album.locked ? "default" : "pointer", - background: album.locked ? "oklch(80% 0.02 210)" : "var(--accent)", - color: album.locked ? "oklch(40% 0.03 210)" : "#fff", + background: album.locked ? "oklch(80% 0.02 var(--hue))" : "var(--accent)", + color: album.locked ? "oklch(40% 0.03 var(--hue))" : "#fff", fontSize: phone ? 15 : 16, fontWeight: 800, padding: phone ? "10px 18px" : "12px 22px", @@ -132,7 +132,7 @@ export function AlbumModal({ display: "flex", alignItems: "center", gap: 10, - boxShadow: album.locked ? "none" : "0 4px 14px oklch(70% 0.16 340 / .45)", + boxShadow: album.locked ? "none" : "0 4px 14px oklch(70% 0.16 var(--accent-hue) / .45)", }} > {album.locked ? ( @@ -158,7 +158,7 @@ export function AlbumModal({ style={{ border: "none", cursor: "pointer", - background: "oklch(88% 0.02 210)", + background: "oklch(88% 0.02 var(--hue))", color: "var(--ink)", fontSize: 18, fontWeight: 900, @@ -193,8 +193,8 @@ export function AlbumModal({ textAlign: "left", opacity: track.locked ? 0.55 : 1, background: current - ? "oklch(70% 0.16 340 / .16)" - : "oklch(30% 0.03 210 / .05)", + ? "oklch(70% 0.16 var(--accent-hue) / .16)" + : "oklch(30% 0.03 var(--hue) / .05)", outline: !phone && index === selectedIndex ? "3px solid var(--accent)" : "none", outlineOffset: 2, }} @@ -209,8 +209,8 @@ export function AlbumModal({ alignItems: "center", justifyContent: "center", font: "800 13px ui-monospace, Menlo, monospace", - background: current ? "var(--accent)" : "oklch(88% 0.02 210)", - color: current ? "#fff" : "oklch(35% 0.03 210)", + background: current ? "var(--accent)" : "oklch(88% 0.02 var(--hue))", + color: current ? "#fff" : "oklch(35% 0.03 var(--hue))", }} > {track.locked ? "❓" : index + 1} @@ -220,7 +220,7 @@ export function AlbumModal({ style={{ fontSize: 16, fontWeight: 800, - color: current ? "oklch(45% 0.16 340)" : "oklch(24% 0.03 210)", + color: current ? "oklch(45% 0.16 var(--accent-hue))" : "oklch(24% 0.03 var(--hue))", }} > {track.locked ? "❓ Noch gesperrt" : track.title} @@ -230,7 +230,7 @@ export function AlbumModal({ style={{ fontSize: 12, fontWeight: 700, - color: "oklch(45% 0.03 210 / .8)", + color: "oklch(45% 0.03 var(--hue) / .8)", marginTop: 2, }} > @@ -243,7 +243,7 @@ export function AlbumModal({
{clock(track.duration)} diff --git a/web/src/components/Ambience.tsx b/web/src/components/Ambience.tsx index c276da6..2337a4f 100644 --- a/web/src/components/Ambience.tsx +++ b/web/src/components/Ambience.tsx @@ -20,6 +20,10 @@ * re-seeded from the server, read against `performance.now()`) but reads it from * inside the loop instead of `useState`, which is what keeps 60fps from touching * React at all. + * + * Under a skin whose `drift` is "leaves" the same particles fall instead of rising and + * are painted as tumbling leaves: same spawn rate, same current (a wind now, rather than + * water), same beat bursts - only the direction and the shape change. */ import { useEffect, useRef } from "react"; @@ -43,6 +47,7 @@ import { import type { AmbienceTunables, ManualControl } from "../lib/ambienceTunables"; import { AMBIENCE_QUALITY } from "../lib/lowPower"; import { groupOf, type Group } from "../lib/search"; +import { SKIN } from "../lib/skins"; /** Everything `?debugDynamicUI=1` wants to see: the raw analysis, what it maps to, * and where the eased, on-screen look currently sits relative to that target. */ @@ -92,17 +97,20 @@ const DEBUG_EMIT_INTERVAL_MS = 200; //: drive the kick), not *fetch* (the curve is wanted regardless of beat quality). const PULSE_THRESHOLD = 0.3; +/** A bubble or, under the leaves skin, a leaf - see the header. */ interface Bubble { /** Horizontal position in CSS pixels - stateful, unlike the old sine-wander's fixed * anchor: a persistent one-directional current has to be integrated over time. */ x: number; - /** Pixels risen since spawn. */ + /** Pixels travelled since spawn - risen for a bubble, fallen for a leaf. */ risen: number; /** Size at spawn. */ baseSize: number; /** Current painted size - grows from `baseSize` as the bubble rises. */ size: number; baseAlpha: number; + /** A leaf's starting angle and sway, radians - unused by a bubble. */ + phase: number; } //: How quickly the painted look eases toward the per-frame target - an exponential @@ -120,6 +128,11 @@ const BEAT_BURST_RATE = 6; const BASE_RISE_FRACTION_PER_SECOND = 0.12; const BUBBLE_MIN_SIZE = 8; const BUBBLE_MAX_SIZE = 24; +//: Leaves fall a little slower than bubbles rise, and are drawn larger - a leaf the +//: size of a small bubble is a speck. +const LEAF_SPEED = 0.7; +const LEAF_SCALE = 1.6; +const LEAVES = SKIN.drift === "leaves"; //: The canvas frame clamps to this, so a tab returning from the background after //: minutes away doesn't dump a giant `dt` into the physics in one jump. const MAX_FRAME_SECONDS = 0.05; @@ -171,9 +184,29 @@ function spawnBubble(width: number): Bubble { baseSize, size: baseSize, baseAlpha: 0.25 + Math.random() * 0.2, + phase: Math.random() * Math.PI * 2, }; } +/** An almond-shaped leaf with a midrib, centred on (x, y). */ +function paintLeaf(ctx: CanvasRenderingContext2D, x: number, y: number, size: number, angle: number): void { + const half = size / 2; + ctx.save(); + ctx.translate(x, y); + ctx.rotate(angle); + ctx.beginPath(); + ctx.moveTo(-half, 0); + ctx.quadraticCurveTo(0, -half * 0.75, half, 0); + ctx.quadraticCurveTo(0, half * 0.75, -half, 0); + ctx.fill(); + ctx.globalAlpha = 0.5; + ctx.beginPath(); + ctx.moveTo(-half * 0.8, 0); + ctx.lineTo(half * 0.8, 0); + ctx.stroke(); + ctx.restore(); +} + /** Turns already-smoothed energy/valence/drive into a full `Ambience` - shared * between the mount-time seed (unsmoothed, snapped straight to the first sample) and * every subsequent frame (smoothed with `tunables.curveSmoothingTau`), so the two @@ -411,14 +444,20 @@ export function Ambience({ album, state, tunables, manual, onDebugFrame }: Props // Not playing (and not in manual mode): no new spawns, but bubbles already in // flight keep rising and draining away rather than freezing mid-air. - const riseSpeed = current.rise * size.height * BASE_RISE_FRACTION_PER_SECOND; + const riseSpeed = + current.rise * size.height * BASE_RISE_FRACTION_PER_SECOND * (LEAVES ? LEAF_SPEED : 1); bubbles = bubbles.filter((b) => { - const heightFraction = clamp01(b.risen / size.height); + // Height above the bottom, which is what the current's bands are measured in. + const travelled = clamp01(b.risen / size.height); + const heightFraction = LEAVES ? 1 - travelled : travelled; const velocity = currentVelocityAt(heightFraction, kickedMagnitude); b.x = (((b.x + velocity * dt) % size.width) + size.width) % size.width; b.risen += riseSpeed * dt; - b.size = b.baseSize * (1 + tunables.bubbleGrowthRate * heightFraction); - return b.risen < size.height + BUBBLE_MAX_SIZE * 2; + // Bubbles swell as the pressure drops on the way up; a leaf stays a leaf. + b.size = LEAVES + ? b.baseSize * LEAF_SCALE + : b.baseSize * (1 + tunables.bubbleGrowthRate * heightFraction); + return b.risen < size.height + BUBBLE_MAX_SIZE * 2 * (LEAVES ? LEAF_SCALE : 1); }); // No `clearRect`: the gradient below is fully opaque (`oklchString` defaults to @@ -433,10 +472,21 @@ export function Ambience({ album, state, tunables, manual, onDebugFrame }: Props ctx.fillRect(0, 0, size.width, size.height); if (!reducedMotion) { + const third = size.height / 3; for (const b of bubbles) { + if (LEAVES) { + // Falling from above the top edge, fading out over the bottom third. + const y = b.risen - BUBBLE_MAX_SIZE * LEAF_SCALE; + const toBottom = size.height - y; + const fade = toBottom < third ? Math.max(0, toBottom / third) : 1; + const sway = Math.sin(b.risen / 60 + b.phase); + ctx.fillStyle = oklchString(current.tint, b.baseAlpha * fade); + ctx.strokeStyle = ctx.fillStyle; + paintLeaf(ctx, b.x + sway * 10, y, b.size, b.phase + b.risen / 140 + sway * 0.4); + continue; + } const y = size.height + BUBBLE_MAX_SIZE - b.risen; - const topThird = size.height / 3; - const fade = y < topThird ? Math.max(0, y / topThird) : 1; + const fade = y < third ? Math.max(0, y / third) : 1; ctx.beginPath(); ctx.fillStyle = oklchString(current.tint, b.baseAlpha * fade); ctx.arc(b.x, y, b.size / 2, 0, Math.PI * 2); diff --git a/web/src/components/AmbienceDebugOverlay.tsx b/web/src/components/AmbienceDebugOverlay.tsx index 3107d06..99e9e87 100644 --- a/web/src/components/AmbienceDebugOverlay.tsx +++ b/web/src/components/AmbienceDebugOverlay.tsx @@ -194,7 +194,7 @@ function ManualControlRow({ }} > - manual {active && ●} + manual {active && ●}