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:
Brian Fertig 2026-09-02 22:31:34 -06:00
parent 572d64c349
commit eddbedbf1c
23 changed files with 1082 additions and 1 deletions

View File

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

19
data/game.json Normal file
View File

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

7
data/manifest.json Normal file
View File

@ -0,0 +1,7 @@
{
"files": [
"game.json",
"menu.json",
"ship.json"
]
}

25
data/menu.json Normal file
View File

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

13
data/ship.json Normal file
View File

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

176
dev/ship-behavior.test.mjs Normal file
View File

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

22
dev/smoke-game.mjs Normal file
View File

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

16
dev/test-game.html Normal file
View File

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

82
docs/PROJECT_NOTES.md Normal file
View File

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

34
index.html Normal file
View File

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

52
js/config/Config.js Normal file
View File

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

33
js/config/ConfigLoader.js Normal file
View File

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

30
js/config/GameConfig.js Normal file
View File

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

125
js/entities/Ship.js Normal file
View File

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

42
js/main.js Normal file
View File

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

78
js/scenes/GameScene.js Normal file
View File

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

67
js/scenes/MenuScene.js Normal file
View File

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

57
js/ui/MenuButton.js Normal file
View File

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

16
js/utils/Color.js Normal file
View File

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

14
js/vendor/phaser.js vendored Normal file
View File

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

74
js/visuals/Starfield.js Normal file
View File

@ -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 = [];
}
}

30
lib/PHASER_LICENSE.md Normal file
View File

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

1
lib/phaser.min.js vendored Normal file

File diff suppressed because one or more lines are too long