Warps preserve your heading, not your bearing

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
This commit is contained in:
Eric Wagoner
2026-08-27 14:51:56 -04:00
co-authored by Claude Fable 5
parent 770e79d063
commit 5f91e30eb4
+21 -2
View File
@@ -12,7 +12,7 @@
import { tokenArt } from "./art"; import { tokenArt } from "./art";
import { objectArt } from "./art"; import { objectArt } from "./art";
import { fpFxForEvents, type FpFx } from "./fpv/fx3d"; 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 { aimOfEvents, gatherGlides, hurledIn, shortestArc } from "./fpv/director";
import { untrack } from "svelte"; import { untrack } from "svelte";
import type { GameEvent, GameView, Side } from "@wizwar/engine"; import type { GameEvent, GameView, Side } from "@wizwar/engine";
@@ -175,6 +175,22 @@
const events = b && b.n !== directedBatch ? b.events : []; const events = b && b.n !== directedBatch ? b.events : [];
if (b) directedBatch = b.n; if (b) directedBatch = b.n;
const hurled = hurledIn(events, povId); 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); const willCut = !camReady || (dist > 1.6 && !hurled);
cutawayShot = false; cutawayShot = false;
const takeCutaway = (ax: number, ay: number) => { const takeCutaway = (ax: number, ay: number) => {
@@ -194,7 +210,10 @@
const aim = aimOfEvents(events, v, povId, m.position, actor); const aim = aimOfEvents(events, v, povId, m.position, actor);
let targetFacing = camF; let targetFacing = camF;
let aimed = false; 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); targetFacing = Math.atan2(dy, dx);
aimed = true; aimed = true;
} else if (aim?.self) { } else if (aim?.self) {