@jgengine/core 0.27.0 → 0.29.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 +140 -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 +55 -4
- 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 +12 -2
- package/dist/gameplay.js +10 -0
- package/dist/meta/changelog.d.ts +1 -1
- package/dist/meta/changelog.js +130 -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 +773 -0
- package/dist/mission/triggers.d.ts +61 -0
- package/dist/mission/triggers.js +108 -0
- package/dist/nav/traffic.d.ts +0 -1
- package/dist/nav/traffic.js +1 -2
- package/dist/runtime/context/combat.js +11 -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/features.d.ts +15 -4
- package/dist/world/terrain.d.ts +1 -0
- package/dist/world/terrain.js +2 -0
- package/dist/world.d.ts +2 -1
- package/dist/world.js +1 -0
- package/package.json +1 -1
- package/skills/jgengine/api.md +2 -2
- package/skills/jgengine-combat/SKILL.md +6 -0
- package/skills/jgengine-combat/api.md +5 -4
- package/skills/jgengine-combat/reference.md +4 -0
- package/skills/jgengine-gameplay/SKILL.md +55 -0
- package/skills/jgengine-gameplay/api.md +335 -10
- package/skills/jgengine-gameplay/capabilities.md +83 -1
- package/skills/jgengine-world/SKILL.md +4 -0
- package/skills/jgengine-world/api.md +128 -6
- package/skills/jgengine-world/capabilities.md +41 -1
- package/skills/jgengine-world/recipes/character-animation.md +25 -2
- package/skills/jgengine-world/reference.md +1 -1
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The engine's one humanoid skeleton standard, and the path any other CC0 rig takes onto it.
|
|
3
|
+
*
|
|
4
|
+
* The standard is the **KayKit character rig**: one armature shared by every rigged character in
|
|
5
|
+
* `kaykit-adventurers` (Barbarian, Knight, Mage, Rogue, Rogue_Hooded) and `kaykit-skeletons`
|
|
6
|
+
* (Skeleton_Mage/Minion/Rogue/Warrior). Because they share it, all nine characters play the same
|
|
7
|
+
* clip set and one authored graph, foot-IK rig, attachment slot or ragdoll layout serves all of
|
|
8
|
+
* them. There is no second standard to keep in sync.
|
|
9
|
+
*
|
|
10
|
+
* A rig on a different scheme (Quaternius aliens, Mixamo exports) is not rewritten: its bones are
|
|
11
|
+
* *mapped* slot by slot onto the standard's names, and what it does not have is reported rather
|
|
12
|
+
* than guessed. A rig missing every leg slot can still be driven on its spine and head; a rig
|
|
13
|
+
* missing a slot never silently borrows a neighbour's bone.
|
|
14
|
+
*/
|
|
15
|
+
/** Every standard slot, in parent-before-child order. */
|
|
16
|
+
export const SKELETON_SLOTS = [
|
|
17
|
+
"root",
|
|
18
|
+
"hips",
|
|
19
|
+
"spine",
|
|
20
|
+
"chest",
|
|
21
|
+
"head",
|
|
22
|
+
"upperArm.l",
|
|
23
|
+
"upperArm.r",
|
|
24
|
+
"lowerArm.l",
|
|
25
|
+
"lowerArm.r",
|
|
26
|
+
"hand.l",
|
|
27
|
+
"hand.r",
|
|
28
|
+
"handSlot.l",
|
|
29
|
+
"handSlot.r",
|
|
30
|
+
"upperLeg.l",
|
|
31
|
+
"upperLeg.r",
|
|
32
|
+
"lowerLeg.l",
|
|
33
|
+
"lowerLeg.r",
|
|
34
|
+
"foot.l",
|
|
35
|
+
"foot.r",
|
|
36
|
+
"toes.l",
|
|
37
|
+
"toes.r",
|
|
38
|
+
];
|
|
39
|
+
/** The parent slot of each slot that has one; `root` has none. */
|
|
40
|
+
export const SKELETON_PARENT = {
|
|
41
|
+
hips: "root",
|
|
42
|
+
spine: "hips",
|
|
43
|
+
chest: "spine",
|
|
44
|
+
head: "chest",
|
|
45
|
+
"upperArm.l": "chest",
|
|
46
|
+
"upperArm.r": "chest",
|
|
47
|
+
"lowerArm.l": "upperArm.l",
|
|
48
|
+
"lowerArm.r": "upperArm.r",
|
|
49
|
+
"hand.l": "lowerArm.l",
|
|
50
|
+
"hand.r": "lowerArm.r",
|
|
51
|
+
"handSlot.l": "hand.l",
|
|
52
|
+
"handSlot.r": "hand.r",
|
|
53
|
+
"upperLeg.l": "hips",
|
|
54
|
+
"upperLeg.r": "hips",
|
|
55
|
+
"lowerLeg.l": "upperLeg.l",
|
|
56
|
+
"lowerLeg.r": "upperLeg.r",
|
|
57
|
+
"foot.l": "lowerLeg.l",
|
|
58
|
+
"foot.r": "lowerLeg.r",
|
|
59
|
+
"toes.l": "foot.l",
|
|
60
|
+
"toes.r": "foot.r",
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* The one humanoid skeleton standard. Bone names are the authored KayKit names; the loaded
|
|
64
|
+
* hierarchy spells them without `.` and `resolveRigNode` accepts either.
|
|
65
|
+
*
|
|
66
|
+
* @capability skeleton-standard the shared humanoid bone standard every rigged character maps onto
|
|
67
|
+
*/
|
|
68
|
+
export const SKELETON_STANDARD = {
|
|
69
|
+
id: "kaykit-humanoid",
|
|
70
|
+
label: "KayKit character rig (adventurers + skeletons)",
|
|
71
|
+
slots: {
|
|
72
|
+
root: "root",
|
|
73
|
+
hips: "hips",
|
|
74
|
+
spine: "spine",
|
|
75
|
+
chest: "chest",
|
|
76
|
+
head: "head",
|
|
77
|
+
"upperArm.l": "upperarm.l",
|
|
78
|
+
"upperArm.r": "upperarm.r",
|
|
79
|
+
"lowerArm.l": "lowerarm.l",
|
|
80
|
+
"lowerArm.r": "lowerarm.r",
|
|
81
|
+
"hand.l": "hand.l",
|
|
82
|
+
"hand.r": "hand.r",
|
|
83
|
+
"handSlot.l": "handslot.l",
|
|
84
|
+
"handSlot.r": "handslot.r",
|
|
85
|
+
"upperLeg.l": "upperleg.l",
|
|
86
|
+
"upperLeg.r": "upperleg.r",
|
|
87
|
+
"lowerLeg.l": "lowerleg.l",
|
|
88
|
+
"lowerLeg.r": "lowerleg.r",
|
|
89
|
+
"foot.l": "foot.l",
|
|
90
|
+
"foot.r": "foot.r",
|
|
91
|
+
"toes.l": "toes.l",
|
|
92
|
+
"toes.r": "toes.r",
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
/** Prefixes stripped from a bone name before matching: exporter scopes, not rig vocabulary. */
|
|
96
|
+
const NAME_PREFIXES = ["mixamorig", "armature"];
|
|
97
|
+
/**
|
|
98
|
+
* The standard itself, as a scheme: a KayKit rig already is the standard, so its own names are the
|
|
99
|
+
* candidates. Both authored (`upperleg.l`) and runtime (`upperlegl`) spellings are accepted.
|
|
100
|
+
*
|
|
101
|
+
* @capability skeleton-standard map a rig on the standard scheme onto the standard's slots
|
|
102
|
+
*/
|
|
103
|
+
export const KAYKIT_SCHEME = {
|
|
104
|
+
id: "kaykit-humanoid",
|
|
105
|
+
label: "KayKit character rig",
|
|
106
|
+
source: "KayKit Adventurers / Skeletons (CC0)",
|
|
107
|
+
slots: Object.fromEntries(SKELETON_SLOTS.filter((slot) => SKELETON_STANDARD.slots[slot] !== undefined).map((slot) => {
|
|
108
|
+
const authored = SKELETON_STANDARD.slots[slot];
|
|
109
|
+
return [slot, [authored, runtimeBoneName(authored)]];
|
|
110
|
+
})),
|
|
111
|
+
};
|
|
112
|
+
/**
|
|
113
|
+
* Mixamo exports (`mixamorig*` names, or the same names without the prefix). Mixamo has a neck and
|
|
114
|
+
* a shoulder per side, which the standard folds into `chest` and `upperArm`; its toe is
|
|
115
|
+
* `ToeBase`, which is the standard's `toes`. Mixamo's topmost bone is `Hips`, so `root` and `hips`
|
|
116
|
+
* both resolve to it — a rig on this scheme has no bone above the pelvis.
|
|
117
|
+
*/
|
|
118
|
+
export const MIXAMO_SCHEME = {
|
|
119
|
+
id: "mixamo",
|
|
120
|
+
label: "Mixamo humanoid",
|
|
121
|
+
source: "Mixamo auto-rig exports (per-asset licence)",
|
|
122
|
+
slots: {
|
|
123
|
+
root: ["Hips"],
|
|
124
|
+
hips: ["Hips"],
|
|
125
|
+
spine: ["Spine"],
|
|
126
|
+
chest: ["Spine2", "Spine1"],
|
|
127
|
+
head: ["Head"],
|
|
128
|
+
"upperArm.l": ["LeftArm"],
|
|
129
|
+
"upperArm.r": ["RightArm"],
|
|
130
|
+
"lowerArm.l": ["LeftForeArm"],
|
|
131
|
+
"lowerArm.r": ["RightForeArm"],
|
|
132
|
+
"hand.l": ["LeftHand"],
|
|
133
|
+
"hand.r": ["RightHand"],
|
|
134
|
+
"upperLeg.l": ["LeftUpLeg"],
|
|
135
|
+
"upperLeg.r": ["RightUpLeg"],
|
|
136
|
+
"lowerLeg.l": ["LeftLeg"],
|
|
137
|
+
"lowerLeg.r": ["RightLeg"],
|
|
138
|
+
"foot.l": ["LeftFoot"],
|
|
139
|
+
"foot.r": ["RightFoot"],
|
|
140
|
+
"toes.l": ["LeftToeBase"],
|
|
141
|
+
"toes.r": ["RightToeBase"],
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
/**
|
|
145
|
+
* Quaternius sci-fi aliens: a `Root` plus a `Spine_1..Spine_6` chain, a `Neck` and a `Head`. The
|
|
146
|
+
* pack ships no arms and no legs, so those slots stay empty and the alien can only be driven on
|
|
147
|
+
* its spine and head. That is the honest mapping: the pack's own clip set is `Alien_Idle` alone.
|
|
148
|
+
*/
|
|
149
|
+
export const QUATERNIUS_ALIEN_SCHEME = {
|
|
150
|
+
id: "quaternius-alien",
|
|
151
|
+
label: "Quaternius sci-fi alien",
|
|
152
|
+
source: "Quaternius Modular Sci-Fi (CC0)",
|
|
153
|
+
slots: {
|
|
154
|
+
root: ["Root"],
|
|
155
|
+
hips: ["Spine_1"],
|
|
156
|
+
spine: ["Spine_3", "Spine_2"],
|
|
157
|
+
chest: ["Spine_5", "Spine_4"],
|
|
158
|
+
head: ["Head"],
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
/** Every scheme the engine ships a mapping for, keyed by {@link RigScheme.id}. */
|
|
162
|
+
export const RIG_SCHEMES = {
|
|
163
|
+
[KAYKIT_SCHEME.id]: KAYKIT_SCHEME,
|
|
164
|
+
[MIXAMO_SCHEME.id]: MIXAMO_SCHEME,
|
|
165
|
+
[QUATERNIUS_ALIEN_SCHEME.id]: QUATERNIUS_ALIEN_SCHEME,
|
|
166
|
+
};
|
|
167
|
+
/**
|
|
168
|
+
* The runtime name GLTFLoader gives an authored bone name: three's `PropertyBinding.sanitizeNodeName`
|
|
169
|
+
* strips `[ ] . : /`, so the authored `upperleg.l` loads as `upperlegl`.
|
|
170
|
+
*
|
|
171
|
+
* @capability skeleton-standard convert an authored bone name to its loaded runtime name
|
|
172
|
+
*/
|
|
173
|
+
export function runtimeBoneName(authored) {
|
|
174
|
+
return authored.replace(/[[\].:/]/g, "");
|
|
175
|
+
}
|
|
176
|
+
/** Lowercase alphanumerics of a bone name, with exporter scope prefixes removed. */
|
|
177
|
+
function matchKey(name) {
|
|
178
|
+
let key = name.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
179
|
+
for (const prefix of NAME_PREFIXES) {
|
|
180
|
+
if (key.startsWith(prefix) && key.length > prefix.length)
|
|
181
|
+
key = key.slice(prefix.length);
|
|
182
|
+
}
|
|
183
|
+
return key;
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Maps a rig's bone names onto {@link SKELETON_STANDARD} through one {@link RigScheme}. Each slot
|
|
187
|
+
* takes the first candidate that resolves; a candidate list is tried in order, so a scheme can name
|
|
188
|
+
* a preferred bone and fall back. Bones that fill no slot come back in `unmapped`, and slots the rig
|
|
189
|
+
* cannot fill in `missing` — the caller decides whether a partial map is usable.
|
|
190
|
+
*
|
|
191
|
+
* @capability skeleton-standard map any CC0 rig's bones onto the shared humanoid slots
|
|
192
|
+
*/
|
|
193
|
+
export function mapRigToStandard(bones, scheme = KAYKIT_SCHEME) {
|
|
194
|
+
const resolved = typeof scheme === "string" ? RIG_SCHEMES[scheme] : scheme;
|
|
195
|
+
if (resolved === undefined)
|
|
196
|
+
throw new Error(`unknown rig scheme "${String(scheme)}"; known: ${Object.keys(RIG_SCHEMES).join(", ")}`);
|
|
197
|
+
const byKey = new Map();
|
|
198
|
+
for (const bone of bones) {
|
|
199
|
+
const key = matchKey(bone);
|
|
200
|
+
if (key.length > 0 && !byKey.has(key))
|
|
201
|
+
byKey.set(key, bone);
|
|
202
|
+
}
|
|
203
|
+
const slots = {};
|
|
204
|
+
const missing = [];
|
|
205
|
+
const used = new Set();
|
|
206
|
+
for (const slot of SKELETON_SLOTS) {
|
|
207
|
+
const candidates = resolved.slots[slot];
|
|
208
|
+
if (candidates === undefined) {
|
|
209
|
+
missing.push(slot);
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
let found;
|
|
213
|
+
for (const candidate of candidates) {
|
|
214
|
+
const bone = byKey.get(matchKey(candidate));
|
|
215
|
+
if (bone !== undefined) {
|
|
216
|
+
found = bone;
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if (found === undefined)
|
|
221
|
+
missing.push(slot);
|
|
222
|
+
else {
|
|
223
|
+
slots[slot] = found;
|
|
224
|
+
used.add(found);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return { scheme: resolved.id, slots, missing, unmapped: bones.filter((bone) => !used.has(bone)) };
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Per-slot length ratios that stretch a source rig's bone chain onto the standard's proportions, so
|
|
231
|
+
* a retargeted clip moves both rigs the same distance rather than the same angle. Slots with no
|
|
232
|
+
* counterpart on either side are omitted; a source length of zero is skipped instead of dividing.
|
|
233
|
+
*
|
|
234
|
+
* @capability skeleton-standard scale a retargeted clip to the target rig's bone proportions
|
|
235
|
+
*/
|
|
236
|
+
export function retargetBoneScales(source, target) {
|
|
237
|
+
const targetBySlot = new Map(target.map((entry) => [entry.slot, entry.length]));
|
|
238
|
+
const scales = {};
|
|
239
|
+
for (const entry of source) {
|
|
240
|
+
const to = targetBySlot.get(entry.slot);
|
|
241
|
+
if (to === undefined || entry.length <= 0 || to <= 0)
|
|
242
|
+
continue;
|
|
243
|
+
scales[entry.slot] = to / entry.length;
|
|
244
|
+
}
|
|
245
|
+
return scales;
|
|
246
|
+
}
|
|
247
|
+
/** Track-name property suffixes three writes on an `AnimationClip`. */
|
|
248
|
+
const TRACK_PROPERTIES = ["quaternion", "position", "scale", "morphTargetInfluences"];
|
|
249
|
+
/**
|
|
250
|
+
* Bone-name prefixes for a set of standard slots, for an {@link AnimLayer.mask}. A slot on one side
|
|
251
|
+
* contributes its side-less prefix (`upperLeg.l` → `upperleg`), which matches both sides' tracks; an
|
|
252
|
+
* unsided slot contributes its own runtime name. Masking by slot means a rig mapped onto the
|
|
253
|
+
* standard is masked without hand-typing its bone names.
|
|
254
|
+
*
|
|
255
|
+
* @capability skeleton-standard build a layer mask from the standard's own slots
|
|
256
|
+
*/
|
|
257
|
+
export function standardBoneMask(slots) {
|
|
258
|
+
const out = new Set();
|
|
259
|
+
for (const slot of slots) {
|
|
260
|
+
const bone = SKELETON_STANDARD.slots[slot];
|
|
261
|
+
if (bone === undefined)
|
|
262
|
+
continue;
|
|
263
|
+
const sided = slot.endsWith(".l") || slot.endsWith(".r");
|
|
264
|
+
out.add(sided ? slot.slice(0, -2).toLowerCase() : runtimeBoneName(bone));
|
|
265
|
+
}
|
|
266
|
+
return [...out];
|
|
267
|
+
}
|
|
268
|
+
/** Standard slots an upper-body action layer drives: spine, chest, head and both arms. */
|
|
269
|
+
export const UPPER_BODY_SLOTS = [
|
|
270
|
+
"spine",
|
|
271
|
+
"chest",
|
|
272
|
+
"head",
|
|
273
|
+
"upperArm.l",
|
|
274
|
+
"upperArm.r",
|
|
275
|
+
"lowerArm.l",
|
|
276
|
+
"lowerArm.r",
|
|
277
|
+
"hand.l",
|
|
278
|
+
"hand.r",
|
|
279
|
+
"handSlot.l",
|
|
280
|
+
"handSlot.r",
|
|
281
|
+
];
|
|
282
|
+
/** Standard slots a lower-body layer drives: hips, both legs and both feet. */
|
|
283
|
+
export const LOWER_BODY_SLOTS = [
|
|
284
|
+
"hips",
|
|
285
|
+
"upperLeg.l",
|
|
286
|
+
"upperLeg.r",
|
|
287
|
+
"lowerLeg.l",
|
|
288
|
+
"lowerLeg.r",
|
|
289
|
+
"foot.l",
|
|
290
|
+
"foot.r",
|
|
291
|
+
"toes.l",
|
|
292
|
+
"toes.r",
|
|
293
|
+
];
|
|
294
|
+
/** Layer mask for an upper-body action layer (aim, reload, hit reaction) on a standard rig. */
|
|
295
|
+
export const UPPER_BODY_MASK = standardBoneMask(UPPER_BODY_SLOTS);
|
|
296
|
+
/** Layer mask for a lower-body layer on a standard rig. */
|
|
297
|
+
export const LOWER_BODY_MASK = standardBoneMask(LOWER_BODY_SLOTS);
|
|
298
|
+
/**
|
|
299
|
+
* Renames a clip's tracks from a rig's own bone names onto the standard's, so one authored clip
|
|
300
|
+
* plays on a mapped rig. The emitted node is the **runtime** bone name (see {@link runtimeBoneName}),
|
|
301
|
+
* which is what both the loaded scene graph and the loaded clips spell. A track whose node fills no
|
|
302
|
+
* slot is dropped (`null` in the result) rather than left pointing at a bone the rig does not have;
|
|
303
|
+
* a track whose property is not one of the four three writes is dropped too, since renaming it
|
|
304
|
+
* would not make it resolvable.
|
|
305
|
+
*
|
|
306
|
+
* @capability skeleton-standard rename an animation clip's tracks onto a mapped rig
|
|
307
|
+
*/
|
|
308
|
+
export function retargetTracks(tracks, map) {
|
|
309
|
+
const bySlot = new Map();
|
|
310
|
+
for (const slot of SKELETON_SLOTS) {
|
|
311
|
+
const bone = map.slots[slot];
|
|
312
|
+
if (bone !== undefined)
|
|
313
|
+
bySlot.set(matchKey(bone), slot);
|
|
314
|
+
}
|
|
315
|
+
return tracks.map((track) => {
|
|
316
|
+
const split = track.lastIndexOf(".");
|
|
317
|
+
if (split <= 0)
|
|
318
|
+
return null;
|
|
319
|
+
const slot = bySlot.get(matchKey(track.slice(0, split)));
|
|
320
|
+
const property = track.slice(split + 1);
|
|
321
|
+
if (slot === undefined || !TRACK_PROPERTIES.includes(property))
|
|
322
|
+
return null;
|
|
323
|
+
const target = SKELETON_STANDARD.slots[slot];
|
|
324
|
+
return target === undefined ? null : `${runtimeBoneName(target)}.${property}`;
|
|
325
|
+
});
|
|
326
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { DamageAppliedEvent, GameEvents } from "../game/events.js";
|
|
1
2
|
import type { StatusApplicationOutcome, StatusInstance } from "./statusApplication.js";
|
|
2
3
|
import { readDamageTable, type DamageTableDef, type DamageTableRow } from "./damageTable.js";
|
|
3
4
|
import { type HitZoneResolution, type HitZoneTable } from "./hitZones.js";
|
|
@@ -78,6 +79,14 @@ export interface DamageRuntimeConfig {
|
|
|
78
79
|
armorStat?: string;
|
|
79
80
|
/** Injected `[0,1)` stream for crit rolls. Omitting it warns once and is not replayable. */
|
|
80
81
|
rng?: () => number;
|
|
82
|
+
/** Bus every applied damage is announced on as `combat.damage`. Omitted, events reach only the probe. */
|
|
83
|
+
events?: GameEvents;
|
|
84
|
+
/**
|
|
85
|
+
* A target's live health, read once per application before the caller applies it. The event's
|
|
86
|
+
* `remaining` is that value minus the damage dealt, and `lethal` is `remaining <= 0`; omit the
|
|
87
|
+
* reader and every event reports `remaining: null` and `lethal: false`.
|
|
88
|
+
*/
|
|
89
|
+
healthOf?: (targetId: string) => number | null;
|
|
81
90
|
/** Register a one-line devtools probe under this name. */
|
|
82
91
|
devtoolsName?: string;
|
|
83
92
|
}
|
|
@@ -123,6 +132,8 @@ export interface DamageRuntime {
|
|
|
123
132
|
restore(next: DamageRuntimeState): void;
|
|
124
133
|
/** Carried statuses, for a HUD or a test. */
|
|
125
134
|
statusesOf(targetId: string): readonly StatusInstance[];
|
|
135
|
+
/** The last `limit` damage applications this model announced, oldest first. Default all it keeps. */
|
|
136
|
+
recentDamage(limit?: number): readonly DamageAppliedEvent[];
|
|
126
137
|
stats(): {
|
|
127
138
|
targets: number;
|
|
128
139
|
hits: number;
|
|
@@ -143,7 +154,9 @@ export declare class UnknownTargetProfileError extends Error {
|
|
|
143
154
|
/**
|
|
144
155
|
* Build a damage model from data. The number a hit lands is
|
|
145
156
|
* `base × zone multiplier × type multiplier × (1 − armor mitigation)`; statuses tick through the
|
|
146
|
-
* same table and armor, so a burn on an armored target is mitigated like a bullet.
|
|
157
|
+
* same table and armor, so a burn on an armored target is mitigated like a bullet. Every application
|
|
158
|
+
* — a hit or a status tick — is announced as `combat.damage` carrying the target, source, type,
|
|
159
|
+
* amount, absorbed, remaining health and a lethal flag, so a HUD names the blow instead of guessing.
|
|
147
160
|
*
|
|
148
161
|
* @capability damage-runtime resolve hits through a damage table, hit zones, and armor, and tick data-defined statuses with declared stacking
|
|
149
162
|
*/
|
|
@@ -3,6 +3,10 @@ import { resolveRng } from "../random/resolveRng.js";
|
|
|
3
3
|
import { assertDamageTable, readDamageTable, resolveTableDamage, } from "./damageTable.js";
|
|
4
4
|
import { assertHitZoneTable, hitZoneMultiplier, resolveHitZone, } from "./hitZones.js";
|
|
5
5
|
import { createStatusRuntime, } from "./statusRuntime.js";
|
|
6
|
+
/** How many recent damage applications the runtime keeps for `recentDamage` and its probe line. */
|
|
7
|
+
const RECENT_DAMAGE_LIMIT = 8;
|
|
8
|
+
/** Most applications the probe line spells out; the ring keeps more than it prints. */
|
|
9
|
+
const RECENT_DAMAGE_PROBE_LINES = 3;
|
|
6
10
|
/** Raised when a hit names a target that was never registered — a wiring error, not a 0-damage hit.
|
|
7
11
|
* @capability damage-target-unknown reject a hit on a target with no registered resistance or armor profile
|
|
8
12
|
*/
|
|
@@ -17,7 +21,9 @@ export class UnknownTargetProfileError extends Error {
|
|
|
17
21
|
/**
|
|
18
22
|
* Build a damage model from data. The number a hit lands is
|
|
19
23
|
* `base × zone multiplier × type multiplier × (1 − armor mitigation)`; statuses tick through the
|
|
20
|
-
* same table and armor, so a burn on an armored target is mitigated like a bullet.
|
|
24
|
+
* same table and armor, so a burn on an armored target is mitigated like a bullet. Every application
|
|
25
|
+
* — a hit or a status tick — is announced as `combat.damage` carrying the target, source, type,
|
|
26
|
+
* amount, absorbed, remaining health and a lethal flag, so a HUD names the blow instead of guessing.
|
|
21
27
|
*
|
|
22
28
|
* @capability damage-runtime resolve hits through a damage table, hit zones, and armor, and tick data-defined statuses with declared stacking
|
|
23
29
|
*/
|
|
@@ -25,6 +31,8 @@ export function createDamageRuntime(config) {
|
|
|
25
31
|
assertDamageTable(config.table);
|
|
26
32
|
assertHitZoneTable(config.zones);
|
|
27
33
|
const rng = resolveRng(config.rng, "createDamageRuntime");
|
|
34
|
+
const bus = config.events;
|
|
35
|
+
const healthOf = config.healthOf;
|
|
28
36
|
let table = config.table;
|
|
29
37
|
let zones = config.zones;
|
|
30
38
|
let armorStat = config.armorStat ?? "armor";
|
|
@@ -34,6 +42,7 @@ export function createDamageRuntime(config) {
|
|
|
34
42
|
let dealt = 0;
|
|
35
43
|
let statusTicks = 0;
|
|
36
44
|
let last = null;
|
|
45
|
+
const recent = [];
|
|
37
46
|
let statusRuntime;
|
|
38
47
|
statusRuntime = createStatusRuntime({
|
|
39
48
|
statuses: config.statuses ?? [],
|
|
@@ -60,14 +69,50 @@ export function createDamageRuntime(config) {
|
|
|
60
69
|
function resolveStatusDamage(tick) {
|
|
61
70
|
if (!targets.has(tick.target))
|
|
62
71
|
return tick.amount;
|
|
63
|
-
|
|
72
|
+
const dealt = scaleAt(tick.target, tick.channel, tick.amount).damage ?? 0;
|
|
73
|
+
announce({
|
|
74
|
+
target: tick.target,
|
|
75
|
+
type: tick.channel,
|
|
76
|
+
incoming: tick.amount,
|
|
77
|
+
dealt,
|
|
78
|
+
...(tick.source !== undefined ? { source: tick.source } : {}),
|
|
79
|
+
status: tick.status,
|
|
80
|
+
});
|
|
81
|
+
return dealt;
|
|
82
|
+
}
|
|
83
|
+
function announce(input) {
|
|
84
|
+
const health = healthOf?.(input.target) ?? null;
|
|
85
|
+
const remaining = health === null ? null : Math.max(0, health - input.dealt);
|
|
86
|
+
const event = {
|
|
87
|
+
target: input.target,
|
|
88
|
+
...(input.source !== undefined ? { source: input.source } : {}),
|
|
89
|
+
type: input.type,
|
|
90
|
+
amount: input.dealt,
|
|
91
|
+
absorbed: Math.max(0, input.incoming - input.dealt),
|
|
92
|
+
remaining,
|
|
93
|
+
lethal: remaining !== null && remaining <= 0,
|
|
94
|
+
...(input.status !== undefined ? { status: input.status } : {}),
|
|
95
|
+
};
|
|
96
|
+
recent.push(event);
|
|
97
|
+
if (recent.length > RECENT_DAMAGE_LIMIT)
|
|
98
|
+
recent.shift();
|
|
99
|
+
bus?.emit("combat.damage", event);
|
|
100
|
+
}
|
|
101
|
+
function describe(event) {
|
|
102
|
+
const source = event.source === undefined ? "" : ` from ${event.source}`;
|
|
103
|
+
const status = event.status === undefined ? "" : ` ${event.status}`;
|
|
104
|
+
const left = event.remaining === null ? "" : ` → ${event.remaining.toFixed(0)}`;
|
|
105
|
+
return `${event.target} -${event.amount.toFixed(0)}${source}${status}${left}${event.lethal ? " LETHAL" : ""}`;
|
|
64
106
|
}
|
|
65
107
|
function probeLine() {
|
|
66
108
|
const l = last;
|
|
67
109
|
const tail = l === null
|
|
68
110
|
? "no hits"
|
|
69
111
|
: `last ${l.type} ${l.base.toFixed(1)}→${l.damage.toFixed(1)} ${l.zone}${l.crit ? " crit" : ""}`;
|
|
70
|
-
|
|
112
|
+
const events = recent.length === 0
|
|
113
|
+
? ""
|
|
114
|
+
: ` · events ${recent.slice(-RECENT_DAMAGE_PROBE_LINES).map(describe).join(" | ")}`;
|
|
115
|
+
return `${targets.size} targets · ${hits} hits ${crits} crits · ${statusTicks} status ticks · ${dealt.toFixed(0)} dealt · ${tail}${events}`;
|
|
71
116
|
}
|
|
72
117
|
const unregister = config.devtoolsName === undefined
|
|
73
118
|
? null
|
|
@@ -86,7 +131,8 @@ export function createDamageRuntime(config) {
|
|
|
86
131
|
},
|
|
87
132
|
hit(input) {
|
|
88
133
|
const zone = resolveHitZone(zones, input.zone, rng);
|
|
89
|
-
const
|
|
134
|
+
const incoming = input.base * zone.multiplier;
|
|
135
|
+
const row = scaleAt(input.target, input.type, incoming);
|
|
90
136
|
const applied = [];
|
|
91
137
|
for (const request of input.statuses ?? []) {
|
|
92
138
|
const statusId = typeof request === "string" ? request : request.id;
|
|
@@ -108,6 +154,13 @@ export function createDamageRuntime(config) {
|
|
|
108
154
|
crits += 1;
|
|
109
155
|
dealt += damage;
|
|
110
156
|
last = { type: input.type, base: input.base, damage, zone: zone.zone, crit: zone.crit };
|
|
157
|
+
announce({
|
|
158
|
+
target: input.target,
|
|
159
|
+
type: input.type,
|
|
160
|
+
incoming,
|
|
161
|
+
dealt: damage,
|
|
162
|
+
...(input.source !== undefined ? { source: input.source } : {}),
|
|
163
|
+
});
|
|
111
164
|
return {
|
|
112
165
|
target: input.target,
|
|
113
166
|
type: input.type,
|
|
@@ -197,8 +250,13 @@ export function createDamageRuntime(config) {
|
|
|
197
250
|
crits = next.crits;
|
|
198
251
|
dealt = next.dealt;
|
|
199
252
|
last = next.last === null ? null : { ...next.last };
|
|
253
|
+
recent.length = 0;
|
|
200
254
|
},
|
|
201
255
|
statusesOf: (targetId) => statusRuntime.statusesOf(targetId),
|
|
256
|
+
recentDamage(limit) {
|
|
257
|
+
const count = limit === undefined ? recent.length : Math.max(0, Math.min(recent.length, Math.floor(limit)));
|
|
258
|
+
return recent.slice(recent.length - count);
|
|
259
|
+
},
|
|
202
260
|
stats: () => ({ targets: targets.size, hits, crits, statusTicks, dealt }),
|
|
203
261
|
dispose() {
|
|
204
262
|
statusRuntime.dispose();
|
package/dist/combat/death.js
CHANGED
|
@@ -36,6 +36,8 @@ export function createDeathSystem(deps) {
|
|
|
36
36
|
event.userId = identity.userId;
|
|
37
37
|
if (identity.displayName !== undefined)
|
|
38
38
|
event.displayName = identity.displayName;
|
|
39
|
+
const source = reason.kind === "environment" ? reason.attacker ?? reason.source : reason.source;
|
|
40
|
+
event.source = source;
|
|
39
41
|
deps.events.emit("entity.died", event);
|
|
40
42
|
const onDeath = normalizeOnDeath(deps.resolveOnDeath(instanceId, reason));
|
|
41
43
|
const drops = [];
|
|
@@ -62,8 +64,10 @@ export function deathReasonFromEffect(ctx) {
|
|
|
62
64
|
const killerUserId = ctx.userIdOf?.(ctx.from);
|
|
63
65
|
if (killerUserId !== undefined) {
|
|
64
66
|
return ctx.via?.item !== undefined
|
|
65
|
-
? { kind: "player_kill", killerUserId, via: { item: ctx.via.item } }
|
|
66
|
-
: { kind: "player_kill", killerUserId };
|
|
67
|
+
? { kind: "player_kill", killerUserId, source: ctx.from, via: { item: ctx.via.item } }
|
|
68
|
+
: { kind: "player_kill", killerUserId, source: ctx.from };
|
|
67
69
|
}
|
|
68
|
-
return
|
|
70
|
+
return ctx.via?.item !== undefined
|
|
71
|
+
? { kind: "environment", source: ctx.via.item, attacker: ctx.from }
|
|
72
|
+
: { kind: "environment", source: "effect", attacker: ctx.from };
|
|
69
73
|
}
|
|
@@ -1,13 +1,19 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* Why an entity died — who or what gets credit, for drop/command rules and the `entity.died` event.
|
|
3
|
+
* `source` names what dealt it: a player kill's killer instance id, or the hazard/item of an
|
|
4
|
+
* environment death; `attacker` carries the non-player entity's instance id when one dealt it.
|
|
5
|
+
*/
|
|
2
6
|
export type DeathReason = {
|
|
3
7
|
kind: "player_kill";
|
|
4
8
|
killerUserId: string;
|
|
9
|
+
source?: string;
|
|
5
10
|
via?: {
|
|
6
11
|
item?: string;
|
|
7
12
|
};
|
|
8
13
|
} | {
|
|
9
14
|
kind: "environment";
|
|
10
15
|
source: string;
|
|
16
|
+
attacker?: string;
|
|
11
17
|
} | {
|
|
12
18
|
kind: "self";
|
|
13
19
|
source: string;
|
package/dist/combat/effects.d.ts
CHANGED
|
@@ -74,6 +74,26 @@ export interface AreaTarget {
|
|
|
74
74
|
scale: number;
|
|
75
75
|
}
|
|
76
76
|
export declare function resolveAreaTargets(spatial: CombatSpatialDeps, input: AreaTargetInput, accept?: (instanceId: string) => boolean): AreaTarget[];
|
|
77
|
+
/**
|
|
78
|
+
* One effect application that drained a target's pools, as an announcer needs it. The effect path
|
|
79
|
+
* has no damage table, so the caller names the channel (`effect`) and reads health its own way.
|
|
80
|
+
*/
|
|
81
|
+
export interface EffectDamageApplication {
|
|
82
|
+
/** The entity that took the drain. */
|
|
83
|
+
target: string;
|
|
84
|
+
/** Who dealt it: the effect's `from` instance id (attacker, hazard, projectile owner). */
|
|
85
|
+
source: string;
|
|
86
|
+
/** The effect id that landed (`"damage"`, `"bullet"`, `"fire"`). */
|
|
87
|
+
effect: string;
|
|
88
|
+
/** Magnitude before receive modifiers and area falloff. */
|
|
89
|
+
incoming: number;
|
|
90
|
+
/** Magnitude drained after modifiers, falloff and available pool headroom. */
|
|
91
|
+
dealt: number;
|
|
92
|
+
/** The last pool's current after the drain — the pool whose exhaustion is `lethal`. */
|
|
93
|
+
remaining: number | null;
|
|
94
|
+
/** True when the last pool reached its minimum. */
|
|
95
|
+
lethal: boolean;
|
|
96
|
+
}
|
|
77
97
|
export interface EffectSystemDeps {
|
|
78
98
|
resolveReceive(instanceId: string): ReceiveMap | null | undefined;
|
|
79
99
|
/** Preferred portable bridge to caller-owned resource state. */
|
|
@@ -93,6 +113,11 @@ export interface EffectSystemDeps {
|
|
|
93
113
|
*/
|
|
94
114
|
resolveSlainIdentity?(instanceId: string): SlainIdentity | null | undefined;
|
|
95
115
|
onLethal?(instanceId: string, ctx: LethalContext): void;
|
|
116
|
+
/**
|
|
117
|
+
* Called once per target a draining effect lands on, after its pools changed and before
|
|
118
|
+
* {@link onLethal} despawns it. Restorative applications (`via.amount` below zero) stay silent.
|
|
119
|
+
*/
|
|
120
|
+
onDamage?(application: EffectDamageApplication): void;
|
|
96
121
|
}
|
|
97
122
|
export interface EffectSystem {
|
|
98
123
|
canReceive(instanceId: string, effect: string, magnitude?: number): string | null;
|
package/dist/combat/effects.js
CHANGED
|
@@ -103,6 +103,22 @@ export function createEffectSystem(deps) {
|
|
|
103
103
|
if (canReceive(instanceId, effect, drainMagnitude) !== null)
|
|
104
104
|
return null;
|
|
105
105
|
const result = drainPools(instanceId, effect, rule, drainMagnitude);
|
|
106
|
+
if (drainMagnitude > 0 && deps.onDamage !== undefined) {
|
|
107
|
+
let dealt = 0;
|
|
108
|
+
for (const delta of result.applied)
|
|
109
|
+
if (delta.delta < 0)
|
|
110
|
+
dealt -= delta.delta;
|
|
111
|
+
const lastStatId = rule.order[rule.order.length - 1];
|
|
112
|
+
deps.onDamage({
|
|
113
|
+
target: instanceId,
|
|
114
|
+
source: from,
|
|
115
|
+
effect,
|
|
116
|
+
incoming: drainMagnitude,
|
|
117
|
+
dealt,
|
|
118
|
+
remaining: statPools.get(instanceId, lastStatId)?.current ?? null,
|
|
119
|
+
lethal: result.lethal,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
106
122
|
if (result.lethal) {
|
|
107
123
|
// Capture identity before onLethal, which runs the death system and despawns the target.
|
|
108
124
|
const slain = deps.resolveSlainIdentity?.(instanceId);
|
|
@@ -44,6 +44,8 @@ export interface StatusDamageTick {
|
|
|
44
44
|
amount: number;
|
|
45
45
|
/** What the caller's pipeline reported as dealt; equals `amount` when no pipeline was supplied. */
|
|
46
46
|
dealt: number;
|
|
47
|
+
/** Who applied the status that is ticking, when known — a burn names its arsonist. */
|
|
48
|
+
source?: string;
|
|
47
49
|
}
|
|
48
50
|
/** A stat tick: a per-stack delta the caller reads while the status lives. */
|
|
49
51
|
export interface StatusStatTick {
|
|
@@ -87,6 +87,7 @@ export function createStatusRuntime(options) {
|
|
|
87
87
|
channel: effect.channel,
|
|
88
88
|
amount,
|
|
89
89
|
dealt: amount,
|
|
90
|
+
...(instance.source !== undefined ? { source: instance.source } : {}),
|
|
90
91
|
};
|
|
91
92
|
if (options.applyDamage !== undefined)
|
|
92
93
|
tick.dealt = options.applyDamage(tick);
|