@jgengine/core 0.35.0 → 0.37.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.
Files changed (39) hide show
  1. package/CHANGELOG.md +116 -0
  2. package/dist/agents/system.d.ts +135 -0
  3. package/dist/agents/system.js +195 -0
  4. package/dist/combat/projectiles.d.ts +6 -2
  5. package/dist/combat/projectiles.js +55 -52
  6. package/dist/combat/shotSpec.d.ts +239 -0
  7. package/dist/combat/shotSpec.js +216 -0
  8. package/dist/combat/weaponSystem.js +26 -21
  9. package/dist/combat.d.ts +1 -0
  10. package/dist/combat.js +1 -0
  11. package/dist/game/playableGame.d.ts +9 -0
  12. package/dist/meta/changelog.d.ts +1 -1
  13. package/dist/meta/changelog.js +77 -1
  14. package/dist/movement/characterController.d.ts +5 -0
  15. package/dist/movement/characterController.js +8 -1
  16. package/dist/movement/movementModel.js +4 -1
  17. package/dist/movement/playerMovement.js +4 -0
  18. package/dist/physics/physicsBackendConformance.js +17 -0
  19. package/dist/physics/physicsWorld.d.ts +7 -0
  20. package/dist/physics/physicsWorld.js +27 -6
  21. package/dist/run/runLoop.d.ts +44 -1
  22. package/dist/run/runLoop.js +66 -5
  23. package/dist/world/features.d.ts +15 -1
  24. package/dist/world/features.js +51 -2
  25. package/dist/world/lod.d.ts +8 -0
  26. package/dist/world/lod.js +13 -5
  27. package/dist/world/modelLod.d.ts +44 -0
  28. package/dist/world/modelLod.js +75 -0
  29. package/dist/world.d.ts +2 -1
  30. package/dist/world.js +2 -1
  31. package/package.json +1 -1
  32. package/skills/jgengine/api.md +2 -2
  33. package/skills/jgengine-combat/SKILL.md +7 -0
  34. package/skills/jgengine-combat/api.md +61 -1
  35. package/skills/jgengine-combat/capabilities.md +4 -0
  36. package/skills/jgengine-combat/reference.md +1 -0
  37. package/skills/jgengine-gameplay/api.md +35 -0
  38. package/skills/jgengine-gameplay/capabilities.md +2 -0
  39. package/skills/jgengine-world/api.md +17 -0
@@ -1,9 +1,6 @@
1
1
  import { createSceneRaycast, firstImpact, hitsUntilBlocked, } from "../scene/sceneRaycast.js";
2
+ import { resolveShotSpec, shotArc, shotHitOutcome, shotSpecFromStats } from "./shotSpec.js";
2
3
  import { aimDirection, aimSpreadDeg, convergeShot, resolveShot, } from "./shotOrigin.js";
3
- const DEFAULT_RANGE = 100;
4
- const DEFAULT_PROJECTILE_SPEED = 15;
5
- const GRAVITY = 9.8;
6
- const BASE_HIT_RADIUS = 0.5;
7
4
  function copyPosition(position) {
8
5
  return [position[0], position[1], position[2]];
9
6
  }
@@ -256,53 +253,36 @@ export function createProjectileSystem(deps) {
256
253
  const until = hitsUntilBlocked(all);
257
254
  return until.map(sceneHitToRaycast);
258
255
  });
259
- function resolveRange(via) {
260
- return itemStat(via, "range") ?? DEFAULT_RANGE;
256
+ function resolveShotSpecOf(input) {
257
+ return resolveShotSpec(input.spec ?? shotSpecFromStats((stat) => itemStat(input.via, stat)));
261
258
  }
262
- function isBallistic(via) {
263
- return itemStat(via, "projectile.fuseTime") !== null || itemStat(via, "explosion.radius") !== null;
264
- }
265
- function ballisticArc(input) {
266
- const resolved = resolveShotFor(input.from, input.aim, input.originPolicy, resolveRange(input.via));
259
+ function ballisticArc(input, spec) {
260
+ const resolved = resolveShotFor(input.from, input.aim, input.originPolicy, spec.range);
267
261
  const origin = resolved?.origin ?? [0, 0, 0];
268
262
  const direction = resolved?.direction ?? aimDirection(input.aim) ?? [0, 0, 1];
269
- const speed = itemStat(input.via, "projectile.speed") ?? DEFAULT_PROJECTILE_SPEED;
270
- const gravityScale = itemStat(input.via, "projectile.gravity") ?? 1;
271
- const fuseTime = itemStat(input.via, "projectile.fuseTime");
272
- const gravity = GRAVITY * gravityScale;
273
- const verticalSpeed = direction[1] * speed;
274
- const flightCap = fuseTime ?? resolveRange(input.via) / speed;
275
- const impactTime = gravity > 0
276
- ? (verticalSpeed + Math.sqrt(verticalSpeed * verticalSpeed + 2 * gravity * Math.max(0, origin[1]))) / gravity
277
- : flightCap;
278
- const flightTime = Math.min(impactTime, flightCap);
279
- const settledY = Math.max(0, origin[1] + verticalSpeed * flightTime - 0.5 * gravity * flightTime * flightTime);
280
- return {
281
- origin,
282
- velocity: [direction[0] * speed, verticalSpeed, direction[2] * speed],
283
- gravity,
284
- flightTime,
285
- landing: [
286
- origin[0] + direction[0] * speed * flightTime,
287
- settledY,
288
- origin[2] + direction[2] * speed * flightTime,
289
- ],
290
- };
263
+ const arc = shotArc(spec, origin, direction);
264
+ return { origin, velocity: arc.velocity, gravity: arc.gravity, flightTime: arc.flightTime, landing: arc.landing };
291
265
  }
292
- function ballisticSettlePoint(input) {
293
- const arc = ballisticArc(input);
266
+ function ballisticSettlePoint(input, spec) {
267
+ const arc = ballisticArc(input, spec);
294
268
  const sweep = deps.sweepBallistic;
295
269
  if (sweep !== undefined) {
296
270
  const impact = sweep(arc.origin, arc.velocity, arc.gravity, arc.flightTime);
297
271
  if (impact !== null)
298
272
  return impact.point;
299
273
  }
274
+ if (arc.gravity === 0 && spec.body.fuse === "impact") {
275
+ const contact = raycast(input.from, withWeaponSpread(input.via, input.aim), spec.range, input.originPolicy)[0];
276
+ if (contact !== undefined)
277
+ return [contact.at[0], contact.at[1], contact.at[2]];
278
+ }
300
279
  return arc.landing;
301
280
  }
302
281
  function predictHits(input) {
303
282
  const aim = withWeaponSpread(input.via, input.aim);
304
- const resolved = resolveShotFor(input.from, aim, input.originPolicy, resolveRange(input.via));
305
- const rawHits = raycast(input.from, aim, resolveRange(input.via), input.originPolicy);
283
+ const range = resolveShotSpecOf(input).range;
284
+ const resolved = resolveShotFor(input.from, aim, input.originPolicy, range);
285
+ const rawHits = raycast(input.from, aim, range, input.originPolicy);
306
286
  const visible = rawHits.filter((hit) => isObjectHit(hit) || deps.spatial.hasLineOfSight(input.from, hit.instanceId));
307
287
  return {
308
288
  rawHits,
@@ -312,7 +292,7 @@ export function createProjectileSystem(deps) {
312
292
  };
313
293
  }
314
294
  function missPoint(input) {
315
- const range = resolveRange(input.via);
295
+ const range = resolveShotSpecOf(input).range;
316
296
  const resolved = resolveShotFor(input.from, input.aim, input.originPolicy, range);
317
297
  if (resolved === null)
318
298
  return [0, 0, 0];
@@ -356,14 +336,16 @@ export function createProjectileSystem(deps) {
356
336
  ...input,
357
337
  aim: copyAim(input.aim),
358
338
  via: { ...input.via },
339
+ ...(input.spec === undefined ? {} : { spec: structuredClone(input.spec) }),
359
340
  originPolicy: copyOriginPolicy(input.originPolicy ?? defaultPolicy),
360
341
  ...(input.travel === undefined ? {} : { travel: { ...input.travel, ...(input.travel.gravity === undefined ? {} : { gravity: copyPosition(input.travel.gravity) }) } }),
361
342
  };
362
343
  captured.aim = withWeaponSpread(captured.via, captured.aim);
344
+ const spec = resolveShotSpecOf(captured);
363
345
  const count = itemStat(captured.via, "pellets") ?? 1;
364
346
  const pellets = Number.isFinite(count) ? Math.max(1, Math.min(maxPellets, Math.round(count))) : 1;
365
347
  const coneSamples = [];
366
- if ((captured.travel !== undefined || !isBallistic(captured.via)) && aimSpreadDeg(captured.aim) > 0) {
348
+ if ((captured.travel !== undefined || spec.mode === "trace") && aimSpreadDeg(captured.aim) > 0) {
367
349
  for (let pellet = 0; pellet < pellets; pellet += 1) {
368
350
  coneSamples.push([rng(), rng()]);
369
351
  }
@@ -372,7 +354,7 @@ export function createProjectileSystem(deps) {
372
354
  if (captured.travel !== undefined) {
373
355
  if (!Number.isFinite(shot.firedAt))
374
356
  throw new RangeError("Projectile authority time must be finite");
375
- const resolved = resolveShotFor(captured.from, captured.aim, captured.originPolicy, resolveRange(captured.via));
357
+ const resolved = resolveShotFor(captured.from, captured.aim, captured.originPolicy, spec.range);
376
358
  if (resolved === null)
377
359
  throw new Error("Live projectile requires a resolvable launch origin and direction");
378
360
  shot.origin = copyPosition(resolved.origin);
@@ -399,7 +381,8 @@ export function createProjectileSystem(deps) {
399
381
  continue;
400
382
  const travel = shot.input.travel;
401
383
  const gravity = travel.gravity ?? [0, 0, 0];
402
- const range = resolveRange(shot.input.via);
384
+ const spec = resolveShotSpecOf(shot.input);
385
+ const range = spec.range;
403
386
  for (const flight of shot.flights) {
404
387
  if (!flight.active)
405
388
  continue;
@@ -440,9 +423,9 @@ export function createProjectileSystem(deps) {
440
423
  flight.active = false;
441
424
  shot.at ??= copyPosition(flight.position);
442
425
  const target = impact?.target;
443
- const splash = itemStat(shot.input.via, "explosion.radius");
426
+ const splash = spec.contact.splash;
444
427
  if (impact !== null && splash !== null) {
445
- shot.hits.push(...deps.effects.applyEffect({ from: shot.input.from, effect: shot.input.effect, via: shot.input.via, at: flight.position, radius: splash, falloff: "linear" }));
428
+ shot.hits.push(...deps.effects.applyEffect({ from: shot.input.from, effect: shot.input.effect, via: shot.input.via, at: flight.position, radius: splash.radius, falloff: splash.falloff }));
446
429
  }
447
430
  else if (target?.kind === "entity" && target.damageEligible !== false && deps.effects.canReceive(target.instanceId, shot.input.effect) === null) {
448
431
  shot.hits.push(...deps.effects.applyEffect({ from: shot.input.from, to: target.instanceId, effect: shot.input.effect, via: shot.input.via }));
@@ -514,20 +497,20 @@ export function createProjectileSystem(deps) {
514
497
  shot.settled = true;
515
498
  pruneSettled();
516
499
  const { input } = shot;
517
- const resolved = resolveShotFor(input.from, input.aim, input.originPolicy, resolveRange(input.via));
500
+ const spec = resolveShotSpecOf(input);
501
+ const resolved = resolveShotFor(input.from, input.aim, input.originPolicy, spec.range);
518
502
  const origin = resolved?.origin ?? [0, 0, 0];
519
503
  const originTuple = [origin[0], origin[1], origin[2]];
520
- if (isBallistic(input.via)) {
521
- const at = ballisticSettlePoint(input);
522
- const splashRadius = itemStat(input.via, "explosion.radius");
523
- const radius = splashRadius ?? BASE_HIT_RADIUS;
504
+ if (spec.mode === "body") {
505
+ const at = ballisticSettlePoint(input, spec);
506
+ const splash = spec.contact.splash;
524
507
  const hits = deps.effects.applyEffect({
525
508
  from: input.from,
526
509
  effect: input.effect,
527
510
  via: input.via,
528
511
  at,
529
- radius,
530
- falloff: splashRadius === null ? "none" : "linear",
512
+ radius: splash?.radius ?? spec.body.radius,
513
+ falloff: splash?.falloff ?? "none",
531
514
  });
532
515
  deps.onSettle?.({ shotId, hits: structuredClone(hits), from: input.from, origin, at, effect: input.effect, hit: hits.length > 0, ballistic: true });
533
516
  return { status: "settled", shotId, at, hits, origin: originTuple };
@@ -550,14 +533,34 @@ export function createProjectileSystem(deps) {
550
533
  const target = untilBlock.find((hit) => hit.targetKind === "entity" && hit.damageEligible &&
551
534
  deps.spatial.hasLineOfSight(input.from, hit.instanceId) &&
552
535
  deps.effects.canReceive(hit.instanceId, input.effect) === null);
553
- if (target !== undefined) {
536
+ const splash = spec.contact.splash;
537
+ if (splash !== null) {
538
+ const centre = (target === undefined ? undefined : deps.spatial.positionOf(target.instanceId)) ?? impact?.point ?? target?.point ?? missPoint(pelletInput);
554
539
  hits.push(...deps.effects.applyEffect({
555
540
  from: input.from,
556
- to: target.instanceId,
557
541
  effect: input.effect,
558
542
  via: input.via,
543
+ at: [centre[0], centre[1], centre[2]],
544
+ radius: splash.radius,
545
+ falloff: splash.falloff,
559
546
  }));
560
547
  }
548
+ else if (target !== undefined) {
549
+ const hitsTaken = untilBlock.filter((hit) => hit.targetKind === "entity" && hit.damageEligible &&
550
+ deps.spatial.hasLineOfSight(input.from, hit.instanceId) &&
551
+ deps.effects.canReceive(hit.instanceId, input.effect) === null).slice(0, spec.contact.penetration.count + 1);
552
+ for (const [index, hit] of hitsTaken.entries()) {
553
+ const outcome = shotHitOutcome(spec, hit.distance, index);
554
+ if (!outcome.applies || outcome.scale <= 0)
555
+ continue;
556
+ hits.push(...deps.effects.applyEffect({
557
+ from: input.from,
558
+ to: hit.instanceId,
559
+ effect: input.effect,
560
+ via: outcome.scale === 1 ? input.via : { ...input.via, amount: (spec.contact.damage?.base ?? 0) * outcome.scale },
561
+ }));
562
+ }
563
+ }
561
564
  if (at === undefined) {
562
565
  const point = impact?.point ?? target?.point ?? missPoint(pelletInput);
563
566
  at = [point[0], point[1], point[2]];
@@ -0,0 +1,239 @@
1
+ /**
2
+ * One description of how a shot travels and what it does when it arrives, plus the single resolver
3
+ * that turns that description into behaviour. A weapon is data: a rifle, a grenade, a mortar and an
4
+ * arrow differ by fields here, never by a code path, a weapon id or a classifier that reads one stat
5
+ * as proof of another.
6
+ */
7
+ /** A world-space point or direction in metres. */
8
+ export type ShotVec3 = readonly [number, number, number];
9
+ /** Standard gravity used to scale `ShotBody.gravity`, m/s². */
10
+ export declare const SHOT_GRAVITY = 9.8;
11
+ /** Furthest a shot resolves when the delivery names no range, m. */
12
+ export declare const DEFAULT_SHOT_RANGE = 100;
13
+ /** Launch speed of a body that names none, m/s. */
14
+ export declare const DEFAULT_SHOT_SPEED = 15;
15
+ /** Hit-sphere radius of a body that names none, m. */
16
+ export declare const DEFAULT_SHOT_RADIUS = 0.5;
17
+ /** Most extra hits a shot may pass through when it names no penetration. */
18
+ export declare const DEFAULT_SHOT_PENETRATION = 0;
19
+ /** Damage multiplier left at the end of falloff when a shot names no end scale. */
20
+ export declare const DEFAULT_FALLOFF_END_SCALE = 0.5;
21
+ /** An instant trace: the shot resolves on the tick it is fired and never travels. */
22
+ export interface ShotTrace {
23
+ kind: "trace";
24
+ /** Furthest the trace resolves, m (default {@link DEFAULT_SHOT_RANGE}). */
25
+ range?: number;
26
+ }
27
+ /**
28
+ * A travelling body simulated over time. Every dimension is stated; nothing is read off another
29
+ * field, so a slow arrow with a splash and a fast bullet without one are the same shape.
30
+ */
31
+ export interface ShotBody {
32
+ kind: "body";
33
+ /** Hard distance cap, m, on top of `lifetime` (default {@link DEFAULT_SHOT_RANGE}). */
34
+ range?: number;
35
+ /** Launch speed, m/s (default {@link DEFAULT_SHOT_SPEED}). */
36
+ speed?: number;
37
+ /** Downward acceleration as a multiple of {@link SHOT_GRAVITY} (default `0`: flies straight). */
38
+ gravity?: number;
39
+ /** Velocity decay, 1/s (default `0`: keeps its speed). */
40
+ drag?: number;
41
+ /** Seconds the body stays alive before it expires (default `range / speed`). */
42
+ lifetime?: number;
43
+ /** Radius of the hit sphere, m (default {@link DEFAULT_SHOT_RADIUS}). */
44
+ radius?: number;
45
+ /**
46
+ * `"impact"` detonates on the first contact (default). A number detonates that many seconds into
47
+ * flight, which is what gives a grenade its fuse and a mortar its ground burst.
48
+ */
49
+ fuse?: number | "impact";
50
+ /** Turn rate toward the aimed target, rad/s (default `0`: no homing). */
51
+ homing?: number;
52
+ }
53
+ /** How the shot reaches its target. */
54
+ export type ShotDelivery = ShotTrace | ShotBody;
55
+ /** Linear damage falloff between two distances. */
56
+ export interface ShotFalloff {
57
+ /** Distance, m, where damage starts dropping. */
58
+ start: number;
59
+ /** Distance, m, where it reaches `endScale`; clamped beyond. */
60
+ end: number;
61
+ /** Damage multiplier at `end`, `0..1`. */
62
+ endScale: number;
63
+ }
64
+ /** Extra targets a hit passes through. */
65
+ export interface ShotPenetration {
66
+ /** Targets after the first that the shot may also hit (default `0`). */
67
+ count: number;
68
+ /** Whether those later hits keep full damage (default `true`); `false` leaves them undamaged. */
69
+ keepDamage?: boolean;
70
+ }
71
+ /** Area damage around the contact point. */
72
+ export interface ShotSplash {
73
+ /** Radius, m. */
74
+ radius: number;
75
+ /** Damage scale across the radius (default `"linear"`). */
76
+ falloff?: "linear" | "none";
77
+ /** Multiplier on the direct hit's damage before falloff (default `1`). */
78
+ damageScale?: number;
79
+ }
80
+ /** Bounces off the first surface contacted. */
81
+ export interface ShotRicochet {
82
+ /** Bounces the shot resolves before it stops (default `0`). */
83
+ count: number;
84
+ /** Widest incoming angle, degrees, that still bounces (default `45`). */
85
+ angleDeg?: number;
86
+ /** Damage multiplier after the first bounce (default `0.5`). */
87
+ damageScale?: number;
88
+ }
89
+ /** One damage channel a shot applies on contact. */
90
+ export interface ShotDamage {
91
+ /** Damage type id in the caller's damage table. */
92
+ type: string;
93
+ /** Damage before hit zone, table, armor, falloff and penetration scale. */
94
+ base: number;
95
+ /** Status ids the hit tries to apply; the damage runtime owns chance and effect. */
96
+ statuses?: readonly string[];
97
+ }
98
+ /** What a shot does on contact: damage, falloff, penetration, splash, ricochet, statuses. */
99
+ export interface ShotContact {
100
+ /** Direct damage. Omit for a shot that only splashes. */
101
+ damage?: ShotDamage;
102
+ /** Damage over distance, on both deliveries. Omit for no falloff. */
103
+ falloff?: ShotFalloff;
104
+ /** Extra targets the shot passes through. */
105
+ penetration?: ShotPenetration;
106
+ /** Area damage around the contact point. */
107
+ splash?: ShotSplash;
108
+ /** Bounces off the first surface contacted. */
109
+ ricochet?: ShotRicochet;
110
+ }
111
+ /**
112
+ * A shot as plain data: how it travels and what it does on arrival. Every field but `delivery.kind`
113
+ * is optional with a documented default, so a plain gun is three lines.
114
+ */
115
+ export interface ShotSpec {
116
+ delivery: ShotDelivery;
117
+ contact?: ShotContact;
118
+ }
119
+ /** A spec with every default filled in. Read `mode` and the branch it names, never the raw spec. */
120
+ export interface ResolvedShotSpec {
121
+ /** The one decision a firing path makes: resolve now, or simulate a body over time. */
122
+ mode: "trace" | "body";
123
+ /** Furthest the shot resolves, m. */
124
+ range: number;
125
+ /** Present when `mode` is `"trace"`. */
126
+ trace: {
127
+ range: number;
128
+ } | null;
129
+ /** Present when `mode` is `"body"`. */
130
+ body: {
131
+ speed: number;
132
+ gravity: number;
133
+ drag: number;
134
+ lifetime: number;
135
+ radius: number;
136
+ fuse: number | "impact";
137
+ homing: number;
138
+ } | null;
139
+ contact: {
140
+ damage: {
141
+ type: string;
142
+ base: number;
143
+ statuses: readonly string[];
144
+ } | null;
145
+ falloff: {
146
+ start: number;
147
+ end: number;
148
+ endScale: number;
149
+ } | null;
150
+ penetration: {
151
+ count: number;
152
+ keepDamage: boolean;
153
+ };
154
+ splash: {
155
+ radius: number;
156
+ falloff: "linear" | "none";
157
+ damageScale: number;
158
+ } | null;
159
+ ricochet: {
160
+ count: number;
161
+ angleDeg: number;
162
+ damageScale: number;
163
+ } | null;
164
+ };
165
+ }
166
+ /** What one contact does: whether the shot still carries damage, at what scale, and whether it goes on. */
167
+ export interface ShotHitOutcome {
168
+ /** `false` once every penetration slot is spent; the shot stops on this target. */
169
+ applies: boolean;
170
+ /** Multiplier on the damage base for this target. */
171
+ scale: number;
172
+ }
173
+ /**
174
+ * Fill every default and return the one shape firing paths act on. This is the only place that
175
+ * decides how a shot travels, so a delivery can never be guessed from a damage or splash field.
176
+ *
177
+ * @capability shot-spec resolve one shot description into explicit travel and contact behaviour
178
+ */
179
+ export declare function resolveShotSpec(spec: ShotSpec): ResolvedShotSpec;
180
+ /** Number of seconds the body flies before its fuse ends the flight, or `null` when it flies to contact. */
181
+ export declare function shotFuseSeconds(shot: ResolvedShotSpec): number | null;
182
+ /** Damage multiplier from falloff at `distance`; `1` with no falloff. */
183
+ export declare function shotFalloffAt(shot: ResolvedShotSpec, distance: number): number;
184
+ /**
185
+ * What a contact at `distance` does to the `hitIndex`-th target it meets, counting the first as `0`.
186
+ * A trace or body stops once its penetration slots are spent, so penetration is a number, not a branch.
187
+ */
188
+ export declare function shotHitOutcome(shot: ResolvedShotSpec, distance: number, hitIndex: number): ShotHitOutcome;
189
+ /** Straight-line flight of a body, for a caller that only needs where it starts and how fast. */
190
+ export interface ShotLaunch {
191
+ origin: ShotVec3;
192
+ velocity: ShotVec3;
193
+ gravity: number;
194
+ }
195
+ /** Where a body lands and how long it took, in the same numbers the runtime simulates. */
196
+ export interface ShotArc extends ShotLaunch {
197
+ /** Seconds of flight, capped by the fuse and ended early by the ground. */
198
+ flightTime: number;
199
+ /** Highest point above the launch, m. */
200
+ apex: number;
201
+ /** Contact point; `y` is clamped to the ground plane. */
202
+ landing: [number, number, number];
203
+ }
204
+ /**
205
+ * Solve the path of a body from a launch pose. A trace has no path and fails loudly instead of
206
+ * returning a straight line a caller would mistake for an arc.
207
+ */
208
+ export declare function shotArc(shot: ResolvedShotSpec, origin: ShotVec3, direction: ShotVec3): ShotArc;
209
+ /** Reads a numeric stat by name; the shape every item registry already exposes. */
210
+ export type ShotStatReader = (stat: string) => number | null;
211
+ /**
212
+ * Project an existing {@link WeaponDefinition} onto a spec, so a weapon authored as a definition and
213
+ * one authored as a spec reach the same firing paths.
214
+ */
215
+ export declare function shotSpecFromWeapon(definition: {
216
+ delivery: {
217
+ kind: "hitscan";
218
+ maxRange: number;
219
+ } | {
220
+ kind: "projectile";
221
+ speed: number;
222
+ gravity?: number;
223
+ drag?: number;
224
+ lifetime: number;
225
+ maxRange?: number;
226
+ };
227
+ damage: {
228
+ type: string;
229
+ base: number;
230
+ statuses?: readonly string[];
231
+ };
232
+ falloff?: ShotFalloff;
233
+ }): ShotSpec;
234
+ /**
235
+ * Adapt the numeric stat keys older item data carries to a spec. This is the only place those keys
236
+ * are read, and delivery is decided by the keys that describe travel (`projectile.*`) alone: a
237
+ * splash radius or a damage number never turns a trace into a body.
238
+ */
239
+ export declare function shotSpecFromStats(read: ShotStatReader): ShotSpec;
@@ -0,0 +1,216 @@
1
+ /**
2
+ * One description of how a shot travels and what it does when it arrives, plus the single resolver
3
+ * that turns that description into behaviour. A weapon is data: a rifle, a grenade, a mortar and an
4
+ * arrow differ by fields here, never by a code path, a weapon id or a classifier that reads one stat
5
+ * as proof of another.
6
+ */
7
+ /** Standard gravity used to scale `ShotBody.gravity`, m/s². */
8
+ export const SHOT_GRAVITY = 9.8;
9
+ /** Furthest a shot resolves when the delivery names no range, m. */
10
+ export const DEFAULT_SHOT_RANGE = 100;
11
+ /** Launch speed of a body that names none, m/s. */
12
+ export const DEFAULT_SHOT_SPEED = 15;
13
+ /** Hit-sphere radius of a body that names none, m. */
14
+ export const DEFAULT_SHOT_RADIUS = 0.5;
15
+ /** Most extra hits a shot may pass through when it names no penetration. */
16
+ export const DEFAULT_SHOT_PENETRATION = 0;
17
+ /** Damage multiplier left at the end of falloff when a shot names no end scale. */
18
+ export const DEFAULT_FALLOFF_END_SCALE = 0.5;
19
+ function finite(value, field, min) {
20
+ if (!Number.isFinite(value) || value < min)
21
+ throw new RangeError(`Shot spec field "${field}" must be a finite number >= ${min}; got ${String(value)}`);
22
+ return value;
23
+ }
24
+ /**
25
+ * Fill every default and return the one shape firing paths act on. This is the only place that
26
+ * decides how a shot travels, so a delivery can never be guessed from a damage or splash field.
27
+ *
28
+ * @capability shot-spec resolve one shot description into explicit travel and contact behaviour
29
+ */
30
+ export function resolveShotSpec(spec) {
31
+ const delivery = spec.delivery;
32
+ const contact = spec.contact ?? {};
33
+ const falloff = contact.falloff;
34
+ const penetration = contact.penetration;
35
+ const splash = contact.splash;
36
+ const ricochet = contact.ricochet;
37
+ const damage = contact.damage;
38
+ const resolvedContact = {
39
+ damage: damage === undefined
40
+ ? null
41
+ : {
42
+ type: damage.type,
43
+ base: finite(damage.base, "contact.damage.base", 0),
44
+ statuses: damage.statuses === undefined ? [] : [...damage.statuses],
45
+ },
46
+ falloff: falloff === undefined
47
+ ? null
48
+ : {
49
+ start: finite(falloff.start, "contact.falloff.start", 0),
50
+ end: finite(falloff.end, "contact.falloff.end", 0),
51
+ endScale: finite(falloff.endScale, "contact.falloff.endScale", 0),
52
+ },
53
+ penetration: {
54
+ count: penetration === undefined ? DEFAULT_SHOT_PENETRATION : Math.floor(finite(penetration.count, "contact.penetration.count", 0)),
55
+ keepDamage: penetration?.keepDamage ?? true,
56
+ },
57
+ splash: splash === undefined
58
+ ? null
59
+ : {
60
+ radius: finite(splash.radius, "contact.splash.radius", 0),
61
+ falloff: splash.falloff ?? "linear",
62
+ damageScale: splash.damageScale === undefined ? 1 : finite(splash.damageScale, "contact.splash.damageScale", 0),
63
+ },
64
+ ricochet: ricochet === undefined
65
+ ? null
66
+ : {
67
+ count: Math.floor(finite(ricochet.count, "contact.ricochet.count", 0)),
68
+ angleDeg: ricochet.angleDeg === undefined ? 45 : finite(ricochet.angleDeg, "contact.ricochet.angleDeg", 0),
69
+ damageScale: ricochet.damageScale === undefined ? 0.5 : finite(ricochet.damageScale, "contact.ricochet.damageScale", 0),
70
+ },
71
+ };
72
+ if (delivery.kind === "trace") {
73
+ const range = delivery.range === undefined ? DEFAULT_SHOT_RANGE : finite(delivery.range, "delivery.range", Number.MIN_VALUE);
74
+ return { mode: "trace", range, trace: { range }, body: null, contact: resolvedContact };
75
+ }
76
+ const speed = delivery.speed === undefined ? DEFAULT_SHOT_SPEED : finite(delivery.speed, "delivery.speed", Number.MIN_VALUE);
77
+ const gravity = delivery.gravity === undefined ? 0 : finite(delivery.gravity, "delivery.gravity", 0);
78
+ const drag = delivery.drag === undefined ? 0 : finite(delivery.drag, "delivery.drag", 0);
79
+ const range = delivery.range === undefined ? DEFAULT_SHOT_RANGE : finite(delivery.range, "delivery.range", Number.MIN_VALUE);
80
+ const lifetime = delivery.lifetime === undefined ? range / speed : finite(delivery.lifetime, "delivery.lifetime", Number.MIN_VALUE);
81
+ const radius = delivery.radius === undefined ? DEFAULT_SHOT_RADIUS : finite(delivery.radius, "delivery.radius", 0);
82
+ const homing = delivery.homing === undefined ? 0 : finite(delivery.homing, "delivery.homing", 0);
83
+ const fuse = delivery.fuse === undefined
84
+ ? "impact"
85
+ : delivery.fuse === "impact"
86
+ ? "impact"
87
+ : finite(delivery.fuse, "delivery.fuse", 0);
88
+ return {
89
+ mode: "body",
90
+ range,
91
+ trace: null,
92
+ body: { speed, gravity, drag, lifetime, radius, fuse, homing },
93
+ contact: resolvedContact,
94
+ };
95
+ }
96
+ /** Number of seconds the body flies before its fuse ends the flight, or `null` when it flies to contact. */
97
+ export function shotFuseSeconds(shot) {
98
+ return shot.body === null || shot.body.fuse === "impact" ? null : shot.body.fuse;
99
+ }
100
+ /** Damage multiplier from falloff at `distance`; `1` with no falloff. */
101
+ export function shotFalloffAt(shot, distance) {
102
+ const falloff = shot.contact.falloff;
103
+ if (falloff === null || distance <= falloff.start)
104
+ return 1;
105
+ if (falloff.end <= falloff.start)
106
+ return falloff.endScale;
107
+ const t = Math.min(1, (distance - falloff.start) / (falloff.end - falloff.start));
108
+ return 1 + (falloff.endScale - 1) * t;
109
+ }
110
+ /**
111
+ * What a contact at `distance` does to the `hitIndex`-th target it meets, counting the first as `0`.
112
+ * A trace or body stops once its penetration slots are spent, so penetration is a number, not a branch.
113
+ */
114
+ export function shotHitOutcome(shot, distance, hitIndex) {
115
+ const index = Math.max(0, Math.floor(hitIndex));
116
+ if (index > shot.contact.penetration.count)
117
+ return { applies: false, scale: 0 };
118
+ const survived = index === 0 || shot.contact.penetration.keepDamage ? 1 : 0;
119
+ return { applies: true, scale: shotFalloffAt(shot, distance) * survived };
120
+ }
121
+ /**
122
+ * Solve the path of a body from a launch pose. A trace has no path and fails loudly instead of
123
+ * returning a straight line a caller would mistake for an arc.
124
+ */
125
+ export function shotArc(shot, origin, direction) {
126
+ if (shot.body === null)
127
+ throw new RangeError('Shot spec field "delivery.kind" must be "body" to have a trajectory; a trace resolves instantly');
128
+ const { speed, gravity, fuse } = shot.body;
129
+ const acceleration = SHOT_GRAVITY * gravity;
130
+ const vertical = direction[1] * speed;
131
+ const fuseSeconds = fuse === "impact" ? null : fuse;
132
+ const flightCap = fuseSeconds ?? shot.range / speed;
133
+ const toGround = acceleration > 0
134
+ ? (vertical + Math.sqrt(vertical * vertical + 2 * acceleration * Math.max(0, origin[1]))) / acceleration
135
+ : flightCap;
136
+ const flightTime = Math.min(toGround, flightCap);
137
+ const settledY = Math.max(0, origin[1] + vertical * flightTime - 0.5 * acceleration * flightTime * flightTime);
138
+ return {
139
+ origin,
140
+ velocity: [direction[0] * speed, vertical, direction[2] * speed],
141
+ gravity: acceleration,
142
+ flightTime,
143
+ apex: acceleration > 0 ? Math.max(0, vertical * vertical / (2 * acceleration)) : 0,
144
+ landing: [
145
+ origin[0] + direction[0] * speed * flightTime,
146
+ settledY,
147
+ origin[2] + direction[2] * speed * flightTime,
148
+ ],
149
+ };
150
+ }
151
+ /**
152
+ * Project an existing {@link WeaponDefinition} onto a spec, so a weapon authored as a definition and
153
+ * one authored as a spec reach the same firing paths.
154
+ */
155
+ export function shotSpecFromWeapon(definition) {
156
+ const delivery = definition.delivery.kind === "hitscan"
157
+ ? { kind: "trace", range: definition.delivery.maxRange }
158
+ : {
159
+ kind: "body",
160
+ speed: definition.delivery.speed,
161
+ ...(definition.delivery.gravity === undefined ? {} : { gravity: definition.delivery.gravity / SHOT_GRAVITY }),
162
+ ...(definition.delivery.drag === undefined ? {} : { drag: definition.delivery.drag }),
163
+ lifetime: definition.delivery.lifetime,
164
+ ...(definition.delivery.maxRange === undefined ? {} : { range: definition.delivery.maxRange }),
165
+ };
166
+ return {
167
+ delivery,
168
+ contact: {
169
+ damage: {
170
+ type: definition.damage.type,
171
+ base: definition.damage.base,
172
+ ...(definition.damage.statuses === undefined ? {} : { statuses: definition.damage.statuses }),
173
+ },
174
+ ...(definition.falloff === undefined ? {} : { falloff: definition.falloff }),
175
+ },
176
+ };
177
+ }
178
+ /**
179
+ * Adapt the numeric stat keys older item data carries to a spec. This is the only place those keys
180
+ * are read, and delivery is decided by the keys that describe travel (`projectile.*`) alone: a
181
+ * splash radius or a damage number never turns a trace into a body.
182
+ */
183
+ export function shotSpecFromStats(read) {
184
+ const speed = read("projectile.speed");
185
+ const fuseTime = read("projectile.fuseTime");
186
+ const gravity = read("projectile.gravity");
187
+ const lifetime = read("projectile.lifetime");
188
+ const radius = read("projectile.radius");
189
+ const drag = read("projectile.drag");
190
+ const homing = read("projectile.homing");
191
+ const range = read("range");
192
+ const splashRadius = read("explosion.radius");
193
+ const damage = read("damage");
194
+ const penetration = read("penetration");
195
+ const delivery = speed === null && fuseTime === null && gravity === null && lifetime === null && radius === null && drag === null && homing === null
196
+ ? { kind: "trace", ...(range === null ? {} : { range }) }
197
+ : {
198
+ kind: "body",
199
+ ...(range === null ? {} : { range }),
200
+ ...(speed === null ? {} : { speed }),
201
+ ...(gravity === null ? {} : { gravity }),
202
+ ...(drag === null ? {} : { drag }),
203
+ ...(lifetime === null ? {} : { lifetime }),
204
+ ...(radius === null ? {} : { radius }),
205
+ ...(homing === null ? {} : { homing }),
206
+ ...(fuseTime === null ? {} : { fuse: fuseTime }),
207
+ };
208
+ return {
209
+ delivery,
210
+ contact: {
211
+ ...(damage === null ? {} : { damage: { type: "damage", base: damage } }),
212
+ ...(splashRadius === null ? {} : { splash: { radius: splashRadius } }),
213
+ ...(penetration === null ? {} : { penetration: { count: penetration } }),
214
+ },
215
+ };
216
+ }