@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.
- package/CHANGELOG.md +67 -0
- package/dist/audio/feedbackCues.d.ts +30 -0
- package/dist/audio/feedbackCues.js +119 -0
- package/dist/combat/damageRuntime.d.ts +10 -1
- package/dist/combat/damageRuntime.js +8 -1
- package/dist/combat/effects.d.ts +2 -0
- package/dist/combat/effects.js +2 -0
- package/dist/devtools/report.d.ts +13 -0
- package/dist/game/events.d.ts +17 -0
- package/dist/game/playableGame.d.ts +3 -0
- package/dist/meta/changelog.d.ts +1 -1
- package/dist/meta/changelog.js +61 -1
- package/dist/movement/solidObstacles.d.ts +34 -5
- package/dist/movement/solidObstacles.js +146 -27
- package/dist/runtime/context/combat.js +1 -0
- package/dist/runtime/context/scene.js +7 -2
- package/dist/runtime/gameContext.js +3 -0
- package/dist/runtime/gameContextTypes.d.ts +16 -0
- package/dist/scene/objectStore.d.ts +4 -0
- package/dist/scene/objectStore.js +103 -15
- package/dist/settings/graphicsSettings.js +3 -2
- package/dist/settings/settingsModel.d.ts +18 -0
- package/dist/settings/settingsModel.js +67 -15
- package/package.json +1 -1
- package/skills/jgengine/api.md +3 -2
- package/skills/jgengine-combat/api.md +2 -2
- package/skills/jgengine-gameplay/api.md +3 -2
- package/skills/jgengine-world/api.md +18 -8
- package/skills/jgengine-world/capabilities.md +4 -0
|
@@ -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
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
if (
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
horizontal = Math.max(horizontal,
|
|
70
|
-
vertical = Math.max(vertical,
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
203
|
-
|
|
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
|
|
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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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 =
|
|
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
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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):
|
|
198
|
-
// every
|
|
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 (
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
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 {
|