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