nova64 0.2.6 → 0.2.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/examples/strider-demo-3d/fix-game.sh +0 -0
- package/dist/runtime/api-2d.js +1158 -0
- package/dist/runtime/api-3d/camera.js +73 -0
- package/dist/runtime/api-3d/instancing.js +180 -0
- package/dist/runtime/api-3d/lights.js +51 -0
- package/dist/runtime/api-3d/materials.js +47 -0
- package/dist/runtime/api-3d/models.js +84 -0
- package/dist/runtime/api-3d/particles.js +296 -0
- package/dist/runtime/api-3d/pbr.js +113 -0
- package/dist/runtime/api-3d/primitives.js +304 -0
- package/dist/runtime/api-3d/scene.js +169 -0
- package/dist/runtime/api-3d/transforms.js +161 -0
- package/dist/runtime/api-3d.js +166 -0
- package/dist/runtime/api-effects.js +840 -0
- package/dist/runtime/api-gameutils.js +476 -0
- package/dist/runtime/api-generative.js +610 -0
- package/dist/runtime/api-presets.js +85 -0
- package/dist/runtime/api-skybox.js +232 -0
- package/dist/runtime/api-sprites.js +100 -0
- package/dist/runtime/api-voxel.js +712 -0
- package/dist/runtime/api.js +201 -0
- package/dist/runtime/assets.js +27 -0
- package/dist/runtime/audio.js +114 -0
- package/dist/runtime/collision.js +47 -0
- package/dist/runtime/console.js +101 -0
- package/dist/runtime/editor.js +233 -0
- package/dist/runtime/font.js +233 -0
- package/dist/runtime/framebuffer.js +28 -0
- package/dist/runtime/fullscreen-button.js +185 -0
- package/dist/runtime/gpu-canvas2d.js +47 -0
- package/dist/runtime/gpu-threejs.js +643 -0
- package/dist/runtime/gpu-webgl2.js +310 -0
- package/dist/runtime/index.d.ts +682 -0
- package/dist/runtime/index.js +22 -0
- package/dist/runtime/input.js +225 -0
- package/dist/runtime/logger.js +60 -0
- package/dist/runtime/physics.js +101 -0
- package/dist/runtime/screens.js +213 -0
- package/dist/runtime/storage.js +38 -0
- package/dist/runtime/store.js +151 -0
- package/dist/runtime/textinput.js +68 -0
- package/dist/runtime/ui/buttons.js +124 -0
- package/dist/runtime/ui/panels.js +105 -0
- package/dist/runtime/ui/text.js +86 -0
- package/dist/runtime/ui/widgets.js +141 -0
- package/dist/runtime/ui.js +111 -0
- package/package.json +34 -32
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// runtime/ui/panels.js
|
|
2
|
+
// Panel (dialog box) creation and rendering
|
|
3
|
+
|
|
4
|
+
import { unpackRGBA64 } from '../api.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {{ g: object, colors: object, panels: Array, state: object,
|
|
8
|
+
* drawText: Function, setTextAlign: Function }} ctx
|
|
9
|
+
*/
|
|
10
|
+
export function uiPanelsModule({ g, colors, panels, state, drawText, setTextAlign }) {
|
|
11
|
+
function createPanel(x, y, width, height, options = {}) {
|
|
12
|
+
const panel = {
|
|
13
|
+
id: `panel_${Date.now()}_${Math.random()}`,
|
|
14
|
+
x,
|
|
15
|
+
y,
|
|
16
|
+
width,
|
|
17
|
+
height,
|
|
18
|
+
bgColor: options.bgColor || g.rgba8(0, 0, 0, 200),
|
|
19
|
+
borderColor: options.borderColor || colors.primary,
|
|
20
|
+
borderWidth: options.borderWidth !== undefined ? options.borderWidth : 2,
|
|
21
|
+
cornerRadius: options.cornerRadius || 0,
|
|
22
|
+
shadow: options.shadow || false,
|
|
23
|
+
shadowOffset: options.shadowOffset || 4,
|
|
24
|
+
title: options.title || null,
|
|
25
|
+
titleColor: options.titleColor || colors.white,
|
|
26
|
+
titleBgColor: options.titleBgColor || colors.primary,
|
|
27
|
+
padding: options.padding || 10,
|
|
28
|
+
visible: options.visible !== undefined ? options.visible : true,
|
|
29
|
+
gradient: options.gradient || false,
|
|
30
|
+
gradientColor: options.gradientColor || g.rgba8(0, 0, 50, 200),
|
|
31
|
+
};
|
|
32
|
+
panels.push(panel);
|
|
33
|
+
return panel;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function drawPanel(panel) {
|
|
37
|
+
if (!panel.visible) return;
|
|
38
|
+
const { x, y, width, height } = panel;
|
|
39
|
+
|
|
40
|
+
if (panel.shadow) {
|
|
41
|
+
const so = panel.shadowOffset;
|
|
42
|
+
g.rect(x + so, y + so, width, height, g.rgba8(0, 0, 0, 100), true);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (panel.gradient) {
|
|
46
|
+
const steps = 20;
|
|
47
|
+
const bgU = unpackRGBA64(panel.bgColor);
|
|
48
|
+
const grU = unpackRGBA64(panel.gradientColor);
|
|
49
|
+
const bgR = Math.floor(bgU.r / 257),
|
|
50
|
+
bgG = Math.floor(bgU.g / 257),
|
|
51
|
+
bgB = Math.floor(bgU.b / 257),
|
|
52
|
+
bgA = Math.floor(bgU.a / 257);
|
|
53
|
+
const grR = Math.floor(grU.r / 257),
|
|
54
|
+
grG = Math.floor(grU.g / 257),
|
|
55
|
+
grB = Math.floor(grU.b / 257);
|
|
56
|
+
for (let i = 0; i < steps; i++) {
|
|
57
|
+
const ratio = i / steps;
|
|
58
|
+
const h = Math.floor(height / steps);
|
|
59
|
+
const r = Math.floor(bgR + (grR - bgR) * ratio);
|
|
60
|
+
const gv = Math.floor(bgG + (grG - bgG) * ratio);
|
|
61
|
+
const b = Math.floor(bgB + (grB - bgB) * ratio);
|
|
62
|
+
g.rect(x, y + i * h, width, h, g.rgba8(r, gv, b, bgA), true);
|
|
63
|
+
}
|
|
64
|
+
} else {
|
|
65
|
+
g.rect(x, y, width, height, panel.bgColor, true);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (panel.cornerRadius > 0) {
|
|
69
|
+
const cr = panel.cornerRadius;
|
|
70
|
+
g.rect(x, y, cr, cr, panel.bgColor, true);
|
|
71
|
+
g.rect(x + width - cr, y, cr, cr, panel.bgColor, true);
|
|
72
|
+
g.rect(x, y + height - cr, cr, cr, panel.bgColor, true);
|
|
73
|
+
g.rect(x + width - cr, y + height - cr, cr, cr, panel.bgColor, true);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (panel.borderWidth > 0) {
|
|
77
|
+
for (let i = 0; i < panel.borderWidth; i++) {
|
|
78
|
+
g.rect(x + i, y + i, width - i * 2, height - i * 2, panel.borderColor, false);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (panel.title) {
|
|
83
|
+
const titleHeight = 20;
|
|
84
|
+
g.rect(x, y - titleHeight, width, titleHeight, panel.titleBgColor, true);
|
|
85
|
+
g.rect(x, y - titleHeight, width, titleHeight, panel.borderColor, false);
|
|
86
|
+
const oldAlign = state.textAlign;
|
|
87
|
+
setTextAlign('center');
|
|
88
|
+
drawText(panel.title, x + width / 2, y - titleHeight + 4, panel.titleColor, 1);
|
|
89
|
+
setTextAlign(oldAlign);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function drawAllPanels() {
|
|
94
|
+
panels.forEach(p => drawPanel(p));
|
|
95
|
+
}
|
|
96
|
+
function removePanel(p) {
|
|
97
|
+
const i = panels.indexOf(p);
|
|
98
|
+
if (i >= 0) panels.splice(i, 1);
|
|
99
|
+
}
|
|
100
|
+
function clearPanels() {
|
|
101
|
+
panels.length = 0;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return { createPanel, drawPanel, drawAllPanels, removePanel, clearPanels };
|
|
105
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// runtime/ui/text.js
|
|
2
|
+
// Font management and text rendering
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @param {{ g: object, fonts: object, state: { currentFont: string, textAlign: string, textBaseline: string }, colors: object }} ctx
|
|
6
|
+
*/
|
|
7
|
+
export function uiTextModule({ g, fonts, state, colors }) {
|
|
8
|
+
function setFont(fontName) {
|
|
9
|
+
if (fonts[fontName]) state.currentFont = fontName;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function getFont() {
|
|
13
|
+
return fonts[state.currentFont];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function setTextAlign(align) {
|
|
17
|
+
state.textAlign = align;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function setTextBaseline(baseline) {
|
|
21
|
+
state.textBaseline = baseline;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function measureText(text, scale = 1) {
|
|
25
|
+
const font = getFont();
|
|
26
|
+
const charWidth = 6 * scale * font.size;
|
|
27
|
+
const charHeight = 8 * scale * font.size;
|
|
28
|
+
return { width: text.length * charWidth, height: charHeight };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function drawText(text, x, y, color = colors.white, scale = 1) {
|
|
32
|
+
const font = getFont();
|
|
33
|
+
const finalScale = scale * font.size;
|
|
34
|
+
const metrics = measureText(text, scale);
|
|
35
|
+
|
|
36
|
+
let drawX = x;
|
|
37
|
+
if (state.textAlign === 'center') drawX = x - metrics.width / 2;
|
|
38
|
+
else if (state.textAlign === 'right') drawX = x - metrics.width;
|
|
39
|
+
|
|
40
|
+
let drawY = y;
|
|
41
|
+
if (state.textBaseline === 'middle') drawY = y - metrics.height / 2;
|
|
42
|
+
else if (state.textBaseline === 'bottom') drawY = y - metrics.height;
|
|
43
|
+
|
|
44
|
+
g.print(text, Math.floor(drawX), Math.floor(drawY), color, finalScale);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function drawTextShadow(
|
|
48
|
+
text,
|
|
49
|
+
x,
|
|
50
|
+
y,
|
|
51
|
+
color = colors.white,
|
|
52
|
+
shadowColor = colors.black,
|
|
53
|
+
offset = 2,
|
|
54
|
+
scale = 1
|
|
55
|
+
) {
|
|
56
|
+
drawText(text, x + offset, y + offset, shadowColor, scale);
|
|
57
|
+
drawText(text, x, y, color, scale);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function drawTextOutline(
|
|
61
|
+
text,
|
|
62
|
+
x,
|
|
63
|
+
y,
|
|
64
|
+
color = colors.white,
|
|
65
|
+
outlineColor = colors.black,
|
|
66
|
+
scale = 1
|
|
67
|
+
) {
|
|
68
|
+
for (let ox = -1; ox <= 1; ox++) {
|
|
69
|
+
for (let oy = -1; oy <= 1; oy++) {
|
|
70
|
+
if (ox !== 0 || oy !== 0) drawText(text, x + ox, y + oy, outlineColor, scale);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
drawText(text, x, y, color, scale);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
setFont,
|
|
78
|
+
getFont,
|
|
79
|
+
setTextAlign,
|
|
80
|
+
setTextBaseline,
|
|
81
|
+
measureText,
|
|
82
|
+
drawText,
|
|
83
|
+
drawTextShadow,
|
|
84
|
+
drawTextOutline,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// runtime/ui/widgets.js
|
|
2
|
+
// Progress bars, shapes, layout helpers, and mouse input
|
|
3
|
+
|
|
4
|
+
import { unpackRGBA64 } from '../api.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {{ g: object, colors: object, mouse: { x:number, y:number, down:boolean, pressed:boolean },
|
|
8
|
+
* state: object, drawText: Function, drawTextOutline: Function,
|
|
9
|
+
* setTextAlign: Function, setTextBaseline: Function }} ctx
|
|
10
|
+
*/
|
|
11
|
+
export function uiWidgetsModule({
|
|
12
|
+
g,
|
|
13
|
+
colors,
|
|
14
|
+
mouse,
|
|
15
|
+
state,
|
|
16
|
+
drawTextOutline,
|
|
17
|
+
setTextAlign,
|
|
18
|
+
setTextBaseline,
|
|
19
|
+
}) {
|
|
20
|
+
// ── Progress bar ──────────────────────────────────────────────────────────
|
|
21
|
+
function drawProgressBar(x, y, width, height, value, maxValue, options = {}) {
|
|
22
|
+
const bgColor = options.bgColor || g.rgba8(50, 50, 50, 255);
|
|
23
|
+
const fillColor = options.fillColor || colors.success;
|
|
24
|
+
const borderColor = options.borderColor || colors.white;
|
|
25
|
+
const showText = options.showText !== undefined ? options.showText : true;
|
|
26
|
+
const textColor = options.textColor || colors.white;
|
|
27
|
+
|
|
28
|
+
g.rect(x, y, width, height, bgColor, true);
|
|
29
|
+
const fillWidth = Math.floor((value / maxValue) * width);
|
|
30
|
+
if (fillWidth > 0) g.rect(x, y, fillWidth, height, fillColor, true);
|
|
31
|
+
g.rect(x, y, width, height, borderColor, false);
|
|
32
|
+
|
|
33
|
+
if (showText) {
|
|
34
|
+
const text = `${Math.floor(value)}/${Math.floor(maxValue)}`;
|
|
35
|
+
const oldAlign = state.textAlign;
|
|
36
|
+
const oldBaseline = state.textBaseline;
|
|
37
|
+
setTextAlign('center');
|
|
38
|
+
setTextBaseline('middle');
|
|
39
|
+
drawTextOutline(text, x + width / 2, y + height / 2, textColor, colors.black, 1);
|
|
40
|
+
setTextAlign(oldAlign);
|
|
41
|
+
setTextBaseline(oldBaseline);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── Shapes ────────────────────────────────────────────────────────────────
|
|
46
|
+
function drawRoundedRect(x, y, width, height, radius, color, filled = true) {
|
|
47
|
+
if (radius === 0) {
|
|
48
|
+
g.rect(x, y, width, height, color, filled);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
g.rect(x + radius, y, width - radius * 2, height, color, filled);
|
|
52
|
+
g.rect(x, y + radius, radius, height - radius * 2, color, filled);
|
|
53
|
+
g.rect(x + width - radius, y + radius, radius, height - radius * 2, color, filled);
|
|
54
|
+
g.circle(x + radius, y + radius, radius, color, filled);
|
|
55
|
+
g.circle(x + width - radius, y + radius, radius, color, filled);
|
|
56
|
+
g.circle(x + radius, y + height - radius, radius, color, filled);
|
|
57
|
+
g.circle(x + width - radius, y + height - radius, radius, color, filled);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function drawGradientRect(x, y, width, height, color1, color2, vertical = true) {
|
|
61
|
+
const steps = vertical ? height : width;
|
|
62
|
+
const c1 = unpackRGBA64(color1);
|
|
63
|
+
const c2 = unpackRGBA64(color2);
|
|
64
|
+
const r1 = Math.floor(c1.r / 257),
|
|
65
|
+
g1 = Math.floor(c1.g / 257),
|
|
66
|
+
b1 = Math.floor(c1.b / 257),
|
|
67
|
+
a1 = Math.floor(c1.a / 257);
|
|
68
|
+
const r2 = Math.floor(c2.r / 257),
|
|
69
|
+
g2 = Math.floor(c2.g / 257),
|
|
70
|
+
b2 = Math.floor(c2.b / 257),
|
|
71
|
+
a2 = Math.floor(c2.a / 257);
|
|
72
|
+
for (let i = 0; i < steps; i++) {
|
|
73
|
+
const ratio = i / steps;
|
|
74
|
+
const r = Math.floor(r1 + (r2 - r1) * ratio);
|
|
75
|
+
const gv = Math.floor(g1 + (g2 - g1) * ratio);
|
|
76
|
+
const b = Math.floor(b1 + (b2 - b1) * ratio);
|
|
77
|
+
const a = Math.floor(a1 + (a2 - a1) * ratio);
|
|
78
|
+
const c = g.rgba8(r, gv, b, a);
|
|
79
|
+
if (vertical) g.rect(x, y + i, width, 1, c, true);
|
|
80
|
+
else g.rect(x + i, y, 1, height, c, true);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── Layout helpers ────────────────────────────────────────────────────────
|
|
85
|
+
function centerX(width, screenWidth = 640) {
|
|
86
|
+
return Math.floor((screenWidth - width) / 2);
|
|
87
|
+
}
|
|
88
|
+
function centerY(height, screenHeight = 360) {
|
|
89
|
+
return Math.floor((screenHeight - height) / 2);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function grid(cols, rows, cellWidth, cellHeight, paddingX = 0, paddingY = 0) {
|
|
93
|
+
const cells = [];
|
|
94
|
+
for (let row = 0; row < rows; row++) {
|
|
95
|
+
for (let col = 0; col < cols; col++) {
|
|
96
|
+
cells.push({
|
|
97
|
+
x: col * (cellWidth + paddingX),
|
|
98
|
+
y: row * (cellHeight + paddingY),
|
|
99
|
+
width: cellWidth,
|
|
100
|
+
height: cellHeight,
|
|
101
|
+
col,
|
|
102
|
+
row,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return cells;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── Mouse input ───────────────────────────────────────────────────────────
|
|
110
|
+
function setMousePosition(x, y) {
|
|
111
|
+
mouse.x = x;
|
|
112
|
+
mouse.y = y;
|
|
113
|
+
}
|
|
114
|
+
function setMouseButton(down) {
|
|
115
|
+
mouse.pressed = down && !mouse.down;
|
|
116
|
+
mouse.down = down;
|
|
117
|
+
}
|
|
118
|
+
function getMousePosition() {
|
|
119
|
+
return { x: mouse.x, y: mouse.y };
|
|
120
|
+
}
|
|
121
|
+
function isMouseDown() {
|
|
122
|
+
return mouse.down;
|
|
123
|
+
}
|
|
124
|
+
function isMousePressed() {
|
|
125
|
+
return mouse.pressed;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
drawProgressBar,
|
|
130
|
+
drawRoundedRect,
|
|
131
|
+
drawGradientRect,
|
|
132
|
+
centerX,
|
|
133
|
+
centerY,
|
|
134
|
+
grid,
|
|
135
|
+
setMousePosition,
|
|
136
|
+
setMouseButton,
|
|
137
|
+
getMousePosition,
|
|
138
|
+
isMouseDown,
|
|
139
|
+
isMousePressed,
|
|
140
|
+
};
|
|
141
|
+
}
|