feat: adds fleet, windows, building

This commit is contained in:
2026-03-11 20:59:15 -04:00
parent 5727cb0e88
commit 5979a74d46
16 changed files with 2218 additions and 153 deletions

View File

@@ -14,6 +14,9 @@ The current prototype includes:
- Three view levels based on zoom: `local`, `solar`, and `universe`
- A bottom command bar with selection info, order buttons, and a minimap
- A strategic HUD overlay that switches to NATO / military-style symbols at higher zoom levels
- 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
@@ -43,12 +46,26 @@ The current prototype includes:
- military
- transport
- mining
- Ship classes now distinguish:
- frigate
- destroyer
- cruiser
- industrial
- capital
- Unit state machine now includes states for:
- idle / moving
- FTL travel
- mining and delivery
- docking approach / docking / docked / undocking
- 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:
- move
- transfer
@@ -60,12 +77,15 @@ The current prototype includes:
- Docking was added as a required step for transfer to stations
- 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:
- mine ore in `Perseus`
- return to `Helios`
- dock at a refinery
- transfer ore
- undock and repeat
- Mining ships now correctly leave for the refinery once full even when the delivery leg is inter-system
### Economy / Inventory Foundations
@@ -75,15 +95,32 @@ The current prototype includes:
- `bulk-gas`
- `container`
- `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 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
- Refineries track:
- ore stored
- active refining batch
- refining timer
- 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
@@ -110,7 +147,12 @@ The current prototype includes:
- Ship and station selection is supported
- 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` view now shows fleet hierarchy links between commanders and wing leaders
- Ships use role-specific long-range symbols:
- military: hostile/combat-style diamond iconography
- transport: boxed logistics symbol
@@ -131,13 +173,16 @@ The current prototype includes:
- `Ctrl/Cmd + Left Click`: toggle ships in selection
- `Left Drag`: marquee-select multiple ships
- `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
- `W A S D`: pan camera
- `Q / E`: rotate camera
- `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
- `B`: toggle build mode
- `1-5`: choose constructible
- `1-7`: choose constructible
- `M`: assign mining
- `P`: assign patrol
- `E`: assign escort
@@ -158,12 +203,19 @@ The current prototype includes:
- `scenario.json`
- `balance.json`
- 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`
- HUD creation and presentation logic are extracted into:
- `src/game/ui/hud.ts`
- `src/game/ui/presenters.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`
- 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
- 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
- Ship transfer paths are curved and orbit-biased, but still use authored steering rather than patched conics or n-body integration
- Fuel / energy exist but station refueling, resupply, and depletion failure states are still minimal
- Module definitions exist, but there is no actual ship/station designer yet
- Inventory classes exist, but only a subset of economic flows are implemented
- Module definitions exist, and a generic window framework now exists, but there is still no actual ship/station designer yet
- 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
- 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
@@ -194,3 +250,4 @@ The current prototype includes:
- Expand the economy beyond ore/refining into manufactured goods and trade lanes
- Improve FTL visuals with a fullscreen post-process distortion or tunnel effect
- Expand the strategic overlay with threat rings, route arrows, and fleet stance/status markers
- 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

View File

@@ -29,6 +29,16 @@
"storage": { "bulk-liquid": 600, "container": 400 },
"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",
"label": "Orbital Shipyard",
@@ -48,5 +58,15 @@
"dockingCapacity": 1,
"storage": { "manufactured": 300 },
"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"]
}
]

View File

@@ -11,6 +11,36 @@
"storage": "manufactured",
"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",
"label": "Volatile Gas",
@@ -28,5 +58,47 @@
"label": "Drone Parts",
"storage": "container",
"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."
}
]

View File

@@ -41,6 +41,12 @@
"category": "dock",
"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",
"label": "Refinery Stack",

View File

@@ -4,11 +4,255 @@
"label": "Ore Refining",
"facilityCategory": "refining",
"duration": 8,
"priority": 100,
"inputs": [
{ "itemId": "ore", "amount": 60 }
],
"outputs": [
{ "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 }
]
}
]

View File

@@ -7,10 +7,13 @@
{ "constructibleId": "defense-grid", "systemId": "helios", "planetIndex": 2, "lagrangeSide": 1 }
],
"shipFormations": [
{ "shipId": "carrier", "count": 1, "center": [120, 0, 60], "systemId": "helios" },
{ "shipId": "frigate", "count": 6, "center": [180, 0, 90], "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": "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" }
],
"patrolRoutes": [

View File

@@ -3,6 +3,7 @@
"id": "frigate",
"label": "Vanguard Frigate",
"role": "military",
"shipClass": "frigate",
"speed": 50,
"ftlSpeed": 3200,
"spoolTime": 2.2,
@@ -17,6 +18,7 @@
"id": "destroyer",
"label": "Bulwark Destroyer",
"role": "military",
"shipClass": "destroyer",
"speed": 34,
"ftlSpeed": 2900,
"spoolTime": 2.8,
@@ -27,10 +29,43 @@
"maxHealth": 240,
"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",
"label": "Atlas Hauler",
"role": "transport",
"shipClass": "industrial",
"speed": 22,
"ftlSpeed": 2600,
"spoolTime": 3.3,
@@ -47,6 +82,7 @@
"id": "miner",
"label": "Prospector Miner",
"role": "mining",
"shipClass": "industrial",
"speed": 26,
"ftlSpeed": 2400,
"spoolTime": 3.1,

236
src/game/fleet/runtime.ts Normal file
View 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);
}
}

View 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;
}
}

View File

@@ -1,12 +1,17 @@
import * as THREE from "three";
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 =
| "station"
| "refining"
| "farm"
| "shipyard"
| "defense";
| "defense"
| "gate";
export type UnitState =
| "idle"
| "moving"
@@ -22,8 +27,9 @@ export type UnitState =
| "docked"
| "undocking"
| "patrolling"
| "escorting";
export type UnitOrderKind = "idle" | "move" | "transfer" | "mine" | "patrol" | "escort";
| "escorting"
| "forming";
export type UnitOrderKind = "idle" | "move" | "transfer" | "mine" | "patrol" | "escort" | "dock";
export type ItemStorageKind = "bulk-solid" | "bulk-liquid" | "bulk-gas" | "container" | "manufactured";
export type ModuleCategory =
| "bridge"
@@ -63,6 +69,8 @@ export interface RecipeDefinition {
label: string;
facilityCategory: ConstructibleCategory;
duration: number;
priority?: number;
requiredModules?: string[];
inputs: RecipeComponentDefinition[];
outputs: RecipeComponentDefinition[];
}
@@ -71,6 +79,7 @@ export interface ShipDefinition {
id: string;
label: string;
role: ShipRole;
shipClass: ShipClass;
speed: number;
ftlSpeed: number;
spoolTime: number;
@@ -82,6 +91,8 @@ export interface ShipDefinition {
size: number;
maxHealth: number;
modules: string[];
dockingCapacity?: number;
dockingClasses?: ShipClass[];
}
export interface ConstructibleDefinition {
@@ -153,6 +164,12 @@ export interface PatrolRouteDefinition {
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 {
initialStations: InitialStationDefinition[];
shipFormations: ShipFormationDefinition[];
@@ -193,7 +210,8 @@ export type UnitOrder =
}
| { kind: "mine"; nodeId: string; refineryId: string; phase: "to-node" | "mining" | "to-refinery" | "transfer" }
| { kind: "patrol"; points: THREE.Vector3[]; systemId: string; index: number }
| { kind: "escort"; targetShipId: string; offset: THREE.Vector3 };
| { kind: "escort"; targetShipId: string; offset: THREE.Vector3 }
| { kind: "dock"; carrierShipId: string };
export interface InventoryState {
"bulk-solid": number;
@@ -210,6 +228,27 @@ export interface TravelPlan {
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 {
id: string;
definition: ShipDefinition;
@@ -226,6 +265,7 @@ export interface ShipInstance {
actionTimer: number;
travelPlan?: TravelPlan;
dockedStationId?: string;
dockedCarrierId?: string;
dockingPortIndex?: number;
fuel: number;
energy: number;
@@ -234,6 +274,14 @@ export interface ShipInstance {
idleOrbitRadius: number;
idleOrbitAngle: number;
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 {
@@ -248,6 +296,7 @@ export interface StationInstance {
activeBatch: number;
activeRecipeId?: string;
inventory: InventoryState;
itemStocks: Record<string, number>;
dockedShipIds: Set<string>;
dockingPorts: THREE.Vector3[];
modules: string[];
@@ -302,4 +351,8 @@ export interface HudElements {
marquee: HTMLDivElement;
strategicOverlay: HTMLCanvasElement;
strategicOverlayContext: CanvasRenderingContext2D;
fleetWindow: HTMLDivElement;
fleetWindowBody: HTMLDivElement;
fleetWindowTitle: HTMLHeadingElement;
fleetWindowSubtitle: HTMLParagraphElement;
}

View File

@@ -1,6 +1,13 @@
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");
root.className = "hud";
root.innerHTML = `
@@ -9,7 +16,7 @@ export function createHud(container: HTMLElement, onOrderAction: (action: string
<h1>Helios Reach Command</h1>
<p>
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>
</section>
<section class="panel details">
@@ -23,25 +30,69 @@ export function createHud(container: HTMLElement, onOrderAction: (action: string
</div>
<div class="orders-panel">
<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">
<button type="button" data-action="move">Move</button>
<button type="button" data-action="mine">Mine</button>
<button type="button" data-action="patrol">Patrol</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>
</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 class="minimap-panel">
<canvas class="minimap" width="220" height="160"></canvas>
</div>
</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>
`;
container.append(root);
initializeWindowInteractions(root);
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");
@@ -66,5 +117,93 @@ export function createHud(container: HTMLElement, onOrderAction: (action: string
marquee: root.querySelector(".marquee") as HTMLDivElement,
strategicOverlay,
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`;
}

View File

@@ -3,8 +3,10 @@ import {
moduleDefinitionsById,
recipeDefinitions,
} from "../data/catalog";
import { describeFleetOrder } from "../fleet/runtime";
import { getShipCargoAmount } from "../state/inventory";
import type {
FleetInstance,
ShipInstance,
SolarSystemInstance,
StationInstance,
@@ -30,45 +32,167 @@ export function getSelectionDetails(
systems: SolarSystemInstance[],
viewLevel: ViewLevel,
ships: ShipInstance[],
fleets: FleetInstance[],
) {
if (selectedStation) {
return describeStation(selectedStation, ships);
return describeStation(selectedStation, ships, fleets);
}
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
.map(
(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(", ")}`,
(ship) => {
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");
}
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 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 localFleets = fleets.filter((fleet) => fleet.systemId === station.systemId).length;
const activeRecipe = station.activeRecipeId
? recipeDefinitions.find((recipe) => recipe.id === station.activeRecipeId)
: undefined;
const refineryStatus =
station.definition.category === "refining"
? `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`
const stockSummary = Object.entries(station.itemStocks)
.filter(([, amount]) => amount > 0)
.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 =
station.definition.category === "refining"
? `Refining ore for ${miners} mining ships`
? `Refining and fabricating for ${miners} mining ships`
: station.definition.category === "shipyard"
? `Maintaining ${patrols} patrol craft`
? `Building ship parts for ${patrols} patrol craft`
: station.definition.category === "farm"
? "Supplying agricultural goods"
? "Supplying agricultural goods and industrial consumables"
: station.definition.category === "defense"
? `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";
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) {

View File

@@ -1,5 +1,6 @@
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 {
context: CanvasRenderingContext2D;
@@ -11,6 +12,8 @@ interface RenderMinimapOptions {
selection: ShipInstance[];
selectedStation?: StationInstance;
cameraFocus: THREE.Vector3;
fleets: FleetInstance[];
activeFleetId?: string;
}
interface RenderOverlayOptions {
@@ -25,6 +28,8 @@ interface RenderOverlayOptions {
selectedStation?: StationInstance;
selectedSystemIndex: number;
viewLevel: ViewLevel;
fleets: FleetInstance[];
activeFleetId?: string;
}
export function drawMinimap({
@@ -37,6 +42,8 @@ export function drawMinimap({
selection,
selectedStation,
cameraFocus,
fleets,
activeFleetId,
}: RenderMinimapOptions) {
context.clearRect(0, 0, width, height);
context.fillStyle = "rgba(4, 9, 20, 0.92)";
@@ -82,6 +89,16 @@ export function drawMinimap({
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);
context.strokeStyle = "rgba(255,255,255,0.7)";
context.strokeRect(focus.x - 9, focus.y - 9, 18, 18);
@@ -99,6 +116,8 @@ export function drawStrategicOverlay({
selectedStation,
selectedSystemIndex,
viewLevel,
fleets,
activeFleetId,
}: RenderOverlayOptions) {
context.clearRect(0, 0, width, height);
if (viewLevel === "local") {
@@ -130,6 +149,8 @@ export function drawStrategicOverlay({
drawShipSymbol(context, screen.x, screen.y, ship, 10, selection.includes(ship));
}
});
drawFleetLinks(context, camera, fleets, ships, systems[selectedSystemIndex]?.definition.id, activeFleetId);
} else {
systems.forEach((system) => {
const screen = projectWorldToScreen(system.center, camera);
@@ -139,19 +160,19 @@ export function drawStrategicOverlay({
drawSystemFrame(context, screen.x, screen.y, system.definition.label);
const fleets = new Map<ShipRole, ShipInstance[]>();
const roleBuckets = new Map<ShipRole, ShipInstance[]>();
ships.forEach((ship) => {
if (ship.systemId !== system.definition.id) {
return;
}
const bucket = fleets.get(ship.definition.role) ?? [];
const bucket = roleBuckets.get(ship.definition.role) ?? [];
bucket.push(ship);
fleets.set(ship.definition.role, bucket);
roleBuckets.set(ship.definition.role, bucket);
});
const roleOrder: ShipRole[] = ["military", "transport", "mining"];
roleOrder.forEach((role, index) => {
const bucket = fleets.get(role);
const bucket = roleBuckets.get(role);
if (!bucket || bucket.length === 0) {
return;
}
@@ -165,6 +186,13 @@ export function drawStrategicOverlay({
if (stationCount > 0) {
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();
}
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(
context: CanvasRenderingContext2D,
x: number,
@@ -410,6 +486,14 @@ function drawStationSymbol(
context.beginPath();
context.arc(0, 0, 5, 0, Math.PI * 2);
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();
@@ -438,5 +522,8 @@ function getStationSymbolColor(station: StationInstance) {
if (station.definition.category === "shipyard") {
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)";
}

View File

@@ -432,6 +432,7 @@ export function createStationInstance({
processTimer: 0,
activeBatch: 0,
inventory: createEmptyInventory(),
itemStocks: {},
dockedShipIds: new Set(),
dockingPorts,
modules: definition.modules,
@@ -462,6 +463,7 @@ function createShip({
const visual = new THREE.Group();
visual.rotation.y = Math.PI / 2;
group.add(visual);
const dockingCapacity = definition.dockingCapacity ?? 0;
const warpFx = new THREE.Group();
warpFx.visible = false;
@@ -511,6 +513,30 @@ function createShip({
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(
new THREE.SphereGeometry(definition.size * 0.35, 14, 14),
new THREE.MeshBasicMaterial({ color: definition.color, transparent: true, opacity: 0.72 }),
@@ -531,6 +557,24 @@ function createShip({
ring.position.y = -definition.size * 0.55;
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(
new THREE.SphereGeometry(definition.size * 1.6, 12, 12),
new THREE.MeshBasicMaterial({ visible: false }),
@@ -558,6 +602,12 @@ function createShip({
idleOrbitRadius: Math.max(120, group.position.length()),
idleOrbitAngle: 0,
warpFx,
behavior: "independent",
isFleetCommander: false,
isWingLeader: false,
formationOffset: new THREE.Vector3(),
dockedShipIds: new Set(),
dockingPorts,
};
selectableTargets.set(pickHull, { kind: "ship", ship });

View File

@@ -163,6 +163,13 @@ canvas {
gap: 14px;
}
.window-launchers,
.fleet-actions {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.orders-panel .mode {
color: var(--warning);
text-shadow: 0 0 18px rgba(255, 191, 105, 0.24);
@@ -170,11 +177,14 @@ canvas {
.orders {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 10px;
}
.orders button {
.window-launchers button,
.orders button,
.fleet-actions button,
.window-close {
border: 1px solid rgba(126, 212, 255, 0.16);
border-radius: 12px;
background: linear-gradient(180deg, rgba(13, 30, 56, 0.95), rgba(8, 17, 33, 0.95));
@@ -184,11 +194,14 @@ canvas {
text-transform: uppercase;
letter-spacing: 0.08em;
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);
}
.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);
transform: translateY(-1px);
}
@@ -197,6 +210,12 @@ canvas {
opacity: 0.45;
}
button:disabled {
opacity: 0.35;
cursor: default;
transform: none;
}
.orders-panel .hint {
color: var(--muted);
line-height: 1.45;
@@ -216,6 +235,242 @@ canvas {
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) {
.summary,
.details,
@@ -242,4 +497,14 @@ canvas {
.orders {
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);
}
}