268 lines
7.8 KiB
Svelte
268 lines
7.8 KiB
Svelte
<script>
|
|
import { onMount, onDestroy } from 'svelte';
|
|
import { browser } from '$app/environment';
|
|
|
|
let canvas;
|
|
let ctx;
|
|
let animationId;
|
|
let particles = [];
|
|
let crystal = new Set(); // Stored as "x,y" strings for O(1) lookup
|
|
let width, height;
|
|
let centerX, centerY;
|
|
let hue = 0;
|
|
let phase = 'growing'; // 'growing' | 'shattering' | 'dissolving'
|
|
let shatterParticles = [];
|
|
let phaseStartTime = 0;
|
|
|
|
const CONFIG = {
|
|
particleCount: 500, // Active random walkers
|
|
particleSpeed: 3, // Movement speed
|
|
stickDistance: 2, // Distance to attach to crystal
|
|
maxCrystalSize: 8000, // Max crystal points before shatter
|
|
hueShiftSpeed: 0.3, // Color cycling speed
|
|
shatterDuration: 2000, // Milliseconds for shatter effect
|
|
dissolveDuration: 2000 // Milliseconds for dissolve effect
|
|
};
|
|
|
|
function initCanvas() {
|
|
if (!canvas) return;
|
|
width = window.innerWidth;
|
|
height = window.innerHeight;
|
|
canvas.width = width;
|
|
canvas.height = height;
|
|
ctx = canvas.getContext('2d');
|
|
centerX = Math.floor(width / 2);
|
|
centerY = Math.floor(height / 2);
|
|
}
|
|
|
|
function initCrystal() {
|
|
crystal.clear();
|
|
particles = [];
|
|
shatterParticles = [];
|
|
phase = 'growing';
|
|
phaseStartTime = performance.now();
|
|
|
|
// Seed crystal at center with a small cluster
|
|
for (let dx = -2; dx <= 2; dx++) {
|
|
for (let dy = -2; dy <= 2; dy++) {
|
|
crystal.add(`${centerX + dx},${centerY + dy}`);
|
|
}
|
|
}
|
|
|
|
// Initialize random walkers from edges
|
|
for (let i = 0; i < CONFIG.particleCount; i++) {
|
|
particles.push(createParticle());
|
|
}
|
|
|
|
// Clear canvas
|
|
if (ctx) {
|
|
ctx.fillStyle = 'black';
|
|
ctx.fillRect(0, 0, width, height);
|
|
}
|
|
}
|
|
|
|
function createParticle() {
|
|
// Spawn from random edge
|
|
const edge = Math.floor(Math.random() * 4);
|
|
let x, y;
|
|
switch (edge) {
|
|
case 0: x = Math.random() * width; y = 0; break;
|
|
case 1: x = width; y = Math.random() * height; break;
|
|
case 2: x = Math.random() * width; y = height; break;
|
|
case 3: x = 0; y = Math.random() * height; break;
|
|
}
|
|
return { x, y, hue: Math.random() * 360 };
|
|
}
|
|
|
|
function update() {
|
|
if (phase === 'growing') {
|
|
updateGrowing();
|
|
} else if (phase === 'shattering') {
|
|
updateShattering();
|
|
} else if (phase === 'dissolving') {
|
|
updateDissolving();
|
|
}
|
|
}
|
|
|
|
function updateGrowing() {
|
|
hue = (hue + CONFIG.hueShiftSpeed) % 360;
|
|
|
|
for (let i = particles.length - 1; i >= 0; i--) {
|
|
const p = particles[i];
|
|
|
|
// Random walk toward center with bias
|
|
const dx = centerX - p.x;
|
|
const dy = centerY - p.y;
|
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
|
|
// Biased random walk (DLA with drift)
|
|
p.x += (Math.random() - 0.5) * CONFIG.particleSpeed * 2 + (dx / dist) * 0.5;
|
|
p.y += (Math.random() - 0.5) * CONFIG.particleSpeed * 2 + (dy / dist) * 0.5;
|
|
|
|
// Check for crystallization
|
|
if (shouldCrystallize(p)) {
|
|
const px = Math.round(p.x);
|
|
const py = Math.round(p.y);
|
|
crystal.add(`${px},${py}`);
|
|
particles[i] = createParticle(); // Respawn
|
|
}
|
|
|
|
// Respawn if out of bounds
|
|
if (p.x < 0 || p.x > width || p.y < 0 || p.y > height) {
|
|
particles[i] = createParticle();
|
|
}
|
|
}
|
|
|
|
// Check if crystal is full
|
|
if (crystal.size > CONFIG.maxCrystalSize) {
|
|
startShatter();
|
|
}
|
|
}
|
|
|
|
function shouldCrystallize(p) {
|
|
const px = Math.round(p.x);
|
|
const py = Math.round(p.y);
|
|
|
|
// Check neighbors
|
|
for (let dx = -CONFIG.stickDistance; dx <= CONFIG.stickDistance; dx++) {
|
|
for (let dy = -CONFIG.stickDistance; dy <= CONFIG.stickDistance; dy++) {
|
|
if (crystal.has(`${px + dx},${py + dy}`)) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function startShatter() {
|
|
phase = 'shattering';
|
|
phaseStartTime = performance.now();
|
|
shatterParticles = [];
|
|
|
|
// Convert crystal points to shatter particles
|
|
for (const key of crystal) {
|
|
const [x, y] = key.split(',').map(Number);
|
|
const angle = Math.atan2(y - centerY, x - centerX);
|
|
const dist = Math.sqrt((x - centerX) ** 2 + (y - centerY) ** 2);
|
|
shatterParticles.push({
|
|
x, y,
|
|
vx: Math.cos(angle) * (2 + Math.random() * 4),
|
|
vy: Math.sin(angle) * (2 + Math.random() * 4),
|
|
hue: (hue + dist * 0.3) % 360,
|
|
alpha: 1,
|
|
size: 2 + Math.random() * 2
|
|
});
|
|
}
|
|
}
|
|
|
|
function updateShattering() {
|
|
const elapsed = performance.now() - phaseStartTime;
|
|
|
|
for (const p of shatterParticles) {
|
|
p.x += p.vx;
|
|
p.y += p.vy;
|
|
p.alpha = Math.max(0, 1 - (elapsed / CONFIG.shatterDuration));
|
|
}
|
|
|
|
if (elapsed >= CONFIG.shatterDuration) {
|
|
phase = 'dissolving';
|
|
phaseStartTime = performance.now();
|
|
}
|
|
}
|
|
|
|
function updateDissolving() {
|
|
const elapsed = performance.now() - phaseStartTime;
|
|
|
|
for (const p of shatterParticles) {
|
|
p.alpha = Math.max(0, 1 - (elapsed / CONFIG.dissolveDuration));
|
|
}
|
|
|
|
if (elapsed >= CONFIG.dissolveDuration) {
|
|
initCrystal(); // Regrow
|
|
}
|
|
}
|
|
|
|
function draw() {
|
|
// Semi-transparent overlay for trail effect
|
|
ctx.fillStyle = 'rgba(0, 0, 0, 0.15)';
|
|
ctx.fillRect(0, 0, width, height);
|
|
|
|
if (phase === 'growing') {
|
|
drawCrystal();
|
|
drawParticles();
|
|
} else {
|
|
drawShatterParticles();
|
|
}
|
|
}
|
|
|
|
function drawCrystal() {
|
|
for (const key of crystal) {
|
|
const [x, y] = key.split(',').map(Number);
|
|
const dist = Math.sqrt((x - centerX) ** 2 + (y - centerY) ** 2);
|
|
const h = (hue + dist * 0.3) % 360;
|
|
|
|
ctx.fillStyle = `hsla(${h}, 80%, 60%, 0.9)`;
|
|
ctx.fillRect(x - 1, y - 1, 3, 3);
|
|
}
|
|
}
|
|
|
|
function drawParticles() {
|
|
ctx.fillStyle = 'rgba(255, 255, 255, 0.3)';
|
|
for (const p of particles) {
|
|
ctx.fillRect(p.x, p.y, 2, 2);
|
|
}
|
|
}
|
|
|
|
function drawShatterParticles() {
|
|
for (const p of shatterParticles) {
|
|
if (p.alpha <= 0) continue;
|
|
ctx.fillStyle = `hsla(${p.hue}, 80%, 60%, ${p.alpha})`;
|
|
ctx.beginPath();
|
|
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
}
|
|
|
|
function startAnimation() {
|
|
function loop() {
|
|
update();
|
|
draw();
|
|
animationId = requestAnimationFrame(loop);
|
|
}
|
|
loop();
|
|
}
|
|
|
|
function handleResize() {
|
|
initCanvas();
|
|
initCrystal();
|
|
}
|
|
|
|
onMount(() => {
|
|
if (!browser) return;
|
|
|
|
initCanvas();
|
|
initCrystal();
|
|
startAnimation();
|
|
|
|
window.addEventListener('resize', handleResize);
|
|
});
|
|
|
|
onDestroy(() => {
|
|
if (animationId) {
|
|
cancelAnimationFrame(animationId);
|
|
}
|
|
if (browser) {
|
|
window.removeEventListener('resize', handleResize);
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<canvas bind:this={canvas} class="fractal-canvas"></canvas>
|
|
|
|
<style>
|
|
.fractal-canvas {
|
|
position: absolute;
|
|
inset: 0;
|
|
pointer-events: none;
|
|
}
|
|
</style>
|