Refactor universe sim and observer HUD

This commit is contained in:
2026-03-12 00:53:52 -04:00
parent 5979a74d46
commit fbdf8d0d5a
11 changed files with 1832 additions and 622 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,7 @@ interface FleetBuildSpec {
label: string;
stance: FleetInstance["stance"];
systemId: string;
factionId?: string;
commander: ShipInstance;
wings: Array<{
id: string;
@@ -18,94 +19,70 @@ interface FleetBuildSpec {
export function createDefaultFleets(ships: ShipInstance[]) {
clearFleetAssignments(ships);
const heliosMilitary = ships.filter((ship) => ship.systemId === "helios" && ship.definition.role === "military");
const heliosCarriers = heliosMilitary.filter((ship) => ship.definition.shipClass === "capital");
const heliosDestroyers = heliosMilitary.filter((ship) => ship.definition.id === "destroyer");
const allHaulers = ships.filter((ship) => ship.definition.role === "transport");
const perseusMilitary = ships.filter((ship) => ship.systemId === "perseus" && ship.definition.role === "military");
const miners = ships.filter((ship) => ship.definition.role === "mining");
const miningHaulers = allHaulers.slice(0, 2);
const homeHaulers = allHaulers.slice(2);
const specs: FleetBuildSpec[] = [];
const factionIds = [...new Set(ships.map((ship) => ship.factionId))];
const homeCommander = heliosCarriers[0] ?? heliosDestroyers[0] ?? heliosMilitary[0];
if (homeCommander) {
const homeScreenShips = heliosMilitary.filter(
(ship) => ship.id !== homeCommander.id && ship.definition.shipClass !== "destroyer",
);
specs.push({
id: "helios-home-fleet",
label: "Helios Home Fleet",
stance: "defensive",
systemId: "helios",
commander: homeCommander,
wings: [
{
id: "command",
label: "Command Wing",
behavior: "command",
ships: [homeCommander, ...heliosDestroyers.slice(1)],
},
{
id: "screen",
label: "Screen Wing",
behavior: "screen",
parentWingId: "command",
ships: homeScreenShips,
},
{
id: "logistics",
label: "Logistics Wing",
behavior: "logistics",
parentWingId: "command",
ships: homeHaulers,
},
],
});
}
factionIds.forEach((factionId) => {
const factionShips = ships.filter((ship) => ship.factionId === factionId);
const military = factionShips.filter((ship) => ship.definition.role === "military");
const industrial = factionShips.filter((ship) => ship.definition.role !== "military");
const systems = [...new Set(factionShips.map((ship) => ship.systemId))];
const extractionCommander = perseusMilitary[0] ?? miners[0];
if (extractionCommander) {
specs.push({
id: "perseus-extraction-fleet",
label: "Perseus Extraction Group",
stance: "industrial",
systemId: "perseus",
commander: extractionCommander,
wings: [
{
id: "command",
label: "Command Wing",
behavior: "command",
ships: [extractionCommander],
},
{
id: "miners",
label: "Mining Wing",
behavior: "mining",
parentWingId: "command",
ships: miners,
},
{
id: "escort",
label: "Escort Wing",
behavior: "escort",
parentWingId: "miners",
ships: perseusMilitary.filter((ship) => ship.id !== extractionCommander.id),
},
{
id: "transport",
label: "Transport Wing",
behavior: "logistics",
parentWingId: "miners",
ships: miningHaulers,
},
],
systems.forEach((systemId) => {
const localMilitary = military.filter((ship) => ship.systemId === systemId);
if (localMilitary.length === 0) {
return;
}
const commander =
localMilitary.find((ship) => ship.definition.shipClass === "capital") ??
localMilitary.find((ship) => ship.definition.shipClass === "cruiser") ??
localMilitary[0];
const lineShips = localMilitary.filter((ship) => ship.id !== commander.id);
specs.push({
id: `${factionId}:${systemId}:warfleet`,
label: `${commander.factionId} War Fleet`,
stance: factionId.includes("pirate") || factionId.includes("flag") || factionId.includes("rats") ? "balanced" : "defensive",
systemId,
factionId,
commander,
wings: [
{ id: "command", label: "Command Wing", behavior: "command", ships: [commander] },
{ id: "screen", label: "Screen Wing", behavior: "screen", parentWingId: "command", ships: lineShips },
],
});
});
}
const miners = industrial.filter((ship) => ship.definition.role === "mining");
const haulers = industrial.filter((ship) => ship.definition.role === "transport");
const logisticsCommander = haulers[0] ?? miners[0];
if (logisticsCommander) {
specs.push({
id: `${factionId}:industry`,
label: `${logisticsCommander.factionId} Industry Group`,
stance: "industrial",
systemId: logisticsCommander.systemId,
factionId,
commander: logisticsCommander,
wings: [
{ id: "command", label: "Command Wing", behavior: "command", ships: [logisticsCommander] },
{
id: "miners",
label: "Mining Wing",
behavior: "mining",
parentWingId: "command",
ships: miners.filter((ship) => ship.id !== logisticsCommander.id),
},
{
id: "transport",
label: "Transport Wing",
behavior: "logistics",
parentWingId: "command",
ships: haulers.filter((ship) => ship.id !== logisticsCommander.id),
},
],
});
}
});
return specs.map((spec) => materializeFleet(spec));
}
@@ -140,11 +117,9 @@ export function describeFleetOrder(fleet: FleetInstance) {
}
function materializeFleet(spec: FleetBuildSpec): FleetInstance {
const wings = spec.wings
.filter((wing) => wing.ships.length > 0)
.map((wing) => buildWing(spec.id, wing));
const wings = spec.wings.filter((wing) => wing.ships.length > 0).map((wing) => buildWing(spec.id, wing));
const shipIds = wings.flatMap((wing) => wing.shipIds);
shipIds.forEach((shipId, index) => {
const ship = spec.wings.flatMap((wing) => wing.ships).find((candidate) => candidate.id === shipId);
if (!ship) {
@@ -168,6 +143,7 @@ function materializeFleet(spec: FleetBuildSpec): FleetInstance {
stance: spec.stance,
commanderShipId: spec.commander.id,
systemId: spec.systemId,
factionId: spec.factionId,
shipIds,
wings,
order: { kind: "idle" },

View File

@@ -1,9 +1,11 @@
import * as THREE from "three";
import type { ShipInstance, StationInstance } from "../types";
import type { PlanetInstance, ShipInstance, SolarSystemInstance, StationInstance } from "../types";
export class SelectionManager {
private shipSelection: ShipInstance[] = [];
private stationSelection?: StationInstance;
private systemSelection?: SolarSystemInstance;
private planetSelection?: PlanetInstance;
getShips() {
return this.shipSelection;
@@ -13,6 +15,14 @@ export class SelectionManager {
return this.stationSelection;
}
getSystem() {
return this.systemSelection;
}
getPlanet() {
return this.planetSelection;
}
clear() {
this.shipSelection.forEach((ship) => this.setShipVisual(ship, false));
this.shipSelection = [];
@@ -20,6 +30,14 @@ export class SelectionManager {
this.setStationVisual(this.stationSelection, false);
this.stationSelection = undefined;
}
if (this.systemSelection) {
this.setSystemVisual(this.systemSelection, false);
this.systemSelection = undefined;
}
if (this.planetSelection) {
this.setPlanetVisual(this.planetSelection, false);
this.planetSelection = undefined;
}
}
replaceShips(ships: ShipInstance[]) {
@@ -36,6 +54,24 @@ export class SelectionManager {
this.setStationVisual(station, true);
}
setSystem(system?: SolarSystemInstance) {
this.clear();
if (!system) {
return;
}
this.systemSelection = system;
this.setSystemVisual(system, true);
}
setPlanet(planet?: PlanetInstance) {
this.clear();
if (!planet) {
return;
}
this.planetSelection = planet;
this.setPlanetVisual(planet, true);
}
addShip(ship: ShipInstance) {
if (this.shipSelection.includes(ship)) {
return;
@@ -44,6 +80,14 @@ export class SelectionManager {
this.setStationVisual(this.stationSelection, false);
this.stationSelection = undefined;
}
if (this.systemSelection) {
this.setSystemVisual(this.systemSelection, false);
this.systemSelection = undefined;
}
if (this.planetSelection) {
this.setPlanetVisual(this.planetSelection, false);
this.planetSelection = undefined;
}
this.shipSelection.push(ship);
this.setShipVisual(ship, true);
}
@@ -73,4 +117,20 @@ export class SelectionManager {
private setStationVisual(station: StationInstance, selected: boolean) {
(station.ring.material as THREE.MeshBasicMaterial).opacity = selected ? 0.95 : 0;
}
private setSystemVisual(system: SolarSystemInstance, selected: boolean) {
if (system.strategicMarker instanceof THREE.Group) {
system.strategicMarker.traverse((child) => {
if ("material" in child && child.material instanceof THREE.MeshBasicMaterial) {
child.material.opacity = selected ? Math.max(child.material.opacity, 0.9) : child === system.strategicMarker.children[0] ? 0.4 : 0.7;
}
});
}
}
private setPlanetVisual(planet: PlanetInstance, selected: boolean) {
if (planet.selectionRing) {
(planet.selectionRing.material as THREE.MeshBasicMaterial).opacity = selected ? 0.95 : 0;
}
}
}

View File

@@ -4,7 +4,8 @@ export type ShipRole = "military" | "transport" | "mining";
export type ShipClass = "frigate" | "destroyer" | "cruiser" | "industrial" | "capital";
export type FleetBehavior = "command" | "screen" | "escort" | "mining" | "logistics" | "reserve";
export type FleetStance = "balanced" | "defensive" | "industrial";
export type GameWindowId = "fleet-command" | "ship-designer" | "station-manager";
export type GameWindowId = "fleet-command" | "ship-designer" | "station-manager" | "debug";
export type FactionKind = "empire" | "pirate";
export type ConstructibleCategory =
| "station"
| "refining"
@@ -147,9 +148,11 @@ export interface SolarSystemDefinition {
export interface InitialStationDefinition {
constructibleId: string;
systemId: string;
factionId?: string;
planetIndex?: number;
lagrangeSide?: -1 | 1;
position?: [number, number, number];
seedStock?: Partial<Record<string, number>>;
}
export interface ShipFormationDefinition {
@@ -157,6 +160,7 @@ export interface ShipFormationDefinition {
count: number;
center: [number, number, number];
systemId: string;
factionId?: string;
}
export interface PatrolRouteDefinition {
@@ -178,6 +182,45 @@ export interface ScenarioDefinition {
nodeSystemId: string;
refinerySystemId: string;
};
factions?: FactionDefinition[];
centralSystemIds?: string[];
}
export interface UniverseDefinition {
seed: number;
label: string;
systems: SolarSystemDefinition[];
scenario: ScenarioDefinition;
}
export interface FactionDefinition {
id: string;
label: string;
kind: FactionKind;
color: string;
accent: string;
homeSystemId: string;
miningSystemId?: string;
targetSystemIds: string[];
rivals: string[];
pirateForFactionId?: string;
}
export interface FactionInstance {
definition: FactionDefinition;
credits: number;
oreMined: number;
goodsProduced: number;
shipsBuilt: number;
stationsBuilt: number;
shipsLost: number;
enemyShipsDestroyed: number;
raidsCompleted: number;
stolenCargo: number;
ownedSystemIds: Set<string>;
shipBuildTimer: number;
stationBuildTimer: number;
commandTick: number;
}
export interface GameBalance {
@@ -244,6 +287,7 @@ export interface FleetInstance {
stance: FleetStance;
commanderShipId: string;
systemId: string;
factionId?: string;
shipIds: string[];
wings: FleetWingInstance[];
order: FleetOrder;
@@ -267,6 +311,14 @@ export interface ShipInstance {
dockedStationId?: string;
dockedCarrierId?: string;
dockingPortIndex?: number;
factionId: string;
factionColor: string;
health: number;
maxHealth: number;
weaponRange: number;
weaponDamage: number;
weaponCooldown: number;
weaponTimer: number;
fuel: number;
energy: number;
maxFuel: number;
@@ -302,6 +354,13 @@ export interface StationInstance {
modules: string[];
orbitalParentPlanetIndex?: number;
lagrangeSide?: -1 | 1;
factionId: string;
factionColor: string;
health: number;
maxHealth: number;
weaponRange: number;
weaponDamage: number;
weaponTimer: number;
fuel: number;
energy: number;
maxFuel: number;
@@ -309,10 +368,14 @@ export interface StationInstance {
}
export interface PlanetInstance {
definition: PlanetDefinition;
group: THREE.Group;
mesh: THREE.Mesh;
orbitSpeed: number;
ring?: THREE.Object3D;
selectionRing?: THREE.Mesh;
systemId: string;
index: number;
}
export interface ResourceNode {
@@ -335,17 +398,24 @@ export interface SolarSystemInstance {
orbitLines: THREE.LineLoop[];
asteroidDecorations: THREE.Object3D[];
strategicMarker: THREE.Object3D;
controllingFactionId?: string;
controlProgress: number;
strategicValue: "core" | "resource" | "frontier" | "central";
}
export type SelectableTarget =
| { kind: "ship"; ship: ShipInstance }
| { kind: "station"; station: StationInstance };
| { kind: "station"; station: StationInstance }
| { kind: "system"; system: SolarSystemInstance }
| { kind: "planet"; system: SolarSystemInstance; planet: PlanetInstance };
export interface HudElements {
details: HTMLDivElement;
status: HTMLDivElement;
selectionTitle: HTMLHeadingElement;
selectionStrip: HTMLDivElement;
orders: HTMLDivElement;
sessionActions: HTMLDivElement;
minimap: HTMLCanvasElement;
minimapContext: CanvasRenderingContext2D;
marquee: HTMLDivElement;
@@ -355,4 +425,5 @@ export interface HudElements {
fleetWindowBody: HTMLDivElement;
fleetWindowTitle: HTMLHeadingElement;
fleetWindowSubtitle: HTMLParagraphElement;
debugWindow: HTMLDivElement;
}

View File

@@ -1,7 +1,5 @@
import type { HudElements } from "../types";
interface HudHandlers {
onOrderAction: (action: string) => void;
onWindowAction: (action: string) => void;
onFleetAction: (action: string, fleetId?: string) => void;
onSelectionAction: (kind: string, id: string) => void;
@@ -12,43 +10,15 @@ export function createHud(container: HTMLElement, handlers: HudHandlers): HudEle
root.className = "hud";
root.innerHTML = `
<canvas class="strategic-overlay"></canvas>
<section class="panel summary">
<h1>Helios Reach Command</h1>
<p>
Dual-star-system prototype with gravity-well exits, FTL spooling, inter-system travel,
and layered fleet command with wing behaviors, escort screens, and logistics groups.
</p>
</section>
<section class="panel details">
<h2>Selection</h2>
<div class="selection-meta">
<h2 class="selection-title">No Selection</h2>
<div class="mode"></div>
</div>
<div class="selection-strip"></div>
<div class="content"></div>
</section>
<section class="panel commandbar">
<div class="selection-panel">
<h2 class="selection-title">No Selection</h2>
<div class="content compact"></div>
</div>
<div class="orders-panel">
<div class="mode"></div>
<div class="window-launchers">
<button type="button" data-window-action="toggle-fleet-command">Fleets</button>
<button type="button" data-window-action="toggle-ship-designer" disabled>Designer</button>
<button type="button" data-window-action="toggle-station-manager" disabled>Stations</button>
</div>
<div class="orders">
<button type="button" data-action="move">Move</button>
<button type="button" data-action="mine">Mine</button>
<button type="button" data-action="patrol">Patrol</button>
<button type="button" data-action="escort">Escort</button>
<button type="button" data-action="dock">Dock</button>
<button type="button" data-action="focus">Focus</button>
</div>
<div class="hint">Left click selects ships or stations. Shift adds. Right click moves selection, or the active fleet when nothing is selected. G toggles fleet command. R recovers selected ships to the nearest friendly carrier. Mouse wheel or -/= zoom. B build. M miners mine. P patrol. E escort.</div>
</div>
<div class="minimap-panel">
<canvas class="minimap" width="220" height="160"></canvas>
</div>
</section>
<canvas class="minimap minimap-hidden" width="220" height="160"></canvas>
<section class="app-window fleet-window" data-window-id="fleet-command">
<div class="window-header">
<div>
@@ -66,14 +36,26 @@ export function createHud(container: HTMLElement, handlers: HudHandlers): HudEle
<div class="window-body fleet-window-body"></div>
<div class="window-resize-handle" aria-hidden="true"></div>
</section>
<section class="app-window debug-window" data-window-id="debug">
<div class="window-header">
<div>
<h2>Debug</h2>
<p class="window-subtitle">Simulation controls</p>
</div>
<button type="button" class="window-close" data-window-action="toggle-debug">Close</button>
</div>
<div class="window-body">
<div class="session-actions">
<button type="button" data-window-action="new-universe">New Universe</button>
</div>
</div>
<div class="window-resize-handle" aria-hidden="true"></div>
</section>
<div class="marquee"></div>
`;
container.append(root);
initializeWindowInteractions(root);
root.querySelectorAll<HTMLButtonElement>(".orders button").forEach((button) => {
button.addEventListener("click", () => handlers.onOrderAction(button.dataset.action ?? ""));
});
root.querySelectorAll<HTMLButtonElement>("[data-window-action]").forEach((button) => {
button.addEventListener("click", () => handlers.onWindowAction(button.dataset.windowAction ?? ""));
});
@@ -111,7 +93,9 @@ export function createHud(container: HTMLElement, handlers: HudHandlers): HudEle
details: root.querySelector(".content") as HTMLDivElement,
status: root.querySelector(".mode") as HTMLDivElement,
selectionTitle: root.querySelector(".selection-title") as HTMLHeadingElement,
orders: root.querySelector(".orders") as HTMLDivElement,
selectionStrip: root.querySelector(".selection-strip") as HTMLDivElement,
orders: document.createElement("div"),
sessionActions: root.querySelector(".session-actions") as HTMLDivElement,
minimap,
minimapContext,
marquee: root.querySelector(".marquee") as HTMLDivElement,
@@ -121,6 +105,7 @@ export function createHud(container: HTMLElement, handlers: HudHandlers): HudEle
fleetWindowBody: fleetWindowBody as HTMLDivElement,
fleetWindowTitle: root.querySelector(".fleet-window h2") as HTMLHeadingElement,
fleetWindowSubtitle: root.querySelector(".window-subtitle") as HTMLParagraphElement,
debugWindow: root.querySelector(".debug-window") as HTMLDivElement,
};
}

View File

@@ -6,14 +6,27 @@ import {
import { describeFleetOrder } from "../fleet/runtime";
import { getShipCargoAmount } from "../state/inventory";
import type {
FactionInstance,
FleetInstance,
PlanetInstance,
ShipInstance,
SolarSystemInstance,
StationInstance,
ViewLevel,
} from "../types";
export function getSelectionTitle(selection: ShipInstance[], selectedStation?: StationInstance) {
export function getSelectionTitle(
selection: ShipInstance[],
selectedStation?: StationInstance,
selectedSystem?: SolarSystemInstance,
selectedPlanet?: PlanetInstance,
) {
if (selectedPlanet) {
return selectedPlanet.definition.label;
}
if (selectedSystem) {
return selectedSystem.definition.label;
}
if (selectedStation) {
return selectedStation.definition.label;
}
@@ -26,19 +39,114 @@ export function getSelectionTitle(selection: ShipInstance[], selectedStation?: S
return `${selection.length} Ships Selected`;
}
export function getSelectionStripLabels(
selection: ShipInstance[],
selectedStation?: StationInstance,
selectedSystem?: SolarSystemInstance,
selectedPlanet?: PlanetInstance,
) {
if (selectedPlanet) {
return [selectedPlanet.definition.label];
}
if (selectedSystem) {
return [selectedSystem.definition.label];
}
if (selectedStation) {
return [selectedStation.definition.label];
}
if (selection.length === 0) {
return [];
}
return selection.map((ship) => ship.definition.label);
}
export function getSelectionCardsMarkup(
selection: ShipInstance[],
selectedStation: StationInstance | undefined,
selectedSystem: SolarSystemInstance | undefined,
selectedPlanet: PlanetInstance | undefined,
) {
if (selectedPlanet) {
return renderCard(
selectedPlanet.definition.label,
[
selectedPlanet.systemId,
`Orbit ${Math.round(selectedPlanet.definition.orbitRadius)}`,
`Size ${selectedPlanet.definition.size}`,
selectedPlanet.definition.hasRing ? "Ringed" : "No ring",
],
);
}
if (selectedSystem) {
return renderCard(
selectedSystem.definition.label,
[
selectedSystem.strategicValue,
`${selectedSystem.planets.length} planets`,
`${selectedSystem.definition.resourceNodes.length} nodes`,
`${selectedSystem.controllingFactionId ?? "Contested"} ${Math.round(selectedSystem.controlProgress)}%`,
],
);
}
if (selectedStation) {
return renderCard(
selectedStation.definition.label,
[
selectedStation.factionId,
selectedStation.definition.category,
`HP ${Math.round(selectedStation.health)}/${selectedStation.maxHealth}`,
`Dock ${selectedStation.dockedShipIds.size}/${selectedStation.definition.dockingCapacity}`,
],
);
}
if (selection.length === 0) {
return `<span class="selection-strip-empty">No active selection</span>`;
}
return selection
.map((ship) =>
renderCard(ship.definition.label, [
ship.factionId,
ship.state,
ship.order.kind,
`HP ${Math.round(ship.health)}/${ship.maxHealth}`,
]),
)
.join("");
}
export function getSelectionDetails(
selection: ShipInstance[],
selectedStation: StationInstance | undefined,
selectedSystem: SolarSystemInstance | undefined,
selectedPlanet: PlanetInstance | undefined,
systems: SolarSystemInstance[],
viewLevel: ViewLevel,
ships: ShipInstance[],
fleets: FleetInstance[],
factions: FactionInstance[],
) {
if (selectedPlanet) {
return `${selectedPlanet.definition.label}${selectedPlanet.systemId}\nOrbit Radius: ${Math.round(selectedPlanet.definition.orbitRadius)}\nSize: ${selectedPlanet.definition.size}\nOrbit Speed: ${selectedPlanet.definition.orbitSpeed.toFixed(2)}\nTilt: ${selectedPlanet.definition.tilt.toFixed(2)}\nRing: ${selectedPlanet.definition.hasRing ? "Yes" : "No"}`;
}
if (selectedSystem) {
return `${selectedSystem.definition.label}\nType: ${selectedSystem.strategicValue}\nControl: ${selectedSystem.controllingFactionId ?? "Contested"} ${Math.round(selectedSystem.controlProgress)}%\nPlanets: ${selectedSystem.planets.length}\nResource Nodes: ${selectedSystem.definition.resourceNodes.length}\nGravity Well: ${Math.round(selectedSystem.gravityWellRadius)}`;
}
if (selectedStation) {
return describeStation(selectedStation, ships, fleets);
}
if (selection.length === 0) {
return `Systems online: ${systems.map((system) => system.definition.label).join(", ")}\nFleets active: ${fleets.length}\n\nOrders: Move, Patrol, Escort, Mine\nView: ${viewLevel}`;
const central = systems
.filter((system) => system.strategicValue === "central")
.map((system) => `${system.definition.label}: ${system.controllingFactionId ?? "contested"} ${Math.round(system.controlProgress)}%`)
.join("\n");
const factionLines = factions
.filter((faction) => faction.definition.kind === "empire")
.map(
(faction) =>
`${faction.definition.label}: systems ${faction.ownedSystemIds.size} • mined ${Math.round(faction.oreMined)} • built ${faction.shipsBuilt} ships • losses ${faction.shipsLost}`,
)
.join("\n");
return `Observer Mode\nSystems online: ${systems.length}\nFleets tracked: ${fleets.length}\nView: ${viewLevel}\n\nCentral systems:\n${central}\n\nEmpires:\n${factionLines}`;
}
return selection
@@ -49,7 +157,7 @@ export function getSelectionDetails(
ship.definition.dockingCapacity && ship.definition.dockingCapacity > 0
? `\nHangar: ${ship.dockedShipIds.size}/${ship.definition.dockingCapacity} for ${(ship.definition.dockingClasses ?? []).join(", ")}`
: "";
return `${ship.definition.label}${ship.systemId}\nClass: ${ship.definition.shipClass}\nState: ${ship.state}${dockedAt ? ` @ ${dockedAt}` : ""}\nOrder: ${ship.order.kind}\nFleet: ${ship.fleetId ?? "Independent"}${ship.isFleetCommander ? " • Commander" : ship.isWingLeader ? " • Wing Leader" : ""}\nBehavior: ${ship.behavior}\nCargo: ${Math.round(getShipCargoAmount(ship))}/${ship.definition.cargoCapacity || 0} ${getItemLabel(ship.cargoItemId)}\nFuel: ${ship.fuel.toFixed(0)}/${ship.maxFuel}\nEnergy: ${ship.energy.toFixed(0)}/${ship.maxEnergy}\nHold Type: ${ship.definition.cargoKind ?? "none"}${hangarStatus}\nModules: ${ship.definition.modules.map(getModuleLabel).join(", ")}`;
return `${ship.definition.label}${ship.systemId}\nFaction: ${ship.factionId}\nClass: ${ship.definition.shipClass}\nState: ${ship.state}${dockedAt ? ` @ ${dockedAt}` : ""}\nOrder: ${ship.order.kind}\nFleet: ${ship.fleetId ?? "Independent"}${ship.isFleetCommander ? " • Commander" : ship.isWingLeader ? " • Wing Leader" : ""}\nBehavior: ${ship.behavior}\nHealth: ${Math.round(ship.health)}/${ship.maxHealth}\nCargo: ${Math.round(getShipCargoAmount(ship))}/${ship.definition.cargoCapacity || 0} ${getItemLabel(ship.cargoItemId)}\nFuel: ${ship.fuel.toFixed(0)}/${ship.maxFuel}\nEnergy: ${ship.energy.toFixed(0)}/${ship.maxEnergy}\nHold Type: ${ship.definition.cargoKind ?? "none"}${hangarStatus}\nModules: ${ship.definition.modules.map(getModuleLabel).join(", ")}`;
},
)
.join("\n\n");
@@ -88,7 +196,7 @@ export function describeStation(station: StationInstance, ships: ShipInstance[],
? "Fabricating industrial parts and equipment"
: "Managing local trade traffic";
return `${station.definition.label}${station.systemId}\nRole: ${station.definition.category}\nActivity: ${activity}\nLocal Fleets: ${localFleets}\nDocking: ${station.dockedShipIds.size}/${station.definition.dockingCapacity}\nFuel: ${station.fuel.toFixed(0)}/${station.maxFuel}\nEnergy: ${station.energy.toFixed(0)}/${station.maxEnergy}\nBulk Solid: ${Math.round(station.inventory["bulk-solid"])}\nContainer: ${Math.round(station.inventory.container)}\nManufactured: ${Math.round(station.inventory.manufactured)}\nModules: ${station.modules.map(getModuleLabel).join(", ")}\n${productionStatus}Radius: ${station.definition.radius}`;
return `${station.definition.label}${station.systemId}\nFaction: ${station.factionId}\nRole: ${station.definition.category}\nActivity: ${activity}\nLocal Fleets: ${localFleets}\nDocking: ${station.dockedShipIds.size}/${station.definition.dockingCapacity}\nHealth: ${Math.round(station.health)}/${station.maxHealth}\nFuel: ${station.fuel.toFixed(0)}/${station.maxFuel}\nEnergy: ${station.energy.toFixed(0)}/${station.maxEnergy}\nBulk Solid: ${Math.round(station.inventory["bulk-solid"])}\nContainer: ${Math.round(station.inventory.container)}\nManufactured: ${Math.round(station.inventory.manufactured)}\nModules: ${station.modules.map(getModuleLabel).join(", ")}\n${productionStatus}Radius: ${station.definition.radius}`;
}
export function getFleetWindowMarkup(
@@ -178,6 +286,15 @@ function describeShipNode(ship: ShipInstance): string {
return `${ship.definition.shipClass}${ship.state}${ship.order.kind}${ship.behavior}`;
}
function renderCard(title: string, lines: string[]) {
return `
<article class="selection-strip-card">
<span class="selection-strip-card-title">${title}</span>
${lines.map((line) => `<span class="selection-strip-card-line">${line}</span>`).join("")}
</article>
`;
}
function collectWingShipIds(fleet: FleetInstance, rootWingId: string): string[] {
const wingIds = new Set<string>([rootWingId]);
let changed = true;

View File

@@ -132,25 +132,21 @@ export function drawStrategicOverlay({
context.textBaseline = "middle";
if (viewLevel === "solar") {
stations
.filter((station) => station.systemId === systems[selectedSystemIndex]?.definition.id)
.forEach((station) => {
const screen = projectWorldToScreen(station.group.position, camera);
if (screen) {
drawStationSymbol(context, screen.x, screen.y, station, 14, station === selectedStation);
}
});
ships
.filter((ship) => ship.systemId === systems[selectedSystemIndex]?.definition.id && ship.state !== "docked")
.forEach((ship) => {
const screen = projectWorldToScreen(ship.group.position, camera);
if (screen) {
drawShipSymbol(context, screen.x, screen.y, ship, 10, selection.includes(ship));
}
});
drawFleetLinks(context, camera, fleets, ships, systems[selectedSystemIndex]?.definition.id, activeFleetId);
selection.forEach((ship) => {
const screen = projectWorldToScreen(ship.group.position, camera);
if (screen) {
drawShipSymbol(context, screen.x, screen.y, ship, 10, true);
}
});
if (selectedStation) {
const screen = projectWorldToScreen(selectedStation.group.position, camera);
if (screen) {
drawStationSymbol(context, screen.x, screen.y, selectedStation, 14, true);
}
}
} else {
systems.forEach((system) => {
const screen = projectWorldToScreen(system.center, camera);

View File

@@ -0,0 +1,353 @@
import { solarSystemDefinitions } from "../data/catalog";
import type {
AsteroidFieldDefinition,
FactionDefinition,
PatrolRouteDefinition,
PlanetDefinition,
ResourceNodeDefinition,
ScenarioDefinition,
SolarSystemDefinition,
UniverseDefinition,
} from "../types";
const TOTAL_SYSTEMS = 28;
const STAR_PALETTES = [
{ starColor: "#ffd27a", starGlow: "#ffb14a" },
{ starColor: "#9dc6ff", starGlow: "#66a0ff" },
{ starColor: "#ffb7a1", starGlow: "#ff7d66" },
{ starColor: "#f3f0ff", starGlow: "#b49cff" },
{ starColor: "#b6ffe0", starGlow: "#5ed6b1" },
{ starColor: "#ffe49a", starGlow: "#ffc14a" },
];
const PLANET_COLORS = ["#d4a373", "#58a36c", "#6ea7d4", "#6958a8", "#c48f6a", "#4f84c4", "#8f8fb0", "#d46e8a"];
const FRONTIER_PREFIXES = ["Aquila", "Draco", "Lyra", "Cygnus", "Orion", "Vela", "Carina", "Pavo", "Vesper", "Altair"];
const FRONTIER_SUFFIXES = ["Reach", "Gate", "Crown", "Run", "March", "Drift", "Anchor", "Span", "Wake", "Vale"];
const EMPIRE_ARCHETYPES = [
{ id: "solar-dominion", label: "Solar Dominion", color: "#f0c36d", accent: "#ffefb0" },
{ id: "aegis-state", label: "Aegis State", color: "#72b7ff", accent: "#d5ecff" },
{ id: "verdant-combine", label: "Verdant Combine", color: "#77dd8c", accent: "#d7ffe2" },
{ id: "iron-clans", label: "Iron Clans", color: "#ff926c", accent: "#ffd8c9" },
];
const PIRATE_ARCHETYPES = [
{ id: "black-flag", label: "Black Flag Cartel", color: "#ff5a6f", accent: "#ffd0d6" },
{ id: "void-rats", label: "Void Rats", color: "#9a7cff", accent: "#e7dcff" },
{ id: "grim-sons", label: "Grim Sons", color: "#ff8d54", accent: "#ffe1d1" },
{ id: "night-jackals", label: "Night Jackals", color: "#a0ff7f", accent: "#e8ffd8" },
{ id: "red-knives", label: "Red Knives", color: "#ff6a8c", accent: "#ffd7e2" },
{ id: "dust-serpents", label: "Dust Serpents", color: "#c2a56f", accent: "#f0e1c3" },
];
export function generateUniverse(seed = Math.floor(Math.random() * 0x7fffffff)): UniverseDefinition {
const rng = createRng(seed);
const systems: SolarSystemDefinition[] = [];
const empires: FactionDefinition[] = [];
const pirates: FactionDefinition[] = [];
const centralSystems = Array.from({ length: 3 }, (_, index) => createCentralSystem(index, rng));
systems.push(...centralSystems);
EMPIRE_ARCHETYPES.forEach((archetype, index) => {
const angle = (index / EMPIRE_ARCHETYPES.length) * Math.PI * 2;
const capitalSystem = createEmpireCapitalSystem(archetype.label, archetype.id, angle, rng);
const miningSystem = createEmpireMiningSystem(archetype.label, archetype.id, angle + 0.22, rng);
systems.push(capitalSystem, miningSystem);
empires.push({
id: archetype.id,
label: archetype.label,
kind: "empire",
color: archetype.color,
accent: archetype.accent,
homeSystemId: capitalSystem.id,
miningSystemId: miningSystem.id,
targetSystemIds: centralSystems.map((system) => system.id),
rivals: EMPIRE_ARCHETYPES.filter((_, rivalIndex) => rivalIndex !== index).map((rival) => rival.id),
});
});
PIRATE_ARCHETYPES.forEach((archetype, index) => {
const targetEmpire = empires[index % empires.length];
const secondaryEmpire = empires[(index + 1) % empires.length];
const pirateSystem = createPirateBaseSystem(archetype.label, archetype.id, index, rng);
systems.push(pirateSystem);
pirates.push({
id: archetype.id,
label: archetype.label,
kind: "pirate",
color: archetype.color,
accent: archetype.accent,
homeSystemId: pirateSystem.id,
targetSystemIds: [targetEmpire.homeSystemId, targetEmpire.miningSystemId ?? targetEmpire.homeSystemId],
rivals: [targetEmpire.id, secondaryEmpire.id],
pirateForFactionId: targetEmpire.id,
});
});
while (systems.length < TOTAL_SYSTEMS) {
systems.push(createFrontierSystem(systems.length, rng));
}
const factions = [...empires, ...pirates];
empires.forEach((empire, index) => {
empire.rivals.push(
pirates[index].id,
pirates[(index + 4) % pirates.length].id,
);
});
return {
seed,
label: `Autonomous Cluster ${seed.toString(16).toUpperCase()}`,
systems,
scenario: createScenario(systems, factions),
};
}
function createScenario(systems: SolarSystemDefinition[], factions: FactionDefinition[]): ScenarioDefinition {
const empires = factions.filter((faction) => faction.kind === "empire");
const pirates = factions.filter((faction) => faction.kind === "pirate");
const initialStations: ScenarioDefinition["initialStations"] = [];
const shipFormations: ScenarioDefinition["shipFormations"] = [];
const patrolRoutes: PatrolRouteDefinition[] = [];
const centralSystemIds = systems.filter((system) => system.id.startsWith("central-")).map((system) => system.id);
empires.forEach((faction) => {
const capital = systems.find((system) => system.id === faction.homeSystemId);
const mining = systems.find((system) => system.id === faction.miningSystemId);
if (!capital || !mining) {
return;
}
initialStations.push(
{ constructibleId: "trade-hub", systemId: capital.id, factionId: faction.id, planetIndex: 1, lagrangeSide: 1 },
{
constructibleId: "farm-ring",
systemId: capital.id,
factionId: faction.id,
planetIndex: 0,
lagrangeSide: -1,
seedStock: { gas: 120, water: 160 },
},
{
constructibleId: "manufactory",
systemId: capital.id,
factionId: faction.id,
planetIndex: Math.min(2, capital.planets.length - 1),
lagrangeSide: 1,
seedStock: { "refined-metals": 200, water: 100, "ship-equipment": 40, "naval-guns": 24 },
},
{
constructibleId: "shipyard",
systemId: capital.id,
factionId: faction.id,
planetIndex: Math.min(3, capital.planets.length - 1),
lagrangeSide: -1,
seedStock: { "ship-parts": 80, "ammo-crates": 70, "hull-sections": 100, "ship-equipment": 40 },
},
{ constructibleId: "defense-grid", systemId: capital.id, factionId: faction.id, planetIndex: 1, lagrangeSide: -1 },
{
constructibleId: "refinery",
systemId: mining.id,
factionId: faction.id,
planetIndex: 0,
lagrangeSide: 1,
seedStock: { ore: 240, "refined-metals": 80 },
},
{ constructibleId: "defense-grid", systemId: mining.id, factionId: faction.id, planetIndex: 1, lagrangeSide: -1 },
);
shipFormations.push(
{ shipId: "frigate", count: 1, center: localPoint(capital, 180, 120), systemId: capital.id, factionId: faction.id },
{ shipId: "hauler", count: 1, center: localPoint(capital, 280, -120), systemId: capital.id, factionId: faction.id },
{ shipId: "miner", count: 1, center: localPoint(mining, 180, 100), systemId: mining.id, factionId: faction.id },
);
patrolRoutes.push(createPatrolRoute(capital), createPatrolRoute(mining));
});
pirates.forEach((faction) => {
const base = systems.find((system) => system.id === faction.homeSystemId);
if (!base) {
return;
}
initialStations.push(
{
constructibleId: "trade-hub",
systemId: base.id,
factionId: faction.id,
planetIndex: 0,
lagrangeSide: 1,
seedStock: { "refined-metals": 100, "ship-parts": 30, "ammo-crates": 30 },
},
{ constructibleId: "defense-grid", systemId: base.id, factionId: faction.id, planetIndex: 1, lagrangeSide: -1 },
);
shipFormations.push(
{ shipId: "frigate", count: 4, center: localPoint(base, 180, 60), systemId: base.id, factionId: faction.id },
{ shipId: "destroyer", count: 2, center: localPoint(base, 250, 120), systemId: base.id, factionId: faction.id },
{ shipId: "hauler", count: 1, center: localPoint(base, 320, -90), systemId: base.id, factionId: faction.id },
);
patrolRoutes.push(createPatrolRoute(base));
});
const firstEmpire = empires[0];
return {
initialStations,
shipFormations,
patrolRoutes,
miningDefaults: {
nodeSystemId: firstEmpire.miningSystemId ?? firstEmpire.homeSystemId,
refinerySystemId: firstEmpire.homeSystemId,
},
factions,
centralSystemIds,
};
}
function createEmpireCapitalSystem(label: string, factionId: string, angle: number, rng: () => number): SolarSystemDefinition {
const base = solarSystemDefinitions[0];
const radius = 6200 + Math.floor(rng() * 700);
return {
...base,
id: `${factionId}-capital`,
label: `${label} Prime`,
position: [Math.round(Math.cos(angle) * radius), 0, Math.round(Math.sin(angle) * radius)],
starSize: 48 + Math.floor(rng() * 10),
gravityWellRadius: 210 + Math.floor(rng() * 18),
asteroidField: createAsteroidFieldDefinition(rng, false),
resourceNodes: [],
planets: createPlanets(4 + Math.floor(rng() * 2), rng),
};
}
function createEmpireMiningSystem(label: string, factionId: string, angle: number, rng: () => number): SolarSystemDefinition {
const base = solarSystemDefinitions[1];
const radius = 7700 + Math.floor(rng() * 900);
return {
...base,
id: `${factionId}-belt`,
label: `${label} Belt`,
position: [Math.round(Math.cos(angle) * radius), 0, Math.round(Math.sin(angle) * radius)],
starSize: 42 + Math.floor(rng() * 10),
gravityWellRadius: 220 + Math.floor(rng() * 20),
asteroidField: createAsteroidFieldDefinition(rng, true),
resourceNodes: createResourceNodes(4 + Math.floor(rng() * 2), rng, 3600, 5200),
planets: createPlanets(3 + Math.floor(rng() * 2), rng),
};
}
function createCentralSystem(index: number, rng: () => number): SolarSystemDefinition {
const palette = STAR_PALETTES[(index + 1) % STAR_PALETTES.length];
const angle = (index / 3) * Math.PI * 2 + rng() * 0.3;
const radius = 900 + Math.floor(rng() * 500);
return {
id: `central-${index + 1}`,
label: ["Crown Basin", "Throne Verge", "Golden Axis"][index] ?? `Central ${index + 1}`,
position: [Math.round(Math.cos(angle) * radius), 0, Math.round(Math.sin(angle) * radius)],
starColor: palette.starColor,
starGlow: palette.starGlow,
starSize: 50 + Math.floor(rng() * 14),
gravityWellRadius: 240 + Math.floor(rng() * 28),
asteroidField: createAsteroidFieldDefinition(rng, true),
resourceNodes: createResourceNodes(6 + Math.floor(rng() * 3), rng, 5200, 7600),
planets: createPlanets(4 + Math.floor(rng() * 2), rng),
};
}
function createPirateBaseSystem(label: string, factionId: string, index: number, rng: () => number): SolarSystemDefinition {
const palette = STAR_PALETTES[(index + 3) % STAR_PALETTES.length];
const angle = (index / PIRATE_ARCHETYPES.length) * Math.PI * 2 + 0.35;
const radius = 9800 + Math.floor(rng() * 1200);
return {
id: `${factionId}-den`,
label: `${label} Den`,
position: [Math.round(Math.cos(angle) * radius), 0, Math.round(Math.sin(angle) * radius)],
starColor: palette.starColor,
starGlow: palette.starGlow,
starSize: 36 + Math.floor(rng() * 10),
gravityWellRadius: 180 + Math.floor(rng() * 30),
asteroidField: createAsteroidFieldDefinition(rng, true),
resourceNodes: createResourceNodes(2 + Math.floor(rng() * 2), rng, 1600, 2600),
planets: createPlanets(2 + Math.floor(rng() * 2), rng),
};
}
function createFrontierSystem(index: number, rng: () => number): SolarSystemDefinition {
const angle = index * 2.399963229728653 + rng() * 0.4;
const radius = 3600 + 900 * Math.sqrt(index) + rng() * 600;
const palette = STAR_PALETTES[Math.floor(rng() * STAR_PALETTES.length)];
const hasResources = rng() > 0.45;
return {
id: `frontier-${index + 1}`,
label: `${FRONTIER_PREFIXES[index % FRONTIER_PREFIXES.length]} ${FRONTIER_SUFFIXES[Math.floor(rng() * FRONTIER_SUFFIXES.length)]}`,
position: [Math.round(Math.cos(angle) * radius), 0, Math.round(Math.sin(angle) * radius)],
starColor: palette.starColor,
starGlow: palette.starGlow,
starSize: 34 + Math.round(rng() * 18),
gravityWellRadius: 185 + Math.round(rng() * 60),
asteroidField: createAsteroidFieldDefinition(rng, hasResources),
resourceNodes: hasResources ? createResourceNodes(1 + Math.floor(rng() * 3), rng, 1800, 3400) : [],
planets: createPlanets(2 + Math.floor(rng() * 3), rng),
};
}
function createAsteroidFieldDefinition(rng: () => number, dense: boolean): AsteroidFieldDefinition {
return {
decorationCount: dense ? 180 + Math.floor(rng() * 70) : 90 + Math.floor(rng() * 70),
radiusOffset: 290 + Math.floor(rng() * 100),
radiusVariance: 70 + Math.floor(rng() * 80),
heightVariance: 12 + Math.floor(rng() * 12),
};
}
function createPlanets(count: number, rng: () => number): PlanetDefinition[] {
const planets: PlanetDefinition[] = [];
let orbitRadius = 150 + Math.floor(rng() * 40);
for (let index = 0; index < count; index += 1) {
orbitRadius += 120 + Math.floor(rng() * 90);
planets.push({
label: `${String.fromCharCode(65 + index)}-${Math.floor(rng() * 90 + 10)}`,
orbitRadius,
orbitSpeed: Number((0.05 + rng() * 0.14).toFixed(3)),
size: 18 + Math.floor(rng() * 30),
color: PLANET_COLORS[Math.floor(rng() * PLANET_COLORS.length)],
tilt: Number(((rng() - 0.5) * 0.8).toFixed(2)),
hasRing: rng() > 0.72,
});
}
return planets;
}
function createResourceNodes(count: number, rng: () => number, minOre: number, maxOre: number): ResourceNodeDefinition[] {
return Array.from({ length: count }, (_, index) => ({
angle: Number((((index / count) * Math.PI * 2 + rng() * 0.7) % (Math.PI * 2)).toFixed(6)),
radiusOffset: 300 + Math.floor(rng() * 140),
oreAmount: minOre + Math.floor(rng() * (maxOre - minOre)),
itemId: "ore",
shardCount: 5 + Math.floor(rng() * 5),
}));
}
function createPatrolRoute(system: SolarSystemDefinition): PatrolRouteDefinition {
return {
systemId: system.id,
points: [
localPoint(system, 160, 90),
localPoint(system, 340, -180),
localPoint(system, 560, 210),
localPoint(system, 240, 340),
],
};
}
function localPoint(system: SolarSystemDefinition, x: number, z: number): [number, number, number] {
return [system.position[0] + x, 0, system.position[2] + z];
}
function createRng(seed: number) {
let value = seed >>> 0;
return () => {
value += 0x6d2b79f5;
let result = Math.imul(value ^ (value >>> 15), 1 | value);
result ^= result + Math.imul(result ^ (result >>> 7), 61 | result);
return ((result ^ (result >>> 14)) >>> 0) / 4294967296;
};
}

View File

@@ -2,14 +2,13 @@ import * as THREE from "three";
import {
constructibleDefinitionsById,
gameBalance,
scenarioDefinition,
shipDefinitionsById,
solarSystemDefinitions,
} from "../data/catalog";
import { createEmptyInventory } from "../state/inventory";
import type {
ConstructibleDefinition,
ResourceNode,
ScenarioDefinition,
SelectableTarget,
ShipDefinition,
ShipInstance,
@@ -31,6 +30,8 @@ interface BuildWorldResult {
export function buildInitialWorld(
scene: THREE.Scene,
selectableTargets: Map<THREE.Object3D, SelectableTarget>,
systemsDefinition: SolarSystemDefinition[],
scenarioDefinition: ScenarioDefinition,
): BuildWorldResult {
const systems: SolarSystemInstance[] = [];
const nodes: ResourceNode[] = [];
@@ -41,6 +42,9 @@ export function buildInitialWorld(
let shipId = 0;
let stationId = 0;
let nodeId = 0;
const factionColors = new Map(
(scenarioDefinition.factions ?? []).map((faction) => [faction.id, faction.color]),
);
scene.add(new THREE.HemisphereLight(0x6ba6ff, 0x03050a, 0.38));
scene.add(new THREE.AmbientLight(0x8397b8, 0.28));
@@ -49,11 +53,11 @@ export function buildInitialWorld(
createNebulae(scene);
const starfield = createStarfield(scene);
solarSystemDefinitions.forEach((definition) => {
systemsDefinition.forEach((definition) => {
systems.push(createSolarSystem(scene, definition, nodes, () => {
nodeId += 1;
return `node-${nodeId}`;
}));
}, selectableTargets));
});
createStrategicLinks(strategicLinks, systems);
@@ -70,8 +74,11 @@ export function buildInitialWorld(
definition,
systemId: plan.systemId,
position: plan.position ? new THREE.Vector3(...plan.position) : new THREE.Vector3(),
factionId: plan.factionId ?? "neutral",
factionColor: factionColors.get(plan.factionId ?? "") ?? "#b4c9da",
planetIndex: plan.planetIndex,
lagrangeSide: plan.lagrangeSide,
seedStock: plan.seedStock,
selectableTargets,
}),
);
@@ -83,10 +90,12 @@ export function buildInitialWorld(
throw new Error(`Missing ship definition ${plan.shipId}`);
}
for (let i = 0; i < plan.count; i += 1) {
const ship = createShip({
const ship = createShipInstance({
id: `ship-${++shipId}`,
definition,
systemId: plan.systemId,
factionId: plan.factionId ?? "neutral",
factionColor: factionColors.get(plan.factionId ?? "") ?? definition.color,
selectableTargets,
});
ship.group.position
@@ -110,6 +119,7 @@ function createSolarSystem(
definition: SolarSystemDefinition,
nodes: ResourceNode[],
nextNodeId: () => string,
selectableTargets?: Map<THREE.Object3D, SelectableTarget>,
) {
const root = new THREE.Group();
root.position.set(...definition.position);
@@ -155,6 +165,19 @@ function createSolarSystem(
planet.receiveShadow = true;
orbitRoot.add(planet);
const selectionRing = new THREE.Mesh(
new THREE.RingGeometry(planetDefinition.size * 1.35, planetDefinition.size * 1.55, 40),
new THREE.MeshBasicMaterial({
color: 0xf5e8a5,
transparent: true,
opacity: 0,
side: THREE.DoubleSide,
}),
);
selectionRing.rotation.x = -Math.PI / 2;
selectionRing.position.x = planetDefinition.orbitRadius;
orbitRoot.add(selectionRing);
let ringObject: THREE.Object3D | undefined;
if (planetDefinition.hasRing) {
const ring = new THREE.Mesh(
@@ -173,7 +196,16 @@ function createSolarSystem(
}
root.add(orbitRoot);
return { group: orbitRoot, mesh: planet, orbitSpeed: planetDefinition.orbitSpeed, ring: ringObject };
return {
definition: planetDefinition,
group: orbitRoot,
mesh: planet,
orbitSpeed: planetDefinition.orbitSpeed,
ring: ringObject,
selectionRing,
systemId: definition.id,
index,
};
});
const orbitLines = definition.planets.map((planetDefinition) => {
@@ -196,8 +228,7 @@ function createSolarSystem(
const asteroidDecorations = createAsteroidField(definition, root, nodes, nextNodeId);
const strategicMarker = createStrategicMarker(scene, definition);
return {
const system = {
definition,
root,
center: new THREE.Vector3(...definition.position),
@@ -207,7 +238,23 @@ function createSolarSystem(
orbitLines,
asteroidDecorations,
strategicMarker,
controlProgress: 0,
strategicValue: "frontier" as const,
};
if (selectableTargets) {
selectableTargets.set(star, { kind: "system", system });
selectableTargets.set(glow, { kind: "system", system });
selectableTargets.set(strategicMarker, { kind: "system", system });
planets.forEach((planet) => {
selectableTargets.set(planet.mesh, { kind: "planet", system, planet });
if (planet.ring) {
selectableTargets.set(planet.ring, { kind: "planet", system, planet });
}
});
}
return system;
}
function createAsteroidField(
@@ -314,18 +361,35 @@ function createStrategicLinks(strategicLinks: THREE.Group, systems: SolarSystemI
if (systems.length < 2) {
return;
}
const line = new THREE.Line(
new THREE.BufferGeometry().setFromPoints(systems.map((system) => system.center)),
new THREE.LineDashedMaterial({
color: 0x5e8fbe,
dashSize: 120,
gapSize: 80,
transparent: true,
opacity: 0.5,
}),
);
line.computeLineDistances();
strategicLinks.add(line);
const material = new THREE.LineDashedMaterial({
color: 0x5e8fbe,
dashSize: 120,
gapSize: 80,
transparent: true,
opacity: 0.5,
});
const links = new Set<string>();
systems.forEach((system) => {
systems
.filter((candidate) => candidate.definition.id !== system.definition.id)
.sort((left, right) => system.center.distanceToSquared(left.center) - system.center.distanceToSquared(right.center))
.slice(0, 2)
.forEach((neighbor) => {
const key = [system.definition.id, neighbor.definition.id].sort().join(":");
if (links.has(key)) {
return;
}
links.add(key);
const line = new THREE.Line(
new THREE.BufferGeometry().setFromPoints([system.center, neighbor.center]),
material,
);
line.computeLineDistances();
strategicLinks.add(line);
});
});
strategicLinks.visible = false;
}
@@ -335,8 +399,11 @@ export function createStationInstance({
definition,
systemId,
position,
factionId,
factionColor,
planetIndex,
lagrangeSide,
seedStock,
selectableTargets,
}: {
id: string;
@@ -344,8 +411,11 @@ export function createStationInstance({
definition: ConstructibleDefinition;
systemId: string;
position: THREE.Vector3;
factionId: string;
factionColor: string;
planetIndex?: number;
lagrangeSide?: -1 | 1;
seedStock?: Partial<Record<string, number>>;
selectableTargets: Map<THREE.Object3D, SelectableTarget>;
}) {
const group = new THREE.Group();
@@ -355,7 +425,7 @@ export function createStationInstance({
new THREE.CylinderGeometry(definition.radius * 0.4, definition.radius * 0.6, definition.radius * 1.2, 8),
new THREE.MeshStandardMaterial({
color: definition.color,
emissive: new THREE.Color(definition.color).multiplyScalar(0.12),
emissive: new THREE.Color(factionColor).multiplyScalar(0.12),
roughness: 0.55,
metalness: 0.45,
}),
@@ -369,7 +439,7 @@ export function createStationInstance({
new THREE.TorusGeometry(definition.radius, Math.max(2.4, definition.radius * 0.08), 18, 48),
new THREE.MeshStandardMaterial({
color: 0xcdd8e5,
emissive: new THREE.Color(definition.color).multiplyScalar(0.05),
emissive: new THREE.Color(factionColor).multiplyScalar(0.05),
roughness: 0.4,
metalness: 0.7,
}),
@@ -380,7 +450,7 @@ export function createStationInstance({
const selectionRing = new THREE.Mesh(
new THREE.RingGeometry(definition.radius * 1.3, definition.radius * 1.5, 40),
new THREE.MeshBasicMaterial({
color: definition.color,
color: factionColor,
transparent: true,
opacity: 0,
side: THREE.DoubleSide,
@@ -399,7 +469,7 @@ export function createStationInstance({
);
const beacon = new THREE.Mesh(
new THREE.BoxGeometry(5, 2, 9),
new THREE.MeshBasicMaterial({ color: definition.color, transparent: true, opacity: 0.75 }),
new THREE.MeshBasicMaterial({ color: factionColor, transparent: true, opacity: 0.75 }),
);
beacon.position.copy(port);
beacon.lookAt(new THREE.Vector3(0, gameBalance.yPlane, 0));
@@ -432,31 +502,62 @@ export function createStationInstance({
processTimer: 0,
activeBatch: 0,
inventory: createEmptyInventory(),
itemStocks: {},
itemStocks: Object.fromEntries(
Object.entries(seedStock ?? {}).map(([itemId, amount]) => [itemId, amount ?? 0]),
),
dockedShipIds: new Set(),
dockingPorts,
modules: definition.modules,
orbitalParentPlanetIndex: planetIndex,
lagrangeSide,
factionId,
factionColor,
health: definition.radius * 160,
maxHealth: definition.radius * 160,
weaponRange: definition.category === "defense" ? 280 : definition.category === "shipyard" ? 180 : 0,
weaponDamage: definition.category === "defense" ? 22 : definition.category === "shipyard" ? 8 : 0,
weaponTimer: 0,
fuel: 800,
energy: 1200,
maxFuel: 800,
maxEnergy: 1200,
};
Object.entries(seedStock ?? {}).forEach(([itemId, rawAmount]) => {
const amount = rawAmount ?? 0;
if (itemId === "ore") {
station.oreStored += amount;
station.inventory["bulk-solid"] += amount;
} else if (itemId === "water") {
station.inventory["bulk-liquid"] += amount;
} else if (itemId === "gas") {
station.inventory["bulk-gas"] += amount;
} else if (itemId === "refined-metals") {
station.refinedStock += amount;
station.inventory.manufactured += amount;
} else if (itemId === "ammo-crates" || itemId === "ship-equipment" || itemId === "drone-parts") {
station.inventory.container += amount;
} else {
station.inventory.manufactured += amount;
}
});
selectableTargets.set(core, { kind: "station", station });
selectableTargets.set(ring, { kind: "station", station });
return station;
}
function createShip({
export function createShipInstance({
id,
definition,
systemId,
factionId,
factionColor,
selectableTargets,
}: {
id: string;
definition: ShipDefinition;
systemId: string;
factionId: string;
factionColor: string;
selectableTargets: Map<THREE.Object3D, SelectableTarget>;
}) {
const group = new THREE.Group();
@@ -470,7 +571,7 @@ function createShip({
for (let i = 0; i < 5; i += 1) {
const streak = new THREE.Mesh(
new THREE.CylinderGeometry(0.12, 0.5, definition.size * 8, 8),
new THREE.MeshBasicMaterial({ color: definition.color, transparent: true, opacity: 0.22 }),
new THREE.MeshBasicMaterial({ color: factionColor, transparent: true, opacity: 0.22 }),
);
streak.rotation.z = Math.PI / 2;
streak.position.set(-definition.size * (2 + i * 1.7), (i - 2) * 0.45, 0);
@@ -480,7 +581,7 @@ function createShip({
const bodyMaterial = new THREE.MeshStandardMaterial({
color: definition.hullColor,
emissive: new THREE.Color(definition.color).multiplyScalar(0.08),
emissive: new THREE.Color(factionColor).multiplyScalar(0.08),
roughness: 0.45,
metalness: 0.7,
});
@@ -496,8 +597,8 @@ function createShip({
const nose = new THREE.Mesh(
new THREE.ConeGeometry(definition.size * 0.7, definition.size * 1.8, 6),
new THREE.MeshStandardMaterial({
color: definition.color,
emissive: new THREE.Color(definition.color).multiplyScalar(0.12),
color: factionColor,
emissive: new THREE.Color(factionColor).multiplyScalar(0.12),
roughness: 0.35,
metalness: 0.65,
}),
@@ -518,7 +619,7 @@ function createShip({
new THREE.BoxGeometry(definition.size * 2.8, definition.size * 1.2, definition.size * 1.5),
new THREE.MeshStandardMaterial({
color: 0x4c6272,
emissive: new THREE.Color(definition.color).multiplyScalar(0.04),
emissive: new THREE.Color(factionColor).multiplyScalar(0.04),
roughness: 0.5,
metalness: 0.75,
}),
@@ -530,7 +631,7 @@ function createShip({
[-1, 1].forEach((side) => {
const bay = new THREE.Mesh(
new THREE.BoxGeometry(definition.size * 1.1, definition.size * 0.38, definition.size * 0.86),
new THREE.MeshBasicMaterial({ color: definition.color, transparent: true, opacity: 0.45 }),
new THREE.MeshBasicMaterial({ color: factionColor, transparent: true, opacity: 0.45 }),
);
bay.position.set(-definition.size * 0.3, side * definition.size * 0.52, 0);
visual.add(bay);
@@ -539,7 +640,7 @@ function createShip({
const engineGlow = new THREE.Mesh(
new THREE.SphereGeometry(definition.size * 0.35, 14, 14),
new THREE.MeshBasicMaterial({ color: definition.color, transparent: true, opacity: 0.72 }),
new THREE.MeshBasicMaterial({ color: factionColor, transparent: true, opacity: 0.72 }),
);
engineGlow.position.x = -definition.size * 1.8;
visual.add(engineGlow);
@@ -547,7 +648,7 @@ function createShip({
const ring = new THREE.Mesh(
new THREE.RingGeometry(definition.size * 1.5, definition.size * 1.9, 32),
new THREE.MeshBasicMaterial({
color: definition.color,
color: factionColor,
transparent: true,
opacity: 0,
side: THREE.DoubleSide,
@@ -567,7 +668,7 @@ function createShip({
);
const beacon = new THREE.Mesh(
new THREE.BoxGeometry(definition.size * 0.26, 0.9, definition.size * 0.42),
new THREE.MeshBasicMaterial({ color: definition.color, transparent: true, opacity: 0.52 }),
new THREE.MeshBasicMaterial({ color: factionColor, transparent: true, opacity: 0.52 }),
);
beacon.position.copy(port);
beacon.visible = dockingCapacity > 0;
@@ -595,6 +696,16 @@ function createShip({
inventory: createEmptyInventory(),
cargoItemId: definition.cargoItemId,
actionTimer: 0,
factionId,
factionColor,
health: definition.maxHealth,
maxHealth: definition.maxHealth,
weaponRange:
definition.shipClass === "capital" ? 260 : definition.shipClass === "cruiser" ? 220 : definition.shipClass === "destroyer" ? 180 : 140,
weaponDamage:
definition.shipClass === "capital" ? 30 : definition.shipClass === "cruiser" ? 18 : definition.shipClass === "destroyer" ? 12 : 7,
weaponCooldown: definition.shipClass === "capital" ? 1.2 : definition.shipClass === "cruiser" ? 0.9 : 0.7,
weaponTimer: 0,
fuel: 220,
energy: 260,
maxFuel: 220,