The eyes watch through a warp mouth; a cutaway is for what no sight reaches

The Warp Sniper cut to another angle the moment the fireball landed,
though the caster could see the whole thing: the director counted a
warp mouth before the target as blocked sight. Sight threads warps as
the rules say, and now the camera does too. director.sightline finds
the heading that actually sees a point — straight, or through a mouth
by aiming at the target's position in the far room and asking whether
the bent ray reaches it — and both the aim and the turn-toward-the-
actor rules use it. Only a point no ray reaches earns the cutaway.

Warp Sniper's second beat re-blessed: Wanderer's eyes now find Rival
through the mouth as the turn passes to him.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
This commit is contained in:
Eric Wagoner
2026-09-02 20:16:47 -04:00
co-authored by Claude Fable 5.1
parent 7c0a761c59
commit 86856781df
3 changed files with 38 additions and 18 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 279 KiB

After

Width:  |  Height:  |  Size: 260 KiB

+11 -17
View File
@@ -7,7 +7,7 @@
import { scheduleFx, type BoardFx } from "./fx"; import { scheduleFx, type BoardFx } from "./fx";
import { fpFxForEvents, type FpFx } from "./fpv/fx3d"; import { fpFxForEvents, type FpFx } from "./fpv/fx3d";
import { castRay, edgeMid } from "./fpv/raycast"; import { castRay, edgeMid } from "./fpv/raycast";
import { cutawayStand, deepestFacing, aimOfEvents, gatherGlides, hurledIn, shortestArc } from "./fpv/director"; import { cutawayStand, deepestFacing, aimOfEvents, gatherGlides, hurledIn, shortestArc, sightline } from "./fpv/director";
import { prefs } from "./prefs.svelte"; import { prefs } from "./prefs.svelte";
import { cardDef, isPermanentDuration, stackSightTrace } from "@wizwar/engine"; import { cardDef, isPermanentDuration, stackSightTrace } from "@wizwar/engine";
import type { GameEvent, GameView } from "@wizwar/engine"; import type { GameEvent, GameView } from "@wizwar/engine";
@@ -387,26 +387,20 @@
} }
else if (aim) { else if (aim) {
const ax = aim.x + 0.5, ay = aim.y + 0.5; const ax = aim.x + 0.5, ay = aim.y + 0.5;
const toAim = Math.hypot(ax - tx, ay - ty); // The eyes watch their own magic land wherever they can see it —
const sight = castRay(v, tx, ty, Math.atan2(ay - ty, ax - tx)); // straight down a corridor or through a warp mouth. Only a target
// Blocked: a wall before the target, or a warp mouth before it — // out of all sight earns the cutaway.
// a warp BEYOND the target bends nothing the eyes need. const heading = sightline(v, tx, ty, ax, ay);
const warpBefore = sight.warped && (sight.warpDist ?? Infinity) < toAim - 0.4; if (heading === null) return takeCutaway(ax, ay);
if (warpBefore || sight.dist < toAim - 0.4) { targetFacing = heading;
return takeCutaway(ax, ay);
}
targetFacing = Math.atan2(ay - ty, ax - tx);
aimed = true; aimed = true;
} else if (actor && actor.id !== povId && } else if (actor && actor.id !== povId &&
(actor.position.x !== me.position.x || actor.position.y !== me.position.y)) { (actor.position.x !== me.position.x || actor.position.y !== me.position.y)) {
// Turn toward the actor — but only if these eyes could actually see // Turn toward the actor — but only if these eyes could actually see
// them: an unbroken straight sight line, no warps bending it. // them, straight or through a mouth.
const ax = actor.position.x + 0.5, ay = actor.position.y + 0.5; const heading = sightline(v, tx, ty, actor.position.x + 0.5, actor.position.y + 0.5, 0.2);
const toActor = Math.hypot(ax - tx, ay - ty); if (heading !== null) {
const ray = castRay(v, tx, ty, Math.atan2(ay - ty, ax - tx)); targetFacing = heading;
const warpBefore = ray.warped && (ray.warpDist ?? Infinity) < toActor - 0.2;
if (!warpBefore && ray.dist > toActor - 0.2) {
targetFacing = Math.atan2(ay - ty, ax - tx);
aimed = true; aimed = true;
} }
} }
+27 -1
View File
@@ -3,7 +3,7 @@
// between one view and the next. One ladder, so the replay and the // between one view and the next. One ladder, so the replay and the
// table never learn different instincts. // table never learn different instincts.
import { edgeMid } from "./raycast"; import { edgeMid, warpMotion } from "./raycast";
import { fpFxForEvents } from "./fx3d"; import { fpFxForEvents } from "./fx3d";
import type { GameEvent, GameView } from "@wizwar/engine"; import type { GameEvent, GameView } from "@wizwar/engine";
import { castRay } from "./raycast"; import { castRay } from "./raycast";
@@ -95,6 +95,32 @@ export function aimOfEvents(
return null; return null;
} }
/** The heading down which the eyes at (tx,ty) actually SEE the point
* (ax,ay): straight, if an unbroken ray reaches it; through a warp
* mouth, if a ray bent by that mouth reaches the point's position in
* the far room — sight threads warps as the rules say, and so must the
* camera. Null when nothing reaches it: that is what a cutaway is for. */
export function sightline(
view: GameView, tx: number, ty: number, ax: number, ay: number, slack = 0.4,
): number | null {
const straight = Math.atan2(ay - ty, ax - tx);
const toAim = Math.hypot(ax - tx, ay - ty);
const direct = castRay(view, tx, ty, straight);
const warpBefore = direct.warped && (direct.warpDist ?? Infinity) < toAim - slack;
if (!warpBefore && direct.dist >= toAim - slack) return straight;
// Through each mouth: aim at where the target would stand if the far
// room continued past the opening, and ask whether the bent ray gets
// that far. The motion is rigid, so path length matches distance.
for (let i = 0; i < view.board.warps.length; i++) {
const virt = warpMotion(view.board.warps[i]!).toVirtual({ x: ax, y: ay });
const heading = Math.atan2(virt.y - ty, virt.x - tx);
const span = Math.hypot(virt.x - tx, virt.y - ty);
const ray = castRay(view, tx, ty, heading);
if (ray.warpId === i && ray.dist >= span - slack) return heading;
}
return null;
}
/** Does this batch hurl the pov wizard bodily — a blow to glide with, /** Does this batch hurl the pov wizard bodily — a blow to glide with,
* eyes held steady, rather than a stride or a cut? */ * eyes held steady, rather than a stride or a cut? */
export function hurledIn(events: GameEvent[], povId: string): boolean { export function hurledIn(events: GameEvent[], povId: string): boolean {