Exact source used by this build. SHA-256: aec4c20de4b8b59c961a70a4938ce3453aed5db81eebefec0b62c241a28f3266.

1 import type { Tile, TilePosition, Warp } from "../../types";
2 import { tileIndex } from "../../state";
3 
4 export interface MovementArea {
5   width: number; height: number; collision: readonly boolean[];
6   tiles?: readonly Pick<Tile, "objectId" | "walkableObject">[];
7   warps?: readonly Warp[];
8 }
9 
10 export function isBlocked(area: MovementArea, x: number, y: number): boolean {
11   const index = tileIndex(area, Math.floor(x), Math.floor(y));
12   return index === null || area.collision[index] === true || (!!area.tiles?.[index]?.objectId && !area.tiles?.[index]?.walkableObject);
13 }
14 /** Sample every grid/warp boundary and intervening interval, so thin corner crossings cannot tunnel. */
15 export function* movementPath(area: MovementArea, from: { x: number; y: number }, to: { x: number; y: number }): Generator<{ x: number; y: number; progress: number; collisionOnly?: boolean }> {
16   const dx = to.x - from.x, dy = to.y - from.y;
17   const fractions = new Set<number>([0, 1]);
18   const addBoundary = (position: number, origin: number, delta: number) => {
19     if (delta === 0) return;
20     const at = (position - origin) / delta;
21     if (at > 0 && at < 1) fractions.add(at);
22   };
23   for (let x = Math.ceil(Math.min(from.x, to.x)); x <= Math.floor(Math.max(from.x, to.x)); x++) addBoundary(x, from.x, dx);
24   for (let y = Math.ceil(Math.min(from.y, to.y)); y <= Math.floor(Math.max(from.y, to.y)); y++) addBoundary(y, from.y, dy);
25   for (const warp of area.warps ?? []) {
26     addBoundary(warp.x, from.x, dx); addBoundary(warp.x + warp.width, from.x, dx);
27     addBoundary(warp.y, from.y, dy); addBoundary(warp.y + warp.height, from.y, dy);
28   }
29   const at = (fraction: number) => ({ x: from.x + dx * fraction, y: from.y + dy * fraction });
30   const ordered = [...fractions].sort((a, b) => a - b);
31   for (let index = 0; index < ordered.length; index++) {
32     const fraction = ordered[index]!;
33     const before = at(index === 0 ? fraction : (ordered[index - 1]! + fraction) / 2);
34     const after = at(index === ordered.length - 1 ? fraction : (ordered[index + 1]! + fraction) / 2);
35     // At an exact diagonal corner, neither neighboring wall may be cut through.
36     if (Math.floor(before.x) !== Math.floor(after.x) && Math.floor(before.y) !== Math.floor(after.y)) {
37       yield { x: before.x, y: after.y, progress: fraction, collisionOnly: true };
38       yield { x: after.x, y: before.y, progress: fraction, collisionOnly: true };
39     }
40     yield { ...at(fraction), progress: fraction };
41     // Interval samples carry their entry boundary, so a half-open warp entered from
42     // the right/bottom charges only travel to the doorway, not travel beyond it.
43     if (index < ordered.length - 1) yield { ...after, progress: fraction };
44   }
45 }
46 
47 /** Authority and prediction use the same swept corner checks. */
48 export function sweptMovementClear(area: MovementArea, from: TilePosition, to: TilePosition): boolean {
49   for (const point of movementPath(area, from, to)) if (isBlocked(area, point.x, point.y)) return false;
50   return true;
51 }
52 
53 /** Slide along one clear axis; every proposed packet must also clear its send anchor. */
54 export function slideMovement(area: MovementArea, from: TilePosition, desired: TilePosition, anchor: TilePosition = from): TilePosition {
55   for (const candidate of [desired, { x: desired.x, y: from.y }, { x: from.x, y: desired.y }]) {
56     if (candidate.x === from.x && candidate.y === from.y) continue;
57     if (sweptMovementClear(area, from, candidate) && sweptMovementClear(area, anchor, candidate)) return candidate;
58   }
59   return { x: from.x, y: from.y };
60 }
61