Add discovery system with off-screen compass and in-world planet layout
- Discovery rule: ship within `discovery.distance` (540 px) of a world's edge discovers it once per system; state is pure, save-ready (`Discovery.toJSON/fromJSON`) - Off-screen compass (`DiscoveryCompass`): themed screen-edge arrows with type/name chips point back at discovered worlds; geometry helpers (`edgeAnchor`, `circleInView`, `lerpAngle`) exported for Node tests - Solar-system layout: `layoutSystemPlanets` places generated worlds in a deterministic annular band around the home world, enforcing edge-to-edge separation and per-class scale/tint from `data/planets.json` - All system planets are solid to the ship (collision + click-clamp), not just home - Rim ping + HUD toast on discovery (`celebrateDiscovery`) - New planet class pools (rocky/gas/ice/lava) with frames, `classScale`, `classTint`, and `typeLabels` - Smoke-game dev hooks: `?ship=x,y` and `?near=px` for screenshotting discovery/compass states - Node test suite (`dev/discovery.test.mjs`) covering rule boundaries, per-system state, JSON round-trip, layout bounds/determinism, compass geometry, and class pools
This commit is contained in:
parent
d41183c1fa
commit
8c52105412
|
|
@ -19,5 +19,13 @@
|
||||||
"camera": {
|
"camera": {
|
||||||
"followShip": true,
|
"followShip": true,
|
||||||
"followRate": 3.0
|
"followRate": 3.0
|
||||||
|
},
|
||||||
|
"discovery": {
|
||||||
|
"_comment": "Discovery rule: when the ship gets within distance px of an object's edge (edge-to-edge), the object is discovered — and stays discovered for the rest of the galaxy. compass = the off-screen arrows: edgeInset px in from the screen edge, and minSeparation px kept between arrows so they never stack.",
|
||||||
|
"distance": 540,
|
||||||
|
"compass": {
|
||||||
|
"edgeInset": 26,
|
||||||
|
"minSeparation": 130
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
"planets.json",
|
"planets.json",
|
||||||
"galaxy.json",
|
"galaxy.json",
|
||||||
"systems.json",
|
"systems.json",
|
||||||
|
"settlements.json",
|
||||||
"naming.json"
|
"naming.json"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,44 @@
|
||||||
{
|
{
|
||||||
"_comment": "Planet visuals + home-planet rules. texture is a spritesheet of frameWidth×frameHeight frames (frame 0 = top-left); frames maps a planet name to the sheet frames it may be drawn as — the generator picks one per planet (seed-deterministic). homePlanet is the player's world — always present in the system the player starts in. scale = world pixels per sheet pixel (1.0 = 1:1, so a Terran world is 1024 px across on screen). A planet is a solid disc: the ship may approach to shipClearance px (edge-to-edge) from its rim but can never cross it. spawnDistanceFromEdge = how far (edge-to-edge) the ship starts from the home world's rim.",
|
"_comment": "Planet visuals + home-planet rules + the solar system's layout. texture is a spritesheet of frameWidth×frameHeight frames (frame 0 = top-left); frames maps a planet kind to the sheet frames it may be drawn as — the generator picks one per planet (seed-deterministic). homePlanet is the player's world — always present at the origin of the system they start in. scale = world pixels per sheet pixel (1.0 = 1:1, so a Terran world is 1024 px across on screen). A planet is a solid disc: the ship may approach to shipClearance px (edge-to-edge) from its rim but can never cross it. spawnDistanceFromEdge = how far (edge-to-edge) the ship starts from the home world's rim. classScale = size multiplier per planet class (1.0 = 1024 px across); classTint = optional canvas tint per class (multiplicative — the terran art stands in for every kind until real art lands). solarSystem = the top-down layout band: the other worlds of a system sit minOrbit–maxOrbit px from the origin, at least minEdgeGap px (edge-to-edge) apart from each other and the origin.",
|
||||||
"texture": "assets/images/planets.png",
|
"texture": "assets/images/planets.png",
|
||||||
"frameWidth": 1024,
|
"frameWidth": 1024,
|
||||||
"frameHeight": 1024,
|
"frameHeight": 1024,
|
||||||
"scale": 1.0,
|
"scale": 1.0,
|
||||||
"frames": {
|
"frames": {
|
||||||
"terran": [0, 1, 2]
|
"terran": [0, 1, 2],
|
||||||
|
"rocky": [0, 1, 2],
|
||||||
|
"gas": [0, 1, 2],
|
||||||
|
"ice": [0, 1, 2],
|
||||||
|
"lava": [0, 1, 2]
|
||||||
|
},
|
||||||
|
"classScale": {
|
||||||
|
"rocky": 1.0,
|
||||||
|
"gas": 1.45,
|
||||||
|
"ice": 0.85,
|
||||||
|
"lava": 0.95
|
||||||
|
},
|
||||||
|
"classTint": {
|
||||||
|
"rocky": "#e8d9b8",
|
||||||
|
"gas": "#ffb066",
|
||||||
|
"ice": "#cfeaff",
|
||||||
|
"lava": "#ff8a5c"
|
||||||
|
},
|
||||||
|
"typeLabels": {
|
||||||
|
"terran": "Home World",
|
||||||
|
"rocky": "Rocky World",
|
||||||
|
"gas": "Gas Giant",
|
||||||
|
"ice": "Ice World",
|
||||||
|
"lava": "Lava World"
|
||||||
},
|
},
|
||||||
"homePlanet": "terran",
|
"homePlanet": "terran",
|
||||||
|
"homeName": "Terra",
|
||||||
|
"homeTypeLabel": "Home World",
|
||||||
"shipClearance": 50,
|
"shipClearance": 50,
|
||||||
"spawnDistanceFromEdge": 150
|
"spawnDistanceFromEdge": 150,
|
||||||
|
"solarSystem": {
|
||||||
|
"enabled": true,
|
||||||
|
"minOrbit": 2600,
|
||||||
|
"maxOrbit": 8800,
|
||||||
|
"minEdgeGap": 1200
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,193 @@
|
||||||
|
/**
|
||||||
|
* Discovery & compass test (dev tool, run with Node — no browser needed):
|
||||||
|
*
|
||||||
|
* node dev/discovery.test.mjs
|
||||||
|
*
|
||||||
|
* Asserts:
|
||||||
|
* - the DISCOVERY RULE (data/game.json → discovery.distance): within that
|
||||||
|
* many px of an object's EDGE ⇒ discovered, exactly once, per system;
|
||||||
|
* state round-trips through toJSON/fromJSON (saves-ready);
|
||||||
|
* - the SOLAR SYSTEM LAYOUT (data/planets.json → solarSystem): every
|
||||||
|
* system's worlds sit at finite, in-band positions around the origin,
|
||||||
|
* at least minEdgeGap px apart edge-to-edge (incl. the origin's home
|
||||||
|
* world), scaled by class — deterministically (same seed ⇒ same layout,
|
||||||
|
* different seed ⇒ different);
|
||||||
|
* - the COMPASS geometry (js/ui/DiscoveryCompass.js): edgeAnchor lands on
|
||||||
|
* the screen-edge rect (edges AND corners), circleInView is exact,
|
||||||
|
* lerpAngle always takes the short arc.
|
||||||
|
* - the new planet class pools resolve to real sheet frames.
|
||||||
|
*/
|
||||||
|
|
||||||
|
process.env.NODE_ENV = 'dev';
|
||||||
|
|
||||||
|
import { pathToFileURL } from 'node:url';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
// --- Load the real config (data/*.json) into the config singleton --------
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Minimal Phaser stub — enough to import the UI/entity modules below.
|
||||||
|
globalThis.window = {
|
||||||
|
Phaser: { GameObjects: { Container: class {}, Sprite: class {} } },
|
||||||
|
};
|
||||||
|
|
||||||
|
const { Rng } = await import(pathToFileURL(join(__dirname, '../js/utils/Rng.js')).href);
|
||||||
|
const { Galaxy } = await import(pathToFileURL(join(__dirname, '../js/galaxy/Galaxy.js')).href);
|
||||||
|
const { Discovery } = await import(pathToFileURL(join(__dirname, '../js/galaxy/Discovery.js')).href);
|
||||||
|
const { Planet } = await import(pathToFileURL(join(__dirname, '../js/entities/Planet.js')).href);
|
||||||
|
const { edgeAnchor, circleInView, lerpAngle } = await import(
|
||||||
|
pathToFileURL(join(__dirname, '../js/ui/DiscoveryCompass.js')).href
|
||||||
|
);
|
||||||
|
|
||||||
|
let pass = 0;
|
||||||
|
function check(name, cond) {
|
||||||
|
if (!cond) {
|
||||||
|
console.error(`✗ ${name}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
pass++;
|
||||||
|
console.log(`✓ ${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- The discovery rule ---------------------------------------------------
|
||||||
|
|
||||||
|
const D = config.get('game.discovery.distance');
|
||||||
|
check('discovery distance is configured (540 px)', D === 540);
|
||||||
|
|
||||||
|
const d = new Discovery(D);
|
||||||
|
const objs = [
|
||||||
|
{ id: 'near', x: 1000, y: 0, radius: 512 }, // rim at 488 px → within 540
|
||||||
|
{ id: 'far', x: 5000, y: 0, radius: 512 }, // rim at 4488 px → out of range
|
||||||
|
{ id: 'sideways', x: 0, y: 900, radius: 512 }, // rim at 388 px → within 540
|
||||||
|
];
|
||||||
|
let fresh = d.check('S1', 0, 0, objs);
|
||||||
|
check('within distance of the edge ⇒ discovered', fresh.length === 2 && d.isDiscovered('S1', 'near') && d.isDiscovered('S1', 'sideways'));
|
||||||
|
check('beyond the distance ⇒ not discovered', !d.isDiscovered('S1', 'far'));
|
||||||
|
check('already-discovered is never re-reported', d.check('S1', 0, 0, objs).length === 0);
|
||||||
|
check('discovery is tracked per system', d.check('S2', 0, 0, objs).length === 2 && !d.isDiscovered('S2', 'far'));
|
||||||
|
|
||||||
|
const dEdge = new Discovery(D);
|
||||||
|
check('exactly at radius+distance ⇒ discovered', dEdge.check('S1', 512 + D, 0, [{ id: 'a', x: 0, y: 0, radius: 512 }]).length === 1);
|
||||||
|
const dPast = new Discovery(D);
|
||||||
|
check('one px past radius+distance ⇒ not discovered', dPast.check('S1', 513 + D, 0, [{ id: 'a', x: 0, y: 0, radius: 512 }]).length === 0);
|
||||||
|
|
||||||
|
let rejected = false;
|
||||||
|
try {
|
||||||
|
new Discovery(-1);
|
||||||
|
} catch {
|
||||||
|
rejected = true;
|
||||||
|
}
|
||||||
|
check('rejects a negative distance', rejected);
|
||||||
|
|
||||||
|
const restored = Discovery.fromJSON(d.toJSON());
|
||||||
|
check('toJSON/fromJSON round-trips (saves-ready)', restored.distance === D && restored.isDiscovered('S1', 'near') && !restored.isDiscovered('S1', 'far') && restored.isDiscovered('S2', 'sideways'));
|
||||||
|
|
||||||
|
// --- The solar system layout ---------------------------------------------
|
||||||
|
|
||||||
|
const band = config.get('planets.solarSystem');
|
||||||
|
const { minOrbit, maxOrbit, minEdgeGap } = band;
|
||||||
|
const baseR = (config.get('planets.frameWidth', 1024) * config.get('planets.scale', 1)) / 2;
|
||||||
|
|
||||||
|
const g = Galaxy.create('discovery-layout-test');
|
||||||
|
let layoutOk = true;
|
||||||
|
let layoutWhy = '';
|
||||||
|
const probe = (systems) => {
|
||||||
|
for (const rec of systems) {
|
||||||
|
const c = g.ensureContent(rec.id);
|
||||||
|
const placed = [{ x: 0, y: 0, r: baseR }]; // the origin's home world
|
||||||
|
for (const p of c.planets) {
|
||||||
|
const r = baseR * p.scale;
|
||||||
|
const d0 = Math.hypot(p.x, p.y);
|
||||||
|
const want = config.get(`planets.classScale.${p.class}`, 1);
|
||||||
|
if (!Number.isFinite(p.x) || !Number.isFinite(p.y) || !Number.isFinite(p.scale)) {
|
||||||
|
layoutOk = false;
|
||||||
|
layoutWhy = `${rec.id}: non-finite layout`;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (Math.abs(p.scale - want) > 1e-9) {
|
||||||
|
layoutOk = false;
|
||||||
|
layoutWhy = `${rec.id}: ${p.class} world scaled ${p.scale} ≠ classScale ${want}`;
|
||||||
|
}
|
||||||
|
if (d0 < minOrbit - 1e-6) {
|
||||||
|
layoutOk = false;
|
||||||
|
layoutWhy = `${rec.id}: world at ${d0.toFixed(1)} px < minOrbit ${minOrbit}`;
|
||||||
|
}
|
||||||
|
for (const q of placed) {
|
||||||
|
const gap = Math.hypot(p.x - q.x, p.y - q.y) - q.r - r;
|
||||||
|
if (gap < minEdgeGap - 1e-6) {
|
||||||
|
layoutOk = false;
|
||||||
|
layoutWhy = `${rec.id}: worlds ${minEdgeGap - gap}px closer than minEdgeGap ${minEdgeGap}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
placed.push({ x: p.x, y: p.y, r });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
probe(g.records.slice(0, 500));
|
||||||
|
check(
|
||||||
|
`layout: 500 systems' worlds finite, scaled by class, in-band (≥${minOrbit}px), ≥${minEdgeGap}px apart edge-to-edge${layoutOk ? '' : ' — ' + layoutWhy}`,
|
||||||
|
layoutOk,
|
||||||
|
);
|
||||||
|
|
||||||
|
const rec0 = g.records[0].id;
|
||||||
|
const la = Galaxy.create('discovery-layout-test').ensureContent(rec0);
|
||||||
|
const lb = Galaxy.create('discovery-layout-test').ensureContent(rec0);
|
||||||
|
const same =
|
||||||
|
la.planets.length === lb.planets.length &&
|
||||||
|
la.planets.every((p, i) => p.x === lb.planets[i].x && p.y === lb.planets[i].y && p.scale === lb.planets[i].scale);
|
||||||
|
check('layout is deterministic (same seed ⇒ same x/y/scale)', same);
|
||||||
|
|
||||||
|
const lc = Galaxy.create('discovery-layout-OTHER').ensureContent(rec0);
|
||||||
|
const diff =
|
||||||
|
lc.planets.length !== la.planets.length ||
|
||||||
|
la.planets.some((p, i) => p.x !== lc.planets[i]?.x || p.y !== lc.planets[i]?.y);
|
||||||
|
check('different seed ⇒ different layout', diff);
|
||||||
|
|
||||||
|
// --- The compass geometry ---------------------------------------------------
|
||||||
|
|
||||||
|
const W = 1280;
|
||||||
|
const H = 720;
|
||||||
|
const INSET = config.get('game.discovery.compass.edgeInset', 26);
|
||||||
|
|
||||||
|
const eR = edgeAnchor(W, H, INSET, 0);
|
||||||
|
check('edgeAnchor(+x) lands on the right edge, inset', Math.abs(eR.x - (W - INSET)) < 1e-9 && Math.abs(eR.y - H / 2) < 1e-9);
|
||||||
|
const eT = edgeAnchor(W, H, INSET, -Math.PI / 2);
|
||||||
|
check('edgeAnchor(up) lands on the top edge, inset', Math.abs(eT.x - W / 2) < 1e-9 && Math.abs(eT.y - INSET) < 1e-9);
|
||||||
|
const cA = Math.atan2(H / 2 - INSET, W / 2 - INSET);
|
||||||
|
const eC = edgeAnchor(W, H, INSET, cA);
|
||||||
|
check('edgeAnchor(corner ray) lands exactly on the corner', Math.abs(eC.x - (W - INSET)) < 1e-6 && Math.abs(eC.y - (H - INSET)) < 1e-6);
|
||||||
|
|
||||||
|
const view = { left: 100, top: 200, w: 1280, h: 720 };
|
||||||
|
check('circleInView: rim overlapping the view ⇒ true', circleInView(view.left + view.w + 100, 560, 120, view) === true);
|
||||||
|
check('circleInView: fully outside ⇒ false', circleInView(view.left + view.w + 200, 560, 120, view) === false);
|
||||||
|
check('circleInView: fully inside ⇒ true', circleInView(700, 560, 512, view) === true);
|
||||||
|
|
||||||
|
check(
|
||||||
|
'lerpAngle(0→π, ½) lands midway (antipodal ⇒ either arc is shortest)',
|
||||||
|
Math.abs(Math.abs(lerpAngle(0, Math.PI, 0.5)) - Math.PI / 2) < 1e-9,
|
||||||
|
);
|
||||||
|
check('lerpAngle eases the short way (3→−3, no long-way sweep)', Math.abs(lerpAngle(3.0, -3.0, 1) - (-3.0 + 2 * Math.PI)) < 1e-9);
|
||||||
|
|
||||||
|
// --- The new planet class pools ---------------------------------------------
|
||||||
|
|
||||||
|
for (const k of ['rocky', 'gas', 'ice', 'lava']) {
|
||||||
|
const pool = config.get(`planets.frames.${k}`);
|
||||||
|
check(`frames pool for ${k} exists`, Array.isArray(pool) && pool.length > 0);
|
||||||
|
const f = Planet.frameFor(k, Rng.derive('test', 'planet', 'x'));
|
||||||
|
check(`frameFor(${k}) picks a frame from its pool`, pool.includes(f));
|
||||||
|
const s = config.get(`planets.classScale.${k}`, 1);
|
||||||
|
check(`classScale.${k} is sane (${s})`, s > 0 && s < 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nAll discovery & compass tests passed (${pass} checks).`);
|
||||||
|
|
@ -6,6 +6,14 @@
|
||||||
* python3 -m http.server 8080
|
* python3 -m http.server 8080
|
||||||
* firefox --headless --screenshot shot.png \
|
* firefox --headless --screenshot shot.png \
|
||||||
* --window-size=1280,720 http://localhost:8080/dev/test-game.html
|
* --window-size=1280,720 http://localhost:8080/dev/test-game.html
|
||||||
|
*
|
||||||
|
* Dev hooks (URL params, dev only — ignored by the game itself):
|
||||||
|
* ?ship=<x>,<y> teleport the ship to a world position after boot
|
||||||
|
* (screenshots of far-off states — e.g. the off-screen
|
||||||
|
* compass arrows pointing back at the home world).
|
||||||
|
* ?near=<px> fly the ship to <px> (edge-to-edge) from the first
|
||||||
|
* system planet — screenshots the discovery moment
|
||||||
|
* (rim ping + toast) with the world in view.
|
||||||
*/
|
*/
|
||||||
import Phaser from '../js/vendor/phaser.js';
|
import Phaser from '../js/vendor/phaser.js';
|
||||||
import { config } from '../js/config/Config.js';
|
import { config } from '../js/config/Config.js';
|
||||||
|
|
@ -16,7 +24,38 @@ import { GameScene } from '../js/scenes/GameScene.js';
|
||||||
const data = await ConfigLoader.load();
|
const data = await ConfigLoader.load();
|
||||||
config.init(data);
|
config.init(data);
|
||||||
const gameConfig = createGameConfig();
|
const gameConfig = createGameConfig();
|
||||||
|
const shipParam = typeof location !== 'undefined' ? new URLSearchParams(location.search).get('ship') : null;
|
||||||
|
const nearParam = typeof location !== 'undefined' ? new URLSearchParams(location.search).get('near') : null;
|
||||||
gameConfig.scene = [GameScene];
|
gameConfig.scene = [GameScene];
|
||||||
const game = new Phaser.Game(gameConfig);
|
const game = new Phaser.Game(gameConfig);
|
||||||
window.game = game;
|
window.game = game;
|
||||||
console.info('smoke: game booted into GameScene');
|
console.info('smoke: game booted into GameScene');
|
||||||
|
|
||||||
|
if (shipParam) {
|
||||||
|
const [sx, sy] = shipParam.split(',').map(Number);
|
||||||
|
if (Number.isFinite(sx) && Number.isFinite(sy)) {
|
||||||
|
setTimeout(() => {
|
||||||
|
const s = game.scene.getScene('GameScene');
|
||||||
|
s.ship.setPosition(sx, sy);
|
||||||
|
s.cameras.main.setScroll(sx - s.scale.width / 2, sy - s.scale.height / 2);
|
||||||
|
console.info(`smoke: ship teleported to (${sx}, ${sy})`);
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nearParam) {
|
||||||
|
const px = Number(nearParam) || 400;
|
||||||
|
setTimeout(() => {
|
||||||
|
const s = game.scene.getScene('GameScene');
|
||||||
|
const p = s.systemPlanets[0];
|
||||||
|
if (!p) return;
|
||||||
|
// Place the ship on the planet→origin axis, `px` (edge-to-edge) off
|
||||||
|
// the rim — inside the discovery distance, with the world in view.
|
||||||
|
const ang = Math.atan2(-p.y, -p.x);
|
||||||
|
const sx = p.x + Math.cos(ang) * (p.radius + px);
|
||||||
|
const sy = p.y + Math.sin(ang) * (p.radius + px);
|
||||||
|
s.ship.setPosition(sx, sy);
|
||||||
|
s.cameras.main.setScroll(sx - s.scale.width / 2, sy - s.scale.height / 2);
|
||||||
|
console.info(`smoke: ship parked ${px}px off ${p.discoveryName}'s rim`);
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -146,6 +146,40 @@ unclaimed". Model & seams:
|
||||||
of interest: the data already says what's there and where (anchor =
|
of interest: the data already says what's there and where (anchor =
|
||||||
planet ordinal or open space).
|
planet ordinal or open space).
|
||||||
|
|
||||||
|
## The current system is a place, not just a dossier (discovery + compass)
|
||||||
|
|
||||||
|
The player starts in a solar system, and the other worlds **exist in the
|
||||||
|
world** — solid, rendered, flyable-to. Rules and seams:
|
||||||
|
|
||||||
|
- **World layout** — `SystemGenerator.layoutSystemPlanets(seed, systemId,
|
||||||
|
planets)` (pure, `js/galaxy/SystemGenerator.js`) places each generated
|
||||||
|
world in an annular band around the home world (origin), enforcing
|
||||||
|
edge-to-edge separation from every other disc. Draws come from the
|
||||||
|
dedicated fork `Rng.derive(seed, 'system', id, 'layout')` — layout
|
||||||
|
results are seed-deterministic *and* don't perturb the content stream
|
||||||
|
(lazy === eager is preserved). Band params live in
|
||||||
|
`data/planets.json → solarSystem` (enabled, minOrbit, maxOrbit,
|
||||||
|
minEdgeGap); class sizes/tints in `classScale`/`classTint`.
|
||||||
|
- **Discovery** — `js/galaxy/Discovery.js` (pure, no Phaser — Node-
|
||||||
|
testable, save-ready: `toJSON()`/`fromJSON()`). Rule: the ship within
|
||||||
|
`game.discovery.distance` (data/game.json, default 540 px) of an
|
||||||
|
object's *edge* (center distance ≤ radius + distance) discovers it,
|
||||||
|
once, per system. Home world is discovered at spawn (the ship starts
|
||||||
|
beside it). Feedback: rim ping + "DISCOVERED — NAME · TYPE" toast
|
||||||
|
(GameScene.celebrateDiscovery).
|
||||||
|
- **Compass** — `js/ui/DiscoveryCompass.js` (screen-space Container,
|
||||||
|
scrollFactor 0). Every frame it refreshes the set of **discovered**
|
||||||
|
objects that are **off-screen**, drawing a themed chevron arrow on the
|
||||||
|
screen edge with a type/name chip, separated by angle when rays crowd.
|
||||||
|
Edge-anchor geometry (`edgeAnchor`, `circleInView`, `lerpAngle`) is
|
||||||
|
exported pure for tests. Arrow texture is generated procedurally on
|
||||||
|
first use (`__compass_arrow`).
|
||||||
|
- **World solids** — the ship collides with *every* system planet
|
||||||
|
(`GameScene.solidPlanets`), not just home: you can approach a rim,
|
||||||
|
never pass through.
|
||||||
|
- Verified: `dev/discovery.test.mjs` (rule, boundaries, per-system
|
||||||
|
state, JSON round-trip, layout bounds + determinism, compass geometry).
|
||||||
|
|
||||||
## Phaser version
|
## Phaser version
|
||||||
|
|
||||||
- Pinned: **Phaser 4.2.1** ("Giedi"), vendored in `lib/phaser.min.js`.
|
- Pinned: **Phaser 4.2.1** ("Giedi"), vendored in `lib/phaser.min.js`.
|
||||||
|
|
@ -167,6 +201,10 @@ unclaimed". Model & seams:
|
||||||
silently renders **black**. Text styles and `setColor()` must get CSS
|
silently renders **black**. Text styles and `setColor()` must get CSS
|
||||||
strings — use `toCss()` from `js/utils/Color.js` (keep `toColor()` for
|
strings — use `toCss()` from `js/utils/Color.js` (keep `toColor()` for
|
||||||
Graphics/shape APIs).
|
Graphics/shape APIs).
|
||||||
|
- An `Image` created **before** its texture key exists can stay bound to
|
||||||
|
the `__MISSING` texture forever — even after the key is generated later
|
||||||
|
in the same session. Generate the texture **first** (see
|
||||||
|
`DiscoveryCompass.ensureArrowTexture`: texture, then `scene.add.image`).
|
||||||
- To upgrade: replace the vendored file + note the version here (and re-check
|
- To upgrade: replace the vendored file + note the version here (and re-check
|
||||||
the quirks above — they may go away).
|
the quirks above — they may go away).
|
||||||
|
|
||||||
|
|
@ -183,6 +221,12 @@ unclaimed". Model & seams:
|
||||||
core→rim density gradient, populations, and a `owner` seam reserved
|
core→rim density gradient, populations, and a `owner` seam reserved
|
||||||
for the factions/pirates to come; readable as a HUD dossier
|
for the factions/pirates to come; readable as a HUD dossier
|
||||||
(`SystemReport`)
|
(`SystemReport`)
|
||||||
|
- [x] The system as a place: generated worlds laid out in the world
|
||||||
|
(deterministic band), solid to the ship; discovery (within 540 px
|
||||||
|
of an edge, once, per system) with rim ping + toast; off-screen
|
||||||
|
compass — themed screen-edge arrows with type/name chips pointing
|
||||||
|
at discovered worlds; pure, save-ready discovery state
|
||||||
|
(`js/galaxy/Discovery.js`)
|
||||||
- [ ] Factions & pirates: claim settlements (`owner`), flags, borders,
|
- [ ] Factions & pirates: claim settlements (`owner`), flags, borders,
|
||||||
and the player's place in a populated galaxy
|
and the player's place in a populated galaxy
|
||||||
- [ ] Landing & exploration: settlements become points of interest you
|
- [ ] Landing & exploration: settlements become points of interest you
|
||||||
|
|
|
||||||
|
|
@ -40,13 +40,18 @@ export class Planet extends Phaser.GameObjects.Sprite {
|
||||||
* @param {number} y — world y
|
* @param {number} y — world y
|
||||||
* @param {number} [frame=0] — spritesheet frame index (usually from Planet.frameFor)
|
* @param {number} [frame=0] — spritesheet frame index (usually from Planet.frameFor)
|
||||||
* @param {string} [name=''] — planet kind, e.g. 'terran' (data/planets.json → frames)
|
* @param {string} [name=''] — planet kind, e.g. 'terran' (data/planets.json → frames)
|
||||||
|
* @param {object} [o={}] — per-planet overrides
|
||||||
|
* @param {number} [o.scale=1] — extra size multiplier on top of planets.scale
|
||||||
|
* (gas giants run bigger; see data/planets.json → classScale)
|
||||||
|
* @param {number} [o.tint] — canvas tint (int) applied to the sheet frame,
|
||||||
|
* so non-terran kinds read differently (see data/planets.json → classTint)
|
||||||
*/
|
*/
|
||||||
constructor(scene, x, y, frame = 0, name = '') {
|
constructor(scene, x, y, frame = 0, name = '', o = {}) {
|
||||||
super(scene, x, y, Planet.TEXTURE_KEY, frame);
|
super(scene, x, y, Planet.TEXTURE_KEY, frame);
|
||||||
scene.add.existing(this);
|
scene.add.existing(this);
|
||||||
this.name = name;
|
this.name = name;
|
||||||
|
|
||||||
const scale = config.get('planets.scale', 1);
|
const scale = config.get('planets.scale', 1) * (o.scale ?? 1);
|
||||||
this.setScale(scale);
|
this.setScale(scale);
|
||||||
|
|
||||||
// Collision circle: frames are square and the world fills its frame,
|
// Collision circle: frames are square and the world fills its frame,
|
||||||
|
|
@ -54,6 +59,8 @@ export class Planet extends Phaser.GameObjects.Sprite {
|
||||||
this.radius = (config.get('planets.frameWidth', 1024) * scale) / 2;
|
this.radius = (config.get('planets.frameWidth', 1024) * scale) / 2;
|
||||||
// Edge-to-edge gap the ship may close in on the rim (never less).
|
// Edge-to-edge gap the ship may close in on the rim (never less).
|
||||||
this.clearance = config.get('planets.shipClearance', 50);
|
this.clearance = config.get('planets.shipClearance', 50);
|
||||||
|
|
||||||
|
if (o.tint !== undefined && o.tint !== null) this.setTint(o.tint);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Minimum allowed center-to-center distance for a ship of `shipRadius`. */
|
/** Minimum allowed center-to-center distance for a ship of `shipRadius`. */
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
/**
|
||||||
|
* Discovery — which objects in which system the player has found.
|
||||||
|
*
|
||||||
|
* The rule (data/game.json → discovery.distance): when the player's ship
|
||||||
|
* comes within that many pixels of an object's EDGE (edge-to-edge), the
|
||||||
|
* object counts as DISCOVERED — and stays discovered for the rest of the
|
||||||
|
* game. That's what feeds the off-screen compass (js/ui/DiscoveryCompass.js)
|
||||||
|
* with "where I've been" arrows.
|
||||||
|
*
|
||||||
|
* Pure — no Phaser — so it's testable in Node (dev/discovery.test.mjs)
|
||||||
|
* and trivially serializable for saves (toJSON / fromJSON). The scene
|
||||||
|
* keeps one instance in the shared registry, so discovery survives a
|
||||||
|
* scene restart (and later, jumps between systems: state is per-system).
|
||||||
|
*
|
||||||
|
* const disc = new Discovery(540);
|
||||||
|
* const fresh = disc.check('S000001', ship.x, ship.y, objects);
|
||||||
|
* if (fresh.length) showACompassPing(fresh[0]);
|
||||||
|
* if (disc.isDiscovered('S000001', obj.id)) ...
|
||||||
|
*/
|
||||||
|
export class Discovery {
|
||||||
|
/** @param {number} distance — the edge-to-edge discovery radius (px) */
|
||||||
|
constructor(distance = 540) {
|
||||||
|
if (!(distance >= 0)) throw new Error(`Discovery distance must be >= 0 (got ${distance})`);
|
||||||
|
this.distance = distance;
|
||||||
|
/** @type {Map<string, Set<string>>} systemId → discovered object ids */
|
||||||
|
this.bySystem = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
isDiscovered(systemId, objectId) {
|
||||||
|
const set = this.bySystem.get(systemId);
|
||||||
|
return !!set && set.has(objectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proximity check against a list of objects.
|
||||||
|
*
|
||||||
|
* @param {string} systemId — the system the objects live in
|
||||||
|
* @param {number} sx — ship world x
|
||||||
|
* @param {number} sy — ship world y
|
||||||
|
* @param {Array<{id: string, x: number, y: number, radius: number}>} objects
|
||||||
|
* @returns {Array<object>} the NEWLY discovered objects (a subset of
|
||||||
|
* `objects`, in the order given) — empty when nothing new is found.
|
||||||
|
*/
|
||||||
|
check(systemId, sx, sy, objects) {
|
||||||
|
let known = this.bySystem.get(systemId);
|
||||||
|
if (!known) {
|
||||||
|
known = new Set();
|
||||||
|
this.bySystem.set(systemId, known);
|
||||||
|
}
|
||||||
|
const fresh = [];
|
||||||
|
for (const o of objects) {
|
||||||
|
if (known.has(o.id)) continue;
|
||||||
|
const dx = sx - o.x;
|
||||||
|
const dy = sy - o.y;
|
||||||
|
// "Within `distance` px of the edge" = center distance <= radius + distance.
|
||||||
|
if (dx * dx + dy * dy <= (o.radius + this.distance) * (o.radius + this.distance)) {
|
||||||
|
known.add(o.id);
|
||||||
|
fresh.push(o);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fresh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {string[]} the discovered object ids in `systemId` (may be empty) */
|
||||||
|
discoveredIds(systemId) {
|
||||||
|
return [...(this.bySystem.get(systemId) ?? [])];
|
||||||
|
}
|
||||||
|
|
||||||
|
toJSON() {
|
||||||
|
const bySystem = {};
|
||||||
|
for (const [id, set] of this.bySystem) bySystem[id] = [...set];
|
||||||
|
return { distance: this.distance, bySystem };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Restore state saved with toJSON() (e.g. from a save file later). */
|
||||||
|
static fromJSON(data) {
|
||||||
|
const d = new Discovery(data?.distance ?? 540);
|
||||||
|
const bySystem = data?.bySystem ?? {};
|
||||||
|
for (const [id, ids] of Object.entries(bySystem)) {
|
||||||
|
if (Array.isArray(ids)) d.bySystem.set(id, new Set(ids));
|
||||||
|
}
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -80,6 +80,7 @@ export function generateSystemContent(galaxy, record, typeDefs = null) {
|
||||||
habitable: pclass === 'rocky' && rng.chance(attr.habitability ?? 0.1),
|
habitable: pclass === 'rocky' && rng.chance(attr.habitability ?? 0.1),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
layoutSystemPlanets(galaxy.seed, record.id, planets);
|
||||||
|
|
||||||
// --- Settlements (the lived-in layer) ---------------------------------
|
// --- Settlements (the lived-in layer) ---------------------------------
|
||||||
const settlements = generateSettlements({
|
const settlements = generateSettlements({
|
||||||
|
|
@ -109,6 +110,63 @@ export function generateSystemContent(galaxy, record, typeDefs = null) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Top-down layout of a system's worlds (the play field around the origin,
|
||||||
|
* where the player's home world sits).
|
||||||
|
*
|
||||||
|
* Each planet record gains:
|
||||||
|
* x, y — world position (the game renders it there),
|
||||||
|
* scale — size multiplier (data/planets.json → classScale, so gas
|
||||||
|
* giants read bigger than rockies).
|
||||||
|
*
|
||||||
|
* Worlds are scattered in the annulus minOrbit–maxOrbit px from the
|
||||||
|
* origin and kept at least minEdgeGap px (edge-to-edge) apart from each
|
||||||
|
* other and from the origin (data/planets.json → solarSystem).
|
||||||
|
*
|
||||||
|
* Determinism: draws come from a DEDICATED fork — (seed, 'system', id,
|
||||||
|
* 'layout') — so layout never perturbs the star/planet/settlement draws
|
||||||
|
* above, and lazy (on-arrival) === eager (generateAll) is preserved.
|
||||||
|
*/
|
||||||
|
function layoutSystemPlanets(seed, systemId, planets) {
|
||||||
|
const band = config.get('planets.solarSystem', {});
|
||||||
|
const minOrbit = band.minOrbit ?? 2600;
|
||||||
|
const maxOrbit = band.maxOrbit ?? 8800;
|
||||||
|
const minGap = band.minEdgeGap ?? 1200;
|
||||||
|
const baseR = (config.get('planets.frameWidth', 1024) * config.get('planets.scale', 1)) / 2;
|
||||||
|
|
||||||
|
const lay = Rng.derive(seed, 'system', systemId, 'layout');
|
||||||
|
// The origin is occupied by a world (the home world) — respect it.
|
||||||
|
const placed = [{ x: 0, y: 0, r: baseR }];
|
||||||
|
|
||||||
|
for (const p of planets) {
|
||||||
|
const scale = config.get(`planets.classScale.${p.class}`, 1) ?? 1;
|
||||||
|
const r = baseR * scale;
|
||||||
|
let ok = false;
|
||||||
|
let x = 0;
|
||||||
|
let y = 0;
|
||||||
|
for (let attempt = 0; attempt < 24 && !ok; attempt++) {
|
||||||
|
const ang = lay.range(0, Math.PI * 2);
|
||||||
|
const d = lay.range(minOrbit, maxOrbit);
|
||||||
|
x = d * Math.cos(ang);
|
||||||
|
y = d * Math.sin(ang);
|
||||||
|
ok = placed.every((q) => Math.hypot(x - q.x, y - q.y) >= q.r + r + minGap);
|
||||||
|
}
|
||||||
|
if (!ok) {
|
||||||
|
// The band is (nearly) full — spiral outward. A deterministic
|
||||||
|
// escape hatch: with the default band and ≤ 9 worlds per system
|
||||||
|
// this never actually triggers.
|
||||||
|
const ang = lay.range(0, Math.PI * 2);
|
||||||
|
const d = maxOrbit + r + minGap + placed.length * (r + minGap);
|
||||||
|
x = d * Math.cos(ang);
|
||||||
|
y = d * Math.sin(ang);
|
||||||
|
}
|
||||||
|
p.scale = scale;
|
||||||
|
p.x = x;
|
||||||
|
p.y = y;
|
||||||
|
placed.push({ x, y, r });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Core→rim density: the settled heart of the galaxy has more activity per
|
* Core→rim density: the settled heart of the galaxy has more activity per
|
||||||
* system; the rim is thinner, lonelier. `factor` scales every settlement
|
* system; the rim is thinner, lonelier. `factor` scales every settlement
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,30 @@
|
||||||
import Phaser from '../vendor/phaser.js';
|
import Phaser from '../vendor/phaser.js';
|
||||||
import { config } from '../config/Config.js';
|
import { config } from '../config/Config.js';
|
||||||
import { toColor, toCss } from '../utils/Color.js';
|
import { toColor, toCss } from '../utils/Color.js';
|
||||||
import { fontStack } from '../utils/Theme.js';
|
import { fontStack, themeColor } from '../utils/Theme.js';
|
||||||
import { Rng } from '../utils/Rng.js';
|
import { Rng } from '../utils/Rng.js';
|
||||||
import { Galaxy } from '../galaxy/Galaxy.js';
|
import { Galaxy } from '../galaxy/Galaxy.js';
|
||||||
import { formatSystemReport } from '../galaxy/SystemReport.js';
|
import { formatSystemReport } from '../galaxy/SystemReport.js';
|
||||||
|
import { Discovery } from '../galaxy/Discovery.js';
|
||||||
import { Ship } from '../entities/Ship.js';
|
import { Ship } from '../entities/Ship.js';
|
||||||
import { Planet } from '../entities/Planet.js';
|
import { Planet } from '../entities/Planet.js';
|
||||||
import { Starfield } from '../visuals/Starfield.js';
|
import { Starfield } from '../visuals/Starfield.js';
|
||||||
|
import { DiscoveryCompass, circleInView } from '../ui/DiscoveryCompass.js';
|
||||||
|
|
||||||
const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif";
|
const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif";
|
||||||
const HEADER_FONT = () => fontStack('header', FONT_FALLBACK);
|
const HEADER_FONT = () => fontStack('header', FONT_FALLBACK);
|
||||||
const BODY_FONT = () => fontStack('body', FONT_FALLBACK);
|
const BODY_FONT = () => fontStack('body', FONT_FALLBACK);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The game world (v0.3: the home planet — the player's Terran world —
|
* The game world (v0.3: the current system — the player's home world at the
|
||||||
* in the current system's open space).
|
* origin plus the system's other worlds scattered around it, in open space).
|
||||||
* Click anywhere to fly there.
|
* Click anywhere to fly there.
|
||||||
|
*
|
||||||
|
* Discovery: come within discovery distance (data/game.json) of a world's
|
||||||
|
* edge and it is DISCOVERED (state in this.discovery). Discovered worlds
|
||||||
|
* that are off-screen get a themed compass arrow on the screen edge
|
||||||
|
* (this.compass) pointing the way back — so the player always has a
|
||||||
|
* reference to found worlds while exploring the rest of the system.
|
||||||
*/
|
*/
|
||||||
export class GameScene extends Phaser.Scene {
|
export class GameScene extends Phaser.Scene {
|
||||||
constructor() {
|
constructor() {
|
||||||
|
|
@ -42,8 +50,12 @@ export class GameScene extends Phaser.Scene {
|
||||||
this.cameraFollowShip = config.get('game.camera.followShip', true);
|
this.cameraFollowShip = config.get('game.camera.followShip', true);
|
||||||
this.cameraFollowRate = config.get('game.camera.followRate', 3.0); // 1/s
|
this.cameraFollowRate = config.get('game.camera.followRate', 3.0); // 1/s
|
||||||
|
|
||||||
// We are, after all, in a system. Show the player which one. (This
|
// We are, after all, in a system. Establish the galaxy (shared
|
||||||
// also establishes the galaxy — and its seed — for what follows.)
|
// registry), the current system record, and its lazy contents — the
|
||||||
|
// dossier below and the world's other planets both read them.
|
||||||
|
this.ensureGalaxy();
|
||||||
|
this.systemRecord = this.galaxy.currentSystem();
|
||||||
|
this.systemContent = this.galaxy.ensureContent(this.systemRecord.id);
|
||||||
this.createSystemHud();
|
this.createSystemHud();
|
||||||
|
|
||||||
// The home planet — the player's Terran world, always present in the
|
// The home planet — the player's Terran world, always present in the
|
||||||
|
|
@ -55,6 +67,28 @@ export class GameScene extends Phaser.Scene {
|
||||||
this.planet = new Planet(this, 0, 0, Planet.frameFor(homeName, homeRng), homeName);
|
this.planet = new Planet(this, 0, 0, Planet.frameFor(homeName, homeRng), homeName);
|
||||||
this.planet.setDepth(5); // above the starfield (depths 0–2), below the ship (10)
|
this.planet.setDepth(5); // above the starfield (depths 0–2), below the ship (10)
|
||||||
|
|
||||||
|
// The rest of the solar system — the generated worlds, placed by the
|
||||||
|
// generator (data/planets.json → solarSystem) in a band around the home
|
||||||
|
// world. Same solid-disc rules as home: fly close, never through.
|
||||||
|
this.systemPlanets = [];
|
||||||
|
if (config.get('planets.solarSystem.enabled', true)) {
|
||||||
|
for (const rec of this.systemContent.planets ?? []) {
|
||||||
|
if (typeof rec.x !== 'number' || typeof rec.y !== 'number') continue;
|
||||||
|
const kind = rec.class || 'rocky';
|
||||||
|
const frame = Planet.frameFor(kind, Rng.derive(this.galaxy.seed, 'planet', rec.name));
|
||||||
|
const tint = config.get(`planets.classTint.${kind}`);
|
||||||
|
const p = new Planet(this, rec.x, rec.y, frame, kind, {
|
||||||
|
scale: rec.scale ?? 1,
|
||||||
|
tint: tint === undefined ? undefined : toColor(tint),
|
||||||
|
});
|
||||||
|
p.setDepth(5);
|
||||||
|
p.discoveryId = rec.name; // unique within the system
|
||||||
|
p.discoveryName = rec.name;
|
||||||
|
this.systemPlanets.push(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.solidPlanets = [this.planet, ...this.systemPlanets];
|
||||||
|
|
||||||
// The ship — a short hop (~150 px, edge-to-edge) from the home world's
|
// The ship — a short hop (~150 px, edge-to-edge) from the home world's
|
||||||
// rim, in a seed-derived direction: same galaxy ⇒ same start.
|
// rim, in a seed-derived direction: same galaxy ⇒ same start.
|
||||||
this.ship = new Ship(this, 0, 0);
|
this.ship = new Ship(this, 0, 0);
|
||||||
|
|
@ -76,6 +110,18 @@ export class GameScene extends Phaser.Scene {
|
||||||
this.starfield = new Starfield(this);
|
this.starfield = new Starfield(this);
|
||||||
this.starfield.create();
|
this.starfield.create();
|
||||||
|
|
||||||
|
// Discovery: which objects the player has found (within discovery
|
||||||
|
// distance of an edge — data/game.json), tracked per system. Kept in
|
||||||
|
// the shared registry so it survives scene restarts; serializable for
|
||||||
|
// saves later (Discovery.toJSON). The compass turns the discovered set
|
||||||
|
// into screen-edge arrows for the objects currently out of sight.
|
||||||
|
this.discovery = this.registry.get('discovery') ?? null;
|
||||||
|
if (!this.discovery) {
|
||||||
|
this.discovery = new Discovery(config.get('game.discovery.distance', 540));
|
||||||
|
this.registry.set('discovery', this.discovery);
|
||||||
|
}
|
||||||
|
this.compass = new DiscoveryCompass(this);
|
||||||
|
|
||||||
// Hint
|
// Hint
|
||||||
this.hint = this.add
|
this.hint = this.add
|
||||||
.text(this.scale.width / 2, this.scale.height - 26, config.get('game.hintText', ''), {
|
.text(this.scale.width / 2, this.scale.height - 26, config.get('game.hintText', ''), {
|
||||||
|
|
@ -87,10 +133,14 @@ export class GameScene extends Phaser.Scene {
|
||||||
.setOrigin(0.5)
|
.setOrigin(0.5)
|
||||||
.setScrollFactor(0); // UI: pinned to the screen, not the world
|
.setScrollFactor(0); // UI: pinned to the screen, not the world
|
||||||
|
|
||||||
// Input: click = fly there. A click inside the planet clamps to the
|
// Input: click = fly there. A click inside a planet clamps to that
|
||||||
// keep-out rim — the ship can stop at the clearance, never inside.
|
// planet's keep-out rim — the ship can stop at the clearance, never
|
||||||
|
// inside. (Worlds don't overlap, so sequential clamping is exact.)
|
||||||
this.input.on('pointerdown', (pointer) => {
|
this.input.on('pointerdown', (pointer) => {
|
||||||
const aim = this.planet.aimPoint(pointer.worldX, pointer.worldY, this.ship.radius);
|
let aim = { x: pointer.worldX, y: pointer.worldY };
|
||||||
|
for (const p of this.solidPlanets) {
|
||||||
|
aim = p.aimPoint(aim.x, aim.y, this.ship.radius);
|
||||||
|
}
|
||||||
this.showTargetMarker(aim.x, aim.y);
|
this.showTargetMarker(aim.x, aim.y);
|
||||||
this.ship.setTarget(aim.x, aim.y);
|
this.ship.setTarget(aim.x, aim.y);
|
||||||
this.hideHint();
|
this.hideHint();
|
||||||
|
|
@ -98,20 +148,11 @@ export class GameScene extends Phaser.Scene {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Top-left HUD: the current system's dossier — name, identity, and what
|
|
||||||
*'s ALREADY THERE: colonies, mining stations, cloud bases, stations adrift
|
|
||||||
* in open space (or "charted · unclaimed" when nobody's settled here).
|
|
||||||
* Formatted by the pure SystemReport helper; this method only renders.
|
|
||||||
*
|
|
||||||
* The galaxy comes from the shared registry (built by the menu from the
|
* The galaxy comes from the shared registry (built by the menu from the
|
||||||
* chosen seed). Dev boots that skip the menu (dev/test-game.html) get a
|
* chosen seed). Dev boots that skip the menu (dev/test-game.html) get a
|
||||||
* fresh dev galaxy so the scene always works standalone.
|
* fresh dev galaxy so the scene always works standalone.
|
||||||
*
|
|
||||||
* The system's CONTENTS are generated here, on arrival — the first touch
|
|
||||||
* of the lazy level-2 generation. (Roster/positions were fixed at the
|
|
||||||
* menu's New Game click; this is just the "build the room" part.)
|
|
||||||
*/
|
*/
|
||||||
createSystemHud() {
|
ensureGalaxy() {
|
||||||
this.galaxy = this.registry.get('galaxy') ?? null;
|
this.galaxy = this.registry.get('galaxy') ?? null;
|
||||||
if (!this.galaxy) {
|
if (!this.galaxy) {
|
||||||
const seed = Rng.randomSeedString(8);
|
const seed = Rng.randomSeedString(8);
|
||||||
|
|
@ -120,12 +161,24 @@ export class GameScene extends Phaser.Scene {
|
||||||
this.registry.set('seed', seed);
|
this.registry.set('seed', seed);
|
||||||
console.warn(`[orbit] no galaxy in the registry — generated a dev galaxy (seed "${seed}")`);
|
console.warn(`[orbit] no galaxy in the registry — generated a dev galaxy (seed "${seed}")`);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const current = this.galaxy.currentSystem();
|
/**
|
||||||
const content = this.galaxy.ensureContent(current.id);
|
* Top-left HUD: the current system's dossier — name, identity, and what
|
||||||
const report = formatSystemReport(content);
|
*'s ALREADY THERE: colonies, mining stations, cloud bases, stations adrift
|
||||||
|
* in open space (or "charted · unclaimed" when nobody's settled here).
|
||||||
|
* Formatted by the pure SystemReport helper; this method only renders.
|
||||||
|
*
|
||||||
|
* The system's CONTENTS were ensured in create() (ensureGalaxy +
|
||||||
|
* ensureContent) — the first touch of the lazy level-2 generation.
|
||||||
|
* (Roster/positions were fixed at the menu's New Game click; this is
|
||||||
|
* just the "build the room" part.)
|
||||||
|
*/
|
||||||
|
createSystemHud() {
|
||||||
|
const report = formatSystemReport(this.systemContent);
|
||||||
const fam = BODY_FONT();
|
const fam = BODY_FONT();
|
||||||
const famHeader = HEADER_FONT();
|
const famHeader = HEADER_FONT();
|
||||||
|
const current = this.systemRecord;
|
||||||
|
|
||||||
let y = 14;
|
let y = 14;
|
||||||
const line = (text, style) => {
|
const line = (text, style) => {
|
||||||
|
|
@ -160,11 +213,15 @@ export class GameScene extends Phaser.Scene {
|
||||||
this.time.update(_time, delta);
|
this.time.update(_time, delta);
|
||||||
this.tweens.update();
|
this.tweens.update();
|
||||||
this.ship.update(_time, delta);
|
this.ship.update(_time, delta);
|
||||||
// The home world is solid: the ship may come within the clearance in
|
// Every world in the system is solid: the ship may come within the
|
||||||
// data/planets.json of its rim, but never closer (or through it).
|
// clearance in data/planets.json of a rim, but never closer (or
|
||||||
this.planet.constrainShip(this.ship, this.ship.radius);
|
// through it).
|
||||||
|
for (const p of this.solidPlanets) {
|
||||||
|
p.constrainShip(this.ship, this.ship.radius);
|
||||||
|
}
|
||||||
this.updateCamera(delta);
|
this.updateCamera(delta);
|
||||||
this.starfield.update(); // after the camera, so it sees this frame's motion
|
this.starfield.update(); // after the camera, so it sees this frame's motion
|
||||||
|
this.updateDiscovery(_time, delta); // last: sees this frame's final camera view
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -189,6 +246,115 @@ export class GameScene extends Phaser.Scene {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discovery, every frame (a handful of distance tests — cheap):
|
||||||
|
* 1. Ship within discovery distance of a world's edge → DISCOVERED
|
||||||
|
* (once — state lives in this.discovery), with a rim ping + toast.
|
||||||
|
* 2. Discovered worlds that are off-screen feed the compass, which
|
||||||
|
* draws the themed screen-edge arrows pointing back to them.
|
||||||
|
*/
|
||||||
|
updateDiscovery(time, delta) {
|
||||||
|
const objects = this.discoverableObjects();
|
||||||
|
const sysId = this.systemRecord.id;
|
||||||
|
|
||||||
|
const fresh = this.discovery.check(sysId, this.ship.x, this.ship.y, objects);
|
||||||
|
for (const o of fresh) this.celebrateDiscovery(o);
|
||||||
|
|
||||||
|
// Which discovered objects are NOT on screen right now? (The camera
|
||||||
|
// never zooms, so the world view is scroll + canvas size.)
|
||||||
|
const cam = this.cameras.main;
|
||||||
|
const view = { left: cam.scrollX, top: cam.scrollY, w: this.scale.width, h: this.scale.height };
|
||||||
|
const offscreen = [];
|
||||||
|
for (const o of objects) {
|
||||||
|
if (this.discovery.isDiscovered(sysId, o.id) && !circleInView(o.x, o.y, o.radius, view)) {
|
||||||
|
offscreen.push(o);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.compass.refresh(offscreen, view, this.scale.width, this.scale.height, time, delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The discoverable objects of the system, with compass metadata. */
|
||||||
|
discoverableObjects() {
|
||||||
|
const out = [];
|
||||||
|
out.push({
|
||||||
|
id: 'home',
|
||||||
|
x: this.planet.x,
|
||||||
|
y: this.planet.y,
|
||||||
|
radius: this.planet.radius,
|
||||||
|
typeLabel: config.get('planets.homeTypeLabel', 'Home World'),
|
||||||
|
name: config.get('planets.homeName', 'Terra'),
|
||||||
|
});
|
||||||
|
for (const p of this.systemPlanets) {
|
||||||
|
out.push({
|
||||||
|
id: p.discoveryId,
|
||||||
|
x: p.x,
|
||||||
|
y: p.y,
|
||||||
|
radius: p.radius,
|
||||||
|
typeLabel: config.get(`planets.typeLabels.${p.name}`, p.name),
|
||||||
|
name: p.discoveryName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The "new object" moment: a rim ping at the world + a HUD toast. */
|
||||||
|
celebrateDiscovery(o) {
|
||||||
|
const neon = themeColor('neon', 0x00e5ff);
|
||||||
|
|
||||||
|
// Expanding ring at the world's rim (world space).
|
||||||
|
const ring = this.add.circle(o.x, o.y, o.radius, 0, 0).setStrokeStyle(2, neon, 0.9).setDepth(6);
|
||||||
|
this.tweens.add({
|
||||||
|
targets: ring,
|
||||||
|
scale: 1.12,
|
||||||
|
alpha: 0,
|
||||||
|
duration: 750,
|
||||||
|
ease: 'Sine.easeOut',
|
||||||
|
onComplete: () => ring.destroy(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Top-center HUD toast, in the console language.
|
||||||
|
if (Array.isArray(this.toast)) {
|
||||||
|
for (const g of this.toast) g.destroy();
|
||||||
|
}
|
||||||
|
const fam = BODY_FONT();
|
||||||
|
const type = (o.typeLabel ?? 'OBJECT').toUpperCase();
|
||||||
|
const name = o.name ? String(o.name).toUpperCase() : null;
|
||||||
|
const label = name ? `DISCOVERED — ${name} · ${type}` : `DISCOVERED — ${type}`;
|
||||||
|
const g1 = this.add
|
||||||
|
.text(0, 0, '\u25b8', { fontFamily: fam, fontSize: '12px', color: toCss(neon) })
|
||||||
|
.setOrigin(0, 0.5)
|
||||||
|
.setScrollFactor(0); // UI — pinned to the screen, not the world
|
||||||
|
const g2 = this.add
|
||||||
|
.text(0, 0, label, {
|
||||||
|
fontFamily: fam,
|
||||||
|
fontSize: '11px',
|
||||||
|
color: toCss(themeColor('dim', 0x7d92c4)),
|
||||||
|
letterSpacing: 2,
|
||||||
|
})
|
||||||
|
.setOrigin(0, 0.5)
|
||||||
|
.setScrollFactor(0); // UI — pinned to the screen, not the world
|
||||||
|
const total = g1.width + 10 + g2.width;
|
||||||
|
const x0 = this.scale.width / 2 - total / 2;
|
||||||
|
g1.setPosition(x0, 26).setDepth(45).setAlpha(0);
|
||||||
|
g2.setPosition(x0 + g1.width + 10, 26).setDepth(45).setAlpha(0);
|
||||||
|
this.toast = [g1, g2];
|
||||||
|
this.tweens.add({ targets: this.toast, alpha: 1, duration: 180, ease: 'Sine.easeOut' });
|
||||||
|
this.time.delayedCall(2400, () => {
|
||||||
|
if (!Array.isArray(this.toast)) return;
|
||||||
|
const [a, b] = this.toast;
|
||||||
|
this.toast = null;
|
||||||
|
this.tweens.add({
|
||||||
|
targets: [a, b],
|
||||||
|
alpha: 0,
|
||||||
|
duration: 350,
|
||||||
|
onComplete: () => {
|
||||||
|
a.destroy();
|
||||||
|
b.destroy();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
showTargetMarker(x, y) {
|
showTargetMarker(x, y) {
|
||||||
const color = toColor(config.get('game.markerColor', '#41c7ff'));
|
const color = toColor(config.get('game.markerColor', '#41c7ff'));
|
||||||
const marker = this.add.circle(x, y, 10, color, 0.8).setDepth(5);
|
const marker = this.add.circle(x, y, 10, color, 0.8).setDepth(5);
|
||||||
|
|
@ -217,5 +383,6 @@ export class GameScene extends Phaser.Scene {
|
||||||
|
|
||||||
shutdown() {
|
shutdown() {
|
||||||
this.starfield?.destroy();
|
this.starfield?.destroy();
|
||||||
|
this.compass?.destroy();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,301 @@
|
||||||
|
import Phaser from '../vendor/phaser.js';
|
||||||
|
import { config } from '../config/Config.js';
|
||||||
|
import { toColor, toCss } from '../utils/Color.js';
|
||||||
|
import { fontStack, themeColor } from '../utils/Theme.js';
|
||||||
|
import { CyberShape } from './CyberShape.js';
|
||||||
|
|
||||||
|
const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif";
|
||||||
|
const ARROW_KEY = '__compass_arrow';
|
||||||
|
const TAU = Math.PI * 2;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The off-screen compass: for every DISCOVERED object that is currently
|
||||||
|
* off-screen, a themed arrow sits on the screen edge pointing at it, with
|
||||||
|
* a cut-corner chip beside it carrying the object's TYPE and NAME (if it
|
||||||
|
* has one). This is how the player finds their way back to worlds they've
|
||||||
|
* already found while exploring the rest of the system.
|
||||||
|
*
|
||||||
|
* Visual language = the shared cyberpunk set (data/theme.json +
|
||||||
|
* CyberShape): neon chevron arrows with a soft glow pass over a dark
|
||||||
|
* fill, speed ticks streaming behind, a slow beacon pulse — and a dim
|
||||||
|
* cut-corner readout chip (neon type label, ink name).
|
||||||
|
*
|
||||||
|
* The geometry helpers are PURE and exported for Node testing
|
||||||
|
* (dev/discovery.test.mjs):
|
||||||
|
* edgeAnchor(w, h, inset, angle) — where the center-out ray meets the
|
||||||
|
* screen-edge rect (inset from border)
|
||||||
|
* circleInView(x, y, r, view) — is a circle (fully or partly) on screen
|
||||||
|
* lerpAngle(a, b, k) — shortest-arc angle easing
|
||||||
|
*
|
||||||
|
* Component usage:
|
||||||
|
* const compass = new DiscoveryCompass(scene);
|
||||||
|
* compass.refresh(targets, view, w, h, time, delta);
|
||||||
|
* targets — [{ id, x, y, radius, typeLabel, name? }] (world coords)
|
||||||
|
* view — { left, top, w, h } the camera's world-space view rect
|
||||||
|
*/
|
||||||
|
export class DiscoveryCompass extends Phaser.GameObjects.Container {
|
||||||
|
constructor(scene) {
|
||||||
|
super(scene, 0, 0);
|
||||||
|
scene.add.existing(this); // v4 quirk: new'd objects are not on the display list
|
||||||
|
this.setScrollFactor(0); // UI — pinned to the screen
|
||||||
|
this.setDepth(40); // above the HUD dossier (30)
|
||||||
|
|
||||||
|
const cfg = config.get('game.discovery.compass', {});
|
||||||
|
this.inset = cfg.edgeInset ?? 26; // arrow line, px in from the border
|
||||||
|
this.minSeparation = cfg.minSeparation ?? 130; // px kept between arrows
|
||||||
|
/** @type {Map<string, object>} target id → entry (arrow, chip, angle…) */
|
||||||
|
this.entries = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconcile + move the arrows. Call once per frame with the CURRENT
|
||||||
|
* off-screen discovered set (the scene computes it — see GameScene).
|
||||||
|
*/
|
||||||
|
refresh(targets, view, w, h, time, delta) {
|
||||||
|
// Reconcile: create entries for new targets, retire the rest.
|
||||||
|
const seen = new Set(targets.map((t) => t.id));
|
||||||
|
for (const t of targets) {
|
||||||
|
if (!this.entries.has(t.id)) this.entries.set(t.id, this.makeEntry(t));
|
||||||
|
}
|
||||||
|
for (const [id, e] of [...this.entries]) {
|
||||||
|
if (!seen.has(id)) {
|
||||||
|
e.arrow.destroy();
|
||||||
|
e.chipRoot.destroy();
|
||||||
|
this.entries.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.entries.size === 0) return;
|
||||||
|
|
||||||
|
const dt = Math.min(delta, 64) / 1000;
|
||||||
|
const cx = w / 2;
|
||||||
|
const cy = h / 2;
|
||||||
|
|
||||||
|
// Ease each arrow toward its object (shortest arc, no long-way sweep).
|
||||||
|
const k = 1 - Math.exp(-9 * dt);
|
||||||
|
for (const t of targets) {
|
||||||
|
const e = this.entries.get(t.id);
|
||||||
|
const sx = t.x - view.left; // screen coords (the game never zooms)
|
||||||
|
const sy = t.y - view.top;
|
||||||
|
const desired = Math.atan2(sy - cy, sx - cx);
|
||||||
|
e.angle = e.angle === null ? desired : lerpAngle(e.angle, desired, k);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep arrows from stacking where the objects cluster in one direction.
|
||||||
|
separateAngles(targets.map((t) => this.entries.get(t.id)), w, h, this.inset, this.minSeparation, cx, cy);
|
||||||
|
|
||||||
|
for (const t of targets) {
|
||||||
|
const e = this.entries.get(t.id);
|
||||||
|
const a = edgeAnchor(w, h, this.inset, e.angle);
|
||||||
|
const dx = Math.cos(e.angle);
|
||||||
|
const dy = Math.sin(e.angle);
|
||||||
|
// The arrow's tip sits on the edge line (inset from the border) and
|
||||||
|
// points outward; its body + speed ticks extend inward. (Local tip
|
||||||
|
// offset TIP, tail offset TAIL — see ensureArrowTexture.)
|
||||||
|
const TIP = 25;
|
||||||
|
const TAIL = 28;
|
||||||
|
e.arrow.setX(a.x - dx * TIP).setY(a.y - dy * TIP).setRotation(e.angle);
|
||||||
|
|
||||||
|
// Chip: just inside the arrow's tail, centered on the ray, clamped
|
||||||
|
// so it never leaves the screen. The chip's leading half is its
|
||||||
|
// half-width (edge arrows) or half-height (top/bottom arrows).
|
||||||
|
const halfLead = Math.abs(dx) >= Math.abs(dy) ? e.w / 2 : e.h / 2;
|
||||||
|
const lead = TIP + TAIL + 8 + halfLead;
|
||||||
|
const px = clampNum(a.x - dx * lead, e.w / 2 + 6, w - e.w / 2 - 6);
|
||||||
|
const py = clampNum(a.y - dy * lead, e.h / 2 + 6, h - e.h / 2 - 6);
|
||||||
|
e.chipRoot.setPosition(px, py);
|
||||||
|
|
||||||
|
// Slow beacon pulse, staggered per object.
|
||||||
|
e.arrow.setAlpha(0.8 + 0.2 * Math.sin(time * 0.004 + e.phase));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the arrow + readout chip for one target. */
|
||||||
|
makeEntry(t) {
|
||||||
|
const scene = this.scene;
|
||||||
|
const fam = fontStack('body', FONT_FALLBACK);
|
||||||
|
const neon = themeColor('neon', 0x00e5ff);
|
||||||
|
const ink = themeColor('ink', 0xeaf6ff);
|
||||||
|
const typeLabel = (t.typeLabel ?? 'OBJECT').toUpperCase();
|
||||||
|
const nameLabel = t.name ? String(t.name).toUpperCase() : '';
|
||||||
|
|
||||||
|
// Text is measured first (canvas fonts), then the chip is cut to fit.
|
||||||
|
const typeText = scene.add
|
||||||
|
.text(0, 0, typeLabel, { fontFamily: fam, fontSize: '10px', color: toCss(neon), letterSpacing: 2 })
|
||||||
|
.setOrigin(0, 0.5);
|
||||||
|
let nameText = null;
|
||||||
|
if (nameLabel) {
|
||||||
|
nameText = scene.add
|
||||||
|
.text(0, 0, nameLabel, { fontFamily: fam, fontSize: '13px', color: toCss(ink), letterSpacing: 1 })
|
||||||
|
.setOrigin(0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
const padX = 13;
|
||||||
|
const gap = 2;
|
||||||
|
const typeW = typeText.width;
|
||||||
|
const typeH = typeText.height;
|
||||||
|
const nameW = nameText ? nameText.width : 0;
|
||||||
|
const nameH = nameText ? nameText.height : 0;
|
||||||
|
const w = Math.max(typeW, nameW) + padX * 2;
|
||||||
|
const h = typeH + nameH + gap + 13;
|
||||||
|
const total = typeH + nameH + gap;
|
||||||
|
const x0 = -w / 2 + padX; // left-aligned readout, vertically centered
|
||||||
|
typeText.setPosition(x0, -total / 2 + typeH / 2);
|
||||||
|
if (nameText) nameText.setPosition(x0, -total / 2 + typeH + gap + nameH / 2);
|
||||||
|
|
||||||
|
const chip = scene.add.graphics();
|
||||||
|
CyberShape.draw(chip, w, h, {
|
||||||
|
notch: Math.min(8, h * 0.3),
|
||||||
|
fill: toColor(config.get('theme.colors.panel', '#0a1120')),
|
||||||
|
fillAlpha: 0.86,
|
||||||
|
stroke: neon,
|
||||||
|
strokeAlpha: 0.55,
|
||||||
|
lineWidth: 1.5,
|
||||||
|
glow: neon,
|
||||||
|
glowAlpha: 0.16,
|
||||||
|
});
|
||||||
|
|
||||||
|
// MenuButton pattern: build the Container by hand, then add the pieces.
|
||||||
|
const chipRoot = new Phaser.GameObjects.Container(scene, 0, 0);
|
||||||
|
scene.add.existing(chipRoot);
|
||||||
|
chipRoot.setScrollFactor(0); // UI — pinned to the screen
|
||||||
|
chipRoot.setDepth(40); // with the compass (above the HUD dossier)
|
||||||
|
chipRoot.add(chip);
|
||||||
|
chipRoot.add(typeText);
|
||||||
|
if (nameText) chipRoot.add(nameText);
|
||||||
|
|
||||||
|
// Texture FIRST: in v4 an image bound to a not-yet-existing key keeps
|
||||||
|
// the __MISSING texture forever, even after the key is generated.
|
||||||
|
ensureArrowTexture(scene);
|
||||||
|
const arrow = scene.add.image(0, 0, ARROW_KEY);
|
||||||
|
arrow.setScrollFactor(0); // UI — pinned to the screen
|
||||||
|
arrow.setDepth(40);
|
||||||
|
|
||||||
|
// Stagger the pulse so a row of arrows doesn't blink in unison.
|
||||||
|
let phase = 0;
|
||||||
|
for (const ch of String(t.id)) phase = (phase * 31 + ch.charCodeAt(0)) % 997;
|
||||||
|
return { arrow, chipRoot, w, h, angle: null, phase: phase * 0.063 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
// Pure geometry (no Phaser) — exported for dev/discovery.test.mjs
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** v4-safe local clamp (no Phaser.Math dependency in the pure path). */
|
||||||
|
function clampNum(v, lo, hi) {
|
||||||
|
return Math.min(hi, Math.max(lo, v));
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrapPI(a) {
|
||||||
|
const t = ((((a + Math.PI) % TAU) + TAU) % TAU);
|
||||||
|
return t - Math.PI;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ease angle `a` toward `b` by fraction `k`, always the shortest way.
|
||||||
|
*/
|
||||||
|
export function lerpAngle(a, b, k) {
|
||||||
|
return a + wrapPI(b - a) * clampNum(k, 0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the ray from the screen center at `angle` (radians, screen y-down)
|
||||||
|
* meets the screen-edge rect inset by `inset` px. That's where an off-screen
|
||||||
|
* object's arrow goes, pointing outward along the ray.
|
||||||
|
*/
|
||||||
|
export function edgeAnchor(w, h, inset, angle) {
|
||||||
|
const hx = Math.max(1, w / 2 - inset);
|
||||||
|
const hy = Math.max(1, h / 2 - inset);
|
||||||
|
const dx = Math.cos(angle);
|
||||||
|
const dy = Math.sin(angle);
|
||||||
|
let t = Infinity;
|
||||||
|
if (Math.abs(dx) > 1e-9) t = Math.min(t, (dx > 0 ? hx : -hx) / dx);
|
||||||
|
if (Math.abs(dy) > 1e-9) t = Math.min(t, (dy > 0 ? hy : -hy) / dy);
|
||||||
|
if (!Number.isFinite(t)) return { x: w / 2, y: h / 2 };
|
||||||
|
return { x: w / 2 + dx * t, y: h / 2 + dy * t };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is the circle (x, y, r) on screen — fully or partly? `view` is the
|
||||||
|
* camera's world-space rect { left, top, w, h }.
|
||||||
|
*/
|
||||||
|
export function circleInView(x, y, r, view) {
|
||||||
|
const cx = clampNum(x, view.left, view.left + view.w);
|
||||||
|
const cy = clampNum(y, view.top, view.top + view.h);
|
||||||
|
const dx = x - cx;
|
||||||
|
const dy = y - cy;
|
||||||
|
return dx * dx + dy * dy <= r * r;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Midpoint of the shorter arc between two angles. */
|
||||||
|
function midAngle(a, b) {
|
||||||
|
return a + wrapPI(b - a) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep arrows at least `minSep` px apart along the edge: repeatedly nudge
|
||||||
|
* the angular gap of any pair that is too close. Mutates entries' `angle`.
|
||||||
|
*/
|
||||||
|
function separateAngles(entries, w, h, inset, minSep, cx, cy) {
|
||||||
|
if (entries.length < 2 || minSep <= 0) return;
|
||||||
|
for (let iter = 0; iter < 4; iter++) {
|
||||||
|
let touched = false;
|
||||||
|
for (let i = 0; i < entries.length; i++) {
|
||||||
|
for (let j = i + 1; j < entries.length; j++) {
|
||||||
|
const ai = edgeAnchor(w, h, inset, entries[i].angle);
|
||||||
|
const aj = edgeAnchor(w, h, inset, entries[j].angle);
|
||||||
|
const d = Math.hypot(ai.x - aj.x, ai.y - aj.y);
|
||||||
|
if (d >= minSep) continue;
|
||||||
|
const midA = midAngle(entries[i].angle, entries[j].angle);
|
||||||
|
const midP = edgeAnchor(w, h, inset, midA);
|
||||||
|
const dist = Math.max(60, Math.hypot(midP.x - cx, midP.y - cy));
|
||||||
|
const push = ((minSep - d) * 0.5) / dist; // radians closing half the gap
|
||||||
|
const s1 = Math.sign(wrapPI(entries[i].angle - midA)) || 1;
|
||||||
|
const s2 = Math.sign(wrapPI(entries[j].angle - midA)) || -1;
|
||||||
|
entries[i].angle += (s1 * push) / 2;
|
||||||
|
entries[j].angle += (s2 * push) / 2;
|
||||||
|
touched = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!touched) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The arrow glyph, generated once per game (procedural, no assets —
|
||||||
|
* same pattern as Ship.ensureTexture). A neon chevron head with a soft
|
||||||
|
* glow pass over a dark fill, plus speed ticks streaming behind it.
|
||||||
|
* Points +x (angle 0); the tip sits 25 px right of the texture center.
|
||||||
|
*/
|
||||||
|
function ensureArrowTexture(scene) {
|
||||||
|
if (scene.textures.exists(ARROW_KEY)) return;
|
||||||
|
const neon = toColor(config.get('theme.colors.neon', '#00e5ff'));
|
||||||
|
const fill = toColor(config.get('theme.colors.panel', '#0a1120'));
|
||||||
|
const W = 56;
|
||||||
|
const H = 36;
|
||||||
|
const head = [
|
||||||
|
{ x: 53, y: 18 }, // tip
|
||||||
|
{ x: 12, y: 3 },
|
||||||
|
{ x: 24, y: 18 }, // notch
|
||||||
|
{ x: 12, y: 33 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const g = scene.make.graphics({ add: false });
|
||||||
|
// Soft outer pass (the "neon glow"), then dark fill, then the sharp edge.
|
||||||
|
g.lineStyle(7, neon, 0.22);
|
||||||
|
g.strokePoints(head, true);
|
||||||
|
g.fillStyle(fill, 0.9);
|
||||||
|
g.fillPoints(head, true);
|
||||||
|
g.lineStyle(2, neon, 1);
|
||||||
|
g.strokePoints(head, true);
|
||||||
|
// Speed ticks behind the head (stronger toward the tip).
|
||||||
|
g.lineStyle(2, neon, 0.8);
|
||||||
|
g.lineBetween(3, 13, 13, 16);
|
||||||
|
g.lineBetween(0, 18, 15, 18);
|
||||||
|
g.lineStyle(2, neon, 0.5);
|
||||||
|
g.lineBetween(3, 23, 13, 20);
|
||||||
|
g.generateTexture(ARROW_KEY, W, H);
|
||||||
|
g.destroy();
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue