193 lines
7.3 KiB
JavaScript
193 lines
7.3 KiB
JavaScript
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', 2.0);
|
||
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 — even at EXACT internal tangency (k = −1,
|
||
* e.g. a level-1 gate tether placed at exactly the level-2 anchor's
|
||
* range: 5120 + 5120 = 10240), where the rim lies entirely inside o
|
||
* and contributes no boundary; k ≥ 1 means externally tangent or
|
||
* disjoint (no covered arc). 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 rim — strictly, or exactly internally tangent
|
||
if (k >= 1) continue; // externally 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;
|
||
}
|