feat(audio): add url param state hooks

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-04-28 19:56:11 -04:00
parent 843081e152
commit 8a3f5a9b42

View file

@ -1,4 +1,5 @@
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { Link } from "react-router-dom";
import styled from "styled-components";
import audioManifest from "@game-data/audio.json";
@ -158,6 +159,41 @@ const EVENT_KINDS = [
] as const;
type EventKind = (typeof EVENT_KINDS)[number];
// ─── URL state — two-way binding state ↔ ?param= ────────────────────────
//
// Designers share a deep link with the audition + filter state baked in.
// Supported params on /audio:
// ?entity=<id> — preselect resolution-playground entity (DEMO_ENTITIES.id)
// ?kind=<kind> — preselect event_kind (attack|hit|death|spawn|...)
// ?filter=<text> — manifest browser substring filter
// ?real=1 — manifest browser "real .ogg only" toggle
// ?open=<key> — pre-expand a manifest entry
// ?play=<key> — auto-play that key on mount (real .ogg if shipped, else synth)
function useUrlString(name: string, fallback: string): [string, (v: string) => void] {
const [params, setParams] = useSearchParams();
const value: string = params.get(name) ?? fallback;
const set = (v: string): void => {
const next: URLSearchParams = new URLSearchParams(params);
if (!v || v === fallback) next.delete(name);
else next.set(name, v);
setParams(next, { replace: true });
};
return [value, set];
}
function useUrlBool(name: string): [boolean, (v: boolean) => void] {
const [params, setParams] = useSearchParams();
const value: boolean = params.get(name) === "1";
const set = (v: boolean): void => {
const next: URLSearchParams = new URLSearchParams(params);
if (v) next.set(name, "1");
else next.delete(name);
setParams(next, { replace: true });
};
return [value, set];
}
// ─── Resolver — TS port of audio_manager.gd::_resolve_keys ───────────────
function resolveKeys(entity: DemoEntity, eventKind: string): string[] {
@ -454,8 +490,18 @@ function ResolutionPlayground(): React.ReactElement {
// so the green ● real-asset button is visible alongside the gold ▶
// synth button on first render. The first 11 launch-pack files cover
// the city / research / turn / border keys.
const [entityIdx, setEntityIdx] = useState(0);
const [eventKind, setEventKind] = useState<EventKind>("attack");
const DEFAULT_ENTITY: string = DEMO_ENTITIES[0].id;
const DEFAULT_KIND: EventKind = "attack";
const [entityIdParam, setEntityIdParam] = useUrlString("entity", DEFAULT_ENTITY);
const [eventKindParam, setEventKindParam] = useUrlString("kind", DEFAULT_KIND);
const entityIdx: number = Math.max(
0,
DEMO_ENTITIES.findIndex((d) => d.id === entityIdParam),
);
const eventKind: EventKind = (EVENT_KINDS as readonly string[]).includes(eventKindParam)
? (eventKindParam as EventKind)
: DEFAULT_KIND;
const entity = DEMO_ENTITIES[entityIdx];
const candidates = resolveKeys(entity, eventKind);
@ -475,11 +521,11 @@ function ResolutionPlayground(): React.ReactElement {
<Label>
Entity
<Select
value={entityIdx}
onChange={(e) => setEntityIdx(Number(e.target.value))}
value={entity.id}
onChange={(e) => setEntityIdParam(e.target.value)}
>
{DEMO_ENTITIES.map((d, i) => (
<option key={d.id} value={i}>
{DEMO_ENTITIES.map((d) => (
<option key={d.id} value={d.id}>
{d.id} {d.kind}.{d.sub}
</option>
))}
@ -489,7 +535,7 @@ function ResolutionPlayground(): React.ReactElement {
Event kind
<Select
value={eventKind}
onChange={(e) => setEventKind(e.target.value as EventKind)}
onChange={(e) => setEventKindParam(e.target.value)}
>
{EVENT_KINDS.map((k) => (
<option key={k} value={k}>{k}</option>
@ -549,9 +595,10 @@ function ResolutionPlayground(): React.ReactElement {
// ─── Section 2: Manifest browser ─────────────────────────────────────────
function ManifestBrowser(): React.ReactElement {
const [filter, setFilter] = useState("");
const [openKey, setOpenKey] = useState<string | null>(null);
const [realOnly, setRealOnly] = useState(false);
const [filter, setFilter] = useUrlString("filter", "");
const [openKeyRaw, setOpenKeyParam] = useUrlString("open", "");
const openKey: string | null = openKeyRaw === "" ? null : openKeyRaw;
const [realOnly, setRealOnly] = useUrlBool("real");
const realCount = useMemo(
() => Object.entries(manifest.sfx)
@ -613,7 +660,7 @@ function ManifestBrowser(): React.ReactElement {
<EntryRow
key={key}
$open={open}
onClick={() => setOpenKey(open ? null : key)}
onClick={() => setOpenKeyParam(open ? "" : key)}
>
<EntryHeader>
<PlayBtnInline
@ -876,7 +923,38 @@ function MusicTrackList(): React.ReactElement {
// ─── Page ────────────────────────────────────────────────────────────────
function useAutoPlayParam(): void {
// `?play=<key>` auditions the key once on mount. Plays the real .ogg if
// the key has one shipped, else the synthesised preview. Strips the param
// after firing so a refresh doesn't replay; navigating away preserves it
// in the user's history. Browser autoplay policies require a prior user
// gesture — first navigation may need a click before audio unlocks.
const [params, setParams] = useSearchParams();
const playKey: string = params.get("play") ?? "";
useEffect((): void => {
if (!playKey) return;
const entry: SfxEntry | undefined = manifest.sfx[playKey];
if (entry) {
const realPath: string | null = realOggAvailable(entry);
if (realPath) {
void playReal(realPath);
} else {
playSynth(playKey);
}
} else {
const track: MusicTrack | undefined = manifest.music.tracks.find(
(m: MusicTrack): boolean => m.id === playKey,
);
if (track && REAL_BY_REL[track.stream]) void playReal(track.stream);
}
const next: URLSearchParams = new URLSearchParams(params);
next.delete("play");
setParams(next, { replace: true });
}, [playKey, params, setParams]);
}
export function AudioSystemPage(): React.ReactElement {
useAutoPlayParam();
return (
<Page>
<Back to="/"> back</Back>
@ -907,6 +985,16 @@ export function AudioSystemPage(): React.ReactElement {
<Mono>.project/designs/audio-system.md</Mono>. Sourcing checklist:{" "}
<Mono>.project/audio-sourcing-checklist.md</Mono>.
</SectionBlurb>
<SectionBlurb style={{ marginTop: 12 }}>
<strong>Shareable URL params</strong> every interactive piece of
state binds to <Mono>?</Mono> on this page so designers can deep-link
a test:
<br />
<Mono>?entity=&lt;id&gt;</Mono> · <Mono>?kind=&lt;attack|hit|death|spawn|complete|select|promote&gt;</Mono>{" "}
· <Mono>?filter=&lt;text&gt;</Mono> · <Mono>?real=1</Mono> ·{" "}
<Mono>?open=&lt;sfx_key&gt;</Mono> · <Mono>?play=&lt;sfx_key|track_id&gt;</Mono>{" "}
(auditions once on load, then strips itself).
</SectionBlurb>
</Section>
</Page>
);