@jgengine/core 0.32.0 → 0.34.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 +58 -0
- package/dist/ai/driver.d.ts +41 -2
- package/dist/ai/driver.js +33 -2
- 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 +54 -1
- package/dist/movement/solidObstacles.d.ts +34 -5
- package/dist/movement/solidObstacles.js +146 -27
- package/dist/run/runLoop.d.ts +88 -0
- package/dist/run/runLoop.js +92 -0
- 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 +13 -2
- package/skills/jgengine-gameplay/capabilities.md +4 -0
- package/skills/jgengine-world/api.md +7 -5
- package/skills/jgengine-world/reference.md +1 -1
|
@@ -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.34.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.34.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.34.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.
|
|
@@ -2653,6 +2653,17 @@
|
|
|
2653
2653
|
- `TriggeredRuleState` (interface): interface TriggeredRuleState — Serializable runtime state — everything that changes future triggers. Rules travel with it.
|
|
2654
2654
|
- `createTriggeredRuleEngine` (function): function createTriggeredRuleEngine(rules: readonly TriggeredRule[] = []): TriggeredRuleEngine — Create an empty triggered-rule engine. Rules are added as data; dispatching an event returns the firings the caller applies to its own effect system. Time is caller-supplied (`now` in ms), so the engine is deterministic and drives equally from a fixed-step loop or a save-restored timeline.
|
|
2655
2655
|
|
|
2656
|
+
## @jgengine/core/run/runLoop
|
|
2657
|
+
|
|
2658
|
+
- `RunChange` (interface): interface RunChange<T> — One change the next run can open with. `T` is the game's own payload (a tighter rival line, an unlocked part, a route modifier) - the loop only draws and remembers which id was drawn.
|
|
2659
|
+
- `RunLoop` (interface): interface RunLoop<T> — A run's lifecycle: what it carries and can lose, and the change it hands the next run.
|
|
2660
|
+
- `RunLoopConfig` (interface): interface RunLoopConfig<T> — Configuration for {@link createRunLoop}.
|
|
2661
|
+
- `RunLoopState` (interface): interface RunLoopState — Everything a run loop stores as plain JSON: the run in progress plus lifetime totals.
|
|
2662
|
+
- `RunOffer` (interface): interface RunOffer<T> — The change a run opens with, and which run it is.
|
|
2663
|
+
- `RunOutcome` (type): type RunOutcome = "banked" | "lost" — How a run ended: what it carried reached the bank, or it was lost.
|
|
2664
|
+
- `RunSettlement` (interface): interface RunSettlement<T> — What one closed run banked or lost, and what the next run changes.
|
|
2665
|
+
- `createRunLoop` (function): function createRunLoop<T>(config: RunLoopConfig<T>): RunLoop<T> — Bank-or-lose between runs, with one deterministic change per run drawn from an authored table.
|
|
2666
|
+
|
|
2656
2667
|
## @jgengine/core/session/contestedChannel
|
|
2657
2668
|
|
|
2658
2669
|
- `ContestReaction` (type): type ContestReaction = "pause" | "decay" — ⚠ undocumented
|
|
@@ -648,6 +648,10 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
|
|
|
648
648
|
- `rerollRules` (function) · `import { rerollRules } from "@jgengine/core/gameplay"`
|
|
649
649
|
- `selectRules` (function) · `import { selectRules } from "@jgengine/core/gameplay"`
|
|
650
650
|
|
|
651
|
+
## run-loop — bank or lose what a run carries, with one deterministic change per run
|
|
652
|
+
|
|
653
|
+
- `createRunLoop` (function) · `import { createRunLoop } from "@jgengine/core/run/runLoop"`
|
|
654
|
+
|
|
651
655
|
## run-modifiers — a roguelike run built from stacking drafted modifier picks
|
|
652
656
|
|
|
653
657
|
- `createRunDraft` (function) · `import { createRunDraft } from "@jgengine/core/gameplay"`
|
|
@@ -106,8 +106,10 @@
|
|
|
106
106
|
- `DriverState` (interface): interface DriverState — Serializable per-driver state advanced in place by {@link driveStep}.
|
|
107
107
|
- `DriverStep` (interface): interface DriverStep extends AxisInput — {@link driveStep}'s result: the axis sample to feed the vehicle sim, plus arrival.
|
|
108
108
|
- `DriverTuning` (interface): interface DriverTuning — Vehicle-feel numbers the game supplies; everything has a sensible default except `maxSpeed`.
|
|
109
|
+
- `PaceBand` (interface): interface PaceBand — Catch-up pacing for one car in a field: the gap it tries to hold to a reference car (a rival, the player, a convoy lead) and how its pace bends when it drifts off that gap. Past `margin` by `tolerance` it sheds up to `ease` of its pace; short of it by `tolerance` it gains up to `push`; inside that tolerance it leaves the car alone. The push never outdrives `DriverTuning.maxSpeed`, so a band changes how hard a car tries, never what it is built to do.
|
|
109
110
|
- `createDriverState` (function): function createDriverState(targetX = 0, targetZ = 0): DriverState — Fresh driver state committed to an initial target (usually the spawn-time goal or the spawn position itself).
|
|
110
|
-
- `driveStep` (function): function driveStep(state: DriverState, dt: number, pose: DriverPose, goal: DriverGoal, profile: DifficultyProfile, tuning: DriverTuning, rng: () => number, obstacleAhead: number | null = null): DriverStep — Advance one driving step, mutating `state`, and return the {@link AxisInput} to feed the vehicle sim. `obstacleAhead` is the caller-measured distance to the nearest blocker in the driving corridor this step (`null`/omitted when clear) — the driver perceives it late by `speed * reactionSeconds`, so easy tiers brake at the last moment and expert tiers with proper margin. The steering target is a delay-line over the goal — resampled every `reactionSeconds`, so it trails a moving goal by up to that long, which is what makes an easy chaser visibly cut toward where the target *was*. When throttle produces no motion for a tier-scaled while the driver reverses out with counter-steer (brake past standstill — the shared sim's reverse).
|
|
111
|
+
- `driveStep` (function): function driveStep(state: DriverState, dt: number, pose: DriverPose, goal: DriverGoal, profile: DifficultyProfile, tuning: DriverTuning, rng: () => number, obstacleAhead: number | null = null): DriverStep — Advance one driving step, mutating `state`, and return the {@link AxisInput} to feed the vehicle sim. `obstacleAhead` is the caller-measured distance to the nearest blocker in the driving corridor this step (`null`/omitted when clear) — the driver perceives it late by `speed * reactionSeconds`, so easy tiers brake at the last moment and expert tiers with proper margin. The steering target is a delay-line over the goal — resampled every `reactionSeconds`, so it trails a moving goal by up to that long, which is what makes an easy chaser visibly cut toward where the target *was*. A {@link PaceBand} on the goal eases a car ahead of its margin and pushes one behind it, bounded by the car's own top speed. When throttle produces no motion for a tier-scaled while the driver reverses out with counter-steer (brake past standstill — the shared sim's reverse).
|
|
112
|
+
- `paceBandFactor` (function): function paceBandFactor(band: PaceBand, gap: number): number — The pace multiplier a {@link PaceBand} asks for at `gap`: `1` inside the tolerance, `1 - ease * scale` fully ahead of it, `1 + push * scale` fully behind it, ramped linearly across `easeAt` / `pushAt` in between. Pure scalar math, so a band is deterministic and costs no allocation per step; the result never goes negative.
|
|
111
113
|
- `pathTargetAhead` (function): function pathTargetAhead(path: readonly DrivePathPoint[], x: number, z: number, lookahead: number, loop = false): DrivePathPoint | null — The pure-pursuit target for street/route following: the point `lookahead` meters further along the polyline from wherever the vehicle currently projects onto it. Feed the result to {@link driveStep} as the goal each tick and the driver follows the road; `loop` wraps a circuit (traffic block, race lap) instead of pinning at the final point. Returns `null` for a degenerate path. Give sharper tiers a longer `lookahead` to cut corners cleanly and duller tiers a short one so they steer segment-to-segment.
|
|
112
114
|
|
|
113
115
|
## @jgengine/core/ai/flock
|
|
@@ -1001,7 +1003,7 @@
|
|
|
1001
1003
|
## @jgengine/core/movement/solidObstacles
|
|
1002
1004
|
|
|
1003
1005
|
- `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.
|
|
1006
|
+
- `ObstacleReachCache` (interface): interface ObstacleReachCache — Mutable reach cache; one per source, invalidated by object count or by the source's version.
|
|
1005
1007
|
- `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
1008
|
- `createObstacleReachCache` (function): function createObstacleReachCache(): ObstacleReachCache — A fresh {@link ObstacleReachCache} for a caller that owns its own source.
|
|
1007
1009
|
- `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 +1012,7 @@
|
|
|
1010
1012
|
- `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
1013
|
- `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
1014
|
- `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.
|
|
1015
|
+
- `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
1016
|
|
|
1015
1017
|
## @jgengine/core/movement/steering
|
|
1016
1018
|
|
|
@@ -2037,7 +2039,7 @@
|
|
|
2037
2039
|
|
|
2038
2040
|
## @jgengine/core/sensor/revealQuery
|
|
2039
2041
|
|
|
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.
|
|
2042
|
+
- `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
2043
|
- `RevealQuery` (interface): interface RevealQuery { inRadius(center: EntityPosition | string, radius: number, tags: readonly string[]): RevealHit[] } — ⚠ undocumented
|
|
2042
2044
|
- `RevealQueryOptions` (interface): interface RevealQueryOptions { resolvePosition: (instanceId: string) => EntityPosition | undefined; resolveTags: (instanceId: string) => readonly string[]; candidates: () => string[] } — ⚠ undocumented
|
|
2043
2045
|
|
|
@@ -2782,7 +2784,7 @@
|
|
|
2782
2784
|
- `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
2785
|
- `ResolvedWeather` (interface): interface ResolvedWeather { grip: number; visibility: number; structureDamage: number; chill: number; ignition: number; spread: number } — ⚠ undocumented
|
|
2784
2786
|
- `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.
|
|
2787
|
+
- `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
2788
|
- `RevealQuery` (interface): interface RevealQuery { inRadius(center: EntityPosition | string, radius: number, tags: readonly string[]): RevealHit[] } — ⚠ undocumented
|
|
2787
2789
|
- `RoadCut` (interface): interface RoadCut — One trim of a ribbon end back to a junction boundary — the seam the welded surface attaches to.
|
|
2788
2790
|
- `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.
|
|
@@ -413,7 +413,7 @@ Replace behavior data through `ctx.scene.entity.update(id, { behaviors })` or en
|
|
|
413
413
|
|
|
414
414
|
Compose, don't replace: scale `MobBrainConfig.aggroRadius` by `perceptionScale`, route `acquireTarget` scoring through `pickScored`, gate `advancePursuit` target swaps behind the reaction gate.
|
|
415
415
|
|
|
416
|
-
**Drive** — `ai/driver` is the vehicle consumer of the profile: `driveStep(state, dt, pose, goal, profile, tuning, rng, obstacleAhead?)` turns a pose + goal into the shared `AxisInput` (`throttle`/`brake`/`steer`) for `tickDrivableVehicle`. The profile decides how well the car drives: the steering target is a delay-line resampled every `reactionSeconds` (easy chasers cut to where the target *was*), corners shed speed instead of orbiting at full throttle, obstacle distance is perceived late by `speed × reactionSeconds`, the wheel wobbles by `executionJitter` (resampled on an interval, so it reads as a loose wheel), and a car grinding a wall backs out with counter-steer after a tier-scaled delay (brake past standstill is the sim's reverse). `createDriverState` is one small serializable object; the caller owns the sim, collision, and all spatial queries. For streets and circuits, `pathTargetAhead(path, x, z, lookahead, loop?)` walks a road/route polyline to the pure-pursuit point ahead — feed it back in as the goal each tick and the same brain follows authored roads; give sharper tiers a longer lookahead.
|
|
416
|
+
**Drive** — `ai/driver` is the vehicle consumer of the profile: `driveStep(state, dt, pose, goal, profile, tuning, rng, obstacleAhead?)` turns a pose + goal into the shared `AxisInput` (`throttle`/`brake`/`steer`) for `tickDrivableVehicle`. The profile decides how well the car drives: the steering target is a delay-line resampled every `reactionSeconds` (easy chasers cut to where the target *was*), corners shed speed instead of orbiting at full throttle, obstacle distance is perceived late by `speed × reactionSeconds`, the wheel wobbles by `executionJitter` (resampled on an interval, so it reads as a loose wheel), and a car grinding a wall backs out with counter-steer after a tier-scaled delay (brake past standstill is the sim's reverse). `createDriverState` is one small serializable object; the caller owns the sim, collision, and all spatial queries. For streets and circuits, `pathTargetAhead(path, x, z, lookahead, loop?)` walks a road/route polyline to the pure-pursuit point ahead — feed it back in as the goal each tick and the same brain follows authored roads; give sharper tiers a longer lookahead. Catch-up pacing is a `PaceBand` on the goal: the caller measures `paceGap` (positive = ahead of the reference) and the band's `margin`/`tolerance` leave a dead zone, `ease` sheds pace for a leader over `easeAt`, `push` adds it for a chaser over `pushAt`, and `scale` is the per-car share. `paceBandFactor(band, gap)` is the same rule on its own; the push never outdrives `tuning.maxSpeed`.
|
|
417
417
|
|
|
418
418
|
### Snapshot and restore on movement and visibility handles
|
|
419
419
|
|