quill/val Public Public: anyone can read this; nobody can change it.

forked from benjivers/val

workbenchthis machine
Files 967e12d2fa288842a7c42ddda720f0c8a28efc34
245 lines · 8.6 KB · ~2.5k tok Raw History
1
/**
2
 * A tile grid and the questions everything else asks of it: which tiles are
3
 * solid, where a circle slides to, whether a wall stands between two points,
4
 * where a ray stops, and how far a walk is.
5
 *
6
 * The grid is rows of letters. `#` is a wall; every other letter is floor,
7
 * and what a letter means beyond that is the game's business — this module
8
 * hands back the cells of a letter and never asks what they are for.
9
 *
10
 * `makeGrid` binds the maths to one set of rows and one tile size. The
11
 * collision itself is `glide.ts`, which the browser runs too; the walk
12
 * distances are cached per goal for the round and cleared by the caller.
13
 *
14
 * Argument order matters for speed: a walk field is built for `to` and read
15
 * at `from`. Keep `to` fixed and vary `from`; walking is symmetric on this
16
 * grid, so swapping the two never changes the answer.
17
 */
18
19
import { circleBlocked, slideCircle } from "./glide.ts";
20
21
export type Point = { x: number; y: number };
22
23
export type Grid = {
24
  lines: string[];
25
  cols: number;
26
  rows: number;
27
  tile: number;
28
  width: number;
29
  height: number;
30
  /** The letter at a tile; anything off the grid is a wall. */
31
  at: (col: number, row: number) => string;
32
  solidTile: (col: number, row: number) => boolean;
33
  tileOf: (x: number, y: number) => { col: number; row: number };
34
  centreOf: (col: number, row: number) => Point;
35
  solidAt: (x: number, y: number) => boolean;
36
  /** The letter under a point. */
37
  cellAt: (x: number, y: number) => string;
38
  /** The centres of every tile carrying this letter, row by row. */
39
  cellsOf: (mark: string) => Point[];
40
  /** Slide a circle through this grid. */
41
  moveCircle: (x: number, y: number, dx: number, dy: number, r: number) => Point;
42
  /** True when a circle at (x,y) overlaps any solid tile. */
43
  blockedCircle: (x: number, y: number, r: number) => boolean;
44
  wallBetween: (a: Point, b: Point) => boolean;
45
  rayHitWall: (from: Point, angle: number, range: number) => Point;
46
  /**
47
   * Tiles walked between two points, or -1. Cached by `to`: hold `to` fixed
48
   * and vary `from`, never the other way (symmetric, so swapping is safe).
49
   */
50
  walkDistance: (from: Point, to: Point) => number;
51
  /** The next point toward `to`; reads the same field, cached by `to`. */
52
  pathStep: (from: Point, to: Point) => Point;
53
  clearPaths: () => void;
54
  /** How many walk fields have been built so far — for tests of the cache. */
55
  fieldsBuilt: () => number;
56
};
57
58
/** Closest approach of a segment to a point, as distance and travel along it. */
59
export function segmentPoint(a: Point, b: Point, p: Point): { dist: number; travel: number } {
60
  const dx = b.x - a.x;
61
  const dy = b.y - a.y;
62
  const len2 = dx * dx + dy * dy;
63
  const t = len2 === 0 ? 0 : Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / len2));
64
  const cx = a.x + dx * t;
65
  const cy = a.y + dy * t;
66
  return { dist: Math.hypot(p.x - cx, p.y - cy), travel: t * Math.sqrt(len2) };
67
}
68
69
export const segmentHitsCircle = (a: Point, b: Point, c: Point, r: number): boolean =>
70
  segmentPoint(a, b, c).dist <= r;
71
72
const STEPS = [
73
  [1, 0],
74
  [-1, 0],
75
  [0, 1],
76
  [0, -1],
77
] as const;
78
79
export function makeGrid(lines: string[], tile: number): Grid {
80
  const cols = lines[0]!.length;
81
  const rows = lines.length;
82
83
  const at = (col: number, row: number): string =>
84
    row < 0 || row >= rows || col < 0 || col >= cols ? "#" : lines[row]![col]!;
85
  const solidTile = (col: number, row: number): boolean => at(col, row) === "#";
86
  const tileOf = (x: number, y: number): { col: number; row: number } => ({
87
    col: Math.floor(x / tile),
88
    row: Math.floor(y / tile),
89
  });
90
  const centreOf = (col: number, row: number): Point => ({
91
    x: col * tile + tile / 2,
92
    y: row * tile + tile / 2,
93
  });
94
  const solidAt = (x: number, y: number): boolean => {
95
    const t = tileOf(x, y);
96
    return solidTile(t.col, t.row);
97
  };
98
  const cellAt = (x: number, y: number): string => {
99
    const t = tileOf(x, y);
100
    return at(t.col, t.row);
101
  };
102
  const cellsOf = (mark: string): Point[] => {
103
    const out: Point[] = [];
104
    for (let row = 0; row < rows; row++)
105
      for (let col = 0; col < cols; col++) if (at(col, row) === mark) out.push(centreOf(col, row));
106
    return out;
107
  };
108
109
  const moveCircle = (x: number, y: number, dx: number, dy: number, r: number): Point =>
110
    slideCircle(x, y, dx, dy, r, solidTile, tile);
111
  const blockedCircle = (x: number, y: number, r: number): boolean => circleBlocked(x, y, r, solidTile, tile);
112
113
  /**
114
   * Does a wall stand between two points? Sampled every third of a tile: a
115
   * wall is a whole tile thick, so nothing is ever stepped over.
116
   */
117
  function wallBetween(a: Point, b: Point): boolean {
118
    const dx = b.x - a.x;
119
    const dy = b.y - a.y;
120
    const dist = Math.hypot(dx, dy);
121
    const steps = Math.ceil(dist / (tile / 3));
122
    for (let i = 1; i < steps; i++) {
123
      const t = i / steps;
124
      if (solidAt(a.x + dx * t, a.y + dy * t)) return true;
125
    }
126
    return false;
127
  }
128
129
  /** Where a shot stops: the first wall along the ray, or its full range. */
130
  function rayHitWall(from: Point, angle: number, range: number): Point {
131
    const dx = Math.cos(angle);
132
    const dy = Math.sin(angle);
133
    const step = tile / 4;
134
    for (let d = step; d <= range; d += step) {
135
      const x = from.x + dx * d;
136
      const y = from.y + dy * d;
137
      if (solidAt(x, y)) return { x: from.x + dx * (d - step), y: from.y + dy * (d - step) };
138
    }
139
    return { x: from.x + dx * range, y: from.y + dy * range };
140
  }
141
142
  // Walk distance from a goal tile, breadth-first over floor. Bots ask for it
143
  // every few ticks; the field for a goal is built once and kept for the round.
144
  const fields = new Map<number, Int32Array>();
145
  const clearPaths = (): void => void fields.clear();
146
  let built = 0;
147
  const fieldsBuilt = (): number => built;
148
149
  function field(goalCol: number, goalRow: number): Int32Array {
150
    const key = goalRow * cols + goalCol;
151
    const cached = fields.get(key);
152
    if (cached) return cached;
153
    built++;
154
    const dist = new Int32Array(cols * rows).fill(-1);
155
    if (!solidTile(goalCol, goalRow)) {
156
      dist[key] = 0;
157
      const queue: number[] = [key];
158
      for (let head = 0; head < queue.length; head++) {
159
        const here = queue[head]!;
160
        const col = here % cols;
161
        const row = (here - col) / cols;
162
        const d = dist[here]!;
163
        for (const [dc, dr] of STEPS) {
164
          const nc = col + dc;
165
          const nr = row + dr;
166
          if (nc < 0 || nc >= cols || nr < 0 || nr >= rows) continue;
167
          const nk = nr * cols + nc;
168
          if (dist[nk] !== -1 || solidTile(nc, nr)) continue;
169
          dist[nk] = d + 1;
170
          queue.push(nk);
171
        }
172
      }
173
    }
174
    if (fields.size > 64) fields.clear();
175
    fields.set(key, dist);
176
    return dist;
177
  }
178
179
  /**
180
   * Walk distance in tiles between two points, or -1 when unreachable. The
181
   * field is built for `to` and read at `from`, so a caller asking about many
182
   * cells should pass them as `from` against one fixed `to`: one field, not
183
   * one per call. Distance is symmetric, so the swap is always safe.
184
   */
185
  function walkDistance(from: Point, to: Point): number {
186
    const f = tileOf(from.x, from.y);
187
    const g = tileOf(to.x, to.y);
188
    return field(g.col, g.row)[f.row * cols + f.col] ?? -1;
189
  }
190
191
  /**
192
   * The next point to walk toward on the way to a goal: the centre of the
193
   * neighbouring tile that is one step closer, so a walker rounds corners
194
   * instead of grinding into them. Uses the field cached for `to`, like
195
   * `walkDistance`: many walkers to one goal cost one field.
196
   */
197
  function pathStep(from: Point, to: Point): Point {
198
    const f = tileOf(from.x, from.y);
199
    const g = tileOf(to.x, to.y);
200
    if (f.col === g.col && f.row === g.row) return to;
201
    const dist = field(g.col, g.row);
202
    const here = dist[f.row * cols + f.col] ?? -1;
203
    if (here <= 0) return to;
204
    let best: Point | null = null;
205
    let bestD = here;
206
    for (const [dc, dr] of STEPS) {
207
      const nc = f.col + dc;
208
      const nr = f.row + dr;
209
      if (nc < 0 || nc >= cols || nr < 0 || nr >= rows) continue;
210
      const d = dist[nr * cols + nc] ?? -1;
211
      if (d !== -1 && d < bestD) {
212
        bestD = d;
213
        best = centreOf(nc, nr);
214
      }
215
    }
216
    // Straight to the goal when nothing stands in the way — smoother than
217
    // walking tile centre to tile centre down an open corridor.
218
    if (best && !wallBetween(from, to) && (walkDistance(from, to) ?? 99) < 6) return to;
219
    return best ?? to;
220
  }
221
222
  return {
223
    lines,
224
    cols,
225
    rows,
226
    tile,
227
    width: cols * tile,
228
    height: rows * tile,
229
    at,
230
    solidTile,
231
    tileOf,
232
    centreOf,
233
    solidAt,
234
    cellAt,
235
    cellsOf,
236
    moveCircle,
237
    blockedCircle,
238
    wallBetween,
239
    rayHitWall,
240
    walkDistance,
241
    pathStep,
242
    clearPaths,
243
    fieldsBuilt,
244
  };
245
}
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts