@jgengine/core 0.14.0 → 0.15.0-next.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 +483 -1
- package/dist/ai/difficulty.d.ts +154 -0
- package/dist/ai/difficulty.js +194 -0
- package/dist/ai/driver.d.ts +107 -0
- package/dist/ai/driver.js +175 -0
- package/dist/ai/flock.js +13 -23
- package/dist/ai/laneSelect.js +2 -1
- package/dist/ai/mobBrain.js +2 -1
- package/dist/ai/waveRunner.d.ts +81 -0
- package/dist/ai/waveRunner.js +104 -0
- package/dist/authoring.d.ts +6 -4
- package/dist/authoring.js +6 -4
- package/dist/combat/castRunner.d.ts +13 -1
- package/dist/combat/castRunner.js +5 -1
- package/dist/combat/comboMeter.d.ts +140 -0
- package/dist/combat/comboMeter.js +215 -0
- package/dist/combat/comboPoints.d.ts +11 -0
- package/dist/combat/comboPoints.js +11 -2
- package/dist/combat/damageResolution.d.ts +1 -1
- package/dist/combat/defensiveWindow.d.ts +5 -0
- package/dist/combat/effects.d.ts +24 -0
- package/dist/combat/effects.js +6 -1
- package/dist/combat/projectiles.d.ts +6 -0
- package/dist/combat/projectiles.js +2 -2
- package/dist/combat/regenShield.d.ts +40 -2
- package/dist/combat/regenShield.js +66 -18
- package/dist/combat/resourcePool.d.ts +19 -0
- package/dist/combat/resourcePool.js +20 -2
- package/dist/combat/shotOrigin.d.ts +5 -0
- package/dist/combat/statusApplication.d.ts +1 -1
- package/dist/combat/statusApplication.js +2 -1
- package/dist/combat/statusEffectView.d.ts +56 -0
- package/dist/combat/statusEffectView.js +52 -0
- package/dist/combat.d.ts +4 -2
- package/dist/combat.js +2 -0
- package/dist/devtools/fallbackSeams.d.ts +3 -2
- package/dist/devtools/fallbackSeams.js +1 -1
- package/dist/devtools/textureErrors.d.ts +40 -0
- package/dist/devtools/textureErrors.js +68 -0
- package/dist/economy/shopStock.d.ts +110 -0
- package/dist/economy/shopStock.js +157 -0
- package/dist/economy/wallet.d.ts +6 -0
- package/dist/economy/wallet.js +1 -0
- package/dist/game/achievements.d.ts +80 -0
- package/dist/game/achievements.js +124 -0
- package/dist/game/cameraConfig.d.ts +7 -0
- package/dist/game/codex.d.ts +60 -0
- package/dist/game/codex.js +89 -0
- package/dist/game/credits.d.ts +36 -0
- package/dist/game/credits.js +0 -0
- package/dist/game/defineGame.d.ts +28 -3
- package/dist/game/defineGame.js +5 -0
- package/dist/game/dialogueGraph.d.ts +116 -0
- package/dist/game/dialogueGraph.js +103 -0
- package/dist/game/eventTicker.d.ts +95 -0
- package/dist/game/eventTicker.js +91 -0
- package/dist/game/events.d.ts +24 -5
- package/dist/game/events.js +18 -0
- package/dist/game/gamePhase.d.ts +1 -1
- package/dist/game/gamePhase.js +1 -1
- package/dist/game/leaderboardRank.d.ts +71 -0
- package/dist/game/leaderboardRank.js +76 -0
- package/dist/game/lootTable.d.ts +10 -1
- package/dist/game/lootTable.js +4 -2
- package/dist/game/notifications.d.ts +57 -0
- package/dist/game/notifications.js +107 -0
- package/dist/game/playableGame.d.ts +39 -2
- package/dist/game/quest.d.ts +26 -0
- package/dist/game/quest.js +31 -0
- package/dist/game/runDraft.js +2 -1
- package/dist/game/saveSlots.d.ts +102 -0
- package/dist/game/saveSlots.js +136 -0
- package/dist/game/talentTreeView.d.ts +106 -0
- package/dist/game/talentTreeView.js +114 -0
- package/dist/game/worldItem.js +2 -1
- package/dist/gameplay.d.ts +15 -4
- package/dist/gameplay.js +13 -2
- package/dist/i18n/i18n.d.ts +51 -0
- package/dist/i18n/i18n.js +82 -0
- package/dist/input/axisInput.d.ts +1 -1
- package/dist/input/axisInput.js +18 -2
- package/dist/input/rebindSession.d.ts +141 -0
- package/dist/input/rebindSession.js +235 -0
- package/dist/input/touchControlsMode.d.ts +16 -0
- package/dist/input/touchControlsMode.js +23 -0
- package/dist/input/touchScheme.d.ts +30 -1
- package/dist/input/touchScheme.js +31 -1
- package/dist/interaction/promptRegistry.d.ts +78 -0
- package/dist/interaction/promptRegistry.js +130 -0
- package/dist/inventory/storageTier.js +3 -2
- package/dist/meta/changelog.d.ts +1 -1
- package/dist/meta/changelog.js +1 -1
- package/dist/movement/movementModel.d.ts +26 -3
- package/dist/movement/movementModel.js +121 -15
- package/dist/movement/playerMovement.js +161 -25
- package/dist/physics/drivableVehicle.js +3 -1
- package/dist/physics/kinematicVehicle.d.ts +56 -1
- package/dist/physics/kinematicVehicle.js +99 -10
- package/dist/physics/vehicleObstacles.js +19 -0
- package/dist/random/resolveRng.d.ts +14 -0
- package/dist/random/resolveRng.js +31 -0
- package/dist/render/postProcessing.d.ts +12 -1
- package/dist/runtime/cameraDirector.d.ts +2 -2
- package/dist/runtime/context/combat.js +8 -0
- package/dist/runtime/gameContext.js +6 -1
- package/dist/runtime/gameContextTypes.d.ts +18 -0
- package/dist/runtime/inputSnapshot.d.ts +10 -1
- package/dist/runtime/inputSnapshot.js +13 -1
- package/dist/runtime/runtimeSave.d.ts +3 -3
- package/dist/runtime/runtimeSave.js +11 -2
- package/dist/scene/assetDiagnostics.js +2 -2
- package/dist/scene/autoTarget.js +2 -1
- package/dist/scene/captureCheck.js +3 -2
- package/dist/scene/entityStore.d.ts +4 -1
- package/dist/scene/entityStore.js +3 -0
- package/dist/scene/objectStore.d.ts +10 -0
- package/dist/scene/objectStore.js +29 -6
- package/dist/scene/sceneKinds.d.ts +18 -0
- package/dist/scene/sceneKinds.js +19 -0
- package/dist/scene/sequenceDirector.d.ts +110 -0
- package/dist/scene/sequenceDirector.js +146 -0
- package/dist/session/roles.js +3 -2
- package/dist/settings/settingsModel.d.ts +1 -0
- package/dist/settings/settingsModel.js +1 -0
- package/dist/stats/rollCheck.js +4 -2
- package/dist/time/dayNightCycle.d.ts +114 -0
- package/dist/time/dayNightCycle.js +208 -0
- package/dist/time/timerSet.d.ts +132 -0
- package/dist/time/timerSet.js +243 -0
- package/dist/ui/accessibility.d.ts +51 -0
- package/dist/ui/accessibility.js +83 -0
- package/dist/ui/coachMarks.d.ts +99 -0
- package/dist/ui/coachMarks.js +126 -0
- package/dist/ui/floatingText.d.ts +114 -0
- package/dist/ui/floatingText.js +203 -0
- package/dist/ui/modalStack.d.ts +143 -0
- package/dist/ui/modalStack.js +160 -0
- package/dist/ui/objectiveBanner.d.ts +123 -0
- package/dist/ui/objectiveBanner.js +162 -0
- package/dist/ui/photoMode.d.ts +32 -0
- package/dist/ui/photoMode.js +51 -0
- package/dist/ui/radialMenu.d.ts +47 -0
- package/dist/ui/radialMenu.js +74 -0
- package/dist/ui/screenMarkers.d.ts +128 -0
- package/dist/ui/screenMarkers.js +118 -0
- package/dist/ui.d.ts +8 -0
- package/dist/ui.js +8 -0
- package/dist/vfx/cameraShake.d.ts +97 -0
- package/dist/vfx/cameraShake.js +136 -0
- package/dist/vfx/damageDirection.d.ts +102 -0
- package/dist/vfx/damageDirection.js +188 -0
- package/dist/vfx/particles.d.ts +103 -0
- package/dist/vfx/particles.js +256 -0
- package/dist/vfx/screenEffects.d.ts +149 -0
- package/dist/vfx/screenEffects.js +191 -0
- package/dist/world/authoredObjects.d.ts +18 -0
- package/dist/world/authoredObjects.js +21 -0
- package/dist/world/authoredSpawn.js +19 -0
- package/dist/world/buildingLots.d.ts +54 -11
- package/dist/world/buildingLots.js +116 -30
- package/dist/world/cityBlocks.d.ts +45 -0
- package/dist/world/cityBlocks.js +159 -0
- package/dist/world/cityContent.d.ts +46 -2
- package/dist/world/cityContent.js +160 -2
- package/dist/world/cityGenerator.d.ts +261 -23
- package/dist/world/cityGenerator.js +643 -33
- package/dist/world/cityGeometry.d.ts +20 -0
- package/dist/world/cityGeometry.js +91 -0
- package/dist/world/cityKind.js +1 -44
- package/dist/world/fastTravel.d.ts +64 -0
- package/dist/world/fastTravel.js +108 -0
- package/dist/world/features.d.ts +16 -0
- package/dist/world/features.js +2 -0
- package/dist/world/grounded.d.ts +11 -0
- package/dist/world/grounded.js +12 -0
- package/dist/world/mapAnnotations.d.ts +75 -0
- package/dist/world/mapAnnotations.js +139 -0
- package/dist/world/markers.js +1 -0
- package/dist/world/resourceNode.d.ts +1 -1
- package/dist/world/resourceNode.js +2 -1
- package/dist/world/roads.d.ts +294 -5
- package/dist/world/roads.js +1262 -21
- package/dist/world/sharedAnnotations.d.ts +84 -0
- package/dist/world/sharedAnnotations.js +76 -0
- package/dist/world/spawnOverride.d.ts +37 -0
- package/dist/world/spawnOverride.js +50 -0
- package/dist/world/streetGenerator.d.ts +60 -11
- package/dist/world/streetGenerator.js +1786 -129
- package/dist/world/terrain.d.ts +1 -0
- package/dist/world/terrain.js +2 -0
- package/dist/world/waypoints.d.ts +63 -0
- package/dist/world/waypoints.js +137 -0
- package/dist/world.d.ts +17 -2
- package/dist/world.js +17 -2
- package/package.json +1 -1
- package/skills/ce-handoff/SKILL.md +149 -0
- package/skills/game-design/SKILL.md +2 -0
- package/skills/jgengine/SKILL.md +3 -1
- package/skills/jgengine/api.md +17 -7
- package/skills/jgengine/capabilities.md +12 -0
- package/skills/jgengine/recipes/minimal-game.md +7 -4
- package/skills/jgengine-combat/api.md +40 -4
- package/skills/jgengine-combat/capabilities.md +12 -0
- package/skills/jgengine-combat/reference.md +2 -0
- package/skills/jgengine-gameplay/api.md +212 -11
- package/skills/jgengine-gameplay/capabilities.md +66 -1
- package/skills/jgengine-world/api.md +378 -11
- package/skills/jgengine-world/capabilities.md +123 -2
- package/skills/jgengine-world/reference.md +15 -1
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
function sortTiers(tiers) {
|
|
2
|
+
return tiers === undefined ? [] : [...tiers].sort((a, b) => a.threshold - b.threshold);
|
|
3
|
+
}
|
|
4
|
+
/** Index of the highest tier whose `threshold` `count` has reached, or `-1` below the first. */
|
|
5
|
+
function activeIndex(count, tiers) {
|
|
6
|
+
let idx = -1;
|
|
7
|
+
for (let i = 0; i < tiers.length; i += 1) {
|
|
8
|
+
if (count >= tiers[i].threshold)
|
|
9
|
+
idx = i;
|
|
10
|
+
else
|
|
11
|
+
break;
|
|
12
|
+
}
|
|
13
|
+
return idx;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* A count-based combo / multiplier meter: an integer hit chain that climbs with
|
|
17
|
+
* every `hit()` and drops when a decay window elapses, crossing free-string tier
|
|
18
|
+
* thresholds ("good" → "great" → "savage") that derive a score multiplier, while
|
|
19
|
+
* tracking the peak reached. Time is injected (`now`, default `Date.now`) and/or
|
|
20
|
+
* driven by `update(dt)`; `view()` returns a pooled draw-state and
|
|
21
|
+
* `snapshot`/`restore` round-trips the chain through a save.
|
|
22
|
+
*
|
|
23
|
+
* Distinct from the existing meters — reused none of them because none is this
|
|
24
|
+
* shape: `stats/eventMeter` is a value/heat gauge that rises by tagged gains and
|
|
25
|
+
* decays continuously (not an integer chain count with a reset-on-hit window);
|
|
26
|
+
* `combat/comboPoints` is a finisher-point pool that hard-expires with no tiers,
|
|
27
|
+
* multiplier, or subscribe; `stats/accumulatorMeter` is a float gauge crossing
|
|
28
|
+
* `at`-value tiers, not a count with a draining window and derived multiplier.
|
|
29
|
+
*
|
|
30
|
+
* @capability combo-meter count-based combo/multiplier meter with a decay window, free-string tier thresholds, derived multiplier, peak tracking and snapshot/restore
|
|
31
|
+
*/
|
|
32
|
+
export function createComboMeter(config) {
|
|
33
|
+
const now = config.now ?? Date.now;
|
|
34
|
+
let windowMs = Math.max(0, config.windowMs);
|
|
35
|
+
let tiers = sortTiers(config.tiers);
|
|
36
|
+
let dropStep = config.dropStep;
|
|
37
|
+
let multiplierPerTier = config.multiplierPerTier;
|
|
38
|
+
let multiplierCurve = config.multiplierCurve;
|
|
39
|
+
let count = Math.max(0, Math.floor(config.count ?? 0));
|
|
40
|
+
let peak = count;
|
|
41
|
+
let kind = "";
|
|
42
|
+
// Logical clock (ms). `manual` flips true on the first update(dt) call, after
|
|
43
|
+
// which the injected `now` is ignored and only update(dt) advances time.
|
|
44
|
+
let manual = false;
|
|
45
|
+
let time = now();
|
|
46
|
+
let lastNow = time;
|
|
47
|
+
// Absolute logical time the current window ends; null when the chain is empty.
|
|
48
|
+
let deadline = count > 0 && windowMs > 0 ? time + windowMs : null;
|
|
49
|
+
const listeners = new Set();
|
|
50
|
+
const pooledView = {
|
|
51
|
+
count: 0,
|
|
52
|
+
peak: 0,
|
|
53
|
+
tier: null,
|
|
54
|
+
tierIndex: -1,
|
|
55
|
+
multiplier: 1,
|
|
56
|
+
remainingMs: 0,
|
|
57
|
+
fraction: 0,
|
|
58
|
+
kind: "",
|
|
59
|
+
};
|
|
60
|
+
function notify() {
|
|
61
|
+
for (const listener of listeners)
|
|
62
|
+
listener();
|
|
63
|
+
}
|
|
64
|
+
/** Sync the logical clock from wall time unless update(dt) has taken over. */
|
|
65
|
+
function syncWall() {
|
|
66
|
+
if (manual)
|
|
67
|
+
return;
|
|
68
|
+
const t = now();
|
|
69
|
+
time += t - lastNow;
|
|
70
|
+
lastNow = t;
|
|
71
|
+
}
|
|
72
|
+
/** Drop the chain once for each elapsed window; returns whether anything changed. */
|
|
73
|
+
function processExpiry() {
|
|
74
|
+
if (deadline === null || windowMs <= 0)
|
|
75
|
+
return false;
|
|
76
|
+
let changed = false;
|
|
77
|
+
while (deadline !== null && time >= deadline) {
|
|
78
|
+
if (dropStep !== undefined && dropStep > 0 && count - dropStep > 0) {
|
|
79
|
+
count -= dropStep;
|
|
80
|
+
deadline += windowMs;
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
count = 0;
|
|
84
|
+
deadline = null;
|
|
85
|
+
}
|
|
86
|
+
changed = true;
|
|
87
|
+
}
|
|
88
|
+
return changed;
|
|
89
|
+
}
|
|
90
|
+
/** Bring the clock and expiry state up to date before any read. */
|
|
91
|
+
function pull() {
|
|
92
|
+
syncWall();
|
|
93
|
+
if (processExpiry())
|
|
94
|
+
notify();
|
|
95
|
+
}
|
|
96
|
+
function multiplierAt(c) {
|
|
97
|
+
const idx = activeIndex(c, tiers);
|
|
98
|
+
if (idx >= 0) {
|
|
99
|
+
const explicit = tiers[idx].multiplier;
|
|
100
|
+
if (explicit !== undefined)
|
|
101
|
+
return explicit;
|
|
102
|
+
}
|
|
103
|
+
if (multiplierPerTier !== undefined)
|
|
104
|
+
return 1 + (idx + 1) * multiplierPerTier;
|
|
105
|
+
if (multiplierCurve !== undefined)
|
|
106
|
+
return multiplierCurve(c, idx);
|
|
107
|
+
return 1;
|
|
108
|
+
}
|
|
109
|
+
function remaining() {
|
|
110
|
+
if (count <= 0 || deadline === null)
|
|
111
|
+
return 0;
|
|
112
|
+
return Math.max(0, deadline - time);
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
hit(hitKind = "") {
|
|
116
|
+
syncWall();
|
|
117
|
+
processExpiry();
|
|
118
|
+
count += 1;
|
|
119
|
+
if (count > peak)
|
|
120
|
+
peak = count;
|
|
121
|
+
kind = hitKind;
|
|
122
|
+
deadline = windowMs > 0 ? time + windowMs : null;
|
|
123
|
+
notify();
|
|
124
|
+
return count;
|
|
125
|
+
},
|
|
126
|
+
update(dtSeconds) {
|
|
127
|
+
manual = true;
|
|
128
|
+
if (dtSeconds > 0)
|
|
129
|
+
time += dtSeconds * 1000;
|
|
130
|
+
if (processExpiry())
|
|
131
|
+
notify();
|
|
132
|
+
},
|
|
133
|
+
count() {
|
|
134
|
+
pull();
|
|
135
|
+
return count;
|
|
136
|
+
},
|
|
137
|
+
peak() {
|
|
138
|
+
pull();
|
|
139
|
+
return peak;
|
|
140
|
+
},
|
|
141
|
+
tier() {
|
|
142
|
+
pull();
|
|
143
|
+
const idx = activeIndex(count, tiers);
|
|
144
|
+
return idx >= 0 ? tiers[idx].id : null;
|
|
145
|
+
},
|
|
146
|
+
multiplier() {
|
|
147
|
+
pull();
|
|
148
|
+
return multiplierAt(count);
|
|
149
|
+
},
|
|
150
|
+
remainingMs() {
|
|
151
|
+
pull();
|
|
152
|
+
return remaining();
|
|
153
|
+
},
|
|
154
|
+
fraction() {
|
|
155
|
+
pull();
|
|
156
|
+
return windowMs <= 0 ? 0 : Math.max(0, Math.min(1, remaining() / windowMs));
|
|
157
|
+
},
|
|
158
|
+
view() {
|
|
159
|
+
pull();
|
|
160
|
+
const idx = activeIndex(count, tiers);
|
|
161
|
+
const rem = remaining();
|
|
162
|
+
pooledView.count = count;
|
|
163
|
+
pooledView.peak = peak;
|
|
164
|
+
pooledView.tier = idx >= 0 ? tiers[idx].id : null;
|
|
165
|
+
pooledView.tierIndex = idx;
|
|
166
|
+
pooledView.multiplier = multiplierAt(count);
|
|
167
|
+
pooledView.remainingMs = rem;
|
|
168
|
+
pooledView.fraction = windowMs <= 0 ? 0 : Math.max(0, Math.min(1, rem / windowMs));
|
|
169
|
+
pooledView.kind = kind;
|
|
170
|
+
return pooledView;
|
|
171
|
+
},
|
|
172
|
+
reset() {
|
|
173
|
+
count = 0;
|
|
174
|
+
peak = 0;
|
|
175
|
+
kind = "";
|
|
176
|
+
deadline = null;
|
|
177
|
+
notify();
|
|
178
|
+
},
|
|
179
|
+
configure(patch) {
|
|
180
|
+
if (patch.windowMs !== undefined)
|
|
181
|
+
windowMs = Math.max(0, patch.windowMs);
|
|
182
|
+
if (patch.tiers !== undefined)
|
|
183
|
+
tiers = sortTiers(patch.tiers);
|
|
184
|
+
if ("dropStep" in patch)
|
|
185
|
+
dropStep = patch.dropStep;
|
|
186
|
+
if ("multiplierPerTier" in patch)
|
|
187
|
+
multiplierPerTier = patch.multiplierPerTier;
|
|
188
|
+
if ("multiplierCurve" in patch)
|
|
189
|
+
multiplierCurve = patch.multiplierCurve;
|
|
190
|
+
// Re-arm the window if it changed while a chain is live.
|
|
191
|
+
if (patch.windowMs !== undefined && count > 0) {
|
|
192
|
+
deadline = windowMs > 0 ? time + windowMs : null;
|
|
193
|
+
}
|
|
194
|
+
notify();
|
|
195
|
+
},
|
|
196
|
+
subscribe(listener) {
|
|
197
|
+
listeners.add(listener);
|
|
198
|
+
return () => listeners.delete(listener);
|
|
199
|
+
},
|
|
200
|
+
snapshot() {
|
|
201
|
+
syncWall();
|
|
202
|
+
processExpiry();
|
|
203
|
+
const idx = activeIndex(count, tiers);
|
|
204
|
+
return { count, peak, remainingMs: remaining(), tier: idx >= 0 ? tiers[idx].id : null };
|
|
205
|
+
},
|
|
206
|
+
restore(snapshot) {
|
|
207
|
+
syncWall();
|
|
208
|
+
count = Math.max(0, Math.floor(snapshot.count));
|
|
209
|
+
peak = Math.max(count, Math.floor(snapshot.peak));
|
|
210
|
+
kind = "";
|
|
211
|
+
deadline = count > 0 && windowMs > 0 && snapshot.remainingMs > 0 ? time + snapshot.remainingMs : null;
|
|
212
|
+
notify();
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
}
|
|
@@ -2,6 +2,13 @@ export interface ComboPointsConfig {
|
|
|
2
2
|
max: number;
|
|
3
3
|
expireAfterSeconds?: number;
|
|
4
4
|
}
|
|
5
|
+
/** Complete serializable state for {@link ComboPoints}; `remaining: null` means no expiry is running. */
|
|
6
|
+
export interface ComboPointsSnapshot {
|
|
7
|
+
points: number;
|
|
8
|
+
remaining: number | null;
|
|
9
|
+
max: number;
|
|
10
|
+
expireAfterSeconds: number | null;
|
|
11
|
+
}
|
|
5
12
|
export interface ComboPoints {
|
|
6
13
|
points(): number;
|
|
7
14
|
gain(amount?: number): number;
|
|
@@ -10,6 +17,10 @@ export interface ComboPoints {
|
|
|
10
17
|
tick(dtSeconds: number): void;
|
|
11
18
|
expiresIn(): number | null;
|
|
12
19
|
clear(): void;
|
|
20
|
+
/** The whole builder as plain data — save it, ship it to a host, diff it in a test. */
|
|
21
|
+
snapshot(): ComboPointsSnapshot;
|
|
22
|
+
/** Adopt a snapshot, replacing points, expiry, and tuning. */
|
|
23
|
+
restore(state: ComboPointsSnapshot): void;
|
|
13
24
|
}
|
|
14
25
|
/**
|
|
15
26
|
* Build and spend finisher points — the combo-point economy behind rogue-style builders and spenders.
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
* @capability combo-points build up and spend finisher/combo points
|
|
5
5
|
*/
|
|
6
6
|
export function createComboPoints(config) {
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
let max = config.max;
|
|
8
|
+
let expireAfterSeconds = config.expireAfterSeconds;
|
|
9
9
|
let current = 0;
|
|
10
10
|
let remaining = null;
|
|
11
11
|
return {
|
|
@@ -49,6 +49,15 @@ export function createComboPoints(config) {
|
|
|
49
49
|
return null;
|
|
50
50
|
return remaining;
|
|
51
51
|
},
|
|
52
|
+
snapshot() {
|
|
53
|
+
return { points: current, remaining, max, expireAfterSeconds: expireAfterSeconds ?? null };
|
|
54
|
+
},
|
|
55
|
+
restore(state) {
|
|
56
|
+
max = state.max;
|
|
57
|
+
expireAfterSeconds = state.expireAfterSeconds ?? undefined;
|
|
58
|
+
current = Math.max(0, Math.min(max, state.points));
|
|
59
|
+
remaining = current <= 0 ? null : state.remaining;
|
|
60
|
+
},
|
|
52
61
|
clear() {
|
|
53
62
|
current = 0;
|
|
54
63
|
remaining = null;
|
|
@@ -29,7 +29,7 @@ export interface DamageHitInput {
|
|
|
29
29
|
currentStatus?: StatusInstance | null;
|
|
30
30
|
/** Statuses the target is immune to. */
|
|
31
31
|
immunity?: StatusImmunity | null;
|
|
32
|
-
/** Deterministic RNG for the status roll
|
|
32
|
+
/** Deterministic RNG for the status roll. Omitting it falls back to `Math.random` and warns once — pass `ctx.rng`. */
|
|
33
33
|
rng?: () => number;
|
|
34
34
|
}
|
|
35
35
|
/**
|
|
@@ -11,6 +11,11 @@ export interface DefensiveWindowConfig {
|
|
|
11
11
|
};
|
|
12
12
|
}
|
|
13
13
|
export type DefenseOutcome = "parry" | "block" | "iframe" | "hit";
|
|
14
|
+
/**
|
|
15
|
+
* Result of resolving an incoming hit against a defensive window: `outcome` is the graded defense
|
|
16
|
+
* (`"parry"`/`"block"`/`"iframe"`/`"hit"`) and `perfect` is true when the input landed inside the
|
|
17
|
+
* tight just-frame at the window's start rather than its wider active span.
|
|
18
|
+
*/
|
|
14
19
|
export interface DefenseResolution {
|
|
15
20
|
outcome: DefenseOutcome;
|
|
16
21
|
perfect: boolean;
|
package/dist/combat/effects.d.ts
CHANGED
|
@@ -29,11 +29,29 @@ export interface AppliedPoolDelta {
|
|
|
29
29
|
statId: string;
|
|
30
30
|
delta: number;
|
|
31
31
|
}
|
|
32
|
+
/**
|
|
33
|
+
* Identity of an entity slain by a lethal effect, captured before the death system despawns it.
|
|
34
|
+
* Serializable and genre-agnostic: `catalogId` is the slain entity's spawn kind/name (the same
|
|
35
|
+
* value the `entity.died` event carries), `name` an optional human-readable label for kill feeds,
|
|
36
|
+
* and `userId` the owning player when the slain unit was player-controlled. The slain entity's
|
|
37
|
+
* instance id is already {@link EffectResult.instanceId}.
|
|
38
|
+
*/
|
|
39
|
+
export interface SlainIdentity {
|
|
40
|
+
catalogId: string;
|
|
41
|
+
name?: string;
|
|
42
|
+
userId?: string;
|
|
43
|
+
}
|
|
32
44
|
export interface EffectResult {
|
|
33
45
|
instanceId: string;
|
|
34
46
|
effect: string;
|
|
35
47
|
applied: AppliedPoolDelta[];
|
|
36
48
|
lethal: boolean;
|
|
49
|
+
/**
|
|
50
|
+
* Present only on a lethal hit: the slain entity's identity, captured before despawn so
|
|
51
|
+
* kill-credit / XP can read `catalogId`/`name` without a game-side spawn-time registry.
|
|
52
|
+
* A non-lethal hit omits this field.
|
|
53
|
+
*/
|
|
54
|
+
slain?: SlainIdentity;
|
|
37
55
|
}
|
|
38
56
|
export interface LethalContext {
|
|
39
57
|
from: string;
|
|
@@ -68,6 +86,12 @@ export interface EffectSystemDeps {
|
|
|
68
86
|
getStat(itemId: string, stat: string): number | null;
|
|
69
87
|
spatial: CombatSpatialDeps;
|
|
70
88
|
drainStatByEffect?: Record<string, string>;
|
|
89
|
+
/**
|
|
90
|
+
* Resolve the target's identity, called on a lethal hit *before* {@link onLethal} despawns it.
|
|
91
|
+
* The returned {@link SlainIdentity} is attached to the lethal {@link EffectResult.slain} so kill
|
|
92
|
+
* credit works without a parallel registry. Return `null`/`undefined` to omit identity.
|
|
93
|
+
*/
|
|
94
|
+
resolveSlainIdentity?(instanceId: string): SlainIdentity | null | undefined;
|
|
71
95
|
onLethal?(instanceId: string, ctx: LethalContext): void;
|
|
72
96
|
}
|
|
73
97
|
export interface EffectSystem {
|
package/dist/combat/effects.js
CHANGED
|
@@ -103,8 +103,13 @@ 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 (result.lethal)
|
|
106
|
+
if (result.lethal) {
|
|
107
|
+
// Capture identity before onLethal, which runs the death system and despawns the target.
|
|
108
|
+
const slain = deps.resolveSlainIdentity?.(instanceId);
|
|
109
|
+
if (slain !== null && slain !== undefined)
|
|
110
|
+
result.slain = slain;
|
|
107
111
|
deps.onLethal?.(instanceId, { from, via, effect });
|
|
112
|
+
}
|
|
108
113
|
return result;
|
|
109
114
|
}
|
|
110
115
|
return {
|
|
@@ -40,6 +40,12 @@ export interface ProjectileSettleReport {
|
|
|
40
40
|
at: EntityPosition;
|
|
41
41
|
effect: string;
|
|
42
42
|
hit: boolean;
|
|
43
|
+
/**
|
|
44
|
+
* True for lobbed/exploding shots (grenades, launchers, rockets) whose real path is an arc,
|
|
45
|
+
* false for direct-fire shots (bullets, bolts) that travel muzzle→impact in a straight line.
|
|
46
|
+
* Presentation uses it to keep straight-line tracers off arced projectiles.
|
|
47
|
+
*/
|
|
48
|
+
ballistic: boolean;
|
|
43
49
|
}
|
|
44
50
|
export interface ProjectileObjectsDeps {
|
|
45
51
|
list(): readonly {
|
|
@@ -308,7 +308,7 @@ export function createProjectileSystem(deps) {
|
|
|
308
308
|
radius,
|
|
309
309
|
falloff: splashRadius === null ? "none" : "linear",
|
|
310
310
|
});
|
|
311
|
-
deps.onSettle?.({ from: input.from, origin, at, effect: input.effect, hit: hits.length > 0 });
|
|
311
|
+
deps.onSettle?.({ from: input.from, origin, at, effect: input.effect, hit: hits.length > 0, ballistic: true });
|
|
312
312
|
return { status: "settled", shotId, at, hits, origin: originTuple };
|
|
313
313
|
}
|
|
314
314
|
const { visible, rawHits } = predictHits(input);
|
|
@@ -343,7 +343,7 @@ export function createProjectileSystem(deps) {
|
|
|
343
343
|
else {
|
|
344
344
|
at = missPoint(input);
|
|
345
345
|
}
|
|
346
|
-
deps.onSettle?.({ from: input.from, origin, at, effect: input.effect, hit: hits.length > 0 });
|
|
346
|
+
deps.onSettle?.({ from: input.from, origin, at, effect: input.effect, hit: hits.length > 0, ballistic: false });
|
|
347
347
|
return { status: "settled", shotId, at, hits, origin: originTuple };
|
|
348
348
|
},
|
|
349
349
|
};
|
|
@@ -1,3 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* External storage a {@link RegenShield} reads and writes instead of keeping the value itself —
|
|
3
|
+
* an entity stat, a replicated store, a save record. This is the same port `Magazine` exposes as
|
|
4
|
+
* `MagazineReserve`, and it exists for the same reason: in most games the shield value is already
|
|
5
|
+
* owned by the stat system that damage, the HUD, and replication all read, so a primitive that
|
|
6
|
+
* insists on owning the number privately cannot be adopted without creating a second source of truth.
|
|
7
|
+
*
|
|
8
|
+
* A pool-backed shield also watches for *external* decreases — damage applied through the game's own
|
|
9
|
+
* effect pipeline rather than through `damage()` — and restarts the regen grace period when it sees
|
|
10
|
+
* one, which is the per-entity snapshot bookkeeping games otherwise hand-roll.
|
|
11
|
+
*/
|
|
12
|
+
export interface RegenShieldPool {
|
|
13
|
+
current(): number;
|
|
14
|
+
max(): number;
|
|
15
|
+
/** Write the value back; implementations clamp to their own bounds. */
|
|
16
|
+
set(value: number): void;
|
|
17
|
+
}
|
|
1
18
|
/** Tuning for `createRegenShield`: pool size, refill rate, and the post-damage grace period. */
|
|
2
19
|
export interface RegenShieldConfig {
|
|
3
20
|
max: number;
|
|
@@ -5,8 +22,14 @@ export interface RegenShieldConfig {
|
|
|
5
22
|
regenPerSecond: number;
|
|
6
23
|
/** Grace period after the last damage before regen resumes. */
|
|
7
24
|
regenDelayMs: number;
|
|
8
|
-
/** Starting value; defaults to `max` (full). */
|
|
25
|
+
/** Starting value; defaults to `max` (full). Ignored when `pool` is set — the pool owns the value. */
|
|
9
26
|
current?: number;
|
|
27
|
+
/**
|
|
28
|
+
* Back this shield with external storage instead of internal state. `max` and `current` then come
|
|
29
|
+
* from the pool, `setMax` is a no-op (the pool owns its bounds), and a drop in the pool's value
|
|
30
|
+
* between ticks is treated as damage for the purposes of the regen grace period.
|
|
31
|
+
*/
|
|
32
|
+
pool?: RegenShieldPool;
|
|
10
33
|
}
|
|
11
34
|
/**
|
|
12
35
|
* A shield pool that stops regenerating for `regenDelayMs` after every hit, then refills at
|
|
@@ -28,8 +51,23 @@ export interface RegenShield {
|
|
|
28
51
|
/** Add `amount` without touching the regen timer (a pickup or scripted refill). */
|
|
29
52
|
restore(amount: number): void;
|
|
30
53
|
set(value: number): void;
|
|
54
|
+
/**
|
|
55
|
+
* Restart the regen grace period without changing the value — for damage the shield cannot see,
|
|
56
|
+
* such as a hit that landed on health after the shield was already down, or damage routed through
|
|
57
|
+
* a game's own effect pipeline.
|
|
58
|
+
*/
|
|
59
|
+
noteDamage(): void;
|
|
31
60
|
/** Advance time: count down the grace period, then regenerate once it has elapsed. */
|
|
32
|
-
tick(dtSeconds: number): void;
|
|
61
|
+
tick(dtSeconds: number, rates?: RegenShieldRates): void;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Per-tick rate overrides, for games that derive regen from live state — a talent tree, a buff, an
|
|
65
|
+
* equipped mod — rather than a constant fixed when the shield was built. Omit to use the configured
|
|
66
|
+
* values; the grace period in effect is whichever `regenDelayMs` was last passed to `tick`.
|
|
67
|
+
*/
|
|
68
|
+
export interface RegenShieldRates {
|
|
69
|
+
regenPerSecond?: number;
|
|
70
|
+
regenDelayMs?: number;
|
|
33
71
|
}
|
|
34
72
|
/**
|
|
35
73
|
* Builds a {@link RegenShield} that suppresses regen for `regenDelayMs` after each hit.
|
|
@@ -4,46 +4,94 @@
|
|
|
4
4
|
* @capability regen-shield a rechargeable overshield that absorbs damage and refills after a lull
|
|
5
5
|
*/
|
|
6
6
|
export function createRegenShield(config) {
|
|
7
|
-
|
|
7
|
+
const pool = config.pool;
|
|
8
8
|
const regenPerSecond = Math.max(0, config.regenPerSecond);
|
|
9
9
|
const regenDelayMs = Math.max(0, config.regenDelayMs);
|
|
10
10
|
function clamp(value, ceiling) {
|
|
11
11
|
return Math.max(0, Math.min(ceiling, value));
|
|
12
12
|
}
|
|
13
|
-
let
|
|
13
|
+
let ownMax = Math.max(0, config.max);
|
|
14
|
+
let ownCurrent = clamp(config.current ?? ownMax, ownMax);
|
|
14
15
|
let sinceDamageMs = regenDelayMs;
|
|
16
|
+
/** Grace period currently in force — the configured one until a `tick` overrides it. */
|
|
17
|
+
let activeDelayMs = regenDelayMs;
|
|
18
|
+
const readMax = () => (pool === undefined ? ownMax : Math.max(0, pool.max()));
|
|
19
|
+
const readCurrent = () => (pool === undefined ? ownCurrent : clamp(pool.current(), readMax()));
|
|
20
|
+
function write(value) {
|
|
21
|
+
const next = clamp(value, readMax());
|
|
22
|
+
if (pool === undefined)
|
|
23
|
+
ownCurrent = next;
|
|
24
|
+
else
|
|
25
|
+
pool.set(next);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Last value this shield knows about, so a pool-backed shield can tell its own writes apart from
|
|
29
|
+
* damage applied elsewhere. `null` until the first observation, so the first tick never reads a
|
|
30
|
+
* cold start as a hit.
|
|
31
|
+
*/
|
|
32
|
+
let observed = pool === undefined ? null : readCurrent();
|
|
33
|
+
/** Detect an external decrease in a pool-backed value and restart the grace period if one happened. */
|
|
34
|
+
function syncExternal() {
|
|
35
|
+
if (pool === undefined)
|
|
36
|
+
return;
|
|
37
|
+
const live = readCurrent();
|
|
38
|
+
if (observed !== null && live < observed)
|
|
39
|
+
sinceDamageMs = 0;
|
|
40
|
+
observed = live;
|
|
41
|
+
}
|
|
15
42
|
return {
|
|
16
|
-
current:
|
|
17
|
-
max:
|
|
43
|
+
current: readCurrent,
|
|
44
|
+
max: readMax,
|
|
18
45
|
setMax(value) {
|
|
19
|
-
|
|
20
|
-
|
|
46
|
+
// A pool owns its own bounds (an entity stat's max, a save record's cap); resizing it here
|
|
47
|
+
// would silently disagree with whatever else writes that storage.
|
|
48
|
+
if (pool !== undefined)
|
|
49
|
+
return;
|
|
50
|
+
ownMax = Math.max(0, value);
|
|
51
|
+
ownCurrent = clamp(ownCurrent, ownMax);
|
|
21
52
|
},
|
|
22
|
-
fraction: () => (
|
|
23
|
-
isFull: () =>
|
|
24
|
-
isBroken: () =>
|
|
25
|
-
suppressed: () => sinceDamageMs <
|
|
53
|
+
fraction: () => (readMax() <= 0 ? 0 : readCurrent() / readMax()),
|
|
54
|
+
isFull: () => readCurrent() >= readMax(),
|
|
55
|
+
isBroken: () => readCurrent() <= 0,
|
|
56
|
+
suppressed: () => sinceDamageMs < activeDelayMs,
|
|
26
57
|
damage(amount) {
|
|
27
58
|
if (amount <= 0)
|
|
28
59
|
return 0;
|
|
60
|
+
syncExternal();
|
|
29
61
|
sinceDamageMs = 0;
|
|
30
|
-
const
|
|
31
|
-
|
|
62
|
+
const live = readCurrent();
|
|
63
|
+
const overflow = Math.max(0, amount - live);
|
|
64
|
+
write(live - amount);
|
|
65
|
+
observed = pool === undefined ? observed : readCurrent();
|
|
32
66
|
return overflow;
|
|
33
67
|
},
|
|
34
68
|
restore(amount) {
|
|
35
|
-
if (amount
|
|
36
|
-
|
|
69
|
+
if (amount <= 0)
|
|
70
|
+
return;
|
|
71
|
+
syncExternal();
|
|
72
|
+
write(readCurrent() + amount);
|
|
73
|
+
observed = pool === undefined ? observed : readCurrent();
|
|
37
74
|
},
|
|
38
75
|
set(value) {
|
|
39
|
-
|
|
76
|
+
write(value);
|
|
77
|
+
observed = pool === undefined ? observed : readCurrent();
|
|
78
|
+
},
|
|
79
|
+
noteDamage() {
|
|
80
|
+
syncExternal();
|
|
81
|
+
sinceDamageMs = 0;
|
|
40
82
|
},
|
|
41
|
-
tick(dtSeconds) {
|
|
83
|
+
tick(dtSeconds, rates) {
|
|
84
|
+
activeDelayMs = Math.max(0, rates?.regenDelayMs ?? regenDelayMs);
|
|
42
85
|
if (dtSeconds <= 0)
|
|
43
86
|
return;
|
|
87
|
+
// Advance time first, THEN observe: damage that landed since the last tick resets the timer to
|
|
88
|
+
// zero for this tick, so the tick that sees a hit never also regenerates over it.
|
|
44
89
|
sinceDamageMs += dtSeconds * 1000;
|
|
45
|
-
|
|
46
|
-
|
|
90
|
+
syncExternal();
|
|
91
|
+
if (sinceDamageMs < activeDelayMs)
|
|
92
|
+
return;
|
|
93
|
+
write(readCurrent() + Math.max(0, rates?.regenPerSecond ?? regenPerSecond) * dtSeconds);
|
|
94
|
+
observed = pool === undefined ? observed : readCurrent();
|
|
47
95
|
},
|
|
48
96
|
};
|
|
49
97
|
}
|
|
@@ -4,6 +4,19 @@ export interface ResourcePoolConfig {
|
|
|
4
4
|
regenPerSecond?: number;
|
|
5
5
|
decayPerSecond?: number;
|
|
6
6
|
}
|
|
7
|
+
/** Complete serializable state for a {@link ResourcePool}. */
|
|
8
|
+
export interface ResourcePoolSnapshot {
|
|
9
|
+
current: number;
|
|
10
|
+
max: number;
|
|
11
|
+
regenPerSecond: number;
|
|
12
|
+
decayPerSecond: number;
|
|
13
|
+
}
|
|
14
|
+
/** Runtime tuning patch for {@link ResourcePool.retune} — omitted fields keep their current value. */
|
|
15
|
+
export interface ResourcePoolRetune {
|
|
16
|
+
max?: number;
|
|
17
|
+
regenPerSecond?: number;
|
|
18
|
+
decayPerSecond?: number;
|
|
19
|
+
}
|
|
7
20
|
export interface ResourcePool {
|
|
8
21
|
current(): number;
|
|
9
22
|
max(): number;
|
|
@@ -16,6 +29,12 @@ export interface ResourcePool {
|
|
|
16
29
|
tick(dtSeconds: number): void;
|
|
17
30
|
isFull(): boolean;
|
|
18
31
|
isEmpty(): boolean;
|
|
32
|
+
/** The whole pool as plain data — save it, ship it to a host, diff it in a test. */
|
|
33
|
+
snapshot(): ResourcePoolSnapshot;
|
|
34
|
+
/** Adopt a snapshot, replacing current value and tuning. */
|
|
35
|
+
restore(state: ResourcePoolSnapshot): void;
|
|
36
|
+
/** Change regen/decay/max mid-run (a buff, a difficulty change) without rebuilding the pool. */
|
|
37
|
+
retune(patch: ResourcePoolRetune): void;
|
|
19
38
|
}
|
|
20
39
|
/**
|
|
21
40
|
* A regenerating resource pool — mana, stamina, energy — that actions spend from and that refills over time.
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
*/
|
|
6
6
|
export function createResourcePool(config) {
|
|
7
7
|
let max = Math.max(0, config.max);
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
let regenPerSecond = Math.max(0, config.regenPerSecond ?? 0);
|
|
9
|
+
let decayPerSecond = Math.max(0, config.decayPerSecond ?? 0);
|
|
10
10
|
let current = clamp(config.initial ?? max, max);
|
|
11
11
|
function clamp(value, ceiling) {
|
|
12
12
|
return Math.max(0, Math.min(ceiling, value));
|
|
@@ -56,5 +56,23 @@ export function createResourcePool(config) {
|
|
|
56
56
|
isEmpty() {
|
|
57
57
|
return current <= 0;
|
|
58
58
|
},
|
|
59
|
+
snapshot() {
|
|
60
|
+
return { current, max, regenPerSecond, decayPerSecond };
|
|
61
|
+
},
|
|
62
|
+
restore(state) {
|
|
63
|
+
max = Math.max(0, state.max);
|
|
64
|
+
regenPerSecond = Math.max(0, state.regenPerSecond);
|
|
65
|
+
decayPerSecond = Math.max(0, state.decayPerSecond);
|
|
66
|
+
current = clamp(state.current, max);
|
|
67
|
+
},
|
|
68
|
+
retune(patch) {
|
|
69
|
+
if (patch.max !== undefined)
|
|
70
|
+
max = Math.max(0, patch.max);
|
|
71
|
+
if (patch.regenPerSecond !== undefined)
|
|
72
|
+
regenPerSecond = Math.max(0, patch.regenPerSecond);
|
|
73
|
+
if (patch.decayPerSecond !== undefined)
|
|
74
|
+
decayPerSecond = Math.max(0, patch.decayPerSecond);
|
|
75
|
+
current = clamp(current, max);
|
|
76
|
+
},
|
|
59
77
|
};
|
|
60
78
|
}
|
|
@@ -43,6 +43,11 @@ export type ShotOriginPolicy = {
|
|
|
43
43
|
origin: EntityPosition;
|
|
44
44
|
direction?: EntityPosition;
|
|
45
45
|
};
|
|
46
|
+
/**
|
|
47
|
+
* A shot's resolved firing geometry in world space: `origin` is the point the projectile/ray starts
|
|
48
|
+
* from and `direction` is its normalized aim vector, both computed by {@link resolveShot} from the
|
|
49
|
+
* shooter's position, facing, and the chosen origin policy.
|
|
50
|
+
*/
|
|
46
51
|
export interface ResolvedShot {
|
|
47
52
|
origin: EntityPosition;
|
|
48
53
|
direction: EntityPosition;
|
|
@@ -70,7 +70,7 @@ export interface StatusApplicationInput {
|
|
|
70
70
|
immunity?: StatusImmunity | null;
|
|
71
71
|
/** Attribution written onto the resulting instance. */
|
|
72
72
|
source?: string;
|
|
73
|
-
/** Deterministic RNG returning `[0,1)
|
|
73
|
+
/** Deterministic RNG returning `[0,1)`. Omitting it falls back to `Math.random` and warns once — pass `ctx.rng` or a seeded stream. */
|
|
74
74
|
rng?: () => number;
|
|
75
75
|
}
|
|
76
76
|
/** Why an application did or did not take, plus the roll for provenance. */
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resolveRng } from "../random/resolveRng.js";
|
|
1
2
|
function clamp01(value) {
|
|
2
3
|
if (value < 0)
|
|
3
4
|
return 0;
|
|
@@ -15,7 +16,7 @@ function clamp01(value) {
|
|
|
15
16
|
*/
|
|
16
17
|
export function resolveStatusApplication(input) {
|
|
17
18
|
const { spec, scale, current, immunity, source } = input;
|
|
18
|
-
const rng = input.rng
|
|
19
|
+
const rng = resolveRng(input.rng, "applyStatus");
|
|
19
20
|
if (immunity?.statuses?.includes(spec.status) === true) {
|
|
20
21
|
return { kind: "immune", instance: current ?? null, chance: 0, roll: null };
|
|
21
22
|
}
|