@arcane-engine/runtime 0.1.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/README.md +38 -0
- package/index.ts +19 -0
- package/package.json +53 -0
- package/src/agent/agent.test.ts +384 -0
- package/src/agent/describe.ts +72 -0
- package/src/agent/index.ts +20 -0
- package/src/agent/protocol.ts +125 -0
- package/src/agent/types.ts +73 -0
- package/src/pathfinding/astar.test.ts +208 -0
- package/src/pathfinding/astar.ts +193 -0
- package/src/pathfinding/index.ts +2 -0
- package/src/pathfinding/types.ts +21 -0
- package/src/physics/aabb.ts +54 -0
- package/src/physics/index.ts +2 -0
- package/src/rendering/animation.test.ts +119 -0
- package/src/rendering/animation.ts +132 -0
- package/src/rendering/audio.test.ts +33 -0
- package/src/rendering/audio.ts +70 -0
- package/src/rendering/camera.ts +35 -0
- package/src/rendering/index.ts +56 -0
- package/src/rendering/input.test.ts +70 -0
- package/src/rendering/input.ts +82 -0
- package/src/rendering/lighting.ts +38 -0
- package/src/rendering/loop.ts +21 -0
- package/src/rendering/sprites.ts +60 -0
- package/src/rendering/text.test.ts +91 -0
- package/src/rendering/text.ts +184 -0
- package/src/rendering/texture.ts +31 -0
- package/src/rendering/tilemap.ts +46 -0
- package/src/rendering/types.ts +54 -0
- package/src/rendering/validate.ts +132 -0
- package/src/state/error.test.ts +45 -0
- package/src/state/error.ts +20 -0
- package/src/state/index.ts +70 -0
- package/src/state/observe.test.ts +173 -0
- package/src/state/observe.ts +110 -0
- package/src/state/prng.test.ts +221 -0
- package/src/state/prng.ts +162 -0
- package/src/state/query.test.ts +208 -0
- package/src/state/query.ts +144 -0
- package/src/state/store.test.ts +211 -0
- package/src/state/store.ts +109 -0
- package/src/state/transaction.test.ts +235 -0
- package/src/state/transaction.ts +280 -0
- package/src/state/types.test.ts +33 -0
- package/src/state/types.ts +30 -0
- package/src/systems/index.ts +2 -0
- package/src/systems/system.test.ts +217 -0
- package/src/systems/system.ts +150 -0
- package/src/systems/types.ts +35 -0
- package/src/testing/harness.ts +271 -0
- package/src/testing/mock-renderer.test.ts +93 -0
- package/src/testing/mock-renderer.ts +178 -0
- package/src/ui/index.ts +3 -0
- package/src/ui/primitives.test.ts +105 -0
- package/src/ui/primitives.ts +260 -0
- package/src/ui/types.ts +57 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mock renderer for testing visual code without GPU
|
|
3
|
+
* Validates API calls and parameter types
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface MockCall {
|
|
7
|
+
api: string;
|
|
8
|
+
params: any[];
|
|
9
|
+
timestamp: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
class MockRenderer {
|
|
13
|
+
private calls: MockCall[] = [];
|
|
14
|
+
private errors: string[] = [];
|
|
15
|
+
|
|
16
|
+
// Track all rendering calls
|
|
17
|
+
drawSprite(opts: any) {
|
|
18
|
+
this.calls.push({ api: "drawSprite", params: [opts], timestamp: Date.now() });
|
|
19
|
+
this.validateDrawSpriteParams(opts);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
drawText(text: string, opts: any) {
|
|
23
|
+
this.calls.push({ api: "drawText", params: [text, opts], timestamp: Date.now() });
|
|
24
|
+
this.validateDrawTextParams(text, opts);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
drawRect(x: any, y: any, w: any, h: any, opts?: any) {
|
|
28
|
+
this.calls.push({ api: "drawRect", params: [x, y, w, h, opts], timestamp: Date.now() });
|
|
29
|
+
this.validateDrawRectParams(x, y, w, h, opts);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Validation methods
|
|
33
|
+
private validateDrawSpriteParams(opts: any) {
|
|
34
|
+
if (typeof opts !== "object") {
|
|
35
|
+
this.addError("drawSprite: params must be object");
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (typeof opts.textureId !== "number") {
|
|
40
|
+
this.addError("drawSprite: textureId must be number");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
this.validateNumber("drawSprite", "x", opts.x);
|
|
44
|
+
this.validateNumber("drawSprite", "y", opts.y);
|
|
45
|
+
this.validateNumber("drawSprite", "w", opts.w);
|
|
46
|
+
this.validateNumber("drawSprite", "h", opts.h);
|
|
47
|
+
|
|
48
|
+
if (opts.layer !== undefined) {
|
|
49
|
+
this.validateNumber("drawSprite", "layer", opts.layer);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
private validateDrawTextParams(text: string, opts: any) {
|
|
54
|
+
if (typeof text !== "string") {
|
|
55
|
+
this.addError("drawText: text must be string");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (typeof opts !== "object") {
|
|
59
|
+
this.addError("drawText: opts must be object");
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
this.validateNumber("drawText", "x", opts.x);
|
|
64
|
+
this.validateNumber("drawText", "y", opts.y);
|
|
65
|
+
this.validateNumber("drawText", "size", opts.size);
|
|
66
|
+
|
|
67
|
+
if (opts.color) {
|
|
68
|
+
this.validateColor("drawText", opts.color);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private validateDrawRectParams(x: any, y: any, w: any, h: any, opts?: any) {
|
|
73
|
+
this.validateNumber("drawRect", "x", x);
|
|
74
|
+
this.validateNumber("drawRect", "y", y);
|
|
75
|
+
this.validateNumber("drawRect", "w", w);
|
|
76
|
+
this.validateNumber("drawRect", "h", h);
|
|
77
|
+
|
|
78
|
+
if (opts?.color) {
|
|
79
|
+
this.validateColor("drawRect", opts.color);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private validateNumber(api: string, param: string, value: any) {
|
|
84
|
+
if (typeof value !== "number") {
|
|
85
|
+
this.addError(`${api}: ${param} must be number, got ${typeof value}`);
|
|
86
|
+
} else if (isNaN(value)) {
|
|
87
|
+
this.addError(`${api}: ${param} is NaN`);
|
|
88
|
+
} else if (!isFinite(value)) {
|
|
89
|
+
this.addError(`${api}: ${param} is not finite`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private validateColor(api: string, color: any) {
|
|
94
|
+
if (typeof color !== "object") {
|
|
95
|
+
this.addError(`${api}: color must be object`);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const { r, g, b } = color;
|
|
100
|
+
|
|
101
|
+
if (typeof r !== "number" || r < 0 || r > 1) {
|
|
102
|
+
this.addError(`${api}: color.r must be 0.0-1.0, got ${r}`);
|
|
103
|
+
}
|
|
104
|
+
if (typeof g !== "number" || g < 0 || g > 1) {
|
|
105
|
+
this.addError(`${api}: color.g must be 0.0-1.0, got ${g}`);
|
|
106
|
+
}
|
|
107
|
+
if (typeof b !== "number" || b < 0 || b > 1) {
|
|
108
|
+
this.addError(`${api}: color.b must be 0.0-1.0, got ${b}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private addError(message: string) {
|
|
113
|
+
this.errors.push(message);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Query methods for tests
|
|
117
|
+
getCalls(api?: string): MockCall[] {
|
|
118
|
+
if (api) {
|
|
119
|
+
return this.calls.filter((c) => c.api === api);
|
|
120
|
+
}
|
|
121
|
+
return this.calls;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
getErrors(): string[] {
|
|
125
|
+
return this.errors;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
hasErrors(): boolean {
|
|
129
|
+
return this.errors.length > 0;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
reset() {
|
|
133
|
+
this.calls = [];
|
|
134
|
+
this.errors = [];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Assert helpers for tests
|
|
138
|
+
assertNoErrors() {
|
|
139
|
+
if (this.hasErrors()) {
|
|
140
|
+
throw new Error(
|
|
141
|
+
"Rendering errors found:\n" + this.errors.map((e) => ` - ${e}`).join("\n")
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
assertCalled(api: string, minTimes: number = 1) {
|
|
147
|
+
const calls = this.getCalls(api);
|
|
148
|
+
if (calls.length < minTimes) {
|
|
149
|
+
throw new Error(`Expected ${api} to be called at least ${minTimes} times, got ${calls.length}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
assertNotCalled(api: string) {
|
|
154
|
+
const calls = this.getCalls(api);
|
|
155
|
+
if (calls.length > 0) {
|
|
156
|
+
throw new Error(`Expected ${api} not to be called, but it was called ${calls.length} times`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Global mock instance
|
|
162
|
+
export const mockRenderer = new MockRenderer();
|
|
163
|
+
|
|
164
|
+
// Helper to install mock renderer
|
|
165
|
+
export function installMockRenderer() {
|
|
166
|
+
// Replace global rendering functions with mock versions
|
|
167
|
+
(globalThis as any).drawSprite = mockRenderer.drawSprite.bind(mockRenderer);
|
|
168
|
+
(globalThis as any).drawText = mockRenderer.drawText.bind(mockRenderer);
|
|
169
|
+
(globalThis as any).drawRect = mockRenderer.drawRect.bind(mockRenderer);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Helper to restore real renderer
|
|
173
|
+
export function restoreRenderer() {
|
|
174
|
+
// Remove mocks (real functions will be restored on next import)
|
|
175
|
+
delete (globalThis as any).drawSprite;
|
|
176
|
+
delete (globalThis as any).drawText;
|
|
177
|
+
delete (globalThis as any).drawRect;
|
|
178
|
+
}
|
package/src/ui/index.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, it, assert } from "../../runtime/testing/harness.ts";
|
|
2
|
+
import { drawRect, drawPanel, drawBar, drawLabel } from "./primitives.ts";
|
|
3
|
+
import { rgb } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
describe("rgb helper", () => {
|
|
6
|
+
it("should normalize 0-255 values to 0.0-1.0 range", () => {
|
|
7
|
+
const color = rgb(255, 128, 0);
|
|
8
|
+
assert.equal(color.r, 1.0, "r should be 1.0");
|
|
9
|
+
assert.equal(color.g, 128 / 255, "g should be ~0.502");
|
|
10
|
+
assert.equal(color.b, 0.0, "b should be 0.0");
|
|
11
|
+
assert.equal(color.a, 1.0, "a should default to 1.0");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("should accept optional alpha parameter", () => {
|
|
15
|
+
const color = rgb(255, 0, 0, 128);
|
|
16
|
+
assert.equal(color.r, 1.0, "r should be 1.0");
|
|
17
|
+
assert.equal(color.g, 0.0, "g should be 0.0");
|
|
18
|
+
assert.equal(color.b, 0.0, "b should be 0.0");
|
|
19
|
+
assert.equal(color.a, 128 / 255, "a should be ~0.502");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("should handle black (0, 0, 0)", () => {
|
|
23
|
+
const color = rgb(0, 0, 0);
|
|
24
|
+
assert.equal(color.r, 0.0, "r should be 0.0");
|
|
25
|
+
assert.equal(color.g, 0.0, "g should be 0.0");
|
|
26
|
+
assert.equal(color.b, 0.0, "b should be 0.0");
|
|
27
|
+
assert.equal(color.a, 1.0, "a should be 1.0");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("should handle white (255, 255, 255)", () => {
|
|
31
|
+
const color = rgb(255, 255, 255);
|
|
32
|
+
assert.equal(color.r, 1.0, "r should be 1.0");
|
|
33
|
+
assert.equal(color.g, 1.0, "g should be 1.0");
|
|
34
|
+
assert.equal(color.b, 1.0, "b should be 1.0");
|
|
35
|
+
assert.equal(color.a, 1.0, "a should be 1.0");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("should handle fully transparent (255, 255, 255, 0)", () => {
|
|
39
|
+
const color = rgb(255, 255, 255, 0);
|
|
40
|
+
assert.equal(color.r, 1.0, "r should be 1.0");
|
|
41
|
+
assert.equal(color.g, 1.0, "g should be 1.0");
|
|
42
|
+
assert.equal(color.b, 1.0, "b should be 1.0");
|
|
43
|
+
assert.equal(color.a, 0.0, "a should be 0.0");
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("ui primitives", () => {
|
|
48
|
+
it("drawRect does not throw in headless mode", () => {
|
|
49
|
+
drawRect(10, 20, 100, 50);
|
|
50
|
+
drawRect(0, 0, 200, 100, { color: { r: 1, g: 0, b: 0, a: 1 } });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("drawPanel does not throw in headless mode", () => {
|
|
54
|
+
drawPanel(10, 20, 200, 100);
|
|
55
|
+
drawPanel(0, 0, 300, 200, {
|
|
56
|
+
fillColor: { r: 0.1, g: 0.1, b: 0.2, a: 0.9 },
|
|
57
|
+
borderColor: { r: 0.8, g: 0.8, b: 0.8, a: 1 },
|
|
58
|
+
borderWidth: 3,
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("drawBar does not throw in headless mode", () => {
|
|
63
|
+
drawBar(10, 20, 100, 16, 0.75);
|
|
64
|
+
drawBar(0, 0, 200, 20, 1.0, {
|
|
65
|
+
fillColor: { r: 0, g: 1, b: 0, a: 1 },
|
|
66
|
+
bgColor: { r: 0.3, g: 0, b: 0, a: 1 },
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("drawBar clamps fill ratio to 0-1", () => {
|
|
71
|
+
// Should not throw with out-of-range values
|
|
72
|
+
drawBar(0, 0, 100, 10, -0.5);
|
|
73
|
+
drawBar(0, 0, 100, 10, 2.0);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("drawBar with border does not throw in headless mode", () => {
|
|
77
|
+
drawBar(10, 20, 100, 16, 0.5, {
|
|
78
|
+
borderColor: { r: 1, g: 1, b: 1, a: 1 },
|
|
79
|
+
borderWidth: 1,
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("drawLabel does not throw in headless mode", () => {
|
|
84
|
+
drawLabel("Hello", 10, 20);
|
|
85
|
+
drawLabel("Score: 42", 100, 50, {
|
|
86
|
+
textColor: { r: 1, g: 1, b: 0, a: 1 },
|
|
87
|
+
padding: 8,
|
|
88
|
+
scale: 2,
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("drawPanel with zero border width works", () => {
|
|
93
|
+
drawPanel(0, 0, 100, 50, { borderWidth: 0 });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("drawLabel with custom colors works", () => {
|
|
97
|
+
drawLabel("HP", 0, 0, {
|
|
98
|
+
textColor: { r: 1, g: 0, b: 0, a: 1 },
|
|
99
|
+
bgColor: { r: 0, g: 0, b: 0, a: 0.8 },
|
|
100
|
+
borderColor: { r: 0.5, g: 0.5, b: 0.5, a: 1 },
|
|
101
|
+
borderWidth: 2,
|
|
102
|
+
padding: 6,
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import type { Color, RectOptions, PanelOptions, BarOptions, LabelOptions } from "./types.ts";
|
|
2
|
+
import { drawSprite } from "../rendering/sprites.ts";
|
|
3
|
+
import { createSolidTexture } from "../rendering/texture.ts";
|
|
4
|
+
import { drawText, measureText } from "../rendering/text.ts";
|
|
5
|
+
import { getCamera } from "../rendering/camera.ts";
|
|
6
|
+
|
|
7
|
+
const hasRenderOps =
|
|
8
|
+
typeof (globalThis as any).Deno !== "undefined" &&
|
|
9
|
+
typeof (globalThis as any).Deno?.core?.ops?.op_draw_sprite === "function";
|
|
10
|
+
|
|
11
|
+
const hasViewportOp =
|
|
12
|
+
typeof (globalThis as any).Deno?.core?.ops?.op_get_viewport_size === "function";
|
|
13
|
+
|
|
14
|
+
function getViewportSize(): [number, number] {
|
|
15
|
+
if (!hasViewportOp) return [800, 600];
|
|
16
|
+
const [w, h] = (globalThis as any).Deno.core.ops.op_get_viewport_size();
|
|
17
|
+
return [w, h];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Cache solid textures by color key to avoid re-creating them every frame. */
|
|
21
|
+
const textureCache = new Map<string, number>();
|
|
22
|
+
|
|
23
|
+
function getColorTexture(color: Color): number {
|
|
24
|
+
const key = `${color.r}_${color.g}_${color.b}_${color.a}`;
|
|
25
|
+
let tex = textureCache.get(key);
|
|
26
|
+
if (tex !== undefined) return tex;
|
|
27
|
+
tex = createSolidTexture(
|
|
28
|
+
key,
|
|
29
|
+
Math.round(color.r * 255),
|
|
30
|
+
Math.round(color.g * 255),
|
|
31
|
+
Math.round(color.b * 255),
|
|
32
|
+
Math.round(color.a * 255),
|
|
33
|
+
);
|
|
34
|
+
textureCache.set(key, tex);
|
|
35
|
+
return tex;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Default colors. */
|
|
39
|
+
const WHITE: Color = { r: 1, g: 1, b: 1, a: 1 };
|
|
40
|
+
const DARK: Color = { r: 0.1, g: 0.1, b: 0.15, a: 0.9 };
|
|
41
|
+
const GRAY: Color = { r: 0.5, g: 0.5, b: 0.5, a: 1 };
|
|
42
|
+
const GREEN: Color = { r: 0.2, g: 0.8, b: 0.2, a: 1 };
|
|
43
|
+
const RED: Color = { r: 0.3, g: 0.1, b: 0.1, a: 0.8 };
|
|
44
|
+
|
|
45
|
+
function toWorld(
|
|
46
|
+
sx: number,
|
|
47
|
+
sy: number,
|
|
48
|
+
sw: number,
|
|
49
|
+
sh: number,
|
|
50
|
+
screenSpace: boolean,
|
|
51
|
+
): { x: number; y: number; w: number; h: number } {
|
|
52
|
+
if (!screenSpace) return { x: sx, y: sy, w: sw, h: sh };
|
|
53
|
+
const cam = getCamera();
|
|
54
|
+
const [vpW, vpH] = getViewportSize();
|
|
55
|
+
return {
|
|
56
|
+
x: sx / cam.zoom + cam.x - vpW / (2 * cam.zoom),
|
|
57
|
+
y: sy / cam.zoom + cam.y - vpH / (2 * cam.zoom),
|
|
58
|
+
w: sw / cam.zoom,
|
|
59
|
+
h: sh / cam.zoom,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Draw a filled rectangle.
|
|
65
|
+
* No-op in headless mode.
|
|
66
|
+
*/
|
|
67
|
+
export function drawRect(
|
|
68
|
+
x: number,
|
|
69
|
+
y: number,
|
|
70
|
+
w: number,
|
|
71
|
+
h: number,
|
|
72
|
+
options?: RectOptions,
|
|
73
|
+
): void {
|
|
74
|
+
if (!hasRenderOps) return;
|
|
75
|
+
const color = options?.color ?? WHITE;
|
|
76
|
+
const layer = options?.layer ?? 90;
|
|
77
|
+
const ss = options?.screenSpace ?? false;
|
|
78
|
+
const tex = getColorTexture(color);
|
|
79
|
+
const pos = toWorld(x, y, w, h, ss);
|
|
80
|
+
const posX = pos.x;
|
|
81
|
+
const posY = pos.y;
|
|
82
|
+
const posW = pos.w;
|
|
83
|
+
const posH = pos.h;
|
|
84
|
+
drawSprite({ textureId: tex, x: posX, y: posY, w: posW, h: posH, layer });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Draw a panel with border and fill.
|
|
89
|
+
* Uses 5 sprites: 4 border edges + 1 fill.
|
|
90
|
+
* No-op in headless mode.
|
|
91
|
+
*/
|
|
92
|
+
export function drawPanel(
|
|
93
|
+
x: number,
|
|
94
|
+
y: number,
|
|
95
|
+
w: number,
|
|
96
|
+
h: number,
|
|
97
|
+
options?: PanelOptions,
|
|
98
|
+
): void {
|
|
99
|
+
if (!hasRenderOps) return;
|
|
100
|
+
const fillColor = options?.fillColor ?? DARK;
|
|
101
|
+
const borderColor = options?.borderColor ?? GRAY;
|
|
102
|
+
const bw = options?.borderWidth ?? 2;
|
|
103
|
+
const layer = options?.layer ?? 90;
|
|
104
|
+
const ss = options?.screenSpace ?? false;
|
|
105
|
+
|
|
106
|
+
const fillTex = getColorTexture(fillColor);
|
|
107
|
+
const borderTex = getColorTexture(borderColor);
|
|
108
|
+
|
|
109
|
+
// Fill (inset by border width)
|
|
110
|
+
const fill = toWorld(x + bw, y + bw, w - 2 * bw, h - 2 * bw, ss);
|
|
111
|
+
const fillX = fill.x;
|
|
112
|
+
const fillY = fill.y;
|
|
113
|
+
const fillW = fill.w;
|
|
114
|
+
const fillH = fill.h;
|
|
115
|
+
drawSprite({ textureId: fillTex, x: fillX, y: fillY, w: fillW, h: fillH, layer });
|
|
116
|
+
|
|
117
|
+
// Top border
|
|
118
|
+
const top = toWorld(x, y, w, bw, ss);
|
|
119
|
+
const topX = top.x;
|
|
120
|
+
const topY = top.y;
|
|
121
|
+
const topW = top.w;
|
|
122
|
+
const topH = top.h;
|
|
123
|
+
drawSprite({ textureId: borderTex, x: topX, y: topY, w: topW, h: topH, layer: layer + 1 });
|
|
124
|
+
|
|
125
|
+
// Bottom border
|
|
126
|
+
const bot = toWorld(x, y + h - bw, w, bw, ss);
|
|
127
|
+
const botX = bot.x;
|
|
128
|
+
const botY = bot.y;
|
|
129
|
+
const botW = bot.w;
|
|
130
|
+
const botH = bot.h;
|
|
131
|
+
drawSprite({ textureId: borderTex, x: botX, y: botY, w: botW, h: botH, layer: layer + 1 });
|
|
132
|
+
|
|
133
|
+
// Left border
|
|
134
|
+
const left = toWorld(x, y + bw, bw, h - 2 * bw, ss);
|
|
135
|
+
const leftX = left.x;
|
|
136
|
+
const leftY = left.y;
|
|
137
|
+
const leftW = left.w;
|
|
138
|
+
const leftH = left.h;
|
|
139
|
+
drawSprite({ textureId: borderTex, x: leftX, y: leftY, w: leftW, h: leftH, layer: layer + 1 });
|
|
140
|
+
|
|
141
|
+
// Right border
|
|
142
|
+
const right = toWorld(x + w - bw, y + bw, bw, h - 2 * bw, ss);
|
|
143
|
+
const rightX = right.x;
|
|
144
|
+
const rightY = right.y;
|
|
145
|
+
const rightW = right.w;
|
|
146
|
+
const rightH = right.h;
|
|
147
|
+
drawSprite({ textureId: borderTex, x: rightX, y: rightY, w: rightW, h: rightH, layer: layer + 1 });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Draw a progress/health bar.
|
|
152
|
+
* fillRatio is 0.0 to 1.0 (clamped).
|
|
153
|
+
* No-op in headless mode.
|
|
154
|
+
*/
|
|
155
|
+
export function drawBar(
|
|
156
|
+
x: number,
|
|
157
|
+
y: number,
|
|
158
|
+
w: number,
|
|
159
|
+
h: number,
|
|
160
|
+
fillRatio: number,
|
|
161
|
+
options?: BarOptions,
|
|
162
|
+
): void {
|
|
163
|
+
if (!hasRenderOps) return;
|
|
164
|
+
const ratio = Math.max(0, Math.min(1, fillRatio));
|
|
165
|
+
const bgColor = options?.bgColor ?? RED;
|
|
166
|
+
const fillColor = options?.fillColor ?? GREEN;
|
|
167
|
+
const borderColor = options?.borderColor;
|
|
168
|
+
const bw = options?.borderWidth ?? 0;
|
|
169
|
+
const layer = options?.layer ?? 90;
|
|
170
|
+
const ss = options?.screenSpace ?? false;
|
|
171
|
+
|
|
172
|
+
// Background
|
|
173
|
+
const bg = toWorld(x, y, w, h, ss);
|
|
174
|
+
const bgTex = getColorTexture(bgColor);
|
|
175
|
+
const bgX = bg.x;
|
|
176
|
+
const bgY = bg.y;
|
|
177
|
+
const bgW = bg.w;
|
|
178
|
+
const bgH = bg.h;
|
|
179
|
+
drawSprite({ textureId: bgTex, x: bgX, y: bgY, w: bgW, h: bgH, layer });
|
|
180
|
+
|
|
181
|
+
// Fill (inset by border if present)
|
|
182
|
+
const inset = bw;
|
|
183
|
+
const fillW = (w - 2 * inset) * ratio;
|
|
184
|
+
if (fillW > 0) {
|
|
185
|
+
const fill = toWorld(x + inset, y + inset, fillW, h - 2 * inset, ss);
|
|
186
|
+
const fillTex = getColorTexture(fillColor);
|
|
187
|
+
const fillX = fill.x;
|
|
188
|
+
const fillY = fill.y;
|
|
189
|
+
const fillW2 = fill.w;
|
|
190
|
+
const fillH = fill.h;
|
|
191
|
+
drawSprite({ textureId: fillTex, x: fillX, y: fillY, w: fillW2, h: fillH, layer: layer + 1 });
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Optional border
|
|
195
|
+
if (borderColor && bw > 0) {
|
|
196
|
+
const borderTex = getColorTexture(borderColor);
|
|
197
|
+
const top = toWorld(x, y, w, bw, ss);
|
|
198
|
+
const topX = top.x;
|
|
199
|
+
const topY = top.y;
|
|
200
|
+
const topW = top.w;
|
|
201
|
+
const topH = top.h;
|
|
202
|
+
drawSprite({ textureId: borderTex, x: topX, y: topY, w: topW, h: topH, layer: layer + 2 });
|
|
203
|
+
const bot = toWorld(x, y + h - bw, w, bw, ss);
|
|
204
|
+
const botX = bot.x;
|
|
205
|
+
const botY = bot.y;
|
|
206
|
+
const botW = bot.w;
|
|
207
|
+
const botH = bot.h;
|
|
208
|
+
drawSprite({ textureId: borderTex, x: botX, y: botY, w: botW, h: botH, layer: layer + 2 });
|
|
209
|
+
const left = toWorld(x, y + bw, bw, h - 2 * bw, ss);
|
|
210
|
+
const leftX = left.x;
|
|
211
|
+
const leftY = left.y;
|
|
212
|
+
const leftW = left.w;
|
|
213
|
+
const leftH = left.h;
|
|
214
|
+
drawSprite({ textureId: borderTex, x: leftX, y: leftY, w: leftW, h: leftH, layer: layer + 2 });
|
|
215
|
+
const right = toWorld(x + w - bw, y + bw, bw, h - 2 * bw, ss);
|
|
216
|
+
const rightX = right.x;
|
|
217
|
+
const rightY = right.y;
|
|
218
|
+
const rightW = right.w;
|
|
219
|
+
const rightH = right.h;
|
|
220
|
+
drawSprite({ textureId: borderTex, x: rightX, y: rightY, w: rightW, h: rightH, layer: layer + 2 });
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Draw a text label with optional background panel.
|
|
226
|
+
* No-op in headless mode.
|
|
227
|
+
*/
|
|
228
|
+
export function drawLabel(
|
|
229
|
+
text: string,
|
|
230
|
+
x: number,
|
|
231
|
+
y: number,
|
|
232
|
+
options?: LabelOptions,
|
|
233
|
+
): void {
|
|
234
|
+
if (!hasRenderOps) return;
|
|
235
|
+
const padding = options?.padding ?? 4;
|
|
236
|
+
const scale = options?.scale ?? 1;
|
|
237
|
+
const layer = options?.layer ?? 90;
|
|
238
|
+
const ss = options?.screenSpace ?? false;
|
|
239
|
+
|
|
240
|
+
const measurement = measureText(text, { scale });
|
|
241
|
+
const panelW = measurement.width + padding * 2;
|
|
242
|
+
const panelH = measurement.height + padding * 2;
|
|
243
|
+
|
|
244
|
+
// Background panel
|
|
245
|
+
drawPanel(x, y, panelW, panelH, {
|
|
246
|
+
fillColor: options?.bgColor ?? DARK,
|
|
247
|
+
borderColor: options?.borderColor ?? GRAY,
|
|
248
|
+
borderWidth: options?.borderWidth ?? 1,
|
|
249
|
+
layer,
|
|
250
|
+
screenSpace: ss,
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// Text
|
|
254
|
+
drawText(text, x + padding, y + padding, {
|
|
255
|
+
scale,
|
|
256
|
+
tint: options?.textColor ?? WHITE,
|
|
257
|
+
layer: layer + 3,
|
|
258
|
+
screenSpace: ss,
|
|
259
|
+
});
|
|
260
|
+
}
|
package/src/ui/types.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/** RGBA color with 0-1 float components (matching sprite tint). */
|
|
2
|
+
export type Color = {
|
|
3
|
+
r: number;
|
|
4
|
+
g: number;
|
|
5
|
+
b: number;
|
|
6
|
+
a: number;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Create a Color from 0-255 RGB(A) values, auto-normalized to 0.0-1.0 range.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* rgb(255, 128, 0) // Orange, fully opaque
|
|
14
|
+
* rgb(255, 0, 0, 128) // Red, 50% transparent
|
|
15
|
+
*/
|
|
16
|
+
export function rgb(r: number, g: number, b: number, a: number = 255): Color {
|
|
17
|
+
return {
|
|
18
|
+
r: r / 255.0,
|
|
19
|
+
g: g / 255.0,
|
|
20
|
+
b: b / 255.0,
|
|
21
|
+
a: a / 255.0,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type RectOptions = {
|
|
26
|
+
color?: Color;
|
|
27
|
+
layer?: number;
|
|
28
|
+
screenSpace?: boolean;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type PanelOptions = {
|
|
32
|
+
fillColor?: Color;
|
|
33
|
+
borderColor?: Color;
|
|
34
|
+
borderWidth?: number;
|
|
35
|
+
layer?: number;
|
|
36
|
+
screenSpace?: boolean;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type BarOptions = {
|
|
40
|
+
fillColor?: Color;
|
|
41
|
+
bgColor?: Color;
|
|
42
|
+
borderColor?: Color;
|
|
43
|
+
borderWidth?: number;
|
|
44
|
+
layer?: number;
|
|
45
|
+
screenSpace?: boolean;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export type LabelOptions = {
|
|
49
|
+
textColor?: Color;
|
|
50
|
+
bgColor?: Color;
|
|
51
|
+
borderColor?: Color;
|
|
52
|
+
borderWidth?: number;
|
|
53
|
+
padding?: number;
|
|
54
|
+
scale?: number;
|
|
55
|
+
layer?: number;
|
|
56
|
+
screenSpace?: boolean;
|
|
57
|
+
};
|