Half the warp pairings were secretly mirrors

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 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-23 19:47:28 -04:00
co-authored by Claude Fable 5
parent 700db70d4e
commit 707297da17
2 changed files with 35 additions and 4 deletions
+4 -1
View File
@@ -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"],
});
+31 -3
View File
@@ -39,6 +39,25 @@ export interface Hit {
export const SIDE_ANGLE: Record<Side, number> = { E: 0, S: Math.PI / 2, W: Math.PI, N: -Math.PI / 2 };
export const OPPOSITE: Record<Side, Side> = { E: "W", W: "E", N: "S", S: "N" };
/** Unit vector along an edge's lane axis (the direction `along` grows). */
const LANE_AXIS: Record<Side, [number, number]> = {
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) {