| <!DOCTYPE html> |
| <html lang="zh"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>宇宙行星球體空間扭曲現象</title> |
| <script src="https://cdn.tailwindcss.com"></script> |
| <style> |
| body { |
| margin: 0; |
| overflow: hidden; |
| background-color: #000; |
| cursor: grab; |
| } |
| body.grabbing { |
| cursor: grabbing; |
| } |
| canvas { |
| display: block; |
| } |
| .info-panel { |
| position: absolute; |
| bottom: 20px; |
| left: 20px; |
| background: rgba(0, 0, 0, 0.7); |
| color: white; |
| padding: 15px; |
| border-radius: 10px; |
| max-width: 300px; |
| font-family: 'Arial', sans-serif; |
| } |
| .title { |
| position: absolute; |
| top: 20px; |
| left: 50%; |
| transform: translateX(-50%); |
| color: white; |
| font-size: 2rem; |
| text-align: center; |
| text-shadow: 0 0 10px #4f8fff; |
| font-family: 'Arial', sans-serif; |
| } |
| .controls { |
| position: absolute; |
| top: 20px; |
| right: 20px; |
| display: flex; |
| flex-direction: column; |
| gap: 10px; |
| } |
| .control-btn { |
| background: rgba(0, 0, 0, 0.7); |
| color: white; |
| border: 1px solid #4f8fff; |
| border-radius: 5px; |
| padding: 8px 12px; |
| cursor: pointer; |
| transition: all 0.3s; |
| } |
| .control-btn:hover { |
| background: rgba(79, 143, 255, 0.3); |
| } |
| .star { |
| position: absolute; |
| background-color: white; |
| border-radius: 50%; |
| pointer-events: none; |
| } |
| </style> |
| </head> |
| <body> |
| <div class="title">宇宙行星球體空間扭曲現象</div> |
| |
| <div class="controls"> |
| <button id="addPlanet" class="control-btn">添加行星</button> |
| <button id="reset" class="control-btn">重置模擬</button> |
| <button id="toggleGravity" class="control-btn">切換引力</button> |
| <button id="toggleDistortion" class="control-btn">切換扭曲效果</button> |
| <button id="resetView" class="control-btn">重置視角</button> |
| </div> |
| |
| <div class="info-panel"> |
| <h3 class="text-xl font-bold mb-2 text-blue-300">空間扭曲現象</h3> |
| <p class="text-sm mb-2">當大質量天體在時空中移動時,會造成周圍空間的彎曲和扭曲,這正是愛因斯坦廣義相對論所描述的現象。</p> |
| <p class="text-sm">在此模擬中,行星的運動會在其周圍產生引力井效應,導致光線偏折和空間變形。</p> |
| <p class="text-sm mt-2 text-blue-200">提示: 用滑鼠拖曳可以改變視角</p> |
| </div> |
| |
| <canvas id="spaceCanvas"></canvas> |
| |
| <script> |
| |
| const canvas = document.getElementById('spaceCanvas'); |
| const ctx = canvas.getContext('2d'); |
| |
| |
| function resizeCanvas() { |
| canvas.width = window.innerWidth; |
| canvas.height = window.innerHeight; |
| } |
| resizeCanvas(); |
| window.addEventListener('resize', resizeCanvas); |
| |
| |
| const params = { |
| gravity: true, |
| distortion: true, |
| distortionStrength: 0.3, |
| friction: 0.98, |
| bounce: 0.7, |
| starCount: 200 |
| }; |
| |
| |
| const view = { |
| x: 0, |
| y: 0, |
| scale: 1, |
| isDragging: false, |
| lastX: 0, |
| lastY: 0 |
| }; |
| |
| |
| class Planet { |
| constructor(x, y, radius, mass, color) { |
| this.x = x; |
| this.y = y; |
| this.radius = radius; |
| this.mass = mass; |
| this.color = color; |
| this.vx = (Math.random() - 0.5) * 5; |
| this.vy = (Math.random() - 0.5) * 5; |
| this.rotation = 0; |
| this.rotationSpeed = (Math.random() - 0.5) * 0.02; |
| this.texture = this.createTexture(radius, color); |
| } |
| |
| createTexture(radius, color) { |
| const textureCanvas = document.createElement('canvas'); |
| const textureCtx = textureCanvas.getContext('2d'); |
| const size = radius * 2; |
| textureCanvas.width = size; |
| textureCanvas.height = size; |
| |
| |
| const gradient = textureCtx.createRadialGradient( |
| radius, radius, 0, |
| radius, radius, radius |
| ); |
| gradient.addColorStop(0, lightenColor(color, 30)); |
| gradient.addColorStop(0.7, color); |
| gradient.addColorStop(1, darkenColor(color, 20)); |
| |
| textureCtx.beginPath(); |
| textureCtx.arc(radius, radius, radius, 0, Math.PI * 2); |
| textureCtx.fillStyle = gradient; |
| textureCtx.fill(); |
| |
| |
| for (let i = 0; i < radius / 2; i++) { |
| const spotRadius = Math.random() * radius / 4; |
| const spotX = Math.random() * size; |
| const spotY = Math.random() * size; |
| |
| |
| const dist = Math.sqrt( |
| Math.pow(spotX - radius, 2) + |
| Math.pow(spotY - radius, 2) |
| ); |
| |
| if (dist < radius - spotRadius) { |
| const spotColor = Math.random() > 0.5 ? |
| lightenColor(color, 15) : |
| darkenColor(color, 15); |
| |
| textureCtx.beginPath(); |
| textureCtx.arc(spotX, spotY, spotRadius, 0, Math.PI * 2); |
| textureCtx.fillStyle = spotColor; |
| textureCtx.fill(); |
| } |
| } |
| |
| return textureCanvas; |
| } |
| |
| update() { |
| this.x += this.vx; |
| this.y += this.vy; |
| this.rotation += this.rotationSpeed; |
| |
| |
| const scaledWidth = canvas.width / view.scale; |
| const scaledHeight = canvas.height / view.scale; |
| |
| if (this.x - this.radius < 0) { |
| this.x = this.radius; |
| this.vx = -this.vx * params.bounce; |
| } else if (this.x + this.radius > scaledWidth) { |
| this.x = scaledWidth - this.radius; |
| this.vx = -this.vx * params.bounce; |
| } |
| |
| if (this.y - this.radius < 0) { |
| this.y = this.radius; |
| this.vy = -this.vy * params.bounce; |
| } else if (this.y + this.radius > scaledHeight) { |
| this.y = scaledHeight - this.radius; |
| this.vy = -this.vy * params.bounce; |
| } |
| |
| |
| this.vx *= params.friction; |
| this.vy *= params.friction; |
| } |
| |
| draw() { |
| ctx.save(); |
| |
| |
| ctx.translate(view.x, view.y); |
| ctx.scale(view.scale, view.scale); |
| |
| ctx.translate(this.x, this.y); |
| ctx.rotate(this.rotation); |
| |
| |
| ctx.drawImage( |
| this.texture, |
| -this.radius, |
| -this.radius, |
| this.radius * 2, |
| this.radius * 2 |
| ); |
| |
| |
| if (params.distortion) { |
| const gradient = ctx.createRadialGradient( |
| 0, 0, this.radius, |
| 0, 0, this.radius * 2 |
| ); |
| gradient.addColorStop(0, `rgba(${hexToRgb(this.color)}, 0.3)`); |
| gradient.addColorStop(1, 'rgba(0, 0, 0, 0)'); |
| |
| ctx.beginPath(); |
| ctx.arc(0, 0, this.radius * 2, 0, Math.PI * 2); |
| ctx.fillStyle = gradient; |
| ctx.fill(); |
| } |
| |
| ctx.restore(); |
| } |
| |
| applyGravity(otherPlanets) { |
| if (!params.gravity) return; |
| |
| otherPlanets.forEach(planet => { |
| if (planet !== this) { |
| const dx = planet.x - this.x; |
| const dy = planet.y - this.y; |
| const distance = Math.sqrt(dx * dx + dy * dy); |
| |
| |
| if (distance > 5) { |
| const force = (planet.mass * this.mass) / (distance * distance) * 0.0001; |
| const fx = (dx / distance) * force; |
| const fy = (dy / distance) * force; |
| |
| this.vx += fx / this.mass; |
| this.vy += fy / this.mass; |
| } |
| } |
| }); |
| } |
| |
| |
| getScreenPosition() { |
| return { |
| x: this.x * view.scale + view.x, |
| y: this.y * view.scale + view.y |
| }; |
| } |
| } |
| |
| |
| class Star { |
| constructor() { |
| this.x = Math.random() * (canvas.width / view.scale); |
| this.y = Math.random() * (canvas.height / view.scale); |
| this.size = Math.random() * 3; |
| this.brightness = Math.random(); |
| this.color = `rgba(255, 255, 255, ${this.brightness})`; |
| this.speed = 0.1 + Math.random() * 0.5; |
| this.direction = Math.random() * Math.PI * 2; |
| } |
| |
| update(planets) { |
| |
| if (params.gravity) { |
| planets.forEach(planet => { |
| const dx = planet.x - this.x; |
| const dy = planet.y - this.y; |
| const distance = Math.sqrt(dx * dx + dy * dy); |
| |
| if (distance < planet.radius * 3) { |
| const angle = Math.atan2(dy, dx); |
| const force = (planet.mass * 0.0001) / (distance * distance); |
| |
| this.x -= Math.cos(angle) * force * 10; |
| this.y -= Math.sin(angle) * force * 10; |
| } |
| }); |
| } |
| |
| |
| this.x += Math.cos(this.direction) * this.speed; |
| this.y += Math.sin(this.direction) * this.speed; |
| |
| |
| const scaledWidth = canvas.width / view.scale; |
| const scaledHeight = canvas.height / view.scale; |
| |
| if (this.x < 0 || this.x > scaledWidth || |
| this.y < 0 || this.y > scaledHeight) { |
| this.x = Math.random() * scaledWidth; |
| this.y = Math.random() * scaledHeight; |
| this.direction = Math.random() * Math.PI * 2; |
| } |
| } |
| |
| draw() { |
| ctx.save(); |
| ctx.translate(view.x, view.y); |
| ctx.scale(view.scale, view.scale); |
| |
| ctx.fillStyle = this.color; |
| ctx.beginPath(); |
| ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2); |
| ctx.fill(); |
| |
| ctx.restore(); |
| } |
| } |
| |
| |
| function lightenColor(color, percent) { |
| const num = parseInt(color.replace("#", ""), 16); |
| const amt = Math.round(2.55 * percent); |
| const R = (num >> 16) + amt; |
| const G = (num >> 8 & 0x00FF) + amt; |
| const B = (num & 0x0000FF) + amt; |
| |
| return `#${( |
| 0x1000000 + |
| (R < 255 ? (R < 1 ? 0 : R) : 255) * 0x10000 + |
| (G < 255 ? (G < 1 ? 0 : G) : 255) * 0x100 + |
| (B < 255 ? (B < 1 ? 0 : B) : 255) |
| ).toString(16).slice(1)}`; |
| } |
| |
| function darkenColor(color, percent) { |
| const num = parseInt(color.replace("#", ""), 16); |
| const amt = Math.round(2.55 * percent); |
| const R = (num >> 16) - amt; |
| const G = (num >> 8 & 0x00FF) - amt; |
| const B = (num & 0x0000FF) - amt; |
| |
| return `#${( |
| 0x1000000 + |
| (R > 0 ? (R < 255 ? R : 255) : 0) * 0x10000 + |
| (G > 0 ? (G < 255 ? G : 255) : 0) * 0x100 + |
| (B > 0 ? (B < 255 ? B : 255) : 0) |
| ).toString(16).slice(1)}`; |
| } |
| |
| function hexToRgb(hex) { |
| const r = parseInt(hex.slice(1, 3), 16); |
| const g = parseInt(hex.slice(3, 5), 16); |
| const b = parseInt(hex.slice(5, 7), 16); |
| return `${r}, ${g}, ${b}`; |
| } |
| |
| function getRandomPlanetColor() { |
| const colors = [ |
| '#4A6FA5', |
| '#A54A4A', |
| '#4AA54A', |
| '#A54AA5', |
| '#A5A54A', |
| '#4AA5A5', |
| '#A56F4A' |
| ]; |
| return colors[Math.floor(Math.random() * colors.length)]; |
| } |
| |
| |
| let planets = []; |
| let stars = []; |
| |
| function init() { |
| planets = []; |
| stars = []; |
| |
| |
| for (let i = 0; i < 3; i++) { |
| const radius = 20 + Math.random() * 30; |
| planets.push(new Planet( |
| Math.random() * (canvas.width / view.scale - radius * 2) + radius, |
| Math.random() * (canvas.height / view.scale - radius * 2) + radius, |
| radius, |
| radius * radius * 0.1, |
| getRandomPlanetColor() |
| )); |
| } |
| |
| |
| for (let i = 0; i < params.starCount; i++) { |
| stars.push(new Star()); |
| } |
| |
| |
| resetView(); |
| } |
| |
| |
| function resetView() { |
| view.x = 0; |
| view.y = 0; |
| view.scale = 1; |
| } |
| |
| |
| function animate() { |
| |
| ctx.fillStyle = 'rgba(0, 0, 0, 0.1)'; |
| ctx.fillRect(0, 0, canvas.width, canvas.height); |
| |
| |
| if (params.distortion) { |
| drawSpaceGrid(); |
| } |
| |
| |
| stars.forEach(star => { |
| star.update(planets); |
| star.draw(); |
| }); |
| |
| |
| planets.forEach(planet => { |
| planet.applyGravity(planets); |
| planet.update(); |
| planet.draw(); |
| }); |
| |
| requestAnimationFrame(animate); |
| } |
| |
| |
| function drawSpaceGrid() { |
| const gridSize = 40; |
| const rows = Math.ceil(canvas.height / (gridSize * view.scale)); |
| const cols = Math.ceil(canvas.width / (gridSize * view.scale)); |
| |
| ctx.save(); |
| ctx.translate(view.x, view.y); |
| ctx.scale(view.scale, view.scale); |
| |
| ctx.strokeStyle = 'rgba(100, 150, 255, 0.2)'; |
| ctx.lineWidth = 1 / view.scale; |
| |
| |
| for (let i = 0; i < rows; i++) { |
| const y = i * gridSize; |
| ctx.beginPath(); |
| |
| for (let j = 0; j < cols; j++) { |
| const x = j * gridSize; |
| let newX = x; |
| let newY = y; |
| |
| |
| planets.forEach(planet => { |
| const dx = x - planet.x; |
| const dy = y - planet.y; |
| const distance = Math.sqrt(dx * dx + dy * dy); |
| |
| if (distance < planet.radius * 5) { |
| const strength = params.distortionStrength * (planet.mass / 100) * (1 - distance / (planet.radius * 5)); |
| const angle = Math.atan2(dy, dx); |
| |
| newX -= Math.cos(angle) * strength * 10; |
| newY -= Math.sin(angle) * strength * 10; |
| } |
| }); |
| |
| if (j === 0) { |
| ctx.moveTo(newX, newY); |
| } else { |
| ctx.lineTo(newX, newY); |
| } |
| } |
| |
| ctx.stroke(); |
| } |
| |
| |
| for (let j = 0; j < cols; j++) { |
| const x = j * gridSize; |
| ctx.beginPath(); |
| |
| for (let i = 0; i < rows; i++) { |
| const y = i * gridSize; |
| let newX = x; |
| let newY = y; |
| |
| |
| planets.forEach(planet => { |
| const dx = x - planet.x; |
| const dy = y - planet.y; |
| const distance = Math.sqrt(dx * dx + dy * dy); |
| |
| if (distance < planet.radius * 5) { |
| const strength = params.distortionStrength * (planet.mass / 100) * (1 - distance / (planet.radius * 5)); |
| const angle = Math.atan2(dy, dx); |
| |
| newX -= Math.cos(angle) * strength * 10; |
| newY -= Math.sin(angle) * strength * 10; |
| } |
| }); |
| |
| if (i === 0) { |
| ctx.moveTo(newX, newY); |
| } else { |
| ctx.lineTo(newX, newY); |
| } |
| } |
| |
| ctx.stroke(); |
| } |
| |
| ctx.restore(); |
| } |
| |
| |
| document.getElementById('addPlanet').addEventListener('click', () => { |
| const radius = 20 + Math.random() * 30; |
| planets.push(new Planet( |
| Math.random() * (canvas.width / view.scale - radius * 2) + radius, |
| Math.random() * (canvas.height / view.scale - radius * 2) + radius, |
| radius, |
| radius * radius * 0.1, |
| getRandomPlanetColor() |
| )); |
| }); |
| |
| document.getElementById('reset').addEventListener('click', init); |
| |
| document.getElementById('toggleGravity').addEventListener('click', () => { |
| params.gravity = !params.gravity; |
| document.getElementById('toggleGravity').textContent = |
| params.gravity ? '關閉引力' : '開啟引力'; |
| }); |
| |
| document.getElementById('toggleDistortion').addEventListener('click', () => { |
| params.distortion = !params.distortion; |
| document.getElementById('toggleDistortion').textContent = |
| params.distortion ? '關閉扭曲' : '開啟扭曲'; |
| }); |
| |
| document.getElementById('resetView').addEventListener('click', resetView); |
| |
| |
| canvas.addEventListener('mousedown', (e) => { |
| view.isDragging = true; |
| view.lastX = e.clientX; |
| view.lastY = e.clientY; |
| document.body.classList.add('grabbing'); |
| }); |
| |
| canvas.addEventListener('mousemove', (e) => { |
| if (view.isDragging) { |
| const dx = e.clientX - view.lastX; |
| const dy = e.clientY - view.lastY; |
| |
| view.x += dx; |
| view.y += dy; |
| |
| view.lastX = e.clientX; |
| view.lastY = e.clientY; |
| } |
| }); |
| |
| canvas.addEventListener('mouseup', () => { |
| view.isDragging = false; |
| document.body.classList.remove('grabbing'); |
| }); |
| |
| canvas.addEventListener('mouseleave', () => { |
| view.isDragging = false; |
| document.body.classList.remove('grabbing'); |
| }); |
| |
| |
| canvas.addEventListener('wheel', (e) => { |
| e.preventDefault(); |
| |
| |
| const mouseX = e.clientX - canvas.getBoundingClientRect().left; |
| const mouseY = e.clientY - canvas.getBoundingClientRect().top; |
| |
| |
| const worldX = (mouseX - view.x) / view.scale; |
| const worldY = (mouseY - view.y) / view.scale; |
| |
| |
| const delta = -e.deltaY; |
| const zoomFactor = delta > 0 ? 1.1 : 0.9; |
| |
| |
| const newScale = view.scale * zoomFactor; |
| if (newScale > 0.1 && newScale < 5) { |
| view.scale = newScale; |
| |
| |
| view.x = mouseX - worldX * view.scale; |
| view.y = mouseY - worldY * view.scale; |
| } |
| }); |
| |
| |
| init(); |
| animate(); |
| </script> |
| <p style="border-radius: 8px; text-align: center; font-size: 12px; color: #fff; margin-top: 16px;position: fixed; left: 8px; bottom: 8px; z-index: 10; background: rgba(0, 0, 0, 0.8); padding: 4px 8px;">Made with <img src="https://enzostvs-deepsite.hf.space/logo.svg" alt="DeepSite Logo" style="width: 16px; height: 16px; vertical-align: middle;display:inline-block;margin-right:3px;filter:brightness(0) invert(1);"><a href="https://enzostvs-deepsite.hf.space" style="color: #fff;text-decoration: underline;" target="_blank" >DeepSite</a> - 🧬 <a href="https://enzostvs-deepsite.hf.space?remix=engerl/space" style="color: #fff;text-decoration: underline;" target="_blank" >Remix</a></p></body> |
| </html> |