@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.
Files changed (71) hide show
  1. package/README.md +167 -69
  2. package/lib/module/assets/AssetLoader.js +1 -1
  3. package/lib/module/assets/ProceduralCache.js +31 -4
  4. package/lib/module/assets/font.js +10 -7
  5. package/lib/module/assets/model.js +41 -19
  6. package/lib/module/assets/storage.shared.js +2 -0
  7. package/lib/module/assets/storage.web.js +1 -0
  8. package/lib/module/assets/texture.js +14 -11
  9. package/lib/module/index.js +4 -1
  10. package/lib/module/media/audio.js +6 -3
  11. package/lib/module/native/BrowserEngine.js +17 -0
  12. package/lib/module/render/GameView.js +86 -18
  13. package/lib/module/render/light.js +24 -0
  14. package/lib/module/render/quality.js +12 -5
  15. package/lib/module/render/useWorld.js +72 -0
  16. package/lib/module/world/Scoreboard.js +21 -3
  17. package/lib/module/world/TransformBatch.js +5 -3
  18. package/lib/module/world/World.js +27 -13
  19. package/lib/module/world/color.js +80 -0
  20. package/lib/module/world/desc.js +6 -5
  21. package/lib/module/world/effects.js +20 -8
  22. package/lib/typescript/src/assets/AssetLoader.d.ts +1 -1
  23. package/lib/typescript/src/assets/ProceduralCache.d.ts +12 -0
  24. package/lib/typescript/src/assets/model.d.ts +10 -0
  25. package/lib/typescript/src/index.d.ts +4 -1
  26. package/lib/typescript/src/native/EngineBridge.d.ts +6 -2
  27. package/lib/typescript/src/native/spec/NativeNayanEngine.d.ts +29 -0
  28. package/lib/typescript/src/render/GameView.d.ts +19 -7
  29. package/lib/typescript/src/render/light.d.ts +12 -0
  30. package/lib/typescript/src/render/quality.d.ts +4 -2
  31. package/lib/typescript/src/render/useWorld.d.ts +20 -0
  32. package/lib/typescript/src/types.d.ts +13 -4
  33. package/lib/typescript/src/world/Scoreboard.d.ts +12 -2
  34. package/lib/typescript/src/world/TransformBatch.d.ts +2 -2
  35. package/lib/typescript/src/world/World.d.ts +10 -2
  36. package/lib/typescript/src/world/color.d.ts +18 -0
  37. package/lib/typescript/src/world/effects.d.ts +13 -12
  38. package/lib/typescript/src/world/types.d.ts +11 -6
  39. package/lib/web/engine_core_bg.wasm +0 -0
  40. package/lib/web/index.js +521 -255
  41. package/lib/web/types/src/assets/AssetLoader.d.ts +1 -1
  42. package/lib/web/types/src/assets/ProceduralCache.d.ts +12 -0
  43. package/lib/web/types/src/assets/model.d.ts +10 -0
  44. package/lib/web/types/src/index.d.ts +4 -1
  45. package/lib/web/types/src/native/EngineBridge.d.ts +6 -2
  46. package/lib/web/types/src/native/spec/NativeNayanEngine.d.ts +29 -0
  47. package/lib/web/types/src/render/GameView.d.ts +19 -7
  48. package/lib/web/types/src/render/light.d.ts +11 -0
  49. package/lib/web/types/src/render/quality.d.ts +4 -2
  50. package/lib/web/types/src/render/useWorld.d.ts +19 -0
  51. package/lib/web/types/src/types.d.ts +13 -4
  52. package/lib/web/types/src/world/Scoreboard.d.ts +12 -2
  53. package/lib/web/types/src/world/TransformBatch.d.ts +2 -2
  54. package/lib/web/types/src/world/World.d.ts +10 -2
  55. package/lib/web/types/src/world/color.d.ts +17 -0
  56. package/lib/web/types/src/world/effects.d.ts +13 -12
  57. package/lib/web/types/src/world/types.d.ts +11 -6
  58. package/native/android/src/main/java/com/nayanui/engine/EngineSurfaceView.java +87 -26
  59. package/native/core/build/EngineCore.xcframework/ios-arm64/libengine_core.a +0 -0
  60. package/native/core/build/EngineCore.xcframework/ios-arm64-simulator/libengine_core.a +0 -0
  61. package/native/core/build/android/arm64-v8a/libengine_core.a +0 -0
  62. package/native/core/build/android/armeabi-v7a/libengine_core.a +0 -0
  63. package/native/core/build/android/x86/libengine_core.a +0 -0
  64. package/native/core/build/android/x86_64/libengine_core.a +0 -0
  65. package/native/core/include/engine_core.h +9 -0
  66. package/native/cpp/NayanEngineModule.cpp +171 -0
  67. package/native/cpp/NayanEngineModule.h +30 -0
  68. package/native/cpp/generated/NayanEngineSpecJSI.h +105 -0
  69. package/native/ios/NayanEngineSurface.mm +30 -1
  70. package/package.json +2 -2
  71. 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 ?? WHITE;
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 rendered = native.renderFrame(surfaceId, f.buffer, source.matrices.buffer, source.colors.buffer, regions.buffer, source.ranges.buffer, drawn(source), viewProj.buffer, source.nativeWorld ?? -1);
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
- renderTime += drawMs;
198
- draws.push(drawMs);
199
- if (rendered === 1) {
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: median(intervals),
209
- renderMs: median(draws)
271
+ frameMs,
272
+ renderMs
210
273
  });
211
274
  intervals.length = 0;
212
275
  draws.length = 0;
213
276
  current.onStats?.({
214
- fps: frames * 1000 / (now - fpsStart),
277
+ fps,
215
278
  updateMs: updateTime / frames,
216
- renderMs: renderTime / frames,
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
- cancelAnimationFrame(raf);
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 = 2000;
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, and steps
60
- * back up after `FAST_SECONDS` smooth seconds. A level that fails again soon after being restored
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 = { onScore: setScore, onSecond: setTime, onTimeUp: () => end(true) };
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
- const h = this.handlers;
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()`. Flush before reading
7
- * positions, animating, or stepping the world. Records and scratch storage are reused; values are
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, color) {
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 ?? [1, 1, 1],
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 after `update()`, then `flush()` once before the frame is drawn.
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
- a[o + 12] = k.color[0];
362
- a[o + 13] = k.color[1];
363
- a[o + 14] = k.color[2];
364
- a[o + 15] = k.color[3] ?? 1;
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 color = options.color ?? WHITE;
397
- const single = typeof color[0] === 'number';
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, single ? color : color[k]);
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
- /** Advances the simulation (fixed 60 Hz steps), resolves finished animations, refreshes what GameView draws. */
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
@@ -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
- d[SLOT.COLOR] = o.color[0];
106
- d[SLOT.COLOR + 1] = o.color[1];
107
- d[SLOT.COLOR + 2] = o.color[2];
108
- d[SLOT.COLOR + 3] = o.color[3] ?? 1;
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;