Created
April 29, 2025 09:20
-
-
Save bustesoul/addc0606fce88d920e4997885ee2923a to your computer and use it in GitHub Desktop.
ball_from_gpt4.1.html
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!DOCTYPE html> | |
<html lang="zh"> | |
<head> | |
<meta charset="UTF-8"> | |
<title>可调参数的旋转六边形抛球模拟</title> | |
<style> | |
body { | |
margin: 0; | |
background: #222; | |
display:flex; flex-direction:column; | |
align-items:center; justify-content:center; | |
min-height: 100vh; | |
} | |
canvas { | |
background: #222; | |
box-shadow: 0 4px 32px #111; | |
display:block; | |
margin-bottom: 12px; | |
} | |
/* UI美化 */ | |
#controls { | |
background: #181818; | |
border-radius: 8px; | |
padding: 16px 22px 10px 22px; | |
box-shadow: 0 2px 14px #0008; | |
margin-bottom: 14px; | |
color: #f0e6cc; | |
min-width: 390px; | |
font-family: sans-serif; | |
} | |
.param-group { | |
display: flex; align-items: center; | |
margin-bottom: 10px; | |
} | |
.param-group label { width: 115px } | |
.param-group input[type=range] { flex:1; margin: 0 8px; } | |
.param-group .val { width:54px; text-align:right;} | |
#btns { | |
text-align: right; padding-top: 5px; | |
} | |
button { | |
margin-left: 10px; | |
padding: 7px 21px; | |
border-radius: 5px; | |
border: none; | |
background: #FFEB3B; | |
color: #444; | |
font-weight: bold; | |
cursor: pointer; | |
transition:.2s; | |
font-size:17px; | |
} | |
button:active { box-shadow: inset 0 2px 8px #fff5,0 1px 2px #0004; } | |
button[disabled] { | |
filter: grayscale(70%); | |
background:#999; | |
color:#ddd; | |
cursor:not-allowed; | |
} | |
</style> | |
</head> | |
<body> | |
<canvas id="canvas" width="600" height="600"></canvas> | |
<div id="controls"> | |
<div class="param-group"> | |
<label>重力 g (px/s²)</label> | |
<input id="paramG" type="range" min="100" max="2000" value="700" step="1"> | |
<span id="valG" class="val">700</span> | |
</div> | |
<div class="param-group"> | |
<label>六边形边长</label> | |
<input id="paramHexR" type="range" min="60" max="290" value="200" step="1"> | |
<span id="valHexR" class="val">200</span> | |
</div> | |
<div class="param-group"> | |
<label>六边形旋转速度</label> | |
<input id="paramAngV" type="range" min="0" max="628" value="104" step="1"> | |
<span id="valAngV" class="val">0.52</span> | |
<span style="font-size:12px;">(rad/s)</span> | |
</div> | |
<div class="param-group"> | |
<label>小球半径</label> | |
<input id="paramBallR" type="range" min="7" max="45" value="14" step="1"> | |
<span id="valBallR" class="val">14</span> | |
</div> | |
<div class="param-group"> | |
<label>初速度幅度</label> | |
<input id="paramV0" type="range" min="0" max="150" value="20" step="1"> | |
<span id="valV0" class="val">20</span> | |
<span style="font-size:12px;">(px/s)</span> | |
</div> | |
<div id="btns"> | |
<button id="btnStart">Start</button> | |
<button id="btnReset">Reset</button> | |
</div> | |
<div style="font-size:12px; color:#ccc; margin-top:7px;"> | |
六边形旋转速度:0为不旋转,π=3.14,请微调。Start后可反复点击画布增加小球。 | |
</div> | |
</div> | |
<script> | |
// 获取控件 | |
const el = s => document.querySelector(s); | |
const canvas = el('#canvas'); | |
const ctx = canvas.getContext('2d'); | |
const center = {x: canvas.width/2, y: canvas.height/2}; | |
// 参数定义与UI同步 | |
let params = { | |
gravity: 700, // px/s^2 | |
hexRadius: 200, // 六边形外接圆半径 | |
hexOmega: 0.52, // 旋转角速度,单位rad/s | |
ballRadius: 14, // 小球半径 | |
v0: 20 // 初速(-v0到+v0) | |
}; | |
/* 说明: 旋转速度滑块最大628, 实际对应6.28 (2π) rad/s, User输入52就是0.52 */ | |
function updateSliderValue(id, val, decimal=0) { | |
el('#val'+id).textContent = decimal>0 ? (+val).toFixed(decimal): +val; | |
} | |
el('#paramG').addEventListener('input',e=>{ | |
params.gravity = Number(e.target.value); | |
updateSliderValue('G',params.gravity) | |
}); | |
el('#paramHexR').addEventListener('input',e=>{ | |
params.hexRadius = Number(e.target.value); | |
updateSliderValue('HexR',params.hexRadius) | |
}); | |
el('#paramAngV').addEventListener('input',e=>{ | |
params.hexOmega = Number(e.target.value)/100; | |
updateSliderValue('AngV',params.hexOmega,2) | |
}); | |
el('#paramBallR').addEventListener('input',e=>{ | |
params.ballRadius = Number(e.target.value); | |
updateSliderValue('BallR',params.ballRadius) | |
}); | |
el('#paramV0').addEventListener('input',e=>{ | |
params.v0 = Number(e.target.value); | |
updateSliderValue('V0',params.v0) | |
}); | |
// 初始化各值 | |
updateSliderValue('G',params.gravity); | |
updateSliderValue('HexR',params.hexRadius); | |
updateSliderValue('AngV',params.hexOmega,2); | |
updateSliderValue('BallR',params.ballRadius); | |
updateSliderValue('V0',params.v0); | |
// 物理主体 | |
const hexSides = 6; | |
let hexAngle = 0; | |
// 支持多个小球 | |
let balls = []; | |
let running = false; | |
let lastTime = null; | |
function getHexVertices(cx, cy, r, theta) { | |
const vs = []; | |
for(let i = 0; i<hexSides; i++){ | |
const ang = theta + i*2*Math.PI/hexSides; | |
vs.push({x:cx + r*Math.cos(ang), y:cy + r*Math.sin(ang)}); | |
} | |
return vs; | |
} | |
function closestPointOnSegment(px, py, x1, y1, x2, y2) { | |
const dx = x2-x1, dy = y2-y1; | |
if (dx===0 && dy===0) return {x:x1, y:y1}; | |
const t = Math.max(0, Math.min(1, ((px-x1)*dx + (py-y1)*dy)/(dx*dx+dy*dy))); | |
return {x:x1+t*dx, y:y1+t*dy}; | |
} | |
function drawHex(vertices) { | |
ctx.save(); | |
ctx.beginPath(); | |
ctx.moveTo(vertices[0].x, vertices[0].y); | |
for(let i=1;i<vertices.length;i++){ | |
ctx.lineTo(vertices[i].x, vertices[i].y); | |
} | |
ctx.closePath(); | |
ctx.lineWidth = 7; | |
ctx.strokeStyle = "#FFEB3B"; | |
ctx.shadowColor = "#FFFCC5"; | |
ctx.shadowBlur = 17; | |
ctx.stroke(); | |
ctx.restore(); | |
} | |
function drawBall(ball) { | |
ctx.save(); | |
ctx.beginPath(); | |
ctx.arc(ball.x, ball.y, ball.r, 0, 2*Math.PI); | |
ctx.fillStyle = "#4FFFEE"; | |
ctx.shadowColor = "#4FFFF0"; | |
ctx.shadowBlur = 18; | |
ctx.fill(); | |
ctx.strokeStyle = "#26A69A"; | |
ctx.lineWidth = 2; | |
ctx.stroke(); | |
ctx.restore(); | |
} | |
function draw() { | |
ctx.clearRect(0,0,canvas.width,canvas.height); | |
const vs = getHexVertices(center.x, center.y, params.hexRadius, hexAngle); | |
drawHex(vs); | |
balls.forEach(drawBall); | |
} | |
function updateBall(ball,dt,hexVs) { | |
// 重力 | |
ball.vy += params.gravity*dt; | |
// 预测 | |
let nextX = ball.x + ball.vx*dt, nextY = ball.y + ball.vy*dt; | |
for(let i=0;i<hexSides;i++){ | |
const v1 = hexVs[i], v2 = hexVs[(i+1)%hexSides]; | |
const closest = closestPointOnSegment(nextX, nextY, v1.x,v1.y, v2.x,v2.y); | |
const dx = nextX-closest.x, dy = nextY-closest.y; | |
const dist = Math.sqrt(dx*dx+dy*dy); | |
if(dist < ball.r){ | |
let nx=dx, ny=dy; | |
const l=Math.sqrt(nx*nx+ny*ny); | |
if(l===0)continue; | |
nx/=l; ny/=l; | |
const vDotN = ball.vx*nx + ball.vy*ny; | |
ball.vx -= 2*vDotN*nx; | |
ball.vy -= 2*vDotN*ny; | |
nextX = closest.x + nx*ball.r*1.06; | |
nextY = closest.y + ny*ball.r*1.06; | |
} | |
} | |
ball.x = nextX; ball.y = nextY; | |
} | |
function animate(t){ | |
if(!running) return; | |
if (!lastTime) lastTime = t; | |
let dtTotal = Math.min((t - lastTime)/1000, 0.022); | |
lastTime = t; | |
let steps = 3; | |
let dt = dtTotal / steps; | |
for (let i = 0; i < steps; i++) { | |
hexAngle += params.hexOmega * dt; | |
hexAngle %= Math.PI * 2; | |
const hexVs = getHexVertices(center.x, center.y, params.hexRadius, hexAngle); | |
for (let ball of balls) updateBall(ball, dt, hexVs); | |
} | |
draw(); | |
requestAnimationFrame(animate); | |
} | |
function addBall(){ | |
let vx = (Math.random()-0.5)*2*params.v0; | |
let vy = -Math.random()*params.v0/2; | |
balls.push({ | |
x:center.x, | |
y:center.y, | |
vx:vx, | |
vy:vy, | |
r:params.ballRadius | |
}); | |
} | |
function resetSim(){ | |
running=false; | |
balls=[]; | |
lastTime=null; | |
hexAngle=0; | |
draw(); | |
} | |
// 事件绑定 | |
el('#btnStart').onclick=()=>{ | |
if(!running){ | |
resetSim(); | |
addBall(); | |
running=true; | |
lastTime = null; | |
requestAnimationFrame(animate); | |
} | |
}; | |
el('#btnReset').onclick=()=>{ | |
// 重置一切 | |
resetSim(); | |
}; | |
canvas.addEventListener('click',(e)=>{ | |
if(running){ | |
// 画布内坐标,可实现点击处出球,但这里始终从中心 | |
addBall(); | |
} | |
}); | |
draw(); | |
</script> | |
</body> | |
</html> | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment