orbit/js/tether/TetherField.js

420 lines
16 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 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);
}