Add v0.1 foundation: menu, click-to-fly ship, and JSON config system
- Boot the game from a static server with no build step (Phaser 4 vendored in lib/) - Main menu (New Game button) and GameScene where clicking makes the ship fly there - All tunables live in data/*.json (game/menu/ship), loaded via manifest + Config singleton - Ship behavior driven by arcade physics (thrust, drag, maxSpeed, arrival) with a Node test harness (dev/ship-behavior.test.mjs) and a dev smoke page that boots straight into GameScene - Project conventions documented in docs/PROJECT_NOTES.md; README rewritten to describe layout, run instructions, and dev tools
This commit is contained in:
parent
572d64c349
commit
eddbedbf1c
70
README.md
70
README.md
|
|
@ -1 +1,69 @@
|
||||||
# Something is coming
|
# Orbit
|
||||||
|
|
||||||
|
> A procedurally generated space RPG in the spirit of *Privateer*, in
|
||||||
|
> top-down 2D. Built with **Phaser 4** as plain **ES6 modules** — no build
|
||||||
|
> step, no package managers required to run.
|
||||||
|
|
||||||
|
## Run it
|
||||||
|
|
||||||
|
Any static file server works (Python, Node, Caddy, nginx, …):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd orbit
|
||||||
|
python3 -m http.server 8080
|
||||||
|
# → http://localhost:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
> Must be served over **http(s)** — opening `index.html` via `file://` won't
|
||||||
|
> work, because the game uses ES modules and `fetch`es its JSON config.
|
||||||
|
|
||||||
|
## Current state — v0.1 foundation
|
||||||
|
|
||||||
|
- Main menu with a **New Game** button
|
||||||
|
- Game screen with a basic top-down ship: **click anywhere to fly there**
|
||||||
|
- Config-driven setup: every tunable value lives in `data/*.json`
|
||||||
|
|
||||||
|
## Project layout
|
||||||
|
|
||||||
|
```
|
||||||
|
orbit/
|
||||||
|
├── index.html # boots the game
|
||||||
|
├── data/ # ← ALL tunable config (edit these freely)
|
||||||
|
│ ├── manifest.json # which config files exist
|
||||||
|
│ ├── game.json # dimensions, colors, starfield, …
|
||||||
|
│ ├── menu.json # menu text, colors, button layout
|
||||||
|
│ └── ship.json # ship feel: thrust, drag, maxSpeed, …
|
||||||
|
├── lib/ # vendored third-party libs (Phaser 4.2.1)
|
||||||
|
├── js/
|
||||||
|
│ ├── main.js # entry point: load config → boot Phaser
|
||||||
|
│ ├── config/ # Config singleton, ConfigLoader, game config
|
||||||
|
│ ├── scenes/ # MenuScene, GameScene (thin, orchestration)
|
||||||
|
│ ├── entities/ # Ship (own behavior)
|
||||||
|
│ ├── ui/ # MenuButton (reusable)
|
||||||
|
│ ├── visuals/ # Starfield (decorative)
|
||||||
|
│ ├── utils/ # small helpers (Color)
|
||||||
|
│ └── vendor/ # shim to the vendored Phaser
|
||||||
|
└── docs/PROJECT_NOTES.md # ← project conventions: read this
|
||||||
|
```
|
||||||
|
|
||||||
|
## Conventions (short version)
|
||||||
|
|
||||||
|
- **Config in JSON.** If a value might change, it goes in `data/`, not code.
|
||||||
|
Add a file = one line in `data/manifest.json`.
|
||||||
|
- **One class per file**, ES modules, scenes stay thin, entities own their
|
||||||
|
behavior. Details in [`docs/PROJECT_NOTES.md`](docs/PROJECT_NOTES.md).
|
||||||
|
- Phaser is imported only via `js/vendor/phaser.js` (one-file version swap).
|
||||||
|
|
||||||
|
## Dev tools
|
||||||
|
|
||||||
|
```sh
|
||||||
|
node dev/ship-behavior.test.mjs # runs the real Ship.update() loop in Node
|
||||||
|
```
|
||||||
|
|
||||||
|
`dev/test-game.html` boots straight into the GameScene (no menu click),
|
||||||
|
handy for manual testing of the flight feel.
|
||||||
|
|
||||||
|
## Phaser
|
||||||
|
|
||||||
|
Phaser 4.2.1 is vendored at `lib/phaser.min.js` (UMD build, MIT license —
|
||||||
|
see `lib/PHASER_LICENSE.md`). No internet or npm needed at runtime.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
{
|
||||||
|
"name": "Orbit",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"width": 1280,
|
||||||
|
"height": 720,
|
||||||
|
"backgroundColor": "#04060d",
|
||||||
|
"hintText": "click anywhere to fly",
|
||||||
|
"markerColor": "#41c7ff",
|
||||||
|
"physics": {
|
||||||
|
"default": "arcade"
|
||||||
|
},
|
||||||
|
"debug": false,
|
||||||
|
"starfield": {
|
||||||
|
"enabled": true,
|
||||||
|
"count": 180,
|
||||||
|
"driftSpeed": 14,
|
||||||
|
"colors": ["#ffffff", "#b9c6ff", "#8090b8"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"files": [
|
||||||
|
"game.json",
|
||||||
|
"menu.json",
|
||||||
|
"ship.json"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
{
|
||||||
|
"title": "ORBIT",
|
||||||
|
"titleFontSize": 84,
|
||||||
|
"subtitle": "a procedural space rpg",
|
||||||
|
"subtitleFontSize": 20,
|
||||||
|
"fontFamily": "'Segoe UI', 'Helvetica Neue', Arial, sans-serif",
|
||||||
|
"buttonFontSize": 24,
|
||||||
|
"colors": {
|
||||||
|
"title": "#e9edf8",
|
||||||
|
"titleGlow": "#2f6df6",
|
||||||
|
"subtitle": "#8fa0c9",
|
||||||
|
"buttonText": "#e9edf8",
|
||||||
|
"buttonBg": "#16203a",
|
||||||
|
"buttonHoverBg": "#24345c",
|
||||||
|
"buttonBorder": "#33456f",
|
||||||
|
"footer": "#54608a"
|
||||||
|
},
|
||||||
|
"buttons": {
|
||||||
|
"newGame": {
|
||||||
|
"label": "New Game",
|
||||||
|
"position": { "x": 0.5, "y": 0.62 },
|
||||||
|
"fontSize": 24
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"size": 46,
|
||||||
|
"color": "#dfe7ff",
|
||||||
|
"cockpitColor": "#41c7ff",
|
||||||
|
"scale": 1,
|
||||||
|
"thrust": 900,
|
||||||
|
"maxSpeed": 480,
|
||||||
|
"drag": 2.4,
|
||||||
|
"rotSpeed": 10,
|
||||||
|
"brakeDistance": 260,
|
||||||
|
"arriveRadius": 8,
|
||||||
|
"arriveSpeed": 50
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,176 @@
|
||||||
|
/**
|
||||||
|
* Ship behavior test (dev tool, run with Node — no browser needed):
|
||||||
|
*
|
||||||
|
* node dev/ship-behavior.test.mjs
|
||||||
|
*
|
||||||
|
* Stubs just enough of Phaser + a scene to run the REAL Ship.update()
|
||||||
|
* loop from js/entities/Ship.js, then asserts the flight feel:
|
||||||
|
* arrives and stops, respects maxSpeed, tracks its heading, can be
|
||||||
|
* re-targeted mid-flight, coasts to rest, hard-brakes on stop().
|
||||||
|
*
|
||||||
|
* The harness integrates acceleration→velocity→position the way the
|
||||||
|
* Arcade physics world does (after the scene's update).
|
||||||
|
*/
|
||||||
|
import { pathToFileURL } from 'node:url';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const TAU = Math.PI * 2;
|
||||||
|
const wrap = (a) => ((a % TAU) + TAU) % TAU;
|
||||||
|
|
||||||
|
class V2 {
|
||||||
|
constructor(x = 0, y = 0) { this.x = x; this.y = y; }
|
||||||
|
set(x, y) { this.x = x; this.y = y; return this; }
|
||||||
|
length() { return Math.hypot(this.x, this.y); }
|
||||||
|
scale(s) { this.x *= s; this.y *= s; return this; }
|
||||||
|
}
|
||||||
|
|
||||||
|
class Sprite {
|
||||||
|
constructor(scene, x, y, key) {
|
||||||
|
this.scene = scene; this.x = x; this.y = y; this.key = key;
|
||||||
|
this.rotation = 0; this.scaleX = 1; this.scaleY = 1; this.body = null;
|
||||||
|
}
|
||||||
|
setCollideWorldBounds() { return this; }
|
||||||
|
setScale(s) { this.scaleX = this.scaleY = s; return this; }
|
||||||
|
}
|
||||||
|
|
||||||
|
const graphicsStub = {
|
||||||
|
fillStyle() {}, beginPath() {}, moveTo() {}, lineTo() {}, closePath() {},
|
||||||
|
fillPath() {}, fillCircle() {}, generateTexture() {}, destroy() {},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mirror of Phaser.Math.Angle.RotateTo (radians, step-based, shortest path).
|
||||||
|
const rotateTo = (cur, tgt, step) => {
|
||||||
|
let diff = wrap(tgt - cur);
|
||||||
|
if (diff > Math.PI) diff -= TAU;
|
||||||
|
if (Math.abs(diff) <= step) return tgt;
|
||||||
|
return cur + Math.sign(diff) * step;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PhaserStub = {
|
||||||
|
Physics: { Arcade: { Sprite } },
|
||||||
|
Math: {
|
||||||
|
Clamp: (v, min, max) => Math.max(min, Math.min(max, v)),
|
||||||
|
Angle: { Wrap: wrap, RotateTo: rotateTo },
|
||||||
|
},
|
||||||
|
Display: { Color: { ValueToColor: (v) => ({ color: parseInt(v.slice(1), 16) }) } },
|
||||||
|
};
|
||||||
|
globalThis.window = { Phaser: PhaserStub }; // js/vendor/phaser.js reads this
|
||||||
|
|
||||||
|
const scene = {
|
||||||
|
textures: { exists: () => true },
|
||||||
|
make: { graphics: () => graphicsStub },
|
||||||
|
add: { existing: (o) => o },
|
||||||
|
physics: {
|
||||||
|
add: {
|
||||||
|
existing: (o) => { o.body = { velocity: new V2(), acceleration: new V2() }; return o; },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scale: { width: 1280, height: 720 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const { Ship } = await import(
|
||||||
|
pathToFileURL(join(__dirname, '../js/entities/Ship.js')).href
|
||||||
|
);
|
||||||
|
|
||||||
|
// Use the real data/*.json config (ship feel comes from data/ship.json).
|
||||||
|
const { config } = await import(pathToFileURL(join(__dirname, '../js/config/Config.js')).href);
|
||||||
|
const fs = await import('node:fs');
|
||||||
|
const dataDir = join(__dirname, '../data');
|
||||||
|
const configData = {};
|
||||||
|
for (const f of fs.readdirSync(dataDir)) {
|
||||||
|
if (!f.endsWith('.json') || f === 'manifest.json') continue;
|
||||||
|
configData[f.replace(/\.json$/i, '')] = JSON.parse(fs.readFileSync(join(dataDir, f), 'utf8'));
|
||||||
|
}
|
||||||
|
config.init(configData);
|
||||||
|
console.log('config loaded from data/:', Object.keys(configData).join(', '));
|
||||||
|
|
||||||
|
let failures = 0;
|
||||||
|
const check = (label, cond) => {
|
||||||
|
console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`);
|
||||||
|
if (!cond) failures++;
|
||||||
|
};
|
||||||
|
|
||||||
|
const dt = 16.67;
|
||||||
|
// Physics-world step: acceleration → velocity → position.
|
||||||
|
const integrate = (ship) => {
|
||||||
|
const s = dt / 1000;
|
||||||
|
ship.body.velocity.x += ship.body.acceleration.x * s;
|
||||||
|
ship.body.velocity.y += ship.body.acceleration.y * s;
|
||||||
|
ship.x += ship.body.velocity.x * s;
|
||||||
|
ship.y += ship.body.velocity.y * s;
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Test 1: fly to a point, arrive and stop --------------------------------
|
||||||
|
{
|
||||||
|
const ship = new Ship(scene, 640, 360);
|
||||||
|
const target = { x: 1000, y: 300 };
|
||||||
|
ship.setTarget(target.x, target.y);
|
||||||
|
let maxSpeedSeen = 0;
|
||||||
|
let arrived = false;
|
||||||
|
for (let t = 0; t < 60 * 30; t++) {
|
||||||
|
ship.update(t * dt, dt);
|
||||||
|
integrate(ship);
|
||||||
|
maxSpeedSeen = Math.max(maxSpeedSeen, ship.body.velocity.length());
|
||||||
|
if (
|
||||||
|
Math.hypot(ship.x - target.x, ship.y - target.y) <= 8 &&
|
||||||
|
ship.body.velocity.length() <= 50 &&
|
||||||
|
ship.target === null
|
||||||
|
) { arrived = true; break; }
|
||||||
|
}
|
||||||
|
const distTo = Math.hypot(ship.x - target.x, ship.y - target.y);
|
||||||
|
check('arrives at target and stops (target cleared)', arrived);
|
||||||
|
check(`final position within arrive radius (dist=${distTo.toFixed(2)})`, distTo <= 8.5);
|
||||||
|
check('velocity is exactly zero on arrival', ship.body.velocity.length() === 0);
|
||||||
|
check(`speed never exceeded maxSpeed (max=${maxSpeedSeen.toFixed(1)} / cap ${ship.maxSpeed})`,
|
||||||
|
maxSpeedSeen <= ship.maxSpeed * 1.05 + 0.001);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Test 2: heading tracks the direction of travel -------------------------
|
||||||
|
{
|
||||||
|
const ship = new Ship(scene, 200, 200);
|
||||||
|
ship.rotation = 2.5; // start facing the wrong way
|
||||||
|
ship.setTarget(600, 200); // fly straight right
|
||||||
|
for (let t = 0; t < 60; t++) { ship.update(t * dt, dt); integrate(ship); }
|
||||||
|
let hd = wrap(ship.rotation); if (hd > Math.PI) hd -= TAU;
|
||||||
|
check(`rotates toward direction of travel (rotation=${ship.rotation.toFixed(3)} rad, want ~0)`,
|
||||||
|
Math.abs(hd) < 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Test 3: re-target mid-flight -------------------------------------------
|
||||||
|
{
|
||||||
|
const ship = new Ship(scene, 100, 100);
|
||||||
|
ship.setTarget(900, 100);
|
||||||
|
for (let t = 0; t < 30; t++) { ship.update(t * dt, dt); integrate(ship); }
|
||||||
|
ship.setTarget(100, 500);
|
||||||
|
for (let t = 30; t < 30 + 60 * 20; t++) { ship.update(t * dt, dt); integrate(ship); }
|
||||||
|
const d = Math.hypot(ship.x - 100, ship.y - 500);
|
||||||
|
check(`re-target mid-flight reaches new point (dist=${d.toFixed(2)})`, d <= 8.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Test 4: coasting drift decays to rest ----------------------------------
|
||||||
|
{
|
||||||
|
const ship = new Ship(scene, 300, 300);
|
||||||
|
ship.body.velocity.set(200, 0);
|
||||||
|
let stopped = false;
|
||||||
|
for (let t = 0; t < 60 * 10; t++) {
|
||||||
|
ship.update(t * dt, dt);
|
||||||
|
integrate(ship);
|
||||||
|
if (ship.body.velocity.length() < 1) { stopped = true; break; }
|
||||||
|
}
|
||||||
|
check('coasting drift decays to rest', stopped);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Test 5: stop() hard-brakes ---------------------------------------------
|
||||||
|
{
|
||||||
|
const ship = new Ship(scene, 300, 300);
|
||||||
|
ship.setTarget(800, 300);
|
||||||
|
for (let t = 0; t < 10; t++) { ship.update(t * dt, dt); integrate(ship); }
|
||||||
|
ship.stop();
|
||||||
|
check('stop() clears target and zeroes velocity',
|
||||||
|
ship.target === null && ship.body.velocity.length() === 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(failures === 0 ? '\nAll ship behavior tests passed ✔' : `\n${failures} test(s) FAILED ✘`);
|
||||||
|
process.exit(failures === 0 ? 0 : 1);
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
/**
|
||||||
|
* Dev-only smoke test: boots the game directly into the GameScene
|
||||||
|
* (skipping the menu), so the flight scene can be screenshotted/checked
|
||||||
|
* without clicking "New Game".
|
||||||
|
*
|
||||||
|
* python3 -m http.server 8080
|
||||||
|
* firefox --headless --screenshot shot.png \
|
||||||
|
* --window-size=1280,720 http://localhost:8080/dev/test-game.html
|
||||||
|
*/
|
||||||
|
import Phaser from '../js/vendor/phaser.js';
|
||||||
|
import { config } from '../js/config/Config.js';
|
||||||
|
import { ConfigLoader } from '../js/config/ConfigLoader.js';
|
||||||
|
import { createGameConfig } from '../js/config/GameConfig.js';
|
||||||
|
import { GameScene } from '../js/scenes/GameScene.js';
|
||||||
|
|
||||||
|
const data = await ConfigLoader.load();
|
||||||
|
config.init(data);
|
||||||
|
const gameConfig = createGameConfig();
|
||||||
|
gameConfig.scene = [GameScene];
|
||||||
|
const game = new Phaser.Game(gameConfig);
|
||||||
|
window.game = game;
|
||||||
|
console.info('smoke: game booted into GameScene');
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Orbit — dev smoke test (GameScene)</title>
|
||||||
|
<style>
|
||||||
|
html, body { margin: 0; height: 100%; background: #04060d; overflow: hidden; }
|
||||||
|
#game { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; }
|
||||||
|
</style>
|
||||||
|
<script src="../lib/phaser.min.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="game"></div>
|
||||||
|
<script type="module" src="smoke-game.mjs"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
# Orbit — Project Notes
|
||||||
|
|
||||||
|
Working agreements and conventions for building this game. Read this before
|
||||||
|
adding new systems — these are the rules that keep the project scalable.
|
||||||
|
|
||||||
|
## What we're building
|
||||||
|
|
||||||
|
A procedurally generated space RPG in the spirit of **Privateer**, but in
|
||||||
|
**top-down 2D**: stars/sectors you can jump between, ships to fly, crew,
|
||||||
|
trading/economy, stations, quests — to be scoped as we go.
|
||||||
|
|
||||||
|
**Hard technical constraints:**
|
||||||
|
|
||||||
|
- Plain **ES6 modules**, no transpilation, no build step.
|
||||||
|
- **No package managers required to run** — the game must work from any
|
||||||
|
static HTTP server (the browser fetches everything).
|
||||||
|
- Third-party libraries are **vendored** into `lib/` (right now:
|
||||||
|
`lib/phaser.min.js`, Phaser 4.2.1 UMD build, see `lib/PHASER_LICENSE.md`).
|
||||||
|
- Must be served over http(s), not `file://` (ES modules + `fetch` of JSON).
|
||||||
|
|
||||||
|
## Config lives in JSON (important)
|
||||||
|
|
||||||
|
- Tunable data lives in `data/*.json`: dimensions, colors, text, physics,
|
||||||
|
balance, spawn tables, and anything a non-programmer might want to tweak.
|
||||||
|
- `data/manifest.json` lists which files to load. **Add a config file =
|
||||||
|
drop it in `data/` + one line in the manifest.** It is then available as
|
||||||
|
a section named after the file (`ship.json` → `config.get('ship.thrust')`).
|
||||||
|
- Code reads config through the `config` singleton (`js/config/Config.js`):
|
||||||
|
```js
|
||||||
|
import { config } from '../config/Config.js';
|
||||||
|
const thrust = config.get('ship.thrust', 900);
|
||||||
|
const menu = config.section('menu', {});
|
||||||
|
```
|
||||||
|
Always supply a sensible fallback so code never depends on a missing key.
|
||||||
|
- **Rule of thumb:** if a value might ever change (balance, layout, copy,
|
||||||
|
colors), it belongs in JSON, not code. Code owns *behavior*, JSON owns
|
||||||
|
*parameters*.
|
||||||
|
- Split config by concern as the game grows: `data/sectors.json` for world
|
||||||
|
generation, `data/economy.json`, `data/ships.json`, `data/crew.json`, etc.
|
||||||
|
One file per system beats one giant file.
|
||||||
|
|
||||||
|
## Code is modular & class-based (important)
|
||||||
|
|
||||||
|
- One class per file, ES module exports, no globals (except the deliberate
|
||||||
|
singletons: `config`).
|
||||||
|
- Layering:
|
||||||
|
- `js/scenes/` — Phaser scenes: state + orchestration only (thin classes)
|
||||||
|
- `js/entities/` — in-world objects that own their behavior (Ship, NPC, …)
|
||||||
|
- `js/ui/` — reusable UI components (MenuButton, panels, HUD)
|
||||||
|
- `js/visuals/` — decorative, non-interactive visuals (Starfield, …)
|
||||||
|
- `js/utils/` — small pure helpers (Color, math, rng)
|
||||||
|
- `js/config/` — config loading + Phaser game config
|
||||||
|
- `js/vendor/` — shims to pinned third-party libraries
|
||||||
|
- Scenes stay thin: they compose entities/UI and wire input. They don't hold
|
||||||
|
balance numbers or game rules.
|
||||||
|
- Every class should be standalone-constructible: it takes what it needs
|
||||||
|
(`scene`, config values) in its constructor rather than reaching for
|
||||||
|
globals — that keeps things testable and reusable.
|
||||||
|
- Entities drive themselves from their own `update(time, delta)` method,
|
||||||
|
called by the owning scene (e.g. `GameScene.update`). If an entity ever
|
||||||
|
needs the engine's auto-update instead, define `preUpdate` (the Phaser v4
|
||||||
|
hook — v4 does not auto-call `update`).
|
||||||
|
- Import Phaser only through `js/vendor/phaser.js` — the single place that
|
||||||
|
changes if we swap framework versions.
|
||||||
|
|
||||||
|
## Phaser version
|
||||||
|
|
||||||
|
- Pinned: **Phaser 4.2.1** ("Giedi"), vendored in `lib/phaser.min.js`.
|
||||||
|
- App code is v4-specific where v4 changed things (e.g.
|
||||||
|
`Phaser.Math.Angle.RotateTo`, `banner: false`, `Clamp(value, min, max)`).
|
||||||
|
- To upgrade: replace the vendored file + note the version here.
|
||||||
|
|
||||||
|
## Roadmap (working list, intentionally rough)
|
||||||
|
|
||||||
|
- [x] v0.1 foundation — menu → New Game → click-to-fly ship
|
||||||
|
- [ ] Decide the world model: bounded sectors vs. infinite space (affects
|
||||||
|
camera, starfield, and world gen)
|
||||||
|
- [ ] Procedural star map / sector generation (driven by `data/sectors.json`)
|
||||||
|
- [ ] Ship input beyond click-to-fly (throttle/brake keys, manual rotation)
|
||||||
|
- [ ] HUD (speed, sector name, later: fuel/crew)
|
||||||
|
- [ ] Save/load (the `config` + entity split should make this tractable)
|
||||||
|
- [ ] Economy/trading loop (the Privateer heart)
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Orbit</title>
|
||||||
|
<style>
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
height: 100%;
|
||||||
|
background: #04060d;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
#game {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
#game canvas {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<!-- Vendored Phaser (UMD build, sets window.Phaser). See lib/PHASER_LICENSE.md -->
|
||||||
|
<script src="lib/phaser.min.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="game"></div>
|
||||||
|
<!-- App code is plain ES6 modules -->
|
||||||
|
<script type="module" src="js/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
/**
|
||||||
|
* Global config singleton.
|
||||||
|
*
|
||||||
|
* Initialized exactly once in main.js from the JSON files in /data,
|
||||||
|
* then any module in the game can import it and read values:
|
||||||
|
*
|
||||||
|
* import { config } from '../config/Config.js';
|
||||||
|
* const thrust = config.get('ship.thrust', 900);
|
||||||
|
* const menu = config.section('menu', {});
|
||||||
|
*
|
||||||
|
* Conventions (see docs/PROJECT_NOTES.md):
|
||||||
|
* - tunable values live in data/*.json, never hardcoded in code;
|
||||||
|
* - add a new data file by listing it in data/manifest.json.
|
||||||
|
*/
|
||||||
|
class Config {
|
||||||
|
constructor() {
|
||||||
|
this.data = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {Record<string, any>} data keyed by config file name (no .json) */
|
||||||
|
init(data) {
|
||||||
|
this.data = data ?? {};
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {boolean} true if a top-level section (a file in /data) exists */
|
||||||
|
has(name) {
|
||||||
|
return Object.prototype.hasOwnProperty.call(this.data, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {object} the whole section as a plain object */
|
||||||
|
section(name, fallback = {}) {
|
||||||
|
const value = this.data[name];
|
||||||
|
return (value && typeof value === 'object') ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dotted-path getter: config.get('menu.buttons.newGame.label', 'New Game')
|
||||||
|
* @returns {*} the value at `path`, or `fallback` if any segment is missing
|
||||||
|
*/
|
||||||
|
get(path, fallback = undefined) {
|
||||||
|
if (!path) return fallback;
|
||||||
|
let node = this.data;
|
||||||
|
for (const part of String(path).split('.')) {
|
||||||
|
if (node == null || typeof node !== 'object') return fallback;
|
||||||
|
node = node[part];
|
||||||
|
}
|
||||||
|
return node === undefined ? fallback : node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = new Config();
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
/**
|
||||||
|
* Loads the config manifest and every JSON file it references.
|
||||||
|
*
|
||||||
|
* data/manifest.json: { "files": ["game.json", "menu.json", "ship.json"] }
|
||||||
|
*
|
||||||
|
* Adding a config file = drop the .json in /data + one line in the manifest.
|
||||||
|
* Each file becomes a section keyed by its file name (without .json), so
|
||||||
|
* `ship.json` is available as `config.get('ship.thrust')`.
|
||||||
|
*/
|
||||||
|
export class ConfigLoader {
|
||||||
|
static async load(manifestPath = 'data/manifest.json') {
|
||||||
|
const manifest = await this.fetchJson(manifestPath);
|
||||||
|
const files = Array.isArray(manifest?.files) ? manifest.files : [];
|
||||||
|
// Resolve config files relative to the manifest's own location,
|
||||||
|
// so this works with relative or absolute manifest paths.
|
||||||
|
const base = manifestPath.replace(/[^/]*$/, '');
|
||||||
|
|
||||||
|
const data = {};
|
||||||
|
for (const file of files) {
|
||||||
|
const name = file.split('/').pop().replace(/\.json$/i, '');
|
||||||
|
data[name] = await this.fetchJson(base + file);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async fetchJson(path) {
|
||||||
|
const res = await fetch(path);
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Could not load config "${path}" (${res.status} ${res.statusText})`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
import Phaser from '../vendor/phaser.js';
|
||||||
|
import { config } from './Config.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the Phaser game config from data/game.json.
|
||||||
|
* Scenes are attached in main.js (createGameConfig() + .scene = [...]).
|
||||||
|
*/
|
||||||
|
export function createGameConfig() {
|
||||||
|
const g = config.section('game');
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: Phaser.AUTO,
|
||||||
|
parent: g.parent ?? 'game', // element id in index.html
|
||||||
|
width: g.width ?? 1280,
|
||||||
|
height: g.height ?? 720,
|
||||||
|
backgroundColor: g.backgroundColor ?? '#04060d',
|
||||||
|
banner: false,
|
||||||
|
scale: {
|
||||||
|
mode: Phaser.Scale.FIT,
|
||||||
|
autoCenter: Phaser.Scale.CENTER_BOTH,
|
||||||
|
},
|
||||||
|
physics: {
|
||||||
|
default: g.physics?.default ?? 'arcade',
|
||||||
|
arcade: {
|
||||||
|
debug: g.debug ?? false,
|
||||||
|
gravity: { x: 0, y: 0 }, // we're in space
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,125 @@
|
||||||
|
import Phaser from '../vendor/phaser.js';
|
||||||
|
import { config } from '../config/Config.js';
|
||||||
|
import { toColor } from '../utils/Color.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The player's ship: an arcade-physics sprite that flies to wherever you click.
|
||||||
|
*
|
||||||
|
* All feel/balance numbers come from data/ship.json:
|
||||||
|
* - thrust, maxSpeed, drag → how it accelerates and coasts
|
||||||
|
* - rotSpeed → how fast it turns toward its heading (rad/s)
|
||||||
|
* - brakeDistance → where it starts easing off the throttle
|
||||||
|
* - arriveRadius / arriveSpeed → when it considers itself "arrived"
|
||||||
|
*/
|
||||||
|
export class Ship extends Phaser.Physics.Arcade.Sprite {
|
||||||
|
static TEXTURE_KEY = '__ship';
|
||||||
|
|
||||||
|
/** Draws the ship texture once per game (procedural, no assets). */
|
||||||
|
static ensureTexture(scene) {
|
||||||
|
if (scene.textures.exists(Ship.TEXTURE_KEY)) return;
|
||||||
|
|
||||||
|
const size = config.get('ship.size', 46);
|
||||||
|
const W = size;
|
||||||
|
const H = Math.round(size * 0.68);
|
||||||
|
const hull = toColor(config.get('ship.color', '#dfe7ff'));
|
||||||
|
const cockpit = toColor(config.get('ship.cockpitColor', '#41c7ff'));
|
||||||
|
|
||||||
|
// A simple dart, pointing right (angle 0).
|
||||||
|
const g = scene.make.graphics({ add: false });
|
||||||
|
g.fillStyle(hull, 1);
|
||||||
|
g.beginPath();
|
||||||
|
g.moveTo(W - 4, H / 2); // nose
|
||||||
|
g.lineTo(4, 3); // top rear
|
||||||
|
g.lineTo(12, H / 2); // tail notch
|
||||||
|
g.lineTo(4, H - 3); // bottom rear
|
||||||
|
g.closePath();
|
||||||
|
g.fillPath();
|
||||||
|
g.fillStyle(cockpit, 1);
|
||||||
|
g.fillCircle(Math.round(W - W * 0.3), H / 2, Math.max(3, Math.round(W * 0.085)));
|
||||||
|
g.generateTexture(Ship.TEXTURE_KEY, W, H);
|
||||||
|
g.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(scene, x, y) {
|
||||||
|
Ship.ensureTexture(scene);
|
||||||
|
super(scene, x, y, Ship.TEXTURE_KEY);
|
||||||
|
|
||||||
|
scene.add.existing(this);
|
||||||
|
scene.physics.add.existing(this);
|
||||||
|
this.setCollideWorldBounds(true);
|
||||||
|
|
||||||
|
// Tuning (data/ship.json) -----------------------------------------
|
||||||
|
this.thrust = config.get('ship.thrust', 900); // px/s^2
|
||||||
|
this.maxSpeed = config.get('ship.maxSpeed', 480); // px/s
|
||||||
|
this.drag = config.get('ship.drag', 2.4); // 1/s, exponential decay
|
||||||
|
this.rotSpeed = config.get('ship.rotSpeed', 10); // rad/s
|
||||||
|
this.brakeDistance = config.get('ship.brakeDistance', 260); // px
|
||||||
|
this.arriveRadius = config.get('ship.arriveRadius', 8); // px
|
||||||
|
this.arriveSpeed = config.get('ship.arriveSpeed', 50); // px/s
|
||||||
|
this.setScale(config.get('ship.scale', 1));
|
||||||
|
|
||||||
|
this.target = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set the destination to fly to (world coordinates). */
|
||||||
|
setTarget(x, y) {
|
||||||
|
this.target = { x, y };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stop steering and brake immediately. */
|
||||||
|
stop() {
|
||||||
|
this.target = null;
|
||||||
|
this.body.acceleration.set(0, 0);
|
||||||
|
this.body.velocity.set(0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(_time, delta) {
|
||||||
|
const body = this.body;
|
||||||
|
if (!body) return;
|
||||||
|
const dt = Math.min(delta, 64) / 1000;
|
||||||
|
|
||||||
|
if (this.target) {
|
||||||
|
const dx = this.target.x - this.x;
|
||||||
|
const dy = this.target.y - this.y;
|
||||||
|
const dist = Math.hypot(dx, dy);
|
||||||
|
const speed = body.velocity.length();
|
||||||
|
|
||||||
|
// Arrived: close enough and slow enough → stop cleanly.
|
||||||
|
if (dist <= this.arriveRadius && speed <= this.arriveSpeed) {
|
||||||
|
this.target = null;
|
||||||
|
// Clear acceleration too, so the physics step can't re-kick us.
|
||||||
|
body.acceleration.set(0, 0);
|
||||||
|
body.velocity.set(0, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nx = dx / dist;
|
||||||
|
const ny = dy / dist;
|
||||||
|
|
||||||
|
// Ease off the throttle as we close in, so we arrive gently.
|
||||||
|
const throttle = Phaser.Math.Clamp(dist / this.brakeDistance, 0.15, 1);
|
||||||
|
body.acceleration.set(nx * this.thrust * throttle, ny * this.thrust * throttle);
|
||||||
|
|
||||||
|
// Gentle drag so we never coast forever.
|
||||||
|
body.velocity.scale(Math.max(0, 1 - this.drag * dt));
|
||||||
|
|
||||||
|
// Hard speed cap.
|
||||||
|
const v = body.velocity.length();
|
||||||
|
if (v > this.maxSpeed) {
|
||||||
|
body.velocity.scale(this.maxSpeed / v);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rotate toward the direction of travel (shortest path).
|
||||||
|
const current = Phaser.Math.Angle.Wrap(this.rotation);
|
||||||
|
const wanted = Phaser.Math.Angle.Wrap(Math.atan2(ny, nx));
|
||||||
|
const step = Math.min(Math.abs(wanted - current), this.rotSpeed * dt);
|
||||||
|
this.rotation = Phaser.Math.Angle.RotateTo(current, wanted, step);
|
||||||
|
} else {
|
||||||
|
body.acceleration.set(0, 0);
|
||||||
|
// Inertial drift, decaying smoothly.
|
||||||
|
if (body.velocity.length() > 0.5) {
|
||||||
|
body.velocity.scale(Math.max(0, 1 - this.drag * dt));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
import Phaser from './vendor/phaser.js';
|
||||||
|
import { config } from './config/Config.js';
|
||||||
|
import { ConfigLoader } from './config/ConfigLoader.js';
|
||||||
|
import { createGameConfig } from './config/GameConfig.js';
|
||||||
|
import { MenuScene } from './scenes/MenuScene.js';
|
||||||
|
import { GameScene } from './scenes/GameScene.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Orbit — entry point.
|
||||||
|
*
|
||||||
|
* 1. Load config JSON (data/manifest.json → data/*.json)
|
||||||
|
* 2. Initialize the global config singleton
|
||||||
|
* 3. Build the Phaser game config and attach the scenes
|
||||||
|
* 4. Boot
|
||||||
|
*/
|
||||||
|
async function boot() {
|
||||||
|
const data = await ConfigLoader.load();
|
||||||
|
config.init(data);
|
||||||
|
|
||||||
|
const gameConfig = createGameConfig();
|
||||||
|
gameConfig.scene = [MenuScene, GameScene];
|
||||||
|
|
||||||
|
const game = new Phaser.Game(gameConfig);
|
||||||
|
|
||||||
|
// Handy for console debugging.
|
||||||
|
window.game = game;
|
||||||
|
window.config = config;
|
||||||
|
console.info(
|
||||||
|
`%c${config.get('game.name', 'Orbit')} %c${config.get('game.version', '')}`,
|
||||||
|
'font-weight:bold;font-size:14px',
|
||||||
|
'color:#8fa0c9',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
boot().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
const pre = document.createElement('pre');
|
||||||
|
pre.style.cssText =
|
||||||
|
'color:#ff9b9b;font:14px/1.6 monospace;padding:2rem;white-space:pre-wrap;margin:0';
|
||||||
|
pre.textContent = `Failed to start Orbit:\n${(err && (err.stack || err.message)) || err}`;
|
||||||
|
document.body.appendChild(pre);
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
import Phaser from '../vendor/phaser.js';
|
||||||
|
import { config } from '../config/Config.js';
|
||||||
|
import { toColor } from '../utils/Color.js';
|
||||||
|
import { Ship } from '../entities/Ship.js';
|
||||||
|
import { Starfield } from '../visuals/Starfield.js';
|
||||||
|
|
||||||
|
const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The game world (v0.1: one ship in open space).
|
||||||
|
* Click anywhere to fly there.
|
||||||
|
*/
|
||||||
|
export class GameScene extends Phaser.Scene {
|
||||||
|
constructor() {
|
||||||
|
super({ key: 'GameScene' });
|
||||||
|
}
|
||||||
|
|
||||||
|
create() {
|
||||||
|
// Background
|
||||||
|
this.starfield = new Starfield(this);
|
||||||
|
this.starfield.create();
|
||||||
|
|
||||||
|
// The ship
|
||||||
|
this.ship = new Ship(this, this.scale.width / 2, this.scale.height / 2);
|
||||||
|
this.ship.setDepth(10);
|
||||||
|
|
||||||
|
// Hint
|
||||||
|
this.hint = this.add
|
||||||
|
.text(this.scale.width / 2, this.scale.height - 26, config.get('game.hintText', ''), {
|
||||||
|
fontFamily: FONT_FALLBACK,
|
||||||
|
fontSize: '14px',
|
||||||
|
color: '#54608a',
|
||||||
|
})
|
||||||
|
.setOrigin(0.5);
|
||||||
|
|
||||||
|
// Input: click = fly there
|
||||||
|
this.input.on('pointerdown', (pointer) => {
|
||||||
|
this.showTargetMarker(pointer.worldX, pointer.worldY);
|
||||||
|
this.ship.setTarget(pointer.worldX, pointer.worldY);
|
||||||
|
this.hideHint();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
update(_time, delta) {
|
||||||
|
this.starfield.update(delta);
|
||||||
|
this.ship.update(_time, delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
showTargetMarker(x, y) {
|
||||||
|
const color = toColor(config.get('game.markerColor', '#41c7ff'));
|
||||||
|
const marker = this.add.circle(x, y, 10, color, 0.8).setDepth(5);
|
||||||
|
this.tweens.add({
|
||||||
|
targets: marker,
|
||||||
|
scale: 2.4,
|
||||||
|
alpha: 0,
|
||||||
|
duration: 450,
|
||||||
|
ease: 'Sine.easeOut',
|
||||||
|
onComplete: () => marker.destroy(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
hideHint() {
|
||||||
|
if (!this.hint || !this.hint.active) return;
|
||||||
|
this.tweens.add({
|
||||||
|
targets: this.hint,
|
||||||
|
alpha: 0,
|
||||||
|
duration: 400,
|
||||||
|
onComplete: () => {
|
||||||
|
this.hint.destroy();
|
||||||
|
this.hint = null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
shutdown() {
|
||||||
|
this.starfield?.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
import Phaser from '../vendor/phaser.js';
|
||||||
|
import { config } from '../config/Config.js';
|
||||||
|
import { MenuButton } from '../ui/MenuButton.js';
|
||||||
|
|
||||||
|
const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main menu. All text/colors/layout come from data/menu.json.
|
||||||
|
*/
|
||||||
|
export class MenuScene extends Phaser.Scene {
|
||||||
|
constructor() {
|
||||||
|
super({ key: 'MenuScene' });
|
||||||
|
}
|
||||||
|
|
||||||
|
create() {
|
||||||
|
const menu = config.section('menu', {});
|
||||||
|
const fontFamily = menu.fontFamily ?? FONT_FALLBACK;
|
||||||
|
const colors = menu.colors ?? {};
|
||||||
|
const { width, height } = this.scale;
|
||||||
|
const cx = width / 2;
|
||||||
|
|
||||||
|
// Title
|
||||||
|
this.add
|
||||||
|
.text(cx, height * 0.34, menu.title ?? 'ORBIT', {
|
||||||
|
fontFamily,
|
||||||
|
fontSize: `${menu.titleFontSize ?? 84}px`,
|
||||||
|
fontStyle: 'bold',
|
||||||
|
color: colors.title ?? '#e9edf8',
|
||||||
|
shadow: {
|
||||||
|
color: colors.titleGlow ?? '#2f6df6',
|
||||||
|
blur: 28,
|
||||||
|
offsetX: 0,
|
||||||
|
offsetY: 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.setOrigin(0.5);
|
||||||
|
|
||||||
|
// Subtitle
|
||||||
|
this.add
|
||||||
|
.text(cx, height * 0.46, menu.subtitle ?? '', {
|
||||||
|
fontFamily,
|
||||||
|
fontSize: `${menu.subtitleFontSize ?? 20}px`,
|
||||||
|
color: colors.subtitle ?? '#8fa0c9',
|
||||||
|
})
|
||||||
|
.setOrigin(0.5);
|
||||||
|
|
||||||
|
// Buttons (menu.json lists each one; add more here as the menu grows)
|
||||||
|
const btn = menu.buttons?.newGame ?? {};
|
||||||
|
new MenuButton(
|
||||||
|
this,
|
||||||
|
(btn.position?.x ?? 0.5) * width,
|
||||||
|
(btn.position?.y ?? 0.62) * height,
|
||||||
|
btn.label ?? 'New Game',
|
||||||
|
() => this.scene.start('GameScene'),
|
||||||
|
btn,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Version footer
|
||||||
|
this.add
|
||||||
|
.text(width - 16, height - 14, config.get('game.version', ''), {
|
||||||
|
fontFamily,
|
||||||
|
fontSize: '12px',
|
||||||
|
color: colors.footer ?? '#54608a',
|
||||||
|
})
|
||||||
|
.setOrigin(1, 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
import Phaser from '../vendor/phaser.js';
|
||||||
|
import { config } from '../config/Config.js';
|
||||||
|
import { toColor } from '../utils/Color.js';
|
||||||
|
|
||||||
|
const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reusable text button with hover state.
|
||||||
|
*
|
||||||
|
* Colors/sizes come from data/menu.json (menu.colors + per-button overrides),
|
||||||
|
* so adding a new button elsewhere is a one-liner:
|
||||||
|
*
|
||||||
|
* new MenuButton(scene, x, y, 'Save', () => ..., { bgColor: '#334' });
|
||||||
|
*/
|
||||||
|
export class MenuButton extends Phaser.GameObjects.Container {
|
||||||
|
constructor(scene, x, y, label, onClick, overrides = {}) {
|
||||||
|
super(scene, x, y);
|
||||||
|
|
||||||
|
const colors = config.section('menu.colors', {});
|
||||||
|
const fontFamily = overrides.fontFamily ?? config.get('menu.fontFamily', FONT_FALLBACK);
|
||||||
|
const fontSize = overrides.fontSize ?? config.get('menu.buttonFontSize', 22);
|
||||||
|
const paddingX = overrides.paddingX ?? 32;
|
||||||
|
const paddingY = overrides.paddingY ?? 16;
|
||||||
|
|
||||||
|
const baseColor = toColor(overrides.bgColor ?? colors.buttonBg ?? '#16203a');
|
||||||
|
const hoverColor = toColor(overrides.hoverColor ?? colors.buttonHoverBg ?? '#24345c');
|
||||||
|
const border = toColor(colors.buttonBorder ?? '#33456f');
|
||||||
|
const textColor = overrides.textColor ?? colors.buttonText ?? '#e9edf8';
|
||||||
|
|
||||||
|
const text = scene.add.text(0, 0, label, {
|
||||||
|
fontFamily,
|
||||||
|
fontSize: `${fontSize}px`,
|
||||||
|
fontStyle: '600',
|
||||||
|
color: textColor,
|
||||||
|
});
|
||||||
|
|
||||||
|
const width = text.width + paddingX * 2;
|
||||||
|
const height = text.height + paddingY * 2;
|
||||||
|
|
||||||
|
const bg = scene
|
||||||
|
.add.rectangle(0, 0, width, height, baseColor, 1)
|
||||||
|
.setOrigin(0.5)
|
||||||
|
.setStrokeStyle(1, border, 0.9);
|
||||||
|
|
||||||
|
this.add([bg, text]);
|
||||||
|
this.setSize(width, height);
|
||||||
|
|
||||||
|
bg.setInteractive({ useHandCursor: true });
|
||||||
|
bg.on('pointerover', () => bg.setFillStyle(hoverColor, 1));
|
||||||
|
bg.on('pointerout', () => bg.setFillStyle(baseColor, 1));
|
||||||
|
bg.on('pointerdown', () => {
|
||||||
|
if (typeof onClick === 'function') onClick(this);
|
||||||
|
});
|
||||||
|
|
||||||
|
scene.add.existing(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
import Phaser from '../vendor/phaser.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a config color (number or CSS string like '#1b2540')
|
||||||
|
* into the integer format Phaser expects.
|
||||||
|
*/
|
||||||
|
export function toColor(value, fallback = 0xffffff) {
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
|
return value >>> 0;
|
||||||
|
}
|
||||||
|
if (typeof value === 'string' && value.length > 0) {
|
||||||
|
const color = Phaser.Display.Color.ValueToColor(value.trim());
|
||||||
|
if (color) return color.color;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
// The single shim for Phaser in this project — every module imports Phaser
|
||||||
|
// from here, so swapping the framework version is a one-file change.
|
||||||
|
//
|
||||||
|
// Vendored: Phaser 4.2.1 ("Giedi") — lib/phaser.min.js (UMD build, MIT).
|
||||||
|
// To change version: replace lib/phaser.min.js with the UMD build you want
|
||||||
|
// (it must set `window.Phaser`) and update the note above.
|
||||||
|
if (typeof window === 'undefined' || !window.Phaser) {
|
||||||
|
throw new Error(
|
||||||
|
'Phaser is not loaded. Make sure lib/phaser.min.js exists and is ' +
|
||||||
|
'referenced in index.html before the game modules.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default window.Phaser;
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
import Phaser from '../vendor/phaser.js';
|
||||||
|
import { config } from '../config/Config.js';
|
||||||
|
import { toColor } from '../utils/Color.js';
|
||||||
|
|
||||||
|
const STAR_KEY = '__star';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decorative parallax starfield.
|
||||||
|
* Tuned by data/game.json → starfield { enabled, count, driftSpeed, colors }.
|
||||||
|
* Purely visual: no physics, no input.
|
||||||
|
*/
|
||||||
|
export class Starfield {
|
||||||
|
constructor(scene) {
|
||||||
|
this.scene = scene;
|
||||||
|
this.cfg = config.get('game.starfield', {});
|
||||||
|
this.driftSpeed = this.cfg.driftSpeed ?? 14; // px/s
|
||||||
|
this.stars = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
create() {
|
||||||
|
if (!this.cfg.enabled) return;
|
||||||
|
|
||||||
|
const scene = this.scene;
|
||||||
|
const width = scene.scale.width;
|
||||||
|
const height = scene.scale.height;
|
||||||
|
|
||||||
|
// Tiny 4x4 white dot, shared by every star.
|
||||||
|
if (!scene.textures.exists(STAR_KEY)) {
|
||||||
|
const g = scene.make.graphics({ add: false });
|
||||||
|
g.fillStyle(0xffffff, 1);
|
||||||
|
g.fillCircle(2, 2, 2);
|
||||||
|
g.generateTexture(STAR_KEY, 4, 4);
|
||||||
|
g.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = this.cfg.count ?? 160;
|
||||||
|
const colors = (this.cfg.colors ?? ['#ffffff']).map((c) => toColor(c));
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const star = scene.add.image(
|
||||||
|
Phaser.Math.Between(0, width),
|
||||||
|
Phaser.Math.Between(0, height),
|
||||||
|
STAR_KEY,
|
||||||
|
);
|
||||||
|
const s = Phaser.Math.FloatBetween(0.4, 1.5);
|
||||||
|
star
|
||||||
|
.setScale(s)
|
||||||
|
.setAlpha(Phaser.Math.FloatBetween(0.2, 0.85))
|
||||||
|
.setTint(colors[Phaser.Math.Between(0, colors.length - 1)])
|
||||||
|
.setDepth(i % 3); // slight layering
|
||||||
|
this.stars.push(star);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
update(delta) {
|
||||||
|
if (!this.cfg.enabled || this.stars.length === 0) return;
|
||||||
|
const dt = Math.min(delta, 64) / 1000;
|
||||||
|
const width = this.scene.scale.width;
|
||||||
|
const height = this.scene.scale.height;
|
||||||
|
|
||||||
|
for (const star of this.stars) {
|
||||||
|
star.x -= this.driftSpeed * star.scale * dt;
|
||||||
|
if (star.x < -4) {
|
||||||
|
star.x = width + 4;
|
||||||
|
star.y = Phaser.Math.Between(0, height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
for (const star of this.stars) star.destroy();
|
||||||
|
this.stars = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
# Phaser
|
||||||
|
|
||||||
|
`lib/phaser.min.js` is a vendored copy of **Phaser 4.2.1** ("Giedi"),
|
||||||
|
the UMD build of the npm package `phaser@4.2.1`
|
||||||
|
(`dist/phaser.min.js`, downloaded from the npm registry).
|
||||||
|
|
||||||
|
Source: https://github.com/phaserjs/phaser
|
||||||
|
License: MIT (see below).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Copyright (c) 2013-2026 Richard Davey / Phaser Studio Inc.
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in
|
||||||
|
all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue