Files
space-game/src/game/GameApp.ts

2576 lines
90 KiB
TypeScript

import * as THREE from "three";
import {
constructibleDefinitions,
gameBalance,
itemDefinitionsById,
recipeDefinitions,
shipDefinitionsById,
} from "./data/catalog";
import {
createDefaultFleets,
describeFleetOrder,
getFleetCommander,
getFleetShipIds,
getWingLeader,
getWingMembers,
} from "./fleet/runtime";
import { addShipCargo, getShipCargoAmount, removeShipCargo } from "./state/inventory";
import { SelectionManager } from "./state/selectionManager";
import type {
FactionInstance,
FleetInstance,
FleetWingInstance,
GameWindowId,
ResourceNode,
SelectableTarget,
ShipInstance,
SolarSystemInstance,
StationInstance,
TravelPlan,
UnitState,
UniverseDefinition,
ViewLevel,
} from "./types";
import { createHud } from "./ui/hud";
import { getFleetWindowMarkup, getSelectionCardsMarkup, getSelectionDetails, getSelectionTitle } from "./ui/presenters";
import { drawMinimap, drawStrategicOverlay } from "./ui/strategicRenderer";
import { buildInitialWorld, createShipInstance, createStationInstance } from "./world/worldFactory";
import { generateUniverse } from "./world/universeGenerator";
const MOVING_STATES = new Set<UnitState>([
"moving",
"mining-approach",
"delivering",
"docking-approach",
"docking",
"undocking",
"leaving-gravity-well",
"arriving",
"patrolling",
"escorting",
]);
type DockingHost = StationInstance | ShipInstance;
export class GameApp {
private readonly container: HTMLElement;
private readonly renderer: THREE.WebGLRenderer;
private readonly scene = new THREE.Scene();
private readonly camera = new THREE.PerspectiveCamera(54, 1, 0.1, 24000);
private readonly clock = new THREE.Clock();
private readonly raycaster = new THREE.Raycaster();
private readonly mouse = new THREE.Vector2();
private readonly movePlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -gameBalance.yPlane);
private readonly keyState = new Set<string>();
private readonly cameraFocus = new THREE.Vector3();
private readonly selectableTargets = new Map<THREE.Object3D, SelectableTarget>();
private readonly ships: ShipInstance[] = [];
private readonly shipsById = new Map<string, ShipInstance>();
private readonly stations: StationInstance[] = [];
private readonly nodes: ResourceNode[] = [];
private readonly systems: SolarSystemInstance[] = [];
private readonly fleets: FleetInstance[] = [];
private readonly fleetsById = new Map<string, FleetInstance>();
private readonly factions: FactionInstance[] = [];
private readonly factionsById = new Map<string, FactionInstance>();
private strategicLinks!: THREE.Group;
private starfield?: THREE.Points;
private buildMode = false;
private selectedConstructible = 0;
private selectedSystemIndex = 0;
private readonly selectionManager = new SelectionManager();
private followShipId?: string;
private viewLevel: ViewLevel = "local";
private marqueeStart?: THREE.Vector2;
private marqueeModifiers = { shift: false, ctrl: false };
private marqueeActive = false;
private suppressClickSelection = false;
private cameraDragMode?: "orbit" | "pan";
private cameraDragPointerId?: number;
private cameraDragLast?: THREE.Vector2;
private stationIdCounter = 0;
private activeFleetId?: string;
private readonly windowState: Record<GameWindowId, boolean> = {
"fleet-command": true,
"ship-designer": false,
"station-manager": false,
debug: false,
};
private readonly detailsEl: HTMLDivElement;
private readonly statusEl: HTMLDivElement;
private readonly selectionTitleEl: HTMLHeadingElement;
private readonly selectionStripEl: HTMLDivElement;
private readonly ordersEl: HTMLDivElement;
private readonly minimapEl: HTMLCanvasElement;
private readonly minimapContext: CanvasRenderingContext2D;
private readonly marqueeEl: HTMLDivElement;
private readonly strategicOverlayEl: HTMLCanvasElement;
private readonly strategicOverlayContext: CanvasRenderingContext2D;
private readonly fleetWindowEl: HTMLDivElement;
private readonly fleetWindowBodyEl: HTMLDivElement;
private readonly fleetWindowTitleEl: HTMLHeadingElement;
private readonly fleetWindowSubtitleEl: HTMLParagraphElement;
private readonly debugWindowEl: HTMLDivElement;
private readonly sessionActionsEl: HTMLDivElement;
private universe: UniverseDefinition;
private fleetRefreshNeeded = false;
constructor(container: HTMLElement) {
this.container = container;
this.universe = generateUniverse();
this.renderer = new THREE.WebGLRenderer({ antialias: true });
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
this.renderer.outputColorSpace = THREE.SRGBColorSpace;
this.renderer.shadowMap.enabled = true;
this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
this.scene.fog = new THREE.FogExp2(0x030811, 0.00006);
this.scene.background = new THREE.Color(0x02060d);
const initialSystem = this.universe.systems[0];
this.cameraFocus.set(...initialSystem.position);
this.camera.position.set(initialSystem.position[0] + 320, 260, initialSystem.position[2] + 300);
this.camera.lookAt(this.cameraFocus);
this.container.append(this.renderer.domElement);
const hud = createHud(this.container, {
onWindowAction: (action) => this.handleWindowAction(action),
onFleetAction: (action, fleetId) => this.handleFleetAction(action, fleetId),
onSelectionAction: (kind, id) => this.handleWindowSelection(kind, id),
});
this.detailsEl = hud.details;
this.statusEl = hud.status;
this.selectionTitleEl = hud.selectionTitle;
this.selectionStripEl = hud.selectionStrip;
this.ordersEl = hud.orders;
this.minimapEl = hud.minimap;
this.minimapContext = hud.minimapContext;
this.marqueeEl = hud.marquee;
this.strategicOverlayEl = hud.strategicOverlay;
this.strategicOverlayContext = hud.strategicOverlayContext;
this.fleetWindowEl = hud.fleetWindow;
this.fleetWindowBodyEl = hud.fleetWindowBody;
this.fleetWindowTitleEl = hud.fleetWindowTitle;
this.fleetWindowSubtitleEl = hud.fleetWindowSubtitle;
this.debugWindowEl = hud.debugWindow;
this.sessionActionsEl = hud.sessionActions;
this.setupScene();
this.bindEvents();
this.onResize();
this.updateHud();
}
start() {
this.renderer.setAnimationLoop(() => this.tick());
}
private get selection() {
return this.selectionManager.getShips();
}
private get selectedStation() {
return this.selectionManager.getStation();
}
private get selectedSystem() {
return this.selectionManager.getSystem();
}
private get selectedPlanet() {
return this.selectionManager.getPlanet();
}
private setupScene() {
const world = buildInitialWorld(this.scene, this.selectableTargets, this.universe.systems, this.universe.scenario);
this.systems.push(...world.systems);
this.nodes.push(...world.nodes);
this.stations.push(...world.stations);
this.ships.push(...world.ships);
world.shipsById.forEach((ship, id) => this.shipsById.set(id, ship));
this.strategicLinks = world.strategicLinks;
this.starfield = world.starfield;
this.stationIdCounter = this.stations.length;
this.initializeFactions();
this.initializeFleets();
this.applyViewLevel();
}
private generateNewUniverse() {
this.selectionManager.clear();
this.selectableTargets.clear();
this.ships.length = 0;
this.shipsById.clear();
this.stations.length = 0;
this.nodes.length = 0;
this.systems.length = 0;
this.fleets.length = 0;
this.fleetsById.clear();
this.factions.length = 0;
this.factionsById.clear();
this.followShipId = undefined;
this.activeFleetId = undefined;
this.buildMode = false;
this.fleetRefreshNeeded = false;
this.selectedSystemIndex = 0;
this.stationIdCounter = 0;
this.marqueeStart = undefined;
this.marqueeActive = false;
this.suppressClickSelection = false;
this.cameraDragMode = undefined;
this.cameraDragPointerId = undefined;
this.cameraDragLast = undefined;
this.hideMarqueeBox();
this.scene.clear();
this.scene.rotation.y = 0;
this.universe = generateUniverse();
const initialSystem = this.universe.systems[0];
this.cameraFocus.set(...initialSystem.position);
this.camera.position.set(initialSystem.position[0] + 320, 260, initialSystem.position[2] + 300);
this.camera.lookAt(this.cameraFocus);
this.setupScene();
this.updateHud();
}
private bindEvents() {
window.addEventListener("resize", this.onResize);
window.addEventListener("keydown", this.onKeyDown);
window.addEventListener("keyup", this.onKeyUp);
this.renderer.domElement.addEventListener("pointerdown", this.onPointerDown);
this.renderer.domElement.addEventListener("pointermove", this.onPointerMove);
this.renderer.domElement.addEventListener("pointerup", this.onPointerUp);
this.renderer.domElement.addEventListener("pointerleave", this.onPointerUp);
this.renderer.domElement.addEventListener("click", this.onClick);
this.renderer.domElement.addEventListener("dblclick", this.onDoubleClick);
this.renderer.domElement.addEventListener("contextmenu", this.onContextMenu);
this.renderer.domElement.addEventListener("wheel", this.onWheel, { passive: false });
}
private onResize = () => {
const width = window.innerWidth;
const height = window.innerHeight;
const pixelRatio = Math.min(window.devicePixelRatio, 2);
this.camera.aspect = width / height;
this.camera.updateProjectionMatrix();
this.renderer.setSize(width, height);
this.strategicOverlayEl.width = Math.floor(width * pixelRatio);
this.strategicOverlayEl.height = Math.floor(height * pixelRatio);
this.strategicOverlayEl.style.width = `${width}px`;
this.strategicOverlayEl.style.height = `${height}px`;
};
private onKeyDown = (event: KeyboardEvent) => {
if (event.repeat) {
return;
}
const key = event.key.toLowerCase();
this.keyState.add(key);
if (key === "b") {
this.buildMode = !this.buildMode;
this.updateHud();
return;
}
if (key === "tab") {
event.preventDefault();
this.selectedSystemIndex = (this.selectedSystemIndex + 1) % this.systems.length;
this.focusSystem(this.systems[this.selectedSystemIndex].definition.id);
return;
}
if (key === "f") {
this.focusSelection();
return;
}
if (key === "g") {
this.toggleWindow("fleet-command");
return;
}
if (key === "escape") {
this.windowState["fleet-command"] = false;
this.updateHud();
return;
}
if (key === "-" || key === "_") {
this.adjustZoom(1.18);
return;
}
if (key === "=" || key === "+") {
this.adjustZoom(1 / 1.18);
return;
}
if (key === "m") {
this.selection
.filter((ship) => ship.definition.role === "mining")
.forEach((ship) =>
this.assignMineOrder(
ship,
this.findBestMiningNode(this.universe.scenario.miningDefaults.nodeSystemId),
this.findRefinery(this.universe.scenario.miningDefaults.refinerySystemId),
),
);
this.updateHud();
return;
}
if (key === "p") {
this.selection
.filter((ship) => ship.definition.role === "military")
.forEach((ship) => this.setPatrolOrder(ship, this.makePatrolPoints(ship.systemId), 0));
this.updateHud();
return;
}
if (key === "e") {
this.selection
.filter((ship) => ship.definition.role !== "mining")
.forEach((ship) => {
const target = this.findNearestFriendlyToEscort(ship);
if (target) {
this.setEscortOrder(ship, target);
}
});
this.updateHud();
return;
}
if (key === "r") {
this.selection.forEach((ship) => {
const carrier = this.findNearestFriendlyCarrier(ship);
if (carrier) {
this.assignDockOrder(ship, carrier);
}
});
this.updateHud();
return;
}
const slot = Number(key);
if (!Number.isNaN(slot) && slot >= 1 && slot <= constructibleDefinitions.length) {
this.selectedConstructible = slot - 1;
this.updateHud();
}
};
private onKeyUp = (event: KeyboardEvent) => {
this.keyState.delete(event.key.toLowerCase());
};
private onPointerDown = (event: PointerEvent) => {
if (event.button === 1) {
event.preventDefault();
this.followShipId = undefined;
this.cameraDragMode = event.shiftKey ? "pan" : "orbit";
this.cameraDragPointerId = event.pointerId;
this.cameraDragLast = new THREE.Vector2(event.clientX, event.clientY);
this.renderer.domElement.setPointerCapture(event.pointerId);
return;
}
if (event.button !== 0) {
return;
}
this.marqueeStart = new THREE.Vector2(event.clientX, event.clientY);
this.marqueeModifiers = { shift: event.shiftKey, ctrl: event.ctrlKey || event.metaKey };
this.marqueeActive = false;
this.suppressClickSelection = false;
this.updateMarqueeBox(event.clientX, event.clientY);
};
private onPointerMove = (event: PointerEvent) => {
if (this.cameraDragMode && this.cameraDragPointerId === event.pointerId && this.cameraDragLast) {
const dx = event.clientX - this.cameraDragLast.x;
const dy = this.cameraDragLast.y - event.clientY;
if (this.cameraDragMode === "orbit") {
this.orbitCamera(dx, dy);
} else {
this.panCamera(dx, dy);
}
this.cameraDragLast.set(event.clientX, event.clientY);
return;
}
if (!this.marqueeStart) {
return;
}
const dx = event.clientX - this.marqueeStart.x;
const dy = event.clientY - this.marqueeStart.y;
if (!this.marqueeActive && Math.hypot(dx, dy) > 8) {
this.marqueeActive = true;
this.suppressClickSelection = true;
}
if (this.marqueeActive) {
this.updateMarqueeBox(event.clientX, event.clientY);
}
};
private onPointerUp = (event: PointerEvent) => {
if (this.cameraDragMode && this.cameraDragPointerId === event.pointerId) {
this.cameraDragMode = undefined;
this.cameraDragPointerId = undefined;
this.cameraDragLast = undefined;
if (this.renderer.domElement.hasPointerCapture(event.pointerId)) {
this.renderer.domElement.releasePointerCapture(event.pointerId);
}
return;
}
if (!this.marqueeStart) {
return;
}
if (this.marqueeActive) {
this.applyMarqueeSelection(event.clientX, event.clientY);
}
this.marqueeStart = undefined;
this.marqueeActive = false;
this.hideMarqueeBox();
};
private onClick = (event: MouseEvent) => {
if (this.suppressClickSelection) {
this.suppressClickSelection = false;
return;
}
this.updateMouse(event.clientX, event.clientY);
this.raycaster.setFromCamera(this.mouse, this.camera);
const hits = this.raycaster.intersectObjects([...this.selectableTargets.keys()], false);
const additive = event.shiftKey;
const toggle = event.ctrlKey || event.metaKey;
if (!additive && !toggle) {
this.clearSelection();
}
if (hits.length > 0) {
const target = this.selectableTargets.get(hits[0].object);
if (target?.kind === "ship") {
if (toggle) {
this.selectionManager.toggleShip(target.ship);
} else if (!this.selectionManager.hasShip(target.ship)) {
this.selectionManager.addShip(target.ship);
}
this.syncActiveFleetFromSelection();
}
if (target?.kind === "station") {
this.selectionManager.setStation(target.station);
}
if (target?.kind === "system") {
this.selectionManager.setSystem(target.system);
this.selectedSystemIndex = this.systems.findIndex((system) => system.definition.id === target.system.definition.id);
}
if (target?.kind === "planet") {
this.selectionManager.setPlanet(target.planet);
this.selectedSystemIndex = this.systems.findIndex((system) => system.definition.id === target.system.definition.id);
}
}
this.updateHud();
};
private onDoubleClick = (event: MouseEvent) => {
this.updateMouse(event.clientX, event.clientY);
this.raycaster.setFromCamera(this.mouse, this.camera);
const hits = this.raycaster.intersectObjects([...this.selectableTargets.keys()], false);
const target = hits.length > 0 ? this.selectableTargets.get(hits[0].object) : undefined;
if (!target) {
return;
}
this.followShipId = undefined;
if (target.kind === "ship") {
this.followShipId = target.ship.id;
this.selectedSystemIndex = this.systems.findIndex((system) => system.definition.id === target.ship.systemId);
this.focusPoint(target.ship.group.position, 520);
} else if (target.kind === "station") {
this.selectedSystemIndex = this.systems.findIndex((system) => system.definition.id === target.station.systemId);
this.focusPoint(target.station.group.position, 640);
} else if (target.kind === "system") {
this.focusSystem(target.system.definition.id);
return;
} else if (target.kind === "planet") {
const worldPosition = target.planet.mesh.getWorldPosition(new THREE.Vector3());
this.focusPoint(worldPosition, 760);
this.selectedSystemIndex = this.systems.findIndex((system) => system.definition.id === target.system.definition.id);
}
this.updateHud();
};
private onContextMenu = (event: MouseEvent) => {
event.preventDefault();
};
private onWheel = (event: WheelEvent) => {
event.preventDefault();
this.adjustZoom(1 + event.deltaY * 0.0012);
};
private orbitCamera(deltaX: number, deltaY: number) {
const focus = this.getCameraFocus();
const offset = this.camera.position.clone().sub(focus);
const spherical = new THREE.Spherical().setFromVector3(offset);
spherical.theta -= deltaX * 0.005;
spherical.phi = THREE.MathUtils.clamp(spherical.phi + deltaY * 0.005, 0.15, Math.PI - 0.15);
const nextOffset = new THREE.Vector3().setFromSpherical(spherical);
this.camera.position.copy(focus).add(nextOffset);
this.camera.lookAt(focus);
this.applyViewLevel();
}
private panCamera(deltaX: number, deltaY: number) {
const focus = this.getCameraFocus();
const offset = this.camera.position.clone().sub(focus);
const distance = offset.length();
const scale = Math.max(0.2, distance * 0.0014);
const forward = new THREE.Vector3();
this.camera.getWorldDirection(forward);
forward.y = 0;
if (forward.lengthSq() === 0) {
forward.set(0, 0, -1);
}
forward.normalize();
const right = new THREE.Vector3().crossVectors(forward, new THREE.Vector3(0, 1, 0)).normalize();
const translation = right.multiplyScalar(-deltaX * scale).add(forward.multiplyScalar(deltaY * scale));
focus.add(translation);
this.camera.position.add(translation);
this.camera.lookAt(focus);
}
private initializeFactions() {
this.factions.length = 0;
this.factionsById.clear();
(this.universe.scenario.factions ?? []).forEach((definition) => {
const faction: FactionInstance = {
definition,
credits: definition.kind === "empire" ? 1800 : 900,
oreMined: 0,
goodsProduced: 0,
shipsBuilt: 0,
stationsBuilt: 0,
shipsLost: 0,
enemyShipsDestroyed: 0,
raidsCompleted: 0,
stolenCargo: 0,
ownedSystemIds: new Set([definition.homeSystemId, ...(definition.miningSystemId ? [definition.miningSystemId] : [])]),
shipBuildTimer: 10,
stationBuildTimer: 30,
commandTick: 0.5,
};
this.factions.push(faction);
this.factionsById.set(definition.id, faction);
});
this.systems.forEach((system) => {
system.controlProgress = 0;
if (this.universe.scenario.centralSystemIds?.includes(system.definition.id)) {
system.strategicValue = "central";
return;
}
const homeOwner = this.factions.find((faction) => faction.definition.homeSystemId === system.definition.id);
if (homeOwner) {
system.strategicValue = "core";
system.controllingFactionId = homeOwner.definition.id;
return;
}
const miningOwner = this.factions.find((faction) => faction.definition.miningSystemId === system.definition.id);
if (miningOwner) {
system.strategicValue = "resource";
system.controllingFactionId = miningOwner.definition.id;
return;
}
system.strategicValue = "frontier";
});
}
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) {
const route = this.universe.scenario.patrolRoutes.find((candidate) => candidate.systemId === systemId);
if (!route) {
const system = this.getSystem(systemId);
return [
system.center.clone().add(new THREE.Vector3(180, 0, 120)),
system.center.clone().add(new THREE.Vector3(360, 0, -140)),
system.center.clone().add(new THREE.Vector3(620, 0, 210)),
system.center.clone().add(new THREE.Vector3(260, 0, 320)),
];
}
return route.points.map((point) => new THREE.Vector3(...point).setY(gameBalance.yPlane));
}
private setFleetMineOrder(fleet: FleetInstance, nodeSystemId: string, refinerySystemId: string) {
fleet.order = { kind: "mine", nodeSystemId, refinerySystemId };
this.applyFleetOrder(fleet);
}
private setFleetPatrolOrder(fleet: FleetInstance, systemId: string) {
fleet.order = {
kind: "patrol",
systemId,
points: this.makePatrolPoints(systemId),
index: 0,
};
this.applyFleetOrder(fleet);
}
private setFleetIdleOrder(fleet: FleetInstance) {
fleet.order = { kind: "idle" };
this.applyFleetOrder(fleet);
}
private issueFleetMoveOrder(fleet: FleetInstance, destination: THREE.Vector3) {
const system = this.findNearestSystem(destination);
fleet.order = {
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));
}
}
private updateFactionSimulation(delta: number) {
this.updateSystemControl(delta);
this.factions.forEach((faction) => {
faction.commandTick -= delta;
faction.shipBuildTimer -= delta;
faction.stationBuildTimer -= delta;
if (faction.commandTick <= 0) {
faction.commandTick = faction.definition.kind === "empire" ? 2.5 : 3.2;
if (faction.definition.kind === "empire") {
this.commandEmpireFaction(faction);
} else {
this.commandPirateFaction(faction);
}
}
if (faction.shipBuildTimer <= 0) {
this.tryBuildShipForFaction(faction);
faction.shipBuildTimer = faction.definition.kind === "empire" ? 18 : 22;
}
if (faction.stationBuildTimer <= 0) {
this.tryBuildOutpostForFaction(faction);
faction.stationBuildTimer = 45;
}
});
}
private commandEmpireFaction(faction: FactionInstance) {
const miningSystems = [
faction.definition.miningSystemId,
...this.systems
.filter((system) => system.strategicValue === "central" && system.controllingFactionId === faction.definition.id)
.map((system) => system.definition.id),
].filter((systemId): systemId is string => Boolean(systemId));
const threatenedSystemId = this.findThreatenedSystem(faction.definition.id);
const centralTarget = this.pickCentralTargetSystem(faction);
const militaryTargetSystemId = threatenedSystemId ?? centralTarget ?? faction.definition.homeSystemId;
const industryFleet = this.getFactionIndustryFleet(faction.definition.id);
if (industryFleet) {
const miningSystemId = miningSystems[0] ?? faction.definition.miningSystemId ?? faction.definition.homeSystemId;
this.setFleetMineOrder(
industryFleet,
miningSystemId,
faction.definition.miningSystemId ?? faction.definition.homeSystemId,
);
}
this.getFactionWarFleets(faction.definition.id).forEach((fleet) => {
if (fleet.systemId !== militaryTargetSystemId) {
const targetSystem = this.getSystem(militaryTargetSystemId);
const rally = targetSystem.center.clone().add(new THREE.Vector3(-140, 0, 120));
this.issueFleetMoveOrder(fleet, rally);
return;
}
this.setFleetPatrolOrder(fleet, militaryTargetSystemId);
});
}
private commandPirateFaction(faction: FactionInstance) {
const targetSystemId = faction.definition.targetSystemIds[0] ?? faction.definition.homeSystemId;
const targetSystem = this.getSystem(targetSystemId);
this.getFactionWarFleets(faction.definition.id).forEach((fleet) => {
const raidPoint = targetSystem.center.clone().add(new THREE.Vector3(120, 0, 160));
if (fleet.systemId !== targetSystemId) {
this.issueFleetMoveOrder(fleet, raidPoint);
return;
}
this.setFleetPatrolOrder(fleet, targetSystemId);
});
const industryFleet = this.getFactionIndustryFleet(faction.definition.id);
if (industryFleet) {
this.setFleetPatrolOrder(industryFleet, faction.definition.homeSystemId);
}
}
private updateSystemControl(delta: number) {
const empireIds = new Set(this.factions.filter((faction) => faction.definition.kind === "empire").map((faction) => faction.definition.id));
this.factions.forEach((faction) => {
faction.ownedSystemIds = new Set([faction.definition.homeSystemId, ...(faction.definition.miningSystemId ? [faction.definition.miningSystemId] : [])]);
});
this.systems
.filter((system) => system.strategicValue === "central")
.forEach((system) => {
const powerByFaction = new Map<string, number>();
this.ships
.filter((ship) => ship.systemId === system.definition.id && ship.definition.role === "military" && empireIds.has(ship.factionId))
.forEach((ship) => {
const power = ship.definition.shipClass === "capital" ? 8 : ship.definition.shipClass === "cruiser" ? 4 : ship.definition.shipClass === "destroyer" ? 2 : 1;
powerByFaction.set(ship.factionId, (powerByFaction.get(ship.factionId) ?? 0) + power);
});
this.stations
.filter((station) => station.systemId === system.definition.id && station.definition.category === "defense" && empireIds.has(station.factionId))
.forEach((station) => {
powerByFaction.set(station.factionId, (powerByFaction.get(station.factionId) ?? 0) + 3);
});
const sorted = [...powerByFaction.entries()].sort((left, right) => right[1] - left[1]);
const leader = sorted[0];
const runnerUp = sorted[1];
if (!leader || leader[1] <= (runnerUp?.[1] ?? 0)) {
system.controlProgress = Math.max(0, system.controlProgress - delta * 2);
} else if (system.controllingFactionId === leader[0]) {
system.controlProgress = Math.min(100, system.controlProgress + delta * leader[1] * 0.8);
} else {
system.controlProgress -= delta * ((runnerUp?.[1] ?? 0) + 2);
if (system.controlProgress <= 0) {
system.controllingFactionId = leader[0];
system.controlProgress = 10;
}
}
if (system.controllingFactionId) {
this.factionsById.get(system.controllingFactionId)?.ownedSystemIds.add(system.definition.id);
}
});
}
private updateCombat(delta: number) {
this.ships.forEach((ship) => {
ship.weaponTimer = Math.max(0, ship.weaponTimer - delta);
if (ship.state === "docked" || ship.weaponRange <= 0 || ship.weaponTimer > 0) {
return;
}
const target = this.findCombatTarget(ship);
if (!target) {
return;
}
target.health -= ship.weaponDamage;
ship.weaponTimer = ship.weaponCooldown;
if (target.health <= 0) {
this.destroyShip(target, ship.factionId);
}
});
this.stations.forEach((station) => {
station.weaponTimer = Math.max(0, station.weaponTimer - delta);
if (station.weaponRange <= 0 || station.weaponTimer > 0) {
return;
}
const target = this.ships
.filter((ship) => ship.systemId === station.systemId && ship.factionId !== station.factionId && ship.state !== "docked")
.sort((left, right) => station.group.position.distanceTo(left.group.position) - station.group.position.distanceTo(right.group.position))[0];
if (!target || station.group.position.distanceTo(target.group.position) > station.weaponRange) {
return;
}
target.health -= station.weaponDamage;
station.weaponTimer = 1.1;
if (target.health <= 0) {
this.destroyShip(target, station.factionId);
}
});
this.updatePirateRaids(delta);
}
private updatePirateRaids(_delta: number) {
this.ships
.filter((ship) => this.factionsById.get(ship.factionId)?.definition.kind === "pirate")
.forEach((pirate) => {
const victim = this.ships
.filter(
(ship) =>
ship.systemId === pirate.systemId &&
ship.factionId !== pirate.factionId &&
ship.definition.role !== "military" &&
getShipCargoAmount(ship) > 0,
)
.sort((left, right) => pirate.group.position.distanceTo(left.group.position) - pirate.group.position.distanceTo(right.group.position))[0];
if (!victim || pirate.group.position.distanceTo(victim.group.position) > 60) {
return;
}
const stolen = removeShipCargo(victim, Math.min(12, getShipCargoAmount(victim)));
if (stolen <= 0) {
return;
}
const pirateFaction = this.factionsById.get(pirate.factionId);
if (pirateFaction) {
pirateFaction.raidsCompleted += 1;
pirateFaction.stolenCargo += stolen;
pirateFaction.credits += stolen * 2;
}
});
}
private refreshFleets() {
if (!this.fleetRefreshNeeded) {
return;
}
this.fleetRefreshNeeded = false;
this.initializeFleets();
this.factions.forEach((faction) => {
faction.commandTick = 0;
});
}
private tick() {
const delta = Math.min(this.clock.getDelta(), 0.033);
const elapsed = this.clock.elapsedTime;
this.updateCamera(delta);
this.updateFactionSimulation(delta);
this.updateShips(delta, elapsed);
this.updateCombat(delta);
this.fleets.forEach((fleet) => {
const commander = getFleetCommander(fleet, this.shipsById);
if (commander) {
fleet.systemId = commander.systemId;
}
});
this.updateSystems(delta);
this.refreshFleets();
this.applyViewLevel();
if (this.selection.length > 0 || this.selectedStation || this.selectedSystem || this.selectedPlanet || this.followShipId) {
this.updateHud();
}
this.renderHudCanvases();
this.scene.rotation.y = Math.sin(elapsed * 0.02) * 0.008;
this.renderer.render(this.scene, this.camera);
}
private updateCamera(delta: number) {
const focus = this.getCameraFocus();
const followedShip = this.followShipId ? this.shipsById.get(this.followShipId) : undefined;
if (followedShip) {
focus.lerp(followedShip.group.position, Math.min(1, delta * 3.2));
this.selectedSystemIndex = this.systems.findIndex((system) => system.definition.id === followedShip.systemId);
}
const forward = new THREE.Vector3();
this.camera.getWorldDirection(forward);
forward.y = 0;
forward.normalize();
const right = new THREE.Vector3().crossVectors(forward, new THREE.Vector3(0, 1, 0)).normalize();
const panSpeed = Math.max(80, this.camera.position.distanceTo(focus) * 0.7) * delta;
let manualCameraInput = false;
if (this.keyState.has("w")) {
focus.add(forward.clone().multiplyScalar(panSpeed));
manualCameraInput = true;
}
if (this.keyState.has("s")) {
focus.add(forward.clone().multiplyScalar(-panSpeed));
manualCameraInput = true;
}
if (this.keyState.has("a")) {
focus.add(right.clone().multiplyScalar(panSpeed));
manualCameraInput = true;
}
if (this.keyState.has("d")) {
focus.add(right.clone().multiplyScalar(-panSpeed));
manualCameraInput = true;
}
if (this.keyState.has("q") || this.keyState.has("e")) {
const angle = (this.keyState.has("q") ? 1 : -1) * delta * 0.9;
const offset = this.camera.position.clone().sub(focus);
offset.applyAxisAngle(new THREE.Vector3(0, 1, 0), angle);
this.camera.position.copy(focus).add(offset);
manualCameraInput = true;
}
if (manualCameraInput) {
this.followShipId = undefined;
}
this.camera.lookAt(focus);
}
private applyViewLevel() {
const distance = this.camera.position.distanceTo(this.getCameraFocus());
const nextLevel: ViewLevel = distance < 950 ? "local" : distance < 3200 ? "solar" : "universe";
const fog = this.scene.fog as THREE.FogExp2;
if (nextLevel !== this.viewLevel) {
this.viewLevel = nextLevel;
this.updateHud();
}
if (this.viewLevel === "local") {
this.camera.fov = 54;
fog.density = 0.00006;
} else if (this.viewLevel === "solar") {
this.camera.fov = 42;
fog.density = 0.00003;
} else {
this.camera.fov = 28;
fog.density = 0.000012;
}
this.camera.updateProjectionMatrix();
const focusedSystemId = this.findNearestSystem(this.getCameraFocus()).definition.id;
this.systems.forEach((system) => {
const universe = this.viewLevel === "universe";
system.orbitLines.forEach((orbit) => {
orbit.visible = !universe;
(orbit.material as THREE.LineBasicMaterial).opacity = this.viewLevel === "local" ? 0.52 : 0.24;
});
system.asteroidDecorations.forEach((object) => {
object.visible = this.viewLevel === "local";
});
system.planets.forEach((planet) => {
planet.mesh.visible = !universe;
if (planet.ring) {
planet.ring.visible = !universe;
}
});
system.star.visible = !universe;
system.light.visible = !universe && system.definition.id === focusedSystemId;
system.strategicMarker.visible = universe;
});
this.nodes.forEach((node) => {
node.mesh.visible = this.viewLevel === "local";
});
this.stations.forEach((station) => {
station.group.visible = this.viewLevel !== "universe";
station.group.scale.setScalar(this.getStationPresentationScale(station));
});
this.ships.forEach((ship) => {
ship.group.visible = this.viewLevel !== "universe";
ship.group.scale.setScalar(this.getShipPresentationScale(ship));
});
this.strategicLinks.visible = this.viewLevel === "universe";
if (this.starfield) {
const material = this.starfield.material as THREE.PointsMaterial;
material.size = this.viewLevel === "universe" ? 12 : this.viewLevel === "solar" ? 9 : 8;
material.opacity = this.viewLevel === "local" ? 0.9 : this.viewLevel === "solar" ? 0.7 : 0.45;
material.needsUpdate = true;
}
}
private getStationPresentationScale(station: StationInstance) {
if (this.viewLevel === "universe") {
return 1;
}
const distance = this.camera.position.distanceTo(station.group.position);
if (this.viewLevel === "solar") {
return THREE.MathUtils.clamp(distance / 260, 2.2, 4.8);
}
return THREE.MathUtils.clamp(distance / 340, 1.2, 2.8);
}
private getShipPresentationScale(ship: ShipInstance) {
if (this.viewLevel === "universe") {
return 1;
}
const distance = this.camera.position.distanceTo(ship.group.position);
if (this.viewLevel === "solar") {
return THREE.MathUtils.clamp(distance / 180, 3.2, 7.5);
}
return THREE.MathUtils.clamp(distance / 240, 1.5, 4.2);
}
private updateShips(delta: number, elapsed: number) {
this.ships.forEach((ship, index) => {
this.consumeShipResources(ship, delta);
if (ship.state === "undocking" && this.moveShipToward(ship, ship.target, ship.definition.speed * 0.8, delta, 8, true)) {
ship.state = "idle";
}
switch (ship.order.kind) {
case "idle":
if (ship.state !== "docked" && ship.state !== "undocking") {
ship.state = "idle";
ship.velocity.multiplyScalar(0.9);
this.updateIdleOrbit(ship, delta);
}
break;
case "move":
if (this.updateTravelState(ship, ship.order.destination, ship.order.systemId, delta, gameBalance.arrivalThreshold)) {
ship.order = { kind: "idle" };
ship.travelPlan = undefined;
}
break;
case "transfer":
this.updateTransferOrder(ship, delta);
break;
case "mine":
this.updateMiningOrder(ship, delta);
break;
case "patrol":
this.updatePatrolOrder(ship, delta);
break;
case "escort":
this.updateEscortOrder(ship, delta);
break;
case "dock":
this.updateDockOrder(ship, delta);
break;
}
if (ship.state === "docked") {
this.updateDockedShipTransform(ship);
ship.group.rotation.z = 0;
ship.energy = Math.min(ship.maxEnergy, ship.energy + gameBalance.energy.shipRechargeRate * delta);
} else if (ship.state !== "warping") {
ship.group.position.y = gameBalance.yPlane + Math.sin(elapsed * 1.2 + index) * 0.7;
ship.group.rotation.z = Math.sin(elapsed * 2 + index) * 0.04;
} else {
ship.group.position.y = gameBalance.yPlane;
ship.group.rotation.z = 0;
}
ship.warpFx.visible = ship.state === "warping" || ship.state === "spooling-ftl";
ship.warpFx.scale.x = ship.state === "warping" ? 2.4 : 1.2;
});
}
private updateTransferOrder(ship: ShipInstance, delta: number) {
const order = ship.order;
if (order.kind !== "transfer") {
return;
}
if (this.updateTravelState(ship, order.destination, order.destinationSystemId, delta, gameBalance.arrivalThreshold, order)) {
ship.order = { kind: "idle" };
ship.travelPlan = undefined;
}
}
private updateMiningOrder(ship: ShipInstance, delta: number) {
const order = ship.order;
if (order.kind !== "mine") {
return;
}
const node = this.nodes.find((candidate) => candidate.id === order.nodeId);
const refinery = this.stations.find((candidate) => candidate.id === order.refineryId);
if (!node || !refinery) {
ship.order = { kind: "idle" };
ship.state = "idle";
return;
}
const cargo = getShipCargoAmount(ship);
if (cargo >= ship.definition.cargoCapacity) {
order.phase = "to-refinery";
}
if (order.phase === "to-node") {
if (this.updateTravelState(ship, node.position, node.systemId, delta, 26)) {
order.phase = "mining";
}
return;
}
if (order.phase === "mining") {
ship.state = "mining";
ship.actionTimer += delta;
ship.velocity.multiplyScalar(0.75);
if (ship.actionTimer >= 1) {
const mined = Math.min(gameBalance.miningRate, ship.definition.cargoCapacity - cargo, node.oreRemaining);
addShipCargo(ship, mined);
node.oreRemaining = Math.max(0, node.oreRemaining - mined);
ship.actionTimer = 0;
if (getShipCargoAmount(ship) >= ship.definition.cargoCapacity) {
order.phase = "to-refinery";
}
if (node.oreRemaining <= 0) {
node.oreRemaining = node.maxOre;
}
}
return;
}
if (order.phase === "to-refinery") {
if (this.updateTravelState(ship, refinery.group.position, refinery.systemId, delta, refinery.definition.radius + 30)) {
order.phase = "transfer";
}
return;
}
if (order.phase === "transfer" && this.updateDockingState(ship, refinery, delta)) {
const transferred = removeShipCargo(ship, getShipCargoAmount(ship));
this.addStationItem(refinery, "ore", transferred);
const faction = this.factionsById.get(ship.factionId);
if (faction) {
faction.oreMined += transferred;
faction.credits += transferred * 0.4;
}
order.phase = "to-node";
this.beginUndock(ship, refinery);
}
}
private updatePatrolOrder(ship: ShipInstance, delta: number) {
const order = ship.order;
if (order.kind !== "patrol") {
return;
}
ship.state = "patrolling";
const target = order.points[order.index];
if (ship.systemId !== order.systemId) {
this.issueMoveOrder(ship, target.clone());
return;
}
if (this.moveShipToward(ship, target, ship.definition.speed, delta, 20)) {
order.index = (order.index + 1) % order.points.length;
}
}
private updateEscortOrder(ship: ShipInstance, delta: number) {
const order = ship.order;
if (order.kind !== "escort") {
return;
}
const targetShip = this.shipsById.get(order.targetShipId);
if (!targetShip) {
ship.order = { kind: "idle" };
ship.state = "idle";
return;
}
ship.state = "escorting";
const anchor = targetShip.group.position.clone().add(order.offset);
if (targetShip.systemId !== ship.systemId) {
this.issueMoveOrder(ship, targetShip.group.position.clone());
return;
}
this.moveShipToward(ship, anchor, ship.definition.speed * 1.05, delta, 18);
}
private updateDockOrder(ship: ShipInstance, delta: number) {
const order = ship.order;
if (order.kind !== "dock") {
return;
}
const carrier = this.shipsById.get(order.carrierShipId);
if (!carrier || !this.canDockShipAtCarrier(ship, carrier)) {
ship.order = { kind: "idle" };
ship.state = "idle";
return;
}
if (ship.systemId !== carrier.systemId) {
this.updateTravelState(ship, carrier.group.position.clone(), carrier.systemId, delta, carrier.definition.size + 28);
return;
}
if (this.updateDockingState(ship, carrier, delta)) {
ship.order = { kind: "idle" };
}
}
private updateDockedShipTransform(ship: ShipInstance) {
const host = this.getDockingHostForShip(ship);
if (!host || ship.dockingPortIndex === undefined) {
return;
}
const port = host.group.localToWorld(host.dockingPorts[ship.dockingPortIndex].clone());
ship.group.position.copy(port);
ship.systemId = host.systemId;
}
private updateSystems(delta: number) {
this.systems.forEach((system) => {
system.planets.forEach((planet) => {
planet.group.rotation.y += planet.orbitSpeed * delta * 0.3;
planet.mesh.rotation.y += delta * 0.18;
});
});
this.stations.forEach((station) => {
if (station.orbitalParentPlanetIndex !== undefined && station.lagrangeSide) {
const system = this.getSystem(station.systemId);
const parentPlanet = system.planets[station.orbitalParentPlanetIndex];
const planetPosition = parentPlanet.mesh.getWorldPosition(new THREE.Vector3());
const radial = planetPosition.clone().sub(system.center);
const lagrange = radial
.clone()
.applyAxisAngle(new THREE.Vector3(0, 1, 0), station.lagrangeSide * Math.PI / 3)
.add(system.center)
.setY(0);
station.group.position.copy(lagrange);
}
station.energy = Math.min(station.maxEnergy, station.energy + gameBalance.energy.stationSolarCharge * delta);
this.updateStationProduction(station, delta);
});
}
private updateStationProduction(station: StationInstance, delta: number) {
if (station.activeBatch <= 0) {
const nextRecipe = this.findNextStationRecipe(station);
if (nextRecipe) {
station.activeRecipeId = nextRecipe.id;
station.activeBatch = nextRecipe.inputs.reduce((total, component) => total + component.amount, 0);
station.processTimer = nextRecipe.duration;
this.consumeFactionItems(station.factionId, nextRecipe.inputs);
}
}
if (station.activeBatch <= 0 || !station.activeRecipeId) {
return;
}
const recipe = recipeDefinitions.find((candidate) => candidate.id === station.activeRecipeId);
if (!recipe) {
station.activeBatch = 0;
station.activeRecipeId = undefined;
station.processTimer = 0;
return;
}
station.processTimer = Math.max(0, station.processTimer - delta);
if (station.processTimer > 0) {
return;
}
recipe.outputs.forEach((component) => this.addStationItem(station, component.itemId, component.amount));
this.factionsById.get(station.factionId)!.goodsProduced += recipe.outputs.reduce((total, output) => total + output.amount, 0);
station.activeBatch = 0;
station.activeRecipeId = undefined;
}
private findNextStationRecipe(station: StationInstance) {
return recipeDefinitions
.filter((recipe) => this.canStationRunRecipe(station, recipe))
.sort((left, right) => (right.priority ?? 0) - (left.priority ?? 0))[0];
}
private canStationRunRecipe(station: StationInstance, recipe: (typeof recipeDefinitions)[number]) {
const categoryMatches =
recipe.facilityCategory === station.definition.category ||
(recipe.facilityCategory === "station" && station.modules.includes("fabricator-array"));
const modulesMatch = (recipe.requiredModules ?? []).every((moduleId) => station.modules.includes(moduleId));
const inputsMatch = recipe.inputs.every((component) => this.getFactionItemAmount(station.factionId, component.itemId) >= component.amount);
return categoryMatches && modulesMatch && inputsMatch;
}
private getStationItemAmount(station: StationInstance, itemId: string) {
return station.itemStocks[itemId] ?? 0;
}
private addStationItem(station: StationInstance, itemId: string, amount: number) {
if (amount <= 0) {
return;
}
station.itemStocks[itemId] = (station.itemStocks[itemId] ?? 0) + amount;
const storage = itemDefinitionsById.get(itemId)?.storage;
if (storage) {
station.inventory[storage] += amount;
}
if (itemId === "ore") {
station.oreStored += amount;
}
if (itemId === "refined-metals") {
station.refinedStock += amount;
}
}
private removeStationItem(station: StationInstance, itemId: string, amount: number) {
if (amount <= 0) {
return 0;
}
const available = station.itemStocks[itemId] ?? 0;
const removed = Math.min(available, amount);
station.itemStocks[itemId] = Math.max(0, available - removed);
const storage = itemDefinitionsById.get(itemId)?.storage;
if (storage) {
station.inventory[storage] = Math.max(0, station.inventory[storage] - removed);
}
if (itemId === "ore") {
station.oreStored = Math.max(0, station.oreStored - removed);
}
if (itemId === "refined-metals") {
station.refinedStock = Math.max(0, station.refinedStock - removed);
}
return removed;
}
private consumeShipResources(ship: ShipInstance, delta: number) {
if (ship.state === "warping" || ship.state === "spooling-ftl") {
ship.energy = Math.max(0, ship.energy - gameBalance.energy.warpDrain * delta);
ship.fuel = Math.max(0, ship.fuel - gameBalance.fuel.warpDrain * delta);
return;
}
if (MOVING_STATES.has(ship.state)) {
ship.energy = Math.max(0, ship.energy - gameBalance.energy.moveDrain * delta);
return;
}
if (ship.state !== "docked") {
ship.energy = Math.max(0, ship.energy - gameBalance.energy.idleDrain * delta);
}
}
private updateIdleOrbit(ship: ShipInstance, delta: number) {
const systemCenter = this.getSystem(ship.systemId).center;
if (ship.idleOrbitRadius < 40) {
ship.idleOrbitRadius = ship.group.position.clone().setY(0).distanceTo(systemCenter);
ship.idleOrbitAngle = Math.atan2(ship.group.position.z - systemCenter.z, ship.group.position.x - systemCenter.x);
}
ship.idleOrbitAngle += delta * (14 / Math.max(ship.idleOrbitRadius, 120));
const nextPosition = new THREE.Vector3(
systemCenter.x + Math.cos(ship.idleOrbitAngle) * ship.idleOrbitRadius,
gameBalance.yPlane,
systemCenter.z + Math.sin(ship.idleOrbitAngle) * ship.idleOrbitRadius,
);
ship.group.position.lerp(nextPosition, 0.12);
const tangent = new THREE.Vector3(-Math.sin(ship.idleOrbitAngle), 0, Math.cos(ship.idleOrbitAngle));
ship.group.lookAt(ship.group.position.clone().add(tangent));
}
private ensureTravelPlan(
ship: ShipInstance,
destination: THREE.Vector3,
destinationSystemId: string,
suppliedPlan?: TravelPlan,
) {
if (
ship.travelPlan &&
ship.travelPlan.destinationSystemId === destinationSystemId &&
ship.travelPlan.destination.distanceToSquared(destination) < 1
) {
return ship.travelPlan;
}
if (suppliedPlan) {
ship.travelPlan = {
destination: suppliedPlan.destination.clone(),
destinationSystemId: suppliedPlan.destinationSystemId,
exitPoint: suppliedPlan.exitPoint.clone().setY(gameBalance.yPlane),
arrivalPoint: suppliedPlan.arrivalPoint.clone().setY(gameBalance.yPlane),
};
return ship.travelPlan;
}
const currentSystem = this.getSystem(ship.systemId);
const destinationSystem = this.getSystem(destinationSystemId);
const exitDirection = ship.group.position.clone().sub(currentSystem.center).setY(0).normalize();
if (exitDirection.lengthSq() === 0) {
exitDirection.copy(destinationSystem.center.clone().sub(currentSystem.center).setY(0).normalize());
}
const arrivalDirection = destination.clone().sub(destinationSystem.center).setY(0).normalize();
if (arrivalDirection.lengthSq() === 0) {
arrivalDirection.copy(currentSystem.center.clone().sub(destinationSystem.center).setY(0).normalize());
}
ship.travelPlan = {
destination: destination.clone().setY(gameBalance.yPlane),
destinationSystemId,
exitPoint: currentSystem.center
.clone()
.add(exitDirection.multiplyScalar(currentSystem.gravityWellRadius + 230))
.setY(gameBalance.yPlane),
arrivalPoint: destinationSystem.center
.clone()
.add(arrivalDirection.multiplyScalar(destinationSystem.gravityWellRadius + 150))
.setY(gameBalance.yPlane),
};
return ship.travelPlan;
}
private updateTravelState(
ship: ShipInstance,
destination: THREE.Vector3,
destinationSystemId: string,
delta: number,
threshold: number,
suppliedPlan?: TravelPlan,
) {
if (ship.state === "docked") {
const host = this.getDockingHostForShip(ship);
if (host) {
this.beginUndock(ship, host);
}
}
const plan = this.ensureTravelPlan(ship, destination, destinationSystemId, suppliedPlan);
if (ship.state === "undocking") {
return false;
}
if (
ship.systemId === destinationSystemId &&
ship.state !== "leaving-gravity-well" &&
ship.state !== "spooling-ftl" &&
ship.state !== "warping" &&
ship.state !== "arriving"
) {
ship.state = "moving";
return this.moveShipToward(ship, destination, ship.definition.speed, delta, threshold);
}
if (
ship.state === "idle" ||
ship.state === "moving" ||
ship.state === "mining-approach" ||
ship.state === "mining" ||
ship.state === "delivering"
) {
ship.state = "leaving-gravity-well";
}
if (ship.state === "leaving-gravity-well") {
if (this.moveShipToward(ship, plan.exitPoint, ship.definition.speed, delta, 28)) {
ship.state = "spooling-ftl";
ship.actionTimer = ship.definition.spoolTime;
}
return false;
}
if (ship.state === "spooling-ftl") {
ship.actionTimer -= delta;
ship.velocity.multiplyScalar(0.8);
if (ship.actionTimer <= 0) {
ship.state = "warping";
}
return false;
}
if (ship.state === "warping") {
if (this.moveShipToward(ship, plan.arrivalPoint, ship.definition.ftlSpeed, delta, 50)) {
ship.systemId = destinationSystemId;
ship.state = "arriving";
}
return false;
}
if (ship.state === "arriving") {
if (this.moveShipToward(ship, destination, ship.definition.speed, delta, threshold)) {
ship.state = "moving";
const systemCenter = this.getSystem(destinationSystemId).center;
ship.idleOrbitRadius = destination.clone().setY(0).distanceTo(systemCenter);
ship.idleOrbitAngle = Math.atan2(destination.z - systemCenter.z, destination.x - systemCenter.x);
return true;
}
return false;
}
return false;
}
private updateDockingState(ship: ShipInstance, host: DockingHost, delta: number) {
const portIndex = this.reserveDockingPort(host, ship);
if (portIndex < 0) {
ship.state = "docking-approach";
ship.velocity.multiplyScalar(0.7);
return false;
}
const portPosition = host.group.localToWorld(host.dockingPorts[portIndex].clone());
if (ship.state !== "docking" && ship.state !== "docked") {
ship.state = "docking-approach";
}
if (ship.state === "docking-approach") {
if (this.moveShipToward(ship, portPosition, ship.definition.speed * 0.75, delta, 8, true)) {
ship.state = "docking";
ship.actionTimer = gameBalance.dockingDuration;
}
return false;
}
if (ship.state === "docking") {
ship.group.position.lerp(portPosition, 0.18);
ship.actionTimer -= delta;
if (ship.actionTimer <= 0) {
ship.state = "docked";
ship.group.position.copy(portPosition);
ship.velocity.setScalar(0);
}
return false;
}
if (ship.state === "docked") {
ship.group.position.copy(portPosition);
return true;
}
return false;
}
private beginUndock(ship: ShipInstance, host: DockingHost) {
if (ship.state === "undocking") {
return;
}
ship.state = "undocking";
ship.actionTimer = gameBalance.dockingDuration * 0.75;
const portIndex = ship.dockingPortIndex ?? 0;
const port = host.group.localToWorld(host.dockingPorts[portIndex].clone());
const direction = port.clone().sub(host.group.position).setY(0).normalize();
ship.target.copy(port.clone().add(direction.multiplyScalar(gameBalance.undockDistance)).setY(gameBalance.yPlane));
this.releaseDockingPort(host, ship);
}
private reserveDockingPort(host: DockingHost, ship: ShipInstance) {
if (this.getAssignedDockingHostId(ship) === host.id && ship.dockingPortIndex !== undefined) {
return ship.dockingPortIndex;
}
if (!this.canShipDockAtHost(ship, host)) {
return -1;
}
if (host.dockedShipIds.size >= host.dockingPorts.length) {
return -1;
}
const usedPorts = new Set(
this.ships
.filter((candidate) => this.getAssignedDockingHostId(candidate) === host.id && candidate.dockingPortIndex !== undefined)
.map((candidate) => candidate.dockingPortIndex as number),
);
const freePort = host.dockingPorts.findIndex((_, index) => !usedPorts.has(index));
if (freePort >= 0) {
host.dockedShipIds.add(ship.id);
this.assignDockingHost(ship, host);
ship.dockingPortIndex = freePort;
}
return freePort;
}
private releaseDockingPort(host: DockingHost, ship: ShipInstance) {
host.dockedShipIds.delete(ship.id);
ship.dockedStationId = undefined;
ship.dockedCarrierId = undefined;
ship.dockingPortIndex = undefined;
}
private getAssignedDockingHostId(ship: ShipInstance) {
return ship.dockedCarrierId ?? ship.dockedStationId;
}
private getDockingHostForShip(ship: ShipInstance) {
if (ship.dockedCarrierId) {
return this.shipsById.get(ship.dockedCarrierId);
}
if (ship.dockedStationId) {
return this.stations.find((candidate) => candidate.id === ship.dockedStationId);
}
return undefined;
}
private assignDockingHost(ship: ShipInstance, host: DockingHost) {
if (this.isCarrierHost(host)) {
ship.dockedCarrierId = host.id;
ship.dockedStationId = undefined;
ship.systemId = host.systemId;
return;
}
ship.dockedStationId = host.id;
ship.dockedCarrierId = undefined;
}
private isCarrierHost(host: DockingHost): host is ShipInstance {
return "definition" in host;
}
private canDockShipAtCarrier(ship: ShipInstance, carrier: ShipInstance) {
return (
ship.id !== carrier.id &&
carrier.definition.dockingCapacity !== undefined &&
carrier.definition.dockingCapacity > 0 &&
(carrier.definition.dockingClasses ?? []).includes(ship.definition.shipClass)
);
}
private canShipDockAtHost(ship: ShipInstance, host: DockingHost) {
if (this.isCarrierHost(host)) {
return this.canDockShipAtCarrier(ship, host);
}
return host.factionId === ship.factionId;
}
private findNearestFriendlyCarrier(ship: ShipInstance) {
return this.ships
.filter(
(candidate) =>
candidate.systemId === ship.systemId &&
candidate.factionId === ship.factionId &&
this.canDockShipAtCarrier(ship, candidate),
)
.sort(
(left, right) =>
ship.group.position.distanceTo(left.group.position) - ship.group.position.distanceTo(right.group.position),
)[0];
}
private assignDockOrder(ship: ShipInstance, carrier: ShipInstance) {
if (!this.canDockShipAtCarrier(ship, carrier)) {
return;
}
ship.travelPlan = undefined;
ship.order = { kind: "dock", carrierShipId: carrier.id };
ship.state = "docking-approach";
}
private moveShipToward(
ship: ShipInstance,
destination: THREE.Vector3,
speed: number,
delta: number,
threshold: number,
directApproach = false,
) {
const target = destination.clone().setY(gameBalance.yPlane);
ship.target.copy(target);
const toTarget = target.clone().sub(ship.group.position);
const distance = toTarget.length();
if (distance <= threshold) {
ship.velocity.multiplyScalar(0.65);
return true;
}
let desiredDirection = toTarget.normalize();
if (!directApproach && ship.state !== "warping" && ship.state !== "spooling-ftl") {
const systemCenter = this.getSystem(ship.systemId).center;
const radial = ship.group.position.clone().sub(systemCenter).setY(0);
const targetRadial = target.clone().sub(systemCenter).setY(0);
if (radial.lengthSq() > 1 && targetRadial.lengthSq() > 1) {
const tangential = new THREE.Vector3(-radial.z, 0, radial.x).normalize();
const crossY = radial.clone().cross(targetRadial).y;
const sign = crossY >= 0 ? 1 : -1;
const curvature = THREE.MathUtils.clamp(distance / 650, 0, 0.9);
desiredDirection = desiredDirection.add(tangential.multiplyScalar(sign * curvature)).normalize();
}
}
const desiredVelocity = desiredDirection.multiplyScalar(speed);
const steering = ship.state === "warping" ? delta * 4.2 : delta * 1.8;
ship.velocity.lerp(desiredVelocity, steering);
ship.group.position.addScaledVector(ship.velocity, delta);
if (ship.velocity.lengthSq() > 1) {
ship.group.lookAt(ship.group.position.clone().add(ship.velocity));
}
return false;
}
private issueMoveOrder(ship: ShipInstance, destination: THREE.Vector3) {
const system = this.findNearestSystem(destination);
destination.y = gameBalance.yPlane;
ship.travelPlan = undefined;
if (ship.systemId === system.definition.id) {
ship.order = { kind: "move", destination, systemId: system.definition.id };
ship.state = "moving";
return;
}
const currentSystem = this.getSystem(ship.systemId);
const exitDirection = ship.group.position.clone().sub(currentSystem.center).setY(0).normalize();
if (exitDirection.lengthSq() === 0) {
exitDirection.copy(system.center.clone().sub(currentSystem.center).setY(0).normalize());
}
const arrivalDirection = destination.clone().sub(system.center).setY(0).normalize();
if (arrivalDirection.lengthSq() === 0) {
arrivalDirection.copy(currentSystem.center.clone().sub(system.center).setY(0).normalize());
}
ship.order = {
kind: "transfer",
destination,
destinationSystemId: system.definition.id,
exitPoint: currentSystem.center
.clone()
.add(exitDirection.multiplyScalar(currentSystem.gravityWellRadius + 230))
.setY(gameBalance.yPlane),
arrivalPoint: system.center
.clone()
.add(arrivalDirection.multiplyScalar(system.gravityWellRadius + 150))
.setY(gameBalance.yPlane),
};
ship.state = "leaving-gravity-well";
ship.travelPlan = undefined;
}
private assignMineOrder(ship: ShipInstance, node: ResourceNode | undefined, refinery: StationInstance | undefined) {
if (!node || !refinery) {
ship.order = { kind: "idle" };
ship.state = "idle";
return;
}
ship.order = { kind: "mine", nodeId: node.id, refineryId: refinery.id, phase: "to-node" };
ship.state = "mining-approach";
}
private setPatrolOrder(ship: ShipInstance, points: THREE.Vector3[], startIndex: number, systemId = ship.systemId) {
ship.order = {
kind: "patrol",
points: points.map((point) => point.clone().setY(gameBalance.yPlane)),
systemId,
index: startIndex,
};
ship.state = "patrolling";
}
private setEscortOrder(ship: ShipInstance, target: ShipInstance, offset = ship.formationOffset.clone()) {
const angle = (this.ships.indexOf(ship) % 6) * (Math.PI / 3);
const formationOffset =
offset.lengthSq() > 0 ? offset : new THREE.Vector3(Math.cos(angle) * 32, 0, Math.sin(angle) * 32);
ship.order = {
kind: "escort",
targetShipId: target.id,
offset: formationOffset,
};
ship.state = "escorting";
}
private findFactionStations(factionId: string) {
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) {
return this.findFactionStations(factionId).reduce((total, station) => total + (station.itemStocks[itemId] ?? 0), 0);
}
private canFactionAfford(factionId: string, costs: Array<{ itemId: string; amount: number }>) {
return costs.every((cost) => this.getFactionItemAmount(factionId, cost.itemId) >= cost.amount);
}
private consumeFactionItems(factionId: string, costs: Array<{ itemId: string; amount: number }>) {
if (!this.canFactionAfford(factionId, costs)) {
return false;
}
costs.forEach((cost) => {
let remaining = cost.amount;
this.findFactionStations(factionId)
.sort((left, right) => (right.itemStocks[cost.itemId] ?? 0) - (left.itemStocks[cost.itemId] ?? 0))
.forEach((station) => {
if (remaining <= 0) {
return;
}
const removed = this.removeStationItem(station, cost.itemId, remaining);
remaining -= removed;
});
});
return true;
}
private findBestMiningNode(systemId: string) {
return this.nodes.filter((node) => node.systemId === systemId).sort((left, right) => right.oreRemaining - left.oreRemaining)[0];
}
private findBestMiningNodeForFaction(factionId: string, systemIds: string[]) {
const allowedSystems = new Set(systemIds);
return this.nodes
.filter((node) => allowedSystems.has(node.systemId))
.sort((left, right) => right.oreRemaining - left.oreRemaining)[0];
}
private findRefinery(systemId: string, factionId?: string) {
return this.stations.find(
(station) =>
station.systemId === systemId &&
station.definition.category === "refining" &&
(!factionId || station.factionId === factionId),
);
}
private findNearestFriendlyToEscort(ship: ShipInstance) {
return this.ships
.filter((candidate) => candidate.id !== ship.id && candidate.systemId === ship.systemId && candidate.factionId === ship.factionId)
.sort((left, right) => ship.group.position.distanceTo(left.group.position) - ship.group.position.distanceTo(right.group.position))[0];
}
private pickCentralTargetSystem(faction: FactionInstance) {
return this.systems
.filter((system) => system.strategicValue === "central")
.sort((left, right) => {
const leftOwned = left.controllingFactionId === faction.definition.id ? 1 : 0;
const rightOwned = right.controllingFactionId === faction.definition.id ? 1 : 0;
if (leftOwned !== rightOwned) {
return leftOwned - rightOwned;
}
return left.controlProgress - right.controlProgress;
})[0]?.definition.id;
}
private findThreatenedSystem(factionId: string) {
return this.systems.find((system) => {
const friendlyPresence = this.ships.some((ship) => ship.systemId === system.definition.id && ship.factionId === factionId && ship.definition.role === "military");
const hostilePresence = this.ships.some((ship) => ship.systemId === system.definition.id && ship.factionId !== factionId && ship.definition.role === "military");
const friendlyStation = this.stations.some((station) => station.systemId === system.definition.id && station.factionId === factionId);
return friendlyStation && hostilePresence && friendlyPresence;
})?.definition.id;
}
private findCombatTarget(ship: ShipInstance) {
return this.ships
.filter((candidate) => candidate.id !== ship.id && candidate.systemId === ship.systemId && candidate.factionId !== ship.factionId && candidate.state !== "docked")
.sort((left, right) => {
const leftPriority = ship.factionId !== left.factionId && left.definition.role !== "military" ? -1 : 0;
const rightPriority = ship.factionId !== right.factionId && right.definition.role !== "military" ? -1 : 0;
if (leftPriority !== rightPriority) {
return leftPriority - rightPriority;
}
return ship.group.position.distanceTo(left.group.position) - ship.group.position.distanceTo(right.group.position);
})
.find((candidate) => ship.group.position.distanceTo(candidate.group.position) <= ship.weaponRange);
}
private destroyShip(ship: ShipInstance, killerFactionId?: string) {
this.selectionManager.removeShip(ship);
const faction = this.factionsById.get(ship.factionId);
if (faction) {
faction.shipsLost += 1;
}
if (killerFactionId && killerFactionId !== ship.factionId) {
const killerFaction = this.factionsById.get(killerFactionId);
if (killerFaction) {
killerFaction.enemyShipsDestroyed += 1;
}
}
this.scene.remove(ship.group);
this.ships.splice(this.ships.indexOf(ship), 1);
this.shipsById.delete(ship.id);
[...this.selectableTargets.entries()]
.filter(([, target]) => target.kind === "ship" && target.ship.id === ship.id)
.forEach(([object]) => this.selectableTargets.delete(object));
this.fleetRefreshNeeded = true;
}
private tryBuildShipForFaction(faction: FactionInstance) {
const spawnStation =
this.findFactionStations(faction.definition.id).find((station) => station.definition.category === "shipyard") ??
this.findFactionStations(faction.definition.id).find((station) => station.definition.category === "station");
if (!spawnStation) {
return;
}
const buildQueue =
faction.definition.kind === "empire"
? [
{ shipId: "frigate", costs: [{ itemId: "hull-sections", amount: 10 }, { itemId: "naval-guns", amount: 2 }, { itemId: "ship-equipment", amount: 4 }, { itemId: "ammo-crates", amount: 6 }] },
{ shipId: "destroyer", costs: [{ itemId: "hull-sections", amount: 16 }, { itemId: "naval-guns", amount: 4 }, { itemId: "ship-equipment", amount: 6 }, { itemId: "ammo-crates", amount: 10 }] },
{ shipId: "miner", costs: [{ itemId: "refined-metals", amount: 18 }, { itemId: "ship-equipment", amount: 6 }, { itemId: "ship-parts", amount: 4 }] },
{ shipId: "hauler", costs: [{ itemId: "refined-metals", amount: 20 }, { itemId: "ship-equipment", amount: 6 }, { itemId: "ship-parts", amount: 5 }] },
]
: [
{ shipId: "frigate", costs: [{ itemId: "hull-sections", amount: 8 }, { itemId: "naval-guns", amount: 2 }, { itemId: "ammo-crates", amount: 6 }] },
{ shipId: "destroyer", costs: [{ itemId: "hull-sections", amount: 14 }, { itemId: "naval-guns", amount: 4 }, { itemId: "ammo-crates", amount: 10 }] },
];
const nextBuild = buildQueue.find((plan) => this.canFactionAfford(faction.definition.id, plan.costs));
if (!nextBuild || !this.consumeFactionItems(faction.definition.id, nextBuild.costs)) {
return;
}
const ship = createShipInstance({
id: `ship-${this.ships.length + 1}-${Date.now()}`,
definition: this.getShipDefinition(nextBuild.shipId),
systemId: spawnStation.systemId,
factionId: faction.definition.id,
factionColor: faction.definition.color,
selectableTargets: this.selectableTargets,
});
ship.group.position.copy(spawnStation.group.position.clone().add(new THREE.Vector3(50 + Math.random() * 25, 0, 30 + Math.random() * 25)));
ship.target.copy(ship.group.position);
ship.idleOrbitRadius = ship.group.position.clone().setY(0).distanceTo(this.getSystem(spawnStation.systemId).center);
this.scene.add(ship.group);
this.ships.push(ship);
this.shipsById.set(ship.id, ship);
faction.shipsBuilt += 1;
faction.credits -= 60;
this.fleetRefreshNeeded = true;
}
private tryBuildOutpostForFaction(faction: FactionInstance) {
if (faction.definition.kind !== "empire") {
return;
}
const targetSystem = this.systems.find(
(system) =>
system.strategicValue === "central" &&
system.controllingFactionId === faction.definition.id &&
!this.stations.some((station) => station.systemId === system.definition.id && station.factionId === faction.definition.id),
);
if (!targetSystem) {
return;
}
const costs = [
{ itemId: "ship-parts", amount: 18 },
{ itemId: "naval-guns", amount: 10 },
{ itemId: "ammo-crates", amount: 14 },
];
if (!this.consumeFactionItems(faction.definition.id, costs)) {
return;
}
const defense = constructibleDefinitions.find((definition) => definition.id === "defense-grid");
if (!defense) {
return;
}
const station = createStationInstance({
id: `station-${++this.stationIdCounter}`,
scene: this.scene,
definition: defense,
systemId: targetSystem.definition.id,
position: targetSystem.center.clone().add(new THREE.Vector3(180, 0, -120)),
factionId: faction.definition.id,
factionColor: faction.definition.color,
selectableTargets: this.selectableTargets,
});
this.stations.push(station);
faction.stationsBuilt += 1;
}
private getShipDefinition(shipId: string) {
const definition = shipDefinitionsById.get(shipId);
if (!definition) {
throw new Error(`Missing ship definition ${shipId}`);
}
return definition;
}
private findNearestSystem(point: THREE.Vector3) {
return this.systems.reduce((best, system) => {
const bestDistance = best.center.distanceToSquared(point);
const distance = system.center.distanceToSquared(point);
return distance < bestDistance ? system : best;
}, this.systems[0]);
}
private getSystem(systemId: string) {
const system = this.systems.find((candidate) => candidate.definition.id === systemId);
if (!system) {
throw new Error(`Missing solar system ${systemId}`);
}
return system;
}
private focusSystem(systemId: string) {
const system = this.getSystem(systemId);
this.followShipId = undefined;
this.focusPoint(system.center, 1100);
this.updateHud();
}
private focusSelection() {
if (this.selection.length === 0 && !this.selectedStation && !this.selectedSystem && !this.selectedPlanet && !this.activeFleetId) {
return;
}
if (this.selectedPlanet) {
this.followShipId = undefined;
const worldPosition = this.selectedPlanet.mesh.getWorldPosition(new THREE.Vector3());
this.focusPoint(worldPosition, 760);
this.selectedSystemIndex = this.systems.findIndex((system) => system.definition.id === this.selectedPlanet?.systemId);
this.updateHud();
return;
}
if (this.selectedSystem) {
this.focusSystem(this.selectedSystem.definition.id);
return;
}
if (this.selectedStation) {
this.followShipId = undefined;
this.selectedSystemIndex = this.systems.findIndex((system) => system.definition.id === this.selectedStation?.systemId);
this.focusPoint(this.selectedStation.group.position, 640);
this.updateHud();
return;
}
if (this.selection.length === 0 && this.activeFleetId) {
this.focusFleet(this.activeFleetId);
return;
}
if (this.selection.length === 1) {
const ship = this.selection[0];
this.followShipId = ship.id;
this.selectedSystemIndex = this.systems.findIndex((system) => system.definition.id === ship.systemId);
this.focusPoint(ship.group.position, 520);
this.updateHud();
return;
}
this.followShipId = undefined;
const center = new THREE.Vector3();
this.selection.forEach((ship) => center.add(ship.group.position));
center.multiplyScalar(1 / this.selection.length);
this.focusPoint(center, 840);
this.updateHud();
}
private focusPoint(point: THREE.Vector3, targetDistance: number) {
const focus = this.getCameraFocus();
focus.copy(point);
const offset = this.camera.position.clone().sub(focus);
if (offset.lengthSq() === 0) {
offset.set(1, 0.75, 1);
}
offset.normalize().multiplyScalar(targetDistance);
if (offset.y < 140) {
offset.y = 140;
}
this.camera.position.copy(focus).add(offset);
this.camera.lookAt(focus);
this.applyViewLevel();
}
private handleOrderAction(action: string) {
if (action === "focus") {
this.focusSelection();
}
this.updateHud();
}
private handleWindowAction(action: string) {
if (action === "new-universe") {
this.generateNewUniverse();
return;
}
if (action === "toggle-fleet-command") {
this.toggleWindow("fleet-command");
return;
}
if (action === "toggle-debug") {
this.toggleWindow("debug");
return;
}
if (action === "toggle-ship-designer") {
this.toggleWindow("ship-designer");
return;
}
if (action === "toggle-station-manager") {
this.toggleWindow("station-manager");
}
}
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) {
if (kind === "fleet") {
this.selectFleetShips(id);
this.activeFleetId = id;
this.updateHud();
return;
}
if (kind === "wing") {
this.selectWingShips(id);
this.updateHud();
return;
}
if (kind === "ship") {
const ship = this.shipsById.get(id);
if (!ship) {
return;
}
this.selectionManager.replaceShips([ship]);
this.syncActiveFleetFromSelection();
this.updateHud();
}
}
private toggleWindow(windowId: GameWindowId) {
this.windowState[windowId] = !this.windowState[windowId];
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) {
const focus = this.getCameraFocus();
const direction = this.camera.position.clone().sub(focus).normalize();
const distance = this.camera.position.distanceTo(focus);
const nextDistance = THREE.MathUtils.clamp(distance * multiplier, 180, 9000);
this.camera.position.copy(focus).add(direction.multiplyScalar(nextDistance));
this.applyViewLevel();
}
private renderHudCanvases() {
drawMinimap({
context: this.minimapContext,
width: this.minimapEl.width,
height: this.minimapEl.height,
systems: this.systems,
stations: this.stations,
ships: this.ships,
selection: this.selection,
selectedStation: this.selectedStation,
cameraFocus: this.getCameraFocus(),
fleets: this.fleets,
activeFleetId: this.activeFleetId,
});
drawStrategicOverlay({
context: this.strategicOverlayContext,
width: this.strategicOverlayEl.width,
height: this.strategicOverlayEl.height,
camera: this.camera,
systems: this.systems,
stations: this.stations,
ships: this.ships,
selection: this.selection,
selectedStation: this.selectedStation,
selectedSystemIndex: this.selectedSystemIndex,
viewLevel: this.viewLevel,
fleets: this.fleets,
activeFleetId: this.activeFleetId,
});
}
private updateHud() {
const selectedDefinition = constructibleDefinitions[this.selectedConstructible];
const system = this.systems[this.selectedSystemIndex] ?? this.systems[0];
const selectedCount =
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.selection,
this.selectedStation,
this.selectedSystem,
this.selectedPlanet,
);
this.selectionStripEl.innerHTML = getSelectionCardsMarkup(
this.selection,
this.selectedStation,
this.selectedSystem,
this.selectedPlanet,
);
const hasExplicitSelection = Boolean(this.selectedStation || this.selectedSystem || this.selectedPlanet || this.selection.length > 0);
this.detailsEl.textContent = hasExplicitSelection
? ""
: getSelectionDetails(
this.selection,
this.selectedStation,
this.selectedSystem,
this.selectedPlanet,
this.systems,
this.viewLevel,
this.ships,
this.fleets,
this.factions,
);
this.detailsEl.style.display = hasExplicitSelection ? "none" : "block";
const sessionButton = this.sessionActionsEl.querySelector("button");
if (sessionButton) {
sessionButton.textContent = `New Universe (${this.universe.systems.length})`;
}
this.sessionActionsEl.title = `${this.universe.label}${this.universe.systems.length} systems`;
this.statusEl.textContent = this.buildMode
? `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}` : ""}`;
this.ordersEl.dataset.mode =
this.selectedStation ? "station" : this.selection.length > 0 ? "ships" : this.activeFleetId ? "fleet" : "none";
this.fleetWindowEl.dataset.open = this.windowState["fleet-command"] ? "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) {
const faction = this.factions.find((candidate) => candidate.definition.kind === "empire");
if (!faction) {
return;
}
const station = createStationInstance({
id: `station-${++this.stationIdCounter}`,
scene: this.scene,
definition,
systemId,
position,
factionId: faction.definition.id,
factionColor: faction.definition.color,
selectableTargets: this.selectableTargets,
});
this.stations.push(station);
this.updateHud();
}
private updateMouse(clientX: number, clientY: number) {
this.mouse.x = (clientX / window.innerWidth) * 2 - 1;
this.mouse.y = -(clientY / window.innerHeight) * 2 + 1;
}
private clearSelection() {
this.selectionManager.clear();
this.syncActiveFleetFromSelection();
}
private addShipToSelection(ship: ShipInstance) {
this.selectionManager.addShip(ship);
this.syncActiveFleetFromSelection();
}
private removeShipFromSelection(ship: ShipInstance) {
this.selectionManager.removeShip(ship);
this.syncActiveFleetFromSelection();
}
private updateMarqueeBox(clientX: number, clientY: number) {
if (!this.marqueeStart) {
return;
}
const left = Math.min(this.marqueeStart.x, clientX);
const top = Math.min(this.marqueeStart.y, clientY);
const width = Math.abs(clientX - this.marqueeStart.x);
const height = Math.abs(clientY - this.marqueeStart.y);
this.marqueeEl.style.display = this.marqueeActive ? "block" : "none";
this.marqueeEl.style.left = `${left}px`;
this.marqueeEl.style.top = `${top}px`;
this.marqueeEl.style.width = `${width}px`;
this.marqueeEl.style.height = `${height}px`;
}
private hideMarqueeBox() {
this.marqueeEl.style.display = "none";
this.marqueeEl.style.width = "0";
this.marqueeEl.style.height = "0";
}
private applyMarqueeSelection(clientX: number, clientY: number) {
if (!this.marqueeStart) {
return;
}
const left = Math.min(this.marqueeStart.x, clientX);
const right = Math.max(this.marqueeStart.x, clientX);
const top = Math.min(this.marqueeStart.y, clientY);
const bottom = Math.max(this.marqueeStart.y, clientY);
if (!this.marqueeModifiers.shift && !this.marqueeModifiers.ctrl) {
this.clearSelection();
}
this.ships.forEach((ship) => {
if (ship.state === "docked" || !ship.group.visible) {
return;
}
const screen = ship.group.position.clone().project(this.camera);
if (screen.z < -1 || screen.z > 1) {
return;
}
const sx = ((screen.x + 1) * 0.5) * window.innerWidth;
const sy = ((-screen.y + 1) * 0.5) * window.innerHeight;
const inside = sx >= left && sx <= right && sy >= top && sy <= bottom;
if (!inside) {
return;
}
if (this.marqueeModifiers.ctrl && this.selection.includes(ship)) {
this.selectionManager.removeShip(ship);
} else {
this.selectionManager.addShip(ship);
}
});
this.syncActiveFleetFromSelection();
this.updateHud();
}
private getCameraFocus() {
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;
}
}
}