commit caa9d40cba79491d81862e95d5773200269a0bfc Author: Jonathan Bourdon Date: Wed Mar 11 16:56:28 2026 -0400 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..af5f487 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +node_modules/ +dist/ + +*.tsbuildinfo + +.DS_Store +Thumbs.db + +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +.env +.env.* +!.env.example diff --git a/SESSION.md b/SESSION.md new file mode 100644 index 0000000..cc7be93 --- /dev/null +++ b/SESSION.md @@ -0,0 +1,173 @@ +# Session Summary + +## Project State + +This repository now contains a playable Three.js/Vite prototype for a space RTS / economy sim testbed inspired by EVE Online and X4. + +The current prototype includes: + +- Two solar systems: `Helios Reach` and `Perseus Gate` +- A large space environment with stars, planets, orbit lines, nebulae, asteroid/resource fields, and starfield +- RTS-style ship selection, command issuance, camera movement, zoom levels, and follow-camera support +- Three view levels based on zoom: `local`, `solar`, and `universe` +- A bottom command bar with selection info, order buttons, and a minimap +- A strategic HUD overlay that switches to NATO / military-style symbols at higher zoom levels + +## Major Gameplay Systems Added + +### World / Navigation + +- Ships can travel between the two systems using staged FTL travel +- Travel flow includes: + - leaving gravity well + - FTL spool + - warp + - arrival +- FTL speed was increased and a basic warp streak / tunnel effect was added +- Local ship movement is no longer purely straight-line: + - ships bias toward curved orbital-style transfers around the system center + - idle ships hold a passive orbit instead of freezing in place + +### Orbital Model + +- Stations are no longer static arbitrary points +- Stations in `Helios` are placed on Lagrange-style offsets relative to planets +- Stations update position over time with the planetary orbital motion +- Ships and stations are beginning to behave like orbitals rather than free-floating markers + +### Units / AI / Orders + +- Ship roles currently in the prototype: + - military + - transport + - mining +- Unit state machine now includes states for: + - idle / moving + - FTL travel + - mining and delivery + - docking approach / docking / docked / undocking + - patrol / escort +- Orders currently supported: + - move + - transfer + - mine + - patrol + - escort + +### Docking / Logistics + +- Docking was added as a required step for transfer to stations +- Stations have limited docking capacity and explicit docking ports +- Mining ships now: + - mine ore in `Perseus` + - return to `Helios` + - dock at a refinery + - transfer ore + - undock and repeat + +### Economy / Inventory Foundations + +- Added item storage classes: + - `bulk-solid` + - `bulk-liquid` + - `bulk-gas` + - `container` + - `manufactured` +- Added module categories and starter module definitions for ships/stations +- Ships and stations now expose compatible cargo/storage/module metadata +- Refineries track: + - ore stored + - active refining batch + - refining timer + - refined output stock + +### Energy / Fuel + +- Ships now track: + - fuel + - energy +- Stations now track: + - fuel + - energy +- Ships consume energy/fuel depending on activity +- Docked ships recharge energy +- Stations recharge energy passively + +## Testbed Layout + +- `Helios Reach` is now the industrial / infrastructure system + - stations are concentrated there + - refinery loop terminates there +- `Perseus Gate` is now the extraction / resource system + - resource asteroid nodes are concentrated there + - miners operate there before hauling back + +## UI / UX State + +- Ship and station selection is supported +- Ship multi-selection is supported via click modifiers and marquee drag selection +- `Solar` and `universe` views now overlay high-level tactical symbology instead of relying only on 3D meshes +- Ships use role-specific long-range symbols: + - military: hostile/combat-style diamond iconography + - transport: boxed logistics symbol + - mining: angular resource / industrial symbol +- Stations and constructibles use square strategic markers with category-specific internal glyphs +- `Universe` view groups ships into fleet counts per system and role for cleaner strategic readability +- Focusing works for: + - single ships: follow camera + - stations: focus camera on the station +- Selection panels show: + - ship state, order, cargo, hold type, fuel, energy, modules + - station role, docking occupancy, stored resources, refinery timing, fuel, energy, modules + +## Controls + +- `Left Click`: select ships or stations +- `Shift + Left Click`: add ships to ship selection +- `Ctrl/Cmd + Left Click`: toggle ships in selection +- `Left Drag`: marquee-select multiple ships +- `Right Click`: issue move/transfer orders +- `Mouse Wheel` or `-` / `=`: zoom +- `W A S D`: pan camera +- `Q / E`: rotate camera +- `F`: focus selection, and follow a single selected ship +- `Tab`: jump camera between systems +- `B`: toggle build mode +- `1-5`: choose constructible +- `M`: assign mining +- `P`: assign patrol +- `E`: assign escort + +## Technical Notes + +- The prototype is built with: + - Vite + - TypeScript + - Three.js +- High-level symbology is rendered through a dedicated 2D HUD overlay canvas layered above the 3D scene +- Production build is currently passing with `npm run build` + +## Known Limitations / Caveats + +- Orbital behavior is still an approximation for gameplay, not a full orbital mechanics simulation +- Stations are on Lagrange-style offsets, but not using a physically rigorous orbital solver +- Ship transfer paths are curved and orbit-biased, but still use authored steering rather than patched conics or n-body integration +- Fuel / energy exist but station refueling, resupply, and depletion failure states are still minimal +- Module definitions exist, but there is no actual ship/station designer yet +- Inventory classes exist, but only a subset of economic flows are implemented +- Docking works for logistics, but there is not yet a richer docking queue / reservation UI +- NATO-style symbology is gameplay-oriented inspiration, not a strict APP-6 / MIL-STD implementation + +## Suggested Next Steps + +- Introduce explicit orbital anchors for: + - stars + - planets + - stations + - asteroid belts / resource fields +- Replace the current movement approximation with a more formal orbital transfer model +- Add refueling and power management gameplay +- Add ship/station fitting data structures that can later drive a designer UI +- Expand the economy beyond ore/refining into manufactured goods and trade lanes +- Improve FTL visuals with a fullscreen post-process distortion or tunnel effect +- Expand the strategic overlay with threat rings, route arrows, and fleet stance/status markers diff --git a/index.html b/index.html new file mode 100644 index 0000000..a5b9aba --- /dev/null +++ b/index.html @@ -0,0 +1,12 @@ + + + + + + Space Command + + + +
+ + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..cc10d8b --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1121 @@ +{ + "name": "space-game", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "space-game", + "version": "0.1.0", + "dependencies": { + "three": "^0.179.1" + }, + "devDependencies": { + "@types/three": "^0.183.1", + "typescript": "^5.9.2", + "vite": "^7.1.3" + } + }, + "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 + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "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 + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true + }, + "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 + }, + "node_modules/@types/three": { + "version": "0.183.1", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.183.1.tgz", + "integrity": "sha512-f2Pu5Hrepfgavttdye3PsH5RWyY/AvdZQwIVhrc4uNtvF7nOWJacQKcoVJn0S4f0yYbmAE6AR+ve7xDcuYtMGw==", + "dev": true, + "dependencies": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": ">=0.5.17", + "@webgpu/types": "*", + "fflate": "~0.8.2", + "meshoptimizer": "~1.0.1" + } + }, + "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 + }, + "node_modules/@webgpu/types": { + "version": "0.1.69", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.69.tgz", + "integrity": "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==", + "dev": true + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "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, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "dev": true + }, + "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, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/meshoptimizer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.0.1.tgz", + "integrity": "sha512-Vix+QlA1YYT3FwmBBZ+49cE5y/b+pRrcXKqGpS5ouh33d3lSp2PoTpCw19E0cKDFWalembrHnIaZetf27a+W2g==", + "dev": true + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "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 + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "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" + } + ], + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "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, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/three": { + "version": "0.179.1", + "resolved": "https://registry.npmjs.org/three/-/three-0.179.1.tgz", + "integrity": "sha512-5y/elSIQbrvKOISxpwXCR4sQqHtGiOI+MKLc3SsBdDXA2hz3Mdp3X59aUp8DyybMa34aeBwbFTpdoLJaUDEWSw==" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "dependencies": { + "esbuild": "^0.27.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 + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..791d41c --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "space-game", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "three": "^0.179.1" + }, + "devDependencies": { + "@types/three": "^0.183.1", + "typescript": "^5.9.2", + "vite": "^7.1.3" + } +} diff --git a/src/game/GameApp.ts b/src/game/GameApp.ts new file mode 100644 index 0000000..7d8abb7 --- /dev/null +++ b/src/game/GameApp.ts @@ -0,0 +1,2513 @@ +import * as THREE from "three"; +import { + constructibleDefinitions, + itemDefinitions, + moduleDefinitions, + shipDefinitions, + solarSystemDefinitions, + type ConstructibleDefinition, + type ShipDefinition, + type SolarSystemDefinition, + type UnitState, +} from "./definitions"; + +type UnitOrder = + | { kind: "idle" } + | { kind: "move"; destination: THREE.Vector3; systemId: string } + | { + kind: "transfer"; + destination: THREE.Vector3; + destinationSystemId: string; + exitPoint: THREE.Vector3; + arrivalPoint: THREE.Vector3; + } + | { kind: "mine"; nodeId: string; refineryId: string; phase: "to-node" | "mining" | "to-refinery" | "transfer" } + | { kind: "patrol"; points: THREE.Vector3[]; systemId: string; index: number } + | { kind: "escort"; targetShipId: string; offset: THREE.Vector3 }; + +interface InventoryState { + "bulk-solid": number; + "bulk-liquid": number; + "bulk-gas": number; + container: number; + manufactured: number; +} + +interface TravelPlan { + destination: THREE.Vector3; + destinationSystemId: string; + exitPoint: THREE.Vector3; + arrivalPoint: THREE.Vector3; +} + +interface ShipInstance { + id: string; + definition: ShipDefinition; + group: THREE.Group; + target: THREE.Vector3; + velocity: THREE.Vector3; + selected: boolean; + ring: THREE.Mesh; + systemId: string; + state: UnitState; + order: UnitOrder; + inventory: InventoryState; + cargoItemId?: string; + actionTimer: number; + travelPlan?: TravelPlan; + dockedStationId?: string; + dockingPortIndex?: number; + fuel: number; + energy: number; + maxFuel: number; + maxEnergy: number; + idleOrbitRadius: number; + idleOrbitAngle: number; + warpFx: THREE.Group; +} + +interface StationInstance { + id: string; + definition: ConstructibleDefinition; + group: THREE.Group; + systemId: string; + ring: THREE.Mesh; + oreStored: number; + refinedStock: number; + processTimer: number; + activeBatch: number; + inventory: InventoryState; + dockedShipIds: Set; + dockingPorts: THREE.Vector3[]; + modules: string[]; + orbitalParentPlanetIndex?: number; + lagrangeSide?: -1 | 1; + fuel: number; + energy: number; + maxFuel: number; + maxEnergy: number; +} + +interface PlanetInstance { + group: THREE.Group; + mesh: THREE.Mesh; + orbitSpeed: number; + ring?: THREE.Object3D; +} + +interface ResourceNode { + id: string; + systemId: string; + position: THREE.Vector3; + mesh: THREE.Object3D; + oreRemaining: number; + itemId: string; +} + +interface SolarSystemInstance { + definition: SolarSystemDefinition; + root: THREE.Group; + center: THREE.Vector3; + planets: PlanetInstance[]; + star: THREE.Object3D; + gravityWellRadius: number; + orbitLines: THREE.LineLoop[]; + asteroidDecorations: THREE.Object3D[]; + strategicMarker: THREE.Object3D; +} + +const Y_PLANE = 4; +const ARRIVAL_THRESHOLD = 16; +const MINING_RATE = 28; +const REFINING_BATCH_SIZE = 60; +const REFINING_DURATION = 8; +const DOCKING_DURATION = 1.2; +const UNDOCK_DISTANCE = 42; +const IDLE_ENERGY_DRAIN = 0.7; +const MOVE_ENERGY_DRAIN = 1.8; +const WARP_ENERGY_DRAIN = 7; +const WARP_FUEL_DRAIN = 4.5; +const SHIP_RECHARGE_RATE = 10; +const STATION_SOLAR_CHARGE = 5; +type ViewLevel = "local" | "solar" | "universe"; +type SelectableTarget = + | { kind: "ship"; ship: ShipInstance } + | { kind: "station"; station: StationInstance }; + +export class GameApp { + private readonly container: HTMLElement; + private readonly renderer: THREE.WebGLRenderer; + private readonly scene = new THREE.Scene(); + private readonly camera = new THREE.PerspectiveCamera(54, 1, 0.1, 24000); + private readonly clock = new THREE.Clock(); + private readonly raycaster = new THREE.Raycaster(); + private readonly mouse = new THREE.Vector2(); + private readonly movePlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -Y_PLANE); + private readonly keyState = new Set(); + private readonly cameraFocus = new THREE.Vector3(); + private readonly ships: ShipInstance[] = []; + private readonly shipsById = new Map(); + private readonly stations: StationInstance[] = []; + private readonly nodes: ResourceNode[] = []; + private readonly systems: SolarSystemInstance[] = []; + private readonly selectableTargets = new Map(); + private readonly strategicLinks = new THREE.Group(); + private starfield?: THREE.Points; + + private buildMode = false; + private selectedConstructible = 0; + private selectedSystemIndex = 0; + private selection: ShipInstance[] = []; + private selectedStation?: StationInstance; + private followShipId?: string; + private viewLevel: ViewLevel = "local"; + private marqueeStart?: THREE.Vector2; + private marqueeModifiers = { shift: false, ctrl: false }; + private marqueeActive = false; + private suppressClickSelection = false; + private shipId = 0; + private stationId = 0; + private nodeId = 0; + + private readonly detailsEl: HTMLDivElement; + private readonly statusEl: HTMLDivElement; + private readonly selectionTitleEl: HTMLHeadingElement; + private readonly ordersEl: HTMLDivElement; + private readonly minimapEl: HTMLCanvasElement; + private readonly minimapContext: CanvasRenderingContext2D; + private readonly marqueeEl: HTMLDivElement; + private readonly strategicOverlayEl: HTMLCanvasElement; + private readonly strategicOverlayContext: CanvasRenderingContext2D; + + constructor(container: HTMLElement) { + this.container = container; + this.renderer = new THREE.WebGLRenderer({ antialias: true }); + this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + this.renderer.outputColorSpace = THREE.SRGBColorSpace; + this.renderer.shadowMap.enabled = true; + this.renderer.shadowMap.type = THREE.PCFSoftShadowMap; + + this.scene.fog = new THREE.FogExp2(0x030811, 0.00006); + this.scene.background = new THREE.Color(0x02060d); + + const initialSystem = solarSystemDefinitions[0]; + this.cameraFocus.set(...initialSystem.position); + this.camera.position.set(initialSystem.position[0] + 320, 260, initialSystem.position[2] + 300); + this.camera.lookAt(this.cameraFocus); + + this.container.append(this.renderer.domElement); + const hud = this.createHud(); + this.detailsEl = hud.details; + this.statusEl = hud.status; + this.selectionTitleEl = hud.selectionTitle; + this.ordersEl = hud.orders; + this.minimapEl = hud.minimap; + this.minimapContext = hud.minimapContext; + this.marqueeEl = hud.marquee; + this.strategicOverlayEl = hud.strategicOverlay; + this.strategicOverlayContext = hud.strategicOverlayContext; + + this.setupScene(); + this.bindEvents(); + this.onResize(); + this.updateHud(); + } + + start() { + this.renderer.setAnimationLoop(() => this.tick()); + } + + private createHud() { + const root = document.createElement("div"); + root.className = "hud"; + root.innerHTML = ` + +
+

Helios Reach Command

+

+ Dual-star-system prototype with gravity-well exits, FTL spooling, inter-system travel, + and unit orders for patrol, escort, mining, and manual fleet movement. +

+
+
+

Selection

+
+
+
+
+

No Selection

+
+
+
+
+
+ + + + + +
+
Left click select ships or stations. Shift+click adds ships. Right click moves selected ships. Mouse wheel or -/= zoom. B build. 1-5 constructible. M miners mine. P patrol. E escort. Tab jump systems. F focus/follow.
+
+
+ +
+
+
+ `; + + this.container.append(root); + root.querySelectorAll(".orders button").forEach((button) => { + button.addEventListener("click", () => this.handleOrderAction(button.dataset.action ?? "")); + }); + + const minimap = root.querySelector(".minimap"); + const minimapContext = minimap?.getContext("2d"); + if (!minimap || !minimapContext) { + throw new Error("Unable to create minimap canvas"); + } + + const strategicOverlay = root.querySelector(".strategic-overlay"); + const strategicOverlayContext = strategicOverlay?.getContext("2d"); + if (!strategicOverlay || !strategicOverlayContext) { + throw new Error("Unable to create strategic overlay canvas"); + } + + return { + details: root.querySelector(".content") as HTMLDivElement, + status: root.querySelector(".mode") as HTMLDivElement, + selectionTitle: root.querySelector(".selection-title") as HTMLHeadingElement, + orders: root.querySelector(".orders") as HTMLDivElement, + minimap, + minimapContext, + marquee: root.querySelector(".marquee") as HTMLDivElement, + strategicOverlay, + strategicOverlayContext, + }; + } + + private setupScene() { + this.scene.add(new THREE.HemisphereLight(0x6ba6ff, 0x03050a, 0.38)); + this.scene.add(new THREE.AmbientLight(0x8397b8, 0.28)); + this.scene.add(this.strategicLinks); + + this.createNebulae(); + this.createStarfield(); + this.createSolarSystems(); + this.createStrategicLinks(); + this.createInitialStations(); + this.createInitialShips(); + this.assignDefaultOrders(); + this.applyViewLevel(); + } + + private createSolarSystems() { + solarSystemDefinitions.forEach((definition) => { + const root = new THREE.Group(); + root.position.set(...definition.position); + this.scene.add(root); + + const star = new THREE.Mesh( + new THREE.SphereGeometry(definition.starSize, 48, 48), + new THREE.MeshBasicMaterial({ color: definition.starColor }), + ); + root.add(star); + + const glow = new THREE.Mesh( + new THREE.SphereGeometry(definition.starSize * 1.6, 32, 32), + new THREE.MeshBasicMaterial({ + color: definition.starGlow, + transparent: true, + opacity: 0.14, + side: THREE.BackSide, + }), + ); + root.add(glow); + + const light = new THREE.PointLight(definition.starColor, 3.2, 2800, 1.2); + light.castShadow = true; + root.add(light); + + const planets: PlanetInstance[] = []; + const orbitLines: THREE.LineLoop[] = []; + definition.planets.forEach((planetDef, index) => { + const orbitRoot = new THREE.Group(); + orbitRoot.rotation.y = (index / definition.planets.length) * Math.PI * 2; + + const planet = new THREE.Mesh( + new THREE.SphereGeometry(planetDef.size, 36, 36), + new THREE.MeshStandardMaterial({ + color: planetDef.color, + metalness: 0.08, + roughness: 0.92, + emissive: new THREE.Color(planetDef.color).multiplyScalar(0.04), + }), + ); + planet.position.x = planetDef.orbitRadius; + planet.rotation.z = planetDef.tilt; + planet.castShadow = true; + planet.receiveShadow = true; + orbitRoot.add(planet); + + let ringObject: THREE.Object3D | undefined; + if (planetDef.hasRing) { + const ring = new THREE.Mesh( + new THREE.RingGeometry(planetDef.size * 1.3, planetDef.size * 2, 72), + new THREE.MeshBasicMaterial({ + color: 0xc1b299, + side: THREE.DoubleSide, + transparent: true, + opacity: 0.4, + }), + ); + ring.rotation.x = Math.PI / 2.35; + ring.position.x = planetDef.orbitRadius; + orbitRoot.add(ring); + ringObject = ring; + } + + const orbitLine = new THREE.LineLoop( + new THREE.BufferGeometry().setFromPoints( + Array.from({ length: 120 }, (_, step) => { + const angle = (step / 120) * Math.PI * 2; + return new THREE.Vector3( + Math.cos(angle) * planetDef.orbitRadius, + 0, + Math.sin(angle) * planetDef.orbitRadius, + ); + }), + ), + new THREE.LineBasicMaterial({ color: 0x17314d, transparent: true, opacity: 0.52 }), + ); + root.add(orbitLine); + orbitLines.push(orbitLine); + + root.add(orbitRoot); + planets.push({ group: orbitRoot, mesh: planet, orbitSpeed: planetDef.orbitSpeed, ring: ringObject }); + }); + + const asteroidDecorations = this.createAsteroidField(definition.id, root, definition.gravityWellRadius + 330); + const strategicMarker = this.createStrategicMarker(definition); + + this.systems.push({ + definition, + root, + center: new THREE.Vector3(...definition.position), + planets, + star, + gravityWellRadius: definition.gravityWellRadius, + orbitLines, + asteroidDecorations, + strategicMarker, + }); + }); + } + + private createAsteroidField(systemId: string, root: THREE.Group, baseRadius: number) { + const rockGeometry = new THREE.IcosahedronGeometry(1, 0); + const rockMaterial = new THREE.MeshStandardMaterial({ + color: 0x707582, + roughness: 1, + metalness: 0.05, + }); + const decorations: THREE.Object3D[] = []; + + for (let i = 0; i < 180; i += 1) { + const rock = new THREE.Mesh(rockGeometry, rockMaterial); + const angle = Math.random() * Math.PI * 2; + const radius = baseRadius + (Math.random() - 0.5) * 90; + rock.position.set(Math.cos(angle) * radius, (Math.random() - 0.5) * 18, Math.sin(angle) * radius); + rock.scale.setScalar(1.5 + Math.random() * 4); + rock.rotation.set(Math.random() * Math.PI, Math.random() * Math.PI, Math.random() * Math.PI); + root.add(rock); + decorations.push(rock); + } + + const spawnResourceNodes = systemId === "perseus"; + for (let i = 0; i < 3; i += 1) { + const angle = (i / 3) * Math.PI * 2 + 0.45; + const position = new THREE.Vector3( + Math.cos(angle) * (baseRadius + 30), + 0, + Math.sin(angle) * (baseRadius + 30), + ); + + const cluster = new THREE.Group(); + cluster.position.copy(position); + for (let j = 0; j < 7; j += 1) { + const shard = new THREE.Mesh( + new THREE.DodecahedronGeometry(6 + Math.random() * 7, 0), + new THREE.MeshStandardMaterial({ + color: 0xd1bd7c, + emissive: new THREE.Color(0xffdd75).multiplyScalar(0.08), + roughness: 0.9, + metalness: 0.15, + }), + ); + shard.position.set((Math.random() - 0.5) * 18, (Math.random() - 0.5) * 12, (Math.random() - 0.5) * 18); + cluster.add(shard); + } + root.add(cluster); + decorations.push(cluster); + + if (spawnResourceNodes) { + this.nodes.push({ + id: `node-${this.nodeId += 1}`, + systemId, + position: cluster.getWorldPosition(new THREE.Vector3()), + mesh: cluster, + oreRemaining: 3000, + itemId: "ore", + }); + } + } + return decorations; + } + + private createStrategicMarker(definition: SolarSystemDefinition) { + const marker = new THREE.Group(); + marker.position.set(...definition.position); + + const outer = new THREE.Mesh( + new THREE.RingGeometry(definition.gravityWellRadius * 0.9, definition.gravityWellRadius * 1.05, 64), + new THREE.MeshBasicMaterial({ + color: definition.starColor, + transparent: true, + opacity: 0.4, + side: THREE.DoubleSide, + }), + ); + outer.rotation.x = -Math.PI / 2; + marker.add(outer); + + const core = new THREE.Mesh( + new THREE.CircleGeometry(definition.gravityWellRadius * 0.22, 32), + new THREE.MeshBasicMaterial({ + color: definition.starColor, + transparent: true, + opacity: 0.7, + side: THREE.DoubleSide, + }), + ); + core.rotation.x = -Math.PI / 2; + marker.add(core); + + marker.visible = false; + this.scene.add(marker); + return marker; + } + + private createStrategicLinks() { + if (this.systems.length < 2) { + return; + } + + const line = new THREE.Line( + new THREE.BufferGeometry().setFromPoints(this.systems.map((system) => system.center)), + new THREE.LineDashedMaterial({ + color: 0x5e8fbe, + dashSize: 120, + gapSize: 80, + transparent: true, + opacity: 0.5, + }), + ); + line.computeLineDistances(); + this.strategicLinks.add(line); + this.strategicLinks.visible = false; + } + + private createNebulae() { + const colors: [string, string, string][] = [ + ["rgba(126,212,255,0.75)", "rgba(197,111,255,0.32)", "rgba(0,0,0,0)"], + ["rgba(255,157,102,0.72)", "rgba(255,102,129,0.28)", "rgba(0,0,0,0)"], + ["rgba(138,255,199,0.7)", "rgba(72,111,255,0.2)", "rgba(0,0,0,0)"], + ]; + + const positions = [ + new THREE.Vector3(-1800, 260, -1100), + new THREE.Vector3(1800, -100, -1600), + new THREE.Vector3(3300, 160, 1800), + new THREE.Vector3(5200, 220, -900), + new THREE.Vector3(6400, 100, 1500), + ]; + + positions.forEach((position, index) => { + const sprite = new THREE.Sprite( + new THREE.SpriteMaterial({ + map: this.makeRadialTexture(colors[index % colors.length]), + transparent: true, + depthWrite: false, + opacity: 0.34, + blending: THREE.AdditiveBlending, + }), + ); + sprite.position.copy(position); + sprite.scale.setScalar(1000 + (index % 3) * 220); + sprite.material.rotation = index * 0.67; + this.scene.add(sprite); + }); + } + + private createStarfield() { + const starCount = 9000; + const positions = new Float32Array(starCount * 3); + const colors = new Float32Array(starCount * 3); + const color = new THREE.Color(); + + for (let i = 0; i < starCount; i += 1) { + const radius = 4200 + Math.random() * 7600; + const theta = Math.random() * Math.PI * 2; + const phi = Math.acos(2 * Math.random() - 1); + const centerBias = Math.random() > 0.5 ? 2200 : 0; + + positions[i * 3] = radius * Math.sin(phi) * Math.cos(theta) + centerBias; + positions[i * 3 + 1] = radius * Math.cos(phi); + positions[i * 3 + 2] = radius * Math.sin(phi) * Math.sin(theta); + + color.setHSL(0.55 + Math.random() * 0.15, 0.56, 0.7 + Math.random() * 0.28); + colors[i * 3] = color.r; + colors[i * 3 + 1] = color.g; + colors[i * 3 + 2] = color.b; + } + + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); + geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3)); + + this.starfield = new THREE.Points( + geometry, + new THREE.PointsMaterial({ + size: 8, + sizeAttenuation: true, + vertexColors: true, + transparent: true, + opacity: 0.9, + depthWrite: false, + }), + ); + this.scene.add(this.starfield); + } + + private createInitialStations() { + const stationPlans: Array<{ + systemId: string; + definition: ConstructibleDefinition; + planetIndex: number; + lagrangeSide: -1 | 1; + }> = [ + { systemId: "helios", definition: constructibleDefinitions[0], planetIndex: 1, lagrangeSide: 1 }, + { systemId: "helios", definition: constructibleDefinitions[1], planetIndex: 2, lagrangeSide: -1 }, + { systemId: "helios", definition: constructibleDefinitions[2], planetIndex: 1, lagrangeSide: -1 }, + { systemId: "helios", definition: constructibleDefinitions[3], planetIndex: 3, lagrangeSide: 1 }, + { systemId: "helios", definition: constructibleDefinitions[4], planetIndex: 2, lagrangeSide: 1 }, + ]; + + stationPlans.forEach((plan) => this.placeStation(plan.definition, new THREE.Vector3(), plan.systemId, plan.planetIndex, plan.lagrangeSide)); + } + + private placeStation( + definition: ConstructibleDefinition, + position: THREE.Vector3, + systemId: string, + planetIndex?: number, + lagrangeSide?: -1 | 1, + ) { + const group = new THREE.Group(); + group.position.copy(position); + + const core = new THREE.Mesh( + new THREE.CylinderGeometry(definition.radius * 0.4, definition.radius * 0.6, definition.radius * 1.2, 8), + new THREE.MeshStandardMaterial({ + color: definition.color, + emissive: new THREE.Color(definition.color).multiplyScalar(0.12), + roughness: 0.55, + metalness: 0.45, + }), + ); + core.rotation.z = Math.PI / 2; + core.castShadow = true; + core.receiveShadow = true; + group.add(core); + + const ring = new THREE.Mesh( + new THREE.TorusGeometry(definition.radius, Math.max(2.4, definition.radius * 0.08), 18, 48), + new THREE.MeshStandardMaterial({ + color: 0xcdd8e5, + emissive: new THREE.Color(definition.color).multiplyScalar(0.05), + roughness: 0.4, + metalness: 0.7, + }), + ); + ring.rotation.x = Math.PI / 2; + group.add(ring); + + const selectionRing = new THREE.Mesh( + new THREE.RingGeometry(definition.radius * 1.3, definition.radius * 1.5, 40), + new THREE.MeshBasicMaterial({ + color: definition.color, + transparent: true, + opacity: 0, + side: THREE.DoubleSide, + }), + ); + selectionRing.rotation.x = -Math.PI / 2; + selectionRing.position.y = -definition.radius * 0.32; + group.add(selectionRing); + + const dockingPorts = Array.from({ length: definition.dockingCapacity }, (_, index) => { + const angle = (index / Math.max(1, definition.dockingCapacity)) * Math.PI * 2; + const port = new THREE.Vector3( + Math.cos(angle) * (definition.radius + 18), + Y_PLANE, + Math.sin(angle) * (definition.radius + 18), + ); + const beacon = new THREE.Mesh( + new THREE.BoxGeometry(5, 2, 9), + new THREE.MeshBasicMaterial({ color: definition.color, transparent: true, opacity: 0.75 }), + ); + beacon.position.copy(port); + beacon.lookAt(new THREE.Vector3(0, Y_PLANE, 0)); + group.add(beacon); + return port; + }); + + for (let i = 0; i < 4; i += 1) { + const arm = new THREE.Mesh( + new THREE.BoxGeometry(definition.radius * 0.2, definition.radius * 0.15, definition.radius * 1.5), + new THREE.MeshStandardMaterial({ color: 0x8294a9, roughness: 0.55, metalness: 0.5 }), + ); + arm.position.set( + Math.cos((i / 4) * Math.PI * 2) * definition.radius * 0.75, + 0, + Math.sin((i / 4) * Math.PI * 2) * definition.radius * 0.75, + ); + group.add(arm); + } + + this.scene.add(group); + const station: StationInstance = { + id: `station-${this.stationId += 1}`, + definition, + group, + systemId, + ring: selectionRing, + oreStored: 0, + refinedStock: 0, + processTimer: 0, + activeBatch: 0, + inventory: this.makeEmptyInventory(), + dockedShipIds: new Set(), + dockingPorts, + modules: definition.modules, + orbitalParentPlanetIndex: planetIndex, + lagrangeSide, + fuel: 800, + energy: 1200, + maxFuel: 800, + maxEnergy: 1200, + }; + this.stations.push(station); + this.selectableTargets.set(core, { kind: "station", station }); + this.selectableTargets.set(ring, { kind: "station", station }); + } + + private createInitialShips() { + const formationPlans: Array<{ definition: ShipDefinition; count: number; center: THREE.Vector3; systemId: string }> = [ + { definition: shipDefinitions[0], count: 6, center: new THREE.Vector3(180, 0, 90), systemId: "helios" }, + { definition: shipDefinitions[1], count: 3, center: new THREE.Vector3(260, 0, 120), systemId: "helios" }, + { definition: shipDefinitions[2], count: 4, center: new THREE.Vector3(310, 0, -150), systemId: "helios" }, + { definition: shipDefinitions[0], count: 4, center: new THREE.Vector3(4350, 0, 560), systemId: "perseus" }, + { definition: shipDefinitions[3], count: 6, center: new THREE.Vector3(4620, 0, 700), systemId: "perseus" }, + ]; + + formationPlans.forEach(({ definition, count, center, systemId }) => { + for (let i = 0; i < count; i += 1) { + const ship = this.makeShip(definition, systemId); + ship.group.position.copy(center).add(new THREE.Vector3((i % 3) * 18, Y_PLANE, Math.floor(i / 3) * 18)); + ship.target.copy(ship.group.position); + const systemCenter = this.getSystemCenterById(systemId); + ship.idleOrbitRadius = ship.group.position.clone().setY(0).distanceTo(systemCenter); + ship.idleOrbitAngle = Math.atan2(ship.group.position.z - systemCenter.z, ship.group.position.x - systemCenter.x); + this.scene.add(ship.group); + this.ships.push(ship); + this.shipsById.set(ship.id, ship); + } + }); + } + + private makeShip(definition: ShipDefinition, systemId: string): ShipInstance { + const group = new THREE.Group(); + const visual = new THREE.Group(); + visual.rotation.y = Math.PI / 2; + group.add(visual); + const warpFx = new THREE.Group(); + warpFx.visible = false; + for (let i = 0; i < 5; i += 1) { + const streak = new THREE.Mesh( + new THREE.CylinderGeometry(0.12, 0.5, definition.size * 8, 8), + new THREE.MeshBasicMaterial({ color: definition.color, transparent: true, opacity: 0.22 }), + ); + streak.rotation.z = Math.PI / 2; + streak.position.set(-definition.size * (2 + i * 1.7), (i - 2) * 0.45, 0); + warpFx.add(streak); + } + visual.add(warpFx); + const bodyMaterial = new THREE.MeshStandardMaterial({ + color: definition.hullColor, + emissive: new THREE.Color(definition.color).multiplyScalar(0.08), + roughness: 0.45, + metalness: 0.7, + }); + + const hull = new THREE.Mesh( + new THREE.CylinderGeometry(definition.size * 0.3, definition.size, definition.size * 3, 6), + bodyMaterial, + ); + hull.rotation.z = -Math.PI / 2; + hull.castShadow = true; + visual.add(hull); + + const nose = new THREE.Mesh( + new THREE.ConeGeometry(definition.size * 0.7, definition.size * 1.8, 6), + new THREE.MeshStandardMaterial({ + color: definition.color, + emissive: new THREE.Color(definition.color).multiplyScalar(0.12), + roughness: 0.35, + metalness: 0.65, + }), + ); + nose.rotation.z = -Math.PI / 2; + nose.position.x = definition.size * 2.1; + visual.add(nose); + + const wingGeometry = new THREE.BoxGeometry(definition.size * 0.25, definition.size * 1.8, definition.size * 0.7); + [-1, 1].forEach((side) => { + const wing = new THREE.Mesh(wingGeometry, bodyMaterial); + wing.position.set(0, side * definition.size * 0.9, 0); + visual.add(wing); + }); + + const engineGlow = new THREE.Mesh( + new THREE.SphereGeometry(definition.size * 0.35, 14, 14), + new THREE.MeshBasicMaterial({ color: definition.color, transparent: true, opacity: 0.72 }), + ); + engineGlow.position.x = -definition.size * 1.8; + visual.add(engineGlow); + + const ring = new THREE.Mesh( + new THREE.RingGeometry(definition.size * 1.5, definition.size * 1.9, 32), + new THREE.MeshBasicMaterial({ + color: definition.color, + transparent: true, + opacity: 0, + side: THREE.DoubleSide, + }), + ); + ring.rotation.x = -Math.PI / 2; + ring.position.y = -definition.size * 0.55; + group.add(ring); + + const pickHull = new THREE.Mesh( + new THREE.SphereGeometry(definition.size * 1.6, 12, 12), + new THREE.MeshBasicMaterial({ visible: false }), + ); + group.add(pickHull); + + const ship: ShipInstance = { + id: `ship-${this.shipId += 1}`, + definition, + group, + target: new THREE.Vector3(), + velocity: new THREE.Vector3(), + selected: false, + ring, + systemId, + state: "idle", + order: { kind: "idle" }, + inventory: this.makeEmptyInventory(), + cargoItemId: definition.cargoItemId, + actionTimer: 0, + fuel: 220, + energy: 260, + maxFuel: 220, + maxEnergy: 260, + idleOrbitRadius: Math.max(120, group.position.length()), + idleOrbitAngle: 0, + warpFx, + }; + + this.selectableTargets.set(pickHull, { kind: "ship", ship }); + this.selectableTargets.set(hull, { kind: "ship", ship }); + return ship; + } + + private assignDefaultOrders() { + const miners = this.ships.filter((ship) => ship.definition.role === "mining"); + const centralRefinery = this.findRefinery("helios"); + miners.forEach((ship) => this.assignMineOrder(ship, this.findBestMiningNode("perseus"), centralRefinery)); + + const militaryBySystem = this.groupShipsBySystem("military"); + militaryBySystem.forEach((ships, systemId) => { + const patrolPoints = this.makePatrolPoints(systemId); + ships.forEach((ship, index) => { + this.setPatrolOrder(ship, patrolPoints, index % patrolPoints.length); + }); + }); + + const transports = this.ships.filter((ship) => ship.definition.role === "transport"); + transports.forEach((ship, index) => { + const minersInSystem = miners; + const escortTarget = minersInSystem[index % Math.max(minersInSystem.length, 1)]; + if (escortTarget) { + this.setEscortOrder(ship, escortTarget); + } + }); + } + + private groupShipsBySystem(role: ShipDefinition["role"]) { + const map = new Map(); + this.ships + .filter((ship) => ship.definition.role === role) + .forEach((ship) => { + const bucket = map.get(ship.systemId) ?? []; + bucket.push(ship); + map.set(ship.systemId, bucket); + }); + return map; + } + + private makePatrolPoints(systemId: string) { + const system = this.getSystem(systemId); + return [ + system.center.clone().add(new THREE.Vector3(180, 0, 120)), + system.center.clone().add(new THREE.Vector3(360, 0, -140)), + system.center.clone().add(new THREE.Vector3(620, 0, 210)), + system.center.clone().add(new THREE.Vector3(260, 0, 320)), + ]; + } + + private bindEvents() { + window.addEventListener("resize", this.onResize); + window.addEventListener("keydown", this.onKeyDown); + window.addEventListener("keyup", this.onKeyUp); + this.renderer.domElement.addEventListener("pointerdown", this.onPointerDown); + this.renderer.domElement.addEventListener("pointermove", this.onPointerMove); + this.renderer.domElement.addEventListener("pointerup", this.onPointerUp); + this.renderer.domElement.addEventListener("pointerleave", this.onPointerUp); + this.renderer.domElement.addEventListener("click", this.onClick); + this.renderer.domElement.addEventListener("contextmenu", this.onContextMenu); + this.renderer.domElement.addEventListener("wheel", this.onWheel, { passive: false }); + } + + private onResize = () => { + const width = window.innerWidth; + const height = window.innerHeight; + this.camera.aspect = width / height; + this.camera.updateProjectionMatrix(); + this.renderer.setSize(width, height); + this.strategicOverlayEl.width = Math.floor(width * Math.min(window.devicePixelRatio, 2)); + this.strategicOverlayEl.height = Math.floor(height * Math.min(window.devicePixelRatio, 2)); + this.strategicOverlayEl.style.width = `${width}px`; + this.strategicOverlayEl.style.height = `${height}px`; + }; + + private onKeyDown = (event: KeyboardEvent) => { + if (event.repeat) { + return; + } + + const key = event.key.toLowerCase(); + this.keyState.add(key); + + if (key === "b") { + this.buildMode = !this.buildMode; + this.updateHud(); + return; + } + + if (key === "tab") { + event.preventDefault(); + this.selectedSystemIndex = (this.selectedSystemIndex + 1) % this.systems.length; + this.focusSystem(this.systems[this.selectedSystemIndex].definition.id); + return; + } + + if (key === "f") { + this.focusSelection(); + return; + } + + if (key === "-" || key === "_") { + this.adjustZoom(1.18); + return; + } + + if (key === "=" || key === "+") { + this.adjustZoom(1 / 1.18); + return; + } + + if (key === "m") { + this.selection + .filter((ship) => ship.definition.role === "mining") + .forEach((ship) => this.assignMineOrder(ship, this.findBestMiningNode("perseus"), this.findRefinery("helios"))); + this.updateHud(); + return; + } + + if (key === "p") { + this.selection + .filter((ship) => ship.definition.role === "military") + .forEach((ship) => this.setPatrolOrder(ship, this.makePatrolPoints(ship.systemId), 0)); + this.updateHud(); + return; + } + + if (key === "e") { + const escorts = this.selection.filter((ship) => ship.definition.role !== "mining"); + escorts.forEach((ship) => { + const target = this.findNearestFriendlyToEscort(ship); + if (target) { + this.setEscortOrder(ship, target); + } + }); + this.updateHud(); + return; + } + + const slot = Number(key); + if (!Number.isNaN(slot) && slot >= 1 && slot <= constructibleDefinitions.length) { + this.selectedConstructible = slot - 1; + this.updateHud(); + } + }; + + private onKeyUp = (event: KeyboardEvent) => { + this.keyState.delete(event.key.toLowerCase()); + }; + + private onPointerDown = (event: PointerEvent) => { + if (event.button !== 0) { + return; + } + this.marqueeStart = new THREE.Vector2(event.clientX, event.clientY); + this.marqueeModifiers = { shift: event.shiftKey, ctrl: event.ctrlKey || event.metaKey }; + this.marqueeActive = false; + this.suppressClickSelection = false; + this.updateMarqueeBox(event.clientX, event.clientY); + }; + + private onPointerMove = (event: PointerEvent) => { + if (!this.marqueeStart) { + return; + } + const dx = event.clientX - this.marqueeStart.x; + const dy = event.clientY - this.marqueeStart.y; + if (!this.marqueeActive && Math.hypot(dx, dy) > 8) { + this.marqueeActive = true; + this.suppressClickSelection = true; + } + if (this.marqueeActive) { + this.updateMarqueeBox(event.clientX, event.clientY); + } + }; + + private onPointerUp = (event: PointerEvent) => { + if (!this.marqueeStart) { + return; + } + if (this.marqueeActive) { + this.applyMarqueeSelection(event.clientX, event.clientY); + } + this.marqueeStart = undefined; + this.marqueeActive = false; + this.hideMarqueeBox(); + }; + + private onClick = (event: MouseEvent) => { + if (this.suppressClickSelection) { + this.suppressClickSelection = false; + return; + } + this.updateMouse(event.clientX, event.clientY); + this.raycaster.setFromCamera(this.mouse, this.camera); + const hits = this.raycaster.intersectObjects([...this.selectableTargets.keys()], false); + + const additive = event.shiftKey; + const toggle = event.ctrlKey || event.metaKey; + + if (!additive && !toggle) { + this.clearSelection(); + } + + if (hits.length > 0) { + const target = this.selectableTargets.get(hits[0].object); + if (target?.kind === "ship") { + if (toggle && this.selection.includes(target.ship)) { + this.removeShipFromSelection(target.ship); + } else if (!this.selection.includes(target.ship)) { + this.addShipToSelection(target.ship); + } + this.selectedStation = undefined; + } + if (target?.kind === "station") { + this.clearSelection(); + this.selectedStation = target.station; + (target.station.ring.material as THREE.MeshBasicMaterial).opacity = 0.95; + } + } + + this.updateHud(); + }; + + private onContextMenu = (event: MouseEvent) => { + event.preventDefault(); + this.updateMouse(event.clientX, event.clientY); + this.raycaster.setFromCamera(this.mouse, this.camera); + + const point = new THREE.Vector3(); + if (!this.raycaster.ray.intersectPlane(this.movePlane, point)) { + return; + } + point.y = Y_PLANE; + + const system = this.findNearestSystem(point); + if (this.buildMode) { + this.placeStation(constructibleDefinitions[this.selectedConstructible], point, system.definition.id); + return; + } + + if (this.selection.length === 0) { + return; + } + + const columns = Math.ceil(Math.sqrt(this.selection.length)); + this.selection.forEach((ship, index) => { + const row = Math.floor(index / columns); + const column = index % columns; + const offset = new THREE.Vector3((column - (columns - 1) / 2) * 22, 0, row * 22); + this.issueMoveOrder(ship, point.clone().add(offset)); + }); + this.updateHud(); + }; + + private onWheel = (event: WheelEvent) => { + event.preventDefault(); + this.adjustZoom(1 + event.deltaY * 0.0012); + }; + + private updateMouse(clientX: number, clientY: number) { + this.mouse.x = (clientX / window.innerWidth) * 2 - 1; + this.mouse.y = -(clientY / window.innerHeight) * 2 + 1; + } + + private clearSelection() { + this.selection.forEach((ship) => { + ship.selected = false; + (ship.ring.material as THREE.MeshBasicMaterial).opacity = 0; + }); + this.selection = []; + if (this.selectedStation) { + (this.selectedStation.ring.material as THREE.MeshBasicMaterial).opacity = 0; + this.selectedStation = undefined; + } + } + + private addShipToSelection(ship: ShipInstance) { + if (this.selection.includes(ship)) { + return; + } + this.selection.push(ship); + ship.selected = true; + (ship.ring.material as THREE.MeshBasicMaterial).opacity = 0.95; + } + + private removeShipFromSelection(ship: ShipInstance) { + this.selection = this.selection.filter((candidate) => candidate.id !== ship.id); + ship.selected = false; + (ship.ring.material as THREE.MeshBasicMaterial).opacity = 0; + } + + private updateMarqueeBox(clientX: number, clientY: number) { + if (!this.marqueeStart) { + return; + } + const left = Math.min(this.marqueeStart.x, clientX); + const top = Math.min(this.marqueeStart.y, clientY); + const width = Math.abs(clientX - this.marqueeStart.x); + const height = Math.abs(clientY - this.marqueeStart.y); + this.marqueeEl.style.display = this.marqueeActive ? "block" : "none"; + this.marqueeEl.style.left = `${left}px`; + this.marqueeEl.style.top = `${top}px`; + this.marqueeEl.style.width = `${width}px`; + this.marqueeEl.style.height = `${height}px`; + } + + private hideMarqueeBox() { + this.marqueeEl.style.display = "none"; + this.marqueeEl.style.width = "0"; + this.marqueeEl.style.height = "0"; + } + + private applyMarqueeSelection(clientX: number, clientY: number) { + if (!this.marqueeStart) { + return; + } + const left = Math.min(this.marqueeStart.x, clientX); + const right = Math.max(this.marqueeStart.x, clientX); + const top = Math.min(this.marqueeStart.y, clientY); + const bottom = Math.max(this.marqueeStart.y, clientY); + + if (!this.marqueeModifiers.shift && !this.marqueeModifiers.ctrl) { + this.clearSelection(); + } + + this.selectedStation = undefined; + this.ships.forEach((ship) => { + if (ship.state === "docked" || !ship.group.visible) { + return; + } + const screen = ship.group.position.clone().project(this.camera); + if (screen.z < -1 || screen.z > 1) { + return; + } + const sx = ((screen.x + 1) * 0.5) * window.innerWidth; + const sy = ((-screen.y + 1) * 0.5) * window.innerHeight; + const inside = sx >= left && sx <= right && sy >= top && sy <= bottom; + if (!inside) { + return; + } + if (this.marqueeModifiers.ctrl && this.selection.includes(ship)) { + this.removeShipFromSelection(ship); + } else { + this.addShipToSelection(ship); + } + }); + + this.updateHud(); + } + + private updateHud() { + const selectedDefinition = constructibleDefinitions[this.selectedConstructible]; + const system = this.systems[this.selectedSystemIndex]; + const selectedCount = this.selection.length + (this.selectedStation ? 1 : 0); + this.selectionTitleEl.textContent = this.getSelectionTitle(); + this.detailsEl.textContent = this.getSelectionDetails(); + this.statusEl.textContent = this.buildMode + ? `Build Mode: ${selectedDefinition.label} in ${system.definition.label} • ${this.viewLevel} view` + : `Command Mode: ${selectedCount} selected • Camera ${system.definition.label} • ${this.viewLevel} view${this.followShipId ? " • following ship" : ""}`; + this.ordersEl.dataset.mode = this.selectedStation ? "station" : this.selection.length > 0 ? "ships" : "none"; + } + + private tick() { + const delta = Math.min(this.clock.getDelta(), 0.033); + const elapsed = this.clock.elapsedTime; + + this.updateCamera(delta); + this.updateShips(delta, elapsed); + this.updateSystems(delta); + this.applyViewLevel(); + if (this.selection.length > 0 || this.selectedStation || this.followShipId) { + this.updateHud(); + } + this.drawMinimap(); + this.drawStrategicOverlay(); + + this.scene.rotation.y = Math.sin(elapsed * 0.02) * 0.008; + this.renderer.render(this.scene, this.camera); + } + + private updateCamera(delta: number) { + const focus = this.getCameraFocus(); + const followedShip = this.followShipId ? this.shipsById.get(this.followShipId) : undefined; + if (followedShip) { + focus.lerp(followedShip.group.position, Math.min(1, delta * 3.2)); + this.selectedSystemIndex = this.systems.findIndex((system) => system.definition.id === followedShip.systemId); + } + + const forward = new THREE.Vector3(); + this.camera.getWorldDirection(forward); + forward.y = 0; + forward.normalize(); + const right = new THREE.Vector3().crossVectors(forward, new THREE.Vector3(0, 1, 0)).normalize(); + + const panSpeed = Math.max(80, this.camera.position.distanceTo(focus) * 0.7) * delta; + let manualCameraInput = false; + if (this.keyState.has("w")) { + focus.add(forward.clone().multiplyScalar(panSpeed)); + manualCameraInput = true; + } + if (this.keyState.has("s")) { + focus.add(forward.clone().multiplyScalar(-panSpeed)); + manualCameraInput = true; + } + if (this.keyState.has("a")) { + focus.add(right.clone().multiplyScalar(panSpeed)); + manualCameraInput = true; + } + if (this.keyState.has("d")) { + focus.add(right.clone().multiplyScalar(-panSpeed)); + manualCameraInput = true; + } + if (this.keyState.has("q") || this.keyState.has("e")) { + const angle = (this.keyState.has("q") ? 1 : -1) * delta * 0.9; + const offset = this.camera.position.clone().sub(focus); + offset.applyAxisAngle(new THREE.Vector3(0, 1, 0), angle); + this.camera.position.copy(focus).add(offset); + manualCameraInput = true; + } + + if (manualCameraInput) { + this.followShipId = undefined; + } + + this.camera.lookAt(focus); + } + + private applyViewLevel() { + const distance = this.camera.position.distanceTo(this.getCameraFocus()); + const nextLevel: ViewLevel = distance < 950 ? "local" : distance < 3200 ? "solar" : "universe"; + const fog = this.scene.fog as THREE.FogExp2; + if (nextLevel !== this.viewLevel) { + this.viewLevel = nextLevel; + this.updateHud(); + } + + if (this.viewLevel === "local") { + this.camera.fov = 54; + fog.density = 0.00006; + } else if (this.viewLevel === "solar") { + this.camera.fov = 42; + fog.density = 0.00003; + } else { + this.camera.fov = 28; + fog.density = 0.000012; + } + this.camera.updateProjectionMatrix(); + + this.systems.forEach((system) => { + const universe = this.viewLevel === "universe"; + + system.orbitLines.forEach((orbit) => { + orbit.visible = !universe; + (orbit.material as THREE.LineBasicMaterial).opacity = this.viewLevel === "local" ? 0.52 : 0.24; + }); + + system.asteroidDecorations.forEach((object) => { + object.visible = this.viewLevel === "local"; + }); + + system.planets.forEach((planet) => { + planet.mesh.visible = !universe; + if (planet.ring) { + planet.ring.visible = !universe; + } + }); + + system.star.visible = !universe; + system.strategicMarker.visible = universe; + }); + + this.nodes.forEach((node) => { + node.mesh.visible = this.viewLevel === "local"; + }); + + this.stations.forEach((station) => { + station.group.visible = this.viewLevel !== "universe"; + station.group.scale.setScalar(this.viewLevel === "solar" ? 1.15 : 1); + }); + + this.ships.forEach((ship) => { + ship.group.visible = this.viewLevel !== "universe"; + ship.group.scale.setScalar(this.viewLevel === "solar" ? 1.3 : 1); + }); + + this.strategicLinks.visible = this.viewLevel === "universe"; + if (this.starfield) { + const material = this.starfield.material as THREE.PointsMaterial; + material.size = this.viewLevel === "universe" ? 12 : this.viewLevel === "solar" ? 9 : 8; + material.opacity = this.viewLevel === "local" ? 0.9 : this.viewLevel === "solar" ? 0.7 : 0.45; + material.needsUpdate = true; + } + } + + private updateShips(delta: number, elapsed: number) { + this.ships.forEach((ship, index) => { + this.consumeShipResources(ship, delta); + + if (ship.state === "undocking") { + if (this.moveShipToward(ship, ship.target, ship.definition.speed * 0.8, delta, 8)) { + ship.state = "idle"; + } + } + + switch (ship.order.kind) { + case "idle": + if (ship.state !== "docked" && ship.state !== "undocking") { + ship.state = "idle"; + ship.velocity.multiplyScalar(0.9); + this.updateIdleOrbit(ship, delta); + } + break; + case "move": + if (this.updateTravelState(ship, ship.order.destination, ship.order.systemId, delta, ARRIVAL_THRESHOLD)) { + ship.order = { kind: "idle" }; + ship.travelPlan = undefined; + } + break; + case "transfer": + this.updateTransferOrder(ship, delta); + break; + case "mine": + this.updateMiningOrder(ship, delta); + break; + case "patrol": + this.updatePatrolOrder(ship, delta); + break; + case "escort": + this.updateEscortOrder(ship, delta); + break; + } + + if (ship.state === "docked") { + ship.group.rotation.z = 0; + ship.energy = Math.min(ship.maxEnergy, ship.energy + SHIP_RECHARGE_RATE * delta); + } else if (ship.state !== "warping") { + ship.group.position.y = Y_PLANE + Math.sin(elapsed * 1.2 + index) * 0.7; + ship.group.rotation.z = Math.sin(elapsed * 2 + index) * 0.04; + } else { + ship.group.position.y = Y_PLANE; + ship.group.rotation.z = 0; + } + + ship.warpFx.visible = ship.state === "warping" || ship.state === "spooling-ftl"; + ship.warpFx.scale.x = ship.state === "warping" ? 2.4 : 1.2; + }); + } + + private updateTransferOrder(ship: ShipInstance, delta: number) { + const order = ship.order; + if (order.kind !== "transfer") { + return; + } + if (this.updateTravelState(ship, order.destination, order.destinationSystemId, delta, ARRIVAL_THRESHOLD, order)) { + ship.order = { kind: "idle" }; + ship.travelPlan = undefined; + } + } + + private updateMiningOrder(ship: ShipInstance, delta: number) { + const order = ship.order; + if (order.kind !== "mine") { + return; + } + + const node = this.nodes.find((candidate) => candidate.id === order.nodeId); + const refinery = this.stations.find((candidate) => candidate.id === order.refineryId); + if (!node || !refinery) { + ship.order = { kind: "idle" }; + ship.state = "idle"; + return; + } + + const cargo = this.getShipCargoAmount(ship); + if (cargo >= ship.definition.cargoCapacity) { + order.phase = "to-refinery"; + } + + if (order.phase === "to-node") { + if (this.updateTravelState(ship, node.position, node.systemId, delta, 26)) { + order.phase = "mining"; + } + return; + } + + if (order.phase === "mining") { + ship.state = "mining"; + ship.actionTimer += delta; + ship.velocity.multiplyScalar(0.75); + if (ship.actionTimer >= 1) { + const mined = Math.min(MINING_RATE, ship.definition.cargoCapacity - cargo, node.oreRemaining); + this.addShipCargo(ship, mined); + node.oreRemaining = Math.max(0, node.oreRemaining - mined); + ship.actionTimer = 0; + if (this.getShipCargoAmount(ship) >= ship.definition.cargoCapacity) { + order.phase = "to-refinery"; + } + if (node.oreRemaining <= 0) { + node.oreRemaining = 3000; + } + } + return; + } + + if (order.phase === "to-refinery") { + if (this.updateTravelState(ship, refinery.group.position, refinery.systemId, delta, refinery.definition.radius + 30)) { + order.phase = "transfer"; + } + return; + } + + if (order.phase === "transfer") { + if (this.updateDockingState(ship, refinery, delta)) { + const transferred = this.removeShipCargo(ship, this.getShipCargoAmount(ship)); + refinery.inventory["bulk-solid"] += transferred; + refinery.oreStored += transferred; + order.phase = "to-node"; + this.beginUndock(ship, refinery); + } + } + } + + private updatePatrolOrder(ship: ShipInstance, delta: number) { + const order = ship.order; + if (order.kind !== "patrol") { + return; + } + + ship.state = "patrolling"; + const target = order.points[order.index]; + if (ship.systemId !== order.systemId) { + this.issueMoveOrder(ship, target.clone()); + return; + } + + if (this.moveShipToward(ship, target, ship.definition.speed, delta, 20)) { + order.index = (order.index + 1) % order.points.length; + } + } + + private updateEscortOrder(ship: ShipInstance, delta: number) { + const order = ship.order; + if (order.kind !== "escort") { + return; + } + + const targetShip = this.shipsById.get(order.targetShipId); + if (!targetShip) { + ship.order = { kind: "idle" }; + ship.state = "idle"; + return; + } + + ship.state = "escorting"; + const anchor = targetShip.group.position.clone().add(order.offset); + if (targetShip.systemId !== ship.systemId) { + this.issueMoveOrder(ship, targetShip.group.position.clone()); + return; + } + + this.moveShipToward(ship, anchor, ship.definition.speed * 1.05, delta, 18); + } + + private updateSystems(delta: number) { + this.systems.forEach((system) => { + system.planets.forEach((planet) => { + planet.group.rotation.y += planet.orbitSpeed * delta * 0.3; + planet.mesh.rotation.y += delta * 0.18; + }); + }); + + this.stations.forEach((station) => { + if (station.orbitalParentPlanetIndex !== undefined && station.lagrangeSide) { + const system = this.getSystem(station.systemId); + const parentPlanet = system.planets[station.orbitalParentPlanetIndex]; + const planetPosition = parentPlanet.mesh.getWorldPosition(new THREE.Vector3()); + const radial = planetPosition.clone().sub(system.center); + const lagrange = radial + .clone() + .applyAxisAngle(new THREE.Vector3(0, 1, 0), station.lagrangeSide * Math.PI / 3) + .add(system.center) + .setY(0); + station.group.position.copy(lagrange); + } + + station.energy = Math.min(station.maxEnergy, station.energy + STATION_SOLAR_CHARGE * delta); + if (station.definition.category !== "refining") { + return; + } + + if (station.activeBatch <= 0 && station.oreStored > 0) { + station.activeBatch = Math.min(REFINING_BATCH_SIZE, station.oreStored); + station.oreStored -= station.activeBatch; + station.processTimer = REFINING_DURATION; + } + + if (station.activeBatch > 0) { + station.processTimer = Math.max(0, station.processTimer - delta); + if (station.processTimer <= 0) { + station.refinedStock += station.activeBatch; + station.activeBatch = 0; + } + } + }); + } + + private makeEmptyInventory(): InventoryState { + return { + "bulk-solid": 0, + "bulk-liquid": 0, + "bulk-gas": 0, + container: 0, + manufactured: 0, + }; + } + + private getSystemCenterById(systemId: string) { + return this.getSystem(systemId).center.clone(); + } + + private consumeShipResources(ship: ShipInstance, delta: number) { + const movingStates = new Set([ + "moving", + "mining-approach", + "delivering", + "docking-approach", + "docking", + "undocking", + "leaving-gravity-well", + "arriving", + "patrolling", + "escorting", + ]); + + if (ship.state === "warping" || ship.state === "spooling-ftl") { + ship.energy = Math.max(0, ship.energy - WARP_ENERGY_DRAIN * delta); + ship.fuel = Math.max(0, ship.fuel - WARP_FUEL_DRAIN * delta); + return; + } + + if (movingStates.has(ship.state)) { + ship.energy = Math.max(0, ship.energy - MOVE_ENERGY_DRAIN * delta); + return; + } + + if (ship.state !== "docked") { + ship.energy = Math.max(0, ship.energy - IDLE_ENERGY_DRAIN * delta); + } + } + + private updateIdleOrbit(ship: ShipInstance, delta: number) { + const systemCenter = this.getSystem(ship.systemId).center; + if (ship.idleOrbitRadius < 40) { + ship.idleOrbitRadius = ship.group.position.clone().setY(0).distanceTo(systemCenter); + ship.idleOrbitAngle = Math.atan2(ship.group.position.z - systemCenter.z, ship.group.position.x - systemCenter.x); + } + ship.idleOrbitAngle += delta * (14 / Math.max(ship.idleOrbitRadius, 120)); + const nextPosition = new THREE.Vector3( + systemCenter.x + Math.cos(ship.idleOrbitAngle) * ship.idleOrbitRadius, + Y_PLANE, + systemCenter.z + Math.sin(ship.idleOrbitAngle) * ship.idleOrbitRadius, + ); + ship.group.position.lerp(nextPosition, 0.12); + const tangent = new THREE.Vector3( + -Math.sin(ship.idleOrbitAngle), + 0, + Math.cos(ship.idleOrbitAngle), + ); + ship.group.lookAt(ship.group.position.clone().add(tangent)); + } + + private getShipCargoAmount(ship: ShipInstance) { + const kind = ship.definition.cargoKind; + return kind ? ship.inventory[kind] : 0; + } + + private addShipCargo(ship: ShipInstance, amount: number) { + const kind = ship.definition.cargoKind; + if (!kind) { + return 0; + } + ship.inventory[kind] += amount; + return amount; + } + + private removeShipCargo(ship: ShipInstance, amount: number) { + const kind = ship.definition.cargoKind; + if (!kind) { + return 0; + } + const transferred = Math.min(amount, ship.inventory[kind]); + ship.inventory[kind] -= transferred; + return transferred; + } + + private ensureTravelPlan(ship: ShipInstance, destination: THREE.Vector3, destinationSystemId: string, suppliedPlan?: TravelPlan) { + if ( + ship.travelPlan && + ship.travelPlan.destinationSystemId === destinationSystemId && + ship.travelPlan.destination.distanceToSquared(destination) < 1 + ) { + return ship.travelPlan; + } + + if (suppliedPlan) { + ship.travelPlan = { + destination: suppliedPlan.destination.clone(), + destinationSystemId: suppliedPlan.destinationSystemId, + exitPoint: suppliedPlan.exitPoint.clone().setY(Y_PLANE), + arrivalPoint: suppliedPlan.arrivalPoint.clone().setY(Y_PLANE), + }; + return ship.travelPlan; + } + + const currentSystem = this.getSystem(ship.systemId); + const destinationSystem = this.getSystem(destinationSystemId); + const exitDirection = ship.group.position.clone().sub(currentSystem.center).setY(0).normalize(); + if (exitDirection.lengthSq() === 0) { + exitDirection.copy(destinationSystem.center.clone().sub(currentSystem.center).setY(0).normalize()); + } + const arrivalDirection = destination.clone().sub(destinationSystem.center).setY(0).normalize(); + if (arrivalDirection.lengthSq() === 0) { + arrivalDirection.copy(currentSystem.center.clone().sub(destinationSystem.center).setY(0).normalize()); + } + + ship.travelPlan = { + destination: destination.clone().setY(Y_PLANE), + destinationSystemId, + exitPoint: currentSystem.center.clone().add(exitDirection.multiplyScalar(currentSystem.gravityWellRadius + 230)).setY(Y_PLANE), + arrivalPoint: destinationSystem.center.clone().add(arrivalDirection.multiplyScalar(destinationSystem.gravityWellRadius + 150)).setY(Y_PLANE), + }; + return ship.travelPlan; + } + + private updateTravelState( + ship: ShipInstance, + destination: THREE.Vector3, + destinationSystemId: string, + delta: number, + threshold: number, + suppliedPlan?: TravelPlan, + ) { + if (ship.state === "docked" && ship.dockedStationId) { + const station = this.stations.find((candidate) => candidate.id === ship.dockedStationId); + if (station) { + this.beginUndock(ship, station); + } + } + + const plan = this.ensureTravelPlan(ship, destination, destinationSystemId, suppliedPlan); + + if (ship.state === "undocking") { + return false; + } + + if (ship.systemId === destinationSystemId && ship.state !== "leaving-gravity-well" && ship.state !== "spooling-ftl" && ship.state !== "warping" && ship.state !== "arriving") { + ship.state = "moving"; + return this.moveShipToward(ship, destination, ship.definition.speed, delta, threshold); + } + + if (ship.state === "idle" || ship.state === "moving" || ship.state === "mining-approach" || ship.state === "delivering") { + ship.state = "leaving-gravity-well"; + } + + if (ship.state === "leaving-gravity-well") { + if (this.moveShipToward(ship, plan.exitPoint, ship.definition.speed, delta, 28)) { + ship.state = "spooling-ftl"; + ship.actionTimer = ship.definition.spoolTime; + } + return false; + } + + if (ship.state === "spooling-ftl") { + ship.actionTimer -= delta; + ship.velocity.multiplyScalar(0.8); + if (ship.actionTimer <= 0) { + ship.state = "warping"; + } + return false; + } + + if (ship.state === "warping") { + if (this.moveShipToward(ship, plan.arrivalPoint, ship.definition.ftlSpeed, delta, 50)) { + ship.systemId = destinationSystemId; + ship.state = "arriving"; + } + return false; + } + + if (ship.state === "arriving") { + if (this.moveShipToward(ship, destination, ship.definition.speed, delta, threshold)) { + ship.state = "moving"; + const systemCenter = this.getSystem(destinationSystemId).center; + ship.idleOrbitRadius = destination.clone().setY(0).distanceTo(systemCenter); + ship.idleOrbitAngle = Math.atan2(destination.z - systemCenter.z, destination.x - systemCenter.x); + return true; + } + return false; + } + + return false; + } + + private updateDockingState(ship: ShipInstance, station: StationInstance, delta: number) { + const portIndex = this.reserveDockingPort(station, ship); + if (portIndex < 0) { + ship.state = "docking-approach"; + ship.velocity.multiplyScalar(0.7); + return false; + } + + const portPosition = station.group.localToWorld(station.dockingPorts[portIndex].clone()); + if (ship.state !== "docking" && ship.state !== "docked") { + ship.state = "docking-approach"; + } + + if (ship.state === "docking-approach") { + if (this.moveShipToward(ship, portPosition, ship.definition.speed * 0.75, delta, 8)) { + ship.state = "docking"; + ship.actionTimer = DOCKING_DURATION; + } + return false; + } + + if (ship.state === "docking") { + ship.group.position.lerp(portPosition, 0.18); + ship.actionTimer -= delta; + if (ship.actionTimer <= 0) { + ship.state = "docked"; + ship.group.position.copy(portPosition); + ship.velocity.setScalar(0); + } + return false; + } + + if (ship.state === "docked") { + ship.group.position.copy(portPosition); + return true; + } + + return false; + } + + private beginUndock(ship: ShipInstance, station: StationInstance) { + if (ship.state === "undocking") { + return; + } + ship.state = "undocking"; + ship.actionTimer = DOCKING_DURATION * 0.75; + const portIndex = ship.dockingPortIndex ?? 0; + const port = station.group.localToWorld(station.dockingPorts[portIndex].clone()); + const direction = port.clone().sub(station.group.position).setY(0).normalize(); + ship.target.copy(port.clone().add(direction.multiplyScalar(UNDOCK_DISTANCE)).setY(Y_PLANE)); + this.releaseDockingPort(station, ship); + } + + private reserveDockingPort(station: StationInstance, ship: ShipInstance) { + if (ship.dockedStationId === station.id && ship.dockingPortIndex !== undefined) { + return ship.dockingPortIndex; + } + if (station.dockedShipIds.size >= station.definition.dockingCapacity) { + return -1; + } + const usedPorts = new Set( + this.ships + .filter((candidate) => candidate.dockedStationId === station.id && candidate.dockingPortIndex !== undefined) + .map((candidate) => candidate.dockingPortIndex as number), + ); + const freePort = station.dockingPorts.findIndex((_, index) => !usedPorts.has(index)); + if (freePort >= 0) { + station.dockedShipIds.add(ship.id); + ship.dockedStationId = station.id; + ship.dockingPortIndex = freePort; + } + return freePort; + } + + private releaseDockingPort(station: StationInstance, ship: ShipInstance) { + station.dockedShipIds.delete(ship.id); + ship.dockedStationId = undefined; + ship.dockingPortIndex = undefined; + } + + private getSelectionTitle() { + if (this.selectedStation) { + return this.selectedStation.definition.label; + } + if (this.selection.length === 0) { + return "No Selection"; + } + if (this.selection.length === 1) { + return this.selection[0].definition.label; + } + return `${this.selection.length} Ships Selected`; + } + + private getSelectionDetails() { + if (this.selectedStation) { + return this.describeStation(this.selectedStation); + } + if (this.selection.length === 0) { + return `Systems online: ${this.systems.map((system) => system.definition.label).join(", ")}\n\nOrders: Move, Patrol, Escort, Mine\nView: ${this.viewLevel}`; + } + return this.selection + .map( + (ship) => + `${ship.definition.label} • ${ship.systemId}\nState: ${ship.state}${ship.dockedStationId ? ` @ ${ship.dockedStationId}` : ""}\nOrder: ${ship.order.kind}\nCargo: ${Math.round(this.getShipCargoAmount(ship))}/${ship.definition.cargoCapacity || 0} ${this.getItemLabel(ship.cargoItemId)}\nFuel: ${ship.fuel.toFixed(0)}/${ship.maxFuel}\nEnergy: ${ship.energy.toFixed(0)}/${ship.maxEnergy}\nHold Type: ${ship.definition.cargoKind ?? "none"}\nModules: ${ship.definition.modules.map((moduleId) => this.getModuleLabel(moduleId)).join(", ")}`, + ) + .join("\n\n"); + } + + private describeStation(station: StationInstance) { + const miners = this.ships.filter((ship) => ship.systemId === station.systemId && ship.order.kind === "mine").length; + const escorts = this.ships.filter((ship) => ship.systemId === station.systemId && ship.order.kind === "escort").length; + const patrols = this.ships.filter((ship) => ship.systemId === station.systemId && ship.order.kind === "patrol").length; + const refineryStatus = + station.definition.category === "refining" + ? `Ore: ${Math.round(station.oreStored)}\nRefined: ${Math.round(station.refinedStock)}\nBatch: ${Math.round(station.activeBatch)}\nTime Remaining: ${station.activeBatch > 0 ? `${station.processTimer.toFixed(1)}s` : "Idle"}\n` + : ""; + const activity = + station.definition.category === "refining" + ? `Refining ore for ${miners} mining ships` + : station.definition.category === "shipyard" + ? `Maintaining ${patrols} patrol craft` + : station.definition.category === "farm" + ? "Supplying agricultural goods" + : station.definition.category === "defense" + ? `Coordinating ${escorts} escort wings` + : "Managing local trade traffic"; + + return `${station.definition.label} • ${station.systemId}\nRole: ${station.definition.category}\nActivity: ${activity}\nDocking: ${station.dockedShipIds.size}/${station.definition.dockingCapacity}\nFuel: ${station.fuel.toFixed(0)}/${station.maxFuel}\nEnergy: ${station.energy.toFixed(0)}/${station.maxEnergy}\nBulk Solid: ${Math.round(station.inventory["bulk-solid"])}\nContainer: ${Math.round(station.inventory.container)}\nManufactured: ${Math.round(station.inventory.manufactured)}\nModules: ${station.modules.map((moduleId) => this.getModuleLabel(moduleId)).join(", ")}\n${refineryStatus}Radius: ${station.definition.radius}`; + } + + private getItemLabel(itemId?: string) { + return itemDefinitions.find((item) => item.id === itemId)?.label ?? "None"; + } + + private getModuleLabel(moduleId: string) { + return moduleDefinitions.find((module) => module.id === moduleId)?.label ?? moduleId; + } + + private moveShipToward(ship: ShipInstance, destination: THREE.Vector3, speed: number, delta: number, threshold: number) { + const target = destination.clone(); + target.y = Y_PLANE; + ship.target.copy(target); + + const toTarget = target.clone().sub(ship.group.position); + const distance = toTarget.length(); + if (distance <= threshold) { + ship.velocity.multiplyScalar(0.65); + return true; + } + + let desiredDirection = toTarget.normalize(); + if (ship.state !== "warping" && ship.state !== "spooling-ftl") { + const systemCenter = this.getSystem(ship.systemId).center; + const radial = ship.group.position.clone().sub(systemCenter).setY(0); + const targetRadial = target.clone().sub(systemCenter).setY(0); + if (radial.lengthSq() > 1 && targetRadial.lengthSq() > 1) { + const tangential = new THREE.Vector3(-radial.z, 0, radial.x).normalize(); + const crossY = radial.clone().cross(targetRadial).y; + const sign = crossY >= 0 ? 1 : -1; + const curvature = THREE.MathUtils.clamp(distance / 650, 0, 0.9); + desiredDirection = desiredDirection.add(tangential.multiplyScalar(sign * curvature)).normalize(); + } + } + + const desiredVelocity = desiredDirection.multiplyScalar(speed); + const steering = ship.state === "warping" ? delta * 4.2 : delta * 1.8; + ship.velocity.lerp(desiredVelocity, steering); + ship.group.position.addScaledVector(ship.velocity, delta); + + if (ship.velocity.lengthSq() > 1) { + ship.group.lookAt(ship.group.position.clone().add(ship.velocity)); + } + return false; + } + + private issueMoveOrder(ship: ShipInstance, destination: THREE.Vector3) { + const system = this.findNearestSystem(destination); + destination.y = Y_PLANE; + ship.travelPlan = undefined; + + if (ship.systemId === system.definition.id) { + ship.order = { kind: "move", destination, systemId: system.definition.id }; + ship.state = "moving"; + return; + } + + const currentSystem = this.getSystem(ship.systemId); + const exitDirection = ship.group.position.clone().sub(currentSystem.center).setY(0).normalize(); + if (exitDirection.lengthSq() === 0) { + exitDirection.copy(system.center.clone().sub(currentSystem.center).setY(0).normalize()); + } + + const arrivalDirection = destination.clone().sub(system.center).setY(0).normalize(); + if (arrivalDirection.lengthSq() === 0) { + arrivalDirection.copy(currentSystem.center.clone().sub(system.center).setY(0).normalize()); + } + + ship.order = { + kind: "transfer", + destination, + destinationSystemId: system.definition.id, + exitPoint: currentSystem.center.clone().add(exitDirection.multiplyScalar(currentSystem.gravityWellRadius + 230)).setY(Y_PLANE), + arrivalPoint: system.center.clone().add(arrivalDirection.multiplyScalar(system.gravityWellRadius + 150)).setY(Y_PLANE), + }; + ship.state = "leaving-gravity-well"; + ship.travelPlan = undefined; + } + + private assignMineOrder(ship: ShipInstance, node: ResourceNode | undefined, refinery: StationInstance | undefined) { + if (!node || !refinery) { + ship.order = { kind: "idle" }; + ship.state = "idle"; + return; + } + ship.order = { kind: "mine", nodeId: node.id, refineryId: refinery.id, phase: "to-node" }; + ship.state = "mining-approach"; + } + + private setPatrolOrder(ship: ShipInstance, points: THREE.Vector3[], startIndex: number) { + ship.order = { + kind: "patrol", + points: points.map((point) => point.clone().setY(Y_PLANE)), + systemId: ship.systemId, + index: startIndex, + }; + ship.state = "patrolling"; + } + + private setEscortOrder(ship: ShipInstance, target: ShipInstance) { + const angle = (this.ships.indexOf(ship) % 6) * (Math.PI / 3); + ship.order = { + kind: "escort", + targetShipId: target.id, + offset: new THREE.Vector3(Math.cos(angle) * 32, 0, Math.sin(angle) * 32), + }; + ship.state = "escorting"; + } + + private findBestMiningNode(systemId: string) { + return this.nodes + .filter((node) => node.systemId === systemId) + .sort((left, right) => right.oreRemaining - left.oreRemaining)[0]; + } + + private findRefinery(systemId: string) { + return this.stations.find((station) => station.systemId === systemId && station.definition.category === "refining"); + } + + private findNearestFriendlyToEscort(ship: ShipInstance) { + return this.ships + .filter((candidate) => candidate.id !== ship.id && candidate.systemId === ship.systemId) + .sort((left, right) => ship.group.position.distanceTo(left.group.position) - ship.group.position.distanceTo(right.group.position))[0]; + } + + private findNearestSystem(point: THREE.Vector3) { + return this.systems.reduce((best, system) => { + const bestDistance = best.center.distanceToSquared(point); + const distance = system.center.distanceToSquared(point); + return distance < bestDistance ? system : best; + }, this.systems[0]); + } + + private getSystem(systemId: string) { + const system = this.systems.find((candidate) => candidate.definition.id === systemId); + if (!system) { + throw new Error(`Missing solar system ${systemId}`); + } + return system; + } + + private focusSystem(systemId: string) { + const system = this.getSystem(systemId); + const currentOffset = this.camera.position.clone().sub(this.getCameraFocus()); + this.followShipId = undefined; + this.getCameraFocus().copy(system.center); + this.camera.position.copy(system.center).add(currentOffset); + this.updateHud(); + } + + private focusSelection() { + if (this.selection.length === 0 && !this.selectedStation) { + return; + } + + if (this.selectedStation) { + this.followShipId = undefined; + this.selectedSystemIndex = this.systems.findIndex((system) => system.definition.id === this.selectedStation?.systemId); + this.getCameraFocus().copy(this.selectedStation.group.position); + this.updateHud(); + return; + } + + if (this.selection.length === 1) { + const ship = this.selection[0]; + this.followShipId = ship.id; + this.selectedSystemIndex = this.systems.findIndex((system) => system.definition.id === ship.systemId); + this.getCameraFocus().copy(ship.group.position); + this.updateHud(); + return; + } + + this.followShipId = undefined; + const center = new THREE.Vector3(); + this.selection.forEach((ship) => center.add(ship.group.position)); + center.multiplyScalar(1 / this.selection.length); + this.getCameraFocus().copy(center); + this.updateHud(); + } + + private getCameraFocus() { + return this.cameraFocus; + } + + private handleOrderAction(action: string) { + if (action === "focus") { + this.focusSelection(); + return; + } + if (this.selection.length === 0) { + return; + } + if (action === "mine") { + this.selection + .filter((ship) => ship.definition.role === "mining") + .forEach((ship) => this.assignMineOrder(ship, this.findBestMiningNode("perseus"), this.findRefinery("helios"))); + } + if (action === "patrol") { + this.selection + .filter((ship) => ship.definition.role === "military") + .forEach((ship) => this.setPatrolOrder(ship, this.makePatrolPoints(ship.systemId), 0)); + } + if (action === "escort") { + this.selection.forEach((ship) => { + const target = this.findNearestFriendlyToEscort(ship); + if (target) { + this.setEscortOrder(ship, target); + } + }); + } + this.updateHud(); + } + + private adjustZoom(multiplier: number) { + const focus = this.getCameraFocus(); + const direction = this.camera.position.clone().sub(focus).normalize(); + const distance = this.camera.position.distanceTo(focus); + const nextDistance = THREE.MathUtils.clamp(distance * multiplier, 180, 9000); + this.camera.position.copy(focus).add(direction.multiplyScalar(nextDistance)); + this.applyViewLevel(); + } + + private drawMinimap() { + const context = this.minimapContext; + const width = this.minimapEl.width; + const height = this.minimapEl.height; + context.clearRect(0, 0, width, height); + context.fillStyle = "rgba(4, 9, 20, 0.92)"; + context.fillRect(0, 0, width, height); + context.strokeStyle = "rgba(126, 212, 255, 0.18)"; + context.strokeRect(0.5, 0.5, width - 1, height - 1); + + const bounds = { minX: -400, maxX: 5000, minZ: -1000, maxZ: 1800 }; + const mapPoint = (position: THREE.Vector3) => ({ + x: ((position.x - bounds.minX) / (bounds.maxX - bounds.minX)) * width, + y: ((position.z - bounds.minZ) / (bounds.maxZ - bounds.minZ)) * height, + }); + + this.systems.forEach((system) => { + const point = mapPoint(system.center); + context.fillStyle = "#7ed4ff"; + context.beginPath(); + context.arc(point.x, point.y, 6, 0, Math.PI * 2); + context.fill(); + context.strokeStyle = "rgba(126,212,255,0.25)"; + context.beginPath(); + context.arc(point.x, point.y, 18, 0, Math.PI * 2); + context.stroke(); + }); + + this.stations.forEach((station) => { + const point = mapPoint(station.group.position); + context.fillStyle = station === this.selectedStation ? "#ffbf69" : "#b4c9da"; + context.fillRect(point.x - 2, point.y - 2, 4, 4); + }); + + this.ships.forEach((ship) => { + const point = mapPoint(ship.group.position); + context.fillStyle = this.selection.includes(ship) ? "#ffbf69" : ship.definition.role === "mining" ? "#ffdd75" : ship.definition.role === "transport" ? "#b0ff8d" : "#7ed4ff"; + context.beginPath(); + context.arc(point.x, point.y, this.selection.includes(ship) ? 3 : 2, 0, Math.PI * 2); + context.fill(); + }); + + const focus = mapPoint(this.getCameraFocus()); + context.strokeStyle = "rgba(255,255,255,0.7)"; + context.strokeRect(focus.x - 9, focus.y - 9, 18, 18); + } + + private drawStrategicOverlay() { + const context = this.strategicOverlayContext; + const width = this.strategicOverlayEl.width; + const height = this.strategicOverlayEl.height; + context.clearRect(0, 0, width, height); + + if (this.viewLevel === "local") { + return; + } + + context.save(); + context.scale(width / window.innerWidth, height / window.innerHeight); + context.lineJoin = "round"; + context.lineCap = "round"; + context.textAlign = "center"; + context.textBaseline = "middle"; + + if (this.viewLevel === "solar") { + this.stations + .filter((station) => station.systemId === this.systems[this.selectedSystemIndex]?.definition.id) + .forEach((station) => { + const screen = this.projectWorldToScreen(station.group.position); + if (!screen) { + return; + } + this.drawStationSymbol(context, screen.x, screen.y, station, 14, station === this.selectedStation); + }); + + this.ships + .filter((ship) => ship.systemId === this.systems[this.selectedSystemIndex]?.definition.id && ship.state !== "docked") + .forEach((ship) => { + const screen = this.projectWorldToScreen(ship.group.position); + if (!screen) { + return; + } + this.drawShipSymbol(context, screen.x, screen.y, ship, 10, ship.selected); + }); + } else { + this.systems.forEach((system) => { + const screen = this.projectWorldToScreen(system.center); + if (!screen) { + return; + } + + this.drawSystemFrame(context, screen.x, screen.y, system.definition.label); + + const fleets = new Map(); + this.ships.forEach((ship) => { + if (ship.systemId !== system.definition.id) { + return; + } + const bucket = fleets.get(ship.definition.role) ?? []; + bucket.push(ship); + fleets.set(ship.definition.role, bucket); + }); + + const roleOrder: ShipDefinition["role"][] = ["military", "transport", "mining"]; + roleOrder.forEach((role, index) => { + const bucket = fleets.get(role); + if (!bucket || bucket.length === 0) { + return; + } + const highlighted = bucket.some((ship) => ship.selected); + const offsetX = -52 + index * 52; + const offsetY = 32; + this.drawFleetSymbol(context, screen.x + offsetX, screen.y + offsetY, role, bucket.length, highlighted); + }); + + const stationCount = this.stations.filter((station) => station.systemId === system.definition.id).length; + const stationSelected = this.stations.some( + (station) => station.systemId === system.definition.id && station === this.selectedStation, + ); + if (stationCount > 0) { + this.drawStrategicStationGroup(context, screen.x, screen.y - 38, stationCount, stationSelected); + } + }); + } + + context.restore(); + } + + private projectWorldToScreen(position: THREE.Vector3) { + const screen = position.clone().project(this.camera); + if (screen.z < -1 || screen.z > 1) { + return undefined; + } + return { + x: ((screen.x + 1) * 0.5) * window.innerWidth, + y: ((-screen.y + 1) * 0.5) * window.innerHeight, + }; + } + + private drawSystemFrame(context: CanvasRenderingContext2D, x: number, y: number, label: string) { + context.strokeStyle = "rgba(126, 212, 255, 0.82)"; + context.lineWidth = 1.25; + context.strokeRect(x - 28, y - 16, 56, 32); + context.beginPath(); + context.moveTo(x - 40, y); + context.lineTo(x - 28, y); + context.moveTo(x + 28, y); + context.lineTo(x + 40, y); + context.stroke(); + context.fillStyle = "rgba(235, 247, 255, 0.9)"; + context.font = "600 11px Space Grotesk, sans-serif"; + context.fillText(label.toUpperCase(), x, y - 28); + } + + private drawFleetSymbol( + context: CanvasRenderingContext2D, + x: number, + y: number, + role: ShipDefinition["role"], + count: number, + highlighted: boolean, + ) { + context.save(); + context.translate(x, y); + context.strokeStyle = highlighted ? "#ffbf69" : "rgba(208, 232, 244, 0.95)"; + context.fillStyle = "rgba(5, 12, 26, 0.88)"; + context.lineWidth = highlighted ? 2.2 : 1.5; + + if (role === "military") { + context.beginPath(); + context.moveTo(0, -12); + context.lineTo(12, 0); + context.lineTo(0, 12); + context.lineTo(-12, 0); + context.closePath(); + context.fill(); + context.stroke(); + context.beginPath(); + context.moveTo(-5, 0); + context.lineTo(5, 0); + context.stroke(); + } else if (role === "transport") { + context.beginPath(); + context.rect(-13, -9, 26, 18); + context.fill(); + context.stroke(); + context.beginPath(); + context.moveTo(-4, -9); + context.lineTo(-4, 9); + context.moveTo(4, -9); + context.lineTo(4, 9); + context.stroke(); + } else { + context.beginPath(); + context.moveTo(-12, -7); + context.lineTo(-5, -12); + context.lineTo(5, -12); + context.lineTo(12, -7); + context.lineTo(12, 7); + context.lineTo(5, 12); + context.lineTo(-5, 12); + context.lineTo(-12, 7); + context.closePath(); + context.fill(); + context.stroke(); + context.beginPath(); + context.moveTo(-8, 0); + context.lineTo(8, 0); + context.stroke(); + } + + context.fillStyle = highlighted ? "#ffbf69" : "rgba(235, 247, 255, 0.9)"; + context.font = "700 12px Space Grotesk, sans-serif"; + context.fillText(String(count), 0, 23); + context.restore(); + } + + private drawStrategicStationGroup( + context: CanvasRenderingContext2D, + x: number, + y: number, + count: number, + highlighted: boolean, + ) { + context.save(); + context.translate(x, y); + context.strokeStyle = highlighted ? "#ffbf69" : "rgba(180, 201, 218, 0.9)"; + context.fillStyle = "rgba(5, 12, 26, 0.88)"; + context.lineWidth = highlighted ? 2.2 : 1.5; + context.beginPath(); + context.rect(-12, -12, 24, 24); + context.fill(); + context.stroke(); + context.beginPath(); + context.moveTo(-18, 0); + context.lineTo(-12, 0); + context.moveTo(12, 0); + context.lineTo(18, 0); + context.moveTo(0, -18); + context.lineTo(0, -12); + context.moveTo(0, 12); + context.lineTo(0, 18); + context.stroke(); + context.fillStyle = highlighted ? "#ffbf69" : "rgba(235, 247, 255, 0.9)"; + context.font = "700 12px Space Grotesk, sans-serif"; + context.fillText(String(count), 0, 24); + context.restore(); + } + + private drawShipSymbol( + context: CanvasRenderingContext2D, + x: number, + y: number, + ship: ShipInstance, + size: number, + highlighted: boolean, + ) { + context.save(); + context.translate(x, y); + context.rotate(-ship.group.rotation.y); + context.strokeStyle = highlighted ? "#ffbf69" : this.getShipSymbolColor(ship); + context.lineWidth = highlighted ? 2.2 : 1.4; + context.fillStyle = "rgba(5, 12, 26, 0.74)"; + + if (ship.definition.role === "military") { + context.beginPath(); + context.moveTo(0, -size); + context.lineTo(size, 0); + context.lineTo(0, size); + context.lineTo(-size, 0); + context.closePath(); + context.fill(); + context.stroke(); + context.beginPath(); + context.moveTo(-size * 0.35, 0); + context.lineTo(size * 0.35, 0); + context.stroke(); + } else if (ship.definition.role === "transport") { + context.beginPath(); + context.rect(-size, -size * 0.68, size * 2, size * 1.36); + context.fill(); + context.stroke(); + context.beginPath(); + context.moveTo(-size * 0.25, -size * 0.68); + context.lineTo(-size * 0.25, size * 0.68); + context.moveTo(size * 0.25, -size * 0.68); + context.lineTo(size * 0.25, size * 0.68); + context.stroke(); + } else { + context.beginPath(); + context.moveTo(-size, -size * 0.5); + context.lineTo(-size * 0.35, -size); + context.lineTo(size * 0.35, -size); + context.lineTo(size, -size * 0.5); + context.lineTo(size, size * 0.5); + context.lineTo(size * 0.35, size); + context.lineTo(-size * 0.35, size); + context.lineTo(-size, size * 0.5); + context.closePath(); + context.fill(); + context.stroke(); + context.beginPath(); + context.moveTo(-size * 0.65, 0); + context.lineTo(size * 0.65, 0); + context.stroke(); + } + + if (highlighted) { + context.strokeStyle = "rgba(255, 191, 105, 0.42)"; + context.lineWidth = 1; + context.beginPath(); + context.arc(0, 0, size + 7, 0, Math.PI * 2); + context.stroke(); + } + context.restore(); + } + + private drawStationSymbol( + context: CanvasRenderingContext2D, + x: number, + y: number, + station: StationInstance, + size: number, + highlighted: boolean, + ) { + context.save(); + context.translate(x, y); + context.strokeStyle = highlighted ? "#ffbf69" : this.getStationSymbolColor(station); + context.fillStyle = "rgba(5, 12, 26, 0.78)"; + context.lineWidth = highlighted ? 2.2 : 1.5; + context.beginPath(); + context.rect(-size, -size, size * 2, size * 2); + context.fill(); + context.stroke(); + + context.beginPath(); + context.moveTo(-size - 7, 0); + context.lineTo(-size, 0); + context.moveTo(size, 0); + context.lineTo(size + 7, 0); + context.moveTo(0, -size - 7); + context.lineTo(0, -size); + context.moveTo(0, size); + context.lineTo(0, size + 7); + context.stroke(); + + if (station.definition.category === "refining") { + context.beginPath(); + context.moveTo(-4, 5); + context.lineTo(0, -5); + context.lineTo(4, 5); + context.stroke(); + } else if (station.definition.category === "defense") { + context.beginPath(); + context.moveTo(-5, -5); + context.lineTo(5, 5); + context.moveTo(5, -5); + context.lineTo(-5, 5); + context.stroke(); + } else if (station.definition.category === "shipyard") { + context.beginPath(); + context.rect(-5, -3, 10, 6); + context.stroke(); + } else if (station.definition.category === "farm") { + context.beginPath(); + context.arc(0, 0, 5, 0, Math.PI * 2); + context.stroke(); + } + + context.restore(); + } + + private getShipSymbolColor(ship: ShipInstance) { + if (ship.definition.role === "military") { + return "rgba(126, 212, 255, 0.95)"; + } + if (ship.definition.role === "transport") { + return "rgba(176, 255, 141, 0.95)"; + } + return "rgba(255, 221, 117, 0.95)"; + } + + private getStationSymbolColor(station: StationInstance) { + if (station.definition.category === "refining") { + return "rgba(255, 184, 108, 0.95)"; + } + if (station.definition.category === "farm") { + return "rgba(146, 239, 138, 0.95)"; + } + if (station.definition.category === "defense") { + return "rgba(255, 122, 149, 0.95)"; + } + if (station.definition.category === "shipyard") { + return "rgba(208, 162, 255, 0.95)"; + } + return "rgba(180, 201, 218, 0.95)"; + } + + private makeRadialTexture(stops: [string, string, string]) { + const canvas = document.createElement("canvas"); + canvas.width = 512; + canvas.height = 512; + const context = canvas.getContext("2d"); + if (!context) { + throw new Error("Unable to create 2D context for nebula texture"); + } + + const gradient = context.createRadialGradient(256, 256, 30, 256, 256, 256); + gradient.addColorStop(0, stops[0]); + gradient.addColorStop(0.45, stops[1]); + gradient.addColorStop(1, stops[2]); + context.fillStyle = gradient; + context.fillRect(0, 0, 512, 512); + + const texture = new THREE.CanvasTexture(canvas); + texture.colorSpace = THREE.SRGBColorSpace; + return texture; + } +} diff --git a/src/game/definitions.ts b/src/game/definitions.ts new file mode 100644 index 0000000..310a765 --- /dev/null +++ b/src/game/definitions.ts @@ -0,0 +1,273 @@ +export type ShipRole = "military" | "transport" | "mining"; +export type ConstructibleCategory = + | "station" + | "refining" + | "farm" + | "shipyard" + | "defense"; + +export type UnitState = + | "idle" + | "moving" + | "leaving-gravity-well" + | "spooling-ftl" + | "warping" + | "arriving" + | "mining-approach" + | "mining" + | "delivering" + | "docking-approach" + | "docking" + | "docked" + | "undocking" + | "patrolling" + | "escorting"; + +export type UnitOrderKind = "idle" | "move" | "transfer" | "mine" | "patrol" | "escort"; + +export type ItemStorageKind = "bulk-solid" | "bulk-liquid" | "bulk-gas" | "container" | "manufactured"; +export type ModuleCategory = + | "bridge" + | "engine" + | "ftl" + | "mining" + | "cargo-bulk" + | "cargo-container" + | "dock" + | "refinery" + | "defense" + | "habitat" + | "production"; + +export interface ModuleDefinition { + id: string; + label: string; + category: ModuleCategory; + summary: string; +} + +export interface ItemDefinition { + id: string; + label: string; + storage: ItemStorageKind; +} + +export interface ShipDefinition { + id: string; + label: string; + role: ShipRole; + speed: number; + ftlSpeed: number; + spoolTime: number; + cargoCapacity: number; + cargoKind?: ItemStorageKind; + cargoItemId?: string; + color: number; + hullColor: number; + size: number; + maxHealth: number; + modules: string[]; +} + +export interface ConstructibleDefinition { + id: string; + label: string; + category: ConstructibleCategory; + color: number; + radius: number; + dockingCapacity: number; + storage: Partial>; + modules: string[]; +} + +export interface PlanetDefinition { + label: string; + orbitRadius: number; + orbitSpeed: number; + size: number; + color: number; + tilt: number; + hasRing?: boolean; +} + +export interface SolarSystemDefinition { + id: string; + label: string; + position: [number, number, number]; + starColor: number; + starGlow: number; + starSize: number; + gravityWellRadius: number; + planets: PlanetDefinition[]; +} + +export const itemDefinitions: ItemDefinition[] = [ + { id: "ore", label: "Raw Ore", storage: "bulk-solid" }, + { id: "refined-metals", label: "Refined Metals", storage: "manufactured" }, + { id: "gas", label: "Volatile Gas", storage: "bulk-gas" }, + { id: "water", label: "Water", storage: "bulk-liquid" }, + { id: "drone-parts", label: "Drone Parts", storage: "container" }, +]; + +export const moduleDefinitions: ModuleDefinition[] = [ + { id: "command-bridge", label: "Command Bridge", category: "bridge", summary: "Core ship control and crew systems." }, + { id: "ion-drive", label: "Ion Drive", category: "engine", summary: "Sub-light propulsion package." }, + { id: "ftl-core", label: "FTL Core", category: "ftl", summary: "Spool and warp inter-system engine." }, + { id: "strip-miner", label: "Strip Miner", category: "mining", summary: "Excavation laser and ore intake." }, + { id: "bulk-bay", label: "Bulk Cargo Bay", category: "cargo-bulk", summary: "Reinforced storage for raw solids." }, + { id: "container-bay", label: "Container Hold", category: "cargo-container", summary: "Standardized freight racks." }, + { id: "docking-clamps", label: "Docking Clamps", category: "dock", summary: "Docking collar and transfer arms." }, + { id: "refinery-stack", label: "Refinery Stack", category: "refinery", summary: "Ore cracking and metal separation." }, + { id: "turret-grid", label: "Turret Grid", category: "defense", summary: "Close defense batteries." }, + { id: "habitat-ring", label: "Habitat Ring", category: "habitat", summary: "Crew quarters and service modules." }, + { id: "fabricator-array", label: "Fabricator Array", category: "production", summary: "Assembly lines for manufactured goods." }, +]; + +export const shipDefinitions: ShipDefinition[] = [ + { + id: "frigate", + label: "Vanguard Frigate", + role: "military", + speed: 50, + ftlSpeed: 3200, + spoolTime: 2.2, + cargoCapacity: 0, + color: 0x7ed4ff, + hullColor: 0x1f4f78, + size: 4, + maxHealth: 100, + modules: ["command-bridge", "ion-drive", "ftl-core", "turret-grid"], + }, + { + id: "destroyer", + label: "Bulwark Destroyer", + role: "military", + speed: 34, + ftlSpeed: 2900, + spoolTime: 2.8, + cargoCapacity: 0, + color: 0xff8f70, + hullColor: 0x6a2e26, + size: 7, + maxHealth: 240, + modules: ["command-bridge", "ion-drive", "ftl-core", "turret-grid", "turret-grid"], + }, + { + id: "hauler", + label: "Atlas Hauler", + role: "transport", + speed: 22, + ftlSpeed: 2600, + spoolTime: 3.3, + cargoCapacity: 180, + cargoKind: "container", + cargoItemId: "drone-parts", + color: 0xb0ff8d, + hullColor: 0x365f2a, + size: 8, + maxHealth: 180, + modules: ["command-bridge", "ion-drive", "ftl-core", "container-bay", "docking-clamps"], + }, + { + id: "miner", + label: "Prospector Miner", + role: "mining", + speed: 26, + ftlSpeed: 2400, + spoolTime: 3.1, + cargoCapacity: 120, + cargoKind: "bulk-solid", + cargoItemId: "ore", + color: 0xffdd75, + hullColor: 0x68552b, + size: 6, + maxHealth: 150, + modules: ["command-bridge", "ion-drive", "ftl-core", "strip-miner", "bulk-bay", "docking-clamps"], + }, +]; + +export const constructibleDefinitions: ConstructibleDefinition[] = [ + { + id: "trade-hub", + label: "Trade Hub", + category: "station", + color: 0x8bd3ff, + radius: 20, + dockingCapacity: 4, + storage: { container: 1200, manufactured: 800 }, + modules: ["habitat-ring", "docking-clamps", "container-bay"], + }, + { + id: "refinery", + label: "Refining Station", + category: "refining", + color: 0xffb86c, + radius: 24, + dockingCapacity: 3, + storage: { "bulk-solid": 2000, manufactured: 1000 }, + modules: ["docking-clamps", "refinery-stack", "bulk-bay", "fabricator-array"], + }, + { + id: "farm-ring", + label: "Farm Station", + category: "farm", + color: 0x92ef8a, + radius: 22, + dockingCapacity: 2, + storage: { "bulk-liquid": 600, container: 400 }, + modules: ["habitat-ring", "production", "container-bay"], + }, + { + id: "shipyard", + label: "Orbital Shipyard", + category: "shipyard", + color: 0xd0a2ff, + radius: 28, + dockingCapacity: 5, + storage: { manufactured: 1800, container: 1200 }, + modules: ["docking-clamps", "fabricator-array", "habitat-ring"], + }, + { + id: "defense-grid", + label: "Defense Platform", + category: "defense", + color: 0xff7a95, + radius: 18, + dockingCapacity: 1, + storage: { manufactured: 300 }, + modules: ["turret-grid", "command-bridge"], + }, +]; + +export const solarSystemDefinitions: SolarSystemDefinition[] = [ + { + id: "helios", + label: "Helios Reach", + position: [0, 0, 0], + starColor: 0xffd27a, + starGlow: 0xffb14a, + starSize: 56, + gravityWellRadius: 210, + planets: [ + { label: "Icarus", orbitRadius: 180, orbitSpeed: 0.18, size: 20, color: 0xd4a373, tilt: 0.2 }, + { label: "Viridia", orbitRadius: 300, orbitSpeed: 0.11, size: 30, color: 0x58a36c, tilt: -0.4 }, + { label: "Aster", orbitRadius: 460, orbitSpeed: 0.08, size: 38, color: 0x6ea7d4, tilt: 0.3, hasRing: true }, + { label: "Noctis", orbitRadius: 670, orbitSpeed: 0.05, size: 50, color: 0x6958a8, tilt: -0.15 }, + ], + }, + { + id: "perseus", + label: "Perseus Gate", + position: [4200, 0, 600], + starColor: 0x9fd4ff, + starGlow: 0x66b6ff, + starSize: 48, + gravityWellRadius: 190, + planets: [ + { label: "Kepler", orbitRadius: 150, orbitSpeed: 0.22, size: 16, color: 0xd9b188, tilt: 0.12 }, + { label: "Tethys", orbitRadius: 280, orbitSpeed: 0.12, size: 28, color: 0x73b0a1, tilt: -0.22 }, + { label: "Orpheon", orbitRadius: 430, orbitSpeed: 0.07, size: 42, color: 0x4a67a8, tilt: 0.25, hasRing: true }, + { label: "Cinder", orbitRadius: 610, orbitSpeed: 0.045, size: 36, color: 0xb15e49, tilt: -0.08 }, + ], + }, +]; diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..bd0a953 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,11 @@ +import "./style.css"; +import { GameApp } from "./game/GameApp"; + +const appRoot = document.querySelector("#app"); + +if (!appRoot) { + throw new Error("Missing #app root element"); +} + +const game = new GameApp(appRoot); +game.start(); diff --git a/src/style.css b/src/style.css new file mode 100644 index 0000000..79642a2 --- /dev/null +++ b/src/style.css @@ -0,0 +1,245 @@ +:root { + color-scheme: dark; + font-family: "Space Grotesk", "Segoe UI", sans-serif; + --bg: #050914; + --panel: rgba(5, 12, 26, 0.78); + --panel-border: rgba(126, 212, 255, 0.18); + --text: #ebf7ff; + --muted: #9fb6c8; + --accent: #7ed4ff; + --warning: #ffbf69; +} + +* { + box-sizing: border-box; +} + +html, +body, +#app { + margin: 0; + width: 100%; + height: 100%; + overflow: hidden; + background: + radial-gradient(circle at top, rgba(75, 123, 236, 0.18), transparent 36%), + radial-gradient(circle at 20% 40%, rgba(255, 134, 91, 0.14), transparent 26%), + linear-gradient(180deg, #03070f 0%, #060c18 100%); + color: var(--text); +} + +canvas { + display: block; +} + +.hud { + position: fixed; + inset: 0; + pointer-events: none; +} + +.strategic-overlay { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + pointer-events: none; + opacity: 0.96; +} + +.marquee { + position: fixed; + display: none; + border: 1px solid rgba(126, 212, 255, 0.85); + background: rgba(126, 212, 255, 0.14); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08); + z-index: 3; +} + +.panel { + position: absolute; + backdrop-filter: blur(14px); + background: var(--panel); + border: 1px solid var(--panel-border); + border-radius: 18px; + box-shadow: 0 18px 60px rgba(0, 0, 0, 0.35); + z-index: 2; +} + +.panel h1, +.panel h2, +.panel p { + margin: 0; +} + +.summary { + top: 24px; + left: 24px; + width: min(380px, calc(100vw - 48px)); + padding: 18px 20px; +} + +.summary h1 { + font-size: 1rem; + letter-spacing: 0.22em; + text-transform: uppercase; +} + +.summary p { + margin-top: 10px; + color: var(--muted); + line-height: 1.5; +} + +.details { + right: 24px; + top: 24px; + width: min(320px, calc(100vw - 48px)); + padding: 18px 20px; +} + +.details h2 { + font-size: 0.82rem; + text-transform: uppercase; + letter-spacing: 0.16em; + color: var(--accent); +} + +.details .content { + margin-top: 12px; + color: var(--muted); + line-height: 1.55; + white-space: pre-line; +} + +.commandbar { + left: 24px; + right: 24px; + bottom: 24px; + min-height: 180px; + display: grid; + grid-template-columns: minmax(240px, 300px) 1fr minmax(220px, 260px); + gap: 16px; + padding: 16px; + align-items: stretch; + pointer-events: auto; +} + +.selection-panel, +.orders-panel, +.minimap-panel { + border: 1px solid rgba(126, 212, 255, 0.14); + border-radius: 14px; + background: + linear-gradient(180deg, rgba(7, 15, 29, 0.82), rgba(4, 10, 20, 0.72)), + repeating-linear-gradient( + 90deg, + rgba(126, 212, 255, 0.025) 0, + rgba(126, 212, 255, 0.025) 1px, + transparent 1px, + transparent 16px + ); + padding: 14px 16px; +} + +.selection-title, +.orders-panel .mode { + margin: 0; + font-size: 0.86rem; + text-transform: uppercase; + letter-spacing: 0.14em; +} + +.selection-panel .compact { + margin-top: 10px; + color: var(--muted); + line-height: 1.45; + white-space: pre-line; +} + +.orders-panel { + display: flex; + flex-direction: column; + gap: 14px; +} + +.orders-panel .mode { + color: var(--warning); + text-shadow: 0 0 18px rgba(255, 191, 105, 0.24); +} + +.orders { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 10px; +} + +.orders button { + border: 1px solid rgba(126, 212, 255, 0.16); + border-radius: 12px; + background: linear-gradient(180deg, rgba(13, 30, 56, 0.95), rgba(8, 17, 33, 0.95)); + color: var(--text); + font: inherit; + padding: 12px 10px; + text-transform: uppercase; + letter-spacing: 0.08em; + cursor: pointer; + transition: border-color 120ms ease, transform 120ms ease, background 120ms ease; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.02); +} + +.orders button:hover { + border-color: rgba(126, 212, 255, 0.4); + transform: translateY(-1px); +} + +.orders[data-mode="none"] button:not([data-action="focus"]) { + opacity: 0.45; +} + +.orders-panel .hint { + color: var(--muted); + line-height: 1.45; +} + +.minimap-panel { + display: flex; + align-items: center; + justify-content: center; +} + +.minimap { + width: 100%; + height: auto; + border-radius: 10px; + border: 1px solid rgba(126, 212, 255, 0.16); + background: rgba(2, 6, 13, 0.92); +} + +@media (max-width: 900px) { + .summary, + .details, + .commandbar { + left: 16px; + right: 16px; + } + + .summary, + .details { + width: auto; + } + + .details { + top: auto; + bottom: 92px; + } + + .commandbar { + grid-template-columns: 1fr; + bottom: 16px; + } + + .orders { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..55b6d38 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM"], + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["src"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..da3941f --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from "vite"; + +export default defineConfig({ + build: { + rollupOptions: { + output: { + manualChunks: { + three: ["three"], + }, + }, + }, + }, + server: { + host: "0.0.0.0", + port: 4173, + }, +});