@downdraft/engine 0.0.0 → 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 (907) hide show
  1. package/app/src/index.ts +10 -0
  2. package/app/src/renderer/bootstrap.ts +271 -0
  3. package/app/src/renderer/downdraft-base.css +93 -0
  4. package/app/src/renderer/feature-log.ts +200 -0
  5. package/app/src/renderer/game-module.ts +923 -0
  6. package/app/src/renderer/hooks.ts +88 -0
  7. package/app/src/renderer/hot-reload.ts +178 -0
  8. package/app/src/renderer/import-cache.ts +88 -0
  9. package/app/src/renderer/index.ts +206 -0
  10. package/app/src/renderer/mcp-harness.ts +144 -0
  11. package/app/src/renderer/save-store-factory.ts +239 -0
  12. package/app/src/renderer/sim-bridge.ts +183 -0
  13. package/app/src/renderer/standard-automation-tools.ts +771 -0
  14. package/app/src/shared/feature-log.ts +71 -0
  15. package/app/src/shared/gpu-info.ts +82 -0
  16. package/app/src/shared/import-cache-store.ts +142 -0
  17. package/app/src/shared/index.ts +6 -0
  18. package/app/src/shared/mcp-discovery.ts +47 -0
  19. package/app/src/shared/mcp-proxy.ts +82 -0
  20. package/app/src/shared/types.ts +324 -0
  21. package/asset-bake/src/bake-audio.ts +119 -0
  22. package/asset-bake/src/bake-gltf.ts +172 -0
  23. package/asset-bake/src/bake.ts +78 -0
  24. package/asset-bake/src/cache.ts +188 -0
  25. package/asset-bake/src/config.ts +186 -0
  26. package/asset-bake/src/index.ts +36 -0
  27. package/core/src/animation/animation-event.ts +41 -0
  28. package/core/src/animation/bone-mask.ts +122 -0
  29. package/core/src/animation/clip.ts +252 -0
  30. package/core/src/animation/index.ts +22 -0
  31. package/core/src/animation/mixamo.ts +113 -0
  32. package/core/src/animation/morph-target.ts +69 -0
  33. package/core/src/animation/player.ts +697 -0
  34. package/core/src/animation/retarget.ts +158 -0
  35. package/core/src/animation/skeleton-animator.ts +1399 -0
  36. package/core/src/animation/skeleton.ts +214 -0
  37. package/core/src/animation/state-machine.ts +280 -0
  38. package/core/src/assets/blob-store.ts +66 -0
  39. package/core/src/assets/cache.ts +186 -0
  40. package/core/src/assets/cubemap-converter.ts +155 -0
  41. package/core/src/assets/environment-manager.ts +121 -0
  42. package/core/src/assets/import-cache.ts +65 -0
  43. package/core/src/assets/import-settings.ts +72 -0
  44. package/core/src/assets/importer.ts +108 -0
  45. package/core/src/assets/index.ts +74 -0
  46. package/core/src/assets/irradiance-generator.ts +198 -0
  47. package/core/src/assets/loader-audio.ts +86 -0
  48. package/core/src/assets/loader-cubemap.ts +211 -0
  49. package/core/src/assets/loader-dds.ts +236 -0
  50. package/core/src/assets/loader-hdr.ts +474 -0
  51. package/core/src/assets/loader-mesh.ts +143 -0
  52. package/core/src/assets/loader-shader.ts +73 -0
  53. package/core/src/assets/loader-texture.ts +349 -0
  54. package/core/src/assets/lod.ts +198 -0
  55. package/core/src/assets/manager.ts +480 -0
  56. package/core/src/assets/manifest.ts +81 -0
  57. package/core/src/assets/material-bridge.ts +186 -0
  58. package/core/src/assets/model-importer.ts +72 -0
  59. package/core/src/assets/model-normalizer.ts +248 -0
  60. package/core/src/assets/model-to-gpu.ts +70 -0
  61. package/core/src/assets/model-to-mesh.ts +237 -0
  62. package/core/src/assets/prefilter-generator.ts +250 -0
  63. package/core/src/audio/engine.ts +177 -0
  64. package/core/src/audio/interface.ts +130 -0
  65. package/core/src/audio/listener.ts +32 -0
  66. package/core/src/audio/mixer.ts +141 -0
  67. package/core/src/audio/registry.ts +52 -0
  68. package/core/src/audio/sab.ts +87 -0
  69. package/core/src/audio/source.ts +89 -0
  70. package/core/src/change-detection/atomics.ts +1 -0
  71. package/core/src/change-detection/tracker.ts +47 -0
  72. package/core/src/debug-draw/lines.ts +64 -0
  73. package/core/src/debug-draw/points.ts +17 -0
  74. package/core/src/debug-draw/queue.ts +124 -0
  75. package/core/src/debug-draw/text.ts +25 -0
  76. package/core/src/ecs/archetype.ts +323 -0
  77. package/core/src/ecs/component.ts +188 -0
  78. package/core/src/ecs/entity.ts +25 -0
  79. package/core/src/ecs/events.ts +93 -0
  80. package/core/src/ecs/hierarchy.ts +124 -0
  81. package/core/src/ecs/hmr-swap-registry.ts +22 -0
  82. package/core/src/ecs/index.ts +25 -0
  83. package/core/src/ecs/query.ts +141 -0
  84. package/core/src/ecs/resource.ts +8 -0
  85. package/core/src/ecs/schedule.ts +181 -0
  86. package/core/src/ecs/system-params.ts +99 -0
  87. package/core/src/ecs/system.ts +42 -0
  88. package/core/src/ecs/world.ts +348 -0
  89. package/core/src/index.ts +352 -0
  90. package/core/src/input/context.ts +73 -0
  91. package/core/src/input/dom-handler.ts +380 -0
  92. package/core/src/input/local-player-manager.ts +218 -0
  93. package/core/src/input/mapping.ts +40 -0
  94. package/core/src/input/multi-sab-bridge.ts +276 -0
  95. package/core/src/input/multi-state.ts +54 -0
  96. package/core/src/input/sab-bridge.ts +71 -0
  97. package/core/src/input/state.ts +132 -0
  98. package/core/src/input/ui-router.ts +87 -0
  99. package/core/src/library/host.ts +317 -0
  100. package/core/src/library/index.ts +16 -0
  101. package/core/src/library/library.ts +292 -0
  102. package/core/src/material/compiler.ts +80 -0
  103. package/core/src/material/graph-bridge.ts +172 -0
  104. package/core/src/material/index.ts +9 -0
  105. package/core/src/material/library.ts +442 -0
  106. package/core/src/material/material.ts +156 -0
  107. package/core/src/material/variants.ts +61 -0
  108. package/core/src/math/mat4.ts +202 -0
  109. package/core/src/math/perlin-noise-2d.ts +77 -0
  110. package/core/src/math/perlin-noise-3d.ts +110 -0
  111. package/core/src/math/quat.ts +33 -0
  112. package/core/src/math/rng.ts +29 -0
  113. package/core/src/math/trig.ts +47 -0
  114. package/core/src/math/value-noise.ts +67 -0
  115. package/core/src/mesh/builder.ts +177 -0
  116. package/core/src/mesh/csg.ts +312 -0
  117. package/core/src/mesh/greased-line.ts +145 -0
  118. package/core/src/mesh/mesh.ts +17 -0
  119. package/core/src/mesh/parametric.ts +501 -0
  120. package/core/src/mesh/skinning.ts +67 -0
  121. package/core/src/mesh/vertex-layout.ts +91 -0
  122. package/core/src/module/cross-thread.ts +163 -0
  123. package/core/src/module/diagnostics.ts +123 -0
  124. package/core/src/module/host.ts +451 -0
  125. package/core/src/module/index.ts +22 -0
  126. package/core/src/module/module.ts +109 -0
  127. package/core/src/module/registry.ts +94 -0
  128. package/core/src/module/renderer-module.ts +247 -0
  129. package/core/src/module/ts-loader.ts +26 -0
  130. package/core/src/particles/compute-pass.ts +378 -0
  131. package/core/src/particles/emitter.ts +156 -0
  132. package/core/src/particles/index.ts +11 -0
  133. package/core/src/particles/particle-data.ts +47 -0
  134. package/core/src/particles/render-pass.ts +365 -0
  135. package/core/src/particles/simulator.ts +264 -0
  136. package/core/src/particles/system.ts +193 -0
  137. package/core/src/physics/body.ts +59 -0
  138. package/core/src/physics/ccd-heuristic.ts +55 -0
  139. package/core/src/physics/character.ts +282 -0
  140. package/core/src/physics/collider.ts +55 -0
  141. package/core/src/physics/collision-system.ts +91 -0
  142. package/core/src/physics/events.ts +133 -0
  143. package/core/src/physics/index.ts +34 -0
  144. package/core/src/physics/interface.ts +473 -0
  145. package/core/src/physics/interpolation-buffer.ts +192 -0
  146. package/core/src/physics/lifecycle.ts +87 -0
  147. package/core/src/physics/load-shedder.ts +164 -0
  148. package/core/src/physics/physics-accumulator.ts +101 -0
  149. package/core/src/physics/physics-system.ts +240 -0
  150. package/core/src/physics/ragdoll-presets.ts +272 -0
  151. package/core/src/physics/ragdoll-system.ts +220 -0
  152. package/core/src/physics/ragdoll.ts +182 -0
  153. package/core/src/physics/raycast.ts +143 -0
  154. package/core/src/physics/realm-manager.ts +499 -0
  155. package/core/src/physics/realm-worker-pool.ts +191 -0
  156. package/core/src/physics/realm-worker.ts +102 -0
  157. package/core/src/physics/realm.ts +189 -0
  158. package/core/src/physics/registry.ts +52 -0
  159. package/core/src/physics/safety.ts +247 -0
  160. package/core/src/physics/snapshot-manager.ts +141 -0
  161. package/core/src/physics/worker-protocol.ts +30 -0
  162. package/core/src/platform/bun-preload.ts +169 -0
  163. package/core/src/platform/css-stub.ts +4 -0
  164. package/core/src/platform/glob-polyfill.ts +124 -0
  165. package/core/src/platform/hidpi.ts +57 -0
  166. package/core/src/platform/render-surface.ts +77 -0
  167. package/core/src/platform/rpc.ts +98 -0
  168. package/core/src/platform/runtime.ts +123 -0
  169. package/core/src/plugin/context.ts +364 -0
  170. package/core/src/plugin/diagnostics.ts +61 -0
  171. package/core/src/plugin/discovery.ts +186 -0
  172. package/core/src/plugin/extension-loaders.ts +172 -0
  173. package/core/src/plugin/host.ts +659 -0
  174. package/core/src/plugin/index.ts +106 -0
  175. package/core/src/plugin/loader-asset.ts +124 -0
  176. package/core/src/plugin/loader-quickjs.ts +84 -0
  177. package/core/src/plugin/loader-wasm.ts +411 -0
  178. package/core/src/plugin/loader-worker.ts +261 -0
  179. package/core/src/plugin/manifest.ts +653 -0
  180. package/core/src/plugin/material-registry.ts +65 -0
  181. package/core/src/plugin/mcp-tools.ts +165 -0
  182. package/core/src/plugin/permissions.ts +175 -0
  183. package/core/src/plugin/quickjs-bridge.ts +294 -0
  184. package/core/src/plugin/registry.ts +107 -0
  185. package/core/src/plugin/sandbox-shim.ts +86 -0
  186. package/core/src/plugin/sandbox-worker.ts +215 -0
  187. package/core/src/plugin/wasm-abi.ts +152 -0
  188. package/core/src/plugin/wasm-worker.ts +229 -0
  189. package/core/src/plugin/workshop.ts +169 -0
  190. package/core/src/profiling/event-loop.ts +180 -0
  191. package/core/src/profiling/index.ts +69 -0
  192. package/core/src/profiling/iops/idb-patch.ts +242 -0
  193. package/core/src/profiling/iops/opfs-patch.ts +276 -0
  194. package/core/src/profiling/profiling-sab.ts +839 -0
  195. package/core/src/profiling/task-latency.ts +134 -0
  196. package/core/src/profiling/thread-metrics.ts +126 -0
  197. package/core/src/profiling/trace-event-writer.ts +205 -0
  198. package/core/src/profiling/warnings.ts +423 -0
  199. package/core/src/profiling/worker-prelude.ts +376 -0
  200. package/core/src/render/bind-group.ts +38 -0
  201. package/core/src/render/bindless/bindless-struct.ts +42 -0
  202. package/core/src/render/bindless/bindless.wgsl.ts +167 -0
  203. package/core/src/render/bindless/frame-bindings.ts +176 -0
  204. package/core/src/render/bindless/index.ts +10 -0
  205. package/core/src/render/bindless/material-manager.ts +257 -0
  206. package/core/src/render/bindless/texture-registry.ts +609 -0
  207. package/core/src/render/buffer.ts +82 -0
  208. package/core/src/render/camera-2d.ts +217 -0
  209. package/core/src/render/camera-controller.ts +440 -0
  210. package/core/src/render/camera.ts +409 -0
  211. package/core/src/render/canvas-resize-watcher.ts +46 -0
  212. package/core/src/render/compute-kernel.ts +172 -0
  213. package/core/src/render/constants.ts +2 -0
  214. package/core/src/render/device.ts +111 -0
  215. package/core/src/render/frame-graph.ts +768 -0
  216. package/core/src/render/frustum.ts +185 -0
  217. package/core/src/render/fullscreen-vs.ts +25 -0
  218. package/core/src/render/g-buffer.ts +186 -0
  219. package/core/src/render/game-renderer.ts +1452 -0
  220. package/core/src/render/gpu-driven/cull-pass.ts +394 -0
  221. package/core/src/render/gpu-driven/hzb.ts +215 -0
  222. package/core/src/render/gpu-driven/index.ts +7 -0
  223. package/core/src/render/gpu-driven/indirect-draw-pass.ts +324 -0
  224. package/core/src/render/gpu-driven/instance-buffer.ts +178 -0
  225. package/core/src/render/gpu-driven/mesh-table.ts +175 -0
  226. package/core/src/render/gpu-driven/passes/hzb-build-pass.ts +47 -0
  227. package/core/src/render/gpu-utils.ts +96 -0
  228. package/core/src/render/hdr.ts +88 -0
  229. package/core/src/render/hot-reload.ts +295 -0
  230. package/core/src/render/ibl-bind-group.ts +266 -0
  231. package/core/src/render/ibl.ts +180 -0
  232. package/core/src/render/index.ts +163 -0
  233. package/core/src/render/input-manager.ts +259 -0
  234. package/core/src/render/lighting/area-light.ts +196 -0
  235. package/core/src/render/lighting/cluster-grid.ts +288 -0
  236. package/core/src/render/lighting/cluster-types.ts +193 -0
  237. package/core/src/render/lighting/ies-parser.ts +162 -0
  238. package/core/src/render/lighting.ts +454 -0
  239. package/core/src/render/material-types/depth.wgsl +44 -0
  240. package/core/src/render/material-types/line.wgsl +50 -0
  241. package/core/src/render/material-types/matcap.wgsl +51 -0
  242. package/core/src/render/material-types/normal.wgsl +41 -0
  243. package/core/src/render/material-types/physical.wgsl +152 -0
  244. package/core/src/render/material-types/sprite.wgsl +46 -0
  245. package/core/src/render/material-types/sss.wgsl +79 -0
  246. package/core/src/render/material-types/toon.wgsl +79 -0
  247. package/core/src/render/passes/README.md +121 -0
  248. package/core/src/render/passes/atmosphere.ts +152 -0
  249. package/core/src/render/passes/cluster-lighting-pass.ts +476 -0
  250. package/core/src/render/passes/csm.ts +408 -0
  251. package/core/src/render/passes/cubemap-capture.ts +114 -0
  252. package/core/src/render/passes/debug-viz.ts +278 -0
  253. package/core/src/render/passes/debug.ts +538 -0
  254. package/core/src/render/passes/decal-mesh.ts +111 -0
  255. package/core/src/render/passes/decal-pass.ts +377 -0
  256. package/core/src/render/passes/deferred-lighting.ts +434 -0
  257. package/core/src/render/passes/depth-prepass.ts +163 -0
  258. package/core/src/render/passes/dome-360.ts +83 -0
  259. package/core/src/render/passes/fluid-render.ts +137 -0
  260. package/core/src/render/passes/gi-types.ts +118 -0
  261. package/core/src/render/passes/graph-compute.ts +300 -0
  262. package/core/src/render/passes/greased-line-pass.ts +232 -0
  263. package/core/src/render/passes/morph-target.ts +40 -0
  264. package/core/src/render/passes/opaque.ts +1028 -0
  265. package/core/src/render/passes/point-light-shadow.ts +266 -0
  266. package/core/src/render/passes/procedural-texture.ts +185 -0
  267. package/core/src/render/passes/reflection-probe.ts +140 -0
  268. package/core/src/render/passes/rsm-pass.ts +248 -0
  269. package/core/src/render/passes/sdf-text.ts +155 -0
  270. package/core/src/render/passes/shadow-map.ts +163 -0
  271. package/core/src/render/passes/shadow.ts +220 -0
  272. package/core/src/render/passes/skinning-vs.ts +121 -0
  273. package/core/src/render/passes/skinning.ts +326 -0
  274. package/core/src/render/passes/sky-dome.ts +404 -0
  275. package/core/src/render/passes/skybox.ts +162 -0
  276. package/core/src/render/passes/spot-light-shadow.ts +273 -0
  277. package/core/src/render/passes/terrain.ts +326 -0
  278. package/core/src/render/passes/transparent.ts +353 -0
  279. package/core/src/render/passes/underwater-fog.ts +185 -0
  280. package/core/src/render/passes/video-texture.ts +79 -0
  281. package/core/src/render/passes/volumetric-pass.ts +312 -0
  282. package/core/src/render/passes/volumetric-types.ts +171 -0
  283. package/core/src/render/passes/water.ts +753 -0
  284. package/core/src/render/pbr.ts +296 -0
  285. package/core/src/render/pipeline.ts +52 -0
  286. package/core/src/render/render-pass.ts +30 -0
  287. package/core/src/render/renderer-input-bus.ts +220 -0
  288. package/core/src/render/renderer-module-host.ts +623 -0
  289. package/core/src/render/resource-tracker.ts +67 -0
  290. package/core/src/render/sab-canvas-overlay.ts +122 -0
  291. package/core/src/render/shader-validator.ts +200 -0
  292. package/core/src/render/shaders/debug/debug-line.wgsl +27 -0
  293. package/core/src/render/shaders/debug/debug-point.wgsl +30 -0
  294. package/core/src/render/shaders/debug/debug-text.wgsl +42 -0
  295. package/core/src/render/shaders/debug-viz/aabb.wgsl +20 -0
  296. package/core/src/render/shaders/debug-viz/depth.wgsl +29 -0
  297. package/core/src/render/shaders/debug-viz/lod.wgsl +38 -0
  298. package/core/src/render/shaders/debug-viz/normals.wgsl +30 -0
  299. package/core/src/render/shaders/debug-viz/overdraw.wgsl +21 -0
  300. package/core/src/render/shaders/debug-viz/tangents.wgsl +29 -0
  301. package/core/src/render/shaders/debug-viz/wireframe.wgsl +33 -0
  302. package/core/src/render/shaders/shadow-chunks.ts +135 -0
  303. package/core/src/render/skybox.ts +146 -0
  304. package/core/src/render/splitscreen.ts +85 -0
  305. package/core/src/render/surface.ts +78 -0
  306. package/core/src/render/tonemap.ts +180 -0
  307. package/core/src/render/tracked-render-pass.ts +158 -0
  308. package/core/src/render/viewport.ts +109 -0
  309. package/core/src/render/wgsl-hmr.ts +70 -0
  310. package/core/src/sab/core-input-channel.ts +23 -0
  311. package/core/src/sab/define.ts +239 -0
  312. package/core/src/sab/errors.ts +76 -0
  313. package/core/src/sab/game-input.ts +343 -0
  314. package/core/src/sab/gamepad-devices.ts +259 -0
  315. package/core/src/sab/grid-buffer.ts +117 -0
  316. package/core/src/sab/input.ts +55 -0
  317. package/core/src/sab/layout.ts +219 -0
  318. package/core/src/sab/multi-input-channel.ts +50 -0
  319. package/core/src/sab/record-buffer.ts +155 -0
  320. package/core/src/sab/sab-polyfill.ts +56 -0
  321. package/core/src/sab/sim-channel.ts +453 -0
  322. package/core/src/sab/slot-buffer.ts +190 -0
  323. package/core/src/sab/types.ts +158 -0
  324. package/core/src/safety/bounds.ts +76 -0
  325. package/core/src/safety/json.ts +80 -0
  326. package/core/src/safety/path.ts +167 -0
  327. package/core/src/save/binary-format.ts +117 -0
  328. package/core/src/save/game-save-system.ts +161 -0
  329. package/core/src/save/grid-save-system.ts +158 -0
  330. package/core/src/save/migrate.ts +44 -0
  331. package/core/src/save/migration-registry.ts +94 -0
  332. package/core/src/save/persist-types.ts +208 -0
  333. package/core/src/save/schema.ts +46 -0
  334. package/core/src/save/serializer.ts +180 -0
  335. package/core/src/scene/builder.ts +65 -0
  336. package/core/src/scene/camera.ts +142 -0
  337. package/core/src/scene/checkpoint.ts +109 -0
  338. package/core/src/scene/game-loop.ts +198 -0
  339. package/core/src/scene/index.ts +24 -0
  340. package/core/src/scene/layer.ts +6 -0
  341. package/core/src/scene/prefab.ts +145 -0
  342. package/core/src/scene/scene-manager.ts +157 -0
  343. package/core/src/scene/scene.ts +192 -0
  344. package/core/src/scene/sky.ts +256 -0
  345. package/core/src/scene/spatial-grid.ts +176 -0
  346. package/core/src/scene/streaming.ts +304 -0
  347. package/core/src/scene/vegetation.ts +163 -0
  348. package/core/src/scene/world.ts +89 -0
  349. package/core/src/scripting/binding.ts +129 -0
  350. package/core/src/scripting/hot-reload.ts +144 -0
  351. package/core/src/scripting/script.ts +99 -0
  352. package/core/src/sim/hot-reload-pipeline.ts +49 -0
  353. package/core/src/sim/sim-state-helper.ts +47 -0
  354. package/core/src/sim/transient-state-registry.ts +77 -0
  355. package/core/src/sim/types.ts +113 -0
  356. package/core/src/sim/worker-loop.ts +222 -0
  357. package/core/src/stores/game-store.ts +158 -0
  358. package/core/src/stores/hot-reload-store.ts +44 -0
  359. package/core/src/telemetry/collector.ts +410 -0
  360. package/core/src/telemetry/gc-controller.ts +453 -0
  361. package/core/src/telemetry/gc-profiler.ts +126 -0
  362. package/core/src/telemetry/gc-tracker.ts +69 -0
  363. package/core/src/telemetry/gpu-profiler.ts +703 -0
  364. package/core/src/telemetry/gpu-resource-tracker.ts +285 -0
  365. package/core/src/telemetry/gpu-timer-pool.ts +234 -0
  366. package/core/src/telemetry/gpu-timer.ts +123 -0
  367. package/core/src/telemetry/gpu-timestamp-gate.ts +21 -0
  368. package/core/src/telemetry/reporter.ts +62 -0
  369. package/core/src/types/engine-types.ts +129 -0
  370. package/core/src/ui-font-scale.ts +112 -0
  371. package/core/src/util/broad-phase-grid.ts +82 -0
  372. package/core/src/util/feature-log.ts +220 -0
  373. package/core/src/util/index.ts +17 -0
  374. package/core/src/util/logger.ts +604 -0
  375. package/core/src/webgpu-destroy-augmentation.d.ts +25 -0
  376. package/core/src/worker/base-worker-host.ts +183 -0
  377. package/core/src/worker/buffer-sync.ts +550 -0
  378. package/core/src/worker/crash-recovery.ts +201 -0
  379. package/core/src/worker/entity-sim-worker-host.ts +190 -0
  380. package/core/src/worker/instrumented-worker-host.ts +171 -0
  381. package/core/src/worker/rpc.ts +253 -0
  382. package/core/src/worker/sim-worker-base.ts +913 -0
  383. package/core/src/worker/sim-worker-host.ts +358 -0
  384. package/core/src/worker/task-pool.ts +256 -0
  385. package/core/src/worker/task-worker.ts +83 -0
  386. package/libraries/asset-browser/src/index.ts +11 -0
  387. package/libraries/asset-browser/src/software-thumbnails.ts +949 -0
  388. package/libraries/audio-kira/README.md +17 -0
  389. package/libraries/audio-kira/native/Cargo.toml +14 -0
  390. package/libraries/audio-kira/native/src/engine.rs +27 -0
  391. package/libraries/audio-kira/native/src/lib.rs +185 -0
  392. package/libraries/audio-kira/native/src/mixer.rs +11 -0
  393. package/libraries/audio-kira/native/src/source.rs +80 -0
  394. package/libraries/audio-kira/src/backend.ts +469 -0
  395. package/libraries/audio-kira/src/index.ts +8 -0
  396. package/libraries/audio-kira/src/library.ts +82 -0
  397. package/libraries/blitz-ui/native/Cargo.toml +33 -0
  398. package/libraries/blitz-ui/native/assets/DejaVuSans.woff2 +0 -0
  399. package/libraries/blitz-ui/native/src/bridge.rs +100 -0
  400. package/libraries/blitz-ui/native/src/fonts.rs +20 -0
  401. package/libraries/blitz-ui/native/src/input.rs +80 -0
  402. package/libraries/blitz-ui/native/src/lib.rs +96 -0
  403. package/libraries/blitz-ui/native/src/shell.rs +301 -0
  404. package/libraries/blitz-ui/native-osr/Cargo.toml +24 -0
  405. package/libraries/blitz-ui/native-osr/src/events.rs +237 -0
  406. package/libraries/blitz-ui/native-osr/src/lib.rs +846 -0
  407. package/libraries/blitz-ui/native-osr/src/mutate.rs +385 -0
  408. package/libraries/blitz-ui/native-osr/src/resources.rs +45 -0
  409. package/libraries/blitz-ui/native-osr-spike.ts +70 -0
  410. package/libraries/blitz-ui/src/index.ts +15 -0
  411. package/libraries/blitz-ui/src/native-module.ts +221 -0
  412. package/libraries/blitz-ui/src/native-osr-ffi.ts +423 -0
  413. package/libraries/blitz-ui/src/router.ts +101 -0
  414. package/libraries/blitz-ui/src/shaders/ui-blit.wgsl.ts +35 -0
  415. package/libraries/blitz-ui/src/ui-blit-pass.ts +116 -0
  416. package/libraries/character/README.md +25 -0
  417. package/libraries/character/src/character-animator.ts +374 -0
  418. package/libraries/character/src/index.ts +24 -0
  419. package/libraries/character/src/loader.ts +375 -0
  420. package/libraries/character/src/locomotion.ts +124 -0
  421. package/libraries/character/src/preview.ts +287 -0
  422. package/libraries/devtools/README.md +51 -0
  423. package/libraries/devtools/native/Cargo.toml +12 -0
  424. package/libraries/devtools/native/src/lib.rs +1646 -0
  425. package/libraries/devtools/native/src/panels/console.rs +338 -0
  426. package/libraries/devtools/native/src/panels/dom_tree.rs +47 -0
  427. package/libraries/devtools/native/src/panels/generic.rs +317 -0
  428. package/libraries/devtools/native/src/panels/gpu.rs +107 -0
  429. package/libraries/devtools/native/src/panels/input.rs +88 -0
  430. package/libraries/devtools/native/src/panels/mod.rs +154 -0
  431. package/libraries/devtools/native/src/panels/perf_metrics.rs +91 -0
  432. package/libraries/devtools/native/src/panels/perf_recorder.rs +158 -0
  433. package/libraries/devtools/native/src/panels/scene.rs +72 -0
  434. package/libraries/devtools/native/src/state.rs +394 -0
  435. package/libraries/devtools/src/cdp-bridge.ts +349 -0
  436. package/libraries/devtools/src/collectors.ts +346 -0
  437. package/libraries/devtools/src/egui-ffi.ts +804 -0
  438. package/libraries/devtools/src/egui-renderer.ts +407 -0
  439. package/libraries/devtools/src/host.ts +363 -0
  440. package/libraries/devtools/src/index.ts +40 -0
  441. package/libraries/devtools/src/mirror.ts +794 -0
  442. package/libraries/devtools/src/native-providers.ts +744 -0
  443. package/libraries/devtools/src/verify.ts +167 -0
  444. package/libraries/devtools/src/web/browser.ts +102 -0
  445. package/libraries/devtools/src/web/host.ts +212 -0
  446. package/libraries/devtools/src/web/mirror.ts +430 -0
  447. package/libraries/devtools/src/web/server.ts +198 -0
  448. package/libraries/entities/README.md +17 -0
  449. package/libraries/entities/src/index.ts +4 -0
  450. package/libraries/entities/src/library.ts +64 -0
  451. package/libraries/entities/src/model-renderer.ts +1345 -0
  452. package/libraries/entities/src/shaders/model.wgsl +375 -0
  453. package/libraries/gamepad/src/feed-input.ts +83 -0
  454. package/libraries/gamepad/src/filters.ts +49 -0
  455. package/libraries/gamepad/src/glyphs.ts +93 -0
  456. package/libraries/gamepad/src/hub.ts +206 -0
  457. package/libraries/gamepad/src/index.ts +22 -0
  458. package/libraries/gamepad/src/library.ts +68 -0
  459. package/libraries/gamepad/src/source-sab.ts +70 -0
  460. package/libraries/gamepad/src/sysfs-enrich.ts +138 -0
  461. package/libraries/gaussian-splats/README.md +25 -0
  462. package/libraries/gaussian-splats/src/gpu-sort.ts +595 -0
  463. package/libraries/gaussian-splats/src/index.ts +31 -0
  464. package/libraries/gaussian-splats/src/library.ts +60 -0
  465. package/libraries/gaussian-splats/src/parser.ts +382 -0
  466. package/libraries/gaussian-splats/src/renderer.ts +427 -0
  467. package/libraries/gaussian-splats/src/sh-eval.ts +211 -0
  468. package/libraries/gaussian-splats/src/sorter.ts +110 -0
  469. package/libraries/gaussian-splats/src/tile-raster.ts +473 -0
  470. package/libraries/gpu-kernels/README.md +154 -0
  471. package/libraries/gpu-kernels/src/index.ts +11 -0
  472. package/libraries/gpu-kernels/src/kernel.ts +696 -0
  473. package/libraries/gpu-kernels/src/transpile.ts +958 -0
  474. package/libraries/html-ui-kit/src/behaviors.ts +250 -0
  475. package/libraries/html-ui-kit/src/components.ts +309 -0
  476. package/libraries/html-ui-kit/src/index.ts +30 -0
  477. package/libraries/html-ui-kit/src/nav/nav-controller.ts +238 -0
  478. package/libraries/html-ui-kit/src/nav/nav-input.ts +145 -0
  479. package/libraries/html-ui-kit/src/nav/osk.ts +153 -0
  480. package/libraries/html-ui-kit/src/nav/router.ts +167 -0
  481. package/libraries/html-ui-kit/src/nav/vcursor.ts +203 -0
  482. package/libraries/html-ui-kit/src/ten-foot.ts +182 -0
  483. package/libraries/html-ui-kit/src/theme.ts +238 -0
  484. package/libraries/lighting/README.md +20 -0
  485. package/libraries/lighting/src/index.ts +5 -0
  486. package/libraries/lighting/src/library.ts +66 -0
  487. package/libraries/lighting/src/light-system.ts +452 -0
  488. package/libraries/lighting/src/lighting-system.ts +68 -0
  489. package/libraries/lighting/src/shaders/light-debug.wgsl +30 -0
  490. package/libraries/marching-cubes/README.md +25 -0
  491. package/libraries/marching-cubes/src/chunked-field.ts +177 -0
  492. package/libraries/marching-cubes/src/deformation.ts +63 -0
  493. package/libraries/marching-cubes/src/generator.ts +407 -0
  494. package/libraries/marching-cubes/src/index.ts +32 -0
  495. package/libraries/marching-cubes/src/library.ts +80 -0
  496. package/libraries/marching-cubes/src/lod.ts +122 -0
  497. package/libraries/marching-cubes/src/marching-tetrahedra.ts +252 -0
  498. package/libraries/marching-cubes/src/sab.ts +45 -0
  499. package/libraries/marching-cubes/src/streaming-config.ts +48 -0
  500. package/libraries/marching-cubes/src/streaming-manager.ts +686 -0
  501. package/libraries/marching-cubes/src/tables.ts +323 -0
  502. package/libraries/marching-cubes/src/types.ts +18 -0
  503. package/libraries/models/README.md +25 -0
  504. package/libraries/models/src/bake-node-transforms.ts +171 -0
  505. package/libraries/models/src/codecs/basisu-codec.ts +180 -0
  506. package/libraries/models/src/codecs/draco-codec.ts +372 -0
  507. package/libraries/models/src/codecs/draco3dgltf.d.ts +40 -0
  508. package/libraries/models/src/codecs/index.ts +22 -0
  509. package/libraries/models/src/codecs/meshopt-codec.ts +91 -0
  510. package/libraries/models/src/codecs/registry.ts +221 -0
  511. package/libraries/models/src/dae.ts +753 -0
  512. package/libraries/models/src/default-part-selection.ts +56 -0
  513. package/libraries/models/src/dxf.ts +200 -0
  514. package/libraries/models/src/fbx/index.ts +457 -0
  515. package/libraries/models/src/fbx/interpreter/animation.ts +431 -0
  516. package/libraries/models/src/fbx/interpreter/blend-shapes.ts +323 -0
  517. package/libraries/models/src/fbx/interpreter/connections.ts +166 -0
  518. package/libraries/models/src/fbx/interpreter/diagnostics.ts +65 -0
  519. package/libraries/models/src/fbx/interpreter/geometry.ts +535 -0
  520. package/libraries/models/src/fbx/interpreter/global-settings.ts +92 -0
  521. package/libraries/models/src/fbx/interpreter/materials.ts +265 -0
  522. package/libraries/models/src/fbx/interpreter/nodes.ts +197 -0
  523. package/libraries/models/src/fbx/interpreter/skeleton.ts +476 -0
  524. package/libraries/models/src/fbx/parsers/binary-parser.ts +244 -0
  525. package/libraries/models/src/fbx/types.ts +119 -0
  526. package/libraries/models/src/gltf-extensions.ts +196 -0
  527. package/libraries/models/src/gltf.ts +935 -0
  528. package/libraries/models/src/index.ts +87 -0
  529. package/libraries/models/src/library.ts +75 -0
  530. package/libraries/models/src/loader.ts +265 -0
  531. package/libraries/models/src/material-adapter.ts +108 -0
  532. package/libraries/models/src/mini-xml.ts +174 -0
  533. package/libraries/models/src/normalize.ts +216 -0
  534. package/libraries/models/src/obj.ts +264 -0
  535. package/libraries/models/src/off.ts +95 -0
  536. package/libraries/models/src/ply.ts +348 -0
  537. package/libraries/models/src/pose-sampler.ts +219 -0
  538. package/libraries/models/src/sidecar/blender-extras.ts +37 -0
  539. package/libraries/models/src/sidecar/ddmeta.ts +114 -0
  540. package/libraries/models/src/sidecar/godot-import.ts +86 -0
  541. package/libraries/models/src/sidecar/resolver.ts +145 -0
  542. package/libraries/models/src/sidecar/types.ts +11 -0
  543. package/libraries/models/src/sidecar/unity-meta.ts +61 -0
  544. package/libraries/models/src/skeleton-synthesis.ts +132 -0
  545. package/libraries/models/src/stl.ts +149 -0
  546. package/libraries/models/src/threeds.ts +363 -0
  547. package/libraries/models/src/threemf.ts +201 -0
  548. package/libraries/models/src/types.ts +188 -0
  549. package/libraries/models/src/vtk.ts +218 -0
  550. package/libraries/models/test-fixtures/fbx/LICENSE-notice.txt +24 -0
  551. package/libraries/models/test-fixtures/fbx/README.md +46 -0
  552. package/libraries/models/test-fixtures/fbx/m01_cube_phong.fbx +0 -0
  553. package/libraries/models/test-fixtures/fbx/m02_geo_ngons.fbx +0 -0
  554. package/libraries/models/test-fixtures/fbx/m03_normals.fbx +0 -0
  555. package/libraries/models/test-fixtures/fbx/m04_material_properties.fbx +0 -0
  556. package/libraries/models/test-fixtures/fbx/m05_external_diffuse.png +0 -0
  557. package/libraries/models/test-fixtures/fbx/m05_textures.fbx +0 -0
  558. package/libraries/models/test-fixtures/fbx/m06_uv_transform.fbx +0 -0
  559. package/libraries/models/test-fixtures/fbx/m07_multimaterial.fbx +0 -0
  560. package/libraries/models/test-fixtures/fbx/m08_transforms.fbx +0 -0
  561. package/libraries/models/test-fixtures/fbx/m09_skinning.fbx +0 -0
  562. package/libraries/models/test-fixtures/fbx/m10_morph.fbx +0 -0
  563. package/libraries/models/test-fixtures/fbx/m11_node_anim.fbx +0 -0
  564. package/libraries/models/test-fixtures/fbx/m12_skeletal_anim.fbx +0 -0
  565. package/libraries/models/test-fixtures/fbx/m13_morph_anim.fbx +0 -0
  566. package/libraries/models/test-fixtures/fbx/m14_multiclip.fbx +0 -0
  567. package/libraries/models/test-fixtures/fbx/m15_camera_lights.fbx +0 -0
  568. package/libraries/models/test-fixtures/fbx/m16_axis_yup.fbx +0 -0
  569. package/libraries/models/test-fixtures/fbx/m16_axis_zup.fbx +0 -0
  570. package/libraries/models/test-fixtures/fbx/m16_units_254.fbx +0 -0
  571. package/libraries/navmesh/README.md +23 -0
  572. package/libraries/navmesh/src/crowd-system.ts +240 -0
  573. package/libraries/navmesh/src/debug-viz.ts +74 -0
  574. package/libraries/navmesh/src/index.ts +15 -0
  575. package/libraries/navmesh/src/library.ts +64 -0
  576. package/libraries/navmesh/src/navmesh-generator.ts +407 -0
  577. package/libraries/navmesh/src/navmesh.ts +159 -0
  578. package/libraries/navmesh/src/pathfinder.ts +252 -0
  579. package/libraries/navmesh/src/types.ts +63 -0
  580. package/libraries/networking/README.md +25 -0
  581. package/libraries/networking/src/authority.ts +121 -0
  582. package/libraries/networking/src/connection.ts +139 -0
  583. package/libraries/networking/src/discovery.ts +373 -0
  584. package/libraries/networking/src/enhanced-replication.ts +355 -0
  585. package/libraries/networking/src/epic-adapter.ts +129 -0
  586. package/libraries/networking/src/index.ts +45 -0
  587. package/libraries/networking/src/library.ts +69 -0
  588. package/libraries/networking/src/lobby.ts +108 -0
  589. package/libraries/networking/src/platform-adapter.ts +163 -0
  590. package/libraries/networking/src/rcs-adapter.ts +127 -0
  591. package/libraries/networking/src/remote-input.ts +188 -0
  592. package/libraries/networking/src/replication.ts +327 -0
  593. package/libraries/networking/src/rpc.ts +188 -0
  594. package/libraries/networking/src/session.ts +173 -0
  595. package/libraries/networking/src/transport.ts +201 -0
  596. package/libraries/networking/src/webrtc.ts +323 -0
  597. package/libraries/pathfinding-2d/README.md +15 -0
  598. package/libraries/pathfinding-2d/src/astar-grid.ts +312 -0
  599. package/libraries/pathfinding-2d/src/index.ts +11 -0
  600. package/libraries/persistence/README.md +25 -0
  601. package/libraries/persistence/src/autosave-manager.ts +112 -0
  602. package/libraries/persistence/src/binary-record-store.ts +244 -0
  603. package/libraries/persistence/src/browser.ts +51 -0
  604. package/libraries/persistence/src/file-save-store.ts +553 -0
  605. package/libraries/persistence/src/hash-utils.ts +59 -0
  606. package/libraries/persistence/src/index.ts +57 -0
  607. package/libraries/persistence/src/library.ts +74 -0
  608. package/libraries/persistence/src/mock-opfs.ts +141 -0
  609. package/libraries/persistence/src/node-fs-directory-handle.ts +161 -0
  610. package/libraries/persistence/src/opfs-save-store.ts +745 -0
  611. package/libraries/persistence/src/save-worker-proxy.ts +127 -0
  612. package/libraries/persistence/src/save-worker.ts +99 -0
  613. package/libraries/persistence/src/xxh3-ts.d.ts +10 -0
  614. package/libraries/physics-native/README.md +21 -0
  615. package/libraries/physics-native/native/Cargo.toml +11 -0
  616. package/libraries/physics-native/native/src/body.rs +403 -0
  617. package/libraries/physics-native/native/src/character.rs +226 -0
  618. package/libraries/physics-native/native/src/collider.rs +299 -0
  619. package/libraries/physics-native/native/src/joint.rs +81 -0
  620. package/libraries/physics-native/native/src/lib.rs +830 -0
  621. package/libraries/physics-native/native/src/query.rs +261 -0
  622. package/libraries/physics-native/native/src/realm.rs +33 -0
  623. package/libraries/physics-native/native/src/step.rs +54 -0
  624. package/libraries/physics-native/native/src/world.rs +148 -0
  625. package/libraries/physics-native/src/backend.ts +1046 -0
  626. package/libraries/physics-native/src/broadphase.ts +101 -0
  627. package/libraries/physics-native/src/ffi-spike-worker.ts +71 -0
  628. package/libraries/physics-native/src/index.ts +11 -0
  629. package/libraries/physics-native/src/library.ts +79 -0
  630. package/libraries/physics-native/src/narrowphase-shapes.ts +420 -0
  631. package/libraries/physics-native/src/narrowphase.ts +123 -0
  632. package/libraries/physics-native/src/solver.ts +141 -0
  633. package/libraries/physics-native/src/types.ts +76 -0
  634. package/libraries/physics-rapier/README.md +21 -0
  635. package/libraries/physics-rapier/bench/ffi-bench.ts +79 -0
  636. package/libraries/physics-rapier/bench/wasm-baseline.ts +90 -0
  637. package/libraries/physics-rapier/src/api.ts +452 -0
  638. package/libraries/physics-rapier/src/auto-backend.ts +59 -0
  639. package/libraries/physics-rapier/src/backend.ts +1207 -0
  640. package/libraries/physics-rapier/src/bulk-ops.ts +68 -0
  641. package/libraries/physics-rapier/src/ffi-backend.ts +21 -0
  642. package/libraries/physics-rapier/src/ffi-lib.ts +597 -0
  643. package/libraries/physics-rapier/src/index.ts +12 -0
  644. package/libraries/physics-rapier/src/library.ts +107 -0
  645. package/libraries/physics-rapier/src/rapier-backend.ts +1052 -0
  646. package/libraries/physics-rapier/src/realm-worker-script.ts +237 -0
  647. package/libraries/postfx/README.md +17 -0
  648. package/libraries/postfx/src/index.ts +19 -0
  649. package/libraries/postfx/src/library.ts +337 -0
  650. package/libraries/postfx/src/post-process-stack.ts +1752 -0
  651. package/libraries/postfx/src/shaders/post-process/afterimage.wgsl +14 -0
  652. package/libraries/postfx/src/shaders/post-process/ascii.wgsl +24 -0
  653. package/libraries/postfx/src/shaders/post-process/blit.wgsl +12 -0
  654. package/libraries/postfx/src/shaders/post-process/bloom-blur.wgsl +21 -0
  655. package/libraries/postfx/src/shaders/post-process/bloom-bright.wgsl +15 -0
  656. package/libraries/postfx/src/shaders/post-process/bloom-composite.wgsl +14 -0
  657. package/libraries/postfx/src/shaders/post-process/bloom-downsample.wgsl +60 -0
  658. package/libraries/postfx/src/shaders/post-process/bloom-soft.wgsl +46 -0
  659. package/libraries/postfx/src/shaders/post-process/bloom-upsample.wgsl +51 -0
  660. package/libraries/postfx/src/shaders/post-process/channel-mixer.wgsl +36 -0
  661. package/libraries/postfx/src/shaders/post-process/chromatic-aberration.wgsl +37 -0
  662. package/libraries/postfx/src/shaders/post-process/dithering.wgsl +64 -0
  663. package/libraries/postfx/src/shaders/post-process/dof.wgsl +131 -0
  664. package/libraries/postfx/src/shaders/post-process/edges.wgsl +45 -0
  665. package/libraries/postfx/src/shaders/post-process/fullscreen-vs.wgsl +15 -0
  666. package/libraries/postfx/src/shaders/post-process/fxaa.wgsl +102 -0
  667. package/libraries/postfx/src/shaders/post-process/gaussian-blur.wgsl +37 -0
  668. package/libraries/postfx/src/shaders/post-process/glow-blur.wgsl +33 -0
  669. package/libraries/postfx/src/shaders/post-process/glow-composite.wgsl +25 -0
  670. package/libraries/postfx/src/shaders/post-process/grain.wgsl +37 -0
  671. package/libraries/postfx/src/shaders/post-process/halftone.wgsl +80 -0
  672. package/libraries/postfx/src/shaders/post-process/highlight-blur.wgsl +33 -0
  673. package/libraries/postfx/src/shaders/post-process/highlight-composite.wgsl +32 -0
  674. package/libraries/postfx/src/shaders/post-process/lens-distortion.wgsl +43 -0
  675. package/libraries/postfx/src/shaders/post-process/lens-flare.wgsl +68 -0
  676. package/libraries/postfx/src/shaders/post-process/lut.wgsl +30 -0
  677. package/libraries/postfx/src/shaders/post-process/motion-blur.wgsl +55 -0
  678. package/libraries/postfx/src/shaders/post-process/occluder-chunk.wgsl +28 -0
  679. package/libraries/postfx/src/shaders/post-process/outline.wgsl +51 -0
  680. package/libraries/postfx/src/shaders/post-process/pixelation.wgsl +47 -0
  681. package/libraries/postfx/src/shaders/post-process/sharpen.wgsl +32 -0
  682. package/libraries/postfx/src/shaders/post-process/sobel.wgsl +26 -0
  683. package/libraries/postfx/src/shaders/post-process/split-tone.wgsl +36 -0
  684. package/libraries/postfx/src/shaders/post-process/ssao-blur.wgsl +38 -0
  685. package/libraries/postfx/src/shaders/post-process/ssao-composite.wgsl +25 -0
  686. package/libraries/postfx/src/shaders/post-process/ssao.wgsl +147 -0
  687. package/libraries/postfx/src/shaders/post-process/ssr.wgsl +155 -0
  688. package/libraries/postfx/src/shaders/post-process/taa.wgsl +98 -0
  689. package/libraries/postfx/src/shaders/post-process/tonemap.wgsl +40 -0
  690. package/libraries/postfx/src/shaders/post-process/watercolor.wgsl +82 -0
  691. package/libraries/postfx/src/shaders/post-process/white-balance.wgsl +41 -0
  692. package/libraries/recast/README.md +21 -0
  693. package/libraries/recast/src/backend.ts +150 -0
  694. package/libraries/recast/src/crowd-system.ts +352 -0
  695. package/libraries/recast/src/index.ts +12 -0
  696. package/libraries/recast/src/library.ts +91 -0
  697. package/libraries/recast/src/types.ts +86 -0
  698. package/libraries/sand/README.md +25 -0
  699. package/libraries/sand/src/cell.ts +43 -0
  700. package/libraries/sand/src/fields.ts +29 -0
  701. package/libraries/sand/src/fluid-grid.ts +261 -0
  702. package/libraries/sand/src/grid-dims.ts +74 -0
  703. package/libraries/sand/src/grid-sim-buffer.ts +271 -0
  704. package/libraries/sand/src/index.ts +81 -0
  705. package/libraries/sand/src/library.ts +92 -0
  706. package/libraries/sand/src/materials.ts +492 -0
  707. package/libraries/sand/src/palette.ts +93 -0
  708. package/libraries/sand/src/particles.ts +185 -0
  709. package/libraries/sand/src/render/sand-grid-pass.ts +430 -0
  710. package/libraries/sand/src/rng.ts +51 -0
  711. package/libraries/sand/src/rules/builtin-rules.ts +132 -0
  712. package/libraries/sand/src/rules/rule-compiler.ts +162 -0
  713. package/libraries/sand/src/rules/rule-engine.ts +231 -0
  714. package/libraries/sand/src/rules/rule-types.ts +87 -0
  715. package/libraries/sand/src/sand-step-pool.ts +484 -0
  716. package/libraries/sand/src/sand-step-worker.ts +97 -0
  717. package/libraries/sand/src/sand-world.ts +3227 -0
  718. package/libraries/stickman/README.md +25 -0
  719. package/libraries/stickman/src/index.ts +25 -0
  720. package/libraries/stickman/src/library.ts +61 -0
  721. package/libraries/stickman/src/proportions.ts +45 -0
  722. package/libraries/stickman/src/shader.ts +117 -0
  723. package/libraries/stickman/src/skeleton.ts +197 -0
  724. package/libraries/stickman/src/stickman-pass.ts +255 -0
  725. package/libraries/stickman/src/thick-line.ts +123 -0
  726. package/libraries/surface-nets/README.md +25 -0
  727. package/libraries/surface-nets/src/chunked-field.ts +14 -0
  728. package/libraries/surface-nets/src/deformation.ts +45 -0
  729. package/libraries/surface-nets/src/index.ts +36 -0
  730. package/libraries/surface-nets/src/library.ts +59 -0
  731. package/libraries/surface-nets/src/lod.ts +7 -0
  732. package/libraries/surface-nets/src/sab.ts +4 -0
  733. package/libraries/surface-nets/src/streaming-config.ts +5 -0
  734. package/libraries/surface-nets/src/streaming-manager.ts +15 -0
  735. package/libraries/surface-nets/src/surface-nets.ts +334 -0
  736. package/libraries/surface-nets/src/types.ts +23 -0
  737. package/libraries/water/README.md +25 -0
  738. package/libraries/water/src/buoyancy.ts +103 -0
  739. package/libraries/water/src/index.ts +43 -0
  740. package/libraries/water/src/library.ts +85 -0
  741. package/libraries/water/src/shore-damping.ts +93 -0
  742. package/libraries/water/src/water-buffer.ts +89 -0
  743. package/libraries/water/src/water-chunks.ts +13 -0
  744. package/libraries/water/src/water-physics.ts +376 -0
  745. package/libraries/water/src/water-renderer.ts +809 -0
  746. package/libraries/water/src/water-sab.ts +161 -0
  747. package/libraries/water/src/wave-sources.ts +84 -0
  748. package/libraries/weather/README.md +20 -0
  749. package/libraries/weather/src/index.ts +9 -0
  750. package/libraries/weather/src/library.ts +72 -0
  751. package/libraries/weather/src/types.ts +64 -0
  752. package/libraries/weather/src/weather-blend.ts +112 -0
  753. package/libraries/weather/src/weather-system.ts +271 -0
  754. package/libraries/weatherfx/README.md +24 -0
  755. package/libraries/weatherfx/src/cloud-provider.ts +31 -0
  756. package/libraries/weatherfx/src/cloud-system.ts +372 -0
  757. package/libraries/weatherfx/src/index.ts +9 -0
  758. package/libraries/weatherfx/src/library.ts +58 -0
  759. package/libraries/weatherfx/src/particle-system.ts +434 -0
  760. package/libraries/weatherfx/src/shaders/cloud.wgsl +111 -0
  761. package/libraries/weatherfx/src/shaders/particle-compute.wgsl +187 -0
  762. package/libraries/weatherfx/src/shaders/particle-render.wgsl +95 -0
  763. package/lint-plugin.mjs +41 -0
  764. package/mcp/src/client.ts +703 -0
  765. package/mcp/src/engine-context.ts +186 -0
  766. package/mcp/src/http-transport.ts +471 -0
  767. package/mcp/src/index.ts +13 -0
  768. package/mcp/src/prompts/add-entity.ts +42 -0
  769. package/mcp/src/prompts/create-scene.ts +44 -0
  770. package/mcp/src/prompts/debug-frame.ts +39 -0
  771. package/mcp/src/prompts/index.ts +12 -0
  772. package/mcp/src/resources/asset-list.ts +32 -0
  773. package/mcp/src/resources/checkpoint-list.ts +33 -0
  774. package/mcp/src/resources/entity-state.ts +55 -0
  775. package/mcp/src/resources/gpu-info.ts +103 -0
  776. package/mcp/src/resources/index.ts +57 -0
  777. package/mcp/src/resources/performance.ts +37 -0
  778. package/mcp/src/resources/scene-tree.ts +43 -0
  779. package/mcp/src/server.ts +375 -0
  780. package/mcp/src/tools/animation.ts +118 -0
  781. package/mcp/src/tools/asset.ts +161 -0
  782. package/mcp/src/tools/audio.ts +96 -0
  783. package/mcp/src/tools/build.ts +143 -0
  784. package/mcp/src/tools/camera.ts +167 -0
  785. package/mcp/src/tools/checkpoint.ts +100 -0
  786. package/mcp/src/tools/component.ts +199 -0
  787. package/mcp/src/tools/debug.ts +300 -0
  788. package/mcp/src/tools/entity.ts +361 -0
  789. package/mcp/src/tools/inspect.ts +210 -0
  790. package/mcp/src/tools/lighting.ts +263 -0
  791. package/mcp/src/tools/material.ts +243 -0
  792. package/mcp/src/tools/mesh.ts +239 -0
  793. package/mcp/src/tools/physics.ts +266 -0
  794. package/mcp/src/tools/scene.ts +153 -0
  795. package/mcp/src/tools/script.ts +122 -0
  796. package/mcp/src/types.ts +92 -0
  797. package/mcp/src/undo-redo.ts +77 -0
  798. package/modules/camera-controls/README.md +20 -0
  799. package/modules/camera-controls/src/index.ts +161 -0
  800. package/modules/controller-ui/src/index.ts +164 -0
  801. package/modules/devtools/README.md +25 -0
  802. package/modules/devtools/src/api.ts +461 -0
  803. package/modules/devtools/src/asset-utils.ts +105 -0
  804. package/modules/devtools/src/bind-debug-store.ts +48 -0
  805. package/modules/devtools/src/data-bridge.ts +286 -0
  806. package/modules/devtools/src/debug-overlay.ts +238 -0
  807. package/modules/devtools/src/debug-raycast.ts +328 -0
  808. package/modules/devtools/src/debug-store.ts +92 -0
  809. package/modules/devtools/src/debug-view-descriptors.ts +59 -0
  810. package/modules/devtools/src/doctor-panel.ts +182 -0
  811. package/modules/devtools/src/gizmo-module.ts +136 -0
  812. package/modules/devtools/src/graph-model.ts +509 -0
  813. package/modules/devtools/src/grid-renderer.ts +157 -0
  814. package/modules/devtools/src/height-ruler-renderer.ts +216 -0
  815. package/modules/devtools/src/index.ts +81 -0
  816. package/modules/devtools/src/init.ts +261 -0
  817. package/modules/devtools/src/label-overlay.ts +154 -0
  818. package/modules/devtools/src/material-stats-panel.ts +188 -0
  819. package/modules/devtools/src/profiling-bridge.ts +377 -0
  820. package/modules/devtools/src/profiling-hooks.ts +98 -0
  821. package/modules/devtools/src/renderer-adapter.ts +76 -0
  822. package/modules/devtools/src/scene-inspector.ts +637 -0
  823. package/modules/devtools/src/scene-store.ts +320 -0
  824. package/modules/devtools/src/scene-sync.ts +116 -0
  825. package/modules/devtools/src/shaders/transform-gizmo.wgsl +38 -0
  826. package/modules/devtools/src/sim-stats-panel.ts +272 -0
  827. package/modules/devtools/src/sim-stats-provider.ts +105 -0
  828. package/modules/devtools/src/skeleton-renderer.ts +351 -0
  829. package/modules/devtools/src/transform-gizmo.ts +928 -0
  830. package/modules/devtools/src/types.ts +290 -0
  831. package/modules/devtools/src/worker-expose.ts +67 -0
  832. package/modules/devtools/src/worker-sync.ts +137 -0
  833. package/modules/editor/src/commands/component.ts +156 -0
  834. package/modules/editor/src/commands/entity.ts +287 -0
  835. package/modules/editor/src/commands/hierarchy.ts +83 -0
  836. package/modules/editor/src/commands/index.ts +61 -0
  837. package/modules/editor/src/commands/prefab.ts +163 -0
  838. package/modules/editor/src/commands/registry.ts +331 -0
  839. package/modules/editor/src/commands/scene-io.ts +211 -0
  840. package/modules/editor/src/commands/selection.ts +73 -0
  841. package/modules/editor/src/commands/transform.ts +91 -0
  842. package/modules/editor/src/ddscene.ts +267 -0
  843. package/modules/editor/src/document.ts +123 -0
  844. package/modules/editor/src/editor-context.ts +172 -0
  845. package/modules/editor/src/index.ts +41 -0
  846. package/modules/editor/src/mcp.ts +103 -0
  847. package/modules/editor/src/module.ts +148 -0
  848. package/modules/editor/src/selection.ts +70 -0
  849. package/modules/editor/src/shell.ts +456 -0
  850. package/modules/editor/src/viewport.ts +199 -0
  851. package/modules/html-ui/src/composite.ts +135 -0
  852. package/modules/html-ui/src/create-html-ui.ts +188 -0
  853. package/modules/html-ui/src/doc-backend.ts +343 -0
  854. package/modules/html-ui/src/host.ts +646 -0
  855. package/modules/html-ui/src/index.ts +13 -0
  856. package/modules/html-ui/src/jsx-dev-runtime.ts +12 -0
  857. package/modules/html-ui/src/jsx-runtime.ts +126 -0
  858. package/modules/html-ui/src/protocol.ts +88 -0
  859. package/modules/html-ui/src/ui-worker.ts +16 -0
  860. package/modules/mcp/README.md +18 -0
  861. package/modules/mcp/src/index.ts +27 -0
  862. package/modules/movement-2d/README.md +19 -0
  863. package/modules/movement-2d/src/grid-character.ts +455 -0
  864. package/modules/movement-2d/src/index.ts +45 -0
  865. package/modules/movement-3d/README.md +21 -0
  866. package/modules/movement-3d/src/character-motor.ts +230 -0
  867. package/modules/movement-3d/src/index.ts +46 -0
  868. package/modules/native-osr/src/index.ts +22 -0
  869. package/modules/native-osr/src/input-router.ts +401 -0
  870. package/modules/native-osr/src/native-osr-manager.ts +247 -0
  871. package/modules/native-osr/src/native-texture-source.ts +86 -0
  872. package/modules/native-osr/src/types.ts +112 -0
  873. package/modules/native-osr/src/world-space-ui-pass.ts +351 -0
  874. package/modules/sailing/README.md +18 -0
  875. package/modules/sailing/src/index.ts +51 -0
  876. package/modules/terrain/README.md +18 -0
  877. package/modules/terrain/src/index.ts +78 -0
  878. package/modules/vitals/README.md +17 -0
  879. package/modules/vitals/src/index.ts +43 -0
  880. package/modules/vitals/src/vitals.ts +265 -0
  881. package/modules/xr/README.md +25 -0
  882. package/modules/xr/src/camera-rig.ts +146 -0
  883. package/modules/xr/src/debug.ts +59 -0
  884. package/modules/xr/src/frame-loop.ts +197 -0
  885. package/modules/xr/src/index.ts +16 -0
  886. package/modules/xr/src/input.ts +208 -0
  887. package/modules/xr/src/layer.ts +140 -0
  888. package/modules/xr/src/session.ts +156 -0
  889. package/modules/xr/src/sim-module.ts +23 -0
  890. package/modules/xr/src/types.ts +32 -0
  891. package/modules/xr/src/webxr.d.ts +182 -0
  892. package/modules/xr/src/xr-module.ts +179 -0
  893. package/package.json +232 -3
  894. package/shader-graph/src/chunks.ts +349 -0
  895. package/shader-graph/src/compiler.ts +732 -0
  896. package/shader-graph/src/compute-compiler.ts +453 -0
  897. package/shader-graph/src/compute-graph.ts +112 -0
  898. package/shader-graph/src/compute-profiles.ts +54 -0
  899. package/shader-graph/src/graph.ts +52 -0
  900. package/shader-graph/src/index.ts +53 -0
  901. package/shader-graph/src/profiles.ts +407 -0
  902. package/shader-graph/src/validator.ts +48 -0
  903. package/shader-graph/src/wgsl-binding-validator.ts +143 -0
  904. package/shader-graph/src/wgsl-struct-validator.ts +245 -0
  905. package/shader-graph/src/wgsl-struct.ts +397 -0
  906. package/test/src/index.ts +7 -0
  907. package/test/src/vision-test.ts +369 -0
@@ -0,0 +1,3227 @@
1
+ import {
2
+ FLAG_ANCHORED,
3
+ FLAG_POPPED,
4
+ FLAG_SPARK,
5
+ FLAG_UPDATED,
6
+ FLAG_UPDATED_BIT,
7
+ pack,
8
+ packCell,
9
+ SHADE_MASK,
10
+ unpack,
11
+ type Cell
12
+ } from "./cell";
13
+ import { DEFAULT_GRAVITY, DEFAULT_TEMP, FIELD } from "./fields";
14
+ import { FluidGrid } from "./fluid-grid";
15
+ import {
16
+ IS_ACID_IMMUNE,
17
+ IS_FIRE,
18
+ IS_HOT,
19
+ MAT_DENSITY,
20
+ MAT_FLAGS,
21
+ MAT_FLAMMABLE,
22
+ MAT_GAS,
23
+ MAT_GRAVITY,
24
+ MAT_GRAVITY_DIR,
25
+ MAT_HAS_REACTIONS,
26
+ MAT_LIFETIME,
27
+ MAT_LIQUID,
28
+ MAT_SOLID,
29
+ Material
30
+ } from "./materials";
31
+ import { PARTICLE_TYPES, ParticleSystem } from "./particles";
32
+ import { SandRNG } from "./rng";
33
+ import { RuleEngine } from "./rules/rule-engine";
34
+ export { MAT_GRAVITY, MAT_GRAVITY_DIR } from "./materials";
35
+
36
+ function initialLifetime(mat: number): number {
37
+ return MAT_LIFETIME[mat];
38
+ }
39
+
40
+ /** Ticks a Gravel cell must remain stationary (and stably supported) before
41
+ * re-settling to Stone. Long so mined debris stays loose and collectible for
42
+ * a while instead of visibly snapping back to solid stone within a second.
43
+ * At 60Hz, 600 ticks ≈ 10 seconds. */
44
+ export const GRAVEL_SETTLE_TICKS = 600;
45
+ /** Settle ticks used when a Gravel/Stone is disturbed by adjacent gravel
46
+ * movement. Short so the cell re-settles quickly if still supported, but
47
+ * gives it time to start falling if gravel flowed out from under it. */
48
+ export const GRAVEL_DISTURB_SETTLE_TICKS = 2;
49
+
50
+ // 4-neighbor offsets for disturbAdjacent
51
+ const DIR_DX = [0, 0, -1, 1];
52
+ const DIR_DY = [-1, 1, 0, 0];
53
+
54
+ export class SandWorld {
55
+ W: number;
56
+ H: number;
57
+ grid: Uint32Array;
58
+ // Per-cell physics fields: 4 bytes per cell [gravity:u8, temp:u8, windX:i8, windY:i8]
59
+ fields: Uint8Array;
60
+ frame = 0;
61
+ // --- Strip mode (multi-threaded sand step) ---
62
+ // When strip mode is enabled, this SandWorld only processes cells in
63
+ // [stripStartX, stripEndX). It can READ the full grid (backed by SAB) but
64
+ // only WRITES to cells in [writeXMin, writeXMax). Cross-strip writes are
65
+ // skipped (trySwap/tryShove return false), and the coordinator's boundary
66
+ // cleanup pass handles them after all workers finish.
67
+ // Defaults: full grid (no restriction) — zero overhead when strip mode is off.
68
+ stripStartX = 0;
69
+ stripEndX = 0; // set to W in constructor
70
+ writeXMin = 0;
71
+ writeXMax = 0; // set to W in constructor
72
+ // Fast xorshift32 PRNG — replaces Math.random() in hot loops.
73
+ private rng: SandRNG;
74
+ // Data-driven rule engine — replaces hardcoded if-else in applyReactions().
75
+ private ruleEngine: RuleEngine;
76
+ // Particle system for explosion visuals (flying debris, blast rings).
77
+ private particles: ParticleSystem;
78
+ // Global impulse settings (not spatial)
79
+ horizontalImpulseChance = 0.02;
80
+ horizontalImpulseStrength = 1;
81
+ // Interlace mode: process every Nth row per frame (halves movement cost
82
+ // for low-end devices). The row offset alternates each frame so all rows
83
+ // are processed over N frames. Default 1 = disabled (process all rows).
84
+ interlaceScale = 1;
85
+ interlaceEnabled = false;
86
+ // Additional flag bits (in the flags byte, bits 16-23) that consumers want
87
+ // preserved across the per-frame FLAG_UPDATED clear in buildActiveListAndClearFlags.
88
+ // The physics engine only uses bits 0-3 and 5 (shade 0-1, FLAG_UPDATED 2,
89
+ // FLAG_SPARK 3, FLAG_POPPED 5); bits 4, 6-7 are available for game-specific
90
+ // flags (e.g. a game's FLAG_DETACHED at bit 4).
91
+ // Set this mask so those bits survive the clear. Defaults to 0 (no extra bits).
92
+ preserveFlagsMask = 0;
93
+ // Flag bits OR'd into the flags of cells disturbed by disturbAdjacent()
94
+ // (Stone→LooseStone conversions when gravel flows out from under them).
95
+ // Disturbed cells also get FLAG_UPDATED, but applyAging clears FLAG_UPDATED
96
+ // before the chunk-world's expireWakeTicks() can detect them in frozen
97
+ // chunks. Setting a persistent flag here (e.g. a game's FLAG_DETACHED)
98
+ // ensures expireWakeTicks can re-activate frozen chunks that contain
99
+ // disturbed cells. Defaults to 0 (no extra flags). Must be a subset of
100
+ // preserveFlagsMask so the flag survives the per-frame clear.
101
+ disturbedFlags = 0;
102
+
103
+ // --- Reusable per-frame buffers (avoid allocations in hot paths) ---
104
+ // fireSources: marks cells that are fire/lava at the start of applyCombustion.
105
+ // visitedFrame: frame-tagged marker used in place of per-frame Sets for the
106
+ // ignitedFuse / ignitedOil de-duplication. A cell is "visited this frame"
107
+ // when visitedFrame[i] === this.frame. Shared between the fuse and
108
+ // burning-oil passes — safe because the two passes target different
109
+ // materials (Fuse vs Oil), so a cell marked in one pass is guarded out by
110
+ // the material-type check in the other before the visited check runs.
111
+ private fireSources: Uint8Array;
112
+ private visitedFrame: Uint32Array;
113
+ // Separate frame-tagged visited array for C4 flood-fill (detonateC4).
114
+ // Avoids clobbering the combustion pass's visitedFrame and avoids
115
+ // allocating a Set on every detonation.
116
+ private visitedC4Frame: Uint32Array;
117
+ // Separate frame-tagged visited array for antimatter annihilation flood-fill.
118
+ // Avoids clobbering the C4 / combustion visited arrays.
119
+ private visitedAntimatterFrame: Uint32Array;
120
+
121
+ // --- Sparse active-cell tracking ---
122
+ // activeCells holds the grid indices of all non-empty cells. activeCount is
123
+ // the number of valid entries. Rebuilt each frame (twice — once at the start
124
+ // to replace the FLAG_UPDATED clear scan, once before applyAging to capture
125
+ // cells created by combustion/reactions). Iterating the active list is
126
+ // O(active) instead of O(W*H) — a massive win when the grid is mostly empty.
127
+ private activeCells: Uint32Array;
128
+ private activeCount = 0;
129
+
130
+ // --- Active Y bounds ---
131
+ // min/max rows that contain non-empty cells. The movement pass only
132
+ // iterates [minActiveY, maxActiveY] instead of [0, H-1]. For a sparse
133
+ // grid (e.g. a 50-cell-tall sand pile in a 512×512 world), this cuts
134
+ // movement from O(W*H) to O(W*activeHeight).
135
+ private minActiveY = 0;
136
+ private maxActiveY = 0;
137
+
138
+ // --- Chunk dirty bitmap ---
139
+ // 1 bit per CHUNK_SIZE×CHUNK_SIZE block. Set when a cell in that chunk
140
+ // changes. The movement pass skips chunks that aren't dirty, avoiding
141
+ // scanning large empty regions within the active Y bounds.
142
+ private static readonly CHUNK_SIZE = 16;
143
+ private numChunksX = 0;
144
+ private numChunksY = 0;
145
+ private chunkDirty: Uint8Array;
146
+
147
+ // --- Coarse-grid fluid simulation ---
148
+ // Replaces the old per-cell wind field system. Provides pressure relaxation
149
+ // and sustained airflow at 1/4 resolution. Per-cell wind is sampled from
150
+ // this grid via bilinear interpolation in tryMove().
151
+ private fluid: FluidGrid;
152
+
153
+ // --- Chunk freeze support ---
154
+ // Optional per-cell skip mask (length = W*H). When set, cells whose skipMask
155
+ // entry is non-zero are excluded from the active-cell list and the movement
156
+ // pass — they are "frozen" and do not participate in the simulation this
157
+ // frame. Used by the game's chunk world to skip inactive (frozen) chunks.
158
+ // null = no skipping (backward compatible with the original single-grid sim).
159
+ skipMask: Uint8Array | null = null;
160
+ // Per-column active cell count — SAB-backed when multi-threaded.
161
+ // Written by each worker during buildActiveListAndClearFlags (only its own
162
+ // strip's columns). Read by the coordinator after all workers finish to
163
+ // rebalance strip boundaries for load balancing.
164
+ histogram: Uint32Array | null = null;
165
+ // Per-cell "deferred move" marker (length = W*H, SAB-backed when
166
+ // multi-threaded). Set by movement functions (trySwap/tryFlow/etc.) when a
167
+ // move is blocked SOLELY by the strip write guard — i.e. the destination is
168
+ // in an adjacent strip and the move would have been valid if not for the
169
+ // guard. The coordinator's boundary cleanup pass reads this to know which
170
+ // cells need a cross-strip move attempt, and clears it after processing.
171
+ //
172
+ // Without this, the boundary cleanup would re-run the FULL tryMove on every
173
+ // boundary cell — re-rolling friction/flicker/impulse RNG gates and giving
174
+ // boundary cells a second movement chance that interior cells don't get.
175
+ // This caused "fast falling" artifacts at strip boundaries: sand in boundary
176
+ // columns fell ~1.3× faster because edge friction (30% skip/frame) was
177
+ // effectively rolled twice (9% skip).
178
+ //
179
+ // null = no deferred tracking (single-threaded / non-SAB mode — no boundary
180
+ // cleanup needed).
181
+ deferredMask: Uint8Array | null = null;
182
+ // When true, tryMove is running in the boundary cleanup pass. This skips all
183
+ // RNG-based movement gates (friction, gas flicker, horizontal impulse, wind)
184
+ // — those already had their chance during the worker pass — and only
185
+ // processes cells marked in deferredMask. Write bounds are restricted to the
186
+ // adjacent strip so only cross-strip moves are attempted.
187
+ boundaryPass = false;
188
+
189
+ constructor(w: number, h: number, options?: {
190
+ sab?: SharedArrayBuffer;
191
+ gridOffset?: number;
192
+ fieldsOffset?: number;
193
+ skipMaskOffset?: number;
194
+ deferredMaskOffset?: number;
195
+ histogramOffset?: number;
196
+ skipStoneFloor?: boolean;
197
+ }) {
198
+ this.W = w;
199
+ this.H = h;
200
+ const cells = w * h;
201
+ this.rng = new SandRNG();
202
+ this.ruleEngine = new RuleEngine();
203
+ this.particles = new ParticleSystem(512);
204
+ this.fluid = new FluidGrid(w, h);
205
+ // Grid + fields can be backed by a SharedArrayBuffer for multi-threaded
206
+ // sand step. When SAB is provided, all workers share the same grid data.
207
+ // Otherwise, regular ArrayBuffer (single-threaded, tests, backward compat).
208
+ if (options?.sab) {
209
+ const gridBytes = cells * 4;
210
+ this.grid = new Uint32Array(options.sab, options.gridOffset ?? 0, cells);
211
+ this.fields = new Uint8Array(options.sab, options.fieldsOffset ?? gridBytes, cells * 4);
212
+ // Optional SAB-backed skip mask — shared between coordinator and workers.
213
+ if (options.skipMaskOffset !== undefined) {
214
+ this.skipMask = new Uint8Array(options.sab, options.skipMaskOffset, cells);
215
+ }
216
+ // Optional SAB-backed deferred-move mask — shared between workers and
217
+ // the coordinator. Each worker writes only to its own strip's cells
218
+ // (the source cell of a blocked move is always within the strip), so
219
+ // there are no races. The coordinator reads it in the boundary cleanup.
220
+ if (options.deferredMaskOffset !== undefined) {
221
+ this.deferredMask = new Uint8Array(options.sab, options.deferredMaskOffset, cells);
222
+ }
223
+ // Optional SAB-backed per-column active cell histogram — used by the
224
+ // coordinator to rebalance strip boundaries for load balancing.
225
+ // Each worker writes only to its own strip's columns (no races).
226
+ if (options.histogramOffset !== undefined) {
227
+ this.histogram = new Uint32Array(options.sab, options.histogramOffset, w);
228
+ }
229
+ } else {
230
+ this.grid = new Uint32Array(cells);
231
+ this.fields = new Uint8Array(cells * 4);
232
+ }
233
+ // Strip mode defaults: full grid (no restriction). Set by SandStepPool
234
+ // when creating per-worker SandWorld instances.
235
+ this.stripStartX = 0;
236
+ this.stripEndX = w;
237
+ this.writeXMin = 0;
238
+ this.writeXMax = w;
239
+ // Per-worker local arrays (not shared — each worker has its own)
240
+ this.fireSources = new Uint8Array(cells);
241
+ this.visitedFrame = new Uint32Array(cells);
242
+ this.visitedC4Frame = new Uint32Array(cells);
243
+ this.visitedAntimatterFrame = new Uint32Array(cells);
244
+ this.activeCells = new Uint32Array(cells);
245
+ this.activeCount = 0;
246
+ // Chunk dirty bitmap — 1 byte per chunk (not bit-packed for simplicity).
247
+ this.numChunksX = Math.ceil(w / SandWorld.CHUNK_SIZE);
248
+ this.numChunksY = Math.ceil(h / SandWorld.CHUNK_SIZE);
249
+ this.chunkDirty = new Uint8Array(this.numChunksX * this.numChunksY);
250
+ // Initialize fields to defaults
251
+ for (let i = 0; i < cells * 4; i += 4) {
252
+ this.fields[i + FIELD.GRAVITY] = DEFAULT_GRAVITY;
253
+ this.fields[i + FIELD.TEMP] = DEFAULT_TEMP;
254
+ }
255
+ if (!options?.skipStoneFloor) {
256
+ // Stone floor
257
+ for (let x = 0; x < w; x++) {
258
+ for (let y = h - 4; y < h; y++) {
259
+ this.grid[y * w + x] = pack({ mat: Material.Stone, lifetime: 0, flags: 0 });
260
+ }
261
+ }
262
+ // Mark the stone floor chunks as dirty so the first movement pass processes them.
263
+ this.minActiveY = h - 4;
264
+ this.maxActiveY = h - 1;
265
+ for (let cy = 0; cy < this.numChunksY; cy++) {
266
+ for (let cx = 0; cx < this.numChunksX; cx++) {
267
+ this.chunkDirty[cy * this.numChunksX + cx] = 1;
268
+ }
269
+ }
270
+ }
271
+ }
272
+
273
+ /** Configure strip mode for multi-threaded sand step. */
274
+ setStripBounds(stripStartX: number, stripEndX: number): void {
275
+ this.stripStartX = stripStartX;
276
+ this.stripEndX = stripEndX;
277
+ this.writeXMin = stripStartX;
278
+ this.writeXMax = stripEndX;
279
+ }
280
+
281
+ /** Check if a cell at X coordinate can be written (within write bounds). */
282
+ canWriteX(x: number): boolean {
283
+ return x >= this.writeXMin && x < this.writeXMax;
284
+ }
285
+
286
+ /** Check if a grid index can be written (within strip write bounds). */
287
+ canWriteIdx(idx: number): boolean {
288
+ const x = idx % this.W;
289
+ return x >= this.writeXMin && x < this.writeXMax;
290
+ }
291
+
292
+ /** Get the active Y bounds [minY, maxY] — used by SandStepPool for boundary cleanup. */
293
+ getActiveYBounds(): { minY: number; maxY: number } {
294
+ return { minY: this.minActiveY, maxY: this.maxActiveY };
295
+ }
296
+
297
+ /** Re-seed the PRNG for deterministic test mode. */
298
+ reseed(seed: number): void {
299
+ this.rng.reseed(seed);
300
+ }
301
+
302
+ /** PRNG accessor for the rule engine. */
303
+ getRng(): SandRNG {
304
+ return this.rng;
305
+ }
306
+
307
+ /** Active cell list accessor for the rule engine. */
308
+ getActiveCells(): Uint32Array {
309
+ return this.activeCells;
310
+ }
311
+
312
+ /** Active cell count accessor for the rule engine. */
313
+ getActiveCount(): number {
314
+ return this.activeCount;
315
+ }
316
+
317
+ /** Particle system accessor for the worker to transfer to SAB. */
318
+ getParticles(): ParticleSystem {
319
+ return this.particles;
320
+ }
321
+
322
+ /** Mark the chunk containing (x, y) as dirty. Called on every cell write. */
323
+ private markChunkDirty(x: number, y: number): void {
324
+ const cx = (x / SandWorld.CHUNK_SIZE) | 0;
325
+ const cy = (y / SandWorld.CHUNK_SIZE) | 0;
326
+ this.chunkDirty[cy * this.numChunksX + cx] = 1;
327
+ // Update Y bounds — the movement pass only scans [minActiveY, maxActiveY].
328
+ if (y < this.minActiveY) this.minActiveY = y;
329
+ if (y > this.maxActiveY) this.maxActiveY = y;
330
+ }
331
+
332
+ // --- Field accessors ---
333
+ // gravity: u8 0-255, 128 = 1.0×. Returns multiplier 0-2.
334
+ getGravity(x: number, y: number): number {
335
+ if (x < 0 || x >= this.W || y < 0 || y >= this.H) return 1.0;
336
+ return this.fields[(y * this.W + x) * 4 + FIELD.GRAVITY] / 128;
337
+ }
338
+
339
+ // temp: u8 0-255, 128 = 1.0. Returns 0-2.
340
+ getTemperature(x: number, y: number): number {
341
+ if (x < 0 || x >= this.W || y < 0 || y >= this.H) return 1.0;
342
+ return this.fields[(y * this.W + x) * 4 + FIELD.TEMP] / 128;
343
+ }
344
+
345
+ // Wind velocity from the coarse-grid fluid simulation. Returns cells/frame.
346
+ getWindX(x: number, y: number): number {
347
+ if (x < 0 || x >= this.W || y < 0 || y >= this.H) return 0;
348
+ return this.fluid.sampleVelX(x, y);
349
+ }
350
+
351
+ getWindY(x: number, y: number): number {
352
+ if (x < 0 || x >= this.W || y < 0 || y >= this.H) return 0;
353
+ return this.fluid.sampleVelY(x, y);
354
+ }
355
+
356
+ // --- Field painting ---
357
+ paintField(cx: number, cy: number, fieldType: number, value: number, radius: number): void {
358
+ for (let y = cy - radius; y <= cy + radius; y++) {
359
+ for (let x = cx - radius; x <= cx + radius; x++) {
360
+ if ((x - cx) * (x - cx) + (y - cy) * (y - cy) > radius * radius) continue;
361
+ if (x < 0 || x >= this.W || y < 0 || y >= this.H) continue;
362
+ this.fields[(y * this.W + x) * 4 + fieldType] = value & 0xff;
363
+ }
364
+ }
365
+ }
366
+
367
+ paintFieldLine(x0: number, y0: number, x1: number, y1: number, fieldType: number, value: number, radius: number): void {
368
+ const dx = Math.abs(x1 - x0);
369
+ const dy = Math.abs(y1 - y0);
370
+ const sx = x0 < x1 ? 1 : -1;
371
+ const sy = y0 < y1 ? 1 : -1;
372
+ let err = dx - dy;
373
+ let x = x0, y = y0;
374
+ while (true) {
375
+ this.paintField(x, y, fieldType, value, radius);
376
+ if (x === x1 && y === y1) break;
377
+ const e2 = 2 * err;
378
+ if (e2 > -dy) { err -= dy; x += sx; }
379
+ if (e2 < dx) { err += dx; y += sy; }
380
+ }
381
+ }
382
+
383
+ getCell(x: number, y: number): Cell {
384
+ if (x < 0 || x >= this.W || y < 0 || y >= this.H) return { mat: Material.Empty, lifetime: 0, flags: 0 };
385
+ return unpack(this.grid[y * this.W + x]);
386
+ }
387
+
388
+ setCell(x: number, y: number, cell: Cell): void {
389
+ if (x < 0 || x >= this.W || y < 0 || y >= this.H) return;
390
+ this.grid[y * this.W + x] = pack(cell);
391
+ }
392
+
393
+ paintMaterial(cx: number, cy: number, mat: number, radius: number): void {
394
+ const W = this.W, H = this.H;
395
+ const grid = this.grid;
396
+ const r2 = radius * radius;
397
+ const lt = initialLifetime(mat);
398
+ for (let y = cy - radius; y <= cy + radius; y++) {
399
+ if (y < 0 || y >= H) continue;
400
+ for (let x = cx - radius; x <= cx + radius; x++) {
401
+ if (x < 0 || x >= W) continue;
402
+ const ddx = x - cx, ddy = y - cy;
403
+ if (ddx * ddx + ddy * ddy > r2) continue;
404
+ const curMat = grid[y * W + x] & 0xff;
405
+ if (curMat === Material.Stone || curMat === Material.Wall) continue;
406
+ grid[y * W + x] = packCell(mat, lt, this.rng.randomShade());
407
+ this.markChunkDirty(x, y);
408
+ }
409
+ }
410
+ }
411
+
412
+ paintLine(x0: number, y0: number, x1: number, y1: number, mat: number, radius: number): void {
413
+ const dx = Math.abs(x1 - x0);
414
+ const dy = Math.abs(y1 - y0);
415
+ const sx = x0 < x1 ? 1 : -1;
416
+ const sy = y0 < y1 ? 1 : -1;
417
+ let err = dx - dy;
418
+ let x = x0, y = y0;
419
+ while (true) {
420
+ this.paintMaterial(x, y, mat, radius);
421
+ if (x === x1 && y === y1) break;
422
+ const e2 = 2 * err;
423
+ if (e2 > -dy) { err -= dy; x += sx; }
424
+ if (e2 < dx) { err += dx; y += sy; }
425
+ }
426
+ }
427
+
428
+ igniteLine(x0: number, y0: number, x1: number, y1: number, radius: number): void {
429
+ const dx = Math.abs(x1 - x0);
430
+ const dy = Math.abs(y1 - y0);
431
+ const sx = x0 < x1 ? 1 : -1;
432
+ const sy = y0 < y1 ? 1 : -1;
433
+ let err = dx - dy;
434
+ let x = x0, y = y0;
435
+ while (true) {
436
+ this.ignite(x, y, radius);
437
+ if (x === x1 && y === y1) break;
438
+ const e2 = 2 * err;
439
+ if (e2 > -dy) { err -= dy; x += sx; }
440
+ if (e2 < dx) { err += dx; y += sy; }
441
+ }
442
+ }
443
+
444
+ ignite(cx: number, cy: number, radius: number): void {
445
+ const W = this.W, H = this.H;
446
+ const grid = this.grid;
447
+ const r2 = radius * radius;
448
+ for (let y = cy - radius; y <= cy + radius; y++) {
449
+ if (y < 0 || y >= H) continue;
450
+ for (let x = cx - radius; x <= cx + radius; x++) {
451
+ if (x < 0 || x >= W) continue;
452
+ const ddx = x - cx, ddy = y - cy;
453
+ if (ddx * ddx + ddy * ddy > r2) continue;
454
+ const mat = grid[y * W + x] & 0xff;
455
+ if (!(MAT_FLAGS[mat] & MAT_FLAMMABLE)) continue;
456
+ // Fuse gets FuseFire (yellow, stays put, deterministic spread)
457
+ if (mat === Material.Fuse) {
458
+ grid[y * W + x] = packCell(Material.FuseFire, 15, this.rng.randomShade());
459
+ } else if (mat === Material.Oil) {
460
+ // Oil only ignites if exposed (has an empty/gas neighbor so fire
461
+ // can reach it). Buried oil stays inert.
462
+ if (!this.isExposed(x, y)) continue;
463
+ // Oil → BurningOil (flows like oil, slow decay, slow spread)
464
+ grid[y * W + x] = packCell(Material.BurningOil, MAT_LIFETIME[Material.BurningOil], this.rng.randomShade());
465
+ } else {
466
+ grid[y * W + x] = packCell(Material.Fire, 30, this.rng.randomShade());
467
+ }
468
+ this.markChunkDirty(x, y);
469
+ }
470
+ }
471
+ }
472
+
473
+ // ===========================================================================
474
+ // Main step
475
+ // ===========================================================================
476
+
477
+ step(): void {
478
+ this.frame++;
479
+ const W = this.W, H = this.H;
480
+
481
+ // Pass 1: Clear FLAG_UPDATED (preserving shade + spark bits) AND build the
482
+ // active-cell list in a single full-grid scan. This replaces the old
483
+ // separate FLAG_UPDATED clear loop — the scan is very cheap (bit-and +
484
+ // comparison per cell, no unpack or MATERIALS lookup).
485
+ this.buildActiveListAndClearFlags();
486
+
487
+ // Pass 2-3: Reactions iterate only the active list (O(active) instead of
488
+ // O(W*H)). Cells created during these passes won't be in the active list —
489
+ // same semantics as the old full-grid scan (one pass per frame).
490
+ // Pass 2: Data-driven rule engine (replaces the old applyReactions()).
491
+ this.ruleEngine.execute(this);
492
+ // Pass 3: Special reactions (complex logic not yet migrated to rules).
493
+ this.applySpecialReactions();
494
+
495
+ // Pass 4: Movement — must iterate in spatial order (bottom-to-top,
496
+ // alternating L/R) for correct falling-sand physics. We skip empty rows
497
+ // outside [minActiveY, maxActiveY] and empty chunks within those bounds.
498
+ // In interlace mode, we process every Nth row (offset by frame % N) to
499
+ // halve movement cost on low-end devices.
500
+ // Strip mode: iterate only [stripStartX, stripEndX) in the X dimension.
501
+ const leftToRight = this.frame % 2 === 0;
502
+ const chunkSize = SandWorld.CHUNK_SIZE;
503
+ const numChunksX = this.numChunksX;
504
+ const chunkDirty = this.chunkDirty;
505
+ const interlace = this.interlaceEnabled ? this.interlaceScale : 1;
506
+ const interlaceOffset = this.frame % interlace;
507
+ const moveX0 = this.stripStartX;
508
+ const moveX1 = this.stripEndX;
509
+ for (let y = this.maxActiveY; y >= this.minActiveY; y--) {
510
+ // Interlace: skip rows not in this frame's subset
511
+ if (interlace > 1 && (y % interlace) !== interlaceOffset) continue;
512
+ // Skip entire rows that are in non-dirty chunks.
513
+ const chunkY = (y / chunkSize) | 0;
514
+ const rowBase = chunkY * numChunksX;
515
+ let rowHasDirty = false;
516
+ for (let cx = 0; cx < numChunksX; cx++) {
517
+ if (chunkDirty[rowBase + cx]) { rowHasDirty = true; break; }
518
+ }
519
+ if (!rowHasDirty) continue;
520
+ if (leftToRight) {
521
+ for (let x = moveX0; x < moveX1; x++) {
522
+ if (!chunkDirty[rowBase + ((x / chunkSize) | 0)]) continue;
523
+ this.tryMove(x, y);
524
+ }
525
+ } else {
526
+ for (let x = moveX1 - 1; x >= moveX0; x--) {
527
+ if (!chunkDirty[rowBase + ((x / chunkSize) | 0)]) continue;
528
+ this.tryMove(x, y);
529
+ }
530
+ }
531
+ }
532
+
533
+ // Pass 5: Combustion — builds fireSources from the active list.
534
+ this.applyCombustion();
535
+
536
+ // Rebuild active list to capture cells created/destroyed by reactions,
537
+ // movement, and combustion. Uses the Y bounds from Pass 1 (expanded by a
538
+ // margin) to avoid a full O(W*H) grid scan — cells can only move a few
539
+ // positions per frame, so the active Y range can't expand beyond that.
540
+ this.buildActiveList();
541
+
542
+ // Pass 6: Aging — iterates the rebuilt active list.
543
+ this.applyAging();
544
+
545
+ // Pass 7: Fluid grid step — pressure relaxation + velocity update.
546
+ // Skipped entirely when no impulses/pressure exist (dirty flag).
547
+ this.fluid.step();
548
+
549
+ // Pass 8: Update explosion particles.
550
+ this.particles.update();
551
+ }
552
+
553
+ /**
554
+ * Boundary cleanup movement pass — used by SandStepPool after all strip
555
+ * workers finish. Re-runs tryMove on the specified columns so cross-strip
556
+ * writes (which were skipped by write guards during the parallel step) get
557
+ * a chance to execute.
558
+ *
559
+ * When boundaryPass is true on this world, tryMove only processes cells
560
+ * marked in deferredMask (cells whose cross-strip move was blocked by the
561
+ * write guard) and skips all RNG-based movement gates. The caller must set
562
+ * writeXMin/writeXMax to the adjacent strip's bounds so only cross-strip
563
+ * moves are attempted.
564
+ *
565
+ * This is a movement-only pass — reactions, combustion, and aging already
566
+ * ran in the workers. We only handle the cross-strip movement writes that
567
+ * were skipped at the boundaries.
568
+ */
569
+ runBoundaryMovement(cols: number[], minY: number, maxY: number, leftToRight: boolean): void {
570
+ // Sort columns in the iteration order matching the main movement pass.
571
+ const sorted = cols.slice().sort((a, b) => leftToRight ? a - b : b - a);
572
+ for (let y = maxY; y >= minY; y--) {
573
+ sorted.forEach((x) => {
574
+ this.tryMove(x, y);
575
+ });
576
+ }
577
+ }
578
+
579
+ /**
580
+ * Build the active-cell list (indices of all non-empty cells) and clear
581
+ * FLAG_UPDATED in a single pass. Merges the old separate FLAG_UPDATED clear
582
+ * loop with active-list construction. Also detects non-zero wind fields so
583
+ * that externally-set wind (e.g. by tests or future field-painting code) is
584
+ * detected even when applyImpulse wasn't called.
585
+ *
586
+ * Static solids (MAT_GRAVITY_DIR === 0: Stone, Wall, Dirt, etc.) are
587
+ * excluded from the active list and chunkDirty bitmap. They can never move,
588
+ * have no reactions, and no lifetimes — including them bloats the active
589
+ * list to ~300k cells in a terrain-heavy world, making every active-list
590
+ * iteration (ruleEngine, applySpecialReactions, applyAging, applyCombustion)
591
+ * 5-10× slower. The tryMove loop also benefits: chunks with only static
592
+ * solids aren't marked dirty, so the loop skips them entirely instead of
593
+ * visiting every cell just to early-return at gravityDir === 0.
594
+ *
595
+ * FLAG_UPDATED is still cleared for ALL cells (including static solids) —
596
+ * the clearMask operation is outside the filter and runs unconditionally.
597
+ */
598
+ private buildActiveListAndClearFlags(): void {
599
+ const grid = this.grid;
600
+ const fields = this.fields;
601
+ const active = this.activeCells;
602
+ const W = this.W, H = this.H;
603
+ const clearMask = ~((0xff & ~(SHADE_MASK | FLAG_SPARK | FLAG_ANCHORED | FLAG_POPPED | this.preserveFlagsMask)) << 16);
604
+ const skip = this.skipMask;
605
+ const chunkDirty = this.chunkDirty;
606
+ const numChunksX = this.numChunksX;
607
+ const chunkSize = SandWorld.CHUNK_SIZE;
608
+ // Strip mode: only scan [stripStartX, stripEndX) in the X dimension.
609
+ // Flag clearing covers [stripStartX-1, stripEndX+1) to handle halo cells
610
+ // at strip boundaries (overlap ensures all flags are cleared).
611
+ const sx0 = this.stripStartX;
612
+ const sx1 = this.stripEndX;
613
+ const clearX0 = sx0 > 0 ? sx0 - 1 : 0;
614
+ const clearX1 = sx1 < W ? sx1 + 1 : W;
615
+ // Reset chunk dirty bitmap and Y bounds — rebuilt from the scan.
616
+ chunkDirty.fill(0);
617
+ // Per-column active cell histogram — clear our strip's columns then
618
+ // count during the scan. SAB-backed; each worker only writes its own
619
+ // columns so there are no races. The coordinator reads it after all
620
+ // workers finish to rebalance strip boundaries.
621
+ const histogram = this.histogram;
622
+ if (histogram !== null) {
623
+ for (let x = sx0; x < sx1; x++) histogram[x] = 0;
624
+ }
625
+ // Clear the deferred-move mask for this strip's columns. Each worker only
626
+ // clears its own strip (no cross-strip writes). The mask is set during the
627
+ // movement pass and read by the coordinator's boundary cleanup after all
628
+ // workers finish.
629
+ const deferred = this.deferredMask;
630
+ let minY = H, maxY = 0;
631
+ let count = 0;
632
+ for (let y = 0; y < H; y++) {
633
+ const rowBase = y * W;
634
+ // Clear flags for the extended range (strip + 1-cell halo)
635
+ for (let x = clearX0; x < clearX1; x++) {
636
+ grid[rowBase + x] &= clearMask;
637
+ }
638
+ // Build active list only for the strip range
639
+ for (let x = sx0; x < sx1; x++) {
640
+ const i = rowBase + x;
641
+ if (deferred !== null) deferred[i] = 0;
642
+ if (grid[i] !== 0 && !(skip !== null && skip[i] !== 0)) {
643
+ const mat = grid[i] & 0xff;
644
+ // Static solids (gravityDir=0) are included if their lifetime field
645
+ // is non-zero (loosened by mining — lifetime is the settle timer).
646
+ if (MAT_GRAVITY_DIR[mat] === 0 && !MAT_HAS_REACTIONS[mat]) {
647
+ if (((grid[i] >> 8) & 0xff) === 0) continue;
648
+ }
649
+ active[count++] = i;
650
+ if (y < minY) minY = y;
651
+ if (y > maxY) maxY = y;
652
+ const cx = (x / chunkSize) | 0;
653
+ const cy = (y / chunkSize) | 0;
654
+ chunkDirty[cy * numChunksX + cx] = 1;
655
+ if (histogram !== null) histogram[x]++;
656
+ }
657
+ }
658
+ }
659
+ this.activeCount = count;
660
+ this.minActiveY = minY;
661
+ this.maxActiveY = maxY;
662
+ }
663
+
664
+ /** Build the active-cell list without clearing flags (for mid-frame rebuild).
665
+ * Like buildActiveListAndClearFlags, excludes static solids (gravityDir=0)
666
+ * from the active list and chunkDirty bitmap.
667
+ *
668
+ * Optimized: only scans rows within [minActiveY - margin, maxActiveY + margin]
669
+ * instead of the full grid. The margin covers cells that moved during the
670
+ * movement pass (particles move at most ~5 cells/frame; reactions affect
671
+ * only 8-connected neighbors). This reduces the scan from O(W*H) to
672
+ * O(W * activeHeight) — typically a 2-4x speedup. */
673
+ private buildActiveList(): void {
674
+ const grid = this.grid;
675
+ const active = this.activeCells;
676
+ const W = this.W, H = this.H;
677
+ const skip = this.skipMask;
678
+ const chunkDirty = this.chunkDirty;
679
+ const numChunksX = this.numChunksX;
680
+ const chunkSize = SandWorld.CHUNK_SIZE;
681
+ // Reset chunk dirty bitmap and Y bounds — rebuilt from the scan.
682
+ chunkDirty.fill(0);
683
+ let minY = H, maxY = 0;
684
+ let count = 0;
685
+
686
+ // Only scan rows near the Pass 1 active Y range. Cells can move at most
687
+ // ~5 cells per frame (liquid flow) and reactions affect 8-connected
688
+ // neighbors (1 cell). A margin of 8 covers all cases with room to spare.
689
+ const margin = 8;
690
+ const startY = Math.max(0, this.minActiveY - margin);
691
+ const endY = Math.min(H - 1, this.maxActiveY + margin);
692
+
693
+ for (let y = startY; y <= endY; y++) {
694
+ const rowBase = y * W;
695
+ for (let x = this.stripStartX; x < this.stripEndX; x++) {
696
+ const i = rowBase + x;
697
+ if (grid[i] !== 0 && !(skip !== null && skip[i] !== 0)) {
698
+ const mat = grid[i] & 0xff;
699
+ if (MAT_GRAVITY_DIR[mat] === 0 && !MAT_HAS_REACTIONS[mat]) {
700
+ if (((grid[i] >> 8) & 0xff) === 0) continue;
701
+ }
702
+ active[count++] = i;
703
+ if (y < minY) minY = y;
704
+ if (y > maxY) maxY = y;
705
+ const cx = (x / chunkSize) | 0;
706
+ const cy = (y / chunkSize) | 0;
707
+ chunkDirty[cy * numChunksX + cx] = 1;
708
+ }
709
+ }
710
+ }
711
+ this.activeCount = count;
712
+ this.minActiveY = minY;
713
+ this.maxActiveY = maxY;
714
+ }
715
+
716
+ private tryMove(x: number, y: number): void {
717
+ const W = this.W, H = this.H;
718
+ const idx = y * W + x;
719
+ const packed = this.grid[idx];
720
+ if (packed === 0) return;
721
+
722
+ // Frozen cells (chunk freeze optimization) do not initiate movement.
723
+ const skip = this.skipMask;
724
+ if (skip !== null && skip[idx] !== 0) return;
725
+
726
+ // Inline unpack — avoid allocating a Cell object on every cell visit
727
+ const mat = packed & 0xff;
728
+ if (mat === Material.Empty) return;
729
+ const flags = (packed >> 16) & 0xff;
730
+ if (flags & FLAG_UPDATED) return;
731
+
732
+ // Boundary cleanup pass: only process cells whose cross-strip move was
733
+ // deferred by the write guard during the worker pass. Cells that didn't
734
+ // attempt a cross-strip move (friction-blocked, obstacle-blocked, or
735
+ // already moved) have no deferred marker and are skipped — this prevents
736
+ // the cleanup from re-rolling RNG gates and giving boundary cells an
737
+ // unfair second movement chance.
738
+ if (this.boundaryPass) {
739
+ const deferred = this.deferredMask;
740
+ if (deferred === null || deferred[idx] === 0) return;
741
+ }
742
+
743
+ // Spark-flagged fire (visual flames emitted by BurningOil) rises straight
744
+ // up without horizontal drift — the fire should stay above its fuel source
745
+ // (the BurningOil cell), not scatter sideways. Horizontal wind, impulse,
746
+ // and gas drift are skipped for these particles.
747
+ const isSparkFire = mat === Material.Fire && (flags & FLAG_SPARK) !== 0;
748
+
749
+ // Typed-array lookups instead of MATERIALS[mat]?.property
750
+ const gravityDir = MAT_GRAVITY_DIR[mat];
751
+ // Static solids (gravityDir === 0: Stone, Wall, Concrete) are frozen
752
+ // UNLESS their lifetime field is non-zero (set by mining as a settle
753
+ // timer). This lets mined Stone fall as Stone — no material conversion.
754
+ // The lifetime field is repurposed as both the "loosened" flag and the
755
+ // settle timer: lifetime > 0 = loosened (can fall, will re-freeze when
756
+ // it lands); lifetime === 0 = frozen static solid.
757
+ if (gravityDir === 0) {
758
+ const lifetime = (packed >> 8) & 0xff;
759
+ if (lifetime === 0) return;
760
+ // Duplicator stores the locked material id in its lifetime field, but
761
+ // it must NEVER fall — it's a permanent static solid. Skip the
762
+ // "loosened static solid" fall path entirely for Duplicator.
763
+ // Void is also a permanent static solid that must never fall.
764
+ if (mat === Material.Duplicator || mat === Material.Void) return;
765
+ // Loosened static solid — fall downward (default direction)
766
+ } else {
767
+ // Normal falling material — check gravity field below
768
+ }
769
+
770
+ // FuseFire stays put so it can deterministically spread to adjacent fuse
771
+ // cells. Without this, the fire gas floats away before it can propagate.
772
+ if (mat === Material.FuseFire) return;
773
+
774
+ // Anchored fire (FLAG_ANCHORED) stays put on the fuel surface (e.g. wax)
775
+ // so it can keep spreading to adjacent fuel. Without this, the fire gas
776
+ // rises and drifts away before it can propagate.
777
+ if (mat === Material.Fire && (flags & FLAG_ANCHORED) !== 0) return;
778
+
779
+ // BurningOil flows like a liquid (gravity: 1, density: 0.8). The
780
+ // burning-oil pass in applyCombustion handles controlled spread to
781
+ // adjacent oil cells, and visual flames are emitted as separate Fire
782
+ // particles (FLAG_SPARK) above the BurningOil — those rise straight up
783
+ // (wind/diagonal movement skipped for spark fire) so they don't scatter
784
+ // even when the BurningOil itself flows.
785
+ // Inline field reads — avoid 4× bounds-checked method calls per cell.
786
+ // tryMove is only called within [0,W)×[0,H) so bounds checks are redundant.
787
+ const fi = idx * 4;
788
+ const gravity = this.fields[fi + FIELD.GRAVITY] / 128;
789
+ // Sample wind from the coarse-grid fluid simulation (bilinear interpolation).
790
+ const windX = this.fluid.sampleVelX(x, y);
791
+ const windY = this.fluid.sampleVelY(x, y);
792
+
793
+ // Apply gravity multiplier — at 0 gravity, nothing falls
794
+ if (gravity <= 0) return;
795
+
796
+ const matFlags = MAT_FLAGS[mat];
797
+ // For loosened static solids (gravityDir=0 but lifetime>0), fall downward.
798
+ const dy = gravityDir !== 0 ? gravityDir : 1;
799
+ const isLiquid = (matFlags & MAT_LIQUID) !== 0;
800
+ const isGas = (matFlags & MAT_GAS) !== 0;
801
+ const matGravity = MAT_GRAVITY[mat];
802
+
803
+ // In the boundary cleanup pass, skip all RNG-based movement gates (wind,
804
+ // friction, gas flicker, horizontal impulse). These already had their
805
+ // chance during the worker pass — re-rolling them would give boundary
806
+ // cells an unfair second movement attempt. The cell is only here because
807
+ // a cross-strip move was deferred by the write guard; we attempt only
808
+ // gravity/diagonal/flow moves into the adjacent strip.
809
+ if (!this.boundaryPass) {
810
+ // --- Wind: apply horizontal/vertical force from the fluid grid ---
811
+ // The fluid grid provides float velocities; scale to cell-frame units.
812
+ // A velocity of ~1.0 means "move every frame" (100% chance).
813
+ // Spark fire (visual flames from BurningOil/fuse) skips wind entirely —
814
+ // the emit impulse gives it a fixed horizontal component at birth, and
815
+ // sampling that back here moves it diagonally (up-left/up-right) in a
816
+ // straight streak for its whole short life. Sparks rise straight up via
817
+ // gas gravity instead. The upward impulse still pushes neighboring
818
+ // smoke/gas up via the fluid grid; it just doesn't steer the spark itself.
819
+ // BurningOil also skips wind — its own spark emission creates upward wind
820
+ // in the fluid grid above it, which would blow the BurningOil itself
821
+ // upward off the oil surface. BurningOil flows via gravity only (sinks,
822
+ // spreads horizontally on the oil surface).
823
+ const windMag = Math.abs(windX) + Math.abs(windY);
824
+ if (!isSparkFire && mat !== Material.BurningOil && windMag > 0.05) {
825
+ const wdx = windX > 0 ? 1 : windX < 0 ? -1 : 0;
826
+ const wdy = windY > 0 ? 1 : windY < 0 ? -1 : 0;
827
+ // Scale chance with wind magnitude: 1.0 = 100% move chance.
828
+ const windChance = Math.min(1, windMag);
829
+ if (this.rng.random() < windChance) {
830
+ // Strong wind can shove into occupied cells (displace liquids/gases)
831
+ if (windMag >= 1.5) {
832
+ if (this.tryShove(x, y, x + wdx, y + wdy, packed)) return;
833
+ } else {
834
+ if (this.trySwap(x, y, x + wdx, y + wdy, packed, mat, matGravity, isGas)) return;
835
+ }
836
+ }
837
+ }
838
+
839
+ // --- Edge friction ---
840
+ const hasLeft = x > 0 && this.grid[y * W + (x - 1)] !== 0;
841
+ const hasRight = x < W - 1 && this.grid[y * W + (x + 1)] !== 0;
842
+ const isExterior = !hasLeft || !hasRight;
843
+
844
+ // Honey: very thick — high friction, barely flows
845
+ if (mat === Material.Honey && isExterior && this.rng.random() < 0.7) return;
846
+ // Tar: extremely viscous — even higher friction than honey, oozes slowly.
847
+ if (mat === Material.Tar && isExterior && this.rng.random() < 0.85) return;
848
+
849
+ // Exterior particles have a chance to skip falling (friction).
850
+ if (isExterior && !isGas) {
851
+ const frictionChance = 0.3 / Math.max(1, matGravity);
852
+ if (this.rng.random() < frictionChance) return;
853
+ }
854
+
855
+ // --- Gas flicker: random chance to not move at all ---
856
+ // Prevents gasses from rising in uniform horizontal lines. Each particle
857
+ // has a chance to "flicker" in place, creating organic, non-uniform spread.
858
+ if (isGas) {
859
+ const flickerChance = (mat === Material.Fire || mat === Material.FuseFire) ? 0.35 : 0.25;
860
+ if (this.rng.random() < flickerChance) return;
861
+ }
862
+
863
+ // --- Density-scaled horizontal impulse ---
864
+ // Lighter materials (low gravity) get more impulse; denser materials get less.
865
+ // Sand (gravity 1) → full impulse, Water (gravity 2) → half, Lava (gravity 3) → third
866
+ // Gasses (fire/smoke/steam) also get impulse so they drift sideways while rising.
867
+ // Spark fire (visual flames from BurningOil) skips this — no horizontal drift.
868
+ if (this.horizontalImpulseChance > 0 && !isSparkFire) {
869
+ const scaledChance = this.horizontalImpulseChance / Math.max(1, matGravity);
870
+ if (this.rng.random() < scaledChance) {
871
+ const nudgeDir = this.rng.random() < 0.5 ? -1 : 1;
872
+ const nudge = nudgeDir * Math.max(1, Math.round(this.horizontalImpulseStrength));
873
+ if (this.trySwap(x, y, x + nudge, y + dy, packed, mat, matGravity, isGas)) return;
874
+ }
875
+ }
876
+ } // end if (!this.boundaryPass)
877
+
878
+ // 1. Try gravity direction
879
+ if (this.trySwap(x, y, x, y + dy, packed, mat, matGravity, isGas)) {
880
+ if (mat === Material.Gravel && ((packed >> 8) & 0xff) !== GRAVEL_DISTURB_SETTLE_TICKS) this.disturbAdjacent(x, y);
881
+ return;
882
+ }
883
+
884
+ // Rubber: bouncy — try to bounce upward when blocked from below
885
+ if (mat === Material.Rubber) {
886
+ // Check if we're resting on something (can't fall)
887
+ const belowY = y + dy;
888
+ const blocked = belowY < 0 || belowY >= H || this.grid[belowY * W + x] !== 0;
889
+ if (blocked) {
890
+ // Bounce: try to move up or sideways
891
+ if (this.rng.random() < 0.5) {
892
+ if (this.trySwap(x, y, x, y - dy, packed, mat, matGravity, isGas)) return;
893
+ }
894
+ const bounceDir = this.rng.random() < 0.5 ? -1 : 1;
895
+ if (this.trySwap(x, y, x + bounceDir * 2, y, packed, mat, matGravity, isGas)) return;
896
+ if (this.trySwap(x, y, x + bounceDir, y, packed, mat, matGravity, isGas)) return;
897
+ }
898
+ }
899
+
900
+ // Diagonal movement (gravity direction + horizontal). Spark fire (visual
901
+ // flames from BurningOil) skips this — if it can't rise straight up, it
902
+ // stays put and decays via lifetime rather than sliding sideways.
903
+ if (!isSparkFire) {
904
+ const dir = this.rng.random() < 0.5 ? -1 : 1;
905
+ if (this.trySwap(x, y, x + dir, y + dy, packed, mat, matGravity, isGas)) {
906
+ if (mat === Material.Gravel && ((packed >> 8) & 0xff) !== GRAVEL_DISTURB_SETTLE_TICKS) this.disturbAdjacent(x, y);
907
+ return;
908
+ }
909
+ if (this.trySwap(x, y, x - dir, y + dy, packed, mat, matGravity, isGas)) {
910
+ if (mat === Material.Gravel && ((packed >> 8) & 0xff) !== GRAVEL_DISTURB_SETTLE_TICKS) this.disturbAdjacent(x, y);
911
+ return;
912
+ }
913
+ }
914
+
915
+ // Gravel: flows horizontally like a liquid when unsupported, but settles
916
+ // firmly in place when supported from below. Unlike a true liquid, gravel
917
+ // only flows when there's empty space below the adjacent cell (it "spills"
918
+ // downhill), and stops as soon as it's resting on something. This creates
919
+ // realistic pile behavior — gravel spreads into low spots then freezes.
920
+ if (mat === Material.Gravel) {
921
+ // Only "real" gravel (from mining, lifetime=GRAVEL_SETTLE_TICKS or 0)
922
+ // disturbs adjacent Stone when it moves. Freshly-disturbed Stone that
923
+ // became Gravel (lifetime=GRAVEL_DISTURB_SETTLE_TICKS) does NOT disturb
924
+ // — otherwise each falling disturbed cell would disturb its neighbors,
925
+ // which would become Gravel, fall, disturb THEIR neighbors, causing an
926
+ // unbounded cascade of stone destruction across the map.
927
+ const canDisturb = ((packed >> 8) & 0xff) !== GRAVEL_DISTURB_SETTLE_TICKS;
928
+ const flowDir = this.rng.random() < 0.5 ? -1 : 1;
929
+ if (this.tryGravelFlow(x, y, flowDir)) {
930
+ if (canDisturb) this.disturbAdjacent(x, y);
931
+ return;
932
+ }
933
+ if (this.tryGravelFlow(x, y, -flowDir)) {
934
+ if (canDisturb) this.disturbAdjacent(x, y);
935
+ return;
936
+ }
937
+ // Gravel is settled (supported below, can't spread) — stop moving.
938
+ return;
939
+ }
940
+
941
+ if (isLiquid) {
942
+ // Buoyancy: a less-dense liquid rises through a denser liquid above it.
943
+ // This is the mirror of the "heavy liquid sinks through lighter liquid
944
+ // below" code at the end of tryMove. Without active rising, oil dropped
945
+ // into water spreads horizontally at the bottom but never reaches the
946
+ // surface — the passive sinking mechanism is too slow because the
947
+ // bottom-to-top movement pass lets oil spread sideways before the water
948
+ // above gets a chance to sink through it.
949
+ if (dy > 0) {
950
+ const above = y - 1;
951
+ if (above >= 0) {
952
+ const aboveIdx = above * W + x;
953
+ const abovePacked = this.grid[aboveIdx];
954
+ if (abovePacked !== 0) {
955
+ const aboveMat = abovePacked & 0xff;
956
+ if (!((abovePacked >> 16) & FLAG_UPDATED) &&
957
+ (MAT_FLAGS[aboveMat] & MAT_LIQUID) &&
958
+ MAT_DENSITY[aboveMat] > MAT_DENSITY[mat]) {
959
+ this.grid[aboveIdx] = packed | FLAG_UPDATED_BIT;
960
+ this.grid[idx] = abovePacked | FLAG_UPDATED_BIT;
961
+ return;
962
+ }
963
+ }
964
+ }
965
+ }
966
+
967
+ const flowDir = this.rng.random() < 0.5 ? -1 : 1;
968
+ if (this.tryFlow(x, y, flowDir, 5)) return;
969
+ if (this.tryFlow(x, y, -flowDir, 5)) return;
970
+ // Density-based horizontal spread: a less-dense liquid (e.g. oil) can
971
+ // push through a denser liquid (e.g. water) to spread across its
972
+ // surface. Without this, oil rises to the top of water in a column but
973
+ // can't spread sideways because tryFlow only enters empty cells.
974
+ if (this.tryDensityFlow(x, y, flowDir, mat, packed)) return;
975
+ if (this.tryDensityFlow(x, y, -flowDir, mat, packed)) return;
976
+ }
977
+
978
+ // Gas: wider horizontal drift (up to 3 cells) for organic spread.
979
+ if (isGas) {
980
+ const driftDir = this.rng.random() < 0.5 ? -1 : 1;
981
+ if (this.tryFlow(x, y, driftDir, 3)) return;
982
+ if (this.tryFlow(x, y, -driftDir, 3)) return;
983
+ }
984
+
985
+ // 5. Liquids: sink through gas below (heavy liquid displaces light gas upward)
986
+ if (isLiquid && dy > 0) {
987
+ const below = y + 1;
988
+ if (below < H) {
989
+ const belowIdx = below * W + x;
990
+ const belowPacked = this.grid[belowIdx];
991
+ if (belowPacked !== 0) {
992
+ const belowMat = belowPacked & 0xff;
993
+ const belowFlags = MAT_FLAGS[belowMat];
994
+ // Heavy liquid sinks through a lighter gas or a less-dense liquid.
995
+ // Density is a separate property from gravity (movement speed):
996
+ // mercury (13.5) sinks through water (1.0), lava (3.0) through
997
+ // water, honey (1.4) through water, etc.
998
+ if (!((belowPacked >> 16) & FLAG_UPDATED) &&
999
+ ((belowFlags & MAT_GAS) || ((belowFlags & MAT_LIQUID) && MAT_DENSITY[mat] > MAT_DENSITY[belowMat]))) {
1000
+ this.grid[belowIdx] = packed | FLAG_UPDATED_BIT;
1001
+ this.grid[idx] = belowPacked | FLAG_UPDATED_BIT;
1002
+ return;
1003
+ }
1004
+ }
1005
+ }
1006
+ }
1007
+
1008
+ // Glitter: suspends in water (tries to float up when in water)
1009
+ if (mat === Material.Glitter) {
1010
+ if (y + 1 < H && (this.grid[(y + 1) * W + x] & 0xff) === Material.Water) {
1011
+ // Stay suspended — don't sink further
1012
+ if (this.rng.random() < 0.8) return;
1013
+ }
1014
+ }
1015
+ }
1016
+
1017
+ /**
1018
+ * Try to move/swap the cell at (x,y) into (nx,ny).
1019
+ *
1020
+ * The caller (tryMove) passes the already-read `srcPacked` plus pre-fetched
1021
+ * srcMat/srcGravity/srcIsGas so the source cell is not re-read or re-unpacked
1022
+ * on every call — tryMove calls this up to ~6 times per cell. The moved cell
1023
+ * is written with FLAG_UPDATED set via a direct bit-OR on `srcPacked` (no
1024
+ * object spread / re-pack).
1025
+ */
1026
+ private trySwap(
1027
+ x: number, y: number, nx: number, ny: number,
1028
+ srcPacked: number, srcMat: number, srcGravity: number, srcIsGas: boolean,
1029
+ ): boolean {
1030
+ const W = this.W, H = this.H;
1031
+ if (nx < 0 || nx >= W || ny < 0 || ny >= H) return false;
1032
+ // Strip mode write guard: don't write to cells outside our strip.
1033
+ // The coordinator's boundary cleanup handles deferred cross-strip moves.
1034
+ if (nx < this.writeXMin || nx >= this.writeXMax) {
1035
+ // Mark the source cell as deferred so the boundary cleanup knows to
1036
+ // re-attempt this cross-strip move. Only set during the worker pass
1037
+ // (not during the boundary cleanup itself).
1038
+ if (!this.boundaryPass) {
1039
+ const dm = this.deferredMask;
1040
+ if (dm !== null) dm[y * W + x] = 1;
1041
+ }
1042
+ return false;
1043
+ }
1044
+ const destIdx = ny * W + nx;
1045
+ const srcIdx = y * W + x;
1046
+ const destPacked = this.grid[destIdx];
1047
+
1048
+ if (destPacked === 0) {
1049
+ // Empty: simple move
1050
+ this.grid[destIdx] = srcPacked | FLAG_UPDATED_BIT;
1051
+ this.grid[srcIdx] = 0;
1052
+ return true;
1053
+ }
1054
+
1055
+ // Non-gas displacing gas: solids and liquids are denser than gas, so a
1056
+ // falling (or rising) solid/liquid pushes the gas aside and the gas swaps
1057
+ // into the source cell. Without this, smoke/steam would block sand and
1058
+ // water from falling — the gas has no gravity-driven way to yield. A gas
1059
+ // that already moved this frame is left alone (it will yield next frame).
1060
+ if (!srcIsGas) {
1061
+ const destMat = destPacked & 0xff;
1062
+ if ((MAT_FLAGS[destMat] & MAT_GAS) && !((destPacked >> 16) & FLAG_UPDATED)) {
1063
+ this.grid[destIdx] = srcPacked | FLAG_UPDATED_BIT;
1064
+ this.grid[srcIdx] = destPacked | FLAG_UPDATED_BIT;
1065
+ return true;
1066
+ }
1067
+ }
1068
+
1069
+ // Gas-to-gas displacement: a lighter gas (higher gravity for upward, i.e.
1070
+ // rises faster) can push through a slower gas. This lets fire (gravity 4)
1071
+ // rise through smoke (gravity 3) so they separate instead of mixing.
1072
+ // Without this, smoke from a bottom-up burn would overtake the fire front
1073
+ // and suffocate it (fire trapped below its own smoke, can't reach fuel).
1074
+ if (srcIsGas) {
1075
+ const destMat = destPacked & 0xff;
1076
+ if ((MAT_FLAGS[destMat] & MAT_GAS) && srcGravity > MAT_GRAVITY[destMat]) {
1077
+ // Swap: source moves to dest, dest moves to source
1078
+ this.grid[destIdx] = srcPacked | FLAG_UPDATED_BIT;
1079
+ this.grid[srcIdx] = destPacked | FLAG_UPDATED_BIT;
1080
+ return true;
1081
+ }
1082
+ }
1083
+
1084
+ // Solid-liquid density displacement: a denser material sinks through a
1085
+ // less-dense one. Sand (density 2.0) sinks through water (1.0) but not
1086
+ // through mercury (13.5). Water (1.0) sinks through wood (0.6), making
1087
+ // wood float. Only applies to solid-liquid pairs — solids don't flow
1088
+ // through each other, and gas displacement is handled above. Structural
1089
+ // barriers (static solids with density >= 2.0, e.g. stone/wall/concrete)
1090
+ // are immovable — nothing sinks through them regardless of density.
1091
+ if (!srcIsGas) {
1092
+ const destMat = destPacked & 0xff;
1093
+ const destFlags = MAT_FLAGS[destMat];
1094
+ if ((destFlags & (MAT_SOLID | MAT_LIQUID)) && !((destPacked >> 16) & FLAG_UPDATED)) {
1095
+ const srcIsSolid = (MAT_FLAGS[srcMat] & MAT_SOLID) !== 0;
1096
+ const destIsSolid = (destFlags & MAT_SOLID) !== 0;
1097
+ // One must be solid, the other liquid (not solid-solid or liquid-liquid).
1098
+ // Structural barriers (static + dense) are immovable. A loosened
1099
+ // static solid (lifetime > 0, set by mining) is NOT a barrier — it
1100
+ // can fall, so other cells can sink through it.
1101
+ const destIsBarrier = destIsSolid && MAT_GRAVITY_DIR[destMat] === 0 &&
1102
+ MAT_DENSITY[destMat] >= 2.0 &&
1103
+ ((destPacked >> 8) & 0xff) === 0;
1104
+ if (srcIsSolid !== destIsSolid &&
1105
+ !destIsBarrier &&
1106
+ MAT_DENSITY[srcMat] > MAT_DENSITY[destMat]) {
1107
+ this.grid[destIdx] = srcPacked | FLAG_UPDATED_BIT;
1108
+ this.grid[srcIdx] = destPacked | FLAG_UPDATED_BIT;
1109
+ return true;
1110
+ }
1111
+ }
1112
+ }
1113
+
1114
+ return false;
1115
+ }
1116
+
1117
+ /**
1118
+ * Like trySwap, but can also displace liquids/gases: if the destination is
1119
+ * occupied by a liquid or gas, the two cells swap (the pushed particle moves
1120
+ * into the source cell). Solids and walls block the shove. Used by strong
1121
+ * wind/impulse forces to push particles through dense media.
1122
+ *
1123
+ * The caller passes the already-read `srcPacked` so the source is not
1124
+ * re-read; the moved cell is written via a direct bit-OR (no spread/re-pack).
1125
+ */
1126
+ private tryShove(x: number, y: number, nx: number, ny: number, srcPacked: number): boolean {
1127
+ const W = this.W, H = this.H;
1128
+ if (nx < 0 || nx >= W || ny < 0 || ny >= H) return false;
1129
+ // Strip mode write guard
1130
+ if (nx < this.writeXMin || nx >= this.writeXMax) {
1131
+ if (!this.boundaryPass) {
1132
+ const dm = this.deferredMask;
1133
+ if (dm !== null) dm[y * W + x] = 1;
1134
+ }
1135
+ return false;
1136
+ }
1137
+ const destIdx = ny * W + nx;
1138
+ const srcIdx = y * W + x;
1139
+ const destPacked = this.grid[destIdx];
1140
+ // Empty destination: normal swap
1141
+ if (destPacked === 0) {
1142
+ this.grid[destIdx] = srcPacked | FLAG_UPDATED_BIT;
1143
+ this.grid[srcIdx] = 0;
1144
+ return true;
1145
+ }
1146
+ // Occupied: only displace liquids/gases (not solids, walls, or already-updated cells)
1147
+ const destMat = destPacked & 0xff;
1148
+ const destFlags = MAT_FLAGS[destMat];
1149
+ if (!(destFlags & (MAT_LIQUID | MAT_GAS))) return false;
1150
+ if ((destPacked >> 16) & FLAG_UPDATED) return false;
1151
+ // Swap the two cells
1152
+ this.grid[destIdx] = srcPacked | FLAG_UPDATED_BIT;
1153
+ this.grid[srcIdx] = destPacked | FLAG_UPDATED_BIT;
1154
+ return true;
1155
+ }
1156
+
1157
+ private tryFlow(x: number, y: number, dir: number, maxSteps: number): boolean {
1158
+ const W = this.W, H = this.H;
1159
+ const srcIdx = y * W + x;
1160
+ // Read the source once and pre-compute the moved value (FLAG_UPDATED set).
1161
+ // The old code re-unpacked the source twice per move via
1162
+ // `pack({ ...unpack(packed), flags: unpack(packed).flags | FLAG_UPDATED })`.
1163
+ const movedPacked = this.grid[srcIdx] | FLAG_UPDATED_BIT;
1164
+ for (let step = 1; step <= maxSteps; step++) {
1165
+ const nx = x + dir * step;
1166
+ if (nx < 0 || nx >= W) return false;
1167
+ // Strip mode write guard
1168
+ if (nx < this.writeXMin || nx >= this.writeXMax) {
1169
+ if (!this.boundaryPass) {
1170
+ const dm = this.deferredMask;
1171
+ if (dm !== null) dm[srcIdx] = 1;
1172
+ }
1173
+ return false;
1174
+ }
1175
+ const destIdx = y * W + nx;
1176
+ if (this.grid[destIdx] !== 0) return false;
1177
+
1178
+ const belowY = y + 1;
1179
+ if (belowY < H) {
1180
+ const belowIdx = belowY * W + nx;
1181
+ if (this.grid[belowIdx] === 0) {
1182
+ this.grid[belowIdx] = movedPacked;
1183
+ this.grid[srcIdx] = 0;
1184
+ return true;
1185
+ }
1186
+ }
1187
+
1188
+ if (step === maxSteps) {
1189
+ this.grid[destIdx] = movedPacked;
1190
+ this.grid[srcIdx] = 0;
1191
+ return true;
1192
+ }
1193
+ }
1194
+ return false;
1195
+ }
1196
+
1197
+ /**
1198
+ * Density-based horizontal flow for liquids: a less-dense liquid can swap
1199
+ * with an adjacent denser liquid. This lets oil (density 0.8) spread across
1200
+ * the surface of water (density 1.0). Without this, oil rises to the top of
1201
+ * water in a vertical column but can't spread horizontally because tryFlow
1202
+ * only moves into empty cells, and the adjacent cells at the surface are
1203
+ * water, not empty.
1204
+ *
1205
+ * Only fires when the source is strictly less dense than the destination
1206
+ * (so same-density liquids don't churn). Solids, gases, and already-updated
1207
+ * cells are not displaced.
1208
+ */
1209
+ private tryDensityFlow(
1210
+ x: number, y: number, dir: number,
1211
+ srcMat: number, srcPacked: number,
1212
+ ): boolean {
1213
+ const W = this.W;
1214
+ const nx = x + dir;
1215
+ if (nx < 0 || nx >= W) return false;
1216
+ // Strip mode write guard
1217
+ if (nx < this.writeXMin || nx >= this.writeXMax) {
1218
+ if (!this.boundaryPass) {
1219
+ const dm = this.deferredMask;
1220
+ if (dm !== null) dm[y * W + x] = 1;
1221
+ }
1222
+ return false;
1223
+ }
1224
+ const destIdx = y * W + nx;
1225
+ const destPacked = this.grid[destIdx];
1226
+ if (destPacked === 0) return false; // empty — tryFlow already handled this
1227
+ const destMat = destPacked & 0xff;
1228
+ // Only displace liquids (not solids, gases, or already-updated cells)
1229
+ if (!(MAT_FLAGS[destMat] & MAT_LIQUID)) return false;
1230
+ if ((destPacked >> 16) & FLAG_UPDATED) return false;
1231
+ // Source must be strictly less dense than destination
1232
+ if (MAT_DENSITY[srcMat] >= MAT_DENSITY[destMat]) return false;
1233
+ // Swap: less-dense liquid moves sideways, denser liquid takes its place
1234
+ this.grid[destIdx] = srcPacked | FLAG_UPDATED_BIT;
1235
+ this.grid[y * W + x] = destPacked | FLAG_UPDATED_BIT;
1236
+ return true;
1237
+ }
1238
+
1239
+ /**
1240
+ * When a gravel cell vacates position (x, y), disturb adjacent Stone and
1241
+ * LooseStone cells so they don't float in the air when their support flows
1242
+ * away.
1243
+ *
1244
+ * - Stone (re-settled or natural) → gravity field set + short settle timer
1245
+ * in the lifetime field, so it falls as Stone if unsupported. No material
1246
+ * conversion — it stays Stone and re-freezes (gravity cleared) when it
1247
+ * lands.
1248
+ * - LooseStone (legacy cells from old saves) → settle timer reset to the
1249
+ * short disturbed value, gravity ensured, so it keeps falling instead of
1250
+ * re-settling prematurely.
1251
+ *
1252
+ * The disturbed settle timer is GRAVEL_DISTURB_SETTLE_TICKS (2 ticks). If the
1253
+ * cell is still supported after gravel moves, it re-settles (gravity cleared)
1254
+ * in 2 ticks. If gravel removed its support, the cell starts falling
1255
+ * (FLAG_UPDATED keeps the timer reset to 2 via applyAging) and won't settle
1256
+ * until it lands.
1257
+ */
1258
+ private disturbAdjacent(x: number, y: number): void {
1259
+ const W = this.W, H = this.H;
1260
+ // Check 4-neighbors (the cell above is most critical — it lost support)
1261
+ // Set disturbedFlags (in addition to FLAG_UPDATED) so the chunk-world's
1262
+ // expireWakeTicks can detect disturbed cells in frozen chunks even after
1263
+ // applyAging clears FLAG_UPDATED. disturbedFlags is persistent (included
1264
+ // in preserveFlagsMask by the game).
1265
+ const extra = this.disturbedFlags | FLAG_UPDATED;
1266
+ for (let i = 0; i < 4; i++) {
1267
+ const nx = x + DIR_DX[i];
1268
+ const ny = y + DIR_DY[i];
1269
+ if (nx < 0 || nx >= W || ny < 0 || ny >= H) continue;
1270
+ // Strip mode write guard: skip neighbors outside our write bounds.
1271
+ if (nx < this.writeXMin || nx >= this.writeXMax) continue;
1272
+ const nIdx = ny * W + nx;
1273
+ const nPacked = this.grid[nIdx];
1274
+ if (nPacked === 0) continue;
1275
+ const nMat = nPacked & 0xff;
1276
+ const nFlags = (nPacked >> 16) & 0xff;
1277
+ const nFi = nIdx * 4;
1278
+
1279
+ if (nMat === Material.Stone) {
1280
+ // Set gravity field + settle timer so Stone falls as Stone if
1281
+ // unsupported. No material conversion. Re-freezes when it lands.
1282
+ this.grid[nIdx] = (nMat & 0xff) | ((GRAVEL_DISTURB_SETTLE_TICKS & 0xff) << 8) | (((nFlags | extra) & 0xff) << 16);
1283
+ this.fields[nFi + FIELD.GRAVITY] = DEFAULT_GRAVITY;
1284
+ } else if (nMat === Material.LooseStone) {
1285
+ // Legacy LooseStone (old saves): reset settle timer to short value
1286
+ const shade = nFlags & SHADE_MASK;
1287
+ this.grid[nIdx] = packCell(Material.LooseStone, GRAVEL_DISTURB_SETTLE_TICKS, shade | extra);
1288
+ this.fields[nFi + FIELD.GRAVITY] = DEFAULT_GRAVITY;
1289
+ }
1290
+ }
1291
+ }
1292
+
1293
+ /**
1294
+ * Gravel flow: like liquid flow, but gravel only moves horizontally to an
1295
+ * adjacent cell if that cell is empty AND the cell below the destination is
1296
+ * also empty (i.e. it "spills downhill" into a low spot). If the destination
1297
+ * at the same level is empty but supported below, gravel still moves there
1298
+ * (spreading along a flat surface), but only for a limited distance so it
1299
+ * forms realistic piles rather than spreading infinitely like water.
1300
+ *
1301
+ * This creates the "flows like a liquid but settles in place" behavior:
1302
+ * gravel pours into gaps, spreads to fill low spots, then stops once it's
1303
+ * resting on something with no downhill path.
1304
+ */
1305
+ private tryGravelFlow(x: number, y: number, dir: number): boolean {
1306
+ const W = this.W, H = this.H;
1307
+ const srcIdx = y * W + x;
1308
+ const movedPacked = this.grid[srcIdx] | FLAG_UPDATED_BIT;
1309
+
1310
+ // Step 1: try to spill into the adjacent cell at the same level
1311
+ const nx = x + dir;
1312
+ if (nx < 0 || nx >= W) return false;
1313
+ // Strip mode write guard
1314
+ if (nx < this.writeXMin || nx >= this.writeXMax) {
1315
+ if (!this.boundaryPass) {
1316
+ const dm = this.deferredMask;
1317
+ if (dm !== null) dm[srcIdx] = 1;
1318
+ }
1319
+ return false;
1320
+ }
1321
+ const destIdx = y * W + nx;
1322
+ if (this.grid[destIdx] !== 0) return false;
1323
+
1324
+ // Check if there's a drop below the destination (downhill spill).
1325
+ // If so, gravel falls into the gap — this is the primary flow mode.
1326
+ const belowY = y + 1;
1327
+ if (belowY < H) {
1328
+ const belowDestIdx = belowY * W + nx;
1329
+ if (this.grid[belowDestIdx] === 0) {
1330
+ // Downhill: gravel spills into the gap and falls
1331
+ this.grid[belowDestIdx] = movedPacked;
1332
+ this.grid[srcIdx] = 0;
1333
+ return true;
1334
+ }
1335
+ }
1336
+
1337
+ // Same-level spread: gravel moves to an adjacent empty cell that's
1338
+ // supported below. Limit spread distance to 2 cells so gravel forms
1339
+ // piles rather than spreading infinitely like a liquid.
1340
+ // Only spread if the gravel is NOT supported directly below (i.e. it's
1341
+ // on an edge and could fall off). This prevents gravel on a flat floor
1342
+ // from spreading forever.
1343
+ const srcBelowIdx = (y + 1) * W + x;
1344
+ const srcSupported = (y + 1 >= H) || this.grid[srcBelowIdx] !== 0;
1345
+ if (srcSupported) {
1346
+ // Gravel is supported below — only spread if the destination is also
1347
+ // NOT supported (gravel flows off the edge of a pile).
1348
+ if (belowY < H) {
1349
+ const belowDestIdx = belowY * W + nx;
1350
+ if (this.grid[belowDestIdx] !== 0) {
1351
+ // Destination is supported too — no flow needed, gravel settles
1352
+ return false;
1353
+ }
1354
+ }
1355
+ }
1356
+
1357
+ // Spread to the adjacent cell
1358
+ this.grid[destIdx] = movedPacked;
1359
+ this.grid[srcIdx] = 0;
1360
+ return true;
1361
+ }
1362
+
1363
+ // ===========================================================================
1364
+ // Special reactions for new materials
1365
+ // ===========================================================================
1366
+
1367
+ private applySpecialReactions(): void {
1368
+ const W = this.W, H = this.H;
1369
+ const grid = this.grid;
1370
+ const fields = this.fields;
1371
+ const active = this.activeCells;
1372
+ const count = this.activeCount;
1373
+ // Strip mode write bounds — neighbor writes outside this range are skipped.
1374
+ const wxMin = this.writeXMin, wxMax = this.writeXMax;
1375
+
1376
+ for (let a = 0; a < count; a++) {
1377
+ const idx = active[a];
1378
+ const packed = grid[idx];
1379
+ if (packed === 0) continue; // cell was destroyed earlier this frame
1380
+
1381
+ const mat = packed & 0xff;
1382
+ if (mat === Material.Empty) continue;
1383
+ const lifetime = (packed >> 8) & 0xff;
1384
+ const flags = (packed >> 16) & 0xff;
1385
+
1386
+ // Inline field read — no bounds check needed (idx is always valid)
1387
+ const fi = idx * 4;
1388
+ const temp = fields[fi + FIELD.TEMP] / 128;
1389
+
1390
+ const x = idx % W;
1391
+ const y = (idx / W) | 0;
1392
+
1393
+ // --- Antimatter: on contact with any non-empty, non-antimatter,
1394
+ // non-wall, non-duplicator material, annihilates the ENTIRE contiguous
1395
+ // antimatter cluster PLUS all contiguous other material (8-connected
1396
+ // flood-fill through non-empty cells). Wall and Duplicator are barriers
1397
+ // (not annihilated, flood-fill stops at them). Empty cells stop the
1398
+ // flood-fill. A fire explosion marks the contact point. ---
1399
+ if (mat === Material.Antimatter) {
1400
+ // Find a contact neighbor (the trigger for annihilation).
1401
+ let contactX = -1, contactY = -1;
1402
+ for (let dy = -1; dy <= 1 && contactX < 0; dy++) {
1403
+ for (let dx = -1; dx <= 1; dx++) {
1404
+ if (dx === 0 && dy === 0) continue;
1405
+ const nx = x + dx, ny = y + dy;
1406
+ if (nx < 0 || nx >= W || ny < 0 || ny >= H) continue;
1407
+ if (nx < wxMin || nx >= wxMax) continue;
1408
+ const nMat = grid[ny * W + nx] & 0xff;
1409
+ if (nMat !== Material.Empty && nMat !== Material.Antimatter &&
1410
+ nMat !== Material.Wall && nMat !== Material.Duplicator &&
1411
+ nMat !== Material.Void) {
1412
+ contactX = nx; contactY = ny; break;
1413
+ }
1414
+ }
1415
+ }
1416
+ if (contactX >= 0) {
1417
+ this.annihilateAntimatter(x, y, contactX, contactY);
1418
+ }
1419
+ continue;
1420
+ }
1421
+
1422
+ // --- Plasma: damages everything around, decays quickly ---
1423
+ if (mat === Material.Plasma) {
1424
+ for (let dy = -1; dy <= 1; dy++) {
1425
+ for (let dx = -1; dx <= 1; dx++) {
1426
+ if (dx === 0 && dy === 0) continue;
1427
+ const nx = x + dx, ny = y + dy;
1428
+ if (nx < 0 || nx >= W || ny < 0 || ny >= H) continue;
1429
+ if (nx < wxMin || nx >= wxMax) continue;
1430
+ const nMat = grid[ny * W + nx] & 0xff;
1431
+ if (nMat !== Material.Empty && nMat !== Material.Plasma && nMat !== Material.Wall) {
1432
+ if (this.rng.random() < 0.3) {
1433
+ grid[ny * W + nx] = packCell(Material.Fire, 10, this.rng.randomShade());
1434
+ }
1435
+ }
1436
+ }
1437
+ }
1438
+ continue;
1439
+ }
1440
+
1441
+ // --- Mystery (???): oscillating reaction ---
1442
+ // Changes color/behavior in a sine wave pattern based on frame count
1443
+ if (mat === Material.Mystery) {
1444
+ const phase = Math.sin(this.frame * 0.1 + x * 0.3 + y * 0.2);
1445
+ if (phase > 0.9 && this.rng.random() < 0.1) {
1446
+ // Emit plasma occasionally (same X, always in strip)
1447
+ if (y > 0 && grid[(y - 1) * W + x] === 0) {
1448
+ grid[(y - 1) * W + x] = packCell(Material.Plasma, 20, this.rng.randomShade());
1449
+ }
1450
+ }
1451
+ if (phase < -0.9 && this.rng.random() < 0.05) {
1452
+ // Absorb nearby particles
1453
+ const MYSTERY_DIRS = [1, 0, -1, 0, 0, 1, 0, -1]; // [dx0,dy0, dx1,dy1, ...]
1454
+ const di = Math.floor(this.rng.random() * 4) * 2;
1455
+ const dx = MYSTERY_DIRS[di], dy = MYSTERY_DIRS[di + 1];
1456
+ const nx = x + dx, ny = y + dy;
1457
+ if (nx >= 0 && nx < W && ny >= 0 && ny < H && nx >= wxMin && nx < wxMax) {
1458
+ const nMat = grid[ny * W + nx] & 0xff;
1459
+ if (nMat !== Material.Empty && nMat !== Material.Wall && nMat !== Material.Mystery) {
1460
+ grid[ny * W + nx] = 0;
1461
+ }
1462
+ }
1463
+ }
1464
+ continue;
1465
+ }
1466
+
1467
+ // --- Gasoline: slowly vaporizes into gas vapor ---
1468
+ if (mat === Material.Gasoline) {
1469
+ if (this.rng.random() < 0.005) {
1470
+ // Same X as source, always in strip
1471
+ if (y > 0 && grid[(y - 1) * W + x] === 0) {
1472
+ grid[(y - 1) * W + x] = packCell(Material.GasVapor, 200, this.rng.randomShade());
1473
+ // Small chance to consume the gasoline
1474
+ if (this.rng.random() < 0.3) grid[idx] = 0;
1475
+ }
1476
+ }
1477
+ continue;
1478
+ }
1479
+
1480
+ // --- Salt + Water → Brine ---
1481
+ if (mat === Material.Salt) {
1482
+ // Single 8-neighbor scan for water + snow
1483
+ let waterNi = -1, hasSnow = false;
1484
+ for (let dy = -1; dy <= 1; dy++) {
1485
+ const ny = y + dy;
1486
+ if (ny < 0 || ny >= H) continue;
1487
+ for (let dx = -1; dx <= 1; dx++) {
1488
+ if (dx === 0 && dy === 0) continue;
1489
+ const nx = x + dx;
1490
+ if (nx < 0 || nx >= W) continue;
1491
+ if (nx < wxMin || nx >= wxMax) continue;
1492
+ const nMat = grid[ny * W + nx] & 0xff;
1493
+ if (nMat === Material.Water) { waterNi = ny * W + nx; }
1494
+ else if (nMat === Material.Snow) { hasSnow = true; }
1495
+ }
1496
+ }
1497
+ if (waterNi >= 0 && this.rng.random() < 0.1) {
1498
+ grid[idx] = packCell(Material.Brine, 0, this.rng.randomShade());
1499
+ grid[waterNi] = packCell(Material.Brine, 0, this.rng.randomShade());
1500
+ continue;
1501
+ }
1502
+ // Salt dissolves snow: 1 salt grain melts up to 10 snow cells nearby
1503
+ if (hasSnow) {
1504
+ // Remove the salt grain
1505
+ grid[idx] = 0;
1506
+ // Dissolve up to 10 snow cells in a radius
1507
+ let dissolved = 0;
1508
+ for (let ry = y - 3; ry <= y + 3 && dissolved < 10; ry++) {
1509
+ for (let rx = x - 3; rx <= x + 3 && dissolved < 10; rx++) {
1510
+ if (rx < 0 || rx >= W || ry < 0 || ry >= H) continue;
1511
+ if (rx < wxMin || rx >= wxMax) continue;
1512
+ const ridx = ry * W + rx;
1513
+ if ((grid[ridx] & 0xff) === Material.Snow) {
1514
+ grid[ridx] = packCell(Material.Water, 0, this.rng.randomShade());
1515
+ dissolved++;
1516
+ }
1517
+ }
1518
+ }
1519
+ continue;
1520
+ }
1521
+ // High temp: salt → molten salt (destructive liquid)
1522
+ if (temp > 1.8 && this.rng.random() < 0.02) {
1523
+ grid[idx] = packCell(Material.MoltenSalt, 0, this.rng.randomShade());
1524
+ continue;
1525
+ }
1526
+ }
1527
+
1528
+ // --- Molten Salt: destroys neighbors, cools to salt in low temp ---
1529
+ if (mat === Material.MoltenSalt) {
1530
+ if (temp < 0.5 && this.rng.random() < 0.05) {
1531
+ grid[idx] = packCell(Material.Salt, 0, this.rng.randomShade());
1532
+ continue;
1533
+ }
1534
+ // Single 8-neighbor scan for water + dry ice
1535
+ let waterNi = -1, dryIceNi = -1;
1536
+ for (let dy = -1; dy <= 1; dy++) {
1537
+ const ny = y + dy;
1538
+ if (ny < 0 || ny >= H) continue;
1539
+ for (let dx = -1; dx <= 1; dx++) {
1540
+ if (dx === 0 && dy === 0) continue;
1541
+ const nx = x + dx;
1542
+ if (nx < 0 || nx >= W) continue;
1543
+ if (nx < wxMin || nx >= wxMax) continue;
1544
+ const nMat = grid[ny * W + nx] & 0xff;
1545
+ if (nMat === Material.Water) { waterNi = ny * W + nx; }
1546
+ else if (nMat === Material.DryIce) { dryIceNi = ny * W + nx; }
1547
+ }
1548
+ }
1549
+ // Contact with water → violent impulse explosion (steam pressure, not destruction)
1550
+ if (waterNi >= 0) {
1551
+ this.applyImpulse(x, y, 6, 100);
1552
+ // Convert water to steam, molten salt cools to salt
1553
+ grid[waterNi] = packCell(Material.Steam, 80, this.rng.randomShade());
1554
+ if (this.rng.random() < 0.3) {
1555
+ grid[idx] = packCell(Material.Salt, 0, this.rng.randomShade());
1556
+ }
1557
+ continue;
1558
+ }
1559
+ // Contact with dry ice → violent impulse explosion (thermal shock)
1560
+ if (dryIceNi >= 0) {
1561
+ this.applyImpulse(x, y, 6, 100);
1562
+ // Consume the dry ice, cool molten salt to salt
1563
+ grid[dryIceNi] = 0;
1564
+ if (this.rng.random() < 0.5) {
1565
+ grid[idx] = packCell(Material.Salt, 0, this.rng.randomShade());
1566
+ }
1567
+ continue;
1568
+ }
1569
+ // Ignite flammable neighbors on contact (molten salt is very hot)
1570
+ for (let dy = -1; dy <= 1; dy++) {
1571
+ for (let dx = -1; dx <= 1; dx++) {
1572
+ if (dx === 0 && dy === 0) continue;
1573
+ const nx = x + dx, ny = y + dy;
1574
+ if (nx < 0 || nx >= W || ny < 0 || ny >= H) continue;
1575
+ if (nx < wxMin || nx >= wxMax) continue;
1576
+ const ni = ny * W + nx;
1577
+ const nMat = grid[ni] & 0xff;
1578
+ if ((MAT_FLAGS[nMat] & MAT_FLAMMABLE) && this.rng.random() < 0.15) {
1579
+ if (nMat === Material.Oil) {
1580
+ // Oil needs air exposure to ignite — molten salt touching
1581
+ // buried oil heats it but can't sustain a flame without oxygen.
1582
+ if (!this.isExposed(nx, ny)) continue;
1583
+ grid[ni] = packCell(Material.BurningOil, MAT_LIFETIME[Material.BurningOil], this.rng.randomShade());
1584
+ } else {
1585
+ grid[ni] = packCell(Material.Fire, 30, this.rng.randomShade());
1586
+ }
1587
+ }
1588
+ }
1589
+ }
1590
+ continue;
1591
+ }
1592
+
1593
+ // --- Concrete Powder + Water → Concrete ---
1594
+ if (mat === Material.ConcretePowder) {
1595
+ let waterNi = -1;
1596
+ for (let dy = -1; dy <= 1; dy++) {
1597
+ const ny = y + dy;
1598
+ if (ny < 0 || ny >= H) continue;
1599
+ for (let dx = -1; dx <= 1; dx++) {
1600
+ if (dx === 0 && dy === 0) continue;
1601
+ const nx = x + dx;
1602
+ if (nx < 0 || nx >= W) continue;
1603
+ if (nx < wxMin || nx >= wxMax) continue;
1604
+ if ((grid[ny * W + nx] & 0xff) === Material.Water) { waterNi = ny * W + nx; break; }
1605
+ }
1606
+ if (waterNi >= 0) break;
1607
+ }
1608
+ if (waterNi >= 0 && this.rng.random() < 0.2) {
1609
+ grid[idx] = packCell(Material.Concrete, 0, this.rng.randomShade());
1610
+ // Consume the water
1611
+ grid[waterNi] = 0;
1612
+ continue;
1613
+ }
1614
+ }
1615
+
1616
+ // --- Snow: melts to water when in contact with hot materials or at
1617
+ // high ambient temperature. Hot neighbors: fire, fusefire, burning
1618
+ // oil, lava, molten salt, plasma. ---
1619
+ if (mat === Material.Snow) {
1620
+ // Single 8-neighbor scan using IS_HOT lookup table
1621
+ let hasHot = false;
1622
+ for (let dy = -1; dy <= 1 && !hasHot; dy++) {
1623
+ const ny = y + dy;
1624
+ if (ny < 0 || ny >= H) continue;
1625
+ for (let dx = -1; dx <= 1; dx++) {
1626
+ if (dx === 0 && dy === 0) continue;
1627
+ const nx = x + dx;
1628
+ if (nx < 0 || nx >= W) continue;
1629
+ if (IS_HOT[grid[ny * W + nx] & 0xff]) { hasHot = true; break; }
1630
+ }
1631
+ }
1632
+ if (hasHot) {
1633
+ // Direct contact with a hot material — melts quickly.
1634
+ if (this.rng.random() < 0.3) {
1635
+ grid[idx] = packCell(Material.Water, 0, this.rng.randomShade());
1636
+ continue;
1637
+ }
1638
+ } else if (temp > 1.3 && this.rng.random() < (temp - 1.3) * 0.05) {
1639
+ // High ambient temperature — melts gradually.
1640
+ grid[idx] = packCell(Material.Water, 0, this.rng.randomShade());
1641
+ continue;
1642
+ }
1643
+ }
1644
+
1645
+ // --- Dry Ice: sublimates into CO2 gas (smoke-like, no water) ---
1646
+ if (mat === Material.DryIce) {
1647
+ if (this.rng.random() < 0.02) {
1648
+ if (y > 0 && grid[(y - 1) * W + x] === 0) {
1649
+ grid[(y - 1) * W + x] = packCell(Material.Smoke, 60, this.rng.randomShade());
1650
+ if (this.rng.random() < 0.5) grid[idx] = 0;
1651
+ }
1652
+ }
1653
+ continue;
1654
+ }
1655
+
1656
+ // --- Liquid Nitrogen: cools neighbors, slowly evaporates into cold vapor ---
1657
+ if (mat === Material.LiquidNitrogen) {
1658
+ // Cool down nearby fire/lava
1659
+ for (let dy = -1; dy <= 1; dy++) {
1660
+ for (let dx = -1; dx <= 1; dx++) {
1661
+ if (dx === 0 && dy === 0) continue;
1662
+ const nx = x + dx, ny = y + dy;
1663
+ if (nx < 0 || nx >= W || ny < 0 || ny >= H) continue;
1664
+ const ni = ny * W + nx;
1665
+ const nMat = grid[ni] & 0xff;
1666
+ if (IS_HOT[nMat]) {
1667
+ grid[ni] = nMat === Material.Lava
1668
+ ? packCell(Material.Stone, 0, this.rng.randomShade())
1669
+ : 0;
1670
+ }
1671
+ }
1672
+ }
1673
+ // Slowly evaporate into ColdVapor — a visible white-ish gas that
1674
+ // doesn't rise (cold vapor is denser than air) and dissipates over
1675
+ // ~30 seconds. Low probability so the liquid phase lasts a while
1676
+ // before converting. Previously converted to Steam (which condensed
1677
+ // to Water) or dissipated directly to empty (invisible).
1678
+ if (this.rng.random() < 0.005) {
1679
+ grid[idx] = packCell(Material.ColdVapor, 255, this.rng.randomShade());
1680
+ }
1681
+ continue;
1682
+ }
1683
+
1684
+ // --- Seed: grows into tree on dirt, progressively from the bottom up ---
1685
+ // lifetime === 0: a normal falling seed. When it lands on dirt/grass,
1686
+ // it plants a Root and spawns a "growing tip" seed above with
1687
+ // lifetime = trunkHeight.
1688
+ // lifetime > 0: a growing tip. Each frame it places TreeWood at its
1689
+ // current position and moves one cell up (lifetime - 1). When
1690
+ // lifetime reaches 1 or the cell above is blocked, it places the
1691
+ // leaf canopy and is consumed — the tree is fully grown.
1692
+ if (mat === Material.Seed) {
1693
+ if (lifetime > 0) {
1694
+ // Growing tip — place TreeWood at current position
1695
+ grid[idx] = packCell(Material.TreeWood, 0, this.rng.randomShade());
1696
+ if (lifetime > 1 && y > 0 && grid[(y - 1) * W + x] === 0) {
1697
+ // Grow one cell higher next frame
1698
+ grid[(y - 1) * W + x] = packCell(Material.Seed, lifetime - 1, this.rng.randomShade());
1699
+ } else {
1700
+ // Reached target height or blocked — grow leaf canopy here
1701
+ this.growCanopy(x, y);
1702
+ }
1703
+ continue;
1704
+ }
1705
+ // Falling seed — check if it landed on dirt/grass
1706
+ if (y + 1 < H) {
1707
+ const belowMat = grid[(y + 1) * W + x] & 0xff;
1708
+ if (belowMat === Material.Dirt || belowMat === Material.Grass) {
1709
+ if (this.rng.random() < 0.05) {
1710
+ // Plant root and start growing upward
1711
+ grid[idx] = packCell(Material.Root, 0, this.rng.randomShade());
1712
+ const trunkHeight = 8 + Math.floor(this.rng.random() * 8);
1713
+ if (y > 0 && grid[(y - 1) * W + x] === 0) {
1714
+ grid[(y - 1) * W + x] = packCell(Material.Seed, trunkHeight, this.rng.randomShade());
1715
+ } else {
1716
+ // Can't grow upward (blocked at base) — just grow a canopy
1717
+ this.growCanopy(x, y);
1718
+ }
1719
+ }
1720
+ }
1721
+ }
1722
+ continue;
1723
+ }
1724
+
1725
+ // --- Grass: spreads on top of dirt ---
1726
+ if (mat === Material.Grass) {
1727
+ if (y + 1 < H && (grid[(y + 1) * W + x] & 0xff) === Material.Dirt) {
1728
+ // Spread sideways on dirt surface
1729
+ if (this.rng.random() < 0.02) {
1730
+ const dir = this.rng.random() < 0.5 ? -1 : 1;
1731
+ const nx = x + dir;
1732
+ if (nx >= 0 && nx < W) {
1733
+ if (y + 1 < H && (grid[(y + 1) * W + nx] & 0xff) === Material.Dirt && grid[y * W + nx] === 0) {
1734
+ grid[y * W + nx] = packCell(Material.Grass, 0, this.rng.randomShade());
1735
+ }
1736
+ }
1737
+ }
1738
+ }
1739
+ continue;
1740
+ }
1741
+
1742
+ // --- Nanobots: randomly move around and slowly eat through materials ---
1743
+ if (mat === Material.Nanobots) {
1744
+ // Pick a random direction to move/eat (inline the 8 dirs to avoid array alloc)
1745
+ const NANOBOT_DIRS = [1, 0, -1, 0, 0, 1, 0, -1, 1, 1, -1, -1, 1, -1, -1, 1];
1746
+ const di = Math.floor(this.rng.random() * 8) * 2;
1747
+ const dx = NANOBOT_DIRS[di], dy = NANOBOT_DIRS[di + 1];
1748
+ const nx = x + dx, ny = y + dy;
1749
+ if (nx >= 0 && nx < W && ny >= 0 && ny < H && nx >= wxMin && nx < wxMax) {
1750
+ const ni = ny * W + nx;
1751
+ const nMat = grid[ni] & 0xff;
1752
+ if (nMat === Material.Empty) {
1753
+ // Move into empty space
1754
+ grid[ni] = packCell(mat, lifetime, flags | FLAG_UPDATED);
1755
+ grid[idx] = 0;
1756
+ } else if (nMat !== Material.Nanobots && nMat !== Material.Wall &&
1757
+ nMat !== Material.Antimatter) {
1758
+ // Eat through the material — slowly destroy it (5% chance per frame)
1759
+ if (this.rng.random() < 0.05) {
1760
+ grid[ni] = 0;
1761
+ }
1762
+ }
1763
+ }
1764
+ continue;
1765
+ }
1766
+
1767
+ // --- Magic Powder: random color flicker, explodes on flesh, decays like fire ---
1768
+ if (mat === Material.MagicPowder) {
1769
+ // Random shade flicker each frame (independent per particle, no spatial pattern)
1770
+ if (this.rng.random() < 0.5) {
1771
+ const newShade = Math.floor(this.rng.random() * 4);
1772
+ grid[idx] = packCell(mat, lifetime, (flags & ~SHADE_MASK) | newShade | FLAG_UPDATED);
1773
+ }
1774
+ // Explodes on contact with flesh
1775
+ let hasFlesh = false;
1776
+ for (let dy = -1; dy <= 1 && !hasFlesh; dy++) {
1777
+ const ny = y + dy;
1778
+ if (ny < 0 || ny >= H) continue;
1779
+ for (let dx = -1; dx <= 1; dx++) {
1780
+ if (dx === 0 && dy === 0) continue;
1781
+ const nx = x + dx;
1782
+ if (nx < 0 || nx >= W) continue;
1783
+ if ((grid[ny * W + nx] & 0xff) === Material.Flesh) { hasFlesh = true; break; }
1784
+ }
1785
+ }
1786
+ if (hasFlesh && this.rng.random() < 0.3) {
1787
+ this.explode(x, y, 4);
1788
+ continue;
1789
+ }
1790
+ // Emit fireflies in a random direction (not always upward)
1791
+ if (this.rng.random() < 0.02) {
1792
+ const dx = Math.floor(this.rng.random() * 3) - 1;
1793
+ const dy = Math.floor(this.rng.random() * 3) - 1;
1794
+ const nx = x + dx, ny = y + dy;
1795
+ if (nx >= 0 && nx < W && ny >= 0 && ny < H && nx >= wxMin && nx < wxMax && grid[ny * W + nx] === 0) {
1796
+ grid[ny * W + nx] = packCell(Material.Fireflies, 255, this.rng.randomShade());
1797
+ }
1798
+ }
1799
+ continue;
1800
+ }
1801
+
1802
+ // --- Popcorn: pops like fireworks near fire/lava/molten salt or high heat ---
1803
+ if (mat === Material.Popcorn) {
1804
+ // Already-popped popcorn (FLAG_POPPED) never pops again — prevents an
1805
+ // infinite creation loop where scattered popcorn re-triggers the pop
1806
+ // reaction and spawns ever more popcorn.
1807
+ if (flags & FLAG_POPPED) continue;
1808
+ // Single 8-neighbor scan using IS_HOT lookup table
1809
+ let hasHot = temp > 1.3;
1810
+ for (let dy = -1; dy <= 1 && !hasHot; dy++) {
1811
+ const ny = y + dy;
1812
+ if (ny < 0 || ny >= H) continue;
1813
+ for (let dx = -1; dx <= 1; dx++) {
1814
+ if (dx === 0 && dy === 0) continue;
1815
+ const nx = x + dx;
1816
+ if (nx < 0 || nx >= W) continue;
1817
+ if (IS_HOT[grid[ny * W + nx] & 0xff]) { hasHot = true; break; }
1818
+ }
1819
+ }
1820
+ if (hasHot && this.rng.random() < 0.3) {
1821
+ // Fireworks pop: radial impulse + scatter popcorn particles outward.
1822
+ // All resulting popcorn (scattered + original) is marked FLAG_POPPED
1823
+ // so it won't pop again — each kernel pops exactly once.
1824
+ this.applyImpulse(x, y, 4, 60);
1825
+ // Scatter popcorn particles in random directions (marked FLAG_POPPED)
1826
+ const POPCORN_DIRS = [-1, -1, 0, -1, 1, -1, -1, 0, 1, 0, -1, 1, 0, 1, 1, 1];
1827
+ for (let di = 0; di < 16; di += 2) {
1828
+ if (this.rng.random() < 0.6) {
1829
+ const nx = x + POPCORN_DIRS[di], ny = y + POPCORN_DIRS[di + 1];
1830
+ if (nx >= 0 && nx < W && ny >= 0 && ny < H && nx >= wxMin && nx < wxMax && grid[ny * W + nx] === 0) {
1831
+ grid[ny * W + nx] = packCell(Material.Popcorn, 0, FLAG_POPPED | this.rng.randomShade());
1832
+ }
1833
+ }
1834
+ }
1835
+ // Mark the original kernel as popped so it doesn't re-pop next frame.
1836
+ grid[idx] = packCell(Material.Popcorn, 0, (flags & ~FLAG_UPDATED) | FLAG_POPPED);
1837
+ // Sometimes launch a popped popcorn particle upward
1838
+ if (this.rng.random() < 0.5 && y > 0 && grid[(y - 1) * W + x] === 0) {
1839
+ grid[(y - 1) * W + x] = packCell(Material.Popcorn, 0, FLAG_POPPED | this.rng.randomShade());
1840
+ }
1841
+ }
1842
+ continue;
1843
+ }
1844
+
1845
+ // --- Dynamite: detonated by fire or fuse, chain-detonates connected sticks ---
1846
+ if (mat === Material.Dynamite) {
1847
+ // Single 8-neighbor scan for fire-class + fuse
1848
+ let hasFireOrFuse = false;
1849
+ for (let dy = -1; dy <= 1 && !hasFireOrFuse; dy++) {
1850
+ const ny = y + dy;
1851
+ if (ny < 0 || ny >= H) continue;
1852
+ for (let dx = -1; dx <= 1; dx++) {
1853
+ if (dx === 0 && dy === 0) continue;
1854
+ const nx = x + dx;
1855
+ if (nx < 0 || nx >= W) continue;
1856
+ const nMat = grid[ny * W + nx] & 0xff;
1857
+ if (IS_FIRE[nMat] || nMat === Material.Fuse) { hasFireOrFuse = true; break; }
1858
+ }
1859
+ }
1860
+ // 100% trigger chance — dynamite is unstable, it should detonate
1861
+ // immediately when lit, not sit there for frames while fire spreads.
1862
+ if (hasFireOrFuse) {
1863
+ this.detonateDynamite(x, y);
1864
+ }
1865
+ continue;
1866
+ }
1867
+
1868
+ // --- C4: only detonates when a burning fuse is adjacent.
1869
+ // C4 itself burns like wax (flammable, slow burn) but does NOT explode from fire alone.
1870
+ // Chain-detonates all connected C4 via flood fill. ---
1871
+ if (mat === Material.C4) {
1872
+ // C4 only detonates when adjacent to FuseFire (fire from a burning fuse).
1873
+ // Regular fire does NOT trigger C4 — only fuse fire does.
1874
+ let fuseBurning = false;
1875
+ for (let dy = -1; dy <= 1 && !fuseBurning; dy++) {
1876
+ for (let dx = -1; dx <= 1 && !fuseBurning; dx++) {
1877
+ if (dx === 0 && dy === 0) continue;
1878
+ const nx = x + dx, ny = y + dy;
1879
+ if (nx < 0 || nx >= W || ny < 0 || ny >= H) continue;
1880
+ if ((grid[ny * W + nx] & 0xff) === Material.FuseFire) { fuseBurning = true; break; }
1881
+ }
1882
+ }
1883
+ if (fuseBurning && this.rng.random() < 0.3) {
1884
+ this.detonateC4(x, y);
1885
+ }
1886
+ continue;
1887
+ }
1888
+
1889
+ // --- Acid: eats adjacent materials, 50% consumed per eat ---
1890
+ // Each frame, acid scans its 8 neighbors for a non-immune material.
1891
+ // If found, it destroys the neighbor and has a 50% chance of being
1892
+ // consumed itself. This gives a ~2:1 ratio (2 material eaten per 1
1893
+ // acid consumed), so 1000 sand + 1000 acid → ~500 acid remaining.
1894
+ // Acid does NOT eat: Empty, Wall, Acid, Base. Base is handled by the
1895
+ // neutralization reaction (base section below).
1896
+ if (mat === Material.Acid) {
1897
+ // First: check for adjacent Base — neutralization takes priority
1898
+ // (acid + base → salt + steam). Fast reaction — 50% chance per frame.
1899
+ let baseNi = -1;
1900
+ for (let dy = -1; dy <= 1 && baseNi < 0; dy++) {
1901
+ const ny = y + dy;
1902
+ if (ny < 0 || ny >= H) continue;
1903
+ for (let dx = -1; dx <= 1; dx++) {
1904
+ if (dx === 0 && dy === 0) continue;
1905
+ const nx = x + dx;
1906
+ if (nx < 0 || nx >= W) continue;
1907
+ if (nx < wxMin || nx >= wxMax) continue;
1908
+ if ((grid[ny * W + nx] & 0xff) === Material.Base) { baseNi = ny * W + nx; break; }
1909
+ }
1910
+ }
1911
+ if (baseNi >= 0 && this.rng.random() < 0.5) {
1912
+ // Neutralization: acid + base → salt + steam
1913
+ grid[idx] = packCell(Material.Salt, 0, this.rng.randomShade());
1914
+ grid[baseNi] = packCell(Material.Steam, 80, this.rng.randomShade());
1915
+ continue;
1916
+ }
1917
+ // Otherwise: eat an adjacent non-immune material
1918
+ if (this.rng.random() < 0.15) {
1919
+ // Pick a random neighbor to eat (avoids eating all 8 at once)
1920
+ const eatDir = Math.floor(this.rng.random() * 8);
1921
+ const EAT_DX = [-1, 0, 1, -1, 1, -1, 0, 1];
1922
+ const EAT_DY = [-1, -1, -1, 0, 0, 1, 1, 1];
1923
+ const nx = x + EAT_DX[eatDir];
1924
+ const ny = y + EAT_DY[eatDir];
1925
+ if (nx >= 0 && nx < W && ny >= 0 && ny < H && nx >= wxMin && nx < wxMax) {
1926
+ const ni = ny * W + nx;
1927
+ const nMat = grid[ni] & 0xff;
1928
+ if (!IS_ACID_IMMUNE[nMat]) {
1929
+ // Eat the neighbor
1930
+ grid[ni] = 0;
1931
+ // 50% chance the acid is consumed by the reaction
1932
+ if (this.rng.random() < 0.5) {
1933
+ grid[idx] = 0;
1934
+ }
1935
+ }
1936
+ }
1937
+ }
1938
+ continue;
1939
+ }
1940
+
1941
+ // --- Base: neutralizes acid on contact (acid + base → salt + steam) ---
1942
+ // Mirrors the acid-side check so the reaction triggers from either
1943
+ // material's perspective. Without this, if only acid checks for base,
1944
+ // a base cell surrounded by acid might not react if none of the acid
1945
+ // cells happen to scan it this frame.
1946
+ if (mat === Material.Base) {
1947
+ let acidNi = -1;
1948
+ for (let dy = -1; dy <= 1 && acidNi < 0; dy++) {
1949
+ const ny = y + dy;
1950
+ if (ny < 0 || ny >= H) continue;
1951
+ for (let dx = -1; dx <= 1; dx++) {
1952
+ if (dx === 0 && dy === 0) continue;
1953
+ const nx = x + dx;
1954
+ if (nx < 0 || nx >= W) continue;
1955
+ if (nx < wxMin || nx >= wxMax) continue;
1956
+ if ((grid[ny * W + nx] & 0xff) === Material.Acid) { acidNi = ny * W + nx; break; }
1957
+ }
1958
+ }
1959
+ if (acidNi >= 0 && this.rng.random() < 0.5) {
1960
+ // Neutralization: base + acid → steam + salt
1961
+ grid[idx] = packCell(Material.Steam, 80, this.rng.randomShade());
1962
+ grid[acidNi] = packCell(Material.Salt, 0, this.rng.randomShade());
1963
+ continue;
1964
+ }
1965
+ }
1966
+
1967
+ // --- Flour: dust explosion when suspended near fire ---
1968
+ if (mat === Material.Flour) {
1969
+ let hasFire = false;
1970
+ for (let dy = -1; dy <= 1 && !hasFire; dy++) {
1971
+ const ny = y + dy;
1972
+ if (ny < 0 || ny >= H) continue;
1973
+ for (let dx = -1; dx <= 1; dx++) {
1974
+ if (dx === 0 && dy === 0) continue;
1975
+ const nx = x + dx;
1976
+ if (nx < 0 || nx >= W) continue;
1977
+ if (IS_FIRE[grid[ny * W + nx] & 0xff]) { hasFire = true; break; }
1978
+ }
1979
+ }
1980
+ if (hasFire && this.rng.random() < 0.15) {
1981
+ this.explode(x, y, 3);
1982
+ }
1983
+ continue;
1984
+ }
1985
+
1986
+ // --- Hydrogen: explodes near fire ---
1987
+ if (mat === Material.Hydrogen) {
1988
+ let hasFire = false;
1989
+ for (let dy = -1; dy <= 1 && !hasFire; dy++) {
1990
+ const ny = y + dy;
1991
+ if (ny < 0 || ny >= H) continue;
1992
+ for (let dx = -1; dx <= 1; dx++) {
1993
+ if (dx === 0 && dy === 0) continue;
1994
+ const nx = x + dx;
1995
+ if (nx < 0 || nx >= W) continue;
1996
+ if (IS_FIRE[grid[ny * W + nx] & 0xff]) { hasFire = true; break; }
1997
+ }
1998
+ }
1999
+ if (hasFire && this.rng.random() < 0.3) {
2000
+ this.explode(x, y, 4);
2001
+ }
2002
+ continue;
2003
+ }
2004
+
2005
+ // --- Glitter: suspends in water, floats in air ---
2006
+ // (handled in tryMove via low gravity — no special reaction needed)
2007
+
2008
+ // --- Fireflies: random flicker + organic flight, spread out (no buoyancy) ---
2009
+ if (mat === Material.Fireflies) {
2010
+ // Flicker: each firefly independently picks a random shade each frame.
2011
+ // Uses cell.lifetime as a per-particle phase seed so neighbors don't sync.
2012
+ if (this.rng.random() < 0.5) {
2013
+ const newShade = Math.floor(this.rng.random() * 4);
2014
+ grid[idx] = packCell(mat, lifetime, (flags & ~SHADE_MASK) | newShade | FLAG_UPDATED);
2015
+ }
2016
+
2017
+ // Flight: pure random walk in all 8 directions + occasional darts.
2018
+ // No gravity/buoyancy — they spread out evenly in all directions.
2019
+ if (this.rng.random() < 0.7) {
2020
+ let dx: number, dy: number;
2021
+ if (this.rng.random() < 0.2) {
2022
+ // Dart: 2-3 cell jump for organic burst movement
2023
+ dx = Math.floor(this.rng.random() * 7) - 3;
2024
+ dy = Math.floor(this.rng.random() * 7) - 3;
2025
+ } else {
2026
+ // Normal: 1-cell step in any of 8 directions (uniform)
2027
+ dx = Math.floor(this.rng.random() * 3) - 1;
2028
+ dy = Math.floor(this.rng.random() * 3) - 1;
2029
+ }
2030
+ const nx = x + dx, ny = y + dy;
2031
+ if (nx >= 0 && nx < W && ny >= 0 && ny < H && grid[ny * W + nx] === 0) {
2032
+ grid[ny * W + nx] = packCell(mat, lifetime, flags | FLAG_UPDATED);
2033
+ grid[idx] = 0;
2034
+ }
2035
+ }
2036
+ continue;
2037
+ }
2038
+
2039
+ // --- Toast: made from bread near fire (future), burns like wood ---
2040
+ // --- Wax: slow burning (handled by combustion + aging) ---
2041
+ // --- Rubber: bouncy (handled in tryMove) ---
2042
+
2043
+ // ===============================================================
2044
+ // Game-specific reactions
2045
+ // Minimal physical reactions; most "mixing" is analyzer-driven
2046
+ // effect-vector math in the game, not cell transforms here.
2047
+ // ===============================================================
2048
+
2049
+ // --- Ether + Fire → EtherealVapor (glowing gas byproduct) ---
2050
+ if (mat === Material.Ether) {
2051
+ let fireNi = -1;
2052
+ for (let dy = -1; dy <= 1 && fireNi < 0; dy++) {
2053
+ const ny = y + dy;
2054
+ if (ny < 0 || ny >= H) continue;
2055
+ for (let dx = -1; dx <= 1; dx++) {
2056
+ if (dx === 0 && dy === 0) continue;
2057
+ const nx = x + dx;
2058
+ if (nx < 0 || nx >= W) continue;
2059
+ if (IS_FIRE[grid[ny * W + nx] & 0xff]) { fireNi = ny * W + nx; break; }
2060
+ }
2061
+ }
2062
+ if (fireNi >= 0 && this.rng.random() < 0.3) {
2063
+ grid[idx] = packCell(Material.EtherealVapor, 120, this.rng.randomShade());
2064
+ grid[fireNi] = packCell(Material.Smoke, 40, this.rng.randomShade());
2065
+ continue;
2066
+ }
2067
+ // High temp: ether evaporates to ethereal vapor
2068
+ if (temp > 1.4 && this.rng.random() < (temp - 1.4) * 0.03) {
2069
+ grid[idx] = packCell(Material.EtherealVapor, 120, this.rng.randomShade());
2070
+ continue;
2071
+ }
2072
+ }
2073
+
2074
+ // --- Sulfur + Water → corrosive reaction (produces AlchemicalSlag slowly) ---
2075
+ if (mat === Material.Sulfur) {
2076
+ let waterNi = -1;
2077
+ for (let dy = -1; dy <= 1 && waterNi < 0; dy++) {
2078
+ const ny = y + dy;
2079
+ if (ny < 0 || ny >= H) continue;
2080
+ for (let dx = -1; dx <= 1; dx++) {
2081
+ if (dx === 0 && dy === 0) continue;
2082
+ const nx = x + dx;
2083
+ if (nx < 0 || nx >= W) continue;
2084
+ if ((grid[ny * W + nx] & 0xff) === Material.Water) { waterNi = ny * W + nx; break; }
2085
+ }
2086
+ }
2087
+ if (waterNi >= 0 && temp > 1.0 && this.rng.random() < 0.04) {
2088
+ grid[idx] = packCell(Material.AlchemicalSlag, 0, this.rng.randomShade());
2089
+ grid[waterNi] = 0; // consume the water
2090
+ continue;
2091
+ }
2092
+ }
2093
+
2094
+ // --- Blood + BoneDust → necrotic reaction (slow, produces Slag) ---
2095
+ if (mat === Material.Blood) {
2096
+ let boneNi = -1;
2097
+ for (let dy = -1; dy <= 1 && boneNi < 0; dy++) {
2098
+ const ny = y + dy;
2099
+ if (ny < 0 || ny >= H) continue;
2100
+ for (let dx = -1; dx <= 1; dx++) {
2101
+ if (dx === 0 && dy === 0) continue;
2102
+ const nx = x + dx;
2103
+ if (nx < 0 || nx >= W) continue;
2104
+ if ((grid[ny * W + nx] & 0xff) === Material.BoneDust) { boneNi = ny * W + nx; break; }
2105
+ }
2106
+ }
2107
+ if (boneNi >= 0 && this.rng.random() < 0.02) {
2108
+ grid[idx] = packCell(Material.AlchemicalSlag, 0, this.rng.randomShade());
2109
+ grid[boneNi] = 0;
2110
+ continue;
2111
+ }
2112
+ }
2113
+
2114
+ // --- MushroomSpores + Water → grows into Plant (mild toxic reaction) ---
2115
+ if (mat === Material.MushroomSpores) {
2116
+ let waterNi = -1;
2117
+ for (let dy = -1; dy <= 1 && waterNi < 0; dy++) {
2118
+ const ny = y + dy;
2119
+ if (ny < 0 || ny >= H) continue;
2120
+ for (let dx = -1; dx <= 1; dx++) {
2121
+ if (dx === 0 && dy === 0) continue;
2122
+ const nx = x + dx;
2123
+ if (nx < 0 || nx >= W) continue;
2124
+ if ((grid[ny * W + nx] & 0xff) === Material.Water) { waterNi = ny * W + nx; break; }
2125
+ }
2126
+ }
2127
+ if (waterNi >= 0 && this.rng.random() < 0.03) {
2128
+ grid[idx] = packCell(Material.Plant, 0, this.rng.randomShade());
2129
+ continue;
2130
+ }
2131
+ }
2132
+
2133
+ // --- PhoenixFeather near fire/lava: glows brighter, resists burning ---
2134
+ // (flammable but long burnTime already set; here we just let it shimmer)
2135
+ // --- DragonScale: very dense, sinks through liquids (handled by density) ---
2136
+
2137
+ // --- LiquidShadow + light source (fire/plasma) → dissipates to smoke ---
2138
+ if (mat === Material.LiquidShadow) {
2139
+ let lightNi = -1;
2140
+ for (let dy = -1; dy <= 1 && lightNi < 0; dy++) {
2141
+ const ny = y + dy;
2142
+ if (ny < 0 || ny >= H) continue;
2143
+ for (let dx = -1; dx <= 1; dx++) {
2144
+ if (dx === 0 && dy === 0) continue;
2145
+ const nx = x + dx;
2146
+ if (nx < 0 || nx >= W) continue;
2147
+ const nMat = grid[ny * W + nx] & 0xff;
2148
+ if (nMat === Material.Fire || nMat === Material.Plasma || nMat === Material.StarShard) {
2149
+ lightNi = ny * W + nx; break;
2150
+ }
2151
+ }
2152
+ }
2153
+ if (lightNi >= 0 && this.rng.random() < 0.08) {
2154
+ grid[idx] = packCell(Material.Smoke, 80, this.rng.randomShade());
2155
+ continue;
2156
+ }
2157
+ }
2158
+
2159
+ // --- VoidEssence + any organic (Blood/Flesh/Plant) → violent annihilation ---
2160
+ if (mat === Material.VoidEssence) {
2161
+ let organicNi = -1;
2162
+ for (let dy = -1; dy <= 1 && organicNi < 0; dy++) {
2163
+ const ny = y + dy;
2164
+ if (ny < 0 || ny >= H) continue;
2165
+ for (let dx = -1; dx <= 1; dx++) {
2166
+ if (dx === 0 && dy === 0) continue;
2167
+ const nx = x + dx;
2168
+ if (nx < 0 || nx >= W) continue;
2169
+ const nMat = grid[ny * W + nx] & 0xff;
2170
+ if (nMat === Material.Blood || nMat === Material.Flesh || nMat === Material.Plant ||
2171
+ nMat === Material.TrollBlood) {
2172
+ organicNi = ny * W + nx; break;
2173
+ }
2174
+ }
2175
+ }
2176
+ if (organicNi >= 0 && this.rng.random() < 0.05) {
2177
+ grid[organicNi] = 0;
2178
+ grid[idx] = packCell(Material.AlchemicalSlag, 0, this.rng.randomShade());
2179
+ this.applyImpulse(x, y, 3, 40);
2180
+ continue;
2181
+ }
2182
+ }
2183
+
2184
+ // --- TimeSand + high temp → brief plasma flash (reality-bending) ---
2185
+ if (mat === Material.TimeSand && temp > 1.6 && this.rng.random() < 0.02) {
2186
+ grid[idx] = packCell(Material.Plasma, 20, this.rng.randomShade());
2187
+ continue;
2188
+ }
2189
+
2190
+ // --- StarShard: emits fireflies when in contact with ether/ethereal vapor ---
2191
+ if (mat === Material.StarShard) {
2192
+ let etherNi = -1;
2193
+ for (let dy = -1; dy <= 1 && etherNi < 0; dy++) {
2194
+ const ny = y + dy;
2195
+ if (ny < 0 || ny >= H) continue;
2196
+ for (let dx = -1; dx <= 1; dx++) {
2197
+ if (dx === 0 && dy === 0) continue;
2198
+ const nx = x + dx;
2199
+ if (nx < 0 || nx >= W) continue;
2200
+ const nMat = grid[ny * W + nx] & 0xff;
2201
+ if (nMat === Material.Ether || nMat === Material.EtherealVapor) {
2202
+ etherNi = ny * W + nx; break;
2203
+ }
2204
+ }
2205
+ }
2206
+ if (etherNi >= 0 && this.rng.random() < 0.04) {
2207
+ // Emit a firefly into a random empty neighbor
2208
+ const rdx = Math.floor(this.rng.random() * 3) - 1;
2209
+ const rdy = Math.floor(this.rng.random() * 3) - 1;
2210
+ const ex = x + rdx, ey = y + rdy;
2211
+ if (ex >= 0 && ex < W && ey >= 0 && ey < H && ex >= wxMin && ex < wxMax && grid[ey * W + ex] === 0) {
2212
+ grid[ey * W + ex] = packCell(Material.Fireflies, 255, this.rng.randomShade());
2213
+ }
2214
+ }
2215
+ }
2216
+
2217
+ // --- Spore: floating mold spore. Germinates into Mold when adjacent to
2218
+ // a food material (wood/plant/leaf/tree wood/root/grass). The spore is
2219
+ // consumed (becomes Mold at its current position). Otherwise it drifts
2220
+ // as a gas (handled by tryMove) and slowly dissipates via lifetime. ---
2221
+ if (mat === Material.Spore) {
2222
+ let hasFood = false;
2223
+ for (let dy = -1; dy <= 1 && !hasFood; dy++) {
2224
+ const ny = y + dy;
2225
+ if (ny < 0 || ny >= H) continue;
2226
+ for (let dx = -1; dx <= 1; dx++) {
2227
+ if (dx === 0 && dy === 0) continue;
2228
+ const nx = x + dx;
2229
+ if (nx < 0 || nx >= W) continue;
2230
+ const nMat = grid[ny * W + nx] & 0xff;
2231
+ if (nMat === Material.Wood || nMat === Material.Plant || nMat === Material.Leaf ||
2232
+ nMat === Material.TreeWood || nMat === Material.Root || nMat === Material.Grass) {
2233
+ hasFood = true; break;
2234
+ }
2235
+ }
2236
+ }
2237
+ if (hasFood && this.rng.random() < 0.2) {
2238
+ grid[idx] = packCell(Material.Mold, 0, this.rng.randomShade());
2239
+ continue;
2240
+ }
2241
+ continue;
2242
+ }
2243
+
2244
+ // --- Mold: grows on wood/plant/leaf/tree wood/root/grass. Spreads very
2245
+ // slowly to an adjacent food cell (converts it to Mold). When no food
2246
+ // remains adjacent, releases a spore cloud into surrounding empty cells
2247
+ // and dies (clears to empty). Static solid (gravityDir=0) kept in the
2248
+ // active list via MAT_HAS_REACTIONS. ---
2249
+ if (mat === Material.Mold) {
2250
+ // Collect food neighbors + empty/gas neighbors (for spore release).
2251
+ let foodNi = -1;
2252
+ let foodCount = 0;
2253
+ let emptyCount = 0;
2254
+ const MOLD_DIRS = [1, 0, -1, 0, 0, 1, 0, -1, 1, 1, -1, -1, 1, -1, -1, 1];
2255
+ for (let di = 0; di < 16; di += 2) {
2256
+ const nx = x + MOLD_DIRS[di], ny = y + MOLD_DIRS[di + 1];
2257
+ if (nx < 0 || nx >= W || ny < 0 || ny >= H) continue;
2258
+ if (nx < wxMin || nx >= wxMax) continue;
2259
+ const ni = ny * W + nx;
2260
+ const nMat = grid[ni] & 0xff;
2261
+ if (nMat === Material.Wood || nMat === Material.Plant || nMat === Material.Leaf ||
2262
+ nMat === Material.TreeWood || nMat === Material.Root || nMat === Material.Grass) {
2263
+ foodCount++;
2264
+ if (foodNi < 0) foodNi = ni;
2265
+ } else if (nMat === Material.Empty || (MAT_FLAGS[nMat] & MAT_GAS)) {
2266
+ emptyCount++;
2267
+ }
2268
+ }
2269
+ if (foodCount > 0) {
2270
+ // Spread slowly: ~2% chance per frame to convert one food neighbor
2271
+ // to Mold. At 60fps that's ~0.8 seconds per spread step — slow
2272
+ // enough to be visible as creeping growth, fast enough to spread
2273
+ // across a food block in reasonable time.
2274
+ if (foodNi >= 0 && this.rng.random() < 0.02) {
2275
+ grid[foodNi] = packCell(Material.Mold, 0, this.rng.randomShade());
2276
+ }
2277
+ } else if (emptyCount > 0) {
2278
+ // No food left — release a spore cloud and die. Spawn a few Spore
2279
+ // particles into surrounding empty/gas cells, then clear self.
2280
+ for (let di = 0; di < 16; di += 2) {
2281
+ if (this.rng.random() < 0.6) {
2282
+ const nx = x + MOLD_DIRS[di], ny = y + MOLD_DIRS[di + 1];
2283
+ if (nx < 0 || nx >= W || ny < 0 || ny >= H) continue;
2284
+ if (nx < wxMin || nx >= wxMax) continue;
2285
+ const ni = ny * W + nx;
2286
+ const nMat = grid[ni] & 0xff;
2287
+ if (nMat === Material.Empty || (MAT_FLAGS[nMat] & MAT_GAS)) {
2288
+ grid[ni] = packCell(Material.Spore, MAT_LIFETIME[Material.Spore], this.rng.randomShade());
2289
+ }
2290
+ }
2291
+ }
2292
+ grid[idx] = 0;
2293
+ }
2294
+ continue;
2295
+ }
2296
+
2297
+ // --- Glitch: randomly swaps places with a neighboring non-empty cell.
2298
+ // Any non-empty neighbor (any material except Empty) is a valid swap
2299
+ // target. The swapped-in material takes the glitch's old position and
2300
+ // vice versa. Falls like a normal solid when it can't swap. ---
2301
+ if (mat === Material.Glitch) {
2302
+ if (this.rng.random() < 0.3) {
2303
+ // Pick a random 8-direction and swap with whatever is there.
2304
+ const GLITCH_DIRS = [1, 0, -1, 0, 0, 1, 0, -1, 1, 1, -1, -1, 1, -1, -1, 1];
2305
+ const di = Math.floor(this.rng.random() * 8) * 2;
2306
+ const nx = x + GLITCH_DIRS[di], ny = y + GLITCH_DIRS[di + 1];
2307
+ if (nx >= 0 && nx < W && ny >= 0 && ny < H && nx >= wxMin && nx < wxMax) {
2308
+ const ni = ny * W + nx;
2309
+ const nPacked = grid[ni];
2310
+ if (nPacked !== 0 && !((nPacked >> 16) & FLAG_UPDATED)) {
2311
+ // Swap: glitch moves to neighbor, neighbor moves to glitch's spot.
2312
+ // Both marked FLAG_UPDATED so neither is reprocessed this frame.
2313
+ grid[ni] = packed | FLAG_UPDATED_BIT;
2314
+ grid[idx] = nPacked | FLAG_UPDATED_BIT;
2315
+ continue;
2316
+ }
2317
+ }
2318
+ }
2319
+ continue;
2320
+ }
2321
+
2322
+ // --- Duplicator: static solid that clones the first material to touch
2323
+ // it. The locked material id is stored in the lifetime field
2324
+ // (0 = not yet locked). Each frame, if locked, it spawns the locked
2325
+ // material into an adjacent empty cell. Does not move, does not react
2326
+ // otherwise (acid-immune, skipped by antimatter). ---
2327
+ if (mat === Material.Duplicator) {
2328
+ const locked = lifetime; // 0 = not locked; otherwise = material id
2329
+ if (locked === 0) {
2330
+ // Not yet locked — scan neighbors for a material to lock onto.
2331
+ // Skip Empty, Wall, other Duplicators, Antimatter (would be
2332
+ // catastrophically destructive), Acid, and Base — the duplicator
2333
+ // should not react with these at all (like Wall). Cloning acid
2334
+ // would spread corrosion through the duplicator; cloning base is
2335
+ // equally undesirable.
2336
+ for (let dy = -1; dy <= 1; dy++) {
2337
+ for (let dx = -1; dx <= 1; dx++) {
2338
+ if (dx === 0 && dy === 0) continue;
2339
+ const nx = x + dx, ny = y + dy;
2340
+ if (nx < 0 || nx >= W || ny < 0 || ny >= H) continue;
2341
+ if (nx < wxMin || nx >= wxMax) continue;
2342
+ const nMat = grid[ny * W + nx] & 0xff;
2343
+ if (nMat !== Material.Empty && nMat !== Material.Wall &&
2344
+ nMat !== Material.Duplicator && nMat !== Material.Antimatter &&
2345
+ nMat !== Material.Acid && nMat !== Material.Base) {
2346
+ // Lock onto this material (store its id in the lifetime field).
2347
+ grid[idx] = packCell(Material.Duplicator, nMat, flags & ~FLAG_UPDATED);
2348
+ break;
2349
+ }
2350
+ }
2351
+ if (((grid[idx] >> 8) & 0xff) !== 0) break;
2352
+ }
2353
+ } else {
2354
+ // Locked — spawn the locked material into an adjacent empty cell,
2355
+ // and slowly propagate the lock to adjacent unlocked Duplicators.
2356
+ // Low chance per frame so it produces a steady trickle, not a flood.
2357
+ if (this.rng.random() < 0.15) {
2358
+ const DUP_DIRS = [1, 0, -1, 0, 0, 1, 0, -1, 1, 1, -1, -1, 1, -1, -1, 1];
2359
+ const di = Math.floor(this.rng.random() * 8) * 2;
2360
+ const nx = x + DUP_DIRS[di], ny = y + DUP_DIRS[di + 1];
2361
+ if (nx >= 0 && nx < W && ny >= 0 && ny < H && nx >= wxMin && nx < wxMax) {
2362
+ const ni = ny * W + nx;
2363
+ if (grid[ni] === 0) {
2364
+ const spawnMat = locked;
2365
+ grid[ni] = packCell(spawnMat, MAT_LIFETIME[spawnMat], this.rng.randomShade());
2366
+ }
2367
+ }
2368
+ }
2369
+ // Propagate lock to adjacent unlocked Duplicators — same low chance
2370
+ // so the lock creeps across a duplicator cluster slowly.
2371
+ if (this.rng.random() < 0.15) {
2372
+ const PROP_DIRS = [1, 0, -1, 0, 0, 1, 0, -1];
2373
+ const di = Math.floor(this.rng.random() * 4) * 2;
2374
+ const nx = x + PROP_DIRS[di], ny = y + PROP_DIRS[di + 1];
2375
+ if (nx >= 0 && nx < W && ny >= 0 && ny < H && nx >= wxMin && nx < wxMax) {
2376
+ const ni = ny * W + nx;
2377
+ const nPacked = grid[ni];
2378
+ // Only lock an adjacent Duplicator that is NOT yet locked
2379
+ // (lifetime === 0). Don't overwrite an existing lock — a
2380
+ // duplicator that already locked onto a different material
2381
+ // keeps its own lock.
2382
+ if ((nPacked & 0xff) === Material.Duplicator &&
2383
+ ((nPacked >> 8) & 0xff) === 0) {
2384
+ grid[ni] = packCell(Material.Duplicator, locked, nPacked >> 16 & 0xff);
2385
+ }
2386
+ }
2387
+ }
2388
+ }
2389
+ continue;
2390
+ }
2391
+
2392
+ // --- Void: a static solid that swallows up any material that touches
2393
+ // it. Each frame, every adjacent non-empty cell (except Wall, other
2394
+ // Void, and Duplicator — those are permanent barriers) is destroyed
2395
+ // (set to empty). The void itself is never consumed. Acid-immune,
2396
+ // antimatter barrier, never falls. MAT_HAS_REACTIONS keeps it active. ---
2397
+ if (mat === Material.Void) {
2398
+ for (let dy = -1; dy <= 1; dy++) {
2399
+ for (let dx = -1; dx <= 1; dx++) {
2400
+ if (dx === 0 && dy === 0) continue;
2401
+ const nx = x + dx, ny = y + dy;
2402
+ if (nx < 0 || nx >= W || ny < 0 || ny >= H) continue;
2403
+ if (nx < wxMin || nx >= wxMax) continue;
2404
+ const ni = ny * W + nx;
2405
+ const nMat = grid[ni] & 0xff;
2406
+ // Swallow everything except Empty, Wall, Void, Duplicator, and
2407
+ // Antimatter (those are permanent — void doesn't eat itself or
2408
+ // other exotic barriers).
2409
+ if (nMat !== Material.Empty && nMat !== Material.Wall &&
2410
+ nMat !== Material.Void && nMat !== Material.Duplicator &&
2411
+ nMat !== Material.Antimatter) {
2412
+ grid[ni] = 0;
2413
+ }
2414
+ }
2415
+ }
2416
+ continue;
2417
+ }
2418
+ }
2419
+ }
2420
+
2421
+ /**
2422
+ * Apply a radial impulse: immediately shoves particles outward from (cx, cy)
2423
+ * and sets per-cell wind fields for ongoing push. This pushes particles away
2424
+ * without destroying them. The fluid grid handles velocity decay via its step().
2425
+ *
2426
+ * Immediate displacement: process rings from outermost to innermost so outer
2427
+ * particles move first, creating space for inner ones. Each particle is
2428
+ * shoved one cell radially outward (displacing liquids/gases via tryShove).
2429
+ *
2430
+ * Wind field: sets radial wind with linear falloff for continued push over
2431
+ * the next several frames.
2432
+ */
2433
+ private applyImpulse(cx: number, cy: number, radius: number, strength: number): void {
2434
+ const W = this.W, H = this.H;
2435
+ const grid = this.grid;
2436
+ const fields = this.fields;
2437
+
2438
+ // --- Phase 1: Immediate radial displacement (outer ring → inner ring) ---
2439
+ // Process from the outermost ring inward so outer particles shove out first,
2440
+ // creating space for inner particles to follow.
2441
+ for (let ring = radius; ring >= 1; ring--) {
2442
+ for (let y = cy - ring; y <= cy + ring; y++) {
2443
+ for (let x = cx - ring; x <= cx + ring; x++) {
2444
+ // Only process cells on the current ring boundary (skip inner cells)
2445
+ const dx = x - cx, dy = y - cy;
2446
+ const dist = Math.sqrt(dx * dx + dy * dy);
2447
+ if (Math.round(dist) !== ring) continue;
2448
+ if (x < 0 || x >= W || y < 0 || y >= H) continue;
2449
+
2450
+ const idx = y * W + x;
2451
+ const packed = grid[idx];
2452
+ if (packed === 0) continue;
2453
+ const flags = (packed >> 16) & 0xff;
2454
+ if (flags & FLAG_UPDATED) continue;
2455
+ // Don't move walls or static solids
2456
+ const mat = packed & 0xff;
2457
+ if (mat === Material.Wall || mat === Material.Stone) continue;
2458
+
2459
+ // Shove one step radially outward
2460
+ const stepX = dx === 0 ? 0 : dx > 0 ? 1 : -1;
2461
+ const stepY = dy === 0 ? 0 : dy > 0 ? 1 : -1;
2462
+ this.tryShove(x, y, x + stepX, y + stepY, packed);
2463
+ }
2464
+ }
2465
+ }
2466
+
2467
+ // --- Phase 2: Apply impulse to the fluid grid for ongoing push ---
2468
+ // The fluid grid handles pressure relaxation and velocity decay, replacing
2469
+ // the old per-cell wind field system.
2470
+ this.fluid.applyImpulse(cx, cy, radius, 0, 0); // pressure impulse for shockwave
2471
+ // Radial velocity impulse — each cell gets pushed outward
2472
+ for (let y = cy - radius; y <= cy + radius; y++) {
2473
+ for (let x = cx - radius; x <= cx + radius; x++) {
2474
+ if (x < 0 || x >= W || y < 0 || y >= H) continue;
2475
+ const dx = x - cx, dy = y - cy;
2476
+ const dist = Math.sqrt(dx * dx + dy * dy);
2477
+ if (dist > radius || dist < 0.5) continue;
2478
+ const falloff = 1 - dist / radius;
2479
+ const mag = strength * falloff * 0.02; // scale to fluid grid velocity units
2480
+ const ux = dx / dist, uy = dy / dist;
2481
+ this.fluid.applyImpulse(x, y, 1, ux * mag, uy * mag);
2482
+ }
2483
+ }
2484
+ }
2485
+
2486
+ /** Create an explosion: fire + smoke in a radius */
2487
+ private explode(cx: number, cy: number, radius: number): void {
2488
+ const W = this.W, H = this.H;
2489
+ const grid = this.grid;
2490
+ const wxMin = this.writeXMin, wxMax = this.writeXMax;
2491
+ for (let y = cy - radius; y <= cy + radius; y++) {
2492
+ for (let x = cx - radius; x <= cx + radius; x++) {
2493
+ const dist = (x - cx) * (x - cx) + (y - cy) * (y - cy);
2494
+ if (dist > radius * radius) continue;
2495
+ if (x < 0 || x >= W || y < 0 || y >= H) continue;
2496
+ // Strip mode write guard — explosions don't cross strip boundaries.
2497
+ // The coordinator's boundary cleanup re-runs explosions that span strips.
2498
+ if (x < wxMin || x >= wxMax) continue;
2499
+ const mat = grid[y * W + x] & 0xff;
2500
+ if (mat === Material.Wall) continue;
2501
+ // Don't destroy C4 or Dynamite in the blast — let detonateC4 /
2502
+ // detonateDynamite handle chain reactions via flood-fill.
2503
+ if (mat === Material.C4 || mat === Material.Dynamite) continue;
2504
+ if (dist < radius * radius * 0.3) {
2505
+ // Core: fire
2506
+ grid[y * W + x] = packCell(Material.Fire, 20, this.rng.randomShade());
2507
+ } else {
2508
+ // Outer: smoke or empty
2509
+ if (this.rng.random() < 0.5) {
2510
+ grid[y * W + x] = packCell(Material.Smoke, 60, this.rng.randomShade());
2511
+ } else {
2512
+ grid[y * W + x] = 0;
2513
+ }
2514
+ }
2515
+ }
2516
+ }
2517
+
2518
+ // --- Spawn explosion particles ---
2519
+ // Projectile particles: flying debris in all directions
2520
+ const numProjectiles = Math.min(8, 3 + radius);
2521
+ for (let i = 0; i < numProjectiles; i++) {
2522
+ const angle = (i / numProjectiles) * Math.PI * 2 + this.rng.random() * 0.5;
2523
+ const speed = 1 + this.rng.random() * 2;
2524
+ this.particles.spawn(
2525
+ cx, cy,
2526
+ PARTICLE_TYPES.PROJECTILE,
2527
+ 0xffaa3300, // orange-red
2528
+ 1 + this.rng.random(),
2529
+ Math.cos(angle) * speed,
2530
+ Math.sin(angle) * speed,
2531
+ 20 + Math.floor(this.rng.random() * 20),
2532
+ );
2533
+ }
2534
+ // Circle particles: expanding blast rings
2535
+ const numCircles = Math.min(3, 1 + Math.floor(radius / 2));
2536
+ for (let i = 0; i < numCircles; i++) {
2537
+ this.particles.spawn(
2538
+ cx, cy,
2539
+ PARTICLE_TYPES.CIRCLE,
2540
+ 0xffff6600, // bright orange
2541
+ radius * 0.5,
2542
+ 0, 0,
2543
+ 15 + i * 5,
2544
+ );
2545
+ }
2546
+ }
2547
+
2548
+ /** Annihilate antimatter: flood-fill all 8-connected non-empty cells from
2549
+ * the antimatter cluster at (ax, ay) through the contacted material at
2550
+ * (cx, cy). Destroys every connected non-empty cell except Wall and
2551
+ * Duplicator (which act as barriers and are left intact). Empty cells
2552
+ * stop the flood-fill. A fire explosion marks the contact point.
2553
+ *
2554
+ * This implements "eliminates all contiguous antimatter and all contiguous
2555
+ * other material, except empty" — the entire connected non-empty region
2556
+ * touching the antimatter cluster is annihilated. */
2557
+ private annihilateAntimatter(ax: number, ay: number, cx: number, cy: number): void {
2558
+ const W = this.W, H = this.H;
2559
+ const grid = this.grid;
2560
+ const visited = this.visitedAntimatterFrame;
2561
+ const frame = this.frame;
2562
+ const wxMin = this.writeXMin, wxMax = this.writeXMax;
2563
+ // Flood-fill from the antimatter seed cell through all 8-connected
2564
+ // non-empty cells. Wall and Duplicator are barriers (skipped, not
2565
+ // destroyed). Empty cells are not traversed.
2566
+ const stack: number[] = [ay * W + ax];
2567
+ const cells: number[] = [];
2568
+ while (stack.length > 0) {
2569
+ const idx = stack.pop()!;
2570
+ if (visited[idx] === frame) continue;
2571
+ visited[idx] = frame;
2572
+ const px = idx % W;
2573
+ const py = (idx / W) | 0;
2574
+ const m = grid[idx] & 0xff;
2575
+ // Stop at empty, wall, duplicator, or void (barriers)
2576
+ if (m === Material.Empty || m === Material.Wall || m === Material.Duplicator ||
2577
+ m === Material.Void) continue;
2578
+ cells.push(idx);
2579
+ // Push 8 neighbors (only those within write bounds — cross-strip
2580
+ // annihilation is handled by the coordinator's boundary cleanup)
2581
+ for (let dy = -1; dy <= 1; dy++) {
2582
+ for (let dx = -1; dx <= 1; dx++) {
2583
+ if (dx === 0 && dy === 0) continue;
2584
+ const nx = px + dx, ny = py + dy;
2585
+ if (nx < 0 || nx >= W || ny < 0 || ny >= H) continue;
2586
+ if (nx < wxMin || nx >= wxMax) continue;
2587
+ stack.push(ny * W + nx);
2588
+ }
2589
+ }
2590
+ }
2591
+ // Annihilate every cell in the connected region.
2592
+ for (let i = 0; i < cells.length; i++) {
2593
+ grid[cells[i]] = 0;
2594
+ }
2595
+ // Fire explosion at the contact point (visual + ignites nearby flammables).
2596
+ this.explode(cx, cy, 3);
2597
+ }
2598
+
2599
+ /** Detonate C4 at (cx, cy) and flood-fill all connected C4 for chain reaction */
2600
+ private detonateC4(cx: number, cy: number): void {
2601
+ const W = this.W, H = this.H;
2602
+ const grid = this.grid;
2603
+ // Collect all connected C4 cells (8-connected flood fill).
2604
+ // Use a frame-tagged visited array instead of allocating a Set every call.
2605
+ // We use a dedicated visitedC4Frame array to avoid clobbering the
2606
+ // combustion pass's visitedFrame.
2607
+ const visited = this.visitedC4Frame;
2608
+ const frame = this.frame;
2609
+ const stack: number[] = [cy * W + cx];
2610
+ const c4Cells: number[] = [];
2611
+ while (stack.length > 0) {
2612
+ const idx = stack.pop()!;
2613
+ if (visited[idx] === frame) continue;
2614
+ visited[idx] = frame;
2615
+ const px = idx % W;
2616
+ const py = (idx / W) | 0;
2617
+ if ((grid[idx] & 0xff) !== Material.C4) continue;
2618
+ c4Cells.push(idx);
2619
+ // Check 8 neighbors
2620
+ for (let dy = -1; dy <= 1; dy++) {
2621
+ for (let dx = -1; dx <= 1; dx++) {
2622
+ if (dx === 0 && dy === 0) continue;
2623
+ const nx = px + dx, ny = py + dy;
2624
+ if (nx < 0 || nx >= W || ny < 0 || ny >= H) continue;
2625
+ stack.push(ny * W + nx);
2626
+ }
2627
+ }
2628
+ }
2629
+ // Explode each C4 cell
2630
+ for (let i = 0; i < c4Cells.length; i++) {
2631
+ const idx = c4Cells[i];
2632
+ const px = idx % W;
2633
+ const py = (idx / W) | 0;
2634
+ this.explode(px, py, 8);
2635
+ }
2636
+ }
2637
+
2638
+ /** Detonate Dynamite at (cx, cy) and flood-fill all connected Dynamite for
2639
+ * chain reaction. Collects all 8-connected Dynamite cells, explodes once at
2640
+ * the center of mass (radius scaled to the cluster size), then consumes all
2641
+ * cells. Exploding once instead of per-cell avoids massive lag on large
2642
+ * dynamite clusters. */
2643
+ private detonateDynamite(cx: number, cy: number): void {
2644
+ const W = this.W, H = this.H;
2645
+ const grid = this.grid;
2646
+ const visited = this.visitedC4Frame;
2647
+ const frame = this.frame;
2648
+ const stack: number[] = [cy * W + cx];
2649
+ const dynoCells: number[] = [];
2650
+ while (stack.length > 0) {
2651
+ const idx = stack.pop()!;
2652
+ if (visited[idx] === frame) continue;
2653
+ visited[idx] = frame;
2654
+ const px = idx % W;
2655
+ const py = (idx / W) | 0;
2656
+ if ((grid[idx] & 0xff) !== Material.Dynamite) continue;
2657
+ dynoCells.push(idx);
2658
+ for (let dy = -1; dy <= 1; dy++) {
2659
+ for (let dx = -1; dx <= 1; dx++) {
2660
+ if (dx === 0 && dy === 0) continue;
2661
+ const nx = px + dx, ny = py + dy;
2662
+ if (nx < 0 || nx >= W || ny < 0 || ny >= H) continue;
2663
+ stack.push(ny * W + nx);
2664
+ }
2665
+ }
2666
+ }
2667
+ if (dynoCells.length === 0) return;
2668
+
2669
+ // Explode once at the center of mass. Radius scales with cluster size
2670
+ // (6 base + 1 per extra stick, capped at 12) so a single big blast
2671
+ // covers the whole cluster — much cheaper than N separate explosions.
2672
+ let sumX = 0, sumY = 0;
2673
+ for (let i = 0; i < dynoCells.length; i++) {
2674
+ sumX += dynoCells[i] % W;
2675
+ sumY += (dynoCells[i] / W) | 0;
2676
+ }
2677
+ const centerX = Math.round(sumX / dynoCells.length);
2678
+ const centerY = Math.round(sumY / dynoCells.length);
2679
+ const radius = Math.min(12, 6 + dynoCells.length - 1);
2680
+ this.explode(centerX, centerY, radius);
2681
+
2682
+ // Consume all dynamite cells — explode() skips them, so we must destroy
2683
+ // them explicitly. Convert to fire so the blast looks right.
2684
+ for (let i = 0; i < dynoCells.length; i++) {
2685
+ grid[dynoCells[i]] = packCell(Material.Fire, 20, this.rng.randomShade());
2686
+ }
2687
+ }
2688
+
2689
+ /** Grow a leaf canopy around (x, y) — the top of the tree trunk.
2690
+ * Called when the growing seed tip reaches its target height or is blocked. */
2691
+ private growCanopy(x: number, y: number): void {
2692
+ const W = this.W, H = this.H;
2693
+ const grid = this.grid;
2694
+ const wxMin = this.writeXMin, wxMax = this.writeXMax;
2695
+ const canopyRadius = 3 + Math.floor(this.rng.random() * 2);
2696
+ for (let dy = -canopyRadius; dy <= 0; dy++) {
2697
+ for (let dx = -canopyRadius; dx <= canopyRadius; dx++) {
2698
+ const lx = x + dx, ly = y + dy - canopyRadius;
2699
+ if (lx < 0 || lx >= W || ly < 0 || ly >= H) continue;
2700
+ if (lx < wxMin || lx >= wxMax) continue;
2701
+ const dist = dx * dx + dy * dy;
2702
+ if (dist > canopyRadius * canopyRadius) continue;
2703
+ if (grid[ly * W + lx] !== 0) continue;
2704
+ if (this.rng.random() < 0.7) {
2705
+ grid[ly * W + lx] = packCell(Material.Leaf, 0, this.rng.randomShade());
2706
+ }
2707
+ }
2708
+ }
2709
+ }
2710
+
2711
+ private findNeighbor(x: number, y: number, mat: number): { x: number; y: number } | null {
2712
+ const W = this.W, H = this.H;
2713
+ const grid = this.grid;
2714
+ for (let dy = -1; dy <= 1; dy++) {
2715
+ for (let dx = -1; dx <= 1; dx++) {
2716
+ if (dx === 0 && dy === 0) continue;
2717
+ const nx = x + dx, ny = y + dy;
2718
+ if (nx < 0 || nx >= W || ny < 0 || ny >= H) continue;
2719
+ if ((grid[ny * W + nx] & 0xff) === mat) return { x: nx, y: ny };
2720
+ }
2721
+ }
2722
+ return null;
2723
+ }
2724
+
2725
+ /**
2726
+ * Check if a cell is "exposed" — has at least one neighbor that is empty
2727
+ * or a gas (so fire/heat can reach it). Used to prevent buried oil from
2728
+ * igniting when it's encased in solid/liquid material.
2729
+ */
2730
+ private isExposed(x: number, y: number): boolean {
2731
+ const W = this.W, H = this.H;
2732
+ const grid = this.grid;
2733
+ for (let dy = -1; dy <= 1; dy++) {
2734
+ for (let dx = -1; dx <= 1; dx++) {
2735
+ if (dx === 0 && dy === 0) continue;
2736
+ const nx = x + dx, ny = y + dy;
2737
+ if (nx < 0 || nx >= W || ny < 0 || ny >= H) continue;
2738
+ const nMat = grid[ny * W + nx] & 0xff;
2739
+ if (nMat === Material.Empty) return true;
2740
+ if (MAT_FLAGS[nMat] & MAT_GAS) return true;
2741
+ }
2742
+ }
2743
+ return false;
2744
+ }
2745
+
2746
+ private applyCombustion(): void {
2747
+ const W = this.W, H = this.H;
2748
+ const grid = this.grid;
2749
+ const fields = this.fields;
2750
+ const active = this.activeCells;
2751
+ const count = this.activeCount;
2752
+ // Strip mode write bounds
2753
+ const wxMin = this.writeXMin, wxMax = this.writeXMax;
2754
+
2755
+ // Snapshot which cells are fire/lava at the start of this pass.
2756
+ // Only these cells can spread fire — newly ignited cells wait until next frame.
2757
+ // This prevents instant cascade through fuse/gunpowder chains in a single pass.
2758
+ // (Reused per-frame buffer — avoids a Uint8Array allocation every step.)
2759
+ // Build from the active list instead of scanning the full grid.
2760
+ const fireSources = this.fireSources;
2761
+ // Only clear the entries we'll use (the active cells). The rest are already 0
2762
+ // from the previous frame's clear, but to be safe we clear all active entries.
2763
+ for (let a = 0; a < count; a++) fireSources[active[a]] = 0;
2764
+ let fireCount = 0;
2765
+ for (let a = 0; a < count; a++) {
2766
+ const idx = active[a];
2767
+ const m = grid[idx] & 0xff;
2768
+ if (m === Material.Fire || m === Material.FuseFire || m === Material.BurningOil || m === Material.Lava) {
2769
+ fireSources[idx] = 1;
2770
+ fireCount++;
2771
+ }
2772
+ }
2773
+ // Early-out: no fire sources means no combustion spread, fuse burn, or
2774
+ // burning-oil passes needed.
2775
+ if (fireCount === 0) return;
2776
+
2777
+ // --- Fire spread pass ---
2778
+ // Iterate only the fire-source cells (from the active list) instead of
2779
+ // scanning the full grid.
2780
+ for (let a = 0; a < count; a++) {
2781
+ const idx = active[a];
2782
+ if (!fireSources[idx]) continue;
2783
+ const srcPacked = grid[idx];
2784
+ const srcMat = srcPacked & 0xff;
2785
+ // Spark-flagged fire (visual flames emitted by BurningOil/fuse) does NOT
2786
+ // ignite oil — the burning-oil pass handles controlled oil-to-oil spread.
2787
+ // Without this, the visual flames would ignite adjacent oil in all 8
2788
+ // directions (including diagonals), causing the fire to "burst" outward
2789
+ // instead of creeping slowly from the ignition site.
2790
+ const srcIsSpark = srcMat === Material.Fire && ((srcPacked >> 16) & FLAG_SPARK) !== 0;
2791
+ const x = idx % W;
2792
+ const y = (idx / W) | 0;
2793
+ for (let dy = -1; dy <= 1; dy++) {
2794
+ for (let dx = -1; dx <= 1; dx++) {
2795
+ if (dx === 0 && dy === 0) continue;
2796
+ const nx = x + dx, ny = y + dy;
2797
+ if (nx < 0 || nx >= W || ny < 0 || ny >= H) continue;
2798
+ if (nx < wxMin || nx >= wxMax) continue;
2799
+ const ni = ny * W + nx;
2800
+ const nMat = grid[ni] & 0xff;
2801
+ if (!(MAT_FLAGS[nMat] & MAT_FLAMMABLE)) continue;
2802
+ if (nMat === Material.Gunpowder) {
2803
+ grid[ni] = packCell(Material.Fire, 15, this.rng.randomShade());
2804
+ continue;
2805
+ }
2806
+ // Gas vapor and hydrogen: explosive
2807
+ if (nMat === Material.GasVapor || nMat === Material.Hydrogen) {
2808
+ grid[ni] = packCell(Material.Fire, 20, this.rng.randomShade());
2809
+ this.explode(nx, ny, 3);
2810
+ continue;
2811
+ }
2812
+ // Flour: dust explosion
2813
+ if (nMat === Material.Flour) {
2814
+ this.explode(nx, ny, 3);
2815
+ continue;
2816
+ }
2817
+ // Dynamite: chain detonate via flood-fill (same as applySpecialReactions)
2818
+ if (nMat === Material.Dynamite) {
2819
+ this.detonateDynamite(nx, ny);
2820
+ continue;
2821
+ }
2822
+ const baseChance = srcMat === Material.Lava ? 0.1 : 0.08;
2823
+ // Rubber is hard to ignite — low continual burn/spread chance.
2824
+ // Oil is a slow-burning liquid fuel: low per-frame spread chance
2825
+ // so the flame creeps gradually across the surface rather than
2826
+ // flashing instantly. BurningOil→oil spread is handled by the
2827
+ // dedicated burning-oil pass below (with decay-linked chance),
2828
+ // so skip oil neighbors here when the source is BurningOil.
2829
+ // Spark-flagged fire (visual flames from BurningOil) also skips
2830
+ // oil — only "real" fire sources (lava, regular fire from burning
2831
+ // wood/gunpowder, etc.) can ignite oil via this pass.
2832
+ if ((srcMat === Material.BurningOil || srcIsSpark) && nMat === Material.Oil) continue;
2833
+ // Oil needs air exposure to ignite — fire/lava touching buried oil
2834
+ // heats it but can't sustain a flame without oxygen.
2835
+ if (nMat === Material.Oil && !this.isExposed(nx, ny)) continue;
2836
+ const matMult =
2837
+ nMat === Material.Rubber ? 0.3 :
2838
+ nMat === Material.Oil ? 0.3 :
2839
+ nMat === Material.Wax ? 2.5 : // wax spreads fire extremely fast
2840
+ 1.0;
2841
+ // Per-cell temperature scales fire spread rate (inline field read)
2842
+ const nTemp = fields[ni * 4 + FIELD.TEMP] / 128;
2843
+ const chance = baseChance * nTemp * matMult;
2844
+ if (this.rng.random() < chance) {
2845
+ // Fuse gets FuseFire (yellow, stays put, deterministic spread)
2846
+ if (nMat === Material.Fuse) {
2847
+ grid[ni] = packCell(Material.FuseFire, 15, this.rng.randomShade());
2848
+ } else if (nMat === Material.Oil) {
2849
+ // Oil → BurningOil (stays put, slow decay, slow spread)
2850
+ grid[ni] = packCell(Material.BurningOil, MAT_LIFETIME[Material.BurningOil], this.rng.randomShade());
2851
+ } else if (nMat === Material.Wax) {
2852
+ // Wax burns very slowly — long fire lifetime + FLAG_ANCHORED so
2853
+ // the flame stays put on the wax surface and keeps spreading.
2854
+ grid[ni] = packCell(Material.Fire, 200, FLAG_ANCHORED | this.rng.randomShade());
2855
+ } else {
2856
+ grid[ni] = packCell(Material.Fire, 30, this.rng.randomShade());
2857
+ }
2858
+ }
2859
+ }
2860
+ }
2861
+ }
2862
+
2863
+ // --- Deterministic fuse burn ---
2864
+ // FuseFire deterministically ignites ALL adjacent fuse cells when its
2865
+ // lifetime drops to the threshold. The fire has a short lifetime
2866
+ // (FUSE_FIRE_LIFETIME) so the flame trail is brief. The burn speed is
2867
+ // controlled by the lifetime: each cell burns for FUSE_FIRE_LIFETIME frames
2868
+ // before passing the flame onward.
2869
+ //
2870
+ // FuseFire is a separate material (Material.FuseFire) so it's reliably
2871
+ // identified even at the end of the trail where all adjacent fuse has been
2872
+ // consumed. No flag bits needed.
2873
+ //
2874
+ // To prevent single-frame cascades, we use a frame-tagged visited array
2875
+ // (this.visitedFrame) instead of allocating a Set every step. A cell is
2876
+ // "visited this frame" when visitedFrame[i] === this.frame.
2877
+ const FUSE_FIRE_LIFETIME = 15;
2878
+ const FUSE_SPREAD_THRESHOLD = 3;
2879
+ const frame = this.frame;
2880
+ const visitedFrame = this.visitedFrame;
2881
+ for (let a = 0; a < count; a++) {
2882
+ const idx = active[a];
2883
+ const packed = grid[idx];
2884
+ const mat = packed & 0xff;
2885
+ if (mat !== Material.FuseFire) continue;
2886
+ const lifetime = (packed >> 8) & 0xff;
2887
+ if (lifetime === 0) continue;
2888
+ const x = idx % W;
2889
+ const y = (idx / W) | 0;
2890
+
2891
+ // Emit sparks every frame — small fire particles that fly upward with
2892
+ // random spread. Sparks use Material.Fire (red) so they're visually
2893
+ // distinct from the yellow fuse fire and fly freely (FuseFire is
2894
+ // anchored, Fire is not).
2895
+ for (let s = 0; s < 3; s++) {
2896
+ if (this.rng.random() < 0.5) {
2897
+ const sx = x + Math.floor(this.rng.random() * 3) - 1;
2898
+ const sy = y - 1 - Math.floor(this.rng.random() * 2); // 1-2 cells above
2899
+ if (sx >= 0 && sx < W && sy >= 0 && sy < H && sx >= wxMin && sx < wxMax && grid[sy * W + sx] === 0) {
2900
+ // Sparks: very short lifetime, expire to empty (not smoke) so they
2901
+ // don't accumulate and suffocate the burn when going upward.
2902
+ grid[sy * W + sx] = packCell(Material.Fire, 6, this.rng.randomShade() | FLAG_SPARK);
2903
+ // Upward impulse only — sparks rise straight up (wind is skipped for
2904
+ // spark fire in tryMove). The impulse still pushes neighboring
2905
+ // smoke/gas upward via the fluid grid. No horizontal drift: a fixed
2906
+ // at-birth drift caused sparks to streak diagonally up-left/up-right.
2907
+ this.fluid.applyImpulse(sx, sy, 2, 0, -0.8); // strong upward
2908
+ }
2909
+ }
2910
+ }
2911
+
2912
+ // Spread to adjacent fuse cells only when near end of lifetime
2913
+ if (lifetime > FUSE_SPREAD_THRESHOLD) continue;
2914
+ for (let dy = -1; dy <= 1; dy++) {
2915
+ for (let dx = -1; dx <= 1; dx++) {
2916
+ if (dx === 0 && dy === 0) continue;
2917
+ const nx = x + dx, ny = y + dy;
2918
+ if (nx < 0 || nx >= W || ny < 0 || ny >= H) continue;
2919
+ if (nx < wxMin || nx >= wxMax) continue;
2920
+ const ni = ny * W + nx;
2921
+ if (visitedFrame[ni] === frame) continue;
2922
+ if ((grid[ni] & 0xff) === Material.Fuse) {
2923
+ grid[ni] = packCell(Material.FuseFire, FUSE_FIRE_LIFETIME, this.rng.randomShade());
2924
+ visitedFrame[ni] = frame;
2925
+ }
2926
+ }
2927
+ }
2928
+ }
2929
+
2930
+ // --- Burning oil pass ---
2931
+ // BurningOil flows like a liquid (gravity: 1, density: 0.8) on the oil
2932
+ // surface. Each frame it:
2933
+ // 1. Emits normal Fire particles upward (visual flames that rise and
2934
+ // decay to smoke like regular fire).
2935
+ // 2. Slowly spreads to adjacent oil. The spread chance scales with
2936
+ // decay progress — early in its life it barely spreads, but as it
2937
+ // burns down the chance increases, so the fire creeps outward
2938
+ // gradually rather than flashing instantly.
2939
+ // The ignitedOil de-dup uses the same frame-tagged visitedFrame array as
2940
+ // the fuse pass above. Safe to share: the two passes target different
2941
+ // materials (Fuse vs Oil), so a cell marked in one pass is guarded out by
2942
+ // the material-type check in the other before the visited check runs.
2943
+ const BURNING_OIL_LIFETIME = MAT_LIFETIME[Material.BurningOil];
2944
+ for (let a = 0; a < count; a++) {
2945
+ const idx = active[a];
2946
+ const packed = grid[idx];
2947
+ const mat = packed & 0xff;
2948
+ if (mat !== Material.BurningOil) continue;
2949
+ const lifetime = (packed >> 8) & 0xff;
2950
+ const x = idx % W;
2951
+ const y = (idx / W) | 0;
2952
+
2953
+ // Emit fire particles above: visual flames that rise from the burning
2954
+ // oil surface with slight horizontal variation for a natural look.
2955
+ // Marked with FLAG_SPARK so they:
2956
+ // 1. Expire to empty (not smoke) — smoke would accumulate and
2957
+ // suffocate the burn. The BurningOil itself already produces smoke
2958
+ // when it decays.
2959
+ // 2. Are skipped by the fire spread pass for oil ignition (see below)
2960
+ // — the burning-oil pass handles controlled oil-to-oil spread.
2961
+ // Without this, the visual flames would ignite adjacent oil in all
2962
+ // directions (including diagonals), causing the fire to "burst"
2963
+ // instead of creeping slowly outward.
2964
+ // 3. Skip diagonal rising in tryMove (isSparkFire) — they rise straight
2965
+ // up or drift horizontally via gas drift, but don't shoot off in
2966
+ // diagonal directions.
2967
+ for (let s = 0; s < 2; s++) {
2968
+ if (this.rng.random() < 0.4) {
2969
+ const sx = x + Math.floor(this.rng.random() * 3) - 1; // -1, 0, or +1
2970
+ const sy = y - 1 - Math.floor(this.rng.random() * 2); // 1-2 cells above
2971
+ if (sx >= 0 && sx < W && sy >= 0 && sy < H && sx >= wxMin && sx < wxMax && grid[sy * W + sx] === 0) {
2972
+ grid[sy * W + sx] = packCell(Material.Fire, 20, this.rng.randomShade() | FLAG_SPARK);
2973
+ // Upward impulse only — sparks rise straight up (wind is skipped for
2974
+ // spark fire in tryMove). The impulse still pushes neighboring
2975
+ // smoke/gas upward via the fluid grid. No horizontal drift: a fixed
2976
+ // at-birth drift caused sparks to streak diagonally up-left/up-right.
2977
+ this.fluid.applyImpulse(sx, sy, 2, 0, -0.5);
2978
+ }
2979
+ }
2980
+ }
2981
+
2982
+ // Spread to adjacent oil: slow chance that increases with decay.
2983
+ // decayProgress goes 0→1 as lifetime goes 200→0.
2984
+ const decayProgress = 1 - (lifetime / BURNING_OIL_LIFETIME);
2985
+ const spreadChance = 0.02 + 0.04 * decayProgress; // 2% early → 6% late
2986
+ for (let dy = -1; dy <= 1; dy++) {
2987
+ for (let dx = -1; dx <= 1; dx++) {
2988
+ if (dx === 0 && dy === 0) continue;
2989
+ const nx = x + dx, ny = y + dy;
2990
+ if (nx < 0 || nx >= W || ny < 0 || ny >= H) continue;
2991
+ if (nx < wxMin || nx >= wxMax) continue;
2992
+ const ni = ny * W + nx;
2993
+ if (visitedFrame[ni] === frame) continue;
2994
+ // Oil only ignites if it has air exposure (empty/gas neighbor) —
2995
+ // burning oil flowing into buried oil won't ignite it without oxygen.
2996
+ if ((grid[ni] & 0xff) === Material.Oil && this.isExposed(nx, ny) && this.rng.random() < spreadChance) {
2997
+ grid[ni] = packCell(Material.BurningOil, BURNING_OIL_LIFETIME, this.rng.randomShade());
2998
+ visitedFrame[ni] = frame;
2999
+ }
3000
+ }
3001
+ }
3002
+ }
3003
+
3004
+ // Clear fireSources for the active cells we marked (so next frame's
3005
+ // buildActiveListAndClearFlags doesn't inherit stale marks). We only
3006
+ // touch the active cells — the rest are already 0.
3007
+ for (let a = 0; a < count; a++) fireSources[active[a]] = 0;
3008
+ }
3009
+
3010
+ private applyAging(): void {
3011
+ const grid = this.grid;
3012
+ const active = this.activeCells;
3013
+ const count = this.activeCount;
3014
+
3015
+ for (let a = 0; a < count; a++) {
3016
+ const i = active[a];
3017
+ const packed = grid[i];
3018
+ if (packed === 0) continue; // cell was destroyed earlier this frame
3019
+
3020
+ // Inline unpack — avoid allocating a Cell object
3021
+ let mat = packed & 0xff;
3022
+ let lifetime = (packed >> 8) & 0xff;
3023
+ let flags = (packed >> 16) & 0xff;
3024
+
3025
+ // Gravel (and legacy LooseStone): re-settle to Stone when stationary AND
3026
+ // supported by stable ground. The lifetime field is a settle timer, set
3027
+ // when the stone is dislodged by mining (GRAVEL_SETTLE_TICKS) or disturbed
3028
+ // by adjacent gravel movement (GRAVEL_DISTURB_SETTLE_TICKS). When the
3029
+ // cell moves (FLAG_UPDATED), the timer keeps its current value. When
3030
+ // stationary, the timer only counts down if the cell is supported from
3031
+ // below by a STABLE material — otherwise it's floating and must not
3032
+ // re-settle (it needs to keep trying to fall). This prevents Gravel from
3033
+ // freezing mid-air when friction or random chance prevents it from moving
3034
+ // for a few ticks.
3035
+ //
3036
+ // Stable support = a solid that won't move out from under us:
3037
+ // - Static solids (gravityDir === 0: Stone, Wall, Concrete) with
3038
+ // lifetime === 0 (not loosened).
3039
+ // - Gravity-affected solids (ore, dirt, etc.) at rest: gravity field
3040
+ // === 0 (not falling) and lifetime === 0 (not loosened/decaying).
3041
+ // Liquids, gases, and loosened/falling cells are NOT stable support —
3042
+ // they can flow or fall away, leaving re-settled stone floating.
3043
+ //
3044
+ // LooseStone (mat 63) is kept in the condition for backwards compat with
3045
+ // old saves that still contain LooseStone cells; no new LooseStone is
3046
+ // created — all stone-debris conversions produce Gravel.
3047
+ // Stone (mat 3) is included: when a Stone cell's gravity field is set
3048
+ // (by mining), it falls. When it lands and is stably supported, it
3049
+ // re-freezes (gravity field cleared) — no material conversion needed.
3050
+ if (mat === Material.Gravel || mat === Material.LooseStone || mat === Material.Stone) {
3051
+ if (!(flags & FLAG_UPDATED) && lifetime > 0) {
3052
+ // Check if supported from below by a stable cell or grid boundary.
3053
+ // A cell is stable support if it won't move out from under us:
3054
+ // - Static solids (gravityDir === 0: Stone, Wall, Concrete) with
3055
+ // lifetime === 0 (not loosened by mining).
3056
+ // - Gravity-affected solids (gravityDir !== 0 but solid: ore,
3057
+ // dirt, etc.) that are at rest — gravity field === 0 (not
3058
+ // falling) and lifetime === 0 (not loosened/decaying). Ore is
3059
+ // generated with gravity=0 so it stays embedded until mined;
3060
+ // without this, stone resting on ore never settles because ore
3061
+ // has gravityDir=1.
3062
+ // Liquids, gases, and loosened/falling cells do NOT count — they
3063
+ // can move out from under us.
3064
+ const belowIdx = i + this.W;
3065
+ let supported: boolean;
3066
+ if (belowIdx >= this.grid.length) {
3067
+ supported = true; // grid boundary = stable floor
3068
+ } else {
3069
+ const belowPacked = this.grid[belowIdx];
3070
+ if (belowPacked === 0) {
3071
+ supported = false;
3072
+ } else {
3073
+ const belowMat = belowPacked & 0xff;
3074
+ const belowLifetime = (belowPacked >> 8) & 0xff;
3075
+ if (MAT_GRAVITY_DIR[belowMat] === 0) {
3076
+ // Static solid — stable if not loosened
3077
+ supported = belowLifetime === 0;
3078
+ } else if (MAT_FLAGS[belowMat] & MAT_SOLID) {
3079
+ // Gravity-affected solid at rest — stable if not falling
3080
+ // (gravity field cleared) and not loosened/decaying.
3081
+ supported = this.fields[belowIdx * 4 + FIELD.GRAVITY] === 0 &&
3082
+ belowLifetime === 0;
3083
+ } else {
3084
+ // Liquid/gas — never stable support
3085
+ supported = false;
3086
+ }
3087
+ }
3088
+ }
3089
+ if (supported) {
3090
+ lifetime--;
3091
+ if (lifetime === 0) {
3092
+ // Re-settle: convert back to Stone (static, no gravity).
3093
+ // For Gravel/LooseStone: change material to Stone.
3094
+ // For Stone: just clear the lifetime (already Stone, now frozen).
3095
+ // Also clear FLAG_DETACHED so the renderer stops drawing the
3096
+ // warm tint + amber outline, and the cell re-freezes (no longer
3097
+ // collectible / unfrozen). Without this, re-settled stone stays
3098
+ // visually distinct from original stone and gets absorbed into
3099
+ // the player's inventory when walked near.
3100
+ mat = Material.Stone;
3101
+ lifetime = 0;
3102
+ flags &= ~0x10; // clear FLAG_DETACHED (bit 4 of flags byte)
3103
+ }
3104
+ }
3105
+ // If not stably supported, don't count down — keep trying to fall
3106
+ }
3107
+ const newFlags = flags & ~FLAG_UPDATED;
3108
+ const newPacked = (mat & 0xff) | ((lifetime & 0xff) << 8) | ((newFlags & 0xff) << 16);
3109
+ if (newPacked !== packed) grid[i] = newPacked;
3110
+ continue;
3111
+ }
3112
+
3113
+ if (lifetime > 0) {
3114
+ // Randomized decay: fire/smoke/steam sometimes skip a tick so
3115
+ // individual particles last variable amounts of time.
3116
+ if (mat === Material.Fire) {
3117
+ // Anchored fire (from wax) decays very slowly so the flame
3118
+ // persists and keeps spreading. Normal fire decays fast.
3119
+ if (flags & FLAG_ANCHORED) {
3120
+ if (this.rng.random() < 0.15) lifetime--;
3121
+ } else {
3122
+ if (this.rng.random() < 0.7) lifetime--;
3123
+ }
3124
+ } else if (mat === Material.FuseFire) {
3125
+ // FuseFire decays deterministically for consistent burn speed
3126
+ lifetime--;
3127
+ } else if (mat === Material.BurningOil) {
3128
+ // BurningOil decays very slowly so the fire sits on the oil surface
3129
+ // for a long time, giving it time to spread to neighbors gradually.
3130
+ if (this.rng.random() < 0.15) lifetime--;
3131
+ } else if (mat === Material.Smoke) {
3132
+ if (this.rng.random() < 0.8) lifetime--;
3133
+ } else if (mat === Material.Steam) {
3134
+ if (this.rng.random() < 0.75) lifetime--;
3135
+ } else if (mat === Material.MagicPowder) {
3136
+ if (this.rng.random() < 0.7) lifetime--;
3137
+ } else if (mat === Material.ColdVapor) {
3138
+ // Cold vapor dissipates very slowly — ~30 seconds at 60fps.
3139
+ // lifetime 255 / 0.14 chance ≈ 1800 frames ≈ 30s.
3140
+ if (this.rng.random() < 0.14) lifetime--;
3141
+ } else if (mat === Material.Seed) {
3142
+ // Growing seeds (lifetime > 0) manage their own lifetime in the
3143
+ // seed growth reaction — don't decrement here.
3144
+ } else if (mat === Material.Duplicator) {
3145
+ // Duplicator's lifetime field stores the locked material id (0 = not
3146
+ // locked). It must NOT decay — the lock is permanent. Don't decrement.
3147
+ } else {
3148
+ lifetime--;
3149
+ }
3150
+ if (lifetime === 0) {
3151
+ if (mat === Material.Fire) {
3152
+ // Sparks and anchored fire (wax) expire to empty, not smoke.
3153
+ // Sparks: marked with FLAG_SPARK (bit 3).
3154
+ // Anchored fire: marked with FLAG_ANCHORED (bit 4) — smoke would
3155
+ // accumulate and suffocate the spreading flame.
3156
+ if (flags & (FLAG_SPARK | FLAG_ANCHORED)) {
3157
+ grid[i] = 0;
3158
+ continue;
3159
+ }
3160
+ mat = Material.Smoke;
3161
+ lifetime = 120;
3162
+ } else if (mat === Material.FuseFire) {
3163
+ // FuseFire expires to empty (not smoke) — smoke would accumulate
3164
+ // below the rising fire and suffocate the burn trail.
3165
+ grid[i] = 0;
3166
+ continue;
3167
+ } else if (mat === Material.BurningOil) {
3168
+ // BurningOil expires to smoke — the oil is consumed.
3169
+ mat = Material.Smoke;
3170
+ lifetime = 60;
3171
+ } else if (mat === Material.Smoke) {
3172
+ grid[i] = 0;
3173
+ continue;
3174
+ } else if (mat === Material.Steam) {
3175
+ if (this.rng.random() < 0.7) {
3176
+ mat = Material.Water;
3177
+ lifetime = 0;
3178
+ } else {
3179
+ grid[i] = 0;
3180
+ continue;
3181
+ }
3182
+ } else if (mat === Material.GasVapor) {
3183
+ // Expired vapor → empty
3184
+ grid[i] = 0;
3185
+ continue;
3186
+ } else if (mat === Material.ColdVapor) {
3187
+ // Cold vapor fully dissipated → empty
3188
+ grid[i] = 0;
3189
+ continue;
3190
+ } else if (mat === Material.Hydrogen) {
3191
+ grid[i] = 0;
3192
+ continue;
3193
+ } else if (mat === Material.Spore) {
3194
+ // Spore that never germinated dissipates to empty.
3195
+ grid[i] = 0;
3196
+ continue;
3197
+ } else if (mat === Material.Plasma) {
3198
+ // Plasma → fire then smoke
3199
+ mat = Material.Fire;
3200
+ lifetime = 15;
3201
+ } else if (mat === Material.Fireflies) {
3202
+ // Fireflies don't expire (lifetime stays at max)
3203
+ lifetime = 255;
3204
+ } else if (mat === Material.Nanobots) {
3205
+ lifetime = 255;
3206
+ } else if (mat === Material.Wood || mat === Material.Plant || mat === Material.Oil ||
3207
+ mat === Material.Flesh || mat === Material.Leaf || mat === Material.TreeWood ||
3208
+ mat === Material.Root || mat === Material.Grass || mat === Material.Toast ||
3209
+ mat === Material.Plastic || mat === Material.Wax || mat === Material.Fuse ||
3210
+ mat === Material.Rubber || mat === Material.C4 || mat === Material.Glitter ||
3211
+ mat === Material.MagicPowder) {
3212
+ mat = Material.Smoke;
3213
+ lifetime = 60;
3214
+ }
3215
+ }
3216
+ }
3217
+ // Clear FLAG_UPDATED and write back. Only write if the packed value
3218
+ // actually changed — avoids unnecessary memory writes when nothing
3219
+ // happened (e.g. static solids with lifetime=0).
3220
+ const newFlags = flags & ~FLAG_UPDATED;
3221
+ const newPacked = (mat & 0xff) | ((lifetime & 0xff) << 8) | ((newFlags & 0xff) << 16);
3222
+ if (newPacked !== packed) {
3223
+ grid[i] = newPacked;
3224
+ }
3225
+ }
3226
+ }
3227
+ }