@jgengine/core 0.9.0 → 0.10.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 +21 -1
- package/dist/ai/spawnDirector.d.ts +13 -0
- package/dist/ai/spawnDirector.js +40 -16
- package/dist/ai/spawnPoint.d.ts +5 -0
- package/dist/ai/spawnPoint.js +5 -0
- package/dist/audio/audioFalloff.d.ts +5 -2
- package/dist/audio/music.d.ts +53 -0
- package/dist/audio/music.js +34 -0
- package/dist/audio/synth.d.ts +60 -0
- package/dist/audio/synth.js +8 -0
- package/dist/authoring.d.ts +14 -0
- package/dist/authoring.js +14 -0
- package/dist/cartridge/runtime.js +2 -2
- package/dist/combat/dotField.d.ts +43 -0
- package/dist/combat/dotField.js +48 -0
- package/dist/combat/projectiles.d.ts +2 -2
- package/dist/combat/projectiles.js +18 -5
- package/dist/combat/regenShield.d.ts +35 -0
- package/dist/combat/regenShield.js +45 -0
- package/dist/combat/shotOrigin.d.ts +13 -1
- package/dist/combat/shotOrigin.js +31 -2
- package/dist/editor/commands.d.ts +65 -0
- package/dist/editor/commands.js +193 -0
- package/dist/editor/document.d.ts +40 -0
- package/dist/editor/document.js +153 -0
- package/dist/editor/index.d.ts +30 -0
- package/dist/editor/index.js +6 -0
- package/dist/editor/types.d.ts +69 -0
- package/dist/editor/types.js +29 -0
- package/dist/game/connectedPlayers.d.ts +28 -0
- package/dist/game/connectedPlayers.js +21 -0
- package/dist/game/defineGame.d.ts +42 -1
- package/dist/game/defineGame.js +1 -0
- package/dist/game/events.d.ts +15 -0
- package/dist/game/keyValueStore.d.ts +25 -0
- package/dist/game/keyValueStore.js +61 -0
- package/dist/game/levelSequence.d.ts +24 -2
- package/dist/game/levelSequence.js +51 -2
- package/dist/game/lootTable.d.ts +4 -0
- package/dist/game/lootTable.js +1 -0
- package/dist/game/modelAnimation.d.ts +8 -0
- package/dist/game/modelAnimation.js +18 -0
- package/dist/game/playableGame.d.ts +68 -5
- package/dist/game/recordBook.d.ts +3 -5
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/input/actionBindings.d.ts +2 -0
- package/dist/input/actionBindings.js +1 -0
- package/dist/input/axisInput.d.ts +7 -0
- package/dist/input/axisInput.js +16 -0
- package/dist/interaction/contextMenu.d.ts +1 -0
- package/dist/interaction/contextMenu.js +1 -0
- package/dist/interaction/proximityPrompt.d.ts +3 -1
- package/dist/interaction/proximityPrompt.js +3 -3
- package/dist/meta/changelog.d.ts +1 -1
- package/dist/meta/changelog.js +22 -1
- package/dist/movement/movementModel.d.ts +4 -0
- package/dist/movement/movementModel.js +13 -0
- package/dist/movement/playerMovement.d.ts +41 -0
- package/dist/movement/playerMovement.js +181 -0
- package/dist/random/rng.d.ts +2 -0
- package/dist/random/rng.js +2 -0
- package/dist/render/postProcessing.d.ts +56 -0
- package/dist/render/postProcessing.js +1 -0
- package/dist/runtime/adapter.d.ts +19 -0
- package/dist/runtime/adapter.js +11 -5
- package/dist/runtime/gameContext.d.ts +53 -10
- package/dist/runtime/gameContext.js +192 -54
- package/dist/runtime/hostedGameRunner.d.ts +49 -0
- package/dist/runtime/hostedGameRunner.js +62 -0
- package/dist/runtime/hostedWorldSession.d.ts +65 -0
- package/dist/runtime/hostedWorldSession.js +57 -0
- package/dist/runtime/inputSnapshot.d.ts +13 -0
- package/dist/runtime/inputSnapshot.js +2 -0
- package/dist/runtime/motionIntents.d.ts +2 -0
- package/dist/runtime/motionIntents.js +11 -0
- package/dist/runtime/worldChannel.d.ts +58 -0
- package/dist/runtime/worldChannel.js +69 -0
- package/dist/runtime/worldMirror.d.ts +24 -0
- package/dist/runtime/worldMirror.js +31 -0
- package/dist/runtime/worldReplication.d.ts +46 -0
- package/dist/runtime/worldReplication.js +185 -0
- package/dist/runtime/worldSnapshot.d.ts +17 -0
- package/dist/runtime/worldSnapshot.js +15 -0
- package/dist/scene/assetCatalog.d.ts +1 -0
- package/dist/scene/colliders.d.ts +6 -0
- package/dist/scene/colliders.js +25 -1
- package/dist/scene/entityStats.d.ts +4 -0
- package/dist/scene/entityStats.js +21 -0
- package/dist/scene/entityStore.d.ts +29 -0
- package/dist/scene/entityStore.js +60 -0
- package/dist/store/observableKeyedStore.d.ts +4 -0
- package/dist/store/observableKeyedStore.js +9 -0
- package/dist/tactics/fallingGrid.d.ts +2 -2
- package/dist/tactics/fallingGrid.js +2 -2
- package/dist/ui/gameLayout.d.ts +125 -0
- package/dist/ui/gameLayout.js +129 -0
- package/dist/ui/orientation.d.ts +39 -0
- package/dist/ui/orientation.js +49 -0
- package/dist/ui/swingTimer.d.ts +42 -0
- package/dist/ui/swingTimer.js +38 -0
- package/dist/visibility/spatialIndex.js +34 -20
- package/dist/world/environmentSummary.d.ts +11 -2
- package/dist/world/environmentSummary.js +11 -1
- package/dist/world/features.d.ts +134 -5
- package/dist/world/features.js +40 -3
- package/dist/world/roads.d.ts +47 -0
- package/dist/world/roads.js +146 -0
- package/dist/world/streets.d.ts +80 -0
- package/dist/world/streets.js +144 -0
- package/dist/world/terrain.d.ts +32 -5
- package/dist/world/terrain.js +124 -12
- package/llms.txt +485 -79
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -11,7 +11,27 @@ Agents building on the published SDK can also read this programmatically:
|
|
|
11
11
|
same data as typed values, so an updater can diff its installed version against
|
|
12
12
|
the latest and surface the migration steps.
|
|
13
13
|
|
|
14
|
-
##
|
|
14
|
+
## 0.10.0
|
|
15
|
+
|
|
16
|
+
### Migrate
|
|
17
|
+
|
|
18
|
+
- **Nothing to change for existing games** — the shared mobile-composition system (viewport allocation, region collision, `--jg-*` viewport vars) is wired automatically by the shell for every game; `HudPanel`s inside a `HudCanvas` register themselves. Legacy `orientation: "landscape" | "portrait"` keeps its advisory dismissible-hint behavior unchanged.
|
|
19
|
+
- **Opt a driving/landscape game into the strict gate**: replace the advisory `orientation` with the contract form — `orientation: { mobile: "landscape-required" }` (rules: `any` · `portrait`/`landscape` advisory · `portrait-required`/`landscape-required` · `unsupported`). In portrait the engine now shows a polished `RotateDeviceScreen`, suppresses input, freezes the sim, and unmounts the HUD/controls until the device is landscape.
|
|
20
|
+
- **Declare HUD intent** where useful: `HudPanel` gains `priority` (`critical`/`secondary`/`tertiary`), `mobileBehavior` (`hidden` unmounts on phones, `transient` softens collision, …), `allowOverlapWith`, and `collisionGroup`. Optional — omitting them keeps prior behavior.
|
|
21
|
+
- **Mobile validation now also fails on overlap**: `bun run shoot <game> --device mobile|mobile-landscape|both` exits non-zero on forbidden inter-element collisions (not just viewport overflow), naming both regions.
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
|
|
25
|
+
- Shared mobile layout composition — `@jgengine/core/ui/gameLayout` (pure geometry: `resolveLayoutMode`, `intersects`, `overlapArea`, `detectLayoutCollisions`, `computeGameplayRect`, `LayoutRegion`, `GameViewportLayout`) and `@jgengine/core/ui/orientation` (`resolveOrientationRequirement`, `orientationGateActive`, `MobileOrientationRule`). `@jgengine/react` adds `GameViewportProvider` (mounted by the shell), hooks (`useGameViewportLayout`, `useGameLayoutMode`, `useGameOrientation`, `useReservedControlZones`, `useLayoutCollisions`, `useRegisterLayoutRegion`), and a headless `RotateDeviceScreen`. The provider tracks `window.visualViewport`, publishes `--jg-viewport-*` / `--jg-visual-viewport-*` / `--jg-safe-*` CSS vars, resolves the explicit layout mode, and detects forbidden region overlaps (dev `data-jg-layout-collision` + console diagnostic).
|
|
26
|
+
- Mandatory orientation contract — `PlayableGame.orientation` accepts `{ mobile: <rule> }`; `landscape-required`/`portrait-required` render an engine-owned rotate gate that blocks gameplay (input suppressed, simulation frozen, HUD/controls unmounted), self-correcting when the device is turned. Reduced-motion and safe-area respected; `visualViewport`-sized for mobile Safari/PWA.
|
|
27
|
+
- Reserved touch-control zones — the touch dock registers its joystick / action-cluster / utility rectangles (runtime-measured) as `control` regions, so HUD placement and collision validation know exactly where controls sit.
|
|
28
|
+
- HUD placement metadata — `HudPanel` gains `priority`, `mobileBehavior`, `allowOverlapWith`, `collisionGroup`, and registers as a collision-tracked layout region.
|
|
29
|
+
- Mobile-landscape shoot device — `bun run shoot --device mobile-landscape` (844×390) plus collision-gate reads that fail mobile validation on forbidden overlaps.
|
|
30
|
+
- Canyon Chase migrated as the reference landscape-required game — declares `orientation: { mobile: "landscape-required" }`, composes its HUD through coordinated `HudPanel` regions (pursuit distance critical, border secondary, survey map hidden on mobile, radio transient) instead of independent fixed corners.
|
|
31
|
+
|
|
32
|
+
### Changed
|
|
33
|
+
|
|
34
|
+
- First-person projectile tracers now originate from the weapon muzzle instead of the camera/eye centerline. Hit detection is unchanged (still crosshair-accurate) — only the drawn tracer's start point moved to the viewmodel muzzle, which tracks full aim yaw and pitch. Non-viewmodel games and enemy tracers are unaffected.
|
|
15
35
|
|
|
16
36
|
## 0.9.0
|
|
17
37
|
|
|
@@ -47,6 +47,16 @@ export interface SpawnRequest {
|
|
|
47
47
|
point?: NavPoint;
|
|
48
48
|
laneId?: number;
|
|
49
49
|
}
|
|
50
|
+
/** Preference for picking a spawn point relative to `avoid` positions: closer, farther, or unweighted. */
|
|
51
|
+
export type SpawnPointDistanceBias = "near" | "far" | "none";
|
|
52
|
+
/** Semantic options for selecting a spawn point without exposing weighting internals. */
|
|
53
|
+
export interface SpawnPointSelectionOptions {
|
|
54
|
+
candidates: readonly NavPoint[];
|
|
55
|
+
avoid?: readonly NavPoint[];
|
|
56
|
+
random: () => number;
|
|
57
|
+
distanceBias?: SpawnPointDistanceBias;
|
|
58
|
+
biasStrength?: number;
|
|
59
|
+
}
|
|
50
60
|
export interface DirectorStep {
|
|
51
61
|
state: SpawnDirectorState;
|
|
52
62
|
spawns: SpawnRequest[];
|
|
@@ -55,6 +65,9 @@ export declare function createSpawnDirectorState(config: SpawnDirectorConfig): S
|
|
|
55
65
|
export declare function raiseAlert(state: SpawnDirectorState, amount: number): SpawnDirectorState;
|
|
56
66
|
export declare function advanceWave(config: SpawnDirectorConfig, state: SpawnDirectorState): SpawnDirectorState;
|
|
57
67
|
export declare function advanceSpawnDirector(config: SpawnDirectorConfig, state: SpawnDirectorState, dt: number, ctx: DirectorContext): DirectorStep;
|
|
68
|
+
/** Selects a candidate spawn point using a semantic distance preference and caller-supplied randomness. */
|
|
69
|
+
export declare function pickSpawnPoint(options: SpawnPointSelectionOptions): NavPoint | null;
|
|
70
|
+
/** @deprecated Pass one SpawnPointSelectionOptions object instead. */
|
|
58
71
|
export declare function pickSpawnPoint(points: readonly NavPoint[], players: readonly NavPoint[], options: {
|
|
59
72
|
roll: number;
|
|
60
73
|
bias?: number;
|
package/dist/ai/spawnDirector.js
CHANGED
|
@@ -118,7 +118,14 @@ export function advanceSpawnDirector(config, state, dt, ctx) {
|
|
|
118
118
|
if (spawnPoints !== undefined && spawnPoints.length > 0) {
|
|
119
119
|
const [roll, nextRng] = nextRandom(rng);
|
|
120
120
|
rng = nextRng;
|
|
121
|
-
const
|
|
121
|
+
const bias = config.spawnPointBias ?? 1;
|
|
122
|
+
const point = pickSpawnPoint({
|
|
123
|
+
candidates: spawnPoints,
|
|
124
|
+
avoid: playerPositions,
|
|
125
|
+
random: () => roll,
|
|
126
|
+
distanceBias: bias < 0 ? "far" : bias > 0 ? "near" : "none",
|
|
127
|
+
biasStrength: Math.abs(bias),
|
|
128
|
+
});
|
|
122
129
|
if (point !== null) {
|
|
123
130
|
spawn.point = point;
|
|
124
131
|
spawn.laneId = spawnPoints.indexOf(point);
|
|
@@ -134,29 +141,46 @@ export function advanceSpawnDirector(config, state, dt, ctx) {
|
|
|
134
141
|
spawns,
|
|
135
142
|
};
|
|
136
143
|
}
|
|
137
|
-
export function pickSpawnPoint(
|
|
138
|
-
|
|
144
|
+
export function pickSpawnPoint(optionsOrPoints, legacyPlayers = [], legacyOptions = { roll: 0 }) {
|
|
145
|
+
let options;
|
|
146
|
+
if (Array.isArray(optionsOrPoints)) {
|
|
147
|
+
const legacyBias = legacyOptions.bias ?? 1;
|
|
148
|
+
options = {
|
|
149
|
+
candidates: optionsOrPoints,
|
|
150
|
+
avoid: legacyPlayers,
|
|
151
|
+
random: () => legacyOptions.roll,
|
|
152
|
+
distanceBias: legacyBias < 0 ? "far" : legacyBias > 0 ? "near" : "none",
|
|
153
|
+
biasStrength: Math.abs(legacyBias),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
options = optionsOrPoints;
|
|
158
|
+
}
|
|
159
|
+
const { candidates, avoid = [], random, distanceBias = "near", biasStrength = 1 } = options;
|
|
160
|
+
if (candidates.length === 0)
|
|
139
161
|
return null;
|
|
140
|
-
|
|
162
|
+
if (distanceBias === "none" || avoid.length === 0 || biasStrength === 0) {
|
|
163
|
+
return candidates[Math.min(candidates.length - 1, Math.floor(clamp01(random()) * candidates.length))];
|
|
164
|
+
}
|
|
165
|
+
const exponent = distanceBias === "far" ? biasStrength : -biasStrength;
|
|
141
166
|
const weights = [];
|
|
142
167
|
let total = 0;
|
|
143
|
-
for (const point of
|
|
168
|
+
for (const point of candidates) {
|
|
144
169
|
let nearest = Number.POSITIVE_INFINITY;
|
|
145
|
-
for (const
|
|
146
|
-
const
|
|
147
|
-
if (
|
|
148
|
-
nearest =
|
|
170
|
+
for (const avoidedPoint of avoid) {
|
|
171
|
+
const distance = Math.hypot(point[0] - avoidedPoint[0], point[1] - avoidedPoint[1]);
|
|
172
|
+
if (distance < nearest)
|
|
173
|
+
nearest = distance;
|
|
149
174
|
}
|
|
150
|
-
const
|
|
151
|
-
const weight = Math.max(1e-6, (1 + distance) ** -bias);
|
|
175
|
+
const weight = Math.max(1e-6, (1 + nearest) ** exponent);
|
|
152
176
|
weights.push(weight);
|
|
153
177
|
total += weight;
|
|
154
178
|
}
|
|
155
|
-
let cursor = clamp01(
|
|
156
|
-
for (let
|
|
157
|
-
cursor -= weights[
|
|
179
|
+
let cursor = clamp01(random()) * total;
|
|
180
|
+
for (let index = 0; index < candidates.length; index += 1) {
|
|
181
|
+
cursor -= weights[index];
|
|
158
182
|
if (cursor <= 0)
|
|
159
|
-
return
|
|
183
|
+
return candidates[index];
|
|
160
184
|
}
|
|
161
|
-
return
|
|
185
|
+
return candidates[candidates.length - 1];
|
|
162
186
|
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { NavPoint } from "../nav/navGrid.js";
|
|
2
|
+
import { type SpawnPointDistanceBias, type SpawnPointSelectionOptions } from "./spawnDirector.js";
|
|
3
|
+
export type { SpawnPointDistanceBias, SpawnPointSelectionOptions };
|
|
4
|
+
/** Selects a spawn point by game intent while keeping weighting mechanics internal. */
|
|
5
|
+
export declare function selectSpawnPoint(options: SpawnPointSelectionOptions): NavPoint | null;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { SynthPatch } from "./synth.js";
|
|
1
2
|
export type AudioBusId = string;
|
|
2
3
|
export type FalloffCurve = "linear" | "inverse" | "none";
|
|
3
4
|
export interface AudioFalloffConfig {
|
|
@@ -15,8 +16,10 @@ export interface AudioBusDef {
|
|
|
15
16
|
}
|
|
16
17
|
export interface SoundDef {
|
|
17
18
|
id: string;
|
|
18
|
-
/**
|
|
19
|
-
url
|
|
19
|
+
/** Sample source — a URL/data URI the shell's Web Audio glue fetches and decodes. Provide exactly one of `url` or `synth`. */
|
|
20
|
+
url?: string;
|
|
21
|
+
/** Procedural source — a pure synth patch the shell realises on Web Audio, no file fetch. Provide exactly one of `url` or `synth`. */
|
|
22
|
+
synth?: SynthPatch;
|
|
20
23
|
bus: AudioBusId;
|
|
21
24
|
/** Base gain before bus/falloff multipliers. Default 1. */
|
|
22
25
|
gain?: number;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Named synthesised instrument. Each maps to a voice in the shell's instrument
|
|
3
|
+
* library (`@jgengine/shell/audio/musicVoices`); an unknown name falls back to a
|
|
4
|
+
* plain sine voice so a theme is never silent.
|
|
5
|
+
*/
|
|
6
|
+
export type MusicInstrument = "strings" | "flute" | "harp" | "horn" | "choir" | "bell" | "timpani" | "bass" | "stacc" | "pad" | "lute" | "dulcimer" | "frameDrum" | "warDrum" | "reed" | "pipe" | "squareLead" | "woodBlock" | "tinyBell" | "piano" | "shaker" | "brassStab" | "cymSwell" | "oboe";
|
|
7
|
+
/** One scheduled note in a theme, positioned on the loop's quarter-note grid. */
|
|
8
|
+
export interface NoteEvent {
|
|
9
|
+
/** Quarter-note position within the loop (0-based). */
|
|
10
|
+
beat: number;
|
|
11
|
+
/** MIDI note number (60 = middle C). */
|
|
12
|
+
midi: number;
|
|
13
|
+
/** Sounding length in quarter-note beats. */
|
|
14
|
+
dur: number;
|
|
15
|
+
/** Velocity 0..1. */
|
|
16
|
+
vel: number;
|
|
17
|
+
/** Which instrument voice plays it. */
|
|
18
|
+
inst: MusicInstrument;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* A through-composed, looping music track. `events` need not be sorted; the
|
|
22
|
+
* director schedules them ahead against a fixed anchor so loops are seamless.
|
|
23
|
+
*/
|
|
24
|
+
export interface MusicTheme {
|
|
25
|
+
id: string;
|
|
26
|
+
/** Tempo in beats (quarter notes) per minute. */
|
|
27
|
+
bpm: number;
|
|
28
|
+
/** Loop length in 4/4 bars; the loop is `bars * 4` beats long. */
|
|
29
|
+
bars: number;
|
|
30
|
+
events: readonly NoteEvent[];
|
|
31
|
+
/**
|
|
32
|
+
* Per-theme loudness normalisation multiplied into the crossfade target, so
|
|
33
|
+
* dense and sparse themes sit at the same perceived level. Default 1.
|
|
34
|
+
*/
|
|
35
|
+
trim?: number;
|
|
36
|
+
}
|
|
37
|
+
/** A resolved note occurrence: the source note plus its absolute onset in seconds. */
|
|
38
|
+
export interface ScheduledNote {
|
|
39
|
+
event: NoteEvent;
|
|
40
|
+
/** Absolute onset in seconds, on the timeline of the `anchorSec`/window args. */
|
|
41
|
+
when: number;
|
|
42
|
+
}
|
|
43
|
+
/** Standard equal-temperament MIDI-to-frequency (A4 = 440 Hz at MIDI 69). */
|
|
44
|
+
export declare function mtof(midi: number): number;
|
|
45
|
+
/** Loop length of a theme in seconds. */
|
|
46
|
+
export declare function themeLoopSeconds(theme: MusicTheme): number;
|
|
47
|
+
/**
|
|
48
|
+
* Pure lookahead scheduler: every note occurrence of `theme` whose onset falls
|
|
49
|
+
* in the half-open window `(fromSec, toSec]`, given the theme's loop-zero at
|
|
50
|
+
* `anchorSec`. Handles any number of loop wraps, so a director calls it once per
|
|
51
|
+
* tick with a non-overlapping window and never double-schedules a note.
|
|
52
|
+
*/
|
|
53
|
+
export declare function notesInWindow(theme: MusicTheme, anchorSec: number, fromSec: number, toSec: number): ScheduledNote[];
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** Standard equal-temperament MIDI-to-frequency (A4 = 440 Hz at MIDI 69). */
|
|
2
|
+
export function mtof(midi) {
|
|
3
|
+
return 440 * 2 ** ((midi - 69) / 12);
|
|
4
|
+
}
|
|
5
|
+
/** Loop length of a theme in seconds. */
|
|
6
|
+
export function themeLoopSeconds(theme) {
|
|
7
|
+
return (theme.bars * 4 * 60) / theme.bpm;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Pure lookahead scheduler: every note occurrence of `theme` whose onset falls
|
|
11
|
+
* in the half-open window `(fromSec, toSec]`, given the theme's loop-zero at
|
|
12
|
+
* `anchorSec`. Handles any number of loop wraps, so a director calls it once per
|
|
13
|
+
* tick with a non-overlapping window and never double-schedules a note.
|
|
14
|
+
*/
|
|
15
|
+
export function notesInWindow(theme, anchorSec, fromSec, toSec) {
|
|
16
|
+
const loop = themeLoopSeconds(theme);
|
|
17
|
+
if (loop <= 0 || toSec <= fromSec)
|
|
18
|
+
return [];
|
|
19
|
+
const spb = 60 / theme.bpm;
|
|
20
|
+
const out = [];
|
|
21
|
+
for (const event of theme.events) {
|
|
22
|
+
const onset = event.beat * spb;
|
|
23
|
+
let k = Math.max(0, Math.floor((fromSec - anchorSec - onset) / loop));
|
|
24
|
+
for (;;) {
|
|
25
|
+
const when = anchorSec + onset + k * loop;
|
|
26
|
+
if (when > toSec)
|
|
27
|
+
break;
|
|
28
|
+
if (when > fromSec)
|
|
29
|
+
out.push({ event, when });
|
|
30
|
+
k += 1;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/** Oscillator waveform available to a pitched synth voice. */
|
|
2
|
+
export type SynthWave = "sine" | "square" | "sawtooth" | "triangle";
|
|
3
|
+
/** Biquad filter shape a noise voice is coloured through. */
|
|
4
|
+
export type SynthFilter = "lowpass" | "highpass" | "bandpass";
|
|
5
|
+
/**
|
|
6
|
+
* A pitched oscillator voice: a 12ms linear attack to `gain`, then an
|
|
7
|
+
* exponential decay to silence across `duration`, with an optional
|
|
8
|
+
* exponential pitch slide from `freq` to `slideTo`.
|
|
9
|
+
*/
|
|
10
|
+
export interface ToneVoice {
|
|
11
|
+
kind: "tone";
|
|
12
|
+
/** Start frequency in Hz. */
|
|
13
|
+
freq: number;
|
|
14
|
+
/** Total voice length in seconds (the decay spans this). */
|
|
15
|
+
duration: number;
|
|
16
|
+
/** Peak gain before patch/bus scaling. Default 1. */
|
|
17
|
+
gain?: number;
|
|
18
|
+
/** Oscillator waveform. Default "sine". */
|
|
19
|
+
wave?: SynthWave;
|
|
20
|
+
/** Start offset in seconds from the cue trigger. Default 0. */
|
|
21
|
+
delay?: number;
|
|
22
|
+
/** When set, pitch ramps exponentially from `freq` to this Hz across `duration`. */
|
|
23
|
+
slideTo?: number;
|
|
24
|
+
/** Attack ramp in seconds. Default 0.012. */
|
|
25
|
+
attack?: number;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* A filtered white-noise burst — impacts, whooshes, breath, crackle. Realised
|
|
29
|
+
* from a shared 1s noise buffer at a randomised playback rate and start offset,
|
|
30
|
+
* decaying exponentially to silence at `duration * decay`.
|
|
31
|
+
*/
|
|
32
|
+
export interface NoiseVoice {
|
|
33
|
+
kind: "noise";
|
|
34
|
+
/** Total voice length in seconds. */
|
|
35
|
+
duration: number;
|
|
36
|
+
/** Biquad cutoff/centre in Hz. */
|
|
37
|
+
filterFreq: number;
|
|
38
|
+
/** Peak gain before patch/bus scaling. Default 1. */
|
|
39
|
+
gain?: number;
|
|
40
|
+
/** Filter shape. Default "lowpass". */
|
|
41
|
+
filterType?: SynthFilter;
|
|
42
|
+
/** Fraction of `duration` over which the burst decays to silence. Default 0.9. */
|
|
43
|
+
decay?: number;
|
|
44
|
+
/** Start offset in seconds from the cue trigger. Default 0. */
|
|
45
|
+
delay?: number;
|
|
46
|
+
}
|
|
47
|
+
/** One layered voice of a synth cue. */
|
|
48
|
+
export type SynthVoice = ToneVoice | NoiseVoice;
|
|
49
|
+
/**
|
|
50
|
+
* A procedural sound cue: a set of voices triggered together, each with its own
|
|
51
|
+
* `delay`, summed into one one-shot. Pure serialisable data — the shell realises
|
|
52
|
+
* it on Web Audio, so the same catalog runs headless in tests with no `AudioContext`.
|
|
53
|
+
*/
|
|
54
|
+
export interface SynthPatch {
|
|
55
|
+
voices: readonly SynthVoice[];
|
|
56
|
+
/** Overall cue gain multiplier applied to every voice. Default 1. */
|
|
57
|
+
gain?: number;
|
|
58
|
+
}
|
|
59
|
+
/** Total wall-clock length of a patch in seconds — the latest voice end across all voices. */
|
|
60
|
+
export declare function patchDuration(patch: SynthPatch): number;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Total wall-clock length of a patch in seconds — the latest voice end across all voices. */
|
|
2
|
+
export function patchDuration(patch) {
|
|
3
|
+
let end = 0;
|
|
4
|
+
for (const voice of patch.voices) {
|
|
5
|
+
end = Math.max(end, (voice.delay ?? 0) + voice.duration);
|
|
6
|
+
}
|
|
7
|
+
return end;
|
|
8
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task-first JGEngine authoring surface.
|
|
3
|
+
*
|
|
4
|
+
* Game code should begin here. Domain and runtime deep imports remain available for
|
|
5
|
+
* advanced composition, but this module intentionally exposes common authored
|
|
6
|
+
* concepts without requiring callers to understand the repository layout.
|
|
7
|
+
*/
|
|
8
|
+
export { defineGame, type GameDefinition, type GameDefinitionConfig, type GameLoop, type InventoryDeclaration, type PhysicsConfig, type GameServerConfig, } from "./game/defineGame.js";
|
|
9
|
+
export { lootTable, type LootTableDef, type LootEntry, type Drop, } from "./game/lootTable.js";
|
|
10
|
+
export { biomes, voxel, plots, tilemap, flat, environment, terrain, rain, snow, grass, ocean, building, road, type WorldFeature, } from "./world/features.js";
|
|
11
|
+
export { seededRng, seededStreams } from "./random/rng.js";
|
|
12
|
+
export { selectSpawnPoint, type SpawnPointSelectionOptions, type SpawnPointDistanceBias, } from "./ai/spawnPoint.js";
|
|
13
|
+
export { contextVerb } from "./interaction/contextMenu.js";
|
|
14
|
+
export { type ActionCodesMap } from "./input/actionBindings.js";
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task-first JGEngine authoring surface.
|
|
3
|
+
*
|
|
4
|
+
* Game code should begin here. Domain and runtime deep imports remain available for
|
|
5
|
+
* advanced composition, but this module intentionally exposes common authored
|
|
6
|
+
* concepts without requiring callers to understand the repository layout.
|
|
7
|
+
*/
|
|
8
|
+
export { defineGame, } from "./game/defineGame.js";
|
|
9
|
+
export { lootTable, } from "./game/lootTable.js";
|
|
10
|
+
export { biomes, voxel, plots, tilemap, flat, environment, terrain, rain, snow, grass, ocean, building, road, } from "./world/features.js";
|
|
11
|
+
export { seededRng, seededStreams } from "./random/rng.js";
|
|
12
|
+
export { selectSpawnPoint, } from "./ai/spawnPoint.js";
|
|
13
|
+
export { contextVerb } from "./interaction/contextMenu.js";
|
|
14
|
+
export {} from "./input/actionBindings.js";
|
|
@@ -409,7 +409,7 @@ export function createCartridge(spec) {
|
|
|
409
409
|
const run = getRun(ctx);
|
|
410
410
|
syncPhase(ctx, run);
|
|
411
411
|
if (spec.rules.killLeaderboardStat !== undefined) {
|
|
412
|
-
ctx.game.leaderboard
|
|
412
|
+
ctx.game.leaderboard?.track({ stat: spec.rules.killLeaderboardStat, scope: "profile" });
|
|
413
413
|
}
|
|
414
414
|
run.disposeEvents?.();
|
|
415
415
|
run.disposeEvents = ctx.game.events.on("entity.died", (event) => {
|
|
@@ -428,7 +428,7 @@ export function createCartridge(spec) {
|
|
|
428
428
|
return;
|
|
429
429
|
run.kills += 1;
|
|
430
430
|
if (spec.rules.killLeaderboardStat !== undefined) {
|
|
431
|
-
ctx.game.leaderboard
|
|
431
|
+
ctx.game.leaderboard?.increment(ctx.player.userId, spec.rules.killLeaderboardStat, { scope: "profile" });
|
|
432
432
|
}
|
|
433
433
|
ctx.scene.worldItem.spawn({
|
|
434
434
|
itemId: XP_GEM_BASE_TYPE,
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { BuildupProc } from "./breakMeters.js";
|
|
2
|
+
/** A single damage-over-time instance: how hard, how often, and for how long. */
|
|
3
|
+
export interface DotSpec {
|
|
4
|
+
/** Damage dealt at each interval. */
|
|
5
|
+
damagePerTick: number;
|
|
6
|
+
/** Milliseconds between damage ticks; clamped to a positive minimum. */
|
|
7
|
+
intervalMs: number;
|
|
8
|
+
/** Total lifetime in milliseconds; the DoT expires once its age reaches this. */
|
|
9
|
+
durationMs: number;
|
|
10
|
+
/** Label echoed on each emitted tick (bleed, poison, burn); defaults to the apply key. */
|
|
11
|
+
status?: string;
|
|
12
|
+
}
|
|
13
|
+
/** One damage event (or expiry marker) emitted by `DotField.tick` for the game to route through its effects. */
|
|
14
|
+
export interface DotTick {
|
|
15
|
+
/** The key the DoT was applied under. */
|
|
16
|
+
id: string;
|
|
17
|
+
/** The DoT's `status` label, or its key when none was given. */
|
|
18
|
+
status: string;
|
|
19
|
+
/** Damage to route through the game's effect pipeline this frame; 0 on a bare expiry marker. */
|
|
20
|
+
damage: number;
|
|
21
|
+
/** True on the entry that ends the DoT — remove any HUD icon on it. */
|
|
22
|
+
expired: boolean;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* A pool of timed damage-over-time instances keyed by string (`bleed`, `poison`, `burn`) — the
|
|
26
|
+
* recurring-damage counterpart to `breakMeters`' one-shot buildup procs (#536.4). `tick` advances
|
|
27
|
+
* every active DoT and returns the damage due this frame, one entry per interval crossed, so the game
|
|
28
|
+
* routes each through its own effect pipeline; a pure scheduler that never applies damage itself.
|
|
29
|
+
*/
|
|
30
|
+
export interface DotField {
|
|
31
|
+
/** Start or refresh a DoT under `id`; reapplying the same key restarts its age and lifetime. */
|
|
32
|
+
apply(id: string, spec: DotSpec): void;
|
|
33
|
+
/** Bridge a `breakMeters` buildup proc into recurring damage under `id`. */
|
|
34
|
+
applyProc(id: string, proc: BuildupProc, damagePerTick: number, intervalMs: number): void;
|
|
35
|
+
active(id: string): boolean;
|
|
36
|
+
cancel(id: string): void;
|
|
37
|
+
clear(): void;
|
|
38
|
+
count(): number;
|
|
39
|
+
/** Advance every active DoT; returns the damage due this frame plus an `expired` marker per finished DoT. */
|
|
40
|
+
tick(dtSeconds: number): DotTick[];
|
|
41
|
+
}
|
|
42
|
+
/** Builds an empty {@link DotField}; `apply` DoTs onto it and drain damage each frame with `tick`. */
|
|
43
|
+
export declare function createDotField(): DotField;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/** Builds an empty {@link DotField}; `apply` DoTs onto it and drain damage each frame with `tick`. */
|
|
2
|
+
export function createDotField() {
|
|
3
|
+
const dots = new Map();
|
|
4
|
+
return {
|
|
5
|
+
apply(id, spec) {
|
|
6
|
+
dots.set(id, {
|
|
7
|
+
status: spec.status ?? id,
|
|
8
|
+
damagePerTick: spec.damagePerTick,
|
|
9
|
+
intervalMs: Math.max(1, spec.intervalMs),
|
|
10
|
+
durationMs: Math.max(0, spec.durationMs),
|
|
11
|
+
ageMs: 0,
|
|
12
|
+
ticksEmitted: 0,
|
|
13
|
+
});
|
|
14
|
+
},
|
|
15
|
+
applyProc(id, proc, damagePerTick, intervalMs) {
|
|
16
|
+
this.apply(id, { damagePerTick, intervalMs, durationMs: proc.durationMs, status: proc.status });
|
|
17
|
+
},
|
|
18
|
+
active: (id) => dots.has(id),
|
|
19
|
+
cancel: (id) => void dots.delete(id),
|
|
20
|
+
clear: () => dots.clear(),
|
|
21
|
+
count: () => dots.size,
|
|
22
|
+
tick(dtSeconds) {
|
|
23
|
+
if (dtSeconds <= 0)
|
|
24
|
+
return [];
|
|
25
|
+
const dtMs = dtSeconds * 1000;
|
|
26
|
+
const emitted = [];
|
|
27
|
+
for (const [id, dot] of dots) {
|
|
28
|
+
dot.ageMs += dtMs;
|
|
29
|
+
const activeMs = Math.min(dot.ageMs, dot.durationMs);
|
|
30
|
+
const dueTicks = Math.floor(activeMs / dot.intervalMs);
|
|
31
|
+
let last = null;
|
|
32
|
+
while (dot.ticksEmitted < dueTicks) {
|
|
33
|
+
dot.ticksEmitted += 1;
|
|
34
|
+
last = { id, status: dot.status, damage: dot.damagePerTick, expired: false };
|
|
35
|
+
emitted.push(last);
|
|
36
|
+
}
|
|
37
|
+
if (dot.ageMs >= dot.durationMs) {
|
|
38
|
+
dots.delete(id);
|
|
39
|
+
if (last !== null)
|
|
40
|
+
last.expired = true;
|
|
41
|
+
else
|
|
42
|
+
emitted.push({ id, status: dot.status, damage: 0, expired: true });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return emitted;
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { BallisticSweep } from "../physics/ballisticSweep.js";
|
|
2
2
|
import type { EntityPosition } from "../scene/entityStore.js";
|
|
3
|
-
import type
|
|
3
|
+
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";
|
|
@@ -10,7 +10,7 @@ export interface ProjectileShotInput {
|
|
|
10
10
|
via: EffectVia;
|
|
11
11
|
aim: Aim;
|
|
12
12
|
effect: string;
|
|
13
|
-
/** Defaults to `{ kind: "
|
|
13
|
+
/** Defaults to `{ kind: "eye" }` — aim.origin when given, else shooter position at eye height. */
|
|
14
14
|
originPolicy?: ShotOriginPolicy;
|
|
15
15
|
}
|
|
16
16
|
export interface EntityRaycastHit {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { defaultEntityColliders, resolveColliders } from "../scene/colliders.js";
|
|
1
2
|
import { createSceneRaycast, firstImpact, hitsUntilBlocked, } from "../scene/sceneRaycast.js";
|
|
2
3
|
import { aimDirection, aimSpreadDeg, resolveShot, } from "./shotOrigin.js";
|
|
3
4
|
const DEFAULT_RANGE = 100;
|
|
@@ -70,7 +71,7 @@ export function createProjectileSystem(deps) {
|
|
|
70
71
|
const shots = new Map();
|
|
71
72
|
let shotCounter = 0;
|
|
72
73
|
const now = deps.now ?? (() => Date.now());
|
|
73
|
-
const defaultPolicy = deps.defaultOriginPolicy ?? { kind: "
|
|
74
|
+
const defaultPolicy = deps.defaultOriginPolicy ?? { kind: "eye" };
|
|
74
75
|
function itemStat(via, stat) {
|
|
75
76
|
return via.item === undefined ? null : deps.getStat(via.item, stat);
|
|
76
77
|
}
|
|
@@ -84,6 +85,7 @@ export function createProjectileSystem(deps) {
|
|
|
84
85
|
return resolveShot({
|
|
85
86
|
positionOf: deps.spatial.positionOf,
|
|
86
87
|
rotationYOf: deps.rotationYOf,
|
|
88
|
+
collidersOf: deps.entityCollidersOf,
|
|
87
89
|
}, from, aim, policy ?? defaultPolicy);
|
|
88
90
|
}
|
|
89
91
|
const internalSceneRaycast = deps.sceneRaycast ??
|
|
@@ -149,15 +151,26 @@ export function createProjectileSystem(deps) {
|
|
|
149
151
|
if (hit.targetKind === "entity" && spreadRad > 0) {
|
|
150
152
|
const position = deps.spatial.positionOf(hit.instanceId);
|
|
151
153
|
if (position !== undefined) {
|
|
152
|
-
const
|
|
153
|
-
const
|
|
154
|
-
const
|
|
154
|
+
const colliders = resolveColliders(deps.entityCollidersOf?.(hit.instanceId) ?? defaultEntityColliders());
|
|
155
|
+
const collider = colliders.find((candidate) => candidate.name === hit.colliderName) ?? colliders[0];
|
|
156
|
+
const localOffset = collider?.shape.offset ?? [0, 0, 0];
|
|
157
|
+
const slack = collider === undefined
|
|
158
|
+
? BASE_HIT_RADIUS
|
|
159
|
+
: collider.shape.kind === "sphere"
|
|
160
|
+
? collider.shape.radius
|
|
161
|
+
: Math.hypot(...collider.shape.halfExtents);
|
|
162
|
+
const rotationY = deps.rotationYOf?.(hit.instanceId) ?? 0;
|
|
163
|
+
const cos = Math.cos(rotationY);
|
|
164
|
+
const sin = Math.sin(rotationY);
|
|
165
|
+
const dx = position[0] + localOffset[0] * cos + localOffset[2] * sin - origin[0];
|
|
166
|
+
const dy = position[1] + localOffset[1] - origin[1];
|
|
167
|
+
const dz = position[2] - localOffset[0] * sin + localOffset[2] * cos - origin[2];
|
|
155
168
|
const along = dx * direction[0] + dy * direction[1] + dz * direction[2];
|
|
156
169
|
const px = dx - direction[0] * along;
|
|
157
170
|
const py = dy - direction[1] * along;
|
|
158
171
|
const pz = dz - direction[2] * along;
|
|
159
172
|
const perpendicular = Math.sqrt(px * px + py * py + pz * pz);
|
|
160
|
-
if (perpendicular >
|
|
173
|
+
if (perpendicular > slack + Math.tan(spreadRad) * along)
|
|
161
174
|
continue;
|
|
162
175
|
}
|
|
163
176
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** Tuning for `createRegenShield`: pool size, refill rate, and the post-damage grace period. */
|
|
2
|
+
export interface RegenShieldConfig {
|
|
3
|
+
max: number;
|
|
4
|
+
/** Points restored per second once the post-damage grace period has elapsed. */
|
|
5
|
+
regenPerSecond: number;
|
|
6
|
+
/** Grace period after the last damage before regen resumes. */
|
|
7
|
+
regenDelayMs: number;
|
|
8
|
+
/** Starting value; defaults to `max` (full). */
|
|
9
|
+
current?: number;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* A shield pool that stops regenerating for `regenDelayMs` after every hit, then refills at
|
|
13
|
+
* `regenPerSecond` — the delayed-regen primitive that replaces snapshot-comparing stat values per
|
|
14
|
+
* tick to detect "damage taken" (#536.3). `damage` resets the grace timer; `tick` counts it down and
|
|
15
|
+
* regenerates once it elapses.
|
|
16
|
+
*/
|
|
17
|
+
export interface RegenShield {
|
|
18
|
+
current(): number;
|
|
19
|
+
max(): number;
|
|
20
|
+
setMax(value: number): void;
|
|
21
|
+
fraction(): number;
|
|
22
|
+
isFull(): boolean;
|
|
23
|
+
isBroken(): boolean;
|
|
24
|
+
/** True while the post-damage grace period is suppressing regen. */
|
|
25
|
+
suppressed(): boolean;
|
|
26
|
+
/** Absorb `amount` and reset the regen grace timer; returns damage that got through (0 while the shield holds). */
|
|
27
|
+
damage(amount: number): number;
|
|
28
|
+
/** Add `amount` without touching the regen timer (a pickup or scripted refill). */
|
|
29
|
+
restore(amount: number): void;
|
|
30
|
+
set(value: number): void;
|
|
31
|
+
/** Advance time: count down the grace period, then regenerate once it has elapsed. */
|
|
32
|
+
tick(dtSeconds: number): void;
|
|
33
|
+
}
|
|
34
|
+
/** Builds a {@link RegenShield} that suppresses regen for `regenDelayMs` after each hit. */
|
|
35
|
+
export declare function createRegenShield(config: RegenShieldConfig): RegenShield;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** Builds a {@link RegenShield} that suppresses regen for `regenDelayMs` after each hit. */
|
|
2
|
+
export function createRegenShield(config) {
|
|
3
|
+
let max = Math.max(0, config.max);
|
|
4
|
+
const regenPerSecond = Math.max(0, config.regenPerSecond);
|
|
5
|
+
const regenDelayMs = Math.max(0, config.regenDelayMs);
|
|
6
|
+
function clamp(value, ceiling) {
|
|
7
|
+
return Math.max(0, Math.min(ceiling, value));
|
|
8
|
+
}
|
|
9
|
+
let current = clamp(config.current ?? max, max);
|
|
10
|
+
let sinceDamageMs = regenDelayMs;
|
|
11
|
+
return {
|
|
12
|
+
current: () => current,
|
|
13
|
+
max: () => max,
|
|
14
|
+
setMax(value) {
|
|
15
|
+
max = Math.max(0, value);
|
|
16
|
+
current = clamp(current, max);
|
|
17
|
+
},
|
|
18
|
+
fraction: () => (max <= 0 ? 0 : current / max),
|
|
19
|
+
isFull: () => current >= max,
|
|
20
|
+
isBroken: () => current <= 0,
|
|
21
|
+
suppressed: () => sinceDamageMs < regenDelayMs,
|
|
22
|
+
damage(amount) {
|
|
23
|
+
if (amount <= 0)
|
|
24
|
+
return 0;
|
|
25
|
+
sinceDamageMs = 0;
|
|
26
|
+
const overflow = Math.max(0, amount - current);
|
|
27
|
+
current = clamp(current - amount, max);
|
|
28
|
+
return overflow;
|
|
29
|
+
},
|
|
30
|
+
restore(amount) {
|
|
31
|
+
if (amount > 0)
|
|
32
|
+
current = clamp(current + amount, max);
|
|
33
|
+
},
|
|
34
|
+
set(value) {
|
|
35
|
+
current = clamp(value, max);
|
|
36
|
+
},
|
|
37
|
+
tick(dtSeconds) {
|
|
38
|
+
if (dtSeconds <= 0)
|
|
39
|
+
return;
|
|
40
|
+
sinceDamageMs += dtSeconds * 1000;
|
|
41
|
+
if (sinceDamageMs >= regenDelayMs)
|
|
42
|
+
current = clamp(current + regenPerSecond * dtSeconds, max);
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|