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) {