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

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