@jgengine/shell 0.12.0 → 0.14.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.
- package/CHANGELOG.md +73 -1
- package/dist/CombatPresentation.js +2 -1
- package/dist/GameHost.d.ts +2 -0
- package/dist/GameHost.js +25 -2
- package/dist/GamePlayerShell.js +15 -1
- package/dist/Shell3dPresentation.js +2 -2
- package/dist/camera/GameOrbitCamera.js +7 -1
- package/dist/camera/cameraCollision.d.ts +28 -0
- package/dist/camera/cameraCollision.js +34 -0
- package/dist/camera/cameraRigs.js +8 -8
- package/dist/camera/index.d.ts +1 -0
- package/dist/camera/index.js +1 -0
- package/dist/camera/rigMath.d.ts +7 -0
- package/dist/camera/rigMath.js +16 -0
- package/dist/devtools/CollisionDebugWorld.js +15 -1
- package/dist/devtools/collisionDebugMath.d.ts +8 -0
- package/dist/devtools/collisionDebugMath.js +14 -5
- package/dist/environment/EnvironmentScene.js +6 -1
- package/dist/environment/RoadJunctions.d.ts +12 -0
- package/dist/environment/RoadJunctions.js +69 -0
- package/dist/environment/RoadRibbons.d.ts +8 -2
- package/dist/environment/RoadRibbons.js +26 -8
- package/dist/postfx/PostProcessing.js +27 -2
- package/dist/postfx/postfxOverlay.d.ts +24 -0
- package/dist/postfx/postfxOverlay.js +35 -0
- package/dist/presentationResolve.d.ts +5 -1
- package/dist/presentationResolve.js +3 -1
- package/dist/render/EntityPreview.d.ts +53 -0
- package/dist/render/EntityPreview.js +23 -0
- package/dist/render/PartMotion.d.ts +18 -0
- package/dist/render/PartMotion.js +91 -0
- package/dist/render/SceneModels.d.ts +9 -2
- package/dist/render/SceneModels.js +43 -179
- package/dist/render/measureBounds.d.ts +34 -0
- package/dist/render/measureBounds.js +131 -0
- package/dist/render/measureCollisionMesh.d.ts +26 -0
- package/dist/render/measureCollisionMesh.js +108 -0
- package/dist/render/resolveModel.js +16 -10
- package/dist/render/useModelAnimation.d.ts +10 -0
- package/dist/render/useModelAnimation.js +198 -0
- package/dist/structures/GeneratedBuilding.d.ts +4 -0
- package/dist/structures/GeneratedBuilding.js +17 -7
- package/dist/world/WorldHud.d.ts +3 -1
- package/dist/world/WorldHud.js +2 -2
- package/dist/world/WorldScene.js +3 -2
- package/package.json +4 -4
- package/skills/jgengine/SKILL.md +5 -2
- package/skills/jgengine/api.md +13 -5
- package/skills/jgengine-ui/SKILL.md +9 -4
- package/skills/jgengine-ui/api.md +138 -8
- package/skills/jgengine-ui/capabilities.md +72 -0
- package/skills/jgengine-ui/reference.md +40 -3
- package/skills/jgengine-world/api.md +82 -25
- package/skills/jgengine-world/capabilities.md +12 -4
- package/skills/jgengine-world/reference.md +1 -1
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { useFrame } from "@react-three/fiber";
|
|
2
|
+
import { useEffect, useMemo, useRef } from "react";
|
|
3
|
+
import * as THREE from "three";
|
|
4
|
+
import { resolveAnimationConfig } from "@jgengine/core/game/clipRoles";
|
|
5
|
+
import { resolveOneShotClip } from "@jgengine/core/game/modelAnimation";
|
|
6
|
+
import { useGameContext } from "@jgengine/react/provider";
|
|
7
|
+
/**
|
|
8
|
+
* The engine's model animation driver as a standalone hook — the same mixer `EntityModel` runs,
|
|
9
|
+
* for games that render a cloned scene themselves (custom materials, procedural composition).
|
|
10
|
+
* Handles `"auto"` derivation from the GLB's clip names, speed-driven idle/walk/run crossfades
|
|
11
|
+
* read from the entity's live position when `instanceId` is set, one-shots fired from
|
|
12
|
+
* `entity.animation` / `combat.hitReaction` / `entity.died`, held poses, and the death clamp.
|
|
13
|
+
*/
|
|
14
|
+
export function useModelAnimation(scene, clips, animationInput, instanceId) {
|
|
15
|
+
const ctx = useGameContext();
|
|
16
|
+
// "auto" (stamped by catalog resolution, or set inline) derives states/one-shots from the
|
|
17
|
+
// loaded GLB's actual clip names; "none" and absent render the bind pose.
|
|
18
|
+
const animation = useMemo(() => resolveAnimationConfig(animationInput, clips.map((clip) => clip.name)), [animationInput, clips]);
|
|
19
|
+
const mixerRef = useRef(null);
|
|
20
|
+
const animationPausedRef = useRef(false);
|
|
21
|
+
const stateActionsRef = useRef(null);
|
|
22
|
+
const states = animation?.states;
|
|
23
|
+
const oneShots = animation?.oneShots;
|
|
24
|
+
const oneShotPlayRef = useRef(null);
|
|
25
|
+
const activeOneShotRef = useRef(null);
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (animation === undefined || clips.length === 0) {
|
|
28
|
+
mixerRef.current = null;
|
|
29
|
+
stateActionsRef.current = null;
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const mixer = new THREE.AnimationMixer(scene);
|
|
33
|
+
if (states !== undefined) {
|
|
34
|
+
const clipFor = (name) => THREE.AnimationClip.findByName(clips, name) ?? clips[0];
|
|
35
|
+
const actions = {
|
|
36
|
+
idle: mixer.clipAction(clipFor(states.idle)),
|
|
37
|
+
walk: mixer.clipAction(clipFor(states.walk)),
|
|
38
|
+
...(states.run === undefined ? {} : { run: mixer.clipAction(clipFor(states.run)) }),
|
|
39
|
+
};
|
|
40
|
+
for (const action of Object.values(actions)) {
|
|
41
|
+
action.setLoop(THREE.LoopRepeat, Infinity);
|
|
42
|
+
action.timeScale = animation.timeScale ?? 1;
|
|
43
|
+
action.enabled = true;
|
|
44
|
+
}
|
|
45
|
+
actions.idle.play();
|
|
46
|
+
mixer.update(0);
|
|
47
|
+
mixerRef.current = mixer;
|
|
48
|
+
stateActionsRef.current = { actions, active: "idle", lastPos: null, smoothedSpeed: 0 };
|
|
49
|
+
animationPausedRef.current = false;
|
|
50
|
+
let onOneShotFinished = null;
|
|
51
|
+
if (oneShots !== undefined) {
|
|
52
|
+
const clipNames = new Set();
|
|
53
|
+
for (const spec of Object.values(oneShots)) {
|
|
54
|
+
if (typeof spec === "string")
|
|
55
|
+
clipNames.add(spec);
|
|
56
|
+
else
|
|
57
|
+
for (const name of spec)
|
|
58
|
+
clipNames.add(name);
|
|
59
|
+
}
|
|
60
|
+
const oneShotActions = new Map();
|
|
61
|
+
for (const name of clipNames) {
|
|
62
|
+
const found = THREE.AnimationClip.findByName(clips, name);
|
|
63
|
+
if (found === null)
|
|
64
|
+
continue;
|
|
65
|
+
const oneShotAction = mixer.clipAction(found);
|
|
66
|
+
oneShotAction.setLoop(THREE.LoopOnce, 1);
|
|
67
|
+
oneShotAction.enabled = true;
|
|
68
|
+
oneShotActions.set(name, oneShotAction);
|
|
69
|
+
}
|
|
70
|
+
onOneShotFinished = ({ action }) => {
|
|
71
|
+
const active = activeOneShotRef.current;
|
|
72
|
+
if (active === null || action !== active.action || active.isDeath)
|
|
73
|
+
return;
|
|
74
|
+
const machine = stateActionsRef.current;
|
|
75
|
+
const back = machine?.actions[machine.active];
|
|
76
|
+
action.fadeOut(0.15);
|
|
77
|
+
if (back !== undefined)
|
|
78
|
+
back.reset().fadeIn(0.15).play();
|
|
79
|
+
activeOneShotRef.current = null;
|
|
80
|
+
};
|
|
81
|
+
mixer.addEventListener("finished", onOneShotFinished);
|
|
82
|
+
oneShotPlayRef.current = (event) => {
|
|
83
|
+
const active = activeOneShotRef.current;
|
|
84
|
+
if (active !== null && active.isDeath)
|
|
85
|
+
return;
|
|
86
|
+
const clipName = resolveOneShotClip(oneShots, event, Math.random());
|
|
87
|
+
if (clipName === null)
|
|
88
|
+
return;
|
|
89
|
+
const oneShotAction = oneShotActions.get(clipName);
|
|
90
|
+
if (oneShotAction === undefined)
|
|
91
|
+
return;
|
|
92
|
+
const machine = stateActionsRef.current;
|
|
93
|
+
machine?.actions[machine.active]?.fadeOut(0.1);
|
|
94
|
+
if (active !== null && active.action !== oneShotAction)
|
|
95
|
+
active.action.stop();
|
|
96
|
+
oneShotAction.clampWhenFinished = event === "death";
|
|
97
|
+
oneShotAction.reset().fadeIn(0.1).play();
|
|
98
|
+
activeOneShotRef.current = { action: oneShotAction, isDeath: event === "death" };
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
return () => {
|
|
102
|
+
if (onOneShotFinished !== null)
|
|
103
|
+
mixer.removeEventListener("finished", onOneShotFinished);
|
|
104
|
+
oneShotPlayRef.current = null;
|
|
105
|
+
activeOneShotRef.current = null;
|
|
106
|
+
mixer.stopAllAction();
|
|
107
|
+
mixerRef.current = null;
|
|
108
|
+
stateActionsRef.current = null;
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
const clip = (animation.clip !== undefined ? THREE.AnimationClip.findByName(clips, animation.clip) : undefined) ??
|
|
112
|
+
clips[0];
|
|
113
|
+
const action = mixer.clipAction(clip);
|
|
114
|
+
action.setLoop(animation.loop === false ? THREE.LoopOnce : THREE.LoopRepeat, Infinity);
|
|
115
|
+
action.clampWhenFinished = animation.loop === false;
|
|
116
|
+
action.timeScale = animation.timeScale ?? 1;
|
|
117
|
+
action.enabled = true;
|
|
118
|
+
action.paused = animation.paused === true;
|
|
119
|
+
action.play();
|
|
120
|
+
if (animation.time !== undefined)
|
|
121
|
+
action.time = animation.time;
|
|
122
|
+
mixer.update(0);
|
|
123
|
+
mixerRef.current = mixer;
|
|
124
|
+
animationPausedRef.current = animation.paused === true;
|
|
125
|
+
return () => {
|
|
126
|
+
mixer.stopAllAction();
|
|
127
|
+
mixerRef.current = null;
|
|
128
|
+
};
|
|
129
|
+
}, [
|
|
130
|
+
scene,
|
|
131
|
+
clips,
|
|
132
|
+
animation?.clip,
|
|
133
|
+
animation?.loop,
|
|
134
|
+
animation?.timeScale,
|
|
135
|
+
animation?.paused,
|
|
136
|
+
animation?.time,
|
|
137
|
+
states,
|
|
138
|
+
oneShots,
|
|
139
|
+
]);
|
|
140
|
+
useEffect(() => {
|
|
141
|
+
if (instanceId === undefined || oneShots === undefined)
|
|
142
|
+
return;
|
|
143
|
+
const fire = (event) => oneShotPlayRef.current?.(event);
|
|
144
|
+
const offAnimation = ctx.game.events.on("entity.animation", (event) => {
|
|
145
|
+
if (event.instanceId === instanceId)
|
|
146
|
+
fire(event.event);
|
|
147
|
+
});
|
|
148
|
+
const offHit = ctx.game.events.on("combat.hitReaction", (event) => {
|
|
149
|
+
if (event.instanceId === instanceId)
|
|
150
|
+
fire("hit");
|
|
151
|
+
});
|
|
152
|
+
const offDied = ctx.game.events.on("entity.died", (event) => {
|
|
153
|
+
if (event.instanceId === instanceId)
|
|
154
|
+
fire("death");
|
|
155
|
+
});
|
|
156
|
+
return () => {
|
|
157
|
+
offAnimation();
|
|
158
|
+
offHit();
|
|
159
|
+
offDied();
|
|
160
|
+
};
|
|
161
|
+
}, [ctx, instanceId, oneShots]);
|
|
162
|
+
useFrame((_state, delta) => {
|
|
163
|
+
const stateMachine = stateActionsRef.current;
|
|
164
|
+
if (stateMachine !== null && states !== undefined && instanceId !== undefined && delta > 0) {
|
|
165
|
+
const entity = ctx.scene.entity.get(instanceId);
|
|
166
|
+
if (entity !== null) {
|
|
167
|
+
const [x, , z] = entity.position;
|
|
168
|
+
if (stateMachine.lastPos !== null) {
|
|
169
|
+
const instantSpeed = Math.hypot(x - stateMachine.lastPos[0], z - stateMachine.lastPos[2]) / delta;
|
|
170
|
+
stateMachine.smoothedSpeed +=
|
|
171
|
+
(instantSpeed - stateMachine.smoothedSpeed) * Math.min(1, delta * 12);
|
|
172
|
+
}
|
|
173
|
+
stateMachine.lastPos = [x, entity.position[1], z];
|
|
174
|
+
const walkSpeed = states.walkSpeed ?? 0.5;
|
|
175
|
+
const runSpeed = states.runSpeed ?? 6;
|
|
176
|
+
const next = stateMachine.smoothedSpeed < walkSpeed
|
|
177
|
+
? "idle"
|
|
178
|
+
: stateMachine.actions.run !== undefined && stateMachine.smoothedSpeed >= runSpeed
|
|
179
|
+
? "run"
|
|
180
|
+
: "walk";
|
|
181
|
+
if (next !== stateMachine.active) {
|
|
182
|
+
if (activeOneShotRef.current === null) {
|
|
183
|
+
const fade = states.fadeSec ?? 0.2;
|
|
184
|
+
const from = stateMachine.actions[stateMachine.active];
|
|
185
|
+
const to = stateMachine.actions[next];
|
|
186
|
+
if (from !== undefined && to !== undefined) {
|
|
187
|
+
to.reset().fadeIn(fade).play();
|
|
188
|
+
from.fadeOut(fade);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
stateMachine.active = next;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (mixerRef.current !== null && !animationPausedRef.current)
|
|
196
|
+
mixerRef.current.update(delta);
|
|
197
|
+
});
|
|
198
|
+
}
|
|
@@ -43,6 +43,10 @@ export interface GeneratedBuildingProps {
|
|
|
43
43
|
export interface InstancedBuildingPlacement {
|
|
44
44
|
building: GeneratedBuildingData;
|
|
45
45
|
position?: readonly [number, number, number];
|
|
46
|
+
/** Building yaw (radians) applied to the whole massing about `pivot` — street-aware facing. */
|
|
47
|
+
rotationY?: number;
|
|
48
|
+
/** World XZ the `rotationY` turns about (the building center); defaults to the world origin. */
|
|
49
|
+
pivot?: readonly [number, number];
|
|
46
50
|
}
|
|
47
51
|
export interface InstancedBuildingsProps {
|
|
48
52
|
buildings: readonly InstancedBuildingPlacement[];
|
|
@@ -70,25 +70,35 @@ function bucketPartMatrices(buildings, visible) {
|
|
|
70
70
|
const buckets = new Map();
|
|
71
71
|
for (const placement of buildings) {
|
|
72
72
|
const [ox, oy, oz] = placement.position ?? [0, 0, 0];
|
|
73
|
+
// Building yaw turns the whole massing about its center. Engine Y-rotation convention (matches
|
|
74
|
+
// dummy.rotation.y below): a local vector (x, z) maps to (x·cos + z·sin, −x·sin + z·cos).
|
|
75
|
+
const yaw = placement.rotationY ?? 0;
|
|
76
|
+
const [px0, pz0] = placement.pivot ?? [0, 0];
|
|
77
|
+
const cos = Math.cos(yaw);
|
|
78
|
+
const sin = Math.sin(yaw);
|
|
73
79
|
for (const part of placement.building.parts) {
|
|
74
80
|
if (visible !== null && !visible.has(part.kind))
|
|
75
81
|
continue;
|
|
76
82
|
const [nx, nz] = normalFor(part.facade);
|
|
77
83
|
const offset = outwardOffset(part);
|
|
78
|
-
|
|
84
|
+
// Local offset from the pivot (part position + facade-normal nudge), then rotated by the yaw.
|
|
85
|
+
const lx = part.position[0] - px0 + nx * offset;
|
|
86
|
+
const lz = part.position[2] - pz0 + nz * offset;
|
|
87
|
+
const px = ox + px0 + lx * cos + lz * sin;
|
|
79
88
|
const py = oy + part.position[1];
|
|
80
|
-
const pz = oz +
|
|
89
|
+
const pz = oz + pz0 - lx * sin + lz * cos;
|
|
90
|
+
const partYaw = part.rotationY + yaw;
|
|
81
91
|
let bucket = buckets.get(part.kind);
|
|
82
92
|
if (bucket === undefined) {
|
|
83
93
|
bucket = [];
|
|
84
94
|
buckets.set(part.kind, bucket);
|
|
85
95
|
}
|
|
86
96
|
if (part.kind === "clothesline") {
|
|
87
|
-
const
|
|
88
|
-
const
|
|
97
|
+
const rowSin = Math.sin(partYaw);
|
|
98
|
+
const rowCos = Math.cos(partYaw);
|
|
89
99
|
for (const row of CLOTHESLINE_ROW_OFFSETS) {
|
|
90
|
-
dummy.position.set(px + row *
|
|
91
|
-
dummy.rotation.set(0,
|
|
100
|
+
dummy.position.set(px + row * rowSin, py, pz + row * rowCos);
|
|
101
|
+
dummy.rotation.set(0, partYaw, 0);
|
|
92
102
|
dummy.scale.set(part.scale[0], Math.max(part.scale[1], 0.025), 0.025);
|
|
93
103
|
dummy.updateMatrix();
|
|
94
104
|
bucket.push(dummy.matrix.clone());
|
|
@@ -96,7 +106,7 @@ function bucketPartMatrices(buildings, visible) {
|
|
|
96
106
|
continue;
|
|
97
107
|
}
|
|
98
108
|
dummy.position.set(px, py, pz);
|
|
99
|
-
dummy.rotation.set(0,
|
|
109
|
+
dummy.rotation.set(0, partYaw, 0);
|
|
100
110
|
dummy.scale.set(part.scale[0], part.scale[1], part.scale[2]);
|
|
101
111
|
dummy.updateMatrix();
|
|
102
112
|
bucket.push(dummy.matrix.clone());
|
package/dist/world/WorldHud.d.ts
CHANGED
|
@@ -23,6 +23,8 @@ export interface WorldNameplatesProps {
|
|
|
23
23
|
maxDistance?: number;
|
|
24
24
|
/** Minimum ms between position/health refreshes — trades smoothness for fewer re-renders at scale. Default 120. */
|
|
25
25
|
tickMs?: number;
|
|
26
|
+
/** Draw the built-in HP bar under the name. Set `false` for a name-only plate when the game already draws its own health bar (its own HUD, or `worldHealthBars`). Default true. */
|
|
27
|
+
showHealth?: boolean;
|
|
26
28
|
className?: string;
|
|
27
29
|
nameplateClassName?: string;
|
|
28
30
|
nameClassName?: string;
|
|
@@ -39,7 +41,7 @@ export interface WorldNameplatesProps {
|
|
|
39
41
|
*
|
|
40
42
|
* @capability nameplates billboarded name + HP bar over nearby entities
|
|
41
43
|
*/
|
|
42
|
-
export declare function WorldNameplates({ statId, height, roles, resolveRole, maxDistance, tickMs, className, nameplateClassName, nameClassName, barClassName, fillClassName, renderNameplate, }: WorldNameplatesProps): import("react").JSX.Element;
|
|
44
|
+
export declare function WorldNameplates({ statId, height, roles, resolveRole, maxDistance, tickMs, showHealth, className, nameplateClassName, nameClassName, barClassName, fillClassName, renderNameplate, }: WorldNameplatesProps): import("react").JSX.Element;
|
|
43
45
|
/** Props for {@link WorldObjectHighlights}. */
|
|
44
46
|
export interface WorldObjectHighlightsProps {
|
|
45
47
|
/** Ring tint. Default a construction-select amber. */
|
package/dist/world/WorldHud.js
CHANGED
|
@@ -50,7 +50,7 @@ export function WorldEntityBars({ statId, height = 2.2, roles, resolveRole, maxD
|
|
|
50
50
|
*
|
|
51
51
|
* @capability nameplates billboarded name + HP bar over nearby entities
|
|
52
52
|
*/
|
|
53
|
-
export function WorldNameplates({ statId = "health", height = 2.3, roles, resolveRole, maxDistance = 40, tickMs = 120, className, nameplateClassName, nameClassName, barClassName, fillClassName, renderNameplate, }) {
|
|
53
|
+
export function WorldNameplates({ statId = "health", height = 2.3, roles, resolveRole, maxDistance = 40, tickMs = 120, showHealth = true, className, nameplateClassName, nameClassName, barClassName, fillClassName, renderNameplate, }) {
|
|
54
54
|
const ctx = useGameContext();
|
|
55
55
|
const camera = useThree((state) => state.camera);
|
|
56
56
|
const size = useThree((state) => state.size);
|
|
@@ -83,7 +83,7 @@ export function WorldNameplates({ statId = "health", height = 2.3, roles, resolv
|
|
|
83
83
|
color: "#f1f5f9",
|
|
84
84
|
textShadow: "0 1px 3px rgba(0,0,0,0.9)",
|
|
85
85
|
whiteSpace: "nowrap",
|
|
86
|
-
}, children: sample.name }), sample.percent !== null ? (_jsx("span", { className: barClassName, "data-nameplate-bar": true, style: {
|
|
86
|
+
}, children: sample.name }), showHealth && sample.percent !== null ? (_jsx("span", { className: barClassName, "data-nameplate-bar": true, style: {
|
|
87
87
|
display: "block",
|
|
88
88
|
width: 78,
|
|
89
89
|
height: 6,
|
package/dist/world/WorldScene.js
CHANGED
|
@@ -9,6 +9,7 @@ import { useGameContext } from "@jgengine/react/provider";
|
|
|
9
9
|
import { useSceneEntityIds, useSceneObjectIds, useGameStore, usePlayer, useTarget } from "@jgengine/react/hooks";
|
|
10
10
|
import { colorFromId } from "../worldSky.js";
|
|
11
11
|
import { DefaultSurface, detailMaps } from "../render/defaultSurface.js";
|
|
12
|
+
import { MeasuredBoundsGroup } from "../render/measureBounds.js";
|
|
12
13
|
import { EntitySprite, IsolatedEntityModel } from "../render/SceneModels.js";
|
|
13
14
|
import { resolveModel, resolveEntityModel, tryResolveCatalogModel } from "../render/resolveModel.js";
|
|
14
15
|
import { useRenderVisibility } from "../visibility/CullingProvider.js";
|
|
@@ -41,7 +42,7 @@ function EntityMarker({ entity, custom, model, sprite, isLocal, targeted, select
|
|
|
41
42
|
const live = ctx.scene.entity.get(entityId);
|
|
42
43
|
if (live !== null)
|
|
43
44
|
onSelect(live);
|
|
44
|
-
}, children: [selected ? (_jsxs("mesh", { "rotation-x": -Math.PI / 2, "position-y": 0.02, children: [_jsx("ringGeometry", { args: [0.8, 0.95, 32] }), _jsx("meshBasicMaterial", { color: "#34d399", transparent: true, opacity: 0.9 })] })) : null, custom !== undefined && custom !== null ? (custom) : model !== undefined ? (_jsx(IsolatedEntityModel, { model: model, instanceId: entityId, fallback: sprite !== undefined ? _jsx(EntitySprite, { sprite: sprite }) : undefined })) : sprite !== undefined ? (_jsx(EntitySprite, { sprite: sprite })) : role === "prop" ? (_jsxs("mesh", { "position-y": 0.5, castShadow: true, receiveShadow: true, children: [_jsx("sphereGeometry", { args: [0.45, 24, 24] }), _jsx(DefaultSurface, { color: color })] })) : (_jsxs("group", { scale: ctx.scene.entity.visualScaleOf(entityId), children: [_jsxs("mesh", { "position-y": 0.95, castShadow: true, receiveShadow: true, children: [_jsx("capsuleGeometry", { args: [0.35, 1.1, 6, 14] }), _jsx(DefaultSurface, { color: color, roughness: 0.6 })] }), _jsxs("mesh", { position: [0, 1.35, 0.32], children: [_jsx("boxGeometry", { args: [0.16, 0.16, 0.16] }), _jsx("meshStandardMaterial", { color: "#f8fafc" })] })] })), targeted ? (_jsxs("mesh", { "rotation-x": -Math.PI / 2, "position-y": 0.03, children: [_jsx("ringGeometry", { args: [0.6, 0.75, 28] }), _jsx("meshBasicMaterial", { color: "#f87171" })] })) : null] }));
|
|
45
|
+
}, children: [selected ? (_jsxs("mesh", { "rotation-x": -Math.PI / 2, "position-y": 0.02, children: [_jsx("ringGeometry", { args: [0.8, 0.95, 32] }), _jsx("meshBasicMaterial", { color: "#34d399", transparent: true, opacity: 0.9 })] })) : null, custom !== undefined && custom !== null ? (_jsx(MeasuredBoundsGroup, { target: "entity", measureKey: name, children: custom })) : model !== undefined ? (_jsx(IsolatedEntityModel, { model: model, instanceId: entityId, measure: { target: "entity", key: name }, fallback: sprite !== undefined ? _jsx(EntitySprite, { sprite: sprite }) : undefined })) : sprite !== undefined ? (_jsx(EntitySprite, { sprite: sprite })) : role === "prop" ? (_jsxs("mesh", { "position-y": 0.5, castShadow: true, receiveShadow: true, children: [_jsx("sphereGeometry", { args: [0.45, 24, 24] }), _jsx(DefaultSurface, { color: color })] })) : (_jsxs("group", { scale: ctx.scene.entity.visualScaleOf(entityId), children: [_jsxs("mesh", { "position-y": 0.95, castShadow: true, receiveShadow: true, children: [_jsx("capsuleGeometry", { args: [0.35, 1.1, 6, 14] }), _jsx(DefaultSurface, { color: color, roughness: 0.6 })] }), _jsxs("mesh", { position: [0, 1.35, 0.32], children: [_jsx("boxGeometry", { args: [0.16, 0.16, 0.16] }), _jsx("meshStandardMaterial", { color: "#f8fafc" })] })] })), targeted ? (_jsxs("mesh", { "rotation-x": -Math.PI / 2, "position-y": 0.03, children: [_jsx("ringGeometry", { args: [0.6, 0.75, 28] }), _jsx("meshBasicMaterial", { color: "#f87171" })] })) : null] }));
|
|
45
46
|
}
|
|
46
47
|
function ObjectMarker({ object, custom, model, style, }) {
|
|
47
48
|
const groupRef = useRef(null);
|
|
@@ -61,7 +62,7 @@ function ObjectMarker({ object, custom, model, style, }) {
|
|
|
61
62
|
writeEntityPose(group, live);
|
|
62
63
|
group.visible = visibleRef.current(instanceId);
|
|
63
64
|
});
|
|
64
|
-
return (_jsx("group", { ref: groupRef, userData: { [POINTER_OBJECT_KEY]: instanceId }, children: custom !== undefined && custom !== null ? (custom) : model !== undefined ? (_jsx(IsolatedEntityModel, { model: model, instanceId: instanceId })) : style?.hidden === true ? null : (_jsxs("mesh", { "position-y": 0.5 * scaleY, scale: [scaleX, scaleY, scaleZ], castShadow: true, receiveShadow: true, children: [_jsx("boxGeometry", { args: [1, 1, 1] }), _jsx(DefaultSurface, { color: color, transparent: opacity < 1, opacity: opacity })] })) }));
|
|
65
|
+
return (_jsx("group", { ref: groupRef, userData: { [POINTER_OBJECT_KEY]: instanceId }, children: custom !== undefined && custom !== null ? (_jsx(MeasuredBoundsGroup, { target: "object", measureKey: object.catalogId, children: custom })) : model !== undefined ? (_jsx(IsolatedEntityModel, { model: model, instanceId: instanceId, measure: { target: "object", key: object.catalogId } })) : style?.hidden === true ? null : (_jsxs("mesh", { "position-y": 0.5 * scaleY, scale: [scaleX, scaleY, scaleZ], castShadow: true, receiveShadow: true, children: [_jsx("boxGeometry", { args: [1, 1, 1] }), _jsx(DefaultSurface, { color: color, transparent: opacity < 1, opacity: opacity })] })) }));
|
|
65
66
|
}
|
|
66
67
|
function GroundPlane() {
|
|
67
68
|
const geometry = useMemo(() => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jgengine/shell",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.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.
|
|
39
|
-
"@jgengine/react": "^0.
|
|
40
|
-
"@jgengine/ws": "^0.
|
|
38
|
+
"@jgengine/core": "^0.14.0",
|
|
39
|
+
"@jgengine/react": "^0.14.0",
|
|
40
|
+
"@jgengine/ws": "^0.14.0"
|
|
41
41
|
},
|
|
42
42
|
"peerDependencies": {
|
|
43
43
|
"@react-three/drei": "^10.0.0",
|
package/skills/jgengine/SKILL.md
CHANGED
|
@@ -5,7 +5,7 @@ description: Route game work to the smallest useful JGengine guidance set.
|
|
|
5
5
|
|
|
6
6
|
# JGengine intake and router
|
|
7
7
|
|
|
8
|
-
Read the
|
|
8
|
+
Read the JGengine README for package truth, commands, layering, and license — the repo-root [`README.md`](../../../README.md) in the monorepo, or [jgengine.com](https://jgengine.com) from a scaffolded consumer project (where the relative link does not resolve). This skill decides what to load; it is not an engine manual.
|
|
9
9
|
|
|
10
10
|
## 1. Establish the target
|
|
11
11
|
|
|
@@ -20,6 +20,8 @@ Treat the pitch as a unique composition of needs, not a genre to fill in — nev
|
|
|
20
20
|
|
|
21
21
|
For a new in-repo game, scaffold with `bun run new:game "<id-or-title>"` (thin script alias for `jgengine create`, auto-detects the in-repo variant). Outside this monorepo, start with `npx jgengine create`; never copy `Games/*`.
|
|
22
22
|
|
|
23
|
+
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.
|
|
24
|
+
|
|
23
25
|
## 2. Select domains
|
|
24
26
|
|
|
25
27
|
Load only rows the target needs. Do not preload every domain.
|
|
@@ -42,7 +44,8 @@ World content routes editor-first: any request that adds, moves, restyles, or re
|
|
|
42
44
|
|
|
43
45
|
Within each selected domain:
|
|
44
46
|
|
|
45
|
-
|
|
47
|
+
0. Fastest start for a common composition: `npx jgengine recipe <name>` prints a vetted, SDK-typechecked snippet (imports + wired code) for `combat-loop`, `boss-telegraph`, `loot`, `quest`, `coop-presence`, `third-person-camera`; `npx jgengine recipe` lists them. Copy that instead of reconning per-file.
|
|
48
|
+
1. Search `capabilities.md` by intent; it maps needs to imports. Outside the monorepo (or before a domain skill is even loaded), `npx jgengine find <intent>` greps every shipped capability index at once and prints the primitive + import — reach for it the moment you're tempted to hand-roll a HUD window, inventory grid, paperdoll, hotkey listener, minimap, stat bar, or character motion. If you're writing a `z-index`, a `keydown` for a panel, a `<div>` inventory, or a limb bob, a shipped drop-in almost certainly exists (`usePanels`/`PanelHost`, `InventoryGrid`, `CharacterSheet`/`Paperdoll`, `PartMotionRig`, `EntityPreview`); games own the *skin*, not the re-derivation.
|
|
46
49
|
2. Open `api.md` only when exact signatures or export inventory are needed.
|
|
47
50
|
3. Open the linked reference only for a deeper recipe or trap.
|
|
48
51
|
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.
|
package/skills/jgengine/api.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
- `CHANGELOG` (const): const CHANGELOG: Record<string, ChangelogEntry> — Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`).
|
|
8
8
|
- `ChangelogEntry` (interface): interface ChangelogEntry — One release's migrate steps plus added/changed/removed notes (typed mirror of CHANGELOG.md).
|
|
9
|
-
- `VERSION` (const): const VERSION: "0.
|
|
9
|
+
- `VERSION` (const): const VERSION: "0.14.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
|
|
10
10
|
|
|
11
11
|
## @jgengine/core/authoring
|
|
12
12
|
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
- `WorldFeature` (type): type WorldFeature = | PlaceWorldFeature | ({ kind: "biomes" } & BiomesWorldConfig) | ({ kind: "voxel" } & VoxelWorldConfig) | ({ kind: "plots" } & PlotsWorldConfig) | ({ kind: "tilemap" } & TilemapWorldConfig) | EnvironmentWorldFeature | { kind: "flat" } — A declared world shape passed to `defineGame`. The preferred model is the place feature from `world()` (`@jgengine/core/world/place`): substrate + laws, with all dressing authored in the editor. The remaining members — biomes, voxel grid, plots, tilemap, environment, flat — are the legacy code-declared shapes kept for existing games.
|
|
39
39
|
- `auditManifest` (function): function auditManifest(manifest: SceneOwnershipManifest): OwnershipDiagnostic[] — Audit every declaration in a manifest, keying diagnostics by each object's provenance id. An empty result means the manifest declares all of its content cleanly — every object is authored, generated, bakeable, or a reasoned runtime/transient object.
|
|
40
40
|
- `biomes` (function): function biomes(config: BiomesWorldConfig): WorldFeature — Declares a biome-painted world — the whole-world alternative to a single `environment()` terrain.
|
|
41
|
-
- `building` (function): function building(config: BuildingEnvironmentConfig = {}): BuildingEnvironmentDescriptor — Declares a cluster of procedurally-massed buildings for `environment()` — count, footprint, stories, style.
|
|
41
|
+
- `building` (function): function building(config: BuildingEnvironmentConfig = {}): BuildingEnvironmentDescriptor — Declares a cluster of procedurally-massed buildings for `environment()` — count, footprint, stories, style. Pass `along` to line road frontage instead of gridding around `position`.
|
|
42
42
|
- `classifyOwnership` (function): function classifyOwnership(declaration: SceneOwnershipDeclaration): OwnershipVerdict — Resolve one declaration into a single boundary verdict.
|
|
43
43
|
- `collectOwnershipDiagnostics` (function): function collectOwnershipDiagnostics(entries: Iterable<readonly [string, SceneOwnershipDeclaration]>): OwnershipDiagnostic[] — Audit a keyed set of declarations and return one diagnostic per object that breaks the boundary contract (a `reject` verdict carrying a `violation`). Deterministic: diagnostics come back in the iteration order of `entries`.
|
|
44
44
|
- `contextVerb` (function): function contextVerb(label: string, command: string, args?: Record<string, unknown>): ContextVerb — Builds a {@link ContextVerb} for a right-click menu entry.
|
|
@@ -209,11 +209,18 @@
|
|
|
209
209
|
- `Tunable` (interface): interface Tunable<T> — Typed handle to a registered control, exposing its live value and subscribe/set/reset operations.
|
|
210
210
|
- `TunableAccessor` (interface): interface TunableAccessor — Get/set accessor pair plus initial value used to bind a discovered field to a control.
|
|
211
211
|
|
|
212
|
+
## @jgengine/core/devtools/urlFlags
|
|
213
|
+
|
|
214
|
+
- `readUrlFlag` (function): function readUrlFlag(param: string): boolean — True when `param` is present and not an explicit off value (`0`/`false`/`off`/`no`).
|
|
215
|
+
- `readUrlParam` (function): function readUrlParam(param: string): string | null — The current query value of `param`, or null when absent (or no DOM).
|
|
216
|
+
- `subscribeUrlChange` (function): function subscribeUrlChange(listener: () => void): () => void — Fires `listener` whenever the query string may have changed out from under a flag — browser back/forward (`popstate`). Manual address-bar edits reload the page, so initial reads cover those; this keeps in-app state honest across history navigation. Returns an unsubscribe.
|
|
217
|
+
- `writeUrlParam` (function): function writeUrlParam(param: string, value: string | null): void — Sets `param` to `value`, or removes it when `value` is null, rewriting the URL in place with `history.replaceState` so the rest of the query and the hash survive and no history entry is pushed. No-ops without a DOM, and skips the write when the URL already matches.
|
|
218
|
+
|
|
212
219
|
## @jgengine/core/meta/changelog
|
|
213
220
|
|
|
214
221
|
- `CHANGELOG` (const): const CHANGELOG: Record<string, ChangelogEntry> — Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`).
|
|
215
222
|
- `ChangelogEntry` (interface): interface ChangelogEntry — One release's migrate steps plus added/changed/removed notes (typed mirror of CHANGELOG.md).
|
|
216
|
-
- `VERSION` (const): const VERSION: "0.
|
|
223
|
+
- `VERSION` (const): const VERSION: "0.14.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
|
|
217
224
|
|
|
218
225
|
## @jgengine/core/runtime/adapter
|
|
219
226
|
|
|
@@ -276,7 +283,7 @@
|
|
|
276
283
|
- `SceneObjectContext` (interface): interface SceneObjectContext extends ObjectStore — ⚠ undocumented
|
|
277
284
|
- `SceneWorldItemContext` (interface): interface SceneWorldItemContext — ⚠ undocumented
|
|
278
285
|
- `TelegraphInput` (interface): interface TelegraphInput — ⚠ undocumented
|
|
279
|
-
- `VfxInput` (interface): interface VfxInput — Request a transient spell/ability VFX burst; `from`/`to` accept an instance id or a world point
|
|
286
|
+
- `VfxInput` (interface): interface VfxInput — Request a transient spell/ability VFX burst. The easy path is a named `preset` — `vfx({ preset: "arrow", from: caster, to: enemy })` renders a visible bolt with no color or archetype tuning; `"lightning"`, `"web"`, `"slash"`, `"shield"`, `"heal"`, `"explosion"` and the rest of {@link vfxPresets} likewise just work. `from`/`to` accept an instance id (the shell follows its live pose) or a fixed world point. Anything you also pass — `kind`, `color` (`0xRRGGBB`), `radius`, `durationMs` — overrides the preset; supply `kind` + `color` yourself for a fully custom burst with no preset. `durationMs` defaults per `kind`.
|
|
280
287
|
- `WorldItemPickupResult` (type): type WorldItemPickupResult = | { status: "ok"; record: WorldItemRecord } | { status: "rejected"; reason: string } — ⚠ undocumented
|
|
281
288
|
- `createGameContext` (function): function createGameContext<TAssetRef extends ModelAssetRef, TMultiplayer>(options: GameContextOptions<TAssetRef, TMultiplayer>): GameContext — ⚠ undocumented
|
|
282
289
|
|
|
@@ -307,7 +314,7 @@
|
|
|
307
314
|
- `SceneObjectContext` (interface): interface SceneObjectContext extends ObjectStore — ⚠ undocumented
|
|
308
315
|
- `SceneWorldItemContext` (interface): interface SceneWorldItemContext — ⚠ undocumented
|
|
309
316
|
- `TelegraphInput` (interface): interface TelegraphInput — ⚠ undocumented
|
|
310
|
-
- `VfxInput` (interface): interface VfxInput — Request a transient spell/ability VFX burst; `from`/`to` accept an instance id or a world point
|
|
317
|
+
- `VfxInput` (interface): interface VfxInput — Request a transient spell/ability VFX burst. The easy path is a named `preset` — `vfx({ preset: "arrow", from: caster, to: enemy })` renders a visible bolt with no color or archetype tuning; `"lightning"`, `"web"`, `"slash"`, `"shield"`, `"heal"`, `"explosion"` and the rest of {@link vfxPresets} likewise just work. `from`/`to` accept an instance id (the shell follows its live pose) or a fixed world point. Anything you also pass — `kind`, `color` (`0xRRGGBB`), `radius`, `durationMs` — overrides the preset; supply `kind` + `color` yourself for a fully custom burst with no preset. `durationMs` defaults per `kind`.
|
|
311
318
|
- `WorldItemPickupResult` (type): type WorldItemPickupResult = | { status: "ok"; record: WorldItemRecord } | { status: "rejected"; reason: string } — ⚠ undocumented
|
|
312
319
|
|
|
313
320
|
## @jgengine/core/runtime/gameRuntime
|
|
@@ -322,6 +329,7 @@
|
|
|
322
329
|
|
|
323
330
|
## @jgengine/core/runtime/headlessRunner
|
|
324
331
|
|
|
332
|
+
- `HeadlessCommandInterface` (interface): interface HeadlessCommandInterface — Canvas-free command/intent surface for {@link HeadlessRunner} — invoke a game's registered UI commands and read the resulting reactive state off `ctx`.
|
|
325
333
|
- `HeadlessInput` (interface): interface HeadlessInput — One step's worth of player intent handed to {@link HeadlessRunner.step} — the held-action set and pointer state the shell would otherwise publish from the browser each frame.
|
|
326
334
|
- `HeadlessRunner` (interface): interface HeadlessRunner — A renderer-free driver for a game loop: builds a {@link GameContext} from a {@link GameDefinition}, runs the init hooks, then advances the simulation one step at a time from injected input. No React, R3F, or three.js — the whole play path (time, input, `onTick`, behaviour nav, optional player movement) runs from `core` primitives alone, so a non-React host (a server tick, a test, a CLI replay) can play a real game and read its world snapshot. The shell's FrameDriver is one such driver bolted to `useFrame`; this is the same step distilled out of the render tree.
|
|
327
335
|
- `HeadlessRunnerOptions` (interface): interface HeadlessRunnerOptions<TAssetRef extends ModelAssetRef, TMultiplayer> — ⚠ undocumented
|
|
@@ -14,9 +14,9 @@ Search [capabilities.md](capabilities.md), use [api.md](api.md) for signatures,
|
|
|
14
14
|
## Canonical workflow
|
|
15
15
|
|
|
16
16
|
1. Write the game's UI art direction first (see [reference.md](reference.md) §1) — fantasy, shape/material language, hierarchy, forbidden patterns. Stock glass widgets are not a stand-in for this step.
|
|
17
|
-
2. Define the real UI states: attract
|
|
17
|
+
2. Define the real UI states: attract, main menu (the game's front-end), character create/select where relevant, live play, pause, settings, credits, results, empty/error, and relevant overlays.
|
|
18
18
|
3. Read state through selectors/hooks; keep simulation mutation behind commands.
|
|
19
|
-
4. Compose custom markup for
|
|
19
|
+
4. Compose this game's UI, reaching first for the shipped building blocks — `InventoryGrid`, `usePanels`/`PanelHost`/`Window`, `CharacterSheet`/`Paperdoll`, and the action/selection bars — and composing + reskinning them; only hand-roll custom markup where no building block fits. Toggleable windows (bag `B`, character `C`, etc.) come from `usePanels`, not hand-rolled open/close state — and `PanelHost` already stacks windows above the HUD (`zIndexBase`) and wires hotkeys/ESC, so never hand-roll a `z-*` or keydown listener for them. A live 3D character-screen portrait is `EntityPreview` (`@jgengine/shell/render/EntityPreview`): it owns the nested `<Canvas>`, the `GameContextBridge` across the R3F boundary, and the `StudioStage` lighting — pass the game's own `renderEntity` as children (or a native `model`+`instanceId`) and it mirrors the live entity; don't re-derive the context bridge by hand. Prefer headless data + game-owned chrome. When a second presentation is plausible, split data / renderer / chrome (minimap stack example: `useLiveMarkers` → track/renderer → game frame; see reference §6).
|
|
20
20
|
5. Make keyboard, pointer, touch, controller, focus, and screen-reader behavior explicit.
|
|
21
21
|
6. Add preview fixtures using the real components for fast deterministic capture.
|
|
22
22
|
7. Verify desktop and mobile layouts through `jgengine-verify`. A HUD that could pass for another game's default chrome fails visual review.
|
|
@@ -28,8 +28,12 @@ Existing React games keep their entity store and use the focused
|
|
|
28
28
|
## Product rules
|
|
29
29
|
|
|
30
30
|
- **Every game owns its UI end-to-end.** Custom composition, skin, placement, terminology, motion, and one main menu. The website/runner is a bare loader.
|
|
31
|
-
-
|
|
32
|
-
-
|
|
31
|
+
- **The main menu is a game front-end, not marketing.** The runner loads straight into the game's own menu; it offers the real front-end verbs the game supports — New Game, Continue/Load (save slots), Create/Choose character or class, Settings, Credits — each an authored screen in the game's art direction, not a landing page or raw scaffold. See [reference.md](reference.md) §"Main menu".
|
|
32
|
+
- **Settings is game-owned and reachable from a sensible place.** Every game ships settings as *its* settings — skinned and placed inline with its UI (main menu and pause), never the engine's stock face floated into a corner. Credits are a reachable in-game screen, not attribution buried in a build artifact.
|
|
33
|
+
- Engine packages ship both headless seams *and* good drop-in building blocks. The seams are layout (`HudCanvas`/`HudPanel`), data hooks, interaction models, and tokens; the building blocks are the parts almost every game needs — inventory grids (`InventoryGrid`), toggleable windows (`usePanels`/`PanelHost`/`Window`), character sheets (`CharacterSheet`/`Paperdoll`), stat/vitals bars, and action/selection bars. Composing and reskinning these is using the engine correctly, not out of policy. What stays out of scope is a whole finished game *face* or a genre theme preset shipped as the product (see [AGENTS.md](../../../AGENTS.md)).
|
|
34
|
+
- **Skin the shared chrome with one `HudTheme` token object.** `hudThemeVars(theme)` emits the `--jg-*` custom properties that the atomic bars, `HudFrame variation="themed"`, painted slots, and the minimap ring all read, so one theme spread on any HUD ancestor reskins the whole subtree — palette, frame material, corner radius, glow, and slot chrome. Author your own theme (tower-guard's `fieldkitVars` is the model); `HUD_THEME_PRESETS` / `resolveHudTheme("<preset>")` are demo/scaffold starting points, never a game's identity. Caller `style`/`renderItem` overrides still win over the theme default.
|
|
35
|
+
- **Paint slot icons with `IconTreatment`, never render raw `itemId` strings.** It draws a `GameIcon` glyph (or any node) over a school/element-keyed radial gradient with a gloss + vignette and optional count/keycap badges, and reads the `--jg-slot-*` / `--jg-accent` `HudTheme` tokens so a theme change reskins every treated icon. `schoolForItem`/`schoolForAction` infer the gradient from an id; `treatedItemIcon(itemId)` / `treatedActionIcon(action)` are ready adapters. Use it in hotbars and ability bars so slots read as painted icons, not debug text.
|
|
36
|
+
- Never reach for a genre HUD kit, theme preset, or "default RPG/FPS chrome" as the game's *identity*. Composing the shared building blocks (bag, character, spellbook, action-bar windows) is fine and expected — it's a prefab genre *look/identity* dropped in unchanged that's discouraged. Build the look this pitch needs on top of them.
|
|
33
37
|
- Layout and skin remain caller-controlled; shared primitives own reusable behavior, not product look.
|
|
34
38
|
- SSR-visible output is hydration-stable; round computed SVG values at the boundary.
|
|
35
39
|
|
|
@@ -39,3 +43,4 @@ Existing React games keep their entity store and use the focused
|
|
|
39
43
|
- Do not duplicate inventory, combat, quest, or selection state inside components.
|
|
40
44
|
- A static facsimile preview does not test the real UI; compose production components with fixtures.
|
|
41
45
|
- Visual quality is judged from rendered evidence, not component counts or prose.
|
|
46
|
+
- Shown HUD UI must fit the player viewport. `HudCanvas` runs a live boundary check on every registered `HudPanel`, any element tagged `data-hud-window`, and its own direct children: if a *visible* surface crosses a viewport edge it draws a red dev overlay on the offender, `console.warn`s, and sets `data-hud-overflow` (which fails the `bun run shoot` gate). A closed/parked/hidden surface (off-screen drawer, unopened menu, `visibility:hidden`, `aria-hidden`) never trips it — only surfaces partly on screen and partly past an edge. Deliberate bleed opts out with `data-hud-allow-overflow`. Mount custom windows inside `HudCanvas` (tag deeper ones `data-hud-window`) so the check can see them.
|