Picking up an object ends the turn's actions AND movement — yet the end-turn caution still tallied the unspendable points as regret. The unspent-movement reason now stays quiet once actions have ended: those points cannot be spent, only mourned. And the raycaster has no downward pitch, so whatever lay in your own square — a treasure, a dropped blade — was invisible from inside the cockpit. An "at your feet" tray now rides the pane's bottom edge on your turn: your square's floor treasures and objects as clickable icons, each a one-tap pickup through the same commands the board's buttons send. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
395 lines
15 KiB
Svelte
395 lines
15 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 { tokenArt } from "./art";
|
||
import { objectArt } from "./art";
|
||
import { fpFxForEvents, type FpFx } from "./fpv/fx3d";
|
||
import { castRay } from "./fpv/raycast";
|
||
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, onpickup = 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;
|
||
/** 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;
|
||
} = $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;
|
||
});
|
||
|
||
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;
|
||
|
||
const SIDES4 = ["E", "S", "W", "N"] as const;
|
||
function manualTurn(dir: -1 | 1) {
|
||
if (turning) return;
|
||
turning = true;
|
||
manualUntil = performance.now() + 4000;
|
||
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);
|
||
}
|
||
function manualStride(back: boolean) {
|
||
if (!canStride || !onstride) return;
|
||
manualUntil = performance.now() + 4000;
|
||
const q = Math.round(cam.facing / (Math.PI / 2));
|
||
const side = SIDES4[((q % 4) + 4 + (back ? 2 : 0)) % 4]!;
|
||
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. -----------------
|
||
let playedBatch = 0;
|
||
$effect(() => {
|
||
const b = batch;
|
||
if (!b || b.n === playedBatch || !me) return;
|
||
playedBatch = b.n;
|
||
const timers: ReturnType<typeof setTimeout>[] = [];
|
||
const started: number[] = [];
|
||
for (const { fx, delay } of fpFxForEvents(b.events, b.view, povId)) {
|
||
timers.push(setTimeout(() => {
|
||
started.push(fx.id);
|
||
fpFx = [...fpFx, { ...fx, t0: performance.now() }];
|
||
timers.push(setTimeout(() => (fpFx = fpFx.filter((f) => f.id !== fx.id)), fx.dur + 80));
|
||
}, delay));
|
||
}
|
||
return () => {
|
||
timers.forEach(clearTimeout);
|
||
started.forEach((id) => (fpFx = fpFx.filter((f) => f.id !== id)));
|
||
};
|
||
});
|
||
|
||
// --- 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; }
|
||
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);
|
||
const willCut = !camReady || (dist > 1.6 && !hurled);
|
||
cutawayShot = false;
|
||
const takeCutaway = (ax: number, ay: number) => {
|
||
let best = { d: -1, a: 0 };
|
||
for (const a of [0, Math.PI / 2, Math.PI, -Math.PI / 2]) {
|
||
const h = castRay(v, ax, ay, a);
|
||
if (h.dist > best.d) best = { d: h.dist, a };
|
||
}
|
||
const back = Math.min(1.6, Math.max(0.35, best.d - 0.4));
|
||
cam.x = ax + Math.cos(best.a) * back;
|
||
cam.y = ay + Math.sin(best.a) * back;
|
||
cam.facing = best.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 (camReady && !willCut && dist > 0.05 && !hurled) {
|
||
targetFacing = Math.atan2(dy, dx);
|
||
aimed = true;
|
||
} else if (aim?.self) {
|
||
return takeCutaway(m.position.x + 0.5, m.position.y + 0.5);
|
||
} else if (aim) {
|
||
const ax = aim.x + 0.5, ay = aim.y + 0.5;
|
||
const toAim = Math.hypot(ax - tx, ay - ty);
|
||
const sight = castRay(v, tx, ty, Math.atan2(ay - ty, ax - tx));
|
||
if (sight.warped || sight.dist < toAim - 0.4) return takeCutaway(ax, ay);
|
||
targetFacing = Math.atan2(ay - ty, ax - tx);
|
||
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 && performance.now() >= manualUntil &&
|
||
(willCut || castRay(v, tx, ty, targetFacing).dist < 0.8)) {
|
||
// Nothing asks to be watched and the eyes would idle nose-to-brick:
|
||
// face the deepest corridor from where the wizard stands.
|
||
let deepest = -1;
|
||
for (const a of [0, Math.PI / 2, Math.PI, -Math.PI / 2]) {
|
||
const h = castRay(v, tx, ty, a);
|
||
if (h.dist > deepest) { deepest = h.dist; targetFacing = a; }
|
||
}
|
||
}
|
||
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);
|
||
});
|
||
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">
|
||
<FirstPerson {view} povId={cutawayShot ? "" : povId}
|
||
x={cam.x} y={cam.y} facing={cam.facing} width={960} height={400}
|
||
fx={fpFx} posOverride={actorPos}
|
||
ontarget={ontarget ?? undefined} {litCells} />
|
||
<button class="live-fp-hide" onclick={onhide} title="hide (re-enable in preferences)">✕</button>
|
||
<!-- The helm, tappable: edge strips turn and stride. -->
|
||
<button class="drive drive-left" onclick={() => manualTurn(-1)} aria-label="turn left">‹</button>
|
||
<button class="drive drive-right" onclick={() => manualTurn(1)} aria-label="turn right">›</button>
|
||
{#if canStride}
|
||
<button class="drive drive-fwd" onclick={() => manualStride(false)} aria-label="step forward">︿</button>
|
||
<button class="drive drive-back" onclick={() => manualStride(true)} aria-label="step back">﹀</button>
|
||
<div class="live-fp-hint">← → turn · ↑ ↓ walk</div>
|
||
{/if}
|
||
{#if onpickup && canStride && atFeet.length > 0}
|
||
<div class="feet-tray">
|
||
<span class="feet-label">at your feet</span>
|
||
{#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}
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
|
||
<style>
|
||
.live-fp {
|
||
position: relative;
|
||
margin-bottom: 0.6rem;
|
||
}
|
||
.live-fp-hide {
|
||
position: absolute;
|
||
top: 0.4rem;
|
||
right: 0.4rem;
|
||
background: rgba(13, 12, 18, 0.65);
|
||
border: 1px solid #5a5342;
|
||
border-radius: 3px;
|
||
color: #a49c86;
|
||
cursor: pointer;
|
||
font-size: 0.75rem;
|
||
padding: 0.1rem 0.4rem;
|
||
}
|
||
.live-fp-hide:hover { color: #e9e1cb; }
|
||
.live-fp-hint {
|
||
position: absolute;
|
||
bottom: 0.45rem;
|
||
left: 0.6rem;
|
||
color: rgba(233, 225, 203, 0.55);
|
||
font-family: "Courier Prime", monospace;
|
||
font-size: 0.7rem;
|
||
pointer-events: none;
|
||
}
|
||
/* The helm: faint chevron strips along the pane's edges — always
|
||
* there for a thumb, louder under a pointer. */
|
||
.drive {
|
||
position: absolute;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: none;
|
||
border: none;
|
||
cursor: pointer;
|
||
color: rgba(233, 225, 203, 0.35);
|
||
font-size: 2.2rem;
|
||
line-height: 1;
|
||
padding: 0;
|
||
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.8);
|
||
}
|
||
.drive:hover {
|
||
color: rgba(233, 225, 203, 0.95);
|
||
background: linear-gradient(to right, rgba(0, 0, 0, 0.22), transparent);
|
||
}
|
||
.drive-left { left: 0; top: 15%; bottom: 15%; width: 13%; }
|
||
.drive-right { right: 0; top: 15%; bottom: 15%; width: 13%; }
|
||
.drive-right:hover { background: linear-gradient(to left, rgba(0, 0, 0, 0.22), transparent); }
|
||
.feet-tray {
|
||
position: absolute;
|
||
bottom: 8px;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
background: rgba(13, 12, 18, 0.78);
|
||
border: 1px solid #3a3428;
|
||
border-radius: 6px;
|
||
padding: 4px 10px;
|
||
z-index: 3;
|
||
}
|
||
.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: 30px; height: 30px; object-fit: contain; }
|
||
.feet-fallback { font-size: 0.65rem; color: #d8d2c0; padding: 0 4px; }
|
||
.drive-fwd { top: 0; left: 20%; right: 20%; height: 16%; }
|
||
.drive-fwd:hover { background: linear-gradient(to bottom, rgba(0, 0, 0, 0.22), transparent); }
|
||
.drive-back { bottom: 0; left: 20%; right: 20%; height: 15%; }
|
||
.drive-back:hover { background: linear-gradient(to top, rgba(0, 0, 0, 0.22), transparent); }
|
||
</style>
|