@jgengine/core 0.25.0 → 0.26.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 +65 -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/gameplay.d.ts +4 -0
- package/dist/gameplay.js +4 -0
- package/dist/meta/changelog.d.ts +1 -1
- package/dist/meta/changelog.js +56 -1
- 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/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/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/features.d.ts +8 -0
- package/dist/world.d.ts +5 -0
- package/dist/world.js +5 -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 +17 -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 +8 -0
- package/skills/jgengine-gameplay/api.md +71 -1
- package/skills/jgengine-gameplay/capabilities.md +16 -0
- package/skills/jgengine-world/SKILL.md +3 -1
- package/skills/jgengine-world/api.md +107 -3
- package/skills/jgengine-world/capabilities.md +22 -2
- package/skills/jgengine-world/recipes/npc-senses.md +8 -2
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import type { DamageRuntime, DamageRuntimeHitInput, DamageRuntimeResolution } from "./damageRuntime.js";
|
|
2
|
+
import { type Magazine, type MagazineSnapshot } from "./magazine.js";
|
|
3
|
+
import { type WeaponDefinition, type WeaponVec3 } from "./weaponDefinition.js";
|
|
4
|
+
import { type WeaponHandling, type WeaponHandlingState, type WeaponStance } from "./weaponHandling.js";
|
|
5
|
+
/** Where one pellet met the caller's world. */
|
|
6
|
+
export interface WeaponContact {
|
|
7
|
+
/** Damage-runtime target id; omit for geometry that should stop the pellet without taking damage. */
|
|
8
|
+
target?: string;
|
|
9
|
+
/** Hit-zone id from the caller's `HitZoneTable`; omitted uses the table default. */
|
|
10
|
+
zone?: string;
|
|
11
|
+
/** Contact point in world metres. */
|
|
12
|
+
point: WeaponVec3;
|
|
13
|
+
}
|
|
14
|
+
/** Everything the runtime is allowed to reach: RNG, the damage model, the muzzle, and the world query. */
|
|
15
|
+
export interface WeaponSystemDeps {
|
|
16
|
+
/** `[0,1)` stream for spread cones and recoil jitter. Required so shots stay replayable. */
|
|
17
|
+
rng: () => number;
|
|
18
|
+
/** The B2.2 damage model; every hit, hitscan or projectile, is resolved through it. */
|
|
19
|
+
damage: Pick<DamageRuntime, "hit">;
|
|
20
|
+
/** Muzzle or eye position for the next shot, world metres — read once per shot. */
|
|
21
|
+
origin: () => WeaponVec3;
|
|
22
|
+
/** Nearest contact along a ray, or `null`. Required for a hitscan definition. */
|
|
23
|
+
raycast?: (from: WeaponVec3, direction: WeaponVec3, maxRange: number) => WeaponContact | null;
|
|
24
|
+
/** First contact along one projectile travel segment, or `null`. Required for a projectile definition. */
|
|
25
|
+
sweep?: (from: WeaponVec3, to: WeaponVec3) => WeaponContact | null;
|
|
26
|
+
/** Replace the definition's damage mapping for one hit; `null` keeps the hit but skips damage. */
|
|
27
|
+
damageFor?: (contact: WeaponContact, hit: WeaponImpactContext) => DamageRuntimeHitInput | null;
|
|
28
|
+
/** Shots one tick may catch up on (default `4`, clamp `1..16`); bounds rays and sweeps per tick. */
|
|
29
|
+
maxShotsPerTick?: number;
|
|
30
|
+
/** Register a one-line devtools probe under this name. */
|
|
31
|
+
devtoolsName?: string;
|
|
32
|
+
}
|
|
33
|
+
/** Read-only facts about the hit handed to `damageFor`. */
|
|
34
|
+
export interface WeaponImpactContext {
|
|
35
|
+
shot: number;
|
|
36
|
+
pellet: number;
|
|
37
|
+
/** Metres from the shot origin. */
|
|
38
|
+
distance: number;
|
|
39
|
+
/** Damage multiplier at `distance` from the definition's falloff. */
|
|
40
|
+
falloff: number;
|
|
41
|
+
}
|
|
42
|
+
/** One fired shot: the cone it fired into, the kick it took, and where each pellet went. */
|
|
43
|
+
export interface WeaponShotRecord {
|
|
44
|
+
/** Monotonic shot number for this weapon instance, starting at `1`. */
|
|
45
|
+
shot: number;
|
|
46
|
+
/** Cone half-angle this shot fired into, rad, after stance and ADS. */
|
|
47
|
+
spread: number;
|
|
48
|
+
/** Aim kick this shot applied, rad. */
|
|
49
|
+
kickPitch: number;
|
|
50
|
+
kickYaw: number;
|
|
51
|
+
origin: WeaponVec3;
|
|
52
|
+
/** Unit direction of each pellet. */
|
|
53
|
+
directions: readonly WeaponVec3[];
|
|
54
|
+
}
|
|
55
|
+
/** One resolved hit, with the damage runtime's full provenance. */
|
|
56
|
+
export interface WeaponImpact {
|
|
57
|
+
shot: number;
|
|
58
|
+
pellet: number;
|
|
59
|
+
target: string;
|
|
60
|
+
zone: string;
|
|
61
|
+
distance: number;
|
|
62
|
+
falloff: number;
|
|
63
|
+
resolution: DamageRuntimeResolution;
|
|
64
|
+
}
|
|
65
|
+
/** What one {@link WeaponSystem.tick} produced. Empty arrays on a quiet tick. */
|
|
66
|
+
export interface WeaponTickResult {
|
|
67
|
+
shots: readonly WeaponShotRecord[];
|
|
68
|
+
impacts: readonly WeaponImpact[];
|
|
69
|
+
}
|
|
70
|
+
/** `idle`, `reloading`, or `interrupted` after `interruptReload()` kept the progress. */
|
|
71
|
+
export type WeaponReloadState = "idle" | "reloading" | "interrupted";
|
|
72
|
+
/** Ammo and cadence as a HUD or a test reads them. */
|
|
73
|
+
export interface WeaponAmmo {
|
|
74
|
+
/** Loaded rounds, or `null` for a weapon without a magazine. */
|
|
75
|
+
loaded: number | null;
|
|
76
|
+
capacity: number | null;
|
|
77
|
+
reserve: number | null;
|
|
78
|
+
reload: WeaponReloadState;
|
|
79
|
+
/** Reload progress in `[0, 1]`, retained while interrupted. */
|
|
80
|
+
reloadFraction: number;
|
|
81
|
+
/** Shots left in the current burst; `0` outside one. */
|
|
82
|
+
burstRemaining: number;
|
|
83
|
+
/** Progress toward the next shot in `[0, 1]`. */
|
|
84
|
+
cadenceFraction: number;
|
|
85
|
+
}
|
|
86
|
+
/** Lifetime ammo economy and resolution counters. */
|
|
87
|
+
export interface WeaponStats {
|
|
88
|
+
shots: number;
|
|
89
|
+
/** Loaded rounds spent; `0` for a weapon without a magazine. */
|
|
90
|
+
roundsSpent: number;
|
|
91
|
+
pelletsFired: number;
|
|
92
|
+
burstPulls: number;
|
|
93
|
+
reloadsStarted: number;
|
|
94
|
+
reloadsCompleted: number;
|
|
95
|
+
reloadsInterrupted: number;
|
|
96
|
+
/** Trigger pulls that found an empty magazine. */
|
|
97
|
+
dryPulls: number;
|
|
98
|
+
projectilesSpawned: number;
|
|
99
|
+
projectilesExpired: number;
|
|
100
|
+
/** Pellets thrown away because `maxInFlight` was reached. */
|
|
101
|
+
pelletsDropped: number;
|
|
102
|
+
impacts: number;
|
|
103
|
+
damage: number;
|
|
104
|
+
}
|
|
105
|
+
/** A pellet alive in flight, for presentation or prediction. */
|
|
106
|
+
export interface WeaponProjectileView {
|
|
107
|
+
shot: number;
|
|
108
|
+
pellet: number;
|
|
109
|
+
position: WeaponVec3;
|
|
110
|
+
velocity: WeaponVec3;
|
|
111
|
+
age: number;
|
|
112
|
+
/** Metres travelled from the muzzle. */
|
|
113
|
+
distance: number;
|
|
114
|
+
}
|
|
115
|
+
/** Serializable runtime state: aim, handling, ammo, trigger, pellets and counters. */
|
|
116
|
+
export interface WeaponSystemState {
|
|
117
|
+
aim: {
|
|
118
|
+
yaw: number;
|
|
119
|
+
pitch: number;
|
|
120
|
+
};
|
|
121
|
+
handling: WeaponHandlingState;
|
|
122
|
+
magazine: MagazineSnapshot | null;
|
|
123
|
+
cadenceElapsedMs: number;
|
|
124
|
+
trigger: boolean;
|
|
125
|
+
pendingPull: boolean;
|
|
126
|
+
burstRemaining: number;
|
|
127
|
+
shotCount: number;
|
|
128
|
+
stats: WeaponStats;
|
|
129
|
+
pellets: WeaponProjectileView[];
|
|
130
|
+
}
|
|
131
|
+
/** Current aim with recoil applied. */
|
|
132
|
+
export interface WeaponAim {
|
|
133
|
+
yaw: number;
|
|
134
|
+
pitch: number;
|
|
135
|
+
/** Unit direction of the centre of the shot cone. */
|
|
136
|
+
direction: WeaponVec3;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* A weapon as the game drives it: base aim in, shots and resolved impacts out. Fire mode, cadence,
|
|
140
|
+
* handling, delivery, magazine and damage all come from one {@link WeaponDefinition}; the game owns
|
|
141
|
+
* input, the muzzle, and the world query.
|
|
142
|
+
*/
|
|
143
|
+
export interface WeaponSystem {
|
|
144
|
+
/** Set the aim before recoil; call every frame from look input. */
|
|
145
|
+
setAim(yaw: number, pitch: number): void;
|
|
146
|
+
/** Current aim, including recoil, and the direction the next shot would fly down. */
|
|
147
|
+
aim(): WeaponAim;
|
|
148
|
+
/** Press or release the trigger. A press survives until the next tick that can use it. */
|
|
149
|
+
setTrigger(down: boolean): void;
|
|
150
|
+
trigger(): boolean;
|
|
151
|
+
/** Advance recoil, bloom, reload, pellets and the trigger by `dt`; returns what fired and what landed. */
|
|
152
|
+
tick(dt: number, stance?: WeaponStance): WeaponTickResult;
|
|
153
|
+
/** True when a shot could leave right now: cadence ready, not reloading, ammo available. */
|
|
154
|
+
canFire(): boolean;
|
|
155
|
+
/** Start a reload, or continue an interrupted one. */
|
|
156
|
+
reload(): boolean;
|
|
157
|
+
/** Suspend an in-progress reload and keep its progress. */
|
|
158
|
+
interruptReload(): boolean;
|
|
159
|
+
/** Continue an interrupted reload from where it stopped. */
|
|
160
|
+
resumeReload(): boolean;
|
|
161
|
+
/** Abandon an in-progress reload, discarding progress. */
|
|
162
|
+
cancelReload(): void;
|
|
163
|
+
/** Swap the definition live (attachments, upgrades) without losing ammo, aim or reload progress. */
|
|
164
|
+
retune(next: WeaponDefinition): boolean;
|
|
165
|
+
/** The definition in force. */
|
|
166
|
+
definition(): WeaponDefinition;
|
|
167
|
+
ammo(): WeaponAmmo;
|
|
168
|
+
stats(): WeaponStats;
|
|
169
|
+
/** Live pellets, capped by the definition's `maxInFlight`. */
|
|
170
|
+
pellets(): readonly WeaponProjectileView[];
|
|
171
|
+
/** Handling state for recoil presentation and `measureWeapon`. */
|
|
172
|
+
handling(): WeaponHandling;
|
|
173
|
+
/** The magazine for reserve pickups and external reserve adapters, or `null`. */
|
|
174
|
+
magazine(): Magazine | null;
|
|
175
|
+
snapshot(): WeaponSystemState;
|
|
176
|
+
/** Restore decoded state; returns false without applying when the magazine reserve model differs. */
|
|
177
|
+
restore(state: WeaponSystemState): boolean;
|
|
178
|
+
reset(): void;
|
|
179
|
+
/** Drop the devtools probe registered by `devtoolsName`. Safe to call twice. */
|
|
180
|
+
dispose(): void;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Build a weapon from one {@link WeaponDefinition}. The game supplies aim, a muzzle, one world query,
|
|
184
|
+
* the damage model and an RNG; the runtime owns fire modes, cadence, recoil, bloom, reload states,
|
|
185
|
+
* ammo accounting, projectile ballistics and the damage call. State snapshots as plain data.
|
|
186
|
+
*
|
|
187
|
+
* @capability weapon-system run a data-defined weapon: fire modes, recoil and bloom, hitscan or ballistic projectiles, interrupted reloads and readable ammo, all resolving through the damage runtime
|
|
188
|
+
*/
|
|
189
|
+
export declare function createWeaponSystem(initial: WeaponDefinition, deps: WeaponSystemDeps): WeaponSystem;
|
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
import { devtools } from "../devtools/devtools.js";
|
|
2
|
+
import { createMagazine } from "./magazine.js";
|
|
3
|
+
import { assertWeaponDefinition, weaponDamageAt, weaponIntervalMs, weaponMaxInFlight, } from "./weaponDefinition.js";
|
|
4
|
+
import { createWeaponHandling, } from "./weaponHandling.js";
|
|
5
|
+
import { createFireCadence } from "./weaponFire.js";
|
|
6
|
+
const MAX_SHOTS_PER_TICK_CAP = 16;
|
|
7
|
+
const DEFAULT_SHOTS_PER_TICK = 4;
|
|
8
|
+
const HALF_PI = Math.PI / 2;
|
|
9
|
+
function freshStats() {
|
|
10
|
+
return {
|
|
11
|
+
shots: 0,
|
|
12
|
+
roundsSpent: 0,
|
|
13
|
+
pelletsFired: 0,
|
|
14
|
+
burstPulls: 0,
|
|
15
|
+
reloadsStarted: 0,
|
|
16
|
+
reloadsCompleted: 0,
|
|
17
|
+
reloadsInterrupted: 0,
|
|
18
|
+
dryPulls: 0,
|
|
19
|
+
projectilesSpawned: 0,
|
|
20
|
+
projectilesExpired: 0,
|
|
21
|
+
pelletsDropped: 0,
|
|
22
|
+
impacts: 0,
|
|
23
|
+
damage: 0,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
function directionOf(yaw, pitch) {
|
|
27
|
+
const cosPitch = Math.cos(pitch);
|
|
28
|
+
return [Math.sin(yaw) * cosPitch, Math.sin(pitch), Math.cos(yaw) * cosPitch];
|
|
29
|
+
}
|
|
30
|
+
function distanceBetween(a, b) {
|
|
31
|
+
const dx = b[0] - a[0];
|
|
32
|
+
const dy = b[1] - a[1];
|
|
33
|
+
const dz = b[2] - a[2];
|
|
34
|
+
return Math.sqrt(dx * dx + dy * dy + dz * dz);
|
|
35
|
+
}
|
|
36
|
+
function normalize(v) {
|
|
37
|
+
const length = Math.sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]);
|
|
38
|
+
return length === 0 ? [0, 0, 1] : [v[0] / length, v[1] / length, v[2] / length];
|
|
39
|
+
}
|
|
40
|
+
/** Unit direction inside the cone around `center`, uniform over solid angle from two RNG draws. */
|
|
41
|
+
function coneDirection(center, halfAngle, rng) {
|
|
42
|
+
const angle = Math.min(Math.max(halfAngle, 0), HALF_PI);
|
|
43
|
+
if (angle <= 0)
|
|
44
|
+
return center;
|
|
45
|
+
const cosHalf = Math.cos(angle);
|
|
46
|
+
const cosTheta = 1 - rng() * (1 - cosHalf);
|
|
47
|
+
const sinTheta = Math.sqrt(Math.max(0, 1 - cosTheta * cosTheta));
|
|
48
|
+
const phi = rng() * Math.PI * 2;
|
|
49
|
+
let right = normalize([center[2], 0, -center[0]]);
|
|
50
|
+
if (center[0] === 0 && center[2] === 0)
|
|
51
|
+
right = [1, 0, 0];
|
|
52
|
+
const up = [
|
|
53
|
+
center[1] * right[2] - center[2] * right[1],
|
|
54
|
+
center[2] * right[0] - center[0] * right[2],
|
|
55
|
+
center[0] * right[1] - center[1] * right[0],
|
|
56
|
+
];
|
|
57
|
+
const lateral = Math.cos(phi) * sinTheta;
|
|
58
|
+
const vertical = Math.sin(phi) * sinTheta;
|
|
59
|
+
return normalize([
|
|
60
|
+
center[0] * cosTheta + right[0] * lateral + up[0] * vertical,
|
|
61
|
+
center[1] * cosTheta + right[1] * lateral + up[1] * vertical,
|
|
62
|
+
center[2] * cosTheta + right[2] * lateral + up[2] * vertical,
|
|
63
|
+
]);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Build a weapon from one {@link WeaponDefinition}. The game supplies aim, a muzzle, one world query,
|
|
67
|
+
* the damage model and an RNG; the runtime owns fire modes, cadence, recoil, bloom, reload states,
|
|
68
|
+
* ammo accounting, projectile ballistics and the damage call. State snapshots as plain data.
|
|
69
|
+
*
|
|
70
|
+
* @capability weapon-system run a data-defined weapon: fire modes, recoil and bloom, hitscan or ballistic projectiles, interrupted reloads and readable ammo, all resolving through the damage runtime
|
|
71
|
+
*/
|
|
72
|
+
export function createWeaponSystem(initial, deps) {
|
|
73
|
+
assertWeaponDefinition(initial);
|
|
74
|
+
const rng = deps.rng;
|
|
75
|
+
const maxShotsPerTick = Math.min(MAX_SHOTS_PER_TICK_CAP, Math.max(1, Math.floor(deps.maxShotsPerTick ?? DEFAULT_SHOTS_PER_TICK)));
|
|
76
|
+
function assertWiring(definition) {
|
|
77
|
+
if (definition.delivery.kind === "hitscan" && deps.raycast === undefined) {
|
|
78
|
+
throw new Error(`Weapon "${definition.id}" is hitscan but no raycast was provided`);
|
|
79
|
+
}
|
|
80
|
+
if (definition.delivery.kind === "projectile" && deps.sweep === undefined) {
|
|
81
|
+
throw new Error(`Weapon "${definition.id}" is projectile but no sweep was provided`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
assertWiring(initial);
|
|
85
|
+
function buildMagazine(definition) {
|
|
86
|
+
const magazine = definition.magazine;
|
|
87
|
+
if (magazine === undefined)
|
|
88
|
+
return null;
|
|
89
|
+
return createMagazine({
|
|
90
|
+
capacity: magazine.capacity,
|
|
91
|
+
reloadMs: magazine.reloadMs,
|
|
92
|
+
...(magazine.reserve !== undefined ? { reserve: magazine.reserve } : {}),
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
let definition = initial;
|
|
96
|
+
let intervalMs = weaponIntervalMs(initial);
|
|
97
|
+
let cadence = createFireCadence({ intervalMs });
|
|
98
|
+
let magazine = buildMagazine(definition);
|
|
99
|
+
const handling = createWeaponHandling(initial.handling, { random: rng });
|
|
100
|
+
let aimYaw = 0;
|
|
101
|
+
let aimPitch = 0;
|
|
102
|
+
let triggerDown = false;
|
|
103
|
+
let pendingPull = false;
|
|
104
|
+
let burstRemaining = 0;
|
|
105
|
+
let shotCount = 0;
|
|
106
|
+
let dryCounted = false;
|
|
107
|
+
let pellets = [];
|
|
108
|
+
const stats = freshStats();
|
|
109
|
+
function reloadState() {
|
|
110
|
+
if (magazine === null)
|
|
111
|
+
return "idle";
|
|
112
|
+
if (magazine.isPaused())
|
|
113
|
+
return "interrupted";
|
|
114
|
+
return magazine.isReloading() ? "reloading" : "idle";
|
|
115
|
+
}
|
|
116
|
+
function aimNow() {
|
|
117
|
+
const frame = handling.frame();
|
|
118
|
+
const yaw = aimYaw + frame.aimYaw;
|
|
119
|
+
const pitch = Math.max(-HALF_PI * 0.98, Math.min(HALF_PI * 0.98, aimPitch + frame.aimPitch));
|
|
120
|
+
return { yaw, pitch, direction: directionOf(yaw, pitch) };
|
|
121
|
+
}
|
|
122
|
+
function defaultDamage(contact, target, falloff) {
|
|
123
|
+
const damage = definition.damage;
|
|
124
|
+
return {
|
|
125
|
+
target,
|
|
126
|
+
type: damage.type,
|
|
127
|
+
base: damage.base * falloff,
|
|
128
|
+
source: definition.id,
|
|
129
|
+
...(contact.zone !== undefined ? { zone: contact.zone } : {}),
|
|
130
|
+
...(damage.statuses !== undefined ? { statuses: damage.statuses } : {}),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
function resolveContact(contact, distance, shot, pellet, out) {
|
|
134
|
+
const target = contact.target;
|
|
135
|
+
if (target === undefined)
|
|
136
|
+
return;
|
|
137
|
+
const falloff = weaponDamageAt(definition, distance);
|
|
138
|
+
const context = { shot, pellet, distance, falloff };
|
|
139
|
+
const input = deps.damageFor === undefined
|
|
140
|
+
? defaultDamage(contact, target, falloff)
|
|
141
|
+
: deps.damageFor(contact, context);
|
|
142
|
+
if (input === null)
|
|
143
|
+
return;
|
|
144
|
+
const resolution = deps.damage.hit(input);
|
|
145
|
+
stats.impacts += 1;
|
|
146
|
+
stats.damage += resolution.damage;
|
|
147
|
+
out.impacts.push({
|
|
148
|
+
shot,
|
|
149
|
+
pellet,
|
|
150
|
+
target,
|
|
151
|
+
zone: resolution.zone.zone,
|
|
152
|
+
distance,
|
|
153
|
+
falloff,
|
|
154
|
+
resolution,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
function spawnPellet(shot, pellet, origin, direction) {
|
|
158
|
+
const delivery = definition.delivery;
|
|
159
|
+
if (delivery.kind !== "projectile")
|
|
160
|
+
return;
|
|
161
|
+
if (pellets.length >= weaponMaxInFlight(definition)) {
|
|
162
|
+
stats.pelletsDropped += 1;
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
const speed = delivery.speed;
|
|
166
|
+
pellets.push({
|
|
167
|
+
shot,
|
|
168
|
+
pellet,
|
|
169
|
+
position: origin,
|
|
170
|
+
velocity: [direction[0] * speed, direction[1] * speed, direction[2] * speed],
|
|
171
|
+
age: 0,
|
|
172
|
+
distance: 0,
|
|
173
|
+
});
|
|
174
|
+
stats.projectilesSpawned += 1;
|
|
175
|
+
}
|
|
176
|
+
function fireShot(stance, out) {
|
|
177
|
+
const shot = handling.fire(stance);
|
|
178
|
+
const aim = aimNow();
|
|
179
|
+
const origin = [...deps.origin()];
|
|
180
|
+
const perShot = Math.max(1, Math.floor(definition.pellets ?? 1));
|
|
181
|
+
const directions = [];
|
|
182
|
+
shotCount += 1;
|
|
183
|
+
for (let pellet = 0; pellet < perShot; pellet += 1) {
|
|
184
|
+
const direction = coneDirection(aim.direction, shot.spread, rng);
|
|
185
|
+
directions.push(direction);
|
|
186
|
+
if (definition.delivery.kind === "hitscan") {
|
|
187
|
+
const raycast = deps.raycast;
|
|
188
|
+
if (raycast === undefined)
|
|
189
|
+
continue;
|
|
190
|
+
const contact = raycast(origin, direction, definition.delivery.maxRange);
|
|
191
|
+
if (contact === null)
|
|
192
|
+
continue;
|
|
193
|
+
const distance = distanceBetween(origin, contact.point);
|
|
194
|
+
if (distance > definition.delivery.maxRange + 1e-6)
|
|
195
|
+
continue;
|
|
196
|
+
resolveContact(contact, distance, shotCount, pellet, out);
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
spawnPellet(shotCount, pellet, origin, direction);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
cadence.restore(intervalMs);
|
|
203
|
+
cadence.fire();
|
|
204
|
+
magazine?.fire(1);
|
|
205
|
+
stats.shots += 1;
|
|
206
|
+
stats.pelletsFired += perShot;
|
|
207
|
+
if (magazine !== null)
|
|
208
|
+
stats.roundsSpent += 1;
|
|
209
|
+
dryCounted = false;
|
|
210
|
+
out.shots.push({
|
|
211
|
+
shot: shotCount,
|
|
212
|
+
spread: shot.spread,
|
|
213
|
+
kickPitch: shot.kickPitch,
|
|
214
|
+
kickYaw: shot.kickYaw,
|
|
215
|
+
origin,
|
|
216
|
+
directions,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
function advancePellets(dt, out) {
|
|
220
|
+
const delivery = definition.delivery;
|
|
221
|
+
if (pellets.length === 0 || delivery.kind !== "projectile")
|
|
222
|
+
return;
|
|
223
|
+
const sweep = deps.sweep;
|
|
224
|
+
if (sweep === undefined)
|
|
225
|
+
return;
|
|
226
|
+
const gravity = delivery.gravity ?? 0;
|
|
227
|
+
const drag = delivery.drag ?? 0;
|
|
228
|
+
const dragScale = Math.max(0, 1 - drag * dt);
|
|
229
|
+
const next = [];
|
|
230
|
+
for (const pellet of pellets) {
|
|
231
|
+
// Semi-implicit Euler: drag the old velocity, apply gravity, then sweep the whole step so a
|
|
232
|
+
// fast pellet cannot tunnel through a thin target between ticks.
|
|
233
|
+
const velocity = [
|
|
234
|
+
pellet.velocity[0] * dragScale,
|
|
235
|
+
pellet.velocity[1] * dragScale - gravity * dt,
|
|
236
|
+
pellet.velocity[2] * dragScale,
|
|
237
|
+
];
|
|
238
|
+
const to = [
|
|
239
|
+
pellet.position[0] + velocity[0] * dt,
|
|
240
|
+
pellet.position[1] + velocity[1] * dt,
|
|
241
|
+
pellet.position[2] + velocity[2] * dt,
|
|
242
|
+
];
|
|
243
|
+
const contact = sweep(pellet.position, to);
|
|
244
|
+
if (contact !== null) {
|
|
245
|
+
resolveContact(contact, pellet.distance + distanceBetween(pellet.position, contact.point), pellet.shot, pellet.pellet, out);
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
const travelled = pellet.distance + distanceBetween(pellet.position, to);
|
|
249
|
+
pellet.position = to;
|
|
250
|
+
pellet.velocity = velocity;
|
|
251
|
+
pellet.age += dt;
|
|
252
|
+
pellet.distance = travelled;
|
|
253
|
+
const outOfRange = delivery.maxRange !== undefined && travelled >= delivery.maxRange;
|
|
254
|
+
if (pellet.age >= delivery.lifetime || outOfRange) {
|
|
255
|
+
stats.projectilesExpired += 1;
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
next.push(pellet);
|
|
259
|
+
}
|
|
260
|
+
pellets = next;
|
|
261
|
+
}
|
|
262
|
+
function noteDryPull() {
|
|
263
|
+
if (!dryCounted) {
|
|
264
|
+
stats.dryPulls += 1;
|
|
265
|
+
dryCounted = true;
|
|
266
|
+
}
|
|
267
|
+
pendingPull = false;
|
|
268
|
+
if (definition.autoReload !== false)
|
|
269
|
+
beginReload();
|
|
270
|
+
}
|
|
271
|
+
function wantsShot() {
|
|
272
|
+
if (burstRemaining > 0)
|
|
273
|
+
return true;
|
|
274
|
+
if (definition.fireMode === "auto")
|
|
275
|
+
return triggerDown || pendingPull;
|
|
276
|
+
return pendingPull;
|
|
277
|
+
}
|
|
278
|
+
function beginReload() {
|
|
279
|
+
if (magazine === null)
|
|
280
|
+
return false;
|
|
281
|
+
const state = reloadState();
|
|
282
|
+
if (state === "interrupted")
|
|
283
|
+
return magazine.resumeReload();
|
|
284
|
+
if (state !== "idle")
|
|
285
|
+
return false;
|
|
286
|
+
if (!magazine.startReload())
|
|
287
|
+
return false;
|
|
288
|
+
stats.reloadsStarted += 1;
|
|
289
|
+
burstRemaining = 0;
|
|
290
|
+
pendingPull = false;
|
|
291
|
+
return true;
|
|
292
|
+
}
|
|
293
|
+
function fireDue(dt, stance, out) {
|
|
294
|
+
if (!wantsShot())
|
|
295
|
+
return;
|
|
296
|
+
if (reloadState() !== "idle") {
|
|
297
|
+
pendingPull = false;
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
if (magazine !== null && !magazine.canFire(1)) {
|
|
301
|
+
noteDryPull();
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (pendingPull && definition.fireMode === "burst" && burstRemaining === 0) {
|
|
305
|
+
burstRemaining = definition.burst?.count ?? 1;
|
|
306
|
+
stats.burstPulls += 1;
|
|
307
|
+
}
|
|
308
|
+
const elapsedBefore = cadence.elapsedMs();
|
|
309
|
+
const totalMs = elapsedBefore + Math.max(0, dt) * 1000;
|
|
310
|
+
cadence.tick(dt);
|
|
311
|
+
const due = Math.min(maxShotsPerTick, Math.floor(totalMs / intervalMs));
|
|
312
|
+
let fired = 0;
|
|
313
|
+
while (fired < due && wantsShot()) {
|
|
314
|
+
if (magazine !== null && !magazine.canFire(1)) {
|
|
315
|
+
noteDryPull();
|
|
316
|
+
break;
|
|
317
|
+
}
|
|
318
|
+
fireShot(stance, out);
|
|
319
|
+
fired += 1;
|
|
320
|
+
if (pendingPull)
|
|
321
|
+
pendingPull = false;
|
|
322
|
+
if (definition.fireMode === "burst")
|
|
323
|
+
burstRemaining = Math.max(0, burstRemaining - 1);
|
|
324
|
+
if (definition.fireMode !== "auto" && !wantsShot())
|
|
325
|
+
break;
|
|
326
|
+
}
|
|
327
|
+
if (fired > 0)
|
|
328
|
+
cadence.restore(totalMs - fired * intervalMs);
|
|
329
|
+
}
|
|
330
|
+
function probeLine() {
|
|
331
|
+
const ammo = magazine;
|
|
332
|
+
const load = ammo === null
|
|
333
|
+
? "unlimited"
|
|
334
|
+
: `${ammo.loaded()}/${ammo.capacity()}${ammo.reserve() === null ? "" : ` +${ammo.reserve()}`}`;
|
|
335
|
+
return `${definition.id} ${definition.fireMode} · ${stats.shots} shots ${load} · ${pellets.length} in flight · ${stats.impacts} hits ${stats.damage.toFixed(0)} dealt`;
|
|
336
|
+
}
|
|
337
|
+
const unregister = deps.devtoolsName === undefined
|
|
338
|
+
? null
|
|
339
|
+
: devtools.probes.register(deps.devtoolsName, () => probeLine());
|
|
340
|
+
return {
|
|
341
|
+
setAim(yaw, pitch) {
|
|
342
|
+
aimYaw = yaw;
|
|
343
|
+
aimPitch = Math.max(-HALF_PI, Math.min(HALF_PI, pitch));
|
|
344
|
+
},
|
|
345
|
+
aim: aimNow,
|
|
346
|
+
setTrigger(down) {
|
|
347
|
+
if (down && !triggerDown)
|
|
348
|
+
pendingPull = true;
|
|
349
|
+
if (!down)
|
|
350
|
+
dryCounted = false;
|
|
351
|
+
triggerDown = down;
|
|
352
|
+
},
|
|
353
|
+
trigger: () => triggerDown,
|
|
354
|
+
tick(dt, stance) {
|
|
355
|
+
const out = { shots: [], impacts: [] };
|
|
356
|
+
const step = Number.isFinite(dt) ? Math.max(0, dt) : 0;
|
|
357
|
+
handling.tick(step, stance);
|
|
358
|
+
if (magazine !== null) {
|
|
359
|
+
const wasReloading = magazine.isReloading();
|
|
360
|
+
magazine.tick(step);
|
|
361
|
+
if (wasReloading && !magazine.isReloading())
|
|
362
|
+
stats.reloadsCompleted += 1;
|
|
363
|
+
}
|
|
364
|
+
advancePellets(step, out);
|
|
365
|
+
fireDue(step, stance, out);
|
|
366
|
+
return out;
|
|
367
|
+
},
|
|
368
|
+
canFire() {
|
|
369
|
+
if (reloadState() !== "idle")
|
|
370
|
+
return false;
|
|
371
|
+
if (!cadence.ready())
|
|
372
|
+
return false;
|
|
373
|
+
return magazine === null || magazine.canFire(1);
|
|
374
|
+
},
|
|
375
|
+
reload: beginReload,
|
|
376
|
+
interruptReload() {
|
|
377
|
+
if (magazine === null || !magazine.pauseReload())
|
|
378
|
+
return false;
|
|
379
|
+
stats.reloadsInterrupted += 1;
|
|
380
|
+
return true;
|
|
381
|
+
},
|
|
382
|
+
resumeReload: () => magazine?.resumeReload() ?? false,
|
|
383
|
+
cancelReload() {
|
|
384
|
+
magazine?.cancelReload();
|
|
385
|
+
},
|
|
386
|
+
retune(next) {
|
|
387
|
+
assertWeaponDefinition(next);
|
|
388
|
+
assertWiring(next);
|
|
389
|
+
if ((next.magazine === undefined) !== (magazine === null))
|
|
390
|
+
return false;
|
|
391
|
+
const elapsed = cadence.elapsedMs();
|
|
392
|
+
definition = next;
|
|
393
|
+
intervalMs = weaponIntervalMs(next);
|
|
394
|
+
cadence = createFireCadence({ intervalMs });
|
|
395
|
+
cadence.restore(elapsed);
|
|
396
|
+
handling.retune(next.handling);
|
|
397
|
+
if (next.magazine !== undefined && magazine !== null) {
|
|
398
|
+
magazine.retune({ capacity: next.magazine.capacity, reloadMs: next.magazine.reloadMs, overflow: "discard" });
|
|
399
|
+
}
|
|
400
|
+
return true;
|
|
401
|
+
},
|
|
402
|
+
definition: () => definition,
|
|
403
|
+
ammo() {
|
|
404
|
+
return {
|
|
405
|
+
loaded: magazine?.loaded() ?? null,
|
|
406
|
+
capacity: magazine?.capacity() ?? null,
|
|
407
|
+
reserve: magazine?.reserve() ?? null,
|
|
408
|
+
reload: reloadState(),
|
|
409
|
+
reloadFraction: magazine?.reloadFraction() ?? 0,
|
|
410
|
+
burstRemaining,
|
|
411
|
+
cadenceFraction: cadence.readyFraction(),
|
|
412
|
+
};
|
|
413
|
+
},
|
|
414
|
+
stats: () => ({ ...stats }),
|
|
415
|
+
pellets: () => pellets.map((pellet) => ({ ...pellet })),
|
|
416
|
+
handling: () => handling,
|
|
417
|
+
magazine: () => magazine,
|
|
418
|
+
snapshot() {
|
|
419
|
+
return {
|
|
420
|
+
aim: { yaw: aimYaw, pitch: aimPitch },
|
|
421
|
+
handling: handling.snapshot(),
|
|
422
|
+
magazine: magazine === null ? null : magazine.snapshot(),
|
|
423
|
+
cadenceElapsedMs: cadence.elapsedMs(),
|
|
424
|
+
trigger: triggerDown,
|
|
425
|
+
pendingPull,
|
|
426
|
+
burstRemaining,
|
|
427
|
+
shotCount,
|
|
428
|
+
stats: { ...stats },
|
|
429
|
+
pellets: pellets.map((pellet) => ({ ...pellet })),
|
|
430
|
+
};
|
|
431
|
+
},
|
|
432
|
+
restore(state) {
|
|
433
|
+
if ((state.magazine === null) !== (magazine === null))
|
|
434
|
+
return false;
|
|
435
|
+
if (state.magazine !== null && magazine !== null && !magazine.restore(state.magazine))
|
|
436
|
+
return false;
|
|
437
|
+
aimYaw = state.aim.yaw;
|
|
438
|
+
aimPitch = state.aim.pitch;
|
|
439
|
+
handling.restore(state.handling);
|
|
440
|
+
cadence.restore(state.cadenceElapsedMs);
|
|
441
|
+
triggerDown = state.trigger;
|
|
442
|
+
pendingPull = state.pendingPull;
|
|
443
|
+
burstRemaining = Math.max(0, Math.floor(state.burstRemaining));
|
|
444
|
+
shotCount = Math.max(0, Math.floor(state.shotCount));
|
|
445
|
+
Object.assign(stats, freshStats(), state.stats);
|
|
446
|
+
const cap = weaponMaxInFlight(definition);
|
|
447
|
+
pellets = state.pellets.slice(0, cap).map((pellet) => ({ ...pellet }));
|
|
448
|
+
dryCounted = false;
|
|
449
|
+
return true;
|
|
450
|
+
},
|
|
451
|
+
reset() {
|
|
452
|
+
cadence = createFireCadence({ intervalMs });
|
|
453
|
+
magazine = buildMagazine(definition);
|
|
454
|
+
handling.reset();
|
|
455
|
+
aimYaw = 0;
|
|
456
|
+
aimPitch = 0;
|
|
457
|
+
triggerDown = false;
|
|
458
|
+
pendingPull = false;
|
|
459
|
+
burstRemaining = 0;
|
|
460
|
+
shotCount = 0;
|
|
461
|
+
dryCounted = false;
|
|
462
|
+
pellets = [];
|
|
463
|
+
Object.assign(stats, freshStats());
|
|
464
|
+
},
|
|
465
|
+
dispose() {
|
|
466
|
+
unregister?.();
|
|
467
|
+
},
|
|
468
|
+
};
|
|
469
|
+
}
|