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([ "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(); private readonly cameraFocus = new THREE.Vector3(); private readonly selectableTargets = new Map(); private readonly ships: ShipInstance[] = []; private readonly shipsById = new Map(); private readonly stations: StationInstance[] = []; private readonly nodes: ResourceNode[] = []; private readonly systems: SolarSystemInstance[] = []; private readonly fleets: FleetInstance[] = []; private readonly fleetsById = new Map(); private readonly factions: FactionInstance[] = []; private readonly factionsById = new Map(); 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 = { "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(); 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([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; } } }