@jgengine/core 0.26.0 → 0.28.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 +174 -0
- package/dist/ai/policeResponse.d.ts +253 -0
- package/dist/ai/policeResponse.js +421 -0
- package/dist/anim/actionLayer.d.ts +47 -0
- package/dist/anim/actionLayer.js +63 -0
- package/dist/anim/ikSolver.d.ts +14 -0
- package/dist/anim/ikSolver.js +37 -0
- package/dist/anim/locomotionGraph.d.ts +58 -3
- package/dist/anim/locomotionGraph.js +142 -15
- package/dist/anim/ragdollRig.d.ts +53 -0
- package/dist/anim/ragdollRig.js +132 -0
- package/dist/anim/skeletonStandard.d.ts +140 -0
- package/dist/anim/skeletonStandard.js +326 -0
- package/dist/combat/damageRuntime.d.ts +14 -1
- package/dist/combat/damageRuntime.js +62 -4
- package/dist/combat/death.js +7 -3
- package/dist/combat/deathReason.d.ts +7 -1
- package/dist/combat/effects.d.ts +25 -0
- package/dist/combat/effects.js +16 -0
- package/dist/combat/statusRuntime.d.ts +2 -0
- package/dist/combat/statusRuntime.js +1 -0
- package/dist/economy/loanBook.d.ts +182 -0
- package/dist/economy/loanBook.js +303 -0
- package/dist/economy/market.d.ts +161 -0
- package/dist/economy/market.js +248 -0
- package/dist/economy/transactionLedger.d.ts +162 -0
- package/dist/economy/transactionLedger.js +301 -0
- package/dist/editor/creatorStorage.js +3 -1
- package/dist/editor/simulation.d.ts +5 -2
- package/dist/editor/simulation.js +29 -19
- package/dist/game/cameraConfig.d.ts +14 -0
- package/dist/game/clipRoles.d.ts +5 -4
- package/dist/game/clipRoles.js +34 -4
- package/dist/game/events.d.ts +27 -0
- package/dist/game/gameSave.d.ts +142 -0
- package/dist/game/gameSave.js +174 -0
- package/dist/game/playableGame.d.ts +74 -6
- package/dist/game/saveSlotIndex.d.ts +54 -0
- package/dist/game/saveSlotIndex.js +71 -0
- package/dist/game/saveSlots.d.ts +2 -0
- package/dist/game/saveSlots.js +1 -1
- package/dist/game/saveStore.d.ts +8 -0
- package/dist/game/saveStore.js +16 -0
- package/dist/gameplay.d.ts +17 -2
- package/dist/gameplay.js +15 -0
- package/dist/inventory/carryLoad.d.ts +48 -0
- package/dist/inventory/carryLoad.js +44 -0
- package/dist/inventory/equipment.d.ts +95 -0
- package/dist/inventory/equipment.js +169 -0
- package/dist/inventory/inventoryModel.d.ts +15 -6
- package/dist/inventory/inventoryModel.js +54 -5
- package/dist/inventory/shapedGrid.d.ts +26 -0
- package/dist/inventory/shapedGrid.js +47 -0
- package/dist/loot/lootGen.d.ts +186 -0
- package/dist/loot/lootGen.js +437 -0
- package/dist/loot/lootWeapon.d.ts +35 -0
- package/dist/loot/lootWeapon.js +70 -0
- package/dist/meta/changelog.d.ts +1 -1
- package/dist/meta/changelog.js +156 -1
- package/dist/mission/compositions.d.ts +85 -0
- package/dist/mission/compositions.js +130 -0
- package/dist/mission/mission.d.ts +435 -0
- package/dist/mission/mission.js +770 -0
- package/dist/mission/triggers.d.ts +61 -0
- package/dist/mission/triggers.js +108 -0
- package/dist/nav/laneGraph.d.ts +178 -0
- package/dist/nav/laneGraph.js +671 -0
- package/dist/nav/traffic.d.ts +222 -0
- package/dist/nav/traffic.js +918 -0
- package/dist/physics/kinematicVehicle.d.ts +36 -0
- package/dist/physics/kinematicVehicle.js +15 -1
- package/dist/physics/vehicleDamage.d.ts +136 -0
- package/dist/physics/vehicleDamage.js +214 -0
- package/dist/physics/vehicleParts.d.ts +62 -0
- package/dist/physics/vehicleParts.js +65 -0
- package/dist/progression/gearScore.d.ts +56 -0
- package/dist/progression/gearScore.js +59 -0
- package/dist/progression/skillTree.d.ts +129 -0
- package/dist/progression/skillTree.js +265 -0
- package/dist/progression/xpCurve.d.ts +60 -0
- package/dist/progression/xpCurve.js +141 -0
- package/dist/runtime/context/combat.js +11 -0
- package/dist/scene/builtinSceneKinds.js +2 -0
- package/dist/settings/accessibilitySettings.d.ts +81 -0
- package/dist/settings/accessibilitySettings.js +152 -0
- package/dist/settings/graphicsProfile.d.ts +3 -0
- package/dist/settings/graphicsProfile.js +4 -0
- package/dist/settings/settingsModel.d.ts +1 -1
- package/dist/settings/settingsModel.js +1 -0
- package/dist/sim/nearAgents.d.ts +8 -1
- package/dist/sim/nearAgents.js +34 -17
- package/dist/sim/offScreen.d.ts +2 -1
- package/dist/sim/offScreen.js +14 -3
- package/dist/sim/population.d.ts +147 -0
- package/dist/sim/population.js +182 -0
- package/dist/sim/queue.d.ts +90 -0
- package/dist/sim/queue.js +171 -0
- package/dist/sim/rides.d.ts +287 -0
- package/dist/sim/rides.js +471 -0
- package/dist/sim/routine.js +19 -11
- package/dist/ui/loadingTips.d.ts +41 -0
- package/dist/ui/loadingTips.js +64 -0
- package/dist/ui/menuNav.d.ts +79 -0
- package/dist/ui/menuNav.js +147 -0
- package/dist/ui/menuTransition.d.ts +50 -0
- package/dist/ui/menuTransition.js +67 -0
- package/dist/ui.d.ts +2 -0
- package/dist/ui.js +2 -0
- package/dist/vfx/effectLibrary.d.ts +98 -0
- package/dist/vfx/effectLibrary.js +181 -0
- package/dist/vfx/particleBudget.d.ts +40 -0
- package/dist/vfx/particleBudget.js +39 -0
- package/dist/vfx/particleDirector.d.ts +16 -0
- package/dist/vfx/particleDirector.js +18 -0
- package/dist/world/authoredSimulation.js +7 -1
- package/dist/world/captureCamera.d.ts +30 -0
- package/dist/world/captureCamera.js +19 -0
- package/dist/world/decalKind.d.ts +48 -0
- package/dist/world/decalKind.js +86 -0
- package/dist/world/features.d.ts +18 -5
- package/dist/world/features.js +1 -0
- package/dist/world/grassKind.d.ts +5 -0
- package/dist/world/grassKind.js +3 -0
- package/dist/world/terraform.d.ts +8 -0
- package/dist/world/terrain.d.ts +1 -0
- package/dist/world/terrain.js +2 -0
- package/dist/world.d.ts +7 -2
- package/dist/world.js +5 -0
- package/package.json +1 -1
- package/skills/jgengine/api.md +2 -2
- package/skills/jgengine-combat/SKILL.md +9 -1
- package/skills/jgengine-combat/api.md +5 -4
- package/skills/jgengine-combat/reference.md +4 -0
- package/skills/jgengine-gameplay/SKILL.md +86 -0
- package/skills/jgengine-gameplay/api.md +485 -16
- package/skills/jgengine-gameplay/capabilities.md +131 -4
- package/skills/jgengine-gameplay/reference-systems.md +118 -0
- package/skills/jgengine-world/SKILL.md +8 -0
- package/skills/jgengine-world/api.md +243 -11
- package/skills/jgengine-world/capabilities.md +61 -1
- package/skills/jgengine-world/recipes/character-animation.md +25 -2
- package/skills/jgengine-world/recipes/vehicle-feel.md +5 -0
- package/skills/jgengine-world/reference.md +1 -1
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
import { devtools } from "../devtools/devtools.js";
|
|
2
|
+
import { advanceHeat, createHeatState, } from "./heatSystem.js";
|
|
3
|
+
/** The starting tier table: patrol until heat arrives, then investigate, pursue and finally cordon. */
|
|
4
|
+
export const DEFAULT_POLICE_TIERS = [
|
|
5
|
+
{ response: "investigate", threshold: 20, pursuers: 1, searchRadius: 25, roadblocks: 0 },
|
|
6
|
+
{ response: "pursue", threshold: 60, pursuers: 3, searchRadius: 40, roadblocks: 0 },
|
|
7
|
+
{ response: "roadblock", threshold: 120, pursuers: 5, searchRadius: 55, roadblocks: 3 },
|
|
8
|
+
];
|
|
9
|
+
/** The starting policy: linear decay, a six-second escape and a search area that grows at 2 m/s. */
|
|
10
|
+
export const DEFAULT_POLICE_TUNING = {
|
|
11
|
+
tiers: DEFAULT_POLICE_TIERS,
|
|
12
|
+
decayPerSecond: 10,
|
|
13
|
+
decayDelaySeconds: 0,
|
|
14
|
+
standDownSeconds: 6,
|
|
15
|
+
escapeSeconds: 6,
|
|
16
|
+
inViewGraceSeconds: 0.25,
|
|
17
|
+
searchGrowthPerSecond: 2,
|
|
18
|
+
searchRadiusMax: 90,
|
|
19
|
+
searchLaneLimit: 12,
|
|
20
|
+
roadblockLimit: 4,
|
|
21
|
+
seed: 1,
|
|
22
|
+
};
|
|
23
|
+
const MAX_PENDING_CRIMES = 64;
|
|
24
|
+
function resolveTuning(base, patch) {
|
|
25
|
+
const next = { ...base, tiers: base.tiers.map((tier) => ({ ...tier })) };
|
|
26
|
+
if (patch !== undefined) {
|
|
27
|
+
if (patch.tiers !== undefined)
|
|
28
|
+
next.tiers = patch.tiers.map((tier) => ({ ...tier }));
|
|
29
|
+
for (const key of [
|
|
30
|
+
"decayPerSecond",
|
|
31
|
+
"decayDelaySeconds",
|
|
32
|
+
"standDownSeconds",
|
|
33
|
+
"escapeSeconds",
|
|
34
|
+
"inViewGraceSeconds",
|
|
35
|
+
"searchGrowthPerSecond",
|
|
36
|
+
"searchRadiusMax",
|
|
37
|
+
"searchLaneLimit",
|
|
38
|
+
"roadblockLimit",
|
|
39
|
+
]) {
|
|
40
|
+
const value = patch[key];
|
|
41
|
+
if (typeof value === "number" && Number.isFinite(value))
|
|
42
|
+
next[key] = Math.max(0, value);
|
|
43
|
+
}
|
|
44
|
+
if (typeof patch.maxHeat === "number" && Number.isFinite(patch.maxHeat))
|
|
45
|
+
next.maxHeat = Math.max(0, patch.maxHeat);
|
|
46
|
+
if (patch.spawnRingRadius !== undefined) {
|
|
47
|
+
next.spawnRingRadius = [patch.spawnRingRadius[0], patch.spawnRingRadius[1]];
|
|
48
|
+
}
|
|
49
|
+
if (typeof patch.seed === "number" && Number.isFinite(patch.seed))
|
|
50
|
+
next.seed = patch.seed;
|
|
51
|
+
}
|
|
52
|
+
next.inViewGraceSeconds = Math.max(0.001, next.inViewGraceSeconds);
|
|
53
|
+
next.escapeSeconds = Math.max(0.001, next.escapeSeconds);
|
|
54
|
+
next.searchLaneLimit = Math.max(0, Math.floor(next.searchLaneLimit));
|
|
55
|
+
next.roadblockLimit = Math.max(0, Math.floor(next.roadblockLimit));
|
|
56
|
+
validateTuning(next);
|
|
57
|
+
return next;
|
|
58
|
+
}
|
|
59
|
+
function validateTuning(tuning) {
|
|
60
|
+
if (tuning.tiers.length === 0)
|
|
61
|
+
throw new Error("tiers must hold at least one entry.");
|
|
62
|
+
let previous = -Infinity;
|
|
63
|
+
for (const tier of tuning.tiers) {
|
|
64
|
+
if (!Number.isFinite(tier.threshold) || tier.threshold < 0)
|
|
65
|
+
throw new Error(`tier "${tier.response}" threshold must be non-negative.`);
|
|
66
|
+
if (tier.threshold <= previous)
|
|
67
|
+
throw new Error("tiers must ascend by threshold.");
|
|
68
|
+
previous = tier.threshold;
|
|
69
|
+
for (const field of ["pursuers", "searchRadius", "roadblocks"]) {
|
|
70
|
+
const value = tier[field];
|
|
71
|
+
if (!Number.isFinite(value) || value < 0)
|
|
72
|
+
throw new Error(`tier "${tier.response}" ${field} must be non-negative.`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (!Number.isFinite(tuning.decayPerSecond) || tuning.decayPerSecond < 0)
|
|
76
|
+
throw new Error("decayPerSecond must be non-negative.");
|
|
77
|
+
if (!Number.isFinite(tuning.searchRadiusMax) || tuning.searchRadiusMax < 0)
|
|
78
|
+
throw new Error("searchRadiusMax must be non-negative.");
|
|
79
|
+
if (tuning.spawnRingRadius !== undefined) {
|
|
80
|
+
const [min, max] = tuning.spawnRingRadius;
|
|
81
|
+
if (!Number.isFinite(min) || !Number.isFinite(max) || min < 0 || max < min) {
|
|
82
|
+
throw new Error("spawnRingRadius must be a finite [min, max] with min >= 0.");
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function heatConfigOf(tuning) {
|
|
87
|
+
return {
|
|
88
|
+
levels: tuning.tiers.map((tier, index) => ({ level: index + 1, threshold: tier.threshold, pursuerBudget: tier.pursuers })),
|
|
89
|
+
...(tuning.maxHeat === undefined ? {} : { maxHeat: tuning.maxHeat }),
|
|
90
|
+
decayPerSecond: tuning.decayPerSecond,
|
|
91
|
+
decayDelaySeconds: tuning.decayDelaySeconds,
|
|
92
|
+
standDownSeconds: tuning.standDownSeconds,
|
|
93
|
+
...(tuning.spawnRingRadius === undefined ? {} : { spawnRingRadius: tuning.spawnRingRadius }),
|
|
94
|
+
...(tuning.seed === undefined ? {} : { seed: tuning.seed }),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
function tierFor(tiers, heat) {
|
|
98
|
+
let current = null;
|
|
99
|
+
for (const tier of tiers)
|
|
100
|
+
if (heat >= tier.threshold)
|
|
101
|
+
current = tier;
|
|
102
|
+
return current;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* The wanted level a crime sandbox needs, over the shared heat curve: a crime raises heat only while
|
|
106
|
+
* a witness holds the suspect in view, the heat bleeds on a stated linear curve once the suspect has
|
|
107
|
+
* been out of every witness's sight for `escapeSeconds`, the tier table decides what police do
|
|
108
|
+
* (patrol → investigate → pursue → roadblock), the search area sits where the suspect was last seen
|
|
109
|
+
* and grows while the trail is cold, and a roadblock tier cordons it with authored sites off the lane
|
|
110
|
+
* graph. Heat, tiers and curves are caller policy; witness facts come from `sensor/perception` memory
|
|
111
|
+
* (`sourceId`/`sourceTick` kept), so there is no second sight path.
|
|
112
|
+
*
|
|
113
|
+
* Deterministic: observers are asked in roster order, ties break on id, and the injected generator is
|
|
114
|
+
* the only source of variety. Bounded per tick: one memory query per observer, one indexed lane query,
|
|
115
|
+
* one pass over the authored roadblock sites, no path search.
|
|
116
|
+
*
|
|
117
|
+
* @capability police-response witness-gated heat, tiered police response, last-sighting search areas and lane-graph roadblocks
|
|
118
|
+
*/
|
|
119
|
+
export function createPoliceResponse(config) {
|
|
120
|
+
const witness = config.witness;
|
|
121
|
+
const observers = config.observers;
|
|
122
|
+
const suspectId = config.suspectId;
|
|
123
|
+
const roads = config.roads;
|
|
124
|
+
let tuning = resolveTuning(DEFAULT_POLICE_TUNING, config);
|
|
125
|
+
let heatState = createHeatState(heatConfigOf(tuning));
|
|
126
|
+
let outOfSightSeconds = 0;
|
|
127
|
+
let escaped = false;
|
|
128
|
+
let lastSighting = null;
|
|
129
|
+
const pending = [];
|
|
130
|
+
let lastTier = null;
|
|
131
|
+
let lastArea = null;
|
|
132
|
+
let lastRoadblocks = [];
|
|
133
|
+
let counters = { ticks: 0, crimes: 0, witnessed: 0, sightings: 0, escapes: 0, roadblockTicks: 0, slowestTickMs: 0 };
|
|
134
|
+
let sightingsChecked = 0;
|
|
135
|
+
let lanesInArea = 0;
|
|
136
|
+
let roadblockSites = 0;
|
|
137
|
+
/** The freshest sighting on the roster, which is also what "in view" and the search area read. */
|
|
138
|
+
function freshest() {
|
|
139
|
+
let best = null;
|
|
140
|
+
sightingsChecked = 0;
|
|
141
|
+
for (const observerId of observers()) {
|
|
142
|
+
sightingsChecked += 1;
|
|
143
|
+
const sighting = witness.memoryOf(observerId, suspectId);
|
|
144
|
+
if (sighting === null)
|
|
145
|
+
continue;
|
|
146
|
+
if (best === null || sighting.lastSeenAt > best.lastSeenAt)
|
|
147
|
+
best = sighting;
|
|
148
|
+
}
|
|
149
|
+
return best;
|
|
150
|
+
}
|
|
151
|
+
function responseLine() {
|
|
152
|
+
const area = lastArea === null ? "no area" : `${lastArea.radius.toFixed(0)} m area ${lastArea.lanes.length} lanes`;
|
|
153
|
+
return (`${heatState.heat.toFixed(0)} heat ${lastTier?.response ?? "patrol"} ${area} ${lastRoadblocks.length} roadblocks ` +
|
|
154
|
+
`${outOfSightSeconds.toFixed(1)} s out of sight ${counters.escapes} escapes ` +
|
|
155
|
+
`${counters.witnessed}/${counters.crimes} witnessed ${counters.slowestTickMs.toFixed(2)} ms slowest`);
|
|
156
|
+
}
|
|
157
|
+
const unregister = config.devtoolsName === undefined ? null : devtools.probes.register(config.devtoolsName, () => responseLine());
|
|
158
|
+
function advance(dt, ctx) {
|
|
159
|
+
const started = performance.now();
|
|
160
|
+
const sighting = freshest();
|
|
161
|
+
const inView = sighting !== null && ctx.nowMs - sighting.lastSeenAt <= tuning.inViewGraceSeconds * 1000;
|
|
162
|
+
if (sighting !== null && (lastSighting === null || sighting.lastSeenAt > lastSighting.at)) {
|
|
163
|
+
lastSighting = {
|
|
164
|
+
position: [sighting.lastKnownPos[0], sighting.lastKnownPos[2]],
|
|
165
|
+
at: sighting.lastSeenAt,
|
|
166
|
+
sourceId: sighting.sourceId,
|
|
167
|
+
sourceTick: sighting.sourceTick,
|
|
168
|
+
};
|
|
169
|
+
counters.sightings += 1;
|
|
170
|
+
}
|
|
171
|
+
outOfSightSeconds = inView ? 0 : outOfSightSeconds + dt;
|
|
172
|
+
const nowEscaped = outOfSightSeconds >= tuning.escapeSeconds;
|
|
173
|
+
if (nowEscaped && !escaped)
|
|
174
|
+
counters.escapes += 1;
|
|
175
|
+
escaped = nowEscaped;
|
|
176
|
+
const gains = [];
|
|
177
|
+
for (const amount of pending)
|
|
178
|
+
gains.push({ amount, witnessed: inView });
|
|
179
|
+
counters.crimes += pending.length;
|
|
180
|
+
if (inView)
|
|
181
|
+
counters.witnessed += pending.length;
|
|
182
|
+
pending.length = 0;
|
|
183
|
+
const heatStep = advanceHeat(heatConfigOf(tuning), heatState, dt, gains, {
|
|
184
|
+
nearWitness: !escaped,
|
|
185
|
+
activePursuers: ctx.activePursuers,
|
|
186
|
+
around: ctx.around,
|
|
187
|
+
});
|
|
188
|
+
heatState = heatStep.state;
|
|
189
|
+
const tier = tierFor(tuning.tiers, heatState.heat);
|
|
190
|
+
const tierChanged = (tier?.threshold ?? null) !== (lastTier?.threshold ?? null);
|
|
191
|
+
lastTier = tier;
|
|
192
|
+
let area = null;
|
|
193
|
+
lanesInArea = 0;
|
|
194
|
+
if (tier !== null && lastSighting !== null) {
|
|
195
|
+
const radius = Math.min(tuning.searchRadiusMax, tier.searchRadius + outOfSightSeconds * tuning.searchGrowthPerSecond);
|
|
196
|
+
const lanes = roads === undefined ? [] : roads.lanesNear(lastSighting.position[0], lastSighting.position[1], radius, tuning.searchLaneLimit);
|
|
197
|
+
lanesInArea = lanes.length;
|
|
198
|
+
area = {
|
|
199
|
+
center: lastSighting.position,
|
|
200
|
+
radius,
|
|
201
|
+
lanes: lanes.map((hit) => hit.lane),
|
|
202
|
+
lastSeenAt: lastSighting.at,
|
|
203
|
+
sourceId: lastSighting.sourceId,
|
|
204
|
+
sourceTick: lastSighting.sourceTick,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
lastArea = area;
|
|
208
|
+
let roadblocks = [];
|
|
209
|
+
roadblockSites = 0;
|
|
210
|
+
if (tier !== null && tier.roadblocks > 0 && area !== null && roads !== undefined) {
|
|
211
|
+
const wanted = Math.min(tier.roadblocks, tuning.roadblockLimit);
|
|
212
|
+
const inside = [];
|
|
213
|
+
for (const site of roads.roadblockSites()) {
|
|
214
|
+
const distance = Math.hypot(site.at[0] - area.center[0], site.at[1] - area.center[1]);
|
|
215
|
+
if (distance > area.radius)
|
|
216
|
+
continue;
|
|
217
|
+
inside.push({ site, distance });
|
|
218
|
+
}
|
|
219
|
+
roadblockSites = inside.length;
|
|
220
|
+
inside.sort((a, b) => a.distance - b.distance || (a.site.id < b.site.id ? -1 : a.site.id > b.site.id ? 1 : 0));
|
|
221
|
+
roadblocks = inside.slice(0, wanted).map((entry) => entry.site);
|
|
222
|
+
if (roadblocks.length > 0)
|
|
223
|
+
counters.roadblockTicks += 1;
|
|
224
|
+
}
|
|
225
|
+
lastRoadblocks = roadblocks;
|
|
226
|
+
counters.ticks += 1;
|
|
227
|
+
counters.slowestTickMs = Math.max(counters.slowestTickMs, performance.now() - started);
|
|
228
|
+
return {
|
|
229
|
+
heat: heatState.heat,
|
|
230
|
+
tier,
|
|
231
|
+
response: tier?.response ?? "patrol",
|
|
232
|
+
tierChanged,
|
|
233
|
+
pursuerBudget: heatStep.pursuerBudget,
|
|
234
|
+
wantPursuers: heatStep.wantSpawns,
|
|
235
|
+
spawnPoints: heatStep.spawnPoints,
|
|
236
|
+
searchArea: area,
|
|
237
|
+
roadblocks,
|
|
238
|
+
outOfSightSeconds,
|
|
239
|
+
escaped,
|
|
240
|
+
standDown: heatStep.standDown,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
return {
|
|
244
|
+
reportCrime(amount) {
|
|
245
|
+
if (!Number.isFinite(amount) || amount <= 0)
|
|
246
|
+
throw new Error("reportCrime amount must be positive.");
|
|
247
|
+
pending.push(amount);
|
|
248
|
+
if (pending.length > MAX_PENDING_CRIMES)
|
|
249
|
+
pending.shift();
|
|
250
|
+
},
|
|
251
|
+
tick(dt, ctx) {
|
|
252
|
+
if (!Number.isFinite(dt) || dt < 0)
|
|
253
|
+
throw new Error("dt must be finite and non-negative.");
|
|
254
|
+
if (!Number.isFinite(ctx.nowMs))
|
|
255
|
+
throw new Error("ctx.nowMs must be finite.");
|
|
256
|
+
return advance(dt, ctx);
|
|
257
|
+
},
|
|
258
|
+
wanted: () => ({ heat: heatState.heat, tier: lastTier, response: lastTier?.response ?? "patrol" }),
|
|
259
|
+
searchArea: () => lastArea,
|
|
260
|
+
roadblocks: () => lastRoadblocks,
|
|
261
|
+
stats: () => ({
|
|
262
|
+
heat: heatState.heat,
|
|
263
|
+
response: lastTier?.response ?? "patrol",
|
|
264
|
+
ticks: counters.ticks,
|
|
265
|
+
crimes: counters.crimes,
|
|
266
|
+
witnessed: counters.witnessed,
|
|
267
|
+
sightings: counters.sightings,
|
|
268
|
+
escapes: counters.escapes,
|
|
269
|
+
roadblockTicks: counters.roadblockTicks,
|
|
270
|
+
sightingsChecked,
|
|
271
|
+
lanesInArea,
|
|
272
|
+
roadblockSites,
|
|
273
|
+
slowestTickMs: counters.slowestTickMs,
|
|
274
|
+
}),
|
|
275
|
+
retune(patch) {
|
|
276
|
+
tuning = resolveTuning(tuning, patch);
|
|
277
|
+
},
|
|
278
|
+
state() {
|
|
279
|
+
return {
|
|
280
|
+
tunables: { ...tuning, tiers: tuning.tiers.map((tier) => ({ ...tier })) },
|
|
281
|
+
heat: { ...heatState },
|
|
282
|
+
outOfSightSeconds,
|
|
283
|
+
lastSighting: lastSighting === null ? null : { ...lastSighting, position: [...lastSighting.position] },
|
|
284
|
+
pending: pending.slice(),
|
|
285
|
+
counters: {
|
|
286
|
+
ticks: counters.ticks,
|
|
287
|
+
crimes: counters.crimes,
|
|
288
|
+
witnessed: counters.witnessed,
|
|
289
|
+
sightings: counters.sightings,
|
|
290
|
+
escapes: counters.escapes,
|
|
291
|
+
roadblockTicks: counters.roadblockTicks,
|
|
292
|
+
},
|
|
293
|
+
};
|
|
294
|
+
},
|
|
295
|
+
restore(next) {
|
|
296
|
+
tuning = resolveTuning(DEFAULT_POLICE_TUNING, next.tunables);
|
|
297
|
+
heatState = { ...next.heat };
|
|
298
|
+
outOfSightSeconds = Math.max(0, next.outOfSightSeconds);
|
|
299
|
+
lastSighting =
|
|
300
|
+
next.lastSighting === null
|
|
301
|
+
? null
|
|
302
|
+
: { ...next.lastSighting, position: [...next.lastSighting.position] };
|
|
303
|
+
pending.length = 0;
|
|
304
|
+
for (const amount of next.pending)
|
|
305
|
+
pending.push(amount);
|
|
306
|
+
counters = { ...next.counters, slowestTickMs: 0 };
|
|
307
|
+
escaped = outOfSightSeconds >= tuning.escapeSeconds;
|
|
308
|
+
lastTier = tierFor(tuning.tiers, heatState.heat);
|
|
309
|
+
lastArea = null;
|
|
310
|
+
lastRoadblocks = [];
|
|
311
|
+
lanesInArea = 0;
|
|
312
|
+
roadblockSites = 0;
|
|
313
|
+
sightingsChecked = 0;
|
|
314
|
+
},
|
|
315
|
+
dispose() {
|
|
316
|
+
unregister?.();
|
|
317
|
+
},
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Read the police road data out of an authored lane graph: `traffic_roadblock` markers become
|
|
322
|
+
* cordon sites projected onto the lane they block, and lanes are indexed so `lanesNear` answers from
|
|
323
|
+
* the local cells rather than a scan of every lane. With no roadblock markers authored, the graph's
|
|
324
|
+
* intersections are the sites, so a cordon still sits where streets meet. Built once; the index is
|
|
325
|
+
* rebuildable from the document, so it is never serialized.
|
|
326
|
+
*
|
|
327
|
+
* @capability police-roads lane-graph roadblock sites and an indexed nearby-lane query for search areas
|
|
328
|
+
*/
|
|
329
|
+
export function policeRoadsFromLaneGraph(graph, markers = [], options = {}) {
|
|
330
|
+
const cellSize = Math.max(0.5, options.cellSize ?? 8);
|
|
331
|
+
const joinRadius = Math.max(0, options.joinRadius ?? 8);
|
|
332
|
+
const kinds = new Set(options.roadblockKinds ?? ["traffic_roadblock"]);
|
|
333
|
+
const buckets = new Map();
|
|
334
|
+
const step = Math.max(0.5, cellSize / 2);
|
|
335
|
+
for (const laneId of graph.laneIds()) {
|
|
336
|
+
const lane = graph.lane(laneId);
|
|
337
|
+
if (lane === null)
|
|
338
|
+
continue;
|
|
339
|
+
for (let i = 0; i < lane.points.length - 1; i += 1) {
|
|
340
|
+
const a = lane.points[i];
|
|
341
|
+
const b = lane.points[i + 1];
|
|
342
|
+
const span = Math.hypot(b[0] - a[0], b[2] - a[2]);
|
|
343
|
+
const steps = Math.max(1, Math.ceil(span / step));
|
|
344
|
+
for (let s = 0; s <= steps; s += 1) {
|
|
345
|
+
const t = s / steps;
|
|
346
|
+
const key = `${Math.floor((a[0] + (b[0] - a[0]) * t) / cellSize)},${Math.floor((a[2] + (b[2] - a[2]) * t) / cellSize)}`;
|
|
347
|
+
let bucket = buckets.get(key);
|
|
348
|
+
if (bucket === undefined) {
|
|
349
|
+
bucket = new Set();
|
|
350
|
+
buckets.set(key, bucket);
|
|
351
|
+
}
|
|
352
|
+
bucket.add(laneId);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
function lanesNear(x, z, radius, limit) {
|
|
357
|
+
if (!(radius > 0) || limit <= 0)
|
|
358
|
+
return [];
|
|
359
|
+
const hits = [];
|
|
360
|
+
const seen = new Set();
|
|
361
|
+
for (let cx = Math.floor((x - radius) / cellSize); cx <= Math.floor((x + radius) / cellSize); cx += 1) {
|
|
362
|
+
for (let cz = Math.floor((z - radius) / cellSize); cz <= Math.floor((z + radius) / cellSize); cz += 1) {
|
|
363
|
+
const bucket = buckets.get(`${cx},${cz}`);
|
|
364
|
+
if (bucket === undefined)
|
|
365
|
+
continue;
|
|
366
|
+
for (const laneId of bucket) {
|
|
367
|
+
if (seen.has(laneId))
|
|
368
|
+
continue;
|
|
369
|
+
seen.add(laneId);
|
|
370
|
+
const arc = graph.project(laneId, x, z);
|
|
371
|
+
const pose = graph.pose(laneId, arc);
|
|
372
|
+
if (pose === null)
|
|
373
|
+
continue;
|
|
374
|
+
const distance = Math.hypot(pose.position[0] - x, pose.position[2] - z);
|
|
375
|
+
if (distance > radius)
|
|
376
|
+
continue;
|
|
377
|
+
hits.push({ lane: laneId, arc, distance });
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
hits.sort((a, b) => a.distance - b.distance || (a.lane < b.lane ? -1 : a.lane > b.lane ? 1 : 0));
|
|
382
|
+
return hits.slice(0, limit);
|
|
383
|
+
}
|
|
384
|
+
const sites = [];
|
|
385
|
+
for (const marker of markers) {
|
|
386
|
+
if (!kinds.has(marker.kind))
|
|
387
|
+
continue;
|
|
388
|
+
const hit = lanesNear(marker.position.x, marker.position.z, joinRadius, 1)[0];
|
|
389
|
+
if (hit === undefined)
|
|
390
|
+
continue;
|
|
391
|
+
const pose = graph.pose(hit.lane, hit.arc);
|
|
392
|
+
if (pose === null)
|
|
393
|
+
continue;
|
|
394
|
+
sites.push({
|
|
395
|
+
id: marker.id,
|
|
396
|
+
lane: hit.lane,
|
|
397
|
+
arc: hit.arc,
|
|
398
|
+
at: [pose.position[0], pose.position[2]],
|
|
399
|
+
heading: pose.heading,
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
if (sites.length === 0) {
|
|
403
|
+
for (const intersection of graph.intersections()) {
|
|
404
|
+
const lane = intersection.outLanes[0];
|
|
405
|
+
if (lane === undefined)
|
|
406
|
+
continue;
|
|
407
|
+
sites.push({
|
|
408
|
+
id: `node:${intersection.node}`,
|
|
409
|
+
lane,
|
|
410
|
+
arc: 0,
|
|
411
|
+
at: [intersection.at[0], intersection.at[2]],
|
|
412
|
+
heading: graph.heading(lane, 0),
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
sites.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
417
|
+
return {
|
|
418
|
+
lanesNear,
|
|
419
|
+
roadblockSites: () => sites,
|
|
420
|
+
};
|
|
421
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { AnimGraph, AnimLayer } from "./animGraph.js";
|
|
2
|
+
/** A masked, optionally additive overlay layer: a resting clip plus one one-shot state per action. */
|
|
3
|
+
export interface ActionLayerInput {
|
|
4
|
+
/** Layer id; also the prefix of its action state ids (`id:action`). */
|
|
5
|
+
id: string;
|
|
6
|
+
/**
|
|
7
|
+
* Bone name prefixes this layer drives (`["spine", "chest", "upperarm"]`). Required and nonempty:
|
|
8
|
+
* an overlay that names no bones would silently drive the whole rig. `UPPER_BODY_MASK` and
|
|
9
|
+
* `LOWER_BODY_MASK` (`@jgengine/core/anim/skeletonStandard`) cover a standard rig.
|
|
10
|
+
*/
|
|
11
|
+
mask: readonly string[];
|
|
12
|
+
/**
|
|
13
|
+
* Clip held while no action plays. On an additive layer this state is held at its first frame,
|
|
14
|
+
* which `makeClipAdditive` makes the zero reference, so rest contributes nothing to the pose.
|
|
15
|
+
*/
|
|
16
|
+
rest: string;
|
|
17
|
+
/** Trigger name → clip, or clips for a random variant, played once over the rest state. */
|
|
18
|
+
actions: Readonly<Record<string, string | readonly string[]>>;
|
|
19
|
+
/** Add the actions to the base pose instead of replacing the masked bones. Default false. */
|
|
20
|
+
additive?: boolean;
|
|
21
|
+
/** Layer influence `0..1`. Default 1. */
|
|
22
|
+
weight?: number;
|
|
23
|
+
/** Crossfade seconds into and out of an action. Default 0.1. */
|
|
24
|
+
fadeSec?: number;
|
|
25
|
+
/** Normalized time an action must reach before it returns to rest. Default 1. */
|
|
26
|
+
exitTime?: number;
|
|
27
|
+
}
|
|
28
|
+
/** State id of an action layer's resting state. */
|
|
29
|
+
export declare const ACTION_REST_STATE = "rest";
|
|
30
|
+
/**
|
|
31
|
+
* One overlay layer from plain data: a resting state plus a one-shot state per action, each joined
|
|
32
|
+
* to the rest by a consumed-trigger transition and returning on `exitTime`. Aim, reload, wave and
|
|
33
|
+
* hit reactions are the same shape, so they are one builder instead of a hand-written state machine
|
|
34
|
+
* per game. The layer is masked by bone prefix, and `additive: true` makes it a recoil/breathing
|
|
35
|
+
* overlay on top of the base pose rather than a replacement.
|
|
36
|
+
*
|
|
37
|
+
* @capability animation build a masked one-shot overlay layer for upper-body actions and hit reactions
|
|
38
|
+
*/
|
|
39
|
+
export declare function actionLayer(input: ActionLayerInput): AnimLayer;
|
|
40
|
+
/**
|
|
41
|
+
* A graph plus overlay layers: the base layers keep their ids and order, and each overlay is
|
|
42
|
+
* appended. Duplicate layer ids are rejected rather than silently shadowing, because the shell keys
|
|
43
|
+
* its mixer actions by layer id.
|
|
44
|
+
*
|
|
45
|
+
* @capability animation attach masked action layers to a locomotion graph
|
|
46
|
+
*/
|
|
47
|
+
export declare function withActionLayers(graph: AnimGraph, layers: readonly ActionLayerInput[]): AnimGraph;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/** State id of an action layer's resting state. */
|
|
2
|
+
export const ACTION_REST_STATE = "rest";
|
|
3
|
+
/**
|
|
4
|
+
* One overlay layer from plain data: a resting state plus a one-shot state per action, each joined
|
|
5
|
+
* to the rest by a consumed-trigger transition and returning on `exitTime`. Aim, reload, wave and
|
|
6
|
+
* hit reactions are the same shape, so they are one builder instead of a hand-written state machine
|
|
7
|
+
* per game. The layer is masked by bone prefix, and `additive: true` makes it a recoil/breathing
|
|
8
|
+
* overlay on top of the base pose rather than a replacement.
|
|
9
|
+
*
|
|
10
|
+
* @capability animation build a masked one-shot overlay layer for upper-body actions and hit reactions
|
|
11
|
+
*/
|
|
12
|
+
export function actionLayer(input) {
|
|
13
|
+
const mask = input.mask.map((prefix) => prefix.trim()).filter((prefix) => prefix.length > 0);
|
|
14
|
+
if (mask.length === 0)
|
|
15
|
+
throw new Error(`action layer "${input.id}" needs at least one bone name prefix in its mask`);
|
|
16
|
+
if (input.rest.trim().length === 0)
|
|
17
|
+
throw new Error(`action layer "${input.id}" needs a nonempty rest clip`);
|
|
18
|
+
const fade = input.fadeSec ?? 0.1;
|
|
19
|
+
const exitTime = input.exitTime ?? 1;
|
|
20
|
+
const states = {
|
|
21
|
+
[ACTION_REST_STATE]: input.additive === true
|
|
22
|
+
? { kind: "clip", clip: input.rest, loop: true, speed: 0 }
|
|
23
|
+
: { kind: "clip", clip: input.rest, loop: true },
|
|
24
|
+
};
|
|
25
|
+
const transitions = [];
|
|
26
|
+
for (const [name, spec] of Object.entries(input.actions)) {
|
|
27
|
+
const clips = typeof spec === "string" ? [spec] : spec;
|
|
28
|
+
if (clips.length === 0 || clips[0].trim().length === 0)
|
|
29
|
+
continue;
|
|
30
|
+
if (name === ACTION_REST_STATE)
|
|
31
|
+
throw new Error(`action layer "${input.id}" cannot name an action "${ACTION_REST_STATE}"; that id is the resting state`);
|
|
32
|
+
states[name] = { kind: "clip", clip: clips[0], loop: false, ...(clips.length < 2 ? {} : { variants: [...clips] }) };
|
|
33
|
+
transitions.push({ from: ACTION_REST_STATE, to: name, trigger: name, duration: fade });
|
|
34
|
+
transitions.push({ from: name, to: ACTION_REST_STATE, exitTime, duration: fade });
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
id: input.id,
|
|
38
|
+
entry: ACTION_REST_STATE,
|
|
39
|
+
states,
|
|
40
|
+
transitions,
|
|
41
|
+
mask,
|
|
42
|
+
...(input.additive === true ? { additive: true } : {}),
|
|
43
|
+
...(input.weight === undefined ? {} : { weight: input.weight }),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* A graph plus overlay layers: the base layers keep their ids and order, and each overlay is
|
|
48
|
+
* appended. Duplicate layer ids are rejected rather than silently shadowing, because the shell keys
|
|
49
|
+
* its mixer actions by layer id.
|
|
50
|
+
*
|
|
51
|
+
* @capability animation attach masked action layers to a locomotion graph
|
|
52
|
+
*/
|
|
53
|
+
export function withActionLayers(graph, layers) {
|
|
54
|
+
const ids = new Set(graph.layers.map((layer) => layer.id));
|
|
55
|
+
const built = [];
|
|
56
|
+
for (const input of layers) {
|
|
57
|
+
if (ids.has(input.id))
|
|
58
|
+
throw new Error(`animation graph already has a layer "${input.id}"`);
|
|
59
|
+
ids.add(input.id);
|
|
60
|
+
built.push(actionLayer(input));
|
|
61
|
+
}
|
|
62
|
+
return { layers: [...graph.layers, ...built], ...(graph.events === undefined ? {} : { events: graph.events }) };
|
|
63
|
+
}
|
package/dist/anim/ikSolver.d.ts
CHANGED
|
@@ -37,6 +37,20 @@ export interface LookAtInput {
|
|
|
37
37
|
* @capability animation inverse kinematics solve a two-bone chain from a target and bend pole
|
|
38
38
|
*/
|
|
39
39
|
export declare function solveTwoBone(input: TwoBoneInput, out: TwoBoneOutput): TwoBoneOutput;
|
|
40
|
+
/**
|
|
41
|
+
* The product of two quaternions (`a` applied after `b`), both as `[x, y, z, w]`.
|
|
42
|
+
*
|
|
43
|
+
* @capability animation inverse kinematics compose two rotations
|
|
44
|
+
*/
|
|
45
|
+
export declare function multiplyQuaternions(a: Quat, b: Quat): Quat;
|
|
46
|
+
/**
|
|
47
|
+
* The shortest rotation taking one unit direction to another, as a quaternion. Parallel directions
|
|
48
|
+
* give the identity; opposite directions give a half turn about a perpendicular axis. Returns the
|
|
49
|
+
* identity for a zero-length input.
|
|
50
|
+
*
|
|
51
|
+
* @capability animation inverse kinematics the shortest rotation between two directions
|
|
52
|
+
*/
|
|
53
|
+
export declare function quaternionFromUnitVectors(from: Vec3, to: Vec3): Quat;
|
|
40
54
|
/**
|
|
41
55
|
* Solves a chain with the FABRIK forward-and-backward reaching algorithm.
|
|
42
56
|
* Returned tuples are new values; the input chain is not modified.
|
package/dist/anim/ikSolver.js
CHANGED
|
@@ -44,6 +44,43 @@ export function solveTwoBone(input, out) {
|
|
|
44
44
|
out.tip[2] = clampedTarget[2];
|
|
45
45
|
return out;
|
|
46
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* The product of two quaternions (`a` applied after `b`), both as `[x, y, z, w]`.
|
|
49
|
+
*
|
|
50
|
+
* @capability animation inverse kinematics compose two rotations
|
|
51
|
+
*/
|
|
52
|
+
export function multiplyQuaternions(a, b) {
|
|
53
|
+
return [
|
|
54
|
+
a[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1],
|
|
55
|
+
a[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0],
|
|
56
|
+
a[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3],
|
|
57
|
+
a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2],
|
|
58
|
+
];
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* The shortest rotation taking one unit direction to another, as a quaternion. Parallel directions
|
|
62
|
+
* give the identity; opposite directions give a half turn about a perpendicular axis. Returns the
|
|
63
|
+
* identity for a zero-length input.
|
|
64
|
+
*
|
|
65
|
+
* @capability animation inverse kinematics the shortest rotation between two directions
|
|
66
|
+
*/
|
|
67
|
+
export function quaternionFromUnitVectors(from, to) {
|
|
68
|
+
const a = normalize(from[0], from[1], from[2]);
|
|
69
|
+
const b = normalize(to[0], to[1], to[2]);
|
|
70
|
+
if (Math.hypot(...a) <= EPSILON || Math.hypot(...b) <= EPSILON)
|
|
71
|
+
return [0, 0, 0, 1];
|
|
72
|
+
const dot = a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
|
73
|
+
if (dot >= 1 - EPSILON)
|
|
74
|
+
return [0, 0, 0, 1];
|
|
75
|
+
if (dot <= -1 + EPSILON) {
|
|
76
|
+
const axis = Math.abs(a[0]) < 0.9 ? normalize(...cross(a, [1, 0, 0])) : normalize(...cross(a, [0, 1, 0]));
|
|
77
|
+
return [axis[0], axis[1], axis[2], 0];
|
|
78
|
+
}
|
|
79
|
+
const c = cross(a, b);
|
|
80
|
+
const q = [c[0], c[1], c[2], 1 + dot];
|
|
81
|
+
const length = Math.hypot(...q);
|
|
82
|
+
return [q[0] / length, q[1] / length, q[2] / length, q[3] / length];
|
|
83
|
+
}
|
|
47
84
|
/**
|
|
48
85
|
* Solves a chain with the FABRIK forward-and-backward reaching algorithm.
|
|
49
86
|
* Returned tuples are new values; the input chain is not modified.
|