@jgengine/core 0.32.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 +48 -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 +6 -0
- package/dist/meta/changelog.d.ts +1 -1
- package/dist/meta/changelog.js +44 -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/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 +2 -2
- package/skills/jgengine-combat/api.md +2 -2
- package/skills/jgengine-gameplay/api.md +2 -2
- package/skills/jgengine-world/api.md +4 -4
package/CHANGELOG.md
CHANGED
|
@@ -26,6 +26,54 @@ At publish, rename this heading to the new version and mirror the entries into
|
|
|
26
26
|
`packages/core/src/meta/changelog.ts` (the typed `CHANGELOG` export).
|
|
27
27
|
-->
|
|
28
28
|
|
|
29
|
+
## 0.33.0
|
|
30
|
+
|
|
31
|
+
### Migrate
|
|
32
|
+
|
|
33
|
+
- **Bump lockstep SDK packages to `^0.33.0`:** `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}`. CLI `jgengine` is `0.30.0`; `@jgengine/github` is `0.20.0`.
|
|
34
|
+
- `useSceneObjects` (`@jgengine/react/hooks`) keeps one list identity across `move` and `rotate`, so a record's `position`/`rotationY` is what it was at the last render. Read the live placement with `ctx.scene.object.get(instanceId)` (a `useFrame` loop or a rAF-ticked HUD) when a mesh or readout must follow a moving object.
|
|
35
|
+
- A read of a mismatched stored value now converts losslessly where it can (a number as its digits, a boolean as `"true"`/`"false"`, those strings back) and throws where it cannot. No in-repo caller relied on the old fallback; `useSetting` (`@jgengine/react/settings`) is the generic path that could, so a game that stored a number under a key it reads with a string fallback now gets the digits.
|
|
36
|
+
|
|
37
|
+
### Added
|
|
38
|
+
|
|
39
|
+
- `releasePostfxOverlayTracking(scene)` (`@jgengine/shell/postfx/postfxOverlay`) drops the cached prepass overlay set and its listeners for a scene that is being torn down.
|
|
40
|
+
- `canvasCover` (`@jgengine/react/canvasCover`): one page-wide hold a covering surface takes, plus `useCanvasCover(declared)` for a React overlay. While it is held the shell stops drawing the world, its shadow passes and postfx; input, audio and the DOM HUD keep running, and the first frame after release draws the untouched scene. A scrim or side panel must not take it.
|
|
41
|
+
- `MenuScreen` and `TitleMenu` take `coversCanvas` (default false) to declare that the screen paints over the whole canvas. A menu with only a translucent scrim leaves the live scene drawing.
|
|
42
|
+
- The dev runner pins the cover with `?cover=1`, and the devtools agent bridge answers `canvas_cover`, so a perf run or `bun run drive` can measure a covered frame on any game.
|
|
43
|
+
- `combat.damage` carries an optional `position`: the effect path fills it from the target's own spot, and `createDamageRuntime` takes one per hit (`hit({ position })`) or from a `positionOf` reader, so a status tick is placed too. A cue with a position pans and attenuates; one without stays centred.
|
|
44
|
+
- `createHapticsRoute` (`@jgengine/shell/audio/hapticsRoute`) sends a feedback pulse to a connected gamepad, and to `navigator.vibrate` on a phone with no pad. `FeedbackHost` wires it; reduced motion still drops the camera shake only.
|
|
45
|
+
- `PerfReport.render.drawsFrame` is every draw a frame issues — shadow, main, postfx prepasses, AO and DOF together — as the 95th percentile of the measured window, with `drawsFrames` naming how many frames it covers. `bun run perf:gate` compares it against each game's re-based `drawsFrame` budget; the renderer's own single-sample `draws` is printed beside it and is no longer gated.
|
|
46
|
+
- `ObjectStore.version()` / `ObjectStore.markSpatialChange()` (`ctx.scene.object`) — a counter that moves whenever membership, a position, a rotation or a consumer-owned collider set changes, so derived spells spatial data can share one cache key.
|
|
47
|
+
- `SolidObstacleSource.version?()` (`@jgengine/core/movement/solidObstacles`) — a source that reports one gets its blocking obstacles resolved once per version into a coarse XZ index; a source without it keeps the per-query `inBox` gather.
|
|
48
|
+
- `SettingsTypeError` and `asSettingType` (`@jgengine/core/settings/settingsModel`) report and resolve a stored setting read as another type.
|
|
49
|
+
|
|
50
|
+
### Changed
|
|
51
|
+
|
|
52
|
+
- `bun run land` queues landings on one lock per repo instead of running them beside each other, so two lanes no longer stack two pull requests' sixteen jobs against the account's 20-job cap. A waiting landing names the branch and pid it waits on; a lock whose process died, or that is older than 30 minutes, is broken with a warning.
|
|
53
|
+
- The scene io browser test skips Playwright's two-frame click stability check and lets three rendered frames pass instead of ten: on a runner its second test went from 53s to 11-24s and the file from 76s to 50s, of which 16-36s is the fixture's own start.
|
|
54
|
+
- `check-game-front-end` recognizes the shipped `CreditsPanel`, so a game that renders credits through the engine's panel is no longer flagged as having no credits screen.
|
|
55
|
+
- The shell's frame loop switches to `frameloop="never"` under a cover instead of drawing a world nobody can see (measured on Brightway Park's pause menu: 1,095 whole-frame draws and 5.2 ms/frame before, 0 draws and 0.11 ms/frame covered). A frame-rate cap no longer advances a covered canvas.
|
|
56
|
+
- `FeedbackDeps.play` takes an optional `position`, which `FeedbackHost` forwards to `ctx.game.audio.play`, so a positioned damage or death cue reaches the spatial engine.
|
|
57
|
+
- Each game's `perf-budget.json` entry now budgets `drawsFrame` and records the budget it replaced as `previousDraws`; an entry that still carries only `draws` fails by name instead of gating a count from a different measurement.
|
|
58
|
+
- The in-page report reads the devtools render probe's frame sample instead of the live renderer counter, so `window.__JG.report()` and the devtools panel no longer report a mid-frame partial count.
|
|
59
|
+
- Movement gathers obstacles from that index instead of re-resolving every nearby object every frame. On Harbor Heat the query fell from 0.62-0.65 ms to 0.10 ms a frame (2.0-2.1 ms to 1.4 ms of simulation a frame).
|
|
60
|
+
- `objectStore.inBox` walks a coarse grid for big boxes instead of scanning every placed object; a 1 m cell lookup costs a string hash, so the switch happens past 32 cells rather than 4096.
|
|
61
|
+
- Ambient occlusion reads the beauty pass's depth buffer instead of rendering the scene a second time for its normal/depth GBuffer. That prepass cost 537 extra draws and 2.6-2.9 ms/frame in Harbor Heat and 2,105 extra draws in Drift Foundry at 1600x900; AO now draws nothing of its own. A post chain whose antialiasing is `"smaa"` (the default) keeps SMAA and drops the beauty target's extra MSAA resolve, because WebGL2 cannot sample a multisampled depth texture; `"msaa"` chains keep the old prepass. Fixed-viewpoint captures differ only on geometry edges (#2173).
|
|
62
|
+
|
|
63
|
+
### Fixed
|
|
64
|
+
|
|
65
|
+
- Postfx prepasses no longer walk the whole scene twice a frame to hide overlays, points and lines: the set is cached per scene and rescanned when the scene graph changes. Drift Foundry 2.11 ms/frame and Harbor Heat 0.66 ms/frame of `traverse` removed.
|
|
66
|
+
- A rig posed from an animation graph no longer allocates a motion sample, a position triple and a fresh pose record every frame, and foot IK no longer allocates a probe input, filter, closure and per-leg records every frame.
|
|
67
|
+
- The Graphics tab lists a post-processing stage toggle only when the game's resolved chain can build that stage. Depth of field, which no reference game authors, hid behind a row that switched a pass the composer never created, so the toggle changed nothing. The rule covers every stage: a chain with `ao: false`, `bloom: false` or `aa: "msaa"`/`false` no longer offers those rows, and a game with no post chain gets none of them. Every other settings row is unchanged (`@jgengine/shell/settings/settingsController`, `availablePostfxStages` in `@jgengine/shell/postfx/PostProcessing`).
|
|
68
|
+
- Positional loops declared through `entitySounds`/`objectSounds` are tracked instead of rebuilt: spawn, despawn and a moved emitter update one retained loop each, and a frame where nothing moved reads only the emitters that already sound. On scrap-signal's 560 placed props (69 sounding), the tracking path drops from 16.7 µs to 1.2 µs per frame and a steady frame allocates no Set, array or record.
|
|
69
|
+
- `useAccessibilitySettings`, `useHoldVsToggle` and `useAccessibilityRows` show a setting written before the component subscribed — a saved profile or the demo path seeding values before first paint — instead of waiting for the player's next change (#2180).
|
|
70
|
+
- `useFeed`, `useQuestJournal` and `useFriends` (`@jgengine/react/hooks`) keep one list identity while membership holds instead of allocating a new array on every store write and comparing it with `Object.is`, so a HUD reading them no longer re-renders on every entity pose write. A pushed feed entry, an accepted, progressed or abandoned quest, and an accepted or removed friend each re-render once. Measured on the three read sites (`ToastStack`, `FriendsList`, `JournalQuestTracker`) with 15 quests, 20 friends and 20 feed rows: 900 renders → 0 across 300 pose writes, 0.14 ms/frame → 0.04 ms/frame.
|
|
71
|
+
- `useWorldItems` (`@jgengine/react/hooks`) keeps one list identity until an item spawns, is picked up or is removed. It read `worldItem.list()`, which allocates a new array per call, against a store signal that also fires on every entity pose write, so a HUD re-rendered every dropped-item marker each frame (0.97 ms/frame of React work in Harbor Heat).
|
|
72
|
+
- `WorldItems` (`@jgengine/shell`) reads each marker's live pose in `useFrame` instead of taking it as a prop, so a moving item still follows without re-rendering its marker.
|
|
73
|
+
- `useSceneEntities` and `useSceneObjects` (`@jgengine/react/hooks`) keep one list identity while membership holds, instead of allocating a new array on every store write and comparing it with `Object.is`. A pose write no longer re-renders every HUD that reads them (30-entity roster: 0.066 ms/frame → 0; the whole loop 0.24 → 0.05 ms/frame), while a spawn, despawn, replace, place or remove still re-renders. Non-pose object writes (state, visual, animation, slots, catalog id) still re-render.
|
|
74
|
+
- `RevealHighlights` (`@jgengine/shell/vision`) drives its own per-frame refresh only while Dark Sight is enabled, so a disabled reveal overlay no longer re-renders (0.019 ms/frame → 0) and an enabled one still follows the origin and its targets.
|
|
75
|
+
- `createSettingsStore().get` returns a stored setting of another type instead of quietly returning the fallback, so a numeric setting read with a string fallback applies instead of vanishing (#2189). Root cause: `coerce` treated the fallback's type as the stored type and replaced every other type with the fallback. A stored value that cannot be read as the asked-for type now throws `SettingsTypeError` naming the key, the stored type and the fallback type.
|
|
76
|
+
|
|
29
77
|
## 0.32.0
|
|
30
78
|
|
|
31
79
|
### Migrate
|
|
@@ -27,6 +27,8 @@ export interface DamageRuntimeHitInput {
|
|
|
27
27
|
zone?: string;
|
|
28
28
|
source?: string;
|
|
29
29
|
statuses?: readonly (string | DamageStatusRequest)[];
|
|
30
|
+
/** Where the blow landed, when the caller knows it; overridable per hit. */
|
|
31
|
+
position?: readonly [number, number, number];
|
|
30
32
|
}
|
|
31
33
|
/** Every stage of one resolved hit, so a UI or a test can read why the final number is what it is. */
|
|
32
34
|
export interface DamageRuntimeResolution {
|
|
@@ -87,6 +89,12 @@ export interface DamageRuntimeConfig {
|
|
|
87
89
|
* reader and every event reports `remaining: null` and `lethal: false`.
|
|
88
90
|
*/
|
|
89
91
|
healthOf?: (targetId: string) => number | null;
|
|
92
|
+
/**
|
|
93
|
+
* Where a target stands, read once per application. Gives the event a `position` so a cue pans
|
|
94
|
+
* and attenuates, including status ticks and hits whose input named none; omit it and the event
|
|
95
|
+
* carries no position and feedback stays centred.
|
|
96
|
+
*/
|
|
97
|
+
positionOf?: (targetId: string) => readonly [number, number, number] | null | undefined;
|
|
90
98
|
/** Register a one-line devtools probe under this name. */
|
|
91
99
|
devtoolsName?: string;
|
|
92
100
|
}
|
|
@@ -156,7 +164,8 @@ export declare class UnknownTargetProfileError extends Error {
|
|
|
156
164
|
* `base × zone multiplier × type multiplier × (1 − armor mitigation)`; statuses tick through the
|
|
157
165
|
* same table and armor, so a burn on an armored target is mitigated like a bullet. Every application
|
|
158
166
|
* — a hit or a status tick — is announced as `combat.damage` carrying the target, source, type,
|
|
159
|
-
* amount, absorbed, remaining health
|
|
167
|
+
* amount, absorbed, remaining health, a lethal flag and, when `positionOf` or the hit names one,
|
|
168
|
+
* where the blow landed, so a HUD names the blow and a cue pans instead of guessing.
|
|
160
169
|
*
|
|
161
170
|
* @capability damage-runtime resolve hits through a damage table, hit zones, and armor, and tick data-defined statuses with declared stacking
|
|
162
171
|
*/
|
|
@@ -23,7 +23,8 @@ export class UnknownTargetProfileError extends Error {
|
|
|
23
23
|
* `base × zone multiplier × type multiplier × (1 − armor mitigation)`; statuses tick through the
|
|
24
24
|
* same table and armor, so a burn on an armored target is mitigated like a bullet. Every application
|
|
25
25
|
* — a hit or a status tick — is announced as `combat.damage` carrying the target, source, type,
|
|
26
|
-
* amount, absorbed, remaining health
|
|
26
|
+
* amount, absorbed, remaining health, a lethal flag and, when `positionOf` or the hit names one,
|
|
27
|
+
* where the blow landed, so a HUD names the blow and a cue pans instead of guessing.
|
|
27
28
|
*
|
|
28
29
|
* @capability damage-runtime resolve hits through a damage table, hit zones, and armor, and tick data-defined statuses with declared stacking
|
|
29
30
|
*/
|
|
@@ -33,6 +34,7 @@ export function createDamageRuntime(config) {
|
|
|
33
34
|
const rng = resolveRng(config.rng, "createDamageRuntime");
|
|
34
35
|
const bus = config.events;
|
|
35
36
|
const healthOf = config.healthOf;
|
|
37
|
+
const positionOf = config.positionOf;
|
|
36
38
|
let table = config.table;
|
|
37
39
|
let zones = config.zones;
|
|
38
40
|
let armorStat = config.armorStat ?? "armor";
|
|
@@ -77,6 +79,7 @@ export function createDamageRuntime(config) {
|
|
|
77
79
|
dealt,
|
|
78
80
|
...(tick.source !== undefined ? { source: tick.source } : {}),
|
|
79
81
|
status: tick.status,
|
|
82
|
+
...(positionOf === undefined ? {} : { position: positionOf(tick.target) ?? undefined }),
|
|
80
83
|
});
|
|
81
84
|
return dealt;
|
|
82
85
|
}
|
|
@@ -92,6 +95,7 @@ export function createDamageRuntime(config) {
|
|
|
92
95
|
remaining,
|
|
93
96
|
lethal: remaining !== null && remaining <= 0,
|
|
94
97
|
...(input.status !== undefined ? { status: input.status } : {}),
|
|
98
|
+
...(input.position === undefined ? {} : { position: input.position }),
|
|
95
99
|
};
|
|
96
100
|
recent.push(event);
|
|
97
101
|
if (recent.length > RECENT_DAMAGE_LIMIT)
|
|
@@ -160,6 +164,9 @@ export function createDamageRuntime(config) {
|
|
|
160
164
|
incoming,
|
|
161
165
|
dealt: damage,
|
|
162
166
|
...(input.source !== undefined ? { source: input.source } : {}),
|
|
167
|
+
...(input.position !== undefined
|
|
168
|
+
? { position: input.position }
|
|
169
|
+
: positionOf === undefined ? {} : { position: positionOf(input.target) ?? undefined }),
|
|
163
170
|
});
|
|
164
171
|
return {
|
|
165
172
|
target: input.target,
|
package/dist/combat/effects.d.ts
CHANGED
|
@@ -93,6 +93,8 @@ export interface EffectDamageApplication {
|
|
|
93
93
|
remaining: number | null;
|
|
94
94
|
/** True when the last pool reached its minimum. */
|
|
95
95
|
lethal: boolean;
|
|
96
|
+
/** The target's position when the spatial seam knows it, for a panned cue or a decal at the hit. */
|
|
97
|
+
at?: EntityPosition;
|
|
96
98
|
}
|
|
97
99
|
export interface EffectSystemDeps {
|
|
98
100
|
resolveReceive(instanceId: string): ReceiveMap | null | undefined;
|
package/dist/combat/effects.js
CHANGED
|
@@ -109,6 +109,7 @@ export function createEffectSystem(deps) {
|
|
|
109
109
|
if (delta.delta < 0)
|
|
110
110
|
dealt -= delta.delta;
|
|
111
111
|
const lastStatId = rule.order[rule.order.length - 1];
|
|
112
|
+
const at = deps.spatial.positionOf(instanceId);
|
|
112
113
|
deps.onDamage({
|
|
113
114
|
target: instanceId,
|
|
114
115
|
source: from,
|
|
@@ -117,6 +118,7 @@ export function createEffectSystem(deps) {
|
|
|
117
118
|
dealt,
|
|
118
119
|
remaining: statPools.get(instanceId, lastStatId)?.current ?? null,
|
|
119
120
|
lethal: result.lethal,
|
|
121
|
+
...(at === undefined ? {} : { at }),
|
|
120
122
|
});
|
|
121
123
|
}
|
|
122
124
|
if (result.lethal) {
|
|
@@ -22,7 +22,20 @@ export interface FrameWindow {
|
|
|
22
22
|
}
|
|
23
23
|
/** Renderer-side counts for the measured window; `null` where the source could not read them. */
|
|
24
24
|
export interface RenderReport {
|
|
25
|
+
/**
|
|
26
|
+
* The renderer's own draw counter as the devtools probe samples it. three accumulates this across
|
|
27
|
+
* every pass in a frame (the probe sets `info.autoReset = false`), but one sample is one frame, so
|
|
28
|
+
* two runs of the same build can differ by a pass that runs on a cadence.
|
|
29
|
+
*/
|
|
25
30
|
draws: number | null;
|
|
31
|
+
/**
|
|
32
|
+
* Every draw issued in a frame, all passes (shadow, main, postfx, AO, DOF), as the 95th percentile
|
|
33
|
+
* of the measured window: the count the budget gate compares. Only the CDP harness can wrap the
|
|
34
|
+
* draw calls, so the in-page sampler reports `null` here.
|
|
35
|
+
*/
|
|
36
|
+
drawsFrame: number | null;
|
|
37
|
+
/** Frames the whole-frame count was taken over; 0 where nothing measured it. */
|
|
38
|
+
drawsFrames: number;
|
|
26
39
|
triangles: number | null;
|
|
27
40
|
geometries: number | null;
|
|
28
41
|
textures: number | null;
|
package/dist/game/events.d.ts
CHANGED
|
@@ -39,6 +39,12 @@ export interface DamageAppliedEvent {
|
|
|
39
39
|
lethal: boolean;
|
|
40
40
|
/** The status that ticked, when the damage came from a carried status rather than a direct hit. */
|
|
41
41
|
status?: string;
|
|
42
|
+
/**
|
|
43
|
+
* Where the blow landed, for spatial feedback (a panned cue, a decal, a floating number that
|
|
44
|
+
* belongs on the body rather than on screen). Omitted by an emitter that knows no position, so a
|
|
45
|
+
* listener must fall back to a centred, unattenuated reaction.
|
|
46
|
+
*/
|
|
47
|
+
position?: readonly [number, number, number];
|
|
42
48
|
}
|
|
43
49
|
/** Request that an entity's rig play a one-shot animation clip bound to `event` in its `animation.oneShots` (e.g. an "attack" swing); the shell resolves the clip and plays it once over the locomotion state. With an `animation.graph`, `event` arms the trigger of that name. */
|
|
44
50
|
export interface EntityAnimationEvent {
|
package/dist/meta/changelog.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating. */
|
|
2
|
-
export declare const VERSION = "0.
|
|
2
|
+
export declare const VERSION = "0.33.0";
|
|
3
3
|
/** One release's migrate steps plus added/changed/removed notes (typed mirror of CHANGELOG.md). */
|
|
4
4
|
export interface ChangelogEntry {
|
|
5
5
|
migrate: readonly string[];
|
package/dist/meta/changelog.js
CHANGED
|
@@ -1,7 +1,50 @@
|
|
|
1
1
|
/** Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating. */
|
|
2
|
-
export const VERSION = "0.
|
|
2
|
+
export const VERSION = "0.33.0";
|
|
3
3
|
/** Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`). */
|
|
4
4
|
export const CHANGELOG = {
|
|
5
|
+
"0.33.0": {
|
|
6
|
+
migrate: [
|
|
7
|
+
"Bump lockstep SDK packages to `^0.33.0`: `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}`. CLI `jgengine` is `0.30.0`; `@jgengine/github` is `0.20.0`.",
|
|
8
|
+
"`useSceneObjects` (`@jgengine/react/hooks`) keeps one list identity across `move` and `rotate`, so a record's `position`/`rotationY` is what it was at the last render. Read the live placement with `ctx.scene.object.get(instanceId)` (a `useFrame` loop or a rAF-ticked HUD) when a mesh or readout must follow a moving object.",
|
|
9
|
+
"A read of a mismatched stored value now converts losslessly where it can (a number as its digits, a boolean as `\"true\"`/`\"false\"`, those strings back) and throws where it cannot. No in-repo caller relied on the old fallback; `useSetting` (`@jgengine/react/settings`) is the generic path that could, so a game that stored a number under a key it reads with a string fallback now gets the digits.",
|
|
10
|
+
],
|
|
11
|
+
added: [
|
|
12
|
+
"`releasePostfxOverlayTracking(scene)` (`@jgengine/shell/postfx/postfxOverlay`) drops the cached prepass overlay set and its listeners for a scene that is being torn down.",
|
|
13
|
+
"`canvasCover` (`@jgengine/react/canvasCover`): one page-wide hold a covering surface takes, plus `useCanvasCover(declared)` for a React overlay. While it is held the shell stops drawing the world, its shadow passes and postfx; input, audio and the DOM HUD keep running, and the first frame after release draws the untouched scene. A scrim or side panel must not take it.",
|
|
14
|
+
"`MenuScreen` and `TitleMenu` take `coversCanvas` (default false) to declare that the screen paints over the whole canvas. A menu with only a translucent scrim leaves the live scene drawing.",
|
|
15
|
+
"The dev runner pins the cover with `?cover=1`, and the devtools agent bridge answers `canvas_cover`, so a perf run or `bun run drive` can measure a covered frame on any game.",
|
|
16
|
+
"`combat.damage` carries an optional `position`: the effect path fills it from the target's own spot, and `createDamageRuntime` takes one per hit (`hit({ position })`) or from a `positionOf` reader, so a status tick is placed too. A cue with a position pans and attenuates; one without stays centred.",
|
|
17
|
+
"`createHapticsRoute` (`@jgengine/shell/audio/hapticsRoute`) sends a feedback pulse to a connected gamepad, and to `navigator.vibrate` on a phone with no pad. `FeedbackHost` wires it; reduced motion still drops the camera shake only.",
|
|
18
|
+
"`PerfReport.render.drawsFrame` is every draw a frame issues — shadow, main, postfx prepasses, AO and DOF together — as the 95th percentile of the measured window, with `drawsFrames` naming how many frames it covers. `bun run perf:gate` compares it against each game's re-based `drawsFrame` budget; the renderer's own single-sample `draws` is printed beside it and is no longer gated.",
|
|
19
|
+
"`ObjectStore.version()` / `ObjectStore.markSpatialChange()` (`ctx.scene.object`) — a counter that moves whenever membership, a position, a rotation or a consumer-owned collider set changes, so derived spells spatial data can share one cache key.",
|
|
20
|
+
"`SolidObstacleSource.version?()` (`@jgengine/core/movement/solidObstacles`) — a source that reports one gets its blocking obstacles resolved once per version into a coarse XZ index; a source without it keeps the per-query `inBox` gather.",
|
|
21
|
+
"`SettingsTypeError` and `asSettingType` (`@jgengine/core/settings/settingsModel`) report and resolve a stored setting read as another type.",
|
|
22
|
+
],
|
|
23
|
+
changed: [
|
|
24
|
+
"`bun run land` queues landings on one lock per repo instead of running them beside each other, so two lanes no longer stack two pull requests' sixteen jobs against the account's 20-job cap. A waiting landing names the branch and pid it waits on; a lock whose process died, or that is older than 30 minutes, is broken with a warning.",
|
|
25
|
+
"The scene io browser test skips Playwright's two-frame click stability check and lets three rendered frames pass instead of ten: on a runner its second test went from 53s to 11-24s and the file from 76s to 50s, of which 16-36s is the fixture's own start.",
|
|
26
|
+
"`check-game-front-end` recognizes the shipped `CreditsPanel`, so a game that renders credits through the engine's panel is no longer flagged as having no credits screen.",
|
|
27
|
+
"The shell's frame loop switches to `frameloop=\"never\"` under a cover instead of drawing a world nobody can see (measured on Brightway Park's pause menu: 1,095 whole-frame draws and 5.2 ms/frame before, 0 draws and 0.11 ms/frame covered). A frame-rate cap no longer advances a covered canvas.",
|
|
28
|
+
"`FeedbackDeps.play` takes an optional `position`, which `FeedbackHost` forwards to `ctx.game.audio.play`, so a positioned damage or death cue reaches the spatial engine.",
|
|
29
|
+
"Each game's `perf-budget.json` entry now budgets `drawsFrame` and records the budget it replaced as `previousDraws`; an entry that still carries only `draws` fails by name instead of gating a count from a different measurement.",
|
|
30
|
+
"The in-page report reads the devtools render probe's frame sample instead of the live renderer counter, so `window.__JG.report()` and the devtools panel no longer report a mid-frame partial count.",
|
|
31
|
+
"Movement gathers obstacles from that index instead of re-resolving every nearby object every frame. On Harbor Heat the query fell from 0.62-0.65 ms to 0.10 ms a frame (2.0-2.1 ms to 1.4 ms of simulation a frame).",
|
|
32
|
+
"`objectStore.inBox` walks a coarse grid for big boxes instead of scanning every placed object; a 1 m cell lookup costs a string hash, so the switch happens past 32 cells rather than 4096.",
|
|
33
|
+
"Ambient occlusion reads the beauty pass's depth buffer instead of rendering the scene a second time for its normal/depth GBuffer. That prepass cost 537 extra draws and 2.6-2.9 ms/frame in Harbor Heat and 2,105 extra draws in Drift Foundry at 1600x900; AO now draws nothing of its own. A post chain whose antialiasing is `\"smaa\"` (the default) keeps SMAA and drops the beauty target's extra MSAA resolve, because WebGL2 cannot sample a multisampled depth texture; `\"msaa\"` chains keep the old prepass. Fixed-viewpoint captures differ only on geometry edges (#2173).",
|
|
34
|
+
"Postfx prepasses no longer walk the whole scene twice a frame to hide overlays, points and lines: the set is cached per scene and rescanned when the scene graph changes. Drift Foundry 2.11 ms/frame and Harbor Heat 0.66 ms/frame of `traverse` removed.",
|
|
35
|
+
"A rig posed from an animation graph no longer allocates a motion sample, a position triple and a fresh pose record every frame, and foot IK no longer allocates a probe input, filter, closure and per-leg records every frame.",
|
|
36
|
+
"The Graphics tab lists a post-processing stage toggle only when the game's resolved chain can build that stage. Depth of field, which no reference game authors, hid behind a row that switched a pass the composer never created, so the toggle changed nothing. The rule covers every stage: a chain with `ao: false`, `bloom: false` or `aa: \"msaa\"`/`false` no longer offers those rows, and a game with no post chain gets none of them. Every other settings row is unchanged (`@jgengine/shell/settings/settingsController`, `availablePostfxStages` in `@jgengine/shell/postfx/PostProcessing`).",
|
|
37
|
+
"Positional loops declared through `entitySounds`/`objectSounds` are tracked instead of rebuilt: spawn, despawn and a moved emitter update one retained loop each, and a frame where nothing moved reads only the emitters that already sound. On scrap-signal's 560 placed props (69 sounding), the tracking path drops from 16.7 µs to 1.2 µs per frame and a steady frame allocates no Set, array or record.",
|
|
38
|
+
"`useAccessibilitySettings`, `useHoldVsToggle` and `useAccessibilityRows` show a setting written before the component subscribed — a saved profile or the demo path seeding values before first paint — instead of waiting for the player's next change (#2180).",
|
|
39
|
+
"`useFeed`, `useQuestJournal` and `useFriends` (`@jgengine/react/hooks`) keep one list identity while membership holds instead of allocating a new array on every store write and comparing it with `Object.is`, so a HUD reading them no longer re-renders on every entity pose write. A pushed feed entry, an accepted, progressed or abandoned quest, and an accepted or removed friend each re-render once. Measured on the three read sites (`ToastStack`, `FriendsList`, `JournalQuestTracker`) with 15 quests, 20 friends and 20 feed rows: 900 renders → 0 across 300 pose writes, 0.14 ms/frame → 0.04 ms/frame.",
|
|
40
|
+
"`useWorldItems` (`@jgengine/react/hooks`) keeps one list identity until an item spawns, is picked up or is removed. It read `worldItem.list()`, which allocates a new array per call, against a store signal that also fires on every entity pose write, so a HUD re-rendered every dropped-item marker each frame (0.97 ms/frame of React work in Harbor Heat).",
|
|
41
|
+
"`WorldItems` (`@jgengine/shell`) reads each marker's live pose in `useFrame` instead of taking it as a prop, so a moving item still follows without re-rendering its marker.",
|
|
42
|
+
"`useSceneEntities` and `useSceneObjects` (`@jgengine/react/hooks`) keep one list identity while membership holds, instead of allocating a new array on every store write and comparing it with `Object.is`. A pose write no longer re-renders every HUD that reads them (30-entity roster: 0.066 ms/frame → 0; the whole loop 0.24 → 0.05 ms/frame), while a spawn, despawn, replace, place or remove still re-renders. Non-pose object writes (state, visual, animation, slots, catalog id) still re-render.",
|
|
43
|
+
"`RevealHighlights` (`@jgengine/shell/vision`) drives its own per-frame refresh only while Dark Sight is enabled, so a disabled reveal overlay no longer re-renders (0.019 ms/frame → 0) and an enabled one still follows the origin and its targets.",
|
|
44
|
+
"`createSettingsStore().get` returns a stored setting of another type instead of quietly returning the fallback, so a numeric setting read with a string fallback applies instead of vanishing (#2189). Root cause: `coerce` treated the fallback's type as the stored type and replaced every other type with the fallback. A stored value that cannot be read as the asked-for type now throws `SettingsTypeError` naming the key, the stored type and the fallback type.",
|
|
45
|
+
],
|
|
46
|
+
removed: [],
|
|
47
|
+
},
|
|
5
48
|
"0.32.0": {
|
|
6
49
|
migrate: [
|
|
7
50
|
"Bump lockstep SDK packages to `^0.32.0`: `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}`. CLI `jgengine` is `0.29.0`; `@jgengine/github` is `0.19.0`.",
|
|
@@ -29,11 +29,35 @@ export interface SolidObstacleSource {
|
|
|
29
29
|
rotationY: number;
|
|
30
30
|
}[];
|
|
31
31
|
collidersOf(instanceId: string): Parameters<typeof resolveColliders>[0] | null;
|
|
32
|
+
/**
|
|
33
|
+
* Bumps whenever membership, a position, a rotation or a collider set changes. A source that reports
|
|
34
|
+
* one is indexed once per version instead of re-resolving every object on every query.
|
|
35
|
+
*/
|
|
36
|
+
version?(): number;
|
|
32
37
|
}
|
|
33
|
-
/**
|
|
38
|
+
/** A placed object's blocking geometry, resolved once per index build and shared read-only by queries. */
|
|
39
|
+
interface ObstacleGroup {
|
|
40
|
+
x: number;
|
|
41
|
+
y: number;
|
|
42
|
+
z: number;
|
|
43
|
+
obstacles: CollisionObstacle[];
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Version-keyed obstacle index: object centre → its prebuilt obstacles, bucketed in coarse XZ cells so
|
|
47
|
+
* a city-scale movement box walks a few populated cells rather than every object in the world.
|
|
48
|
+
*/
|
|
49
|
+
interface ObstacleIndex {
|
|
50
|
+
cells: Map<number, ObstacleGroup[]>;
|
|
51
|
+
groups: ObstacleGroup[];
|
|
52
|
+
}
|
|
53
|
+
/** Mutable reach cache; one per source, invalidated by object count or by the source's version. */
|
|
34
54
|
export interface ObstacleReachCache {
|
|
35
55
|
count: number;
|
|
36
56
|
value: ObstacleReach;
|
|
57
|
+
/** Version the value and index were built from; absent for a source that reports none. */
|
|
58
|
+
version?: number | undefined;
|
|
59
|
+
/** Built index, present only for a versioned source. */
|
|
60
|
+
index?: ObstacleIndex | undefined;
|
|
37
61
|
}
|
|
38
62
|
/** A fresh {@link ObstacleReachCache} for a caller that owns its own source. */
|
|
39
63
|
export declare function createObstacleReachCache(): ObstacleReachCache;
|
|
@@ -71,15 +95,19 @@ export declare function obstacleFromSolid(solid: WorldSolid): CollisionObstacle;
|
|
|
71
95
|
* @capability solid-obstacles the one blocking-geometry query the player resolver and NPC movers share
|
|
72
96
|
*/
|
|
73
97
|
export declare function solidObstaclesNear(ctx: GameContext, position: EntityPosition, reachX: number, reachZ: number, height?: number): CollisionObstacle[];
|
|
74
|
-
/**
|
|
75
|
-
|
|
98
|
+
/**
|
|
99
|
+
* {@link solidObstaclesNear} against a bare source and an already-resolved reach. Pass the source's
|
|
100
|
+
* {@link ObstacleReachCache} to use the version-keyed obstacle index; a source that reports no
|
|
101
|
+
* `version()` keeps the per-query `inBox` gather.
|
|
102
|
+
*/
|
|
103
|
+
export declare function sourceObstaclesNear(source: SolidObstacleSource, reach: ObstacleReach, position: EntityPosition, reachX: number, reachZ: number, height?: number, cache?: ObstacleReachCache): CollisionObstacle[];
|
|
76
104
|
/**
|
|
77
105
|
* Slide a walker's horizontal step against the same solid geometry that stops the player, returning the
|
|
78
106
|
* X/Z it may actually take this tick. A step into a wall is cut on the blocked axis and preserved on the
|
|
79
107
|
* other, exactly as {@link stepPlayerMovement} resolves the player's.
|
|
80
108
|
*
|
|
81
|
-
* Bounded by the same broadphase as the player's gather: one
|
|
82
|
-
* full-world scan, so it stays affordable per NPC per tick.
|
|
109
|
+
* Bounded by the same broadphase as the player's gather: one query sized to this step, answered from the
|
|
110
|
+
* source's obstacle index, never a full-world scan, so it stays affordable per NPC per tick.
|
|
83
111
|
*
|
|
84
112
|
* The sliding is already axis-separated: X and Z are resolved independently inside, so a caller that
|
|
85
113
|
* also splits its own movement by axis must hand this exactly one axis per call (`stepZ: 0`, then
|
|
@@ -118,3 +146,4 @@ export declare function slideStep(position: EntityPosition, stepX: number, stepZ
|
|
|
118
146
|
stepX: number;
|
|
119
147
|
stepZ: number;
|
|
120
148
|
};
|
|
149
|
+
export {};
|
|
@@ -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,
|
|
@@ -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 {
|
|
@@ -60,30 +60,81 @@ function defaultStorage() {
|
|
|
60
60
|
}
|
|
61
61
|
return null;
|
|
62
62
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
63
|
+
/** A stored setting holds a value the read asked for as another type, so no honest value exists. */
|
|
64
|
+
export class SettingsTypeError extends Error {
|
|
65
|
+
settingId;
|
|
66
|
+
storedType;
|
|
67
|
+
fallbackType;
|
|
68
|
+
constructor(settingId, storedType, fallbackType) {
|
|
69
|
+
super(`Setting "${settingId}" stores ${storedType} but was read as ${fallbackType}.`);
|
|
70
|
+
this.name = "SettingsTypeError";
|
|
71
|
+
this.settingId = settingId;
|
|
72
|
+
this.storedType = storedType;
|
|
73
|
+
this.fallbackType = fallbackType;
|
|
67
74
|
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
return
|
|
75
|
+
}
|
|
76
|
+
function storedTypeName(raw) {
|
|
77
|
+
if (raw === null)
|
|
78
|
+
return "null";
|
|
79
|
+
if (Array.isArray(raw))
|
|
80
|
+
return "array";
|
|
81
|
+
return typeof raw;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Read a stored value as the type the caller asked for. A value of another primitive type converts
|
|
85
|
+
* when the result is the same value in the asked-for type (a number as its digits, a boolean as
|
|
86
|
+
* `"true"`/`"false"`); otherwise it throws {@link SettingsTypeError}, naming the key and both types,
|
|
87
|
+
* instead of handing back the fallback as if nothing were stored.
|
|
88
|
+
*/
|
|
89
|
+
export function asSettingType(id, stored, fallback) {
|
|
90
|
+
const wanted = typeof fallback;
|
|
91
|
+
const storedType = storedTypeName(stored);
|
|
92
|
+
if (storedType === wanted) {
|
|
93
|
+
if (wanted === "number" && !Number.isFinite(stored)) {
|
|
94
|
+
throw new SettingsTypeError(id, storedType, wanted);
|
|
95
|
+
}
|
|
96
|
+
return stored;
|
|
97
|
+
}
|
|
98
|
+
if (wanted === "number" && storedType === "string") {
|
|
99
|
+
const text = stored.trim();
|
|
100
|
+
const parsed = text === "" ? Number.NaN : Number(text);
|
|
101
|
+
if (Number.isFinite(parsed))
|
|
102
|
+
return parsed;
|
|
72
103
|
}
|
|
73
|
-
|
|
104
|
+
else if (wanted === "string" && (storedType === "number" || storedType === "boolean")) {
|
|
105
|
+
return String(stored);
|
|
106
|
+
}
|
|
107
|
+
else if (wanted === "boolean" && storedType === "string" && (stored === "true" || stored === "false")) {
|
|
108
|
+
return (stored === "true");
|
|
109
|
+
}
|
|
110
|
+
throw new SettingsTypeError(id, storedType, wanted);
|
|
74
111
|
}
|
|
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
|
+
*/
|
|
75
116
|
export function loadSettingValue(id, fallback, storage = defaultStorage()) {
|
|
76
117
|
if (storage == null)
|
|
77
118
|
return fallback;
|
|
119
|
+
let raw;
|
|
78
120
|
try {
|
|
79
|
-
|
|
80
|
-
if (raw === null)
|
|
81
|
-
return fallback;
|
|
82
|
-
return coerce(JSON.parse(raw), fallback);
|
|
121
|
+
raw = storage.getItem(settingStorageKey(id));
|
|
83
122
|
}
|
|
84
123
|
catch {
|
|
85
124
|
return fallback;
|
|
86
125
|
}
|
|
126
|
+
if (raw === null)
|
|
127
|
+
return fallback;
|
|
128
|
+
let parsed;
|
|
129
|
+
try {
|
|
130
|
+
parsed = JSON.parse(raw);
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
return fallback;
|
|
134
|
+
}
|
|
135
|
+
if (parsed === null)
|
|
136
|
+
return fallback;
|
|
137
|
+
return asSettingType(id, parsed, fallback);
|
|
87
138
|
}
|
|
88
139
|
export function saveSettingValue(id, value, storage = defaultStorage()) {
|
|
89
140
|
if (storage == null)
|
|
@@ -100,8 +151,9 @@ export function createSettingsStore(storage = defaultStorage()) {
|
|
|
100
151
|
const mem = createObservableKeyedStore();
|
|
101
152
|
return {
|
|
102
153
|
get(id, fallback) {
|
|
103
|
-
|
|
104
|
-
|
|
154
|
+
const stored = mem.get(id);
|
|
155
|
+
if (stored !== undefined)
|
|
156
|
+
return asSettingType(id, stored, fallback);
|
|
105
157
|
return loadSettingValue(id, fallback, storage ?? undefined);
|
|
106
158
|
},
|
|
107
159
|
set(id, value) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jgengine/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.33.0",
|
|
4
4
|
"description": "jgengine core — pure-TypeScript game framework SDK (npm @jgengine/core). Entity stores, commands, combat, inventory, multiplayer seams. Zero dependencies. Not an ECS. Agent skills ship in the tarball. https://jgengine.com",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"jgengine",
|
package/skills/jgengine/api.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
- `CHANGELOG` (const): const CHANGELOG: Record<string, ChangelogEntry> — Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`).
|
|
8
8
|
- `ChangelogEntry` (interface): interface ChangelogEntry — One release's migrate steps plus added/changed/removed notes (typed mirror of CHANGELOG.md).
|
|
9
|
-
- `VERSION` (const): const VERSION: "0.
|
|
9
|
+
- `VERSION` (const): const VERSION: "0.33.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
|
|
10
10
|
|
|
11
11
|
## @jgengine/core/authoring
|
|
12
12
|
|
|
@@ -285,7 +285,7 @@
|
|
|
285
285
|
|
|
286
286
|
- `CHANGELOG` (const): const CHANGELOG: Record<string, ChangelogEntry> — Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`).
|
|
287
287
|
- `ChangelogEntry` (interface): interface ChangelogEntry — One release's migrate steps plus added/changed/removed notes (typed mirror of CHANGELOG.md).
|
|
288
|
-
- `VERSION` (const): const VERSION: "0.
|
|
288
|
+
- `VERSION` (const): const VERSION: "0.33.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
|
|
289
289
|
|
|
290
290
|
## @jgengine/core/runtime/adapter
|
|
291
291
|
|
|
@@ -243,7 +243,7 @@
|
|
|
243
243
|
- `createCoverField` (function): function createCoverField(options: CoverFieldOptions): CoverField — Cover over authored boxes: derive the stand points once, re-derive them after an edit, and answer bounded nearest-cover queries. `walkable` is injected, so the seam checks the game's own ground test (`nav.findPolygon(point) !== null`) instead of assuming a flat floor.
|
|
244
244
|
- `createDamageClamp` (function): function createDamageClamp(config: DamageClampConfig): DamageInterceptor — A stateless interceptor that caps any single application at `maxPerHit`.
|
|
245
245
|
- `createDamagePipeline` (function): function createDamagePipeline(initial?: readonly DamageInterceptor[]): DamagePipeline — Create a mutable damage interception chain that state transitions install into and remove from — e.g. add an invulnerability interceptor on phase enter and drop it on phase exit. Evaluation order is install order.
|
|
246
|
-
- `createDamageRuntime` (function): function createDamageRuntime(config: DamageRuntimeConfig): DamageRuntime — Build a damage model from data. The number a hit lands is `base × zone multiplier × type multiplier × (1 − armor mitigation)`; statuses tick through the same table and armor, so a burn on an armored target is mitigated like a bullet. Every application — a hit or a status tick — is announced as `combat.damage` carrying the target, source, type, amount, absorbed, remaining health
|
|
246
|
+
- `createDamageRuntime` (function): function createDamageRuntime(config: DamageRuntimeConfig): DamageRuntime — Build a damage model from data. The number a hit lands is `base × zone multiplier × type multiplier × (1 − armor mitigation)`; statuses tick through the same table and armor, so a burn on an armored target is mitigated like a bullet. Every application — a hit or a status tick — is announced as `combat.damage` carrying the target, source, type, amount, absorbed, remaining health, a lethal flag and, when `positionOf` or the hit names one, where the blow landed, so a HUD names the blow and a cue pans instead of guessing.
|
|
247
247
|
- `createDeathSystem` (function): function createDeathSystem(deps: DeathSystemDeps): DeathSystem — Resolve entity death and the on-death consequences — drops, respawn eligibility, kill credit.
|
|
248
248
|
- `createDefensiveWindow` (function): function createDefensiveWindow(config: DefensiveWindowConfig): DefensiveWindow — Open a timed defensive window — block, parry, or i-frames — and test incoming hits against it.
|
|
249
249
|
- `createDotField` (function): function createDotField(): DotField — Builds an empty {@link DotField}; `apply` DoTs onto it and drain damage each frame with `tick`.
|
|
@@ -462,7 +462,7 @@
|
|
|
462
462
|
- `TargetProfile` (interface): interface TargetProfile — What a target presents to the table: one resistance class and its armor points.
|
|
463
463
|
- `TimeToKill` (interface): interface TimeToKill — A quoted time to kill for one fixed hit against one health pool.
|
|
464
464
|
- `UnknownTargetProfileError` (class): class UnknownTargetProfileError extends Error — Raised when a hit names a target that was never registered — a wiring error, not a 0-damage hit.
|
|
465
|
-
- `createDamageRuntime` (function): function createDamageRuntime(config: DamageRuntimeConfig): DamageRuntime — Build a damage model from data. The number a hit lands is `base × zone multiplier × type multiplier × (1 − armor mitigation)`; statuses tick through the same table and armor, so a burn on an armored target is mitigated like a bullet. Every application — a hit or a status tick — is announced as `combat.damage` carrying the target, source, type, amount, absorbed, remaining health
|
|
465
|
+
- `createDamageRuntime` (function): function createDamageRuntime(config: DamageRuntimeConfig): DamageRuntime — Build a damage model from data. The number a hit lands is `base × zone multiplier × type multiplier × (1 − armor mitigation)`; statuses tick through the same table and armor, so a burn on an armored target is mitigated like a bullet. Every application — a hit or a status tick — is announced as `combat.damage` carrying the target, source, type, amount, absorbed, remaining health, a lethal flag and, when `positionOf` or the hit names one, where the blow landed, so a HUD names the blow and a cue pans instead of guessing.
|
|
466
466
|
|
|
467
467
|
## @jgengine/core/combat/damageTable
|
|
468
468
|
|
|
@@ -1073,7 +1073,7 @@
|
|
|
1073
1073
|
- `EmoteSpatialLookup` (interface): interface EmoteSpatialLookup { inRadius(center: readonly [number, number, number], radius: number, filter?: (id: string) => boolean): readonly string[] } — ⚠ undocumented
|
|
1074
1074
|
- `Emotes` (interface): interface Emotes { play(fromUserId: string, emoteId: string, radius?: number): EmoteBroadcastResult | {reason: string} } — ⚠ undocumented
|
|
1075
1075
|
- `EmotesDeps` (interface): interface EmotesDeps { entities: EmoteEntityLookup; spatial: EmoteSpatialLookup } — ⚠ undocumented
|
|
1076
|
-
- `FriendEntry` (interface): interface FriendEntry { userId: string; online: boolean } — ⚠ undocumented
|
|
1076
|
+
- `FriendEntry` (interface): interface FriendEntry { userId: string; online: boolean } — ⚠ undocumented · used by `useFriends` (@jgengine/react): The local player's friends with their presence: the list keeps one identity until a request is accepted, a friend is removed or a friend's p…
|
|
1077
1077
|
- `FriendRequestEntry` (interface): interface FriendRequestEntry { requestId: string; fromUserId: string } — ⚠ undocumented
|
|
1078
1078
|
- `Friends` (interface): interface Friends { canRequest(fromUserId: string, toUserId: string): {reason: string} | null; request(fromUserId: string, toUserId: string): {requestId: string} | {reason: string}; accept(userId: string, requestId: string): {reason: string} | null; decline(… — ⚠ undocumented
|
|
1079
1079
|
- `FriendsSnapshot` (interface): interface FriendsSnapshot { friends: string[]; blocked: string[] } — ⚠ undocumented
|
|
@@ -1351,7 +1351,7 @@
|
|
|
1351
1351
|
- `FirstPersonCameraConfig` (interface): interface FirstPersonCameraConfig { eyeHeight?: number; sensitivity?: number; maxPitch?: number; reticle?: boolean; viewmodel?: boolean; collision?: false | FirstPersonCollisionConfig } — ⚠ undocumented
|
|
1352
1352
|
- `FirstPersonCollisionConfig` (interface): interface FirstPersonCollisionConfig — Clearance tuning for the first-person camera's collision pull-in.
|
|
1353
1353
|
- `ForbidRule` (interface): interface ForbidRule — A rule that forbids a combination — e.g. "an incendiary barrel cannot pair with a cryo core". A forbid rule can never become satisfiable by adding more parts, so it is the check a backtracking generator runs on each candidate.
|
|
1354
|
-
- `FriendEntry` (interface): interface FriendEntry { userId: string; online: boolean } — ⚠ undocumented
|
|
1354
|
+
- `FriendEntry` (interface): interface FriendEntry { userId: string; online: boolean } — ⚠ undocumented · used by `useFriends` (@jgengine/react): The local player's friends with their presence: the list keeps one identity until a request is accepted, a friend is removed or a friend's p…
|
|
1355
1355
|
- `FriendRequestEntry` (interface): interface FriendRequestEntry { requestId: string; fromUserId: string } — ⚠ undocumented
|
|
1356
1356
|
- `Friends` (interface): interface Friends { canRequest(fromUserId: string, toUserId: string): {reason: string} | null; request(fromUserId: string, toUserId: string): {requestId: string} | {reason: string}; accept(userId: string, requestId: string): {reason: string} | null; decline(… — ⚠ undocumented
|
|
1357
1357
|
- `GAME_SAVE_VERSION` (const): const GAME_SAVE_VERSION: 1 — Current save-format version of {@link GameSaveDocument}. Bump it with {@link GameSaveConfig.migrate} when the shape changes.
|
|
@@ -1001,7 +1001,7 @@
|
|
|
1001
1001
|
## @jgengine/core/movement/solidObstacles
|
|
1002
1002
|
|
|
1003
1003
|
- `ObstacleReach` (interface): interface ObstacleReach — Largest blocking-collider extent in the scene, split so tower height cannot inflate the XZ query.
|
|
1004
|
-
- `ObstacleReachCache` (interface): interface ObstacleReachCache — Mutable reach cache; one per source, invalidated by object count.
|
|
1004
|
+
- `ObstacleReachCache` (interface): interface ObstacleReachCache — Mutable reach cache; one per source, invalidated by object count or by the source's version.
|
|
1005
1005
|
- `SolidObstacleSource` (interface): interface SolidObstacleSource — The slice of the scene's object store this query needs. Narrow on purpose: the scene context wires its own store here while it is still being constructed, `ctx.scene.object` satisfies it as-is, and a pure rules package with no `GameContext` to import can satisfy it from its own object list.
|
|
1006
1006
|
- `createObstacleReachCache` (function): function createObstacleReachCache(): ObstacleReachCache — A fresh {@link ObstacleReachCache} for a caller that owns its own source.
|
|
1007
1007
|
- `obstacleFromSolid` (function): function obstacleFromSolid(solid: WorldSolid): CollisionObstacle — A {@link WorldSolid} as a {@link CollisionObstacle}. Axis-aligned solids are one box; a yawed solid is cut into strips along its long axis, each fitted with an AABB, so a turned building does not block the street beside it the way a single enclosing box would.
|
|
@@ -1010,7 +1010,7 @@
|
|
|
1010
1010
|
- `slideStep` (function): function slideStep(position: EntityPosition, stepX: number, stepZ: number, obstacles: readonly CollisionObstacle[], radius = DEFAULT_OBSTACLE_PLAYER_RADIUS, stepUpHeight = 0): { stepX: number; stepZ: number } — {@link resolveWalkerStep} against an already-gathered obstacle set.
|
|
1011
1011
|
- `solidObstaclesNear` (function): function solidObstaclesNear(ctx: GameContext, position: EntityPosition, reachX: number, reachZ: number, height = WALKER_HEIGHT): CollisionObstacle[] — Every blocking physical obstacle overlapping the box `position` ± `reachX`/`reachZ`, read from the scene's resolved colliders and `ctx.world.solids`.
|
|
1012
1012
|
- `sourceObstacleReach` (function): function sourceObstacleReach(source: SolidObstacleSource, cache: ObstacleReachCache): ObstacleReach — {@link solidObstacleReach} against a bare source and caller-owned cache.
|
|
1013
|
-
- `sourceObstaclesNear` (function): function sourceObstaclesNear(source: SolidObstacleSource, reach: ObstacleReach, position: EntityPosition, reachX: number, reachZ: number, height = WALKER_HEIGHT): CollisionObstacle[] — {@link solidObstaclesNear} against a bare source and an already-resolved reach.
|
|
1013
|
+
- `sourceObstaclesNear` (function): function sourceObstaclesNear(source: SolidObstacleSource, reach: ObstacleReach, position: EntityPosition, reachX: number, reachZ: number, height = WALKER_HEIGHT, cache?: ObstacleReachCache): CollisionObstacle[] — {@link solidObstaclesNear} against a bare source and an already-resolved reach. Pass the source's {@link ObstacleReachCache} to use the version-keyed obstacle index; a source that reports no `version()` keeps the per-query `inBox` gather.
|
|
1014
1014
|
|
|
1015
1015
|
## @jgengine/core/movement/steering
|
|
1016
1016
|
|
|
@@ -2037,7 +2037,7 @@
|
|
|
2037
2037
|
|
|
2038
2038
|
## @jgengine/core/sensor/revealQuery
|
|
2039
2039
|
|
|
2040
|
-
- `RevealHit` (interface): interface RevealHit { instanceId: string; distance: number; tags: readonly string[] } — ⚠ undocumented · used by `useRevealHits` (@jgengine/shell/vision/RevealVision): Occlusion-ignoring tagged-entity radius query (#115), bound to the live scene.
|
|
2040
|
+
- `RevealHit` (interface): interface RevealHit { instanceId: string; distance: number; tags: readonly string[] } — ⚠ undocumented · used by `useRevealHits` (@jgengine/shell/vision/RevealVision): Occlusion-ignoring tagged-entity radius query (#115), bound to the live scene and refreshed on render.
|
|
2041
2041
|
- `RevealQuery` (interface): interface RevealQuery { inRadius(center: EntityPosition | string, radius: number, tags: readonly string[]): RevealHit[] } — ⚠ undocumented
|
|
2042
2042
|
- `RevealQueryOptions` (interface): interface RevealQueryOptions { resolvePosition: (instanceId: string) => EntityPosition | undefined; resolveTags: (instanceId: string) => readonly string[]; candidates: () => string[] } — ⚠ undocumented
|
|
2043
2043
|
|
|
@@ -2782,7 +2782,7 @@
|
|
|
2782
2782
|
- `ResolvedTerrainDetail` (type): type ResolvedTerrainDetail = Required<Omit<TerrainDetailConfig, "waterLevel" | "material" | "sweeps">> & { waterLevel: number; material?: ResolvedTerrainDetailMaterial; sweeps: { dry: readonly [number, number, number]; wet: readonly [number, number, number] }; } — A {@link TerrainDetailConfig} with every field resolved to a concrete value — the shape the shell's detail material consumes.
|
|
2783
2783
|
- `ResolvedWeather` (interface): interface ResolvedWeather { grip: number; visibility: number; structureDamage: number; chill: number; ignition: number; spread: number } — ⚠ undocumented
|
|
2784
2784
|
- `ResponseCurve` (type): type ResponseCurve = | { readonly kind: "linear"; readonly from: number; readonly to: number } /** Same shape as `linear`, raised to `exponent` (default `2`), so appeal stays flat until it matters. */ | { readonly kind: "quadratic"; readonly from: number; readonly to: number; readonly exponent?: num… — How one need's satisfaction maps to an option's appeal: `1` at `from`, `0` at `to`, clamped outside. Read satisfaction `1` as a full meter; pair with {@link UtilityTerm.invert} when a full meter is what makes the option attractive.
|
|
2785
|
-
- `RevealHit` (interface): interface RevealHit { instanceId: string; distance: number; tags: readonly string[] } — ⚠ undocumented · used by `useRevealHits` (@jgengine/shell/vision/RevealVision): Occlusion-ignoring tagged-entity radius query (#115), bound to the live scene.
|
|
2785
|
+
- `RevealHit` (interface): interface RevealHit { instanceId: string; distance: number; tags: readonly string[] } — ⚠ undocumented · used by `useRevealHits` (@jgengine/shell/vision/RevealVision): Occlusion-ignoring tagged-entity radius query (#115), bound to the live scene and refreshed on render.
|
|
2786
2786
|
- `RevealQuery` (interface): interface RevealQuery { inRadius(center: EntityPosition | string, radius: number, tags: readonly string[]): RevealHit[] } — ⚠ undocumented
|
|
2787
2787
|
- `RoadCut` (interface): interface RoadCut — One trim of a ribbon end back to a junction boundary — the seam the welded surface attaches to.
|
|
2788
2788
|
- `RoadEnvironmentDescriptor` (type): type RoadEnvironmentDescriptor = { kind: "road" } & Required< Pick<RoadEnvironmentConfig, "path" | "width" | "color" | "markings" | "markingColor" | "elevation"> > & { /** Resolved sidewalk band, or `false` when the road has none. */ sidewalk: { width: number; color: string } | false; /** Procedural… — Resolved road descriptor produced by {@link road} and rendered by the shell environment scene.
|