2576 lines
90 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
}
|