From fbbe71afa4b2cae89e0526f3c0e0a63a2748bfc1 Mon Sep 17 00:00:00 2001 From: Amamiya Miu Date: Wed, 25 Feb 2026 02:09:44 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=A2=9E=E5=8A=A0=E9=9D=9E=E6=AC=A7?= =?UTF-8?q?=E5=87=A0=E4=BD=95=E7=94=9F=E5=91=BD=E6=B8=B8=E6=88=8F=20demo?= =?UTF-8?q?=20=E5=85=A5=E5=8F=A3=E9=87=8D=E5=AE=9A=E5=90=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- demo/game-of-life/non-euclidean/index.html | 15 + .../game-of-life/non-euclidean/sphere-life.js | 299 ++++++++++-------- 2 files changed, 179 insertions(+), 135 deletions(-) create mode 100644 demo/game-of-life/non-euclidean/index.html diff --git a/demo/game-of-life/non-euclidean/index.html b/demo/game-of-life/non-euclidean/index.html new file mode 100644 index 0000000..c5b5c3a --- /dev/null +++ b/demo/game-of-life/non-euclidean/index.html @@ -0,0 +1,15 @@ + + + + + + 跳转中 · 球面生命游戏 + + + + +

正在跳转到非欧几何球面生命游戏预览页面…

+ + diff --git a/public/demo/game-of-life/non-euclidean/sphere-life.js b/public/demo/game-of-life/non-euclidean/sphere-life.js index 5f399e6..ec711ee 100644 --- a/public/demo/game-of-life/non-euclidean/sphere-life.js +++ b/public/demo/game-of-life/non-euclidean/sphere-life.js @@ -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); @@ -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(); } @@ -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); @@ -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);