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