@jgengine/core 0.31.0 → 0.33.0

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.
@@ -48,34 +48,91 @@ export function solidObstacleReach(ctx) {
48
48
  }
49
49
  /** {@link solidObstacleReach} against a bare source and caller-owned cache. */
50
50
  export function sourceObstacleReach(source, cache) {
51
+ ensureSourceCache(source, cache);
52
+ return cache.value;
53
+ }
54
+ /** Coarse XZ cell for the obstacle index: a movement box spans tens of metres, so 1 m cells would be
55
+ * almost all empty lookups while a handful of these cover the whole query. */
56
+ const INDEX_CELL_SIZE = 16;
57
+ /** Beyond this many coarse cells a query walks the whole group list instead. */
58
+ const INDEX_CELL_VOLUME_LIMIT = 4096;
59
+ /** Two signed axis indices packed into one small-integer key (no string allocation, no boxing). */
60
+ const INDEX_KEY_RADIX = 32768;
61
+ const INDEX_KEY_BIAS = 16384;
62
+ const INDEX_KEY_LIMIT = 16384;
63
+ function indexCell(value) {
64
+ return Math.floor(value / INDEX_CELL_SIZE);
65
+ }
66
+ function indexKey(cx, cz) {
67
+ return (cx + INDEX_KEY_BIAS) * INDEX_KEY_RADIX + (cz + INDEX_KEY_BIAS);
68
+ }
69
+ /**
70
+ * Refresh `cache` for `source`: one scan resolves the broadphase reach and, when the source reports a
71
+ * version, every blocking object's obstacles into a coarse XZ index. A versioned source is scanned only
72
+ * when that version changes, so a per-frame query costs index lookups instead of a world scan.
73
+ */
74
+ function ensureSourceCache(source, cache) {
75
+ const version = source.version?.();
76
+ if (version !== undefined) {
77
+ if (cache.version === version && cache.index !== undefined)
78
+ return;
79
+ cache.version = version;
80
+ cache.count = -1;
81
+ cache.index = undefined;
82
+ buildSourceCache(source, cache, source.list(), true);
83
+ return;
84
+ }
51
85
  const objects = source.list();
52
86
  if (cache.count === objects.length)
53
- return cache.value;
87
+ return;
88
+ cache.count = objects.length;
89
+ cache.version = undefined;
90
+ cache.index = undefined;
91
+ buildSourceCache(source, cache, objects, false);
92
+ }
93
+ function buildSourceCache(source, cache, objects, index) {
94
+ const groups = index ? [] : EMPTY_GROUPS;
95
+ const cells = index ? new Map() : null;
54
96
  let horizontal = OBSTACLE_MAX_HALF_EXTENT;
55
97
  let vertical = OBSTACLE_VERTICAL_FLOOR;
56
98
  for (const object of objects) {
57
99
  const set = source.collidersOf(object.instanceId);
58
- if (set === null)
59
- continue;
60
- for (const collider of resolveColliders(set)) {
61
- if (!collider.blocks || collider.purpose !== "physical")
62
- continue;
63
- const shape = collider.shape;
64
- const offset = shape.offset;
65
- const ox = offset !== undefined ? Math.abs(offset[0]) : 0;
66
- const oy = offset !== undefined ? Math.abs(offset[1]) : 0;
67
- const oz = offset !== undefined ? Math.abs(offset[2]) : 0;
68
- if (shape.kind === "sphere") {
69
- horizontal = Math.max(horizontal, ox + oz + shape.radius);
70
- vertical = Math.max(vertical, oy + shape.radius);
71
- }
72
- else {
73
- // Horizontal bound sums both axes so any yaw of the box stays covered.
74
- const h = shape.halfExtents;
75
- horizontal = Math.max(horizontal, ox + oz + h[0] + h[2]);
76
- vertical = Math.max(vertical, oy + h[1]);
100
+ const obstacles = [];
101
+ if (set === null) {
102
+ // No resolved collider: preserve the default 1×1×1 box.
103
+ if (index)
104
+ obstacles.push({ position: object.position });
105
+ }
106
+ else {
107
+ for (const collider of resolveColliders(set)) {
108
+ if (!collider.blocks || collider.purpose !== "physical")
109
+ continue;
110
+ const reach = blockingReach(collider);
111
+ horizontal = Math.max(horizontal, reach[0]);
112
+ vertical = Math.max(vertical, reach[1]);
113
+ if (index)
114
+ obstacles.push(obstacleFromCollider(collider, object.position, object.rotationY));
77
115
  }
78
116
  }
117
+ if (!index || obstacles.length === 0)
118
+ continue;
119
+ const group = {
120
+ x: object.position[0],
121
+ y: object.position[1],
122
+ z: object.position[2],
123
+ obstacles,
124
+ };
125
+ groups.push(group);
126
+ const cx = indexCell(group.x);
127
+ const cz = indexCell(group.z);
128
+ if (cx <= -INDEX_KEY_LIMIT || cx >= INDEX_KEY_LIMIT || cz <= -INDEX_KEY_LIMIT || cz >= INDEX_KEY_LIMIT)
129
+ continue;
130
+ const key = indexKey(cx, cz);
131
+ const bucket = cells.get(key);
132
+ if (bucket === undefined)
133
+ cells.set(key, [group]);
134
+ else
135
+ bucket.push(group);
79
136
  }
80
137
  const value = {
81
138
  horizontal: Math.min(horizontal, OBSTACLE_HORIZONTAL_CAP),
@@ -83,7 +140,56 @@ export function sourceObstacleReach(source, cache) {
83
140
  };
84
141
  cache.count = objects.length;
85
142
  cache.value = value;
86
- return value;
143
+ if (index)
144
+ cache.index = { cells: cells, groups };
145
+ }
146
+ const EMPTY_GROUPS = [];
147
+ /** A blocking collider's center-to-face reach, split so tower height cannot inflate the XZ query. */
148
+ function blockingReach(collider) {
149
+ const shape = collider.shape;
150
+ const offset = shape.offset;
151
+ const ox = offset !== undefined ? Math.abs(offset[0]) : 0;
152
+ const oy = offset !== undefined ? Math.abs(offset[1]) : 0;
153
+ const oz = offset !== undefined ? Math.abs(offset[2]) : 0;
154
+ if (shape.kind === "sphere")
155
+ return [ox + oz + shape.radius, oy + shape.radius];
156
+ // Horizontal bound sums both axes so any yaw of the box stays covered.
157
+ const h = shape.halfExtents;
158
+ return [ox + oz + h[0] + h[2], oy + h[1]];
159
+ }
160
+ /** Every indexed obstacle whose object centre lies inside `min`..`max` — the set the broadphase promises. */
161
+ function indexedObstaclesNear(index, min, max, out) {
162
+ const minCx = indexCell(min[0]);
163
+ const maxCx = indexCell(max[0]);
164
+ const minCz = indexCell(min[2]);
165
+ const maxCz = indexCell(max[2]);
166
+ const volume = Math.max(0, maxCx - minCx + 1) * Math.max(0, maxCz - minCz + 1);
167
+ if (volume > INDEX_CELL_VOLUME_LIMIT ||
168
+ minCx <= -INDEX_KEY_LIMIT || maxCx >= INDEX_KEY_LIMIT ||
169
+ minCz <= -INDEX_KEY_LIMIT || maxCz >= INDEX_KEY_LIMIT) {
170
+ for (const group of index.groups)
171
+ if (groupInBox(group, min, max))
172
+ pushGroup(group, out);
173
+ return;
174
+ }
175
+ for (let cx = minCx; cx <= maxCx; cx += 1) {
176
+ const rowBase = (cx + INDEX_KEY_BIAS) * INDEX_KEY_RADIX + minCz + INDEX_KEY_BIAS;
177
+ for (let cz = minCz; cz <= maxCz; cz += 1) {
178
+ const bucket = index.cells.get(rowBase + (cz - minCz));
179
+ if (bucket === undefined)
180
+ continue;
181
+ for (const group of bucket)
182
+ if (groupInBox(group, min, max))
183
+ pushGroup(group, out);
184
+ }
185
+ }
186
+ }
187
+ function groupInBox(group, min, max) {
188
+ return group.x >= min[0] && group.x <= max[0] && group.y >= min[1] && group.y <= max[1] && group.z >= min[2] && group.z <= max[2];
189
+ }
190
+ function pushGroup(group, out) {
191
+ for (const obstacle of group.obstacles)
192
+ out.push(obstacle);
87
193
  }
88
194
  /** Yaw-rotate an entity-local AABB about the object origin and re-fit it to an axis-aligned box, expressed
89
195
  * as `min`/`max` relative to `position`. Conservative: the rotated box is grown to its enclosing AABB. */
@@ -188,7 +294,7 @@ export function obstacleFromSolid(solid) {
188
294
  * @capability solid-obstacles the one blocking-geometry query the player resolver and NPC movers share
189
295
  */
190
296
  export function solidObstaclesNear(ctx, position, reachX, reachZ, height = WALKER_HEIGHT) {
191
- const obstacles = sourceObstaclesNear(ctx.scene.object, solidObstacleReach(ctx), position, reachX, reachZ, height);
297
+ const obstacles = sourceObstaclesNear(ctx.scene.object, solidObstacleReach(ctx), position, reachX, reachZ, height, reachCache(ctx));
192
298
  appendWorldSolidObstacles(obstacles, ctx.world.solids, position, reachX, reachZ, height);
193
299
  return obstacles;
194
300
  }
@@ -199,8 +305,12 @@ function appendWorldSolidObstacles(obstacles, solids, position, reachX, reachZ,
199
305
  obstacles.push(obstacleFromSolid(solid));
200
306
  }
201
307
  }
202
- /** {@link solidObstaclesNear} against a bare source and an already-resolved reach. */
203
- export function sourceObstaclesNear(source, reach, position, reachX, reachZ, height = WALKER_HEIGHT) {
308
+ /**
309
+ * {@link solidObstaclesNear} against a bare source and an already-resolved reach. Pass the source's
310
+ * {@link ObstacleReachCache} to use the version-keyed obstacle index; a source that reports no
311
+ * `version()` keeps the per-query `inBox` gather.
312
+ */
313
+ export function sourceObstaclesNear(source, reach, position, reachX, reachZ, height = WALKER_HEIGHT, cache) {
204
314
  const min = [
205
315
  position[0] - reachX - reach.horizontal,
206
316
  position[1] - reach.vertical,
@@ -212,6 +322,15 @@ export function sourceObstaclesNear(source, reach, position, reachX, reachZ, hei
212
322
  position[2] + reachZ + reach.horizontal,
213
323
  ];
214
324
  const obstacles = [];
325
+ if (cache !== undefined && source.version !== undefined) {
326
+ ensureSourceCache(source, cache);
327
+ const index = cache.index;
328
+ if (index !== undefined) {
329
+ indexedObstaclesNear(index, min, max, obstacles);
330
+ appendWorldSolidObstacles(obstacles, source.solids, position, reachX, reachZ, height);
331
+ return obstacles;
332
+ }
333
+ }
215
334
  for (const object of source.inBox(min, max)) {
216
335
  const set = source.collidersOf(object.instanceId);
217
336
  if (set === null) {
@@ -233,8 +352,8 @@ export function sourceObstaclesNear(source, reach, position, reachX, reachZ, hei
233
352
  * X/Z it may actually take this tick. A step into a wall is cut on the blocked axis and preserved on the
234
353
  * other, exactly as {@link stepPlayerMovement} resolves the player's.
235
354
  *
236
- * Bounded by the same broadphase as the player's gather: one `inBox` query sized to this step, never a
237
- * full-world scan, so it stays affordable per NPC per tick.
355
+ * Bounded by the same broadphase as the player's gather: one query sized to this step, answered from the
356
+ * source's obstacle index, never a full-world scan, so it stays affordable per NPC per tick.
238
357
  *
239
358
  * The sliding is already axis-separated: X and Z are resolved independently inside, so a caller that
240
359
  * also splits its own movement by axis must hand this exactly one axis per call (`stepZ: 0`, then
@@ -265,7 +384,7 @@ export function resolveSourceWalkerStep(source, cache, position, stepX, stepZ, o
265
384
  if (stepX === 0 && stepZ === 0)
266
385
  return { stepX, stepZ };
267
386
  const radius = options.radius ?? DEFAULT_OBSTACLE_PLAYER_RADIUS;
268
- const obstacles = sourceObstaclesNear(source, sourceObstacleReach(source, cache), position, Math.abs(stepX) + radius, Math.abs(stepZ) + radius);
387
+ const obstacles = sourceObstaclesNear(source, sourceObstacleReach(source, cache), position, Math.abs(stepX) + radius, Math.abs(stepZ) + radius, WALKER_HEIGHT, cache);
269
388
  return slideStep(position, stepX, stepZ, obstacles, radius, options.stepUpHeight ?? 0);
270
389
  }
271
390
  /** {@link resolveWalkerStep} against an already-gathered obstacle set. */
@@ -68,6 +68,7 @@ export function createCombatSubsystem(d) {
68
68
  absorbed: Math.max(0, application.incoming - application.dealt),
69
69
  remaining: application.remaining,
70
70
  lethal: application.lethal,
71
+ ...(application.at === undefined ? {} : { position: application.at }),
71
72
  });
72
73
  },
73
74
  onLethal(instanceId, lethalCtx) {
@@ -51,20 +51,22 @@ export function createSceneSubsystem(d) {
51
51
  return object === null ? undefined : content.objectById?.(object.catalogId);
52
52
  }
53
53
  // `moveToward` reads the same blocking colliders the player resolver does, so a chaser stops at the
54
- // wall that stops the player rather than lerping through it.
54
+ // wall that stops the player rather than lerping through it. The obstacle index behind it rebuilds
55
+ // only when this version moves: an object placed, moved, rotated or re-collidered.
55
56
  const walkerReach = createObstacleReachCache();
56
57
  const solidSource = {
57
58
  solids: d.solids,
58
59
  list: () => objects.list(),
59
60
  inBox: (min, max) => objects.inBox(min, max),
60
61
  collidersOf: (instanceId) => objectCollidersOf(instanceId),
62
+ version: () => objects.version(),
61
63
  };
62
64
  const spatial = createSpatialApi({
63
65
  resolvePosition: (instanceId) => entities.get(instanceId)?.position,
64
66
  candidates: entities.ids,
65
67
  grid: { cellSize: 8 },
66
68
  incremental: true,
67
- resolveStep: (position, stepX, stepZ) => slideStep(position, stepX, stepZ, sourceObstaclesNear(solidSource, sourceObstacleReach(solidSource, walkerReach), position, Math.abs(stepX) + DEFAULT_OBSTACLE_PLAYER_RADIUS, Math.abs(stepZ) + DEFAULT_OBSTACLE_PLAYER_RADIUS)),
69
+ resolveStep: (position, stepX, stepZ) => slideStep(position, stepX, stepZ, sourceObstaclesNear(solidSource, sourceObstacleReach(solidSource, walkerReach), position, Math.abs(stepX) + DEFAULT_OBSTACLE_PLAYER_RADIUS, Math.abs(stepZ) + DEFAULT_OBSTACLE_PLAYER_RADIUS, undefined, walkerReach)),
68
70
  ...(occluder !== undefined ? { occluder } : {}),
69
71
  });
70
72
  const entityColliders = new Map();
@@ -123,6 +125,7 @@ export function createSceneSubsystem(d) {
123
125
  return true;
124
126
  }
125
127
  function reportObjectCollisionMesh(catalogId, mesh) {
128
+ objects.markSpatialChange();
126
129
  if (mesh === null) {
127
130
  const existed = meshObjectByCatalogId.delete(catalogId);
128
131
  if (existed)
@@ -138,6 +141,7 @@ export function createSceneSubsystem(d) {
138
141
  return true;
139
142
  }
140
143
  function reportObjectBounds(catalogId, bounds) {
144
+ objects.markSpatialChange();
141
145
  if (bounds === null) {
142
146
  const existed = measuredObjectByCatalogId.delete(catalogId);
143
147
  if (existed)
@@ -350,6 +354,7 @@ export function createSceneSubsystem(d) {
350
354
  objectColliders.delete(instanceId);
351
355
  else
352
356
  objectColliders.set(instanceId, colliders);
357
+ objects.markSpatialChange();
353
358
  signalNotify();
354
359
  },
355
360
  collidersOf: objectCollidersOf,
@@ -505,6 +505,9 @@ export function createGameContext(options) {
505
505
  setLoop: (id, params) => events.emit("audio.loopSet", { id, ...params }),
506
506
  stopLoop: (id) => events.emit("audio.loopStop", { id }),
507
507
  },
508
+ feedback: {
509
+ cue: (cue, caption) => events.emit("feedback.cue", caption === undefined ? { cue } : { cue, caption }),
510
+ },
508
511
  feed: {
509
512
  bind: (action) => feed.bind(action, events),
510
513
  push(action, entry) {
@@ -1,5 +1,6 @@
1
1
  import type { AuthoredSimulationRuntime } from "../world/authoredSimulation.js";
2
2
  import type { Territory } from "../world/territory.js";
3
+ import type { FeedbackCue } from "../audio/feedbackCues.js";
3
4
  import type { NoticeFeed } from "../game/notices.js";
4
5
  import type { CurrencyDefinition } from "../economy/currency.js";
5
6
  import type { CardPile, CardPileConfig } from "../cards/cardPile.js";
@@ -436,6 +437,15 @@ export interface GameContextRace {
436
437
  /** Lazily creates (on first call, `config` required) or returns the existing reactive race for `id` — discrete mutations and eventful `update` calls bump `ctx.version()`, so HUDs stop hand-managing `store.set` (#286.2). */
437
438
  state(id: string, config?: RaceStateConfig): RaceState;
438
439
  }
440
+ /**
441
+ * The game-announced feel seam on `ctx.game`: name a moment the engine has no event for and the
442
+ * shell plays its cue's sound, rumble, camera kick and caption. The reaction table lives in the
443
+ * shell, so a game never encodes what a hit or a wave should sound like.
444
+ */
445
+ export interface GameFeedback {
446
+ /** Raise `cue` (`"hit" | "hurt" | "kill" | "pickup" | "objective" | "wave" | "deny"`); `caption` replaces the cue's default caption text. Emits `feedback.cue`. */
447
+ cue(cue: FeedbackCue, caption?: string): void;
448
+ }
439
449
  /** Reachable audio seam on `ctx.game`: `play`, `music`, and `resume` route through the `audio.play`/`audio.music`/`audio.resume` events the shell's audio engine listens on, so game code triggers sound without importing the shell. Retained `loop`/`setLoop`/`stopLoop` add id-keyed loops with live pitch/gain control over `audio.loopStart`/`audio.loopSet`/`audio.loopStop` (#1051). */
440
450
  export interface GameAudio {
441
451
  play(sound: string, at?: readonly [number, number, number]): void;
@@ -500,6 +510,12 @@ export interface GameContext {
500
510
  */
501
511
  notices: NoticeFeed;
502
512
  audio: GameAudio;
513
+ /**
514
+ * Announce a gameplay moment the engine has no event for (a wave starting, a scripted beat).
515
+ * The shell answers with the cue's sound, gamepad pulse, camera kick and caption, so a game
516
+ * never encodes the reaction itself.
517
+ */
518
+ feedback: GameFeedback;
503
519
  /** Play a model's one-shot animation clip bound to `event` in its `animation.oneShots` (e.g. an attack swing) — emits `entity.animation` for the shell to pick up. */
504
520
  playEntityAnimation(instanceId: string, event: string): void;
505
521
  feed: GameContextFeed;
@@ -69,6 +69,10 @@ export interface ObjectStore {
69
69
  ids(): readonly string[];
70
70
  at(x: number, y: number, z: number, tolerance?: number): SceneObject[];
71
71
  inBox(min: EntityPosition, max: EntityPosition): readonly SceneObject[];
72
+ /** Bumps whenever membership, a position or a rotation changes — cheap cache key for consumers. */
73
+ version(): number;
74
+ /** Bump {@link version} for a change the store cannot see, such as a consumer-owned collider set. */
75
+ markSpatialChange(): void;
72
76
  clear(): void;
73
77
  subscribe(listener: () => void): () => void;
74
78
  /** Notified only when the object set changes (place/remove), never on move/rotate/setVisual — markers read live poses imperatively. */
@@ -1,15 +1,34 @@
1
1
  import { createObservableKeyedStore } from "../store/observableKeyedStore.js";
2
2
  const CELL_SIZE = 1;
3
3
  /**
4
- * When an `inBox` query spans more than this many 1 m cells, walk the object list instead of the
5
- * cell grid. Tall/wide colliders (city buildings) can inflate movement broadphase to hundreds of
6
- * metres on each axis — at 1 m cells that is O(10⁶) empty `Map.get`s per frame and freezes `pose`.
7
- * Linear scan over a few hundred objects is cheaper the moment the cell volume exceeds the scene.
4
+ * Queries above this many 1 m cells walk the coarse grid instead. A 1 m cell lookup hashes a fresh
5
+ * string while an object test is a few comparisons, so the crossover is tens of cells — not the
6
+ * thousands a "scan every object instead" comparison implied. A city movement box is hundreds.
8
7
  */
9
- const IN_BOX_CELL_VOLUME_LIMIT = 4096;
8
+ const IN_BOX_CELL_VOLUME_LIMIT = 32;
9
+ /**
10
+ * Second index level for those big queries: coarse XZ columns hold the same centre points as the 1 m
11
+ * grid, so a city-scale box walks a few populated columns instead of every object in the world.
12
+ * Beyond {@link COARSE_CELL_VOLUME_LIMIT} (a world-sized query) a list walk is cheaper.
13
+ */
14
+ const COARSE_CELL_SIZE = 16;
15
+ const COARSE_CELL_VOLUME_LIMIT = 4096;
16
+ /**
17
+ * Two signed coarse axes packed into one small-integer key (no string allocation, no heap-number
18
+ * hash): ±16384 cells covers ±262 km at 16 m and the key stays inside a 32-bit SMI.
19
+ */
20
+ const COARSE_KEY_RADIX = 32768;
21
+ const COARSE_KEY_BIAS = 16384;
22
+ const COARSE_KEY_LIMIT = 16384;
10
23
  function cellIndexOf(value) {
11
24
  return Math.floor(value / CELL_SIZE);
12
25
  }
26
+ function coarseIndexOf(value) {
27
+ return Math.floor(value / COARSE_CELL_SIZE);
28
+ }
29
+ function coarseKey(cx, cz) {
30
+ return (cx + COARSE_KEY_BIAS) * COARSE_KEY_RADIX + (cz + COARSE_KEY_BIAS);
31
+ }
13
32
  function cellKey(cx, cy, cz) {
14
33
  return `${cx}:${cy}:${cz}`;
15
34
  }
@@ -23,7 +42,9 @@ function objectInAabb(object, min, max) {
23
42
  export function createObjectStore() {
24
43
  const store = createObservableKeyedStore();
25
44
  const cellIndex = new Map();
45
+ const coarseIndex = new Map();
26
46
  let nextCounter = 1;
47
+ let revision = 0;
27
48
  function generateId() {
28
49
  let id = `object-${nextCounter}`;
29
50
  while (store.has(id)) {
@@ -33,6 +54,32 @@ export function createObjectStore() {
33
54
  nextCounter += 1;
34
55
  return id;
35
56
  }
57
+ function coarseAdd(instanceId, position) {
58
+ const cx = coarseIndexOf(position[0]);
59
+ const cz = coarseIndexOf(position[2]);
60
+ if (cx <= -COARSE_KEY_LIMIT || cx >= COARSE_KEY_LIMIT || cz <= -COARSE_KEY_LIMIT || cz >= COARSE_KEY_LIMIT)
61
+ return;
62
+ const key = coarseKey(cx, cz);
63
+ let bucket = coarseIndex.get(key);
64
+ if (bucket === undefined) {
65
+ bucket = new Set();
66
+ coarseIndex.set(key, bucket);
67
+ }
68
+ bucket.add(instanceId);
69
+ }
70
+ function coarseRemove(instanceId, position) {
71
+ const cx = coarseIndexOf(position[0]);
72
+ const cz = coarseIndexOf(position[2]);
73
+ if (cx <= -COARSE_KEY_LIMIT || cx >= COARSE_KEY_LIMIT || cz <= -COARSE_KEY_LIMIT || cz >= COARSE_KEY_LIMIT)
74
+ return;
75
+ const key = coarseKey(cx, cz);
76
+ const bucket = coarseIndex.get(key);
77
+ if (bucket === undefined)
78
+ return;
79
+ bucket.delete(instanceId);
80
+ if (bucket.size === 0)
81
+ coarseIndex.delete(key);
82
+ }
36
83
  function indexAdd(instanceId, position) {
37
84
  const key = cellKeyOf(position);
38
85
  let bucket = cellIndex.get(key);
@@ -41,15 +88,17 @@ export function createObjectStore() {
41
88
  cellIndex.set(key, bucket);
42
89
  }
43
90
  bucket.add(instanceId);
91
+ coarseAdd(instanceId, position);
44
92
  }
45
93
  function indexRemove(instanceId, position) {
46
94
  const key = cellKeyOf(position);
47
95
  const bucket = cellIndex.get(key);
48
- if (bucket === undefined)
49
- return;
50
- bucket.delete(instanceId);
51
- if (bucket.size === 0)
52
- cellIndex.delete(key);
96
+ if (bucket !== undefined) {
97
+ bucket.delete(instanceId);
98
+ if (bucket.size === 0)
99
+ cellIndex.delete(key);
100
+ }
101
+ coarseRemove(instanceId, position);
53
102
  }
54
103
  return {
55
104
  place(catalogId, x, y, z, options = {}) {
@@ -76,6 +125,7 @@ export function createObjectStore() {
76
125
  ...(options.slots !== undefined ? { slots: options.slots } : {}),
77
126
  });
78
127
  indexAdd(instanceId, position);
128
+ revision += 1;
79
129
  return instanceId;
80
130
  },
81
131
  remove(instanceId) {
@@ -84,6 +134,7 @@ export function createObjectStore() {
84
134
  return false;
85
135
  store.delete(instanceId);
86
136
  indexRemove(instanceId, current.position);
137
+ revision += 1;
87
138
  return true;
88
139
  },
89
140
  move(instanceId, x, y, z) {
@@ -94,6 +145,7 @@ export function createObjectStore() {
94
145
  store.set(instanceId, { ...current, position });
95
146
  indexRemove(instanceId, current.position);
96
147
  indexAdd(instanceId, position);
148
+ revision += 1;
97
149
  return true;
98
150
  },
99
151
  rotate(instanceId, rotationY) {
@@ -101,6 +153,7 @@ export function createObjectStore() {
101
153
  if (!current)
102
154
  return false;
103
155
  store.set(instanceId, { ...current, rotationY });
156
+ revision += 1;
104
157
  return true;
105
158
  },
106
159
  setVisual(instanceId, visual) {
@@ -194,13 +247,39 @@ export function createObjectStore() {
194
247
  const cellsY = Math.max(0, maxCy - minCy + 1);
195
248
  const cellsZ = Math.max(0, maxCz - minCz + 1);
196
249
  const cellVolume = cellsX * cellsY * cellsZ;
197
- // Huge queries (city-scale movement broadphase inflated by tall building colliders): scanning
198
- // every empty 1 m cell is far more expensive than testing the few hundred placed objects.
250
+ // Huge queries (city-scale movement broadphase inflated by tall building colliders): walking
251
+ // every 1 m cell is all empty lookups, so walk the coarse grid instead.
199
252
  if (cellVolume > IN_BOX_CELL_VOLUME_LIMIT) {
253
+ const coarseMinX = coarseIndexOf(min[0]);
254
+ const coarseMaxX = coarseIndexOf(max[0]);
255
+ const coarseMinZ = coarseIndexOf(min[2]);
256
+ const coarseMaxZ = coarseIndexOf(max[2]);
257
+ const coarseVolume = Math.max(0, coarseMaxX - coarseMinX + 1) * Math.max(0, coarseMaxZ - coarseMinZ + 1);
258
+ if (coarseVolume > COARSE_CELL_VOLUME_LIMIT ||
259
+ coarseMinX <= -COARSE_KEY_LIMIT || coarseMaxX >= COARSE_KEY_LIMIT ||
260
+ coarseMinZ <= -COARSE_KEY_LIMIT || coarseMaxZ >= COARSE_KEY_LIMIT) {
261
+ const hits = [];
262
+ for (const object of store.arraySnapshot()) {
263
+ if (objectInAabb(object, min, max))
264
+ hits.push(object);
265
+ }
266
+ return hits;
267
+ }
200
268
  const hits = [];
201
- for (const object of store.arraySnapshot()) {
202
- if (objectInAabb(object, min, max))
203
- hits.push(object);
269
+ for (let cx = coarseMinX; cx <= coarseMaxX; cx += 1) {
270
+ const rowBase = (cx + COARSE_KEY_BIAS) * COARSE_KEY_RADIX + coarseMinZ + COARSE_KEY_BIAS;
271
+ for (let cz = coarseMinZ; cz <= coarseMaxZ; cz += 1) {
272
+ const bucket = coarseIndex.get(rowBase + (cz - coarseMinZ));
273
+ if (bucket === undefined)
274
+ continue;
275
+ for (const instanceId of bucket) {
276
+ const object = store.get(instanceId);
277
+ if (object === undefined)
278
+ continue;
279
+ if (objectInAabb(object, min, max))
280
+ hits.push(object);
281
+ }
282
+ }
204
283
  }
205
284
  return hits;
206
285
  }
@@ -228,6 +307,8 @@ export function createObjectStore() {
228
307
  store.delete(object.instanceId);
229
308
  }
230
309
  cellIndex.clear();
310
+ coarseIndex.clear();
311
+ revision += 1;
231
312
  },
232
313
  subscribe(listener) {
233
314
  return store.subscribe(listener);
@@ -238,6 +319,12 @@ export function createObjectStore() {
238
319
  snapshot() {
239
320
  return store.arraySnapshot();
240
321
  },
322
+ version() {
323
+ return revision;
324
+ },
325
+ markSpatialChange() {
326
+ revision += 1;
327
+ },
241
328
  hydrate(objects) {
242
329
  const incoming = new Set(objects.map((object) => object.instanceId));
243
330
  for (const current of store.arraySnapshot()) {
@@ -253,6 +340,7 @@ export function createObjectStore() {
253
340
  store.set(object.instanceId, object);
254
341
  indexAdd(object.instanceId, object.position);
255
342
  }
343
+ revision += 1;
256
344
  },
257
345
  };
258
346
  }
@@ -1,5 +1,5 @@
1
1
  import { resolveGraphicsProfile } from "./graphicsProfile.js";
2
- import { DEFAULT_GRAPHICS_QUALITY, GRAPHICS_QUALITY_OPTIONS, SETTING_IDS, } from "./settingsModel.js";
2
+ import { asSettingType, DEFAULT_GRAPHICS_QUALITY, GRAPHICS_QUALITY_OPTIONS, SETTING_IDS, } from "./settingsModel.js";
3
3
  /** Ordered stage list used to build settings rows. */
4
4
  export const GRAPHICS_POST_STAGES = ["ao", "bloom", "dof", "smaa"];
5
5
  /** Player-facing labels for each post stage. */
@@ -95,7 +95,8 @@ export function withGraphicsQualityOverride(store, quality, overrides) {
95
95
  ]);
96
96
  return {
97
97
  get(id, fallback) {
98
- return pinned.has(id) ? pinned.get(id) : store.get(id, fallback);
98
+ const pinnedValue = pinned.get(id);
99
+ return pinnedValue === undefined ? store.get(id, fallback) : asSettingType(id, pinnedValue, fallback);
99
100
  },
100
101
  set(id, value) {
101
102
  pinned.delete(id);
@@ -95,6 +95,24 @@ interface WebStorageLike {
95
95
  getItem(key: string): string | null;
96
96
  setItem(key: string, value: string): void;
97
97
  }
98
+ /** A stored setting holds a value the read asked for as another type, so no honest value exists. */
99
+ export declare class SettingsTypeError extends Error {
100
+ readonly settingId: string;
101
+ readonly storedType: string;
102
+ readonly fallbackType: string;
103
+ constructor(settingId: string, storedType: string, fallbackType: string);
104
+ }
105
+ /**
106
+ * Read a stored value as the type the caller asked for. A value of another primitive type converts
107
+ * when the result is the same value in the asked-for type (a number as its digits, a boolean as
108
+ * `"true"`/`"false"`); otherwise it throws {@link SettingsTypeError}, naming the key and both types,
109
+ * instead of handing back the fallback as if nothing were stored.
110
+ */
111
+ export declare function asSettingType<T extends SettingValue>(id: string, stored: unknown, fallback: T): T;
112
+ /**
113
+ * Read one persisted setting as `fallback`'s type. An absent or unreadable cell yields the
114
+ * fallback; a stored value of an incompatible type throws {@link SettingsTypeError}.
115
+ */
98
116
  export declare function loadSettingValue<T extends SettingValue>(id: string, fallback: T, storage?: Pick<WebStorageLike, "getItem"> | null | undefined): T;
99
117
  export declare function saveSettingValue(id: string, value: SettingValue, storage?: Pick<WebStorageLike, "setItem"> | null | undefined): void;
100
118
  export interface SettingsStore {