feat: adds fleet, windows, building
This commit is contained in:
67
SESSION.md
67
SESSION.md
@@ -14,6 +14,9 @@ The current prototype includes:
|
|||||||
- Three view levels based on zoom: `local`, `solar`, and `universe`
|
- Three view levels based on zoom: `local`, `solar`, and `universe`
|
||||||
- A bottom command bar with selection info, order buttons, and a minimap
|
- 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
|
- A strategic HUD overlay that switches to NATO / military-style symbols at higher zoom levels
|
||||||
|
- A generic window layer, with an initial fleet command window for managing multi-wing groups
|
||||||
|
- App windows can now be dragged by their headers and resized from a corner grip
|
||||||
|
- `stargate` now exists as a constructible category in the same authored pipeline as other stations
|
||||||
|
|
||||||
## Major Gameplay Systems Added
|
## Major Gameplay Systems Added
|
||||||
|
|
||||||
@@ -43,12 +46,26 @@ The current prototype includes:
|
|||||||
- military
|
- military
|
||||||
- transport
|
- transport
|
||||||
- mining
|
- mining
|
||||||
|
- Ship classes now distinguish:
|
||||||
|
- frigate
|
||||||
|
- destroyer
|
||||||
|
- cruiser
|
||||||
|
- industrial
|
||||||
|
- capital
|
||||||
- Unit state machine now includes states for:
|
- Unit state machine now includes states for:
|
||||||
- idle / moving
|
- idle / moving
|
||||||
- FTL travel
|
- FTL travel
|
||||||
- mining and delivery
|
- mining and delivery
|
||||||
- docking approach / docking / docked / undocking
|
- docking approach / docking / docked / undocking
|
||||||
- patrol / escort
|
- patrol / escort
|
||||||
|
- Fleets are now a first-class gameplay concept:
|
||||||
|
- fleets have a commander, stance, high-level fleet order, and explicit wings
|
||||||
|
- wings can be nested via parent/child relationships to represent sub-wings
|
||||||
|
- ships now carry behavior metadata such as command, mining, escort, screen, or logistics
|
||||||
|
- fleet orders fan out into ship-level execution orders, keeping "order" separate from "behavior"
|
||||||
|
- Fleet tree selection is now manager-backed:
|
||||||
|
- fleets, wings, sub-wings, and ships can be selected directly from the fleet window
|
||||||
|
- selection is now handled through a dedicated selection manager rather than scattered UI state mutations
|
||||||
- Orders currently supported:
|
- Orders currently supported:
|
||||||
- move
|
- move
|
||||||
- transfer
|
- transfer
|
||||||
@@ -60,12 +77,15 @@ The current prototype includes:
|
|||||||
|
|
||||||
- Docking was added as a required step for transfer to stations
|
- Docking was added as a required step for transfer to stations
|
||||||
- Stations have limited docking capacity and explicit docking ports
|
- Stations have limited docking capacity and explicit docking ports
|
||||||
|
- Carriers now act as mobile docking hosts for smaller combatants
|
||||||
|
- Carrier recovery was corrected so docking ships reserve a pad early and approach the moving pad directly instead of stalling behind the hull
|
||||||
- Mining ships now:
|
- Mining ships now:
|
||||||
- mine ore in `Perseus`
|
- mine ore in `Perseus`
|
||||||
- return to `Helios`
|
- return to `Helios`
|
||||||
- dock at a refinery
|
- dock at a refinery
|
||||||
- transfer ore
|
- transfer ore
|
||||||
- undock and repeat
|
- undock and repeat
|
||||||
|
- Mining ships now correctly leave for the refinery once full even when the delivery leg is inter-system
|
||||||
|
|
||||||
### Economy / Inventory Foundations
|
### Economy / Inventory Foundations
|
||||||
|
|
||||||
@@ -75,15 +95,32 @@ The current prototype includes:
|
|||||||
- `bulk-gas`
|
- `bulk-gas`
|
||||||
- `container`
|
- `container`
|
||||||
- `manufactured`
|
- `manufactured`
|
||||||
|
- Added itemized manufactured goods for industrial progression:
|
||||||
|
- `hull-sections`
|
||||||
|
- `ammo-crates`
|
||||||
|
- `naval-guns`
|
||||||
|
- `ship-equipment`
|
||||||
|
- `ship-parts`
|
||||||
|
- Added deployable constructible kit items so buildables are also producible:
|
||||||
|
- `trade-hub-kit`
|
||||||
|
- `refinery-kit`
|
||||||
|
- `farm-ring-kit`
|
||||||
|
- `manufactory-kit`
|
||||||
|
- `shipyard-kit`
|
||||||
|
- `defense-grid-kit`
|
||||||
|
- `stargate-kit`
|
||||||
- Added module categories and starter module definitions for ships/stations
|
- Added module categories and starter module definitions for ships/stations
|
||||||
- Added explicit recipe data for refinery processing
|
- Added explicit recipe data for refinery and fabrication processing
|
||||||
- Ships and stations now expose compatible cargo/storage/module metadata
|
- Ships and stations now expose compatible cargo/storage/module metadata
|
||||||
- Refineries track:
|
- Refineries track:
|
||||||
- ore stored
|
- ore stored
|
||||||
- active refining batch
|
- active refining batch
|
||||||
- refining timer
|
- refining timer
|
||||||
- refined output stock
|
- refined output stock
|
||||||
- Refinery processing now consumes ore inventory and produces manufactured output through a recipe-driven flow
|
- Stations now also maintain per-item stock internally
|
||||||
|
- Fabricator-array stations can now build ship parts, ammo, guns, and equipment from recipe data
|
||||||
|
- Fabricator-array stations can now also assemble deployable kits for constructibles, including stargates
|
||||||
|
- Refinery/manufactory processing now consumes itemized inputs and produces itemized outputs through a shared recipe-driven flow
|
||||||
|
|
||||||
### Energy / Fuel
|
### Energy / Fuel
|
||||||
|
|
||||||
@@ -110,7 +147,12 @@ The current prototype includes:
|
|||||||
|
|
||||||
- Ship and station selection is supported
|
- Ship and station selection is supported
|
||||||
- Ship multi-selection is supported via click modifiers and marquee drag selection
|
- Ship multi-selection is supported via click modifiers and marquee drag selection
|
||||||
|
- Selected ships can now be ordered to dock with the nearest friendly carrier
|
||||||
|
- Active fleets can be selected and focused through the fleet command window
|
||||||
|
- The fleet window now renders fleets as a real tree rather than a flat list
|
||||||
|
- Fleet tree nodes show order, behavior, and state on ship-level entries
|
||||||
- `Solar` and `universe` views now overlay high-level tactical symbology instead of relying only on 3D meshes
|
- `Solar` and `universe` views now overlay high-level tactical symbology instead of relying only on 3D meshes
|
||||||
|
- `Solar` view now shows fleet hierarchy links between commanders and wing leaders
|
||||||
- Ships use role-specific long-range symbols:
|
- Ships use role-specific long-range symbols:
|
||||||
- military: hostile/combat-style diamond iconography
|
- military: hostile/combat-style diamond iconography
|
||||||
- transport: boxed logistics symbol
|
- transport: boxed logistics symbol
|
||||||
@@ -131,13 +173,16 @@ The current prototype includes:
|
|||||||
- `Ctrl/Cmd + Left Click`: toggle ships in selection
|
- `Ctrl/Cmd + Left Click`: toggle ships in selection
|
||||||
- `Left Drag`: marquee-select multiple ships
|
- `Left Drag`: marquee-select multiple ships
|
||||||
- `Right Click`: issue move/transfer orders
|
- `Right Click`: issue move/transfer orders
|
||||||
|
- `Right Click` with no ship selection and an active fleet: issue a fleet move order
|
||||||
- `Mouse Wheel` or `-` / `=`: zoom
|
- `Mouse Wheel` or `-` / `=`: zoom
|
||||||
- `W A S D`: pan camera
|
- `W A S D`: pan camera
|
||||||
- `Q / E`: rotate camera
|
- `Q / E`: rotate camera
|
||||||
- `F`: focus selection, and follow a single selected ship
|
- `F`: focus selection, and follow a single selected ship
|
||||||
|
- `G`: toggle the fleet command window
|
||||||
|
- `R`: assign selected compatible ships to dock with the nearest friendly carrier
|
||||||
- `Tab`: jump camera between systems
|
- `Tab`: jump camera between systems
|
||||||
- `B`: toggle build mode
|
- `B`: toggle build mode
|
||||||
- `1-5`: choose constructible
|
- `1-7`: choose constructible
|
||||||
- `M`: assign mining
|
- `M`: assign mining
|
||||||
- `P`: assign patrol
|
- `P`: assign patrol
|
||||||
- `E`: assign escort
|
- `E`: assign escort
|
||||||
@@ -158,12 +203,19 @@ The current prototype includes:
|
|||||||
- `scenario.json`
|
- `scenario.json`
|
||||||
- `balance.json`
|
- `balance.json`
|
||||||
- Shared domain and runtime types now live in `src/game/types.ts`
|
- Shared domain and runtime types now live in `src/game/types.ts`
|
||||||
|
- Stations now carry both coarse storage totals and itemized stock for recipes
|
||||||
|
- The recipe graph now covers every current constructible and every current catalog item ID
|
||||||
- World construction is extracted into `src/game/world/worldFactory.ts`
|
- World construction is extracted into `src/game/world/worldFactory.ts`
|
||||||
- HUD creation and presentation logic are extracted into:
|
- HUD creation and presentation logic are extracted into:
|
||||||
- `src/game/ui/hud.ts`
|
- `src/game/ui/hud.ts`
|
||||||
- `src/game/ui/presenters.ts`
|
- `src/game/ui/presenters.ts`
|
||||||
- `src/game/ui/strategicRenderer.ts`
|
- `src/game/ui/strategicRenderer.ts`
|
||||||
|
- Fleet composition helpers now live in:
|
||||||
|
- `src/game/fleet/runtime.ts`
|
||||||
- Inventory helpers now live in `src/game/state/inventory.ts`
|
- Inventory helpers now live in `src/game/state/inventory.ts`
|
||||||
|
- Selection logic is now centralized in:
|
||||||
|
- `src/game/state/selectionManager.ts`
|
||||||
|
- Ship-to-ship docking now reuses the same generalized docking path as station docking inside `src/game/GameApp.ts`
|
||||||
- High-level symbology is rendered through a dedicated 2D HUD overlay canvas layered above the 3D scene
|
- 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`
|
- Production build is currently passing with `npm run build`
|
||||||
|
|
||||||
@@ -173,10 +225,14 @@ The current prototype includes:
|
|||||||
- Stations are on Lagrange-style offsets, but not using a physically rigorous orbital solver
|
- 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
|
- 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
|
- 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
|
- Module definitions exist, and a generic window framework now exists, but there is still no actual ship/station designer yet
|
||||||
- Inventory classes exist, but only a subset of economic flows are implemented
|
- Production is still automated by recipe priority; there is not yet a player-facing queue UI for choosing or reordering station recipes
|
||||||
|
- Constructible recipes currently output kit items, but build mode still spawns structures directly rather than consuming those kits
|
||||||
- Docking works for logistics, but there is not yet a richer docking queue / reservation UI
|
- 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
|
- NATO-style symbology is gameplay-oriented inspiration, not a strict APP-6 / MIL-STD implementation
|
||||||
|
- Fleet orders currently cover patrol, move, hold, and mining, but wing-specific doctrine editing is still minimal
|
||||||
|
- Carrier recovery exists, but launch/redeploy flows and richer carrier doctrine are still minimal
|
||||||
|
- Window positions and sizes are not yet persisted across reloads
|
||||||
|
|
||||||
## Suggested Next Steps
|
## Suggested Next Steps
|
||||||
|
|
||||||
@@ -194,3 +250,4 @@ The current prototype includes:
|
|||||||
- Expand the economy beyond ore/refining into manufactured goods and trade lanes
|
- Expand the economy beyond ore/refining into manufactured goods and trade lanes
|
||||||
- Improve FTL visuals with a fullscreen post-process distortion or tunnel effect
|
- 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
|
- Expand the strategic overlay with threat rings, route arrows, and fleet stance/status markers
|
||||||
|
- Extract fleet command propagation and ship AI execution out of `GameApp.ts` into dedicated simulation systems now that the fleet model has stabilized enough to justify it
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,16 @@
|
|||||||
"storage": { "bulk-liquid": 600, "container": 400 },
|
"storage": { "bulk-liquid": 600, "container": 400 },
|
||||||
"modules": ["habitat-ring", "fabricator-array", "container-bay"]
|
"modules": ["habitat-ring", "fabricator-array", "container-bay"]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "manufactory",
|
||||||
|
"label": "Orbital Manufactory",
|
||||||
|
"category": "station",
|
||||||
|
"color": "#8df0d2",
|
||||||
|
"radius": 24,
|
||||||
|
"dockingCapacity": 3,
|
||||||
|
"storage": { "manufactured": 2200, "container": 1600 },
|
||||||
|
"modules": ["fabricator-array", "fabricator-array", "container-bay", "docking-clamps"]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "shipyard",
|
"id": "shipyard",
|
||||||
"label": "Orbital Shipyard",
|
"label": "Orbital Shipyard",
|
||||||
@@ -48,5 +58,15 @@
|
|||||||
"dockingCapacity": 1,
|
"dockingCapacity": 1,
|
||||||
"storage": { "manufactured": 300 },
|
"storage": { "manufactured": 300 },
|
||||||
"modules": ["turret-grid", "command-bridge"]
|
"modules": ["turret-grid", "command-bridge"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "stargate",
|
||||||
|
"label": "Stargate",
|
||||||
|
"category": "gate",
|
||||||
|
"color": "#76f0ff",
|
||||||
|
"radius": 34,
|
||||||
|
"dockingCapacity": 0,
|
||||||
|
"storage": { "manufactured": 2400, "container": 800 },
|
||||||
|
"modules": ["ftl-core", "fabricator-array", "docking-clamps"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -11,6 +11,36 @@
|
|||||||
"storage": "manufactured",
|
"storage": "manufactured",
|
||||||
"summary": "Processed structural metals used by stations and shipyards."
|
"summary": "Processed structural metals used by stations and shipyards."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "hull-sections",
|
||||||
|
"label": "Hull Sections",
|
||||||
|
"storage": "manufactured",
|
||||||
|
"summary": "Prefabricated structural assemblies for ships and stations."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ammo-crates",
|
||||||
|
"label": "Ammo Crates",
|
||||||
|
"storage": "container",
|
||||||
|
"summary": "Containerized magazines for turrets, launchers, and point defense."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "naval-guns",
|
||||||
|
"label": "Naval Guns",
|
||||||
|
"storage": "manufactured",
|
||||||
|
"summary": "Shipboard turret and cannon assemblies."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ship-equipment",
|
||||||
|
"label": "Ship Equipment",
|
||||||
|
"storage": "container",
|
||||||
|
"summary": "Shield emitters, avionics, cooling loops, and service kits."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ship-parts",
|
||||||
|
"label": "Ship Parts",
|
||||||
|
"storage": "manufactured",
|
||||||
|
"summary": "High-value integration kits for hull fitting and final assembly."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "gas",
|
"id": "gas",
|
||||||
"label": "Volatile Gas",
|
"label": "Volatile Gas",
|
||||||
@@ -28,5 +58,47 @@
|
|||||||
"label": "Drone Parts",
|
"label": "Drone Parts",
|
||||||
"storage": "container",
|
"storage": "container",
|
||||||
"summary": "Containerized industrial freight."
|
"summary": "Containerized industrial freight."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "trade-hub-kit",
|
||||||
|
"label": "Trade Hub Kit",
|
||||||
|
"storage": "manufactured",
|
||||||
|
"summary": "Deployable prefab package for a trade hub station."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "refinery-kit",
|
||||||
|
"label": "Refinery Kit",
|
||||||
|
"storage": "manufactured",
|
||||||
|
"summary": "Deployable prefab package for a refining station."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "farm-ring-kit",
|
||||||
|
"label": "Farm Ring Kit",
|
||||||
|
"storage": "manufactured",
|
||||||
|
"summary": "Deployable prefab package for a farm station."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "manufactory-kit",
|
||||||
|
"label": "Manufactory Kit",
|
||||||
|
"storage": "manufactured",
|
||||||
|
"summary": "Deployable prefab package for an orbital manufactory."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "shipyard-kit",
|
||||||
|
"label": "Shipyard Kit",
|
||||||
|
"storage": "manufactured",
|
||||||
|
"summary": "Deployable prefab package for an orbital shipyard."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "defense-grid-kit",
|
||||||
|
"label": "Defense Grid Kit",
|
||||||
|
"storage": "manufactured",
|
||||||
|
"summary": "Deployable prefab package for a defense platform."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "stargate-kit",
|
||||||
|
"label": "Stargate Kit",
|
||||||
|
"storage": "manufactured",
|
||||||
|
"summary": "Deployable prefab package for a stargate structure."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -41,6 +41,12 @@
|
|||||||
"category": "dock",
|
"category": "dock",
|
||||||
"summary": "Docking collar and transfer arms."
|
"summary": "Docking collar and transfer arms."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "carrier-bay",
|
||||||
|
"label": "Carrier Bay",
|
||||||
|
"category": "dock",
|
||||||
|
"summary": "Internal hangar decks and launch recovery systems."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "refinery-stack",
|
"id": "refinery-stack",
|
||||||
"label": "Refinery Stack",
|
"label": "Refinery Stack",
|
||||||
|
|||||||
@@ -4,11 +4,255 @@
|
|||||||
"label": "Ore Refining",
|
"label": "Ore Refining",
|
||||||
"facilityCategory": "refining",
|
"facilityCategory": "refining",
|
||||||
"duration": 8,
|
"duration": 8,
|
||||||
|
"priority": 100,
|
||||||
"inputs": [
|
"inputs": [
|
||||||
{ "itemId": "ore", "amount": 60 }
|
{ "itemId": "ore", "amount": 60 }
|
||||||
],
|
],
|
||||||
"outputs": [
|
"outputs": [
|
||||||
{ "itemId": "refined-metals", "amount": 60 }
|
{ "itemId": "refined-metals", "amount": 60 }
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ore-reclamation",
|
||||||
|
"label": "Ore Reclamation",
|
||||||
|
"facilityCategory": "station",
|
||||||
|
"duration": 7,
|
||||||
|
"priority": 8,
|
||||||
|
"requiredModules": ["fabricator-array"],
|
||||||
|
"inputs": [
|
||||||
|
{ "itemId": "refined-metals", "amount": 16 }
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
{ "itemId": "ore", "amount": 24 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "gas-synthesis",
|
||||||
|
"label": "Gas Synthesis",
|
||||||
|
"facilityCategory": "station",
|
||||||
|
"duration": 6,
|
||||||
|
"priority": 12,
|
||||||
|
"requiredModules": ["fabricator-array"],
|
||||||
|
"inputs": [
|
||||||
|
{ "itemId": "refined-metals", "amount": 10 }
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
{ "itemId": "gas", "amount": 20 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "water-reclamation",
|
||||||
|
"label": "Water Reclamation",
|
||||||
|
"facilityCategory": "farm",
|
||||||
|
"duration": 6,
|
||||||
|
"priority": 14,
|
||||||
|
"requiredModules": ["fabricator-array"],
|
||||||
|
"inputs": [
|
||||||
|
{ "itemId": "gas", "amount": 8 }
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
{ "itemId": "water", "amount": 18 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "drone-parts-assembly",
|
||||||
|
"label": "Drone Parts Assembly",
|
||||||
|
"facilityCategory": "station",
|
||||||
|
"duration": 7,
|
||||||
|
"priority": 18,
|
||||||
|
"requiredModules": ["fabricator-array"],
|
||||||
|
"inputs": [
|
||||||
|
{ "itemId": "refined-metals", "amount": 12 },
|
||||||
|
{ "itemId": "ship-equipment", "amount": 6 }
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
{ "itemId": "drone-parts", "amount": 16 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "hull-fabrication",
|
||||||
|
"label": "Hull Fabrication",
|
||||||
|
"facilityCategory": "station",
|
||||||
|
"duration": 10,
|
||||||
|
"priority": 40,
|
||||||
|
"requiredModules": ["fabricator-array"],
|
||||||
|
"inputs": [
|
||||||
|
{ "itemId": "refined-metals", "amount": 70 }
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
{ "itemId": "hull-sections", "amount": 35 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ammo-fabrication",
|
||||||
|
"label": "Ammo Fabrication",
|
||||||
|
"facilityCategory": "station",
|
||||||
|
"duration": 6,
|
||||||
|
"priority": 34,
|
||||||
|
"requiredModules": ["fabricator-array"],
|
||||||
|
"inputs": [
|
||||||
|
{ "itemId": "refined-metals", "amount": 24 }
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
{ "itemId": "ammo-crates", "amount": 30 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "gun-assembly",
|
||||||
|
"label": "Gun Assembly",
|
||||||
|
"facilityCategory": "station",
|
||||||
|
"duration": 9,
|
||||||
|
"priority": 32,
|
||||||
|
"requiredModules": ["fabricator-array"],
|
||||||
|
"inputs": [
|
||||||
|
{ "itemId": "refined-metals", "amount": 36 }
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
{ "itemId": "naval-guns", "amount": 12 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "equipment-assembly",
|
||||||
|
"label": "Equipment Assembly",
|
||||||
|
"facilityCategory": "station",
|
||||||
|
"duration": 11,
|
||||||
|
"priority": 30,
|
||||||
|
"requiredModules": ["fabricator-array"],
|
||||||
|
"inputs": [
|
||||||
|
{ "itemId": "refined-metals", "amount": 28 },
|
||||||
|
{ "itemId": "water", "amount": 8 }
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
{ "itemId": "ship-equipment", "amount": 18 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ship-parts-integration",
|
||||||
|
"label": "Ship Parts Integration",
|
||||||
|
"facilityCategory": "station",
|
||||||
|
"duration": 14,
|
||||||
|
"priority": 50,
|
||||||
|
"requiredModules": ["fabricator-array"],
|
||||||
|
"inputs": [
|
||||||
|
{ "itemId": "hull-sections", "amount": 24 },
|
||||||
|
{ "itemId": "naval-guns", "amount": 6 },
|
||||||
|
{ "itemId": "ship-equipment", "amount": 10 }
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
{ "itemId": "ship-parts", "amount": 20 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "trade-hub-assembly",
|
||||||
|
"label": "Trade Hub Assembly",
|
||||||
|
"facilityCategory": "station",
|
||||||
|
"duration": 18,
|
||||||
|
"priority": 24,
|
||||||
|
"requiredModules": ["fabricator-array"],
|
||||||
|
"inputs": [
|
||||||
|
{ "itemId": "ship-parts", "amount": 26 },
|
||||||
|
{ "itemId": "ship-equipment", "amount": 16 },
|
||||||
|
{ "itemId": "drone-parts", "amount": 10 }
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
{ "itemId": "trade-hub-kit", "amount": 1 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "refinery-assembly",
|
||||||
|
"label": "Refinery Assembly",
|
||||||
|
"facilityCategory": "station",
|
||||||
|
"duration": 20,
|
||||||
|
"priority": 26,
|
||||||
|
"requiredModules": ["fabricator-array"],
|
||||||
|
"inputs": [
|
||||||
|
{ "itemId": "ship-parts", "amount": 32 },
|
||||||
|
{ "itemId": "hull-sections", "amount": 24 },
|
||||||
|
{ "itemId": "ship-equipment", "amount": 14 }
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
{ "itemId": "refinery-kit", "amount": 1 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "farm-ring-assembly",
|
||||||
|
"label": "Farm Ring Assembly",
|
||||||
|
"facilityCategory": "station",
|
||||||
|
"duration": 18,
|
||||||
|
"priority": 22,
|
||||||
|
"requiredModules": ["fabricator-array"],
|
||||||
|
"inputs": [
|
||||||
|
{ "itemId": "ship-parts", "amount": 22 },
|
||||||
|
{ "itemId": "ship-equipment", "amount": 18 },
|
||||||
|
{ "itemId": "water", "amount": 22 }
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
{ "itemId": "farm-ring-kit", "amount": 1 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "manufactory-assembly",
|
||||||
|
"label": "Manufactory Assembly",
|
||||||
|
"facilityCategory": "station",
|
||||||
|
"duration": 22,
|
||||||
|
"priority": 28,
|
||||||
|
"requiredModules": ["fabricator-array"],
|
||||||
|
"inputs": [
|
||||||
|
{ "itemId": "ship-parts", "amount": 34 },
|
||||||
|
{ "itemId": "hull-sections", "amount": 16 },
|
||||||
|
{ "itemId": "ship-equipment", "amount": 18 }
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
{ "itemId": "manufactory-kit", "amount": 1 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "shipyard-assembly",
|
||||||
|
"label": "Shipyard Assembly",
|
||||||
|
"facilityCategory": "station",
|
||||||
|
"duration": 26,
|
||||||
|
"priority": 30,
|
||||||
|
"requiredModules": ["fabricator-array"],
|
||||||
|
"inputs": [
|
||||||
|
{ "itemId": "ship-parts", "amount": 42 },
|
||||||
|
{ "itemId": "hull-sections", "amount": 30 },
|
||||||
|
{ "itemId": "naval-guns", "amount": 10 }
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
{ "itemId": "shipyard-kit", "amount": 1 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "defense-grid-assembly",
|
||||||
|
"label": "Defense Grid Assembly",
|
||||||
|
"facilityCategory": "station",
|
||||||
|
"duration": 16,
|
||||||
|
"priority": 20,
|
||||||
|
"requiredModules": ["fabricator-array"],
|
||||||
|
"inputs": [
|
||||||
|
{ "itemId": "ship-parts", "amount": 18 },
|
||||||
|
{ "itemId": "naval-guns", "amount": 12 },
|
||||||
|
{ "itemId": "ammo-crates", "amount": 18 }
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
{ "itemId": "defense-grid-kit", "amount": 1 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "stargate-assembly",
|
||||||
|
"label": "Stargate Assembly",
|
||||||
|
"facilityCategory": "station",
|
||||||
|
"duration": 34,
|
||||||
|
"priority": 36,
|
||||||
|
"requiredModules": ["fabricator-array"],
|
||||||
|
"inputs": [
|
||||||
|
{ "itemId": "ship-parts", "amount": 60 },
|
||||||
|
{ "itemId": "hull-sections", "amount": 44 },
|
||||||
|
{ "itemId": "ship-equipment", "amount": 26 },
|
||||||
|
{ "itemId": "naval-guns", "amount": 8 }
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
{ "itemId": "stargate-kit", "amount": 1 }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -7,10 +7,13 @@
|
|||||||
{ "constructibleId": "defense-grid", "systemId": "helios", "planetIndex": 2, "lagrangeSide": 1 }
|
{ "constructibleId": "defense-grid", "systemId": "helios", "planetIndex": 2, "lagrangeSide": 1 }
|
||||||
],
|
],
|
||||||
"shipFormations": [
|
"shipFormations": [
|
||||||
|
{ "shipId": "carrier", "count": 1, "center": [120, 0, 60], "systemId": "helios" },
|
||||||
{ "shipId": "frigate", "count": 6, "center": [180, 0, 90], "systemId": "helios" },
|
{ "shipId": "frigate", "count": 6, "center": [180, 0, 90], "systemId": "helios" },
|
||||||
{ "shipId": "destroyer", "count": 3, "center": [260, 0, 120], "systemId": "helios" },
|
{ "shipId": "destroyer", "count": 3, "center": [260, 0, 120], "systemId": "helios" },
|
||||||
|
{ "shipId": "cruiser", "count": 2, "center": [220, 0, 180], "systemId": "helios" },
|
||||||
{ "shipId": "hauler", "count": 4, "center": [310, 0, -150], "systemId": "helios" },
|
{ "shipId": "hauler", "count": 4, "center": [310, 0, -150], "systemId": "helios" },
|
||||||
{ "shipId": "frigate", "count": 4, "center": [4350, 0, 560], "systemId": "perseus" },
|
{ "shipId": "frigate", "count": 4, "center": [4350, 0, 560], "systemId": "perseus" },
|
||||||
|
{ "shipId": "cruiser", "count": 1, "center": [4430, 0, 640], "systemId": "perseus" },
|
||||||
{ "shipId": "miner", "count": 6, "center": [4620, 0, 700], "systemId": "perseus" }
|
{ "shipId": "miner", "count": 6, "center": [4620, 0, 700], "systemId": "perseus" }
|
||||||
],
|
],
|
||||||
"patrolRoutes": [
|
"patrolRoutes": [
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
"id": "frigate",
|
"id": "frigate",
|
||||||
"label": "Vanguard Frigate",
|
"label": "Vanguard Frigate",
|
||||||
"role": "military",
|
"role": "military",
|
||||||
|
"shipClass": "frigate",
|
||||||
"speed": 50,
|
"speed": 50,
|
||||||
"ftlSpeed": 3200,
|
"ftlSpeed": 3200,
|
||||||
"spoolTime": 2.2,
|
"spoolTime": 2.2,
|
||||||
@@ -17,6 +18,7 @@
|
|||||||
"id": "destroyer",
|
"id": "destroyer",
|
||||||
"label": "Bulwark Destroyer",
|
"label": "Bulwark Destroyer",
|
||||||
"role": "military",
|
"role": "military",
|
||||||
|
"shipClass": "destroyer",
|
||||||
"speed": 34,
|
"speed": 34,
|
||||||
"ftlSpeed": 2900,
|
"ftlSpeed": 2900,
|
||||||
"spoolTime": 2.8,
|
"spoolTime": 2.8,
|
||||||
@@ -27,10 +29,43 @@
|
|||||||
"maxHealth": 240,
|
"maxHealth": 240,
|
||||||
"modules": ["command-bridge", "ion-drive", "ftl-core", "turret-grid", "turret-grid"]
|
"modules": ["command-bridge", "ion-drive", "ftl-core", "turret-grid", "turret-grid"]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "cruiser",
|
||||||
|
"label": "Aegis Cruiser",
|
||||||
|
"role": "military",
|
||||||
|
"shipClass": "cruiser",
|
||||||
|
"speed": 28,
|
||||||
|
"ftlSpeed": 2750,
|
||||||
|
"spoolTime": 3.1,
|
||||||
|
"cargoCapacity": 0,
|
||||||
|
"color": "#9ec1ff",
|
||||||
|
"hullColor": "#314562",
|
||||||
|
"size": 10,
|
||||||
|
"maxHealth": 340,
|
||||||
|
"modules": ["command-bridge", "ion-drive", "ftl-core", "turret-grid", "turret-grid", "docking-clamps"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "carrier",
|
||||||
|
"label": "Citadel Carrier",
|
||||||
|
"role": "military",
|
||||||
|
"shipClass": "capital",
|
||||||
|
"speed": 18,
|
||||||
|
"ftlSpeed": 2500,
|
||||||
|
"spoolTime": 4.1,
|
||||||
|
"cargoCapacity": 0,
|
||||||
|
"color": "#c6f4ff",
|
||||||
|
"hullColor": "#35586d",
|
||||||
|
"size": 16,
|
||||||
|
"maxHealth": 900,
|
||||||
|
"modules": ["command-bridge", "ion-drive", "ftl-core", "carrier-bay", "carrier-bay", "turret-grid", "habitat-ring"],
|
||||||
|
"dockingCapacity": 6,
|
||||||
|
"dockingClasses": ["frigate", "destroyer", "cruiser"]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "hauler",
|
"id": "hauler",
|
||||||
"label": "Atlas Hauler",
|
"label": "Atlas Hauler",
|
||||||
"role": "transport",
|
"role": "transport",
|
||||||
|
"shipClass": "industrial",
|
||||||
"speed": 22,
|
"speed": 22,
|
||||||
"ftlSpeed": 2600,
|
"ftlSpeed": 2600,
|
||||||
"spoolTime": 3.3,
|
"spoolTime": 3.3,
|
||||||
@@ -47,6 +82,7 @@
|
|||||||
"id": "miner",
|
"id": "miner",
|
||||||
"label": "Prospector Miner",
|
"label": "Prospector Miner",
|
||||||
"role": "mining",
|
"role": "mining",
|
||||||
|
"shipClass": "industrial",
|
||||||
"speed": 26,
|
"speed": 26,
|
||||||
"ftlSpeed": 2400,
|
"ftlSpeed": 2400,
|
||||||
"spoolTime": 3.1,
|
"spoolTime": 3.1,
|
||||||
|
|||||||
236
src/game/fleet/runtime.ts
Normal file
236
src/game/fleet/runtime.ts
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
import * as THREE from "three";
|
||||||
|
import type { FleetBehavior, FleetInstance, FleetWingInstance, ShipInstance } from "../types";
|
||||||
|
|
||||||
|
interface FleetBuildSpec {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
stance: FleetInstance["stance"];
|
||||||
|
systemId: string;
|
||||||
|
commander: ShipInstance;
|
||||||
|
wings: Array<{
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
behavior: FleetBehavior;
|
||||||
|
parentWingId?: string;
|
||||||
|
ships: ShipInstance[];
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDefaultFleets(ships: ShipInstance[]) {
|
||||||
|
clearFleetAssignments(ships);
|
||||||
|
|
||||||
|
const heliosMilitary = ships.filter((ship) => ship.systemId === "helios" && ship.definition.role === "military");
|
||||||
|
const heliosCarriers = heliosMilitary.filter((ship) => ship.definition.shipClass === "capital");
|
||||||
|
const heliosDestroyers = heliosMilitary.filter((ship) => ship.definition.id === "destroyer");
|
||||||
|
const allHaulers = ships.filter((ship) => ship.definition.role === "transport");
|
||||||
|
const perseusMilitary = ships.filter((ship) => ship.systemId === "perseus" && ship.definition.role === "military");
|
||||||
|
const miners = ships.filter((ship) => ship.definition.role === "mining");
|
||||||
|
|
||||||
|
const miningHaulers = allHaulers.slice(0, 2);
|
||||||
|
const homeHaulers = allHaulers.slice(2);
|
||||||
|
|
||||||
|
const specs: FleetBuildSpec[] = [];
|
||||||
|
|
||||||
|
const homeCommander = heliosCarriers[0] ?? heliosDestroyers[0] ?? heliosMilitary[0];
|
||||||
|
if (homeCommander) {
|
||||||
|
const homeScreenShips = heliosMilitary.filter(
|
||||||
|
(ship) => ship.id !== homeCommander.id && ship.definition.shipClass !== "destroyer",
|
||||||
|
);
|
||||||
|
specs.push({
|
||||||
|
id: "helios-home-fleet",
|
||||||
|
label: "Helios Home Fleet",
|
||||||
|
stance: "defensive",
|
||||||
|
systemId: "helios",
|
||||||
|
commander: homeCommander,
|
||||||
|
wings: [
|
||||||
|
{
|
||||||
|
id: "command",
|
||||||
|
label: "Command Wing",
|
||||||
|
behavior: "command",
|
||||||
|
ships: [homeCommander, ...heliosDestroyers.slice(1)],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "screen",
|
||||||
|
label: "Screen Wing",
|
||||||
|
behavior: "screen",
|
||||||
|
parentWingId: "command",
|
||||||
|
ships: homeScreenShips,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "logistics",
|
||||||
|
label: "Logistics Wing",
|
||||||
|
behavior: "logistics",
|
||||||
|
parentWingId: "command",
|
||||||
|
ships: homeHaulers,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const extractionCommander = perseusMilitary[0] ?? miners[0];
|
||||||
|
if (extractionCommander) {
|
||||||
|
specs.push({
|
||||||
|
id: "perseus-extraction-fleet",
|
||||||
|
label: "Perseus Extraction Group",
|
||||||
|
stance: "industrial",
|
||||||
|
systemId: "perseus",
|
||||||
|
commander: extractionCommander,
|
||||||
|
wings: [
|
||||||
|
{
|
||||||
|
id: "command",
|
||||||
|
label: "Command Wing",
|
||||||
|
behavior: "command",
|
||||||
|
ships: [extractionCommander],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "miners",
|
||||||
|
label: "Mining Wing",
|
||||||
|
behavior: "mining",
|
||||||
|
parentWingId: "command",
|
||||||
|
ships: miners,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "escort",
|
||||||
|
label: "Escort Wing",
|
||||||
|
behavior: "escort",
|
||||||
|
parentWingId: "miners",
|
||||||
|
ships: perseusMilitary.filter((ship) => ship.id !== extractionCommander.id),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "transport",
|
||||||
|
label: "Transport Wing",
|
||||||
|
behavior: "logistics",
|
||||||
|
parentWingId: "miners",
|
||||||
|
ships: miningHaulers,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return specs.map((spec) => materializeFleet(spec));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFleetShipIds(fleet: FleetInstance) {
|
||||||
|
return fleet.shipIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFleetCommander(fleet: FleetInstance, shipsById: Map<string, ShipInstance>) {
|
||||||
|
return shipsById.get(fleet.commanderShipId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWingLeader(wing: FleetWingInstance, shipsById: Map<string, ShipInstance>) {
|
||||||
|
return shipsById.get(wing.leaderShipId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWingMembers(wing: FleetWingInstance, shipsById: Map<string, ShipInstance>) {
|
||||||
|
return wing.shipIds.map((shipId) => shipsById.get(shipId)).filter((ship): ship is ShipInstance => Boolean(ship));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function describeFleetOrder(fleet: FleetInstance) {
|
||||||
|
switch (fleet.order.kind) {
|
||||||
|
case "idle":
|
||||||
|
return "Holding formation";
|
||||||
|
case "move":
|
||||||
|
return `Moving to ${fleet.order.systemId}`;
|
||||||
|
case "patrol":
|
||||||
|
return `Patrolling ${fleet.order.systemId}`;
|
||||||
|
case "mine":
|
||||||
|
return `Mining ${fleet.order.nodeSystemId} -> ${fleet.order.refinerySystemId}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function materializeFleet(spec: FleetBuildSpec): FleetInstance {
|
||||||
|
const wings = spec.wings
|
||||||
|
.filter((wing) => wing.ships.length > 0)
|
||||||
|
.map((wing) => buildWing(spec.id, wing));
|
||||||
|
|
||||||
|
const shipIds = wings.flatMap((wing) => wing.shipIds);
|
||||||
|
shipIds.forEach((shipId, index) => {
|
||||||
|
const ship = spec.wings.flatMap((wing) => wing.ships).find((candidate) => candidate.id === shipId);
|
||||||
|
if (!ship) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fleetWing = wings.find((wing) => wing.shipIds.includes(shipId));
|
||||||
|
if (!fleetWing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ship.fleetId = spec.id;
|
||||||
|
ship.wingId = fleetWing.id;
|
||||||
|
ship.behavior = fleetWing.behavior;
|
||||||
|
ship.isFleetCommander = ship.id === spec.commander.id;
|
||||||
|
ship.isWingLeader = ship.id === fleetWing.leaderShipId;
|
||||||
|
ship.formationOffset.copy(makeFormationOffset(index));
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: spec.id,
|
||||||
|
label: spec.label,
|
||||||
|
stance: spec.stance,
|
||||||
|
commanderShipId: spec.commander.id,
|
||||||
|
systemId: spec.systemId,
|
||||||
|
shipIds,
|
||||||
|
wings,
|
||||||
|
order: { kind: "idle" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWing(
|
||||||
|
fleetId: string,
|
||||||
|
wing: {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
behavior: FleetBehavior;
|
||||||
|
parentWingId?: string;
|
||||||
|
ships: ShipInstance[];
|
||||||
|
},
|
||||||
|
): FleetWingInstance {
|
||||||
|
const orderedShips = [...wing.ships];
|
||||||
|
const leader = orderedShips[0];
|
||||||
|
|
||||||
|
orderedShips.forEach((ship, index) => {
|
||||||
|
ship.formationOffset.copy(makeFormationOffset(index));
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `${fleetId}:${wing.id}`,
|
||||||
|
fleetId,
|
||||||
|
label: wing.label,
|
||||||
|
behavior: wing.behavior,
|
||||||
|
parentWingId: wing.parentWingId ? `${fleetId}:${wing.parentWingId}` : undefined,
|
||||||
|
leaderShipId: leader.id,
|
||||||
|
shipIds: orderedShips.map((ship) => ship.id),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFleetAssignments(ships: ShipInstance[]) {
|
||||||
|
ships.forEach((ship) => {
|
||||||
|
ship.fleetId = undefined;
|
||||||
|
ship.wingId = undefined;
|
||||||
|
ship.behavior = "independent";
|
||||||
|
ship.isFleetCommander = false;
|
||||||
|
ship.isWingLeader = false;
|
||||||
|
ship.formationOffset.set(0, 0, 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeFormationOffset(index: number) {
|
||||||
|
if (index === 0) {
|
||||||
|
return new THREE.Vector3();
|
||||||
|
}
|
||||||
|
const ring = Math.ceil((Math.sqrt(index + 1) - 1) / 2);
|
||||||
|
const slot = index - (2 * ring - 1) ** 2;
|
||||||
|
const side = Math.floor(slot / Math.max(1, ring * 2));
|
||||||
|
const local = slot % Math.max(1, ring * 2);
|
||||||
|
const spacing = 26;
|
||||||
|
|
||||||
|
switch (side) {
|
||||||
|
case 0:
|
||||||
|
return new THREE.Vector3((local - ring) * spacing, 0, ring * spacing);
|
||||||
|
case 1:
|
||||||
|
return new THREE.Vector3(ring * spacing, 0, (ring - local) * spacing);
|
||||||
|
case 2:
|
||||||
|
return new THREE.Vector3((ring - local) * spacing, 0, -ring * spacing);
|
||||||
|
default:
|
||||||
|
return new THREE.Vector3(-ring * spacing, 0, (local - ring) * spacing);
|
||||||
|
}
|
||||||
|
}
|
||||||
76
src/game/state/selectionManager.ts
Normal file
76
src/game/state/selectionManager.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import * as THREE from "three";
|
||||||
|
import type { ShipInstance, StationInstance } from "../types";
|
||||||
|
|
||||||
|
export class SelectionManager {
|
||||||
|
private shipSelection: ShipInstance[] = [];
|
||||||
|
private stationSelection?: StationInstance;
|
||||||
|
|
||||||
|
getShips() {
|
||||||
|
return this.shipSelection;
|
||||||
|
}
|
||||||
|
|
||||||
|
getStation() {
|
||||||
|
return this.stationSelection;
|
||||||
|
}
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
this.shipSelection.forEach((ship) => this.setShipVisual(ship, false));
|
||||||
|
this.shipSelection = [];
|
||||||
|
if (this.stationSelection) {
|
||||||
|
this.setStationVisual(this.stationSelection, false);
|
||||||
|
this.stationSelection = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
replaceShips(ships: ShipInstance[]) {
|
||||||
|
this.clear();
|
||||||
|
ships.forEach((ship) => this.addShip(ship));
|
||||||
|
}
|
||||||
|
|
||||||
|
setStation(station?: StationInstance) {
|
||||||
|
this.clear();
|
||||||
|
if (!station) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.stationSelection = station;
|
||||||
|
this.setStationVisual(station, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
addShip(ship: ShipInstance) {
|
||||||
|
if (this.shipSelection.includes(ship)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.stationSelection) {
|
||||||
|
this.setStationVisual(this.stationSelection, false);
|
||||||
|
this.stationSelection = undefined;
|
||||||
|
}
|
||||||
|
this.shipSelection.push(ship);
|
||||||
|
this.setShipVisual(ship, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeShip(ship: ShipInstance) {
|
||||||
|
this.shipSelection = this.shipSelection.filter((candidate) => candidate.id !== ship.id);
|
||||||
|
this.setShipVisual(ship, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleShip(ship: ShipInstance) {
|
||||||
|
if (this.shipSelection.includes(ship)) {
|
||||||
|
this.removeShip(ship);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.addShip(ship);
|
||||||
|
}
|
||||||
|
|
||||||
|
hasShip(ship: ShipInstance) {
|
||||||
|
return this.shipSelection.includes(ship);
|
||||||
|
}
|
||||||
|
|
||||||
|
private setShipVisual(ship: ShipInstance, selected: boolean) {
|
||||||
|
ship.selected = selected;
|
||||||
|
(ship.ring.material as THREE.MeshBasicMaterial).opacity = selected ? 0.95 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private setStationVisual(station: StationInstance, selected: boolean) {
|
||||||
|
(station.ring.material as THREE.MeshBasicMaterial).opacity = selected ? 0.95 : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,17 @@
|
|||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
|
|
||||||
export type ShipRole = "military" | "transport" | "mining";
|
export type ShipRole = "military" | "transport" | "mining";
|
||||||
|
export type ShipClass = "frigate" | "destroyer" | "cruiser" | "industrial" | "capital";
|
||||||
|
export type FleetBehavior = "command" | "screen" | "escort" | "mining" | "logistics" | "reserve";
|
||||||
|
export type FleetStance = "balanced" | "defensive" | "industrial";
|
||||||
|
export type GameWindowId = "fleet-command" | "ship-designer" | "station-manager";
|
||||||
export type ConstructibleCategory =
|
export type ConstructibleCategory =
|
||||||
| "station"
|
| "station"
|
||||||
| "refining"
|
| "refining"
|
||||||
| "farm"
|
| "farm"
|
||||||
| "shipyard"
|
| "shipyard"
|
||||||
| "defense";
|
| "defense"
|
||||||
|
| "gate";
|
||||||
export type UnitState =
|
export type UnitState =
|
||||||
| "idle"
|
| "idle"
|
||||||
| "moving"
|
| "moving"
|
||||||
@@ -22,8 +27,9 @@ export type UnitState =
|
|||||||
| "docked"
|
| "docked"
|
||||||
| "undocking"
|
| "undocking"
|
||||||
| "patrolling"
|
| "patrolling"
|
||||||
| "escorting";
|
| "escorting"
|
||||||
export type UnitOrderKind = "idle" | "move" | "transfer" | "mine" | "patrol" | "escort";
|
| "forming";
|
||||||
|
export type UnitOrderKind = "idle" | "move" | "transfer" | "mine" | "patrol" | "escort" | "dock";
|
||||||
export type ItemStorageKind = "bulk-solid" | "bulk-liquid" | "bulk-gas" | "container" | "manufactured";
|
export type ItemStorageKind = "bulk-solid" | "bulk-liquid" | "bulk-gas" | "container" | "manufactured";
|
||||||
export type ModuleCategory =
|
export type ModuleCategory =
|
||||||
| "bridge"
|
| "bridge"
|
||||||
@@ -63,6 +69,8 @@ export interface RecipeDefinition {
|
|||||||
label: string;
|
label: string;
|
||||||
facilityCategory: ConstructibleCategory;
|
facilityCategory: ConstructibleCategory;
|
||||||
duration: number;
|
duration: number;
|
||||||
|
priority?: number;
|
||||||
|
requiredModules?: string[];
|
||||||
inputs: RecipeComponentDefinition[];
|
inputs: RecipeComponentDefinition[];
|
||||||
outputs: RecipeComponentDefinition[];
|
outputs: RecipeComponentDefinition[];
|
||||||
}
|
}
|
||||||
@@ -71,6 +79,7 @@ export interface ShipDefinition {
|
|||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
role: ShipRole;
|
role: ShipRole;
|
||||||
|
shipClass: ShipClass;
|
||||||
speed: number;
|
speed: number;
|
||||||
ftlSpeed: number;
|
ftlSpeed: number;
|
||||||
spoolTime: number;
|
spoolTime: number;
|
||||||
@@ -82,6 +91,8 @@ export interface ShipDefinition {
|
|||||||
size: number;
|
size: number;
|
||||||
maxHealth: number;
|
maxHealth: number;
|
||||||
modules: string[];
|
modules: string[];
|
||||||
|
dockingCapacity?: number;
|
||||||
|
dockingClasses?: ShipClass[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConstructibleDefinition {
|
export interface ConstructibleDefinition {
|
||||||
@@ -153,6 +164,12 @@ export interface PatrolRouteDefinition {
|
|||||||
points: [number, number, number][];
|
points: [number, number, number][];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type FleetOrder =
|
||||||
|
| { kind: "idle" }
|
||||||
|
| { kind: "move"; destination: THREE.Vector3; systemId: string }
|
||||||
|
| { kind: "patrol"; points: THREE.Vector3[]; systemId: string; index: number }
|
||||||
|
| { kind: "mine"; nodeSystemId: string; refinerySystemId: string };
|
||||||
|
|
||||||
export interface ScenarioDefinition {
|
export interface ScenarioDefinition {
|
||||||
initialStations: InitialStationDefinition[];
|
initialStations: InitialStationDefinition[];
|
||||||
shipFormations: ShipFormationDefinition[];
|
shipFormations: ShipFormationDefinition[];
|
||||||
@@ -193,7 +210,8 @@ export type UnitOrder =
|
|||||||
}
|
}
|
||||||
| { kind: "mine"; nodeId: string; refineryId: string; phase: "to-node" | "mining" | "to-refinery" | "transfer" }
|
| { kind: "mine"; nodeId: string; refineryId: string; phase: "to-node" | "mining" | "to-refinery" | "transfer" }
|
||||||
| { kind: "patrol"; points: THREE.Vector3[]; systemId: string; index: number }
|
| { kind: "patrol"; points: THREE.Vector3[]; systemId: string; index: number }
|
||||||
| { kind: "escort"; targetShipId: string; offset: THREE.Vector3 };
|
| { kind: "escort"; targetShipId: string; offset: THREE.Vector3 }
|
||||||
|
| { kind: "dock"; carrierShipId: string };
|
||||||
|
|
||||||
export interface InventoryState {
|
export interface InventoryState {
|
||||||
"bulk-solid": number;
|
"bulk-solid": number;
|
||||||
@@ -210,6 +228,27 @@ export interface TravelPlan {
|
|||||||
arrivalPoint: THREE.Vector3;
|
arrivalPoint: THREE.Vector3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FleetWingInstance {
|
||||||
|
id: string;
|
||||||
|
fleetId: string;
|
||||||
|
label: string;
|
||||||
|
behavior: FleetBehavior;
|
||||||
|
parentWingId?: string;
|
||||||
|
leaderShipId: string;
|
||||||
|
shipIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FleetInstance {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
stance: FleetStance;
|
||||||
|
commanderShipId: string;
|
||||||
|
systemId: string;
|
||||||
|
shipIds: string[];
|
||||||
|
wings: FleetWingInstance[];
|
||||||
|
order: FleetOrder;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ShipInstance {
|
export interface ShipInstance {
|
||||||
id: string;
|
id: string;
|
||||||
definition: ShipDefinition;
|
definition: ShipDefinition;
|
||||||
@@ -226,6 +265,7 @@ export interface ShipInstance {
|
|||||||
actionTimer: number;
|
actionTimer: number;
|
||||||
travelPlan?: TravelPlan;
|
travelPlan?: TravelPlan;
|
||||||
dockedStationId?: string;
|
dockedStationId?: string;
|
||||||
|
dockedCarrierId?: string;
|
||||||
dockingPortIndex?: number;
|
dockingPortIndex?: number;
|
||||||
fuel: number;
|
fuel: number;
|
||||||
energy: number;
|
energy: number;
|
||||||
@@ -234,6 +274,14 @@ export interface ShipInstance {
|
|||||||
idleOrbitRadius: number;
|
idleOrbitRadius: number;
|
||||||
idleOrbitAngle: number;
|
idleOrbitAngle: number;
|
||||||
warpFx: THREE.Group;
|
warpFx: THREE.Group;
|
||||||
|
fleetId?: string;
|
||||||
|
wingId?: string;
|
||||||
|
behavior: FleetBehavior | "independent";
|
||||||
|
isFleetCommander: boolean;
|
||||||
|
isWingLeader: boolean;
|
||||||
|
formationOffset: THREE.Vector3;
|
||||||
|
dockedShipIds: Set<string>;
|
||||||
|
dockingPorts: THREE.Vector3[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StationInstance {
|
export interface StationInstance {
|
||||||
@@ -248,6 +296,7 @@ export interface StationInstance {
|
|||||||
activeBatch: number;
|
activeBatch: number;
|
||||||
activeRecipeId?: string;
|
activeRecipeId?: string;
|
||||||
inventory: InventoryState;
|
inventory: InventoryState;
|
||||||
|
itemStocks: Record<string, number>;
|
||||||
dockedShipIds: Set<string>;
|
dockedShipIds: Set<string>;
|
||||||
dockingPorts: THREE.Vector3[];
|
dockingPorts: THREE.Vector3[];
|
||||||
modules: string[];
|
modules: string[];
|
||||||
@@ -302,4 +351,8 @@ export interface HudElements {
|
|||||||
marquee: HTMLDivElement;
|
marquee: HTMLDivElement;
|
||||||
strategicOverlay: HTMLCanvasElement;
|
strategicOverlay: HTMLCanvasElement;
|
||||||
strategicOverlayContext: CanvasRenderingContext2D;
|
strategicOverlayContext: CanvasRenderingContext2D;
|
||||||
|
fleetWindow: HTMLDivElement;
|
||||||
|
fleetWindowBody: HTMLDivElement;
|
||||||
|
fleetWindowTitle: HTMLHeadingElement;
|
||||||
|
fleetWindowSubtitle: HTMLParagraphElement;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import type { HudElements } from "../types";
|
import type { HudElements } from "../types";
|
||||||
|
|
||||||
export function createHud(container: HTMLElement, onOrderAction: (action: string) => void): HudElements {
|
interface HudHandlers {
|
||||||
|
onOrderAction: (action: string) => void;
|
||||||
|
onWindowAction: (action: string) => void;
|
||||||
|
onFleetAction: (action: string, fleetId?: string) => void;
|
||||||
|
onSelectionAction: (kind: string, id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createHud(container: HTMLElement, handlers: HudHandlers): HudElements {
|
||||||
const root = document.createElement("div");
|
const root = document.createElement("div");
|
||||||
root.className = "hud";
|
root.className = "hud";
|
||||||
root.innerHTML = `
|
root.innerHTML = `
|
||||||
@@ -9,7 +16,7 @@ export function createHud(container: HTMLElement, onOrderAction: (action: string
|
|||||||
<h1>Helios Reach Command</h1>
|
<h1>Helios Reach Command</h1>
|
||||||
<p>
|
<p>
|
||||||
Dual-star-system prototype with gravity-well exits, FTL spooling, inter-system travel,
|
Dual-star-system prototype with gravity-well exits, FTL spooling, inter-system travel,
|
||||||
and unit orders for patrol, escort, mining, and manual fleet movement.
|
and layered fleet command with wing behaviors, escort screens, and logistics groups.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
<section class="panel details">
|
<section class="panel details">
|
||||||
@@ -23,25 +30,69 @@ export function createHud(container: HTMLElement, onOrderAction: (action: string
|
|||||||
</div>
|
</div>
|
||||||
<div class="orders-panel">
|
<div class="orders-panel">
|
||||||
<div class="mode"></div>
|
<div class="mode"></div>
|
||||||
|
<div class="window-launchers">
|
||||||
|
<button type="button" data-window-action="toggle-fleet-command">Fleets</button>
|
||||||
|
<button type="button" data-window-action="toggle-ship-designer" disabled>Designer</button>
|
||||||
|
<button type="button" data-window-action="toggle-station-manager" disabled>Stations</button>
|
||||||
|
</div>
|
||||||
<div class="orders">
|
<div class="orders">
|
||||||
<button type="button" data-action="move">Move</button>
|
<button type="button" data-action="move">Move</button>
|
||||||
<button type="button" data-action="mine">Mine</button>
|
<button type="button" data-action="mine">Mine</button>
|
||||||
<button type="button" data-action="patrol">Patrol</button>
|
<button type="button" data-action="patrol">Patrol</button>
|
||||||
<button type="button" data-action="escort">Escort</button>
|
<button type="button" data-action="escort">Escort</button>
|
||||||
|
<button type="button" data-action="dock">Dock</button>
|
||||||
<button type="button" data-action="focus">Focus</button>
|
<button type="button" data-action="focus">Focus</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="hint">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.</div>
|
<div class="hint">Left click selects ships or stations. Shift adds. Right click moves selection, or the active fleet when nothing is selected. G toggles fleet command. R recovers selected ships to the nearest friendly carrier. Mouse wheel or -/= zoom. B build. M miners mine. P patrol. E escort.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="minimap-panel">
|
<div class="minimap-panel">
|
||||||
<canvas class="minimap" width="220" height="160"></canvas>
|
<canvas class="minimap" width="220" height="160"></canvas>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
<section class="app-window fleet-window" data-window-id="fleet-command">
|
||||||
|
<div class="window-header">
|
||||||
|
<div>
|
||||||
|
<h2>Fleet Command</h2>
|
||||||
|
<p class="window-subtitle">No fleet selected</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="window-close" data-window-action="toggle-fleet-command">Close</button>
|
||||||
|
</div>
|
||||||
|
<div class="fleet-actions">
|
||||||
|
<button type="button" data-fleet-action="focus">Focus</button>
|
||||||
|
<button type="button" data-fleet-action="patrol">Patrol</button>
|
||||||
|
<button type="button" data-fleet-action="mine">Mine</button>
|
||||||
|
<button type="button" data-fleet-action="hold">Hold</button>
|
||||||
|
</div>
|
||||||
|
<div class="window-body fleet-window-body"></div>
|
||||||
|
<div class="window-resize-handle" aria-hidden="true"></div>
|
||||||
|
</section>
|
||||||
<div class="marquee"></div>
|
<div class="marquee"></div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
container.append(root);
|
container.append(root);
|
||||||
|
initializeWindowInteractions(root);
|
||||||
root.querySelectorAll<HTMLButtonElement>(".orders button").forEach((button) => {
|
root.querySelectorAll<HTMLButtonElement>(".orders button").forEach((button) => {
|
||||||
button.addEventListener("click", () => onOrderAction(button.dataset.action ?? ""));
|
button.addEventListener("click", () => handlers.onOrderAction(button.dataset.action ?? ""));
|
||||||
|
});
|
||||||
|
root.querySelectorAll<HTMLButtonElement>("[data-window-action]").forEach((button) => {
|
||||||
|
button.addEventListener("click", () => handlers.onWindowAction(button.dataset.windowAction ?? ""));
|
||||||
|
});
|
||||||
|
root.querySelectorAll<HTMLButtonElement>("[data-fleet-action]").forEach((button) => {
|
||||||
|
button.addEventListener("click", () => handlers.onFleetAction(button.dataset.fleetAction ?? ""));
|
||||||
|
});
|
||||||
|
|
||||||
|
const fleetWindowBody = root.querySelector<HTMLDivElement>(".fleet-window-body");
|
||||||
|
fleetWindowBody?.addEventListener("click", (event) => {
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
const selectionNode = target.closest<HTMLElement>("[data-select-kind][data-select-id]");
|
||||||
|
if (selectionNode) {
|
||||||
|
handlers.onSelectionAction(selectionNode.dataset.selectKind ?? "", selectionNode.dataset.selectId ?? "");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fleetButton = target.closest<HTMLButtonElement>("[data-fleet-id]");
|
||||||
|
if (fleetButton?.dataset.fleetId) {
|
||||||
|
handlers.onFleetAction("select", fleetButton.dataset.fleetId);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const minimap = root.querySelector<HTMLCanvasElement>(".minimap");
|
const minimap = root.querySelector<HTMLCanvasElement>(".minimap");
|
||||||
@@ -66,5 +117,93 @@ export function createHud(container: HTMLElement, onOrderAction: (action: string
|
|||||||
marquee: root.querySelector(".marquee") as HTMLDivElement,
|
marquee: root.querySelector(".marquee") as HTMLDivElement,
|
||||||
strategicOverlay,
|
strategicOverlay,
|
||||||
strategicOverlayContext,
|
strategicOverlayContext,
|
||||||
|
fleetWindow: root.querySelector(".fleet-window") as HTMLDivElement,
|
||||||
|
fleetWindowBody: fleetWindowBody as HTMLDivElement,
|
||||||
|
fleetWindowTitle: root.querySelector(".fleet-window h2") as HTMLHeadingElement,
|
||||||
|
fleetWindowSubtitle: root.querySelector(".window-subtitle") as HTMLParagraphElement,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function initializeWindowInteractions(root: HTMLDivElement) {
|
||||||
|
root.querySelectorAll<HTMLElement>(".app-window").forEach((windowEl) => {
|
||||||
|
initializeWindowPosition(windowEl);
|
||||||
|
|
||||||
|
const header = windowEl.querySelector<HTMLElement>(".window-header");
|
||||||
|
const resizeHandle = windowEl.querySelector<HTMLElement>(".window-resize-handle");
|
||||||
|
|
||||||
|
header?.addEventListener("pointerdown", (event) => {
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
if (target.closest("button")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = windowEl.getBoundingClientRect();
|
||||||
|
const offsetX = event.clientX - rect.left;
|
||||||
|
const offsetY = event.clientY - rect.top;
|
||||||
|
windowEl.dataset.dragging = "true";
|
||||||
|
|
||||||
|
const move = (moveEvent: PointerEvent) => {
|
||||||
|
const nextLeft = moveEvent.clientX - offsetX;
|
||||||
|
const nextTop = moveEvent.clientY - offsetY;
|
||||||
|
applyWindowRect(windowEl, nextLeft, nextTop, rect.width, rect.height);
|
||||||
|
};
|
||||||
|
|
||||||
|
const end = () => {
|
||||||
|
windowEl.dataset.dragging = "false";
|
||||||
|
window.removeEventListener("pointermove", move);
|
||||||
|
window.removeEventListener("pointerup", end);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("pointermove", move);
|
||||||
|
window.addEventListener("pointerup", end);
|
||||||
|
});
|
||||||
|
|
||||||
|
resizeHandle?.addEventListener("pointerdown", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
const rect = windowEl.getBoundingClientRect();
|
||||||
|
const startX = event.clientX;
|
||||||
|
const startY = event.clientY;
|
||||||
|
windowEl.dataset.resizing = "true";
|
||||||
|
|
||||||
|
const move = (moveEvent: PointerEvent) => {
|
||||||
|
const nextWidth = rect.width + (moveEvent.clientX - startX);
|
||||||
|
const nextHeight = rect.height + (moveEvent.clientY - startY);
|
||||||
|
applyWindowRect(windowEl, rect.left, rect.top, nextWidth, nextHeight);
|
||||||
|
};
|
||||||
|
|
||||||
|
const end = () => {
|
||||||
|
windowEl.dataset.resizing = "false";
|
||||||
|
window.removeEventListener("pointermove", move);
|
||||||
|
window.removeEventListener("pointerup", end);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("pointermove", move);
|
||||||
|
window.addEventListener("pointerup", end);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function initializeWindowPosition(windowEl: HTMLElement) {
|
||||||
|
const defaultWidth = Math.min(480, window.innerWidth - 48);
|
||||||
|
const defaultHeight = Math.min(680, Math.floor(window.innerHeight * 0.68));
|
||||||
|
const left = Math.max(16, Math.round(window.innerWidth * 0.5 - defaultWidth * 0.5));
|
||||||
|
const top = Math.max(24, 104);
|
||||||
|
applyWindowRect(windowEl, left, top, defaultWidth, defaultHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyWindowRect(windowEl: HTMLElement, left: number, top: number, width: number, height: number) {
|
||||||
|
const minWidth = 340;
|
||||||
|
const minHeight = 240;
|
||||||
|
const maxWidth = window.innerWidth - 32;
|
||||||
|
const maxHeight = window.innerHeight - 32;
|
||||||
|
const clampedWidth = Math.max(minWidth, Math.min(width, maxWidth));
|
||||||
|
const clampedHeight = Math.max(minHeight, Math.min(height, maxHeight));
|
||||||
|
const clampedLeft = Math.max(16, Math.min(left, window.innerWidth - clampedWidth - 16));
|
||||||
|
const clampedTop = Math.max(16, Math.min(top, window.innerHeight - clampedHeight - 16));
|
||||||
|
|
||||||
|
windowEl.style.left = `${clampedLeft}px`;
|
||||||
|
windowEl.style.top = `${clampedTop}px`;
|
||||||
|
windowEl.style.width = `${clampedWidth}px`;
|
||||||
|
windowEl.style.height = `${clampedHeight}px`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ import {
|
|||||||
moduleDefinitionsById,
|
moduleDefinitionsById,
|
||||||
recipeDefinitions,
|
recipeDefinitions,
|
||||||
} from "../data/catalog";
|
} from "../data/catalog";
|
||||||
|
import { describeFleetOrder } from "../fleet/runtime";
|
||||||
import { getShipCargoAmount } from "../state/inventory";
|
import { getShipCargoAmount } from "../state/inventory";
|
||||||
import type {
|
import type {
|
||||||
|
FleetInstance,
|
||||||
ShipInstance,
|
ShipInstance,
|
||||||
SolarSystemInstance,
|
SolarSystemInstance,
|
||||||
StationInstance,
|
StationInstance,
|
||||||
@@ -30,45 +32,167 @@ export function getSelectionDetails(
|
|||||||
systems: SolarSystemInstance[],
|
systems: SolarSystemInstance[],
|
||||||
viewLevel: ViewLevel,
|
viewLevel: ViewLevel,
|
||||||
ships: ShipInstance[],
|
ships: ShipInstance[],
|
||||||
|
fleets: FleetInstance[],
|
||||||
) {
|
) {
|
||||||
if (selectedStation) {
|
if (selectedStation) {
|
||||||
return describeStation(selectedStation, ships);
|
return describeStation(selectedStation, ships, fleets);
|
||||||
}
|
}
|
||||||
if (selection.length === 0) {
|
if (selection.length === 0) {
|
||||||
return `Systems online: ${systems.map((system) => system.definition.label).join(", ")}\n\nOrders: Move, Patrol, Escort, Mine\nView: ${viewLevel}`;
|
return `Systems online: ${systems.map((system) => system.definition.label).join(", ")}\nFleets active: ${fleets.length}\n\nOrders: Move, Patrol, Escort, Mine\nView: ${viewLevel}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return selection
|
return selection
|
||||||
.map(
|
.map(
|
||||||
(ship) =>
|
(ship) => {
|
||||||
`${ship.definition.label} • ${ship.systemId}\nState: ${ship.state}${ship.dockedStationId ? ` @ ${ship.dockedStationId}` : ""}\nOrder: ${ship.order.kind}\nCargo: ${Math.round(getShipCargoAmount(ship))}/${ship.definition.cargoCapacity || 0} ${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(getModuleLabel).join(", ")}`,
|
const dockedAt = ship.dockedCarrierId ?? ship.dockedStationId;
|
||||||
|
const hangarStatus =
|
||||||
|
ship.definition.dockingCapacity && ship.definition.dockingCapacity > 0
|
||||||
|
? `\nHangar: ${ship.dockedShipIds.size}/${ship.definition.dockingCapacity} for ${(ship.definition.dockingClasses ?? []).join(", ")}`
|
||||||
|
: "";
|
||||||
|
return `${ship.definition.label} • ${ship.systemId}\nClass: ${ship.definition.shipClass}\nState: ${ship.state}${dockedAt ? ` @ ${dockedAt}` : ""}\nOrder: ${ship.order.kind}\nFleet: ${ship.fleetId ?? "Independent"}${ship.isFleetCommander ? " • Commander" : ship.isWingLeader ? " • Wing Leader" : ""}\nBehavior: ${ship.behavior}\nCargo: ${Math.round(getShipCargoAmount(ship))}/${ship.definition.cargoCapacity || 0} ${getItemLabel(ship.cargoItemId)}\nFuel: ${ship.fuel.toFixed(0)}/${ship.maxFuel}\nEnergy: ${ship.energy.toFixed(0)}/${ship.maxEnergy}\nHold Type: ${ship.definition.cargoKind ?? "none"}${hangarStatus}\nModules: ${ship.definition.modules.map(getModuleLabel).join(", ")}`;
|
||||||
|
},
|
||||||
)
|
)
|
||||||
.join("\n\n");
|
.join("\n\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function describeStation(station: StationInstance, ships: ShipInstance[]) {
|
export function describeStation(station: StationInstance, ships: ShipInstance[], fleets: FleetInstance[]) {
|
||||||
const miners = ships.filter((ship) => ship.systemId === station.systemId && ship.order.kind === "mine").length;
|
const miners = ships.filter((ship) => ship.systemId === station.systemId && ship.order.kind === "mine").length;
|
||||||
const escorts = ships.filter((ship) => ship.systemId === station.systemId && ship.order.kind === "escort").length;
|
const escorts = ships.filter((ship) => ship.systemId === station.systemId && ship.order.kind === "escort").length;
|
||||||
const patrols = ships.filter((ship) => ship.systemId === station.systemId && ship.order.kind === "patrol").length;
|
const patrols = ships.filter((ship) => ship.systemId === station.systemId && ship.order.kind === "patrol").length;
|
||||||
|
const localFleets = fleets.filter((fleet) => fleet.systemId === station.systemId).length;
|
||||||
const activeRecipe = station.activeRecipeId
|
const activeRecipe = station.activeRecipeId
|
||||||
? recipeDefinitions.find((recipe) => recipe.id === station.activeRecipeId)
|
? recipeDefinitions.find((recipe) => recipe.id === station.activeRecipeId)
|
||||||
: undefined;
|
: undefined;
|
||||||
const refineryStatus =
|
const stockSummary = Object.entries(station.itemStocks)
|
||||||
station.definition.category === "refining"
|
.filter(([, amount]) => amount > 0)
|
||||||
? `Ore: ${Math.round(station.oreStored)}\nRefined: ${Math.round(station.refinedStock)}\nBatch: ${Math.round(station.activeBatch)}\nRecipe: ${activeRecipe?.label ?? "Idle"}\nTime Remaining: ${station.activeBatch > 0 ? `${station.processTimer.toFixed(1)}s` : "Idle"}\n`
|
.sort((left, right) => right[1] - left[1])
|
||||||
|
.slice(0, 5)
|
||||||
|
.map(([itemId, amount]) => `${getItemLabel(itemId)} ${Math.round(amount)}`)
|
||||||
|
.join(", ");
|
||||||
|
const productionStatus =
|
||||||
|
station.modules.includes("fabricator-array") || station.definition.category === "refining"
|
||||||
|
? `Ore: ${Math.round(station.oreStored)}\nRefined Metals: ${Math.round(station.refinedStock)}\nBatch: ${Math.round(station.activeBatch)}\nRecipe: ${activeRecipe?.label ?? "Idle"}\nTime Remaining: ${station.activeBatch > 0 ? `${station.processTimer.toFixed(1)}s` : "Idle"}\nStocks: ${stockSummary || "None"}\n`
|
||||||
: "";
|
: "";
|
||||||
const activity =
|
const activity =
|
||||||
station.definition.category === "refining"
|
station.definition.category === "refining"
|
||||||
? `Refining ore for ${miners} mining ships`
|
? `Refining and fabricating for ${miners} mining ships`
|
||||||
: station.definition.category === "shipyard"
|
: station.definition.category === "shipyard"
|
||||||
? `Maintaining ${patrols} patrol craft`
|
? `Building ship parts for ${patrols} patrol craft`
|
||||||
: station.definition.category === "farm"
|
: station.definition.category === "farm"
|
||||||
? "Supplying agricultural goods"
|
? "Supplying agricultural goods and industrial consumables"
|
||||||
: station.definition.category === "defense"
|
: station.definition.category === "defense"
|
||||||
? `Coordinating ${escorts} escort wings`
|
? `Coordinating ${escorts} escort wings`
|
||||||
|
: station.definition.category === "gate"
|
||||||
|
? "Assembling transit infrastructure and gate components"
|
||||||
|
: station.modules.includes("fabricator-array")
|
||||||
|
? "Fabricating industrial parts and equipment"
|
||||||
: "Managing local trade traffic";
|
: "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(getModuleLabel).join(", ")}\n${refineryStatus}Radius: ${station.definition.radius}`;
|
return `${station.definition.label} • ${station.systemId}\nRole: ${station.definition.category}\nActivity: ${activity}\nLocal Fleets: ${localFleets}\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(getModuleLabel).join(", ")}\n${productionStatus}Radius: ${station.definition.radius}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFleetWindowMarkup(
|
||||||
|
fleets: FleetInstance[],
|
||||||
|
shipsById: Map<string, ShipInstance>,
|
||||||
|
activeFleetId: string | undefined,
|
||||||
|
selection: ShipInstance[],
|
||||||
|
) {
|
||||||
|
if (fleets.length === 0) {
|
||||||
|
return `<div class="fleet-card"><span class="fleet-card-line">No fleets initialized.</span></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedShipIds = new Set(selection.map((ship) => ship.id));
|
||||||
|
|
||||||
|
return fleets
|
||||||
|
.map((fleet) => {
|
||||||
|
const commander = shipsById.get(fleet.commanderShipId);
|
||||||
|
const rootWings = fleet.wings.filter((wing) => !wing.parentWingId);
|
||||||
|
const tree = rootWings.map((wing) => renderWingNode(fleet, wing.id, shipsById, selectedShipIds)).join("");
|
||||||
|
const fleetSelected = fleet.shipIds.length > 0 && fleet.shipIds.every((shipId) => selectedShipIds.has(shipId));
|
||||||
|
|
||||||
|
return `
|
||||||
|
<article class="fleet-card" data-active="${fleet.id === activeFleetId}">
|
||||||
|
<button type="button" class="fleet-select" data-select-kind="fleet" data-select-id="${fleet.id}">${fleet.label}</button>
|
||||||
|
<div class="fleet-tree">
|
||||||
|
<div class="fleet-tree-root" data-select-kind="fleet" data-select-id="${fleet.id}" data-selected="${fleetSelected}">
|
||||||
|
<span class="fleet-card-title">${commander?.definition.label ?? fleet.commanderShipId}</span>
|
||||||
|
<span class="fleet-card-line">Commander • ${fleet.shipIds.length} ships • ${fleet.systemId}</span>
|
||||||
|
<span class="fleet-card-line">Fleet Order: ${describeFleetOrder(fleet)}</span>
|
||||||
|
<span class="fleet-card-line">Stance: ${fleet.stance}</span>
|
||||||
|
</div>
|
||||||
|
<div class="fleet-tree-children">${tree}</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWingNode(
|
||||||
|
fleet: FleetInstance,
|
||||||
|
wingId: string,
|
||||||
|
shipsById: Map<string, ShipInstance>,
|
||||||
|
selectedShipIds: Set<string>,
|
||||||
|
): string {
|
||||||
|
const wing = fleet.wings.find((candidate) => candidate.id === wingId);
|
||||||
|
if (!wing) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const leader = shipsById.get(wing.leaderShipId);
|
||||||
|
const childWings = fleet.wings.filter((candidate) => candidate.parentWingId === wing.id);
|
||||||
|
const wingTreeShipIds = collectWingShipIds(fleet, wing.id);
|
||||||
|
const wingSelected = wingTreeShipIds.length > 0 && wingTreeShipIds.every((shipId) => selectedShipIds.has(shipId));
|
||||||
|
const nonLeaderShips = wing.shipIds
|
||||||
|
.filter((shipId) => shipId !== wing.leaderShipId)
|
||||||
|
.map((shipId) => shipsById.get(shipId))
|
||||||
|
.filter((ship): ship is ShipInstance => Boolean(ship));
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="fleet-tree-node wing-node">
|
||||||
|
<div class="fleet-node-card" data-select-kind="wing" data-select-id="${wing.id}" data-selected="${wingSelected}">
|
||||||
|
<span class="fleet-node-title">${wing.label}</span>
|
||||||
|
<span class="fleet-node-meta">${wing.behavior} • ${wing.shipIds.length} ships</span>
|
||||||
|
<span class="fleet-node-meta">Wing Lead: ${leader ? describeShipNode(leader) : wing.leaderShipId}</span>
|
||||||
|
</div>
|
||||||
|
<div class="fleet-tree-children">
|
||||||
|
${childWings.map((childWing) => renderWingNode(fleet, childWing.id, shipsById, selectedShipIds)).join("")}
|
||||||
|
${nonLeaderShips
|
||||||
|
.map(
|
||||||
|
(ship) => `
|
||||||
|
<div class="fleet-tree-node ship-node">
|
||||||
|
<div class="fleet-node-card" data-select-kind="ship" data-select-id="${ship.id}" data-selected="${selectedShipIds.has(ship.id)}">
|
||||||
|
<span class="fleet-node-title">${ship.definition.label}</span>
|
||||||
|
<span class="fleet-node-meta">${describeShipNode(ship)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.join("")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeShipNode(ship: ShipInstance): string {
|
||||||
|
return `${ship.definition.shipClass} • ${ship.state} • ${ship.order.kind} • ${ship.behavior}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectWingShipIds(fleet: FleetInstance, rootWingId: string): string[] {
|
||||||
|
const wingIds = new Set<string>([rootWingId]);
|
||||||
|
let changed = true;
|
||||||
|
|
||||||
|
while (changed) {
|
||||||
|
changed = false;
|
||||||
|
fleet.wings.forEach((wing) => {
|
||||||
|
if (wing.parentWingId && wingIds.has(wing.parentWingId) && !wingIds.has(wing.id)) {
|
||||||
|
wingIds.add(wing.id);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return fleet.wings.filter((wing) => wingIds.has(wing.id)).flatMap((wing) => wing.shipIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getItemLabel(itemId?: string) {
|
export function getItemLabel(itemId?: string) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
import type { ShipRole, ShipInstance, SolarSystemInstance, StationInstance, ViewLevel } from "../types";
|
import { getFleetCommander, getWingLeader } from "../fleet/runtime";
|
||||||
|
import type { FleetInstance, ShipRole, ShipInstance, SolarSystemInstance, StationInstance, ViewLevel } from "../types";
|
||||||
|
|
||||||
interface RenderMinimapOptions {
|
interface RenderMinimapOptions {
|
||||||
context: CanvasRenderingContext2D;
|
context: CanvasRenderingContext2D;
|
||||||
@@ -11,6 +12,8 @@ interface RenderMinimapOptions {
|
|||||||
selection: ShipInstance[];
|
selection: ShipInstance[];
|
||||||
selectedStation?: StationInstance;
|
selectedStation?: StationInstance;
|
||||||
cameraFocus: THREE.Vector3;
|
cameraFocus: THREE.Vector3;
|
||||||
|
fleets: FleetInstance[];
|
||||||
|
activeFleetId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RenderOverlayOptions {
|
interface RenderOverlayOptions {
|
||||||
@@ -25,6 +28,8 @@ interface RenderOverlayOptions {
|
|||||||
selectedStation?: StationInstance;
|
selectedStation?: StationInstance;
|
||||||
selectedSystemIndex: number;
|
selectedSystemIndex: number;
|
||||||
viewLevel: ViewLevel;
|
viewLevel: ViewLevel;
|
||||||
|
fleets: FleetInstance[];
|
||||||
|
activeFleetId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function drawMinimap({
|
export function drawMinimap({
|
||||||
@@ -37,6 +42,8 @@ export function drawMinimap({
|
|||||||
selection,
|
selection,
|
||||||
selectedStation,
|
selectedStation,
|
||||||
cameraFocus,
|
cameraFocus,
|
||||||
|
fleets,
|
||||||
|
activeFleetId,
|
||||||
}: RenderMinimapOptions) {
|
}: RenderMinimapOptions) {
|
||||||
context.clearRect(0, 0, width, height);
|
context.clearRect(0, 0, width, height);
|
||||||
context.fillStyle = "rgba(4, 9, 20, 0.92)";
|
context.fillStyle = "rgba(4, 9, 20, 0.92)";
|
||||||
@@ -82,6 +89,16 @@ export function drawMinimap({
|
|||||||
context.fill();
|
context.fill();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
fleets.forEach((fleet) => {
|
||||||
|
const commander = getFleetCommander(fleet, new Map(ships.map((ship) => [ship.id, ship])));
|
||||||
|
if (!commander) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const point = mapPoint(commander.group.position);
|
||||||
|
context.strokeStyle = fleet.id === activeFleetId ? "#ffbf69" : "rgba(126, 212, 255, 0.42)";
|
||||||
|
context.strokeRect(point.x - 6, point.y - 6, 12, 12);
|
||||||
|
});
|
||||||
|
|
||||||
const focus = mapPoint(cameraFocus);
|
const focus = mapPoint(cameraFocus);
|
||||||
context.strokeStyle = "rgba(255,255,255,0.7)";
|
context.strokeStyle = "rgba(255,255,255,0.7)";
|
||||||
context.strokeRect(focus.x - 9, focus.y - 9, 18, 18);
|
context.strokeRect(focus.x - 9, focus.y - 9, 18, 18);
|
||||||
@@ -99,6 +116,8 @@ export function drawStrategicOverlay({
|
|||||||
selectedStation,
|
selectedStation,
|
||||||
selectedSystemIndex,
|
selectedSystemIndex,
|
||||||
viewLevel,
|
viewLevel,
|
||||||
|
fleets,
|
||||||
|
activeFleetId,
|
||||||
}: RenderOverlayOptions) {
|
}: RenderOverlayOptions) {
|
||||||
context.clearRect(0, 0, width, height);
|
context.clearRect(0, 0, width, height);
|
||||||
if (viewLevel === "local") {
|
if (viewLevel === "local") {
|
||||||
@@ -130,6 +149,8 @@ export function drawStrategicOverlay({
|
|||||||
drawShipSymbol(context, screen.x, screen.y, ship, 10, selection.includes(ship));
|
drawShipSymbol(context, screen.x, screen.y, ship, 10, selection.includes(ship));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
drawFleetLinks(context, camera, fleets, ships, systems[selectedSystemIndex]?.definition.id, activeFleetId);
|
||||||
} else {
|
} else {
|
||||||
systems.forEach((system) => {
|
systems.forEach((system) => {
|
||||||
const screen = projectWorldToScreen(system.center, camera);
|
const screen = projectWorldToScreen(system.center, camera);
|
||||||
@@ -139,19 +160,19 @@ export function drawStrategicOverlay({
|
|||||||
|
|
||||||
drawSystemFrame(context, screen.x, screen.y, system.definition.label);
|
drawSystemFrame(context, screen.x, screen.y, system.definition.label);
|
||||||
|
|
||||||
const fleets = new Map<ShipRole, ShipInstance[]>();
|
const roleBuckets = new Map<ShipRole, ShipInstance[]>();
|
||||||
ships.forEach((ship) => {
|
ships.forEach((ship) => {
|
||||||
if (ship.systemId !== system.definition.id) {
|
if (ship.systemId !== system.definition.id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const bucket = fleets.get(ship.definition.role) ?? [];
|
const bucket = roleBuckets.get(ship.definition.role) ?? [];
|
||||||
bucket.push(ship);
|
bucket.push(ship);
|
||||||
fleets.set(ship.definition.role, bucket);
|
roleBuckets.set(ship.definition.role, bucket);
|
||||||
});
|
});
|
||||||
|
|
||||||
const roleOrder: ShipRole[] = ["military", "transport", "mining"];
|
const roleOrder: ShipRole[] = ["military", "transport", "mining"];
|
||||||
roleOrder.forEach((role, index) => {
|
roleOrder.forEach((role, index) => {
|
||||||
const bucket = fleets.get(role);
|
const bucket = roleBuckets.get(role);
|
||||||
if (!bucket || bucket.length === 0) {
|
if (!bucket || bucket.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -165,6 +186,13 @@ export function drawStrategicOverlay({
|
|||||||
if (stationCount > 0) {
|
if (stationCount > 0) {
|
||||||
drawStrategicStationGroup(context, screen.x, screen.y - 38, stationCount, stationSelected);
|
drawStrategicStationGroup(context, screen.x, screen.y - 38, stationCount, stationSelected);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const activeFleets = fleets.filter((fleet) => fleet.systemId === system.definition.id).length;
|
||||||
|
if (activeFleets > 0) {
|
||||||
|
context.fillStyle = "rgba(255, 191, 105, 0.92)";
|
||||||
|
context.font = "600 10px Space Grotesk, sans-serif";
|
||||||
|
context.fillText(`${activeFleets} FLEET${activeFleets > 1 ? "S" : ""}`, screen.x, screen.y + 58);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,6 +288,54 @@ function drawFleetSymbol(
|
|||||||
context.restore();
|
context.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function drawFleetLinks(
|
||||||
|
context: CanvasRenderingContext2D,
|
||||||
|
camera: THREE.PerspectiveCamera,
|
||||||
|
fleets: FleetInstance[],
|
||||||
|
ships: ShipInstance[],
|
||||||
|
systemId: string | undefined,
|
||||||
|
activeFleetId: string | undefined,
|
||||||
|
) {
|
||||||
|
const shipsById = new Map(ships.map((ship) => [ship.id, ship]));
|
||||||
|
|
||||||
|
fleets
|
||||||
|
.filter((fleet) => !systemId || fleet.systemId === systemId)
|
||||||
|
.forEach((fleet) => {
|
||||||
|
const commander = getFleetCommander(fleet, shipsById);
|
||||||
|
if (!commander) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const commanderScreen = projectWorldToScreen(commander.group.position, camera);
|
||||||
|
if (!commanderScreen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const highlighted = fleet.id === activeFleetId;
|
||||||
|
context.strokeStyle = highlighted ? "rgba(255, 191, 105, 0.85)" : "rgba(126, 212, 255, 0.24)";
|
||||||
|
context.fillStyle = highlighted ? "#ffbf69" : "rgba(126, 212, 255, 0.72)";
|
||||||
|
context.lineWidth = highlighted ? 1.8 : 1.1;
|
||||||
|
|
||||||
|
fleet.wings.forEach((wing) => {
|
||||||
|
const leader = getWingLeader(wing, shipsById);
|
||||||
|
if (!leader || leader.id === commander.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const leaderScreen = projectWorldToScreen(leader.group.position, camera);
|
||||||
|
if (!leaderScreen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(commanderScreen.x, commanderScreen.y);
|
||||||
|
context.lineTo(leaderScreen.x, leaderScreen.y);
|
||||||
|
context.stroke();
|
||||||
|
context.beginPath();
|
||||||
|
context.arc(leaderScreen.x, leaderScreen.y, highlighted ? 4.5 : 3, 0, Math.PI * 2);
|
||||||
|
context.fill();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function drawStrategicStationGroup(
|
function drawStrategicStationGroup(
|
||||||
context: CanvasRenderingContext2D,
|
context: CanvasRenderingContext2D,
|
||||||
x: number,
|
x: number,
|
||||||
@@ -410,6 +486,14 @@ function drawStationSymbol(
|
|||||||
context.beginPath();
|
context.beginPath();
|
||||||
context.arc(0, 0, 5, 0, Math.PI * 2);
|
context.arc(0, 0, 5, 0, Math.PI * 2);
|
||||||
context.stroke();
|
context.stroke();
|
||||||
|
} else if (station.definition.category === "gate") {
|
||||||
|
context.beginPath();
|
||||||
|
context.arc(0, 0, 6, 0, Math.PI * 2);
|
||||||
|
context.stroke();
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(-6, 0);
|
||||||
|
context.lineTo(6, 0);
|
||||||
|
context.stroke();
|
||||||
}
|
}
|
||||||
|
|
||||||
context.restore();
|
context.restore();
|
||||||
@@ -438,5 +522,8 @@ function getStationSymbolColor(station: StationInstance) {
|
|||||||
if (station.definition.category === "shipyard") {
|
if (station.definition.category === "shipyard") {
|
||||||
return "rgba(208, 162, 255, 0.95)";
|
return "rgba(208, 162, 255, 0.95)";
|
||||||
}
|
}
|
||||||
|
if (station.definition.category === "gate") {
|
||||||
|
return "rgba(118, 240, 255, 0.95)";
|
||||||
|
}
|
||||||
return "rgba(180, 201, 218, 0.95)";
|
return "rgba(180, 201, 218, 0.95)";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -432,6 +432,7 @@ export function createStationInstance({
|
|||||||
processTimer: 0,
|
processTimer: 0,
|
||||||
activeBatch: 0,
|
activeBatch: 0,
|
||||||
inventory: createEmptyInventory(),
|
inventory: createEmptyInventory(),
|
||||||
|
itemStocks: {},
|
||||||
dockedShipIds: new Set(),
|
dockedShipIds: new Set(),
|
||||||
dockingPorts,
|
dockingPorts,
|
||||||
modules: definition.modules,
|
modules: definition.modules,
|
||||||
@@ -462,6 +463,7 @@ function createShip({
|
|||||||
const visual = new THREE.Group();
|
const visual = new THREE.Group();
|
||||||
visual.rotation.y = Math.PI / 2;
|
visual.rotation.y = Math.PI / 2;
|
||||||
group.add(visual);
|
group.add(visual);
|
||||||
|
const dockingCapacity = definition.dockingCapacity ?? 0;
|
||||||
|
|
||||||
const warpFx = new THREE.Group();
|
const warpFx = new THREE.Group();
|
||||||
warpFx.visible = false;
|
warpFx.visible = false;
|
||||||
@@ -511,6 +513,30 @@ function createShip({
|
|||||||
visual.add(wing);
|
visual.add(wing);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (dockingCapacity > 0) {
|
||||||
|
const hangarBody = new THREE.Mesh(
|
||||||
|
new THREE.BoxGeometry(definition.size * 2.8, definition.size * 1.2, definition.size * 1.5),
|
||||||
|
new THREE.MeshStandardMaterial({
|
||||||
|
color: 0x4c6272,
|
||||||
|
emissive: new THREE.Color(definition.color).multiplyScalar(0.04),
|
||||||
|
roughness: 0.5,
|
||||||
|
metalness: 0.75,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
hangarBody.position.x = -definition.size * 0.5;
|
||||||
|
hangarBody.castShadow = true;
|
||||||
|
visual.add(hangarBody);
|
||||||
|
|
||||||
|
[-1, 1].forEach((side) => {
|
||||||
|
const bay = new THREE.Mesh(
|
||||||
|
new THREE.BoxGeometry(definition.size * 1.1, definition.size * 0.38, definition.size * 0.86),
|
||||||
|
new THREE.MeshBasicMaterial({ color: definition.color, transparent: true, opacity: 0.45 }),
|
||||||
|
);
|
||||||
|
bay.position.set(-definition.size * 0.3, side * definition.size * 0.52, 0);
|
||||||
|
visual.add(bay);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const engineGlow = new THREE.Mesh(
|
const engineGlow = new THREE.Mesh(
|
||||||
new THREE.SphereGeometry(definition.size * 0.35, 14, 14),
|
new THREE.SphereGeometry(definition.size * 0.35, 14, 14),
|
||||||
new THREE.MeshBasicMaterial({ color: definition.color, transparent: true, opacity: 0.72 }),
|
new THREE.MeshBasicMaterial({ color: definition.color, transparent: true, opacity: 0.72 }),
|
||||||
@@ -531,6 +557,24 @@ function createShip({
|
|||||||
ring.position.y = -definition.size * 0.55;
|
ring.position.y = -definition.size * 0.55;
|
||||||
group.add(ring);
|
group.add(ring);
|
||||||
|
|
||||||
|
const dockingPorts = Array.from({ length: dockingCapacity }, (_, index) => {
|
||||||
|
const lane = index % 2 === 0 ? -1 : 1;
|
||||||
|
const row = Math.floor(index / 2);
|
||||||
|
const port = new THREE.Vector3(
|
||||||
|
-definition.size * (0.4 + row * 0.7),
|
||||||
|
0,
|
||||||
|
lane * definition.size * 1.35,
|
||||||
|
);
|
||||||
|
const beacon = new THREE.Mesh(
|
||||||
|
new THREE.BoxGeometry(definition.size * 0.26, 0.9, definition.size * 0.42),
|
||||||
|
new THREE.MeshBasicMaterial({ color: definition.color, transparent: true, opacity: 0.52 }),
|
||||||
|
);
|
||||||
|
beacon.position.copy(port);
|
||||||
|
beacon.visible = dockingCapacity > 0;
|
||||||
|
group.add(beacon);
|
||||||
|
return port;
|
||||||
|
});
|
||||||
|
|
||||||
const pickHull = new THREE.Mesh(
|
const pickHull = new THREE.Mesh(
|
||||||
new THREE.SphereGeometry(definition.size * 1.6, 12, 12),
|
new THREE.SphereGeometry(definition.size * 1.6, 12, 12),
|
||||||
new THREE.MeshBasicMaterial({ visible: false }),
|
new THREE.MeshBasicMaterial({ visible: false }),
|
||||||
@@ -558,6 +602,12 @@ function createShip({
|
|||||||
idleOrbitRadius: Math.max(120, group.position.length()),
|
idleOrbitRadius: Math.max(120, group.position.length()),
|
||||||
idleOrbitAngle: 0,
|
idleOrbitAngle: 0,
|
||||||
warpFx,
|
warpFx,
|
||||||
|
behavior: "independent",
|
||||||
|
isFleetCommander: false,
|
||||||
|
isWingLeader: false,
|
||||||
|
formationOffset: new THREE.Vector3(),
|
||||||
|
dockedShipIds: new Set(),
|
||||||
|
dockingPorts,
|
||||||
};
|
};
|
||||||
|
|
||||||
selectableTargets.set(pickHull, { kind: "ship", ship });
|
selectableTargets.set(pickHull, { kind: "ship", ship });
|
||||||
|
|||||||
273
src/style.css
273
src/style.css
@@ -163,6 +163,13 @@ canvas {
|
|||||||
gap: 14px;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.window-launchers,
|
||||||
|
.fleet-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.orders-panel .mode {
|
.orders-panel .mode {
|
||||||
color: var(--warning);
|
color: var(--warning);
|
||||||
text-shadow: 0 0 18px rgba(255, 191, 105, 0.24);
|
text-shadow: 0 0 18px rgba(255, 191, 105, 0.24);
|
||||||
@@ -170,11 +177,14 @@ canvas {
|
|||||||
|
|
||||||
.orders {
|
.orders {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.orders button {
|
.window-launchers button,
|
||||||
|
.orders button,
|
||||||
|
.fleet-actions button,
|
||||||
|
.window-close {
|
||||||
border: 1px solid rgba(126, 212, 255, 0.16);
|
border: 1px solid rgba(126, 212, 255, 0.16);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: linear-gradient(180deg, rgba(13, 30, 56, 0.95), rgba(8, 17, 33, 0.95));
|
background: linear-gradient(180deg, rgba(13, 30, 56, 0.95), rgba(8, 17, 33, 0.95));
|
||||||
@@ -184,11 +194,14 @@ canvas {
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: border-color 120ms ease, transform 120ms ease, background 120ms ease;
|
transition: border-color 120ms ease, transform 120ms ease, background 120ms ease, opacity 120ms ease;
|
||||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.02);
|
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.02);
|
||||||
}
|
}
|
||||||
|
|
||||||
.orders button:hover {
|
.orders button:hover,
|
||||||
|
.window-launchers button:hover,
|
||||||
|
.fleet-actions button:hover,
|
||||||
|
.window-close:hover {
|
||||||
border-color: rgba(126, 212, 255, 0.4);
|
border-color: rgba(126, 212, 255, 0.4);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
@@ -197,6 +210,12 @@ canvas {
|
|||||||
opacity: 0.45;
|
opacity: 0.45;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: default;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
.orders-panel .hint {
|
.orders-panel .hint {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
@@ -216,6 +235,242 @@ canvas {
|
|||||||
background: rgba(2, 6, 13, 0.92);
|
background: rgba(2, 6, 13, 0.92);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-window {
|
||||||
|
position: absolute;
|
||||||
|
top: 104px;
|
||||||
|
left: 50%;
|
||||||
|
width: min(480px, calc(100vw - 48px));
|
||||||
|
height: min(68vh, 680px);
|
||||||
|
display: none;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 18px;
|
||||||
|
pointer-events: auto;
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(6, 13, 27, 0.94), rgba(4, 10, 21, 0.92)),
|
||||||
|
radial-gradient(circle at top, rgba(126, 212, 255, 0.08), transparent 60%);
|
||||||
|
border: 1px solid rgba(126, 212, 255, 0.2);
|
||||||
|
border-radius: 18px;
|
||||||
|
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.44);
|
||||||
|
z-index: 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-window[data-open="true"] {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
cursor: move;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-header h2,
|
||||||
|
.window-header p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-header h2 {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-subtitle {
|
||||||
|
margin-top: 6px;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-close {
|
||||||
|
padding-inline: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fleet-actions {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-body {
|
||||||
|
overflow: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
padding-right: 4px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-resize-handle {
|
||||||
|
position: absolute;
|
||||||
|
right: 10px;
|
||||||
|
bottom: 10px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-right: 2px solid rgba(126, 212, 255, 0.42);
|
||||||
|
border-bottom: 2px solid rgba(126, 212, 255, 0.42);
|
||||||
|
border-radius: 0 0 8px 0;
|
||||||
|
cursor: nwse-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-resize-handle::before,
|
||||||
|
.window-resize-handle::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
right: 3px;
|
||||||
|
bottom: 3px;
|
||||||
|
border-right: 1px solid rgba(126, 212, 255, 0.24);
|
||||||
|
border-bottom: 1px solid rgba(126, 212, 255, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-resize-handle::before {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-resize-handle::after {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fleet-card {
|
||||||
|
border: 1px solid rgba(126, 212, 255, 0.14);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 14px;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(8, 18, 35, 0.84), rgba(5, 11, 22, 0.8)),
|
||||||
|
repeating-linear-gradient(
|
||||||
|
90deg,
|
||||||
|
rgba(126, 212, 255, 0.02) 0,
|
||||||
|
rgba(126, 212, 255, 0.02) 1px,
|
||||||
|
transparent 1px,
|
||||||
|
transparent 14px
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fleet-card[data-active="true"] {
|
||||||
|
border-color: rgba(255, 191, 105, 0.44);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(255, 191, 105, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fleet-select {
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fleet-card-title,
|
||||||
|
.fleet-card-line,
|
||||||
|
.fleet-wing-line {
|
||||||
|
display: block;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fleet-card-title {
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fleet-wing-line {
|
||||||
|
color: #bdd9ea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fleet-tree,
|
||||||
|
.fleet-tree-children {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fleet-tree-root,
|
||||||
|
.fleet-node-card {
|
||||||
|
position: relative;
|
||||||
|
border: 1px solid rgba(126, 212, 255, 0.14);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: linear-gradient(180deg, rgba(9, 20, 38, 0.88), rgba(5, 12, 24, 0.84));
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fleet-tree-root::before,
|
||||||
|
.fleet-node-card::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: -1px;
|
||||||
|
top: -1px;
|
||||||
|
bottom: -1px;
|
||||||
|
width: 3px;
|
||||||
|
border-radius: 12px 0 0 12px;
|
||||||
|
background: rgba(126, 212, 255, 0.48);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fleet-tree-node {
|
||||||
|
position: relative;
|
||||||
|
margin-left: 18px;
|
||||||
|
padding-left: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fleet-tree-node::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 18px;
|
||||||
|
width: 1px;
|
||||||
|
background: rgba(126, 212, 255, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fleet-tree-node::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 18px;
|
||||||
|
width: 14px;
|
||||||
|
height: 1px;
|
||||||
|
background: rgba(126, 212, 255, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ship-node .fleet-node-card::before {
|
||||||
|
background: rgba(255, 191, 105, 0.44);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fleet-node-title,
|
||||||
|
.fleet-node-meta {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fleet-node-title {
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fleet-node-meta {
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.4;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fleet-tree-root:hover,
|
||||||
|
.fleet-node-card:hover {
|
||||||
|
border-color: rgba(126, 212, 255, 0.32);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fleet-tree-root[data-selected="true"],
|
||||||
|
.fleet-node-card[data-selected="true"] {
|
||||||
|
border-color: rgba(255, 191, 105, 0.46);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(255, 191, 105, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fleet-tree-root[data-selected="true"]::before,
|
||||||
|
.fleet-node-card[data-selected="true"]::before {
|
||||||
|
background: rgba(255, 191, 105, 0.72);
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.summary,
|
.summary,
|
||||||
.details,
|
.details,
|
||||||
@@ -242,4 +497,14 @@ canvas {
|
|||||||
.orders {
|
.orders {
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.window-launchers,
|
||||||
|
.fleet-actions {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-window {
|
||||||
|
top: 76px;
|
||||||
|
width: calc(100vw - 32px);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user