@jgengine/shell 0.19.0 → 0.21.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 (203) hide show
  1. package/CHANGELOG.md +346 -0
  2. package/dist/GameHost.d.ts +12 -1
  3. package/dist/GameHost.js +14 -1
  4. package/dist/GamePlayerShell.js +113 -15
  5. package/dist/Shell3dPresentation.d.ts +2 -2
  6. package/dist/Shell3dPresentation.js +49 -7
  7. package/dist/ShellChrome.d.ts +2 -1
  8. package/dist/ShellChrome.js +22 -6
  9. package/dist/ShellHudPresentation.d.ts +11 -1
  10. package/dist/ShellHudPresentation.js +20 -6
  11. package/dist/audio/audioEngine.d.ts +16 -0
  12. package/dist/audio/audioEngine.js +178 -89
  13. package/dist/audio/audioWire.js +2 -2
  14. package/dist/audio/synthEngine.d.ts +7 -1
  15. package/dist/audio/synthEngine.js +103 -38
  16. package/dist/camera/GameCameraRig.js +2 -1
  17. package/dist/camera/GameFirstPersonCamera.d.ts +2 -2
  18. package/dist/camera/GameFirstPersonCamera.js +17 -13
  19. package/dist/camera/cameraRigs.js +8 -2
  20. package/dist/camera/chaseHeading.d.ts +9 -0
  21. package/dist/camera/chaseHeading.js +10 -0
  22. package/dist/camera/customMuzzle.d.ts +14 -0
  23. package/dist/camera/customMuzzle.js +68 -0
  24. package/dist/camera/index.d.ts +1 -0
  25. package/dist/camera/index.js +1 -0
  26. package/dist/camera/rigResolve.d.ts +3 -0
  27. package/dist/camera/rigResolve.js +11 -0
  28. package/dist/combat/ProjectileModels.d.ts +31 -0
  29. package/dist/combat/ProjectileModels.js +70 -0
  30. package/dist/commandSink.d.ts +1 -2
  31. package/dist/commandSink.js +3 -2
  32. package/dist/defineGame.d.ts +12 -5
  33. package/dist/defineGame.js +24 -9
  34. package/dist/devtools/DevtoolsOverlay.d.ts +2 -69
  35. package/dist/devtools/DevtoolsOverlay.js +14 -168
  36. package/dist/devtools/DevtoolsRuntime.d.ts +7 -0
  37. package/dist/devtools/DevtoolsRuntime.js +61 -0
  38. package/dist/devtools/agentBridge.js +1 -1
  39. package/dist/devtools/devtoolsReports.d.ts +68 -0
  40. package/dist/devtools/devtoolsReports.js +100 -0
  41. package/dist/devtools/latencyInstrumentation.d.ts +3 -0
  42. package/dist/devtools/latencyInstrumentation.js +11 -0
  43. package/dist/devtools/playtestProbe.d.ts +4 -0
  44. package/dist/devtools/playtestProbe.js +22 -0
  45. package/dist/drivers/FrameDriver.js +25 -21
  46. package/dist/drivers/HudOnlyDriver.d.ts +6 -1
  47. package/dist/drivers/HudOnlyDriver.js +32 -14
  48. package/dist/environment/EnvironmentScene.js +11 -10
  49. package/dist/inputSink.d.ts +6 -6
  50. package/dist/inputSink.js +60 -21
  51. package/dist/lazyShell3dPresentation.d.ts +9 -0
  52. package/dist/lazyShell3dPresentation.js +24 -0
  53. package/dist/materialOverride.d.ts +42 -0
  54. package/dist/materialOverride.js +95 -36
  55. package/dist/multiplayer.d.ts +33 -3
  56. package/dist/multiplayer.js +105 -22
  57. package/dist/pointer/pointerService.d.ts +2 -0
  58. package/dist/pointer/pointerService.js +12 -2
  59. package/dist/postfx/PostProcessing.d.ts +10 -0
  60. package/dist/postfx/PostProcessing.js +9 -41
  61. package/dist/postfx/gradeShader.d.ts +1 -1
  62. package/dist/postfx/gradeShader.js +30 -2
  63. package/dist/postfx/shadowPrepass.d.ts +3 -0
  64. package/dist/postfx/shadowPrepass.js +18 -0
  65. package/dist/presentationRecovery.d.ts +75 -0
  66. package/dist/presentationRecovery.js +204 -0
  67. package/dist/presentationRecovery3d.d.ts +4 -0
  68. package/dist/presentationRecovery3d.js +32 -0
  69. package/dist/presentationResolve.d.ts +8 -1
  70. package/dist/presentationResolve.js +5 -2
  71. package/dist/render/CascadedShadows.d.ts +7 -2
  72. package/dist/render/CascadedShadows.js +98 -20
  73. package/dist/render/EnvironmentLighting.js +74 -35
  74. package/dist/render/HairCards.d.ts +7 -0
  75. package/dist/render/HairCards.js +23 -0
  76. package/dist/render/SceneModels.js +51 -77
  77. package/dist/render/authoredSurfaceMaterial.d.ts +43 -0
  78. package/dist/render/authoredSurfaceMaterial.js +108 -0
  79. package/dist/render/materialAppearanceSignals.d.ts +23 -0
  80. package/dist/render/materialAppearanceSignals.js +58 -0
  81. package/dist/render/materialAsset.d.ts +98 -0
  82. package/dist/render/materialAsset.js +454 -0
  83. package/dist/render/measureBounds.js +16 -3
  84. package/dist/render/measureCollisionMesh.js +8 -1
  85. package/dist/render/modelAssets.d.ts +8 -0
  86. package/dist/render/modelAssets.js +43 -0
  87. package/dist/render/modelBindPose.d.ts +5 -0
  88. package/dist/render/modelBindPose.js +35 -0
  89. package/dist/render/modelLoad.d.ts +3 -0
  90. package/dist/render/modelLoad.js +9 -0
  91. package/dist/render/modelNodeVisibility.d.ts +6 -0
  92. package/dist/render/modelNodeVisibility.js +18 -0
  93. package/dist/render/modelRender.d.ts +18 -1
  94. package/dist/render/modelRender.js +81 -3
  95. package/dist/render/projectedOverdraw.d.ts +35 -0
  96. package/dist/render/projectedOverdraw.js +204 -0
  97. package/dist/render/resolveModel.d.ts +12 -2
  98. package/dist/render/resolveModel.js +21 -4
  99. package/dist/render/rigNode.d.ts +26 -0
  100. package/dist/render/rigNode.js +32 -0
  101. package/dist/render/spriteRender.d.ts +4 -0
  102. package/dist/render/spriteRender.js +5 -0
  103. package/dist/render/staticShapeInstances.d.ts +34 -0
  104. package/dist/render/staticShapeInstances.js +78 -0
  105. package/dist/render/useFootIk.js +76 -11
  106. package/dist/render/useModelAnimation.d.ts +28 -2
  107. package/dist/render/useModelAnimation.js +194 -69
  108. package/dist/render/useModelInstance.d.ts +25 -0
  109. package/dist/render/useModelInstance.js +43 -0
  110. package/dist/scatter/scatterModels.d.ts +3 -2
  111. package/dist/scatter/scatterModels.js +15 -7
  112. package/dist/scene/AuthoredScene.d.ts +14 -6
  113. package/dist/scene/AuthoredScene.js +30 -9
  114. package/dist/scene/GeneratedAssetRenderer.js +11 -42
  115. package/dist/settings/SettingsMenu.js +11 -18
  116. package/dist/settings/SettingsRuntime.d.ts +5 -0
  117. package/dist/settings/SettingsRuntime.js +29 -2
  118. package/dist/shellControlSuspension.d.ts +18 -0
  119. package/dist/shellControlSuspension.js +28 -0
  120. package/dist/shellInputPublication.d.ts +28 -0
  121. package/dist/shellInputPublication.js +49 -0
  122. package/dist/shellPointerInput.d.ts +18 -0
  123. package/dist/shellPointerInput.js +49 -0
  124. package/dist/structures/BuildingKitBatch.d.ts +3 -1
  125. package/dist/structures/BuildingKitBatch.js +34 -59
  126. package/dist/structures/BuildingRenderBudget.d.ts +23 -0
  127. package/dist/structures/BuildingRenderBudget.js +54 -0
  128. package/dist/structures/GeneratedBuilding.js +13 -8
  129. package/dist/structures/buildingKitMaterials.d.ts +20 -0
  130. package/dist/structures/buildingKitMaterials.js +70 -0
  131. package/dist/structures/buildingSpatialBatch.d.ts +11 -0
  132. package/dist/structures/buildingSpatialBatch.js +45 -0
  133. package/dist/terrain/GrassField.d.ts +2 -1
  134. package/dist/terrain/GrassField.js +59 -5
  135. package/dist/terrain/grassGeometry.d.ts +10 -0
  136. package/dist/terrain/grassGeometry.js +55 -0
  137. package/dist/terrain/grassMaterial.js +2 -2
  138. package/dist/terrain/terrainDetailMaterial.d.ts +3 -3
  139. package/dist/terrain/terrainDetailMaterial.js +7 -7
  140. package/dist/touch/TouchControlsOverlay.js +4 -4
  141. package/dist/useShellMultiplayerSync.js +4 -0
  142. package/dist/vfx/ParticleField.d.ts +23 -20
  143. package/dist/vfx/ParticleField.js +168 -50
  144. package/dist/visibility/CullingProvider.js +2 -62
  145. package/dist/visibility/cullingDriver.d.ts +9 -0
  146. package/dist/visibility/cullingDriver.js +68 -0
  147. package/dist/weather/AuthoredWeatherLayer.d.ts +16 -0
  148. package/dist/weather/AuthoredWeatherLayer.js +31 -0
  149. package/dist/weather/DustField.d.ts +2 -1
  150. package/dist/weather/DustField.js +17 -3
  151. package/dist/weather/FireSpreadLayer.d.ts +4 -2
  152. package/dist/weather/FireSpreadLayer.js +5 -11
  153. package/dist/weather/LightningStrike.d.ts +4 -1
  154. package/dist/weather/LightningStrike.js +13 -17
  155. package/dist/weather/RainField.d.ts +2 -1
  156. package/dist/weather/RainField.js +17 -3
  157. package/dist/weather/RainImpactField.d.ts +16 -0
  158. package/dist/weather/RainImpactField.js +89 -0
  159. package/dist/weather/SnowField.d.ts +2 -1
  160. package/dist/weather/SnowField.js +17 -3
  161. package/dist/weather/WeatherLayer.d.ts +11 -2
  162. package/dist/weather/WeatherLayer.js +30 -6
  163. package/dist/weather/index.d.ts +2 -0
  164. package/dist/weather/index.js +2 -0
  165. package/dist/weather/weatherMath.d.ts +2 -0
  166. package/dist/weather/weatherMath.js +8 -0
  167. package/dist/weather/weatherShelter.d.ts +18 -0
  168. package/dist/weather/weatherShelter.js +75 -0
  169. package/dist/weather/weatherUniforms.d.ts +40 -1
  170. package/dist/weather/weatherUniforms.js +29 -9
  171. package/dist/world/AuthoredFlocks.d.ts +22 -0
  172. package/dist/world/AuthoredFlocks.js +87 -0
  173. package/dist/world/StaticObjectModels.d.ts +8 -0
  174. package/dist/world/StaticObjectModels.js +75 -0
  175. package/dist/world/WorldHud.d.ts +14 -6
  176. package/dist/world/WorldHud.js +25 -12
  177. package/dist/world/WorldParticles.d.ts +10 -23
  178. package/dist/world/WorldParticles.js +209 -61
  179. package/dist/world/WorldScene.d.ts +3 -1
  180. package/dist/world/WorldScene.js +41 -26
  181. package/dist/world/staticObjectBatches.d.ts +35 -0
  182. package/dist/world/staticObjectBatches.js +163 -0
  183. package/dist/world/worldBarSamples.d.ts +37 -33
  184. package/dist/world/worldBarSamples.js +117 -75
  185. package/package.json +4 -4
  186. package/skills/ce-handoff/SKILL.md +1 -1
  187. package/skills/jgengine/SKILL.md +22 -2
  188. package/skills/jgengine/api.md +28 -17
  189. package/skills/jgengine/recipes/minimal-game.md +7 -2
  190. package/skills/jgengine-ui/SKILL.md +3 -1
  191. package/skills/jgengine-ui/api.md +204 -67
  192. package/skills/jgengine-ui/capabilities.md +115 -1
  193. package/skills/jgengine-ui/reference-primitives.md +93 -3
  194. package/skills/jgengine-ui/reference.md +2 -0
  195. package/skills/jgengine-world/SKILL.md +12 -3
  196. package/skills/jgengine-world/api.md +200 -57
  197. package/skills/jgengine-world/capabilities.md +116 -3
  198. package/skills/jgengine-world/recipes/character-animation.md +31 -8
  199. package/skills/jgengine-world/recipes/controls.md +4 -0
  200. package/skills/jgengine-world/recipes/npc-senses.md +12 -0
  201. package/skills/jgengine-world/recipes/vehicle-feel.md +3 -1
  202. package/skills/jgengine-world/reference.md +152 -5
  203. package/skills/jgengine-world/visibility.md +28 -1
@@ -0,0 +1,163 @@
1
+ import * as THREE from "three";
2
+ import { POINTER_OBJECT_INSTANCES_KEY } from "../pointer/pointerService.js";
3
+ /** The same compatibility gate is used when live placements change after grouping. @internal */
4
+ export function canBatchStaticObject(candidate) {
5
+ const { object, model, catalog } = candidate;
6
+ return candidate.authored && !candidate.custom && candidate.style === undefined &&
7
+ isCompatibleStaticObject(object, model, catalog);
8
+ }
9
+ /** Checks a live placement without constructing a grouping candidate. @internal */
10
+ export function isCompatibleStaticObject(object, model, catalog) {
11
+ const animation = object.animation ?? model?.animation;
12
+ return model !== undefined &&
13
+ object.visual === undefined && object.state === undefined && object.slots === undefined &&
14
+ catalog?.proximityPrompt === undefined && (catalog?.verbs?.length ?? 0) === 0 &&
15
+ (catalog?.breakable === undefined || catalog.breakable === false) && catalog?.slotInventory === undefined &&
16
+ (animation === undefined || animation === "auto" || animation === "none") && model.ik === undefined &&
17
+ (model.scale ?? 1) >= 0 && (model.parts?.length ?? 0) === 0 && (model.attachments?.length ?? 0) === 0 &&
18
+ model.partMotion === undefined && model.material?.maps === undefined && (model.materialAssignments?.length ?? 0) === 0 &&
19
+ (model.hiddenNodes === undefined || (Array.isArray(model.hiddenNodes) && model.hiddenNodes.length === 0));
20
+ }
21
+ /** Stable exclusion snapshot; only compatibility transitions allocate. @internal */
22
+ export function incompatibleStaticObjectIds(batch, get, catalog, previous, fallbackObjects) {
23
+ const compatible = (object) => object.catalogId === batch.objects[0].catalogId &&
24
+ isCompatibleStaticObject(object, batch.model, catalog(object.instanceId));
25
+ let count = 0;
26
+ let changed = false;
27
+ for (const object of batch.objects) {
28
+ const live = get(object.instanceId);
29
+ if (live !== null && !compatible(live)) {
30
+ changed ||= previous[count] !== live.instanceId;
31
+ const before = fallbackObjects?.get(live.instanceId);
32
+ if (before !== undefined)
33
+ changed ||= before.catalogId !== live.catalogId || before.animation !== live.animation ||
34
+ before.visual !== live.visual || before.state !== live.state || before.slots !== live.slots;
35
+ fallbackObjects?.set(live.instanceId, live);
36
+ count++;
37
+ }
38
+ }
39
+ if (fallbackObjects !== undefined)
40
+ for (const id of fallbackObjects.keys()) {
41
+ const live = get(id);
42
+ if (live === null || compatible(live))
43
+ fallbackObjects.delete(id);
44
+ }
45
+ if (!changed && count === previous.length)
46
+ return previous;
47
+ return batch.objects.flatMap((object) => {
48
+ const live = get(object.instanceId);
49
+ return live !== null && !compatible(live) ? [live.instanceId] : [];
50
+ });
51
+ }
52
+ /** Groups compatible authored props above eight placements per spatial cell. @internal */
53
+ export function groupStaticObjects(candidates, chunkSize = 24, previous) {
54
+ const groups = new Map();
55
+ const singles = [];
56
+ for (const candidate of candidates) {
57
+ const { object, model } = candidate;
58
+ if (model === undefined || !canBatchStaticObject(candidate)) {
59
+ singles.push(candidate);
60
+ continue;
61
+ }
62
+ const key = JSON.stringify([object.catalogId, Math.floor(object.position[0] / chunkSize), Math.floor(object.position[2] / chunkSize), model]);
63
+ let group = groups.get(key);
64
+ if (group === undefined) {
65
+ group = { batch: { key, model, objects: [] }, candidates: [] };
66
+ groups.set(key, group);
67
+ }
68
+ group.batch.objects.push(object);
69
+ group.candidates.push(candidate);
70
+ }
71
+ const batches = [];
72
+ for (const { batch, candidates: members } of groups.values()) {
73
+ if (batch.objects.length > 8) {
74
+ const cached = previous?.get(batch.key);
75
+ batches.push(cached !== undefined && cached.objects.length === batch.objects.length &&
76
+ cached.objects.every((object, index) => object.instanceId === batch.objects[index].instanceId) ? cached : batch);
77
+ }
78
+ else
79
+ singles.push(...members);
80
+ }
81
+ return { batches, singles };
82
+ }
83
+ /** Rejects loaded scenes whose behavior cannot be represented by static instanced draws. @internal */
84
+ export function canInstanceStaticScene(scene, clips) {
85
+ if (clips.length > 0)
86
+ return false;
87
+ scene.updateMatrixWorld(true);
88
+ if (!scene.matrix.equals(new THREE.Matrix4()))
89
+ return false;
90
+ let supported = true;
91
+ scene.traverse((node) => {
92
+ if (node.layers.mask !== 1 || node.renderOrder !== 0 || node.matrix.determinant() < 0)
93
+ supported = false;
94
+ const mesh = node;
95
+ if (!mesh.isMesh) {
96
+ if (!node.isGroup && node.type !== "Object3D" && node.type !== "Scene")
97
+ supported = false;
98
+ return;
99
+ }
100
+ if (mesh.isSkinnedMesh || mesh.isInstancedMesh ||
101
+ mesh.morphTargetInfluences !== undefined || mesh.onBeforeRender !== THREE.Object3D.prototype.onBeforeRender ||
102
+ mesh.onAfterRender !== THREE.Object3D.prototype.onAfterRender)
103
+ supported = false;
104
+ const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
105
+ if (materials.some((material) => material.transparent || material.isShaderMaterial))
106
+ supported = false;
107
+ const elements = mesh.matrixWorld.elements;
108
+ for (let a = 0; a < 3; a++)
109
+ for (let b = a + 1; b < 3; b++) {
110
+ const dot = elements[a * 4] * elements[b * 4] + elements[a * 4 + 1] * elements[b * 4 + 1] + elements[a * 4 + 2] * elements[b * 4 + 2];
111
+ const lengthA = Math.hypot(elements[a * 4], elements[a * 4 + 1], elements[a * 4 + 2]);
112
+ const lengthB = Math.hypot(elements[b * 4], elements[b * 4 + 1], elements[b * 4 + 2]);
113
+ // Three's instanced normal transform assumes orthogonal matrix columns.
114
+ if (Math.abs(dot) > 1e-5 * lengthA * lengthB)
115
+ supported = false;
116
+ }
117
+ });
118
+ return supported;
119
+ }
120
+ /** Compact visible placements and update picking identities without allocating per instance. @internal */
121
+ export function syncStaticObjectSource(mesh, source, objects, get, visible, scratch) {
122
+ const ids = (mesh.userData[POINTER_OBJECT_INSTANCES_KEY] ??= []);
123
+ const poses = (mesh.userData["jgStaticObjectPoses"] ??= []);
124
+ if (mesh.userData["jgStaticObjectSource"] !== source) {
125
+ poses.length = 0;
126
+ mesh.userData["jgStaticObjectSource"] = source;
127
+ }
128
+ let count = 0;
129
+ let changed = false;
130
+ const composed = scratch.matrixWorld;
131
+ for (const object of objects) {
132
+ const live = get(object.instanceId);
133
+ if (live === null || !visible(live.instanceId))
134
+ continue;
135
+ if (poses[count] === live) {
136
+ ids[count++] = live.instanceId;
137
+ continue;
138
+ }
139
+ scratch.position.fromArray(live.position);
140
+ scratch.rotation.set(0, live.rotationY, 0);
141
+ scratch.updateMatrix();
142
+ composed.multiplyMatrices(scratch.matrix, source.localMatrix);
143
+ const offset = count * 16;
144
+ for (let axis = 0; axis < 16; axis += 1) {
145
+ if (mesh.instanceMatrix.array[offset + axis] !== Math.fround(composed.elements[axis])) {
146
+ changed = true;
147
+ break;
148
+ }
149
+ }
150
+ mesh.setMatrixAt(count, composed);
151
+ poses[count] = live;
152
+ ids[count++] = live.instanceId;
153
+ }
154
+ ids.length = count;
155
+ poses.length = count;
156
+ changed ||= mesh.count !== count;
157
+ mesh.count = count;
158
+ mesh.visible = count > 0;
159
+ if (changed) {
160
+ mesh.instanceMatrix.needsUpdate = true;
161
+ mesh.computeBoundingSphere();
162
+ }
163
+ }
@@ -1,7 +1,8 @@
1
- import type { SceneEntity } from "@jgengine/core/scene/entityStore";
2
- import type { CatalogEntityRole } from "@jgengine/core/runtime/gameContext";
3
- import type { GameContext } from "@jgengine/core/runtime/gameContext";
1
+ import type { SceneEntity, EntityPosition } from "@jgengine/core/scene/entityStore";
2
+ import type { CatalogEntityRole, GameContext } from "@jgengine/core/runtime/gameContext";
4
3
  export interface WorldBarSample {
4
+ /** Live anchor id used by retained samples; omitted for caller-painted screen samples. */
5
+ entityId?: string;
5
6
  x: number;
6
7
  y: number;
7
8
  percent: number;
@@ -16,49 +17,52 @@ export interface Projectable {
16
17
  y: number;
17
18
  z: number;
18
19
  }
19
- /**
20
- * True when world geometry stands between the player's eye and `target`. World bars and nameplates
21
- * are DOM/canvas overlays projected to screen space, so nothing in the depth buffer can hide them —
22
- * without this test an enemy's health bar reads straight through the building it is standing behind.
23
- * @internal
24
- */
25
- export declare function worldBarOccluded(ctx: GameContext, from: readonly [number, number, number], target: readonly [number, number, number], eyeHeight: number): boolean;
26
- /**
27
- * Projects every non-local, role/distance-filtered entity carrying `statId` into `into` as a
28
- * `WorldBarSample`. Pure data step behind `WorldHealthBars` — call it directly to paint the bars
29
- * with your own renderer.
30
- * @internal
31
- */
32
- export declare function collectWorldBarSamples(ctx: GameContext, statId: string, height: number, roles: readonly CatalogEntityRole[] | undefined, resolveRole: ((entity: SceneEntity) => CatalogEntityRole | undefined) | undefined, camera: {
33
- matrixWorldInverse: unknown;
20
+ /** Render camera matrices; world translation supplies the overlay's viewpoint. */
21
+ export interface WorldOverlayCamera {
22
+ matrixWorld: {
23
+ elements: ArrayLike<number>;
24
+ };
25
+ matrixWorldInverse: {
26
+ elements: ArrayLike<number>;
27
+ };
34
28
  projectionMatrix: unknown;
35
- }, viewport: {
29
+ }
30
+ /** Sampling policy shared by health bars and nameplates. */
31
+ export interface WorldOverlaySampleOptions {
32
+ /** Maximum sampled overlays and visibility rays per refresh. Default 64; unchecked entities stay hidden. */
33
+ maxSamples?: number;
34
+ /** Match a renderer's visibility or membership policy. */
35
+ isVisible?: (id: string) => boolean;
36
+ /** Authored display name; return null to omit the nameplate. */
37
+ resolveName?: (entity: SceneEntity) => string | null;
38
+ }
39
+ /** True when blocking scene geometry lies between the render camera and overlay anchor. @internal */
40
+ export declare function worldBarOccluded(ctx: GameContext, from: EntityPosition, target: EntityPosition): boolean;
41
+ /** Project nearby live non-local entities carrying `statId`; occlusion defaults on. @internal */
42
+ export declare function collectWorldBarSamples(ctx: GameContext, statId: string, height: number, roles: readonly CatalogEntityRole[] | undefined, resolveRole: ((entity: SceneEntity) => CatalogEntityRole | undefined) | undefined, camera: WorldOverlayCamera, viewport: {
36
43
  width: number;
37
44
  height: number;
38
- }, into: WorldBarSample[], project: Projectable, maxDistance?: number, occlude?: boolean): number;
39
- /** One entity's projected nameplate: screen `x`/`y`, display `name`, health `percent` (or `null` when statless), and world `distance` from the player. */
45
+ }, into: WorldBarSample[], project: Projectable, maxDistance?: number, occlude?: boolean, options?: WorldOverlaySampleOptions): number;
46
+ /** Reproject retained bar anchors without a nearby query or visibility ray. @internal */
47
+ export declare function refreshWorldBarSamples(ctx: GameContext, height: number, camera: WorldOverlayCamera, viewport: {
48
+ width: number;
49
+ height: number;
50
+ }, samples: WorldBarSample[], project: Projectable, maxDistance?: number, options?: WorldOverlaySampleOptions): void;
51
+ /** Projected nameplate with optional health and distance from the render camera. */
40
52
  export interface NameplateSample {
41
53
  id: string;
42
54
  name: string;
43
55
  x: number;
44
56
  y: number;
45
- /** Health-stat fraction 0..1, or `null` when the entity carries no `statId` stat (name-only nameplate). */
57
+ /** Health-stat fraction 0..1, or null for a statless entity. */
46
58
  percent: number | null;
47
59
  distance: number;
48
60
  }
49
- /**
50
- * Projects every non-local, role/distance-filtered entity into `into` as a
51
- * `NameplateSample`. Pure data step powering `WorldNameplates` — swap in your
52
- * own renderer by calling this directly instead of the component.
53
- * @internal
54
- */
55
- export declare function collectNameplateSamples(ctx: GameContext, statId: string, height: number, roles: readonly CatalogEntityRole[] | undefined, resolveRole: ((entity: SceneEntity) => CatalogEntityRole | undefined) | undefined, camera: {
56
- matrixWorldInverse: unknown;
57
- projectionMatrix: unknown;
58
- }, viewport: {
61
+ /** Project nearby live non-local nameplates; occlusion defaults on. @internal */
62
+ export declare function collectNameplateSamples(ctx: GameContext, statId: string, height: number, roles: readonly CatalogEntityRole[] | undefined, resolveRole: ((entity: SceneEntity) => CatalogEntityRole | undefined) | undefined, camera: WorldOverlayCamera, viewport: {
59
63
  width: number;
60
64
  height: number;
61
- }, into: NameplateSample[], project: Projectable, maxDistance?: number, occlude?: boolean): number;
65
+ }, into: NameplateSample[], project: Projectable, maxDistance?: number, occlude?: boolean, options?: WorldOverlaySampleOptions): number;
62
66
  /** @internal */
63
67
  export declare function paintWorldBarSamples(canvas: {
64
68
  width: number;
@@ -1,98 +1,140 @@
1
1
  import { worldHealthBarAllowsRole } from "@jgengine/core/game/playableGame";
2
- /**
3
- * True when world geometry stands between the player's eye and `target`. World bars and nameplates
4
- * are DOM/canvas overlays projected to screen space, so nothing in the depth buffer can hide them —
5
- * without this test an enemy's health bar reads straight through the building it is standing behind.
6
- * @internal
7
- */
8
- export function worldBarOccluded(ctx, from, target, eyeHeight) {
9
- const origin = [from[0], from[1] + eyeHeight, from[2]];
10
- const dx = target[0] - origin[0];
11
- const dy = target[1] + eyeHeight - origin[1];
12
- const dz = target[2] - origin[2];
2
+ /** True when blocking scene geometry lies between the render camera and overlay anchor. @internal */
3
+ export function worldBarOccluded(ctx, from, target) {
4
+ const dx = target[0] - from[0];
5
+ const dy = target[1] - from[1];
6
+ const dz = target[2] - from[2];
13
7
  const length = Math.hypot(dx, dy, dz);
14
8
  if (length < 0.001)
15
9
  return false;
16
- const hit = ctx.scene.object.raycast({
17
- origin,
10
+ return ctx.scene.raycast({
11
+ origin: from,
18
12
  direction: [dx / length, dy / length, dz / length],
19
- // Stop short of the entity so its own collider never counts as its own occluder.
20
- maxDistance: length - 0.5,
21
- });
22
- return hit !== null;
13
+ maxDistance: length - 0.001,
14
+ filter: { entities: false },
15
+ accept: (hit) => hit.blocks,
16
+ }) !== null;
23
17
  }
24
- /**
25
- * Projects every non-local, role/distance-filtered entity carrying `statId` into `into` as a
26
- * `WorldBarSample`. Pure data step behind `WorldHealthBars` — call it directly to paint the bars
27
- * with your own renderer.
28
- * @internal
29
- */
30
- export function collectWorldBarSamples(ctx, statId, height, roles, resolveRole, camera, viewport, into, project, maxDistance = 60, occlude = false) {
18
+ function cameraOrigin(camera) {
19
+ const matrix = camera.matrixWorld.elements;
20
+ return [matrix[12], matrix[13], matrix[14]];
21
+ }
22
+ function projectAnchor(entity, height, camera, project) {
23
+ const anchor = [entity.position[0], entity.position[1] + height, entity.position[2]];
24
+ const inverse = camera.matrixWorldInverse.elements;
25
+ const cameraZ = inverse[2] * anchor[0] + inverse[6] * anchor[1] + inverse[10] * anchor[2] + inverse[14];
26
+ if (!Number.isFinite(cameraZ) || cameraZ >= 0)
27
+ return null;
28
+ project.set(...anchor).project(camera);
29
+ if (!Number.isFinite(project.x) || !Number.isFinite(project.y) || !Number.isFinite(project.z))
30
+ return null;
31
+ if (Math.abs(project.x) > 1 || Math.abs(project.y) > 1 || Math.abs(project.z) > 1)
32
+ return null;
33
+ return anchor;
34
+ }
35
+ function healthFraction(stat) {
36
+ const range = stat.max - stat.min;
37
+ return range <= 0 ? 0 : Math.max(0, Math.min(1, (stat.current - stat.min) / range));
38
+ }
39
+ function eligibleEntity(ctx, id, options) {
40
+ if (id === ctx.player.userId || id === ctx.player.possession.active(ctx.player.userId) || options.isVisible?.(id) === false)
41
+ return null;
42
+ const entity = ctx.scene.entity.get(id);
43
+ if (entity === null || entity.hidden === true)
44
+ return null;
45
+ const health = ctx.scene.entity.stats.get(id, "health");
46
+ if (health !== null && health.current <= health.min)
47
+ return null;
48
+ return entity;
49
+ }
50
+ function sampleLimit(options) {
51
+ return Math.max(0, Math.min(256, Math.floor(options.maxSamples ?? 64) || 0));
52
+ }
53
+ /** Project nearby live non-local entities carrying `statId`; occlusion defaults on. @internal */
54
+ export function collectWorldBarSamples(ctx, statId, height, roles, resolveRole, camera, viewport, into, project, maxDistance = 60, occlude = true, options = {}) {
31
55
  into.length = 0;
32
- const playerId = ctx.player.userId;
33
- const player = ctx.scene.entity.get(playerId);
34
- for (const entity of ctx.scene.entity.list()) {
35
- if (entity.id === playerId)
56
+ const origin = cameraOrigin(camera);
57
+ const limit = sampleLimit(options);
58
+ if (limit === 0 || !Number.isFinite(maxDistance) || maxDistance <= 0)
59
+ return 0;
60
+ let checked = 0;
61
+ for (const id of ctx.scene.entity.inRadius(origin, maxDistance)) {
62
+ const entity = eligibleEntity(ctx, id, options);
63
+ if (entity === null || !worldHealthBarAllowsRole(roles, resolveRole?.(entity)))
36
64
  continue;
37
- if (!worldHealthBarAllowsRole(roles, resolveRole?.(entity)))
65
+ const stat = ctx.scene.entity.stats.get(id, statId);
66
+ if (stat === null)
38
67
  continue;
39
- if (player !== null &&
40
- Math.hypot(entity.position[0] - player.position[0], entity.position[2] - player.position[2]) >
41
- maxDistance) {
68
+ const anchor = projectAnchor(entity, height, camera, project);
69
+ if (anchor === null)
42
70
  continue;
43
- }
44
- if (occlude && player !== null && worldBarOccluded(ctx, player.position, entity.position, 1.5))
71
+ if (checked++ >= limit)
72
+ break;
73
+ if (occlude && worldBarOccluded(ctx, origin, anchor))
45
74
  continue;
46
- const stat = ctx.scene.entity.stats.get(entity.id, statId);
47
- if (stat === null)
75
+ into.push({
76
+ entityId: id,
77
+ x: (project.x * 0.5 + 0.5) * viewport.width,
78
+ y: (-project.y * 0.5 + 0.5) * viewport.height,
79
+ percent: healthFraction(stat),
80
+ });
81
+ }
82
+ return into.length;
83
+ }
84
+ /** Reproject retained bar anchors without a nearby query or visibility ray. @internal */
85
+ export function refreshWorldBarSamples(ctx, height, camera, viewport, samples, project, maxDistance = 60, options = {}) {
86
+ const origin = cameraOrigin(camera);
87
+ let kept = 0;
88
+ for (const sample of samples) {
89
+ if (sample.entityId === undefined)
90
+ continue;
91
+ const entity = eligibleEntity(ctx, sample.entityId, options);
92
+ if (entity === null || Math.hypot(entity.position[0] - origin[0], entity.position[1] - origin[1], entity.position[2] - origin[2]) > maxDistance)
48
93
  continue;
49
- const range = stat.max - stat.min;
50
- const percent = range <= 0 ? 0 : Math.max(0, Math.min(1, (stat.current - stat.min) / range));
51
- project.set(entity.position[0], entity.position[1] + height, entity.position[2]);
52
- project.project(camera);
53
- if (project.z < -1 || project.z > 1)
94
+ if (projectAnchor(entity, height, camera, project) === null)
54
95
  continue;
55
- const x = (project.x * 0.5 + 0.5) * viewport.width;
56
- const y = (-project.y * 0.5 + 0.5) * viewport.height;
57
- into.push({ x, y, percent });
96
+ sample.x = (project.x * 0.5 + 0.5) * viewport.width;
97
+ sample.y = (-project.y * 0.5 + 0.5) * viewport.height;
98
+ samples[kept++] = sample;
58
99
  }
59
- return into.length;
100
+ samples.length = kept;
101
+ }
102
+ function defaultDisplayName(entity) {
103
+ const name = entity.name.trim();
104
+ // Entity names also key catalogs; machine identifiers need an explicit display-name resolver.
105
+ return name === entity.id || /^[a-z0-9_-]+$/.test(name) || /[_:/]/.test(name) ? null : name || null;
60
106
  }
61
- /**
62
- * Projects every non-local, role/distance-filtered entity into `into` as a
63
- * `NameplateSample`. Pure data step powering `WorldNameplates` — swap in your
64
- * own renderer by calling this directly instead of the component.
65
- * @internal
66
- */
67
- export function collectNameplateSamples(ctx, statId, height, roles, resolveRole, camera, viewport, into, project, maxDistance = 40, occlude = false) {
107
+ /** Project nearby live non-local nameplates; occlusion defaults on. @internal */
108
+ export function collectNameplateSamples(ctx, statId, height, roles, resolveRole, camera, viewport, into, project, maxDistance = 40, occlude = true, options = {}) {
68
109
  into.length = 0;
69
- const playerId = ctx.player.userId;
70
- const player = ctx.scene.entity.get(playerId);
71
- for (const entity of ctx.scene.entity.list()) {
72
- if (entity.id === playerId)
110
+ const origin = cameraOrigin(camera);
111
+ const limit = sampleLimit(options);
112
+ if (limit === 0 || !Number.isFinite(maxDistance) || maxDistance <= 0)
113
+ return 0;
114
+ let checked = 0;
115
+ for (const id of ctx.scene.entity.inRadius(origin, maxDistance)) {
116
+ const entity = eligibleEntity(ctx, id, options);
117
+ if (entity === null || !worldHealthBarAllowsRole(roles, resolveRole?.(entity)))
73
118
  continue;
74
- if (!worldHealthBarAllowsRole(roles, resolveRole?.(entity)))
119
+ const name = (options.resolveName ?? defaultDisplayName)(entity)?.trim();
120
+ if (!name)
75
121
  continue;
76
- const distance = player === null
77
- ? 0
78
- : Math.hypot(entity.position[0] - player.position[0], entity.position[2] - player.position[2]);
79
- if (player !== null && distance > maxDistance)
122
+ const anchor = projectAnchor(entity, height, camera, project);
123
+ if (anchor === null)
80
124
  continue;
81
- if (occlude && player !== null && worldBarOccluded(ctx, player.position, entity.position, 1.5))
82
- continue;
83
- const stat = ctx.scene.entity.stats.get(entity.id, statId);
84
- let percent = null;
85
- if (stat !== null) {
86
- const range = stat.max - stat.min;
87
- percent = range <= 0 ? 0 : Math.max(0, Math.min(1, (stat.current - stat.min) / range));
88
- }
89
- project.set(entity.position[0], entity.position[1] + height, entity.position[2]);
90
- project.project(camera);
91
- if (project.z < -1 || project.z > 1)
125
+ if (checked++ >= limit)
126
+ break;
127
+ if (occlude && worldBarOccluded(ctx, origin, anchor))
92
128
  continue;
93
- const x = (project.x * 0.5 + 0.5) * viewport.width;
94
- const y = (-project.y * 0.5 + 0.5) * viewport.height;
95
- into.push({ id: entity.id, name: entity.name, x, y, percent, distance });
129
+ const stat = ctx.scene.entity.stats.get(id, statId);
130
+ into.push({
131
+ id,
132
+ name,
133
+ x: (project.x * 0.5 + 0.5) * viewport.width,
134
+ y: (-project.y * 0.5 + 0.5) * viewport.height,
135
+ percent: stat === null ? null : healthFraction(stat),
136
+ distance: Math.hypot(entity.position[0] - origin[0], entity.position[1] - origin[1], entity.position[2] - origin[2]),
137
+ });
96
138
  }
97
139
  return into.length;
98
140
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jgengine/shell",
3
- "version": "0.19.0",
3
+ "version": "0.21.0",
4
4
  "description": "Game player shell for JGengine: React Three Fiber canvas, orbit camera, input tracking, HUD mounting, GameUiPreview, and a demo game. Consumers supply a GameRegistry.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -35,9 +35,9 @@
35
35
  "test": "bun test src"
36
36
  },
37
37
  "dependencies": {
38
- "@jgengine/core": "^0.19.0",
39
- "@jgengine/react": "^0.19.0",
40
- "@jgengine/ws": "^0.19.0"
38
+ "@jgengine/core": "^0.21.0",
39
+ "@jgengine/react": "^0.21.0",
40
+ "@jgengine/ws": "^0.21.0"
41
41
  },
42
42
  "peerDependencies": {
43
43
  "@react-three/drei": "^10.0.0",
@@ -25,7 +25,7 @@ Create one immutable handoff at the destination the user requested, or use the m
25
25
  ### Build the handoff
26
26
 
27
27
  1. Distill the current objective and the user's latest intent. If a focus was supplied, make it the `resume_focus`.
28
- 2. Inspect only the workspace state needed to explain what exists now. Use JGengine project instructions already in context (`AGENTS.md` / `CLAUDE.md`, `workflow`, domain skills).
28
+ 2. Inspect only the workspace state needed to explain what exists now. Use JGengine project instructions already in context (`AGENTS.md`, `workflow`, domain skills).
29
29
  3. Point to plans, issues, commits, diffs, documentation, and relevant files instead of reproducing their contents. Prefer repository-relative paths (`docs/plans/…`, `packages/…`, `Games/…`).
30
30
  4. Redact secrets, credentials, tokens, and unrelated personal information. Preserve operational paths only when the next agent needs them.
31
31
  5. Write or publish with existing tools. If the user named another path, folder, format, or publication destination, honor it. Do not also create a managed-store copy unless asked.
@@ -9,6 +9,22 @@ This skill decides what to load; it is not an engine manual. Package map, comman
9
9
 
10
10
  A created project installs only `jgengine`, `jgengine-editor`, `jgengine-verify`, `game-design`, and `jgengine-ui`. Run `npx jgengine skills -p --all` before loading any other domain named below. Those projects do not ship `api.md`; read exact signatures from the `.d.ts` files under `node_modules/@jgengine/<pkg>/dist/`.
11
11
 
12
+ Created projects pin their scaffolding CLI as a local devDependency, separately from SDK versions. Run `npx jgengine doctor` after SDK upgrades to check CLI compatibility. Skill installation preserves newer or differing unversioned copies; update the project CLI first, and use `skills -p --force` only when replacement is intended. Each packaged skill install records its CLI and SDK versions in `.jgengine-version.json`.
13
+
14
+ ## Framework-first decisions
15
+
16
+ For every requested interaction, feature, fix or visual change, follow the boundary in
17
+ [AGENTS.md](../../../AGENTS.md): discover the installed
18
+ capability, adopt it, or improve its shared owner before adding reusable game-local code.
19
+ Games keep unique rules, content, assets and presentation. Custom markup is not a reason
20
+ to copy focus handling, input tracking, storage or model lifecycle.
21
+
22
+ Check installed versions before recreating an upstream fix. A published upgrade may
23
+ replace a workaround; a beta merge does not update consumers. In the games workspace,
24
+ the root catalog owns SDK versions. After adoption, remove superseded local machinery
25
+ and verify the actual game. Outside these repositories, report missing SDK contracts
26
+ upstream rather than treating a local engine implementation as the finished solution.
27
+
12
28
  ## 1. Establish the target
13
29
 
14
30
  For greenfield builds, write `src/art-direction.md` before any code.
@@ -26,6 +42,10 @@ Pick the dimension first: `create` defaults to a 3D world (terrain, sky, player
26
42
 
27
43
  For an existing game asked to pick up a new engine release ("jgengine published a new version, grab it"), run `npx jgengine upgrade` in the project first: it diffs installed `@jgengine/*` versions against the latest release and prints every Migrate step and Adopt-worthy addition in between (`--json` for structured output). Bump, migrate oldest-first, then work the Adopt lists instead of reconstructing the changelog by hand.
28
44
 
45
+ `doctor` and `upgrade` resolve Bun `catalog:` and `catalog:<name>` declarations from the nearest workspace's `workspaces.catalog` or `workspaces.catalogs`, and follow hoisted SDK installs. Missing or malformed catalog entries fail diagnostics. `upgrade` is read-only by default. For catalog-owned versions, run `upgrade --plan --to <published-version>` at the workspace root or in a game, review the whole root catalog plan and migrations, then run `upgrade --apply --to <published-version>`. Apply preserves per-game `catalog:` declarations, unrelated text, CLI/github versions, and a held editor override; it verifies published SDK targets and refuses files changed while planning. Apply changes declarations only: run `bun install` at the root, then `jgengine doctor --workspace --json` once there to check installed SDK identities across declared workspace owners. Rebuild, migrate, and verify game behavior afterward. `--json` includes exact before/after text; an apply prints its plan before writing and reports write status on stderr. A report without installed packages labels its resolved pins as a declared baseline.
46
+
47
+ When a linked consumer renders differently despite matching root SDK versions, `doctor` checks direct consumers and SDK dependency/peer paths, including private copies with the same version. Review the reported importer and real paths, align the shared catalog and linked-consumer declarations, then reinstall at the root and rerun `doctor --workspace`; a root install may leave private linked-package installs intact. Restart running games after reinstall. Workspace mode checks installed identities only, reports coverage, and refuses unsupported patterns, escaped symlinks or exhausted traversal budgets. The default project audit stays scoped; neither mode covers custom bundler aliases or indirect non-SDK chains.
48
+
29
49
  ## 2. Select domains
30
50
 
31
51
  Load only rows the target needs. Do not preload every domain.
@@ -49,12 +69,12 @@ World content routes editor-first: any request that adds, moves, restyles, or re
49
69
  Within each selected domain:
50
70
 
51
71
  0. Fastest start for a common composition: `npx jgengine recipe <name>` prints a vetted, SDK-typechecked snippet (imports + wired code) (`combat-loop`, `loot`, `world-drops`, `click-target`, `ability-bar`, `hitscan-weapon`, `rolled-gear`, `enter-vehicle` and more); `npx jgengine recipe` lists them. Copy that instead of reconning per-file.
52
- 1. Search capabilities by intent; do not read `capabilities.md` top to bottom. `npx jgengine find <intent>` searches every domain's index and every recipe at once, installed or not, and prints the primitive and its import (or the recipe to open); in the monorepo, `grep -i "<intent>" .claude/skills/*/capabilities.md` does the same. Search the moment you are tempted to hand-roll a HUD window, inventory grid, paperdoll, hotkey listener, minimap, stat bar, or character motion. A `z-index`, a `keydown` for a panel, a `<div>` inventory, or a limb bob almost certainly has a shipped drop-in (`usePanels`/`PanelHost`, `InventoryGrid`, `CharacterSheet`/`Paperdoll`, `PartMotionRig`, `EntityPreview`); games own the *skin*, not the re-derivation.
72
+ 1. Search capabilities by intent; do not read `capabilities.md` top to bottom. `npx jgengine find <intent>` searches every domain's index and every recipe at once, installed or not, and prints the primitive and its import (or the recipe to open); agents can use `--json --limit 5` for ranked imports and recipes with total, partial-match, and truncation fields. In the monorepo, `rg -i "<intent>" .claude/skills/*/capabilities.md` does the same search. Search the moment you are tempted to hand-roll a HUD window, inventory grid, paperdoll, hotkey listener, minimap, stat bar, or character motion. A `z-index`, a `keydown` for a panel, a `<div>` inventory, or a limb bob almost certainly has a shipped drop-in (`usePanels`/`PanelHost`, `InventoryGrid`, `CharacterSheet`/`Paperdoll`, `PartMotionRig`, `EntityPreview`); games own the *skin*, not the re-derivation.
53
73
  2. Open `api.md` (monorepo) or the package `.d.ts` only when exact signatures are needed; grep it, never read it whole.
54
74
  3. Open the linked reference only for a deeper recipe or trap.
55
75
  4. For how several primitives wire into a running loop, read the domain's `recipes/` — connected, genre-free walkthroughs organized by composition seam; this skill's own [recipes/minimal-game.md](recipes/minimal-game.md) is the default whole-game walkthrough. Prefer a recipe over reading a game's source; the games under `Games/*` are not templates or references, and a hybrid game is just a different composition of the same primitives.
56
76
 
57
- If no capability fits, identify the upstream package seam before writing game-local code. In this repository, implement a reusable primitive with its first adopter; if editor-owned content cannot be expressed, file the editor gap first.
77
+ If no capability fits, identify the upstream package seam before writing game-local code. In this repository, implement a reusable primitive with its first adopter and a reviewed consumer cleanup; coordinate a differently composed second consumer when applicable. Recipes wire SDK APIs, not copied implementations. If editor-owned content cannot be expressed, file the editor gap first; track any fallback and its removal path.
58
78
 
59
79
  ## 4. Build and finish
60
80