@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.
Files changed (57) hide show
  1. package/README.md +38 -0
  2. package/index.ts +19 -0
  3. package/package.json +53 -0
  4. package/src/agent/agent.test.ts +384 -0
  5. package/src/agent/describe.ts +72 -0
  6. package/src/agent/index.ts +20 -0
  7. package/src/agent/protocol.ts +125 -0
  8. package/src/agent/types.ts +73 -0
  9. package/src/pathfinding/astar.test.ts +208 -0
  10. package/src/pathfinding/astar.ts +193 -0
  11. package/src/pathfinding/index.ts +2 -0
  12. package/src/pathfinding/types.ts +21 -0
  13. package/src/physics/aabb.ts +54 -0
  14. package/src/physics/index.ts +2 -0
  15. package/src/rendering/animation.test.ts +119 -0
  16. package/src/rendering/animation.ts +132 -0
  17. package/src/rendering/audio.test.ts +33 -0
  18. package/src/rendering/audio.ts +70 -0
  19. package/src/rendering/camera.ts +35 -0
  20. package/src/rendering/index.ts +56 -0
  21. package/src/rendering/input.test.ts +70 -0
  22. package/src/rendering/input.ts +82 -0
  23. package/src/rendering/lighting.ts +38 -0
  24. package/src/rendering/loop.ts +21 -0
  25. package/src/rendering/sprites.ts +60 -0
  26. package/src/rendering/text.test.ts +91 -0
  27. package/src/rendering/text.ts +184 -0
  28. package/src/rendering/texture.ts +31 -0
  29. package/src/rendering/tilemap.ts +46 -0
  30. package/src/rendering/types.ts +54 -0
  31. package/src/rendering/validate.ts +132 -0
  32. package/src/state/error.test.ts +45 -0
  33. package/src/state/error.ts +20 -0
  34. package/src/state/index.ts +70 -0
  35. package/src/state/observe.test.ts +173 -0
  36. package/src/state/observe.ts +110 -0
  37. package/src/state/prng.test.ts +221 -0
  38. package/src/state/prng.ts +162 -0
  39. package/src/state/query.test.ts +208 -0
  40. package/src/state/query.ts +144 -0
  41. package/src/state/store.test.ts +211 -0
  42. package/src/state/store.ts +109 -0
  43. package/src/state/transaction.test.ts +235 -0
  44. package/src/state/transaction.ts +280 -0
  45. package/src/state/types.test.ts +33 -0
  46. package/src/state/types.ts +30 -0
  47. package/src/systems/index.ts +2 -0
  48. package/src/systems/system.test.ts +217 -0
  49. package/src/systems/system.ts +150 -0
  50. package/src/systems/types.ts +35 -0
  51. package/src/testing/harness.ts +271 -0
  52. package/src/testing/mock-renderer.test.ts +93 -0
  53. package/src/testing/mock-renderer.ts +178 -0
  54. package/src/ui/index.ts +3 -0
  55. package/src/ui/primitives.test.ts +105 -0
  56. package/src/ui/primitives.ts +260 -0
  57. 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
+ }
@@ -0,0 +1,3 @@
1
+ export type { Color, RectOptions, PanelOptions, BarOptions, LabelOptions } from "./types.ts";
2
+ export { rgb } from "./types.ts";
3
+ export { drawRect, drawPanel, drawBar, drawLabel } from "./primitives.ts";
@@ -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
+ }
@@ -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
+ };