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