262 lines
7.0 KiB
JavaScript
262 lines
7.0 KiB
JavaScript
/**
|
||
* 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.5–2.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();
|
||
}
|
||
}
|