chore: refactor GameApp.ts into parts
This commit is contained in:
23
SESSION.md
23
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
|
||||
|
||||
1817
src/game/GameApp.ts
1817
src/game/GameApp.ts
File diff suppressed because it is too large
Load Diff
17
src/game/data/balance.json
Normal file
17
src/game/data/balance.json
Normal 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
36
src/game/data/catalog.ts
Normal 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]));
|
||||
52
src/game/data/constructibles.json
Normal file
52
src/game/data/constructibles.json
Normal 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
32
src/game/data/items.json
Normal 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."
|
||||
}
|
||||
]
|
||||
68
src/game/data/modules.json
Normal file
68
src/game/data/modules.json
Normal 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."
|
||||
}
|
||||
]
|
||||
14
src/game/data/recipes.json
Normal file
14
src/game/data/recipes.json
Normal 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 }
|
||||
]
|
||||
}
|
||||
]
|
||||
40
src/game/data/scenario.json
Normal file
40
src/game/data/scenario.json
Normal 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
62
src/game/data/ships.json
Normal 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"]
|
||||
}
|
||||
]
|
||||
49
src/game/data/systems.json
Normal file
49
src/game/data/systems.json
Normal 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 }
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -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 },
|
||||
],
|
||||
},
|
||||
];
|
||||
35
src/game/state/inventory.ts
Normal file
35
src/game/state/inventory.ts
Normal 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
305
src/game/types.ts
Normal 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
70
src/game/ui/hud.ts
Normal 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
80
src/game/ui/presenters.ts
Normal 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;
|
||||
}
|
||||
442
src/game/ui/strategicRenderer.ts
Normal file
442
src/game/ui/strategicRenderer.ts
Normal 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)";
|
||||
}
|
||||
668
src/game/world/worldFactory.ts
Normal file
668
src/game/world/worldFactory.ts
Normal 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();
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
|
||||
Reference in New Issue
Block a user