436 lines
16 KiB
JavaScript
436 lines
16 KiB
JavaScript
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);
|
||
}
|
||
|
||
/**
|
||
* External kick to the barrier's glitch (the DEEP SCAN wave being absorbed
|
||
* at the rim — js/scan/ScanPulse.js): starts a glitch burst RIGHT NOW at
|
||
* the given intensity (1.0 = a normal ambient burst; ~1.15 = a shudder),
|
||
* and pushes the next ambient burst back so it does not double-fire.
|
||
*/
|
||
excite(intensity = 1.15) {
|
||
if (!this.glitchEnabled) return;
|
||
const now = this.lastTime || 0;
|
||
this.burstT0 = now;
|
||
this.burstDur = Math.max(this.burstDur, 620);
|
||
this.burstIntensity = Math.max(this.burstIntensity, intensity);
|
||
this.glitchLevel = Math.max(this.glitchLevel, 0.85);
|
||
this.nextBurst = now + this.burstDur + this.randRange(this.glitchInterval);
|
||
}
|
||
|
||
/** 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);
|
||
}
|