@threenative/ui 0.2.0 → 0.3.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/LICENSE +21 -0
- package/README.md +65 -0
- package/dist/index.d.ts +23 -1
- package/dist/index.js +79 -46
- package/package.json +7 -5
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 João Paulo Furtado
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# @threenative/ui
|
|
2
|
+
|
|
3
|
+
## What it is
|
|
4
|
+
|
|
5
|
+
`@threenative/ui` provides React bindings for a ThreeNative game: `GameCanvas` mounts the game
|
|
6
|
+
surface, `useGameState` reads its throttled state store, and `DebugOverlay` exposes development
|
|
7
|
+
snapshots. It is not a scene-graph or visual-design layer; React renders HUDs, menus, and overlays,
|
|
8
|
+
while the game owns gameplay and the Three.js scene.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
Install the package with its peer dependencies:
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
pnpm add @threenative/ui @threenative/core react react-dom three
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Example
|
|
19
|
+
|
|
20
|
+
In a generated `starter` project, `src/ui/App.tsx` mounts the real game and its HUD:
|
|
21
|
+
|
|
22
|
+
```tsx
|
|
23
|
+
import type { IGame } from "@threenative/core";
|
|
24
|
+
import type { IPhysicsContext } from "@threenative/physics";
|
|
25
|
+
import { DebugOverlay, GameCanvas } from "@threenative/ui";
|
|
26
|
+
import type { GameState } from "../state.js";
|
|
27
|
+
import { Hud } from "./Hud.js";
|
|
28
|
+
import { Menu } from "./Menu.js";
|
|
29
|
+
|
|
30
|
+
export function App({ game }: { game: IGame<GameState, IPhysicsContext> }) {
|
|
31
|
+
return (
|
|
32
|
+
<main className="relative h-screen w-screen overflow-hidden bg-ink">
|
|
33
|
+
<GameCanvas className="absolute inset-0" game={game} />
|
|
34
|
+
<Hud game={game} />
|
|
35
|
+
<Menu game={game} />
|
|
36
|
+
<DebugOverlay />
|
|
37
|
+
</main>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Styling the debug overlay
|
|
43
|
+
|
|
44
|
+
`DebugOverlay` ships no presentation — it renders one `<aside data-threenative-debug-overlay="true">`
|
|
45
|
+
and leaves every pixel to you. Generated projects already carry a rule for it in their stylesheet;
|
|
46
|
+
a project that imports the package directly must write its own, or the overlay paints behind an
|
|
47
|
+
absolutely positioned `GameCanvas` and its diagnostics are invisible:
|
|
48
|
+
|
|
49
|
+
```css
|
|
50
|
+
[data-threenative-debug-overlay="true"] {
|
|
51
|
+
position: fixed;
|
|
52
|
+
top: 1rem;
|
|
53
|
+
right: 1rem;
|
|
54
|
+
z-index: 50;
|
|
55
|
+
pointer-events: none;
|
|
56
|
+
background: rgb(2 6 23 / 90%);
|
|
57
|
+
color: #cffafe;
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Links
|
|
62
|
+
|
|
63
|
+
- [Repository](https://github.com/ThreeNativeHQ/threenative)
|
|
64
|
+
- [MIT License](https://github.com/ThreeNativeHQ/threenative/blob/main/LICENSE)
|
|
65
|
+
- Start a project with `pnpm create threenative my-game`.
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
2
2
|
import { IGame } from '@threenative/core';
|
|
3
|
+
import { ReactNode } from 'react';
|
|
3
4
|
|
|
4
5
|
interface IGameCanvasProps<TState extends Record<string, unknown> = Record<string, unknown>, TPhysics = undefined> {
|
|
5
6
|
className?: string;
|
|
@@ -13,4 +14,25 @@ type GameSelector<TState, TSelected> = (state: TState) => TSelected;
|
|
|
13
14
|
declare function useGameState<TState extends Record<string, unknown>, TPhysics, TSelected>(game: IGame<TState, TPhysics>, selector: GameSelector<TState, TSelected>): TSelected;
|
|
14
15
|
declare function useGameState<TState extends Record<string, unknown>, TPhysics>(game: IGame<TState, TPhysics>): TState;
|
|
15
16
|
|
|
16
|
-
|
|
17
|
+
declare function UiLayer({ children }: {
|
|
18
|
+
children: ReactNode;
|
|
19
|
+
}): react_jsx_runtime.JSX.Element | null;
|
|
20
|
+
/**
|
|
21
|
+
* Read a slice of the game's published state.
|
|
22
|
+
*
|
|
23
|
+
* The UI holds a mirror, never the game object: on every native target the game is in another
|
|
24
|
+
* realm, and a HUD written against a live store would work on web and read nothing on a phone.
|
|
25
|
+
* The mirror is fed at the store's published cadence, which is not the game loop.
|
|
26
|
+
*/
|
|
27
|
+
declare function useUiState<TState extends object>(): TState | undefined;
|
|
28
|
+
declare function useUiState<TState extends object, TSelected>(selector: (state: TState) => TSelected): TSelected | undefined;
|
|
29
|
+
/**
|
|
30
|
+
* Send an intent to the game — `restart`, `pause`, whatever the game named.
|
|
31
|
+
*
|
|
32
|
+
* Intents are one-way and the game may ignore one; that is not an error. Anything the UI needs
|
|
33
|
+
* back arrives as published state, which keeps a single source of truth on the side that owns
|
|
34
|
+
* the simulation.
|
|
35
|
+
*/
|
|
36
|
+
declare function useUiIntent(): (intent: string, payload?: unknown) => void;
|
|
37
|
+
|
|
38
|
+
export { DebugOverlay, GameCanvas, UiLayer, useGameState, useUiIntent, useUiState };
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createContext, useRef, useState, useEffect, useSyncExternalStore, useMemo, useContext } from 'react';
|
|
2
2
|
import { jsx, jsxs } from 'react/jsx-runtime';
|
|
3
|
+
import { connectUiBridge, subscribeUiState, publishHitRegions, sendUiIntent, UI_READY_INTENT } from '@threenative/core/ui-layer';
|
|
3
4
|
|
|
4
5
|
// src/GameCanvas.tsx
|
|
5
6
|
function GameCanvas({
|
|
@@ -7,8 +8,10 @@ function GameCanvas({
|
|
|
7
8
|
game
|
|
8
9
|
}) {
|
|
9
10
|
const host = useRef(null);
|
|
11
|
+
const [failure, setFailure] = useState();
|
|
10
12
|
useEffect(() => {
|
|
11
13
|
let cancelled = false;
|
|
14
|
+
setFailure(void 0);
|
|
12
15
|
void game.start().then(() => {
|
|
13
16
|
const canvas = game.ctx?.renderer.domElement;
|
|
14
17
|
if (!cancelled && canvas !== void 0 && host.current !== null) {
|
|
@@ -19,7 +22,8 @@ function GameCanvas({
|
|
|
19
22
|
host.current.replaceChildren(canvas);
|
|
20
23
|
}
|
|
21
24
|
}).catch((error) => {
|
|
22
|
-
if (
|
|
25
|
+
if (cancelled) return;
|
|
26
|
+
setFailure(error instanceof Error ? error.message : String(error));
|
|
23
27
|
});
|
|
24
28
|
return () => {
|
|
25
29
|
cancelled = true;
|
|
@@ -32,31 +36,12 @@ function GameCanvas({
|
|
|
32
36
|
ref: host,
|
|
33
37
|
className,
|
|
34
38
|
"data-threenative-canvas": "true",
|
|
35
|
-
style: { height: "100%", width: "100%" }
|
|
39
|
+
style: { height: "100%", width: "100%" },
|
|
40
|
+
children: failure === void 0 ? null : /* @__PURE__ */ jsx("div", { "data-threenative-canvas-error": "true", role: "alert", children: failure })
|
|
36
41
|
}
|
|
37
42
|
);
|
|
38
43
|
}
|
|
39
44
|
var isDev = import.meta.env?.DEV === true;
|
|
40
|
-
var panelStyle = {
|
|
41
|
-
background: "rgb(2 6 23 / 90%)",
|
|
42
|
-
boxShadow: "0 10px 15px -3px rgb(0 0 0 / 30%)",
|
|
43
|
-
color: "#cffafe",
|
|
44
|
-
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
|
|
45
|
-
fontSize: "12px",
|
|
46
|
-
lineHeight: 1.5,
|
|
47
|
-
maxWidth: "24rem",
|
|
48
|
-
padding: "12px",
|
|
49
|
-
pointerEvents: "none",
|
|
50
|
-
position: "fixed",
|
|
51
|
-
right: "16px",
|
|
52
|
-
top: "16px",
|
|
53
|
-
zIndex: 50
|
|
54
|
-
};
|
|
55
|
-
var tableStyle = { tableLayout: "fixed", width: "100%" };
|
|
56
|
-
var headerCellStyle = { paddingRight: "16px", textAlign: "left" };
|
|
57
|
-
var cellStyle = { paddingRight: "16px" };
|
|
58
|
-
var lastCellStyle = { textAlign: "left" };
|
|
59
|
-
var valueCellStyle = { overflowWrap: "anywhere", wordBreak: "break-all" };
|
|
60
45
|
function readSnapshot() {
|
|
61
46
|
const snapshot = globalThis.window?.__THREENATIVE__?.snapshot;
|
|
62
47
|
return typeof snapshot === "function" ? snapshot() : {};
|
|
@@ -84,33 +69,81 @@ function DebugOverlay() {
|
|
|
84
69
|
const rows = Object.entries(snapshot).flatMap(
|
|
85
70
|
([entity, fields]) => Object.entries(fields).map(([key, value]) => ({ entity, key, value }))
|
|
86
71
|
);
|
|
87
|
-
return /* @__PURE__ */ jsx(
|
|
88
|
-
"
|
|
89
|
-
|
|
90
|
-
"
|
|
91
|
-
"
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
/* @__PURE__ */ jsx("tbody", { children: rows.map(({ entity, key, value }) => /* @__PURE__ */ jsxs("tr", { children: [
|
|
100
|
-
/* @__PURE__ */ jsx("td", { style: cellStyle, children: entity }),
|
|
101
|
-
/* @__PURE__ */ jsx("td", { style: cellStyle, children: key }),
|
|
102
|
-
/* @__PURE__ */ jsx("td", { style: valueCellStyle, children: displayValue(value) })
|
|
103
|
-
] }, `${entity}.${key}`)) })
|
|
104
|
-
] })
|
|
105
|
-
}
|
|
106
|
-
);
|
|
72
|
+
return /* @__PURE__ */ jsx("aside", { "aria-label": "ThreeNative entity debug overlay", "data-threenative-debug-overlay": "true", children: /* @__PURE__ */ jsxs("table", { children: [
|
|
73
|
+
/* @__PURE__ */ jsx("thead", { children: /* @__PURE__ */ jsxs("tr", { children: [
|
|
74
|
+
/* @__PURE__ */ jsx("th", { children: "entity" }),
|
|
75
|
+
/* @__PURE__ */ jsx("th", { children: "key" }),
|
|
76
|
+
/* @__PURE__ */ jsx("th", { children: "value" })
|
|
77
|
+
] }) }),
|
|
78
|
+
/* @__PURE__ */ jsx("tbody", { children: rows.map(({ entity, key, value }) => /* @__PURE__ */ jsxs("tr", { children: [
|
|
79
|
+
/* @__PURE__ */ jsx("td", { children: entity }),
|
|
80
|
+
/* @__PURE__ */ jsx("td", { children: key }),
|
|
81
|
+
/* @__PURE__ */ jsx("td", { children: displayValue(value) })
|
|
82
|
+
] }, `${entity}.${key}`)) })
|
|
83
|
+
] }) });
|
|
107
84
|
}
|
|
108
85
|
function useGameState(game, selector = (state) => state) {
|
|
109
86
|
return useSyncExternalStore(
|
|
110
87
|
(onStoreChange) => game.state.subscribe(onStoreChange),
|
|
111
|
-
() => selector(game.state.
|
|
112
|
-
() => selector(game.state.
|
|
88
|
+
() => selector(game.state.getPublishedState()),
|
|
89
|
+
() => selector(game.state.getPublishedState())
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
var UiLayerContext = createContext(void 0);
|
|
93
|
+
function useUiLayer(name) {
|
|
94
|
+
const value = useContext(UiLayerContext);
|
|
95
|
+
if (value === void 0) {
|
|
96
|
+
throw new Error(`TN_UI_LAYER_MISSING: ${name} must be used inside <UiLayer>.`);
|
|
97
|
+
}
|
|
98
|
+
return value;
|
|
99
|
+
}
|
|
100
|
+
function UiLayer({ children }) {
|
|
101
|
+
const [value, setValue] = useState(void 0);
|
|
102
|
+
const registryRef = useRef(void 0);
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
const bridge = connectUiBridge({ end: "ui" });
|
|
105
|
+
const mirror = subscribeUiState(bridge);
|
|
106
|
+
setValue({ bridge, mirror });
|
|
107
|
+
return () => {
|
|
108
|
+
registryRef.current?.stop();
|
|
109
|
+
registryRef.current = void 0;
|
|
110
|
+
mirror.stop();
|
|
111
|
+
bridge.close();
|
|
112
|
+
setValue(void 0);
|
|
113
|
+
};
|
|
114
|
+
}, []);
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
if (value === void 0) return;
|
|
117
|
+
const registry = publishHitRegions({ bridge: value.bridge });
|
|
118
|
+
registryRef.current = registry;
|
|
119
|
+
sendUiIntent(value.bridge, UI_READY_INTENT, registry.regions().length);
|
|
120
|
+
return () => {
|
|
121
|
+
registryRef.current = void 0;
|
|
122
|
+
};
|
|
123
|
+
}, [value]);
|
|
124
|
+
if (value === void 0) return null;
|
|
125
|
+
return /* @__PURE__ */ jsx(UiLayerContext.Provider, { value, children });
|
|
126
|
+
}
|
|
127
|
+
function useUiState(selector) {
|
|
128
|
+
const { mirror } = useUiLayer("useUiState");
|
|
129
|
+
const selectorRef = useRef(selector);
|
|
130
|
+
selectorRef.current = selector;
|
|
131
|
+
return useSyncExternalStore(
|
|
132
|
+
(onChange) => mirror.subscribe(onChange),
|
|
133
|
+
() => {
|
|
134
|
+
const state = mirror.get();
|
|
135
|
+
if (state === void 0) return void 0;
|
|
136
|
+
return selectorRef.current === void 0 ? state : selectorRef.current(state);
|
|
137
|
+
},
|
|
138
|
+
() => void 0
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
function useUiIntent() {
|
|
142
|
+
const { bridge } = useUiLayer("useUiIntent");
|
|
143
|
+
return useMemo(
|
|
144
|
+
() => (intent, payload) => sendUiIntent(bridge, intent, payload),
|
|
145
|
+
[bridge]
|
|
113
146
|
);
|
|
114
147
|
}
|
|
115
148
|
|
|
116
|
-
export { DebugOverlay, GameCanvas, useGameState };
|
|
149
|
+
export { DebugOverlay, GameCanvas, UiLayer, useGameState, useUiIntent, useUiState };
|
package/package.json
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@threenative/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"license": "MIT",
|
|
4
5
|
"description": "React bindings for mounting a ThreeNative game and reading throttled state.",
|
|
5
6
|
"type": "module",
|
|
6
7
|
"files": [
|
|
7
|
-
"dist"
|
|
8
|
+
"dist",
|
|
9
|
+
"README.md"
|
|
8
10
|
],
|
|
9
11
|
"main": "./dist/index.js",
|
|
10
12
|
"module": "./dist/index.js",
|
|
@@ -21,7 +23,7 @@
|
|
|
21
23
|
},
|
|
22
24
|
"sideEffects": false,
|
|
23
25
|
"peerDependencies": {
|
|
24
|
-
"@threenative/core": ">=0.
|
|
26
|
+
"@threenative/core": ">=0.3.0 <0.4.0",
|
|
25
27
|
"react": ">=19.2.0 <20",
|
|
26
28
|
"react-dom": ">=19.2.0 <20"
|
|
27
29
|
},
|
|
@@ -35,11 +37,11 @@
|
|
|
35
37
|
"react-test-renderer": "19.2.0",
|
|
36
38
|
"tsup": "8.5.1",
|
|
37
39
|
"typescript": "5.9.3",
|
|
38
|
-
"@threenative/core": "0.
|
|
40
|
+
"@threenative/core": "0.3.0"
|
|
39
41
|
},
|
|
40
42
|
"scripts": {
|
|
41
43
|
"build": "tsup --config tsup.config.ts && publint",
|
|
42
44
|
"typecheck": "tsc --noEmit",
|
|
43
|
-
"test": "
|
|
45
|
+
"test": "publint --strict"
|
|
44
46
|
}
|
|
45
47
|
}
|