Simplify simulation and restore ship browser

This commit is contained in:
2026-03-12 11:13:37 -04:00
parent 44311a8585
commit 9db53f1b48
10 changed files with 219 additions and 1089 deletions

View File

@@ -8,7 +8,7 @@ The codebase is still TypeScript + Three.js on Vite, with authored catalogs unde
- procedural universe generation - procedural universe generation
- autonomous faction behavior - autonomous faction behavior
- fleet / wing hierarchy - direct per-ship faction control
- economic production loops - economic production loops
- pirate harassment - pirate harassment
- strategic system control - strategic system control
@@ -24,12 +24,6 @@ The current build includes:
- rich central systems that factions contest for control - rich central systems that factions contest for control
- faction-owned stations, ships, inventories, and combat stats - faction-owned stations, ships, inventories, and combat stats
- autonomous shipbuilding and limited outpost growth - autonomous shipbuilding and limited outpost growth
- fleet and wing structure with behaviors such as:
- `command`
- `screen`
- `mining`
- `logistics`
- `escort`
- observer controls for camera orbit, pan, focus, and inspection - observer controls for camera orbit, pan, focus, and inspection
## Major Gameplay / Sim Systems ## Major Gameplay / Sim Systems
@@ -67,23 +61,14 @@ The current build includes:
### High-Level AI / Delegation ### High-Level AI / Delegation
- Faction AI now acts at a strategic level instead of directly micromanaging every ship. - Faction AI now acts at a strategic level and issues direct orders to ships.
- Empire AI chooses high-level goals such as: - Empire AI chooses high-level goals such as:
- secure home and mining space - secure home and mining space
- contest central systems - contest central systems
- assign industrial fleets to mining loops - send miners to resource systems
- Pirate AI chooses raid targets and dispatches fleets into hostile space. - send military ships to rally and patrol targets
- Fleet-level orders are now the intended command boundary between: - Pirate AI chooses raid targets and moves military ships into hostile space.
- faction strategy - The fleet / wing layer has been removed from both simulation and UI.
- fleet / wing execution
- This work was specifically done to stop faction AI from stomping `screen` behavior with raw ship move orders.
### Fleets / Wings
- Fleet creation now groups ships per faction and role in `src/game/fleet/runtime.ts`.
- War fleets and industry fleets are generated from faction-owned ships.
- Wing behaviors remain meaningful at the tactical layer.
- `screen` is intended to remain subordinate to fleet command rather than independent faction micromanagement.
### Economy / Production ### Economy / Production
@@ -106,11 +91,14 @@ The current build includes:
## Starting State ## Starting State
- Empires now start very small for easier debugging and growth observation. - Empires now start very small for easier debugging and growth observation.
- Each empire currently starts with only 3 ships: - Each empire currently starts with:
- 1 frigate
- 1 hauler
- 1 miner - 1 miner
- Pirates still start with small raiding groups. - 1 manufactory
- 1 refinery
- Each pirate faction currently starts with:
- 1 frigate
- 1 trade hub
- This is a bootstrap-oriented setup: factions mine first, then try to grow from minimal infrastructure.
## UI / UX State ## UI / UX State
@@ -123,7 +111,6 @@ The current build includes:
- status line - status line
- horizontally scrolling cards for selected entities - horizontally scrolling cards for selected entities
- fallback observer details when nothing specific is selected - fallback observer details when nothing specific is selected
- Fleet launch controls were removed from the main HUD.
- A dedicated `Debug` window now contains the `New Universe` button. - A dedicated `Debug` window now contains the `New Universe` button.
### Selection / Inspection ### Selection / Inspection
@@ -136,19 +123,24 @@ The current build includes:
- stations - stations
- Double-click centers / focuses the clicked target. - Double-click centers / focuses the clicked target.
- Multiple ship selections render as horizontal cards in the bottom dock. - Multiple ship selections render as horizontal cards in the bottom dock.
- Fleet window tree selection still works.
### Windows ### Windows
- Generic draggable / resizable app windows still exist. - Generic draggable / resizable app windows still exist.
- Main windows currently in use: - Main windows currently in use:
- `Fleet Command` - `Ships`
- `Debug` - `Debug`
- The `Ships` window:
- lists ships grouped by faction
- selects a ship on click
- focuses a ship on double click
- focuses a faction home system when clicking that faction header
### Strategic Rendering ### Strategic Rendering
- Strategic overlay and minimap infrastructure still exist. - Strategic overlay and minimap infrastructure still exist.
- The minimap canvas is still created for renderer use, but it is no longer shown in the visible HUD. - The minimap canvas is still created for renderer use, but it is no longer shown in the visible HUD.
- Fleet link overlays and fleet counters were removed along with the fleet system.
## Controls ## Controls
@@ -160,10 +152,10 @@ The current build includes:
- `Middle Drag`: orbit camera - `Middle Drag`: orbit camera
- `Shift + Middle Drag`: pan camera - `Shift + Middle Drag`: pan camera
- `Mouse Wheel` or `-` / `=`: zoom - `Mouse Wheel` or `-` / `=`: zoom
- `W A S D`: pan camera - `W A S D`: pan camera using the same motion as `Shift + Middle Drag`
- `Q / E`: rotate camera - `Q / E`: rotate camera
- `F`: focus current selection - `F`: focus current selection
- `G`: toggle fleet command window - `G`: toggle ships window
- `Tab`: jump camera between systems - `Tab`: jump camera between systems
## Technical Notes ## Technical Notes
@@ -173,8 +165,6 @@ The current build includes:
- `src/game/world/worldFactory.ts` - `src/game/world/worldFactory.ts`
- Procedural universe generation: - Procedural universe generation:
- `src/game/world/universeGenerator.ts` - `src/game/world/universeGenerator.ts`
- Fleet composition helpers:
- `src/game/fleet/runtime.ts`
- Selection state: - Selection state:
- `src/game/state/selectionManager.ts` - `src/game/state/selectionManager.ts`
- HUD / presentation: - HUD / presentation:
@@ -191,16 +181,16 @@ The current build includes:
- Economic logistics are still abstracted heavily. - Economic logistics are still abstracted heavily.
- Ship construction is recipe-gated but still simplified. - Ship construction is recipe-gated but still simplified.
- Stations consume pooled faction stock rather than explicit transport delivery chains. - Stations consume pooled faction stock rather than explicit transport delivery chains.
- Fleet window remains useful, but the overall UI is now only partially refit for observer mode. - Bootstrap progression is still constrained by the current station recipe / stock model.
- The ships window is useful for inspection, but the overall UI is still only partially refit for observer mode.
- There is still no persistence layer for window layouts, saves, or generated universe seeds. - There is still no persistence layer for window layouts, saves, or generated universe seeds.
## Suggested Next Steps ## Suggested Next Steps
- Extract faction strategy into a dedicated AI / planning module - Extract faction strategy into a dedicated AI / planning module
- Extract fleet order execution into its own gameplay system
- Separate economic simulation from UI and rendering concerns - Separate economic simulation from UI and rendering concerns
- Improve transport logistics so goods physically move through faction supply chains - Improve transport logistics so goods physically move through faction supply chains
- Add explicit shipyard construction queues and faction production priorities - Add explicit shipyard construction queues and faction production priorities
- Improve combat behavior so `screen`, `escort`, and `command` have stronger distinct tactical roles - Rework bootstrap progression so factions can genuinely grow from near-zero infrastructure
- Add system-level threat, ownership, and economy views for game-master inspection - Add system-level threat, ownership, and economy views for game-master inspection
- Add save/load support for generated universes and long-running simulations - Add save/load support for generated universes and long-running simulations

View File

@@ -6,20 +6,10 @@ import {
recipeDefinitions, recipeDefinitions,
shipDefinitionsById, shipDefinitionsById,
} from "./data/catalog"; } from "./data/catalog";
import {
createDefaultFleets,
describeFleetOrder,
getFleetCommander,
getFleetShipIds,
getWingLeader,
getWingMembers,
} from "./fleet/runtime";
import { addShipCargo, getShipCargoAmount, removeShipCargo } from "./state/inventory"; import { addShipCargo, getShipCargoAmount, removeShipCargo } from "./state/inventory";
import { SelectionManager } from "./state/selectionManager"; import { SelectionManager } from "./state/selectionManager";
import type { import type {
FactionInstance, FactionInstance,
FleetInstance,
FleetWingInstance,
GameWindowId, GameWindowId,
ResourceNode, ResourceNode,
SelectableTarget, SelectableTarget,
@@ -32,7 +22,7 @@ import type {
ViewLevel, ViewLevel,
} from "./types"; } from "./types";
import { createHud } from "./ui/hud"; import { createHud } from "./ui/hud";
import { getFleetWindowMarkup, getSelectionCardsMarkup, getSelectionDetails, getSelectionTitle } from "./ui/presenters"; import { getSelectionCardsMarkup, getSelectionDetails, getSelectionTitle, getShipWindowMarkup } from "./ui/presenters";
import { drawMinimap, drawStrategicOverlay } from "./ui/strategicRenderer"; import { drawMinimap, drawStrategicOverlay } from "./ui/strategicRenderer";
import { buildInitialWorld, createShipInstance, createStationInstance } from "./world/worldFactory"; import { buildInitialWorld, createShipInstance, createStationInstance } from "./world/worldFactory";
import { generateUniverse } from "./world/universeGenerator"; import { generateUniverse } from "./world/universeGenerator";
@@ -70,8 +60,6 @@ export class GameApp {
private readonly stations: StationInstance[] = []; private readonly stations: StationInstance[] = [];
private readonly nodes: ResourceNode[] = []; private readonly nodes: ResourceNode[] = [];
private readonly systems: SolarSystemInstance[] = []; private readonly systems: SolarSystemInstance[] = [];
private readonly fleets: FleetInstance[] = [];
private readonly fleetsById = new Map<string, FleetInstance>();
private readonly factions: FactionInstance[] = []; private readonly factions: FactionInstance[] = [];
private readonly factionsById = new Map<string, FactionInstance>(); private readonly factionsById = new Map<string, FactionInstance>();
@@ -92,7 +80,6 @@ export class GameApp {
private cameraDragPointerId?: number; private cameraDragPointerId?: number;
private cameraDragLast?: THREE.Vector2; private cameraDragLast?: THREE.Vector2;
private stationIdCounter = 0; private stationIdCounter = 0;
private activeFleetId?: string;
private readonly windowState: Record<GameWindowId, boolean> = { private readonly windowState: Record<GameWindowId, boolean> = {
"fleet-command": true, "fleet-command": true,
"ship-designer": false, "ship-designer": false,
@@ -113,11 +100,9 @@ export class GameApp {
private readonly fleetWindowEl: HTMLDivElement; private readonly fleetWindowEl: HTMLDivElement;
private readonly fleetWindowBodyEl: HTMLDivElement; private readonly fleetWindowBodyEl: HTMLDivElement;
private readonly fleetWindowTitleEl: HTMLHeadingElement; private readonly fleetWindowTitleEl: HTMLHeadingElement;
private readonly fleetWindowSubtitleEl: HTMLParagraphElement;
private readonly debugWindowEl: HTMLDivElement; private readonly debugWindowEl: HTMLDivElement;
private readonly sessionActionsEl: HTMLDivElement; private readonly sessionActionsEl: HTMLDivElement;
private universe: UniverseDefinition; private universe: UniverseDefinition;
private fleetRefreshNeeded = false;
constructor(container: HTMLElement) { constructor(container: HTMLElement) {
this.container = container; this.container = container;
@@ -139,7 +124,6 @@ export class GameApp {
this.container.append(this.renderer.domElement); this.container.append(this.renderer.domElement);
const hud = createHud(this.container, { const hud = createHud(this.container, {
onWindowAction: (action) => this.handleWindowAction(action), onWindowAction: (action) => this.handleWindowAction(action),
onFleetAction: (action, fleetId) => this.handleFleetAction(action, fleetId),
onSelectionAction: (kind, id) => this.handleWindowSelection(kind, id), onSelectionAction: (kind, id) => this.handleWindowSelection(kind, id),
}); });
this.detailsEl = hud.details; this.detailsEl = hud.details;
@@ -155,7 +139,6 @@ export class GameApp {
this.fleetWindowEl = hud.fleetWindow; this.fleetWindowEl = hud.fleetWindow;
this.fleetWindowBodyEl = hud.fleetWindowBody; this.fleetWindowBodyEl = hud.fleetWindowBody;
this.fleetWindowTitleEl = hud.fleetWindowTitle; this.fleetWindowTitleEl = hud.fleetWindowTitle;
this.fleetWindowSubtitleEl = hud.fleetWindowSubtitle;
this.debugWindowEl = hud.debugWindow; this.debugWindowEl = hud.debugWindow;
this.sessionActionsEl = hud.sessionActions; this.sessionActionsEl = hud.sessionActions;
@@ -197,7 +180,6 @@ export class GameApp {
this.stationIdCounter = this.stations.length; this.stationIdCounter = this.stations.length;
this.initializeFactions(); this.initializeFactions();
this.initializeFleets();
this.applyViewLevel(); this.applyViewLevel();
} }
@@ -209,14 +191,10 @@ export class GameApp {
this.stations.length = 0; this.stations.length = 0;
this.nodes.length = 0; this.nodes.length = 0;
this.systems.length = 0; this.systems.length = 0;
this.fleets.length = 0;
this.fleetsById.clear();
this.factions.length = 0; this.factions.length = 0;
this.factionsById.clear(); this.factionsById.clear();
this.followShipId = undefined; this.followShipId = undefined;
this.activeFleetId = undefined;
this.buildMode = false; this.buildMode = false;
this.fleetRefreshNeeded = false;
this.selectedSystemIndex = 0; this.selectedSystemIndex = 0;
this.stationIdCounter = 0; this.stationIdCounter = 0;
this.marqueeStart = undefined; this.marqueeStart = undefined;
@@ -460,7 +438,6 @@ export class GameApp {
} else if (!this.selectionManager.hasShip(target.ship)) { } else if (!this.selectionManager.hasShip(target.ship)) {
this.selectionManager.addShip(target.ship); this.selectionManager.addShip(target.ship);
} }
this.syncActiveFleetFromSelection();
} }
if (target?.kind === "station") { if (target?.kind === "station") {
this.selectionManager.setStation(target.station); this.selectionManager.setStation(target.station);
@@ -592,17 +569,6 @@ export class GameApp {
}); });
} }
private initializeFleets() {
const previousActiveFleetId = this.activeFleetId;
this.fleets.length = 0;
this.fleetsById.clear();
createDefaultFleets(this.ships).forEach((fleet) => {
this.fleets.push(fleet);
this.fleetsById.set(fleet.id, fleet);
});
this.activeFleetId = this.fleetsById.has(previousActiveFleetId ?? "") ? previousActiveFleetId : this.fleets[0]?.id;
}
private makePatrolPoints(systemId: string) { private makePatrolPoints(systemId: string) {
const route = this.universe.scenario.patrolRoutes.find((candidate) => candidate.systemId === systemId); const route = this.universe.scenario.patrolRoutes.find((candidate) => candidate.systemId === systemId);
if (!route) { if (!route) {
@@ -617,180 +583,25 @@ export class GameApp {
return route.points.map((point) => new THREE.Vector3(...point).setY(gameBalance.yPlane)); return route.points.map((point) => new THREE.Vector3(...point).setY(gameBalance.yPlane));
} }
private setFleetMineOrder(fleet: FleetInstance, nodeSystemId: string, refinerySystemId: string) { private getFactionShips(factionId: string) {
fleet.order = { kind: "mine", nodeSystemId, refinerySystemId }; return this.ships.filter((ship) => ship.factionId === factionId);
this.applyFleetOrder(fleet);
} }
private setFleetPatrolOrder(fleet: FleetInstance, systemId: string) { private getFactionMilitaryShips(factionId: string) {
fleet.order = { return this.getFactionShips(factionId).filter((ship) => ship.definition.role === "military");
kind: "patrol",
systemId,
points: this.makePatrolPoints(systemId),
index: 0,
};
this.applyFleetOrder(fleet);
} }
private setFleetIdleOrder(fleet: FleetInstance) { private getFactionIndustryShips(factionId: string) {
fleet.order = { kind: "idle" }; return this.getFactionShips(factionId).filter((ship) => ship.definition.role !== "military");
this.applyFleetOrder(fleet);
} }
private issueFleetMoveOrder(fleet: FleetInstance, destination: THREE.Vector3) { private makeEscortOffset(index: number, spacing = 26) {
const system = this.findNearestSystem(destination); if (index === 0) {
fleet.order = { return new THREE.Vector3(0, 0, 22);
kind: "move",
destination: destination.clone().setY(gameBalance.yPlane),
systemId: system.definition.id,
};
this.applyFleetOrder(fleet);
}
private applyFleetOrder(fleet: FleetInstance) {
const commander = getFleetCommander(fleet, this.shipsById);
if (!commander) {
return;
}
fleet.wings.forEach((wing) => {
switch (fleet.order.kind) {
case "idle":
this.applyFleetIdleDirective(fleet, wing, commander);
break;
case "move":
this.applyFleetMoveDirective(fleet, wing, commander);
break;
case "patrol":
this.applyFleetPatrolDirective(fleet, wing, commander);
break;
case "mine":
this.applyFleetMiningDirective(fleet, wing, commander);
break;
}
});
fleet.systemId = commander.systemId;
}
private applyFleetIdleDirective(fleet: FleetInstance, wing: FleetWingInstance, commander: ShipInstance) {
if (wing.behavior === "command") {
const members = getWingMembers(wing, this.shipsById);
members.forEach((ship) => {
if (ship.id === commander.id) {
ship.order = { kind: "idle" };
ship.state = "idle";
return;
}
this.setEscortOrder(ship, commander, ship.formationOffset.clone());
});
return;
}
this.assignWingFollowOrder(fleet, wing, this.resolveWingAnchor(fleet, wing) ?? commander);
}
private applyFleetMoveDirective(fleet: FleetInstance, wing: FleetWingInstance, commander: ShipInstance) {
const order = fleet.order;
if (order.kind !== "move") {
return;
}
if (wing.behavior === "command") {
const members = getWingMembers(wing, this.shipsById);
members.forEach((ship) => {
if (ship.id === commander.id) {
this.issueMoveOrder(ship, order.destination.clone());
return;
}
this.setEscortOrder(ship, commander, ship.formationOffset.clone());
});
return;
}
this.assignWingFollowOrder(fleet, wing, this.resolveWingAnchor(fleet, wing) ?? commander);
}
private applyFleetPatrolDirective(fleet: FleetInstance, wing: FleetWingInstance, commander: ShipInstance) {
const order = fleet.order;
if (order.kind !== "patrol") {
return;
}
if (wing.behavior === "command") {
this.setPatrolOrder(commander, order.points, order.index, order.systemId);
getWingMembers(wing, this.shipsById)
.filter((ship) => ship.id !== commander.id)
.forEach((ship) => this.setEscortOrder(ship, commander, ship.formationOffset.clone()));
return;
}
if (wing.behavior === "screen") {
getWingMembers(wing, this.shipsById).forEach((ship, index) =>
this.setPatrolOrder(ship, order.points, (order.index + index) % order.points.length, order.systemId),
);
return;
}
this.assignWingFollowOrder(fleet, wing, this.resolveWingAnchor(fleet, wing) ?? commander);
}
private applyFleetMiningDirective(fleet: FleetInstance, wing: FleetWingInstance, commander: ShipInstance) {
const order = fleet.order;
if (order.kind !== "mine") {
return;
}
if (wing.behavior === "command") {
const targetSystem = this.getSystem(order.nodeSystemId);
const rally = targetSystem.center.clone().add(new THREE.Vector3(-140, 0, 120));
this.issueMoveOrder(commander, rally);
getWingMembers(wing, this.shipsById)
.filter((ship) => ship.id !== commander.id)
.forEach((ship) => this.setEscortOrder(ship, commander, ship.formationOffset.clone()));
return;
}
if (wing.behavior === "mining") {
const refinery = this.findRefinery(order.refinerySystemId);
getWingMembers(wing, this.shipsById).forEach((ship) =>
this.assignMineOrder(ship, this.findBestMiningNode(order.nodeSystemId), refinery),
);
return;
}
const anchor = this.resolveWingAnchor(fleet, wing) ?? commander;
const multiplier = wing.behavior === "logistics" ? 1.8 : 1.2;
this.assignWingFollowOrder(fleet, wing, anchor, multiplier);
}
private resolveWingAnchor(fleet: FleetInstance, wing: FleetWingInstance) {
if (wing.parentWingId) {
const parentWing = fleet.wings.find((candidate) => candidate.id === wing.parentWingId);
if (parentWing) {
return getWingLeader(parentWing, this.shipsById);
}
}
return getFleetCommander(fleet, this.shipsById);
}
private assignWingFollowOrder(fleet: FleetInstance, wing: FleetWingInstance, anchor: ShipInstance, offsetScale = 1) {
getWingMembers(wing, this.shipsById).forEach((ship) => {
if (ship.id === anchor.id) {
return;
}
const localOffset = ship.formationOffset.clone().multiplyScalar(offsetScale);
const twist = ship.behavior === "escort" ? new THREE.Vector3(0, 0, -18) : new THREE.Vector3(0, 0, 18);
this.setEscortOrder(ship, anchor, localOffset.add(twist));
});
if (anchor.fleetId !== fleet.id && wing.behavior !== "logistics") {
return;
}
const leader = getWingLeader(wing, this.shipsById);
if (leader && leader.id !== anchor.id && wing.behavior !== "escort" && wing.behavior !== "logistics") {
this.setEscortOrder(leader, anchor, leader.formationOffset.clone().multiplyScalar(offsetScale));
} }
const side = index % 2 === 0 ? 1 : -1;
const rank = Math.ceil(index / 2);
return new THREE.Vector3(side * rank * spacing, 0, 22 + rank * 10);
} }
private updateFactionSimulation(delta: number) { private updateFactionSimulation(delta: number) {
@@ -832,43 +643,45 @@ export class GameApp {
const threatenedSystemId = this.findThreatenedSystem(faction.definition.id); const threatenedSystemId = this.findThreatenedSystem(faction.definition.id);
const centralTarget = this.pickCentralTargetSystem(faction); const centralTarget = this.pickCentralTargetSystem(faction);
const militaryTargetSystemId = threatenedSystemId ?? centralTarget ?? faction.definition.homeSystemId; const militaryTargetSystemId = threatenedSystemId ?? centralTarget ?? faction.definition.homeSystemId;
const industryFleet = this.getFactionIndustryFleet(faction.definition.id); const miningSystemId = miningSystems[0] ?? faction.definition.miningSystemId ?? faction.definition.homeSystemId;
if (industryFleet) { const refinery = this.findRefinery(faction.definition.miningSystemId ?? faction.definition.homeSystemId, faction.definition.id);
const miningSystemId = miningSystems[0] ?? faction.definition.miningSystemId ?? faction.definition.homeSystemId; const miners = this.getFactionIndustryShips(faction.definition.id).filter((ship) => ship.definition.role === "mining");
this.setFleetMineOrder( const transports = this.getFactionIndustryShips(faction.definition.id).filter((ship) => ship.definition.role === "transport");
industryFleet, miners.forEach((ship) => this.assignMineOrder(ship, this.findBestMiningNode(miningSystemId), refinery));
miningSystemId, transports.forEach((ship, index) => {
faction.definition.miningSystemId ?? faction.definition.homeSystemId, const anchor = miners[index % Math.max(1, miners.length)];
); if (anchor) {
} this.setEscortOrder(ship, anchor, this.makeEscortOffset(index));
} else {
this.setPatrolOrder(ship, this.makePatrolPoints(faction.definition.homeSystemId), index, faction.definition.homeSystemId);
}
});
this.getFactionWarFleets(faction.definition.id).forEach((fleet) => { const targetSystem = this.getSystem(militaryTargetSystemId);
if (fleet.systemId !== militaryTargetSystemId) { const rally = targetSystem.center.clone().add(new THREE.Vector3(-140, 0, 120));
const targetSystem = this.getSystem(militaryTargetSystemId); this.getFactionMilitaryShips(faction.definition.id).forEach((ship, index) => {
const rally = targetSystem.center.clone().add(new THREE.Vector3(-140, 0, 120)); if (ship.systemId !== militaryTargetSystemId) {
this.issueFleetMoveOrder(fleet, rally); this.issueMoveOrder(ship, rally.clone().add(this.makeEscortOffset(index, 18)));
return; return;
} }
this.setFleetPatrolOrder(fleet, militaryTargetSystemId); this.setPatrolOrder(ship, this.makePatrolPoints(militaryTargetSystemId), index, militaryTargetSystemId);
}); });
} }
private commandPirateFaction(faction: FactionInstance) { private commandPirateFaction(faction: FactionInstance) {
const targetSystemId = faction.definition.targetSystemIds[0] ?? faction.definition.homeSystemId; const targetSystemId = faction.definition.targetSystemIds[0] ?? faction.definition.homeSystemId;
const targetSystem = this.getSystem(targetSystemId); const targetSystem = this.getSystem(targetSystemId);
this.getFactionWarFleets(faction.definition.id).forEach((fleet) => { const raidPoint = targetSystem.center.clone().add(new THREE.Vector3(120, 0, 160));
const raidPoint = targetSystem.center.clone().add(new THREE.Vector3(120, 0, 160)); this.getFactionMilitaryShips(faction.definition.id).forEach((ship, index) => {
if (fleet.systemId !== targetSystemId) { if (ship.systemId !== targetSystemId) {
this.issueFleetMoveOrder(fleet, raidPoint); this.issueMoveOrder(ship, raidPoint.clone().add(this.makeEscortOffset(index, 20)));
return; return;
} }
this.setFleetPatrolOrder(fleet, targetSystemId); this.setPatrolOrder(ship, this.makePatrolPoints(targetSystemId), index, targetSystemId);
});
this.getFactionIndustryShips(faction.definition.id).forEach((ship, index) => {
this.setPatrolOrder(ship, this.makePatrolPoints(faction.definition.homeSystemId), index, faction.definition.homeSystemId);
}); });
const industryFleet = this.getFactionIndustryFleet(faction.definition.id);
if (industryFleet) {
this.setFleetPatrolOrder(industryFleet, faction.definition.homeSystemId);
}
} }
private updateSystemControl(delta: number) { private updateSystemControl(delta: number) {
@@ -981,17 +794,6 @@ export class GameApp {
}); });
} }
private refreshFleets() {
if (!this.fleetRefreshNeeded) {
return;
}
this.fleetRefreshNeeded = false;
this.initializeFleets();
this.factions.forEach((faction) => {
faction.commandTick = 0;
});
}
private tick() { private tick() {
const delta = Math.min(this.clock.getDelta(), 0.033); const delta = Math.min(this.clock.getDelta(), 0.033);
const elapsed = this.clock.elapsedTime; const elapsed = this.clock.elapsedTime;
@@ -1000,14 +802,7 @@ export class GameApp {
this.updateFactionSimulation(delta); this.updateFactionSimulation(delta);
this.updateShips(delta, elapsed); this.updateShips(delta, elapsed);
this.updateCombat(delta); this.updateCombat(delta);
this.fleets.forEach((fleet) => {
const commander = getFleetCommander(fleet, this.shipsById);
if (commander) {
fleet.systemId = commander.systemId;
}
});
this.updateSystems(delta); this.updateSystems(delta);
this.refreshFleets();
this.applyViewLevel(); this.applyViewLevel();
if (this.selection.length > 0 || this.selectedStation || this.selectedSystem || this.selectedPlanet || this.followShipId) { if (this.selection.length > 0 || this.selectedStation || this.selectedSystem || this.selectedPlanet || this.followShipId) {
this.updateHud(); this.updateHud();
@@ -1026,28 +821,22 @@ export class GameApp {
this.selectedSystemIndex = this.systems.findIndex((system) => system.definition.id === followedShip.systemId); this.selectedSystemIndex = this.systems.findIndex((system) => system.definition.id === followedShip.systemId);
} }
const forward = new THREE.Vector3();
this.camera.getWorldDirection(forward);
forward.y = 0;
forward.normalize();
const right = new THREE.Vector3().crossVectors(forward, new THREE.Vector3(0, 1, 0)).normalize();
const panSpeed = Math.max(80, this.camera.position.distanceTo(focus) * 0.7) * delta; const panSpeed = Math.max(80, this.camera.position.distanceTo(focus) * 0.7) * delta;
let manualCameraInput = false; let manualCameraInput = false;
if (this.keyState.has("w")) { if (this.keyState.has("w")) {
focus.add(forward.clone().multiplyScalar(panSpeed)); this.panCamera(0, panSpeed / Math.max(0.2, this.camera.position.distanceTo(focus) * 0.0014));
manualCameraInput = true; manualCameraInput = true;
} }
if (this.keyState.has("s")) { if (this.keyState.has("s")) {
focus.add(forward.clone().multiplyScalar(-panSpeed)); this.panCamera(0, -panSpeed / Math.max(0.2, this.camera.position.distanceTo(focus) * 0.0014));
manualCameraInput = true; manualCameraInput = true;
} }
if (this.keyState.has("a")) { if (this.keyState.has("a")) {
focus.add(right.clone().multiplyScalar(panSpeed)); this.panCamera(panSpeed / Math.max(0.2, this.camera.position.distanceTo(focus) * 0.0014), 0);
manualCameraInput = true; manualCameraInput = true;
} }
if (this.keyState.has("d")) { if (this.keyState.has("d")) {
focus.add(right.clone().multiplyScalar(-panSpeed)); this.panCamera(-panSpeed / Math.max(0.2, this.camera.position.distanceTo(focus) * 0.0014), 0);
manualCameraInput = true; manualCameraInput = true;
} }
if (this.keyState.has("q") || this.keyState.has("e")) { if (this.keyState.has("q") || this.keyState.has("e")) {
@@ -1896,7 +1685,7 @@ export class GameApp {
ship.state = "patrolling"; ship.state = "patrolling";
} }
private setEscortOrder(ship: ShipInstance, target: ShipInstance, offset = ship.formationOffset.clone()) { private setEscortOrder(ship: ShipInstance, target: ShipInstance, offset = new THREE.Vector3()) {
const angle = (this.ships.indexOf(ship) % 6) * (Math.PI / 3); const angle = (this.ships.indexOf(ship) % 6) * (Math.PI / 3);
const formationOffset = const formationOffset =
offset.lengthSq() > 0 ? offset : new THREE.Vector3(Math.cos(angle) * 32, 0, Math.sin(angle) * 32); offset.lengthSq() > 0 ? offset : new THREE.Vector3(Math.cos(angle) * 32, 0, Math.sin(angle) * 32);
@@ -1912,18 +1701,6 @@ export class GameApp {
return this.stations.filter((station) => station.factionId === factionId); return this.stations.filter((station) => station.factionId === factionId);
} }
private getFactionFleets(factionId: string) {
return this.fleets.filter((fleet) => fleet.factionId === factionId);
}
private getFactionIndustryFleet(factionId: string) {
return this.getFactionFleets(factionId).find((fleet) => fleet.wings.some((wing) => wing.behavior === "mining"));
}
private getFactionWarFleets(factionId: string) {
return this.getFactionFleets(factionId).filter((fleet) => !fleet.wings.some((wing) => wing.behavior === "mining"));
}
private getFactionItemAmount(factionId: string, itemId: string) { private getFactionItemAmount(factionId: string, itemId: string) {
return this.findFactionStations(factionId).reduce((total, station) => total + (station.itemStocks[itemId] ?? 0), 0); return this.findFactionStations(factionId).reduce((total, station) => total + (station.itemStocks[itemId] ?? 0), 0);
} }
@@ -2031,7 +1808,6 @@ export class GameApp {
[...this.selectableTargets.entries()] [...this.selectableTargets.entries()]
.filter(([, target]) => target.kind === "ship" && target.ship.id === ship.id) .filter(([, target]) => target.kind === "ship" && target.ship.id === ship.id)
.forEach(([object]) => this.selectableTargets.delete(object)); .forEach(([object]) => this.selectableTargets.delete(object));
this.fleetRefreshNeeded = true;
} }
private tryBuildShipForFaction(faction: FactionInstance) { private tryBuildShipForFaction(faction: FactionInstance) {
@@ -2075,7 +1851,6 @@ export class GameApp {
this.shipsById.set(ship.id, ship); this.shipsById.set(ship.id, ship);
faction.shipsBuilt += 1; faction.shipsBuilt += 1;
faction.credits -= 60; faction.credits -= 60;
this.fleetRefreshNeeded = true;
} }
private tryBuildOutpostForFaction(faction: FactionInstance) { private tryBuildOutpostForFaction(faction: FactionInstance) {
@@ -2149,7 +1924,7 @@ export class GameApp {
} }
private focusSelection() { private focusSelection() {
if (this.selection.length === 0 && !this.selectedStation && !this.selectedSystem && !this.selectedPlanet && !this.activeFleetId) { if (this.selection.length === 0 && !this.selectedStation && !this.selectedSystem && !this.selectedPlanet) {
return; return;
} }
if (this.selectedPlanet) { if (this.selectedPlanet) {
@@ -2171,10 +1946,6 @@ export class GameApp {
this.updateHud(); this.updateHud();
return; return;
} }
if (this.selection.length === 0 && this.activeFleetId) {
this.focusFleet(this.activeFleetId);
return;
}
if (this.selection.length === 1) { if (this.selection.length === 1) {
const ship = this.selection[0]; const ship = this.selection[0];
this.followShipId = ship.id; this.followShipId = ship.id;
@@ -2220,10 +1991,6 @@ export class GameApp {
this.generateNewUniverse(); this.generateNewUniverse();
return; return;
} }
if (action === "toggle-fleet-command") {
this.toggleWindow("fleet-command");
return;
}
if (action === "toggle-debug") { if (action === "toggle-debug") {
this.toggleWindow("debug"); this.toggleWindow("debug");
return; return;
@@ -2237,54 +2004,28 @@ export class GameApp {
} }
} }
private handleFleetAction(action: string, fleetId = this.activeFleetId) {
if (!fleetId) {
return;
}
if (action === "select") {
this.activeFleetId = fleetId;
this.selectFleetShips(fleetId);
this.updateHud();
return;
}
const fleet = this.fleetsById.get(fleetId);
if (!fleet) {
return;
}
this.activeFleetId = fleet.id;
if (action === "focus") {
this.focusFleet(fleet.id);
return;
}
if (action === "patrol") {
this.setFleetPatrolOrder(fleet, fleet.systemId);
}
if (action === "mine") {
this.setFleetMineOrder(
fleet,
this.universe.scenario.miningDefaults.nodeSystemId,
this.universe.scenario.miningDefaults.refinerySystemId,
);
}
if (action === "hold") {
this.setFleetIdleOrder(fleet);
}
this.updateHud();
}
private handleWindowSelection(kind: string, id: string) { private handleWindowSelection(kind: string, id: string) {
if (kind === "fleet") { if (kind === "faction" || kind === "focus-faction") {
this.selectFleetShips(id); const faction = this.factionsById.get(id);
this.activeFleetId = id; if (!faction) {
this.updateHud(); return;
}
const system = this.getSystem(faction.definition.homeSystemId);
this.selectionManager.setSystem(system);
this.selectedSystemIndex = this.systems.findIndex((candidate) => candidate.definition.id === system.definition.id);
this.focusSystem(system.definition.id);
return; return;
} }
if (kind === "wing") { if (kind === "focus-ship") {
this.selectWingShips(id); const ship = this.shipsById.get(id);
if (!ship) {
return;
}
this.selectionManager.replaceShips([ship]);
this.followShipId = ship.id;
this.selectedSystemIndex = this.systems.findIndex((system) => system.definition.id === ship.systemId);
this.focusPoint(ship.group.position, 520);
this.updateHud(); this.updateHud();
return; return;
} }
@@ -2295,7 +2036,6 @@ export class GameApp {
return; return;
} }
this.selectionManager.replaceShips([ship]); this.selectionManager.replaceShips([ship]);
this.syncActiveFleetFromSelection();
this.updateHud(); this.updateHud();
} }
} }
@@ -2305,70 +2045,6 @@ export class GameApp {
this.updateHud(); this.updateHud();
} }
private focusFleet(fleetId: string) {
const fleet = this.fleetsById.get(fleetId);
if (!fleet) {
return;
}
const center = new THREE.Vector3();
const ships = getFleetShipIds(fleet)
.map((shipId) => this.shipsById.get(shipId))
.filter((ship): ship is ShipInstance => Boolean(ship));
if (ships.length === 0) {
return;
}
ships.forEach((ship) => center.add(ship.group.position));
center.multiplyScalar(1 / ships.length);
this.followShipId = undefined;
this.getCameraFocus().copy(center);
this.selectedSystemIndex = this.systems.findIndex((system) => system.definition.id === fleet.systemId);
this.updateHud();
}
private selectFleetShips(fleetId: string) {
const fleet = this.fleetsById.get(fleetId);
if (!fleet) {
return;
}
this.selectionManager.replaceShips(
getFleetShipIds(fleet)
.map((shipId) => this.shipsById.get(shipId))
.filter((ship): ship is ShipInstance => Boolean(ship)),
);
this.syncActiveFleetFromSelection();
}
private selectWingShips(wingId: string) {
const fleet = this.fleets.find((candidate) => candidate.wings.some((wing) => wing.id === wingId));
if (!fleet) {
return;
}
const wingIds = this.collectWingTreeIds(fleet, wingId);
const ships = fleet.wings
.filter((wing) => wingIds.has(wing.id))
.flatMap((wing) => wing.shipIds)
.map((shipId) => this.shipsById.get(shipId))
.filter((ship): ship is ShipInstance => Boolean(ship));
this.selectionManager.replaceShips(ships);
this.activeFleetId = fleet.id;
this.syncActiveFleetFromSelection();
}
private collectWingTreeIds(fleet: FleetInstance, rootWingId: string) {
const ids = new Set<string>([rootWingId]);
let changed = true;
while (changed) {
changed = false;
fleet.wings.forEach((wing) => {
if (wing.parentWingId && ids.has(wing.parentWingId) && !ids.has(wing.id)) {
ids.add(wing.id);
changed = true;
}
});
}
return ids;
}
private adjustZoom(multiplier: number) { private adjustZoom(multiplier: number) {
const focus = this.getCameraFocus(); const focus = this.getCameraFocus();
const direction = this.camera.position.clone().sub(focus).normalize(); const direction = this.camera.position.clone().sub(focus).normalize();
@@ -2389,8 +2065,6 @@ export class GameApp {
selection: this.selection, selection: this.selection,
selectedStation: this.selectedStation, selectedStation: this.selectedStation,
cameraFocus: this.getCameraFocus(), cameraFocus: this.getCameraFocus(),
fleets: this.fleets,
activeFleetId: this.activeFleetId,
}); });
drawStrategicOverlay({ drawStrategicOverlay({
context: this.strategicOverlayContext, context: this.strategicOverlayContext,
@@ -2404,8 +2078,6 @@ export class GameApp {
selectedStation: this.selectedStation, selectedStation: this.selectedStation,
selectedSystemIndex: this.selectedSystemIndex, selectedSystemIndex: this.selectedSystemIndex,
viewLevel: this.viewLevel, viewLevel: this.viewLevel,
fleets: this.fleets,
activeFleetId: this.activeFleetId,
}); });
} }
@@ -2414,7 +2086,6 @@ export class GameApp {
const system = this.systems[this.selectedSystemIndex] ?? this.systems[0]; const system = this.systems[this.selectedSystemIndex] ?? this.systems[0];
const selectedCount = const selectedCount =
this.selection.length + (this.selectedStation ? 1 : 0) + (this.selectedSystem ? 1 : 0) + (this.selectedPlanet ? 1 : 0); this.selection.length + (this.selectedStation ? 1 : 0) + (this.selectedSystem ? 1 : 0) + (this.selectedPlanet ? 1 : 0);
const activeFleet = this.activeFleetId ? this.fleetsById.get(this.activeFleetId) : undefined;
this.selectionTitleEl.textContent = getSelectionTitle( this.selectionTitleEl.textContent = getSelectionTitle(
this.selection, this.selection,
this.selectedStation, this.selectedStation,
@@ -2438,7 +2109,6 @@ export class GameApp {
this.systems, this.systems,
this.viewLevel, this.viewLevel,
this.ships, this.ships,
this.fleets,
this.factions, this.factions,
); );
this.detailsEl.style.display = hasExplicitSelection ? "none" : "block"; this.detailsEl.style.display = hasExplicitSelection ? "none" : "block";
@@ -2449,16 +2119,12 @@ export class GameApp {
this.sessionActionsEl.title = `${this.universe.label}${this.universe.systems.length} systems`; this.sessionActionsEl.title = `${this.universe.label}${this.universe.systems.length} systems`;
this.statusEl.textContent = this.buildMode this.statusEl.textContent = this.buildMode
? `Observer Mode: ${selectedDefinition.label} preview in ${system.definition.label}${this.viewLevel} view • ${this.universe.systems.length} systems` ? `Observer Mode: ${selectedDefinition.label} preview in ${system.definition.label}${this.viewLevel} view • ${this.universe.systems.length} systems`
: `Game Master Mode: ${selectedCount} inspected • Camera ${system.definition.label}${this.viewLevel} view • ${this.universe.systems.length} systems${this.followShipId ? " • following ship" : ""}${activeFleet ? ` • Fleet ${activeFleet.label}` : ""}`; : `Game Master Mode: ${selectedCount} inspected • Camera ${system.definition.label}${this.viewLevel} view • ${this.universe.systems.length} systems${this.followShipId ? " • following ship" : ""}`;
this.ordersEl.dataset.mode = this.ordersEl.dataset.mode = this.selectedStation ? "station" : this.selection.length > 0 ? "ships" : "none";
this.selectedStation ? "station" : this.selection.length > 0 ? "ships" : this.activeFleetId ? "fleet" : "none";
this.fleetWindowEl.dataset.open = this.windowState["fleet-command"] ? "true" : "false"; this.fleetWindowEl.dataset.open = this.windowState["fleet-command"] ? "true" : "false";
this.fleetWindowTitleEl.textContent = "Ships";
this.fleetWindowBodyEl.innerHTML = getShipWindowMarkup(this.ships, this.selection);
this.debugWindowEl.dataset.open = this.windowState.debug ? "true" : "false"; this.debugWindowEl.dataset.open = this.windowState.debug ? "true" : "false";
this.fleetWindowTitleEl.textContent = "Fleet Command";
this.fleetWindowSubtitleEl.textContent = activeFleet
? `${activeFleet.label}${describeFleetOrder(activeFleet)}`
: "No fleet selected";
this.fleetWindowBodyEl.innerHTML = getFleetWindowMarkup(this.fleets, this.shipsById, this.activeFleetId, this.selection);
} }
private placeStation(definition: StationInstance["definition"], position: THREE.Vector3, systemId: string) { private placeStation(definition: StationInstance["definition"], position: THREE.Vector3, systemId: string) {
@@ -2487,17 +2153,14 @@ export class GameApp {
private clearSelection() { private clearSelection() {
this.selectionManager.clear(); this.selectionManager.clear();
this.syncActiveFleetFromSelection();
} }
private addShipToSelection(ship: ShipInstance) { private addShipToSelection(ship: ShipInstance) {
this.selectionManager.addShip(ship); this.selectionManager.addShip(ship);
this.syncActiveFleetFromSelection();
} }
private removeShipFromSelection(ship: ShipInstance) { private removeShipFromSelection(ship: ShipInstance) {
this.selectionManager.removeShip(ship); this.selectionManager.removeShip(ship);
this.syncActiveFleetFromSelection();
} }
private updateMarqueeBox(clientX: number, clientY: number) { private updateMarqueeBox(clientX: number, clientY: number) {
@@ -2555,21 +2218,10 @@ export class GameApp {
} }
}); });
this.syncActiveFleetFromSelection();
this.updateHud(); this.updateHud();
} }
private getCameraFocus() { private getCameraFocus() {
return this.cameraFocus; return this.cameraFocus;
} }
private syncActiveFleetFromSelection() {
if (this.selection.length === 0) {
return;
}
const firstFleetId = this.selection[0]?.fleetId;
if (firstFleetId && this.selection.every((ship) => ship.fleetId === firstFleetId)) {
this.activeFleetId = firstFleetId;
}
}
} }

View File

@@ -1,212 +0,0 @@
import * as THREE from "three";
import type { FleetBehavior, FleetInstance, FleetWingInstance, ShipInstance } from "../types";
interface FleetBuildSpec {
id: string;
label: string;
stance: FleetInstance["stance"];
systemId: string;
factionId?: string;
commander: ShipInstance;
wings: Array<{
id: string;
label: string;
behavior: FleetBehavior;
parentWingId?: string;
ships: ShipInstance[];
}>;
}
export function createDefaultFleets(ships: ShipInstance[]) {
clearFleetAssignments(ships);
const specs: FleetBuildSpec[] = [];
const factionIds = [...new Set(ships.map((ship) => ship.factionId))];
factionIds.forEach((factionId) => {
const factionShips = ships.filter((ship) => ship.factionId === factionId);
const military = factionShips.filter((ship) => ship.definition.role === "military");
const industrial = factionShips.filter((ship) => ship.definition.role !== "military");
const systems = [...new Set(factionShips.map((ship) => ship.systemId))];
systems.forEach((systemId) => {
const localMilitary = military.filter((ship) => ship.systemId === systemId);
if (localMilitary.length === 0) {
return;
}
const commander =
localMilitary.find((ship) => ship.definition.shipClass === "capital") ??
localMilitary.find((ship) => ship.definition.shipClass === "cruiser") ??
localMilitary[0];
const lineShips = localMilitary.filter((ship) => ship.id !== commander.id);
specs.push({
id: `${factionId}:${systemId}:warfleet`,
label: `${commander.factionId} War Fleet`,
stance: factionId.includes("pirate") || factionId.includes("flag") || factionId.includes("rats") ? "balanced" : "defensive",
systemId,
factionId,
commander,
wings: [
{ id: "command", label: "Command Wing", behavior: "command", ships: [commander] },
{ id: "screen", label: "Screen Wing", behavior: "screen", parentWingId: "command", ships: lineShips },
],
});
});
const miners = industrial.filter((ship) => ship.definition.role === "mining");
const haulers = industrial.filter((ship) => ship.definition.role === "transport");
const logisticsCommander = haulers[0] ?? miners[0];
if (logisticsCommander) {
specs.push({
id: `${factionId}:industry`,
label: `${logisticsCommander.factionId} Industry Group`,
stance: "industrial",
systemId: logisticsCommander.systemId,
factionId,
commander: logisticsCommander,
wings: [
{ id: "command", label: "Command Wing", behavior: "command", ships: [logisticsCommander] },
{
id: "miners",
label: "Mining Wing",
behavior: "mining",
parentWingId: "command",
ships: miners.filter((ship) => ship.id !== logisticsCommander.id),
},
{
id: "transport",
label: "Transport Wing",
behavior: "logistics",
parentWingId: "command",
ships: haulers.filter((ship) => ship.id !== logisticsCommander.id),
},
],
});
}
});
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,
factionId: spec.factionId,
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

@@ -2,8 +2,6 @@ import * as THREE from "three";
export type ShipRole = "military" | "transport" | "mining"; export type ShipRole = "military" | "transport" | "mining";
export type ShipClass = "frigate" | "destroyer" | "cruiser" | "industrial" | "capital"; export type 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" | "debug"; export type GameWindowId = "fleet-command" | "ship-designer" | "station-manager" | "debug";
export type FactionKind = "empire" | "pirate"; export type FactionKind = "empire" | "pirate";
export type ConstructibleCategory = export type ConstructibleCategory =
@@ -168,12 +166,6 @@ export interface PatrolRouteDefinition {
points: [number, number, number][]; points: [number, number, number][];
} }
export type FleetOrder =
| { kind: "idle" }
| { kind: "move"; destination: THREE.Vector3; systemId: string }
| { kind: "patrol"; points: THREE.Vector3[]; systemId: string; index: number }
| { kind: "mine"; nodeSystemId: string; refinerySystemId: string };
export interface ScenarioDefinition { export interface ScenarioDefinition {
initialStations: InitialStationDefinition[]; initialStations: InitialStationDefinition[];
shipFormations: ShipFormationDefinition[]; shipFormations: ShipFormationDefinition[];
@@ -271,28 +263,6 @@ export interface TravelPlan {
arrivalPoint: THREE.Vector3; arrivalPoint: THREE.Vector3;
} }
export interface FleetWingInstance {
id: string;
fleetId: string;
label: string;
behavior: FleetBehavior;
parentWingId?: string;
leaderShipId: string;
shipIds: string[];
}
export interface FleetInstance {
id: string;
label: string;
stance: FleetStance;
commanderShipId: string;
systemId: string;
factionId?: string;
shipIds: string[];
wings: FleetWingInstance[];
order: FleetOrder;
}
export interface ShipInstance { export interface ShipInstance {
id: string; id: string;
definition: ShipDefinition; definition: ShipDefinition;
@@ -326,12 +296,6 @@ export interface ShipInstance {
idleOrbitRadius: number; idleOrbitRadius: number;
idleOrbitAngle: number; idleOrbitAngle: number;
warpFx: THREE.Group; warpFx: THREE.Group;
fleetId?: string;
wingId?: string;
behavior: FleetBehavior | "independent";
isFleetCommander: boolean;
isWingLeader: boolean;
formationOffset: THREE.Vector3;
dockedShipIds: Set<string>; dockedShipIds: Set<string>;
dockingPorts: THREE.Vector3[]; dockingPorts: THREE.Vector3[];
} }
@@ -425,6 +389,5 @@ export interface HudElements {
fleetWindow: HTMLDivElement; fleetWindow: HTMLDivElement;
fleetWindowBody: HTMLDivElement; fleetWindowBody: HTMLDivElement;
fleetWindowTitle: HTMLHeadingElement; fleetWindowTitle: HTMLHeadingElement;
fleetWindowSubtitle: HTMLParagraphElement;
debugWindow: HTMLDivElement; debugWindow: HTMLDivElement;
} }

View File

@@ -1,7 +1,6 @@
import type { HudElements } from "../types"; import type { HudElements } from "../types";
interface HudHandlers { interface HudHandlers {
onWindowAction: (action: string) => void; onWindowAction: (action: string) => void;
onFleetAction: (action: string, fleetId?: string) => void;
onSelectionAction: (kind: string, id: string) => void; onSelectionAction: (kind: string, id: string) => void;
} }
@@ -22,16 +21,8 @@ export function createHud(container: HTMLElement, handlers: HudHandlers): HudEle
<section class="app-window fleet-window" data-window-id="fleet-command"> <section class="app-window fleet-window" data-window-id="fleet-command">
<div class="window-header"> <div class="window-header">
<div> <div>
<h2>Fleet Command</h2> <h2>Ships</h2>
<p class="window-subtitle">No fleet selected</p>
</div> </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>
<div class="window-body fleet-window-body"></div> <div class="window-body fleet-window-body"></div>
<div class="window-resize-handle" aria-hidden="true"></div> <div class="window-resize-handle" aria-hidden="true"></div>
@@ -59,24 +50,17 @@ export function createHud(container: HTMLElement, handlers: HudHandlers): HudEle
root.querySelectorAll<HTMLButtonElement>("[data-window-action]").forEach((button) => { root.querySelectorAll<HTMLButtonElement>("[data-window-action]").forEach((button) => {
button.addEventListener("click", () => handlers.onWindowAction(button.dataset.windowAction ?? "")); 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"); const fleetWindowBody = root.querySelector<HTMLDivElement>(".fleet-window-body");
fleetWindowBody?.addEventListener("click", (event) => { fleetWindowBody?.addEventListener("click", (event) => {
const mouseEvent = event as MouseEvent;
const target = event.target as HTMLElement; const target = event.target as HTMLElement;
const selectionNode = target.closest<HTMLElement>("[data-select-kind][data-select-id]"); const selectionNode = target.closest<HTMLElement>("[data-select-kind][data-select-id]");
if (selectionNode) { if (selectionNode) {
handlers.onSelectionAction(selectionNode.dataset.selectKind ?? "", selectionNode.dataset.selectId ?? ""); const kind = selectionNode.dataset.selectKind ?? "";
return; const id = selectionNode.dataset.selectId ?? "";
} handlers.onSelectionAction(mouseEvent.detail >= 2 ? `focus-${kind}` : kind, id);
const fleetButton = target.closest<HTMLButtonElement>("[data-fleet-id]");
if (fleetButton?.dataset.fleetId) {
handlers.onFleetAction("select", fleetButton.dataset.fleetId);
} }
}); });
const minimap = root.querySelector<HTMLCanvasElement>(".minimap"); const minimap = root.querySelector<HTMLCanvasElement>(".minimap");
const minimapContext = minimap?.getContext("2d"); const minimapContext = minimap?.getContext("2d");
if (!minimap || !minimapContext) { if (!minimap || !minimapContext) {
@@ -104,7 +88,6 @@ export function createHud(container: HTMLElement, handlers: HudHandlers): HudEle
fleetWindow: root.querySelector(".fleet-window") as HTMLDivElement, fleetWindow: root.querySelector(".fleet-window") as HTMLDivElement,
fleetWindowBody: fleetWindowBody as HTMLDivElement, fleetWindowBody: fleetWindowBody as HTMLDivElement,
fleetWindowTitle: root.querySelector(".fleet-window h2") as HTMLHeadingElement, fleetWindowTitle: root.querySelector(".fleet-window h2") as HTMLHeadingElement,
fleetWindowSubtitle: root.querySelector(".window-subtitle") as HTMLParagraphElement,
debugWindow: root.querySelector(".debug-window") as HTMLDivElement, debugWindow: root.querySelector(".debug-window") as HTMLDivElement,
}; };
} }

View File

@@ -3,11 +3,9 @@ import {
moduleDefinitionsById, moduleDefinitionsById,
recipeDefinitions, recipeDefinitions,
} from "../data/catalog"; } from "../data/catalog";
import { describeFleetOrder } from "../fleet/runtime";
import { getShipCargoAmount } from "../state/inventory"; import { getShipCargoAmount } from "../state/inventory";
import type { import type {
FactionInstance, FactionInstance,
FleetInstance,
PlanetInstance, PlanetInstance,
ShipInstance, ShipInstance,
SolarSystemInstance, SolarSystemInstance,
@@ -122,7 +120,6 @@ export function getSelectionDetails(
systems: SolarSystemInstance[], systems: SolarSystemInstance[],
viewLevel: ViewLevel, viewLevel: ViewLevel,
ships: ShipInstance[], ships: ShipInstance[],
fleets: FleetInstance[],
factions: FactionInstance[], factions: FactionInstance[],
) { ) {
if (selectedPlanet) { if (selectedPlanet) {
@@ -132,7 +129,7 @@ export function getSelectionDetails(
return `${selectedSystem.definition.label}\nType: ${selectedSystem.strategicValue}\nControl: ${selectedSystem.controllingFactionId ?? "Contested"} ${Math.round(selectedSystem.controlProgress)}%\nPlanets: ${selectedSystem.planets.length}\nResource Nodes: ${selectedSystem.definition.resourceNodes.length}\nGravity Well: ${Math.round(selectedSystem.gravityWellRadius)}`; return `${selectedSystem.definition.label}\nType: ${selectedSystem.strategicValue}\nControl: ${selectedSystem.controllingFactionId ?? "Contested"} ${Math.round(selectedSystem.controlProgress)}%\nPlanets: ${selectedSystem.planets.length}\nResource Nodes: ${selectedSystem.definition.resourceNodes.length}\nGravity Well: ${Math.round(selectedSystem.gravityWellRadius)}`;
} }
if (selectedStation) { if (selectedStation) {
return describeStation(selectedStation, ships, fleets); return describeStation(selectedStation, ships);
} }
if (selection.length === 0) { if (selection.length === 0) {
const central = systems const central = systems
@@ -146,7 +143,7 @@ export function getSelectionDetails(
`${faction.definition.label}: systems ${faction.ownedSystemIds.size} • mined ${Math.round(faction.oreMined)} • built ${faction.shipsBuilt} ships • losses ${faction.shipsLost}`, `${faction.definition.label}: systems ${faction.ownedSystemIds.size} • mined ${Math.round(faction.oreMined)} • built ${faction.shipsBuilt} ships • losses ${faction.shipsLost}`,
) )
.join("\n"); .join("\n");
return `Observer Mode\nSystems online: ${systems.length}\nFleets tracked: ${fleets.length}\nView: ${viewLevel}\n\nCentral systems:\n${central}\n\nEmpires:\n${factionLines}`; return `Systems online: ${systems.length}\nShips tracked: ${ships.length}\nView: ${viewLevel}\n\nCentral systems:\n${central}\n\nEmpires:\n${factionLines}`;
} }
return selection return selection
@@ -157,17 +154,17 @@ export function getSelectionDetails(
ship.definition.dockingCapacity && ship.definition.dockingCapacity > 0 ship.definition.dockingCapacity && ship.definition.dockingCapacity > 0
? `\nHangar: ${ship.dockedShipIds.size}/${ship.definition.dockingCapacity} for ${(ship.definition.dockingClasses ?? []).join(", ")}` ? `\nHangar: ${ship.dockedShipIds.size}/${ship.definition.dockingCapacity} for ${(ship.definition.dockingClasses ?? []).join(", ")}`
: ""; : "";
return `${ship.definition.label}${ship.systemId}\nFaction: ${ship.factionId}\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}\nHealth: ${Math.round(ship.health)}/${ship.maxHealth}\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(", ")}`; return `${ship.definition.label}${ship.systemId}\nFaction: ${ship.factionId}\nClass: ${ship.definition.shipClass}\nState: ${ship.state}${dockedAt ? ` @ ${dockedAt}` : ""}\nOrder: ${ship.order.kind}\nHealth: ${Math.round(ship.health)}/${ship.maxHealth}\nCargo: ${Math.round(getShipCargoAmount(ship))}/${ship.definition.cargoCapacity || 0} ${getItemLabel(ship.cargoItemId)}\nFuel: ${ship.fuel.toFixed(0)}/${ship.maxFuel}\nEnergy: ${ship.energy.toFixed(0)}/${ship.maxEnergy}\nHold Type: ${ship.definition.cargoKind ?? "none"}${hangarStatus}\nModules: ${ship.definition.modules.map(getModuleLabel).join(", ")}`;
}, },
) )
.join("\n\n"); .join("\n\n");
} }
export function describeStation(station: StationInstance, ships: ShipInstance[], fleets: FleetInstance[]) { export function describeStation(station: StationInstance, ships: ShipInstance[]) {
const miners = ships.filter((ship) => ship.systemId === station.systemId && ship.order.kind === "mine").length; const miners = ships.filter((ship) => ship.systemId === station.systemId && ship.order.kind === "mine").length;
const escorts = ships.filter((ship) => ship.systemId === station.systemId && ship.order.kind === "escort").length; const escorts = ships.filter((ship) => ship.systemId === station.systemId && ship.order.kind === "escort").length;
const patrols = ships.filter((ship) => ship.systemId === station.systemId && ship.order.kind === "patrol").length; const patrols = ships.filter((ship) => ship.systemId === station.systemId && ship.order.kind === "patrol").length;
const localFleets = fleets.filter((fleet) => fleet.systemId === station.systemId).length; const localShips = ships.filter((ship) => ship.systemId === station.systemId).length;
const activeRecipe = station.activeRecipeId const activeRecipe = station.activeRecipeId
? recipeDefinitions.find((recipe) => recipe.id === station.activeRecipeId) ? recipeDefinitions.find((recipe) => recipe.id === station.activeRecipeId)
: undefined; : undefined;
@@ -189,101 +186,60 @@ export function describeStation(station: StationInstance, ships: ShipInstance[],
: station.definition.category === "farm" : station.definition.category === "farm"
? "Supplying agricultural goods and industrial consumables" ? "Supplying agricultural goods and industrial consumables"
: station.definition.category === "defense" : station.definition.category === "defense"
? `Coordinating ${escorts} escort wings` ? `Coordinating ${escorts} escort ships`
: station.definition.category === "gate" : station.definition.category === "gate"
? "Assembling transit infrastructure and gate components" ? "Assembling transit infrastructure and gate components"
: station.modules.includes("fabricator-array") : station.modules.includes("fabricator-array")
? "Fabricating industrial parts and equipment" ? "Fabricating industrial parts and equipment"
: "Managing local trade traffic"; : "Managing local trade traffic";
return `${station.definition.label}${station.systemId}\nFaction: ${station.factionId}\nRole: ${station.definition.category}\nActivity: ${activity}\nLocal Fleets: ${localFleets}\nDocking: ${station.dockedShipIds.size}/${station.definition.dockingCapacity}\nHealth: ${Math.round(station.health)}/${station.maxHealth}\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}`; return `${station.definition.label}${station.systemId}\nFaction: ${station.factionId}\nRole: ${station.definition.category}\nActivity: ${activity}\nLocal Ships: ${localShips}\nDocking: ${station.dockedShipIds.size}/${station.definition.dockingCapacity}\nHealth: ${Math.round(station.health)}/${station.maxHealth}\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( export function getShipWindowMarkup(ships: ShipInstance[], selection: ShipInstance[]) {
fleets: FleetInstance[], if (ships.length === 0) {
shipsById: Map<string, ShipInstance>, return `<div class="ship-window-empty">No ships online.</div>`;
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)); const selectedShipIds = new Set(selection.map((ship) => ship.id));
const sortedShips = [...ships]
.sort((left, right) =>
left.factionId.localeCompare(right.factionId) ||
left.systemId.localeCompare(right.systemId) ||
left.definition.label.localeCompare(right.definition.label) ||
left.id.localeCompare(right.id),
);
return fleets const shipsByFaction = new Map<string, ShipInstance[]>();
.map((fleet) => { sortedShips.forEach((ship) => {
const commander = shipsById.get(fleet.commanderShipId); const bucket = shipsByFaction.get(ship.factionId) ?? [];
const rootWings = fleet.wings.filter((wing) => !wing.parentWingId); bucket.push(ship);
const tree = rootWings.map((wing) => renderWingNode(fleet, wing.id, shipsById, selectedShipIds)).join(""); shipsByFaction.set(ship.factionId, bucket);
const fleetSelected = fleet.shipIds.length > 0 && fleet.shipIds.every((shipId) => selectedShipIds.has(shipId)); });
return ` return [...shipsByFaction.entries()]
<article class="fleet-card" data-active="${fleet.id === activeFleetId}"> .map(
<button type="button" class="fleet-select" data-select-kind="fleet" data-select-id="${fleet.id}">${fleet.label}</button> ([factionId, factionShips]) => `
<div class="fleet-tree"> <section class="ship-window-group">
<div class="fleet-tree-root" data-select-kind="fleet" data-select-id="${fleet.id}" data-selected="${fleetSelected}"> <h3 class="ship-window-group-title" data-select-kind="faction" data-select-id="${factionId}">${factionId}</h3>
<span class="fleet-card-title">${commander?.definition.label ?? fleet.commanderShipId}</span> ${factionShips
<span class="fleet-card-line">Commander • ${fleet.shipIds.length} ships • ${fleet.systemId}</span> .map(
<span class="fleet-card-line">Fleet Order: ${describeFleetOrder(fleet)}</span> (ship) => `
<span class="fleet-card-line">Stance: ${fleet.stance}</span> <div class="ship-window-row" data-select-kind="ship" data-select-id="${ship.id}" data-selected="${selectedShipIds.has(ship.id)}">
</div> <span class="ship-window-name">${ship.definition.label}</span>
<div class="fleet-tree-children">${tree}</div> <span class="ship-window-meta">${describeShipNode(ship)}</span>
</div> </div>
</article> `,
`; )
}) .join("")}
</section>
`,
)
.join(""); .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 { function describeShipNode(ship: ShipInstance): string {
return `${ship.definition.shipClass}${ship.state} ${ship.order.kind}${ship.behavior}`; return `${ship.state} - ${ship.systemId}`;
} }
function renderCard(title: string, lines: string[]) { function renderCard(title: string, lines: string[]) {
@@ -295,23 +251,6 @@ function renderCard(title: string, lines: string[]) {
`; `;
} }
function collectWingShipIds(fleet: FleetInstance, rootWingId: string): string[] {
const wingIds = new Set<string>([rootWingId]);
let changed = true;
while (changed) {
changed = false;
fleet.wings.forEach((wing) => {
if (wing.parentWingId && wingIds.has(wing.parentWingId) && !wingIds.has(wing.id)) {
wingIds.add(wing.id);
changed = true;
}
});
}
return fleet.wings.filter((wing) => wingIds.has(wing.id)).flatMap((wing) => wing.shipIds);
}
export function getItemLabel(itemId?: string) { export function getItemLabel(itemId?: string) {
return itemId ? itemDefinitionsById.get(itemId)?.label ?? itemId : "None"; return itemId ? itemDefinitionsById.get(itemId)?.label ?? itemId : "None";
} }

View File

@@ -1,6 +1,5 @@
import * as THREE from "three"; import * as THREE from "three";
import { getFleetCommander, getWingLeader } from "../fleet/runtime"; import type { ShipRole, ShipInstance, SolarSystemInstance, StationInstance, ViewLevel } from "../types";
import type { FleetInstance, ShipRole, ShipInstance, SolarSystemInstance, StationInstance, ViewLevel } from "../types";
interface RenderMinimapOptions { interface RenderMinimapOptions {
context: CanvasRenderingContext2D; context: CanvasRenderingContext2D;
@@ -12,8 +11,6 @@ interface RenderMinimapOptions {
selection: ShipInstance[]; selection: ShipInstance[];
selectedStation?: StationInstance; selectedStation?: StationInstance;
cameraFocus: THREE.Vector3; cameraFocus: THREE.Vector3;
fleets: FleetInstance[];
activeFleetId?: string;
} }
interface RenderOverlayOptions { interface RenderOverlayOptions {
@@ -28,8 +25,6 @@ interface RenderOverlayOptions {
selectedStation?: StationInstance; selectedStation?: StationInstance;
selectedSystemIndex: number; selectedSystemIndex: number;
viewLevel: ViewLevel; viewLevel: ViewLevel;
fleets: FleetInstance[];
activeFleetId?: string;
} }
export function drawMinimap({ export function drawMinimap({
@@ -42,8 +37,6 @@ export function drawMinimap({
selection, selection,
selectedStation, selectedStation,
cameraFocus, cameraFocus,
fleets,
activeFleetId,
}: RenderMinimapOptions) { }: RenderMinimapOptions) {
context.clearRect(0, 0, width, height); context.clearRect(0, 0, width, height);
context.fillStyle = "rgba(4, 9, 20, 0.92)"; context.fillStyle = "rgba(4, 9, 20, 0.92)";
@@ -89,16 +82,6 @@ export function drawMinimap({
context.fill(); context.fill();
}); });
fleets.forEach((fleet) => {
const commander = getFleetCommander(fleet, new Map(ships.map((ship) => [ship.id, ship])));
if (!commander) {
return;
}
const point = mapPoint(commander.group.position);
context.strokeStyle = fleet.id === activeFleetId ? "#ffbf69" : "rgba(126, 212, 255, 0.42)";
context.strokeRect(point.x - 6, point.y - 6, 12, 12);
});
const focus = mapPoint(cameraFocus); const focus = mapPoint(cameraFocus);
context.strokeStyle = "rgba(255,255,255,0.7)"; context.strokeStyle = "rgba(255,255,255,0.7)";
context.strokeRect(focus.x - 9, focus.y - 9, 18, 18); context.strokeRect(focus.x - 9, focus.y - 9, 18, 18);
@@ -116,8 +99,6 @@ export function drawStrategicOverlay({
selectedStation, selectedStation,
selectedSystemIndex, selectedSystemIndex,
viewLevel, viewLevel,
fleets,
activeFleetId,
}: RenderOverlayOptions) { }: RenderOverlayOptions) {
context.clearRect(0, 0, width, height); context.clearRect(0, 0, width, height);
if (viewLevel === "local") { if (viewLevel === "local") {
@@ -132,8 +113,6 @@ export function drawStrategicOverlay({
context.textBaseline = "middle"; context.textBaseline = "middle";
if (viewLevel === "solar") { if (viewLevel === "solar") {
drawFleetLinks(context, camera, fleets, ships, systems[selectedSystemIndex]?.definition.id, activeFleetId);
selection.forEach((ship) => { selection.forEach((ship) => {
const screen = projectWorldToScreen(ship.group.position, camera); const screen = projectWorldToScreen(ship.group.position, camera);
if (screen) { if (screen) {
@@ -172,7 +151,7 @@ export function drawStrategicOverlay({
if (!bucket || bucket.length === 0) { if (!bucket || bucket.length === 0) {
return; return;
} }
drawFleetSymbol(context, screen.x - 52 + index * 52, screen.y + 32, role, bucket.length, bucket.some((ship) => selection.includes(ship))); drawRoleSymbol(context, screen.x - 52 + index * 52, screen.y + 32, role, bucket.length, bucket.some((ship) => selection.includes(ship)));
}); });
const stationCount = stations.filter((station) => station.systemId === system.definition.id).length; const stationCount = stations.filter((station) => station.systemId === system.definition.id).length;
@@ -183,12 +162,6 @@ export function drawStrategicOverlay({
drawStrategicStationGroup(context, screen.x, screen.y - 38, stationCount, stationSelected); drawStrategicStationGroup(context, screen.x, screen.y - 38, stationCount, stationSelected);
} }
const activeFleets = fleets.filter((fleet) => fleet.systemId === system.definition.id).length;
if (activeFleets > 0) {
context.fillStyle = "rgba(255, 191, 105, 0.92)";
context.font = "600 10px Space Grotesk, sans-serif";
context.fillText(`${activeFleets} FLEET${activeFleets > 1 ? "S" : ""}`, screen.x, screen.y + 58);
}
}); });
} }
@@ -221,7 +194,7 @@ function drawSystemFrame(context: CanvasRenderingContext2D, x: number, y: number
context.fillText(label.toUpperCase(), x, y - 28); context.fillText(label.toUpperCase(), x, y - 28);
} }
function drawFleetSymbol( function drawRoleSymbol(
context: CanvasRenderingContext2D, context: CanvasRenderingContext2D,
x: number, x: number,
y: number, y: number,
@@ -284,54 +257,6 @@ function drawFleetSymbol(
context.restore(); context.restore();
} }
function drawFleetLinks(
context: CanvasRenderingContext2D,
camera: THREE.PerspectiveCamera,
fleets: FleetInstance[],
ships: ShipInstance[],
systemId: string | undefined,
activeFleetId: string | undefined,
) {
const shipsById = new Map(ships.map((ship) => [ship.id, ship]));
fleets
.filter((fleet) => !systemId || fleet.systemId === systemId)
.forEach((fleet) => {
const commander = getFleetCommander(fleet, shipsById);
if (!commander) {
return;
}
const commanderScreen = projectWorldToScreen(commander.group.position, camera);
if (!commanderScreen) {
return;
}
const highlighted = fleet.id === activeFleetId;
context.strokeStyle = highlighted ? "rgba(255, 191, 105, 0.85)" : "rgba(126, 212, 255, 0.24)";
context.fillStyle = highlighted ? "#ffbf69" : "rgba(126, 212, 255, 0.72)";
context.lineWidth = highlighted ? 1.8 : 1.1;
fleet.wings.forEach((wing) => {
const leader = getWingLeader(wing, shipsById);
if (!leader || leader.id === commander.id) {
return;
}
const leaderScreen = projectWorldToScreen(leader.group.position, camera);
if (!leaderScreen) {
return;
}
context.beginPath();
context.moveTo(commanderScreen.x, commanderScreen.y);
context.lineTo(leaderScreen.x, leaderScreen.y);
context.stroke();
context.beginPath();
context.arc(leaderScreen.x, leaderScreen.y, highlighted ? 4.5 : 3, 0, Math.PI * 2);
context.fill();
});
});
}
function drawStrategicStationGroup( function drawStrategicStationGroup(
context: CanvasRenderingContext2D, context: CanvasRenderingContext2D,
x: number, x: number,

View File

@@ -118,48 +118,31 @@ function createScenario(systems: SolarSystemDefinition[], factions: FactionDefin
} }
initialStations.push( initialStations.push(
{ constructibleId: "trade-hub", systemId: capital.id, factionId: faction.id, planetIndex: 1, lagrangeSide: 1 },
{
constructibleId: "farm-ring",
systemId: capital.id,
factionId: faction.id,
planetIndex: 0,
lagrangeSide: -1,
seedStock: { gas: 120, water: 160 },
},
{ {
constructibleId: "manufactory", constructibleId: "manufactory",
systemId: capital.id, systemId: capital.id,
factionId: faction.id, factionId: faction.id,
planetIndex: Math.min(2, capital.planets.length - 1), planetIndex: Math.min(1, capital.planets.length - 1),
lagrangeSide: 1, lagrangeSide: 1,
seedStock: { "refined-metals": 200, water: 100, "ship-equipment": 40, "naval-guns": 24 }, seedStock: { water: 40 },
}, },
{
constructibleId: "shipyard",
systemId: capital.id,
factionId: faction.id,
planetIndex: Math.min(3, capital.planets.length - 1),
lagrangeSide: -1,
seedStock: { "ship-parts": 80, "ammo-crates": 70, "hull-sections": 100, "ship-equipment": 40 },
},
{ constructibleId: "defense-grid", systemId: capital.id, factionId: faction.id, planetIndex: 1, lagrangeSide: -1 },
{ {
constructibleId: "refinery", constructibleId: "refinery",
systemId: mining.id, systemId: mining.id,
factionId: faction.id, factionId: faction.id,
planetIndex: 0, planetIndex: 0,
lagrangeSide: 1, lagrangeSide: 1,
seedStock: { ore: 240, "refined-metals": 80 }, seedStock: { ore: 60 },
}, },
{ constructibleId: "defense-grid", systemId: mining.id, factionId: faction.id, planetIndex: 1, lagrangeSide: -1 },
); );
shipFormations.push( shipFormations.push({
{ shipId: "frigate", count: 1, center: localPoint(capital, 180, 120), systemId: capital.id, factionId: faction.id }, shipId: "miner",
{ shipId: "hauler", count: 1, center: localPoint(capital, 280, -120), systemId: capital.id, factionId: faction.id }, count: 1,
{ shipId: "miner", count: 1, center: localPoint(mining, 180, 100), systemId: mining.id, factionId: faction.id }, center: localPoint(mining, 180, 100),
); systemId: mining.id,
factionId: faction.id,
});
patrolRoutes.push(createPatrolRoute(capital), createPatrolRoute(mining)); patrolRoutes.push(createPatrolRoute(capital), createPatrolRoute(mining));
}); });
@@ -176,15 +159,16 @@ function createScenario(systems: SolarSystemDefinition[], factions: FactionDefin
factionId: faction.id, factionId: faction.id,
planetIndex: 0, planetIndex: 0,
lagrangeSide: 1, lagrangeSide: 1,
seedStock: { "refined-metals": 100, "ship-parts": 30, "ammo-crates": 30 }, seedStock: { "refined-metals": 60, "ammo-crates": 12 },
}, },
{ constructibleId: "defense-grid", systemId: base.id, factionId: faction.id, planetIndex: 1, lagrangeSide: -1 },
);
shipFormations.push(
{ shipId: "frigate", count: 4, center: localPoint(base, 180, 60), systemId: base.id, factionId: faction.id },
{ shipId: "destroyer", count: 2, center: localPoint(base, 250, 120), systemId: base.id, factionId: faction.id },
{ shipId: "hauler", count: 1, center: localPoint(base, 320, -90), systemId: base.id, factionId: faction.id },
); );
shipFormations.push({
shipId: "frigate",
count: 1,
center: localPoint(base, 180, 60),
systemId: base.id,
factionId: faction.id,
});
patrolRoutes.push(createPatrolRoute(base)); patrolRoutes.push(createPatrolRoute(base));
}); });

View File

@@ -714,10 +714,6 @@ export function createShipInstance({
idleOrbitRadius: Math.max(120, group.position.length()), idleOrbitRadius: Math.max(120, group.position.length()),
idleOrbitAngle: 0, idleOrbitAngle: 0,
warpFx, warpFx,
behavior: "independent",
isFleetCommander: false,
isWingLeader: false,
formationOffset: new THREE.Vector3(),
dockedShipIds: new Set(), dockedShipIds: new Set(),
dockingPorts, dockingPorts,
}; };

View File

@@ -161,8 +161,7 @@ canvas {
letter-spacing: 0.14em; letter-spacing: 0.14em;
} }
.window-launchers, .window-launchers {
.fleet-actions {
display: grid; display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px; gap: 10px;
@@ -174,7 +173,6 @@ canvas {
} }
.window-launchers button, .window-launchers button,
.fleet-actions button,
.session-actions button, .session-actions button,
.window-close { .window-close {
border: 1px solid rgba(126, 212, 255, 0.16); border: 1px solid rgba(126, 212, 255, 0.16);
@@ -191,7 +189,6 @@ canvas {
} }
.window-launchers button:hover, .window-launchers button:hover,
.fleet-actions button:hover,
.session-actions button:hover, .session-actions button:hover,
.window-close:hover { .window-close:hover {
border-color: rgba(126, 212, 255, 0.4); border-color: rgba(126, 212, 255, 0.4);
@@ -272,10 +269,6 @@ button:disabled {
cursor: pointer; cursor: pointer;
} }
.fleet-actions {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.window-body { .window-body {
overflow: auto; overflow: auto;
display: flex; display: flex;
@@ -285,6 +278,59 @@ button:disabled {
min-height: 0; min-height: 0;
} }
.ship-window-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
padding: 8px 10px;
border-bottom: 1px solid rgba(126, 212, 255, 0.1);
color: var(--muted);
cursor: pointer;
}
.ship-window-row:hover {
color: var(--text);
}
.ship-window-row[data-selected="true"] {
color: #ffbf69;
}
.ship-window-name {
color: inherit;
font-weight: 600;
line-height: 1.35;
}
.ship-window-meta {
color: inherit;
line-height: 1.35;
text-align: right;
font-size: 0.92em;
}
.ship-window-group + .ship-window-group {
margin-top: 10px;
}
.ship-window-group-title {
margin: 0 0 6px;
color: var(--text);
font-size: 0.8rem;
letter-spacing: 0.12em;
text-transform: uppercase;
cursor: pointer;
}
.ship-window-group-title:hover {
color: #ffbf69;
}
.ship-window-empty {
color: var(--muted);
}
.window-resize-handle { .window-resize-handle {
position: absolute; position: absolute;
right: 10px; right: 10px;
@@ -317,141 +363,6 @@ button:disabled {
height: 6px; height: 6px;
} }
.fleet-card {
border: 1px solid rgba(126, 212, 255, 0.14);
border-radius: 14px;
padding: 14px;
background:
linear-gradient(180deg, rgba(8, 18, 35, 0.84), rgba(5, 11, 22, 0.8)),
repeating-linear-gradient(
90deg,
rgba(126, 212, 255, 0.02) 0,
rgba(126, 212, 255, 0.02) 1px,
transparent 1px,
transparent 14px
);
}
.fleet-card[data-active="true"] {
border-color: rgba(255, 191, 105, 0.44);
box-shadow: inset 0 0 0 1px rgba(255, 191, 105, 0.16);
}
.fleet-select {
width: 100%;
margin-bottom: 10px;
}
.fleet-card-title,
.fleet-card-line,
.fleet-wing-line {
display: block;
color: var(--muted);
line-height: 1.45;
}
.fleet-card-title {
color: var(--text);
font-weight: 600;
margin-bottom: 4px;
}
.fleet-wing-line {
color: #bdd9ea;
}
.fleet-tree,
.fleet-tree-children {
display: flex;
flex-direction: column;
gap: 10px;
}
.fleet-tree-root,
.fleet-node-card {
position: relative;
border: 1px solid rgba(126, 212, 255, 0.14);
border-radius: 12px;
padding: 10px 12px;
background: linear-gradient(180deg, rgba(9, 20, 38, 0.88), rgba(5, 12, 24, 0.84));
cursor: pointer;
}
.fleet-tree-root::before,
.fleet-node-card::before {
content: "";
position: absolute;
left: -1px;
top: -1px;
bottom: -1px;
width: 3px;
border-radius: 12px 0 0 12px;
background: rgba(126, 212, 255, 0.48);
}
.fleet-tree-node {
position: relative;
margin-left: 18px;
padding-left: 18px;
}
.fleet-tree-node::before {
content: "";
position: absolute;
left: 0;
top: 0;
bottom: 18px;
width: 1px;
background: rgba(126, 212, 255, 0.18);
}
.fleet-tree-node::after {
content: "";
position: absolute;
left: 0;
top: 18px;
width: 14px;
height: 1px;
background: rgba(126, 212, 255, 0.18);
}
.ship-node .fleet-node-card::before {
background: rgba(255, 191, 105, 0.44);
}
.fleet-node-title,
.fleet-node-meta {
display: block;
}
.fleet-node-title {
color: var(--text);
font-weight: 600;
line-height: 1.35;
}
.fleet-node-meta {
color: var(--muted);
line-height: 1.4;
margin-top: 2px;
}
.fleet-tree-root:hover,
.fleet-node-card:hover {
border-color: rgba(126, 212, 255, 0.32);
}
.fleet-tree-root[data-selected="true"],
.fleet-node-card[data-selected="true"] {
border-color: rgba(255, 191, 105, 0.46);
box-shadow: inset 0 0 0 1px rgba(255, 191, 105, 0.16);
}
.fleet-tree-root[data-selected="true"]::before,
.fleet-node-card[data-selected="true"]::before {
background: rgba(255, 191, 105, 0.72);
}
@media (max-width: 900px) { @media (max-width: 900px) {
.details { .details {
width: auto; width: auto;
@@ -463,8 +374,7 @@ button:disabled {
bottom: 16px; bottom: 16px;
} }
.window-launchers, .window-launchers {
.fleet-actions {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }