Skip to content

Commit acee776

Browse files
authored
feat(engine): implement minimal UI APIs (UIRenderer, UINode and UISprite) (#125)
fix(changeset): use main as branch
1 parent e391f2a commit acee776

File tree

25 files changed

+1165
-261
lines changed

25 files changed

+1165
-261
lines changed

.changeset/config.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
"fixed": [],
99
"linked": [],
1010
"access": "public",
11-
"baseBranch": "master",
11+
"baseBranch": "main",
1212
"updateInternalDependencies": "patch",
1313
"ignore": []
1414
}

.changeset/seven-llamas-swim.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@jolly-pixel/engine": minor
3+
---
4+
5+
Implement minimal UI apis (UIRenderer, UINode, UISprite, UIText)

packages/editors/model/src/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,12 @@ function initRuntime() {
4646
.registerComponent(ModelRenderer, {
4747
path: "models/Standard.fbx"
4848
})
49-
.registerComponent(PlayerBehavior);
49+
.registerComponent(PlayerBehavior, {}, (_component) => {
50+
// console.log(component);
51+
// component.onPlayerPunch.connect(() => {
52+
// console.log("Player punched!");
53+
// });
54+
});
5055
// new Actor(gameInstance, { name: "duckModel" })
5156
// .registerComponent(ModelRenderer, {
5257
// path: "models/Duck.gltf"

packages/engine/README.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,19 @@ gameInstance.audio.observe(bg);
198198
gameInstance.audio.volume = 0.5;
199199
```
200200

201-
### 🔨 Internals
201+
### 🖼️ UI
202+
203+
An orthographic 2D overlay drawn on top of the 3D scene.
204+
UI elements are anchored to screen edges and support pointer interaction through signals.
205+
206+
- [UIRenderer](./docs/ui/ui-renderer.md) — orthographic camera and
207+
CSS2D overlay that drives the UI layer.
208+
- [UINode](./docs/ui/ui-node.md) — base positioning component with
209+
anchor, offset, and pivot.
210+
- [UISprite](./docs/ui/ui-sprite.md) — interactive sprite with
211+
style, hover states, text labels, and pointer signals.
212+
213+
### 📦 Internals
202214

203215
- [Adapters](./docs/internals/adapters.md)
204216
- [Audio](./docs/internals/audio.md)

packages/engine/docs/ui/ui-node.md

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
## UINode
2+
3+
A **UINode** is an [ActorComponent](../actor/actor-component.md)
4+
that positions an actor in screen space using an **anchor**,
5+
**offset**, and **pivot** system. It is the base positioning layer
6+
for all 2D UI elements — [UISprite](./ui-sprite.md) extends it to
7+
add visuals and interaction.
8+
9+
When a [UIRenderer](./ui-renderer.md) exists in the game instance,
10+
every new `UINode` automatically registers itself with the renderer.
11+
Otherwise it falls back to listening to renderer `resize` events
12+
directly.
13+
14+
```ts
15+
import { Actor, UINode } from "@jolly-pixel/engine";
16+
17+
const hud = new Actor(gameInstance, {
18+
name: "hud",
19+
parent: camera2D
20+
})
21+
.registerComponent(UINode, {
22+
anchor: { x: "right", y: "top" },
23+
offset: { x: -16, y: -16 },
24+
size: { width: 100, height: 40 }
25+
});
26+
```
27+
28+
### Anchor
29+
30+
The **anchor** determines which screen edge the element aligns to.
31+
Anchors are independent on each axis.
32+
33+
| Axis | Values | Description |
34+
| ---- | ------ | ----------- |
35+
| `x` | `"left"`, `"center"`, `"right"` | Horizontal screen edge |
36+
| `y` | `"top"`, `"center"`, `"bottom"` | Vertical screen edge |
37+
38+
When an anchor is set to `"left"`, the element is pushed against the
39+
left edge of the screen. Combined with an offset you can create
40+
consistent margins that survive window resizes.
41+
42+
### Offset
43+
44+
The **offset** shifts the element away from its anchor in
45+
world units. Positive X moves right, positive Y moves up.
46+
47+
```ts
48+
// 20 units from the left edge, 10 units below the top
49+
{
50+
anchor: { x: "left", y: "top" },
51+
offset: { x: 20, y: -10 }
52+
}
53+
```
54+
55+
### Pivot
56+
57+
The **pivot** is a normalized origin point from `0` to `1` that
58+
controls which part of the element sits at the computed position.
59+
60+
| Pivot | Meaning |
61+
| ----- | ------- |
62+
| `{ x: 0, y: 0 }` | Bottom-left corner |
63+
| `{ x: 0.5, y: 0.5 }` | Center (default) |
64+
| `{ x: 1, y: 1 }` | Top-right corner |
65+
66+
Setting the pivot to `{ x: 0, y: 1 }` makes the top-left corner
67+
the origin — useful for left-aligned HUD panels anchored to the
68+
top of the screen.
69+
70+
### Size
71+
72+
The **size** defines the width and height of the element in world
73+
units. It is used by `UISprite` to create the mesh geometry and by
74+
the anchoring logic to keep the element fully on-screen.
75+
76+
```ts
77+
{
78+
size: { width: 200, height: 60 }
79+
}
80+
```
81+
82+
### Constructor
83+
84+
```ts
85+
interface UINodeOptions {
86+
anchor?: {
87+
x?: "left" | "center" | "right";
88+
y?: "top" | "center" | "bottom";
89+
};
90+
offset?: {
91+
x?: number;
92+
y?: number;
93+
};
94+
size?: {
95+
width?: number;
96+
height?: number;
97+
};
98+
pivot?: {
99+
x?: number;
100+
y?: number;
101+
};
102+
}
103+
```
104+
105+
| Option | Default | Description |
106+
| ------ | ------- | ----------- |
107+
| `anchor` | `{ x: "center", y: "center" }` | Screen-edge alignment |
108+
| `offset` | `{ x: 0, y: 0 }` | Offset from the anchor in world units |
109+
| `size` | `{ width: 0, height: 0 }` | Element dimensions in world units |
110+
| `pivot` | `{ x: 0.5, y: 0.5 }` | Normalized origin point (0 – 1) |
111+
112+
### Positioning examples
113+
114+
**Centered element:**
115+
116+
```ts
117+
// Defaults — centered on screen
118+
{ anchor: { x: "center", y: "center" } }
119+
```
120+
121+
**Top-right corner with margin:**
122+
123+
```ts
124+
{
125+
anchor: { x: "right", y: "top" },
126+
offset: { x: -16, y: -16 },
127+
pivot: { x: 1, y: 1 }
128+
}
129+
```
130+
131+
**Bottom-left health bar:**
132+
133+
```ts
134+
{
135+
anchor: { x: "left", y: "bottom" },
136+
offset: { x: 20, y: 20 },
137+
size: { width: 300, height: 24 },
138+
pivot: { x: 0, y: 0 }
139+
}
140+
```
141+
142+
### API
143+
144+
```ts
145+
class UINode extends ActorComponent {
146+
get size(): { width: number; height: number };
147+
get pivot(): { x: number; y: number };
148+
149+
addChildren(object: THREE.Object3D): void;
150+
updateToWorldPosition(): void;
151+
}
152+
```
153+
154+
### See also
155+
156+
- [UIRenderer](./ui-renderer.md) — the orthographic overlay system
157+
- [UISprite](./ui-sprite.md) — visual + interactive UI element
158+
- [ActorComponent](../actor/actor-component.md) — component base type
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
## UIRenderer
2+
3+
The **UIRenderer** is an [ActorComponent](../actor/actor-component.md)
4+
that provides an orthographic 2D overlay on top of the 3D scene.
5+
It creates its own `OrthographicCamera` and a Three.js
6+
`CSS2DRenderer` so that [UINode](./ui-node.md) and
7+
[UISprite](./ui-sprite.md) elements are always drawn screen-aligned,
8+
independently from the main 3D camera.
9+
10+
Typically a single `UIRenderer` is registered once on a dedicated
11+
actor (e.g. `"camera2D"`). Every `UINode` created under that actor
12+
auto-discovers the renderer and registers itself.
13+
14+
```ts
15+
import { Actor, UIRenderer, UISprite } from "@jolly-pixel/engine";
16+
17+
const camera2D = new Actor(gameInstance, { name: "camera2D" })
18+
.registerComponent(UIRenderer, { near: 1 });
19+
```
20+
21+
### How it works
22+
23+
1. On construction the component creates an `OrthographicCamera`
24+
sized to match the current canvas and appends a `CSS2DRenderer`
25+
DOM element on top of the WebGL canvas.
26+
2. It listens to the game renderer's `resize` event and
27+
automatically updates both the CSS overlay size and every
28+
registered node position.
29+
3. On every `draw` event it renders the scene through the
30+
orthographic camera so that CSS2D objects (used by
31+
[UIText](./ui-sprite.md)) stay in sync with the 3D world.
32+
33+
### Constructor
34+
35+
```ts
36+
interface UIRendererOptions {
37+
/** Near clipping plane of the orthographic camera. */
38+
near?: number;
39+
/** Far clipping plane of the orthographic camera. */
40+
far?: number;
41+
/** Z position of the camera (draw order). */
42+
zIndex?: number;
43+
}
44+
```
45+
46+
| Option | Default | Description |
47+
| ------ | ------- | ----------- |
48+
| `near` | `0.1` | Near clipping plane |
49+
| `far` | `2000` | Far clipping plane |
50+
| `zIndex` | `10` | Camera Z position — controls draw order |
51+
52+
### Creating a HUD element
53+
54+
Once a `UIRenderer` exists, child actors can register
55+
[UISprite](./ui-sprite.md) components that are automatically
56+
positioned relative to the screen edges.
57+
58+
```ts
59+
const button = new Actor(gameInstance, {
60+
name: "startButton",
61+
parent: camera2D
62+
})
63+
.registerComponentAndGet(UISprite, {
64+
anchor: { x: "center", y: "top" },
65+
offset: { y: -80 },
66+
size: { width: 200, height: 60 },
67+
style: { color: 0x0077ff },
68+
styleOnHover: { color: 0x0099ff },
69+
text: {
70+
textContent: "Start",
71+
style: {
72+
color: "#ffffff",
73+
fontSize: "20px",
74+
fontWeight: "bold"
75+
}
76+
}
77+
});
78+
79+
button.onClick.connect(() => {
80+
console.log("Start clicked!");
81+
});
82+
```
83+
84+
### Updating positions at runtime
85+
86+
When you change the offset or anchor of a node after creation
87+
you can force a full re-layout by calling `updateWorldPosition`:
88+
89+
```ts
90+
const uiRenderer = camera2D.getComponent(UIRenderer)!;
91+
uiRenderer.updateWorldPosition();
92+
```
93+
94+
### Cleanup
95+
96+
Calling `clear()` destroys every registered node, removes the
97+
CSS overlay from the DOM, and unsubscribes from renderer events:
98+
99+
```ts
100+
uiRenderer.clear();
101+
```
102+
103+
### API
104+
105+
```ts
106+
class UIRenderer extends ActorComponent {
107+
static ID: symbol;
108+
camera: THREE.OrthographicCamera;
109+
nodes: UINode[];
110+
111+
addChildren(node: UINode): void;
112+
updateWorldPosition(): void;
113+
clear(): void;
114+
}
115+
```
116+
117+
### See also
118+
119+
- [UINode](./ui-node.md) — base positioning component
120+
- [UISprite](./ui-sprite.md) — interactive sprite with events
121+
- [ActorComponent](../actor/actor-component.md) — component base type
122+
- [Renderers](../components/renderers.md) — 3D renderer components

0 commit comments

Comments
 (0)