Web frontend: switchable dolphin/dinosaur skins
Two kids, two looks: ?theme=dolphin or ?theme=dinosaur picks one, and the choice is remembered in the browser so the installed home-screen app keeps it. Everything that differs per child lives in one Skin object per file under web/src/lib/skins/; layout and feature toggles in lib/theme.ts stay shared. The dolphin renders pixel-identical to before. The hardcoded sea hues (210 music, 175 typing game, 340 accent) are now CSS variables written once at startup, and the Gecko-friendly sRGB gradient stops come from the skin. The dinosaur is jungle green with a lava-orange accent, its own mascot, icons, manifest and browser colour. Nothing underwater is left in the dinosaur skin: leaves instead of bubbles (play-view canvas, room page, typing game), a walking mascot, lava blobs in the bubble game, a hatching egg instead of the treasure chest, dino pets that walk or fly, a prehistoric speed ladder with the T-Rex as the hero rung, and renamed games and light colours. Pets and ladder rungs are keyed by the backend's existing ids, used as slots, so the backend and save format are unchanged. Pets show as emoji until drawings exist. python-backend/.gitignore: ignore tippen-curriculum-dino.yml, the dino device's personal curriculum, like the original. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -53,7 +53,7 @@ export function AlbumModal({
|
||||
className={ANIMATE_VIEW_TRANSITIONS ? "overlay backdrop-enter" : "overlay"}
|
||||
style={{
|
||||
zIndex: 4,
|
||||
background: "oklch(15% 0.03 210 / .6)",
|
||||
background: "oklch(15% 0.03 var(--hue) / .6)",
|
||||
...(phone && { alignItems: "flex-end", padding: "calc(40px + env(safe-area-inset-top)) 0 0" }),
|
||||
}}
|
||||
onClick={onClose}
|
||||
@@ -63,14 +63,14 @@ export function AlbumModal({
|
||||
className={ANIMATE_VIEW_TRANSITIONS ? "sheet-enter" : undefined}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
style={{
|
||||
background: "oklch(96% 0.012 210)",
|
||||
background: "oklch(96% 0.012 var(--hue))",
|
||||
borderRadius: phone ? "26px 26px 0 0" : 26,
|
||||
padding: phone ? "20px 16px calc(20px + env(safe-area-inset-bottom))" : 26,
|
||||
width: phone ? "100%" : 640,
|
||||
maxWidth: "100%",
|
||||
maxHeight: "100%",
|
||||
overflow: "auto",
|
||||
boxShadow: "0 24px 60px oklch(10% 0.04 210 / .55)",
|
||||
boxShadow: "0 24px 60px oklch(10% 0.04 var(--hue) / .55)",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", gap: phone ? 14 : 20, alignItems: "flex-start" }}>
|
||||
@@ -88,7 +88,7 @@ export function AlbumModal({
|
||||
style={{
|
||||
fontSize: phone ? 20 : 26,
|
||||
fontWeight: 900,
|
||||
color: "oklch(22% 0.03 210)",
|
||||
color: "oklch(22% 0.03 var(--hue))",
|
||||
lineHeight: 1.15,
|
||||
}}
|
||||
>
|
||||
@@ -98,7 +98,7 @@ export function AlbumModal({
|
||||
style={{
|
||||
fontSize: 15,
|
||||
fontWeight: 700,
|
||||
color: "oklch(45% 0.03 210 / .85)",
|
||||
color: "oklch(45% 0.03 var(--hue) / .85)",
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
@@ -108,7 +108,7 @@ export function AlbumModal({
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
color: "oklch(50% 0.03 210 / .7)",
|
||||
color: "oklch(50% 0.03 var(--hue) / .7)",
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
@@ -123,8 +123,8 @@ export function AlbumModal({
|
||||
marginTop: 14,
|
||||
border: "none",
|
||||
cursor: album.locked ? "default" : "pointer",
|
||||
background: album.locked ? "oklch(80% 0.02 210)" : "var(--accent)",
|
||||
color: album.locked ? "oklch(40% 0.03 210)" : "#fff",
|
||||
background: album.locked ? "oklch(80% 0.02 var(--hue))" : "var(--accent)",
|
||||
color: album.locked ? "oklch(40% 0.03 var(--hue))" : "#fff",
|
||||
fontSize: phone ? 15 : 16,
|
||||
fontWeight: 800,
|
||||
padding: phone ? "10px 18px" : "12px 22px",
|
||||
@@ -132,7 +132,7 @@ export function AlbumModal({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
boxShadow: album.locked ? "none" : "0 4px 14px oklch(70% 0.16 340 / .45)",
|
||||
boxShadow: album.locked ? "none" : "0 4px 14px oklch(70% 0.16 var(--accent-hue) / .45)",
|
||||
}}
|
||||
>
|
||||
{album.locked ? (
|
||||
@@ -158,7 +158,7 @@ export function AlbumModal({
|
||||
style={{
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
background: "oklch(88% 0.02 210)",
|
||||
background: "oklch(88% 0.02 var(--hue))",
|
||||
color: "var(--ink)",
|
||||
fontSize: 18,
|
||||
fontWeight: 900,
|
||||
@@ -193,8 +193,8 @@ export function AlbumModal({
|
||||
textAlign: "left",
|
||||
opacity: track.locked ? 0.55 : 1,
|
||||
background: current
|
||||
? "oklch(70% 0.16 340 / .16)"
|
||||
: "oklch(30% 0.03 210 / .05)",
|
||||
? "oklch(70% 0.16 var(--accent-hue) / .16)"
|
||||
: "oklch(30% 0.03 var(--hue) / .05)",
|
||||
outline: !phone && index === selectedIndex ? "3px solid var(--accent)" : "none",
|
||||
outlineOffset: 2,
|
||||
}}
|
||||
@@ -209,8 +209,8 @@ export function AlbumModal({
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
font: "800 13px ui-monospace, Menlo, monospace",
|
||||
background: current ? "var(--accent)" : "oklch(88% 0.02 210)",
|
||||
color: current ? "#fff" : "oklch(35% 0.03 210)",
|
||||
background: current ? "var(--accent)" : "oklch(88% 0.02 var(--hue))",
|
||||
color: current ? "#fff" : "oklch(35% 0.03 var(--hue))",
|
||||
}}
|
||||
>
|
||||
{track.locked ? "❓" : index + 1}
|
||||
@@ -220,7 +220,7 @@ export function AlbumModal({
|
||||
style={{
|
||||
fontSize: 16,
|
||||
fontWeight: 800,
|
||||
color: current ? "oklch(45% 0.16 340)" : "oklch(24% 0.03 210)",
|
||||
color: current ? "oklch(45% 0.16 var(--accent-hue))" : "oklch(24% 0.03 var(--hue))",
|
||||
}}
|
||||
>
|
||||
{track.locked ? "❓ Noch gesperrt" : track.title}
|
||||
@@ -230,7 +230,7 @@ export function AlbumModal({
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
color: "oklch(45% 0.03 210 / .8)",
|
||||
color: "oklch(45% 0.03 var(--hue) / .8)",
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
@@ -243,7 +243,7 @@ export function AlbumModal({
|
||||
<div
|
||||
style={{
|
||||
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)}
|
||||
|
||||
@@ -20,6 +20,10 @@
|
||||
* re-seeded from the server, read against `performance.now()`) but reads it from
|
||||
* inside the loop instead of `useState`, which is what keeps 60fps from touching
|
||||
* React at all.
|
||||
*
|
||||
* Under a skin whose `drift` is "leaves" the same particles fall instead of rising and
|
||||
* are painted as tumbling leaves: same spawn rate, same current (a wind now, rather than
|
||||
* water), same beat bursts - only the direction and the shape change.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
@@ -43,6 +47,7 @@ import {
|
||||
import type { AmbienceTunables, ManualControl } from "../lib/ambienceTunables";
|
||||
import { AMBIENCE_QUALITY } from "../lib/lowPower";
|
||||
import { groupOf, type Group } from "../lib/search";
|
||||
import { SKIN } from "../lib/skins";
|
||||
|
||||
/** Everything `?debugDynamicUI=1` wants to see: the raw analysis, what it maps to,
|
||||
* and where the eased, on-screen look currently sits relative to that target. */
|
||||
@@ -92,17 +97,20 @@ const DEBUG_EMIT_INTERVAL_MS = 200;
|
||||
//: drive the kick), not *fetch* (the curve is wanted regardless of beat quality).
|
||||
const PULSE_THRESHOLD = 0.3;
|
||||
|
||||
/** A bubble or, under the leaves skin, a leaf - see the header. */
|
||||
interface Bubble {
|
||||
/** Horizontal position in CSS pixels - stateful, unlike the old sine-wander's fixed
|
||||
* anchor: a persistent one-directional current has to be integrated over time. */
|
||||
x: number;
|
||||
/** Pixels risen since spawn. */
|
||||
/** Pixels travelled since spawn - risen for a bubble, fallen for a leaf. */
|
||||
risen: number;
|
||||
/** Size at spawn. */
|
||||
baseSize: number;
|
||||
/** Current painted size - grows from `baseSize` as the bubble rises. */
|
||||
size: number;
|
||||
baseAlpha: number;
|
||||
/** A leaf's starting angle and sway, radians - unused by a bubble. */
|
||||
phase: number;
|
||||
}
|
||||
|
||||
//: How quickly the painted look eases toward the per-frame target - an exponential
|
||||
@@ -120,6 +128,11 @@ const BEAT_BURST_RATE = 6;
|
||||
const BASE_RISE_FRACTION_PER_SECOND = 0.12;
|
||||
const BUBBLE_MIN_SIZE = 8;
|
||||
const BUBBLE_MAX_SIZE = 24;
|
||||
//: Leaves fall a little slower than bubbles rise, and are drawn larger - a leaf the
|
||||
//: size of a small bubble is a speck.
|
||||
const LEAF_SPEED = 0.7;
|
||||
const LEAF_SCALE = 1.6;
|
||||
const LEAVES = SKIN.drift === "leaves";
|
||||
//: The canvas frame clamps to this, so a tab returning from the background after
|
||||
//: minutes away doesn't dump a giant `dt` into the physics in one jump.
|
||||
const MAX_FRAME_SECONDS = 0.05;
|
||||
@@ -171,9 +184,29 @@ function spawnBubble(width: number): Bubble {
|
||||
baseSize,
|
||||
size: baseSize,
|
||||
baseAlpha: 0.25 + Math.random() * 0.2,
|
||||
phase: Math.random() * Math.PI * 2,
|
||||
};
|
||||
}
|
||||
|
||||
/** An almond-shaped leaf with a midrib, centred on (x, y). */
|
||||
function paintLeaf(ctx: CanvasRenderingContext2D, x: number, y: number, size: number, angle: number): void {
|
||||
const half = size / 2;
|
||||
ctx.save();
|
||||
ctx.translate(x, y);
|
||||
ctx.rotate(angle);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-half, 0);
|
||||
ctx.quadraticCurveTo(0, -half * 0.75, half, 0);
|
||||
ctx.quadraticCurveTo(0, half * 0.75, -half, 0);
|
||||
ctx.fill();
|
||||
ctx.globalAlpha = 0.5;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-half * 0.8, 0);
|
||||
ctx.lineTo(half * 0.8, 0);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/** Turns already-smoothed energy/valence/drive into a full `Ambience` - shared
|
||||
* between the mount-time seed (unsmoothed, snapped straight to the first sample) and
|
||||
* every subsequent frame (smoothed with `tunables.curveSmoothingTau`), so the two
|
||||
@@ -411,14 +444,20 @@ export function Ambience({ album, state, tunables, manual, onDebugFrame }: Props
|
||||
// Not playing (and not in manual mode): no new spawns, but bubbles already in
|
||||
// flight keep rising and draining away rather than freezing mid-air.
|
||||
|
||||
const riseSpeed = current.rise * size.height * BASE_RISE_FRACTION_PER_SECOND;
|
||||
const riseSpeed =
|
||||
current.rise * size.height * BASE_RISE_FRACTION_PER_SECOND * (LEAVES ? LEAF_SPEED : 1);
|
||||
bubbles = bubbles.filter((b) => {
|
||||
const heightFraction = clamp01(b.risen / size.height);
|
||||
// Height above the bottom, which is what the current's bands are measured in.
|
||||
const travelled = clamp01(b.risen / size.height);
|
||||
const heightFraction = LEAVES ? 1 - travelled : travelled;
|
||||
const velocity = currentVelocityAt(heightFraction, kickedMagnitude);
|
||||
b.x = (((b.x + velocity * dt) % size.width) + size.width) % size.width;
|
||||
b.risen += riseSpeed * dt;
|
||||
b.size = b.baseSize * (1 + tunables.bubbleGrowthRate * heightFraction);
|
||||
return b.risen < size.height + BUBBLE_MAX_SIZE * 2;
|
||||
// Bubbles swell as the pressure drops on the way up; a leaf stays a leaf.
|
||||
b.size = LEAVES
|
||||
? b.baseSize * LEAF_SCALE
|
||||
: b.baseSize * (1 + tunables.bubbleGrowthRate * heightFraction);
|
||||
return b.risen < size.height + BUBBLE_MAX_SIZE * 2 * (LEAVES ? LEAF_SCALE : 1);
|
||||
});
|
||||
|
||||
// No `clearRect`: the gradient below is fully opaque (`oklchString` defaults to
|
||||
@@ -433,10 +472,21 @@ export function Ambience({ album, state, tunables, manual, onDebugFrame }: Props
|
||||
ctx.fillRect(0, 0, size.width, size.height);
|
||||
|
||||
if (!reducedMotion) {
|
||||
const third = size.height / 3;
|
||||
for (const b of bubbles) {
|
||||
if (LEAVES) {
|
||||
// Falling from above the top edge, fading out over the bottom third.
|
||||
const y = b.risen - BUBBLE_MAX_SIZE * LEAF_SCALE;
|
||||
const toBottom = size.height - y;
|
||||
const fade = toBottom < third ? Math.max(0, toBottom / third) : 1;
|
||||
const sway = Math.sin(b.risen / 60 + b.phase);
|
||||
ctx.fillStyle = oklchString(current.tint, b.baseAlpha * fade);
|
||||
ctx.strokeStyle = ctx.fillStyle;
|
||||
paintLeaf(ctx, b.x + sway * 10, y, b.size, b.phase + b.risen / 140 + sway * 0.4);
|
||||
continue;
|
||||
}
|
||||
const y = size.height + BUBBLE_MAX_SIZE - b.risen;
|
||||
const topThird = size.height / 3;
|
||||
const fade = y < topThird ? Math.max(0, y / topThird) : 1;
|
||||
const fade = y < third ? Math.max(0, y / third) : 1;
|
||||
ctx.beginPath();
|
||||
ctx.fillStyle = oklchString(current.tint, b.baseAlpha * fade);
|
||||
ctx.arc(b.x, y, b.size / 2, 0, Math.PI * 2);
|
||||
|
||||
@@ -194,7 +194,7 @@ function ManualControlRow({
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
<label style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
|
||||
<input
|
||||
@@ -266,8 +266,8 @@ export function AmbienceDebugOverlay({
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
zIndex: 1000,
|
||||
background: "oklch(15% 0.02 210 / .92)",
|
||||
color: "oklch(95% 0.01 210)",
|
||||
background: "oklch(15% 0.02 var(--hue) / .92)",
|
||||
color: "oklch(95% 0.01 var(--hue))",
|
||||
font: "12px/1.5 ui-monospace, Menlo, monospace",
|
||||
padding: "8px 14px",
|
||||
display: "flex",
|
||||
@@ -278,7 +278,7 @@ export function AmbienceDebugOverlay({
|
||||
}}
|
||||
>
|
||||
<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="playing" value={snapshot.playing ? "yes" : "no"} />
|
||||
<Field label="position" value={`${fmt(snapshot.position, 1)}s`} />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { SKIN } from "../lib/skins";
|
||||
import { SHOW_APP_TITLE } from "../lib/theme";
|
||||
|
||||
interface Props {
|
||||
@@ -12,8 +13,8 @@ interface Props {
|
||||
}
|
||||
|
||||
export function AppHeader({
|
||||
title = "Musik Delfin",
|
||||
mascot = "/dolphin-mascot.png",
|
||||
title = SKIN.appName,
|
||||
mascot = SKIN.mascot,
|
||||
status,
|
||||
compact = false,
|
||||
}: Props) {
|
||||
@@ -58,7 +59,7 @@ export function AppHeader({
|
||||
fontSize: compact ? 28 : 34,
|
||||
fontWeight: 900,
|
||||
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}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import { clock } from "../lib/format";
|
||||
import type { Category, Group, Results, SearchIndex, SongHit } from "../lib/search";
|
||||
import { categoryMatches, effectiveSearch, groupOf } from "../lib/search";
|
||||
import { SKIN } from "../lib/skins";
|
||||
import {
|
||||
ANIMATE_VIEW_TRANSITIONS,
|
||||
glassTint,
|
||||
@@ -260,7 +261,7 @@ function BrowseViewImpl({
|
||||
textAlign: "center",
|
||||
fontSize: 14,
|
||||
fontWeight: 700,
|
||||
color: "oklch(88% 0.02 210 / .6)",
|
||||
color: "oklch(88% 0.02 var(--hue) / .6)",
|
||||
}}
|
||||
>
|
||||
Noch nichts hier
|
||||
@@ -400,7 +401,7 @@ function BrowseViewImpl({
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
color: "oklch(88% 0.02 210 / .65)",
|
||||
color: "oklch(88% 0.02 var(--hue) / .65)",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
@@ -412,7 +413,7 @@ function BrowseViewImpl({
|
||||
<div
|
||||
style={{
|
||||
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)}
|
||||
@@ -540,7 +541,7 @@ function BrowseViewImpl({
|
||||
style={{
|
||||
fontSize: 16,
|
||||
fontWeight: 800,
|
||||
color: "oklch(22% 0.03 210)",
|
||||
color: "oklch(22% 0.03 var(--hue))",
|
||||
lineHeight: 1.2,
|
||||
}}
|
||||
>
|
||||
@@ -551,7 +552,7 @@ function BrowseViewImpl({
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
color: "oklch(40% 0.03 210)",
|
||||
color: "oklch(40% 0.03 var(--hue))",
|
||||
marginTop: 4,
|
||||
lineHeight: 1.3,
|
||||
}}
|
||||
@@ -566,7 +567,7 @@ function BrowseViewImpl({
|
||||
style={{
|
||||
fontSize: 16,
|
||||
fontWeight: 800,
|
||||
color: "oklch(22% 0.03 210)",
|
||||
color: "oklch(22% 0.03 var(--hue))",
|
||||
lineHeight: 1.2,
|
||||
height: `${titleLines * 1.2}em`,
|
||||
display: "-webkit-box",
|
||||
@@ -582,7 +583,7 @@ function BrowseViewImpl({
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
color: "oklch(30% 0.03 210)",
|
||||
color: "oklch(30% 0.03 var(--hue))",
|
||||
marginTop: 2,
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
@@ -596,7 +597,7 @@ function BrowseViewImpl({
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 800,
|
||||
color: "oklch(40% 0.17 340)",
|
||||
color: "oklch(40% 0.17 var(--accent-hue))",
|
||||
marginTop: 6,
|
||||
}}
|
||||
>
|
||||
@@ -623,11 +624,11 @@ function BrowseViewImpl({
|
||||
style={{
|
||||
textAlign: "center",
|
||||
marginTop: 60,
|
||||
color: "oklch(92% 0.02 210 / .8)",
|
||||
color: "oklch(92% 0.02 var(--hue) / .8)",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src="/dolphin-mascot.png"
|
||||
src={SKIN.mascot}
|
||||
alt=""
|
||||
style={{
|
||||
width: 120,
|
||||
@@ -813,7 +814,7 @@ export function CategoryTile({
|
||||
style={{
|
||||
fontSize: 16,
|
||||
fontWeight: 800,
|
||||
color: "oklch(22% 0.03 210)",
|
||||
color: "oklch(22% 0.03 var(--hue))",
|
||||
lineHeight: 1.2,
|
||||
}}
|
||||
>
|
||||
@@ -823,7 +824,7 @@ export function CategoryTile({
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 800,
|
||||
color: "oklch(40% 0.17 340)",
|
||||
color: "oklch(40% 0.17 var(--accent-hue))",
|
||||
marginTop: 5,
|
||||
}}
|
||||
>
|
||||
@@ -871,7 +872,7 @@ function Muted({ children }: { children: ReactNode }) {
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
color: "oklch(88% 0.02 210 / .7)",
|
||||
color: "oklch(88% 0.02 var(--hue) / .7)",
|
||||
}}
|
||||
>
|
||||
{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,
|
||||
lineHeight: 1.15,
|
||||
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",
|
||||
}}
|
||||
>
|
||||
|
||||
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,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 1,
|
||||
color: "oklch(50% 0.03 210 / .8)",
|
||||
color: "oklch(50% 0.03 var(--hue) / .8)",
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
@@ -92,7 +92,7 @@ export function HelpOverlay({ onClose }: { onClose: () => void }) {
|
||||
</span>
|
||||
))}
|
||||
</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}
|
||||
</div>
|
||||
</Fragment>
|
||||
|
||||
@@ -6,15 +6,10 @@ import { useMemo } from "react";
|
||||
|
||||
import type { HaDevice, HaEntityState } from "../api/types";
|
||||
import { oklchToRgb } from "../lib/oklch";
|
||||
import { SKIN } from "../lib/skins";
|
||||
|
||||
const SWATCHES: { name: string; oklch: string }[] = [
|
||||
{ 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)" },
|
||||
];
|
||||
/** The colour presets - named for the skin's world (`lightSwatches`). */
|
||||
const SWATCHES = SKIN.lightSwatches;
|
||||
|
||||
/** `["brightness"]`/`["onoff"]` lights get the brightness row only (a `brightness_pct`
|
||||
* sent to a plain on/off bulb is harmlessly ignored by Home Assistant). */
|
||||
|
||||
@@ -53,7 +53,7 @@ export function ParentPanel({ onClose }: { onClose: () => void }) {
|
||||
};
|
||||
|
||||
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 style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<div style={{ fontSize: 20, fontWeight: 900, color: "var(--ink)", flex: 1 }}>
|
||||
@@ -65,7 +65,7 @@ export function ParentPanel({ onClose }: { onClose: () => void }) {
|
||||
style={{
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
background: "oklch(88% 0.02 210)",
|
||||
background: "oklch(88% 0.02 var(--hue))",
|
||||
color: "var(--ink)",
|
||||
fontSize: 18,
|
||||
fontWeight: 900,
|
||||
@@ -79,13 +79,13 @@ export function ParentPanel({ onClose }: { onClose: () => void }) {
|
||||
</div>
|
||||
|
||||
{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 }}>
|
||||
{FIELDS.map(([key, label, hint, min, max, step]) => (
|
||||
<label key={key} style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
<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}
|
||||
</span>
|
||||
<input
|
||||
@@ -100,7 +100,7 @@ export function ParentPanel({ onClose }: { onClose: () => void }) {
|
||||
style={{
|
||||
padding: "8px 12px",
|
||||
borderRadius: 10,
|
||||
border: "2px solid oklch(85% 0.02 210)",
|
||||
border: "2px solid oklch(85% 0.02 var(--hue))",
|
||||
fontSize: 16,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
@@ -130,7 +130,7 @@ export function ParentPanel({ onClose }: { onClose: () => void }) {
|
||||
style={{
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
background: "oklch(88% 0.02 210)",
|
||||
background: "oklch(88% 0.02 var(--hue))",
|
||||
color: "var(--ink)",
|
||||
fontSize: 15,
|
||||
fontWeight: 800,
|
||||
@@ -146,7 +146,7 @@ export function ParentPanel({ onClose }: { onClose: () => void }) {
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: "oklch(50% 0.03 210)",
|
||||
color: "oklch(50% 0.03 var(--hue))",
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -12,19 +12,25 @@ import {
|
||||
savePerfSettings,
|
||||
type PerfSettings,
|
||||
} from "../lib/perfSettings";
|
||||
import { SKIN } from "../lib/skins";
|
||||
|
||||
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];
|
||||
|
||||
/** 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][] = [
|
||||
["ambience", "Animierter Hintergrund", "Canvas im Abspielbildschirm (größter CPU-Verbraucher)"],
|
||||
["cardBlur", "Weichzeichner auf Karten", "Backdrop-Blur je Album-Karte und Titelzeile"],
|
||||
["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"],
|
||||
["tileShadows", "Schatten auf Kacheln", "Schlagschatten unter den Kacheln im Raster"],
|
||||
["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"],
|
||||
["serviceWorker", "Service Worker", "Aus: vorhandener Worker wird entfernt"],
|
||||
["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],
|
||||
["ambienceMaxPx", "Hintergrund: max. Pixel", "Obergrenze je Achse", 256, 4096, 64],
|
||||
["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) =>
|
||||
@@ -63,7 +69,7 @@ export function PerfPanel({ onClose }: { onClose: () => void }) {
|
||||
};
|
||||
|
||||
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 style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<div style={{ fontSize: 20, fontWeight: 900, color: "var(--ink)", flex: 1 }}>
|
||||
@@ -73,7 +79,7 @@ export function PerfPanel({ onClose }: { onClose: () => void }) {
|
||||
onClick={onClose}
|
||||
aria-label="Schließen"
|
||||
style={{
|
||||
...pill("oklch(88% 0.02 210)", "var(--ink)"),
|
||||
...pill("oklch(88% 0.02 var(--hue))", "var(--ink)"),
|
||||
fontSize: 18,
|
||||
fontWeight: 900,
|
||||
width: 36,
|
||||
@@ -86,10 +92,10 @@ export function PerfPanel({ onClose }: { onClose: () => void }) {
|
||||
</div>
|
||||
|
||||
<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
|
||||
</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
|
||||
</button>
|
||||
</div>
|
||||
@@ -105,7 +111,7 @@ export function PerfPanel({ onClose }: { onClose: () => void }) {
|
||||
/>
|
||||
<span style={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
<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}
|
||||
</span>
|
||||
</span>
|
||||
@@ -115,7 +121,7 @@ export function PerfPanel({ onClose }: { onClose: () => void }) {
|
||||
{NUMBERS.map(([key, label, hint, min, max, step]) => (
|
||||
<label key={key} style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
<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}
|
||||
</span>
|
||||
<input
|
||||
@@ -131,7 +137,7 @@ export function PerfPanel({ onClose }: { onClose: () => void }) {
|
||||
style={{
|
||||
padding: "8px 12px",
|
||||
borderRadius: 10,
|
||||
border: "2px solid oklch(85% 0.02 210)",
|
||||
border: "2px solid oklch(85% 0.02 var(--hue))",
|
||||
fontSize: 16,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
@@ -143,12 +149,12 @@ export function PerfPanel({ onClose }: { onClose: () => void }) {
|
||||
<button onClick={save} style={pill("var(--accent)", "#fff")}>
|
||||
Speichern & neu laden
|
||||
</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
|
||||
</button>
|
||||
</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>.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { usePlaybackClock } from "../hooks/usePlaybackClock";
|
||||
import { isBook, nowPlayingText } from "../lib/covers";
|
||||
import { clock, remainingInAlbum } from "../lib/format";
|
||||
import { groupOf } from "../lib/search";
|
||||
import { SKIN } from "../lib/skins";
|
||||
import { SHOW_DECORATIVE_ANIMATIONS } from "../lib/theme";
|
||||
import { Cover } from "./Cover";
|
||||
import { ProgressBar } from "./ProgressBar";
|
||||
@@ -27,6 +28,12 @@ interface Props {
|
||||
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({
|
||||
state,
|
||||
album,
|
||||
@@ -66,7 +73,7 @@ export function PlayView({
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src="/dolphin-mascot.png"
|
||||
src={SKIN.mascot}
|
||||
alt=""
|
||||
style={{
|
||||
position: "absolute",
|
||||
@@ -78,11 +85,15 @@ export function PlayView({
|
||||
zIndex: 0,
|
||||
pointerEvents: "none",
|
||||
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 -
|
||||
// `animation: "none"` would restart it, `animationPlayState` just pauses.
|
||||
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",
|
||||
zIndex: 1,
|
||||
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 ? (
|
||||
@@ -123,7 +134,7 @@ export function PlayView({
|
||||
height: "100%",
|
||||
aspectRatio: 1,
|
||||
borderRadius: 28,
|
||||
background: "oklch(35% 0.03 210)",
|
||||
background: "oklch(35% 0.03 var(--hue))",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -145,7 +156,7 @@ export function PlayView({
|
||||
style={{
|
||||
fontSize: 20,
|
||||
fontWeight: 700,
|
||||
color: "oklch(88% 0.02 210 / .8)",
|
||||
color: "oklch(88% 0.02 var(--hue) / .8)",
|
||||
marginTop: 8,
|
||||
}}
|
||||
>
|
||||
@@ -183,7 +194,7 @@ export function PlayView({
|
||||
marginTop: 8,
|
||||
fontSize: 14,
|
||||
fontWeight: 800,
|
||||
color: "oklch(88% 0.02 210 / .8)",
|
||||
color: "oklch(88% 0.02 var(--hue) / .8)",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontFamily: "ui-monospace, Menlo, monospace" }}>
|
||||
@@ -232,13 +243,13 @@ export function PlayView({
|
||||
width: 52,
|
||||
height: 52,
|
||||
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)",
|
||||
fontSize: 22,
|
||||
display: "flex",
|
||||
alignItems: "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 { usePlaybackClock } from "../hooks/usePlaybackClock";
|
||||
import { nowPlayingText } from "../lib/covers";
|
||||
import { SKIN } from "../lib/skins";
|
||||
import { SHOW_DECORATIVE_ANIMATIONS, SHOW_GLASS_BLUR } from "../lib/theme";
|
||||
import { Cover } from "./Cover";
|
||||
import { ProgressBar } from "./ProgressBar";
|
||||
@@ -42,7 +43,7 @@ export function PlayerBar({
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
zIndex: 2,
|
||||
background: "oklch(18% 0.05 210 / .96)",
|
||||
background: "oklch(18% 0.05 var(--hue) / .96)",
|
||||
backdropFilter: SHOW_GLASS_BLUR ? "blur(6px)" : undefined,
|
||||
padding: "16px clamp(12px, 3vw, 32px)",
|
||||
// Clear of the iPhone home indicator when installed to the home screen.
|
||||
@@ -51,11 +52,11 @@ export function PlayerBar({
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
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
|
||||
src="/dolphin-mascot.png"
|
||||
src={SKIN.mascot}
|
||||
alt=""
|
||||
style={{
|
||||
width: "clamp(56px, 7vw, 84px)",
|
||||
@@ -65,9 +66,9 @@ export function PlayerBar({
|
||||
minWidth: 0,
|
||||
animation:
|
||||
SHOW_DECORATIVE_ANIMATIONS && state.playing
|
||||
? "dolphinBob 1.6s ease-in-out infinite"
|
||||
? "mascotBob 1.6s ease-in-out infinite"
|
||||
: "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",
|
||||
display: "flex",
|
||||
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" />}
|
||||
@@ -107,7 +108,7 @@ export function PlayerBar({
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: "oklch(85% 0.02 210 / .75)",
|
||||
color: "oklch(85% 0.02 var(--hue) / .75)",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
|
||||
@@ -68,7 +68,7 @@ export function ProgressBar({
|
||||
style={{
|
||||
height,
|
||||
borderRadius: 999,
|
||||
background: "oklch(30% 0.03 210 / .6)",
|
||||
background: "oklch(30% 0.03 var(--hue) / .6)",
|
||||
overflow: "hidden",
|
||||
cursor: interactive && duration ? "pointer" : "default",
|
||||
touchAction: "none",
|
||||
|
||||
@@ -35,7 +35,7 @@ export function RemoteAssignPopup({ album, mapping, albums, onAssign, onClear, o
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
color: "oklch(45% 0.03 210)",
|
||||
color: "oklch(45% 0.03 var(--hue))",
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
@@ -78,7 +78,7 @@ export function RemoteAssignPopup({ album, mapping, albums, onAssign, onClear, o
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
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",
|
||||
}}
|
||||
>
|
||||
@@ -90,7 +90,7 @@ export function RemoteAssignPopup({ album, mapping, albums, onAssign, onClear, o
|
||||
fontSize: 20,
|
||||
fontWeight: 900,
|
||||
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" ? "⚠️" : "+"}
|
||||
@@ -136,7 +136,7 @@ export function RemoteAssignPopup({ album, mapping, albums, onAssign, onClear, o
|
||||
marginTop: 16,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: "oklch(50% 0.03 210 / .8)",
|
||||
color: "oklch(50% 0.03 var(--hue) / .8)",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -7,15 +7,27 @@ import { useState } from "react";
|
||||
|
||||
import type { HaConfig } from "../api/types";
|
||||
import { useHomeAssistant } from "../hooks/useHomeAssistant";
|
||||
import { SKIN } from "../lib/skins";
|
||||
import { SHOW_DECORATIVE_ANIMATIONS, TAB_RAIL_CLEARANCE } from "../lib/theme";
|
||||
import { AppHeader } from "./AppHeader";
|
||||
import { Bubbles } from "./Bubbles";
|
||||
import { Drift } from "./Drift";
|
||||
import type { DriftField } from "./Drift";
|
||||
import { LightCard } from "./LightCard";
|
||||
import { SceneRow } from "./SceneRow";
|
||||
import { ShutterCard } from "./ShutterCard";
|
||||
|
||||
/** `phone`: no tab rail on the right to keep clear of, but the floating dock along the
|
||||
* 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 }) {
|
||||
const ha = useHomeAssistant(config);
|
||||
// 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 (
|
||||
<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
|
||||
style={{
|
||||
position: "relative",
|
||||
@@ -41,7 +53,7 @@ export function RoomView({ config, phone = false }: { config: HaConfig; phone?:
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<AppHeader title="Mein Zimmer" mascot="/dolphin-remote.png" compact={phone} />
|
||||
<AppHeader title="Mein Zimmer" mascot={SKIN.mascotRemote} compact={phone} />
|
||||
<div
|
||||
// Right padding reserves room for the tab rail's own glass bar (see
|
||||
// TAB_RAIL_CLEARANCE) so the device grid never renders under it.
|
||||
|
||||
@@ -35,11 +35,11 @@ export function TabRail({ page, onSelect, showRoom }: Props) {
|
||||
gap: 8,
|
||||
padding: 8,
|
||||
borderRadius: 999,
|
||||
background: "oklch(97% 0.01 210 / .22)",
|
||||
background: "oklch(97% 0.01 var(--hue) / .22)",
|
||||
backdropFilter: SHOW_GLASS_BLUR ? "blur(14px)" : undefined,
|
||||
WebkitBackdropFilter: SHOW_GLASS_BLUR ? "blur(14px)" : undefined,
|
||||
border: "1px solid oklch(97% 0.01 210 / .3)",
|
||||
boxShadow: "0 8px 24px oklch(15% 0.05 210 / .35)",
|
||||
border: "1px solid oklch(97% 0.01 var(--hue) / .3)",
|
||||
boxShadow: "0 8px 24px oklch(15% 0.05 var(--hue) / .35)",
|
||||
}}
|
||||
>
|
||||
{pages.map((candidate) => {
|
||||
|
||||
@@ -23,14 +23,16 @@ import { AppHeader } from "./tippen/AppHeader";
|
||||
import { HelpOverlay } from "./tippen/HelpOverlay";
|
||||
import { LessonMap } from "./tippen/LessonMap";
|
||||
import { ResultSheet } from "./tippen/ResultSheet";
|
||||
import { PetPicture } from "./tippen/PetPicture";
|
||||
import { RewardUnlockOverlay } from "./tippen/RewardUnlockOverlay";
|
||||
import { Stage } from "./tippen/Stage";
|
||||
import { BubblesRun } from "./tippen/modes/BubblesRun";
|
||||
import { DiveRun } from "./tippen/modes/DiveRun";
|
||||
import { FeedRun } from "./tippen/modes/FeedRun";
|
||||
import { RaceRun } from "./tippen/modes/RaceRun";
|
||||
import type { CreatureId } from "../lib/tippen/aquarium";
|
||||
import { creatureById } from "../lib/tippen/aquarium";
|
||||
import { SKIN } from "../lib/skins";
|
||||
import type { CreatureId } from "../lib/tippen/pets";
|
||||
import { creatureById } from "../lib/tippen/pets";
|
||||
import type { Lesson, ModeId } from "../lib/tippen/curriculum";
|
||||
import { lessonById, nextLesson } from "../lib/tippen/curriculum";
|
||||
import { letterStream, lineFor, lineText, mulberry32 } from "../lib/tippen/generator";
|
||||
@@ -308,7 +310,7 @@ export function TippenApp({ onExit }: Props) {
|
||||
return (
|
||||
<Stage creatures={progress.aquarium} dimmed={screen === "run"}>
|
||||
<AppHeader
|
||||
title={screen === "run" && lesson ? lesson.title : "Delfin Tippen"}
|
||||
title={screen === "run" && lesson ? lesson.title : SKIN.tippenName}
|
||||
compact={screen === "run"}
|
||||
status={
|
||||
<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 owned = progress.aquarium.includes(creature.id);
|
||||
return (
|
||||
<img
|
||||
<PetPicture
|
||||
key={creature.id}
|
||||
src={creature.image}
|
||||
alt=""
|
||||
creature={creature}
|
||||
size={22}
|
||||
title={owned ? creature.name : `Welt ${world.number}`}
|
||||
style={{
|
||||
width: 22,
|
||||
height: 22,
|
||||
objectFit: "contain",
|
||||
filter: owned ? "none" : "brightness(0) invert(1)",
|
||||
opacity: owned ? 1 : 0.3,
|
||||
}}
|
||||
|
||||
@@ -24,7 +24,7 @@ export function Transport({ playing, onToggle, onNext, onPrevious, size, gap }:
|
||||
className="round"
|
||||
onClick={onPrevious}
|
||||
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={{ width: bar, height: glyph, borderRadius: 1, background: "#fff" }} />
|
||||
@@ -47,7 +47,7 @@ export function Transport({ playing, onToggle, onNext, onPrevious, size, gap }:
|
||||
width: playSize,
|
||||
height: playSize,
|
||||
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 ? (
|
||||
@@ -75,7 +75,7 @@ export function Transport({ playing, onToggle, onNext, onPrevious, size, gap }:
|
||||
className="round"
|
||||
onClick={onNext}
|
||||
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
|
||||
|
||||
@@ -52,7 +52,7 @@ export function VolumeBars({
|
||||
padding: 0,
|
||||
cursor: "pointer",
|
||||
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 type { Group, Mode, SearchIndex, SongHit } 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 { CategoryTile } from "../BrowseView";
|
||||
import { Cover } from "../Cover";
|
||||
@@ -135,8 +136,8 @@ function MobileBrowseImpl({
|
||||
return (
|
||||
<div className="m-scroll" key={group}>
|
||||
<div className="m-title-row">
|
||||
<img src="/dolphin-mascot.png" alt="" className="m-title-mascot" />
|
||||
<div className="m-title">Musik Delfin</div>
|
||||
<img src={SKIN.mascot} alt="" className="m-title-mascot" />
|
||||
<div className="m-title">{SKIN.appName}</div>
|
||||
{statusLabel && (
|
||||
<div className="m-status" data-offline={offline}>
|
||||
{statusLabel}
|
||||
@@ -253,7 +254,7 @@ function Segment({
|
||||
function Hint({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="m-hint">
|
||||
<img src="/dolphin-mascot.png" alt="" />
|
||||
<img src={SKIN.mascot} alt="" />
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/** 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 { SKIN } from "../../lib/skins";
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
@@ -12,7 +13,7 @@ interface Props {
|
||||
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;
|
||||
return (
|
||||
<div
|
||||
@@ -27,14 +28,14 @@ export function AppHeader({ title = "Delfin Tippen", status, compact = false, on
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src="/dolphin-mascot.png"
|
||||
src={SKIN.mascot}
|
||||
alt=""
|
||||
onClick={onMascotClick}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
objectFit: "contain",
|
||||
animation: "dolphinBob 4s ease-in-out infinite",
|
||||
animation: "mascotBob 4s ease-in-out infinite",
|
||||
cursor: onMascotClick ? "pointer" : "default",
|
||||
}}
|
||||
/>
|
||||
@@ -43,7 +44,7 @@ export function AppHeader({ title = "Delfin Tippen", status, compact = false, on
|
||||
fontSize: compact ? 24 : 34,
|
||||
fontWeight: 900,
|
||||
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}
|
||||
|
||||
@@ -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 { coverUrl } from "../../api/client";
|
||||
import { SKIN } from "../../lib/skins";
|
||||
import type { Lesson, World } from "../../lib/tippen/curriculum";
|
||||
import { animalById } from "../../lib/tippen/grading";
|
||||
import { NODE_SPACING, pathD, pointFor } from "../../lib/tippen/lessonPath";
|
||||
import { MODE_INFO } from "../../lib/tippen/modeInfo";
|
||||
import type { Progress } from "../../lib/tippen/progress";
|
||||
import { TreasureChest } from "./TreasureChest";
|
||||
import { RewardBox } from "./RewardBox";
|
||||
|
||||
interface Props {
|
||||
worlds: readonly World[];
|
||||
@@ -96,7 +96,7 @@ export function LessonMap({ worlds, lessons, progress, selected, onPick, nextLes
|
||||
height={height}
|
||||
viewBox={`${-PATH_HALF_WIDTH} 0 ${PATH_HALF_WIDTH * 2} ${height}`}
|
||||
>
|
||||
<path d={pathD(points)} stroke="oklch(97% 0.01 175 / 0.35)" strokeWidth={8} strokeLinecap="round" fill="none" />
|
||||
<path d={pathD(points)} style={{ stroke: "oklch(97% 0.01 var(--tp-hue) / 0.35)" }} strokeWidth={8} strokeLinecap="round" fill="none" />
|
||||
</svg>
|
||||
|
||||
{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 index = lessons.indexOf(lesson);
|
||||
const point = points[i]!;
|
||||
const modeInfo = MODE_INFO[lesson.primaryMode];
|
||||
const modeInfo = SKIN.game.modes[lesson.primaryMode];
|
||||
|
||||
return (
|
||||
// The node and its chest are two siblings, both positioned against
|
||||
@@ -257,7 +257,7 @@ function RewardChest({
|
||||
transform: "translate(-50%, -50%)",
|
||||
pointerEvents: "none",
|
||||
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!"}
|
||||
>
|
||||
@@ -267,7 +267,7 @@ function RewardChest({
|
||||
TreasureChest's `layer`. */}
|
||||
{earned && albumId ? (
|
||||
<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
|
||||
src={coverUrl(albumId)}
|
||||
alt=""
|
||||
@@ -288,10 +288,10 @@ function RewardChest({
|
||||
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>
|
||||
) : (
|
||||
<TreasureChest size={CHEST_SIZE} open={earned} muted={!earned} />
|
||||
<RewardBox size={CHEST_SIZE} open={earned} muted={!earned} />
|
||||
)}
|
||||
</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
|
||||
* ../../web/src/components/Ambience.tsx. Swimmers live in a ref and each frame is written
|
||||
* straight to its image's `transform`, so sixty frames a second never touch React. React
|
||||
* renders this component only when a pet moves in or the layer is dimmed.
|
||||
*
|
||||
* 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
|
||||
* .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.
|
||||
@@ -15,8 +18,8 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { PERF } from "../../lib/perfSettings";
|
||||
import { creatureById, createSwimmer, pose, stepSwimmer } from "../../lib/tippen/aquarium";
|
||||
import type { CreatureId, Swimmer } from "../../lib/tippen/aquarium";
|
||||
import { BANDS, creatureById, createSwimmer, pose, stepSwimmer } from "../../lib/tippen/pets";
|
||||
import type { CreatureId, Swimmer } from "../../lib/tippen/pets";
|
||||
|
||||
interface Props {
|
||||
creatures: readonly CreatureId[];
|
||||
@@ -28,9 +31,9 @@ interface Props {
|
||||
* teleport every pet, so a frame never counts for more than this. */
|
||||
const MAX_STEP_S = 0.1;
|
||||
|
||||
export function AquariumCreatures({ creatures, opacity }: Props) {
|
||||
export function Pets({ creatures, opacity }: Props) {
|
||||
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 current = useRef(creatures);
|
||||
current.current = creatures;
|
||||
@@ -59,17 +62,20 @@ export function AquariumCreatures({ creatures, opacity }: Props) {
|
||||
const height = creature.size * size.height;
|
||||
const margin = height / 2;
|
||||
const speed = creature.speed * size.width;
|
||||
const band = BANDS[creature.motion];
|
||||
|
||||
const previous = swimmers.current.get(id);
|
||||
const s = previous
|
||||
? reducedMotion
|
||||
? previous
|
||||
: stepSwimmer(previous, dt, size, margin, speed, Math.random)
|
||||
: createSwimmer(size, margin, Math.random, !reducedMotion && !present.current.has(id));
|
||||
: stepSwimmer(previous, dt, size, margin, speed, Math.random, band)
|
||||
: createSwimmer(size, margin, Math.random, !reducedMotion && !present.current.has(id), band);
|
||||
swimmers.current.set(id, s);
|
||||
|
||||
const p = pose(s, creature, speed);
|
||||
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 =
|
||||
`translate(${p.x}px, ${p.y}px) translate(-50%, -50%) ` +
|
||||
`rotate(${p.rotation}deg) scaleX(${p.mirror})`;
|
||||
@@ -96,28 +102,30 @@ export function AquariumCreatures({ creatures, opacity }: Props) {
|
||||
transition: "opacity 700ms ease",
|
||||
}}
|
||||
>
|
||||
{creatures.map((id) => (
|
||||
<img
|
||||
key={id}
|
||||
ref={(image) => {
|
||||
if (image) images.current.set(id, image);
|
||||
else images.current.delete(id);
|
||||
}}
|
||||
src={creatureById(id).image}
|
||||
alt=""
|
||||
draggable={false}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
top: 0,
|
||||
height: 0,
|
||||
// Off-stage until the loop has placed it, so nothing flashes in the corner.
|
||||
transform: "translate(-200vw, 0)",
|
||||
willChange: "transform",
|
||||
filter: "drop-shadow(0 10px 16px oklch(15% 0.05 175 / 0.35))",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{creatures.map((id) => {
|
||||
const ref = (element: HTMLElement | null) => {
|
||||
if (element) images.current.set(id, element);
|
||||
else images.current.delete(id);
|
||||
};
|
||||
const style = {
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
top: 0,
|
||||
height: 0,
|
||||
// Off-stage until the loop has placed it, so nothing flashes in the corner.
|
||||
transform: "translate(-200vw, 0)",
|
||||
willChange: "transform",
|
||||
filter: "drop-shadow(0 10px 16px oklch(15% 0.05 var(--tp-hue) / 0.35))",
|
||||
} as const;
|
||||
const { art } = creatureById(id);
|
||||
return "image" in art ? (
|
||||
<img key={id} ref={ref} src={art.image} alt="" draggable={false} style={style} />
|
||||
) : (
|
||||
<span key={id} ref={ref} style={{ ...style, lineHeight: 1 }}>
|
||||
{art.emoji}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,20 +8,21 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { coverUrl } from "../../api/client";
|
||||
import { creatureById } from "../../lib/tippen/aquarium";
|
||||
import type { CreatureId } from "../../lib/tippen/aquarium";
|
||||
import { SKIN } from "../../lib/skins";
|
||||
import { creatureById } from "../../lib/tippen/pets";
|
||||
import type { CreatureId } from "../../lib/tippen/pets";
|
||||
import type { ModeId } from "../../lib/tippen/curriculum";
|
||||
import type { RunResult } from "../../lib/tippen/grading";
|
||||
import { STAR_THRESHOLDS, visibleAnimals, animalById, animalIndex, animalProgress } from "../../lib/tippen/grading";
|
||||
import type { AnimalId } from "../../lib/tippen/grading";
|
||||
import { MODE_INFO } from "../../lib/tippen/modeInfo";
|
||||
import type { UnlockedReward } from "../../lib/tippen/progress";
|
||||
import { PetPicture } from "./PetPicture";
|
||||
|
||||
interface Props {
|
||||
result: RunResult;
|
||||
/** Set when this run opened the next lesson. */
|
||||
unlockedTitle: string | null;
|
||||
/** Set when this run released a creature into the aquarium. */
|
||||
/** Set when this run released a pet - into the aquarium, the dino valley, … */
|
||||
newCreature: CreatureId | null;
|
||||
/** Set when this run's lesson named an `unlocks:` target that just became reachable -
|
||||
* the literal track/episode it names, not "one of several". */
|
||||
@@ -101,7 +102,7 @@ export function ResultSheet({
|
||||
style={{
|
||||
height: 9,
|
||||
borderRadius: 999,
|
||||
background: "oklch(88% 0.02 175)",
|
||||
background: "oklch(88% 0.02 var(--tp-hue))",
|
||||
margin: "18px 0 6px",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
@@ -140,12 +141,8 @@ export function ResultSheet({
|
||||
marginTop: 8,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={creatureById(newCreature).image}
|
||||
alt=""
|
||||
style={{ width: 52, height: 52, objectFit: "contain", animation: "tierEnter 520ms ease-out" }}
|
||||
/>
|
||||
{creatureById(newCreature).article} {creatureById(newCreature).name} ist ins Aquarium gezogen!
|
||||
<PetPicture creature={creatureById(newCreature)} size={52} style={{ animation: "tierEnter 520ms ease-out" }} />
|
||||
{creatureById(newCreature).article} {creatureById(newCreature).name} ist {SKIN.game.habitat} gezogen!
|
||||
</div>
|
||||
)}
|
||||
{/* The recap, not the reveal. RewardUnlockOverlay has already given this its own
|
||||
@@ -205,7 +202,7 @@ export function ResultSheet({
|
||||
Nochmal ⏎
|
||||
</button>
|
||||
{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}
|
||||
</button>
|
||||
)}
|
||||
@@ -228,11 +225,11 @@ export function ResultSheet({
|
||||
fontSize: 14,
|
||||
fontWeight: 800,
|
||||
cursor: "pointer",
|
||||
background: "oklch(90% 0.02 175)",
|
||||
background: "oklch(90% 0.02 var(--tp-hue))",
|
||||
color: "var(--ink)",
|
||||
}}
|
||||
>
|
||||
{MODE_INFO[mode].emoji} {MODE_INFO[mode].name}
|
||||
{SKIN.game.modes[mode].emoji} {SKIN.game.modes[mode].name}
|
||||
</button>
|
||||
))}
|
||||
</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,
|
||||
} from "../../lib/tippen/pop";
|
||||
import type { UnlockedReward } from "../../lib/tippen/progress";
|
||||
import { TreasureChest } from "./TreasureChest";
|
||||
import { RewardBox } from "./RewardBox";
|
||||
|
||||
type Phase = "drop" | "open" | "reveal";
|
||||
|
||||
@@ -69,7 +69,7 @@ const COPY: Record<
|
||||
|
||||
const CONFETTI_COLOURS = [
|
||||
"oklch(85% 0.17 88)",
|
||||
"oklch(78% 0.16 340)",
|
||||
"oklch(78% 0.16 var(--accent-hue))",
|
||||
"oklch(82% 0.15 150)",
|
||||
"oklch(80% 0.14 220)",
|
||||
"oklch(88% 0.13 60)",
|
||||
@@ -178,10 +178,10 @@ export function RewardUnlockOverlay({ reward, sound, onClose }: Props) {
|
||||
marginTop: COVER_SIZE * 0.78,
|
||||
animation: open
|
||||
? 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 />}
|
||||
|
||||
@@ -199,7 +199,7 @@ export function RewardUnlockOverlay({ reward, sound, onClose }: Props) {
|
||||
</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
|
||||
@@ -305,7 +305,7 @@ function RewardArt({
|
||||
justifyContent: "center",
|
||||
fontSize: 92,
|
||||
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}
|
||||
@@ -321,7 +321,7 @@ function RewardArt({
|
||||
style={{
|
||||
...shared,
|
||||
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
|
||||
* 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. */
|
||||
|
||||
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 { SHOW_AQUARIUM_CREATURES, SHOW_BUBBLES, SHOW_GLASS_BLUR } from "../../lib/tippen/theme";
|
||||
import { AquariumCreatures } from "./AquariumCreatures";
|
||||
import { Bubbles } from "./Bubbles";
|
||||
import { SHOW_DRIFT, SHOW_GLASS_BLUR, SHOW_PETS } from "../../lib/tippen/theme";
|
||||
import { Drift } from "../Drift";
|
||||
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 {
|
||||
children: ReactNode;
|
||||
@@ -29,8 +39,8 @@ export function Stage({ children, creatures, dimmed }: Props) {
|
||||
data-blur={SHOW_GLASS_BLUR ? "on" : "off"}
|
||||
data-anim={PERF.petAnimations ? "on" : "off"}
|
||||
>
|
||||
{SHOW_AQUARIUM_CREATURES && <AquariumCreatures creatures={creatures} opacity={dimmed ? 0.25 : 1} />}
|
||||
{SHOW_BUBBLES && <Bubbles />}
|
||||
{SHOW_PETS && <Pets creatures={creatures} opacity={dimmed ? 0.25 : 1} />}
|
||||
{SHOW_DRIFT && <Drift field={FIELD} bubbleColor={(alpha) => `oklch(97% 0.01 var(--tp-hue) / ${alpha})`} />}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* 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
|
||||
* 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
|
||||
* 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
|
||||
* 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
|
||||
* `<defs>` ids, or the second one silently paints with the first one's stops. */
|
||||
let nextId = 0;
|
||||
|
||||
interface Props {
|
||||
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) {
|
||||
export function TreasureChest({ size, open, layer = "all", muted = false, className, style }: RewardBoxProps) {
|
||||
const id = `tp-chest-${(nextId += 1)}`;
|
||||
|
||||
return (
|
||||
@@ -130,7 +120,7 @@ export function TreasureChest({ size, open, layer = "all", muted = false, classN
|
||||
* that works in a flat, front-on view. */
|
||||
function Lid({ id, open }: { id: string; open: boolean }) {
|
||||
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" />
|
||||
<rect
|
||||
x="7"
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { SKIN } from "../../../lib/skins";
|
||||
import { currentChar } from "../../../lib/tippen/engine";
|
||||
import type { RunEvent } from "../../../lib/tippen/engine";
|
||||
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. */
|
||||
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) {
|
||||
const text = letters.join("");
|
||||
const [popped, setPopped] = useState<number | null>(null);
|
||||
@@ -76,7 +89,7 @@ export function BubblesRun({ letters, activeKeys, progress, paused, onFinished }
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 2,
|
||||
background: "oklch(97% 0.01 175 / 0.25)",
|
||||
background: LOOK.surface,
|
||||
}}
|
||||
/>
|
||||
{[...text].map((letter, i) => {
|
||||
@@ -98,18 +111,18 @@ export function BubblesRun({ letters, activeKeys, progress, paused, onFinished }
|
||||
transform: "translate(-50%, 0)",
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: "50%",
|
||||
borderRadius: LOOK.radius,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: size * 0.42,
|
||||
fontWeight: 900,
|
||||
color: isCurrent ? "oklch(25% 0.05 175)" : "var(--paper)",
|
||||
color: isCurrent ? "oklch(25% 0.05 var(--tp-hue))" : "var(--paper)",
|
||||
background: isCurrent
|
||||
? "var(--paper)"
|
||||
: `linear-gradient(160deg, oklch(75% 0.12 ${finger?.hue ?? 175} / .45), oklch(50% 0.09 ${finger?.hue ?? 175} / .2))`,
|
||||
border: `2px solid oklch(90% 0.05 ${finger?.hue ?? 175} / ${isCurrent ? 0.9 : 0.35})`,
|
||||
boxShadow: isCurrent ? "0 10px 30px var(--shadow)" : "0 4px 14px var(--shadow)",
|
||||
: `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 ?? "var(--tp-hue)"} / ${isCurrent ? 0.9 : 0.35})`,
|
||||
boxShadow: (isCurrent ? "0 10px 30px var(--shadow)" : "0 4px 14px var(--shadow)") + LOOK.glow,
|
||||
opacity: 1 - distance * 0.13,
|
||||
transition: "top 380ms cubic-bezier(.2,.7,.3,1), width 240ms ease, height 240ms ease, font-size 240ms ease",
|
||||
animation:
|
||||
|
||||
@@ -73,7 +73,7 @@ function ProgressBar({ done, total }: { done: number; total: number }) {
|
||||
width: "min(520px, 80%)",
|
||||
height: 8,
|
||||
borderRadius: 999,
|
||||
background: "oklch(97% 0.01 175 / 0.16)",
|
||||
background: "oklch(97% 0.01 var(--tp-hue) / 0.16)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { SKIN } from "../../../lib/skins";
|
||||
import { currentChar } from "../../../lib/tippen/engine";
|
||||
import type { RunEvent } from "../../../lib/tippen/engine";
|
||||
import { chunkOffsets } from "../../../lib/tippen/generator";
|
||||
@@ -29,8 +30,6 @@ interface Props {
|
||||
onFinished: (result: RunResult) => void;
|
||||
}
|
||||
|
||||
const FISH = ["🐟", "🐠", "🐡", "🦐", "🦀"];
|
||||
|
||||
export function FeedRun({
|
||||
chunks,
|
||||
text,
|
||||
@@ -79,18 +78,18 @@ export function FeedRun({
|
||||
}}
|
||||
>
|
||||
<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
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 8,
|
||||
top: "42%",
|
||||
fontSize: 66,
|
||||
animation: "dolphinBob 3.2s ease-in-out infinite",
|
||||
animation: "mascotBob 3.2s ease-in-out infinite",
|
||||
zIndex: 2,
|
||||
}}
|
||||
>
|
||||
🐬
|
||||
{SKIN.game.feeder}
|
||||
</div>
|
||||
|
||||
{chunks.map((word, i) => {
|
||||
@@ -115,8 +114,8 @@ export function FeedRun({
|
||||
gap: 10,
|
||||
padding: isTarget ? "10px 18px" : "7px 13px",
|
||||
borderRadius: 999,
|
||||
background: isTarget ? "var(--paper)" : "oklch(97% 0.01 175 / .13)",
|
||||
border: `2px solid oklch(97% 0.01 175 / ${isTarget ? 0.85 : 0.2})`,
|
||||
background: isTarget ? "var(--paper)" : "oklch(97% 0.01 var(--tp-hue) / .13)",
|
||||
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)",
|
||||
maxWidth: "34%",
|
||||
opacity: 1 - distance * 0.22,
|
||||
@@ -125,13 +124,13 @@ export function FeedRun({
|
||||
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
|
||||
style={{
|
||||
display: "flex",
|
||||
fontSize: isTarget ? 34 : 22,
|
||||
fontWeight: 800,
|
||||
color: isTarget ? "oklch(25% 0.05 175)" : "var(--paper)",
|
||||
color: isTarget ? "oklch(25% 0.05 var(--tp-hue))" : "var(--paper)",
|
||||
}}
|
||||
>
|
||||
{isTarget
|
||||
@@ -147,7 +146,7 @@ export function FeedRun({
|
||||
? "oklch(58% 0.15 150)"
|
||||
: charState === "current"
|
||||
? "var(--accent)"
|
||||
: "oklch(45% 0.03 175)",
|
||||
: "oklch(45% 0.03 var(--tp-hue))",
|
||||
textDecoration: charState === "current" ? "underline" : undefined,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { SKIN } from "../../../lib/skins";
|
||||
import { currentChar } from "../../../lib/tippen/engine";
|
||||
import type { RunResult } from "../../../lib/tippen/grading";
|
||||
import type { Progress } from "../../../lib/tippen/progress";
|
||||
@@ -127,13 +128,13 @@ export function RaceRun({
|
||||
transition: "left 140ms ease-out",
|
||||
}}
|
||||
>
|
||||
🐬
|
||||
{SKIN.game.racer}
|
||||
</div>
|
||||
</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 }}>
|
||||
{ghost ? "👻" : "🦀"}
|
||||
{ghost ? "👻" : SKIN.game.rival.emoji}
|
||||
</div>
|
||||
</Lane>
|
||||
</div>
|
||||
@@ -163,8 +164,8 @@ function Lane({ label, color, children }: { label: string; color: string; childr
|
||||
flex: 1,
|
||||
height: 44,
|
||||
borderRadius: 999,
|
||||
background: "oklch(97% 0.01 175 / .1)",
|
||||
border: "1px solid oklch(97% 0.01 175 / .16)",
|
||||
background: "oklch(97% 0.01 var(--tp-hue) / .1)",
|
||||
border: "1px solid oklch(97% 0.01 var(--tp-hue) / .16)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
}}
|
||||
|
||||
Reference in New Issue
Block a user