Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions demo/game-of-life/non-euclidean/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>跳转中 · 球面生命游戏</title>
<meta http-equiv="refresh" content="0; url=/public/demo/game-of-life/non-euclidean/index.html" />
<script>
window.location.replace('/public/demo/game-of-life/non-euclidean/index.html');
</script>
</head>
<body>
<p>正在跳转到非欧几何球面生命游戏预览页面…</p>
</body>
</html>
299 changes: 164 additions & 135 deletions public/demo/game-of-life/non-euclidean/sphere-life.js
Original file line number Diff line number Diff line change
@@ -1,69 +1,45 @@
import * as THREE from 'https://esm.sh/three@0.159.0';
import { OrbitControls } from 'https://esm.sh/three@0.159.0/examples/jsm/controls/OrbitControls.js';

const canvas = document.getElementById('life-sphere');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setClearColor(0x010510, 0);

const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100);
camera.position.set(0, 0, 9);

const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.enablePan = false;
controls.autoRotate = true;
controls.autoRotateSpeed = 0.6;
controls.minDistance = 6;
controls.maxDistance = 16;

const hemiLight = new THREE.HemisphereLight(0x38bdf8, 0x020617, 0.75);
scene.add(hemiLight);
const ctx = canvas?.getContext('2d');

const keyLight = new THREE.DirectionalLight(0xf8fafc, 0.65);
keyLight.position.set(6, 8, 6);
scene.add(keyLight);

const rimLight = new THREE.DirectionalLight(0x60a5fa, 0.55);
rimLight.position.set(-6, -8, -10);
scene.add(rimLight);
if (!canvas || !ctx) {
throw new Error('Canvas not available for sphere life.');
}

const LAT_DIVS = 24;
const LON_DIVS = 48;
const CELL_COUNT = LAT_DIVS * LON_DIVS;
const NEIGHBOR_ANGLE = 0.24;
const STEP_INTERVAL = 460;
const RADIUS = 4.2;

const cells = new Uint8Array(CELL_COUNT);
const buffer = new Uint8Array(CELL_COUNT);

const positions = [];
const aliveColors = [];
const neighbourList = Array.from({ length: CELL_COUNT }, () => []);
const tmpColor = new THREE.Color();

const generationLabel = document.querySelector('[data-generation]');
const populationLabel = document.querySelector('[data-population]');
const resetButton = document.querySelector('[data-reset]');

const points = [];

for (let lat = 0; lat < LAT_DIVS; lat += 1) {
const phi = Math.PI * ((lat + 0.5) / LAT_DIVS);
const latRatio = lat / (LAT_DIVS - 1);
for (let lon = 0; lon < LON_DIVS; lon += 1) {
const theta = 2 * Math.PI * (lon / LON_DIVS);
const x = Math.sin(phi) * Math.cos(theta);
const y = Math.cos(phi);
const z = Math.sin(phi) * Math.sin(theta);
positions.push(new THREE.Vector3(x, y, z).multiplyScalar(RADIUS));

tmpColor.setHSL(0.58 - latRatio * 0.3, 0.75, 0.52 + (0.18 * (1 - Math.abs(0.5 - latRatio) * 2)));
aliveColors.push(tmpColor.clone());
points.push({
x: Math.sin(phi) * Math.cos(theta),
y: Math.cos(phi),
z: Math.sin(phi) * Math.sin(theta),
latRatio,
});
}
}

const unitPositions = positions.map((vec) => vec.clone().normalize());

for (let i = 0; i < CELL_COUNT; i += 1) {
const p1 = points[i];
for (let j = i + 1; j < CELL_COUNT; j += 1) {
const dot = THREE.MathUtils.clamp(unitPositions[i].dot(unitPositions[j]), -1, 1);
const p2 = points[j];
const dot = Math.max(-1, Math.min(1, p1.x * p2.x + p1.y * p2.y + p1.z * p2.z));
const angle = Math.acos(dot);
if (angle <= NEIGHBOR_ANGLE) {
neighbourList[i].push(j);
Expand All @@ -72,86 +48,31 @@ for (let i = 0; i < CELL_COUNT; i += 1) {
}
}

const neighbourCounts = neighbourList.map((list) => list.length);
const minNeighbours = Math.min(...neighbourCounts);
const maxNeighbours = Math.max(...neighbourCounts);
const avgNeighbours = neighbourCounts.reduce((sum, count) => sum + count, 0) / neighbourCounts.length;
console.info('Sphere life neighbour stats', { minNeighbours, maxNeighbours, avgNeighbours: avgNeighbours.toFixed(2) });

const sphereGeometry = new THREE.SphereGeometry(0.18, 12, 12);
const sphereMaterial = new THREE.MeshStandardMaterial({
color: 0xffffff,
metalness: 0.2,
roughness: 0.35,
vertexColors: true,
transparent: true,
opacity: 0.95,
emissive: 0x082f49,
emissiveIntensity: 0.35,
});

const instanced = new THREE.InstancedMesh(sphereGeometry, sphereMaterial, CELL_COUNT);
instanced.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
scene.add(instanced);

const globe = new THREE.Mesh(
new THREE.SphereGeometry(RADIUS - 0.08, 64, 32),
new THREE.MeshStandardMaterial({
color: 0x0f172a,
roughness: 0.9,
metalness: 0.05,
opacity: 0.35,
transparent: true,
emissive: 0x020617,
emissiveIntensity: 0.25,
}),
);
scene.add(globe);

const identityQuaternion = new THREE.Quaternion();
const aliveScale = new THREE.Vector3(0.32, 0.32, 0.32);
const deadScale = new THREE.Vector3(0.0001, 0.0001, 0.0001);
const deadColor = new THREE.Color(0x0b1120);
const matrix = new THREE.Matrix4();

const generationLabel = document.querySelector('[data-generation]');
const populationLabel = document.querySelector('[data-population]');
const resetButton = document.querySelector('[data-reset]');

let generation = 0;
let population = 0;

function refreshInstances() {
population = 0;
for (let i = 0; i < CELL_COUNT; i += 1) {
const alive = cells[i] === 1;
const scale = alive ? aliveScale : deadScale;
matrix.compose(positions[i], identityQuaternion, scale);
instanced.setMatrixAt(i, matrix);
instanced.setColorAt(i, alive ? aliveColors[i] : deadColor);
if (alive) {
population += 1;
}
}
instanced.instanceMatrix.needsUpdate = true;
instanced.instanceColor.needsUpdate = true;
}
let rotationY = 0;
let rotationX = 0.35;
let dragging = false;
let pointerX = 0;
let pointerY = 0;
let radius = 0;
let centerX = 0;
let centerY = 0;
let zoom = 1;

function updateLabels() {
if (generationLabel) {
generationLabel.textContent = generation.toString();
}
if (populationLabel) {
populationLabel.textContent = population.toString();
}
if (generationLabel) generationLabel.textContent = String(generation);
if (populationLabel) populationLabel.textContent = String(population);
}

function randomizeCells() {
population = 0;
for (let i = 0; i < CELL_COUNT; i += 1) {
cells[i] = Math.random() < 0.28 ? 1 : 0;
const alive = Math.random() < 0.28 ? 1 : 0;
cells[i] = alive;
population += alive;
}
generation = 0;
refreshInstances();
updateLabels();
}

Expand All @@ -163,17 +84,11 @@ function stepLife() {
for (let k = 0; k < neighbours.length; k += 1) {
sum += cells[neighbours[k]];
}

const alive = cells[i] === 1;
let next = 0;
if (alive) {
next = sum === 2 || sum === 3 ? 1 : 0;
} else {
next = sum === 3 ? 1 : 0;
}
const next = alive ? (sum === 2 || sum === 3 ? 1 : 0) : (sum === 3 ? 1 : 0);
buffer[i] = next;
if (next === 1) {
aliveCount += 1;
}
aliveCount += next;
}

cells.set(buffer);
Expand All @@ -182,42 +97,156 @@ function stepLife() {
return;
}

population = aliveCount;
generation += 1;
refreshInstances();
updateLabels();
}

function hsl(latRatio, alive, alpha) {
if (!alive) return `rgba(11, 17, 32, ${alpha})`;
const hue = Math.round((0.58 - latRatio * 0.3) * 360);
const lightness = Math.round((0.52 + (0.18 * (1 - Math.abs(0.5 - latRatio) * 2))) * 100);
return `hsla(${hue} 78% ${lightness}% / ${alpha})`;
}

function resizeRenderer() {
const width = canvas.clientWidth || canvas.parentElement.clientWidth;
const height = canvas.clientHeight || canvas.parentElement.clientHeight;
if (width === 0 || height === 0) return;
renderer.setSize(width, height, false);
camera.aspect = width / height;
camera.updateProjectionMatrix();
const rect = canvas.getBoundingClientRect();
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const w = Math.max(1, Math.round(rect.width * dpr));
const h = Math.max(1, Math.round(rect.height * dpr));

if (canvas.width !== w || canvas.height !== h) {
canvas.width = w;
canvas.height = h;
}

centerX = w / 2;
centerY = h / 2;
radius = Math.min(w, h) * 0.38 * zoom;
}

function rotatePoint({ x, y, z }) {
const cosY = Math.cos(rotationY);
const sinY = Math.sin(rotationY);
const x1 = x * cosY + z * sinY;
const z1 = z * cosY - x * sinY;

const cosX = Math.cos(rotationX);
const sinX = Math.sin(rotationX);
const y2 = y * cosX - z1 * sinX;
const z2 = z1 * cosX + y * sinX;

return { x: x1, y: y2, z: z2 };
}

function drawSphere() {
ctx.clearRect(0, 0, canvas.width, canvas.height);

const gradient = ctx.createRadialGradient(
centerX - radius * 0.26,
centerY - radius * 0.26,
radius * 0.2,
centerX,
centerY,
radius,
);
gradient.addColorStop(0, 'rgba(29, 78, 216, 0.4)');
gradient.addColorStop(0.5, 'rgba(15, 23, 42, 0.55)');
gradient.addColorStop(1, 'rgba(2, 6, 23, 0.75)');

ctx.beginPath();
ctx.arc(centerX, centerY, radius, 0, Math.PI * 2);
ctx.fillStyle = gradient;
ctx.fill();

const rendered = points.map((point, index) => {
const rotated = rotatePoint(point);
const depth = (rotated.z + 1) * 0.5;
const scale = 0.4 + depth * 0.9;
return {
index,
x: centerX + rotated.x * radius,
y: centerY + rotated.y * radius,
z: rotated.z,
size: Math.max(1.2, radius * 0.035 * scale),
alpha: 0.2 + depth * 0.9,
};
});

rendered.sort((a, b) => a.z - b.z);

for (let i = 0; i < rendered.length; i += 1) {
const cell = rendered[i];
const alive = cells[cell.index] === 1;
const latRatio = points[cell.index].latRatio;
ctx.beginPath();
ctx.arc(cell.x, cell.y, cell.size, 0, Math.PI * 2);
ctx.fillStyle = hsl(latRatio, alive, cell.alpha);
ctx.fill();
}

ctx.beginPath();
ctx.arc(centerX, centerY, radius, 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(96, 165, 250, 0.28)';
ctx.lineWidth = Math.max(1, radius * 0.008);
ctx.stroke();
}

let lastStep = 0;

function animate(timestamp) {
requestAnimationFrame(animate);
controls.update();

if (!dragging) {
rotationY += 0.003;
}

if (timestamp - lastStep > STEP_INTERVAL) {
stepLife();
lastStep = timestamp;
}

renderer.render(scene, camera);
drawSphere();
}

resetButton?.addEventListener('click', () => {
randomizeCells();
canvas.addEventListener('pointerdown', (event) => {
dragging = true;
pointerX = event.clientX;
pointerY = event.clientY;
canvas.setPointerCapture(event.pointerId);
});

window.addEventListener('resize', () => {
resizeRenderer();
canvas.addEventListener('pointermove', (event) => {
if (!dragging) return;
const dx = event.clientX - pointerX;
const dy = event.clientY - pointerY;
pointerX = event.clientX;
pointerY = event.clientY;

rotationY += dx * 0.006;
rotationX = Math.max(-Math.PI * 0.45, Math.min(Math.PI * 0.45, rotationX + dy * 0.006));
});

canvas.addEventListener('pointerup', (event) => {
dragging = false;
canvas.releasePointerCapture(event.pointerId);
});

canvas.addEventListener('pointercancel', (event) => {
dragging = false;
canvas.releasePointerCapture(event.pointerId);
});

canvas.addEventListener('wheel', (event) => {
event.preventDefault();
const direction = Math.sign(event.deltaY);
zoom = Math.max(0.72, Math.min(1.35, zoom - direction * 0.06));
resizeRenderer();
}, { passive: false });

resetButton?.addEventListener('click', randomizeCells);
window.addEventListener('resize', resizeRenderer);

resizeRenderer();
randomizeCells();
requestAnimationFrame(animate);