@jgengine/core 0.18.0 → 0.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. package/CHANGELOG.md +114 -2
  2. package/dist/ai/decisionGraph.d.ts +52 -0
  3. package/dist/ai/decisionGraph.js +82 -0
  4. package/dist/ai/mobBrainGraph.d.ts +3 -0
  5. package/dist/ai/mobBrainGraph.js +25 -0
  6. package/dist/ai/tacticalQueries.d.ts +30 -0
  7. package/dist/ai/tacticalQueries.js +45 -0
  8. package/dist/anim/animGraph.d.ts +144 -0
  9. package/dist/anim/animGraph.js +286 -0
  10. package/dist/anim/ikSolver.d.ts +51 -0
  11. package/dist/anim/ikSolver.js +124 -0
  12. package/dist/anim/locomotionGraph.d.ts +26 -0
  13. package/dist/anim/locomotionGraph.js +33 -0
  14. package/dist/assets/spriteAtlas.d.ts +21 -0
  15. package/dist/assets/spriteAtlas.js +103 -0
  16. package/dist/audio/audioFalloff.d.ts +10 -0
  17. package/dist/economy/currency.d.ts +16 -2
  18. package/dist/economy/currency.js +55 -14
  19. package/dist/economy/wallet.d.ts +5 -4
  20. package/dist/economy/wallet.js +8 -4
  21. package/dist/editor/commandApply.js +1 -0
  22. package/dist/editor/commands.d.ts +4 -1
  23. package/dist/editor/document.d.ts +2 -1
  24. package/dist/editor/document.js +89 -0
  25. package/dist/editor/environment.d.ts +3 -0
  26. package/dist/editor/environment.js +16 -0
  27. package/dist/editor/index.d.ts +4 -2
  28. package/dist/editor/index.js +1 -1
  29. package/dist/editor/types.d.ts +16 -0
  30. package/dist/game/cameraConfig.d.ts +8 -0
  31. package/dist/game/chat.d.ts +22 -1
  32. package/dist/game/chat.js +48 -19
  33. package/dist/game/controlGate.d.ts +6 -0
  34. package/dist/game/controlGate.js +30 -1
  35. package/dist/game/defineGame.d.ts +10 -1
  36. package/dist/game/events.d.ts +8 -1
  37. package/dist/game/pixelPerfect.d.ts +25 -0
  38. package/dist/game/pixelPerfect.js +29 -0
  39. package/dist/game/playableGame.d.ts +97 -3
  40. package/dist/game/systemRuntime.js +6 -1
  41. package/dist/input/actionBindings.d.ts +2 -1
  42. package/dist/input/actionBindings.js +10 -8
  43. package/dist/input/actionContexts.d.ts +21 -0
  44. package/dist/input/actionContexts.js +56 -0
  45. package/dist/input/gamepadModel.d.ts +45 -0
  46. package/dist/input/gamepadModel.js +69 -0
  47. package/dist/meta/changelog.d.ts +1 -1
  48. package/dist/meta/changelog.js +85 -1
  49. package/dist/movement/characterController.d.ts +76 -0
  50. package/dist/movement/characterController.js +216 -0
  51. package/dist/movement/freeFlight.d.ts +119 -0
  52. package/dist/movement/freeFlight.js +294 -0
  53. package/dist/movement/playerMovement.d.ts +10 -2
  54. package/dist/movement/playerMovement.js +218 -4
  55. package/dist/movement/serverStep.d.ts +5 -0
  56. package/dist/movement/serverStep.js +6 -0
  57. package/dist/movement/solidObstacles.d.ts +30 -7
  58. package/dist/movement/solidObstacles.js +27 -4
  59. package/dist/multiplayer/poseSyncGate.d.ts +3 -1
  60. package/dist/multiplayer/poseSyncGate.js +9 -1
  61. package/dist/multiplayer/presenceContract.d.ts +6 -0
  62. package/dist/multiplayer/presenceModel.d.ts +2 -0
  63. package/dist/multiplayer/presenceModel.js +11 -0
  64. package/dist/nav/navMesh.d.ts +33 -0
  65. package/dist/nav/navMesh.js +164 -0
  66. package/dist/physics/flightDynamics.d.ts +8 -0
  67. package/dist/physics/kinematicVehicle.d.ts +6 -1
  68. package/dist/physics/physicsBackend.d.ts +191 -0
  69. package/dist/physics/physicsBackend.js +49 -0
  70. package/dist/physics/physicsBackendConformance.d.ts +27 -0
  71. package/dist/physics/physicsBackendConformance.js +117 -0
  72. package/dist/physics/physicsWorldBackend.d.ts +44 -0
  73. package/dist/physics/physicsWorldBackend.js +485 -0
  74. package/dist/physics/worldColliders.d.ts +13 -0
  75. package/dist/physics/worldColliders.js +96 -0
  76. package/dist/random/rng.d.ts +12 -1
  77. package/dist/random/rng.js +20 -3
  78. package/dist/render/environment.d.ts +23 -0
  79. package/dist/render/environment.js +1 -0
  80. package/dist/render/lookPreset.d.ts +11 -5
  81. package/dist/render/lookPreset.js +29 -4
  82. package/dist/render/postProcessing.d.ts +10 -1
  83. package/dist/render/sprite2d.d.ts +38 -0
  84. package/dist/render/sprite2d.js +40 -0
  85. package/dist/runtime/adapter.d.ts +20 -1
  86. package/dist/runtime/adapter.js +8 -1
  87. package/dist/runtime/commandInput.d.ts +15 -0
  88. package/dist/runtime/commandInput.js +13 -0
  89. package/dist/runtime/commandRunner.d.ts +12 -7
  90. package/dist/runtime/commandRunner.js +7 -5
  91. package/dist/runtime/gameContext.js +11 -1
  92. package/dist/runtime/gameContextTypes.d.ts +14 -4
  93. package/dist/runtime/gameRuntime.d.ts +20 -4
  94. package/dist/runtime/gameRuntime.js +17 -4
  95. package/dist/runtime/headlessRunner.d.ts +10 -0
  96. package/dist/runtime/headlessRunner.js +26 -8
  97. package/dist/runtime/hostPolicy.d.ts +3 -2
  98. package/dist/runtime/hostPolicy.js +8 -10
  99. package/dist/runtime/hostedGameRunner.js +34 -6
  100. package/dist/runtime/hostedWorldSession.d.ts +12 -16
  101. package/dist/runtime/hostedWorldSession.js +29 -6
  102. package/dist/runtime/hostedWorldStore.d.ts +20 -0
  103. package/dist/runtime/hostedWorldStore.js +1 -0
  104. package/dist/runtime/inputRecorder.d.ts +30 -0
  105. package/dist/runtime/inputRecorder.js +59 -0
  106. package/dist/runtime/inputSnapshot.d.ts +8 -0
  107. package/dist/runtime/inputSnapshot.js +1 -0
  108. package/dist/runtime/poseInterpolation.d.ts +36 -0
  109. package/dist/runtime/poseInterpolation.js +91 -0
  110. package/dist/runtime/prediction.d.ts +45 -0
  111. package/dist/runtime/prediction.js +62 -0
  112. package/dist/runtime/simContext.d.ts +45 -0
  113. package/dist/runtime/simContext.js +65 -0
  114. package/dist/runtime/simLoop.d.ts +52 -0
  115. package/dist/runtime/simLoop.js +72 -0
  116. package/dist/runtime/simSnapshot.d.ts +32 -0
  117. package/dist/runtime/simSnapshot.js +60 -0
  118. package/dist/runtime/snapshot.d.ts +8 -0
  119. package/dist/runtime/snapshotBuffer.d.ts +33 -0
  120. package/dist/runtime/snapshotBuffer.js +58 -0
  121. package/dist/runtime/transport.d.ts +32 -2
  122. package/dist/runtime/worldChunks.d.ts +2 -0
  123. package/dist/runtime/worldChunks.js +14 -0
  124. package/dist/runtime/worldProjection.d.ts +9 -0
  125. package/dist/runtime/worldProjection.js +17 -1
  126. package/dist/runtime/worldSnapshot.d.ts +6 -0
  127. package/dist/runtime/worldSnapshot.js +3 -0
  128. package/dist/scene/behaviorRuntime.d.ts +19 -1
  129. package/dist/scene/behaviorRuntime.js +55 -4
  130. package/dist/scene/behaviors.d.ts +8 -1
  131. package/dist/scene/builtinSceneKinds.js +10 -0
  132. package/dist/sensor/perception.d.ts +60 -0
  133. package/dist/sensor/perception.js +142 -0
  134. package/dist/settings/graphicsProfile.d.ts +19 -0
  135. package/dist/settings/graphicsProfile.js +36 -0
  136. package/dist/time/accrueSince.d.ts +10 -0
  137. package/dist/time/accrueSince.js +9 -0
  138. package/dist/time/rateWindow.d.ts +16 -0
  139. package/dist/time/rateWindow.js +14 -0
  140. package/dist/time/serverTick.d.ts +21 -3
  141. package/dist/time/serverTick.js +14 -13
  142. package/dist/vfx/particleDirector.d.ts +68 -0
  143. package/dist/vfx/particleDirector.js +81 -0
  144. package/dist/world/buildings.d.ts +12 -0
  145. package/dist/world/buildings.js +43 -3
  146. package/dist/world/cityContent.js +103 -25
  147. package/dist/world/cityKind.d.ts +132 -0
  148. package/dist/world/cityKind.js +315 -15
  149. package/dist/world/features.d.ts +27 -1
  150. package/dist/world/features.js +7 -2
  151. package/dist/world/placement.d.ts +7 -0
  152. package/dist/world/placement.js +2 -0
  153. package/dist/world/placementController.d.ts +1 -1
  154. package/dist/world/placementController.js +7 -0
  155. package/dist/world/raceCircuit.d.ts +168 -0
  156. package/dist/world/raceCircuit.js +797 -0
  157. package/dist/world/terrain.d.ts +5 -1
  158. package/dist/world/terrain.js +5 -0
  159. package/dist/world/territory.d.ts +97 -0
  160. package/dist/world/territory.js +182 -0
  161. package/dist/world.d.ts +5 -3
  162. package/dist/world.js +4 -2
  163. package/package.json +1 -1
  164. package/skills/ce-handoff/SKILL.md +1 -1
  165. package/skills/game-design/SKILL.md +11 -1
  166. package/skills/jgengine/SKILL.md +2 -0
  167. package/skills/jgengine/api.md +94 -19
  168. package/skills/jgengine/capabilities.md +28 -0
  169. package/skills/jgengine-gameplay/SKILL.md +10 -0
  170. package/skills/jgengine-gameplay/api.md +40 -18
  171. package/skills/jgengine-gameplay/capabilities.md +12 -0
  172. package/skills/jgengine-world/SKILL.md +6 -0
  173. package/skills/jgengine-world/api.md +312 -12
  174. package/skills/jgengine-world/capabilities.md +102 -0
package/dist/game/chat.js CHANGED
@@ -1,6 +1,7 @@
1
- export const DEFAULT_CHAT_RATE_LIMIT = { count: 10, perMs: 10_000 };
1
+ import { decideRateWindow } from "../time/rateWindow.js";
2
+ export const DEFAULT_CHAT_RATE_LIMIT = { count: 1, perMs: 2_000 };
2
3
  export const DEFAULT_CHAT_HISTORY_LIMIT = 100;
3
- export const DEFAULT_CHAT_BODY_LENGTH = 500;
4
+ export const DEFAULT_CHAT_BODY_LENGTH = 240;
4
5
  export const DEFAULT_PROXIMITY_CHAT_RADIUS = 20;
5
6
  export const WHISPER_CHANNEL_PREFIX = "whisper:";
6
7
  export function whisperChannelId(a, b) {
@@ -11,15 +12,17 @@ export function createChatRateLimiter(limit) {
11
12
  const windows = new Map();
12
13
  return {
13
14
  allow(key, atMs) {
14
- const cutoff = atMs - limit.perMs;
15
- const stamps = (windows.get(key) ?? []).filter((stamp) => stamp > cutoff);
16
- if (stamps.length >= limit.count) {
17
- windows.set(key, stamps);
18
- return false;
19
- }
20
- stamps.push(atMs);
21
- windows.set(key, stamps);
22
- return true;
15
+ const result = decideRateWindow(windows.get(key) ?? [], atMs, { windowMs: limit.perMs, max: limit.count });
16
+ windows.set(key, result.timestamps);
17
+ return result.allowed;
18
+ },
19
+ snapshot() {
20
+ return Object.fromEntries(Array.from(windows, ([key, stamps]) => [key, [...stamps]]));
21
+ },
22
+ restore(state) {
23
+ windows.clear();
24
+ for (const [key, stamps] of Object.entries(state))
25
+ windows.set(key, [...stamps]);
23
26
  },
24
27
  };
25
28
  }
@@ -93,11 +96,10 @@ export function createChat(deps) {
93
96
  messagesByChannel.set(channelId, ring);
94
97
  }
95
98
  function sendResolved(fromUserId, channelId, body, def) {
96
- const trimmed = body.trim();
97
- if (trimmed.length === 0)
98
- return { reason: "empty message" };
99
- if (trimmed.length > maxBodyLength)
100
- return { reason: "message too long" };
99
+ const validation = validateChatMessage(body, { maxLength: maxBodyLength });
100
+ if (!validation.ok)
101
+ return { reason: validation.reason };
102
+ const trimmed = validation.text;
101
103
  let filteredBody = trimmed;
102
104
  if (filter !== undefined) {
103
105
  const filtered = filter.apply(trimmed);
@@ -136,10 +138,18 @@ export function createChat(deps) {
136
138
  channels() {
137
139
  return Array.from(channelDefs.values(), (def) => ({ ...def }));
138
140
  },
139
- send(fromUserId, channelId, body) {
141
+ send(fromUserId, channelOrBody, suppliedBody) {
142
+ const channelId = suppliedBody === undefined ? "global" : channelOrBody;
143
+ const body = suppliedBody ?? channelOrBody;
140
144
  const def = channelDefs.get(channelId) ?? null;
141
145
  return sendResolved(fromUserId, channelId, body, def);
142
146
  },
147
+ recent(options) {
148
+ return this.history(options?.channelId ?? "global", {
149
+ limit: Math.max(0, Math.min(100, Math.floor(options?.limit ?? 50) || 0)),
150
+ viewerUserId: options?.viewerUserId,
151
+ });
152
+ },
143
153
  whisper(fromUserId, toUserId, body) {
144
154
  if (fromUserId === toUserId)
145
155
  return { reason: "cannot whisper yourself" };
@@ -153,14 +163,15 @@ export function createChat(deps) {
153
163
  ? ring
154
164
  : ring.filter((message) => !hasBlocked(viewerUserId, message.fromUserId));
155
165
  const limit = options?.limit;
156
- return limit === undefined ? visible.slice() : visible.slice(-limit);
166
+ return limit === undefined ? visible.slice() : limit <= 0 ? [] : visible.slice(-limit);
157
167
  },
158
168
  snapshot() {
159
169
  const messages = {};
160
170
  for (const [channelId, ring] of messagesByChannel) {
161
171
  messages[channelId] = ring.map((message) => ({ ...message }));
162
172
  }
163
- return { messages, counter };
173
+ const rateWindows = Object.fromEntries(Array.from(limiters, ([id, limiter]) => [id, limiter.snapshot()]));
174
+ return { messages, counter, rateWindows };
164
175
  },
165
176
  hydrate(data) {
166
177
  messagesByChannel.clear();
@@ -168,6 +179,24 @@ export function createChat(deps) {
168
179
  messagesByChannel.set(channelId, ring.map((message) => ({ ...message })));
169
180
  }
170
181
  counter = data.counter;
182
+ limiters.clear();
183
+ for (const [channelId, state] of Object.entries(data.rateWindows ?? {})) {
184
+ limiterFor(channelId, channelDefs.get(channelId)?.rateLimit ?? defaultRateLimit).restore(state);
185
+ }
171
186
  },
172
187
  };
173
188
  }
189
+ /** Strip control characters and enforce the shared chat message policy before storage or broadcast. */
190
+ export function validateChatMessage(value, options = {}) {
191
+ const maxLength = options.maxLength ?? DEFAULT_CHAT_BODY_LENGTH;
192
+ if (!Number.isSafeInteger(maxLength) || maxLength < 1)
193
+ throw new RangeError("Invalid chat length limit");
194
+ if (typeof value !== "string")
195
+ return { ok: false, reason: "invalid message" };
196
+ const text = value.replace(/[\u0000-\u001f\u007f-\u009f]/g, "").trim();
197
+ if (!text.length)
198
+ return { ok: false, reason: "empty message" };
199
+ if (text.length > maxLength)
200
+ return { ok: false, reason: "message too long" };
201
+ return { ok: true, text };
202
+ }
@@ -1,4 +1,10 @@
1
1
  import type { GameContext } from "../runtime/gameContext.js";
2
+ import { type ActionContextStack } from "../input/actionContexts.js";
3
+ import type { ActionCodesMap } from "../input/actionBindings.js";
2
4
  export declare const PLAY_CONTROLS_STORE_KEY = "jg.playControls";
5
+ /** Returns the context stack associated with a game context. */
6
+ export declare function actionContextStack(ctx: GameContext): ActionContextStack;
7
+ /** Applies active contexts to a base action map for shell input tracking. */
8
+ export declare function activeActionCodes(ctx: GameContext, base: ActionCodesMap): ActionCodesMap;
3
9
  export declare function setPlayControlsActive(ctx: GameContext, active: boolean): void;
4
10
  export declare function playControlsActive(ctx: GameContext): boolean;
@@ -1,7 +1,36 @@
1
+ import { createActionContextStack } from "../input/actionContexts.js";
1
2
  export const PLAY_CONTROLS_STORE_KEY = "jg.playControls";
3
+ const ACTION_CONTEXTS = new WeakMap();
4
+ function contextsFor(ctx) {
5
+ let stack = ACTION_CONTEXTS.get(ctx);
6
+ if (stack === undefined) {
7
+ stack = createActionContextStack();
8
+ ACTION_CONTEXTS.set(ctx, stack);
9
+ }
10
+ return stack;
11
+ }
12
+ /** Returns the context stack associated with a game context. */
13
+ export function actionContextStack(ctx) {
14
+ return contextsFor(ctx);
15
+ }
16
+ /** Applies active contexts to a base action map for shell input tracking. */
17
+ export function activeActionCodes(ctx, base) {
18
+ const stack = contextsFor(ctx);
19
+ const layered = createActionContextStack();
20
+ layered.push({ id: "__jg_base_actions", codes: base, passthrough: true });
21
+ for (const context of stack.snapshot().contexts)
22
+ layered.push(context);
23
+ return layered.active();
24
+ }
2
25
  export function setPlayControlsActive(ctx, active) {
26
+ const stack = contextsFor(ctx);
27
+ if (active)
28
+ stack.pop("menu");
29
+ else
30
+ stack.push({ id: "menu", codes: {}, passthrough: false });
3
31
  ctx.game.store.set(PLAY_CONTROLS_STORE_KEY, active);
4
32
  }
5
33
  export function playControlsActive(ctx) {
6
- return ctx.game.store.get(PLAY_CONTROLS_STORE_KEY) !== false;
34
+ return ctx.game.store.get(PLAY_CONTROLS_STORE_KEY) !== false &&
35
+ !actionContextStack(ctx).snapshot().contexts.some((context) => context.id === "menu");
7
36
  }
@@ -12,7 +12,10 @@ import { type AssetCatalog, type ModelAssetRef } from "../scene/assetCatalog.js"
12
12
  import { type EntityStore } from "../scene/entityStore.js";
13
13
  import type { StoreHandle } from "../store/defineStore.js";
14
14
  import type { TimeConfig } from "../time/simClock.js";
15
+ import type { SimulationConfig } from "../runtime/simLoop.js";
15
16
  import type { WorldFeature } from "../world/features.js";
17
+ import type { PhysicsBackend } from "../physics/physicsBackend.js";
18
+ import type { CharacterControllerConfig } from "../movement/characterController.js";
16
19
  /** Tunes offline whole-world save (`defineGameDefinition({ persist })`). Offline games already autosave to `localStorage` by default — pass this only to tune it (or `persist: false` to opt out). Defaults: continuous `autosave` to `localStorage`, one slot, no version. */
17
20
  export interface PersistConfig {
18
21
  /** `"autosave"` (default) writes on a trailing timer while the world changes — at most once per `autosaveMs`, so a never-idle world (AI, physics, day/night) still persists; `"manual"` writes only on `ctx.game.save.checkpoint()` (save points / quest triggers). */
@@ -24,10 +27,14 @@ export interface PersistConfig {
24
27
  /** Autosave debounce in ms (autosave mode). Default 3000. */
25
28
  autosaveMs?: number;
26
29
  }
27
- /** World gravity and jump tuning, plus scene-object collision opt-ins, for the game's physics step. */
30
+ /** World gravity and jump tuning, plus the optional rigid-body backend and character capsule. */
28
31
  export interface PhysicsConfig {
29
32
  gravity?: number;
30
33
  jumpVelocity?: number;
34
+ /** Physics solver used by the shell movement controller and the context's physics stage. */
35
+ backend?: PhysicsBackend;
36
+ /** Capsule dimensions for players using the configured physics backend. */
37
+ controller?: CharacterControllerConfig;
31
38
  /** Opt placed scene objects into projectile raycasts as blocking cover AABBs. Default false — enabling changes where shots land. */
32
39
  projectileObstacles?: boolean;
33
40
  }
@@ -180,6 +187,8 @@ export interface GameDefinition<TAssetRef extends ModelAssetRef = ModelAssetRef,
180
187
  physics?: PhysicsConfig;
181
188
  /** Simulation clock: real→game time scale, selectable speeds, calendar. Exposed as `ctx.time`; the shell feeds its scaled dt to `loop.onTick`. */
182
189
  time?: TimeConfig;
190
+ /** How the simulation is stepped — a fixed `hz` with render interpolation, or `"variable"` (default) once per frame. Exposed as `ctx.sim`. */
191
+ simulation?: SimulationConfig;
183
192
  /** Per-action ring-buffer capacity for `ctx.game.feed`. Default 20. */
184
193
  feed?: GameFeedOptions;
185
194
  inventories?: Record<string, InventoryDeclaration>;
@@ -12,11 +12,17 @@ export interface EntityDiedEvent {
12
12
  position: [number, number, number];
13
13
  serverId?: string;
14
14
  }
15
- /** Request that an entity's rig play a one-shot animation clip bound to `event` in its `animation.oneShots` (e.g. an "attack" swing); the shell resolves the clip and plays it once over the locomotion state. */
15
+ /** Request that an entity's rig play a one-shot animation clip bound to `event` in its `animation.oneShots` (e.g. an "attack" swing); the shell resolves the clip and plays it once over the locomotion state. With an `animation.graph`, `event` arms the trigger of that name. */
16
16
  export interface EntityAnimationEvent {
17
17
  instanceId: string;
18
18
  event: string;
19
19
  }
20
+ /** A named clip moment (`AnimGraph.events`) the rig just crossed — foot plant, hit frame, reload point. Audio and combat listen by `name`. */
21
+ export interface AnimationClipEvent {
22
+ instanceId: string;
23
+ name: string;
24
+ clip: string;
25
+ }
20
26
  /**
21
27
  * One granted reward: an inventory `item` id or a `currency` id, plus how many. Named because every
22
28
  * consumer of `loot.granted` needs it — a toast renderer, a loot log, a session summary — and each
@@ -269,6 +275,7 @@ export interface GameEventMap {
269
275
  "audio.loopSet": AudioLoopSetEvent;
270
276
  "audio.loopStop": AudioLoopStopEvent;
271
277
  "entity.animation": EntityAnimationEvent;
278
+ "animation.event": AnimationClipEvent;
272
279
  }
273
280
  export type GameEventHandler<TPayload> = (payload: TPayload) => void;
274
281
  export interface GameEvents<TMap extends GameEventMap = GameEventMap> {
@@ -0,0 +1,25 @@
1
+ /** Viewport dimensions in device-independent canvas pixels. */
2
+ export interface PixelPerfectViewport {
3
+ width: number;
4
+ height: number;
5
+ }
6
+ /** The orthographic bounds and scale derived from a pixel-perfect camera config. */
7
+ export interface PixelPerfectFrustum {
8
+ left: number;
9
+ right: number;
10
+ top: number;
11
+ bottom: number;
12
+ zoom: number;
13
+ scale: number;
14
+ }
15
+ /**
16
+ * Computes an orthographic frustum whose world units map to the requested pixel density.
17
+ * Integer scaling chooses the largest whole-number density multiplier available in the viewport.
18
+ */
19
+ export declare function pixelPerfectFrustum(viewport: PixelPerfectViewport, pixelsPerUnit: number, integerScale?: boolean): PixelPerfectFrustum;
20
+ /** Snaps a camera position to the authored pixel grid without changing its orientation. */
21
+ export declare function snapPixelPerfectPosition<T extends {
22
+ x: number;
23
+ y: number;
24
+ z: number;
25
+ }>(position: T, pixelsPerUnit: number): T;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Computes an orthographic frustum whose world units map to the requested pixel density.
3
+ * Integer scaling chooses the largest whole-number density multiplier available in the viewport.
4
+ */
5
+ export function pixelPerfectFrustum(viewport, pixelsPerUnit, integerScale = false) {
6
+ const width = Math.max(1, viewport.width);
7
+ const height = Math.max(1, viewport.height);
8
+ const density = Math.max(1e-6, pixelsPerUnit);
9
+ const scale = integerScale ? Math.max(1, Math.floor(Math.min(width, height) / density)) : 1;
10
+ const zoom = density * scale;
11
+ return {
12
+ left: -width / (2 * zoom),
13
+ right: width / (2 * zoom),
14
+ top: height / (2 * zoom),
15
+ bottom: -height / (2 * zoom),
16
+ zoom,
17
+ scale,
18
+ };
19
+ }
20
+ /** Snaps a camera position to the authored pixel grid without changing its orientation. */
21
+ export function snapPixelPerfectPosition(position, pixelsPerUnit) {
22
+ const density = Math.max(1e-6, pixelsPerUnit);
23
+ return {
24
+ ...position,
25
+ x: Math.round(position.x * density) / density,
26
+ y: Math.round(position.y * density) / density,
27
+ z: Math.round(position.z * density) / density,
28
+ };
29
+ }
@@ -1,14 +1,20 @@
1
+ import type { AnimGraph } from "../anim/animGraph.js";
1
2
  import type { AudioBusDef, SoundDef } from "../audio/audioFalloff.js";
2
3
  import type { MusicTheme } from "../audio/music.js";
3
4
  import type { EditorCatalogDefinition, EditorDocument } from "../editor/types.js";
4
5
  import type { PostProcessingConfig } from "../render/postProcessing.js";
6
+ import type { EnvironmentSource } from "../render/environment.js";
7
+ import type { SpriteAtlas } from "../assets/spriteAtlas.js";
5
8
  import type { LookPreset } from "../render/lookPreset.js";
6
9
  import type { TouchControlsConfig } from "../input/touchScheme.js";
7
10
  import type { GameSettingsConfig } from "../settings/settingsModel.js";
11
+ import type { GraphicsProfile } from "../settings/graphicsProfile.js";
8
12
  import type { GameOrientation } from "../ui/orientation.js";
9
13
  import type { HudPlatform, HudViewportConfig } from "../ui/hudScale.js";
10
14
  import type { PositionedPrompt } from "../interaction/proximityPrompt.js";
15
+ import type { ChaseCameraTuning } from "../runtime/cameraDirector.js";
11
16
  import type { CatalogEntityRole, GameContext, GameContextContent } from "../runtime/gameContext.js";
17
+ import type { FreeFlightBindings } from "../movement/freeFlight.js";
12
18
  import type { ModelDims } from "../scene/assetCatalog.js";
13
19
  import type { PartMotionParams, PartRole } from "./partAnimation.js";
14
20
  import type { CollisionMeshData } from "../scene/collisionMesh.js";
@@ -52,6 +58,11 @@ export interface EntitySpriteConfig {
52
58
  width: number;
53
59
  height: number;
54
60
  y: number;
61
+ /** Optional atlas-backed animation. The atlas image supersedes `url` when present. */
62
+ clip?: {
63
+ atlas: SpriteAtlas;
64
+ animation: string;
65
+ };
55
66
  }
56
67
  export interface WorldItemRenderConfig {
57
68
  /** Baseline rarity→color/beam/label render binding (#32); the game's rarity palette. */
@@ -119,6 +130,14 @@ export interface ModelAnimationConfig {
119
130
  * `string[]` picks a random variant per trigger. `death` clamps on its final frame instead of returning.
120
131
  */
121
132
  oneShots?: Record<string, string | readonly string[]>;
133
+ /**
134
+ * A full animation graph (`@jgengine/core/anim/animGraph`) — layered state machine with blend trees, data
135
+ * transitions, triggers, and clip events. Takes over from `states`/`oneShots` when set: the shell feeds the
136
+ * entity's smoothed ground speed as the `speed` parameter plus anything under the entity blackboard key
137
+ * `ANIM_PARAMS_KEY`, arms triggers from `entity.animation` (`hit` and `death` from their combat events), and
138
+ * emits `animation.event` for clip events. `locomotionGraph()` builds the default idle/walk/run graph.
139
+ */
140
+ graph?: AnimGraph;
122
141
  }
123
142
  /**
124
143
  * Real PBR map URLs (e.g. `buildMaterialCatalog(...).resolve(id)!.maps` from `@jgengine/assets`)
@@ -130,8 +149,11 @@ export interface ModelMaterialMaps {
130
149
  normal?: string;
131
150
  roughness?: string;
132
151
  ao?: string;
152
+ metalness?: string;
153
+ emissive?: string;
154
+ height?: string;
133
155
  }
134
- /** Per-entity PBR material override (#151.3) applied to every `MeshStandardMaterial` in the model's cloned scene graph. */
156
+ /** Per-entity PBR material override (#151.3) applied to every standard or physical material in the model's cloned scene graph. */
135
157
  export interface ModelMaterialOverride {
136
158
  color?: string;
137
159
  metalness?: number;
@@ -203,6 +225,8 @@ export interface ModelConfig {
203
225
  collisionMesh?: CollisionMeshData;
204
226
  /** Per-entity PBR tint/finish override (#151.3); cloned onto each `MeshStandardMaterial` in the model so shared GLTF caches stay untouched. */
205
227
  material?: ModelMaterialOverride;
228
+ /** Shadow participation of every mesh in the model. Default `"both"` (casts and receives); `"none"` for foliage cards, decals, or viewmodels that must never shadow the world. */
229
+ shadows?: "cast" | "receive" | "both" | "none";
206
230
  /**
207
231
  * Plays a GLTF animation clip on the model when the source has any (skinned or not).
208
232
  * A model resolved from a catalog id whose index carries `clips` animates automatically —
@@ -212,6 +236,17 @@ export interface ModelConfig {
212
236
  * rig's bind pose.
213
237
  */
214
238
  animation?: ModelAnimationConfig | "auto" | "none";
239
+ /** Optional runtime inverse-kinematics targets for a rigged model. Bone names are resolved on the loaded scene. */
240
+ ik?: {
241
+ feet: readonly {
242
+ root: string;
243
+ mid: string;
244
+ tip: string;
245
+ }[];
246
+ lookAt?: {
247
+ bone: string;
248
+ };
249
+ };
215
250
  /** Props/weapons parented to named bones on this model's rig; each follows its bone through animation. */
216
251
  attachments?: readonly ModelAttachment[];
217
252
  /** Static kit-of-parts pieces stacked at fixed local offsets — no bone/rig involved. Use this for a compound entity assembled from several modular meshes (a castle keep from base + mid + roof pieces); use `attachments` for props parented to an animated rig's bones. Tag parts with a `role` to procedurally animate a rig-less character composition. */
@@ -255,10 +290,29 @@ export interface HemisphereLightingConfig {
255
290
  groundColor?: string;
256
291
  intensity?: number;
257
292
  }
293
+ /** A bounded dynamic point light in world space. */
294
+ export interface PointLightingConfig {
295
+ color?: string;
296
+ intensity?: number;
297
+ position: readonly [number, number, number];
298
+ distance?: number;
299
+ decay?: number;
300
+ castShadow?: boolean;
301
+ }
302
+ /** A bounded dynamic spot light in world space. */
303
+ export interface SpotLightingConfig extends PointLightingConfig {
304
+ target?: readonly [number, number, number];
305
+ angle?: number;
306
+ penumbra?: number;
307
+ }
258
308
  /** Declarative lighting replacing the shell's hardcoded ambient/directional default (#207.5); mounts regardless of world kind, only when supplied. */
259
309
  export interface LightingConfig {
260
310
  ambient?: AmbientLightingConfig;
261
311
  directional?: readonly DirectionalLightingConfig[];
312
+ point?: readonly PointLightingConfig[];
313
+ spot?: readonly SpotLightingConfig[];
314
+ /** Maximum dynamic point + spot lights mounted by the shell. Default 8. */
315
+ maxDynamicLights?: number;
262
316
  hemisphere?: HemisphereLightingConfig;
263
317
  }
264
318
  export interface BackdropFogConfig {
@@ -274,6 +328,33 @@ export interface BackdropConfig {
274
328
  sky?: SkyEnvironmentConfig;
275
329
  fog?: BackdropFogConfig;
276
330
  }
331
+ /** Free-flight families moved into the walk controller so one seam covers ground + air — creative/spectator/noclip/hover are weightless, aircraft/rotorcraft stay on `flightDynamics`. */
332
+ export interface FlightConfig {
333
+ /** Which free-flight family to use when flight is active. Default "creative". */
334
+ mode?: "creative" | "spectator" | "noclip" | "hover";
335
+ /** Horizontal base speed (units/s). Default 8. */
336
+ speed?: number;
337
+ /** Vertical base speed for ascend/descend. Default = `speed`. */
338
+ verticalSpeed?: number;
339
+ /** Sprint multiplier while shift is held. Default 1.8. */
340
+ sprintMultiplier?: number;
341
+ /** Lerp rate toward target velocity. Default 20. */
342
+ acceleration?: number;
343
+ /** Downward acceleration for hover mode. Default 0 (weightless). */
344
+ gravity?: number;
345
+ /** Upward thrust while ascending in hover. Default 32. */
346
+ thrust?: number;
347
+ /** When true, W moves along the look pitch (6DOF spectator). Default false. */
348
+ alignWithLook?: boolean;
349
+ /** Whether flight slides against world solids. Default true for creative/hover, false for spectator/noclip. */
350
+ collide?: boolean;
351
+ /** Axis bindings for this flight — which actions drive each axis. Unlisted axes keep the defaults above. */
352
+ bindings?: FreeFlightBindings;
353
+ /** Camera POV while this flight is active — chase tuning overlay applied on mount, cleared on dismount. */
354
+ camera?: ChaseCameraTuning | null;
355
+ /** Gate flight behind live game state — creative toggle, stamina check, etc. `false` stays walking. Default true when `flight` is present. */
356
+ canFly?: (ctx: GameContext) => boolean;
357
+ }
277
358
  /** Movement-control levers for the shell-driven local player walk controller. */
278
359
  export interface PlayerMovementConfig {
279
360
  /** "free" (default) moves camera-relative across the plane; "axis" locks travel to one world axis; "grid" snaps each committed position to cell centers. */
@@ -308,6 +389,15 @@ export interface PlayerMovementConfig {
308
389
  slopeSlide?: {
309
390
  maxClimbSlope?: number;
310
391
  } | boolean;
392
+ /**
393
+ * Free-flight — creative/spectator/noclip/hover — folded into the same walk controller so a game
394
+ * doesn't hand-roll a second movement loop to get Minecraft-like flight. `true` uses creative defaults;
395
+ * an object tunes speed/vertical/sprint/collide/canFly. Horizontal is always yaw-relative strafe
396
+ * (A = left, D = right — never roll/bank), vertical is Space/Ctrl, and aircraft/rotorcraft remain
397
+ * on `physics/flightDynamics`. The shell drives this automatically when present; `canFly` toggles
398
+ * between walking and flying per tick without rebuilding the config.
399
+ */
400
+ flight?: FlightConfig | boolean;
311
401
  }
312
402
  /** One frame's movement resolution handed to `PlayerMovementConfig.beforeCommit`. */
313
403
  export interface MovementCommitFrame {
@@ -412,7 +502,7 @@ export interface PlayableGame<TUi = unknown, TWorldOverlay = unknown, TRenderEnt
412
502
  /** Optional canvas-layer VFX component (e.g. traveling projectiles); receives `{ ctx }` (#542) so overlay VFX read live engine state without a separate hook or a module-global workaround. */
413
503
  WorldOverlay?: TOverlay;
414
504
  /** Replaces the default demo backdrop (ground + grid + rocks) with the game's own scene — ground, sky, structures. Camera, input, HUD, entity rendering, and the loop stay shell-provided; supply your world without forking the shell. When unset and `game.world` is an `environment()` descriptor, the shell auto-renders that world here — no manual wiring needed. */
415
- environment?: TWorldOverlay;
505
+ environment?: TWorldOverlay | EnvironmentSource;
416
506
  /** The game's authored scene document (`editor.scene.json`, normalized). When set, `defineGame` always mounts `AuthoredScene` over it (draped paths, scatter, studios, placed catalog props) — any `WorldOverlay` renders alongside it as VFX only — and the embedded editor opens it as its default layers: the zero-wiring path from document to play and edit modes. */
417
507
  editorLayers?: EditorDocument;
418
508
  /** Game-exported gameplay catalog definitions (schemas + defaults). `GameHost` forwards them to the summoned editor's Data panel; no per-game editor bootstrap needed. */
@@ -441,6 +531,8 @@ export interface PlayableGame<TUi = unknown, TWorldOverlay = unknown, TRenderEnt
441
531
  capture?: GameCaptureConfig;
442
532
  /** Cast/receive shadows across the scene (R3F Canvas shadow pass). Default true. */
443
533
  shadows?: boolean;
534
+ /** Per-quality renderer budgets; omitted tiers use {@link DEFAULT_GRAPHICS_PROFILES}. */
535
+ graphics?: Partial<Record<"low" | "medium" | "high", Partial<GraphicsProfile>>>;
444
536
  /** Pointer-driven input: click-to-move, box-select, right-click verbs, cursor aim (#22/#30/#31). */
445
537
  pointer?: PointerConfig;
446
538
  /** Touch controls on coarse-pointer devices. Unset derives a scheme from `input` (virtual joystick for movement actions, on-screen buttons for the rest); a config refines it with gestures and curated buttons; `false` opts out. */
@@ -499,10 +591,12 @@ export interface PlayableGame<TUi = unknown, TWorldOverlay = unknown, TRenderEnt
499
591
  collision?: VoxelCollisionConfig;
500
592
  /** Movement-control levers (axis/grid constraints, object collision, pre-commit hook) for the shell-driven walk controller. */
501
593
  movement?: PlayerMovementConfig;
502
- /** Default-look preset (#773). Unset/`"cinematic"` composes a real sky, a shadow-casting sun+hemisphere rig, a network-free image-based-lighting environment (soft PBR reflections), and a tuned tone-map/bloom/AO/vignette post stack, so a scene reads lit-like-a-game out of the box; `"flat"` opts out to the bare ambient+directional default. Upgraded default primitive materials (tuned PBR + subtle procedural surface detail) apply under both presets. Any explicit `lighting`/`backdrop`/`postProcessing` always wins. */
594
+ /** Graphics look profile (#1680). Unset/`"neutral"` keeps the output unstyled; `"photoreal"` and `"toon"` select authored presentation profiles, while `"cinematic"` and `"flat"` remain compatibility aliases. Explicit `lighting`/`backdrop`/`postProcessing` always wins. */
503
595
  look?: LookPreset;
504
596
  /** Declarative ambient/directional/hemisphere lighting (#207.5); replaces the shell's hardcoded default lights when present, regardless of world kind. */
505
597
  lighting?: LightingConfig;
598
+ /** Image-based environment source; omit to use the procedural gradient probe. */
599
+ environmentSource?: EnvironmentSource;
506
600
  /** Generic background/sky/fog (#207.6), applied for any world kind including a custom `environment` component. */
507
601
  backdrop?: BackdropConfig;
508
602
  /** Declarative post-processing chain (AO/bloom/tone-map/grade). When set, the shell mounts an EffectComposer and owns the render; absent leaves the renderer drawing directly (unchanged). */
@@ -1,4 +1,5 @@
1
1
  import { compileSystemSchedule, } from "./systemSchedule.js";
2
+ const MAX_FIXED_SUBSTEPS = 8;
2
3
  function resolveModule(spec, ctx) {
3
4
  if (spec === undefined)
4
5
  return undefined;
@@ -56,10 +57,14 @@ export function installSystems(ctx, systems, options) {
56
57
  for (const group of schedule.fixed) {
57
58
  const step = 1 / group.rate;
58
59
  let acc = (fixedAcc.get(group.rate) ?? 0) + dt;
59
- while (acc >= step) {
60
+ let substeps = 0;
61
+ while (acc >= step && substeps < MAX_FIXED_SUBSTEPS) {
60
62
  runIds(ctx, group.order, step);
61
63
  acc -= step;
64
+ substeps += 1;
62
65
  }
66
+ if (acc >= step)
67
+ acc %= step;
63
68
  fixedAcc.set(group.rate, acc);
64
69
  }
65
70
  runIds(ctx, schedule.frameOrder, dt);
@@ -1,3 +1,4 @@
1
+ import { type GamepadGlyphName, type GamepadGlyphSet } from "./gamepadModel.js";
1
2
  /**
2
3
  * Action-binding model: games bind semantic actions ("jump", "interact") to
3
4
  * physical control codes; capture layers resolve raw events through this map
@@ -69,7 +70,7 @@ export declare function resolveActionCommand(action: string, has: (command: stri
69
70
  /** Short display label for a raw key/button code (e.g. `"KeyW"` → `"W"`).
70
71
  * @internal
71
72
  */
72
- export declare function bindingLabel(code: string): string;
73
+ export declare function bindingLabel(code: string, glyphSet?: GamepadGlyphName | GamepadGlyphSet): string;
73
74
  /** Short display label for an action's first bound code, or null when unbound.
74
75
  * @internal
75
76
  */
@@ -1,10 +1,4 @@
1
- /**
2
- * Action-binding model: games bind semantic actions ("jump", "interact") to
3
- * physical control codes; capture layers resolve raw events through this map
4
- * so gameplay code never sees keycodes. Control codes are plain strings, so
5
- * the same model serves keyboard codes, mouse buttons, touch controls, or
6
- * gamepad inputs.
7
- */
1
+ import { GAMEPAD_GLYPH_SETS } from "./gamepadModel.js";
8
2
  /** @internal */
9
3
  export function bindingMatches(code, binding) {
10
4
  return code === binding.primary || code === binding.secondary;
@@ -142,7 +136,15 @@ const CODE_LABELS = {
142
136
  /** Short display label for a raw key/button code (e.g. `"KeyW"` → `"W"`).
143
137
  * @internal
144
138
  */
145
- export function bindingLabel(code) {
139
+ export function bindingLabel(code, glyphSet = "generic") {
140
+ const padButton = /^pad:(\d+)$/.exec(code);
141
+ if (padButton !== null) {
142
+ const set = typeof glyphSet === "string" ? GAMEPAD_GLYPH_SETS[glyphSet] : glyphSet;
143
+ return set.buttons[Number(padButton[1])] ?? `Button ${padButton[1]}`;
144
+ }
145
+ const padAxis = /^padaxis:(\d+)([+-])$/.exec(code);
146
+ if (padAxis !== null)
147
+ return `Axis ${padAxis[1]}${padAxis[2]}`;
146
148
  const direct = CODE_LABELS[code];
147
149
  if (direct !== undefined)
148
150
  return direct;
@@ -0,0 +1,21 @@
1
+ import type { ActionCodesMap } from "./actionBindings.js";
2
+ /** Named action-binding layer that may optionally expose lower layers. */
3
+ export interface ActionContext {
4
+ id: string;
5
+ codes: ActionCodesMap;
6
+ passthrough: boolean;
7
+ }
8
+ /** Serializable state for an action-context stack. */
9
+ export interface ActionContextStackSnapshot {
10
+ contexts: ActionContext[];
11
+ }
12
+ /** Mutable layered action-map stack with snapshot and restore support. */
13
+ export interface ActionContextStack {
14
+ push(context: ActionContext): void;
15
+ pop(id: string): boolean;
16
+ active(): ActionCodesMap;
17
+ snapshot(): ActionContextStackSnapshot;
18
+ restore(snapshot: ActionContextStackSnapshot): void;
19
+ }
20
+ /** Creates a serializable stack of layered action maps for menus and gameplay modes. */
21
+ export declare function createActionContextStack(): ActionContextStack;
@@ -0,0 +1,56 @@
1
+ function cloneCodes(codes) {
2
+ const result = {};
3
+ for (const [action, value] of Object.entries(codes)) {
4
+ const modes = value;
5
+ result[action] = Array.isArray(value)
6
+ ? [...value]
7
+ : {
8
+ ...(modes.hold === undefined ? {} : { hold: [...modes.hold] }),
9
+ ...(modes.toggle === undefined ? {} : { toggle: [...modes.toggle] }),
10
+ ...(modes.repeatMs === undefined ? {} : { repeatMs: modes.repeatMs }),
11
+ };
12
+ }
13
+ return result;
14
+ }
15
+ /** Creates a serializable stack of layered action maps for menus and gameplay modes. */
16
+ export function createActionContextStack() {
17
+ let contexts = [];
18
+ return {
19
+ push(context) {
20
+ contexts = [...contexts.filter((current) => current.id !== context.id), {
21
+ id: context.id,
22
+ codes: cloneCodes(context.codes),
23
+ passthrough: context.passthrough,
24
+ }];
25
+ },
26
+ pop(id) {
27
+ const next = contexts.filter((context) => context.id !== id);
28
+ const changed = next.length !== contexts.length;
29
+ contexts = next;
30
+ return changed;
31
+ },
32
+ active() {
33
+ const merged = {};
34
+ for (let index = contexts.length - 1; index >= 0; index -= 1) {
35
+ const context = contexts[index];
36
+ for (const [action, codes] of Object.entries(context.codes)) {
37
+ if (merged[action] === undefined)
38
+ merged[action] = codes;
39
+ }
40
+ if (!context.passthrough)
41
+ break;
42
+ }
43
+ return merged;
44
+ },
45
+ snapshot() {
46
+ return { contexts: contexts.map((context) => ({ ...context, codes: cloneCodes(context.codes) })) };
47
+ },
48
+ restore(snapshot) {
49
+ contexts = snapshot.contexts.map((context) => ({
50
+ id: context.id,
51
+ codes: cloneCodes(context.codes),
52
+ passthrough: context.passthrough,
53
+ }));
54
+ },
55
+ };
56
+ }