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,101 @@
1
+ // runtime/physics.js
2
+
3
+ export function physicsApi() {
4
+ const bodies = new Set();
5
+ let gravity = 500; // px/s^2
6
+ let tileSize = 8;
7
+ let solidFn = (_tx, _ty) => false;
8
+
9
+ function setGravity(g) {
10
+ gravity = g;
11
+ }
12
+ function setTileSize(ts) {
13
+ tileSize = ts | 0;
14
+ }
15
+ function setTileSolidFn(fn) {
16
+ solidFn = fn;
17
+ }
18
+ const setCollisionMap = setTileSolidFn; // friendlier alias
19
+
20
+ function createBody(x, y, w, h, opts = {}) {
21
+ const b = Object.assign(
22
+ { x, y, w, h, vx: 0, vy: 0, restitution: 0, friction: 0.9, onGround: false },
23
+ opts
24
+ );
25
+ bodies.add(b);
26
+ return b;
27
+ }
28
+ function destroyBody(b) {
29
+ bodies.delete(b);
30
+ }
31
+
32
+ function _sweepAABB(b, dt) {
33
+ // Integrate velocity
34
+ b.vy += gravity * dt;
35
+ let nx = b.x + b.vx * dt;
36
+ let ny = b.y + b.vy * dt;
37
+ b.onGround = false;
38
+
39
+ // Resolve X axis
40
+ const xdir = Math.sign(b.vx);
41
+ if (xdir !== 0) {
42
+ const ahead = xdir > 0 ? nx + b.w : nx;
43
+ const top = Math.floor(b.y / tileSize);
44
+ const bottom = Math.floor((b.y + b.h - 1) / tileSize);
45
+ for (let ty = top; ty <= bottom; ty++) {
46
+ const tx = Math.floor(ahead / tileSize);
47
+ if (solidFn(tx, ty)) {
48
+ if (xdir > 0) nx = tx * tileSize - b.w - 0.01;
49
+ else nx = (tx + 1) * tileSize + 0.01;
50
+ b.vx = -b.vx * b.restitution;
51
+ break;
52
+ }
53
+ }
54
+ }
55
+
56
+ // Resolve Y axis
57
+ const ydir = Math.sign(b.vy);
58
+ if (ydir !== 0) {
59
+ const ahead = ydir > 0 ? ny + b.h : ny;
60
+ const left = Math.floor(nx / tileSize);
61
+ const right = Math.floor((nx + b.w - 1) / tileSize);
62
+ for (let tx = left; tx <= right; tx++) {
63
+ const ty = Math.floor(ahead / tileSize);
64
+ if (solidFn(tx, ty)) {
65
+ if (ydir > 0) {
66
+ ny = ty * tileSize - b.h - 0.01;
67
+ b.onGround = true;
68
+ } else {
69
+ ny = (ty + 1) * tileSize + 0.01;
70
+ }
71
+ b.vy = -b.vy * b.restitution;
72
+ break;
73
+ }
74
+ }
75
+ }
76
+
77
+ // Friction on ground
78
+ if (b.onGround) b.vx *= b.friction;
79
+
80
+ b.x = nx;
81
+ b.y = ny;
82
+ }
83
+
84
+ function step(dt) {
85
+ for (const b of bodies) _sweepAABB(b, dt);
86
+ }
87
+
88
+ return {
89
+ exposeTo(target) {
90
+ Object.assign(target, {
91
+ createBody,
92
+ destroyBody,
93
+ stepPhysics: step,
94
+ setGravity,
95
+ setTileSize,
96
+ setTileSolidFn,
97
+ setCollisionMap,
98
+ });
99
+ },
100
+ };
101
+ }
@@ -0,0 +1,213 @@
1
+ // runtime/screens.js
2
+ // Nova64 Screen Management System
3
+ // Screen state machine with animated transitions (fade, slide-left, slide-right, wipe)
4
+ import { logger } from './logger.js';
5
+
6
+ export class ScreenManager {
7
+ constructor() {
8
+ this.screens = new Map();
9
+ this.currentScreen = null;
10
+ this.defaultScreen = null;
11
+ // Transition state
12
+ this._tr = null; // { type, progress, duration, fromName, toName, data, onEnd }
13
+ }
14
+
15
+ addScreen(name, screenDefinition) {
16
+ if (typeof screenDefinition === 'function') {
17
+ const Cls = screenDefinition;
18
+ this.screens.set(name, new Cls());
19
+ } else {
20
+ this.screens.set(name, screenDefinition);
21
+ }
22
+ if (!this.defaultScreen) this.defaultScreen = name;
23
+ return this;
24
+ }
25
+
26
+ // Immediate switch (no animation)
27
+ switchTo(screenName, data = {}) {
28
+ const screen = this.screens.get(screenName);
29
+ if (!screen) {
30
+ logger.warn("Screen '" + screenName + "' not found");
31
+ return false;
32
+ }
33
+ if (this.currentScreen) {
34
+ const c = this.screens.get(this.currentScreen);
35
+ if (c && typeof c.exit === 'function') c.exit();
36
+ }
37
+ this.currentScreen = screenName;
38
+ if (typeof screen.enter === 'function') screen.enter(data);
39
+ return true;
40
+ }
41
+
42
+ // Animated transition
43
+ // type: 'fade' | 'slide-left' | 'slide-right' | 'wipe' | 'instant'
44
+ transitionTo(screenName, type = 'fade', duration = 0.4, data = {}) {
45
+ if (this._tr && this._tr.active) return;
46
+ const toScreen = this.screens.get(screenName);
47
+ if (!toScreen) {
48
+ logger.warn("Screen '" + screenName + "' not found");
49
+ return;
50
+ }
51
+ this._tr = {
52
+ active: true,
53
+ type,
54
+ duration,
55
+ progress: 0,
56
+ fromName: this.currentScreen,
57
+ toName: screenName,
58
+ data,
59
+ onEnd: null,
60
+ };
61
+ if (typeof toScreen.enter === 'function') toScreen.enter(data);
62
+ }
63
+
64
+ onTransitionEnd(cb) {
65
+ if (this._tr) this._tr.onEnd = cb;
66
+ }
67
+
68
+ start(screenName = null) {
69
+ this.switchTo(screenName || this.defaultScreen);
70
+ }
71
+
72
+ update(dt) {
73
+ if (this._tr && this._tr.active) {
74
+ this._tr.progress += dt / this._tr.duration;
75
+ if (this._tr.progress >= 1) {
76
+ this._tr.progress = 1;
77
+ this._finishTransition();
78
+ }
79
+ const from = this._tr && this._tr.fromName ? this.screens.get(this._tr.fromName) : null;
80
+ const to = this._tr ? this.screens.get(this._tr.toName) : null;
81
+ if (from && typeof from.update === 'function') from.update(dt);
82
+ if (to && typeof to.update === 'function') to.update(dt);
83
+ return;
84
+ }
85
+ if (this.currentScreen) {
86
+ const s = this.screens.get(this.currentScreen);
87
+ if (s && typeof s.update === 'function') s.update(dt);
88
+ }
89
+ }
90
+
91
+ _finishTransition() {
92
+ const tr = this._tr;
93
+ if (tr.fromName) {
94
+ const old = this.screens.get(tr.fromName);
95
+ if (old && typeof old.exit === 'function') old.exit();
96
+ }
97
+ this.currentScreen = tr.toName;
98
+ this._tr = null;
99
+ if (typeof tr.onEnd === 'function') tr.onEnd();
100
+ }
101
+
102
+ draw() {
103
+ if (this._tr && this._tr.active) {
104
+ this._drawTransition();
105
+ return;
106
+ }
107
+ if (this.currentScreen) {
108
+ const s = this.screens.get(this.currentScreen);
109
+ if (s && typeof s.draw === 'function') s.draw();
110
+ }
111
+ }
112
+
113
+ _drawTransition() {
114
+ const { type, progress, fromName, toName } = this._tr;
115
+ const from = fromName ? this.screens.get(fromName) : null;
116
+ const to = this.screens.get(toName);
117
+ const t = Math.max(0, Math.min(1, progress));
118
+ const e = t * t * (3 - 2 * t); // smoothstep
119
+
120
+ if (type === 'fade') {
121
+ // Cross-fade via black
122
+ const midBlack = t < 0.5 ? Math.round(e * 510) : 0;
123
+ if (t < 0.5) {
124
+ if (from && typeof from.draw === 'function') from.draw();
125
+ if (midBlack > 0 && typeof globalThis.rect === 'function')
126
+ globalThis.rect(0, 0, 640, 360, globalThis.rgba8(0, 0, 0, Math.min(255, midBlack)), true);
127
+ } else {
128
+ if (to && typeof to.draw === 'function') to.draw();
129
+ const fadeIn = Math.round((1 - e) * 510);
130
+ if (fadeIn > 0 && typeof globalThis.rect === 'function')
131
+ globalThis.rect(0, 0, 640, 360, globalThis.rgba8(0, 0, 0, Math.min(255, fadeIn)), true);
132
+ }
133
+ } else if (type === 'slide-left' || type === 'slide-right') {
134
+ const dir = type === 'slide-left' ? -1 : 1;
135
+ const off = Math.round(e * 640);
136
+ const setC = typeof globalThis.setCamera === 'function' ? globalThis.setCamera : null;
137
+ if (from && typeof from.draw === 'function') {
138
+ if (setC) setC(dir * off, 0);
139
+ from.draw();
140
+ }
141
+ if (to && typeof to.draw === 'function') {
142
+ if (setC) setC(dir * off - dir * 640, 0);
143
+ to.draw();
144
+ }
145
+ if (setC) setC(0, 0);
146
+ } else if (type === 'wipe') {
147
+ if (to && typeof to.draw === 'function') to.draw();
148
+ if (from && typeof from.draw === 'function') {
149
+ const wipeX = Math.round(e * 640);
150
+ if (typeof globalThis.setCamera === 'function') globalThis.setCamera(wipeX, 0);
151
+ from.draw();
152
+ if (typeof globalThis.setCamera === 'function') globalThis.setCamera(0, 0);
153
+ }
154
+ } else {
155
+ if (to && typeof to.draw === 'function') to.draw();
156
+ }
157
+ }
158
+
159
+ getCurrentScreen() {
160
+ return this.currentScreen;
161
+ }
162
+ getCurrentScreenObject() {
163
+ return this.currentScreen ? this.screens.get(this.currentScreen) : null;
164
+ }
165
+ isTransitioning() {
166
+ return !!(this._tr && this._tr.active);
167
+ }
168
+
169
+ reset() {
170
+ if (this.currentScreen) {
171
+ const s = this.screens.get(this.currentScreen);
172
+ if (s && typeof s.exit === 'function') s.exit();
173
+ }
174
+ this.screens = new Map();
175
+ this.currentScreen = null;
176
+ this.defaultScreen = null;
177
+ this._tr = null;
178
+ }
179
+ }
180
+
181
+ // Base Screen class for class-based patterns
182
+ export class Screen {
183
+ constructor() {
184
+ this.data = {};
185
+ }
186
+ enter(data = {}) {
187
+ this.data = { ...this.data, ...data };
188
+ }
189
+ exit() {}
190
+ update(_dt) {}
191
+ draw() {}
192
+ }
193
+
194
+ // Factory
195
+ export function screenApi() {
196
+ const manager = new ScreenManager();
197
+ return {
198
+ manager,
199
+ exposeTo(target) {
200
+ target.ScreenManager = ScreenManager;
201
+ target.Screen = Screen;
202
+ target.screens = manager;
203
+ target.addScreen = (name, def) => manager.addScreen(name, def);
204
+ target.switchToScreen = (name, data) => manager.switchTo(name, data);
205
+ target.switchScreen = (name, data) => manager.switchTo(name, data);
206
+ target.transitionTo = (name, type, dur, data) => manager.transitionTo(name, type, dur, data);
207
+ target.onTransitionEnd = cb => manager.onTransitionEnd(cb);
208
+ target.isTransitioning = () => manager.isTransitioning();
209
+ target.getCurrentScreen = () => manager.getCurrentScreen();
210
+ target.startScreens = initial => manager.start(initial);
211
+ },
212
+ };
213
+ }
@@ -0,0 +1,38 @@
1
+ // runtime/storage.js
2
+ export function storageApi(namespace = 'nova64') {
3
+ function _k(k) {
4
+ return namespace + ':' + k;
5
+ }
6
+ function saveJSON(key, obj) {
7
+ try {
8
+ localStorage.setItem(_k(key), JSON.stringify(obj));
9
+ return true;
10
+ } catch (e) {
11
+ return false;
12
+ }
13
+ }
14
+ function loadJSON(key, fallback = null) {
15
+ try {
16
+ const s = localStorage.getItem(_k(key));
17
+ return s ? JSON.parse(s) : fallback;
18
+ } catch (e) {
19
+ return fallback;
20
+ }
21
+ }
22
+ function remove(key) {
23
+ try {
24
+ localStorage.removeItem(_k(key));
25
+ } catch (e) {
26
+ /* ignore */
27
+ }
28
+ }
29
+ // Canonical names match docs (saveData/loadData) — saveJSON/loadJSON kept as aliases
30
+ const saveData = saveJSON;
31
+ const loadData = loadJSON;
32
+ const deleteData = remove;
33
+ return {
34
+ exposeTo(target) {
35
+ Object.assign(target, { saveData, loadData, deleteData, saveJSON, loadJSON, remove });
36
+ },
37
+ };
38
+ }
@@ -0,0 +1,151 @@
1
+ // runtime/store.js
2
+ // Zustand-powered reactive game state for Nova64 carts
3
+ import { logger } from './logger.js';
4
+ // Usage from any cart:
5
+ // const store = createGameStore({ hp: 100, score: 0 });
6
+ // store.setState({ score: store.getState().score + 10 });
7
+ // store.subscribe(state => logger.debug('score changed:', state.score));
8
+ //
9
+ // The built-in novaStore is a global singleton all carts can read/write:
10
+ // novaStore.setState({ level: 2 });
11
+ // const { score, lives } = novaStore.getState();
12
+
13
+ let _createStore;
14
+
15
+ // Try to use real Zustand vanilla, fall back to a compatible hand-rolled version
16
+ // so the file works even before `npm install` resolves.
17
+ async function _loadZustand() {
18
+ try {
19
+ const mod = await import('zustand/vanilla');
20
+ _createStore = mod.createStore;
21
+ logger.info('✅ Nova64 Store: using Zustand/vanilla');
22
+ } catch {
23
+ // Hand-rolled Zustand-compatible store (same API surface)
24
+ _createStore = function createStorePolyfill(initializer) {
25
+ let state;
26
+ const listeners = new Set();
27
+
28
+ const setState = (partial, replace = false) => {
29
+ const next = typeof partial === 'function' ? partial(state) : partial;
30
+ const nextState = replace ? next : Object.assign({}, state, next);
31
+ if (nextState !== state) {
32
+ const prev = state;
33
+ state = nextState;
34
+ listeners.forEach(l => l(state, prev));
35
+ }
36
+ };
37
+
38
+ const getState = () => state;
39
+
40
+ const subscribe = listener => {
41
+ listeners.add(listener);
42
+ return () => listeners.delete(listener);
43
+ };
44
+
45
+ const destroy = () => listeners.clear();
46
+
47
+ const api = { setState, getState, subscribe, destroy };
48
+ state =
49
+ typeof initializer === 'function' ? initializer(setState, getState, api) : initializer;
50
+ return api;
51
+ };
52
+ logger.info(
53
+ 'ℹ️ Nova64 Store: using built-in store polyfill (run pnpm install to enable Zustand)'
54
+ );
55
+ }
56
+ }
57
+
58
+ // Pre-initialize synchronously with polyfill so stores work immediately,
59
+ // then swap to real Zustand if available — existing stores keep their state.
60
+ _createStore = function createStorePolyfill(initializer) {
61
+ let state;
62
+ const listeners = new Set();
63
+
64
+ const setState = (partial, replace = false) => {
65
+ const next = typeof partial === 'function' ? partial(state) : partial;
66
+ const nextState = replace ? next : Object.assign({}, state, next);
67
+ if (nextState !== state) {
68
+ const prev = state;
69
+ state = nextState;
70
+ listeners.forEach(l => l(state, prev));
71
+ }
72
+ };
73
+
74
+ const getState = () => state;
75
+ const subscribe = listener => {
76
+ listeners.add(listener);
77
+ return () => listeners.delete(listener);
78
+ };
79
+ const destroy = () => listeners.clear();
80
+
81
+ const api = { setState, getState, subscribe, destroy };
82
+ state =
83
+ typeof initializer === 'function' ? initializer(setState, getState, api) : initializer || {};
84
+ return api;
85
+ };
86
+
87
+ // Attempt to upgrade to real Zustand asynchronously
88
+ _loadZustand();
89
+
90
+ /**
91
+ * createGameStore(initialState) → store
92
+ * Creates a new reactive store for a cart.
93
+ * Identical to Zustand vanilla's createStore.
94
+ *
95
+ * @param {object|function} initialState Plain object or Zustand initializer fn
96
+ * @returns {{ getState, setState, subscribe, destroy }}
97
+ */
98
+ export function createGameStore(initialState) {
99
+ return _createStore(initialState);
100
+ }
101
+
102
+ /**
103
+ * novaStore — built-in singleton store, auto-ticked by the main loop.
104
+ * All carts can read/write without any setup.
105
+ *
106
+ * getState() returns:
107
+ * { gameState, score, lives, level, time, paused, playerX, playerY }
108
+ */
109
+ export const novaStore = _createStore({
110
+ gameState: 'start', // 'start' | 'playing' | 'paused' | 'gameover' | 'win'
111
+ score: 0,
112
+ lives: 3,
113
+ level: 1,
114
+ time: 0,
115
+ paused: false,
116
+ playerX: 0,
117
+ playerY: 0,
118
+ });
119
+
120
+ export function storeApi() {
121
+ return {
122
+ exposeTo(target) {
123
+ target.createGameStore = createGameStore;
124
+ target.novaStore = novaStore;
125
+ },
126
+
127
+ // Called by main loop each frame to increment global time
128
+ tick(dt) {
129
+ const s = novaStore.getState();
130
+ if (!s.paused) {
131
+ novaStore.setState({ time: s.time + dt });
132
+ }
133
+ },
134
+
135
+ reset() {
136
+ novaStore.setState(
137
+ {
138
+ gameState: 'start',
139
+ score: 0,
140
+ lives: 3,
141
+ level: 1,
142
+ time: 0,
143
+ paused: false,
144
+ playerX: 0,
145
+ playerY: 0,
146
+ },
147
+ /*replace=*/ true
148
+ );
149
+ },
150
+ };
151
+ }
@@ -0,0 +1,68 @@
1
+ // runtime/textinput.js
2
+ class TextInput {
3
+ constructor() {
4
+ this.active = false;
5
+ this.value = '';
6
+ this.maxLen = 128;
7
+ this.placeholder = '';
8
+ this._build();
9
+ }
10
+ _build() {
11
+ const el = document.createElement('input');
12
+ el.type = 'text';
13
+ el.autocomplete = 'off';
14
+ el.spellcheck = false;
15
+ el.style.position = 'fixed';
16
+ el.style.left = '50%';
17
+ el.style.top = '10px';
18
+ el.style.transform = 'translateX(-50%)';
19
+ el.style.zIndex = '9998';
20
+ el.style.fontSize = '14px';
21
+ el.style.padding = '6px 10px';
22
+ el.style.borderRadius = '8px';
23
+ el.style.border = '1px solid #2a324a';
24
+ el.style.background = '#202538';
25
+ el.style.color = '#dcdfe4';
26
+ el.style.display = 'none';
27
+ document.body.appendChild(el);
28
+ el.addEventListener('input', () => {
29
+ if (el.value.length > this.maxLen) el.value = el.value.slice(0, this.maxLen);
30
+ this.value = el.value;
31
+ });
32
+ this.el = el;
33
+ }
34
+ start(opts = {}) {
35
+ this.active = true;
36
+ this.value = opts.value || '';
37
+ this.maxLen = opts.maxLen || 128;
38
+ this.placeholder = opts.placeholder || '';
39
+ this.el.placeholder = this.placeholder;
40
+ this.el.value = this.value;
41
+ this.el.style.display = 'block';
42
+ this.el.focus();
43
+ this.el.selectionStart = this.el.selectionEnd = this.el.value.length;
44
+ }
45
+ stop() {
46
+ this.active = false;
47
+ this.el.style.display = 'none';
48
+ this.el.blur();
49
+ return this.value;
50
+ }
51
+ get() {
52
+ return this.value;
53
+ }
54
+ }
55
+
56
+ const input = new TextInput();
57
+
58
+ export function textInputApi() {
59
+ return {
60
+ exposeTo(target) {
61
+ Object.assign(target, {
62
+ startTextInput: opts => input.start(opts || {}),
63
+ stopTextInput: () => input.stop(),
64
+ getTextInput: () => input.get(),
65
+ });
66
+ },
67
+ };
68
+ }
@@ -0,0 +1,124 @@
1
+ // runtime/ui/buttons.js
2
+ // Button creation, update, and rendering
3
+
4
+ /**
5
+ * @param {{ g: object, colors: object, buttons: Array, mouse: { x:number, y:number, down:boolean, pressed:boolean },
6
+ * state: object, drawText: Function, setTextAlign: Function, setTextBaseline: Function }} ctx
7
+ */
8
+ export function uiButtonsModule({
9
+ g,
10
+ colors,
11
+ buttons,
12
+ mouse,
13
+ state,
14
+ drawText,
15
+ setTextAlign,
16
+ setTextBaseline,
17
+ }) {
18
+ function createButton(x, y, width, height, text, callback, options = {}) {
19
+ const button = {
20
+ id: `button_${Date.now()}_${Math.random()}`,
21
+ x,
22
+ y,
23
+ width,
24
+ height,
25
+ text,
26
+ callback,
27
+ enabled: options.enabled !== undefined ? options.enabled : true,
28
+ visible: options.visible !== undefined ? options.visible : true,
29
+ normalColor: options.normalColor || colors.primary,
30
+ hoverColor: options.hoverColor || g.rgba8(50, 150, 255, 255),
31
+ pressedColor: options.pressedColor || g.rgba8(0, 80, 200, 255),
32
+ disabledColor: options.disabledColor || g.rgba8(100, 100, 100, 255),
33
+ textColor: options.textColor || colors.white,
34
+ borderColor: options.borderColor || colors.white,
35
+ borderWidth: options.borderWidth !== undefined ? options.borderWidth : 2,
36
+ hovered: false,
37
+ pressed: false,
38
+ rounded: options.rounded || false,
39
+ icon: options.icon || null,
40
+ };
41
+ buttons.push(button);
42
+ return button;
43
+ }
44
+
45
+ function updateButton(button) {
46
+ if (!button.enabled || !button.visible) {
47
+ button.hovered = false;
48
+ button.pressed = false;
49
+ return false;
50
+ }
51
+ const over =
52
+ mouse.x >= button.x &&
53
+ mouse.x <= button.x + button.width &&
54
+ mouse.y >= button.y &&
55
+ mouse.y <= button.y + button.height;
56
+ button.hovered = over;
57
+ button.pressed = over && mouse.down;
58
+ if (over && mouse.pressed) {
59
+ if (button.callback) button.callback();
60
+ return true;
61
+ }
62
+ return false;
63
+ }
64
+
65
+ function drawButton(button) {
66
+ if (!button.visible) return;
67
+ const { x, y, width, height } = button;
68
+ let bgColor = button.normalColor;
69
+ if (!button.enabled) bgColor = button.disabledColor;
70
+ else if (button.pressed) bgColor = button.pressedColor;
71
+ else if (button.hovered) bgColor = button.hoverColor;
72
+
73
+ g.rect(x, y, width, height, bgColor, true);
74
+
75
+ if (button.borderWidth > 0) {
76
+ const bCol = button.hovered ? button.hoverColor : button.borderColor;
77
+ for (let i = 0; i < button.borderWidth; i++) {
78
+ g.rect(x + i, y + i, width - i * 2, height - i * 2, bCol, false);
79
+ }
80
+ }
81
+
82
+ if (button.pressed)
83
+ g.rect(x + 2, y + 2, width - 4, height - 4, g.rgba8(255, 255, 255, 50), true);
84
+
85
+ const oldAlign = state.textAlign;
86
+ const oldBaseline = state.textBaseline;
87
+ setTextAlign('center');
88
+ setTextBaseline('middle');
89
+ const textY = button.pressed ? y + height / 2 + 1 : y + height / 2;
90
+ drawText(button.text, x + width / 2, textY, button.textColor, 1);
91
+ setTextAlign(oldAlign);
92
+ setTextBaseline(oldBaseline);
93
+ }
94
+
95
+ function updateAllButtons() {
96
+ let anyClicked = false;
97
+ buttons.forEach(b => {
98
+ if (updateButton(b)) anyClicked = true;
99
+ });
100
+ mouse.pressed = false; // consume click after all buttons have seen it
101
+ return anyClicked;
102
+ }
103
+
104
+ function drawAllButtons() {
105
+ buttons.forEach(b => drawButton(b));
106
+ }
107
+ function removeButton(b) {
108
+ const i = buttons.indexOf(b);
109
+ if (i >= 0) buttons.splice(i, 1);
110
+ }
111
+ function clearButtons() {
112
+ buttons.length = 0;
113
+ }
114
+
115
+ return {
116
+ createButton,
117
+ updateButton,
118
+ drawButton,
119
+ updateAllButtons,
120
+ drawAllButtons,
121
+ removeButton,
122
+ clearButtons,
123
+ };
124
+ }