@nayan-ui/engine 1.0.0-beta.4 → 1.0.0-beta.6

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 (77) hide show
  1. package/README.md +225 -116
  2. package/lib/module/assets/ProceduralCache.js +5 -0
  3. package/lib/module/assets/loft.js +15 -63
  4. package/lib/module/assets/procedural.js +500 -0
  5. package/lib/module/assets/shape.js +147 -245
  6. package/lib/module/assets/texture.js +65 -0
  7. package/lib/module/drawing/DrawingDocument.js +1 -1
  8. package/lib/module/index.js +4 -1
  9. package/lib/module/media/audio-worklet.js +27 -0
  10. package/lib/module/media/audio.js +20 -0
  11. package/lib/module/media/input.web.js +107 -0
  12. package/lib/module/media/microphone.js +89 -0
  13. package/lib/module/media/speech.js +80 -0
  14. package/lib/module/media/speechPlatform.js +12 -0
  15. package/lib/module/media/speechPlatform.web.js +16 -0
  16. package/lib/module/native/BrowserEngine.js +122 -0
  17. package/lib/module/native/NativeNayanEngine.web.js +2 -0
  18. package/lib/module/render/GameView.js +45 -13
  19. package/lib/module/render/quality.js +151 -17
  20. package/lib/module/world/World.js +63 -0
  21. package/lib/typescript/src/assets/ProceduralCache.d.ts +2 -0
  22. package/lib/typescript/src/assets/loft.d.ts +3 -1
  23. package/lib/typescript/src/assets/procedural.d.ts +394 -0
  24. package/lib/typescript/src/assets/shape.d.ts +13 -7
  25. package/lib/typescript/src/assets/texture.d.ts +63 -0
  26. package/lib/typescript/src/index.d.ts +8 -2
  27. package/lib/typescript/src/media/audio.d.ts +13 -0
  28. package/lib/typescript/src/media/input.web.d.ts +4 -0
  29. package/lib/typescript/src/media/microphone.d.ts +51 -0
  30. package/lib/typescript/src/media/speech.d.ts +38 -0
  31. package/lib/typescript/src/media/speechPlatform.d.ts +5 -0
  32. package/lib/typescript/src/media/speechPlatform.web.d.ts +5 -0
  33. package/lib/typescript/src/native/EngineBridge.d.ts +7 -1
  34. package/lib/typescript/src/native/spec/NativeNayanEngine.d.ts +72 -0
  35. package/lib/typescript/src/render/GameView.d.ts +30 -3
  36. package/lib/typescript/src/render/quality.d.ts +54 -2
  37. package/lib/typescript/src/types.d.ts +9 -2
  38. package/lib/typescript/src/world/World.d.ts +16 -0
  39. package/lib/typescript/src/world/types.d.ts +8 -0
  40. package/lib/web/audio-worklet.js +27 -0
  41. package/lib/web/engine_core_bg.wasm +0 -0
  42. package/lib/web/index.js +1181 -422
  43. package/lib/web/types/src/assets/ProceduralCache.d.ts +2 -0
  44. package/lib/web/types/src/assets/loft.d.ts +3 -1
  45. package/lib/web/types/src/assets/procedural.d.ts +393 -0
  46. package/lib/web/types/src/assets/shape.d.ts +13 -7
  47. package/lib/web/types/src/assets/texture.d.ts +63 -0
  48. package/lib/web/types/src/index.d.ts +8 -2
  49. package/lib/web/types/src/media/audio.d.ts +13 -0
  50. package/lib/web/types/src/media/input.d.ts +3 -0
  51. package/lib/web/types/src/media/microphone.d.ts +50 -0
  52. package/lib/web/types/src/media/speech.d.ts +37 -0
  53. package/lib/web/types/src/media/speechPlatform.d.ts +4 -0
  54. package/lib/web/types/src/native/EngineBridge.d.ts +7 -1
  55. package/lib/web/types/src/native/spec/NativeNayanEngine.d.ts +72 -0
  56. package/lib/web/types/src/render/GameView.d.ts +30 -3
  57. package/lib/web/types/src/render/quality.d.ts +54 -2
  58. package/lib/web/types/src/types.d.ts +9 -2
  59. package/lib/web/types/src/world/World.d.ts +16 -0
  60. package/lib/web/types/src/world/types.d.ts +8 -0
  61. package/native/android/src/main/AndroidManifest.xml +10 -0
  62. package/native/android/src/main/java/com/nayanui/engine/EngineMicrophone.java +60 -0
  63. package/native/android/src/main/java/com/nayanui/engine/EngineSpeech.java +28 -0
  64. package/native/android/src/main/java/com/nayanui/engine/EngineSurfaceView.java +1 -0
  65. package/native/android/src/main/java/com/nayanui/engine/NayanEnginePackage.java +2 -0
  66. package/native/core/build/EngineCore.xcframework/ios-arm64/libengine_core.a +0 -0
  67. package/native/core/build/EngineCore.xcframework/ios-arm64-simulator/libengine_core.a +0 -0
  68. package/native/core/build/android/arm64-v8a/libengine_core.a +0 -0
  69. package/native/core/build/android/armeabi-v7a/libengine_core.a +0 -0
  70. package/native/core/build/android/x86/libengine_core.a +0 -0
  71. package/native/core/build/android/x86_64/libengine_core.a +0 -0
  72. package/native/core/include/engine_core.h +63 -1
  73. package/native/cpp/NayanEngineModule.cpp +296 -0
  74. package/native/cpp/NayanEngineModule.h +40 -0
  75. package/native/cpp/generated/NayanEngineSpecJSI.h +397 -0
  76. package/package.json +1 -1
  77. package/src/native/spec/NativeNayanEngine.ts +77 -0
package/README.md CHANGED
@@ -4,13 +4,29 @@
4
4
 
5
5
  A fast 3D game engine for **React** and **React Native**. One TypeScript API on one Rust core: rendering (wgpu), physics (Rapier), skeletal and tween animation, particles, audio, haptics and 2D drawing.
6
6
 
7
- **[Documentation](https://www.nayanui.com/game-engine)** · **[First game tutorial](https://www.nayanui.com/game-engine/first-game)** · **[API reference](https://www.nayanui.com/game-engine/api-reference)** · **[Guide for AI agents](https://www.nayanui.com/game-engine/agent-guide)**
7
+ **[Documentation](https://www.nayanui.com/game-engine)** · **[First game tutorial](https://www.nayanui.com/game-engine/first-game)** · **[API reference](https://www.nayanui.com/game-engine/api-reference)** · **[Guide for AI agents](https://www.nayanui.com/game-engine/agent-guide)** · **[Prebuilt games](https://www.nayanui.com/games)**
8
+
9
+ ## Contents
10
+
11
+ - [Features](#features)
12
+ - [Install](#install)
13
+ - [Quick start](#quick-start)
14
+ - [Core concepts](#core-concepts)
15
+ - [Guides](#guides): [Entities](#entities) · [Game loop](#game-loop) · [Physics](#physics) · [Animation](#animation) · [Characters](#characters) · [Effects](#effects) · [Input](#input) · [Assets](#assets) · [Sound and haptics](#sound-and-haptics) · [Score and timers](#score-and-timers) · [Shapes in code](#shapes-in-code) · [Flat boards](#flat-boards-and-puzzles) · [Drawing](#drawing) · [Colors](#colors)
16
+ - [Performance](#performance)
17
+ - [Troubleshooting](#troubleshooting)
18
+ - [Prebuilt games](#prebuilt-games)
19
+ - [Learn more](#learn-more)
20
+
21
+ ## Features
8
22
 
9
23
  - **Native speed on phones.** Rendering, physics, animation, culling and audio mixing run in Rust. On iOS and Android the game loop follows the display's clock, and frames are drawn on the engine's own render thread, so your JavaScript never waits on the GPU.
10
24
  - **Small API.** `useWorld` + `GameView` is a complete scene. Spawn entities, move them, animate them: the engine steps, draws and cleans up.
11
25
  - **Fast loading.** Images, glTF models, fonts and sounds decode on native background threads, and procedural meshes can be cached on disk between launches.
12
26
  - **Same code on the web.** The core compiles to WebAssembly and draws with WebGPU, falling back to WebGL2.
13
27
 
28
+ ### Platforms
29
+
14
30
  | Platform | Renders with | Needs |
15
31
  | ----------- | ------------------------------ | --------------------------------------------------------- |
16
32
  | iOS | Metal | A development or store build (Expo Go doesn't include it) |
@@ -78,7 +94,7 @@ export function BouncingBall() {
78
94
 
79
95
  `useWorld` warms the engine up, creates the world (100 entity slots), runs your setup and disposes it on unmount. `GameView` draws it and steps it every frame.
80
96
 
81
- ## The five rules
97
+ ## Core concepts
82
98
 
83
99
  1. **Own** a world with `useWorld(capacity, setup)`, or create it yourself after `await warmUpEngine()` and `dispose()` it on unmount.
84
100
  2. **Play** in `GameView`'s `onUpdate(dt)`: read input, move things, spawn. The world is stepped for you right after (call `world.update(dt)` yourself only to step it earlier or with another `dt`).
@@ -86,197 +102,282 @@ export function BouncingBall() {
86
102
  4. **Batch per-frame moves** in `world.transforms`: they are applied in one native call when the world steps (`flush()` yourself before reading positions).
87
103
  5. **Don't track disposal yourself**: check `world.isDisposed`. Cleanup calls are safe no-ops after `dispose()`.
88
104
 
89
- ## Performance
105
+ ## Guides
90
106
 
91
- The engine is built so that a game written the obvious way stays smooth on mid-range Android phones.
107
+ Each snippet assumes a `world` from `useWorld`.
92
108
 
93
- - **Display clock and render thread (iOS and Android).** The display's vsync (`CADisplayLink` on iOS, `Choreographer` on Android) asks for each frame at 60 Hz. `onUpdate` steps the game and hands a copy of the frame to a native render thread, which culls, submits and presents it. The JS thread never waits on the GPU, and a frame that is still waiting when a newer one arrives is replaced, not queued. The clock stops while the app is in the background or the view is off screen. On the web, `requestAnimationFrame` drives the same loop.
94
- - **Culling in Rust.** Everything outside the camera's view (and outside the light's box for shadows) is skipped every frame, so open levels and long tracks only pay for what is on screen. Don't cull scenery in JS.
95
- - **Automatic quality.** `quality="auto"` (the default) starts at `high` and steps down (shadow detail first, then anti-aliasing, resolution last) only while the device can't keep up. `onStats` reports fps, update and render time, the quality chosen and the graphics backend.
96
- - **Background loading.** `loadTexture`, `loadModel`, `loadFont` and `audio.load` decode on native threads in parallel, and meshes are registered with the renderer inside Rust.
97
- - **Work between frames.** `afterNextFrame(fn)` runs `fn` right after the next frame leaves, so you can spread long setup work (map generation, mesh building) over several frames behind a loading screen.
98
- - **Cached procedural assets.** `ProceduralCache` saves built geometry on disk keyed by a recipe, so later launches restore it instead of rebuilding; `peek(key)` returns a saved value without building anything.
109
+ ### Entities
99
110
 
100
- Keep these habits:
111
+ ```ts
112
+ const box = world.spawn({ mesh: 'cube', position: [0, 1, 0], color: '#FF4757' });
113
+ world.set(box, { color: '#2ED573', scale: 2 }); // only the options you pass change
114
+ world.despawn(box);
115
+ ```
101
116
 
102
- - Build meshes once, at module scope or behind a cache, and spawn new entities from them. Mesh ids are app-wide and never freed (`getMeshIdLimit()` tells you how many there are), so never create a mesh per spawn or per frame.
103
- - Move many things each frame through `world.transforms` and the `out` arguments of queries, which allocate nothing.
104
- - Keep long one-off jobs out of gameplay frames: a single slow frame stalls Android's Hermes far longer than desktop JavaScript suggests.
117
+ Built-in meshes include `'cube'`, `'sphere'`, `'plane'` and `'cylinder'`.
105
118
 
106
- ## Common tasks
119
+ ### Game loop
120
+
121
+ ```tsx
122
+ <GameView
123
+ source={world}
124
+ onUpdate={(dt) => {
125
+ world.transforms.set(player, { position: [x, 0, z] }); // batched, applied as the world steps
126
+ }}
127
+ />
128
+ ```
107
129
 
108
- **Physics and collisions**
130
+ ### Physics
109
131
 
110
132
  ```ts
111
133
  const ball = world.spawn({ mesh: 'sphere', position: [0, 5, 0], physics: { type: 'dynamic', bounce: 0.6 } });
112
134
  const coin = world.spawn({ mesh: 'cylinder', position: [2, 1, 0], physics: { type: 'fixed', sensor: true } });
135
+
113
136
  world.impulse(ball, [0, 4, 0]);
114
137
  world.forEachCollision((a, b, { started }) => {
115
138
  if (started && (a === coin || b === coin)) world.despawn(coin);
116
139
  });
117
140
  ```
118
141
 
119
- **Animation** (runs in Rust and returns a Promise)
142
+ ### Animation
143
+
144
+ Tweens run in Rust and return a Promise.
120
145
 
121
146
  ```ts
122
- await world.animate(piece, { position: [2, 0, 3] }, { duration: 0.25, easing: 'back' });
147
+ await world.animate(box, { position: [2, 0, 3] }, { duration: 0.25, easing: 'back' });
123
148
  world.animate(star, { scale: 1.2 }, { repeat: 'forever', yoyo: true });
124
149
  ```
125
150
 
126
- **Skinned characters**: glTF clips play natively, with crossfades
151
+ ### Characters
152
+
153
+ glTF clips play natively, with crossfades.
127
154
 
128
155
  ```ts
129
156
  const hero = await loadModel(require('./hero.glb'));
130
- console.log(hero.animations.map((clip) => clip.name)); // e.g. ['idle', 'walk', 'run']
131
157
  const player = world.spawn({ mesh: hero });
132
- world.playClip(player, 'idle', { speed: 0 }); // hold the first pose on the ready screen
133
- world.playClip(player, 'run', { fade: 0.2 }); // crossfade when play starts
158
+
159
+ world.playClip(player, 'idle');
160
+ world.playClip(player, 'run', { fade: 0.2 });
134
161
  world.stopClip(player);
135
162
  ```
136
163
 
137
- `world.sampleSkinnedVertices` reads chosen vertices of the next pose (for planting feet on uneven ground) without skinning the whole character in JS.
164
+ Clip names are in `hero.animations`. `world.sampleSkinnedVertices` reads chosen vertices of the next pose (for planting feet on uneven ground).
138
165
 
139
- **Ready-made effects**
166
+ ### Effects
140
167
 
141
168
  ```ts
142
169
  import { effects } from '@nayan-ui/engine';
143
170
 
144
- effects.confetti(world, {
145
- position: [0, 2, 0],
146
- colors: ['#FF4D4D', '#FFD933'],
147
- size: 0.12,
148
- speed: 6,
149
- count: 40,
150
- lifetime: 1.2,
151
- gravity: -6
152
- });
153
- effects.dust(world, { position: [0, 0, 0], color: [0.8, 0.75, 0.65, 0.6], size: 0.3, speed: 1.5, count: 6, lifetime: 0.5 });
154
- await effects.pop(world, piece, { scale: 1, color: '#FF4D4D' }); // swell, flash white, shrink away
155
- world.despawn(piece);
171
+ effects.confetti(world, { position: [0, 2, 0], colors: ['#FF4D4D', '#FFD933'], count: 40 });
172
+ await effects.pop(world, box, { scale: 1, color: '#FF4D4D' }); // swell, flash, shrink away
156
173
  ```
157
174
 
158
- Also available: `sparkle`, `shatter`, `splash`, `sparks`, `explosion` and `flash`.
175
+ Also available: `dust`, `sparkle`, `shatter`, `splash`, `sparks`, `explosion` and `flash`.
159
176
 
160
- **Per-frame movement without allocations**
177
+ ### Input
178
+
179
+ ```tsx
180
+ <GameView
181
+ source={world}
182
+ onTap={(x, y) => {
183
+ const hit = world.pick(x, y);
184
+ if (hit) world.animate(hit.entity, { scale: 1.2 }, { yoyo: true, repeat: 1 });
185
+ }}
186
+ onSwipe={(direction) => move(direction)}
187
+ />
188
+ ```
189
+
190
+ Also available: `onDrag`, `onLongPress`, `onPinch`, the on-screen `Joystick` and `createKeyboardInput` (web).
191
+
192
+ ### Assets
161
193
 
162
194
  ```ts
163
- import { type MutableQuat, quat } from '@nayan-ui/engine';
195
+ import { loadFont, loadModel, loadTexture } from '@nayan-ui/engine';
164
196
 
165
- const turn: MutableQuat = [0, 0, 0, 1];
166
- const at: [number, number, number] = [0, 0, 0];
197
+ const car = await loadModel(require('./car.glb'), { fit: 2 }); // or a URL on the web
198
+ const crate = await loadTexture(require('./crate.png'));
199
+ const font = await loadFont(require('./Inter-Bold.ttf'));
167
200
 
168
- function onUpdate(dt: number) {
169
- world.position(player, at); // queries can fill an `out` array
170
- quat.y(heading, turn);
171
- world.transforms.transform(car, carX, 0, carZ, turn[0], turn[1], turn[2], turn[3]); // applied in one native call as the world steps
172
- }
201
+ world.spawn({ mesh: car });
202
+ world.spawn({ mesh: 'cube', texture: crate });
203
+ world.spawn({ text: '2048', font, position: [0, 2, 0], scale: 0.5 });
173
204
  ```
174
205
 
175
- **Models built in code**: bake a shape once, spawn it many times
206
+ ### Sound and haptics
176
207
 
177
208
  ```ts
178
- import { createShape, lathe, merge, tube } from '@nayan-ui/engine';
209
+ import { audio, haptics } from '@nayan-ui/engine';
179
210
 
180
- // Built once per app (after warmUpEngine), at module scope.
181
- const vase = createShape(
182
- merge([
183
- {
184
- geometry: lathe(
185
- [
186
- [0, 0],
187
- [0.4, 0.1],
188
- [0.25, 0.8],
189
- [0.3, 1]
190
- ],
191
- { segments: 24 }
192
- )
193
- },
194
- {
195
- geometry: tube(
196
- [
197
- [0.3, 0.9, 0],
198
- [0.5, 0.7, 0],
199
- [0.3, 0.5, 0]
200
- ],
201
- { radius: 0.04 }
202
- ),
203
- color: [0.5, 0.25, 0.1]
204
- } // part colors are 0..1 tuples
205
- ])
206
- );
207
- world.spawn({ mesh: vase, color: '#C8763A' });
211
+ const sfx = audio.load({ coin: require('./coin.wav') });
212
+ sfx.play('coin');
213
+ haptics.impact(0.5);
214
+
215
+ audio.enabled = settings.sounds; // respect the player's preferences
216
+ haptics.enabled = settings.haptics;
208
217
  ```
209
218
 
210
- Also available: `extrude`, `loft`, `block`, `ellipsoid`, `parametricSurface`, `Solid` and `shapeGeometry` for the built-in shapes.
219
+ ### System speech
220
+
221
+ ```ts
222
+ import { speech } from '@nayan-ui/engine';
223
+
224
+ await speech.speak('Well done!', { language: 'en-US', rate: 1, pitch: 1, volume: 1 });
225
+ const voices = await speech.getAvailableVoicesAsync();
226
+ await speech.speak('Your next turn', { voice: voices[0]?.identifier });
227
+ await speech.stop(); // Cancel current speech and clear the queue when leaving the screen.
228
+ ```
211
229
 
212
- **Score, lives and timers**
230
+ Speech runs through the Rust core, using iOS AVSpeechSynthesizer through Objective-C bindings,
231
+ Android TextToSpeech through JNI, and browser speechSynthesis through web-sys/WASM.
232
+ It creates no audio files, needs no microphone permission or Expo Speech dependency,
233
+ and plays independently of `audio` and its volume/mute settings. The web adapter automatically
234
+ awaits engine warm-up before sending commands into WASM. Rebuild native apps after upgrading
235
+ from an engine without speech support; `speech.supported` checks API availability.
236
+
237
+ `speak` adds text to the system queue and returns a promise that resolves when queued, **not when
238
+ playback ends**. Initialization, invalid voices, unsupported languages and Android's system text
239
+ length limit can reject that promise. On Android, initialization times out after 15 seconds and a
240
+ later request can retry. Stopping during initialization cancels pending speech immediately (those
241
+ `speak` promises reject); it does not wait for a system callback. Empty/whitespace text is ignored. Options default to the
242
+ system language/voice, rate 1, pitch 1 and volume 1. Rate accepts 0.1–2, pitch 0.5–2 and volume 0–1;
243
+ the platform may clamp the effective rate. Voices and pronunciation depend on the installed system
244
+ speech engine. `getAvailableVoicesAsync()` returns identifiers, names, BCP 47 languages and quality.
245
+ Android lists installed voices that do not require a network connection.
246
+
247
+ `isSpeakingAsync()` reports active speech (including paused speech on iOS/web). `pause()` and
248
+ `resume()` work on iOS/web and reject on Android, whose system API has no pause/resume support.
249
+ `stop()` resets paused browser state and ends the iOS synthesizer session so the next run starts cleanly.
250
+ Browsers may require a user gesture before speaking, and voice discovery can initially be empty.
251
+ As with [Expo Speech](https://docs.expo.dev/versions/latest/sdk/speech/), actual output depends on
252
+ device settings such as silent mode. This API implements the options and methods above, not Expo's
253
+ utterance event callbacks. System playback errors after an utterance is queued are not reported.
254
+
255
+ For a muted iOS integration check, rebuild the core, boot an Apple Silicon simulator and run
256
+ `bun test packages/engine/native/tests/speech-ios.test.ts` from the repository root. This checks the
257
+ real Rust/native bridge, including stopping before playback begins and speaking again afterwards.
258
+
259
+ ### Score and timers
213
260
 
214
261
  ```ts
215
- world.scoreboard.handlers = { onTimeUp: () => endGame('lose') }; // the game's rules
216
- const stopListening = world.scoreboard.listen({ onScore: setScore, onSecond: setTime }); // UI follows along
217
- world.scoreboard.reset(3, 60); // 3 lives and a stopped 60 s countdown for the ready screen
218
- world.scoreboard.resume(); // Start
262
+ world.scoreboard.handlers = { onTimeUp: () => endGame('lose') };
263
+ world.scoreboard.listen({ onScore: setScore, onSecond: setTime }); // keep your UI in sync
264
+ world.scoreboard.reset(3, 60); // 3 lives, 60 s countdown
265
+ world.scoreboard.resume();
219
266
  world.scoreboard.add(10);
267
+
220
268
  const timer = world.after(0.5, spawnWave); // game time: pauses with the game
221
269
  world.cancelTimer(timer);
222
270
  ```
223
271
 
224
- **Flat boards and puzzles**: `frontLight` keeps camera-facing surfaces at their exact color
272
+ ### Shapes in code
225
273
 
226
- ```tsx
227
- <GameView source={world} camera={{ eye: [0, 0, 50], target: [0, 0, 0], fov: 1, ortho: 10 }} light={frontLight([-0.4, 0.6, 1], 0.5)} />
228
- ```
274
+ Build a mesh once at module scope (after `warmUpEngine()`), then spawn it many times.
229
275
 
230
- **Input**
276
+ ```ts
277
+ import { createShape, lathe } from '@nayan-ui/engine';
231
278
 
232
- ```tsx
233
- <GameView
234
- source={world}
235
- onTap={(x, y) => {
236
- const hit = world.pick(x, y);
237
- if (hit) world.animate(hit.entity, { scale: 1.2 }, { yoyo: true, repeat: 1 });
238
- }}
239
- onSwipe={(direction) => move(direction)}
240
- onDrag={({ phase, x, y }) => drag(phase, x, y)}
241
- />
279
+ const vase = createShape(
280
+ lathe(
281
+ [
282
+ [0, 0],
283
+ [0.4, 0.1],
284
+ [0.25, 0.8],
285
+ [0.3, 1]
286
+ ],
287
+ { segments: 24 }
288
+ )
289
+ );
290
+ world.spawn({ mesh: vase, color: '#C8763A' });
242
291
  ```
243
292
 
244
- `onLongPress`, `onPinch`, the on-screen `Joystick` and `createKeyboardInput` (web) cover other controls.
293
+ Also available: `merge`, `tube`, `extrude`, `loft`, `block`, `ellipsoid`, `parametricSurface`, `Solid` and `shapeGeometry`.
245
294
 
246
- **Models, textures, text, sound and haptics**
295
+ ### Procedural worlds
296
+
297
+ Describe terrain and placement rules as data; the native core generates the world on a background
298
+ thread and answers walker queries, so none of that work runs in JavaScript.
247
299
 
248
300
  ```ts
249
- import { audio, haptics, loadFont, loadModel, loadTexture } from '@nayan-ui/engine';
301
+ import { FieldGraph, generateWorldAsync } from '@nayan-ui/engine';
302
+
303
+ const f = new FieldGraph();
304
+ const bank = f.sub(f.abs(f.sub(f.x, f.sines(f.x, f.z, 0, [[0, 0.04, 0, 12]]))), 4); // a winding river
305
+ f.output('bank', bank);
306
+ f.output('height', f.select(bank, -1, f.bumps(f.x, f.z, [[30, 20, 5, 15, 12, 0]])));
307
+ f.output('clear', f.sub(bank, 1));
308
+
309
+ const world = await generateWorldAsync({
310
+ seed: 7,
311
+ fields: f,
312
+ terrain: {
313
+ axis: [...Array(101).keys()].map((i) => i * 2 - 100),
314
+ height: 'height',
315
+ regions: [{ field: 'bank', surface: 2, material: 1 }],
316
+ fallback: { surface: 1, material: 0 },
317
+ materials: 2
318
+ },
319
+ walk: { boundary: 95, clearance: 'clear', height: 2 },
320
+ steps: [
321
+ { kind: 'spawn', near: [-30, 0], rings: 20, samples: 16, radius: 1 },
322
+ { kind: 'reach', cell: 3, steps: 6, radius: 0.6, extent: 100 },
323
+ { kind: 'pick', groups: [{ name: 'coins', count: 10, spacing: 8 }] }
324
+ ]
325
+ });
326
+ const land = createShape(world.terrain(0), { center: false }); // one mesh per material
327
+ world.move(player, dx, dz, 0.5); // swept move-and-slide; also ground, walkable, canSee, tilt
328
+ ```
250
329
 
251
- const car = await loadModel(require('./car.glb'), { fit: 2 }); // or a URL string on the web
252
- const crate = await loadTexture(require('./crate.png'));
253
- const font = await loadFont(require('./Inter-Bold.ttf'));
254
- world.spawn({ mesh: car, position: [0, 0, 0] });
255
- world.spawn({ mesh: 'cube', texture: crate });
256
- world.spawn({ text: '2048', font, position: [0, 2, 0], scale: 0.5 });
330
+ Scatter steps place scenery with filters (fields, walkability, surfaces) and optional solids;
331
+ `generateMesh` builds parametric surfaces and seeded cards. Pass `key` to save a world (tied to its
332
+ exact recipe) and restore it on later visits.
257
333
 
258
- const sfx = audio.load({ coin: require('./coin.wav') });
259
- sfx.play('coin');
260
- haptics.impact(0.5);
261
- audio.enabled = settings.sounds; // respect the player's preferences
262
- haptics.enabled = settings.haptics;
334
+ ### Flat boards and puzzles
335
+
336
+ `frontLight` keeps camera-facing surfaces at their exact color.
337
+
338
+ ```tsx
339
+ <GameView source={world} camera={{ eye: [0, 0, 50], target: [0, 0, 0], fov: 1, ortho: 10 }} light={frontLight([-0.4, 0.6, 1], 0.5)} />
263
340
  ```
264
341
 
265
- **Colors** are CSS strings (`'#FF4757'`, `'rgba(0, 0, 0, 0.5)'`) or linear 0..1 tuples (`[1, 0.3, 0.3]`; channels above 1 glow) for entities, animations, particles, lights and the background. Vertex colors baked into geometry are tuples. `toColor('#FF4757')` converts a CSS color when you need the numbers.
342
+ ### Drawing
266
343
 
267
- **Drawing**: a native paint canvas, also usable as a live texture inside games
344
+ A native paint canvas, also usable as a live texture inside games.
268
345
 
269
346
  ```tsx
270
347
  import { DrawingCanvas, DrawingDocument } from '@nayan-ui/engine';
271
348
 
272
349
  const paper = new DrawingDocument(512, 512);
273
350
  paper.setBrush({ brushType: 'MARKER', color: '#e53935', width: 12, opacity: 1 });
351
+
274
352
  <DrawingCanvas document={paper} mode="draw" />;
275
353
  world.spawn({ mesh: 'plane', texture: paper.texture() }); // paint on a 3D object
276
354
  ```
277
355
 
278
356
  Drawings save as SVG paths and export transparent PNG snapshots.
279
357
 
358
+ ### Colors
359
+
360
+ Use CSS strings (`'#FF4757'`, `'rgba(0, 0, 0, 0.5)'`) or linear 0..1 tuples (`[1, 0.3, 0.3]`; channels above 1 glow). `toColor('#FF4757')` converts a CSS color to numbers.
361
+
362
+ ## Performance
363
+
364
+ The engine is built so that a game written the obvious way stays smooth on mid-range Android phones.
365
+
366
+ - **Display clock and render thread (iOS and Android).** The display's vsync (`CADisplayLink` on iOS, `Choreographer` on Android) asks for each frame at 60 Hz. `onUpdate` steps the game and hands a copy of the frame to a native render thread, which culls, submits and presents it. The JS thread never waits on the GPU, and a frame that is still waiting when a newer one arrives is replaced, not queued. The clock stops while the app is in the background or the view is off screen. On the web, `requestAnimationFrame` drives the same loop.
367
+ - **Culling in Rust.** Everything outside the camera's view (and outside the light's box for shadows) is skipped every frame, so open levels and long tracks only pay for what is on screen. Don't cull scenery in JS.
368
+ - **Automatic quality.** `quality="auto"` (the default) starts at `high` and steps down (shadows off first, then anti-aliasing, resolution last) only while the device can't keep up. Shadows always come from a full-resolution map (soft at `high`, crisp at `medium`): `low` and `lowest` skip the shadow pass rather than show blurred low-resolution shadows. `lowest` smooths edges with FXAA (in the pass that stretches its reduced scene), and `lowest`'s pixel cap shrinks only the 3D scene (stretched to fill the view): the overlay, and so all HUD text, is always drawn at the screen's own resolution. With a `qualityKey` (games pass their id) the level it settles on is saved in the engine's durable store, so later visits, after app restarts too, start there. Pass `adaptQuality={false}` while what's on screen isn't the game being played (a menu, a results card): those frames then never change or save the level. `onStats` reports fps, update and render time, the quality chosen and the graphics backend.
369
+ - **Cached backdrops.** Spawn scenery that never moves (a sky, distant hills, a board's background) with `still: true`, or a whole still scene into `world.backdropLayer(capacity)` (then `update(0)` it). `GameView` draws the world's backdrop layer once into an image behind the scene and draws that image each frame instead, redrawing it only after a still entity changes, the camera moves or the light changes. Live entities are always drawn in front of it and it receives no live shadows, so keep anything that moves, or that live pieces pass behind, in the world. A still entity takes `set` and `despawn` but can't be attached, animated, picked or given physics or text.
370
+ - **Small things stay cheap.** Built-in spheres a few pixels across on screen (stars, pellets, beads) are drawn with 8- or 12-sided stand-ins, and instances scaled to nothing are never drawn, so pools of hidden pieces cost nothing on the GPU.
371
+ - **Background loading.** `loadTexture`, `loadModel`, `loadFont` and `audio.load` decode on native threads in parallel, and meshes are registered with the renderer inside Rust.
372
+ - **Work between frames.** `afterNextFrame(fn)` runs `fn` right after the next frame leaves, so you can spread long setup work (map generation, mesh building) over several frames behind a loading screen.
373
+ - **Cached procedural assets.** `ProceduralCache` saves built geometry on disk keyed by a recipe, so later launches restore it instead of rebuilding; `peek(key)` returns a saved value without building anything.
374
+
375
+ Keep these habits:
376
+
377
+ - Build meshes once, at module scope or behind a cache, and spawn new entities from them. Mesh ids are app-wide and never freed (`getMeshIdLimit()` tells you how many there are), so never create a mesh per spawn or per frame.
378
+ - Move many things each frame through `world.transforms` and the `out` arguments of queries, which allocate nothing.
379
+ - Keep long one-off jobs out of gameplay frames: a single slow frame stalls Android's Hermes far longer than desktop JavaScript suggests.
380
+
280
381
  ## Troubleshooting
281
382
 
282
383
  | Symptom | Fix |
@@ -287,10 +388,18 @@ Drawings save as SVG paths and export transparent PNG snapshots.
287
388
  | An Android screenshot of the game view is blank | The engine draws into a `SurfaceView`: use `captureScreen(..., { handleGLSurfaceViewOnAndroid: true })`. |
288
389
  | Out of mesh ids | A mesh is being created per spawn or per frame: build it once and reuse it (`getMeshIdLimit()`). |
289
390
 
391
+ ## Prebuilt games
392
+
393
+ [@nayan-ui/games](https://www.nayanui.com/games) offers 53 ready-made 3D games built on this engine. Each is one component with the same props.
394
+
395
+ `@nayan-ui/games` is a **private package**. To use it in your application or website, reach out to [niranjan.devasani@gmail.com](mailto:niranjan.devasani@gmail.com).
396
+
397
+ **[Play them in your browser](https://www.nayanui.com/games)**
398
+
290
399
  ## Learn more
291
400
 
292
- | Topic | Guide |
293
- | ------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
401
+ | Topic | Guide |
402
+ | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
294
403
  | Build a complete game step by step | [First game](https://www.nayanui.com/game-engine/first-game) |
295
404
  | Copy-paste solutions | [Recipes](https://www.nayanui.com/game-engine/recipes) |
296
405
  | Worlds, entities, rotations, disposal | [World & entities](https://www.nayanui.com/game-engine/world-entities) |
@@ -302,7 +411,7 @@ Drawings save as SVG paths and export transparent PNG snapshots.
302
411
 
303
412
  ## License
304
413
 
305
- MIT © [Niranjan Devasani](https://github.com/ursnj)
414
+ MIT
306
415
 
307
416
  ---
308
417
 
@@ -43,6 +43,11 @@ export class ProceduralCache {
43
43
  this.revision = revision;
44
44
  this.storage = storage;
45
45
  }
46
+
47
+ /** The engine storage key of `key` in this revision (for records the native core reads and writes itself). */
48
+ storageKey(key) {
49
+ return `procedural:${this.revision}:${key}`;
50
+ }
46
51
  get stats() {
47
52
  return {
48
53
  hits: this.hits,
@@ -1,7 +1,7 @@
1
1
  "use strict";
2
2
 
3
- import { lathe, merge, recolor } from "./shape.js";
4
- import { parametricSurface } from "./surface.js";
3
+ import { FieldFunction } from "./procedural.js";
4
+ import { lathe, merge, nativeSweep, recolor } from "./shape.js";
5
5
  /**
6
6
  * Smooth closed body through rounded rectangular cross sections along Z. Useful for vehicle
7
7
  * shells, cabins, shoes and fairings. Caps have separate flat normals and planar UVs; sides have
@@ -15,72 +15,24 @@ export function loft(sections, options = {}) {
15
15
  steps = options.smoothness ?? 4;
16
16
  if (!Number.isInteger(n) || n < 6 || n > 512 || !Number.isInteger(steps) || steps < 1 || steps > 64) throw new Error('loft: invalid around/smoothness');
17
17
  const values = ordered.map(s => [s.z, s.bottom, s.top, s.width, s.topWidth ?? s.width, s.square ?? 4]);
18
- const at = i => values[Math.max(0, Math.min(values.length - 1, i))];
19
- const row = v => {
20
- const cursor = v * (values.length - 1),
21
- i = Math.min(values.length - 2, Math.floor(cursor)),
22
- t = cursor - i;
23
- const a = at(i - 1),
24
- b = at(i),
25
- c = at(i + 1),
26
- d = at(i + 2);
27
- const f = b.map((_, k) => 0.5 * (2 * b[k] + (-a[k] + c[k]) * t + (2 * a[k] - 5 * b[k] + 4 * c[k] - d[k]) * t * t + (-a[k] + 3 * b[k] - 3 * c[k] + d[k]) * t * t * t));
28
- f[2] = Math.max(f[1] + 1e-6, f[2]);
29
- f[3] = Math.max(0, f[3]);
30
- f[4] = Math.max(0, f[4]);
31
- f[5] = Math.max(1, f[5]);
32
- return f;
33
- };
34
- const rowCount = (sections.length - 1) * steps;
35
- const rows = Array.from({
36
- length: rowCount + 1
37
- }, (_, i) => row(i / rowCount));
38
- const point = (u, v) => {
39
- const [z, bottom, top, width, topWidth, square] = rows[Math.round(v * rowCount)],
40
- angle = -Math.PI / 2 + u * Math.PI * 2;
41
- const c = Math.cos(angle),
42
- s = Math.sin(angle),
43
- exponent = 2 / square;
44
- const x = Math.sign(c) * Math.abs(c) ** exponent,
45
- y = Math.sign(s) * Math.abs(s) ** exponent;
46
- return [x * (y > 0 ? width + (topWidth - width) * y : width), (bottom + top) / 2 + y * (top - bottom) / 2, z];
47
- };
48
- const side = parametricSurface(point, {
49
- uSegments: n,
50
- vSegments: rowCount,
51
- wrapU: true
18
+ const color = options.color;
19
+ const g = nativeSweep({
20
+ kind: 'loft',
21
+ sections: values,
22
+ around: n,
23
+ smoothness: steps,
24
+ color: typeof color === 'function' || color instanceof FieldFunction || !color ? undefined : [color[0], color[1], color[2]],
25
+ colorField: color instanceof FieldFunction ? color : undefined
52
26
  });
53
- const parts = [{
54
- geometry: side
55
- }];
56
- for (const [v, facing] of [[0, -1], [1, 1]]) {
57
- const section = rows[v * rowCount],
58
- cy = (section[1] + section[2]) / 2;
59
- const vertices = new Float32Array((n + 1) * 11),
60
- indices = [];
61
- vertices.set([0, cy, section[0], 0, 0, facing, 1, 1, 1, 0.5, 0.5]);
62
- for (let j = 0; j < n; j++) {
63
- const p = point(j / n, v),
64
- width = Math.max(section[3], section[4], 1e-6);
65
- vertices.set([p[0], p[1], p[2], 0, 0, facing, 1, 1, 1, 0.5 + p[0] / (2 * width), (p[1] - section[1]) / (section[2] - section[1])], (j + 1) * 11);
66
- const a = j + 1,
67
- b = (j + 1) % n + 1;
68
- if (facing === 1) indices.push(0, a, b);else indices.push(0, b, a);
69
- }
70
- parts.push({
71
- geometry: {
72
- vertices,
73
- indices: new Uint32Array(indices)
74
- }
75
- });
76
- }
77
- return paint(merge(parts), options.color);
27
+ return typeof color === 'function' ? paint(g, color) : g;
78
28
  }
79
29
  function paint(geometry, color) {
80
- return color ? recolor(geometry, typeof color === 'function' ? ({
30
+ if (!color) return geometry;
31
+ if (color instanceof FieldFunction) return recolor(geometry, color);
32
+ return recolor(geometry, typeof color === 'function' ? ({
81
33
  position,
82
34
  normal
83
- }) => color(position, normal) : () => color) : geometry;
35
+ }) => color(position, normal) : () => color);
84
36
  }
85
37
 
86
38
  /** A rounded rectangular cross-section with flat front/back caps, centered at the origin. */