Compare commits
2 Commits
6981890be0
...
2a629faf50
| Author | SHA1 | Date | |
|---|---|---|---|
| 2a629faf50 | |||
| 21d65cad9e |
1
python-backend/.gitignore
vendored
1
python-backend/.gitignore
vendored
@@ -1,4 +1,5 @@
|
|||||||
config.yml
|
config.yml
|
||||||
tippen-curriculum.yml
|
tippen-curriculum.yml
|
||||||
|
tippen-curriculum-dino.yml
|
||||||
tippen-progress.json
|
tippen-progress.json
|
||||||
/.musicmouse-cache
|
/.musicmouse-cache
|
||||||
|
|||||||
BIN
web/public/dinosaur-mascot.png
Normal file
BIN
web/public/dinosaur-mascot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 977 KiB |
BIN
web/public/dinosaur-remote.png
Normal file
BIN
web/public/dinosaur-remote.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 986 KiB |
BIN
web/public/dinosaur/apple-touch-icon.png
Normal file
BIN
web/public/dinosaur/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
BIN
web/public/dinosaur/icon-192.png
Normal file
BIN
web/public/dinosaur/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
BIN
web/public/dinosaur/icon-512.png
Normal file
BIN
web/public/dinosaur/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 126 KiB |
BIN
web/public/dinosaur/icon-maskable-512.png
Normal file
BIN
web/public/dinosaur/icon-maskable-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
22
web/public/dinosaur/manifest.webmanifest
Normal file
22
web/public/dinosaur/manifest.webmanifest
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
// work offline - the audio, the library and the state all live on the mouse, and a
|
// 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.
|
// 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
|
// 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.
|
// SHELL, which retires the two generations of cover cache this worker used to keep.
|
||||||
@@ -16,6 +16,7 @@ self.addEventListener("install", (event) => {
|
|||||||
"/",
|
"/",
|
||||||
"/manifest.webmanifest",
|
"/manifest.webmanifest",
|
||||||
"/dolphin-mascot.png",
|
"/dolphin-mascot.png",
|
||||||
|
"/dinosaur-mascot.png",
|
||||||
"/fonts/nunito.css",
|
"/fonts/nunito.css",
|
||||||
"/fonts/nunito-latin.woff2",
|
"/fonts/nunito-latin.woff2",
|
||||||
"/fonts/nunito-latin-ext.woff2",
|
"/fonts/nunito-latin-ext.woff2",
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import { playPop } from "./lib/pop";
|
|||||||
import { targetForAlbum } from "./lib/remote";
|
import { targetForAlbum } from "./lib/remote";
|
||||||
import type { Group, Mode, Results, SongHit } from "./lib/search";
|
import type { Group, Mode, Results, SongHit } from "./lib/search";
|
||||||
import { buildSearchIndex, categoryMatches, groupOf, results as computeResults } 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";
|
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
|
// The room page and the typing game are each a tab away from the player and together a
|
||||||
@@ -446,6 +447,7 @@ export function App() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="stage"
|
className="stage"
|
||||||
|
data-phone={phone || undefined}
|
||||||
data-blur={SHOW_GLASS_BLUR ? undefined : "off"}
|
data-blur={SHOW_GLASS_BLUR ? undefined : "off"}
|
||||||
data-cardblur={SHOW_CARD_BLUR ? undefined : "off"}
|
data-cardblur={SHOW_CARD_BLUR ? undefined : "off"}
|
||||||
>
|
>
|
||||||
@@ -556,7 +558,7 @@ export function App() {
|
|||||||
fontWeight: 800,
|
fontWeight: 800,
|
||||||
padding: "10px 20px",
|
padding: "10px 20px",
|
||||||
borderRadius: 999,
|
borderRadius: 999,
|
||||||
boxShadow: "0 8px 24px oklch(15% 0.05 210 / .4)",
|
boxShadow: "0 8px 24px oklch(15% 0.05 var(--hue) / .4)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{assignStatus}
|
{assignStatus}
|
||||||
@@ -653,7 +655,7 @@ export function App() {
|
|||||||
border: "none",
|
border: "none",
|
||||||
borderRadius: 999,
|
borderRadius: 999,
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
background: "oklch(15% 0.03 210 / .35)",
|
background: "oklch(15% 0.03 var(--hue) / .35)",
|
||||||
color: "#fff",
|
color: "#fff",
|
||||||
opacity: 0.5,
|
opacity: 0.5,
|
||||||
}}
|
}}
|
||||||
@@ -679,7 +681,7 @@ export function App() {
|
|||||||
border: "none",
|
border: "none",
|
||||||
borderRadius: 999,
|
borderRadius: 999,
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
background: "oklch(15% 0.03 210 / .35)",
|
background: "oklch(15% 0.03 var(--hue) / .35)",
|
||||||
color: "#fff",
|
color: "#fff",
|
||||||
opacity: 0.5,
|
opacity: 0.5,
|
||||||
}}
|
}}
|
||||||
@@ -733,10 +735,10 @@ function CornerButton({
|
|||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
background: "oklch(97% 0.01 210 / .95)",
|
background: "oklch(97% 0.01 var(--hue) / .95)",
|
||||||
border: "none",
|
border: "none",
|
||||||
borderRadius: 999,
|
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,
|
fontSize: 20,
|
||||||
fontWeight: 900,
|
fontWeight: 900,
|
||||||
color: "var(--ink)",
|
color: "var(--ink)",
|
||||||
@@ -761,7 +763,7 @@ function ConnectionDot({ online, state }: { online: boolean; state: { active_fig
|
|||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: 800,
|
fontWeight: 800,
|
||||||
color: online ? "var(--paper)" : "oklch(75% 0.16 30)",
|
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",
|
padding: "6px 14px",
|
||||||
borderRadius: 999,
|
borderRadius: 999,
|
||||||
}}
|
}}
|
||||||
@@ -775,15 +777,15 @@ function Splash({ error }: { error: string | null }) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="overlay"
|
className="overlay"
|
||||||
style={{ zIndex: 10, background: "oklch(20% 0.045 210 / .92)", flexDirection: "column", gap: 18 }}
|
style={{ zIndex: 10, background: "oklch(20% 0.045 var(--hue) / .92)", flexDirection: "column", gap: 18 }}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src="/dolphin-mascot.png"
|
src={SKIN.mascot}
|
||||||
alt=""
|
alt=""
|
||||||
style={{ width: 140, height: 140, objectFit: "contain", animation: "dolphinBob 1.6s ease-in-out infinite" }}
|
style={{ width: 140, height: 140, objectFit: "contain", animation: "mascotBob 1.6s ease-in-out infinite" }}
|
||||||
/>
|
/>
|
||||||
<div style={{ fontSize: 22, fontWeight: 800, color: "var(--paper)" }}>
|
<div style={{ fontSize: 22, fontWeight: 800, color: "var(--paper)" }}>
|
||||||
{error ? "Der Delphin ist nicht erreichbar …" : "Einen Moment …"}
|
{error ? `${SKIN.mascotName} ist nicht erreichbar …` : "Einen Moment …"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export function AlbumModal({
|
|||||||
className={ANIMATE_VIEW_TRANSITIONS ? "overlay backdrop-enter" : "overlay"}
|
className={ANIMATE_VIEW_TRANSITIONS ? "overlay backdrop-enter" : "overlay"}
|
||||||
style={{
|
style={{
|
||||||
zIndex: 4,
|
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" }),
|
...(phone && { alignItems: "flex-end", padding: "calc(40px + env(safe-area-inset-top)) 0 0" }),
|
||||||
}}
|
}}
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
@@ -63,14 +63,14 @@ export function AlbumModal({
|
|||||||
className={ANIMATE_VIEW_TRANSITIONS ? "sheet-enter" : undefined}
|
className={ANIMATE_VIEW_TRANSITIONS ? "sheet-enter" : undefined}
|
||||||
onClick={(event) => event.stopPropagation()}
|
onClick={(event) => event.stopPropagation()}
|
||||||
style={{
|
style={{
|
||||||
background: "oklch(96% 0.012 210)",
|
background: "oklch(96% 0.012 var(--hue))",
|
||||||
borderRadius: phone ? "26px 26px 0 0" : 26,
|
borderRadius: phone ? "26px 26px 0 0" : 26,
|
||||||
padding: phone ? "20px 16px calc(20px + env(safe-area-inset-bottom))" : 26,
|
padding: phone ? "20px 16px calc(20px + env(safe-area-inset-bottom))" : 26,
|
||||||
width: phone ? "100%" : 640,
|
width: phone ? "100%" : 640,
|
||||||
maxWidth: "100%",
|
maxWidth: "100%",
|
||||||
maxHeight: "100%",
|
maxHeight: "100%",
|
||||||
overflow: "auto",
|
overflow: "auto",
|
||||||
boxShadow: "0 24px 60px oklch(10% 0.04 210 / .55)",
|
boxShadow: "0 24px 60px oklch(10% 0.04 var(--hue) / .55)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ display: "flex", gap: phone ? 14 : 20, alignItems: "flex-start" }}>
|
<div style={{ display: "flex", gap: phone ? 14 : 20, alignItems: "flex-start" }}>
|
||||||
@@ -88,7 +88,7 @@ export function AlbumModal({
|
|||||||
style={{
|
style={{
|
||||||
fontSize: phone ? 20 : 26,
|
fontSize: phone ? 20 : 26,
|
||||||
fontWeight: 900,
|
fontWeight: 900,
|
||||||
color: "oklch(22% 0.03 210)",
|
color: "oklch(22% 0.03 var(--hue))",
|
||||||
lineHeight: 1.15,
|
lineHeight: 1.15,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -98,7 +98,7 @@ export function AlbumModal({
|
|||||||
style={{
|
style={{
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
color: "oklch(45% 0.03 210 / .85)",
|
color: "oklch(45% 0.03 var(--hue) / .85)",
|
||||||
marginTop: 2,
|
marginTop: 2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -108,7 +108,7 @@ export function AlbumModal({
|
|||||||
style={{
|
style={{
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
color: "oklch(50% 0.03 210 / .7)",
|
color: "oklch(50% 0.03 var(--hue) / .7)",
|
||||||
marginTop: 2,
|
marginTop: 2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -123,8 +123,8 @@ export function AlbumModal({
|
|||||||
marginTop: 14,
|
marginTop: 14,
|
||||||
border: "none",
|
border: "none",
|
||||||
cursor: album.locked ? "default" : "pointer",
|
cursor: album.locked ? "default" : "pointer",
|
||||||
background: album.locked ? "oklch(80% 0.02 210)" : "var(--accent)",
|
background: album.locked ? "oklch(80% 0.02 var(--hue))" : "var(--accent)",
|
||||||
color: album.locked ? "oklch(40% 0.03 210)" : "#fff",
|
color: album.locked ? "oklch(40% 0.03 var(--hue))" : "#fff",
|
||||||
fontSize: phone ? 15 : 16,
|
fontSize: phone ? 15 : 16,
|
||||||
fontWeight: 800,
|
fontWeight: 800,
|
||||||
padding: phone ? "10px 18px" : "12px 22px",
|
padding: phone ? "10px 18px" : "12px 22px",
|
||||||
@@ -132,7 +132,7 @@ export function AlbumModal({
|
|||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: 10,
|
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 ? (
|
{album.locked ? (
|
||||||
@@ -158,7 +158,7 @@ export function AlbumModal({
|
|||||||
style={{
|
style={{
|
||||||
border: "none",
|
border: "none",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
background: "oklch(88% 0.02 210)",
|
background: "oklch(88% 0.02 var(--hue))",
|
||||||
color: "var(--ink)",
|
color: "var(--ink)",
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: 900,
|
fontWeight: 900,
|
||||||
@@ -193,8 +193,8 @@ export function AlbumModal({
|
|||||||
textAlign: "left",
|
textAlign: "left",
|
||||||
opacity: track.locked ? 0.55 : 1,
|
opacity: track.locked ? 0.55 : 1,
|
||||||
background: current
|
background: current
|
||||||
? "oklch(70% 0.16 340 / .16)"
|
? "oklch(70% 0.16 var(--accent-hue) / .16)"
|
||||||
: "oklch(30% 0.03 210 / .05)",
|
: "oklch(30% 0.03 var(--hue) / .05)",
|
||||||
outline: !phone && index === selectedIndex ? "3px solid var(--accent)" : "none",
|
outline: !phone && index === selectedIndex ? "3px solid var(--accent)" : "none",
|
||||||
outlineOffset: 2,
|
outlineOffset: 2,
|
||||||
}}
|
}}
|
||||||
@@ -209,8 +209,8 @@ export function AlbumModal({
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
font: "800 13px ui-monospace, Menlo, monospace",
|
font: "800 13px ui-monospace, Menlo, monospace",
|
||||||
background: current ? "var(--accent)" : "oklch(88% 0.02 210)",
|
background: current ? "var(--accent)" : "oklch(88% 0.02 var(--hue))",
|
||||||
color: current ? "#fff" : "oklch(35% 0.03 210)",
|
color: current ? "#fff" : "oklch(35% 0.03 var(--hue))",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{track.locked ? "❓" : index + 1}
|
{track.locked ? "❓" : index + 1}
|
||||||
@@ -220,7 +220,7 @@ export function AlbumModal({
|
|||||||
style={{
|
style={{
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: 800,
|
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}
|
{track.locked ? "❓ Noch gesperrt" : track.title}
|
||||||
@@ -230,7 +230,7 @@ export function AlbumModal({
|
|||||||
style={{
|
style={{
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
color: "oklch(45% 0.03 210 / .8)",
|
color: "oklch(45% 0.03 var(--hue) / .8)",
|
||||||
marginTop: 2,
|
marginTop: 2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -243,7 +243,7 @@ export function AlbumModal({
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
font: "700 13px ui-monospace, Menlo, monospace",
|
font: "700 13px ui-monospace, Menlo, monospace",
|
||||||
color: "oklch(45% 0.03 210 / .7)",
|
color: "oklch(45% 0.03 var(--hue) / .7)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{clock(track.duration)}
|
{clock(track.duration)}
|
||||||
|
|||||||
@@ -20,6 +20,10 @@
|
|||||||
* re-seeded from the server, read against `performance.now()`) but reads it from
|
* 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
|
* inside the loop instead of `useState`, which is what keeps 60fps from touching
|
||||||
* React at all.
|
* 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";
|
import { useEffect, useRef } from "react";
|
||||||
@@ -43,6 +47,7 @@ import {
|
|||||||
import type { AmbienceTunables, ManualControl } from "../lib/ambienceTunables";
|
import type { AmbienceTunables, ManualControl } from "../lib/ambienceTunables";
|
||||||
import { AMBIENCE_QUALITY } from "../lib/lowPower";
|
import { AMBIENCE_QUALITY } from "../lib/lowPower";
|
||||||
import { groupOf, type Group } from "../lib/search";
|
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,
|
/** 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. */
|
* 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).
|
//: drive the kick), not *fetch* (the curve is wanted regardless of beat quality).
|
||||||
const PULSE_THRESHOLD = 0.3;
|
const PULSE_THRESHOLD = 0.3;
|
||||||
|
|
||||||
|
/** A bubble or, under the leaves skin, a leaf - see the header. */
|
||||||
interface Bubble {
|
interface Bubble {
|
||||||
/** Horizontal position in CSS pixels - stateful, unlike the old sine-wander's fixed
|
/** 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. */
|
* anchor: a persistent one-directional current has to be integrated over time. */
|
||||||
x: number;
|
x: number;
|
||||||
/** Pixels risen since spawn. */
|
/** Pixels travelled since spawn - risen for a bubble, fallen for a leaf. */
|
||||||
risen: number;
|
risen: number;
|
||||||
/** Size at spawn. */
|
/** Size at spawn. */
|
||||||
baseSize: number;
|
baseSize: number;
|
||||||
/** Current painted size - grows from `baseSize` as the bubble rises. */
|
/** Current painted size - grows from `baseSize` as the bubble rises. */
|
||||||
size: number;
|
size: number;
|
||||||
baseAlpha: 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
|
//: 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 BASE_RISE_FRACTION_PER_SECOND = 0.12;
|
||||||
const BUBBLE_MIN_SIZE = 8;
|
const BUBBLE_MIN_SIZE = 8;
|
||||||
const BUBBLE_MAX_SIZE = 24;
|
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
|
//: 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.
|
//: minutes away doesn't dump a giant `dt` into the physics in one jump.
|
||||||
const MAX_FRAME_SECONDS = 0.05;
|
const MAX_FRAME_SECONDS = 0.05;
|
||||||
@@ -171,9 +184,29 @@ function spawnBubble(width: number): Bubble {
|
|||||||
baseSize,
|
baseSize,
|
||||||
size: baseSize,
|
size: baseSize,
|
||||||
baseAlpha: 0.25 + Math.random() * 0.2,
|
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
|
/** Turns already-smoothed energy/valence/drive into a full `Ambience` - shared
|
||||||
* between the mount-time seed (unsmoothed, snapped straight to the first sample) and
|
* between the mount-time seed (unsmoothed, snapped straight to the first sample) and
|
||||||
* every subsequent frame (smoothed with `tunables.curveSmoothingTau`), so the two
|
* 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
|
// 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.
|
// 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) => {
|
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);
|
const velocity = currentVelocityAt(heightFraction, kickedMagnitude);
|
||||||
b.x = (((b.x + velocity * dt) % size.width) + size.width) % size.width;
|
b.x = (((b.x + velocity * dt) % size.width) + size.width) % size.width;
|
||||||
b.risen += riseSpeed * dt;
|
b.risen += riseSpeed * dt;
|
||||||
b.size = b.baseSize * (1 + tunables.bubbleGrowthRate * heightFraction);
|
// Bubbles swell as the pressure drops on the way up; a leaf stays a leaf.
|
||||||
return b.risen < size.height + BUBBLE_MAX_SIZE * 2;
|
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
|
// 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);
|
ctx.fillRect(0, 0, size.width, size.height);
|
||||||
|
|
||||||
if (!reducedMotion) {
|
if (!reducedMotion) {
|
||||||
|
const third = size.height / 3;
|
||||||
for (const b of bubbles) {
|
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 y = size.height + BUBBLE_MAX_SIZE - b.risen;
|
||||||
const topThird = size.height / 3;
|
const fade = y < third ? Math.max(0, y / third) : 1;
|
||||||
const fade = y < topThird ? Math.max(0, y / topThird) : 1;
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.fillStyle = oklchString(current.tint, b.baseAlpha * fade);
|
ctx.fillStyle = oklchString(current.tint, b.baseAlpha * fade);
|
||||||
ctx.arc(b.x, y, b.size / 2, 0, Math.PI * 2);
|
ctx.arc(b.x, y, b.size / 2, 0, Math.PI * 2);
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ function ManualControlRow({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={{ opacity: 0.6, width: 58, display: "inline-block" }}>
|
<span style={{ opacity: 0.6, width: 58, display: "inline-block" }}>
|
||||||
manual {active && <span style={{ color: "oklch(80% 0.14 340)" }}>●</span>}
|
manual {active && <span style={{ color: "oklch(80% 0.14 var(--accent-hue))" }}>●</span>}
|
||||||
</span>
|
</span>
|
||||||
<label style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
|
<label style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
|
||||||
<input
|
<input
|
||||||
@@ -266,8 +266,8 @@ export function AmbienceDebugOverlay({
|
|||||||
right: 0,
|
right: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
zIndex: 1000,
|
zIndex: 1000,
|
||||||
background: "oklch(15% 0.02 210 / .92)",
|
background: "oklch(15% 0.02 var(--hue) / .92)",
|
||||||
color: "oklch(95% 0.01 210)",
|
color: "oklch(95% 0.01 var(--hue))",
|
||||||
font: "12px/1.5 ui-monospace, Menlo, monospace",
|
font: "12px/1.5 ui-monospace, Menlo, monospace",
|
||||||
padding: "8px 14px",
|
padding: "8px 14px",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
@@ -278,7 +278,7 @@ export function AmbienceDebugOverlay({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ display: "flex", gap: 14, flexWrap: "wrap", alignItems: "center" }}>
|
<div style={{ display: "flex", gap: 14, flexWrap: "wrap", alignItems: "center" }}>
|
||||||
<b style={{ color: "oklch(80% 0.14 340)" }}>debugDynamicUI</b>
|
<b style={{ color: "oklch(80% 0.14 var(--accent-hue))" }}>debugDynamicUI</b>
|
||||||
<Field label="group" value={snapshot.group} />
|
<Field label="group" value={snapshot.group} />
|
||||||
<Field label="playing" value={snapshot.playing ? "yes" : "no"} />
|
<Field label="playing" value={snapshot.playing ? "yes" : "no"} />
|
||||||
<Field label="position" value={`${fmt(snapshot.position, 1)}s`} />
|
<Field label="position" value={`${fmt(snapshot.position, 1)}s`} />
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
import { SKIN } from "../lib/skins";
|
||||||
import { SHOW_APP_TITLE } from "../lib/theme";
|
import { SHOW_APP_TITLE } from "../lib/theme";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -12,8 +13,8 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function AppHeader({
|
export function AppHeader({
|
||||||
title = "Musik Delfin",
|
title = SKIN.appName,
|
||||||
mascot = "/dolphin-mascot.png",
|
mascot = SKIN.mascot,
|
||||||
status,
|
status,
|
||||||
compact = false,
|
compact = false,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
@@ -58,7 +59,7 @@ export function AppHeader({
|
|||||||
fontSize: compact ? 28 : 34,
|
fontSize: compact ? 28 : 34,
|
||||||
fontWeight: 900,
|
fontWeight: 900,
|
||||||
color: "var(--paper)",
|
color: "var(--paper)",
|
||||||
textShadow: "0 3px 14px oklch(15% 0.05 210 / .5)",
|
textShadow: "0 3px 14px oklch(15% 0.05 var(--hue) / .5)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
import { clock } from "../lib/format";
|
import { clock } from "../lib/format";
|
||||||
import type { Category, Group, Results, SearchIndex, SongHit } from "../lib/search";
|
import type { Category, Group, Results, SearchIndex, SongHit } from "../lib/search";
|
||||||
import { categoryMatches, effectiveSearch, groupOf } from "../lib/search";
|
import { categoryMatches, effectiveSearch, groupOf } from "../lib/search";
|
||||||
|
import { SKIN } from "../lib/skins";
|
||||||
import {
|
import {
|
||||||
ANIMATE_VIEW_TRANSITIONS,
|
ANIMATE_VIEW_TRANSITIONS,
|
||||||
glassTint,
|
glassTint,
|
||||||
@@ -260,7 +261,7 @@ function BrowseViewImpl({
|
|||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
color: "oklch(88% 0.02 210 / .6)",
|
color: "oklch(88% 0.02 var(--hue) / .6)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Noch nichts hier
|
Noch nichts hier
|
||||||
@@ -400,7 +401,7 @@ function BrowseViewImpl({
|
|||||||
style={{
|
style={{
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
color: "oklch(88% 0.02 210 / .65)",
|
color: "oklch(88% 0.02 var(--hue) / .65)",
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
textOverflow: "ellipsis",
|
textOverflow: "ellipsis",
|
||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
@@ -412,7 +413,7 @@ function BrowseViewImpl({
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
font: "700 13px ui-monospace, Menlo, monospace",
|
font: "700 13px ui-monospace, Menlo, monospace",
|
||||||
color: "oklch(88% 0.02 210 / .6)",
|
color: "oklch(88% 0.02 var(--hue) / .6)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{clock(hit.duration)}
|
{clock(hit.duration)}
|
||||||
@@ -540,7 +541,7 @@ function BrowseViewImpl({
|
|||||||
style={{
|
style={{
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: 800,
|
fontWeight: 800,
|
||||||
color: "oklch(22% 0.03 210)",
|
color: "oklch(22% 0.03 var(--hue))",
|
||||||
lineHeight: 1.2,
|
lineHeight: 1.2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -551,7 +552,7 @@ function BrowseViewImpl({
|
|||||||
style={{
|
style={{
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
color: "oklch(40% 0.03 210)",
|
color: "oklch(40% 0.03 var(--hue))",
|
||||||
marginTop: 4,
|
marginTop: 4,
|
||||||
lineHeight: 1.3,
|
lineHeight: 1.3,
|
||||||
}}
|
}}
|
||||||
@@ -566,7 +567,7 @@ function BrowseViewImpl({
|
|||||||
style={{
|
style={{
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: 800,
|
fontWeight: 800,
|
||||||
color: "oklch(22% 0.03 210)",
|
color: "oklch(22% 0.03 var(--hue))",
|
||||||
lineHeight: 1.2,
|
lineHeight: 1.2,
|
||||||
height: `${titleLines * 1.2}em`,
|
height: `${titleLines * 1.2}em`,
|
||||||
display: "-webkit-box",
|
display: "-webkit-box",
|
||||||
@@ -582,7 +583,7 @@ function BrowseViewImpl({
|
|||||||
style={{
|
style={{
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
color: "oklch(30% 0.03 210)",
|
color: "oklch(30% 0.03 var(--hue))",
|
||||||
marginTop: 2,
|
marginTop: 2,
|
||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
@@ -596,7 +597,7 @@ function BrowseViewImpl({
|
|||||||
style={{
|
style={{
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: 800,
|
fontWeight: 800,
|
||||||
color: "oklch(40% 0.17 340)",
|
color: "oklch(40% 0.17 var(--accent-hue))",
|
||||||
marginTop: 6,
|
marginTop: 6,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -623,11 +624,11 @@ function BrowseViewImpl({
|
|||||||
style={{
|
style={{
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
marginTop: 60,
|
marginTop: 60,
|
||||||
color: "oklch(92% 0.02 210 / .8)",
|
color: "oklch(92% 0.02 var(--hue) / .8)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src="/dolphin-mascot.png"
|
src={SKIN.mascot}
|
||||||
alt=""
|
alt=""
|
||||||
style={{
|
style={{
|
||||||
width: 120,
|
width: 120,
|
||||||
@@ -813,7 +814,7 @@ export function CategoryTile({
|
|||||||
style={{
|
style={{
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: 800,
|
fontWeight: 800,
|
||||||
color: "oklch(22% 0.03 210)",
|
color: "oklch(22% 0.03 var(--hue))",
|
||||||
lineHeight: 1.2,
|
lineHeight: 1.2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -823,7 +824,7 @@ export function CategoryTile({
|
|||||||
style={{
|
style={{
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: 800,
|
fontWeight: 800,
|
||||||
color: "oklch(40% 0.17 340)",
|
color: "oklch(40% 0.17 var(--accent-hue))",
|
||||||
marginTop: 5,
|
marginTop: 5,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -871,7 +872,7 @@ function Muted({ children }: { children: ReactNode }) {
|
|||||||
style={{
|
style={{
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
color: "oklch(88% 0.02 210 / .7)",
|
color: "oklch(88% 0.02 var(--hue) / .7)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
/** The ambient bubble field. Positions are fixed so they do not reshuffle on render. */
|
|
||||||
|
|
||||||
const BUBBLES: Array<[left: number, size: number, alpha: number, seconds: number, delay: number]> =
|
|
||||||
[
|
|
||||||
[5, 14, 0.5, 9, 0], [15, 22, 0.4, 12, 2], [28, 10, 0.5, 7, 1], [42, 18, 0.45, 10, 4],
|
|
||||||
[58, 12, 0.5, 8, 3], [70, 26, 0.35, 13, 5], [82, 16, 0.5, 9.5, 1.5], [92, 10, 0.5, 6.5, 2.5],
|
|
||||||
[2, 8, 0.45, 7.5, 3.5], [9, 18, 0.3, 14, 6], [21, 12, 0.5, 8.5, 5.5], [34, 24, 0.32, 12.5, 1.2],
|
|
||||||
[38, 9, 0.5, 6.8, 4.6], [48, 14, 0.42, 10.5, 0.6], [53, 20, 0.3, 13.5, 7], [63, 8, 0.5, 7.2, 2.2],
|
|
||||||
[66, 16, 0.38, 11.5, 5.2], [76, 11, 0.48, 9.2, 6.4], [87, 22, 0.28, 15, 3.2],
|
|
||||||
[96, 14, 0.42, 10.8, 8], [45, 7, 0.5, 6.2, 7.6],
|
|
||||||
];
|
|
||||||
|
|
||||||
export function Bubbles() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{BUBBLES.map(([left, size, alpha, seconds, delay], index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className="bubble"
|
|
||||||
style={{
|
|
||||||
left: `${left}%`,
|
|
||||||
width: size,
|
|
||||||
height: size,
|
|
||||||
background: `oklch(90% 0.02 210 / ${alpha})`,
|
|
||||||
animationDuration: `${seconds}s`,
|
|
||||||
animationDelay: `${delay}s`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -85,7 +85,7 @@ export function Cover({ album, size, fit = "width", radius, className, style, la
|
|||||||
fontWeight: 900,
|
fontWeight: 900,
|
||||||
lineHeight: 1.15,
|
lineHeight: 1.15,
|
||||||
color: "oklch(99% 0 0 / .92)",
|
color: "oklch(99% 0 0 / .92)",
|
||||||
textShadow: "0 2px 8px oklch(15% 0.05 210 / .6)",
|
textShadow: "0 2px 8px oklch(15% 0.05 var(--hue) / .6)",
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
64
web/src/components/Drift.tsx
Normal file
64
web/src/components/Drift.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
/** A decorative field drifting behind a page: rising bubbles for the dolphin, falling
|
||||||
|
* leaves for the dinosaur (`drift` in the skin). The room page and the typing game each
|
||||||
|
* pass their own fixed table, so positions do not reshuffle on render; the play view's
|
||||||
|
* moving field is a canvas of its own (`Ambience.tsx`). */
|
||||||
|
|
||||||
|
import { SKIN } from "../lib/skins";
|
||||||
|
|
||||||
|
/** [left %, size px, opacity, seconds, delay seconds] */
|
||||||
|
export type DriftField = readonly (readonly [number, number, number, number, number])[];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
field: DriftField;
|
||||||
|
/** A bubble's fill at the given opacity - each page tints its own. */
|
||||||
|
bubbleColor: (alpha: number) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LEAVES = ["🍃", "🌿", "🍂"];
|
||||||
|
|
||||||
|
export function Drift({ field, bubbleColor }: Props) {
|
||||||
|
const leaves = SKIN.drift === "leaves";
|
||||||
|
// 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" }}>
|
||||||
|
{field.map(([left, size, alpha, seconds, delay], i) =>
|
||||||
|
leaves ? (
|
||||||
|
// A leaf is an opaque glyph rather than a translucent disc, so it gets a larger
|
||||||
|
// size and a stronger opacity to read as present-but-quiet like a bubble does,
|
||||||
|
// and falls a little slower than a bubble rises.
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="leaf"
|
||||||
|
style={{
|
||||||
|
left: `${left}%`,
|
||||||
|
fontSize: 12 + size,
|
||||||
|
opacity: Math.min(0.55, alpha * 3),
|
||||||
|
animationDuration: `${seconds * 1.4}s`,
|
||||||
|
animationDelay: `${delay}s`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{LEAVES[i % LEAVES.length]}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="bubble"
|
||||||
|
style={{
|
||||||
|
left: `${left}%`,
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
background: bubbleColor(alpha),
|
||||||
|
animationDuration: `${seconds}s`,
|
||||||
|
animationDelay: `${delay}s`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -78,7 +78,7 @@ export function HelpOverlay({ onClose }: { onClose: () => void }) {
|
|||||||
fontWeight: 800,
|
fontWeight: 800,
|
||||||
textTransform: "uppercase",
|
textTransform: "uppercase",
|
||||||
letterSpacing: 1,
|
letterSpacing: 1,
|
||||||
color: "oklch(50% 0.03 210 / .8)",
|
color: "oklch(50% 0.03 var(--hue) / .8)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
@@ -92,7 +92,7 @@ export function HelpOverlay({ onClose }: { onClose: () => void }) {
|
|||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 13, fontWeight: 600, color: "oklch(35% 0.03 210)" }}>
|
<div style={{ fontSize: 13, fontWeight: 600, color: "oklch(35% 0.03 var(--hue))" }}>
|
||||||
{label}
|
{label}
|
||||||
</div>
|
</div>
|
||||||
</Fragment>
|
</Fragment>
|
||||||
|
|||||||
@@ -6,15 +6,10 @@ import { useMemo } from "react";
|
|||||||
|
|
||||||
import type { HaDevice, HaEntityState } from "../api/types";
|
import type { HaDevice, HaEntityState } from "../api/types";
|
||||||
import { oklchToRgb } from "../lib/oklch";
|
import { oklchToRgb } from "../lib/oklch";
|
||||||
|
import { SKIN } from "../lib/skins";
|
||||||
|
|
||||||
const SWATCHES: { name: string; oklch: string }[] = [
|
/** The colour presets - named for the skin's world (`lightSwatches`). */
|
||||||
{ name: "Warmweiß", oklch: "oklch(90% 0.06 85)" },
|
const SWATCHES = SKIN.lightSwatches;
|
||||||
{ name: "Sonnengelb", oklch: "oklch(85% 0.16 95)" },
|
|
||||||
{ name: "Korallenrot", oklch: "oklch(65% 0.20 25)" },
|
|
||||||
{ name: "Delfinblau", oklch: "oklch(70% 0.15 235)" },
|
|
||||||
{ name: "Riffgrün", oklch: "oklch(75% 0.16 155)" },
|
|
||||||
{ name: "Quallenlila", oklch: "oklch(65% 0.18 310)" },
|
|
||||||
];
|
|
||||||
|
|
||||||
/** `["brightness"]`/`["onoff"]` lights get the brightness row only (a `brightness_pct`
|
/** `["brightness"]`/`["onoff"]` lights get the brightness row only (a `brightness_pct`
|
||||||
* sent to a plain on/off bulb is harmlessly ignored by Home Assistant). */
|
* sent to a plain on/off bulb is harmlessly ignored by Home Assistant). */
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export function ParentPanel({ onClose }: { onClose: () => void }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overlay" style={{ zIndex: 6, background: "oklch(15% 0.03 210 / .75)" }}>
|
<div className="overlay" style={{ zIndex: 6, background: "oklch(15% 0.03 var(--hue) / .75)" }}>
|
||||||
<div className="sheet" style={{ padding: 28, width: 460, maxHeight: "100%", overflow: "auto" }}>
|
<div className="sheet" style={{ padding: 28, width: 460, maxHeight: "100%", overflow: "auto" }}>
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||||
<div style={{ fontSize: 20, fontWeight: 900, color: "var(--ink)", flex: 1 }}>
|
<div style={{ fontSize: 20, fontWeight: 900, color: "var(--ink)", flex: 1 }}>
|
||||||
@@ -65,7 +65,7 @@ export function ParentPanel({ onClose }: { onClose: () => void }) {
|
|||||||
style={{
|
style={{
|
||||||
border: "none",
|
border: "none",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
background: "oklch(88% 0.02 210)",
|
background: "oklch(88% 0.02 var(--hue))",
|
||||||
color: "var(--ink)",
|
color: "var(--ink)",
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: 900,
|
fontWeight: 900,
|
||||||
@@ -79,13 +79,13 @@ export function ParentPanel({ onClose }: { onClose: () => void }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{settings === null ? (
|
{settings === null ? (
|
||||||
<div style={{ marginTop: 18, color: "oklch(45% 0.03 210)" }}>Wird geladen …</div>
|
<div style={{ marginTop: 18, color: "oklch(45% 0.03 var(--hue))" }}>Wird geladen …</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 14, marginTop: 18 }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 14, marginTop: 18 }}>
|
||||||
{FIELDS.map(([key, label, hint, min, max, step]) => (
|
{FIELDS.map(([key, label, hint, min, max, step]) => (
|
||||||
<label key={key} style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
<label key={key} style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||||
<span style={{ fontSize: 14, fontWeight: 800, color: "var(--ink)" }}>{label}</span>
|
<span style={{ fontSize: 14, fontWeight: 800, color: "var(--ink)" }}>{label}</span>
|
||||||
<span style={{ fontSize: 12, fontWeight: 600, color: "oklch(50% 0.03 210)" }}>
|
<span style={{ fontSize: 12, fontWeight: 600, color: "oklch(50% 0.03 var(--hue))" }}>
|
||||||
{hint}
|
{hint}
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
@@ -100,7 +100,7 @@ export function ParentPanel({ onClose }: { onClose: () => void }) {
|
|||||||
style={{
|
style={{
|
||||||
padding: "8px 12px",
|
padding: "8px 12px",
|
||||||
borderRadius: 10,
|
borderRadius: 10,
|
||||||
border: "2px solid oklch(85% 0.02 210)",
|
border: "2px solid oklch(85% 0.02 var(--hue))",
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
}}
|
}}
|
||||||
@@ -130,7 +130,7 @@ export function ParentPanel({ onClose }: { onClose: () => void }) {
|
|||||||
style={{
|
style={{
|
||||||
border: "none",
|
border: "none",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
background: "oklch(88% 0.02 210)",
|
background: "oklch(88% 0.02 var(--hue))",
|
||||||
color: "var(--ink)",
|
color: "var(--ink)",
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontWeight: 800,
|
fontWeight: 800,
|
||||||
@@ -146,7 +146,7 @@ export function ParentPanel({ onClose }: { onClose: () => void }) {
|
|||||||
style={{
|
style={{
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
color: "oklch(50% 0.03 210)",
|
color: "oklch(50% 0.03 var(--hue))",
|
||||||
lineHeight: 1.5,
|
lineHeight: 1.5,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -12,19 +12,25 @@ import {
|
|||||||
savePerfSettings,
|
savePerfSettings,
|
||||||
type PerfSettings,
|
type PerfSettings,
|
||||||
} from "../lib/perfSettings";
|
} from "../lib/perfSettings";
|
||||||
|
import { SKIN } from "../lib/skins";
|
||||||
|
|
||||||
type BoolKey = { [K in keyof PerfSettings]: PerfSettings[K] extends boolean ? K : never }[keyof PerfSettings];
|
type BoolKey = { [K in keyof PerfSettings]: PerfSettings[K] extends boolean ? K : never }[keyof PerfSettings];
|
||||||
type NumKey = { [K in keyof PerfSettings]: PerfSettings[K] extends number ? K : never }[keyof PerfSettings];
|
type NumKey = { [K in keyof PerfSettings]: PerfSettings[K] extends number ? K : never }[keyof PerfSettings];
|
||||||
|
|
||||||
|
/** What drifts and how the mascot moves, in the skin's own words. */
|
||||||
|
const DRIFT = SKIN.drift === "leaves" ? "Blätter" : "Blasen";
|
||||||
|
const DRIFT_MOVING = SKIN.drift === "leaves" ? "Fallende Blätter" : "Aufsteigende Blasen";
|
||||||
|
const MASCOT_MOVING = SKIN.mascotMotion === "walk" ? "wanderndes" : "schwimmendes";
|
||||||
|
|
||||||
const TOGGLES: [BoolKey, string, string][] = [
|
const TOGGLES: [BoolKey, string, string][] = [
|
||||||
["ambience", "Animierter Hintergrund", "Canvas im Abspielbildschirm (größter CPU-Verbraucher)"],
|
["ambience", "Animierter Hintergrund", "Canvas im Abspielbildschirm (größter CPU-Verbraucher)"],
|
||||||
["cardBlur", "Weichzeichner auf Karten", "Backdrop-Blur je Album-Karte und Titelzeile"],
|
["cardBlur", "Weichzeichner auf Karten", "Backdrop-Blur je Album-Karte und Titelzeile"],
|
||||||
["viewTransitions", "Übergänge", "Ein-/Ausblenden beim Öffnen von Gruppen und Alben"],
|
["viewTransitions", "Übergänge", "Ein-/Ausblenden beim Öffnen von Gruppen und Alben"],
|
||||||
["decorativeAnimations", "Deko-Animationen", "Blasen im Zimmer, schwimmender Delfin"],
|
["decorativeAnimations", "Deko-Animationen", `${DRIFT} im Zimmer, ${MASCOT_MOVING} Maskottchen`],
|
||||||
["cardShadows", "Schatten auf Karten", "Schlagschatten unter den Album-Karten"],
|
["cardShadows", "Schatten auf Karten", "Schlagschatten unter den Album-Karten"],
|
||||||
["tileShadows", "Schatten auf Kacheln", "Schlagschatten unter den Kacheln im Raster"],
|
["tileShadows", "Schatten auf Kacheln", "Schlagschatten unter den Kacheln im Raster"],
|
||||||
["petAnimations", "Tippen: Tiere bewegen sich", "Aus: Tiere stehen still"],
|
["petAnimations", "Tippen: Tiere bewegen sich", "Aus: Tiere stehen still"],
|
||||||
["tippenBubbles", "Tippen: Blasen", "Aufsteigende Blasen im Hintergrund"],
|
["tippenBubbles", `Tippen: ${DRIFT}`, `${DRIFT_MOVING} im Hintergrund`],
|
||||||
["tippenViewTransitions", "Tippen: Übergänge", "Ein-/Ausblenden der Bildschirme"],
|
["tippenViewTransitions", "Tippen: Übergänge", "Ein-/Ausblenden der Bildschirme"],
|
||||||
["serviceWorker", "Service Worker", "Aus: vorhandener Worker wird entfernt"],
|
["serviceWorker", "Service Worker", "Aus: vorhandener Worker wird entfernt"],
|
||||||
["coverWarmup", "Cover vorladen", "Cover im Hintergrund dekodieren"],
|
["coverWarmup", "Cover vorladen", "Cover im Hintergrund dekodieren"],
|
||||||
@@ -35,7 +41,7 @@ const NUMBERS: [NumKey, string, string, number, number, number][] = [
|
|||||||
["ambienceScale", "Hintergrund: Auflösung", "Faktor der Canvas-Größe", 0.1, 1, 0.05],
|
["ambienceScale", "Hintergrund: Auflösung", "Faktor der Canvas-Größe", 0.1, 1, 0.05],
|
||||||
["ambienceMaxPx", "Hintergrund: max. Pixel", "Obergrenze je Achse", 256, 4096, 64],
|
["ambienceMaxPx", "Hintergrund: max. Pixel", "Obergrenze je Achse", 256, 4096, 64],
|
||||||
["ambienceMaxFps", "Hintergrund: max. FPS", "0 = unbegrenzt", 0, 60, 1],
|
["ambienceMaxFps", "Hintergrund: max. FPS", "0 = unbegrenzt", 0, 60, 1],
|
||||||
["ambienceMaxBubbles", "Hintergrund: max. Blasen", "0 = unbegrenzt", 0, 500, 5],
|
["ambienceMaxBubbles", `Hintergrund: max. ${DRIFT}`, "0 = unbegrenzt", 0, 500, 5],
|
||||||
];
|
];
|
||||||
|
|
||||||
const pill = (background: string, color: string) =>
|
const pill = (background: string, color: string) =>
|
||||||
@@ -63,7 +69,7 @@ export function PerfPanel({ onClose }: { onClose: () => void }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overlay" style={{ zIndex: 7, background: "oklch(15% 0.03 210 / .75)" }}>
|
<div className="overlay" style={{ zIndex: 7, background: "oklch(15% 0.03 var(--hue) / .75)" }}>
|
||||||
<div className="sheet" style={{ padding: 28, width: 480, maxHeight: "100%", overflow: "auto" }}>
|
<div className="sheet" style={{ padding: 28, width: 480, maxHeight: "100%", overflow: "auto" }}>
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||||
<div style={{ fontSize: 20, fontWeight: 900, color: "var(--ink)", flex: 1 }}>
|
<div style={{ fontSize: 20, fontWeight: 900, color: "var(--ink)", flex: 1 }}>
|
||||||
@@ -73,7 +79,7 @@ export function PerfPanel({ onClose }: { onClose: () => void }) {
|
|||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
aria-label="Schließen"
|
aria-label="Schließen"
|
||||||
style={{
|
style={{
|
||||||
...pill("oklch(88% 0.02 210)", "var(--ink)"),
|
...pill("oklch(88% 0.02 var(--hue))", "var(--ink)"),
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: 900,
|
fontWeight: 900,
|
||||||
width: 36,
|
width: 36,
|
||||||
@@ -86,10 +92,10 @@ export function PerfPanel({ onClose }: { onClose: () => void }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: "flex", gap: 10, marginTop: 16 }}>
|
<div style={{ display: "flex", gap: 10, marginTop: 16 }}>
|
||||||
<button onClick={() => setDraft({ ...PI })} style={pill("oklch(88% 0.02 210)", "var(--ink)")}>
|
<button onClick={() => setDraft({ ...PI })} style={pill("oklch(88% 0.02 var(--hue))", "var(--ink)")}>
|
||||||
Pi-Vorgabe
|
Pi-Vorgabe
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => setDraft({ ...FULL })} style={pill("oklch(88% 0.02 210)", "var(--ink)")}>
|
<button onClick={() => setDraft({ ...FULL })} style={pill("oklch(88% 0.02 var(--hue))", "var(--ink)")}>
|
||||||
Alles an
|
Alles an
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -105,7 +111,7 @@ export function PerfPanel({ onClose }: { onClose: () => void }) {
|
|||||||
/>
|
/>
|
||||||
<span style={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
<span style={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||||
<span style={{ fontSize: 14, fontWeight: 800, color: "var(--ink)" }}>{label}</span>
|
<span style={{ fontSize: 14, fontWeight: 800, color: "var(--ink)" }}>{label}</span>
|
||||||
<span style={{ fontSize: 12, fontWeight: 600, color: "oklch(50% 0.03 210)" }}>
|
<span style={{ fontSize: 12, fontWeight: 600, color: "oklch(50% 0.03 var(--hue))" }}>
|
||||||
{hint}
|
{hint}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -115,7 +121,7 @@ export function PerfPanel({ onClose }: { onClose: () => void }) {
|
|||||||
{NUMBERS.map(([key, label, hint, min, max, step]) => (
|
{NUMBERS.map(([key, label, hint, min, max, step]) => (
|
||||||
<label key={key} style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
<label key={key} style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||||
<span style={{ fontSize: 14, fontWeight: 800, color: "var(--ink)" }}>{label}</span>
|
<span style={{ fontSize: 14, fontWeight: 800, color: "var(--ink)" }}>{label}</span>
|
||||||
<span style={{ fontSize: 12, fontWeight: 600, color: "oklch(50% 0.03 210)" }}>
|
<span style={{ fontSize: 12, fontWeight: 600, color: "oklch(50% 0.03 var(--hue))" }}>
|
||||||
{hint}
|
{hint}
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
@@ -131,7 +137,7 @@ export function PerfPanel({ onClose }: { onClose: () => void }) {
|
|||||||
style={{
|
style={{
|
||||||
padding: "8px 12px",
|
padding: "8px 12px",
|
||||||
borderRadius: 10,
|
borderRadius: 10,
|
||||||
border: "2px solid oklch(85% 0.02 210)",
|
border: "2px solid oklch(85% 0.02 var(--hue))",
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
}}
|
}}
|
||||||
@@ -143,12 +149,12 @@ export function PerfPanel({ onClose }: { onClose: () => void }) {
|
|||||||
<button onClick={save} style={pill("var(--accent)", "#fff")}>
|
<button onClick={save} style={pill("var(--accent)", "#fff")}>
|
||||||
Speichern & neu laden
|
Speichern & neu laden
|
||||||
</button>
|
</button>
|
||||||
<button onClick={reset} style={pill("oklch(88% 0.02 210)", "var(--ink)")}>
|
<button onClick={reset} style={pill("oklch(88% 0.02 var(--hue))", "var(--ink)")}>
|
||||||
Zurücksetzen
|
Zurücksetzen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ fontSize: 12, fontWeight: 600, color: "oklch(50% 0.03 210)", lineHeight: 1.5 }}>
|
<div style={{ fontSize: 12, fontWeight: 600, color: "oklch(50% 0.03 var(--hue))", lineHeight: 1.5 }}>
|
||||||
Wird nur in diesem Browser gespeichert und gilt nur mit <code>?pi=1</code>.
|
Wird nur in diesem Browser gespeichert und gilt nur mit <code>?pi=1</code>.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { usePlaybackClock } from "../hooks/usePlaybackClock";
|
|||||||
import { isBook, nowPlayingText } from "../lib/covers";
|
import { isBook, nowPlayingText } from "../lib/covers";
|
||||||
import { clock, remainingInAlbum } from "../lib/format";
|
import { clock, remainingInAlbum } from "../lib/format";
|
||||||
import { groupOf } from "../lib/search";
|
import { groupOf } from "../lib/search";
|
||||||
|
import { SKIN } from "../lib/skins";
|
||||||
import { SHOW_DECORATIVE_ANIMATIONS } from "../lib/theme";
|
import { SHOW_DECORATIVE_ANIMATIONS } from "../lib/theme";
|
||||||
import { Cover } from "./Cover";
|
import { Cover } from "./Cover";
|
||||||
import { ProgressBar } from "./ProgressBar";
|
import { ProgressBar } from "./ProgressBar";
|
||||||
@@ -27,6 +28,12 @@ interface Props {
|
|||||||
assignPending: boolean;
|
assignPending: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** How the mascot crosses the play view - keyframes in app.css. */
|
||||||
|
const MASCOT_CROSSING = {
|
||||||
|
swim: "mascotSwim 62s linear infinite",
|
||||||
|
walk: "mascotWalk 62s linear infinite, mascotWaddle 0.8s ease-in-out infinite",
|
||||||
|
} as const;
|
||||||
|
|
||||||
export function PlayView({
|
export function PlayView({
|
||||||
state,
|
state,
|
||||||
album,
|
album,
|
||||||
@@ -66,7 +73,7 @@ export function PlayView({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src="/dolphin-mascot.png"
|
src={SKIN.mascot}
|
||||||
alt=""
|
alt=""
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
@@ -78,11 +85,15 @@ export function PlayView({
|
|||||||
zIndex: 0,
|
zIndex: 0,
|
||||||
pointerEvents: "none",
|
pointerEvents: "none",
|
||||||
opacity: 0.92,
|
opacity: 0.92,
|
||||||
animation: SHOW_DECORATIVE_ANIMATIONS ? "dolphinSwim 62s linear infinite" : "none",
|
animation: SHOW_DECORATIVE_ANIMATIONS ? MASCOT_CROSSING[SKIN.mascotMotion] : "none",
|
||||||
|
// `mascotSwim` assumes a drawing that faces right. `scale` composes with the
|
||||||
|
// keyframes' `transform` rather than replacing it, so a left-facing mascot
|
||||||
|
// mirrored here still turns round at the far edge with everyone else.
|
||||||
|
scale: SKIN.mascotFacesLeft ? "-1 1" : undefined,
|
||||||
// Freezes in its current pose rather than snapping back to frame 0 -
|
// Freezes in its current pose rather than snapping back to frame 0 -
|
||||||
// `animation: "none"` would restart it, `animationPlayState` just pauses.
|
// `animation: "none"` would restart it, `animationPlayState` just pauses.
|
||||||
animationPlayState: state.playing ? "running" : "paused",
|
animationPlayState: state.playing ? "running" : "paused",
|
||||||
filter: "drop-shadow(0 10px 26px oklch(10% 0.05 210 / .45))",
|
filter: "drop-shadow(0 10px 26px oklch(10% 0.05 var(--hue) / .45))",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -93,7 +104,7 @@ export function PlayView({
|
|||||||
position: "relative",
|
position: "relative",
|
||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
display: "flex",
|
display: "flex",
|
||||||
filter: "drop-shadow(0 20px 40px oklch(10% 0.05 210 / .55))",
|
filter: "drop-shadow(0 20px 40px oklch(10% 0.05 var(--hue) / .55))",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{album ? (
|
{album ? (
|
||||||
@@ -123,7 +134,7 @@ export function PlayView({
|
|||||||
height: "100%",
|
height: "100%",
|
||||||
aspectRatio: 1,
|
aspectRatio: 1,
|
||||||
borderRadius: 28,
|
borderRadius: 28,
|
||||||
background: "oklch(35% 0.03 210)",
|
background: "oklch(35% 0.03 var(--hue))",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -145,7 +156,7 @@ export function PlayView({
|
|||||||
style={{
|
style={{
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
color: "oklch(88% 0.02 210 / .8)",
|
color: "oklch(88% 0.02 var(--hue) / .8)",
|
||||||
marginTop: 8,
|
marginTop: 8,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -183,7 +194,7 @@ export function PlayView({
|
|||||||
marginTop: 8,
|
marginTop: 8,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: 800,
|
fontWeight: 800,
|
||||||
color: "oklch(88% 0.02 210 / .8)",
|
color: "oklch(88% 0.02 var(--hue) / .8)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={{ fontFamily: "ui-monospace, Menlo, monospace" }}>
|
<span style={{ fontFamily: "ui-monospace, Menlo, monospace" }}>
|
||||||
@@ -232,13 +243,13 @@ export function PlayView({
|
|||||||
width: 52,
|
width: 52,
|
||||||
height: 52,
|
height: 52,
|
||||||
borderRadius: 999,
|
borderRadius: 999,
|
||||||
background: assignPending ? "var(--accent)" : "oklch(97% 0.01 210 / .95)",
|
background: assignPending ? "var(--accent)" : "oklch(97% 0.01 var(--hue) / .95)",
|
||||||
color: assignPending ? "#fff" : "var(--ink)",
|
color: assignPending ? "#fff" : "var(--ink)",
|
||||||
fontSize: 22,
|
fontSize: 22,
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
boxShadow: "0 8px 24px oklch(15% 0.05 210 / .4)",
|
boxShadow: "0 8px 24px oklch(15% 0.05 var(--hue) / .4)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
🎛️
|
🎛️
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import type { Album, PlayerState } from "../api/types";
|
import type { Album, PlayerState } from "../api/types";
|
||||||
import { usePlaybackClock } from "../hooks/usePlaybackClock";
|
import { usePlaybackClock } from "../hooks/usePlaybackClock";
|
||||||
import { nowPlayingText } from "../lib/covers";
|
import { nowPlayingText } from "../lib/covers";
|
||||||
|
import { SKIN } from "../lib/skins";
|
||||||
import { SHOW_DECORATIVE_ANIMATIONS, SHOW_GLASS_BLUR } from "../lib/theme";
|
import { SHOW_DECORATIVE_ANIMATIONS, SHOW_GLASS_BLUR } from "../lib/theme";
|
||||||
import { Cover } from "./Cover";
|
import { Cover } from "./Cover";
|
||||||
import { ProgressBar } from "./ProgressBar";
|
import { ProgressBar } from "./ProgressBar";
|
||||||
@@ -42,7 +43,7 @@ export function PlayerBar({
|
|||||||
right: 0,
|
right: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
zIndex: 2,
|
zIndex: 2,
|
||||||
background: "oklch(18% 0.05 210 / .96)",
|
background: "oklch(18% 0.05 var(--hue) / .96)",
|
||||||
backdropFilter: SHOW_GLASS_BLUR ? "blur(6px)" : undefined,
|
backdropFilter: SHOW_GLASS_BLUR ? "blur(6px)" : undefined,
|
||||||
padding: "16px clamp(12px, 3vw, 32px)",
|
padding: "16px clamp(12px, 3vw, 32px)",
|
||||||
// Clear of the iPhone home indicator when installed to the home screen.
|
// Clear of the iPhone home indicator when installed to the home screen.
|
||||||
@@ -51,11 +52,11 @@ export function PlayerBar({
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
gap: "clamp(10px, 1.6vw, 22px)",
|
gap: "clamp(10px, 1.6vw, 22px)",
|
||||||
borderTop: "3px solid oklch(70% 0.16 340 / .5)",
|
borderTop: "3px solid oklch(70% 0.16 var(--accent-hue) / .5)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src="/dolphin-mascot.png"
|
src={SKIN.mascot}
|
||||||
alt=""
|
alt=""
|
||||||
style={{
|
style={{
|
||||||
width: "clamp(56px, 7vw, 84px)",
|
width: "clamp(56px, 7vw, 84px)",
|
||||||
@@ -65,9 +66,9 @@ export function PlayerBar({
|
|||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
animation:
|
animation:
|
||||||
SHOW_DECORATIVE_ANIMATIONS && state.playing
|
SHOW_DECORATIVE_ANIMATIONS && state.playing
|
||||||
? "dolphinBob 1.6s ease-in-out infinite"
|
? "mascotBob 1.6s ease-in-out infinite"
|
||||||
: "none",
|
: "none",
|
||||||
filter: "drop-shadow(0 4px 12px oklch(10% 0.05 210 / .5))",
|
filter: "drop-shadow(0 4px 12px oklch(10% 0.05 var(--hue) / .5))",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -84,7 +85,7 @@ export function PlayerBar({
|
|||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
cursor: album ? "pointer" : "default",
|
cursor: album ? "pointer" : "default",
|
||||||
background: album ? "none" : "oklch(35% 0.03 210)",
|
background: album ? "none" : "oklch(35% 0.03 var(--hue))",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{album && <Cover album={album} size={56} fit="height" radius="10px" />}
|
{album && <Cover album={album} size={56} fit="height" radius="10px" />}
|
||||||
@@ -107,7 +108,7 @@ export function PlayerBar({
|
|||||||
style={{
|
style={{
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
color: "oklch(85% 0.02 210 / .75)",
|
color: "oklch(85% 0.02 var(--hue) / .75)",
|
||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
textOverflow: "ellipsis",
|
textOverflow: "ellipsis",
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export function ProgressBar({
|
|||||||
style={{
|
style={{
|
||||||
height,
|
height,
|
||||||
borderRadius: 999,
|
borderRadius: 999,
|
||||||
background: "oklch(30% 0.03 210 / .6)",
|
background: "oklch(30% 0.03 var(--hue) / .6)",
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
cursor: interactive && duration ? "pointer" : "default",
|
cursor: interactive && duration ? "pointer" : "default",
|
||||||
touchAction: "none",
|
touchAction: "none",
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export function RemoteAssignPopup({ album, mapping, albums, onAssign, onClear, o
|
|||||||
style={{
|
style={{
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
color: "oklch(45% 0.03 210)",
|
color: "oklch(45% 0.03 var(--hue))",
|
||||||
marginBottom: 16,
|
marginBottom: 16,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -78,7 +78,7 @@ export function RemoteAssignPopup({ album, mapping, albums, onAssign, onClear, o
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
gap: 4,
|
gap: 4,
|
||||||
background: view.state === "ok" ? "oklch(90% 0.04 210)" : "oklch(94% 0.01 210)",
|
background: view.state === "ok" ? "oklch(90% 0.04 var(--hue))" : "oklch(94% 0.01 var(--hue))",
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -90,7 +90,7 @@ export function RemoteAssignPopup({ album, mapping, albums, onAssign, onClear, o
|
|||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
fontWeight: 900,
|
fontWeight: 900,
|
||||||
color:
|
color:
|
||||||
view.state === "missing" ? "oklch(55% 0.15 30)" : "oklch(60% 0.02 210)",
|
view.state === "missing" ? "oklch(55% 0.15 30)" : "oklch(60% 0.02 var(--hue))",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{view.state === "missing" ? "⚠️" : "+"}
|
{view.state === "missing" ? "⚠️" : "+"}
|
||||||
@@ -136,7 +136,7 @@ export function RemoteAssignPopup({ album, mapping, albums, onAssign, onClear, o
|
|||||||
marginTop: 16,
|
marginTop: 16,
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
color: "oklch(50% 0.03 210 / .8)",
|
color: "oklch(50% 0.03 var(--hue) / .8)",
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -7,15 +7,27 @@ import { useState } from "react";
|
|||||||
|
|
||||||
import type { HaConfig } from "../api/types";
|
import type { HaConfig } from "../api/types";
|
||||||
import { useHomeAssistant } from "../hooks/useHomeAssistant";
|
import { useHomeAssistant } from "../hooks/useHomeAssistant";
|
||||||
|
import { SKIN } from "../lib/skins";
|
||||||
import { SHOW_DECORATIVE_ANIMATIONS, TAB_RAIL_CLEARANCE } from "../lib/theme";
|
import { SHOW_DECORATIVE_ANIMATIONS, TAB_RAIL_CLEARANCE } from "../lib/theme";
|
||||||
import { AppHeader } from "./AppHeader";
|
import { AppHeader } from "./AppHeader";
|
||||||
import { Bubbles } from "./Bubbles";
|
import { Drift } from "./Drift";
|
||||||
|
import type { DriftField } from "./Drift";
|
||||||
import { LightCard } from "./LightCard";
|
import { LightCard } from "./LightCard";
|
||||||
import { SceneRow } from "./SceneRow";
|
import { SceneRow } from "./SceneRow";
|
||||||
import { ShutterCard } from "./ShutterCard";
|
import { ShutterCard } from "./ShutterCard";
|
||||||
|
|
||||||
/** `phone`: no tab rail on the right to keep clear of, but the floating dock along the
|
/** `phone`: no tab rail on the right to keep clear of, but the floating dock along the
|
||||||
* bottom (see `MobileDock`) instead. */
|
* bottom (see `MobileDock`) instead. */
|
||||||
|
/** The room page's ambient field - see Drift. */
|
||||||
|
const FIELD: DriftField = [
|
||||||
|
[5, 14, 0.5, 9, 0], [15, 22, 0.4, 12, 2], [28, 10, 0.5, 7, 1], [42, 18, 0.45, 10, 4],
|
||||||
|
[58, 12, 0.5, 8, 3], [70, 26, 0.35, 13, 5], [82, 16, 0.5, 9.5, 1.5], [92, 10, 0.5, 6.5, 2.5],
|
||||||
|
[2, 8, 0.45, 7.5, 3.5], [9, 18, 0.3, 14, 6], [21, 12, 0.5, 8.5, 5.5], [34, 24, 0.32, 12.5, 1.2],
|
||||||
|
[38, 9, 0.5, 6.8, 4.6], [48, 14, 0.42, 10.5, 0.6], [53, 20, 0.3, 13.5, 7], [63, 8, 0.5, 7.2, 2.2],
|
||||||
|
[66, 16, 0.38, 11.5, 5.2], [76, 11, 0.48, 9.2, 6.4], [87, 22, 0.28, 15, 3.2],
|
||||||
|
[96, 14, 0.42, 10.8, 8], [45, 7, 0.5, 6.2, 7.6],
|
||||||
|
];
|
||||||
|
|
||||||
export function RoomView({ config, phone = false }: { config: HaConfig; phone?: boolean }) {
|
export function RoomView({ config, phone = false }: { config: HaConfig; phone?: boolean }) {
|
||||||
const ha = useHomeAssistant(config);
|
const ha = useHomeAssistant(config);
|
||||||
// Purely local: Home Assistant scenes have no "currently active" state of their own.
|
// Purely local: Home Assistant scenes have no "currently active" state of their own.
|
||||||
@@ -31,7 +43,7 @@ export function RoomView({ config, phone = false }: { config: HaConfig; phone?:
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="stage room-stage" style={{ display: "flex", flexDirection: "column" }}>
|
<div className="stage room-stage" style={{ display: "flex", flexDirection: "column" }}>
|
||||||
{SHOW_DECORATIVE_ANIMATIONS && <Bubbles />}
|
{SHOW_DECORATIVE_ANIMATIONS && <Drift field={FIELD} bubbleColor={(alpha) => `oklch(90% 0.02 var(--hue) / ${alpha})`} />}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: "relative",
|
position: "relative",
|
||||||
@@ -41,7 +53,7 @@ export function RoomView({ config, phone = false }: { config: HaConfig; phone?:
|
|||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<AppHeader title="Mein Zimmer" mascot="/dolphin-remote.png" compact={phone} />
|
<AppHeader title="Mein Zimmer" mascot={SKIN.mascotRemote} compact={phone} />
|
||||||
<div
|
<div
|
||||||
// Right padding reserves room for the tab rail's own glass bar (see
|
// Right padding reserves room for the tab rail's own glass bar (see
|
||||||
// TAB_RAIL_CLEARANCE) so the device grid never renders under it.
|
// TAB_RAIL_CLEARANCE) so the device grid never renders under it.
|
||||||
|
|||||||
@@ -35,11 +35,11 @@ export function TabRail({ page, onSelect, showRoom }: Props) {
|
|||||||
gap: 8,
|
gap: 8,
|
||||||
padding: 8,
|
padding: 8,
|
||||||
borderRadius: 999,
|
borderRadius: 999,
|
||||||
background: "oklch(97% 0.01 210 / .22)",
|
background: "oklch(97% 0.01 var(--hue) / .22)",
|
||||||
backdropFilter: SHOW_GLASS_BLUR ? "blur(14px)" : undefined,
|
backdropFilter: SHOW_GLASS_BLUR ? "blur(14px)" : undefined,
|
||||||
WebkitBackdropFilter: SHOW_GLASS_BLUR ? "blur(14px)" : undefined,
|
WebkitBackdropFilter: SHOW_GLASS_BLUR ? "blur(14px)" : undefined,
|
||||||
border: "1px solid oklch(97% 0.01 210 / .3)",
|
border: "1px solid oklch(97% 0.01 var(--hue) / .3)",
|
||||||
boxShadow: "0 8px 24px oklch(15% 0.05 210 / .35)",
|
boxShadow: "0 8px 24px oklch(15% 0.05 var(--hue) / .35)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{pages.map((candidate) => {
|
{pages.map((candidate) => {
|
||||||
|
|||||||
@@ -23,14 +23,16 @@ import { AppHeader } from "./tippen/AppHeader";
|
|||||||
import { HelpOverlay } from "./tippen/HelpOverlay";
|
import { HelpOverlay } from "./tippen/HelpOverlay";
|
||||||
import { LessonMap } from "./tippen/LessonMap";
|
import { LessonMap } from "./tippen/LessonMap";
|
||||||
import { ResultSheet } from "./tippen/ResultSheet";
|
import { ResultSheet } from "./tippen/ResultSheet";
|
||||||
|
import { PetPicture } from "./tippen/PetPicture";
|
||||||
import { RewardUnlockOverlay } from "./tippen/RewardUnlockOverlay";
|
import { RewardUnlockOverlay } from "./tippen/RewardUnlockOverlay";
|
||||||
import { Stage } from "./tippen/Stage";
|
import { Stage } from "./tippen/Stage";
|
||||||
import { BubblesRun } from "./tippen/modes/BubblesRun";
|
import { BubblesRun } from "./tippen/modes/BubblesRun";
|
||||||
import { DiveRun } from "./tippen/modes/DiveRun";
|
import { DiveRun } from "./tippen/modes/DiveRun";
|
||||||
import { FeedRun } from "./tippen/modes/FeedRun";
|
import { FeedRun } from "./tippen/modes/FeedRun";
|
||||||
import { RaceRun } from "./tippen/modes/RaceRun";
|
import { RaceRun } from "./tippen/modes/RaceRun";
|
||||||
import type { CreatureId } from "../lib/tippen/aquarium";
|
import { SKIN } from "../lib/skins";
|
||||||
import { creatureById } from "../lib/tippen/aquarium";
|
import type { CreatureId } from "../lib/tippen/pets";
|
||||||
|
import { creatureById } from "../lib/tippen/pets";
|
||||||
import type { Lesson, ModeId } from "../lib/tippen/curriculum";
|
import type { Lesson, ModeId } from "../lib/tippen/curriculum";
|
||||||
import { lessonById, nextLesson } from "../lib/tippen/curriculum";
|
import { lessonById, nextLesson } from "../lib/tippen/curriculum";
|
||||||
import { letterStream, lineFor, lineText, mulberry32 } from "../lib/tippen/generator";
|
import { letterStream, lineFor, lineText, mulberry32 } from "../lib/tippen/generator";
|
||||||
@@ -308,7 +310,7 @@ export function TippenApp({ onExit }: Props) {
|
|||||||
return (
|
return (
|
||||||
<Stage creatures={progress.aquarium} dimmed={screen === "run"}>
|
<Stage creatures={progress.aquarium} dimmed={screen === "run"}>
|
||||||
<AppHeader
|
<AppHeader
|
||||||
title={screen === "run" && lesson ? lesson.title : "Delfin Tippen"}
|
title={screen === "run" && lesson ? lesson.title : SKIN.tippenName}
|
||||||
compact={screen === "run"}
|
compact={screen === "run"}
|
||||||
status={
|
status={
|
||||||
<div style={{ display: "flex", gap: 12, alignItems: "center", color: "var(--paper)", fontWeight: 800 }}>
|
<div style={{ display: "flex", gap: 12, alignItems: "center", color: "var(--paper)", fontWeight: 800 }}>
|
||||||
@@ -319,15 +321,12 @@ export function TippenApp({ onExit }: Props) {
|
|||||||
const creature = creatureById(world.reward);
|
const creature = creatureById(world.reward);
|
||||||
const owned = progress.aquarium.includes(creature.id);
|
const owned = progress.aquarium.includes(creature.id);
|
||||||
return (
|
return (
|
||||||
<img
|
<PetPicture
|
||||||
key={creature.id}
|
key={creature.id}
|
||||||
src={creature.image}
|
creature={creature}
|
||||||
alt=""
|
size={22}
|
||||||
title={owned ? creature.name : `Welt ${world.number}`}
|
title={owned ? creature.name : `Welt ${world.number}`}
|
||||||
style={{
|
style={{
|
||||||
width: 22,
|
|
||||||
height: 22,
|
|
||||||
objectFit: "contain",
|
|
||||||
filter: owned ? "none" : "brightness(0) invert(1)",
|
filter: owned ? "none" : "brightness(0) invert(1)",
|
||||||
opacity: owned ? 1 : 0.3,
|
opacity: owned ? 1 : 0.3,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export function Transport({ playing, onToggle, onNext, onPrevious, size, gap }:
|
|||||||
className="round"
|
className="round"
|
||||||
onClick={onPrevious}
|
onClick={onPrevious}
|
||||||
aria-label="Vorheriger Titel"
|
aria-label="Vorheriger Titel"
|
||||||
style={{ width: size, height: size, background: "oklch(30% 0.04 210)" }}
|
style={{ width: size, height: size, background: "oklch(30% 0.04 var(--hue))" }}
|
||||||
>
|
>
|
||||||
<span style={{ display: "flex", alignItems: "center", gap: 2 }}>
|
<span style={{ display: "flex", alignItems: "center", gap: 2 }}>
|
||||||
<span style={{ width: bar, height: glyph, borderRadius: 1, background: "#fff" }} />
|
<span style={{ width: bar, height: glyph, borderRadius: 1, background: "#fff" }} />
|
||||||
@@ -47,7 +47,7 @@ export function Transport({ playing, onToggle, onNext, onPrevious, size, gap }:
|
|||||||
width: playSize,
|
width: playSize,
|
||||||
height: playSize,
|
height: playSize,
|
||||||
background: "var(--accent)",
|
background: "var(--accent)",
|
||||||
boxShadow: "0 6px 20px oklch(70% 0.16 340 / .5)",
|
boxShadow: "0 6px 20px oklch(70% 0.16 var(--accent-hue) / .5)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{playing ? (
|
{playing ? (
|
||||||
@@ -75,7 +75,7 @@ export function Transport({ playing, onToggle, onNext, onPrevious, size, gap }:
|
|||||||
className="round"
|
className="round"
|
||||||
onClick={onNext}
|
onClick={onNext}
|
||||||
aria-label="Nächster Titel"
|
aria-label="Nächster Titel"
|
||||||
style={{ width: size, height: size, background: "oklch(30% 0.04 210)" }}
|
style={{ width: size, height: size, background: "oklch(30% 0.04 var(--hue))" }}
|
||||||
>
|
>
|
||||||
<span style={{ display: "flex", alignItems: "center", gap: 2 }}>
|
<span style={{ display: "flex", alignItems: "center", gap: 2 }}>
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export function VolumeBars({
|
|||||||
padding: 0,
|
padding: 0,
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
background:
|
background:
|
||||||
volume >= level ? "var(--accent)" : "oklch(88% 0.02 210 / .4)",
|
volume >= level ? "var(--accent)" : "oklch(88% 0.02 var(--hue) / .4)",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { albumLine, isBook, unitLabel } from "../../lib/covers";
|
|||||||
import { clock } from "../../lib/format";
|
import { clock } from "../../lib/format";
|
||||||
import type { Group, Mode, SearchIndex, SongHit } from "../../lib/search";
|
import type { Group, Mode, SearchIndex, SongHit } from "../../lib/search";
|
||||||
import { effectiveSearch, groupOf, results as computeResults } from "../../lib/search";
|
import { effectiveSearch, groupOf, results as computeResults } from "../../lib/search";
|
||||||
|
import { SKIN } from "../../lib/skins";
|
||||||
import { GROUP_ICON, GROUP_LABEL } from "../../lib/theme";
|
import { GROUP_ICON, GROUP_LABEL } from "../../lib/theme";
|
||||||
import { CategoryTile } from "../BrowseView";
|
import { CategoryTile } from "../BrowseView";
|
||||||
import { Cover } from "../Cover";
|
import { Cover } from "../Cover";
|
||||||
@@ -135,8 +136,8 @@ function MobileBrowseImpl({
|
|||||||
return (
|
return (
|
||||||
<div className="m-scroll" key={group}>
|
<div className="m-scroll" key={group}>
|
||||||
<div className="m-title-row">
|
<div className="m-title-row">
|
||||||
<img src="/dolphin-mascot.png" alt="" className="m-title-mascot" />
|
<img src={SKIN.mascot} alt="" className="m-title-mascot" />
|
||||||
<div className="m-title">Musik Delfin</div>
|
<div className="m-title">{SKIN.appName}</div>
|
||||||
{statusLabel && (
|
{statusLabel && (
|
||||||
<div className="m-status" data-offline={offline}>
|
<div className="m-status" data-offline={offline}>
|
||||||
{statusLabel}
|
{statusLabel}
|
||||||
@@ -253,7 +254,7 @@ function Segment({
|
|||||||
function Hint({ children }: { children: ReactNode }) {
|
function Hint({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="m-hint">
|
<div className="m-hint">
|
||||||
<img src="/dolphin-mascot.png" alt="" />
|
<img src={SKIN.mascot} alt="" />
|
||||||
<div>{children}</div>
|
<div>{children}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
/** The header, matching ../../web/src/components/AppHeader.tsx so the two apps open the
|
/** 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. */
|
* same way. The mascot bobs; `SHOW_DRIFT` doubles as the decorative-motion switch. */
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import { SKIN } from "../../lib/skins";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -12,7 +13,7 @@ interface Props {
|
|||||||
onMascotClick?: () => void;
|
onMascotClick?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppHeader({ title = "Delfin Tippen", status, compact = false, onMascotClick }: Props) {
|
export function AppHeader({ title = SKIN.tippenName, status, compact = false, onMascotClick }: Props) {
|
||||||
const size = compact ? 44 : 64;
|
const size = compact ? 44 : 64;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -27,14 +28,14 @@ export function AppHeader({ title = "Delfin Tippen", status, compact = false, on
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src="/dolphin-mascot.png"
|
src={SKIN.mascot}
|
||||||
alt=""
|
alt=""
|
||||||
onClick={onMascotClick}
|
onClick={onMascotClick}
|
||||||
style={{
|
style={{
|
||||||
width: size,
|
width: size,
|
||||||
height: size,
|
height: size,
|
||||||
objectFit: "contain",
|
objectFit: "contain",
|
||||||
animation: "dolphinBob 4s ease-in-out infinite",
|
animation: "mascotBob 4s ease-in-out infinite",
|
||||||
cursor: onMascotClick ? "pointer" : "default",
|
cursor: onMascotClick ? "pointer" : "default",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -43,7 +44,7 @@ export function AppHeader({ title = "Delfin Tippen", status, compact = false, on
|
|||||||
fontSize: compact ? 24 : 34,
|
fontSize: compact ? 24 : 34,
|
||||||
fontWeight: 900,
|
fontWeight: 900,
|
||||||
color: "var(--paper)",
|
color: "var(--paper)",
|
||||||
textShadow: "0 3px 14px oklch(15% 0.05 175 / .5)",
|
textShadow: "0 3px 14px oklch(15% 0.05 var(--tp-hue) / .5)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
/** 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
117
web/src/components/tippen/DinoEgg.tsx
Normal file
117
web/src/components/tippen/DinoEgg.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
/** A speckled dinosaur egg that hatches - the dinosaur skin's treasure chest.
|
||||||
|
*
|
||||||
|
* Built to be a drop-in for TreasureChest (same props, same 64x62 view box, same
|
||||||
|
* `.tp-reward-lid` hinge), so the lesson map and the unlock overlay need not know which
|
||||||
|
* one they are drawing. The top of the shell, above a zigzag crack, is the lid: it hinges
|
||||||
|
* at the crack's left end and tips back the way the chest's lid swings. Inside is the
|
||||||
|
* same dark opening with light coming out of it.
|
||||||
|
*
|
||||||
|
* The crack sits a little above the egg's middle on purpose. The overlay and the map
|
||||||
|
* stand a cover up inside the box with its foot at about half height, and that foot has
|
||||||
|
* to be hidden behind the bottom shell, just as it is behind the chest's front wall.
|
||||||
|
*
|
||||||
|
* The two halves are one egg outline clipped twice, not two hand-drawn shapes: the
|
||||||
|
* pieces then meet along the crack exactly, at every size. */
|
||||||
|
|
||||||
|
import type { RewardBoxProps } from "./RewardBox";
|
||||||
|
|
||||||
|
/** Instance-unique ids for the gradient and the two clip paths - see TreasureChest. */
|
||||||
|
let nextId = 0;
|
||||||
|
|
||||||
|
const EGG = "M32 3 C45 3 54 24 54 37 C54 50 44 59 32 59 C20 59 10 50 10 37 C10 24 19 3 32 3 Z";
|
||||||
|
|
||||||
|
/** The crack, left to right. Its ends sit on the egg's outline; the lid hinges at the
|
||||||
|
* left one. */
|
||||||
|
const CRACK: readonly (readonly [number, number])[] = [
|
||||||
|
[11, 28.5], [16, 24.5], [21, 30], [26, 24], [32, 30], [38, 24], [43, 30], [48, 24.5], [53, 28.5],
|
||||||
|
];
|
||||||
|
const CRACK_POINTS = CRACK.map(([x, y]) => `${x},${y}`).join(" ");
|
||||||
|
const ABOVE_CRACK = `0,0 64,0 64,28.5 ${[...CRACK].reverse().map(([x, y]) => `${x},${y}`).join(" ")} 0,28.5`;
|
||||||
|
const BELOW_CRACK = `0,28.5 ${CRACK_POINTS} 64,28.5 64,62 0,62`;
|
||||||
|
|
||||||
|
const OUTLINE = "oklch(42% 0.05 70)";
|
||||||
|
const SPOT = "oklch(64% 0.12 145)";
|
||||||
|
|
||||||
|
export function DinoEgg({ size, open, layer = "all", muted = false, className, style }: RewardBoxProps) {
|
||||||
|
const id = `tp-egg-${(nextId += 1)}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 64 62"
|
||||||
|
className={className}
|
||||||
|
aria-hidden
|
||||||
|
style={{
|
||||||
|
overflow: "visible",
|
||||||
|
filter: muted ? "grayscale(0.35) brightness(0.9)" : undefined,
|
||||||
|
opacity: muted ? 0.9 : 1,
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id={`${id}-shell`} x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stopColor="oklch(97% 0.03 95)" />
|
||||||
|
<stop offset="100%" stopColor="oklch(83% 0.06 80)" />
|
||||||
|
</linearGradient>
|
||||||
|
<radialGradient id={`${id}-glow`}>
|
||||||
|
<stop offset="0%" stopColor="oklch(97% 0.14 95 / 0.95)" />
|
||||||
|
<stop offset="55%" stopColor="oklch(90% 0.16 92 / 0.35)" />
|
||||||
|
<stop offset="100%" stopColor="oklch(90% 0.16 92 / 0)" />
|
||||||
|
</radialGradient>
|
||||||
|
<clipPath id={`${id}-top`}>
|
||||||
|
<polygon points={ABOVE_CRACK} />
|
||||||
|
</clipPath>
|
||||||
|
<clipPath id={`${id}-bottom`}>
|
||||||
|
<polygon points={BELOW_CRACK} />
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
{/* The opening and its light, before the bottom shell - as in the chest, so the
|
||||||
|
shell's rim covers their lower half and it reads as a hole in the egg. */}
|
||||||
|
{layer !== "front" && open && (
|
||||||
|
<>
|
||||||
|
<ellipse cx="32" cy="28" rx="20" ry="5" fill="oklch(24% 0.04 60)" />
|
||||||
|
<circle cx="32" cy="25" r="24" fill={`url(#${id}-glow)`} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{layer === "back" && <TopShell id={id} open={open} />}
|
||||||
|
|
||||||
|
{layer !== "back" && (
|
||||||
|
<>
|
||||||
|
<g clipPath={`url(#${id}-bottom)`}>
|
||||||
|
<path d={EGG} fill={`url(#${id}-shell)`} stroke={OUTLINE} strokeWidth="2" />
|
||||||
|
<circle cx="18" cy="41" r="3" fill={SPOT} />
|
||||||
|
<circle cx="30" cy="47" r="2.4" fill={SPOT} />
|
||||||
|
<circle cx="44" cy="39" r="3.4" fill={SPOT} />
|
||||||
|
<circle cx="39" cy="53" r="2" fill={SPOT} />
|
||||||
|
<circle cx="24" cy="53" r="1.8" fill={SPOT} />
|
||||||
|
</g>
|
||||||
|
<polyline points={CRACK_POINTS} fill="none" stroke={OUTLINE} strokeWidth="1.6" strokeLinejoin="round" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* In one piece, the top goes last, over the crack's edge. */}
|
||||||
|
{layer === "all" && <TopShell id={id} open={open} />}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The cap of the shell, hinged at the crack's left end. */
|
||||||
|
function TopShell({ id, open }: { id: string; open: boolean }) {
|
||||||
|
const [hingeX, hingeY] = CRACK[0]!;
|
||||||
|
return (
|
||||||
|
<g className="tp-reward-lid" data-open={open || undefined} style={{ transformOrigin: `${hingeX}px ${hingeY}px` }}>
|
||||||
|
<g clipPath={`url(#${id}-top)`}>
|
||||||
|
<path d={EGG} fill={`url(#${id}-shell)`} stroke={OUTLINE} strokeWidth="2" />
|
||||||
|
<circle cx="24" cy="15" r="3.2" fill={SPOT} />
|
||||||
|
<circle cx="38" cy="10" r="2.2" fill={SPOT} />
|
||||||
|
<circle cx="43" cy="20" r="2.6" fill={SPOT} />
|
||||||
|
<circle cx="19" cy="24" r="1.6" fill={SPOT} />
|
||||||
|
<ellipse cx="23" cy="11" rx="2.5" ry="5" transform="rotate(25 23 11)" fill="oklch(100% 0 0 / 0.5)" />
|
||||||
|
</g>
|
||||||
|
<polyline points={CRACK_POINTS} fill="none" stroke={OUTLINE} strokeWidth="1.6" strokeLinejoin="round" />
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,12 +18,12 @@
|
|||||||
import { Fragment } from "react";
|
import { Fragment } from "react";
|
||||||
|
|
||||||
import { coverUrl } from "../../api/client";
|
import { coverUrl } from "../../api/client";
|
||||||
|
import { SKIN } from "../../lib/skins";
|
||||||
import type { Lesson, World } from "../../lib/tippen/curriculum";
|
import type { Lesson, World } from "../../lib/tippen/curriculum";
|
||||||
import { animalById } from "../../lib/tippen/grading";
|
import { animalById } from "../../lib/tippen/grading";
|
||||||
import { NODE_SPACING, pathD, pointFor } from "../../lib/tippen/lessonPath";
|
import { NODE_SPACING, pathD, pointFor } from "../../lib/tippen/lessonPath";
|
||||||
import { MODE_INFO } from "../../lib/tippen/modeInfo";
|
|
||||||
import type { Progress } from "../../lib/tippen/progress";
|
import type { Progress } from "../../lib/tippen/progress";
|
||||||
import { TreasureChest } from "./TreasureChest";
|
import { RewardBox } from "./RewardBox";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
worlds: readonly World[];
|
worlds: readonly World[];
|
||||||
@@ -96,7 +96,7 @@ export function LessonMap({ worlds, lessons, progress, selected, onPick, nextLes
|
|||||||
height={height}
|
height={height}
|
||||||
viewBox={`${-PATH_HALF_WIDTH} 0 ${PATH_HALF_WIDTH * 2} ${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" />
|
<path d={pathD(points)} style={{ stroke: "oklch(97% 0.01 var(--tp-hue) / 0.35)" }} strokeWidth={8} strokeLinecap="round" fill="none" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
{worldLessons.map((lesson, i) => {
|
{worldLessons.map((lesson, i) => {
|
||||||
@@ -105,7 +105,7 @@ export function LessonMap({ worlds, lessons, progress, selected, onPick, nextLes
|
|||||||
const animal = entry?.bestAnimal ? animalById(entry.bestAnimal) : null;
|
const animal = entry?.bestAnimal ? animalById(entry.bestAnimal) : null;
|
||||||
const index = lessons.indexOf(lesson);
|
const index = lessons.indexOf(lesson);
|
||||||
const point = points[i]!;
|
const point = points[i]!;
|
||||||
const modeInfo = MODE_INFO[lesson.primaryMode];
|
const modeInfo = SKIN.game.modes[lesson.primaryMode];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// The node and its chest are two siblings, both positioned against
|
// The node and its chest are two siblings, both positioned against
|
||||||
@@ -257,7 +257,7 @@ function RewardChest({
|
|||||||
transform: "translate(-50%, -50%)",
|
transform: "translate(-50%, -50%)",
|
||||||
pointerEvents: "none",
|
pointerEvents: "none",
|
||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
filter: "drop-shadow(0 3px 6px oklch(15% 0.05 175 / 0.55))",
|
filter: "drop-shadow(0 3px 6px oklch(15% 0.05 var(--tp-hue) / 0.55))",
|
||||||
}}
|
}}
|
||||||
title={earned ? "Freigespielt! Im Musik-Player zu hören" : "Hier gibt es neue Musik zu gewinnen!"}
|
title={earned ? "Freigespielt! Im Musik-Player zu hören" : "Hier gibt es neue Musik zu gewinnen!"}
|
||||||
>
|
>
|
||||||
@@ -267,7 +267,7 @@ function RewardChest({
|
|||||||
TreasureChest's `layer`. */}
|
TreasureChest's `layer`. */}
|
||||||
{earned && albumId ? (
|
{earned && albumId ? (
|
||||||
<div style={{ position: "relative", width: CHEST_SIZE, height: CHEST_SIZE }}>
|
<div style={{ position: "relative", width: CHEST_SIZE, height: CHEST_SIZE }}>
|
||||||
<TreasureChest size={CHEST_SIZE} open layer="back" style={{ position: "absolute", inset: 0 }} />
|
<RewardBox size={CHEST_SIZE} open layer="back" style={{ position: "absolute", inset: 0 }} />
|
||||||
<img
|
<img
|
||||||
src={coverUrl(albumId)}
|
src={coverUrl(albumId)}
|
||||||
alt=""
|
alt=""
|
||||||
@@ -288,10 +288,10 @@ function RewardChest({
|
|||||||
border: "1.5px solid oklch(92% 0.13 92 / 0.9)",
|
border: "1.5px solid oklch(92% 0.13 92 / 0.9)",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<TreasureChest size={CHEST_SIZE} open layer="front" style={{ position: "absolute", inset: 0 }} />
|
<RewardBox size={CHEST_SIZE} open layer="front" style={{ position: "absolute", inset: 0 }} />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<TreasureChest size={CHEST_SIZE} open={earned} muted={!earned} />
|
<RewardBox size={CHEST_SIZE} open={earned} muted={!earned} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
37
web/src/components/tippen/PetPicture.tsx
Normal file
37
web/src/components/tippen/PetPicture.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
/** A pet as drawn - or, for a skin whose drawings do not exist yet, its stand-in emoji
|
||||||
|
* in the same square, so every place that shows a pet works with either. */
|
||||||
|
|
||||||
|
import type { CSSProperties } from "react";
|
||||||
|
|
||||||
|
import type { Creature } from "../../lib/tippen/pets";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
creature: Creature;
|
||||||
|
/** Edge of the square, px. */
|
||||||
|
size: number;
|
||||||
|
title?: string;
|
||||||
|
style?: CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PetPicture({ creature, size, title, style }: Props) {
|
||||||
|
const box: CSSProperties = { width: size, height: size, flex: "none", ...style };
|
||||||
|
if ("image" in creature.art) {
|
||||||
|
return <img src={creature.art.image} alt="" title={title} draggable={false} style={{ objectFit: "contain", ...box }} />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
title={title}
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
fontSize: size * 0.85,
|
||||||
|
lineHeight: 1,
|
||||||
|
...box,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{creature.art.emoji}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
/** The pets, swimming freely behind every screen.
|
/** The pets, moving freely behind every screen.
|
||||||
*
|
*
|
||||||
* One requestAnimationFrame loop, started once and never restarted - the same approach as
|
* 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
|
* ../../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
|
* 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.
|
* renders this component only when a pet moves in or the layer is dimmed.
|
||||||
*
|
*
|
||||||
|
* Where a pet goes is its skin's `motion`: swimmers use the whole tank, walkers the
|
||||||
|
* ground and fliers the sky (`BANDS` in lib/tippen/pets.ts).
|
||||||
|
*
|
||||||
* The layer sits at z-index -1 inside the stage's own stacking context (`isolation` on
|
* 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
|
* .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.
|
* panels frost a pet that drifts behind one, instead of it swimming over the buttons.
|
||||||
@@ -15,8 +18,8 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
import { PERF } from "../../lib/perfSettings";
|
import { PERF } from "../../lib/perfSettings";
|
||||||
import { creatureById, createSwimmer, pose, stepSwimmer } from "../../lib/tippen/aquarium";
|
import { BANDS, creatureById, createSwimmer, pose, stepSwimmer } from "../../lib/tippen/pets";
|
||||||
import type { CreatureId, Swimmer } from "../../lib/tippen/aquarium";
|
import type { CreatureId, Swimmer } from "../../lib/tippen/pets";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
creatures: readonly CreatureId[];
|
creatures: readonly CreatureId[];
|
||||||
@@ -28,9 +31,9 @@ interface Props {
|
|||||||
* teleport every pet, so a frame never counts for more than this. */
|
* teleport every pet, so a frame never counts for more than this. */
|
||||||
const MAX_STEP_S = 0.1;
|
const MAX_STEP_S = 0.1;
|
||||||
|
|
||||||
export function AquariumCreatures({ creatures, opacity }: Props) {
|
export function Pets({ creatures, opacity }: Props) {
|
||||||
const tank = useRef<HTMLDivElement>(null);
|
const tank = useRef<HTMLDivElement>(null);
|
||||||
const images = useRef(new Map<CreatureId, HTMLImageElement>());
|
const images = useRef(new Map<CreatureId, HTMLElement>());
|
||||||
const swimmers = useRef(new Map<CreatureId, Swimmer>());
|
const swimmers = useRef(new Map<CreatureId, Swimmer>());
|
||||||
const current = useRef(creatures);
|
const current = useRef(creatures);
|
||||||
current.current = creatures;
|
current.current = creatures;
|
||||||
@@ -59,17 +62,20 @@ export function AquariumCreatures({ creatures, opacity }: Props) {
|
|||||||
const height = creature.size * size.height;
|
const height = creature.size * size.height;
|
||||||
const margin = height / 2;
|
const margin = height / 2;
|
||||||
const speed = creature.speed * size.width;
|
const speed = creature.speed * size.width;
|
||||||
|
const band = BANDS[creature.motion];
|
||||||
|
|
||||||
const previous = swimmers.current.get(id);
|
const previous = swimmers.current.get(id);
|
||||||
const s = previous
|
const s = previous
|
||||||
? reducedMotion
|
? reducedMotion
|
||||||
? previous
|
? previous
|
||||||
: stepSwimmer(previous, dt, size, margin, speed, Math.random)
|
: stepSwimmer(previous, dt, size, margin, speed, Math.random, band)
|
||||||
: createSwimmer(size, margin, Math.random, !reducedMotion && !present.current.has(id));
|
: createSwimmer(size, margin, Math.random, !reducedMotion && !present.current.has(id), band);
|
||||||
swimmers.current.set(id, s);
|
swimmers.current.set(id, s);
|
||||||
|
|
||||||
const p = pose(s, creature, speed);
|
const p = pose(s, creature, speed);
|
||||||
image.style.height = `${height}px`;
|
image.style.height = `${height}px`;
|
||||||
|
// Only an emoji stand-in reads this; a drawing is sized by its height alone.
|
||||||
|
image.style.fontSize = `${height * 0.85}px`;
|
||||||
image.style.transform =
|
image.style.transform =
|
||||||
`translate(${p.x}px, ${p.y}px) translate(-50%, -50%) ` +
|
`translate(${p.x}px, ${p.y}px) translate(-50%, -50%) ` +
|
||||||
`rotate(${p.rotation}deg) scaleX(${p.mirror})`;
|
`rotate(${p.rotation}deg) scaleX(${p.mirror})`;
|
||||||
@@ -96,28 +102,30 @@ export function AquariumCreatures({ creatures, opacity }: Props) {
|
|||||||
transition: "opacity 700ms ease",
|
transition: "opacity 700ms ease",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{creatures.map((id) => (
|
{creatures.map((id) => {
|
||||||
<img
|
const ref = (element: HTMLElement | null) => {
|
||||||
key={id}
|
if (element) images.current.set(id, element);
|
||||||
ref={(image) => {
|
else images.current.delete(id);
|
||||||
if (image) images.current.set(id, image);
|
};
|
||||||
else images.current.delete(id);
|
const style = {
|
||||||
}}
|
position: "absolute",
|
||||||
src={creatureById(id).image}
|
left: 0,
|
||||||
alt=""
|
top: 0,
|
||||||
draggable={false}
|
height: 0,
|
||||||
style={{
|
// Off-stage until the loop has placed it, so nothing flashes in the corner.
|
||||||
position: "absolute",
|
transform: "translate(-200vw, 0)",
|
||||||
left: 0,
|
willChange: "transform",
|
||||||
top: 0,
|
filter: "drop-shadow(0 10px 16px oklch(15% 0.05 var(--tp-hue) / 0.35))",
|
||||||
height: 0,
|
} as const;
|
||||||
// Off-stage until the loop has placed it, so nothing flashes in the corner.
|
const { art } = creatureById(id);
|
||||||
transform: "translate(-200vw, 0)",
|
return "image" in art ? (
|
||||||
willChange: "transform",
|
<img key={id} ref={ref} src={art.image} alt="" draggable={false} style={style} />
|
||||||
filter: "drop-shadow(0 10px 16px oklch(15% 0.05 175 / 0.35))",
|
) : (
|
||||||
}}
|
<span key={id} ref={ref} style={{ ...style, lineHeight: 1 }}>
|
||||||
/>
|
{art.emoji}
|
||||||
))}
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -8,20 +8,21 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
import { coverUrl } from "../../api/client";
|
import { coverUrl } from "../../api/client";
|
||||||
import { creatureById } from "../../lib/tippen/aquarium";
|
import { SKIN } from "../../lib/skins";
|
||||||
import type { CreatureId } from "../../lib/tippen/aquarium";
|
import { creatureById } from "../../lib/tippen/pets";
|
||||||
|
import type { CreatureId } from "../../lib/tippen/pets";
|
||||||
import type { ModeId } from "../../lib/tippen/curriculum";
|
import type { ModeId } from "../../lib/tippen/curriculum";
|
||||||
import type { RunResult } from "../../lib/tippen/grading";
|
import type { RunResult } from "../../lib/tippen/grading";
|
||||||
import { STAR_THRESHOLDS, visibleAnimals, animalById, animalIndex, animalProgress } from "../../lib/tippen/grading";
|
import { STAR_THRESHOLDS, visibleAnimals, animalById, animalIndex, animalProgress } from "../../lib/tippen/grading";
|
||||||
import type { AnimalId } 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";
|
import type { UnlockedReward } from "../../lib/tippen/progress";
|
||||||
|
import { PetPicture } from "./PetPicture";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
result: RunResult;
|
result: RunResult;
|
||||||
/** Set when this run opened the next lesson. */
|
/** Set when this run opened the next lesson. */
|
||||||
unlockedTitle: string | null;
|
unlockedTitle: string | null;
|
||||||
/** Set when this run released a creature into the aquarium. */
|
/** Set when this run released a pet - into the aquarium, the dino valley, … */
|
||||||
newCreature: CreatureId | null;
|
newCreature: CreatureId | null;
|
||||||
/** Set when this run's lesson named an `unlocks:` target that just became reachable -
|
/** Set when this run's lesson named an `unlocks:` target that just became reachable -
|
||||||
* the literal track/episode it names, not "one of several". */
|
* the literal track/episode it names, not "one of several". */
|
||||||
@@ -101,7 +102,7 @@ export function ResultSheet({
|
|||||||
style={{
|
style={{
|
||||||
height: 9,
|
height: 9,
|
||||||
borderRadius: 999,
|
borderRadius: 999,
|
||||||
background: "oklch(88% 0.02 175)",
|
background: "oklch(88% 0.02 var(--tp-hue))",
|
||||||
margin: "18px 0 6px",
|
margin: "18px 0 6px",
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
}}
|
}}
|
||||||
@@ -140,12 +141,8 @@ export function ResultSheet({
|
|||||||
marginTop: 8,
|
marginTop: 8,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<img
|
<PetPicture creature={creatureById(newCreature)} size={52} style={{ animation: "tierEnter 520ms ease-out" }} />
|
||||||
src={creatureById(newCreature).image}
|
{creatureById(newCreature).article} {creatureById(newCreature).name} ist {SKIN.game.habitat} gezogen!
|
||||||
alt=""
|
|
||||||
style={{ width: 52, height: 52, objectFit: "contain", animation: "tierEnter 520ms ease-out" }}
|
|
||||||
/>
|
|
||||||
{creatureById(newCreature).article} {creatureById(newCreature).name} ist ins Aquarium gezogen!
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* The recap, not the reveal. RewardUnlockOverlay has already given this its own
|
{/* The recap, not the reveal. RewardUnlockOverlay has already given this its own
|
||||||
@@ -205,7 +202,7 @@ export function ResultSheet({
|
|||||||
Nochmal ⏎
|
Nochmal ⏎
|
||||||
</button>
|
</button>
|
||||||
{continueLabel && (
|
{continueLabel && (
|
||||||
<button onClick={onContinue} style={{ ...buttonStyle, background: "oklch(90% 0.02 175)", color: "var(--ink)" }}>
|
<button onClick={onContinue} style={{ ...buttonStyle, background: "oklch(90% 0.02 var(--tp-hue))", color: "var(--ink)" }}>
|
||||||
{continueLabel}
|
{continueLabel}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -228,11 +225,11 @@ export function ResultSheet({
|
|||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: 800,
|
fontWeight: 800,
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
background: "oklch(90% 0.02 175)",
|
background: "oklch(90% 0.02 var(--tp-hue))",
|
||||||
color: "var(--ink)",
|
color: "var(--ink)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{MODE_INFO[mode].emoji} {MODE_INFO[mode].name}
|
{SKIN.game.modes[mode].emoji} {SKIN.game.modes[mode].name}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
27
web/src/components/tippen/RewardBox.tsx
Normal file
27
web/src/components/tippen/RewardBox.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
/** What a music reward comes out of: the dolphin's treasure chest or the dinosaur's egg
|
||||||
|
* (`game.rewardBox` in the skin). Both draw the same way - a lid that hinges open, split
|
||||||
|
* into a back and a front layer so a cover can rise out from between them - so every
|
||||||
|
* caller works with either. */
|
||||||
|
|
||||||
|
import type { CSSProperties } from "react";
|
||||||
|
|
||||||
|
import { SKIN } from "../../lib/skins";
|
||||||
|
import { DinoEgg } from "./DinoEgg";
|
||||||
|
import { TreasureChest } from "./TreasureChest";
|
||||||
|
|
||||||
|
export interface RewardBoxProps {
|
||||||
|
size: number;
|
||||||
|
open: boolean;
|
||||||
|
/** Which half to draw - see TreasureChest's header. "all" is the whole box, in one
|
||||||
|
* element. */
|
||||||
|
layer?: "all" | "back" | "front";
|
||||||
|
/** Softened - a reward whose lesson is still ahead of her. Muted rather than greyed
|
||||||
|
* out: a shut chest still has to look like treasure, or the badge promises nothing. */
|
||||||
|
muted?: boolean;
|
||||||
|
className?: string;
|
||||||
|
style?: CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RewardBox(props: RewardBoxProps) {
|
||||||
|
return SKIN.game.rewardBox === "egg" ? <DinoEgg {...props} /> : <TreasureChest {...props} />;
|
||||||
|
}
|
||||||
@@ -29,7 +29,7 @@ import {
|
|||||||
playRewardJingle,
|
playRewardJingle,
|
||||||
} from "../../lib/tippen/pop";
|
} from "../../lib/tippen/pop";
|
||||||
import type { UnlockedReward } from "../../lib/tippen/progress";
|
import type { UnlockedReward } from "../../lib/tippen/progress";
|
||||||
import { TreasureChest } from "./TreasureChest";
|
import { RewardBox } from "./RewardBox";
|
||||||
|
|
||||||
type Phase = "drop" | "open" | "reveal";
|
type Phase = "drop" | "open" | "reveal";
|
||||||
|
|
||||||
@@ -69,7 +69,7 @@ const COPY: Record<
|
|||||||
|
|
||||||
const CONFETTI_COLOURS = [
|
const CONFETTI_COLOURS = [
|
||||||
"oklch(85% 0.17 88)",
|
"oklch(85% 0.17 88)",
|
||||||
"oklch(78% 0.16 340)",
|
"oklch(78% 0.16 var(--accent-hue))",
|
||||||
"oklch(82% 0.15 150)",
|
"oklch(82% 0.15 150)",
|
||||||
"oklch(80% 0.14 220)",
|
"oklch(80% 0.14 220)",
|
||||||
"oklch(88% 0.13 60)",
|
"oklch(88% 0.13 60)",
|
||||||
@@ -178,10 +178,10 @@ export function RewardUnlockOverlay({ reward, sound, onClose }: Props) {
|
|||||||
marginTop: COVER_SIZE * 0.78,
|
marginTop: COVER_SIZE * 0.78,
|
||||||
animation: open
|
animation: open
|
||||||
? undefined
|
? undefined
|
||||||
: "chestDrop 520ms cubic-bezier(0.34, 1.4, 0.64, 1), chestRattle 420ms ease-in-out 540ms 2",
|
: "rewardDrop 520ms cubic-bezier(0.34, 1.4, 0.64, 1), rewardRattle 420ms ease-in-out 540ms 2",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TreasureChest size={CHEST_SIZE} open={open} layer="back" style={{ position: "absolute", inset: 0 }} />
|
<RewardBox size={CHEST_SIZE} open={open} layer="back" style={{ position: "absolute", inset: 0 }} />
|
||||||
|
|
||||||
{open && !reducedMotion && <Rays />}
|
{open && !reducedMotion && <Rays />}
|
||||||
|
|
||||||
@@ -199,7 +199,7 @@ export function RewardUnlockOverlay({ reward, sound, onClose }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<TreasureChest size={CHEST_SIZE} open={open} layer="front" style={{ position: "absolute", inset: 0 }} />
|
<RewardBox size={CHEST_SIZE} open={open} layer="front" style={{ position: "absolute", inset: 0 }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -305,7 +305,7 @@ function RewardArt({
|
|||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
fontSize: 92,
|
fontSize: 92,
|
||||||
background:
|
background:
|
||||||
"linear-gradient(160deg, oklch(70% 0.09 175), oklch(45% 0.08 175))",
|
"linear-gradient(160deg, oklch(70% 0.09 var(--tp-hue)), oklch(45% 0.08 var(--tp-hue)))",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{glyph}
|
{glyph}
|
||||||
@@ -321,7 +321,7 @@ function RewardArt({
|
|||||||
style={{
|
style={{
|
||||||
...shared,
|
...shared,
|
||||||
objectFit: "cover",
|
objectFit: "cover",
|
||||||
background: "oklch(45% 0.08 175)",
|
background: "oklch(45% 0.08 var(--tp-hue))",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,16 +3,26 @@
|
|||||||
* by tippen.css to stop the one CSS animation loop that is not a component's to switch
|
* by tippen.css to stop the one CSS animation loop that is not a component's to switch
|
||||||
* off - the pulse on the next key to press.
|
* off - the pulse on the next key to press.
|
||||||
*
|
*
|
||||||
* The pets swim here rather than on the home screen, so they stay with her on the lesson
|
* The pets live here rather than on the home screen, so they stay with her on the lesson
|
||||||
* map and during a run too. */
|
* map and during a run too. */
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
import type { CreatureId } from "../../lib/tippen/aquarium";
|
import type { CreatureId } from "../../lib/tippen/pets";
|
||||||
import { PERF } from "../../lib/perfSettings";
|
import { PERF } from "../../lib/perfSettings";
|
||||||
import { SHOW_AQUARIUM_CREATURES, SHOW_BUBBLES, SHOW_GLASS_BLUR } from "../../lib/tippen/theme";
|
import { SHOW_DRIFT, SHOW_GLASS_BLUR, SHOW_PETS } from "../../lib/tippen/theme";
|
||||||
import { AquariumCreatures } from "./AquariumCreatures";
|
import { Drift } from "../Drift";
|
||||||
import { Bubbles } from "./Bubbles";
|
import type { DriftField } from "../Drift";
|
||||||
|
import { Pets } from "./Pets";
|
||||||
|
|
||||||
|
/** The typing game's background field - see Drift. Sparser and fainter than the room
|
||||||
|
* page's, since it sits behind a typing drill. */
|
||||||
|
const FIELD: DriftField = [
|
||||||
|
[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],
|
||||||
|
];
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -29,8 +39,8 @@ export function Stage({ children, creatures, dimmed }: Props) {
|
|||||||
data-blur={SHOW_GLASS_BLUR ? "on" : "off"}
|
data-blur={SHOW_GLASS_BLUR ? "on" : "off"}
|
||||||
data-anim={PERF.petAnimations ? "on" : "off"}
|
data-anim={PERF.petAnimations ? "on" : "off"}
|
||||||
>
|
>
|
||||||
{SHOW_AQUARIUM_CREATURES && <AquariumCreatures creatures={creatures} opacity={dimmed ? 0.25 : 1} />}
|
{SHOW_PETS && <Pets creatures={creatures} opacity={dimmed ? 0.25 : 1} />}
|
||||||
{SHOW_BUBBLES && <Bubbles />}
|
{SHOW_DRIFT && <Drift field={FIELD} bubbleColor={(alpha) => `oklch(97% 0.01 var(--tp-hue) / ${alpha})`} />}
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
* badge on the lesson map and the 168px chest in the unlock overlay.
|
* badge on the lesson map and the 168px chest in the unlock overlay.
|
||||||
*
|
*
|
||||||
* `open` is a plain prop, not an animation trigger: the lid transitions to its open
|
* `open` is a plain prop, not an animation trigger: the lid transitions to its open
|
||||||
* angle through CSS (`.tp-chest-lid`, tippen.css), so the map can render a permanently
|
* angle through CSS (`.tp-reward-lid`, tippen.css), so the map can render a permanently
|
||||||
* open chest for a claimed reward with no animation bookkeeping at all, and the overlay
|
* open chest for a claimed reward with no animation bookkeeping at all, and the overlay
|
||||||
* gets its swing for free just by flipping the prop mid-sequence.
|
* gets its swing for free just by flipping the prop mid-sequence.
|
||||||
*
|
*
|
||||||
@@ -19,23 +19,13 @@
|
|||||||
* what coming out of a chest actually looks like. The map, which puts nothing inside, and
|
* what coming out of a chest actually looks like. The map, which puts nothing inside, and
|
||||||
* whose shut chests need the lid painted over the body's top edge, takes the default. */
|
* whose shut chests need the lid painted over the body's top edge, takes the default. */
|
||||||
|
|
||||||
|
import type { RewardBoxProps } from "./RewardBox";
|
||||||
|
|
||||||
/** Instance-unique gradient ids. Two chests on one page (the map has many) must not share
|
/** Instance-unique gradient ids. Two chests on one page (the map has many) must not share
|
||||||
* `<defs>` ids, or the second one silently paints with the first one's stops. */
|
* `<defs>` ids, or the second one silently paints with the first one's stops. */
|
||||||
let nextId = 0;
|
let nextId = 0;
|
||||||
|
|
||||||
interface Props {
|
export function TreasureChest({ size, open, layer = "all", muted = false, className, style }: RewardBoxProps) {
|
||||||
size: number;
|
|
||||||
open: boolean;
|
|
||||||
/** Which half to draw - see the header. "all" is the whole chest, in one element. */
|
|
||||||
layer?: "all" | "back" | "front";
|
|
||||||
/** Softened - a reward whose lesson is still ahead of her. Muted rather than greyed
|
|
||||||
* out: a shut chest still has to look like treasure, or the badge promises nothing. */
|
|
||||||
muted?: boolean;
|
|
||||||
className?: string;
|
|
||||||
style?: React.CSSProperties;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TreasureChest({ size, open, layer = "all", muted = false, className, style }: Props) {
|
|
||||||
const id = `tp-chest-${(nextId += 1)}`;
|
const id = `tp-chest-${(nextId += 1)}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -130,7 +120,7 @@ export function TreasureChest({ size, open, layer = "all", muted = false, classN
|
|||||||
* that works in a flat, front-on view. */
|
* that works in a flat, front-on view. */
|
||||||
function Lid({ id, open }: { id: string; open: boolean }) {
|
function Lid({ id, open }: { id: string; open: boolean }) {
|
||||||
return (
|
return (
|
||||||
<g className="tp-chest-lid" data-open={open || undefined} style={{ transformOrigin: "8px 27px" }}>
|
<g className="tp-reward-lid" data-open={open || undefined} style={{ transformOrigin: "8px 27px" }}>
|
||||||
<path d="M8 27 A 24 21 0 0 1 56 27 Z" fill={`url(#${id}-lid)`} stroke="oklch(28% 0.06 45)" strokeWidth="2" />
|
<path d="M8 27 A 24 21 0 0 1 56 27 Z" fill={`url(#${id}-lid)`} stroke="oklch(28% 0.06 45)" strokeWidth="2" />
|
||||||
<rect
|
<rect
|
||||||
x="7"
|
x="7"
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { SKIN } from "../../../lib/skins";
|
||||||
import { currentChar } from "../../../lib/tippen/engine";
|
import { currentChar } from "../../../lib/tippen/engine";
|
||||||
import type { RunEvent } from "../../../lib/tippen/engine";
|
import type { RunEvent } from "../../../lib/tippen/engine";
|
||||||
import { fingerOf } from "../../../lib/tippen/fingers";
|
import { fingerOf } from "../../../lib/tippen/fingers";
|
||||||
@@ -35,6 +36,18 @@ const VISIBLE_COUNT = 5;
|
|||||||
/** Fixed horizontal lanes, so bubbles do not jitter sideways as they rise. */
|
/** Fixed horizontal lanes, so bubbles do not jitter sideways as they rise. */
|
||||||
const LANES = [50, 28, 68, 38, 60, 46];
|
const LANES = [50, 28, 68, 38, 60, 46];
|
||||||
|
|
||||||
|
/** Soap bubbles under water, or (`blobs: "lava"`) glowing lava blobs welling up to the
|
||||||
|
* lava's surface. Only the outline and the glow change - the fill stays the finger's
|
||||||
|
* colour either way, because that colour is what teaches which finger to use. */
|
||||||
|
const LOOK =
|
||||||
|
SKIN.game.blobs === "lava"
|
||||||
|
? {
|
||||||
|
surface: "oklch(74% 0.17 50 / 0.55)",
|
||||||
|
radius: "46% 54% 52% 48% / 56% 50% 50% 44%",
|
||||||
|
glow: ", 0 0 24px oklch(72% 0.18 45 / 0.5)",
|
||||||
|
}
|
||||||
|
: { surface: "oklch(97% 0.01 var(--tp-hue) / 0.25)", radius: "50%", glow: "" };
|
||||||
|
|
||||||
export function BubblesRun({ letters, activeKeys, progress, paused, onFinished }: Props) {
|
export function BubblesRun({ letters, activeKeys, progress, paused, onFinished }: Props) {
|
||||||
const text = letters.join("");
|
const text = letters.join("");
|
||||||
const [popped, setPopped] = useState<number | null>(null);
|
const [popped, setPopped] = useState<number | null>(null);
|
||||||
@@ -76,7 +89,7 @@ export function BubblesRun({ letters, activeKeys, progress, paused, onFinished }
|
|||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
height: 2,
|
height: 2,
|
||||||
background: "oklch(97% 0.01 175 / 0.25)",
|
background: LOOK.surface,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{[...text].map((letter, i) => {
|
{[...text].map((letter, i) => {
|
||||||
@@ -98,18 +111,18 @@ export function BubblesRun({ letters, activeKeys, progress, paused, onFinished }
|
|||||||
transform: "translate(-50%, 0)",
|
transform: "translate(-50%, 0)",
|
||||||
width: size,
|
width: size,
|
||||||
height: size,
|
height: size,
|
||||||
borderRadius: "50%",
|
borderRadius: LOOK.radius,
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
fontSize: size * 0.42,
|
fontSize: size * 0.42,
|
||||||
fontWeight: 900,
|
fontWeight: 900,
|
||||||
color: isCurrent ? "oklch(25% 0.05 175)" : "var(--paper)",
|
color: isCurrent ? "oklch(25% 0.05 var(--tp-hue))" : "var(--paper)",
|
||||||
background: isCurrent
|
background: isCurrent
|
||||||
? "var(--paper)"
|
? "var(--paper)"
|
||||||
: `linear-gradient(160deg, oklch(75% 0.12 ${finger?.hue ?? 175} / .45), oklch(50% 0.09 ${finger?.hue ?? 175} / .2))`,
|
: `linear-gradient(160deg, oklch(75% 0.12 ${finger?.hue ?? "var(--tp-hue)"} / .45), oklch(50% 0.09 ${finger?.hue ?? "var(--tp-hue)"} / .2))`,
|
||||||
border: `2px solid oklch(90% 0.05 ${finger?.hue ?? 175} / ${isCurrent ? 0.9 : 0.35})`,
|
border: `2px solid oklch(90% 0.05 ${finger?.hue ?? "var(--tp-hue)"} / ${isCurrent ? 0.9 : 0.35})`,
|
||||||
boxShadow: isCurrent ? "0 10px 30px var(--shadow)" : "0 4px 14px var(--shadow)",
|
boxShadow: (isCurrent ? "0 10px 30px var(--shadow)" : "0 4px 14px var(--shadow)") + LOOK.glow,
|
||||||
opacity: 1 - distance * 0.13,
|
opacity: 1 - distance * 0.13,
|
||||||
transition: "top 380ms cubic-bezier(.2,.7,.3,1), width 240ms ease, height 240ms ease, font-size 240ms ease",
|
transition: "top 380ms cubic-bezier(.2,.7,.3,1), width 240ms ease, height 240ms ease, font-size 240ms ease",
|
||||||
animation:
|
animation:
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ function ProgressBar({ done, total }: { done: number; total: number }) {
|
|||||||
width: "min(520px, 80%)",
|
width: "min(520px, 80%)",
|
||||||
height: 8,
|
height: 8,
|
||||||
borderRadius: 999,
|
borderRadius: 999,
|
||||||
background: "oklch(97% 0.01 175 / 0.16)",
|
background: "oklch(97% 0.01 var(--tp-hue) / 0.16)",
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { SKIN } from "../../../lib/skins";
|
||||||
import { currentChar } from "../../../lib/tippen/engine";
|
import { currentChar } from "../../../lib/tippen/engine";
|
||||||
import type { RunEvent } from "../../../lib/tippen/engine";
|
import type { RunEvent } from "../../../lib/tippen/engine";
|
||||||
import { chunkOffsets } from "../../../lib/tippen/generator";
|
import { chunkOffsets } from "../../../lib/tippen/generator";
|
||||||
@@ -29,8 +30,6 @@ interface Props {
|
|||||||
onFinished: (result: RunResult) => void;
|
onFinished: (result: RunResult) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FISH = ["🐟", "🐠", "🐡", "🦐", "🦀"];
|
|
||||||
|
|
||||||
export function FeedRun({
|
export function FeedRun({
|
||||||
chunks,
|
chunks,
|
||||||
text,
|
text,
|
||||||
@@ -79,18 +78,18 @@ export function FeedRun({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ position: "relative", flex: 1, width: "100%", minHeight: 0, overflow: "hidden" }}>
|
<div style={{ position: "relative", flex: 1, width: "100%", minHeight: 0, overflow: "hidden" }}>
|
||||||
{/* The dolphin waits on the left; the fish come to it. */}
|
{/* The feeder waits on the left; the food comes to it. */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
left: 8,
|
left: 8,
|
||||||
top: "42%",
|
top: "42%",
|
||||||
fontSize: 66,
|
fontSize: 66,
|
||||||
animation: "dolphinBob 3.2s ease-in-out infinite",
|
animation: "mascotBob 3.2s ease-in-out infinite",
|
||||||
zIndex: 2,
|
zIndex: 2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
🐬
|
{SKIN.game.feeder}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{chunks.map((word, i) => {
|
{chunks.map((word, i) => {
|
||||||
@@ -115,8 +114,8 @@ export function FeedRun({
|
|||||||
gap: 10,
|
gap: 10,
|
||||||
padding: isTarget ? "10px 18px" : "7px 13px",
|
padding: isTarget ? "10px 18px" : "7px 13px",
|
||||||
borderRadius: 999,
|
borderRadius: 999,
|
||||||
background: isTarget ? "var(--paper)" : "oklch(97% 0.01 175 / .13)",
|
background: isTarget ? "var(--paper)" : "oklch(97% 0.01 var(--tp-hue) / .13)",
|
||||||
border: `2px solid oklch(97% 0.01 175 / ${isTarget ? 0.85 : 0.2})`,
|
border: `2px solid oklch(97% 0.01 var(--tp-hue) / ${isTarget ? 0.85 : 0.2})`,
|
||||||
boxShadow: isTarget ? "0 10px 28px var(--shadow)" : "0 3px 10px var(--shadow)",
|
boxShadow: isTarget ? "0 10px 28px var(--shadow)" : "0 3px 10px var(--shadow)",
|
||||||
maxWidth: "34%",
|
maxWidth: "34%",
|
||||||
opacity: 1 - distance * 0.22,
|
opacity: 1 - distance * 0.22,
|
||||||
@@ -125,13 +124,13 @@ export function FeedRun({
|
|||||||
zIndex: isTarget ? 3 : 1,
|
zIndex: isTarget ? 3 : 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={{ fontSize: isTarget ? 34 : 24 }}>{FISH[i % FISH.length]}</span>
|
<span style={{ fontSize: isTarget ? 34 : 24 }}>{SKIN.game.feedItems[i % SKIN.game.feedItems.length]}</span>
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
fontSize: isTarget ? 34 : 22,
|
fontSize: isTarget ? 34 : 22,
|
||||||
fontWeight: 800,
|
fontWeight: 800,
|
||||||
color: isTarget ? "oklch(25% 0.05 175)" : "var(--paper)",
|
color: isTarget ? "oklch(25% 0.05 var(--tp-hue))" : "var(--paper)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isTarget
|
{isTarget
|
||||||
@@ -147,7 +146,7 @@ export function FeedRun({
|
|||||||
? "oklch(58% 0.15 150)"
|
? "oklch(58% 0.15 150)"
|
||||||
: charState === "current"
|
: charState === "current"
|
||||||
? "var(--accent)"
|
? "var(--accent)"
|
||||||
: "oklch(45% 0.03 175)",
|
: "oklch(45% 0.03 var(--tp-hue))",
|
||||||
textDecoration: charState === "current" ? "underline" : undefined,
|
textDecoration: charState === "current" ? "underline" : undefined,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
import { SKIN } from "../../../lib/skins";
|
||||||
import { currentChar } from "../../../lib/tippen/engine";
|
import { currentChar } from "../../../lib/tippen/engine";
|
||||||
import type { RunResult } from "../../../lib/tippen/grading";
|
import type { RunResult } from "../../../lib/tippen/grading";
|
||||||
import type { Progress } from "../../../lib/tippen/progress";
|
import type { Progress } from "../../../lib/tippen/progress";
|
||||||
@@ -127,13 +128,13 @@ export function RaceRun({
|
|||||||
transition: "left 140ms ease-out",
|
transition: "left 140ms ease-out",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
🐬
|
{SKIN.game.racer}
|
||||||
</div>
|
</div>
|
||||||
</Lane>
|
</Lane>
|
||||||
|
|
||||||
<Lane label={ghost ? "Dein Rekord" : "Die Krabbe"} color="oklch(97% 0.01 175 / .55)">
|
<Lane label={ghost ? "Dein Rekord" : SKIN.game.rival.label} color="oklch(97% 0.01 var(--tp-hue) / .55)">
|
||||||
<div ref={opponent} style={{ position: "absolute", left: "0%", transform: "translateX(-50%)", fontSize: 30 }}>
|
<div ref={opponent} style={{ position: "absolute", left: "0%", transform: "translateX(-50%)", fontSize: 30 }}>
|
||||||
{ghost ? "👻" : "🦀"}
|
{ghost ? "👻" : SKIN.game.rival.emoji}
|
||||||
</div>
|
</div>
|
||||||
</Lane>
|
</Lane>
|
||||||
</div>
|
</div>
|
||||||
@@ -163,8 +164,8 @@ function Lane({ label, color, children }: { label: string; color: string; childr
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
height: 44,
|
height: 44,
|
||||||
borderRadius: 999,
|
borderRadius: 999,
|
||||||
background: "oklch(97% 0.01 175 / .1)",
|
background: "oklch(97% 0.01 var(--tp-hue) / .1)",
|
||||||
border: "1px solid oklch(97% 0.01 175 / .16)",
|
border: "1px solid oklch(97% 0.01 var(--tp-hue) / .16)",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
}}
|
}}
|
||||||
|
|||||||
69
web/src/lib/__tests__/skin.test.ts
Normal file
69
web/src/lib/__tests__/skin.test.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const store = new Map<string, string>();
|
||||||
|
|
||||||
|
async function load(search: string) {
|
||||||
|
vi.resetModules();
|
||||||
|
vi.stubGlobal("location", { search });
|
||||||
|
vi.stubGlobal("localStorage", {
|
||||||
|
getItem: (k: string) => store.get(k) ?? null,
|
||||||
|
setItem: (k: string, v: string) => void store.set(k, v),
|
||||||
|
removeItem: (k: string) => void store.delete(k),
|
||||||
|
});
|
||||||
|
return import("../skins");
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("skins", () => {
|
||||||
|
beforeEach(() => store.clear());
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
it("is the dolphin with nothing chosen", async () => {
|
||||||
|
const m = await load("");
|
||||||
|
expect(m.SKIN.id).toBe("dolphin");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes ?theme= and remembers it", async () => {
|
||||||
|
const m = await load("?theme=dinosaur");
|
||||||
|
expect(m.SKIN.id).toBe("dinosaur");
|
||||||
|
expect(store.get(m.STORAGE_KEY)).toBe("dinosaur");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the remembered skin when the URL has none", async () => {
|
||||||
|
store.set("musicmouse.theme", "dinosaur");
|
||||||
|
const m = await load("?pi=1");
|
||||||
|
expect(m.SKIN.id).toBe("dinosaur");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets ?theme= switch back", async () => {
|
||||||
|
store.set("musicmouse.theme", "dinosaur");
|
||||||
|
const m = await load("?theme=dolphin");
|
||||||
|
expect(m.SKIN.id).toBe("dolphin");
|
||||||
|
expect(store.get(m.STORAGE_KEY)).toBe("dolphin");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives every skin a complete, readable game world", async () => {
|
||||||
|
const { SKINS } = await load("");
|
||||||
|
const { CREATURE_IDS } = await import("../tippen/pets");
|
||||||
|
const { ANIMALS } = await import("../tippen/grading");
|
||||||
|
for (const skin of Object.values(SKINS)) {
|
||||||
|
const { game } = skin;
|
||||||
|
for (const id of CREATURE_IDS) expect(game.pets[id].name, `${skin.id} ${id}`).not.toBe("");
|
||||||
|
const ladder = ANIMALS.map((animal) => game.ladder[animal.id]);
|
||||||
|
for (const rung of ladder) expect(rung.name && rung.emoji && rung.praise, skin.id).toBeTruthy();
|
||||||
|
// One look per rung: two rungs sharing a name or an emoji would read as no progress.
|
||||||
|
expect(new Set(ladder.map((rung) => rung.emoji)).size, skin.id).toBe(ladder.length);
|
||||||
|
expect(new Set(ladder.map((rung) => rung.name)).size, skin.id).toBe(ladder.length);
|
||||||
|
// ⭐ is the star rating, three lines below the animal on the result sheet.
|
||||||
|
for (const rung of ladder) expect(rung.emoji, skin.id).not.toBe("⭐");
|
||||||
|
for (const mode of Object.values(game.modes)) expect(mode.name && mode.emoji, skin.id).toBeTruthy();
|
||||||
|
expect(game.feedItems.length, skin.id).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores unknown values, in the URL and in storage", async () => {
|
||||||
|
store.set("musicmouse.theme", "toString");
|
||||||
|
const m = await load("?theme=unicorn");
|
||||||
|
expect(m.SKIN.id).toBe("dolphin");
|
||||||
|
expect(store.get(m.STORAGE_KEY)).toBe("toString");
|
||||||
|
});
|
||||||
|
});
|
||||||
73
web/src/lib/skins/dinosaur.ts
Normal file
73
web/src/lib/skins/dinosaur.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import type { Skin } from "./types";
|
||||||
|
|
||||||
|
/** Dino: the same design on a jungle green with a lava-orange accent. Lightness and
|
||||||
|
* chroma follow the dolphin's sea exactly - only the hues move - so every contrast
|
||||||
|
* ratio the original was tuned for still holds. */
|
||||||
|
export const DINOSAUR: Skin = {
|
||||||
|
id: "dinosaur",
|
||||||
|
appName: "Musik Dino",
|
||||||
|
tippenName: "Dino Tippen",
|
||||||
|
mascotName: "Der Dino",
|
||||||
|
mascot: "/dinosaur-mascot.png",
|
||||||
|
mascotRemote: "/dinosaur-remote.png",
|
||||||
|
mascotFacesLeft: true,
|
||||||
|
mascotMotion: "walk",
|
||||||
|
drift: "leaves",
|
||||||
|
icons: {
|
||||||
|
favicon: "/dinosaur/icon-192.png",
|
||||||
|
appleTouch: "/dinosaur/apple-touch-icon.png",
|
||||||
|
manifest: "/dinosaur/manifest.webmanifest",
|
||||||
|
},
|
||||||
|
// The dolphin's #0d2f3c - oklch(28.75% 0.045 226) - turned to the jungle's hue.
|
||||||
|
themeColor: "#1c311c",
|
||||||
|
// The dolphin's six colours, named for this world instead of the sea.
|
||||||
|
lightSwatches: [
|
||||||
|
{ name: "Warmweiß", oklch: "oklch(90% 0.06 85)" },
|
||||||
|
{ name: "Sonnengelb", oklch: "oklch(85% 0.16 95)" },
|
||||||
|
{ name: "Lavarot", oklch: "oklch(65% 0.20 25)" },
|
||||||
|
{ name: "Himmelblau", oklch: "oklch(70% 0.15 235)" },
|
||||||
|
{ name: "Farngrün", oklch: "oklch(75% 0.16 155)" },
|
||||||
|
{ name: "Blütenlila", oklch: "oklch(65% 0.18 310)" },
|
||||||
|
],
|
||||||
|
hue: 145,
|
||||||
|
accentHue: 45,
|
||||||
|
tippenHue: 120,
|
||||||
|
stageGradient: ["#577d58", "#2d4b2e", "#071b08"],
|
||||||
|
paperRgb: "241 247 241",
|
||||||
|
game: {
|
||||||
|
habitat: "ins Dino-Tal",
|
||||||
|
// Emoji stand in until the drawings exist. Each becomes `art: { image: "/dinopark/….webp" }`
|
||||||
|
// (and `facing: "side"` for a side view drawn facing right) once it is made.
|
||||||
|
pets: {
|
||||||
|
clownfish: { name: "Triceratops", article: "Der", art: { emoji: "🦏" }, size: 0.15, speed: 0.03, facing: "front", motion: "walk" },
|
||||||
|
octopus: { name: "Stegosaurus", article: "Der", art: { emoji: "🐊" }, size: 0.15, speed: 0.025, facing: "front", motion: "walk" },
|
||||||
|
seahorse: { name: "Flugsaurier", article: "Der", art: { emoji: "🦅" }, size: 0.13, speed: 0.05, facing: "front", motion: "fly" },
|
||||||
|
turtle: { name: "Langhals", article: "Der", art: { emoji: "🦕" }, size: 0.2, speed: 0.02, facing: "front", motion: "walk" },
|
||||||
|
pearlmussel: { name: "Dino-Baby", article: "Das", art: { emoji: "🐣" }, size: 0.1, speed: 0.025, facing: "front", motion: "walk" },
|
||||||
|
},
|
||||||
|
ladder: {
|
||||||
|
snail: { name: "Schnecke", emoji: "🐌", praise: "Die Schnecke ist losgekrochen!" },
|
||||||
|
crab: { name: "Raupe", emoji: "🐛", praise: "Eine Raupe! Die krabbelt schon los." },
|
||||||
|
turtle: { name: "Schildkröte", emoji: "🐢", praise: "Die Schildkröte ist stetig und sicher." },
|
||||||
|
jellyfish: { name: "Eidechse", emoji: "🦎", praise: "Eine Eidechse! Die huscht schon flink." },
|
||||||
|
fish: { name: "Krokodil", emoji: "🐊", praise: "Ein Krokodil! Das ist richtig flott." },
|
||||||
|
penguin: { name: "Langhals", emoji: "🦕", praise: "Ein Langhals! Der stapft mit großen Schritten." },
|
||||||
|
seal: { name: "Mammut", emoji: "🦣", praise: "Das Mammut stampft schnell und kräftig!" },
|
||||||
|
dolphin: { name: "T-Rex", emoji: "🦖", praise: "Ein T-Rex! Das ist richtig, richtig schnell." },
|
||||||
|
shark: { name: "Säbelzahntiger", emoji: "🐅", praise: "Ein Säbelzahntiger! Unglaublich schnell." },
|
||||||
|
orca: { name: "Drache", emoji: "🐉", praise: "Ein Drache! Schneller wird es im Dino-Tal nicht." },
|
||||||
|
},
|
||||||
|
modes: {
|
||||||
|
dive: { emoji: "🥾", name: "Expedition" },
|
||||||
|
bubbles: { emoji: "🌋", name: "Lavablubbern" },
|
||||||
|
feed: { emoji: "🌿", name: "Fütterungszeit" },
|
||||||
|
race: { emoji: "🦖", name: "Dinorennen" },
|
||||||
|
},
|
||||||
|
feeder: "🦕",
|
||||||
|
feedItems: ["🌿", "🍃", "🍓", "🍎", "🌰"],
|
||||||
|
racer: "🦖",
|
||||||
|
rival: { emoji: "🐢", label: "Die Schildkröte" },
|
||||||
|
blobs: "lava",
|
||||||
|
rewardBox: "egg",
|
||||||
|
},
|
||||||
|
};
|
||||||
65
web/src/lib/skins/dolphin.ts
Normal file
65
web/src/lib/skins/dolphin.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import type { Skin } from "./types";
|
||||||
|
|
||||||
|
/** Dolphin Beats: the original design (claude-design/Dolphin Beats.dc.html). A teal
|
||||||
|
* sea with a pink accent and a turquoise lagoon for the typing game. */
|
||||||
|
export const DOLPHIN: Skin = {
|
||||||
|
id: "dolphin",
|
||||||
|
appName: "Musik Delfin",
|
||||||
|
tippenName: "Delfin Tippen",
|
||||||
|
mascotName: "Der Delphin",
|
||||||
|
mascot: "/dolphin-mascot.png",
|
||||||
|
mascotRemote: "/dolphin-remote.png",
|
||||||
|
mascotFacesLeft: false,
|
||||||
|
mascotMotion: "swim",
|
||||||
|
drift: "bubbles",
|
||||||
|
icons: { favicon: "/icon-192.png", appleTouch: "/apple-touch-icon.png", manifest: "/manifest.webmanifest" },
|
||||||
|
themeColor: "#0d2f3c",
|
||||||
|
lightSwatches: [
|
||||||
|
{ name: "Warmweiß", oklch: "oklch(90% 0.06 85)" },
|
||||||
|
{ name: "Sonnengelb", oklch: "oklch(85% 0.16 95)" },
|
||||||
|
{ name: "Korallenrot", oklch: "oklch(65% 0.20 25)" },
|
||||||
|
{ name: "Delfinblau", oklch: "oklch(70% 0.15 235)" },
|
||||||
|
{ name: "Riffgrün", oklch: "oklch(75% 0.16 155)" },
|
||||||
|
{ name: "Quallenlila", oklch: "oklch(65% 0.18 310)" },
|
||||||
|
],
|
||||||
|
hue: 210,
|
||||||
|
accentHue: 340,
|
||||||
|
tippenHue: 175,
|
||||||
|
stageGradient: ["#397d88", "#0e4b54", "#001b21"],
|
||||||
|
paperRgb: "238 247 249",
|
||||||
|
game: {
|
||||||
|
habitat: "ins Aquarium",
|
||||||
|
// Drawings made from the originals by scripts/aquarium-bild.sh.
|
||||||
|
pets: {
|
||||||
|
clownfish: { name: "Clownfisch", article: "Der", art: { image: "/aquarium/clownfisch.webp" }, size: 0.13, speed: 0.045, facing: "side", motion: "swim" },
|
||||||
|
octopus: { name: "Krake", article: "Die", art: { image: "/aquarium/krake.webp" }, size: 0.17, speed: 0.025, facing: "front", motion: "swim" },
|
||||||
|
seahorse: { name: "Seepferdchen", article: "Das", art: { image: "/aquarium/seepferdchen.webp" }, size: 0.19, speed: 0.02, facing: "side", motion: "swim" },
|
||||||
|
turtle: { name: "Schildkröte", article: "Die", art: { image: "/aquarium/schildkroete.webp" }, size: 0.17, speed: 0.032, facing: "side", motion: "swim" },
|
||||||
|
pearlmussel: { name: "Perlmuschel", article: "Die", art: { image: "/aquarium/perlmuschel.webp" }, size: 0.12, speed: 0.016, facing: "front", motion: "swim" },
|
||||||
|
},
|
||||||
|
ladder: {
|
||||||
|
snail: { name: "Meeresschnecke", emoji: "🐌", praise: "Die Schnecke ist losgekrochen!" },
|
||||||
|
crab: { name: "Krabbe", emoji: "🦀", praise: "Eine Krabbe! Die krabbelt schon los." },
|
||||||
|
turtle: { name: "Schildkröte", emoji: "🐢", praise: "Die Schildkröte ist stetig und sicher." },
|
||||||
|
jellyfish: { name: "Qualle", emoji: "🪼", praise: "Die Qualle gleitet dahin!" },
|
||||||
|
fish: { name: "Fisch", emoji: "🐟", praise: "Ein Fisch! Der schwimmt richtig flott." },
|
||||||
|
penguin: { name: "Pinguin", emoji: "🐧", praise: "Ein Pinguin! Der flitzt durchs Wasser." },
|
||||||
|
seal: { name: "Robbe", emoji: "🦭", praise: "Die Robbe ist schnell und geschickt!" },
|
||||||
|
dolphin: { name: "Delfin", emoji: "🐬", praise: "Ein Delfin! Das ist richtig, richtig schnell." },
|
||||||
|
shark: { name: "Hai", emoji: "🦈", praise: "Ein Hai! Unglaublich schnell." },
|
||||||
|
orca: { name: "Schwertwal", emoji: "🐋", praise: "Ein Schwertwal! Schneller wird es im Meer nicht." },
|
||||||
|
},
|
||||||
|
modes: {
|
||||||
|
dive: { emoji: "🤿", name: "Tauchgang" },
|
||||||
|
bubbles: { emoji: "🫧", name: "Blasenplatzen" },
|
||||||
|
feed: { emoji: "🐟", name: "Fütterungszeit" },
|
||||||
|
race: { emoji: "🐬", name: "Delfinrennen" },
|
||||||
|
},
|
||||||
|
feeder: "🐬",
|
||||||
|
feedItems: ["🐟", "🐠", "🐡", "🦐", "🦀"],
|
||||||
|
racer: "🐬",
|
||||||
|
rival: { emoji: "🦀", label: "Die Krabbe" },
|
||||||
|
blobs: "bubbles",
|
||||||
|
rewardBox: "chest",
|
||||||
|
},
|
||||||
|
};
|
||||||
72
web/src/lib/skins/index.ts
Normal file
72
web/src/lib/skins/index.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
/** Which skin this page runs in, and putting it on the page.
|
||||||
|
*
|
||||||
|
* Chosen with `?theme=dolphin` or `?theme=dinosaur`. The choice is remembered in this
|
||||||
|
* browser, because the installed home-screen app always starts at plain "/" (the
|
||||||
|
* manifest's `start_url`) and would otherwise forget it on every launch. Without either,
|
||||||
|
* it is the dolphin. */
|
||||||
|
|
||||||
|
import { DINOSAUR } from "./dinosaur";
|
||||||
|
import { DOLPHIN } from "./dolphin";
|
||||||
|
import type { Skin, SkinId } from "./types";
|
||||||
|
|
||||||
|
export type { AnimalLook, GameWorld, PetLook, Skin, SkinId } from "./types";
|
||||||
|
|
||||||
|
export const SKINS: Record<SkinId, Skin> = {
|
||||||
|
dolphin: DOLPHIN,
|
||||||
|
dinosaur: DINOSAUR,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const STORAGE_KEY = "musicmouse.theme";
|
||||||
|
|
||||||
|
const isSkinId = (value: unknown): value is SkinId =>
|
||||||
|
typeof value === "string" && Object.hasOwn(SKINS, value);
|
||||||
|
|
||||||
|
function readSkin(): Skin {
|
||||||
|
// `location` is absent under vitest's default node environment.
|
||||||
|
if (typeof location === "undefined") return DOLPHIN;
|
||||||
|
|
||||||
|
const requested = new URLSearchParams(location.search).get("theme");
|
||||||
|
if (isSkinId(requested)) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, requested);
|
||||||
|
} catch {
|
||||||
|
// Storage blocked: this page still gets the skin, the next one will not remember it.
|
||||||
|
}
|
||||||
|
return SKINS[requested];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (isSkinId(stored)) return SKINS[stored];
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return DOLPHIN;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SKIN: Skin = readSkin();
|
||||||
|
|
||||||
|
/** Write the skin's CSS custom properties onto `<html>`. Called once, before the first
|
||||||
|
* render, so nothing ever paints in the wrong colours. */
|
||||||
|
export function applySkin(skin: Skin = SKIN): void {
|
||||||
|
const root = document.documentElement;
|
||||||
|
root.dataset.theme = skin.id;
|
||||||
|
const vars: Record<string, string> = {
|
||||||
|
"--hue": String(skin.hue),
|
||||||
|
"--accent-hue": String(skin.accentHue),
|
||||||
|
"--tp-hue": String(skin.tippenHue),
|
||||||
|
"--stage-top": skin.stageGradient[0],
|
||||||
|
"--stage-mid": skin.stageGradient[1],
|
||||||
|
"--stage-deep": skin.stageGradient[2],
|
||||||
|
"--paper-rgb": skin.paperRgb,
|
||||||
|
};
|
||||||
|
for (const [name, value] of Object.entries(vars)) root.style.setProperty(name, value);
|
||||||
|
document.title = skin.appName;
|
||||||
|
|
||||||
|
// index.html ships the dolphin's; these are the same values for it. iOS reads the
|
||||||
|
// touch icon when "Zum Home-Bildschirm" is tapped, so a swap made here is in time.
|
||||||
|
document.querySelector<HTMLLinkElement>('link[rel="icon"]')?.setAttribute("href", skin.icons.favicon);
|
||||||
|
document.querySelector<HTMLLinkElement>('link[rel="apple-touch-icon"]')?.setAttribute("href", skin.icons.appleTouch);
|
||||||
|
document.querySelector<HTMLLinkElement>('link[rel="manifest"]')?.setAttribute("href", skin.icons.manifest);
|
||||||
|
document.querySelector<HTMLMetaElement>('meta[name="theme-color"]')?.setAttribute("content", skin.themeColor);
|
||||||
|
}
|
||||||
112
web/src/lib/skins/types.ts
Normal file
112
web/src/lib/skins/types.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
/** What one child's version of the app looks like.
|
||||||
|
*
|
||||||
|
* Everything that differs between the two kids' devices lives in one `Skin` object, so
|
||||||
|
* a new look is a new file next to dolphin.ts rather than a hunt through components.
|
||||||
|
* What does *not* live here: layout and feature toggles (`lib/theme.ts`,
|
||||||
|
* `lib/tippen/theme.ts`), which are the same for every skin.
|
||||||
|
*
|
||||||
|
* Hues are oklch degrees. CSS reads them as custom properties (`applySkin` in
|
||||||
|
* index.ts writes them onto `<html>`), canvas and computed style strings read the
|
||||||
|
* numbers from here directly - the canvas cannot resolve `var()`. */
|
||||||
|
|
||||||
|
import type { ModeId } from "../tippen/curriculum";
|
||||||
|
import type { AnimalId } from "../tippen/grading";
|
||||||
|
import type { CreatureId } from "../tippen/pets";
|
||||||
|
|
||||||
|
export type SkinId = "dolphin" | "dinosaur";
|
||||||
|
|
||||||
|
export interface Skin {
|
||||||
|
id: SkinId;
|
||||||
|
/** Browser tab title, the music header and the phone layout's title bar. */
|
||||||
|
appName: string;
|
||||||
|
/** The typing game's header. */
|
||||||
|
tippenName: string;
|
||||||
|
/** Who the app is, in "… ist nicht erreichbar" when the backend cannot be reached. */
|
||||||
|
mascotName: string;
|
||||||
|
/** The mascot with headphones, used everywhere a mascot shows. */
|
||||||
|
mascot: string;
|
||||||
|
/** The mascot holding a remote, for "Mein Zimmer". */
|
||||||
|
mascotRemote: string;
|
||||||
|
/** The play view's swimming mascot is animated assuming it faces right; a drawing
|
||||||
|
* that faces left is mirrored so it looks where it is going. */
|
||||||
|
mascotFacesLeft: boolean;
|
||||||
|
/** How the play view's mascot crosses the screen: swimming in waves, or walking. */
|
||||||
|
mascotMotion: "swim" | "walk";
|
||||||
|
/** What drifts through every background - the play view's canvas, the room page and
|
||||||
|
* the typing game: rising bubbles or falling leaves. */
|
||||||
|
drift: "bubbles" | "leaves";
|
||||||
|
/** Browser chrome and the installed app. `index.html` carries the dolphin's; another
|
||||||
|
* skin swaps them in at startup (`applySkin`). */
|
||||||
|
icons: { favicon: string; appleTouch: string; manifest: string };
|
||||||
|
/** The browser/status bar colour, as `<meta name="theme-color">`. */
|
||||||
|
themeColor: string;
|
||||||
|
/** The colour presets on a room light's card. */
|
||||||
|
lightSwatches: readonly { name: string; oklch: string }[];
|
||||||
|
/** The music player's base hue: stage, glass, text tints, the ambience canvas. */
|
||||||
|
hue: number;
|
||||||
|
/** Focus rings, the search box frame, the caret, the playing-card outline. */
|
||||||
|
accentHue: number;
|
||||||
|
/** The typing game's base hue - a sibling of `hue`, not the same room. */
|
||||||
|
tippenHue: number;
|
||||||
|
/** The stage gradient's three stops, top to deep, as sRGB hex. Not oklch on purpose:
|
||||||
|
* see the note on `.stage` in app.css. Each is the sRGB rendering of
|
||||||
|
* oklch(55% 0.07 hue) / (38% 0.06 hue) / (20% 0.045 hue). */
|
||||||
|
stageGradient: readonly [string, string, string];
|
||||||
|
/** oklch(97% 0.01 hue) as space-separated sRGB channels, for `rgb(var(--paper-rgb) / a)`
|
||||||
|
* in the glass gradients that must stay sRGB for the same reason. */
|
||||||
|
paperRgb: string;
|
||||||
|
/** Everything the typing game shows that is not a colour. */
|
||||||
|
game: GameWorld;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The typing game's world: who lives in it, who races and who eats.
|
||||||
|
*
|
||||||
|
* The backend stores pets and speed animals by id (`CreatureId`, `AnimalId`: the
|
||||||
|
* dolphin's names, because the dolphin came first). Those ids are slot keys and nothing
|
||||||
|
* more - "clownfish" means "world 1's pet", "dolphin" means "the eighth rung". Thresholds,
|
||||||
|
* world order and the surprise boundary stay with the game logic; a skin only says what
|
||||||
|
* each slot looks like, so both skins share one backend and one save format. */
|
||||||
|
export interface GameWorld {
|
||||||
|
/** Where pets move in, finishing "Die Krake ist … gezogen!". */
|
||||||
|
habitat: string;
|
||||||
|
pets: Record<CreatureId, PetLook>;
|
||||||
|
ladder: Record<AnimalId, AnimalLook>;
|
||||||
|
modes: Record<ModeId, { emoji: string; name: string }>;
|
||||||
|
/** Feed mode: who waits on the left, and what carries each word to it. */
|
||||||
|
feeder: string;
|
||||||
|
feedItems: readonly string[];
|
||||||
|
/** Race mode: the player's racer, and the opponent before a ghost exists. */
|
||||||
|
racer: string;
|
||||||
|
rival: { emoji: string; label: string };
|
||||||
|
/** The letters in the bubbles game: soap-like bubbles, or glowing lava blobs. */
|
||||||
|
blobs: "bubbles" | "lava";
|
||||||
|
/** What a music reward comes out of. */
|
||||||
|
rewardBox: "chest" | "egg";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PetLook {
|
||||||
|
name: string;
|
||||||
|
/** For the sentence on arrival: "Die Krake ist ins Aquarium gezogen!" */
|
||||||
|
article: "Der" | "Die" | "Das";
|
||||||
|
/** A drawing under public/, or - until one exists - an emoji standing in for it. */
|
||||||
|
art: { image: string } | { emoji: string };
|
||||||
|
/** Height as a fraction of the stage height - so a pet is the same size relative to
|
||||||
|
* the stage 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 drawings face right and are mirrored to look
|
||||||
|
* where they go; front-view ones never are - an octopus flipping on every turn looks
|
||||||
|
* broken. Emoji are front: which way they face depends on the platform's font. */
|
||||||
|
facing: "side" | "front";
|
||||||
|
/** Where on the stage it lives: anywhere, along the ground, or up in the sky. */
|
||||||
|
motion: "swim" | "walk" | "fly";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnimalLook {
|
||||||
|
name: string;
|
||||||
|
emoji: string;
|
||||||
|
/** What the speech synthesis says when this animal is reached. */
|
||||||
|
praise: string;
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import type { CSSProperties } from "react";
|
|||||||
import { PERF } from "./perfSettings";
|
import { PERF } from "./perfSettings";
|
||||||
import type { Group } from "./search";
|
import type { Group } from "./search";
|
||||||
import type { UiState } from "./keyboard";
|
import type { UiState } from "./keyboard";
|
||||||
|
import { SKIN } from "./skins";
|
||||||
|
|
||||||
// ---------------------------------------------------------------- toggles --
|
// ---------------------------------------------------------------- toggles --
|
||||||
|
|
||||||
@@ -88,7 +89,7 @@ export const SHOW_DECORATIVE_ANIMATIONS = PERF.decorativeAnimations;
|
|||||||
* at the mood its albums play into - and so does the book/music card tint in
|
* at the mood its albums play into - and so does the book/music card tint in
|
||||||
* `lib/covers.ts`, which reads `GROUP_HUE.audiobooks`/`GROUP_HUE.music` too. */
|
* `lib/covers.ts`, which reads `GROUP_HUE.audiobooks`/`GROUP_HUE.music` too. */
|
||||||
export const GROUP_HUE: Record<Group, number> = {
|
export const GROUP_HUE: Record<Group, number> = {
|
||||||
music: 210, // today's sea - Dolphin Beats' baseline hue
|
music: SKIN.hue, // the skin's own base: the dolphin's sea, the dinosaur's jungle
|
||||||
audiobooks: 55, // warm amber - richer than the old shared hue (80)'s dull brown
|
audiobooks: 55, // warm amber - richer than the old shared hue (80)'s dull brown
|
||||||
podcasts: 300, // the one otherwise-unused family
|
podcasts: 300, // the one otherwise-unused family
|
||||||
};
|
};
|
||||||
@@ -144,7 +145,7 @@ export function glassTint(hue: number): CSSProperties {
|
|||||||
* neutral highlight the row used before tinting existed. */
|
* neutral highlight the row used before tinting existed. */
|
||||||
export function rowTint(hue: number, highlighted: boolean): CSSProperties {
|
export function rowTint(hue: number, highlighted: boolean): CSSProperties {
|
||||||
if (!SHOW_ROW_TINT) {
|
if (!SHOW_ROW_TINT) {
|
||||||
return { background: `oklch(97% 0.01 210 / ${highlighted ? ".22" : ".10"})` };
|
return { background: `oklch(97% 0.01 var(--hue) / ${highlighted ? ".22" : ".10"})` };
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
background: `oklch(${highlighted ? "62% 0.12" : "42% 0.08"} ${hue} / ${highlighted ? ".34" : ".2"})`,
|
background: `oklch(${highlighted ? "62% 0.12" : "42% 0.08"} ${hue} / ${highlighted ? ".34" : ".2"})`,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { CREATURES, creatureFromRaw, creatureById, createSwimmer, pose, stepSwimmer } from "../aquarium";
|
import { BANDS, CREATURES, creatureFromRaw, creatureById, createSwimmer, pose, stepSwimmer } from "../pets";
|
||||||
import type { Tank, Swimmer } from "../aquarium";
|
import type { Tank, Swimmer } from "../pets";
|
||||||
import { mulberry32 } from "../generator";
|
import { mulberry32 } from "../generator";
|
||||||
|
|
||||||
const TANK: Tank = { width: 1280, height: 800 };
|
const TANK: Tank = { width: 1280, height: 800 };
|
||||||
@@ -69,6 +69,27 @@ describe("stepSwimmer", () => {
|
|||||||
expect(inside.x).toBeLessThan(TANK.width);
|
expect(inside.x).toBeLessThan(TANK.width);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps a walker on the ground and a flier in the sky", () => {
|
||||||
|
for (const [band, top, bottom] of [
|
||||||
|
[BANDS.walk, 0.7 * TANK.height + MARGIN, TANK.height - MARGIN],
|
||||||
|
[BANDS.fly, MARGIN, 0.35 * TANK.height - MARGIN],
|
||||||
|
] as const) {
|
||||||
|
const rng = mulberry32(9);
|
||||||
|
let s = createSwimmer(TANK, MARGIN, rng, false, band);
|
||||||
|
expect(s.y).toBeGreaterThanOrEqual(top);
|
||||||
|
expect(s.y).toBeLessThanOrEqual(bottom);
|
||||||
|
// Ten minutes, checked every frame: steering eases towards each target, so the
|
||||||
|
// centre may never overshoot past the strip its targets are drawn from.
|
||||||
|
for (let t = 0; t < 600; t += DT) {
|
||||||
|
s = stepSwimmer(s, DT, TANK, MARGIN, SPEED, rng, band);
|
||||||
|
expect(s.targetY).toBeGreaterThanOrEqual(top);
|
||||||
|
expect(s.targetY).toBeLessThanOrEqual(bottom);
|
||||||
|
expect(s.y).toBeGreaterThanOrEqual(top - 1);
|
||||||
|
expect(s.y).toBeLessThanOrEqual(bottom + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("finds a new target when the window shrinks under the old one", () => {
|
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 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 small = { width: 500, height: 400 };
|
||||||
@@ -85,6 +106,15 @@ describe("pose", () => {
|
|||||||
expect(pose(s, creatureById("clownfish"), SPEED).mirror).toBe(-1);
|
expect(pose(s, creatureById("clownfish"), SPEED).mirror).toBe(-1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("hops a walker off the ground instead of bobbing it through", () => {
|
||||||
|
const walker = { ...creatureById("clownfish"), motion: "walk" as const };
|
||||||
|
for (let age = 0; age < 4; age += 0.1) {
|
||||||
|
const p = pose({ ...s, vy: 20, age }, walker, SPEED);
|
||||||
|
expect(p.y).toBeLessThanOrEqual(s.y);
|
||||||
|
if (walker.facing === "side") expect(Math.abs(p.rotation)).toBe(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("never mirrors a front-view pet", () => {
|
it("never mirrors a front-view pet", () => {
|
||||||
expect(pose(s, creatureById("octopus"), SPEED).mirror).toBe(1);
|
expect(pose(s, creatureById("octopus"), SPEED).mirror).toBe(1);
|
||||||
expect(pose(s, creatureById("pearlmussel"), SPEED).mirror).toBe(1);
|
expect(pose(s, creatureById("pearlmussel"), SPEED).mirror).toBe(1);
|
||||||
@@ -10,7 +10,7 @@ import type {
|
|||||||
TippenLesson as ApiLesson,
|
TippenLesson as ApiLesson,
|
||||||
TippenReward as ApiReward,
|
TippenReward as ApiReward,
|
||||||
} from "../../api/types";
|
} from "../../api/types";
|
||||||
import type { CreatureId } from "./aquarium";
|
import type { CreatureId } from "./pets";
|
||||||
|
|
||||||
export type LessonKind = "letters" | "fragments" | "words" | "sentences";
|
export type LessonKind = "letters" | "fragments" | "words" | "sentences";
|
||||||
export type ModeId = "dive" | "bubbles" | "feed" | "race";
|
export type ModeId = "dive" | "bubbles" | "feed" | "race";
|
||||||
|
|||||||
@@ -16,6 +16,8 @@
|
|||||||
* Crucially, speed only ever buys the *animal*. The unlock gate below is accuracy-only
|
* 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. */
|
* (see `isPassed`), so a slow, careful child still reaches the end of the curriculum. */
|
||||||
|
|
||||||
|
import { SKIN } from "../skins";
|
||||||
|
import type { AnimalLook } from "../skins";
|
||||||
import type { RunState, Stroke } from "./engine";
|
import type { RunState, Stroke } from "./engine";
|
||||||
|
|
||||||
export interface RunResult {
|
export interface RunResult {
|
||||||
@@ -39,6 +41,9 @@ export interface RunResult {
|
|||||||
strokes: readonly Stroke[];
|
strokes: readonly Stroke[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The ladder's rungs, slowest first. The ids are the backend's and name the dolphin
|
||||||
|
* skin's sea animals; to any other skin they are just rung 1 … rung 10 - see
|
||||||
|
* `GameWorld` in ../skins/types.ts. */
|
||||||
export type AnimalId =
|
export type AnimalId =
|
||||||
| "snail"
|
| "snail"
|
||||||
| "crab"
|
| "crab"
|
||||||
@@ -51,43 +56,42 @@ export type AnimalId =
|
|||||||
| "shark"
|
| "shark"
|
||||||
| "orca";
|
| "orca";
|
||||||
|
|
||||||
export interface Animal {
|
export interface Animal extends AnimalLook {
|
||||||
id: AnimalId;
|
id: AnimalId;
|
||||||
name: string;
|
|
||||||
emoji: string;
|
|
||||||
/** Lower bound in points (characters per minute, accuracy-weighted). */
|
/** Lower bound in points (characters per minute, accuracy-weighted). */
|
||||||
from: number;
|
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
|
/** Ten rungs, slowest first. Thresholds are calibrated against the "5 WPM pro
|
||||||
* Klassenstufe" school rule - roughly 25 characters/min at the end of first grade -
|
* Klassenstufe" school rule - roughly 25 characters/min at the end of first grade -
|
||||||
* with plenty of headroom above it.
|
* with plenty of headroom above it.
|
||||||
*
|
*
|
||||||
* The dolphin sits deliberately high, at about 24 WPM, one below the top. It is a
|
* The eighth rung - the dolphin, the T-Rex - sits deliberately high, at about 24 WPM. It
|
||||||
* realistic second- or third-grade target, which means it stays out of reach and
|
* 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.
|
* 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
|
* What each rung is called, looks like and says comes from the skin (`game.ladder`).
|
||||||
|
* Every emoji there 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 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. */
|
* ⭐ for a starfish, which collided with the star rating three lines below it. */
|
||||||
export const ANIMALS: readonly Animal[] = [
|
const RUNGS: readonly { id: AnimalId; from: number }[] = [
|
||||||
{ id: "snail", name: "Meeresschnecke", emoji: "🐌", from: 0, praise: "Die Schnecke ist losgekrochen!" },
|
{ id: "snail", from: 0 },
|
||||||
{ id: "crab", name: "Krabbe", emoji: "🦀", from: 15, praise: "Eine Krabbe! Die krabbelt schon los." },
|
{ id: "crab", from: 15 },
|
||||||
{ id: "turtle", name: "Schildkröte", emoji: "🐢", from: 25, praise: "Die Schildkröte ist stetig und sicher." },
|
{ id: "turtle", from: 25 },
|
||||||
{ id: "jellyfish", name: "Qualle", emoji: "🪼", from: 40, praise: "Die Qualle gleitet dahin!" },
|
{ id: "jellyfish", from: 40 },
|
||||||
{ id: "fish", name: "Fisch", emoji: "🐟", from: 55, praise: "Ein Fisch! Der schwimmt richtig flott." },
|
{ id: "fish", from: 55 },
|
||||||
{ id: "penguin", name: "Pinguin", emoji: "🐧", from: 75, praise: "Ein Pinguin! Der flitzt durchs Wasser." },
|
{ id: "penguin", from: 75 },
|
||||||
{ id: "seal", name: "Robbe", emoji: "🦭", from: 95, praise: "Die Robbe ist schnell und geschickt!" },
|
{ id: "seal", from: 95 },
|
||||||
{ id: "dolphin", name: "Delfin", emoji: "🐬", from: 120, praise: "Ein Delfin! Das ist richtig, richtig schnell." },
|
{ id: "dolphin", from: 120 },
|
||||||
{ id: "shark", name: "Hai", emoji: "🦈", from: 150, praise: "Ein Hai! Unglaublich schnell." },
|
{ id: "shark", from: 150 },
|
||||||
{ id: "orca", name: "Schwertwal", emoji: "🐋", from: 190, praise: "Ein Schwertwal! Schneller wird es im Meer nicht." },
|
{ id: "orca", from: 190 },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const ANIMALS: readonly Animal[] = RUNGS.map((rung) => ({ ...rung, ...SKIN.game.ladder[rung.id] }));
|
||||||
|
|
||||||
/** The last animal shown on the ladder before a run has been fast enough to earn it.
|
/** 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
|
* Everything above the dolphin (the rung, whatever the skin draws there) stays hidden
|
||||||
* `visibleAnimals`. */
|
* until it is actually reached - see `visibleAnimals`. */
|
||||||
export const SURPRISE_FROM: AnimalId = "dolphin";
|
export const SURPRISE_FROM: AnimalId = "dolphin";
|
||||||
|
|
||||||
export function animalIndex(id: AnimalId): number {
|
export function animalIndex(id: AnimalId): number {
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
/** 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" },
|
|
||||||
feed: { emoji: "🐟", name: "Fütterungszeit" },
|
|
||||||
race: { emoji: "🐬", name: "Delfinrennen" },
|
|
||||||
};
|
|
||||||
@@ -1,47 +1,38 @@
|
|||||||
/** The aquarium's pets, and how they swim.
|
/** The pets, and how they get around.
|
||||||
*
|
*
|
||||||
* A pet moves in when a world is finished and then stays - not in a list on the home
|
* 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
|
* screen, but moving 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
|
* in view, drifting past while she types, is the strongest version of "the reward
|
||||||
* persists", and it costs nothing to look at.
|
* persists", and it costs nothing to look at.
|
||||||
*
|
*
|
||||||
* Pets are illustrations, speed trophies are emoji. The ladder in grading.ts changes
|
* 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
|
* 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
|
* 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.
|
* drawing) without a six-year-old having to work out which is which. (A skin whose
|
||||||
|
* drawings do not exist yet stands in with emoji until they do.)
|
||||||
*
|
*
|
||||||
* The swimming lives here rather than in the component because it is the part worth
|
* Who the pets are is the skin's business (`game.pets` in ../skins/); this module only
|
||||||
* testing: `stepSwimmer` is a pure step - a swimmer and a time slice in, a swimmer out -
|
* knows the slots and the movement. The movement lives here rather than in the component
|
||||||
* so "never leaves the tank" and "looks where it is going" are checkable without a DOM
|
* because it is the part worth testing: `stepSwimmer` is a pure step - a swimmer and a
|
||||||
* or a clock. The component only calls it once per frame and writes the transform. */
|
* 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";
|
import { SKIN } from "../skins";
|
||||||
|
import type { PetLook } from "../skins";
|
||||||
|
|
||||||
export interface Creature {
|
/** One pet per world, in world order. The ids are the backend's (and the curriculum
|
||||||
|
* file's `reward:` keys) and name the dolphin skin's pets; to every other skin they are
|
||||||
|
* just "world 1's pet" … "world 5's pet". */
|
||||||
|
export const CREATURE_IDS = ["clownfish", "octopus", "seahorse", "turtle", "pearlmussel"] as const;
|
||||||
|
|
||||||
|
export type CreatureId = (typeof CREATURE_IDS)[number];
|
||||||
|
|
||||||
|
export interface Creature extends PetLook {
|
||||||
id: CreatureId;
|
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[] = [
|
export const CREATURES: readonly Creature[] = CREATURE_IDS.map((id) => ({ id, ...SKIN.game.pets[id] }));
|
||||||
{ 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]));
|
const CREATURE_BY_ID = new Map(CREATURES.map((creature) => [creature.id, creature]));
|
||||||
|
|
||||||
@@ -68,13 +59,34 @@ export function creatureFromRaw(raw: unknown): CreatureId | null {
|
|||||||
return LEGACY_EMOJI[raw] ?? null;
|
return LEGACY_EMOJI[raw] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- swimming ---------------------------------------------------------------
|
// --- moving -----------------------------------------------------------------
|
||||||
|
|
||||||
export interface Tank {
|
export interface Tank {
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The horizontal strip of the tank a pet keeps to, as fractions of its height. */
|
||||||
|
export interface Band {
|
||||||
|
top: number;
|
||||||
|
bottom: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Swimmers use the whole tank; walkers keep to the ground along the bottom and fliers
|
||||||
|
* to the sky along the top, so a dinosaur never floats past the lesson title. */
|
||||||
|
export const BANDS: Readonly<Record<PetLook["motion"], Band>> = {
|
||||||
|
swim: { top: 0, bottom: 1 },
|
||||||
|
walk: { top: 0.7, bottom: 1 },
|
||||||
|
fly: { top: 0, bottom: 0.35 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const WHOLE_TANK = BANDS.swim;
|
||||||
|
|
||||||
|
/** The range a pet's centre may take vertically: its band, less half its size. */
|
||||||
|
function verticalRange(tank: Tank, margin: number, band: Band): { top: number; bottom: number } {
|
||||||
|
return { top: band.top * tank.height + margin, bottom: band.bottom * tank.height - margin };
|
||||||
|
}
|
||||||
|
|
||||||
export interface Swimmer {
|
export interface Swimmer {
|
||||||
/** Centre, in px. */
|
/** Centre, in px. */
|
||||||
x: number;
|
x: number;
|
||||||
@@ -100,13 +112,18 @@ const TURN_TAU = 0.25;
|
|||||||
/** One gentle bob per this many seconds. */
|
/** One gentle bob per this many seconds. */
|
||||||
const BOB_PERIOD = 3.6;
|
const BOB_PERIOD = 3.6;
|
||||||
|
|
||||||
function randomTarget(tank: Tank, margin: number, rng: () => number): { x: number; y: number } {
|
function randomTarget(
|
||||||
|
tank: Tank,
|
||||||
|
margin: number,
|
||||||
|
rng: () => number,
|
||||||
|
band: Band,
|
||||||
|
): { x: number; y: number } {
|
||||||
// A tank smaller than the creature (a collapsed window) still needs a valid target:
|
// A tank smaller than the creature (a collapsed window) still needs a valid target:
|
||||||
// the middle.
|
// the middle.
|
||||||
const span = (length: number) => Math.max(0, length - 2 * margin);
|
const range = verticalRange(tank, margin, band);
|
||||||
return {
|
return {
|
||||||
x: margin + rng() * span(tank.width),
|
x: margin + rng() * Math.max(0, tank.width - 2 * margin),
|
||||||
y: margin + rng() * span(tank.height),
|
y: range.top + rng() * Math.max(0, range.bottom - range.top),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,11 +134,12 @@ export function createSwimmer(
|
|||||||
margin: number,
|
margin: number,
|
||||||
rng: () => number,
|
rng: () => number,
|
||||||
fromOutside = false,
|
fromOutside = false,
|
||||||
|
band: Band = WHOLE_TANK,
|
||||||
): Swimmer {
|
): Swimmer {
|
||||||
const target = randomTarget(tank, margin, rng);
|
const target = randomTarget(tank, margin, rng, band);
|
||||||
const start = fromOutside
|
const start = fromOutside
|
||||||
? { x: rng() < 0.5 ? -margin : tank.width + margin, y: target.y }
|
? { x: rng() < 0.5 ? -margin : tank.width + margin, y: target.y }
|
||||||
: randomTarget(tank, margin, rng);
|
: randomTarget(tank, margin, rng, band);
|
||||||
return {
|
return {
|
||||||
x: start.x,
|
x: start.x,
|
||||||
y: start.y,
|
y: start.y,
|
||||||
@@ -146,16 +164,18 @@ export function stepSwimmer(
|
|||||||
margin: number,
|
margin: number,
|
||||||
speed: number,
|
speed: number,
|
||||||
rng: () => number,
|
rng: () => number,
|
||||||
|
band: Band = WHOLE_TANK,
|
||||||
): Swimmer {
|
): Swimmer {
|
||||||
let { targetX, targetY } = s;
|
let { targetX, targetY } = s;
|
||||||
|
const range = verticalRange(tank, margin, band);
|
||||||
const inTank = (x: number, y: number) =>
|
const inTank = (x: number, y: number) =>
|
||||||
x >= margin && x <= tank.width - margin && y >= margin && y <= tank.height - margin;
|
x >= margin && x <= tank.width - margin && y >= range.top && y <= range.bottom;
|
||||||
|
|
||||||
// Arrived, or the window shrank and the target is now outside it: drift somewhere new.
|
// Arrived, or the window shrank and the target is now outside it: drift somewhere new.
|
||||||
const dx = targetX - s.x;
|
const dx = targetX - s.x;
|
||||||
const dy = targetY - s.y;
|
const dy = targetY - s.y;
|
||||||
if (Math.hypot(dx, dy) < Math.max(margin, 24) || !inTank(targetX, targetY)) {
|
if (Math.hypot(dx, dy) < Math.max(margin, 24) || !inTank(targetX, targetY)) {
|
||||||
const target = randomTarget(tank, margin, rng);
|
const target = randomTarget(tank, margin, rng, band);
|
||||||
targetX = target.x;
|
targetX = target.x;
|
||||||
targetY = target.y;
|
targetY = target.y;
|
||||||
}
|
}
|
||||||
@@ -197,11 +217,14 @@ export function pose(
|
|||||||
): { x: number; y: number; mirror: number; rotation: number } {
|
): { x: number; y: number; mirror: number; rotation: number } {
|
||||||
const bob = Math.sin((s.age / BOB_PERIOD) * 2 * Math.PI);
|
const bob = Math.sin((s.age / BOB_PERIOD) * 2 * Math.PI);
|
||||||
const isSide = creature.facing === "side";
|
const isSide = creature.facing === "side";
|
||||||
|
// Something walking hops off the ground and back rather than bobbing through it, and
|
||||||
|
// keeps its nose level - only swimmers and fliers point up a slope they are taking.
|
||||||
|
const walking = creature.motion === "walk";
|
||||||
// Nose up when rising, down when sinking - a few degrees, in the direction it faces.
|
// 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;
|
const tilt = speed > 0 && !walking ? Math.max(-1, Math.min(1, s.vy / speed)) : 0;
|
||||||
return {
|
return {
|
||||||
x: s.x,
|
x: s.x,
|
||||||
y: s.y + bob * 7,
|
y: walking ? s.y - Math.abs(bob) * 7 : s.y + bob * 7,
|
||||||
mirror: isSide ? s.facing : 1,
|
mirror: isSide ? s.facing : 1,
|
||||||
rotation: isSide ? tilt * 10 * Math.sign(s.facing || 1) : bob * 3,
|
rotation: isSide ? tilt * 10 * Math.sign(s.facing || 1) : bob * 3,
|
||||||
};
|
};
|
||||||
@@ -10,7 +10,7 @@ import type {
|
|||||||
TippenRunResult as ApiRunResult,
|
TippenRunResult as ApiRunResult,
|
||||||
TippenStroke,
|
TippenStroke,
|
||||||
} from "../../api/types";
|
} from "../../api/types";
|
||||||
import type { CreatureId } from "./aquarium";
|
import type { CreatureId } from "./pets";
|
||||||
import type { AnimalId, RunResult } from "./grading";
|
import type { AnimalId, RunResult } from "./grading";
|
||||||
|
|
||||||
export interface LessonProgress {
|
export interface LessonProgress {
|
||||||
|
|||||||
@@ -1,32 +1,29 @@
|
|||||||
/** Feature toggles and the one hue this app is built on.
|
/** Feature toggles for the typing game.
|
||||||
*
|
*
|
||||||
* Same role as ../../web/src/lib/theme.ts: flip a switch here rather than hunting
|
* Same role as ../theme.ts: flip a switch here rather than hunting through components.
|
||||||
* through components. The hue lives in styles/app.css because CSS is where it is used;
|
* Its hue is not here - it belongs to the skin (`tippenHue` in ../skins/), which writes
|
||||||
* it is repeated here only for the canvas, which cannot read a custom property. */
|
* it to CSS as `--tp-hue`. */
|
||||||
|
|
||||||
import { PERF } from "../perfSettings";
|
import { PERF } from "../perfSettings";
|
||||||
|
|
||||||
/** 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. Looks like the first thing to cut
|
/** Frost the glass panels with a real backdrop blur. Looks like the first thing to cut
|
||||||
* on a weak GPU; on the Pi it measured five times worse with it off, because the blur is
|
* on a weak GPU; on the Pi it measured five times worse with it off, because the blur is
|
||||||
* what gives each panel its own compositing layer. Same finding as the music app's
|
* what gives each panel its own compositing layer. Same finding as the music app's
|
||||||
* matching toggle - see the table in ../lowPower.ts. */
|
* matching toggle - see the table in ../lowPower.ts. */
|
||||||
export const SHOW_GLASS_BLUR = true;
|
export const SHOW_GLASS_BLUR = true;
|
||||||
|
|
||||||
/** The decorative rising bubbles behind everything. A dozen elements on a CSS transform
|
/** The decorative drift behind everything - rising bubbles or falling leaves, by skin. A
|
||||||
* loop - off under `?pi=1`, like every other loop in the app. */
|
* dozen elements on a CSS transform loop - off under `?pi=1`, like every other loop in the
|
||||||
export const SHOW_BUBBLES = PERF.tippenBubbles;
|
* app. */
|
||||||
|
export const SHOW_DRIFT = PERF.tippenBubbles;
|
||||||
|
|
||||||
/** The earned pets swimming behind every screen. The reward that is always in view - off
|
/** The earned pets moving behind every screen. The reward that is always in view - off
|
||||||
* only to rule it out when chasing a performance problem.
|
* only to rule it out when chasing a performance problem.
|
||||||
*
|
*
|
||||||
* Stays on under `?pi=1`, but the pets hold still there: `AquariumCreatures` already has
|
* Stays on under `?pi=1`, but the pets hold still there: `Pets` already has that exact
|
||||||
* that exact mode for `prefers-reduced-motion`, where every pet is placed and shown and
|
* mode for `prefers-reduced-motion`, where every pet is placed and shown and simply does
|
||||||
* simply does not swim. Taking the animation away is the point; taking away what she
|
* not move. Taking the animation away is the point; taking away what she earned is not. */
|
||||||
* earned is not. */
|
export const SHOW_PETS = true;
|
||||||
export const SHOW_AQUARIUM_CREATURES = true;
|
|
||||||
|
|
||||||
/** Fade screens in on entry. */
|
/** Fade screens in on entry. */
|
||||||
export const ANIMATE_VIEW_TRANSITIONS = PERF.tippenViewTransitions;
|
export const ANIMATE_VIEW_TRANSITIONS = PERF.tippenViewTransitions;
|
||||||
|
|||||||
@@ -3,11 +3,15 @@ import { createRoot } from "react-dom/client";
|
|||||||
|
|
||||||
import { App } from "./App";
|
import { App } from "./App";
|
||||||
import { PERF } from "./lib/perfSettings";
|
import { PERF } from "./lib/perfSettings";
|
||||||
|
import { applySkin } from "./lib/skins";
|
||||||
import "./styles/app.css";
|
import "./styles/app.css";
|
||||||
import "./styles/mobile.css";
|
import "./styles/mobile.css";
|
||||||
import "./styles/room.css";
|
import "./styles/room.css";
|
||||||
import "./styles/tippen.css";
|
import "./styles/tippen.css";
|
||||||
|
|
||||||
|
// Before the first render, so nothing ever paints in the wrong skin's colours.
|
||||||
|
applySkin();
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<App />
|
||||||
|
|||||||
@@ -2,12 +2,12 @@
|
|||||||
the tokens below are the handful of values that repeat. */
|
the tokens below are the handful of values that repeat. */
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--ink: oklch(30% 0.04 210);
|
--ink: oklch(30% 0.04 var(--hue));
|
||||||
--paper: oklch(97% 0.01 210);
|
--paper: oklch(97% 0.01 var(--hue));
|
||||||
--accent: oklch(70% 0.16 340);
|
--accent: oklch(70% 0.16 var(--accent-hue));
|
||||||
--accent-dim: oklch(78% 0.14 340);
|
--accent-dim: oklch(78% 0.14 var(--accent-hue));
|
||||||
--sea-deep: oklch(20% 0.045 210);
|
--sea-deep: oklch(20% 0.045 var(--hue));
|
||||||
--shadow: oklch(15% 0.05 210 / 0.35);
|
--shadow: oklch(15% 0.05 var(--hue) / 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -48,7 +48,7 @@ button {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes dolphinBob {
|
@keyframes mascotBob {
|
||||||
0%,
|
0%,
|
||||||
100% {
|
100% {
|
||||||
transform: translateY(0) rotate(-2deg);
|
transform: translateY(0) rotate(-2deg);
|
||||||
@@ -58,7 +58,7 @@ button {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes dolphinSwim {
|
@keyframes mascotSwim {
|
||||||
0% { transform: translate(-20vw, 10vh) rotate(-3deg) scaleX(1); }
|
0% { transform: translate(-20vw, 10vh) rotate(-3deg) scaleX(1); }
|
||||||
15% { transform: translate(2vw, 2vh) rotate(4deg) scaleX(1); }
|
15% { transform: translate(2vw, 2vh) rotate(4deg) scaleX(1); }
|
||||||
30% { transform: translate(24vw, 12vh) rotate(-3deg) scaleX(1); }
|
30% { transform: translate(24vw, 12vh) rotate(-3deg) scaleX(1); }
|
||||||
@@ -71,6 +71,21 @@ button {
|
|||||||
100% { transform: translate(-20vw, 10vh) rotate(-3deg) scaleX(-1); }
|
100% { transform: translate(-20vw, 10vh) rotate(-3deg) scaleX(-1); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The walking mascot's crossing: the same timeline as `mascotSwim`, turn included, but
|
||||||
|
along one level line instead of in waves. `mascotWaddle` runs beside it on the
|
||||||
|
separate `rotate`/`translate` properties, which compose with this `transform`. */
|
||||||
|
@keyframes mascotWalk {
|
||||||
|
0% { transform: translate(-20vw, 10vh) scaleX(1); }
|
||||||
|
70% { transform: translate(84vw, 10vh) scaleX(1); }
|
||||||
|
74% { transform: translate(84vw, 10vh) scaleX(-1); }
|
||||||
|
100% { transform: translate(-20vw, 10vh) scaleX(-1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes mascotWaddle {
|
||||||
|
0%, 100% { rotate: -4deg; translate: 0 0; }
|
||||||
|
50% { rotate: 4deg; translate: 0 -6px; }
|
||||||
|
}
|
||||||
|
|
||||||
/* Entrance animations for the browse grid and the album modal - gated by
|
/* Entrance animations for the browse grid and the album modal - gated by
|
||||||
`ANIMATE_VIEW_TRANSITIONS` in `lib/theme.ts`. */
|
`ANIMATE_VIEW_TRANSITIONS` in `lib/theme.ts`. */
|
||||||
@keyframes viewEnter {
|
@keyframes viewEnter {
|
||||||
@@ -106,9 +121,10 @@ button {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
/* Gradient stops are plain sRGB hex, not oklch(): a gradient with an oklch stop
|
/* Gradient stops are plain sRGB hex, not oklch(): a gradient with an oklch stop
|
||||||
interpolates in Oklab, which Gecko expands stop by stop on the CPU every time the
|
interpolates in Oklab, which Gecko expands stop by stop on the CPU every time the
|
||||||
display list is rebuilt - about 15% of a Pi-profile's busy time. Values are the
|
display list is rebuilt - about 15% of a Pi-profile's busy time. The skin supplies
|
||||||
oklch(55% 0.07 210) / (38% 0.06 210) / (20% 0.045 210) they replace. */
|
them (`stageGradient` in lib/skins/), pre-converted from oklch(55% 0.07 hue) /
|
||||||
background: linear-gradient(180deg, #397d88 0%, #0e4b54 45%, #001b21 100%);
|
(38% 0.06 hue) / (20% 0.045 hue). */
|
||||||
|
background: linear-gradient(180deg, var(--stage-top) 0%, var(--stage-mid) 45%, var(--stage-deep) 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Still used by the room-control page's own `<Bubbles>` field (`RoomView.tsx`) - the
|
/* Still used by the room-control page's own `<Bubbles>` field (`RoomView.tsx`) - the
|
||||||
@@ -124,6 +140,26 @@ button {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A leaf (Drift.tsx, the dinosaur's answer to `.bubble`) tumbling down the stage and
|
||||||
|
swaying on its way. Transform only: each leaf's opacity is its own inline style. */
|
||||||
|
@keyframes leafFall {
|
||||||
|
0% { transform: translate(0, 0) rotate(0deg); }
|
||||||
|
25% { transform: translate(28px, 32vh) rotate(80deg); }
|
||||||
|
50% { transform: translate(-6px, 64vh) rotate(170deg); }
|
||||||
|
75% { transform: translate(24px, 96vh) rotate(250deg); }
|
||||||
|
100% { transform: translate(-10px, calc(100vh + 80px)) rotate(330deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaf {
|
||||||
|
position: absolute;
|
||||||
|
top: -40px;
|
||||||
|
line-height: 1;
|
||||||
|
animation-name: leafFall;
|
||||||
|
animation-timing-function: linear;
|
||||||
|
animation-iteration-count: infinite;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.ambience {
|
.ambience {
|
||||||
display: block;
|
display: block;
|
||||||
/* A canvas is a replaced element: without an explicit size here, its CSS layout box
|
/* A canvas is a replaced element: without an explicit size here, its CSS layout box
|
||||||
@@ -146,13 +182,13 @@ button {
|
|||||||
padding: 9px 15px;
|
padding: 9px 15px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
background: oklch(97% 0.01 210 / 0.16);
|
background: oklch(97% 0.01 var(--hue) / 0.16);
|
||||||
color: var(--paper);
|
color: var(--paper);
|
||||||
}
|
}
|
||||||
|
|
||||||
.pill[data-active="true"] {
|
.pill[data-active="true"] {
|
||||||
background: var(--paper);
|
background: var(--paper);
|
||||||
color: oklch(28% 0.04 210);
|
color: oklch(28% 0.04 var(--hue));
|
||||||
box-shadow: 0 4px 14px var(--shadow);
|
box-shadow: 0 4px 14px var(--shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,10 +305,10 @@ button {
|
|||||||
flex: none;
|
flex: none;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
font-size: 17px;
|
font-size: 17px;
|
||||||
background: linear-gradient(160deg, #eef7f961, #eef7f91f) /* oklch(97% 0.01 210 / .38 -> .12) */;
|
background: linear-gradient(160deg, rgb(var(--paper-rgb) / .38), rgb(var(--paper-rgb) / .12)) /* oklch(97% 0.01 hue), kept sRGB like .stage */;
|
||||||
backdrop-filter: blur(8px);
|
backdrop-filter: blur(8px);
|
||||||
-webkit-backdrop-filter: blur(8px);
|
-webkit-backdrop-filter: blur(8px);
|
||||||
border: 1px solid oklch(97% 0.01 210 / 0.4);
|
border: 1px solid oklch(97% 0.01 var(--hue) / 0.4);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 3px 10px var(--shadow),
|
0 3px 10px var(--shadow),
|
||||||
inset 0 1px 0 oklch(100% 0 0 / 0.35);
|
inset 0 1px 0 oklch(100% 0 0 / 0.35);
|
||||||
@@ -281,10 +317,10 @@ button {
|
|||||||
/* A frosted panel wrapped around a shelf/list of rows so they read as a distinct
|
/* A frosted panel wrapped around a shelf/list of rows so they read as a distinct
|
||||||
surface floating over the stage gradient, rather than flat text on a gradient. */
|
surface floating over the stage gradient, rather than flat text on a gradient. */
|
||||||
.glass-panel {
|
.glass-panel {
|
||||||
background: linear-gradient(160deg, #eef7f924, #eef7f90d) /* oklch(97% 0.01 210 / .14 -> .05) */;
|
background: linear-gradient(160deg, rgb(var(--paper-rgb) / .14), rgb(var(--paper-rgb) / .05)) /* oklch(97% 0.01 hue), kept sRGB like .stage */;
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
-webkit-backdrop-filter: blur(10px);
|
-webkit-backdrop-filter: blur(10px);
|
||||||
border: 1px solid oklch(97% 0.01 210 / 0.16);
|
border: 1px solid oklch(97% 0.01 var(--hue) / 0.16);
|
||||||
border-radius: 22px;
|
border-radius: 22px;
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 8px 24px var(--shadow),
|
0 8px 24px var(--shadow),
|
||||||
@@ -296,7 +332,7 @@ button {
|
|||||||
.list-row {
|
.list-row {
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
-webkit-backdrop-filter: blur(10px);
|
-webkit-backdrop-filter: blur(10px);
|
||||||
border: 1px solid oklch(97% 0.01 210 / 0.16);
|
border: 1px solid oklch(97% 0.01 var(--hue) / 0.16);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 4px 14px var(--shadow),
|
0 4px 14px var(--shadow),
|
||||||
inset 0 1px 0 oklch(100% 0 0 / 0.12);
|
inset 0 1px 0 oklch(100% 0 0 / 0.12);
|
||||||
@@ -326,13 +362,13 @@ button {
|
|||||||
.sheet {
|
.sheet {
|
||||||
background: var(--paper);
|
background: var(--paper);
|
||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
box-shadow: 0 20px 50px oklch(10% 0.04 210 / 0.5);
|
box-shadow: 0 20px 50px oklch(10% 0.04 var(--hue) / 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.overlay {
|
.overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: oklch(15% 0.03 210 / 0.55);
|
background: oklch(15% 0.03 var(--hue) / 0.55);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -398,7 +434,7 @@ input[type="range"] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.searchbox-placeholder {
|
.searchbox-placeholder {
|
||||||
color: oklch(30% 0.04 210 / .45);
|
color: oklch(30% 0.04 var(--hue) / .45);
|
||||||
}
|
}
|
||||||
|
|
||||||
.searchbox-caret {
|
.searchbox-caret {
|
||||||
@@ -417,7 +453,7 @@ input[type="range"] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.searchbox-note {
|
.searchbox-note {
|
||||||
color: oklch(90% 0.02 210 / .85);
|
color: oklch(90% 0.02 var(--hue) / .85);
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,14 +5,30 @@
|
|||||||
/* Liquid-glass surfaces over the sea gradient: a dark translucent fill, a strong
|
/* Liquid-glass surfaces over the sea gradient: a dark translucent fill, a strong
|
||||||
blur with a saturation boost so colour behind it still glows through, and a thin
|
blur with a saturation boost so colour behind it still glows through, and a thin
|
||||||
bright rim along the top edge. */
|
bright rim along the top edge. */
|
||||||
--m-glass-fill: oklch(32% 0.04 210 / 0.42);
|
--m-glass-fill: oklch(32% 0.04 var(--hue) / 0.42);
|
||||||
--m-glass-rim: oklch(100% 0 0 / 0.22);
|
--m-glass-rim: oklch(100% 0 0 / 0.22);
|
||||||
--m-glass-shadow: 0 10px 30px oklch(10% 0.04 210 / 0.45);
|
--m-glass-shadow: 0 10px 30px oklch(10% 0.04 var(--hue) / 0.45);
|
||||||
--m-gutter: 16px;
|
--m-gutter: 16px;
|
||||||
/* Height of the floating dock, so scrolling content can clear it. */
|
/* Height of the floating dock, so scrolling content can clear it. */
|
||||||
--m-dock-clearance: calc(96px + env(safe-area-inset-bottom));
|
--m-dock-clearance: calc(96px + env(safe-area-inset-bottom));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* iOS can leave the layout viewport shorter than the screen - after the keyboard has
|
||||||
|
been up, `100dvh` in a home-screen app comes back one status bar short - and the
|
||||||
|
page behind the stage showed as a white strip along the bottom. Pinning the stage to
|
||||||
|
the viewport's edges does not depend on that height at all, and the sea colour
|
||||||
|
underneath covers whatever might still peek through. */
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
background: var(--stage-deep, #001b21);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage[data-phone] {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.m-glass {
|
.m-glass {
|
||||||
background: var(--m-glass-fill);
|
background: var(--m-glass-fill);
|
||||||
backdrop-filter: blur(14px) saturate(1.7);
|
backdrop-filter: blur(14px) saturate(1.7);
|
||||||
@@ -39,7 +55,8 @@
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
padding: env(safe-area-inset-top) 0 calc(var(--m-dock-clearance) + 84px);
|
/* No top inset here: the sticky bar reaches up under the status bar itself. */
|
||||||
|
padding: 0 0 calc(var(--m-dock-clearance) + 84px);
|
||||||
animation: viewEnter 180ms ease-out;
|
animation: viewEnter 180ms ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,14 +66,14 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 14px var(--m-gutter) 4px;
|
padding: calc(14px + env(safe-area-inset-top)) var(--m-gutter) 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.m-title-mascot {
|
.m-title-mascot {
|
||||||
width: 44px;
|
width: 44px;
|
||||||
height: 44px;
|
height: 44px;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
filter: drop-shadow(0 3px 8px oklch(15% 0.05 210 / 0.5));
|
filter: drop-shadow(0 3px 8px oklch(15% 0.05 var(--hue) / 0.5));
|
||||||
}
|
}
|
||||||
|
|
||||||
.m-title {
|
.m-title {
|
||||||
@@ -65,14 +82,14 @@
|
|||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
color: var(--paper);
|
color: var(--paper);
|
||||||
letter-spacing: -0.01em;
|
letter-spacing: -0.01em;
|
||||||
text-shadow: 0 2px 10px oklch(15% 0.05 210 / 0.45);
|
text-shadow: 0 2px 10px oklch(15% 0.05 var(--hue) / 0.45);
|
||||||
}
|
}
|
||||||
|
|
||||||
.m-status {
|
.m-status {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
color: var(--paper);
|
color: var(--paper);
|
||||||
background: oklch(97% 0.01 210 / 0.16);
|
background: oklch(97% 0.01 var(--hue) / 0.16);
|
||||||
padding: 5px 12px;
|
padding: 5px 12px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -82,21 +99,45 @@
|
|||||||
color: oklch(80% 0.16 30);
|
color: oklch(80% 0.16 30);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The bar that stays put while the page scrolls under it. Fades from the top of the
|
/* The bar that stays put while the page scrolls under it. Sticks to the very top of
|
||||||
sea into transparency rather than ending in a hard edge. */
|
the screen, under the status bar, so content never shows behind the clock.
|
||||||
|
|
||||||
|
Frosted rather than painted: the background behind it is the ambience canvas,
|
||||||
|
which takes its colour from whatever is playing, so any fixed fill was the wrong
|
||||||
|
colour as soon as an album with different art started. A blur with a neutral
|
||||||
|
darkening suits every one of them, and the mask fades it out instead of ending in
|
||||||
|
a hard edge. */
|
||||||
.m-sticky {
|
.m-sticky {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
padding: 10px var(--m-gutter) 14px;
|
padding: calc(10px + env(safe-area-inset-top)) var(--m-gutter) 14px;
|
||||||
background: linear-gradient(180deg, #2f6f7aee 0%, #2f6f7a99 65%, #2f6f7a00 100%);
|
}
|
||||||
|
|
||||||
|
/* Under the title row, overlap the row by the status bar's height: the bar keeps its
|
||||||
|
place in the layout but already covers the status bar once it sticks. */
|
||||||
|
.m-title-row + .m-sticky {
|
||||||
|
margin-top: calc(-1 * env(safe-area-inset-top));
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-sticky::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: -1;
|
||||||
|
background: oklch(0% 0 0 / 0.16);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
-webkit-mask-image: linear-gradient(180deg, #000 70%, transparent);
|
||||||
|
mask-image: linear-gradient(180deg, #000 70%, transparent);
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.m-navbar {
|
.m-navbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding-top: 12px;
|
padding-top: calc(12px + env(safe-area-inset-top));
|
||||||
}
|
}
|
||||||
|
|
||||||
.m-navbar-title {
|
.m-navbar-title {
|
||||||
@@ -116,7 +157,7 @@
|
|||||||
margin: -6px var(--m-gutter) 14px;
|
margin: -6px var(--m-gutter) 14px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: oklch(90% 0.02 210 / 0.75);
|
color: oklch(90% 0.02 var(--hue) / 0.75);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------- segmented -- */
|
/* ------------------------------------------------------------- segmented -- */
|
||||||
@@ -126,11 +167,11 @@
|
|||||||
gap: 2px;
|
gap: 2px;
|
||||||
padding: 3px;
|
padding: 3px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: oklch(20% 0.04 210 / 0.38);
|
background: oklch(20% 0.04 var(--hue) / 0.38);
|
||||||
backdrop-filter: blur(12px) saturate(1.6);
|
backdrop-filter: blur(12px) saturate(1.6);
|
||||||
-webkit-backdrop-filter: blur(12px) saturate(1.6);
|
-webkit-backdrop-filter: blur(12px) saturate(1.6);
|
||||||
border: 1px solid oklch(100% 0 0 / 0.14);
|
border: 1px solid oklch(100% 0 0 / 0.14);
|
||||||
box-shadow: inset 0 1px 2px oklch(10% 0.04 210 / 0.35);
|
box-shadow: inset 0 1px 2px oklch(10% 0.04 var(--hue) / 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
.m-segment {
|
.m-segment {
|
||||||
@@ -138,7 +179,7 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: oklch(95% 0.01 210 / 0.85);
|
color: oklch(95% 0.01 var(--hue) / 0.85);
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
@@ -154,7 +195,7 @@
|
|||||||
.m-segment[data-active="true"] {
|
.m-segment[data-active="true"] {
|
||||||
background: var(--paper);
|
background: var(--paper);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
box-shadow: 0 3px 10px oklch(10% 0.04 210 / 0.35);
|
box-shadow: 0 3px 10px oklch(10% 0.04 var(--hue) / 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ grids -- */
|
/* ------------------------------------------------------------------ grids -- */
|
||||||
@@ -202,7 +243,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.m-album-cover {
|
.m-album-cover {
|
||||||
box-shadow: 0 6px 16px oklch(10% 0.04 210 / 0.45);
|
box-shadow: 0 6px 16px oklch(10% 0.04 var(--hue) / 0.45);
|
||||||
transition: transform 0.12s ease;
|
transition: transform 0.12s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,7 +294,7 @@
|
|||||||
|
|
||||||
.m-song:active,
|
.m-song:active,
|
||||||
.m-song[data-current="true"] {
|
.m-song[data-current="true"] {
|
||||||
background: oklch(97% 0.01 210 / 0.14);
|
background: oklch(97% 0.01 var(--hue) / 0.14);
|
||||||
}
|
}
|
||||||
|
|
||||||
.m-song-cover {
|
.m-song-cover {
|
||||||
@@ -282,12 +323,12 @@
|
|||||||
.m-song-sub {
|
.m-song-sub {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: oklch(88% 0.02 210 / 0.65);
|
color: oklch(88% 0.02 var(--hue) / 0.65);
|
||||||
}
|
}
|
||||||
|
|
||||||
.m-song-time {
|
.m-song-time {
|
||||||
font: 700 12px ui-monospace, Menlo, monospace;
|
font: 700 12px ui-monospace, Menlo, monospace;
|
||||||
color: oklch(88% 0.02 210 / 0.6);
|
color: oklch(88% 0.02 var(--hue) / 0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
.m-hint {
|
.m-hint {
|
||||||
@@ -296,7 +337,7 @@
|
|||||||
padding: 0 var(--m-gutter);
|
padding: 0 var(--m-gutter);
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
color: oklch(92% 0.02 210 / 0.8);
|
color: oklch(92% 0.02 var(--hue) / 0.8);
|
||||||
}
|
}
|
||||||
|
|
||||||
.m-hint img {
|
.m-hint img {
|
||||||
@@ -381,14 +422,14 @@
|
|||||||
border: none;
|
border: none;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: oklch(95% 0.01 210 / 0.8);
|
color: oklch(95% 0.01 var(--hue) / 0.8);
|
||||||
font: inherit;
|
font: inherit;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.2s ease, color 0.2s ease;
|
transition: background 0.2s ease, color 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.m-tab[data-active="true"] {
|
.m-tab[data-active="true"] {
|
||||||
background: oklch(97% 0.01 210 / 0.2);
|
background: oklch(97% 0.01 var(--hue) / 0.2);
|
||||||
color: var(--paper);
|
color: var(--paper);
|
||||||
box-shadow: inset 0 1px 0 oklch(100% 0 0 / 0.2);
|
box-shadow: inset 0 1px 0 oklch(100% 0 0 / 0.2);
|
||||||
}
|
}
|
||||||
@@ -442,7 +483,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.m-search input::placeholder {
|
.m-search input::placeholder {
|
||||||
color: oklch(95% 0.01 210 / 0.55);
|
color: oklch(95% 0.01 var(--hue) / 0.55);
|
||||||
}
|
}
|
||||||
|
|
||||||
.m-search input::-webkit-search-cancel-button {
|
.m-search input::-webkit-search-cancel-button {
|
||||||
@@ -453,7 +494,7 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 17px;
|
font-size: 17px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: oklch(95% 0.01 210 / 0.7);
|
color: oklch(95% 0.01 var(--hue) / 0.7);
|
||||||
}
|
}
|
||||||
|
|
||||||
.m-search-clear {
|
.m-search-clear {
|
||||||
@@ -465,7 +506,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: oklch(97% 0.01 210 / 0.3);
|
background: oklch(97% 0.01 var(--hue) / 0.3);
|
||||||
color: var(--sea-deep);
|
color: var(--sea-deep);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -504,7 +545,7 @@
|
|||||||
height: 44px;
|
height: 44px;
|
||||||
flex: none;
|
flex: none;
|
||||||
display: flex;
|
display: flex;
|
||||||
box-shadow: 0 3px 10px oklch(10% 0.04 210 / 0.4);
|
box-shadow: 0 3px 10px oklch(10% 0.04 var(--hue) / 0.4);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -527,7 +568,7 @@
|
|||||||
.m-mini-sub {
|
.m-mini-sub {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: oklch(90% 0.02 210 / 0.7);
|
color: oklch(90% 0.02 var(--hue) / 0.7);
|
||||||
}
|
}
|
||||||
|
|
||||||
.m-mini-button {
|
.m-mini-button {
|
||||||
@@ -546,7 +587,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.m-mini-button:active {
|
.m-mini-button:active {
|
||||||
background: oklch(97% 0.01 210 / 0.15);
|
background: oklch(97% 0.01 var(--hue) / 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.m-mini-progress {
|
.m-mini-progress {
|
||||||
|
|||||||
@@ -3,24 +3,25 @@
|
|||||||
the radius scale and the entrance animations are deliberately identical [to
|
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
|
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
|
player's sea is 210, this is 175 - a turquoise lagoon, clearly the same ocean and
|
||||||
clearly not the same room.").
|
clearly not the same room."). Both hues now come from the skin (lib/skins/) as
|
||||||
|
`--hue` and `--tp-hue`; 210/175 are the dolphin's.
|
||||||
|
|
||||||
Classes here are prefixed `tp-` and colours scoped under `.tp-stage` rather than
|
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
|
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 -
|
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
|
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,
|
below with no hue baked into it - .bubble, .card, .key-cap, .view-enter,
|
||||||
.backdrop-enter, and the dolphinBob/bubbleRise/viewEnter/backdropEnter keyframes - is
|
.backdrop-enter, and the mascotBob/bubbleRise/viewEnter/backdropEnter keyframes - is
|
||||||
identical between the two apps and reused directly from app.css instead of being
|
identical between the two apps and reused directly from app.css instead of being
|
||||||
duplicated here. */
|
duplicated here. */
|
||||||
|
|
||||||
.tp-stage {
|
.tp-stage {
|
||||||
--ink: oklch(30% 0.04 175);
|
--ink: oklch(30% 0.04 var(--tp-hue));
|
||||||
--paper: oklch(97% 0.01 175);
|
--paper: oklch(97% 0.01 var(--tp-hue));
|
||||||
--accent: oklch(70% 0.16 340);
|
--accent: oklch(70% 0.16 var(--accent-hue));
|
||||||
--accent-dim: oklch(78% 0.14 340);
|
--accent-dim: oklch(78% 0.14 var(--accent-hue));
|
||||||
--sea-deep: oklch(20% 0.045 175);
|
--sea-deep: oklch(20% 0.045 var(--tp-hue));
|
||||||
--shadow: oklch(15% 0.05 175 / 0.35);
|
--shadow: oklch(15% 0.05 var(--tp-hue) / 0.35);
|
||||||
/* This app's own additions: the feedback colours. Note there is no red - a wrong key
|
/* This app's own additions: the feedback colours. Note there is no red - a wrong key
|
||||||
is amber and gentle, never an alarm. */
|
is amber and gentle, never an alarm. */
|
||||||
--correct: oklch(80% 0.17 150);
|
--correct: oklch(80% 0.17 150);
|
||||||
@@ -32,8 +33,8 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
180deg,
|
180deg,
|
||||||
oklch(55% 0.07 175) 0%,
|
oklch(55% 0.07 var(--tp-hue)) 0%,
|
||||||
oklch(38% 0.06 175) 45%,
|
oklch(38% 0.06 var(--tp-hue)) 45%,
|
||||||
var(--sea-deep) 100%
|
var(--sea-deep) 100%
|
||||||
);
|
);
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -51,8 +52,8 @@
|
|||||||
opacity: 0.45;
|
opacity: 0.45;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* None of these five collide with app.css's own keyframes (bubbleRise, dolphinBob,
|
/* None of these five collide with app.css's own keyframes (bubbleRise, mascotBob,
|
||||||
dolphinSwim, viewEnter, sheetEnter, backdropEnter), so they keep their plain names -
|
mascotSwim, viewEnter, sheetEnter, backdropEnter), so they keep their plain names -
|
||||||
only classes that would otherwise collide are `tp-`-prefixed. */
|
only classes that would otherwise collide are `tp-`-prefixed. */
|
||||||
@keyframes correctPop {
|
@keyframes correctPop {
|
||||||
0% { transform: scale(1); }
|
0% { transform: scale(1); }
|
||||||
@@ -69,8 +70,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes keyPulse {
|
@keyframes keyPulse {
|
||||||
0%, 100% { box-shadow: 0 0 0 0 oklch(97% 0.01 175 / 0.6); }
|
0%, 100% { box-shadow: 0 0 0 0 oklch(97% 0.01 var(--tp-hue) / 0.6); }
|
||||||
50% { box-shadow: 0 0 0 9px oklch(97% 0.01 175 / 0); }
|
50% { box-shadow: 0 0 0 9px oklch(97% 0.01 var(--tp-hue) / 0); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes tierEnter {
|
@keyframes tierEnter {
|
||||||
@@ -93,6 +94,7 @@
|
|||||||
/* Keep the colour feedback, drop the movement - covers the shared .bubble/.view-enter/
|
/* Keep the colour feedback, drop the movement - covers the shared .bubble/.view-enter/
|
||||||
.backdrop-enter classes too, which app.css does not otherwise handle. */
|
.backdrop-enter classes too, which app.css does not otherwise handle. */
|
||||||
.bubble,
|
.bubble,
|
||||||
|
.leaf,
|
||||||
.view-enter,
|
.view-enter,
|
||||||
.tp-sheet-enter,
|
.tp-sheet-enter,
|
||||||
.backdrop-enter {
|
.backdrop-enter {
|
||||||
@@ -101,10 +103,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tp-glass-panel {
|
.tp-glass-panel {
|
||||||
background: linear-gradient(160deg, oklch(97% 0.01 175 / 0.14), oklch(97% 0.01 175 / 0.05));
|
background: linear-gradient(160deg, oklch(97% 0.01 var(--tp-hue) / 0.14), oklch(97% 0.01 var(--tp-hue) / 0.05));
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
-webkit-backdrop-filter: blur(10px);
|
-webkit-backdrop-filter: blur(10px);
|
||||||
border: 1px solid oklch(97% 0.01 175 / 0.16);
|
border: 1px solid oklch(97% 0.01 var(--tp-hue) / 0.16);
|
||||||
border-radius: 22px;
|
border-radius: 22px;
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 8px 24px var(--shadow),
|
0 8px 24px var(--shadow),
|
||||||
@@ -126,26 +128,26 @@
|
|||||||
padding: 9px 15px;
|
padding: 9px 15px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
background: oklch(97% 0.01 175 / 0.16);
|
background: oklch(97% 0.01 var(--tp-hue) / 0.16);
|
||||||
color: var(--paper);
|
color: var(--paper);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tp-pill[data-active="true"] {
|
.tp-pill[data-active="true"] {
|
||||||
background: var(--paper);
|
background: var(--paper);
|
||||||
color: oklch(28% 0.04 175);
|
color: oklch(28% 0.04 var(--tp-hue));
|
||||||
box-shadow: 0 4px 14px var(--shadow);
|
box-shadow: 0 4px 14px var(--shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tp-sheet {
|
.tp-sheet {
|
||||||
background: var(--paper);
|
background: var(--paper);
|
||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
box-shadow: 0 20px 50px oklch(10% 0.04 175 / 0.5);
|
box-shadow: 0 20px 50px oklch(10% 0.04 var(--tp-hue) / 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tp-overlay {
|
.tp-overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: oklch(15% 0.03 175 / 0.55);
|
background: oklch(15% 0.03 var(--tp-hue) / 0.55);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -179,8 +181,8 @@
|
|||||||
font-size: calc(var(--kb-size, 42px) * 0.42);
|
font-size: calc(var(--kb-size, 42px) * 0.42);
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
color: var(--paper);
|
color: var(--paper);
|
||||||
background: oklch(97% 0.01 175 / 0.1);
|
background: oklch(97% 0.01 var(--tp-hue) / 0.1);
|
||||||
border: 1px solid oklch(97% 0.01 175 / 0.14);
|
border: 1px solid oklch(97% 0.01 var(--tp-hue) / 0.14);
|
||||||
backdrop-filter: blur(6px);
|
backdrop-filter: blur(6px);
|
||||||
transition:
|
transition:
|
||||||
background 0.15s ease,
|
background 0.15s ease,
|
||||||
@@ -209,7 +211,7 @@
|
|||||||
|
|
||||||
.tp-kb-key[data-next="true"] {
|
.tp-kb-key[data-next="true"] {
|
||||||
background: var(--paper);
|
background: var(--paper);
|
||||||
color: oklch(25% 0.05 175);
|
color: oklch(25% 0.05 var(--tp-hue));
|
||||||
transform: translateY(-3px) scale(1.08);
|
transform: translateY(-3px) scale(1.08);
|
||||||
animation: keyPulse 1.3s ease-out infinite;
|
animation: keyPulse 1.3s ease-out infinite;
|
||||||
}
|
}
|
||||||
@@ -240,7 +242,7 @@
|
|||||||
they read as the modifier they are, and dim until a capital actually needs one. */
|
they read as the modifier they are, and dim until a capital actually needs one. */
|
||||||
.tp-kb-shift {
|
.tp-kb-shift {
|
||||||
width: calc(var(--kb-size, 42px) * 1.8);
|
width: calc(var(--kb-size, 42px) * 1.8);
|
||||||
background: oklch(97% 0.01 175 / 0.12);
|
background: oklch(97% 0.01 var(--tp-hue) / 0.12);
|
||||||
font-size: calc(var(--kb-size, 42px) * 0.4);
|
font-size: calc(var(--kb-size, 42px) * 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,7 +275,7 @@
|
|||||||
wider, the whole line would reflow around the cursor as she types, and chunks
|
wider, the whole line would reflow around the cursor as she types, and chunks
|
||||||
would visibly jump between lines mid-word. */
|
would visibly jump between lines mid-word. */
|
||||||
padding: 0 6px;
|
padding: 0 6px;
|
||||||
color: oklch(97% 0.01 175 / 0.45);
|
color: oklch(97% 0.01 var(--tp-hue) / 0.45);
|
||||||
transition: color 0.12s ease;
|
transition: color 0.12s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,7 +284,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tp-target-char[data-state="current"] {
|
.tp-target-char[data-state="current"] {
|
||||||
color: oklch(25% 0.05 175);
|
color: oklch(25% 0.05 var(--tp-hue));
|
||||||
background: var(--paper);
|
background: var(--paper);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
box-shadow: 0 6px 18px var(--shadow);
|
box-shadow: 0 6px 18px var(--shadow);
|
||||||
@@ -342,19 +344,20 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: radial-gradient(
|
background: radial-gradient(
|
||||||
circle at 50% 42%,
|
circle at 50% 42%,
|
||||||
oklch(45% 0.07 175 / 0.82) 0%,
|
oklch(45% 0.07 var(--tp-hue) / 0.82) 0%,
|
||||||
oklch(16% 0.04 175 / 0.94) 70%
|
oklch(16% 0.04 var(--tp-hue) / 0.94) 70%
|
||||||
);
|
);
|
||||||
animation: backdropEnter 260ms ease-out;
|
animation: backdropEnter 260ms ease-out;
|
||||||
/* Thrown right back, out of the column the cover rises through - see the note on
|
/* Thrown right back, out of the column the cover rises through - see the note on
|
||||||
.tp-chest-lid. Alone this would look like a lid coming off; with the cover standing
|
.tp-reward-lid. Alone this would look like a lid coming off; with the cover standing
|
||||||
in the middle it reads as the lid flung open to make room for it. */
|
in the middle it reads as the lid flung open to make room for it. */
|
||||||
--tp-chest-open: -108deg;
|
--tp-reward-open: -108deg;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The lid's two states. Transitioned rather than keyframed so the map can draw a
|
/* The lid's two states - the chest's lid, or the egg's top shell (DinoEgg.tsx), which
|
||||||
|
hinges the same way. Transitioned rather than keyframed so the map can draw a
|
||||||
permanently open chest with no animation at all - see TreasureChest.tsx. */
|
permanently open chest with no animation at all - see TreasureChest.tsx. */
|
||||||
.tp-chest-lid {
|
.tp-reward-lid {
|
||||||
transform-box: view-box;
|
transform-box: view-box;
|
||||||
transform: rotate(0deg);
|
transform: rotate(0deg);
|
||||||
transition: transform 620ms cubic-bezier(0.34, 1.56, 0.64, 1);
|
transition: transform 620ms cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
@@ -365,12 +368,12 @@
|
|||||||
attached to its hinge - a lid much past -90deg, alone, reads as a flap flying off.
|
attached to its hinge - a lid much past -90deg, alone, reads as a flap flying off.
|
||||||
The overlay overrides it above, because there the lid has a cover rising through the
|
The overlay overrides it above, because there the lid has a cover rising through the
|
||||||
middle of the chest and has to clear out of its way. */
|
middle of the chest and has to clear out of its way. */
|
||||||
.tp-chest-lid[data-open] {
|
.tp-reward-lid[data-open] {
|
||||||
transform: rotate(var(--tp-chest-open, -62deg));
|
transform: rotate(var(--tp-reward-open, -62deg));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The chest arriving: dropped in, with a squash on landing. */
|
/* The chest arriving: dropped in, with a squash on landing. */
|
||||||
@keyframes chestDrop {
|
@keyframes rewardDrop {
|
||||||
0% { opacity: 0; transform: translateY(-120px) scale(0.7); }
|
0% { opacity: 0; transform: translateY(-120px) scale(0.7); }
|
||||||
55% { opacity: 1; transform: translateY(0) scale(1.06, 0.9); }
|
55% { opacity: 1; transform: translateY(0) scale(1.06, 0.9); }
|
||||||
75% { transform: translateY(-6px) scale(0.98, 1.04); }
|
75% { transform: translateY(-6px) scale(0.98, 1.04); }
|
||||||
@@ -380,7 +383,7 @@
|
|||||||
/* The wait before the lid goes: the chest rattling, because something inside wants out.
|
/* The wait before the lid goes: the chest rattling, because something inside wants out.
|
||||||
This is the beat that earns the opening - a lid that opens the instant the chest lands
|
This is the beat that earns the opening - a lid that opens the instant the chest lands
|
||||||
is just a transition, not a reveal. */
|
is just a transition, not a reveal. */
|
||||||
@keyframes chestRattle {
|
@keyframes rewardRattle {
|
||||||
0%, 100% { transform: translateX(0) rotate(0deg); }
|
0%, 100% { transform: translateX(0) rotate(0deg); }
|
||||||
15% { transform: translateX(-3px) rotate(-2.5deg); }
|
15% { transform: translateX(-3px) rotate(-2.5deg); }
|
||||||
30% { transform: translateX(3px) rotate(2.5deg); }
|
30% { transform: translateX(3px) rotate(2.5deg); }
|
||||||
@@ -447,7 +450,7 @@
|
|||||||
.tp-reward-overlay * {
|
.tp-reward-overlay * {
|
||||||
animation: none !important;
|
animation: none !important;
|
||||||
}
|
}
|
||||||
.tp-chest-lid {
|
.tp-reward-lid {
|
||||||
transition: none;
|
transition: none;
|
||||||
}
|
}
|
||||||
.tp-confetti-piece {
|
.tp-confetti-piece {
|
||||||
|
|||||||
Reference in New Issue
Block a user