@jgengine/core 0.11.0 → 0.13.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 (256) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/dist/ai/populationDirector.d.ts +103 -0
  3. package/dist/ai/populationDirector.js +190 -0
  4. package/dist/ai/pursuit.d.ts +56 -0
  5. package/dist/ai/pursuit.js +53 -0
  6. package/dist/authoring.d.ts +1 -1
  7. package/dist/authoring.js +1 -1
  8. package/dist/combat/effects.d.ts +11 -4
  9. package/dist/combat/effects.js +24 -17
  10. package/dist/combat/magazine.d.ts +13 -0
  11. package/dist/combat/magazine.js +27 -0
  12. package/dist/combat/vfxPresets.d.ts +176 -0
  13. package/dist/combat/vfxPresets.js +74 -0
  14. package/dist/combat/weaponFire.d.ts +114 -0
  15. package/dist/combat/weaponFire.js +96 -0
  16. package/dist/combat.d.ts +5 -2
  17. package/dist/combat.js +4 -1
  18. package/dist/devtools/controls.d.ts +38 -0
  19. package/dist/devtools/controls.js +181 -0
  20. package/dist/devtools/devtools.d.ts +5 -197
  21. package/dist/devtools/devtools.js +38 -774
  22. package/dist/devtools/discover.d.ts +32 -0
  23. package/dist/devtools/discover.js +240 -0
  24. package/dist/devtools/fallbackSeams.d.ts +50 -0
  25. package/dist/devtools/fallbackSeams.js +111 -0
  26. package/dist/devtools/frame.d.ts +15 -0
  27. package/dist/devtools/frame.js +247 -0
  28. package/dist/devtools/logs.d.ts +15 -0
  29. package/dist/devtools/logs.js +86 -0
  30. package/dist/devtools/overrides.d.ts +16 -0
  31. package/dist/devtools/overrides.js +66 -0
  32. package/dist/devtools/types.d.ts +212 -0
  33. package/dist/devtools/types.js +1 -0
  34. package/dist/devtools/urlFlags.d.ts +22 -0
  35. package/dist/devtools/urlFlags.js +62 -0
  36. package/dist/economy/auctionBook.d.ts +122 -0
  37. package/dist/economy/auctionBook.js +213 -0
  38. package/dist/economy/priceHistory.d.ts +44 -0
  39. package/dist/economy/priceHistory.js +89 -0
  40. package/dist/economy/unlockPoints.d.ts +92 -0
  41. package/dist/economy/unlockPoints.js +103 -0
  42. package/dist/editor/commandApply.d.ts +5 -0
  43. package/dist/editor/commandApply.js +755 -0
  44. package/dist/editor/commands.d.ts +48 -3
  45. package/dist/editor/commands.js +40 -754
  46. package/dist/editor/directives.d.ts +94 -0
  47. package/dist/editor/directives.js +206 -0
  48. package/dist/editor/document.d.ts +37 -2
  49. package/dist/editor/document.js +422 -6
  50. package/dist/editor/entityCatalog.d.ts +42 -0
  51. package/dist/editor/entityCatalog.js +101 -0
  52. package/dist/editor/environment.d.ts +74 -0
  53. package/dist/editor/environment.js +169 -0
  54. package/dist/editor/index.d.ts +26 -1
  55. package/dist/editor/index.js +8 -1
  56. package/dist/editor/minimap.d.ts +31 -0
  57. package/dist/editor/minimap.js +73 -0
  58. package/dist/editor/streamer.d.ts +66 -0
  59. package/dist/editor/streamer.js +68 -0
  60. package/dist/editor/types.d.ts +143 -0
  61. package/dist/editor/world.d.ts +136 -0
  62. package/dist/editor/world.js +303 -0
  63. package/dist/game/breeding.d.ts +161 -0
  64. package/dist/game/breeding.js +181 -0
  65. package/dist/game/cameraConfig.d.ts +451 -0
  66. package/dist/game/cameraConfig.js +1 -0
  67. package/dist/game/clipRoles.d.ts +51 -0
  68. package/dist/game/clipRoles.js +142 -0
  69. package/dist/game/defineGame.d.ts +16 -9
  70. package/dist/game/defineGame.js +14 -6
  71. package/dist/game/defineSystem.d.ts +2 -0
  72. package/dist/game/defineSystem.js +2 -0
  73. package/dist/game/events.d.ts +28 -0
  74. package/dist/game/partAnimation.d.ts +78 -0
  75. package/dist/game/partAnimation.js +147 -0
  76. package/dist/game/playableGame.d.ts +72 -440
  77. package/dist/game/playableGame.js +1 -1
  78. package/dist/game/progression.d.ts +35 -0
  79. package/dist/game/progression.js +22 -1
  80. package/dist/game/quest.js +1 -3
  81. package/dist/game/tribe.d.ts +184 -0
  82. package/dist/game/tribe.js +432 -0
  83. package/dist/gameplay.d.ts +3 -1
  84. package/dist/gameplay.js +3 -1
  85. package/dist/inventory/encumbrance.d.ts +69 -0
  86. package/dist/inventory/encumbrance.js +68 -0
  87. package/dist/inventory/inventoryModel.d.ts +24 -8
  88. package/dist/inventory/inventoryModel.js +47 -8
  89. package/dist/item/durability.js +1 -3
  90. package/dist/meta/changelog.d.ts +1 -1
  91. package/dist/meta/changelog.js +48 -1
  92. package/dist/movement/movementModel.d.ts +15 -1
  93. package/dist/movement/movementModel.js +33 -15
  94. package/dist/movement/playerMovement.js +102 -2
  95. package/dist/multiplayer/combatSnapshot.js +13 -8
  96. package/dist/multiplayer/presenceModel.d.ts +2 -0
  97. package/dist/multiplayer/presenceModel.js +8 -0
  98. package/dist/physics/drivableVehicle.d.ts +2 -0
  99. package/dist/physics/drivableVehicle.js +1 -1
  100. package/dist/physics/flightDynamics.d.ts +82 -0
  101. package/dist/physics/flightDynamics.js +165 -0
  102. package/dist/physics/gravityField.d.ts +25 -0
  103. package/dist/physics/gravityField.js +41 -0
  104. package/dist/physics/kinematicVehicle.d.ts +87 -0
  105. package/dist/physics/kinematicVehicle.js +193 -11
  106. package/dist/physics/physicsWorld.d.ts +26 -0
  107. package/dist/physics/physicsWorld.js +109 -117
  108. package/dist/physics/vehicleObstacles.d.ts +44 -0
  109. package/dist/physics/vehicleObstacles.js +115 -0
  110. package/dist/random/rng.d.ts +4 -1
  111. package/dist/random/rng.js +4 -1
  112. package/dist/runtime/adapter.d.ts +13 -0
  113. package/dist/runtime/adapter.js +13 -0
  114. package/dist/runtime/context/combat.d.ts +51 -0
  115. package/dist/runtime/context/combat.js +131 -0
  116. package/dist/runtime/context/combatFx.js +15 -5
  117. package/dist/runtime/context/deathLoot.d.ts +30 -0
  118. package/dist/runtime/context/deathLoot.js +39 -0
  119. package/dist/runtime/context/player.d.ts +48 -0
  120. package/dist/runtime/context/player.js +125 -0
  121. package/dist/runtime/context/scene.d.ts +60 -0
  122. package/dist/runtime/context/scene.js +329 -0
  123. package/dist/runtime/context/snapshotCodecs.d.ts +12 -0
  124. package/dist/runtime/context/snapshotCodecs.js +26 -0
  125. package/dist/runtime/descriptors/baseline.js +26 -3
  126. package/dist/runtime/descriptors/features.d.ts +5 -0
  127. package/dist/runtime/descriptors/features.js +20 -2
  128. package/dist/runtime/gameContext.d.ts +2 -428
  129. package/dist/runtime/gameContext.js +133 -500
  130. package/dist/runtime/gameContextTypes.d.ts +500 -0
  131. package/dist/runtime/gameContextTypes.js +1 -0
  132. package/dist/runtime/headlessRunner.d.ts +17 -2
  133. package/dist/runtime/headlessRunner.js +15 -0
  134. package/dist/runtime/hostPolicy.d.ts +51 -0
  135. package/dist/runtime/hostPolicy.js +68 -0
  136. package/dist/runtime/hostedGameRunner.d.ts +3 -1
  137. package/dist/runtime/hostedGameRunner.js +1 -0
  138. package/dist/runtime/hostedWorldSession.d.ts +3 -1
  139. package/dist/runtime/hostedWorldSession.js +1 -0
  140. package/dist/runtime/worldSnapshot.d.ts +13 -1
  141. package/dist/runtime/worldSnapshot.js +15 -3
  142. package/dist/scene/assetCatalog.d.ts +7 -0
  143. package/dist/scene/assetSpace.js +1 -3
  144. package/dist/scene/authoredTriggers.d.ts +50 -0
  145. package/dist/scene/authoredTriggers.js +114 -0
  146. package/dist/scene/builtinSceneKinds.js +8 -3
  147. package/dist/scene/colliders.d.ts +79 -0
  148. package/dist/scene/colliders.js +170 -0
  149. package/dist/scene/collisionMesh.d.ts +94 -0
  150. package/dist/scene/collisionMesh.js +699 -0
  151. package/dist/scene/companion.d.ts +147 -0
  152. package/dist/scene/companion.js +193 -0
  153. package/dist/scene/entityStats.d.ts +7 -9
  154. package/dist/scene/entityStats.js +19 -15
  155. package/dist/scene/sceneKinds.d.ts +16 -0
  156. package/dist/scene/sceneKinds.js +4 -0
  157. package/dist/scene/sceneRaycast.js +15 -0
  158. package/dist/stats/spawnLevelStats.d.ts +110 -0
  159. package/dist/stats/spawnLevelStats.js +140 -0
  160. package/dist/stats/statModifiers.d.ts +16 -0
  161. package/dist/stats/statModifiers.js +30 -1
  162. package/dist/stats/statPool.d.ts +70 -0
  163. package/dist/stats/statPool.js +62 -0
  164. package/dist/store/changeSignal.d.ts +3 -0
  165. package/dist/store/changeSignal.js +2 -0
  166. package/dist/store/observableKeyedStore.d.ts +2 -0
  167. package/dist/store/observableKeyedStore.js +1 -0
  168. package/dist/survival/decayMeter.js +1 -3
  169. package/dist/survival/regionHealth.js +1 -3
  170. package/dist/ui/hudLayout.d.ts +5 -0
  171. package/dist/ui/hudLayout.js +17 -1
  172. package/dist/ui/panelModel.d.ts +120 -0
  173. package/dist/ui/panelModel.js +168 -0
  174. package/dist/ui.d.ts +1 -0
  175. package/dist/ui.js +1 -0
  176. package/dist/world/authoredEntities.d.ts +29 -0
  177. package/dist/world/authoredEntities.js +29 -0
  178. package/dist/world/authoredObjects.d.ts +15 -1
  179. package/dist/world/authoredObjects.js +13 -1
  180. package/dist/world/authoredSpawn.d.ts +39 -0
  181. package/dist/world/authoredSpawn.js +37 -0
  182. package/dist/world/buildSockets.d.ts +152 -0
  183. package/dist/world/buildSockets.js +188 -0
  184. package/dist/world/buildingLots.d.ts +84 -0
  185. package/dist/world/buildingLots.js +119 -0
  186. package/dist/world/buildings.d.ts +6 -0
  187. package/dist/world/cityBlocks.d.ts +152 -0
  188. package/dist/world/cityBlocks.js +670 -0
  189. package/dist/world/cityContent.d.ts +77 -0
  190. package/dist/world/cityContent.js +261 -0
  191. package/dist/world/cityGenerator.d.ts +41 -0
  192. package/dist/world/cityGenerator.js +58 -0
  193. package/dist/world/cityGeometry.d.ts +114 -0
  194. package/dist/world/cityGeometry.js +573 -0
  195. package/dist/world/cityKind.d.ts +314 -0
  196. package/dist/world/cityKind.js +1578 -0
  197. package/dist/world/environmentSummary.js +57 -1
  198. package/dist/world/features.d.ts +53 -5
  199. package/dist/world/features.js +15 -3
  200. package/dist/world/grassKind.js +2 -2
  201. package/dist/world/markers.d.ts +50 -4
  202. package/dist/world/markers.js +49 -0
  203. package/dist/world/massing.js +1 -1
  204. package/dist/world/minimapBake.d.ts +78 -0
  205. package/dist/world/minimapBake.js +208 -0
  206. package/dist/world/minimapTrack.d.ts +17 -0
  207. package/dist/world/minimapTrack.js +22 -0
  208. package/dist/world/place.d.ts +166 -0
  209. package/dist/world/place.js +96 -0
  210. package/dist/world/placeAsset.js +3 -1
  211. package/dist/world/poleLineKind.d.ts +40 -0
  212. package/dist/world/poleLineKind.js +97 -0
  213. package/dist/world/polyline.js +9 -2
  214. package/dist/world/resourceNode.d.ts +109 -0
  215. package/dist/world/resourceNode.js +140 -0
  216. package/dist/world/roads.d.ts +19 -2
  217. package/dist/world/roads.js +60 -3
  218. package/dist/world/scatter.js +2 -20
  219. package/dist/world/streetGenerator.d.ts +159 -0
  220. package/dist/world/streetGenerator.js +684 -0
  221. package/dist/world/streets.d.ts +103 -1
  222. package/dist/world/streets.js +273 -0
  223. package/dist/world/terraform.js +58 -88
  224. package/dist/world/terrain.d.ts +65 -1
  225. package/dist/world/terrain.js +119 -2
  226. package/dist/world.d.ts +14 -5
  227. package/dist/world.js +12 -3
  228. package/package.json +1 -1
  229. package/skills/game-design/SKILL.md +28 -0
  230. package/skills/game-design/agents/openai.yaml +4 -0
  231. package/skills/game-design/references/application-playbook.md +170 -0
  232. package/skills/game-design/references/field-guide.md +288 -0
  233. package/skills/jgengine/SKILL.md +8 -4
  234. package/skills/jgengine/api.md +182 -81
  235. package/skills/jgengine/capabilities.md +104 -2
  236. package/skills/jgengine/recipes/minimal-game.md +88 -0
  237. package/skills/jgengine-combat/SKILL.md +19 -1
  238. package/skills/jgengine-combat/api.md +75 -2
  239. package/skills/jgengine-combat/capabilities.md +61 -1
  240. package/skills/jgengine-combat/recipes/portable-damage-effects.md +197 -0
  241. package/skills/jgengine-combat/recipes/portable-runtime-state.md +154 -0
  242. package/skills/jgengine-combat/recipes/portable-stat-pools.md +122 -0
  243. package/skills/jgengine-combat/recipes/portable-weapon-plumbing.md +156 -0
  244. package/skills/jgengine-combat/recipes/visible-attacks.md +81 -0
  245. package/skills/jgengine-combat/reference.md +33 -4
  246. package/skills/jgengine-gameplay/SKILL.md +8 -0
  247. package/skills/jgengine-gameplay/api.md +182 -26
  248. package/skills/jgengine-gameplay/capabilities.md +96 -2
  249. package/skills/jgengine-gameplay/recipes/portable-xp-leveling.md +168 -0
  250. package/skills/jgengine-gameplay/reference-systems.md +10 -5
  251. package/skills/jgengine-world/SKILL.md +13 -0
  252. package/skills/jgengine-world/api.md +453 -21
  253. package/skills/jgengine-world/capabilities.md +112 -2
  254. package/skills/jgengine-world/recipes/commanding-entities.md +128 -0
  255. package/skills/jgengine-world/reference.md +43 -4
  256. package/skills/jgengine/reference-cartridge.md +0 -72
@@ -5,14 +5,16 @@ export { runPipeline } from "./cards/modifierPipeline.js";
5
5
  export { type CropDef, type CropTileState } from "./crafting/crop.js";
6
6
  export { advanceTransport, createProductionState, drainOutput, feedProduction, productionBuilding, resolvePowerGrid, tickProduction, } from "./crafting/production.js";
7
7
  export { canCraft, craft, craftSeconds, createRecipeGraph, stationSatisfied, type RecipeDef, type RecipeItem, } from "./crafting/recipe.js";
8
+ export { createAuctionBook, type Auction, type AuctionSettlement } from "./economy/auctionBook.js";
8
9
  export { createListingBook, type Listing } from "./economy/listingBook.js";
10
+ export { createPriceHistory, type PriceStats, type SaleRecord } from "./economy/priceHistory.js";
9
11
  export { addScheduledRule, advanceLedger, annotate, balanceOf, cancelRule, capAmount, createResourceLedger, curveScale, pauseRule, redirect, rejectWhen, resumeRule, taxFraction, thresholdScale, type AdvanceOptions, type AdvanceResult, type AppliedTransaction, type CatchUpPolicy, type LedgerEvent, type LedgerEventKind, type PolicyContext, type PolicyRead, type Precision, type ResourceLedger, type ResourcePolicy, type ResourceTransaction, type RuleCursor, type ScheduledRule, type ThresholdBand, } from "./economy/resourceLedger.js";
10
12
  export { type TechNodeDef } from "./economy/techTree.js";
11
13
  export { balance, charge, chargeAll, createEmptyWallet, grant, isOverdrawn } from "./economy/wallet.js";
12
14
  export { DEFAULT_CHAT_BODY_LENGTH, DEFAULT_CHAT_HISTORY_LIMIT, DEFAULT_CHAT_RATE_LIMIT, createChatRateLimiter, type Chat, type ChatMessage, type ChatRateLimit, type ChatSendResult, } from "./game/chat.js";
13
15
  export { playControlsActive } from "./game/controlGate.js";
14
16
  export { createCosmetics } from "./game/cosmetics.js";
15
- export { defineGame, type GameDefinition, type GameDefinitionConfig, type GameLoop, type InventoryDeclaration, type LifecycleConfig, type PhysicsConfig, } from "./game/defineGame.js";
17
+ export { type GameDefinition, type GameDefinitionConfig, type GameLoop, type InventoryDeclaration, type LifecycleConfig, type PhysicsConfig, } from "./game/defineGame.js";
16
18
  export { defineSystem, type SystemDefinition, type SystemEventHandlers, type SystemTick } from "./game/defineSystem.js";
17
19
  export { createGameDialogue, dialogueSlot } from "./game/dialogue.js";
18
20
  export { createGameEvents, type CombatTelegraphEvent, type CombatVfxEvent, type EntityDiedEvent, type EntityFloatTextEvent, type GameEventMap, type GameEvents, type RetainedVfxKind, type StatLevelUpEvent, type VfxKind, type VfxRef, } from "./game/events.js";
package/dist/gameplay.js CHANGED
@@ -5,14 +5,16 @@ export { runPipeline } from "./cards/modifierPipeline.js";
5
5
  export {} from "./crafting/crop.js";
6
6
  export { advanceTransport, createProductionState, drainOutput, feedProduction, productionBuilding, resolvePowerGrid, tickProduction, } from "./crafting/production.js";
7
7
  export { canCraft, craft, craftSeconds, createRecipeGraph, stationSatisfied, } from "./crafting/recipe.js";
8
+ export { createAuctionBook } from "./economy/auctionBook.js";
8
9
  export { createListingBook } from "./economy/listingBook.js";
10
+ export { createPriceHistory } from "./economy/priceHistory.js";
9
11
  export { addScheduledRule, advanceLedger, annotate, balanceOf, cancelRule, capAmount, createResourceLedger, curveScale, pauseRule, redirect, rejectWhen, resumeRule, taxFraction, thresholdScale, } from "./economy/resourceLedger.js";
10
12
  export {} from "./economy/techTree.js";
11
13
  export { balance, charge, chargeAll, createEmptyWallet, grant, isOverdrawn } from "./economy/wallet.js";
12
14
  export { DEFAULT_CHAT_BODY_LENGTH, DEFAULT_CHAT_HISTORY_LIMIT, DEFAULT_CHAT_RATE_LIMIT, createChatRateLimiter, } from "./game/chat.js";
13
15
  export { playControlsActive } from "./game/controlGate.js";
14
16
  export { createCosmetics } from "./game/cosmetics.js";
15
- export { defineGame, } from "./game/defineGame.js";
17
+ export {} from "./game/defineGame.js";
16
18
  export { defineSystem } from "./game/defineSystem.js";
17
19
  export { createGameDialogue, dialogueSlot } from "./game/dialogue.js";
18
20
  export { createGameEvents, } from "./game/events.js";
@@ -0,0 +1,69 @@
1
+ /** One carried stack: an item id and how many of it the carrier holds. */
2
+ export interface LoadEntry {
3
+ itemId: string;
4
+ quantity: number;
5
+ }
6
+ /** Resolves the per-unit mass of an item id. Injected so mass tables stay decoupled from this module. */
7
+ export type MassResolver = (itemId: string) => number;
8
+ /**
9
+ * Tuning for where the encumbrance bands sit and how hard the carrier is slowed.
10
+ * Both fields are load *fractions* / multipliers in `0..1`, never absolute mass.
11
+ */
12
+ export interface EncumbranceConfig {
13
+ /**
14
+ * Load fraction at/above which the carrier becomes encumbered (loses sprint & jump and
15
+ * starts slowing). Below it the carrier is unencumbered. Default `0.85`.
16
+ */
17
+ soft?: number;
18
+ /**
19
+ * Move-speed multiplier reached as load approaches full capacity (fraction → 1), the floor
20
+ * the progressive slow curve bottoms out at just before immobility. Default `0.5`.
21
+ */
22
+ floor?: number;
23
+ }
24
+ /** Coarse carry state: free-moving, slowed, or pinned in place. */
25
+ export type EncumbranceTier = "unencumbered" | "encumbered" | "immobile";
26
+ /** Serializable snapshot of a carrier's load versus capacity and the movement consequences. */
27
+ export interface EncumbranceState {
28
+ /** Total carried mass (never negative). */
29
+ mass: number;
30
+ /** Carrying capacity this load was measured against. */
31
+ capacity: number;
32
+ /** `mass / capacity`, clamped to be finite and non-negative; may exceed 1 when overloaded. */
33
+ fraction: number;
34
+ tier: EncumbranceTier;
35
+ /** Movement speed scalar in `0..1`: 1 when unencumbered, decays toward `floor`, 0 when immobile. */
36
+ moveMultiplier: number;
37
+ canSprint: boolean;
38
+ canJump: boolean;
39
+ immobile: boolean;
40
+ }
41
+ /**
42
+ * Sum the mass of every carried stack using an injected per-unit mass resolver. Bounded by the
43
+ * number of entries; allocates nothing. Missing or negative unit masses are treated as 0.
44
+ *
45
+ * @param entries carried stacks (`{ itemId, quantity }`, compatible with any inventory listing).
46
+ * @param massOf per-unit mass lookup for an item id.
47
+ * @capability carry-load sum carried mass from item stacks via an injected mass resolver
48
+ */
49
+ export declare function totalLoad(entries: readonly LoadEntry[], massOf: MassResolver): number;
50
+ /**
51
+ * Progressive move-speed multiplier for a raw load fraction: 1 while at/below `soft`, a linear
52
+ * decay from 1 down toward `floor` across the `soft..1` band, and 0 once at/over capacity.
53
+ * Pure and side-effect free — the curve `resolveEncumbrance` reads for `moveMultiplier`.
54
+ *
55
+ * @capability encumbrance-move-curve progressive move-speed multiplier from a carry-load fraction
56
+ */
57
+ export declare function encumbranceMoveMultiplier(fraction: number, config?: EncumbranceConfig): number;
58
+ /**
59
+ * Resolve carried mass against carrying capacity into a serializable encumbrance state: load
60
+ * fraction, tier, and the movement consequences (`canSprint`/`canJump`/`immobile` gates plus a
61
+ * progressive `moveMultiplier`). Below `soft` the carrier is unencumbered and unhindered; from
62
+ * `soft` up to capacity it is encumbered — no sprint or jump, and speed decays from 1 toward
63
+ * `floor`; at or above capacity it is immobile (multiplier 0). Deterministic and allocation-light;
64
+ * pair with {@link totalLoad} to derive `mass` from an inventory and a mass table. A capacity of 0
65
+ * or less pins any positive load as immobile (`fraction` 1) so the state stays finite/serializable.
66
+ *
67
+ * @capability carry-encumbrance carried mass vs capacity into movement penalty tiers and a speed multiplier
68
+ */
69
+ export declare function resolveEncumbrance(mass: number, capacity: number, config?: EncumbranceConfig): EncumbranceState;
@@ -0,0 +1,68 @@
1
+ const DEFAULT_SOFT = 0.85;
2
+ const DEFAULT_FLOOR = 0.5;
3
+ /**
4
+ * Sum the mass of every carried stack using an injected per-unit mass resolver. Bounded by the
5
+ * number of entries; allocates nothing. Missing or negative unit masses are treated as 0.
6
+ *
7
+ * @param entries carried stacks (`{ itemId, quantity }`, compatible with any inventory listing).
8
+ * @param massOf per-unit mass lookup for an item id.
9
+ * @capability carry-load sum carried mass from item stacks via an injected mass resolver
10
+ */
11
+ export function totalLoad(entries, massOf) {
12
+ let total = 0;
13
+ for (const entry of entries) {
14
+ if (entry.quantity <= 0)
15
+ continue;
16
+ const unit = massOf(entry.itemId);
17
+ if (unit > 0)
18
+ total += unit * entry.quantity;
19
+ }
20
+ return total;
21
+ }
22
+ /**
23
+ * Progressive move-speed multiplier for a raw load fraction: 1 while at/below `soft`, a linear
24
+ * decay from 1 down toward `floor` across the `soft..1` band, and 0 once at/over capacity.
25
+ * Pure and side-effect free — the curve `resolveEncumbrance` reads for `moveMultiplier`.
26
+ *
27
+ * @capability encumbrance-move-curve progressive move-speed multiplier from a carry-load fraction
28
+ */
29
+ export function encumbranceMoveMultiplier(fraction, config) {
30
+ const soft = config?.soft ?? DEFAULT_SOFT;
31
+ const floor = config?.floor ?? DEFAULT_FLOOR;
32
+ if (fraction >= 1)
33
+ return 0;
34
+ if (fraction <= soft)
35
+ return 1;
36
+ const span = 1 - soft;
37
+ const t = span > 0 ? (fraction - soft) / span : 1;
38
+ return 1 - (1 - floor) * t;
39
+ }
40
+ /**
41
+ * Resolve carried mass against carrying capacity into a serializable encumbrance state: load
42
+ * fraction, tier, and the movement consequences (`canSprint`/`canJump`/`immobile` gates plus a
43
+ * progressive `moveMultiplier`). Below `soft` the carrier is unencumbered and unhindered; from
44
+ * `soft` up to capacity it is encumbered — no sprint or jump, and speed decays from 1 toward
45
+ * `floor`; at or above capacity it is immobile (multiplier 0). Deterministic and allocation-light;
46
+ * pair with {@link totalLoad} to derive `mass` from an inventory and a mass table. A capacity of 0
47
+ * or less pins any positive load as immobile (`fraction` 1) so the state stays finite/serializable.
48
+ *
49
+ * @capability carry-encumbrance carried mass vs capacity into movement penalty tiers and a speed multiplier
50
+ */
51
+ export function resolveEncumbrance(mass, capacity, config) {
52
+ const soft = config?.soft ?? DEFAULT_SOFT;
53
+ const safeMass = mass > 0 ? mass : 0;
54
+ const fraction = capacity > 0 ? safeMass / capacity : safeMass > 0 ? 1 : 0;
55
+ const immobile = fraction >= 1;
56
+ const tier = immobile ? "immobile" : fraction >= soft ? "encumbered" : "unencumbered";
57
+ const unencumbered = tier === "unencumbered";
58
+ return {
59
+ mass: safeMass,
60
+ capacity,
61
+ fraction,
62
+ tier,
63
+ moveMultiplier: encumbranceMoveMultiplier(fraction, config),
64
+ canSprint: unencumbered,
65
+ canJump: unencumbered,
66
+ immobile,
67
+ };
68
+ }
@@ -35,25 +35,41 @@ export type MoveResult = {
35
35
  status: "rejected";
36
36
  reason: "invalid-slot" | "empty-slot" | "wrong-kind" | "no-space";
37
37
  };
38
+ /** Outcome of {@link splitStack}: the new state, or a rejection reason. */
39
+ export type SplitResult = {
40
+ status: "ok";
41
+ state: InventoryState;
42
+ } | {
43
+ status: "rejected";
44
+ reason: "invalid-slot" | "empty-slot" | "invalid-amount" | "no-space" | "slot-occupied";
45
+ };
38
46
  /**
39
47
  * A bag of stackable items supporting add, remove, count, and move — the base inventory model.
40
48
  *
41
49
  * @capability inventory-grid a bag of stackable items with add, remove, and move
42
- * @internal
43
- */
50
+ */
44
51
  export declare function createEmptyInventory(layout: InventoryLayout): InventoryState;
45
- /** @internal */
52
+ /** Add `count` of `itemId` — into a specific `options.slot`, or auto-stacked across free/partial slots. Rejects on no space or wrong kind. */
46
53
  export declare function putItem(state: InventoryState, layout: InventoryLayout, traits: ItemTraits, itemId: string, count: number, options?: {
47
54
  slot?: number;
48
55
  }): PutResult;
49
- /** @internal */
56
+ /** Remove `count` of `itemId`, draining from the last matching stacks first. Rejects if fewer than `count` are held. */
50
57
  export declare function takeItem(state: InventoryState, itemId: string, count: number): TakeResult;
51
- /** @internal */
58
+ /** Total quantity of `itemId` summed across every stack in the inventory. */
52
59
  export declare function countItem(state: InventoryState, itemId: string): number;
53
- /** @internal */
60
+ /** True when the inventory holds at least `count` of `itemId`. */
54
61
  export declare function hasItem(state: InventoryState, itemId: string, count: number): boolean;
55
- /** @internal */
62
+ /** Move the stack at `fromSlot` to `toSlot` (or auto-stack when omitted) — swapping, merging with remainder, or relocating across two inventories. Rejects on empty source, wrong kind, or no space. */
56
63
  export declare function moveItem(from: InventoryState, fromSlot: number, to: InventoryState, toLayout: InventoryLayout, traits: ItemTraits, toSlot?: number): MoveResult;
64
+ /**
65
+ * Splits `amount` off the stack in `slot` into `toSlot` (or the first empty slot when `toSlot` is
66
+ * omitted). Limit-agnostic: it does not consult {@link ItemTraits}, so a same-item target merges by
67
+ * simple addition. Rejects an empty source, `amount <= 0`, `amount >= stack count` (a split must
68
+ * leave both sides non-empty), no free slot, or a target holding a different item.
69
+ *
70
+ * @capability inventory-split split a stack into a target or first-empty slot
71
+ */
72
+ export declare function splitStack(state: InventoryState, slot: number, amount: number, toSlot?: number): SplitResult;
57
73
  export interface InventorySet<TId extends string> {
58
74
  put(id: TId, itemId: string, count: number, options?: {
59
75
  slot?: number;
@@ -65,5 +81,5 @@ export interface InventorySet<TId extends string> {
65
81
  state(id: TId): InventoryState;
66
82
  replaceState(id: TId, state: InventoryState): void;
67
83
  }
68
- /** @internal */
84
+ /** Build a keyed set of named inventories from their `layouts`, sharing one `traits` table — the stateful façade over put/take/move/count. */
69
85
  export declare function createInventorySet<TId extends string>(layouts: Record<TId, InventoryLayout>, traits: ItemTraits): InventorySet<TId>;
@@ -2,8 +2,7 @@
2
2
  * A bag of stackable items supporting add, remove, count, and move — the base inventory model.
3
3
  *
4
4
  * @capability inventory-grid a bag of stackable items with add, remove, and move
5
- * @internal
6
- */
5
+ */
7
6
  export function createEmptyInventory(layout) {
8
7
  return { slots: new Array(layout.slots).fill(null) };
9
8
  }
@@ -82,13 +81,13 @@ function explicitPut(state, layout, traits, itemId, count, slot) {
82
81
  slots[slot] = { itemId, count: newCount };
83
82
  return { status: "ok", state: { slots } };
84
83
  }
85
- /** @internal */
84
+ /** Add `count` of `itemId` — into a specific `options.slot`, or auto-stacked across free/partial slots. Rejects on no space or wrong kind. */
86
85
  export function putItem(state, layout, traits, itemId, count, options) {
87
86
  if (options?.slot !== undefined)
88
87
  return explicitPut(state, layout, traits, itemId, count, options.slot);
89
88
  return autoPut(state, layout, traits, itemId, count);
90
89
  }
91
- /** @internal */
90
+ /** Remove `count` of `itemId`, draining from the last matching stacks first. Rejects if fewer than `count` are held. */
92
91
  export function takeItem(state, itemId, count) {
93
92
  if (count <= 0)
94
93
  return { status: "ok", state };
@@ -107,7 +106,7 @@ export function takeItem(state, itemId, count) {
107
106
  }
108
107
  return { status: "ok", state: { slots } };
109
108
  }
110
- /** @internal */
109
+ /** Total quantity of `itemId` summed across every stack in the inventory. */
111
110
  export function countItem(state, itemId) {
112
111
  let total = 0;
113
112
  for (const slot of state.slots)
@@ -115,11 +114,11 @@ export function countItem(state, itemId) {
115
114
  total += slot.count;
116
115
  return total;
117
116
  }
118
- /** @internal */
117
+ /** True when the inventory holds at least `count` of `itemId`. */
119
118
  export function hasItem(state, itemId, count) {
120
119
  return countItem(state, itemId) >= count;
121
120
  }
122
- /** @internal */
121
+ /** Move the stack at `fromSlot` to `toSlot` (or auto-stack when omitted) — swapping, merging with remainder, or relocating across two inventories. Rejects on empty source, wrong kind, or no space. */
123
122
  export function moveItem(from, fromSlot, to, toLayout, traits, toSlot) {
124
123
  if (fromSlot < 0 || fromSlot >= from.slots.length)
125
124
  return { status: "rejected", reason: "invalid-slot" };
@@ -171,7 +170,47 @@ export function moveItem(from, fromSlot, to, toLayout, traits, toSlot) {
171
170
  to: sameInventory ? { slots: fromSlots } : { slots: toSlots },
172
171
  };
173
172
  }
174
- /** @internal */
173
+ /**
174
+ * Splits `amount` off the stack in `slot` into `toSlot` (or the first empty slot when `toSlot` is
175
+ * omitted). Limit-agnostic: it does not consult {@link ItemTraits}, so a same-item target merges by
176
+ * simple addition. Rejects an empty source, `amount <= 0`, `amount >= stack count` (a split must
177
+ * leave both sides non-empty), no free slot, or a target holding a different item.
178
+ *
179
+ * @capability inventory-split split a stack into a target or first-empty slot
180
+ */
181
+ export function splitStack(state, slot, amount, toSlot) {
182
+ if (slot < 0 || slot >= state.slots.length)
183
+ return { status: "rejected", reason: "invalid-slot" };
184
+ const source = state.slots[slot];
185
+ if (source === null)
186
+ return { status: "rejected", reason: "empty-slot" };
187
+ if (amount <= 0 || amount >= source.count)
188
+ return { status: "rejected", reason: "invalid-amount" };
189
+ let target = toSlot;
190
+ if (target === undefined) {
191
+ target = state.slots.findIndex((s) => s === null);
192
+ if (target === -1)
193
+ return { status: "rejected", reason: "no-space" };
194
+ }
195
+ else {
196
+ if (target < 0 || target >= state.slots.length)
197
+ return { status: "rejected", reason: "invalid-slot" };
198
+ if (target === slot)
199
+ return { status: "rejected", reason: "slot-occupied" };
200
+ const dest = state.slots[target];
201
+ if (dest !== null && dest.itemId !== source.itemId)
202
+ return { status: "rejected", reason: "slot-occupied" };
203
+ }
204
+ const slots = state.slots.slice();
205
+ const dest = slots[target];
206
+ slots[slot] = { itemId: source.itemId, count: source.count - amount };
207
+ slots[target] =
208
+ dest === null || dest === undefined
209
+ ? { itemId: source.itemId, count: amount }
210
+ : { itemId: source.itemId, count: dest.count + amount };
211
+ return { status: "ok", state: { slots } };
212
+ }
213
+ /** Build a keyed set of named inventories from their `layouts`, sharing one `traits` table — the stateful façade over put/take/move/count. */
175
214
  export function createInventorySet(layouts, traits) {
176
215
  const states = new Map();
177
216
  for (const id of Object.keys(layouts)) {
@@ -1,6 +1,4 @@
1
- function clamp(value, min, max) {
2
- return value < min ? min : value > max ? max : value;
3
- }
1
+ import { clamp } from "../math/scalar.js";
4
2
  export function createDurability(spec) {
5
3
  const max = Math.max(0, spec.max);
6
4
  return { current: max, max };
@@ -1,5 +1,5 @@
1
1
  /** Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating. */
2
- export declare const VERSION = "0.11.0";
2
+ export declare const VERSION = "0.13.0";
3
3
  /** One release's migrate steps plus added/changed/removed notes (typed mirror of CHANGELOG.md). */
4
4
  export interface ChangelogEntry {
5
5
  migrate: readonly string[];
@@ -1,7 +1,54 @@
1
1
  /** Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating. */
2
- export const VERSION = "0.11.0";
2
+ export const VERSION = "0.13.0";
3
3
  /** Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`). */
4
4
  export const CHANGELOG = {
5
+ "0.13.0": {
6
+ migrate: [
7
+ "Bump lockstep SDK packages to ^0.13.0: @jgengine/{core,react,ws,node,sql,convex,shell,editor,assets}. CLI jgengine is 0.10.0; @jgengine/github is unchanged.",
8
+ "Nothing required — additive release. Two overridable defaults changed in the player's favor: offline games persist to localStorage automatically (#1207) and rigged catalog assets animate by default via clip metadata + semantic clip roles (#1209); opt out explicitly if a game relied on the old behavior.",
9
+ ],
10
+ added: [
11
+ "Drop-in inventory grid, window manager, and character sheet (#1220) — @jgengine/react InventoryGrid (drag/stack/split), toggleable windows backed by @jgengine/core/ui/panelModel, CharacterSheet paperdoll; all HudTheme-skinnable.",
12
+ "Serializable runtime snapshots (#1179) — capture/restore live runtime state including magazine state (@jgengine/core/combat/magazine) and stat modifiers.",
13
+ "Portable weapon plumbing (#1211) — @jgengine/core/combat/weaponFire composes trigger → magazine → spread → projectile/hitscan as data.",
14
+ "Portable leveling (#1206) and portable damage-pool access (#1208) as genre-agnostic game-context seams.",
15
+ "Named combat VFX presets (#1204) — @jgengine/core/combat/vfxPresets; a visible attack is one line.",
16
+ "Runtime-measured render-bounds hitboxes (#1227) — colliders wrap what the shell actually mounts.",
17
+ "Procedural part motion for rig-less part-composed characters (#1222); squash & stretch and death splat (#1229).",
18
+ "City fabric (#1191) — streetGenerator + cityGenerator, editor bake verb, street-aware lots, road junctions, grounding.",
19
+ "jgengine recipe CLI (#1214, #1231) — vetted wired compositions, compile-pinned against the SDK.",
20
+ "Editor/debug modes mirrored to the URL with an editor exit (#1203); dependency-free WebGL screenshot script in the game template (#1202).",
21
+ ],
22
+ changed: [
23
+ "Offline games persist to localStorage by default (#1207); rigged assets animate by default (#1209).",
24
+ "RTS camera pan inversion fixed (#1228); sprite raycast warning silenced and duplicate nameplate health bars removed (#1194); scaffolded F2+E editor Tailwind @source renders its theme (#1195).",
25
+ "Outside-game DX: stat-pool no-op stability, camera-transparent decor, cleaner install/version output, headless UI-intent seam (#1223).",
26
+ ],
27
+ removed: [],
28
+ },
29
+ "0.12.0": {
30
+ migrate: [
31
+ "Bump lockstep SDK packages to ^0.12.0: @jgengine/{core,react,ws,node,sql,convex,shell,editor,assets}. CLI jgengine is 0.9.0; @jgengine/github is unchanged.",
32
+ "Author games through defineGame from @jgengine/shell — the single public authoring entry. Core's constructor was renamed defineGame → defineGameDefinition (@jgengine/core/game/defineGame); only hosts and tooling call it. GamePlayer/GamePlayerShell are @internal; GameHost is the documented mount and owns the editor summon (?mode=editor, F2+E, dev save endpoint) — delete per-game editor bootstraps.",
33
+ "Declare worlds with world({ id, ground: { mode, size, surface? }, physics? }) from @jgengine/core/world/place (flat/round/voxel/board). environment() still works as the legacy consumption path for editor-written scene data — nothing required for existing games; author dressing in the editor.",
34
+ ],
35
+ added: [
36
+ "world() place API (#1178) — @jgengine/core/world/place: ground mode/size discriminated at type and runtime (flat {x,z} with Infinity, round {radius}, voxel generator domain, board {x,y} cells); ground.surface matter/feel laws; per-place physics over the game default; multiple worlds per game; shell PlaceScene substrate rendering; seeds via seedForPlace.",
37
+ "@jgengine/shell/gameKit — happy-path authoring surface: authoring, mount, stores, systems, authored-scene helpers, HUD primitives, seeded rng.",
38
+ "Portable generic stat pools — @jgengine/core/stats/statPool (bounded named pools: health, energy, heat, ammo…); entity stats rebuilt on them; recipe in skill jgengine-combat.",
39
+ "Auction-house economy primitives (#1086) — @jgengine/core/economy/auctionBook: timed bid auctions, escrowed bids, outbid refunds, optional buyout, anti-snipe extension, settlement with house cut; plus market price history.",
40
+ "Portable minimap marker sources — core world seam for game minimaps; recipe in skill jgengine-ui.",
41
+ "Unified seed-driven path network + city generation v2 (#1103, #1106) — street/block/building dials, zone bands, presets, junction geometry, road-derived parcels; shell renders real intersections, curbs, lane lines, tunnels, bridge decks.",
42
+ "Mesh-accurate hitboxes (#1075), auto-fit colliders from model bounds (#1072), movement obstruction from fitted boxes + compound-box openings (#1077).",
43
+ "Editor overhaul (#1110) — materials/lighting/scripting/animation/multiplayer workspaces, command palette, minimap bake (#1036), GLB thumbnails, hierarchy DnD/keys/context/visibility-lock, multi-select inspector, console RPC, asset drag-place, ortho projection, pivot modes, play chrome.",
44
+ "Editor authoring pipeline — entity definitions and starter catalogs (#1012), Data-tab catalogs/schema fields (#1043), promote authored scene folder to runnable game (#1011), shared trigger primitive with goal/win rules (#1013), asset import rewrites game/assets.ts (#1042).",
45
+ ],
46
+ changed: [
47
+ "npx jgengine create scaffolds a thin infinite flat world() place (no more 96m meadow); editor blankWorld drops pre-baked seeded grass — dressing belongs in editor.scene.json.",
48
+ "Environment studio visual-quality pass; grass fields no longer rebuild geometry per frame (#1090).",
49
+ ],
50
+ removed: [],
51
+ },
5
52
  "0.11.0": {
6
53
  migrate: [
7
54
  "Bump lockstep SDK packages to ^0.11.0: @jgengine/{core,react,ws,node,sql,convex,shell,editor,assets}. CLI jgengine and @jgengine/github may lag on their own version lines.",
@@ -121,9 +121,23 @@ export declare function advancePlayerMotion(motion: PlayerMotionState, intent: M
121
121
  /** A placed scene object the walking player collides against as a circle-vs-AABB obstacle. */
122
122
  export interface CollisionObstacle {
123
123
  position: readonly [number, number, number];
124
- /** Box half-size on each axis. Default `[0.5, 0.5, 0.5]` (unit-box scene objects). */
124
+ /** Box half-size on each axis. Default `[0.5, 0.5, 0.5]` (unit-box scene objects). Ignored when `boxes` is set. */
125
125
  halfExtents?: readonly [number, number, number];
126
+ /** Local center shift added to `position` for the single-AABB path (the collider's yaw-rotated offset). Ignored when `boxes` is set. */
127
+ offset?: readonly [number, number, number];
128
+ /**
129
+ * Optional compound solid: axis-aligned sub-boxes as `min`/`max` **relative to `position`** (add
130
+ * `position` for world space), already yaw-rotated and conservatively expanded by the caller. When
131
+ * present the capsule slides against EACH sub-box instead of the single `halfExtents` AABB, so a
132
+ * concave opening (the gap between an archway's pillars/lintel) lets the capsule pass through.
133
+ */
134
+ boxes?: readonly {
135
+ min: readonly [number, number, number];
136
+ max: readonly [number, number, number];
137
+ }[];
126
138
  }
139
+ /** @internal Player radius the walking obstruction inflates obstacles by; callers gather within reach of it. */
140
+ export declare const DEFAULT_OBSTACLE_PLAYER_RADIUS = 0.3;
127
141
  /**
128
142
  * Resolve a horizontal step against nearby placed objects with classic
129
143
  * axis-separated sliding (try X, then Z against the post-X position) so
@@ -187,7 +187,8 @@ export function advancePlayerMotion(motion, intent, forwardX, forwardZ, baseSpee
187
187
  };
188
188
  }
189
189
  const DEFAULT_OBSTACLE_HALF_EXTENTS = [0.5, 0.5, 0.5];
190
- const DEFAULT_OBSTACLE_PLAYER_RADIUS = 0.3;
190
+ /** @internal Player radius the walking obstruction inflates obstacles by; callers gather within reach of it. */
191
+ export const DEFAULT_OBSTACLE_PLAYER_RADIUS = 0.3;
191
192
  /** Feet-to-head span used for the obstacle's vertical overlap test; matches DEFAULT_VOXEL_DIMS.height. */
192
193
  const OBSTACLE_PLAYER_HEIGHT = 1.8;
193
194
  function clampAxisStep(originalCoord, step, min, max) {
@@ -216,22 +217,39 @@ export function resolveObstacleStep(current, stepX, stepZ, obstacles, playerRadi
216
217
  const reachX = Math.abs(stepX) + playerRadius;
217
218
  const reachZ = Math.abs(stepZ) + playerRadius;
218
219
  const nearby = [];
219
- for (const obstacle of obstacles) {
220
- const halfExtents = obstacle.halfExtents ?? DEFAULT_OBSTACLE_HALF_EXTENTS;
221
- const obstacleBottom = obstacle.position[1] - halfExtents[1];
222
- const obstacleTop = obstacle.position[1] + halfExtents[1];
223
- if (obstacleTop <= feetY || obstacleBottom >= headY)
224
- continue;
225
- const dx = obstacle.position[0] - current[0];
226
- const dz = obstacle.position[2] - current[2];
227
- if (Math.abs(dx) > halfExtents[0] + reachX || Math.abs(dz) > halfExtents[2] + reachZ)
228
- continue;
220
+ const considerBox = (minX, maxX, minY, maxY, minZ, maxZ) => {
221
+ // Vertical-span cull: a sub-box entirely above the head (walk under the lintel) or below the feet is skipped.
222
+ if (maxY <= feetY || minY >= headY)
223
+ return;
224
+ const centerX = (minX + maxX) / 2;
225
+ const centerZ = (minZ + maxZ) / 2;
226
+ const halfX = (maxX - minX) / 2;
227
+ const halfZ = (maxZ - minZ) / 2;
228
+ if (Math.abs(centerX - current[0]) > halfX + reachX || Math.abs(centerZ - current[2]) > halfZ + reachZ)
229
+ return;
229
230
  nearby.push({
230
- minX: obstacle.position[0] - halfExtents[0] - playerRadius,
231
- maxX: obstacle.position[0] + halfExtents[0] + playerRadius,
232
- minZ: obstacle.position[2] - halfExtents[2] - playerRadius,
233
- maxZ: obstacle.position[2] + halfExtents[2] + playerRadius,
231
+ minX: minX - playerRadius,
232
+ maxX: maxX + playerRadius,
233
+ minZ: minZ - playerRadius,
234
+ maxZ: maxZ + playerRadius,
234
235
  });
236
+ };
237
+ for (const obstacle of obstacles) {
238
+ const px = obstacle.position[0];
239
+ const py = obstacle.position[1];
240
+ const pz = obstacle.position[2];
241
+ if (obstacle.boxes !== undefined && obstacle.boxes.length > 0) {
242
+ for (const box of obstacle.boxes) {
243
+ considerBox(px + box.min[0], px + box.max[0], py + box.min[1], py + box.max[1], pz + box.min[2], pz + box.max[2]);
244
+ }
245
+ continue;
246
+ }
247
+ const halfExtents = obstacle.halfExtents ?? DEFAULT_OBSTACLE_HALF_EXTENTS;
248
+ const offset = obstacle.offset;
249
+ const cx = px + (offset !== undefined ? offset[0] : 0);
250
+ const cy = py + (offset !== undefined ? offset[1] : 0);
251
+ const cz = pz + (offset !== undefined ? offset[2] : 0);
252
+ considerBox(cx - halfExtents[0], cx + halfExtents[0], cy - halfExtents[1], cy + halfExtents[1], cz - halfExtents[2], cz + halfExtents[2]);
235
253
  }
236
254
  let resultX = stepX;
237
255
  for (const box of nearby) {
@@ -1,6 +1,7 @@
1
1
  import { applyHorizontalImpulses, applyMotionImpulses } from "../runtime/motionIntents.js";
2
2
  import { groundFieldFor, hasEnvironmentTerrain, sampleSlope } from "../world/terrain.js";
3
- import { advancePlayerMotion, constrainStepToAxis, createEmptyMovementKeys, createPlayerMotionState, nearbyObstacles, resolveMovementIntent, resolveObstacleStep, snapPositionToGrid, } from "./movementModel.js";
3
+ import { resolveColliders } from "../scene/colliders.js";
4
+ import { advancePlayerMotion, constrainStepToAxis, createEmptyMovementKeys, createPlayerMotionState, DEFAULT_OBSTACLE_PLAYER_RADIUS, resolveMovementIntent, resolveObstacleStep, snapPositionToGrid, } from "./movementModel.js";
4
5
  import { approachYaw, steerYaw } from "./steering.js";
5
6
  import { advanceVoxelPlayer, createVoxelPlayerBody, } from "./voxelController.js";
6
7
  const DEFAULT_TURN_SPEED = 2.4;
@@ -180,7 +181,7 @@ export function stepPlayerMovement(ctx, userId, input, dt, tuning, heading) {
180
181
  stepZ = constrained.stepZ;
181
182
  }
182
183
  if (tuning.movement?.collideObjects === true) {
183
- const obstacles = nearbyObstacles(ctx.scene.object.list(), player.position);
184
+ const obstacles = gatherMovementObstacles(ctx, player.position, stepX, stepZ);
184
185
  const resolved = resolveObstacleStep(player.position, stepX, stepZ, obstacles);
185
186
  stepX = resolved.stepX;
186
187
  stepZ = resolved.stepZ;
@@ -234,3 +235,102 @@ export function stepPlayerMovement(ctx, userId, input, dt, tuning, heading) {
234
235
  dt,
235
236
  });
236
237
  }
238
+ /** Feet-to-head span the obstruction test uses; matches `movementModel`'s `OBSTACLE_PLAYER_HEIGHT`. */
239
+ const OBSTACLE_PLAYER_HEIGHT = 1.8;
240
+ /**
241
+ * Largest collider half-extent the bounded broadphase assumes, so an object whose blocking box reaches
242
+ * the player still has its center inside the `inBox` query — a ~4m wall (half-extent ~2m) is caught. An
243
+ * object wider than this on an axis could be missed at its far edge; raise it if a wider blocker exists.
244
+ */
245
+ const OBSTACLE_MAX_HALF_EXTENT = 4;
246
+ /**
247
+ * Gather the player's nearby blocking obstacles from resolved colliders — the mesh-accurate replacement
248
+ * for the old `nearbyObstacles(list(), …)` which emitted a default 1×1×1 box per object. Bounded by an
249
+ * `inBox` broadphase (reach ≥ player radius + max half-extent so wide walls can't be missed), then, per
250
+ * candidate, keeps only blocking physical colliders and emits their real half-extents/offset (or, for a
251
+ * mesh collider carrying `boxes`, the compound sub-boxes), conservatively yaw-rotated by the object's
252
+ * `rotationY`. Objects with no resolved collider fall back to the default box; objects whose colliders
253
+ * don't block stop obstructing. Runs for both the shell's local player and a host, so parity is automatic.
254
+ */
255
+ function gatherMovementObstacles(ctx, position, stepX, stepZ) {
256
+ const reachX = Math.abs(stepX) + DEFAULT_OBSTACLE_PLAYER_RADIUS + OBSTACLE_MAX_HALF_EXTENT;
257
+ const reachZ = Math.abs(stepZ) + DEFAULT_OBSTACLE_PLAYER_RADIUS + OBSTACLE_MAX_HALF_EXTENT;
258
+ const min = [
259
+ position[0] - reachX,
260
+ position[1] - OBSTACLE_MAX_HALF_EXTENT,
261
+ position[2] - reachZ,
262
+ ];
263
+ const max = [
264
+ position[0] + reachX,
265
+ position[1] + OBSTACLE_PLAYER_HEIGHT + OBSTACLE_MAX_HALF_EXTENT,
266
+ position[2] + reachZ,
267
+ ];
268
+ const obstacles = [];
269
+ for (const object of ctx.scene.object.inBox(min, max)) {
270
+ const set = ctx.scene.object.collidersOf(object.instanceId);
271
+ if (set === null) {
272
+ // No resolved collider: preserve today's default 1×1×1 box.
273
+ obstacles.push({ position: object.position });
274
+ continue;
275
+ }
276
+ for (const collider of resolveColliders(set)) {
277
+ if (!collider.blocks || collider.purpose !== "physical")
278
+ continue;
279
+ obstacles.push(obstacleFromCollider(collider, object.position, object.rotationY));
280
+ }
281
+ }
282
+ return obstacles;
283
+ }
284
+ /** One blocking collider as a {@link CollisionObstacle}: a compound `boxes` obstacle for a mesh collider
285
+ * carrying sub-boxes, else a single half-extents/offset AABB. Yaw is applied by conservatively expanding
286
+ * each AABB to its rotated extent (mirroring `worldOffset`), so the obstruction never under-covers. */
287
+ function obstacleFromCollider(collider, position, rotationY) {
288
+ const cos = Math.cos(rotationY);
289
+ const sin = Math.sin(rotationY);
290
+ const shape = collider.shape;
291
+ if (shape.kind === "mesh" && shape.boxes !== undefined && shape.boxes.length > 0) {
292
+ return {
293
+ position,
294
+ boxes: shape.boxes.map((b) => yawExpandLocalBox(b.min, b.max, cos, sin)),
295
+ };
296
+ }
297
+ let hx;
298
+ let hy;
299
+ let hz;
300
+ if (shape.kind === "sphere") {
301
+ hx = hy = hz = shape.radius;
302
+ }
303
+ else {
304
+ hx = shape.halfExtents[0];
305
+ hy = shape.halfExtents[1];
306
+ hz = shape.halfExtents[2];
307
+ }
308
+ const offset = shape.offset;
309
+ const ox = offset !== undefined ? offset[0] : 0;
310
+ const oy = offset !== undefined ? offset[1] : 0;
311
+ const oz = offset !== undefined ? offset[2] : 0;
312
+ return {
313
+ position,
314
+ // Rotate the offset by yaw (mirror of colliders.worldOffset), expand the extents to the rotated AABB.
315
+ offset: [ox * cos + oz * sin, oy, -ox * sin + oz * cos],
316
+ halfExtents: [Math.abs(hx * cos) + Math.abs(hz * sin), hy, Math.abs(hx * sin) + Math.abs(hz * cos)],
317
+ };
318
+ }
319
+ /** Yaw-rotate an entity-local AABB about the object origin and re-fit it to an axis-aligned box, expressed
320
+ * as `min`/`max` relative to `position`. Conservative: the rotated box is grown to its enclosing AABB. */
321
+ function yawExpandLocalBox(boxMin, boxMax, cos, sin) {
322
+ const centerX = (boxMin[0] + boxMax[0]) / 2;
323
+ const centerY = (boxMin[1] + boxMax[1]) / 2;
324
+ const centerZ = (boxMin[2] + boxMax[2]) / 2;
325
+ const halfX = (boxMax[0] - boxMin[0]) / 2;
326
+ const halfY = (boxMax[1] - boxMin[1]) / 2;
327
+ const halfZ = (boxMax[2] - boxMin[2]) / 2;
328
+ const rcX = centerX * cos + centerZ * sin;
329
+ const rcZ = -centerX * sin + centerZ * cos;
330
+ const rhX = Math.abs(halfX * cos) + Math.abs(halfZ * sin);
331
+ const rhZ = Math.abs(halfX * sin) + Math.abs(halfZ * cos);
332
+ return {
333
+ min: [rcX - rhX, centerY - halfY, rcZ - rhZ],
334
+ max: [rcX + rhX, centerY + halfY, rcZ + rhZ],
335
+ };
336
+ }