|
|
<!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> |