diff --git a/data/game.json b/data/game.json index 3716e9c..bd6a3d4 100644 --- a/data/game.json +++ b/data/game.json @@ -19,5 +19,13 @@ "camera": { "followShip": true, "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 + } } } diff --git a/data/manifest.json b/data/manifest.json index d9e80d4..020d928 100644 --- a/data/manifest.json +++ b/data/manifest.json @@ -7,6 +7,7 @@ "planets.json", "galaxy.json", "systems.json", + "settlements.json", "naming.json" ] } diff --git a/data/planets.json b/data/planets.json index 64d10d6..1899dea 100644 --- a/data/planets.json +++ b/data/planets.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", "frameWidth": 1024, "frameHeight": 1024, "scale": 1.0, "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", + "homeName": "Terra", + "homeTypeLabel": "Home World", "shipClearance": 50, - "spawnDistanceFromEdge": 150 + "spawnDistanceFromEdge": 150, + "solarSystem": { + "enabled": true, + "minOrbit": 2600, + "maxOrbit": 8800, + "minEdgeGap": 1200 + } } diff --git a/dev/discovery.test.mjs b/dev/discovery.test.mjs new file mode 100644 index 0000000..60daa35 --- /dev/null +++ b/dev/discovery.test.mjs @@ -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).`); diff --git a/dev/smoke-game.mjs b/dev/smoke-game.mjs index de02802..1a21a5b 100644 --- a/dev/smoke-game.mjs +++ b/dev/smoke-game.mjs @@ -6,6 +6,14 @@ * python3 -m http.server 8080 * firefox --headless --screenshot shot.png \ * --window-size=1280,720 http://localhost:8080/dev/test-game.html + * + * Dev hooks (URL params, dev only — ignored by the game itself): + * ?ship=, 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= fly the ship to (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 { config } from '../js/config/Config.js'; @@ -16,7 +24,38 @@ import { GameScene } from '../js/scenes/GameScene.js'; const data = await ConfigLoader.load(); config.init(data); 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]; const game = new Phaser.Game(gameConfig); window.game = game; 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); +} diff --git a/docs/PROJECT_NOTES.md b/docs/PROJECT_NOTES.md index 42a578f..8e013b1 100644 --- a/docs/PROJECT_NOTES.md +++ b/docs/PROJECT_NOTES.md @@ -146,6 +146,40 @@ unclaimed". Model & seams: of interest: the data already says what's there and where (anchor = 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 - 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 strings — use `toCss()` from `js/utils/Color.js` (keep `toColor()` for 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 the quirks above — they may go away). @@ -183,6 +221,12 @@ unclaimed". Model & seams: core→rim density gradient, populations, and a `owner` seam reserved for the factions/pirates to come; readable as a HUD dossier (`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, and the player's place in a populated galaxy - [ ] Landing & exploration: settlements become points of interest you diff --git a/js/entities/Planet.js b/js/entities/Planet.js index ef90cb4..24dbad6 100644 --- a/js/entities/Planet.js +++ b/js/entities/Planet.js @@ -40,13 +40,18 @@ export class Planet extends Phaser.GameObjects.Sprite { * @param {number} y — world y * @param {number} [frame=0] — spritesheet frame index (usually from Planet.frameFor) * @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); scene.add.existing(this); this.name = name; - const scale = config.get('planets.scale', 1); + const scale = config.get('planets.scale', 1) * (o.scale ?? 1); this.setScale(scale); // 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; // Edge-to-edge gap the ship may close in on the rim (never less). 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`. */ diff --git a/js/galaxy/Discovery.js b/js/galaxy/Discovery.js new file mode 100644 index 0000000..0635b0c --- /dev/null +++ b/js/galaxy/Discovery.js @@ -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>} 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} 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; + } +} diff --git a/js/galaxy/SystemGenerator.js b/js/galaxy/SystemGenerator.js index 88371ec..b74f5c9 100644 --- a/js/galaxy/SystemGenerator.js +++ b/js/galaxy/SystemGenerator.js @@ -80,6 +80,7 @@ export function generateSystemContent(galaxy, record, typeDefs = null) { habitable: pclass === 'rocky' && rng.chance(attr.habitability ?? 0.1), }); } + layoutSystemPlanets(galaxy.seed, record.id, planets); // --- Settlements (the lived-in layer) --------------------------------- 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 * system; the rim is thinner, lonelier. `factor` scales every settlement diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index ce20979..e475aff 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -1,22 +1,30 @@ import Phaser from '../vendor/phaser.js'; import { config } from '../config/Config.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 { Galaxy } from '../galaxy/Galaxy.js'; import { formatSystemReport } from '../galaxy/SystemReport.js'; +import { Discovery } from '../galaxy/Discovery.js'; import { Ship } from '../entities/Ship.js'; import { Planet } from '../entities/Planet.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 HEADER_FONT = () => fontStack('header', FONT_FALLBACK); const BODY_FONT = () => fontStack('body', FONT_FALLBACK); /** - * The game world (v0.3: the home planet — the player's Terran world — - * in the current system's open space). + * The game world (v0.3: the current system — the player's home world at the + * origin plus the system's other worlds scattered around it, in open space). * 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 { constructor() { @@ -42,8 +50,12 @@ export class GameScene extends Phaser.Scene { this.cameraFollowShip = config.get('game.camera.followShip', true); this.cameraFollowRate = config.get('game.camera.followRate', 3.0); // 1/s - // We are, after all, in a system. Show the player which one. (This - // also establishes the galaxy — and its seed — for what follows.) + // We are, after all, in a system. Establish the galaxy (shared + // 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(); // 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.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 // rim, in a seed-derived direction: same galaxy ⇒ same start. this.ship = new Ship(this, 0, 0); @@ -76,6 +110,18 @@ export class GameScene extends Phaser.Scene { this.starfield = new Starfield(this); 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 this.hint = this.add .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) .setScrollFactor(0); // UI: pinned to the screen, not the world - // Input: click = fly there. A click inside the planet clamps to the - // keep-out rim — the ship can stop at the clearance, never inside. + // Input: click = fly there. A click inside a planet clamps to that + // 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) => { - 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.ship.setTarget(aim.x, aim.y); 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 * chosen seed). Dev boots that skip the menu (dev/test-game.html) get a * 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; if (!this.galaxy) { const seed = Rng.randomSeedString(8); @@ -120,12 +161,24 @@ export class GameScene extends Phaser.Scene { this.registry.set('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); - const report = formatSystemReport(content); + /** + * 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 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 famHeader = HEADER_FONT(); + const current = this.systemRecord; let y = 14; const line = (text, style) => { @@ -160,11 +213,15 @@ export class GameScene extends Phaser.Scene { this.time.update(_time, delta); this.tweens.update(); this.ship.update(_time, delta); - // The home world is solid: the ship may come within the clearance in - // data/planets.json of its rim, but never closer (or through it). - this.planet.constrainShip(this.ship, this.ship.radius); + // Every world in the system is solid: the ship may come within the + // clearance in data/planets.json of a rim, but never closer (or + // through it). + for (const p of this.solidPlanets) { + p.constrainShip(this.ship, this.ship.radius); + } this.updateCamera(delta); 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) { const color = toColor(config.get('game.markerColor', '#41c7ff')); const marker = this.add.circle(x, y, 10, color, 0.8).setDepth(5); @@ -217,5 +383,6 @@ export class GameScene extends Phaser.Scene { shutdown() { this.starfield?.destroy(); + this.compass?.destroy(); } } diff --git a/js/ui/DiscoveryCompass.js b/js/ui/DiscoveryCompass.js new file mode 100644 index 0000000..2b8aa0a --- /dev/null +++ b/js/ui/DiscoveryCompass.js @@ -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} 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(); +}