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.
Files changed (326) hide show
  1. package/README.md +72 -2
  2. package/community/audio/README.md +24 -0
  3. package/community/audio/index.ts +196 -0
  4. package/community/audio/kiln-plugin.json +6 -0
  5. package/community/orbit-camera/README.md +20 -0
  6. package/community/orbit-camera/index.ts +88 -0
  7. package/community/orbit-camera/kiln-plugin.json +6 -0
  8. package/community/particles/README.md +19 -0
  9. package/community/particles/index.ts +167 -0
  10. package/community/particles/kiln-plugin.json +6 -0
  11. package/community/terrain/README.md +22 -0
  12. package/community/terrain/index.ts +133 -0
  13. package/community/terrain/kiln-plugin.json +6 -0
  14. package/community/water/README.md +20 -0
  15. package/community/water/index.ts +117 -0
  16. package/community/water/kiln-plugin.json +6 -0
  17. package/docs/agents-guide.md +521 -0
  18. package/lib/browser.d.ts +28 -0
  19. package/lib/browser.d.ts.map +1 -0
  20. package/lib/browser.js +260 -0
  21. package/lib/browser.js.map +1 -0
  22. package/lib/cli/add.d.ts +18 -0
  23. package/lib/cli/add.d.ts.map +1 -0
  24. package/lib/cli/add.js +66 -0
  25. package/lib/cli/add.js.map +1 -0
  26. package/lib/cli/args.d.ts +9 -0
  27. package/lib/cli/args.d.ts.map +1 -0
  28. package/lib/cli/args.js +48 -0
  29. package/lib/cli/args.js.map +1 -0
  30. package/lib/cli/check.d.ts +13 -0
  31. package/lib/cli/check.d.ts.map +1 -0
  32. package/lib/cli/check.js +64 -0
  33. package/lib/cli/check.js.map +1 -0
  34. package/lib/cli/describe.d.ts +4 -0
  35. package/lib/cli/describe.d.ts.map +1 -0
  36. package/lib/cli/describe.js +19 -0
  37. package/lib/cli/describe.js.map +1 -0
  38. package/lib/cli/docs.d.ts +21 -0
  39. package/lib/cli/docs.d.ts.map +1 -0
  40. package/lib/cli/docs.js +170 -0
  41. package/lib/cli/docs.js.map +1 -0
  42. package/lib/cli/kiln.d.ts +15 -0
  43. package/lib/cli/kiln.d.ts.map +1 -0
  44. package/lib/cli/kiln.js +82 -0
  45. package/lib/cli/kiln.js.map +1 -0
  46. package/lib/cli/load.d.ts +12 -0
  47. package/lib/cli/load.d.ts.map +1 -0
  48. package/lib/cli/load.js +47 -0
  49. package/lib/cli/load.js.map +1 -0
  50. package/lib/cli/new.d.ts +10 -0
  51. package/lib/cli/new.d.ts.map +1 -0
  52. package/lib/cli/new.js +48 -0
  53. package/lib/cli/new.js.map +1 -0
  54. package/lib/cli/replay.d.ts +11 -0
  55. package/lib/cli/replay.d.ts.map +1 -0
  56. package/lib/cli/replay.js +82 -0
  57. package/lib/cli/replay.js.map +1 -0
  58. package/lib/cli/run.d.ts +12 -0
  59. package/lib/cli/run.d.ts.map +1 -0
  60. package/lib/cli/run.js +78 -0
  61. package/lib/cli/run.js.map +1 -0
  62. package/lib/cli/shot.d.ts +29 -0
  63. package/lib/cli/shot.d.ts.map +1 -0
  64. package/lib/cli/shot.js +163 -0
  65. package/lib/cli/shot.js.map +1 -0
  66. package/lib/cli/test.d.ts +4 -0
  67. package/lib/cli/test.d.ts.map +1 -0
  68. package/lib/cli/test.js +16 -0
  69. package/lib/cli/test.js.map +1 -0
  70. package/lib/core/access.d.ts +56 -0
  71. package/lib/core/access.d.ts.map +1 -0
  72. package/lib/core/access.js +25 -0
  73. package/lib/core/access.js.map +1 -0
  74. package/lib/core/app.d.ts +255 -0
  75. package/lib/core/app.d.ts.map +1 -0
  76. package/lib/core/app.js +827 -0
  77. package/lib/core/app.js.map +1 -0
  78. package/lib/core/commands.d.ts +56 -0
  79. package/lib/core/commands.d.ts.map +1 -0
  80. package/lib/core/commands.js +94 -0
  81. package/lib/core/commands.js.map +1 -0
  82. package/lib/core/core-plugin.d.ts +3 -0
  83. package/lib/core/core-plugin.d.ts.map +1 -0
  84. package/lib/core/core-plugin.js +8 -0
  85. package/lib/core/core-plugin.js.map +1 -0
  86. package/lib/core/describe.d.ts +41 -0
  87. package/lib/core/describe.d.ts.map +1 -0
  88. package/lib/core/describe.js +91 -0
  89. package/lib/core/describe.js.map +1 -0
  90. package/lib/core/dev.d.ts +6 -0
  91. package/lib/core/dev.d.ts.map +1 -0
  92. package/lib/core/dev.js +12 -0
  93. package/lib/core/dev.js.map +1 -0
  94. package/lib/core/errors.d.ts +10 -0
  95. package/lib/core/errors.d.ts.map +1 -0
  96. package/lib/core/errors.js +12 -0
  97. package/lib/core/errors.js.map +1 -0
  98. package/lib/core/event.d.ts +49 -0
  99. package/lib/core/event.d.ts.map +1 -0
  100. package/lib/core/event.js +122 -0
  101. package/lib/core/event.js.map +1 -0
  102. package/lib/core/guard.d.ts +23 -0
  103. package/lib/core/guard.d.ts.map +1 -0
  104. package/lib/core/guard.js +124 -0
  105. package/lib/core/guard.js.map +1 -0
  106. package/lib/core/input-types.d.ts +75 -0
  107. package/lib/core/input-types.d.ts.map +1 -0
  108. package/lib/core/input-types.js +1 -0
  109. package/lib/core/input-types.js.map +1 -0
  110. package/lib/core/koota.d.ts +2 -0
  111. package/lib/core/koota.d.ts.map +1 -0
  112. package/lib/core/koota.js +3 -0
  113. package/lib/core/koota.js.map +1 -0
  114. package/lib/core/math.d.ts +14 -0
  115. package/lib/core/math.d.ts.map +1 -0
  116. package/lib/core/math.js +21 -0
  117. package/lib/core/math.js.map +1 -0
  118. package/lib/core/names.d.ts +13 -0
  119. package/lib/core/names.d.ts.map +1 -0
  120. package/lib/core/names.js +52 -0
  121. package/lib/core/names.js.map +1 -0
  122. package/lib/core/plugin.d.ts +17 -0
  123. package/lib/core/plugin.d.ts.map +1 -0
  124. package/lib/core/plugin.js +26 -0
  125. package/lib/core/plugin.js.map +1 -0
  126. package/lib/core/prefab.d.ts +20 -0
  127. package/lib/core/prefab.d.ts.map +1 -0
  128. package/lib/core/prefab.js +31 -0
  129. package/lib/core/prefab.js.map +1 -0
  130. package/lib/core/recording.d.ts +159 -0
  131. package/lib/core/recording.d.ts.map +1 -0
  132. package/lib/core/recording.js +266 -0
  133. package/lib/core/recording.js.map +1 -0
  134. package/lib/core/resource.d.ts +26 -0
  135. package/lib/core/resource.d.ts.map +1 -0
  136. package/lib/core/resource.js +32 -0
  137. package/lib/core/resource.js.map +1 -0
  138. package/lib/core/rng.d.ts +40 -0
  139. package/lib/core/rng.d.ts.map +1 -0
  140. package/lib/core/rng.js +79 -0
  141. package/lib/core/rng.js.map +1 -0
  142. package/lib/core/runtime.d.ts +35 -0
  143. package/lib/core/runtime.d.ts.map +1 -0
  144. package/lib/core/runtime.js +168 -0
  145. package/lib/core/runtime.js.map +1 -0
  146. package/lib/core/schedule.d.ts +70 -0
  147. package/lib/core/schedule.d.ts.map +1 -0
  148. package/lib/core/schedule.js +214 -0
  149. package/lib/core/schedule.js.map +1 -0
  150. package/lib/core/snapshot.d.ts +65 -0
  151. package/lib/core/snapshot.d.ts.map +1 -0
  152. package/lib/core/snapshot.js +228 -0
  153. package/lib/core/snapshot.js.map +1 -0
  154. package/lib/core/state.d.ts +82 -0
  155. package/lib/core/state.d.ts.map +1 -0
  156. package/lib/core/state.js +139 -0
  157. package/lib/core/state.js.map +1 -0
  158. package/lib/core/system.d.ts +129 -0
  159. package/lib/core/system.d.ts.map +1 -0
  160. package/lib/core/system.js +109 -0
  161. package/lib/core/system.js.map +1 -0
  162. package/lib/core/time.d.ts +3 -0
  163. package/lib/core/time.d.ts.map +1 -0
  164. package/lib/core/time.js +4 -0
  165. package/lib/core/time.js.map +1 -0
  166. package/lib/core/transform.d.ts +34 -0
  167. package/lib/core/transform.d.ts.map +1 -0
  168. package/lib/core/transform.js +47 -0
  169. package/lib/core/transform.js.map +1 -0
  170. package/lib/index.d.ts +63 -0
  171. package/lib/index.d.ts.map +1 -0
  172. package/lib/index.js +37 -0
  173. package/lib/index.js.map +1 -0
  174. package/lib/plugins/animation/graph.d.ts +232 -0
  175. package/lib/plugins/animation/graph.d.ts.map +1 -0
  176. package/lib/plugins/animation/graph.js +415 -0
  177. package/lib/plugins/animation/graph.js.map +1 -0
  178. package/lib/plugins/animation/index.d.ts +7 -0
  179. package/lib/plugins/animation/index.d.ts.map +1 -0
  180. package/lib/plugins/animation/index.js +5 -0
  181. package/lib/plugins/animation/index.js.map +1 -0
  182. package/lib/plugins/animation/skeletal.d.ts +68 -0
  183. package/lib/plugins/animation/skeletal.d.ts.map +1 -0
  184. package/lib/plugins/animation/skeletal.js +200 -0
  185. package/lib/plugins/animation/skeletal.js.map +1 -0
  186. package/lib/plugins/animation/sprite.d.ts +141 -0
  187. package/lib/plugins/animation/sprite.d.ts.map +1 -0
  188. package/lib/plugins/animation/sprite.js +249 -0
  189. package/lib/plugins/animation/sprite.js.map +1 -0
  190. package/lib/plugins/assets/index.d.ts +69 -0
  191. package/lib/plugins/assets/index.d.ts.map +1 -0
  192. package/lib/plugins/assets/index.js +283 -0
  193. package/lib/plugins/assets/index.js.map +1 -0
  194. package/lib/plugins/camera/index.d.ts +85 -0
  195. package/lib/plugins/camera/index.d.ts.map +1 -0
  196. package/lib/plugins/camera/index.js +167 -0
  197. package/lib/plugins/camera/index.js.map +1 -0
  198. package/lib/plugins/core-plugins.d.ts +7 -0
  199. package/lib/plugins/core-plugins.d.ts.map +1 -0
  200. package/lib/plugins/core-plugins.js +14 -0
  201. package/lib/plugins/core-plugins.js.map +1 -0
  202. package/lib/plugins/debug/index.d.ts +16 -0
  203. package/lib/plugins/debug/index.d.ts.map +1 -0
  204. package/lib/plugins/debug/index.js +287 -0
  205. package/lib/plugins/debug/index.js.map +1 -0
  206. package/lib/plugins/input/index.d.ts +109 -0
  207. package/lib/plugins/input/index.d.ts.map +1 -0
  208. package/lib/plugins/input/index.js +539 -0
  209. package/lib/plugins/input/index.js.map +1 -0
  210. package/lib/plugins/physics/index.d.ts +112 -0
  211. package/lib/plugins/physics/index.d.ts.map +1 -0
  212. package/lib/plugins/physics/index.js +356 -0
  213. package/lib/plugins/physics/index.js.map +1 -0
  214. package/lib/plugins/render/index.d.ts +67 -0
  215. package/lib/plugins/render/index.d.ts.map +1 -0
  216. package/lib/plugins/render/index.js +391 -0
  217. package/lib/plugins/render/index.js.map +1 -0
  218. package/lib/plugins/render/traits.d.ts +24 -0
  219. package/lib/plugins/render/traits.d.ts.map +1 -0
  220. package/lib/plugins/render/traits.js +20 -0
  221. package/lib/plugins/render/traits.js.map +1 -0
  222. package/lib/plugins/save/index.d.ts +81 -0
  223. package/lib/plugins/save/index.d.ts.map +1 -0
  224. package/lib/plugins/save/index.js +229 -0
  225. package/lib/plugins/save/index.js.map +1 -0
  226. package/lib/plugins/timer/index.d.ts +91 -0
  227. package/lib/plugins/timer/index.d.ts.map +1 -0
  228. package/lib/plugins/timer/index.js +145 -0
  229. package/lib/plugins/timer/index.js.map +1 -0
  230. package/lib/plugins/tween/index.d.ts +132 -0
  231. package/lib/plugins/tween/index.d.ts.map +1 -0
  232. package/lib/plugins/tween/index.js +304 -0
  233. package/lib/plugins/tween/index.js.map +1 -0
  234. package/lib/plugins/ui/index.d.ts +92 -0
  235. package/lib/plugins/ui/index.d.ts.map +1 -0
  236. package/lib/plugins/ui/index.js +290 -0
  237. package/lib/plugins/ui/index.js.map +1 -0
  238. package/lib/test/index.d.ts +59 -0
  239. package/lib/test/index.d.ts.map +1 -0
  240. package/lib/test/index.js +152 -0
  241. package/lib/test/index.js.map +1 -0
  242. package/lib/tooling/names.d.ts +6 -0
  243. package/lib/tooling/names.d.ts.map +1 -0
  244. package/lib/tooling/names.js +130 -0
  245. package/lib/tooling/names.js.map +1 -0
  246. package/lib/tooling/preload.d.ts +2 -0
  247. package/lib/tooling/preload.d.ts.map +1 -0
  248. package/lib/tooling/preload.js +19 -0
  249. package/lib/tooling/preload.js.map +1 -0
  250. package/lib/tooling/vite.d.ts +15 -0
  251. package/lib/tooling/vite.d.ts.map +1 -0
  252. package/lib/tooling/vite.js +164 -0
  253. package/lib/tooling/vite.js.map +1 -0
  254. package/package.json +121 -4
  255. package/src/browser.ts +265 -0
  256. package/src/cli/add.ts +70 -0
  257. package/src/cli/args.ts +46 -0
  258. package/src/cli/check.ts +65 -0
  259. package/src/cli/describe.ts +18 -0
  260. package/src/cli/docs.ts +170 -0
  261. package/src/cli/kiln.ts +84 -0
  262. package/src/cli/load.ts +53 -0
  263. package/src/cli/new.ts +45 -0
  264. package/src/cli/replay.ts +73 -0
  265. package/src/cli/run.ts +79 -0
  266. package/src/cli/shot.ts +161 -0
  267. package/src/cli/test.ts +16 -0
  268. package/src/core/access.ts +79 -0
  269. package/src/core/app.ts +892 -0
  270. package/src/core/commands.ts +117 -0
  271. package/src/core/core-plugin.ts +8 -0
  272. package/src/core/describe.ts +117 -0
  273. package/src/core/dev.ts +10 -0
  274. package/src/core/errors.ts +11 -0
  275. package/src/core/event.ts +147 -0
  276. package/src/core/guard.ts +151 -0
  277. package/src/core/input-types.ts +54 -0
  278. package/src/core/koota.ts +2 -0
  279. package/src/core/math.ts +32 -0
  280. package/src/core/names.ts +56 -0
  281. package/src/core/plugin.ts +38 -0
  282. package/src/core/prefab.ts +45 -0
  283. package/src/core/recording.ts +362 -0
  284. package/src/core/resource.ts +59 -0
  285. package/src/core/rng.ts +88 -0
  286. package/src/core/runtime.ts +191 -0
  287. package/src/core/schedule.ts +264 -0
  288. package/src/core/snapshot.ts +266 -0
  289. package/src/core/state.ts +160 -0
  290. package/src/core/system.ts +239 -0
  291. package/src/core/time.ts +4 -0
  292. package/src/core/transform.ts +54 -0
  293. package/src/index.ts +64 -0
  294. package/src/plugins/animation/graph.ts +513 -0
  295. package/src/plugins/animation/index.ts +19 -0
  296. package/src/plugins/animation/skeletal.ts +219 -0
  297. package/src/plugins/animation/sprite.ts +322 -0
  298. package/src/plugins/assets/index.ts +310 -0
  299. package/src/plugins/camera/index.ts +194 -0
  300. package/src/plugins/core-plugins.ts +15 -0
  301. package/src/plugins/debug/index.ts +286 -0
  302. package/src/plugins/input/index.ts +546 -0
  303. package/src/plugins/physics/index.ts +388 -0
  304. package/src/plugins/render/index.ts +437 -0
  305. package/src/plugins/render/traits.ts +25 -0
  306. package/src/plugins/save/index.ts +258 -0
  307. package/src/plugins/timer/index.ts +158 -0
  308. package/src/plugins/tween/index.ts +359 -0
  309. package/src/plugins/ui/index.ts +349 -0
  310. package/src/test/index.ts +206 -0
  311. package/src/tooling/names.ts +116 -0
  312. package/src/tooling/preload.ts +18 -0
  313. package/src/tooling/vite.ts +163 -0
  314. package/template/_gitignore +3 -0
  315. package/template/bunfig.toml +2 -0
  316. package/template/index.html +20 -0
  317. package/template/kiln.config.ts +8 -0
  318. package/template/package.json +23 -0
  319. package/template/public/.gitkeep +0 -0
  320. package/template/scenarios/player.test.ts +10 -0
  321. package/template/src/data.ts +9 -0
  322. package/template/src/main.ts +10 -0
  323. package/template/src/prefabs.ts +11 -0
  324. package/template/src/systems.ts +29 -0
  325. package/template/tsconfig.json +15 -0
  326. package/template/vite.config.ts +4 -0
package/README.md CHANGED
@@ -1,3 +1,73 @@
1
- # Temporary Holding Version
1
+ # Kiln
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
+ [![Docs](https://img.shields.io/badge/docs-read%20online-orange?style=for-the-badge&logo=readthedocs&logoColor=white)](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,6 @@
1
+ {
2
+ "name": "audio",
3
+ "description": "Positional sounds tied to Transform on Web Audio, plus a PlaySound one-shot event. No-op headless.",
4
+ "files": ["index.ts", "README.md"],
5
+ "requires": []
6
+ }
@@ -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,6 @@
1
+ {
2
+ "name": "orbit-camera",
3
+ "description": "Orbit camera: drag to rotate, wheel to zoom, optional smooth follow of an entity.",
4
+ "files": ["index.ts", "README.md"],
5
+ "requires": []
6
+ }
@@ -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,6 @@
1
+ {
2
+ "name": "particles",
3
+ "description": "Visual-only particle emitters: one InstancedMesh per emitter, seeded from the app Rng.",
4
+ "files": ["index.ts", "README.md"],
5
+ "requires": []
6
+ }
@@ -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`.