From 5f91e30eb41f67b0cc4322275ff5135761d5310d Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Thu, 27 Aug 2026 14:51:56 -0400 Subject: [PATCH] Warps preserve your heading, not your bearing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stepping through a rim warp spun the live camera 180: the director aimed along the displacement vector, and a top-to-bottom wrap's vector points exactly opposite the walk. The exit facing now comes from the warp's own geometry — the far mouth's inward direction — so travel carries straight through plain wraps and rotates correctly where boards were laid rotated; a dimensional token step keeps the facing it entered with. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF --- packages/web/src/LiveFirstPerson.svelte | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/packages/web/src/LiveFirstPerson.svelte b/packages/web/src/LiveFirstPerson.svelte index 9a28a3a..2a4d975 100644 --- a/packages/web/src/LiveFirstPerson.svelte +++ b/packages/web/src/LiveFirstPerson.svelte @@ -12,7 +12,7 @@ import { tokenArt } from "./art"; import { objectArt } from "./art"; import { fpFxForEvents, type FpFx } from "./fpv/fx3d"; - import { castRay } from "./fpv/raycast"; + import { castRay, SIDE_ANGLE, OPPOSITE } from "./fpv/raycast"; import { aimOfEvents, gatherGlides, hurledIn, shortestArc } from "./fpv/director"; import { untrack } from "svelte"; import type { GameEvent, GameView, Side } from "@wizwar/engine"; @@ -175,6 +175,22 @@ 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) => { @@ -194,7 +210,10 @@ const aim = aimOfEvents(events, v, povId, m.position, actor); let targetFacing = camF; let aimed = false; - if (camReady && !willCut && dist > 0.05 && !hurled) { + 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) {