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 = ` + + `; + 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.itemId}${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 +
+
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, + }, + }, + }, +});