This commit is contained in:
parent
a0e6d40679
commit
954755fbc3
19 changed files with 356 additions and 321 deletions
|
|
@ -200,7 +200,6 @@ button:disabled {
|
|||
|
||||
.nav {
|
||||
background: #000;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.nav-container {
|
||||
|
|
|
|||
|
|
@ -5,23 +5,25 @@
|
|||
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;
|
||||
let branches = []; // Active growing branch tips
|
||||
let crystalPoints = []; // All drawn points for shatter effect
|
||||
let shatterParticles = [];
|
||||
|
||||
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
|
||||
seedCount: 5, // Number of initial seed points
|
||||
branchSpeed: 2, // Pixels per frame
|
||||
maxBranches: 800, // Max simultaneous branch tips
|
||||
branchChance: 0.03, // Chance to spawn new branch per frame
|
||||
turnAngle: 0.3, // Max random turn per frame (radians)
|
||||
hueShiftSpeed: 0.2, // Color cycling speed
|
||||
maxPoints: 15000, // Max crystal points before shatter
|
||||
shatterDuration: 2500, // Milliseconds for shatter effect
|
||||
dissolveDuration: 2000, // Milliseconds for dissolve effect
|
||||
lineWidth: 2
|
||||
};
|
||||
|
||||
function initCanvas() {
|
||||
|
|
@ -31,47 +33,40 @@
|
|||
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 = [];
|
||||
branches = [];
|
||||
crystalPoints = [];
|
||||
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;
|
||||
// Create seed points at random locations
|
||||
for (let i = 0; i < CONFIG.seedCount; i++) {
|
||||
const x = Math.random() * width;
|
||||
const y = Math.random() * height;
|
||||
|
||||
// Each seed spawns multiple branches in different directions
|
||||
const branchCount = 3 + Math.floor(Math.random() * 4);
|
||||
for (let j = 0; j < branchCount; j++) {
|
||||
const angle = (Math.PI * 2 * j) / branchCount + Math.random() * 0.5;
|
||||
branches.push({
|
||||
x,
|
||||
y,
|
||||
angle,
|
||||
hue: Math.random() * 360,
|
||||
age: 0,
|
||||
generation: 0
|
||||
});
|
||||
}
|
||||
}
|
||||
return { x, y, hue: Math.random() * 360 };
|
||||
}
|
||||
|
||||
function update() {
|
||||
|
|
@ -87,51 +82,90 @@
|
|||
function updateGrowing() {
|
||||
hue = (hue + CONFIG.hueShiftSpeed) % 360;
|
||||
|
||||
for (let i = particles.length - 1; i >= 0; i--) {
|
||||
const p = particles[i];
|
||||
const newBranches = [];
|
||||
|
||||
// Random walk toward center with bias
|
||||
const dx = centerX - p.x;
|
||||
const dy = centerY - p.y;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
for (let i = branches.length - 1; i >= 0; i--) {
|
||||
const branch = branches[i];
|
||||
|
||||
// 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;
|
||||
// Random walk - slight angle change
|
||||
branch.angle += (Math.random() - 0.5) * CONFIG.turnAngle;
|
||||
|
||||
// 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
|
||||
// Calculate new position
|
||||
const newX = branch.x + Math.cos(branch.angle) * CONFIG.branchSpeed;
|
||||
const newY = branch.y + Math.sin(branch.angle) * CONFIG.branchSpeed;
|
||||
|
||||
// Check bounds - kill branch if out of screen
|
||||
if (newX < 0 || newX > width || newY < 0 || newY > height) {
|
||||
branches.splice(i, 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Respawn if out of bounds
|
||||
if (p.x < 0 || p.x > width || p.y < 0 || p.y > height) {
|
||||
particles[i] = createParticle();
|
||||
// Store point for shatter effect
|
||||
crystalPoints.push({
|
||||
x: newX,
|
||||
y: newY,
|
||||
hue: (branch.hue + branch.age * 0.5) % 360
|
||||
});
|
||||
|
||||
// Draw the branch segment
|
||||
const h = (branch.hue + branch.age * 0.5) % 360;
|
||||
ctx.strokeStyle = `hsla(${h}, 80%, 60%, 0.9)`;
|
||||
ctx.lineWidth = CONFIG.lineWidth;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(branch.x, branch.y);
|
||||
ctx.lineTo(newX, newY);
|
||||
ctx.stroke();
|
||||
|
||||
// Update branch position
|
||||
branch.x = newX;
|
||||
branch.y = newY;
|
||||
branch.age++;
|
||||
|
||||
// Chance to spawn a new branch (fork)
|
||||
if (Math.random() < CONFIG.branchChance && branches.length + newBranches.length < CONFIG.maxBranches) {
|
||||
const forkAngle = branch.angle + (Math.random() > 0.5 ? 1 : -1) * (0.3 + Math.random() * 0.7);
|
||||
newBranches.push({
|
||||
x: newX,
|
||||
y: newY,
|
||||
angle: forkAngle,
|
||||
hue: (branch.hue + 20 + Math.random() * 40) % 360,
|
||||
age: 0,
|
||||
generation: branch.generation + 1
|
||||
});
|
||||
}
|
||||
|
||||
// Chance to die (increases with age and generation)
|
||||
const deathChance = 0.001 + branch.age * 0.0001 + branch.generation * 0.002;
|
||||
if (Math.random() < deathChance) {
|
||||
branches.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if crystal is full
|
||||
if (crystal.size > CONFIG.maxCrystalSize) {
|
||||
// Add new branches
|
||||
branches.push(...newBranches);
|
||||
|
||||
// Check if we should shatter (too many points or no more branches)
|
||||
if (crystalPoints.length > CONFIG.maxPoints || (branches.length === 0 && crystalPoints.length > 100)) {
|
||||
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;
|
||||
}
|
||||
// Spawn new seeds occasionally if branches are dying off
|
||||
if (branches.length < 10 && crystalPoints.length < CONFIG.maxPoints * 0.5) {
|
||||
const x = Math.random() * width;
|
||||
const y = Math.random() * height;
|
||||
const branchCount = 2 + Math.floor(Math.random() * 3);
|
||||
for (let j = 0; j < branchCount; j++) {
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
branches.push({
|
||||
x,
|
||||
y,
|
||||
angle,
|
||||
hue: Math.random() * 360,
|
||||
age: 0,
|
||||
generation: 0
|
||||
});
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function startShatter() {
|
||||
|
|
@ -140,15 +174,18 @@
|
|||
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);
|
||||
// Sample points to avoid too many particles
|
||||
const step = Math.max(1, Math.floor(crystalPoints.length / 3000));
|
||||
for (let i = 0; i < crystalPoints.length; i += step) {
|
||||
const p = crystalPoints[i];
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const speed = 1 + Math.random() * 4;
|
||||
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,
|
||||
x: p.x,
|
||||
y: p.y,
|
||||
vx: Math.cos(angle) * speed,
|
||||
vy: Math.sin(angle) * speed,
|
||||
hue: p.hue,
|
||||
alpha: 1,
|
||||
size: 2 + Math.random() * 2
|
||||
});
|
||||
|
|
@ -158,10 +195,22 @@
|
|||
function updateShattering() {
|
||||
const elapsed = performance.now() - phaseStartTime;
|
||||
|
||||
// Clear with slight fade for trail effect
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.1)';
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
for (const p of shatterParticles) {
|
||||
p.x += p.vx;
|
||||
p.y += p.vy;
|
||||
p.vy += 0.05; // Slight gravity
|
||||
p.alpha = Math.max(0, 1 - (elapsed / CONFIG.shatterDuration));
|
||||
|
||||
if (p.alpha > 0) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
if (elapsed >= CONFIG.shatterDuration) {
|
||||
|
|
@ -173,8 +222,20 @@
|
|||
function updateDissolving() {
|
||||
const elapsed = performance.now() - phaseStartTime;
|
||||
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.08)';
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
for (const p of shatterParticles) {
|
||||
p.x += p.vx * 0.5;
|
||||
p.y += p.vy * 0.5;
|
||||
p.alpha = Math.max(0, 1 - (elapsed / CONFIG.dissolveDuration));
|
||||
|
||||
if (p.alpha > 0.01) {
|
||||
ctx.fillStyle = `hsla(${p.hue}, 80%, 60%, ${p.alpha * 0.5})`;
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, p.size * 0.8, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
if (elapsed >= CONFIG.dissolveDuration) {
|
||||
|
|
@ -182,51 +243,9 @@
|
|||
}
|
||||
}
|
||||
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -97,7 +97,6 @@
|
|||
|
||||
.nav {
|
||||
background: #000;
|
||||
padding: var(--nav-padding-y) 0;
|
||||
}
|
||||
|
||||
.nav-container {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue