nova64 0.2.1

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 (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +786 -0
  3. package/index.html +651 -0
  4. package/package.json +255 -0
  5. package/public/os9-shell/assets/index-B1Uvacma.js +32825 -0
  6. package/public/os9-shell/assets/index-B1Uvacma.js.map +1 -0
  7. package/public/os9-shell/assets/index-DIHfrTaW.css +1 -0
  8. package/public/os9-shell/index.html +14 -0
  9. package/public/os9-shell/nova-icon.svg +12 -0
  10. package/runtime/api-2d.js +878 -0
  11. package/runtime/api-3d/camera.js +73 -0
  12. package/runtime/api-3d/instancing.js +180 -0
  13. package/runtime/api-3d/lights.js +51 -0
  14. package/runtime/api-3d/materials.js +47 -0
  15. package/runtime/api-3d/models.js +84 -0
  16. package/runtime/api-3d/pbr.js +69 -0
  17. package/runtime/api-3d/primitives.js +304 -0
  18. package/runtime/api-3d/scene.js +169 -0
  19. package/runtime/api-3d/transforms.js +161 -0
  20. package/runtime/api-3d.js +154 -0
  21. package/runtime/api-effects.js +753 -0
  22. package/runtime/api-presets.js +85 -0
  23. package/runtime/api-skybox.js +178 -0
  24. package/runtime/api-sprites.js +100 -0
  25. package/runtime/api-voxel.js +601 -0
  26. package/runtime/api.js +201 -0
  27. package/runtime/assets.js +27 -0
  28. package/runtime/audio.js +114 -0
  29. package/runtime/collision.js +47 -0
  30. package/runtime/console.js +101 -0
  31. package/runtime/editor.js +233 -0
  32. package/runtime/font.js +233 -0
  33. package/runtime/framebuffer.js +28 -0
  34. package/runtime/fullscreen-button.js +185 -0
  35. package/runtime/gpu-canvas2d.js +47 -0
  36. package/runtime/gpu-threejs.js +639 -0
  37. package/runtime/gpu-webgl2.js +310 -0
  38. package/runtime/index.js +22 -0
  39. package/runtime/input.js +225 -0
  40. package/runtime/logger.js +60 -0
  41. package/runtime/physics.js +101 -0
  42. package/runtime/screens.js +213 -0
  43. package/runtime/storage.js +38 -0
  44. package/runtime/store.js +151 -0
  45. package/runtime/textinput.js +68 -0
  46. package/runtime/ui/buttons.js +124 -0
  47. package/runtime/ui/panels.js +105 -0
  48. package/runtime/ui/text.js +86 -0
  49. package/runtime/ui/widgets.js +141 -0
  50. package/runtime/ui.js +111 -0
  51. package/src/main.js +474 -0
  52. package/vite.config.js +63 -0
@@ -0,0 +1,878 @@
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, 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
+ /**
735
+ * drawMinimap(x, y, size, entities, bgColor)
736
+ * entities: [{ x, y, color, worldW, worldH }] — dot map for players/enemies/items.
737
+ * worldW/worldH are the world-space bounds to normalise against.
738
+ */
739
+ function drawMinimap(x, y, size, entities, bgColor) {
740
+ const bg = bgColor ?? rgba8(0, 0, 0, 180);
741
+ const bgc = _unpack(bg);
742
+ // Fill minimap bg
743
+ for (let py = y; py < y + size; py++)
744
+ for (let px = x; px < x + size; px++) _blend(px, py, bgc.r, bgc.g, bgc.b, bgc.a);
745
+ // Border
746
+ drawPixelBorder(x, y, size, size, rgba8(150, 150, 150), rgba8(50, 50, 50), 1);
747
+ // Dots
748
+ for (const e of entities) {
749
+ const ww = e.worldW ?? 100,
750
+ wh = e.worldH ?? 100;
751
+ const dx = (x + (e.x / ww) * size) | 0;
752
+ const dy = (y + (e.y / wh) * size) | 0;
753
+ const ec = _unpack(e.color ?? rgba8(255, 255, 255));
754
+ // 2x2 dot
755
+ for (let oy = 0; oy < 2; oy++)
756
+ for (let ox = 0; ox < 2; ox++) _blend(dx + ox, dy + oy, ec.r, ec.g, ec.b, ec.a);
757
+ }
758
+ }
759
+
760
+ /**
761
+ * scrollingText(text, y, speed, time, color, scale=1, width=640)
762
+ * Marquee text scrolling left across the screen.
763
+ * time = accumulated seconds (from novaStore or cart-local variable).
764
+ */
765
+ function scrollingText(text, y, speed, time, color, scale = 1, width = 640) {
766
+ const tw = measureText(text, scale).width;
767
+ const x = (width - ((time * speed) % (width + tw))) | 0;
768
+ _print(text, x, y, color, scale);
769
+ }
770
+
771
+ // ── Full-screen helpers ───────────────────────────────────────────────────────
772
+
773
+ /** Full-screen vertical gradient — great for sky/title backgrounds */
774
+ function drawSkyGradient(topColor, bottomColor) {
775
+ drawGradient(0, 0, W, H, topColor, bottomColor, 'v');
776
+ }
777
+
778
+ /** Full-screen flash — use alpha 0..255 for a fade effect */
779
+ function drawFlash(color) {
780
+ const { r, g, b, a } = _unpack(color);
781
+ for (let py = 0; py < H; py++) for (let px = 0; px < W; px++) _blend(px, py, r, g, b, a);
782
+ }
783
+
784
+ /**
785
+ * Draw a crosshair / reticle at (cx, cy).
786
+ * @param {number} cx - centre X in screen pixels
787
+ * @param {number} cy - centre Y in screen pixels
788
+ * @param {number} [size=8] - half-length of each arm in pixels
789
+ * @param {number} [color=0xffffffff] - rgba8 or 0xRRGGBB colour
790
+ * @param {'cross'|'dot'|'circle'} [style='cross'] - reticle style
791
+ */
792
+ function drawCrosshair(cx, cy, size = 8, color = 0xffffffff, style = 'cross') {
793
+ if (style === 'cross' || style === 'dot') {
794
+ // Horizontal arm
795
+ for (let x = cx - size; x <= cx + size; x++) _pset(x, cy, color);
796
+ // Vertical arm
797
+ for (let y = cy - size; y <= cy + size; y++) _pset(cx, y, color);
798
+ }
799
+ if (style === 'circle') {
800
+ // Thin circle
801
+ const steps = Math.max(32, size * 4);
802
+ for (let i = 0; i < steps; i++) {
803
+ const a = (i / steps) * Math.PI * 2;
804
+ _pset(Math.round(cx + Math.cos(a) * size), Math.round(cy + Math.sin(a) * size), color);
805
+ }
806
+ }
807
+ if (style === 'dot') {
808
+ // Centre dot (extra pixels around the intersection)
809
+ _pset(cx - 1, cy, color);
810
+ _pset(cx + 1, cy, color);
811
+ _pset(cx, cy - 1, color);
812
+ _pset(cx, cy + 1, color);
813
+ }
814
+ }
815
+
816
+ // ─── expose ──────────────────────────────────────────────────────────────────
817
+ return {
818
+ exposeTo(target) {
819
+ Object.assign(target, {
820
+ // Colour helpers
821
+ colorLerp,
822
+ colorMix,
823
+ hexColor,
824
+ hslColor,
825
+ n64Palette,
826
+
827
+ // Math utilities
828
+ lerp,
829
+ clamp,
830
+ randRange,
831
+ randInt,
832
+ dist,
833
+ dist3d,
834
+ remap,
835
+ pulse,
836
+ deg2rad,
837
+ rad2deg,
838
+
839
+ // Gradient fills
840
+ drawGradient,
841
+ drawRadialGradient,
842
+ drawSkyGradient,
843
+ drawFlash,
844
+
845
+ // Shapes
846
+ drawRoundedRect,
847
+ poly,
848
+ drawTriangle,
849
+ drawDiamond,
850
+ drawStarburst,
851
+ drawWave,
852
+ drawSpiral,
853
+ drawCheckerboard,
854
+
855
+ // Text
856
+ measureText,
857
+ printCentered,
858
+ printRight,
859
+ drawGlowText,
860
+ drawGlowTextCentered,
861
+ drawPulsingText,
862
+
863
+ // Overlays
864
+ drawScanlines,
865
+ drawNoise,
866
+
867
+ // HUD Widgets
868
+ drawProgressBar,
869
+ drawHealthBar,
870
+ drawPixelBorder,
871
+ drawPanel,
872
+ drawCrosshair,
873
+ drawMinimap,
874
+ scrollingText,
875
+ });
876
+ },
877
+ };
878
+ }