diff --git a/SESSION.md b/SESSION.md index c1941d0..fe60aa5 100644 --- a/SESSION.md +++ b/SESSION.md @@ -2,252 +2,205 @@ ## Project State -This repository now contains a playable Three.js/Vite prototype for a space RTS / economy sim testbed inspired by EVE Online and X4. +This repository now contains a playable Three.js/Vite autonomous space-sim prototype that has moved away from a player-command RTS testbed and toward a game-master / observer simulation. -The codebase has been refactored away from a single monolithic `GameApp.ts` toward a more maintainable, data-driven structure. Authored game content now lives in JSON catalogs, while runtime code is split into domain types, world-building helpers, UI presenters, and rendering helpers. +The codebase is still TypeScript + Three.js on Vite, with authored catalogs under `src/game/data/`, but the runtime now centers on: -The current prototype includes: +- procedural universe generation +- autonomous faction behavior +- fleet / wing hierarchy +- economic production loops +- pirate harassment +- strategic system control +- observer-oriented HUD and camera controls -- Two solar systems: `Helios Reach` and `Perseus Gate` -- A large space environment with stars, planets, orbit lines, nebulae, asteroid/resource fields, and starfield -- RTS-style ship selection, command issuance, camera movement, zoom levels, and follow-camera support -- Three view levels based on zoom: `local`, `solar`, and `universe` -- A bottom command bar with selection info, order buttons, and a minimap -- A strategic HUD overlay that switches to NATO / military-style symbols at higher zoom levels -- A generic window layer, with an initial fleet command window for managing multi-wing groups -- App windows can now be dragged by their headers and resized from a corner grip -- `stargate` now exists as a constructible category in the same authored pipeline as other stations +## Current Prototype -## Major Gameplay Systems Added +The current build includes: -### World / Navigation +- a generated universe with a few dozen systems +- 4 empire factions inspired by EVE-style sovereign powers +- multiple pirate factions that raid empire space +- rich central systems that factions contest for control +- faction-owned stations, ships, inventories, and combat stats +- autonomous shipbuilding and limited outpost growth +- fleet and wing structure with behaviors such as: + - `command` + - `screen` + - `mining` + - `logistics` + - `escort` +- observer controls for camera orbit, pan, focus, and inspection -- Ships can travel between the two systems using staged FTL travel -- Travel flow includes: - - leaving gravity well - - FTL spool - - warp - - arrival -- FTL speed was increased and a basic warp streak / tunnel effect was added -- Local ship movement is no longer purely straight-line: - - ships bias toward curved orbital-style transfers around the system center - - idle ships hold a passive orbit instead of freezing in place +## Major Gameplay / Sim Systems -### Orbital Model +### Universe Generation -- Stations are no longer static arbitrary points -- Stations in `Helios` are placed on Lagrange-style offsets relative to planets -- Stations update position over time with the planetary orbital motion -- Ships and stations are beginning to behave like orbitals rather than free-floating markers +- Startup no longer uses the fixed two-system authored sandbox. +- `src/game/world/universeGenerator.ts` now generates: + - empire capitals + - empire mining systems + - pirate base systems + - central high-value systems + - frontier filler systems +- The generated scenario also assigns: + - faction definitions + - initial faction-owned stations + - initial ship formations + - central system IDs -### Units / AI / Orders +### Factions -- Ship roles currently in the prototype: - - military - - transport - - mining -- Ship classes now distinguish: - - frigate - - destroyer - - cruiser - - industrial - - capital -- Unit state machine now includes states for: - - idle / moving - - FTL travel - - mining and delivery - - docking approach / docking / docked / undocking - - patrol / escort -- Fleets are now a first-class gameplay concept: - - fleets have a commander, stance, high-level fleet order, and explicit wings - - wings can be nested via parent/child relationships to represent sub-wings - - ships now carry behavior metadata such as command, mining, escort, screen, or logistics - - fleet orders fan out into ship-level execution orders, keeping "order" separate from "behavior" -- Fleet tree selection is now manager-backed: - - fleets, wings, sub-wings, and ships can be selected directly from the fleet window - - selection is now handled through a dedicated selection manager rather than scattered UI state mutations -- Orders currently supported: - - move - - transfer - - mine - - patrol - - escort +- Runtime faction state now exists in `src/game/types.ts`. +- Factions track: + - credits + - ore mined + - goods produced + - ships built + - stations built + - ships lost + - enemy ships destroyed + - raids completed + - stolen cargo + - owned systems +- Empire factions and pirate factions are distinct runtime kinds. -### Docking / Logistics +### High-Level AI / Delegation -- Docking was added as a required step for transfer to stations -- Stations have limited docking capacity and explicit docking ports -- Carriers now act as mobile docking hosts for smaller combatants -- Carrier recovery was corrected so docking ships reserve a pad early and approach the moving pad directly instead of stalling behind the hull -- Mining ships now: - - mine ore in `Perseus` - - return to `Helios` - - dock at a refinery - - transfer ore - - undock and repeat -- Mining ships now correctly leave for the refinery once full even when the delivery leg is inter-system +- Faction AI now acts at a strategic level instead of directly micromanaging every ship. +- Empire AI chooses high-level goals such as: + - secure home and mining space + - contest central systems + - assign industrial fleets to mining loops +- Pirate AI chooses raid targets and dispatches fleets into hostile space. +- Fleet-level orders are now the intended command boundary between: + - faction strategy + - fleet / wing execution +- This work was specifically done to stop faction AI from stomping `screen` behavior with raw ship move orders. -### Economy / Inventory Foundations +### Fleets / Wings -- Added item storage classes: - - `bulk-solid` - - `bulk-liquid` - - `bulk-gas` - - `container` - - `manufactured` -- Added itemized manufactured goods for industrial progression: - - `hull-sections` - - `ammo-crates` - - `naval-guns` - - `ship-equipment` - - `ship-parts` -- Added deployable constructible kit items so buildables are also producible: - - `trade-hub-kit` - - `refinery-kit` - - `farm-ring-kit` - - `manufactory-kit` - - `shipyard-kit` - - `defense-grid-kit` - - `stargate-kit` -- Added module categories and starter module definitions for ships/stations -- Added explicit recipe data for refinery and fabrication processing -- Ships and stations now expose compatible cargo/storage/module metadata -- Refineries track: - - ore stored - - active refining batch - - refining timer - - refined output stock -- Stations now also maintain per-item stock internally -- Fabricator-array stations can now build ship parts, ammo, guns, and equipment from recipe data -- Fabricator-array stations can now also assemble deployable kits for constructibles, including stargates -- Refinery/manufactory processing now consumes itemized inputs and produces itemized outputs through a shared recipe-driven flow +- Fleet creation now groups ships per faction and role in `src/game/fleet/runtime.ts`. +- War fleets and industry fleets are generated from faction-owned ships. +- Wing behaviors remain meaningful at the tactical layer. +- `screen` is intended to remain subordinate to fleet command rather than independent faction micromanagement. -### Energy / Fuel +### Economy / Production -- Ships now track: - - fuel - - energy -- Stations now track: - - fuel - - energy -- Ships consume energy/fuel depending on activity -- Docked ships recharge energy -- Stations recharge energy passively +- Mining, refining, and fabrication still run through recipe-driven station logic. +- Faction-owned inventories are effectively pooled across faction stations for recipe consumption. +- Factions can build new ships when enough goods exist. +- Empires can build limited defense outposts in central systems they control. -## Testbed Layout +### Combat / Control -- `Helios Reach` is now the industrial / infrastructure system - - stations are concentrated there - - refinery loop terminates there -- `Perseus Gate` is now the extraction / resource system - - resource asteroid nodes are concentrated there - - miners operate there before hauling back +- Ships and relevant stations now have combat stats: + - health + - damage + - range + - cooldown +- Combat is lightweight and proximity-based. +- Central systems track control progress and controlling faction. +- Pirate ships can steal cargo from vulnerable civilian ships. + +## Starting State + +- Empires now start very small for easier debugging and growth observation. +- Each empire currently starts with only 3 ships: + - 1 frigate + - 1 hauler + - 1 miner +- Pirates still start with small raiding groups. ## UI / UX State -- Ship and station selection is supported -- Ship multi-selection is supported via click modifiers and marquee drag selection -- Selected ships can now be ordered to dock with the nearest friendly carrier -- Active fleets can be selected and focused through the fleet command window -- The fleet window now renders fleets as a real tree rather than a flat list -- Fleet tree nodes show order, behavior, and state on ship-level entries -- `Solar` and `universe` views now overlay high-level tactical symbology instead of relying only on 3D meshes -- `Solar` view now shows fleet hierarchy links between commanders and wing leaders -- Ships use role-specific long-range symbols: - - military: hostile/combat-style diamond iconography - - transport: boxed logistics symbol - - mining: angular resource / industrial symbol -- Stations and constructibles use square strategic markers with category-specific internal glyphs -- `Universe` view groups ships into fleet counts per system and role for cleaner strategic readability -- Focusing works for: - - single ships: follow camera - - stations: focus camera on the station -- Selection panels show: - - ship state, order, cargo, hold type, fuel, energy, modules - - station role, docking occupancy, stored resources, refinery timing, fuel, energy, modules +### Observer HUD + +- The old summary panel is gone. +- The old bottom RTS command bar has been removed. +- The bottom HUD is now a selection dock that shows: + - selection title + - status line + - horizontally scrolling cards for selected entities + - fallback observer details when nothing specific is selected +- Fleet launch controls were removed from the main HUD. +- A dedicated `Debug` window now contains the `New Universe` button. + +### Selection / Inspection + +- Selection is no longer limited to ships and stations. +- It is now possible to select: + - systems + - planets + - ships + - stations +- Double-click centers / focuses the clicked target. +- Multiple ship selections render as horizontal cards in the bottom dock. +- Fleet window tree selection still works. + +### Windows + +- Generic draggable / resizable app windows still exist. +- Main windows currently in use: + - `Fleet Command` + - `Debug` + +### Strategic Rendering + +- Strategic overlay and minimap infrastructure still exist. +- The minimap canvas is still created for renderer use, but it is no longer shown in the visible HUD. ## Controls -- `Left Click`: select ships or stations -- `Shift + Left Click`: add ships to ship selection -- `Ctrl/Cmd + Left Click`: toggle ships in selection -- `Left Drag`: marquee-select multiple ships -- `Right Click`: issue move/transfer orders -- `Right Click` with no ship selection and an active fleet: issue a fleet move order +- `Left Click`: inspect / select systems, planets, ships, or stations +- `Shift + Left Click`: add ships to multi-selection +- `Ctrl/Cmd + Left Click`: toggle ships in multi-selection +- `Left Drag`: marquee-select multiple visible ships +- `Double Click`: center / focus the clicked target +- `Middle Drag`: orbit camera +- `Shift + Middle Drag`: pan camera - `Mouse Wheel` or `-` / `=`: zoom - `W A S D`: pan camera - `Q / E`: rotate camera -- `F`: focus selection, and follow a single selected ship -- `G`: toggle the fleet command window -- `R`: assign selected compatible ships to dock with the nearest friendly carrier +- `F`: focus current selection +- `G`: toggle fleet command window - `Tab`: jump camera between systems -- `B`: toggle build mode -- `1-7`: choose constructible -- `M`: assign mining -- `P`: assign patrol -- `E`: assign escort ## Technical Notes -- The prototype is built with: - - Vite - - TypeScript - - Three.js -- Authored data now lives in JSON files under `src/game/data/`, including: - - `items.json` - - `recipes.json` - - `systems.json` - - `modules.json` - - `ships.json` - - `constructibles.json` - - `scenario.json` - - `balance.json` -- Shared domain and runtime types now live in `src/game/types.ts` -- Stations now carry both coarse storage totals and itemized stock for recipes -- The recipe graph now covers every current constructible and every current catalog item ID -- World construction is extracted into `src/game/world/worldFactory.ts` -- HUD creation and presentation logic are extracted into: +- Main runtime remains concentrated in `src/game/GameApp.ts` +- World construction and entity instancing: + - `src/game/world/worldFactory.ts` +- Procedural universe generation: + - `src/game/world/universeGenerator.ts` +- Fleet composition helpers: + - `src/game/fleet/runtime.ts` +- Selection state: + - `src/game/state/selectionManager.ts` +- HUD / presentation: - `src/game/ui/hud.ts` - `src/game/ui/presenters.ts` - `src/game/ui/strategicRenderer.ts` -- Fleet composition helpers now live in: - - `src/game/fleet/runtime.ts` -- Inventory helpers now live in `src/game/state/inventory.ts` -- Selection logic is now centralized in: - - `src/game/state/selectionManager.ts` -- Ship-to-ship docking now reuses the same generalized docking path as station docking inside `src/game/GameApp.ts` -- High-level symbology is rendered through a dedicated 2D HUD overlay canvas layered above the 3D scene - Production build is currently passing with `npm run build` ## Known Limitations / Caveats -- Orbital behavior is still an approximation for gameplay, not a full orbital mechanics simulation -- Stations are on Lagrange-style offsets, but not using a physically rigorous orbital solver -- Ship transfer paths are curved and orbit-biased, but still use authored steering rather than patched conics or n-body integration -- Fuel / energy exist but station refueling, resupply, and depletion failure states are still minimal -- Module definitions exist, and a generic window framework now exists, but there is still no actual ship/station designer yet -- Production is still automated by recipe priority; there is not yet a player-facing queue UI for choosing or reordering station recipes -- Constructible recipes currently output kit items, but build mode still spawns structures directly rather than consuming those kits -- Docking works for logistics, but there is not yet a richer docking queue / reservation UI -- NATO-style symbology is gameplay-oriented inspiration, not a strict APP-6 / MIL-STD implementation -- Fleet orders currently cover patrol, move, hold, and mining, but wing-specific doctrine editing is still minimal -- Carrier recovery exists, but launch/redeploy flows and richer carrier doctrine are still minimal -- Window positions and sizes are not yet persisted across reloads +- `GameApp.ts` is still carrying too much simulation responsibility. +- Faction AI is improved, but still fairly heuristic and not yet a deep planning system. +- Combat is lightweight and does not yet model formations, threat evaluation, or target priorities in a sophisticated way. +- Economic logistics are still abstracted heavily. +- Ship construction is recipe-gated but still simplified. +- Stations consume pooled faction stock rather than explicit transport delivery chains. +- Fleet window remains useful, but the overall UI is now only partially refit for observer mode. +- There is still no persistence layer for window layouts, saves, or generated universe seeds. ## Suggested Next Steps -- Continue shrinking `GameApp.ts` by extracting simulation/order logic into dedicated gameplay systems once the current rules stabilize -- Add JSON schema validation or runtime validation for the authored data catalogs to catch content errors earlier -- Move constructible placement and future unit spawning onto a shared scenario/entity factory pipeline -- Introduce explicit orbital anchors for: - - stars - - planets - - stations - - asteroid belts / resource fields -- Replace the current movement approximation with a more formal orbital transfer model -- Add refueling and power management gameplay -- Add ship/station fitting data structures that can later drive a designer UI -- Expand the economy beyond ore/refining into manufactured goods and trade lanes -- Improve FTL visuals with a fullscreen post-process distortion or tunnel effect -- Expand the strategic overlay with threat rings, route arrows, and fleet stance/status markers -- Extract fleet command propagation and ship AI execution out of `GameApp.ts` into dedicated simulation systems now that the fleet model has stabilized enough to justify it +- Extract faction strategy into a dedicated AI / planning module +- Extract fleet order execution into its own gameplay system +- Separate economic simulation from UI and rendering concerns +- Improve transport logistics so goods physically move through faction supply chains +- Add explicit shipyard construction queues and faction production priorities +- Improve combat behavior so `screen`, `escort`, and `command` have stronger distinct tactical roles +- Add system-level threat, ownership, and economy views for game-master inspection +- Add save/load support for generated universes and long-running simulations diff --git a/src/game/GameApp.ts b/src/game/GameApp.ts index 47f11ba..1058275 100644 --- a/src/game/GameApp.ts +++ b/src/game/GameApp.ts @@ -4,8 +4,7 @@ import { gameBalance, itemDefinitionsById, recipeDefinitions, - scenarioDefinition, - solarSystemDefinitions, + shipDefinitionsById, } from "./data/catalog"; import { createDefaultFleets, @@ -18,6 +17,7 @@ import { import { addShipCargo, getShipCargoAmount, removeShipCargo } from "./state/inventory"; import { SelectionManager } from "./state/selectionManager"; import type { + FactionInstance, FleetInstance, FleetWingInstance, GameWindowId, @@ -28,12 +28,14 @@ import type { StationInstance, TravelPlan, UnitState, + UniverseDefinition, ViewLevel, } from "./types"; import { createHud } from "./ui/hud"; -import { getFleetWindowMarkup, getSelectionDetails, getSelectionTitle } from "./ui/presenters"; +import { getFleetWindowMarkup, getSelectionCardsMarkup, getSelectionDetails, getSelectionTitle } from "./ui/presenters"; import { drawMinimap, drawStrategicOverlay } from "./ui/strategicRenderer"; -import { buildInitialWorld, createStationInstance } from "./world/worldFactory"; +import { buildInitialWorld, createShipInstance, createStationInstance } from "./world/worldFactory"; +import { generateUniverse } from "./world/universeGenerator"; const MOVING_STATES = new Set([ "moving", @@ -70,6 +72,8 @@ export class GameApp { private readonly systems: SolarSystemInstance[] = []; private readonly fleets: FleetInstance[] = []; private readonly fleetsById = new Map(); + private readonly factions: FactionInstance[] = []; + private readonly factionsById = new Map(); private strategicLinks!: THREE.Group; private starfield?: THREE.Points; @@ -84,17 +88,22 @@ export class GameApp { private marqueeModifiers = { shift: false, ctrl: false }; private marqueeActive = false; private suppressClickSelection = false; + private cameraDragMode?: "orbit" | "pan"; + private cameraDragPointerId?: number; + private cameraDragLast?: THREE.Vector2; private stationIdCounter = 0; private activeFleetId?: string; private readonly windowState: Record = { "fleet-command": true, "ship-designer": false, "station-manager": false, + debug: false, }; private readonly detailsEl: HTMLDivElement; private readonly statusEl: HTMLDivElement; private readonly selectionTitleEl: HTMLHeadingElement; + private readonly selectionStripEl: HTMLDivElement; private readonly ordersEl: HTMLDivElement; private readonly minimapEl: HTMLCanvasElement; private readonly minimapContext: CanvasRenderingContext2D; @@ -105,9 +114,14 @@ export class GameApp { 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; @@ -117,14 +131,13 @@ export class GameApp { this.scene.fog = new THREE.FogExp2(0x030811, 0.00006); this.scene.background = new THREE.Color(0x02060d); - const initialSystem = solarSystemDefinitions[0]; + 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, { - onOrderAction: (action) => this.handleOrderAction(action), onWindowAction: (action) => this.handleWindowAction(action), onFleetAction: (action, fleetId) => this.handleFleetAction(action, fleetId), onSelectionAction: (kind, id) => this.handleWindowSelection(kind, id), @@ -132,6 +145,7 @@ export class GameApp { 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; @@ -142,6 +156,8 @@ export class GameApp { this.fleetWindowBodyEl = hud.fleetWindowBody; this.fleetWindowTitleEl = hud.fleetWindowTitle; this.fleetWindowSubtitleEl = hud.fleetWindowSubtitle; + this.debugWindowEl = hud.debugWindow; + this.sessionActionsEl = hud.sessionActions; this.setupScene(); this.bindEvents(); @@ -161,8 +177,16 @@ export class GameApp { 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); + 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); @@ -172,11 +196,49 @@ export class GameApp { this.starfield = world.starfield; this.stationIdCounter = this.stations.length; + this.initializeFactions(); this.initializeFleets(); - this.assignDefaultOrders(); 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); @@ -186,6 +248,7 @@ export class GameApp { 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 }); } @@ -256,8 +319,8 @@ export class GameApp { .forEach((ship) => this.assignMineOrder( ship, - this.findBestMiningNode(scenarioDefinition.miningDefaults.nodeSystemId), - this.findRefinery(scenarioDefinition.miningDefaults.refinerySystemId), + this.findBestMiningNode(this.universe.scenario.miningDefaults.nodeSystemId), + this.findRefinery(this.universe.scenario.miningDefaults.refinerySystemId), ), ); this.updateHud(); @@ -308,6 +371,15 @@ export class GameApp { }; 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; } @@ -319,6 +391,17 @@ export class GameApp { }; 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; } @@ -334,6 +417,15 @@ export class GameApp { }; 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; } @@ -373,80 +465,146 @@ export class GameApp { 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 onContextMenu = (event: MouseEvent) => { - event.preventDefault(); + private onDoubleClick = (event: MouseEvent) => { this.updateMouse(event.clientX, event.clientY); this.raycaster.setFromCamera(this.mouse, this.camera); - - const point = new THREE.Vector3(); - if (!this.raycaster.ray.intersectPlane(this.movePlane, point)) { - return; - } - point.y = gameBalance.yPlane; - - const system = this.findNearestSystem(point); - if (this.buildMode) { - this.placeStation(constructibleDefinitions[this.selectedConstructible], point, system.definition.id); + const hits = this.raycaster.intersectObjects([...this.selectableTargets.keys()], false); + const target = hits.length > 0 ? this.selectableTargets.get(hits[0].object) : undefined; + if (!target) { return; } - if (this.selection.length === 0) { - if (this.activeFleetId) { - const fleet = this.fleetsById.get(this.activeFleetId); - if (fleet) { - this.issueFleetMoveOrder(fleet, point.clone()); - this.updateHud(); - } - } + 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); } - - const columns = Math.ceil(Math.sqrt(this.selection.length)); - this.selection.forEach((ship, index) => { - const row = Math.floor(index / columns); - const column = index % columns; - const offset = new THREE.Vector3((column - (columns - 1) / 2) * 22, 0, row * 22); - this.issueMoveOrder(ship, point.clone().add(offset)); - }); this.updateHud(); }; + private onContextMenu = (event: MouseEvent) => { + event.preventDefault(); + }; + private onWheel = (event: WheelEvent) => { event.preventDefault(); this.adjustZoom(1 + event.deltaY * 0.0012); }; - private assignDefaultOrders() { - this.fleets.forEach((fleet) => { - if (fleet.id === "perseus-extraction-fleet") { - this.setFleetMineOrder( - fleet, - scenarioDefinition.miningDefaults.nodeSystemId, - scenarioDefinition.miningDefaults.refinerySystemId, - ); + 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; } - this.setFleetPatrolOrder(fleet, fleet.systemId); + 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.fleets[0]?.id; + this.activeFleetId = this.fleetsById.has(previousActiveFleetId ?? "") ? previousActiveFleetId : this.fleets[0]?.id; } private makePatrolPoints(systemId: string) { - const route = scenarioDefinition.patrolRoutes.find((candidate) => candidate.systemId === systemId); + const route = this.universe.scenario.patrolRoutes.find((candidate) => candidate.systemId === systemId); if (!route) { const system = this.getSystem(systemId); return [ @@ -635,12 +793,213 @@ export class GameApp { } } + private updateFactionSimulation(delta: number) { + this.updateSystemControl(delta); + + this.factions.forEach((faction) => { + faction.commandTick -= delta; + faction.shipBuildTimer -= delta; + faction.stationBuildTimer -= delta; + + if (faction.commandTick <= 0) { + faction.commandTick = faction.definition.kind === "empire" ? 2.5 : 3.2; + if (faction.definition.kind === "empire") { + this.commandEmpireFaction(faction); + } else { + this.commandPirateFaction(faction); + } + } + + if (faction.shipBuildTimer <= 0) { + this.tryBuildShipForFaction(faction); + faction.shipBuildTimer = faction.definition.kind === "empire" ? 18 : 22; + } + + if (faction.stationBuildTimer <= 0) { + this.tryBuildOutpostForFaction(faction); + faction.stationBuildTimer = 45; + } + }); + } + + private commandEmpireFaction(faction: FactionInstance) { + const miningSystems = [ + faction.definition.miningSystemId, + ...this.systems + .filter((system) => system.strategicValue === "central" && system.controllingFactionId === faction.definition.id) + .map((system) => system.definition.id), + ].filter((systemId): systemId is string => Boolean(systemId)); + const threatenedSystemId = this.findThreatenedSystem(faction.definition.id); + const centralTarget = this.pickCentralTargetSystem(faction); + const militaryTargetSystemId = threatenedSystemId ?? centralTarget ?? faction.definition.homeSystemId; + const industryFleet = this.getFactionIndustryFleet(faction.definition.id); + if (industryFleet) { + const miningSystemId = miningSystems[0] ?? faction.definition.miningSystemId ?? faction.definition.homeSystemId; + this.setFleetMineOrder( + industryFleet, + miningSystemId, + faction.definition.miningSystemId ?? faction.definition.homeSystemId, + ); + } + + this.getFactionWarFleets(faction.definition.id).forEach((fleet) => { + if (fleet.systemId !== militaryTargetSystemId) { + const targetSystem = this.getSystem(militaryTargetSystemId); + const rally = targetSystem.center.clone().add(new THREE.Vector3(-140, 0, 120)); + this.issueFleetMoveOrder(fleet, rally); + return; + } + this.setFleetPatrolOrder(fleet, militaryTargetSystemId); + }); + } + + private commandPirateFaction(faction: FactionInstance) { + const targetSystemId = faction.definition.targetSystemIds[0] ?? faction.definition.homeSystemId; + const targetSystem = this.getSystem(targetSystemId); + this.getFactionWarFleets(faction.definition.id).forEach((fleet) => { + const raidPoint = targetSystem.center.clone().add(new THREE.Vector3(120, 0, 160)); + if (fleet.systemId !== targetSystemId) { + this.issueFleetMoveOrder(fleet, raidPoint); + return; + } + this.setFleetPatrolOrder(fleet, targetSystemId); + }); + + const industryFleet = this.getFactionIndustryFleet(faction.definition.id); + if (industryFleet) { + this.setFleetPatrolOrder(industryFleet, faction.definition.homeSystemId); + } + } + + private updateSystemControl(delta: number) { + const empireIds = new Set(this.factions.filter((faction) => faction.definition.kind === "empire").map((faction) => faction.definition.id)); + this.factions.forEach((faction) => { + faction.ownedSystemIds = new Set([faction.definition.homeSystemId, ...(faction.definition.miningSystemId ? [faction.definition.miningSystemId] : [])]); + }); + + this.systems + .filter((system) => system.strategicValue === "central") + .forEach((system) => { + const powerByFaction = new Map(); + this.ships + .filter((ship) => ship.systemId === system.definition.id && ship.definition.role === "military" && empireIds.has(ship.factionId)) + .forEach((ship) => { + const power = ship.definition.shipClass === "capital" ? 8 : ship.definition.shipClass === "cruiser" ? 4 : ship.definition.shipClass === "destroyer" ? 2 : 1; + powerByFaction.set(ship.factionId, (powerByFaction.get(ship.factionId) ?? 0) + power); + }); + this.stations + .filter((station) => station.systemId === system.definition.id && station.definition.category === "defense" && empireIds.has(station.factionId)) + .forEach((station) => { + powerByFaction.set(station.factionId, (powerByFaction.get(station.factionId) ?? 0) + 3); + }); + + const sorted = [...powerByFaction.entries()].sort((left, right) => right[1] - left[1]); + const leader = sorted[0]; + const runnerUp = sorted[1]; + if (!leader || leader[1] <= (runnerUp?.[1] ?? 0)) { + system.controlProgress = Math.max(0, system.controlProgress - delta * 2); + } else if (system.controllingFactionId === leader[0]) { + system.controlProgress = Math.min(100, system.controlProgress + delta * leader[1] * 0.8); + } else { + system.controlProgress -= delta * ((runnerUp?.[1] ?? 0) + 2); + if (system.controlProgress <= 0) { + system.controllingFactionId = leader[0]; + system.controlProgress = 10; + } + } + + if (system.controllingFactionId) { + this.factionsById.get(system.controllingFactionId)?.ownedSystemIds.add(system.definition.id); + } + }); + } + + private updateCombat(delta: number) { + this.ships.forEach((ship) => { + ship.weaponTimer = Math.max(0, ship.weaponTimer - delta); + if (ship.state === "docked" || ship.weaponRange <= 0 || ship.weaponTimer > 0) { + return; + } + const target = this.findCombatTarget(ship); + if (!target) { + return; + } + target.health -= ship.weaponDamage; + ship.weaponTimer = ship.weaponCooldown; + if (target.health <= 0) { + this.destroyShip(target, ship.factionId); + } + }); + + this.stations.forEach((station) => { + station.weaponTimer = Math.max(0, station.weaponTimer - delta); + if (station.weaponRange <= 0 || station.weaponTimer > 0) { + return; + } + const target = this.ships + .filter((ship) => ship.systemId === station.systemId && ship.factionId !== station.factionId && ship.state !== "docked") + .sort((left, right) => station.group.position.distanceTo(left.group.position) - station.group.position.distanceTo(right.group.position))[0]; + if (!target || station.group.position.distanceTo(target.group.position) > station.weaponRange) { + return; + } + target.health -= station.weaponDamage; + station.weaponTimer = 1.1; + if (target.health <= 0) { + this.destroyShip(target, station.factionId); + } + }); + + this.updatePirateRaids(delta); + } + + private updatePirateRaids(_delta: number) { + this.ships + .filter((ship) => this.factionsById.get(ship.factionId)?.definition.kind === "pirate") + .forEach((pirate) => { + const victim = this.ships + .filter( + (ship) => + ship.systemId === pirate.systemId && + ship.factionId !== pirate.factionId && + ship.definition.role !== "military" && + getShipCargoAmount(ship) > 0, + ) + .sort((left, right) => pirate.group.position.distanceTo(left.group.position) - pirate.group.position.distanceTo(right.group.position))[0]; + if (!victim || pirate.group.position.distanceTo(victim.group.position) > 60) { + return; + } + const stolen = removeShipCargo(victim, Math.min(12, getShipCargoAmount(victim))); + if (stolen <= 0) { + return; + } + const pirateFaction = this.factionsById.get(pirate.factionId); + if (pirateFaction) { + pirateFaction.raidsCompleted += 1; + pirateFaction.stolenCargo += stolen; + pirateFaction.credits += stolen * 2; + } + }); + } + + private refreshFleets() { + if (!this.fleetRefreshNeeded) { + return; + } + this.fleetRefreshNeeded = false; + this.initializeFleets(); + this.factions.forEach((faction) => { + faction.commandTick = 0; + }); + } + private tick() { const delta = Math.min(this.clock.getDelta(), 0.033); const elapsed = this.clock.elapsedTime; this.updateCamera(delta); + this.updateFactionSimulation(delta); this.updateShips(delta, elapsed); + this.updateCombat(delta); this.fleets.forEach((fleet) => { const commander = getFleetCommander(fleet, this.shipsById); if (commander) { @@ -648,8 +1007,9 @@ export class GameApp { } }); this.updateSystems(delta); + this.refreshFleets(); this.applyViewLevel(); - if (this.selection.length > 0 || this.selectedStation || this.followShipId) { + if (this.selection.length > 0 || this.selectedStation || this.selectedSystem || this.selectedPlanet || this.followShipId) { this.updateHud(); } this.renderHudCanvases(); @@ -755,12 +1115,12 @@ export class GameApp { this.stations.forEach((station) => { station.group.visible = this.viewLevel !== "universe"; - station.group.scale.setScalar(this.viewLevel === "solar" ? 1.15 : 1); + station.group.scale.setScalar(this.getStationPresentationScale(station)); }); this.ships.forEach((ship) => { ship.group.visible = this.viewLevel !== "universe"; - ship.group.scale.setScalar(this.viewLevel === "solar" ? 1.3 : 1); + ship.group.scale.setScalar(this.getShipPresentationScale(ship)); }); this.strategicLinks.visible = this.viewLevel === "universe"; @@ -772,6 +1132,28 @@ export class GameApp { } } + 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); @@ -894,6 +1276,11 @@ export class GameApp { 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); } @@ -1007,7 +1394,7 @@ export class GameApp { station.activeRecipeId = nextRecipe.id; station.activeBatch = nextRecipe.inputs.reduce((total, component) => total + component.amount, 0); station.processTimer = nextRecipe.duration; - nextRecipe.inputs.forEach((component) => this.removeStationItem(station, component.itemId, component.amount)); + this.consumeFactionItems(station.factionId, nextRecipe.inputs); } } @@ -1029,6 +1416,7 @@ export class GameApp { } 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; } @@ -1044,7 +1432,7 @@ export class GameApp { 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.getStationItemAmount(station, component.itemId) >= component.amount); + const inputsMatch = recipe.inputs.every((component) => this.getFactionItemAmount(station.factionId, component.itemId) >= component.amount); return categoryMatches && modulesMatch && inputsMatch; } @@ -1377,12 +1765,17 @@ export class GameApp { if (this.isCarrierHost(host)) { return this.canDockShipAtCarrier(ship, host); } - return true; + return host.factionId === ship.factionId; } private findNearestFriendlyCarrier(ship: ShipInstance) { return this.ships - .filter((candidate) => candidate.systemId === ship.systemId && this.canDockShipAtCarrier(ship, candidate)) + .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), @@ -1512,20 +1905,223 @@ export class GameApp { 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 findRefinery(systemId: string) { - return this.stations.find((station) => station.systemId === systemId && station.definition.category === "refining"); + 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) + .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); @@ -1544,21 +2140,31 @@ export class GameApp { private focusSystem(systemId: string) { const system = this.getSystem(systemId); - const currentOffset = this.camera.position.clone().sub(this.getCameraFocus()); this.followShipId = undefined; - this.getCameraFocus().copy(system.center); - this.camera.position.copy(system.center).add(currentOffset); + this.focusPoint(system.center, 1100); this.updateHud(); } private focusSelection() { - if (this.selection.length === 0 && !this.selectedStation && !this.activeFleetId) { + 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.getCameraFocus().copy(this.selectedStation.group.position); + this.focusPoint(this.selectedStation.group.position, 640); this.updateHud(); return; } @@ -1570,7 +2176,7 @@ export class GameApp { const ship = this.selection[0]; this.followShipId = ship.id; this.selectedSystemIndex = this.systems.findIndex((system) => system.definition.id === ship.systemId); - this.getCameraFocus().copy(ship.group.position); + this.focusPoint(ship.group.position, 520); this.updateHud(); return; } @@ -1579,61 +2185,46 @@ export class GameApp { const center = new THREE.Vector3(); this.selection.forEach((ship) => center.add(ship.group.position)); center.multiplyScalar(1 / this.selection.length); - this.getCameraFocus().copy(center); + 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(); - return; - } - if (this.selection.length === 0) { - if (this.activeFleetId && (action === "mine" || action === "patrol")) { - this.handleFleetAction(action, this.activeFleetId); - } - return; - } - if (action === "mine") { - this.selection - .filter((ship) => ship.definition.role === "mining") - .forEach((ship) => - this.assignMineOrder( - ship, - this.findBestMiningNode(scenarioDefinition.miningDefaults.nodeSystemId), - this.findRefinery(scenarioDefinition.miningDefaults.refinerySystemId), - ), - ); - } - if (action === "patrol") { - this.selection - .filter((ship) => ship.definition.role === "military") - .forEach((ship) => this.setPatrolOrder(ship, this.makePatrolPoints(ship.systemId), 0)); - } - if (action === "escort") { - this.selection.forEach((ship) => { - const target = this.findNearestFriendlyToEscort(ship); - if (target) { - this.setEscortOrder(ship, target); - } - }); - } - if (action === "dock") { - this.selection.forEach((ship) => { - const carrier = this.findNearestFriendlyCarrier(ship); - if (carrier) { - this.assignDockOrder(ship, carrier); - } - }); } 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; @@ -1671,8 +2262,8 @@ export class GameApp { if (action === "mine") { this.setFleetMineOrder( fleet, - scenarioDefinition.miningDefaults.nodeSystemId, - scenarioDefinition.miningDefaults.refinerySystemId, + this.universe.scenario.miningDefaults.nodeSystemId, + this.universe.scenario.miningDefaults.refinerySystemId, ); } if (action === "hold") { @@ -1817,23 +2408,49 @@ export class GameApp { private updateHud() { const selectedDefinition = constructibleDefinitions[this.selectedConstructible]; - const system = this.systems[this.selectedSystemIndex]; - const selectedCount = this.selection.length + (this.selectedStation ? 1 : 0); + 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.detailsEl.textContent = getSelectionDetails( + this.selectionTitleEl.textContent = getSelectionTitle( this.selection, this.selectedStation, - this.systems, - this.viewLevel, - this.ships, - this.fleets, + 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 - ? `Build Mode: ${selectedDefinition.label} in ${system.definition.label} • ${this.viewLevel} view` - : `Command Mode: ${selectedCount} selected • Camera ${system.definition.label} • ${this.viewLevel} view${this.followShipId ? " • following ship" : ""}${activeFleet ? ` • Fleet ${activeFleet.label}` : ""}`; - this.ordersEl.dataset.mode = this.selectedStation ? "station" : this.selection.length > 0 ? "ships" : this.activeFleetId ? "fleet" : "none"; + ? `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)}` @@ -1842,12 +2459,18 @@ export class GameApp { } 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); diff --git a/src/game/fleet/runtime.ts b/src/game/fleet/runtime.ts index b0ffc2c..13d3317 100644 --- a/src/game/fleet/runtime.ts +++ b/src/game/fleet/runtime.ts @@ -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" }, diff --git a/src/game/state/selectionManager.ts b/src/game/state/selectionManager.ts index b2a04fe..aa2f880 100644 --- a/src/game/state/selectionManager.ts +++ b/src/game/state/selectionManager.ts @@ -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; + } + } } diff --git a/src/game/types.ts b/src/game/types.ts index e9f2f3e..397d3c7 100644 --- a/src/game/types.ts +++ b/src/game/types.ts @@ -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>; } 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; + 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; } diff --git a/src/game/ui/hud.ts b/src/game/ui/hud.ts index 087a250..7157bf8 100644 --- a/src/game/ui/hud.ts +++ b/src/game/ui/hud.ts @@ -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 = ` -
-

Helios Reach Command

-

- 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. -

-
-

Selection

+
+

No Selection

+
+
+
-
-
-

No Selection

-
-
-
-
-
- - - -
-
- - - - - - -
-
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.
-
-
- -
-
+
@@ -66,14 +36,26 @@ export function createHud(container: HTMLElement, handlers: HudHandlers): HudEle
+
+
+
+

Debug

+

Simulation controls

+
+ +
+
+
+ +
+
+ +
`; container.append(root); initializeWindowInteractions(root); - root.querySelectorAll(".orders button").forEach((button) => { - button.addEventListener("click", () => handlers.onOrderAction(button.dataset.action ?? "")); - }); root.querySelectorAll("[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, }; } diff --git a/src/game/ui/presenters.ts b/src/game/ui/presenters.ts index 8b8bb5b..1125196 100644 --- a/src/game/ui/presenters.ts +++ b/src/game/ui/presenters.ts @@ -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 `No active selection`; + } + 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 ` +
+ ${title} + ${lines.map((line) => `${line}`).join("")} +
+ `; +} + function collectWingShipIds(fleet: FleetInstance, rootWingId: string): string[] { const wingIds = new Set([rootWingId]); let changed = true; diff --git a/src/game/ui/strategicRenderer.ts b/src/game/ui/strategicRenderer.ts index f9c3950..3216129 100644 --- a/src/game/ui/strategicRenderer.ts +++ b/src/game/ui/strategicRenderer.ts @@ -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); diff --git a/src/game/world/universeGenerator.ts b/src/game/world/universeGenerator.ts new file mode 100644 index 0000000..6e3d976 --- /dev/null +++ b/src/game/world/universeGenerator.ts @@ -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; + }; +} diff --git a/src/game/world/worldFactory.ts b/src/game/world/worldFactory.ts index 6004a23..8571806 100644 --- a/src/game/world/worldFactory.ts +++ b/src/game/world/worldFactory.ts @@ -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, + 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, ) { 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(); + + 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>; selectableTargets: Map; }) { 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; }) { 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, diff --git a/src/style.css b/src/style.css index 6349f9a..f23cbe5 100644 --- a/src/style.css +++ b/src/style.css @@ -72,30 +72,28 @@ canvas { margin: 0; } -.summary { - top: 24px; - left: 24px; - width: min(380px, calc(100vw - 48px)); - padding: 18px 20px; -} - -.summary h1 { - font-size: 1rem; - letter-spacing: 0.22em; - text-transform: uppercase; -} - -.summary p { - margin-top: 10px; - color: var(--muted); - line-height: 1.5; +.session-actions { + display: flex; + gap: 10px; } .details { + left: 24px; right: 24px; - top: 24px; - width: min(320px, calc(100vw - 48px)); - padding: 18px 20px; + bottom: 24px; + min-height: 138px; + padding: 16px 18px; + display: flex; + flex-direction: column; + gap: 12px; + pointer-events: auto; +} + +.selection-meta { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 16px; } .details h2 { @@ -105,85 +103,79 @@ canvas { color: var(--accent); } +.selection-strip { + display: flex; + gap: 10px; + overflow-x: auto; + overflow-y: hidden; + padding-bottom: 4px; + align-items: stretch; +} + +.selection-strip-card { + flex: 0 0 auto; + border: 1px solid rgba(126, 212, 255, 0.18); + border-radius: 14px; + min-width: 180px; + padding: 10px 12px; + color: var(--text); + background: linear-gradient(180deg, rgba(13, 30, 56, 0.7), rgba(8, 17, 33, 0.82)); + display: flex; + flex-direction: column; + gap: 4px; +} + +.selection-strip-card-title { + font-size: 0.84rem; + font-weight: 600; + letter-spacing: 0.04em; + color: var(--text); +} + +.selection-strip-card-line { + font-size: 0.76rem; + line-height: 1.35; + color: var(--muted); + white-space: nowrap; +} + +.selection-strip-empty { + color: var(--muted); + font-size: 0.84rem; + padding: 8px 2px; +} + .details .content { - margin-top: 12px; color: var(--muted); line-height: 1.55; white-space: pre-line; -} - -.commandbar { - left: 24px; - right: 24px; - bottom: 24px; - min-height: 180px; - display: grid; - grid-template-columns: minmax(240px, 300px) 1fr minmax(220px, 260px); - gap: 16px; - padding: 16px; - align-items: stretch; - pointer-events: auto; -} - -.selection-panel, -.orders-panel, -.minimap-panel { - border: 1px solid rgba(126, 212, 255, 0.14); - border-radius: 14px; - background: - linear-gradient(180deg, rgba(7, 15, 29, 0.82), rgba(4, 10, 20, 0.72)), - repeating-linear-gradient( - 90deg, - rgba(126, 212, 255, 0.025) 0, - rgba(126, 212, 255, 0.025) 1px, - transparent 1px, - transparent 16px - ); - padding: 14px 16px; + max-height: 132px; + overflow: auto; } .selection-title, -.orders-panel .mode { +.selection-meta .mode { margin: 0; font-size: 0.86rem; text-transform: uppercase; letter-spacing: 0.14em; } -.selection-panel .compact { - margin-top: 10px; - color: var(--muted); - line-height: 1.45; - white-space: pre-line; -} - -.orders-panel { - display: flex; - flex-direction: column; - gap: 14px; -} - .window-launchers, .fleet-actions { display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; } -.orders-panel .mode { +.selection-meta .mode { color: var(--warning); text-shadow: 0 0 18px rgba(255, 191, 105, 0.24); } -.orders { - display: grid; - grid-template-columns: repeat(6, minmax(0, 1fr)); - gap: 10px; -} - .window-launchers button, -.orders button, .fleet-actions button, +.session-actions button, .window-close { border: 1px solid rgba(126, 212, 255, 0.16); border-radius: 12px; @@ -198,35 +190,20 @@ canvas { box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.02); } -.orders button:hover, .window-launchers button:hover, .fleet-actions button:hover, +.session-actions button:hover, .window-close:hover { border-color: rgba(126, 212, 255, 0.4); transform: translateY(-1px); } -.orders[data-mode="none"] button:not([data-action="focus"]) { - opacity: 0.45; -} - button:disabled { opacity: 0.35; cursor: default; transform: none; } -.orders-panel .hint { - color: var(--muted); - line-height: 1.45; -} - -.minimap-panel { - display: flex; - align-items: center; - justify-content: center; -} - .minimap { width: 100%; height: auto; @@ -235,6 +212,10 @@ button:disabled { background: rgba(2, 6, 13, 0.92); } +.minimap-hidden { + display: none; +} + .app-window { position: absolute; top: 104px; @@ -472,32 +453,16 @@ button:disabled { } @media (max-width: 900px) { - .summary, - .details, - .commandbar { + .details { + width: auto; left: 16px; right: 16px; } - .summary, .details { - width: auto; - } - - .details { - top: auto; - bottom: 92px; - } - - .commandbar { - grid-template-columns: 1fr; bottom: 16px; } - .orders { - grid-template-columns: repeat(3, minmax(0, 1fr)); - } - .window-launchers, .fleet-actions { grid-template-columns: repeat(2, minmax(0, 1fr));