Milestone one: the pane becomes the cockpit

FPV play begins. On your turn the pane takes the helm: arrows or WASD
— quarter-turns are free camera, a stride issues a REAL move command
through the same cautions and refusals as a board click, and a wall
bounces you with the table's own toast. While the player steers, the
director keeps its hands off the idle facings; your own magic still
turns your head, and cutaways still fire. The layout inverts to match
the mode: the 3D view is the primary window into the game, the
top-down board reduced to a keymap beneath it, with the control hint
in the pane's corner. Typing in chat is never hijacked.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-24 00:02:16 -04:00
co-authored by Claude Fable 5
parent c7109fcddf
commit b8bb60cf6d
2 changed files with 75 additions and 7 deletions
+14 -2
View File
@@ -1869,9 +1869,10 @@
{:else if view} {:else if view}
<div class="game"> <div class="game">
<section class="board-zone"> <section class="board-zone">
<div class="table-stack"> <div class="table-stack" class:fpv-primary={prefs.liveFp && !!view.you && !net.spectating}>
{#if prefs.liveFp && view.you && !net.spectating} {#if prefs.liveFp && view.you && !net.spectating}
<LiveFirstPerson {view} batch={fpBatch} onhide={() => setPref("liveFp", false)} /> <LiveFirstPerson {view} batch={fpBatch} onhide={() => setPref("liveFp", false)}
onstride={(side) => tryMove(side)} canStride={yourMoment} />
{/if} {/if}
<Board <Board
{view} {view}
@@ -2829,6 +2830,17 @@
} }
.table-stack :global(.live-fp) { width: 0; min-width: 100%; } .table-stack :global(.live-fp) { width: 0; min-width: 100%; }
.table-stack :global(.live-fp .fpv-canvas) { width: 100%; height: auto; } .table-stack :global(.live-fp .fpv-canvas) { width: 100%; height: auto; }
/* FPV play: the 3D view is the primary window into the game; the
* top-down board shrinks to a keymap beneath it. */
.table-stack.fpv-primary {
display: flex;
width: 100%;
max-width: 64rem;
}
.board-zone .table-stack.fpv-primary :global(svg.board) {
max-height: 30dvh;
align-self: center;
}
.board-zone :global(svg.board) { .board-zone :global(svg.board) {
max-height: calc(100dvh - 21.5rem); max-height: calc(100dvh - 21.5rem);
width: auto; width: auto;
+61 -5
View File
@@ -12,14 +12,17 @@
import { castRay } from "./fpv/raycast"; import { castRay } from "./fpv/raycast";
import { aimOfEvents, gatherGlides, hurledIn, shortestArc } from "./fpv/director"; import { aimOfEvents, gatherGlides, hurledIn, shortestArc } from "./fpv/director";
import { untrack } from "svelte"; import { untrack } from "svelte";
import type { GameEvent, GameView } from "@wizwar/engine"; import type { GameEvent, GameView, Side } from "@wizwar/engine";
let { view, batch, onhide }: { let { view, batch, onhide, onstride = null, canStride = false }: {
view: GameView; view: GameView;
/** The latest live event batch, numbered so each plays once, with /** The latest live event batch, numbered so each plays once, with
* the view the server sent alongside it. */ * the view the server sent alongside it. */
batch: { n: number; events: GameEvent[]; view: GameView } | null; batch: { n: number; events: GameEvent[]; view: GameView } | null;
onhide: () => void; onhide: () => void;
/** Issue a real move command: the pane is the cockpit on your turn. */
onstride?: ((side: Side) => void) | null;
canStride?: boolean;
} = $props(); } = $props();
const povId = $derived(view.you); const povId = $derived(view.you);
@@ -27,6 +30,44 @@
const cam = $state({ x: 0, y: 0, facing: 0 }); const cam = $state({ x: 0, y: 0, facing: 0 });
let camReady = false; 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 cutawayShot = $state(false);
let fpFx = $state<FpFx[]>([]); let fpFx = $state<FpFx[]>([]);
let actorPos = $state<Record<string, { x: number; y: number }>>({}); let actorPos = $state<Record<string, { x: number; y: number }>>({});
@@ -130,7 +171,7 @@
if (sight.warped || sight.dist < toAim - 0.4) return takeCutaway(ax, ay); if (sight.warped || sight.dist < toAim - 0.4) return takeCutaway(ax, ay);
targetFacing = Math.atan2(ay - ty, ax - tx); targetFacing = Math.atan2(ay - ty, ax - tx);
aimed = true; aimed = true;
} else { } else if (performance.now() >= manualUntil) {
// Another wizard acting in your sight line turns your head. // Another wizard acting in your sight line turns your head.
const other = actor !== povId ? v.players.find((p) => p.id === actor && p.alive) : null; 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)) { if (other && (other.position.x !== m.position.x || other.position.y !== m.position.y)) {
@@ -143,7 +184,8 @@
} }
} }
} }
if (!aimed && (willCut || castRay(v, tx, ty, targetFacing).dist < 0.8)) { 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: // Nothing asks to be watched and the eyes would idle nose-to-brick:
// face the deepest corridor from where the wizard stands. // face the deepest corridor from where the wizard stands.
let deepest = -1; let deepest = -1;
@@ -185,12 +227,17 @@
}); });
</script> </script>
<svelte:window onkeydown={onKey} />
{#if me} {#if me}
<div class="live-fp"> <div class="live-fp">
<FirstPerson {view} povId={cutawayShot ? "" : povId} <FirstPerson {view} povId={cutawayShot ? "" : povId}
x={cam.x} y={cam.y} facing={cam.facing} width={720} height={280} x={cam.x} y={cam.y} facing={cam.facing} width={960} height={400}
fx={fpFx} posOverride={actorPos} /> fx={fpFx} posOverride={actorPos} />
<button class="live-fp-hide" onclick={onhide} title="hide (re-enable in preferences)"></button> <button class="live-fp-hide" onclick={onhide} title="hide (re-enable in preferences)"></button>
{#if canStride}
<div class="live-fp-hint">← → turn · ↑ ↓ walk</div>
{/if}
</div> </div>
{/if} {/if}
@@ -212,4 +259,13 @@
padding: 0.1rem 0.4rem; padding: 0.1rem 0.4rem;
} }
.live-fp-hide:hover { color: #e9e1cb; } .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;
}
</style> </style>