@jgengine/shell 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 (151) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/dist/CombatPresentation.d.ts +5 -0
  3. package/dist/CombatPresentation.js +8 -0
  4. package/dist/GameHost.d.ts +27 -1
  5. package/dist/GameHost.js +83 -4
  6. package/dist/GamePlayer.d.ts +5 -0
  7. package/dist/GamePlayer.js +5 -0
  8. package/dist/GamePlayerShell.d.ts +5 -0
  9. package/dist/GamePlayerShell.js +63 -359
  10. package/dist/GameUiPreview.js +3 -0
  11. package/dist/Shell3dPresentation.d.ts +63 -0
  12. package/dist/Shell3dPresentation.js +302 -0
  13. package/dist/ShellChrome.d.ts +51 -0
  14. package/dist/ShellChrome.js +51 -0
  15. package/dist/ShellHudPresentation.d.ts +41 -0
  16. package/dist/ShellHudPresentation.js +22 -0
  17. package/dist/audio/audioEngine.d.ts +4 -0
  18. package/dist/audio/audioEngine.js +111 -26
  19. package/dist/audio/audioWire.d.ts +22 -4
  20. package/dist/audio/audioWire.js +46 -3
  21. package/dist/audio/loopParams.d.ts +20 -0
  22. package/dist/audio/loopParams.js +28 -0
  23. package/dist/camera/GameCameraRig.js +6 -1
  24. package/dist/camera/GameOrbitCamera.js +7 -1
  25. package/dist/camera/cameraCollision.d.ts +28 -0
  26. package/dist/camera/cameraCollision.js +34 -0
  27. package/dist/camera/cameraRigs.d.ts +3 -1
  28. package/dist/camera/cameraRigs.js +24 -10
  29. package/dist/camera/index.d.ts +1 -0
  30. package/dist/camera/index.js +1 -0
  31. package/dist/camera/rigMath.d.ts +27 -0
  32. package/dist/camera/rigMath.js +45 -0
  33. package/dist/defineGame.d.ts +15 -1
  34. package/dist/defineGame.js +26 -4
  35. package/dist/devtools/DevtoolsOverlay.d.ts +4 -4
  36. package/dist/devtools/DevtoolsOverlay.js +28 -12
  37. package/dist/devtools/agentBridge.js +4 -0
  38. package/dist/devtools/perfDiagnose.js +1 -1
  39. package/dist/diagnostics/RuntimeDiagnostics.d.ts +7 -0
  40. package/dist/diagnostics/RuntimeDiagnostics.js +23 -12
  41. package/dist/environment/EnvironmentScene.js +17 -3
  42. package/dist/environment/RoadJunctions.d.ts +12 -0
  43. package/dist/environment/RoadJunctions.js +69 -0
  44. package/dist/environment/RoadRibbons.d.ts +8 -2
  45. package/dist/environment/RoadRibbons.js +26 -8
  46. package/dist/environment/terrainGroundColor.d.ts +9 -0
  47. package/dist/environment/terrainGroundColor.js +22 -0
  48. package/dist/gameKit.d.ts +27 -0
  49. package/dist/gameKit.js +29 -0
  50. package/dist/map/MapMarkerBeacons.d.ts +3 -2
  51. package/dist/postfx/PostProcessing.d.ts +9 -1
  52. package/dist/postfx/PostProcessing.js +11 -4
  53. package/dist/presentationResolve.d.ts +39 -0
  54. package/dist/presentationResolve.js +53 -0
  55. package/dist/registry.d.ts +1 -0
  56. package/dist/render/PartMotion.d.ts +18 -0
  57. package/dist/render/PartMotion.js +91 -0
  58. package/dist/render/SceneModels.d.ts +9 -2
  59. package/dist/render/SceneModels.js +31 -180
  60. package/dist/render/assetBase.d.ts +17 -0
  61. package/dist/render/assetBase.js +35 -0
  62. package/dist/render/measureBounds.d.ts +34 -0
  63. package/dist/render/measureBounds.js +127 -0
  64. package/dist/render/modelLoad.js +4 -1
  65. package/dist/render/resolveModel.d.ts +15 -0
  66. package/dist/render/resolveModel.js +43 -2
  67. package/dist/render/useModelAnimation.d.ts +10 -0
  68. package/dist/render/useModelAnimation.js +198 -0
  69. package/dist/scatter/InstancedScatter.js +5 -0
  70. package/dist/scatter/scatterProxies.js +24 -0
  71. package/dist/scene/AuthoredScene.d.ts +3 -1
  72. package/dist/scene/AuthoredScene.js +7 -2
  73. package/dist/scene/CityRenderer.d.ts +2 -0
  74. package/dist/scene/CityRenderer.js +1031 -0
  75. package/dist/scene/GrassFieldRenderer.js +53 -5
  76. package/dist/scene/PoleLineRenderer.d.ts +2 -0
  77. package/dist/scene/PoleLineRenderer.js +287 -0
  78. package/dist/scene/SoilPatchRenderer.js +70 -7
  79. package/dist/scene/WaterRenderer.js +34 -10
  80. package/dist/scene/builtinSceneKindRenderers.js +4 -0
  81. package/dist/scene/cityWindowMaterial.d.ts +16 -0
  82. package/dist/scene/cityWindowMaterial.js +81 -0
  83. package/dist/scene/sceneKindRenderers.d.ts +2 -0
  84. package/dist/settings/appliedSettings.d.ts +2 -1
  85. package/dist/settings/appliedSettings.js +4 -2
  86. package/dist/structures/GeneratedBuilding.d.ts +4 -0
  87. package/dist/structures/GeneratedBuilding.js +17 -7
  88. package/dist/structures/TransformGizmo.d.ts +11 -1
  89. package/dist/structures/TransformGizmo.js +9 -5
  90. package/dist/terrain/GrassField.d.ts +11 -3
  91. package/dist/terrain/GrassField.js +16 -6
  92. package/dist/terrain/grassGeometry.d.ts +33 -1
  93. package/dist/terrain/grassGeometry.js +83 -21
  94. package/dist/terrain/grassMaterial.d.ts +18 -0
  95. package/dist/terrain/grassMaterial.js +66 -13
  96. package/dist/terrain/heightfieldGeometry.d.ts +34 -0
  97. package/dist/terrain/heightfieldGeometry.js +109 -0
  98. package/dist/terrain/index.d.ts +1 -0
  99. package/dist/terrain/index.js +1 -0
  100. package/dist/terrain/soilPatchMaterial.d.ts +15 -2
  101. package/dist/terrain/soilPatchMaterial.js +46 -8
  102. package/dist/terrain/terrainDetailMaterial.js +15 -2
  103. package/dist/water/Ocean.d.ts +7 -1
  104. package/dist/water/Ocean.js +16 -3
  105. package/dist/water/OceanConfig.d.ts +9 -0
  106. package/dist/water/OceanConfig.js +6 -0
  107. package/dist/water/OceanMaterial.d.ts +12 -0
  108. package/dist/water/OceanMaterial.js +9 -0
  109. package/dist/water/OceanShader.d.ts +2 -2
  110. package/dist/water/OceanShader.js +78 -10
  111. package/dist/world/PlaceScene.d.ts +11 -0
  112. package/dist/world/PlaceScene.js +27 -0
  113. package/dist/world/WorldHud.d.ts +3 -1
  114. package/dist/world/WorldHud.js +2 -2
  115. package/dist/world/WorldScene.js +32 -4
  116. package/dist/worldSky.d.ts +7 -1
  117. package/dist/worldSky.js +11 -1
  118. package/package.json +4 -4
  119. package/skills/game-design/SKILL.md +28 -0
  120. package/skills/game-design/agents/openai.yaml +4 -0
  121. package/skills/game-design/references/application-playbook.md +170 -0
  122. package/skills/game-design/references/field-guide.md +288 -0
  123. package/skills/jgengine/SKILL.md +8 -4
  124. package/skills/jgengine/api.md +182 -81
  125. package/skills/jgengine/capabilities.md +104 -2
  126. package/skills/jgengine/recipes/minimal-game.md +88 -0
  127. package/skills/jgengine-ui/SKILL.md +17 -10
  128. package/skills/jgengine-ui/api.md +316 -51
  129. package/skills/jgengine-ui/capabilities.md +135 -2
  130. package/skills/jgengine-ui/recipes/portable-minimap.md +150 -0
  131. package/skills/jgengine-ui/reference.md +59 -9
  132. package/skills/jgengine-ui/references/visual-scorecard.md +1 -1
  133. package/skills/jgengine-world/SKILL.md +13 -0
  134. package/skills/jgengine-world/api.md +453 -21
  135. package/skills/jgengine-world/capabilities.md +112 -2
  136. package/skills/jgengine-world/recipes/commanding-entities.md +128 -0
  137. package/skills/jgengine-world/reference.md +43 -4
  138. package/skills/level-design/SKILL.md +54 -0
  139. package/skills/level-design/agents/openai.yaml +4 -0
  140. package/skills/level-design/references/application-playbook.md +192 -0
  141. package/skills/level-design/references/field-guide.md +337 -0
  142. package/skills/level-design/references/live-editor-loop.md +179 -0
  143. package/dist/cartridge/runtime.d.ts +0 -15
  144. package/dist/cartridge/runtime.js +0 -556
  145. package/dist/cartridge/spec.d.ts +0 -188
  146. package/dist/cartridge/spec.js +0 -19
  147. package/dist/cartridge/validate.d.ts +0 -2
  148. package/dist/cartridge/validate.js +0 -78
  149. package/dist/cartridge.d.ts +0 -145
  150. package/dist/cartridge.js +0 -246
  151. package/skills/jgengine/reference-cartridge.md +0 -72
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The happy-path game kit — one import surface covering a whole simple game: authoring
3
+ * (`defineGame`), mounting (`GameHost`), serializable state (`defineStore`), behavior
4
+ * (`defineSystem`), the authored-scene document helpers, HUD primitives, and deterministic
5
+ * randomness. Start here; reach for deep package paths only when a game outgrows the kit.
6
+ *
7
+ * @capability game-kit one-stop happy-path import surface for authoring, mounting, state, systems, authored scenes, and HUD
8
+ */
9
+ export { defineGame, type GameConfig } from "./defineGame.js";
10
+ export { GameHost, type GameHostProps, type EditorSummonModule } from "./GameHost.js";
11
+ export type { PlayableGame } from "./registry.js";
12
+ export { defineStore, type StoreHandle } from "@jgengine/core/store/defineStore";
13
+ export { defineKeyedStore } from "@jgengine/core/store/defineKeyedStore";
14
+ export { defineSystem, type SystemDefinition } from "@jgengine/core/game/defineSystem";
15
+ export type { GameContext } from "@jgengine/core/runtime/gameContext";
16
+ export type { GameLoop } from "@jgengine/core/game/defineGame";
17
+ export { normalizeEditorLayers } from "@jgengine/core/editor/document";
18
+ export type { EditorDocument, EditorCatalogDefinition } from "@jgengine/core/editor/types";
19
+ export { authoredSpawnPosition } from "@jgengine/core/world/authoredSpawn";
20
+ export { collectAuthoredTriggers } from "@jgengine/core/scene/authoredTriggers";
21
+ export { environmentContentFromDocument } from "@jgengine/core/editor/environment";
22
+ export { environment } from "@jgengine/core/world/features";
23
+ export type { GameCameraConfig } from "@jgengine/core/game/playableGame";
24
+ export { HudCanvas, HudPanel, useHudLayout } from "@jgengine/react/hudLayout";
25
+ export { StatBar, Hotbar, Clock, Coins } from "@jgengine/react/hud";
26
+ export { seededRng } from "@jgengine/core/random/rng";
27
+ export { offline } from "@jgengine/core/runtime/adapter";
@@ -0,0 +1,29 @@
1
+ /**
2
+ * The happy-path game kit — one import surface covering a whole simple game: authoring
3
+ * (`defineGame`), mounting (`GameHost`), serializable state (`defineStore`), behavior
4
+ * (`defineSystem`), the authored-scene document helpers, HUD primitives, and deterministic
5
+ * randomness. Start here; reach for deep package paths only when a game outgrows the kit.
6
+ *
7
+ * @capability game-kit one-stop happy-path import surface for authoring, mounting, state, systems, authored scenes, and HUD
8
+ */
9
+ // Authoring + mounting — the one public path from config to a running game.
10
+ export { defineGame } from "./defineGame.js";
11
+ export { GameHost } from "./GameHost.js";
12
+ // Serializable state + composable behavior.
13
+ export { defineStore } from "@jgengine/core/store/defineStore";
14
+ export { defineKeyedStore } from "@jgengine/core/store/defineKeyedStore";
15
+ export { defineSystem } from "@jgengine/core/game/defineSystem";
16
+ // Authored scene document — normalize `editor.scene.json`, read spawns and trigger volumes.
17
+ export { normalizeEditorLayers } from "@jgengine/core/editor/document";
18
+ export { authoredSpawnPosition } from "@jgengine/core/world/authoredSpawn";
19
+ export { collectAuthoredTriggers } from "@jgengine/core/scene/authoredTriggers";
20
+ export { environmentContentFromDocument } from "@jgengine/core/editor/environment";
21
+ // World + camera.
22
+ export { environment } from "@jgengine/core/world/features";
23
+ // HUD layout + optional building blocks — games own custom chrome (not a stock face).
24
+ export { HudCanvas, HudPanel, useHudLayout } from "@jgengine/react/hudLayout";
25
+ export { StatBar, Hotbar, Clock, Coins } from "@jgengine/react/hud";
26
+ // Deterministic randomness (inject seeds; never Math.random in game logic).
27
+ export { seededRng } from "@jgengine/core/random/rng";
28
+ // Multiplayer story: omit `multiplayer` entirely for solo — offline is the shell default.
29
+ export { offline } from "@jgengine/core/runtime/adapter";
@@ -1,6 +1,7 @@
1
- import { type MarkerKindStyle, type MarkerSet } from "@jgengine/core/world/markers";
1
+ import { type MarkerCollection, type MarkerKindStyle } from "@jgengine/core/world/markers";
2
2
  export interface MapMarkerBeaconsProps {
3
- markers: MarkerSet;
3
+ /** Static views, an external marker source, or a native JGengine MarkerSet. */
4
+ markers: MarkerCollection;
4
5
  kindStyles?: Record<string, MarkerKindStyle>;
5
6
  height?: number;
6
7
  }
@@ -1,10 +1,18 @@
1
1
  import type { PostProcessingConfig } from "@jgengine/core/render/postProcessing";
2
+ import type { GraphicsQuality } from "@jgengine/core/settings/settingsModel";
2
3
  /**
3
4
  * Mounts an `EffectComposer` inside the shell Canvas and takes over rendering
4
5
  * (priority-1 `useFrame`, which disables R3F auto-render) to run the configured
5
6
  * post chain: RenderPass → GTAO → UnrealBloom → OutputPass → Grade. Rendered only
6
7
  * when `PlayableGame.postProcessing` is set, so games without it draw unchanged.
8
+ *
9
+ * `quality` (the player's graphics-quality setting) gates the passes whose cost
10
+ * scales with scene geometry or resolution beyond the dpr cap: GTAO re-renders
11
+ * the whole scene for depth/normals and runs a multi-sample full-screen pass,
12
+ * and Bokeh DOF renders scene depth again — both run on "high" only. Bloom,
13
+ * tone mapping, and grade stay on every tier.
7
14
  */
8
- export declare function PostProcessing({ config }: {
15
+ export declare function PostProcessing({ config, quality }: {
9
16
  config: PostProcessingConfig;
17
+ quality?: GraphicsQuality;
10
18
  }): null;
@@ -36,8 +36,14 @@ function syncSize(built, width, height, pixelRatio) {
36
36
  * (priority-1 `useFrame`, which disables R3F auto-render) to run the configured
37
37
  * post chain: RenderPass → GTAO → UnrealBloom → OutputPass → Grade. Rendered only
38
38
  * when `PlayableGame.postProcessing` is set, so games without it draw unchanged.
39
+ *
40
+ * `quality` (the player's graphics-quality setting) gates the passes whose cost
41
+ * scales with scene geometry or resolution beyond the dpr cap: GTAO re-renders
42
+ * the whole scene for depth/normals and runs a multi-sample full-screen pass,
43
+ * and Bokeh DOF renders scene depth again — both run on "high" only. Bloom,
44
+ * tone mapping, and grade stay on every tier.
39
45
  */
40
- export function PostProcessing({ config }) {
46
+ export function PostProcessing({ config, quality = "high" }) {
41
47
  const gl = useThree((s) => s.gl);
42
48
  const scene = useThree((s) => s.scene);
43
49
  const camera = useThree((s) => s.camera);
@@ -52,7 +58,8 @@ export function PostProcessing({ config }) {
52
58
  });
53
59
  const composer = new EffectComposer(gl, target);
54
60
  composer.addPass(new RenderPass(scene, camera));
55
- if (config.ao !== undefined && config.ao !== false) {
61
+ const heavyPasses = quality === "high";
62
+ if (heavyPasses && config.ao !== undefined && config.ao !== false) {
56
63
  const ao = new GTAOPass(scene, camera, width, height);
57
64
  ao.blendIntensity = config.ao.blend ?? 1;
58
65
  ao.updateGtaoMaterial({
@@ -69,7 +76,7 @@ export function PostProcessing({ config }) {
69
76
  composer.addPass(new UnrealBloomPass(new THREE.Vector2(width, height), b.strength ?? 0.32, b.radius ?? 0.55, b.threshold ?? 0.85));
70
77
  }
71
78
  let dof = null;
72
- if (config.dof !== undefined && config.dof !== false) {
79
+ if (heavyPasses && config.dof !== undefined && config.dof !== false) {
73
80
  const d = config.dof;
74
81
  dof = new BokehPass(scene, camera, {
75
82
  focus: d.focus ?? 18,
@@ -91,7 +98,7 @@ export function PostProcessing({ config }) {
91
98
  disposeGraph(graph);
92
99
  builtRef.current = null;
93
100
  };
94
- }, [gl, scene, camera, config]);
101
+ }, [gl, scene, camera, config, quality]);
95
102
  useEffect(() => {
96
103
  const prevTone = gl.toneMapping;
97
104
  const prevExposure = gl.toneMappingExposure;
@@ -0,0 +1,39 @@
1
+ import type { CatalogEntityRole } from "@jgengine/core/runtime/gameContext";
2
+ import type { PresentationEffectsConfig } from "@jgengine/core/game/playableGame";
3
+ /** Resolved world-bar / nameplate config, or `null` when the feature is off. @internal */
4
+ export interface ResolvedWorldOverlayBars {
5
+ statId: string;
6
+ roles?: readonly CatalogEntityRole[];
7
+ maxDistance?: number;
8
+ /** Nameplates only: draw the built-in HP bar. `false` yields a name-only plate. Defaults to `true`. */
9
+ showHealth?: boolean;
10
+ }
11
+ /**
12
+ * Normalize `worldHealthBars` / `nameplates` (`boolean | object | undefined`) into a
13
+ * single resolved shape. `undefined` / `false` → `null` (off); `true` → default stat `"health"`;
14
+ * object → explicit fields with `statId` defaulting to `"health"`. `showHealth` is nameplate-only
15
+ * (ignored by `worldHealthBars`, which is itself a bar).
16
+ * @internal
17
+ */
18
+ export declare function resolveWorldOverlayBars(config: boolean | {
19
+ statId?: string;
20
+ roles?: readonly CatalogEntityRole[];
21
+ maxDistance?: number;
22
+ showHealth?: boolean;
23
+ } | undefined): ResolvedWorldOverlayBars | null;
24
+ /** Resolved combat presentation channels (each boolean is whether that stack piece mounts). @internal */
25
+ export interface ResolvedPresentationEffects {
26
+ telegraphs: boolean;
27
+ vfx: boolean;
28
+ floatText: boolean;
29
+ tracers: boolean;
30
+ shake: boolean;
31
+ }
32
+ /**
33
+ * Resolve `presentationEffects` for the 3D shell.
34
+ * - `undefined` / `true` → all channels on (historical default).
35
+ * - `false` → none.
36
+ * - object → per-channel; missing keys default to on so games can disable one channel.
37
+ * @internal
38
+ */
39
+ export declare function resolvePresentationEffects(config: boolean | PresentationEffectsConfig | undefined): ResolvedPresentationEffects;
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Normalize `worldHealthBars` / `nameplates` (`boolean | object | undefined`) into a
3
+ * single resolved shape. `undefined` / `false` → `null` (off); `true` → default stat `"health"`;
4
+ * object → explicit fields with `statId` defaulting to `"health"`. `showHealth` is nameplate-only
5
+ * (ignored by `worldHealthBars`, which is itself a bar).
6
+ * @internal
7
+ */
8
+ export function resolveWorldOverlayBars(config) {
9
+ if (config === undefined || config === false)
10
+ return null;
11
+ if (config === true)
12
+ return { statId: "health" };
13
+ return {
14
+ statId: config.statId ?? "health",
15
+ ...(config.roles === undefined ? {} : { roles: config.roles }),
16
+ ...(config.maxDistance === undefined ? {} : { maxDistance: config.maxDistance }),
17
+ ...(config.showHealth === undefined ? {} : { showHealth: config.showHealth }),
18
+ };
19
+ }
20
+ const ALL_ON = {
21
+ telegraphs: true,
22
+ vfx: true,
23
+ floatText: true,
24
+ tracers: true,
25
+ shake: true,
26
+ };
27
+ const ALL_OFF = {
28
+ telegraphs: false,
29
+ vfx: false,
30
+ floatText: false,
31
+ tracers: false,
32
+ shake: false,
33
+ };
34
+ /**
35
+ * Resolve `presentationEffects` for the 3D shell.
36
+ * - `undefined` / `true` → all channels on (historical default).
37
+ * - `false` → none.
38
+ * - object → per-channel; missing keys default to on so games can disable one channel.
39
+ * @internal
40
+ */
41
+ export function resolvePresentationEffects(config) {
42
+ if (config === false)
43
+ return ALL_OFF;
44
+ if (config === undefined || config === true)
45
+ return ALL_ON;
46
+ return {
47
+ telegraphs: config.telegraphs !== false,
48
+ vfx: config.vfx !== false,
49
+ floatText: config.floatText !== false,
50
+ tracers: config.tracers !== false,
51
+ shake: config.shake !== false,
52
+ };
53
+ }
@@ -5,6 +5,7 @@ import type { SceneObject } from "@jgengine/core/scene/objectStore";
5
5
  import type { ViewmodelProps } from "./camera/GameFirstPersonCamera.js";
6
6
  export type RenderEntity = (entity: SceneEntity) => ReactNode;
7
7
  export type RenderObject = (object: SceneObject) => ReactNode;
8
+ /** The concrete React-bound playable a shell `defineGame` returns and `GameHost` mounts — the one PlayableGame games handle. */
8
9
  export type PlayableGame = EnginePlayableGame<ComponentType, ComponentType, RenderEntity, RenderObject, ComponentType<ViewmodelProps>, ComponentType<WorldOverlayProps>>;
9
10
  export type GameRegistry = Record<string, () => Promise<PlayableGame>>;
10
11
  export declare function resolveGameLoader(registry: GameRegistry, gameId: string, fallbackGameId?: string): (() => Promise<PlayableGame>) | undefined;
@@ -0,0 +1,18 @@
1
+ import { type ReactNode } from "react";
2
+ import type { ModelConfig, ModelPart } from "@jgengine/core/game/playableGame";
3
+ /**
4
+ * Procedural motion rig for a rig-less part-composed character (`ModelPart.role` — see
5
+ * `@jgengine/core/game/partAnimation`). Wraps the whole composition in a root group that bobs,
6
+ * breathes, flinches on `combat.hitReaction`, and topples on `entity.died`, while each
7
+ * role-tagged part swings around its authored transform — legs/arms counter-phase from the
8
+ * entity's live movement speed, head counter-sway, tail wag, wing flap. Untagged parts render
9
+ * as static kit pieces. Children are the base model content (primitive, attachments).
10
+ */
11
+ export declare function PartMotionRig({ parts, model, instanceId, renderPart, children, }: {
12
+ parts: readonly ModelPart[];
13
+ model: ModelConfig;
14
+ instanceId?: string;
15
+ /** Renders one part's model content (the shell passes its part renderer to avoid an import cycle). */
16
+ renderPart: (part: ModelPart, index: number) => ReactNode;
17
+ children: ReactNode;
18
+ }): ReactNode;
@@ -0,0 +1,91 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useFrame } from "@react-three/fiber";
3
+ import { useEffect, useMemo, useRef } from "react";
4
+ import * as THREE from "three";
5
+ import { createPartPose, partMotionPhase, sampleBodyPose, samplePartPose, } from "@jgengine/core/game/partAnimation";
6
+ import { useGameContext } from "@jgengine/react/provider";
7
+ const FLINCH_RELEASE_SEC = 0.35;
8
+ const DEATH_RAMP_PER_SEC = 2.5;
9
+ const SPEED_SMOOTHING = 12;
10
+ /**
11
+ * Procedural motion rig for a rig-less part-composed character (`ModelPart.role` — see
12
+ * `@jgengine/core/game/partAnimation`). Wraps the whole composition in a root group that bobs,
13
+ * breathes, flinches on `combat.hitReaction`, and topples on `entity.died`, while each
14
+ * role-tagged part swings around its authored transform — legs/arms counter-phase from the
15
+ * entity's live movement speed, head counter-sway, tail wag, wing flap. Untagged parts render
16
+ * as static kit pieces. Children are the base model content (primitive, attachments).
17
+ */
18
+ export function PartMotionRig({ parts, model, instanceId, renderPart, children, }) {
19
+ const ctx = useGameContext();
20
+ const rootRef = useRef(null);
21
+ const partRefs = useRef([]);
22
+ const motionRef = useRef({
23
+ lastPos: null,
24
+ speed: 0,
25
+ flinch: 0,
26
+ death: 0,
27
+ });
28
+ const phase = useMemo(() => partMotionPhase(instanceId ?? model.url), [instanceId, model.url]);
29
+ useEffect(() => {
30
+ if (instanceId === undefined)
31
+ return;
32
+ const offHit = ctx.game.events.on("combat.hitReaction", (event) => {
33
+ if (event.instanceId === instanceId)
34
+ motionRef.current.flinch = 1;
35
+ });
36
+ const offDied = ctx.game.events.on("entity.died", (event) => {
37
+ if (event.instanceId === instanceId)
38
+ motionRef.current.death = Math.max(motionRef.current.death, 1e-6);
39
+ });
40
+ return () => {
41
+ offHit();
42
+ offDied();
43
+ };
44
+ }, [ctx, instanceId]);
45
+ const input = useMemo(() => ({ timeSec: 0, speed: 0, phase, flinch: 0, death: 0 }), [phase]);
46
+ const pose = useMemo(() => createPartPose(), []);
47
+ useFrame((frameState, delta) => {
48
+ const motion = motionRef.current;
49
+ if (delta > 0 && instanceId !== undefined) {
50
+ const entity = ctx.scene.entity.get(instanceId);
51
+ if (entity !== null) {
52
+ const [x, , z] = entity.position;
53
+ if (motion.lastPos !== null) {
54
+ const instantSpeed = Math.hypot(x - motion.lastPos[0], z - motion.lastPos[2]) / delta;
55
+ motion.speed += (instantSpeed - motion.speed) * Math.min(1, delta * SPEED_SMOOTHING);
56
+ }
57
+ motion.lastPos = [x, entity.position[1], z];
58
+ }
59
+ motion.flinch = Math.max(0, motion.flinch - delta / FLINCH_RELEASE_SEC);
60
+ if (motion.death > 0)
61
+ motion.death = Math.min(1, motion.death + delta * DEATH_RAMP_PER_SEC);
62
+ }
63
+ input.timeSec = frameState.clock.elapsedTime;
64
+ input.speed = motion.speed;
65
+ input.flinch = motion.flinch;
66
+ input.death = motion.death;
67
+ const root = rootRef.current;
68
+ if (root !== null) {
69
+ sampleBodyPose(input, model.partMotion, pose);
70
+ root.position.set(pose.position[0], pose.position[1], pose.position[2]);
71
+ root.rotation.set(pose.rotation[0], pose.rotation[1], pose.rotation[2]);
72
+ root.scale.set(pose.scale[0], pose.scale[1], pose.scale[2]);
73
+ }
74
+ for (let index = 0; index < parts.length; index += 1) {
75
+ const part = parts[index];
76
+ const group = partRefs.current[index];
77
+ if (group === null || group === undefined || part.role === undefined)
78
+ continue;
79
+ samplePartPose(part.role, input, model.partMotion, pose);
80
+ const base = part.position ?? [0, 0, 0];
81
+ const baseRotation = part.rotation ?? [0, 0, 0];
82
+ group.position.set(base[0] + pose.position[0], base[1] + pose.position[1], base[2] + pose.position[2]);
83
+ group.rotation.set(baseRotation[0] + pose.rotation[0], baseRotation[1] + pose.rotation[1], baseRotation[2] + pose.rotation[2]);
84
+ const baseScale = part.scale ?? 1;
85
+ group.scale.set(baseScale * pose.scale[0], baseScale * pose.scale[1], baseScale * pose.scale[2]);
86
+ }
87
+ });
88
+ return (_jsxs("group", { ref: rootRef, children: [children, parts.map((part, index) => (_jsx("group", { ref: (group) => {
89
+ partRefs.current[index] = group;
90
+ }, position: part.position ?? [0, 0, 0], rotation: part.rotation ?? [0, 0, 0], scale: part.scale ?? 1, children: renderPart(part, index) }, index)))] }));
91
+ }
@@ -3,12 +3,19 @@ import type { EntitySpriteConfig, ModelConfig } from "@jgengine/core/game/playab
3
3
  export declare function EntitySprite({ sprite }: {
4
4
  sprite: EntitySpriteConfig;
5
5
  }): import("react").JSX.Element;
6
- export declare function IsolatedEntityModel({ model, instanceId, fallback, }: {
6
+ export declare function IsolatedEntityModel({ model, instanceId, measure, fallback, }: {
7
7
  model: ModelConfig;
8
8
  instanceId?: string;
9
+ measure?: MeasureTarget;
9
10
  fallback?: ReactNode;
10
11
  }): import("react").JSX.Element;
11
- export declare function EntityModel({ model, instanceId }: {
12
+ /** Where a measured model reports its rendered bounds: an entity kind or an object catalog id. */
13
+ export interface MeasureTarget {
14
+ target: "entity" | "object";
15
+ key: string;
16
+ }
17
+ export declare function EntityModel({ model, instanceId, measure, }: {
12
18
  model: ModelConfig;
13
19
  instanceId?: string;
20
+ measure?: MeasureTarget;
14
21
  }): import("react").JSX.Element;
@@ -3,9 +3,11 @@ import { useFrame, useLoader } from "@react-three/fiber";
3
3
  import { useTexture } from "@react-three/drei";
4
4
  import { Component, Suspense, useEffect, useMemo, useRef } from "react";
5
5
  import * as THREE from "three";
6
- import { resolveOneShotClip } from "@jgengine/core/game/modelAnimation";
7
6
  import { useGameContext } from "@jgengine/react/provider";
8
7
  import { sharedGltfLoader } from "./modelLoad.js";
8
+ import { measureLocalBounds, reportMeasuredBounds } from "./measureBounds.js";
9
+ import { useModelAnimation } from "./useModelAnimation.js";
10
+ import { PartMotionRig } from "./PartMotion.js";
9
11
  import { applyMaterialOverride } from "../materialOverride.js";
10
12
  import { applyPaintTextureToMaterials, cacheStandardMaterials, cloneModelScene, createPaintCanvas, disposeClonedMaterials, disposePaintCanvas, syncPaintCanvas, } from "./modelRender.js";
11
13
  export function EntitySprite({ sprite }) {
@@ -26,8 +28,8 @@ class ModelFallbackBoundary extends Component {
26
28
  return this.state.failed ? this.props.fallback : this.props.children;
27
29
  }
28
30
  }
29
- export function IsolatedEntityModel({ model, instanceId, fallback, }) {
30
- return (_jsx(ModelFallbackBoundary, { fallback: fallback ?? null, children: _jsx(Suspense, { fallback: null, children: _jsx(EntityModel, { model: model, instanceId: instanceId }) }) }));
31
+ export function IsolatedEntityModel({ model, instanceId, measure, fallback, }) {
32
+ return (_jsx(ModelFallbackBoundary, { fallback: fallback ?? null, children: _jsx(Suspense, { fallback: null, children: _jsx(EntityModel, { model: model, instanceId: instanceId, measure: measure }) }) }));
31
33
  }
32
34
  function BoneAttachment({ rig, model, slot, position, rotation, scale, }) {
33
35
  const gltf = useLoader(sharedGltfLoader, model.url);
@@ -83,7 +85,7 @@ function ModelMaterialMapsApplier({ scene, maps }) {
83
85
  }, [scene, textures]);
84
86
  return null;
85
87
  }
86
- export function EntityModel({ model, instanceId }) {
88
+ export function EntityModel({ model, instanceId, measure, }) {
87
89
  const gltf = useLoader(sharedGltfLoader, model.url);
88
90
  const ctx = useGameContext();
89
91
  const material = model.material;
@@ -119,150 +121,26 @@ export function EntityModel({ model, instanceId }) {
119
121
  useEffect(() => () => {
120
122
  disposeClonedMaterials(scene);
121
123
  }, [scene]);
122
- const animation = model.animation;
123
- const mixerRef = useRef(null);
124
- const animationPausedRef = useRef(false);
125
- const stateActionsRef = useRef(null);
126
- const states = animation?.states;
127
- const oneShots = animation?.oneShots;
128
- const oneShotPlayRef = useRef(null);
129
- const activeOneShotRef = useRef(null);
124
+ // A model without index-measured dims can't drive the fitted-collider path, so report the live
125
+ // measurement (the primitive mounts with only this uniform scale + position) instead of letting
126
+ // the kind fall back to a fixed-size box. Index dims keep priority — skip when they exist.
127
+ const measureTarget = measure?.target;
128
+ const measureKey = measure?.key;
129
+ const dimsMaxY = dims?.maxY;
130
+ const [positionX, positionY, positionZ] = position;
130
131
  useEffect(() => {
131
- if (animation === undefined || gltf.animations.length === 0) {
132
- mixerRef.current = null;
133
- stateActionsRef.current = null;
132
+ if (measureTarget === undefined || measureKey === undefined || dimsMaxY !== undefined)
134
133
  return;
135
- }
136
- const mixer = new THREE.AnimationMixer(scene);
137
- if (states !== undefined) {
138
- const clipFor = (name) => THREE.AnimationClip.findByName(gltf.animations, name) ?? gltf.animations[0];
139
- const actions = {
140
- idle: mixer.clipAction(clipFor(states.idle)),
141
- walk: mixer.clipAction(clipFor(states.walk)),
142
- ...(states.run === undefined ? {} : { run: mixer.clipAction(clipFor(states.run)) }),
143
- };
144
- for (const action of Object.values(actions)) {
145
- action.setLoop(THREE.LoopRepeat, Infinity);
146
- action.timeScale = animation.timeScale ?? 1;
147
- action.enabled = true;
148
- }
149
- actions.idle.play();
150
- mixer.update(0);
151
- mixerRef.current = mixer;
152
- stateActionsRef.current = { actions, active: "idle", lastPos: null, smoothedSpeed: 0 };
153
- animationPausedRef.current = false;
154
- let onOneShotFinished = null;
155
- if (oneShots !== undefined) {
156
- const clipNames = new Set();
157
- for (const spec of Object.values(oneShots)) {
158
- if (typeof spec === "string")
159
- clipNames.add(spec);
160
- else
161
- for (const name of spec)
162
- clipNames.add(name);
163
- }
164
- const oneShotActions = new Map();
165
- for (const name of clipNames) {
166
- const found = THREE.AnimationClip.findByName(gltf.animations, name);
167
- if (found === null)
168
- continue;
169
- const oneShotAction = mixer.clipAction(found);
170
- oneShotAction.setLoop(THREE.LoopOnce, 1);
171
- oneShotAction.enabled = true;
172
- oneShotActions.set(name, oneShotAction);
173
- }
174
- onOneShotFinished = ({ action }) => {
175
- const active = activeOneShotRef.current;
176
- if (active === null || action !== active.action || active.isDeath)
177
- return;
178
- const machine = stateActionsRef.current;
179
- const back = machine?.actions[machine.active];
180
- action.fadeOut(0.15);
181
- if (back !== undefined)
182
- back.reset().fadeIn(0.15).play();
183
- activeOneShotRef.current = null;
184
- };
185
- mixer.addEventListener("finished", onOneShotFinished);
186
- oneShotPlayRef.current = (event) => {
187
- const active = activeOneShotRef.current;
188
- if (active !== null && active.isDeath)
189
- return;
190
- const clipName = resolveOneShotClip(oneShots, event, Math.random());
191
- if (clipName === null)
192
- return;
193
- const oneShotAction = oneShotActions.get(clipName);
194
- if (oneShotAction === undefined)
195
- return;
196
- const machine = stateActionsRef.current;
197
- machine?.actions[machine.active]?.fadeOut(0.1);
198
- if (active !== null && active.action !== oneShotAction)
199
- active.action.stop();
200
- oneShotAction.clampWhenFinished = event === "death";
201
- oneShotAction.reset().fadeIn(0.1).play();
202
- activeOneShotRef.current = { action: oneShotAction, isDeath: event === "death" };
203
- };
204
- }
205
- return () => {
206
- if (onOneShotFinished !== null)
207
- mixer.removeEventListener("finished", onOneShotFinished);
208
- oneShotPlayRef.current = null;
209
- activeOneShotRef.current = null;
210
- mixer.stopAllAction();
211
- mixerRef.current = null;
212
- stateActionsRef.current = null;
213
- };
214
- }
215
- const clip = (animation.clip !== undefined ? THREE.AnimationClip.findByName(gltf.animations, animation.clip) : undefined) ??
216
- gltf.animations[0];
217
- const action = mixer.clipAction(clip);
218
- action.setLoop(animation.loop === false ? THREE.LoopOnce : THREE.LoopRepeat, Infinity);
219
- action.clampWhenFinished = animation.loop === false;
220
- action.timeScale = animation.timeScale ?? 1;
221
- action.enabled = true;
222
- action.paused = animation.paused === true;
223
- action.play();
224
- if (animation.time !== undefined)
225
- action.time = animation.time;
226
- mixer.update(0);
227
- mixerRef.current = mixer;
228
- animationPausedRef.current = animation.paused === true;
229
- return () => {
230
- mixer.stopAllAction();
231
- mixerRef.current = null;
232
- };
233
- }, [
234
- scene,
235
- gltf,
236
- animation?.clip,
237
- animation?.loop,
238
- animation?.timeScale,
239
- animation?.paused,
240
- animation?.time,
241
- states,
242
- oneShots,
243
- ]);
244
- useEffect(() => {
245
- if (instanceId === undefined || oneShots === undefined)
134
+ const raw = measureLocalBounds(scene);
135
+ if (raw === null)
246
136
  return;
247
- const fire = (event) => oneShotPlayRef.current?.(event);
248
- const offAnimation = ctx.game.events.on("entity.animation", (event) => {
249
- if (event.instanceId === instanceId)
250
- fire(event.event);
137
+ reportMeasuredBounds(ctx, measureTarget, measureKey, {
138
+ min: [raw.min[0] * scale + positionX, raw.min[1] * scale + positionY, raw.min[2] * scale + positionZ],
139
+ max: [raw.max[0] * scale + positionX, raw.max[1] * scale + positionY, raw.max[2] * scale + positionZ],
140
+ meshCount: raw.meshCount,
251
141
  });
252
- const offHit = ctx.game.events.on("combat.hitReaction", (event) => {
253
- if (event.instanceId === instanceId)
254
- fire("hit");
255
- });
256
- const offDied = ctx.game.events.on("entity.died", (event) => {
257
- if (event.instanceId === instanceId)
258
- fire("death");
259
- });
260
- return () => {
261
- offAnimation();
262
- offHit();
263
- offDied();
264
- };
265
- }, [ctx, instanceId, oneShots]);
142
+ }, [ctx, scene, scale, positionX, positionY, positionZ, measureTarget, measureKey, dimsMaxY]);
143
+ useModelAnimation(scene, gltf.animations, model.animation, instanceId);
266
144
  const paintCanvasRef = useRef(null);
267
145
  const paintDrawnCountRef = useRef(0);
268
146
  const paintVersionRef = useRef(-1);
@@ -276,41 +154,7 @@ export function EntityModel({ model, instanceId }) {
276
154
  paintVersionRef.current = -1;
277
155
  materialCacheRef.current = null;
278
156
  }, [scene]);
279
- useFrame((_state, delta) => {
280
- const stateMachine = stateActionsRef.current;
281
- if (stateMachine !== null && states !== undefined && instanceId !== undefined && delta > 0) {
282
- const entity = ctx.scene.entity.get(instanceId);
283
- if (entity !== null) {
284
- const [x, , z] = entity.position;
285
- if (stateMachine.lastPos !== null) {
286
- const instantSpeed = Math.hypot(x - stateMachine.lastPos[0], z - stateMachine.lastPos[2]) / delta;
287
- stateMachine.smoothedSpeed +=
288
- (instantSpeed - stateMachine.smoothedSpeed) * Math.min(1, delta * 12);
289
- }
290
- stateMachine.lastPos = [x, entity.position[1], z];
291
- const walkSpeed = states.walkSpeed ?? 0.5;
292
- const runSpeed = states.runSpeed ?? 6;
293
- const next = stateMachine.smoothedSpeed < walkSpeed
294
- ? "idle"
295
- : stateMachine.actions.run !== undefined && stateMachine.smoothedSpeed >= runSpeed
296
- ? "run"
297
- : "walk";
298
- if (next !== stateMachine.active) {
299
- if (activeOneShotRef.current === null) {
300
- const fade = states.fadeSec ?? 0.2;
301
- const from = stateMachine.actions[stateMachine.active];
302
- const to = stateMachine.actions[next];
303
- if (from !== undefined && to !== undefined) {
304
- to.reset().fadeIn(fade).play();
305
- from.fadeOut(fade);
306
- }
307
- }
308
- stateMachine.active = next;
309
- }
310
- }
311
- }
312
- if (mixerRef.current !== null && !animationPausedRef.current)
313
- mixerRef.current.update(delta);
157
+ useFrame(() => {
314
158
  if (instanceId === undefined)
315
159
  return;
316
160
  const paint = ctx.scene.entity.paint;
@@ -333,5 +177,12 @@ export function EntityModel({ model, instanceId }) {
333
177
  }
334
178
  paintDrawnCountRef.current = syncPaintCanvas(paintCanvasRef.current, cache.seedColor, strokes, paintDrawnCountRef.current);
335
179
  });
336
- return (_jsxs(_Fragment, { children: [_jsx("primitive", { object: scene, position: position, scale: [scale, scale, scale] }), material?.maps !== undefined ? _jsx(ModelMaterialMapsApplier, { scene: scene, maps: material.maps }) : null, (model.attachments ?? []).map((attachment, index) => typeof attachment.model === "string" ? null : (_jsx(BoneAttachment, { rig: scene, model: attachment.model, slot: attachment.slot, position: attachment.position, rotation: attachment.rotation, scale: attachment.scale }, `${attachment.slot}-${index}`))), (model.parts ?? []).map((part, index) => typeof part.model === "string" ? null : (_jsx(ModelPartGroup, { model: part.model, position: part.position, rotation: part.rotation, scale: part.scale }, index)))] }));
180
+ const base = (_jsxs(_Fragment, { children: [_jsx("primitive", { object: scene, position: position, scale: [scale, scale, scale] }), material?.maps !== undefined ? _jsx(ModelMaterialMapsApplier, { scene: scene, maps: material.maps }) : null, (model.attachments ?? []).map((attachment, index) => typeof attachment.model === "string" ? null : (_jsx(BoneAttachment, { rig: scene, model: attachment.model, slot: attachment.slot, position: attachment.position, rotation: attachment.rotation, scale: attachment.scale }, `${attachment.slot}-${index}`)))] }));
181
+ const parts = model.parts ?? [];
182
+ // Any role-tagged part switches the composition onto the procedural part-motion rig:
183
+ // root bob/flinch/topple plus per-role limb swing, driven from the entity's live state.
184
+ if (parts.some((part) => part.role !== undefined)) {
185
+ return (_jsx(PartMotionRig, { parts: parts, model: model, instanceId: instanceId, renderPart: (part) => (typeof part.model === "string" ? null : _jsx(EntityModel, { model: part.model })), children: base }));
186
+ }
187
+ return (_jsxs(_Fragment, { children: [base, parts.map((part, index) => typeof part.model === "string" ? null : (_jsx(ModelPartGroup, { model: part.model, position: part.position, rotation: part.rotation, scale: part.scale }, index)))] }));
337
188
  }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Resolves a root-absolute asset URL (`/models/…`, `/materials/…`) against the
3
+ * installed app base. Relative, protocol-relative, absolute-scheme (`https:`,
4
+ * `blob:`, `data:`), and already-based URLs pass through unchanged. Installed
5
+ * as the URL modifier on THREE loading managers so every model and texture
6
+ * load resolves correctly wherever the app is mounted.
7
+ */
8
+ export declare function resolveAssetBaseUrl(url: string): string;
9
+ /**
10
+ * Installs the app base URL (pass `import.meta.env.BASE_URL`) so root-absolute
11
+ * asset paths load from under it. Call once at app startup, before any game
12
+ * loads. Bases that are not root-absolute (`/`, `./`) reset to the pass-through
13
+ * default. Also registers {@link resolveAssetBaseUrl} on
14
+ * `THREE.DefaultLoadingManager`, covering `TextureLoader`, drei's `useTexture`
15
+ * / `useGLTF`, and every other loader on the default manager.
16
+ */
17
+ export declare function installAssetBase(base: string): void;