Replace cursor fire with hacker bit-trail particles on Command Deck.
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Swaps orange flame blobs for rising 0/1 and hex glyphs in green/cyan with glow, capped rAF particles, and prefers-reduced-motion support.
This commit is contained in:
13
server/web/src/components/Visual/CursorFire.css
Normal file
13
server/web/src/components/Visual/CursorFire.css
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
.cursor-hacker-fx {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 9998;
|
||||||
|
mix-blend-mode: screen;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.cursor-hacker-fx {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,114 +1,143 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
|
import './CursorFire.css';
|
||||||
|
|
||||||
|
const BIT_CHARS = '01';
|
||||||
|
const HEX_CHARS = '0123456789ABCDEF';
|
||||||
|
const MAX_PARTICLES = 280;
|
||||||
|
const EMIT_PER_FRAME = 5;
|
||||||
|
const EMIT_WINDOW_MS = 90;
|
||||||
|
const FONT_STACK = '"JetBrains Mono", "Fira Code", "Cascadia Code", monospace';
|
||||||
|
|
||||||
interface Particle {
|
interface Particle {
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
vx: number;
|
vx: number;
|
||||||
vy: number;
|
vy: number;
|
||||||
life: number; // 1 → 0
|
life: number;
|
||||||
size: number;
|
|
||||||
decay: number;
|
decay: number;
|
||||||
|
char: string;
|
||||||
|
fontSize: number;
|
||||||
|
tint: 'cyan' | 'green';
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickChar(): string {
|
||||||
|
if (Math.random() < 0.72) return BIT_CHARS[Math.floor(Math.random() * 2)];
|
||||||
|
return HEX_CHARS[Math.floor(Math.random() * HEX_CHARS.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function colorForLife(life: number, tint: Particle['tint']): string {
|
||||||
|
const a = Math.min(1, life * 0.92);
|
||||||
|
if (tint === 'cyan') {
|
||||||
|
if (life > 0.55) return `rgba(0, 232, 245, ${a})`;
|
||||||
|
return `rgba(0, 210, 170, ${a * 0.8})`;
|
||||||
|
}
|
||||||
|
if (life > 0.55) return `rgba(0, 255, 136, ${a})`;
|
||||||
|
return `rgba(0, 190, 96, ${a * 0.75})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function glowForTint(tint: Particle['tint']): string {
|
||||||
|
return tint === 'cyan' ? '#00e8f5' : '#00ff88';
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CursorFire() {
|
export default function CursorFire() {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const particles = useRef<Particle[]>([]);
|
const particles = useRef<Particle[]>([]);
|
||||||
const mouse = useRef({ x: -9999, y: -9999, moved: false });
|
const mouse = useRef({ x: -9999, y: -9999 });
|
||||||
const rafRef = useRef<number>(0);
|
const lastMoveRef = useRef(0);
|
||||||
|
const rafRef = useRef(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||||
|
if (motionQuery.matches) return;
|
||||||
|
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
|
|
||||||
|
let running = true;
|
||||||
|
|
||||||
const resize = () => {
|
const resize = () => {
|
||||||
canvas.width = window.innerWidth;
|
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||||
canvas.height = window.innerHeight;
|
const w = window.innerWidth;
|
||||||
|
const h = window.innerHeight;
|
||||||
|
canvas.width = Math.floor(w * dpr);
|
||||||
|
canvas.height = Math.floor(h * dpr);
|
||||||
|
canvas.style.width = `${w}px`;
|
||||||
|
canvas.style.height = `${h}px`;
|
||||||
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
};
|
};
|
||||||
resize();
|
resize();
|
||||||
window.addEventListener('resize', resize);
|
window.addEventListener('resize', resize);
|
||||||
|
|
||||||
const onMove = (e: MouseEvent) => {
|
const onMove = (e: MouseEvent) => {
|
||||||
mouse.current = { x: e.clientX, y: e.clientY, moved: true };
|
mouse.current = { x: e.clientX, y: e.clientY };
|
||||||
|
lastMoveRef.current = performance.now();
|
||||||
};
|
};
|
||||||
window.addEventListener('mousemove', onMove);
|
window.addEventListener('mousemove', onMove, { passive: true });
|
||||||
|
|
||||||
|
const stopOnReducedMotion = () => {
|
||||||
|
if (!motionQuery.matches) return;
|
||||||
|
running = false;
|
||||||
|
cancelAnimationFrame(rafRef.current);
|
||||||
|
particles.current = [];
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
};
|
||||||
|
motionQuery.addEventListener('change', stopOnReducedMotion);
|
||||||
|
|
||||||
const emit = () => {
|
const emit = () => {
|
||||||
|
if (performance.now() - lastMoveRef.current > EMIT_WINDOW_MS) return;
|
||||||
const { x, y } = mouse.current;
|
const { x, y } = mouse.current;
|
||||||
// Emit 6 particles per frame at cursor
|
|
||||||
for (let i = 0; i < 6; i++) {
|
for (let i = 0; i < EMIT_PER_FRAME; i++) {
|
||||||
const spread = 8;
|
const spread = 10;
|
||||||
particles.current.push({
|
particles.current.push({
|
||||||
x: x + (Math.random() - 0.5) * spread,
|
x: x + (Math.random() - 0.5) * spread,
|
||||||
y: y + (Math.random() - 0.5) * (spread * 0.5),
|
y: y + (Math.random() - 0.5) * (spread * 0.4),
|
||||||
vx: (Math.random() - 0.5) * 1.2,
|
vx: (Math.random() - 0.5) * 1.4,
|
||||||
vy: -(Math.random() * 2.8 + 1.8),
|
vy: -(Math.random() * 2.6 + 1.6),
|
||||||
life: 1,
|
life: 1,
|
||||||
size: Math.random() * 14 + 7,
|
decay: Math.random() * 0.024 + 0.018,
|
||||||
decay: Math.random() * 0.022 + 0.016,
|
char: pickChar(),
|
||||||
|
fontSize: Math.random() * 10 + 9,
|
||||||
|
tint: Math.random() < 0.55 ? 'cyan' : 'green',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Cap particle count for perf
|
|
||||||
if (particles.current.length > 400) {
|
if (particles.current.length > MAX_PARTICLES) {
|
||||||
particles.current = particles.current.slice(-400);
|
particles.current = particles.current.slice(-MAX_PARTICLES);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const draw = () => {
|
const draw = () => {
|
||||||
|
if (!running) return;
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
// Additive blending makes overlapping particles look white-hot
|
|
||||||
ctx.globalCompositeOperation = 'screen';
|
|
||||||
|
|
||||||
emit();
|
emit();
|
||||||
|
|
||||||
const alive: Particle[] = [];
|
const alive: Particle[] = [];
|
||||||
for (const p of particles.current) {
|
for (const p of particles.current) {
|
||||||
// Turbulent horizontal drift
|
p.vx += (Math.random() - 0.5) * 0.28;
|
||||||
p.vx += (Math.random() - 0.5) * 0.35;
|
p.vx *= 0.96;
|
||||||
// Slight drag on vx
|
p.vy -= 0.035;
|
||||||
p.vx *= 0.97;
|
|
||||||
// Upward acceleration (heat rises)
|
|
||||||
p.vy -= 0.04;
|
|
||||||
|
|
||||||
p.x += p.vx;
|
p.x += p.vx;
|
||||||
p.y += p.vy;
|
p.y += p.vy;
|
||||||
p.life -= p.decay;
|
p.life -= p.decay;
|
||||||
// Particles shrink as they cool
|
p.fontSize *= 0.985;
|
||||||
p.size *= 0.982;
|
|
||||||
|
|
||||||
if (p.life <= 0 || p.size < 1) continue;
|
if (p.life <= 0 || p.fontSize < 6) continue;
|
||||||
alive.push(p);
|
alive.push(p);
|
||||||
|
|
||||||
const l = p.life;
|
const glow = 6 + p.life * 10;
|
||||||
// Color temperature: white-yellow core → orange → red → dark red
|
ctx.shadowBlur = glow;
|
||||||
let r: number, g: number, b: number;
|
ctx.shadowColor = glowForTint(p.tint);
|
||||||
if (l > 0.75) {
|
ctx.font = `600 ${p.fontSize}px ${FONT_STACK}`;
|
||||||
// White-hot
|
ctx.textAlign = 'center';
|
||||||
r = 255; g = 255; b = Math.round((l - 0.75) / 0.25 * 220);
|
ctx.textBaseline = 'middle';
|
||||||
} else if (l > 0.5) {
|
ctx.fillStyle = colorForLife(p.life, p.tint);
|
||||||
// Yellow-orange
|
ctx.fillText(p.char, p.x, p.y);
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ctx.shadowBlur = 0;
|
||||||
particles.current = alive;
|
particles.current = alive;
|
||||||
rafRef.current = requestAnimationFrame(draw);
|
rafRef.current = requestAnimationFrame(draw);
|
||||||
};
|
};
|
||||||
@@ -116,22 +145,13 @@ export default function CursorFire() {
|
|||||||
rafRef.current = requestAnimationFrame(draw);
|
rafRef.current = requestAnimationFrame(draw);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
running = false;
|
||||||
cancelAnimationFrame(rafRef.current);
|
cancelAnimationFrame(rafRef.current);
|
||||||
window.removeEventListener('resize', resize);
|
window.removeEventListener('resize', resize);
|
||||||
window.removeEventListener('mousemove', onMove);
|
window.removeEventListener('mousemove', onMove);
|
||||||
|
motionQuery.removeEventListener('change', stopOnReducedMotion);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return <canvas ref={canvasRef} className="cursor-hacker-fx" aria-hidden="true" />;
|
||||||
<canvas
|
|
||||||
ref={canvasRef}
|
|
||||||
className="cursor-fire-fx"
|
|
||||||
style={{
|
|
||||||
position: 'fixed',
|
|
||||||
inset: 0,
|
|
||||||
pointerEvents: 'none',
|
|
||||||
zIndex: 9998,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -907,9 +907,29 @@ describe('AmbientBackground', () => {
|
|||||||
describe('CursorFire', () => {
|
describe('CursorFire', () => {
|
||||||
afterEach(() => cleanup());
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
it('mounts fullscreen canvas', () => {
|
it('mounts fullscreen hacker-trail canvas', () => {
|
||||||
const { container } = render(<CursorFire />);
|
const { container } = render(<CursorFire />);
|
||||||
expect(container.querySelector('canvas')).toBeTruthy();
|
const canvas = container.querySelector('canvas.cursor-hacker-fx');
|
||||||
|
expect(canvas).toBeTruthy();
|
||||||
|
expect(canvas).toHaveAttribute('aria-hidden', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips animation loop when prefers-reduced-motion', () => {
|
||||||
|
const rafSpy = vi.spyOn(window, 'requestAnimationFrame');
|
||||||
|
const matchMediaSpy = vi.spyOn(window, 'matchMedia').mockReturnValue({
|
||||||
|
matches: true,
|
||||||
|
media: '(prefers-reduced-motion: reduce)',
|
||||||
|
onchange: null,
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
});
|
||||||
|
render(<CursorFire />);
|
||||||
|
expect(rafSpy).not.toHaveBeenCalled();
|
||||||
|
matchMediaSpy.mockRestore();
|
||||||
|
rafSpy.mockRestore();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -980,7 +1000,7 @@ describe('Layout', () => {
|
|||||||
);
|
);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(deck.querySelector('.matrix-rain-canvas')).toBeTruthy();
|
expect(deck.querySelector('.matrix-rain-canvas')).toBeTruthy();
|
||||||
expect(deck.querySelector('.cursor-fire-fx')).toBeTruthy();
|
expect(deck.querySelector('.cursor-hacker-fx')).toBeTruthy();
|
||||||
});
|
});
|
||||||
cleanup();
|
cleanup();
|
||||||
|
|
||||||
@@ -995,6 +1015,6 @@ describe('Layout', () => {
|
|||||||
expect(screen.getByText('crucible')).toBeInTheDocument();
|
expect(screen.getByText('crucible')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
expect(crucible.querySelector('.matrix-rain-canvas')).toBeNull();
|
expect(crucible.querySelector('.matrix-rain-canvas')).toBeNull();
|
||||||
expect(crucible.querySelector('.cursor-fire-fx')).toBeNull();
|
expect(crucible.querySelector('.cursor-hacker-fx')).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ body {
|
|||||||
touch-action: manipulation;
|
touch-action: manipulation;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cursor-fire-fx {
|
.cursor-hacker-fx {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user