From 9db53f1b48a7d4a95e9606d85a1d10f0d2a0e3c6 Mon Sep 17 00:00:00 2001 From: Jonathan Bourdon Date: Thu, 12 Mar 2026 11:13:37 -0400 Subject: [PATCH] Simplify simulation and restore ship browser --- SESSION.md | 60 ++-- src/game/GameApp.ts | 484 ++++------------------------ src/game/fleet/runtime.ts | 212 ------------ src/game/types.ts | 37 --- src/game/ui/hud.ts | 27 +- src/game/ui/presenters.ts | 151 +++------ src/game/ui/strategicRenderer.ts | 81 +---- src/game/world/universeGenerator.ts | 52 ++- src/game/world/worldFactory.ts | 4 - src/style.css | 200 ++++-------- 10 files changed, 219 insertions(+), 1089 deletions(-) delete mode 100644 src/game/fleet/runtime.ts diff --git a/SESSION.md b/SESSION.md index fe60aa5..68340d4 100644 --- a/SESSION.md +++ b/SESSION.md @@ -8,7 +8,7 @@ The codebase is still TypeScript + Three.js on Vite, with authored catalogs unde - procedural universe generation - autonomous faction behavior -- fleet / wing hierarchy +- direct per-ship faction control - economic production loops - pirate harassment - strategic system control @@ -24,12 +24,6 @@ The current build includes: - rich central systems that factions contest for control - faction-owned stations, ships, inventories, and combat stats - autonomous shipbuilding and limited outpost growth -- fleet and wing structure with behaviors such as: - - `command` - - `screen` - - `mining` - - `logistics` - - `escort` - observer controls for camera orbit, pan, focus, and inspection ## Major Gameplay / Sim Systems @@ -67,23 +61,14 @@ The current build includes: ### High-Level AI / Delegation -- Faction AI now acts at a strategic level instead of directly micromanaging every ship. +- Faction AI now acts at a strategic level and issues direct orders to ships. - Empire AI chooses high-level goals such as: - secure home and mining space - contest central systems - - assign industrial fleets to mining loops -- Pirate AI chooses raid targets and dispatches fleets into hostile space. -- Fleet-level orders are now the intended command boundary between: - - faction strategy - - fleet / wing execution -- This work was specifically done to stop faction AI from stomping `screen` behavior with raw ship move orders. - -### Fleets / Wings - -- Fleet creation now groups ships per faction and role in `src/game/fleet/runtime.ts`. -- War fleets and industry fleets are generated from faction-owned ships. -- Wing behaviors remain meaningful at the tactical layer. -- `screen` is intended to remain subordinate to fleet command rather than independent faction micromanagement. + - send miners to resource systems + - send military ships to rally and patrol targets +- Pirate AI chooses raid targets and moves military ships into hostile space. +- The fleet / wing layer has been removed from both simulation and UI. ### Economy / Production @@ -106,11 +91,14 @@ The current build includes: ## Starting State - Empires now start very small for easier debugging and growth observation. -- Each empire currently starts with only 3 ships: - - 1 frigate - - 1 hauler +- Each empire currently starts with: - 1 miner -- Pirates still start with small raiding groups. + - 1 manufactory + - 1 refinery +- Each pirate faction currently starts with: + - 1 frigate + - 1 trade hub +- This is a bootstrap-oriented setup: factions mine first, then try to grow from minimal infrastructure. ## UI / UX State @@ -123,7 +111,6 @@ The current build includes: - status line - horizontally scrolling cards for selected entities - fallback observer details when nothing specific is selected -- Fleet launch controls were removed from the main HUD. - A dedicated `Debug` window now contains the `New Universe` button. ### Selection / Inspection @@ -136,19 +123,24 @@ The current build includes: - stations - Double-click centers / focuses the clicked target. - Multiple ship selections render as horizontal cards in the bottom dock. -- Fleet window tree selection still works. ### Windows - Generic draggable / resizable app windows still exist. - Main windows currently in use: - - `Fleet Command` + - `Ships` - `Debug` +- The `Ships` window: + - lists ships grouped by faction + - selects a ship on click + - focuses a ship on double click + - focuses a faction home system when clicking that faction header ### Strategic Rendering - Strategic overlay and minimap infrastructure still exist. - The minimap canvas is still created for renderer use, but it is no longer shown in the visible HUD. +- Fleet link overlays and fleet counters were removed along with the fleet system. ## Controls @@ -160,10 +152,10 @@ The current build includes: - `Middle Drag`: orbit camera - `Shift + Middle Drag`: pan camera - `Mouse Wheel` or `-` / `=`: zoom -- `W A S D`: pan camera +- `W A S D`: pan camera using the same motion as `Shift + Middle Drag` - `Q / E`: rotate camera - `F`: focus current selection -- `G`: toggle fleet command window +- `G`: toggle ships window - `Tab`: jump camera between systems ## Technical Notes @@ -173,8 +165,6 @@ The current build includes: - `src/game/world/worldFactory.ts` - Procedural universe generation: - `src/game/world/universeGenerator.ts` -- Fleet composition helpers: - - `src/game/fleet/runtime.ts` - Selection state: - `src/game/state/selectionManager.ts` - HUD / presentation: @@ -191,16 +181,16 @@ The current build includes: - Economic logistics are still abstracted heavily. - Ship construction is recipe-gated but still simplified. - Stations consume pooled faction stock rather than explicit transport delivery chains. -- Fleet window remains useful, but the overall UI is now only partially refit for observer mode. +- Bootstrap progression is still constrained by the current station recipe / stock model. +- The ships window is useful for inspection, but the overall UI is still only partially refit for observer mode. - There is still no persistence layer for window layouts, saves, or generated universe seeds. ## Suggested Next Steps - Extract faction strategy into a dedicated AI / planning module -- Extract fleet order execution into its own gameplay system - Separate economic simulation from UI and rendering concerns - Improve transport logistics so goods physically move through faction supply chains - Add explicit shipyard construction queues and faction production priorities -- Improve combat behavior so `screen`, `escort`, and `command` have stronger distinct tactical roles +- Rework bootstrap progression so factions can genuinely grow from near-zero infrastructure - Add system-level threat, ownership, and economy views for game-master inspection - Add save/load support for generated universes and long-running simulations diff --git a/src/game/GameApp.ts b/src/game/GameApp.ts index a4d74a9..5409b48 100644 --- a/src/game/GameApp.ts +++ b/src/game/GameApp.ts @@ -6,20 +6,10 @@ import { recipeDefinitions, shipDefinitionsById, } from "./data/catalog"; -import { - createDefaultFleets, - describeFleetOrder, - getFleetCommander, - getFleetShipIds, - getWingLeader, - getWingMembers, -} from "./fleet/runtime"; import { addShipCargo, getShipCargoAmount, removeShipCargo } from "./state/inventory"; import { SelectionManager } from "./state/selectionManager"; import type { FactionInstance, - FleetInstance, - FleetWingInstance, GameWindowId, ResourceNode, SelectableTarget, @@ -32,7 +22,7 @@ import type { ViewLevel, } from "./types"; import { createHud } from "./ui/hud"; -import { getFleetWindowMarkup, getSelectionCardsMarkup, getSelectionDetails, getSelectionTitle } from "./ui/presenters"; +import { getSelectionCardsMarkup, getSelectionDetails, getSelectionTitle, getShipWindowMarkup } from "./ui/presenters"; import { drawMinimap, drawStrategicOverlay } from "./ui/strategicRenderer"; import { buildInitialWorld, createShipInstance, createStationInstance } from "./world/worldFactory"; import { generateUniverse } from "./world/universeGenerator"; @@ -70,8 +60,6 @@ export class GameApp { private readonly stations: StationInstance[] = []; private readonly nodes: ResourceNode[] = []; private readonly systems: SolarSystemInstance[] = []; - private readonly fleets: FleetInstance[] = []; - private readonly fleetsById = new Map(); private readonly factions: FactionInstance[] = []; private readonly factionsById = new Map(); @@ -92,7 +80,6 @@ export class GameApp { private cameraDragPointerId?: number; private cameraDragLast?: THREE.Vector2; private stationIdCounter = 0; - private activeFleetId?: string; private readonly windowState: Record = { "fleet-command": true, "ship-designer": false, @@ -113,11 +100,9 @@ export class GameApp { private readonly fleetWindowEl: HTMLDivElement; private readonly fleetWindowBodyEl: HTMLDivElement; private readonly fleetWindowTitleEl: HTMLHeadingElement; - private readonly fleetWindowSubtitleEl: HTMLParagraphElement; private readonly debugWindowEl: HTMLDivElement; private readonly sessionActionsEl: HTMLDivElement; private universe: UniverseDefinition; - private fleetRefreshNeeded = false; constructor(container: HTMLElement) { this.container = container; @@ -139,7 +124,6 @@ export class GameApp { this.container.append(this.renderer.domElement); const hud = createHud(this.container, { onWindowAction: (action) => this.handleWindowAction(action), - onFleetAction: (action, fleetId) => this.handleFleetAction(action, fleetId), onSelectionAction: (kind, id) => this.handleWindowSelection(kind, id), }); this.detailsEl = hud.details; @@ -155,7 +139,6 @@ export class GameApp { this.fleetWindowEl = hud.fleetWindow; this.fleetWindowBodyEl = hud.fleetWindowBody; this.fleetWindowTitleEl = hud.fleetWindowTitle; - this.fleetWindowSubtitleEl = hud.fleetWindowSubtitle; this.debugWindowEl = hud.debugWindow; this.sessionActionsEl = hud.sessionActions; @@ -197,7 +180,6 @@ export class GameApp { this.stationIdCounter = this.stations.length; this.initializeFactions(); - this.initializeFleets(); this.applyViewLevel(); } @@ -209,14 +191,10 @@ export class GameApp { this.stations.length = 0; this.nodes.length = 0; this.systems.length = 0; - this.fleets.length = 0; - this.fleetsById.clear(); this.factions.length = 0; this.factionsById.clear(); this.followShipId = undefined; - this.activeFleetId = undefined; this.buildMode = false; - this.fleetRefreshNeeded = false; this.selectedSystemIndex = 0; this.stationIdCounter = 0; this.marqueeStart = undefined; @@ -460,7 +438,6 @@ export class GameApp { } else if (!this.selectionManager.hasShip(target.ship)) { this.selectionManager.addShip(target.ship); } - this.syncActiveFleetFromSelection(); } if (target?.kind === "station") { this.selectionManager.setStation(target.station); @@ -592,17 +569,6 @@ export class GameApp { }); } - private initializeFleets() { - const previousActiveFleetId = this.activeFleetId; - this.fleets.length = 0; - this.fleetsById.clear(); - createDefaultFleets(this.ships).forEach((fleet) => { - this.fleets.push(fleet); - this.fleetsById.set(fleet.id, fleet); - }); - this.activeFleetId = this.fleetsById.has(previousActiveFleetId ?? "") ? previousActiveFleetId : this.fleets[0]?.id; - } - private makePatrolPoints(systemId: string) { const route = this.universe.scenario.patrolRoutes.find((candidate) => candidate.systemId === systemId); if (!route) { @@ -617,180 +583,25 @@ 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 getFactionShips(factionId: string) { + return this.ships.filter((ship) => ship.factionId === factionId); } - private setFleetPatrolOrder(fleet: FleetInstance, systemId: string) { - fleet.order = { - kind: "patrol", - systemId, - points: this.makePatrolPoints(systemId), - index: 0, - }; - this.applyFleetOrder(fleet); + private getFactionMilitaryShips(factionId: string) { + return this.getFactionShips(factionId).filter((ship) => ship.definition.role === "military"); } - private setFleetIdleOrder(fleet: FleetInstance) { - fleet.order = { kind: "idle" }; - this.applyFleetOrder(fleet); + private getFactionIndustryShips(factionId: string) { + return this.getFactionShips(factionId).filter((ship) => ship.definition.role !== "military"); } - 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 makeEscortOffset(index: number, spacing = 26) { + if (index === 0) { + return new THREE.Vector3(0, 0, 22); } + const side = index % 2 === 0 ? 1 : -1; + const rank = Math.ceil(index / 2); + return new THREE.Vector3(side * rank * spacing, 0, 22 + rank * 10); } private updateFactionSimulation(delta: number) { @@ -832,43 +643,45 @@ export class GameApp { const threatenedSystemId = this.findThreatenedSystem(faction.definition.id); const centralTarget = this.pickCentralTargetSystem(faction); const militaryTargetSystemId = threatenedSystemId ?? centralTarget ?? faction.definition.homeSystemId; - const industryFleet = this.getFactionIndustryFleet(faction.definition.id); - if (industryFleet) { - const miningSystemId = miningSystems[0] ?? faction.definition.miningSystemId ?? faction.definition.homeSystemId; - this.setFleetMineOrder( - industryFleet, - miningSystemId, - faction.definition.miningSystemId ?? faction.definition.homeSystemId, - ); - } + const miningSystemId = miningSystems[0] ?? faction.definition.miningSystemId ?? faction.definition.homeSystemId; + const refinery = this.findRefinery(faction.definition.miningSystemId ?? faction.definition.homeSystemId, faction.definition.id); + const miners = this.getFactionIndustryShips(faction.definition.id).filter((ship) => ship.definition.role === "mining"); + const transports = this.getFactionIndustryShips(faction.definition.id).filter((ship) => ship.definition.role === "transport"); + miners.forEach((ship) => this.assignMineOrder(ship, this.findBestMiningNode(miningSystemId), refinery)); + transports.forEach((ship, index) => { + const anchor = miners[index % Math.max(1, miners.length)]; + if (anchor) { + this.setEscortOrder(ship, anchor, this.makeEscortOffset(index)); + } else { + this.setPatrolOrder(ship, this.makePatrolPoints(faction.definition.homeSystemId), index, faction.definition.homeSystemId); + } + }); - this.getFactionWarFleets(faction.definition.id).forEach((fleet) => { - if (fleet.systemId !== militaryTargetSystemId) { - const targetSystem = this.getSystem(militaryTargetSystemId); - const rally = targetSystem.center.clone().add(new THREE.Vector3(-140, 0, 120)); - this.issueFleetMoveOrder(fleet, rally); + const targetSystem = this.getSystem(militaryTargetSystemId); + const rally = targetSystem.center.clone().add(new THREE.Vector3(-140, 0, 120)); + this.getFactionMilitaryShips(faction.definition.id).forEach((ship, index) => { + if (ship.systemId !== militaryTargetSystemId) { + this.issueMoveOrder(ship, rally.clone().add(this.makeEscortOffset(index, 18))); return; } - this.setFleetPatrolOrder(fleet, militaryTargetSystemId); + this.setPatrolOrder(ship, this.makePatrolPoints(militaryTargetSystemId), index, militaryTargetSystemId); }); } private commandPirateFaction(faction: FactionInstance) { const targetSystemId = faction.definition.targetSystemIds[0] ?? faction.definition.homeSystemId; const targetSystem = this.getSystem(targetSystemId); - this.getFactionWarFleets(faction.definition.id).forEach((fleet) => { - const raidPoint = targetSystem.center.clone().add(new THREE.Vector3(120, 0, 160)); - if (fleet.systemId !== targetSystemId) { - this.issueFleetMoveOrder(fleet, raidPoint); + const raidPoint = targetSystem.center.clone().add(new THREE.Vector3(120, 0, 160)); + this.getFactionMilitaryShips(faction.definition.id).forEach((ship, index) => { + if (ship.systemId !== targetSystemId) { + this.issueMoveOrder(ship, raidPoint.clone().add(this.makeEscortOffset(index, 20))); return; } - this.setFleetPatrolOrder(fleet, targetSystemId); + this.setPatrolOrder(ship, this.makePatrolPoints(targetSystemId), index, targetSystemId); + }); + this.getFactionIndustryShips(faction.definition.id).forEach((ship, index) => { + this.setPatrolOrder(ship, this.makePatrolPoints(faction.definition.homeSystemId), index, faction.definition.homeSystemId); }); - - const industryFleet = this.getFactionIndustryFleet(faction.definition.id); - if (industryFleet) { - this.setFleetPatrolOrder(industryFleet, faction.definition.homeSystemId); - } } private updateSystemControl(delta: number) { @@ -981,17 +794,6 @@ export class GameApp { }); } - private refreshFleets() { - if (!this.fleetRefreshNeeded) { - return; - } - this.fleetRefreshNeeded = false; - this.initializeFleets(); - this.factions.forEach((faction) => { - faction.commandTick = 0; - }); - } - private tick() { const delta = Math.min(this.clock.getDelta(), 0.033); const elapsed = this.clock.elapsedTime; @@ -1000,14 +802,7 @@ export class GameApp { this.updateFactionSimulation(delta); this.updateShips(delta, elapsed); this.updateCombat(delta); - this.fleets.forEach((fleet) => { - const commander = getFleetCommander(fleet, this.shipsById); - if (commander) { - fleet.systemId = commander.systemId; - } - }); this.updateSystems(delta); - this.refreshFleets(); this.applyViewLevel(); if (this.selection.length > 0 || this.selectedStation || this.selectedSystem || this.selectedPlanet || this.followShipId) { this.updateHud(); @@ -1026,28 +821,22 @@ export class GameApp { this.selectedSystemIndex = this.systems.findIndex((system) => system.definition.id === followedShip.systemId); } - const forward = new THREE.Vector3(); - this.camera.getWorldDirection(forward); - forward.y = 0; - forward.normalize(); - const right = new THREE.Vector3().crossVectors(forward, new THREE.Vector3(0, 1, 0)).normalize(); - const panSpeed = Math.max(80, this.camera.position.distanceTo(focus) * 0.7) * delta; let manualCameraInput = false; if (this.keyState.has("w")) { - focus.add(forward.clone().multiplyScalar(panSpeed)); + this.panCamera(0, panSpeed / Math.max(0.2, this.camera.position.distanceTo(focus) * 0.0014)); manualCameraInput = true; } if (this.keyState.has("s")) { - focus.add(forward.clone().multiplyScalar(-panSpeed)); + this.panCamera(0, -panSpeed / Math.max(0.2, this.camera.position.distanceTo(focus) * 0.0014)); manualCameraInput = true; } if (this.keyState.has("a")) { - focus.add(right.clone().multiplyScalar(panSpeed)); + this.panCamera(panSpeed / Math.max(0.2, this.camera.position.distanceTo(focus) * 0.0014), 0); manualCameraInput = true; } if (this.keyState.has("d")) { - focus.add(right.clone().multiplyScalar(-panSpeed)); + this.panCamera(-panSpeed / Math.max(0.2, this.camera.position.distanceTo(focus) * 0.0014), 0); manualCameraInput = true; } if (this.keyState.has("q") || this.keyState.has("e")) { @@ -1896,7 +1685,7 @@ export class GameApp { ship.state = "patrolling"; } - private setEscortOrder(ship: ShipInstance, target: ShipInstance, offset = ship.formationOffset.clone()) { + private setEscortOrder(ship: ShipInstance, target: ShipInstance, offset = new THREE.Vector3()) { 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); @@ -1912,18 +1701,6 @@ export class GameApp { return this.stations.filter((station) => station.factionId === factionId); } - private getFactionFleets(factionId: string) { - return this.fleets.filter((fleet) => fleet.factionId === factionId); - } - - private getFactionIndustryFleet(factionId: string) { - return this.getFactionFleets(factionId).find((fleet) => fleet.wings.some((wing) => wing.behavior === "mining")); - } - - private getFactionWarFleets(factionId: string) { - return this.getFactionFleets(factionId).filter((fleet) => !fleet.wings.some((wing) => wing.behavior === "mining")); - } - private getFactionItemAmount(factionId: string, itemId: string) { return this.findFactionStations(factionId).reduce((total, station) => total + (station.itemStocks[itemId] ?? 0), 0); } @@ -2031,7 +1808,6 @@ export class GameApp { [...this.selectableTargets.entries()] .filter(([, target]) => target.kind === "ship" && target.ship.id === ship.id) .forEach(([object]) => this.selectableTargets.delete(object)); - this.fleetRefreshNeeded = true; } private tryBuildShipForFaction(faction: FactionInstance) { @@ -2075,7 +1851,6 @@ export class GameApp { this.shipsById.set(ship.id, ship); faction.shipsBuilt += 1; faction.credits -= 60; - this.fleetRefreshNeeded = true; } private tryBuildOutpostForFaction(faction: FactionInstance) { @@ -2149,7 +1924,7 @@ export class GameApp { } private focusSelection() { - if (this.selection.length === 0 && !this.selectedStation && !this.selectedSystem && !this.selectedPlanet && !this.activeFleetId) { + if (this.selection.length === 0 && !this.selectedStation && !this.selectedSystem && !this.selectedPlanet) { return; } if (this.selectedPlanet) { @@ -2171,10 +1946,6 @@ 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; @@ -2220,10 +1991,6 @@ export class GameApp { this.generateNewUniverse(); return; } - if (action === "toggle-fleet-command") { - this.toggleWindow("fleet-command"); - return; - } if (action === "toggle-debug") { this.toggleWindow("debug"); return; @@ -2237,54 +2004,28 @@ export class GameApp { } } - private handleFleetAction(action: string, fleetId = this.activeFleetId) { - if (!fleetId) { - return; - } - - if (action === "select") { - this.activeFleetId = fleetId; - this.selectFleetShips(fleetId); - this.updateHud(); - return; - } - - const fleet = this.fleetsById.get(fleetId); - if (!fleet) { - return; - } - - this.activeFleetId = fleet.id; - if (action === "focus") { - this.focusFleet(fleet.id); - return; - } - if (action === "patrol") { - this.setFleetPatrolOrder(fleet, fleet.systemId); - } - if (action === "mine") { - this.setFleetMineOrder( - fleet, - this.universe.scenario.miningDefaults.nodeSystemId, - this.universe.scenario.miningDefaults.refinerySystemId, - ); - } - if (action === "hold") { - this.setFleetIdleOrder(fleet); - } - this.updateHud(); - } - private handleWindowSelection(kind: string, id: string) { - if (kind === "fleet") { - this.selectFleetShips(id); - this.activeFleetId = id; - this.updateHud(); + if (kind === "faction" || kind === "focus-faction") { + const faction = this.factionsById.get(id); + if (!faction) { + return; + } + const system = this.getSystem(faction.definition.homeSystemId); + this.selectionManager.setSystem(system); + this.selectedSystemIndex = this.systems.findIndex((candidate) => candidate.definition.id === system.definition.id); + this.focusSystem(system.definition.id); return; } - if (kind === "wing") { - this.selectWingShips(id); + if (kind === "focus-ship") { + const ship = this.shipsById.get(id); + if (!ship) { + return; + } + this.selectionManager.replaceShips([ship]); + this.followShipId = ship.id; + this.selectedSystemIndex = this.systems.findIndex((system) => system.definition.id === ship.systemId); + this.focusPoint(ship.group.position, 520); this.updateHud(); return; } @@ -2295,7 +2036,6 @@ export class GameApp { return; } this.selectionManager.replaceShips([ship]); - this.syncActiveFleetFromSelection(); this.updateHud(); } } @@ -2305,70 +2045,6 @@ export class GameApp { 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(); @@ -2389,8 +2065,6 @@ export class GameApp { selection: this.selection, selectedStation: this.selectedStation, cameraFocus: this.getCameraFocus(), - fleets: this.fleets, - activeFleetId: this.activeFleetId, }); drawStrategicOverlay({ context: this.strategicOverlayContext, @@ -2404,8 +2078,6 @@ export class GameApp { selectedStation: this.selectedStation, selectedSystemIndex: this.selectedSystemIndex, viewLevel: this.viewLevel, - fleets: this.fleets, - activeFleetId: this.activeFleetId, }); } @@ -2414,7 +2086,6 @@ export class GameApp { const system = this.systems[this.selectedSystemIndex] ?? this.systems[0]; const selectedCount = this.selection.length + (this.selectedStation ? 1 : 0) + (this.selectedSystem ? 1 : 0) + (this.selectedPlanet ? 1 : 0); - const activeFleet = this.activeFleetId ? this.fleetsById.get(this.activeFleetId) : undefined; this.selectionTitleEl.textContent = getSelectionTitle( this.selection, this.selectedStation, @@ -2438,7 +2109,6 @@ export class GameApp { this.systems, this.viewLevel, this.ships, - this.fleets, this.factions, ); this.detailsEl.style.display = hasExplicitSelection ? "none" : "block"; @@ -2449,16 +2119,12 @@ export class GameApp { this.sessionActionsEl.title = `${this.universe.label} • ${this.universe.systems.length} systems`; this.statusEl.textContent = this.buildMode ? `Observer Mode: ${selectedDefinition.label} preview in ${system.definition.label} • ${this.viewLevel} view • ${this.universe.systems.length} systems` - : `Game Master Mode: ${selectedCount} inspected • Camera ${system.definition.label} • ${this.viewLevel} view • ${this.universe.systems.length} systems${this.followShipId ? " • following ship" : ""}${activeFleet ? ` • Fleet ${activeFleet.label}` : ""}`; - this.ordersEl.dataset.mode = - this.selectedStation ? "station" : this.selection.length > 0 ? "ships" : this.activeFleetId ? "fleet" : "none"; + : `Game Master Mode: ${selectedCount} inspected • Camera ${system.definition.label} • ${this.viewLevel} view • ${this.universe.systems.length} systems${this.followShipId ? " • following ship" : ""}`; + this.ordersEl.dataset.mode = this.selectedStation ? "station" : this.selection.length > 0 ? "ships" : "none"; this.fleetWindowEl.dataset.open = this.windowState["fleet-command"] ? "true" : "false"; + this.fleetWindowTitleEl.textContent = "Ships"; + this.fleetWindowBodyEl.innerHTML = getShipWindowMarkup(this.ships, this.selection); this.debugWindowEl.dataset.open = this.windowState.debug ? "true" : "false"; - this.fleetWindowTitleEl.textContent = "Fleet Command"; - this.fleetWindowSubtitleEl.textContent = activeFleet - ? `${activeFleet.label} • ${describeFleetOrder(activeFleet)}` - : "No fleet selected"; - this.fleetWindowBodyEl.innerHTML = getFleetWindowMarkup(this.fleets, this.shipsById, this.activeFleetId, this.selection); } private placeStation(definition: StationInstance["definition"], position: THREE.Vector3, systemId: string) { @@ -2487,17 +2153,14 @@ export class GameApp { private clearSelection() { this.selectionManager.clear(); - this.syncActiveFleetFromSelection(); } private addShipToSelection(ship: ShipInstance) { this.selectionManager.addShip(ship); - this.syncActiveFleetFromSelection(); } private removeShipFromSelection(ship: ShipInstance) { this.selectionManager.removeShip(ship); - this.syncActiveFleetFromSelection(); } private updateMarqueeBox(clientX: number, clientY: number) { @@ -2555,21 +2218,10 @@ export class GameApp { } }); - 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/fleet/runtime.ts b/src/game/fleet/runtime.ts deleted file mode 100644 index 13d3317..0000000 --- a/src/game/fleet/runtime.ts +++ /dev/null @@ -1,212 +0,0 @@ -import * as THREE from "three"; -import type { FleetBehavior, FleetInstance, FleetWingInstance, ShipInstance } from "../types"; - -interface FleetBuildSpec { - id: string; - label: string; - stance: FleetInstance["stance"]; - systemId: string; - factionId?: string; - commander: ShipInstance; - wings: Array<{ - id: string; - label: string; - behavior: FleetBehavior; - parentWingId?: string; - ships: ShipInstance[]; - }>; -} - -export function createDefaultFleets(ships: ShipInstance[]) { - clearFleetAssignments(ships); - const specs: FleetBuildSpec[] = []; - const factionIds = [...new Set(ships.map((ship) => ship.factionId))]; - - factionIds.forEach((factionId) => { - const factionShips = ships.filter((ship) => ship.factionId === factionId); - const military = factionShips.filter((ship) => ship.definition.role === "military"); - const industrial = factionShips.filter((ship) => ship.definition.role !== "military"); - const systems = [...new Set(factionShips.map((ship) => ship.systemId))]; - - systems.forEach((systemId) => { - const localMilitary = military.filter((ship) => ship.systemId === systemId); - if (localMilitary.length === 0) { - return; - } - const commander = - localMilitary.find((ship) => ship.definition.shipClass === "capital") ?? - localMilitary.find((ship) => ship.definition.shipClass === "cruiser") ?? - localMilitary[0]; - const lineShips = localMilitary.filter((ship) => ship.id !== commander.id); - specs.push({ - id: `${factionId}:${systemId}:warfleet`, - label: `${commander.factionId} War Fleet`, - stance: factionId.includes("pirate") || factionId.includes("flag") || factionId.includes("rats") ? "balanced" : "defensive", - systemId, - factionId, - commander, - wings: [ - { id: "command", label: "Command Wing", behavior: "command", ships: [commander] }, - { id: "screen", label: "Screen Wing", behavior: "screen", parentWingId: "command", ships: lineShips }, - ], - }); - }); - - const miners = industrial.filter((ship) => ship.definition.role === "mining"); - const haulers = industrial.filter((ship) => ship.definition.role === "transport"); - const logisticsCommander = haulers[0] ?? miners[0]; - if (logisticsCommander) { - specs.push({ - id: `${factionId}:industry`, - label: `${logisticsCommander.factionId} Industry Group`, - stance: "industrial", - systemId: logisticsCommander.systemId, - factionId, - commander: logisticsCommander, - wings: [ - { id: "command", label: "Command Wing", behavior: "command", ships: [logisticsCommander] }, - { - id: "miners", - label: "Mining Wing", - behavior: "mining", - parentWingId: "command", - ships: miners.filter((ship) => ship.id !== logisticsCommander.id), - }, - { - id: "transport", - label: "Transport Wing", - behavior: "logistics", - parentWingId: "command", - ships: haulers.filter((ship) => ship.id !== logisticsCommander.id), - }, - ], - }); - } - }); - - return specs.map((spec) => materializeFleet(spec)); -} - -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, - factionId: spec.factionId, - 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/types.ts b/src/game/types.ts index b3c75b2..00dcf52 100644 --- a/src/game/types.ts +++ b/src/game/types.ts @@ -2,8 +2,6 @@ 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" | "debug"; export type FactionKind = "empire" | "pirate"; export type ConstructibleCategory = @@ -168,12 +166,6 @@ 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[]; @@ -271,28 +263,6 @@ 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; - factionId?: string; - shipIds: string[]; - wings: FleetWingInstance[]; - order: FleetOrder; -} - export interface ShipInstance { id: string; definition: ShipDefinition; @@ -326,12 +296,6 @@ 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[]; } @@ -425,6 +389,5 @@ export interface HudElements { fleetWindow: HTMLDivElement; fleetWindowBody: HTMLDivElement; fleetWindowTitle: HTMLHeadingElement; - fleetWindowSubtitle: HTMLParagraphElement; debugWindow: HTMLDivElement; } diff --git a/src/game/ui/hud.ts b/src/game/ui/hud.ts index 7157bf8..6f03258 100644 --- a/src/game/ui/hud.ts +++ b/src/game/ui/hud.ts @@ -1,7 +1,6 @@ import type { HudElements } from "../types"; interface HudHandlers { onWindowAction: (action: string) => void; - onFleetAction: (action: string, fleetId?: string) => void; onSelectionAction: (kind: string, id: string) => void; } @@ -22,16 +21,8 @@ export function createHud(container: HTMLElement, handlers: HudHandlers): HudEle
-

Fleet Command

-

No fleet selected

+

Ships

- -
-
- - - -
@@ -59,24 +50,17 @@ export function createHud(container: HTMLElement, handlers: HudHandlers): HudEle 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 mouseEvent = event as MouseEvent; 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 kind = selectionNode.dataset.selectKind ?? ""; + const id = selectionNode.dataset.selectId ?? ""; + handlers.onSelectionAction(mouseEvent.detail >= 2 ? `focus-${kind}` : kind, id); } }); - const minimap = root.querySelector(".minimap"); const minimapContext = minimap?.getContext("2d"); if (!minimap || !minimapContext) { @@ -104,7 +88,6 @@ export function createHud(container: HTMLElement, handlers: HudHandlers): HudEle 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, debugWindow: root.querySelector(".debug-window") as HTMLDivElement, }; } diff --git a/src/game/ui/presenters.ts b/src/game/ui/presenters.ts index 1125196..00445e5 100644 --- a/src/game/ui/presenters.ts +++ b/src/game/ui/presenters.ts @@ -3,11 +3,9 @@ import { moduleDefinitionsById, recipeDefinitions, } from "../data/catalog"; -import { describeFleetOrder } from "../fleet/runtime"; import { getShipCargoAmount } from "../state/inventory"; import type { FactionInstance, - FleetInstance, PlanetInstance, ShipInstance, SolarSystemInstance, @@ -122,7 +120,6 @@ export function getSelectionDetails( systems: SolarSystemInstance[], viewLevel: ViewLevel, ships: ShipInstance[], - fleets: FleetInstance[], factions: FactionInstance[], ) { if (selectedPlanet) { @@ -132,7 +129,7 @@ export function getSelectionDetails( return `${selectedSystem.definition.label}\nType: ${selectedSystem.strategicValue}\nControl: ${selectedSystem.controllingFactionId ?? "Contested"} ${Math.round(selectedSystem.controlProgress)}%\nPlanets: ${selectedSystem.planets.length}\nResource Nodes: ${selectedSystem.definition.resourceNodes.length}\nGravity Well: ${Math.round(selectedSystem.gravityWellRadius)}`; } if (selectedStation) { - return describeStation(selectedStation, ships, fleets); + return describeStation(selectedStation, ships); } if (selection.length === 0) { const central = systems @@ -146,7 +143,7 @@ export function getSelectionDetails( `${faction.definition.label}: systems ${faction.ownedSystemIds.size} • mined ${Math.round(faction.oreMined)} • built ${faction.shipsBuilt} ships • losses ${faction.shipsLost}`, ) .join("\n"); - return `Observer Mode\nSystems online: ${systems.length}\nFleets tracked: ${fleets.length}\nView: ${viewLevel}\n\nCentral systems:\n${central}\n\nEmpires:\n${factionLines}`; + return `Systems online: ${systems.length}\nShips tracked: ${ships.length}\nView: ${viewLevel}\n\nCentral systems:\n${central}\n\nEmpires:\n${factionLines}`; } return selection @@ -157,17 +154,17 @@ export function getSelectionDetails( ship.definition.dockingCapacity && ship.definition.dockingCapacity > 0 ? `\nHangar: ${ship.dockedShipIds.size}/${ship.definition.dockingCapacity} for ${(ship.definition.dockingClasses ?? []).join(", ")}` : ""; - return `${ship.definition.label} • ${ship.systemId}\nFaction: ${ship.factionId}\nClass: ${ship.definition.shipClass}\nState: ${ship.state}${dockedAt ? ` @ ${dockedAt}` : ""}\nOrder: ${ship.order.kind}\nFleet: ${ship.fleetId ?? "Independent"}${ship.isFleetCommander ? " • Commander" : ship.isWingLeader ? " • Wing Leader" : ""}\nBehavior: ${ship.behavior}\nHealth: ${Math.round(ship.health)}/${ship.maxHealth}\nCargo: ${Math.round(getShipCargoAmount(ship))}/${ship.definition.cargoCapacity || 0} ${getItemLabel(ship.cargoItemId)}\nFuel: ${ship.fuel.toFixed(0)}/${ship.maxFuel}\nEnergy: ${ship.energy.toFixed(0)}/${ship.maxEnergy}\nHold Type: ${ship.definition.cargoKind ?? "none"}${hangarStatus}\nModules: ${ship.definition.modules.map(getModuleLabel).join(", ")}`; + return `${ship.definition.label} • ${ship.systemId}\nFaction: ${ship.factionId}\nClass: ${ship.definition.shipClass}\nState: ${ship.state}${dockedAt ? ` @ ${dockedAt}` : ""}\nOrder: ${ship.order.kind}\nHealth: ${Math.round(ship.health)}/${ship.maxHealth}\nCargo: ${Math.round(getShipCargoAmount(ship))}/${ship.definition.cargoCapacity || 0} ${getItemLabel(ship.cargoItemId)}\nFuel: ${ship.fuel.toFixed(0)}/${ship.maxFuel}\nEnergy: ${ship.energy.toFixed(0)}/${ship.maxEnergy}\nHold Type: ${ship.definition.cargoKind ?? "none"}${hangarStatus}\nModules: ${ship.definition.modules.map(getModuleLabel).join(", ")}`; }, ) .join("\n\n"); } -export function describeStation(station: StationInstance, ships: ShipInstance[], fleets: FleetInstance[]) { +export function describeStation(station: StationInstance, ships: ShipInstance[]) { 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 localShips = ships.filter((ship) => ship.systemId === station.systemId).length; const activeRecipe = station.activeRecipeId ? recipeDefinitions.find((recipe) => recipe.id === station.activeRecipeId) : undefined; @@ -189,101 +186,60 @@ export function describeStation(station: StationInstance, ships: ShipInstance[], : station.definition.category === "farm" ? "Supplying agricultural goods and industrial consumables" : station.definition.category === "defense" - ? `Coordinating ${escorts} escort wings` + ? `Coordinating ${escorts} escort ships` : station.definition.category === "gate" ? "Assembling transit infrastructure and gate components" - : station.modules.includes("fabricator-array") - ? "Fabricating industrial parts and equipment" - : "Managing local trade traffic"; + : station.modules.includes("fabricator-array") + ? "Fabricating industrial parts and equipment" + : "Managing local trade traffic"; - return `${station.definition.label} • ${station.systemId}\nFaction: ${station.factionId}\nRole: ${station.definition.category}\nActivity: ${activity}\nLocal Fleets: ${localFleets}\nDocking: ${station.dockedShipIds.size}/${station.definition.dockingCapacity}\nHealth: ${Math.round(station.health)}/${station.maxHealth}\nFuel: ${station.fuel.toFixed(0)}/${station.maxFuel}\nEnergy: ${station.energy.toFixed(0)}/${station.maxEnergy}\nBulk Solid: ${Math.round(station.inventory["bulk-solid"])}\nContainer: ${Math.round(station.inventory.container)}\nManufactured: ${Math.round(station.inventory.manufactured)}\nModules: ${station.modules.map(getModuleLabel).join(", ")}\n${productionStatus}Radius: ${station.definition.radius}`; + return `${station.definition.label} • ${station.systemId}\nFaction: ${station.factionId}\nRole: ${station.definition.category}\nActivity: ${activity}\nLocal Ships: ${localShips}\nDocking: ${station.dockedShipIds.size}/${station.definition.dockingCapacity}\nHealth: ${Math.round(station.health)}/${station.maxHealth}\nFuel: ${station.fuel.toFixed(0)}/${station.maxFuel}\nEnergy: ${station.energy.toFixed(0)}/${station.maxEnergy}\nBulk Solid: ${Math.round(station.inventory["bulk-solid"])}\nContainer: ${Math.round(station.inventory.container)}\nManufactured: ${Math.round(station.inventory.manufactured)}\nModules: ${station.modules.map(getModuleLabel).join(", ")}\n${productionStatus}Radius: ${station.definition.radius}`; } -export function getFleetWindowMarkup( - fleets: FleetInstance[], - shipsById: Map, - activeFleetId: string | undefined, - selection: ShipInstance[], -) { - if (fleets.length === 0) { - return `
No fleets initialized.
`; +export function getShipWindowMarkup(ships: ShipInstance[], selection: ShipInstance[]) { + if (ships.length === 0) { + return `
No ships online.
`; } const selectedShipIds = new Set(selection.map((ship) => ship.id)); + const sortedShips = [...ships] + .sort((left, right) => + left.factionId.localeCompare(right.factionId) || + left.systemId.localeCompare(right.systemId) || + left.definition.label.localeCompare(right.definition.label) || + left.id.localeCompare(right.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)); + const shipsByFaction = new Map(); + sortedShips.forEach((ship) => { + const bucket = shipsByFaction.get(ship.factionId) ?? []; + bucket.push(ship); + shipsByFaction.set(ship.factionId, bucket); + }); - return ` -
- -
-
- ${commander?.definition.label ?? fleet.commanderShipId} - Commander • ${fleet.shipIds.length} ships • ${fleet.systemId} - Fleet Order: ${describeFleetOrder(fleet)} - Stance: ${fleet.stance} -
-
${tree}
-
-
- `; - }) + return [...shipsByFaction.entries()] + .map( + ([factionId, factionShips]) => ` +
+

${factionId}

+ ${factionShips + .map( + (ship) => ` +
+ ${ship.definition.label} + ${describeShipNode(ship)} +
+ `, + ) + .join("")} +
+ `, + ) .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}`; + return `${ship.state} - ${ship.systemId}`; } function renderCard(title: string, lines: string[]) { @@ -295,23 +251,6 @@ function renderCard(title: string, lines: string[]) { `; } -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) { return itemId ? itemDefinitionsById.get(itemId)?.label ?? itemId : "None"; } diff --git a/src/game/ui/strategicRenderer.ts b/src/game/ui/strategicRenderer.ts index 3216129..43daf74 100644 --- a/src/game/ui/strategicRenderer.ts +++ b/src/game/ui/strategicRenderer.ts @@ -1,6 +1,5 @@ import * as THREE from "three"; -import { getFleetCommander, getWingLeader } from "../fleet/runtime"; -import type { FleetInstance, ShipRole, ShipInstance, SolarSystemInstance, StationInstance, ViewLevel } from "../types"; +import type { ShipRole, ShipInstance, SolarSystemInstance, StationInstance, ViewLevel } from "../types"; interface RenderMinimapOptions { context: CanvasRenderingContext2D; @@ -12,8 +11,6 @@ interface RenderMinimapOptions { selection: ShipInstance[]; selectedStation?: StationInstance; cameraFocus: THREE.Vector3; - fleets: FleetInstance[]; - activeFleetId?: string; } interface RenderOverlayOptions { @@ -28,8 +25,6 @@ interface RenderOverlayOptions { selectedStation?: StationInstance; selectedSystemIndex: number; viewLevel: ViewLevel; - fleets: FleetInstance[]; - activeFleetId?: string; } export function drawMinimap({ @@ -42,8 +37,6 @@ export function drawMinimap({ selection, selectedStation, cameraFocus, - fleets, - activeFleetId, }: RenderMinimapOptions) { context.clearRect(0, 0, width, height); context.fillStyle = "rgba(4, 9, 20, 0.92)"; @@ -89,16 +82,6 @@ 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); @@ -116,8 +99,6 @@ export function drawStrategicOverlay({ selectedStation, selectedSystemIndex, viewLevel, - fleets, - activeFleetId, }: RenderOverlayOptions) { context.clearRect(0, 0, width, height); if (viewLevel === "local") { @@ -132,8 +113,6 @@ export function drawStrategicOverlay({ context.textBaseline = "middle"; if (viewLevel === "solar") { - drawFleetLinks(context, camera, fleets, ships, systems[selectedSystemIndex]?.definition.id, activeFleetId); - selection.forEach((ship) => { const screen = projectWorldToScreen(ship.group.position, camera); if (screen) { @@ -172,7 +151,7 @@ export function drawStrategicOverlay({ if (!bucket || bucket.length === 0) { return; } - drawFleetSymbol(context, screen.x - 52 + index * 52, screen.y + 32, role, bucket.length, bucket.some((ship) => selection.includes(ship))); + drawRoleSymbol(context, screen.x - 52 + index * 52, screen.y + 32, role, bucket.length, bucket.some((ship) => selection.includes(ship))); }); const stationCount = stations.filter((station) => station.systemId === system.definition.id).length; @@ -183,12 +162,6 @@ export function drawStrategicOverlay({ 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); - } }); } @@ -221,7 +194,7 @@ function drawSystemFrame(context: CanvasRenderingContext2D, x: number, y: number context.fillText(label.toUpperCase(), x, y - 28); } -function drawFleetSymbol( +function drawRoleSymbol( context: CanvasRenderingContext2D, x: number, y: number, @@ -284,54 +257,6 @@ 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, diff --git a/src/game/world/universeGenerator.ts b/src/game/world/universeGenerator.ts index 6e3d976..ee1f5ea 100644 --- a/src/game/world/universeGenerator.ts +++ b/src/game/world/universeGenerator.ts @@ -118,48 +118,31 @@ function createScenario(systems: SolarSystemDefinition[], factions: FactionDefin } initialStations.push( - { constructibleId: "trade-hub", systemId: capital.id, factionId: faction.id, planetIndex: 1, lagrangeSide: 1 }, - { - constructibleId: "farm-ring", - systemId: capital.id, - factionId: faction.id, - planetIndex: 0, - lagrangeSide: -1, - seedStock: { gas: 120, water: 160 }, - }, { constructibleId: "manufactory", systemId: capital.id, factionId: faction.id, - planetIndex: Math.min(2, capital.planets.length - 1), + planetIndex: Math.min(1, capital.planets.length - 1), lagrangeSide: 1, - seedStock: { "refined-metals": 200, water: 100, "ship-equipment": 40, "naval-guns": 24 }, + seedStock: { water: 40 }, }, - { - constructibleId: "shipyard", - systemId: capital.id, - factionId: faction.id, - planetIndex: Math.min(3, capital.planets.length - 1), - lagrangeSide: -1, - seedStock: { "ship-parts": 80, "ammo-crates": 70, "hull-sections": 100, "ship-equipment": 40 }, - }, - { constructibleId: "defense-grid", systemId: capital.id, factionId: faction.id, planetIndex: 1, lagrangeSide: -1 }, { constructibleId: "refinery", systemId: mining.id, factionId: faction.id, planetIndex: 0, lagrangeSide: 1, - seedStock: { ore: 240, "refined-metals": 80 }, + seedStock: { ore: 60 }, }, - { constructibleId: "defense-grid", systemId: mining.id, factionId: faction.id, planetIndex: 1, lagrangeSide: -1 }, ); - shipFormations.push( - { shipId: "frigate", count: 1, center: localPoint(capital, 180, 120), systemId: capital.id, factionId: faction.id }, - { shipId: "hauler", count: 1, center: localPoint(capital, 280, -120), systemId: capital.id, factionId: faction.id }, - { shipId: "miner", count: 1, center: localPoint(mining, 180, 100), systemId: mining.id, factionId: faction.id }, - ); + shipFormations.push({ + shipId: "miner", + count: 1, + center: localPoint(mining, 180, 100), + systemId: mining.id, + factionId: faction.id, + }); patrolRoutes.push(createPatrolRoute(capital), createPatrolRoute(mining)); }); @@ -176,15 +159,16 @@ function createScenario(systems: SolarSystemDefinition[], factions: FactionDefin factionId: faction.id, planetIndex: 0, lagrangeSide: 1, - seedStock: { "refined-metals": 100, "ship-parts": 30, "ammo-crates": 30 }, + seedStock: { "refined-metals": 60, "ammo-crates": 12 }, }, - { constructibleId: "defense-grid", systemId: base.id, factionId: faction.id, planetIndex: 1, lagrangeSide: -1 }, - ); - shipFormations.push( - { shipId: "frigate", count: 4, center: localPoint(base, 180, 60), systemId: base.id, factionId: faction.id }, - { shipId: "destroyer", count: 2, center: localPoint(base, 250, 120), systemId: base.id, factionId: faction.id }, - { shipId: "hauler", count: 1, center: localPoint(base, 320, -90), systemId: base.id, factionId: faction.id }, ); + shipFormations.push({ + shipId: "frigate", + count: 1, + center: localPoint(base, 180, 60), + systemId: base.id, + factionId: faction.id, + }); patrolRoutes.push(createPatrolRoute(base)); }); diff --git a/src/game/world/worldFactory.ts b/src/game/world/worldFactory.ts index 19bbba1..69877cb 100644 --- a/src/game/world/worldFactory.ts +++ b/src/game/world/worldFactory.ts @@ -714,10 +714,6 @@ export function createShipInstance({ idleOrbitRadius: Math.max(120, group.position.length()), idleOrbitAngle: 0, warpFx, - behavior: "independent", - isFleetCommander: false, - isWingLeader: false, - formationOffset: new THREE.Vector3(), dockedShipIds: new Set(), dockingPorts, }; diff --git a/src/style.css b/src/style.css index f23cbe5..8a40783 100644 --- a/src/style.css +++ b/src/style.css @@ -161,8 +161,7 @@ canvas { letter-spacing: 0.14em; } -.window-launchers, -.fleet-actions { +.window-launchers { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; @@ -174,7 +173,6 @@ canvas { } .window-launchers button, -.fleet-actions button, .session-actions button, .window-close { border: 1px solid rgba(126, 212, 255, 0.16); @@ -191,7 +189,6 @@ canvas { } .window-launchers button:hover, -.fleet-actions button:hover, .session-actions button:hover, .window-close:hover { border-color: rgba(126, 212, 255, 0.4); @@ -272,10 +269,6 @@ button:disabled { cursor: pointer; } -.fleet-actions { - grid-template-columns: repeat(4, minmax(0, 1fr)); -} - .window-body { overflow: auto; display: flex; @@ -285,6 +278,59 @@ button:disabled { min-height: 0; } +.ship-window-row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + padding: 8px 10px; + border-bottom: 1px solid rgba(126, 212, 255, 0.1); + color: var(--muted); + cursor: pointer; +} + +.ship-window-row:hover { + color: var(--text); +} + +.ship-window-row[data-selected="true"] { + color: #ffbf69; +} + +.ship-window-name { + color: inherit; + font-weight: 600; + line-height: 1.35; +} + +.ship-window-meta { + color: inherit; + line-height: 1.35; + text-align: right; + font-size: 0.92em; +} + +.ship-window-group + .ship-window-group { + margin-top: 10px; +} + +.ship-window-group-title { + margin: 0 0 6px; + color: var(--text); + font-size: 0.8rem; + letter-spacing: 0.12em; + text-transform: uppercase; + cursor: pointer; +} + +.ship-window-group-title:hover { + color: #ffbf69; +} + +.ship-window-empty { + color: var(--muted); +} + .window-resize-handle { position: absolute; right: 10px; @@ -317,141 +363,6 @@ button:disabled { 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) { .details { width: auto; @@ -463,8 +374,7 @@ button:disabled { bottom: 16px; } - .window-launchers, - .fleet-actions { + .window-launchers { grid-template-columns: repeat(2, minmax(0, 1fr)); }