@jgengine/core 0.7.0 → 0.8.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 +47 -0
- package/README.md +2 -0
- package/dist/ai/spawnDirector.d.ts +5 -0
- package/dist/ai/spawnDirector.js +13 -1
- package/dist/anim/easing.d.ts +14 -0
- package/dist/anim/easing.js +55 -0
- package/dist/behaviour/behaviour.d.ts +136 -0
- package/dist/behaviour/behaviour.js +388 -0
- package/dist/cards/cardPile.d.ts +1 -0
- package/dist/cards/cardPile.js +12 -3
- package/dist/combat/abilityKit.d.ts +5 -0
- package/dist/combat/abilityKit.js +10 -0
- package/dist/combat/effects.d.ts +1 -1
- package/dist/combat/effects.js +9 -6
- package/dist/combat/projectiles.d.ts +42 -5
- package/dist/combat/projectiles.js +98 -12
- package/dist/combat/telegraph.d.ts +5 -1
- package/dist/combat/telegraph.js +14 -0
- package/dist/commands/commandRegistry.d.ts +1 -1
- package/dist/commands/commandRegistry.js +2 -1
- package/dist/data/dataSource.d.ts +26 -0
- package/dist/data/dataSource.js +93 -0
- package/dist/data/devProxy.d.ts +11 -0
- package/dist/data/devProxy.js +38 -0
- package/dist/data/fetchJson.d.ts +20 -0
- package/dist/data/fetchJson.js +43 -0
- package/dist/data/jsonDataSource.d.ts +4 -0
- package/dist/data/jsonDataSource.js +6 -0
- package/dist/devtools/devtools.d.ts +157 -0
- package/dist/devtools/devtools.js +484 -0
- package/dist/devtools/transformTunables.d.ts +14 -0
- package/dist/devtools/transformTunables.js +38 -0
- package/dist/game/chat.d.ts +66 -0
- package/dist/game/chat.js +165 -0
- package/dist/game/defineGame.d.ts +8 -2
- package/dist/game/defineGame.js +2 -1
- package/dist/game/events.d.ts +25 -0
- package/dist/game/levelSequence.d.ts +44 -0
- package/dist/game/levelSequence.js +78 -0
- package/dist/game/playableGame.d.ts +237 -6
- package/dist/game/playableGame.js +6 -1
- package/dist/game/progression.d.ts +1 -0
- package/dist/game/progression.js +23 -2
- package/dist/game/race.d.ts +5 -0
- package/dist/game/race.js +20 -0
- package/dist/game/social.d.ts +42 -0
- package/dist/game/social.js +122 -0
- package/dist/game/spawnPoints.d.ts +19 -0
- package/dist/game/spawnPoints.js +23 -0
- package/dist/game/worldItem.d.ts +3 -0
- package/dist/game/worldItem.js +12 -6
- package/dist/input/actionBindings.d.ts +10 -0
- package/dist/input/actionBindings.js +14 -0
- package/dist/input/gestureSurface.d.ts +31 -0
- package/dist/input/gestureSurface.js +120 -0
- package/dist/input/pointer.d.ts +45 -0
- package/dist/input/pointer.js +48 -0
- package/dist/input/touchScheme.d.ts +83 -0
- package/dist/input/touchScheme.js +115 -0
- package/dist/item/affix.d.ts +1 -1
- package/dist/item/affix.js +1 -19
- package/dist/meta/changelog.d.ts +1 -1
- package/dist/meta/changelog.js +37 -1
- package/dist/movement/movementModel.d.ts +36 -3
- package/dist/movement/movementModel.js +81 -5
- package/dist/movement/voxelController.d.ts +61 -0
- package/dist/movement/voxelController.js +229 -0
- package/dist/multiplayer/chatContract.d.ts +42 -0
- package/dist/multiplayer/chatContract.js +57 -0
- package/dist/multiplayer/identity.d.ts +13 -0
- package/dist/multiplayer/identity.js +20 -0
- package/dist/multiplayer/poseSyncGate.d.ts +1 -0
- package/dist/multiplayer/poseSyncGate.js +13 -1
- package/dist/multiplayer/presenceModel.d.ts +3 -0
- package/dist/multiplayer/presenceModel.js +15 -1
- package/dist/multiplayer/voiceContract.d.ts +44 -0
- package/dist/multiplayer/voiceContract.js +108 -0
- package/dist/nav/navConstrain.d.ts +14 -0
- package/dist/nav/navConstrain.js +26 -0
- package/dist/nav/navFromEnvironment.d.ts +11 -0
- package/dist/nav/navFromEnvironment.js +16 -0
- package/dist/nav/navGrid.d.ts +10 -0
- package/dist/nav/navGrid.js +18 -1
- package/dist/nav/pathFollow.d.ts +4 -2
- package/dist/nav/pathFollow.js +5 -3
- package/dist/physics/ballisticSweep.d.ts +18 -0
- package/dist/physics/ballisticSweep.js +49 -0
- package/dist/physics/forceVolume.js +5 -5
- package/dist/physics/physicsWorld.d.ts +41 -2
- package/dist/physics/physicsWorld.js +232 -40
- package/dist/physics/spatialGrid.d.ts +1 -1
- package/dist/physics/spatialGrid.js +5 -0
- package/dist/physics/vehicleBody.d.ts +6 -0
- package/dist/physics/vehicleBody.js +16 -17
- package/dist/puzzle/cellGrid.d.ts +26 -0
- package/dist/puzzle/cellGrid.js +110 -0
- package/dist/puzzle/fallingPiece.d.ts +24 -0
- package/dist/puzzle/fallingPiece.js +51 -0
- package/dist/random/nameGen.d.ts +18 -0
- package/dist/random/nameGen.js +35 -0
- package/dist/random/rng.d.ts +2 -0
- package/dist/random/rng.js +23 -0
- package/dist/runtime/adapter.d.ts +35 -0
- package/dist/runtime/adapter.js +37 -1
- package/dist/runtime/cameraDirector.d.ts +10 -0
- package/dist/runtime/cameraDirector.js +25 -0
- package/dist/runtime/gameContext.d.ts +44 -2
- package/dist/runtime/gameContext.js +100 -4
- package/dist/runtime/hostPersistence.d.ts +21 -0
- package/dist/runtime/hostPersistence.js +50 -0
- package/dist/runtime/inputSnapshot.d.ts +7 -0
- package/dist/runtime/inputSnapshot.js +12 -0
- package/dist/runtime/motionIntents.d.ts +25 -0
- package/dist/runtime/motionIntents.js +25 -0
- package/dist/runtime/transport.d.ts +32 -0
- package/dist/scene/entityStore.d.ts +8 -0
- package/dist/scene/entityStore.js +25 -2
- package/dist/scene/movementSpeed.d.ts +35 -0
- package/dist/scene/movementSpeed.js +20 -0
- package/dist/scene/objectQuery.d.ts +18 -0
- package/dist/scene/objectQuery.js +108 -0
- package/dist/scene/objectStore.d.ts +11 -0
- package/dist/scene/objectStore.js +109 -4
- package/dist/scene/paintLayer.d.ts +15 -0
- package/dist/scene/paintLayer.js +53 -0
- package/dist/scene/spatial.d.ts +21 -0
- package/dist/scene/spatial.js +63 -1
- package/dist/sensor/concealment.d.ts +21 -0
- package/dist/sensor/concealment.js +63 -0
- package/dist/sensor/freezeMonitor.d.ts +17 -0
- package/dist/sensor/freezeMonitor.js +35 -0
- package/dist/session/roles.d.ts +6 -0
- package/dist/session/roles.js +32 -0
- package/dist/session/roundState.d.ts +41 -17
- package/dist/session/roundState.js +67 -38
- package/dist/tactics/fallingGrid.d.ts +62 -0
- package/dist/tactics/fallingGrid.js +158 -0
- package/dist/tactics/tacticalGrid.d.ts +11 -0
- package/dist/tactics/tacticalGrid.js +17 -0
- package/dist/time/simClock.d.ts +25 -1
- package/dist/time/simClock.js +28 -3
- package/dist/turn/intent.d.ts +14 -0
- package/dist/turn/intent.js +29 -0
- package/dist/turn/turnLoop.d.ts +2 -0
- package/dist/turn/turnLoop.js +8 -1
- package/dist/world/environmentSummary.d.ts +53 -0
- package/dist/world/environmentSummary.js +129 -0
- package/dist/world/features.d.ts +87 -6
- package/dist/world/features.js +52 -2
- package/dist/world/gridInstances.d.ts +13 -0
- package/dist/world/gridInstances.js +22 -0
- package/dist/world/placementController.d.ts +22 -1
- package/dist/world/placementController.js +56 -0
- package/dist/world/terrain.d.ts +23 -1
- package/dist/world/terrain.js +64 -1
- package/dist/world/voxelField.d.ts +54 -0
- package/dist/world/voxelField.js +241 -0
- package/llms.txt +4102 -0
- package/package.json +3 -2
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
import { createObservableKeyedStore } from "../store/observableKeyedStore.js";
|
|
2
|
+
const CELL_SIZE = 1;
|
|
3
|
+
function cellIndexOf(value) {
|
|
4
|
+
return Math.floor(value / CELL_SIZE);
|
|
5
|
+
}
|
|
6
|
+
function cellKey(cx, cy, cz) {
|
|
7
|
+
return `${cx}:${cy}:${cz}`;
|
|
8
|
+
}
|
|
9
|
+
function cellKeyOf(position) {
|
|
10
|
+
return cellKey(cellIndexOf(position[0]), cellIndexOf(position[1]), cellIndexOf(position[2]));
|
|
11
|
+
}
|
|
2
12
|
export function createObjectStore() {
|
|
3
13
|
const store = createObservableKeyedStore();
|
|
14
|
+
const cellIndex = new Map();
|
|
4
15
|
let nextCounter = 1;
|
|
5
16
|
function generateId() {
|
|
6
17
|
let id = `object-${nextCounter}`;
|
|
@@ -11,31 +22,58 @@ export function createObjectStore() {
|
|
|
11
22
|
nextCounter += 1;
|
|
12
23
|
return id;
|
|
13
24
|
}
|
|
25
|
+
function indexAdd(instanceId, position) {
|
|
26
|
+
const key = cellKeyOf(position);
|
|
27
|
+
let bucket = cellIndex.get(key);
|
|
28
|
+
if (bucket === undefined) {
|
|
29
|
+
bucket = new Set();
|
|
30
|
+
cellIndex.set(key, bucket);
|
|
31
|
+
}
|
|
32
|
+
bucket.add(instanceId);
|
|
33
|
+
}
|
|
34
|
+
function indexRemove(instanceId, position) {
|
|
35
|
+
const key = cellKeyOf(position);
|
|
36
|
+
const bucket = cellIndex.get(key);
|
|
37
|
+
if (bucket === undefined)
|
|
38
|
+
return;
|
|
39
|
+
bucket.delete(instanceId);
|
|
40
|
+
if (bucket.size === 0)
|
|
41
|
+
cellIndex.delete(key);
|
|
42
|
+
}
|
|
14
43
|
return {
|
|
15
44
|
place(catalogId, x, y, z, options = {}) {
|
|
16
45
|
if (options.instanceId !== undefined && store.has(options.instanceId)) {
|
|
17
46
|
throw new Error(`Scene object id "${options.instanceId}" is already placed.`);
|
|
18
47
|
}
|
|
19
48
|
const instanceId = options.instanceId ?? generateId();
|
|
49
|
+
const position = [x, y, z];
|
|
20
50
|
store.set(instanceId, {
|
|
21
51
|
instanceId,
|
|
22
52
|
catalogId,
|
|
23
|
-
position
|
|
53
|
+
position,
|
|
24
54
|
rotationY: options.rotation ?? 0,
|
|
25
55
|
...(options.parentSpace !== undefined ? { parentSpace: options.parentSpace } : {}),
|
|
56
|
+
...(options.visual !== undefined ? { visual: options.visual } : {}),
|
|
26
57
|
});
|
|
58
|
+
indexAdd(instanceId, position);
|
|
27
59
|
return instanceId;
|
|
28
60
|
},
|
|
29
61
|
remove(instanceId) {
|
|
30
|
-
const
|
|
62
|
+
const current = store.get(instanceId);
|
|
63
|
+
if (!current)
|
|
64
|
+
return false;
|
|
31
65
|
store.delete(instanceId);
|
|
32
|
-
|
|
66
|
+
indexRemove(instanceId, current.position);
|
|
67
|
+
return true;
|
|
33
68
|
},
|
|
34
69
|
move(instanceId, x, y, z) {
|
|
35
70
|
const current = store.get(instanceId);
|
|
36
71
|
if (!current)
|
|
37
72
|
return false;
|
|
38
|
-
|
|
73
|
+
const position = [x, y, z];
|
|
74
|
+
store.set(instanceId, { ...current, position });
|
|
75
|
+
indexRemove(instanceId, current.position);
|
|
76
|
+
indexAdd(instanceId, position);
|
|
39
77
|
return true;
|
|
40
78
|
},
|
|
41
79
|
rotate(instanceId, rotationY) {
|
|
@@ -45,6 +83,20 @@ export function createObjectStore() {
|
|
|
45
83
|
store.set(instanceId, { ...current, rotationY });
|
|
46
84
|
return true;
|
|
47
85
|
},
|
|
86
|
+
setVisual(instanceId, visual) {
|
|
87
|
+
const current = store.get(instanceId);
|
|
88
|
+
if (!current)
|
|
89
|
+
return false;
|
|
90
|
+
store.set(instanceId, {
|
|
91
|
+
instanceId: current.instanceId,
|
|
92
|
+
catalogId: current.catalogId,
|
|
93
|
+
position: current.position,
|
|
94
|
+
rotationY: current.rotationY,
|
|
95
|
+
...(current.parentSpace !== undefined ? { parentSpace: current.parentSpace } : {}),
|
|
96
|
+
...(visual !== undefined ? { visual } : {}),
|
|
97
|
+
});
|
|
98
|
+
return true;
|
|
99
|
+
},
|
|
48
100
|
get(instanceId) {
|
|
49
101
|
return store.get(instanceId) ?? null;
|
|
50
102
|
},
|
|
@@ -54,10 +106,57 @@ export function createObjectStore() {
|
|
|
54
106
|
return all;
|
|
55
107
|
return all.filter((object) => object.parentSpace === filter.parentSpace);
|
|
56
108
|
},
|
|
109
|
+
at(x, y, z, tolerance = 0) {
|
|
110
|
+
const minCx = cellIndexOf(x - tolerance);
|
|
111
|
+
const maxCx = cellIndexOf(x + tolerance);
|
|
112
|
+
const minCy = cellIndexOf(y - tolerance);
|
|
113
|
+
const maxCy = cellIndexOf(y + tolerance);
|
|
114
|
+
const minCz = cellIndexOf(z - tolerance);
|
|
115
|
+
const maxCz = cellIndexOf(z + tolerance);
|
|
116
|
+
const results = [];
|
|
117
|
+
for (let cx = minCx; cx <= maxCx; cx += 1) {
|
|
118
|
+
for (let cy = minCy; cy <= maxCy; cy += 1) {
|
|
119
|
+
for (let cz = minCz; cz <= maxCz; cz += 1) {
|
|
120
|
+
const bucket = cellIndex.get(cellKey(cx, cy, cz));
|
|
121
|
+
if (bucket === undefined)
|
|
122
|
+
continue;
|
|
123
|
+
for (const instanceId of bucket) {
|
|
124
|
+
const object = store.get(instanceId);
|
|
125
|
+
if (object === undefined)
|
|
126
|
+
continue;
|
|
127
|
+
const dx = object.position[0] - x;
|
|
128
|
+
const dy = object.position[1] - y;
|
|
129
|
+
const dz = object.position[2] - z;
|
|
130
|
+
if (Math.hypot(dx, dy, dz) <= tolerance)
|
|
131
|
+
results.push(object);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return results;
|
|
137
|
+
},
|
|
138
|
+
inBox(min, max) {
|
|
139
|
+
const hits = [];
|
|
140
|
+
for (const bucket of cellIndex.values()) {
|
|
141
|
+
for (const instanceId of bucket) {
|
|
142
|
+
const object = store.get(instanceId);
|
|
143
|
+
if (object === undefined)
|
|
144
|
+
continue;
|
|
145
|
+
const [x, y, z] = object.position;
|
|
146
|
+
if (x >= min[0] && x <= max[0] &&
|
|
147
|
+
y >= min[1] && y <= max[1] &&
|
|
148
|
+
z >= min[2] && z <= max[2]) {
|
|
149
|
+
hits.push(object);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return hits;
|
|
154
|
+
},
|
|
57
155
|
clear() {
|
|
58
156
|
for (const object of store.arraySnapshot()) {
|
|
59
157
|
store.delete(object.instanceId);
|
|
60
158
|
}
|
|
159
|
+
cellIndex.clear();
|
|
61
160
|
},
|
|
62
161
|
subscribe(listener) {
|
|
63
162
|
return store.subscribe(listener);
|
|
@@ -67,3 +166,9 @@ export function createObjectStore() {
|
|
|
67
166
|
},
|
|
68
167
|
};
|
|
69
168
|
}
|
|
169
|
+
export function objectVisualScale(visual) {
|
|
170
|
+
const scale = visual?.scale;
|
|
171
|
+
if (scale === undefined)
|
|
172
|
+
return [1, 1, 1];
|
|
173
|
+
return typeof scale === "number" ? [scale, scale, scale] : scale;
|
|
174
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface PaintStroke {
|
|
2
|
+
u: number;
|
|
3
|
+
v: number;
|
|
4
|
+
radius: number;
|
|
5
|
+
color: string;
|
|
6
|
+
}
|
|
7
|
+
export interface PaintLayer {
|
|
8
|
+
paint(instanceId: string, stroke: PaintStroke): void;
|
|
9
|
+
strokes(instanceId: string): readonly PaintStroke[];
|
|
10
|
+
paintedIds(): readonly string[];
|
|
11
|
+
clear(instanceId?: string): void;
|
|
12
|
+
version(instanceId: string): number;
|
|
13
|
+
subscribe(listener: () => void): () => void;
|
|
14
|
+
}
|
|
15
|
+
export declare function createPaintLayer(): PaintLayer;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export function createPaintLayer() {
|
|
2
|
+
const strokesByInstance = new Map();
|
|
3
|
+
const versionByInstance = new Map();
|
|
4
|
+
const listeners = new Set();
|
|
5
|
+
function bumpVersion(instanceId) {
|
|
6
|
+
versionByInstance.set(instanceId, (versionByInstance.get(instanceId) ?? 0) + 1);
|
|
7
|
+
}
|
|
8
|
+
function notify() {
|
|
9
|
+
for (const listener of listeners)
|
|
10
|
+
listener();
|
|
11
|
+
}
|
|
12
|
+
return {
|
|
13
|
+
paint(instanceId, stroke) {
|
|
14
|
+
const list = strokesByInstance.get(instanceId);
|
|
15
|
+
if (list === undefined)
|
|
16
|
+
strokesByInstance.set(instanceId, [stroke]);
|
|
17
|
+
else
|
|
18
|
+
list.push(stroke);
|
|
19
|
+
bumpVersion(instanceId);
|
|
20
|
+
notify();
|
|
21
|
+
},
|
|
22
|
+
strokes(instanceId) {
|
|
23
|
+
return strokesByInstance.get(instanceId) ?? [];
|
|
24
|
+
},
|
|
25
|
+
paintedIds() {
|
|
26
|
+
return Array.from(strokesByInstance.keys());
|
|
27
|
+
},
|
|
28
|
+
clear(instanceId) {
|
|
29
|
+
if (instanceId === undefined) {
|
|
30
|
+
if (strokesByInstance.size === 0)
|
|
31
|
+
return;
|
|
32
|
+
for (const id of strokesByInstance.keys())
|
|
33
|
+
bumpVersion(id);
|
|
34
|
+
strokesByInstance.clear();
|
|
35
|
+
notify();
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (!strokesByInstance.delete(instanceId))
|
|
39
|
+
return;
|
|
40
|
+
bumpVersion(instanceId);
|
|
41
|
+
notify();
|
|
42
|
+
},
|
|
43
|
+
version(instanceId) {
|
|
44
|
+
return versionByInstance.get(instanceId) ?? 0;
|
|
45
|
+
},
|
|
46
|
+
subscribe(listener) {
|
|
47
|
+
listeners.add(listener);
|
|
48
|
+
return () => {
|
|
49
|
+
listeners.delete(listener);
|
|
50
|
+
};
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
package/dist/scene/spatial.d.ts
CHANGED
|
@@ -18,10 +18,22 @@ export interface MoveTowardOptions {
|
|
|
18
18
|
stopDistance?: number;
|
|
19
19
|
dt: number;
|
|
20
20
|
}
|
|
21
|
+
export interface SpatialGridOptions {
|
|
22
|
+
/** Cell edge over the x/z plane; tune to ≈ the typical query radius so a query touches a handful of cells. */
|
|
23
|
+
cellSize: number;
|
|
24
|
+
}
|
|
21
25
|
export interface SpatialApiOptions {
|
|
22
26
|
resolvePosition: (instanceId: string) => EntityPosition | undefined;
|
|
23
27
|
candidates: () => string[];
|
|
24
28
|
occluder?: (from: EntityPosition, to: EntityPosition) => boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Opt-in broadphase acceleration for `inRadius`/`queryArc` over large candidate sets. When set,
|
|
31
|
+
* a uniform x/z cell index is built lazily from `candidates()` + `resolvePosition()` on first use
|
|
32
|
+
* and reused across calls until `invalidate()` is called — so many queries against one tick's
|
|
33
|
+
* frozen positions pay for one rebuild, not one linear scan each. Results are exactly the linear
|
|
34
|
+
* scan's results (same filtering; see `SpatialApi.invalidate` for the staleness contract).
|
|
35
|
+
*/
|
|
36
|
+
grid?: SpatialGridOptions;
|
|
25
37
|
}
|
|
26
38
|
export interface SpatialApi {
|
|
27
39
|
distance(aInstanceId: string, bInstanceId: string): number | null;
|
|
@@ -29,6 +41,15 @@ export interface SpatialApi {
|
|
|
29
41
|
hasLineOfSight(fromInstanceId: string, toInstanceId: string): boolean;
|
|
30
42
|
queryArc(options: QueryArcOptions): string[];
|
|
31
43
|
moveToward(instanceId: string, target: EntityPosition | string, options: MoveTowardOptions): EntityPosition | null;
|
|
44
|
+
/**
|
|
45
|
+
* Marks the `grid` broadphase index stale so the next `inRadius`/`queryArc` call rebuilds it from
|
|
46
|
+
* `candidates()` and `resolvePosition()`. Call this after moving, spawning, or despawning a
|
|
47
|
+
* candidate; a candidate absent from the index at query time is never skipped (it falls back to
|
|
48
|
+
* an exact check), but a candidate that *moved* since the last rebuild can be missed until you
|
|
49
|
+
* invalidate — so treat "invalidate after any position change" as the contract, not an
|
|
50
|
+
* optimization. A no-op when no `grid` config was supplied. Safe to call every tick.
|
|
51
|
+
*/
|
|
52
|
+
invalidate(): void;
|
|
32
53
|
}
|
|
33
54
|
export declare function distanceBetween(a: EntityPosition, b: EntityPosition): number;
|
|
34
55
|
export declare function createSpatialApi(options: SpatialApiOptions): SpatialApi;
|
package/dist/scene/spatial.js
CHANGED
|
@@ -14,11 +14,62 @@ function forwardXZ(aim) {
|
|
|
14
14
|
}
|
|
15
15
|
return [Math.sin(aim.yaw), Math.cos(aim.yaw)];
|
|
16
16
|
}
|
|
17
|
+
function cellCoord(value, cellSize) {
|
|
18
|
+
return Math.floor(value / cellSize);
|
|
19
|
+
}
|
|
20
|
+
function gridCellKey(cx, cz) {
|
|
21
|
+
return `${cx}:${cz}`;
|
|
22
|
+
}
|
|
17
23
|
export function createSpatialApi(options) {
|
|
18
|
-
const { resolvePosition, candidates, occluder } = options;
|
|
24
|
+
const { resolvePosition, candidates, occluder, grid } = options;
|
|
25
|
+
const cellSize = grid?.cellSize;
|
|
26
|
+
let gridIndex = null;
|
|
27
|
+
let gridDirty = true;
|
|
19
28
|
function resolveTarget(target) {
|
|
20
29
|
return typeof target === "string" ? resolvePosition(target) : target;
|
|
21
30
|
}
|
|
31
|
+
function ensureGrid() {
|
|
32
|
+
if (cellSize === undefined)
|
|
33
|
+
return null;
|
|
34
|
+
if (!gridDirty && gridIndex !== null)
|
|
35
|
+
return gridIndex;
|
|
36
|
+
const cells = new Map();
|
|
37
|
+
const indexed = new Set();
|
|
38
|
+
for (const instanceId of candidates()) {
|
|
39
|
+
const position = resolvePosition(instanceId);
|
|
40
|
+
if (position === undefined)
|
|
41
|
+
continue;
|
|
42
|
+
indexed.add(instanceId);
|
|
43
|
+
const key = gridCellKey(cellCoord(position[0], cellSize), cellCoord(position[2], cellSize));
|
|
44
|
+
let bucket = cells.get(key);
|
|
45
|
+
if (bucket === undefined) {
|
|
46
|
+
bucket = [];
|
|
47
|
+
cells.set(key, bucket);
|
|
48
|
+
}
|
|
49
|
+
bucket.push(instanceId);
|
|
50
|
+
}
|
|
51
|
+
gridIndex = { cells, indexed };
|
|
52
|
+
gridDirty = false;
|
|
53
|
+
return gridIndex;
|
|
54
|
+
}
|
|
55
|
+
function inRangeSet(index, centerX, centerZ, radius) {
|
|
56
|
+
const size = cellSize;
|
|
57
|
+
const cxMin = cellCoord(centerX - radius, size);
|
|
58
|
+
const cxMax = cellCoord(centerX + radius, size);
|
|
59
|
+
const czMin = cellCoord(centerZ - radius, size);
|
|
60
|
+
const czMax = cellCoord(centerZ + radius, size);
|
|
61
|
+
const near = new Set();
|
|
62
|
+
for (let cz = czMin; cz <= czMax; cz += 1) {
|
|
63
|
+
for (let cx = cxMin; cx <= cxMax; cx += 1) {
|
|
64
|
+
const bucket = index.cells.get(gridCellKey(cx, cz));
|
|
65
|
+
if (bucket === undefined)
|
|
66
|
+
continue;
|
|
67
|
+
for (const id of bucket)
|
|
68
|
+
near.add(id);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return near;
|
|
72
|
+
}
|
|
22
73
|
return {
|
|
23
74
|
distance(aInstanceId, bInstanceId) {
|
|
24
75
|
const a = resolvePosition(aInstanceId);
|
|
@@ -32,11 +83,15 @@ export function createSpatialApi(options) {
|
|
|
32
83
|
const centerPosition = resolveTarget(center);
|
|
33
84
|
if (centerPosition === undefined)
|
|
34
85
|
return [];
|
|
86
|
+
const index = ensureGrid();
|
|
87
|
+
const near = index === null ? null : inRangeSet(index, centerPosition[0], centerPosition[2], radius);
|
|
35
88
|
return candidates().filter((instanceId) => {
|
|
36
89
|
if (instanceId === centerId)
|
|
37
90
|
return false;
|
|
38
91
|
if (filter !== undefined && !filter(instanceId))
|
|
39
92
|
return false;
|
|
93
|
+
if (near !== null && index.indexed.has(instanceId) && !near.has(instanceId))
|
|
94
|
+
return false;
|
|
40
95
|
const position = resolvePosition(instanceId);
|
|
41
96
|
return position !== undefined && distanceBetween(centerPosition, position) <= radius;
|
|
42
97
|
});
|
|
@@ -58,9 +113,13 @@ export function createSpatialApi(options) {
|
|
|
58
113
|
if (forward === null)
|
|
59
114
|
return [];
|
|
60
115
|
const minDot = Math.cos((halfAngleDeg * Math.PI) / 180);
|
|
116
|
+
const index = ensureGrid();
|
|
117
|
+
const near = index === null ? null : inRangeSet(index, origin[0], origin[2], radius);
|
|
61
118
|
return candidates().filter((instanceId) => {
|
|
62
119
|
if (instanceId === from)
|
|
63
120
|
return false;
|
|
121
|
+
if (near !== null && index.indexed.has(instanceId) && !near.has(instanceId))
|
|
122
|
+
return false;
|
|
64
123
|
const position = resolvePosition(instanceId);
|
|
65
124
|
if (position === undefined)
|
|
66
125
|
return false;
|
|
@@ -75,6 +134,9 @@ export function createSpatialApi(options) {
|
|
|
75
134
|
return dot >= minDot;
|
|
76
135
|
});
|
|
77
136
|
},
|
|
137
|
+
invalidate() {
|
|
138
|
+
gridDirty = true;
|
|
139
|
+
},
|
|
78
140
|
moveToward(instanceId, target, { speed, stopDistance = 0, dt }) {
|
|
79
141
|
const current = resolvePosition(instanceId);
|
|
80
142
|
const destination = resolveTarget(target);
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export type ColorHex = string;
|
|
2
|
+
export declare function colorDistance(a: ColorHex, b: ColorHex): number;
|
|
3
|
+
export declare function concealmentScore(entityColors: readonly ColorHex[], backgroundColors: readonly ColorHex[]): number;
|
|
4
|
+
export interface ConcealmentTarget {
|
|
5
|
+
id: string;
|
|
6
|
+
entityColors: readonly ColorHex[];
|
|
7
|
+
backgroundColors: readonly ColorHex[];
|
|
8
|
+
}
|
|
9
|
+
export interface ConcealmentSample {
|
|
10
|
+
id: string;
|
|
11
|
+
score: number;
|
|
12
|
+
concealed: boolean;
|
|
13
|
+
dwellSeconds: number;
|
|
14
|
+
}
|
|
15
|
+
export interface ConcealmentSensor {
|
|
16
|
+
tick(targets: readonly ConcealmentTarget[], dt: number): ConcealmentSample[];
|
|
17
|
+
reset(id?: string): void;
|
|
18
|
+
}
|
|
19
|
+
export declare function createConcealmentSensor(config?: {
|
|
20
|
+
threshold?: number;
|
|
21
|
+
}): ConcealmentSensor;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
function parseHex(hex) {
|
|
2
|
+
const match = /^#?([0-9a-fA-F]{6})$/.exec(hex.trim());
|
|
3
|
+
if (match === null)
|
|
4
|
+
return [0, 0, 0];
|
|
5
|
+
const value = parseInt(match[1], 16);
|
|
6
|
+
return [(value >> 16) & 0xff, (value >> 8) & 0xff, value & 0xff];
|
|
7
|
+
}
|
|
8
|
+
function redmean(a, b) {
|
|
9
|
+
const rmean = (a[0] + b[0]) / 2;
|
|
10
|
+
const dr = a[0] - b[0];
|
|
11
|
+
const dg = a[1] - b[1];
|
|
12
|
+
const db = a[2] - b[2];
|
|
13
|
+
return Math.sqrt((2 + rmean / 256) * dr * dr + 4 * dg * dg + (2 + (255 - rmean) / 256) * db * db);
|
|
14
|
+
}
|
|
15
|
+
const REDMEAN_MAX = redmean([0, 0, 0], [255, 255, 255]);
|
|
16
|
+
export function colorDistance(a, b) {
|
|
17
|
+
const distance = redmean(parseHex(a), parseHex(b));
|
|
18
|
+
return REDMEAN_MAX === 0 ? 0 : Math.min(1, distance / REDMEAN_MAX);
|
|
19
|
+
}
|
|
20
|
+
export function concealmentScore(entityColors, backgroundColors) {
|
|
21
|
+
if (entityColors.length === 0 || backgroundColors.length === 0)
|
|
22
|
+
return 0;
|
|
23
|
+
let total = 0;
|
|
24
|
+
for (const entityColor of entityColors) {
|
|
25
|
+
let nearest = Number.POSITIVE_INFINITY;
|
|
26
|
+
for (const backgroundColor of backgroundColors) {
|
|
27
|
+
const distance = colorDistance(entityColor, backgroundColor);
|
|
28
|
+
if (distance < nearest)
|
|
29
|
+
nearest = distance;
|
|
30
|
+
}
|
|
31
|
+
total += nearest;
|
|
32
|
+
}
|
|
33
|
+
return 1 - total / entityColors.length;
|
|
34
|
+
}
|
|
35
|
+
export function createConcealmentSensor(config) {
|
|
36
|
+
const threshold = config?.threshold ?? 0.7;
|
|
37
|
+
const dwell = new Map();
|
|
38
|
+
return {
|
|
39
|
+
tick(targets, dt) {
|
|
40
|
+
const seen = new Set();
|
|
41
|
+
const samples = [];
|
|
42
|
+
for (const target of targets) {
|
|
43
|
+
seen.add(target.id);
|
|
44
|
+
const score = concealmentScore(target.entityColors, target.backgroundColors);
|
|
45
|
+
const concealed = score >= threshold;
|
|
46
|
+
const nextDwell = concealed ? (dwell.get(target.id) ?? 0) + dt : 0;
|
|
47
|
+
dwell.set(target.id, nextDwell);
|
|
48
|
+
samples.push({ id: target.id, score, concealed, dwellSeconds: nextDwell });
|
|
49
|
+
}
|
|
50
|
+
for (const id of dwell.keys()) {
|
|
51
|
+
if (!seen.has(id))
|
|
52
|
+
dwell.delete(id);
|
|
53
|
+
}
|
|
54
|
+
return samples;
|
|
55
|
+
},
|
|
56
|
+
reset(id) {
|
|
57
|
+
if (id === undefined)
|
|
58
|
+
dwell.clear();
|
|
59
|
+
else
|
|
60
|
+
dwell.delete(id);
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface FreezeSubject {
|
|
2
|
+
id: string;
|
|
3
|
+
groundSpeed: number;
|
|
4
|
+
}
|
|
5
|
+
export interface FreezeViolation {
|
|
6
|
+
id: string;
|
|
7
|
+
speed: number;
|
|
8
|
+
movedSeconds: number;
|
|
9
|
+
}
|
|
10
|
+
export interface FreezeMonitor {
|
|
11
|
+
tick(subjects: readonly FreezeSubject[], frozenIds: ReadonlySet<string>, dt: number): FreezeViolation[];
|
|
12
|
+
reset(id?: string): void;
|
|
13
|
+
}
|
|
14
|
+
export declare function createFreezeMonitor(config?: {
|
|
15
|
+
toleranceSpeed?: number;
|
|
16
|
+
graceSeconds?: number;
|
|
17
|
+
}): FreezeMonitor;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export function createFreezeMonitor(config) {
|
|
2
|
+
const toleranceSpeed = config?.toleranceSpeed ?? 0.05;
|
|
3
|
+
const graceSeconds = config?.graceSeconds ?? 0;
|
|
4
|
+
const moved = new Map();
|
|
5
|
+
return {
|
|
6
|
+
tick(subjects, frozenIds, dt) {
|
|
7
|
+
const seen = new Set();
|
|
8
|
+
const violations = [];
|
|
9
|
+
for (const subject of subjects) {
|
|
10
|
+
seen.add(subject.id);
|
|
11
|
+
const shouldHoldStill = frozenIds.has(subject.id) && subject.groundSpeed > toleranceSpeed;
|
|
12
|
+
if (!shouldHoldStill) {
|
|
13
|
+
moved.delete(subject.id);
|
|
14
|
+
continue;
|
|
15
|
+
}
|
|
16
|
+
const movedSeconds = (moved.get(subject.id) ?? 0) + dt;
|
|
17
|
+
moved.set(subject.id, movedSeconds);
|
|
18
|
+
if (movedSeconds > graceSeconds) {
|
|
19
|
+
violations.push({ id: subject.id, speed: subject.groundSpeed, movedSeconds });
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
for (const id of moved.keys()) {
|
|
23
|
+
if (!seen.has(id))
|
|
24
|
+
moved.delete(id);
|
|
25
|
+
}
|
|
26
|
+
return violations;
|
|
27
|
+
},
|
|
28
|
+
reset(id) {
|
|
29
|
+
if (id === undefined)
|
|
30
|
+
moved.clear();
|
|
31
|
+
else
|
|
32
|
+
moved.delete(id);
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { shuffleWithRng } from "../cards/cardPile.js";
|
|
2
|
+
export function assignRoles(userIds, roles, rng = Math.random) {
|
|
3
|
+
if (roles.length === 0)
|
|
4
|
+
throw new Error("assignRoles needs at least one role");
|
|
5
|
+
const shuffled = shuffleWithRng(userIds, rng);
|
|
6
|
+
const assignment = {};
|
|
7
|
+
let index = 0;
|
|
8
|
+
const countRoles = roles.filter((role) => role.count !== undefined);
|
|
9
|
+
const ratioRoles = roles.filter((role) => role.count === undefined && role.ratio !== undefined);
|
|
10
|
+
const fillRole = roles.find((role) => role.count === undefined && role.ratio === undefined);
|
|
11
|
+
const fallbackRole = fillRole ?? roles[roles.length - 1];
|
|
12
|
+
for (const role of countRoles) {
|
|
13
|
+
const take = Math.max(0, Math.min(role.count, shuffled.length - index));
|
|
14
|
+
for (let i = 0; i < take; i += 1) {
|
|
15
|
+
assignment[shuffled[index]] = role.id;
|
|
16
|
+
index += 1;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
for (const role of ratioRoles) {
|
|
20
|
+
const wanted = Math.round(role.ratio * shuffled.length);
|
|
21
|
+
const take = Math.max(0, Math.min(wanted, shuffled.length - index));
|
|
22
|
+
for (let i = 0; i < take; i += 1) {
|
|
23
|
+
assignment[shuffled[index]] = role.id;
|
|
24
|
+
index += 1;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
while (index < shuffled.length) {
|
|
28
|
+
assignment[shuffled[index]] = fallbackRole.id;
|
|
29
|
+
index += 1;
|
|
30
|
+
}
|
|
31
|
+
return assignment;
|
|
32
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
export type RoundPhase =
|
|
1
|
+
export type RoundPhase = string;
|
|
2
|
+
/** Default phase-duration shape for the built-in buy/live/end cycle; pass a wider `Record<string, number>` when using a custom `phaseOrder`. */
|
|
2
3
|
export interface RoundPhaseDurations {
|
|
3
4
|
buy: number;
|
|
4
5
|
live: number;
|
|
@@ -9,9 +10,24 @@ export interface LossBonusRule {
|
|
|
9
10
|
step: number;
|
|
10
11
|
max: number;
|
|
11
12
|
}
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
/** A team entry with an optional role tag (e.g. "attacker", "defender") retrievable via `RoundState.roleOf`. */
|
|
14
|
+
export interface RoundTeam {
|
|
15
|
+
id: string;
|
|
16
|
+
role?: string;
|
|
17
|
+
}
|
|
18
|
+
export interface RoundConfig<TPhase extends string = RoundPhase> {
|
|
19
|
+
/** Duration in seconds for each phase name in `phaseOrder` (or the default `RoundPhaseDurations` shape). */
|
|
20
|
+
phases: Record<TPhase, number>;
|
|
21
|
+
teams: readonly string[] | readonly RoundTeam[];
|
|
22
|
+
/**
|
|
23
|
+
* Overrides the default `["buy", "live", "end"]` cycle. Phases other than the first and last are
|
|
24
|
+
* "conclude-eligible": `concludeRound`/`evaluate` may settle the round only while the current phase
|
|
25
|
+
* is neither the first nor the last entry, matching the original buy (no conclude) / live (conclude) /
|
|
26
|
+
* end (no conclude, settles the round) roles. The cycle wraps from the last entry back to the first.
|
|
27
|
+
*/
|
|
28
|
+
phaseOrder?: readonly TPhase[];
|
|
29
|
+
/** Evaluated by `evaluate()`; returning a team id concludes the round with that team as winner. */
|
|
30
|
+
winCondition?: (state: RoundSnapshot<TPhase>) => string | null;
|
|
15
31
|
maxRounds?: number;
|
|
16
32
|
winReward?: number;
|
|
17
33
|
lossBonus?: LossBonusRule;
|
|
@@ -23,33 +39,41 @@ export interface RoundEconomy {
|
|
|
23
39
|
won: boolean;
|
|
24
40
|
lossStreak: number;
|
|
25
41
|
}
|
|
26
|
-
export interface RoundEvent {
|
|
42
|
+
export interface RoundEvent<TPhase extends string = RoundPhase> {
|
|
27
43
|
kind: RoundEventKind;
|
|
28
44
|
round: number;
|
|
29
|
-
phase?:
|
|
30
|
-
nextPhase?:
|
|
45
|
+
phase?: TPhase;
|
|
46
|
+
nextPhase?: TPhase;
|
|
31
47
|
winner?: string;
|
|
32
48
|
economy?: RoundEconomy[];
|
|
33
49
|
}
|
|
34
|
-
export type PhaseEndHook = (endingPhase:
|
|
35
|
-
export interface RoundSnapshot {
|
|
50
|
+
export type PhaseEndHook<TPhase extends string = RoundPhase> = (endingPhase: TPhase, nextPhase: TPhase, round: number) => void;
|
|
51
|
+
export interface RoundSnapshot<TPhase extends string = RoundPhase> {
|
|
36
52
|
round: number;
|
|
37
|
-
phase:
|
|
53
|
+
phase: TPhase;
|
|
38
54
|
timeLeft: number;
|
|
39
55
|
scores: Record<string, number>;
|
|
40
56
|
lossStreaks: Record<string, number>;
|
|
57
|
+
roles: Record<string, string | undefined>;
|
|
41
58
|
matchOver: boolean;
|
|
42
59
|
}
|
|
43
|
-
export interface RoundState {
|
|
44
|
-
tick(dt: number): RoundEvent[];
|
|
45
|
-
concludeRound(winner: string): RoundEvent[];
|
|
46
|
-
|
|
47
|
-
|
|
60
|
+
export interface RoundState<TPhase extends string = RoundPhase> {
|
|
61
|
+
tick(dt: number): RoundEvent<TPhase>[];
|
|
62
|
+
concludeRound(winner: string): RoundEvent<TPhase>[];
|
|
63
|
+
/** Calls `RoundConfig.winCondition` (a no-op when absent) and concludes the round if it returns a team id. */
|
|
64
|
+
evaluate(): RoundEvent<TPhase>[];
|
|
65
|
+
onPhaseEnd(hook: PhaseEndHook<TPhase>): () => void;
|
|
66
|
+
phase(): TPhase;
|
|
48
67
|
round(): number;
|
|
49
68
|
timeLeft(): number;
|
|
50
69
|
score(team: string): number;
|
|
51
70
|
economyFor(team: string): RoundEconomy;
|
|
52
|
-
|
|
71
|
+
/** The role tag configured for `team`, if any. */
|
|
72
|
+
roleOf(team: string): string | undefined;
|
|
73
|
+
snapshot(): RoundSnapshot<TPhase>;
|
|
53
74
|
}
|
|
54
75
|
export declare function lossBonusFor(rule: LossBonusRule | undefined, streak: number): number;
|
|
55
|
-
export declare function createRoundState(config: RoundConfig): RoundState
|
|
76
|
+
export declare function createRoundState(config: RoundConfig<RoundPhase>): RoundState<RoundPhase>;
|
|
77
|
+
export declare function createRoundState<TPhase extends string>(config: RoundConfig<TPhase> & {
|
|
78
|
+
phaseOrder: readonly TPhase[];
|
|
79
|
+
}): RoundState<TPhase>;
|