Add the tether: the player's range, a level-1 zone (5120 px) with a glitchy barrier rim
The player starts with a level-1 tether anchored on the home world: a circular zone 5120 px from the planet's center. The ship may fly anywhere in the UNION of its tethers' zones — where zones overlap there is no line and no wall — and the union's boundary is a hard barrier, rendered as a thick cyberpunk glitch dotted line (marching dashes, additive glow + RGB fringe, ambient glitch bursts with dash flicker, radial displacement and data drops, contact shudder + camera kick + throttled console toast where the ship bumps it). - data/tether.json: level radii (5120 x 1.25^lv), the starting tether, barrier line/look, glitch and contact tuning (manifest-registered) - js/tether/Tether.js: pure range math (union membership, clamp to the nearest union boundary point + normal, visibleArcs = each tether's share of the union boundary) — Node-tested - js/tether/TetherField.js: scene-facing field — add/remove/setLevel seam for future builds/upgrades, per-frame ship constraint, and the barrier renderer (on-screen dot culling only) - js/scenes/GameScene.js: home tether at the system origin, HUD readout (level / range / anchor per tether), click-to-fly and autopilot targets clamped to the union boundary, contact feedback, destruction on shutdown - dev/tether.test.mjs: 29 checks incl. the two-direction boundary property (no line in overlaps, no gaps in the line) and a numeric nearest-boundary cross-check (worst 0.79 px) - dev/smoke-game.mjs: ?tethers / ?homeLevel / ?report dev hooks for manual and headless verification - README + PROJECT_NOTES: the system, its rules and its layering Verified in headless Chromium: clean boot (menu + game), barrier rendering (calm and glitch-burst frames), union boundary with two tethers (arcs meet at the rim crossings, lens is line-free), ship clamped exactly onto the rim from outside, contact toast fired.
This commit is contained in:
parent
23fcbe7176
commit
30af365c68
15
README.md
15
README.md
|
|
@ -53,6 +53,17 @@ python3 -m http.server 8080
|
||||||
world. The ship spawns ~150 px (edge-to-edge) off its rim in a
|
world. The ship spawns ~150 px (edge-to-edge) off its rim in a
|
||||||
seed-derived direction, may fly in as close as 50 px from the rim, and
|
seed-derived direction, may fly in as close as 50 px from the rim, and
|
||||||
can never cross it — a planet is solid (tuning in `data/planets.json`)
|
can never cross it — a planet is solid (tuning in `data/planets.json`)
|
||||||
|
- **The tether — your range.** You start with a level-1 tether anchored
|
||||||
|
on the home world: a circular zone, **5120 px from the planet's center**
|
||||||
|
(`data/tether.json`). The ship cannot leave it — anywhere in the union
|
||||||
|
of its tethers' zones is open space (where zones overlap there is no
|
||||||
|
line and no wall), and the rim is a hard barrier rendered as a thick,
|
||||||
|
glitchy, marching dotted line (neon core + RGB fringe + ambient glitch
|
||||||
|
bursts, contact shudder where the ship bumps it). Click-to-fly and
|
||||||
|
autopilot targets are clamped to the union boundary, so a world outside
|
||||||
|
your range is where the ship parks — on the line — until a tether grows.
|
||||||
|
Upgrades and extra anchors (planets/stations) plug into
|
||||||
|
`TetherField.add/setLevel` (tuning in `data/tether.json`)
|
||||||
- Camera gently trails the ship; the **parallax starfield** streams past
|
- Camera gently trails the ship; the **parallax starfield** streams past
|
||||||
while it flies and the view slowly recenters (≈1.5 s) once the ship
|
while it flies and the view slowly recenters (≈1.5 s) once the ship
|
||||||
comes to rest
|
comes to rest
|
||||||
|
|
@ -69,6 +80,8 @@ orbit/
|
||||||
│ ├── menu.json # menu text, colors, button layout, seed panel
|
│ ├── menu.json # menu text, colors, button layout, seed panel
|
||||||
│ ├── theme.json # shared UI theme: fonts, palette, CRT/glitch tuning
|
│ ├── theme.json # shared UI theme: fonts, palette, CRT/glitch tuning
|
||||||
│ ├── ship.json # ship feel: thrust, drag, maxSpeed, …
|
│ ├── ship.json # ship feel: thrust, drag, maxSpeed, …
|
||||||
|
│ ├── planets.json # home world + system layout + solid-disc rules
|
||||||
|
│ ├── tether.json # the tether (your range): level radii, barrier line, glitch, contact
|
||||||
│ ├── galaxy.json # galaxy scale & shape (count, radius, spiral…)
|
│ ├── galaxy.json # galaxy scale & shape (count, radius, spiral…)
|
||||||
│ ├── systems.json # system archetypes: theme, attributes, distribution
|
│ ├── systems.json # system archetypes: theme, attributes, distribution
|
||||||
│ ├── settlements.json # the lived-in layer: settlement kinds & populations
|
│ ├── settlements.json # the lived-in layer: settlement kinds & populations
|
||||||
|
|
@ -85,6 +98,7 @@ orbit/
|
||||||
│ ├── scenes/ # MenuScene, GameScene (thin, orchestration)
|
│ ├── scenes/ # MenuScene, GameScene (thin, orchestration)
|
||||||
│ ├── entities/ # Ship (own behavior), Planet (home world, solid)
|
│ ├── entities/ # Ship (own behavior), Planet (home world, solid)
|
||||||
│ ├── galaxy/ # Galaxy (seeded world model), SystemGenerator, SystemReport
|
│ ├── galaxy/ # Galaxy (seeded world model), SystemGenerator, SystemReport
|
||||||
|
│ ├── tether/ # Tether (pure range math) + TetherField (constraint + barrier line)
|
||||||
│ ├── ui/ # MenuButton, GlitchText, CyberShape, ActionBar, DiscoveryCompass (reusable)
|
│ ├── ui/ # MenuButton, GlitchText, CyberShape, ActionBar, DiscoveryCompass (reusable)
|
||||||
│ ├── visuals/ # Starfield, CyberOverlay (CRT/glitch, shared)
|
│ ├── visuals/ # Starfield, CyberOverlay (CRT/glitch, shared)
|
||||||
│ ├── utils/ # small pure helpers (Color, Rng, NameGenerator)
|
│ ├── utils/ # small pure helpers (Color, Rng, NameGenerator)
|
||||||
|
|
@ -134,6 +148,7 @@ node dev/ship-behavior.test.mjs # runs the real Ship.update() loop in Node
|
||||||
node dev/starfield.test.mjs # runs the real Starfield.create() in Node
|
node dev/starfield.test.mjs # runs the real Starfield.create() in Node
|
||||||
node dev/galaxy.test.mjs # galaxy determinism, distribution, lazy vs eager
|
node dev/galaxy.test.mjs # galaxy determinism, distribution, lazy vs eager
|
||||||
node dev/discovery.test.mjs # discovery rules + compass geometry + chip hit test
|
node dev/discovery.test.mjs # discovery rules + compass geometry + chip hit test
|
||||||
|
node dev/tether.test.mjs # tether range math: union, clamp, visible arcs (no line in overlaps)
|
||||||
node dev/research-builds.test.mjs # data contract: research/builds/actionbar shapes + manifest
|
node dev/research-builds.test.mjs # data contract: research/builds/actionbar shapes + manifest
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
"width": 1280,
|
"width": 1280,
|
||||||
"height": 720,
|
"height": 720,
|
||||||
"backgroundColor": "#04060d",
|
"backgroundColor": "#04060d",
|
||||||
"hintText": "click anywhere to fly",
|
"hintText": "click anywhere to fly · the tether holds your range",
|
||||||
"markerColor": "#41c7ff",
|
"markerColor": "#41c7ff",
|
||||||
"physics": {
|
"physics": {
|
||||||
"default": "arcade"
|
"default": "arcade"
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
"menu.json",
|
"menu.json",
|
||||||
"ship.json",
|
"ship.json",
|
||||||
"planets.json",
|
"planets.json",
|
||||||
|
"tether.json",
|
||||||
"galaxy.json",
|
"galaxy.json",
|
||||||
"systems.json",
|
"systems.json",
|
||||||
"settlements.json",
|
"settlements.json",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
{
|
||||||
|
"_comment": "TETHER — the player's range. A tether anchors on a world or station and defines a circular zone: radius = distance from the anchor's center. Level 1 = 5120 px (design rule); radius(level) = level1Radius × radiusGrowth^(level−1). The ship may fly anywhere in the UNION of its tethers' zones — where zones overlap there is no line and no wall; outside all of them is a hard barrier, drawn as a thick cyberpunk glitch dotted line along the union's boundary. homeId/homeLevel/homeLabel = the tether the player starts with (anchored on the home world, which sits at the system origin). line = the barrier's look & motion (dash/gap px along the rim, thicknesses, march speed = how fast dashes flow, colors/alphas = the neon passes, glitch = ambient bursts: dash flicker, radial displacement, sparks). contact = feedback when the ship bumps the barrier (pulse = the local shudder of the line; toastCooldown = console warning throttle, ms).",
|
||||||
|
"level1Radius": 5120,
|
||||||
|
"radiusGrowth": 1.25,
|
||||||
|
"maxLevel": 12,
|
||||||
|
"homeId": "home",
|
||||||
|
"homeLevel": 1,
|
||||||
|
"homeLabel": null,
|
||||||
|
"line": {
|
||||||
|
"dash": 26,
|
||||||
|
"gap": 18,
|
||||||
|
"width": 9,
|
||||||
|
"coreWidth": 3.5,
|
||||||
|
"glowWidth": 26,
|
||||||
|
"marchSpeed": 26,
|
||||||
|
"ghostOffset": 3,
|
||||||
|
"colors": {
|
||||||
|
"core": "#eaf6ff",
|
||||||
|
"main": "#00e5ff",
|
||||||
|
"ghost": "#ff2d6f",
|
||||||
|
"glow": "#00e5ff"
|
||||||
|
},
|
||||||
|
"alphas": {
|
||||||
|
"core": 0.85,
|
||||||
|
"main": 0.5,
|
||||||
|
"ghost": 0.16,
|
||||||
|
"glow": 0.09
|
||||||
|
},
|
||||||
|
"glitch": {
|
||||||
|
"enabled": true,
|
||||||
|
"intervalMs": [3200, 8200],
|
||||||
|
"durationMs": [220, 460],
|
||||||
|
"maxDisplace": 26,
|
||||||
|
"dropChance": 0.35
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"contact": {
|
||||||
|
"pulseStrength": 1.0,
|
||||||
|
"pulseDuration": 650,
|
||||||
|
"pulseWidth": 320,
|
||||||
|
"toastCooldown": 4000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,12 +8,15 @@
|
||||||
* --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):
|
* Dev hooks (URL params, dev only — ignored by the game itself):
|
||||||
* ?ship=<x>,<y> teleport the ship to a world position after boot
|
* ?ship=<x>,<y> teleport the ship to a world position after boot
|
||||||
* (screenshots of far-off states — e.g. the off-screen
|
* (screenshots of far-off states — e.g. the off-screen
|
||||||
* compass arrows pointing back at the home world).
|
* compass arrows pointing back at the home world).
|
||||||
* ?near=<px> fly the ship to <px> (edge-to-edge) from the first
|
* ?near=<px> fly the ship to <px> (edge-to-edge) from the first
|
||||||
* system planet — screenshots the discovery moment
|
* system planet — screenshots the discovery moment
|
||||||
* (rim ping + toast) with the world in view.
|
* (rim ping + toast) with the world in view.
|
||||||
|
* ?tethers=x,y,level;x,y,level add dev tethers (union-boundary tests)
|
||||||
|
* ?homeLevel=<n> set the home tether's level (bigger/smaller range)
|
||||||
|
* ?report=1 paint console errors (or "SMOKE OK") on the canvas
|
||||||
*/
|
*/
|
||||||
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';
|
||||||
|
|
@ -24,8 +27,19 @@ 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 params = typeof location !== 'undefined' ? new URLSearchParams(location.search) : null;
|
||||||
const nearParam = typeof location !== 'undefined' ? new URLSearchParams(location.search).get('near') : null;
|
const shipParam = params ? params.get('ship') : null;
|
||||||
|
const nearParam = params ? params.get('near') : null;
|
||||||
|
const tethersParam = params ? params.get('tethers') : null;
|
||||||
|
const homeLevelParam = params ? params.get('homeLevel') : null;
|
||||||
|
|
||||||
|
// Dev: capture console errors + uncaught exceptions for ?report=1.
|
||||||
|
const __errors = [];
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const origErr = console.error.bind(console);
|
||||||
|
console.error = (...a) => { __errors.push(a.map(String).join(' ')); origErr(...a); };
|
||||||
|
window.addEventListener('error', (e) => __errors.push(String(e.message)));
|
||||||
|
}
|
||||||
gameConfig.scene = [GameScene];
|
gameConfig.scene = [GameScene];
|
||||||
const game = new Phaser.Game(gameConfig);
|
const game = new Phaser.Game(gameConfig);
|
||||||
window.game = game;
|
window.game = game;
|
||||||
|
|
@ -43,6 +57,43 @@ if (shipParam) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (tethersParam || homeLevelParam) {
|
||||||
|
setTimeout(() => {
|
||||||
|
const s = game.scene.getScene('GameScene');
|
||||||
|
if (homeLevelParam) {
|
||||||
|
const home = s.tetherField.tethers[0];
|
||||||
|
if (home) s.tetherField.setLevel(home.id, Number(homeLevelParam) || 1);
|
||||||
|
}
|
||||||
|
if (tethersParam) {
|
||||||
|
tethersParam.split(';').forEach((triple, i) => {
|
||||||
|
const [tx, ty, tl] = triple.split(',').map(Number);
|
||||||
|
if (Number.isFinite(tx) && Number.isFinite(ty)) {
|
||||||
|
s.tetherField.add('dev' + i, tx, ty, Number.isFinite(tl) ? tl : 1, 'dev');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.info(`smoke: dev tethers ready (homeLevel=${homeLevelParam ?? '—'}, extra=${tethersParam ?? '—'})`);
|
||||||
|
}, 600);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params && params.get('report') === '1') {
|
||||||
|
// After boot, paint any captured console errors onto the canvas so a
|
||||||
|
// headless screenshot shows whether the scene is healthy.
|
||||||
|
setTimeout(() => {
|
||||||
|
const s = game.scene.getScene('GameScene');
|
||||||
|
if (!s) return;
|
||||||
|
const msg = __errors.length === 0
|
||||||
|
? 'SMOKE OK — no console errors'
|
||||||
|
: __errors.slice(0, 3).join('\n');
|
||||||
|
s.add.text(16, 690, msg, {
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: '12px',
|
||||||
|
color: __errors.length === 0 ? '#7ce8a4' : '#ff2d6f',
|
||||||
|
align: 'left',
|
||||||
|
}).setOrigin(0, 1).setScrollFactor(0).setDepth(9000);
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
if (nearParam) {
|
if (nearParam) {
|
||||||
const px = Number(nearParam) || 400;
|
const px = Number(nearParam) || 400;
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,330 @@
|
||||||
|
/**
|
||||||
|
* Tether test (dev tool, run with Node — no browser needed):
|
||||||
|
*
|
||||||
|
* node dev/tether.test.mjs
|
||||||
|
*
|
||||||
|
* Runs the REAL pure tether math (js/tether/Tether.js, no Phaser) against
|
||||||
|
* the real data/tether.json config and asserts the range rules:
|
||||||
|
* - level 1 radius = 5120 px from the anchor center (design rule),
|
||||||
|
* radius grows monotonically with level;
|
||||||
|
* - the ship is allowed anywhere in the UNION of its tethers' zones —
|
||||||
|
* a point inside one tether is free even when outside another
|
||||||
|
* (overlap = no wall, no line);
|
||||||
|
* - outside the union, the clamp lands EXACTLY on the closest boundary
|
||||||
|
* point (checked against a dense numeric sample of the union rim)
|
||||||
|
* with the correct outward normal;
|
||||||
|
* - visibleArcs() = each tether's share of the union boundary, both
|
||||||
|
* directions: nothing visible is inside another zone, and nothing on
|
||||||
|
* a rim outside all other zones is missing from the visible arcs
|
||||||
|
* (no gaps in the line, no line in the overlap).
|
||||||
|
*/
|
||||||
|
import { pathToFileURL, 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);
|
||||||
|
|
||||||
|
let failures = 0;
|
||||||
|
const check = (label, cond) => {
|
||||||
|
console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`);
|
||||||
|
if (!cond) failures++;
|
||||||
|
};
|
||||||
|
|
||||||
|
const { Tether } = await import(pathToFileURL(join(__dirname, '../js/tether/Tether.js')).href);
|
||||||
|
|
||||||
|
const TAU = Math.PI * 2;
|
||||||
|
const wrap = (a) => {
|
||||||
|
const w = a % TAU;
|
||||||
|
return w < 0 ? w + TAU : w;
|
||||||
|
};
|
||||||
|
const insideArcs = (arcs, th) => {
|
||||||
|
const w = wrap(th);
|
||||||
|
return arcs.some((a) => w >= a.a0 - 1e-9 && w <= a.a1 + 1e-9);
|
||||||
|
};
|
||||||
|
/** Deterministic PRNG (mulberry32) — stable test points. */
|
||||||
|
const mulberry32 = (seed) => () => {
|
||||||
|
seed |= 0;
|
||||||
|
seed = (seed + 0x6d2b79f5) | 0;
|
||||||
|
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
|
||||||
|
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||||
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
const rand = mulberry32(20260903);
|
||||||
|
const dist = (x, y, t) => Math.hypot(x - t.x, y - t.y);
|
||||||
|
|
||||||
|
// --- 1. The level-1 rule: 5120 px from the anchor center ----------------
|
||||||
|
{
|
||||||
|
const r1 = Tether.radiusForLevel(1);
|
||||||
|
check(`level 1 radius is 5120 px (got ${r1})`, r1 === 5120);
|
||||||
|
const growth = config.get('tether.radiusGrowth', 1.25);
|
||||||
|
const r2 = Tether.radiusForLevel(2);
|
||||||
|
check(`level 2 = 5120 × growth (${growth}) (got ${r2})`, Math.abs(r2 - 5120 * growth) < 1e-9);
|
||||||
|
const maxLevel = config.get('tether.maxLevel', 12);
|
||||||
|
let mono = true;
|
||||||
|
let prev = 0;
|
||||||
|
for (let l = 1; l <= maxLevel; l++) {
|
||||||
|
const r = Tether.radiusForLevel(l);
|
||||||
|
if (!(r > prev)) mono = false;
|
||||||
|
prev = r;
|
||||||
|
}
|
||||||
|
check(`radius grows monotonically through level ${maxLevel}`, mono);
|
||||||
|
|
||||||
|
const t = new Tether('home', 0, 0, 1);
|
||||||
|
check(`Tether record: id/anchor/level/radius (got ${t.id} @${t.x},${t.y} lv${t.level} r${t.radius})`,
|
||||||
|
t.id === 'home' && t.x === 0 && t.y === 0 && t.level === 1 && t.radius === 5120);
|
||||||
|
const frac = new Tether('x', 10, -10, 2.7);
|
||||||
|
check(`level rounds to an integer (2.7 → ${frac.level}, radius ${frac.radius})`,
|
||||||
|
frac.level === 3 && Math.abs(frac.radius - 5120 * growth * growth) < 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 2. Single tether: membership + the barrier clamp -------------------
|
||||||
|
{
|
||||||
|
const A = new Tether('home', 0, 0, 1); // r = 5120 at the origin
|
||||||
|
const t = A;
|
||||||
|
|
||||||
|
check('inside the zone: free (clamped: false)',
|
||||||
|
!Tether.clampPoint(1000, 2000, [t]).clamped && Tether.containsAny(1000, 2000, [t]));
|
||||||
|
|
||||||
|
// On the rim exactly: allowed ("within 5120 px" includes 5120 px).
|
||||||
|
const on = Tether.clampPoint(5120, 0, [t]);
|
||||||
|
check('exactly on the rim: allowed, untouched', !on.clamped && on.x === 5120 && on.y === 0);
|
||||||
|
|
||||||
|
// Outside: clamped to the rim, along the ray, with the outward normal.
|
||||||
|
const out = Tether.clampPoint(7000, 0, [t]);
|
||||||
|
check(`outside: clamped to exactly 5120 along the ray (got ${out.x.toFixed(3)}, ${out.y.toFixed(3)})`,
|
||||||
|
out.clamped && out.id === 'home' && Math.abs(out.x - 5120) < 1e-9 && Math.abs(out.y) < 1e-9);
|
||||||
|
check('outward normal is +x (got ' + out.nx.toFixed(3) + ')', out.nx === 1 && Math.abs(out.ny) < 1e-12);
|
||||||
|
const outN = Tether.clampPoint(0, -9000, [t]);
|
||||||
|
check('south approach: normal is −y (got ny=' + outN.ny.toFixed(3) + ')',
|
||||||
|
outN.clamped && Math.abs(outN.y + 5120) < 1e-9 && outN.ny === -1);
|
||||||
|
|
||||||
|
// No tethers at all: free space (seam for pre-tether / debug states).
|
||||||
|
const free = Tether.clampPoint(12345, -678, []);
|
||||||
|
check('no tethers: everything is free', !free.clamped && free.id === null && free.x === 12345);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 3. Overlap = the union (no wall, no line, inside the overlap) ------
|
||||||
|
{
|
||||||
|
// Home tether r=5120 at origin + a second tether r=4000 at (6000, 0):
|
||||||
|
// the rims cross; their overlap is fully open space.
|
||||||
|
const A = new Tether('home', 0, 0, 1);
|
||||||
|
const B = new Tether('base', 6000, 0, 2);
|
||||||
|
B.radius = 4000; // (a lower-level-ish radius for a clean test)
|
||||||
|
const field = [A, B];
|
||||||
|
|
||||||
|
// 5500 is OUTSIDE the home tether but INSIDE the second → free.
|
||||||
|
const p1 = Tether.clampPoint(5500, 0, field);
|
||||||
|
check('outside home tether but inside the second: free (union rule)', !p1.clamped);
|
||||||
|
check('…and it really is inside the second tether', Tether.containsAny(5500, 0, field));
|
||||||
|
|
||||||
|
// Deep in the lens: free, even though ~9000 from home's center.
|
||||||
|
const p2 = Tether.clampPoint(6000, 2000, field);
|
||||||
|
check('deep in the overlap (6000, 2000): free', !p2.clamped);
|
||||||
|
|
||||||
|
// Outside BOTH: clamped to the CLOSEST rim — the second tether's, not
|
||||||
|
// home's (500 px gap vs 5380 px).
|
||||||
|
const p3 = Tether.clampPoint(10500, 0, field);
|
||||||
|
check(`outside both: clamps to the nearer rim (10000, 0), id=${p3.id} (got ${p3.x}, ${p3.y}, ${p3.id})`,
|
||||||
|
p3.clamped && p3.id === 'base' && Math.abs(p3.x - 10000) < 1e-9 && Math.abs(p3.y) < 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 4. Nearest-boundary clamp — numeric cross-check --------------------
|
||||||
|
{
|
||||||
|
const A = new Tether('home', 0, 0, 1);
|
||||||
|
const B = new Tether('base', 6000, 0, 2);
|
||||||
|
B.radius = 4000;
|
||||||
|
const field = [A, B];
|
||||||
|
const SAMPLES = 20000; // rim sampling density (≈1.6 px on the 5120 rim)
|
||||||
|
|
||||||
|
let worst = 0;
|
||||||
|
let worstPt = null;
|
||||||
|
for (let i = 0; i < 80; i++) {
|
||||||
|
const ang = rand() * TAU;
|
||||||
|
const scale = 1.03 + rand() * 0.6;
|
||||||
|
const R = Math.max(A.radius, B.radius) * scale;
|
||||||
|
const px = Math.cos(ang) * R;
|
||||||
|
const py = Math.sin(ang) * R;
|
||||||
|
// Only points truly outside the union.
|
||||||
|
if (Tether.containsAny(px, py, field)) continue;
|
||||||
|
|
||||||
|
const got = Tether.clampPoint(px, py, field);
|
||||||
|
// Dense numeric min over the union rim.
|
||||||
|
let bestD = Infinity;
|
||||||
|
let bestQ = null;
|
||||||
|
for (const t of field) {
|
||||||
|
for (let s = 0; s < SAMPLES; s++) {
|
||||||
|
const a = (s / SAMPLES) * TAU;
|
||||||
|
const qx = t.x + Math.cos(a) * t.radius;
|
||||||
|
const qy = t.y + Math.sin(a) * t.radius;
|
||||||
|
const d = Math.hypot(px - qx, py - qy);
|
||||||
|
if (d < bestD) {
|
||||||
|
bestD = d;
|
||||||
|
bestQ = { x: qx, y: qy };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const err = Math.hypot(got.x - bestQ.x, got.y - bestQ.y);
|
||||||
|
if (err > worst) {
|
||||||
|
worst = err;
|
||||||
|
worstPt = { px, py };
|
||||||
|
}
|
||||||
|
if (!got.clamped || err > 2.5) {
|
||||||
|
check(`clamp(×${(px).toFixed(0)}, ${py.toFixed(0)}) → nearest union rim (err ${err.toFixed(2)} px)`, false);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
check(`80 random outside points: clamp lands on the nearest union rim (worst ${worst.toFixed(2)} px)`, worst <= 2.5);
|
||||||
|
// And the normal always points from the owning anchor.
|
||||||
|
const chk = Tether.clampPoint(2000, 6400, field);
|
||||||
|
const own = field.find((t) => t.id === chk.id);
|
||||||
|
const dot = (chk.nx * (chk.x - own.x) + chk.ny * (chk.y - own.y)) / own.radius;
|
||||||
|
check('normal is radial from the owning anchor (dot=1, got ' + dot.toFixed(4) + ')', Math.abs(dot - 1) < 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 5. visibleArcs — the union boundary, per tether --------------------
|
||||||
|
{
|
||||||
|
const soloT = new Tether('a', 0, 0, 1);
|
||||||
|
const solo = Tether.visibleArcs(soloT, [soloT]);
|
||||||
|
check('solo tether: one full-circle arc',
|
||||||
|
solo.length === 1 && Math.abs(solo[0].a0) < 1e-9 && Math.abs(solo[0].a1 - TAU) < 1e-9);
|
||||||
|
|
||||||
|
// B strictly contains A → A's rim is fully covered → no line at all.
|
||||||
|
const small = new Tether('a', 500, 0, 1);
|
||||||
|
small.radius = 1000;
|
||||||
|
const big = new Tether('b', 0, 0, 1);
|
||||||
|
big.radius = 5000;
|
||||||
|
check('fully contained: no visible arcs (no line inside a bigger zone)',
|
||||||
|
Tether.visibleArcs(small, [small, big]).length === 0);
|
||||||
|
|
||||||
|
// B strictly inside A (rim untouched) → A stays a full circle.
|
||||||
|
const inner = new Tether('b', 3000, 0, 1);
|
||||||
|
inner.radius = 1000; // d + rB = 4000 ≤ rA
|
||||||
|
check('inner tether not touching the rim: outer stays a full circle',
|
||||||
|
(() => {
|
||||||
|
const arcs = Tether.visibleArcs(big, [big, inner]);
|
||||||
|
return arcs.length === 1 && Math.abs(arcs[0].a1 - arcs[0].a0 - TAU) < 1e-9;
|
||||||
|
})());
|
||||||
|
|
||||||
|
// Partial overlap — EXACT covered length (k from the circle geometry):
|
||||||
|
// covered(θ) where cos(θ−β) ≥ k, k = (rA²+d²−rB²)/(2·rA·d)
|
||||||
|
const A = new Tether('a', 0, 0, 1); // r = 5120
|
||||||
|
const B = new Tether('b', 6000, 0, 1);
|
||||||
|
B.radius = 4000;
|
||||||
|
const field = [A, B];
|
||||||
|
const coveredLen = (t, o) => {
|
||||||
|
const r = t.radius;
|
||||||
|
const d = Math.hypot(o.x - t.x, o.y - t.y);
|
||||||
|
const k = (r * r + d * d - o.radius * o.radius) / (2 * r * d);
|
||||||
|
if (k < -1) return TAU;
|
||||||
|
if (k >= 1) return 0;
|
||||||
|
return 2 * Math.acos(k);
|
||||||
|
};
|
||||||
|
const visA = Tether.visibleArcs(A, field);
|
||||||
|
const visB = Tether.visibleArcs(B, field);
|
||||||
|
const arcLen = (arcs) => arcs.reduce((s, a) => s + (a.a1 - a.a0), 0);
|
||||||
|
check(
|
||||||
|
`A: visible = TAU − covered (got ${(arcLen(visA) / TAU).toFixed(5)} of circle, want ${(1 - coveredLen(A, B) / TAU).toFixed(5)})`,
|
||||||
|
Math.abs(arcLen(visA) - (TAU - coveredLen(A, B))) < 1e-6,
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
`B: visible = TAU − covered (got ${(arcLen(visB) / TAU).toFixed(5)} of circle, want ${(1 - coveredLen(B, A) / TAU).toFixed(5)})`,
|
||||||
|
Math.abs(arcLen(visB) - (TAU - coveredLen(B, A))) < 1e-6,
|
||||||
|
);
|
||||||
|
check('partial overlap: visible arcs are well-formed (sorted, in [0,TAU), non-overlapping)',
|
||||||
|
(() => {
|
||||||
|
const ok = (arcs) =>
|
||||||
|
arcs.length > 0 &&
|
||||||
|
arcs.every((a) => a.a0 >= 0 && a.a1 <= TAU + 1e-9 && a.a1 > a.a0) &&
|
||||||
|
arcs.every((a, i) => i === 0 || a.a0 >= arcs[i - 1].a1 - 1e-9);
|
||||||
|
return ok(visA) && ok(visB);
|
||||||
|
})());
|
||||||
|
|
||||||
|
// External tangent (d = rA + rB): a single contact point, no covered arc.
|
||||||
|
const T1 = new Tether('a', 0, 0, 1);
|
||||||
|
T1.radius = 3000;
|
||||||
|
const T2 = new Tether('b', 7000, 0, 1);
|
||||||
|
T2.radius = 4000; // d = 7000 = 3000 + 4000
|
||||||
|
const tangA = Tether.visibleArcs(T1, [T1, T2]);
|
||||||
|
let noInside = true;
|
||||||
|
for (let s = 0; s < 4000; s++) {
|
||||||
|
const a = (s / 4000) * TAU;
|
||||||
|
const px = T1.x + Math.cos(a) * T1.radius;
|
||||||
|
const py = T1.y + Math.sin(a) * T1.radius;
|
||||||
|
if (Math.hypot(px - T2.x, py - T2.y) < T2.radius - 1e-6) noInside = false;
|
||||||
|
}
|
||||||
|
check('external tangent: no rim point inside the other zone',
|
||||||
|
noInside && Math.abs(arcLen(tangA) - TAU) < 1e-6);
|
||||||
|
|
||||||
|
// B's center inside A, partial overlap (k = 0.65): exact length again.
|
||||||
|
const C1 = new Tether('a', 0, 0, 1);
|
||||||
|
C1.radius = 5000;
|
||||||
|
const C2 = new Tether('b', 2000, 0, 1);
|
||||||
|
C2.radius = 4000;
|
||||||
|
const visC = Tether.visibleArcs(C1, [C1, C2]);
|
||||||
|
check(
|
||||||
|
`center-inside overlap: A visible = ${(arcLen(visC) / TAU).toFixed(5)} of circle (want ${(1 - coveredLen(C1, C2) / TAU).toFixed(5)})`,
|
||||||
|
Math.abs(arcLen(visC) - (TAU - coveredLen(C1, C2))) < 1e-6,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 6. The two-direction boundary property (2 + 3 tethers) -------------
|
||||||
|
// (a) nothing on a visible arc is inside another zone (no line in the
|
||||||
|
// overlap), and
|
||||||
|
// (b) every rim point NOT inside any other zone sits on a visible arc
|
||||||
|
// (no gaps in the line).
|
||||||
|
{
|
||||||
|
const A = new Tether('home', 0, 0, 1); // r = 5120
|
||||||
|
const B = new Tether('base', 6000, 0, 1);
|
||||||
|
B.radius = 4000;
|
||||||
|
const C = new Tether('relay', 6000, 3500, 1);
|
||||||
|
C.radius = 2200;
|
||||||
|
const field = [A, B, C];
|
||||||
|
const arcs = Object.fromEntries(field.map((t) => [t.id, Tether.visibleArcs(t, field)]));
|
||||||
|
|
||||||
|
let aOk = true;
|
||||||
|
let bOk = true;
|
||||||
|
const SAMPLES = 6000;
|
||||||
|
for (const t of field) {
|
||||||
|
for (let s = 0; s < SAMPLES; s++) {
|
||||||
|
const ang = (s / SAMPLES) * TAU;
|
||||||
|
const px = t.x + Math.cos(ang) * t.radius;
|
||||||
|
const py = t.y + Math.sin(ang) * t.radius;
|
||||||
|
const inOther = field.some(
|
||||||
|
(o) => o !== t && Math.hypot(px - o.x, py - o.y) < o.radius - 1e-6,
|
||||||
|
);
|
||||||
|
const onVisible = insideArcs(arcs[t.id], ang);
|
||||||
|
if (onVisible && inOther) aOk = false;
|
||||||
|
if (!inOther && !onVisible) bOk = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
check('(a) visible arcs are never inside another zone (no line in the overlap)', aOk);
|
||||||
|
check('(b) every exposed rim point is on a visible arc (no gaps in the line)', bOk);
|
||||||
|
|
||||||
|
// And where a rim point sits inside another zone, the union is still
|
||||||
|
// "owned" by the other tether's boundary or interior — i.e. the point
|
||||||
|
// is inside the union (free space), so the wall is nowhere near it.
|
||||||
|
let unionOk = true;
|
||||||
|
for (const t of field) {
|
||||||
|
for (let s = 0; s < 2000; s++) {
|
||||||
|
const ang = (s / 2000) * TAU;
|
||||||
|
const px = t.x + Math.cos(ang) * t.radius;
|
||||||
|
const py = t.y + Math.sin(ang) * t.radius;
|
||||||
|
const inOther = field.some((o) => o !== t && Math.hypot(px - o.x, py - o.y) < o.radius);
|
||||||
|
if (inOther && !Tether.containsAny(px, py, field)) unionOk = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
check('covered rim points are inside the union (overlap is open space)', unionOk);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(failures === 0 ? '\nAll tether tests passed ✔' : `\n${failures} test(s) FAILED ✘`);
|
||||||
|
process.exit(failures === 0 ? 0 : 1);
|
||||||
|
|
@ -180,6 +180,56 @@ world** — solid, rendered, flyable-to. Rules and seams:
|
||||||
- Verified: `dev/discovery.test.mjs` (rule, boundaries, per-system
|
- Verified: `dev/discovery.test.mjs` (rule, boundaries, per-system
|
||||||
state, JSON round-trip, layout bounds + determinism, compass geometry).
|
state, JSON round-trip, layout bounds + determinism, compass geometry).
|
||||||
|
|
||||||
|
## The tether — the player's range (important)
|
||||||
|
|
||||||
|
The ship starts with **one level-1 tether** anchored on the home world
|
||||||
|
(system origin): a circular zone, **5120 px from the anchor's center**
|
||||||
|
(`data/tether.json → level1Radius`). That rim is the player's boundary:
|
||||||
|
|
||||||
|
- **The union is the space.** The ship may be anywhere inside ANY of its
|
||||||
|
tethers' zones. Where two zones overlap there is **no wall and no line**
|
||||||
|
— the boundary of the union is where the barrier lives, and the line is
|
||||||
|
drawn only on each tether's *visible* arcs (`Tether.visibleArcs` — the
|
||||||
|
rim arcs not covered by another zone).
|
||||||
|
- **Outside the union is a hard barrier.** The ship is clamped to the
|
||||||
|
closest boundary point with the outward normal; outward velocity AND
|
||||||
|
acceleration are stripped (same static-resolve pattern as
|
||||||
|
`Planet.constrainShip`, run after the planets in `GameScene.update`).
|
||||||
|
Click-to-fly and autopilot targets are clamped too, so an out-of-range
|
||||||
|
destination becomes "fly to the rim and rest on the line".
|
||||||
|
- **Feedback on contact:** the line shudders locally at the hit point
|
||||||
|
(TetherField pulse + radial zap), a small camera kick, and a throttled
|
||||||
|
magenta console toast (`tether.contact` in the config).
|
||||||
|
|
||||||
|
**Layering (same rules as everything else):**
|
||||||
|
- `js/tether/Tether.js` — PURE (no Phaser): the record (id/anchor/level/
|
||||||
|
radius), `radiusForLevel`, union membership, `clampPoint` (nearest union
|
||||||
|
boundary point + normal), and `visibleArcs` (circle-arc interval math on
|
||||||
|
the unit circle). Node-tested by `dev/tether.test.mjs` — including the
|
||||||
|
two-direction boundary property (nothing visible is inside another zone;
|
||||||
|
nothing exposed is missing from the visible arcs).
|
||||||
|
- `js/tether/TetherField.js` — the scene-facing field: owns the tether
|
||||||
|
list, exposes the seam the build system will use (`add(id, x, y, level)`,
|
||||||
|
`remove(id)`, `setLevel(id, level)`, `onChange` → HUD refresh), applies
|
||||||
|
the constraint (`constrainShip`), and renders the barrier: dash dots of
|
||||||
|
constant world size along the visible arcs, on-screen culling only,
|
||||||
|
marching offset, additive glow + RGB ghost fringe under the main pass,
|
||||||
|
ambient glitch bursts (dash flicker, radial displacement, data drops,
|
||||||
|
sparks), contact pulses. All tuning in `data/tether.json → line/contact`.
|
||||||
|
- `data/tether.json` — level radii (`level1Radius`, `radiusGrowth`,
|
||||||
|
`maxLevel`), the starting tether (`homeId`/`homeLevel`), and every
|
||||||
|
visual/feel parameter.
|
||||||
|
|
||||||
|
**Upgrade economics (planned):** radius(level) = 5120 × 1.25^(level−1) —
|
||||||
|
level 4 = 10000 px, which reaches the far system orbit (maxOrbit 8800)
|
||||||
|
plus rim clearance. Extra tethers anchored on planets/stations will be the
|
||||||
|
"expand your reach" verb; the seams are in place.
|
||||||
|
|
||||||
|
**Phaser v4 note:** the barrier is two `Graphics` layers redrawn per frame
|
||||||
|
(`clear()` → one `strokePath` per pass) — no textures, no physics bodies,
|
||||||
|
no camera math beyond culling. The scene drives `tick()`/`draw()` from
|
||||||
|
`update()` alongside the TimeClock/tween stepping.
|
||||||
|
|
||||||
## 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`.
|
||||||
|
|
@ -250,6 +300,17 @@ world** — solid, rendered, flyable-to. Rules and seams:
|
||||||
The scene's click-to-fly guards against deck AND chip clicks
|
The scene's click-to-fly guards against deck AND chip clicks
|
||||||
(`ActionBar.contains` / `DiscoveryCompass.contains`)
|
(`ActionBar.contains` / `DiscoveryCompass.contains`)
|
||||||
(`js/galaxy/Discovery.js`)
|
(`js/galaxy/Discovery.js`)
|
||||||
|
- [x] The tether — the player's range: a level-1 tether (5120 px) anchored
|
||||||
|
on the home world; the ship lives in the UNION of its tethers' zones
|
||||||
|
(overlap = no line, no wall), the rim is a hard barrier rendered as a
|
||||||
|
thick glitchy dotted line; click/autopilot targets clamp to the
|
||||||
|
boundary; contact feedback (line shudder, camera kick, toast).
|
||||||
|
Pure range math in `js/tether/Tether.js` (Node-tested), visuals +
|
||||||
|
constraint seam in `js/tether/TetherField.js`, tuning in
|
||||||
|
`data/tether.json`
|
||||||
|
- [ ] Tether progression: the build/research verbs that anchor tethers on
|
||||||
|
planets/stations and upgrade levels (the `add`/`setLevel`/`onChange`
|
||||||
|
seams are in place; the costs + panel + research gate come next)
|
||||||
- [ ] 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
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,21 @@ 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';
|
import { DiscoveryCompass, circleInView } from '../ui/DiscoveryCompass.js';
|
||||||
import { ActionBar } from '../ui/ActionBar.js';
|
import { ActionBar } from '../ui/ActionBar.js';
|
||||||
|
import { TetherField } from '../tether/TetherField.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);
|
||||||
|
|
||||||
|
/** 5120 → "5.1K", 640 → "640" — compact range readout for the HUD. */
|
||||||
|
function fmtRange(v) {
|
||||||
|
if (v >= 1000) {
|
||||||
|
const k = (v / 1000).toFixed(1).replace(/\.0$/, '');
|
||||||
|
return `${k}K`;
|
||||||
|
}
|
||||||
|
return String(Math.round(v));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The game world (v0.3: the current system — the player's home world at the
|
* 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).
|
* origin plus the system's other worlds scattered around it, in open space).
|
||||||
|
|
@ -132,6 +142,27 @@ export class GameScene extends Phaser.Scene {
|
||||||
this.starfield = new Starfield(this);
|
this.starfield = new Starfield(this);
|
||||||
this.starfield.create();
|
this.starfield.create();
|
||||||
|
|
||||||
|
// The TETHER — the player's range. Starts as one level-1 tether anchored
|
||||||
|
// on the home world (data/tether.json): the ship may fly anywhere within
|
||||||
|
// its rim (level 1 = 5120 px from the planet's center); the rim is a
|
||||||
|
// hard barrier drawn as a thick glitchy dotted line. The field owns the
|
||||||
|
// tether list (add/remove/setLevel are the seam the build system will
|
||||||
|
// use later); where multiple tethers' zones overlap there is no line
|
||||||
|
// and no wall — the union is the player's space.
|
||||||
|
this.tetherHudTexts = [];
|
||||||
|
this.tetherField = new TetherField(this, {
|
||||||
|
depth: 6, // above planets (5), below the ship (10)
|
||||||
|
onChange: () => this.refreshTetherHud(),
|
||||||
|
});
|
||||||
|
this.tetherField.add(
|
||||||
|
config.get('tether.homeId', 'home'),
|
||||||
|
0, 0, // home world's center (it sits at the system origin)
|
||||||
|
config.get('tether.homeLevel', 1),
|
||||||
|
config.get('tether.homeLabel', '') || config.get('planets.homeName', 'Terra'),
|
||||||
|
);
|
||||||
|
this.tetherToastAt = null;
|
||||||
|
this.refreshTetherHud();
|
||||||
|
|
||||||
// Discovery: which objects the player has found (within discovery
|
// Discovery: which objects the player has found (within discovery
|
||||||
// distance of an edge — data/game.json), tracked per system. Kept in
|
// distance of an edge — data/game.json), tracked per system. Kept in
|
||||||
// the shared registry so it survives scene restarts; serializable for
|
// the shared registry so it survives scene restarts; serializable for
|
||||||
|
|
@ -188,7 +219,9 @@ export class GameScene extends Phaser.Scene {
|
||||||
// already retargeted the ship) — neither is a fly-here.
|
// already retargeted the ship) — neither is a fly-here.
|
||||||
// A click inside a planet clamps to that planet's keep-out rim — the
|
// 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
|
// ship can stop at the clearance, never inside. (Worlds don't
|
||||||
// overlap, so sequential clamping is exact.)
|
// overlap, so sequential clamping is exact.) A click BEYOND the
|
||||||
|
// player's tether range clamps to the union boundary — the target
|
||||||
|
// marker lands on the barrier line itself.
|
||||||
this.input.on('pointerdown', (pointer) => {
|
this.input.on('pointerdown', (pointer) => {
|
||||||
if (this.actionBar && this.actionBar.contains(pointer.x, pointer.y)) return;
|
if (this.actionBar && this.actionBar.contains(pointer.x, pointer.y)) return;
|
||||||
if (this.compass.contains(pointer.x, pointer.y)) return;
|
if (this.compass.contains(pointer.x, pointer.y)) return;
|
||||||
|
|
@ -196,6 +229,7 @@ export class GameScene extends Phaser.Scene {
|
||||||
for (const p of this.solidPlanets) {
|
for (const p of this.solidPlanets) {
|
||||||
aim = p.aimPoint(aim.x, aim.y, this.ship.radius);
|
aim = p.aimPoint(aim.x, aim.y, this.ship.radius);
|
||||||
}
|
}
|
||||||
|
aim = this.tetherField.clampPoint(aim.x, aim.y);
|
||||||
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();
|
||||||
|
|
@ -259,6 +293,37 @@ export class GameScene extends Phaser.Scene {
|
||||||
}
|
}
|
||||||
line(report.summary, { fontFamily: fam, fontSize: '12px', color: '#54608a' });
|
line(report.summary, { fontFamily: fam, fontSize: '12px', color: '#54608a' });
|
||||||
line(`seed ${this.galaxy.seed}`, { fontFamily: fam, fontSize: '11px', color: '#3d476b' });
|
line(`seed ${this.galaxy.seed}`, { fontFamily: fam, fontSize: '11px', color: '#3d476b' });
|
||||||
|
this.hudEndY = y + 6; // the tether readout (refreshTetherHud) continues below
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Under the dossier: one line per tether — level, range, anchor. Rebuilt
|
||||||
|
* whenever the field changes (TetherField.onChange), so a future
|
||||||
|
* "tether upgrade" build shows up here for free.
|
||||||
|
*/
|
||||||
|
refreshTetherHud() {
|
||||||
|
if (this.hudEndY === undefined || !this.tetherField) return;
|
||||||
|
for (const g of this.tetherHudTexts) g.destroy();
|
||||||
|
this.tetherHudTexts = [];
|
||||||
|
const fam = BODY_FONT();
|
||||||
|
const neon = toCss(themeColor('neon', 0x00e5ff));
|
||||||
|
let y = this.hudEndY;
|
||||||
|
for (const t of this.tetherField.tethers) {
|
||||||
|
const label = t.label ? ` · ${String(t.label).toUpperCase()}` : '';
|
||||||
|
this.tetherHudTexts.push(
|
||||||
|
this.add
|
||||||
|
.text(16, y, `TETHER LV ${t.level} · RANGE ${fmtRange(t.radius)} PX${label}`, {
|
||||||
|
fontFamily: fam,
|
||||||
|
fontSize: '12px',
|
||||||
|
color: neon,
|
||||||
|
letterSpacing: 1,
|
||||||
|
})
|
||||||
|
.setOrigin(0, 0)
|
||||||
|
.setScrollFactor(0) // UI — pinned to the screen
|
||||||
|
.setDepth(30),
|
||||||
|
);
|
||||||
|
y += 18;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
update(_time, delta) {
|
update(_time, delta) {
|
||||||
|
|
@ -274,12 +339,77 @@ export class GameScene extends Phaser.Scene {
|
||||||
for (const p of this.solidPlanets) {
|
for (const p of this.solidPlanets) {
|
||||||
p.constrainShip(this.ship, this.ship.radius);
|
p.constrainShip(this.ship, this.ship.radius);
|
||||||
}
|
}
|
||||||
|
// The tether is the player's range: anywhere in the union of its
|
||||||
|
// tethers' zones is space; outside it, the ship is clamped to the
|
||||||
|
// boundary and the line shudders where it was hit.
|
||||||
|
const contact = this.tetherField.constrainShip(this.ship);
|
||||||
|
if (contact) this.onTetherContact(contact);
|
||||||
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.tetherField.tick(_time, delta); // glitch/pulse lifecycle
|
||||||
|
this.tetherField.draw(_time); // the barrier (on-screen dots only)
|
||||||
this.actionBar?.update(_time, delta); // the deck's living details
|
this.actionBar?.update(_time, delta); // the deck's living details
|
||||||
this.updateDiscovery(_time, delta); // last: sees this frame's final camera view
|
this.updateDiscovery(_time, delta); // last: sees this frame's final camera view
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ship met the tether boundary: a small camera kick (impact), and a
|
||||||
|
* console warning — throttled so a held push against the line doesn't
|
||||||
|
* spam it. The line itself already shudders locally (TetherField pulse).
|
||||||
|
*/
|
||||||
|
onTetherContact(contact) {
|
||||||
|
const cam = this.cameras.main;
|
||||||
|
if (typeof cam.shake === 'function') {
|
||||||
|
cam.shake(80, 0.003 + Math.min(0.004, (contact.vn ?? 0) * 0.00002));
|
||||||
|
}
|
||||||
|
const now = this.time.now;
|
||||||
|
const cooldown = config.get('tether.contact.toastCooldown', 4000);
|
||||||
|
if (this.tetherToastAt !== null && now - this.tetherToastAt < cooldown) return;
|
||||||
|
this.tetherToastAt = now;
|
||||||
|
this.tetherToast();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Top-center console warning (below the discovery toast, magenta). */
|
||||||
|
tetherToast() {
|
||||||
|
if (Array.isArray(this.tetherToastG)) {
|
||||||
|
for (const g of this.tetherToastG) g.destroy();
|
||||||
|
}
|
||||||
|
const fam = BODY_FONT();
|
||||||
|
const g1 = this.add
|
||||||
|
.text(0, 0, '\u26a0', { fontFamily: fam, fontSize: '13px', color: toCss(themeColor('neon2', 0xff2d6f)) })
|
||||||
|
.setOrigin(0, 0.5)
|
||||||
|
.setScrollFactor(0);
|
||||||
|
const g2 = this.add
|
||||||
|
.text(0, 0, 'TETHER BARRIER — RANGE LIMIT · UPGRADE THE TETHER TO EXTEND IT', {
|
||||||
|
fontFamily: fam,
|
||||||
|
fontSize: '11px',
|
||||||
|
color: toCss(themeColor('dim', 0x7d92c4)),
|
||||||
|
letterSpacing: 2,
|
||||||
|
})
|
||||||
|
.setOrigin(0, 0.5)
|
||||||
|
.setScrollFactor(0);
|
||||||
|
const total = g1.width + 10 + g2.width;
|
||||||
|
const x0 = this.scale.width / 2 - total / 2;
|
||||||
|
g1.setPosition(x0, 54).setDepth(45).setAlpha(0);
|
||||||
|
g2.setPosition(x0 + g1.width + 10, 54).setDepth(45).setAlpha(0);
|
||||||
|
this.tetherToastG = [g1, g2];
|
||||||
|
this.tweens.add({ targets: this.tetherToastG, alpha: 1, duration: 180, ease: 'Sine.easeOut' });
|
||||||
|
this.time.delayedCall(2600, () => {
|
||||||
|
if (!Array.isArray(this.tetherToastG)) return;
|
||||||
|
const [a, b] = this.tetherToastG;
|
||||||
|
this.tetherToastG = null;
|
||||||
|
this.tweens.add({
|
||||||
|
targets: [a, b],
|
||||||
|
alpha: 0,
|
||||||
|
duration: 350,
|
||||||
|
onComplete: () => {
|
||||||
|
a.destroy();
|
||||||
|
b.destroy();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The camera chases the ship with a frame-rate-independent ease:
|
* The camera chases the ship with a frame-rate-independent ease:
|
||||||
* scroll += (shipCenter - scroll) * (1 - e^(-rate·dt))
|
* scroll += (shipCenter - scroll) * (1 - e^(-rate·dt))
|
||||||
|
|
@ -368,13 +498,16 @@ export class GameScene extends Phaser.Scene {
|
||||||
id === 'home' ? this.planet : (this.systemPlanets.find((p) => p.discoveryId === id) ?? this.planet);
|
id === 'home' ? this.planet : (this.systemPlanets.find((p) => p.discoveryId === id) ?? this.planet);
|
||||||
// Approach point: on the rim (clearance + hull), on the side the ship
|
// Approach point: on the rim (clearance + hull), on the side the ship
|
||||||
// is approaching from (center → ship direction) — it ends up facing
|
// is approaching from (center → ship direction) — it ends up facing
|
||||||
// the world.
|
// the world. If the world is outside the player's tether range, the
|
||||||
|
// aim clamps to the union boundary — the ship flies as far as its
|
||||||
|
// tether lets it (and rests on the line) until the tether grows.
|
||||||
const dx = this.ship.x - o.x;
|
const dx = this.ship.x - o.x;
|
||||||
const dy = this.ship.y - o.y;
|
const dy = this.ship.y - o.y;
|
||||||
const d = Math.hypot(dx, dy) || 1;
|
const d = Math.hypot(dx, dy) || 1;
|
||||||
let aim = planet.edgePoint(Math.atan2(dy, dx), planet.clearance, this.ship.radius);
|
let aim = planet.edgePoint(Math.atan2(dy, dx), planet.clearance, this.ship.radius);
|
||||||
// Belt and braces: no other world may own this point either.
|
// Belt and braces: no other world may own this point either.
|
||||||
for (const p of this.solidPlanets) aim = p.aimPoint(aim.x, aim.y, this.ship.radius);
|
for (const p of this.solidPlanets) aim = p.aimPoint(aim.x, aim.y, this.ship.radius);
|
||||||
|
aim = this.tetherField.clampPoint(aim.x, aim.y);
|
||||||
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();
|
||||||
|
|
@ -468,5 +601,6 @@ export class GameScene extends Phaser.Scene {
|
||||||
this.starfield?.destroy();
|
this.starfield?.destroy();
|
||||||
this.compass?.destroy();
|
this.compass?.destroy();
|
||||||
this.actionBar?.destroy();
|
this.actionBar?.destroy();
|
||||||
|
this.tetherField?.destroy();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,188 @@
|
||||||
|
import { config } from '../config/Config.js';
|
||||||
|
|
||||||
|
const TAU = Math.PI * 2;
|
||||||
|
const EPS = 1e-9;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A TETHER: the player's range. A tether anchors on a world or station and
|
||||||
|
* defines a circular zone — radius = distance from the anchor's CENTER.
|
||||||
|
*
|
||||||
|
* Rules:
|
||||||
|
* - radius(level) = tether.level1Radius × tether.radiusGrowth^(level−1)
|
||||||
|
* (level 1 = 5120 px, per data/tether.json);
|
||||||
|
* - the ship may be anywhere inside the UNION of its tethers' zones —
|
||||||
|
* where two zones overlap there is no wall and no line;
|
||||||
|
* - outside all of them is a hard barrier: the ship (and its click /
|
||||||
|
* autopilot targets) are clamped to the union's boundary.
|
||||||
|
*
|
||||||
|
* This file is PURE (no Phaser): the record + the geometry the game is
|
||||||
|
* built on — union membership, clamping to the union boundary (nearest
|
||||||
|
* point + outward normal), and each tether's share of that boundary
|
||||||
|
* (visibleArcs — the only place a barrier line is drawn). Node-testable:
|
||||||
|
* dev/tether.test.mjs.
|
||||||
|
*/
|
||||||
|
export class Tether {
|
||||||
|
/**
|
||||||
|
* @param {string} id — unique within the field (e.g. 'home')
|
||||||
|
* @param {number} x — world x of the anchor (a planet/station center)
|
||||||
|
* @param {number} y — world y
|
||||||
|
* @param {number} level — 1..tether.maxLevel (radius derives from this)
|
||||||
|
*/
|
||||||
|
constructor(id, x, y, level) {
|
||||||
|
this.id = id;
|
||||||
|
this.x = x;
|
||||||
|
this.y = y;
|
||||||
|
this.level = Math.max(1, Math.round(level ?? 1));
|
||||||
|
this.radius = Tether.radiusForLevel(this.level);
|
||||||
|
this.label = ''; // anchor name for the HUD (set by the owner, optional)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** radius(level) = level1Radius × radiusGrowth^(level−1) (data/tether.json). */
|
||||||
|
static radiusForLevel(level) {
|
||||||
|
const l1 = config.get('tether.level1Radius', 5120);
|
||||||
|
const g = config.get('tether.radiusGrowth', 1.25);
|
||||||
|
const n = Math.max(1, Math.round(level ?? 1));
|
||||||
|
return l1 * Math.pow(g, n - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Is the point inside this tether's zone (center distance ≤ radius)? */
|
||||||
|
static contains(t, x, y) {
|
||||||
|
const dx = x - t.x;
|
||||||
|
const dy = y - t.y;
|
||||||
|
return dx * dx + dy * dy <= t.radius * t.radius;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Union membership: the ship is allowed wherever ANY tether reaches. */
|
||||||
|
static containsAny(x, y, tethers) {
|
||||||
|
for (const t of tethers) {
|
||||||
|
if (Tether.contains(t, x, y)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The barrier, as a clamp: a point inside the union is returned
|
||||||
|
* unchanged (clamped: false); a point outside is moved to the CLOSEST
|
||||||
|
* point on the union's boundary, with the outward normal there
|
||||||
|
* (the direction from the owning anchor — strip velocity along it and
|
||||||
|
* the ship can never cross the line).
|
||||||
|
*
|
||||||
|
* Closest point on the union = min over tethers of the closest point
|
||||||
|
* on that tether (the boundary point of the tether with the smallest
|
||||||
|
* rim gap), so this is exact for any number of overlapping zones.
|
||||||
|
*
|
||||||
|
* @returns {{x:number, y:number, clamped:boolean, id:string|null, nx:number, ny:number}}
|
||||||
|
*/
|
||||||
|
static clampPoint(x, y, tethers) {
|
||||||
|
let best = null;
|
||||||
|
for (const t of tethers) {
|
||||||
|
const dx = x - t.x;
|
||||||
|
const dy = y - t.y;
|
||||||
|
const d = Math.hypot(dx, dy);
|
||||||
|
if (d <= t.radius) {
|
||||||
|
return { x, y, clamped: false, id: t.id, nx: 0, ny: 0 };
|
||||||
|
}
|
||||||
|
const gap = d - t.radius; // distance from the point to this tether's rim
|
||||||
|
if (!best || gap < best.gap - EPS) {
|
||||||
|
const nx = dx / d;
|
||||||
|
const ny = dy / d;
|
||||||
|
best = { gap, id: t.id, x: t.x + nx * t.radius, y: t.y + ny * t.radius, nx, ny };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (best) return { x: best.x, y: best.y, clamped: true, id: best.id, nx: best.nx, ny: best.ny };
|
||||||
|
return { x, y, clamped: false, id: null, nx: 0, ny: 0 }; // no tethers → free space
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This tether's share of the union boundary: the rim arcs NOT covered
|
||||||
|
* by any other tether's zone. The barrier line is drawn ONLY on these
|
||||||
|
* arcs — where two zones overlap, the boundary simply ends where one
|
||||||
|
* zone begins (no line, no wall, in the overlap).
|
||||||
|
*
|
||||||
|
* Math: a point P(θ) = center + r·(cosθ, sinθ) on this rim is inside
|
||||||
|
* another zone o ⟺ |P − o| ≤ o.radius ⟺ cos(θ − β) ≥ k, where
|
||||||
|
* β = atan2(o.y − y, o.x − x), k = (r² + d² − o.radius²) / (2·r·d),
|
||||||
|
* d = |center → o|. So each other tether covers the angular interval
|
||||||
|
* [β − arccos k, β + arccos k] (when −1 < k < 1); k < −1 means o
|
||||||
|
* contains this whole rim; k ≥ 1 means no overlap. Union the covered
|
||||||
|
* intervals on the circle; the complement is the visible arcs.
|
||||||
|
*
|
||||||
|
* @returns {Array<{a0:number, a1:number}>} arcs in [0, TAU), a1 > a0, sorted
|
||||||
|
*/
|
||||||
|
static visibleArcs(t, tethers) {
|
||||||
|
const r = t.radius;
|
||||||
|
const covered = [];
|
||||||
|
for (const o of tethers) {
|
||||||
|
if (o === t) continue;
|
||||||
|
const dx = o.x - t.x;
|
||||||
|
const dy = o.y - t.y;
|
||||||
|
const d = Math.hypot(dx, dy);
|
||||||
|
if (d === 0) {
|
||||||
|
// Concentric: only a larger (or equal) radius can cover this rim.
|
||||||
|
if (o.radius >= r) return [];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const k = (r * r + d * d - o.radius * o.radius) / (2 * r * d);
|
||||||
|
if (k < -1) return []; // o contains this disc → the whole rim is covered
|
||||||
|
if (k <= -1 || k >= 1) continue; // tangent or disjoint → no covered arc
|
||||||
|
const beta = Math.atan2(dy, dx);
|
||||||
|
const half = Math.acos(k);
|
||||||
|
covered.push([beta - half, beta + half]);
|
||||||
|
}
|
||||||
|
return complementOf(unionOnCircle(covered));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wrap an angle into [0, TAU). */
|
||||||
|
function wrapAngle(a) {
|
||||||
|
const w = a % TAU;
|
||||||
|
return w < 0 ? w + TAU : w;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Union of angular intervals on the circle. Inputs may be unwrapped
|
||||||
|
* (a1 may exceed a0 + TAU); outputs are merged intervals in [0, TAU],
|
||||||
|
* sorted, possibly covering the full circle.
|
||||||
|
*/
|
||||||
|
function unionOnCircle(intervals) {
|
||||||
|
const pieces = [];
|
||||||
|
for (const [a0, a1] of intervals) {
|
||||||
|
const span = a1 - a0;
|
||||||
|
if (span <= EPS) continue;
|
||||||
|
const s = wrapAngle(a0);
|
||||||
|
if (s + span >= TAU - EPS) {
|
||||||
|
pieces.push([s, TAU]);
|
||||||
|
const tail = s + span - TAU;
|
||||||
|
if (tail > EPS) pieces.push([0, tail]);
|
||||||
|
} else {
|
||||||
|
pieces.push([s, s + span]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pieces.sort((p, q) => p[0] - q[0]);
|
||||||
|
const merged = [];
|
||||||
|
for (const p of pieces) {
|
||||||
|
const last = merged[merged.length - 1];
|
||||||
|
if (last && p[0] <= last[1] + EPS) last[1] = Math.max(last[1], p[1]);
|
||||||
|
else merged.push([p[0], p[1]]);
|
||||||
|
}
|
||||||
|
// NOTE: no seam merge. Intervals that touch across 0/TAU (e.g. [5.9, TAU]
|
||||||
|
// and [0, 0.9]) stay two flat intervals: on the circle they are one arc,
|
||||||
|
// but "wrapping" cannot be represented as a single [a0, a1] ⊂ [0, TAU].
|
||||||
|
// The complement (complementOf) handles the seam correctly — merging them
|
||||||
|
// would wrongly report the whole circle as covered.
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Complement of merged [0, TAU] intervals → the uncovered arcs. */
|
||||||
|
function complementOf(covered) {
|
||||||
|
if (covered.length === 0) return [{ a0: 0, a1: TAU }];
|
||||||
|
const out = [];
|
||||||
|
let cursor = 0;
|
||||||
|
for (const [a0, a1] of covered) {
|
||||||
|
if (a0 > cursor + EPS) out.push({ a0: cursor, a1: a0 });
|
||||||
|
if (a1 > cursor) cursor = a1;
|
||||||
|
if (cursor >= TAU - EPS) break;
|
||||||
|
}
|
||||||
|
if (cursor < TAU - EPS) out.push({ a0: cursor, a1: TAU });
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,419 @@
|
||||||
|
import Phaser from '../vendor/phaser.js';
|
||||||
|
import { config } from '../config/Config.js';
|
||||||
|
import { toColor } from '../utils/Color.js';
|
||||||
|
import { Tether } from './Tether.js';
|
||||||
|
|
||||||
|
const TAU = Math.PI * 2;
|
||||||
|
const EPS = 1e-9;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The player's tether FIELD for one scene: owns the tether records
|
||||||
|
* (Tether), applies the range barrier to the ship each frame, and draws
|
||||||
|
* the barrier — the union boundary — as a thick cyberpunk glitch dotted
|
||||||
|
* line (marching dashes, additive glow, RGB fringe, ambient glitch
|
||||||
|
* bursts, contact shudder where the ship bumps it).
|
||||||
|
*
|
||||||
|
* Layering:
|
||||||
|
* - model + rules → js/tether/Tether.js (pure, Node-tested)
|
||||||
|
* - this file → scene-facing container, constraint, visuals
|
||||||
|
* - builds (later) → add() / remove() / setLevel() + the onChange hook
|
||||||
|
*
|
||||||
|
* Rendering: the rim of each tether is drawn as dots (dashes) of
|
||||||
|
* constant WORLD size — `dash` px of arc — only where it is on screen
|
||||||
|
* (cull against the camera rect) and only on the tether's visible arcs
|
||||||
|
* (Tether.visibleArcs — the union boundary, so overlaps get no line).
|
||||||
|
* Cheap at any radius: ~700 dots scanned per level-1 tether, a few dozen
|
||||||
|
* drawn.
|
||||||
|
*/
|
||||||
|
export class TetherField {
|
||||||
|
/**
|
||||||
|
* @param {Phaser.Scene} scene
|
||||||
|
* @param {object} [o]
|
||||||
|
* @param {number} [o.depth=6] — graphics depth (above planets 5, below ship 10)
|
||||||
|
* @param {Function} [o.onChange] — fired after add/remove/setLevel (HUD refresh seam)
|
||||||
|
*/
|
||||||
|
constructor(scene, o = {}) {
|
||||||
|
this.scene = scene;
|
||||||
|
this.tethers = []; // Tether[] — the model this field owns
|
||||||
|
this.onChange = typeof o.onChange === 'function' ? o.onChange : null;
|
||||||
|
|
||||||
|
// ---- line tuning (data/tether.json → line) ------------------------
|
||||||
|
const line = config.section('tether.line', {});
|
||||||
|
this.dash = Math.max(4, line.dash ?? 26); // px of rim per dot (arc length)
|
||||||
|
this.gap = Math.max(2, line.gap ?? 18);
|
||||||
|
this.period = this.dash + this.gap;
|
||||||
|
this.lineWidth = line.width ?? 9;
|
||||||
|
this.coreWidth = Math.max(1, line.coreWidth ?? 3.5);
|
||||||
|
this.glowWidth = Math.max(4, line.glowWidth ?? 26);
|
||||||
|
this.marchSpeed = line.marchSpeed ?? 26; // px/s — dashes flow along the rim
|
||||||
|
this.ghostOffset = line.ghostOffset ?? 3; // px of RGB fringe (radial)
|
||||||
|
const colors = line.colors ?? {};
|
||||||
|
this.colorCore = toColor(colors.core, 0xeaf6ff);
|
||||||
|
this.colorMain = toColor(colors.main, 0x00e5ff);
|
||||||
|
this.colorGhost = toColor(colors.ghost, 0xff2d6f);
|
||||||
|
this.colorGlow = toColor(colors.glow, 0x00e5ff);
|
||||||
|
const alphas = line.alphas ?? {};
|
||||||
|
this.alphaCore = alphas.core ?? 0.85;
|
||||||
|
this.alphaMain = alphas.main ?? 0.5;
|
||||||
|
this.alphaGhost = alphas.ghost ?? 0.16;
|
||||||
|
this.alphaGlow = alphas.glow ?? 0.09;
|
||||||
|
const gl = line.glitch ?? {};
|
||||||
|
this.glitchEnabled = gl.enabled !== false;
|
||||||
|
this.glitchInterval = Array.isArray(gl.intervalMs) ? gl.intervalMs : [3200, 8200];
|
||||||
|
this.glitchDuration = Array.isArray(gl.durationMs) ? gl.durationMs : [220, 460];
|
||||||
|
this.maxDisplace = gl.maxDisplace ?? 26; // px of radial glitch displacement
|
||||||
|
this.dropChance = gl.dropChance ?? 0.35; // max fraction of dots dropped in a burst
|
||||||
|
const contact = config.section('tether.contact', {});
|
||||||
|
this.contactStrength = contact.pulseStrength ?? 1.0;
|
||||||
|
this.contactDuration = contact.pulseDuration ?? 650; // ms
|
||||||
|
this.contactWidth = contact.pulseWidth ?? 320; // px of rim a hit shudders
|
||||||
|
|
||||||
|
// ---- glitch / pulse state -----------------------------------------
|
||||||
|
this.glitchLevel = 0; // 0..1 — ambient burst level (drives flicker/displace)
|
||||||
|
this.burstT0 = null;
|
||||||
|
this.burstDur = 0;
|
||||||
|
this.burstIntensity = 1;
|
||||||
|
this.nextBurst = null;
|
||||||
|
this.pulses = []; // { id, angle, t0, strength } — contact shudders
|
||||||
|
this._arcs = new Map(); // tether id → visible arcs (cached, invalidated on change)
|
||||||
|
|
||||||
|
// ---- graphics: two layers (glow + ghosts under, main + core over) --
|
||||||
|
const depth = o.depth ?? 6;
|
||||||
|
this.gUnder = scene.add.graphics().setDepth(depth);
|
||||||
|
this.gUnder.setBlendMode(Phaser.BlendModes.ADD);
|
||||||
|
this.gOver = scene.add.graphics().setDepth(depth + 1);
|
||||||
|
|
||||||
|
this.lastTime = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Model (the seam future builds plug into)
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anchor a tether: (x, y) = the anchor's center (planet/station),
|
||||||
|
* level = its level. Replaces a tether with the same id.
|
||||||
|
*/
|
||||||
|
add(id, x, y, level, label = '') {
|
||||||
|
this.remove(id);
|
||||||
|
const t = new Tether(id, x, y, level);
|
||||||
|
t.label = label ?? '';
|
||||||
|
this.tethers.push(t);
|
||||||
|
this.invalidateArcs();
|
||||||
|
this._emitChange();
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove a tether by id. Returns the removed Tether or null. */
|
||||||
|
remove(id) {
|
||||||
|
const i = this.tethers.findIndex((t) => t.id === id);
|
||||||
|
if (i === -1) return null;
|
||||||
|
const [t] = this.tethers.splice(i, 1);
|
||||||
|
this.invalidateArcs();
|
||||||
|
this._emitChange();
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Upgrade/downgrade a tether's level (radius + boundary update). */
|
||||||
|
setLevel(id, level) {
|
||||||
|
const t = this.get(id);
|
||||||
|
if (!t) return null;
|
||||||
|
t.level = Math.max(1, Math.round(level));
|
||||||
|
t.radius = Tether.radiusForLevel(t.level);
|
||||||
|
this.invalidateArcs();
|
||||||
|
this._emitChange();
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
get(id) {
|
||||||
|
return this.tethers.find((t) => t.id === id) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Is the point inside the player's range (the union)? */
|
||||||
|
contains(x, y) {
|
||||||
|
return Tether.containsAny(x, y, this.tethers);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clamp a point to the union (barrier boundary) — see Tether.clampPoint. */
|
||||||
|
clampPoint(x, y) {
|
||||||
|
return Tether.clampPoint(x, y, this.tethers);
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidateArcs() {
|
||||||
|
for (const t of this.tethers) {
|
||||||
|
this._arcs.set(t.id, Tether.visibleArcs(t, this.tethers));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_emitChange() {
|
||||||
|
try {
|
||||||
|
this.onChange?.(this);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[tether] onChange handler failed', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// The barrier (called by the scene each frame, after the planets)
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep the ship inside the union: outside → moved to the closest
|
||||||
|
* boundary point, with the outward velocity AND acceleration stripped
|
||||||
|
* (same static-resolve pattern as Planet.constrainShip — the physics
|
||||||
|
* world integrates after this runs, so the inward kick is removed
|
||||||
|
* before it can push the ship back over the line).
|
||||||
|
*
|
||||||
|
* @returns {null | {id, angle, vn}} a contact report (vn = speed into the wall)
|
||||||
|
*/
|
||||||
|
constrainShip(ship) {
|
||||||
|
if (this.tethers.length === 0) return null;
|
||||||
|
const p = Tether.clampPoint(ship.x, ship.y, this.tethers);
|
||||||
|
if (!p.clamped) return null;
|
||||||
|
|
||||||
|
ship.x = p.x;
|
||||||
|
ship.y = p.y;
|
||||||
|
|
||||||
|
let vn = 0;
|
||||||
|
const body = ship.body;
|
||||||
|
if (body) {
|
||||||
|
vn = body.velocity.x * p.nx + body.velocity.y * p.ny;
|
||||||
|
if (vn > 0) {
|
||||||
|
body.velocity.x -= vn * p.nx;
|
||||||
|
body.velocity.y -= vn * p.ny;
|
||||||
|
}
|
||||||
|
if (body.acceleration) {
|
||||||
|
const an = body.acceleration.x * p.nx + body.acceleration.y * p.ny;
|
||||||
|
if (an > 0) {
|
||||||
|
body.acceleration.x -= an * p.nx;
|
||||||
|
body.acceleration.y -= an * p.ny;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const angle = Math.atan2(p.ny, p.nx);
|
||||||
|
// A standing push (resting on the line, throttle still aimed out)
|
||||||
|
// keeps the barrier buzz at a low level; a fresh hit buzzes harder.
|
||||||
|
const strength = Math.min(1, 0.25 + (vn > 0 ? vn / 240 : 0));
|
||||||
|
this.addContact(p.id, angle, strength);
|
||||||
|
return { id: p.id, angle, vn };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Record a contact (merged when close to a recent one, capped). */
|
||||||
|
addContact(id, angle, strength) {
|
||||||
|
const now = this.lastTime || 0;
|
||||||
|
const existing = this.pulses.find(
|
||||||
|
(p) => p.id === id && Math.abs(Math.atan2(Math.sin(p.angle - angle), Math.cos(p.angle - angle))) < 0.15,
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
existing.t0 = now;
|
||||||
|
existing.strength = Math.max(existing.strength, strength);
|
||||||
|
} else {
|
||||||
|
this.pulses.push({ id, angle, t0: now, strength });
|
||||||
|
if (this.pulses.length > 6) this.pulses.shift();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Contact pulses active on this tether right now (decayed). */
|
||||||
|
pulsesFor(id, time) {
|
||||||
|
const out = [];
|
||||||
|
for (const p of this.pulses) {
|
||||||
|
if (p.id !== id) continue;
|
||||||
|
const u = (time - p.t0) / this.contactDuration;
|
||||||
|
if (u < 0 || u >= 1) continue;
|
||||||
|
out.push({ angle: p.angle, strength: p.strength * Math.pow(1 - u, 1.4) * this.contactStrength });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Visuals (driven by the scene's update loop)
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Step the glitch/pulse lifecycle. Call once per frame. */
|
||||||
|
tick(time, delta) {
|
||||||
|
this.lastTime = time;
|
||||||
|
|
||||||
|
if (this.glitchEnabled) {
|
||||||
|
if (this.nextBurst === null) this.nextBurst = time + 900 + Math.random() * 900;
|
||||||
|
if (this.burstT0 !== null) {
|
||||||
|
const el = time - this.burstT0;
|
||||||
|
if (el >= this.burstDur) {
|
||||||
|
this.burstT0 = null;
|
||||||
|
} else {
|
||||||
|
this.glitchLevel = Math.max(this.glitchLevel, (1 - el / this.burstDur) * this.burstIntensity);
|
||||||
|
}
|
||||||
|
} else if (time >= this.nextBurst) {
|
||||||
|
this.burstT0 = time;
|
||||||
|
this.burstDur = this.randRange(this.glitchDuration);
|
||||||
|
this.burstIntensity = 0.7 + Math.random() * 0.5;
|
||||||
|
this.nextBurst = time + this.burstDur + this.randRange(this.glitchInterval);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft tail after any burst/contact.
|
||||||
|
this.glitchLevel *= Math.exp(-(delta / 140));
|
||||||
|
if (this.glitchLevel < 0.003) this.glitchLevel = 0;
|
||||||
|
|
||||||
|
this.pulses = this.pulses.filter((p) => time - p.t0 < this.contactDuration);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Redraw the barrier (only the on-screen dots). Call once per frame. */
|
||||||
|
draw(time) {
|
||||||
|
const cam = this.scene.cameras.main;
|
||||||
|
const w = this.scene.scale.width;
|
||||||
|
const h = this.scene.scale.height;
|
||||||
|
const M = this.glowWidth + this.maxDisplace + 16; // cull margin
|
||||||
|
const L = cam.scrollX - M;
|
||||||
|
const T = cam.scrollY - M;
|
||||||
|
const R = cam.scrollX + w + M;
|
||||||
|
const B = cam.scrollY + h + M;
|
||||||
|
|
||||||
|
const gl = this.glitchLevel;
|
||||||
|
const quant = Math.floor(time / 80); // flicker hash quantum (~12 Hz)
|
||||||
|
const off = ((time * 0.001 * this.marchSpeed) % this.period + this.period) % this.period;
|
||||||
|
const ghost = this.ghostOffset + gl * 7;
|
||||||
|
|
||||||
|
const glowSegs = [];
|
||||||
|
const ghostSegs = []; // [x0, y0, x1, y1, radiusShift] per dot (red = +, cyan = −)
|
||||||
|
const mainSegs = [];
|
||||||
|
const coreSegs = [];
|
||||||
|
|
||||||
|
for (let ti = 0; ti < this.tethers.length; ti++) {
|
||||||
|
const t = this.tethers[ti];
|
||||||
|
const arcs = this._arcs.get(t.id);
|
||||||
|
if (!arcs || arcs.length === 0) continue;
|
||||||
|
const r = t.radius;
|
||||||
|
const C = TAU * r;
|
||||||
|
const n = Math.ceil(C / this.period) + 1;
|
||||||
|
const pulses = this.pulsesFor(t.id, time);
|
||||||
|
const sigma = this.contactWidth / r;
|
||||||
|
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const s = (i * this.period + off) % C;
|
||||||
|
const thMid = s / r;
|
||||||
|
if (!this.inArcs(arcs, thMid)) continue;
|
||||||
|
const cx = t.x + Math.cos(thMid) * r;
|
||||||
|
const cy = t.y + Math.sin(thMid) * r;
|
||||||
|
if (cx < L || cx > R || cy < T || cy > B) continue;
|
||||||
|
|
||||||
|
// Per-dot glitch: radial displacement (hash-driven, ~12 Hz) + the
|
||||||
|
// contact shudder window around where the ship hit the line.
|
||||||
|
const h1 = hash01(i, ti, quant);
|
||||||
|
let disp = (h1 - 0.5) * 2 * this.maxDisplace * gl;
|
||||||
|
let flick = 0;
|
||||||
|
if (pulses.length > 0) {
|
||||||
|
for (const p of pulses) {
|
||||||
|
const da = Math.atan2(Math.sin(p.angle - thMid), Math.cos(p.angle - thMid));
|
||||||
|
const g = Math.exp(-(da * da) / (2 * sigma * sigma));
|
||||||
|
disp += p.strength * 30 * g;
|
||||||
|
if (p.strength * 0.5 * g > flick) flick = p.strength * 0.5 * g;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const h2 = hash01(i, ti + 100, quant + 13);
|
||||||
|
if (h2 < gl * this.dropChance || h2 < flick) continue; // data drop
|
||||||
|
|
||||||
|
const rad = r + disp;
|
||||||
|
const th0 = s / r;
|
||||||
|
const th1 = (s + this.dash) / r;
|
||||||
|
const c0 = Math.cos(th0);
|
||||||
|
const s0 = Math.sin(th0);
|
||||||
|
const c1 = Math.cos(th1);
|
||||||
|
const s1 = Math.sin(th1);
|
||||||
|
|
||||||
|
const push = (segs, radius) => {
|
||||||
|
segs.push(t.x + c0 * radius, t.y + s0 * radius, t.x + c1 * radius, t.y + s1 * radius);
|
||||||
|
};
|
||||||
|
push(glowSegs, rad);
|
||||||
|
ghostSegs.push(t.x + c0 * (rad + ghost), t.y + s0 * (rad + ghost), t.x + c1 * (rad + ghost), t.y + s1 * (rad + ghost));
|
||||||
|
ghostSegs.push(t.x + c0 * (rad - ghost), t.y + s0 * (rad - ghost), t.x + c1 * (rad - ghost), t.y + s1 * (rad - ghost));
|
||||||
|
push(mainSegs, rad);
|
||||||
|
push(coreSegs, rad);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sparks: bright radial ticks during a burst (data tearing off the rim).
|
||||||
|
if (gl > 0.15) {
|
||||||
|
const k = 2 + Math.floor(gl * 3);
|
||||||
|
for (let kk = 0; kk < k; kk++) {
|
||||||
|
const hi = Math.floor(hash01(kk, 7 + ti * 3, quant) * n);
|
||||||
|
const s = (hi * this.period + off) % C;
|
||||||
|
const th = s / r;
|
||||||
|
if (!this.inArcs(arcs, th)) continue;
|
||||||
|
const cx = t.x + Math.cos(th) * r;
|
||||||
|
const cy = t.y + Math.sin(th) * r;
|
||||||
|
if (cx < L || cx > R || cy < T || cy > B) continue;
|
||||||
|
const len = 10 + hash01(kk, 91, quant) * 24;
|
||||||
|
const dir = hash01(kk, 55, quant) > 0.5 ? 1 : -1;
|
||||||
|
coreSegs.push(cx, cy, t.x + Math.cos(th) * (r + dir * len), t.y + Math.sin(th) * (r + dir * len));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contact zaps: spikes at the exact hit point while the pulse decays.
|
||||||
|
for (const p of pulses) {
|
||||||
|
for (let k = -1; k <= 1; k++) {
|
||||||
|
const a2 = p.angle + k * 0.05;
|
||||||
|
const len = 8 + p.strength * 26;
|
||||||
|
const ca = Math.cos(a2);
|
||||||
|
const sa = Math.sin(a2);
|
||||||
|
coreSegs.push(t.x + ca * r, t.y + sa * r, t.x + ca * (r + len), t.y + sa * (r + len));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Passes (one stroked path each — the whole barrier is ~5 draw calls).
|
||||||
|
const gU = this.gUnder;
|
||||||
|
const gO = this.gOver;
|
||||||
|
gU.clear();
|
||||||
|
gO.clear();
|
||||||
|
if (glowSegs.length === 0 && ghostSegs.length === 0 && mainSegs.length === 0 && coreSegs.length === 0) return;
|
||||||
|
|
||||||
|
this.strokePass(gU, glowSegs, this.glowWidth, this.colorGlow, this.alphaGlow * (1 + 1.5 * gl));
|
||||||
|
this.strokePass(gU, ghostSegs, this.lineWidth * 0.9, this.colorGhost, this.alphaGhost * (1 + 2.2 * gl));
|
||||||
|
this.strokePass(gO, mainSegs, this.lineWidth, this.colorMain, this.alphaMain * (0.9 + 0.3 * gl));
|
||||||
|
this.strokePass(gO, coreSegs, this.coreWidth, this.colorCore, this.alphaCore * (0.8 + 0.3 * gl));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One pass = one beginPath…strokePath over a flat [x0,y0,x1,y1,…] list. */
|
||||||
|
strokePass(g, segs, width, color, alpha) {
|
||||||
|
if (segs.length === 0) return;
|
||||||
|
g.lineStyle(width, color, alpha);
|
||||||
|
g.beginPath();
|
||||||
|
for (let i = 0; i < segs.length; i += 4) {
|
||||||
|
g.moveTo(segs[i], segs[i + 1]);
|
||||||
|
g.lineTo(segs[i + 2], segs[i + 3]);
|
||||||
|
}
|
||||||
|
g.strokePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Is angle θ on (or at the edge of) any of this tether's visible arcs? */
|
||||||
|
inArcs(arcs, th) {
|
||||||
|
let w = th % TAU;
|
||||||
|
if (w < 0) w += TAU;
|
||||||
|
for (const a of arcs) {
|
||||||
|
if (w >= a.a0 - EPS && w <= a.a1 + EPS) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
randRange([lo, hi]) {
|
||||||
|
if (!Array.isArray(lo)) return lo;
|
||||||
|
return lo + Math.random() * (hi - lo);
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.tethers.length = 0;
|
||||||
|
this.pulses.length = 0;
|
||||||
|
this._arcs.clear();
|
||||||
|
this.onChange = null;
|
||||||
|
this.gUnder?.destroy();
|
||||||
|
this.gOver?.destroy();
|
||||||
|
this.gUnder = null;
|
||||||
|
this.gOver = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deterministic 0..1 hash from (dot index, tether index, time quantum). */
|
||||||
|
function hash01(i, salt, quant) {
|
||||||
|
const s = Math.sin(i * 12.9898 + salt * 78.233 + quant * 37.719) * 43758.5453;
|
||||||
|
return s - Math.floor(s);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue