From 707297da17c92042a021645654df51c0a8a7ce44 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Sun, 23 Aug 2026 19:47:28 -0400 Subject: [PATCH] Half the warp pairings were secretly mirrors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eric walked a five-player board's corner warp and the world stretched. The lane rule — carry the along-the-edge offset through unchanged — is only correct for half the side pairings: for the rest (N to N, E to N, and their kin, the ones five-player boards deal), the proper rotation lands the entry axis head-to-head against the exit axis, and preserving the raw lane smuggled a REFLECTION into the traversal — rays crossing over at the mouth, parallax inverted, the far room swimming. warpLaneMirrored computes the pairing's sign from the rotation itself; castRay mirrors the lane where required, and warpMotion anchors the far mouth at its opposite corner to stay the same pure rotation — so sprites and projectile legs agree with the rays again. The workshop grows ?players=2..6 to stand on any table size (the mirror-class mouths live on the five-player boards). Co-Authored-By: Claude Fable 5 --- packages/web/src/fpv/FpvWorkshop.svelte | 5 +++- packages/web/src/fpv/raycast.ts | 34 ++++++++++++++++++++++--- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/packages/web/src/fpv/FpvWorkshop.svelte b/packages/web/src/fpv/FpvWorkshop.svelte index bd8247a..af3ed94 100644 --- a/packages/web/src/fpv/FpvWorkshop.svelte +++ b/packages/web/src/fpv/FpvWorkshop.svelte @@ -14,8 +14,11 @@ /** ?demo=1: two automatons play a stretch, then the reel replays it — * the real Replay component, toggleable into first person. */ const demoReel = q.has("demo"); + // ?players=2..6 sizes the table (5-player boards carry the corner + // warps whose traversals rotate the world a quarter-turn). + const count = Math.min(6, Math.max(2, Number(q.get("players") ?? 3))); const { state: dealt } = createGame({ - playerIds: ["Wanderer", "Rival", "Stranger"], + playerIds: ["Wanderer", "Rival", "Stranger", "Pilgrim", "Vagabond", "Drifter"].slice(0, count), seed, sets: ["basic", "expansion1"], }); diff --git a/packages/web/src/fpv/raycast.ts b/packages/web/src/fpv/raycast.ts index 6823c4a..3eabb8f 100644 --- a/packages/web/src/fpv/raycast.ts +++ b/packages/web/src/fpv/raycast.ts @@ -39,6 +39,25 @@ export interface Hit { export const SIDE_ANGLE: Record = { E: 0, S: Math.PI / 2, W: Math.PI, N: -Math.PI / 2 }; export const OPPOSITE: Record = { E: "W", W: "E", N: "S", S: "N" }; +/** Unit vector along an edge's lane axis (the direction `along` grows). */ +const LANE_AXIS: Record = { + E: [0, 1], W: [0, 1], N: [1, 0], S: [1, 0], +}; + +/** Does this warp pair MIRROR the lane? A traversal is a proper rigid + * rotation; for half the side pairings the rotated entry axis points + * AGAINST the exit edge's axis, and preserving the raw lane there would + * smuggle in a reflection — rays crossing over at the mouth, parallax + * inverted, the far room stretching as the eye moves. */ +export function warpLaneMirrored(from: Side, to: Side): boolean { + const d = SIDE_ANGLE[OPPOSITE[to]] - SIDE_ANGLE[from]; + const [x, y] = LANE_AXIS[from]; + const rx = x * Math.cos(d) - y * Math.sin(d); + const ry = x * Math.sin(d) + y * Math.cos(d); + const [ex2, ey] = LANE_AXIS[to]; + return rx * ex2 + ry * ey < 0; +} + /** Is this edge passable to the EYE (rays), and if not, what is it? * Doors always report as doors; how far each stands open — fully for an * open or held door, mid-swing during an animation — is castRay's call. */ @@ -180,11 +199,12 @@ export function castRay( ); if (!warp) return { dist: baseDist + dist, kind: "rim", u: texU, axis, worldU: along, warped, ghost, doorway }; // Step through: re-enter at the paired mouth, heading inward, the - // offset along the edge preserved (a wraparound keeps its lane). + // lane carried by the same proper rotation the heading turns by — + // mirrored for the pairings whose axes land head-to-head. const exitHeading = SIDE_ANGLE[OPPOSITE[warp.to.side]]; angle = angle + (exitHeading - SIDE_ANGLE[warp.from.side]); const c = warp.to.cell; - const lane = texU; + const lane = warpLaneMirrored(warp.from.side, warp.to.side) ? 1 - texU : texU; if (warp.to.side === "E") { ox = c.x + 1 - 1e-4; oy = c.y + lane; } else if (warp.to.side === "W") { ox = c.x + 1e-4; oy = c.y + lane; } else if (warp.to.side === "S") { ox = c.x + lane; oy = c.y + 1 - 1e-4; } @@ -280,7 +300,15 @@ export function warpMotion(w: GameView["board"]["warps"][number]): { } { const delta = SIDE_ANGLE[OPPOSITE[w.to.side]] - SIDE_ANGLE[w.from.side]; const a1 = mouthAnchor(w.from); - const a2 = mouthAnchor(w.to); + // Mirrored pairings anchor the far mouth at its OTHER corner — the + // lane runs backwards there, and this keeps the motion a pure + // rotation matching castRay's traversal. + const a2 = { ...mouthAnchor(w.to) }; + if (warpLaneMirrored(w.from.side, w.to.side)) { + const [lx, ly] = LANE_AXIS[w.to.side]; + a2.x += lx; + a2.y += ly; + } const c = Math.cos(delta), s = Math.sin(delta); return { toVirtual(p) {