nova64 0.2.6 → 0.2.8

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 (53) hide show
  1. package/dist/examples/strider-demo-3d/fix-game.sh +0 -0
  2. package/dist/os9-shell/assets/index-3Hr_q5dj.js +483 -0
  3. package/dist/os9-shell/assets/index-3Hr_q5dj.js.map +1 -0
  4. package/dist/os9-shell/index.html +2 -1
  5. package/dist/runtime/api-2d.js +1158 -0
  6. package/dist/runtime/api-3d/camera.js +73 -0
  7. package/dist/runtime/api-3d/instancing.js +180 -0
  8. package/dist/runtime/api-3d/lights.js +51 -0
  9. package/dist/runtime/api-3d/materials.js +47 -0
  10. package/dist/runtime/api-3d/models.js +84 -0
  11. package/dist/runtime/api-3d/particles.js +296 -0
  12. package/dist/runtime/api-3d/pbr.js +113 -0
  13. package/dist/runtime/api-3d/primitives.js +304 -0
  14. package/dist/runtime/api-3d/scene.js +169 -0
  15. package/dist/runtime/api-3d/transforms.js +161 -0
  16. package/dist/runtime/api-3d.js +166 -0
  17. package/dist/runtime/api-effects.js +840 -0
  18. package/dist/runtime/api-gameutils.js +476 -0
  19. package/dist/runtime/api-generative.js +610 -0
  20. package/dist/runtime/api-presets.js +85 -0
  21. package/dist/runtime/api-skybox.js +232 -0
  22. package/dist/runtime/api-sprites.js +100 -0
  23. package/dist/runtime/api-voxel.js +712 -0
  24. package/dist/runtime/api.js +201 -0
  25. package/dist/runtime/assets.js +27 -0
  26. package/dist/runtime/audio.js +114 -0
  27. package/dist/runtime/collision.js +47 -0
  28. package/dist/runtime/console.js +101 -0
  29. package/dist/runtime/editor.js +233 -0
  30. package/dist/runtime/font.js +233 -0
  31. package/dist/runtime/framebuffer.js +28 -0
  32. package/dist/runtime/fullscreen-button.js +185 -0
  33. package/dist/runtime/gpu-canvas2d.js +47 -0
  34. package/dist/runtime/gpu-threejs.js +643 -0
  35. package/dist/runtime/gpu-webgl2.js +310 -0
  36. package/dist/runtime/index.d.ts +682 -0
  37. package/dist/runtime/index.js +22 -0
  38. package/dist/runtime/input.js +225 -0
  39. package/dist/runtime/logger.js +60 -0
  40. package/dist/runtime/physics.js +101 -0
  41. package/dist/runtime/screens.js +213 -0
  42. package/dist/runtime/storage.js +38 -0
  43. package/dist/runtime/store.js +151 -0
  44. package/dist/runtime/textinput.js +68 -0
  45. package/dist/runtime/ui/buttons.js +124 -0
  46. package/dist/runtime/ui/panels.js +105 -0
  47. package/dist/runtime/ui/text.js +86 -0
  48. package/dist/runtime/ui/widgets.js +141 -0
  49. package/dist/runtime/ui.js +111 -0
  50. package/package.json +34 -32
  51. package/public/os9-shell/assets/index-3Hr_q5dj.js +483 -0
  52. package/public/os9-shell/assets/index-3Hr_q5dj.js.map +1 -0
  53. package/public/os9-shell/index.html +2 -1
@@ -0,0 +1,1158 @@
1
+ // runtime/api-2d.js
2
+ // Nova64 Enhanced 2D Drawing API
3
+ // All primitives operate on the framebuffer (the 2D overlay that composites over the 3D scene).
4
+ //
5
+ // Exposed globals: drawGradient, drawRadialGradient, drawRoundedRect, poly, tristrip,
6
+ // printCentered, printRight, drawScanlines, drawNoise, drawProgressBar,
7
+ // drawGlowText, drawPixelBorder, drawCheckerboard, colorLerp, colorMix, hexColor,
8
+ // drawStarburst, drawDiamond, drawTriangle, drawWave, drawSpiral, scrollingText,
9
+ // drawPanel, drawHealthBar, createMinimap, drawMinimap, n64Palette
10
+
11
+ import { rgba8 } from './api.js';
12
+
13
+ // ─── colour helpers ───────────────────────────────────────────────────────────
14
+
15
+ /** Unpack an rgba8() BigInt into {r,g,b,a} 0-255 floats */
16
+ function _unpack(c) {
17
+ if (typeof c === 'bigint') {
18
+ return {
19
+ r: Number((c >> 48n) & 0xffffn) / 257,
20
+ g: Number((c >> 32n) & 0xffffn) / 257,
21
+ b: Number((c >> 16n) & 0xffffn) / 257,
22
+ a: Number(c & 0xffffn) / 257,
23
+ };
24
+ }
25
+ const bc = BigInt(Math.floor(c));
26
+ return {
27
+ r: Number((bc >> 48n) & 0xffffn) / 257,
28
+ g: Number((bc >> 32n) & 0xffffn) / 257,
29
+ b: Number((bc >> 16n) & 0xffffn) / 257,
30
+ a: Number(bc & 0xffffn) / 257,
31
+ };
32
+ }
33
+
34
+ /** Linearly interpolate two rgba8 colours. t = 0..1 */
35
+ function colorLerp(c1, c2, t) {
36
+ const a = _unpack(c1),
37
+ b = _unpack(c2);
38
+ return rgba8(
39
+ a.r + (b.r - a.r) * t,
40
+ a.g + (b.g - a.g) * t,
41
+ a.b + (b.b - a.b) * t,
42
+ a.a + (b.a - a.a) * t
43
+ );
44
+ }
45
+
46
+ /** Multiply/tint a colour by a brightness factor 0..2 */
47
+ function colorMix(c, factor) {
48
+ const { r, g, b, a } = _unpack(c);
49
+ return rgba8(Math.min(255, r * factor), Math.min(255, g * factor), Math.min(255, b * factor), a);
50
+ }
51
+
52
+ /** Convert 0xRRGGBB hex number to rgba8 */
53
+ function hexColor(hex, alpha = 255) {
54
+ return rgba8((hex >> 16) & 0xff, (hex >> 8) & 0xff, hex & 0xff, alpha);
55
+ }
56
+
57
+ /** Convert HSL(0-360, 0-1, 0-1) to rgba8 */
58
+ function hslColor(h, s = 1, l = 0.5, alpha = 255) {
59
+ h = ((h % 360) + 360) % 360;
60
+ const c = (1 - Math.abs(2 * l - 1)) * s;
61
+ const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
62
+ const m = l - c / 2;
63
+ let r, g, b;
64
+ if (h < 60) {
65
+ r = c;
66
+ g = x;
67
+ b = 0;
68
+ } else if (h < 120) {
69
+ r = x;
70
+ g = c;
71
+ b = 0;
72
+ } else if (h < 180) {
73
+ r = 0;
74
+ g = c;
75
+ b = x;
76
+ } else if (h < 240) {
77
+ r = 0;
78
+ g = x;
79
+ b = c;
80
+ } else if (h < 300) {
81
+ r = x;
82
+ g = 0;
83
+ b = c;
84
+ } else {
85
+ r = c;
86
+ g = 0;
87
+ b = x;
88
+ }
89
+ return rgba8(
90
+ Math.round((r + m) * 255),
91
+ Math.round((g + m) * 255),
92
+ Math.round((b + m) * 255),
93
+ alpha
94
+ );
95
+ }
96
+
97
+ // ─── Math utilities ──────────────────────────────────────────────────────────
98
+
99
+ /** Linear interpolation: lerp(a, b, t) → a + (b - a) * t */
100
+ function lerp(a, b, t) {
101
+ return a + (b - a) * t;
102
+ }
103
+
104
+ /** Clamp value between min and max */
105
+ function clamp(v, min = 0, max = 1) {
106
+ return v < min ? min : v > max ? max : v;
107
+ }
108
+
109
+ /** Random float in [min, max) */
110
+ function randRange(min, max) {
111
+ return min + Math.random() * (max - min);
112
+ }
113
+
114
+ /** Random integer in [min, max] inclusive */
115
+ function randInt(min, max) {
116
+ return (min + Math.random() * (max - min + 1)) | 0;
117
+ }
118
+
119
+ /** Distance between two 2D points */
120
+ function dist(x1, y1, x2, y2) {
121
+ const dx = x2 - x1,
122
+ dy = y2 - y1;
123
+ return Math.sqrt(dx * dx + dy * dy);
124
+ }
125
+
126
+ /** Distance between two 3D points */
127
+ function dist3d(x1, y1, z1, x2, y2, z2) {
128
+ const dx = x2 - x1,
129
+ dy = y2 - y1,
130
+ dz = z2 - z1;
131
+ return Math.sqrt(dx * dx + dy * dy + dz * dz);
132
+ }
133
+
134
+ /** Map a value from one range to another */
135
+ function remap(value, inMin, inMax, outMin, outMax) {
136
+ return outMin + ((value - inMin) * (outMax - outMin)) / (inMax - inMin);
137
+ }
138
+
139
+ /** Smooth pulse: returns 0-1-0 based on time with given frequency */
140
+ function pulse(time, frequency = 1) {
141
+ return Math.sin(time * frequency * Math.PI * 2) * 0.5 + 0.5;
142
+ }
143
+
144
+ /** Convert degrees to radians */
145
+ function deg2rad(d) {
146
+ return (d * Math.PI) / 180;
147
+ }
148
+
149
+ /** Convert radians to degrees */
150
+ function rad2deg(r) {
151
+ return (r * 180) / Math.PI;
152
+ }
153
+
154
+ // ─── Classic N64 / PS1 limited palette ───────────────────────────────────────
155
+ const n64Palette = {
156
+ black: rgba8(0, 0, 0),
157
+ white: rgba8(255, 255, 255),
158
+ red: rgba8(220, 30, 30),
159
+ green: rgba8(30, 200, 60),
160
+ blue: rgba8(30, 80, 220),
161
+ yellow: rgba8(255, 220, 0),
162
+ cyan: rgba8(0, 220, 220),
163
+ magenta: rgba8(200, 0, 200),
164
+ orange: rgba8(255, 140, 0),
165
+ purple: rgba8(120, 0, 200),
166
+ teal: rgba8(0, 160, 160),
167
+ brown: rgba8(140, 80, 30),
168
+ grey: rgba8(128, 128, 128),
169
+ darkGrey: rgba8(60, 60, 60),
170
+ lightGrey: rgba8(200, 200, 200),
171
+ sky: rgba8(70, 130, 200),
172
+ gold: rgba8(255, 210, 50),
173
+ silver: rgba8(192, 192, 210),
174
+ };
175
+
176
+ // ─── actual drawing functions ─────────────────────────────────────────────────
177
+
178
+ export function api2d(gpu) {
179
+ const fb = gpu.getFramebuffer();
180
+ const W = fb.width; // 640
181
+ const H = fb.height; // 360
182
+
183
+ // cached camRef from std api - unused for 2D (no camera offset)
184
+ // const cam = { x: 0, y: 0 };
185
+
186
+ // helper: write a pixel with alpha blending
187
+ function _blend(x, y, r, g, b, a) {
188
+ if (x < 0 || y < 0 || x >= W || y >= H) return;
189
+ if (a >= 255) {
190
+ fb.pset(x, y, r * 257, g * 257, b * 257, 65535);
191
+ return;
192
+ }
193
+ const i = (y * W + x) * 4;
194
+ const p = fb.pixels;
195
+ const af = a / 255;
196
+ const bf = 1 - af;
197
+ p[i] = Math.round(r * af + (p[i] / 257) * bf) * 257;
198
+ p[i + 1] = Math.round(g * af + (p[i + 1] / 257) * bf) * 257;
199
+ p[i + 2] = Math.round(b * af + (p[i + 2] / 257) * bf) * 257;
200
+ p[i + 3] = 65535;
201
+ }
202
+
203
+ /** pset with color as rgba8 bigint */
204
+ function _pset(x, y, color) {
205
+ const c = _unpack(color);
206
+ _blend(x | 0, y | 0, c.r, c.g, c.b, c.a);
207
+ }
208
+
209
+ // ── Gradient fills ──────────────────────────────────────────────────────────
210
+
211
+ /**
212
+ * drawGradient(x, y, w, h, c1, c2, dir)
213
+ * dir: 'v' vertical (top→bottom), 'h' horizontal (left→right), 'd' diagonal
214
+ */
215
+ function drawGradient(x, y, w, h, c1, c2, dir = 'v') {
216
+ x |= 0;
217
+ y |= 0;
218
+ w |= 0;
219
+ h |= 0;
220
+ const a1 = _unpack(c1),
221
+ a2 = _unpack(c2);
222
+ const x1 = Math.max(0, x),
223
+ y1 = Math.max(0, y);
224
+ const x2 = Math.min(W, x + w),
225
+ y2 = Math.min(H, y + h);
226
+ for (let py = y1; py < y2; py++) {
227
+ for (let px = x1; px < x2; px++) {
228
+ let t;
229
+ if (dir === 'h') {
230
+ t = w > 0 ? (px - x) / w : 0;
231
+ } else if (dir === 'd') {
232
+ t = w + h > 0 ? (px - x + (py - y)) / (w + h) : 0;
233
+ } else {
234
+ t = h > 0 ? (py - y) / h : 0;
235
+ }
236
+ t = Math.max(0, Math.min(1, t));
237
+ _blend(
238
+ px,
239
+ py,
240
+ (a1.r + (a2.r - a1.r) * t) | 0,
241
+ (a1.g + (a2.g - a1.g) * t) | 0,
242
+ (a1.b + (a2.b - a1.b) * t) | 0,
243
+ (a1.a + (a2.a - a1.a) * t) | 0
244
+ );
245
+ }
246
+ }
247
+ }
248
+
249
+ /**
250
+ * drawRadialGradient(cx, cy, radius, innerColor, outerColor)
251
+ * Great for glows, halos, spot lights.
252
+ */
253
+ function drawRadialGradient(cx, cy, radius, innerColor, outerColor) {
254
+ cx |= 0;
255
+ cy |= 0;
256
+ radius = radius | 0;
257
+ const ci = _unpack(innerColor),
258
+ co = _unpack(outerColor);
259
+ const x1 = Math.max(0, cx - radius),
260
+ y1 = Math.max(0, cy - radius);
261
+ const x2 = Math.min(W, cx + radius),
262
+ y2 = Math.min(H, cy + radius);
263
+ const r2 = radius * radius;
264
+ for (let py = y1; py < y2; py++) {
265
+ for (let px = x1; px < x2; px++) {
266
+ const dx = px - cx,
267
+ dy = py - cy;
268
+ const d2 = dx * dx + dy * dy;
269
+ if (d2 > r2) continue;
270
+ const t = Math.sqrt(d2) / radius;
271
+ _blend(
272
+ px,
273
+ py,
274
+ (ci.r + (co.r - ci.r) * t) | 0,
275
+ (ci.g + (co.g - ci.g) * t) | 0,
276
+ (ci.b + (co.b - ci.b) * t) | 0,
277
+ (ci.a + (co.a - ci.a) * t) | 0
278
+ );
279
+ }
280
+ }
281
+ }
282
+
283
+ // ── Rounded rect ────────────────────────────────────────────────────────────
284
+
285
+ /**
286
+ * drawRoundedRect(x, y, w, h, radius, color, fill=true)
287
+ * PS1/N64-style rounded panel corners.
288
+ */
289
+ function drawRoundedRect(x, y, w, h, radius, color, fill = true) {
290
+ x |= 0;
291
+ y |= 0;
292
+ w |= 0;
293
+ h |= 0;
294
+ radius = Math.min(radius | 0, Math.min(w, h) >> 1);
295
+ const { r, g, b, a } = _unpack(color);
296
+
297
+ if (fill) {
298
+ // Fill main body (three rects)
299
+ for (let py = y + radius; py < y + h - radius; py++) {
300
+ for (let px = x; px < x + w; px++) _blend(px, py, r, g, b, a);
301
+ }
302
+ for (let py = y; py < y + radius; py++) {
303
+ for (let px = x + radius; px < x + w - radius; px++) _blend(px, py, r, g, b, a);
304
+ }
305
+ for (let py = y + h - radius; py < y + h; py++) {
306
+ for (let px = x + radius; px < x + w - radius; px++) _blend(px, py, r, g, b, a);
307
+ }
308
+ // Fill corners with quarter-circles
309
+ const corners = [
310
+ [x + radius, y + radius],
311
+ [x + w - radius - 1, y + radius],
312
+ [x + radius, y + h - radius - 1],
313
+ [x + w - radius - 1, y + h - radius - 1],
314
+ ];
315
+ for (const [cx, cy] of corners) {
316
+ for (let dy = -radius; dy <= radius; dy++) {
317
+ for (let dx = -radius; dx <= radius; dx++) {
318
+ if (dx * dx + dy * dy <= radius * radius) _blend(cx + dx, cy + dy, r, g, b, a);
319
+ }
320
+ }
321
+ }
322
+ } else {
323
+ // Outline only — top/bottom edges
324
+ for (let px = x + radius; px < x + w - radius; px++) {
325
+ _blend(px, y, r, g, b, a);
326
+ _blend(px, y + h - 1, r, g, b, a);
327
+ }
328
+ // Left/right edges
329
+ for (let py = y + radius; py < y + h - radius; py++) {
330
+ _blend(x, py, r, g, b, a);
331
+ _blend(x + w - 1, py, r, g, b, a);
332
+ }
333
+ // Corners (just the arc outline)
334
+ const corners = [
335
+ [x + radius, y + radius, -1, -1],
336
+ [x + w - radius - 1, y + radius, 1, -1],
337
+ [x + radius, y + h - radius - 1, -1, 1],
338
+ [x + w - radius - 1, y + h - radius - 1, 1, 1],
339
+ ];
340
+ for (const [cx, cy, sx, sy] of corners) {
341
+ for (let angle = 0; angle <= 90; angle += 1) {
342
+ const rad = (angle * Math.PI) / 180;
343
+ const px = (cx + sx * Math.cos(rad) * radius) | 0;
344
+ const py = (cy + sy * Math.sin(rad) * radius) | 0;
345
+ _blend(px, py, r, g, b, a);
346
+ }
347
+ }
348
+ }
349
+ }
350
+
351
+ // ── Polygon ─────────────────────────────────────────────────────────────────
352
+
353
+ /**
354
+ * poly(points, color, fill=true)
355
+ * points: [[x0,y0],[x1,y1],...] — convex or concave polygon
356
+ * Uses scanline fill for filled, Bresenham for outline.
357
+ */
358
+ function poly(points, color, fill = true) {
359
+ if (points.length < 3) return;
360
+ const { r, g, b, a } = _unpack(color);
361
+
362
+ if (fill) {
363
+ const minY = Math.max(0, Math.min(...points.map(p => p[1])) | 0);
364
+ const maxY = Math.min(H - 1, Math.max(...points.map(p => p[1])) | 0);
365
+ for (let py = minY; py <= maxY; py++) {
366
+ const intersections = [];
367
+ const n = points.length;
368
+ for (let i = 0; i < n; i++) {
369
+ const [x0, y0] = points[i];
370
+ const [x1, y1] = points[(i + 1) % n];
371
+ if ((y0 <= py && py < y1) || (y1 <= py && py < y0)) {
372
+ const t = (py - y0) / (y1 - y0);
373
+ intersections.push(x0 + t * (x1 - x0));
374
+ }
375
+ }
376
+ intersections.sort((a, b) => a - b);
377
+ for (let i = 0; i + 1 < intersections.length; i += 2) {
378
+ const lx = Math.max(0, intersections[i] | 0);
379
+ const rx = Math.min(W - 1, intersections[i + 1] | 0);
380
+ for (let px = lx; px <= rx; px++) _blend(px, py, r, g, b, a);
381
+ }
382
+ }
383
+ } else {
384
+ // Outline
385
+ const n = points.length;
386
+ for (let i = 0; i < n; i++) {
387
+ const [x0, y0] = points[i];
388
+ const [x1, y1] = points[(i + 1) % n];
389
+ // Bresenham
390
+ let dx = Math.abs(x1 - x0),
391
+ dy = -Math.abs(y1 - y0);
392
+ let sx = x0 < x1 ? 1 : -1,
393
+ sy = y0 < y1 ? 1 : -1;
394
+ let err = dx + dy,
395
+ px = x0 | 0,
396
+ py = y0 | 0;
397
+ for (;;) {
398
+ _blend(px, py, r, g, b, a);
399
+ if (px === (x1 | 0) && py === (y1 | 0)) break;
400
+ const e2 = 2 * err;
401
+ if (e2 >= dy) {
402
+ err += dy;
403
+ px += sx;
404
+ }
405
+ if (e2 <= dx) {
406
+ err += dx;
407
+ py += sy;
408
+ }
409
+ }
410
+ }
411
+ }
412
+ }
413
+
414
+ /**
415
+ * drawTriangle(x0,y0, x1,y1, x2,y2, color, fill=true)
416
+ */
417
+ function drawTriangle(x0, y0, x1, y1, x2, y2, color, fill = true) {
418
+ poly(
419
+ [
420
+ [x0, y0],
421
+ [x1, y1],
422
+ [x2, y2],
423
+ ],
424
+ color,
425
+ fill
426
+ );
427
+ }
428
+
429
+ /**
430
+ * drawDiamond(cx, cy, halfW, halfH, color, fill=true)
431
+ */
432
+ function drawDiamond(cx, cy, halfW, halfH, color, fill = true) {
433
+ poly(
434
+ [
435
+ [cx, cy - halfH],
436
+ [cx + halfW, cy],
437
+ [cx, cy + halfH],
438
+ [cx - halfW, cy],
439
+ ],
440
+ color,
441
+ fill
442
+ );
443
+ }
444
+
445
+ // ── Text helpers ─────────────────────────────────────────────────────────────
446
+
447
+ // We reach into the gpu's exposed stdApi print via globalThis
448
+ function _print(text, x, y, color, scale) {
449
+ if (typeof globalThis.print === 'function') globalThis.print(text, x, y, color, scale);
450
+ }
451
+
452
+ function measureText(text, scale = 1) {
453
+ // 5px glyph + 1px spacing = 6px per character; 7px tall
454
+ const s = Math.max(1, Math.round(scale));
455
+ return { width: text.length * 6 * s, height: 7 * s };
456
+ }
457
+
458
+ /** printCentered(text, cx, y, color, scale=1) — centre on x */
459
+ function printCentered(text, cx, y, color, scale = 1) {
460
+ const w = measureText(text, scale).width;
461
+ _print(text, (cx - w / 2) | 0, y, color, scale);
462
+ }
463
+
464
+ /** printRight(text, rightX, y, color, scale=1) — right-align */
465
+ function printRight(text, rightX, y, color, scale = 1) {
466
+ const w = measureText(text, scale).width;
467
+ _print(text, (rightX - w) | 0, y, color, scale);
468
+ }
469
+
470
+ /**
471
+ * drawGlowText(text, x, y, color, glowColor, scale=1)
472
+ * Prints text with a soft glow halo by drawing the glowColor in 8 surrounding offsets
473
+ * then the main color on top. Pure N64/Dreamcast HUD aesthetic.
474
+ */
475
+ function drawGlowText(text, x, y, color, glowColor, scale = 1) {
476
+ const offsets = [
477
+ [-1, -1],
478
+ [0, -1],
479
+ [1, -1],
480
+ [-1, 0],
481
+ [1, 0],
482
+ [-1, 1],
483
+ [0, 1],
484
+ [1, 1],
485
+ ];
486
+ for (const [dx, dy] of offsets) {
487
+ _print(text, x + dx, y + dy, glowColor, scale);
488
+ }
489
+ _print(text, x, y, color, scale);
490
+ }
491
+
492
+ /**
493
+ * drawGlowTextCentered(text, cx, y, color, glowColor, scale=1)
494
+ */
495
+ function drawGlowTextCentered(text, cx, y, color, glowColor, scale = 1) {
496
+ const w = measureText(text, scale).width;
497
+ drawGlowText(text, (cx - w / 2) | 0, y, color, glowColor, scale);
498
+ }
499
+
500
+ /**
501
+ * drawPulsingText(text, cx, y, color, time, opts)
502
+ * Centered text that pulses opacity and/or scale over time.
503
+ * opts: { frequency, minAlpha, glowColor, scale }
504
+ */
505
+ function drawPulsingText(text, cx, y, color, time, opts = {}) {
506
+ const freq = opts.frequency ?? 3;
507
+ const minAlpha = opts.minAlpha ?? 120;
508
+ const scale = opts.scale ?? 1;
509
+ const alpha = Math.floor((Math.sin(time * freq) * 0.5 + 0.5) * (255 - minAlpha) + minAlpha);
510
+ const { r, g, b } = _unpack(color);
511
+ const pulsedColor = rgba8(r, g, b, alpha);
512
+ if (opts.glowColor) {
513
+ drawGlowTextCentered(text, cx, y, pulsedColor, opts.glowColor, scale);
514
+ } else {
515
+ printCentered(text, cx, y, pulsedColor, scale);
516
+ }
517
+ }
518
+
519
+ // ── Screen overlays ──────────────────────────────────────────────────────────
520
+
521
+ /**
522
+ * drawScanlines(alpha=80, spacing=2)
523
+ * CRT scanline overlay — horizontal dark bands every `spacing` rows.
524
+ */
525
+ function drawScanlines(alpha = 80, spacing = 2) {
526
+ const p = fb.pixels;
527
+ const af = (255 - alpha) / 255;
528
+ for (let y = 0; y < H; y += spacing) {
529
+ for (let x = 0; x < W; x++) {
530
+ const i = (y * W + x) * 4;
531
+ p[i] = (p[i] * af) | 0;
532
+ p[i + 1] = (p[i + 1] * af) | 0;
533
+ p[i + 2] = (p[i + 2] * af) | 0;
534
+ }
535
+ }
536
+ }
537
+
538
+ /**
539
+ * drawNoise(x, y, w, h, alpha=40, seed=0)
540
+ * Deterministic LCG grain/static overlay. Good for title card grit.
541
+ */
542
+ function drawNoise(x, y, w, h, alpha = 40, seed = 0) {
543
+ x |= 0;
544
+ y |= 0;
545
+ w |= 0;
546
+ h |= 0;
547
+ let s = seed | 12345;
548
+ const x1 = Math.max(0, x),
549
+ y1 = Math.max(0, y);
550
+ const x2 = Math.min(W, x + w),
551
+ y2 = Math.min(H, y + h);
552
+ for (let py = y1; py < y2; py++) {
553
+ for (let px = x1; px < x2; px++) {
554
+ s = (s * 1664525 + 1013904223) & 0xffffffff;
555
+ const n = (s >>> 16) & 0xff;
556
+ _blend(px, py, n, n, n, ((n / 255) * alpha) | 0);
557
+ }
558
+ }
559
+ }
560
+
561
+ /**
562
+ * drawCheckerboard(x, y, w, h, c1, c2, size=8)
563
+ * N64 loading pattern / retro background filler.
564
+ */
565
+ function drawCheckerboard(x, y, w, h, c1, c2, size = 8) {
566
+ x |= 0;
567
+ y |= 0;
568
+ w |= 0;
569
+ h |= 0;
570
+ size |= 0;
571
+ const x1 = Math.max(0, x),
572
+ y1 = Math.max(0, y);
573
+ const x2 = Math.min(W, x + w),
574
+ y2 = Math.min(H, y + h);
575
+ for (let py = y1; py < y2; py++) {
576
+ for (let px = x1; px < x2; px++) {
577
+ const cell = ((((px - x) / size) | 0) + (((py - y) / size) | 0)) % 2;
578
+ _pset(px, py, cell === 0 ? c1 : c2);
579
+ }
580
+ }
581
+ }
582
+
583
+ // ── Progress / HUD bars ───────────────────────────────────────────────────────
584
+
585
+ /**
586
+ * drawProgressBar(x, y, w, h, t, fgColor, bgColor, borderColor)
587
+ * t = 0..1 fill fraction.
588
+ */
589
+ function drawProgressBar(x, y, w, h, t, fgColor, bgColor, borderColor) {
590
+ t = Math.max(0, Math.min(1, t));
591
+ // Background
592
+ if (bgColor !== undefined) {
593
+ const bg = _unpack(bgColor);
594
+ for (let py = y; py < y + h; py++)
595
+ for (let px = x; px < x + w; px++) _blend(px, py, bg.r, bg.g, bg.b, bg.a);
596
+ }
597
+ // Fill
598
+ const fill = (w * t) | 0;
599
+ const fg = _unpack(fgColor);
600
+ for (let py = y; py < y + h; py++)
601
+ for (let px = x; px < x + fill; px++) _blend(px, py, fg.r, fg.g, fg.b, fg.a);
602
+ // Border
603
+ if (borderColor !== undefined) {
604
+ const bc = _unpack(borderColor);
605
+ for (let px = x; px < x + w; px++) {
606
+ _blend(px, y, bc.r, bc.g, bc.b, bc.a);
607
+ _blend(px, y + h - 1, bc.r, bc.g, bc.b, bc.a);
608
+ }
609
+ for (let py = y; py < y + h; py++) {
610
+ _blend(x, py, bc.r, bc.g, bc.b, bc.a);
611
+ _blend(x + w - 1, py, bc.r, bc.g, bc.b, bc.a);
612
+ }
613
+ }
614
+ }
615
+
616
+ /**
617
+ * drawHealthBar(x, y, w, h, current, max, opts)
618
+ * opts: { barColor, backgroundColor, borderColor, dangerColor, dangerThreshold }
619
+ * Auto-switches to dangerColor when hp < dangerThreshold (default 0.25).
620
+ */
621
+ function drawHealthBar(x, y, w, h, current, max, opts = {}) {
622
+ const t = max > 0 ? current / max : 0;
623
+ const danger = opts.dangerThreshold ?? 0.25;
624
+ const fg =
625
+ t < danger ? (opts.dangerColor ?? rgba8(220, 40, 40)) : (opts.barColor ?? rgba8(50, 220, 80));
626
+ drawProgressBar(
627
+ x,
628
+ y,
629
+ w,
630
+ h,
631
+ t,
632
+ fg,
633
+ opts.backgroundColor ?? rgba8(30, 30, 30, 200),
634
+ opts.borderColor ?? rgba8(200, 200, 200, 180)
635
+ );
636
+ }
637
+
638
+ // ── PS1/N64 panel border ──────────────────────────────────────────────────────
639
+
640
+ /**
641
+ * drawPixelBorder(x, y, w, h, lightColor, darkColor, thickness=2)
642
+ * 3D-embossed bevel — PS1/SNES-style UI panel border.
643
+ */
644
+ function drawPixelBorder(x, y, w, h, lightColor, darkColor, thickness = 2) {
645
+ const lc = _unpack(lightColor),
646
+ dc = _unpack(darkColor);
647
+ for (let i = 0; i < thickness; i++) {
648
+ // Top & left (light)
649
+ for (let px = x + i; px < x + w - i; px++) _blend(px, y + i, lc.r, lc.g, lc.b, lc.a);
650
+ for (let py = y + i; py < y + h - i; py++) _blend(x + i, py, lc.r, lc.g, lc.b, lc.a);
651
+ // Bottom & right (dark)
652
+ for (let px = x + i; px < x + w - i; px++) _blend(px, y + h - 1 - i, dc.r, dc.g, dc.b, dc.a);
653
+ for (let py = y + i; py < y + h - i; py++) _blend(x + w - 1 - i, py, dc.r, dc.g, dc.b, dc.a);
654
+ }
655
+ }
656
+
657
+ // ── Decorative shapes ─────────────────────────────────────────────────────────
658
+
659
+ /**
660
+ * drawStarburst(cx, cy, outerR, innerR, spikes, color, fill=true)
661
+ * Classic star shape — great for score popups and collectible icons.
662
+ */
663
+ function drawStarburst(cx, cy, outerR, innerR, spikes, color, fill = true) {
664
+ const pts = [];
665
+ for (let i = 0; i < spikes * 2; i++) {
666
+ const angle = (i * Math.PI) / spikes - Math.PI / 2;
667
+ const r = i % 2 === 0 ? outerR : innerR;
668
+ pts.push([cx + Math.cos(angle) * r, cy + Math.sin(angle) * r]);
669
+ }
670
+ poly(pts, color, fill);
671
+ }
672
+
673
+ /**
674
+ * drawWave(x, y, w, amplitude, frequency, phase, color, thickness=2)
675
+ * Animated sine wave — good for water, plasma, audio visualizers.
676
+ * phase = time * speed for animation.
677
+ */
678
+ function drawWave(x, y, w, amplitude, frequency, phase, color, thickness = 2) {
679
+ const { r, g, b, a } = _unpack(color);
680
+ for (let px = x; px < x + w && px < W; px++) {
681
+ const py = (y + Math.sin((px - x) * frequency + phase) * amplitude) | 0;
682
+ for (let t = -thickness >> 1; t <= thickness >> 1; t++) {
683
+ const ny = py + t;
684
+ if (ny >= 0 && ny < H) _blend(px, ny, r, g, b, a);
685
+ }
686
+ }
687
+ }
688
+
689
+ /**
690
+ * drawSpiral(cx, cy, turns, spacing, color)
691
+ * Decorative Archimedean spiral — psychedelic demoscene / loading screen art.
692
+ */
693
+ function drawSpiral(cx, cy, turns, spacing, color) {
694
+ const { r, g, b, a } = _unpack(color);
695
+ const steps = turns * 360;
696
+ for (let i = 0; i < steps; i++) {
697
+ const angle = (i / 180) * Math.PI;
698
+ const rad = (i / 360) * spacing;
699
+ const px = (cx + Math.cos(angle) * rad) | 0;
700
+ const py = (cy + Math.sin(angle) * rad) | 0;
701
+ if (px >= 0 && px < W && py >= 0 && py < H) _blend(px, py, r, g, b, a);
702
+ }
703
+ }
704
+
705
+ // ── Composite widgets ─────────────────────────────────────────────────────────
706
+
707
+ /**
708
+ * drawPanel(x, y, w, h, opts)
709
+ * A complete PS1/N64-style info panel with background, border, and optional title.
710
+ * opts: { bgColor, borderLight, borderDark, title, titleColor, padding, alpha }
711
+ */
712
+ function drawPanel(x, y, w, h, opts = {}) {
713
+ const bg = opts.bgColor ?? rgba8(10, 10, 30, 200);
714
+ const bl = opts.borderLight ?? rgba8(180, 180, 220, 255);
715
+ const bd = opts.borderDark ?? rgba8(30, 30, 60, 255);
716
+ // opts.alpha available for callers but not used in fill path
717
+
718
+ // Fill panel (re-use opts.bgColor with alpha override)
719
+ const bgc = _unpack(bg);
720
+ for (let py = y + 2; py < y + h - 2; py++)
721
+ for (let px = x + 2; px < x + w - 2; px++) _blend(px, py, bgc.r, bgc.g, bgc.b, bgc.a);
722
+
723
+ // 3D border
724
+ drawPixelBorder(x, y, w, h, bl, bd, 2);
725
+
726
+ // Optional title text at top
727
+ if (opts.title) {
728
+ const tc = opts.titleColor ?? rgba8(255, 255, 255, 255);
729
+ const pad = opts.padding ?? 6;
730
+ printCentered(opts.title, x + w / 2, y + pad, tc);
731
+ }
732
+ }
733
+
734
+ // ── Minimap System ─────────────────────────────────────────────────────────
735
+
736
+ /**
737
+ * createMinimap(opts) — create a reusable minimap configuration.
738
+ *
739
+ * opts:
740
+ * x, y — screen position (default: bottom-right corner)
741
+ * width, height — pixel dimensions (default: 80×80)
742
+ * bgColor — background fill (default: semi-transparent black)
743
+ * borderLight, borderDark — border colours (null to disable border)
744
+ * shape — 'rect' | 'circle' (default: 'rect')
745
+ * follow — entity to center on (object with .x, .y in world coords)
746
+ * worldW, worldH — world-space bounds for coordinate mapping
747
+ * tileW, tileH — if set, map is tile-based (grid cells)
748
+ * tileScale — pixels per tile (default: 2)
749
+ * tiles — fn(tx,ty)→color|null or 2D array; null/0 = skip
750
+ * fogOfWar — if set, only reveal tiles within this radius of follow entity
751
+ * entities — array of { x, y, color, size?, label? }
752
+ * player — shorthand for the player entity { x, y, color?, blink? }
753
+ * sweep — { speed, color } for animated radar sweep line
754
+ * gridLines — number of grid divisions (0 = none)
755
+ * gridColor — colour for grid lines
756
+ *
757
+ * Returns a minimap object you pass to drawMinimap().
758
+ * You can mutate its properties between frames (e.g. update entities).
759
+ */
760
+ function createMinimap(opts = {}) {
761
+ return {
762
+ x: opts.x ?? W - 90,
763
+ y: opts.y ?? 10,
764
+ width: opts.width ?? 80,
765
+ height: opts.height ?? 80,
766
+ bgColor: opts.bgColor ?? rgba8(0, 0, 0, 180),
767
+ borderLight: opts.borderLight !== undefined ? opts.borderLight : rgba8(150, 150, 150),
768
+ borderDark: opts.borderDark !== undefined ? opts.borderDark : rgba8(50, 50, 50),
769
+ shape: opts.shape ?? 'rect',
770
+ follow: opts.follow ?? null,
771
+ worldW: opts.worldW ?? 100,
772
+ worldH: opts.worldH ?? 100,
773
+ tileW: opts.tileW ?? 0,
774
+ tileH: opts.tileH ?? 0,
775
+ tileScale: opts.tileScale ?? 2,
776
+ tiles: opts.tiles ?? null,
777
+ fogOfWar: opts.fogOfWar ?? 0,
778
+ entities: opts.entities ?? [],
779
+ player: opts.player ?? null,
780
+ sweep: opts.sweep ?? null,
781
+ gridLines: opts.gridLines ?? 0,
782
+ gridColor: opts.gridColor ?? rgba8(40, 60, 40, 120),
783
+ };
784
+ }
785
+
786
+ /** Internal: check if pixel is inside a circle */
787
+ function _inCircle(px, py, cx, cy, r) {
788
+ const dx = px - cx,
789
+ dy = py - cy;
790
+ return dx * dx + dy * dy <= r * r;
791
+ }
792
+
793
+ /**
794
+ * drawMinimap(minimap, time?)
795
+ *
796
+ * Accepts EITHER a createMinimap() object, OR the legacy signature:
797
+ * drawMinimap(x, y, size, entities, bgColor)
798
+ *
799
+ * time is optional — only needed for sweep animation and player blinking.
800
+ */
801
+ function drawMinimap(minimapOrX, timeOrY, sizeArg, entitiesArg, bgColorArg) {
802
+ // Legacy compat: drawMinimap(x, y, size, entities, bgColor)
803
+ let mm;
804
+ let time = 0;
805
+ if (typeof minimapOrX === 'number') {
806
+ mm = createMinimap({
807
+ x: minimapOrX,
808
+ y: timeOrY,
809
+ width: sizeArg,
810
+ height: sizeArg,
811
+ worldW: 100,
812
+ worldH: 100,
813
+ bgColor: bgColorArg,
814
+ });
815
+ // Convert legacy entities (they carry worldW/worldH per-entity)
816
+ if (Array.isArray(entitiesArg)) {
817
+ mm.entities = entitiesArg.map(e => ({
818
+ x: e.x,
819
+ y: e.y,
820
+ color: e.color ?? rgba8(255, 255, 255),
821
+ size: 2,
822
+ }));
823
+ // Use first entity's world bounds if provided
824
+ if (entitiesArg.length > 0) {
825
+ mm.worldW = entitiesArg[0].worldW ?? 100;
826
+ mm.worldH = entitiesArg[0].worldH ?? 100;
827
+ }
828
+ }
829
+ } else {
830
+ mm = minimapOrX;
831
+ time = timeOrY ?? 0;
832
+ }
833
+
834
+ const { x, y, width: mw, height: mh, shape } = mm;
835
+ const cx = x + mw / 2,
836
+ cy = y + mh / 2;
837
+ const isCircle = shape === 'circle';
838
+ const radius = Math.min(mw, mh) / 2;
839
+
840
+ // 1. Background fill
841
+ const bgc = _unpack(mm.bgColor);
842
+ for (let py = y; py < y + mh; py++) {
843
+ for (let px = x; px < x + mw; px++) {
844
+ if (isCircle && !_inCircle(px, py, cx, cy, radius)) continue;
845
+ _blend(px, py, bgc.r, bgc.g, bgc.b, bgc.a);
846
+ }
847
+ }
848
+
849
+ // 2. Tile map rendering
850
+ if (mm.tiles && mm.tileW > 0 && mm.tileH > 0) {
851
+ const ts = mm.tileScale;
852
+ const isFunc = typeof mm.tiles === 'function';
853
+ const followTX = mm.follow ? Math.floor(mm.follow.x) : 0;
854
+ const followTY = mm.follow ? Math.floor(mm.follow.y) : 0;
855
+
856
+ for (let ty = 0; ty < mm.tileH; ty++) {
857
+ for (let tx = 0; tx < mm.tileW; tx++) {
858
+ // Fog of war check
859
+ if (mm.fogOfWar > 0 && mm.follow) {
860
+ const dist = Math.abs(tx - followTX) + Math.abs(ty - followTY);
861
+ if (dist > mm.fogOfWar) continue;
862
+ }
863
+
864
+ const tileColor = isFunc ? mm.tiles(tx, ty) : mm.tiles[ty] ? mm.tiles[ty][tx] : null;
865
+ if (!tileColor) continue;
866
+
867
+ const tc = _unpack(tileColor);
868
+ const px0 = x + tx * ts;
869
+ const py0 = y + ty * ts;
870
+ for (let dy = 0; dy < ts; dy++) {
871
+ for (let dx = 0; dx < ts; dx++) {
872
+ const px = px0 + dx,
873
+ py = py0 + dy;
874
+ if (isCircle && !_inCircle(px, py, cx, cy, radius)) continue;
875
+ _blend(px, py, tc.r, tc.g, tc.b, tc.a);
876
+ }
877
+ }
878
+ }
879
+ }
880
+ }
881
+
882
+ // 3. Grid lines
883
+ if (mm.gridLines > 0) {
884
+ const gc = _unpack(mm.gridColor);
885
+ for (let i = 1; i < mm.gridLines; i++) {
886
+ // Vertical line
887
+ const gx = (x + (mw / mm.gridLines) * i) | 0;
888
+ for (let py2 = y; py2 < y + mh; py2++) {
889
+ if (isCircle && !_inCircle(gx, py2, cx, cy, radius)) continue;
890
+ _blend(gx, py2, gc.r, gc.g, gc.b, gc.a);
891
+ }
892
+ // Horizontal line
893
+ const gy = (y + (mh / mm.gridLines) * i) | 0;
894
+ for (let px2 = x; px2 < x + mw; px2++) {
895
+ if (isCircle && !_inCircle(px2, gy, cx, cy, radius)) continue;
896
+ _blend(px2, gy, gc.r, gc.g, gc.b, gc.a);
897
+ }
898
+ }
899
+ }
900
+
901
+ // 4. Radar sweep line
902
+ if (mm.sweep) {
903
+ const angle = time * (mm.sweep.speed ?? 2);
904
+ const sc = _unpack(mm.sweep.color ?? rgba8(0, 255, 0, 100));
905
+ const sx = Math.cos(angle) * radius;
906
+ const sy = Math.sin(angle) * radius;
907
+ // Bresenham-ish sweep from center
908
+ const steps = Math.max(Math.abs(sx), Math.abs(sy)) | 0;
909
+ if (steps > 0) {
910
+ for (let s = 0; s <= steps; s++) {
911
+ const t = s / steps;
912
+ const px = (cx + sx * t) | 0;
913
+ const py = (cy + sy * t) | 0;
914
+ if (px < x || px >= x + mw || py < y || py >= y + mh) continue;
915
+ if (isCircle && !_inCircle(px, py, cx, cy, radius)) continue;
916
+ _blend(px, py, sc.r, sc.g, sc.b, sc.a);
917
+ }
918
+ }
919
+ }
920
+
921
+ // Helper: convert world coords to screen pixel
922
+ function worldToScreen(wx, wy) {
923
+ if (mm.tiles && mm.tileW > 0) {
924
+ // Tile-based — wx/wy are tile coords
925
+ return [(x + wx * mm.tileScale) | 0, (y + wy * mm.tileScale) | 0];
926
+ }
927
+ // World-space normalised
928
+ let nx, ny;
929
+ if (mm.follow) {
930
+ // Center on follow entity
931
+ nx = 0.5 + (wx - mm.follow.x) / mm.worldW;
932
+ ny = 0.5 + (wy - mm.follow.y) / mm.worldH;
933
+ } else {
934
+ nx = wx / mm.worldW;
935
+ ny = wy / mm.worldH;
936
+ }
937
+ return [(x + nx * mw) | 0, (y + ny * mh) | 0];
938
+ }
939
+
940
+ // 5. Entity dots
941
+ for (const e of mm.entities) {
942
+ const [ex, ey] = worldToScreen(e.x, e.y);
943
+ const dotSize = e.size ?? 2;
944
+ if (ex < x - dotSize || ex >= x + mw + dotSize || ey < y - dotSize || ey >= y + mh + dotSize)
945
+ continue;
946
+ const ec = _unpack(e.color ?? rgba8(255, 255, 255));
947
+ const half = (dotSize / 2) | 0;
948
+ for (let dy = -half; dy < dotSize - half; dy++) {
949
+ for (let dx = -half; dx < dotSize - half; dx++) {
950
+ const px = ex + dx,
951
+ py = ey + dy;
952
+ if (px < x || px >= x + mw || py < y || py >= y + mh) continue;
953
+ if (isCircle && !_inCircle(px, py, cx, cy, radius)) continue;
954
+ _blend(px, py, ec.r, ec.g, ec.b, ec.a);
955
+ }
956
+ }
957
+ }
958
+
959
+ // 6. Player marker (with optional blink)
960
+ if (mm.player) {
961
+ const blink = mm.player.blink !== false;
962
+ const visible = !blink || Math.sin(time * 8) > 0;
963
+ if (visible) {
964
+ const [px, py] = worldToScreen(mm.player.x, mm.player.y);
965
+ const pc = _unpack(mm.player.color ?? rgba8(50, 150, 255));
966
+ const ps = mm.player.size ?? 3;
967
+ const half = (ps / 2) | 0;
968
+ for (let dy = -half; dy < ps - half; dy++) {
969
+ for (let dx = -half; dx < ps - half; dx++) {
970
+ const ppx = px + dx,
971
+ ppy = py + dy;
972
+ if (ppx < x || ppx >= x + mw || ppy < y || ppy >= y + mh) continue;
973
+ if (isCircle && !_inCircle(ppx, ppy, cx, cy, radius)) continue;
974
+ _blend(ppx, ppy, pc.r, pc.g, pc.b, pc.a);
975
+ }
976
+ }
977
+ }
978
+ }
979
+
980
+ // 7. Border
981
+ if (mm.borderLight !== null) {
982
+ if (isCircle) {
983
+ // Circle border — draw a ring
984
+ const bc = _unpack(mm.borderLight);
985
+ const r2inner = (radius - 1) * (radius - 1);
986
+ const r2outer = radius * radius;
987
+ for (let py = y; py < y + mh; py++) {
988
+ for (let px = x; px < x + mw; px++) {
989
+ const dx = px - cx,
990
+ dy = py - cy;
991
+ const d2 = dx * dx + dy * dy;
992
+ if (d2 >= r2inner && d2 <= r2outer) {
993
+ _blend(px, py, bc.r, bc.g, bc.b, bc.a);
994
+ }
995
+ }
996
+ }
997
+ } else {
998
+ drawPixelBorder(x, y, mw, mh, mm.borderLight, mm.borderDark, 1);
999
+ }
1000
+ }
1001
+ }
1002
+
1003
+ /**
1004
+ * scrollingText(text, y, speed, time, color, scale=1, width=640)
1005
+ * Marquee text scrolling left across the screen.
1006
+ * time = accumulated seconds (from novaStore or cart-local variable).
1007
+ */
1008
+ function scrollingText(text, y, speed, time, color, scale = 1, width = 640) {
1009
+ const tw = measureText(text, scale).width;
1010
+ const x = (width - ((time * speed) % (width + tw))) | 0;
1011
+ _print(text, x, y, color, scale);
1012
+ }
1013
+
1014
+ /**
1015
+ * drawFloatingTexts(system, offsetX?, offsetY?)
1016
+ * Render all active texts from a createFloatingTextSystem().
1017
+ * offsetX/offsetY allow camera offset (for screen shake, etc.)
1018
+ */
1019
+ function drawFloatingTexts(system, offsetX = 0, offsetY = 0) {
1020
+ const texts = system.getTexts();
1021
+ for (const t of texts) {
1022
+ const alpha = Math.min(255, Math.floor((t.timer / t.maxTimer) * 255));
1023
+ const r = (t.color >> 16) & 0xff;
1024
+ const g = (t.color >> 8) & 0xff;
1025
+ const b = t.color & 0xff;
1026
+ _print(t.text, (t.x + offsetX) | 0, (t.y + offsetY) | 0, rgba8(r, g, b, alpha), t.scale);
1027
+ }
1028
+ }
1029
+
1030
+ /**
1031
+ * drawFloatingTexts3D(system, projectFn)
1032
+ * Render 3D floating texts using a user-supplied projection function.
1033
+ * projectFn(x, y, z) should return [screenX, screenY].
1034
+ * Use with spawn(..., { z: worldZ }) for 3D world-space floating texts.
1035
+ */
1036
+ function drawFloatingTexts3D(system, projectFn) {
1037
+ const texts = system.getTexts();
1038
+ for (const t of texts) {
1039
+ const [sx, sy] = projectFn(t.x, t.y, t.z ?? 0);
1040
+ const alpha = Math.min(255, Math.floor((t.timer / t.maxTimer) * 255));
1041
+ const r = (t.color >> 16) & 0xff;
1042
+ const g = (t.color >> 8) & 0xff;
1043
+ const b = t.color & 0xff;
1044
+ _print(t.text, sx | 0, sy | 0, rgba8(r, g, b, alpha), t.scale);
1045
+ }
1046
+ }
1047
+
1048
+ // ── Full-screen helpers ───────────────────────────────────────────────────────
1049
+
1050
+ /** Full-screen vertical gradient — great for sky/title backgrounds */
1051
+ function drawSkyGradient(topColor, bottomColor) {
1052
+ drawGradient(0, 0, W, H, topColor, bottomColor, 'v');
1053
+ }
1054
+
1055
+ /** Full-screen flash — use alpha 0..255 for a fade effect */
1056
+ function drawFlash(color) {
1057
+ const { r, g, b, a } = _unpack(color);
1058
+ for (let py = 0; py < H; py++) for (let px = 0; px < W; px++) _blend(px, py, r, g, b, a);
1059
+ }
1060
+
1061
+ /**
1062
+ * Draw a crosshair / reticle at (cx, cy).
1063
+ * @param {number} cx - centre X in screen pixels
1064
+ * @param {number} cy - centre Y in screen pixels
1065
+ * @param {number} [size=8] - half-length of each arm in pixels
1066
+ * @param {number} [color=0xffffffff] - rgba8 or 0xRRGGBB colour
1067
+ * @param {'cross'|'dot'|'circle'} [style='cross'] - reticle style
1068
+ */
1069
+ function drawCrosshair(cx, cy, size = 8, color = 0xffffffff, style = 'cross') {
1070
+ if (style === 'cross' || style === 'dot') {
1071
+ // Horizontal arm
1072
+ for (let x = cx - size; x <= cx + size; x++) _pset(x, cy, color);
1073
+ // Vertical arm
1074
+ for (let y = cy - size; y <= cy + size; y++) _pset(cx, y, color);
1075
+ }
1076
+ if (style === 'circle') {
1077
+ // Thin circle
1078
+ const steps = Math.max(32, size * 4);
1079
+ for (let i = 0; i < steps; i++) {
1080
+ const a = (i / steps) * Math.PI * 2;
1081
+ _pset(Math.round(cx + Math.cos(a) * size), Math.round(cy + Math.sin(a) * size), color);
1082
+ }
1083
+ }
1084
+ if (style === 'dot') {
1085
+ // Centre dot (extra pixels around the intersection)
1086
+ _pset(cx - 1, cy, color);
1087
+ _pset(cx + 1, cy, color);
1088
+ _pset(cx, cy - 1, color);
1089
+ _pset(cx, cy + 1, color);
1090
+ }
1091
+ }
1092
+
1093
+ // ─── expose ──────────────────────────────────────────────────────────────────
1094
+ return {
1095
+ exposeTo(target) {
1096
+ Object.assign(target, {
1097
+ // Colour helpers
1098
+ colorLerp,
1099
+ colorMix,
1100
+ hexColor,
1101
+ hslColor,
1102
+ n64Palette,
1103
+
1104
+ // Math utilities
1105
+ lerp,
1106
+ clamp,
1107
+ randRange,
1108
+ randInt,
1109
+ dist,
1110
+ dist3d,
1111
+ remap,
1112
+ pulse,
1113
+ deg2rad,
1114
+ rad2deg,
1115
+
1116
+ // Gradient fills
1117
+ drawGradient,
1118
+ drawRadialGradient,
1119
+ drawSkyGradient,
1120
+ drawFlash,
1121
+
1122
+ // Shapes
1123
+ drawRoundedRect,
1124
+ poly,
1125
+ drawTriangle,
1126
+ drawDiamond,
1127
+ drawStarburst,
1128
+ drawWave,
1129
+ drawSpiral,
1130
+ drawCheckerboard,
1131
+
1132
+ // Text
1133
+ measureText,
1134
+ printCentered,
1135
+ printRight,
1136
+ drawGlowText,
1137
+ drawGlowTextCentered,
1138
+ drawPulsingText,
1139
+
1140
+ // Overlays
1141
+ drawScanlines,
1142
+ drawNoise,
1143
+
1144
+ // HUD Widgets
1145
+ drawProgressBar,
1146
+ drawHealthBar,
1147
+ drawPixelBorder,
1148
+ drawPanel,
1149
+ drawCrosshair,
1150
+ createMinimap,
1151
+ drawMinimap,
1152
+ drawFloatingTexts,
1153
+ drawFloatingTexts3D,
1154
+ scrollingText,
1155
+ });
1156
+ },
1157
+ };
1158
+ }