diff --git a/web/.gitignore b/web/.gitignore
new file mode 100644
index 00000000..d70bb9cc
--- /dev/null
+++ b/web/.gitignore
@@ -0,0 +1,4 @@
+node_modules
+dist
+.DS_Store
+*.local
diff --git a/web/PARITY.md b/web/PARITY.md
new file mode 100644
index 00000000..2c558aa0
--- /dev/null
+++ b/web/PARITY.md
@@ -0,0 +1,39 @@
+# FactoryForge Web — Parity Checklist
+
+Mapped against iOS `FactoryForge/Docs/INSTRUCTIONS.md` and the Complete Parity plan.
+
+## Core loop
+- [x] Procedural chunks / resources / trees
+- [x] Player move (WASD), mine, chop
+- [x] Inventory + hand crafting
+- [x] Place / rotate / remove buildings
+- [x] Belts + inserters
+- [x] Furnaces + assemblers + chests
+- [x] Power poles, boilers, steam engines, solar, accumulators
+- [x] Research / tech tree / labs
+- [x] Fluids (pipes, pumps, boilers/steam, pumpjack oil)
+- [x] Oil refinery / chemical plant shells
+- [x] Pollution + biters + turrets + walls
+- [x] Player shoot (F / double-click)
+- [x] Units + unit production buildings
+- [x] Rocket silo + satellite → space science victory
+- [x] Save / load / autosave (localStorage)
+- [x] AutoPlay scenarios + game speed
+- [x] PvAI + lobby UI
+- [x] Fog of war helper
+- [x] WebSocket multiplayer (handshake / snapshot / delta / command / ping)
+- [x] Hybrid 2D ortho + 3D perspective toggle (V)
+
+## Controls (web equivalents)
+- [x] WASD move, wheel zoom, MMB/RMB pan
+- [x] LMB select/mine/place, R rotate, Esc cancel
+- [x] I/C/B/T menus, P pause, V view mode
+
+## Intentionally out of scope
+- [ ] StoreKit IAP
+- [ ] CoreMotion tilt / shake / haptics
+- [ ] iOS MCP debug bridge as gameplay dependency
+- [ ] Full PNG sprite atlas (procedural colors until assets copied from iOS bundle)
+
+## Asset note
+Copy PNGs from the iOS app bundle into `web/public/assets/` per `FactoryForge/Assets/SPRITE_LIST.md` when available; `ColorAtlas` provides fallbacks.
diff --git a/web/README.md b/web/README.md
new file mode 100644
index 00000000..6c3c71b7
--- /dev/null
+++ b/web/README.md
@@ -0,0 +1,48 @@
+# FactoryForge Web
+
+Three.js (WebGPU + WebGL2 fallback) port of FactoryForge with keyboard/mouse controls, full single-player systems, PvAI, AutoPlay, and WebSocket multiplayer.
+
+## Assets
+
+Game sprites are copied from `FactoryForge/Assets/` into `public/assets/`.
+
+```bash
+powershell -File scripts/sync-assets.ps1
+```
+
+The splash screen loads `/assets/splash.png` and preloads all PNGs listed in `public/assets/manifest.json`. The renderer uses these via `AssetCatalog` (nearest filtering), falling back to procedural textures only if a file is missing.
+
+### Multiplayer server
+
+```bash
+npm run server
+```
+
+In-game: Menu → Lobby → Connect Multiplayer (`ws://localhost:8080`).
+
+## Controls
+
+| Action | Input |
+|--------|--------|
+| Move | WASD / arrows |
+| Zoom | Mouse wheel / trackpad |
+| Pan camera | MMB or RMB drag |
+| Mine / select / place | LMB |
+| Shoot | Double-click or F |
+| Rotate building | R / Q / E |
+| Inventory / Craft / Build / Research | I / C / B / T |
+| Toggle 2D/3D view | V |
+| Pause | Space / P |
+| Deselect / cancel | Esc |
+
+## Architecture
+
+- `src/engine` — ECS, GameLoop (60 Hz fixed), input, render
+- `src/game` — systems (mining→rockets), world, AI, multiplayer helpers
+- `shared/data` — items, recipes, techs, building_configs JSON
+- `shared/net` — `NetworkMessage` protocol
+- `server` — authoritative WebSocket host
+
+## Parity checklist
+
+See in-game Menu help and iOS `FactoryForge/Docs/INSTRUCTIONS.md`. Win by launching a rocket with a satellite (space science).
diff --git a/web/index.html b/web/index.html
new file mode 100644
index 00000000..c5f583bb
--- /dev/null
+++ b/web/index.html
@@ -0,0 +1,16 @@
+
+
+
+
+
+ FactoryForge
+
+
+
+
+
+
+
diff --git a/web/package-lock.json b/web/package-lock.json
new file mode 100644
index 00000000..5b0f5019
--- /dev/null
+++ b/web/package-lock.json
@@ -0,0 +1,1618 @@
+{
+ "name": "factoryforge-web",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "factoryforge-web",
+ "version": "0.1.0",
+ "dependencies": {
+ "three": "^0.179.0",
+ "ws": "^8.18.3"
+ },
+ "devDependencies": {
+ "@types/three": "^0.179.0",
+ "@types/ws": "^8.18.1",
+ "concurrently": "^9.2.0",
+ "tsx": "^4.20.3",
+ "typescript": "^5.9.2",
+ "vite": "^7.0.6"
+ }
+ },
+ "node_modules/@dimforge/rapier3d-compat": {
+ "version": "0.12.0",
+ "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
+ "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
+ "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
+ "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
+ "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
+ "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
+ "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
+ "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
+ "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
+ "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
+ "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
+ "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
+ "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
+ "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
+ "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
+ "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
+ "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
+ "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
+ "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
+ "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
+ "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
+ "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
+ "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
+ "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
+ "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.3.tgz",
+ "integrity": "sha512-c0wdcekXtQvvn5Tsrk/+op/gUArrbWaFduBnTLP2l1cKLSQs4diMWjJw3m6A0DdzT8dAAX95KpkJ3qynCePbmw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.3.tgz",
+ "integrity": "sha512-3YjElDdWN+qXAFbJ/CzPV+0wspLqh54k/I6GfdYtEJRqg7buSgc1yPM3B+93j1M4neobtkATHZTmxK2AMVGfnA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.3.tgz",
+ "integrity": "sha512-Pch2pFNOxxz1hTjypIdPyRTR6riiwRl84+VcN9djS680fw+Co1nAJINrdpqp7KV0NvyuU8ilZXZCjd7ykJl1GQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.3.tgz",
+ "integrity": "sha512-LEuncFUHFiF8t4yZVZvvZA1wk0pjAscRnsrn1EfTEmN4HXotBi2YtcnLRyaK6UbuczW7xZS5ES+81Rdz8Z0T6g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.3.tgz",
+ "integrity": "sha512-zvBUvsQUpOWALdDsk6qbS8bXf2VxmPisuudNDrY7x0p0jBdsoZl8HsHczIOgkQiZldmcacMKtBzpoGVNeIe2bQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.3.tgz",
+ "integrity": "sha512-C2KmNrcSem/AMg984H/dev+si0lieQGdXdR/lYGJnuumXnFb9Y7QdiI62obFdLlxRYLBv4P0eUVIDbD4c1vVvw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.3.tgz",
+ "integrity": "sha512-ggXnsTAEzNQx74XpunRsiZ9aBZDsI7XIa0hm2nzR9f4WzH5/f/d73ZSDaC5ejJ8YLY4NW+V3wr0tjOaeCq8hqA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.3.tgz",
+ "integrity": "sha512-2vng+FlzNUhKZxtej3IUqJgbZoQk2M/dwQM20+ULV0R/E/8tr9/P6uEf2iiGIk4HL0zMKh5Jry7mUHdUOvyGgA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.3.tgz",
+ "integrity": "sha512-LLLFZKt4/Nraf9rxDkhiU8QVgLF4WmCkfr0L4fj0fPfIZFBib0DeiFk1hhaYKd03LFAFJcxHslhDFlNJLylf5Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.3.tgz",
+ "integrity": "sha512-WJkdQCvS9sWNOUBJZfQRKpZGFBztRzcowI+nndmflKgU4XY+3a420FgTOSKTsVqJbnzSxeT4vaJalpOaPo2YCQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.3.tgz",
+ "integrity": "sha512-PwHXCCS2n64/1Ot6rP1YEYA02MGYBcQlr8CSZZyrUG2O7NH6NklYmvr9v3Jy+5e/eDeNchc/ukmKJi9LuflMIQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.3.tgz",
+ "integrity": "sha512-vUjxINQu3RC8NZS3ykk1gN65gIz8pAopOq2HXuZhiIxHdx7TFvDG+jgrdSgInu1Eza4/Rfi2VzZgyIgEH4WOaw==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.3.tgz",
+ "integrity": "sha512-wzko4aJ13+0G3kGnviCg5gnXFKd40izKsrf2uOw12US4XqprkDrmwOpeW14aSNa37V8bfPcz5Fkob6LZ3BAPmA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.3.tgz",
+ "integrity": "sha512-8120ue0JUMSwy11stlwnfdX3pPd+WZYGCDBwEHWtIHi6pOpZmsEF5QKB7a/UN+XFdqvobxz98kv8RTqikyCEBw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.3.tgz",
+ "integrity": "sha512-XLFHnR3tXMjbOCh2vtVJHmxt+995uJsTERQyseFDRA0xxMxyTZPLa3OIUlyFaO4mF/Lu0FjmWHCuPXJT1n/IOg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.3.tgz",
+ "integrity": "sha512-se6yXvNGMIl0f+RQzyh7XAmia8/9kplQx424wnG2w0C1oi6XgO6Y8otKhdXFHbHs88Ihavzmvh1NWjuovE76BQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.3.tgz",
+ "integrity": "sha512-gNoxRefktVIiGflpONuxWWXZAzIQG++z9qHO3xKwk4WdDMuQja3JHGfE1u0i3PfPDyvhypdk+WrgIJqLhGG7sg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.3.tgz",
+ "integrity": "sha512-V4KtWtQfAFMU7+9/A/VDps/VI8CHd3cYz0L8sgJzz8qK7eY7wI4ruFD82UYIYvW9Z4DtlTfhQcsl4XyPHW5uSg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.3.tgz",
+ "integrity": "sha512-LBx9LYXvj2CBkMkjLdNAWLwH0MLMin7do2VcVo9kVPibGLkY0BQQut2fv7NVqkXqZ/CrAu9LqDHVV1xHCMpCPw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.3.tgz",
+ "integrity": "sha512-ABVf3Q0RCu7NcyCCOZQI0pJ3GuSdfSl8EXcy88QtdceIMIoCUdfhsJChZ64L9zVM2aJHjde1Bhn5uqSRcX9ySA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.3.tgz",
+ "integrity": "sha512-+2Cy/ldweGBLlPIKsQLF8U5N44a0KDdbrk1rAjHOM9M2K+kGdIVjHLmmrZIcx+9Ny3ke/1JomCsDI1ocb11+sg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.3.tgz",
+ "integrity": "sha512-dtZvzc8BedpSaFNy75x6uiWwAGTH+aZHDtdrqP6qk+WcLJrfti6sGje1ZJ9UxyzDLF23d/mV+PaMwuC0hL7UVA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.3.tgz",
+ "integrity": "sha512-Rj8Ra4noo+aYy7sKBggCx0407mws34kAb1ySyWuq5DAtFBQdkSwnsjCgPrhPe9cvgBKZIukpE+CVHvORCS93kQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.3.tgz",
+ "integrity": "sha512-vp7N084ew/odXn2gi/mzm9mUkQu9l6AiN6dt4IeUM2Uvm9o+cVmP+YkqbMOteLbiGgqBBlJZjIMYVCfOOIVbVQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.3.tgz",
+ "integrity": "sha512-MOG/3gTOn4Fwf574RVOaY61I5o6P90legkFADiTyn1hyjNydT+cerU2rLUwPdZkKKyJ+iT+K9p7WXK4LM1Ka6g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@tweenjs/tween.js": {
+ "version": "23.1.3",
+ "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
+ "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "26.1.2",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz",
+ "integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~8.3.0"
+ }
+ },
+ "node_modules/@types/stats.js": {
+ "version": "0.17.4",
+ "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
+ "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/three": {
+ "version": "0.179.0",
+ "resolved": "https://registry.npmjs.org/@types/three/-/three-0.179.0.tgz",
+ "integrity": "sha512-VgbFG2Pgsm84BqdegZzr7w2aKbQxmgzIu4Dy7/75ygiD/0P68LKmp5ie08KMPNqGTQwIge8s6D1guZf1RnZE0A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@dimforge/rapier3d-compat": "~0.12.0",
+ "@tweenjs/tween.js": "~23.1.3",
+ "@types/stats.js": "*",
+ "@types/webxr": "*",
+ "@webgpu/types": "*",
+ "fflate": "~0.8.2",
+ "meshoptimizer": "~0.22.0"
+ }
+ },
+ "node_modules/@types/webxr": {
+ "version": "0.5.24",
+ "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
+ "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/ws": {
+ "version": "8.18.1",
+ "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
+ "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@webgpu/types": {
+ "version": "0.1.71",
+ "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.71.tgz",
+ "integrity": "sha512-mMy8/ODcKhab808co15eW+yN+HgXoQxRQHTiBV9Mrvl1r0ufnid7YOcI+gi4eUWSWl9ezD6TW2KXccrL8HCh2A==",
+ "dev": true,
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/chalk/node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/cliui": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
+ "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^4.2.0",
+ "strip-ansi": "^6.0.1",
+ "wrap-ansi": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/concurrently": {
+ "version": "9.2.4",
+ "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.4.tgz",
+ "integrity": "sha512-TZ0CEhyzvFjgtAvHTusDMgj7wNdihCh7LLLrzdUOXIhdlnL2JBBGA9eJxR24rtqgmdjh3OA3hrN1rCHj6HM8qA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "chalk": "4.1.2",
+ "rxjs": "7.8.2",
+ "shell-quote": "1.9.0",
+ "supports-color": "8.1.1",
+ "tree-kill": "1.2.2",
+ "yargs": "17.7.2"
+ },
+ "bin": {
+ "conc": "dist/bin/concurrently.js",
+ "concurrently": "dist/bin/concurrently.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
+ }
+ },
+ "node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/esbuild": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
+ "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.28.1",
+ "@esbuild/android-arm": "0.28.1",
+ "@esbuild/android-arm64": "0.28.1",
+ "@esbuild/android-x64": "0.28.1",
+ "@esbuild/darwin-arm64": "0.28.1",
+ "@esbuild/darwin-x64": "0.28.1",
+ "@esbuild/freebsd-arm64": "0.28.1",
+ "@esbuild/freebsd-x64": "0.28.1",
+ "@esbuild/linux-arm": "0.28.1",
+ "@esbuild/linux-arm64": "0.28.1",
+ "@esbuild/linux-ia32": "0.28.1",
+ "@esbuild/linux-loong64": "0.28.1",
+ "@esbuild/linux-mips64el": "0.28.1",
+ "@esbuild/linux-ppc64": "0.28.1",
+ "@esbuild/linux-riscv64": "0.28.1",
+ "@esbuild/linux-s390x": "0.28.1",
+ "@esbuild/linux-x64": "0.28.1",
+ "@esbuild/netbsd-arm64": "0.28.1",
+ "@esbuild/netbsd-x64": "0.28.1",
+ "@esbuild/openbsd-arm64": "0.28.1",
+ "@esbuild/openbsd-x64": "0.28.1",
+ "@esbuild/openharmony-arm64": "0.28.1",
+ "@esbuild/sunos-x64": "0.28.1",
+ "@esbuild/win32-arm64": "0.28.1",
+ "@esbuild/win32-ia32": "0.28.1",
+ "@esbuild/win32-x64": "0.28.1"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fflate": {
+ "version": "0.8.3",
+ "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
+ "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/get-caller-file": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": "6.* || 8.* || >= 10.*"
+ }
+ },
+ "node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/meshoptimizer": {
+ "version": "0.22.0",
+ "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.22.0.tgz",
+ "integrity": "sha512-IebiK79sqIy+E4EgOr+CAw+Ke8hAspXKzBd0JdgEmPHiAwmvEj2S4h1rfvo+o/BnfEYd/jAOg5IeeIjzlzSnDg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.16",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
+ "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
+ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.25",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz",
+ "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.16",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/require-directory": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
+ "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.3.tgz",
+ "integrity": "sha512-Gu0c0iH9FzgX1L1t7ByIbbS3Vmdz+6KHm/EsqmmC71gUQ82yvZRkTK6XzrFObSka91WUVdynqp6nsfilzr5k6Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.9"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.62.3",
+ "@rollup/rollup-android-arm64": "4.62.3",
+ "@rollup/rollup-darwin-arm64": "4.62.3",
+ "@rollup/rollup-darwin-x64": "4.62.3",
+ "@rollup/rollup-freebsd-arm64": "4.62.3",
+ "@rollup/rollup-freebsd-x64": "4.62.3",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.62.3",
+ "@rollup/rollup-linux-arm-musleabihf": "4.62.3",
+ "@rollup/rollup-linux-arm64-gnu": "4.62.3",
+ "@rollup/rollup-linux-arm64-musl": "4.62.3",
+ "@rollup/rollup-linux-loong64-gnu": "4.62.3",
+ "@rollup/rollup-linux-loong64-musl": "4.62.3",
+ "@rollup/rollup-linux-ppc64-gnu": "4.62.3",
+ "@rollup/rollup-linux-ppc64-musl": "4.62.3",
+ "@rollup/rollup-linux-riscv64-gnu": "4.62.3",
+ "@rollup/rollup-linux-riscv64-musl": "4.62.3",
+ "@rollup/rollup-linux-s390x-gnu": "4.62.3",
+ "@rollup/rollup-linux-x64-gnu": "4.62.3",
+ "@rollup/rollup-linux-x64-musl": "4.62.3",
+ "@rollup/rollup-openbsd-x64": "4.62.3",
+ "@rollup/rollup-openharmony-arm64": "4.62.3",
+ "@rollup/rollup-win32-arm64-msvc": "4.62.3",
+ "@rollup/rollup-win32-ia32-msvc": "4.62.3",
+ "@rollup/rollup-win32-x64-gnu": "4.62.3",
+ "@rollup/rollup-win32-x64-msvc": "4.62.3",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/rxjs": {
+ "version": "7.8.2",
+ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
+ "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.1.0"
+ }
+ },
+ "node_modules/shell-quote": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz",
+ "integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/supports-color": {
+ "version": "8.1.1",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
+ "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/supports-color?sponsor=1"
+ }
+ },
+ "node_modules/three": {
+ "version": "0.179.1",
+ "resolved": "https://registry.npmjs.org/three/-/three-0.179.1.tgz",
+ "integrity": "sha512-5y/elSIQbrvKOISxpwXCR4sQqHtGiOI+MKLc3SsBdDXA2hz3Mdp3X59aUp8DyybMa34aeBwbFTpdoLJaUDEWSw==",
+ "license": "MIT"
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tree-kill": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
+ "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "tree-kill": "cli.js"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "dev": true,
+ "license": "0BSD"
+ },
+ "node_modules/tsx": {
+ "version": "4.23.1",
+ "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz",
+ "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "~0.28.0"
+ },
+ "bin": {
+ "tsx": "dist/cli.mjs"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
+ "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/vite": {
+ "version": "7.3.6",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz",
+ "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3",
+ "postcss": "^8.5.6",
+ "rollup": "^4.43.0",
+ "tinyglobby": "^0.2.15"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "lightningcss": "^1.21.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/wrap-ansi": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/ws": {
+ "version": "8.21.1",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
+ "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/y18n": {
+ "version": "5.0.8",
+ "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
+ "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/yargs": {
+ "version": "17.7.2",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
+ "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cliui": "^8.0.1",
+ "escalade": "^3.1.1",
+ "get-caller-file": "^2.0.5",
+ "require-directory": "^2.1.1",
+ "string-width": "^4.2.3",
+ "y18n": "^5.0.5",
+ "yargs-parser": "^21.1.1"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/yargs-parser": {
+ "version": "21.1.1",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
+ "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ }
+ }
+}
diff --git a/web/package.json b/web/package.json
new file mode 100644
index 00000000..191fbc62
--- /dev/null
+++ b/web/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "factoryforge-web",
+ "private": true,
+ "version": "0.1.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc -p tsconfig.json --noEmit && vite build",
+ "typecheck": "tsc -p tsconfig.json --noEmit",
+ "preview": "vite preview",
+ "server": "tsx server/index.ts",
+ "dev:all": "concurrently \"npm run server\" \"npm run dev\""
+ },
+ "dependencies": {
+ "three": "^0.179.0",
+ "ws": "^8.18.3"
+ },
+ "devDependencies": {
+ "@types/three": "^0.179.0",
+ "@types/ws": "^8.18.1",
+ "concurrently": "^9.2.0",
+ "tsx": "^4.20.3",
+ "typescript": "^5.9.2",
+ "vite": "^7.0.6"
+ }
+}
diff --git a/web/public/assets/AppIcon.png b/web/public/assets/AppIcon.png
new file mode 100644
index 00000000..9fd98e43
Binary files /dev/null and b/web/public/assets/AppIcon.png differ
diff --git a/web/public/assets/AppIcon_1024x1024.png b/web/public/assets/AppIcon_1024x1024.png
new file mode 100644
index 00000000..b46de32e
Binary files /dev/null and b/web/public/assets/AppIcon_1024x1024.png differ
diff --git a/web/public/assets/AppIcon_120x120.png b/web/public/assets/AppIcon_120x120.png
new file mode 100644
index 00000000..ae1533dd
Binary files /dev/null and b/web/public/assets/AppIcon_120x120.png differ
diff --git a/web/public/assets/AppIcon_180x180.png b/web/public/assets/AppIcon_180x180.png
new file mode 100644
index 00000000..256c990c
Binary files /dev/null and b/web/public/assets/AppIcon_180x180.png differ
diff --git a/web/public/assets/AppIcon_40x40.png b/web/public/assets/AppIcon_40x40.png
new file mode 100644
index 00000000..a0002f6f
Binary files /dev/null and b/web/public/assets/AppIcon_40x40.png differ
diff --git a/web/public/assets/AppIcon_58x58.png b/web/public/assets/AppIcon_58x58.png
new file mode 100644
index 00000000..6abfd546
Binary files /dev/null and b/web/public/assets/AppIcon_58x58.png differ
diff --git a/web/public/assets/AppIcon_60x60.png b/web/public/assets/AppIcon_60x60.png
new file mode 100644
index 00000000..db479afa
Binary files /dev/null and b/web/public/assets/AppIcon_60x60.png differ
diff --git a/web/public/assets/AppIcon_80x80.png b/web/public/assets/AppIcon_80x80.png
new file mode 100644
index 00000000..30a65d7c
Binary files /dev/null and b/web/public/assets/AppIcon_80x80.png differ
diff --git a/web/public/assets/AppIcon_87x87.png b/web/public/assets/AppIcon_87x87.png
new file mode 100644
index 00000000..491674a9
Binary files /dev/null and b/web/public/assets/AppIcon_87x87.png differ
diff --git a/web/public/assets/accumulator.png b/web/public/assets/accumulator.png
new file mode 100644
index 00000000..01decc81
Binary files /dev/null and b/web/public/assets/accumulator.png differ
diff --git a/web/public/assets/advanced_circuit.png b/web/public/assets/advanced_circuit.png
new file mode 100644
index 00000000..8de43fdb
Binary files /dev/null and b/web/public/assets/advanced_circuit.png differ
diff --git a/web/public/assets/assembler.png b/web/public/assets/assembler.png
new file mode 100644
index 00000000..bdc769e0
Binary files /dev/null and b/web/public/assets/assembler.png differ
diff --git a/web/public/assets/assembling_machine_1.png b/web/public/assets/assembling_machine_1.png
new file mode 100644
index 00000000..bdc769e0
Binary files /dev/null and b/web/public/assets/assembling_machine_1.png differ
diff --git a/web/public/assets/assembling_machine_2.png b/web/public/assets/assembling_machine_2.png
new file mode 100644
index 00000000..2136278e
Binary files /dev/null and b/web/public/assets/assembling_machine_2.png differ
diff --git a/web/public/assets/assembling_machine_3.png b/web/public/assets/assembling_machine_3.png
new file mode 100644
index 00000000..2f6bda53
Binary files /dev/null and b/web/public/assets/assembling_machine_3.png differ
diff --git a/web/public/assets/automation_science_pack.png b/web/public/assets/automation_science_pack.png
new file mode 100644
index 00000000..c5c5b2b8
Binary files /dev/null and b/web/public/assets/automation_science_pack.png differ
diff --git a/web/public/assets/battery.png b/web/public/assets/battery.png
new file mode 100644
index 00000000..607cd40d
Binary files /dev/null and b/web/public/assets/battery.png differ
diff --git a/web/public/assets/belt.png b/web/public/assets/belt.png
new file mode 100644
index 00000000..4c1afc23
Binary files /dev/null and b/web/public/assets/belt.png differ
diff --git a/web/public/assets/belt_bridge.png b/web/public/assets/belt_bridge.png
new file mode 100644
index 00000000..30c5ca2c
Binary files /dev/null and b/web/public/assets/belt_bridge.png differ
diff --git a/web/public/assets/belts/transport/east/transport_belt_east_001.png b/web/public/assets/belts/transport/east/transport_belt_east_001.png
new file mode 100644
index 00000000..ec9cf24c
Binary files /dev/null and b/web/public/assets/belts/transport/east/transport_belt_east_001.png differ
diff --git a/web/public/assets/belts/transport/east/transport_belt_east_002.png b/web/public/assets/belts/transport/east/transport_belt_east_002.png
new file mode 100644
index 00000000..2877b0dd
Binary files /dev/null and b/web/public/assets/belts/transport/east/transport_belt_east_002.png differ
diff --git a/web/public/assets/belts/transport/east/transport_belt_east_003.png b/web/public/assets/belts/transport/east/transport_belt_east_003.png
new file mode 100644
index 00000000..097a0c29
Binary files /dev/null and b/web/public/assets/belts/transport/east/transport_belt_east_003.png differ
diff --git a/web/public/assets/belts/transport/east/transport_belt_east_004.png b/web/public/assets/belts/transport/east/transport_belt_east_004.png
new file mode 100644
index 00000000..9a695b35
Binary files /dev/null and b/web/public/assets/belts/transport/east/transport_belt_east_004.png differ
diff --git a/web/public/assets/belts/transport/east/transport_belt_east_005.png b/web/public/assets/belts/transport/east/transport_belt_east_005.png
new file mode 100644
index 00000000..2cd4c6b0
Binary files /dev/null and b/web/public/assets/belts/transport/east/transport_belt_east_005.png differ
diff --git a/web/public/assets/belts/transport/east/transport_belt_east_006.png b/web/public/assets/belts/transport/east/transport_belt_east_006.png
new file mode 100644
index 00000000..560287ce
Binary files /dev/null and b/web/public/assets/belts/transport/east/transport_belt_east_006.png differ
diff --git a/web/public/assets/belts/transport/east/transport_belt_east_007.png b/web/public/assets/belts/transport/east/transport_belt_east_007.png
new file mode 100644
index 00000000..f6a447ff
Binary files /dev/null and b/web/public/assets/belts/transport/east/transport_belt_east_007.png differ
diff --git a/web/public/assets/belts/transport/east/transport_belt_east_008.png b/web/public/assets/belts/transport/east/transport_belt_east_008.png
new file mode 100644
index 00000000..8e2a9c7c
Binary files /dev/null and b/web/public/assets/belts/transport/east/transport_belt_east_008.png differ
diff --git a/web/public/assets/belts/transport/east/transport_belt_east_009.png b/web/public/assets/belts/transport/east/transport_belt_east_009.png
new file mode 100644
index 00000000..32828383
Binary files /dev/null and b/web/public/assets/belts/transport/east/transport_belt_east_009.png differ
diff --git a/web/public/assets/belts/transport/east/transport_belt_east_010.png b/web/public/assets/belts/transport/east/transport_belt_east_010.png
new file mode 100644
index 00000000..b1cd2ec7
Binary files /dev/null and b/web/public/assets/belts/transport/east/transport_belt_east_010.png differ
diff --git a/web/public/assets/belts/transport/east/transport_belt_east_011.png b/web/public/assets/belts/transport/east/transport_belt_east_011.png
new file mode 100644
index 00000000..1d9b5569
Binary files /dev/null and b/web/public/assets/belts/transport/east/transport_belt_east_011.png differ
diff --git a/web/public/assets/belts/transport/east/transport_belt_east_012.png b/web/public/assets/belts/transport/east/transport_belt_east_012.png
new file mode 100644
index 00000000..e2d1039c
Binary files /dev/null and b/web/public/assets/belts/transport/east/transport_belt_east_012.png differ
diff --git a/web/public/assets/belts/transport/east/transport_belt_east_013.png b/web/public/assets/belts/transport/east/transport_belt_east_013.png
new file mode 100644
index 00000000..b687c000
Binary files /dev/null and b/web/public/assets/belts/transport/east/transport_belt_east_013.png differ
diff --git a/web/public/assets/belts/transport/east/transport_belt_east_014.png b/web/public/assets/belts/transport/east/transport_belt_east_014.png
new file mode 100644
index 00000000..68657a4d
Binary files /dev/null and b/web/public/assets/belts/transport/east/transport_belt_east_014.png differ
diff --git a/web/public/assets/belts/transport/east/transport_belt_east_015.png b/web/public/assets/belts/transport/east/transport_belt_east_015.png
new file mode 100644
index 00000000..128319ff
Binary files /dev/null and b/web/public/assets/belts/transport/east/transport_belt_east_015.png differ
diff --git a/web/public/assets/belts/transport/east/transport_belt_east_016.png b/web/public/assets/belts/transport/east/transport_belt_east_016.png
new file mode 100644
index 00000000..9c23b7d6
Binary files /dev/null and b/web/public/assets/belts/transport/east/transport_belt_east_016.png differ
diff --git a/web/public/assets/belts/transport/north/transport_belt_north_001.png b/web/public/assets/belts/transport/north/transport_belt_north_001.png
new file mode 100644
index 00000000..32e182b2
Binary files /dev/null and b/web/public/assets/belts/transport/north/transport_belt_north_001.png differ
diff --git a/web/public/assets/belts/transport/north/transport_belt_north_002.png b/web/public/assets/belts/transport/north/transport_belt_north_002.png
new file mode 100644
index 00000000..48f78707
Binary files /dev/null and b/web/public/assets/belts/transport/north/transport_belt_north_002.png differ
diff --git a/web/public/assets/belts/transport/north/transport_belt_north_003.png b/web/public/assets/belts/transport/north/transport_belt_north_003.png
new file mode 100644
index 00000000..8960a400
Binary files /dev/null and b/web/public/assets/belts/transport/north/transport_belt_north_003.png differ
diff --git a/web/public/assets/belts/transport/north/transport_belt_north_004.png b/web/public/assets/belts/transport/north/transport_belt_north_004.png
new file mode 100644
index 00000000..5f179316
Binary files /dev/null and b/web/public/assets/belts/transport/north/transport_belt_north_004.png differ
diff --git a/web/public/assets/belts/transport/north/transport_belt_north_005.png b/web/public/assets/belts/transport/north/transport_belt_north_005.png
new file mode 100644
index 00000000..8fda3495
Binary files /dev/null and b/web/public/assets/belts/transport/north/transport_belt_north_005.png differ
diff --git a/web/public/assets/belts/transport/north/transport_belt_north_006.png b/web/public/assets/belts/transport/north/transport_belt_north_006.png
new file mode 100644
index 00000000..06678c96
Binary files /dev/null and b/web/public/assets/belts/transport/north/transport_belt_north_006.png differ
diff --git a/web/public/assets/belts/transport/north/transport_belt_north_007.png b/web/public/assets/belts/transport/north/transport_belt_north_007.png
new file mode 100644
index 00000000..8a6cd620
Binary files /dev/null and b/web/public/assets/belts/transport/north/transport_belt_north_007.png differ
diff --git a/web/public/assets/belts/transport/north/transport_belt_north_008.png b/web/public/assets/belts/transport/north/transport_belt_north_008.png
new file mode 100644
index 00000000..bad9f23a
Binary files /dev/null and b/web/public/assets/belts/transport/north/transport_belt_north_008.png differ
diff --git a/web/public/assets/belts/transport/north/transport_belt_north_009.png b/web/public/assets/belts/transport/north/transport_belt_north_009.png
new file mode 100644
index 00000000..15c53ada
Binary files /dev/null and b/web/public/assets/belts/transport/north/transport_belt_north_009.png differ
diff --git a/web/public/assets/belts/transport/north/transport_belt_north_010.png b/web/public/assets/belts/transport/north/transport_belt_north_010.png
new file mode 100644
index 00000000..a725325b
Binary files /dev/null and b/web/public/assets/belts/transport/north/transport_belt_north_010.png differ
diff --git a/web/public/assets/belts/transport/north/transport_belt_north_011.png b/web/public/assets/belts/transport/north/transport_belt_north_011.png
new file mode 100644
index 00000000..730a3fa3
Binary files /dev/null and b/web/public/assets/belts/transport/north/transport_belt_north_011.png differ
diff --git a/web/public/assets/belts/transport/north/transport_belt_north_012.png b/web/public/assets/belts/transport/north/transport_belt_north_012.png
new file mode 100644
index 00000000..33f1532d
Binary files /dev/null and b/web/public/assets/belts/transport/north/transport_belt_north_012.png differ
diff --git a/web/public/assets/belts/transport/north/transport_belt_north_013.png b/web/public/assets/belts/transport/north/transport_belt_north_013.png
new file mode 100644
index 00000000..5b76f0c7
Binary files /dev/null and b/web/public/assets/belts/transport/north/transport_belt_north_013.png differ
diff --git a/web/public/assets/belts/transport/north/transport_belt_north_014.png b/web/public/assets/belts/transport/north/transport_belt_north_014.png
new file mode 100644
index 00000000..35e53689
Binary files /dev/null and b/web/public/assets/belts/transport/north/transport_belt_north_014.png differ
diff --git a/web/public/assets/belts/transport/north/transport_belt_north_015.png b/web/public/assets/belts/transport/north/transport_belt_north_015.png
new file mode 100644
index 00000000..e6be0b40
Binary files /dev/null and b/web/public/assets/belts/transport/north/transport_belt_north_015.png differ
diff --git a/web/public/assets/belts/transport/north/transport_belt_north_016.png b/web/public/assets/belts/transport/north/transport_belt_north_016.png
new file mode 100644
index 00000000..5eacf0d4
Binary files /dev/null and b/web/public/assets/belts/transport/north/transport_belt_north_016.png differ
diff --git a/web/public/assets/belts/transport/south/transport_belt_south_001.png b/web/public/assets/belts/transport/south/transport_belt_south_001.png
new file mode 100644
index 00000000..17d1905a
Binary files /dev/null and b/web/public/assets/belts/transport/south/transport_belt_south_001.png differ
diff --git a/web/public/assets/belts/transport/south/transport_belt_south_002.png b/web/public/assets/belts/transport/south/transport_belt_south_002.png
new file mode 100644
index 00000000..97c0fcbe
Binary files /dev/null and b/web/public/assets/belts/transport/south/transport_belt_south_002.png differ
diff --git a/web/public/assets/belts/transport/south/transport_belt_south_003.png b/web/public/assets/belts/transport/south/transport_belt_south_003.png
new file mode 100644
index 00000000..b58d972f
Binary files /dev/null and b/web/public/assets/belts/transport/south/transport_belt_south_003.png differ
diff --git a/web/public/assets/belts/transport/south/transport_belt_south_004.png b/web/public/assets/belts/transport/south/transport_belt_south_004.png
new file mode 100644
index 00000000..6f0d9e88
Binary files /dev/null and b/web/public/assets/belts/transport/south/transport_belt_south_004.png differ
diff --git a/web/public/assets/belts/transport/south/transport_belt_south_005.png b/web/public/assets/belts/transport/south/transport_belt_south_005.png
new file mode 100644
index 00000000..52f11d90
Binary files /dev/null and b/web/public/assets/belts/transport/south/transport_belt_south_005.png differ
diff --git a/web/public/assets/belts/transport/south/transport_belt_south_006.png b/web/public/assets/belts/transport/south/transport_belt_south_006.png
new file mode 100644
index 00000000..a6de28d1
Binary files /dev/null and b/web/public/assets/belts/transport/south/transport_belt_south_006.png differ
diff --git a/web/public/assets/belts/transport/south/transport_belt_south_007.png b/web/public/assets/belts/transport/south/transport_belt_south_007.png
new file mode 100644
index 00000000..477a3994
Binary files /dev/null and b/web/public/assets/belts/transport/south/transport_belt_south_007.png differ
diff --git a/web/public/assets/belts/transport/south/transport_belt_south_008.png b/web/public/assets/belts/transport/south/transport_belt_south_008.png
new file mode 100644
index 00000000..13246422
Binary files /dev/null and b/web/public/assets/belts/transport/south/transport_belt_south_008.png differ
diff --git a/web/public/assets/belts/transport/south/transport_belt_south_009.png b/web/public/assets/belts/transport/south/transport_belt_south_009.png
new file mode 100644
index 00000000..5ae4da3e
Binary files /dev/null and b/web/public/assets/belts/transport/south/transport_belt_south_009.png differ
diff --git a/web/public/assets/belts/transport/south/transport_belt_south_010.png b/web/public/assets/belts/transport/south/transport_belt_south_010.png
new file mode 100644
index 00000000..e24abaae
Binary files /dev/null and b/web/public/assets/belts/transport/south/transport_belt_south_010.png differ
diff --git a/web/public/assets/belts/transport/south/transport_belt_south_011.png b/web/public/assets/belts/transport/south/transport_belt_south_011.png
new file mode 100644
index 00000000..57293e76
Binary files /dev/null and b/web/public/assets/belts/transport/south/transport_belt_south_011.png differ
diff --git a/web/public/assets/belts/transport/south/transport_belt_south_012.png b/web/public/assets/belts/transport/south/transport_belt_south_012.png
new file mode 100644
index 00000000..60cdad8f
Binary files /dev/null and b/web/public/assets/belts/transport/south/transport_belt_south_012.png differ
diff --git a/web/public/assets/belts/transport/south/transport_belt_south_013.png b/web/public/assets/belts/transport/south/transport_belt_south_013.png
new file mode 100644
index 00000000..36328a01
Binary files /dev/null and b/web/public/assets/belts/transport/south/transport_belt_south_013.png differ
diff --git a/web/public/assets/belts/transport/south/transport_belt_south_014.png b/web/public/assets/belts/transport/south/transport_belt_south_014.png
new file mode 100644
index 00000000..39e53e2b
Binary files /dev/null and b/web/public/assets/belts/transport/south/transport_belt_south_014.png differ
diff --git a/web/public/assets/belts/transport/south/transport_belt_south_015.png b/web/public/assets/belts/transport/south/transport_belt_south_015.png
new file mode 100644
index 00000000..76c71191
Binary files /dev/null and b/web/public/assets/belts/transport/south/transport_belt_south_015.png differ
diff --git a/web/public/assets/belts/transport/south/transport_belt_south_016.png b/web/public/assets/belts/transport/south/transport_belt_south_016.png
new file mode 100644
index 00000000..2d141bd0
Binary files /dev/null and b/web/public/assets/belts/transport/south/transport_belt_south_016.png differ
diff --git a/web/public/assets/belts/transport/west/transport_belt_west_001.png b/web/public/assets/belts/transport/west/transport_belt_west_001.png
new file mode 100644
index 00000000..7952b27a
Binary files /dev/null and b/web/public/assets/belts/transport/west/transport_belt_west_001.png differ
diff --git a/web/public/assets/belts/transport/west/transport_belt_west_002.png b/web/public/assets/belts/transport/west/transport_belt_west_002.png
new file mode 100644
index 00000000..3038b351
Binary files /dev/null and b/web/public/assets/belts/transport/west/transport_belt_west_002.png differ
diff --git a/web/public/assets/belts/transport/west/transport_belt_west_003.png b/web/public/assets/belts/transport/west/transport_belt_west_003.png
new file mode 100644
index 00000000..75d2c055
Binary files /dev/null and b/web/public/assets/belts/transport/west/transport_belt_west_003.png differ
diff --git a/web/public/assets/belts/transport/west/transport_belt_west_004.png b/web/public/assets/belts/transport/west/transport_belt_west_004.png
new file mode 100644
index 00000000..f202d602
Binary files /dev/null and b/web/public/assets/belts/transport/west/transport_belt_west_004.png differ
diff --git a/web/public/assets/belts/transport/west/transport_belt_west_005.png b/web/public/assets/belts/transport/west/transport_belt_west_005.png
new file mode 100644
index 00000000..e1a1701b
Binary files /dev/null and b/web/public/assets/belts/transport/west/transport_belt_west_005.png differ
diff --git a/web/public/assets/belts/transport/west/transport_belt_west_006.png b/web/public/assets/belts/transport/west/transport_belt_west_006.png
new file mode 100644
index 00000000..523d5251
Binary files /dev/null and b/web/public/assets/belts/transport/west/transport_belt_west_006.png differ
diff --git a/web/public/assets/belts/transport/west/transport_belt_west_007.png b/web/public/assets/belts/transport/west/transport_belt_west_007.png
new file mode 100644
index 00000000..191adeba
Binary files /dev/null and b/web/public/assets/belts/transport/west/transport_belt_west_007.png differ
diff --git a/web/public/assets/belts/transport/west/transport_belt_west_008.png b/web/public/assets/belts/transport/west/transport_belt_west_008.png
new file mode 100644
index 00000000..a6e479ff
Binary files /dev/null and b/web/public/assets/belts/transport/west/transport_belt_west_008.png differ
diff --git a/web/public/assets/belts/transport/west/transport_belt_west_009.png b/web/public/assets/belts/transport/west/transport_belt_west_009.png
new file mode 100644
index 00000000..5db57f20
Binary files /dev/null and b/web/public/assets/belts/transport/west/transport_belt_west_009.png differ
diff --git a/web/public/assets/belts/transport/west/transport_belt_west_010.png b/web/public/assets/belts/transport/west/transport_belt_west_010.png
new file mode 100644
index 00000000..0bd1b9ee
Binary files /dev/null and b/web/public/assets/belts/transport/west/transport_belt_west_010.png differ
diff --git a/web/public/assets/belts/transport/west/transport_belt_west_011.png b/web/public/assets/belts/transport/west/transport_belt_west_011.png
new file mode 100644
index 00000000..4afd1e55
Binary files /dev/null and b/web/public/assets/belts/transport/west/transport_belt_west_011.png differ
diff --git a/web/public/assets/belts/transport/west/transport_belt_west_012.png b/web/public/assets/belts/transport/west/transport_belt_west_012.png
new file mode 100644
index 00000000..62e66a90
Binary files /dev/null and b/web/public/assets/belts/transport/west/transport_belt_west_012.png differ
diff --git a/web/public/assets/belts/transport/west/transport_belt_west_013.png b/web/public/assets/belts/transport/west/transport_belt_west_013.png
new file mode 100644
index 00000000..9b3b4043
Binary files /dev/null and b/web/public/assets/belts/transport/west/transport_belt_west_013.png differ
diff --git a/web/public/assets/belts/transport/west/transport_belt_west_014.png b/web/public/assets/belts/transport/west/transport_belt_west_014.png
new file mode 100644
index 00000000..466b95f3
Binary files /dev/null and b/web/public/assets/belts/transport/west/transport_belt_west_014.png differ
diff --git a/web/public/assets/belts/transport/west/transport_belt_west_015.png b/web/public/assets/belts/transport/west/transport_belt_west_015.png
new file mode 100644
index 00000000..b1b591bb
Binary files /dev/null and b/web/public/assets/belts/transport/west/transport_belt_west_015.png differ
diff --git a/web/public/assets/belts/transport/west/transport_belt_west_016.png b/web/public/assets/belts/transport/west/transport_belt_west_016.png
new file mode 100644
index 00000000..65e2cf32
Binary files /dev/null and b/web/public/assets/belts/transport/west/transport_belt_west_016.png differ
diff --git a/web/public/assets/big_electric_pole.png b/web/public/assets/big_electric_pole.png
new file mode 100644
index 00000000..275a2518
Binary files /dev/null and b/web/public/assets/big_electric_pole.png differ
diff --git a/web/public/assets/biter.png b/web/public/assets/biter.png
new file mode 100644
index 00000000..62cf1aa2
Binary files /dev/null and b/web/public/assets/biter.png differ
diff --git a/web/public/assets/biter_left.png b/web/public/assets/biter_left.png
new file mode 100644
index 00000000..a2fc2622
Binary files /dev/null and b/web/public/assets/biter_left.png differ
diff --git a/web/public/assets/biter_right.png b/web/public/assets/biter_right.png
new file mode 100644
index 00000000..427116b2
Binary files /dev/null and b/web/public/assets/biter_right.png differ
diff --git a/web/public/assets/boiler.png b/web/public/assets/boiler.png
new file mode 100644
index 00000000..61c1b47b
Binary files /dev/null and b/web/public/assets/boiler.png differ
diff --git a/web/public/assets/build.png b/web/public/assets/build.png
new file mode 100644
index 00000000..9fd98e43
Binary files /dev/null and b/web/public/assets/build.png differ
diff --git a/web/public/assets/building_placeholder.png b/web/public/assets/building_placeholder.png
new file mode 100644
index 00000000..deb85510
Binary files /dev/null and b/web/public/assets/building_placeholder.png differ
diff --git a/web/public/assets/bullet.png b/web/public/assets/bullet.png
new file mode 100644
index 00000000..47d9d845
Binary files /dev/null and b/web/public/assets/bullet.png differ
diff --git a/web/public/assets/bullet_down.png b/web/public/assets/bullet_down.png
new file mode 100644
index 00000000..61c630f0
Binary files /dev/null and b/web/public/assets/bullet_down.png differ
diff --git a/web/public/assets/bullet_left.png b/web/public/assets/bullet_left.png
new file mode 100644
index 00000000..776d6158
Binary files /dev/null and b/web/public/assets/bullet_left.png differ
diff --git a/web/public/assets/bullet_right.png b/web/public/assets/bullet_right.png
new file mode 100644
index 00000000..e4327e32
Binary files /dev/null and b/web/public/assets/bullet_right.png differ
diff --git a/web/public/assets/bullet_up.png b/web/public/assets/bullet_up.png
new file mode 100644
index 00000000..09ef8336
Binary files /dev/null and b/web/public/assets/bullet_up.png differ
diff --git a/web/public/assets/burner_mining_drill.png b/web/public/assets/burner_mining_drill.png
new file mode 100644
index 00000000..354962be
Binary files /dev/null and b/web/public/assets/burner_mining_drill.png differ
diff --git a/web/public/assets/buy.png b/web/public/assets/buy.png
new file mode 100644
index 00000000..70f186b8
Binary files /dev/null and b/web/public/assets/buy.png differ
diff --git a/web/public/assets/cancel_button.png b/web/public/assets/cancel_button.png
new file mode 100644
index 00000000..bb0a43b8
Binary files /dev/null and b/web/public/assets/cancel_button.png differ
diff --git a/web/public/assets/centrifuge.png b/web/public/assets/centrifuge.png
new file mode 100644
index 00000000..8f86be9f
Binary files /dev/null and b/web/public/assets/centrifuge.png differ
diff --git a/web/public/assets/chemical_plant.png b/web/public/assets/chemical_plant.png
new file mode 100644
index 00000000..040587ff
Binary files /dev/null and b/web/public/assets/chemical_plant.png differ
diff --git a/web/public/assets/chemical_science_pack.png b/web/public/assets/chemical_science_pack.png
new file mode 100644
index 00000000..b2898214
Binary files /dev/null and b/web/public/assets/chemical_science_pack.png differ
diff --git a/web/public/assets/chest.png b/web/public/assets/chest.png
new file mode 100644
index 00000000..861df0c2
Binary files /dev/null and b/web/public/assets/chest.png differ
diff --git a/web/public/assets/clear.png b/web/public/assets/clear.png
new file mode 100644
index 00000000..14fcee1c
Binary files /dev/null and b/web/public/assets/clear.png differ
diff --git a/web/public/assets/coal.png b/web/public/assets/coal.png
new file mode 100644
index 00000000..16c7af9d
Binary files /dev/null and b/web/public/assets/coal.png differ
diff --git a/web/public/assets/coal_2.png b/web/public/assets/coal_2.png
new file mode 100644
index 00000000..370ec726
Binary files /dev/null and b/web/public/assets/coal_2.png differ
diff --git a/web/public/assets/copper_cable.png b/web/public/assets/copper_cable.png
new file mode 100644
index 00000000..630a9e0f
Binary files /dev/null and b/web/public/assets/copper_cable.png differ
diff --git a/web/public/assets/copper_ore _2.png b/web/public/assets/copper_ore _2.png
new file mode 100644
index 00000000..7191d293
Binary files /dev/null and b/web/public/assets/copper_ore _2.png differ
diff --git a/web/public/assets/copper_ore.png b/web/public/assets/copper_ore.png
new file mode 100644
index 00000000..c43a59c9
Binary files /dev/null and b/web/public/assets/copper_ore.png differ
diff --git a/web/public/assets/copper_plate.png b/web/public/assets/copper_plate.png
new file mode 100644
index 00000000..72b31cdc
Binary files /dev/null and b/web/public/assets/copper_plate.png differ
diff --git a/web/public/assets/cross_pipe.png b/web/public/assets/cross_pipe.png
new file mode 100644
index 00000000..b52a05ec
Binary files /dev/null and b/web/public/assets/cross_pipe.png differ
diff --git a/web/public/assets/crude_oil.png b/web/public/assets/crude_oil.png
new file mode 100644
index 00000000..5bce2205
Binary files /dev/null and b/web/public/assets/crude_oil.png differ
diff --git a/web/public/assets/delete_game.png b/web/public/assets/delete_game.png
new file mode 100644
index 00000000..b172bcf7
Binary files /dev/null and b/web/public/assets/delete_game.png differ
diff --git a/web/public/assets/dirt.png b/web/public/assets/dirt.png
new file mode 100644
index 00000000..18884307
Binary files /dev/null and b/web/public/assets/dirt.png differ
diff --git a/web/public/assets/disable_audio.png b/web/public/assets/disable_audio.png
new file mode 100644
index 00000000..a6561fb8
Binary files /dev/null and b/web/public/assets/disable_audio.png differ
diff --git a/web/public/assets/electric_engine_unit.png b/web/public/assets/electric_engine_unit.png
new file mode 100644
index 00000000..c32cb112
Binary files /dev/null and b/web/public/assets/electric_engine_unit.png differ
diff --git a/web/public/assets/electric_furnace.png b/web/public/assets/electric_furnace.png
new file mode 100644
index 00000000..8ae51ca3
Binary files /dev/null and b/web/public/assets/electric_furnace.png differ
diff --git a/web/public/assets/electric_mining_drill.png b/web/public/assets/electric_mining_drill.png
new file mode 100644
index 00000000..b3f64f26
Binary files /dev/null and b/web/public/assets/electric_mining_drill.png differ
diff --git a/web/public/assets/electronic_circuit.png b/web/public/assets/electronic_circuit.png
new file mode 100644
index 00000000..5ccebdc9
Binary files /dev/null and b/web/public/assets/electronic_circuit.png differ
diff --git a/web/public/assets/engine_unit.png b/web/public/assets/engine_unit.png
new file mode 100644
index 00000000..4f5c22f0
Binary files /dev/null and b/web/public/assets/engine_unit.png differ
diff --git a/web/public/assets/explosives.png b/web/public/assets/explosives.png
new file mode 100644
index 00000000..899baa63
Binary files /dev/null and b/web/public/assets/explosives.png differ
diff --git a/web/public/assets/express_transport_belt.png b/web/public/assets/express_transport_belt.png
new file mode 100644
index 00000000..c9ff011a
Binary files /dev/null and b/web/public/assets/express_transport_belt.png differ
diff --git a/web/public/assets/fast_inserter.png b/web/public/assets/fast_inserter.png
new file mode 100644
index 00000000..2383d97c
Binary files /dev/null and b/web/public/assets/fast_inserter.png differ
diff --git a/web/public/assets/fast_transport_belt.png b/web/public/assets/fast_transport_belt.png
new file mode 100644
index 00000000..2c1df31d
Binary files /dev/null and b/web/public/assets/fast_transport_belt.png differ
diff --git a/web/public/assets/firearm_magazine.png b/web/public/assets/firearm_magazine.png
new file mode 100644
index 00000000..b36b60c7
Binary files /dev/null and b/web/public/assets/firearm_magazine.png differ
diff --git a/web/public/assets/fluid_tank.png b/web/public/assets/fluid_tank.png
new file mode 100644
index 00000000..669e9575
Binary files /dev/null and b/web/public/assets/fluid_tank.png differ
diff --git a/web/public/assets/furnace.png b/web/public/assets/furnace.png
new file mode 100644
index 00000000..eca2c57a
Binary files /dev/null and b/web/public/assets/furnace.png differ
diff --git a/web/public/assets/gear.png b/web/public/assets/gear.png
new file mode 100644
index 00000000..59f6cf12
Binary files /dev/null and b/web/public/assets/gear.png differ
diff --git a/web/public/assets/grass.png b/web/public/assets/grass.png
new file mode 100644
index 00000000..2144fdbc
Binary files /dev/null and b/web/public/assets/grass.png differ
diff --git a/web/public/assets/grenade.png b/web/public/assets/grenade.png
new file mode 100644
index 00000000..4b222f4e
Binary files /dev/null and b/web/public/assets/grenade.png differ
diff --git a/web/public/assets/gun_turret.png b/web/public/assets/gun_turret.png
new file mode 100644
index 00000000..2930cfd8
Binary files /dev/null and b/web/public/assets/gun_turret.png differ
diff --git a/web/public/assets/heavy_oil.png b/web/public/assets/heavy_oil.png
new file mode 100644
index 00000000..155a4e28
Binary files /dev/null and b/web/public/assets/heavy_oil.png differ
diff --git a/web/public/assets/help.png b/web/public/assets/help.png
new file mode 100644
index 00000000..1e500217
Binary files /dev/null and b/web/public/assets/help.png differ
diff --git a/web/public/assets/inserter.png b/web/public/assets/inserter.png
new file mode 100644
index 00000000..9cb93e6e
Binary files /dev/null and b/web/public/assets/inserter.png differ
diff --git a/web/public/assets/inserter_cancel_button.png b/web/public/assets/inserter_cancel_button.png
new file mode 100644
index 00000000..bb0a43b8
Binary files /dev/null and b/web/public/assets/inserter_cancel_button.png differ
diff --git a/web/public/assets/inserter_input_button.png b/web/public/assets/inserter_input_button.png
new file mode 100644
index 00000000..e5a6476e
Binary files /dev/null and b/web/public/assets/inserter_input_button.png differ
diff --git a/web/public/assets/inserter_output_button.png b/web/public/assets/inserter_output_button.png
new file mode 100644
index 00000000..32e43ded
Binary files /dev/null and b/web/public/assets/inserter_output_button.png differ
diff --git a/web/public/assets/inserters_sheet.png b/web/public/assets/inserters_sheet.png
new file mode 100644
index 00000000..2e14ef5f
Binary files /dev/null and b/web/public/assets/inserters_sheet.png differ
diff --git a/web/public/assets/inventory.png b/web/public/assets/inventory.png
new file mode 100644
index 00000000..43724b41
Binary files /dev/null and b/web/public/assets/inventory.png differ
diff --git a/web/public/assets/iron_chest.png b/web/public/assets/iron_chest.png
new file mode 100644
index 00000000..afa2630a
Binary files /dev/null and b/web/public/assets/iron_chest.png differ
diff --git a/web/public/assets/iron_gear_wheel.png b/web/public/assets/iron_gear_wheel.png
new file mode 100644
index 00000000..59f6cf12
Binary files /dev/null and b/web/public/assets/iron_gear_wheel.png differ
diff --git a/web/public/assets/iron_ore.png b/web/public/assets/iron_ore.png
new file mode 100644
index 00000000..800fbfd7
Binary files /dev/null and b/web/public/assets/iron_ore.png differ
diff --git a/web/public/assets/iron_ore_2.png b/web/public/assets/iron_ore_2.png
new file mode 100644
index 00000000..ca4fc05b
Binary files /dev/null and b/web/public/assets/iron_ore_2.png differ
diff --git a/web/public/assets/iron_plate.png b/web/public/assets/iron_plate.png
new file mode 100644
index 00000000..ff8ff0ae
Binary files /dev/null and b/web/public/assets/iron_plate.png differ
diff --git a/web/public/assets/lab.png b/web/public/assets/lab.png
new file mode 100644
index 00000000..c9f42ae2
Binary files /dev/null and b/web/public/assets/lab.png differ
diff --git a/web/public/assets/laser_turret.png b/web/public/assets/laser_turret.png
new file mode 100644
index 00000000..47a161e9
Binary files /dev/null and b/web/public/assets/laser_turret.png differ
diff --git a/web/public/assets/light_oil.png b/web/public/assets/light_oil.png
new file mode 100644
index 00000000..e29ac5b3
Binary files /dev/null and b/web/public/assets/light_oil.png differ
diff --git a/web/public/assets/load_game.png b/web/public/assets/load_game.png
new file mode 100644
index 00000000..5fa53c99
Binary files /dev/null and b/web/public/assets/load_game.png differ
diff --git a/web/public/assets/logistic_science_pack.png b/web/public/assets/logistic_science_pack.png
new file mode 100644
index 00000000..dc9f6955
Binary files /dev/null and b/web/public/assets/logistic_science_pack.png differ
diff --git a/web/public/assets/long_handed_inserter.png b/web/public/assets/long_handed_inserter.png
new file mode 100644
index 00000000..774372d9
Binary files /dev/null and b/web/public/assets/long_handed_inserter.png differ
diff --git a/web/public/assets/low_density_structure.png b/web/public/assets/low_density_structure.png
new file mode 100644
index 00000000..09cf000b
Binary files /dev/null and b/web/public/assets/low_density_structure.png differ
diff --git a/web/public/assets/lubricant.png b/web/public/assets/lubricant.png
new file mode 100644
index 00000000..6de837df
Binary files /dev/null and b/web/public/assets/lubricant.png differ
diff --git a/web/public/assets/manifest.json b/web/public/assets/manifest.json
new file mode 100644
index 00000000..a2365a15
--- /dev/null
+++ b/web/public/assets/manifest.json
@@ -0,0 +1,166 @@
+[
+ "accumulator",
+ "advanced_circuit",
+ "AppIcon",
+ "AppIcon_1024x1024",
+ "AppIcon_120x120",
+ "AppIcon_180x180",
+ "AppIcon_40x40",
+ "AppIcon_58x58",
+ "AppIcon_60x60",
+ "AppIcon_80x80",
+ "AppIcon_87x87",
+ "assembler",
+ "assembling_machine_1",
+ "assembling_machine_2",
+ "assembling_machine_3",
+ "automation_science_pack",
+ "battery",
+ "belt",
+ "belt_bridge",
+ "big_electric_pole",
+ "biter",
+ "biter_left",
+ "biter_right",
+ "boiler",
+ "build",
+ "building_placeholder",
+ "bullet",
+ "bullet_down",
+ "bullet_left",
+ "bullet_right",
+ "bullet_up",
+ "burner_mining_drill",
+ "buy",
+ "cancel_button",
+ "centrifuge",
+ "chemical_plant",
+ "chemical_science_pack",
+ "chest",
+ "clear",
+ "coal",
+ "coal_2",
+ "copper_cable",
+ "copper_ore",
+ "copper_ore _2",
+ "copper_plate",
+ "cross_pipe",
+ "crude_oil",
+ "delete_game",
+ "dirt",
+ "disable_audio",
+ "electric_engine_unit",
+ "electric_furnace",
+ "electric_mining_drill",
+ "electronic_circuit",
+ "engine_unit",
+ "explosives",
+ "express_transport_belt",
+ "fast_inserter",
+ "fast_transport_belt",
+ "firearm_magazine",
+ "fluid_tank",
+ "furnace",
+ "gear",
+ "grass",
+ "grenade",
+ "gun_turret",
+ "heavy_oil",
+ "help",
+ "inserter",
+ "inserter_cancel_button",
+ "inserter_input_button",
+ "inserter_output_button",
+ "inserters_sheet",
+ "inventory",
+ "iron_chest",
+ "iron_gear_wheel",
+ "iron_ore",
+ "iron_ore_2",
+ "iron_plate",
+ "lab",
+ "laser_turret",
+ "light_oil",
+ "load_game",
+ "logistic_science_pack",
+ "long_handed_inserter",
+ "low_density_structure",
+ "lubricant",
+ "medium_electric_pole",
+ "menu",
+ "merger",
+ "military_science_pack",
+ "miner",
+ "move",
+ "new_game",
+ "nuclear_fuel",
+ "nuclear_reactor",
+ "offshore_pump",
+ "oil_refinery",
+ "petroleum_gas",
+ "piercing_rounds_magazine",
+ "pipe",
+ "pipe_2",
+ "plastic_bar",
+ "player",
+ "player_down",
+ "player_left",
+ "player_right",
+ "player_up",
+ "power_pole",
+ "processing_unit",
+ "production_science_pack",
+ "pumpjack",
+ "radar",
+ "recycle",
+ "research",
+ "right_arrow",
+ "rocket",
+ "rocket_fuel",
+ "rocket_parts",
+ "rocket_silo",
+ "rotate",
+ "sand",
+ "satellite",
+ "save_game",
+ "small_electric_pole",
+ "solar_panel",
+ "solid_fuel",
+ "solid_white",
+ "space_science_pack",
+ "spawner",
+ "splash",
+ "splitter",
+ "stack_inserter",
+ "steam_engine",
+ "steel_chest",
+ "steel_furnace",
+ "steel_plate",
+ "stone",
+ "stone_2",
+ "stone_brick",
+ "stone_furnace",
+ "stone_wall",
+ "sulfur",
+ "sulfuric_acid",
+ "tee_pipe",
+ "transport_belt",
+ "transport_belt_animation",
+ "transport_belt_animation copy",
+ "transport_belt_animation copy 2",
+ "trash",
+ "tree",
+ "turret",
+ "underground_belt",
+ "underground_pipe",
+ "uranium_235",
+ "uranium_238",
+ "uranium_ore",
+ "uranium_processing",
+ "utility_science_pack",
+ "wall",
+ "water",
+ "water_pump",
+ "wood",
+ "wooden_chest"
+]
diff --git a/web/public/assets/medium_electric_pole.png b/web/public/assets/medium_electric_pole.png
new file mode 100644
index 00000000..8445fd56
Binary files /dev/null and b/web/public/assets/medium_electric_pole.png differ
diff --git a/web/public/assets/menu.png b/web/public/assets/menu.png
new file mode 100644
index 00000000..f24adb92
Binary files /dev/null and b/web/public/assets/menu.png differ
diff --git a/web/public/assets/merger.png b/web/public/assets/merger.png
new file mode 100644
index 00000000..c375b074
Binary files /dev/null and b/web/public/assets/merger.png differ
diff --git a/web/public/assets/military_science_pack.png b/web/public/assets/military_science_pack.png
new file mode 100644
index 00000000..a0ae847a
Binary files /dev/null and b/web/public/assets/military_science_pack.png differ
diff --git a/web/public/assets/miner.png b/web/public/assets/miner.png
new file mode 100644
index 00000000..354962be
Binary files /dev/null and b/web/public/assets/miner.png differ
diff --git a/web/public/assets/move.png b/web/public/assets/move.png
new file mode 100644
index 00000000..7a2209d7
Binary files /dev/null and b/web/public/assets/move.png differ
diff --git a/web/public/assets/new_game.png b/web/public/assets/new_game.png
new file mode 100644
index 00000000..68faf030
Binary files /dev/null and b/web/public/assets/new_game.png differ
diff --git a/web/public/assets/nuclear_fuel.png b/web/public/assets/nuclear_fuel.png
new file mode 100644
index 00000000..2673d0ac
Binary files /dev/null and b/web/public/assets/nuclear_fuel.png differ
diff --git a/web/public/assets/nuclear_reactor.png b/web/public/assets/nuclear_reactor.png
new file mode 100644
index 00000000..e962f40e
Binary files /dev/null and b/web/public/assets/nuclear_reactor.png differ
diff --git a/web/public/assets/offshore_pump.png b/web/public/assets/offshore_pump.png
new file mode 100644
index 00000000..d9950dc4
Binary files /dev/null and b/web/public/assets/offshore_pump.png differ
diff --git a/web/public/assets/oil_refinery.png b/web/public/assets/oil_refinery.png
new file mode 100644
index 00000000..b3b2adfb
Binary files /dev/null and b/web/public/assets/oil_refinery.png differ
diff --git a/web/public/assets/petroleum_gas.png b/web/public/assets/petroleum_gas.png
new file mode 100644
index 00000000..eec39391
Binary files /dev/null and b/web/public/assets/petroleum_gas.png differ
diff --git a/web/public/assets/piercing_rounds_magazine.png b/web/public/assets/piercing_rounds_magazine.png
new file mode 100644
index 00000000..2bfa7605
Binary files /dev/null and b/web/public/assets/piercing_rounds_magazine.png differ
diff --git a/web/public/assets/pipe.png b/web/public/assets/pipe.png
new file mode 100644
index 00000000..ffaace3d
Binary files /dev/null and b/web/public/assets/pipe.png differ
diff --git a/web/public/assets/pipe_2.png b/web/public/assets/pipe_2.png
new file mode 100644
index 00000000..4cbd2219
Binary files /dev/null and b/web/public/assets/pipe_2.png differ
diff --git a/web/public/assets/plastic_bar.png b/web/public/assets/plastic_bar.png
new file mode 100644
index 00000000..1c9ef314
Binary files /dev/null and b/web/public/assets/plastic_bar.png differ
diff --git a/web/public/assets/player.png b/web/public/assets/player.png
new file mode 100644
index 00000000..d508bea8
Binary files /dev/null and b/web/public/assets/player.png differ
diff --git a/web/public/assets/player_down.png b/web/public/assets/player_down.png
new file mode 100644
index 00000000..d01379d0
Binary files /dev/null and b/web/public/assets/player_down.png differ
diff --git a/web/public/assets/player_left.png b/web/public/assets/player_left.png
new file mode 100644
index 00000000..2b1098b0
Binary files /dev/null and b/web/public/assets/player_left.png differ
diff --git a/web/public/assets/player_right.png b/web/public/assets/player_right.png
new file mode 100644
index 00000000..407e719b
Binary files /dev/null and b/web/public/assets/player_right.png differ
diff --git a/web/public/assets/player_up.png b/web/public/assets/player_up.png
new file mode 100644
index 00000000..6558e5ec
Binary files /dev/null and b/web/public/assets/player_up.png differ
diff --git a/web/public/assets/power_pole.png b/web/public/assets/power_pole.png
new file mode 100644
index 00000000..8cb8b3aa
Binary files /dev/null and b/web/public/assets/power_pole.png differ
diff --git a/web/public/assets/processing_unit.png b/web/public/assets/processing_unit.png
new file mode 100644
index 00000000..3a120d13
Binary files /dev/null and b/web/public/assets/processing_unit.png differ
diff --git a/web/public/assets/production_science_pack.png b/web/public/assets/production_science_pack.png
new file mode 100644
index 00000000..63eedc08
Binary files /dev/null and b/web/public/assets/production_science_pack.png differ
diff --git a/web/public/assets/pumpjack.png b/web/public/assets/pumpjack.png
new file mode 100644
index 00000000..a970b38e
Binary files /dev/null and b/web/public/assets/pumpjack.png differ
diff --git a/web/public/assets/radar.png b/web/public/assets/radar.png
new file mode 100644
index 00000000..0c783799
Binary files /dev/null and b/web/public/assets/radar.png differ
diff --git a/web/public/assets/recycle.png b/web/public/assets/recycle.png
new file mode 100644
index 00000000..7b908984
Binary files /dev/null and b/web/public/assets/recycle.png differ
diff --git a/web/public/assets/research.png b/web/public/assets/research.png
new file mode 100644
index 00000000..8d0702ad
Binary files /dev/null and b/web/public/assets/research.png differ
diff --git a/web/public/assets/right_arrow.png b/web/public/assets/right_arrow.png
new file mode 100644
index 00000000..e926051c
Binary files /dev/null and b/web/public/assets/right_arrow.png differ
diff --git a/web/public/assets/rocket.png b/web/public/assets/rocket.png
new file mode 100644
index 00000000..26d8f55e
Binary files /dev/null and b/web/public/assets/rocket.png differ
diff --git a/web/public/assets/rocket_fuel.png b/web/public/assets/rocket_fuel.png
new file mode 100644
index 00000000..18f62f72
Binary files /dev/null and b/web/public/assets/rocket_fuel.png differ
diff --git a/web/public/assets/rocket_parts.png b/web/public/assets/rocket_parts.png
new file mode 100644
index 00000000..4afc4adf
Binary files /dev/null and b/web/public/assets/rocket_parts.png differ
diff --git a/web/public/assets/rocket_silo.png b/web/public/assets/rocket_silo.png
new file mode 100644
index 00000000..a109c512
Binary files /dev/null and b/web/public/assets/rocket_silo.png differ
diff --git a/web/public/assets/rotate.png b/web/public/assets/rotate.png
new file mode 100644
index 00000000..1ec2250c
Binary files /dev/null and b/web/public/assets/rotate.png differ
diff --git a/web/public/assets/sand.png b/web/public/assets/sand.png
new file mode 100644
index 00000000..503e675b
Binary files /dev/null and b/web/public/assets/sand.png differ
diff --git a/web/public/assets/satellite.png b/web/public/assets/satellite.png
new file mode 100644
index 00000000..3aa3c8c0
Binary files /dev/null and b/web/public/assets/satellite.png differ
diff --git a/web/public/assets/save_game.png b/web/public/assets/save_game.png
new file mode 100644
index 00000000..e8219a4b
Binary files /dev/null and b/web/public/assets/save_game.png differ
diff --git a/web/public/assets/small_electric_pole.png b/web/public/assets/small_electric_pole.png
new file mode 100644
index 00000000..8cb8b3aa
Binary files /dev/null and b/web/public/assets/small_electric_pole.png differ
diff --git a/web/public/assets/solar_panel.png b/web/public/assets/solar_panel.png
new file mode 100644
index 00000000..eb353f43
Binary files /dev/null and b/web/public/assets/solar_panel.png differ
diff --git a/web/public/assets/solid_fuel.png b/web/public/assets/solid_fuel.png
new file mode 100644
index 00000000..fc54f573
Binary files /dev/null and b/web/public/assets/solid_fuel.png differ
diff --git a/web/public/assets/solid_white.png b/web/public/assets/solid_white.png
new file mode 100644
index 00000000..72d71e87
Binary files /dev/null and b/web/public/assets/solid_white.png differ
diff --git a/web/public/assets/space_science_pack.png b/web/public/assets/space_science_pack.png
new file mode 100644
index 00000000..6fe31432
Binary files /dev/null and b/web/public/assets/space_science_pack.png differ
diff --git a/web/public/assets/spawner.png b/web/public/assets/spawner.png
new file mode 100644
index 00000000..5d35485c
Binary files /dev/null and b/web/public/assets/spawner.png differ
diff --git a/web/public/assets/splash.png b/web/public/assets/splash.png
new file mode 100644
index 00000000..a6e98d9e
Binary files /dev/null and b/web/public/assets/splash.png differ
diff --git a/web/public/assets/splitter.png b/web/public/assets/splitter.png
new file mode 100644
index 00000000..ab313e3e
Binary files /dev/null and b/web/public/assets/splitter.png differ
diff --git a/web/public/assets/stack_inserter.png b/web/public/assets/stack_inserter.png
new file mode 100644
index 00000000..85efe209
Binary files /dev/null and b/web/public/assets/stack_inserter.png differ
diff --git a/web/public/assets/steam_engine.png b/web/public/assets/steam_engine.png
new file mode 100644
index 00000000..8caa28b8
Binary files /dev/null and b/web/public/assets/steam_engine.png differ
diff --git a/web/public/assets/steel_chest.png b/web/public/assets/steel_chest.png
new file mode 100644
index 00000000..ee4841ed
Binary files /dev/null and b/web/public/assets/steel_chest.png differ
diff --git a/web/public/assets/steel_furnace.png b/web/public/assets/steel_furnace.png
new file mode 100644
index 00000000..7ee30107
Binary files /dev/null and b/web/public/assets/steel_furnace.png differ
diff --git a/web/public/assets/steel_plate.png b/web/public/assets/steel_plate.png
new file mode 100644
index 00000000..cee0706e
Binary files /dev/null and b/web/public/assets/steel_plate.png differ
diff --git a/web/public/assets/stone.png b/web/public/assets/stone.png
new file mode 100644
index 00000000..d3d928af
Binary files /dev/null and b/web/public/assets/stone.png differ
diff --git a/web/public/assets/stone_2.png b/web/public/assets/stone_2.png
new file mode 100644
index 00000000..4e85d8d5
Binary files /dev/null and b/web/public/assets/stone_2.png differ
diff --git a/web/public/assets/stone_brick.png b/web/public/assets/stone_brick.png
new file mode 100644
index 00000000..fafa3442
Binary files /dev/null and b/web/public/assets/stone_brick.png differ
diff --git a/web/public/assets/stone_furnace.png b/web/public/assets/stone_furnace.png
new file mode 100644
index 00000000..eca2c57a
Binary files /dev/null and b/web/public/assets/stone_furnace.png differ
diff --git a/web/public/assets/stone_wall.png b/web/public/assets/stone_wall.png
new file mode 100644
index 00000000..c82cf3a4
Binary files /dev/null and b/web/public/assets/stone_wall.png differ
diff --git a/web/public/assets/sulfur.png b/web/public/assets/sulfur.png
new file mode 100644
index 00000000..6ba33959
Binary files /dev/null and b/web/public/assets/sulfur.png differ
diff --git a/web/public/assets/sulfuric_acid.png b/web/public/assets/sulfuric_acid.png
new file mode 100644
index 00000000..6660fd9f
Binary files /dev/null and b/web/public/assets/sulfuric_acid.png differ
diff --git a/web/public/assets/tee_pipe.png b/web/public/assets/tee_pipe.png
new file mode 100644
index 00000000..f045a399
Binary files /dev/null and b/web/public/assets/tee_pipe.png differ
diff --git a/web/public/assets/transport_belt.png b/web/public/assets/transport_belt.png
new file mode 100644
index 00000000..4c1afc23
Binary files /dev/null and b/web/public/assets/transport_belt.png differ
diff --git a/web/public/assets/transport_belt_animation copy 2.png b/web/public/assets/transport_belt_animation copy 2.png
new file mode 100644
index 00000000..4f780b47
Binary files /dev/null and b/web/public/assets/transport_belt_animation copy 2.png differ
diff --git a/web/public/assets/transport_belt_animation copy.png b/web/public/assets/transport_belt_animation copy.png
new file mode 100644
index 00000000..8ec20ee0
Binary files /dev/null and b/web/public/assets/transport_belt_animation copy.png differ
diff --git a/web/public/assets/transport_belt_animation.png b/web/public/assets/transport_belt_animation.png
new file mode 100644
index 00000000..a0d9c250
Binary files /dev/null and b/web/public/assets/transport_belt_animation.png differ
diff --git a/web/public/assets/trash.png b/web/public/assets/trash.png
new file mode 100644
index 00000000..34cf8c4d
Binary files /dev/null and b/web/public/assets/trash.png differ
diff --git a/web/public/assets/tree.png b/web/public/assets/tree.png
new file mode 100644
index 00000000..e0a3a6cc
Binary files /dev/null and b/web/public/assets/tree.png differ
diff --git a/web/public/assets/turret.png b/web/public/assets/turret.png
new file mode 100644
index 00000000..2930cfd8
Binary files /dev/null and b/web/public/assets/turret.png differ
diff --git a/web/public/assets/underground_belt.png b/web/public/assets/underground_belt.png
new file mode 100644
index 00000000..2d3bdc72
Binary files /dev/null and b/web/public/assets/underground_belt.png differ
diff --git a/web/public/assets/underground_pipe.png b/web/public/assets/underground_pipe.png
new file mode 100644
index 00000000..8d8f22a8
Binary files /dev/null and b/web/public/assets/underground_pipe.png differ
diff --git a/web/public/assets/uranium_235.png b/web/public/assets/uranium_235.png
new file mode 100644
index 00000000..3a75c966
Binary files /dev/null and b/web/public/assets/uranium_235.png differ
diff --git a/web/public/assets/uranium_238.png b/web/public/assets/uranium_238.png
new file mode 100644
index 00000000..e5be35c0
Binary files /dev/null and b/web/public/assets/uranium_238.png differ
diff --git a/web/public/assets/uranium_ore.png b/web/public/assets/uranium_ore.png
new file mode 100644
index 00000000..9033ba10
Binary files /dev/null and b/web/public/assets/uranium_ore.png differ
diff --git a/web/public/assets/uranium_processing.png b/web/public/assets/uranium_processing.png
new file mode 100644
index 00000000..5aebd505
Binary files /dev/null and b/web/public/assets/uranium_processing.png differ
diff --git a/web/public/assets/utility_science_pack.png b/web/public/assets/utility_science_pack.png
new file mode 100644
index 00000000..24f6804a
Binary files /dev/null and b/web/public/assets/utility_science_pack.png differ
diff --git a/web/public/assets/wall.png b/web/public/assets/wall.png
new file mode 100644
index 00000000..668c36e2
Binary files /dev/null and b/web/public/assets/wall.png differ
diff --git a/web/public/assets/water.png b/web/public/assets/water.png
new file mode 100644
index 00000000..487af948
Binary files /dev/null and b/web/public/assets/water.png differ
diff --git a/web/public/assets/water_pump.png b/web/public/assets/water_pump.png
new file mode 100644
index 00000000..d9950dc4
Binary files /dev/null and b/web/public/assets/water_pump.png differ
diff --git a/web/public/assets/wood.png b/web/public/assets/wood.png
new file mode 100644
index 00000000..2f29b1ef
Binary files /dev/null and b/web/public/assets/wood.png differ
diff --git a/web/public/assets/wooden_chest.png b/web/public/assets/wooden_chest.png
new file mode 100644
index 00000000..861df0c2
Binary files /dev/null and b/web/public/assets/wooden_chest.png differ
diff --git a/web/scripts/README.md b/web/scripts/README.md
new file mode 100644
index 00000000..1ea1d1ea
--- /dev/null
+++ b/web/scripts/README.md
@@ -0,0 +1,3 @@
+# Regenerated when copying sprites from FactoryForge/Assets
+# Run from repo root:
+# powershell -File web/scripts/sync-assets.ps1
diff --git a/web/scripts/sync-assets.ps1 b/web/scripts/sync-assets.ps1
new file mode 100644
index 00000000..f1546a62
--- /dev/null
+++ b/web/scripts/sync-assets.ps1
@@ -0,0 +1,12 @@
+$ErrorActionPreference = "Stop"
+$root = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
+if (-not (Test-Path "$root/FactoryForge/Assets")) {
+ $root = Resolve-Path (Join-Path $PSScriptRoot "../..")
+}
+$src = Join-Path $root "FactoryForge/Assets"
+$dst = Join-Path $root "web/public/assets"
+New-Item -ItemType Directory -Force -Path $dst | Out-Null
+Copy-Item -Path "$src/*.png" -Destination $dst -Force
+$names = Get-ChildItem $dst -Filter *.png | ForEach-Object { $_.BaseName } | Sort-Object
+$names | ConvertTo-Json | Set-Content -Encoding utf8 (Join-Path $dst "manifest.json")
+Write-Host "Synced $($names.Count) PNGs to web/public/assets"
diff --git a/web/server/index.ts b/web/server/index.ts
new file mode 100644
index 00000000..fd5186ae
--- /dev/null
+++ b/web/server/index.ts
@@ -0,0 +1,116 @@
+import { WebSocketServer, WebSocket } from 'ws';
+import {
+ decodeMessage,
+ encodeMessage,
+ type GameRules,
+ type NetworkMessage,
+ type PlayerAction,
+} from '../shared/net/protocol.ts';
+import { GameLoop } from '../src/engine/core/GameLoop.ts';
+
+const PORT = Number(process.env.PORT ?? 8080);
+
+interface Client {
+ ws: WebSocket;
+ playerId: number;
+}
+
+const clients = new Map();
+let nextPlayerId = 1;
+const seed = Math.floor(Math.random() * 1e9);
+const rules: GameRules = { seed, friendlyFire: false, maxPlayers: 8, tickRate: 60 };
+
+const game = new GameLoop(seed, true);
+game.matchMode = 'freeForAll';
+
+const wss = new WebSocketServer({ port: PORT });
+console.log(`FactoryForge MP server listening on ws://localhost:${PORT}`);
+
+function broadcast(msg: NetworkMessage, except?: WebSocket): void {
+ const data = encodeMessage(msg);
+ for (const [ws] of clients) {
+ if (ws !== except && ws.readyState === WebSocket.OPEN) ws.send(data);
+ }
+}
+
+function applyAction(playerId: number, action: PlayerAction): void {
+ if (action.type === 'build') {
+ game.placeBuilding(action.buildingId, action.position.x, action.position.y, action.direction as 0 | 1 | 2 | 3, playerId);
+ }
+ if (action.type === 'move') {
+ const p = game.playerManager.getPlayer(playerId);
+ if (!p) return;
+ // soft teleport / set target — for demo set position
+ game.world.updatePosition(p.entity, Math.floor(action.position.x), Math.floor(action.position.y));
+ }
+ if (action.type === 'mine') {
+ game.tryMineOrInteract(action.x, action.y);
+ }
+ if (action.type === 'shoot') {
+ const p = game.playerManager.getPlayer(playerId);
+ if (p) game.combatSystem.playerShoot(p.entity, action.x, action.y, playerId);
+ }
+}
+
+wss.on('connection', (ws) => {
+ const playerId = nextPlayerId++;
+ // First client reuses local player; others spawn
+ if (playerId > 1) {
+ const angle = playerId * 1.2;
+ game.playerManager.createPlayer(`P${playerId}`, false, Math.round(Math.cos(angle) * 30), Math.round(Math.sin(angle) * 30));
+ // Map network id to last created — PlayerManager nextId already assigned
+ }
+ clients.set(ws, { ws, playerId });
+
+ const handshake: NetworkMessage = { type: 'handshake', seed, rules, playerId };
+ ws.send(encodeMessage(handshake));
+
+ const snapshot: NetworkMessage = {
+ type: 'snapshot',
+ tick: 0,
+ worldData: { entities: game.world.serializeEntities() },
+ };
+ ws.send(encodeMessage(snapshot));
+
+ ws.on('message', (raw) => {
+ let msg: NetworkMessage;
+ try {
+ msg = decodeMessage(String(raw));
+ } catch {
+ return;
+ }
+ if (msg.type === 'ping') {
+ ws.send(encodeMessage({ type: 'pong', timestamp: msg.timestamp }));
+ return;
+ }
+ if (msg.type === 'command') {
+ applyAction(msg.playerId, msg.action);
+ broadcast(
+ {
+ type: 'delta',
+ tick: 0,
+ deltas: [{ type: 'upsert', networkEntityId: 0, entityData: { action: msg.action } }],
+ },
+ ws,
+ );
+ }
+ });
+
+ ws.on('close', () => {
+ clients.delete(ws);
+ });
+});
+
+// Authoritative tick
+setInterval(() => {
+ game.frame(0, 0);
+ if (clients.size === 0) return;
+ // Periodic resync
+ if (Math.random() < 0.02) {
+ broadcast({
+ type: 'resync',
+ tick: 0,
+ worldData: { entities: game.world.serializeEntities() },
+ });
+ }
+}, 1000 / rules.tickRate);
diff --git a/web/shared/data/assembler_schema.json b/web/shared/data/assembler_schema.json
new file mode 100644
index 00000000..9f0ac743
--- /dev/null
+++ b/web/shared/data/assembler_schema.json
@@ -0,0 +1,94 @@
+{
+ "$schema": "https://example.com/machineui.schema.json",
+ "version": "1.0.0",
+ "id": "machineui.assembler",
+ "machineKind": "assembler",
+ "title": "Assembling Machine",
+ "layout": {
+ "flowAxis": "leftToRight",
+ "safeArea": { "top": 16, "left": 16, "bottom": 16, "right": 16 },
+ "padding": { "x": 24, "y": 24 },
+ "grid": { "columns": 12, "rows": 8, "gutterX": 8, "gutterY": 12 }
+ },
+
+ "groups": [
+ {
+ "id": "input",
+ "role": "input",
+ "header": { "text": "Input", "styleRole": "input", "alignment": "leading" },
+ "anchor": { "gridX": 0, "gridY": 0, "spanX": 3, "spanY": 3, "alignX": "leading", "alignY": "top" },
+ "content": {
+ "slots": [
+ { "id": "input.slot0", "slotKind": "item", "capacity": { "maxStacks": 1 }, "accepts": { "tags": [] }, "renders": { "sizeDp": 48, "style": "square", "overlay": "count" } },
+ { "id": "input.slot1", "slotKind": "item", "capacity": { "maxStacks": 1 }, "accepts": { "tags": [] }, "renders": { "sizeDp": 48, "style": "square", "overlay": "count" } },
+ { "id": "input.slot2", "slotKind": "item", "capacity": { "maxStacks": 1 }, "accepts": { "tags": [] }, "renders": { "sizeDp": 48, "style": "square", "overlay": "count" } },
+ { "id": "input.slot3", "slotKind": "item", "capacity": { "maxStacks": 1 }, "accepts": { "tags": [] }, "renders": { "sizeDp": 48, "style": "square", "overlay": "count" } }
+ ],
+ "stateText": { "mode": "auto", "empty": "(insert ingredients)", "nonEmpty": "" }
+ }
+ },
+
+ {
+ "id": "output",
+ "role": "output",
+ "header": { "text": "Output", "styleRole": "output", "alignment": "leading" },
+ "anchor": { "gridX": 7, "gridY": 0, "spanX": 3, "spanY": 3, "alignX": "leading", "alignY": "top" },
+ "content": {
+ "slots": [
+ { "id": "output.slot0", "slotKind": "item", "capacity": { "maxStacks": 1 }, "accepts": { "tags": [] }, "renders": { "sizeDp": 48, "style": "square", "overlay": "count" } },
+ { "id": "output.slot1", "slotKind": "item", "capacity": { "maxStacks": 1 }, "accepts": { "tags": [] }, "renders": { "sizeDp": 48, "style": "square", "overlay": "count" } },
+ { "id": "output.slot2", "slotKind": "item", "capacity": { "maxStacks": 1 }, "accepts": { "tags": [] }, "renders": { "sizeDp": 48, "style": "square", "overlay": "count" } },
+ { "id": "output.slot3", "slotKind": "item", "capacity": { "maxStacks": 1 }, "accepts": { "tags": [] }, "renders": { "sizeDp": 48, "style": "square", "overlay": "count" } }
+ ],
+ "stateText": { "mode": "auto", "empty": "(idle)", "nonEmpty": "" }
+ }
+ }
+ ],
+
+ "process": {
+ "id": "crafting",
+ "label": { "text": "Crafting", "styleRole": "process", "alignment": "center" },
+ "anchor": { "gridX": 3, "gridY": 0, "spanX": 4, "spanY": 3, "alignX": "fill", "alignY": "fill" },
+ "progress": { "style": "bar", "bindTo": "recipeProgress", "showPercent": false },
+ "operators": { "showFlowGlyphs": false, "glyphStyleRole": "muted" }
+ },
+
+ "recipes": {
+ "title": "Available Recipes",
+ "mode": "picker",
+ "anchor": { "gridX": 0, "gridY": 3, "spanX": 10, "spanY": 4, "alignX": "fill", "alignY": "fill" },
+ "grid": { "cellSizeDp": 48, "columns": 6, "rowSpacingDp": 8, "colSpacingDp": 8 }
+ },
+
+ "style": {
+ "palette": {
+ "fuel": "#FFAA00",
+ "input": "#00AAFF",
+ "output": "#00FF00",
+ "process": "#FFA500",
+ "mutedText": "#CCCCCC"
+ },
+ "typography": {
+ "headerSize": 14,
+ "bodySize": 12,
+ "mutedSize": 10
+ },
+ "chrome": {
+ "panelOpacity": 1.0,
+ "cornerRadiusDp": 8,
+ "labelBackplateOpacity": 0.5
+ }
+ },
+
+ "invariants": [
+ "Every GroupHeader must be a descendant of exactly one Group.",
+ "No floating labels: GroupHeader must not be sibling of Slot; it must be ancestor (container header).",
+ "Process.progress must be visually adjacent to Process.label (max distance <= 1 grid row).",
+ "All input-role groups must be placed left of process anchor when flowAxis=leftToRight.",
+ "All output-role groups must be placed right of process anchor when flowAxis=leftToRight.",
+ "Slots in the same Group must share the same leading alignment and spacing.",
+ "If operators.showFlowGlyphs=true, glyphs must use glyphStyleRole=muted and never use semantic colors.",
+ "Any progress bar must have an explicit label (Process.label.text non-empty).",
+ "Empty state text must be inside the owning Group (content.stateText), never global."
+ ]
+}
diff --git a/web/shared/data/boiler_schema.json b/web/shared/data/boiler_schema.json
new file mode 100644
index 00000000..e4729309
--- /dev/null
+++ b/web/shared/data/boiler_schema.json
@@ -0,0 +1,81 @@
+{
+ "$schema": "https://example.com/machineui.schema.json",
+ "version": "1.0.0",
+ "id": "machineui.boiler",
+ "machineKind": "boiler",
+ "title": "Boiler",
+ "layout": {
+ "flowAxis": "leftToRight",
+ "safeArea": { "top": 16, "left": 16, "bottom": 16, "right": 16 },
+ "padding": { "x": 24, "y": 24 },
+ "grid": { "columns": 12, "rows": 8, "gutterX": 8, "gutterY": 12 }
+ },
+
+ "groups": [
+ {
+ "id": "fuel",
+ "role": "fuel",
+ "header": { "text": "Fuel", "styleRole": "fuel", "alignment": "leading" },
+ "anchor": { "gridX": 0, "gridY": 1, "spanX": 2, "spanY": 3, "alignX": "leading", "alignY": "top" },
+ "content": {
+ "slots": [
+ { "id": "fuel.slot0", "slotKind": "item", "capacity": { "maxStacks": 1 }, "accepts": { "tags": ["fuel"] }, "renders": { "sizeDp": 56, "style": "square", "overlay": "count" } }
+ ],
+ "stateText": { "mode": "auto", "empty": "(empty)", "nonEmpty": "" }
+ }
+ },
+
+ {
+ "id": "water",
+ "role": "input",
+ "header": { "text": "Water", "styleRole": "input", "alignment": "leading" },
+ "anchor": { "gridX": 2, "gridY": 1, "spanX": 2, "spanY": 3, "alignX": "leading", "alignY": "top" },
+ "content": {
+ "slots": [
+ { "id": "water.slot0", "slotKind": "item", "capacity": { "maxStacks": 1 }, "accepts": { "tags": [] }, "renders": { "sizeDp": 56, "style": "square", "overlay": "count" } }
+ ],
+ "stateText": { "mode": "auto", "empty": "(fluid input)", "nonEmpty": "" }
+ }
+ }
+ ],
+
+ "process": {
+ "id": "heating",
+ "label": { "text": "Heating", "styleRole": "process", "alignment": "center" },
+ "anchor": { "gridX": 4, "gridY": 1, "spanX": 6, "spanY": 3, "alignX": "fill", "alignY": "fill" },
+ "progress": { "style": "bar", "bindTo": "fuelBurn", "showPercent": false },
+ "operators": { "showFlowGlyphs": false, "glyphStyleRole": "muted" }
+ },
+
+ "style": {
+ "palette": {
+ "fuel": "#FFAA00",
+ "input": "#00AAFF",
+ "output": "#00FF00",
+ "process": "#FFA500",
+ "mutedText": "#CCCCCC"
+ },
+ "typography": {
+ "headerSize": 14,
+ "bodySize": 12,
+ "mutedSize": 10
+ },
+ "chrome": {
+ "panelOpacity": 1.0,
+ "cornerRadiusDp": 8,
+ "labelBackplateOpacity": 0.5
+ }
+ },
+
+ "invariants": [
+ "Every GroupHeader must be a descendant of exactly one Group.",
+ "No floating labels: GroupHeader must not be sibling of Slot; it must be ancestor (container header).",
+ "Process.progress must be visually adjacent to Process.label (max distance <= 1 grid row).",
+ "All input-role groups must be placed left of process anchor when flowAxis=leftToRight.",
+ "All output-role groups must be placed right of process anchor when flowAxis=leftToRight.",
+ "Slots in the same Group must share the same leading alignment and spacing.",
+ "If operators.showFlowGlyphs=true, glyphs must use glyphStyleRole=muted and never use semantic colors.",
+ "Any progress bar must have an explicit label (Process.label.text non-empty).",
+ "Empty state text must be inside the owning Group (content.stateText), never global."
+ ]
+}
diff --git a/web/shared/data/buildings/assemblers.json b/web/shared/data/buildings/assemblers.json
new file mode 100644
index 00000000..6272ab01
--- /dev/null
+++ b/web/shared/data/buildings/assemblers.json
@@ -0,0 +1,76 @@
+[
+ {
+ "id": "assembling-machine-1",
+ "name": "Assembling Machine 1",
+ "type": "assembler",
+ "width": 3,
+ "height": 3,
+ "maxHealth": 300,
+ "cost": [
+ {"itemId": "electronic-circuit", "count": 3},
+ {"itemId": "iron-gear-wheel", "count": 5},
+ {"itemId": "iron-plate", "count": 9}
+ ],
+ "craftingSpeed": 0.5,
+ "craftingCategory": "crafting",
+ "powerConsumption": 75,
+ "inputSlots": 4,
+ "outputSlots": 4,
+ "fuelSlots": 0
+ },
+ {
+ "id": "assembling-machine-2",
+ "name": "Assembling Machine 2",
+ "type": "assembler",
+ "width": 3,
+ "height": 3,
+ "maxHealth": 350,
+ "cost": [
+ {"itemId": "iron-plate", "count": 9},
+ {"itemId": "electronic-circuit", "count": 3},
+ {"itemId": "iron-gear-wheel", "count": 5},
+ {"itemId": "assembling-machine-1", "count": 1}
+ ],
+ "craftingSpeed": 0.75,
+ "craftingCategory": "advanced-crafting",
+ "powerConsumption": 150,
+ "inputSlots": 4,
+ "outputSlots": 4,
+ "fuelSlots": 0
+ },
+ {
+ "id": "assembling-machine-3",
+ "name": "Assembling Machine 3",
+ "type": "assembler",
+ "width": 3,
+ "height": 3,
+ "maxHealth": 400,
+ "cost": [
+ {"itemId": "assembling-machine-2", "count": 2},
+ {"itemId": "speed-module", "count": 4}
+ ],
+ "craftingSpeed": 1.25,
+ "craftingCategory": "advanced-crafting",
+ "powerConsumption": 375,
+ "inputSlots": 4,
+ "outputSlots": 4,
+ "fuelSlots": 0
+ },
+ {
+ "id": "lab",
+ "name": "Lab",
+ "type": "lab",
+ "width": 3,
+ "height": 3,
+ "maxHealth": 150,
+ "cost": [
+ {"itemId": "electronic-circuit", "count": 10},
+ {"itemId": "iron-gear-wheel", "count": 10},
+ {"itemId": "transport-belt", "count": 4}
+ ],
+ "powerConsumption": 60,
+ "inputSlots": 12,
+ "outputSlots": 0,
+ "fuelSlots": 0
+ }
+]
\ No newline at end of file
diff --git a/web/shared/data/buildings/belts.json b/web/shared/data/buildings/belts.json
new file mode 100644
index 00000000..ad9ca0b2
--- /dev/null
+++ b/web/shared/data/buildings/belts.json
@@ -0,0 +1,83 @@
+[
+ {
+ "id": "transport-belt",
+ "name": "Transport Belt",
+ "type": "belt",
+ "maxHealth": 50,
+ "cost": [
+ {"itemId": "iron-gear-wheel", "count": 1},
+ {"itemId": "iron-plate", "count": 1}
+ ],
+ "beltSpeed": 1.875
+ },
+ {
+ "id": "fast-transport-belt",
+ "name": "Fast Transport Belt",
+ "type": "belt",
+ "maxHealth": 50,
+ "cost": [
+ {"itemId": "iron-gear-wheel", "count": 5},
+ {"itemId": "iron-plate", "count": 1}
+ ],
+ "beltSpeed": 3.75
+ },
+ {
+ "id": "express-transport-belt",
+ "name": "Express Transport Belt",
+ "type": "belt",
+ "maxHealth": 50,
+ "cost": [
+ {"itemId": "iron-gear-wheel", "count": 10},
+ {"itemId": "advanced-circuit", "count": 2},
+ {"itemId": "steel-plate", "count": 1}
+ ],
+ "beltSpeed": 5.625
+ },
+ {
+ "id": "underground-belt",
+ "name": "Underground Belt",
+ "type": "belt",
+ "maxHealth": 60,
+ "cost": [
+ {"itemId": "iron-plate", "count": 10},
+ {"itemId": "transport-belt", "count": 5}
+ ],
+ "beltSpeed": 1.875
+ },
+ {
+ "id": "splitter",
+ "name": "Splitter",
+ "type": "belt",
+ "maxHealth": 80,
+ "cost": [
+ {"itemId": "electronic-circuit", "count": 5},
+ {"itemId": "iron-plate", "count": 5},
+ {"itemId": "transport-belt", "count": 4}
+ ],
+ "beltSpeed": 1.875
+ },
+ {
+ "id": "merger",
+ "name": "Merger",
+ "type": "belt",
+ "maxHealth": 80,
+ "cost": [
+ {"itemId": "electronic-circuit", "count": 5},
+ {"itemId": "iron-plate", "count": 5},
+ {"itemId": "transport-belt", "count": 4}
+ ],
+ "beltSpeed": 1.875
+ },
+ {
+ "id": "belt-bridge",
+ "name": "Belt Bridge",
+ "type": "belt",
+ "maxHealth": 50,
+ "cost": [
+ {"itemId": "iron-plate", "count": 1},
+ {"itemId": "iron-stick", "count": 2},
+ {"itemId": "iron-gear-wheel", "count": 2}
+ ],
+ "beltSpeed": 1.875
+ }
+]
\ No newline at end of file
diff --git a/web/shared/data/buildings/combat.json b/web/shared/data/buildings/combat.json
new file mode 100644
index 00000000..fd4bfc1d
--- /dev/null
+++ b/web/shared/data/buildings/combat.json
@@ -0,0 +1,51 @@
+[
+ {
+ "id": "gun-turret",
+ "name": "Gun Turret",
+ "type": "turret",
+ "width": 2,
+ "height": 2,
+ "maxHealth": 400,
+ "cost": [
+ {"itemId": "iron-gear-wheel", "count": 10},
+ {"itemId": "copper-plate", "count": 10},
+ {"itemId": "iron-plate", "count": 20}
+ ],
+ "turretRange": 18,
+ "turretDamage": 6,
+ "turretFireRate": 10,
+ "inputSlots": 1,
+ "outputSlots": 0,
+ "fuelSlots": 0
+ },
+ {
+ "id": "laser-turret",
+ "name": "Laser Turret",
+ "type": "turret",
+ "width": 2,
+ "height": 2,
+ "maxHealth": 1000,
+ "cost": [
+ {"itemId": "steel-plate", "count": 20},
+ {"itemId": "electronic-circuit", "count": 20},
+ {"itemId": "battery", "count": 12}
+ ],
+ "turretRange": 24,
+ "turretDamage": 20,
+ "turretFireRate": 20,
+ "powerConsumption": 800,
+ "inputSlots": 1,
+ "outputSlots": 0,
+ "fuelSlots": 0
+ },
+ {
+ "id": "stone-wall",
+ "name": "Wall",
+ "type": "wall",
+ "maxHealth": 350,
+ "cost": [
+ {"itemId": "stone-brick", "count": 5}
+ ],
+ "textureId": "wall"
+ }
+]
\ No newline at end of file
diff --git a/web/shared/data/buildings/fluids.json b/web/shared/data/buildings/fluids.json
new file mode 100644
index 00000000..05fcd33d
--- /dev/null
+++ b/web/shared/data/buildings/fluids.json
@@ -0,0 +1,79 @@
+[
+ {
+ "id": "oil-refinery",
+ "name": "Oil Refinery",
+ "type": "oilRefinery",
+ "width": 3,
+ "height": 3,
+ "maxHealth": 300,
+ "cost": [
+ {"itemId": "steel-plate", "count": 15},
+ {"itemId": "iron-gear-wheel", "count": 10},
+ {"itemId": "electronic-circuit", "count": 10},
+ {"itemId": "pipe", "count": 10},
+ {"itemId": "stone-brick", "count": 10}
+ ],
+ "powerConsumption": 420,
+ "inventorySlots": 0,
+ "inputSlots": 0,
+ "outputSlots": 0,
+ "fuelSlots": 0,
+ "craftingCategory": "oil-processing",
+ "fluidCapacity": 12500
+ },
+ {
+ "id": "chemical-plant",
+ "name": "Chemical Plant",
+ "type": "chemicalPlant",
+ "width": 3,
+ "height": 3,
+ "maxHealth": 300,
+ "cost": [
+ {"itemId": "steel-plate", "count": 5},
+ {"itemId": "iron-gear-wheel", "count": 5},
+ {"itemId": "electronic-circuit", "count": 5},
+ {"itemId": "pipe", "count": 5}
+ ],
+ "powerConsumption": 210,
+ "inventorySlots": 5,
+ "inputSlots": 3,
+ "outputSlots": 2,
+ "fuelSlots": 0,
+ "fluidCapacity": 1500
+ },
+ {
+ "id": "pipe",
+ "name": "Pipe",
+ "type": "pipe",
+ "maxHealth": 50,
+ "cost": [
+ {"itemId": "iron-plate", "count": 1}
+ ],
+ "fluidCapacity": 100
+ },
+ {
+ "id": "underground-pipe",
+ "name": "Underground Pipe",
+ "type": "pipe",
+ "maxHealth": 60,
+ "cost": [
+ {"itemId": "iron-plate", "count": 5},
+ {"itemId": "pipe", "count": 5}
+ ],
+ "fluidCapacity": 300
+ },
+ {
+ "id": "fluid-tank",
+ "name": "Fluid Tank",
+ "type": "fluidTank",
+ "width": 3,
+ "height": 3,
+ "maxHealth": 500,
+ "cost": [
+ {"itemId": "iron-plate", "count": 20},
+ {"itemId": "steel-plate", "count": 5},
+ {"itemId": "iron-gear-wheel", "count": 3}
+ ],
+ "fluidCapacity": 25000
+ }
+]
\ No newline at end of file
diff --git a/web/shared/data/buildings/furnaces.json b/web/shared/data/buildings/furnaces.json
new file mode 100644
index 00000000..98b3fe20
--- /dev/null
+++ b/web/shared/data/buildings/furnaces.json
@@ -0,0 +1,54 @@
+[
+ {
+ "id": "stone-furnace",
+ "name": "Stone Furnace",
+ "type": "furnace",
+ "width": 2,
+ "height": 2,
+ "maxHealth": 200,
+ "cost": [
+ {"itemId": "iron-plate", "count": 5}
+ ],
+ "craftingSpeed": 1,
+ "craftingCategory": "smelting",
+ "inputSlots": 1,
+ "outputSlots": 1,
+ "fuelSlots": 1
+ },
+ {
+ "id": "steel-furnace",
+ "name": "Steel Furnace",
+ "type": "furnace",
+ "width": 2,
+ "height": 2,
+ "maxHealth": 300,
+ "cost": [
+ {"itemId": "steel-plate", "count": 6},
+ {"itemId": "stone-brick", "count": 10}
+ ],
+ "craftingSpeed": 2,
+ "craftingCategory": "smelting",
+ "inputSlots": 1,
+ "outputSlots": 1,
+ "fuelSlots": 1
+ },
+ {
+ "id": "electric-furnace",
+ "name": "Electric Furnace",
+ "type": "furnace",
+ "width": 3,
+ "height": 3,
+ "maxHealth": 350,
+ "cost": [
+ {"itemId": "steel-plate", "count": 10},
+ {"itemId": "advanced-circuit", "count": 5},
+ {"itemId": "stone-brick", "count": 10}
+ ],
+ "craftingSpeed": 2,
+ "craftingCategory": "smelting",
+ "powerConsumption": 180,
+ "inputSlots": 1,
+ "outputSlots": 1,
+ "fuelSlots": 0
+ }
+]
\ No newline at end of file
diff --git a/web/shared/data/buildings/inserters.json b/web/shared/data/buildings/inserters.json
new file mode 100644
index 00000000..304be359
--- /dev/null
+++ b/web/shared/data/buildings/inserters.json
@@ -0,0 +1,59 @@
+[
+ {
+ "id": "inserter",
+ "name": "Inserter",
+ "type": "inserter",
+ "maxHealth": 40,
+ "cost": [
+ {"itemId": "electronic-circuit", "count": 1},
+ {"itemId": "iron-gear-wheel", "count": 1},
+ {"itemId": "iron-plate", "count": 1}
+ ],
+ "inserterSpeed": 4.0,
+ "inserterStackSize": 1,
+ "powerConsumption": 13
+ },
+ {
+ "id": "long-handed-inserter",
+ "name": "Long Handed Inserter",
+ "type": "inserter",
+ "maxHealth": 40,
+ "cost": [
+ {"itemId": "iron-gear-wheel", "count": 1},
+ {"itemId": "iron-plate", "count": 1},
+ {"itemId": "inserter", "count": 1}
+ ],
+ "inserterSpeed": 1.2,
+ "inserterStackSize": 1,
+ "powerConsumption": 18
+ },
+ {
+ "id": "fast-inserter",
+ "name": "Fast Inserter",
+ "type": "inserter",
+ "maxHealth": 40,
+ "cost": [
+ {"itemId": "electronic-circuit", "count": 2},
+ {"itemId": "iron-plate", "count": 2},
+ {"itemId": "inserter", "count": 1}
+ ],
+ "inserterSpeed": 2.31,
+ "inserterStackSize": 1,
+ "powerConsumption": 46
+ },
+ {
+ "id": "stack-inserter",
+ "name": "Stack Inserter",
+ "type": "inserter",
+ "maxHealth": 40,
+ "cost": [
+ {"itemId": "advanced-circuit", "count": 1},
+ {"itemId": "electronic-circuit", "count": 15},
+ {"itemId": "iron-gear-wheel", "count": 15},
+ {"itemId": "fast-inserter", "count": 1}
+ ],
+ "inserterSpeed": 1.5,
+ "inserterStackSize": 2,
+ "powerConsumption": 75
+ }
+]
\ No newline at end of file
diff --git a/web/shared/data/buildings/miners.json b/web/shared/data/buildings/miners.json
new file mode 100644
index 00000000..a201eecd
--- /dev/null
+++ b/web/shared/data/buildings/miners.json
@@ -0,0 +1,72 @@
+[
+ {
+ "id": "burner-mining-drill",
+ "name": "Burner Mining Drill",
+ "type": "miner",
+ "width": 2,
+ "height": 2,
+ "maxHealth": 150,
+ "cost": [
+ {"itemId": "iron-plate", "count": 5}
+ ],
+ "miningSpeed": 0.5,
+ "inputSlots": 0,
+ "outputSlots": 1,
+ "fuelSlots": 1
+ },
+ {
+ "id": "electric-mining-drill",
+ "name": "Electric Mining Drill",
+ "type": "miner",
+ "width": 3,
+ "height": 3,
+ "maxHealth": 300,
+ "cost": [
+ {"itemId": "electronic-circuit", "count": 3},
+ {"itemId": "iron-gear-wheel", "count": 5},
+ {"itemId": "iron-plate", "count": 10}
+ ],
+ "miningSpeed": 0.75,
+ "powerConsumption": 90,
+ "inputSlots": 0,
+ "outputSlots": 1,
+ "fuelSlots": 0
+ },
+ {
+ "id": "pumpjack",
+ "name": "Pumpjack",
+ "type": "pumpjack",
+ "maxHealth": 200,
+ "cost": [
+ {"itemId": "steel-plate", "count": 5},
+ {"itemId": "iron-gear-wheel", "count": 10},
+ {"itemId": "electronic-circuit", "count": 5},
+ {"itemId": "pipe", "count": 10}
+ ],
+ "powerConsumption": 90,
+ "extractionRate": 1.0,
+ "fluidOutputType": "crudeOil",
+ "inventorySlots": 1,
+ "inputSlots": 0,
+ "outputSlots": 1,
+ "fuelSlots": 0
+ },
+ {
+ "id": "water-pump",
+ "name": "Water Pump",
+ "type": "waterPump",
+ "maxHealth": 150,
+ "cost": [
+ {"itemId": "iron-plate", "count": 5},
+ {"itemId": "pipe", "count": 5},
+ {"itemId": "electronic-circuit", "count": 2}
+ ],
+ "powerConsumption": 30,
+ "extractionRate": 20.0,
+ "fluidOutputType": "water",
+ "inventorySlots": 0,
+ "inputSlots": 0,
+ "outputSlots": 0,
+ "fuelSlots": 0
+ }
+]
\ No newline at end of file
diff --git a/web/shared/data/buildings/nuclear.json b/web/shared/data/buildings/nuclear.json
new file mode 100644
index 00000000..6101770e
--- /dev/null
+++ b/web/shared/data/buildings/nuclear.json
@@ -0,0 +1,36 @@
+[
+ {
+ "id": "nuclear-reactor",
+ "name": "Nuclear Reactor",
+ "type": "nuclearReactor",
+ "width": 5,
+ "height": 5,
+ "maxHealth": 500,
+ "cost": [
+ {"itemId": "steel-plate", "count": 400},
+ {"itemId": "advanced-circuit", "count": 400},
+ {"itemId": "copper-plate", "count": 400},
+ {"itemId": "stone-brick", "count": 400}
+ ],
+ "powerProduction": 40000,
+ "inputSlots": 0,
+ "outputSlots": 0,
+ "fuelSlots": 1
+ },
+ {
+ "id": "centrifuge",
+ "name": "Centrifuge",
+ "type": "centrifuge",
+ "width": 3,
+ "height": 3,
+ "maxHealth": 300,
+ "cost": [
+ {"itemId": "centrifuge", "count": 1}
+ ],
+ "powerConsumption": 350,
+ "inventorySlots": 4,
+ "inputSlots": 2,
+ "outputSlots": 2,
+ "fuelSlots": 0
+ }
+]
\ No newline at end of file
diff --git a/web/shared/data/buildings/power.json b/web/shared/data/buildings/power.json
new file mode 100644
index 00000000..f30d575c
--- /dev/null
+++ b/web/shared/data/buildings/power.json
@@ -0,0 +1,112 @@
+[
+ {
+ "id": "small-electric-pole",
+ "name": "Small Electric Pole",
+ "type": "powerPole",
+ "maxHealth": 100,
+ "cost": [
+ {"itemId": "wood", "count": 2},
+ {"itemId": "copper-cable", "count": 2}
+ ],
+ "wireReach": 7.5,
+ "supplyArea": 2.5
+ },
+ {
+ "id": "medium-electric-pole",
+ "name": "Medium Electric Pole",
+ "type": "powerPole",
+ "maxHealth": 100,
+ "cost": [
+ {"itemId": "steel-plate", "count": 2},
+ {"itemId": "copper-plate", "count": 2}
+ ],
+ "wireReach": 9,
+ "supplyArea": 3.5
+ },
+ {
+ "id": "big-electric-pole",
+ "name": "Big Electric Pole",
+ "type": "powerPole",
+ "width": 2,
+ "height": 2,
+ "maxHealth": 150,
+ "cost": [
+ {"itemId": "steel-plate", "count": 5},
+ {"itemId": "copper-plate", "count": 5}
+ ],
+ "wireReach": 30,
+ "supplyArea": 2
+ },
+ {
+ "id": "boiler",
+ "name": "Boiler",
+ "type": "generator",
+ "width": 2,
+ "height": 3,
+ "maxHealth": 200,
+ "cost": [
+ {"itemId": "iron-plate", "count": 5},
+ {"itemId": "pipe", "count": 4}
+ ],
+ "fuelCategory": "chemical",
+ "fluidInputType": "water",
+ "fluidOutputType": "steam",
+ "fluidCapacity": 540,
+ "inputSlots": 0,
+ "outputSlots": 0,
+ "fuelSlots": 1
+ },
+ {
+ "id": "steam-engine",
+ "name": "Steam Engine",
+ "type": "generator",
+ "width": 3,
+ "height": 5,
+ "maxHealth": 400,
+ "cost": [
+ {"itemId": "iron-gear-wheel", "count": 8},
+ {"itemId": "iron-plate", "count": 10},
+ {"itemId": "pipe", "count": 5}
+ ],
+ "powerProduction": 900,
+ "fluidInputType": "steam",
+ "fluidCapacity": 540,
+ "inputSlots": 0,
+ "outputSlots": 0,
+ "fuelSlots": 0
+ },
+ {
+ "id": "solar-panel",
+ "name": "Solar Panel",
+ "type": "solarPanel",
+ "width": 3,
+ "height": 3,
+ "maxHealth": 200,
+ "cost": [
+ {"itemId": "steel-plate", "count": 5},
+ {"itemId": "electronic-circuit", "count": 15},
+ {"itemId": "copper-plate", "count": 5}
+ ],
+ "powerProduction": 60,
+ "inputSlots": 0,
+ "outputSlots": 0,
+ "fuelSlots": 0
+ },
+ {
+ "id": "accumulator",
+ "name": "Accumulator",
+ "type": "accumulator",
+ "width": 2,
+ "height": 2,
+ "maxHealth": 150,
+ "cost": [
+ {"itemId": "iron-plate", "count": 2},
+ {"itemId": "battery", "count": 5}
+ ],
+ "accumulatorCapacity": 5000,
+ "accumulatorChargeRate": 300,
+ "inputSlots": 0,
+ "outputSlots": 0,
+ "fuelSlots": 0
+ }
+]
\ No newline at end of file
diff --git a/web/shared/data/buildings/rockets.json b/web/shared/data/buildings/rockets.json
new file mode 100644
index 00000000..80decbc5
--- /dev/null
+++ b/web/shared/data/buildings/rockets.json
@@ -0,0 +1,21 @@
+[
+ {
+ "id": "rocket-silo",
+ "name": "Rocket Silo",
+ "type": "rocketSilo",
+ "width": 9,
+ "height": 9,
+ "maxHealth": 5000,
+ "cost": [
+ {"itemId": "steel-plate", "count": 1000},
+ {"itemId": "stone-brick", "count": 1000},
+ {"itemId": "pipe", "count": 100},
+ {"itemId": "processing-unit", "count": 200}
+ ],
+ "powerConsumption": 1000,
+ "inventorySlots": 5,
+ "inputSlots": 4,
+ "outputSlots": 0,
+ "fuelSlots": 1
+ }
+]
\ No newline at end of file
diff --git a/web/shared/data/buildings/storage.json b/web/shared/data/buildings/storage.json
new file mode 100644
index 00000000..d0fdc7f2
--- /dev/null
+++ b/web/shared/data/buildings/storage.json
@@ -0,0 +1,41 @@
+[
+ {
+ "id": "wooden-chest",
+ "name": "Wooden Chest",
+ "type": "chest",
+ "maxHealth": 100,
+ "cost": [
+ {"itemId": "wood", "count": 2}
+ ],
+ "inventorySlots": 16,
+ "inputSlots": 0,
+ "outputSlots": 0,
+ "fuelSlots": 0
+ },
+ {
+ "id": "iron-chest",
+ "name": "Iron Chest",
+ "type": "chest",
+ "maxHealth": 200,
+ "cost": [
+ {"itemId": "iron-plate", "count": 8}
+ ],
+ "inventorySlots": 32,
+ "inputSlots": 0,
+ "outputSlots": 0,
+ "fuelSlots": 0
+ },
+ {
+ "id": "steel-chest",
+ "name": "Steel Chest",
+ "type": "chest",
+ "maxHealth": 350,
+ "cost": [
+ {"itemId": "steel-plate", "count": 8}
+ ],
+ "inventorySlots": 48,
+ "inputSlots": 0,
+ "outputSlots": 0,
+ "fuelSlots": 0
+ }
+]
\ No newline at end of file
diff --git a/web/shared/data/buildings/units.json b/web/shared/data/buildings/units.json
new file mode 100644
index 00000000..d74bdce0
--- /dev/null
+++ b/web/shared/data/buildings/units.json
@@ -0,0 +1,56 @@
+[
+ {
+ "id": "military-barracks",
+ "name": "Military Barracks",
+ "type": "unitProduction",
+ "width": 4,
+ "height": 3,
+ "maxHealth": 300,
+ "cost": [
+ {"itemId": "iron-plate", "count": 20},
+ {"itemId": "stone-brick", "count": 10},
+ {"itemId": "electronic-circuit", "count": 5}
+ ],
+ "powerConsumption": 50,
+ "inventorySlots": 10,
+ "inputSlots": 0,
+ "outputSlots": 10,
+ "fuelSlots": 0
+ },
+ {
+ "id": "fantasy-academy",
+ "name": "Fantasy Academy",
+ "type": "unitProduction",
+ "width": 4,
+ "height": 3,
+ "maxHealth": 350,
+ "cost": [
+ {"itemId": "stone-brick", "count": 20},
+ {"itemId": "iron-plate", "count": 15},
+ {"itemId": "electronic-circuit", "count": 10}
+ ],
+ "powerConsumption": 75,
+ "inventorySlots": 8,
+ "inputSlots": 0,
+ "outputSlots": 8,
+ "fuelSlots": 0
+ },
+ {
+ "id": "elemental-summoning-circle",
+ "name": "Elemental Summoning Circle",
+ "type": "unitProduction",
+ "width": 5,
+ "height": 5,
+ "maxHealth": 400,
+ "cost": [
+ {"itemId": "stone-brick", "count": 25},
+ {"itemId": "electronic-circuit", "count": 15},
+ {"itemId": "advanced-circuit", "count": 5}
+ ],
+ "powerConsumption": 100,
+ "inventorySlots": 6,
+ "inputSlots": 0,
+ "outputSlots": 6,
+ "fuelSlots": 0
+ }
+]
\ No newline at end of file
diff --git a/web/shared/data/furnace_schema.json b/web/shared/data/furnace_schema.json
new file mode 100644
index 00000000..5e134012
--- /dev/null
+++ b/web/shared/data/furnace_schema.json
@@ -0,0 +1,101 @@
+{
+ "$schema": "https://example.com/machineui.schema.json",
+ "version": "1.0.0",
+ "id": "machineui.furnace",
+ "machineKind": "furnace",
+ "title": "Furnace",
+ "layout": {
+ "flowAxis": "leftToRight",
+ "safeArea": { "top": 16, "left": 16, "bottom": 16, "right": 16 },
+ "padding": { "x": 24, "y": 24 },
+ "grid": { "columns": 12, "rows": 8, "gutterX": 8, "gutterY": 12 }
+ },
+
+ "groups": [
+ {
+ "id": "fuel",
+ "role": "fuel",
+ "header": { "text": "Fuel", "styleRole": "fuel", "alignment": "leading" },
+ "anchor": { "gridX": 0, "gridY": 1, "spanX": 2, "spanY": 3, "alignX": "leading", "alignY": "top" },
+ "content": {
+ "slots": [
+ { "id": "fuel.slot0", "slotKind": "item", "capacity": { "maxStacks": 1 }, "accepts": { "tags": ["fuel"] }, "renders": { "sizeDp": 56, "style": "square", "overlay": "count" } }
+ ],
+ "stateText": { "mode": "auto", "empty": "(empty)", "nonEmpty": "" }
+ }
+ },
+
+ {
+ "id": "ore",
+ "role": "input",
+ "header": { "text": "Ore", "styleRole": "input", "alignment": "leading" },
+ "anchor": { "gridX": 2, "gridY": 1, "spanX": 2, "spanY": 3, "alignX": "leading", "alignY": "top" },
+ "content": {
+ "slots": [
+ { "id": "ore.slot0", "slotKind": "item", "capacity": { "maxStacks": 1 }, "accepts": { "tags": ["ore"] }, "renders": { "sizeDp": 56, "style": "square", "overlay": "count" } }
+ ],
+ "stateText": { "mode": "auto", "empty": "(insert ore)", "nonEmpty": "" }
+ }
+ },
+
+ {
+ "id": "output",
+ "role": "output",
+ "header": { "text": "Output", "styleRole": "output", "alignment": "leading" },
+ "anchor": { "gridX": 9, "gridY": 1, "spanX": 2, "spanY": 3, "alignX": "leading", "alignY": "top" },
+ "content": {
+ "slots": [
+ { "id": "output.slot0", "slotKind": "item", "capacity": { "maxStacks": 1 }, "accepts": { "tags": ["ingot", "plate"] }, "renders": { "sizeDp": 56, "style": "square", "overlay": "count" } }
+ ],
+ "stateText": { "mode": "auto", "empty": "(idle)", "nonEmpty": "" }
+ }
+ }
+ ],
+
+ "process": {
+ "id": "smelting",
+ "label": { "text": "Smelting", "styleRole": "process", "alignment": "center" },
+ "anchor": { "gridX": 4, "gridY": 1, "spanX": 5, "spanY": 3, "alignX": "fill", "alignY": "fill" },
+ "progress": { "style": "bar", "bindTo": "recipeProgress", "showPercent": false },
+ "operators": { "showFlowGlyphs": false, "glyphStyleRole": "muted" }
+ },
+
+ "recipes": {
+ "title": "Available Recipes",
+ "mode": "picker",
+ "anchor": { "gridX": 1, "gridY": 4, "spanX": 10, "spanY": 3, "alignX": "fill", "alignY": "fill" },
+ "grid": { "cellSizeDp": 48, "columns": 6, "rowSpacingDp": 8, "colSpacingDp": 8 }
+ },
+
+ "style": {
+ "palette": {
+ "fuel": "#FFAA00",
+ "input": "#00AAFF",
+ "output": "#00FF00",
+ "process": "#FFA500",
+ "mutedText": "#CCCCCC"
+ },
+ "typography": {
+ "headerSize": 14,
+ "bodySize": 12,
+ "mutedSize": 10
+ },
+ "chrome": {
+ "panelOpacity": 1.0,
+ "cornerRadiusDp": 8,
+ "labelBackplateOpacity": 0.5
+ }
+ },
+
+ "invariants": [
+ "Every GroupHeader must be a descendant of exactly one Group.",
+ "No floating labels: GroupHeader must not be sibling of Slot; it must be ancestor (container header).",
+ "Process.progress must be visually adjacent to Process.label (max distance <= 1 grid row).",
+ "All input-role groups must be placed left of process anchor when flowAxis=leftToRight.",
+ "All output-role groups must be placed right of process anchor when flowAxis=leftToRight.",
+ "Slots in the same Group must share the same leading alignment and spacing.",
+ "If operators.showFlowGlyphs=true, glyphs must use glyphStyleRole=muted and never use semantic colors.",
+ "Any progress bar must have an explicit label (Process.label.text non-empty).",
+ "Empty state text must be inside the owning Group (content.stateText), never global."
+ ]
+}
\ No newline at end of file
diff --git a/web/shared/data/items.json b/web/shared/data/items.json
new file mode 100644
index 00000000..94bc1010
--- /dev/null
+++ b/web/shared/data/items.json
@@ -0,0 +1,688 @@
+[
+ {
+ "id": "iron-ore",
+ "name": "Iron Ore",
+ "stackSize": 1000,
+ "category": "raw",
+ "order": "a"
+ },
+ {
+ "id": "copper-ore",
+ "name": "Copper Ore",
+ "stackSize": 1000,
+ "category": "raw",
+ "order": "b"
+ },
+ {
+ "id": "coal",
+ "name": "Coal",
+ "stackSize": 1000,
+ "category": "raw",
+ "order": "c",
+ "fuelValue": 4000,
+ "fuelCategory": "chemical"
+ },
+ {
+ "id": "stone",
+ "name": "Stone",
+ "stackSize": 1000,
+ "category": "raw",
+ "order": "d"
+ },
+ {
+ "id": "wood",
+ "name": "Wood",
+ "stackSize": 1000,
+ "category": "raw",
+ "order": "e",
+ "fuelValue": 2000,
+ "fuelCategory": "chemical"
+ },
+ {
+ "id": "water",
+ "name": "Water",
+ "stackSize": 0,
+ "category": "fluid",
+ "order": "f"
+ },
+ {
+ "id": "crude-oil",
+ "name": "Crude Oil",
+ "stackSize": 100,
+ "category": "fluid",
+ "order": "g"
+ },
+ {
+ "id": "petroleum-gas",
+ "name": "Petroleum Gas",
+ "stackSize": 0,
+ "category": "fluid",
+ "order": "g"
+ },
+ {
+ "id": "light-oil",
+ "name": "Light Oil",
+ "stackSize": 0,
+ "category": "fluid",
+ "order": "h"
+ },
+ {
+ "id": "heavy-oil",
+ "name": "Heavy Oil",
+ "stackSize": 0,
+ "category": "fluid",
+ "order": "i"
+ },
+ {
+ "id": "lubricant",
+ "name": "Lubricant",
+ "stackSize": 0,
+ "category": "fluid",
+ "order": "j"
+ },
+ {
+ "id": "sulfuric-acid",
+ "name": "Sulfuric Acid",
+ "stackSize": 0,
+ "category": "fluid",
+ "order": "k"
+ },
+ {
+ "id": "uranium-ore",
+ "name": "Uranium Ore",
+ "stackSize": 1000,
+ "category": "raw",
+ "order": "l"
+ },
+ {
+ "id": "iron-plate",
+ "name": "Iron Plate",
+ "stackSize": 1000,
+ "category": "intermediate",
+ "order": "a"
+ },
+ {
+ "id": "copper-plate",
+ "name": "Copper Plate",
+ "stackSize": 1000,
+ "category": "intermediate",
+ "order": "b"
+ },
+ {
+ "id": "steel-plate",
+ "name": "Steel Plate",
+ "stackSize": 1000,
+ "category": "intermediate",
+ "order": "c"
+ },
+ {
+ "id": "stone-brick",
+ "name": "Stone Brick",
+ "stackSize": 1000,
+ "category": "intermediate",
+ "order": "d"
+ },
+ {
+ "id": "iron-gear-wheel",
+ "name": "Iron Gear Wheel",
+ "stackSize": 1000,
+ "category": "intermediate",
+ "order": "e"
+ },
+ {
+ "id": "copper-cable",
+ "name": "Copper Cable",
+ "stackSize": 1000,
+ "category": "intermediate",
+ "order": "f"
+ },
+ {
+ "id": "pipe",
+ "name": "Pipe",
+ "stackSize": 1000,
+ "category": "intermediate",
+ "order": "f1"
+ },
+ {
+ "id": "electronic-circuit",
+ "name": "Electronic Circuit",
+ "stackSize": 1000,
+ "category": "intermediate",
+ "order": "g"
+ },
+ {
+ "id": "advanced-circuit",
+ "name": "Advanced Circuit",
+ "stackSize": 1000,
+ "category": "intermediate",
+ "order": "h"
+ },
+ {
+ "id": "processing-unit",
+ "name": "Processing Unit",
+ "stackSize": 500,
+ "category": "intermediate",
+ "order": "i"
+ },
+ {
+ "id": "engine-unit",
+ "name": "Engine Unit",
+ "stackSize": 500,
+ "category": "intermediate",
+ "order": "j"
+ },
+ {
+ "id": "electric-engine-unit",
+ "name": "Electric Engine Unit",
+ "stackSize": 500,
+ "category": "intermediate",
+ "order": "k"
+ },
+ {
+ "id": "plastic-bar",
+ "name": "Plastic Bar",
+ "stackSize": 1000,
+ "category": "intermediate",
+ "order": "l"
+ },
+ {
+ "id": "sulfur",
+ "name": "Sulfur",
+ "stackSize": 1000,
+ "category": "intermediate",
+ "order": "m"
+ },
+ {
+ "id": "battery",
+ "name": "Battery",
+ "stackSize": 1000,
+ "category": "intermediate",
+ "order": "n"
+ },
+ {
+ "id": "explosives",
+ "name": "Explosives",
+ "stackSize": 500,
+ "category": "intermediate",
+ "order": "o"
+ },
+ {
+ "id": "uranium-235",
+ "name": "Uranium-235",
+ "stackSize": 100,
+ "category": "intermediate",
+ "order": "p"
+ },
+ {
+ "id": "uranium-238",
+ "name": "Uranium-238",
+ "stackSize": 100,
+ "category": "intermediate",
+ "order": "q"
+ },
+ {
+ "id": "nuclear-fuel",
+ "name": "Nuclear Fuel",
+ "stackSize": 10,
+ "category": "intermediate",
+ "order": "r"
+ },
+ {
+ "id": "rocket-fuel",
+ "name": "Rocket Fuel",
+ "stackSize": 100,
+ "category": "intermediate",
+ "order": "s"
+ },
+ {
+ "id": "rocket-parts",
+ "name": "Rocket Parts",
+ "stackSize": 50,
+ "category": "intermediate",
+ "order": "t"
+ },
+ {
+ "id": "satellite",
+ "name": "Satellite",
+ "stackSize": 10,
+ "category": "intermediate",
+ "order": "u"
+ },
+ {
+ "id": "low-density-structure",
+ "name": "Low Density Structure",
+ "stackSize": 100,
+ "category": "intermediate",
+ "order": "v"
+ },
+ {
+ "id": "solid-fuel",
+ "name": "Solid Fuel",
+ "stackSize": 500,
+ "category": "intermediate",
+ "order": "w",
+ "fuelValue": 25000,
+ "fuelCategory": "chemical"
+ },
+ {
+ "id": "automation-science-pack",
+ "name": "Automation Science Pack",
+ "stackSize": 200,
+ "category": "science",
+ "order": "a"
+ },
+ {
+ "id": "logistic-science-pack",
+ "name": "Logistic Science Pack",
+ "stackSize": 200,
+ "category": "science",
+ "order": "b"
+ },
+ {
+ "id": "military-science-pack",
+ "name": "Military Science Pack",
+ "stackSize": 200,
+ "category": "science",
+ "order": "c"
+ },
+ {
+ "id": "chemical-science-pack",
+ "name": "Chemical Science Pack",
+ "stackSize": 200,
+ "category": "science",
+ "order": "d"
+ },
+ {
+ "id": "production-science-pack",
+ "name": "Production Science Pack",
+ "stackSize": 200,
+ "category": "science",
+ "order": "e"
+ },
+ {
+ "id": "utility-science-pack",
+ "name": "Utility Science Pack",
+ "stackSize": 200,
+ "category": "science",
+ "order": "f"
+ },
+ {
+ "id": "space-science-pack",
+ "name": "Space Science Pack",
+ "stackSize": 2000,
+ "category": "science",
+ "order": "g"
+ },
+ {
+ "id": "transport-belt",
+ "name": "Transport Belt",
+ "stackSize": 500,
+ "category": "logistics",
+ "order": "a",
+ "placedAs": "transport-belt"
+ },
+ {
+ "id": "fast-transport-belt",
+ "name": "Fast Transport Belt",
+ "stackSize": 500,
+ "category": "logistics",
+ "order": "b",
+ "placedAs": "fast-transport-belt"
+ },
+ {
+ "id": "express-transport-belt",
+ "name": "Express Transport Belt",
+ "stackSize": 500,
+ "category": "logistics",
+ "order": "c",
+ "placedAs": "express-transport-belt"
+ },
+ {
+ "id": "underground-belt",
+ "name": "Underground Belt",
+ "stackSize": 200,
+ "category": "logistics",
+ "order": "d",
+ "placedAs": "underground-belt"
+ },
+ {
+ "id": "splitter",
+ "name": "Splitter",
+ "stackSize": 200,
+ "category": "logistics",
+ "order": "e",
+ "placedAs": "splitter"
+ },
+ {
+ "id": "merger",
+ "name": "Merger",
+ "stackSize": 200,
+ "category": "logistics",
+ "order": "f",
+ "placedAs": "merger"
+ },
+ {
+ "id": "belt-bridge",
+ "name": "Belt Bridge",
+ "stackSize": 200,
+ "category": "logistics",
+ "order": "g",
+ "placedAs": "belt-bridge"
+ },
+ {
+ "id": "inserter",
+ "name": "Inserter",
+ "stackSize": 100,
+ "category": "logistics",
+ "order": "h",
+ "placedAs": "inserter"
+ },
+ {
+ "id": "long-handed-inserter",
+ "name": "Long Handed Inserter",
+ "stackSize": 100,
+ "category": "logistics",
+ "order": "i",
+ "placedAs": "long-handed-inserter"
+ },
+ {
+ "id": "fast-inserter",
+ "name": "Fast Inserter",
+ "stackSize": 100,
+ "category": "logistics",
+ "order": "j",
+ "placedAs": "fast-inserter"
+ },
+ {
+ "id": "stack-inserter",
+ "name": "Stack Inserter",
+ "stackSize": 50,
+ "category": "logistics",
+ "order": "k",
+ "placedAs": "stack-inserter"
+ },
+ {
+ "id": "wooden-chest",
+ "name": "Wooden Chest",
+ "stackSize": 200,
+ "category": "logistics",
+ "order": "l",
+ "placedAs": "wooden-chest"
+ },
+ {
+ "id": "iron-chest",
+ "name": "Iron Chest",
+ "stackSize": 200,
+ "category": "logistics",
+ "order": "m",
+ "placedAs": "iron-chest"
+ },
+ {
+ "id": "steel-chest",
+ "name": "Steel Chest",
+ "stackSize": 200,
+ "category": "logistics",
+ "order": "n",
+ "placedAs": "steel-chest"
+ },
+ {
+ "id": "burner-mining-drill",
+ "name": "Burner Mining Drill",
+ "stackSize": 100,
+ "category": "production",
+ "order": "a",
+ "placedAs": "burner-mining-drill"
+ },
+ {
+ "id": "electric-mining-drill",
+ "name": "Electric Mining Drill",
+ "stackSize": 100,
+ "category": "production",
+ "order": "b",
+ "placedAs": "electric-mining-drill"
+ },
+ {
+ "id": "stone-furnace",
+ "name": "Stone Furnace",
+ "stackSize": 100,
+ "category": "production",
+ "order": "c",
+ "placedAs": "stone-furnace"
+ },
+ {
+ "id": "steel-furnace",
+ "name": "Steel Furnace",
+ "stackSize": 100,
+ "category": "production",
+ "order": "d",
+ "placedAs": "steel-furnace"
+ },
+ {
+ "id": "electric-furnace",
+ "name": "Electric Furnace",
+ "stackSize": 100,
+ "category": "production",
+ "order": "e",
+ "placedAs": "electric-furnace"
+ },
+ {
+ "id": "assembling-machine-1",
+ "name": "Assembling Machine 1",
+ "stackSize": 100,
+ "category": "production",
+ "order": "f",
+ "placedAs": "assembling-machine-1"
+ },
+ {
+ "id": "assembling-machine-2",
+ "name": "Assembling Machine 2",
+ "stackSize": 100,
+ "category": "production",
+ "order": "g",
+ "placedAs": "assembling-machine-2"
+ },
+ {
+ "id": "assembling-machine-3",
+ "name": "Assembling Machine 3",
+ "stackSize": 100,
+ "category": "production",
+ "order": "h",
+ "placedAs": "assembling-machine-3"
+ },
+ {
+ "id": "lab",
+ "name": "Lab",
+ "stackSize": 50,
+ "category": "production",
+ "order": "l",
+ "placedAs": "lab"
+ },
+ {
+ "id": "pumpjack",
+ "name": "Pumpjack",
+ "stackSize": 50,
+ "category": "production",
+ "order": "m",
+ "placedAs": "pumpjack"
+ },
+ {
+ "id": "oil-refinery",
+ "name": "Oil Refinery",
+ "stackSize": 50,
+ "category": "production",
+ "order": "n",
+ "placedAs": "oil-refinery"
+ },
+ {
+ "id": "chemical-plant",
+ "name": "Chemical Plant",
+ "stackSize": 50,
+ "category": "production",
+ "order": "o",
+ "placedAs": "chemical-plant"
+ },
+ {
+ "id": "nuclear-reactor",
+ "name": "Nuclear Reactor",
+ "stackSize": 20,
+ "category": "production",
+ "order": "p",
+ "placedAs": "nuclear-reactor"
+ },
+ {
+ "id": "centrifuge",
+ "name": "Centrifuge",
+ "stackSize": 20,
+ "category": "production",
+ "order": "q",
+ "placedAs": "centrifuge"
+ },
+ {
+ "id": "rocket-silo",
+ "name": "Rocket Silo",
+ "stackSize": 1,
+ "category": "production",
+ "order": "r",
+ "placedAs": "rocket-silo"
+ },
+ {
+ "id": "boiler",
+ "name": "Boiler",
+ "stackSize": 100,
+ "category": "production",
+ "order": "j",
+ "placedAs": "boiler"
+ },
+ {
+ "id": "steam-engine",
+ "name": "Steam Engine",
+ "stackSize": 50,
+ "category": "production",
+ "order": "k",
+ "placedAs": "steam-engine"
+ },
+ {
+ "id": "solar-panel",
+ "name": "Solar Panel",
+ "stackSize": 200,
+ "category": "production",
+ "order": "l",
+ "placedAs": "solar-panel"
+ },
+ {
+ "id": "accumulator",
+ "name": "Accumulator",
+ "stackSize": 100,
+ "category": "production",
+ "order": "m",
+ "placedAs": "accumulator"
+ },
+ {
+ "id": "small-electric-pole",
+ "name": "Small Electric Pole",
+ "stackSize": 200,
+ "category": "production",
+ "order": "n",
+ "placedAs": "small-electric-pole"
+ },
+ {
+ "id": "medium-electric-pole",
+ "name": "Medium Electric Pole",
+ "stackSize": 200,
+ "category": "production",
+ "order": "o",
+ "placedAs": "medium-electric-pole"
+ },
+ {
+ "id": "big-electric-pole",
+ "name": "Big Electric Pole",
+ "stackSize": 100,
+ "category": "production",
+ "order": "p",
+ "placedAs": "big-electric-pole"
+ },
+ {
+ "id": "sword",
+ "name": "Sword",
+ "stackSize": 1,
+ "category": "combat",
+ "order": "a"
+ },
+ {
+ "id": "firearm-magazine",
+ "name": "Firearm Magazine",
+ "stackSize": 1000,
+ "category": "ammo",
+ "order": "a"
+ },
+ {
+ "id": "piercing-rounds-magazine",
+ "name": "Piercing Rounds Magazine",
+ "stackSize": 1000,
+ "category": "ammo",
+ "order": "b"
+ },
+ {
+ "id": "gun-turret",
+ "name": "Gun Turret",
+ "stackSize": 100,
+ "category": "combat",
+ "order": "c",
+ "placedAs": "gun-turret"
+ },
+ {
+ "id": "laser-turret",
+ "name": "Laser Turret",
+ "stackSize": 100,
+ "category": "combat",
+ "order": "d",
+ "placedAs": "laser-turret"
+ },
+ {
+ "id": "wall",
+ "name": "Wall",
+ "stackSize": 100,
+ "category": "combat",
+ "order": "e",
+ "placedAs": "stone-wall"
+ },
+ {
+ "id": "grenade",
+ "name": "Grenade",
+ "stackSize": 100,
+ "category": "combat",
+ "order": "f"
+ },
+ {
+ "id": "radar",
+ "name": "Radar",
+ "stackSize": 50,
+ "category": "combat",
+ "order": "g",
+ "placedAs": "radar"
+ },
+ {
+ "id": "water-pump",
+ "name": "Water Pump",
+ "stackSize": 50,
+ "category": "production",
+ "order": "m1",
+ "placedAs": "water-pump"
+ },
+ {
+ "id": "fluid-tank",
+ "name": "Fluid Tank",
+ "stackSize": 50,
+ "category": "production",
+ "order": "m2",
+ "placedAs": "fluid-tank"
+ },
+ {
+ "id": "underground-pipe",
+ "name": "Underground Pipe",
+ "stackSize": 50,
+ "category": "production",
+ "order": "m3",
+ "placedAs": "underground-pipe"
+ }
+]
diff --git a/web/shared/data/mining_drill_schema.json b/web/shared/data/mining_drill_schema.json
new file mode 100644
index 00000000..f9ebb07d
--- /dev/null
+++ b/web/shared/data/mining_drill_schema.json
@@ -0,0 +1,82 @@
+{
+ "$schema": "https://example.com/machineui.schema.json",
+ "version": "1.0.0",
+ "id": "machineui.mining_drill",
+ "machineKind": "mining_drill",
+ "title": "Burner Mining Drill",
+
+ "layout": {
+ "flowAxis": "leftToRight",
+ "safeArea": { "top": 16, "left": 16, "bottom": 16, "right": 16 },
+ "padding": { "x": 24, "y": 24 },
+ "grid": { "columns": 12, "rows": 6, "gutterX": 8, "gutterY": 12 }
+ },
+
+ "groups": [
+ {
+ "id": "fuel",
+ "role": "fuel",
+ "header": { "text": "Fuel", "styleRole": "fuel", "alignment": "leading" },
+ "anchor": { "gridX": 0, "gridY": 1, "spanX": 2, "spanY": 3, "alignX": "leading", "alignY": "top" },
+ "content": {
+ "slots": [
+ { "id": "fuel.slot0", "slotKind": "item", "capacity": { "maxStacks": 1 }, "accepts": { "tags": ["fuel"] }, "renders": { "sizeDp": 56, "style": "square", "overlay": "count" } }
+ ],
+ "stateText": { "mode": "auto", "empty": "(empty)", "nonEmpty": "" }
+ }
+ },
+
+ {
+ "id": "output",
+ "role": "output",
+ "header": { "text": "Output", "styleRole": "output", "alignment": "leading" },
+ "anchor": { "gridX": 8, "gridY": 1, "spanX": 2, "spanY": 3, "alignX": "leading", "alignY": "top" },
+ "content": {
+ "slots": [
+ { "id": "output.slot0", "slotKind": "item", "capacity": { "maxStacks": 1 }, "accepts": { "tags": ["ore", "stone", "coal"] }, "renders": { "sizeDp": 56, "style": "square", "overlay": "count" } }
+ ],
+ "stateText": { "mode": "auto", "empty": "(idle)", "nonEmpty": "" }
+ }
+ }
+ ],
+
+ "process": {
+ "id": "mining",
+ "label": { "text": "Mining", "styleRole": "process", "alignment": "center" },
+ "anchor": { "gridX": 4, "gridY": 1, "spanX": 4, "spanY": 3, "alignX": "fill", "alignY": "fill" },
+ "progress": { "style": "bar", "bindTo": "recipeProgress", "showPercent": false },
+ "operators": { "showFlowGlyphs": false, "glyphStyleRole": "muted" }
+ },
+
+ "style": {
+ "palette": {
+ "fuel": "#FFAA00",
+ "input": "#00AAFF",
+ "output": "#00FF00",
+ "process": "#FFA500",
+ "mutedText": "#CCCCCC"
+ },
+ "typography": {
+ "headerSize": 14,
+ "bodySize": 12,
+ "mutedSize": 10
+ },
+ "chrome": {
+ "panelOpacity": 1.0,
+ "cornerRadiusDp": 8,
+ "labelBackplateOpacity": 0.5
+ }
+ },
+
+ "invariants": [
+ "Every GroupHeader must be a descendant of exactly one Group.",
+ "No floating labels: GroupHeader must not be sibling of Slot; it must be ancestor (container header).",
+ "Process.progress must be visually adjacent to Process.label (max distance <= 1 grid row).",
+ "All input-role groups must be placed left of process anchor when flowAxis=leftToRight.",
+ "All output-role groups must be placed right of process anchor when flowAxis=leftToRight.",
+ "Slots in the same Group must share the same leading alignment and spacing.",
+ "If operators.showFlowGlyphs=true, glyphs must use glyphStyleRole=muted and never use semantic colors.",
+ "Any progress bar must have an explicit label (Process.label.text non-empty).",
+ "Empty state text must be inside the owning Group (content.stateText), never global."
+ ]
+}
diff --git a/web/shared/data/recipes.json b/web/shared/data/recipes.json
new file mode 100644
index 00000000..e31a9d05
--- /dev/null
+++ b/web/shared/data/recipes.json
@@ -0,0 +1,1399 @@
+[
+ {
+ "id": "iron-plate",
+ "name": "Iron Plate",
+ "inputs": [
+ {
+ "itemId": "iron-ore",
+ "count": 1
+ }
+ ],
+ "outputs": [
+ {
+ "itemId": "iron-plate",
+ "count": 1
+ }
+ ],
+ "craftTime": 3.2,
+ "category": "smelting",
+ "order": "a"
+ },
+ {
+ "id": "copper-plate",
+ "name": "Copper Plate",
+ "inputs": [
+ {
+ "itemId": "copper-ore",
+ "count": 1
+ }
+ ],
+ "outputs": [
+ {
+ "itemId": "copper-plate",
+ "count": 1
+ }
+ ],
+ "craftTime": 3.2,
+ "category": "smelting",
+ "order": "b"
+ },
+ {
+ "id": "steel-plate",
+ "name": "Steel Plate",
+ "inputs": [
+ {
+ "itemId": "iron-plate",
+ "count": 5
+ }
+ ],
+ "outputs": [
+ {
+ "itemId": "steel-plate",
+ "count": 1
+ }
+ ],
+ "craftTime": 16,
+ "category": "smelting",
+ "order": "c"
+ },
+ {
+ "id": "stone-brick",
+ "name": "Stone Brick",
+ "inputs": [
+ {
+ "itemId": "stone",
+ "count": 2
+ }
+ ],
+ "outputs": [
+ {
+ "itemId": "stone-brick",
+ "count": 1
+ }
+ ],
+ "craftTime": 3.2,
+ "category": "smelting",
+ "order": "d"
+ },
+ {
+ "id": "iron-gear-wheel",
+ "name": "Iron Gear Wheel",
+ "inputs": [
+ {
+ "itemId": "iron-plate",
+ "count": 2
+ }
+ ],
+ "outputs": [
+ {
+ "itemId": "iron-gear-wheel",
+ "count": 1
+ }
+ ],
+ "craftTime": 0.5,
+ "category": "crafting",
+ "order": "a"
+ },
+ {
+ "id": "copper-cable",
+ "name": "Copper Cable",
+ "inputs": [
+ {
+ "itemId": "copper-plate",
+ "count": 1
+ }
+ ],
+ "outputs": [
+ {
+ "itemId": "copper-cable",
+ "count": 2
+ }
+ ],
+ "craftTime": 0.5,
+ "category": "crafting",
+ "order": "b"
+ },
+ {
+ "id": "pipe",
+ "name": "Pipe",
+ "inputs": [
+ {
+ "itemId": "iron-plate",
+ "count": 1
+ }
+ ],
+ "outputs": [
+ {
+ "itemId": "pipe",
+ "count": 1
+ }
+ ],
+ "craftTime": 0.5,
+ "category": "crafting",
+ "order": "b1"
+ },
+ {
+ "id": "electronic-circuit",
+ "name": "Electronic Circuit",
+ "inputs": [
+ {
+ "itemId": "iron-plate",
+ "count": 1
+ },
+ {
+ "itemId": "copper-cable",
+ "count": 3
+ }
+ ],
+ "outputs": [
+ {
+ "itemId": "electronic-circuit",
+ "count": 1
+ }
+ ],
+ "craftTime": 0.5,
+ "category": "crafting",
+ "order": "c"
+ },
+ {
+ "id": "advanced-circuit",
+ "name": "Advanced Circuit",
+ "inputs": [
+ {
+ "itemId": "electronic-circuit",
+ "count": 2
+ },
+ {
+ "itemId": "copper-cable",
+ "count": 4
+ },
+ {
+ "itemId": "plastic-bar",
+ "count": 2
+ }
+ ],
+ "outputs": [
+ {
+ "itemId": "advanced-circuit",
+ "count": 1
+ }
+ ],
+ "craftTime": 6,
+ "category": "crafting",
+ "order": "d"
+ },
+ {
+ "id": "processing-unit",
+ "name": "Processing Unit",
+ "inputs": [
+ {
+ "itemId": "advanced-circuit",
+ "count": 2
+ },
+ {
+ "itemId": "electronic-circuit",
+ "count": 20
+ },
+ {
+ "itemId": "sulfuric-acid",
+ "count": 5
+ }
+ ],
+ "outputs": [
+ {
+ "itemId": "processing-unit",
+ "count": 1
+ }
+ ],
+ "craftTime": 10,
+ "category": "crafting",
+ "order": "e"
+ },
+ {
+ "id": "automation-science-pack",
+ "name": "Automation Science Pack",
+ "inputs": [
+ {
+ "itemId": "copper-plate",
+ "count": 1
+ },
+ {
+ "itemId": "iron-gear-wheel",
+ "count": 1
+ }
+ ],
+ "outputs": [
+ {
+ "itemId": "automation-science-pack",
+ "count": 5
+ }
+ ],
+ "craftTime": 5,
+ "category": "crafting",
+ "order": "e"
+ },
+ {
+ "id": "logistic-science-pack",
+ "name": "Logistic Science Pack",
+ "inputs": [
+ {
+ "itemId": "inserter",
+ "count": 1
+ },
+ {
+ "itemId": "transport-belt",
+ "count": 1
+ }
+ ],
+ "outputs": [
+ {
+ "itemId": "logistic-science-pack",
+ "count": 5
+ }
+ ],
+ "craftTime": 6,
+ "category": "crafting",
+ "order": "f"
+ },
+ {
+ "id": "transport-belt",
+ "name": "Transport Belt",
+ "inputs": [
+ {
+ "itemId": "iron-gear-wheel",
+ "count": 1
+ },
+ {
+ "itemId": "iron-plate",
+ "count": 1
+ }
+ ],
+ "outputs": [
+ {
+ "itemId": "transport-belt",
+ "count": 2
+ }
+ ],
+ "craftTime": 0.5,
+ "category": "crafting",
+ "order": "g"
+ },
+ {
+ "id": "fast-transport-belt",
+ "name": "Fast Transport Belt",
+ "inputs": [
+ {
+ "itemId": "iron-gear-wheel",
+ "count": 5
+ },
+ {
+ "itemId": "iron-plate",
+ "count": 1
+ }
+ ],
+ "outputs": [
+ {
+ "itemId": "fast-transport-belt",
+ "count": 1
+ }
+ ],
+ "craftTime": 0.5,
+ "category": "crafting",
+ "order": "g1"
+ },
+ {
+ "id": "express-transport-belt",
+ "name": "Express Transport Belt",
+ "inputs": [
+ {
+ "itemId": "iron-gear-wheel",
+ "count": 10
+ },
+ {
+ "itemId": "advanced-circuit",
+ "count": 2
+ },
+ {
+ "itemId": "steel-plate",
+ "count": 1
+ }
+ ],
+ "outputs": [
+ {
+ "itemId": "express-transport-belt",
+ "count": 1
+ }
+ ],
+ "craftTime": 0.5,
+ "category": "crafting",
+ "order": "g2"
+ },
+ {
+ "id": "underground-belt",
+ "name": "Underground Belt",
+ "inputs": [
+ {
+ "itemId": "iron-plate",
+ "count": 10
+ },
+ {
+ "itemId": "transport-belt",
+ "count": 5
+ }
+ ],
+ "outputs": [
+ {
+ "itemId": "underground-belt",
+ "count": 2
+ }
+ ],
+ "craftTime": 1,
+ "category": "crafting",
+ "order": "g3"
+ },
+ {
+ "id": "splitter",
+ "name": "Splitter",
+ "inputs": [
+ {
+ "itemId": "electronic-circuit",
+ "count": 5
+ },
+ {
+ "itemId": "iron-plate",
+ "count": 5
+ },
+ {
+ "itemId": "transport-belt",
+ "count": 4
+ }
+ ],
+ "outputs": [
+ {
+ "itemId": "splitter",
+ "count": 1
+ }
+ ],
+ "craftTime": 1,
+ "category": "crafting",
+ "order": "g4"
+ },
+ {
+ "id": "merger",
+ "name": "Merger",
+ "inputs": [
+ {
+ "itemId": "electronic-circuit",
+ "count": 5
+ },
+ {
+ "itemId": "iron-plate",
+ "count": 5
+ },
+ {
+ "itemId": "transport-belt",
+ "count": 4
+ }
+ ],
+ "outputs": [
+ {
+ "itemId": "merger",
+ "count": 1
+ }
+ ],
+ "craftTime": 1,
+ "category": "crafting",
+ "order": "g5"
+ },
+ {
+ "id": "belt-bridge",
+ "name": "Belt Bridge",
+ "inputs": [
+ {
+ "itemId": "iron-plate",
+ "count": 1
+ },
+ {
+ "itemId": "iron-stick",
+ "count": 2
+ },
+ {
+ "itemId": "iron-gear-wheel",
+ "count": 2
+ }
+ ],
+ "outputs": [
+ {
+ "itemId": "belt-bridge",
+ "count": 1
+ }
+ ],
+ "craftTime": 0.5,
+ "category": "crafting",
+ "order": "g6"
+ },
+ {
+ "id": "inserter",
+ "name": "Inserter",
+ "inputs": [
+ {
+ "itemId": "electronic-circuit",
+ "count": 1
+ },
+ {
+ "itemId": "iron-gear-wheel",
+ "count": 1
+ },
+ {
+ "itemId": "iron-plate",
+ "count": 1
+ }
+ ],
+ "outputs": [
+ {
+ "itemId": "inserter",
+ "count": 1
+ }
+ ],
+ "craftTime": 0.5,
+ "category": "crafting",
+ "order": "h"
+ },
+ {
+ "id": "long-handed-inserter",
+ "name": "Long Handed Inserter",
+ "inputs": [
+ {
+ "itemId": "iron-gear-wheel",
+ "count": 1
+ },
+ {
+ "itemId": "iron-plate",
+ "count": 1
+ },
+ {
+ "itemId": "inserter",
+ "count": 1
+ }
+ ],
+ "outputs": [
+ {
+ "itemId": "long-handed-inserter",
+ "count": 1
+ }
+ ],
+ "craftTime": 0.5,
+ "category": "crafting",
+ "order": "h1"
+ },
+ {
+ "id": "fast-inserter",
+ "name": "Fast Inserter",
+ "inputs": [
+ {
+ "itemId": "electronic-circuit",
+ "count": 2
+ },
+ {
+ "itemId": "iron-plate",
+ "count": 2
+ },
+ {
+ "itemId": "inserter",
+ "count": 1
+ }
+ ],
+ "outputs": [
+ {
+ "itemId": "fast-inserter",
+ "count": 1
+ }
+ ],
+ "craftTime": 0.5,
+ "category": "crafting",
+ "order": "h2"
+ },
+ {
+ "id": "stack-inserter",
+ "name": "Stack Inserter",
+ "inputs": [
+ {
+ "itemId": "advanced-circuit",
+ "count": 1
+ },
+ {
+ "itemId": "electronic-circuit",
+ "count": 15
+ },
+ {
+ "itemId": "iron-gear-wheel",
+ "count": 15
+ },
+ {
+ "itemId": "fast-inserter",
+ "count": 1
+ }
+ ],
+ "outputs": [
+ {
+ "itemId": "stack-inserter",
+ "count": 1
+ }
+ ],
+ "craftTime": 0.5,
+ "category": "crafting",
+ "order": "h3"
+ },
+ {
+ "id": "wooden-chest",
+ "name": "Wooden Chest",
+ "inputs": [
+ {
+ "itemId": "wood",
+ "count": 2
+ }
+ ],
+ "outputs": [
+ {
+ "itemId": "wooden-chest",
+ "count": 1
+ }
+ ],
+ "craftTime": 0.5,
+ "category": "crafting",
+ "order": "i"
+ },
+ {
+ "id": "iron-chest",
+ "name": "Iron Chest",
+ "inputs": [
+ {
+ "itemId": "iron-plate",
+ "count": 8
+ }
+ ],
+ "outputs": [
+ {
+ "itemId": "iron-chest",
+ "count": 1
+ }
+ ],
+ "craftTime": 0.5,
+ "category": "crafting",
+ "order": "i1"
+ },
+ {
+ "id": "steel-chest",
+ "name": "Steel Chest",
+ "inputs": [
+ {
+ "itemId": "steel-plate",
+ "count": 8
+ }
+ ],
+ "outputs": [
+ {
+ "itemId": "steel-chest",
+ "count": 1
+ }
+ ],
+ "craftTime": 0.5,
+ "category": "crafting",
+ "order": "i2"
+ },
+ {
+ "id": "small-electric-pole",
+ "name": "Small Electric Pole",
+ "inputs": [
+ {
+ "itemId": "wood",
+ "count": 2
+ },
+ {
+ "itemId": "copper-cable",
+ "count": 2
+ }
+ ],
+ "outputs": [
+ {
+ "itemId": "small-electric-pole",
+ "count": 2
+ }
+ ],
+ "craftTime": 0.5,
+ "category": "crafting",
+ "order": "j"
+ },
+ {
+ "id": "medium-electric-pole",
+ "name": "Medium Electric Pole",
+ "inputs": [
+ {
+ "itemId": "steel-plate",
+ "count": 2
+ },
+ {
+ "itemId": "copper-plate",
+ "count": 2
+ }
+ ],
+ "outputs": [
+ {
+ "itemId": "medium-electric-pole",
+ "count": 1
+ }
+ ],
+ "craftTime": 0.5,
+ "category": "crafting",
+ "order": "j1"
+ },
+ {
+ "id": "big-electric-pole",
+ "name": "Big Electric Pole",
+ "inputs": [
+ {
+ "itemId": "steel-plate",
+ "count": 5
+ },
+ {
+ "itemId": "copper-plate",
+ "count": 5
+ }
+ ],
+ "outputs": [
+ {
+ "itemId": "big-electric-pole",
+ "count": 1
+ }
+ ],
+ "craftTime": 0.5,
+ "category": "crafting",
+ "order": "j2"
+ },
+ {
+ "id": "stone-furnace",
+ "name": "Stone Furnace",
+ "inputs": [
+ {
+ "itemId": "iron-plate",
+ "count": 5
+ }
+ ],
+ "outputs": [
+ {
+ "itemId": "stone-furnace",
+ "count": 1
+ }
+ ],
+ "craftTime": 0.5,
+ "category": "crafting",
+ "order": "k"
+ },
+ {
+ "id": "steel-furnace",
+ "name": "Steel Furnace",
+ "inputs": [
+ {
+ "itemId": "steel-plate",
+ "count": 6
+ },
+ {
+ "itemId": "stone-brick",
+ "count": 10
+ }
+ ],
+ "outputs": [
+ {
+ "itemId": "steel-furnace",
+ "count": 1
+ }
+ ],
+ "craftTime": 3,
+ "category": "crafting",
+ "order": "k1"
+ },
+ {
+ "id": "burner-mining-drill",
+ "name": "Burner Mining Drill",
+ "inputs": [
+ {
+ "itemId": "iron-plate",
+ "count": 5
+ }
+ ],
+ "outputs": [
+ {
+ "itemId": "burner-mining-drill",
+ "count": 1
+ }
+ ],
+ "craftTime": 2,
+ "category": "crafting",
+ "order": "m"
+ },
+ {
+ "id": "electric-mining-drill",
+ "name": "Electric Mining Drill",
+ "inputs": [
+ {
+ "itemId": "electronic-circuit",
+ "count": 3
+ },
+ {
+ "itemId": "iron-gear-wheel",
+ "count": 5
+ },
+ {
+ "itemId": "iron-plate",
+ "count": 10
+ }
+ ],
+ "outputs": [
+ {
+ "itemId": "electric-mining-drill",
+ "count": 1
+ }
+ ],
+ "craftTime": 2,
+ "category": "crafting",
+ "order": "m1"
+ },
+ {
+ "id": "stone-wall",
+ "name": "Wall",
+ "inputs": [
+ {
+ "itemId": "stone-brick",
+ "count": 5
+ }
+ ],
+ "outputs": [
+ {
+ "itemId": "stone-wall",
+ "count": 1
+ }
+ ],
+ "craftTime": 0.5,
+ "category": "crafting",
+ "order": "n"
+ },
+ {
+ "id": "firearm-magazine",
+ "name": "Firearm Magazine",
+ "inputs": [
+ {
+ "itemId": "iron-plate",
+ "count": 4
+ }
+ ],
+ "outputs": [
+ {
+ "itemId": "firearm-magazine",
+ "count": 1
+ }
+ ],
+ "craftTime": 1,
+ "category": "crafting",
+ "order": "t"
+ },
+ {
+ "id": "water-pump",
+ "name": "Water Pump",
+ "inputs": [
+ {
+ "itemId": "iron-plate",
+ "count": 5
+ },
+ {
+ "itemId": "pipe",
+ "count": 5
+ },
+ {
+ "itemId": "electronic-circuit",
+ "count": 2
+ }
+ ],
+ "outputs": [
+ {
+ "itemId": "water-pump",
+ "count": 1
+ }
+ ],
+ "craftTime": 5,
+ "category": "crafting",
+ "order": "w"
+ },
+ {
+ "id": "basic-oil-processing",
+ "name": "Basic Oil Processing",
+ "inputs": [
+ {
+ "itemId": "crude-oil",
+ "count": 50
+ }
+ ],
+ "outputs": [
+ {
+ "itemId": "petroleum-gas",
+ "count": 22.5
+ },
+ {
+ "itemId": "light-oil",
+ "count": 15
+ },
+ {
+ "itemId": "heavy-oil",
+ "count": 12.5
+ }
+ ],
+ "craftTime": 5,
+ "category": "oil-processing",
+ "order": "a"
+ },
+ {
+ "id": "advanced-oil-processing",
+ "name": "Advanced Oil Processing",
+ "inputs": [
+ {
+ "itemId": "crude-oil",
+ "count": 50
+ },
+ {
+ "itemId": "water",
+ "count": 25
+ }
+ ],
+ "outputs": [
+ {
+ "itemId": "petroleum-gas",
+ "count": 27.5
+ },
+ {
+ "itemId": "light-oil",
+ "count": 22.5
+ },
+ {
+ "itemId": "heavy-oil",
+ "count": 12.5
+ }
+ ],
+ "craftTime": 5,
+ "category": "oil-processing",
+ "order": "c"
+ },
+ {
+ "id": "light-oil-cracking",
+ "name": "Light Oil Cracking",
+ "inputs": [
+ {
+ "itemId": "light-oil",
+ "count": 15
+ },
+ {
+ "itemId": "water",
+ "count": 15
+ }
+ ],
+ "outputs": [
+ {
+ "itemId": "petroleum-gas",
+ "count": 10
+ }
+ ],
+ "craftTime": 5,
+ "category": "chemistry",
+ "order": "c"
+ },
+ {
+ "id": "heavy-oil-cracking",
+ "name": "Heavy Oil Cracking",
+ "inputs": [
+ {
+ "itemId": "heavy-oil",
+ "count": 20
+ },
+ {
+ "itemId": "water",
+ "count": 15
+ }
+ ],
+ "outputs": [
+ {
+ "itemId": "light-oil",
+ "count": 15
+ }
+ ],
+ "craftTime": 5,
+ "category": "chemistry",
+ "order": "d"
+ },
+ {
+ "id": "plastic-bar",
+ "name": "Plastic Bar",
+ "inputs": [
+ {
+ "itemId": "coal",
+ "count": 1
+ },
+ {
+ "itemId": "petroleum-gas",
+ "count": 10
+ }
+ ],
+ "outputs": [
+ {
+ "itemId": "plastic-bar",
+ "count": 2
+ }
+ ],
+ "craftTime": 1,
+ "category": "chemistry",
+ "order": "e"
+ },
+ {
+ "id": "sulfur",
+ "name": "Sulfur",
+ "inputs": [
+ {
+ "itemId": "petroleum-gas",
+ "count": 15
+ },
+ {
+ "itemId": "water",
+ "count": 15
+ }
+ ],
+ "outputs": [
+ {
+ "itemId": "sulfur",
+ "count": 2
+ },
+ {
+ "itemId": "sulfuric-acid",
+ "count": 25
+ }
+ ],
+ "craftTime": 1,
+ "category": "chemistry",
+ "order": "f"
+ },
+ {
+ "id": "sulfuric-acid",
+ "name": "Sulfuric Acid",
+ "inputs": [
+ {
+ "itemId": "sulfur",
+ "count": 5
+ },
+ {
+ "itemId": "iron-plate",
+ "count": 1
+ },
+ {
+ "itemId": "water",
+ "count": 50
+ }
+ ],
+ "outputs": [
+ {
+ "itemId": "sulfuric-acid",
+ "count": 50
+ }
+ ],
+ "craftTime": 1,
+ "category": "chemistry",
+ "order": "g"
+ },
+ {
+ "id": "lubricant",
+ "name": "Lubricant",
+ "inputs": [
+ {
+ "itemId": "heavy-oil",
+ "count": 5
+ }
+ ],
+ "outputs": [
+ {
+ "itemId": "lubricant",
+ "count": 5
+ }
+ ],
+ "craftTime": 1,
+ "category": "chemistry",
+ "order": "h"
+ },
+ {
+ "id": "solid-fuel",
+ "name": "Solid Fuel",
+ "inputs": [
+ {
+ "itemId": "coal",
+ "count": 1
+ },
+ {
+ "itemId": "petroleum-gas",
+ "count": 10
+ }
+ ],
+ "outputs": [
+ {
+ "itemId": "solid-fuel",
+ "count": 1
+ }
+ ],
+ "craftTime": 2,
+ "category": "chemistry",
+ "order": "i"
+ },
+ {
+ "id": "low-density-structure",
+ "name": "Low Density Structure",
+ "inputs": [
+ {
+ "itemId": "steel-plate",
+ "count": 2
+ },
+ {
+ "itemId": "copper-plate",
+ "count": 20
+ },
+ {
+ "itemId": "plastic-bar",
+ "count": 5
+ }
+ ],
+ "outputs": [
+ {
+ "itemId": "low-density-structure",
+ "count": 1
+ }
+ ],
+ "craftTime": 20,
+ "category": "crafting",
+ "order": "j"
+ },
+ {
+ "id": "battery",
+ "name": "Battery",
+ "inputs": [
+ {
+ "itemId": "iron-plate",
+ "count": 1
+ },
+ {
+ "itemId": "copper-plate",
+ "count": 1
+ },
+ {
+ "itemId": "sulfuric-acid",
+ "count": 20
+ }
+ ],
+ "outputs": [
+ {
+ "itemId": "battery",
+ "count": 1
+ }
+ ],
+ "craftTime": 5,
+ "category": "chemistry",
+ "order": "i"
+ },
+ {
+ "id": "explosives",
+ "name": "Explosives",
+ "inputs": [
+ {
+ "itemId": "coal",
+ "count": 1
+ },
+ {
+ "itemId": "sulfur",
+ "count": 1
+ },
+ {
+ "itemId": "water",
+ "count": 1
+ }
+ ],
+ "outputs": [
+ {
+ "itemId": "explosives",
+ "count": 2
+ }
+ ],
+ "craftTime": 5,
+ "category": "chemistry",
+ "order": "j"
+ },
+ {
+ "id": "uranium-processing",
+ "name": "Uranium Processing",
+ "inputs": [
+ {
+ "itemId": "uranium-ore",
+ "count": 10
+ }
+ ],
+ "outputs": [
+ {
+ "itemId": "uranium-235",
+ "count": 1
+ },
+ {
+ "itemId": "uranium-238",
+ "count": 9
+ }
+ ],
+ "craftTime": 12,
+ "category": "centrifuging",
+ "order": "ab"
+ },
+ {
+ "id": "nuclear-fuel",
+ "name": "Nuclear Fuel",
+ "inputs": [
+ {
+ "itemId": "uranium-235",
+ "count": 1
+ },
+ {
+ "itemId": "uranium-238",
+ "count": 19
+ }
+ ],
+ "outputs": [
+ {
+ "itemId": "nuclear-fuel",
+ "count": 1
+ }
+ ],
+ "craftTime": 60,
+ "category": "centrifuging",
+ "order": "ac"
+ },
+ {
+ "id": "nuclear-reactor",
+ "name": "Nuclear Reactor",
+ "inputs": [
+ {
+ "itemId": "steel-plate",
+ "count": 400
+ },
+ {
+ "itemId": "advanced-circuit",
+ "count": 400
+ },
+ {
+ "itemId": "copper-plate",
+ "count": 400
+ },
+ {
+ "itemId": "stone-brick",
+ "count": 400
+ }
+ ],
+ "outputs": [
+ {
+ "itemId": "nuclear-reactor",
+ "count": 1
+ }
+ ],
+ "craftTime": 8,
+ "category": "crafting",
+ "order": "ad"
+ },
+ {
+ "id": "centrifuge",
+ "name": "Centrifuge",
+ "inputs": [
+ {
+ "itemId": "steel-plate",
+ "count": 50
+ },
+ {
+ "itemId": "advanced-circuit",
+ "count": 100
+ },
+ {
+ "itemId": "processing-unit",
+ "count": 100
+ },
+ {
+ "itemId": "stone-brick",
+ "count": 100
+ }
+ ],
+ "outputs": [
+ {
+ "itemId": "centrifuge",
+ "count": 1
+ }
+ ],
+ "craftTime": 4,
+ "category": "crafting",
+ "order": "ae"
+ },
+ {
+ "id": "rocket-fuel",
+ "name": "Rocket Fuel",
+ "inputs": [
+ {
+ "itemId": "solid-fuel",
+ "count": 10
+ }
+ ],
+ "outputs": [
+ {
+ "itemId": "rocket-fuel",
+ "count": 1
+ }
+ ],
+ "craftTime": 30,
+ "category": "crafting",
+ "order": "af"
+ },
+ {
+ "id": "rocket-parts",
+ "name": "Rocket Parts",
+ "inputs": [
+ {
+ "itemId": "steel-plate",
+ "count": 10
+ },
+ {
+ "itemId": "low-density-structure",
+ "count": 10
+ },
+ {
+ "itemId": "rocket-fuel",
+ "count": 10
+ },
+ {
+ "itemId": "electronic-circuit",
+ "count": 10
+ }
+ ],
+ "outputs": [
+ {
+ "itemId": "rocket-parts",
+ "count": 1
+ }
+ ],
+ "craftTime": 3,
+ "category": "crafting",
+ "order": "ag"
+ },
+ {
+ "id": "satellite",
+ "name": "Satellite",
+ "inputs": [
+ {
+ "itemId": "low-density-structure",
+ "count": 100
+ },
+ {
+ "itemId": "solar-panel",
+ "count": 100
+ },
+ {
+ "itemId": "accumulator",
+ "count": 100
+ },
+ {
+ "itemId": "radar",
+ "count": 5
+ },
+ {
+ "itemId": "processing-unit",
+ "count": 100
+ },
+ {
+ "itemId": "rocket-fuel",
+ "count": 50
+ }
+ ],
+ "outputs": [
+ {
+ "itemId": "satellite",
+ "count": 1
+ }
+ ],
+ "craftTime": 5,
+ "category": "crafting",
+ "order": "ah"
+ },
+ {
+ "id": "rocket-silo",
+ "name": "Rocket Silo",
+ "inputs": [
+ {
+ "itemId": "steel-plate",
+ "count": 1000
+ },
+ {
+ "itemId": "stone-brick",
+ "count": 1000
+ },
+ {
+ "itemId": "pipe",
+ "count": 100
+ },
+ {
+ "itemId": "processing-unit",
+ "count": 200
+ }
+ ],
+ "outputs": [
+ {
+ "itemId": "rocket-silo",
+ "count": 1
+ }
+ ],
+ "craftTime": 30,
+ "category": "crafting",
+ "order": "ai"
+ },
+ {
+ "id": "space-science-pack",
+ "name": "Space Science Pack (Generated by Rocket Launches)",
+ "inputs": [],
+ "outputs": [
+ {
+ "itemId": "space-science-pack",
+ "count": 1000
+ }
+ ],
+ "craftTime": 0,
+ "category": "crafting",
+ "order": "aj"
+ },
+ {
+ "id": "chemical-science-pack",
+ "name": "Chemical Science Pack",
+ "inputs": [
+ {
+ "itemId": "advanced-circuit",
+ "count": 3
+ },
+ {
+ "itemId": "engine-unit",
+ "count": 2
+ },
+ {
+ "itemId": "sulfuric-acid",
+ "count": 1
+ }
+ ],
+ "outputs": [
+ {
+ "itemId": "chemical-science-pack",
+ "count": 1
+ }
+ ],
+ "craftTime": 24,
+ "category": "crafting",
+ "order": "aa"
+ }
+]
diff --git a/web/shared/data/starting_items.json b/web/shared/data/starting_items.json
new file mode 100644
index 00000000..f281aad4
--- /dev/null
+++ b/web/shared/data/starting_items.json
@@ -0,0 +1,74 @@
+{
+ "startingItems": [
+ {
+ "itemId": "iron-plate",
+ "count": 80,
+ "comment": "Iron plates for basic construction and assembler recipes"
+ },
+ {
+ "itemId": "copper-plate",
+ "count": 80,
+ "comment": "Copper plates for electronics and assembler recipes"
+ },
+ {
+ "itemId": "coal",
+ "count": 150,
+ "comment": "Coal for fuel, furnaces, and boilers"
+ },
+ {
+ "itemId": "stone",
+ "count": 100,
+ "comment": "Stone for furnaces and walls"
+ },
+ {
+ "itemId": "iron-gear-wheel",
+ "count": 30,
+ "comment": "Gears for mechanical components and assembler recipes"
+ },
+ {
+ "itemId": "electronic-circuit",
+ "count": 30,
+ "comment": "Circuits for advanced machines and assembler recipes"
+ },
+ {
+ "itemId": "firearm-magazine",
+ "count": 50,
+ "comment": "Ammunition for early-game biter defense"
+ },
+ {
+ "itemId": "wood",
+ "count": 50,
+ "comment": "Fuel for boilers and burner miners; assembler recipes"
+ },
+ {
+ "itemId": "copper-cable",
+ "count": 50,
+ "comment": "Assembler input for electronic-circuit, small electric pole, etc."
+ },
+ {
+ "itemId": "pipe",
+ "count": 30,
+ "comment": "Boiler/lab building cost; assembler recipes"
+ },
+ {
+ "itemId": "transport-belt",
+ "count": 20,
+ "comment": "Assembler recipes and belt upgrades"
+ },
+ {
+ "itemId": "stone-brick",
+ "count": 30,
+ "comment": "Furnace output; building costs and assembler recipes"
+ },
+ {
+ "itemId": "solid-fuel",
+ "count": 10,
+ "comment": "High-value fuel for boilers and furnaces"
+ },
+ {
+ "itemId": "sword",
+ "count": 1,
+ "comment": "Melee weapon for early-game combat and defense"
+ }
+ ]
+}
diff --git a/web/shared/data/techs.json b/web/shared/data/techs.json
new file mode 100644
index 00000000..63774c43
--- /dev/null
+++ b/web/shared/data/techs.json
@@ -0,0 +1,950 @@
+[
+ {
+ "id": "automation",
+ "name": "Automation",
+ "description": "Unlocks assembling machines for automated crafting",
+ "cost": [
+ {
+ "itemId": "automation-science-pack",
+ "count": 10
+ }
+ ],
+ "researchTime": 10,
+ "unlocks": {
+ "recipes": [
+ "assembling-machine-1"
+ ]
+ },
+ "order": "a",
+ "tier": 1
+ },
+ {
+ "id": "logistics",
+ "name": "Logistics",
+ "description": "Unlocks basic logistics items",
+ "cost": [
+ {
+ "itemId": "automation-science-pack",
+ "count": 20
+ }
+ ],
+ "researchTime": 15,
+ "unlocks": {
+ "recipes": [
+ "fast-inserter",
+ "long-handed-inserter"
+ ]
+ },
+ "order": "b",
+ "tier": 1
+ },
+ {
+ "id": "turrets",
+ "name": "Turrets",
+ "description": "Unlocks gun turrets for defense",
+ "cost": [
+ {
+ "itemId": "automation-science-pack",
+ "count": 10
+ }
+ ],
+ "researchTime": 10,
+ "unlocks": {
+ "recipes": [
+ "gun-turret"
+ ]
+ },
+ "order": "c",
+ "tier": 1
+ },
+ {
+ "id": "stone-walls",
+ "name": "Stone Walls",
+ "description": "Unlocks walls for defense",
+ "cost": [
+ {
+ "itemId": "automation-science-pack",
+ "count": 10
+ }
+ ],
+ "researchTime": 10,
+ "unlocks": {
+ "recipes": [
+ "stone-wall"
+ ]
+ },
+ "order": "d",
+ "tier": 1
+ },
+ {
+ "id": "steel-processing",
+ "name": "Steel Processing",
+ "description": "Unlocks steel plate smelting",
+ "cost": [
+ {
+ "itemId": "automation-science-pack",
+ "count": 50
+ }
+ ],
+ "researchTime": 20,
+ "unlocks": {
+ "recipes": [
+ "steel-plate"
+ ]
+ },
+ "order": "e",
+ "tier": 1
+ },
+ {
+ "id": "military",
+ "name": "Military",
+ "description": "Unlocks better ammunition",
+ "cost": [
+ {
+ "itemId": "automation-science-pack",
+ "count": 20
+ }
+ ],
+ "researchTime": 15,
+ "unlocks": {
+ "recipes": [
+ "piercing-rounds-magazine"
+ ]
+ },
+ "order": "f",
+ "tier": 1
+ },
+ {
+ "id": "logistic-science-pack",
+ "name": "Logistic Science Pack",
+ "description": "Unlocks green science packs",
+ "cost": [
+ {
+ "itemId": "automation-science-pack",
+ "count": 75
+ }
+ ],
+ "researchTime": 30,
+ "unlocks": {
+ "recipes": [
+ "logistic-science-pack"
+ ]
+ },
+ "order": "a",
+ "tier": 2,
+ "prerequisites": [
+ "automation"
+ ]
+ },
+ {
+ "id": "automation-2",
+ "name": "Automation 2",
+ "description": "Unlocks faster assembling machines",
+ "cost": [
+ {
+ "itemId": "automation-science-pack",
+ "count": 40
+ },
+ {
+ "itemId": "logistic-science-pack",
+ "count": 40
+ }
+ ],
+ "researchTime": 30,
+ "unlocks": {
+ "recipes": [
+ "assembling-machine-2"
+ ]
+ },
+ "order": "b",
+ "tier": 2,
+ "prerequisites": [
+ "automation",
+ "logistic-science-pack"
+ ]
+ },
+ {
+ "id": "automation-3",
+ "name": "Automation 3",
+ "description": "Unlocks the fastest assembling machines",
+ "cost": [
+ {
+ "itemId": "automation-science-pack",
+ "count": 100
+ },
+ {
+ "itemId": "logistic-science-pack",
+ "count": 100
+ },
+ {
+ "itemId": "chemical-science-pack",
+ "count": 100
+ }
+ ],
+ "researchTime": 60,
+ "unlocks": {
+ "recipes": [
+ "assembling-machine-3"
+ ]
+ },
+ "order": "c",
+ "tier": 3,
+ "prerequisites": [
+ "automation-2",
+ "advanced-electronics"
+ ]
+ },
+ {
+ "id": "logistics-2",
+ "name": "Logistics 2",
+ "description": "Unlocks fast transport belts",
+ "cost": [
+ {
+ "itemId": "automation-science-pack",
+ "count": 40
+ },
+ {
+ "itemId": "logistic-science-pack",
+ "count": 40
+ }
+ ],
+ "researchTime": 30,
+ "unlocks": {
+ "recipes": [
+ "fast-transport-belt"
+ ]
+ },
+ "order": "c",
+ "tier": 2,
+ "prerequisites": [
+ "logistics",
+ "logistic-science-pack"
+ ]
+ },
+ {
+ "id": "advanced-logistics",
+ "name": "Advanced Logistics",
+ "description": "Unlocks underground belts, splitters, and mergers",
+ "cost": [
+ {
+ "itemId": "automation-science-pack",
+ "count": 75
+ },
+ {
+ "itemId": "logistic-science-pack",
+ "count": 75
+ }
+ ],
+ "researchTime": 45,
+ "unlocks": {
+ "recipes": [
+ "underground-belt",
+ "splitter",
+ "merger"
+ ]
+ },
+ "order": "d",
+ "tier": 3,
+ "prerequisites": [
+ "logistics-2"
+ ]
+ },
+ {
+ "id": "advanced-material-processing",
+ "name": "Advanced Material Processing",
+ "description": "Unlocks steel furnaces",
+ "cost": [
+ {
+ "itemId": "automation-science-pack",
+ "count": 50
+ },
+ {
+ "itemId": "logistic-science-pack",
+ "count": 50
+ }
+ ],
+ "researchTime": 30,
+ "unlocks": {
+ "recipes": [
+ "steel-furnace"
+ ]
+ },
+ "order": "d",
+ "tier": 2,
+ "prerequisites": [
+ "steel-processing"
+ ]
+ },
+ {
+ "id": "solar-energy",
+ "name": "Solar Energy",
+ "description": "Unlocks solar panels",
+ "cost": [
+ {
+ "itemId": "automation-science-pack",
+ "count": 100
+ },
+ {
+ "itemId": "logistic-science-pack",
+ "count": 100
+ }
+ ],
+ "researchTime": 60,
+ "unlocks": {
+ "recipes": [
+ "solar-panel"
+ ]
+ },
+ "order": "e",
+ "tier": 2,
+ "prerequisites": [
+ "logistic-science-pack"
+ ]
+ },
+ {
+ "id": "electric-energy-accumulators",
+ "name": "Electric Energy Accumulators",
+ "description": "Unlocks accumulators for energy storage",
+ "cost": [
+ {
+ "itemId": "automation-science-pack",
+ "count": 100
+ },
+ {
+ "itemId": "logistic-science-pack",
+ "count": 100
+ }
+ ],
+ "researchTime": 60,
+ "unlocks": {
+ "recipes": [
+ "accumulator"
+ ]
+ },
+ "order": "f",
+ "tier": 2,
+ "prerequisites": [
+ "solar-energy"
+ ]
+ },
+ {
+ "id": "laser-turrets",
+ "name": "Laser Turrets",
+ "description": "Unlocks laser turrets",
+ "cost": [
+ {
+ "itemId": "automation-science-pack",
+ "count": 150
+ },
+ {
+ "itemId": "logistic-science-pack",
+ "count": 150
+ }
+ ],
+ "researchTime": 60,
+ "unlocks": {
+ "recipes": [
+ "laser-turret"
+ ]
+ },
+ "order": "g",
+ "tier": 2,
+ "prerequisites": [
+ "turrets",
+ "logistic-science-pack"
+ ]
+ },
+ {
+ "id": "mining-productivity-1",
+ "name": "Mining Productivity 1",
+ "description": "Increases mining output by 10%",
+ "cost": [
+ {
+ "itemId": "automation-science-pack",
+ "count": 100
+ },
+ {
+ "itemId": "logistic-science-pack",
+ "count": 100
+ }
+ ],
+ "researchTime": 60,
+ "unlocks": {},
+ "order": "z-a",
+ "tier": 2,
+ "prerequisites": [
+ "logistic-science-pack"
+ ]
+ },
+ {
+ "id": "research-speed-1",
+ "name": "Research Speed 1",
+ "description": "Increases research speed by 20%",
+ "cost": [
+ {
+ "itemId": "automation-science-pack",
+ "count": 100
+ },
+ {
+ "itemId": "logistic-science-pack",
+ "count": 100
+ }
+ ],
+ "researchTime": 60,
+ "unlocks": {},
+ "order": "z-b",
+ "tier": 2,
+ "prerequisites": [
+ "logistic-science-pack"
+ ]
+ },
+ {
+ "id": "advanced-electronics",
+ "name": "Advanced Electronics",
+ "description": "Unlocks advanced electronic circuits and processing units",
+ "cost": [
+ {
+ "itemId": "automation-science-pack",
+ "count": 75
+ },
+ {
+ "itemId": "logistic-science-pack",
+ "count": 75
+ }
+ ],
+ "researchTime": 45,
+ "unlocks": {
+ "recipes": [
+ "advanced-circuit",
+ "processing-unit"
+ ]
+ },
+ "order": "h",
+ "tier": 2,
+ "prerequisites": [
+ "logistic-science-pack"
+ ]
+ },
+ {
+ "id": "oil-processing",
+ "name": "Oil Processing",
+ "description": "Unlocks oil wells and basic oil refining",
+ "cost": [
+ {
+ "itemId": "automation-science-pack",
+ "count": 50
+ },
+ {
+ "itemId": "logistic-science-pack",
+ "count": 50
+ },
+ {
+ "itemId": "chemical-science-pack",
+ "count": 50
+ }
+ ],
+ "researchTime": 30,
+ "unlocks": {
+ "recipes": [
+ "pumpjack",
+ "water-pump",
+ "basic-oil-processing"
+ ]
+ },
+ "order": "a",
+ "tier": 3,
+ "prerequisites": [
+ "advanced-electronics"
+ ]
+ },
+ {
+ "id": "advanced-oil-processing",
+ "name": "Advanced Oil Processing",
+ "description": "Unlocks advanced oil refining techniques",
+ "cost": [
+ {
+ "itemId": "automation-science-pack",
+ "count": 50
+ },
+ {
+ "itemId": "logistic-science-pack",
+ "count": 50
+ },
+ {
+ "itemId": "chemical-science-pack",
+ "count": 50
+ }
+ ],
+ "researchTime": 30,
+ "unlocks": {
+ "recipes": [
+ "oil-refinery",
+ "advanced-oil-processing"
+ ]
+ },
+ "order": "b",
+ "tier": 3,
+ "prerequisites": [
+ "oil-processing"
+ ]
+ },
+ {
+ "id": "chemistry",
+ "name": "Chemistry",
+ "description": "Unlocks chemical plants and basic chemical production",
+ "cost": [
+ {
+ "itemId": "automation-science-pack",
+ "count": 100
+ },
+ {
+ "itemId": "logistic-science-pack",
+ "count": 100
+ },
+ {
+ "itemId": "chemical-science-pack",
+ "count": 100
+ }
+ ],
+ "researchTime": 30,
+ "unlocks": {
+ "recipes": [
+ "chemical-plant",
+ "sulfur",
+ "plastic-bar",
+ "chemical-science-pack"
+ ]
+ },
+ "order": "c",
+ "tier": 3,
+ "prerequisites": [
+ "advanced-oil-processing"
+ ]
+ },
+ {
+ "id": "sulfur-processing",
+ "name": "Sulfur Processing",
+ "description": "Unlocks sulfuric acid production",
+ "cost": [
+ {
+ "itemId": "automation-science-pack",
+ "count": 100
+ },
+ {
+ "itemId": "logistic-science-pack",
+ "count": 100
+ },
+ {
+ "itemId": "chemical-science-pack",
+ "count": 100
+ }
+ ],
+ "researchTime": 30,
+ "unlocks": {
+ "recipes": [
+ "sulfuric-acid"
+ ]
+ },
+ "order": "d",
+ "tier": 3,
+ "prerequisites": [
+ "chemistry"
+ ]
+ },
+ {
+ "id": "oil-cracking",
+ "name": "Oil Cracking",
+ "description": "Unlocks oil cracking for better yields",
+ "cost": [
+ {
+ "itemId": "automation-science-pack",
+ "count": 100
+ },
+ {
+ "itemId": "logistic-science-pack",
+ "count": 100
+ },
+ {
+ "itemId": "chemical-science-pack",
+ "count": 100
+ }
+ ],
+ "researchTime": 30,
+ "unlocks": {
+ "recipes": [
+ "light-oil-cracking",
+ "heavy-oil-cracking"
+ ]
+ },
+ "order": "e",
+ "tier": 3,
+ "prerequisites": [
+ "chemistry"
+ ]
+ },
+ {
+ "id": "battery",
+ "name": "Battery",
+ "description": "Unlocks battery production",
+ "cost": [
+ {
+ "itemId": "automation-science-pack",
+ "count": 100
+ },
+ {
+ "itemId": "logistic-science-pack",
+ "count": 100
+ },
+ {
+ "itemId": "chemical-science-pack",
+ "count": 100
+ }
+ ],
+ "researchTime": 30,
+ "unlocks": {
+ "recipes": [
+ "battery"
+ ]
+ },
+ "order": "f",
+ "tier": 3,
+ "prerequisites": [
+ "sulfur-processing"
+ ]
+ },
+ {
+ "id": "explosives",
+ "name": "Explosives",
+ "description": "Unlocks explosive production",
+ "cost": [
+ {
+ "itemId": "automation-science-pack",
+ "count": 100
+ },
+ {
+ "itemId": "logistic-science-pack",
+ "count": 100
+ },
+ {
+ "itemId": "chemical-science-pack",
+ "count": 100
+ }
+ ],
+ "researchTime": 30,
+ "unlocks": {
+ "recipes": [
+ "explosives"
+ ]
+ },
+ "order": "g",
+ "tier": 3,
+ "prerequisites": [
+ "sulfur-processing"
+ ]
+ },
+ {
+ "id": "lubricant",
+ "name": "Lubricant",
+ "description": "Unlocks lubricant production",
+ "cost": [
+ {
+ "itemId": "automation-science-pack",
+ "count": 100
+ },
+ {
+ "itemId": "logistic-science-pack",
+ "count": 100
+ },
+ {
+ "itemId": "chemical-science-pack",
+ "count": 100
+ }
+ ],
+ "researchTime": 30,
+ "unlocks": {
+ "recipes": [
+ "lubricant"
+ ]
+ },
+ "order": "h",
+ "tier": 3,
+ "prerequisites": [
+ "oil-cracking"
+ ]
+ },
+ {
+ "id": "stack-inserter",
+ "name": "Stack Inserter",
+ "description": "Unlocks stack inserters for moving multiple items",
+ "cost": [
+ {
+ "itemId": "automation-science-pack",
+ "count": 150
+ },
+ {
+ "itemId": "logistic-science-pack",
+ "count": 150
+ },
+ {
+ "itemId": "chemical-science-pack",
+ "count": 150
+ }
+ ],
+ "researchTime": 30,
+ "unlocks": {
+ "recipes": [
+ "stack-inserter"
+ ]
+ },
+ "order": "i",
+ "tier": 3,
+ "prerequisites": [
+ "advanced-electronics"
+ ]
+ },
+ {
+ "id": "nuclear-processing",
+ "name": "Nuclear Processing",
+ "description": "Unlocks uranium processing and nuclear fuel production",
+ "cost": [
+ {
+ "itemId": "automation-science-pack",
+ "count": 200
+ },
+ {
+ "itemId": "logistic-science-pack",
+ "count": 200
+ },
+ {
+ "itemId": "chemical-science-pack",
+ "count": 200
+ }
+ ],
+ "researchTime": 45,
+ "unlocks": {
+ "recipes": [
+ "uranium-processing",
+ "nuclear-fuel"
+ ]
+ },
+ "order": "j",
+ "tier": 3,
+ "prerequisites": [
+ "chemistry"
+ ]
+ },
+ {
+ "id": "nuclear-power",
+ "name": "Nuclear Power",
+ "description": "Unlocks nuclear reactors and centrifuges",
+ "cost": [
+ {
+ "itemId": "automation-science-pack",
+ "count": 300
+ },
+ {
+ "itemId": "logistic-science-pack",
+ "count": 300
+ },
+ {
+ "itemId": "chemical-science-pack",
+ "count": 300
+ }
+ ],
+ "researchTime": 60,
+ "unlocks": {
+ "recipes": [
+ "nuclear-reactor",
+ "centrifuge"
+ ]
+ },
+ "order": "k",
+ "tier": 3,
+ "prerequisites": [
+ "nuclear-processing",
+ "automation-3"
+ ]
+ },
+ {
+ "id": "rocket-fuel",
+ "name": "Rocket Fuel",
+ "description": "Unlocks solid fuel and rocket fuel production",
+ "cost": [
+ {
+ "itemId": "automation-science-pack",
+ "count": 200
+ },
+ {
+ "itemId": "logistic-science-pack",
+ "count": 200
+ },
+ {
+ "itemId": "chemical-science-pack",
+ "count": 200
+ }
+ ],
+ "researchTime": 30,
+ "unlocks": {
+ "recipes": [
+ "solid-fuel",
+ "rocket-fuel"
+ ]
+ },
+ "order": "l",
+ "tier": 3,
+ "prerequisites": [
+ "advanced-oil-processing"
+ ]
+ },
+ {
+ "id": "low-density-structure",
+ "name": "Low Density Structure",
+ "description": "Unlocks lightweight structural materials",
+ "cost": [
+ {
+ "itemId": "automation-science-pack",
+ "count": 200
+ },
+ {
+ "itemId": "logistic-science-pack",
+ "count": 200
+ },
+ {
+ "itemId": "chemical-science-pack",
+ "count": 200
+ }
+ ],
+ "researchTime": 30,
+ "unlocks": {
+ "recipes": [
+ "low-density-structure"
+ ]
+ },
+ "order": "m",
+ "tier": 3,
+ "prerequisites": [
+ "rocket-fuel"
+ ]
+ },
+ {
+ "id": "rocket-parts",
+ "name": "Rocket Parts",
+ "description": "Unlocks rocket part assembly",
+ "cost": [
+ {
+ "itemId": "automation-science-pack",
+ "count": 300
+ },
+ {
+ "itemId": "logistic-science-pack",
+ "count": 300
+ },
+ {
+ "itemId": "chemical-science-pack",
+ "count": 300
+ }
+ ],
+ "researchTime": 45,
+ "unlocks": {
+ "recipes": [
+ "rocket-parts"
+ ]
+ },
+ "order": "n",
+ "tier": 3,
+ "prerequisites": [
+ "low-density-structure"
+ ]
+ },
+ {
+ "id": "satellite",
+ "name": "Satellite",
+ "description": "Unlocks satellite construction",
+ "cost": [
+ {
+ "itemId": "automation-science-pack",
+ "count": 400
+ },
+ {
+ "itemId": "logistic-science-pack",
+ "count": 400
+ },
+ {
+ "itemId": "chemical-science-pack",
+ "count": 400
+ }
+ ],
+ "researchTime": 60,
+ "unlocks": {
+ "recipes": [
+ "satellite"
+ ]
+ },
+ "order": "o",
+ "tier": 3,
+ "prerequisites": [
+ "rocket-parts",
+ "solar-energy"
+ ]
+ },
+ {
+ "id": "rocket-silo",
+ "name": "Rocket Silo",
+ "description": "Unlocks rocket silo construction",
+ "cost": [
+ {
+ "itemId": "automation-science-pack",
+ "count": 500
+ },
+ {
+ "itemId": "logistic-science-pack",
+ "count": 500
+ },
+ {
+ "itemId": "chemical-science-pack",
+ "count": 500
+ }
+ ],
+ "researchTime": 90,
+ "unlocks": {
+ "recipes": [
+ "rocket-silo"
+ ]
+ },
+ "order": "p",
+ "tier": 3,
+ "prerequisites": [
+ "satellite"
+ ]
+ },
+ {
+ "id": "space-science-pack",
+ "name": "Space Science Pack",
+ "description": "Unlocks space science research",
+ "cost": [
+ {
+ "itemId": "automation-science-pack",
+ "count": 1000
+ },
+ {
+ "itemId": "logistic-science-pack",
+ "count": 1000
+ },
+ {
+ "itemId": "chemical-science-pack",
+ "count": 1000
+ }
+ ],
+ "researchTime": 120,
+ "unlocks": {
+ "recipes": [
+ "space-science-pack"
+ ]
+ },
+ "order": "q",
+ "tier": 4,
+ "prerequisites": [
+ "rocket-silo"
+ ]
+ }
+]
diff --git a/web/shared/data/water_pump_schema.json b/web/shared/data/water_pump_schema.json
new file mode 100644
index 00000000..0de3f563
--- /dev/null
+++ b/web/shared/data/water_pump_schema.json
@@ -0,0 +1,74 @@
+{
+ "$schema": "https://example.com/machineui.schema.json",
+ "version": "1.0.0",
+ "id": "machineui.water_pump",
+ "machineKind": "water_pump",
+ "title": "Water Pump",
+ "layout": {
+ "flowAxis": "leftToRight",
+ "safeArea": { "top": 16, "left": 16, "bottom": 16, "right": 16 },
+ "padding": { "x": 24, "y": 24 },
+ "grid": { "columns": 12, "rows": 6, "gutterX": 8, "gutterY": 12 }
+ },
+
+ "groups": [
+ {
+ "id": "output",
+ "role": "output",
+ "header": { "text": "Water Output", "styleRole": "output", "alignment": "leading" },
+ "anchor": { "gridX": 5, "gridY": 0, "spanX": 4, "spanY": 4, "alignX": "leading", "alignY": "top" },
+ "content": {
+ "slots": [
+ {
+ "id": "water.output",
+ "slotKind": "fluid",
+ "capacity": { "maxStacks": 1 },
+ "accepts": { "tags": [] },
+ "renders": { "sizeDp": 64, "style": "square", "overlay": "connection" }
+ }
+ ],
+ "stateText": { "mode": "auto", "empty": "(no connection)", "nonEmpty": "" }
+ }
+ }
+ ],
+
+ "process": {
+ "id": "pumping",
+ "label": { "text": "Pumping", "styleRole": "process", "alignment": "center" },
+ "anchor": { "gridX": 1, "gridY": 0, "spanX": 4, "spanY": 4, "alignX": "fill", "alignY": "fill" },
+ "progress": { "style": "bar", "bindTo": "recipeProgress", "showPercent": false },
+ "operators": { "showFlowGlyphs": true, "glyphStyleRole": "muted" }
+ },
+
+ "style": {
+ "palette": {
+ "fuel": "#FFAA00",
+ "input": "#00AAFF",
+ "output": "#00FF00",
+ "process": "#4A90E2",
+ "mutedText": "#CCCCCC"
+ },
+ "typography": {
+ "headerSize": 14,
+ "bodySize": 12,
+ "mutedSize": 10
+ },
+ "chrome": {
+ "panelOpacity": 1.0,
+ "cornerRadiusDp": 8,
+ "labelBackplateOpacity": 0.5
+ }
+ },
+
+ "invariants": [
+ "Every GroupHeader must be a descendant of exactly one Group.",
+ "No floating labels: GroupHeader must not be sibling of Slot; it must be ancestor (container header).",
+ "Process.progress must be visually adjacent to Process.label (max distance <= 1 grid row).",
+ "All input-role groups must be placed left of process anchor when flowAxis=leftToRight.",
+ "All output-role groups must be placed right of process anchor when flowAxis=leftToRight.",
+ "Slots in the same Group must share the same leading alignment and spacing.",
+ "If operators.showFlowGlyphs=true, glyphs must use glyphStyleRole=muted and never use semantic colors.",
+ "Any progress bar must have an explicit label (Process.label.text non-empty).",
+ "Empty state text must be inside the owning Group (content.stateText), never global."
+ ]
+}
diff --git a/web/shared/net/protocol.ts b/web/shared/net/protocol.ts
new file mode 100644
index 00000000..8e10324a
--- /dev/null
+++ b/web/shared/net/protocol.ts
@@ -0,0 +1,44 @@
+export interface GameRules {
+ seed: number;
+ friendlyFire: boolean;
+ maxPlayers: number;
+ tickRate: number;
+}
+
+export type NetworkUnitCommand =
+ | { type: 'move'; x: number; y: number }
+ | { type: 'attack'; targetEntityId: number }
+ | { type: 'hold' }
+ | { type: 'stop' };
+
+export type PlayerAction =
+ | { type: 'move'; position: { x: number; y: number } }
+ | { type: 'build'; buildingId: string; position: { x: number; y: number }; direction: number }
+ | { type: 'attack'; targetEntityId: number }
+ | { type: 'unitCommand'; unitId: number; command: NetworkUnitCommand }
+ | { type: 'mine'; x: number; y: number }
+ | { type: 'shoot'; x: number; y: number };
+
+export type EntityDelta =
+ | { type: 'upsert'; networkEntityId: number; entityData: Record }
+ | { type: 'remove'; networkEntityId: number };
+
+export type NetworkMessage =
+ | { type: 'handshake'; seed: number; rules: GameRules; playerId: number }
+ | { type: 'snapshot'; worldData: { entities: unknown[] }; tick: number }
+ | { type: 'delta'; deltas: EntityDelta[]; tick: number }
+ | { type: 'command'; action: PlayerAction; playerId: number }
+ | { type: 'ping'; timestamp: number }
+ | { type: 'pong'; timestamp: number }
+ | { type: 'ack'; messageId: number }
+ | { type: 'resync'; worldData: { entities: unknown[] }; tick: number }
+ | { type: 'lobby'; slots: unknown[]; config: unknown }
+ | { type: 'chat'; text: string; from: string };
+
+export function encodeMessage(msg: NetworkMessage): string {
+ return JSON.stringify(msg);
+}
+
+export function decodeMessage(data: string): NetworkMessage {
+ return JSON.parse(data) as NetworkMessage;
+}
diff --git a/web/shared/save/types.ts b/web/shared/save/types.ts
new file mode 100644
index 00000000..9f994b35
--- /dev/null
+++ b/web/shared/save/types.ts
@@ -0,0 +1,19 @@
+export interface GameSave {
+ version: number;
+ seed: number;
+ playTime: number;
+ playerData: {
+ position: { x: number; y: number };
+ inventory: { slots: Array<{ itemId: string | null; count: number }> };
+ health: number;
+ };
+ worldData: { entities: Array<{ id: number; generation: number; components: Record }> };
+ researchData: {
+ currentResearchId: string | null;
+ progress: Record;
+ completed: string[];
+ unlockedRecipes: string[];
+ };
+ chunks: unknown[];
+ timestamp: string;
+}
diff --git a/web/src/engine/audio/AudioManager.ts b/web/src/engine/audio/AudioManager.ts
new file mode 100644
index 00000000..b88aaceb
--- /dev/null
+++ b/web/src/engine/audio/AudioManager.ts
@@ -0,0 +1,40 @@
+/** Lightweight Web Audio hooks for place/mine/shoot feedback. */
+export class AudioManager {
+ private ctx: AudioContext | null = null;
+ enabled = true;
+
+ private ensure(): AudioContext | null {
+ if (!this.enabled || typeof AudioContext === 'undefined') return null;
+ if (!this.ctx) this.ctx = new AudioContext();
+ return this.ctx;
+ }
+
+ private beep(freq: number, dur = 0.05, type: OscillatorType = 'square', gain = 0.03): void {
+ const ctx = this.ensure();
+ if (!ctx) return;
+ const osc = ctx.createOscillator();
+ const g = ctx.createGain();
+ osc.type = type;
+ osc.frequency.value = freq;
+ g.gain.value = gain;
+ osc.connect(g);
+ g.connect(ctx.destination);
+ osc.start();
+ osc.stop(ctx.currentTime + dur);
+ }
+
+ place(): void {
+ this.beep(440, 0.04);
+ }
+ mine(): void {
+ this.beep(220, 0.03, 'triangle');
+ }
+ shoot(): void {
+ this.beep(880, 0.06, 'sawtooth', 0.02);
+ }
+ ui(): void {
+ this.beep(660, 0.03, 'sine');
+ }
+}
+
+export const audio = new AudioManager();
diff --git a/web/src/engine/core/GameLoop.ts b/web/src/engine/core/GameLoop.ts
new file mode 100644
index 00000000..7c89db09
--- /dev/null
+++ b/web/src/engine/core/GameLoop.ts
@@ -0,0 +1,689 @@
+import { World } from '../ecs/World';
+import { gameTime } from './Time';
+import { ChunkManager } from '../../game/world/ChunkManager';
+import {
+ BuildingRegistry,
+ ItemRegistry,
+ RecipeRegistry,
+ TechnologyRegistry,
+} from '../../game/data/Registries';
+import { PlayerManager } from '../../game/player/PlayerManager';
+import {
+ AutoPlaySystem,
+ BeltSystem,
+ CombatSystem,
+ CraftingSystem,
+ EnemyAISystem,
+ EntityCleanupSystem,
+ FluidNetworkSystem,
+ InserterSystem,
+ MiningSystem,
+ PollutionSystem,
+ PowerSystem,
+ ResearchSystem,
+ RocketSystem,
+ UnitSystem,
+ chopTree,
+} from '../../game/systems/Systems';
+import { AIPlayerSystem } from '../../game/ai/AIPlayerSystem';
+import {
+ FogOfWarSystem,
+ LobbySystem,
+ SpawnSystem,
+ VictorySystem,
+ type MatchConfig,
+ type MatchMode,
+} from '../../game/multiplayer/LobbyAndVictory';
+import {
+ RenderLayer,
+ emptyInventory,
+ inventoryAdd,
+ inventoryCount,
+ inventoryRemove,
+ createSpriteAnimation,
+ updateSpriteAnimation,
+ type Direction,
+ type HealthComponent,
+ type InventoryComponent,
+ type PlayerComponent,
+ type PositionComponent,
+ type SpriteComponent,
+ type UnitComponent,
+} from '../../game/components';
+import { playerFrames } from '../render/AssetCatalog';
+import type { GameSave } from '@shared/save/types';
+import type { BuildingDef } from '../../game/data/Registries';
+
+export class GameLoop {
+ world = new World();
+ itemRegistry = new ItemRegistry();
+ recipeRegistry = new RecipeRegistry();
+ buildingRegistry = new BuildingRegistry();
+ technologyRegistry = new TechnologyRegistry();
+ chunkManager: ChunkManager;
+ playerManager: PlayerManager;
+ localPlayerId = 0;
+ worldSeed: number;
+ playTime = 0;
+ gameSpeed = 1;
+ isRunning = true;
+ isHeadless = false;
+ isPlayerDead = false;
+ matchMode: MatchMode = 'singlePlayer';
+
+ beltSystem: BeltSystem;
+ researchSystem: ResearchSystem;
+ powerSystem: PowerSystem;
+ fluidSystem: FluidNetworkSystem;
+ combatSystem: CombatSystem;
+ rocketSystem: RocketSystem;
+ autoPlaySystem: AutoPlaySystem;
+ aiPlayerSystem: AIPlayerSystem;
+ lobby = new LobbySystem();
+ victory = new VictorySystem();
+ fog = new FogOfWarSystem();
+ spawnSystem = new SpawnSystem();
+
+ private systems: Array<{ update(dt: number): void }> = [];
+ private treeSpawned = new Set();
+ selectedEntities: number[] = [];
+ onVictory: ((reason: string) => void) | null = null;
+ onDeath: (() => void) | null = null;
+
+ constructor(seed?: number, headless = false) {
+ this.isHeadless = headless;
+ this.worldSeed = seed ?? Math.floor(Math.random() * 1e9);
+ this.chunkManager = new ChunkManager(this.worldSeed);
+ this.playerManager = new PlayerManager(this.world, this.itemRegistry);
+ this.localPlayerId = this.playerManager.createPlayer('Player', false, 0, 0);
+
+ this.beltSystem = new BeltSystem(this.world);
+ const mining = new MiningSystem(this.world, this.chunkManager, this.itemRegistry, this.buildingRegistry);
+ const inserter = new InserterSystem(this.world, this.beltSystem, this.itemRegistry);
+ const crafting = new CraftingSystem(this.world, this.recipeRegistry, this.itemRegistry);
+ this.powerSystem = new PowerSystem(this.world);
+ this.fluidSystem = new FluidNetworkSystem(this.world, this.buildingRegistry, this.itemRegistry);
+ this.researchSystem = new ResearchSystem(this.world, this.technologyRegistry);
+ const pollution = new PollutionSystem(this.world, this.chunkManager);
+ const enemy = new EnemyAISystem(this.world, this.chunkManager, () => {
+ const p = this.playerManager.getPlayer(this.localPlayerId);
+ if (!p) return null;
+ const pos = this.world.getByName('position', p.entity);
+ return pos ? { x: pos.tileX + pos.offsetX, y: pos.tileY + pos.offsetY } : null;
+ });
+ this.combatSystem = new CombatSystem(this.world);
+ const units = new UnitSystem(this.world);
+ this.rocketSystem = new RocketSystem(this.world);
+ const cleanup = new EntityCleanupSystem(this.world);
+ this.autoPlaySystem = new AutoPlaySystem(
+ (id, x, y, dir) => this.placeBuilding(id, x, y, dir, this.localPlayerId),
+ (s) => {
+ this.gameSpeed = s;
+ },
+ );
+ this.aiPlayerSystem = new AIPlayerSystem(this.world, this.playerManager, (pid, id, x, y, dir) =>
+ this.placeBuilding(id, x, y, dir, pid),
+ );
+
+ this.systems = [
+ mining,
+ this.beltSystem,
+ this.fluidSystem,
+ inserter,
+ crafting,
+ this.powerSystem,
+ this.researchSystem,
+ pollution,
+ enemy,
+ this.aiPlayerSystem,
+ this.combatSystem,
+ units,
+ this.rocketSystem,
+ cleanup,
+ this.autoPlaySystem,
+ ];
+
+ this.chunkManager.updateAround(0, 0);
+ this.spawnTreesAround(0, 0);
+ }
+
+ get localPlayer() {
+ return this.playerManager.getPlayer(this.localPlayerId);
+ }
+
+ setGameSpeed(speed: number): void {
+ this.gameSpeed = speed;
+ gameTime.timeScale = speed === 0 ? 0 : 1;
+ }
+
+ togglePause(): void {
+ if (this.gameSpeed === 0) this.setGameSpeed(1);
+ else this.setGameSpeed(0);
+ }
+
+ fixedUpdate(): void {
+ if (this.isPlayerDead) return;
+ const dt = gameTime.fixedDeltaTime * (this.gameSpeed || 1);
+ if (this.gameSpeed === 0) return;
+ this.playTime += dt;
+
+ const player = this.localPlayer;
+ if (player) {
+ const pc = this.world.getByName('player', player.entity)!;
+ const pos = this.world.getByName('position', player.entity)!;
+ const speed = 5;
+ pos.offsetX += pc.moveX * speed * dt;
+ pos.offsetY += pc.moveY * speed * dt;
+ while (pos.offsetX >= 1) {
+ pos.tileX++;
+ pos.offsetX -= 1;
+ }
+ while (pos.offsetX < 0) {
+ pos.tileX--;
+ pos.offsetX += 1;
+ }
+ while (pos.offsetY >= 1) {
+ pos.tileY++;
+ pos.offsetY -= 1;
+ }
+ while (pos.offsetY < 0) {
+ pos.tileY--;
+ pos.offsetY += 1;
+ }
+ this.world.updatePosition(player.entity, pos.tileX, pos.tileY, pos.offsetX, pos.offsetY);
+ this.updatePlayerAnimation(player.entity, pc, dt);
+ this.chunkManager.updateAround(pos.tileX, pos.tileY);
+ this.spawnTreesAround(pos.tileX, pos.tileY);
+
+ const hp = this.world.getByName('health', player.entity);
+ if (hp && hp.current <= 0) {
+ this.isPlayerDead = true;
+ this.onDeath?.();
+ }
+ }
+
+ for (const s of this.systems) s.update(dt);
+
+ const positions = this.playerManager.all.map((p) => {
+ const pos = this.world.getByName('position', p.entity)!;
+ return { x: pos.tileX + pos.offsetX, y: pos.tileY + pos.offsetY };
+ });
+ this.fog.update(positions);
+
+ this.victory.check(
+ this.matchMode,
+ this.playerManager.all.map((p) => {
+ const hp = this.world.getByName('health', p.entity);
+ return { playerId: p.playerId, alive: (hp?.current ?? 0) > 0, team: p.playerId % 2 };
+ }),
+ this.rocketSystem.victory,
+ this.localPlayerId,
+ );
+ if (this.victory.winnerId !== null && this.victory.reason) {
+ this.onVictory?.(this.victory.reason);
+ this.rocketSystem.victory = false;
+ }
+ }
+
+ frame(moveX: number, moveY: number): void {
+ if (this.isHeadless) gameTime.updateDeterministic();
+ else gameTime.update();
+
+ const player = this.localPlayer;
+ if (player) {
+ const pc = this.world.getByName('player', player.entity);
+ if (pc && !pc.isAI) {
+ pc.moveX = moveX;
+ pc.moveY = moveY;
+ }
+ }
+
+ let steps = 0;
+ while (gameTime.consumeFixedUpdate() && steps < 5) {
+ this.fixedUpdate();
+ steps++;
+ }
+ }
+
+ private updatePlayerAnimation(entity: import('../ecs/Entity').Entity, pc: PlayerComponent, dt: number): void {
+ const sprite = this.world.getByName('sprite', entity);
+ if (!sprite) return;
+ if (!sprite.animation) {
+ sprite.animation = createSpriteAnimation(playerFrames('down'), 0.08, true);
+ }
+
+ const moving = Math.abs(pc.moveX) > 0.01 || Math.abs(pc.moveY) > 0.01;
+ let facing = pc.facing ?? 'down';
+
+ if (moving) {
+ // Prefer dominant axis (matches iOS priority: up/down/right/left checks)
+ if (Math.abs(pc.moveY) >= Math.abs(pc.moveX)) {
+ // moveY < 0 is screen-up (see InputManager.updateMovement)
+ facing = pc.moveY < 0 ? 'up' : 'down';
+ } else {
+ facing = pc.moveX < 0 ? 'left' : 'right';
+ }
+
+ const wantPrefix = `player_${facing}_0`;
+ const currentFirst = sprite.animation.frames[0] ?? '';
+ if (currentFirst !== wantPrefix) {
+ const next = createSpriteAnimation(playerFrames(facing), 0.08, true);
+ next.currentFrame = sprite.animation.currentFrame;
+ next.elapsedTime = sprite.animation.elapsedTime;
+ next.isPlaying = true;
+ sprite.animation = next;
+ sprite.textureId = next.frames[next.currentFrame] ?? wantPrefix;
+ }
+
+ if (!sprite.animation.isPlaying) sprite.animation.isPlaying = true;
+ const frame = updateSpriteAnimation(sprite.animation, dt);
+ if (frame) sprite.textureId = frame;
+ } else {
+ if (sprite.animation.isPlaying) {
+ sprite.animation.isPlaying = false;
+ sprite.animation.currentFrame = 0;
+ sprite.animation.elapsedTime = 0;
+ }
+ sprite.textureId = sprite.animation.frames[0] ?? 'player_down_0';
+ }
+
+ pc.facing = facing;
+ }
+
+ private spawnTreesAround(tx: number, ty: number): void {
+ for (let y = ty - 24; y <= ty + 24; y++) {
+ for (let x = tx - 24; x <= tx + 24; x++) {
+ const key = `${x},${y}`;
+ if (this.treeSpawned.has(key)) continue;
+ this.treeSpawned.add(key);
+ const tile = this.chunkManager.getTile(x, y);
+ if (!tile) continue;
+ if (!this.chunkManager.generator.shouldSpawnTree(x, y, tile.type)) continue;
+ if (this.world.entityAt(x, y)) continue;
+ this.world
+ .spawnWith()
+ .with('position', { tileX: x, tileY: y, offsetX: 0.5, offsetY: 0.5 })
+ .with('sprite', { textureId: 'tree', width: 1, height: 1, layer: RenderLayer.Entity })
+ .with('tree', { woodRemaining: 4 })
+ .with('health', { current: 20, max: 20 })
+ .build();
+ }
+ }
+ }
+
+ tryMineOrInteract(wx: number, wy: number): void {
+ const tx = Math.floor(wx);
+ const ty = Math.floor(wy);
+ const player = this.localPlayer;
+ if (!player) return;
+ const pos = this.world.getByName('position', player.entity)!;
+ if (Math.hypot(pos.tileX - tx, pos.tileY - ty) > 3) return;
+ const inv = this.world.getByName('inventory', player.entity)!;
+
+ const ent = this.world.entityAt(tx, ty);
+ if (ent && this.world.hasByName('tree', ent)) {
+ chopTree(this.world, ent, inv, this.itemRegistry);
+ return;
+ }
+ const tile = this.chunkManager.getTile(tx, ty);
+ if (tile?.resource && tile.resource.type !== 'crude-oil') {
+ inventoryAdd(inv, tile.resource.type, 1, this.itemRegistry.stackSize(tile.resource.type));
+ tile.resource.amount -= 1;
+ if (tile.resource.amount <= 0) tile.resource = null;
+ }
+ }
+
+ placeBuilding(buildingId: string, tileX: number, tileY: number, direction: Direction, playerId?: number): boolean {
+ const pid = playerId ?? this.localPlayerId;
+ const def = this.buildingRegistry.get(buildingId);
+ if (!def) return false;
+ const player = this.playerManager.getPlayer(pid);
+ if (!player) return false;
+ const inv = this.world.getByName('inventory', player.entity);
+ if (!inv) return false;
+
+ const id = def.id;
+ const type = def.type;
+ const w = def.width ?? 1;
+ const h = def.height ?? 1;
+
+ // Clear trees in the footprint; block on other entities (player, buildings, enemies).
+ for (let y = 0; y < h; y++) {
+ for (let x = 0; x < w; x++) {
+ const tx = tileX + x;
+ const ty = tileY + y;
+ const existing = this.world.entityAt(tx, ty);
+ if (!existing) continue;
+ if (this.world.hasByName('tree', existing)) {
+ // Fully clear the tile (chopTree only removes one wood per call).
+ while (this.world.isAlive(existing) && this.world.hasByName('tree', existing)) {
+ chopTree(this.world, existing, inv, this.itemRegistry);
+ }
+ continue;
+ }
+ return false;
+ }
+ }
+
+ // Cost: prefer item with same id
+ if (inventoryCount(inv, buildingId) > 0) {
+ inventoryRemove(inv, buildingId, 1);
+ } else if (def.cost) {
+ if (!def.cost.every((c) => inventoryCount(inv, c.itemId) >= c.count)) return false;
+ for (const c of def.cost) inventoryRemove(inv, c.itemId, c.count);
+ } else return false;
+
+ const directional =
+ type === 'belt' ||
+ type === 'inserter' ||
+ type === 'pipe' ||
+ id.includes('belt') ||
+ id.includes('inserter') ||
+ id.includes('pipe') ||
+ id === 'splitter' ||
+ id === 'merger' ||
+ id === 'underground-belt' ||
+ id === 'underground-pipe';
+
+ const entity = this.world
+ .spawnWith()
+ .with('position', { tileX, tileY, offsetX: 0.5, offsetY: 0.5 })
+ .with('sprite', {
+ textureId: buildingId,
+ width: w,
+ height: h,
+ layer: type === 'belt' || type === 'pipe' ? RenderLayer.GroundDecoration : RenderLayer.Building,
+ rotation: directional ? direction : 0,
+ })
+ .with('health', { current: def.maxHealth ?? 100, max: def.maxHealth ?? 100 })
+ .with('ownership', { playerId: pid })
+ .build();
+
+ this.attachBuildingComponents(entity, def, direction);
+ return true;
+ }
+
+ private attachBuildingComponents(entity: import('../ecs/Entity').Entity, def: BuildingDef, direction: Direction): void {
+ const id = def.id;
+ const type = def.type;
+ const invSize = Math.max(4, (def.inputSlots ?? 0) + (def.outputSlots ?? 0) + (def.fuelSlots ?? 0) + 8);
+
+ if (type === 'miner' || id.includes('drill')) {
+ this.world.addByName('miner', { buildingId: id, progress: 0, miningSpeed: def.miningSpeed ?? 0.5 }, entity);
+ this.world.addByName('inventory', emptyInventory(invSize), entity);
+ if (!id.includes('burner')) {
+ this.world.addByName('powerConsumer', { buildingId: id, powerDraw: def.powerConsumption ?? 90, powered: false }, entity);
+ }
+ }
+ if (type === 'belt' || id.includes('belt') || id === 'splitter' || id === 'merger') {
+ this.world.addByName(
+ 'belt',
+ { buildingId: id, direction, speed: def.beltSpeed ?? 1.875, items: [] },
+ entity,
+ );
+ }
+ if (type === 'inserter' || id.includes('inserter')) {
+ this.world.addByName(
+ 'inserter',
+ {
+ buildingId: id,
+ direction,
+ reach: (def.reach as number) ?? (id.includes('long') ? 2 : 1),
+ speed: (def.insertionSpeed as number) ?? 1,
+ stackSize: (def.stackSize as number) ?? 1,
+ progress: 0,
+ heldItem: null,
+ heldCount: 0,
+ },
+ entity,
+ );
+ this.world.addByName('powerConsumer', { buildingId: id, powerDraw: 13, powered: false }, entity);
+ }
+ if (type === 'furnace' || id.includes('furnace')) {
+ this.world.addByName('furnace', { buildingId: id, progress: 0, recipeId: null, fuelRemaining: 0 }, entity);
+ this.world.addByName('inventory', emptyInventory(invSize), entity);
+ if (id.includes('electric')) {
+ this.world.addByName('powerConsumer', { buildingId: id, powerDraw: def.powerConsumption ?? 180, powered: false }, entity);
+ }
+ }
+ if (type === 'assembler' || id.includes('assembling')) {
+ this.world.addByName(
+ 'assembler',
+ { buildingId: id, progress: 0, recipeId: null, craftingSpeed: def.craftingSpeed ?? 0.5 },
+ entity,
+ );
+ this.world.addByName('inventory', emptyInventory(invSize), entity);
+ this.world.addByName('powerConsumer', { buildingId: id, powerDraw: def.powerConsumption ?? 75, powered: false }, entity);
+ }
+ if (type === 'chest' || id.includes('chest')) {
+ this.world.addByName('chest', { buildingId: id }, entity);
+ this.world.addByName('inventory', emptyInventory(id.includes('steel') ? 48 : id.includes('iron') ? 32 : 16), entity);
+ }
+ if (type === 'powerPole' || id.includes('pole')) {
+ this.world.addByName(
+ 'powerPole',
+ {
+ buildingId: id,
+ supplyRadius: (def.supplyArea as number) ?? 5,
+ wireReach: (def.wireReach as number) ?? 9,
+ },
+ entity,
+ );
+ }
+ if (id === 'boiler') {
+ this.world.addByName('boiler', { buildingId: id, fuelRemaining: 0, temperature: 15 }, entity);
+ this.world.addByName('inventory', emptyInventory(4), entity);
+ }
+ if (id === 'steam-engine') {
+ this.world.addByName('steamEngine', { buildingId: id, powerOutput: 0 }, entity);
+ this.world.addByName('generator', { buildingId: id, powerOutput: 900, active: false }, entity);
+ }
+ if (id === 'solar-panel') {
+ this.world.addByName('solar', { buildingId: id, powerOutput: def.powerProduction ?? 60 }, entity);
+ }
+ if (id === 'accumulator') {
+ this.world.addByName('accumulator', { buildingId: id, capacity: 5000, stored: 0 }, entity);
+ }
+ if (id === 'lab' || type === 'lab') {
+ this.world.addByName('lab', { buildingId: id, progress: 0 }, entity);
+ this.world.addByName('inventory', emptyInventory(12), entity);
+ this.world.addByName('powerConsumer', { buildingId: id, powerDraw: def.powerConsumption ?? 60, powered: false }, entity);
+ }
+ if (type === 'pipe' || id === 'pipe' || id === 'underground-pipe' || id === 'fluid-tank') {
+ this.world.addByName(
+ 'pipe',
+ { buildingId: id, fluidId: null, amount: 0, capacity: (def.fluidCapacity as number) ?? 100 },
+ entity,
+ );
+ }
+ if (id === 'water-pump') {
+ this.world.addByName('fluidProducer', { buildingId: id, fluidId: 'water', rate: 20 }, entity);
+ this.world.addByName('pipe', { buildingId: id, fluidId: 'water', amount: 0, capacity: 100 }, entity);
+ this.world.addByName('powerConsumer', { buildingId: id, powerDraw: 30, powered: true }, entity);
+ }
+ if (id === 'pumpjack') {
+ this.world.addByName('pumpjack', { buildingId: id }, entity);
+ this.world.addByName('fluidProducer', { buildingId: id, fluidId: 'crude-oil', rate: 2 }, entity);
+ this.world.addByName('powerConsumer', { buildingId: id, powerDraw: 90, powered: false }, entity);
+ }
+ if (id === 'oil-refinery' || id === 'chemical-plant') {
+ this.world.addByName('assembler', { buildingId: id, progress: 0, recipeId: null, craftingSpeed: 1 }, entity);
+ this.world.addByName('inventory', emptyInventory(16), entity);
+ this.world.addByName('pipe', { buildingId: id, fluidId: null, amount: 0, capacity: 200 }, entity);
+ this.world.addByName('powerConsumer', { buildingId: id, powerDraw: 210, powered: false }, entity);
+ }
+ if (id === 'gun-turret' || id === 'laser-turret') {
+ this.world.addByName(
+ 'turret',
+ {
+ buildingId: id,
+ range: (def.range as number) ?? 18,
+ damage: (def.damage as number) ?? 20,
+ cooldown: 0.4,
+ fireTimer: 0,
+ usesAmmo: id === 'gun-turret',
+ },
+ entity,
+ );
+ this.world.addByName('inventory', emptyInventory(8), entity);
+ if (id === 'laser-turret') {
+ this.world.addByName('powerConsumer', { buildingId: id, powerDraw: 240, powered: false }, entity);
+ }
+ }
+ if (id === 'stone-wall' || type === 'wall') {
+ this.world.addByName('wall', { buildingId: id }, entity);
+ }
+ if (id === 'rocket-silo') {
+ this.world.addByName(
+ 'rocketSilo',
+ { buildingId: id, parts: 0, partsRequired: 100, hasSatellite: false, launching: false, launchProgress: 0 },
+ entity,
+ );
+ this.world.addByName('inventory', emptyInventory(20), entity);
+ this.world.addByName('powerConsumer', { buildingId: id, powerDraw: 1000, powered: false }, entity);
+ }
+ if (type === 'unitProduction' || id.includes('barracks') || id.includes('academy') || id.includes('summoning')) {
+ this.world.addByName('unitProduction', { buildingId: id, progress: 0, queue: [] }, entity);
+ this.world.addByName('powerConsumer', { buildingId: id, powerDraw: 100, powered: false }, entity);
+ }
+ }
+
+ removeBuildingAt(tx: number, ty: number): boolean {
+ const e = this.world.entityAt(tx, ty);
+ if (!e) return false;
+ if (this.world.hasByName('player', e) || this.world.hasByName('enemy', e) || this.world.hasByName('tree', e))
+ return false;
+ // refund item
+ const sprite = this.world.getByName<{ textureId: string }>('sprite', e);
+ const player = this.localPlayer;
+ if (player && sprite) {
+ const inv = this.world.getByName('inventory', player.entity)!;
+ inventoryAdd(inv, sprite.textureId, 1, this.itemRegistry.stackSize(sprite.textureId));
+ }
+ this.world.despawn(e);
+ return true;
+ }
+
+ rotateSelected(): void {
+ for (const id of this.selectedEntities) {
+ const entity = this.world.entities.find((e) => e.id === id);
+ if (!entity) continue;
+ const belt = this.world.getByName<{ direction: Direction }>('belt', entity);
+ const ins = this.world.getByName<{ direction: Direction }>('inserter', entity);
+ const sprite = this.world.getByName<{ rotation?: number }>('sprite', entity);
+ if (belt) belt.direction = ((belt.direction + 1) % 4) as Direction;
+ if (ins) ins.direction = ((ins.direction + 1) % 4) as Direction;
+ if (sprite) sprite.rotation = ((sprite.rotation ?? 0) + 1) % 4;
+ }
+ }
+
+ selectAt(wx: number, wy: number): void {
+ const e = this.world.entityAt(Math.floor(wx), Math.floor(wy));
+ this.selectedEntities = e ? [e.id] : [];
+ }
+
+ queueCraft(recipeId: string): void {
+ const player = this.localPlayer;
+ if (!player) return;
+ if (!this.researchSystem.unlockedRecipes.has(recipeId) && !this.recipeRegistry.get(recipeId)) return;
+ const recipe = this.recipeRegistry.get(recipeId);
+ if (!recipe) return;
+ const pc = this.world.getByName('player', player.entity)!;
+ pc.craftQueue.push({ recipeId, remaining: recipe.craftTime });
+ }
+
+ setAssemblerRecipe(entityId: number, recipeId: string): void {
+ const entity = this.world.entities.find((e) => e.id === entityId);
+ if (!entity) return;
+ const asm = this.world.getByName<{ recipeId: string | null }>('assembler', entity);
+ if (asm) asm.recipeId = recipeId;
+ }
+
+ commandSelectedUnits(cmd: 'move' | 'attack' | 'stop', x?: number, y?: number, targetId?: number): void {
+ for (const id of this.selectedEntities) {
+ const entity = this.world.entities.find((e) => e.id === id);
+ if (!entity) continue;
+ const unit = this.world.getByName('unit', entity);
+ if (!unit) continue;
+ if (cmd === 'stop') unit.command = 'hold';
+ if (cmd === 'move' && x !== undefined && y !== undefined) {
+ unit.command = 'move';
+ unit.targetX = x;
+ unit.targetY = y;
+ }
+ if (cmd === 'attack') {
+ unit.command = 'attack';
+ unit.targetX = x;
+ unit.targetY = y;
+ if (targetId !== undefined) {
+ const t = this.world.entities.find((e) => e.id === targetId);
+ if (t) unit.targetEntity = t;
+ }
+ }
+ }
+ }
+
+ startPvAI(config?: Partial): void {
+ this.matchMode = 'pvai';
+ this.lobby.config = { ...this.lobby.config, ...config, mode: 'pvai' };
+ this.lobby.reset(4);
+ this.lobby.addAI();
+ this.lobby.addAI();
+ const spawns = this.spawnSystem.positions(3);
+ // AI players
+ let i = 1;
+ for (const slot of this.lobby.slots) {
+ if (!slot.isAI) continue;
+ const s = spawns[i++] ?? { x: 20, y: 20 };
+ this.playerManager.createPlayer(slot.name, true, s.x, s.y);
+ }
+ }
+
+ serializeSave(): GameSave {
+ const player = this.localPlayer!;
+ const pos = this.world.getByName('position', player.entity)!;
+ const inv = this.world.getByName('inventory', player.entity)!;
+ const hp = this.world.getByName('health', player.entity)!;
+ return {
+ version: 1,
+ seed: this.worldSeed,
+ playTime: this.playTime,
+ playerData: {
+ position: { x: pos.tileX + pos.offsetX, y: pos.tileY + pos.offsetY },
+ inventory: inv,
+ health: hp.current,
+ },
+ worldData: { entities: this.world.serializeEntities() },
+ researchData: this.researchSystem.serialize(),
+ chunks: this.chunkManager.serialize(),
+ timestamp: new Date().toISOString(),
+ };
+ }
+
+ loadSave(save: GameSave): void {
+ this.world.clear();
+ this.worldSeed = save.seed;
+ this.chunkManager = new ChunkManager(save.seed);
+ if (save.chunks?.length) this.chunkManager.loadChunks(save.chunks as never);
+ this.playerManager = new PlayerManager(this.world, this.itemRegistry);
+ this.localPlayerId = this.playerManager.createPlayer('Player', false, Math.floor(save.playerData.position.x), Math.floor(save.playerData.position.y));
+ const player = this.localPlayer!;
+ const inv = this.world.getByName('inventory', player.entity)!;
+ inv.slots = save.playerData.inventory.slots;
+ const hp = this.world.getByName('health', player.entity)!;
+ hp.current = save.playerData.health;
+ this.researchSystem.load(save.researchData);
+ this.playTime = save.playTime;
+ this.isPlayerDead = false;
+ this.victory.reset();
+ // Rehydrate non-player entities from save
+ for (const ed of save.worldData.entities) {
+ if (ed.components.player) continue;
+ const b = this.world.spawnWith();
+ for (const [name, comp] of Object.entries(ed.components)) {
+ b.with(name, comp);
+ }
+ b.build();
+ }
+ }
+}
diff --git a/web/src/engine/core/Time.ts b/web/src/engine/core/Time.ts
new file mode 100644
index 00000000..3ed6d380
--- /dev/null
+++ b/web/src/engine/core/Time.ts
@@ -0,0 +1,66 @@
+export class Time {
+ totalTime = 0;
+ deltaTime = 0;
+ unscaledDeltaTime = 0;
+ frameCount = 0;
+ serverTick = 0;
+ fixedDeltaTime = 1 / 60;
+ deterministicDeltaTime = 1 / 60;
+ isDeterministic = false;
+ timeScale = 1;
+ private accumulator = 0;
+ private lastFrameTime = performance.now() / 1000;
+ private readonly maxDeltaTime = 1 / 30;
+ private alpha = 0;
+
+ get isPaused(): boolean {
+ return this.timeScale === 0;
+ }
+ set isPaused(v: boolean) {
+ this.timeScale = v ? 0 : 1;
+ }
+
+ get fixedUpdateAlpha(): number {
+ return this.alpha;
+ }
+
+ update(): void {
+ const now = performance.now() / 1000;
+ this.unscaledDeltaTime = Math.min(now - this.lastFrameTime, this.maxDeltaTime);
+ this.lastFrameTime = now;
+ this.deltaTime = this.unscaledDeltaTime * this.timeScale;
+ this.totalTime += this.deltaTime;
+ this.frameCount++;
+ this.accumulator += this.deltaTime;
+ }
+
+ updateDeterministic(): void {
+ this.unscaledDeltaTime = Math.min(this.deterministicDeltaTime, this.maxDeltaTime);
+ this.deltaTime = this.unscaledDeltaTime * this.timeScale;
+ this.totalTime += this.deltaTime;
+ this.frameCount++;
+ this.serverTick++;
+ this.accumulator += this.deltaTime;
+ }
+
+ consumeFixedUpdate(): boolean {
+ if (this.accumulator >= this.fixedDeltaTime) {
+ this.accumulator -= this.fixedDeltaTime;
+ this.alpha = this.accumulator / this.fixedDeltaTime;
+ return true;
+ }
+ this.alpha = this.accumulator / this.fixedDeltaTime;
+ return false;
+ }
+
+ reset(): void {
+ this.totalTime = 0;
+ this.deltaTime = 0;
+ this.accumulator = 0;
+ this.frameCount = 0;
+ this.serverTick = 0;
+ this.lastFrameTime = performance.now() / 1000;
+ }
+}
+
+export const gameTime = new Time();
diff --git a/web/src/engine/ecs/ComponentStore.ts b/web/src/engine/ecs/ComponentStore.ts
new file mode 100644
index 00000000..a69456b7
--- /dev/null
+++ b/web/src/engine/ecs/ComponentStore.ts
@@ -0,0 +1,67 @@
+import type { Entity } from './Entity';
+
+/** Sparse-set component store */
+export class ComponentStore {
+ private dense: T[] = [];
+ private entities: Entity[] = [];
+ private sparse = new Map();
+
+ set(component: T, entity: Entity): void {
+ const idx = this.sparse.get(entity.id);
+ if (idx !== undefined && this.entities[idx]?.generation === entity.generation) {
+ this.dense[idx] = component;
+ return;
+ }
+ const newIdx = this.dense.length;
+ this.dense.push(component);
+ this.entities.push(entity);
+ this.sparse.set(entity.id, newIdx);
+ }
+
+ get(entity: Entity): T | undefined {
+ const idx = this.sparse.get(entity.id);
+ if (idx === undefined) return undefined;
+ const e = this.entities[idx];
+ if (!e || e.generation !== entity.generation) return undefined;
+ return this.dense[idx];
+ }
+
+ has(entity: Entity): boolean {
+ return this.get(entity) !== undefined;
+ }
+
+ remove(entity: Entity): void {
+ const idx = this.sparse.get(entity.id);
+ if (idx === undefined) return;
+ const e = this.entities[idx];
+ if (!e || e.generation !== entity.generation) return;
+
+ const last = this.dense.length - 1;
+ if (idx !== last) {
+ this.dense[idx] = this.dense[last]!;
+ this.entities[idx] = this.entities[last]!;
+ this.sparse.set(this.entities[idx]!.id, idx);
+ }
+ this.dense.pop();
+ this.entities.pop();
+ this.sparse.delete(entity.id);
+ }
+
+ forEach(fn: (component: T, entity: Entity) => void): void {
+ for (let i = 0; i < this.dense.length; i++) {
+ fn(this.dense[i]!, this.entities[i]!);
+ }
+ }
+
+ entries(): Array<{ entity: Entity; component: T }> {
+ const out: Array<{ entity: Entity; component: T }> = [];
+ for (let i = 0; i < this.dense.length; i++) {
+ out.push({ entity: this.entities[i]!, component: this.dense[i]! });
+ }
+ return out;
+ }
+
+ get size(): number {
+ return this.dense.length;
+ }
+}
diff --git a/web/src/engine/ecs/Entity.ts b/web/src/engine/ecs/Entity.ts
new file mode 100644
index 00000000..804d0b90
--- /dev/null
+++ b/web/src/engine/ecs/Entity.ts
@@ -0,0 +1,51 @@
+export type EntityId = number;
+
+export interface Entity {
+ id: EntityId;
+ generation: number;
+}
+
+export function entityKey(e: Entity): string {
+ return `${e.id}:${e.generation}`;
+}
+
+export function entitiesEqual(a: Entity, b: Entity): boolean {
+ return a.id === b.id && a.generation === b.generation;
+}
+
+export class EntityManager {
+ private generations: number[] = [];
+ private freeList: number[] = [];
+ private alive = new Set();
+
+ create(): Entity {
+ let id: number;
+ if (this.freeList.length > 0) {
+ id = this.freeList.pop()!;
+ } else {
+ id = this.generations.length;
+ this.generations.push(0);
+ }
+ this.alive.add(id);
+ return { id, generation: this.generations[id]! };
+ }
+
+ destroy(entity: Entity): void {
+ if (!this.isAlive(entity)) return;
+ this.alive.delete(entity.id);
+ this.generations[entity.id] = (this.generations[entity.id] ?? 0) + 1;
+ this.freeList.push(entity.id);
+ }
+
+ isAlive(entity: Entity): boolean {
+ return this.alive.has(entity.id) && this.generations[entity.id] === entity.generation;
+ }
+
+ get entities(): Entity[] {
+ return [...this.alive].map((id) => ({ id, generation: this.generations[id]! }));
+ }
+
+ get count(): number {
+ return this.alive.size;
+ }
+}
diff --git a/web/src/engine/ecs/World.ts b/web/src/engine/ecs/World.ts
new file mode 100644
index 00000000..c8295c75
--- /dev/null
+++ b/web/src/engine/ecs/World.ts
@@ -0,0 +1,143 @@
+import { EntityManager, type Entity } from './Entity';
+import { ComponentStore } from './ComponentStore';
+import type { PositionComponent } from '../../game/components';
+
+export class World {
+ private entityManager = new EntityManager();
+ private stores = new Map>();
+ private spatialIndex = new Map();
+ private pendingDespawns: Entity[] = [];
+
+ spawn(): Entity {
+ return this.entityManager.create();
+ }
+
+ spawnWith(): EntityBuilder {
+ return new EntityBuilder(this.spawn(), this);
+ }
+
+ despawn(entity: Entity): void {
+ if (!this.entityManager.isAlive(entity)) return;
+ const pos = this.getByName('position', entity);
+ if (pos) this.spatialIndex.delete(tileKey(pos.tileX, pos.tileY));
+ for (const store of this.stores.values()) store.remove(entity);
+ this.entityManager.destroy(entity);
+ }
+
+ despawnDeferred(entity: Entity): void {
+ this.pendingDespawns.push(entity);
+ }
+
+ processPending(): void {
+ for (const e of this.pendingDespawns) this.despawn(e);
+ this.pendingDespawns.length = 0;
+ }
+
+ isAlive(entity: Entity): boolean {
+ return this.entityManager.isAlive(entity);
+ }
+
+ get entities(): Entity[] {
+ return this.entityManager.entities;
+ }
+
+ get entityCount(): number {
+ return this.entityManager.count;
+ }
+
+ private store(name: string): ComponentStore {
+ let s = this.stores.get(name) as ComponentStore | undefined;
+ if (!s) {
+ s = new ComponentStore();
+ this.stores.set(name, s as ComponentStore);
+ }
+ return s;
+ }
+
+ addByName(name: string, component: T, entity: Entity): void {
+ if (name === 'position') {
+ const old = this.getByName('position', entity);
+ if (old) this.spatialIndex.delete(tileKey(old.tileX, old.tileY));
+ }
+ this.store(name).set(component, entity);
+ if (name === 'position') {
+ const p = component as PositionComponent;
+ this.spatialIndex.set(tileKey(p.tileX, p.tileY), entity);
+ }
+ }
+
+ getByName(name: string, entity: Entity): T | undefined {
+ return this.store(name).get(entity);
+ }
+
+ hasByName(name: string, entity: Entity): boolean {
+ return this.store(name).has(entity);
+ }
+
+ removeByName(name: string, entity: Entity): void {
+ if (name === 'position') {
+ const old = this.getByName('position', entity);
+ if (old) this.spatialIndex.delete(tileKey(old.tileX, old.tileY));
+ }
+ this.store(name).remove(entity);
+ }
+
+ forEach(name: string, fn: (component: T, entity: Entity) => void): void {
+ this.store(name).forEach(fn);
+ }
+
+ entries(name: string): Array<{ entity: Entity; component: T }> {
+ return this.store(name).entries();
+ }
+
+ entityAt(tileX: number, tileY: number): Entity | undefined {
+ return this.spatialIndex.get(tileKey(tileX, tileY));
+ }
+
+ updatePosition(entity: Entity, tileX: number, tileY: number, offsetX = 0.5, offsetY = 0.5): void {
+ const old = this.getByName('position', entity);
+ if (old) this.spatialIndex.delete(tileKey(old.tileX, old.tileY));
+ const next: PositionComponent = { tileX, tileY, offsetX, offsetY };
+ this.store('position').set(next, entity);
+ this.spatialIndex.set(tileKey(tileX, tileY), entity);
+ }
+
+ clear(): void {
+ for (const e of [...this.entityManager.entities]) this.despawn(e);
+ this.spatialIndex.clear();
+ this.stores.clear();
+ }
+
+ serializeEntities(): Array<{ id: number; generation: number; components: Record }> {
+ const out: Array<{ id: number; generation: number; components: Record }> = [];
+ for (const entity of this.entityManager.entities) {
+ const components: Record = {};
+ for (const [name, store] of this.stores) {
+ const c = store.get(entity);
+ if (c !== undefined) components[name] = c;
+ }
+ out.push({ id: entity.id, generation: entity.generation, components });
+ }
+ return out;
+ }
+}
+
+export class EntityBuilder {
+ constructor(
+ private entity: Entity,
+ private world: World,
+ ) {}
+
+ with(name: string, component: unknown): this {
+ this.world.addByName(name, component, this.entity);
+ return this;
+ }
+
+ build(): Entity {
+ return this.entity;
+ }
+}
+
+export function tileKey(x: number, y: number): string {
+ return `${x},${y}`;
+}
diff --git a/web/src/engine/input/InputManager.ts b/web/src/engine/input/InputManager.ts
new file mode 100644
index 00000000..21178cdc
--- /dev/null
+++ b/web/src/engine/input/InputManager.ts
@@ -0,0 +1,198 @@
+import type { Direction } from '../../game/components';
+
+export type BuildMode = 'none' | 'placing' | 'removing' | 'selecting' | 'moving' | 'connectingInserter';
+
+/** Physical key codes (layout-independent). */
+const MOVE_CODES = new Set([
+ 'KeyW',
+ 'KeyA',
+ 'KeyS',
+ 'KeyD',
+ 'ArrowUp',
+ 'ArrowDown',
+ 'ArrowLeft',
+ 'ArrowRight',
+]);
+
+export class InputManager {
+ keys = new Set();
+ moveX = 0;
+ moveY = 0;
+ mouseX = 0;
+ mouseY = 0;
+ worldX = 0;
+ worldY = 0;
+ leftDown = false;
+ rightDown = false;
+ middleDown = false;
+ justClicked = false;
+ justRightClicked = false;
+ justDoubleClicked = false;
+ wheelDelta = 0;
+ buildMode: BuildMode = 'none';
+ placeBuildingId: string | null = null;
+ placeDirection: Direction = 0;
+ cameraPanActive = false;
+ boxSelecting = false;
+ boxStartX = 0;
+ boxStartY = 0;
+ pausePressed = false;
+ rotatePressed = false;
+ deselectPressed = false;
+ toggleInventory = false;
+ toggleCraft = false;
+ toggleBuild = false;
+ toggleResearch = false;
+ toggleView3D = false;
+ attackPressed = false;
+ stopUnitsPressed = false;
+ private lastClickTime = 0;
+ private canvas: HTMLElement | null = null;
+
+ private trackKey(e: KeyboardEvent, down: boolean): void {
+ const code = e.code;
+ const key = e.key.length === 1 ? e.key.toLowerCase() : e.key.toLowerCase();
+ if (down) {
+ this.keys.add(code);
+ this.keys.add(key);
+ } else {
+ this.keys.delete(code);
+ this.keys.delete(key);
+ }
+ }
+
+ private pressed(...ids: string[]): boolean {
+ return ids.some((id) => this.keys.has(id));
+ }
+
+ attach(canvas: HTMLElement): () => void {
+ this.canvas = canvas;
+ canvas.setAttribute('tabindex', '0');
+ canvas.style.outline = 'none';
+
+ const onKeyDown = (e: KeyboardEvent) => {
+ const tag = (e.target as HTMLElement)?.tagName;
+ if (tag === 'INPUT' || tag === 'TEXTAREA') return;
+
+ this.trackKey(e, true);
+ if (MOVE_CODES.has(e.code) || e.code === 'Space') e.preventDefault();
+
+ if (e.code === 'Space' || e.code === 'KeyP') this.pausePressed = true;
+ if (e.code === 'KeyR') this.rotatePressed = true;
+ if (e.code === 'Escape') this.deselectPressed = true;
+ if (e.code === 'KeyI') this.toggleInventory = true;
+ if (e.code === 'KeyC') this.toggleCraft = true;
+ if (e.code === 'KeyB') this.toggleBuild = true;
+ if (e.code === 'KeyT') this.toggleResearch = true;
+ if (e.code === 'KeyV') this.toggleView3D = true;
+ if (e.code === 'KeyF') this.attackPressed = true;
+ if (e.code === 'KeyH') this.stopUnitsPressed = true;
+ if (e.code === 'KeyQ') this.placeDirection = ((this.placeDirection + 3) % 4) as Direction;
+ if (e.code === 'KeyE') this.placeDirection = ((this.placeDirection + 1) % 4) as Direction;
+ };
+ const onKeyUp = (e: KeyboardEvent) => {
+ this.trackKey(e, false);
+ };
+ const onBlur = () => {
+ this.keys.clear();
+ this.moveX = 0;
+ this.moveY = 0;
+ };
+ const onMouseMove = (e: MouseEvent) => {
+ const rect = canvas.getBoundingClientRect();
+ this.mouseX = e.clientX - rect.left;
+ this.mouseY = e.clientY - rect.top;
+ if (this.middleDown || (this.rightDown && this.buildMode === 'none')) {
+ this.cameraPanActive = true;
+ }
+ };
+ const onMouseDown = (e: MouseEvent) => {
+ canvas.focus();
+ if (e.button === 0) {
+ this.leftDown = true;
+ const now = performance.now();
+ if (now - this.lastClickTime < 300) this.justDoubleClicked = true;
+ else this.justClicked = true;
+ this.lastClickTime = now;
+ if (!this.pressed('Space', ' ')) {
+ this.boxSelecting = true;
+ this.boxStartX = this.mouseX;
+ this.boxStartY = this.mouseY;
+ }
+ }
+ if (e.button === 1) this.middleDown = true;
+ if (e.button === 2) {
+ this.rightDown = true;
+ this.justRightClicked = true;
+ }
+ };
+ const onMouseUp = (e: MouseEvent) => {
+ if (e.button === 0) {
+ this.leftDown = false;
+ this.boxSelecting = false;
+ }
+ if (e.button === 1) this.middleDown = false;
+ if (e.button === 2) this.rightDown = false;
+ };
+ const onWheel = (e: WheelEvent) => {
+ e.preventDefault();
+ this.wheelDelta += e.deltaY;
+ };
+ const onContext = (e: Event) => e.preventDefault();
+
+ window.addEventListener('keydown', onKeyDown);
+ window.addEventListener('keyup', onKeyUp);
+ window.addEventListener('blur', onBlur);
+ canvas.addEventListener('mousemove', onMouseMove);
+ canvas.addEventListener('mousedown', onMouseDown);
+ window.addEventListener('mouseup', onMouseUp);
+ canvas.addEventListener('wheel', onWheel, { passive: false });
+ canvas.addEventListener('contextmenu', onContext);
+
+ return () => {
+ window.removeEventListener('keydown', onKeyDown);
+ window.removeEventListener('keyup', onKeyUp);
+ window.removeEventListener('blur', onBlur);
+ canvas.removeEventListener('mousemove', onMouseMove);
+ canvas.removeEventListener('mousedown', onMouseDown);
+ window.removeEventListener('mouseup', onMouseUp);
+ canvas.removeEventListener('wheel', onWheel);
+ canvas.removeEventListener('contextmenu', onContext);
+ };
+ }
+
+ focusGame(): void {
+ this.canvas?.focus();
+ }
+
+ updateMovement(): void {
+ let x = 0;
+ let y = 0;
+ // Screen-up = decreasing world Y with our ortho + camera-follow feel (matches prior working mapping).
+ if (this.pressed('KeyW', 'w', 'ArrowUp', 'arrowup')) y -= 1;
+ if (this.pressed('KeyS', 's', 'ArrowDown', 'arrowdown')) y += 1;
+ if (this.pressed('KeyA', 'a', 'ArrowLeft', 'arrowleft')) x -= 1;
+ if (this.pressed('KeyD', 'd', 'ArrowRight', 'arrowright')) x += 1;
+ const len = Math.hypot(x, y) || 1;
+ this.moveX = x / len;
+ this.moveY = y / len;
+ }
+
+ consumeFrameFlags(): void {
+ this.justClicked = false;
+ this.justRightClicked = false;
+ this.justDoubleClicked = false;
+ this.wheelDelta = 0;
+ this.pausePressed = false;
+ this.rotatePressed = false;
+ this.deselectPressed = false;
+ this.toggleInventory = false;
+ this.toggleCraft = false;
+ this.toggleBuild = false;
+ this.toggleResearch = false;
+ this.toggleView3D = false;
+ this.attackPressed = false;
+ this.stopUnitsPressed = false;
+ this.cameraPanActive = false;
+ }
+}
diff --git a/web/src/engine/render/AssetCatalog.ts b/web/src/engine/render/AssetCatalog.ts
new file mode 100644
index 00000000..102c3ae9
--- /dev/null
+++ b/web/src/engine/render/AssetCatalog.ts
@@ -0,0 +1,283 @@
+import * as THREE from 'three';
+import { ProceduralAtlas } from './ProceduralAtlas';
+
+/** 1024×1024 sheets sliced into 4×4 frames of 256×256 (matches iOS TextureAtlas). */
+const SHEET_4X4 = new Set([
+ 'player_down',
+ 'player_left',
+ 'player_right',
+ 'player_up',
+ 'biter',
+ 'biter_left',
+ 'biter_right',
+ 'inserters_sheet',
+ 'transport_belt_animation',
+]);
+
+/** Skip border crop (full-bleed UI / splash / already-cropped frames). */
+const NO_BORDER_CROP = new Set([
+ 'splash',
+ 'solid_white',
+ 'new_game',
+ 'save_game',
+ 'load_game',
+ 'delete_game',
+ 'build',
+ 'menu',
+ 'help',
+ 'research',
+ 'inventory',
+ 'gear',
+ 'rotate',
+ 'move',
+ 'buy',
+ 'recycle',
+ 'trash',
+ 'cancel_button',
+ 'clear',
+ 'disable_audio',
+ 'right_arrow',
+]);
+
+/** Same as iOS TextureAtlas: crop 20% from each side to remove transparent padding. */
+const BORDER_CROP = 0.2;
+
+/** Maps game texture IDs to `/assets/*.png`, with sprite-sheet frame extraction. */
+export class AssetCatalog {
+ private loader = new THREE.TextureLoader();
+ private textures = new Map();
+ private loading = new Map>();
+ private fallback = new ProceduralAtlas();
+ private available = new Set();
+ ready = false;
+
+ private static readonly ALIASES: Record = {
+ player: 'player_down_0',
+ wall: 'stone_wall',
+ enemy: 'biter_0',
+ unit: 'player_right_0',
+ default: 'building_placeholder',
+ 'water-pump': 'water_pump',
+ 'offshore-pump': 'offshore_pump',
+ 'fluid-tank': 'fluid_tank',
+ 'underground-pipe': 'underground_pipe',
+ 'nuclear-reactor': 'nuclear_reactor',
+ 'rocket-silo': 'rocket_silo',
+ 'rocket-parts': 'rocket_parts',
+ 'rocket-fuel': 'rocket_fuel',
+ 'space-science-pack': 'space_science_pack',
+ 'low-density-structure': 'low_density_structure',
+ 'solid-fuel': 'solid_fuel',
+ 'plastic-bar': 'plastic_bar',
+ 'heavy-oil': 'heavy_oil',
+ 'light-oil': 'light_oil',
+ 'petroleum-gas': 'petroleum_gas',
+ 'sulfuric-acid': 'sulfuric_acid',
+ 'uranium-235': 'uranium_235',
+ 'uranium-238': 'uranium_238',
+ 'nuclear-fuel': 'nuclear_fuel',
+ };
+
+ filenameFor(id: string): string {
+ if (AssetCatalog.ALIASES[id]) return AssetCatalog.ALIASES[id]!;
+ return id.replace(/-/g, '_');
+ }
+
+ urlFor(id: string): string {
+ const file = this.filenameFor(id);
+ const sheet = file.replace(/_\d+$/, '');
+ if (SHEET_4X4.has(sheet) && /_\d+$/.test(file)) {
+ return `/assets/${sheet}.png`;
+ }
+ return `/assets/${file}.png`;
+ }
+
+ iconUrl(id: string): string {
+ return this.urlFor(id);
+ }
+
+ async preload(onProgress?: (loaded: number, total: number) => void): Promise {
+ let files: string[] = [];
+ try {
+ const res = await fetch('/assets/manifest.json');
+ files = (await res.json()) as string[];
+ files = files.filter(
+ (f) => !f.startsWith('AppIcon') && !f.includes(' copy') && !f.includes('copper_ore '),
+ );
+ } catch {
+ files = ['grass', 'dirt', 'water', 'sand', 'stone', 'tree', 'player_down', 'transport_belt', 'splash'];
+ }
+
+ let loaded = 0;
+ const total = files.length;
+ await Promise.all(
+ files.map(async (name) => {
+ try {
+ const raw = await this.loadFile(name);
+ if (SHEET_4X4.has(name)) {
+ this.textures.set(name, raw);
+ this.available.add(name);
+ await this.sliceSheet4x4(name, raw);
+ } else {
+ const processed = this.cropBorderAndNormalize(name, raw);
+ this.textures.set(name, processed);
+ this.available.add(name);
+ if (processed !== raw) raw.dispose();
+ }
+ } catch {
+ /* missing */
+ }
+ loaded++;
+ onProgress?.(loaded, total);
+ }),
+ );
+ this.ready = true;
+ }
+
+ /**
+ * Match iOS packSpriteIntoAtlas border crop: remove ~20% padding from each side
+ * so tiles abut without black/transparent gutters.
+ */
+ private cropBorderAndNormalize(name: string, tex: THREE.Texture): THREE.Texture {
+ if (NO_BORDER_CROP.has(name)) return this.configureTex(tex);
+
+ const img = tex.image as HTMLImageElement | ImageBitmap | HTMLCanvasElement;
+ const w = 'naturalWidth' in img && img.naturalWidth ? img.naturalWidth : (img as HTMLImageElement).width;
+ const h = 'naturalHeight' in img && img.naturalHeight ? img.naturalHeight : (img as HTMLImageElement).height;
+ if (!w || !h || w < 48 || h < 48) return this.configureTex(tex);
+
+ const sx = Math.floor(w * BORDER_CROP);
+ const sy = Math.floor(h * BORDER_CROP);
+ const sw = Math.max(1, w - sx * 2);
+ const sh = Math.max(1, h - sy * 2);
+
+ const outSize = 64;
+ const canvas = document.createElement('canvas');
+ canvas.width = outSize;
+ canvas.height = outSize;
+ const ctx = canvas.getContext('2d', { alpha: true })!;
+ ctx.imageSmoothingEnabled = false;
+ ctx.clearRect(0, 0, outSize, outSize);
+ // Terrain only: opaque underfill so leftover alpha can't open tile seams.
+ // Buildings/entities keep true transparency.
+ const terrainFill: Record = {
+ grass: '#3a6b34',
+ dirt: '#6b5428',
+ sand: '#c2b280',
+ water: '#1e5a8a',
+ stone: '#6e6e6e',
+ };
+ const fill = terrainFill[name];
+ if (fill) {
+ ctx.fillStyle = fill;
+ ctx.fillRect(0, 0, outSize, outSize);
+ }
+ ctx.drawImage(img as CanvasImageSource, sx, sy, sw, sh, 0, 0, outSize, outSize);
+
+ const out = new THREE.CanvasTexture(canvas);
+ return this.configureTex(out);
+ }
+
+ private configureTex(tex: THREE.Texture): THREE.Texture {
+ tex.magFilter = THREE.NearestFilter;
+ tex.minFilter = THREE.NearestFilter;
+ tex.colorSpace = THREE.SRGBColorSpace;
+ tex.wrapS = THREE.ClampToEdgeWrapping;
+ tex.wrapT = THREE.ClampToEdgeWrapping;
+ tex.generateMipmaps = false;
+ tex.anisotropy = 1;
+ tex.needsUpdate = true;
+ return tex;
+ }
+
+ /** Extract 16 frames from a 1024×1024 sheet into `name_0` … `name_15`. */
+ private async sliceSheet4x4(name: string, sheetTex: THREE.Texture): Promise {
+ const img = sheetTex.image as HTMLImageElement | ImageBitmap;
+ const w = 'width' in img ? img.width : 0;
+ const h = 'height' in img ? img.height : 0;
+ if (w !== 1024 || h !== 1024) {
+ this.textures.set(`${name}_0`, this.configureTex(sheetTex));
+ this.available.add(`${name}_0`);
+ return;
+ }
+
+ const frameSize = 256;
+ const cols = 4;
+ for (let frameIndex = 0; frameIndex < 16; frameIndex++) {
+ const row = Math.floor(frameIndex / cols);
+ const col = frameIndex % cols;
+ const canvas = document.createElement('canvas');
+ canvas.width = frameSize;
+ canvas.height = frameSize;
+ const ctx = canvas.getContext('2d')!;
+ ctx.imageSmoothingEnabled = false;
+ ctx.clearRect(0, 0, frameSize, frameSize);
+ ctx.drawImage(
+ img as CanvasImageSource,
+ col * frameSize,
+ row * frameSize,
+ frameSize,
+ frameSize,
+ 0,
+ 0,
+ frameSize,
+ frameSize,
+ );
+ const frameTex = new THREE.CanvasTexture(canvas);
+ const frameName = `${name}_${frameIndex}`;
+ this.textures.set(frameName, this.configureTex(frameTex));
+ this.available.add(frameName);
+ }
+ }
+
+ private loadFile(filename: string): Promise {
+ const existing = this.loading.get(filename);
+ if (existing) return existing;
+ const p = new Promise((resolve, reject) => {
+ this.loader.load(
+ `/assets/${filename}.png`,
+ (tex) => resolve(tex),
+ undefined,
+ () => reject(new Error(`Failed ${filename}`)),
+ );
+ });
+ this.loading.set(filename, p);
+ return p;
+ }
+
+ get(id: string): THREE.Texture {
+ const file = this.filenameFor(id);
+ const tex = this.textures.get(file);
+ if (tex) return tex;
+ if (SHEET_4X4.has(file)) {
+ const f0 = this.textures.get(`${file}_0`);
+ if (f0) return f0;
+ }
+ if (!this.loading.has(file) && !/_\d+$/.test(file)) {
+ void this.loadFile(file)
+ .then(async (t) => {
+ if (SHEET_4X4.has(file)) {
+ this.textures.set(file, t);
+ this.available.add(file);
+ await this.sliceSheet4x4(file, t);
+ } else {
+ const processed = this.cropBorderAndNormalize(file, t);
+ this.textures.set(file, processed);
+ this.available.add(file);
+ }
+ })
+ .catch(() => undefined);
+ }
+ return this.fallback.get(file);
+ }
+
+ hasPng(id: string): boolean {
+ return this.available.has(this.filenameFor(id));
+ }
+}
+
+export const assets = new AssetCatalog();
+
+export function playerFrames(dir: 'down' | 'up' | 'left' | 'right'): string[] {
+ return Array.from({ length: 16 }, (_, i) => `player_${dir}_${i}`);
+}
diff --git a/web/src/engine/render/Camera2D.ts b/web/src/engine/render/Camera2D.ts
new file mode 100644
index 00000000..0f78f4df
--- /dev/null
+++ b/web/src/engine/render/Camera2D.ts
@@ -0,0 +1,37 @@
+export class Camera2D {
+ x = 0;
+ y = 0;
+ zoom = 1.15;
+ minZoom = 0.2;
+ maxZoom = 10;
+ pixelsPerTile = 32;
+ followLerp = 8;
+
+ setFollow(targetX: number, targetY: number, dt: number, enabled: boolean): void {
+ if (!enabled) return;
+ const t = 1 - Math.exp(-this.followLerp * dt);
+ this.x += (targetX - this.x) * t;
+ this.y += (targetY - this.y) * t;
+ }
+
+ zoomBy(delta: number): void {
+ this.zoom = Math.min(this.maxZoom, Math.max(this.minZoom, this.zoom * (1 - delta * 0.001)));
+ }
+
+ pan(dx: number, dy: number): void {
+ const scale = 1 / (this.zoom * this.pixelsPerTile);
+ this.x -= dx * scale * this.pixelsPerTile;
+ this.y += dy * scale * this.pixelsPerTile;
+ }
+
+ screenToWorld(sx: number, sy: number, viewW: number, viewH: number): { x: number; y: number } {
+ const halfW = viewW / (2 * this.zoom * this.pixelsPerTile);
+ const halfH = viewH / (2 * this.zoom * this.pixelsPerTile);
+ const ndcX = sx / viewW - 0.5;
+ const ndcY = 0.5 - sy / viewH;
+ return {
+ x: this.x + ndcX * 2 * halfW,
+ y: this.y + ndcY * 2 * halfH,
+ };
+ }
+}
diff --git a/web/src/engine/render/ColorAtlas.ts b/web/src/engine/render/ColorAtlas.ts
new file mode 100644
index 00000000..8944cde6
--- /dev/null
+++ b/web/src/engine/render/ColorAtlas.ts
@@ -0,0 +1,59 @@
+/** Procedural color fallbacks when PNGs are missing */
+const COLORS: Record = {
+ grass: '#3d7a3d',
+ dirt: '#8b6914',
+ sand: '#c2b280',
+ water: '#2a6a9a',
+ stone: '#7a7a7a',
+ player: '#4fc3f7',
+ tree: '#1b5e20',
+ 'iron-ore': '#8d6e63',
+ 'copper-ore': '#e65100',
+ coal: '#212121',
+ 'uranium-ore': '#76ff03',
+ 'crude-oil': '#311b92',
+ 'burner-mining-drill': '#795548',
+ 'electric-mining-drill': '#546e7a',
+ 'stone-furnace': '#a1887f',
+ 'steel-furnace': '#78909c',
+ 'electric-furnace': '#90a4ae',
+ 'assembling-machine-1': '#ffb74d',
+ 'assembling-machine-2': '#ffa726',
+ 'assembling-machine-3': '#ff9800',
+ 'transport-belt': '#fdd835',
+ 'fast-transport-belt': '#ef5350',
+ 'express-transport-belt': '#42a5f5',
+ inserter: '#66bb6a',
+ 'long-handed-inserter': '#43a047',
+ 'fast-inserter': '#2e7d32',
+ 'stack-inserter': '#1b5e20',
+ 'wooden-chest': '#6d4c41',
+ 'iron-chest': '#90a4ae',
+ 'steel-chest': '#607d8b',
+ 'small-electric-pole': '#bdbdbd',
+ 'medium-electric-pole': '#9e9e9e',
+ 'big-electric-pole': '#757575',
+ boiler: '#ef6c00',
+ 'steam-engine': '#546e7a',
+ 'solar-panel': '#1565c0',
+ accumulator: '#7e57c2',
+ lab: '#ab47bc',
+ pipe: '#78909c',
+ 'fluid-tank': '#0288d1',
+ pumpjack: '#5d4037',
+ 'water-pump': '#039be5',
+ 'oil-refinery': '#37474f',
+ 'chemical-plant': '#00897b',
+ 'gun-turret': '#c62828',
+ 'laser-turret': '#ad1457',
+ 'stone-wall': '#9e9e9e',
+ 'rocket-silo': '#455a64',
+ biter: '#d32f2f',
+ enemy: '#b71c1c',
+ unit: '#1976d2',
+ default: '#cccccc',
+};
+
+export function colorFor(textureId: string): string {
+ return COLORS[textureId] ?? COLORS.default!;
+}
diff --git a/web/src/engine/render/OrthoSpriteView.ts b/web/src/engine/render/OrthoSpriteView.ts
new file mode 100644
index 00000000..651a65a6
--- /dev/null
+++ b/web/src/engine/render/OrthoSpriteView.ts
@@ -0,0 +1,194 @@
+import * as THREE from 'three';
+import type { GameView } from './ViewMode';
+import type { Camera2D } from './Camera2D';
+import type { World } from '../ecs/World';
+import type { ChunkManager } from '../../game/world/ChunkManager';
+import { CHUNK_SIZE } from '../../game/world/Chunk';
+import type { PositionComponent, SpriteComponent } from '../../game/components';
+import { RenderLayer } from '../../game/components';
+import { createRenderer, type GameRenderer } from './createRenderer';
+import { assets } from './AssetCatalog';
+
+const OPAQUE_TILES = new Set(['grass', 'dirt', 'sand', 'water', 'stone']);
+
+interface Quad {
+ mesh: THREE.Mesh;
+ key: string;
+ textureId: string;
+}
+
+export class OrthoSpriteView implements GameView {
+ readonly kind = 'ortho2d' as const;
+ private renderer!: GameRenderer;
+ private scene = new THREE.Scene();
+ private camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 1000);
+ private quads = new Map();
+ private tileQuads = new Map();
+ private tileTex = new Map();
+ private geom = new THREE.PlaneGeometry(1, 1);
+ private materials = new Map();
+ private enabled = true;
+ private width = 800;
+ private height = 600;
+ private canvas!: HTMLCanvasElement;
+
+ async init(canvas: HTMLCanvasElement): Promise {
+ this.canvas = canvas;
+ this.renderer = await createRenderer(canvas, false);
+ this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
+ this.scene.background = new THREE.Color('#1a2a18');
+ this.camera.position.z = 10;
+ }
+
+ setEnabled(enabled: boolean): void {
+ this.enabled = enabled;
+ if (this.canvas) this.canvas.style.display = enabled ? 'block' : 'none';
+ }
+
+ resize(width: number, height: number): void {
+ this.width = width;
+ this.height = height;
+ this.renderer.setSize(width, height, false);
+ }
+
+ private mat(textureId: string, opaque = false): THREE.MeshBasicMaterial {
+ const key = opaque ? `o:${textureId}` : textureId;
+ let m = this.materials.get(key);
+ if (!m) {
+ m = new THREE.MeshBasicMaterial({
+ map: assets.get(textureId),
+ transparent: !opaque,
+ depthWrite: opaque,
+ // Cut out near-zero alpha so transparent PNG padding doesn't tint quads
+ alphaTest: opaque ? 0 : 0.1,
+ });
+ this.materials.set(key, m);
+ } else {
+ const map = assets.get(textureId);
+ if (m.map !== map) {
+ m.map = map;
+ m.needsUpdate = true;
+ }
+ }
+ return m;
+ }
+
+ private syncTile(key: string, x: number, y: number, textureId: string, z: number, scale = 1): void {
+ const opaque = OPAQUE_TILES.has(textureId);
+ let mesh = this.tileQuads.get(key);
+ if (!mesh) {
+ mesh = new THREE.Mesh(this.geom, this.mat(textureId, opaque));
+ this.tileQuads.set(key, mesh);
+ this.tileTex.set(key, textureId);
+ this.scene.add(mesh);
+ } else if (this.tileTex.get(key) !== textureId) {
+ mesh.material = this.mat(textureId, opaque);
+ this.tileTex.set(key, textureId);
+ } else {
+ mesh.material = this.mat(textureId, opaque);
+ }
+ mesh.position.set(x, y, z);
+ // Tiny overlap avoids sub-pixel cracks after crop; content is now edge-to-edge
+ mesh.scale.set(scale, scale, 1);
+ mesh.visible = true;
+ }
+
+ private syncSprite(
+ key: string,
+ x: number,
+ y: number,
+ w: number,
+ h: number,
+ textureId: string,
+ z: number,
+ rot = 0,
+ ): void {
+ let q = this.quads.get(key);
+ if (!q) {
+ const mesh = new THREE.Mesh(this.geom, this.mat(textureId));
+ q = { mesh, key, textureId };
+ this.quads.set(key, q);
+ this.scene.add(mesh);
+ } else {
+ q.textureId = textureId;
+ q.mesh.material = this.mat(textureId);
+ }
+ q.mesh.position.set(x, y, z);
+ q.mesh.scale.set(w, h, 1);
+ q.mesh.rotation.z = -rot;
+ q.mesh.visible = true;
+ }
+
+ render(world: World, chunks: ChunkManager, camera: Camera2D, _dt: number): void {
+ if (!this.enabled) return;
+
+ const halfW = this.width / (2 * camera.zoom * camera.pixelsPerTile);
+ const halfH = this.height / (2 * camera.zoom * camera.pixelsPerTile);
+ this.camera.left = -halfW;
+ this.camera.right = halfW;
+ this.camera.top = halfH;
+ this.camera.bottom = -halfH;
+ this.camera.position.x = camera.x;
+ this.camera.position.y = camera.y;
+ this.camera.updateProjectionMatrix();
+
+ const seenTiles = new Set();
+ const seenSprites = new Set();
+
+ for (const chunk of chunks.loadedChunks()) {
+ for (let ly = 0; ly < CHUNK_SIZE; ly++) {
+ for (let lx = 0; lx < CHUNK_SIZE; lx++) {
+ const wx = chunk.coord.x * CHUNK_SIZE + lx + 0.5;
+ const wy = chunk.coord.y * CHUNK_SIZE + ly + 0.5;
+ if (Math.abs(wx - camera.x) > halfW + 2 || Math.abs(wy - camera.y) > halfH + 2) continue;
+ const tile = chunk.tiles[ly]![lx]!;
+ const key = `t:${chunk.coord.x},${chunk.coord.y}:${lx},${ly}`;
+ seenTiles.add(key);
+ this.syncTile(key, wx, wy, tile.type, RenderLayer.Ground * 0.01, 1.002);
+ if (tile.resource) {
+ const rk = `r:${key}`;
+ seenTiles.add(rk);
+ this.syncTile(rk, wx, wy, tile.resource.type, RenderLayer.GroundDecoration * 0.01, 0.75);
+ }
+ }
+ }
+ }
+
+ world.forEach('sprite', (sprite, entity) => {
+ if (sprite.visible === false) return;
+ const pos = world.getByName('position', entity);
+ if (!pos) return;
+ const x = pos.tileX + pos.offsetX;
+ const y = pos.tileY + pos.offsetY;
+ if (Math.abs(x - camera.x) > halfW + 4 || Math.abs(y - camera.y) > halfH + 4) return;
+ const key = `s:${entity.id}`;
+ seenSprites.add(key);
+ const rot = ((sprite.rotation ?? 0) * Math.PI) / 2;
+ this.syncSprite(
+ key,
+ x + (sprite.width - 1) * 0.5,
+ y + (sprite.height - 1) * 0.5,
+ sprite.width * 0.95,
+ sprite.height * 0.95,
+ sprite.textureId,
+ sprite.layer * 0.01,
+ rot,
+ );
+ });
+
+ for (const [key, mesh] of this.tileQuads) {
+ if (!seenTiles.has(key)) mesh.visible = false;
+ }
+ for (const [key, q] of this.quads) {
+ if (!seenSprites.has(key)) q.mesh.visible = false;
+ }
+
+ this.renderer.render(this.scene, this.camera);
+ }
+
+ dispose(): void {
+ for (const m of this.materials.values()) m.dispose();
+ this.geom.dispose();
+ this.renderer.dispose();
+ }
+}
diff --git a/web/src/engine/render/ProceduralAtlas.ts b/web/src/engine/render/ProceduralAtlas.ts
new file mode 100644
index 00000000..72a0c1ca
--- /dev/null
+++ b/web/src/engine/render/ProceduralAtlas.ts
@@ -0,0 +1,290 @@
+import * as THREE from 'three';
+
+/** Canvas-generated pixel textures so the world reads as a factory game, not flat color blocks. */
+export class ProceduralAtlas {
+ private canvas: HTMLCanvasElement;
+ private ctx: CanvasRenderingContext2D;
+ private map = new Map();
+ private cell = 32;
+
+ constructor() {
+ const size = 512;
+ this.canvas = document.createElement('canvas');
+ this.canvas.width = size;
+ this.canvas.height = size;
+ this.ctx = this.canvas.getContext('2d')!;
+ this.ctx.imageSmoothingEnabled = false;
+ }
+
+ get(id: string): THREE.CanvasTexture {
+ let tex = this.map.get(id);
+ if (tex) return tex;
+ tex = this.bake(id);
+ this.map.set(id, tex);
+ return tex;
+ }
+
+ private bake(id: string): THREE.CanvasTexture {
+ const c = document.createElement('canvas');
+ c.width = this.cell;
+ c.height = this.cell;
+ const g = c.getContext('2d')!;
+ g.imageSmoothingEnabled = false;
+ drawSprite(g, id, this.cell);
+ const tex = new THREE.CanvasTexture(c);
+ tex.magFilter = THREE.NearestFilter;
+ tex.minFilter = THREE.NearestFilter;
+ tex.colorSpace = THREE.SRGBColorSpace;
+ tex.needsUpdate = true;
+ return tex;
+ }
+}
+
+function noise(g: CanvasRenderingContext2D, n: number, color: string, size: number): void {
+ g.fillStyle = color;
+ for (let i = 0; i < n; i++) {
+ const x = Math.floor(Math.random() * size);
+ const y = Math.floor(Math.random() * size);
+ g.fillRect(x, y, 1 + (Math.random() > 0.7 ? 1 : 0), 1);
+ }
+}
+
+function drawSprite(g: CanvasRenderingContext2D, id: string, s: number): void {
+ // deterministic-ish seed from id
+ let h = 0;
+ for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) | 0;
+ const rnd = () => {
+ h = (h * 1664525 + 1013904223) | 0;
+ return (h >>> 0) / 4294967296;
+ };
+
+ const fill = (color: string) => {
+ g.fillStyle = color;
+ g.fillRect(0, 0, s, s);
+ };
+
+ if (id === 'grass') {
+ fill('#3a6b34');
+ noise(g, 40, '#4c8a42', s);
+ noise(g, 20, '#2d5528', s);
+ // subtle tile edge
+ g.strokeStyle = '#2a4f26';
+ g.strokeRect(0.5, 0.5, s - 1, s - 1);
+ return;
+ }
+ if (id === 'dirt') {
+ fill('#6b5428');
+ noise(g, 35, '#7d6432', s);
+ noise(g, 20, '#55401c', s);
+ g.strokeStyle = '#443318';
+ g.strokeRect(0.5, 0.5, s - 1, s - 1);
+ return;
+ }
+ if (id === 'sand') {
+ fill('#c2b280');
+ noise(g, 30, '#d4c496', s);
+ noise(g, 15, '#a89868', s);
+ return;
+ }
+ if (id === 'water') {
+ fill('#1e5a8a');
+ noise(g, 25, '#2a6fa3', s);
+ noise(g, 15, '#154a72', s);
+ g.fillStyle = '#3a8bc4';
+ for (let i = 0; i < 4; i++) g.fillRect(4 + i * 6, 10 + ((i * 3) % 8), 5, 2);
+ return;
+ }
+ if (id === 'stone') {
+ fill('#6e6e6e');
+ noise(g, 40, '#808080', s);
+ noise(g, 25, '#555', s);
+ return;
+ }
+ if (id.includes('ore') || id === 'coal' || id === 'crude-oil') {
+ const base =
+ id === 'iron-ore'
+ ? '#5c4033'
+ : id === 'copper-ore'
+ ? '#8b4513'
+ : id === 'coal'
+ ? '#1a1a1a'
+ : id === 'uranium-ore'
+ ? '#3d5c1a'
+ : '#2a1840';
+ const spark =
+ id === 'iron-ore'
+ ? '#a08070'
+ : id === 'copper-ore'
+ ? '#e07030'
+ : id === 'coal'
+ ? '#444'
+ : id === 'uranium-ore'
+ ? '#b8ff40'
+ : '#6a40a0';
+ fill(base);
+ g.fillStyle = spark;
+ for (let i = 0; i < 18; i++) {
+ g.fillRect(Math.floor(rnd() * (s - 2)), Math.floor(rnd() * (s - 2)), 2, 2);
+ }
+ return;
+ }
+ if (id === 'player') {
+ fill('#1a1a1a00');
+ g.clearRect(0, 0, s, s);
+ g.fillStyle = '#4fc3f7';
+ g.fillRect(10, 6, 12, 20);
+ g.fillStyle = '#fff';
+ g.fillRect(12, 8, 8, 6);
+ g.fillStyle = '#0288d1';
+ g.fillRect(8, 24, 6, 6);
+ g.fillRect(18, 24, 6, 6);
+ return;
+ }
+ if (id === 'tree') {
+ g.clearRect(0, 0, s, s);
+ g.fillStyle = '#5d4037';
+ g.fillRect(14, 18, 4, 12);
+ g.fillStyle = '#2e7d32';
+ g.beginPath();
+ g.arc(16, 14, 10, 0, Math.PI * 2);
+ g.fill();
+ g.fillStyle = '#43a047';
+ g.beginPath();
+ g.arc(12, 12, 5, 0, Math.PI * 2);
+ g.fill();
+ return;
+ }
+ if (id === 'biter' || id === 'enemy') {
+ g.clearRect(0, 0, s, s);
+ g.fillStyle = '#c62828';
+ g.fillRect(6, 10, 20, 14);
+ g.fillStyle = '#ff8a80';
+ g.fillRect(10, 12, 4, 4);
+ g.fillRect(18, 12, 4, 4);
+ g.fillStyle = '#8b0000';
+ g.fillRect(4, 22, 6, 4);
+ g.fillRect(22, 22, 6, 4);
+ return;
+ }
+ if (id.includes('belt')) {
+ const col = id.includes('express') ? '#42a5f5' : id.includes('fast') ? '#ef5350' : '#fdd835';
+ fill('#3e2723');
+ g.fillStyle = col;
+ g.fillRect(2, 10, 28, 12);
+ g.fillStyle = '#fff8';
+ for (let i = 0; i < 4; i++) g.fillRect(4 + i * 7, 12, 3, 8);
+ return;
+ }
+ if (id.includes('inserter')) {
+ g.clearRect(0, 0, s, s);
+ g.fillStyle = '#424242';
+ g.fillRect(12, 12, 8, 8);
+ g.fillStyle = '#66bb6a';
+ g.fillRect(14, 4, 4, 12);
+ g.fillRect(10, 4, 12, 4);
+ return;
+ }
+ if (id.includes('furnace')) {
+ fill('#5d4037');
+ g.fillStyle = '#3e2723';
+ g.fillRect(6, 8, 20, 18);
+ g.fillStyle = '#ff5722';
+ g.fillRect(12, 14, 8, 8);
+ g.fillStyle = '#9e9e9e';
+ g.fillRect(10, 4, 12, 4);
+ return;
+ }
+ if (id.includes('drill') || id.includes('miner')) {
+ fill('#455a64');
+ g.fillStyle = '#78909c';
+ g.fillRect(4, 4, 24, 24);
+ g.fillStyle = '#ffc107';
+ g.beginPath();
+ g.arc(16, 16, 6, 0, Math.PI * 2);
+ g.fill();
+ return;
+ }
+ if (id.includes('assembl')) {
+ fill('#ef6c00');
+ g.fillStyle = '#ffe0b2';
+ g.fillRect(6, 6, 20, 20);
+ g.fillStyle = '#e65100';
+ g.fillRect(10, 10, 12, 12);
+ return;
+ }
+ if (id.includes('pole')) {
+ g.clearRect(0, 0, s, s);
+ g.fillStyle = '#bdbdbd';
+ g.fillRect(14, 4, 4, 24);
+ g.fillStyle = '#ffeb3b';
+ g.fillRect(8, 6, 16, 3);
+ return;
+ }
+ if (id === 'boiler' || id === 'steam-engine') {
+ fill('#546e7a');
+ g.fillStyle = '#90a4ae';
+ g.fillRect(4, 8, 24, 16);
+ g.fillStyle = '#eceff1';
+ g.fillRect(8, 4, 6, 8);
+ return;
+ }
+ if (id === 'pipe' || id.includes('pipe') || id === 'fluid-tank') {
+ fill('#607d8b');
+ g.fillStyle = '#90a4ae';
+ g.fillRect(0, 12, 32, 8);
+ g.fillRect(12, 0, 8, 32);
+ return;
+ }
+ if (id.includes('chest')) {
+ fill('#6d4c41');
+ g.fillStyle = '#a1887f';
+ g.fillRect(6, 8, 20, 16);
+ g.fillStyle = '#ffc107';
+ g.fillRect(14, 14, 4, 4);
+ return;
+ }
+ if (id === 'lab') {
+ fill('#6a1b9a');
+ g.fillStyle = '#ce93d8';
+ g.fillRect(6, 6, 20, 20);
+ g.fillStyle = '#ea80fc';
+ g.fillRect(12, 10, 8, 12);
+ return;
+ }
+ if (id.includes('turret') || id === 'stone-wall' || id === 'wall') {
+ fill('#5d4037');
+ g.fillStyle = '#8d6e63';
+ g.fillRect(4, 4, 24, 24);
+ if (id.includes('turret')) {
+ g.fillStyle = '#c62828';
+ g.fillRect(12, 8, 8, 16);
+ }
+ return;
+ }
+ if (id === 'rocket-silo') {
+ fill('#37474f');
+ g.fillStyle = '#90a4ae';
+ g.fillRect(4, 4, 24, 24);
+ g.fillStyle = '#fff';
+ g.beginPath();
+ g.moveTo(16, 6);
+ g.lineTo(22, 26);
+ g.lineTo(10, 26);
+ g.fill();
+ return;
+ }
+ if (id === 'unit') {
+ g.clearRect(0, 0, s, s);
+ g.fillStyle = '#1565c0';
+ g.fillRect(8, 8, 16, 16);
+ return;
+ }
+
+ // default building
+ fill('#757575');
+ g.fillStyle = '#9e9e9e';
+ g.fillRect(4, 4, 24, 24);
+ g.fillStyle = '#bdbdbd';
+ g.fillRect(8, 8, 16, 16);
+ void rnd;
+}
diff --git a/web/src/engine/render/ViewMode.ts b/web/src/engine/render/ViewMode.ts
new file mode 100644
index 00000000..257a9668
--- /dev/null
+++ b/web/src/engine/render/ViewMode.ts
@@ -0,0 +1,14 @@
+import type { Camera2D } from './Camera2D';
+import type { World } from '../ecs/World';
+import type { ChunkManager } from '../../game/world/ChunkManager';
+
+export type ViewKind = 'ortho2d' | 'perspective3d';
+
+export interface GameView {
+ readonly kind: ViewKind;
+ init(canvas: HTMLCanvasElement): Promise;
+ resize(width: number, height: number): void;
+ render(world: World, chunks: ChunkManager, camera: Camera2D, dt: number): void;
+ dispose(): void;
+ setEnabled(enabled: boolean): void;
+}
diff --git a/web/src/engine/render/createRenderer.ts b/web/src/engine/render/createRenderer.ts
new file mode 100644
index 00000000..5e503aa6
--- /dev/null
+++ b/web/src/engine/render/createRenderer.ts
@@ -0,0 +1,27 @@
+import * as THREE from 'three';
+
+export type GameRenderer = {
+ setPixelRatio(n: number): void;
+ setSize(w: number, h: number, updateStyle?: boolean): void;
+ render(scene: THREE.Scene, camera: THREE.Camera): void;
+ dispose(): void;
+ outputColorSpace: string;
+ domElement: HTMLCanvasElement;
+};
+
+/** Prefer WebGPURenderer (three/webgpu) with automatic WebGL2 fallback. */
+export async function createRenderer(canvas: HTMLCanvasElement, antialias: boolean): Promise {
+ try {
+ const webgpu = await import('three/webgpu');
+ const RendererCtor = (webgpu as { WebGPURenderer: new (p: unknown) => GameRenderer & { init(): Promise } })
+ .WebGPURenderer;
+ const renderer = new RendererCtor({ canvas, antialias });
+ await renderer.init();
+ renderer.outputColorSpace = THREE.SRGBColorSpace;
+ return renderer;
+ } catch {
+ const renderer = new THREE.WebGLRenderer({ canvas, antialias, alpha: false });
+ renderer.outputColorSpace = THREE.SRGBColorSpace;
+ return renderer;
+ }
+}
diff --git a/web/src/engine/render3d/Perspective3DView.ts b/web/src/engine/render3d/Perspective3DView.ts
new file mode 100644
index 00000000..056005eb
--- /dev/null
+++ b/web/src/engine/render3d/Perspective3DView.ts
@@ -0,0 +1,166 @@
+import * as THREE from 'three';
+import type { GameView } from '../render/ViewMode';
+import type { Camera2D } from '../render/Camera2D';
+import type { World } from '../ecs/World';
+import type { ChunkManager } from '../../game/world/ChunkManager';
+import { CHUNK_SIZE } from '../../game/world/Chunk';
+import { assets } from '../render/AssetCatalog';
+import type { PositionComponent, SpriteComponent } from '../../game/components';
+import { createRenderer, type GameRenderer } from '../render/createRenderer';
+
+/** Perspective 3D view sharing the same ECS world coords (X/Z = tile plane, Y = height). */
+export class Perspective3DView implements GameView {
+ readonly kind = 'perspective3d' as const;
+ private renderer!: GameRenderer;
+ private scene = new THREE.Scene();
+ private camera = new THREE.PerspectiveCamera(60, 1, 0.1, 500);
+ private sun!: THREE.DirectionalLight;
+ private terrain = new Map();
+ private entities = new Map();
+ private box = new THREE.BoxGeometry(1, 1, 1);
+ private materials = new Map();
+ private enabled = false;
+ private canvas!: HTMLCanvasElement;
+ private orbitAngle = 0.6;
+ private orbitYaw = 0.4;
+
+ async init(canvas: HTMLCanvasElement): Promise {
+ this.canvas = canvas;
+ this.renderer = await createRenderer(canvas, true);
+ this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
+ this.scene.background = new THREE.Color('#87a0b5');
+ this.scene.fog = new THREE.Fog('#87a0b5', 40, 120);
+
+ const ambient = new THREE.AmbientLight(0xffffff, 0.45);
+ this.scene.add(ambient);
+ this.sun = new THREE.DirectionalLight(0xfff2dd, 1.1);
+ this.sun.position.set(30, 50, 20);
+ this.scene.add(this.sun);
+ }
+
+ setEnabled(enabled: boolean): void {
+ this.enabled = enabled;
+ if (this.canvas) this.canvas.style.display = enabled ? 'block' : 'none';
+ }
+
+ resize(width: number, height: number): void {
+ this.camera.aspect = width / Math.max(1, height);
+ this.camera.updateProjectionMatrix();
+ this.renderer.setSize(width, height, false);
+ }
+
+ private mat(textureId: string): THREE.MeshStandardMaterial {
+ let m = this.materials.get(textureId);
+ if (!m) {
+ m = new THREE.MeshStandardMaterial({
+ map: assets.get(textureId),
+ roughness: 0.85,
+ metalness: 0.05,
+ });
+ this.materials.set(textureId, m);
+ } else {
+ const map = assets.get(textureId);
+ if (m.map !== map) {
+ m.map = map;
+ m.needsUpdate = true;
+ }
+ }
+ return m;
+ }
+
+ private heightFor(type: string): number {
+ if (type === 'water') return 0.15;
+ if (type === 'stone') return 0.55;
+ if (type === 'sand') return 0.25;
+ return 0.35;
+ }
+
+ render(world: World, chunks: ChunkManager, camera2d: Camera2D, dt: number): void {
+ if (!this.enabled) return;
+
+ const distance = Math.max(8, Math.min(100, 100 / camera2d.zoom));
+ this.orbitYaw += dt * 0.05;
+ const target = new THREE.Vector3(camera2d.x, 0, camera2d.y);
+ const ox = Math.sin(this.orbitYaw) * Math.cos(this.orbitAngle) * distance;
+ const oy = Math.sin(this.orbitAngle) * distance;
+ const oz = Math.cos(this.orbitYaw) * Math.cos(this.orbitAngle) * distance;
+ this.camera.position.set(target.x + ox, oy, target.z + oz);
+ this.camera.lookAt(target);
+
+ const seenT = new Set();
+ for (const chunk of chunks.loadedChunks()) {
+ for (let ly = 0; ly < CHUNK_SIZE; ly += 1) {
+ for (let lx = 0; lx < CHUNK_SIZE; lx += 1) {
+ const wx = chunk.coord.x * CHUNK_SIZE + lx;
+ const wz = chunk.coord.y * CHUNK_SIZE + ly;
+ if (Math.hypot(wx - camera2d.x, wz - camera2d.y) > distance * 1.2) continue;
+ const tile = chunk.tiles[ly]![lx]!;
+ const key = `${chunk.coord.x},${chunk.coord.y}:${lx},${ly}`;
+ seenT.add(key);
+ let mesh = this.terrain.get(key);
+ const h = this.heightFor(tile.type);
+ if (!mesh) {
+ mesh = new THREE.Mesh(this.box, this.mat(tile.type));
+ this.terrain.set(key, mesh);
+ this.scene.add(mesh);
+ } else {
+ mesh.material = this.mat(tile.type);
+ }
+ mesh.position.set(wx + 0.5, h / 2, wz + 0.5);
+ mesh.scale.set(1, h, 1);
+ mesh.visible = true;
+
+ if (tile.resource) {
+ const rk = `r:${key}`;
+ seenT.add(rk);
+ let rm = this.terrain.get(rk);
+ if (!rm) {
+ rm = new THREE.Mesh(this.box, this.mat(tile.resource.type));
+ this.terrain.set(rk, rm);
+ this.scene.add(rm);
+ }
+ rm.position.set(wx + 0.5, h + 0.2, wz + 0.5);
+ rm.scale.set(0.45, 0.4, 0.45);
+ rm.visible = true;
+ }
+ }
+ }
+ }
+ for (const [k, m] of this.terrain) if (!seenT.has(k)) m.visible = false;
+
+ const seenE = new Set();
+ world.forEach('sprite', (sprite, entity) => {
+ if (sprite.visible === false) return;
+ const pos = world.getByName('position', entity);
+ if (!pos) return;
+ const key = `e:${entity.id}`;
+ seenE.add(key);
+ let mesh = this.entities.get(key);
+ const color = sprite.textureId;
+ if (!mesh) {
+ mesh = new THREE.Mesh(this.box, this.mat(color));
+ this.entities.set(key, mesh);
+ this.scene.add(mesh);
+ } else {
+ mesh.material = this.mat(color);
+ }
+ const h = Math.max(0.4, Math.min(sprite.width, sprite.height) * 0.8);
+ mesh.position.set(
+ pos.tileX + pos.offsetX + (sprite.width - 1) * 0.5,
+ h / 2 + 0.35,
+ pos.tileY + pos.offsetY + (sprite.height - 1) * 0.5,
+ );
+ mesh.scale.set(sprite.width * 0.85, h, sprite.height * 0.85);
+ mesh.visible = true;
+ });
+ for (const [k, m] of this.entities) if (!seenE.has(k)) m.visible = false;
+
+ this.renderer.render(this.scene, this.camera);
+ }
+
+ dispose(): void {
+ for (const m of this.materials.values()) m.dispose();
+ this.box.dispose();
+ this.renderer.dispose();
+ }
+}
diff --git a/web/src/game/ai/AIPlayerSystem.ts b/web/src/game/ai/AIPlayerSystem.ts
new file mode 100644
index 00000000..3f0cf2f2
--- /dev/null
+++ b/web/src/game/ai/AIPlayerSystem.ts
@@ -0,0 +1,58 @@
+import type { World } from '../../engine/ecs/World';
+import type { PlayerManager } from '../player/PlayerManager';
+import type { InventoryComponent, PlayerComponent, PositionComponent } from '../components';
+import { inventoryCount } from '../components';
+import type { Direction } from '../components';
+
+export type AIDifficulty = 'easy' | 'normal' | 'hard' | 'expert';
+
+const SPEEDS: Record = {
+ easy: 0.5,
+ normal: 1,
+ hard: 1.5,
+ expert: 2,
+};
+
+export class AIPlayerSystem {
+ difficulty: AIDifficulty = 'normal';
+ private timers = new Map();
+
+ constructor(
+ private world: World,
+ private players: PlayerManager,
+ private placeBuilding: (playerId: number, id: string, x: number, y: number, dir: Direction) => boolean,
+ ) {}
+
+ update(dt: number): void {
+ const scale = SPEEDS[this.difficulty];
+ for (const p of this.players.all) {
+ if (!p.isAI) continue;
+ const t = (this.timers.get(p.playerId) ?? 0) + dt * scale;
+ this.timers.set(p.playerId, t);
+ if (t < 2) continue;
+ this.timers.set(p.playerId, 0);
+
+ const pos = this.world.getByName('position', p.entity)!;
+ const inv = this.world.getByName('inventory', p.entity)!;
+ const pc = this.world.getByName('player', p.entity)!;
+
+ // Simple opening: place miners/furnaces/belts near spawn
+ const ox = (p.playerId % 5) * 12;
+ const oy = Math.floor(p.playerId / 5) * 12;
+
+ if (inventoryCount(inv, 'burner-mining-drill') > 0) {
+ this.placeBuilding(p.playerId, 'burner-mining-drill', pos.tileX + 2 + ox % 3, pos.tileY + oy % 3, 1);
+ } else if (inventoryCount(inv, 'stone-furnace') > 0) {
+ this.placeBuilding(p.playerId, 'stone-furnace', pos.tileX + 5, pos.tileY, 1);
+ } else if (inventoryCount(inv, 'transport-belt') > 0) {
+ this.placeBuilding(p.playerId, 'transport-belt', pos.tileX + 1, pos.tileY + 1, 1);
+ } else if (inventoryCount(inv, 'gun-turret') > 0) {
+ this.placeBuilding(p.playerId, 'gun-turret', pos.tileX - 2, pos.tileY - 2, 1);
+ }
+
+ // Wander
+ pc.moveX = Math.sin(t + p.playerId) * 0.3;
+ pc.moveY = Math.cos(t * 0.7 + p.playerId) * 0.3;
+ }
+ }
+}
diff --git a/web/src/game/components.ts b/web/src/game/components.ts
new file mode 100644
index 00000000..bef05cca
--- /dev/null
+++ b/web/src/game/components.ts
@@ -0,0 +1,339 @@
+import type { Entity } from '../engine/ecs/Entity';
+
+export type Direction = 0 | 1 | 2 | 3; // N E S W
+
+export interface PositionComponent {
+ tileX: number;
+ tileY: number;
+ offsetX: number;
+ offsetY: number;
+}
+
+export interface SpriteAnimation {
+ frames: string[];
+ frameTime: number;
+ currentFrame: number;
+ elapsedTime: number;
+ isLooping: boolean;
+ isPlaying: boolean;
+}
+
+export function createSpriteAnimation(frames: string[], frameTime = 0.08, isLooping = true): SpriteAnimation {
+ return {
+ frames,
+ frameTime,
+ currentFrame: 0,
+ elapsedTime: 0,
+ isLooping,
+ isPlaying: false,
+ };
+}
+
+export function updateSpriteAnimation(anim: SpriteAnimation, deltaTime: number): string | null {
+ if (!anim.isPlaying || anim.frames.length === 0) return null;
+ anim.elapsedTime += deltaTime;
+ while (anim.elapsedTime >= anim.frameTime) {
+ anim.elapsedTime -= anim.frameTime;
+ anim.currentFrame += 1;
+ if (anim.currentFrame >= anim.frames.length) {
+ if (anim.isLooping) anim.currentFrame = 0;
+ else {
+ anim.currentFrame = anim.frames.length - 1;
+ anim.isPlaying = false;
+ }
+ }
+ }
+ return anim.frames[anim.currentFrame] ?? null;
+}
+
+export interface SpriteComponent {
+ textureId: string;
+ width: number;
+ height: number;
+ layer: RenderLayer;
+ color?: string;
+ rotation?: number;
+ visible?: boolean;
+ animation?: SpriteAnimation;
+}
+
+export enum RenderLayer {
+ Ground = 0,
+ GroundDecoration = 1,
+ Shadow = 2,
+ Building = 3,
+ Entity = 4,
+ Item = 5,
+ Enemy = 6,
+ Projectile = 7,
+ Particle = 8,
+ Ui = 9,
+}
+
+export interface HealthComponent {
+ current: number;
+ max: number;
+}
+
+export interface InventorySlot {
+ itemId: string | null;
+ count: number;
+}
+
+export interface InventoryComponent {
+ slots: InventorySlot[];
+}
+
+export interface PlayerComponent {
+ playerId: number;
+ name: string;
+ isAI: boolean;
+ moveX: number;
+ moveY: number;
+ craftQueue: Array<{ recipeId: string; remaining: number }>;
+ facing?: 'down' | 'up' | 'left' | 'right';
+}
+
+export interface OwnershipComponent {
+ playerId: number;
+}
+
+export interface MinerComponent {
+ buildingId: string;
+ progress: number;
+ miningSpeed: number;
+}
+
+export interface BeltItem {
+ itemId: string;
+ progress: number; // 0-1 along belt
+ lane: 0 | 1;
+}
+
+export interface BeltComponent {
+ buildingId: string;
+ direction: Direction;
+ speed: number;
+ items: BeltItem[];
+}
+
+export interface InserterComponent {
+ buildingId: string;
+ direction: Direction;
+ reach: number;
+ speed: number;
+ stackSize: number;
+ progress: number;
+ heldItem: string | null;
+ heldCount: number;
+ pickupEntity?: Entity;
+ dropEntity?: Entity;
+}
+
+export interface FurnaceComponent {
+ buildingId: string;
+ progress: number;
+ recipeId: string | null;
+ fuelRemaining: number;
+}
+
+export interface AssemblerComponent {
+ buildingId: string;
+ progress: number;
+ recipeId: string | null;
+ craftingSpeed: number;
+}
+
+export interface ChestComponent {
+ buildingId: string;
+}
+
+export interface PowerPoleComponent {
+ buildingId: string;
+ supplyRadius: number;
+ wireReach: number;
+}
+
+export interface GeneratorComponent {
+ buildingId: string;
+ powerOutput: number; // Watts
+ active: boolean;
+}
+
+export interface PowerConsumerComponent {
+ buildingId: string;
+ powerDraw: number;
+ powered: boolean;
+}
+
+export interface SolarPanelComponent {
+ buildingId: string;
+ powerOutput: number;
+}
+
+export interface AccumulatorComponent {
+ buildingId: string;
+ capacity: number;
+ stored: number;
+}
+
+export interface LabComponent {
+ buildingId: string;
+ progress: number;
+}
+
+export interface PipeComponent {
+ buildingId: string;
+ fluidId: string | null;
+ amount: number;
+ capacity: number;
+}
+
+export interface FluidProducerComponent {
+ buildingId: string;
+ fluidId: string;
+ rate: number;
+}
+
+export interface BoilerComponent {
+ buildingId: string;
+ fuelRemaining: number;
+ temperature: number;
+}
+
+export interface SteamEngineComponent {
+ buildingId: string;
+ powerOutput: number;
+}
+
+export interface TurretComponent {
+ buildingId: string;
+ range: number;
+ damage: number;
+ cooldown: number;
+ fireTimer: number;
+ usesAmmo: boolean;
+}
+
+export interface WallComponent {
+ buildingId: string;
+}
+
+export interface EnemyComponent {
+ kind: string;
+ damage: number;
+ speed: number;
+ aggroRange: number;
+ attackCooldown: number;
+ attackTimer: number;
+}
+
+export interface UnitComponent {
+ unitType: string;
+ ownerId: number;
+ command: 'idle' | 'move' | 'attack' | 'hold' | 'patrol';
+ targetX?: number;
+ targetY?: number;
+ targetEntity?: Entity;
+ speed: number;
+ damage: number;
+ range: number;
+}
+
+export interface UnitProductionComponent {
+ buildingId: string;
+ progress: number;
+ queue: string[];
+}
+
+export interface RocketSiloComponent {
+ buildingId: string;
+ parts: number;
+ partsRequired: number;
+ hasSatellite: boolean;
+ launching: boolean;
+ launchProgress: number;
+}
+
+export interface TreeComponent {
+ woodRemaining: number;
+}
+
+export interface VelocityComponent {
+ vx: number;
+ vy: number;
+}
+
+export interface ProjectileComponent {
+ damage: number;
+ ownerId: number;
+ ttl: number;
+}
+
+export function emptyInventory(size: number): InventoryComponent {
+ return {
+ slots: Array.from({ length: size }, () => ({ itemId: null, count: 0 })),
+ };
+}
+
+export function inventoryAdd(inv: InventoryComponent, itemId: string, count: number, stackSize = 1000): number {
+ let remaining = count;
+ for (const slot of inv.slots) {
+ if (slot.itemId === itemId && slot.count < stackSize) {
+ const space = stackSize - slot.count;
+ const add = Math.min(space, remaining);
+ slot.count += add;
+ remaining -= add;
+ if (remaining <= 0) return 0;
+ }
+ }
+ for (const slot of inv.slots) {
+ if (!slot.itemId) {
+ const add = Math.min(stackSize, remaining);
+ slot.itemId = itemId;
+ slot.count = add;
+ remaining -= add;
+ if (remaining <= 0) return 0;
+ }
+ }
+ return remaining;
+}
+
+export function inventoryCount(inv: InventoryComponent, itemId: string): number {
+ return inv.slots.reduce((n, s) => (s.itemId === itemId ? n + s.count : n), 0);
+}
+
+export function inventoryRemove(inv: InventoryComponent, itemId: string, count: number): boolean {
+ if (inventoryCount(inv, itemId) < count) return false;
+ let remaining = count;
+ for (const slot of inv.slots) {
+ if (slot.itemId === itemId) {
+ const take = Math.min(slot.count, remaining);
+ slot.count -= take;
+ remaining -= take;
+ if (slot.count <= 0) {
+ slot.itemId = null;
+ slot.count = 0;
+ }
+ if (remaining <= 0) return true;
+ }
+ }
+ return remaining <= 0;
+}
+
+export function dirOffset(d: Direction): [number, number] {
+ switch (d) {
+ case 0:
+ return [0, -1];
+ case 1:
+ return [1, 0];
+ case 2:
+ return [0, 1];
+ case 3:
+ return [-1, 0];
+ }
+}
+
+export function rotateDir(d: Direction, delta = 1): Direction {
+ return ((((d + delta) % 4) + 4) % 4) as Direction;
+}
diff --git a/web/src/game/data/Registries.ts b/web/src/game/data/Registries.ts
new file mode 100644
index 00000000..3a945e92
--- /dev/null
+++ b/web/src/game/data/Registries.ts
@@ -0,0 +1,203 @@
+import itemsData from '@shared/data/items.json';
+import recipesData from '@shared/data/recipes.json';
+import techsData from '@shared/data/techs.json';
+import startingItemsData from '@shared/data/starting_items.json';
+
+import miners from '@shared/data/buildings/miners.json';
+import furnaces from '@shared/data/buildings/furnaces.json';
+import assemblers from '@shared/data/buildings/assemblers.json';
+import belts from '@shared/data/buildings/belts.json';
+import inserters from '@shared/data/buildings/inserters.json';
+import power from '@shared/data/buildings/power.json';
+import fluids from '@shared/data/buildings/fluids.json';
+import storage from '@shared/data/buildings/storage.json';
+import combat from '@shared/data/buildings/combat.json';
+import rockets from '@shared/data/buildings/rockets.json';
+import nuclear from '@shared/data/buildings/nuclear.json';
+import units from '@shared/data/buildings/units.json';
+
+export interface ItemDef {
+ id: string;
+ name: string;
+ stackSize: number;
+ category: string;
+ order: string;
+ fuelValue?: number | null;
+ fuelCategory?: string | null;
+ placedAs?: string | null;
+}
+
+export interface ItemStack {
+ itemId: string;
+ count: number;
+}
+
+export interface RecipeDef {
+ id: string;
+ name: string;
+ inputs: ItemStack[];
+ outputs: ItemStack[];
+ craftTime: number;
+ category: string;
+ order: string;
+ enabled?: boolean;
+}
+
+export interface TechDef {
+ id: string;
+ name: string;
+ description: string;
+ cost: ItemStack[];
+ researchTime: number;
+ unlocks: { recipes?: string[]; buildings?: string[] };
+ prerequisites?: string[];
+ order: string;
+ tier: number;
+}
+
+export interface BuildingDef {
+ id: string;
+ name: string;
+ type: string;
+ width?: number;
+ height?: number;
+ maxHealth?: number;
+ cost?: ItemStack[];
+ miningSpeed?: number;
+ beltSpeed?: number;
+ craftingSpeed?: number;
+ powerConsumption?: number;
+ powerProduction?: number;
+ supplyArea?: number;
+ wireReach?: number;
+ insertionSpeed?: number;
+ stackSize?: number;
+ reach?: number;
+ fuelSlots?: number;
+ inputSlots?: number;
+ outputSlots?: number;
+ fluidCapacity?: number;
+ range?: number;
+ damage?: number;
+ [key: string]: unknown;
+}
+
+export class ItemRegistry {
+ private items = new Map();
+
+ constructor() {
+ for (const item of itemsData as ItemDef[]) this.items.set(item.id, item);
+ }
+
+ get(id: string): ItemDef | undefined {
+ return this.items.get(id);
+ }
+
+ get all(): ItemDef[] {
+ return [...this.items.values()].sort((a, b) => a.order.localeCompare(b.order));
+ }
+
+ stackSize(id: string): number {
+ return this.items.get(id)?.stackSize ?? 100;
+ }
+
+ isFuel(id: string): boolean {
+ return (this.items.get(id)?.fuelValue ?? 0) > 0;
+ }
+
+ fuelValue(id: string): number {
+ return this.items.get(id)?.fuelValue ?? 0;
+ }
+}
+
+export class RecipeRegistry {
+ private recipes = new Map();
+
+ constructor() {
+ for (const r of recipesData as RecipeDef[]) this.recipes.set(r.id, r);
+ }
+
+ get(id: string): RecipeDef | undefined {
+ return this.recipes.get(id);
+ }
+
+ get all(): RecipeDef[] {
+ return [...this.recipes.values()].sort((a, b) => a.order.localeCompare(b.order));
+ }
+
+ byCategory(category: string): RecipeDef[] {
+ return this.all.filter((r) => r.category === category);
+ }
+
+ forSmelting(inputItemId: string): RecipeDef | undefined {
+ return this.all.find(
+ (r) => r.category === 'smelting' && r.inputs.some((i) => i.itemId === inputItemId),
+ );
+ }
+}
+
+export class TechnologyRegistry {
+ private techs = new Map();
+
+ constructor() {
+ for (const t of techsData as TechDef[]) this.techs.set(t.id, t);
+ }
+
+ get(id: string): TechDef | undefined {
+ return this.techs.get(id);
+ }
+
+ get all(): TechDef[] {
+ return [...this.techs.values()].sort((a, b) => a.order.localeCompare(b.order));
+ }
+}
+
+export class BuildingRegistry {
+ private buildings = new Map();
+
+ constructor() {
+ const packs = [
+ miners,
+ furnaces,
+ assemblers,
+ belts,
+ inserters,
+ power,
+ fluids,
+ storage,
+ combat,
+ rockets,
+ nuclear,
+ units,
+ ] as Array;
+
+ for (const pack of packs) {
+ const list = Array.isArray(pack) ? pack : (pack.buildings ?? []);
+ for (const b of list) {
+ this.buildings.set(b.id, {
+ width: 1,
+ height: 1,
+ maxHealth: 100,
+ ...b,
+ });
+ }
+ }
+ }
+
+ get(id: string): BuildingDef | undefined {
+ return this.buildings.get(id);
+ }
+
+ get all(): BuildingDef[] {
+ return [...this.buildings.values()];
+ }
+
+ byType(type: string): BuildingDef[] {
+ return this.all.filter((b) => b.type === type);
+ }
+}
+
+export function getStartingItems(): ItemStack[] {
+ const data = startingItemsData as { startingItems: Array<{ itemId: string; count: number }> };
+ return data.startingItems.map((i) => ({ itemId: i.itemId, count: i.count }));
+}
diff --git a/web/src/game/data/SaveStore.ts b/web/src/game/data/SaveStore.ts
new file mode 100644
index 00000000..41b7c85c
--- /dev/null
+++ b/web/src/game/data/SaveStore.ts
@@ -0,0 +1,26 @@
+import type { GameSave } from '@shared/save/types';
+
+const PREFIX = 'ff-save-';
+
+export function listSaves(): string[] {
+ const keys: string[] = [];
+ for (let i = 0; i < localStorage.length; i++) {
+ const k = localStorage.key(i);
+ if (k?.startsWith(PREFIX)) keys.push(k.slice(PREFIX.length));
+ }
+ return keys.sort();
+}
+
+export function writeSave(slot: string, save: GameSave): void {
+ localStorage.setItem(PREFIX + slot, JSON.stringify(save));
+}
+
+export function readSave(slot: string): GameSave | null {
+ const raw = localStorage.getItem(PREFIX + slot);
+ if (!raw) return null;
+ return JSON.parse(raw) as GameSave;
+}
+
+export function deleteSave(slot: string): void {
+ localStorage.removeItem(PREFIX + slot);
+}
diff --git a/web/src/game/multiplayer/LobbyAndVictory.ts b/web/src/game/multiplayer/LobbyAndVictory.ts
new file mode 100644
index 00000000..6cdb96e9
--- /dev/null
+++ b/web/src/game/multiplayer/LobbyAndVictory.ts
@@ -0,0 +1,142 @@
+export type MatchMode = 'freeForAll' | 'teamDeathmatch' | 'singlePlayer' | 'pvai';
+
+export interface LobbySlot {
+ slotId: number;
+ playerId: number | null;
+ name: string;
+ isAI: boolean;
+ team: number;
+ ready: boolean;
+}
+
+export interface MatchConfig {
+ mode: MatchMode;
+ maxPlayers: number;
+ seed: number;
+ friendlyFire: boolean;
+ tickRate: number;
+ difficulty?: string;
+}
+
+export class LobbySystem {
+ slots: LobbySlot[] = [];
+ config: MatchConfig = {
+ mode: 'singlePlayer',
+ maxPlayers: 8,
+ seed: Math.floor(Math.random() * 1e9),
+ friendlyFire: false,
+ tickRate: 60,
+ difficulty: 'normal',
+ };
+
+ reset(max = 8): void {
+ this.slots = Array.from({ length: max }, (_, i) => ({
+ slotId: i,
+ playerId: i === 0 ? 1 : null,
+ name: i === 0 ? 'Player' : '',
+ isAI: false,
+ team: i % 2,
+ ready: i === 0,
+ }));
+ }
+
+ addAI(): void {
+ const slot = this.slots.find((s) => s.playerId === null);
+ if (!slot) return;
+ slot.playerId = 1000 + slot.slotId;
+ slot.name = `AI-${slot.slotId}`;
+ slot.isAI = true;
+ slot.ready = true;
+ }
+
+ setReady(slotId: number, ready: boolean): void {
+ const s = this.slots[slotId];
+ if (s) s.ready = ready;
+ }
+
+ canStart(): boolean {
+ const filled = this.slots.filter((s) => s.playerId !== null);
+ return filled.length > 0 && filled.every((s) => s.ready);
+ }
+}
+
+export class VictorySystem {
+ winnerId: number | null = null;
+ reason: string | null = null;
+
+ check(
+ mode: MatchMode,
+ players: Array<{ playerId: number; alive: boolean; team: number }>,
+ rocketVictory: boolean,
+ localId: number,
+ ): void {
+ if (this.winnerId !== null) return;
+ if (mode === 'singlePlayer' && rocketVictory) {
+ this.winnerId = localId;
+ this.reason = 'Rocket launched with satellite';
+ return;
+ }
+ if (mode === 'pvai' || mode === 'freeForAll') {
+ const alive = players.filter((p) => p.alive);
+ if (alive.length === 1) {
+ this.winnerId = alive[0]!.playerId;
+ this.reason = 'Last standing';
+ }
+ }
+ if (mode === 'teamDeathmatch') {
+ const teams = new Map();
+ for (const p of players.filter((x) => x.alive)) {
+ teams.set(p.team, (teams.get(p.team) ?? 0) + 1);
+ }
+ if (teams.size === 1) {
+ const team = [...teams.keys()][0]!;
+ this.winnerId = players.find((p) => p.team === team)?.playerId ?? null;
+ this.reason = `Team ${team} wins`;
+ }
+ }
+ }
+
+ reset(): void {
+ this.winnerId = null;
+ this.reason = null;
+ }
+}
+
+export class FogOfWarSystem {
+ visible = new Set();
+ visionRadius = 24;
+
+ update(playerPositions: Array<{ x: number; y: number }>): void {
+ this.visible.clear();
+ for (const p of playerPositions) {
+ const r = this.visionRadius;
+ for (let y = -r; y <= r; y++) {
+ for (let x = -r; x <= r; x++) {
+ if (x * x + y * y <= r * r) {
+ this.visible.add(`${Math.floor(p.x + x)},${Math.floor(p.y + y)}`);
+ }
+ }
+ }
+ }
+ }
+
+ isVisible(tx: number, ty: number): boolean {
+ return this.visible.has(`${tx},${ty}`);
+ }
+}
+
+export class SpawnSystem {
+ constructor(private spawnRadius = 40) {}
+
+ positions(count: number): Array<{ x: number; y: number }> {
+ const out: Array<{ x: number; y: number }> = [];
+ for (let i = 0; i < count; i++) {
+ const angle = (i / count) * Math.PI * 2;
+ out.push({
+ x: Math.round(Math.cos(angle) * this.spawnRadius),
+ y: Math.round(Math.sin(angle) * this.spawnRadius),
+ });
+ }
+ return out;
+ }
+}
diff --git a/web/src/game/multiplayer/NetClient.ts b/web/src/game/multiplayer/NetClient.ts
new file mode 100644
index 00000000..0b117178
--- /dev/null
+++ b/web/src/game/multiplayer/NetClient.ts
@@ -0,0 +1,41 @@
+import { decodeMessage, encodeMessage, type NetworkMessage, type PlayerAction } from '@shared/net/protocol';
+
+export class NetClient {
+ private ws: WebSocket | null = null;
+ playerId: number | null = null;
+ connected = false;
+ onMessage: ((msg: NetworkMessage) => void) | null = null;
+
+ connect(url: string): void {
+ this.ws = new WebSocket(url);
+ this.ws.onopen = () => {
+ this.connected = true;
+ };
+ this.ws.onclose = () => {
+ this.connected = false;
+ };
+ this.ws.onmessage = (ev) => {
+ const msg = decodeMessage(String(ev.data));
+ if (msg.type === 'handshake') this.playerId = msg.playerId;
+ this.onMessage?.(msg);
+ };
+ }
+
+ send(msg: NetworkMessage): void {
+ if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(encodeMessage(msg));
+ }
+
+ sendCommand(action: PlayerAction): void {
+ if (this.playerId == null) return;
+ this.send({ type: 'command', action, playerId: this.playerId });
+ }
+
+ ping(): void {
+ this.send({ type: 'ping', timestamp: performance.now() });
+ }
+
+ disconnect(): void {
+ this.ws?.close();
+ this.ws = null;
+ }
+}
diff --git a/web/src/game/player/PlayerManager.ts b/web/src/game/player/PlayerManager.ts
new file mode 100644
index 00000000..518a920e
--- /dev/null
+++ b/web/src/game/player/PlayerManager.ts
@@ -0,0 +1,91 @@
+import type { World } from '../../engine/ecs/World';
+import type { Entity } from '../../engine/ecs/Entity';
+import { emptyInventory, inventoryAdd, RenderLayer, createSpriteAnimation, type InventoryComponent, type PlayerComponent } from '../components';
+import type { ItemRegistry } from '../data/Registries';
+import { getStartingItems } from '../data/Registries';
+import { playerFrames } from '../../engine/render/AssetCatalog';
+
+export interface PlayerInfo {
+ playerId: number;
+ entity: Entity;
+ name: string;
+ isAI: boolean;
+}
+
+export class PlayerManager {
+ private players = new Map();
+ private nextId = 1;
+
+ constructor(
+ private world: World,
+ private items: ItemRegistry,
+ ) {}
+
+ createPlayer(name: string, isAI: boolean, x = 0, y = 0): number {
+ const playerId = this.nextId++;
+ const inv = emptyInventory(70);
+ for (const stack of getStartingItems()) {
+ inventoryAdd(inv, stack.itemId, stack.count, this.items.stackSize(stack.itemId));
+ }
+ // Give starter placeables for web playability
+ for (const [id, count] of [
+ ['burner-mining-drill', 5],
+ ['stone-furnace', 4],
+ ['transport-belt', 100],
+ ['inserter', 20],
+ ['wooden-chest', 5],
+ ['small-electric-pole', 20],
+ ['boiler', 2],
+ ['steam-engine', 2],
+ ['pipe', 50],
+ ['water-pump', 1],
+ ['lab', 1],
+ ['assembling-machine-1', 2],
+ ['gun-turret', 2],
+ ['stone-wall', 20],
+ ] as const) {
+ inventoryAdd(inv, id, count as number, this.items.stackSize(id));
+ }
+
+ const entity = this.world
+ .spawnWith()
+ .with('position', { tileX: x, tileY: y, offsetX: 0.5, offsetY: 0.5 })
+ .with('sprite', {
+ textureId: 'player_down_0',
+ width: 1,
+ height: 1,
+ layer: RenderLayer.Entity,
+ animation: createSpriteAnimation(playerFrames('down'), 0.08, true),
+ })
+ .with('health', { current: 100, max: 100 })
+ .with('inventory', inv)
+ .with('player', {
+ playerId,
+ name,
+ isAI,
+ moveX: 0,
+ moveY: 0,
+ craftQueue: [],
+ facing: 'down' as const,
+ })
+ .with('ownership', { playerId })
+ .build();
+
+ this.players.set(playerId, { playerId, entity, name, isAI });
+ return playerId;
+ }
+
+ getPlayer(playerId: number): PlayerInfo | undefined {
+ return this.players.get(playerId);
+ }
+
+ get all(): PlayerInfo[] {
+ return [...this.players.values()];
+ }
+
+ getInventory(playerId: number): InventoryComponent | undefined {
+ const p = this.players.get(playerId);
+ if (!p) return undefined;
+ return this.world.getByName('inventory', p.entity);
+ }
+}
diff --git a/web/src/game/systems/Systems.ts b/web/src/game/systems/Systems.ts
new file mode 100644
index 00000000..ee85e38d
--- /dev/null
+++ b/web/src/game/systems/Systems.ts
@@ -0,0 +1,1039 @@
+import type { World } from '../../engine/ecs/World';
+import type { Entity } from '../../engine/ecs/Entity';
+import type { ChunkManager } from '../world/ChunkManager';
+import type { BuildingRegistry, ItemRegistry, RecipeRegistry, TechnologyRegistry } from '../data/Registries';
+import {
+ type AssemblerComponent,
+ type BeltComponent,
+ type BoilerComponent,
+ type Direction,
+ type EnemyComponent,
+ type FurnaceComponent,
+ type GeneratorComponent,
+ type HealthComponent,
+ type InserterComponent,
+ type InventoryComponent,
+ type LabComponent,
+ type MinerComponent,
+ type PipeComponent,
+ type PlayerComponent,
+ type PositionComponent,
+ type PowerConsumerComponent,
+ type PowerPoleComponent,
+ type ProjectileComponent,
+ type RocketSiloComponent,
+ type SolarPanelComponent,
+ type AccumulatorComponent,
+ type SteamEngineComponent,
+ type TreeComponent,
+ type TurretComponent,
+ type UnitComponent,
+ type UnitProductionComponent,
+ type VelocityComponent,
+ dirOffset,
+ emptyInventory,
+ inventoryAdd,
+ inventoryCount,
+ inventoryRemove,
+ RenderLayer,
+} from '../components';
+
+export interface System {
+ update(dt: number): void;
+}
+
+export class MiningSystem implements System {
+ constructor(
+ private world: World,
+ private chunks: ChunkManager,
+ private items: ItemRegistry,
+ private buildings: BuildingRegistry,
+ ) {}
+
+ update(dt: number): void {
+ this.world.forEach('miner', (miner, entity) => {
+ const pos = this.world.getByName('position', entity)!;
+ const inv = this.world.getByName('inventory', entity);
+ if (!inv) return;
+ const def = this.buildings.get(miner.buildingId);
+ const isBurner = miner.buildingId.includes('burner');
+ if (isBurner) {
+ const fuelOk =
+ inventoryCount(inv, 'coal') > 0 ||
+ inventoryCount(inv, 'wood') > 0 ||
+ inventoryCount(inv, 'solid-fuel') > 0;
+ if (!fuelOk) return;
+ } else {
+ const power = this.world.getByName('powerConsumer', entity);
+ if (power && !power.powered) return;
+ }
+
+ const tile = this.chunks.getTile(pos.tileX, pos.tileY);
+ if (!tile?.resource || tile.resource.type === 'crude-oil') return;
+
+ miner.progress += miner.miningSpeed * (def?.miningSpeed ?? 1) * dt;
+ if (miner.progress >= 1) {
+ miner.progress = 0;
+ const left = inventoryAdd(inv, tile.resource.type, 1, this.items.stackSize(tile.resource.type));
+ if (left === 0) {
+ tile.resource.amount -= 1;
+ if (tile.resource.amount <= 0) tile.resource = null;
+ if (isBurner && Math.random() < 0.05) {
+ for (const fuel of ['coal', 'wood', 'solid-fuel']) {
+ if (inventoryCount(inv, fuel) > 0) {
+ inventoryRemove(inv, fuel, 1);
+ break;
+ }
+ }
+ }
+ this.chunks.addPollution(pos.tileX, pos.tileY, 0.5);
+ }
+ }
+ });
+ }
+}
+
+export class BeltSystem implements System {
+ constructor(private world: World) {}
+
+ update(dt: number): void {
+ this.world.forEach('belt', (belt) => {
+ for (const item of belt.items) {
+ item.progress += belt.speed * dt;
+ }
+ belt.items = belt.items.filter((i) => i.progress < 1.05);
+ });
+
+ // Transfer between adjacent belts
+ this.world.forEach('belt', (belt, entity) => {
+ const pos = this.world.getByName('position', entity)!;
+ const [dx, dy] = dirOffset(belt.direction);
+ const next = this.world.entityAt(pos.tileX + dx, pos.tileY + dy);
+ if (!next) return;
+ const nextBelt = this.world.getByName('belt', next);
+ if (!nextBelt) return;
+ const transferring = belt.items.filter((i) => i.progress >= 1);
+ belt.items = belt.items.filter((i) => i.progress < 1);
+ for (const item of transferring) {
+ if (nextBelt.items.length < 4) {
+ nextBelt.items.push({ itemId: item.itemId, progress: 0, lane: item.lane });
+ } else {
+ item.progress = 0.95;
+ belt.items.push(item);
+ }
+ }
+ });
+ }
+
+ tryInsert(entity: Entity, itemId: string): boolean {
+ const belt = this.world.getByName('belt', entity);
+ if (!belt || belt.items.length >= 4) return false;
+ belt.items.push({ itemId, progress: 0.1, lane: 0 });
+ return true;
+ }
+
+ tryExtract(entity: Entity): string | null {
+ const belt = this.world.getByName('belt', entity);
+ if (!belt || belt.items.length === 0) return null;
+ const item = belt.items.shift()!;
+ return item.itemId;
+ }
+}
+
+export class InserterSystem implements System {
+ constructor(
+ private world: World,
+ private belts: BeltSystem,
+ private items: ItemRegistry,
+ ) {}
+
+ update(dt: number): void {
+ this.world.forEach('inserter', (ins, entity) => {
+ const power = this.world.getByName('powerConsumer', entity);
+ if (power && !power.powered && !ins.buildingId.includes('burner')) return;
+ const pos = this.world.getByName('position', entity)!;
+ const [dx, dy] = dirOffset(ins.direction);
+ const dropPos = { x: pos.tileX + dx * ins.reach, y: pos.tileY + dy * ins.reach };
+ const pickPos = { x: pos.tileX - dx * ins.reach, y: pos.tileY - dy * ins.reach };
+
+ if (!ins.heldItem) {
+ ins.progress += ins.speed * dt;
+ if (ins.progress < 1) return;
+ ins.progress = 0;
+ const src = this.world.entityAt(pickPos.x, pickPos.y);
+ if (!src) return;
+ const fromBelt = this.belts.tryExtract(src);
+ if (fromBelt) {
+ ins.heldItem = fromBelt;
+ ins.heldCount = 1;
+ return;
+ }
+ const inv = this.world.getByName('inventory', src);
+ if (!inv) return;
+ for (const slot of inv.slots) {
+ if (slot.itemId && slot.count > 0) {
+ const take = Math.min(ins.stackSize, slot.count);
+ ins.heldItem = slot.itemId;
+ ins.heldCount = take;
+ inventoryRemove(inv, slot.itemId, take);
+ break;
+ }
+ }
+ } else {
+ ins.progress += ins.speed * dt;
+ if (ins.progress < 1) return;
+ ins.progress = 0;
+ const dst = this.world.entityAt(dropPos.x, dropPos.y);
+ if (!dst) return;
+ if (this.world.hasByName('belt', dst)) {
+ if (this.belts.tryInsert(dst, ins.heldItem)) {
+ ins.heldCount--;
+ if (ins.heldCount <= 0) ins.heldItem = null;
+ }
+ return;
+ }
+ const inv = this.world.getByName('inventory', dst);
+ if (!inv) return;
+ const left = inventoryAdd(inv, ins.heldItem, ins.heldCount, this.items.stackSize(ins.heldItem));
+ if (left < ins.heldCount) {
+ ins.heldCount = left;
+ if (left <= 0) ins.heldItem = null;
+ }
+ }
+ });
+ }
+}
+
+export class CraftingSystem implements System {
+ constructor(
+ private world: World,
+ private recipes: RecipeRegistry,
+ private items: ItemRegistry,
+ ) {}
+
+ update(dt: number): void {
+ // Furnaces
+ this.world.forEach('furnace', (furnace, entity) => {
+ const inv = this.world.getByName('inventory', entity);
+ if (!inv) return;
+ const power = this.world.getByName('powerConsumer', entity);
+ const electric = furnace.buildingId.includes('electric');
+ if (electric && power && !power.powered) return;
+
+ if (!furnace.recipeId) {
+ for (const slot of inv.slots) {
+ if (!slot.itemId) continue;
+ const recipe = this.recipes.forSmelting(slot.itemId);
+ if (recipe) {
+ furnace.recipeId = recipe.id;
+ break;
+ }
+ }
+ }
+ const recipe = furnace.recipeId ? this.recipes.get(furnace.recipeId) : undefined;
+ if (!recipe) return;
+
+ if (!electric) {
+ if (furnace.fuelRemaining <= 0) {
+ for (const fuel of ['coal', 'wood', 'solid-fuel']) {
+ if (inventoryRemove(inv, fuel, 1)) {
+ furnace.fuelRemaining = this.items.fuelValue(fuel) / 1000;
+ break;
+ }
+ }
+ }
+ if (furnace.fuelRemaining <= 0) return;
+ furnace.fuelRemaining -= dt;
+ }
+
+ if (!recipe.inputs.every((i) => inventoryCount(inv, i.itemId) >= i.count)) {
+ furnace.progress = 0;
+ return;
+ }
+ furnace.progress += dt / recipe.craftTime;
+ if (furnace.progress >= 1) {
+ furnace.progress = 0;
+ for (const i of recipe.inputs) inventoryRemove(inv, i.itemId, i.count);
+ for (const o of recipe.outputs) inventoryAdd(inv, o.itemId, o.count, this.items.stackSize(o.itemId));
+ }
+ });
+
+ // Assemblers
+ this.world.forEach('assembler', (asm, entity) => {
+ if (!asm.recipeId) return;
+ const power = this.world.getByName('powerConsumer', entity);
+ if (power && !power.powered) return;
+ const inv = this.world.getByName('inventory', entity);
+ const recipe = this.recipes.get(asm.recipeId);
+ if (!inv || !recipe) return;
+ if (!recipe.inputs.every((i) => inventoryCount(inv, i.itemId) >= i.count)) {
+ asm.progress = 0;
+ return;
+ }
+ asm.progress += (asm.craftingSpeed * dt) / recipe.craftTime;
+ if (asm.progress >= 1) {
+ asm.progress = 0;
+ for (const i of recipe.inputs) inventoryRemove(inv, i.itemId, i.count);
+ for (const o of recipe.outputs) inventoryAdd(inv, o.itemId, o.count, this.items.stackSize(o.itemId));
+ }
+ });
+
+ // Player hand crafting
+ this.world.forEach('player', (player, entity) => {
+ const inv = this.world.getByName('inventory', entity);
+ if (!inv || player.craftQueue.length === 0) return;
+ const job = player.craftQueue[0]!;
+ const recipe = this.recipes.get(job.recipeId);
+ if (!recipe) {
+ player.craftQueue.shift();
+ return;
+ }
+ if (!recipe.inputs.every((i) => inventoryCount(inv, i.itemId) >= i.count)) return;
+ job.remaining -= dt;
+ if (job.remaining <= 0) {
+ for (const i of recipe.inputs) inventoryRemove(inv, i.itemId, i.count);
+ for (const o of recipe.outputs) inventoryAdd(inv, o.itemId, o.count, this.items.stackSize(o.itemId));
+ player.craftQueue.shift();
+ }
+ });
+ }
+}
+
+export class PowerSystem implements System {
+ networks: Array<{ poles: Entity[]; producers: number; consumers: number; satisfaction: number }> = [];
+
+ constructor(private world: World) {}
+
+ update(_dt: number): void {
+ const poles = this.world.entries('powerPole');
+ const visited = new Set();
+ this.networks = [];
+
+ for (const { entity } of poles) {
+ if (visited.has(entity.id)) continue;
+ const group: Entity[] = [];
+ const queue = [entity];
+ visited.add(entity.id);
+ while (queue.length) {
+ const cur = queue.shift()!;
+ group.push(cur);
+ const pos = this.world.getByName('position', cur)!;
+ const pole = this.world.getByName('powerPole', cur)!;
+ for (const { entity: other } of poles) {
+ if (visited.has(other.id)) continue;
+ const op = this.world.getByName('position', other)!;
+ const reach = Math.max(pole.wireReach, this.world.getByName('powerPole', other)!.wireReach);
+ if (Math.hypot(pos.tileX - op.tileX, pos.tileY - op.tileY) <= reach) {
+ visited.add(other.id);
+ queue.push(other);
+ }
+ }
+ }
+
+ let production = 0;
+ let consumption = 0;
+ const covered = new Set();
+
+ for (const poleE of group) {
+ const pos = this.world.getByName('position', poleE)!;
+ const pole = this.world.getByName('powerPole', poleE)!;
+ this.world.forEach('position', (p, e) => {
+ if (Math.hypot(p.tileX - pos.tileX, p.tileY - pos.tileY) > pole.supplyRadius) return;
+ covered.add(e.id);
+ });
+ }
+
+ this.world.forEach('generator', (g, e) => {
+ if (covered.has(e.id) && g.active) production += g.powerOutput;
+ });
+ this.world.forEach('solar', (s, e) => {
+ if (covered.has(e.id)) production += s.powerOutput * 0.7; // day average
+ });
+ this.world.forEach('steamEngine', (s, e) => {
+ if (covered.has(e.id)) production += s.powerOutput;
+ });
+ this.world.forEach('powerConsumer', (c, e) => {
+ if (covered.has(e.id)) consumption += c.powerDraw;
+ });
+
+ // Accumulators charge/discharge
+ let storedAvail = 0;
+ this.world.forEach('accumulator', (a, e) => {
+ if (covered.has(e.id)) storedAvail += a.stored;
+ });
+ let satisfaction = consumption <= 0 ? 1 : Math.min(1, (production + storedAvail * 60) / consumption);
+ const surplus = production - consumption * satisfaction;
+ this.world.forEach('accumulator', (a, e) => {
+ if (!covered.has(e.id)) return;
+ if (surplus > 0) a.stored = Math.min(a.capacity, a.stored + surplus * 0.01);
+ else if (satisfaction < 1) a.stored = Math.max(0, a.stored - a.capacity * 0.002);
+ });
+
+ this.world.forEach('powerConsumer', (c, e) => {
+ c.powered = covered.has(e.id) && satisfaction > 0.5;
+ });
+
+ this.networks.push({ poles: group, producers: production, consumers: consumption, satisfaction });
+ }
+
+ // Entities not near any pole
+ if (poles.length === 0) {
+ this.world.forEach('powerConsumer', (c) => {
+ c.powered = false;
+ });
+ }
+ }
+}
+
+export class FluidNetworkSystem implements System {
+ constructor(
+ private world: World,
+ private buildings: BuildingRegistry,
+ private items: ItemRegistry,
+ ) {}
+
+ update(dt: number): void {
+ // Water pumps
+ this.world.forEach('pipe', (pipe, entity) => {
+ const building = this.world.getByName<{ buildingId: string }>('fluidProducer' as never, entity);
+ void building;
+ });
+
+ this.world.forEach<{ buildingId: string; fluidId: string; rate: number }>('fluidProducer', (prod, entity) => {
+ const pipe = this.world.getByName('pipe', entity);
+ const pos = this.world.getByName('position', entity);
+ if (!pos) return;
+ if (prod.buildingId === 'water-pump' || prod.fluidId === 'water') {
+ // Must be near water - simplified: always produce if labeled water-pump
+ const target = pipe ?? this.findNearbyPipe(pos.tileX, pos.tileY);
+ if (target) {
+ target.fluidId = 'water';
+ target.amount = Math.min(target.capacity, target.amount + prod.rate * dt);
+ }
+ }
+ if (prod.fluidId === 'crude-oil') {
+ // pumpjack
+ }
+ });
+
+ // Equalize adjacent pipes
+ const pipes = this.world.entries('pipe');
+ for (const { entity, component: a } of pipes) {
+ const pos = this.world.getByName('position', entity)!;
+ for (const [dx, dy] of [
+ [1, 0],
+ [0, 1],
+ ] as const) {
+ const otherE = this.world.entityAt(pos.tileX + dx, pos.tileY + dy);
+ if (!otherE) continue;
+ const b = this.world.getByName('pipe', otherE);
+ if (!b) continue;
+ if (a.fluidId && b.fluidId && a.fluidId !== b.fluidId) continue;
+ const fluid = a.fluidId ?? b.fluidId;
+ if (!fluid) continue;
+ a.fluidId = fluid;
+ b.fluidId = fluid;
+ const total = a.amount + b.amount;
+ const avg = total / 2;
+ a.amount = avg;
+ b.amount = avg;
+ }
+ }
+
+ // Boilers: water + fuel -> steam into connected pipes (stored as fluid steam via petroleum workaround: use light-oil id? use 'steam' as fluid id)
+ this.world.forEach('boiler', (boiler, entity) => {
+ const inv = this.world.getByName('inventory', entity);
+ const pos = this.world.getByName('position', entity)!;
+ if (!inv) return;
+ if (boiler.fuelRemaining <= 0) {
+ for (const fuel of ['coal', 'wood', 'solid-fuel']) {
+ if (inventoryRemove(inv, fuel, 1)) {
+ boiler.fuelRemaining = this.items.fuelValue(fuel) / 500;
+ break;
+ }
+ }
+ }
+ if (boiler.fuelRemaining <= 0) return;
+ const waterPipe = this.findNearbyPipe(pos.tileX, pos.tileY, 'water');
+ if (!waterPipe || waterPipe.amount < 1) return;
+ boiler.fuelRemaining -= dt;
+ waterPipe.amount -= 0.5 * dt;
+ const steamPipe = this.findNearbyPipe(pos.tileX + 1, pos.tileY) ?? this.findNearbyPipe(pos.tileX, pos.tileY + 1);
+ if (steamPipe) {
+ steamPipe.fluidId = 'steam';
+ steamPipe.amount = Math.min(steamPipe.capacity, steamPipe.amount + 2 * dt);
+ }
+ boiler.temperature = 165;
+ });
+
+ // Steam engines
+ this.world.forEach('steamEngine', (engine, entity) => {
+ const pos = this.world.getByName('position', entity)!;
+ const steam = this.findNearbyPipe(pos.tileX, pos.tileY, 'steam');
+ const gen = this.world.getByName('generator', entity);
+ if (steam && steam.amount > 0.5) {
+ steam.amount -= 1 * dt;
+ engine.powerOutput = 900;
+ if (gen) {
+ gen.powerOutput = 900;
+ gen.active = true;
+ }
+ } else {
+ engine.powerOutput = 0;
+ if (gen) gen.active = false;
+ }
+ });
+
+ // Pumpjacks on oil
+ this.world.forEach<{ buildingId: string }>('pumpjack', (pj, entity) => {
+ void pj;
+ const pos = this.world.getByName('position', entity)!;
+ // handled via fluidProducer
+ const pipe = this.findNearbyPipe(pos.tileX, pos.tileY);
+ if (pipe) {
+ pipe.fluidId = 'crude-oil';
+ pipe.amount = Math.min(pipe.capacity, pipe.amount + 2 * dt);
+ }
+ });
+
+ void this.buildings;
+ }
+
+ private findNearbyPipe(x: number, y: number, fluid?: string): PipeComponent | null {
+ for (const [dx, dy] of [
+ [0, 0],
+ [1, 0],
+ [-1, 0],
+ [0, 1],
+ [0, -1],
+ ] as const) {
+ const e = this.world.entityAt(x + dx, y + dy);
+ if (!e) continue;
+ const p = this.world.getByName('pipe', e);
+ if (!p) continue;
+ if (fluid && p.fluidId && p.fluidId !== fluid) continue;
+ return p;
+ }
+ return null;
+ }
+}
+
+export class ResearchSystem implements System {
+ currentResearchId: string | null = null;
+ progress = new Map();
+ completed = new Set();
+ unlockedRecipes = new Set([
+ 'iron-plate',
+ 'copper-plate',
+ 'iron-gear-wheel',
+ 'copper-cable',
+ 'electronic-circuit',
+ 'transport-belt',
+ 'inserter',
+ 'burner-mining-drill',
+ 'stone-furnace',
+ 'wooden-chest',
+ 'small-electric-pole',
+ 'pipe',
+ 'boiler',
+ 'steam-engine',
+ 'automation-science-pack',
+ 'firearm-magazine',
+ 'lab',
+ ]);
+
+ constructor(
+ private world: World,
+ private techs: TechnologyRegistry,
+ ) {}
+
+ update(dt: number): void {
+ if (!this.currentResearchId) return;
+ const tech = this.techs.get(this.currentResearchId);
+ if (!tech) return;
+
+ let labsWorking = 0;
+ this.world.forEach('lab', (lab, entity) => {
+ const power = this.world.getByName('powerConsumer', entity);
+ if (power && !power.powered) return;
+ const inv = this.world.getByName('inventory', entity);
+ if (!inv) return;
+ const hasPacks = tech.cost.every((c) => inventoryCount(inv, c.itemId) >= 1);
+ if (!hasPacks) return;
+ labsWorking++;
+ lab.progress += dt;
+ if (lab.progress >= 1) {
+ lab.progress = 0;
+ for (const c of tech.cost) inventoryRemove(inv, c.itemId, 1);
+ }
+ });
+
+ if (labsWorking <= 0) return;
+ const cur = this.progress.get(tech.id) ?? 0;
+ const next = cur + (dt * labsWorking) / tech.researchTime;
+ this.progress.set(tech.id, next);
+ if (next >= tech.cost.reduce((n, c) => n + c.count, 0) || next >= 1) {
+ // Complete when progress reaches 1 (normalized)
+ if (next >= 1) {
+ this.completed.add(tech.id);
+ for (const r of tech.unlocks.recipes ?? []) this.unlockedRecipes.add(r);
+ this.currentResearchId = null;
+ this.progress.set(tech.id, 1);
+ }
+ }
+ }
+
+ startResearch(id: string): boolean {
+ const tech = this.techs.get(id);
+ if (!tech || this.completed.has(id)) return false;
+ for (const pre of tech.prerequisites ?? []) {
+ if (!this.completed.has(pre)) return false;
+ }
+ this.currentResearchId = id;
+ return true;
+ }
+
+ serialize() {
+ return {
+ currentResearchId: this.currentResearchId,
+ progress: Object.fromEntries(this.progress),
+ completed: [...this.completed],
+ unlockedRecipes: [...this.unlockedRecipes],
+ };
+ }
+
+ load(data: {
+ currentResearchId: string | null;
+ progress: Record;
+ completed: string[];
+ unlockedRecipes: string[];
+ }): void {
+ this.currentResearchId = data.currentResearchId;
+ this.progress = new Map(Object.entries(data.progress ?? {}));
+ this.completed = new Set(data.completed ?? []);
+ this.unlockedRecipes = new Set(data.unlockedRecipes ?? this.unlockedRecipes);
+ }
+}
+
+export class PollutionSystem implements System {
+ constructor(
+ private world: World,
+ private chunks: ChunkManager,
+ ) {}
+
+ update(dt: number): void {
+ this.world.forEach('miner', (_m, e) => {
+ const pos = this.world.getByName('position', e)!;
+ this.chunks.addPollution(pos.tileX, pos.tileY, 0.2 * dt);
+ });
+ this.world.forEach('furnace', (_f, e) => {
+ const pos = this.world.getByName('position', e)!;
+ this.chunks.addPollution(pos.tileX, pos.tileY, 0.3 * dt);
+ });
+ for (const chunk of this.chunks.loadedChunks()) {
+ chunk.pollution = Math.max(0, chunk.pollution - 0.05 * dt);
+ }
+ }
+}
+
+export class EnemyAISystem implements System {
+ spawnTimer = 0;
+
+ constructor(
+ private world: World,
+ private chunks: ChunkManager,
+ private getPlayerPos: () => { x: number; y: number } | null,
+ ) {}
+
+ update(dt: number): void {
+ const playerPos = this.getPlayerPos();
+ if (!playerPos) return;
+
+ this.spawnTimer += dt;
+ if (this.spawnTimer > 15) {
+ this.spawnTimer = 0;
+ let pollution = 0;
+ for (const c of this.chunks.loadedChunks()) pollution += c.pollution;
+ if (pollution > 20) {
+ const angle = Math.random() * Math.PI * 2;
+ const dist = 40 + Math.random() * 20;
+ const x = Math.floor(playerPos.x + Math.cos(angle) * dist);
+ const y = Math.floor(playerPos.y + Math.sin(angle) * dist);
+ this.spawnBiter(x, y);
+ }
+ for (const chunk of this.chunks.loadedChunks()) {
+ for (const [sx, sy] of chunk.spawnerPositions) {
+ if (Math.hypot(sx - playerPos.x, sy - playerPos.y) < 60 && Math.random() < 0.1) {
+ this.spawnBiter(sx + 1, sy + 1);
+ }
+ }
+ }
+ }
+
+ this.world.forEach('enemy', (enemy, entity) => {
+ const pos = this.world.getByName('position', entity)!;
+ const dx = playerPos.x - (pos.tileX + pos.offsetX);
+ const dy = playerPos.y - (pos.tileY + pos.offsetY);
+ const dist = Math.hypot(dx, dy);
+ if (dist < enemy.aggroRange) {
+ const nx = dx / (dist || 1);
+ const ny = dy / (dist || 1);
+ pos.offsetX += nx * enemy.speed * dt;
+ pos.offsetY += ny * enemy.speed * dt;
+ if (pos.offsetX >= 1) {
+ pos.tileX++;
+ pos.offsetX -= 1;
+ }
+ if (pos.offsetX < 0) {
+ pos.tileX--;
+ pos.offsetX += 1;
+ }
+ if (pos.offsetY >= 1) {
+ pos.tileY++;
+ pos.offsetY -= 1;
+ }
+ if (pos.offsetY < 0) {
+ pos.tileY--;
+ pos.offsetY += 1;
+ }
+ this.world.updatePosition(entity, pos.tileX, pos.tileY, pos.offsetX, pos.offsetY);
+ }
+ enemy.attackTimer -= dt;
+ if (dist < 1.2 && enemy.attackTimer <= 0) {
+ enemy.attackTimer = enemy.attackCooldown;
+ // damage nearest player
+ this.world.forEach('player', (_p, pe) => {
+ const hp = this.world.getByName('health', pe);
+ if (hp) hp.current = Math.max(0, hp.current - enemy.damage);
+ });
+ }
+ });
+ }
+
+ spawnBiter(x: number, y: number): void {
+ this.world
+ .spawnWith()
+ .with('position', { tileX: x, tileY: y, offsetX: 0.5, offsetY: 0.5 })
+ .with('sprite', {
+ textureId: 'biter_0',
+ width: 1,
+ height: 1,
+ layer: RenderLayer.Enemy,
+ color: '#d32f2f',
+ })
+ .with('health', { current: 30, max: 30 })
+ .with('enemy', {
+ kind: 'biter',
+ damage: 8,
+ speed: 2.5,
+ aggroRange: 25,
+ attackCooldown: 1,
+ attackTimer: 0,
+ })
+ .build();
+ }
+}
+
+export class CombatSystem implements System {
+ constructor(private world: World) {}
+
+ update(dt: number): void {
+ // Turrets
+ this.world.forEach('turret', (turret, entity) => {
+ turret.fireTimer -= dt;
+ const pos = this.world.getByName('position', entity)!;
+ const power = this.world.getByName('powerConsumer', entity);
+ if (!turret.usesAmmo && power && !power.powered) return;
+ if (turret.usesAmmo) {
+ const inv = this.world.getByName('inventory', entity);
+ if (!inv || inventoryCount(inv, 'firearm-magazine') + inventoryCount(inv, 'piercing-rounds-magazine') <= 0)
+ return;
+ }
+ if (turret.fireTimer > 0) return;
+
+ let best: Entity | null = null;
+ let bestDist = turret.range;
+ this.world.forEach('enemy', (_e, ee) => {
+ const ep = this.world.getByName('position', ee)!;
+ const d = Math.hypot(ep.tileX - pos.tileX, ep.tileY - pos.tileY);
+ if (d < bestDist) {
+ bestDist = d;
+ best = ee;
+ }
+ });
+ if (!best) return;
+ turret.fireTimer = turret.cooldown;
+ if (turret.usesAmmo) {
+ const inv = this.world.getByName('inventory', entity)!;
+ if (!inventoryRemove(inv, 'firearm-magazine', 1)) inventoryRemove(inv, 'piercing-rounds-magazine', 1);
+ }
+ const hp = this.world.getByName('health', best);
+ if (hp) {
+ hp.current -= turret.damage;
+ if (hp.current <= 0) this.world.despawnDeferred(best);
+ }
+ });
+
+ // Projectiles
+ this.world.forEach('projectile', (proj, entity) => {
+ proj.ttl -= dt;
+ const vel = this.world.getByName('velocity', entity);
+ const pos = this.world.getByName('position', entity);
+ if (!pos || !vel) return;
+ pos.offsetX += vel.vx * dt;
+ pos.offsetY += vel.vy * dt;
+ this.world.updatePosition(entity, pos.tileX, pos.tileY, pos.offsetX, pos.offsetY);
+ this.world.forEach('enemy', (_e, ee) => {
+ const ep = this.world.getByName('position', ee)!;
+ if (Math.hypot(ep.tileX + ep.offsetX - (pos.tileX + pos.offsetX), ep.tileY + ep.offsetY - (pos.tileY + pos.offsetY)) < 0.6) {
+ const hp = this.world.getByName('health', ee);
+ if (hp) {
+ hp.current -= proj.damage;
+ if (hp.current <= 0) this.world.despawnDeferred(ee);
+ }
+ proj.ttl = 0;
+ }
+ });
+ if (proj.ttl <= 0) this.world.despawnDeferred(entity);
+ });
+ }
+
+ playerShoot(from: Entity, targetX: number, targetY: number, ownerId: number): void {
+ const inv = this.world.getByName('inventory', from);
+ const pos = this.world.getByName('position', from);
+ if (!inv || !pos) return;
+ if (!inventoryRemove(inv, 'firearm-magazine', 1) && !inventoryRemove(inv, 'piercing-rounds-magazine', 1)) return;
+ const dx = targetX - (pos.tileX + pos.offsetX);
+ const dy = targetY - (pos.tileY + pos.offsetY);
+ const len = Math.hypot(dx, dy) || 1;
+ this.world
+ .spawnWith()
+ .with('position', { tileX: pos.tileX, tileY: pos.tileY, offsetX: pos.offsetX, offsetY: pos.offsetY })
+ .with('sprite', { textureId: 'default', width: 0.25, height: 0.25, layer: RenderLayer.Projectile, color: '#ffeb3b' })
+ .with('velocity', { vx: (dx / len) * 18, vy: (dy / len) * 18 })
+ .with('projectile', { damage: 12, ownerId, ttl: 1.2 })
+ .build();
+ }
+}
+
+export class UnitSystem implements System {
+ constructor(private world: World) {}
+
+ update(dt: number): void {
+ this.world.forEach('unit', (unit, entity) => {
+ const pos = this.world.getByName('position', entity)!;
+ if (unit.command === 'move' && unit.targetX !== undefined && unit.targetY !== undefined) {
+ const dx = unit.targetX - (pos.tileX + pos.offsetX);
+ const dy = unit.targetY - (pos.tileY + pos.offsetY);
+ const dist = Math.hypot(dx, dy);
+ if (dist < 0.2) {
+ unit.command = 'hold';
+ return;
+ }
+ pos.offsetX += (dx / dist) * unit.speed * dt;
+ pos.offsetY += (dy / dist) * unit.speed * dt;
+ this.normalizePos(entity, pos);
+ }
+ if (unit.command === 'attack') {
+ let tx = unit.targetX;
+ let ty = unit.targetY;
+ if (unit.targetEntity && this.world.isAlive(unit.targetEntity)) {
+ const tp = this.world.getByName('position', unit.targetEntity);
+ if (tp) {
+ tx = tp.tileX + tp.offsetX;
+ ty = tp.tileY + tp.offsetY;
+ }
+ }
+ if (tx === undefined || ty === undefined) return;
+ const dx = tx - (pos.tileX + pos.offsetX);
+ const dy = ty - (pos.tileY + pos.offsetY);
+ const dist = Math.hypot(dx, dy);
+ if (dist > unit.range) {
+ pos.offsetX += (dx / dist) * unit.speed * dt;
+ pos.offsetY += (dy / dist) * unit.speed * dt;
+ this.normalizePos(entity, pos);
+ } else if (unit.targetEntity) {
+ const hp = this.world.getByName('health', unit.targetEntity);
+ if (hp) {
+ hp.current -= unit.damage * dt;
+ if (hp.current <= 0) this.world.despawnDeferred(unit.targetEntity);
+ }
+ }
+ }
+ });
+
+ this.world.forEach('unitProduction', (prod, entity) => {
+ if (prod.queue.length === 0) return;
+ const power = this.world.getByName('powerConsumer', entity);
+ if (power && !power.powered) return;
+ prod.progress += dt / 5;
+ if (prod.progress >= 1) {
+ prod.progress = 0;
+ const type = prod.queue.shift()!;
+ const pos = this.world.getByName('position', entity)!;
+ const owner = this.world.getByName<{ playerId: number }>('ownership', entity);
+ this.world
+ .spawnWith()
+ .with('position', { tileX: pos.tileX + 2, tileY: pos.tileY + 2, offsetX: 0.5, offsetY: 0.5 })
+ .with('sprite', { textureId: 'unit', width: 1, height: 1, layer: RenderLayer.Entity, color: '#1976d2' })
+ .with('health', { current: 50, max: 50 })
+ .with('unit', {
+ unitType: type,
+ ownerId: owner?.playerId ?? 1,
+ command: 'idle',
+ speed: 3,
+ damage: 10,
+ range: 1.5,
+ })
+ .with('ownership', { playerId: owner?.playerId ?? 1 })
+ .build();
+ }
+ });
+ }
+
+ private normalizePos(entity: Entity, pos: PositionComponent): void {
+ while (pos.offsetX >= 1) {
+ pos.tileX++;
+ pos.offsetX -= 1;
+ }
+ while (pos.offsetX < 0) {
+ pos.tileX--;
+ pos.offsetX += 1;
+ }
+ while (pos.offsetY >= 1) {
+ pos.tileY++;
+ pos.offsetY -= 1;
+ }
+ while (pos.offsetY < 0) {
+ pos.tileY--;
+ pos.offsetY += 1;
+ }
+ this.world.updatePosition(entity, pos.tileX, pos.tileY, pos.offsetX, pos.offsetY);
+ }
+}
+
+export class RocketSystem implements System {
+ victory = false;
+
+ constructor(private world: World) {}
+
+ update(dt: number): void {
+ this.world.forEach('rocketSilo', (silo, entity) => {
+ const power = this.world.getByName('powerConsumer', entity);
+ if (power && !power.powered) return;
+ const inv = this.world.getByName('inventory', entity);
+ if (!inv) return;
+
+ if (silo.parts < silo.partsRequired) {
+ if (inventoryRemove(inv, 'rocket-parts', 1)) silo.parts++;
+ }
+ if (!silo.hasSatellite && inventoryCount(inv, 'satellite') > 0) {
+ inventoryRemove(inv, 'satellite', 1);
+ silo.hasSatellite = true;
+ }
+ if (silo.parts >= silo.partsRequired && silo.hasSatellite && !silo.launching) {
+ silo.launching = true;
+ }
+ if (silo.launching) {
+ silo.launchProgress += dt / 10;
+ if (silo.launchProgress >= 1) {
+ silo.launching = false;
+ silo.launchProgress = 0;
+ silo.parts = 0;
+ silo.hasSatellite = false;
+ inventoryAdd(inv, 'space-science-pack', 1000);
+ this.victory = true;
+ }
+ }
+ });
+ }
+}
+
+export class EntityCleanupSystem implements System {
+ constructor(private world: World) {}
+
+ update(_dt: number): void {
+ this.world.forEach('health', (hp, entity) => {
+ if (hp.current <= 0) this.world.despawnDeferred(entity);
+ });
+ this.world.processPending();
+ }
+}
+
+export class AutoPlaySystem implements System {
+ active = false;
+ scenarioId: string | null = null;
+ stepIndex = 0;
+ waitTimer = 0;
+ private scenarios: Record> = {
+ basic_mining: [
+ { type: 'setGameSpeed', speed: 2 },
+ { type: 'placeBuilding', buildingId: 'burner-mining-drill', x: 2, y: 0 },
+ { type: 'wait', seconds: 5 },
+ ],
+ smelting_setup: [
+ { type: 'placeBuilding', buildingId: 'stone-furnace', x: 4, y: 0 },
+ { type: 'wait', seconds: 3 },
+ ],
+ production_line: [
+ { type: 'placeBuilding', buildingId: 'transport-belt', x: 1, y: 1, direction: 1 },
+ { type: 'placeBuilding', buildingId: 'transport-belt', x: 2, y: 1, direction: 1 },
+ { type: 'placeBuilding', buildingId: 'inserter', x: 2, y: 0, direction: 2 },
+ { type: 'wait', seconds: 5 },
+ ],
+ };
+
+ constructor(
+ private placeBuilding: (id: string, x: number, y: number, dir: Direction) => boolean,
+ private setSpeed: (s: number) => void,
+ ) {}
+
+ start(scenarioId: string): void {
+ this.scenarioId = scenarioId;
+ this.stepIndex = 0;
+ this.active = true;
+ this.waitTimer = 0;
+ }
+
+ stop(): void {
+ this.active = false;
+ }
+
+ listScenarios(): string[] {
+ return Object.keys(this.scenarios);
+ }
+
+ update(dt: number): void {
+ if (!this.active || !this.scenarioId) return;
+ const steps = this.scenarios[this.scenarioId];
+ if (!steps || this.stepIndex >= steps.length) {
+ this.active = false;
+ return;
+ }
+ const step = steps[this.stepIndex]!;
+ if (step.type === 'wait') {
+ this.waitTimer += dt;
+ if (this.waitTimer >= (step.seconds as number)) {
+ this.waitTimer = 0;
+ this.stepIndex++;
+ }
+ return;
+ }
+ if (step.type === 'setGameSpeed') this.setSpeed(step.speed as number);
+ if (step.type === 'placeBuilding') {
+ this.placeBuilding(step.buildingId as string, step.x as number, step.y as number, (step.direction as Direction) ?? 1);
+ }
+ this.stepIndex++;
+ }
+}
+
+export function chopTree(world: World, entity: Entity, playerInv: InventoryComponent, items: ItemRegistry): boolean {
+ const tree = world.getByName('tree', entity);
+ if (!tree) return false;
+ tree.woodRemaining -= 1;
+ inventoryAdd(playerInv, 'wood', 1, items.stackSize('wood'));
+ if (tree.woodRemaining <= 0) world.despawn(entity);
+ return true;
+}
+
+export { emptyInventory };
diff --git a/web/src/game/world/Chunk.ts b/web/src/game/world/Chunk.ts
new file mode 100644
index 00000000..a3e3bcce
--- /dev/null
+++ b/web/src/game/world/Chunk.ts
@@ -0,0 +1,89 @@
+export const CHUNK_SIZE = 32;
+
+export type TileType = 'grass' | 'dirt' | 'sand' | 'water' | 'stone';
+
+export type ResourceType = 'iron-ore' | 'copper-ore' | 'coal' | 'stone' | 'uranium-ore' | 'crude-oil';
+
+export interface ResourceDeposit {
+ type: ResourceType;
+ amount: number;
+ originalAmount: number;
+ richness: number;
+}
+
+export interface TileData {
+ type: TileType;
+ variation: number;
+ resource: ResourceDeposit | null;
+}
+
+export interface ChunkCoord {
+ x: number;
+ y: number;
+}
+
+export function chunkKey(c: ChunkCoord): string {
+ return `${c.x},${c.y}`;
+}
+
+export function worldToChunk(tileX: number, tileY: number): ChunkCoord {
+ return {
+ x: Math.floor(tileX / CHUNK_SIZE),
+ y: Math.floor(tileY / CHUNK_SIZE),
+ };
+}
+
+export interface ChunkData {
+ coordX: number;
+ coordY: number;
+ tiles: TileData[][];
+ pollution: number;
+ biome: string;
+ spawnerPositions: Array<[number, number]>;
+}
+
+export class Chunk {
+ readonly coord: ChunkCoord;
+ tiles: TileData[][];
+ pollution = 0;
+ biome = 'plains';
+ spawnerPositions: Array<[number, number]> = [];
+ dirty = true;
+
+ constructor(coord: ChunkCoord, tiles?: TileData[][]) {
+ this.coord = coord;
+ this.tiles =
+ tiles ??
+ Array.from({ length: CHUNK_SIZE }, () =>
+ Array.from({ length: CHUNK_SIZE }, () => ({
+ type: 'grass' as TileType,
+ variation: 0,
+ resource: null,
+ })),
+ );
+ }
+
+ getTileLocal(lx: number, ly: number): TileData | null {
+ if (lx < 0 || ly < 0 || lx >= CHUNK_SIZE || ly >= CHUNK_SIZE) return null;
+ return this.tiles[ly]![lx]!;
+ }
+
+ toData(): ChunkData {
+ return {
+ coordX: this.coord.x,
+ coordY: this.coord.y,
+ tiles: this.tiles,
+ pollution: this.pollution,
+ biome: this.biome,
+ spawnerPositions: this.spawnerPositions,
+ };
+ }
+
+ static fromData(data: ChunkData): Chunk {
+ const c = new Chunk({ x: data.coordX, y: data.coordY }, data.tiles);
+ c.pollution = data.pollution;
+ c.biome = data.biome;
+ c.spawnerPositions = data.spawnerPositions ?? [];
+ return c;
+ }
+}
diff --git a/web/src/game/world/ChunkManager.ts b/web/src/game/world/ChunkManager.ts
new file mode 100644
index 00000000..dcb6e303
--- /dev/null
+++ b/web/src/game/world/ChunkManager.ts
@@ -0,0 +1,84 @@
+import { Chunk, CHUNK_SIZE, chunkKey, worldToChunk, type ChunkCoord, type ChunkData, type TileData } from './Chunk';
+import { WorldGenerator } from './WorldGenerator';
+
+export class ChunkManager {
+ private chunks = new Map();
+ readonly generator: WorldGenerator;
+ loadRadius = 3;
+
+ constructor(seed: number) {
+ this.generator = new WorldGenerator(seed);
+ }
+
+ getChunk(coord: ChunkCoord): Chunk | undefined {
+ return this.chunks.get(chunkKey(coord));
+ }
+
+ ensureChunk(coord: ChunkCoord): Chunk {
+ const key = chunkKey(coord);
+ let c = this.chunks.get(key);
+ if (!c) {
+ c = this.generator.generateChunk(coord.x, coord.y);
+ this.chunks.set(key, c);
+ }
+ return c;
+ }
+
+ updateAround(tileX: number, tileY: number): void {
+ const center = worldToChunk(tileX, tileY);
+ const needed = new Set();
+ for (let dy = -this.loadRadius; dy <= this.loadRadius; dy++) {
+ for (let dx = -this.loadRadius; dx <= this.loadRadius; dx++) {
+ const coord = { x: center.x + dx, y: center.y + dy };
+ needed.add(chunkKey(coord));
+ this.ensureChunk(coord);
+ }
+ }
+ // Keep loaded chunks in radius; don't unload aggressively for gameplay stability
+ for (const [key, chunk] of this.chunks) {
+ if (!needed.has(key)) {
+ const dist = Math.max(Math.abs(chunk.coord.x - center.x), Math.abs(chunk.coord.y - center.y));
+ if (dist > this.loadRadius + 2) this.chunks.delete(key);
+ }
+ }
+ }
+
+ getTile(tileX: number, tileY: number): TileData | null {
+ const coord = worldToChunk(tileX, tileY);
+ const chunk = this.ensureChunk(coord);
+ const lx = ((tileX % CHUNK_SIZE) + CHUNK_SIZE) % CHUNK_SIZE;
+ const ly = ((tileY % CHUNK_SIZE) + CHUNK_SIZE) % CHUNK_SIZE;
+ return chunk.getTileLocal(lx, ly);
+ }
+
+ setTileResource(tileX: number, tileY: number, amount: number): void {
+ const tile = this.getTile(tileX, tileY);
+ if (!tile?.resource) return;
+ tile.resource.amount = amount;
+ if (amount <= 0) tile.resource = null;
+ const coord = worldToChunk(tileX, tileY);
+ this.getChunk(coord)!.dirty = true;
+ }
+
+ addPollution(tileX: number, tileY: number, amount: number): void {
+ const coord = worldToChunk(tileX, tileY);
+ const chunk = this.ensureChunk(coord);
+ chunk.pollution = Math.max(0, chunk.pollution + amount);
+ }
+
+ loadedChunks(): Chunk[] {
+ return [...this.chunks.values()];
+ }
+
+ serialize(): ChunkData[] {
+ return [...this.chunks.values()].map((c) => c.toData());
+ }
+
+ loadChunks(data: ChunkData[]): void {
+ this.chunks.clear();
+ for (const d of data) {
+ const c = Chunk.fromData(d);
+ this.chunks.set(chunkKey(c.coord), c);
+ }
+ }
+}
diff --git a/web/src/game/world/WorldGenerator.ts b/web/src/game/world/WorldGenerator.ts
new file mode 100644
index 00000000..aa26f619
--- /dev/null
+++ b/web/src/game/world/WorldGenerator.ts
@@ -0,0 +1,117 @@
+import { CHUNK_SIZE, Chunk, type ResourceType, type TileType } from './Chunk';
+
+/** Simple seeded PRNG (mulberry32) */
+export function createRng(seed: number): () => number {
+ let t = seed >>> 0;
+ return () => {
+ t += 0x6d2b79f5;
+ let r = Math.imul(t ^ (t >>> 15), 1 | t);
+ r ^= r + Math.imul(r ^ (r >>> 7), 61 | r);
+ return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
+ };
+}
+
+function hash2(x: number, y: number, seed: number): number {
+ let h = seed ^ Math.imul(x, 374761393) ^ Math.imul(y, 668265263);
+ h = Math.imul(h ^ (h >>> 13), 1274126177);
+ return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
+}
+
+function valueNoise(x: number, y: number, seed: number): number {
+ const x0 = Math.floor(x);
+ const y0 = Math.floor(y);
+ const fx = x - x0;
+ const fy = y - y0;
+ const a = hash2(x0, y0, seed);
+ const b = hash2(x0 + 1, y0, seed);
+ const c = hash2(x0, y0 + 1, seed);
+ const d = hash2(x0 + 1, y0 + 1, seed);
+ const u = fx * fx * (3 - 2 * fx);
+ const v = fy * fy * (3 - 2 * fy);
+ return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v;
+}
+
+function fbm(x: number, y: number, seed: number, octaves = 4): number {
+ let amp = 1;
+ let freq = 1;
+ let sum = 0;
+ let norm = 0;
+ for (let i = 0; i < octaves; i++) {
+ sum += valueNoise(x * freq, y * freq, seed + i * 1013) * amp;
+ norm += amp;
+ amp *= 0.5;
+ freq *= 2;
+ }
+ return sum / norm;
+}
+
+const RESOURCE_TYPES: ResourceType[] = ['iron-ore', 'copper-ore', 'coal', 'stone', 'uranium-ore'];
+
+export class WorldGenerator {
+ constructor(public seed: number) {}
+
+ generateChunk(cx: number, cy: number): Chunk {
+ const chunk = new Chunk({ x: cx, y: cy });
+ const elevSeed = this.seed;
+ const moistSeed = this.seed + 99991;
+ const resSeed = this.seed + 12345;
+
+ for (let ly = 0; ly < CHUNK_SIZE; ly++) {
+ for (let lx = 0; lx < CHUNK_SIZE; lx++) {
+ const wx = cx * CHUNK_SIZE + lx;
+ const wy = cy * CHUNK_SIZE + ly;
+ const elev = fbm(wx / 48, wy / 48, elevSeed);
+ const moist = fbm(wx / 64, wy / 64, moistSeed);
+
+ let type: TileType = 'grass';
+ if (elev < 0.32) type = 'water';
+ else if (elev < 0.38) type = 'sand';
+ else if (elev > 0.72) type = 'stone';
+ else if (moist < 0.35) type = 'dirt';
+
+ const tile = chunk.tiles[ly]![lx]!;
+ tile.type = type;
+ tile.variation = Math.floor(hash2(wx, wy, this.seed) * 4);
+
+ if (type !== 'water') {
+ const patch = fbm(wx / 20, wy / 20, resSeed);
+ if (patch > 0.78) {
+ const r = hash2(wx, wy, resSeed + 7);
+ let resType: ResourceType = RESOURCE_TYPES[Math.floor(r * 4)]!;
+ if (patch > 0.92 && r > 0.85) resType = 'uranium-ore';
+ // oil near sand
+ if (type === 'sand' && patch > 0.82) resType = 'crude-oil';
+ const amount = resType === 'crude-oil' ? 100000 : 500 + Math.floor(r * 4500);
+ tile.resource = {
+ type: resType,
+ amount,
+ originalAmount: amount,
+ richness: 1 + r,
+ };
+ }
+ }
+
+ // Biters spawners far from origin
+ const dist = Math.hypot(wx, wy);
+ if (dist > 80 && type !== 'water' && hash2(wx, wy, this.seed + 555) > 0.997) {
+ chunk.spawnerPositions.push([wx, wy]);
+ }
+ }
+ }
+
+ chunk.biome = elevBiome(cx, cy, this.seed);
+ return chunk;
+ }
+
+ shouldSpawnTree(wx: number, wy: number, tileType: TileType): boolean {
+ if (tileType !== 'grass' && tileType !== 'dirt') return false;
+ return hash2(wx, wy, this.seed + 7777) > 0.94;
+ }
+}
+
+function elevBiome(cx: number, cy: number, seed: number): string {
+ const n = fbm(cx / 4, cy / 4, seed);
+ if (n < 0.35) return 'wetlands';
+ if (n > 0.7) return 'highlands';
+ return 'plains';
+}
diff --git a/web/src/main.ts b/web/src/main.ts
new file mode 100644
index 00000000..abc98710
--- /dev/null
+++ b/web/src/main.ts
@@ -0,0 +1,253 @@
+import { GameLoop } from './engine/core/GameLoop';
+import { Camera2D } from './engine/render/Camera2D';
+import { OrthoSpriteView } from './engine/render/OrthoSpriteView';
+import { Perspective3DView } from './engine/render3d/Perspective3DView';
+import { InputManager } from './engine/input/InputManager';
+import { Hud } from './ui/Hud';
+import { LoadingMenu, type MenuAction } from './ui/LoadingMenu';
+import { NetClient } from './game/multiplayer/NetClient';
+import { audio } from './engine/audio/AudioManager';
+import type { PositionComponent } from './game/components';
+import type { GameSave } from '@shared/save/types';
+
+type AppPhase = 'menu' | 'playing';
+
+async function main(): Promise {
+ const canvas = document.querySelector('#game-canvas')!;
+ const uiRoot = document.querySelector('#ui-root')!;
+
+ let phase: AppPhase = 'menu';
+ let game: GameLoop | null = null;
+ const camera = new Camera2D();
+ const input = new InputManager();
+ const detachInput = input.attach(canvas);
+
+ const view2d = new OrthoSpriteView();
+ const view3d = new Perspective3DView();
+ const canvas3d = document.createElement('canvas');
+ canvas3d.id = 'game-canvas-3d';
+ canvas3d.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;display:none';
+ canvas.parentElement!.appendChild(canvas3d);
+
+ await view2d.init(canvas);
+ await view3d.init(canvas3d);
+ view2d.setEnabled(false);
+ view3d.setEnabled(false);
+ canvas.style.display = 'none';
+
+ // Placeholder game for Hud construction; replaced on start
+ game = new GameLoop();
+ game.setGameSpeed(0);
+ const hud = new Hud(uiRoot, game, input);
+ hud.setInGame(false);
+ const net = new NetClient();
+
+ const resize = () => {
+ const w = window.innerWidth;
+ const h = window.innerHeight;
+ canvas.width = w;
+ canvas.height = h;
+ canvas3d.width = w;
+ canvas3d.height = h;
+ view2d.resize(w, h);
+ view3d.resize(w, h);
+ };
+ window.addEventListener('resize', resize);
+ resize();
+
+ let use3d = false;
+ uiRoot.addEventListener('toggle-view', ((e: CustomEvent) => {
+ if (phase !== 'playing') return;
+ use3d = e.detail;
+ view2d.setEnabled(!use3d);
+ view3d.setEnabled(use3d);
+ }) as EventListener);
+
+ uiRoot.addEventListener('mp-connect', ((e: CustomEvent) => {
+ net.connect(e.detail);
+ net.onMessage = (msg) => {
+ if (msg.type === 'handshake') {
+ hud.showMessage('Multiplayer', `Connected as player ${msg.playerId}, seed ${msg.seed}`);
+ }
+ };
+ setInterval(() => net.ping(), 2000);
+ }) as EventListener);
+
+ const startPlaying = (opts?: { save?: GameSave; pvai?: boolean; autoplay?: boolean }) => {
+ loadingMenu.hide();
+ // Fresh world unless loading
+ if (opts?.save) {
+ game = new GameLoop(opts.save.seed);
+ hud.setGame(game);
+ game.loadSave(opts.save);
+ } else {
+ game = new GameLoop();
+ hud.setGame(game);
+ }
+ game.setGameSpeed(1);
+ game.onVictory = (reason) => hud.showMessage('Victory', reason);
+ game.onDeath = () => hud.showMessage('Game Over', 'You died. Open Menu → New Game to retry.');
+
+ if (opts?.pvai) game.startPvAI();
+ if (opts?.autoplay) {
+ game.autoPlaySystem.start('basic_mining');
+ game.setGameSpeed(2);
+ }
+
+ const player = game.localPlayer;
+ if (player) {
+ const pos = game.world.getByName('position', player.entity)!;
+ camera.x = pos.tileX + pos.offsetX;
+ camera.y = pos.tileY + pos.offsetY;
+ camera.zoom = 1.15;
+ }
+
+ phase = 'playing';
+ canvas.style.display = 'block';
+ canvas.focus();
+ view2d.setEnabled(!use3d);
+ view3d.setEnabled(use3d);
+ hud.setInGame(true);
+ };
+
+ const loadingMenu = new LoadingMenu(uiRoot, (action: MenuAction) => {
+ if (action.type === 'new') startPlaying();
+ if (action.type === 'load') startPlaying({ save: action.save });
+ if (action.type === 'pvai') startPlaying({ pvai: true });
+ if (action.type === 'pvp') {
+ startPlaying();
+ hud.toggle('lobby');
+ }
+ if (action.type === 'autoplay') startPlaying({ autoplay: true });
+ if (action.type === 'help') {
+ /* handled inside menu */
+ }
+ });
+
+ let lastPanX = 0;
+ let lastPanY = 0;
+
+ const loop = () => {
+ if (phase === 'playing' && game) {
+ input.updateMovement();
+
+ if (input.pausePressed) game.togglePause();
+ if (input.rotatePressed) {
+ if (input.buildMode === 'placing') {
+ input.placeDirection = ((input.placeDirection + 1) % 4) as 0 | 1 | 2 | 3;
+ } else game.rotateSelected();
+ }
+ if (input.deselectPressed) {
+ game.selectedEntities = [];
+ input.buildMode = 'none';
+ input.placeBuildingId = null;
+ hud.hideAll();
+ }
+ if (input.toggleView3D) {
+ use3d = !use3d;
+ hud.view3d = use3d;
+ view2d.setEnabled(!use3d);
+ view3d.setEnabled(use3d);
+ }
+ if (input.wheelDelta) camera.zoomBy(input.wheelDelta);
+ if (input.middleDown || input.rightDown) {
+ const dx = input.mouseX - lastPanX;
+ const dy = input.mouseY - lastPanY;
+ if (lastPanX || lastPanY) camera.pan(dx, dy);
+ }
+ lastPanX = input.mouseX;
+ lastPanY = input.mouseY;
+
+ const world = camera.screenToWorld(input.mouseX, input.mouseY, window.innerWidth, window.innerHeight);
+ input.worldX = world.x;
+ input.worldY = world.y;
+
+ if (input.justClicked) {
+ if (input.buildMode === 'placing' && input.placeBuildingId) {
+ const tx = Math.floor(world.x);
+ const ty = Math.floor(world.y);
+ const ok = game.placeBuilding(input.placeBuildingId, tx, ty, input.placeDirection);
+ if (ok) {
+ audio.place();
+ if (net.connected) {
+ net.sendCommand({
+ type: 'build',
+ buildingId: input.placeBuildingId,
+ position: { x: tx, y: ty },
+ direction: input.placeDirection,
+ });
+ }
+ }
+ } else if (input.buildMode === 'removing') {
+ game.removeBuildingAt(Math.floor(world.x), Math.floor(world.y));
+ } else {
+ game.selectAt(world.x, world.y);
+ const e = game.world.entityAt(Math.floor(world.x), Math.floor(world.y));
+ if (e && (game.world.hasByName('inventory', e) || game.world.hasByName('assembler', e))) {
+ hud.openMachineIfSelected();
+ } else if (!e) {
+ game.tryMineOrInteract(world.x, world.y);
+ audio.mine();
+ game.commandSelectedUnits('move', world.x, world.y);
+ } else if (game.world.hasByName('enemy', e)) {
+ game.commandSelectedUnits('attack', world.x, world.y, e.id);
+ }
+ }
+ }
+ if (input.justDoubleClicked || input.attackPressed) {
+ const player = game.localPlayer;
+ if (player) {
+ game.combatSystem.playerShoot(player.entity, world.x, world.y, game.localPlayerId);
+ audio.shoot();
+ }
+ }
+ if (input.justRightClicked) {
+ input.buildMode = 'none';
+ input.placeBuildingId = null;
+ }
+ if (input.stopUnitsPressed) game.commandSelectedUnits('stop');
+
+ game.frame(input.moveX, input.moveY);
+
+ const player = game.localPlayer;
+ if (player) {
+ const pos = game.world.getByName('position', player.entity)!;
+ camera.setFollow(
+ pos.tileX + pos.offsetX,
+ pos.tileY + pos.offsetY,
+ 1 / 60,
+ !input.middleDown && !input.rightDown,
+ );
+ }
+
+ if (use3d) view3d.render(game.world, game.chunkManager, camera, 1 / 60);
+ else view2d.render(game.world, game.chunkManager, camera, 1 / 60);
+
+ hud.update();
+ input.consumeFrameFlags();
+ } else {
+ // Keep splash/menu responsive; clear input edge flags
+ input.consumeFrameFlags();
+ }
+ requestAnimationFrame(loop);
+ };
+
+ void import('./game/data/SaveStore').then(({ writeSave }) => {
+ setInterval(() => {
+ if (phase === 'playing' && game) writeSave('autosave', game.serializeSave());
+ }, 300_000);
+ });
+
+ requestAnimationFrame(loop);
+
+ window.addEventListener('beforeunload', () => {
+ detachInput();
+ view2d.dispose();
+ view3d.dispose();
+ });
+}
+
+main().catch((err) => {
+ console.error(err);
+ document.body.innerHTML = `Failed to start FactoryForge:\n${err}`;
+});
diff --git a/web/src/styles.css b/web/src/styles.css
new file mode 100644
index 00000000..d1f79e6f
--- /dev/null
+++ b/web/src/styles.css
@@ -0,0 +1,407 @@
+:root {
+ --bg: #12160f;
+ --panel: #1c2418ee;
+ --border: #3d4f32;
+ --text: #e8f0e0;
+ --muted: #9aab8c;
+ --accent: #8bc34a;
+ --danger: #e57373;
+ font-family: "Segoe UI", system-ui, sans-serif;
+}
+
+* { box-sizing: border-box; }
+html, body, #app {
+ margin: 0;
+ width: 100%;
+ height: 100%;
+ overflow: hidden;
+ background: var(--bg);
+ color: var(--text);
+}
+
+#game-canvas {
+ display: none;
+ width: 100%;
+ height: 100%;
+}
+
+#ui-root {
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+}
+
+.hud-top {
+ pointer-events: auto;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ align-items: center;
+ padding: 8px 12px;
+ background: linear-gradient(180deg, #1a2216cc, transparent);
+}
+
+.brand {
+ font-weight: 700;
+ letter-spacing: 0.04em;
+ color: var(--accent);
+ margin-right: 12px;
+}
+
+.status {
+ font-size: 12px;
+ color: var(--muted);
+ flex: 1;
+ min-width: 200px;
+}
+
+.toolbar button {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+}
+.tb-icon {
+ width: 20px;
+ height: 20px;
+ image-rendering: pixelated;
+ object-fit: contain;
+}
+
+button {
+ pointer-events: auto;
+ background: #2a3524;
+ color: var(--text);
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ padding: 6px 10px;
+ cursor: pointer;
+}
+button:hover { border-color: var(--accent); }
+button:disabled { opacity: 0.5; cursor: default; }
+
+.panel {
+ pointer-events: auto;
+ position: absolute;
+ top: 56px;
+ right: 12px;
+ width: min(420px, 92vw);
+ max-height: calc(100% - 80px);
+ overflow: auto;
+ background: var(--panel);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 12px;
+ backdrop-filter: blur(6px);
+}
+
+.panel.hidden, .overlay.hidden, .hidden { display: none !important; }
+
+.panel h3, .panel h4 { margin: 0 0 8px; }
+.list { display: flex; flex-direction: column; gap: 4px; }
+.list-item {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ text-align: left;
+ width: 100%;
+}
+.list-item span { color: var(--muted); font-size: 12px; }
+
+.grid {
+ display: grid;
+ grid-template-columns: repeat(8, 1fr);
+ gap: 4px;
+}
+.slot {
+ background: #0f140c;
+ border: 1px solid var(--border);
+ border-radius: 3px;
+ min-height: 44px;
+ font-size: 10px;
+ padding: 2px;
+ overflow: hidden;
+}
+
+.row { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
+
+.help-hint {
+ position: absolute;
+ left: 12px;
+ bottom: 10px;
+ font-size: 12px;
+ color: var(--muted);
+ pointer-events: none;
+}
+
+.overlay {
+ pointer-events: auto;
+ position: absolute;
+ inset: 0;
+ background: #0008;
+ display: grid;
+ place-items: center;
+}
+.modal {
+ background: var(--panel);
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ padding: 20px;
+ max-width: 420px;
+}
+.slot-row { font-size: 13px; padding: 4px 0; border-bottom: 1px solid #2a3524; }
+
+/* —— Splash / Loading Menu —— */
+.loading-menu {
+ position: absolute;
+ inset: 0;
+ z-index: 50;
+ pointer-events: auto;
+}
+.loading-menu.hidden { display: none !important; }
+
+.splash {
+ position: absolute;
+ inset: 0;
+ display: grid;
+ place-items: center;
+ overflow: hidden;
+ background: #0a0e0c;
+}
+.splash-img {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ opacity: 0.55;
+}
+.splash-overlay {
+ position: relative;
+ text-align: center;
+ z-index: 1;
+ padding: 24px 32px;
+ background: #0a120acc;
+ border: 1px solid #3d4f32;
+ border-radius: 12px;
+ backdrop-filter: blur(6px);
+ animation: splash-in 0.6s ease-out;
+}
+@keyframes splash-in {
+ from { opacity: 0; transform: translateY(10px); }
+ to { opacity: 1; transform: none; }
+}
+.splash-title {
+ font-size: clamp(2rem, 6vw, 3.2rem);
+ font-weight: 800;
+ letter-spacing: 0.06em;
+ color: #c5e1a5;
+ text-shadow: 0 0 40px #8bc34a66;
+}
+.splash-sub {
+ margin-top: 8px;
+ color: #9aab8c;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ font-size: 0.8rem;
+}
+.splash-bar {
+ margin: 20px auto 0;
+ width: 200px;
+ height: 4px;
+ background: #2a3524;
+ border-radius: 2px;
+ overflow: hidden;
+}
+.splash-pct {
+ margin-top: 8px;
+ font-size: 12px;
+ color: #9aab8c;
+}
+.splash-bar span {
+ display: block;
+ height: 100%;
+ width: 0%;
+ background: linear-gradient(90deg, #8bc34a, #cddc39);
+ transition: width 0.1s linear;
+}
+
+.menu-splash-bg {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ opacity: 0.25;
+ filter: saturate(0.8);
+}
+.menu-header {
+ display: flex;
+ gap: 14px;
+ align-items: center;
+ margin-bottom: 8px;
+}
+.menu-icon {
+ width: 56px;
+ height: 56px;
+ image-rendering: pixelated;
+}
+.menu-btn img {
+ width: 22px;
+ height: 22px;
+ vertical-align: middle;
+ margin-right: 4px;
+ image-rendering: pixelated;
+}
+
+.slot.has-item {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 2px;
+ padding: 2px;
+}
+.slot.has-item img {
+ width: 28px;
+ height: 28px;
+ object-fit: contain;
+ image-rendering: pixelated;
+}
+.slot.has-item span {
+ font-size: 10px;
+ line-height: 1;
+}
+.slot.empty {
+ color: #3d4f32;
+ display: grid;
+ place-items: center;
+ font-size: 10px;
+}
+
+.build-item {
+ flex-direction: row !important;
+ align-items: center;
+ gap: 10px;
+}
+.build-item img {
+ width: 40px;
+ height: 40px;
+ object-fit: contain;
+ image-rendering: pixelated;
+ flex-shrink: 0;
+}
+.build-item em {
+ font-style: normal;
+ color: var(--muted);
+ font-size: 12px;
+}
+
+.menu-screen {
+ position: absolute;
+ inset: 0;
+ display: grid;
+ place-items: center;
+ padding: 24px;
+}
+.menu-bg {
+ position: absolute;
+ inset: 0;
+ background:
+ radial-gradient(ellipse at 50% 0%, #2d4a2288, transparent 55%),
+ linear-gradient(180deg, #10180e 0%, #0a0e0c 100%);
+}
+.menu-card {
+ position: relative;
+ width: min(520px, 94vw);
+ background: #1a2218ee;
+ border: 1px solid #3d4f32;
+ border-radius: 12px;
+ padding: 28px 24px 24px;
+ box-shadow: 0 20px 60px #000a;
+ backdrop-filter: blur(8px);
+ animation: splash-in 0.45s ease-out;
+}
+.menu-brand {
+ margin: 0;
+ font-size: 2rem;
+ color: #c5e1a5;
+ letter-spacing: 0.04em;
+}
+.menu-tagline {
+ margin: 6px 0 18px;
+ color: #9aab8c;
+ font-size: 0.95rem;
+}
+.menu-saves {
+ max-height: 160px;
+ overflow: auto;
+ background: #0f140c;
+ border: 1px solid #2a3524;
+ border-radius: 8px;
+ padding: 8px;
+ margin-bottom: 14px;
+}
+.menu-empty {
+ margin: 12px;
+ text-align: center;
+ color: #6a7a5a;
+ font-size: 0.9rem;
+}
+.save-slot {
+ display: block;
+ width: 100%;
+ text-align: left;
+ margin-bottom: 4px;
+ background: #24301f;
+}
+.save-slot.selected {
+ border-color: #8bc34a;
+ background: #2e4528;
+}
+.menu-actions, .menu-modes {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-bottom: 12px;
+}
+.menu-btn {
+ flex: 1 1 auto;
+ min-width: 110px;
+ padding: 10px 14px;
+ font-weight: 600;
+}
+.menu-btn.primary {
+ background: #3d6b28;
+ border-color: #8bc34a;
+ color: #e8f5d8;
+}
+.menu-btn.primary:hover {
+ background: #4a7f30;
+}
+.menu-help {
+ position: absolute;
+ inset: 12px;
+ background: #12180fee;
+ border: 1px solid #3d4f32;
+ border-radius: 10px;
+ padding: 16px;
+ overflow: auto;
+}
+.menu-help ul {
+ padding-left: 18px;
+ color: #c5d6b8;
+ line-height: 1.55;
+}
+.menu-help.hidden { display: none !important; }
+
+.hud-shell {
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+}
+.hud-shell > * { pointer-events: none; }
+.hud-shell .hud-top,
+.hud-shell .panel,
+.hud-shell .overlay,
+.hud-shell button { pointer-events: auto; }
+
diff --git a/web/src/ui/Hud.ts b/web/src/ui/Hud.ts
new file mode 100644
index 00000000..0f4805a1
--- /dev/null
+++ b/web/src/ui/Hud.ts
@@ -0,0 +1,386 @@
+import type { GameLoop } from '../engine/core/GameLoop';
+import type { InputManager } from '../engine/input/InputManager';
+import {
+ inventoryCount,
+ type AssemblerComponent,
+ type InventoryComponent,
+ type PositionComponent,
+} from '../game/components';
+import { listSaves, writeSave, readSave, deleteSave } from '../game/data/SaveStore';
+import { assets } from '../engine/render/AssetCatalog';
+
+export class Hud {
+ root: HTMLElement;
+ private panels: Record = {};
+ private statusEl!: HTMLElement;
+ view3d = false;
+ private game: GameLoop;
+
+ constructor(
+ root: HTMLElement,
+ game: GameLoop,
+ private input: InputManager,
+ ) {
+ this.root = root;
+ this.game = game;
+ this.build();
+ this.setInGame(false);
+ }
+
+ setGame(game: GameLoop): void {
+ this.game = game;
+ }
+
+ setInGame(active: boolean): void {
+ const hud = this.root.querySelector('.hud-shell') as HTMLElement | null;
+ if (hud) hud.classList.toggle('hidden', !active);
+ }
+
+ private build(): void {
+ this.root.innerHTML = `
+
+
+
+
+
+
+
+
+
+
+
+
WASD move · Wheel zoom · MMB/RMB pan · LMB mine/select · R rotate · Esc deselect · B build
+
+ `;
+ this.statusEl = this.root.querySelector('#status')!;
+ for (const id of ['inv', 'craft', 'build', 'research', 'menu', 'machine', 'lobby', 'autoplay']) {
+ this.panels[id] = this.root.querySelector(`#panel-${id}`)!;
+ }
+ this.root.querySelector('.toolbar')!.addEventListener('click', (e) => {
+ const btn = (e.target as HTMLElement).closest('button');
+ if (!btn) return;
+ const act = btn.getAttribute('data-act');
+ if (act === 'inv') this.toggle('inv');
+ if (act === 'craft') this.toggle('craft');
+ if (act === 'build') this.toggle('build');
+ if (act === 'research') this.toggle('research');
+ if (act === 'menu') this.toggle('menu');
+ if (act === 'pause') this.game.togglePause();
+ if (act === 'view') {
+ this.view3d = !this.view3d;
+ this.root.dispatchEvent(new CustomEvent('toggle-view', { detail: this.view3d }));
+ }
+ });
+ }
+
+ toggle(name: string): void {
+ const p = this.panels[name];
+ if (!p) return;
+ const opening = p.classList.contains('hidden');
+ for (const panel of Object.values(this.panels)) panel.classList.add('hidden');
+ if (opening) {
+ p.classList.remove('hidden');
+ this.renderPanel(name);
+ }
+ }
+
+ hideAll(): void {
+ for (const panel of Object.values(this.panels)) panel.classList.add('hidden');
+ }
+
+ showMessage(title: string, body: string): void {
+ const el = this.root.querySelector('#overlay-msg')!;
+ el.classList.remove('hidden');
+ el.innerHTML = ``;
+ el.querySelector('#msg-ok')!.addEventListener('click', () => el.classList.add('hidden'));
+ }
+
+ private renderPanel(name: string): void {
+ if (name === 'inv') this.renderInventory();
+ if (name === 'craft') this.renderCraft();
+ if (name === 'build') this.renderBuild();
+ if (name === 'research') this.renderResearch();
+ if (name === 'menu') this.renderMenu();
+ if (name === 'machine') this.renderMachine();
+ if (name === 'lobby') this.renderLobby();
+ if (name === 'autoplay') this.renderAutoPlay();
+ }
+
+ private renderInventory(): void {
+ const inv = this.game.playerManager.getInventory(this.game.localPlayerId);
+ if (!inv) return;
+ this.panels.inv!.innerHTML = `Inventory
${inv.slots
+ .map((s, i) => {
+ if (!s.itemId) return `
${i}
`;
+ const url = assets.iconUrl(s.itemId);
+ return `
${s.count}`;
+ })
+ .join('')}
`;
+ }
+
+ private renderCraft(): void {
+ const recipes = this.game.recipeRegistry.all.filter(
+ (r) =>
+ (r.category === 'crafting' || r.category === 'smelting') &&
+ (this.game.researchSystem.unlockedRecipes.has(r.id) || r.category === 'smelting'),
+ );
+ this.panels.craft!.innerHTML = `Crafting
${recipes
+ .map(
+ (r) =>
+ ``,
+ )
+ .join('')}
`;
+ this.panels.craft!.onclick = (e) => {
+ const btn = (e.target as HTMLElement).closest('[data-recipe]') as HTMLElement | null;
+ if (!btn) return;
+ this.game.queueCraft(btn.dataset.recipe!);
+ };
+ }
+
+ private renderBuild(): void {
+ const unlocked = this.game.researchSystem.unlockedRecipes;
+ const buildings = this.game.buildingRegistry.all.filter(
+ (b) => unlocked.has(b.id) || ['burner-mining-drill', 'stone-furnace', 'transport-belt', 'inserter', 'wooden-chest', 'small-electric-pole', 'pipe', 'boiler', 'steam-engine', 'water-pump', 'lab', 'gun-turret', 'wall', 'stone-wall'].includes(b.id),
+ );
+ this.panels.build!.innerHTML = `Build
Click a building, then LMB to place. R rotates. Right-click cancels.
${buildings
+ .map((b) => {
+ const url = assets.iconUrl(b.id);
+ return `
`;
+ })
+ .join('')}
+
+
+
+
`;
+ this.panels.build!.onclick = (e) => {
+ const t = e.target as HTMLElement;
+ const build = t.closest('[data-build]') as HTMLElement | null;
+ if (build) {
+ this.input.buildMode = 'placing';
+ this.input.placeBuildingId = build.dataset.build!;
+ this.hideAll();
+ this.input.focusGame();
+ return;
+ }
+ const mode = t.closest('[data-mode]') as HTMLElement | null;
+ if (mode?.dataset.mode === 'remove') {
+ this.input.buildMode = 'removing';
+ this.input.placeBuildingId = null;
+ this.hideAll();
+ this.input.focusGame();
+ }
+ if (mode?.dataset.mode === 'none') {
+ this.input.buildMode = 'none';
+ this.input.placeBuildingId = null;
+ this.hideAll();
+ this.input.focusGame();
+ }
+ };
+ }
+
+ private renderResearch(): void {
+ const techs = this.game.technologyRegistry.all;
+ this.panels.research!.innerHTML = `Research
Current: ${this.game.researchSystem.currentResearchId ?? 'none'}
${techs
+ .map((t) => {
+ const done = this.game.researchSystem.completed.has(t.id);
+ const prog = this.game.researchSystem.progress.get(t.id) ?? 0;
+ return ``;
+ })
+ .join('')}
`;
+ this.panels.research!.onclick = (e) => {
+ const btn = (e.target as HTMLElement).closest('[data-tech]') as HTMLElement | null;
+ if (!btn) return;
+ this.game.researchSystem.startResearch(btn.dataset.tech!);
+ this.renderResearch();
+ };
+ }
+
+ private renderMenu(): void {
+ const saves = listSaves();
+ this.panels.menu!.innerHTML = `
+ Menu
+
+
+
+
+ Load
+ ${saves.map((s) => `
`).join('') || '
No saves
'}
+ Modes
+
+
+
+
+
+ Help
+ Launch a rocket with a satellite to win. Automate mining, smelting, power, research, and defense.
+ `;
+ this.panels.menu!.querySelector('#save-game')!.addEventListener('click', () => {
+ const slot = `save_${new Date().toISOString().replace(/[:.]/g, '-')}`;
+ writeSave(slot, this.game.serializeSave());
+ this.renderMenu();
+ this.showMessage('Saved', slot);
+ });
+ this.panels.menu!.querySelector('#new-game')!.addEventListener('click', () => {
+ location.reload();
+ });
+ this.panels.menu!.querySelector('#start-pvai')!.addEventListener('click', () => {
+ this.game.startPvAI();
+ this.showMessage('PvAI', 'AI opponents spawned. Survive and outproduce them.');
+ });
+ this.panels.menu!.querySelector('#open-lobby')!.addEventListener('click', () => this.toggle('lobby'));
+ this.panels.menu!.querySelector('#open-autoplay')!.addEventListener('click', () => this.toggle('autoplay'));
+ this.panels.menu!.onclick = (e) => {
+ const btn = (e.target as HTMLElement).closest('[data-load]') as HTMLElement | null;
+ if (!btn) return;
+ const save = readSave(btn.dataset.load!);
+ if (save) {
+ this.game.loadSave(save);
+ this.showMessage('Loaded', btn.dataset.load!);
+ }
+ };
+ }
+
+ renderMachine(): void {
+ const id = this.game.selectedEntities[0];
+ if (id === undefined) {
+ this.panels.machine!.innerHTML = 'Machine
No selection
';
+ return;
+ }
+ const entity = this.game.world.entities.find((e) => e.id === id);
+ if (!entity) return;
+ const sprite = this.game.world.getByName<{ textureId: string }>('sprite', entity);
+ const inv = this.game.world.getByName('inventory', entity);
+ const asm = this.game.world.getByName('assembler', entity);
+ let html = `${sprite?.textureId ?? 'Machine'}
`;
+ if (inv) {
+ html += `${inv.slots
+ .filter((s) => s.itemId)
+ .map((s) => {
+ const url = assets.iconUrl(s.itemId!);
+ return `
${s.count}`;
+ })
+ .join('')}
`;
+ }
+ if (asm) {
+ const recipes = this.game.recipeRegistry.byCategory('crafting').slice(0, 30);
+ html += `Recipe: ${asm.recipeId ?? 'none'}
${recipes
+ .map((r) => ``)
+ .join('')}
`;
+ }
+ html += ``;
+ this.panels.machine!.innerHTML = html;
+ this.panels.machine!.querySelector('#rot')?.addEventListener('click', () => this.game.rotateSelected());
+ this.panels.machine!.querySelector('#del')?.addEventListener('click', () => {
+ const pos = this.game.world.getByName('position', entity);
+ if (pos) this.game.removeBuildingAt(pos.tileX, pos.tileY);
+ this.hideAll();
+ });
+ this.panels.machine!.onclick = (e) => {
+ const btn = (e.target as HTMLElement).closest('[data-set-recipe]') as HTMLElement | null;
+ if (!btn) return;
+ this.game.setAssemblerRecipe(id, btn.dataset.setRecipe!);
+ this.renderMachine();
+ };
+ }
+
+ private renderLobby(): void {
+ this.game.lobby.reset(8);
+ this.panels.lobby!.innerHTML = `
+ Lobby
+ Mode: ${this.game.lobby.config.mode}
+
+
+
+
+
+
+
+ `;
+ const renderSlots = () => {
+ this.panels.lobby!.querySelector('#lobby-slots')!.innerHTML = this.game.lobby.slots
+ .map((s) => `${s.slotId}: ${s.name || 'empty'} ${s.isAI ? '(AI)' : ''} ${s.ready ? 'ready' : ''}
`)
+ .join('');
+ };
+ renderSlots();
+ this.panels.lobby!.querySelector('#add-ai')!.addEventListener('click', () => {
+ this.game.lobby.addAI();
+ renderSlots();
+ });
+ this.panels.lobby!.querySelector('#start-match')!.addEventListener('click', () => {
+ this.game.startPvAI();
+ this.showMessage('Match', 'Local PvAI match started');
+ this.hideAll();
+ });
+ this.panels.lobby!.querySelector('#connect-mp')!.addEventListener('click', () => {
+ const url = (this.panels.lobby!.querySelector('#ws-url') as HTMLInputElement).value;
+ this.root.dispatchEvent(new CustomEvent('mp-connect', { detail: url }));
+ });
+ }
+
+ private renderAutoPlay(): void {
+ const scenarios = this.game.autoPlaySystem.listScenarios();
+ this.panels.autoplay!.innerHTML = `AutoPlay
${scenarios
+ .map((s) => ``)
+ .join('')}
+
+
+
+
+
+
+
`;
+ this.panels.autoplay!.onclick = (e) => {
+ const t = e.target as HTMLElement;
+ const sc = t.closest('[data-scenario]') as HTMLElement | null;
+ if (sc) this.game.autoPlaySystem.start(sc.dataset.scenario!);
+ const sp = t.closest('[data-speed]') as HTMLElement | null;
+ if (sp) this.game.setGameSpeed(Number(sp.dataset.speed));
+ if (t.id === 'stop-ap') this.game.autoPlaySystem.stop();
+ };
+ }
+
+ update(): void {
+ const player = this.game.localPlayer;
+ if (!player) return;
+ const pos = this.game.world.getByName('position', player.entity)!;
+ const inv = this.game.world.getByName('inventory', player.entity)!;
+ const net = this.game.powerSystem.networks[0];
+ const iron = inventoryCount(inv, 'iron-ore') + inventoryCount(inv, 'iron-plate');
+ const placing =
+ this.input.buildMode === 'placing' && this.input.placeBuildingId
+ ? ` · Place: ${this.input.placeBuildingId}`
+ : this.input.buildMode === 'removing'
+ ? ' · Remove mode'
+ : '';
+ this.statusEl.textContent = `Pos ${pos.tileX},${pos.tileY} · Time ${this.game.playTime.toFixed(0)}s · Speed ${this.game.gameSpeed}x · Power ${net ? `${(net.satisfaction * 100).toFixed(0)}%` : '—'} · Iron ${iron} · Entities ${this.game.world.entityCount}${placing}`;
+
+ if (this.input.toggleInventory) this.toggle('inv');
+ if (this.input.toggleCraft) this.toggle('craft');
+ if (this.input.toggleBuild) this.toggle('build');
+ if (this.input.toggleResearch) this.toggle('research');
+
+ if (!this.panels.inv!.classList.contains('hidden')) this.renderInventory();
+ if (!this.panels.machine!.classList.contains('hidden')) this.renderMachine();
+ }
+
+ openMachineIfSelected(): void {
+ if (this.game.selectedEntities.length) {
+ this.toggle('machine');
+ }
+ }
+}
+
+// silence unused import in case tree-shaking
+void deleteSave;
diff --git a/web/src/ui/LoadingMenu.ts b/web/src/ui/LoadingMenu.ts
new file mode 100644
index 00000000..27d2810d
--- /dev/null
+++ b/web/src/ui/LoadingMenu.ts
@@ -0,0 +1,172 @@
+import { listSaves, readSave, deleteSave } from '../game/data/SaveStore';
+import type { GameSave } from '@shared/save/types';
+import { audio } from '../engine/audio/AudioManager';
+import { assets } from '../engine/render/AssetCatalog';
+
+export type MenuAction =
+ | { type: 'new' }
+ | { type: 'load'; save: GameSave; slot: string }
+ | { type: 'pvai' }
+ | { type: 'pvp' }
+ | { type: 'autoplay' }
+ | { type: 'help' };
+
+/** Splash → loading menu matching iOS LoadingMenu flow. */
+export class LoadingMenu {
+ root: HTMLElement;
+ private selectedSlot: string | null = null;
+ private onAction: (action: MenuAction) => void;
+ private assetsReady = false;
+
+ constructor(parent: HTMLElement, onAction: (action: MenuAction) => void) {
+ this.onAction = onAction;
+ this.root = document.createElement('div');
+ this.root.id = 'loading-menu';
+ this.root.className = 'loading-menu';
+ parent.appendChild(this.root);
+ void this.showSplashAndPreload();
+ }
+
+ private async showSplashAndPreload(): Promise {
+ this.root.innerHTML = `
+
+

+
+
FactoryForge
+
Loading assets…
+
+
0%
+
+
+ `;
+ const fill = this.root.querySelector('#splash-fill') as HTMLElement;
+ const pct = this.root.querySelector('#splash-pct') as HTMLElement;
+
+ const start = performance.now();
+ await assets.preload((loaded, total) => {
+ const p = Math.round((loaded / total) * 100);
+ fill.style.width = `${p}%`;
+ fill.style.transform = 'none';
+ fill.style.animation = 'none';
+ pct.textContent = `${p}%`;
+ });
+ this.assetsReady = true;
+
+ // Keep splash visible at least ~1.5s for brand moment
+ const elapsed = performance.now() - start;
+ const wait = Math.max(0, 1500 - elapsed);
+ window.setTimeout(() => this.showMenu(), wait);
+ }
+
+ showMenu(): void {
+ audio.ui();
+ const saves = listSaves();
+ this.root.innerHTML = `
+
+ `;
+
+ this.root.querySelector('#save-list')?.addEventListener('click', (e) => {
+ const btn = (e.target as HTMLElement).closest('.save-slot') as HTMLElement | null;
+ if (!btn) return;
+ this.root.querySelectorAll('.save-slot').forEach((el) => el.classList.remove('selected'));
+ btn.classList.add('selected');
+ this.selectedSlot = btn.dataset.slot ?? null;
+ audio.ui();
+ });
+
+ this.root.addEventListener('click', (e) => {
+ const btn = (e.target as HTMLElement).closest('[data-act]') as HTMLElement | null;
+ if (!btn) return;
+ if (!this.assetsReady) return;
+ const act = btn.dataset.act;
+ audio.ui();
+ if (act === 'new') this.onAction({ type: 'new' });
+ if (act === 'load' && this.selectedSlot) {
+ const save = readSave(this.selectedSlot);
+ if (save) this.onAction({ type: 'load', save, slot: this.selectedSlot });
+ }
+ if (act === 'delete' && this.selectedSlot) {
+ deleteSave(this.selectedSlot);
+ this.showMenu();
+ }
+ if (act === 'pvai') this.onAction({ type: 'pvai' });
+ if (act === 'pvp') this.onAction({ type: 'pvp' });
+ if (act === 'autoplay') this.onAction({ type: 'autoplay' });
+ if (act === 'help') this.root.querySelector('#menu-help')?.classList.remove('hidden');
+ if (act === 'close-help') this.root.querySelector('#menu-help')?.classList.add('hidden');
+ });
+ }
+
+ hide(): void {
+ this.root.classList.add('hidden');
+ }
+
+ show(): void {
+ this.root.classList.remove('hidden');
+ this.showMenu();
+ }
+
+ destroy(): void {
+ this.root.remove();
+ }
+}
diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts
new file mode 100644
index 00000000..4c47c68a
--- /dev/null
+++ b/web/src/vite-env.d.ts
@@ -0,0 +1,8 @@
+///
+
+declare module 'three/webgpu' {
+ // Runtime module; typed loosely so createRenderer can import it.
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const mod: any;
+ export = mod;
+}
diff --git a/web/tsconfig.json b/web/tsconfig.json
new file mode 100644
index 00000000..9d03d23b
--- /dev/null
+++ b/web/tsconfig.json
@@ -0,0 +1,25 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "useDefineForClassFields": true,
+ "module": "ESNext",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "strict": true,
+ "esModuleInterop": true,
+ "allowSyntheticDefaultImports": true,
+ "noUnusedLocals": false,
+ "noUnusedParameters": false,
+ "noFallthroughCasesInSwitch": true,
+ "paths": {
+ "@shared/*": ["./shared/*"]
+ },
+ "baseUrl": "."
+ },
+ "include": ["src", "shared", "server", "vite.config.ts"]
+}
diff --git a/web/vite.config.ts b/web/vite.config.ts
new file mode 100644
index 00000000..4c8989d1
--- /dev/null
+++ b/web/vite.config.ts
@@ -0,0 +1,19 @@
+import { defineConfig } from 'vite';
+import path from 'node:path';
+
+export default defineConfig({
+ resolve: {
+ alias: {
+ '@shared': path.resolve(__dirname, 'shared'),
+ },
+ },
+ server: {
+ port: 5173,
+ proxy: {
+ '/ws': {
+ target: 'ws://localhost:8080',
+ ws: true,
+ },
+ },
+ },
+});