diff --git a/SESSION.md b/SESSION.md index 571cc86..c1941d0 100644 --- a/SESSION.md +++ b/SESSION.md @@ -14,6 +14,9 @@ The current prototype includes: - 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 ## Major Gameplay Systems Added @@ -43,12 +46,26 @@ The current prototype includes: - 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 @@ -60,12 +77,15 @@ The current prototype includes: - 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 ### Economy / Inventory Foundations @@ -75,15 +95,32 @@ The current prototype includes: - `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 processing +- 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 -- Refinery processing now consumes ore inventory and produces manufactured output through a recipe-driven flow +- 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 ### Energy / Fuel @@ -110,7 +147,12 @@ The current prototype includes: - 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 @@ -131,13 +173,16 @@ The current prototype includes: - `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 - `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 - `Tab`: jump camera between systems - `B`: toggle build mode -- `1-5`: choose constructible +- `1-7`: choose constructible - `M`: assign mining - `P`: assign patrol - `E`: assign escort @@ -158,12 +203,19 @@ The current prototype includes: - `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: - `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` @@ -173,10 +225,14 @@ The current prototype includes: - 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, but there is no actual ship/station designer yet -- Inventory classes exist, but only a subset of economic flows are implemented +- 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 ## Suggested Next Steps @@ -194,3 +250,4 @@ The current prototype includes: - 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 diff --git a/src/game/GameApp.ts b/src/game/GameApp.ts index df8368f..47f11ba 100644 --- a/src/game/GameApp.ts +++ b/src/game/GameApp.ts @@ -2,12 +2,25 @@ import * as THREE from "three"; import { constructibleDefinitions, gameBalance, + itemDefinitionsById, recipeDefinitions, scenarioDefinition, solarSystemDefinitions, } from "./data/catalog"; +import { + createDefaultFleets, + describeFleetOrder, + getFleetCommander, + getFleetShipIds, + getWingLeader, + getWingMembers, +} from "./fleet/runtime"; import { addShipCargo, getShipCargoAmount, removeShipCargo } from "./state/inventory"; +import { SelectionManager } from "./state/selectionManager"; import type { + FleetInstance, + FleetWingInstance, + GameWindowId, ResourceNode, SelectableTarget, ShipInstance, @@ -18,7 +31,7 @@ import type { ViewLevel, } from "./types"; import { createHud } from "./ui/hud"; -import { getSelectionDetails, getSelectionTitle } from "./ui/presenters"; +import { getFleetWindowMarkup, getSelectionDetails, getSelectionTitle } from "./ui/presenters"; import { drawMinimap, drawStrategicOverlay } from "./ui/strategicRenderer"; import { buildInitialWorld, createStationInstance } from "./world/worldFactory"; @@ -35,6 +48,8 @@ const MOVING_STATES = new Set([ "escorting", ]); +type DockingHost = StationInstance | ShipInstance; + export class GameApp { private readonly container: HTMLElement; private readonly renderer: THREE.WebGLRenderer; @@ -47,13 +62,14 @@ export class GameApp { private readonly keyState = new Set(); private readonly cameraFocus = new THREE.Vector3(); private readonly selectableTargets = new Map(); - private readonly refiningRecipe = recipeDefinitions.find((recipe) => recipe.facilityCategory === "refining"); private readonly ships: ShipInstance[] = []; private readonly shipsById = new Map(); private readonly stations: StationInstance[] = []; private readonly nodes: ResourceNode[] = []; private readonly systems: SolarSystemInstance[] = []; + private readonly fleets: FleetInstance[] = []; + private readonly fleetsById = new Map(); private strategicLinks!: THREE.Group; private starfield?: THREE.Points; @@ -61,8 +77,7 @@ export class GameApp { private buildMode = false; private selectedConstructible = 0; private selectedSystemIndex = 0; - private selection: ShipInstance[] = []; - private selectedStation?: StationInstance; + private readonly selectionManager = new SelectionManager(); private followShipId?: string; private viewLevel: ViewLevel = "local"; private marqueeStart?: THREE.Vector2; @@ -70,6 +85,12 @@ export class GameApp { private marqueeActive = false; private suppressClickSelection = false; private stationIdCounter = 0; + private activeFleetId?: string; + private readonly windowState: Record = { + "fleet-command": true, + "ship-designer": false, + "station-manager": false, + }; private readonly detailsEl: HTMLDivElement; private readonly statusEl: HTMLDivElement; @@ -80,6 +101,10 @@ export class GameApp { private readonly marqueeEl: HTMLDivElement; private readonly strategicOverlayEl: HTMLCanvasElement; private readonly strategicOverlayContext: CanvasRenderingContext2D; + private readonly fleetWindowEl: HTMLDivElement; + private readonly fleetWindowBodyEl: HTMLDivElement; + private readonly fleetWindowTitleEl: HTMLHeadingElement; + private readonly fleetWindowSubtitleEl: HTMLParagraphElement; constructor(container: HTMLElement) { this.container = container; @@ -98,7 +123,12 @@ export class GameApp { this.camera.lookAt(this.cameraFocus); this.container.append(this.renderer.domElement); - const hud = createHud(this.container, (action) => this.handleOrderAction(action)); + 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), + }); this.detailsEl = hud.details; this.statusEl = hud.status; this.selectionTitleEl = hud.selectionTitle; @@ -108,6 +138,10 @@ export class GameApp { this.marqueeEl = hud.marquee; this.strategicOverlayEl = hud.strategicOverlay; this.strategicOverlayContext = hud.strategicOverlayContext; + this.fleetWindowEl = hud.fleetWindow; + this.fleetWindowBodyEl = hud.fleetWindowBody; + this.fleetWindowTitleEl = hud.fleetWindowTitle; + this.fleetWindowSubtitleEl = hud.fleetWindowSubtitle; this.setupScene(); this.bindEvents(); @@ -119,6 +153,14 @@ export class GameApp { this.renderer.setAnimationLoop(() => this.tick()); } + private get selection() { + return this.selectionManager.getShips(); + } + + private get selectedStation() { + return this.selectionManager.getStation(); + } + private setupScene() { const world = buildInitialWorld(this.scene, this.selectableTargets); this.systems.push(...world.systems); @@ -130,6 +172,7 @@ export class GameApp { this.starfield = world.starfield; this.stationIdCounter = this.stations.length; + this.initializeFleets(); this.assignDefaultOrders(); this.applyViewLevel(); } @@ -186,6 +229,17 @@ export class GameApp { return; } + if (key === "g") { + this.toggleWindow("fleet-command"); + return; + } + + if (key === "escape") { + this.windowState["fleet-command"] = false; + this.updateHud(); + return; + } + if (key === "-" || key === "_") { this.adjustZoom(1.18); return; @@ -231,6 +285,17 @@ export class GameApp { return; } + if (key === "r") { + this.selection.forEach((ship) => { + const carrier = this.findNearestFriendlyCarrier(ship); + if (carrier) { + this.assignDockOrder(ship, carrier); + } + }); + this.updateHud(); + return; + } + const slot = Number(key); if (!Number.isNaN(slot) && slot >= 1 && slot <= constructibleDefinitions.length) { this.selectedConstructible = slot - 1; @@ -298,17 +363,15 @@ export class GameApp { if (hits.length > 0) { const target = this.selectableTargets.get(hits[0].object); if (target?.kind === "ship") { - if (toggle && this.selection.includes(target.ship)) { - this.removeShipFromSelection(target.ship); - } else if (!this.selection.includes(target.ship)) { - this.addShipToSelection(target.ship); + if (toggle) { + this.selectionManager.toggleShip(target.ship); + } else if (!this.selectionManager.hasShip(target.ship)) { + this.selectionManager.addShip(target.ship); } - this.selectedStation = undefined; + this.syncActiveFleetFromSelection(); } if (target?.kind === "station") { - this.clearSelection(); - this.selectedStation = target.station; - (target.station.ring.material as THREE.MeshBasicMaterial).opacity = 0.95; + this.selectionManager.setStation(target.station); } } @@ -333,6 +396,13 @@ export class GameApp { } if (this.selection.length === 0) { + if (this.activeFleetId) { + const fleet = this.fleetsById.get(this.activeFleetId); + if (fleet) { + this.issueFleetMoveOrder(fleet, point.clone()); + this.updateHud(); + } + } return; } @@ -352,37 +422,27 @@ export class GameApp { }; private assignDefaultOrders() { - const miners = this.ships.filter((ship) => ship.definition.role === "mining"); - const centralRefinery = this.findRefinery(scenarioDefinition.miningDefaults.refinerySystemId); - miners.forEach((ship) => - this.assignMineOrder(ship, this.findBestMiningNode(scenarioDefinition.miningDefaults.nodeSystemId), centralRefinery), - ); - - const militaryBySystem = this.groupShipsBySystem("military"); - militaryBySystem.forEach((ships, systemId) => { - const patrolPoints = this.makePatrolPoints(systemId); - ships.forEach((ship, index) => this.setPatrolOrder(ship, patrolPoints, index % patrolPoints.length)); - }); - - const transports = this.ships.filter((ship) => ship.definition.role === "transport"); - transports.forEach((ship, index) => { - const target = miners[index % Math.max(miners.length, 1)]; - if (target) { - this.setEscortOrder(ship, target); + this.fleets.forEach((fleet) => { + if (fleet.id === "perseus-extraction-fleet") { + this.setFleetMineOrder( + fleet, + scenarioDefinition.miningDefaults.nodeSystemId, + scenarioDefinition.miningDefaults.refinerySystemId, + ); + return; } + this.setFleetPatrolOrder(fleet, fleet.systemId); }); } - private groupShipsBySystem(role: ShipInstance["definition"]["role"]) { - const map = new Map(); - this.ships - .filter((ship) => ship.definition.role === role) - .forEach((ship) => { - const bucket = map.get(ship.systemId) ?? []; - bucket.push(ship); - map.set(ship.systemId, bucket); - }); - return map; + private initializeFleets() { + 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; } private makePatrolPoints(systemId: string) { @@ -399,12 +459,194 @@ export class GameApp { return route.points.map((point) => new THREE.Vector3(...point).setY(gameBalance.yPlane)); } + private setFleetMineOrder(fleet: FleetInstance, nodeSystemId: string, refinerySystemId: string) { + fleet.order = { kind: "mine", nodeSystemId, refinerySystemId }; + this.applyFleetOrder(fleet); + } + + private setFleetPatrolOrder(fleet: FleetInstance, systemId: string) { + fleet.order = { + kind: "patrol", + systemId, + points: this.makePatrolPoints(systemId), + index: 0, + }; + this.applyFleetOrder(fleet); + } + + private setFleetIdleOrder(fleet: FleetInstance) { + fleet.order = { kind: "idle" }; + this.applyFleetOrder(fleet); + } + + private issueFleetMoveOrder(fleet: FleetInstance, destination: THREE.Vector3) { + const system = this.findNearestSystem(destination); + fleet.order = { + kind: "move", + destination: destination.clone().setY(gameBalance.yPlane), + systemId: system.definition.id, + }; + this.applyFleetOrder(fleet); + } + + private applyFleetOrder(fleet: FleetInstance) { + const commander = getFleetCommander(fleet, this.shipsById); + if (!commander) { + return; + } + + fleet.wings.forEach((wing) => { + switch (fleet.order.kind) { + case "idle": + this.applyFleetIdleDirective(fleet, wing, commander); + break; + case "move": + this.applyFleetMoveDirective(fleet, wing, commander); + break; + case "patrol": + this.applyFleetPatrolDirective(fleet, wing, commander); + break; + case "mine": + this.applyFleetMiningDirective(fleet, wing, commander); + break; + } + }); + + fleet.systemId = commander.systemId; + } + + private applyFleetIdleDirective(fleet: FleetInstance, wing: FleetWingInstance, commander: ShipInstance) { + if (wing.behavior === "command") { + const members = getWingMembers(wing, this.shipsById); + members.forEach((ship) => { + if (ship.id === commander.id) { + ship.order = { kind: "idle" }; + ship.state = "idle"; + return; + } + this.setEscortOrder(ship, commander, ship.formationOffset.clone()); + }); + return; + } + + this.assignWingFollowOrder(fleet, wing, this.resolveWingAnchor(fleet, wing) ?? commander); + } + + private applyFleetMoveDirective(fleet: FleetInstance, wing: FleetWingInstance, commander: ShipInstance) { + const order = fleet.order; + if (order.kind !== "move") { + return; + } + if (wing.behavior === "command") { + const members = getWingMembers(wing, this.shipsById); + members.forEach((ship) => { + if (ship.id === commander.id) { + this.issueMoveOrder(ship, order.destination.clone()); + return; + } + this.setEscortOrder(ship, commander, ship.formationOffset.clone()); + }); + return; + } + + this.assignWingFollowOrder(fleet, wing, this.resolveWingAnchor(fleet, wing) ?? commander); + } + + private applyFleetPatrolDirective(fleet: FleetInstance, wing: FleetWingInstance, commander: ShipInstance) { + const order = fleet.order; + if (order.kind !== "patrol") { + return; + } + + if (wing.behavior === "command") { + this.setPatrolOrder(commander, order.points, order.index, order.systemId); + getWingMembers(wing, this.shipsById) + .filter((ship) => ship.id !== commander.id) + .forEach((ship) => this.setEscortOrder(ship, commander, ship.formationOffset.clone())); + return; + } + + if (wing.behavior === "screen") { + getWingMembers(wing, this.shipsById).forEach((ship, index) => + this.setPatrolOrder(ship, order.points, (order.index + index) % order.points.length, order.systemId), + ); + return; + } + + this.assignWingFollowOrder(fleet, wing, this.resolveWingAnchor(fleet, wing) ?? commander); + } + + private applyFleetMiningDirective(fleet: FleetInstance, wing: FleetWingInstance, commander: ShipInstance) { + const order = fleet.order; + if (order.kind !== "mine") { + return; + } + + if (wing.behavior === "command") { + const targetSystem = this.getSystem(order.nodeSystemId); + const rally = targetSystem.center.clone().add(new THREE.Vector3(-140, 0, 120)); + this.issueMoveOrder(commander, rally); + getWingMembers(wing, this.shipsById) + .filter((ship) => ship.id !== commander.id) + .forEach((ship) => this.setEscortOrder(ship, commander, ship.formationOffset.clone())); + return; + } + + if (wing.behavior === "mining") { + const refinery = this.findRefinery(order.refinerySystemId); + getWingMembers(wing, this.shipsById).forEach((ship) => + this.assignMineOrder(ship, this.findBestMiningNode(order.nodeSystemId), refinery), + ); + return; + } + + const anchor = this.resolveWingAnchor(fleet, wing) ?? commander; + const multiplier = wing.behavior === "logistics" ? 1.8 : 1.2; + this.assignWingFollowOrder(fleet, wing, anchor, multiplier); + } + + private resolveWingAnchor(fleet: FleetInstance, wing: FleetWingInstance) { + if (wing.parentWingId) { + const parentWing = fleet.wings.find((candidate) => candidate.id === wing.parentWingId); + if (parentWing) { + return getWingLeader(parentWing, this.shipsById); + } + } + return getFleetCommander(fleet, this.shipsById); + } + + private assignWingFollowOrder(fleet: FleetInstance, wing: FleetWingInstance, anchor: ShipInstance, offsetScale = 1) { + getWingMembers(wing, this.shipsById).forEach((ship) => { + if (ship.id === anchor.id) { + return; + } + const localOffset = ship.formationOffset.clone().multiplyScalar(offsetScale); + const twist = ship.behavior === "escort" ? new THREE.Vector3(0, 0, -18) : new THREE.Vector3(0, 0, 18); + this.setEscortOrder(ship, anchor, localOffset.add(twist)); + }); + + if (anchor.fleetId !== fleet.id && wing.behavior !== "logistics") { + return; + } + + const leader = getWingLeader(wing, this.shipsById); + if (leader && leader.id !== anchor.id && wing.behavior !== "escort" && wing.behavior !== "logistics") { + this.setEscortOrder(leader, anchor, leader.formationOffset.clone().multiplyScalar(offsetScale)); + } + } + private tick() { const delta = Math.min(this.clock.getDelta(), 0.033); const elapsed = this.clock.elapsedTime; this.updateCamera(delta); this.updateShips(delta, elapsed); + this.fleets.forEach((fleet) => { + const commander = getFleetCommander(fleet, this.shipsById); + if (commander) { + fleet.systemId = commander.systemId; + } + }); this.updateSystems(delta); this.applyViewLevel(); if (this.selection.length > 0 || this.selectedStation || this.followShipId) { @@ -534,7 +776,7 @@ export class GameApp { this.ships.forEach((ship, index) => { this.consumeShipResources(ship, delta); - if (ship.state === "undocking" && this.moveShipToward(ship, ship.target, ship.definition.speed * 0.8, delta, 8)) { + if (ship.state === "undocking" && this.moveShipToward(ship, ship.target, ship.definition.speed * 0.8, delta, 8, true)) { ship.state = "idle"; } @@ -564,9 +806,13 @@ export class GameApp { case "escort": this.updateEscortOrder(ship, delta); break; + case "dock": + this.updateDockOrder(ship, delta); + break; } if (ship.state === "docked") { + this.updateDockedShipTransform(ship); ship.group.rotation.z = 0; ship.energy = Math.min(ship.maxEnergy, ship.energy + gameBalance.energy.shipRechargeRate * delta); } else if (ship.state !== "warping") { @@ -647,8 +893,7 @@ export class GameApp { if (order.phase === "transfer" && this.updateDockingState(ship, refinery, delta)) { const transferred = removeShipCargo(ship, getShipCargoAmount(ship)); - refinery.inventory["bulk-solid"] += transferred; - refinery.oreStored += transferred; + this.addStationItem(refinery, "ore", transferred); order.phase = "to-node"; this.beginUndock(ship, refinery); } @@ -695,6 +940,39 @@ export class GameApp { this.moveShipToward(ship, anchor, ship.definition.speed * 1.05, delta, 18); } + private updateDockOrder(ship: ShipInstance, delta: number) { + const order = ship.order; + if (order.kind !== "dock") { + return; + } + + const carrier = this.shipsById.get(order.carrierShipId); + if (!carrier || !this.canDockShipAtCarrier(ship, carrier)) { + ship.order = { kind: "idle" }; + ship.state = "idle"; + return; + } + + if (ship.systemId !== carrier.systemId) { + this.updateTravelState(ship, carrier.group.position.clone(), carrier.systemId, delta, carrier.definition.size + 28); + return; + } + + if (this.updateDockingState(ship, carrier, delta)) { + ship.order = { kind: "idle" }; + } + } + + private updateDockedShipTransform(ship: ShipInstance) { + const host = this.getDockingHostForShip(ship); + if (!host || ship.dockingPortIndex === undefined) { + return; + } + const port = host.group.localToWorld(host.dockingPorts[ship.dockingPortIndex].clone()); + ship.group.position.copy(port); + ship.systemId = host.systemId; + } + private updateSystems(delta: number) { this.systems.forEach((system) => { system.planets.forEach((planet) => { @@ -718,33 +996,99 @@ export class GameApp { } station.energy = Math.min(station.maxEnergy, station.energy + gameBalance.energy.stationSolarCharge * delta); - if (station.definition.category !== "refining" || !this.refiningRecipe) { - return; - } - - const recipeInput = this.refiningRecipe.inputs[0]; - const recipeOutput = this.refiningRecipe.outputs[0]; - if (station.activeBatch <= 0 && station.oreStored >= recipeInput.amount) { - station.activeRecipeId = this.refiningRecipe.id; - station.activeBatch = recipeInput.amount; - station.oreStored -= recipeInput.amount; - station.inventory["bulk-solid"] = Math.max(0, station.inventory["bulk-solid"] - recipeInput.amount); - station.processTimer = this.refiningRecipe.duration; - } - - if (station.activeBatch > 0) { - station.processTimer = Math.max(0, station.processTimer - delta); - if (station.processTimer <= 0) { - const outputAmount = (station.activeBatch / recipeInput.amount) * recipeOutput.amount; - station.refinedStock += outputAmount; - station.inventory.manufactured += outputAmount; - station.activeBatch = 0; - station.activeRecipeId = undefined; - } - } + this.updateStationProduction(station, delta); }); } + private updateStationProduction(station: StationInstance, delta: number) { + if (station.activeBatch <= 0) { + const nextRecipe = this.findNextStationRecipe(station); + if (nextRecipe) { + station.activeRecipeId = nextRecipe.id; + station.activeBatch = nextRecipe.inputs.reduce((total, component) => total + component.amount, 0); + station.processTimer = nextRecipe.duration; + nextRecipe.inputs.forEach((component) => this.removeStationItem(station, component.itemId, component.amount)); + } + } + + if (station.activeBatch <= 0 || !station.activeRecipeId) { + return; + } + + const recipe = recipeDefinitions.find((candidate) => candidate.id === station.activeRecipeId); + if (!recipe) { + station.activeBatch = 0; + station.activeRecipeId = undefined; + station.processTimer = 0; + return; + } + + station.processTimer = Math.max(0, station.processTimer - delta); + if (station.processTimer > 0) { + return; + } + + recipe.outputs.forEach((component) => this.addStationItem(station, component.itemId, component.amount)); + station.activeBatch = 0; + station.activeRecipeId = undefined; + } + + private findNextStationRecipe(station: StationInstance) { + return recipeDefinitions + .filter((recipe) => this.canStationRunRecipe(station, recipe)) + .sort((left, right) => (right.priority ?? 0) - (left.priority ?? 0))[0]; + } + + private canStationRunRecipe(station: StationInstance, recipe: (typeof recipeDefinitions)[number]) { + const categoryMatches = + recipe.facilityCategory === station.definition.category || + (recipe.facilityCategory === "station" && station.modules.includes("fabricator-array")); + const modulesMatch = (recipe.requiredModules ?? []).every((moduleId) => station.modules.includes(moduleId)); + const inputsMatch = recipe.inputs.every((component) => this.getStationItemAmount(station, component.itemId) >= component.amount); + return categoryMatches && modulesMatch && inputsMatch; + } + + private getStationItemAmount(station: StationInstance, itemId: string) { + return station.itemStocks[itemId] ?? 0; + } + + private addStationItem(station: StationInstance, itemId: string, amount: number) { + if (amount <= 0) { + return; + } + station.itemStocks[itemId] = (station.itemStocks[itemId] ?? 0) + amount; + const storage = itemDefinitionsById.get(itemId)?.storage; + if (storage) { + station.inventory[storage] += amount; + } + if (itemId === "ore") { + station.oreStored += amount; + } + if (itemId === "refined-metals") { + station.refinedStock += amount; + } + } + + private removeStationItem(station: StationInstance, itemId: string, amount: number) { + if (amount <= 0) { + return 0; + } + const available = station.itemStocks[itemId] ?? 0; + const removed = Math.min(available, amount); + station.itemStocks[itemId] = Math.max(0, available - removed); + const storage = itemDefinitionsById.get(itemId)?.storage; + if (storage) { + station.inventory[storage] = Math.max(0, station.inventory[storage] - removed); + } + if (itemId === "ore") { + station.oreStored = Math.max(0, station.oreStored - removed); + } + if (itemId === "refined-metals") { + station.refinedStock = Math.max(0, station.refinedStock - removed); + } + return removed; + } + private consumeShipResources(ship: ShipInstance, delta: number) { if (ship.state === "warping" || ship.state === "spooling-ftl") { ship.energy = Math.max(0, ship.energy - gameBalance.energy.warpDrain * delta); @@ -835,10 +1179,10 @@ export class GameApp { threshold: number, suppliedPlan?: TravelPlan, ) { - if (ship.state === "docked" && ship.dockedStationId) { - const station = this.stations.find((candidate) => candidate.id === ship.dockedStationId); - if (station) { - this.beginUndock(ship, station); + if (ship.state === "docked") { + const host = this.getDockingHostForShip(ship); + if (host) { + this.beginUndock(ship, host); } } @@ -858,7 +1202,13 @@ export class GameApp { return this.moveShipToward(ship, destination, ship.definition.speed, delta, threshold); } - if (ship.state === "idle" || ship.state === "moving" || ship.state === "mining-approach" || ship.state === "delivering") { + if ( + ship.state === "idle" || + ship.state === "moving" || + ship.state === "mining-approach" || + ship.state === "mining" || + ship.state === "delivering" + ) { ship.state = "leaving-gravity-well"; } @@ -901,21 +1251,21 @@ export class GameApp { return false; } - private updateDockingState(ship: ShipInstance, station: StationInstance, delta: number) { - const portIndex = this.reserveDockingPort(station, ship); + private updateDockingState(ship: ShipInstance, host: DockingHost, delta: number) { + const portIndex = this.reserveDockingPort(host, ship); if (portIndex < 0) { ship.state = "docking-approach"; ship.velocity.multiplyScalar(0.7); return false; } - const portPosition = station.group.localToWorld(station.dockingPorts[portIndex].clone()); + const portPosition = host.group.localToWorld(host.dockingPorts[portIndex].clone()); if (ship.state !== "docking" && ship.state !== "docked") { ship.state = "docking-approach"; } if (ship.state === "docking-approach") { - if (this.moveShipToward(ship, portPosition, ship.definition.speed * 0.75, delta, 8)) { + if (this.moveShipToward(ship, portPosition, ship.definition.speed * 0.75, delta, 8, true)) { ship.state = "docking"; ship.actionTimer = gameBalance.dockingDuration; } @@ -941,47 +1291,121 @@ export class GameApp { return false; } - private beginUndock(ship: ShipInstance, station: StationInstance) { + private beginUndock(ship: ShipInstance, host: DockingHost) { if (ship.state === "undocking") { return; } ship.state = "undocking"; ship.actionTimer = gameBalance.dockingDuration * 0.75; const portIndex = ship.dockingPortIndex ?? 0; - const port = station.group.localToWorld(station.dockingPorts[portIndex].clone()); - const direction = port.clone().sub(station.group.position).setY(0).normalize(); + const port = host.group.localToWorld(host.dockingPorts[portIndex].clone()); + const direction = port.clone().sub(host.group.position).setY(0).normalize(); ship.target.copy(port.clone().add(direction.multiplyScalar(gameBalance.undockDistance)).setY(gameBalance.yPlane)); - this.releaseDockingPort(station, ship); + this.releaseDockingPort(host, ship); } - private reserveDockingPort(station: StationInstance, ship: ShipInstance) { - if (ship.dockedStationId === station.id && ship.dockingPortIndex !== undefined) { + private reserveDockingPort(host: DockingHost, ship: ShipInstance) { + if (this.getAssignedDockingHostId(ship) === host.id && ship.dockingPortIndex !== undefined) { return ship.dockingPortIndex; } - if (station.dockedShipIds.size >= station.definition.dockingCapacity) { + if (!this.canShipDockAtHost(ship, host)) { + return -1; + } + if (host.dockedShipIds.size >= host.dockingPorts.length) { return -1; } const usedPorts = new Set( this.ships - .filter((candidate) => candidate.dockedStationId === station.id && candidate.dockingPortIndex !== undefined) + .filter((candidate) => this.getAssignedDockingHostId(candidate) === host.id && candidate.dockingPortIndex !== undefined) .map((candidate) => candidate.dockingPortIndex as number), ); - const freePort = station.dockingPorts.findIndex((_, index) => !usedPorts.has(index)); + const freePort = host.dockingPorts.findIndex((_, index) => !usedPorts.has(index)); if (freePort >= 0) { - station.dockedShipIds.add(ship.id); - ship.dockedStationId = station.id; + host.dockedShipIds.add(ship.id); + this.assignDockingHost(ship, host); ship.dockingPortIndex = freePort; } return freePort; } - private releaseDockingPort(station: StationInstance, ship: ShipInstance) { - station.dockedShipIds.delete(ship.id); + private releaseDockingPort(host: DockingHost, ship: ShipInstance) { + host.dockedShipIds.delete(ship.id); ship.dockedStationId = undefined; + ship.dockedCarrierId = undefined; ship.dockingPortIndex = undefined; } - private moveShipToward(ship: ShipInstance, destination: THREE.Vector3, speed: number, delta: number, threshold: number) { + private getAssignedDockingHostId(ship: ShipInstance) { + return ship.dockedCarrierId ?? ship.dockedStationId; + } + + private getDockingHostForShip(ship: ShipInstance) { + if (ship.dockedCarrierId) { + return this.shipsById.get(ship.dockedCarrierId); + } + if (ship.dockedStationId) { + return this.stations.find((candidate) => candidate.id === ship.dockedStationId); + } + return undefined; + } + + private assignDockingHost(ship: ShipInstance, host: DockingHost) { + if (this.isCarrierHost(host)) { + ship.dockedCarrierId = host.id; + ship.dockedStationId = undefined; + ship.systemId = host.systemId; + return; + } + ship.dockedStationId = host.id; + ship.dockedCarrierId = undefined; + } + + private isCarrierHost(host: DockingHost): host is ShipInstance { + return "definition" in host; + } + + private canDockShipAtCarrier(ship: ShipInstance, carrier: ShipInstance) { + return ( + ship.id !== carrier.id && + carrier.definition.dockingCapacity !== undefined && + carrier.definition.dockingCapacity > 0 && + (carrier.definition.dockingClasses ?? []).includes(ship.definition.shipClass) + ); + } + + private canShipDockAtHost(ship: ShipInstance, host: DockingHost) { + if (this.isCarrierHost(host)) { + return this.canDockShipAtCarrier(ship, host); + } + return true; + } + + private findNearestFriendlyCarrier(ship: ShipInstance) { + return this.ships + .filter((candidate) => candidate.systemId === ship.systemId && this.canDockShipAtCarrier(ship, candidate)) + .sort( + (left, right) => + ship.group.position.distanceTo(left.group.position) - ship.group.position.distanceTo(right.group.position), + )[0]; + } + + private assignDockOrder(ship: ShipInstance, carrier: ShipInstance) { + if (!this.canDockShipAtCarrier(ship, carrier)) { + return; + } + ship.travelPlan = undefined; + ship.order = { kind: "dock", carrierShipId: carrier.id }; + ship.state = "docking-approach"; + } + + private moveShipToward( + ship: ShipInstance, + destination: THREE.Vector3, + speed: number, + delta: number, + threshold: number, + directApproach = false, + ) { const target = destination.clone().setY(gameBalance.yPlane); ship.target.copy(target); @@ -993,7 +1417,7 @@ export class GameApp { } let desiredDirection = toTarget.normalize(); - if (ship.state !== "warping" && ship.state !== "spooling-ftl") { + if (!directApproach && ship.state !== "warping" && ship.state !== "spooling-ftl") { const systemCenter = this.getSystem(ship.systemId).center; const radial = ship.group.position.clone().sub(systemCenter).setY(0); const targetRadial = target.clone().sub(systemCenter).setY(0); @@ -1066,22 +1490,24 @@ export class GameApp { ship.state = "mining-approach"; } - private setPatrolOrder(ship: ShipInstance, points: THREE.Vector3[], startIndex: number) { + private setPatrolOrder(ship: ShipInstance, points: THREE.Vector3[], startIndex: number, systemId = ship.systemId) { ship.order = { kind: "patrol", points: points.map((point) => point.clone().setY(gameBalance.yPlane)), - systemId: ship.systemId, + systemId, index: startIndex, }; ship.state = "patrolling"; } - private setEscortOrder(ship: ShipInstance, target: ShipInstance) { + private setEscortOrder(ship: ShipInstance, target: ShipInstance, offset = ship.formationOffset.clone()) { const angle = (this.ships.indexOf(ship) % 6) * (Math.PI / 3); + const formationOffset = + offset.lengthSq() > 0 ? offset : new THREE.Vector3(Math.cos(angle) * 32, 0, Math.sin(angle) * 32); ship.order = { kind: "escort", targetShipId: target.id, - offset: new THREE.Vector3(Math.cos(angle) * 32, 0, Math.sin(angle) * 32), + offset: formationOffset, }; ship.state = "escorting"; } @@ -1126,7 +1552,7 @@ export class GameApp { } private focusSelection() { - if (this.selection.length === 0 && !this.selectedStation) { + if (this.selection.length === 0 && !this.selectedStation && !this.activeFleetId) { return; } if (this.selectedStation) { @@ -1136,6 +1562,10 @@ export class GameApp { this.updateHud(); return; } + if (this.selection.length === 0 && this.activeFleetId) { + this.focusFleet(this.activeFleetId); + return; + } if (this.selection.length === 1) { const ship = this.selection[0]; this.followShipId = ship.id; @@ -1159,6 +1589,9 @@ export class GameApp { return; } if (this.selection.length === 0) { + if (this.activeFleetId && (action === "mine" || action === "patrol")) { + this.handleFleetAction(action, this.activeFleetId); + } return; } if (action === "mine") { @@ -1185,9 +1618,163 @@ export class GameApp { } }); } + 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 === "toggle-fleet-command") { + this.toggleWindow("fleet-command"); + return; + } + if (action === "toggle-ship-designer") { + this.toggleWindow("ship-designer"); + return; + } + if (action === "toggle-station-manager") { + this.toggleWindow("station-manager"); + } + } + + private handleFleetAction(action: string, fleetId = this.activeFleetId) { + if (!fleetId) { + return; + } + + if (action === "select") { + this.activeFleetId = fleetId; + this.selectFleetShips(fleetId); + this.updateHud(); + return; + } + + const fleet = this.fleetsById.get(fleetId); + if (!fleet) { + return; + } + + this.activeFleetId = fleet.id; + if (action === "focus") { + this.focusFleet(fleet.id); + return; + } + if (action === "patrol") { + this.setFleetPatrolOrder(fleet, fleet.systemId); + } + if (action === "mine") { + this.setFleetMineOrder( + fleet, + scenarioDefinition.miningDefaults.nodeSystemId, + scenarioDefinition.miningDefaults.refinerySystemId, + ); + } + if (action === "hold") { + this.setFleetIdleOrder(fleet); + } + this.updateHud(); + } + + private handleWindowSelection(kind: string, id: string) { + if (kind === "fleet") { + this.selectFleetShips(id); + this.activeFleetId = id; + this.updateHud(); + return; + } + + if (kind === "wing") { + this.selectWingShips(id); + this.updateHud(); + return; + } + + if (kind === "ship") { + const ship = this.shipsById.get(id); + if (!ship) { + return; + } + this.selectionManager.replaceShips([ship]); + this.syncActiveFleetFromSelection(); + this.updateHud(); + } + } + + private toggleWindow(windowId: GameWindowId) { + this.windowState[windowId] = !this.windowState[windowId]; + this.updateHud(); + } + + private focusFleet(fleetId: string) { + const fleet = this.fleetsById.get(fleetId); + if (!fleet) { + return; + } + const center = new THREE.Vector3(); + const ships = getFleetShipIds(fleet) + .map((shipId) => this.shipsById.get(shipId)) + .filter((ship): ship is ShipInstance => Boolean(ship)); + if (ships.length === 0) { + return; + } + ships.forEach((ship) => center.add(ship.group.position)); + center.multiplyScalar(1 / ships.length); + this.followShipId = undefined; + this.getCameraFocus().copy(center); + this.selectedSystemIndex = this.systems.findIndex((system) => system.definition.id === fleet.systemId); + this.updateHud(); + } + + private selectFleetShips(fleetId: string) { + const fleet = this.fleetsById.get(fleetId); + if (!fleet) { + return; + } + this.selectionManager.replaceShips( + getFleetShipIds(fleet) + .map((shipId) => this.shipsById.get(shipId)) + .filter((ship): ship is ShipInstance => Boolean(ship)), + ); + this.syncActiveFleetFromSelection(); + } + + private selectWingShips(wingId: string) { + const fleet = this.fleets.find((candidate) => candidate.wings.some((wing) => wing.id === wingId)); + if (!fleet) { + return; + } + const wingIds = this.collectWingTreeIds(fleet, wingId); + const ships = fleet.wings + .filter((wing) => wingIds.has(wing.id)) + .flatMap((wing) => wing.shipIds) + .map((shipId) => this.shipsById.get(shipId)) + .filter((ship): ship is ShipInstance => Boolean(ship)); + this.selectionManager.replaceShips(ships); + this.activeFleetId = fleet.id; + this.syncActiveFleetFromSelection(); + } + + private collectWingTreeIds(fleet: FleetInstance, rootWingId: string) { + const ids = new Set([rootWingId]); + let changed = true; + while (changed) { + changed = false; + fleet.wings.forEach((wing) => { + if (wing.parentWingId && ids.has(wing.parentWingId) && !ids.has(wing.id)) { + ids.add(wing.id); + changed = true; + } + }); + } + return ids; + } + private adjustZoom(multiplier: number) { const focus = this.getCameraFocus(); const direction = this.camera.position.clone().sub(focus).normalize(); @@ -1208,6 +1795,8 @@ export class GameApp { selection: this.selection, selectedStation: this.selectedStation, cameraFocus: this.getCameraFocus(), + fleets: this.fleets, + activeFleetId: this.activeFleetId, }); drawStrategicOverlay({ context: this.strategicOverlayContext, @@ -1221,6 +1810,8 @@ export class GameApp { selectedStation: this.selectedStation, selectedSystemIndex: this.selectedSystemIndex, viewLevel: this.viewLevel, + fleets: this.fleets, + activeFleetId: this.activeFleetId, }); } @@ -1228,6 +1819,7 @@ export class GameApp { const selectedDefinition = constructibleDefinitions[this.selectedConstructible]; const system = this.systems[this.selectedSystemIndex]; const selectedCount = this.selection.length + (this.selectedStation ? 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.selection, @@ -1235,11 +1827,18 @@ export class GameApp { this.systems, this.viewLevel, this.ships, + this.fleets, ); 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" : ""}`; - this.ordersEl.dataset.mode = this.selectedStation ? "station" : this.selection.length > 0 ? "ships" : "none"; + : `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"; + this.fleetWindowEl.dataset.open = this.windowState["fleet-command"] ? "true" : "false"; + this.fleetWindowTitleEl.textContent = "Fleet Command"; + this.fleetWindowSubtitleEl.textContent = activeFleet + ? `${activeFleet.label} • ${describeFleetOrder(activeFleet)}` + : "No fleet selected"; + this.fleetWindowBodyEl.innerHTML = getFleetWindowMarkup(this.fleets, this.shipsById, this.activeFleetId, this.selection); } private placeStation(definition: StationInstance["definition"], position: THREE.Vector3, systemId: string) { @@ -1261,30 +1860,18 @@ export class GameApp { } private clearSelection() { - this.selection.forEach((ship) => { - ship.selected = false; - (ship.ring.material as THREE.MeshBasicMaterial).opacity = 0; - }); - this.selection = []; - if (this.selectedStation) { - (this.selectedStation.ring.material as THREE.MeshBasicMaterial).opacity = 0; - this.selectedStation = undefined; - } + this.selectionManager.clear(); + this.syncActiveFleetFromSelection(); } private addShipToSelection(ship: ShipInstance) { - if (this.selection.includes(ship)) { - return; - } - this.selection.push(ship); - ship.selected = true; - (ship.ring.material as THREE.MeshBasicMaterial).opacity = 0.95; + this.selectionManager.addShip(ship); + this.syncActiveFleetFromSelection(); } private removeShipFromSelection(ship: ShipInstance) { - this.selection = this.selection.filter((candidate) => candidate.id !== ship.id); - ship.selected = false; - (ship.ring.material as THREE.MeshBasicMaterial).opacity = 0; + this.selectionManager.removeShip(ship); + this.syncActiveFleetFromSelection(); } private updateMarqueeBox(clientX: number, clientY: number) { @@ -1321,7 +1908,6 @@ export class GameApp { this.clearSelection(); } - this.selectedStation = undefined; this.ships.forEach((ship) => { if (ship.state === "docked" || !ship.group.visible) { return; @@ -1337,16 +1923,27 @@ export class GameApp { return; } if (this.marqueeModifiers.ctrl && this.selection.includes(ship)) { - this.removeShipFromSelection(ship); + this.selectionManager.removeShip(ship); } else { - this.addShipToSelection(ship); + this.selectionManager.addShip(ship); } }); + this.syncActiveFleetFromSelection(); this.updateHud(); } private getCameraFocus() { return this.cameraFocus; } + + private syncActiveFleetFromSelection() { + if (this.selection.length === 0) { + return; + } + const firstFleetId = this.selection[0]?.fleetId; + if (firstFleetId && this.selection.every((ship) => ship.fleetId === firstFleetId)) { + this.activeFleetId = firstFleetId; + } + } } diff --git a/src/game/data/constructibles.json b/src/game/data/constructibles.json index eed35df..6d63bf2 100644 --- a/src/game/data/constructibles.json +++ b/src/game/data/constructibles.json @@ -29,6 +29,16 @@ "storage": { "bulk-liquid": 600, "container": 400 }, "modules": ["habitat-ring", "fabricator-array", "container-bay"] }, + { + "id": "manufactory", + "label": "Orbital Manufactory", + "category": "station", + "color": "#8df0d2", + "radius": 24, + "dockingCapacity": 3, + "storage": { "manufactured": 2200, "container": 1600 }, + "modules": ["fabricator-array", "fabricator-array", "container-bay", "docking-clamps"] + }, { "id": "shipyard", "label": "Orbital Shipyard", @@ -48,5 +58,15 @@ "dockingCapacity": 1, "storage": { "manufactured": 300 }, "modules": ["turret-grid", "command-bridge"] + }, + { + "id": "stargate", + "label": "Stargate", + "category": "gate", + "color": "#76f0ff", + "radius": 34, + "dockingCapacity": 0, + "storage": { "manufactured": 2400, "container": 800 }, + "modules": ["ftl-core", "fabricator-array", "docking-clamps"] } ] diff --git a/src/game/data/items.json b/src/game/data/items.json index 4fd8dc6..0397e7a 100644 --- a/src/game/data/items.json +++ b/src/game/data/items.json @@ -11,6 +11,36 @@ "storage": "manufactured", "summary": "Processed structural metals used by stations and shipyards." }, + { + "id": "hull-sections", + "label": "Hull Sections", + "storage": "manufactured", + "summary": "Prefabricated structural assemblies for ships and stations." + }, + { + "id": "ammo-crates", + "label": "Ammo Crates", + "storage": "container", + "summary": "Containerized magazines for turrets, launchers, and point defense." + }, + { + "id": "naval-guns", + "label": "Naval Guns", + "storage": "manufactured", + "summary": "Shipboard turret and cannon assemblies." + }, + { + "id": "ship-equipment", + "label": "Ship Equipment", + "storage": "container", + "summary": "Shield emitters, avionics, cooling loops, and service kits." + }, + { + "id": "ship-parts", + "label": "Ship Parts", + "storage": "manufactured", + "summary": "High-value integration kits for hull fitting and final assembly." + }, { "id": "gas", "label": "Volatile Gas", @@ -28,5 +58,47 @@ "label": "Drone Parts", "storage": "container", "summary": "Containerized industrial freight." + }, + { + "id": "trade-hub-kit", + "label": "Trade Hub Kit", + "storage": "manufactured", + "summary": "Deployable prefab package for a trade hub station." + }, + { + "id": "refinery-kit", + "label": "Refinery Kit", + "storage": "manufactured", + "summary": "Deployable prefab package for a refining station." + }, + { + "id": "farm-ring-kit", + "label": "Farm Ring Kit", + "storage": "manufactured", + "summary": "Deployable prefab package for a farm station." + }, + { + "id": "manufactory-kit", + "label": "Manufactory Kit", + "storage": "manufactured", + "summary": "Deployable prefab package for an orbital manufactory." + }, + { + "id": "shipyard-kit", + "label": "Shipyard Kit", + "storage": "manufactured", + "summary": "Deployable prefab package for an orbital shipyard." + }, + { + "id": "defense-grid-kit", + "label": "Defense Grid Kit", + "storage": "manufactured", + "summary": "Deployable prefab package for a defense platform." + }, + { + "id": "stargate-kit", + "label": "Stargate Kit", + "storage": "manufactured", + "summary": "Deployable prefab package for a stargate structure." } ] diff --git a/src/game/data/modules.json b/src/game/data/modules.json index 1e6a34a..0ae507d 100644 --- a/src/game/data/modules.json +++ b/src/game/data/modules.json @@ -41,6 +41,12 @@ "category": "dock", "summary": "Docking collar and transfer arms." }, + { + "id": "carrier-bay", + "label": "Carrier Bay", + "category": "dock", + "summary": "Internal hangar decks and launch recovery systems." + }, { "id": "refinery-stack", "label": "Refinery Stack", diff --git a/src/game/data/recipes.json b/src/game/data/recipes.json index 34729f2..26f643f 100644 --- a/src/game/data/recipes.json +++ b/src/game/data/recipes.json @@ -4,11 +4,255 @@ "label": "Ore Refining", "facilityCategory": "refining", "duration": 8, + "priority": 100, "inputs": [ { "itemId": "ore", "amount": 60 } ], "outputs": [ { "itemId": "refined-metals", "amount": 60 } ] + }, + { + "id": "ore-reclamation", + "label": "Ore Reclamation", + "facilityCategory": "station", + "duration": 7, + "priority": 8, + "requiredModules": ["fabricator-array"], + "inputs": [ + { "itemId": "refined-metals", "amount": 16 } + ], + "outputs": [ + { "itemId": "ore", "amount": 24 } + ] + }, + { + "id": "gas-synthesis", + "label": "Gas Synthesis", + "facilityCategory": "station", + "duration": 6, + "priority": 12, + "requiredModules": ["fabricator-array"], + "inputs": [ + { "itemId": "refined-metals", "amount": 10 } + ], + "outputs": [ + { "itemId": "gas", "amount": 20 } + ] + }, + { + "id": "water-reclamation", + "label": "Water Reclamation", + "facilityCategory": "farm", + "duration": 6, + "priority": 14, + "requiredModules": ["fabricator-array"], + "inputs": [ + { "itemId": "gas", "amount": 8 } + ], + "outputs": [ + { "itemId": "water", "amount": 18 } + ] + }, + { + "id": "drone-parts-assembly", + "label": "Drone Parts Assembly", + "facilityCategory": "station", + "duration": 7, + "priority": 18, + "requiredModules": ["fabricator-array"], + "inputs": [ + { "itemId": "refined-metals", "amount": 12 }, + { "itemId": "ship-equipment", "amount": 6 } + ], + "outputs": [ + { "itemId": "drone-parts", "amount": 16 } + ] + }, + { + "id": "hull-fabrication", + "label": "Hull Fabrication", + "facilityCategory": "station", + "duration": 10, + "priority": 40, + "requiredModules": ["fabricator-array"], + "inputs": [ + { "itemId": "refined-metals", "amount": 70 } + ], + "outputs": [ + { "itemId": "hull-sections", "amount": 35 } + ] + }, + { + "id": "ammo-fabrication", + "label": "Ammo Fabrication", + "facilityCategory": "station", + "duration": 6, + "priority": 34, + "requiredModules": ["fabricator-array"], + "inputs": [ + { "itemId": "refined-metals", "amount": 24 } + ], + "outputs": [ + { "itemId": "ammo-crates", "amount": 30 } + ] + }, + { + "id": "gun-assembly", + "label": "Gun Assembly", + "facilityCategory": "station", + "duration": 9, + "priority": 32, + "requiredModules": ["fabricator-array"], + "inputs": [ + { "itemId": "refined-metals", "amount": 36 } + ], + "outputs": [ + { "itemId": "naval-guns", "amount": 12 } + ] + }, + { + "id": "equipment-assembly", + "label": "Equipment Assembly", + "facilityCategory": "station", + "duration": 11, + "priority": 30, + "requiredModules": ["fabricator-array"], + "inputs": [ + { "itemId": "refined-metals", "amount": 28 }, + { "itemId": "water", "amount": 8 } + ], + "outputs": [ + { "itemId": "ship-equipment", "amount": 18 } + ] + }, + { + "id": "ship-parts-integration", + "label": "Ship Parts Integration", + "facilityCategory": "station", + "duration": 14, + "priority": 50, + "requiredModules": ["fabricator-array"], + "inputs": [ + { "itemId": "hull-sections", "amount": 24 }, + { "itemId": "naval-guns", "amount": 6 }, + { "itemId": "ship-equipment", "amount": 10 } + ], + "outputs": [ + { "itemId": "ship-parts", "amount": 20 } + ] + }, + { + "id": "trade-hub-assembly", + "label": "Trade Hub Assembly", + "facilityCategory": "station", + "duration": 18, + "priority": 24, + "requiredModules": ["fabricator-array"], + "inputs": [ + { "itemId": "ship-parts", "amount": 26 }, + { "itemId": "ship-equipment", "amount": 16 }, + { "itemId": "drone-parts", "amount": 10 } + ], + "outputs": [ + { "itemId": "trade-hub-kit", "amount": 1 } + ] + }, + { + "id": "refinery-assembly", + "label": "Refinery Assembly", + "facilityCategory": "station", + "duration": 20, + "priority": 26, + "requiredModules": ["fabricator-array"], + "inputs": [ + { "itemId": "ship-parts", "amount": 32 }, + { "itemId": "hull-sections", "amount": 24 }, + { "itemId": "ship-equipment", "amount": 14 } + ], + "outputs": [ + { "itemId": "refinery-kit", "amount": 1 } + ] + }, + { + "id": "farm-ring-assembly", + "label": "Farm Ring Assembly", + "facilityCategory": "station", + "duration": 18, + "priority": 22, + "requiredModules": ["fabricator-array"], + "inputs": [ + { "itemId": "ship-parts", "amount": 22 }, + { "itemId": "ship-equipment", "amount": 18 }, + { "itemId": "water", "amount": 22 } + ], + "outputs": [ + { "itemId": "farm-ring-kit", "amount": 1 } + ] + }, + { + "id": "manufactory-assembly", + "label": "Manufactory Assembly", + "facilityCategory": "station", + "duration": 22, + "priority": 28, + "requiredModules": ["fabricator-array"], + "inputs": [ + { "itemId": "ship-parts", "amount": 34 }, + { "itemId": "hull-sections", "amount": 16 }, + { "itemId": "ship-equipment", "amount": 18 } + ], + "outputs": [ + { "itemId": "manufactory-kit", "amount": 1 } + ] + }, + { + "id": "shipyard-assembly", + "label": "Shipyard Assembly", + "facilityCategory": "station", + "duration": 26, + "priority": 30, + "requiredModules": ["fabricator-array"], + "inputs": [ + { "itemId": "ship-parts", "amount": 42 }, + { "itemId": "hull-sections", "amount": 30 }, + { "itemId": "naval-guns", "amount": 10 } + ], + "outputs": [ + { "itemId": "shipyard-kit", "amount": 1 } + ] + }, + { + "id": "defense-grid-assembly", + "label": "Defense Grid Assembly", + "facilityCategory": "station", + "duration": 16, + "priority": 20, + "requiredModules": ["fabricator-array"], + "inputs": [ + { "itemId": "ship-parts", "amount": 18 }, + { "itemId": "naval-guns", "amount": 12 }, + { "itemId": "ammo-crates", "amount": 18 } + ], + "outputs": [ + { "itemId": "defense-grid-kit", "amount": 1 } + ] + }, + { + "id": "stargate-assembly", + "label": "Stargate Assembly", + "facilityCategory": "station", + "duration": 34, + "priority": 36, + "requiredModules": ["fabricator-array"], + "inputs": [ + { "itemId": "ship-parts", "amount": 60 }, + { "itemId": "hull-sections", "amount": 44 }, + { "itemId": "ship-equipment", "amount": 26 }, + { "itemId": "naval-guns", "amount": 8 } + ], + "outputs": [ + { "itemId": "stargate-kit", "amount": 1 } + ] } ] diff --git a/src/game/data/scenario.json b/src/game/data/scenario.json index 8eb9ab2..3d56114 100644 --- a/src/game/data/scenario.json +++ b/src/game/data/scenario.json @@ -7,10 +7,13 @@ { "constructibleId": "defense-grid", "systemId": "helios", "planetIndex": 2, "lagrangeSide": 1 } ], "shipFormations": [ + { "shipId": "carrier", "count": 1, "center": [120, 0, 60], "systemId": "helios" }, { "shipId": "frigate", "count": 6, "center": [180, 0, 90], "systemId": "helios" }, { "shipId": "destroyer", "count": 3, "center": [260, 0, 120], "systemId": "helios" }, + { "shipId": "cruiser", "count": 2, "center": [220, 0, 180], "systemId": "helios" }, { "shipId": "hauler", "count": 4, "center": [310, 0, -150], "systemId": "helios" }, { "shipId": "frigate", "count": 4, "center": [4350, 0, 560], "systemId": "perseus" }, + { "shipId": "cruiser", "count": 1, "center": [4430, 0, 640], "systemId": "perseus" }, { "shipId": "miner", "count": 6, "center": [4620, 0, 700], "systemId": "perseus" } ], "patrolRoutes": [ diff --git a/src/game/data/ships.json b/src/game/data/ships.json index df7e6b2..4084d05 100644 --- a/src/game/data/ships.json +++ b/src/game/data/ships.json @@ -3,6 +3,7 @@ "id": "frigate", "label": "Vanguard Frigate", "role": "military", + "shipClass": "frigate", "speed": 50, "ftlSpeed": 3200, "spoolTime": 2.2, @@ -17,6 +18,7 @@ "id": "destroyer", "label": "Bulwark Destroyer", "role": "military", + "shipClass": "destroyer", "speed": 34, "ftlSpeed": 2900, "spoolTime": 2.8, @@ -27,10 +29,43 @@ "maxHealth": 240, "modules": ["command-bridge", "ion-drive", "ftl-core", "turret-grid", "turret-grid"] }, + { + "id": "cruiser", + "label": "Aegis Cruiser", + "role": "military", + "shipClass": "cruiser", + "speed": 28, + "ftlSpeed": 2750, + "spoolTime": 3.1, + "cargoCapacity": 0, + "color": "#9ec1ff", + "hullColor": "#314562", + "size": 10, + "maxHealth": 340, + "modules": ["command-bridge", "ion-drive", "ftl-core", "turret-grid", "turret-grid", "docking-clamps"] + }, + { + "id": "carrier", + "label": "Citadel Carrier", + "role": "military", + "shipClass": "capital", + "speed": 18, + "ftlSpeed": 2500, + "spoolTime": 4.1, + "cargoCapacity": 0, + "color": "#c6f4ff", + "hullColor": "#35586d", + "size": 16, + "maxHealth": 900, + "modules": ["command-bridge", "ion-drive", "ftl-core", "carrier-bay", "carrier-bay", "turret-grid", "habitat-ring"], + "dockingCapacity": 6, + "dockingClasses": ["frigate", "destroyer", "cruiser"] + }, { "id": "hauler", "label": "Atlas Hauler", "role": "transport", + "shipClass": "industrial", "speed": 22, "ftlSpeed": 2600, "spoolTime": 3.3, @@ -47,6 +82,7 @@ "id": "miner", "label": "Prospector Miner", "role": "mining", + "shipClass": "industrial", "speed": 26, "ftlSpeed": 2400, "spoolTime": 3.1, diff --git a/src/game/fleet/runtime.ts b/src/game/fleet/runtime.ts new file mode 100644 index 0000000..b0ffc2c --- /dev/null +++ b/src/game/fleet/runtime.ts @@ -0,0 +1,236 @@ +import * as THREE from "three"; +import type { FleetBehavior, FleetInstance, FleetWingInstance, ShipInstance } from "../types"; + +interface FleetBuildSpec { + id: string; + label: string; + stance: FleetInstance["stance"]; + systemId: string; + commander: ShipInstance; + wings: Array<{ + id: string; + label: string; + behavior: FleetBehavior; + parentWingId?: string; + ships: ShipInstance[]; + }>; +} + +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 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, + }, + ], + }); + } + + 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, + }, + ], + }); + } + + return specs.map((spec) => materializeFleet(spec)); +} + +export function getFleetShipIds(fleet: FleetInstance) { + return fleet.shipIds; +} + +export function getFleetCommander(fleet: FleetInstance, shipsById: Map) { + return shipsById.get(fleet.commanderShipId); +} + +export function getWingLeader(wing: FleetWingInstance, shipsById: Map) { + return shipsById.get(wing.leaderShipId); +} + +export function getWingMembers(wing: FleetWingInstance, shipsById: Map) { + return wing.shipIds.map((shipId) => shipsById.get(shipId)).filter((ship): ship is ShipInstance => Boolean(ship)); +} + +export function describeFleetOrder(fleet: FleetInstance) { + switch (fleet.order.kind) { + case "idle": + return "Holding formation"; + case "move": + return `Moving to ${fleet.order.systemId}`; + case "patrol": + return `Patrolling ${fleet.order.systemId}`; + case "mine": + return `Mining ${fleet.order.nodeSystemId} -> ${fleet.order.refinerySystemId}`; + } +} + +function materializeFleet(spec: FleetBuildSpec): FleetInstance { + 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) { + return; + } + const fleetWing = wings.find((wing) => wing.shipIds.includes(shipId)); + if (!fleetWing) { + return; + } + ship.fleetId = spec.id; + ship.wingId = fleetWing.id; + ship.behavior = fleetWing.behavior; + ship.isFleetCommander = ship.id === spec.commander.id; + ship.isWingLeader = ship.id === fleetWing.leaderShipId; + ship.formationOffset.copy(makeFormationOffset(index)); + }); + + return { + id: spec.id, + label: spec.label, + stance: spec.stance, + commanderShipId: spec.commander.id, + systemId: spec.systemId, + shipIds, + wings, + order: { kind: "idle" }, + }; +} + +function buildWing( + fleetId: string, + wing: { + id: string; + label: string; + behavior: FleetBehavior; + parentWingId?: string; + ships: ShipInstance[]; + }, +): FleetWingInstance { + const orderedShips = [...wing.ships]; + const leader = orderedShips[0]; + + orderedShips.forEach((ship, index) => { + ship.formationOffset.copy(makeFormationOffset(index)); + }); + + return { + id: `${fleetId}:${wing.id}`, + fleetId, + label: wing.label, + behavior: wing.behavior, + parentWingId: wing.parentWingId ? `${fleetId}:${wing.parentWingId}` : undefined, + leaderShipId: leader.id, + shipIds: orderedShips.map((ship) => ship.id), + }; +} + +function clearFleetAssignments(ships: ShipInstance[]) { + ships.forEach((ship) => { + ship.fleetId = undefined; + ship.wingId = undefined; + ship.behavior = "independent"; + ship.isFleetCommander = false; + ship.isWingLeader = false; + ship.formationOffset.set(0, 0, 0); + }); +} + +function makeFormationOffset(index: number) { + if (index === 0) { + return new THREE.Vector3(); + } + const ring = Math.ceil((Math.sqrt(index + 1) - 1) / 2); + const slot = index - (2 * ring - 1) ** 2; + const side = Math.floor(slot / Math.max(1, ring * 2)); + const local = slot % Math.max(1, ring * 2); + const spacing = 26; + + switch (side) { + case 0: + return new THREE.Vector3((local - ring) * spacing, 0, ring * spacing); + case 1: + return new THREE.Vector3(ring * spacing, 0, (ring - local) * spacing); + case 2: + return new THREE.Vector3((ring - local) * spacing, 0, -ring * spacing); + default: + return new THREE.Vector3(-ring * spacing, 0, (local - ring) * spacing); + } +} diff --git a/src/game/state/selectionManager.ts b/src/game/state/selectionManager.ts new file mode 100644 index 0000000..b2a04fe --- /dev/null +++ b/src/game/state/selectionManager.ts @@ -0,0 +1,76 @@ +import * as THREE from "three"; +import type { ShipInstance, StationInstance } from "../types"; + +export class SelectionManager { + private shipSelection: ShipInstance[] = []; + private stationSelection?: StationInstance; + + getShips() { + return this.shipSelection; + } + + getStation() { + return this.stationSelection; + } + + clear() { + this.shipSelection.forEach((ship) => this.setShipVisual(ship, false)); + this.shipSelection = []; + if (this.stationSelection) { + this.setStationVisual(this.stationSelection, false); + this.stationSelection = undefined; + } + } + + replaceShips(ships: ShipInstance[]) { + this.clear(); + ships.forEach((ship) => this.addShip(ship)); + } + + setStation(station?: StationInstance) { + this.clear(); + if (!station) { + return; + } + this.stationSelection = station; + this.setStationVisual(station, true); + } + + addShip(ship: ShipInstance) { + if (this.shipSelection.includes(ship)) { + return; + } + if (this.stationSelection) { + this.setStationVisual(this.stationSelection, false); + this.stationSelection = undefined; + } + this.shipSelection.push(ship); + this.setShipVisual(ship, true); + } + + removeShip(ship: ShipInstance) { + this.shipSelection = this.shipSelection.filter((candidate) => candidate.id !== ship.id); + this.setShipVisual(ship, false); + } + + toggleShip(ship: ShipInstance) { + if (this.shipSelection.includes(ship)) { + this.removeShip(ship); + return; + } + this.addShip(ship); + } + + hasShip(ship: ShipInstance) { + return this.shipSelection.includes(ship); + } + + private setShipVisual(ship: ShipInstance, selected: boolean) { + ship.selected = selected; + (ship.ring.material as THREE.MeshBasicMaterial).opacity = selected ? 0.95 : 0; + } + + private setStationVisual(station: StationInstance, selected: boolean) { + (station.ring.material as THREE.MeshBasicMaterial).opacity = selected ? 0.95 : 0; + } +} diff --git a/src/game/types.ts b/src/game/types.ts index a4dab69..e9f2f3e 100644 --- a/src/game/types.ts +++ b/src/game/types.ts @@ -1,12 +1,17 @@ import * as THREE from "three"; 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 ConstructibleCategory = | "station" | "refining" | "farm" | "shipyard" - | "defense"; + | "defense" + | "gate"; export type UnitState = | "idle" | "moving" @@ -22,8 +27,9 @@ export type UnitState = | "docked" | "undocking" | "patrolling" - | "escorting"; -export type UnitOrderKind = "idle" | "move" | "transfer" | "mine" | "patrol" | "escort"; + | "escorting" + | "forming"; +export type UnitOrderKind = "idle" | "move" | "transfer" | "mine" | "patrol" | "escort" | "dock"; export type ItemStorageKind = "bulk-solid" | "bulk-liquid" | "bulk-gas" | "container" | "manufactured"; export type ModuleCategory = | "bridge" @@ -63,6 +69,8 @@ export interface RecipeDefinition { label: string; facilityCategory: ConstructibleCategory; duration: number; + priority?: number; + requiredModules?: string[]; inputs: RecipeComponentDefinition[]; outputs: RecipeComponentDefinition[]; } @@ -71,6 +79,7 @@ export interface ShipDefinition { id: string; label: string; role: ShipRole; + shipClass: ShipClass; speed: number; ftlSpeed: number; spoolTime: number; @@ -82,6 +91,8 @@ export interface ShipDefinition { size: number; maxHealth: number; modules: string[]; + dockingCapacity?: number; + dockingClasses?: ShipClass[]; } export interface ConstructibleDefinition { @@ -153,6 +164,12 @@ export interface PatrolRouteDefinition { points: [number, number, number][]; } +export type FleetOrder = + | { kind: "idle" } + | { kind: "move"; destination: THREE.Vector3; systemId: string } + | { kind: "patrol"; points: THREE.Vector3[]; systemId: string; index: number } + | { kind: "mine"; nodeSystemId: string; refinerySystemId: string }; + export interface ScenarioDefinition { initialStations: InitialStationDefinition[]; shipFormations: ShipFormationDefinition[]; @@ -193,7 +210,8 @@ export type UnitOrder = } | { kind: "mine"; nodeId: string; refineryId: string; phase: "to-node" | "mining" | "to-refinery" | "transfer" } | { kind: "patrol"; points: THREE.Vector3[]; systemId: string; index: number } - | { kind: "escort"; targetShipId: string; offset: THREE.Vector3 }; + | { kind: "escort"; targetShipId: string; offset: THREE.Vector3 } + | { kind: "dock"; carrierShipId: string }; export interface InventoryState { "bulk-solid": number; @@ -210,6 +228,27 @@ export interface TravelPlan { arrivalPoint: THREE.Vector3; } +export interface FleetWingInstance { + id: string; + fleetId: string; + label: string; + behavior: FleetBehavior; + parentWingId?: string; + leaderShipId: string; + shipIds: string[]; +} + +export interface FleetInstance { + id: string; + label: string; + stance: FleetStance; + commanderShipId: string; + systemId: string; + shipIds: string[]; + wings: FleetWingInstance[]; + order: FleetOrder; +} + export interface ShipInstance { id: string; definition: ShipDefinition; @@ -226,6 +265,7 @@ export interface ShipInstance { actionTimer: number; travelPlan?: TravelPlan; dockedStationId?: string; + dockedCarrierId?: string; dockingPortIndex?: number; fuel: number; energy: number; @@ -234,6 +274,14 @@ export interface ShipInstance { idleOrbitRadius: number; idleOrbitAngle: number; warpFx: THREE.Group; + fleetId?: string; + wingId?: string; + behavior: FleetBehavior | "independent"; + isFleetCommander: boolean; + isWingLeader: boolean; + formationOffset: THREE.Vector3; + dockedShipIds: Set; + dockingPorts: THREE.Vector3[]; } export interface StationInstance { @@ -248,6 +296,7 @@ export interface StationInstance { activeBatch: number; activeRecipeId?: string; inventory: InventoryState; + itemStocks: Record; dockedShipIds: Set; dockingPorts: THREE.Vector3[]; modules: string[]; @@ -302,4 +351,8 @@ export interface HudElements { marquee: HTMLDivElement; strategicOverlay: HTMLCanvasElement; strategicOverlayContext: CanvasRenderingContext2D; + fleetWindow: HTMLDivElement; + fleetWindowBody: HTMLDivElement; + fleetWindowTitle: HTMLHeadingElement; + fleetWindowSubtitle: HTMLParagraphElement; } diff --git a/src/game/ui/hud.ts b/src/game/ui/hud.ts index c516bbe..087a250 100644 --- a/src/game/ui/hud.ts +++ b/src/game/ui/hud.ts @@ -1,6 +1,13 @@ import type { HudElements } from "../types"; -export function createHud(container: HTMLElement, onOrderAction: (action: string) => void): HudElements { +interface HudHandlers { + onOrderAction: (action: string) => void; + onWindowAction: (action: string) => void; + onFleetAction: (action: string, fleetId?: string) => void; + onSelectionAction: (kind: string, id: string) => void; +} + +export function createHud(container: HTMLElement, handlers: HudHandlers): HudElements { const root = document.createElement("div"); root.className = "hud"; root.innerHTML = ` @@ -9,7 +16,7 @@ export function createHud(container: HTMLElement, onOrderAction: (action: string

Helios Reach Command

Dual-star-system prototype with gravity-well exits, FTL spooling, inter-system travel, - and unit orders for patrol, escort, mining, and manual fleet movement. + and layered fleet command with wing behaviors, escort screens, and logistics groups.

@@ -23,25 +30,69 @@ export function createHud(container: HTMLElement, onOrderAction: (action: string
+
+ + + +
+
-
Left click select ships or stations. Shift+click adds ships. Right click moves selected ships. Mouse wheel or -/= zoom. B build. 1-5 constructible. M miners mine. P patrol. E escort. Tab jump systems. F focus/follow.
+
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.
+
+
+
+

Fleet Command

+

No fleet selected

+
+ +
+
+ + + + +
+
+ +
`; container.append(root); + initializeWindowInteractions(root); root.querySelectorAll(".orders button").forEach((button) => { - button.addEventListener("click", () => onOrderAction(button.dataset.action ?? "")); + button.addEventListener("click", () => handlers.onOrderAction(button.dataset.action ?? "")); + }); + root.querySelectorAll("[data-window-action]").forEach((button) => { + button.addEventListener("click", () => handlers.onWindowAction(button.dataset.windowAction ?? "")); + }); + root.querySelectorAll("[data-fleet-action]").forEach((button) => { + button.addEventListener("click", () => handlers.onFleetAction(button.dataset.fleetAction ?? "")); + }); + + const fleetWindowBody = root.querySelector(".fleet-window-body"); + fleetWindowBody?.addEventListener("click", (event) => { + const target = event.target as HTMLElement; + const selectionNode = target.closest("[data-select-kind][data-select-id]"); + if (selectionNode) { + handlers.onSelectionAction(selectionNode.dataset.selectKind ?? "", selectionNode.dataset.selectId ?? ""); + return; + } + const fleetButton = target.closest("[data-fleet-id]"); + if (fleetButton?.dataset.fleetId) { + handlers.onFleetAction("select", fleetButton.dataset.fleetId); + } }); const minimap = root.querySelector(".minimap"); @@ -66,5 +117,93 @@ export function createHud(container: HTMLElement, onOrderAction: (action: string marquee: root.querySelector(".marquee") as HTMLDivElement, strategicOverlay, strategicOverlayContext, + fleetWindow: root.querySelector(".fleet-window") as HTMLDivElement, + fleetWindowBody: fleetWindowBody as HTMLDivElement, + fleetWindowTitle: root.querySelector(".fleet-window h2") as HTMLHeadingElement, + fleetWindowSubtitle: root.querySelector(".window-subtitle") as HTMLParagraphElement, }; } + +function initializeWindowInteractions(root: HTMLDivElement) { + root.querySelectorAll(".app-window").forEach((windowEl) => { + initializeWindowPosition(windowEl); + + const header = windowEl.querySelector(".window-header"); + const resizeHandle = windowEl.querySelector(".window-resize-handle"); + + header?.addEventListener("pointerdown", (event) => { + const target = event.target as HTMLElement; + if (target.closest("button")) { + return; + } + + const rect = windowEl.getBoundingClientRect(); + const offsetX = event.clientX - rect.left; + const offsetY = event.clientY - rect.top; + windowEl.dataset.dragging = "true"; + + const move = (moveEvent: PointerEvent) => { + const nextLeft = moveEvent.clientX - offsetX; + const nextTop = moveEvent.clientY - offsetY; + applyWindowRect(windowEl, nextLeft, nextTop, rect.width, rect.height); + }; + + const end = () => { + windowEl.dataset.dragging = "false"; + window.removeEventListener("pointermove", move); + window.removeEventListener("pointerup", end); + }; + + window.addEventListener("pointermove", move); + window.addEventListener("pointerup", end); + }); + + resizeHandle?.addEventListener("pointerdown", (event) => { + event.preventDefault(); + event.stopPropagation(); + const rect = windowEl.getBoundingClientRect(); + const startX = event.clientX; + const startY = event.clientY; + windowEl.dataset.resizing = "true"; + + const move = (moveEvent: PointerEvent) => { + const nextWidth = rect.width + (moveEvent.clientX - startX); + const nextHeight = rect.height + (moveEvent.clientY - startY); + applyWindowRect(windowEl, rect.left, rect.top, nextWidth, nextHeight); + }; + + const end = () => { + windowEl.dataset.resizing = "false"; + window.removeEventListener("pointermove", move); + window.removeEventListener("pointerup", end); + }; + + window.addEventListener("pointermove", move); + window.addEventListener("pointerup", end); + }); + }); +} + +function initializeWindowPosition(windowEl: HTMLElement) { + const defaultWidth = Math.min(480, window.innerWidth - 48); + const defaultHeight = Math.min(680, Math.floor(window.innerHeight * 0.68)); + const left = Math.max(16, Math.round(window.innerWidth * 0.5 - defaultWidth * 0.5)); + const top = Math.max(24, 104); + applyWindowRect(windowEl, left, top, defaultWidth, defaultHeight); +} + +function applyWindowRect(windowEl: HTMLElement, left: number, top: number, width: number, height: number) { + const minWidth = 340; + const minHeight = 240; + const maxWidth = window.innerWidth - 32; + const maxHeight = window.innerHeight - 32; + const clampedWidth = Math.max(minWidth, Math.min(width, maxWidth)); + const clampedHeight = Math.max(minHeight, Math.min(height, maxHeight)); + const clampedLeft = Math.max(16, Math.min(left, window.innerWidth - clampedWidth - 16)); + const clampedTop = Math.max(16, Math.min(top, window.innerHeight - clampedHeight - 16)); + + windowEl.style.left = `${clampedLeft}px`; + windowEl.style.top = `${clampedTop}px`; + windowEl.style.width = `${clampedWidth}px`; + windowEl.style.height = `${clampedHeight}px`; +} diff --git a/src/game/ui/presenters.ts b/src/game/ui/presenters.ts index f113bf5..8b8bb5b 100644 --- a/src/game/ui/presenters.ts +++ b/src/game/ui/presenters.ts @@ -3,8 +3,10 @@ import { moduleDefinitionsById, recipeDefinitions, } from "../data/catalog"; +import { describeFleetOrder } from "../fleet/runtime"; import { getShipCargoAmount } from "../state/inventory"; import type { + FleetInstance, ShipInstance, SolarSystemInstance, StationInstance, @@ -30,45 +32,167 @@ export function getSelectionDetails( systems: SolarSystemInstance[], viewLevel: ViewLevel, ships: ShipInstance[], + fleets: FleetInstance[], ) { if (selectedStation) { - return describeStation(selectedStation, ships); + return describeStation(selectedStation, ships, fleets); } if (selection.length === 0) { - return `Systems online: ${systems.map((system) => system.definition.label).join(", ")}\n\nOrders: Move, Patrol, Escort, Mine\nView: ${viewLevel}`; + return `Systems online: ${systems.map((system) => system.definition.label).join(", ")}\nFleets active: ${fleets.length}\n\nOrders: Move, Patrol, Escort, Mine\nView: ${viewLevel}`; } return selection .map( - (ship) => - `${ship.definition.label} • ${ship.systemId}\nState: ${ship.state}${ship.dockedStationId ? ` @ ${ship.dockedStationId}` : ""}\nOrder: ${ship.order.kind}\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"}\nModules: ${ship.definition.modules.map(getModuleLabel).join(", ")}`, + (ship) => { + const dockedAt = ship.dockedCarrierId ?? ship.dockedStationId; + const hangarStatus = + 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(", ")}`; + }, ) .join("\n\n"); } -export function describeStation(station: StationInstance, ships: ShipInstance[]) { +export function describeStation(station: StationInstance, ships: ShipInstance[], fleets: FleetInstance[]) { const miners = ships.filter((ship) => ship.systemId === station.systemId && ship.order.kind === "mine").length; const escorts = ships.filter((ship) => ship.systemId === station.systemId && ship.order.kind === "escort").length; const patrols = ships.filter((ship) => ship.systemId === station.systemId && ship.order.kind === "patrol").length; + const localFleets = fleets.filter((fleet) => fleet.systemId === station.systemId).length; const activeRecipe = station.activeRecipeId ? recipeDefinitions.find((recipe) => recipe.id === station.activeRecipeId) : undefined; - const refineryStatus = - station.definition.category === "refining" - ? `Ore: ${Math.round(station.oreStored)}\nRefined: ${Math.round(station.refinedStock)}\nBatch: ${Math.round(station.activeBatch)}\nRecipe: ${activeRecipe?.label ?? "Idle"}\nTime Remaining: ${station.activeBatch > 0 ? `${station.processTimer.toFixed(1)}s` : "Idle"}\n` + const stockSummary = Object.entries(station.itemStocks) + .filter(([, amount]) => amount > 0) + .sort((left, right) => right[1] - left[1]) + .slice(0, 5) + .map(([itemId, amount]) => `${getItemLabel(itemId)} ${Math.round(amount)}`) + .join(", "); + const productionStatus = + station.modules.includes("fabricator-array") || station.definition.category === "refining" + ? `Ore: ${Math.round(station.oreStored)}\nRefined Metals: ${Math.round(station.refinedStock)}\nBatch: ${Math.round(station.activeBatch)}\nRecipe: ${activeRecipe?.label ?? "Idle"}\nTime Remaining: ${station.activeBatch > 0 ? `${station.processTimer.toFixed(1)}s` : "Idle"}\nStocks: ${stockSummary || "None"}\n` : ""; const activity = station.definition.category === "refining" - ? `Refining ore for ${miners} mining ships` + ? `Refining and fabricating for ${miners} mining ships` : station.definition.category === "shipyard" - ? `Maintaining ${patrols} patrol craft` + ? `Building ship parts for ${patrols} patrol craft` : station.definition.category === "farm" - ? "Supplying agricultural goods" + ? "Supplying agricultural goods and industrial consumables" : station.definition.category === "defense" ? `Coordinating ${escorts} escort wings` + : station.definition.category === "gate" + ? "Assembling transit infrastructure and gate components" + : station.modules.includes("fabricator-array") + ? "Fabricating industrial parts and equipment" : "Managing local trade traffic"; - return `${station.definition.label} • ${station.systemId}\nRole: ${station.definition.category}\nActivity: ${activity}\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${refineryStatus}Radius: ${station.definition.radius}`; + 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}`; +} + +export function getFleetWindowMarkup( + fleets: FleetInstance[], + shipsById: Map, + activeFleetId: string | undefined, + selection: ShipInstance[], +) { + if (fleets.length === 0) { + return `
No fleets initialized.
`; + } + + const selectedShipIds = new Set(selection.map((ship) => ship.id)); + + return fleets + .map((fleet) => { + const commander = shipsById.get(fleet.commanderShipId); + const rootWings = fleet.wings.filter((wing) => !wing.parentWingId); + const tree = rootWings.map((wing) => renderWingNode(fleet, wing.id, shipsById, selectedShipIds)).join(""); + const fleetSelected = fleet.shipIds.length > 0 && fleet.shipIds.every((shipId) => selectedShipIds.has(shipId)); + + return ` +
+ +
+
+ ${commander?.definition.label ?? fleet.commanderShipId} + Commander • ${fleet.shipIds.length} ships • ${fleet.systemId} + Fleet Order: ${describeFleetOrder(fleet)} + Stance: ${fleet.stance} +
+
${tree}
+
+
+ `; + }) + .join(""); +} + +function renderWingNode( + fleet: FleetInstance, + wingId: string, + shipsById: Map, + selectedShipIds: Set, +): string { + const wing = fleet.wings.find((candidate) => candidate.id === wingId); + if (!wing) { + return ""; + } + + const leader = shipsById.get(wing.leaderShipId); + const childWings = fleet.wings.filter((candidate) => candidate.parentWingId === wing.id); + const wingTreeShipIds = collectWingShipIds(fleet, wing.id); + const wingSelected = wingTreeShipIds.length > 0 && wingTreeShipIds.every((shipId) => selectedShipIds.has(shipId)); + const nonLeaderShips = wing.shipIds + .filter((shipId) => shipId !== wing.leaderShipId) + .map((shipId) => shipsById.get(shipId)) + .filter((ship): ship is ShipInstance => Boolean(ship)); + + return ` +
+
+ ${wing.label} + ${wing.behavior} • ${wing.shipIds.length} ships + Wing Lead: ${leader ? describeShipNode(leader) : wing.leaderShipId} +
+
+ ${childWings.map((childWing) => renderWingNode(fleet, childWing.id, shipsById, selectedShipIds)).join("")} + ${nonLeaderShips + .map( + (ship) => ` +
+
+ ${ship.definition.label} + ${describeShipNode(ship)} +
+
+ `, + ) + .join("")} +
+
+ `; +} + +function describeShipNode(ship: ShipInstance): string { + return `${ship.definition.shipClass} • ${ship.state} • ${ship.order.kind} • ${ship.behavior}`; +} + +function collectWingShipIds(fleet: FleetInstance, rootWingId: string): string[] { + const wingIds = new Set([rootWingId]); + let changed = true; + + while (changed) { + changed = false; + fleet.wings.forEach((wing) => { + if (wing.parentWingId && wingIds.has(wing.parentWingId) && !wingIds.has(wing.id)) { + wingIds.add(wing.id); + changed = true; + } + }); + } + + return fleet.wings.filter((wing) => wingIds.has(wing.id)).flatMap((wing) => wing.shipIds); } export function getItemLabel(itemId?: string) { diff --git a/src/game/ui/strategicRenderer.ts b/src/game/ui/strategicRenderer.ts index 6a0aa86..f9c3950 100644 --- a/src/game/ui/strategicRenderer.ts +++ b/src/game/ui/strategicRenderer.ts @@ -1,5 +1,6 @@ import * as THREE from "three"; -import type { ShipRole, ShipInstance, SolarSystemInstance, StationInstance, ViewLevel } from "../types"; +import { getFleetCommander, getWingLeader } from "../fleet/runtime"; +import type { FleetInstance, ShipRole, ShipInstance, SolarSystemInstance, StationInstance, ViewLevel } from "../types"; interface RenderMinimapOptions { context: CanvasRenderingContext2D; @@ -11,6 +12,8 @@ interface RenderMinimapOptions { selection: ShipInstance[]; selectedStation?: StationInstance; cameraFocus: THREE.Vector3; + fleets: FleetInstance[]; + activeFleetId?: string; } interface RenderOverlayOptions { @@ -25,6 +28,8 @@ interface RenderOverlayOptions { selectedStation?: StationInstance; selectedSystemIndex: number; viewLevel: ViewLevel; + fleets: FleetInstance[]; + activeFleetId?: string; } export function drawMinimap({ @@ -37,6 +42,8 @@ export function drawMinimap({ selection, selectedStation, cameraFocus, + fleets, + activeFleetId, }: RenderMinimapOptions) { context.clearRect(0, 0, width, height); context.fillStyle = "rgba(4, 9, 20, 0.92)"; @@ -82,6 +89,16 @@ export function drawMinimap({ context.fill(); }); + fleets.forEach((fleet) => { + const commander = getFleetCommander(fleet, new Map(ships.map((ship) => [ship.id, ship]))); + if (!commander) { + return; + } + const point = mapPoint(commander.group.position); + context.strokeStyle = fleet.id === activeFleetId ? "#ffbf69" : "rgba(126, 212, 255, 0.42)"; + context.strokeRect(point.x - 6, point.y - 6, 12, 12); + }); + const focus = mapPoint(cameraFocus); context.strokeStyle = "rgba(255,255,255,0.7)"; context.strokeRect(focus.x - 9, focus.y - 9, 18, 18); @@ -99,6 +116,8 @@ export function drawStrategicOverlay({ selectedStation, selectedSystemIndex, viewLevel, + fleets, + activeFleetId, }: RenderOverlayOptions) { context.clearRect(0, 0, width, height); if (viewLevel === "local") { @@ -130,6 +149,8 @@ export function drawStrategicOverlay({ drawShipSymbol(context, screen.x, screen.y, ship, 10, selection.includes(ship)); } }); + + drawFleetLinks(context, camera, fleets, ships, systems[selectedSystemIndex]?.definition.id, activeFleetId); } else { systems.forEach((system) => { const screen = projectWorldToScreen(system.center, camera); @@ -139,19 +160,19 @@ export function drawStrategicOverlay({ drawSystemFrame(context, screen.x, screen.y, system.definition.label); - const fleets = new Map(); + const roleBuckets = new Map(); ships.forEach((ship) => { if (ship.systemId !== system.definition.id) { return; } - const bucket = fleets.get(ship.definition.role) ?? []; + const bucket = roleBuckets.get(ship.definition.role) ?? []; bucket.push(ship); - fleets.set(ship.definition.role, bucket); + roleBuckets.set(ship.definition.role, bucket); }); const roleOrder: ShipRole[] = ["military", "transport", "mining"]; roleOrder.forEach((role, index) => { - const bucket = fleets.get(role); + const bucket = roleBuckets.get(role); if (!bucket || bucket.length === 0) { return; } @@ -165,6 +186,13 @@ export function drawStrategicOverlay({ if (stationCount > 0) { drawStrategicStationGroup(context, screen.x, screen.y - 38, stationCount, stationSelected); } + + const activeFleets = fleets.filter((fleet) => fleet.systemId === system.definition.id).length; + if (activeFleets > 0) { + context.fillStyle = "rgba(255, 191, 105, 0.92)"; + context.font = "600 10px Space Grotesk, sans-serif"; + context.fillText(`${activeFleets} FLEET${activeFleets > 1 ? "S" : ""}`, screen.x, screen.y + 58); + } }); } @@ -260,6 +288,54 @@ function drawFleetSymbol( context.restore(); } +function drawFleetLinks( + context: CanvasRenderingContext2D, + camera: THREE.PerspectiveCamera, + fleets: FleetInstance[], + ships: ShipInstance[], + systemId: string | undefined, + activeFleetId: string | undefined, +) { + const shipsById = new Map(ships.map((ship) => [ship.id, ship])); + + fleets + .filter((fleet) => !systemId || fleet.systemId === systemId) + .forEach((fleet) => { + const commander = getFleetCommander(fleet, shipsById); + if (!commander) { + return; + } + + const commanderScreen = projectWorldToScreen(commander.group.position, camera); + if (!commanderScreen) { + return; + } + + const highlighted = fleet.id === activeFleetId; + context.strokeStyle = highlighted ? "rgba(255, 191, 105, 0.85)" : "rgba(126, 212, 255, 0.24)"; + context.fillStyle = highlighted ? "#ffbf69" : "rgba(126, 212, 255, 0.72)"; + context.lineWidth = highlighted ? 1.8 : 1.1; + + fleet.wings.forEach((wing) => { + const leader = getWingLeader(wing, shipsById); + if (!leader || leader.id === commander.id) { + return; + } + const leaderScreen = projectWorldToScreen(leader.group.position, camera); + if (!leaderScreen) { + return; + } + context.beginPath(); + context.moveTo(commanderScreen.x, commanderScreen.y); + context.lineTo(leaderScreen.x, leaderScreen.y); + context.stroke(); + context.beginPath(); + context.arc(leaderScreen.x, leaderScreen.y, highlighted ? 4.5 : 3, 0, Math.PI * 2); + context.fill(); + }); + }); +} + function drawStrategicStationGroup( context: CanvasRenderingContext2D, x: number, @@ -410,6 +486,14 @@ function drawStationSymbol( context.beginPath(); context.arc(0, 0, 5, 0, Math.PI * 2); context.stroke(); + } else if (station.definition.category === "gate") { + context.beginPath(); + context.arc(0, 0, 6, 0, Math.PI * 2); + context.stroke(); + context.beginPath(); + context.moveTo(-6, 0); + context.lineTo(6, 0); + context.stroke(); } context.restore(); @@ -438,5 +522,8 @@ function getStationSymbolColor(station: StationInstance) { if (station.definition.category === "shipyard") { return "rgba(208, 162, 255, 0.95)"; } + if (station.definition.category === "gate") { + return "rgba(118, 240, 255, 0.95)"; + } return "rgba(180, 201, 218, 0.95)"; } diff --git a/src/game/world/worldFactory.ts b/src/game/world/worldFactory.ts index 4547cf3..6004a23 100644 --- a/src/game/world/worldFactory.ts +++ b/src/game/world/worldFactory.ts @@ -432,6 +432,7 @@ export function createStationInstance({ processTimer: 0, activeBatch: 0, inventory: createEmptyInventory(), + itemStocks: {}, dockedShipIds: new Set(), dockingPorts, modules: definition.modules, @@ -462,6 +463,7 @@ function createShip({ const visual = new THREE.Group(); visual.rotation.y = Math.PI / 2; group.add(visual); + const dockingCapacity = definition.dockingCapacity ?? 0; const warpFx = new THREE.Group(); warpFx.visible = false; @@ -511,6 +513,30 @@ function createShip({ visual.add(wing); }); + if (dockingCapacity > 0) { + const hangarBody = new THREE.Mesh( + 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), + roughness: 0.5, + metalness: 0.75, + }), + ); + hangarBody.position.x = -definition.size * 0.5; + hangarBody.castShadow = true; + visual.add(hangarBody); + + [-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 }), + ); + bay.position.set(-definition.size * 0.3, side * definition.size * 0.52, 0); + visual.add(bay); + }); + } + 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 }), @@ -531,6 +557,24 @@ function createShip({ ring.position.y = -definition.size * 0.55; group.add(ring); + const dockingPorts = Array.from({ length: dockingCapacity }, (_, index) => { + const lane = index % 2 === 0 ? -1 : 1; + const row = Math.floor(index / 2); + const port = new THREE.Vector3( + -definition.size * (0.4 + row * 0.7), + 0, + lane * definition.size * 1.35, + ); + 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 }), + ); + beacon.position.copy(port); + beacon.visible = dockingCapacity > 0; + group.add(beacon); + return port; + }); + const pickHull = new THREE.Mesh( new THREE.SphereGeometry(definition.size * 1.6, 12, 12), new THREE.MeshBasicMaterial({ visible: false }), @@ -558,6 +602,12 @@ function createShip({ idleOrbitRadius: Math.max(120, group.position.length()), idleOrbitAngle: 0, warpFx, + behavior: "independent", + isFleetCommander: false, + isWingLeader: false, + formationOffset: new THREE.Vector3(), + dockedShipIds: new Set(), + dockingPorts, }; selectableTargets.set(pickHull, { kind: "ship", ship }); diff --git a/src/style.css b/src/style.css index 79642a2..6349f9a 100644 --- a/src/style.css +++ b/src/style.css @@ -163,6 +163,13 @@ canvas { gap: 14px; } +.window-launchers, +.fleet-actions { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} + .orders-panel .mode { color: var(--warning); text-shadow: 0 0 18px rgba(255, 191, 105, 0.24); @@ -170,11 +177,14 @@ canvas { .orders { display: grid; - grid-template-columns: repeat(5, minmax(0, 1fr)); + grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 10px; } -.orders button { +.window-launchers button, +.orders button, +.fleet-actions button, +.window-close { border: 1px solid rgba(126, 212, 255, 0.16); border-radius: 12px; background: linear-gradient(180deg, rgba(13, 30, 56, 0.95), rgba(8, 17, 33, 0.95)); @@ -184,11 +194,14 @@ canvas { text-transform: uppercase; letter-spacing: 0.08em; cursor: pointer; - transition: border-color 120ms ease, transform 120ms ease, background 120ms ease; + transition: border-color 120ms ease, transform 120ms ease, background 120ms ease, opacity 120ms ease; box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.02); } -.orders button:hover { +.orders button:hover, +.window-launchers button:hover, +.fleet-actions button:hover, +.window-close:hover { border-color: rgba(126, 212, 255, 0.4); transform: translateY(-1px); } @@ -197,6 +210,12 @@ canvas { opacity: 0.45; } +button:disabled { + opacity: 0.35; + cursor: default; + transform: none; +} + .orders-panel .hint { color: var(--muted); line-height: 1.45; @@ -216,6 +235,242 @@ canvas { background: rgba(2, 6, 13, 0.92); } +.app-window { + position: absolute; + top: 104px; + left: 50%; + width: min(480px, calc(100vw - 48px)); + height: min(68vh, 680px); + display: none; + flex-direction: column; + gap: 14px; + padding: 18px; + pointer-events: auto; + backdrop-filter: blur(16px); + background: + linear-gradient(180deg, rgba(6, 13, 27, 0.94), rgba(4, 10, 21, 0.92)), + radial-gradient(circle at top, rgba(126, 212, 255, 0.08), transparent 60%); + border: 1px solid rgba(126, 212, 255, 0.2); + border-radius: 18px; + box-shadow: 0 24px 80px rgba(0, 0, 0, 0.44); + z-index: 4; +} + +.app-window[data-open="true"] { + display: flex; +} + +.window-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + cursor: move; + user-select: none; +} + +.window-header h2, +.window-header p { + margin: 0; +} + +.window-header h2 { + font-size: 0.95rem; + letter-spacing: 0.16em; + text-transform: uppercase; +} + +.window-subtitle { + margin-top: 6px; + color: var(--muted); + line-height: 1.4; +} + +.window-close { + padding-inline: 14px; + cursor: pointer; +} + +.fleet-actions { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.window-body { + overflow: auto; + display: flex; + flex-direction: column; + gap: 12px; + padding-right: 4px; + min-height: 0; +} + +.window-resize-handle { + position: absolute; + right: 10px; + bottom: 10px; + width: 18px; + height: 18px; + border-right: 2px solid rgba(126, 212, 255, 0.42); + border-bottom: 2px solid rgba(126, 212, 255, 0.42); + border-radius: 0 0 8px 0; + cursor: nwse-resize; +} + +.window-resize-handle::before, +.window-resize-handle::after { + content: ""; + position: absolute; + right: 3px; + bottom: 3px; + border-right: 1px solid rgba(126, 212, 255, 0.24); + border-bottom: 1px solid rgba(126, 212, 255, 0.24); +} + +.window-resize-handle::before { + width: 10px; + height: 10px; +} + +.window-resize-handle::after { + width: 6px; + height: 6px; +} + +.fleet-card { + border: 1px solid rgba(126, 212, 255, 0.14); + border-radius: 14px; + padding: 14px; + background: + linear-gradient(180deg, rgba(8, 18, 35, 0.84), rgba(5, 11, 22, 0.8)), + repeating-linear-gradient( + 90deg, + rgba(126, 212, 255, 0.02) 0, + rgba(126, 212, 255, 0.02) 1px, + transparent 1px, + transparent 14px + ); +} + +.fleet-card[data-active="true"] { + border-color: rgba(255, 191, 105, 0.44); + box-shadow: inset 0 0 0 1px rgba(255, 191, 105, 0.16); +} + +.fleet-select { + width: 100%; + margin-bottom: 10px; +} + +.fleet-card-title, +.fleet-card-line, +.fleet-wing-line { + display: block; + color: var(--muted); + line-height: 1.45; +} + +.fleet-card-title { + color: var(--text); + font-weight: 600; + margin-bottom: 4px; +} + +.fleet-wing-line { + color: #bdd9ea; +} + +.fleet-tree, +.fleet-tree-children { + display: flex; + flex-direction: column; + gap: 10px; +} + +.fleet-tree-root, +.fleet-node-card { + position: relative; + border: 1px solid rgba(126, 212, 255, 0.14); + border-radius: 12px; + padding: 10px 12px; + background: linear-gradient(180deg, rgba(9, 20, 38, 0.88), rgba(5, 12, 24, 0.84)); + cursor: pointer; +} + +.fleet-tree-root::before, +.fleet-node-card::before { + content: ""; + position: absolute; + left: -1px; + top: -1px; + bottom: -1px; + width: 3px; + border-radius: 12px 0 0 12px; + background: rgba(126, 212, 255, 0.48); +} + +.fleet-tree-node { + position: relative; + margin-left: 18px; + padding-left: 18px; +} + +.fleet-tree-node::before { + content: ""; + position: absolute; + left: 0; + top: 0; + bottom: 18px; + width: 1px; + background: rgba(126, 212, 255, 0.18); +} + +.fleet-tree-node::after { + content: ""; + position: absolute; + left: 0; + top: 18px; + width: 14px; + height: 1px; + background: rgba(126, 212, 255, 0.18); +} + +.ship-node .fleet-node-card::before { + background: rgba(255, 191, 105, 0.44); +} + +.fleet-node-title, +.fleet-node-meta { + display: block; +} + +.fleet-node-title { + color: var(--text); + font-weight: 600; + line-height: 1.35; +} + +.fleet-node-meta { + color: var(--muted); + line-height: 1.4; + margin-top: 2px; +} + +.fleet-tree-root:hover, +.fleet-node-card:hover { + border-color: rgba(126, 212, 255, 0.32); +} + +.fleet-tree-root[data-selected="true"], +.fleet-node-card[data-selected="true"] { + border-color: rgba(255, 191, 105, 0.46); + box-shadow: inset 0 0 0 1px rgba(255, 191, 105, 0.16); +} + +.fleet-tree-root[data-selected="true"]::before, +.fleet-node-card[data-selected="true"]::before { + background: rgba(255, 191, 105, 0.72); +} + @media (max-width: 900px) { .summary, .details, @@ -242,4 +497,14 @@ canvas { .orders { grid-template-columns: repeat(3, minmax(0, 1fr)); } + + .window-launchers, + .fleet-actions { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .app-window { + top: 76px; + width: calc(100vw - 32px); + } }