Files
wizwar6e/packages/web/src/LiveFirstPerson.svelte
T
Eric WagonerandClaude Fable 5 825a73b154 The cockpit's spells fire: fx timers survive the state message
Two lightning blasts at an adjacent wizard drew nothing in first
person. The fx pipeline staged the bolt correctly — then died unfired:
the server sends `events` and `state` back to back, the state message
re-derives `me`, and the fx effect tracked it, so Svelte ran the
cleanup — which cleared every pending timer and stripped in-flight
sprites — before the bolt's zero-delay start could fire. Every live
cast effect in the pane died this way since the pane was born.

The effect now tracks only the batch; everything else reads under
untrack, and timers are cleared only when the component goes down.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-27 19:15:55 -04:00

596 lines
25 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
// The living maze through your own eyes: a pane that rides above the
// board during play. The board below stays the tactical truth — this
// is the experience. The camera carries the reel's instincts: it
// walks your strides, turns to your aims and to actors you can see,
// cuts away to deeds beyond your sight, and never idles nose-to-brick.
// Every live event batch plays through the same fx pipeline the
// instant replay uses, so spells, doors, and conjurations perform
// here first.
import FirstPerson from "./fpv/FirstPerson.svelte";
import type { FpvTarget } from "./fpv/raycast";
import { objectArt, tokenArt } from "./art";
import { fpFxForEvents, type FpFx } from "./fpv/fx3d";
import { castRay, SIDE_ANGLE, OPPOSITE } from "./fpv/raycast";
import { deepestFacing } from "./fpv/director";
import { aimOfEvents, gatherGlides, hurledIn, shortestArc } from "./fpv/director";
import { untrack } from "svelte";
import type { GameEvent, GameView, Side } from "@wizwar/engine";
let { view, batch, onhide, onstride = null, canStride = false, ontarget = null, onfacing = null, litCells = null, edgeSelect = false, onpickup = null, onCreatureMove = null, onCreatureAttack = null, onbody = null, onWarpStep = null }: {
view: GameView;
/** The latest live event batch, numbered so each plays once, with
* the view the server sent alongside it. */
batch: { n: number; events: GameEvent[]; view: GameView } | null;
onhide: () => void;
/** Issue a real move command: the pane is the cockpit on your turn. */
onstride?: ((side: Side) => void) | null;
canStride?: boolean;
/** Clicks in the pane resolve to board targets: the pane is not just
* the window but the wand hand. */
ontarget?: ((t: FpvTarget) => void) | null;
/** Reports the camera's compass quadrant whenever it settles on a new
* one — the keymap wears it as a wedge on your token. */
onfacing?: ((side: Side) => void) | null;
/** Cell-card eligibility, shared with the board's dimming aid. */
litCells?: Set<string> | null;
/** An edge-target card is raised: wall faces offer themselves. */
edgeSelect?: boolean;
/** The at-your-feet tray's grab: the pane cannot look down, so what
* lies in your own square shows as clickable icons instead. */
onpickup?: ((w: { kind: "treasure" | "object"; id: string }) => void) | null;
/** Possession: ride a creature's eyes and drive it from the pane. */
onCreatureMove?: ((creatureId: string, side: Side) => void) | null;
onCreatureAttack?: ((creatureId: string, targetId: string) => void) | null;
/** Reports which creature (if any) the pane is riding, so the keymap
* can move the facing ribbon to the mount's token. */
onbody?: ((creatureId: string | null) => void) | null;
/** A dimensional-warp step for whichever body the pane is: the
* wizard (null) or the ridden creature (its id). */
onWarpStep?: ((creatureId: string | null) => void) | null;
} = $props();
const povId = $derived(view.you);
const me = $derived(view.players.find((p) => p.id === povId && p.alive) ?? null);
/** What lies in YOUR square, grabbable: floor treasures and dropped
* objects. The raycaster has no downward pitch — the tray is the eye's
* substitute for looking at your own boots. */
const atFeet = $derived.by(() => {
if (!me || me.carriedTreasureId) return [];
const k = `${me.position.x},${me.position.y}`;
const items: { kind: "treasure" | "object"; id: string; art: string | null; label: string }[] = [];
for (const t of view.treasures) {
if (!t.position || t.carriedBy) continue;
if (`${t.position.x},${t.position.y}` !== k) continue;
if (t.owner === view.you && k === `${me.home.x},${me.home.y}`) continue; // safe at home
const ci = view.players.find((p) => p.id === t.owner)?.colorIndex ?? 0;
items.push({ kind: "treasure", id: t.id, art: tokenArt(`treasure-${ci % 6}`, "objects"), label: `${t.owner}'s treasure` });
}
for (const o of view.groundObjects[k] ?? []) {
const file = objectArt(o.cardId);
items.push({ kind: "object", id: o.instanceId, art: file ? tokenArt(file, "objects") : null, label: o.cardId.replace(/-/g, " ") });
}
return items;
});
/** Riding a creature: the pane looks through ITS eyes and the helm
* drives ITS legs, until you step back into your own skull. */
let possessedId = $state<string | null>(null);
const possessed = $derived(
possessedId ? view.creatures.find((c) => c.id === possessedId) ?? null : null);
/** Creatures you may ride right now: yours (and the democratic
* monster), with movement or an attack still unspent. */
const rideable = $derived(canStride
? view.creatures.filter((c) =>
(c.controllerId === view.you || c.kind === "democratic-monster") &&
!c.justCreated &&
(c.movementUsed < c.movesPerTurn || !c.attackUsed))
: []);
/** The rider's own stance, held while riding: dismounting must hand
* back the exact view you left — same feet, same facing, no cut. */
let preMount: { x: number; y: number; facing: number } | null = null;
function dismount() {
possessedId = null;
if (preMount) {
cam.x = preMount.x; cam.y = preMount.y; cam.facing = preMount.facing;
preMount = null;
}
}
// A dead or vanished mount — or the turn passing — dismounts you.
$effect(() => {
if (possessedId && (!possessed || !canStride)) dismount();
});
// Mounting seats the eyes in the creature's skull facing its longest
// corridor; the rider steers from there.
let seatedMount: string | null = null;
$effect(() => {
if (!possessedId || !possessed) { seatedMount = null; return; }
if (seatedMount === possessedId) return;
seatedMount = possessedId;
preMount = untrack(() => ({ x: cam.x, y: cam.y, facing: cam.facing }));
const mx = possessed.position.x + 0.5, my = possessed.position.y + 0.5;
cam.x = mx; cam.y = my; cam.facing = deepestFacing(view, mx, my);
});
/** What the ACTIVE body stands in or on: hazards it cannot see below
* itself, and the dimensional token it could step through. */
const underfoot = $derived.by(() => {
const body = possessed ?? me;
if (!body || !canStride) return { hazard: null as string | null, warpHere: false };
const k = `${body.position.x},${body.position.y}`;
const HAZARD_NAMES: Record<string, string> = {
tacks: "tacks underfoot", slime: "green slime", dust: "a dust cloud",
pit: "an open pit", ooze: "the killer ooze",
};
const kind = view.squareContents[k]?.kind;
return {
hazard: kind ? HAZARD_NAMES[kind] ?? null : null,
warpHere: view.dimWarps.some((w) =>
(w.a.x === body.position.x && w.a.y === body.position.y) ||
(w.b.x === body.position.x && w.b.y === body.position.y)),
};
});
/** Bodies sharing the active eyes' square: inside the near plane, so
* the renderer cannot show them — the tray stands in for the sprite,
* and clicking routes exactly as clicking the sprite would. */
const withYou = $derived.by(() => {
const body = possessed ?? me;
if (!body || !canStride) return [];
const bx = body.position.x, by = body.position.y;
const items: { kind: "player" | "creature"; id: string; art: string; label: string }[] = [];
for (const p of view.players) {
if (!p.alive || p.id === view.you) continue;
if (p.position.x !== bx || p.position.y !== by) continue;
items.push({ kind: "player", id: p.id, art: tokenArt(`wizard-${p.colorIndex}`, "players"), label: p.id });
}
for (const c of view.creatures) {
if (c.id === possessedId) continue;
if (c.position.x !== bx || c.position.y !== by) continue;
items.push({ kind: "creature", id: c.id, art: tokenArt(c.kind, "creatures"), label: c.kind.replace(/-/g, " ") });
}
return items;
});
const cam = $state({ x: 0, y: 0, facing: 0 });
let camReady = false;
/** While the player steers, the director keeps its hands off the
* idle facings (actor-watching, corridor rescue); aims and cutaways
* still fire — your own magic always turns your head. */
let manualUntil = 0;
let turning = false;
/** Manual steering holds the director's idle aims off this long. */
const STEER_HOLD_MS = 4000;
/** Mounting holds them off for the whole ride. */
const RIDE_HOLD_MS = 60000;
const SIDES4 = ["E", "S", "W", "N"] as const;
function manualTurn(dir: -1 | 1) {
if (turning) return;
turning = true;
manualUntil = performance.now() + STEER_HOLD_MS;
const from = cam.facing;
const to = from + dir * (Math.PI / 2);
const t0 = performance.now();
const tick = (now: number) => {
const w = Math.min(1, (now - t0) / 180);
cam.facing = from + (to - from) * (w * w * (3 - 2 * w));
if (w < 1) requestAnimationFrame(tick);
else turning = false;
};
requestAnimationFrame(tick);
}
/** Pane clicks: a possessed mount claims them first — adjacent floor
* steps it, a same-square enemy takes its blow — and only the rider's
* own eyes pass clicks through to the board's handlers. */
function handleTarget(t: FpvTarget) {
if (!possessed) { ontarget?.(t); return; }
const c = possessed;
if (t.kind === "player" && onCreatureAttack) {
const p = view.players.find((q) => q.id === t.id);
if (p && p.position.x === c.position.x && p.position.y === c.position.y) {
onCreatureAttack(c.id, t.id);
return;
}
}
if (t.kind === "cell" && onCreatureMove) {
const dx = t.cell.x - c.position.x, dy = t.cell.y - c.position.y;
if (Math.abs(dx) + Math.abs(dy) === 1) {
onCreatureMove(c.id, dx === 1 ? "E" : dx === -1 ? "W" : dy === 1 ? "S" : "N");
}
}
}
function manualStride(back: boolean) {
if (!canStride) return;
manualUntil = performance.now() + STEER_HOLD_MS;
const q = Math.round(cam.facing / (Math.PI / 2));
const side = SIDES4[((q % 4) + 4 + (back ? 2 : 0)) % 4]!;
if (possessed) onCreatureMove?.(possessed.id, side);
else onstride?.(side);
}
function onKey(e: KeyboardEvent) {
const t = e.target as HTMLElement | null;
if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return;
const k = e.key.toLowerCase();
if (k === "arrowleft" || k === "a") { e.preventDefault(); manualTurn(-1); }
else if (k === "arrowright" || k === "d") { e.preventDefault(); manualTurn(1); }
else if (k === "arrowup" || k === "w") { e.preventDefault(); manualStride(false); }
else if (k === "arrowdown" || k === "s") { e.preventDefault(); manualStride(true); }
}
let cutawayShot = $state(false);
let fpFx = $state<FpFx[]>([]);
let actorPos = $state<Record<string, { x: number; y: number }>>({});
// --- The fx: each batch plays once, on its own beat. -----------------
// The effect must track ONLY the batch: the server sends `events` then
// `state` back to back, so anything else in the dependency set (me, the
// view) re-runs this before a zero-delay timer can fire — and an eager
// cleanup would cancel every spell before it left the wand. Timers die
// only with the component.
let playedBatch = 0;
const fxTimers = new Set<ReturnType<typeof setTimeout>>();
$effect(() => {
const b = batch;
if (!b) return;
untrack(() => {
if (b.n === playedBatch || !me) return;
playedBatch = b.n;
for (const { fx, delay } of fpFxForEvents(b.events, b.view, povId)) {
const starter = setTimeout(() => {
fxTimers.delete(starter);
fpFx = [...fpFx, { ...fx, t0: performance.now() }];
const ender = setTimeout(() => {
fxTimers.delete(ender);
fpFx = fpFx.filter((f) => f.id !== fx.id);
}, fx.dur + 80);
fxTimers.add(ender);
}, delay);
fxTimers.add(starter);
}
});
return undefined;
});
$effect(() => () => fxTimers.forEach(clearTimeout));
// --- Other bodies glide between views. -------------------------------
let prevView: GameView | null = null;
$effect(() => {
const v = view;
const before = prevView;
prevView = v;
if (!before || before === v || !me) return;
const moves = gatherGlides(before, v, povId);
if (moves.length === 0) { actorPos = {}; return; }
const t0 = performance.now();
const dur = 400;
let raf = 0;
const tick = (now: number) => {
const w = Math.min(1, Math.max(0, (now - t0) / dur));
const ease = w * w * (3 - 2 * w);
const next: Record<string, { x: number; y: number }> = {};
for (const m of moves) {
next[m.id] = { x: m.fx + (m.tx - m.fx) * ease, y: m.fy + (m.ty - m.fy) * ease };
}
actorPos = next;
if (w < 1) raf = requestAnimationFrame(tick);
else actorPos = {};
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
});
// --- The camera: the director's ladder, live. ------------------------
let directedBatch = 0;
$effect(() => {
const b = batch;
const m = me;
if (!m) { camReady = false; return; }
// Possessed: the director stands aside. The camera sits in the
// mount's skull, keeps whatever facing the rider has chosen, and
// hard-follows the mount's steps; dismounting reseats the wizard.
const mount = possessed;
if (mount) {
if (b) directedBatch = b.n; // consume batches so dismounting doesn't replay them
const mx = mount.position.x + 0.5, my = mount.position.y + 0.5;
if (Math.hypot(untrack(() => cam.x) - mx, untrack(() => cam.y) - my) > 0.01) {
cam.x = mx; cam.y = my;
}
return; // dismount() restores the rider's own stance, uncut
}
const v = view;
const tx = m.position.x + 0.5;
const ty = m.position.y + 0.5;
// Untracked camera reads: this effect's own tween writes cam every
// frame, and a tracked read would restart the ease per frame.
const camX = untrack(() => cam.x);
const camY = untrack(() => cam.y);
const camF = untrack(() => cam.facing);
const dx = tx - camX;
const dy = ty - camY;
const dist = Math.hypot(dx, dy);
const events = b && b.n !== directedBatch ? b.events : [];
if (b) directedBatch = b.n;
const hurled = hurledIn(events, povId);
// Stepping through a warp keeps your HEADING, not your bearing to a
// point: you emerge walking out of the far mouth, so the exit facing
// is the far mouth's inward direction — travel preserved through a
// plain wrap, properly rotated where the boards were laid rotated.
// (A dimensional token has no mouths: facing simply carries over.)
let warpFacing: number | null = null;
for (const e of events) {
if (e.type === "moved" && e.player === povId && e.via === "warp") {
const w = v.board.warps.find((wp) =>
wp.from.cell.x === e.from.x && wp.from.cell.y === e.from.y &&
wp.to.cell.x === e.to.x && wp.to.cell.y === e.to.y);
if (w) warpFacing = SIDE_ANGLE[OPPOSITE[w.to.side]];
} else if (e.type === "warpStepped" && e.player === povId) {
warpFacing = camF;
}
}
const willCut = !camReady || (dist > 1.6 && !hurled);
cutawayShot = false;
const takeCutaway = (ax: number, ay: number) => {
const a = deepestFacing(v, ax, ay);
const d = castRay(v, ax, ay, a).dist;
const back = Math.min(1.6, Math.max(0.35, d - 0.4));
cam.x = ax + Math.cos(a) * back;
cam.y = ay + Math.sin(a) * back;
cam.facing = a + Math.PI;
camReady = true;
cutawayShot = true;
};
const actor = v.activePlayerId;
const aim = aimOfEvents(events, v, povId, m.position, actor);
let targetFacing = camF;
let aimed = false;
if (warpFacing !== null) {
targetFacing = warpFacing;
aimed = true;
} else if (camReady && !willCut && dist > 0.05 && !hurled) {
targetFacing = Math.atan2(dy, dx);
aimed = true;
} else if (aim?.self && (actor !== povId || hurled)) {
// A cutaway to your own body is replay theater: in the cockpit it
// plays only for things done TO you, never for your own hands —
// dropping a dagger must not fling your eyes out of your skull.
return takeCutaway(m.position.x + 0.5, m.position.y + 0.5);
} else if (aim && !aim.self && actor !== povId) {
// In the cockpit your own casts never move the camera: casting
// through the glass means you are ALREADY facing the target, and
// a keymap cast is a deliberate off-screen act. Only deeds done
// BY OTHERS earn an aim or a cutaway.
const ax = aim.x + 0.5, ay = aim.y + 0.5;
const wantFacing = Math.atan2(ay - ty, ax - tx);
const toAim = Math.hypot(ax - tx, ay - ty);
const sight = castRay(v, tx, ty, wantFacing);
if (sight.warped || sight.dist < toAim - 0.4) return takeCutaway(ax, ay);
targetFacing = wantFacing;
aimed = true;
} else if (performance.now() >= manualUntil) {
// Another wizard acting in your sight line turns your head.
const other = actor !== povId ? v.players.find((p) => p.id === actor && p.alive) : null;
if (other && (other.position.x !== m.position.x || other.position.y !== m.position.y)) {
const ax = other.position.x + 0.5, ay = other.position.y + 0.5;
const toActor = Math.hypot(ax - tx, ay - ty);
const ray = castRay(v, tx, ty, Math.atan2(ay - ty, ax - tx));
if (!ray.warped && ray.dist > toActor - 0.2) {
targetFacing = Math.atan2(ay - ty, ax - tx);
aimed = true;
}
}
}
if (!aimed && willCut && performance.now() >= manualUntil) {
// A fresh CUT with nothing asking to be watched faces the deepest
// corridor — but a STANDING camera is never "rescued": where you
// pointed your own head is where it stays, brick or no brick.
targetFacing = deepestFacing(v, tx, ty);
}
if (willCut) {
cam.x = tx; cam.y = ty; cam.facing = targetFacing;
camReady = true;
return;
}
const fromX = camX, fromY = camY, fromF = camF;
const arc = shortestArc(fromF, targetFacing);
const turnMs = hurled ? 0 : Math.min(260, Math.abs(arc) * 180);
const walkMs = dist > 0.05 ? (hurled ? 260 : 380) : 0;
const t0 = performance.now();
let raf = 0;
const tick = (now: number) => {
const t = Math.max(0, now - t0);
if (turnMs > 0 && t < turnMs) {
cam.facing = fromF + arc * (t / turnMs);
} else if (walkMs > 0 && t < turnMs + walkMs) {
cam.facing = fromF + arc;
const w = (t - turnMs) / walkMs;
const ease = w * w * (3 - 2 * w);
cam.x = fromX + (tx - fromX) * ease;
cam.y = fromY + (ty - fromY) * ease;
} else {
cam.facing = fromF + arc;
cam.x = tx; cam.y = ty;
return;
}
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
});
$effect(() => { onbody?.(possessedId); });
let lastQuad = -99;
$effect(() => {
const q = ((Math.round(cam.facing / (Math.PI / 2)) % 4) + 4) % 4;
if (q !== lastQuad) {
lastQuad = q;
onfacing?.(SIDES4[q]!);
}
});
</script>
<svelte:window onkeydown={onKey} />
{#if me}
<div class="live-fp">
<!-- The bezel: instruments live in the frame, never on the glass —
every canvas pixel is target. -->
<div class="bezel-top">
<span class="bezel-zone">
{#if possessed}
<span class="ride-label">riding the {possessed.kind.replace(/-/g, " ")}</span>
<span class="ride-stats">{Math.max(0, possessed.movesPerTurn - possessed.movementUsed)} moves{possessed.attackUsed ? "" : " · claws ready"}</span>
<button class="stamp tiny" onclick={dismount}>back to your eyes</button>
{:else if rideable.length > 0 && (onCreatureMove || onCreatureAttack)}
{#each rideable as c (c.id)}
<button class="stamp tiny" onclick={() => { possessedId = c.id; manualUntil = performance.now() + RIDE_HOLD_MS; }}>
👁 ride the {c.kind.replace(/-/g, " ")}
</button>
{/each}
{/if}
</span>
<span class="bezel-zone bezel-center">
{#if canStride}
<button class="drive stride" onclick={() => manualStride(false)} aria-label="step forward">︿ forward</button>
{/if}
</span>
<span class="bezel-zone bezel-right">
{#if withYou.length > 0}
<span class="feet-label">sharing your square</span>
{#each withYou as body (body.id)}
<button class="feet-item" title={body.label}
onclick={() => handleTarget(body.kind === "player" ? { kind: "player", id: body.id } : { kind: "creature", id: body.id })}>
<img src={body.art} alt={body.label} />
</button>
{/each}
{/if}
<button class="live-fp-hide" onclick={onhide} title="hide (re-enable in preferences)"></button>
</span>
</div>
<div class="bezel-mid">
<button class="drive" onclick={() => manualTurn(-1)} aria-label="turn left"></button>
<FirstPerson {view} povId={cutawayShot ? "" : (possessedId ?? povId)}
x={cam.x} y={cam.y} facing={cam.facing} width={960} height={400}
fx={fpFx} posOverride={actorPos}
ontarget={handleTarget} {litCells} {edgeSelect} />
<button class="drive" onclick={() => manualTurn(1)} aria-label="turn right"></button>
</div>
<div class="bezel-bottom">
<span class="bezel-zone">
{#if canStride && ((!possessed && atFeet.length > 0) || underfoot.hazard || underfoot.warpHere)}
<span class="feet-label">at your feet</span>
{#if !possessed && onpickup}
{#each atFeet as item (item.id)}
<button class="feet-item" title={item.label}
onclick={() => onpickup?.({ kind: item.kind, id: item.id })}>
{#if item.art}
<img src={item.art} alt={item.label} />
{:else}
<span class="feet-fallback">{item.label}</span>
{/if}
</button>
{/each}
{/if}
{#if underfoot.hazard}
<span class="feet-hazard">{underfoot.hazard}</span>
{/if}
{#if underfoot.warpHere && onWarpStep}
<button class="stamp tiny" onclick={() => onWarpStep?.(possessedId)}> step through the warp</button>
{/if}
{/if}
</span>
<span class="bezel-zone bezel-center">
{#if canStride}
<button class="drive stride" onclick={() => manualStride(true)} aria-label="step back">﹀ back</button>
{/if}
</span>
<span class="bezel-zone bezel-right">
<span class="live-fp-hint">← → turn · ↑ ↓ walk</span>
</span>
</div>
</div>
{/if}
<style>
.live-fp {
margin-bottom: 0.6rem;
border: 1px solid #3a3428;
border-radius: 6px;
background: #14121a;
overflow: hidden;
}
.bezel-top, .bezel-bottom {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 8px;
min-height: 34px;
}
.bezel-top { border-bottom: 1px solid #2a2620; }
.bezel-bottom { border-top: 1px solid #2a2620; }
.bezel-spacer { flex: 1; }
.bezel-zone { flex: 1; display: flex; align-items: center; gap: 8px; }
.bezel-center { justify-content: center; }
.bezel-right { justify-content: flex-end; }
.bezel-mid {
display: flex;
align-items: stretch;
}
.bezel-mid :global(.fpv-canvas) {
border: none;
border-radius: 0;
flex: 1;
min-width: 0;
}
.drive {
background: none;
border: none;
color: #8d8672;
font-size: 1.3rem;
cursor: pointer;
padding: 0 10px;
}
.drive:hover { color: #e9e1cb; background: rgba(201, 167, 42, 0.08); }
.stride { font-size: 0.8rem; letter-spacing: 0.06em; }
.live-fp-hide {
background: none;
border: none;
color: #8d8672;
cursor: pointer;
font-size: 0.9rem;
}
.live-fp-hide:hover { color: #e9e1cb; }
.live-fp-hint {
font-family: "Courier Prime", monospace;
font-size: 0.65rem;
color: #5d5748;
}
.ride-label {
font-family: "Oswald", sans-serif;
font-size: 0.62rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: #c9a72a;
}
.ride-stats { font-size: 0.7rem; color: #8d8672; }
.feet-label {
font-family: "Oswald", sans-serif;
font-size: 0.6rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: #8d8672;
}
.feet-item {
background: none;
border: 1px solid #4a4536;
border-radius: 4px;
padding: 2px;
cursor: pointer;
line-height: 0;
}
.feet-item:hover { border-color: #c9a72a; }
.feet-item img { width: 26px; height: 26px; object-fit: contain; }
.feet-fallback { font-size: 0.65rem; color: #d8d2c0; padding: 0 4px; }
.feet-hazard { font-size: 0.7rem; color: #d98a4a; white-space: nowrap; }
</style>