@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.
- package/CHANGELOG.md +116 -0
- package/dist/agents/system.d.ts +135 -0
- package/dist/agents/system.js +195 -0
- package/dist/combat/projectiles.d.ts +6 -2
- package/dist/combat/projectiles.js +55 -52
- package/dist/combat/shotSpec.d.ts +239 -0
- package/dist/combat/shotSpec.js +216 -0
- package/dist/combat/weaponSystem.js +26 -21
- package/dist/combat.d.ts +1 -0
- package/dist/combat.js +1 -0
- package/dist/game/playableGame.d.ts +9 -0
- package/dist/meta/changelog.d.ts +1 -1
- package/dist/meta/changelog.js +77 -1
- package/dist/movement/characterController.d.ts +5 -0
- package/dist/movement/characterController.js +8 -1
- package/dist/movement/movementModel.js +4 -1
- package/dist/movement/playerMovement.js +4 -0
- package/dist/physics/physicsBackendConformance.js +17 -0
- package/dist/physics/physicsWorld.d.ts +7 -0
- package/dist/physics/physicsWorld.js +27 -6
- package/dist/run/runLoop.d.ts +44 -1
- package/dist/run/runLoop.js +66 -5
- package/dist/world/features.d.ts +15 -1
- package/dist/world/features.js +51 -2
- package/dist/world/lod.d.ts +8 -0
- package/dist/world/lod.js +13 -5
- package/dist/world/modelLod.d.ts +44 -0
- package/dist/world/modelLod.js +75 -0
- package/dist/world.d.ts +2 -1
- package/dist/world.js +2 -1
- package/package.json +1 -1
- package/skills/jgengine/api.md +2 -2
- package/skills/jgengine-combat/SKILL.md +7 -0
- package/skills/jgengine-combat/api.md +61 -1
- package/skills/jgengine-combat/capabilities.md +4 -0
- package/skills/jgengine-combat/reference.md +1 -0
- package/skills/jgengine-gameplay/api.md +35 -0
- package/skills/jgengine-gameplay/capabilities.md +2 -0
- 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
|
|
260
|
-
return itemStat(via,
|
|
256
|
+
function resolveShotSpecOf(input) {
|
|
257
|
+
return resolveShotSpec(input.spec ?? shotSpecFromStats((stat) => itemStat(input.via, stat)));
|
|
261
258
|
}
|
|
262
|
-
function
|
|
263
|
-
|
|
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
|
|
270
|
-
|
|
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
|
|
305
|
-
const
|
|
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 =
|
|
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 ||
|
|
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,
|
|
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
|
|
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 =
|
|
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:
|
|
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
|
|
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 (
|
|
521
|
-
const at = ballisticSettlePoint(input);
|
|
522
|
-
const
|
|
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:
|
|
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
|
-
|
|
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
|
+
}
|