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; }