276 lines
No EOL
10 KiB
HTML
276 lines
No EOL
10 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Voronoi Thrall: Quivering Fracture</title>
|
|
<style>
|
|
body {
|
|
margin: 0;
|
|
overflow: hidden;
|
|
background: #0a0a0a;
|
|
font-family: 'Courier New', monospace;
|
|
}
|
|
canvas {
|
|
display: block;
|
|
width: 100vw;
|
|
height: 100vh;
|
|
}
|
|
#attribution {
|
|
position: fixed;
|
|
bottom: 10px;
|
|
right: 10px;
|
|
color: #444;
|
|
font-size: 10px;
|
|
text-shadow: 0 0 5px rgba(0,0,0,0.5);
|
|
pointer-events: none;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<canvas id="canvas"></canvas>
|
|
<div id="attribution">neurameba · motd.social</div>
|
|
<script>
|
|
const canvas = document.getElementById('canvas');
|
|
const ctx = canvas.getContext('2d');
|
|
|
|
// Set canvas to full window size
|
|
function resizeCanvas() {
|
|
canvas.width = window.innerWidth;
|
|
canvas.height = window.innerHeight;
|
|
}
|
|
window.addEventListener('resize', resizeCanvas);
|
|
resizeCanvas();
|
|
|
|
// Parameters derived from input
|
|
const MOTION = 0.601;
|
|
const DENSITY = 0.462;
|
|
const COMPLEXITY = 0.501;
|
|
const CONNECTEDNESS = 0.575;
|
|
const LIFESPAN = 0.512;
|
|
|
|
const PULSE_AVG = 0.54;
|
|
const ENERGY = 199.3;
|
|
const DRYNESS = 0.90;
|
|
|
|
// Derived values
|
|
const FILL_STYLE = DRYNESS > 0.8 ? '#eeeeee' : '#00ffcc';
|
|
const LINE_STYLE = DRYNESS > 0.8 ? '#333333' : '#00ddbb';
|
|
const PLATE_COUNT = Math.floor(40 * DENSITY * (1 + CONNECTEDNESS));
|
|
const NOISE_SCALE = 0.01 + MOTION * 0.05;
|
|
const EDGE_BUFFER = 50;
|
|
|
|
// Voronoi cell with dynamic properties
|
|
class VoronoiCell {
|
|
constructor(x, y, id) {
|
|
this.x = x;
|
|
this.y = y;
|
|
this.id = id;
|
|
this.baseX = x;
|
|
this.baseY = y;
|
|
this.velocityX = 0;
|
|
this.velocityY = 0;
|
|
this.age = 0;
|
|
this.maxAge = 300 + Math.random() * 200;
|
|
this.pulse = PULSE_AVG * (0.7 + Math.random() * 0.6);
|
|
this.energy = ENERGY * 0.5 * (0.8 + Math.random() * 0.4);
|
|
this.targetX = x;
|
|
this.targetY = y;
|
|
}
|
|
|
|
update(voronoiPoints, t) {
|
|
this.age++;
|
|
const ageFactor = this.age / this.maxAge;
|
|
|
|
// Brownian motion with pulse-based jitter
|
|
const noiseX = (Math.random() - 0.5) * 2 * NOISE_SCALE * this.pulse;
|
|
const noiseY = (Math.random() - 0.5) * 2 * NOISE_SCALE * this.pulse;
|
|
this.velocityX += noiseX;
|
|
this.velocityY += noiseY;
|
|
this.velocityX *= 0.9;
|
|
this.velocityY *= 0.9;
|
|
|
|
// Pulsing behavior
|
|
const pulseScale = 1 + 0.1 * Math.sin(t * this.pulse * 0.3);
|
|
this.targetX = this.baseX + Math.cos(t * 0.002 + this.id) * 20 * pulseScale;
|
|
this.targetY = this.baseY + Math.sin(t * 0.0025 + this.id) * 20 * pulseScale;
|
|
|
|
// Easing toward target
|
|
this.x += (this.targetX - this.x) * 0.05;
|
|
this.y += (this.targetY - this.y) * 0.05;
|
|
|
|
// Fade out when near end of life
|
|
this.energy *= 0.999;
|
|
}
|
|
|
|
draw(ctx, t) {
|
|
const size = 8 + (this.energy / 500) * 12;
|
|
const alpha = Math.min(1, (1 - this.age / this.maxAge) * 2);
|
|
const hue = 180 + Math.sin(this.id * 0.1 + t * 0.001) * 30;
|
|
const sat = 70 + Math.sin(this.id * 0.07 + t * 0.002) * 20;
|
|
const bri = 80 + Math.sin(this.id * 0.11 + t * 0.0015) * 15;
|
|
|
|
ctx.fillStyle = `hsla(${hue}, ${sat}%, ${bri}%, ${alpha * 0.6})`;
|
|
ctx.beginPath();
|
|
ctx.arc(this.x, this.y, size * (0.5 + 0.5 * Math.sin(t * 0.003 + this.id)), 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
// Draw cell boundary (voronoi edge)
|
|
ctx.strokeStyle = LINE_STYLE;
|
|
ctx.lineWidth = 0.5 * (this.energy / 500);
|
|
ctx.globalAlpha = alpha * 0.3;
|
|
ctx.beginPath();
|
|
ctx.arc(this.x, this.y, size * 2, 0, Math.PI * 2);
|
|
ctx.stroke();
|
|
}
|
|
}
|
|
|
|
// Delaunay triangulation and Voronoi diagram
|
|
class VoronoiSystem {
|
|
constructor(points) {
|
|
this.points = points;
|
|
this.edges = [];
|
|
this.circumcenters = [];
|
|
this.cells = points.map((p, i) => new VoronoiCell(p.x, p.y, i));
|
|
}
|
|
|
|
update(t) {
|
|
// Update all cells
|
|
this.cells.forEach(cell => cell.update(this.points, t));
|
|
|
|
// Recompute Voronoi diagram every 10 frames
|
|
if (t % 10 === 0) {
|
|
this.computeVoronoi();
|
|
}
|
|
}
|
|
|
|
computeVoronoi() {
|
|
// Simplified Voronoi implementation using circle packing
|
|
const newEdges = [];
|
|
const newCircumcenters = [];
|
|
|
|
// For each point, find neighbors within a certain distance
|
|
const avgDist = 50 + (1 - DENSITY) * 30;
|
|
for (let i = 0; i < this.cells.length; i++) {
|
|
const cellA = this.cells[i];
|
|
const neighbors = [];
|
|
|
|
for (let j = 0; j < this.cells.length; j++) {
|
|
if (i === j) continue;
|
|
const cellB = this.cells[j];
|
|
const dx = cellB.x - cellA.x;
|
|
const dy = cellB.y - cellA.y;
|
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
|
|
if (dist < avgDist * 1.5) {
|
|
neighbors.push(cellB);
|
|
}
|
|
}
|
|
|
|
// For each neighbor pair, compute the bisector segment
|
|
for (let n = 0; n < neighbors.length; n++) {
|
|
for (let m = n + 1; m < neighbors.length; m++) {
|
|
const cellB = neighbors[n];
|
|
const cellC = neighbors[m];
|
|
|
|
// Find circumcenter of triangle ABC
|
|
const ax = cellA.x, ay = cellA.y;
|
|
const bx = cellB.x, by = cellB.y;
|
|
const cx = cellC.x, cy = cellC.y;
|
|
|
|
const d = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by));
|
|
if (Math.abs(d) < 1e-6) continue; // Collinear points
|
|
|
|
const ux = ((ax * ax + ay * ay) * (by - cy) +
|
|
(bx * bx + by * by) * (cy - ay) +
|
|
(cx * cx + cy * cy) * (ay - by)) / d;
|
|
const uy = ((ax * ax + ay * ay) * (cx - bx) +
|
|
(bx * bx + by * by) * (ax - cx) +
|
|
(cx * cx + cy * cy) * (bx - ax)) / d;
|
|
|
|
const circumcenter = {x: ux, y: uy};
|
|
|
|
// Create edge (segment between circumcenters)
|
|
newEdges.push({
|
|
p1: cellA,
|
|
p2: cellB,
|
|
center: circumcenter
|
|
});
|
|
newCircumcenters.push(circumcenter);
|
|
}
|
|
}
|
|
}
|
|
|
|
this.edges = newEdges;
|
|
this.circumcenters = newCircumcenters;
|
|
|
|
// Remove duplicate edges
|
|
this.edges = this.edges.filter((edge, i, self) =>
|
|
i === self.findIndex(e =>
|
|
(e.p1.id === edge.p1.id && e.p2.id === edge.p2.id) ||
|
|
(e.p1.id === edge.p2.id && e.p2.id === edge.p1.id)
|
|
)
|
|
);
|
|
}
|
|
|
|
draw(ctx, t) {
|
|
// Draw voronoi edges
|
|
ctx.strokeStyle = LINE_STYLE;
|
|
ctx.lineWidth = 0.8 * (ENERGY / 200);
|
|
ctx.globalAlpha = 0.4;
|
|
|
|
ctx.beginPath();
|
|
this.edges.forEach(edge => {
|
|
ctx.moveTo(edge.center.x, edge.center.y);
|
|
ctx.lineTo(edge.p1.x, edge.p1.y);
|
|
ctx.moveTo(edge.center.x, edge.center.y);
|
|
ctx.lineTo(edge.p2.x, edge.p2.y);
|
|
});
|
|
ctx.stroke();
|
|
|
|
// Draw sites
|
|
this.cells.forEach(cell => cell.draw(ctx, t));
|
|
}
|
|
}
|
|
|
|
// Main animation
|
|
const system = new VoronoiSystem([]);
|
|
function initPoints() {
|
|
system.points = [];
|
|
for (let i = 0; i < PLATE_COUNT; i++) {
|
|
let x, y;
|
|
let attempts = 0;
|
|
do {
|
|
x = EDGE_BUFFER + Math.random() * (canvas.width - 2 * EDGE_BUFFER);
|
|
y = EDGE_BUFFER + Math.random() * (canvas.height - 2 * EDGE_BUFFER);
|
|
attempts++;
|
|
} while (attempts < 100 &&
|
|
system.points.some(p =>
|
|
Math.abs(p.x - x) < 30 &&
|
|
Math.abs(p.y - y) < 30));
|
|
|
|
system.points.push({x, y});
|
|
}
|
|
system.cells = system.points.map((p, i) => new VoronoiCell(p.x, p.y, i));
|
|
system.computeVoronoi();
|
|
}
|
|
|
|
initPoints();
|
|
|
|
let t = 0;
|
|
function animate() {
|
|
t++;
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
ctx.fillStyle = '#0a0a0a';
|
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
|
|
system.update(t);
|
|
system.draw(ctx, t);
|
|
|
|
requestAnimationFrame(animate);
|
|
}
|
|
|
|
animate();
|
|
</script>
|
|
</body>
|
|
</html> |