kiln-ecs 0.0.0-stage → 0.1.0
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 +72 -2
- package/community/audio/README.md +24 -0
- package/community/audio/index.ts +196 -0
- package/community/audio/kiln-plugin.json +6 -0
- package/community/orbit-camera/README.md +20 -0
- package/community/orbit-camera/index.ts +88 -0
- package/community/orbit-camera/kiln-plugin.json +6 -0
- package/community/particles/README.md +19 -0
- package/community/particles/index.ts +167 -0
- package/community/particles/kiln-plugin.json +6 -0
- package/community/terrain/README.md +22 -0
- package/community/terrain/index.ts +133 -0
- package/community/terrain/kiln-plugin.json +6 -0
- package/community/water/README.md +20 -0
- package/community/water/index.ts +117 -0
- package/community/water/kiln-plugin.json +6 -0
- package/docs/agents-guide.md +521 -0
- package/lib/browser.d.ts +28 -0
- package/lib/browser.d.ts.map +1 -0
- package/lib/browser.js +260 -0
- package/lib/browser.js.map +1 -0
- package/lib/cli/add.d.ts +18 -0
- package/lib/cli/add.d.ts.map +1 -0
- package/lib/cli/add.js +66 -0
- package/lib/cli/add.js.map +1 -0
- package/lib/cli/args.d.ts +9 -0
- package/lib/cli/args.d.ts.map +1 -0
- package/lib/cli/args.js +48 -0
- package/lib/cli/args.js.map +1 -0
- package/lib/cli/check.d.ts +13 -0
- package/lib/cli/check.d.ts.map +1 -0
- package/lib/cli/check.js +64 -0
- package/lib/cli/check.js.map +1 -0
- package/lib/cli/describe.d.ts +4 -0
- package/lib/cli/describe.d.ts.map +1 -0
- package/lib/cli/describe.js +19 -0
- package/lib/cli/describe.js.map +1 -0
- package/lib/cli/docs.d.ts +21 -0
- package/lib/cli/docs.d.ts.map +1 -0
- package/lib/cli/docs.js +170 -0
- package/lib/cli/docs.js.map +1 -0
- package/lib/cli/kiln.d.ts +15 -0
- package/lib/cli/kiln.d.ts.map +1 -0
- package/lib/cli/kiln.js +82 -0
- package/lib/cli/kiln.js.map +1 -0
- package/lib/cli/load.d.ts +12 -0
- package/lib/cli/load.d.ts.map +1 -0
- package/lib/cli/load.js +47 -0
- package/lib/cli/load.js.map +1 -0
- package/lib/cli/new.d.ts +10 -0
- package/lib/cli/new.d.ts.map +1 -0
- package/lib/cli/new.js +48 -0
- package/lib/cli/new.js.map +1 -0
- package/lib/cli/replay.d.ts +11 -0
- package/lib/cli/replay.d.ts.map +1 -0
- package/lib/cli/replay.js +82 -0
- package/lib/cli/replay.js.map +1 -0
- package/lib/cli/run.d.ts +12 -0
- package/lib/cli/run.d.ts.map +1 -0
- package/lib/cli/run.js +78 -0
- package/lib/cli/run.js.map +1 -0
- package/lib/cli/shot.d.ts +29 -0
- package/lib/cli/shot.d.ts.map +1 -0
- package/lib/cli/shot.js +163 -0
- package/lib/cli/shot.js.map +1 -0
- package/lib/cli/test.d.ts +4 -0
- package/lib/cli/test.d.ts.map +1 -0
- package/lib/cli/test.js +16 -0
- package/lib/cli/test.js.map +1 -0
- package/lib/core/access.d.ts +56 -0
- package/lib/core/access.d.ts.map +1 -0
- package/lib/core/access.js +25 -0
- package/lib/core/access.js.map +1 -0
- package/lib/core/app.d.ts +255 -0
- package/lib/core/app.d.ts.map +1 -0
- package/lib/core/app.js +827 -0
- package/lib/core/app.js.map +1 -0
- package/lib/core/commands.d.ts +56 -0
- package/lib/core/commands.d.ts.map +1 -0
- package/lib/core/commands.js +94 -0
- package/lib/core/commands.js.map +1 -0
- package/lib/core/core-plugin.d.ts +3 -0
- package/lib/core/core-plugin.d.ts.map +1 -0
- package/lib/core/core-plugin.js +8 -0
- package/lib/core/core-plugin.js.map +1 -0
- package/lib/core/describe.d.ts +41 -0
- package/lib/core/describe.d.ts.map +1 -0
- package/lib/core/describe.js +91 -0
- package/lib/core/describe.js.map +1 -0
- package/lib/core/dev.d.ts +6 -0
- package/lib/core/dev.d.ts.map +1 -0
- package/lib/core/dev.js +12 -0
- package/lib/core/dev.js.map +1 -0
- package/lib/core/errors.d.ts +10 -0
- package/lib/core/errors.d.ts.map +1 -0
- package/lib/core/errors.js +12 -0
- package/lib/core/errors.js.map +1 -0
- package/lib/core/event.d.ts +49 -0
- package/lib/core/event.d.ts.map +1 -0
- package/lib/core/event.js +122 -0
- package/lib/core/event.js.map +1 -0
- package/lib/core/guard.d.ts +23 -0
- package/lib/core/guard.d.ts.map +1 -0
- package/lib/core/guard.js +124 -0
- package/lib/core/guard.js.map +1 -0
- package/lib/core/input-types.d.ts +75 -0
- package/lib/core/input-types.d.ts.map +1 -0
- package/lib/core/input-types.js +1 -0
- package/lib/core/input-types.js.map +1 -0
- package/lib/core/koota.d.ts +2 -0
- package/lib/core/koota.d.ts.map +1 -0
- package/lib/core/koota.js +3 -0
- package/lib/core/koota.js.map +1 -0
- package/lib/core/math.d.ts +14 -0
- package/lib/core/math.d.ts.map +1 -0
- package/lib/core/math.js +21 -0
- package/lib/core/math.js.map +1 -0
- package/lib/core/names.d.ts +13 -0
- package/lib/core/names.d.ts.map +1 -0
- package/lib/core/names.js +52 -0
- package/lib/core/names.js.map +1 -0
- package/lib/core/plugin.d.ts +17 -0
- package/lib/core/plugin.d.ts.map +1 -0
- package/lib/core/plugin.js +26 -0
- package/lib/core/plugin.js.map +1 -0
- package/lib/core/prefab.d.ts +20 -0
- package/lib/core/prefab.d.ts.map +1 -0
- package/lib/core/prefab.js +31 -0
- package/lib/core/prefab.js.map +1 -0
- package/lib/core/recording.d.ts +159 -0
- package/lib/core/recording.d.ts.map +1 -0
- package/lib/core/recording.js +266 -0
- package/lib/core/recording.js.map +1 -0
- package/lib/core/resource.d.ts +26 -0
- package/lib/core/resource.d.ts.map +1 -0
- package/lib/core/resource.js +32 -0
- package/lib/core/resource.js.map +1 -0
- package/lib/core/rng.d.ts +40 -0
- package/lib/core/rng.d.ts.map +1 -0
- package/lib/core/rng.js +79 -0
- package/lib/core/rng.js.map +1 -0
- package/lib/core/runtime.d.ts +35 -0
- package/lib/core/runtime.d.ts.map +1 -0
- package/lib/core/runtime.js +168 -0
- package/lib/core/runtime.js.map +1 -0
- package/lib/core/schedule.d.ts +70 -0
- package/lib/core/schedule.d.ts.map +1 -0
- package/lib/core/schedule.js +214 -0
- package/lib/core/schedule.js.map +1 -0
- package/lib/core/snapshot.d.ts +65 -0
- package/lib/core/snapshot.d.ts.map +1 -0
- package/lib/core/snapshot.js +228 -0
- package/lib/core/snapshot.js.map +1 -0
- package/lib/core/state.d.ts +82 -0
- package/lib/core/state.d.ts.map +1 -0
- package/lib/core/state.js +139 -0
- package/lib/core/state.js.map +1 -0
- package/lib/core/system.d.ts +129 -0
- package/lib/core/system.d.ts.map +1 -0
- package/lib/core/system.js +109 -0
- package/lib/core/system.js.map +1 -0
- package/lib/core/time.d.ts +3 -0
- package/lib/core/time.d.ts.map +1 -0
- package/lib/core/time.js +4 -0
- package/lib/core/time.js.map +1 -0
- package/lib/core/transform.d.ts +34 -0
- package/lib/core/transform.d.ts.map +1 -0
- package/lib/core/transform.js +47 -0
- package/lib/core/transform.js.map +1 -0
- package/lib/index.d.ts +63 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +37 -0
- package/lib/index.js.map +1 -0
- package/lib/plugins/animation/graph.d.ts +232 -0
- package/lib/plugins/animation/graph.d.ts.map +1 -0
- package/lib/plugins/animation/graph.js +415 -0
- package/lib/plugins/animation/graph.js.map +1 -0
- package/lib/plugins/animation/index.d.ts +7 -0
- package/lib/plugins/animation/index.d.ts.map +1 -0
- package/lib/plugins/animation/index.js +5 -0
- package/lib/plugins/animation/index.js.map +1 -0
- package/lib/plugins/animation/skeletal.d.ts +68 -0
- package/lib/plugins/animation/skeletal.d.ts.map +1 -0
- package/lib/plugins/animation/skeletal.js +200 -0
- package/lib/plugins/animation/skeletal.js.map +1 -0
- package/lib/plugins/animation/sprite.d.ts +141 -0
- package/lib/plugins/animation/sprite.d.ts.map +1 -0
- package/lib/plugins/animation/sprite.js +249 -0
- package/lib/plugins/animation/sprite.js.map +1 -0
- package/lib/plugins/assets/index.d.ts +69 -0
- package/lib/plugins/assets/index.d.ts.map +1 -0
- package/lib/plugins/assets/index.js +283 -0
- package/lib/plugins/assets/index.js.map +1 -0
- package/lib/plugins/camera/index.d.ts +85 -0
- package/lib/plugins/camera/index.d.ts.map +1 -0
- package/lib/plugins/camera/index.js +167 -0
- package/lib/plugins/camera/index.js.map +1 -0
- package/lib/plugins/core-plugins.d.ts +7 -0
- package/lib/plugins/core-plugins.d.ts.map +1 -0
- package/lib/plugins/core-plugins.js +14 -0
- package/lib/plugins/core-plugins.js.map +1 -0
- package/lib/plugins/debug/index.d.ts +16 -0
- package/lib/plugins/debug/index.d.ts.map +1 -0
- package/lib/plugins/debug/index.js +287 -0
- package/lib/plugins/debug/index.js.map +1 -0
- package/lib/plugins/input/index.d.ts +109 -0
- package/lib/plugins/input/index.d.ts.map +1 -0
- package/lib/plugins/input/index.js +539 -0
- package/lib/plugins/input/index.js.map +1 -0
- package/lib/plugins/physics/index.d.ts +112 -0
- package/lib/plugins/physics/index.d.ts.map +1 -0
- package/lib/plugins/physics/index.js +356 -0
- package/lib/plugins/physics/index.js.map +1 -0
- package/lib/plugins/render/index.d.ts +67 -0
- package/lib/plugins/render/index.d.ts.map +1 -0
- package/lib/plugins/render/index.js +391 -0
- package/lib/plugins/render/index.js.map +1 -0
- package/lib/plugins/render/traits.d.ts +24 -0
- package/lib/plugins/render/traits.d.ts.map +1 -0
- package/lib/plugins/render/traits.js +20 -0
- package/lib/plugins/render/traits.js.map +1 -0
- package/lib/plugins/save/index.d.ts +81 -0
- package/lib/plugins/save/index.d.ts.map +1 -0
- package/lib/plugins/save/index.js +229 -0
- package/lib/plugins/save/index.js.map +1 -0
- package/lib/plugins/timer/index.d.ts +91 -0
- package/lib/plugins/timer/index.d.ts.map +1 -0
- package/lib/plugins/timer/index.js +145 -0
- package/lib/plugins/timer/index.js.map +1 -0
- package/lib/plugins/tween/index.d.ts +132 -0
- package/lib/plugins/tween/index.d.ts.map +1 -0
- package/lib/plugins/tween/index.js +304 -0
- package/lib/plugins/tween/index.js.map +1 -0
- package/lib/plugins/ui/index.d.ts +92 -0
- package/lib/plugins/ui/index.d.ts.map +1 -0
- package/lib/plugins/ui/index.js +290 -0
- package/lib/plugins/ui/index.js.map +1 -0
- package/lib/test/index.d.ts +59 -0
- package/lib/test/index.d.ts.map +1 -0
- package/lib/test/index.js +152 -0
- package/lib/test/index.js.map +1 -0
- package/lib/tooling/names.d.ts +6 -0
- package/lib/tooling/names.d.ts.map +1 -0
- package/lib/tooling/names.js +130 -0
- package/lib/tooling/names.js.map +1 -0
- package/lib/tooling/preload.d.ts +2 -0
- package/lib/tooling/preload.d.ts.map +1 -0
- package/lib/tooling/preload.js +19 -0
- package/lib/tooling/preload.js.map +1 -0
- package/lib/tooling/vite.d.ts +15 -0
- package/lib/tooling/vite.d.ts.map +1 -0
- package/lib/tooling/vite.js +164 -0
- package/lib/tooling/vite.js.map +1 -0
- package/package.json +121 -4
- package/src/browser.ts +265 -0
- package/src/cli/add.ts +70 -0
- package/src/cli/args.ts +46 -0
- package/src/cli/check.ts +65 -0
- package/src/cli/describe.ts +18 -0
- package/src/cli/docs.ts +170 -0
- package/src/cli/kiln.ts +84 -0
- package/src/cli/load.ts +53 -0
- package/src/cli/new.ts +45 -0
- package/src/cli/replay.ts +73 -0
- package/src/cli/run.ts +79 -0
- package/src/cli/shot.ts +161 -0
- package/src/cli/test.ts +16 -0
- package/src/core/access.ts +79 -0
- package/src/core/app.ts +892 -0
- package/src/core/commands.ts +117 -0
- package/src/core/core-plugin.ts +8 -0
- package/src/core/describe.ts +117 -0
- package/src/core/dev.ts +10 -0
- package/src/core/errors.ts +11 -0
- package/src/core/event.ts +147 -0
- package/src/core/guard.ts +151 -0
- package/src/core/input-types.ts +54 -0
- package/src/core/koota.ts +2 -0
- package/src/core/math.ts +32 -0
- package/src/core/names.ts +56 -0
- package/src/core/plugin.ts +38 -0
- package/src/core/prefab.ts +45 -0
- package/src/core/recording.ts +362 -0
- package/src/core/resource.ts +59 -0
- package/src/core/rng.ts +88 -0
- package/src/core/runtime.ts +191 -0
- package/src/core/schedule.ts +264 -0
- package/src/core/snapshot.ts +266 -0
- package/src/core/state.ts +160 -0
- package/src/core/system.ts +239 -0
- package/src/core/time.ts +4 -0
- package/src/core/transform.ts +54 -0
- package/src/index.ts +64 -0
- package/src/plugins/animation/graph.ts +513 -0
- package/src/plugins/animation/index.ts +19 -0
- package/src/plugins/animation/skeletal.ts +219 -0
- package/src/plugins/animation/sprite.ts +322 -0
- package/src/plugins/assets/index.ts +310 -0
- package/src/plugins/camera/index.ts +194 -0
- package/src/plugins/core-plugins.ts +15 -0
- package/src/plugins/debug/index.ts +286 -0
- package/src/plugins/input/index.ts +546 -0
- package/src/plugins/physics/index.ts +388 -0
- package/src/plugins/render/index.ts +437 -0
- package/src/plugins/render/traits.ts +25 -0
- package/src/plugins/save/index.ts +258 -0
- package/src/plugins/timer/index.ts +158 -0
- package/src/plugins/tween/index.ts +359 -0
- package/src/plugins/ui/index.ts +349 -0
- package/src/test/index.ts +206 -0
- package/src/tooling/names.ts +116 -0
- package/src/tooling/preload.ts +18 -0
- package/src/tooling/vite.ts +163 -0
- package/template/_gitignore +3 -0
- package/template/bunfig.toml +2 -0
- package/template/index.html +20 -0
- package/template/kiln.config.ts +8 -0
- package/template/package.json +23 -0
- package/template/public/.gitkeep +0 -0
- package/template/scenarios/player.test.ts +10 -0
- package/template/src/data.ts +9 -0
- package/template/src/main.ts +10 -0
- package/template/src/prefabs.ts +11 -0
- package/template/src/systems.ts +29 -0
- package/template/tsconfig.json +15 -0
- package/template/vite.config.ts +4 -0
package/README.md
CHANGED
|
@@ -1,3 +1,73 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Kiln
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
An agent-first game framework on Three.js: a fixed-step loop, a [Koota](https://github.com/pmndrs/koota)
|
|
4
|
+
ECS, and a render bridge, designed so a coding agent can add a feature with a few exports and one
|
|
5
|
+
scheduling line, and verify it headlessly.
|
|
6
|
+
|
|
7
|
+
[](https://kilnjs.pages.dev)
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
bunx kiln-ecs new my-game # a playable starter game; npm package kiln-ecs, command kiln
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
// src/systems.ts
|
|
15
|
+
/** Integrates Velocity into Transform. */
|
|
16
|
+
export const Movement = system([Mut(Transform), Velocity], ([t, v], { time }) => {
|
|
17
|
+
t.x += v.x * time.step
|
|
18
|
+
t.z += v.z * time.step
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
// src/main.ts
|
|
22
|
+
export default () =>
|
|
23
|
+
createApp(config)
|
|
24
|
+
.use(CorePlugins)
|
|
25
|
+
.add(Startup, SpawnLevel)
|
|
26
|
+
.add(Fixed, chain(PlayerMove, Movement, CollectCoins))
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
kiln describe # the schedule: phases, systems, reads/writes/order
|
|
31
|
+
kiln test # headless scenario tests (bun test)
|
|
32
|
+
kiln run --steps 600 --snapshot Transform # the world as JSON after 10 simulated seconds
|
|
33
|
+
kiln check # determinism lint: Math.random, Date.now and friends in game code
|
|
34
|
+
kiln replay recordings/session.json # replay a recorded play session headless on the current code
|
|
35
|
+
kiln shot out.png # one frame from headless Chromium
|
|
36
|
+
kiln docs # AGENTS.md from the code and its JSDoc
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## What's included
|
|
40
|
+
|
|
41
|
+
- `CorePlugins`: time and seeded RNG, rendering, input actions, assets, timers, tweens, a HUD/UI layer
|
|
42
|
+
(text, bars and buttons as retained data) and a dev debug panel.
|
|
43
|
+
- Game states and run conditions: `state()`, `runIf(inState(...))`, `onEnter`/`onExit`, `despawnOnExit`.
|
|
44
|
+
- Opt-in plugins: `CameraPlugin` (follow, bounds, zoom, trauma shake; 2D and 3D), `savePlugin` (save slots
|
|
45
|
+
and persisted settings), `SpriteAnimationPlugin` (sprite sheets and atlases), `SkeletalAnimationPlugin`
|
|
46
|
+
(glTF models), both driven by an animation graph with crossfades and frame events, and Rapier physics
|
|
47
|
+
(`kiln-ecs/physics`).
|
|
48
|
+
- Session recording: press F8 in the dev server to save a play session, then `kiln replay` it headless to
|
|
49
|
+
find where the current code stops matching it.
|
|
50
|
+
- Community plugins via `kiln add`: audio, orbit-camera, particles, terrain, water.
|
|
51
|
+
|
|
52
|
+
## Repo
|
|
53
|
+
|
|
54
|
+
- `packages/kiln`: the framework (`kiln-ecs`, `kiln-ecs/physics`, `kiln-ecs/test`, `kiln-ecs/vite`, `kiln-ecs/browser`), the CLI,
|
|
55
|
+
community plugins for `kiln add`, and the `kiln new` template.
|
|
56
|
+
- `examples/coin-collector`: a 3D sample game (states, pause menu, tweens, camera follow, saved best score),
|
|
57
|
+
with `eval/` tasks for measuring agents.
|
|
58
|
+
- `examples/sprite-hero`: a 2D sprite-sheet hero with an animation graph, footstep dust and hit-frame sword hits.
|
|
59
|
+
- `docs/`: the documentation site (VitePress guides plus a TypeDoc API reference); `cd docs && bun install && bun run dev`.
|
|
60
|
+
`docs/design.md` is how the build maps to the design doc.
|
|
61
|
+
|
|
62
|
+
## Develop
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
bun install
|
|
66
|
+
bun test # everything, including the examples' scenarios
|
|
67
|
+
bun run typecheck
|
|
68
|
+
cd examples/coin-collector && bunx vite # play it (or examples/sprite-hero)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Start a game from the checkout: `bun packages/kiln/src/cli/kiln.ts new ../my-game`.
|
|
72
|
+
|
|
73
|
+
Build the npm package: `cd packages/kiln && bun run build` writes `dist/`, which is what gets published as `kiln-ecs`.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# audio
|
|
2
|
+
|
|
3
|
+
Positional sounds tied to Transform, on Web Audio through Three.js (`AudioListener`,
|
|
4
|
+
`PositionalAudio`, `AudioLoader`). The listener rides on the Camera. Buffers are cached by URL.
|
|
5
|
+
Browsers keep audio suspended until the first click or key press; the plugin resumes it then.
|
|
6
|
+
Headless (tests, `kiln run`, scenarios) it does nothing and never throws. A missing or undecodable
|
|
7
|
+
file logs one `[audio]` warning and stays silent.
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { AudioPlugin, Sound, PlaySound } from './plugins/audio/index.ts'
|
|
11
|
+
|
|
12
|
+
app.use(CorePlugins, AudioPlugin)
|
|
13
|
+
|
|
14
|
+
// a looping sound at a place (put files in public/, they are served from '/'):
|
|
15
|
+
commands.spawn(Transform({ x: 4 }), Sound({ src: '/sfx/campfire.ogg', loop: true, refDistance: 3 }))
|
|
16
|
+
// a one-shot from any system:
|
|
17
|
+
events.emit(PlaySound, { src: '/sfx/coin.ogg', entity: player, volume: 0.8 })
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Adds:
|
|
21
|
+
- `Sound` trait: src, volume, loop, autoplay, refDistance. Changing src swaps the sound.
|
|
22
|
+
- `PlaySound` event: `{ entity?, src, volume? }`; positional when the entity has a Transform.
|
|
23
|
+
- `AudioState` resource: `listener` (null headless), master `volume`, `failed` URLs.
|
|
24
|
+
- System (Update): `UpdateAudio`.
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// Positional sounds tied to Transform, on Web Audio via Three.js. Vendored by `kiln add audio`.
|
|
2
|
+
import * as THREE from 'three'
|
|
3
|
+
import { Camera, event, plugin, resource, Scene, system, trait, Transform, Update, type App, type Entity } from 'kiln-ecs'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A looping or one-time sound at this entity's Transform:
|
|
7
|
+
* `commands.spawn(Transform({ x: 4 }), Sound({ src: '/sfx/waterfall.ogg', loop: true }))`.
|
|
8
|
+
* `src` is a URL (files in the game's `public/` folder are served from `/`), `volume` 0..1+,
|
|
9
|
+
* `refDistance` the distance in world units at which volume starts to fall off. `autoplay` starts it
|
|
10
|
+
* once the file has loaded (browsers wait for the first click or key press before any audio plays).
|
|
11
|
+
* Without a Transform the sound is not positional. Headless (tests, `kiln run`) nothing loads or plays.
|
|
12
|
+
*/
|
|
13
|
+
export const Sound = trait({ src: '', volume: 1, loop: false, autoplay: true, refDistance: 4 })
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Plays a one-shot: `events.emit(PlaySound, { src: '/sfx/coin.ogg', entity })`. With an entity that
|
|
17
|
+
* has a Transform the sound plays positionally at that spot; without one it plays flat.
|
|
18
|
+
*/
|
|
19
|
+
export const PlaySound = event<{ entity?: Entity; src: string; volume?: number }>()
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Audio state: the `THREE.AudioListener` attached to the Camera (null headless or before the first
|
|
23
|
+
* frame) and a master `volume`. Change the volume with `res.mut(AudioState).volume = 0.5`.
|
|
24
|
+
*/
|
|
25
|
+
export const AudioState = resource(
|
|
26
|
+
(): { listener: THREE.AudioListener | null; volume: number; failed: string[] } => ({ listener: null, volume: 1, failed: [] }),
|
|
27
|
+
{ readonlyProxy: false },
|
|
28
|
+
).named('AudioState')
|
|
29
|
+
|
|
30
|
+
interface Voice {
|
|
31
|
+
audio: THREE.PositionalAudio | THREE.Audio
|
|
32
|
+
src: string
|
|
33
|
+
started: boolean
|
|
34
|
+
seen: number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** True only where Web Audio exists and the app is running for real. */
|
|
38
|
+
function canPlay(app: App): boolean {
|
|
39
|
+
return !app.headless && typeof window !== 'undefined' && (typeof AudioContext !== 'undefined' || typeof (window as any).webkitAudioContext !== 'undefined')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Positional audio: `app.use(AudioPlugin)`, then spawn `Sound` entities or emit `PlaySound`.
|
|
44
|
+
* A no-op headless. Missing or undecodable files log one warning and stay silent.
|
|
45
|
+
*/
|
|
46
|
+
export const AudioPlugin = plugin((app) => {
|
|
47
|
+
app.init(AudioState, PlaySound)
|
|
48
|
+
const voices = new Map<Entity, Voice>()
|
|
49
|
+
const oneShots = new Set<THREE.Audio | THREE.PositionalAudio>()
|
|
50
|
+
const buffers = new Map<string, Promise<AudioBuffer | null>>()
|
|
51
|
+
let loader: THREE.AudioLoader | null = null
|
|
52
|
+
let frame = 0
|
|
53
|
+
let unlock: (() => void) | null = null
|
|
54
|
+
|
|
55
|
+
const load = (src: string, state: { failed: string[] }): Promise<AudioBuffer | null> => {
|
|
56
|
+
let p = buffers.get(src)
|
|
57
|
+
if (p) return p
|
|
58
|
+
loader ??= new THREE.AudioLoader()
|
|
59
|
+
const l = loader
|
|
60
|
+
p = new Promise<AudioBuffer | null>((resolve) => {
|
|
61
|
+
l.load(
|
|
62
|
+
src,
|
|
63
|
+
(buf) => resolve(buf),
|
|
64
|
+
undefined,
|
|
65
|
+
(err) => {
|
|
66
|
+
state.failed.push(src)
|
|
67
|
+
console.warn(`[audio] couldn't load '${src}' (${(err as Error)?.message ?? err}). Check the path; files in public/ are served from '/'.`)
|
|
68
|
+
resolve(null)
|
|
69
|
+
},
|
|
70
|
+
)
|
|
71
|
+
})
|
|
72
|
+
buffers.set(src, p)
|
|
73
|
+
app.track(p)
|
|
74
|
+
return p
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const listenerFor = (state: { listener: THREE.AudioListener | null }, camera: THREE.Camera): THREE.AudioListener => {
|
|
78
|
+
if (state.listener) return state.listener
|
|
79
|
+
const listener = new THREE.AudioListener()
|
|
80
|
+
camera.add(listener)
|
|
81
|
+
state.listener = listener
|
|
82
|
+
// Browsers keep the AudioContext suspended until a user gesture.
|
|
83
|
+
const resume = () => {
|
|
84
|
+
if (listener.context.state === 'suspended') void listener.context.resume()
|
|
85
|
+
}
|
|
86
|
+
window.addEventListener('pointerdown', resume)
|
|
87
|
+
window.addEventListener('keydown', resume)
|
|
88
|
+
unlock = () => {
|
|
89
|
+
window.removeEventListener('pointerdown', resume)
|
|
90
|
+
window.removeEventListener('keydown', resume)
|
|
91
|
+
}
|
|
92
|
+
return listener
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const stop = (a: THREE.Audio | THREE.PositionalAudio) => {
|
|
96
|
+
try {
|
|
97
|
+
if (a.isPlaying) a.stop()
|
|
98
|
+
} catch {}
|
|
99
|
+
a.removeFromParent()
|
|
100
|
+
try {
|
|
101
|
+
a.disconnect()
|
|
102
|
+
} catch {}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Starts, moves and stops Sound entities and plays PlaySound one-shots. */
|
|
106
|
+
const UpdateAudio = system(
|
|
107
|
+
{ sounds: [Sound] },
|
|
108
|
+
({ sounds }, { res, events }) => {
|
|
109
|
+
const shots = events.read(PlaySound)
|
|
110
|
+
if (!canPlay(app)) return
|
|
111
|
+
frame++
|
|
112
|
+
const state = res.mut(AudioState)
|
|
113
|
+
const scene = res.mut(Scene)
|
|
114
|
+
const camera = res.mut(Camera)
|
|
115
|
+
const listener = listenerFor(state, camera)
|
|
116
|
+
listener.setMasterVolume(state.volume)
|
|
117
|
+
|
|
118
|
+
sounds.forEach((e) => {
|
|
119
|
+
const s = e.get(Sound)!
|
|
120
|
+
let v = voices.get(e)
|
|
121
|
+
if (v && v.src !== s.src) {
|
|
122
|
+
stop(v.audio)
|
|
123
|
+
voices.delete(e)
|
|
124
|
+
v = undefined
|
|
125
|
+
}
|
|
126
|
+
const positional = e.has(Transform)
|
|
127
|
+
if (!v) {
|
|
128
|
+
if (!s.src) return
|
|
129
|
+
const audio = positional ? new THREE.PositionalAudio(listener) : new THREE.Audio(listener)
|
|
130
|
+
scene.add(audio)
|
|
131
|
+
v = { audio, src: s.src, started: false, seen: frame }
|
|
132
|
+
voices.set(e, v)
|
|
133
|
+
const voice = v
|
|
134
|
+
void load(s.src, state).then((buf) => {
|
|
135
|
+
if (!buf || voices.get(e) !== voice) return
|
|
136
|
+
voice.audio.setBuffer(buf)
|
|
137
|
+
voice.audio.setLoop(s.loop)
|
|
138
|
+
if (s.autoplay && !voice.started) {
|
|
139
|
+
voice.started = true
|
|
140
|
+
voice.audio.play()
|
|
141
|
+
}
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
v.seen = frame
|
|
145
|
+
v.audio.setVolume(s.volume)
|
|
146
|
+
if (v.audio.getLoop() !== s.loop && v.audio.buffer) v.audio.setLoop(s.loop)
|
|
147
|
+
if (positional) {
|
|
148
|
+
const t = e.get(Transform)!
|
|
149
|
+
v.audio.position.set(t.x, t.y, t.z)
|
|
150
|
+
;(v.audio as THREE.PositionalAudio).setRefDistance?.(s.refDistance)
|
|
151
|
+
}
|
|
152
|
+
})
|
|
153
|
+
for (const [e, v] of voices) {
|
|
154
|
+
if (v.seen === frame) continue
|
|
155
|
+
stop(v.audio)
|
|
156
|
+
voices.delete(e)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
for (const shot of shots) {
|
|
160
|
+
if (!shot?.src) continue
|
|
161
|
+
const at = shot.entity !== undefined && app.world.has(shot.entity) && shot.entity.has(Transform) ? shot.entity.get(Transform)! : null
|
|
162
|
+
const audio = at ? new THREE.PositionalAudio(listener) : new THREE.Audio(listener)
|
|
163
|
+
if (at) audio.position.set(at.x, at.y, at.z)
|
|
164
|
+
audio.setVolume(shot.volume ?? 1)
|
|
165
|
+
scene.add(audio)
|
|
166
|
+
oneShots.add(audio)
|
|
167
|
+
void load(shot.src, state).then((buf) => {
|
|
168
|
+
if (!buf || !oneShots.has(audio)) return
|
|
169
|
+
audio.setBuffer(buf)
|
|
170
|
+
const ended = audio.onEnded.bind(audio)
|
|
171
|
+
audio.onEnded = () => {
|
|
172
|
+
ended()
|
|
173
|
+
oneShots.delete(audio)
|
|
174
|
+
stop(audio)
|
|
175
|
+
}
|
|
176
|
+
audio.play()
|
|
177
|
+
})
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
{ name: 'UpdateAudio' },
|
|
181
|
+
)
|
|
182
|
+
app.add(Update, UpdateAudio)
|
|
183
|
+
|
|
184
|
+
app.hooks.dispose.push(() => {
|
|
185
|
+
for (const v of voices.values()) stop(v.audio)
|
|
186
|
+
for (const a of oneShots) stop(a)
|
|
187
|
+
voices.clear()
|
|
188
|
+
oneShots.clear()
|
|
189
|
+
unlock?.()
|
|
190
|
+
const state = app.hasResource(AudioState) ? app.resource(AudioState) : null
|
|
191
|
+
if (state?.listener) {
|
|
192
|
+
state.listener.removeFromParent()
|
|
193
|
+
state.listener = null
|
|
194
|
+
}
|
|
195
|
+
})
|
|
196
|
+
}, 'AudioPlugin')
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# orbit-camera
|
|
2
|
+
|
|
3
|
+
An orbit camera around a target point. Drag with the `fire` or `alt` action held (left/right mouse
|
|
4
|
+
by default) to rotate, scroll to zoom. Add `CameraFollow` to an entity and the orbit target follows
|
|
5
|
+
its Transform smoothly. Needs the render and input plugins (`CorePlugins`).
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { OrbitCameraPlugin, OrbitCamera, CameraFollow } from './plugins/orbit-camera/index.ts'
|
|
9
|
+
|
|
10
|
+
app.use(CorePlugins, OrbitCameraPlugin)
|
|
11
|
+
|
|
12
|
+
// in a Startup system:
|
|
13
|
+
commands.spawn(OrbitCamera({ distance: 20, yaw: 0.8, pitch: 0.5 }))
|
|
14
|
+
commands.spawn(Transform({ x: 2 }), Player, CameraFollow({ smoothing: 6, offsetY: 1 })) // the camera follows it
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Adds:
|
|
18
|
+
- `OrbitCamera` trait: targetX/Y/Z, distance, yaw, pitch, min/max distance and pitch, rotateSpeed, zoomSpeed, enabled. The first entity with it drives the `Camera` resource.
|
|
19
|
+
- `CameraFollow` trait: smoothing (catch-up rate per second, 0 snaps), offsetX/Y/Z.
|
|
20
|
+
- Systems (Update): `FollowTarget`, then `OrbitControl` (writes `res.mut(Camera)`).
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Orbit camera: drag to rotate, wheel to zoom, optionally follow an entity. Vendored by `kiln add orbit-camera`.
|
|
2
|
+
import { Camera, clamp, Input, Mut, plugin, system, trait, Transform, Update } from 'kiln-ecs'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Orbit camera parameters. Put it on one entity (the first one found drives the Camera):
|
|
6
|
+
* `commands.spawn(OrbitCamera({ distance: 20, pitch: 0.6 }))`. The camera sits `distance` away from
|
|
7
|
+
* (targetX, targetY, targetZ), at `yaw` radians around Y and `pitch` radians above the horizon.
|
|
8
|
+
* Dragging with 'fire' or 'alt' held rotates it, the wheel zooms. Set `enabled: false` to freeze
|
|
9
|
+
* input while still placing the camera.
|
|
10
|
+
*/
|
|
11
|
+
export const OrbitCamera = trait({
|
|
12
|
+
targetX: 0,
|
|
13
|
+
targetY: 0,
|
|
14
|
+
targetZ: 0,
|
|
15
|
+
distance: 15,
|
|
16
|
+
yaw: 0,
|
|
17
|
+
pitch: 0.5,
|
|
18
|
+
minDistance: 2,
|
|
19
|
+
maxDistance: 200,
|
|
20
|
+
minPitch: -1.45,
|
|
21
|
+
maxPitch: 1.45,
|
|
22
|
+
/** Radians per pixel of pointer drag. */
|
|
23
|
+
rotateSpeed: 0.006,
|
|
24
|
+
/** Fraction of distance per 100 wheel units. */
|
|
25
|
+
zoomSpeed: 0.1,
|
|
26
|
+
enabled: true,
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Makes the orbit target follow this entity's Transform: `CameraFollow({ smoothing: 6, offsetY: 1 })`.
|
|
31
|
+
* `smoothing` is the catch-up rate per second (0 snaps). The first entity with CameraFollow and
|
|
32
|
+
* Transform is followed.
|
|
33
|
+
*/
|
|
34
|
+
export const CameraFollow = trait({ smoothing: 6, offsetX: 0, offsetY: 0, offsetZ: 0 })
|
|
35
|
+
|
|
36
|
+
/** Moves the orbit target toward the followed entity, framerate-independently. */
|
|
37
|
+
export const FollowTarget = system(
|
|
38
|
+
{ cams: [Mut(OrbitCamera)], follow: [CameraFollow, Transform] },
|
|
39
|
+
({ cams, follow }, { time }) => {
|
|
40
|
+
const target = follow.first()
|
|
41
|
+
if (target === undefined || cams.length === 0) return
|
|
42
|
+
const t = target.get(Transform)!
|
|
43
|
+
const f = target.get(CameraFollow)!
|
|
44
|
+
cams.each(([o]) => {
|
|
45
|
+
const k = f.smoothing <= 0 ? 1 : 1 - Math.exp(-f.smoothing * time.dt)
|
|
46
|
+
o.targetX += (t.x + f.offsetX - o.targetX) * k
|
|
47
|
+
o.targetY += (t.y + f.offsetY - o.targetY) * k
|
|
48
|
+
o.targetZ += (t.z + f.offsetZ - o.targetZ) * k
|
|
49
|
+
})
|
|
50
|
+
},
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
/** Applies pointer drag and wheel to the OrbitCamera, then places the Camera and aims it at the target. */
|
|
54
|
+
export const OrbitControl = system({ cams: [Mut(OrbitCamera)] }, ({ cams }, { res }) => {
|
|
55
|
+
if (cams.length === 0) return
|
|
56
|
+
const input = res.has(Input) ? res.get(Input) : null
|
|
57
|
+
let done = false
|
|
58
|
+
cams.each(([o]) => {
|
|
59
|
+
if (done) return // only the first OrbitCamera drives the Camera
|
|
60
|
+
done = true
|
|
61
|
+
if (input && o.enabled) {
|
|
62
|
+
const p = input.pointer
|
|
63
|
+
if (input.down('fire') || input.down('alt')) {
|
|
64
|
+
o.yaw -= p.dx * o.rotateSpeed
|
|
65
|
+
o.pitch += p.dy * o.rotateSpeed
|
|
66
|
+
}
|
|
67
|
+
if (p.wheel !== 0) o.distance *= Math.exp((p.wheel / 100) * o.zoomSpeed)
|
|
68
|
+
}
|
|
69
|
+
o.pitch = clamp(o.pitch, o.minPitch, o.maxPitch)
|
|
70
|
+
o.distance = clamp(o.distance, o.minDistance, o.maxDistance)
|
|
71
|
+
const cam = res.mut(Camera)
|
|
72
|
+
const cp = Math.cos(o.pitch)
|
|
73
|
+
cam.position.set(
|
|
74
|
+
o.targetX + o.distance * cp * Math.sin(o.yaw),
|
|
75
|
+
o.targetY + o.distance * Math.sin(o.pitch),
|
|
76
|
+
o.targetZ + o.distance * cp * Math.cos(o.yaw),
|
|
77
|
+
)
|
|
78
|
+
cam.lookAt(o.targetX, o.targetY, o.targetZ)
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Orbit camera: `app.use(OrbitCameraPlugin)` after CorePlugins, then spawn an `OrbitCamera` entity.
|
|
84
|
+
* Runs FollowTarget then OrbitControl in Update; writes `res.mut(Camera)`.
|
|
85
|
+
*/
|
|
86
|
+
export const OrbitCameraPlugin = plugin((app) => {
|
|
87
|
+
app.add(Update, OrbitControl.after(FollowTarget), FollowTarget)
|
|
88
|
+
}, 'OrbitCameraPlugin')
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# particles
|
|
2
|
+
|
|
3
|
+
Visual-only particle emitters. Each emitter entity gets one `THREE.InstancedMesh` holding all its
|
|
4
|
+
particles, simulated in Update in world space (moving the emitter doesn't drag live particles).
|
|
5
|
+
Particles are not entities, so they never show up in snapshots or queries. Each emitter forks its
|
|
6
|
+
own RNG from the app's seeded `Rng` the first frame it is seen, so runs replay exactly.
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
import { ParticlesPlugin, ParticleEmitter } from './plugins/particles/index.ts'
|
|
10
|
+
|
|
11
|
+
app.use(CorePlugins, ParticlesPlugin)
|
|
12
|
+
|
|
13
|
+
// in a Startup system (or on a torch prefab):
|
|
14
|
+
commands.spawn(Transform({ y: 1 }), ParticleEmitter({ rate: 60, lifetime: 1.2, speed: 3, spread: 0.4, size: 0.2, color: 0xffb347 }))
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Adds:
|
|
18
|
+
- `ParticleEmitter` trait: rate (per second, 0 stops), lifetime, speed, spread (cone half-angle, radians), size, gravity, color, colorEnd, max.
|
|
19
|
+
- System (Update): `UpdateParticles`. Removing the trait or despawning the entity removes its mesh.
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// Visual-only particle emitters: one InstancedMesh per emitter. Vendored by `kiln add particles`.
|
|
2
|
+
import * as THREE from 'three'
|
|
3
|
+
import { plugin, Rng, RngState, Scene, system, trait, Transform, Update, type Entity } from 'kiln-ecs'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A particle emitter at this entity's Transform:
|
|
7
|
+
* `commands.spawn(Transform({ y: 1 }), ParticleEmitter({ rate: 40, color: 0xffaa33 }))`.
|
|
8
|
+
* `rate` is particles per second, `lifetime` seconds, `speed` the launch speed (units/s),
|
|
9
|
+
* `spread` the cone half-angle in radians around +Y (π = every direction), `size` the start
|
|
10
|
+
* diameter (particles shrink to nothing), `gravity` units/s² down, `max` the most alive at once.
|
|
11
|
+
* `color` fades toward `colorEnd` over a particle's life. Set `rate: 0` to stop emitting.
|
|
12
|
+
* Particles are not entities: they live in the plugin, in world space, and never touch the
|
|
13
|
+
* simulation or the gameplay Rng stream after the emitter's first frame.
|
|
14
|
+
*/
|
|
15
|
+
export const ParticleEmitter = trait({
|
|
16
|
+
rate: 30,
|
|
17
|
+
lifetime: 1.5,
|
|
18
|
+
speed: 3,
|
|
19
|
+
spread: 0.4,
|
|
20
|
+
size: 0.15,
|
|
21
|
+
gravity: 2,
|
|
22
|
+
color: 0xffb347,
|
|
23
|
+
colorEnd: 0xff3b1f,
|
|
24
|
+
max: 256,
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
interface EmitterState {
|
|
28
|
+
mesh: THREE.InstancedMesh
|
|
29
|
+
rng: RngState
|
|
30
|
+
/** Per particle: x y z vx vy vz age life. */
|
|
31
|
+
data: Float32Array
|
|
32
|
+
alive: number
|
|
33
|
+
carry: number
|
|
34
|
+
max: number
|
|
35
|
+
seen: number
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const STRIDE = 8
|
|
39
|
+
const geometry = new THREE.IcosahedronGeometry(0.5, 0)
|
|
40
|
+
const _m = new THREE.Matrix4()
|
|
41
|
+
const _q = new THREE.Quaternion()
|
|
42
|
+
const _p = new THREE.Vector3()
|
|
43
|
+
const _s = new THREE.Vector3()
|
|
44
|
+
const _c0 = new THREE.Color()
|
|
45
|
+
const _c1 = new THREE.Color()
|
|
46
|
+
const _c = new THREE.Color()
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Colours go in a per-instance 'color' geometry attribute (read through vertexColors) rather than
|
|
50
|
+
* InstancedMesh.instanceColor: WebGPURenderer re-uploads geometry attributes on every render call,
|
|
51
|
+
* but syncs instanceColor only once per animation frame, which loses updates when a frame renders
|
|
52
|
+
* several times (kiln shot steps).
|
|
53
|
+
*/
|
|
54
|
+
function makeMesh(max: number): THREE.InstancedMesh {
|
|
55
|
+
const geo = new THREE.BufferGeometry()
|
|
56
|
+
geo.setAttribute('position', geometry.getAttribute('position'))
|
|
57
|
+
geo.setAttribute('color', new THREE.InstancedBufferAttribute(new Float32Array(max * 3), 3))
|
|
58
|
+
const mat = new THREE.MeshBasicMaterial({ color: 0xffffff, vertexColors: true })
|
|
59
|
+
const mesh = new THREE.InstancedMesh(geo, mat, max)
|
|
60
|
+
mesh.name = 'particles'
|
|
61
|
+
mesh.frustumCulled = false
|
|
62
|
+
mesh.count = 0
|
|
63
|
+
return mesh
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function disposeMesh(mesh: THREE.InstancedMesh): void {
|
|
67
|
+
mesh.removeFromParent()
|
|
68
|
+
mesh.geometry.dispose()
|
|
69
|
+
;(mesh.material as THREE.Material).dispose()
|
|
70
|
+
mesh.dispose()
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Particle emitters: `app.use(ParticlesPlugin)`, then spawn `ParticleEmitter` entities with a Transform.
|
|
75
|
+
* Each emitter gets its own RNG forked from the app's `Rng` when first seen, so runs replay exactly.
|
|
76
|
+
*/
|
|
77
|
+
export const ParticlesPlugin = plugin((app) => {
|
|
78
|
+
const emitters = new Map<Entity, EmitterState>()
|
|
79
|
+
let frame = 0
|
|
80
|
+
|
|
81
|
+
/** Emits, moves, ages and draws every emitter's particles. */
|
|
82
|
+
const UpdateParticles = system(
|
|
83
|
+
{ emitters: [ParticleEmitter, Transform] },
|
|
84
|
+
({ emitters: q }, { res, time }) => {
|
|
85
|
+
frame++
|
|
86
|
+
const dt = time.dt
|
|
87
|
+
q.each(([em, t], e) => {
|
|
88
|
+
let st = emitters.get(e)
|
|
89
|
+
const max = Math.max(1, Math.min(100000, Math.round(em.max)))
|
|
90
|
+
if (!st || st.max !== max) {
|
|
91
|
+
if (st) disposeMesh(st.mesh)
|
|
92
|
+
const mesh = makeMesh(max)
|
|
93
|
+
res.mut(Scene).add(mesh)
|
|
94
|
+
st = { mesh, rng: st?.rng ?? new RngState(res.mut(Rng).u32()), data: new Float32Array(max * STRIDE), alive: 0, carry: 0, max, seen: frame }
|
|
95
|
+
emitters.set(e, st)
|
|
96
|
+
}
|
|
97
|
+
st.seen = frame
|
|
98
|
+
const d = st.data
|
|
99
|
+
// Age and move; swap-remove the dead.
|
|
100
|
+
for (let i = 0; i < st.alive; ) {
|
|
101
|
+
const o = i * STRIDE
|
|
102
|
+
d[o + 6] += dt
|
|
103
|
+
if (d[o + 6] >= d[o + 7]) {
|
|
104
|
+
st.alive--
|
|
105
|
+
d.copyWithin(o, st.alive * STRIDE, st.alive * STRIDE + STRIDE)
|
|
106
|
+
continue
|
|
107
|
+
}
|
|
108
|
+
d[o + 4] -= em.gravity * dt
|
|
109
|
+
d[o] += d[o + 3] * dt
|
|
110
|
+
d[o + 1] += d[o + 4] * dt
|
|
111
|
+
d[o + 2] += d[o + 5] * dt
|
|
112
|
+
i++
|
|
113
|
+
}
|
|
114
|
+
// Emit.
|
|
115
|
+
st.carry += Math.max(0, em.rate) * dt
|
|
116
|
+
const rng = st.rng
|
|
117
|
+
while (st.carry >= 1) {
|
|
118
|
+
st.carry -= 1
|
|
119
|
+
if (st.alive >= max) continue
|
|
120
|
+
const o = st.alive++ * STRIDE
|
|
121
|
+
const theta = rng.float(0, Math.PI * 2)
|
|
122
|
+
const cosPhi = 1 - rng.float() * (1 - Math.cos(Math.min(Math.PI, Math.max(0, em.spread))))
|
|
123
|
+
const sinPhi = Math.sqrt(Math.max(0, 1 - cosPhi * cosPhi))
|
|
124
|
+
const v = em.speed * rng.float(0.7, 1)
|
|
125
|
+
d[o] = t.x
|
|
126
|
+
d[o + 1] = t.y
|
|
127
|
+
d[o + 2] = t.z
|
|
128
|
+
d[o + 3] = Math.cos(theta) * sinPhi * v
|
|
129
|
+
d[o + 4] = cosPhi * v
|
|
130
|
+
d[o + 5] = Math.sin(theta) * sinPhi * v
|
|
131
|
+
d[o + 6] = 0
|
|
132
|
+
d[o + 7] = em.lifetime * rng.float(0.75, 1.25)
|
|
133
|
+
}
|
|
134
|
+
// Write instances.
|
|
135
|
+
_c0.setHex(em.color)
|
|
136
|
+
_c1.setHex(em.colorEnd)
|
|
137
|
+
const mesh = st.mesh
|
|
138
|
+
const colors = mesh.geometry.getAttribute('color') as THREE.InstancedBufferAttribute
|
|
139
|
+
for (let i = 0; i < st.alive; i++) {
|
|
140
|
+
const o = i * STRIDE
|
|
141
|
+
const k = d[o + 6] / d[o + 7]
|
|
142
|
+
const sz = em.size * (1 - k)
|
|
143
|
+
_p.set(d[o], d[o + 1], d[o + 2])
|
|
144
|
+
_s.set(sz, sz, sz)
|
|
145
|
+
mesh.setMatrixAt(i, _m.compose(_p, _q, _s))
|
|
146
|
+
_c.copy(_c0).lerp(_c1, k)
|
|
147
|
+
colors.setXYZ(i, _c.r, _c.g, _c.b)
|
|
148
|
+
}
|
|
149
|
+
mesh.count = st.alive
|
|
150
|
+
mesh.instanceMatrix.needsUpdate = true
|
|
151
|
+
colors.needsUpdate = true
|
|
152
|
+
})
|
|
153
|
+
// Emitters that lost their trait or were despawned.
|
|
154
|
+
for (const [e, st] of emitters) {
|
|
155
|
+
if (st.seen === frame) continue
|
|
156
|
+
disposeMesh(st.mesh)
|
|
157
|
+
emitters.delete(e)
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
{ name: 'UpdateParticles' },
|
|
161
|
+
)
|
|
162
|
+
app.add(Update, UpdateParticles)
|
|
163
|
+
app.hooks.dispose.push(() => {
|
|
164
|
+
for (const st of emitters.values()) disposeMesh(st.mesh)
|
|
165
|
+
emitters.clear()
|
|
166
|
+
})
|
|
167
|
+
}, 'ParticlesPlugin')
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# terrain
|
|
2
|
+
|
|
3
|
+
A procedural heightfield: seeded fractal value noise (no Math.random), vertex colours blended from
|
|
4
|
+
`colorLow` to `colorHigh` by height, flat-shaded. `terrainHeightAt` samples the exact same function
|
|
5
|
+
headless, so gameplay can put things on the ground without the mesh.
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { TerrainPlugin, Terrain, terrainHeightAt } from './plugins/terrain/index.ts'
|
|
9
|
+
|
|
10
|
+
app.use(CorePlugins, TerrainPlugin)
|
|
11
|
+
|
|
12
|
+
const land = { size: 64, resolution: 96, height: 6, seed: 7, scale: 18, octaves: 4, colorLow: 0xd9c99a, colorHigh: 0x2f6b2f }
|
|
13
|
+
// in a Startup system:
|
|
14
|
+
commands.spawn(Transform, Terrain(land))
|
|
15
|
+
commands.spawn(Tree, { x: 3, y: terrainHeightAt(land, 3, -2), z: -2 })
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Adds:
|
|
19
|
+
- `Terrain` trait: size, resolution (quads per side, 1..1024), height, seed, scale, octaves, colorLow, colorHigh. Changing any field rebuilds the geometry.
|
|
20
|
+
- `terrainHeightAt(params, x, z)`: height in local space (add the terrain's Transform if it moved).
|
|
21
|
+
- `buildTerrainGeometry(params)`: the BufferGeometry, if you want it elsewhere (e.g. a physics trimesh).
|
|
22
|
+
- Systems (Update): `BuildTerrain` (adds MeshRef via commands), `RebuildTerrain`.
|