@xtia/grid 0.0.1 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,156 @@
1
+ # `Grid`
2
+
3
+ ## Summary
4
+
5
+ A mutable 2D grid with efficient region views, spatial operations, and seamless interoperability with [Pipe2D](https://github.com/tiadrop/pipe2d). Store, edit, and transform 2D data with an ergonomic API designed for practical use.
6
+
7
+ ## Features
8
+
9
+ - **Mutable 2D storage** with type safety
10
+ - **Zero-copy region views** - edit or provide subgrids and transformation layers without copying
11
+ - **Spatial operations** - fill, paste, flood fill, pathfinding
12
+ - **Pipe2D integration** - materialise pipes, get pipe views
13
+ - **Consistent API** - for grids and regions
14
+ - **Mask support** - apply operations to arbitrary shapes
15
+
16
+ ## Example
17
+
18
+ ```
19
+ npm i @xtia/grid
20
+ ```
21
+
22
+ ```ts
23
+ import { Grid } from "@xtia/grid";
24
+
25
+ // initialise a 30x20 Grid<number> of 0's
26
+ const numGrid = Grid.solid(30, 20, 0);
27
+
28
+ // change a value
29
+ numGrid.set(3, 3, 50);
30
+
31
+ // initialise a chessboard
32
+ const chessGrid = Grid.init(8, 8, (x, y) =>
33
+ (x + y) % 2 === 0 ? 'black' : 'white'
34
+ );
35
+
36
+ // read a value
37
+ const colour = chessGrid.get(4, 5);
38
+
39
+ // initialise a grid from a Pipe2D
40
+ const source = imagePipe
41
+ .crop(10, 10, 64, 64)
42
+ .scale(.5)
43
+ .rotateLeft();
44
+ const grid = Grid.from(source);
45
+ ```
46
+
47
+ ## Regions
48
+
49
+ Use `grid.region(x, y, w, h)` to define a subgrid. The subgrid is a **view** into the parent; changes to the subgrid affect the parent and vice-versa.
50
+
51
+ ```ts
52
+ // game map with terrain
53
+ const world = Grid.solid(100, 100, 0); // 0 = grass
54
+
55
+ // add a lake
56
+ world.region(30, 30, 20, 20).fill(1); // 1 = water
57
+
58
+ // add mountains around the lake with a circular mask
59
+ const centre = world.cells.get(40, 40);
60
+ const mask = (x: number, y: number) => {
61
+ const dist = Math.hypot(x - centre.x, y - centre.y);
62
+ return dist > 12 && dist < 18; // mountain ring
63
+ };
64
+ world.fill(2, mask); // 2 = mountain
65
+
66
+ // get a view of just the interesting area
67
+ const lakeRegion = world.region(25, 25, 30, 30);
68
+
69
+ // do we want a distinct, self-contained copy of the region?
70
+ const lakeGrid = Grid.from(lakeRegion);
71
+ ```
72
+
73
+ ## Transformation Layers
74
+
75
+ `grid.map(read, write)` creates a **view** that reads and writes the parent through the provided transformation functions.
76
+
77
+ ```ts
78
+ const sprites = ["grass.png", "water.png", "mountain.png", "forest.png"];
79
+ const spriteMap = world.map(
80
+ n => sprites[n],
81
+ s => sprites.indexOf(s)
82
+ );
83
+
84
+ world.set(0, 0, 2); // modify the parent
85
+ console.log(spriteMap.get(0, 0)); // read the transformed view: "mountain.png"
86
+ spriteMap.set(1, 0, "forest.png"); // modify the view
87
+ console.log(world.get(1, 0)); // read the parent: 3
88
+ ```
89
+
90
+ For one-way read-mapping, use `grid.pipe` - a [Pipe2D](https://github.com/tiadrop/pipe2d) view into the grid's data.
91
+
92
+ ## Cell interface
93
+
94
+ `grid.cells` provides a Pipe2D, for convenient transformation, of live-view interfaces relating to positions in the grid.
95
+
96
+ ```ts
97
+ const topLeft = world.cells.get(0, 0);
98
+ console.log(topLeft.value); // 2
99
+
100
+ topLeft.value = 3; // modifies the underlying data
101
+ console.log(spriteMap.get(0, 0)); // now "forest.png";
102
+
103
+ // navigate by offset
104
+ const adjacentCell = topLeft.look(1, 0);
105
+ console.log(adjacentCell.x, adjacentCell.y); // 1, 0
106
+ ```
107
+
108
+ Cells provide methods for locational utilities such as pathfinding and visibility mapping.
109
+
110
+ Cells are unique to, owned by, and coordinated relative to the view that provided them. Their pathfinding and visibility mapping features are unaware of space outside of their view's bounds.
111
+
112
+ ### Path finding
113
+ ```
114
+ const costs = [1, Infinity, Infinity, 2]; // grass=1, water/mountain=impassable, forest=2
115
+
116
+ const start = world.cells.get(5, 5);
117
+ const destination = world.cells.get(90, 90);
118
+ const path = start.findPath(
119
+ destination,
120
+ (cell) => costs[cell.value]
121
+ ); // Cell<number>[]
122
+ ```
123
+
124
+ ### Visibility mapping
125
+
126
+ ```ts
127
+ const visibility = start.createVisibilityMap(
128
+ cell => cell.value == 0 // anything except grass blocks vision
129
+ );
130
+
131
+ // any 2d source, including visibility maps, can be used as a mask
132
+ // paste(source: Source2D<T>, mask?: Source2D<boolean>)
133
+ screenGrid.paste(spriteMap, visibility.map(v => v != Visibility.hidden));
134
+
135
+ // using pipe2d's convenience
136
+ const mountainCells = world.cells.toFlatArrayXY().filter(c => c.value === 2);
137
+ ```
138
+
139
+ ### Reusing path data
140
+
141
+ Use `cell.getPathMap(costFunc)` to create a Pipe2D of optimal paths from the parent cell. The grid space is explored once to produce an internal traversal map, and paths to individual cells are constructed form that data (and cached) when that pipe is queried.
142
+
143
+ ```ts
144
+ const pathMap = start.getPathMap(c => costs[c]);
145
+
146
+ const pathToCentre = pathMap.get(50, 50);
147
+ const pathToCorner = pathMap.get(99, 99);
148
+ ```
149
+
150
+ ## The storage layer
151
+
152
+ `Grid` itself is an interface for reading and writing 2D data. `GridBase` - a subclass of `Grid` - maintains the actual storage of such data.
153
+
154
+ Although the Grid factory methods (`Grid.solid()`, `Grid.from()`, `Grid.init()`) return a `GridBase<T>`, the mental model is that we're simply working with `Grid`s, therefore those factory methods live on `Grid`. The only API distinction is that `GridBase` provides a 'change' event, via `grid.on("change", handler)`.
155
+
156
+ You can perform batched updates, holding off the 'change' event until the batch process concludes, with `grid.batchUpdate(callback)`.
package/lib/grid.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Pipe2D } from "@xtia/pipe2d";
2
- import { Angle, GetXYFunc, Source2D } from "./utils.js";
2
+ import { GetXYFunc, Source2D } from "./utils.js";
3
3
  type ChangeEvent<T> = {
4
4
  changedCells: Set<Cell<T>>;
5
5
  };
@@ -54,41 +54,28 @@ export declare class Cell<T> {
54
54
  export declare class Grid<T> {
55
55
  readonly width: number;
56
56
  readonly height: number;
57
- private batch;
58
57
  private parentGet;
59
58
  private parentSet;
59
+ private batch;
60
60
  readonly cells: Pipe2D<Cell<T>>;
61
- protected constructor(width: number, height: number, get: GetXYFunc<T>, set: (x: number, y: number, value: T) => void, batch: (cb: () => void) => void);
62
- private writeMasks;
61
+ protected constructor(width: number, height: number, parentGet: GetXYFunc<T>, parentSet: (x: number, y: number, value: T) => void, batch: (cb: () => void) => void);
63
62
  pipe: Pipe2D<T>;
64
- private isWritable;
65
63
  batchUpdate(fn: () => void): void;
66
64
  get(x: number, y: number): T;
67
65
  set(x: number, y: number, value: T): void;
68
66
  trySet(x: number, y: number, value: T): boolean;
69
- fill(value: T): void;
70
- paste(x: number, y: number, source: Source2D<T>): void;
71
- applyWriteMask(mask: Source2D<boolean>): () => void;
67
+ fill(value: T, mask?: Source2D<boolean> | GetXYFunc<boolean>): void;
68
+ paste(x: number, y: number, source: Source2D<T>, mask?: Source2D<boolean> | GetXYFunc<boolean>): void;
72
69
  [Symbol.iterator](): IterableIterator<{
73
70
  x: number;
74
71
  y: number;
75
72
  value: T;
76
73
  }>;
77
74
  forEach(callback: (value: T, x: number, y: number) => void): void;
78
- getVisibilityMap(originX: number, originY: number, maxDistance: number, isWall: (value: T, x: number, y: number) => boolean, options?: {
79
- startAngle?: Angle;
80
- endAngle?: Angle;
81
- includeWalls?: boolean;
82
- angleStep?: Angle;
83
- }): Pipe2D<boolean>;
84
- getFloodMap(startX: number, startY: number, predicate: (value: T, x: number, y: number, initialValue: T) => boolean, options?: {
85
- includeDiagonals?: boolean;
86
- maxDistance?: number;
87
- }): Pipe2D<boolean>;
88
75
  combine<U, R>(aux: Source2D<U>, cb: (source: T, aux: U) => R): GridBase<R>;
89
76
  map<U>(read: (initial: T, x: number, y: number) => U, write: (local: U, x: number, y: number) => T): Grid<U>;
90
77
  region(x: number, y: number, width: number, height: number): Grid<T>;
91
- static init<T>(width: number, height: number, initCell: () => T): GridBase<T>;
78
+ static init<T>(width: number, height: number, initCell: (x: number, y: number) => T): GridBase<T>;
92
79
  static from<T>(source: Source2D<T>): GridBase<T>;
93
80
  static solid<T>(width: number, height: number, fillValue: T): GridBase<T>;
94
81
  }
@@ -99,7 +86,6 @@ export declare class GridBase<T> extends Grid<T> {
99
86
  private batchState;
100
87
  batchUpdate(fn: () => void): void;
101
88
  constructor(source: Source2D<T>);
102
- constructor(width: number, height: number, cellInit: GetXYFunc<T>);
103
89
  on<K extends keyof EventMap<T>>(eventName: K, handler: (data: EventMap<T>[K]) => void): () => void;
104
90
  private xyToIndex;
105
91
  private _set;
package/lib/grid.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Pipe2D } from "@xtia/pipe2d";
2
- import { angleToRadians, OrderedQueue } from "./utils.js";
2
+ import { OrderedQueue } from "./utils.js";
3
3
  export var TraversalType;
4
4
  (function (TraversalType) {
5
5
  TraversalType[TraversalType["cardinal"] = 0] = "cardinal";
@@ -177,6 +177,8 @@ export class Cell {
177
177
  }
178
178
  createVisibilityMap(isWall, maxDistance = Infinity) {
179
179
  return new Pipe2D(this.gridView.width, this.gridView.height, (x, y) => {
180
+ if (isWall(this))
181
+ return Visibility.hidden;
180
182
  const distance = getDistance(this, { x, y });
181
183
  if (distance > maxDistance) {
182
184
  return Visibility.hidden;
@@ -200,20 +202,16 @@ function getDistance(c1, c2) {
200
202
  return Math.sqrt(Math.pow(c2.x - c1.x, 2) + Math.pow(c2.y - c1.y, 2));
201
203
  }
202
204
  export class Grid {
203
- constructor(width, height, get, set, batch) {
205
+ constructor(width, height, parentGet, parentSet, batch) {
204
206
  this.width = width;
205
207
  this.height = height;
208
+ this.parentGet = parentGet;
209
+ this.parentSet = parentSet;
206
210
  this.batch = batch;
207
211
  this.cells = new Pipe2D(this.width, this.height, (x, y) => {
208
212
  return new Cell(x, y, this);
209
213
  }).strict().withCache();
210
- this.writeMasks = [];
211
214
  this.pipe = new Pipe2D(this);
212
- this.parentGet = get;
213
- this.parentSet = set;
214
- }
215
- isWritable(x, y) {
216
- return !this.writeMasks.some(mask => !mask.mask.get(x, y));
217
215
  }
218
216
  batchUpdate(fn) {
219
217
  this.batch(fn);
@@ -228,8 +226,7 @@ export class Grid {
228
226
  if (x < 0 || x >= this.width || y < 0 || y >= this.height) {
229
227
  throw new RangeError("Coordinates out of bounds");
230
228
  }
231
- if (this.isWritable(x, y))
232
- this.parentSet(x, y, value);
229
+ this.parentSet(x, y, value);
233
230
  }
234
231
  trySet(x, y, value) {
235
232
  if (x < 0 || x >= this.width || y < 0 || y >= this.height)
@@ -237,39 +234,41 @@ export class Grid {
237
234
  this.set(x, y, value);
238
235
  return true;
239
236
  }
240
- fill(value) {
237
+ fill(value, mask) {
238
+ const maskSource = typeof mask == "function" ? {
239
+ width: this.width,
240
+ height: this.height,
241
+ get: mask
242
+ } : mask;
241
243
  this.batchUpdate(() => {
242
244
  for (let y = 0; y < this.height; y++) {
243
245
  for (let x = 0; x < this.width; x++) {
244
- const tx = x;
245
- const ty = y;
246
- this.trySet(tx, ty, value);
246
+ if (maskSource && !maskSource.get(x, y))
247
+ continue;
248
+ this.trySet(x, y, value);
247
249
  }
248
250
  }
249
251
  });
250
252
  }
251
- paste(x, y, source) {
253
+ paste(x, y, source, mask) {
254
+ const maskSource = typeof mask == "function" ? {
255
+ width: this.width,
256
+ height: this.height,
257
+ get: mask
258
+ } : mask;
252
259
  const cachedSource = new GridBase(source);
253
260
  this.batchUpdate(() => {
254
261
  for (let oy = 0; oy < source.height; oy++) {
255
262
  for (let ox = 0; ox < source.width; ox++) {
256
263
  const tx = ox + x;
257
264
  const ty = oy + y;
265
+ if (maskSource && !maskSource.get(tx, ty))
266
+ continue;
258
267
  this.trySet(tx, ty, cachedSource.get(ox, oy));
259
268
  }
260
269
  }
261
270
  });
262
271
  }
263
- applyWriteMask(mask) {
264
- const unique = { mask };
265
- this.writeMasks.push(unique);
266
- return () => {
267
- const idx = this.writeMasks.indexOf(unique);
268
- if (idx == -1)
269
- throw new Error("Mask was not present");
270
- this.writeMasks.splice(idx, 1);
271
- };
272
- }
273
272
  *[Symbol.iterator]() {
274
273
  for (let y = 0; y < this.height; y++) {
275
274
  for (let x = 0; x < this.width; x++) {
@@ -284,91 +283,15 @@ export class Grid {
284
283
  }
285
284
  }
286
285
  }
287
- getVisibilityMap(originX, originY, maxDistance, isWall, options) {
288
- const { startAngle = { asTurns: 0 }, endAngle = { asTurns: 1 }, includeWalls = false, angleStep = { asDegrees: 1 } } = options || {};
289
- const visibilityGrid = new GridBase(this.width, this.height, () => false);
290
- const startRad = angleToRadians(startAngle);
291
- const endRad = angleToRadians(endAngle);
292
- const stepRad = angleToRadians(angleStep);
293
- const totalAngle = endRad > startRad
294
- ? endRad - startRad
295
- : endRad + (2 * Math.PI) - startRad;
296
- const effectiveStepRad = Math.max(0.001, Math.min(stepRad, totalAngle));
297
- const steps = Math.ceil(totalAngle / effectiveStepRad);
298
- for (let i = 0; i <= steps; i++) {
299
- const angle = startRad + i * effectiveStepRad;
300
- if (angle > endRad && i > 0)
301
- break;
302
- const endX = Math.round(originX + maxDistance * Math.cos(angle));
303
- const endY = Math.round(originY + maxDistance * Math.sin(angle));
304
- let x0 = originX;
305
- let y0 = originY;
306
- let x1 = endX;
307
- let y1 = endY;
308
- const dx = Math.abs(x1 - x0);
309
- const dy = Math.abs(y1 - y0);
310
- const sx = x0 < x1 ? 1 : -1;
311
- const sy = y0 < y1 ? 1 : -1;
312
- let err = dx - dy;
313
- while (true) {
314
- if (x0 < 0 || x0 >= this.width || y0 < 0 || y0 >= this.height) {
315
- break;
316
- }
317
- const isWallCell = isWall(this.get(x0, y0), x0, y0);
318
- if (isWallCell) {
319
- if (includeWalls) {
320
- visibilityGrid.set(x0, y0, true);
321
- }
322
- break;
323
- }
324
- else {
325
- visibilityGrid.set(x0, y0, true);
326
- }
327
- if (x0 === x1 && y0 === y1)
328
- break;
329
- const e2 = 2 * err;
330
- if (e2 > -dy) {
331
- err -= dy;
332
- x0 += sx;
333
- }
334
- if (e2 < dx) {
335
- err += dx;
336
- y0 += sy;
337
- }
338
- }
339
- }
340
- return visibilityGrid.pipe;
341
- }
342
- getFloodMap(startX, startY, predicate, options) {
343
- const { includeDiagonals = false, maxDistance = Infinity } = options || {};
344
- const result = new GridBase(this.width, this.height, () => false);
345
- const startValue = this.get(startX, startY);
346
- if (!predicate(startValue, startX, startY, startValue)) {
347
- return result.pipe;
348
- }
349
- const queue = [[startX, startY, 0]];
350
- result.set(startX, startY, true);
351
- while (queue.length > 0) {
352
- const [x, y, distance] = queue.shift();
353
- if (distance >= maxDistance)
354
- continue;
355
- this.cells.get(x, y).getNeighbours(includeDiagonals)
356
- .forEach(neighbour => {
357
- if (result.get(neighbour.x, neighbour.y))
358
- return;
359
- if (predicate(neighbour.value, neighbour.x, neighbour.y, startValue)) {
360
- result.set(neighbour.x, neighbour.y, true);
361
- queue.push([neighbour.x, neighbour.y, distance + 1]);
362
- }
363
- });
364
- }
365
- return result.pipe;
366
- }
367
286
  combine(aux, cb) {
368
287
  const width = Math.min(this.width, aux.width);
369
288
  const height = Math.min(this.height, aux.height);
370
- return new GridBase(width, height, (x, y) => {
371
- return cb(this.get(x, y), aux.get(x, y));
289
+ return new GridBase({
290
+ width,
291
+ height,
292
+ get: (x, y) => {
293
+ return cb(this.get(x, y), aux.get(x, y));
294
+ }
372
295
  });
373
296
  }
374
297
  map(read, write) {
@@ -384,7 +307,11 @@ export class Grid {
384
307
  }, fn => this.batch(fn));
385
308
  }
386
309
  static init(width, height, initCell) {
387
- return new GridBase(width, height, initCell);
310
+ return new GridBase({
311
+ width,
312
+ height,
313
+ get: initCell
314
+ });
388
315
  }
389
316
  static from(source) {
390
317
  return new GridBase(source);
@@ -416,22 +343,14 @@ export class GridBase extends Grid {
416
343
  this.triggerChange(changed);
417
344
  }
418
345
  }
419
- constructor(widthOrSource, height, cellInit) {
420
- const source = typeof widthOrSource == "object"
421
- ? widthOrSource
422
- : {
423
- width: widthOrSource,
424
- height: height,
425
- get: cellInit
426
- };
346
+ constructor(source) {
427
347
  super(source.width, source.height, (x, y) => this.data[this.xyToIndex(x, y)], (x, y, value) => this._set(x, y, value), fn => this.batchUpdate(fn));
428
348
  this.eventHandlers = {};
429
349
  this.batchState = null;
430
- this.data = Array.from({ length: source.width * source.height }, (_, i) => {
431
- const x = i % source.width;
432
- const y = Math.floor(i / source.width);
433
- return source.get(x, y);
434
- });
350
+ const sourcePipe = source instanceof Pipe2D
351
+ ? source
352
+ : new Pipe2D(source);
353
+ this.data = sourcePipe.toFlatArrayXY();
435
354
  }
436
355
  on(eventName, handler) {
437
356
  const unique = { fn: handler };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
- "version": "0.0.1",
3
- "description": "2D store and utilities",
2
+ "version": "0.0.3",
3
+ "description": "2D store and utilities, with zero-copy regions and transform layers, pathfinding, Pipe2D interop",
4
4
  "name": "@xtia/grid",
5
5
  "exports": {
6
6
  ".": {
@@ -11,16 +11,14 @@
11
11
  },
12
12
  "scripts": {
13
13
  "build": "npx tsc",
14
- "test": "npx jest"
14
+ "test": "npx vitest run"
15
15
  },
16
16
  "files": [
17
17
  "lib/"
18
18
  ],
19
19
  "devDependencies": {
20
- "@swc/jest": "^0.2.39",
21
- "@types/jest": "^30.0.0",
22
- "jest": "^30.4.2",
23
- "typescript": "^7.0.2"
20
+ "typescript": "^7.0.2",
21
+ "vitest": "^4.1.10"
24
22
  },
25
23
  "dependencies": {
26
24
  "@xtia/pipe2d": "^2.1.0"