chore: refactor GameApp.ts into parts

This commit is contained in:
2026-03-11 18:21:51 -04:00
parent caa9d40cba
commit 5727cb0e88
19 changed files with 2322 additions and 1762 deletions

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -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
}
}

36
src/game/data/catalog.ts Normal file
View File

@@ -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]));

View File

@@ -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"]
}
]

32
src/game/data/items.json Normal file
View File

@@ -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."
}
]

View File

@@ -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."
}
]

View File

@@ -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 }
]
}
]

View File

@@ -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"
}
}

62
src/game/data/ships.json Normal file
View File

@@ -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"]
}
]

View File

@@ -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 }
]
}
]

View File

@@ -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<Record<ItemStorageKind, number>>;
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 },
],
},
];

View File

@@ -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;
}

305
src/game/types.ts Normal file
View File

@@ -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<Record<ItemStorageKind, number>>;
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<string>;
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;
}

70
src/game/ui/hud.ts Normal file
View File

@@ -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 = `
<canvas class="strategic-overlay"></canvas>
<section class="panel summary">
<h1>Helios Reach Command</h1>
<p>
Dual-star-system prototype with gravity-well exits, FTL spooling, inter-system travel,
and unit orders for patrol, escort, mining, and manual fleet movement.
</p>
</section>
<section class="panel details">
<h2>Selection</h2>
<div class="content"></div>
</section>
<section class="panel commandbar">
<div class="selection-panel">
<h2 class="selection-title">No Selection</h2>
<div class="content compact"></div>
</div>
<div class="orders-panel">
<div class="mode"></div>
<div class="orders">
<button type="button" data-action="move">Move</button>
<button type="button" data-action="mine">Mine</button>
<button type="button" data-action="patrol">Patrol</button>
<button type="button" data-action="escort">Escort</button>
<button type="button" data-action="focus">Focus</button>
</div>
<div class="hint">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.</div>
</div>
<div class="minimap-panel">
<canvas class="minimap" width="220" height="160"></canvas>
</div>
</section>
<div class="marquee"></div>
`;
container.append(root);
root.querySelectorAll<HTMLButtonElement>(".orders button").forEach((button) => {
button.addEventListener("click", () => onOrderAction(button.dataset.action ?? ""));
});
const minimap = root.querySelector<HTMLCanvasElement>(".minimap");
const minimapContext = minimap?.getContext("2d");
if (!minimap || !minimapContext) {
throw new Error("Unable to create minimap canvas");
}
const strategicOverlay = root.querySelector<HTMLCanvasElement>(".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,
};
}

80
src/game/ui/presenters.ts Normal file
View File

@@ -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;
}

View File

@@ -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<ShipRole, ShipInstance[]>();
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)";
}

View File

@@ -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<string, ShipInstance>;
strategicLinks: THREE.Group;
starfield?: THREE.Points;
}
export function buildInitialWorld(
scene: THREE.Scene,
selectableTargets: Map<THREE.Object3D, SelectableTarget>,
): BuildWorldResult {
const systems: SolarSystemInstance[] = [];
const nodes: ResourceNode[] = [];
const stations: StationInstance[] = [];
const ships: ShipInstance[] = [];
const shipsById = new Map<string, ShipInstance>();
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<THREE.Object3D, SelectableTarget>;
}) {
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<THREE.Object3D, SelectableTarget>;
}) {
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();
}

View File

@@ -4,6 +4,7 @@
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"lib": ["ES2022", "DOM"],
"strict": true,
"noEmit": true,