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.
- package/LICENSE +21 -0
- package/README.md +786 -0
- package/index.html +651 -0
- package/package.json +255 -0
- package/public/os9-shell/assets/index-B1Uvacma.js +32825 -0
- package/public/os9-shell/assets/index-B1Uvacma.js.map +1 -0
- package/public/os9-shell/assets/index-DIHfrTaW.css +1 -0
- package/public/os9-shell/index.html +14 -0
- package/public/os9-shell/nova-icon.svg +12 -0
- package/runtime/api-2d.js +878 -0
- package/runtime/api-3d/camera.js +73 -0
- package/runtime/api-3d/instancing.js +180 -0
- package/runtime/api-3d/lights.js +51 -0
- package/runtime/api-3d/materials.js +47 -0
- package/runtime/api-3d/models.js +84 -0
- package/runtime/api-3d/pbr.js +69 -0
- package/runtime/api-3d/primitives.js +304 -0
- package/runtime/api-3d/scene.js +169 -0
- package/runtime/api-3d/transforms.js +161 -0
- package/runtime/api-3d.js +154 -0
- package/runtime/api-effects.js +753 -0
- package/runtime/api-presets.js +85 -0
- package/runtime/api-skybox.js +178 -0
- package/runtime/api-sprites.js +100 -0
- package/runtime/api-voxel.js +601 -0
- package/runtime/api.js +201 -0
- package/runtime/assets.js +27 -0
- package/runtime/audio.js +114 -0
- package/runtime/collision.js +47 -0
- package/runtime/console.js +101 -0
- package/runtime/editor.js +233 -0
- package/runtime/font.js +233 -0
- package/runtime/framebuffer.js +28 -0
- package/runtime/fullscreen-button.js +185 -0
- package/runtime/gpu-canvas2d.js +47 -0
- package/runtime/gpu-threejs.js +639 -0
- package/runtime/gpu-webgl2.js +310 -0
- package/runtime/index.js +22 -0
- package/runtime/input.js +225 -0
- package/runtime/logger.js +60 -0
- package/runtime/physics.js +101 -0
- package/runtime/screens.js +213 -0
- package/runtime/storage.js +38 -0
- package/runtime/store.js +151 -0
- package/runtime/textinput.js +68 -0
- package/runtime/ui/buttons.js +124 -0
- package/runtime/ui/panels.js +105 -0
- package/runtime/ui/text.js +86 -0
- package/runtime/ui/widgets.js +141 -0
- package/runtime/ui.js +111 -0
- package/src/main.js +474 -0
- 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
|
+
}
|
package/runtime/store.js
ADDED
|
@@ -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
|
+
}
|