@jgengine/react 0.18.0 → 0.19.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 +265 -2
- package/dist/bars.js +2 -1
- package/dist/chat.d.ts +10 -2
- package/dist/chat.js +45 -5
- package/dist/hooks.d.ts +5 -0
- package/dist/hooks.js +5 -0
- package/dist/hudFrame.js +2 -0
- package/dist/hudSkin.d.ts +29 -0
- package/dist/hudSkin.js +50 -0
- package/dist/hudSkinPreview.d.ts +4 -0
- package/dist/hudSkinPreview.js +31 -0
- package/dist/hudTheme.d.ts +4 -0
- package/dist/hudTheme.js +14 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/previewFixtures.js +14 -0
- package/dist/spriteClipPreview.d.ts +6 -0
- package/dist/spriteClipPreview.js +72 -0
- package/dist/standaloneChatPanel.d.ts +38 -0
- package/dist/standaloneChatPanel.js +55 -0
- package/dist/tileLayerPreview.d.ts +4 -0
- package/dist/tileLayerPreview.js +37 -0
- package/dist/useServerSession.d.ts +11 -0
- package/dist/useServerSession.js +39 -0
- package/package.json +2 -2
- package/skills/ce-handoff/SKILL.md +2 -2
- package/skills/jgengine/SKILL.md +9 -5
- package/skills/jgengine/api.md +205 -101
- package/skills/jgengine/capabilities.md +56 -0
- package/skills/jgengine/recipes/minimal-game.md +3 -0
- package/skills/jgengine-ui/SKILL.md +20 -15
- package/skills/jgengine-ui/api.md +538 -326
- package/skills/jgengine-ui/capabilities.md +37 -0
- package/skills/jgengine-ui/reference-primitives.md +89 -0
- package/skills/jgengine-ui/reference.md +2 -78
- package/skills/jgengine-ui/references/visual-scorecard.md +0 -73
package/dist/hudSkin.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
/** Converts a skin into the CSS variables consumed by shared HUD components. */
|
|
3
|
+
export function hudSkinVars(skin) {
|
|
4
|
+
if (!skin)
|
|
5
|
+
return {};
|
|
6
|
+
const vars = {};
|
|
7
|
+
const material = (prefix, value) => {
|
|
8
|
+
if (!value)
|
|
9
|
+
return;
|
|
10
|
+
vars[`--jg-${prefix}-image`] = `url(${value.image})`;
|
|
11
|
+
vars[`--jg-${prefix}-slice`] = value.slice.join(" ");
|
|
12
|
+
if (value.scale !== undefined)
|
|
13
|
+
vars[`--jg-${prefix}-scale`] = String(value.scale);
|
|
14
|
+
if (value.fill !== undefined)
|
|
15
|
+
vars[`--jg-${prefix}-fill`] = value.fill ? "fill" : "";
|
|
16
|
+
};
|
|
17
|
+
material("frame", skin.frame);
|
|
18
|
+
material("slot", skin.slot);
|
|
19
|
+
if (skin.bar) {
|
|
20
|
+
vars["--jg-bar-skin-fill"] = skin.bar.fill;
|
|
21
|
+
vars["--jg-bar-skin-track"] = skin.bar.track;
|
|
22
|
+
if (skin.bar.cap)
|
|
23
|
+
vars["--jg-bar-skin-cap"] = `url(${skin.bar.cap})`;
|
|
24
|
+
}
|
|
25
|
+
if (skin.font) {
|
|
26
|
+
vars["--jg-skin-font"] = skin.font.family;
|
|
27
|
+
if (skin.font.pixel)
|
|
28
|
+
vars["--jg-skin-font-rendering"] = "pixelated";
|
|
29
|
+
}
|
|
30
|
+
if (skin.cursor)
|
|
31
|
+
vars["--jg-skin-cursor"] = `url(${skin.cursor}), auto`;
|
|
32
|
+
return vars;
|
|
33
|
+
}
|
|
34
|
+
/** Loads a skin font in browsers and reports when it is available. */
|
|
35
|
+
export function useHudFont(skin) {
|
|
36
|
+
const [loaded, setLoaded] = useState(false);
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
if (!skin?.font || typeof FontFace === "undefined") {
|
|
39
|
+
setLoaded(!skin?.font);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const font = new FontFace(skin.font.family, `url(${skin.font.src})`, { weight: String(skin.font.weight ?? 400) });
|
|
43
|
+
let active = true;
|
|
44
|
+
font.load().then((face) => { document.fonts.add(face); if (active)
|
|
45
|
+
setLoaded(true); }).catch(() => { if (active)
|
|
46
|
+
setLoaded(false); });
|
|
47
|
+
return () => { active = false; };
|
|
48
|
+
}, [skin?.font?.family, skin?.font?.src, skin?.font?.weight]);
|
|
49
|
+
return loaded;
|
|
50
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useMemo } from "react";
|
|
3
|
+
import { HealthBar, ShieldBar } from "./bars.js";
|
|
4
|
+
import { HudFrame } from "./hudFrame.js";
|
|
5
|
+
import { hudTheme, hudThemeVars } from "./hudTheme.js";
|
|
6
|
+
import { hudSkinVars } from "./hudSkin.js";
|
|
7
|
+
function painted(color, dark) {
|
|
8
|
+
if (typeof document === "undefined")
|
|
9
|
+
return "";
|
|
10
|
+
const canvas = document.createElement("canvas");
|
|
11
|
+
canvas.width = 32;
|
|
12
|
+
canvas.height = 32;
|
|
13
|
+
const ctx = canvas.getContext("2d");
|
|
14
|
+
if (!ctx)
|
|
15
|
+
return "";
|
|
16
|
+
ctx.fillStyle = dark;
|
|
17
|
+
ctx.fillRect(0, 0, 32, 32);
|
|
18
|
+
ctx.strokeStyle = color;
|
|
19
|
+
ctx.lineWidth = 5;
|
|
20
|
+
ctx.strokeRect(3, 3, 26, 26);
|
|
21
|
+
return canvas.toDataURL("image/png");
|
|
22
|
+
}
|
|
23
|
+
function SkinCell({ title, skin }) {
|
|
24
|
+
const vars = { ...hudThemeVars(hudTheme({ skin })), ...hudSkinVars(skin) };
|
|
25
|
+
return _jsx("div", { style: { ...vars, width: 300 }, children: _jsx(HudFrame, { variation: "themed", title: title, padding: 14, children: _jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 10 }, children: [_jsx(HealthBar, { value: 78, max: 100, label: "HP", width: "100%" }), _jsx(ShieldBar, { value: 42, max: 60, label: "SH", width: "100%" }), _jsx("div", { style: { display: "flex", gap: 6 }, children: ["A", "B", "C", "D", "E"].map((label) => _jsx("div", { style: { width: 40, height: 40, display: "grid", placeItems: "center", color: "var(--jg-text)", border: "var(--jg-slot-border)", borderImage: "var(--jg-slot-image, none) var(--jg-slot-slice, 0) / var(--jg-slot-scale, 1)", background: "var(--jg-slot-bg)", borderRadius: "var(--jg-slot-radius)" }, children: label }, label)) })] }) }) });
|
|
26
|
+
}
|
|
27
|
+
/** Procedural two-skin fixture for visual regression captures. */
|
|
28
|
+
export function HudSkinPreview({ className }) {
|
|
29
|
+
const skins = useMemo(() => [{ title: "painted cartoon", skin: { frame: { image: painted("#ffd166", "#7b3f00"), slice: [8, 8, 8, 8], fill: true }, slot: { image: painted("#66e3ff", "#12354a"), slice: [8, 8, 8, 8] }, bar: { fill: "linear-gradient(90deg,#ff6b6b,#ffd166)", track: "#24152a" } } }, { title: "thin dark overlay", skin: { frame: { image: painted("#8d99ae", "#11151c"), slice: [4, 4, 4, 4] }, slot: { image: painted("#566070", "#090b10"), slice: [4, 4, 4, 4] }, bar: { fill: "#8d99ae", track: "#090b10" } } }], []);
|
|
30
|
+
return _jsx("div", { className: className, "data-hud-skin-preview": true, style: { display: "flex", gap: 18, flexWrap: "wrap", padding: 22, background: "#0e1216", color: "#f5f7fa" }, children: skins.map((item) => _jsx(SkinCell, { ...item }, item.title)) });
|
|
31
|
+
}
|
package/dist/hudTheme.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { type CSSProperties } from "react";
|
|
2
|
+
import { type HudSkin } from "./hudSkin.js";
|
|
3
|
+
export { hudSkinVars, type HudSkin } from "./hudSkin.js";
|
|
2
4
|
/**
|
|
3
5
|
* `HudTheme` (#1034, #1573): one token object that restyles every shared token-driven building
|
|
4
6
|
* block at once — the atomic bars (#1033), frames, slots, the minimap ring, and the chrome the
|
|
@@ -106,6 +108,7 @@ export interface HudTheme {
|
|
|
106
108
|
slot: HudThemeSlot;
|
|
107
109
|
/** Minimap chrome ring color. */
|
|
108
110
|
ring: string;
|
|
111
|
+
skin?: HudSkin;
|
|
109
112
|
}
|
|
110
113
|
/** Emits the CSS custom properties for a theme — spread onto any HUD ancestor to re-skin the subtree. */
|
|
111
114
|
export declare function hudThemeVars(theme: HudTheme): CSSProperties;
|
|
@@ -122,6 +125,7 @@ export interface HudThemeOverrides {
|
|
|
122
125
|
bar?: Partial<HudThemeBar>;
|
|
123
126
|
slot?: Partial<HudThemeSlot>;
|
|
124
127
|
ring?: string;
|
|
128
|
+
skin?: HudSkin;
|
|
125
129
|
}
|
|
126
130
|
/**
|
|
127
131
|
* Builds a full {@link HudTheme} from per-group partials, filling the rest from
|
package/dist/hudTheme.js
CHANGED
|
@@ -1,8 +1,21 @@
|
|
|
1
1
|
import {} from "react";
|
|
2
|
+
import { hudSkinVars } from "./hudSkin.js";
|
|
3
|
+
export { hudSkinVars } from "./hudSkin.js";
|
|
2
4
|
/** Emits the CSS custom properties for a theme — spread onto any HUD ancestor to re-skin the subtree. */
|
|
3
5
|
export function hudThemeVars(theme) {
|
|
4
6
|
const { palette, surface, status, font, rarity, frame, bar, slot } = theme;
|
|
5
7
|
return {
|
|
8
|
+
"--jg-frame-image": "none",
|
|
9
|
+
"--jg-frame-slice": "0",
|
|
10
|
+
"--jg-frame-scale": "1",
|
|
11
|
+
"--jg-frame-fill": "stretch",
|
|
12
|
+
"--jg-slot-image": "none",
|
|
13
|
+
"--jg-slot-slice": "0",
|
|
14
|
+
"--jg-slot-scale": "1",
|
|
15
|
+
"--jg-bar-skin-track": "none",
|
|
16
|
+
"--jg-bar-skin-fill": "none",
|
|
17
|
+
"--jg-skin-cursor": "auto",
|
|
18
|
+
...hudSkinVars(theme.skin),
|
|
6
19
|
// atomic-bar tokens (same names the bars read — #1033)
|
|
7
20
|
"--jg-health": palette.health,
|
|
8
21
|
"--jg-health-low": palette.healthLow,
|
|
@@ -146,6 +159,7 @@ export function hudTheme(overrides) {
|
|
|
146
159
|
bar: { ...defaultHudTheme.bar, ...overrides.bar },
|
|
147
160
|
slot: { ...defaultHudTheme.slot, ...overrides.slot },
|
|
148
161
|
ring: overrides.ring ?? defaultHudTheme.ring,
|
|
162
|
+
skin: overrides.skin,
|
|
149
163
|
};
|
|
150
164
|
}
|
|
151
165
|
const theme = hudTheme;
|
package/dist/index.d.ts
CHANGED
|
@@ -48,6 +48,8 @@ export * from "./panels.js";
|
|
|
48
48
|
export * from "./characterSheet.js";
|
|
49
49
|
export * from "./talentTree.js";
|
|
50
50
|
export * from "./hudTheme.js";
|
|
51
|
+
export * from "./hudSkin.js";
|
|
52
|
+
export * from "./hudSkinPreview.js";
|
|
51
53
|
export * from "./hudThemePreview.js";
|
|
52
54
|
export * from "./hudFrame.js";
|
|
53
55
|
export * from "./keyHint.js";
|
package/dist/index.js
CHANGED
|
@@ -48,6 +48,8 @@ export * from "./panels.js";
|
|
|
48
48
|
export * from "./characterSheet.js";
|
|
49
49
|
export * from "./talentTree.js";
|
|
50
50
|
export * from "./hudTheme.js";
|
|
51
|
+
export * from "./hudSkin.js";
|
|
52
|
+
export * from "./hudSkinPreview.js";
|
|
51
53
|
export * from "./hudThemePreview.js";
|
|
52
54
|
export * from "./hudFrame.js";
|
|
53
55
|
export * from "./keyHint.js";
|
package/dist/previewFixtures.js
CHANGED
|
@@ -2,6 +2,9 @@ import { BarsPreview } from "./barsPreview.js";
|
|
|
2
2
|
import { HudThemePreview } from "./hudThemePreview.js";
|
|
3
3
|
import { IconsPreview } from "./iconsPreview.js";
|
|
4
4
|
import { StreetGeometryPreview } from "./streetGeometryPreview.js";
|
|
5
|
+
import { SpriteClipPreview } from "./spriteClipPreview.js";
|
|
6
|
+
import { TileLayerPreview } from "./tileLayerPreview.js";
|
|
7
|
+
import { HudSkinPreview } from "./hudSkinPreview.js";
|
|
5
8
|
/**
|
|
6
9
|
* Registry of deterministic, engine-level preview fixtures — the *real* exported
|
|
7
10
|
* `@jgengine/react` components (`HudThemePreview`, `BarsPreview`, `IconsPreview`, …) that render
|
|
@@ -35,6 +38,17 @@ export const PREVIEW_FIXTURES = {
|
|
|
35
38
|
description: "Close-up welded road meshes for turns, unequal crossings, and five-way junctions.",
|
|
36
39
|
component: StreetGeometryPreview,
|
|
37
40
|
},
|
|
41
|
+
SpriteClipPreview: {
|
|
42
|
+
name: "SpriteClipPreview",
|
|
43
|
+
description: "A procedurally drawn two-frame atlas driven by the core sprite clip player.",
|
|
44
|
+
component: SpriteClipPreview,
|
|
45
|
+
},
|
|
46
|
+
TileLayerPreview: {
|
|
47
|
+
name: "TileLayerPreview",
|
|
48
|
+
description: "A procedural 3x3 atlas-backed tile layer preview.",
|
|
49
|
+
component: TileLayerPreview,
|
|
50
|
+
},
|
|
51
|
+
HudSkinPreview: { name: "HudSkinPreview", description: "Two procedural HUD skins applied to frames, bars, and inventory slots.", component: HudSkinPreview },
|
|
38
52
|
};
|
|
39
53
|
/** Sorted list of registered fixture names, for discovery/listing. */
|
|
40
54
|
export function previewFixtureNames() {
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useRef } from "react";
|
|
3
|
+
import { createSpriteClipPlayer } from "@jgengine/core/render/sprite2d";
|
|
4
|
+
/** Deterministic canvas-backed sprite clip fixture for renderer evidence.
|
|
5
|
+
* @internal
|
|
6
|
+
*/
|
|
7
|
+
export function SpriteClipPreview({ className }) {
|
|
8
|
+
const canvasRef = useRef(null);
|
|
9
|
+
useEffect(() => {
|
|
10
|
+
const canvas = canvasRef.current;
|
|
11
|
+
if (canvas === null)
|
|
12
|
+
return;
|
|
13
|
+
canvas.width = 640;
|
|
14
|
+
canvas.height = 360;
|
|
15
|
+
const context = canvas.getContext("2d");
|
|
16
|
+
if (context === null)
|
|
17
|
+
return;
|
|
18
|
+
const atlasCanvas = document.createElement("canvas");
|
|
19
|
+
atlasCanvas.width = 64;
|
|
20
|
+
atlasCanvas.height = 32;
|
|
21
|
+
const atlasContext = atlasCanvas.getContext("2d");
|
|
22
|
+
if (atlasContext === null)
|
|
23
|
+
return;
|
|
24
|
+
for (const x of [0, 32]) {
|
|
25
|
+
atlasContext.fillStyle = "#172033";
|
|
26
|
+
atlasContext.fillRect(x, 0, 32, 32);
|
|
27
|
+
atlasContext.fillStyle = "#f4c95d";
|
|
28
|
+
atlasContext.beginPath();
|
|
29
|
+
atlasContext.arc(x + 16, 14, 9, 0, Math.PI * 2);
|
|
30
|
+
atlasContext.fill();
|
|
31
|
+
atlasContext.fillStyle = "#e76f51";
|
|
32
|
+
atlasContext.fillRect(x + 8, x === 0 ? 22 : 20, 16, 5);
|
|
33
|
+
}
|
|
34
|
+
const atlas = {
|
|
35
|
+
image: "fixture",
|
|
36
|
+
size: [64, 32],
|
|
37
|
+
frames: { idle0: { x: 0, y: 0, w: 32, h: 32 }, idle1: { x: 32, y: 0, w: 32, h: 32 } },
|
|
38
|
+
animations: { idle: { frames: ["idle0", "idle1"], fps: 2 } },
|
|
39
|
+
};
|
|
40
|
+
const player = createSpriteClipPlayer(atlas);
|
|
41
|
+
let previous = performance.now();
|
|
42
|
+
let animationFrame = 0;
|
|
43
|
+
const draw = (now) => {
|
|
44
|
+
player.advance(Math.min((now - previous) / 1000, 0.1));
|
|
45
|
+
previous = now;
|
|
46
|
+
const current = player.frame() ?? atlas.frames.idle0;
|
|
47
|
+
context.fillStyle = "#0e1216";
|
|
48
|
+
context.fillRect(0, 0, canvas.width, canvas.height);
|
|
49
|
+
context.fillStyle = "#e8edf2";
|
|
50
|
+
context.font = "700 18px system-ui";
|
|
51
|
+
context.fillText("SPRITE CLIP PREVIEW", 32, 42);
|
|
52
|
+
context.font = "14px system-ui";
|
|
53
|
+
context.fillStyle = "#9aa7b5";
|
|
54
|
+
context.fillText(`atlas frame ${player.snapshot().frameIndex + 1} / 2 · 2 fps`, 32, 68);
|
|
55
|
+
context.imageSmoothingEnabled = false;
|
|
56
|
+
context.drawImage(atlasCanvas, current.x, current.y, current.w, current.h, 256, 112, 128, 128);
|
|
57
|
+
context.strokeStyle = "#f4c95d";
|
|
58
|
+
context.strokeRect(256, 112, 128, 128);
|
|
59
|
+
context.fillStyle = "#9aa7b5";
|
|
60
|
+
context.font = "12px system-ui";
|
|
61
|
+
context.fillText("procedural atlas frames", 32, 300);
|
|
62
|
+
context.drawImage(atlasCanvas, 0, 0, 32, 32, 32, 312, 64, 64);
|
|
63
|
+
context.drawImage(atlasCanvas, 32, 0, 32, 32, 112, 312, 64, 64);
|
|
64
|
+
context.strokeStyle = "#f4c95d";
|
|
65
|
+
context.strokeRect(32 + player.snapshot().frameIndex * 80, 312, 64, 64);
|
|
66
|
+
animationFrame = requestAnimationFrame(draw);
|
|
67
|
+
};
|
|
68
|
+
animationFrame = requestAnimationFrame(draw);
|
|
69
|
+
return () => cancelAnimationFrame(animationFrame);
|
|
70
|
+
}, []);
|
|
71
|
+
return _jsx("canvas", { ref: canvasRef, className: className, style: { imageRendering: "pixelated", width: "100%", height: "100%" } });
|
|
72
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { type CSSProperties, type ReactNode } from "react";
|
|
2
|
+
import { type ChatMessage } from "@jgengine/core/game/chat";
|
|
3
|
+
import type { ChatSendOutcome } from "@jgengine/core/multiplayer/chatContract";
|
|
4
|
+
type StatefulClass = string | ((expanded: boolean) => string);
|
|
5
|
+
/** Message-store inputs and caller-owned styling for standalone chat. */
|
|
6
|
+
export interface StandaloneChatPanelProps<T extends ChatMessage = ChatMessage> {
|
|
7
|
+
messages: readonly T[];
|
|
8
|
+
userId: string;
|
|
9
|
+
onSend: (body: string) => Promise<ChatSendOutcome | void> | ChatSendOutcome | void;
|
|
10
|
+
className?: StatefulClass;
|
|
11
|
+
logClassName?: StatefulClass;
|
|
12
|
+
inputClassName?: StatefulClass;
|
|
13
|
+
inputFieldClassName?: StatefulClass;
|
|
14
|
+
messageClassName?: string;
|
|
15
|
+
ownMessageClassName?: string;
|
|
16
|
+
style?: CSSProperties;
|
|
17
|
+
defaultExpanded?: boolean;
|
|
18
|
+
collapsedLimit?: number;
|
|
19
|
+
limit?: number;
|
|
20
|
+
maxLength?: number;
|
|
21
|
+
hotkeysEnabled?: boolean;
|
|
22
|
+
onFocus?: () => void;
|
|
23
|
+
placeholder?: string;
|
|
24
|
+
collapsedPlaceholder?: string;
|
|
25
|
+
emptyLabel?: ReactNode;
|
|
26
|
+
renderMessage?: (message: T, state: {
|
|
27
|
+
own: boolean;
|
|
28
|
+
expanded: boolean;
|
|
29
|
+
index: number;
|
|
30
|
+
count: number;
|
|
31
|
+
}) => ReactNode;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Headless chat interaction for external message stores. Prefer the ChatPanel entrypoint.
|
|
35
|
+
* @capability standalone-chat render persisted chat with focus controls and visible send failures
|
|
36
|
+
*/
|
|
37
|
+
export declare function StandaloneChatPanel<T extends ChatMessage>({ messages, userId, onSend, className, logClassName, inputClassName, inputFieldClassName, messageClassName, ownMessageClassName, style, defaultExpanded, collapsedLimit, limit, maxLength, hotkeysEnabled, onFocus, placeholder, collapsedPlaceholder, emptyLabel, renderMessage }: StandaloneChatPanelProps<T>): import("react").JSX.Element;
|
|
38
|
+
export {};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useRef, useState } from "react";
|
|
3
|
+
import { DEFAULT_CHAT_BODY_LENGTH } from "@jgengine/core/game/chat";
|
|
4
|
+
const classFor = (value, expanded) => typeof value === "function" ? value(expanded) : value;
|
|
5
|
+
/**
|
|
6
|
+
* Headless chat interaction for external message stores. Prefer the ChatPanel entrypoint.
|
|
7
|
+
* @capability standalone-chat render persisted chat with focus controls and visible send failures
|
|
8
|
+
*/
|
|
9
|
+
export function StandaloneChatPanel({ messages, userId, onSend, className, logClassName, inputClassName, inputFieldClassName, messageClassName, ownMessageClassName, style, defaultExpanded = false, collapsedLimit = 3, limit = 50, maxLength = DEFAULT_CHAT_BODY_LENGTH, hotkeysEnabled = true, onFocus, placeholder = "Message the world...", collapsedPlaceholder = "Enter to chat", emptyLabel = "Press Enter to chat", renderMessage }) {
|
|
10
|
+
const [expanded, setExpanded] = useState(defaultExpanded);
|
|
11
|
+
const [draft, setDraft] = useState("");
|
|
12
|
+
const [failure, setFailure] = useState(null);
|
|
13
|
+
const [sending, setSending] = useState(false);
|
|
14
|
+
const inputRef = useRef(null);
|
|
15
|
+
const logRef = useRef(null);
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
if (!hotkeysEnabled)
|
|
18
|
+
return;
|
|
19
|
+
const handleKey = (event) => {
|
|
20
|
+
if (event.defaultPrevented || event.isComposing || event.key !== "Enter")
|
|
21
|
+
return;
|
|
22
|
+
if (event.target?.closest?.("input, textarea, select, button, [contenteditable]:not([contenteditable=false])"))
|
|
23
|
+
return;
|
|
24
|
+
event.preventDefault();
|
|
25
|
+
inputRef.current?.focus({ preventScroll: true });
|
|
26
|
+
};
|
|
27
|
+
window.addEventListener("keydown", handleKey);
|
|
28
|
+
return () => window.removeEventListener("keydown", handleKey);
|
|
29
|
+
}, [hotkeysEnabled]);
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
if (expanded && logRef.current !== null)
|
|
32
|
+
logRef.current.scrollTop = logRef.current.scrollHeight;
|
|
33
|
+
}, [expanded, messages.length]);
|
|
34
|
+
const visible = messages.slice(-Math.max(1, expanded ? limit : collapsedLimit));
|
|
35
|
+
return _jsxs("section", { "data-chat-panel": true, "data-expanded": expanded, className: classFor(className, expanded), style: style, children: [_jsx("div", { role: "log", "aria-live": "polite", ref: logRef, className: classFor(logClassName, expanded), children: visible.length === 0 ? emptyLabel : visible.map((message, index) => {
|
|
36
|
+
const own = message.fromUserId === userId;
|
|
37
|
+
return _jsx("div", { "data-own-message": own || undefined, className: [messageClassName, own ? ownMessageClassName : undefined].filter(Boolean).join(" "), children: renderMessage?.(message, { own, expanded, index, count: visible.length }) ?? _jsxs(_Fragment, { children: [message.fromUserId, ": ", message.body] }) }, message.id);
|
|
38
|
+
}) }), _jsx("form", { className: classFor(inputClassName, expanded), onSubmit: (event) => {
|
|
39
|
+
event.preventDefault();
|
|
40
|
+
if (sending || draft.trim().length === 0)
|
|
41
|
+
return;
|
|
42
|
+
const body = draft;
|
|
43
|
+
setSending(true);
|
|
44
|
+
setFailure(null);
|
|
45
|
+
void Promise.resolve().then(() => onSend(body)).then((outcome) => {
|
|
46
|
+
if (outcome && !outcome.ok)
|
|
47
|
+
setFailure(outcome.reason ?? "Could not send message");
|
|
48
|
+
else
|
|
49
|
+
setDraft((current) => current === body ? "" : current);
|
|
50
|
+
}).catch((error) => setFailure(error instanceof Error ? error.message : "Could not send message")).finally(() => setSending(false));
|
|
51
|
+
}, children: _jsx("input", { ref: inputRef, "aria-label": "Chat message", "aria-busy": sending, className: classFor(inputFieldClassName, expanded), value: draft, maxLength: maxLength, placeholder: expanded ? placeholder : collapsedPlaceholder, onChange: (event) => setDraft(event.target.value), onFocus: () => { onFocus?.(); setExpanded(true); }, onBlur: () => setExpanded(false), onKeyDown: (event) => { if (event.key === "Escape") {
|
|
52
|
+
event.currentTarget.blur();
|
|
53
|
+
event.stopPropagation();
|
|
54
|
+
} } }) }), failure !== null && _jsx("p", { role: "alert", children: failure })] });
|
|
55
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useRef } from "react";
|
|
3
|
+
/** Deterministic canvas-backed tile-layer fixture for textured tile presentation evidence. */
|
|
4
|
+
export function TileLayerPreview({ className }) {
|
|
5
|
+
const canvasRef = useRef(null);
|
|
6
|
+
useEffect(() => {
|
|
7
|
+
const canvas = canvasRef.current;
|
|
8
|
+
const context = canvas?.getContext("2d");
|
|
9
|
+
if (canvas === null || context == null)
|
|
10
|
+
return;
|
|
11
|
+
canvas.width = 640;
|
|
12
|
+
canvas.height = 360;
|
|
13
|
+
context.fillStyle = "#0e1216";
|
|
14
|
+
context.fillRect(0, 0, canvas.width, canvas.height);
|
|
15
|
+
context.fillStyle = "#e8edf2";
|
|
16
|
+
context.font = "700 18px system-ui";
|
|
17
|
+
context.fillText("TEXTURED TILE LAYER PREVIEW", 32, 42);
|
|
18
|
+
context.font = "14px system-ui";
|
|
19
|
+
context.fillStyle = "#9aa7b5";
|
|
20
|
+
context.fillText("3 × 3 procedural atlas map · one layer", 32, 68);
|
|
21
|
+
const colors = { a: "#355070", b: "#6b705c", c: "#3a86a8" };
|
|
22
|
+
const map = ["aba", "bcb", "aba"];
|
|
23
|
+
const size = 72;
|
|
24
|
+
for (let row = 0; row < map.length; row += 1) {
|
|
25
|
+
for (let column = 0; column < map[row].length; column += 1) {
|
|
26
|
+
context.fillStyle = colors[map[row][column]];
|
|
27
|
+
context.fillRect(212 + column * size, 104 + row * size, size - 2, size - 2);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
context.strokeStyle = "#f4c95d";
|
|
31
|
+
context.strokeRect(212, 104, size * 3 - 2, size * 3 - 2);
|
|
32
|
+
context.fillStyle = "#9aa7b5";
|
|
33
|
+
context.font = "12px system-ui";
|
|
34
|
+
context.fillText("procedural atlas tiles · floor / wall / water", 32, 344);
|
|
35
|
+
}, []);
|
|
36
|
+
return _jsx("canvas", { ref: canvasRef, className: className, style: { imageRendering: "pixelated", width: "100%", height: "100%" } });
|
|
37
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type GameRuntimeTransport } from "@jgengine/core/runtime/transport";
|
|
2
|
+
/**
|
|
3
|
+
* Joins a host and exposes a retryable blocking state until membership is confirmed.
|
|
4
|
+
* @capability server-session join a multiplayer host with status, retry, and teardown
|
|
5
|
+
*/
|
|
6
|
+
export declare function useServerSession(transport: GameRuntimeTransport, gameId: string, preferredServerId?: string): {
|
|
7
|
+
serverId: string | null;
|
|
8
|
+
status: "joining" | "joined" | "failed";
|
|
9
|
+
failureReason: string | null;
|
|
10
|
+
retry: () => void;
|
|
11
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from "react";
|
|
2
|
+
import { createTransportSessionId } from "@jgengine/core/runtime/transport";
|
|
3
|
+
/**
|
|
4
|
+
* Joins a host and exposes a retryable blocking state until membership is confirmed.
|
|
5
|
+
* @capability server-session join a multiplayer host with status, retry, and teardown
|
|
6
|
+
*/
|
|
7
|
+
export function useServerSession(transport, gameId, preferredServerId) {
|
|
8
|
+
const [attempt, setAttempt] = useState(0);
|
|
9
|
+
const [session, setSession] = useState({ serverId: null, status: "joining", failureReason: null });
|
|
10
|
+
const retry = useCallback(() => setAttempt((value) => value + 1), []);
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
let disposed = false;
|
|
13
|
+
let serverId = null;
|
|
14
|
+
const sessionId = createTransportSessionId();
|
|
15
|
+
setSession({ serverId: null, status: "joining", failureReason: null });
|
|
16
|
+
void transport.joinServer({ gameId, serverId: preferredServerId, sessionId }).then((result) => {
|
|
17
|
+
if (!result.ok) {
|
|
18
|
+
if (!disposed)
|
|
19
|
+
setSession({ serverId: null, status: "failed", failureReason: result.reason });
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
if (disposed) {
|
|
23
|
+
void transport.leaveServer({ serverId: result.serverId, sessionId }).catch(() => undefined);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
serverId = result.serverId;
|
|
27
|
+
setSession({ serverId, status: "joined", failureReason: null });
|
|
28
|
+
}).catch((error) => {
|
|
29
|
+
if (!disposed)
|
|
30
|
+
setSession({ serverId: null, status: "failed", failureReason: error instanceof Error ? error.message : "Connection failed" });
|
|
31
|
+
});
|
|
32
|
+
return () => {
|
|
33
|
+
disposed = true;
|
|
34
|
+
if (serverId !== null)
|
|
35
|
+
void transport.leaveServer({ serverId, sessionId }).catch(() => undefined);
|
|
36
|
+
};
|
|
37
|
+
}, [transport, gameId, preferredServerId, attempt]);
|
|
38
|
+
return { ...session, retry };
|
|
39
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jgengine/react",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
4
4
|
"description": "React UI layer for JGengine: GameProvider, hooks, and headless primitives over @jgengine/core.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"test": "bun test src"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@jgengine/core": "^0.
|
|
34
|
+
"@jgengine/core": "^0.19.0"
|
|
35
35
|
},
|
|
36
36
|
"peerDependencies": {
|
|
37
37
|
"react": "^19.0.0",
|
|
@@ -143,7 +143,7 @@ When sufficient, return a concise orientation: objective, progress, decisions, c
|
|
|
143
143
|
|
|
144
144
|
## JGengine notes
|
|
145
145
|
|
|
146
|
-
- Multi-session engine work
|
|
146
|
+
- Multi-session engine work spans GitHub issues and PRs — point at those; do not paste full plan bodies.
|
|
147
147
|
- Prefer `bun run agent:bootstrap --check` status, branch names, and PR links as state anchors.
|
|
148
148
|
- Local parallelism uses `.claude/worktrees/`; never invent `C:\tmp\…` Codex-shared trees.
|
|
149
|
-
-
|
|
149
|
+
- Durable process changes belong in skills, not only the handoff.
|
package/skills/jgengine/SKILL.md
CHANGED
|
@@ -5,10 +5,14 @@ description: Route game work to the smallest useful JGengine guidance set.
|
|
|
5
5
|
|
|
6
6
|
# JGengine intake and router
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
This skill decides what to load; it is not an engine manual. Package map, commands, layering, and license live in the JGengine README (repo-root [`README.md`](../../../README.md) in the monorepo, [jgengine.com](https://jgengine.com) from a created project); open it when you need one of those facts.
|
|
9
|
+
|
|
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/`.
|
|
9
11
|
|
|
10
12
|
## 1. Establish the target
|
|
11
13
|
|
|
14
|
+
For greenfield builds, write `src/art-direction.md` before any code.
|
|
15
|
+
|
|
12
16
|
Treat the pitch as a unique composition of needs, not a genre to fill in — never reach for a genre kit, preset, archetype, or class template ("default sports car", "default RPG", "default FPS") in the SDK or skills (see [AGENTS.md](../../../AGENTS.md)). Use `game-design` to turn the pitch into a testable player promise, verbs, loops, progression, failure, and completion scenario. Use `level-design` when play depends on authored spaces, routes, encounters, exploration, or spatial pacing. Capture only decisions that change architecture:
|
|
13
17
|
|
|
14
18
|
- player point of view and controls
|
|
@@ -18,7 +22,7 @@ Treat the pitch as a unique composition of needs, not a genre to fill in — nev
|
|
|
18
22
|
- HUD/menu states and **custom** UI art direction (compose shipped building blocks + HudTheme; no unskinned stock face)
|
|
19
23
|
- one observable completion scenario
|
|
20
24
|
|
|
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/*`.
|
|
25
|
+
Pick the dimension first: `create` defaults to a 3D world (terrain, sky, player model, editor scene); a grid, card, tile, or other board game with no 3D scene takes `create --2d` (a `board` world drawn by GameUI over `@jgengine/core/puzzle/cellGrid`). 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
26
|
|
|
23
27
|
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
28
|
|
|
@@ -44,9 +48,9 @@ World content routes editor-first: any request that adds, moves, restyles, or re
|
|
|
44
48
|
|
|
45
49
|
Within each selected domain:
|
|
46
50
|
|
|
47
|
-
0. Fastest start for a common composition: `npx jgengine recipe <name>` prints a vetted, SDK-typechecked snippet (imports + wired code)
|
|
48
|
-
1. Search
|
|
49
|
-
2. Open `api.md` only when exact signatures
|
|
51
|
+
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.
|
|
53
|
+
2. Open `api.md` (monorepo) or the package `.d.ts` only when exact signatures are needed; grep it, never read it whole.
|
|
50
54
|
3. Open the linked reference only for a deeper recipe or trap.
|
|
51
55
|
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.
|
|
52
56
|
|