diff --git a/SESSION.md b/SESSION.md index cc7be93..571cc86 100644 --- a/SESSION.md +++ b/SESSION.md @@ -4,6 +4,8 @@ This repository now contains a playable Three.js/Vite prototype for a space RTS / economy sim testbed inspired by EVE Online and X4. +The codebase has been refactored away from a single monolithic `GameApp.ts` toward a more maintainable, data-driven structure. Authored game content now lives in JSON catalogs, while runtime code is split into domain types, world-building helpers, UI presenters, and rendering helpers. + The current prototype includes: - Two solar systems: `Helios Reach` and `Perseus Gate` @@ -74,12 +76,14 @@ The current prototype includes: - `container` - `manufactured` - Added module categories and starter module definitions for ships/stations +- Added explicit recipe data for refinery 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 ### Energy / Fuel @@ -144,6 +148,22 @@ The current prototype includes: - Vite - TypeScript - Three.js +- Authored data now lives in JSON files under `src/game/data/`, including: + - `items.json` + - `recipes.json` + - `systems.json` + - `modules.json` + - `ships.json` + - `constructibles.json` + - `scenario.json` + - `balance.json` +- Shared domain and runtime types now live in `src/game/types.ts` +- 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` +- Inventory helpers now live in `src/game/state/inventory.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` @@ -160,6 +180,9 @@ The current prototype includes: ## Suggested Next Steps +- Continue shrinking `GameApp.ts` by extracting simulation/order logic into dedicated gameplay systems once the current rules stabilize +- Add JSON schema validation or runtime validation for the authored data catalogs to catch content errors earlier +- Move constructible placement and future unit spawning onto a shared scenario/entity factory pipeline - Introduce explicit orbital anchors for: - stars - planets diff --git a/src/game/GameApp.ts b/src/game/GameApp.ts index 7d8abb7..df8368f 100644 --- a/src/game/GameApp.ts +++ b/src/game/GameApp.ts @@ -1,138 +1,39 @@ import * as THREE from "three"; import { constructibleDefinitions, - itemDefinitions, - moduleDefinitions, - shipDefinitions, + gameBalance, + recipeDefinitions, + scenarioDefinition, solarSystemDefinitions, - type ConstructibleDefinition, - type ShipDefinition, - type SolarSystemDefinition, - type UnitState, -} from "./definitions"; +} from "./data/catalog"; +import { addShipCargo, getShipCargoAmount, removeShipCargo } from "./state/inventory"; +import type { + ResourceNode, + SelectableTarget, + ShipInstance, + SolarSystemInstance, + StationInstance, + TravelPlan, + UnitState, + ViewLevel, +} from "./types"; +import { createHud } from "./ui/hud"; +import { getSelectionDetails, getSelectionTitle } from "./ui/presenters"; +import { drawMinimap, drawStrategicOverlay } from "./ui/strategicRenderer"; +import { buildInitialWorld, createStationInstance } from "./world/worldFactory"; -type UnitOrder = - | { kind: "idle" } - | { kind: "move"; destination: THREE.Vector3; systemId: string } - | { - kind: "transfer"; - destination: THREE.Vector3; - destinationSystemId: string; - exitPoint: THREE.Vector3; - arrivalPoint: THREE.Vector3; - } - | { 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 }; - -interface InventoryState { - "bulk-solid": number; - "bulk-liquid": number; - "bulk-gas": number; - container: number; - manufactured: number; -} - -interface TravelPlan { - destination: THREE.Vector3; - destinationSystemId: string; - exitPoint: THREE.Vector3; - arrivalPoint: THREE.Vector3; -} - -interface ShipInstance { - id: string; - definition: ShipDefinition; - group: THREE.Group; - target: THREE.Vector3; - velocity: THREE.Vector3; - selected: boolean; - ring: THREE.Mesh; - systemId: string; - state: UnitState; - order: UnitOrder; - inventory: InventoryState; - cargoItemId?: string; - actionTimer: number; - travelPlan?: TravelPlan; - dockedStationId?: string; - dockingPortIndex?: number; - fuel: number; - energy: number; - maxFuel: number; - maxEnergy: number; - idleOrbitRadius: number; - idleOrbitAngle: number; - warpFx: THREE.Group; -} - -interface StationInstance { - id: string; - definition: ConstructibleDefinition; - group: THREE.Group; - systemId: string; - ring: THREE.Mesh; - oreStored: number; - refinedStock: number; - processTimer: number; - activeBatch: number; - inventory: InventoryState; - dockedShipIds: Set; - dockingPorts: THREE.Vector3[]; - modules: string[]; - orbitalParentPlanetIndex?: number; - lagrangeSide?: -1 | 1; - fuel: number; - energy: number; - maxFuel: number; - maxEnergy: number; -} - -interface PlanetInstance { - group: THREE.Group; - mesh: THREE.Mesh; - orbitSpeed: number; - ring?: THREE.Object3D; -} - -interface ResourceNode { - id: string; - systemId: string; - position: THREE.Vector3; - mesh: THREE.Object3D; - oreRemaining: number; - itemId: string; -} - -interface SolarSystemInstance { - definition: SolarSystemDefinition; - root: THREE.Group; - center: THREE.Vector3; - planets: PlanetInstance[]; - star: THREE.Object3D; - gravityWellRadius: number; - orbitLines: THREE.LineLoop[]; - asteroidDecorations: THREE.Object3D[]; - strategicMarker: THREE.Object3D; -} - -const Y_PLANE = 4; -const ARRIVAL_THRESHOLD = 16; -const MINING_RATE = 28; -const REFINING_BATCH_SIZE = 60; -const REFINING_DURATION = 8; -const DOCKING_DURATION = 1.2; -const UNDOCK_DISTANCE = 42; -const IDLE_ENERGY_DRAIN = 0.7; -const MOVE_ENERGY_DRAIN = 1.8; -const WARP_ENERGY_DRAIN = 7; -const WARP_FUEL_DRAIN = 4.5; -const SHIP_RECHARGE_RATE = 10; -const STATION_SOLAR_CHARGE = 5; -type ViewLevel = "local" | "solar" | "universe"; -type SelectableTarget = - | { kind: "ship"; ship: ShipInstance } - | { kind: "station"; station: StationInstance }; +const MOVING_STATES = new Set([ + "moving", + "mining-approach", + "delivering", + "docking-approach", + "docking", + "undocking", + "leaving-gravity-well", + "arriving", + "patrolling", + "escorting", +]); export class GameApp { private readonly container: HTMLElement; @@ -142,16 +43,19 @@ export class GameApp { private readonly clock = new THREE.Clock(); private readonly raycaster = new THREE.Raycaster(); private readonly mouse = new THREE.Vector2(); - private readonly movePlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -Y_PLANE); + private readonly movePlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -gameBalance.yPlane); 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 selectableTargets = new Map(); - private readonly strategicLinks = new THREE.Group(); + + private strategicLinks!: THREE.Group; private starfield?: THREE.Points; private buildMode = false; @@ -165,9 +69,7 @@ export class GameApp { private marqueeModifiers = { shift: false, ctrl: false }; private marqueeActive = false; private suppressClickSelection = false; - private shipId = 0; - private stationId = 0; - private nodeId = 0; + private stationIdCounter = 0; private readonly detailsEl: HTMLDivElement; private readonly statusEl: HTMLDivElement; @@ -196,7 +98,7 @@ export class GameApp { this.camera.lookAt(this.cameraFocus); this.container.append(this.renderer.domElement); - const hud = this.createHud(); + const hud = createHud(this.container, (action) => this.handleOrderAction(action)); this.detailsEl = hud.details; this.statusEl = hud.status; this.selectionTitleEl = hud.selectionTitle; @@ -217,676 +119,21 @@ export class GameApp { this.renderer.setAnimationLoop(() => this.tick()); } - private createHud() { - const root = document.createElement("div"); - root.className = "hud"; - root.innerHTML = ` - -
-

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

-
-
-

Selection

-
-
-
-
-

No Selection

-
-
-
-
-
- - - - - -
-
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.
-
-
- -
-
-
- `; - - this.container.append(root); - root.querySelectorAll(".orders button").forEach((button) => { - button.addEventListener("click", () => this.handleOrderAction(button.dataset.action ?? "")); - }); - - const minimap = root.querySelector(".minimap"); - const minimapContext = minimap?.getContext("2d"); - if (!minimap || !minimapContext) { - throw new Error("Unable to create minimap canvas"); - } - - const strategicOverlay = root.querySelector(".strategic-overlay"); - const strategicOverlayContext = strategicOverlay?.getContext("2d"); - if (!strategicOverlay || !strategicOverlayContext) { - throw new Error("Unable to create strategic overlay canvas"); - } - - return { - details: root.querySelector(".content") as HTMLDivElement, - status: root.querySelector(".mode") as HTMLDivElement, - selectionTitle: root.querySelector(".selection-title") as HTMLHeadingElement, - orders: root.querySelector(".orders") as HTMLDivElement, - minimap, - minimapContext, - marquee: root.querySelector(".marquee") as HTMLDivElement, - strategicOverlay, - strategicOverlayContext, - }; - } - private setupScene() { - this.scene.add(new THREE.HemisphereLight(0x6ba6ff, 0x03050a, 0.38)); - this.scene.add(new THREE.AmbientLight(0x8397b8, 0.28)); - this.scene.add(this.strategicLinks); + const world = buildInitialWorld(this.scene, this.selectableTargets); + this.systems.push(...world.systems); + this.nodes.push(...world.nodes); + this.stations.push(...world.stations); + this.ships.push(...world.ships); + world.shipsById.forEach((ship, id) => this.shipsById.set(id, ship)); + this.strategicLinks = world.strategicLinks; + this.starfield = world.starfield; + this.stationIdCounter = this.stations.length; - this.createNebulae(); - this.createStarfield(); - this.createSolarSystems(); - this.createStrategicLinks(); - this.createInitialStations(); - this.createInitialShips(); this.assignDefaultOrders(); this.applyViewLevel(); } - private createSolarSystems() { - solarSystemDefinitions.forEach((definition) => { - const root = new THREE.Group(); - root.position.set(...definition.position); - this.scene.add(root); - - const star = new THREE.Mesh( - new THREE.SphereGeometry(definition.starSize, 48, 48), - new THREE.MeshBasicMaterial({ color: definition.starColor }), - ); - root.add(star); - - const glow = new THREE.Mesh( - new THREE.SphereGeometry(definition.starSize * 1.6, 32, 32), - new THREE.MeshBasicMaterial({ - color: definition.starGlow, - transparent: true, - opacity: 0.14, - side: THREE.BackSide, - }), - ); - root.add(glow); - - const light = new THREE.PointLight(definition.starColor, 3.2, 2800, 1.2); - light.castShadow = true; - root.add(light); - - const planets: PlanetInstance[] = []; - const orbitLines: THREE.LineLoop[] = []; - definition.planets.forEach((planetDef, index) => { - const orbitRoot = new THREE.Group(); - orbitRoot.rotation.y = (index / definition.planets.length) * Math.PI * 2; - - const planet = new THREE.Mesh( - new THREE.SphereGeometry(planetDef.size, 36, 36), - new THREE.MeshStandardMaterial({ - color: planetDef.color, - metalness: 0.08, - roughness: 0.92, - emissive: new THREE.Color(planetDef.color).multiplyScalar(0.04), - }), - ); - planet.position.x = planetDef.orbitRadius; - planet.rotation.z = planetDef.tilt; - planet.castShadow = true; - planet.receiveShadow = true; - orbitRoot.add(planet); - - let ringObject: THREE.Object3D | undefined; - if (planetDef.hasRing) { - const ring = new THREE.Mesh( - new THREE.RingGeometry(planetDef.size * 1.3, planetDef.size * 2, 72), - new THREE.MeshBasicMaterial({ - color: 0xc1b299, - side: THREE.DoubleSide, - transparent: true, - opacity: 0.4, - }), - ); - ring.rotation.x = Math.PI / 2.35; - ring.position.x = planetDef.orbitRadius; - orbitRoot.add(ring); - ringObject = ring; - } - - const orbitLine = new THREE.LineLoop( - new THREE.BufferGeometry().setFromPoints( - Array.from({ length: 120 }, (_, step) => { - const angle = (step / 120) * Math.PI * 2; - return new THREE.Vector3( - Math.cos(angle) * planetDef.orbitRadius, - 0, - Math.sin(angle) * planetDef.orbitRadius, - ); - }), - ), - new THREE.LineBasicMaterial({ color: 0x17314d, transparent: true, opacity: 0.52 }), - ); - root.add(orbitLine); - orbitLines.push(orbitLine); - - root.add(orbitRoot); - planets.push({ group: orbitRoot, mesh: planet, orbitSpeed: planetDef.orbitSpeed, ring: ringObject }); - }); - - const asteroidDecorations = this.createAsteroidField(definition.id, root, definition.gravityWellRadius + 330); - const strategicMarker = this.createStrategicMarker(definition); - - this.systems.push({ - definition, - root, - center: new THREE.Vector3(...definition.position), - planets, - star, - gravityWellRadius: definition.gravityWellRadius, - orbitLines, - asteroidDecorations, - strategicMarker, - }); - }); - } - - private createAsteroidField(systemId: string, root: THREE.Group, baseRadius: number) { - const rockGeometry = new THREE.IcosahedronGeometry(1, 0); - const rockMaterial = new THREE.MeshStandardMaterial({ - color: 0x707582, - roughness: 1, - metalness: 0.05, - }); - const decorations: THREE.Object3D[] = []; - - for (let i = 0; i < 180; i += 1) { - const rock = new THREE.Mesh(rockGeometry, rockMaterial); - const angle = Math.random() * Math.PI * 2; - const radius = baseRadius + (Math.random() - 0.5) * 90; - rock.position.set(Math.cos(angle) * radius, (Math.random() - 0.5) * 18, Math.sin(angle) * radius); - rock.scale.setScalar(1.5 + Math.random() * 4); - rock.rotation.set(Math.random() * Math.PI, Math.random() * Math.PI, Math.random() * Math.PI); - root.add(rock); - decorations.push(rock); - } - - const spawnResourceNodes = systemId === "perseus"; - for (let i = 0; i < 3; i += 1) { - const angle = (i / 3) * Math.PI * 2 + 0.45; - const position = new THREE.Vector3( - Math.cos(angle) * (baseRadius + 30), - 0, - Math.sin(angle) * (baseRadius + 30), - ); - - const cluster = new THREE.Group(); - cluster.position.copy(position); - for (let j = 0; j < 7; j += 1) { - const shard = new THREE.Mesh( - new THREE.DodecahedronGeometry(6 + Math.random() * 7, 0), - new THREE.MeshStandardMaterial({ - color: 0xd1bd7c, - emissive: new THREE.Color(0xffdd75).multiplyScalar(0.08), - roughness: 0.9, - metalness: 0.15, - }), - ); - shard.position.set((Math.random() - 0.5) * 18, (Math.random() - 0.5) * 12, (Math.random() - 0.5) * 18); - cluster.add(shard); - } - root.add(cluster); - decorations.push(cluster); - - if (spawnResourceNodes) { - this.nodes.push({ - id: `node-${this.nodeId += 1}`, - systemId, - position: cluster.getWorldPosition(new THREE.Vector3()), - mesh: cluster, - oreRemaining: 3000, - itemId: "ore", - }); - } - } - return decorations; - } - - private createStrategicMarker(definition: SolarSystemDefinition) { - const marker = new THREE.Group(); - marker.position.set(...definition.position); - - const outer = new THREE.Mesh( - new THREE.RingGeometry(definition.gravityWellRadius * 0.9, definition.gravityWellRadius * 1.05, 64), - new THREE.MeshBasicMaterial({ - color: definition.starColor, - transparent: true, - opacity: 0.4, - side: THREE.DoubleSide, - }), - ); - outer.rotation.x = -Math.PI / 2; - marker.add(outer); - - const core = new THREE.Mesh( - new THREE.CircleGeometry(definition.gravityWellRadius * 0.22, 32), - new THREE.MeshBasicMaterial({ - color: definition.starColor, - transparent: true, - opacity: 0.7, - side: THREE.DoubleSide, - }), - ); - core.rotation.x = -Math.PI / 2; - marker.add(core); - - marker.visible = false; - this.scene.add(marker); - return marker; - } - - private createStrategicLinks() { - if (this.systems.length < 2) { - return; - } - - const line = new THREE.Line( - new THREE.BufferGeometry().setFromPoints(this.systems.map((system) => system.center)), - new THREE.LineDashedMaterial({ - color: 0x5e8fbe, - dashSize: 120, - gapSize: 80, - transparent: true, - opacity: 0.5, - }), - ); - line.computeLineDistances(); - this.strategicLinks.add(line); - this.strategicLinks.visible = false; - } - - private createNebulae() { - const colors: [string, string, string][] = [ - ["rgba(126,212,255,0.75)", "rgba(197,111,255,0.32)", "rgba(0,0,0,0)"], - ["rgba(255,157,102,0.72)", "rgba(255,102,129,0.28)", "rgba(0,0,0,0)"], - ["rgba(138,255,199,0.7)", "rgba(72,111,255,0.2)", "rgba(0,0,0,0)"], - ]; - - const positions = [ - new THREE.Vector3(-1800, 260, -1100), - new THREE.Vector3(1800, -100, -1600), - new THREE.Vector3(3300, 160, 1800), - new THREE.Vector3(5200, 220, -900), - new THREE.Vector3(6400, 100, 1500), - ]; - - positions.forEach((position, index) => { - const sprite = new THREE.Sprite( - new THREE.SpriteMaterial({ - map: this.makeRadialTexture(colors[index % colors.length]), - transparent: true, - depthWrite: false, - opacity: 0.34, - blending: THREE.AdditiveBlending, - }), - ); - sprite.position.copy(position); - sprite.scale.setScalar(1000 + (index % 3) * 220); - sprite.material.rotation = index * 0.67; - this.scene.add(sprite); - }); - } - - private createStarfield() { - const starCount = 9000; - const positions = new Float32Array(starCount * 3); - const colors = new Float32Array(starCount * 3); - const color = new THREE.Color(); - - for (let i = 0; i < starCount; i += 1) { - const radius = 4200 + Math.random() * 7600; - const theta = Math.random() * Math.PI * 2; - const phi = Math.acos(2 * Math.random() - 1); - const centerBias = Math.random() > 0.5 ? 2200 : 0; - - positions[i * 3] = radius * Math.sin(phi) * Math.cos(theta) + centerBias; - positions[i * 3 + 1] = radius * Math.cos(phi); - positions[i * 3 + 2] = radius * Math.sin(phi) * Math.sin(theta); - - color.setHSL(0.55 + Math.random() * 0.15, 0.56, 0.7 + Math.random() * 0.28); - colors[i * 3] = color.r; - colors[i * 3 + 1] = color.g; - colors[i * 3 + 2] = color.b; - } - - const geometry = new THREE.BufferGeometry(); - geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); - geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3)); - - this.starfield = new THREE.Points( - geometry, - new THREE.PointsMaterial({ - size: 8, - sizeAttenuation: true, - vertexColors: true, - transparent: true, - opacity: 0.9, - depthWrite: false, - }), - ); - this.scene.add(this.starfield); - } - - private createInitialStations() { - const stationPlans: Array<{ - systemId: string; - definition: ConstructibleDefinition; - planetIndex: number; - lagrangeSide: -1 | 1; - }> = [ - { systemId: "helios", definition: constructibleDefinitions[0], planetIndex: 1, lagrangeSide: 1 }, - { systemId: "helios", definition: constructibleDefinitions[1], planetIndex: 2, lagrangeSide: -1 }, - { systemId: "helios", definition: constructibleDefinitions[2], planetIndex: 1, lagrangeSide: -1 }, - { systemId: "helios", definition: constructibleDefinitions[3], planetIndex: 3, lagrangeSide: 1 }, - { systemId: "helios", definition: constructibleDefinitions[4], planetIndex: 2, lagrangeSide: 1 }, - ]; - - stationPlans.forEach((plan) => this.placeStation(plan.definition, new THREE.Vector3(), plan.systemId, plan.planetIndex, plan.lagrangeSide)); - } - - private placeStation( - definition: ConstructibleDefinition, - position: THREE.Vector3, - systemId: string, - planetIndex?: number, - lagrangeSide?: -1 | 1, - ) { - const group = new THREE.Group(); - group.position.copy(position); - - const core = new THREE.Mesh( - new THREE.CylinderGeometry(definition.radius * 0.4, definition.radius * 0.6, definition.radius * 1.2, 8), - new THREE.MeshStandardMaterial({ - color: definition.color, - emissive: new THREE.Color(definition.color).multiplyScalar(0.12), - roughness: 0.55, - metalness: 0.45, - }), - ); - core.rotation.z = Math.PI / 2; - core.castShadow = true; - core.receiveShadow = true; - group.add(core); - - const ring = new THREE.Mesh( - new THREE.TorusGeometry(definition.radius, Math.max(2.4, definition.radius * 0.08), 18, 48), - new THREE.MeshStandardMaterial({ - color: 0xcdd8e5, - emissive: new THREE.Color(definition.color).multiplyScalar(0.05), - roughness: 0.4, - metalness: 0.7, - }), - ); - ring.rotation.x = Math.PI / 2; - group.add(ring); - - const selectionRing = new THREE.Mesh( - new THREE.RingGeometry(definition.radius * 1.3, definition.radius * 1.5, 40), - new THREE.MeshBasicMaterial({ - color: definition.color, - transparent: true, - opacity: 0, - side: THREE.DoubleSide, - }), - ); - selectionRing.rotation.x = -Math.PI / 2; - selectionRing.position.y = -definition.radius * 0.32; - group.add(selectionRing); - - const dockingPorts = Array.from({ length: definition.dockingCapacity }, (_, index) => { - const angle = (index / Math.max(1, definition.dockingCapacity)) * Math.PI * 2; - const port = new THREE.Vector3( - Math.cos(angle) * (definition.radius + 18), - Y_PLANE, - Math.sin(angle) * (definition.radius + 18), - ); - const beacon = new THREE.Mesh( - new THREE.BoxGeometry(5, 2, 9), - new THREE.MeshBasicMaterial({ color: definition.color, transparent: true, opacity: 0.75 }), - ); - beacon.position.copy(port); - beacon.lookAt(new THREE.Vector3(0, Y_PLANE, 0)); - group.add(beacon); - return port; - }); - - for (let i = 0; i < 4; i += 1) { - const arm = new THREE.Mesh( - new THREE.BoxGeometry(definition.radius * 0.2, definition.radius * 0.15, definition.radius * 1.5), - new THREE.MeshStandardMaterial({ color: 0x8294a9, roughness: 0.55, metalness: 0.5 }), - ); - arm.position.set( - Math.cos((i / 4) * Math.PI * 2) * definition.radius * 0.75, - 0, - Math.sin((i / 4) * Math.PI * 2) * definition.radius * 0.75, - ); - group.add(arm); - } - - this.scene.add(group); - const station: StationInstance = { - id: `station-${this.stationId += 1}`, - definition, - group, - systemId, - ring: selectionRing, - oreStored: 0, - refinedStock: 0, - processTimer: 0, - activeBatch: 0, - inventory: this.makeEmptyInventory(), - dockedShipIds: new Set(), - dockingPorts, - modules: definition.modules, - orbitalParentPlanetIndex: planetIndex, - lagrangeSide, - fuel: 800, - energy: 1200, - maxFuel: 800, - maxEnergy: 1200, - }; - this.stations.push(station); - this.selectableTargets.set(core, { kind: "station", station }); - this.selectableTargets.set(ring, { kind: "station", station }); - } - - private createInitialShips() { - const formationPlans: Array<{ definition: ShipDefinition; count: number; center: THREE.Vector3; systemId: string }> = [ - { definition: shipDefinitions[0], count: 6, center: new THREE.Vector3(180, 0, 90), systemId: "helios" }, - { definition: shipDefinitions[1], count: 3, center: new THREE.Vector3(260, 0, 120), systemId: "helios" }, - { definition: shipDefinitions[2], count: 4, center: new THREE.Vector3(310, 0, -150), systemId: "helios" }, - { definition: shipDefinitions[0], count: 4, center: new THREE.Vector3(4350, 0, 560), systemId: "perseus" }, - { definition: shipDefinitions[3], count: 6, center: new THREE.Vector3(4620, 0, 700), systemId: "perseus" }, - ]; - - formationPlans.forEach(({ definition, count, center, systemId }) => { - for (let i = 0; i < count; i += 1) { - const ship = this.makeShip(definition, systemId); - ship.group.position.copy(center).add(new THREE.Vector3((i % 3) * 18, Y_PLANE, Math.floor(i / 3) * 18)); - ship.target.copy(ship.group.position); - const systemCenter = this.getSystemCenterById(systemId); - ship.idleOrbitRadius = ship.group.position.clone().setY(0).distanceTo(systemCenter); - ship.idleOrbitAngle = Math.atan2(ship.group.position.z - systemCenter.z, ship.group.position.x - systemCenter.x); - this.scene.add(ship.group); - this.ships.push(ship); - this.shipsById.set(ship.id, ship); - } - }); - } - - private makeShip(definition: ShipDefinition, systemId: string): ShipInstance { - const group = new THREE.Group(); - const visual = new THREE.Group(); - visual.rotation.y = Math.PI / 2; - group.add(visual); - const warpFx = new THREE.Group(); - warpFx.visible = false; - for (let i = 0; i < 5; i += 1) { - const streak = new THREE.Mesh( - new THREE.CylinderGeometry(0.12, 0.5, definition.size * 8, 8), - new THREE.MeshBasicMaterial({ color: definition.color, transparent: true, opacity: 0.22 }), - ); - streak.rotation.z = Math.PI / 2; - streak.position.set(-definition.size * (2 + i * 1.7), (i - 2) * 0.45, 0); - warpFx.add(streak); - } - visual.add(warpFx); - const bodyMaterial = new THREE.MeshStandardMaterial({ - color: definition.hullColor, - emissive: new THREE.Color(definition.color).multiplyScalar(0.08), - roughness: 0.45, - metalness: 0.7, - }); - - const hull = new THREE.Mesh( - new THREE.CylinderGeometry(definition.size * 0.3, definition.size, definition.size * 3, 6), - bodyMaterial, - ); - hull.rotation.z = -Math.PI / 2; - hull.castShadow = true; - visual.add(hull); - - const nose = new THREE.Mesh( - new THREE.ConeGeometry(definition.size * 0.7, definition.size * 1.8, 6), - new THREE.MeshStandardMaterial({ - color: definition.color, - emissive: new THREE.Color(definition.color).multiplyScalar(0.12), - roughness: 0.35, - metalness: 0.65, - }), - ); - nose.rotation.z = -Math.PI / 2; - nose.position.x = definition.size * 2.1; - visual.add(nose); - - const wingGeometry = new THREE.BoxGeometry(definition.size * 0.25, definition.size * 1.8, definition.size * 0.7); - [-1, 1].forEach((side) => { - const wing = new THREE.Mesh(wingGeometry, bodyMaterial); - wing.position.set(0, side * definition.size * 0.9, 0); - visual.add(wing); - }); - - 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 }), - ); - engineGlow.position.x = -definition.size * 1.8; - visual.add(engineGlow); - - const ring = new THREE.Mesh( - new THREE.RingGeometry(definition.size * 1.5, definition.size * 1.9, 32), - new THREE.MeshBasicMaterial({ - color: definition.color, - transparent: true, - opacity: 0, - side: THREE.DoubleSide, - }), - ); - ring.rotation.x = -Math.PI / 2; - ring.position.y = -definition.size * 0.55; - group.add(ring); - - const pickHull = new THREE.Mesh( - new THREE.SphereGeometry(definition.size * 1.6, 12, 12), - new THREE.MeshBasicMaterial({ visible: false }), - ); - group.add(pickHull); - - const ship: ShipInstance = { - id: `ship-${this.shipId += 1}`, - definition, - group, - target: new THREE.Vector3(), - velocity: new THREE.Vector3(), - selected: false, - ring, - systemId, - state: "idle", - order: { kind: "idle" }, - inventory: this.makeEmptyInventory(), - cargoItemId: definition.cargoItemId, - actionTimer: 0, - fuel: 220, - energy: 260, - maxFuel: 220, - maxEnergy: 260, - idleOrbitRadius: Math.max(120, group.position.length()), - idleOrbitAngle: 0, - warpFx, - }; - - this.selectableTargets.set(pickHull, { kind: "ship", ship }); - this.selectableTargets.set(hull, { kind: "ship", ship }); - return ship; - } - - private assignDefaultOrders() { - const miners = this.ships.filter((ship) => ship.definition.role === "mining"); - const centralRefinery = this.findRefinery("helios"); - miners.forEach((ship) => this.assignMineOrder(ship, this.findBestMiningNode("perseus"), 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 minersInSystem = miners; - const escortTarget = minersInSystem[index % Math.max(minersInSystem.length, 1)]; - if (escortTarget) { - this.setEscortOrder(ship, escortTarget); - } - }); - } - - private groupShipsBySystem(role: ShipDefinition["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 makePatrolPoints(systemId: string) { - const system = this.getSystem(systemId); - return [ - system.center.clone().add(new THREE.Vector3(180, 0, 120)), - system.center.clone().add(new THREE.Vector3(360, 0, -140)), - system.center.clone().add(new THREE.Vector3(620, 0, 210)), - system.center.clone().add(new THREE.Vector3(260, 0, 320)), - ]; - } - private bindEvents() { window.addEventListener("resize", this.onResize); window.addEventListener("keydown", this.onKeyDown); @@ -903,11 +150,12 @@ export class GameApp { private onResize = () => { const width = window.innerWidth; const height = window.innerHeight; + const pixelRatio = Math.min(window.devicePixelRatio, 2); this.camera.aspect = width / height; this.camera.updateProjectionMatrix(); this.renderer.setSize(width, height); - this.strategicOverlayEl.width = Math.floor(width * Math.min(window.devicePixelRatio, 2)); - this.strategicOverlayEl.height = Math.floor(height * Math.min(window.devicePixelRatio, 2)); + this.strategicOverlayEl.width = Math.floor(width * pixelRatio); + this.strategicOverlayEl.height = Math.floor(height * pixelRatio); this.strategicOverlayEl.style.width = `${width}px`; this.strategicOverlayEl.style.height = `${height}px`; }; @@ -951,7 +199,13 @@ export class GameApp { if (key === "m") { this.selection .filter((ship) => ship.definition.role === "mining") - .forEach((ship) => this.assignMineOrder(ship, this.findBestMiningNode("perseus"), this.findRefinery("helios"))); + .forEach((ship) => + this.assignMineOrder( + ship, + this.findBestMiningNode(scenarioDefinition.miningDefaults.nodeSystemId), + this.findRefinery(scenarioDefinition.miningDefaults.refinerySystemId), + ), + ); this.updateHud(); return; } @@ -965,13 +219,14 @@ export class GameApp { } if (key === "e") { - const escorts = this.selection.filter((ship) => ship.definition.role !== "mining"); - escorts.forEach((ship) => { - const target = this.findNearestFriendlyToEscort(ship); - if (target) { - this.setEscortOrder(ship, target); - } - }); + this.selection + .filter((ship) => ship.definition.role !== "mining") + .forEach((ship) => { + const target = this.findNearestFriendlyToEscort(ship); + if (target) { + this.setEscortOrder(ship, target); + } + }); this.updateHud(); return; } @@ -1036,7 +291,6 @@ export class GameApp { const additive = event.shiftKey; const toggle = event.ctrlKey || event.metaKey; - if (!additive && !toggle) { this.clearSelection(); } @@ -1070,7 +324,7 @@ export class GameApp { if (!this.raycaster.ray.intersectPlane(this.movePlane, point)) { return; } - point.y = Y_PLANE; + point.y = gameBalance.yPlane; const system = this.findNearestSystem(point); if (this.buildMode) { @@ -1097,107 +351,52 @@ export class GameApp { this.adjustZoom(1 + event.deltaY * 0.0012); }; - private updateMouse(clientX: number, clientY: number) { - this.mouse.x = (clientX / window.innerWidth) * 2 - 1; - this.mouse.y = -(clientY / window.innerHeight) * 2 + 1; - } + 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), + ); - 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; - } - } - - 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; - } - - 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; - } - - private updateMarqueeBox(clientX: number, clientY: number) { - if (!this.marqueeStart) { - return; - } - const left = Math.min(this.marqueeStart.x, clientX); - const top = Math.min(this.marqueeStart.y, clientY); - const width = Math.abs(clientX - this.marqueeStart.x); - const height = Math.abs(clientY - this.marqueeStart.y); - this.marqueeEl.style.display = this.marqueeActive ? "block" : "none"; - this.marqueeEl.style.left = `${left}px`; - this.marqueeEl.style.top = `${top}px`; - this.marqueeEl.style.width = `${width}px`; - this.marqueeEl.style.height = `${height}px`; - } - - private hideMarqueeBox() { - this.marqueeEl.style.display = "none"; - this.marqueeEl.style.width = "0"; - this.marqueeEl.style.height = "0"; - } - - private applyMarqueeSelection(clientX: number, clientY: number) { - if (!this.marqueeStart) { - return; - } - const left = Math.min(this.marqueeStart.x, clientX); - const right = Math.max(this.marqueeStart.x, clientX); - const top = Math.min(this.marqueeStart.y, clientY); - const bottom = Math.max(this.marqueeStart.y, clientY); - - if (!this.marqueeModifiers.shift && !this.marqueeModifiers.ctrl) { - this.clearSelection(); - } - - this.selectedStation = undefined; - this.ships.forEach((ship) => { - if (ship.state === "docked" || !ship.group.visible) { - return; - } - const screen = ship.group.position.clone().project(this.camera); - if (screen.z < -1 || screen.z > 1) { - return; - } - const sx = ((screen.x + 1) * 0.5) * window.innerWidth; - const sy = ((-screen.y + 1) * 0.5) * window.innerHeight; - const inside = sx >= left && sx <= right && sy >= top && sy <= bottom; - if (!inside) { - return; - } - if (this.marqueeModifiers.ctrl && this.selection.includes(ship)) { - this.removeShipFromSelection(ship); - } else { - this.addShipToSelection(ship); - } + 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)); }); - this.updateHud(); + 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); + } + }); } - private updateHud() { - const selectedDefinition = constructibleDefinitions[this.selectedConstructible]; - const system = this.systems[this.selectedSystemIndex]; - const selectedCount = this.selection.length + (this.selectedStation ? 1 : 0); - this.selectionTitleEl.textContent = this.getSelectionTitle(); - this.detailsEl.textContent = this.getSelectionDetails(); - 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"; + 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 makePatrolPoints(systemId: string) { + const route = scenarioDefinition.patrolRoutes.find((candidate) => candidate.systemId === systemId); + if (!route) { + const system = this.getSystem(systemId); + return [ + system.center.clone().add(new THREE.Vector3(180, 0, 120)), + system.center.clone().add(new THREE.Vector3(360, 0, -140)), + system.center.clone().add(new THREE.Vector3(620, 0, 210)), + system.center.clone().add(new THREE.Vector3(260, 0, 320)), + ]; + } + return route.points.map((point) => new THREE.Vector3(...point).setY(gameBalance.yPlane)); } private tick() { @@ -1211,8 +410,7 @@ export class GameApp { if (this.selection.length > 0 || this.selectedStation || this.followShipId) { this.updateHud(); } - this.drawMinimap(); - this.drawStrategicOverlay(); + this.renderHudCanvases(); this.scene.rotation.y = Math.sin(elapsed * 0.02) * 0.008; this.renderer.render(this.scene, this.camera); @@ -1336,10 +534,8 @@ export class GameApp { this.ships.forEach((ship, index) => { this.consumeShipResources(ship, delta); - if (ship.state === "undocking") { - if (this.moveShipToward(ship, ship.target, ship.definition.speed * 0.8, delta, 8)) { - ship.state = "idle"; - } + if (ship.state === "undocking" && this.moveShipToward(ship, ship.target, ship.definition.speed * 0.8, delta, 8)) { + ship.state = "idle"; } switch (ship.order.kind) { @@ -1351,7 +547,7 @@ export class GameApp { } break; case "move": - if (this.updateTravelState(ship, ship.order.destination, ship.order.systemId, delta, ARRIVAL_THRESHOLD)) { + if (this.updateTravelState(ship, ship.order.destination, ship.order.systemId, delta, gameBalance.arrivalThreshold)) { ship.order = { kind: "idle" }; ship.travelPlan = undefined; } @@ -1372,12 +568,12 @@ export class GameApp { if (ship.state === "docked") { ship.group.rotation.z = 0; - ship.energy = Math.min(ship.maxEnergy, ship.energy + SHIP_RECHARGE_RATE * delta); + ship.energy = Math.min(ship.maxEnergy, ship.energy + gameBalance.energy.shipRechargeRate * delta); } else if (ship.state !== "warping") { - ship.group.position.y = Y_PLANE + Math.sin(elapsed * 1.2 + index) * 0.7; + ship.group.position.y = gameBalance.yPlane + Math.sin(elapsed * 1.2 + index) * 0.7; ship.group.rotation.z = Math.sin(elapsed * 2 + index) * 0.04; } else { - ship.group.position.y = Y_PLANE; + ship.group.position.y = gameBalance.yPlane; ship.group.rotation.z = 0; } @@ -1391,7 +587,7 @@ export class GameApp { if (order.kind !== "transfer") { return; } - if (this.updateTravelState(ship, order.destination, order.destinationSystemId, delta, ARRIVAL_THRESHOLD, order)) { + if (this.updateTravelState(ship, order.destination, order.destinationSystemId, delta, gameBalance.arrivalThreshold, order)) { ship.order = { kind: "idle" }; ship.travelPlan = undefined; } @@ -1411,7 +607,7 @@ export class GameApp { return; } - const cargo = this.getShipCargoAmount(ship); + const cargo = getShipCargoAmount(ship); if (cargo >= ship.definition.cargoCapacity) { order.phase = "to-refinery"; } @@ -1428,15 +624,15 @@ export class GameApp { ship.actionTimer += delta; ship.velocity.multiplyScalar(0.75); if (ship.actionTimer >= 1) { - const mined = Math.min(MINING_RATE, ship.definition.cargoCapacity - cargo, node.oreRemaining); - this.addShipCargo(ship, mined); + const mined = Math.min(gameBalance.miningRate, ship.definition.cargoCapacity - cargo, node.oreRemaining); + addShipCargo(ship, mined); node.oreRemaining = Math.max(0, node.oreRemaining - mined); ship.actionTimer = 0; - if (this.getShipCargoAmount(ship) >= ship.definition.cargoCapacity) { + if (getShipCargoAmount(ship) >= ship.definition.cargoCapacity) { order.phase = "to-refinery"; } if (node.oreRemaining <= 0) { - node.oreRemaining = 3000; + node.oreRemaining = node.maxOre; } } return; @@ -1449,14 +645,12 @@ export class GameApp { return; } - if (order.phase === "transfer") { - if (this.updateDockingState(ship, refinery, delta)) { - const transferred = this.removeShipCargo(ship, this.getShipCargoAmount(ship)); - refinery.inventory["bulk-solid"] += transferred; - refinery.oreStored += transferred; - order.phase = "to-node"; - this.beginUndock(ship, refinery); - } + if (order.phase === "transfer" && this.updateDockingState(ship, refinery, delta)) { + const transferred = removeShipCargo(ship, getShipCargoAmount(ship)); + refinery.inventory["bulk-solid"] += transferred; + refinery.oreStored += transferred; + order.phase = "to-node"; + this.beginUndock(ship, refinery); } } @@ -1523,68 +717,46 @@ export class GameApp { station.group.position.copy(lagrange); } - station.energy = Math.min(station.maxEnergy, station.energy + STATION_SOLAR_CHARGE * delta); - if (station.definition.category !== "refining") { + station.energy = Math.min(station.maxEnergy, station.energy + gameBalance.energy.stationSolarCharge * delta); + if (station.definition.category !== "refining" || !this.refiningRecipe) { return; } - if (station.activeBatch <= 0 && station.oreStored > 0) { - station.activeBatch = Math.min(REFINING_BATCH_SIZE, station.oreStored); - station.oreStored -= station.activeBatch; - station.processTimer = REFINING_DURATION; + 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) { - station.refinedStock += station.activeBatch; + const outputAmount = (station.activeBatch / recipeInput.amount) * recipeOutput.amount; + station.refinedStock += outputAmount; + station.inventory.manufactured += outputAmount; station.activeBatch = 0; + station.activeRecipeId = undefined; } } }); } - private makeEmptyInventory(): InventoryState { - return { - "bulk-solid": 0, - "bulk-liquid": 0, - "bulk-gas": 0, - container: 0, - manufactured: 0, - }; - } - - private getSystemCenterById(systemId: string) { - return this.getSystem(systemId).center.clone(); - } - private consumeShipResources(ship: ShipInstance, delta: number) { - const movingStates = new Set([ - "moving", - "mining-approach", - "delivering", - "docking-approach", - "docking", - "undocking", - "leaving-gravity-well", - "arriving", - "patrolling", - "escorting", - ]); - if (ship.state === "warping" || ship.state === "spooling-ftl") { - ship.energy = Math.max(0, ship.energy - WARP_ENERGY_DRAIN * delta); - ship.fuel = Math.max(0, ship.fuel - WARP_FUEL_DRAIN * delta); + ship.energy = Math.max(0, ship.energy - gameBalance.energy.warpDrain * delta); + ship.fuel = Math.max(0, ship.fuel - gameBalance.fuel.warpDrain * delta); return; } - - if (movingStates.has(ship.state)) { - ship.energy = Math.max(0, ship.energy - MOVE_ENERGY_DRAIN * delta); + if (MOVING_STATES.has(ship.state)) { + ship.energy = Math.max(0, ship.energy - gameBalance.energy.moveDrain * delta); return; } - if (ship.state !== "docked") { - ship.energy = Math.max(0, ship.energy - IDLE_ENERGY_DRAIN * delta); + ship.energy = Math.max(0, ship.energy - gameBalance.energy.idleDrain * delta); } } @@ -1597,43 +769,20 @@ export class GameApp { ship.idleOrbitAngle += delta * (14 / Math.max(ship.idleOrbitRadius, 120)); const nextPosition = new THREE.Vector3( systemCenter.x + Math.cos(ship.idleOrbitAngle) * ship.idleOrbitRadius, - Y_PLANE, + gameBalance.yPlane, systemCenter.z + Math.sin(ship.idleOrbitAngle) * ship.idleOrbitRadius, ); ship.group.position.lerp(nextPosition, 0.12); - const tangent = new THREE.Vector3( - -Math.sin(ship.idleOrbitAngle), - 0, - Math.cos(ship.idleOrbitAngle), - ); + const tangent = new THREE.Vector3(-Math.sin(ship.idleOrbitAngle), 0, Math.cos(ship.idleOrbitAngle)); ship.group.lookAt(ship.group.position.clone().add(tangent)); } - private getShipCargoAmount(ship: ShipInstance) { - const kind = ship.definition.cargoKind; - return kind ? ship.inventory[kind] : 0; - } - - private addShipCargo(ship: ShipInstance, amount: number) { - const kind = ship.definition.cargoKind; - if (!kind) { - return 0; - } - ship.inventory[kind] += amount; - return amount; - } - - private removeShipCargo(ship: ShipInstance, amount: number) { - const kind = ship.definition.cargoKind; - if (!kind) { - return 0; - } - const transferred = Math.min(amount, ship.inventory[kind]); - ship.inventory[kind] -= transferred; - return transferred; - } - - private ensureTravelPlan(ship: ShipInstance, destination: THREE.Vector3, destinationSystemId: string, suppliedPlan?: TravelPlan) { + private ensureTravelPlan( + ship: ShipInstance, + destination: THREE.Vector3, + destinationSystemId: string, + suppliedPlan?: TravelPlan, + ) { if ( ship.travelPlan && ship.travelPlan.destinationSystemId === destinationSystemId && @@ -1646,8 +795,8 @@ export class GameApp { ship.travelPlan = { destination: suppliedPlan.destination.clone(), destinationSystemId: suppliedPlan.destinationSystemId, - exitPoint: suppliedPlan.exitPoint.clone().setY(Y_PLANE), - arrivalPoint: suppliedPlan.arrivalPoint.clone().setY(Y_PLANE), + exitPoint: suppliedPlan.exitPoint.clone().setY(gameBalance.yPlane), + arrivalPoint: suppliedPlan.arrivalPoint.clone().setY(gameBalance.yPlane), }; return ship.travelPlan; } @@ -1664,10 +813,16 @@ export class GameApp { } ship.travelPlan = { - destination: destination.clone().setY(Y_PLANE), + destination: destination.clone().setY(gameBalance.yPlane), destinationSystemId, - exitPoint: currentSystem.center.clone().add(exitDirection.multiplyScalar(currentSystem.gravityWellRadius + 230)).setY(Y_PLANE), - arrivalPoint: destinationSystem.center.clone().add(arrivalDirection.multiplyScalar(destinationSystem.gravityWellRadius + 150)).setY(Y_PLANE), + exitPoint: currentSystem.center + .clone() + .add(exitDirection.multiplyScalar(currentSystem.gravityWellRadius + 230)) + .setY(gameBalance.yPlane), + arrivalPoint: destinationSystem.center + .clone() + .add(arrivalDirection.multiplyScalar(destinationSystem.gravityWellRadius + 150)) + .setY(gameBalance.yPlane), }; return ship.travelPlan; } @@ -1688,12 +843,17 @@ export class GameApp { } const plan = this.ensureTravelPlan(ship, destination, destinationSystemId, suppliedPlan); - if (ship.state === "undocking") { return false; } - if (ship.systemId === destinationSystemId && ship.state !== "leaving-gravity-well" && ship.state !== "spooling-ftl" && ship.state !== "warping" && ship.state !== "arriving") { + if ( + ship.systemId === destinationSystemId && + ship.state !== "leaving-gravity-well" && + ship.state !== "spooling-ftl" && + ship.state !== "warping" && + ship.state !== "arriving" + ) { ship.state = "moving"; return this.moveShipToward(ship, destination, ship.definition.speed, delta, threshold); } @@ -1757,7 +917,7 @@ export class GameApp { if (ship.state === "docking-approach") { if (this.moveShipToward(ship, portPosition, ship.definition.speed * 0.75, delta, 8)) { ship.state = "docking"; - ship.actionTimer = DOCKING_DURATION; + ship.actionTimer = gameBalance.dockingDuration; } return false; } @@ -1786,11 +946,11 @@ export class GameApp { return; } ship.state = "undocking"; - ship.actionTimer = DOCKING_DURATION * 0.75; + 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(); - ship.target.copy(port.clone().add(direction.multiplyScalar(UNDOCK_DISTANCE)).setY(Y_PLANE)); + ship.target.copy(port.clone().add(direction.multiplyScalar(gameBalance.undockDistance)).setY(gameBalance.yPlane)); this.releaseDockingPort(station, ship); } @@ -1821,67 +981,8 @@ export class GameApp { ship.dockingPortIndex = undefined; } - private getSelectionTitle() { - if (this.selectedStation) { - return this.selectedStation.definition.label; - } - if (this.selection.length === 0) { - return "No Selection"; - } - if (this.selection.length === 1) { - return this.selection[0].definition.label; - } - return `${this.selection.length} Ships Selected`; - } - - private getSelectionDetails() { - if (this.selectedStation) { - return this.describeStation(this.selectedStation); - } - if (this.selection.length === 0) { - return `Systems online: ${this.systems.map((system) => system.definition.label).join(", ")}\n\nOrders: Move, Patrol, Escort, Mine\nView: ${this.viewLevel}`; - } - return this.selection - .map( - (ship) => - `${ship.definition.label} • ${ship.systemId}\nState: ${ship.state}${ship.dockedStationId ? ` @ ${ship.dockedStationId}` : ""}\nOrder: ${ship.order.kind}\nCargo: ${Math.round(this.getShipCargoAmount(ship))}/${ship.definition.cargoCapacity || 0} ${this.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((moduleId) => this.getModuleLabel(moduleId)).join(", ")}`, - ) - .join("\n\n"); - } - - private describeStation(station: StationInstance) { - const miners = this.ships.filter((ship) => ship.systemId === station.systemId && ship.order.kind === "mine").length; - const escorts = this.ships.filter((ship) => ship.systemId === station.systemId && ship.order.kind === "escort").length; - const patrols = this.ships.filter((ship) => ship.systemId === station.systemId && ship.order.kind === "patrol").length; - const refineryStatus = - station.definition.category === "refining" - ? `Ore: ${Math.round(station.oreStored)}\nRefined: ${Math.round(station.refinedStock)}\nBatch: ${Math.round(station.activeBatch)}\nTime Remaining: ${station.activeBatch > 0 ? `${station.processTimer.toFixed(1)}s` : "Idle"}\n` - : ""; - const activity = - station.definition.category === "refining" - ? `Refining ore for ${miners} mining ships` - : station.definition.category === "shipyard" - ? `Maintaining ${patrols} patrol craft` - : station.definition.category === "farm" - ? "Supplying agricultural goods" - : station.definition.category === "defense" - ? `Coordinating ${escorts} escort wings` - : "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((moduleId) => this.getModuleLabel(moduleId)).join(", ")}\n${refineryStatus}Radius: ${station.definition.radius}`; - } - - private getItemLabel(itemId?: string) { - return itemDefinitions.find((item) => item.id === itemId)?.label ?? "None"; - } - - private getModuleLabel(moduleId: string) { - return moduleDefinitions.find((module) => module.id === moduleId)?.label ?? moduleId; - } - private moveShipToward(ship: ShipInstance, destination: THREE.Vector3, speed: number, delta: number, threshold: number) { - const target = destination.clone(); - target.y = Y_PLANE; + const target = destination.clone().setY(gameBalance.yPlane); ship.target.copy(target); const toTarget = target.clone().sub(ship.group.position); @@ -1918,7 +1019,7 @@ export class GameApp { private issueMoveOrder(ship: ShipInstance, destination: THREE.Vector3) { const system = this.findNearestSystem(destination); - destination.y = Y_PLANE; + destination.y = gameBalance.yPlane; ship.travelPlan = undefined; if (ship.systemId === system.definition.id) { @@ -1942,8 +1043,14 @@ export class GameApp { kind: "transfer", destination, destinationSystemId: system.definition.id, - exitPoint: currentSystem.center.clone().add(exitDirection.multiplyScalar(currentSystem.gravityWellRadius + 230)).setY(Y_PLANE), - arrivalPoint: system.center.clone().add(arrivalDirection.multiplyScalar(system.gravityWellRadius + 150)).setY(Y_PLANE), + exitPoint: currentSystem.center + .clone() + .add(exitDirection.multiplyScalar(currentSystem.gravityWellRadius + 230)) + .setY(gameBalance.yPlane), + arrivalPoint: system.center + .clone() + .add(arrivalDirection.multiplyScalar(system.gravityWellRadius + 150)) + .setY(gameBalance.yPlane), }; ship.state = "leaving-gravity-well"; ship.travelPlan = undefined; @@ -1962,7 +1069,7 @@ export class GameApp { private setPatrolOrder(ship: ShipInstance, points: THREE.Vector3[], startIndex: number) { ship.order = { kind: "patrol", - points: points.map((point) => point.clone().setY(Y_PLANE)), + points: points.map((point) => point.clone().setY(gameBalance.yPlane)), systemId: ship.systemId, index: startIndex, }; @@ -1980,9 +1087,7 @@ export class GameApp { } private findBestMiningNode(systemId: string) { - return this.nodes - .filter((node) => node.systemId === systemId) - .sort((left, right) => right.oreRemaining - left.oreRemaining)[0]; + return this.nodes.filter((node) => node.systemId === systemId).sort((left, right) => right.oreRemaining - left.oreRemaining)[0]; } private findRefinery(systemId: string) { @@ -2024,7 +1129,6 @@ export class GameApp { if (this.selection.length === 0 && !this.selectedStation) { return; } - if (this.selectedStation) { this.followShipId = undefined; this.selectedSystemIndex = this.systems.findIndex((system) => system.definition.id === this.selectedStation?.systemId); @@ -2032,7 +1136,6 @@ export class GameApp { this.updateHud(); return; } - if (this.selection.length === 1) { const ship = this.selection[0]; this.followShipId = ship.id; @@ -2050,10 +1153,6 @@ export class GameApp { this.updateHud(); } - private getCameraFocus() { - return this.cameraFocus; - } - private handleOrderAction(action: string) { if (action === "focus") { this.focusSelection(); @@ -2065,7 +1164,13 @@ export class GameApp { if (action === "mine") { this.selection .filter((ship) => ship.definition.role === "mining") - .forEach((ship) => this.assignMineOrder(ship, this.findBestMiningNode("perseus"), this.findRefinery("helios"))); + .forEach((ship) => + this.assignMineOrder( + ship, + this.findBestMiningNode(scenarioDefinition.miningDefaults.nodeSystemId), + this.findRefinery(scenarioDefinition.miningDefaults.refinerySystemId), + ), + ); } if (action === "patrol") { this.selection @@ -2092,422 +1197,156 @@ export class GameApp { this.applyViewLevel(); } - private drawMinimap() { - const context = this.minimapContext; - const width = this.minimapEl.width; - const height = this.minimapEl.height; - context.clearRect(0, 0, width, height); - context.fillStyle = "rgba(4, 9, 20, 0.92)"; - context.fillRect(0, 0, width, height); - context.strokeStyle = "rgba(126, 212, 255, 0.18)"; - context.strokeRect(0.5, 0.5, width - 1, height - 1); - - const bounds = { minX: -400, maxX: 5000, minZ: -1000, maxZ: 1800 }; - const mapPoint = (position: THREE.Vector3) => ({ - x: ((position.x - bounds.minX) / (bounds.maxX - bounds.minX)) * width, - y: ((position.z - bounds.minZ) / (bounds.maxZ - bounds.minZ)) * height, + private renderHudCanvases() { + drawMinimap({ + context: this.minimapContext, + width: this.minimapEl.width, + height: this.minimapEl.height, + systems: this.systems, + stations: this.stations, + ships: this.ships, + selection: this.selection, + selectedStation: this.selectedStation, + cameraFocus: this.getCameraFocus(), }); - - this.systems.forEach((system) => { - const point = mapPoint(system.center); - context.fillStyle = "#7ed4ff"; - context.beginPath(); - context.arc(point.x, point.y, 6, 0, Math.PI * 2); - context.fill(); - context.strokeStyle = "rgba(126,212,255,0.25)"; - context.beginPath(); - context.arc(point.x, point.y, 18, 0, Math.PI * 2); - context.stroke(); + drawStrategicOverlay({ + context: this.strategicOverlayContext, + width: this.strategicOverlayEl.width, + height: this.strategicOverlayEl.height, + camera: this.camera, + systems: this.systems, + stations: this.stations, + ships: this.ships, + selection: this.selection, + selectedStation: this.selectedStation, + selectedSystemIndex: this.selectedSystemIndex, + viewLevel: this.viewLevel, }); - - this.stations.forEach((station) => { - const point = mapPoint(station.group.position); - context.fillStyle = station === this.selectedStation ? "#ffbf69" : "#b4c9da"; - context.fillRect(point.x - 2, point.y - 2, 4, 4); - }); - - this.ships.forEach((ship) => { - const point = mapPoint(ship.group.position); - context.fillStyle = this.selection.includes(ship) ? "#ffbf69" : ship.definition.role === "mining" ? "#ffdd75" : ship.definition.role === "transport" ? "#b0ff8d" : "#7ed4ff"; - context.beginPath(); - context.arc(point.x, point.y, this.selection.includes(ship) ? 3 : 2, 0, Math.PI * 2); - context.fill(); - }); - - const focus = mapPoint(this.getCameraFocus()); - context.strokeStyle = "rgba(255,255,255,0.7)"; - context.strokeRect(focus.x - 9, focus.y - 9, 18, 18); } - private drawStrategicOverlay() { - const context = this.strategicOverlayContext; - const width = this.strategicOverlayEl.width; - const height = this.strategicOverlayEl.height; - context.clearRect(0, 0, width, height); + private updateHud() { + const selectedDefinition = constructibleDefinitions[this.selectedConstructible]; + const system = this.systems[this.selectedSystemIndex]; + const selectedCount = this.selection.length + (this.selectedStation ? 1 : 0); + this.selectionTitleEl.textContent = getSelectionTitle(this.selection, this.selectedStation); + this.detailsEl.textContent = getSelectionDetails( + this.selection, + this.selectedStation, + this.systems, + this.viewLevel, + this.ships, + ); + 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"; + } - if (this.viewLevel === "local") { + private placeStation(definition: StationInstance["definition"], position: THREE.Vector3, systemId: string) { + const station = createStationInstance({ + id: `station-${++this.stationIdCounter}`, + scene: this.scene, + definition, + systemId, + position, + selectableTargets: this.selectableTargets, + }); + this.stations.push(station); + this.updateHud(); + } + + private updateMouse(clientX: number, clientY: number) { + this.mouse.x = (clientX / window.innerWidth) * 2 - 1; + this.mouse.y = -(clientY / window.innerHeight) * 2 + 1; + } + + private clearSelection() { + this.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; + } + } + + private addShipToSelection(ship: ShipInstance) { + if (this.selection.includes(ship)) { return; } - - context.save(); - context.scale(width / window.innerWidth, height / window.innerHeight); - context.lineJoin = "round"; - context.lineCap = "round"; - context.textAlign = "center"; - context.textBaseline = "middle"; - - if (this.viewLevel === "solar") { - this.stations - .filter((station) => station.systemId === this.systems[this.selectedSystemIndex]?.definition.id) - .forEach((station) => { - const screen = this.projectWorldToScreen(station.group.position); - if (!screen) { - return; - } - this.drawStationSymbol(context, screen.x, screen.y, station, 14, station === this.selectedStation); - }); - - this.ships - .filter((ship) => ship.systemId === this.systems[this.selectedSystemIndex]?.definition.id && ship.state !== "docked") - .forEach((ship) => { - const screen = this.projectWorldToScreen(ship.group.position); - if (!screen) { - return; - } - this.drawShipSymbol(context, screen.x, screen.y, ship, 10, ship.selected); - }); - } else { - this.systems.forEach((system) => { - const screen = this.projectWorldToScreen(system.center); - if (!screen) { - return; - } - - this.drawSystemFrame(context, screen.x, screen.y, system.definition.label); - - const fleets = new Map(); - this.ships.forEach((ship) => { - if (ship.systemId !== system.definition.id) { - return; - } - const bucket = fleets.get(ship.definition.role) ?? []; - bucket.push(ship); - fleets.set(ship.definition.role, bucket); - }); - - const roleOrder: ShipDefinition["role"][] = ["military", "transport", "mining"]; - roleOrder.forEach((role, index) => { - const bucket = fleets.get(role); - if (!bucket || bucket.length === 0) { - return; - } - const highlighted = bucket.some((ship) => ship.selected); - const offsetX = -52 + index * 52; - const offsetY = 32; - this.drawFleetSymbol(context, screen.x + offsetX, screen.y + offsetY, role, bucket.length, highlighted); - }); - - const stationCount = this.stations.filter((station) => station.systemId === system.definition.id).length; - const stationSelected = this.stations.some( - (station) => station.systemId === system.definition.id && station === this.selectedStation, - ); - if (stationCount > 0) { - this.drawStrategicStationGroup(context, screen.x, screen.y - 38, stationCount, stationSelected); - } - }); - } - - context.restore(); + this.selection.push(ship); + ship.selected = true; + (ship.ring.material as THREE.MeshBasicMaterial).opacity = 0.95; } - private projectWorldToScreen(position: THREE.Vector3) { - const screen = position.clone().project(this.camera); - if (screen.z < -1 || screen.z > 1) { - return undefined; - } - return { - x: ((screen.x + 1) * 0.5) * window.innerWidth, - y: ((-screen.y + 1) * 0.5) * window.innerHeight, - }; + 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; } - private drawSystemFrame(context: CanvasRenderingContext2D, x: number, y: number, label: string) { - context.strokeStyle = "rgba(126, 212, 255, 0.82)"; - context.lineWidth = 1.25; - context.strokeRect(x - 28, y - 16, 56, 32); - context.beginPath(); - context.moveTo(x - 40, y); - context.lineTo(x - 28, y); - context.moveTo(x + 28, y); - context.lineTo(x + 40, y); - context.stroke(); - context.fillStyle = "rgba(235, 247, 255, 0.9)"; - context.font = "600 11px Space Grotesk, sans-serif"; - context.fillText(label.toUpperCase(), x, y - 28); + private updateMarqueeBox(clientX: number, clientY: number) { + if (!this.marqueeStart) { + return; + } + const left = Math.min(this.marqueeStart.x, clientX); + const top = Math.min(this.marqueeStart.y, clientY); + const width = Math.abs(clientX - this.marqueeStart.x); + const height = Math.abs(clientY - this.marqueeStart.y); + this.marqueeEl.style.display = this.marqueeActive ? "block" : "none"; + this.marqueeEl.style.left = `${left}px`; + this.marqueeEl.style.top = `${top}px`; + this.marqueeEl.style.width = `${width}px`; + this.marqueeEl.style.height = `${height}px`; } - private drawFleetSymbol( - context: CanvasRenderingContext2D, - x: number, - y: number, - role: ShipDefinition["role"], - count: number, - highlighted: boolean, - ) { - context.save(); - context.translate(x, y); - context.strokeStyle = highlighted ? "#ffbf69" : "rgba(208, 232, 244, 0.95)"; - context.fillStyle = "rgba(5, 12, 26, 0.88)"; - context.lineWidth = highlighted ? 2.2 : 1.5; - - if (role === "military") { - context.beginPath(); - context.moveTo(0, -12); - context.lineTo(12, 0); - context.lineTo(0, 12); - context.lineTo(-12, 0); - context.closePath(); - context.fill(); - context.stroke(); - context.beginPath(); - context.moveTo(-5, 0); - context.lineTo(5, 0); - context.stroke(); - } else if (role === "transport") { - context.beginPath(); - context.rect(-13, -9, 26, 18); - context.fill(); - context.stroke(); - context.beginPath(); - context.moveTo(-4, -9); - context.lineTo(-4, 9); - context.moveTo(4, -9); - context.lineTo(4, 9); - context.stroke(); - } else { - context.beginPath(); - context.moveTo(-12, -7); - context.lineTo(-5, -12); - context.lineTo(5, -12); - context.lineTo(12, -7); - context.lineTo(12, 7); - context.lineTo(5, 12); - context.lineTo(-5, 12); - context.lineTo(-12, 7); - context.closePath(); - context.fill(); - context.stroke(); - context.beginPath(); - context.moveTo(-8, 0); - context.lineTo(8, 0); - context.stroke(); - } - - context.fillStyle = highlighted ? "#ffbf69" : "rgba(235, 247, 255, 0.9)"; - context.font = "700 12px Space Grotesk, sans-serif"; - context.fillText(String(count), 0, 23); - context.restore(); + private hideMarqueeBox() { + this.marqueeEl.style.display = "none"; + this.marqueeEl.style.width = "0"; + this.marqueeEl.style.height = "0"; } - private drawStrategicStationGroup( - context: CanvasRenderingContext2D, - x: number, - y: number, - count: number, - highlighted: boolean, - ) { - context.save(); - context.translate(x, y); - context.strokeStyle = highlighted ? "#ffbf69" : "rgba(180, 201, 218, 0.9)"; - context.fillStyle = "rgba(5, 12, 26, 0.88)"; - context.lineWidth = highlighted ? 2.2 : 1.5; - context.beginPath(); - context.rect(-12, -12, 24, 24); - context.fill(); - context.stroke(); - context.beginPath(); - context.moveTo(-18, 0); - context.lineTo(-12, 0); - context.moveTo(12, 0); - context.lineTo(18, 0); - context.moveTo(0, -18); - context.lineTo(0, -12); - context.moveTo(0, 12); - context.lineTo(0, 18); - context.stroke(); - context.fillStyle = highlighted ? "#ffbf69" : "rgba(235, 247, 255, 0.9)"; - context.font = "700 12px Space Grotesk, sans-serif"; - context.fillText(String(count), 0, 24); - context.restore(); + private applyMarqueeSelection(clientX: number, clientY: number) { + if (!this.marqueeStart) { + return; + } + const left = Math.min(this.marqueeStart.x, clientX); + const right = Math.max(this.marqueeStart.x, clientX); + const top = Math.min(this.marqueeStart.y, clientY); + const bottom = Math.max(this.marqueeStart.y, clientY); + + if (!this.marqueeModifiers.shift && !this.marqueeModifiers.ctrl) { + this.clearSelection(); + } + + this.selectedStation = undefined; + this.ships.forEach((ship) => { + if (ship.state === "docked" || !ship.group.visible) { + return; + } + const screen = ship.group.position.clone().project(this.camera); + if (screen.z < -1 || screen.z > 1) { + return; + } + const sx = ((screen.x + 1) * 0.5) * window.innerWidth; + const sy = ((-screen.y + 1) * 0.5) * window.innerHeight; + const inside = sx >= left && sx <= right && sy >= top && sy <= bottom; + if (!inside) { + return; + } + if (this.marqueeModifiers.ctrl && this.selection.includes(ship)) { + this.removeShipFromSelection(ship); + } else { + this.addShipToSelection(ship); + } + }); + + this.updateHud(); } - private drawShipSymbol( - context: CanvasRenderingContext2D, - x: number, - y: number, - ship: ShipInstance, - size: number, - highlighted: boolean, - ) { - context.save(); - context.translate(x, y); - context.rotate(-ship.group.rotation.y); - context.strokeStyle = highlighted ? "#ffbf69" : this.getShipSymbolColor(ship); - context.lineWidth = highlighted ? 2.2 : 1.4; - context.fillStyle = "rgba(5, 12, 26, 0.74)"; - - if (ship.definition.role === "military") { - context.beginPath(); - context.moveTo(0, -size); - context.lineTo(size, 0); - context.lineTo(0, size); - context.lineTo(-size, 0); - context.closePath(); - context.fill(); - context.stroke(); - context.beginPath(); - context.moveTo(-size * 0.35, 0); - context.lineTo(size * 0.35, 0); - context.stroke(); - } else if (ship.definition.role === "transport") { - context.beginPath(); - context.rect(-size, -size * 0.68, size * 2, size * 1.36); - context.fill(); - context.stroke(); - context.beginPath(); - context.moveTo(-size * 0.25, -size * 0.68); - context.lineTo(-size * 0.25, size * 0.68); - context.moveTo(size * 0.25, -size * 0.68); - context.lineTo(size * 0.25, size * 0.68); - context.stroke(); - } else { - context.beginPath(); - context.moveTo(-size, -size * 0.5); - context.lineTo(-size * 0.35, -size); - context.lineTo(size * 0.35, -size); - context.lineTo(size, -size * 0.5); - context.lineTo(size, size * 0.5); - context.lineTo(size * 0.35, size); - context.lineTo(-size * 0.35, size); - context.lineTo(-size, size * 0.5); - context.closePath(); - context.fill(); - context.stroke(); - context.beginPath(); - context.moveTo(-size * 0.65, 0); - context.lineTo(size * 0.65, 0); - context.stroke(); - } - - if (highlighted) { - context.strokeStyle = "rgba(255, 191, 105, 0.42)"; - context.lineWidth = 1; - context.beginPath(); - context.arc(0, 0, size + 7, 0, Math.PI * 2); - context.stroke(); - } - context.restore(); - } - - private drawStationSymbol( - context: CanvasRenderingContext2D, - x: number, - y: number, - station: StationInstance, - size: number, - highlighted: boolean, - ) { - context.save(); - context.translate(x, y); - context.strokeStyle = highlighted ? "#ffbf69" : this.getStationSymbolColor(station); - context.fillStyle = "rgba(5, 12, 26, 0.78)"; - context.lineWidth = highlighted ? 2.2 : 1.5; - context.beginPath(); - context.rect(-size, -size, size * 2, size * 2); - context.fill(); - context.stroke(); - - context.beginPath(); - context.moveTo(-size - 7, 0); - context.lineTo(-size, 0); - context.moveTo(size, 0); - context.lineTo(size + 7, 0); - context.moveTo(0, -size - 7); - context.lineTo(0, -size); - context.moveTo(0, size); - context.lineTo(0, size + 7); - context.stroke(); - - if (station.definition.category === "refining") { - context.beginPath(); - context.moveTo(-4, 5); - context.lineTo(0, -5); - context.lineTo(4, 5); - context.stroke(); - } else if (station.definition.category === "defense") { - context.beginPath(); - context.moveTo(-5, -5); - context.lineTo(5, 5); - context.moveTo(5, -5); - context.lineTo(-5, 5); - context.stroke(); - } else if (station.definition.category === "shipyard") { - context.beginPath(); - context.rect(-5, -3, 10, 6); - context.stroke(); - } else if (station.definition.category === "farm") { - context.beginPath(); - context.arc(0, 0, 5, 0, Math.PI * 2); - context.stroke(); - } - - context.restore(); - } - - private getShipSymbolColor(ship: ShipInstance) { - if (ship.definition.role === "military") { - return "rgba(126, 212, 255, 0.95)"; - } - if (ship.definition.role === "transport") { - return "rgba(176, 255, 141, 0.95)"; - } - return "rgba(255, 221, 117, 0.95)"; - } - - private getStationSymbolColor(station: StationInstance) { - if (station.definition.category === "refining") { - return "rgba(255, 184, 108, 0.95)"; - } - if (station.definition.category === "farm") { - return "rgba(146, 239, 138, 0.95)"; - } - if (station.definition.category === "defense") { - return "rgba(255, 122, 149, 0.95)"; - } - if (station.definition.category === "shipyard") { - return "rgba(208, 162, 255, 0.95)"; - } - return "rgba(180, 201, 218, 0.95)"; - } - - private makeRadialTexture(stops: [string, string, string]) { - const canvas = document.createElement("canvas"); - canvas.width = 512; - canvas.height = 512; - const context = canvas.getContext("2d"); - if (!context) { - throw new Error("Unable to create 2D context for nebula texture"); - } - - const gradient = context.createRadialGradient(256, 256, 30, 256, 256, 256); - gradient.addColorStop(0, stops[0]); - gradient.addColorStop(0.45, stops[1]); - gradient.addColorStop(1, stops[2]); - context.fillStyle = gradient; - context.fillRect(0, 0, 512, 512); - - const texture = new THREE.CanvasTexture(canvas); - texture.colorSpace = THREE.SRGBColorSpace; - return texture; + private getCameraFocus() { + return this.cameraFocus; } } diff --git a/src/game/data/balance.json b/src/game/data/balance.json new file mode 100644 index 0000000..ce444d5 --- /dev/null +++ b/src/game/data/balance.json @@ -0,0 +1,17 @@ +{ + "yPlane": 4, + "arrivalThreshold": 16, + "miningRate": 28, + "dockingDuration": 1.2, + "undockDistance": 42, + "energy": { + "idleDrain": 0.7, + "moveDrain": 1.8, + "warpDrain": 7, + "shipRechargeRate": 10, + "stationSolarCharge": 5 + }, + "fuel": { + "warpDrain": 4.5 + } +} diff --git a/src/game/data/catalog.ts b/src/game/data/catalog.ts new file mode 100644 index 0000000..65d552c --- /dev/null +++ b/src/game/data/catalog.ts @@ -0,0 +1,36 @@ +import balanceData from "./balance.json"; +import constructiblesData from "./constructibles.json"; +import itemsData from "./items.json"; +import modulesData from "./modules.json"; +import recipesData from "./recipes.json"; +import scenarioData from "./scenario.json"; +import shipsData from "./ships.json"; +import systemsData from "./systems.json"; +import type { + ConstructibleDefinition, + GameBalance, + ItemDefinition, + ModuleDefinition, + RecipeDefinition, + ScenarioDefinition, + ShipDefinition, + SolarSystemDefinition, +} from "../types"; + +export const itemDefinitions = itemsData as ItemDefinition[]; +export const recipeDefinitions = recipesData as RecipeDefinition[]; +export const moduleDefinitions = modulesData as ModuleDefinition[]; +export const shipDefinitions = shipsData as ShipDefinition[]; +export const constructibleDefinitions = constructiblesData as ConstructibleDefinition[]; +export const solarSystemDefinitions = systemsData as SolarSystemDefinition[]; +export const scenarioDefinition = scenarioData as ScenarioDefinition; +export const gameBalance = balanceData as GameBalance; + +export const itemDefinitionsById = new Map(itemDefinitions.map((definition) => [definition.id, definition])); +export const recipeDefinitionsById = new Map(recipeDefinitions.map((definition) => [definition.id, definition])); +export const moduleDefinitionsById = new Map(moduleDefinitions.map((definition) => [definition.id, definition])); +export const shipDefinitionsById = new Map(shipDefinitions.map((definition) => [definition.id, definition])); +export const constructibleDefinitionsById = new Map( + constructibleDefinitions.map((definition) => [definition.id, definition]), +); +export const solarSystemDefinitionsById = new Map(solarSystemDefinitions.map((definition) => [definition.id, definition])); diff --git a/src/game/data/constructibles.json b/src/game/data/constructibles.json new file mode 100644 index 0000000..eed35df --- /dev/null +++ b/src/game/data/constructibles.json @@ -0,0 +1,52 @@ +[ + { + "id": "trade-hub", + "label": "Trade Hub", + "category": "station", + "color": "#8bd3ff", + "radius": 20, + "dockingCapacity": 4, + "storage": { "container": 1200, "manufactured": 800 }, + "modules": ["habitat-ring", "docking-clamps", "container-bay"] + }, + { + "id": "refinery", + "label": "Refining Station", + "category": "refining", + "color": "#ffb86c", + "radius": 24, + "dockingCapacity": 3, + "storage": { "bulk-solid": 2000, "manufactured": 1000 }, + "modules": ["docking-clamps", "refinery-stack", "bulk-bay", "fabricator-array"] + }, + { + "id": "farm-ring", + "label": "Farm Station", + "category": "farm", + "color": "#92ef8a", + "radius": 22, + "dockingCapacity": 2, + "storage": { "bulk-liquid": 600, "container": 400 }, + "modules": ["habitat-ring", "fabricator-array", "container-bay"] + }, + { + "id": "shipyard", + "label": "Orbital Shipyard", + "category": "shipyard", + "color": "#d0a2ff", + "radius": 28, + "dockingCapacity": 5, + "storage": { "manufactured": 1800, "container": 1200 }, + "modules": ["docking-clamps", "fabricator-array", "habitat-ring"] + }, + { + "id": "defense-grid", + "label": "Defense Platform", + "category": "defense", + "color": "#ff7a95", + "radius": 18, + "dockingCapacity": 1, + "storage": { "manufactured": 300 }, + "modules": ["turret-grid", "command-bridge"] + } +] diff --git a/src/game/data/items.json b/src/game/data/items.json new file mode 100644 index 0000000..4fd8dc6 --- /dev/null +++ b/src/game/data/items.json @@ -0,0 +1,32 @@ +[ + { + "id": "ore", + "label": "Raw Ore", + "storage": "bulk-solid", + "summary": "Unprocessed asteroid ore used as the main industrial feedstock." + }, + { + "id": "refined-metals", + "label": "Refined Metals", + "storage": "manufactured", + "summary": "Processed structural metals used by stations and shipyards." + }, + { + "id": "gas", + "label": "Volatile Gas", + "storage": "bulk-gas", + "summary": "Compressed gas reserves for future chemical and fuel chains." + }, + { + "id": "water", + "label": "Water", + "storage": "bulk-liquid", + "summary": "Life-support and agricultural input." + }, + { + "id": "drone-parts", + "label": "Drone Parts", + "storage": "container", + "summary": "Containerized industrial freight." + } +] diff --git a/src/game/data/modules.json b/src/game/data/modules.json new file mode 100644 index 0000000..1e6a34a --- /dev/null +++ b/src/game/data/modules.json @@ -0,0 +1,68 @@ +[ + { + "id": "command-bridge", + "label": "Command Bridge", + "category": "bridge", + "summary": "Core ship control and crew systems." + }, + { + "id": "ion-drive", + "label": "Ion Drive", + "category": "engine", + "summary": "Sub-light propulsion package." + }, + { + "id": "ftl-core", + "label": "FTL Core", + "category": "ftl", + "summary": "Spool and warp inter-system engine." + }, + { + "id": "strip-miner", + "label": "Strip Miner", + "category": "mining", + "summary": "Excavation laser and ore intake." + }, + { + "id": "bulk-bay", + "label": "Bulk Cargo Bay", + "category": "cargo-bulk", + "summary": "Reinforced storage for raw solids." + }, + { + "id": "container-bay", + "label": "Container Hold", + "category": "cargo-container", + "summary": "Standardized freight racks." + }, + { + "id": "docking-clamps", + "label": "Docking Clamps", + "category": "dock", + "summary": "Docking collar and transfer arms." + }, + { + "id": "refinery-stack", + "label": "Refinery Stack", + "category": "refinery", + "summary": "Ore cracking and metal separation." + }, + { + "id": "turret-grid", + "label": "Turret Grid", + "category": "defense", + "summary": "Close defense batteries." + }, + { + "id": "habitat-ring", + "label": "Habitat Ring", + "category": "habitat", + "summary": "Crew quarters and service modules." + }, + { + "id": "fabricator-array", + "label": "Fabricator Array", + "category": "production", + "summary": "Assembly lines for manufactured goods." + } +] diff --git a/src/game/data/recipes.json b/src/game/data/recipes.json new file mode 100644 index 0000000..34729f2 --- /dev/null +++ b/src/game/data/recipes.json @@ -0,0 +1,14 @@ +[ + { + "id": "ore-refining", + "label": "Ore Refining", + "facilityCategory": "refining", + "duration": 8, + "inputs": [ + { "itemId": "ore", "amount": 60 } + ], + "outputs": [ + { "itemId": "refined-metals", "amount": 60 } + ] + } +] diff --git a/src/game/data/scenario.json b/src/game/data/scenario.json new file mode 100644 index 0000000..8eb9ab2 --- /dev/null +++ b/src/game/data/scenario.json @@ -0,0 +1,40 @@ +{ + "initialStations": [ + { "constructibleId": "trade-hub", "systemId": "helios", "planetIndex": 1, "lagrangeSide": 1 }, + { "constructibleId": "refinery", "systemId": "helios", "planetIndex": 2, "lagrangeSide": -1 }, + { "constructibleId": "farm-ring", "systemId": "helios", "planetIndex": 1, "lagrangeSide": -1 }, + { "constructibleId": "shipyard", "systemId": "helios", "planetIndex": 3, "lagrangeSide": 1 }, + { "constructibleId": "defense-grid", "systemId": "helios", "planetIndex": 2, "lagrangeSide": 1 } + ], + "shipFormations": [ + { "shipId": "frigate", "count": 6, "center": [180, 0, 90], "systemId": "helios" }, + { "shipId": "destroyer", "count": 3, "center": [260, 0, 120], "systemId": "helios" }, + { "shipId": "hauler", "count": 4, "center": [310, 0, -150], "systemId": "helios" }, + { "shipId": "frigate", "count": 4, "center": [4350, 0, 560], "systemId": "perseus" }, + { "shipId": "miner", "count": 6, "center": [4620, 0, 700], "systemId": "perseus" } + ], + "patrolRoutes": [ + { + "systemId": "helios", + "points": [ + [180, 0, 120], + [360, 0, -140], + [620, 0, 210], + [260, 0, 320] + ] + }, + { + "systemId": "perseus", + "points": [ + [4580, 0, 740], + [4750, 0, 480], + [5020, 0, 860], + [4680, 0, 980] + ] + } + ], + "miningDefaults": { + "nodeSystemId": "perseus", + "refinerySystemId": "helios" + } +} diff --git a/src/game/data/ships.json b/src/game/data/ships.json new file mode 100644 index 0000000..df7e6b2 --- /dev/null +++ b/src/game/data/ships.json @@ -0,0 +1,62 @@ +[ + { + "id": "frigate", + "label": "Vanguard Frigate", + "role": "military", + "speed": 50, + "ftlSpeed": 3200, + "spoolTime": 2.2, + "cargoCapacity": 0, + "color": "#7ed4ff", + "hullColor": "#1f4f78", + "size": 4, + "maxHealth": 100, + "modules": ["command-bridge", "ion-drive", "ftl-core", "turret-grid"] + }, + { + "id": "destroyer", + "label": "Bulwark Destroyer", + "role": "military", + "speed": 34, + "ftlSpeed": 2900, + "spoolTime": 2.8, + "cargoCapacity": 0, + "color": "#ff8f70", + "hullColor": "#6a2e26", + "size": 7, + "maxHealth": 240, + "modules": ["command-bridge", "ion-drive", "ftl-core", "turret-grid", "turret-grid"] + }, + { + "id": "hauler", + "label": "Atlas Hauler", + "role": "transport", + "speed": 22, + "ftlSpeed": 2600, + "spoolTime": 3.3, + "cargoCapacity": 180, + "cargoKind": "container", + "cargoItemId": "drone-parts", + "color": "#b0ff8d", + "hullColor": "#365f2a", + "size": 8, + "maxHealth": 180, + "modules": ["command-bridge", "ion-drive", "ftl-core", "container-bay", "docking-clamps"] + }, + { + "id": "miner", + "label": "Prospector Miner", + "role": "mining", + "speed": 26, + "ftlSpeed": 2400, + "spoolTime": 3.1, + "cargoCapacity": 120, + "cargoKind": "bulk-solid", + "cargoItemId": "ore", + "color": "#ffdd75", + "hullColor": "#68552b", + "size": 6, + "maxHealth": 150, + "modules": ["command-bridge", "ion-drive", "ftl-core", "strip-miner", "bulk-bay", "docking-clamps"] + } +] diff --git a/src/game/data/systems.json b/src/game/data/systems.json new file mode 100644 index 0000000..74ce5f4 --- /dev/null +++ b/src/game/data/systems.json @@ -0,0 +1,49 @@ +[ + { + "id": "helios", + "label": "Helios Reach", + "position": [0, 0, 0], + "starColor": "#ffd27a", + "starGlow": "#ffb14a", + "starSize": 56, + "gravityWellRadius": 210, + "asteroidField": { + "decorationCount": 180, + "radiusOffset": 330, + "radiusVariance": 90, + "heightVariance": 18 + }, + "resourceNodes": [], + "planets": [ + { "label": "Icarus", "orbitRadius": 180, "orbitSpeed": 0.18, "size": 20, "color": "#d4a373", "tilt": 0.2 }, + { "label": "Viridia", "orbitRadius": 300, "orbitSpeed": 0.11, "size": 30, "color": "#58a36c", "tilt": -0.4 }, + { "label": "Aster", "orbitRadius": 460, "orbitSpeed": 0.08, "size": 38, "color": "#6ea7d4", "tilt": 0.3, "hasRing": true }, + { "label": "Noctis", "orbitRadius": 670, "orbitSpeed": 0.05, "size": 50, "color": "#6958a8", "tilt": -0.15 } + ] + }, + { + "id": "perseus", + "label": "Perseus Gate", + "position": [4400, 0, 620], + "starColor": "#9dc6ff", + "starGlow": "#66a0ff", + "starSize": 48, + "gravityWellRadius": 230, + "asteroidField": { + "decorationCount": 180, + "radiusOffset": 330, + "radiusVariance": 90, + "heightVariance": 18 + }, + "resourceNodes": [ + { "angle": 0.45, "radiusOffset": 360, "oreAmount": 3000, "itemId": "ore", "shardCount": 7 }, + { "angle": 2.544395102, "radiusOffset": 360, "oreAmount": 3000, "itemId": "ore", "shardCount": 7 }, + { "angle": 4.638790205, "radiusOffset": 360, "oreAmount": 3000, "itemId": "ore", "shardCount": 7 } + ], + "planets": [ + { "label": "Talos", "orbitRadius": 200, "orbitSpeed": 0.15, "size": 24, "color": "#c48f6a", "tilt": 0.18 }, + { "label": "Cygnus", "orbitRadius": 360, "orbitSpeed": 0.1, "size": 34, "color": "#4f84c4", "tilt": -0.22, "hasRing": true }, + { "label": "Rhea", "orbitRadius": 540, "orbitSpeed": 0.07, "size": 44, "color": "#8f8fb0", "tilt": 0.08 } + ] + } +] diff --git a/src/game/definitions.ts b/src/game/definitions.ts deleted file mode 100644 index 310a765..0000000 --- a/src/game/definitions.ts +++ /dev/null @@ -1,273 +0,0 @@ -export type ShipRole = "military" | "transport" | "mining"; -export type ConstructibleCategory = - | "station" - | "refining" - | "farm" - | "shipyard" - | "defense"; - -export type UnitState = - | "idle" - | "moving" - | "leaving-gravity-well" - | "spooling-ftl" - | "warping" - | "arriving" - | "mining-approach" - | "mining" - | "delivering" - | "docking-approach" - | "docking" - | "docked" - | "undocking" - | "patrolling" - | "escorting"; - -export type UnitOrderKind = "idle" | "move" | "transfer" | "mine" | "patrol" | "escort"; - -export type ItemStorageKind = "bulk-solid" | "bulk-liquid" | "bulk-gas" | "container" | "manufactured"; -export type ModuleCategory = - | "bridge" - | "engine" - | "ftl" - | "mining" - | "cargo-bulk" - | "cargo-container" - | "dock" - | "refinery" - | "defense" - | "habitat" - | "production"; - -export interface ModuleDefinition { - id: string; - label: string; - category: ModuleCategory; - summary: string; -} - -export interface ItemDefinition { - id: string; - label: string; - storage: ItemStorageKind; -} - -export interface ShipDefinition { - id: string; - label: string; - role: ShipRole; - speed: number; - ftlSpeed: number; - spoolTime: number; - cargoCapacity: number; - cargoKind?: ItemStorageKind; - cargoItemId?: string; - color: number; - hullColor: number; - size: number; - maxHealth: number; - modules: string[]; -} - -export interface ConstructibleDefinition { - id: string; - label: string; - category: ConstructibleCategory; - color: number; - radius: number; - dockingCapacity: number; - storage: Partial>; - modules: string[]; -} - -export interface PlanetDefinition { - label: string; - orbitRadius: number; - orbitSpeed: number; - size: number; - color: number; - tilt: number; - hasRing?: boolean; -} - -export interface SolarSystemDefinition { - id: string; - label: string; - position: [number, number, number]; - starColor: number; - starGlow: number; - starSize: number; - gravityWellRadius: number; - planets: PlanetDefinition[]; -} - -export const itemDefinitions: ItemDefinition[] = [ - { id: "ore", label: "Raw Ore", storage: "bulk-solid" }, - { id: "refined-metals", label: "Refined Metals", storage: "manufactured" }, - { id: "gas", label: "Volatile Gas", storage: "bulk-gas" }, - { id: "water", label: "Water", storage: "bulk-liquid" }, - { id: "drone-parts", label: "Drone Parts", storage: "container" }, -]; - -export const moduleDefinitions: ModuleDefinition[] = [ - { id: "command-bridge", label: "Command Bridge", category: "bridge", summary: "Core ship control and crew systems." }, - { id: "ion-drive", label: "Ion Drive", category: "engine", summary: "Sub-light propulsion package." }, - { id: "ftl-core", label: "FTL Core", category: "ftl", summary: "Spool and warp inter-system engine." }, - { id: "strip-miner", label: "Strip Miner", category: "mining", summary: "Excavation laser and ore intake." }, - { id: "bulk-bay", label: "Bulk Cargo Bay", category: "cargo-bulk", summary: "Reinforced storage for raw solids." }, - { id: "container-bay", label: "Container Hold", category: "cargo-container", summary: "Standardized freight racks." }, - { id: "docking-clamps", label: "Docking Clamps", category: "dock", summary: "Docking collar and transfer arms." }, - { id: "refinery-stack", label: "Refinery Stack", category: "refinery", summary: "Ore cracking and metal separation." }, - { id: "turret-grid", label: "Turret Grid", category: "defense", summary: "Close defense batteries." }, - { id: "habitat-ring", label: "Habitat Ring", category: "habitat", summary: "Crew quarters and service modules." }, - { id: "fabricator-array", label: "Fabricator Array", category: "production", summary: "Assembly lines for manufactured goods." }, -]; - -export const shipDefinitions: ShipDefinition[] = [ - { - id: "frigate", - label: "Vanguard Frigate", - role: "military", - speed: 50, - ftlSpeed: 3200, - spoolTime: 2.2, - cargoCapacity: 0, - color: 0x7ed4ff, - hullColor: 0x1f4f78, - size: 4, - maxHealth: 100, - modules: ["command-bridge", "ion-drive", "ftl-core", "turret-grid"], - }, - { - id: "destroyer", - label: "Bulwark Destroyer", - role: "military", - speed: 34, - ftlSpeed: 2900, - spoolTime: 2.8, - cargoCapacity: 0, - color: 0xff8f70, - hullColor: 0x6a2e26, - size: 7, - maxHealth: 240, - modules: ["command-bridge", "ion-drive", "ftl-core", "turret-grid", "turret-grid"], - }, - { - id: "hauler", - label: "Atlas Hauler", - role: "transport", - speed: 22, - ftlSpeed: 2600, - spoolTime: 3.3, - cargoCapacity: 180, - cargoKind: "container", - cargoItemId: "drone-parts", - color: 0xb0ff8d, - hullColor: 0x365f2a, - size: 8, - maxHealth: 180, - modules: ["command-bridge", "ion-drive", "ftl-core", "container-bay", "docking-clamps"], - }, - { - id: "miner", - label: "Prospector Miner", - role: "mining", - speed: 26, - ftlSpeed: 2400, - spoolTime: 3.1, - cargoCapacity: 120, - cargoKind: "bulk-solid", - cargoItemId: "ore", - color: 0xffdd75, - hullColor: 0x68552b, - size: 6, - maxHealth: 150, - modules: ["command-bridge", "ion-drive", "ftl-core", "strip-miner", "bulk-bay", "docking-clamps"], - }, -]; - -export const constructibleDefinitions: ConstructibleDefinition[] = [ - { - id: "trade-hub", - label: "Trade Hub", - category: "station", - color: 0x8bd3ff, - radius: 20, - dockingCapacity: 4, - storage: { container: 1200, manufactured: 800 }, - modules: ["habitat-ring", "docking-clamps", "container-bay"], - }, - { - id: "refinery", - label: "Refining Station", - category: "refining", - color: 0xffb86c, - radius: 24, - dockingCapacity: 3, - storage: { "bulk-solid": 2000, manufactured: 1000 }, - modules: ["docking-clamps", "refinery-stack", "bulk-bay", "fabricator-array"], - }, - { - id: "farm-ring", - label: "Farm Station", - category: "farm", - color: 0x92ef8a, - radius: 22, - dockingCapacity: 2, - storage: { "bulk-liquid": 600, container: 400 }, - modules: ["habitat-ring", "production", "container-bay"], - }, - { - id: "shipyard", - label: "Orbital Shipyard", - category: "shipyard", - color: 0xd0a2ff, - radius: 28, - dockingCapacity: 5, - storage: { manufactured: 1800, container: 1200 }, - modules: ["docking-clamps", "fabricator-array", "habitat-ring"], - }, - { - id: "defense-grid", - label: "Defense Platform", - category: "defense", - color: 0xff7a95, - radius: 18, - dockingCapacity: 1, - storage: { manufactured: 300 }, - modules: ["turret-grid", "command-bridge"], - }, -]; - -export const solarSystemDefinitions: SolarSystemDefinition[] = [ - { - id: "helios", - label: "Helios Reach", - position: [0, 0, 0], - starColor: 0xffd27a, - starGlow: 0xffb14a, - starSize: 56, - gravityWellRadius: 210, - planets: [ - { label: "Icarus", orbitRadius: 180, orbitSpeed: 0.18, size: 20, color: 0xd4a373, tilt: 0.2 }, - { label: "Viridia", orbitRadius: 300, orbitSpeed: 0.11, size: 30, color: 0x58a36c, tilt: -0.4 }, - { label: "Aster", orbitRadius: 460, orbitSpeed: 0.08, size: 38, color: 0x6ea7d4, tilt: 0.3, hasRing: true }, - { label: "Noctis", orbitRadius: 670, orbitSpeed: 0.05, size: 50, color: 0x6958a8, tilt: -0.15 }, - ], - }, - { - id: "perseus", - label: "Perseus Gate", - position: [4200, 0, 600], - starColor: 0x9fd4ff, - starGlow: 0x66b6ff, - starSize: 48, - gravityWellRadius: 190, - planets: [ - { label: "Kepler", orbitRadius: 150, orbitSpeed: 0.22, size: 16, color: 0xd9b188, tilt: 0.12 }, - { label: "Tethys", orbitRadius: 280, orbitSpeed: 0.12, size: 28, color: 0x73b0a1, tilt: -0.22 }, - { label: "Orpheon", orbitRadius: 430, orbitSpeed: 0.07, size: 42, color: 0x4a67a8, tilt: 0.25, hasRing: true }, - { label: "Cinder", orbitRadius: 610, orbitSpeed: 0.045, size: 36, color: 0xb15e49, tilt: -0.08 }, - ], - }, -]; diff --git a/src/game/state/inventory.ts b/src/game/state/inventory.ts new file mode 100644 index 0000000..df8b052 --- /dev/null +++ b/src/game/state/inventory.ts @@ -0,0 +1,35 @@ +import type { InventoryState, ShipInstance } from "../types"; + +export function createEmptyInventory(): InventoryState { + return { + "bulk-solid": 0, + "bulk-liquid": 0, + "bulk-gas": 0, + container: 0, + manufactured: 0, + }; +} + +export function getShipCargoAmount(ship: ShipInstance) { + const kind = ship.definition.cargoKind; + return kind ? ship.inventory[kind] : 0; +} + +export function addShipCargo(ship: ShipInstance, amount: number) { + const kind = ship.definition.cargoKind; + if (!kind) { + return 0; + } + ship.inventory[kind] += amount; + return amount; +} + +export function removeShipCargo(ship: ShipInstance, amount: number) { + const kind = ship.definition.cargoKind; + if (!kind) { + return 0; + } + const transferred = Math.min(amount, ship.inventory[kind]); + ship.inventory[kind] -= transferred; + return transferred; +} diff --git a/src/game/types.ts b/src/game/types.ts new file mode 100644 index 0000000..a4dab69 --- /dev/null +++ b/src/game/types.ts @@ -0,0 +1,305 @@ +import * as THREE from "three"; + +export type ShipRole = "military" | "transport" | "mining"; +export type ConstructibleCategory = + | "station" + | "refining" + | "farm" + | "shipyard" + | "defense"; +export type UnitState = + | "idle" + | "moving" + | "leaving-gravity-well" + | "spooling-ftl" + | "warping" + | "arriving" + | "mining-approach" + | "mining" + | "delivering" + | "docking-approach" + | "docking" + | "docked" + | "undocking" + | "patrolling" + | "escorting"; +export type UnitOrderKind = "idle" | "move" | "transfer" | "mine" | "patrol" | "escort"; +export type ItemStorageKind = "bulk-solid" | "bulk-liquid" | "bulk-gas" | "container" | "manufactured"; +export type ModuleCategory = + | "bridge" + | "engine" + | "ftl" + | "mining" + | "cargo-bulk" + | "cargo-container" + | "dock" + | "refinery" + | "defense" + | "habitat" + | "production"; +export type ViewLevel = "local" | "solar" | "universe"; + +export interface ModuleDefinition { + id: string; + label: string; + category: ModuleCategory; + summary: string; +} + +export interface ItemDefinition { + id: string; + label: string; + storage: ItemStorageKind; + summary?: string; +} + +export interface RecipeComponentDefinition { + itemId: string; + amount: number; +} + +export interface RecipeDefinition { + id: string; + label: string; + facilityCategory: ConstructibleCategory; + duration: number; + inputs: RecipeComponentDefinition[]; + outputs: RecipeComponentDefinition[]; +} + +export interface ShipDefinition { + id: string; + label: string; + role: ShipRole; + speed: number; + ftlSpeed: number; + spoolTime: number; + cargoCapacity: number; + cargoKind?: ItemStorageKind; + cargoItemId?: string; + color: string; + hullColor: string; + size: number; + maxHealth: number; + modules: string[]; +} + +export interface ConstructibleDefinition { + id: string; + label: string; + category: ConstructibleCategory; + color: string; + radius: number; + dockingCapacity: number; + storage: Partial>; + modules: string[]; +} + +export interface PlanetDefinition { + label: string; + orbitRadius: number; + orbitSpeed: number; + size: number; + color: string; + tilt: number; + hasRing?: boolean; +} + +export interface ResourceNodeDefinition { + angle: number; + radiusOffset: number; + oreAmount: number; + itemId: string; + shardCount: number; +} + +export interface AsteroidFieldDefinition { + decorationCount: number; + radiusOffset: number; + radiusVariance: number; + heightVariance: number; +} + +export interface SolarSystemDefinition { + id: string; + label: string; + position: [number, number, number]; + starColor: string; + starGlow: string; + starSize: number; + gravityWellRadius: number; + asteroidField: AsteroidFieldDefinition; + resourceNodes: ResourceNodeDefinition[]; + planets: PlanetDefinition[]; +} + +export interface InitialStationDefinition { + constructibleId: string; + systemId: string; + planetIndex?: number; + lagrangeSide?: -1 | 1; + position?: [number, number, number]; +} + +export interface ShipFormationDefinition { + shipId: string; + count: number; + center: [number, number, number]; + systemId: string; +} + +export interface PatrolRouteDefinition { + systemId: string; + points: [number, number, number][]; +} + +export interface ScenarioDefinition { + initialStations: InitialStationDefinition[]; + shipFormations: ShipFormationDefinition[]; + patrolRoutes: PatrolRouteDefinition[]; + miningDefaults: { + nodeSystemId: string; + refinerySystemId: string; + }; +} + +export interface GameBalance { + yPlane: number; + arrivalThreshold: number; + miningRate: number; + dockingDuration: number; + undockDistance: number; + energy: { + idleDrain: number; + moveDrain: number; + warpDrain: number; + shipRechargeRate: number; + stationSolarCharge: number; + }; + fuel: { + warpDrain: number; + }; +} + +export type UnitOrder = + | { kind: "idle" } + | { kind: "move"; destination: THREE.Vector3; systemId: string } + | { + kind: "transfer"; + destination: THREE.Vector3; + destinationSystemId: string; + exitPoint: THREE.Vector3; + arrivalPoint: THREE.Vector3; + } + | { 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 }; + +export interface InventoryState { + "bulk-solid": number; + "bulk-liquid": number; + "bulk-gas": number; + container: number; + manufactured: number; +} + +export interface TravelPlan { + destination: THREE.Vector3; + destinationSystemId: string; + exitPoint: THREE.Vector3; + arrivalPoint: THREE.Vector3; +} + +export interface ShipInstance { + id: string; + definition: ShipDefinition; + group: THREE.Group; + target: THREE.Vector3; + velocity: THREE.Vector3; + selected: boolean; + ring: THREE.Mesh; + systemId: string; + state: UnitState; + order: UnitOrder; + inventory: InventoryState; + cargoItemId?: string; + actionTimer: number; + travelPlan?: TravelPlan; + dockedStationId?: string; + dockingPortIndex?: number; + fuel: number; + energy: number; + maxFuel: number; + maxEnergy: number; + idleOrbitRadius: number; + idleOrbitAngle: number; + warpFx: THREE.Group; +} + +export interface StationInstance { + id: string; + definition: ConstructibleDefinition; + group: THREE.Group; + systemId: string; + ring: THREE.Mesh; + oreStored: number; + refinedStock: number; + processTimer: number; + activeBatch: number; + activeRecipeId?: string; + inventory: InventoryState; + dockedShipIds: Set; + dockingPorts: THREE.Vector3[]; + modules: string[]; + orbitalParentPlanetIndex?: number; + lagrangeSide?: -1 | 1; + fuel: number; + energy: number; + maxFuel: number; + maxEnergy: number; +} + +export interface PlanetInstance { + group: THREE.Group; + mesh: THREE.Mesh; + orbitSpeed: number; + ring?: THREE.Object3D; +} + +export interface ResourceNode { + id: string; + systemId: string; + position: THREE.Vector3; + mesh: THREE.Object3D; + oreRemaining: number; + maxOre: number; + itemId: string; +} + +export interface SolarSystemInstance { + definition: SolarSystemDefinition; + root: THREE.Group; + center: THREE.Vector3; + planets: PlanetInstance[]; + star: THREE.Object3D; + gravityWellRadius: number; + orbitLines: THREE.LineLoop[]; + asteroidDecorations: THREE.Object3D[]; + strategicMarker: THREE.Object3D; +} + +export type SelectableTarget = + | { kind: "ship"; ship: ShipInstance } + | { kind: "station"; station: StationInstance }; + +export interface HudElements { + details: HTMLDivElement; + status: HTMLDivElement; + selectionTitle: HTMLHeadingElement; + orders: HTMLDivElement; + minimap: HTMLCanvasElement; + minimapContext: CanvasRenderingContext2D; + marquee: HTMLDivElement; + strategicOverlay: HTMLCanvasElement; + strategicOverlayContext: CanvasRenderingContext2D; +} diff --git a/src/game/ui/hud.ts b/src/game/ui/hud.ts new file mode 100644 index 0000000..c516bbe --- /dev/null +++ b/src/game/ui/hud.ts @@ -0,0 +1,70 @@ +import type { HudElements } from "../types"; + +export function createHud(container: HTMLElement, onOrderAction: (action: string) => void): HudElements { + const root = document.createElement("div"); + root.className = "hud"; + root.innerHTML = ` + +
+

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

+
+
+

Selection

+
+
+
+
+

No Selection

+
+
+
+
+
+ + + + + +
+
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.
+
+
+ +
+
+
+ `; + + container.append(root); + root.querySelectorAll(".orders button").forEach((button) => { + button.addEventListener("click", () => onOrderAction(button.dataset.action ?? "")); + }); + + const minimap = root.querySelector(".minimap"); + const minimapContext = minimap?.getContext("2d"); + if (!minimap || !minimapContext) { + throw new Error("Unable to create minimap canvas"); + } + + const strategicOverlay = root.querySelector(".strategic-overlay"); + const strategicOverlayContext = strategicOverlay?.getContext("2d"); + if (!strategicOverlay || !strategicOverlayContext) { + throw new Error("Unable to create strategic overlay canvas"); + } + + return { + details: root.querySelector(".content") as HTMLDivElement, + status: root.querySelector(".mode") as HTMLDivElement, + selectionTitle: root.querySelector(".selection-title") as HTMLHeadingElement, + orders: root.querySelector(".orders") as HTMLDivElement, + minimap, + minimapContext, + marquee: root.querySelector(".marquee") as HTMLDivElement, + strategicOverlay, + strategicOverlayContext, + }; +} diff --git a/src/game/ui/presenters.ts b/src/game/ui/presenters.ts new file mode 100644 index 0000000..f113bf5 --- /dev/null +++ b/src/game/ui/presenters.ts @@ -0,0 +1,80 @@ +import { + itemDefinitionsById, + moduleDefinitionsById, + recipeDefinitions, +} from "../data/catalog"; +import { getShipCargoAmount } from "../state/inventory"; +import type { + ShipInstance, + SolarSystemInstance, + StationInstance, + ViewLevel, +} from "../types"; + +export function getSelectionTitle(selection: ShipInstance[], selectedStation?: StationInstance) { + if (selectedStation) { + return selectedStation.definition.label; + } + if (selection.length === 0) { + return "No Selection"; + } + if (selection.length === 1) { + return selection[0].definition.label; + } + return `${selection.length} Ships Selected`; +} + +export function getSelectionDetails( + selection: ShipInstance[], + selectedStation: StationInstance | undefined, + systems: SolarSystemInstance[], + viewLevel: ViewLevel, + ships: ShipInstance[], +) { + if (selectedStation) { + return describeStation(selectedStation, ships); + } + if (selection.length === 0) { + return `Systems online: ${systems.map((system) => system.definition.label).join(", ")}\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(", ")}`, + ) + .join("\n\n"); +} + +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 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 activity = + station.definition.category === "refining" + ? `Refining ore for ${miners} mining ships` + : station.definition.category === "shipyard" + ? `Maintaining ${patrols} patrol craft` + : station.definition.category === "farm" + ? "Supplying agricultural goods" + : station.definition.category === "defense" + ? `Coordinating ${escorts} escort wings` + : "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}`; +} + +export function getItemLabel(itemId?: string) { + return itemId ? itemDefinitionsById.get(itemId)?.label ?? itemId : "None"; +} + +export function getModuleLabel(moduleId: string) { + return moduleDefinitionsById.get(moduleId)?.label ?? moduleId; +} diff --git a/src/game/ui/strategicRenderer.ts b/src/game/ui/strategicRenderer.ts new file mode 100644 index 0000000..6a0aa86 --- /dev/null +++ b/src/game/ui/strategicRenderer.ts @@ -0,0 +1,442 @@ +import * as THREE from "three"; +import type { ShipRole, ShipInstance, SolarSystemInstance, StationInstance, ViewLevel } from "../types"; + +interface RenderMinimapOptions { + context: CanvasRenderingContext2D; + width: number; + height: number; + systems: SolarSystemInstance[]; + stations: StationInstance[]; + ships: ShipInstance[]; + selection: ShipInstance[]; + selectedStation?: StationInstance; + cameraFocus: THREE.Vector3; +} + +interface RenderOverlayOptions { + context: CanvasRenderingContext2D; + width: number; + height: number; + camera: THREE.PerspectiveCamera; + systems: SolarSystemInstance[]; + stations: StationInstance[]; + ships: ShipInstance[]; + selection: ShipInstance[]; + selectedStation?: StationInstance; + selectedSystemIndex: number; + viewLevel: ViewLevel; +} + +export function drawMinimap({ + context, + width, + height, + systems, + stations, + ships, + selection, + selectedStation, + cameraFocus, +}: RenderMinimapOptions) { + context.clearRect(0, 0, width, height); + context.fillStyle = "rgba(4, 9, 20, 0.92)"; + context.fillRect(0, 0, width, height); + context.strokeStyle = "rgba(126, 212, 255, 0.18)"; + context.strokeRect(0.5, 0.5, width - 1, height - 1); + + const bounds = { minX: -400, maxX: 5000, minZ: -1000, maxZ: 1800 }; + const mapPoint = (position: THREE.Vector3) => ({ + x: ((position.x - bounds.minX) / (bounds.maxX - bounds.minX)) * width, + y: ((position.z - bounds.minZ) / (bounds.maxZ - bounds.minZ)) * height, + }); + + systems.forEach((system) => { + const point = mapPoint(system.center); + context.fillStyle = "#7ed4ff"; + context.beginPath(); + context.arc(point.x, point.y, 6, 0, Math.PI * 2); + context.fill(); + context.strokeStyle = "rgba(126,212,255,0.25)"; + context.beginPath(); + context.arc(point.x, point.y, 18, 0, Math.PI * 2); + context.stroke(); + }); + + stations.forEach((station) => { + const point = mapPoint(station.group.position); + context.fillStyle = station === selectedStation ? "#ffbf69" : "#b4c9da"; + context.fillRect(point.x - 2, point.y - 2, 4, 4); + }); + + ships.forEach((ship) => { + const point = mapPoint(ship.group.position); + context.fillStyle = selection.includes(ship) + ? "#ffbf69" + : ship.definition.role === "mining" + ? "#ffdd75" + : ship.definition.role === "transport" + ? "#b0ff8d" + : "#7ed4ff"; + context.beginPath(); + context.arc(point.x, point.y, selection.includes(ship) ? 3 : 2, 0, Math.PI * 2); + context.fill(); + }); + + const focus = mapPoint(cameraFocus); + context.strokeStyle = "rgba(255,255,255,0.7)"; + context.strokeRect(focus.x - 9, focus.y - 9, 18, 18); +} + +export function drawStrategicOverlay({ + context, + width, + height, + camera, + systems, + stations, + ships, + selection, + selectedStation, + selectedSystemIndex, + viewLevel, +}: RenderOverlayOptions) { + context.clearRect(0, 0, width, height); + if (viewLevel === "local") { + return; + } + + context.save(); + context.scale(width / window.innerWidth, height / window.innerHeight); + context.lineJoin = "round"; + context.lineCap = "round"; + context.textAlign = "center"; + context.textBaseline = "middle"; + + if (viewLevel === "solar") { + stations + .filter((station) => station.systemId === systems[selectedSystemIndex]?.definition.id) + .forEach((station) => { + const screen = projectWorldToScreen(station.group.position, camera); + if (screen) { + drawStationSymbol(context, screen.x, screen.y, station, 14, station === selectedStation); + } + }); + + ships + .filter((ship) => ship.systemId === systems[selectedSystemIndex]?.definition.id && ship.state !== "docked") + .forEach((ship) => { + const screen = projectWorldToScreen(ship.group.position, camera); + if (screen) { + drawShipSymbol(context, screen.x, screen.y, ship, 10, selection.includes(ship)); + } + }); + } else { + systems.forEach((system) => { + const screen = projectWorldToScreen(system.center, camera); + if (!screen) { + return; + } + + drawSystemFrame(context, screen.x, screen.y, system.definition.label); + + const fleets = new Map(); + ships.forEach((ship) => { + if (ship.systemId !== system.definition.id) { + return; + } + const bucket = fleets.get(ship.definition.role) ?? []; + bucket.push(ship); + fleets.set(ship.definition.role, bucket); + }); + + const roleOrder: ShipRole[] = ["military", "transport", "mining"]; + roleOrder.forEach((role, index) => { + const bucket = fleets.get(role); + 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))); + }); + + const stationCount = stations.filter((station) => station.systemId === system.definition.id).length; + const stationSelected = stations.some( + (station) => station.systemId === system.definition.id && station === selectedStation, + ); + if (stationCount > 0) { + drawStrategicStationGroup(context, screen.x, screen.y - 38, stationCount, stationSelected); + } + }); + } + + context.restore(); +} + +function projectWorldToScreen(position: THREE.Vector3, camera: THREE.PerspectiveCamera) { + const screen = position.clone().project(camera); + if (screen.z < -1 || screen.z > 1) { + return undefined; + } + return { + x: ((screen.x + 1) * 0.5) * window.innerWidth, + y: ((-screen.y + 1) * 0.5) * window.innerHeight, + }; +} + +function drawSystemFrame(context: CanvasRenderingContext2D, x: number, y: number, label: string) { + context.strokeStyle = "rgba(126, 212, 255, 0.82)"; + context.lineWidth = 1.25; + context.strokeRect(x - 28, y - 16, 56, 32); + context.beginPath(); + context.moveTo(x - 40, y); + context.lineTo(x - 28, y); + context.moveTo(x + 28, y); + context.lineTo(x + 40, y); + context.stroke(); + context.fillStyle = "rgba(235, 247, 255, 0.9)"; + context.font = "600 11px Space Grotesk, sans-serif"; + context.fillText(label.toUpperCase(), x, y - 28); +} + +function drawFleetSymbol( + context: CanvasRenderingContext2D, + x: number, + y: number, + role: ShipRole, + count: number, + highlighted: boolean, +) { + context.save(); + context.translate(x, y); + context.strokeStyle = highlighted ? "#ffbf69" : "rgba(208, 232, 244, 0.95)"; + context.fillStyle = "rgba(5, 12, 26, 0.88)"; + context.lineWidth = highlighted ? 2.2 : 1.5; + + if (role === "military") { + context.beginPath(); + context.moveTo(0, -12); + context.lineTo(12, 0); + context.lineTo(0, 12); + context.lineTo(-12, 0); + context.closePath(); + context.fill(); + context.stroke(); + context.beginPath(); + context.moveTo(-5, 0); + context.lineTo(5, 0); + context.stroke(); + } else if (role === "transport") { + context.beginPath(); + context.rect(-13, -9, 26, 18); + context.fill(); + context.stroke(); + context.beginPath(); + context.moveTo(-4, -9); + context.lineTo(-4, 9); + context.moveTo(4, -9); + context.lineTo(4, 9); + context.stroke(); + } else { + context.beginPath(); + context.moveTo(-12, -7); + context.lineTo(-5, -12); + context.lineTo(5, -12); + context.lineTo(12, -7); + context.lineTo(12, 7); + context.lineTo(5, 12); + context.lineTo(-5, 12); + context.lineTo(-12, 7); + context.closePath(); + context.fill(); + context.stroke(); + context.beginPath(); + context.moveTo(-8, 0); + context.lineTo(8, 0); + context.stroke(); + } + + context.fillStyle = highlighted ? "#ffbf69" : "rgba(235, 247, 255, 0.9)"; + context.font = "700 12px Space Grotesk, sans-serif"; + context.fillText(String(count), 0, 23); + context.restore(); +} + +function drawStrategicStationGroup( + context: CanvasRenderingContext2D, + x: number, + y: number, + count: number, + highlighted: boolean, +) { + context.save(); + context.translate(x, y); + context.strokeStyle = highlighted ? "#ffbf69" : "rgba(180, 201, 218, 0.9)"; + context.fillStyle = "rgba(5, 12, 26, 0.88)"; + context.lineWidth = highlighted ? 2.2 : 1.5; + context.beginPath(); + context.rect(-12, -12, 24, 24); + context.fill(); + context.stroke(); + context.beginPath(); + context.moveTo(-18, 0); + context.lineTo(-12, 0); + context.moveTo(12, 0); + context.lineTo(18, 0); + context.moveTo(0, -18); + context.lineTo(0, -12); + context.moveTo(0, 12); + context.lineTo(0, 18); + context.stroke(); + context.fillStyle = highlighted ? "#ffbf69" : "rgba(235, 247, 255, 0.9)"; + context.font = "700 12px Space Grotesk, sans-serif"; + context.fillText(String(count), 0, 24); + context.restore(); +} + +function drawShipSymbol( + context: CanvasRenderingContext2D, + x: number, + y: number, + ship: ShipInstance, + size: number, + highlighted: boolean, +) { + context.save(); + context.translate(x, y); + context.rotate(-ship.group.rotation.y); + context.strokeStyle = highlighted ? "#ffbf69" : getShipSymbolColor(ship); + context.lineWidth = highlighted ? 2.2 : 1.4; + context.fillStyle = "rgba(5, 12, 26, 0.74)"; + + if (ship.definition.role === "military") { + context.beginPath(); + context.moveTo(0, -size); + context.lineTo(size, 0); + context.lineTo(0, size); + context.lineTo(-size, 0); + context.closePath(); + context.fill(); + context.stroke(); + context.beginPath(); + context.moveTo(-size * 0.35, 0); + context.lineTo(size * 0.35, 0); + context.stroke(); + } else if (ship.definition.role === "transport") { + context.beginPath(); + context.rect(-size, -size * 0.68, size * 2, size * 1.36); + context.fill(); + context.stroke(); + context.beginPath(); + context.moveTo(-size * 0.25, -size * 0.68); + context.lineTo(-size * 0.25, size * 0.68); + context.moveTo(size * 0.25, -size * 0.68); + context.lineTo(size * 0.25, size * 0.68); + context.stroke(); + } else { + context.beginPath(); + context.moveTo(-size, -size * 0.5); + context.lineTo(-size * 0.35, -size); + context.lineTo(size * 0.35, -size); + context.lineTo(size, -size * 0.5); + context.lineTo(size, size * 0.5); + context.lineTo(size * 0.35, size); + context.lineTo(-size * 0.35, size); + context.lineTo(-size, size * 0.5); + context.closePath(); + context.fill(); + context.stroke(); + context.beginPath(); + context.moveTo(-size * 0.65, 0); + context.lineTo(size * 0.65, 0); + context.stroke(); + } + + if (highlighted) { + context.strokeStyle = "rgba(255, 191, 105, 0.42)"; + context.lineWidth = 1; + context.beginPath(); + context.arc(0, 0, size + 7, 0, Math.PI * 2); + context.stroke(); + } + context.restore(); +} + +function drawStationSymbol( + context: CanvasRenderingContext2D, + x: number, + y: number, + station: StationInstance, + size: number, + highlighted: boolean, +) { + context.save(); + context.translate(x, y); + context.strokeStyle = highlighted ? "#ffbf69" : getStationSymbolColor(station); + context.fillStyle = "rgba(5, 12, 26, 0.78)"; + context.lineWidth = highlighted ? 2.2 : 1.5; + context.beginPath(); + context.rect(-size, -size, size * 2, size * 2); + context.fill(); + context.stroke(); + + context.beginPath(); + context.moveTo(-size - 7, 0); + context.lineTo(-size, 0); + context.moveTo(size, 0); + context.lineTo(size + 7, 0); + context.moveTo(0, -size - 7); + context.lineTo(0, -size); + context.moveTo(0, size); + context.lineTo(0, size + 7); + context.stroke(); + + if (station.definition.category === "refining") { + context.beginPath(); + context.moveTo(-4, 5); + context.lineTo(0, -5); + context.lineTo(4, 5); + context.stroke(); + } else if (station.definition.category === "defense") { + context.beginPath(); + context.moveTo(-5, -5); + context.lineTo(5, 5); + context.moveTo(5, -5); + context.lineTo(-5, 5); + context.stroke(); + } else if (station.definition.category === "shipyard") { + context.beginPath(); + context.rect(-5, -3, 10, 6); + context.stroke(); + } else if (station.definition.category === "farm") { + context.beginPath(); + context.arc(0, 0, 5, 0, Math.PI * 2); + context.stroke(); + } + + context.restore(); +} + +function getShipSymbolColor(ship: ShipInstance) { + if (ship.definition.role === "military") { + return "rgba(126, 212, 255, 0.95)"; + } + if (ship.definition.role === "transport") { + return "rgba(176, 255, 141, 0.95)"; + } + return "rgba(255, 221, 117, 0.95)"; +} + +function getStationSymbolColor(station: StationInstance) { + if (station.definition.category === "refining") { + return "rgba(255, 184, 108, 0.95)"; + } + if (station.definition.category === "farm") { + return "rgba(146, 239, 138, 0.95)"; + } + if (station.definition.category === "defense") { + return "rgba(255, 122, 149, 0.95)"; + } + if (station.definition.category === "shipyard") { + return "rgba(208, 162, 255, 0.95)"; + } + return "rgba(180, 201, 218, 0.95)"; +} diff --git a/src/game/world/worldFactory.ts b/src/game/world/worldFactory.ts new file mode 100644 index 0000000..4547cf3 --- /dev/null +++ b/src/game/world/worldFactory.ts @@ -0,0 +1,668 @@ +import * as THREE from "three"; +import { + constructibleDefinitionsById, + gameBalance, + scenarioDefinition, + shipDefinitionsById, + solarSystemDefinitions, +} from "../data/catalog"; +import { createEmptyInventory } from "../state/inventory"; +import type { + ConstructibleDefinition, + ResourceNode, + SelectableTarget, + ShipDefinition, + ShipInstance, + SolarSystemDefinition, + SolarSystemInstance, + StationInstance, +} from "../types"; + +interface BuildWorldResult { + systems: SolarSystemInstance[]; + nodes: ResourceNode[]; + stations: StationInstance[]; + ships: ShipInstance[]; + shipsById: Map; + strategicLinks: THREE.Group; + starfield?: THREE.Points; +} + +export function buildInitialWorld( + scene: THREE.Scene, + selectableTargets: Map, +): BuildWorldResult { + const systems: SolarSystemInstance[] = []; + const nodes: ResourceNode[] = []; + const stations: StationInstance[] = []; + const ships: ShipInstance[] = []; + const shipsById = new Map(); + const strategicLinks = new THREE.Group(); + let shipId = 0; + let stationId = 0; + let nodeId = 0; + + scene.add(new THREE.HemisphereLight(0x6ba6ff, 0x03050a, 0.38)); + scene.add(new THREE.AmbientLight(0x8397b8, 0.28)); + scene.add(strategicLinks); + + createNebulae(scene); + const starfield = createStarfield(scene); + + solarSystemDefinitions.forEach((definition) => { + systems.push(createSolarSystem(scene, definition, nodes, () => { + nodeId += 1; + return `node-${nodeId}`; + })); + }); + + createStrategicLinks(strategicLinks, systems); + + scenarioDefinition.initialStations.forEach((plan) => { + const definition = constructibleDefinitionsById.get(plan.constructibleId); + if (!definition) { + throw new Error(`Missing constructible definition ${plan.constructibleId}`); + } + stations.push( + createStationInstance({ + id: `station-${++stationId}`, + scene, + definition, + systemId: plan.systemId, + position: plan.position ? new THREE.Vector3(...plan.position) : new THREE.Vector3(), + planetIndex: plan.planetIndex, + lagrangeSide: plan.lagrangeSide, + selectableTargets, + }), + ); + }); + + scenarioDefinition.shipFormations.forEach((plan) => { + const definition = shipDefinitionsById.get(plan.shipId); + if (!definition) { + throw new Error(`Missing ship definition ${plan.shipId}`); + } + for (let i = 0; i < plan.count; i += 1) { + const ship = createShip({ + id: `ship-${++shipId}`, + definition, + systemId: plan.systemId, + selectableTargets, + }); + ship.group.position + .set(...plan.center) + .add(new THREE.Vector3((i % 3) * 18, gameBalance.yPlane, Math.floor(i / 3) * 18)); + ship.target.copy(ship.group.position); + const systemCenter = getSystemCenter(systems, plan.systemId); + ship.idleOrbitRadius = ship.group.position.clone().setY(0).distanceTo(systemCenter); + ship.idleOrbitAngle = Math.atan2(ship.group.position.z - systemCenter.z, ship.group.position.x - systemCenter.x); + scene.add(ship.group); + ships.push(ship); + shipsById.set(ship.id, ship); + } + }); + + return { systems, nodes, stations, ships, shipsById, strategicLinks, starfield }; +} + +function createSolarSystem( + scene: THREE.Scene, + definition: SolarSystemDefinition, + nodes: ResourceNode[], + nextNodeId: () => string, +) { + const root = new THREE.Group(); + root.position.set(...definition.position); + scene.add(root); + + const star = new THREE.Mesh( + new THREE.SphereGeometry(definition.starSize, 48, 48), + new THREE.MeshBasicMaterial({ color: definition.starColor }), + ); + root.add(star); + + const glow = new THREE.Mesh( + new THREE.SphereGeometry(definition.starSize * 1.6, 32, 32), + new THREE.MeshBasicMaterial({ + color: definition.starGlow, + transparent: true, + opacity: 0.14, + side: THREE.BackSide, + }), + ); + root.add(glow); + + const light = new THREE.PointLight(definition.starColor, 3.2, 2800, 1.2); + light.castShadow = true; + root.add(light); + + const planets = definition.planets.map((planetDefinition, index) => { + const orbitRoot = new THREE.Group(); + orbitRoot.rotation.y = (index / definition.planets.length) * Math.PI * 2; + + const planet = new THREE.Mesh( + new THREE.SphereGeometry(planetDefinition.size, 36, 36), + new THREE.MeshStandardMaterial({ + color: planetDefinition.color, + metalness: 0.08, + roughness: 0.92, + emissive: new THREE.Color(planetDefinition.color).multiplyScalar(0.04), + }), + ); + planet.position.x = planetDefinition.orbitRadius; + planet.rotation.z = planetDefinition.tilt; + planet.castShadow = true; + planet.receiveShadow = true; + orbitRoot.add(planet); + + let ringObject: THREE.Object3D | undefined; + if (planetDefinition.hasRing) { + const ring = new THREE.Mesh( + new THREE.RingGeometry(planetDefinition.size * 1.3, planetDefinition.size * 2, 72), + new THREE.MeshBasicMaterial({ + color: 0xc1b299, + side: THREE.DoubleSide, + transparent: true, + opacity: 0.4, + }), + ); + ring.rotation.x = Math.PI / 2.35; + ring.position.x = planetDefinition.orbitRadius; + orbitRoot.add(ring); + ringObject = ring; + } + + root.add(orbitRoot); + return { group: orbitRoot, mesh: planet, orbitSpeed: planetDefinition.orbitSpeed, ring: ringObject }; + }); + + const orbitLines = definition.planets.map((planetDefinition) => { + const orbitLine = new THREE.LineLoop( + new THREE.BufferGeometry().setFromPoints( + Array.from({ length: 120 }, (_, step) => { + const angle = (step / 120) * Math.PI * 2; + return new THREE.Vector3( + Math.cos(angle) * planetDefinition.orbitRadius, + 0, + Math.sin(angle) * planetDefinition.orbitRadius, + ); + }), + ), + new THREE.LineBasicMaterial({ color: 0x17314d, transparent: true, opacity: 0.52 }), + ); + root.add(orbitLine); + return orbitLine; + }); + + const asteroidDecorations = createAsteroidField(definition, root, nodes, nextNodeId); + const strategicMarker = createStrategicMarker(scene, definition); + + return { + definition, + root, + center: new THREE.Vector3(...definition.position), + planets, + star, + gravityWellRadius: definition.gravityWellRadius, + orbitLines, + asteroidDecorations, + strategicMarker, + }; +} + +function createAsteroidField( + definition: SolarSystemDefinition, + root: THREE.Group, + nodes: ResourceNode[], + nextNodeId: () => string, +) { + const rockGeometry = new THREE.IcosahedronGeometry(1, 0); + const rockMaterial = new THREE.MeshStandardMaterial({ + color: 0x707582, + roughness: 1, + metalness: 0.05, + }); + const decorations: THREE.Object3D[] = []; + const baseRadius = definition.gravityWellRadius + definition.asteroidField.radiusOffset; + + for (let i = 0; i < definition.asteroidField.decorationCount; i += 1) { + const rock = new THREE.Mesh(rockGeometry, rockMaterial); + const angle = Math.random() * Math.PI * 2; + const radius = baseRadius + (Math.random() - 0.5) * definition.asteroidField.radiusVariance; + rock.position.set( + Math.cos(angle) * radius, + (Math.random() - 0.5) * definition.asteroidField.heightVariance, + Math.sin(angle) * radius, + ); + rock.scale.setScalar(1.5 + Math.random() * 4); + rock.rotation.set(Math.random() * Math.PI, Math.random() * Math.PI, Math.random() * Math.PI); + root.add(rock); + decorations.push(rock); + } + + definition.resourceNodes.forEach((resourceNode) => { + const cluster = new THREE.Group(); + const position = new THREE.Vector3( + Math.cos(resourceNode.angle) * (baseRadius + resourceNode.radiusOffset - definition.asteroidField.radiusOffset), + 0, + Math.sin(resourceNode.angle) * (baseRadius + resourceNode.radiusOffset - definition.asteroidField.radiusOffset), + ); + cluster.position.copy(position); + for (let i = 0; i < resourceNode.shardCount; i += 1) { + const shard = new THREE.Mesh( + new THREE.DodecahedronGeometry(6 + Math.random() * 7, 0), + new THREE.MeshStandardMaterial({ + color: 0xd1bd7c, + emissive: new THREE.Color("#ffdd75").multiplyScalar(0.08), + roughness: 0.9, + metalness: 0.15, + }), + ); + shard.position.set((Math.random() - 0.5) * 18, (Math.random() - 0.5) * 12, (Math.random() - 0.5) * 18); + cluster.add(shard); + } + root.add(cluster); + decorations.push(cluster); + nodes.push({ + id: nextNodeId(), + systemId: definition.id, + position: cluster.getWorldPosition(new THREE.Vector3()), + mesh: cluster, + oreRemaining: resourceNode.oreAmount, + maxOre: resourceNode.oreAmount, + itemId: resourceNode.itemId, + }); + }); + + return decorations; +} + +function createStrategicMarker(scene: THREE.Scene, definition: SolarSystemDefinition) { + const marker = new THREE.Group(); + marker.position.set(...definition.position); + + const outer = new THREE.Mesh( + new THREE.RingGeometry(definition.gravityWellRadius * 0.9, definition.gravityWellRadius * 1.05, 64), + new THREE.MeshBasicMaterial({ + color: definition.starColor, + transparent: true, + opacity: 0.4, + side: THREE.DoubleSide, + }), + ); + outer.rotation.x = -Math.PI / 2; + marker.add(outer); + + const core = new THREE.Mesh( + new THREE.CircleGeometry(definition.gravityWellRadius * 0.22, 32), + new THREE.MeshBasicMaterial({ + color: definition.starColor, + transparent: true, + opacity: 0.7, + side: THREE.DoubleSide, + }), + ); + core.rotation.x = -Math.PI / 2; + marker.add(core); + + marker.visible = false; + scene.add(marker); + return marker; +} + +function createStrategicLinks(strategicLinks: THREE.Group, systems: SolarSystemInstance[]) { + if (systems.length < 2) { + return; + } + const line = new THREE.Line( + new THREE.BufferGeometry().setFromPoints(systems.map((system) => system.center)), + new THREE.LineDashedMaterial({ + color: 0x5e8fbe, + dashSize: 120, + gapSize: 80, + transparent: true, + opacity: 0.5, + }), + ); + line.computeLineDistances(); + strategicLinks.add(line); + strategicLinks.visible = false; +} + +export function createStationInstance({ + id, + scene, + definition, + systemId, + position, + planetIndex, + lagrangeSide, + selectableTargets, +}: { + id: string; + scene: THREE.Scene; + definition: ConstructibleDefinition; + systemId: string; + position: THREE.Vector3; + planetIndex?: number; + lagrangeSide?: -1 | 1; + selectableTargets: Map; +}) { + const group = new THREE.Group(); + group.position.copy(position); + + const core = new THREE.Mesh( + new THREE.CylinderGeometry(definition.radius * 0.4, definition.radius * 0.6, definition.radius * 1.2, 8), + new THREE.MeshStandardMaterial({ + color: definition.color, + emissive: new THREE.Color(definition.color).multiplyScalar(0.12), + roughness: 0.55, + metalness: 0.45, + }), + ); + core.rotation.z = Math.PI / 2; + core.castShadow = true; + core.receiveShadow = true; + group.add(core); + + const ring = new THREE.Mesh( + new THREE.TorusGeometry(definition.radius, Math.max(2.4, definition.radius * 0.08), 18, 48), + new THREE.MeshStandardMaterial({ + color: 0xcdd8e5, + emissive: new THREE.Color(definition.color).multiplyScalar(0.05), + roughness: 0.4, + metalness: 0.7, + }), + ); + ring.rotation.x = Math.PI / 2; + group.add(ring); + + const selectionRing = new THREE.Mesh( + new THREE.RingGeometry(definition.radius * 1.3, definition.radius * 1.5, 40), + new THREE.MeshBasicMaterial({ + color: definition.color, + transparent: true, + opacity: 0, + side: THREE.DoubleSide, + }), + ); + selectionRing.rotation.x = -Math.PI / 2; + selectionRing.position.y = -definition.radius * 0.32; + group.add(selectionRing); + + const dockingPorts = Array.from({ length: definition.dockingCapacity }, (_, index) => { + const angle = (index / Math.max(1, definition.dockingCapacity)) * Math.PI * 2; + const port = new THREE.Vector3( + Math.cos(angle) * (definition.radius + 18), + gameBalance.yPlane, + Math.sin(angle) * (definition.radius + 18), + ); + const beacon = new THREE.Mesh( + new THREE.BoxGeometry(5, 2, 9), + new THREE.MeshBasicMaterial({ color: definition.color, transparent: true, opacity: 0.75 }), + ); + beacon.position.copy(port); + beacon.lookAt(new THREE.Vector3(0, gameBalance.yPlane, 0)); + group.add(beacon); + return port; + }); + + for (let i = 0; i < 4; i += 1) { + const arm = new THREE.Mesh( + new THREE.BoxGeometry(definition.radius * 0.2, definition.radius * 0.15, definition.radius * 1.5), + new THREE.MeshStandardMaterial({ color: 0x8294a9, roughness: 0.55, metalness: 0.5 }), + ); + arm.position.set( + Math.cos((i / 4) * Math.PI * 2) * definition.radius * 0.75, + 0, + Math.sin((i / 4) * Math.PI * 2) * definition.radius * 0.75, + ); + group.add(arm); + } + + scene.add(group); + const station: StationInstance = { + id, + definition, + group, + systemId, + ring: selectionRing, + oreStored: 0, + refinedStock: 0, + processTimer: 0, + activeBatch: 0, + inventory: createEmptyInventory(), + dockedShipIds: new Set(), + dockingPorts, + modules: definition.modules, + orbitalParentPlanetIndex: planetIndex, + lagrangeSide, + fuel: 800, + energy: 1200, + maxFuel: 800, + maxEnergy: 1200, + }; + selectableTargets.set(core, { kind: "station", station }); + selectableTargets.set(ring, { kind: "station", station }); + return station; +} + +function createShip({ + id, + definition, + systemId, + selectableTargets, +}: { + id: string; + definition: ShipDefinition; + systemId: string; + selectableTargets: Map; +}) { + const group = new THREE.Group(); + const visual = new THREE.Group(); + visual.rotation.y = Math.PI / 2; + group.add(visual); + + const warpFx = new THREE.Group(); + warpFx.visible = false; + for (let i = 0; i < 5; i += 1) { + const streak = new THREE.Mesh( + new THREE.CylinderGeometry(0.12, 0.5, definition.size * 8, 8), + new THREE.MeshBasicMaterial({ color: definition.color, transparent: true, opacity: 0.22 }), + ); + streak.rotation.z = Math.PI / 2; + streak.position.set(-definition.size * (2 + i * 1.7), (i - 2) * 0.45, 0); + warpFx.add(streak); + } + visual.add(warpFx); + + const bodyMaterial = new THREE.MeshStandardMaterial({ + color: definition.hullColor, + emissive: new THREE.Color(definition.color).multiplyScalar(0.08), + roughness: 0.45, + metalness: 0.7, + }); + + const hull = new THREE.Mesh( + new THREE.CylinderGeometry(definition.size * 0.3, definition.size, definition.size * 3, 6), + bodyMaterial, + ); + hull.rotation.z = -Math.PI / 2; + hull.castShadow = true; + visual.add(hull); + + const nose = new THREE.Mesh( + new THREE.ConeGeometry(definition.size * 0.7, definition.size * 1.8, 6), + new THREE.MeshStandardMaterial({ + color: definition.color, + emissive: new THREE.Color(definition.color).multiplyScalar(0.12), + roughness: 0.35, + metalness: 0.65, + }), + ); + nose.rotation.z = -Math.PI / 2; + nose.position.x = definition.size * 2.1; + visual.add(nose); + + const wingGeometry = new THREE.BoxGeometry(definition.size * 0.25, definition.size * 1.8, definition.size * 0.7); + [-1, 1].forEach((side) => { + const wing = new THREE.Mesh(wingGeometry, bodyMaterial); + wing.position.set(0, side * definition.size * 0.9, 0); + visual.add(wing); + }); + + 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 }), + ); + engineGlow.position.x = -definition.size * 1.8; + visual.add(engineGlow); + + const ring = new THREE.Mesh( + new THREE.RingGeometry(definition.size * 1.5, definition.size * 1.9, 32), + new THREE.MeshBasicMaterial({ + color: definition.color, + transparent: true, + opacity: 0, + side: THREE.DoubleSide, + }), + ); + ring.rotation.x = -Math.PI / 2; + ring.position.y = -definition.size * 0.55; + group.add(ring); + + const pickHull = new THREE.Mesh( + new THREE.SphereGeometry(definition.size * 1.6, 12, 12), + new THREE.MeshBasicMaterial({ visible: false }), + ); + group.add(pickHull); + + const ship: ShipInstance = { + id, + definition, + group, + target: new THREE.Vector3(), + velocity: new THREE.Vector3(), + selected: false, + ring, + systemId, + state: "idle", + order: { kind: "idle" }, + inventory: createEmptyInventory(), + cargoItemId: definition.cargoItemId, + actionTimer: 0, + fuel: 220, + energy: 260, + maxFuel: 220, + maxEnergy: 260, + idleOrbitRadius: Math.max(120, group.position.length()), + idleOrbitAngle: 0, + warpFx, + }; + + selectableTargets.set(pickHull, { kind: "ship", ship }); + selectableTargets.set(hull, { kind: "ship", ship }); + return ship; +} + +function createNebulae(scene: THREE.Scene) { + const colors: [string, string, string][] = [ + ["rgba(126,212,255,0.75)", "rgba(197,111,255,0.32)", "rgba(0,0,0,0)"], + ["rgba(255,157,102,0.72)", "rgba(255,102,129,0.28)", "rgba(0,0,0,0)"], + ["rgba(138,255,199,0.7)", "rgba(72,111,255,0.2)", "rgba(0,0,0,0)"], + ]; + + const positions = [ + new THREE.Vector3(-1800, 260, -1100), + new THREE.Vector3(1800, -100, -1600), + new THREE.Vector3(3300, 160, 1800), + new THREE.Vector3(5200, 220, -900), + new THREE.Vector3(6400, 100, 1500), + ]; + + positions.forEach((position, index) => { + const sprite = new THREE.Sprite( + new THREE.SpriteMaterial({ + map: makeRadialTexture(colors[index % colors.length]), + transparent: true, + depthWrite: false, + opacity: 0.34, + blending: THREE.AdditiveBlending, + }), + ); + sprite.position.copy(position); + sprite.scale.setScalar(1000 + (index % 3) * 220); + sprite.material.rotation = index * 0.67; + scene.add(sprite); + }); +} + +function createStarfield(scene: THREE.Scene) { + const starCount = 9000; + const positions = new Float32Array(starCount * 3); + const colors = new Float32Array(starCount * 3); + const color = new THREE.Color(); + + for (let i = 0; i < starCount; i += 1) { + const radius = 4200 + Math.random() * 7600; + const theta = Math.random() * Math.PI * 2; + const phi = Math.acos(2 * Math.random() - 1); + const centerBias = Math.random() > 0.5 ? 2200 : 0; + + positions[i * 3] = radius * Math.sin(phi) * Math.cos(theta) + centerBias; + positions[i * 3 + 1] = radius * Math.cos(phi); + positions[i * 3 + 2] = radius * Math.sin(phi) * Math.sin(theta); + + color.setHSL(0.55 + Math.random() * 0.15, 0.56, 0.7 + Math.random() * 0.28); + colors[i * 3] = color.r; + colors[i * 3 + 1] = color.g; + colors[i * 3 + 2] = color.b; + } + + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); + geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3)); + + const starfield = new THREE.Points( + geometry, + new THREE.PointsMaterial({ + size: 8, + sizeAttenuation: true, + vertexColors: true, + transparent: true, + opacity: 0.9, + depthWrite: false, + }), + ); + scene.add(starfield); + return starfield; +} + +function makeRadialTexture(stops: [string, string, string]) { + const canvas = document.createElement("canvas"); + canvas.width = 512; + canvas.height = 512; + const context = canvas.getContext("2d"); + if (!context) { + throw new Error("Unable to create 2D context for nebula texture"); + } + + const gradient = context.createRadialGradient(256, 256, 30, 256, 256, 256); + gradient.addColorStop(0, stops[0]); + gradient.addColorStop(0.45, stops[1]); + gradient.addColorStop(1, stops[2]); + context.fillStyle = gradient; + context.fillRect(0, 0, 512, 512); + + const texture = new THREE.CanvasTexture(canvas); + texture.colorSpace = THREE.SRGBColorSpace; + return texture; +} + +function getSystemCenter(systems: SolarSystemInstance[], systemId: string) { + const system = systems.find((candidate) => candidate.definition.id === systemId); + if (!system) { + throw new Error(`Missing solar system ${systemId}`); + } + return system.center.clone(); +} diff --git a/tsconfig.json b/tsconfig.json index 55b6d38..2dadc30 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,6 +4,7 @@ "useDefineForClassFields": true, "module": "ESNext", "moduleResolution": "Bundler", + "resolveJsonModule": true, "lib": ["ES2022", "DOM"], "strict": true, "noEmit": true,