diff --git a/README.md b/README.md index 927e9ad..00715e7 100644 --- a/README.md +++ b/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 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`) +- **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 while it flies and the view slowly recenters (≈1.5 s) once the ship comes to rest @@ -69,6 +80,8 @@ orbit/ │ ├── menu.json # menu text, colors, button layout, seed panel │ ├── theme.json # shared UI theme: fonts, palette, CRT/glitch tuning │ ├── 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…) │ ├── systems.json # system archetypes: theme, attributes, distribution │ ├── settlements.json # the lived-in layer: settlement kinds & populations @@ -85,6 +98,7 @@ orbit/ │ ├── scenes/ # MenuScene, GameScene (thin, orchestration) │ ├── entities/ # Ship (own behavior), Planet (home world, solid) │ ├── galaxy/ # Galaxy (seeded world model), SystemGenerator, SystemReport +│ ├── tether/ # Tether (pure range math) + TetherField (constraint + barrier line) │ ├── ui/ # MenuButton, GlitchText, CyberShape, ActionBar, DiscoveryCompass (reusable) │ ├── visuals/ # Starfield, CyberOverlay (CRT/glitch, shared) │ ├── 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/galaxy.test.mjs # galaxy determinism, distribution, lazy vs eager 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 ``` diff --git a/data/game.json b/data/game.json index bd6a3d4..f6e3da2 100644 --- a/data/game.json +++ b/data/game.json @@ -4,7 +4,7 @@ "width": 1280, "height": 720, "backgroundColor": "#04060d", - "hintText": "click anywhere to fly", + "hintText": "click anywhere to fly · the tether holds your range", "markerColor": "#41c7ff", "physics": { "default": "arcade" diff --git a/data/manifest.json b/data/manifest.json index 23300f9..3913ca5 100644 --- a/data/manifest.json +++ b/data/manifest.json @@ -5,6 +5,7 @@ "menu.json", "ship.json", "planets.json", + "tether.json", "galaxy.json", "systems.json", "settlements.json", diff --git a/data/tether.json b/data/tether.json new file mode 100644 index 0000000..dfdaded --- /dev/null +++ b/data/tether.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 + } +} diff --git a/dev/smoke-game.mjs b/dev/smoke-game.mjs index 1a21a5b..18e3449 100644 --- a/dev/smoke-game.mjs +++ b/dev/smoke-game.mjs @@ -8,12 +8,15 @@ * --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). + * ?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. + * system planet — screenshots the discovery moment + * (rim ping + toast) with the world in view. + * ?tethers=x,y,level;x,y,level add dev tethers (union-boundary tests) + * ?homeLevel= 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 { config } from '../js/config/Config.js'; @@ -24,8 +27,19 @@ 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; +const params = typeof location !== 'undefined' ? new URLSearchParams(location.search) : 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]; const game = new Phaser.Game(gameConfig); 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) { const px = Number(nearParam) || 400; setTimeout(() => { diff --git a/dev/tether.test.mjs b/dev/tether.test.mjs new file mode 100644 index 0000000..30fdf48 --- /dev/null +++ b/dev/tether.test.mjs @@ -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); diff --git a/docs/PROJECT_NOTES.md b/docs/PROJECT_NOTES.md index 7261253..1a15bb7 100644 --- a/docs/PROJECT_NOTES.md +++ b/docs/PROJECT_NOTES.md @@ -180,6 +180,56 @@ world** — solid, rendered, flyable-to. Rules and seams: - Verified: `dev/discovery.test.mjs` (rule, boundaries, per-system 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 - 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 (`ActionBar.contains` / `DiscoveryCompass.contains`) (`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, and the player's place in a populated galaxy - [ ] Landing & exploration: settlements become points of interest you diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index 890f4f5..bc1776f 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -11,11 +11,21 @@ import { Planet } from '../entities/Planet.js'; import { Starfield } from '../visuals/Starfield.js'; import { DiscoveryCompass, circleInView } from '../ui/DiscoveryCompass.js'; import { ActionBar } from '../ui/ActionBar.js'; +import { TetherField } from '../tether/TetherField.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); +/** 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 * 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.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 // distance of an edge — data/game.json), tracked per system. Kept in // 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. // 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.) + // 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) => { if (this.actionBar && this.actionBar.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) { 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.ship.setTarget(aim.x, aim.y); this.hideHint(); @@ -259,6 +293,37 @@ export class GameScene extends Phaser.Scene { } line(report.summary, { fontFamily: fam, fontSize: '12px', color: '#54608a' }); 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) { @@ -274,12 +339,77 @@ export class GameScene extends Phaser.Scene { for (const p of this.solidPlanets) { 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.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.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: * 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); // Approach point: on the rim (clearance + hull), on the side the ship // 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 dy = this.ship.y - o.y; const d = Math.hypot(dx, dy) || 1; let aim = planet.edgePoint(Math.atan2(dy, dx), planet.clearance, this.ship.radius); // 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); + aim = this.tetherField.clampPoint(aim.x, aim.y); this.showTargetMarker(aim.x, aim.y); this.ship.setTarget(aim.x, aim.y); this.hideHint(); @@ -468,5 +601,6 @@ export class GameScene extends Phaser.Scene { this.starfield?.destroy(); this.compass?.destroy(); this.actionBar?.destroy(); + this.tetherField?.destroy(); } } diff --git a/js/tether/Tether.js b/js/tether/Tether.js new file mode 100644 index 0000000..480493a --- /dev/null +++ b/js/tether/Tether.js @@ -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; +} diff --git a/js/tether/TetherField.js b/js/tether/TetherField.js new file mode 100644 index 0000000..5db4012 --- /dev/null +++ b/js/tether/TetherField.js @@ -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); +}