@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.
Files changed (114) hide show
  1. package/CHANGELOG.md +21 -1
  2. package/dist/ai/spawnDirector.d.ts +13 -0
  3. package/dist/ai/spawnDirector.js +40 -16
  4. package/dist/ai/spawnPoint.d.ts +5 -0
  5. package/dist/ai/spawnPoint.js +5 -0
  6. package/dist/audio/audioFalloff.d.ts +5 -2
  7. package/dist/audio/music.d.ts +53 -0
  8. package/dist/audio/music.js +34 -0
  9. package/dist/audio/synth.d.ts +60 -0
  10. package/dist/audio/synth.js +8 -0
  11. package/dist/authoring.d.ts +14 -0
  12. package/dist/authoring.js +14 -0
  13. package/dist/cartridge/runtime.js +2 -2
  14. package/dist/combat/dotField.d.ts +43 -0
  15. package/dist/combat/dotField.js +48 -0
  16. package/dist/combat/projectiles.d.ts +2 -2
  17. package/dist/combat/projectiles.js +18 -5
  18. package/dist/combat/regenShield.d.ts +35 -0
  19. package/dist/combat/regenShield.js +45 -0
  20. package/dist/combat/shotOrigin.d.ts +13 -1
  21. package/dist/combat/shotOrigin.js +31 -2
  22. package/dist/editor/commands.d.ts +65 -0
  23. package/dist/editor/commands.js +193 -0
  24. package/dist/editor/document.d.ts +40 -0
  25. package/dist/editor/document.js +153 -0
  26. package/dist/editor/index.d.ts +30 -0
  27. package/dist/editor/index.js +6 -0
  28. package/dist/editor/types.d.ts +69 -0
  29. package/dist/editor/types.js +29 -0
  30. package/dist/game/connectedPlayers.d.ts +28 -0
  31. package/dist/game/connectedPlayers.js +21 -0
  32. package/dist/game/defineGame.d.ts +42 -1
  33. package/dist/game/defineGame.js +1 -0
  34. package/dist/game/events.d.ts +15 -0
  35. package/dist/game/keyValueStore.d.ts +25 -0
  36. package/dist/game/keyValueStore.js +61 -0
  37. package/dist/game/levelSequence.d.ts +24 -2
  38. package/dist/game/levelSequence.js +51 -2
  39. package/dist/game/lootTable.d.ts +4 -0
  40. package/dist/game/lootTable.js +1 -0
  41. package/dist/game/modelAnimation.d.ts +8 -0
  42. package/dist/game/modelAnimation.js +18 -0
  43. package/dist/game/playableGame.d.ts +68 -5
  44. package/dist/game/recordBook.d.ts +3 -5
  45. package/dist/index.d.ts +7 -0
  46. package/dist/index.js +7 -0
  47. package/dist/input/actionBindings.d.ts +2 -0
  48. package/dist/input/actionBindings.js +1 -0
  49. package/dist/input/axisInput.d.ts +7 -0
  50. package/dist/input/axisInput.js +16 -0
  51. package/dist/interaction/contextMenu.d.ts +1 -0
  52. package/dist/interaction/contextMenu.js +1 -0
  53. package/dist/interaction/proximityPrompt.d.ts +3 -1
  54. package/dist/interaction/proximityPrompt.js +3 -3
  55. package/dist/meta/changelog.d.ts +1 -1
  56. package/dist/meta/changelog.js +22 -1
  57. package/dist/movement/movementModel.d.ts +4 -0
  58. package/dist/movement/movementModel.js +13 -0
  59. package/dist/movement/playerMovement.d.ts +41 -0
  60. package/dist/movement/playerMovement.js +181 -0
  61. package/dist/random/rng.d.ts +2 -0
  62. package/dist/random/rng.js +2 -0
  63. package/dist/render/postProcessing.d.ts +56 -0
  64. package/dist/render/postProcessing.js +1 -0
  65. package/dist/runtime/adapter.d.ts +19 -0
  66. package/dist/runtime/adapter.js +11 -5
  67. package/dist/runtime/gameContext.d.ts +53 -10
  68. package/dist/runtime/gameContext.js +192 -54
  69. package/dist/runtime/hostedGameRunner.d.ts +49 -0
  70. package/dist/runtime/hostedGameRunner.js +62 -0
  71. package/dist/runtime/hostedWorldSession.d.ts +65 -0
  72. package/dist/runtime/hostedWorldSession.js +57 -0
  73. package/dist/runtime/inputSnapshot.d.ts +13 -0
  74. package/dist/runtime/inputSnapshot.js +2 -0
  75. package/dist/runtime/motionIntents.d.ts +2 -0
  76. package/dist/runtime/motionIntents.js +11 -0
  77. package/dist/runtime/worldChannel.d.ts +58 -0
  78. package/dist/runtime/worldChannel.js +69 -0
  79. package/dist/runtime/worldMirror.d.ts +24 -0
  80. package/dist/runtime/worldMirror.js +31 -0
  81. package/dist/runtime/worldReplication.d.ts +46 -0
  82. package/dist/runtime/worldReplication.js +185 -0
  83. package/dist/runtime/worldSnapshot.d.ts +17 -0
  84. package/dist/runtime/worldSnapshot.js +15 -0
  85. package/dist/scene/assetCatalog.d.ts +1 -0
  86. package/dist/scene/colliders.d.ts +6 -0
  87. package/dist/scene/colliders.js +25 -1
  88. package/dist/scene/entityStats.d.ts +4 -0
  89. package/dist/scene/entityStats.js +21 -0
  90. package/dist/scene/entityStore.d.ts +29 -0
  91. package/dist/scene/entityStore.js +60 -0
  92. package/dist/store/observableKeyedStore.d.ts +4 -0
  93. package/dist/store/observableKeyedStore.js +9 -0
  94. package/dist/tactics/fallingGrid.d.ts +2 -2
  95. package/dist/tactics/fallingGrid.js +2 -2
  96. package/dist/ui/gameLayout.d.ts +125 -0
  97. package/dist/ui/gameLayout.js +129 -0
  98. package/dist/ui/orientation.d.ts +39 -0
  99. package/dist/ui/orientation.js +49 -0
  100. package/dist/ui/swingTimer.d.ts +42 -0
  101. package/dist/ui/swingTimer.js +38 -0
  102. package/dist/visibility/spatialIndex.js +34 -20
  103. package/dist/world/environmentSummary.d.ts +11 -2
  104. package/dist/world/environmentSummary.js +11 -1
  105. package/dist/world/features.d.ts +134 -5
  106. package/dist/world/features.js +40 -3
  107. package/dist/world/roads.d.ts +47 -0
  108. package/dist/world/roads.js +146 -0
  109. package/dist/world/streets.d.ts +80 -0
  110. package/dist/world/streets.js +144 -0
  111. package/dist/world/terrain.d.ts +32 -5
  112. package/dist/world/terrain.js +124 -12
  113. package/llms.txt +485 -79
  114. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -1,5 +1,12 @@
1
1
  export * from "./runtime/save.js";
2
2
  export * from "./runtime/snapshot.js";
3
+ export * from "./runtime/worldSnapshot.js";
4
+ export * from "./runtime/worldReplication.js";
5
+ export * from "./game/connectedPlayers.js";
6
+ export * from "./runtime/hostedGameRunner.js";
7
+ export * from "./runtime/hostedWorldSession.js";
8
+ export * from "./runtime/worldMirror.js";
9
+ export * from "./runtime/worldChannel.js";
3
10
  export * from "./runtime/commandRunner.js";
4
11
  export * from "./runtime/gameRuntime.js";
5
12
  export * from "./runtime/adapter.js";
@@ -29,6 +29,7 @@ export type ActionCodes<TCode extends string = string> = readonly TCode[] | {
29
29
  toggle?: readonly TCode[];
30
30
  repeatMs?: number;
31
31
  };
32
+ /** Maps each game action name to the input codes (hold/toggle keys, repeat rate) that trigger it. */
32
33
  export type ActionCodesMap<TAction extends string = string, TCode extends string = string> = Record<TAction, ActionCodes<TCode>>;
33
34
  export declare function actionRepeatMs(codes: ActionCodes | undefined): number | undefined;
34
35
  export interface ShouldDispatchActionInput {
@@ -53,6 +54,7 @@ export declare function isHotbarSlotAction(action: string): boolean;
53
54
  * yield null so the shell can handle them itself.
54
55
  */
55
56
  export declare function resolveActionCommand(action: string, has: (command: string) => boolean, reserved: ReadonlySet<string>): string | null;
57
+ /** Short display label for a raw key/button code (e.g. `"KeyW"` → `"W"`). */
56
58
  export declare function bindingLabel(code: string): string;
57
59
  /** Short display label for an action's first bound code, or null when unbound. */
58
60
  export declare function actionLabel(map: ActionCodesMap, action: string): string | null;
@@ -127,6 +127,7 @@ const CODE_LABELS = {
127
127
  ArrowLeft: "←",
128
128
  ArrowRight: "→",
129
129
  };
130
+ /** Short display label for a raw key/button code (e.g. `"KeyW"` → `"W"`). */
130
131
  export function bindingLabel(code) {
131
132
  const direct = CODE_LABELS[code];
132
133
  if (direct !== undefined)
@@ -63,4 +63,11 @@ export interface GenericAxisChannel<TAxes extends string> {
63
63
  reset(): void;
64
64
  readonly value: Record<TAxes, number>;
65
65
  }
66
+ /**
67
+ * Instantaneous (unsmoothed) analog sample of an axis binding map — the per-frame target an
68
+ * `AxisChannel` ramps toward, exposed as a pure read for `ctx.input.axis` and headless sampling.
69
+ * Each axis is its binding's positive-minus-negative held state (or its pointer source while a pointer
70
+ * is active), clamped to the axis range; axes without a listed range default to bipolar `[-1, 1]`.
71
+ */
72
+ export declare function sampleAxisBindings<TAxes extends string>(bindings: Record<TAxes, AxisBinding>, isDown: (code: string) => boolean, pointer?: PointerAxisState | null, ranges?: Partial<Record<TAxes, AxisRange>>): Record<TAxes, number>;
66
73
  export declare function createAxisChannel<TAxes extends string>(config: GenericAxisConfig<TAxes>): GenericAxisChannel<TAxes>;
@@ -75,6 +75,22 @@ export const DRIVE_AXIS_BINDINGS = {
75
75
  handbrake: { positive: ["Space"] },
76
76
  };
77
77
  const BIPOLAR_RANGE = { min: -1, max: 1 };
78
+ /**
79
+ * Instantaneous (unsmoothed) analog sample of an axis binding map — the per-frame target an
80
+ * `AxisChannel` ramps toward, exposed as a pure read for `ctx.input.axis` and headless sampling.
81
+ * Each axis is its binding's positive-minus-negative held state (or its pointer source while a pointer
82
+ * is active), clamped to the axis range; axes without a listed range default to bipolar `[-1, 1]`.
83
+ */
84
+ export function sampleAxisBindings(bindings, isDown, pointer, ranges) {
85
+ const out = {};
86
+ for (const axis of Object.keys(bindings)) {
87
+ const binding = bindings[axis];
88
+ const range = ranges?.[axis] ?? BIPOLAR_RANGE;
89
+ const fromPointer = binding.pointer === undefined ? null : pointerAxisValue(binding.pointer, pointer ?? null);
90
+ out[axis] = clampAxis(fromPointer ?? digitalTarget(binding, isDown), range);
91
+ }
92
+ return out;
93
+ }
78
94
  export function createAxisChannel(config) {
79
95
  const axes = Object.keys(config.bindings);
80
96
  const smoothing = config.smoothing ?? 6;
@@ -7,6 +7,7 @@ export interface ContextVerb {
7
7
  /** Greyed-out and non-dispatchable when true (e.g. requirement unmet). */
8
8
  disabled?: boolean;
9
9
  }
10
+ /** Builds a {@link ContextVerb} for a right-click menu entry. */
10
11
  export declare function contextVerb(label: string, command: string, args?: Record<string, unknown>): ContextVerb;
11
12
  export interface ContextMenu {
12
13
  kind: ContextTargetKind;
@@ -1,3 +1,4 @@
1
+ /** Builds a {@link ContextVerb} for a right-click menu entry. */
1
2
  export function contextVerb(label, command, args) {
2
3
  return args === undefined ? { label, command } : { label, command, args };
3
4
  }
@@ -5,6 +5,8 @@ export interface PromptPoint {
5
5
  export interface KeybindPromptDisplay {
6
6
  kind: "keybind";
7
7
  actionId: string;
8
+ /** What the interaction does, shown beside the key glyph (e.g. `"Open Chest"`, `"Talk"`); omit for key-only. */
9
+ label?: string;
8
10
  }
9
11
  export interface GaugePromptDisplay {
10
12
  kind: "gauge";
@@ -15,7 +17,7 @@ export interface LabelPromptDisplay {
15
17
  text: string;
16
18
  }
17
19
  export type ProximityPromptDisplay = KeybindPromptDisplay | GaugePromptDisplay | LabelPromptDisplay;
18
- export declare function keybind(actionId: string): KeybindPromptDisplay;
20
+ export declare function keybind(actionId: string, label?: string): KeybindPromptDisplay;
19
21
  export declare function gauge(gaugeId: string): GaugePromptDisplay;
20
22
  export declare function label(text: string): LabelPromptDisplay;
21
23
  export interface PromptCommand {
@@ -1,5 +1,5 @@
1
- export function keybind(actionId) {
2
- return { kind: "keybind", actionId };
1
+ export function keybind(actionId, label) {
2
+ return label === undefined ? { kind: "keybind", actionId } : { kind: "keybind", actionId, label };
3
3
  }
4
4
  export function gauge(gaugeId) {
5
5
  return { kind: "gauge", gaugeId };
@@ -39,7 +39,7 @@ export function promptDisplaysEqual(a, b) {
39
39
  if (a === b)
40
40
  return true;
41
41
  if (a.kind === "keybind")
42
- return b.kind === "keybind" && a.actionId === b.actionId;
42
+ return b.kind === "keybind" && a.actionId === b.actionId && a.label === b.label;
43
43
  if (a.kind === "gauge")
44
44
  return b.kind === "gauge" && a.gaugeId === b.gaugeId;
45
45
  return b.kind === "label" && a.text === b.text;
@@ -1,4 +1,4 @@
1
- export declare const VERSION = "0.9.0";
1
+ export declare const VERSION = "0.10.0";
2
2
  export interface ChangelogEntry {
3
3
  migrate: readonly string[];
4
4
  added: readonly string[];
@@ -1,5 +1,26 @@
1
- export const VERSION = "0.9.0";
1
+ export const VERSION = "0.10.0";
2
2
  export const CHANGELOG = {
3
+ "0.10.0": {
4
+ migrate: [
5
+ "Bump every @jgengine/* dependency to ^0.10.0 (the eight packages version in lockstep).",
6
+ "Nothing to change for existing games — the shared mobile-composition system (viewport allocation, region collision, --jg-* viewport vars) is wired automatically by the shell; HudPanels inside a HudCanvas register themselves. Legacy orientation: \"landscape\" | \"portrait\" keeps its advisory dismissible-hint behavior.",
7
+ "Opt a driving/landscape game into the strict gate: replace advisory orientation with the contract form orientation: { mobile: \"landscape-required\" } (rules: any · portrait/landscape advisory · portrait-required/landscape-required · unsupported). In portrait the engine shows a polished RotateDeviceScreen, suppresses input, freezes the sim, and unmounts the HUD/controls until the device is landscape.",
8
+ "Declare HUD intent where useful: HudPanel gains priority (critical/secondary/tertiary), mobileBehavior (hidden unmounts on phones, transient softens collision), allowOverlapWith, and collisionGroup — all optional; omitting them keeps prior behavior.",
9
+ "Mobile validation now also fails on overlap: bun run shoot <game> --device mobile|mobile-landscape|both exits non-zero on forbidden inter-element collisions, naming both regions.",
10
+ ],
11
+ added: [
12
+ "Shared mobile layout composition — @jgengine/core/ui/gameLayout (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.",
13
+ "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.",
14
+ "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.",
15
+ "HUD placement metadata — HudPanel gains priority, mobileBehavior, allowOverlapWith, collisionGroup, and registers as a collision-tracked layout region.",
16
+ "Mobile-landscape shoot device — bun run shoot --device mobile-landscape (844×390) plus collision-gate reads that fail mobile validation on forbidden overlaps.",
17
+ "Canyon Chase migrated as the reference landscape-required game — declares orientation: { mobile: \"landscape-required\" } and composes its HUD through coordinated HudPanel regions instead of independent fixed corners.",
18
+ ],
19
+ changed: [
20
+ "First-person projectile tracers now originate from the weapon muzzle instead of the camera/eye centerline; hit detection is unchanged (still crosshair-accurate), and non-viewmodel games and enemy tracers are unaffected.",
21
+ ],
22
+ removed: [],
23
+ },
3
24
  "0.9.0": {
4
25
  migrate: [
5
26
  "Bump every @jgengine/* dependency to ^0.9.0 (the eight packages version in lockstep).",
@@ -115,6 +115,10 @@ export interface CollisionObstacle {
115
115
  * objects — this also early-exits per obstacle on horizontal distance.
116
116
  */
117
117
  export declare function resolveObstacleStep(current: readonly [number, number, number], stepX: number, stepZ: number, obstacles: readonly CollisionObstacle[], playerRadius?: number): MovementFrameStep;
118
+ /** Placed objects within `radius` (XZ) of `center`, as {@link CollisionObstacle}s to pre-filter for {@link resolveObstacleStep}. */
119
+ export declare function nearbyObstacles(objects: readonly {
120
+ position: readonly [number, number, number];
121
+ }[], center: readonly [number, number, number], radius?: number): CollisionObstacle[];
118
122
  /** Zeroes the off-axis component so travel is locked to a single world axis (`PlayerMovementConfig.mode: "axis"`). */
119
123
  export declare function constrainStepToAxis(stepX: number, stepZ: number, axis: "x" | "z"): MovementFrameStep;
120
124
  /**
@@ -227,6 +227,19 @@ export function resolveObstacleStep(current, stepX, stepZ, obstacles, playerRadi
227
227
  }
228
228
  return { stepX: resultX, stepZ: resultZ };
229
229
  }
230
+ const OBSTACLE_GATHER_RADIUS = 3;
231
+ /** Placed objects within `radius` (XZ) of `center`, as {@link CollisionObstacle}s to pre-filter for {@link resolveObstacleStep}. */
232
+ export function nearbyObstacles(objects, center, radius = OBSTACLE_GATHER_RADIUS) {
233
+ const radiusSq = radius * radius;
234
+ const result = [];
235
+ for (const object of objects) {
236
+ const dx = object.position[0] - center[0];
237
+ const dz = object.position[2] - center[2];
238
+ if (dx * dx + dz * dz <= radiusSq)
239
+ result.push({ position: object.position });
240
+ }
241
+ return result;
242
+ }
230
243
  /** Zeroes the off-axis component so travel is locked to a single world axis (`PlayerMovementConfig.mode: "axis"`). */
231
244
  export function constrainStepToAxis(stepX, stepZ, axis) {
232
245
  return axis === "x" ? { stepX, stepZ: 0 } : { stepX: 0, stepZ };
@@ -0,0 +1,41 @@
1
+ import type { PhysicsConfig } from "../game/defineGame.js";
2
+ import type { PlayerMovementConfig, VoxelCollisionConfig } from "../game/playableGame.js";
3
+ import type { GameContext } from "../runtime/gameContext.js";
4
+ import type { InputFrame } from "../runtime/inputSnapshot.js";
5
+ import { type TerrainField } from "../world/terrain.js";
6
+ import type { WorldFeature } from "../world/features.js";
7
+ import { type MovementTuningOverrides } from "./movementModel.js";
8
+ /** The resolved, per-world movement configuration {@link stepPlayerMovement} integrates against — the same inputs the shell FrameDriver used to read piecemeal, gathered into one struct so single-player and host movement run identical math. */
9
+ export interface PlayerMovementTuning {
10
+ collision?: VoxelCollisionConfig;
11
+ movement?: PlayerMovementConfig;
12
+ physics?: MovementTuningOverrides;
13
+ ground: TerrainField;
14
+ hasTerrain: boolean;
15
+ }
16
+ /**
17
+ * Maps a game's declared `physics` onto the movement controllers' tuning. `PhysicsConfig.gravity` is a signed
18
+ * world acceleration (negative points down), but the controllers integrate `velocityY -= gravityAcceleration * dt`
19
+ * and expect a positive downward magnitude — so gravity is negated here to keep down-pointing gravity pulling down.
20
+ */
21
+ export declare function resolvePhysicsTuning(physics: PhysicsConfig | undefined): MovementTuningOverrides | undefined;
22
+ /** Gather a game's collision/movement/physics/world config into a {@link PlayerMovementTuning} — call once per world; both the shell and a host pass the result to {@link stepPlayerMovement}. */
23
+ export declare function resolvePlayerMovementTuning(opts: {
24
+ collision?: VoxelCollisionConfig;
25
+ movement?: PlayerMovementConfig;
26
+ physics?: PhysicsConfig;
27
+ world?: WorldFeature;
28
+ }): PlayerMovementTuning;
29
+ /** One player's current heading (radians), integrated by {@link stepPlayerMovement} — the shell reads it back into its camera/aim yaw. */
30
+ export declare function playerMovementHeading(ctx: GameContext, userId: string): number;
31
+ /** Drop a player's retained movement state (heading + kinematic body) — call on leave so a rejoin starts fresh instead of resuming stale velocity. */
32
+ export declare function forgetPlayerMovement(ctx: GameContext, userId: string): void;
33
+ /**
34
+ * Integrate one player's movement for a tick from their held-input frame and commit the pose — the single
35
+ * genre-agnostic controller both the shell (its local player) and a host (each connected player in `onTick`) call,
36
+ * so single-player and server-authoritative movement are identical. Reads the player's controlled entity, terrain,
37
+ * scene solids, and pending motion impulses; writes the entity pose via `setPose`. Retains heading + kinematic body
38
+ * per `userId` on the `ctx`. Pass `heading` to override the internally-integrated yaw (the shell owns yaw for its
39
+ * camera); omit it and the controller turns from the frame's `turnLeft`/`turnRight` actions.
40
+ */
41
+ export declare function stepPlayerMovement(ctx: GameContext, userId: string, input: InputFrame, dt: number, tuning: PlayerMovementTuning, heading?: number): void;
@@ -0,0 +1,181 @@
1
+ import { applyHorizontalImpulses, applyMotionImpulses } from "../runtime/motionIntents.js";
2
+ import { groundFieldFor, hasEnvironmentTerrain } from "../world/terrain.js";
3
+ import { advancePlayerMotion, constrainStepToAxis, createEmptyMovementKeys, createPlayerMotionState, nearbyObstacles, resolveMovementIntent, resolveObstacleStep, snapPositionToGrid, } from "./movementModel.js";
4
+ import { steerYaw } from "./steering.js";
5
+ import { advanceVoxelPlayer, createVoxelPlayerBody, } from "./voxelController.js";
6
+ const DEFAULT_TURN_SPEED = 2.4;
7
+ const DEFAULT_WALK_SPEED = 2;
8
+ /**
9
+ * Maps a game's declared `physics` onto the movement controllers' tuning. `PhysicsConfig.gravity` is a signed
10
+ * world acceleration (negative points down), but the controllers integrate `velocityY -= gravityAcceleration * dt`
11
+ * and expect a positive downward magnitude — so gravity is negated here to keep down-pointing gravity pulling down.
12
+ */
13
+ export function resolvePhysicsTuning(physics) {
14
+ if (physics === undefined)
15
+ return undefined;
16
+ return {
17
+ get gravityAcceleration() {
18
+ return physics.gravity === undefined ? undefined : -physics.gravity;
19
+ },
20
+ get jumpVelocity() {
21
+ return physics.jumpVelocity;
22
+ },
23
+ };
24
+ }
25
+ /** Gather a game's collision/movement/physics/world config into a {@link PlayerMovementTuning} — call once per world; both the shell and a host pass the result to {@link stepPlayerMovement}. */
26
+ export function resolvePlayerMovementTuning(opts) {
27
+ const physics = resolvePhysicsTuning(opts.physics);
28
+ return {
29
+ ...(opts.collision === undefined ? {} : { collision: opts.collision }),
30
+ ...(opts.movement === undefined ? {} : { movement: opts.movement }),
31
+ ...(physics === undefined ? {} : { physics }),
32
+ ground: groundFieldFor(opts.world),
33
+ hasTerrain: hasEnvironmentTerrain(opts.world),
34
+ };
35
+ }
36
+ const stores = new WeakMap();
37
+ function storeFor(ctx) {
38
+ let store = stores.get(ctx);
39
+ if (store === undefined) {
40
+ store = { players: new Map(), solids: { count: -1, set: new Set() } };
41
+ stores.set(ctx, store);
42
+ }
43
+ return store;
44
+ }
45
+ function stateFor(store, userId) {
46
+ let state = store.players.get(userId);
47
+ if (state === undefined) {
48
+ state = { heading: 0, voxelBody: null, motion: null };
49
+ store.players.set(userId, state);
50
+ }
51
+ return state;
52
+ }
53
+ /** One player's current heading (radians), integrated by {@link stepPlayerMovement} — the shell reads it back into its camera/aim yaw. */
54
+ export function playerMovementHeading(ctx, userId) {
55
+ return stores.get(ctx)?.players.get(userId)?.heading ?? 0;
56
+ }
57
+ /** Drop a player's retained movement state (heading + kinematic body) — call on leave so a rejoin starts fresh instead of resuming stale velocity. */
58
+ export function forgetPlayerMovement(ctx, userId) {
59
+ stores.get(ctx)?.players.delete(userId);
60
+ }
61
+ /**
62
+ * Integrate one player's movement for a tick from their held-input frame and commit the pose — the single
63
+ * genre-agnostic controller both the shell (its local player) and a host (each connected player in `onTick`) call,
64
+ * so single-player and server-authoritative movement are identical. Reads the player's controlled entity, terrain,
65
+ * scene solids, and pending motion impulses; writes the entity pose via `setPose`. Retains heading + kinematic body
66
+ * per `userId` on the `ctx`. Pass `heading` to override the internally-integrated yaw (the shell owns yaw for its
67
+ * camera); omit it and the controller turns from the frame's `turnLeft`/`turnRight` actions.
68
+ */
69
+ export function stepPlayerMovement(ctx, userId, input, dt, tuning, heading) {
70
+ const playerId = ctx.player.possession.active(userId);
71
+ const player = ctx.scene.entity.get(playerId);
72
+ if (player === null)
73
+ return;
74
+ const store = storeFor(ctx);
75
+ const state = stateFor(store, userId);
76
+ const held = new Set(input.held);
77
+ const isDown = (action) => held.has(action);
78
+ if (heading !== undefined) {
79
+ state.heading = heading;
80
+ }
81
+ else {
82
+ const turnInput = (isDown("turnRight") ? 1 : 0) - (isDown("turnLeft") ? 1 : 0);
83
+ if (turnInput !== 0)
84
+ state.heading = steerYaw(state.heading, turnInput, DEFAULT_TURN_SPEED, dt);
85
+ }
86
+ const forwardX = Math.sin(state.heading);
87
+ const forwardZ = Math.cos(state.heading);
88
+ const keys = createEmptyMovementKeys();
89
+ keys.w = isDown("moveForward");
90
+ keys.s = isDown("moveBack");
91
+ keys.a = isDown("moveLeft");
92
+ keys.d = isDown("moveRight");
93
+ keys.shift = isDown("sprint") && (tuning.movement?.canSprint?.(ctx) ?? true);
94
+ keys.space = isDown("jump");
95
+ const intent = resolveMovementIntent(keys, true);
96
+ const motionBatch = ctx.player.motionFor(userId).takePending();
97
+ const walkSpeed = player.movement?.walkSpeed ?? DEFAULT_WALK_SPEED;
98
+ if (tuning.collision?.voxel === true) {
99
+ let body = state.voxelBody;
100
+ if (body === null) {
101
+ body = createVoxelPlayerBody(player.position[0], player.position[1], player.position[2]);
102
+ state.voxelBody = body;
103
+ }
104
+ const objects = ctx.scene.object.list();
105
+ if (store.solids.count !== objects.length) {
106
+ store.solids.set = new Set(objects.map((o) => `${o.position[0]},${o.position[1]},${o.position[2]}`));
107
+ store.solids.count = objects.length;
108
+ }
109
+ const solids = store.solids.set;
110
+ const isSolid = (x, y, z) => solids.has(`${x},${y},${z}`);
111
+ const dims = {
112
+ halfWidth: tuning.collision.halfWidth ?? 0.3,
113
+ height: tuning.collision.height ?? 1.8,
114
+ stepHeight: tuning.collision.stepHeight ?? 0.6,
115
+ };
116
+ body.velocityY = applyMotionImpulses(body.velocityY, motionBatch);
117
+ [body.velocityX, body.velocityZ] = applyHorizontalImpulses(body.velocityX, body.velocityZ, motionBatch);
118
+ advanceVoxelPlayer(body, intent, forwardX, forwardZ, walkSpeed, dt, isSolid, dims, tuning.physics, tuning.hasTerrain ? (x, z) => tuning.ground.sampleHeight(x, z) : undefined);
119
+ if (motionBatch !== null && motionBatch.y !== null)
120
+ body.y = motionBatch.y;
121
+ ctx.scene.entity.setPose(playerId, {
122
+ position: [body.x, body.y, body.z],
123
+ rotationY: intent.moving ? Math.atan2(body.velocityX, body.velocityZ) : player.rotationY,
124
+ dt,
125
+ });
126
+ return;
127
+ }
128
+ let motion = state.motion;
129
+ if (motion === null) {
130
+ motion = createPlayerMotionState();
131
+ state.motion = motion;
132
+ }
133
+ motion.verticalVelocity = applyMotionImpulses(motion.verticalVelocity, motionBatch);
134
+ [motion.horizontalVelocityX, motion.horizontalVelocityZ] = applyHorizontalImpulses(motion.horizontalVelocityX, motion.horizontalVelocityZ, motionBatch);
135
+ const step = advancePlayerMotion(motion, intent, forwardX, forwardZ, walkSpeed, dt, tuning.physics);
136
+ let stepX = step.stepX;
137
+ let stepZ = step.stepZ;
138
+ if (tuning.movement?.mode === "axis") {
139
+ const constrained = constrainStepToAxis(stepX, stepZ, tuning.movement.axis ?? "x");
140
+ stepX = constrained.stepX;
141
+ stepZ = constrained.stepZ;
142
+ }
143
+ if (tuning.movement?.collideObjects === true) {
144
+ const obstacles = nearbyObstacles(ctx.scene.object.list(), player.position);
145
+ const resolved = resolveObstacleStep(player.position, stepX, stepZ, obstacles);
146
+ stepX = resolved.stepX;
147
+ stepZ = resolved.stepZ;
148
+ }
149
+ let nextX = player.position[0] + stepX;
150
+ let nextZ = player.position[2] + stepZ;
151
+ if (tuning.movement?.mode === "grid") {
152
+ const snapped = snapPositionToGrid(nextX, nextZ, tuning.movement.cellSize ?? 1);
153
+ nextX = snapped[0];
154
+ nextZ = snapped[1];
155
+ }
156
+ let nextY = tuning.ground.sampleHeight(nextX, nextZ) + motion.jumpOffset;
157
+ if (motionBatch !== null && motionBatch.y !== null) {
158
+ nextY = motionBatch.y;
159
+ motion.jumpOffset = motionBatch.y - tuning.ground.sampleHeight(nextX, nextZ);
160
+ }
161
+ if (tuning.movement?.beforeCommit !== undefined) {
162
+ const frame = {
163
+ entityId: playerId,
164
+ current: player.position,
165
+ next: [nextX, nextY, nextZ],
166
+ dt,
167
+ ctx,
168
+ };
169
+ const replacement = tuning.movement.beforeCommit(frame);
170
+ if (replacement !== undefined) {
171
+ nextX = replacement[0];
172
+ nextY = replacement[1];
173
+ nextZ = replacement[2];
174
+ }
175
+ }
176
+ ctx.scene.entity.setPose(playerId, {
177
+ position: [nextX, nextY, nextZ],
178
+ rotationY: intent.moving ? Math.atan2(motion.horizontalVelocityX, motion.horizontalVelocityZ) : player.rotationY,
179
+ dt,
180
+ });
181
+ }
@@ -1,2 +1,4 @@
1
+ /** Deterministic pseudo-random generator seeded from a string or number — same seed, same sequence. */
1
2
  export declare function seededRng(seed: string | number): () => number;
3
+ /** Derives independent, deterministic {@link seededRng} streams from one base seed, keyed by stream name. */
2
4
  export declare function seededStreams(seed: string | number): (stream: string) => () => number;
@@ -7,6 +7,7 @@ function hashSeed(seed) {
7
7
  }
8
8
  return h >>> 0;
9
9
  }
10
+ /** Deterministic pseudo-random generator seeded from a string or number — same seed, same sequence. */
10
11
  export function seededRng(seed) {
11
12
  let a = hashSeed(seed) >>> 0;
12
13
  return () => {
@@ -17,6 +18,7 @@ export function seededRng(seed) {
17
18
  return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
18
19
  };
19
20
  }
21
+ /** Derives independent, deterministic {@link seededRng} streams from one base seed, keyed by stream name. */
20
22
  export function seededStreams(seed) {
21
23
  const base = typeof seed === "number" ? seed.toString() : seed;
22
24
  return (stream) => seededRng(`${base}:${stream}`);
@@ -0,0 +1,56 @@
1
+ /** Renderer tone-mapping curve applied by the post chain's output stage. */
2
+ export type ToneMappingMode = "aces" | "agx" | "reinhard" | "cineon" | "linear" | "none";
3
+ /** UnrealBloom stage — soft HDR glow around bright pixels (sun, glints, emissive). */
4
+ export interface BloomConfig {
5
+ /** Glow intensity. Default 0.32. */
6
+ strength?: number;
7
+ /** Glow spread radius, 0..1. Default 0.55. */
8
+ radius?: number;
9
+ /** Luminance above which a pixel blooms, 0..1. Default 0.85. */
10
+ threshold?: number;
11
+ }
12
+ /** Ground-truth ambient occlusion stage — darkens contact creases and cavities for depth. */
13
+ export interface AoConfig {
14
+ /** Sample radius in world units. Default 1.8. */
15
+ radius?: number;
16
+ /** Occlusion strength multiplier. Default 2.4. */
17
+ intensity?: number;
18
+ /** Distance over which occlusion fades out, world units. Default 3.6. */
19
+ distanceFalloff?: number;
20
+ /** Blend the AO onto the beauty at this fraction, 0..1. Default 1. */
21
+ blend?: number;
22
+ }
23
+ /** Final colour-grade stage: lift/gain/gamma, saturation, vignette, film grain — applied in display space after tone mapping. */
24
+ export interface GradeConfig {
25
+ /** Additive shadow tint (RGB, ~0..0.1). Default cool [0.012, 0.010, 0.018]. */
26
+ lift?: readonly [number, number, number];
27
+ /** Multiplicative highlight tint (RGB, ~1). Default warm [1.05, 1.02, 0.98]. */
28
+ gain?: readonly [number, number, number];
29
+ /** Output gamma. Default 0.96. */
30
+ gamma?: number;
31
+ /** Saturation multiplier around luma. Default 1.12. */
32
+ saturation?: number;
33
+ /** Vignette darkening strength at the corners, 0..1. Default 0.2. */
34
+ vignette?: number;
35
+ /** Animated film-grain amplitude. Default 0.012. */
36
+ grain?: number;
37
+ }
38
+ /**
39
+ * Declarative post-processing chain (RenderPass → AO → Bloom → tone-map output →
40
+ * Grade). Present on a game means the shell mounts an `EffectComposer` and owns
41
+ * the render; absent means the renderer draws directly (unchanged). Each stage is
42
+ * a config object, `false` to skip, or omitted for its default. Pure data — no
43
+ * three.js types leak into core.
44
+ */
45
+ export interface PostProcessingConfig {
46
+ /** Master switch; a config object with `enabled: false` mounts nothing. Default true. */
47
+ enabled?: boolean;
48
+ /** Tone-mapping curve the output stage applies. Default "aces". */
49
+ toneMapping?: ToneMappingMode;
50
+ /** Exposure multiplier applied before tone mapping. Default 1. */
51
+ exposure?: number;
52
+ bloom?: BloomConfig | false;
53
+ /** Ambient occlusion. Heavier than the other stages — omit or `false` on low-end targets. Default off (omitted). */
54
+ ao?: AoConfig | false;
55
+ grade?: GradeConfig | false;
56
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -1,52 +1,71 @@
1
1
  export type MultiplayerTopology = "shared" | "lobbies" | "private";
2
+ /**
3
+ * Where the world simulation is authoritative. `"client"` (default) keeps the historical model — each client runs
4
+ * its own `onTick` and syncs only presence/feeds/chat. `"server"` opts into host-authoritative replication: the
5
+ * host runs the loop, and the shell mirrors the server's world into the local `ctx` instead of simulating locally.
6
+ */
7
+ export type MultiplayerAuthority = "server" | "client";
2
8
  export type MultiplayerAdapterConfig = {
3
9
  kind: "convex";
4
10
  topology?: MultiplayerTopology;
11
+ authority?: MultiplayerAuthority;
5
12
  } | {
6
13
  kind: "ws";
7
14
  topology?: MultiplayerTopology;
8
15
  url?: string;
16
+ authority?: MultiplayerAuthority;
9
17
  } | {
10
18
  kind: "socketio";
11
19
  topology?: MultiplayerTopology;
12
20
  url?: string;
21
+ authority?: MultiplayerAuthority;
13
22
  } | {
14
23
  kind: "p2p";
15
24
  topology?: MultiplayerTopology;
16
25
  room?: string;
26
+ authority?: MultiplayerAuthority;
17
27
  } | {
18
28
  kind: "lan";
19
29
  topology?: MultiplayerTopology;
20
30
  port?: number;
21
31
  path?: string;
32
+ authority?: MultiplayerAuthority;
22
33
  } | {
23
34
  kind: "offline";
24
35
  };
25
36
  export declare function convex(config?: {
26
37
  topology?: MultiplayerTopology;
38
+ authority?: MultiplayerAuthority;
27
39
  }): MultiplayerAdapterConfig;
28
40
  export declare function ws(config?: {
29
41
  topology?: MultiplayerTopology;
30
42
  url?: string;
43
+ authority?: MultiplayerAuthority;
31
44
  }): MultiplayerAdapterConfig;
32
45
  export declare function fly(config: {
33
46
  app: string;
34
47
  topology?: MultiplayerTopology;
35
48
  path?: string;
49
+ authority?: MultiplayerAuthority;
36
50
  }): MultiplayerAdapterConfig;
37
51
  export declare function socketIo(config?: {
38
52
  topology?: MultiplayerTopology;
39
53
  url?: string;
54
+ authority?: MultiplayerAuthority;
40
55
  }): MultiplayerAdapterConfig;
41
56
  export declare function p2p(config?: {
42
57
  topology?: MultiplayerTopology;
43
58
  room?: string;
59
+ authority?: MultiplayerAuthority;
44
60
  }): MultiplayerAdapterConfig;
45
61
  export declare function lan(config?: {
46
62
  topology?: MultiplayerTopology;
47
63
  port?: number;
48
64
  path?: string;
65
+ authority?: MultiplayerAuthority;
49
66
  }): MultiplayerAdapterConfig;
67
+ /** True when the adapter opts into host-authoritative world replication (`authority: "server"`). */
68
+ export declare function isServerAuthoritative(multiplayer: unknown): boolean;
50
69
  export declare function offline(): MultiplayerAdapterConfig;
51
70
  export declare function adapterOf(multiplayer: unknown): MultiplayerAdapterConfig | null;
52
71
  export declare function multiplayerAdapterKind(multiplayer: unknown): string | null;
@@ -1,24 +1,30 @@
1
1
  export function convex(config) {
2
- return { kind: "convex", topology: config?.topology ?? "shared" };
2
+ return { kind: "convex", topology: config?.topology ?? "shared", authority: config?.authority };
3
3
  }
4
4
  export function ws(config) {
5
- return { kind: "ws", topology: config?.topology ?? "shared", url: config?.url };
5
+ return { kind: "ws", topology: config?.topology ?? "shared", url: config?.url, authority: config?.authority };
6
6
  }
7
7
  export function fly(config) {
8
8
  return {
9
9
  kind: "ws",
10
10
  topology: config.topology ?? "shared",
11
11
  url: `wss://${config.app}.fly.dev${config.path ?? "/ws"}`,
12
+ authority: config.authority,
12
13
  };
13
14
  }
14
15
  export function socketIo(config) {
15
- return { kind: "socketio", topology: config?.topology ?? "shared", url: config?.url };
16
+ return { kind: "socketio", topology: config?.topology ?? "shared", url: config?.url, authority: config?.authority };
16
17
  }
17
18
  export function p2p(config) {
18
- return { kind: "p2p", topology: config?.topology ?? "private", room: config?.room };
19
+ return { kind: "p2p", topology: config?.topology ?? "private", room: config?.room, authority: config?.authority };
19
20
  }
20
21
  export function lan(config) {
21
- return { kind: "lan", topology: config?.topology ?? "shared", port: config?.port, path: config?.path };
22
+ return { kind: "lan", topology: config?.topology ?? "shared", port: config?.port, path: config?.path, authority: config?.authority };
23
+ }
24
+ /** True when the adapter opts into host-authoritative world replication (`authority: "server"`). */
25
+ export function isServerAuthoritative(multiplayer) {
26
+ const adapter = adapterOf(multiplayer);
27
+ return adapter !== null && "authority" in adapter && adapter.authority === "server";
22
28
  }
23
29
  export function offline() {
24
30
  return { kind: "offline" };