orbit/js/tether/Tether.js

189 lines
7.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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^(level1)
* (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^(level1) (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;
}