From b8bb60cf6d8db601af9fdbe7578092d583274f9f Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Mon, 24 Aug 2026 00:02:16 -0400 Subject: [PATCH] Milestone one: the pane becomes the cockpit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/web/src/App.svelte | 16 +++++- packages/web/src/LiveFirstPerson.svelte | 66 +++++++++++++++++++++++-- 2 files changed, 75 insertions(+), 7 deletions(-) diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index 058c99a..6cadd19 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -1869,9 +1869,10 @@ {:else if view}
-
+
{#if prefs.liveFp && view.you && !net.spectating} - setPref("liveFp", false)} /> + setPref("liveFp", false)} + onstride={(side) => tryMove(side)} canStride={yourMoment} /> {/if} void; + /** Issue a real move command: the pane is the cockpit on your turn. */ + onstride?: ((side: Side) => void) | null; + canStride?: boolean; } = $props(); const povId = $derived(view.you); @@ -27,6 +30,44 @@ 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([]); let actorPos = $state>({}); @@ -130,7 +171,7 @@ if (sight.warped || sight.dist < toAim - 0.4) return takeCutaway(ax, ay); targetFacing = Math.atan2(ay - ty, ax - tx); aimed = true; - } else { + } 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)) { @@ -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: // face the deepest corridor from where the wizard stands. let deepest = -1; @@ -185,12 +227,17 @@ }); + + {#if me}
+ {#if canStride} +
← → turn · ↑ ↓ walk
+ {/if}
{/if} @@ -212,4 +259,13 @@ 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; + }