@jgengine/core 0.25.0 → 0.27.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 +109 -0
- package/dist/ai/rvo.d.ts +106 -0
- package/dist/ai/rvo.js +581 -0
- package/dist/ai/utilityBrain.d.ts +173 -0
- package/dist/ai/utilityBrain.js +277 -0
- package/dist/combat/cover.d.ts +169 -0
- package/dist/combat/cover.js +264 -0
- package/dist/combat/damageRuntime.d.ts +150 -0
- package/dist/combat/damageRuntime.js +208 -0
- package/dist/combat/damageTable.d.ts +116 -0
- package/dist/combat/damageTable.js +165 -0
- package/dist/combat/hitZones.d.ts +70 -0
- package/dist/combat/hitZones.js +77 -0
- package/dist/combat/magazine.d.ts +8 -0
- package/dist/combat/magazine.js +20 -2
- package/dist/combat/squadCombat.d.ts +167 -0
- package/dist/combat/squadCombat.js +557 -0
- package/dist/combat/squadTactics.d.ts +73 -0
- package/dist/combat/squadTactics.js +49 -0
- package/dist/combat/statusRuntime.d.ts +142 -0
- package/dist/combat/statusRuntime.js +236 -0
- package/dist/combat/weaponDefinition.d.ts +106 -0
- package/dist/combat/weaponDefinition.js +108 -0
- package/dist/combat/weaponSystem.d.ts +189 -0
- package/dist/combat/weaponSystem.js +469 -0
- package/dist/combat.d.ts +9 -0
- package/dist/combat.js +9 -0
- package/dist/devtools/devtools.d.ts +4 -2
- package/dist/devtools/devtools.js +2 -1
- package/dist/devtools/discover.d.ts +2 -1
- package/dist/devtools/discover.js +38 -0
- package/dist/devtools/frame.d.ts +0 -2
- package/dist/devtools/frame.js +1 -2
- package/dist/devtools/logBus.d.ts +58 -0
- package/dist/devtools/logBus.js +79 -0
- package/dist/devtools/logs.d.ts +6 -3
- package/dist/devtools/logs.js +17 -13
- package/dist/devtools/report.d.ts +120 -0
- package/dist/devtools/report.js +49 -0
- package/dist/devtools/reportEndpoint.d.ts +17 -0
- package/dist/devtools/reportEndpoint.js +7 -0
- package/dist/devtools/types.d.ts +48 -11
- package/dist/editor/document.js +210 -0
- package/dist/editor/environment.d.ts +48 -1
- package/dist/editor/environment.js +161 -0
- package/dist/editor/index.d.ts +6 -2
- package/dist/editor/index.js +1 -1
- package/dist/editor/types.d.ts +54 -0
- package/dist/game/playableGame.d.ts +19 -2
- package/dist/gameplay.d.ts +9 -0
- package/dist/gameplay.js +9 -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 +92 -1
- package/dist/nav/laneGraph.d.ts +178 -0
- package/dist/nav/laneGraph.js +671 -0
- package/dist/nav/navCrowd.d.ts +138 -0
- package/dist/nav/navCrowd.js +412 -0
- package/dist/nav/navRuntime.d.ts +108 -0
- package/dist/nav/navRuntime.js +256 -0
- package/dist/nav/traffic.d.ts +223 -0
- package/dist/nav/traffic.js +919 -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/render/colorPipeline.d.ts +53 -0
- package/dist/render/colorPipeline.js +79 -0
- package/dist/render/lightProbes.d.ts +97 -0
- package/dist/render/lightProbes.js +221 -0
- package/dist/render/lookPreset.js +4 -4
- package/dist/render/physicalSky.d.ts +39 -0
- package/dist/render/physicalSky.js +89 -0
- package/dist/render/postProcessing.d.ts +40 -8
- package/dist/render/postProcessing.js +1 -1
- package/dist/render/skyOwnedLighting.d.ts +31 -0
- package/dist/render/skyOwnedLighting.js +46 -0
- package/dist/scene/builtinSceneKinds.js +2 -0
- package/dist/sensor/perception.d.ts +66 -5
- package/dist/sensor/perception.js +167 -42
- package/dist/sim/nearAgents.d.ts +68 -0
- package/dist/sim/nearAgents.js +152 -0
- package/dist/sim/needs.d.ts +60 -0
- package/dist/sim/needs.js +108 -0
- package/dist/sim/offScreen.d.ts +181 -0
- package/dist/sim/offScreen.js +450 -0
- package/dist/sim/routine.d.ts +79 -0
- package/dist/sim/routine.js +137 -0
- package/dist/visibility/visibilitySystem.js +21 -0
- package/dist/world/decalKind.d.ts +48 -0
- package/dist/world/decalKind.js +86 -0
- package/dist/world/features.d.ts +11 -1
- 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.d.ts +10 -1
- package/dist/world.js +9 -0
- package/package.json +1 -1
- package/skills/jgengine/api.md +59 -6
- package/skills/jgengine/capabilities.md +4 -0
- package/skills/jgengine-combat/SKILL.md +19 -0
- package/skills/jgengine-combat/api.md +246 -0
- package/skills/jgengine-combat/capabilities.md +112 -0
- package/skills/jgengine-combat/reference.md +32 -0
- package/skills/jgengine-gameplay/SKILL.md +39 -0
- package/skills/jgengine-gameplay/api.md +221 -7
- package/skills/jgengine-gameplay/capabilities.md +64 -3
- package/skills/jgengine-gameplay/reference-systems.md +118 -0
- package/skills/jgengine-world/SKILL.md +7 -1
- package/skills/jgengine-world/api.md +222 -8
- package/skills/jgengine-world/capabilities.md +42 -2
- package/skills/jgengine-world/recipes/npc-senses.md +8 -2
- package/skills/jgengine-world/recipes/vehicle-feel.md +5 -0
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
import { devtools } from "../devtools/devtools.js";
|
|
2
|
+
import { createNavCrowd } from "../nav/navCrowd.js";
|
|
3
|
+
import { resolveRng } from "../random/resolveRng.js";
|
|
4
|
+
import { selectCover, scoreCoverPoint } from "./cover.js";
|
|
5
|
+
import { assignSquadRoles, threatsFromMemory, } from "./squadTactics.js";
|
|
6
|
+
const ROLE_DEFAULTS = {
|
|
7
|
+
preferredRange: 6,
|
|
8
|
+
flankAngle: 0,
|
|
9
|
+
retreatBelow: 0,
|
|
10
|
+
holdCover: false,
|
|
11
|
+
discipline: { holdSeconds: 1.6, peekSeconds: 1, peekDistance: 1.3, fireInterval: 0.7 },
|
|
12
|
+
};
|
|
13
|
+
const TUNABLE_DEFAULTS = {
|
|
14
|
+
reselectSeconds: 0.6,
|
|
15
|
+
maxCoverCandidates: 24,
|
|
16
|
+
coverRadius: 12,
|
|
17
|
+
maxThreats: 6,
|
|
18
|
+
suppressSeconds: 2.5,
|
|
19
|
+
pinAt: 0.5,
|
|
20
|
+
exposureLimitSeconds: 3,
|
|
21
|
+
speed: 3.2,
|
|
22
|
+
radius: 0.4,
|
|
23
|
+
goalEpsilon: 0.15,
|
|
24
|
+
coverSwitchMargin: 0.8,
|
|
25
|
+
relocateAfterSeconds: 1.5,
|
|
26
|
+
};
|
|
27
|
+
function resolveRole(role) {
|
|
28
|
+
return {
|
|
29
|
+
id: role.id,
|
|
30
|
+
preferredRange: Math.max(0, role.preferredRange ?? ROLE_DEFAULTS.preferredRange),
|
|
31
|
+
flankAngle: role.flankAngle ?? ROLE_DEFAULTS.flankAngle,
|
|
32
|
+
retreatBelow: Math.max(0, Math.min(1, role.retreatBelow ?? ROLE_DEFAULTS.retreatBelow)),
|
|
33
|
+
holdCover: role.holdCover ?? ROLE_DEFAULTS.holdCover,
|
|
34
|
+
discipline: { ...ROLE_DEFAULTS.discipline, ...(role.discipline ?? {}) },
|
|
35
|
+
cover: { ...(role.cover ?? {}) },
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function flatDistance(a, b) {
|
|
39
|
+
return Math.hypot(a[0] - b[0], a[2] - b[2]);
|
|
40
|
+
}
|
|
41
|
+
function pointToward(from, to, distance) {
|
|
42
|
+
const dx = to[0] - from[0];
|
|
43
|
+
const dz = to[2] - from[2];
|
|
44
|
+
const length = Math.hypot(dx, dz);
|
|
45
|
+
if (length < 0.0001)
|
|
46
|
+
return from;
|
|
47
|
+
return [from[0] + (dx / length) * distance, from[1], from[2] + (dz / length) * distance];
|
|
48
|
+
}
|
|
49
|
+
function samePoint(a, b, epsilon) {
|
|
50
|
+
if (a === null || b === null)
|
|
51
|
+
return a === b;
|
|
52
|
+
return Math.abs(a[0] - b[0]) <= epsilon && Math.abs(a[1] - b[1]) <= epsilon && Math.abs(a[2] - b[2]) <= epsilon;
|
|
53
|
+
}
|
|
54
|
+
function copy(point) {
|
|
55
|
+
return point === null ? null : [point[0], point[1], point[2]];
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* One squad's combat behavior per tick, over the perception memory a game already keeps and the nav
|
|
59
|
+
* crowd it already moves. Each member picks a stand point that hides it from the threats it remembers,
|
|
60
|
+
* at the distance and angle its role wants; a flanker goes around the side, a support holds and fires,
|
|
61
|
+
* an assault closes. Incoming fire raises suppression, which pins the member to its cover and stops
|
|
62
|
+
* its shooting until the meter decays; low health turns it toward the point furthest from the threat.
|
|
63
|
+
*
|
|
64
|
+
* Cover refresh runs on a per-member timer, so the per-tick cost is the crowd step plus one bounded
|
|
65
|
+
* memory read and a handful of segment tests per member — not a world scan.
|
|
66
|
+
*
|
|
67
|
+
* @capability squad-combat threat-driven cover, flanking, suppression, retreat and role-driven fire discipline
|
|
68
|
+
*/
|
|
69
|
+
export function createSquadCombat(options) {
|
|
70
|
+
const nav = options.nav;
|
|
71
|
+
const perception = options.perception;
|
|
72
|
+
const coverSource = options.cover;
|
|
73
|
+
const teamOf = options.teamOf;
|
|
74
|
+
const blocked = options.blocked;
|
|
75
|
+
const engage = options.engage;
|
|
76
|
+
const healthOf = options.healthOf;
|
|
77
|
+
const rng = resolveRng(options.rng, "createSquadCombat");
|
|
78
|
+
let tunables = { ...TUNABLE_DEFAULTS };
|
|
79
|
+
const crowd = createNavCrowd({
|
|
80
|
+
nav,
|
|
81
|
+
speed: tunables.speed,
|
|
82
|
+
radius: tunables.radius,
|
|
83
|
+
// A stand point only hides the body if the body actually reaches it; the default half-metre
|
|
84
|
+
// arrival tolerance leaves an agent half in the open.
|
|
85
|
+
goalRadius: 0.15,
|
|
86
|
+
waypointRadius: 0.2,
|
|
87
|
+
replanBudget: 6,
|
|
88
|
+
replanMs: 1.2,
|
|
89
|
+
});
|
|
90
|
+
const rolesById = new Map();
|
|
91
|
+
const defaultRole = resolveRole(options.defaultRole ?? { id: "member" });
|
|
92
|
+
const members = [];
|
|
93
|
+
const memberOf = new Map();
|
|
94
|
+
const squads = new Map();
|
|
95
|
+
let nowMs = 0;
|
|
96
|
+
let ticks = 0;
|
|
97
|
+
let coverQueries = 0;
|
|
98
|
+
let coverScored = 0;
|
|
99
|
+
let losChecks = 0;
|
|
100
|
+
let shots = 0;
|
|
101
|
+
let worstExposure = 0;
|
|
102
|
+
let exposuresOverLimit = 0;
|
|
103
|
+
let slowestTickMs = 0;
|
|
104
|
+
for (const spec of options.members) {
|
|
105
|
+
const squad = spec.squad ?? spec.team;
|
|
106
|
+
const list = squads.get(squad) ?? [];
|
|
107
|
+
list.push(spec.id);
|
|
108
|
+
squads.set(squad, list);
|
|
109
|
+
const member = {
|
|
110
|
+
id: spec.id,
|
|
111
|
+
team: spec.team,
|
|
112
|
+
squad,
|
|
113
|
+
role: defaultRole,
|
|
114
|
+
health: spec.health ?? 1,
|
|
115
|
+
mode: "advance",
|
|
116
|
+
target: null,
|
|
117
|
+
targetPosition: null,
|
|
118
|
+
threatPosition: null,
|
|
119
|
+
cover: null,
|
|
120
|
+
goal: null,
|
|
121
|
+
reselect: 0,
|
|
122
|
+
peek: 0,
|
|
123
|
+
peeking: false,
|
|
124
|
+
fire: 0,
|
|
125
|
+
suppression: 0,
|
|
126
|
+
exposure: 0,
|
|
127
|
+
shots: 0,
|
|
128
|
+
hostile: (targetId) => teamOf(targetId) !== spec.team,
|
|
129
|
+
};
|
|
130
|
+
members.push(member);
|
|
131
|
+
memberOf.set(member.id, member);
|
|
132
|
+
crowd.add(member.id, { position: spec.position, goal: spec.position });
|
|
133
|
+
// De-sync peek phases so a squad does not lean out in lockstep.
|
|
134
|
+
member.peek = rng() * (member.role.discipline.holdSeconds + member.role.discipline.peekSeconds);
|
|
135
|
+
}
|
|
136
|
+
function compositionFor(squad) {
|
|
137
|
+
return typeof options.composition === "function" ? options.composition(squad) : options.composition;
|
|
138
|
+
}
|
|
139
|
+
for (const [squad, ids] of squads) {
|
|
140
|
+
const squadComposition = compositionFor(squad);
|
|
141
|
+
if (squadComposition === undefined)
|
|
142
|
+
continue;
|
|
143
|
+
for (const [id, role] of assignSquadRoles(ids, squadComposition)) {
|
|
144
|
+
const resolved = resolveRole(role);
|
|
145
|
+
rolesById.set(resolved.id, resolved);
|
|
146
|
+
memberOf.get(id).role = resolved;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
function squadLine(squad) {
|
|
150
|
+
const ids = squads.get(squad);
|
|
151
|
+
if (ids === undefined || ids.length === 0)
|
|
152
|
+
return null;
|
|
153
|
+
let x = 0;
|
|
154
|
+
let y = 0;
|
|
155
|
+
let z = 0;
|
|
156
|
+
let counted = 0;
|
|
157
|
+
for (const id of ids) {
|
|
158
|
+
const body = crowd.agent(id);
|
|
159
|
+
if (body === null)
|
|
160
|
+
continue;
|
|
161
|
+
x += body.position[0];
|
|
162
|
+
y += body.position[1];
|
|
163
|
+
z += body.position[2];
|
|
164
|
+
counted += 1;
|
|
165
|
+
}
|
|
166
|
+
return counted === 0 ? null : [x / counted, y / counted, z / counted];
|
|
167
|
+
}
|
|
168
|
+
function positionOf(member) {
|
|
169
|
+
return crowd.agent(member.id)?.position ?? [0, 0, 0];
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Where a peek steps to: straight out at the threat when the face already points at it, otherwise
|
|
173
|
+
* along the wall toward the end of cover, which is where a body actually gets a sight line.
|
|
174
|
+
*/
|
|
175
|
+
function peekSpot(member) {
|
|
176
|
+
const point = member.cover.point;
|
|
177
|
+
const threat = member.threatPosition;
|
|
178
|
+
const dx = threat[0] - point.position[0];
|
|
179
|
+
const dz = threat[2] - point.position[2];
|
|
180
|
+
const [nx, nz] = point.normal;
|
|
181
|
+
const along = dx * nx + dz * nz;
|
|
182
|
+
if (along >= 0)
|
|
183
|
+
return pointToward(point.position, threat, member.role.discipline.peekDistance);
|
|
184
|
+
const lateralX = dx - nx * along;
|
|
185
|
+
const lateralZ = dz - nz * along;
|
|
186
|
+
const length = Math.hypot(lateralX, lateralZ);
|
|
187
|
+
const step = member.role.discipline.peekDistance;
|
|
188
|
+
if (length < 1e-6)
|
|
189
|
+
return [point.position[0] + nx * step, point.position[1], point.position[2] + nz * step];
|
|
190
|
+
return [point.position[0] + (lateralX / length) * step, point.position[1], point.position[2] + (lateralZ / length) * step];
|
|
191
|
+
}
|
|
192
|
+
function lineOfSight(from, to) {
|
|
193
|
+
losChecks += 1;
|
|
194
|
+
return !blocked(from, to);
|
|
195
|
+
}
|
|
196
|
+
function selectPoint(member, threats, taken, retreating, survival) {
|
|
197
|
+
const position = positionOf(member);
|
|
198
|
+
const radius = tunables.coverRadius;
|
|
199
|
+
coverQueries += 1;
|
|
200
|
+
const candidates = coverSource.near(position, radius, tunables.maxCoverCandidates);
|
|
201
|
+
coverScored += candidates.length;
|
|
202
|
+
if (candidates.length === 0) {
|
|
203
|
+
member.cover = null;
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
const line = squadLine(member.squad) ?? position;
|
|
207
|
+
const weights = survival
|
|
208
|
+
? { hidden: 6, range: 0, flank: 0, near: 0.2, retreat: 1, path: 1, crowding: 1 }
|
|
209
|
+
: retreating
|
|
210
|
+
? { ...member.role.cover, range: 0, flank: 0, near: 0.3, retreat: 1, hidden: 3 }
|
|
211
|
+
: member.role.cover;
|
|
212
|
+
const query = {
|
|
213
|
+
position,
|
|
214
|
+
radius,
|
|
215
|
+
threats,
|
|
216
|
+
preferredRange: survival || retreating ? radius : member.role.preferredRange,
|
|
217
|
+
flankAngle: survival || retreating ? 0 : member.role.flankAngle,
|
|
218
|
+
lineThrough: line,
|
|
219
|
+
taken,
|
|
220
|
+
blocked: (from, to) => {
|
|
221
|
+
losChecks += 1;
|
|
222
|
+
return blocked(from, to);
|
|
223
|
+
},
|
|
224
|
+
hiddenOnly: true,
|
|
225
|
+
weights,
|
|
226
|
+
};
|
|
227
|
+
const choice = selectCover(candidates, query);
|
|
228
|
+
const held = member.cover === null ? null : scoreCoverPoint(member.cover.point, query);
|
|
229
|
+
// Retention: keep the held point unless the rival hides from strictly more threats, or ties on
|
|
230
|
+
// hiding and scores past the margin. Without it a squad churns between equivalent crates and
|
|
231
|
+
// never stops walking in the open. Under sustained fire there is no retention — move.
|
|
232
|
+
const better = choice !== null &&
|
|
233
|
+
(held === null ||
|
|
234
|
+
choice.hidden > held.hidden + 0.001 ||
|
|
235
|
+
(choice.hidden >= held.hidden - 0.001 && choice.score >= held.score + tunables.coverSwitchMargin));
|
|
236
|
+
if (!survival && held !== null && held.hidden > 0 && !better) {
|
|
237
|
+
member.cover = held;
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
member.cover = choice;
|
|
241
|
+
}
|
|
242
|
+
function updateTarget(member, threats, position) {
|
|
243
|
+
const reach = member.role.preferredRange * 1.8;
|
|
244
|
+
member.target = null;
|
|
245
|
+
member.targetPosition = null;
|
|
246
|
+
member.threatPosition = threats.find((threat) => threat.ageSeconds <= 12)?.position ?? null;
|
|
247
|
+
for (const threat of threats) {
|
|
248
|
+
if (threat.ageSeconds > 12)
|
|
249
|
+
continue;
|
|
250
|
+
if (flatDistance(position, threat.position) > reach)
|
|
251
|
+
continue;
|
|
252
|
+
if (!lineOfSight(position, threat.position))
|
|
253
|
+
continue;
|
|
254
|
+
member.target = threat.id;
|
|
255
|
+
member.targetPosition = threat.position;
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* The held point is still good only when it hides the member from every remembered threat and the
|
|
261
|
+
* member is not in the open right now. A point that hides from one of two shooters is not cover —
|
|
262
|
+
* it is a spot the other one can shoot.
|
|
263
|
+
*/
|
|
264
|
+
function coverStillGood(member, threats, holders, position) {
|
|
265
|
+
if (member.cover === null)
|
|
266
|
+
return false;
|
|
267
|
+
const holder = holders.get(member.cover.point.id);
|
|
268
|
+
if (holder !== undefined && holder !== member.id)
|
|
269
|
+
return false;
|
|
270
|
+
for (const threat of threats) {
|
|
271
|
+
if (threat.confidence < 0.05)
|
|
272
|
+
continue;
|
|
273
|
+
if (lineOfSight(member.cover.point.position, threat.position))
|
|
274
|
+
return false;
|
|
275
|
+
if (lineOfSight(position, threat.position))
|
|
276
|
+
return false;
|
|
277
|
+
}
|
|
278
|
+
return true;
|
|
279
|
+
}
|
|
280
|
+
function decide(member, threats, holders, dt) {
|
|
281
|
+
member.health = healthOf === undefined ? member.health : healthOf(member.id);
|
|
282
|
+
if (member.health <= 0) {
|
|
283
|
+
member.mode = "down";
|
|
284
|
+
member.target = null;
|
|
285
|
+
member.targetPosition = null;
|
|
286
|
+
member.cover = null;
|
|
287
|
+
member.goal = null;
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
const position = positionOf(member);
|
|
291
|
+
member.suppression = Math.max(0, member.suppression - dt / Math.max(0.001, tunables.suppressSeconds));
|
|
292
|
+
member.reselect = Math.max(0, member.reselect - dt);
|
|
293
|
+
member.fire = Math.max(0, member.fire - dt);
|
|
294
|
+
const pinned = member.suppression >= tunables.pinAt;
|
|
295
|
+
const retreating = member.role.retreatBelow > 0 && member.health <= member.role.retreatBelow;
|
|
296
|
+
const survival = member.exposure > tunables.relocateAfterSeconds;
|
|
297
|
+
updateTarget(member, threats, position);
|
|
298
|
+
if (member.cover === null || member.reselect <= 0 || survival || !coverStillGood(member, threats, holders, position)) {
|
|
299
|
+
const taken = new Set();
|
|
300
|
+
for (const [pointId, holder] of holders)
|
|
301
|
+
if (holder !== member.id)
|
|
302
|
+
taken.add(pointId);
|
|
303
|
+
// Under sustained fire, stop trusting the point the member is already at.
|
|
304
|
+
if (survival && member.cover !== null)
|
|
305
|
+
taken.add(member.cover.point.id);
|
|
306
|
+
selectPoint(member, threats, taken, retreating, survival);
|
|
307
|
+
member.reselect = tunables.reselectSeconds;
|
|
308
|
+
}
|
|
309
|
+
const atCover = member.cover !== null && flatDistance(position, member.cover.point.position) <= 0.9;
|
|
310
|
+
const targetVisible = member.target !== null && (member.targetPosition === null || lineOfSight(position, member.targetPosition));
|
|
311
|
+
if (retreating)
|
|
312
|
+
member.mode = "retreat";
|
|
313
|
+
else if (member.cover === null)
|
|
314
|
+
member.mode = "advance";
|
|
315
|
+
else if (!atCover)
|
|
316
|
+
member.mode = "advance";
|
|
317
|
+
else
|
|
318
|
+
member.mode = "cover";
|
|
319
|
+
if (member.mode === "cover" && atCover && member.threatPosition !== null && !pinned && !member.role.holdCover) {
|
|
320
|
+
member.peek -= dt;
|
|
321
|
+
if (member.peek <= 0) {
|
|
322
|
+
member.peeking = !member.peeking;
|
|
323
|
+
member.peek = member.peeking ? member.role.discipline.peekSeconds : member.role.discipline.holdSeconds;
|
|
324
|
+
}
|
|
325
|
+
if (member.peeking)
|
|
326
|
+
member.mode = "peek";
|
|
327
|
+
}
|
|
328
|
+
else {
|
|
329
|
+
member.peeking = false;
|
|
330
|
+
}
|
|
331
|
+
if (pinned) {
|
|
332
|
+
// Suppression only holds a member that already has cover; in the open it keeps running to it.
|
|
333
|
+
member.goal = member.cover === null || atCover ? position : member.cover.point.position;
|
|
334
|
+
}
|
|
335
|
+
else if (member.cover === null) {
|
|
336
|
+
const gap = member.targetPosition === null ? 0 : flatDistance(position, member.targetPosition) - member.role.preferredRange;
|
|
337
|
+
member.goal = member.targetPosition === null || gap <= 0
|
|
338
|
+
? position
|
|
339
|
+
: pointToward(position, member.targetPosition, Math.max(1, gap));
|
|
340
|
+
}
|
|
341
|
+
else if (member.mode === "peek" && member.threatPosition !== null) {
|
|
342
|
+
member.goal = peekSpot(member);
|
|
343
|
+
}
|
|
344
|
+
else {
|
|
345
|
+
member.goal = member.cover.point.position;
|
|
346
|
+
}
|
|
347
|
+
if (!retreating && targetVisible && member.target !== null && member.targetPosition !== null && member.fire <= 0) {
|
|
348
|
+
member.fire = Math.max(0.05, member.role.discipline.fireInterval);
|
|
349
|
+
member.shots += 1;
|
|
350
|
+
shots += 1;
|
|
351
|
+
engage?.(member.id, member.target, flatDistance(position, member.targetPosition));
|
|
352
|
+
}
|
|
353
|
+
let visible = false;
|
|
354
|
+
for (const threat of threats) {
|
|
355
|
+
if (threat.confidence < 0.05)
|
|
356
|
+
continue;
|
|
357
|
+
if (lineOfSight(position, threat.position)) {
|
|
358
|
+
visible = true;
|
|
359
|
+
break;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
const wasOver = member.exposure > tunables.exposureLimitSeconds;
|
|
363
|
+
member.exposure = visible ? member.exposure + dt : 0;
|
|
364
|
+
if (member.exposure > worstExposure)
|
|
365
|
+
worstExposure = member.exposure;
|
|
366
|
+
if (member.exposure > tunables.exposureLimitSeconds && !wasOver)
|
|
367
|
+
exposuresOverLimit += 1;
|
|
368
|
+
}
|
|
369
|
+
function viewOf(member) {
|
|
370
|
+
return {
|
|
371
|
+
id: member.id,
|
|
372
|
+
team: member.team,
|
|
373
|
+
squad: member.squad,
|
|
374
|
+
role: member.role.id,
|
|
375
|
+
mode: member.mode,
|
|
376
|
+
target: member.target,
|
|
377
|
+
cover: member.cover?.point.id ?? null,
|
|
378
|
+
position: positionOf(member),
|
|
379
|
+
health: member.health,
|
|
380
|
+
suppression: member.suppression,
|
|
381
|
+
exposure: member.exposure,
|
|
382
|
+
shots: member.shots,
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
function probeLine() {
|
|
386
|
+
let pinned = 0;
|
|
387
|
+
let peeking = 0;
|
|
388
|
+
for (const member of members) {
|
|
389
|
+
if (member.suppression >= tunables.pinAt)
|
|
390
|
+
pinned += 1;
|
|
391
|
+
if (member.mode === "peek")
|
|
392
|
+
peeking += 1;
|
|
393
|
+
}
|
|
394
|
+
return `${members.length} members ${pinned} pinned ${peeking} peeking · ${shots} shots · ${worstExposure.toFixed(1)}s worst exposure ${exposuresOverLimit} over ${tunables.exposureLimitSeconds}s · ${coverQueries} cover queries`;
|
|
395
|
+
}
|
|
396
|
+
const unregister = options.devtoolsName === undefined ? null : devtools.probes.register(options.devtoolsName, () => probeLine());
|
|
397
|
+
return {
|
|
398
|
+
tick(dt) {
|
|
399
|
+
if (!(dt > 0) || members.length === 0)
|
|
400
|
+
return;
|
|
401
|
+
const started = performance.now();
|
|
402
|
+
nowMs += dt * 1000;
|
|
403
|
+
ticks += 1;
|
|
404
|
+
const holders = new Map();
|
|
405
|
+
for (const member of members)
|
|
406
|
+
if (member.cover !== null)
|
|
407
|
+
holders.set(member.cover.point.id, member.id);
|
|
408
|
+
for (const member of members) {
|
|
409
|
+
const threats = threatsFromMemory(perception, member.id, nowMs, {
|
|
410
|
+
max: tunables.maxThreats,
|
|
411
|
+
hostile: member.hostile,
|
|
412
|
+
});
|
|
413
|
+
decide(member, threats, holders, dt);
|
|
414
|
+
for (const [pointId, holder] of holders)
|
|
415
|
+
if (holder === member.id)
|
|
416
|
+
holders.delete(pointId);
|
|
417
|
+
if (member.cover !== null)
|
|
418
|
+
holders.set(member.cover.point.id, member.id);
|
|
419
|
+
const body = crowd.agent(member.id);
|
|
420
|
+
if (body === null)
|
|
421
|
+
continue;
|
|
422
|
+
const goal = member.mode === "down" ? body.position : member.goal ?? body.position;
|
|
423
|
+
if (!samePoint(body.goal, goal, tunables.goalEpsilon))
|
|
424
|
+
crowd.setGoal(member.id, goal);
|
|
425
|
+
}
|
|
426
|
+
crowd.tick(dt);
|
|
427
|
+
slowestTickMs = Math.max(slowestTickMs, performance.now() - started);
|
|
428
|
+
},
|
|
429
|
+
underFire(memberId, amount = 1) {
|
|
430
|
+
const member = memberOf.get(memberId);
|
|
431
|
+
if (member === undefined || amount <= 0)
|
|
432
|
+
return;
|
|
433
|
+
member.suppression = Math.min(1, member.suppression + amount);
|
|
434
|
+
member.reselect = Math.min(member.reselect, 0.05);
|
|
435
|
+
},
|
|
436
|
+
member(id) {
|
|
437
|
+
const member = memberOf.get(id);
|
|
438
|
+
return member === undefined ? null : viewOf(member);
|
|
439
|
+
},
|
|
440
|
+
members: () => members.map(viewOf),
|
|
441
|
+
stats: () => ({
|
|
442
|
+
members: members.length,
|
|
443
|
+
ticks,
|
|
444
|
+
coverQueries,
|
|
445
|
+
coverScored,
|
|
446
|
+
losChecks,
|
|
447
|
+
shots,
|
|
448
|
+
worstExposure,
|
|
449
|
+
exposuresOverLimit,
|
|
450
|
+
slowestTickMs,
|
|
451
|
+
}),
|
|
452
|
+
retune(patch) {
|
|
453
|
+
const next = { ...tunables };
|
|
454
|
+
for (const key of Object.keys(TUNABLE_DEFAULTS)) {
|
|
455
|
+
const value = patch[key];
|
|
456
|
+
if (value !== undefined)
|
|
457
|
+
next[key] = Math.max(0, value);
|
|
458
|
+
}
|
|
459
|
+
tunables = next;
|
|
460
|
+
crowd.retune({ speed: tunables.speed, radius: tunables.radius });
|
|
461
|
+
},
|
|
462
|
+
snapshot() {
|
|
463
|
+
return {
|
|
464
|
+
nowMs,
|
|
465
|
+
tunables: { ...tunables },
|
|
466
|
+
members: members.map((member) => ({
|
|
467
|
+
id: member.id,
|
|
468
|
+
team: member.team,
|
|
469
|
+
squad: member.squad,
|
|
470
|
+
role: member.role.id,
|
|
471
|
+
mode: member.mode,
|
|
472
|
+
health: member.health,
|
|
473
|
+
target: member.target,
|
|
474
|
+
targetPosition: copy(member.targetPosition),
|
|
475
|
+
coverId: member.cover?.point.id ?? null,
|
|
476
|
+
coverPosition: copy(member.cover?.point.position ?? null),
|
|
477
|
+
coverHidden: member.cover?.hidden ?? 0,
|
|
478
|
+
goal: copy(member.goal),
|
|
479
|
+
reselect: member.reselect,
|
|
480
|
+
peek: member.peek,
|
|
481
|
+
peeking: member.peeking,
|
|
482
|
+
fire: member.fire,
|
|
483
|
+
suppression: member.suppression,
|
|
484
|
+
exposure: member.exposure,
|
|
485
|
+
shots: member.shots,
|
|
486
|
+
})),
|
|
487
|
+
crowd: crowd.snapshot(),
|
|
488
|
+
ticks,
|
|
489
|
+
coverQueries,
|
|
490
|
+
coverScored,
|
|
491
|
+
losChecks,
|
|
492
|
+
shots,
|
|
493
|
+
worstExposure,
|
|
494
|
+
exposuresOverLimit,
|
|
495
|
+
};
|
|
496
|
+
},
|
|
497
|
+
restore(next) {
|
|
498
|
+
nowMs = next.nowMs;
|
|
499
|
+
ticks = next.ticks;
|
|
500
|
+
coverQueries = next.coverQueries;
|
|
501
|
+
coverScored = next.coverScored;
|
|
502
|
+
losChecks = next.losChecks;
|
|
503
|
+
shots = next.shots;
|
|
504
|
+
worstExposure = next.worstExposure;
|
|
505
|
+
exposuresOverLimit = next.exposuresOverLimit;
|
|
506
|
+
tunables = { ...next.tunables };
|
|
507
|
+
crowd.restore(next.crowd);
|
|
508
|
+
crowd.retune({ speed: tunables.speed, radius: tunables.radius });
|
|
509
|
+
members.length = 0;
|
|
510
|
+
memberOf.clear();
|
|
511
|
+
squads.clear();
|
|
512
|
+
for (const saved of next.members) {
|
|
513
|
+
const member = {
|
|
514
|
+
id: saved.id,
|
|
515
|
+
team: saved.team,
|
|
516
|
+
squad: saved.squad,
|
|
517
|
+
role: rolesById.get(saved.role) ?? defaultRole,
|
|
518
|
+
health: saved.health,
|
|
519
|
+
mode: saved.mode,
|
|
520
|
+
target: saved.target,
|
|
521
|
+
targetPosition: copy(saved.targetPosition),
|
|
522
|
+
threatPosition: null,
|
|
523
|
+
cover: saved.coverId === null || saved.coverPosition === null
|
|
524
|
+
? null
|
|
525
|
+
: {
|
|
526
|
+
point: { id: saved.coverId, position: copy(saved.coverPosition), normal: [0, 0] },
|
|
527
|
+
score: 0,
|
|
528
|
+
hidden: saved.coverHidden,
|
|
529
|
+
range: 0,
|
|
530
|
+
flank: 0,
|
|
531
|
+
near: 0,
|
|
532
|
+
retreat: 0,
|
|
533
|
+
path: 0,
|
|
534
|
+
},
|
|
535
|
+
goal: copy(saved.goal),
|
|
536
|
+
reselect: saved.reselect,
|
|
537
|
+
peek: saved.peek,
|
|
538
|
+
peeking: saved.peeking,
|
|
539
|
+
fire: saved.fire,
|
|
540
|
+
suppression: saved.suppression,
|
|
541
|
+
exposure: saved.exposure,
|
|
542
|
+
shots: saved.shots,
|
|
543
|
+
hostile: (targetId) => teamOf(targetId) !== saved.team,
|
|
544
|
+
};
|
|
545
|
+
members.push(member);
|
|
546
|
+
memberOf.set(member.id, member);
|
|
547
|
+
const list = squads.get(member.squad) ?? [];
|
|
548
|
+
list.push(member.id);
|
|
549
|
+
squads.set(member.squad, list);
|
|
550
|
+
}
|
|
551
|
+
},
|
|
552
|
+
dispose() {
|
|
553
|
+
unregister?.();
|
|
554
|
+
crowd.dispose();
|
|
555
|
+
},
|
|
556
|
+
};
|
|
557
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { PerceptionService } from "../sensor/perception.js";
|
|
2
|
+
import type { CoverThreat, CoverWeights } from "./cover.js";
|
|
3
|
+
/**
|
|
4
|
+
* Fire discipline: how long a member stays down between shots, how long it leans out, and how fast
|
|
5
|
+
* it fires while it has a target. A role that peeks for longer holds an angle better and spends more
|
|
6
|
+
* time in the open — the trade is the caller's to tune.
|
|
7
|
+
*/
|
|
8
|
+
export interface FireDiscipline {
|
|
9
|
+
/** Seconds held in cover between peeks. Default `1.6`. */
|
|
10
|
+
holdSeconds?: number;
|
|
11
|
+
/** Seconds exposed while shooting. Default `0.8`. */
|
|
12
|
+
peekSeconds?: number;
|
|
13
|
+
/** How far toward the threat a peek steps out of cover. Default `0.9`. */
|
|
14
|
+
peekDistance?: number;
|
|
15
|
+
/** Seconds between shots while a target is visible. Default `0.7`. */
|
|
16
|
+
fireInterval?: number;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* One caller-defined squad role. The engine ships the machinery and no role list: a game names its
|
|
20
|
+
* own roles and dials them, so `"assault"`, `"breacher"` and `"sniper"` are content, not engine
|
|
21
|
+
* concepts.
|
|
22
|
+
*/
|
|
23
|
+
export interface SquadRole {
|
|
24
|
+
/** Id the game names and its HUD, logs and tests read. */
|
|
25
|
+
id: string;
|
|
26
|
+
/** Members this role takes when a squad is assigned. Default `1`. */
|
|
27
|
+
count?: number;
|
|
28
|
+
/** Distance from the threat this role wants to fight at, world units. Default `6`. */
|
|
29
|
+
preferredRange?: number;
|
|
30
|
+
/** Angle around the threat this role attacks from, radians off the squad's line. Default `0`. */
|
|
31
|
+
flankAngle?: number;
|
|
32
|
+
/** Health fraction at or below which this role falls back. Default `0` (never). */
|
|
33
|
+
retreatBelow?: number;
|
|
34
|
+
/** True when the role holds its point instead of closing to `preferredRange`. Default `false`. */
|
|
35
|
+
holdCover?: boolean;
|
|
36
|
+
discipline?: FireDiscipline;
|
|
37
|
+
/** Cover weights this role scores with; omitted fields take the shared defaults. */
|
|
38
|
+
cover?: CoverWeights;
|
|
39
|
+
}
|
|
40
|
+
/** A squad's roles in assignment order. */
|
|
41
|
+
export interface SquadComposition {
|
|
42
|
+
readonly roles: readonly SquadRole[];
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Deterministic role per member: declared order, `count` copies of each role, and the list repeats
|
|
46
|
+
* when the squad is larger than the composition. Same members and composition give the same answer
|
|
47
|
+
* on every host and after a reload.
|
|
48
|
+
*
|
|
49
|
+
* @capability squad-roles deterministic role assignment over a caller-authored composition
|
|
50
|
+
*/
|
|
51
|
+
export declare function assignSquadRoles(members: readonly string[], composition: SquadComposition): Map<string, SquadRole>;
|
|
52
|
+
/** One remembered enemy with its age, so a role can treat a stale sighting differently. */
|
|
53
|
+
export interface ThreatReading extends CoverThreat {
|
|
54
|
+
readonly ageSeconds: number;
|
|
55
|
+
/** Whose senses produced the fact: this member's own id, or the squadmate that shared it. */
|
|
56
|
+
readonly sourceId: string;
|
|
57
|
+
}
|
|
58
|
+
/** Bounds for {@link threatsFromMemory}. */
|
|
59
|
+
export interface ThreatReadOptions {
|
|
60
|
+
/** Most threats to return, strongest first. Default `6`. */
|
|
61
|
+
max?: number;
|
|
62
|
+
/** Memories weaker than this are ignored. Default `0.05`. */
|
|
63
|
+
minConfidence?: number;
|
|
64
|
+
/** Only these ids count as hostile; omit to treat every remembered id as a threat. */
|
|
65
|
+
hostile?: (targetId: string) => boolean;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Read one member's threat list out of its perception memory — the only source of threat. Sorted by
|
|
69
|
+
* confidence, then recency, then id, and bounded by `max`, so a crowded memory cannot grow the tick.
|
|
70
|
+
*
|
|
71
|
+
* @capability threats-from-memory turn perception memory into a bounded, ranked threat list
|
|
72
|
+
*/
|
|
73
|
+
export declare function threatsFromMemory(perception: PerceptionService, observerId: string, nowMs: number, options?: ThreatReadOptions): ThreatReading[];
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic role per member: declared order, `count` copies of each role, and the list repeats
|
|
3
|
+
* when the squad is larger than the composition. Same members and composition give the same answer
|
|
4
|
+
* on every host and after a reload.
|
|
5
|
+
*
|
|
6
|
+
* @capability squad-roles deterministic role assignment over a caller-authored composition
|
|
7
|
+
*/
|
|
8
|
+
export function assignSquadRoles(members, composition) {
|
|
9
|
+
const slots = [];
|
|
10
|
+
for (const role of composition.roles) {
|
|
11
|
+
const count = Math.max(0, Math.floor(role.count ?? 1));
|
|
12
|
+
for (let i = 0; i < count; i += 1)
|
|
13
|
+
slots.push(role);
|
|
14
|
+
}
|
|
15
|
+
const assigned = new Map();
|
|
16
|
+
if (slots.length === 0)
|
|
17
|
+
return assigned;
|
|
18
|
+
for (let i = 0; i < members.length; i += 1)
|
|
19
|
+
assigned.set(members[i], slots[i % slots.length]);
|
|
20
|
+
return assigned;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Read one member's threat list out of its perception memory — the only source of threat. Sorted by
|
|
24
|
+
* confidence, then recency, then id, and bounded by `max`, so a crowded memory cannot grow the tick.
|
|
25
|
+
*
|
|
26
|
+
* @capability threats-from-memory turn perception memory into a bounded, ranked threat list
|
|
27
|
+
*/
|
|
28
|
+
export function threatsFromMemory(perception, observerId, nowMs, options = {}) {
|
|
29
|
+
const minConfidence = options.minConfidence ?? 0.05;
|
|
30
|
+
const max = Math.max(0, Math.floor(options.max ?? 6));
|
|
31
|
+
const out = [];
|
|
32
|
+
for (const memory of perception.memory(observerId)) {
|
|
33
|
+
if (memory.confidence < minConfidence)
|
|
34
|
+
continue;
|
|
35
|
+
if (options.hostile !== undefined && !options.hostile(memory.targetId))
|
|
36
|
+
continue;
|
|
37
|
+
out.push({
|
|
38
|
+
id: memory.targetId,
|
|
39
|
+
position: memory.lastKnownPos,
|
|
40
|
+
confidence: memory.confidence,
|
|
41
|
+
ageSeconds: Math.max(0, nowMs - memory.lastSeenAt) / 1000,
|
|
42
|
+
sourceId: memory.sourceId,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
out.sort((a, b) => b.confidence - a.confidence ||
|
|
46
|
+
a.ageSeconds - b.ageSeconds ||
|
|
47
|
+
(a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
48
|
+
return out.slice(0, max);
|
|
49
|
+
}
|