@nayan-ui/engine 1.0.0-beta.3 → 1.0.0-beta.4
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/README.md +167 -69
- package/lib/module/assets/AssetLoader.js +1 -1
- package/lib/module/assets/ProceduralCache.js +31 -4
- package/lib/module/assets/font.js +10 -7
- package/lib/module/assets/model.js +41 -19
- package/lib/module/assets/storage.shared.js +2 -0
- package/lib/module/assets/storage.web.js +1 -0
- package/lib/module/assets/texture.js +14 -11
- package/lib/module/index.js +4 -1
- package/lib/module/media/audio.js +6 -3
- package/lib/module/native/BrowserEngine.js +17 -0
- package/lib/module/render/GameView.js +86 -18
- package/lib/module/render/light.js +24 -0
- package/lib/module/render/quality.js +12 -5
- package/lib/module/render/useWorld.js +72 -0
- package/lib/module/world/Scoreboard.js +21 -3
- package/lib/module/world/TransformBatch.js +5 -3
- package/lib/module/world/World.js +27 -13
- package/lib/module/world/color.js +80 -0
- package/lib/module/world/desc.js +6 -5
- package/lib/module/world/effects.js +20 -8
- package/lib/typescript/src/assets/AssetLoader.d.ts +1 -1
- package/lib/typescript/src/assets/ProceduralCache.d.ts +12 -0
- package/lib/typescript/src/assets/model.d.ts +10 -0
- package/lib/typescript/src/index.d.ts +4 -1
- package/lib/typescript/src/native/EngineBridge.d.ts +6 -2
- package/lib/typescript/src/native/spec/NativeNayanEngine.d.ts +29 -0
- package/lib/typescript/src/render/GameView.d.ts +19 -7
- package/lib/typescript/src/render/light.d.ts +12 -0
- package/lib/typescript/src/render/quality.d.ts +4 -2
- package/lib/typescript/src/render/useWorld.d.ts +20 -0
- package/lib/typescript/src/types.d.ts +13 -4
- package/lib/typescript/src/world/Scoreboard.d.ts +12 -2
- package/lib/typescript/src/world/TransformBatch.d.ts +2 -2
- package/lib/typescript/src/world/World.d.ts +10 -2
- package/lib/typescript/src/world/color.d.ts +18 -0
- package/lib/typescript/src/world/effects.d.ts +13 -12
- package/lib/typescript/src/world/types.d.ts +11 -6
- package/lib/web/engine_core_bg.wasm +0 -0
- package/lib/web/index.js +521 -255
- package/lib/web/types/src/assets/AssetLoader.d.ts +1 -1
- package/lib/web/types/src/assets/ProceduralCache.d.ts +12 -0
- package/lib/web/types/src/assets/model.d.ts +10 -0
- package/lib/web/types/src/index.d.ts +4 -1
- package/lib/web/types/src/native/EngineBridge.d.ts +6 -2
- package/lib/web/types/src/native/spec/NativeNayanEngine.d.ts +29 -0
- package/lib/web/types/src/render/GameView.d.ts +19 -7
- package/lib/web/types/src/render/light.d.ts +11 -0
- package/lib/web/types/src/render/quality.d.ts +4 -2
- package/lib/web/types/src/render/useWorld.d.ts +19 -0
- package/lib/web/types/src/types.d.ts +13 -4
- package/lib/web/types/src/world/Scoreboard.d.ts +12 -2
- package/lib/web/types/src/world/TransformBatch.d.ts +2 -2
- package/lib/web/types/src/world/World.d.ts +10 -2
- package/lib/web/types/src/world/color.d.ts +17 -0
- package/lib/web/types/src/world/effects.d.ts +13 -12
- package/lib/web/types/src/world/types.d.ts +11 -6
- package/native/android/src/main/java/com/nayanui/engine/EngineSurfaceView.java +87 -26
- package/native/core/build/EngineCore.xcframework/ios-arm64/libengine_core.a +0 -0
- package/native/core/build/EngineCore.xcframework/ios-arm64-simulator/libengine_core.a +0 -0
- package/native/core/build/android/arm64-v8a/libengine_core.a +0 -0
- package/native/core/build/android/armeabi-v7a/libengine_core.a +0 -0
- package/native/core/build/android/x86/libengine_core.a +0 -0
- package/native/core/build/android/x86_64/libengine_core.a +0 -0
- package/native/core/include/engine_core.h +9 -0
- package/native/cpp/NayanEngineModule.cpp +171 -0
- package/native/cpp/NayanEngineModule.h +30 -0
- package/native/cpp/generated/NayanEngineSpecJSI.h +105 -0
- package/native/ios/NayanEngineSurface.mm +30 -1
- package/package.json +2 -2
- package/src/native/spec/NativeNayanEngine.ts +39 -0
|
@@ -204,6 +204,8 @@ export function createBrowserEngine(core) {
|
|
|
204
204
|
fn(name, id, p);
|
|
205
205
|
return Array.from(float ? new Float32Array(memory(), p, count) : new Uint32Array(memory(), p, count));
|
|
206
206
|
};
|
|
207
|
+
/** A promise for work that runs right here (its errors reject it). */
|
|
208
|
+
const settle = work => new Promise(resolve => resolve(work()));
|
|
207
209
|
const engine = {
|
|
208
210
|
createWorld(capacity) {
|
|
209
211
|
if (!Number.isInteger(capacity) || capacity < 1 || capacity > 1_000_000) throw new Error('World: capacity must be an integer in 1..1000000');
|
|
@@ -402,6 +404,21 @@ export function createBrowserEngine(core) {
|
|
|
402
404
|
const [x, y, z] = dimensions('model_size', mesh, 3, true);
|
|
403
405
|
return [x, y, z];
|
|
404
406
|
},
|
|
407
|
+
// The browser core is single-threaded: the "background" loads settle at once with the same work.
|
|
408
|
+
modelLoadAsync(value, center, fit) {
|
|
409
|
+
return settle(() => engine.modelLoad(value, center, fit));
|
|
410
|
+
},
|
|
411
|
+
textureLoadAsync(value) {
|
|
412
|
+
return settle(() => engine.textureLoad(value));
|
|
413
|
+
},
|
|
414
|
+
fontLoadAsync(value, depth, chars, weight) {
|
|
415
|
+
return settle(() => engine.fontLoad(value, depth, chars, weight));
|
|
416
|
+
},
|
|
417
|
+
audioLoadAsync(value) {
|
|
418
|
+
return settle(() => engine.audioLoad(value));
|
|
419
|
+
},
|
|
420
|
+
// An older core without it uploads through JS instead (see registerCoreMesh).
|
|
421
|
+
renderModel: (mesh, texture, shine) => typeof core.engine_render_model === 'function' && fn('render_model', mesh, texture, shine) !== 0,
|
|
405
422
|
modelTexture: mesh => fn('model_texture', mesh),
|
|
406
423
|
meshAlias: mesh => fn('mesh_alias', mesh),
|
|
407
424
|
meshCreate(vertices, indices) {
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { useEffect, useMemo, useRef } from 'react';
|
|
4
4
|
import NativeEngine from '../native/NativeNayanEngine';
|
|
5
5
|
import { MAX_MESHES } from "../types.js";
|
|
6
|
+
import { resolveColor } from "../world/color.js";
|
|
6
7
|
import { follow, shake } from "./camera.js";
|
|
7
8
|
import { createTouchResponder, pixelRatio, Surface, useActive } from './host';
|
|
8
9
|
import { getMeshIdLimit, uploadBuiltInMeshes } from "./meshes.js";
|
|
@@ -21,6 +22,44 @@ const defaultLight = () => ({
|
|
|
21
22
|
direction: [0.4, 0.8, 0.5],
|
|
22
23
|
ambient: 0.35
|
|
23
24
|
});
|
|
25
|
+
|
|
26
|
+
/** Callbacks waiting for the next frame any GameView finishes (see `afterNextFrame`). */
|
|
27
|
+
let afterFrame = [];
|
|
28
|
+
/** GameView frame loops running now. */
|
|
29
|
+
let runningLoops = 0;
|
|
30
|
+
function runAfterFrame() {
|
|
31
|
+
if (afterFrame.length === 0) return;
|
|
32
|
+
const due = afterFrame;
|
|
33
|
+
afterFrame = [];
|
|
34
|
+
for (const fn of due) fn();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Runs `fn` right after the next frame a GameView hands to the screen, on the same display clock
|
|
39
|
+
* (the native one where the platform has it): the time a frame leaves is the best moment for
|
|
40
|
+
* background work, such as loading in slices. With no GameView running it falls back to
|
|
41
|
+
* requestAnimationFrame. Returns a function that cancels it.
|
|
42
|
+
*
|
|
43
|
+
* ```ts
|
|
44
|
+
* const cancel = afterNextFrame(() => buildNextChunk());
|
|
45
|
+
* ```
|
|
46
|
+
*/
|
|
47
|
+
export function afterNextFrame(fn) {
|
|
48
|
+
let cancelled = false;
|
|
49
|
+
const run = () => {
|
|
50
|
+
if (!cancelled) fn();
|
|
51
|
+
};
|
|
52
|
+
if (runningLoops > 0) afterFrame.push(run);else {
|
|
53
|
+
const raf = requestAnimationFrame(run);
|
|
54
|
+
return () => {
|
|
55
|
+
cancelled = true;
|
|
56
|
+
cancelAnimationFrame(raf);
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
return () => {
|
|
60
|
+
cancelled = true;
|
|
61
|
+
};
|
|
62
|
+
}
|
|
24
63
|
export function GameView(props) {
|
|
25
64
|
const {
|
|
26
65
|
source,
|
|
@@ -75,6 +114,11 @@ export function GameView(props) {
|
|
|
75
114
|
let alive = true;
|
|
76
115
|
let raf = 0;
|
|
77
116
|
let errors = 0;
|
|
117
|
+
// Where the platform has them (Android), frames come from the display's own clock and are drawn
|
|
118
|
+
// on the engine's render thread: this loop only runs the game and publishes what to draw.
|
|
119
|
+
const native2 = native;
|
|
120
|
+
const threaded = native2.hasFrameClock?.() === true && typeof native2.publishFrame === 'function' && typeof native2.startFrames === 'function';
|
|
121
|
+
let presentedAt = 0;
|
|
78
122
|
if (autoQuality.current?.source !== source) autoQuality.current = {
|
|
79
123
|
source,
|
|
80
124
|
auto: new AutoQuality()
|
|
@@ -132,7 +176,7 @@ export function GameView(props) {
|
|
|
132
176
|
let shown = false;
|
|
133
177
|
const frame = now => {
|
|
134
178
|
if (!alive) return;
|
|
135
|
-
raf = requestAnimationFrame(frame);
|
|
179
|
+
if (!threaded) raf = requestAnimationFrame(frame);
|
|
136
180
|
if (!last) last = fpsStart = now;
|
|
137
181
|
if (now > last) intervals.push(now - last);
|
|
138
182
|
const dt = shown ? Math.min((now - last) / 1000, 0.1) : 0;
|
|
@@ -144,7 +188,13 @@ export function GameView(props) {
|
|
|
144
188
|
environment
|
|
145
189
|
} = live.current;
|
|
146
190
|
const t0 = performance.now();
|
|
191
|
+
const layer = current.overlay;
|
|
192
|
+
const sourceSteps = source.updateCount;
|
|
193
|
+
const layerSteps = layer?.updateCount;
|
|
147
194
|
current.onUpdate?.(dt);
|
|
195
|
+
// Step what onUpdate didn't (a World counts its own updates), so the loop needs no boilerplate.
|
|
196
|
+
if (source.update && !source.isDisposed && source.updateCount === sourceSteps) source.update(dt);
|
|
197
|
+
if (layer?.update && !layer.isDisposed && layer.updateCount === layerSteps) layer.update(dt);
|
|
148
198
|
updateTime += performance.now() - t0;
|
|
149
199
|
follow(camera, source, followed, dt);
|
|
150
200
|
const view = shake(camera, shaken, dt);
|
|
@@ -166,11 +216,11 @@ export function GameView(props) {
|
|
|
166
216
|
f[12] = light.shadows ?? true ? 1 : 0;
|
|
167
217
|
f[13] = light.shadowExtent ?? 30;
|
|
168
218
|
// Exactly three channels each: either color may carry an alpha that must not spill over.
|
|
169
|
-
const lightColor = light.color
|
|
219
|
+
const lightColor = light.color === undefined ? WHITE : resolveColor(light.color);
|
|
170
220
|
f[14] = lightColor[0];
|
|
171
221
|
f[15] = lightColor[1];
|
|
172
222
|
f[16] = lightColor[2];
|
|
173
|
-
const sky = environment.background;
|
|
223
|
+
const sky = resolveColor(environment.background);
|
|
174
224
|
f[17] = sky[0];
|
|
175
225
|
f[18] = sky[1];
|
|
176
226
|
f[19] = sky[2];
|
|
@@ -181,7 +231,6 @@ export function GameView(props) {
|
|
|
181
231
|
f[24] = level.shadowSize;
|
|
182
232
|
f[25] = level.softShadows ? 1 : 0;
|
|
183
233
|
const t1 = performance.now();
|
|
184
|
-
const layer = current.overlay;
|
|
185
234
|
if (layer) {
|
|
186
235
|
if (overlay?.source !== layer) overlay = {
|
|
187
236
|
source: layer,
|
|
@@ -192,28 +241,42 @@ export function GameView(props) {
|
|
|
192
241
|
overlay = null;
|
|
193
242
|
native.renderOverlay(surfaceId, regions.buffer, regions.buffer, regions.buffer, regions.buffer, 0, 0, 0); // removes it
|
|
194
243
|
}
|
|
195
|
-
const
|
|
244
|
+
const render = threaded ? native2.publishFrame : native.renderFrame;
|
|
245
|
+
const rendered = render(surfaceId, f.buffer, source.matrices.buffer, source.colors.buffer, regions.buffer, source.ranges.buffer, drawn(source), viewProj.buffer, source.nativeWorld ?? -1);
|
|
196
246
|
const drawMs = performance.now() - t1;
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
shown = true;
|
|
201
|
-
source.setView?.(viewProj, width, height);
|
|
202
|
-
} else if (rendered < 0 && errors++ < 3) {
|
|
203
|
-
report(new Error(native.renderError() || 'rendering failed'));
|
|
247
|
+
if (!threaded) {
|
|
248
|
+
renderTime += drawMs;
|
|
249
|
+
draws.push(drawMs);
|
|
204
250
|
}
|
|
251
|
+
// A published frame's camera is known at once (picking needs it), whether or not it's presented yet.
|
|
252
|
+
if (rendered === 1 || threaded && rendered === 0) source.setView?.(viewProj, width, height);
|
|
253
|
+
if (rendered === 1) shown = true;else if (rendered < 0 && errors++ < 3) report(new Error(native.renderError() || 'rendering failed'));
|
|
205
254
|
frames++;
|
|
206
255
|
if (now - fpsStart >= 1000) {
|
|
256
|
+
const elapsed = now - fpsStart;
|
|
257
|
+
let fps = frames * 1000 / elapsed;
|
|
258
|
+
let frameMs = median(intervals);
|
|
259
|
+
let renderMs = median(draws);
|
|
260
|
+
let meanRenderMs = renderTime / frames;
|
|
261
|
+
if (threaded) {
|
|
262
|
+
// What the display actually got: frames presented by the render thread, and how long each took to draw.
|
|
263
|
+
const [presented = 0, nativeMs = 0] = native2.renderStats?.(surfaceId) ?? [];
|
|
264
|
+
const shownFrames = presented - presentedAt;
|
|
265
|
+
presentedAt = presented;
|
|
266
|
+
fps = shownFrames * 1000 / elapsed;
|
|
267
|
+
frameMs = shownFrames > 0 ? Math.max(frameMs, elapsed / shownFrames) : elapsed;
|
|
268
|
+
renderMs = meanRenderMs = nativeMs;
|
|
269
|
+
}
|
|
207
270
|
auto.report(now, {
|
|
208
|
-
frameMs
|
|
209
|
-
renderMs
|
|
271
|
+
frameMs,
|
|
272
|
+
renderMs
|
|
210
273
|
});
|
|
211
274
|
intervals.length = 0;
|
|
212
275
|
draws.length = 0;
|
|
213
276
|
current.onStats?.({
|
|
214
|
-
fps
|
|
277
|
+
fps,
|
|
215
278
|
updateMs: updateTime / frames,
|
|
216
|
-
renderMs:
|
|
279
|
+
renderMs: meanRenderMs,
|
|
217
280
|
quality,
|
|
218
281
|
backend: native.renderBackend()
|
|
219
282
|
});
|
|
@@ -222,11 +285,16 @@ export function GameView(props) {
|
|
|
222
285
|
renderTime = 0;
|
|
223
286
|
fpsStart = now;
|
|
224
287
|
}
|
|
288
|
+
runAfterFrame();
|
|
225
289
|
};
|
|
226
|
-
raf = requestAnimationFrame(frame);
|
|
290
|
+
if (threaded) native2.startFrames(surfaceId, frame);else raf = requestAnimationFrame(frame);
|
|
291
|
+
runningLoops++;
|
|
227
292
|
return () => {
|
|
228
293
|
alive = false;
|
|
229
|
-
|
|
294
|
+
runningLoops--;
|
|
295
|
+
// Work waiting on this loop's frames moves to requestAnimationFrame if it was the last one.
|
|
296
|
+
if (runningLoops === 0 && afterFrame.length) requestAnimationFrame(runAfterFrame);
|
|
297
|
+
if (threaded) native2.stopFrames(surfaceId);else cancelAnimationFrame(raf);
|
|
230
298
|
};
|
|
231
299
|
// A new source or resuming a paused view restarts drawing; other props are read live.
|
|
232
300
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A light for flat, front-facing scenes (boards, puzzles, 2D games looking down -z): surfaces facing
|
|
5
|
+
* the camera (+z) are lit at exactly 1, so flat panels show their color unchanged, while rounded
|
|
6
|
+
* shapes keep their shading (lighter toward the light, darker away). Shadows are off.
|
|
7
|
+
*
|
|
8
|
+
* ```tsx
|
|
9
|
+
* <GameView source={world} light={frontLight([-0.45, 0.6, 1], 0.5)} />
|
|
10
|
+
* ```
|
|
11
|
+
*/
|
|
12
|
+
export function frontLight(direction, ambient) {
|
|
13
|
+
const [x, y, z] = direction;
|
|
14
|
+
const facing = z / Math.hypot(x, y, z);
|
|
15
|
+
// Mirrors the renderer's shading: ambient × hemisphere (0.8 for a sideways normal) plus diffuse.
|
|
16
|
+
const k = 1 / (ambient * 0.8 + (1 - ambient) * facing);
|
|
17
|
+
return {
|
|
18
|
+
direction,
|
|
19
|
+
ambient,
|
|
20
|
+
shadows: false,
|
|
21
|
+
color: [k, k, k]
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
//# sourceMappingURL=light.js.map
|
|
@@ -39,15 +39,18 @@ export const QUALITY_LEVELS = {
|
|
|
39
39
|
|
|
40
40
|
/** A typical frame slower than this (under ~50 fps) is a frame the device can't hold. */
|
|
41
41
|
const SLOW_FRAME_MS = 20;
|
|
42
|
+
/** Under ~33 fps the game is plainly struggling: one such second is enough to step down. */
|
|
43
|
+
const VERY_SLOW_FRAME_MS = 30;
|
|
42
44
|
/** A typical frame this quick (60 fps with room to spare) is one it holds comfortably. */
|
|
43
45
|
const FAST_FRAME_MS = 17.4;
|
|
44
46
|
/** Only a slow second spent mostly drawing is the GPU's fault (a slow game loop isn't helped by drawing less). */
|
|
45
47
|
const RENDER_SHARE = 0.6;
|
|
46
48
|
/** Ignore start-up (loading, shader compiles) and the moments after a change. */
|
|
47
49
|
const STARTUP_MS = 2500;
|
|
48
|
-
const SETTLE_MS =
|
|
49
|
-
/** Consecutive slow seconds before stepping down, and smooth seconds before trying a step back up. */
|
|
50
|
+
const SETTLE_MS = 1500;
|
|
51
|
+
/** Consecutive slow seconds before stepping down (one when very slow), and smooth seconds before trying a step back up. */
|
|
50
52
|
const SLOW_SECONDS = 3;
|
|
53
|
+
const VERY_SLOW_SECONDS = 1;
|
|
51
54
|
const FAST_SECONDS = 10;
|
|
52
55
|
/** Stepping down again this soon after a step up means that level is too much: never return to it. */
|
|
53
56
|
const RELAPSE_MS = 20_000;
|
|
@@ -56,8 +59,9 @@ const RELAPSE_MS = 20_000;
|
|
|
56
59
|
|
|
57
60
|
/**
|
|
58
61
|
* Picks the level for "auto": starts at the best, steps down one level after `SLOW_SECONDS`
|
|
59
|
-
* consecutive seconds whose typical frame misses ~50 fps while drawing took most of it
|
|
60
|
-
*
|
|
62
|
+
* consecutive seconds whose typical frame misses ~50 fps while drawing took most of it (after a
|
|
63
|
+
* single second when it misses ~33 fps, so a struggling phone recovers in seconds rather than
|
|
64
|
+
* stuttering through every level), and steps back up after `FAST_SECONDS` smooth seconds. A level that fails again soon after being restored
|
|
61
65
|
* becomes the ceiling, so it never bounces.
|
|
62
66
|
*/
|
|
63
67
|
export class AutoQuality {
|
|
@@ -68,6 +72,7 @@ export class AutoQuality {
|
|
|
68
72
|
changed = 0;
|
|
69
73
|
raisedAt = -Infinity;
|
|
70
74
|
slow = 0;
|
|
75
|
+
verySlow = 0;
|
|
71
76
|
fast = 0;
|
|
72
77
|
get quality() {
|
|
73
78
|
return QUALITIES[this.index];
|
|
@@ -80,8 +85,9 @@ export class AutoQuality {
|
|
|
80
85
|
const gpuBound = frames.frameMs > SLOW_FRAME_MS && frames.renderMs > frames.frameMs * RENDER_SHARE;
|
|
81
86
|
const smooth = frames.frameMs <= FAST_FRAME_MS;
|
|
82
87
|
this.slow = gpuBound ? this.slow + 1 : 0;
|
|
88
|
+
this.verySlow = gpuBound && frames.frameMs > VERY_SLOW_FRAME_MS ? this.verySlow + 1 : 0;
|
|
83
89
|
this.fast = smooth ? this.fast + 1 : 0;
|
|
84
|
-
if (this.slow >= SLOW_SECONDS && this.index < QUALITIES.length - 1) {
|
|
90
|
+
if ((this.slow >= SLOW_SECONDS || this.verySlow >= VERY_SLOW_SECONDS) && this.index < QUALITIES.length - 1) {
|
|
85
91
|
// Failing again right after being restored: this level is too much for the device
|
|
86
92
|
if (now - this.raisedAt < RELAPSE_MS) this.ceiling = Math.max(this.ceiling, this.index + 1);
|
|
87
93
|
this.index++;
|
|
@@ -97,6 +103,7 @@ export class AutoQuality {
|
|
|
97
103
|
moved(now) {
|
|
98
104
|
this.changed = now;
|
|
99
105
|
this.slow = 0;
|
|
106
|
+
this.verySlow = 0;
|
|
100
107
|
this.fast = 0;
|
|
101
108
|
return true;
|
|
102
109
|
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState } from 'react';
|
|
4
|
+
import { isEngineAvailable, World } from "../world/World.js";
|
|
5
|
+
import { warmUpEngine } from "./meshes.js";
|
|
6
|
+
|
|
7
|
+
/** What `useWorld`'s setup may return: a cleanup, run just before the world is disposed. */
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A `World` owned by a component: the engine is warmed up (needed on the web, cheap on mobile), the
|
|
11
|
+
* world is created with `capacity` entity slots and handed to `setup` to fill, and it is disposed on
|
|
12
|
+
* unmount. A change in `deps` replaces it with a fresh one. Returns null until it's ready (one frame),
|
|
13
|
+
* and while the engine is unavailable (Expo Go) or failed to start (`onError`, console by default).
|
|
14
|
+
*
|
|
15
|
+
* ```tsx
|
|
16
|
+
* const world = useWorld(100, (w) => {
|
|
17
|
+
* w.spawn({ mesh: 'plane', scale: [20, 1, 20], color: '#4C9A4C', physics: 'fixed' });
|
|
18
|
+
* w.spawn({ mesh: 'sphere', position: [0, 4, 0], color: '#3399FF', physics: 'dynamic' });
|
|
19
|
+
* });
|
|
20
|
+
* return world && <GameView source={world} style={{ flex: 1 }} />;
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
export function useWorld(capacity, setup, deps = [], onError) {
|
|
24
|
+
const [world, setWorld] = useState(null);
|
|
25
|
+
// The latest callbacks, so passing inline functions doesn't recreate the world each render.
|
|
26
|
+
const latest = useRef({
|
|
27
|
+
setup,
|
|
28
|
+
onError
|
|
29
|
+
});
|
|
30
|
+
latest.current = {
|
|
31
|
+
setup,
|
|
32
|
+
onError
|
|
33
|
+
};
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
let active = true;
|
|
36
|
+
let created = null;
|
|
37
|
+
let cleanup;
|
|
38
|
+
const report = error => {
|
|
39
|
+
const e = error instanceof Error ? error : new Error(String(error));
|
|
40
|
+
(latest.current.onError ?? (x => console.error(`useWorld: ${x.message}`)))(e);
|
|
41
|
+
};
|
|
42
|
+
if (!isEngineAvailable) {
|
|
43
|
+
report(new Error('the native engine is not linked into this build (a development build is needed, not Expo Go)'));
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
warmUpEngine().then(() => {
|
|
47
|
+
if (!active) return;
|
|
48
|
+
try {
|
|
49
|
+
created = new World(capacity);
|
|
50
|
+
cleanup = latest.current.setup?.(created);
|
|
51
|
+
setWorld(created);
|
|
52
|
+
} catch (error) {
|
|
53
|
+
created?.dispose();
|
|
54
|
+
created = null;
|
|
55
|
+
report(error);
|
|
56
|
+
}
|
|
57
|
+
}, report);
|
|
58
|
+
return () => {
|
|
59
|
+
active = false;
|
|
60
|
+
setWorld(null);
|
|
61
|
+
if (!created) return;
|
|
62
|
+
try {
|
|
63
|
+
cleanup?.();
|
|
64
|
+
} finally {
|
|
65
|
+
created.dispose();
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
69
|
+
}, [capacity, ...deps]);
|
|
70
|
+
return world;
|
|
71
|
+
}
|
|
72
|
+
//# sourceMappingURL=useWorld.js.map
|
|
@@ -33,17 +33,21 @@ const FLAG = {
|
|
|
33
33
|
/**
|
|
34
34
|
* Score, lives and the game clock, kept and advanced in Rust by `world.update`: countdowns, elapsed
|
|
35
35
|
* time and time-based points (survival, distance) cost no JS per frame. Handlers run from `update`
|
|
36
|
-
* only when something they show changed.
|
|
36
|
+
* only when something they show changed. Set `handlers` for the game's own rules, and `listen` for
|
|
37
|
+
* anything else that follows along (a UI showing the score), so neither replaces the other.
|
|
37
38
|
*
|
|
38
39
|
* ```ts
|
|
39
|
-
* world.scoreboard.handlers = {
|
|
40
|
+
* world.scoreboard.handlers = { onTimeUp: () => end(true) };
|
|
41
|
+
* const stop = world.scoreboard.listen({ onScore: setScore, onSecond: setTime });
|
|
40
42
|
* world.scoreboard.reset(3);
|
|
41
43
|
* world.scoreboard.startCountdown(60);
|
|
42
44
|
* world.scoreboard.add(10);
|
|
43
45
|
* ```
|
|
44
46
|
*/
|
|
45
47
|
export class Scoreboard {
|
|
48
|
+
/** The game's own handlers. `listen` adds more without replacing these. */
|
|
46
49
|
handlers = {};
|
|
50
|
+
listeners = new Set();
|
|
47
51
|
/** @internal */
|
|
48
52
|
constructor(world) {
|
|
49
53
|
this.world = world;
|
|
@@ -127,12 +131,26 @@ export class Scoreboard {
|
|
|
127
131
|
}
|
|
128
132
|
}
|
|
129
133
|
|
|
134
|
+
/**
|
|
135
|
+
* Adds handlers alongside `handlers` (and other listeners); they run after them, in order of
|
|
136
|
+
* listening. Returns a function that removes them.
|
|
137
|
+
*/
|
|
138
|
+
listen(handlers) {
|
|
139
|
+
this.listeners.add(handlers);
|
|
140
|
+
return () => {
|
|
141
|
+
this.listeners.delete(handlers);
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
130
145
|
/** @internal Runs handlers for what changed since the last call. */
|
|
131
146
|
dispatch() {
|
|
132
147
|
const flags = this.state[S.flags];
|
|
133
148
|
if (flags === 0) return;
|
|
134
149
|
this.state[S.flags] = 0;
|
|
135
|
-
|
|
150
|
+
this.run(this.handlers, flags);
|
|
151
|
+
if (this.listeners.size) for (const h of [...this.listeners]) this.run(h, flags);
|
|
152
|
+
}
|
|
153
|
+
run(h, flags) {
|
|
136
154
|
if (flags & FLAG.score) h.onScore?.(this.score);
|
|
137
155
|
if (flags & FLAG.lives) h.onLives?.(this.lives);
|
|
138
156
|
if (flags & FLAG.second) h.onSecond?.(this.shownSeconds);
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
|
+
import { resolveColor } from "./color.js";
|
|
3
4
|
export const TRANSFORM_STRIDE = 16;
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
|
-
* Explicit draw-only updates, applied in one native call by `flush()`.
|
|
7
|
-
*
|
|
7
|
+
* Explicit draw-only updates, applied in one native call by `flush()`. `world.update` flushes
|
|
8
|
+
* `world.transforms` before stepping; flush yourself before reading positions or animating. Records and scratch storage are reused; values are
|
|
8
9
|
* copied when queued, never retained by reference. The scalar methods (`position`, `rotation`,
|
|
9
10
|
* `scale`, `color`, `transform`) allocate nothing; consecutive calls for the same entity share one
|
|
10
11
|
* record.
|
|
@@ -105,7 +106,8 @@ export class TransformBatch {
|
|
|
105
106
|
if (typeof scale === 'number') this.data.fill(scale, offset + 9, offset + 12);else if (scale) this.data.set(scale, offset + 9);
|
|
106
107
|
if (color) this.writeColor(offset, color);
|
|
107
108
|
}
|
|
108
|
-
writeColor(offset,
|
|
109
|
+
writeColor(offset, input) {
|
|
110
|
+
const color = resolveColor(input);
|
|
109
111
|
this.data[offset + 12] = color[0];
|
|
110
112
|
this.data[offset + 13] = color[1];
|
|
111
113
|
this.data[offset + 14] = color[2];
|
|
@@ -4,6 +4,7 @@ import { layoutText } from "../assets/font.js";
|
|
|
4
4
|
import { texturedMesh } from "../assets/texture.js";
|
|
5
5
|
import NativeEngine from '../native/NativeNayanEngine';
|
|
6
6
|
import { SHAPE_MESH } from "../types.js";
|
|
7
|
+
import { resolveColor, resolveColors } from "./color.js";
|
|
7
8
|
import { ANIM_HEADER, ANIM_KEY_LEN, BURST_LEN, DESC_LEN, EASING, encode } from "./desc.js";
|
|
8
9
|
import { Scoreboard } from "./Scoreboard.js";
|
|
9
10
|
import { TRANSFORM_STRIDE, TransformBatch } from "./TransformBatch.js";
|
|
@@ -75,6 +76,7 @@ export class World {
|
|
|
75
76
|
gravityValue = [0, -9.81, 0];
|
|
76
77
|
boundsValue = null;
|
|
77
78
|
listenerValue = null;
|
|
79
|
+
updates = 0;
|
|
78
80
|
constructor(capacity) {
|
|
79
81
|
this.capacity = capacity;
|
|
80
82
|
if (!NativeEngine) {
|
|
@@ -130,7 +132,7 @@ export class World {
|
|
|
130
132
|
letterSpacing: options.letterSpacing,
|
|
131
133
|
tabular: options.tabular
|
|
132
134
|
},
|
|
133
|
-
color: options.color ??
|
|
135
|
+
color: resolveColor(options.color ?? WHITE),
|
|
134
136
|
glyphs: []
|
|
135
137
|
};
|
|
136
138
|
try {
|
|
@@ -163,7 +165,7 @@ export class World {
|
|
|
163
165
|
letterSpacing: options.letterSpacing ?? text.layout.letterSpacing,
|
|
164
166
|
tabular: options.tabular ?? text.layout.tabular
|
|
165
167
|
};
|
|
166
|
-
if (options.color) text.color = options.color;
|
|
168
|
+
if (options.color) text.color = resolveColor(options.color);
|
|
167
169
|
if (rebuild) this.buildText(e, text);else if (options.color) {
|
|
168
170
|
const desc = this.encodeColor(options.color);
|
|
169
171
|
for (const g of text.glyphs) this.native.setDesc(this.id, g, desc);
|
|
@@ -174,7 +176,8 @@ export class World {
|
|
|
174
176
|
|
|
175
177
|
/**
|
|
176
178
|
* This world's shared draw-only transform batch (created on first use). Queue per-frame moves with
|
|
177
|
-
* its allocation-free methods
|
|
179
|
+
* its allocation-free methods; the next `update()` applies them first (or `flush()` them yourself
|
|
180
|
+
* before reading positions or animating).
|
|
178
181
|
*/
|
|
179
182
|
get transforms() {
|
|
180
183
|
return this.transformsValue ??= this.createTransformBatch();
|
|
@@ -358,10 +361,11 @@ export class World {
|
|
|
358
361
|
}
|
|
359
362
|
if (k.color) {
|
|
360
363
|
flags |= KEY_FLAG.color;
|
|
361
|
-
|
|
362
|
-
a[o +
|
|
363
|
-
a[o +
|
|
364
|
-
a[o +
|
|
364
|
+
const c = resolveColor(k.color);
|
|
365
|
+
a[o + 12] = c[0];
|
|
366
|
+
a[o + 13] = c[1];
|
|
367
|
+
a[o + 14] = c[2];
|
|
368
|
+
a[o + 15] = c[3] ?? 1;
|
|
365
369
|
}
|
|
366
370
|
if (k.moveBy) {
|
|
367
371
|
flags |= KEY_FLAG.moveBy;
|
|
@@ -393,9 +397,8 @@ export class World {
|
|
|
393
397
|
*/
|
|
394
398
|
burst(options) {
|
|
395
399
|
const b = this.burstDesc;
|
|
396
|
-
const
|
|
397
|
-
const
|
|
398
|
-
const colorCount = single ? 1 : Math.max(1, Math.min(4, color.length));
|
|
400
|
+
const colors = resolveColors(options.color ?? WHITE);
|
|
401
|
+
const colorCount = Math.max(1, Math.min(4, colors.length));
|
|
399
402
|
b.set(options.position, 0);
|
|
400
403
|
b.set(options.direction ?? UP, 3);
|
|
401
404
|
b[6] = options.spread ?? Math.PI;
|
|
@@ -406,11 +409,11 @@ export class World {
|
|
|
406
409
|
b[11] = options.lifetime ?? 0.8;
|
|
407
410
|
b[12] = options.gravity ?? -9.81;
|
|
408
411
|
b[13] = colorCount;
|
|
409
|
-
for (let k = 0; k < colorCount; k++) writeRgba(b, 14 + k * 4,
|
|
412
|
+
for (let k = 0; k < colorCount; k++) writeRgba(b, 14 + k * 4, colors[k]);
|
|
410
413
|
b[30] = options.endSize ?? 1;
|
|
411
414
|
const end = options.endColor;
|
|
412
415
|
b[31] = end ? 1 : 0;
|
|
413
|
-
if (end) writeRgba(b, 32, end);
|
|
416
|
+
if (end) writeRgba(b, 32, resolveColor(end));
|
|
414
417
|
return this.native.burst(this.id, b.buffer);
|
|
415
418
|
}
|
|
416
419
|
|
|
@@ -468,6 +471,11 @@ export class World {
|
|
|
468
471
|
return this.scoreboardValue ??= new Scoreboard(this.id);
|
|
469
472
|
}
|
|
470
473
|
|
|
474
|
+
/** How many times `update` has run. GameView reads it to step the world only when `onUpdate` didn't. */
|
|
475
|
+
get updateCount() {
|
|
476
|
+
return this.updates;
|
|
477
|
+
}
|
|
478
|
+
|
|
471
479
|
/** Timers waiting to fire. */
|
|
472
480
|
get timerCount() {
|
|
473
481
|
return this.timers.size + this.fallbackTimers.length;
|
|
@@ -647,8 +655,14 @@ export class World {
|
|
|
647
655
|
|
|
648
656
|
// ── Simulation ───────────────────────────────────────────────────────
|
|
649
657
|
|
|
650
|
-
/**
|
|
658
|
+
/**
|
|
659
|
+
* Advances the simulation (fixed 60 Hz steps), resolves finished animations, runs due timers and
|
|
660
|
+
* scoreboard handlers, and refreshes what GameView draws. `GameView` calls it for you each frame;
|
|
661
|
+
* call it yourself from `onUpdate` only to step with a different `dt` (it is then not stepped twice).
|
|
662
|
+
*/
|
|
651
663
|
update(dt) {
|
|
664
|
+
this.updates++;
|
|
665
|
+
this.transformsValue?.flush(); // moves queued this frame land before the step
|
|
652
666
|
this.native.update(this.id, dt);
|
|
653
667
|
this.resolveFinished();
|
|
654
668
|
this.runFallbackTimers(dt);
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// Colors: every engine option that takes a color accepts either a linear 0..1 tuple or a CSS color
|
|
4
|
+
// string, so theme colors go straight in: `world.spawn({ color: '#FF4757' })`.
|
|
5
|
+
|
|
6
|
+
/** A color as an option takes it: `[r, g, b]` / `[r, g, b, a]` (0..1, channels above 1 glow) or a CSS string. */
|
|
7
|
+
|
|
8
|
+
const HEX = /^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
|
|
9
|
+
const RGB = /^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*(?:,\s*([\d.]+%?)\s*)?\)$/i;
|
|
10
|
+
const NAMED = {
|
|
11
|
+
transparent: [0, 0, 0, 0],
|
|
12
|
+
black: [0, 0, 0, 1],
|
|
13
|
+
white: [1, 1, 1, 1],
|
|
14
|
+
red: [1, 0, 0, 1],
|
|
15
|
+
green: [0, 128 / 255, 0, 1],
|
|
16
|
+
blue: [0, 0, 1, 1],
|
|
17
|
+
yellow: [1, 1, 0, 1],
|
|
18
|
+
orange: [1, 165 / 255, 0, 1],
|
|
19
|
+
purple: [128 / 255, 0, 128 / 255, 1],
|
|
20
|
+
gray: [128 / 255, 128 / 255, 128 / 255, 1],
|
|
21
|
+
grey: [128 / 255, 128 / 255, 128 / 255, 1]
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// Theme colors repeat endlessly; parse each string once.
|
|
25
|
+
const parsed = new Map();
|
|
26
|
+
const PARSED_LIMIT = 1024;
|
|
27
|
+
function parse(css) {
|
|
28
|
+
const text = css.trim();
|
|
29
|
+
const named = NAMED[text.toLowerCase()];
|
|
30
|
+
if (named) return named;
|
|
31
|
+
const hex = HEX.exec(text);
|
|
32
|
+
if (hex) {
|
|
33
|
+
let digits = hex[1];
|
|
34
|
+
if (digits.length <= 4) digits = [...digits].map(d => d + d).join('');
|
|
35
|
+
const n = i => parseInt(digits.slice(i, i + 2), 16) / 255;
|
|
36
|
+
return [n(0), n(2), n(4), digits.length === 8 ? n(6) : 1];
|
|
37
|
+
}
|
|
38
|
+
const rgb = RGB.exec(text);
|
|
39
|
+
if (rgb) {
|
|
40
|
+
const alpha = rgb[4] === undefined ? 1 : rgb[4].endsWith('%') ? parseFloat(rgb[4]) / 100 : Number(rgb[4]);
|
|
41
|
+
return [Number(rgb[1]) / 255, Number(rgb[2]) / 255, Number(rgb[3]) / 255, alpha];
|
|
42
|
+
}
|
|
43
|
+
throw new Error(`@nayan-ui/engine: "${css}" is not a color (use '#RRGGBB', '#RGB', '#RRGGBBAA', 'rgb(…)', 'rgba(…)' or a 0..1 tuple)`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Any color input as an `[r, g, b, a]` tuple (0..1), with its alpha multiplied by `alpha`. Use it when
|
|
48
|
+
* you need the numbers (to brighten, mix or fade a theme color); options take strings directly.
|
|
49
|
+
*
|
|
50
|
+
* ```ts
|
|
51
|
+
* toColor('#FF4757'); // [1, 0.278, 0.341, 1]
|
|
52
|
+
* toColor('#FF4757', 0.5); // half see-through
|
|
53
|
+
* ```
|
|
54
|
+
*/
|
|
55
|
+
export function toColor(color, alpha = 1) {
|
|
56
|
+
let c;
|
|
57
|
+
if (typeof color === 'string') {
|
|
58
|
+
let hit = parsed.get(color);
|
|
59
|
+
if (!hit) {
|
|
60
|
+
hit = parse(color);
|
|
61
|
+
if (parsed.size >= PARSED_LIMIT) parsed.clear();
|
|
62
|
+
parsed.set(color, hit);
|
|
63
|
+
}
|
|
64
|
+
c = hit;
|
|
65
|
+
} else c = color;
|
|
66
|
+
return [c[0], c[1], c[2], (c[3] ?? 1) * alpha];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A color input as a tuple, without copying tuples (the hot path of `spawn`, `set` and `animate`). */
|
|
70
|
+
export function resolveColor(color) {
|
|
71
|
+
return typeof color === 'string' ? toColor(color) : color;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** One color input, or a list to pick from, as tuples. */
|
|
75
|
+
export function resolveColors(colors) {
|
|
76
|
+
if (typeof colors === 'string') return [toColor(colors)];
|
|
77
|
+
if (typeof colors[0] === 'number') return [colors];
|
|
78
|
+
return colors.map(resolveColor);
|
|
79
|
+
}
|
|
80
|
+
//# sourceMappingURL=color.js.map
|
package/lib/module/world/desc.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// Encodes entity options into the flat layout the Rust core decodes (native/core/include/engine_core.h,
|
|
4
4
|
// native/core/src/world/desc.rs). One reusable Float64Array per world: spawning or changing an entity is
|
|
5
5
|
// a single native call, and doubles keep entity ids and layer bits exact.
|
|
6
|
-
|
|
6
|
+
import { resolveColor } from "./color.js";
|
|
7
7
|
export const DESC_LEN = 64;
|
|
8
8
|
const FLAG = {
|
|
9
9
|
MESH: 1 << 0,
|
|
@@ -102,10 +102,11 @@ export function encode(d, o, mesh) {
|
|
|
102
102
|
}
|
|
103
103
|
if (o.color) {
|
|
104
104
|
flags |= FLAG.COLOR;
|
|
105
|
-
|
|
106
|
-
d[SLOT.COLOR
|
|
107
|
-
d[SLOT.COLOR +
|
|
108
|
-
d[SLOT.COLOR +
|
|
105
|
+
const c = resolveColor(o.color);
|
|
106
|
+
d[SLOT.COLOR] = c[0];
|
|
107
|
+
d[SLOT.COLOR + 1] = c[1];
|
|
108
|
+
d[SLOT.COLOR + 2] = c[2];
|
|
109
|
+
d[SLOT.COLOR + 3] = c[3] ?? 1;
|
|
109
110
|
}
|
|
110
111
|
if (o.velocity) {
|
|
111
112
|
flags |= FLAG.VELOCITY;
|