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
596 lines
25 KiB
Svelte
596 lines
25 KiB
Svelte
<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>
|