@jgengine/core 0.36.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 +84 -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 +58 -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 +6 -0
- package/skills/jgengine-gameplay/capabilities.md +1 -0
- package/skills/jgengine-world/api.md +17 -0
package/CHANGELOG.md
CHANGED
|
@@ -26,6 +26,90 @@ At publish, rename this heading to the new version and mirror the entries into
|
|
|
26
26
|
`packages/core/src/meta/changelog.ts` (the typed `CHANGELOG` export).
|
|
27
27
|
-->
|
|
28
28
|
|
|
29
|
+
## 0.37.0
|
|
30
|
+
|
|
31
|
+
### Migrate
|
|
32
|
+
|
|
33
|
+
- **Bump lockstep SDK packages to `^0.37.0`:** `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}`. CLI `jgengine` is `0.34.0`; `@jgengine/github` is `0.24.0`.
|
|
34
|
+
|
|
35
|
+
### Added
|
|
36
|
+
|
|
37
|
+
- `bun run check:script-help` walks every `package.json` script that points into `scripts/`, runs each with `--help`, and fails when one errors, hangs over its own timeout, prints nothing, or when a script names a file the repo does not have. Script files that predate `--help` are listed in `scripts/script-help-baseline.json` and reported, so a new one cannot arrive undocumented.
|
|
38
|
+
- `land`, `lane:close`, `lane:audit`, `ci-status` and `verify:fast` answer `--help` with their purpose, the flags they actually accept, one example and their exit codes, through one shared `scripts/script-help.ts`. No install, network or browser needed.
|
|
39
|
+
- `RunLoop.present(count)` and `RunLoop.choose(id)` (`@jgengine/core/run/runLoop`) draw several next-run changes for a choice screen and adopt the pick, so a game that offers three relics does not compose `game/runDraft` beside the loop. `present` honours the same `after` gates and weights as the single draw and never repeats the change just played while alternatives remain; `choose` opens the next run around the pick and refuses an id the loop's table does not carry.
|
|
40
|
+
- `installRunProbe` (`@jgengine/core/run/runLoop`) publishes `window.__jgengineRun` with the run in progress, what it carries, what it banked or lost, its best bank and the change it offers, so a capture or devtools read proves the loop is live instead of only imported.
|
|
41
|
+
- `bun run find <pattern> [--in <dir>] [--kind ts,tsx] [--files]` answers a repository-wide search in about a second: ripgrep reads the tree once, honours `.gitignore` and never enters `node_modules`, `.git`, `.claude/worktrees` or build output. `--files` lists names by `--glob`, which is what a tree listing was for. Eight of the ten hard timeouts in three hours of lane transcripts were a recursive directory listing over this checkout (160 s, 3.0M files) against a 120 s limit, while every other call's p90 was 10.5 s.
|
|
42
|
+
- `bun run check-recursive-walk` fails a `scripts/**` file or a lane document (`AGENTS.md`, `README.md`, every skill under `.claude/skills`) that ships a recursive directory listing, a `find` walk from a root, or `git ls-files` piped into per-file work; a required one carries `// walk-ok: <reason>`. It runs in CI through the scripts test shard.
|
|
43
|
+
- `ModelConfig.variants` (`levels: [{ url, maxDistance? }]`, `hysteresis?`) declares ordered mesh detail levels for a model. The instanced prop path switches level per instance by distance to the camera, so near and far placements of one species keep their own mesh inside one batch cell. A model without `variants` draws exactly as before.
|
|
44
|
+
- `createModelVariantSelector` (`@jgengine/core/world/modelLod`) returns the level index for an id at a distance, carrying hysteresis across each boundary (`DEFAULT_VARIANT_HYSTERESIS`, 6 m). `lodBandIndex` is now exported from `@jgengine/core/world/lod` and both the LOD scheduler and the selector read distances through it, so simulation throttling and mesh detail cannot disagree.
|
|
45
|
+
- `syncStaticObjectSource` takes an optional `accept` predicate so one batch can split its instances between two instanced draws.
|
|
46
|
+
- `InstancedScatter` now honours `ModelConfig.variants`: each species-chunk batch asks the same `createModelVariantSelector` the static prop path uses for every placement's level, and each level draws its own instanced meshes claiming only its placements. A chunk that holds near and far palms of one species draws both levels from the one batch; a model without `variants` draws exactly as before.
|
|
47
|
+
- `writeScatterVariantLevel` (`@jgengine/shell` internal, `scatter/scatterVariants`) is the per-frame pass: it writes one level's composed matrices into a reserved slot array and returns `count`, allocating nothing per frame, and takes an optional `keep` predicate so a tier-distance or occlusion cull composes with level selection in one loop.
|
|
48
|
+
- `terrain({ cellSize })` states ground density as world units per quad instead of a segment count, and derives the per-axis grid from `bounds` — the same `cellSize` on a bigger world spends more cells rather than stretching one count across it. `segments` still works and also takes `[x, z]` for a non-square grid. Setting both fails naming `cellSize`; a cell size over the 1,000,000-vertex ceiling fails with the cell/vertex numbers and the smallest `cellSize` that fits.
|
|
49
|
+
- `shoot`, `drive` and `perf` take `--quality <low|medium|high|ultra>` and pin that graphics tier on the game page (`?quality=<tier>`). Each run reads the tier back from the shell (`data-jg-quality`) and fails when it is missing or different, so a capture or perf record always names the tier it measured. Every capture summary prints `tier <tier>`; `perf` echoes `perf: tier <tier>` and records it in `quality`.
|
|
50
|
+
- The old `--quality draft|full` iteration mode is now the `--draft` switch (low tier, half viewport, `-draft` file names), on `shoot`, `drive` and `perf`; `perf:gate` takes `--draft` too. An unknown `--quality` value fails naming all four tiers.
|
|
51
|
+
- `attributeTriangles` (`@jgengine/shell/render/triangleAttribution`) sums a scene's triangles by model and geometry with instance counts, and the devtools render probe publishes it as `probes["render/triangles"]` and the `render/triangles` system probe, so `debug_snapshot` names the object behind a heavy frame instead of only counting it. Harbor Heat read 1,021,671 triangles from 840 objects: the 640 m terrain at 300 segments (180,000), the `TwistedTree_2` foliage species (137,010 over 15 trees) and 93 instances of a 1,280-triangle NPC model.
|
|
52
|
+
- `ShotSpec` and `resolveShotSpec` (`@jgengine/core/combat/shotSpec`) describe how a shot travels and what it does on contact as plain data: an instant trace or a travelling body with speed, gravity, drag, fuse, lifetime, radius and homing, plus contact damage, falloff, penetration, splash, ricochet and statuses. Every field but `delivery.kind` is optional with a documented default, so a plain gun is three lines and a grenade, mortar, arrow or hitscan explosive is the same shape with different numbers. `shotArc`, `shotFalloffAt` and `shotHitOutcome` are the one place trajectory, falloff and penetration are solved; `shotSpecFromStats` and `shotSpecFromWeapon` adapt existing item stats and weapon definitions once, and a splash radius or damage number never decides that a shot travels.
|
|
53
|
+
- `ProjectileShotInput.spec` lets a caller state the spec directly; the projectile and weapon systems read the resolved spec at spawn, fuse, hitscan settle and damage application instead of classifying a shot.
|
|
54
|
+
- A trace that carries splash now resolves its area damage at the contact point on the same tick, which is the #2272 hitscan-explosive configuration.
|
|
55
|
+
- `bun run check:path-portability` fails a test that asserts one platform's path separator: a `\\` literal under `toContain`/`toEqual`/`toBe`/`includes`/`endsWith`, a bare `toContain("/")`, a POSIX root handed to `join`/`resolve`, or a literal compared with an OS path result. It reports the file, the line and the `join(...)` that replaces it. A genuinely single-platform assertion is named in `scripts/path-portability-allow.json` with a reason, printed on every run. This is the class behind #2301 (`toContain("proofs\\flows")`) and the `agent-worktree.test.ts` assertions that only pass on one OS.
|
|
56
|
+
|
|
57
|
+
Verification:
|
|
58
|
+
- `bun test scripts/check-path-portability.test.ts` - pass, including the pre-fix `proofs\\flows` line failing through the gate and the fixed one passing
|
|
59
|
+
- `bun run check:path-portability` - ok, 3 allowlisted with reasons
|
|
60
|
+
- `bun test scripts/agent-worktree.test.ts scripts/shot-metrics.test.ts scripts/lane-audit.test.ts` - pass
|
|
61
|
+
|
|
62
|
+
### Changed
|
|
63
|
+
|
|
64
|
+
- Merge pull request #2309 from Noisemaker111/agent/lane-path-portability
|
|
65
|
+
- `bun run land` hands the PR over instead of waiting for it: it pushes, opens or updates the PR, runs `--verify`, arms auto-merge and exits in seconds, printing the PR URL and `bun run ci:status --pr <n>`. A trivial change spent 5m09s inside `land`'s own poll loop, and one night's lanes spent 449 minutes watching CI. `--watch` still lands the old way and says it is deprecated.
|
|
66
|
+
- `bun run land:queue` is the only process that waits for CI. It lands ready PRs into `beta` in readiness order (auto-merge armed, else the `land:queue` label), rebases a branch whose base moved in a throwaway worktree so a moved base costs one CI run instead of two, waits once per PR and prints one line per PR with its wait time and run id. A lane hands off by running `bun run land`; it never rebases, labels or waits.
|
|
67
|
+
- `bun run lane:close` ends a lane in one command. It stages the worktree's tracked and new files (ignored ones such as `.scratch/` stay out), takes the commit message from the branch's own `changes/` fragment, writes that fragment from the commit summary when the diff has none, pushes, opens or updates the PR with the fragment as the body, arms auto-merge, and prints the PR URL and `bun run ci:status --pr <n>`. One lane's last 20 calls were 13 of commit, push, rebase and PR choreography; this replaces them with one. `bun run land --commit` does the same for a lane that skips `lane:close`, and a clean tree lands as it did before.
|
|
68
|
+
- `bun run bg:run -- <cmd>` starts a long command and returns its id at once; `bun run bg:await <id>` blocks on that task's exit file as an event, then prints its whole output and its exit code. It replaces waiting out a `.out` file with guessed intervals — one lane spent 10 minutes of `Start-Sleep` calls watching a capture it had already started. A runner that dies is answered rather than waited on forever.
|
|
69
|
+
- `--commit` prints every path it will commit before committing, and refuses the ones a lane did not author: a scratch artifact, a path git ignores, a lockfile staged without its `package.json` manifest, and a new `changes/` fragment whose name is not part of the branch's name. `--allow <path>` keeps one refused file on purpose. The first replay committed a test-written `changes/agent-lane-x.md` and this checkout's shared-install `bun.lock` drift; both are refused now, with the file list printed either way.
|
|
70
|
+
- A capture at a non-default tier writes its tier into the file name (`-low`, `-medium`, `-ultra`), so an ultra shot cannot overwrite the high one it was compared against.
|
|
71
|
+
|
|
72
|
+
Root cause: `--quality` carried the iteration preset, not the graphics tier, so a culling lane that asked for ultra got the game's default `high` on both sides of its comparison and could not prove a tier claim.
|
|
73
|
+
|
|
74
|
+
### Fixed
|
|
75
|
+
|
|
76
|
+
- `verify:fast`, `land` and `lane:close` refuse a checkout with no `node_modules` up front: they print the missing marker, the exact fix (`bun install` in this checkout, or `bun run agent:bootstrap`) and exit 78, instead of a `command not found` or a module error from a test file. A genuine failure still exits on its own code.
|
|
77
|
+
- `lane:audit --cwd` finds the lane whose own tool calls name that worktree, and prints `attributed ses_… (N references to …)`. Ambiguous ties name their candidates and pick the worst-waiting one; no candidate at all exits non-zero naming the path it searched, so `--fail-on-waiting` has a lane to judge instead of an empty table.
|
|
78
|
+
- `land` builds the PR body only from `changes/` fragments its own `git diff origin/beta...HEAD` adds, prints a line for every fragment it ignores, and falls back to the branch's commit subjects.
|
|
79
|
+
|
|
80
|
+
Root cause: a subagent session stores the directory of the agent that spawned it, so a lane was invisible to its own audit; `changes/` is shared by every lane on the branch, so "newest fragment" was free to be a stranger's note.
|
|
81
|
+
|
|
82
|
+
Before: `lane:audit --cwd …\lane-wait-audit` exited 1 with "no child sessions", and a body built from a stray `changes/` fragment claimed work the diff did not contain.
|
|
83
|
+
After: `lane:audit: attributed ses_e94f3aeedffe2CCMsTlrzgvez5 (136 references to …\lane-wait-audit)`, and `land: ignoring changes/scratch-leftover.md: this branch's diff against origin/beta does not add it`.
|
|
84
|
+
|
|
85
|
+
Verification:
|
|
86
|
+
- `bun test scripts/lane-audit.test.ts scripts/lane-close.test.ts scripts/land.test.ts` — pass
|
|
87
|
+
- `bun run verify:fast` — pass
|
|
88
|
+
- `bun run lane:audit --cwd <the real lane worktree> --hours 6` — attributed that lane by 136 references
|
|
89
|
+
- `lane:audit` classifies a call by what it runs, not by the text inside its arguments: `rg`, `grep`, `Select-String`, `findstr`, `Get-Content`, `cat`, `echo`, `Write-Output` and `git` (any subcommand) never count as a sleep or a CI watch, and quoted arguments are stripped before matching. A lane that greps for `Start-Sleep` or writes a PR body about `gh run watch` no longer records waiting it never did.
|
|
90
|
+
- Catches the waits it used to miss: `Start-Sleep` under `powershell -Command`/`pwsh -c`/`bash -lc`, `cmd /c "timeout /t"`, `Thread.sleep`, `Atomics.wait`, `Bun.sleep`, and `gh pr checks` inside a `for`/`while` loop.
|
|
91
|
+
|
|
92
|
+
Over the last 40 hours (209 lanes): sleep 1066.6 -> 1068.7 minutes, CI watch 421.8 -> 421.3 minutes. 15 calls stopped counting as waits, one real `Start-Sleep -Seconds 150` started counting.
|
|
93
|
+
|
|
94
|
+
Verification:
|
|
95
|
+
- `bun test scripts/lane-audit.test.ts scripts/lane-close.test.ts scripts/land.test.ts` — pass
|
|
96
|
+
- `bun run lane:audit --json --hours 40` before and after, compared per command
|
|
97
|
+
- `lane:close` reads `calls` as the count it is, takes waits from a new `waits` list in `lane:audit --json`, and refuses an unfamiliar audit shape with `the audit output shape changed: <field> is <type>, expected <type> (keys seen: …)` instead of a TypeError. Exit codes unchanged: a sleep or CI watch is 2, `--waive` records the reason, no percentage thresholds.
|
|
98
|
+
|
|
99
|
+
Root cause: `lane:audit --json` had always reported `calls` as a number, while `lane:close` held it as a list of call objects; nothing read the real document until lane attribution started returning a lane, so the gate that judges a finishing lane crashed on the first lane that had one.
|
|
100
|
+
|
|
101
|
+
Before: `lane:close` landed the PR, then died with `source.filter is not a function` at `waitingGate`.
|
|
102
|
+
After: the same captured output is judged, the waits are listed, and the lane gets exit 2 with a report skeleton.
|
|
103
|
+
|
|
104
|
+
Verification:
|
|
105
|
+
- `bun test scripts/lane-audit.test.ts scripts/lane-close.test.ts` — pass, including the captured-output fixture
|
|
106
|
+
- `bun run verify:fast` — pass
|
|
107
|
+
- `shoot --url ... --look` now sends the aim to the page it captures, so a lane can measure a branch on a dev runner it started itself. The read-back stays: a page that reports no applied aim still fails the capture instead of returning a plausible wrong view, and a page that applies `--param look=` itself can report it on `data-jg-look` or `window.__jgLook`. `--site --look` is refused at the command line, naming the `--url` form that works.
|
|
108
|
+
- `check:no-blocking-wait` judges code that waits, not prose that describes waiting. In `packages/<pkg>/src/meta` (the changelog mirror and its neighbours) single- and double-quoted text and comments are masked before matching, so a release note reading "one lane spent 10 minutes of `Start-Sleep`" no longer fails the gate; a command in a template literal still fails, and every other `scripts/**` path keeps the strict rule where a quoted command is a real finding. The 0.37.0 release cut failed `types` on `packages/core/src/meta/changelog.ts:46,55,56` with exactly that false positive.
|
|
109
|
+
- `@jgengine/rapier` now accumulates `step(dt)` into its fixed substeps instead of stepping once per call with `fixedDt` as the timestep. A 1 s move covered 0.5 s of simulated time at 30 fps and 2.4 s at 144 fps; it now covers 1 s at either rate, matching `PhysicsWorld` and the `PhysicsBackend` contract.
|
|
110
|
+
- `PhysicsWorld` bounds-floor friction is Coulomb (`friction × g × dt`) like contact friction, not a 60 Hz multiplicative brake: a crate sliding at 4 m/s travelled 0.165 m on the world floor and 0.970 m on a floor collider, and now travels 0.967 m on both.
|
|
111
|
+
- Player and character-controller gravity integrate trapezoidally, so a jump reaches `v²/2g` at any frame rate: the default jump peaked at 0.933 m at 30 fps and 1.026 m at 144 fps, and now at 1.050 m for both.
|
|
112
|
+
|
|
29
113
|
## 0.36.0
|
|
30
114
|
|
|
31
115
|
### Migrate
|
|
@@ -4,10 +4,13 @@ import type { EntityColliderSet } from "../scene/colliders.js";
|
|
|
4
4
|
import { type SceneRaycastApi } from "../scene/sceneRaycast.js";
|
|
5
5
|
import type { Aim } from "../scene/spatial.js";
|
|
6
6
|
import type { CombatSpatialDeps, EffectResult, EffectSystem, EffectVia } from "./effects.js";
|
|
7
|
+
import { type ShotSpec } from "./shotSpec.js";
|
|
7
8
|
import { type ShotOriginPolicy } from "./shotOrigin.js";
|
|
8
9
|
export interface ProjectileShotInput {
|
|
9
10
|
from: string;
|
|
10
11
|
via: EffectVia;
|
|
12
|
+
/** How this shot travels and what it does on contact. Omitted means the shot's numeric stats are adapted once. */
|
|
13
|
+
spec?: ShotSpec;
|
|
11
14
|
aim: Aim;
|
|
12
15
|
effect: string;
|
|
13
16
|
/** Defaults to `{ kind: "converge" }` — explicit rays pass through; angular aim converges from the muzzle to the sightline. */
|
|
@@ -121,8 +124,9 @@ export interface ProjectileSettleReport {
|
|
|
121
124
|
effect: string;
|
|
122
125
|
hit: boolean;
|
|
123
126
|
/**
|
|
124
|
-
* True for lobbed
|
|
125
|
-
* false for direct-fire shots (bullets, bolts) that travel muzzle→impact in a straight line
|
|
127
|
+
* True for lobbed fused rounds (grenades, launchers, rockets) whose real path is an arc,
|
|
128
|
+
* false for direct-fire shots (bullets, bolts) that travel muzzle→impact in a straight line,
|
|
129
|
+
* including direct-fire shots that carry an explosion.
|
|
126
130
|
* Presentation uses it to keep straight-line tracers off arced projectiles.
|
|
127
131
|
*/
|
|
128
132
|
ballistic: boolean;
|
|
@@ -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;
|