Files
the-nexus/frontend/js/satflow.js
Alexander Whitestone cec0781d95
Some checks failed
CI / test (pull_request) Failing after 11s
CI / validate (pull_request) Failing after 11s
Review Approval Gate / verify-review (pull_request) Failing after 9s
feat: restore frontend shell and implement Project Mnemosyne visual memory bridge
2026-04-08 21:24:32 -04:00

262 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.
/**
* satflow.js — Sat flow particle effects for Lightning payments.
*
* When a payment_flow event arrives, gold particles fly from sender
* to receiver along a bezier arc. On arrival, a brief burst radiates
* outward from the target agent.
*
* Resolves Issue #13 — Sat flow particle effects
*/
import * as THREE from 'three';
let scene = null;
/* ── Pool management ── */
const MAX_ACTIVE_FLOWS = 6;
const activeFlows = [];
/* ── Shared resources ── */
const SAT_COLOR = new THREE.Color(0xffcc00);
const BURST_COLOR = new THREE.Color(0xffee44);
const particleGeo = new THREE.BufferGeometry();
// Pre-build a single-point geometry for instancing via Points
const _singleVert = new Float32Array([0, 0, 0]);
particleGeo.setAttribute('position', new THREE.BufferAttribute(_singleVert, 3));
/* ── API ── */
/**
* Initialize the sat flow system.
* @param {THREE.Scene} scn
*/
export function initSatFlow(scn) {
scene = scn;
}
/**
* Trigger a sat flow animation between two world positions.
*
* @param {THREE.Vector3} fromPos — sender world position
* @param {THREE.Vector3} toPos — receiver world position
* @param {number} amountSats — payment amount (scales particle count)
*/
export function triggerSatFlow(fromPos, toPos, amountSats = 100) {
if (!scene) return;
// Evict oldest flow if at capacity
if (activeFlows.length >= MAX_ACTIVE_FLOWS) {
const old = activeFlows.shift();
_cleanupFlow(old);
}
// Particle count: 5-20 based on amount, log-scaled
const count = Math.min(20, Math.max(5, Math.round(Math.log10(amountSats + 1) * 5)));
const flow = _createFlow(fromPos.clone(), toPos.clone(), count);
activeFlows.push(flow);
}
/**
* Per-frame update — advance all active flows.
* @param {number} delta — seconds since last frame
*/
export function updateSatFlow(delta) {
for (let i = activeFlows.length - 1; i >= 0; i--) {
const flow = activeFlows[i];
flow.elapsed += delta;
if (flow.phase === 'travel') {
_updateTravel(flow, delta);
if (flow.elapsed >= flow.duration) {
flow.phase = 'burst';
flow.elapsed = 0;
_startBurst(flow);
}
} else if (flow.phase === 'burst') {
_updateBurst(flow, delta);
if (flow.elapsed >= flow.burstDuration) {
_cleanupFlow(flow);
activeFlows.splice(i, 1);
}
}
}
}
/**
* Dispose all sat flow resources.
*/
export function disposeSatFlow() {
for (const flow of activeFlows) _cleanupFlow(flow);
activeFlows.length = 0;
scene = null;
}
/* ── Internals: Flow lifecycle ── */
function _createFlow(from, to, count) {
// Bezier control point — arc upward
const mid = new THREE.Vector3().lerpVectors(from, to, 0.5);
mid.y += 3 + from.distanceTo(to) * 0.3;
// Create particles
const positions = new Float32Array(count * 3);
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geo.boundingSphere = new THREE.Sphere(mid, 50);
const mat = new THREE.PointsMaterial({
color: SAT_COLOR,
size: 0.25,
transparent: true,
opacity: 1.0,
blending: THREE.AdditiveBlending,
depthWrite: false,
sizeAttenuation: true,
});
const points = new THREE.Points(geo, mat);
scene.add(points);
// Per-particle timing offsets (stagger the swarm)
const offsets = new Float32Array(count);
for (let i = 0; i < count; i++) {
offsets[i] = (i / count) * 0.4; // stagger over first 40% of duration
}
return {
phase: 'travel',
elapsed: 0,
duration: 1.5 + from.distanceTo(to) * 0.05, // 1.52.5s depending on distance
from, to, mid,
count,
points, geo, mat, positions,
offsets,
burstPoints: null,
burstGeo: null,
burstMat: null,
burstPositions: null,
burstVelocities: null,
burstDuration: 0.6,
};
}
function _updateTravel(flow, _delta) {
const { from, to, mid, count, positions, offsets, elapsed, duration } = flow;
for (let i = 0; i < count; i++) {
// Per-particle progress with stagger offset
let t = (elapsed - offsets[i]) / (duration - 0.4);
t = Math.max(0, Math.min(1, t));
// Quadratic bezier: B(t) = (1-t)²·P0 + 2(1-t)t·P1 + t²·P2
const mt = 1 - t;
const i3 = i * 3;
positions[i3] = mt * mt * from.x + 2 * mt * t * mid.x + t * t * to.x;
positions[i3 + 1] = mt * mt * from.y + 2 * mt * t * mid.y + t * t * to.y;
positions[i3 + 2] = mt * mt * from.z + 2 * mt * t * mid.z + t * t * to.z;
// Add slight wobble for organic feel
const wobble = Math.sin(elapsed * 12 + i * 1.7) * 0.08;
positions[i3] += wobble;
positions[i3 + 2] += wobble;
}
flow.geo.attributes.position.needsUpdate = true;
// Fade in/out
if (elapsed < 0.2) {
flow.mat.opacity = elapsed / 0.2;
} else if (elapsed > duration - 0.3) {
flow.mat.opacity = Math.max(0, (duration - elapsed) / 0.3);
} else {
flow.mat.opacity = 1.0;
}
}
function _startBurst(flow) {
// Hide travel particles
if (flow.points) flow.points.visible = false;
// Create burst particles at destination
const burstCount = 12;
const positions = new Float32Array(burstCount * 3);
const velocities = new Float32Array(burstCount * 3);
for (let i = 0; i < burstCount; i++) {
const i3 = i * 3;
positions[i3] = flow.to.x;
positions[i3 + 1] = flow.to.y + 0.5;
positions[i3 + 2] = flow.to.z;
// Random outward velocity
const angle = (i / burstCount) * Math.PI * 2;
const speed = 2 + Math.random() * 3;
velocities[i3] = Math.cos(angle) * speed;
velocities[i3 + 1] = 1 + Math.random() * 3;
velocities[i3 + 2] = Math.sin(angle) * speed;
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geo.boundingSphere = new THREE.Sphere(flow.to, 20);
const mat = new THREE.PointsMaterial({
color: BURST_COLOR,
size: 0.18,
transparent: true,
opacity: 1.0,
blending: THREE.AdditiveBlending,
depthWrite: false,
sizeAttenuation: true,
});
const points = new THREE.Points(geo, mat);
scene.add(points);
flow.burstPoints = points;
flow.burstGeo = geo;
flow.burstMat = mat;
flow.burstPositions = positions;
flow.burstVelocities = velocities;
}
function _updateBurst(flow, delta) {
if (!flow.burstPositions) return;
const pos = flow.burstPositions;
const vel = flow.burstVelocities;
const count = pos.length / 3;
for (let i = 0; i < count; i++) {
const i3 = i * 3;
pos[i3] += vel[i3] * delta;
pos[i3 + 1] += vel[i3 + 1] * delta;
pos[i3 + 2] += vel[i3 + 2] * delta;
// Gravity
vel[i3 + 1] -= 6 * delta;
}
flow.burstGeo.attributes.position.needsUpdate = true;
// Fade out
const t = flow.elapsed / flow.burstDuration;
flow.burstMat.opacity = Math.max(0, 1 - t);
}
function _cleanupFlow(flow) {
if (flow.points) {
scene?.remove(flow.points);
flow.geo?.dispose();
flow.mat?.dispose();
}
if (flow.burstPoints) {
scene?.remove(flow.burstPoints);
flow.burstGeo?.dispose();
flow.burstMat?.dispose();
}
}