feat: Build Manager, Crucible ops deck, branding, and portable Cloudflare tunnel
Add Build Manager with pin-to-dropper, Crucible multi-node terminal with SSH probe/wake, Command Deck chart balance and pretty stats, AetherForge logo and sacred geometry UI, Field Guide refresh, and LAUNCH.bat Cloudflare MSI + token service install flow.
This commit is contained in:
136
server/web/src/components/Visual/CursorFire.tsx
Normal file
136
server/web/src/components/Visual/CursorFire.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
interface Particle {
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
life: number; // 1 → 0
|
||||
size: number;
|
||||
decay: number;
|
||||
}
|
||||
|
||||
export default function CursorFire() {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const particles = useRef<Particle[]>([]);
|
||||
const mouse = useRef({ x: -9999, y: -9999, moved: false });
|
||||
const rafRef = useRef<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const resize = () => {
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
};
|
||||
resize();
|
||||
window.addEventListener('resize', resize);
|
||||
|
||||
const onMove = (e: MouseEvent) => {
|
||||
mouse.current = { x: e.clientX, y: e.clientY, moved: true };
|
||||
};
|
||||
window.addEventListener('mousemove', onMove);
|
||||
|
||||
const emit = () => {
|
||||
const { x, y } = mouse.current;
|
||||
// Emit 6 particles per frame at cursor
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const spread = 8;
|
||||
particles.current.push({
|
||||
x: x + (Math.random() - 0.5) * spread,
|
||||
y: y + (Math.random() - 0.5) * (spread * 0.5),
|
||||
vx: (Math.random() - 0.5) * 1.2,
|
||||
vy: -(Math.random() * 2.8 + 1.8),
|
||||
life: 1,
|
||||
size: Math.random() * 14 + 7,
|
||||
decay: Math.random() * 0.022 + 0.016,
|
||||
});
|
||||
}
|
||||
// Cap particle count for perf
|
||||
if (particles.current.length > 400) {
|
||||
particles.current = particles.current.slice(-400);
|
||||
}
|
||||
};
|
||||
|
||||
const draw = () => {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Additive blending makes overlapping particles look white-hot
|
||||
ctx.globalCompositeOperation = 'screen';
|
||||
|
||||
emit();
|
||||
|
||||
const alive: Particle[] = [];
|
||||
for (const p of particles.current) {
|
||||
// Turbulent horizontal drift
|
||||
p.vx += (Math.random() - 0.5) * 0.35;
|
||||
// Slight drag on vx
|
||||
p.vx *= 0.97;
|
||||
// Upward acceleration (heat rises)
|
||||
p.vy -= 0.04;
|
||||
|
||||
p.x += p.vx;
|
||||
p.y += p.vy;
|
||||
p.life -= p.decay;
|
||||
// Particles shrink as they cool
|
||||
p.size *= 0.982;
|
||||
|
||||
if (p.life <= 0 || p.size < 1) continue;
|
||||
alive.push(p);
|
||||
|
||||
const l = p.life;
|
||||
// Color temperature: white-yellow core → orange → red → dark red
|
||||
let r: number, g: number, b: number;
|
||||
if (l > 0.75) {
|
||||
// White-hot
|
||||
r = 255; g = 255; b = Math.round((l - 0.75) / 0.25 * 220);
|
||||
} else if (l > 0.5) {
|
||||
// Yellow-orange
|
||||
r = 255; g = Math.round(100 + (l - 0.5) / 0.25 * 155); b = 0;
|
||||
} else if (l > 0.25) {
|
||||
// Orange-red
|
||||
r = 255; g = Math.round((l - 0.25) / 0.25 * 100); b = 0;
|
||||
} else {
|
||||
// Deep red, fading
|
||||
r = Math.round(160 + l / 0.25 * 95); g = 0; b = 0;
|
||||
}
|
||||
|
||||
const grad = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.size);
|
||||
grad.addColorStop(0, `rgba(${r},${g},${b},${l})`);
|
||||
grad.addColorStop(0.4, `rgba(${r},${Math.round(g * 0.6)},0,${l * 0.6})`);
|
||||
grad.addColorStop(1, `rgba(0,0,0,0)`);
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
particles.current = alive;
|
||||
rafRef.current = requestAnimationFrame(draw);
|
||||
};
|
||||
|
||||
rafRef.current = requestAnimationFrame(draw);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
window.removeEventListener('resize', resize);
|
||||
window.removeEventListener('mousemove', onMove);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
pointerEvents: 'none',
|
||||
zIndex: 9998,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user