diff --git a/deploy/scene-goldens/warp-sniper-s16-fpv.png b/deploy/scene-goldens/warp-sniper-s16-fpv.png index 9697cc2..4bcb586 100644 Binary files a/deploy/scene-goldens/warp-sniper-s16-fpv.png and b/deploy/scene-goldens/warp-sniper-s16-fpv.png differ diff --git a/packages/web/src/Replay.svelte b/packages/web/src/Replay.svelte index 312e2c8..57c59cf 100644 --- a/packages/web/src/Replay.svelte +++ b/packages/web/src/Replay.svelte @@ -7,7 +7,7 @@ import { scheduleFx, type BoardFx } from "./fx"; import { fpFxForEvents, type FpFx } from "./fpv/fx3d"; 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 { cardDef, isPermanentDuration, stackSightTrace } from "@wizwar/engine"; import type { GameEvent, GameView } from "@wizwar/engine"; @@ -387,26 +387,20 @@ } else if (aim) { const ax = aim.x + 0.5, ay = aim.y + 0.5; - const toAim = Math.hypot(ax - tx, ay - ty); - const sight = castRay(v, tx, ty, Math.atan2(ay - ty, ax - tx)); - // Blocked: a wall before the target, or a warp mouth before it — - // a warp BEYOND the target bends nothing the eyes need. - const warpBefore = sight.warped && (sight.warpDist ?? Infinity) < toAim - 0.4; - if (warpBefore || sight.dist < toAim - 0.4) { - return takeCutaway(ax, ay); - } - targetFacing = Math.atan2(ay - ty, ax - tx); + // The eyes watch their own magic land wherever they can see it — + // straight down a corridor or through a warp mouth. Only a target + // out of all sight earns the cutaway. + const heading = sightline(v, tx, ty, ax, ay); + if (heading === null) return takeCutaway(ax, ay); + targetFacing = heading; aimed = true; } else if (actor && actor.id !== povId && (actor.position.x !== me.position.x || actor.position.y !== me.position.y)) { // Turn toward the actor — but only if these eyes could actually see - // them: an unbroken straight sight line, no warps bending it. - const ax = actor.position.x + 0.5, ay = actor.position.y + 0.5; - const toActor = Math.hypot(ax - tx, ay - ty); - const ray = castRay(v, tx, ty, Math.atan2(ay - ty, ax - tx)); - const warpBefore = ray.warped && (ray.warpDist ?? Infinity) < toActor - 0.2; - if (!warpBefore && ray.dist > toActor - 0.2) { - targetFacing = Math.atan2(ay - ty, ax - tx); + // them, straight or through a mouth. + const heading = sightline(v, tx, ty, actor.position.x + 0.5, actor.position.y + 0.5, 0.2); + if (heading !== null) { + targetFacing = heading; aimed = true; } } diff --git a/packages/web/src/fpv/director.ts b/packages/web/src/fpv/director.ts index fcb96b6..40a1783 100644 --- a/packages/web/src/fpv/director.ts +++ b/packages/web/src/fpv/director.ts @@ -3,7 +3,7 @@ // between one view and the next. One ladder, so the replay and the // table never learn different instincts. -import { edgeMid } from "./raycast"; +import { edgeMid, warpMotion } from "./raycast"; import { fpFxForEvents } from "./fx3d"; import type { GameEvent, GameView } from "@wizwar/engine"; import { castRay } from "./raycast"; @@ -95,6 +95,32 @@ export function aimOfEvents( 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, * eyes held steady, rather than a stride or a cut? */ export function hurledIn(events: GameEvent[], povId: string): boolean {