@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.
- package/README.md +225 -116
- package/lib/module/assets/ProceduralCache.js +5 -0
- package/lib/module/assets/loft.js +15 -63
- package/lib/module/assets/procedural.js +500 -0
- package/lib/module/assets/shape.js +147 -245
- package/lib/module/assets/texture.js +65 -0
- package/lib/module/drawing/DrawingDocument.js +1 -1
- package/lib/module/index.js +4 -1
- package/lib/module/media/audio-worklet.js +27 -0
- package/lib/module/media/audio.js +20 -0
- package/lib/module/media/input.web.js +107 -0
- package/lib/module/media/microphone.js +89 -0
- package/lib/module/media/speech.js +80 -0
- package/lib/module/media/speechPlatform.js +12 -0
- package/lib/module/media/speechPlatform.web.js +16 -0
- package/lib/module/native/BrowserEngine.js +122 -0
- package/lib/module/native/NativeNayanEngine.web.js +2 -0
- package/lib/module/render/GameView.js +45 -13
- package/lib/module/render/quality.js +151 -17
- package/lib/module/world/World.js +63 -0
- package/lib/typescript/src/assets/ProceduralCache.d.ts +2 -0
- package/lib/typescript/src/assets/loft.d.ts +3 -1
- package/lib/typescript/src/assets/procedural.d.ts +394 -0
- package/lib/typescript/src/assets/shape.d.ts +13 -7
- package/lib/typescript/src/assets/texture.d.ts +63 -0
- package/lib/typescript/src/index.d.ts +8 -2
- package/lib/typescript/src/media/audio.d.ts +13 -0
- package/lib/typescript/src/media/input.web.d.ts +4 -0
- package/lib/typescript/src/media/microphone.d.ts +51 -0
- package/lib/typescript/src/media/speech.d.ts +38 -0
- package/lib/typescript/src/media/speechPlatform.d.ts +5 -0
- package/lib/typescript/src/media/speechPlatform.web.d.ts +5 -0
- package/lib/typescript/src/native/EngineBridge.d.ts +7 -1
- package/lib/typescript/src/native/spec/NativeNayanEngine.d.ts +72 -0
- package/lib/typescript/src/render/GameView.d.ts +30 -3
- package/lib/typescript/src/render/quality.d.ts +54 -2
- package/lib/typescript/src/types.d.ts +9 -2
- package/lib/typescript/src/world/World.d.ts +16 -0
- package/lib/typescript/src/world/types.d.ts +8 -0
- package/lib/web/audio-worklet.js +27 -0
- package/lib/web/engine_core_bg.wasm +0 -0
- package/lib/web/index.js +1181 -422
- package/lib/web/types/src/assets/ProceduralCache.d.ts +2 -0
- package/lib/web/types/src/assets/loft.d.ts +3 -1
- package/lib/web/types/src/assets/procedural.d.ts +393 -0
- package/lib/web/types/src/assets/shape.d.ts +13 -7
- package/lib/web/types/src/assets/texture.d.ts +63 -0
- package/lib/web/types/src/index.d.ts +8 -2
- package/lib/web/types/src/media/audio.d.ts +13 -0
- package/lib/web/types/src/media/input.d.ts +3 -0
- package/lib/web/types/src/media/microphone.d.ts +50 -0
- package/lib/web/types/src/media/speech.d.ts +37 -0
- package/lib/web/types/src/media/speechPlatform.d.ts +4 -0
- package/lib/web/types/src/native/EngineBridge.d.ts +7 -1
- package/lib/web/types/src/native/spec/NativeNayanEngine.d.ts +72 -0
- package/lib/web/types/src/render/GameView.d.ts +30 -3
- package/lib/web/types/src/render/quality.d.ts +54 -2
- package/lib/web/types/src/types.d.ts +9 -2
- package/lib/web/types/src/world/World.d.ts +16 -0
- package/lib/web/types/src/world/types.d.ts +8 -0
- package/native/android/src/main/AndroidManifest.xml +10 -0
- package/native/android/src/main/java/com/nayanui/engine/EngineMicrophone.java +60 -0
- package/native/android/src/main/java/com/nayanui/engine/EngineSpeech.java +28 -0
- package/native/android/src/main/java/com/nayanui/engine/EngineSurfaceView.java +1 -0
- package/native/android/src/main/java/com/nayanui/engine/NayanEnginePackage.java +2 -0
- 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 +63 -1
- package/native/cpp/NayanEngineModule.cpp +296 -0
- package/native/cpp/NayanEngineModule.h +40 -0
- package/native/cpp/generated/NayanEngineSpecJSI.h +397 -0
- package/package.json +1 -1
- 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
|
-
##
|
|
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
|
-
##
|
|
105
|
+
## Guides
|
|
90
106
|
|
|
91
|
-
|
|
107
|
+
Each snippet assumes a `world` from `useWorld`.
|
|
92
108
|
|
|
93
|
-
|
|
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
|
-
|
|
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
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
142
|
+
### Animation
|
|
143
|
+
|
|
144
|
+
Tweens run in Rust and return a Promise.
|
|
120
145
|
|
|
121
146
|
```ts
|
|
122
|
-
await world.animate(
|
|
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
|
-
|
|
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
|
-
|
|
133
|
-
world.playClip(player, '
|
|
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)
|
|
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
|
-
|
|
166
|
+
### Effects
|
|
140
167
|
|
|
141
168
|
```ts
|
|
142
169
|
import { effects } from '@nayan-ui/engine';
|
|
143
170
|
|
|
144
|
-
effects.confetti(world, {
|
|
145
|
-
|
|
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
|
-
|
|
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 {
|
|
195
|
+
import { loadFont, loadModel, loadTexture } from '@nayan-ui/engine';
|
|
164
196
|
|
|
165
|
-
const
|
|
166
|
-
const
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
206
|
+
### Sound and haptics
|
|
176
207
|
|
|
177
208
|
```ts
|
|
178
|
-
import {
|
|
209
|
+
import { audio, haptics } from '@nayan-ui/engine';
|
|
179
210
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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') };
|
|
216
|
-
|
|
217
|
-
world.scoreboard.reset(3, 60); // 3 lives
|
|
218
|
-
world.scoreboard.resume();
|
|
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
|
-
|
|
272
|
+
### Shapes in code
|
|
225
273
|
|
|
226
|
-
|
|
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
|
-
|
|
276
|
+
```ts
|
|
277
|
+
import { createShape, lathe } from '@nayan-ui/engine';
|
|
231
278
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
`
|
|
293
|
+
Also available: `merge`, `tube`, `extrude`, `loft`, `block`, `ellipsoid`, `parametricSurface`, `Solid` and `shapeGeometry`.
|
|
245
294
|
|
|
246
|
-
|
|
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 {
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
342
|
+
### Drawing
|
|
266
343
|
|
|
267
|
-
|
|
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
|
|
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 {
|
|
4
|
-
import {
|
|
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
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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. */
|