876a3b8768
新增两个独立思想实验室: 1. 万花筒模拟器:支持可调参数物理万花筒效果,配置方案SQLite持久化 2. 闵可夫斯基画板:支持世界线绘制、洛伦兹变换、光锥因果判定 同时添加自动绑定实验室后端的逻辑,以及配套的单元测试与前端页面
513 lines
14 KiB
HTML
513 lines
14 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>老式万花筒 自然混合碎片版</title>
|
||
<style>
|
||
*{margin:0;padding:0;box-sizing:border-box}
|
||
body{
|
||
background:#1a1a1a;
|
||
display:flex;
|
||
flex-direction:column;
|
||
align-items:center;
|
||
justify-content:center;
|
||
min-height:100vh;
|
||
font-family:system-ui;
|
||
padding:16px;
|
||
}
|
||
.kaleido-wrap{
|
||
position:relative;
|
||
width:520px;
|
||
height:520px;
|
||
}
|
||
#canvas{
|
||
border-radius:50%;
|
||
box-shadow:0 0 0 16px #6b4423, 0 0 0 22px #4a2e16, 0 12px 45px #000;
|
||
cursor:grab;
|
||
}
|
||
#canvas:active{cursor:grabbing}
|
||
.tip{
|
||
color:#aaa;
|
||
margin-top:12px;
|
||
font-size:14px;
|
||
line-height:1.6;
|
||
text-align:center;
|
||
}
|
||
.control-panel{
|
||
background:#2c2c2c;
|
||
padding:14px 20px;
|
||
border-radius:10px;
|
||
margin-bottom:18px;
|
||
color:#eee;
|
||
display:flex;
|
||
gap:14px;
|
||
align-items:center;
|
||
flex-wrap:wrap;
|
||
justify-content:center;
|
||
}
|
||
.ctrl-item{
|
||
display:flex;
|
||
flex-direction:column;
|
||
gap:4px;
|
||
}
|
||
.ctrl-item label{
|
||
font-size:13px;
|
||
color:#ddd;
|
||
}
|
||
input[type="range"]{
|
||
width:100px;
|
||
}
|
||
.check-item{
|
||
display:flex;
|
||
align-items:center;
|
||
gap:8px;
|
||
}
|
||
button{
|
||
background:#784c28;
|
||
color:#fff;
|
||
border:none;
|
||
padding:7px 12px;
|
||
border-radius:6px;
|
||
cursor:pointer;
|
||
}
|
||
button:hover{background:#8d5b32;}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<div class="control-panel">
|
||
<div class="ctrl-item">
|
||
<label>碎片数量: <span id="valPieces">120</span></label>
|
||
<input id="rangePieces" type="range" min="30" max="260" value="120">
|
||
</div>
|
||
<div class="ctrl-item">
|
||
<label>棱镜分割份数: <span id="valPrism">6</span></label>
|
||
<input id="rangePrism" type="range" min="3" max="12" value="6">
|
||
</div>
|
||
<div class="ctrl-item">
|
||
<label>桶宽(视窗孔径): <span id="valTube">1.00</span></label>
|
||
<input id="rangeTube" type="range" min="0.40" max="1.00" step="0.02" value="1.00">
|
||
</div>
|
||
<div class="ctrl-item">
|
||
<label>碎片最小倍率: <span id="valScaleMin">0.50x</span></label>
|
||
<input id="rangeScaleMin" type="range" min="0.2" max="1.2" step="0.05" value="0.5">
|
||
</div>
|
||
<div class="ctrl-item">
|
||
<label>碎片最大倍率: <span id="valScaleMax">3.00x</span></label>
|
||
<input id="rangeScaleMax" type="range" min="1.0" max="4.0" step="0.05" value="3.0">
|
||
</div>
|
||
|
||
<div class="ctrl-item">
|
||
<label>重力强度: <span id="valGravity">0.07</span></label>
|
||
<input id="rangeGravity" type="range" min="0" max="0.30" step="0.01" value="0.07">
|
||
</div>
|
||
<div class="ctrl-item">
|
||
<label>静摩擦系数: <span id="valFriction">0.11</span></label>
|
||
<input id="rangeFriction" type="range" min="0.02" max="0.20" step="0.01" value="0.11">
|
||
</div>
|
||
|
||
<div class="check-item">
|
||
<input id="checkGravity" type="checkbox">
|
||
<label for="checkGravity">启用重力</label>
|
||
</div>
|
||
|
||
<button id="btnApply">应用设置</button>
|
||
<button id="btnShake">手动摇晃万花筒</button>
|
||
</div>
|
||
|
||
<div class="kaleido-wrap">
|
||
<canvas id="canvas" width="520" height="520"></canvas>
|
||
</div>
|
||
<div class="tip">按住鼠标拖动旋转镜筒|双击画面摇晃万花筒</div>
|
||
|
||
<script>
|
||
const canvas = document.getElementById('canvas');
|
||
const ctx = canvas.getContext('2d');
|
||
const W = canvas.width;
|
||
const H = canvas.height;
|
||
const cx = W/2, cy = H/2;
|
||
const baseRadius = W/2;
|
||
|
||
let config = {
|
||
pieceCount: 120,
|
||
prismDiv: 6,
|
||
tubeScale: 1.00,
|
||
scaleMin: 0.5,
|
||
scaleMax: 3.0,
|
||
gravityEnabled: false,
|
||
gravityStrength: 0.07,
|
||
baseStaticFriction: 0.11
|
||
};
|
||
|
||
let pieces = [];
|
||
let rotateAngle = 0;
|
||
let isDrag = false;
|
||
let lastMouseAngle = 0;
|
||
let shakeTime = 0;
|
||
const SHAKE_DURATION = 700;
|
||
|
||
const colorPool = [
|
||
'#c82423','#f2b138','#287233','#2b549c','#883488','#d86080','#42a5b8','#e6c250','#992840','#366947'
|
||
];
|
||
|
||
/**
|
||
碎片概率分配:
|
||
50% poly 不规则多角碎片 3‑7边
|
||
25% ellipse 椭圆/扁片云母薄片
|
||
10% nearCircle 近圆、轻微变形圆
|
||
10% mixedDrop 水滴/圆角不规则片/三角/梯形
|
||
5% prim:正三角形,正圆,正方形
|
||
*/
|
||
function makeNaturalShard(){
|
||
const r = Math.random();
|
||
let type;
|
||
if(r < 0.50) type = 'poly';
|
||
else if(r < 0.75) type = 'ellipse';
|
||
else if(r < 0.85) type = 'nearCircle';
|
||
else if(r < 0.95) type = 'mixedDrop';
|
||
else type = 'prim';
|
||
|
||
let shapeData;
|
||
if(type === 'poly'){
|
||
const sides = 3 + Math.floor(Math.random()*5);
|
||
const pts = [];
|
||
for(let i=0;i<sides;i++){
|
||
const ang = (i / sides) * Math.PI*2 + (Math.random()-0.5)*0.45;
|
||
const rad = 4 + Math.random()*6;
|
||
pts.push({x:Math.cos(ang)*rad, y:Math.sin(ang)*rad});
|
||
}
|
||
shapeData = {pts};
|
||
}else if(type === 'ellipse'){
|
||
const r1 = 3 + Math.random()*7;
|
||
const r2 = 1.5 + Math.random()*3.5;
|
||
shapeData = {rx:r1, ry:r2, rot:Math.random()*Math.PI};
|
||
}else if(type === 'nearCircle'){
|
||
const baseR = 3.5 + Math.random()*5;
|
||
const pts = [];
|
||
const seg = 8;
|
||
for(let i=0;i<seg;i++){
|
||
const ang = (i/seg)*Math.PI*2;
|
||
const rr = baseR * (0.82 + Math.random()*0.36);
|
||
pts.push({x:Math.cos(ang)*rr, y:Math.sin(ang)*rr});
|
||
}
|
||
shapeData = {pts};
|
||
}else if(type === 'mixedDrop'){
|
||
const sub = Math.floor(Math.random()*3);
|
||
const pts = [];
|
||
if(sub === 0){
|
||
//水滴
|
||
const seg = 7;
|
||
for(let i=0;i<seg;i++){
|
||
const ang = (i/seg)*Math.PI*2;
|
||
let rr = 3 + Math.random()*5;
|
||
if(ang>0 && ang < Math.PI*0.75) rr *= 1.35;
|
||
pts.push({x:Math.cos(ang)*rr, y:Math.sin(ang)*rr});
|
||
}
|
||
}else if(sub ===1){
|
||
//圆角三角
|
||
pts.push({x:0,y:-5},{x:-4,y:4},{x:4,y:3});
|
||
}else{
|
||
//梯形
|
||
pts.push({x:-5,y:-3},{x:5,y:-3},{x:3,y:4},{x:-3,y:4});
|
||
}
|
||
shapeData = {pts};
|
||
}else if(type === 'prim'){
|
||
const sub = Math.floor(Math.random()*3);
|
||
if(sub===0){
|
||
//正三角
|
||
const pts=[];
|
||
for(let i=0;i<3;i++){
|
||
const ang = (i/3)*Math.PI*2 - Math.PI/2;
|
||
pts.push({x:Math.cos(ang)*5,y:Math.sin(ang)*5});
|
||
}
|
||
shapeData={pts,primKind:'tri'};
|
||
}else if(sub===1){
|
||
//正圆
|
||
shapeData={primKind:'circle',r:4.5+Math.random()*2};
|
||
}else{
|
||
//正方形
|
||
const s=4.5;
|
||
shapeData={pts:[{x:-s,y:-s},{x:s,y:-s},{x:s,y:s},{x:-s,y:s}],primKind:'sq'};
|
||
}
|
||
}
|
||
return {type, shapeData};
|
||
}
|
||
|
||
function resetPieces(){
|
||
pieces = [];
|
||
const maxPieceRange = baseRadius * config.tubeScale * 0.75;
|
||
for(let i=0;i<config.pieceCount;i++){
|
||
const s = config.scaleMin + Math.random()*(config.scaleMax - config.scaleMin);
|
||
const shard = makeNaturalShard();
|
||
pieces.push({
|
||
x: (Math.random()-0.5)*maxPieceRange,
|
||
y: (Math.random()-0.5)*maxPieceRange,
|
||
shard: shard,
|
||
sizeScale: s,
|
||
rot: Math.random()*Math.PI*2,
|
||
color: colorPool[Math.floor(Math.random()*colorPool.length)],
|
||
alpha: 0.62 + Math.random()*0.32,
|
||
vx: 0,
|
||
vy: 0,
|
||
vrot: 0,
|
||
mass: s,
|
||
isSleep: false
|
||
})
|
||
}
|
||
}
|
||
|
||
function startShake(){
|
||
shakeTime = SHAKE_DURATION;
|
||
pieces.forEach(p=>{
|
||
p.vx = (Math.random()-0.5)*14;
|
||
p.vy = (Math.random()-0.5)*14;
|
||
p.vrot = (Math.random()-0.5)*0.2;
|
||
p.isSleep = false;
|
||
})
|
||
}
|
||
|
||
function updatePhysics(dt){
|
||
const maxR = baseRadius * config.tubeScale * 0.75;
|
||
const dtFactor = dt / 16.67;
|
||
|
||
pieces.forEach(p=>{
|
||
if(p.isSleep && !shakeTime) return;
|
||
|
||
let gx = 0, gy = 0;
|
||
if(config.gravityEnabled){
|
||
gx = Math.sin(rotateAngle) * config.gravityStrength * p.mass;
|
||
gy = Math.cos(rotateAngle) * config.gravityStrength * p.mass;
|
||
}
|
||
|
||
p.vx += gx * dtFactor;
|
||
p.vy += gy * dtFactor;
|
||
|
||
p.x += p.vx * dtFactor;
|
||
p.y += p.vy * dtFactor;
|
||
p.rot += p.vrot * dtFactor;
|
||
|
||
p.vx *= 0.975;
|
||
p.vy *= 0.975;
|
||
p.vrot *= 0.975;
|
||
|
||
const velMag = Math.hypot(p.vx, p.vy);
|
||
const pieceStaticFric = config.baseStaticFriction + (1.0 / p.sizeScale) * 0.08;
|
||
const gravityDrive = Math.hypot(gx, gy);
|
||
|
||
if(velMag < pieceStaticFric && gravityDrive < pieceStaticFric){
|
||
p.vx = 0;
|
||
p.vy = 0;
|
||
p.vrot *= 0.6;
|
||
if(!shakeTime) p.isSleep = true;
|
||
}
|
||
|
||
const dist = Math.hypot(p.x, p.y);
|
||
if(dist > maxR){
|
||
const scale = maxR / dist;
|
||
p.x *= scale;
|
||
p.y *= scale;
|
||
const nx = p.x / maxR;
|
||
const ny = p.y / maxR;
|
||
const dot = p.vx * nx + p.vy * ny;
|
||
if(dot>0){
|
||
p.vx -= 1.15*dot*nx;
|
||
p.vy -= 1.15*dot*ny;
|
||
p.vx *=0.82;
|
||
p.vy *=0.82;
|
||
p.vrot *=0.85;
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
function drawGlassPiece(p){
|
||
ctx.save();
|
||
ctx.translate(p.x, p.y);
|
||
ctx.rotate(p.rot);
|
||
ctx.scale(p.sizeScale, p.sizeScale);
|
||
ctx.globalAlpha = p.alpha;
|
||
ctx.fillStyle = p.color;
|
||
|
||
const shard = p.shard;
|
||
const sd = shard.shapeData;
|
||
if(shard.type === 'ellipse'){
|
||
ctx.beginPath();
|
||
ctx.rotate(sd.rot);
|
||
ctx.ellipse(0,0, sd.rx, sd.ry,0,0,Math.PI*2);
|
||
ctx.fill();
|
||
}else if(shard.type === 'prim' && sd.primKind === 'circle'){
|
||
ctx.beginPath();
|
||
ctx.arc(0,0,sd.r,0,Math.PI*2);
|
||
ctx.fill();
|
||
}else{
|
||
ctx.beginPath();
|
||
ctx.moveTo(sd.pts[0].x, sd.pts[0].y);
|
||
for(let i=1;i<sd.pts.length;i++){
|
||
ctx.lineTo(sd.pts[i].x, sd.pts[i].y);
|
||
}
|
||
ctx.closePath();
|
||
ctx.fill();
|
||
}
|
||
ctx.restore();
|
||
}
|
||
|
||
function drawKaleido(){
|
||
ctx.clearRect(0,0,W,H);
|
||
const viewRadius = baseRadius * config.tubeScale;
|
||
|
||
ctx.save();
|
||
ctx.translate(cx,cy);
|
||
ctx.rotate(rotateAngle);
|
||
|
||
ctx.beginPath();
|
||
ctx.arc(0,0, viewRadius, 0, Math.PI*2);
|
||
ctx.clip();
|
||
|
||
const seg = Math.PI * 2 / config.prismDiv;
|
||
for(let s=0;s<config.prismDiv;s++){
|
||
ctx.save();
|
||
ctx.rotate(seg * s);
|
||
if(s%2===1){
|
||
ctx.scale(1,-1);
|
||
}
|
||
pieces.forEach(p=> drawGlassPiece(p));
|
||
ctx.restore();
|
||
}
|
||
ctx.restore();
|
||
|
||
ctx.save();
|
||
ctx.globalCompositeOperation = 'screen';
|
||
ctx.filter = 'blur(3.2px)';
|
||
ctx.globalAlpha = 0.20;
|
||
ctx.drawImage(canvas,0,0);
|
||
ctx.filter = 'none';
|
||
|
||
const grad = ctx.createRadialGradient(cx,cy, viewRadius*0.62, cx,cy, viewRadius);
|
||
grad.addColorStop(0,'rgba(0,0,0,0)');
|
||
grad.addColorStop(1,'rgba(0,0,0,0.48)');
|
||
ctx.globalCompositeOperation='multiply';
|
||
ctx.globalAlpha=1;
|
||
ctx.fillStyle = grad;
|
||
ctx.beginPath();
|
||
ctx.arc(cx,cy, viewRadius,0,Math.PI*2);
|
||
ctx.fill();
|
||
|
||
ctx.globalCompositeOperation='source-over';
|
||
ctx.fillStyle="#000000";
|
||
ctx.beginPath();
|
||
ctx.arc(cx,cy,baseRadius,0,Math.PI*2);
|
||
ctx.arc(cx,cy,viewRadius,0,Math.PI*2,true);
|
||
ctx.fill();
|
||
ctx.restore();
|
||
}
|
||
|
||
function getMouseAngle(e){
|
||
const rect = canvas.getBoundingClientRect();
|
||
const t = e.touches ? e.touches[0] : e;
|
||
const mx = t.clientX - rect.left - cx;
|
||
const my = t.clientY - rect.top - cy;
|
||
return Math.atan2(my,mx);
|
||
}
|
||
|
||
canvas.addEventListener('mousedown',(e)=>{
|
||
isDrag = true;
|
||
lastMouseAngle = getMouseAngle(e);
|
||
})
|
||
window.addEventListener('mousemove',(e)=>{
|
||
if(!isDrag) return;
|
||
const nowAngle = getMouseAngle(e);
|
||
rotateAngle += (nowAngle - lastMouseAngle);
|
||
lastMouseAngle = nowAngle;
|
||
pieces.forEach(p=>p.isSleep=false);
|
||
})
|
||
window.addEventListener('mouseup',()=>isDrag=false);
|
||
canvas.addEventListener('dblclick', startShake);
|
||
|
||
canvas.addEventListener('touchstart',(e)=>{
|
||
e.preventDefault();
|
||
isDrag=true;
|
||
lastMouseAngle = getMouseAngle(e);
|
||
})
|
||
canvas.addEventListener('touchmove',(e)=>{
|
||
e.preventDefault();
|
||
if(!isDrag) return;
|
||
const nowAngle = getMouseAngle(e);
|
||
rotateAngle += (nowAngle - lastMouseAngle);
|
||
lastMouseAngle = nowAngle;
|
||
pieces.forEach(p=>p.isSleep=false);
|
||
})
|
||
canvas.addEventListener('touchend',()=>isDrag=false);
|
||
|
||
let lastTap=0;
|
||
canvas.addEventListener('touchstart',()=>{
|
||
const now=Date.now();
|
||
if(now-lastTap<320) startShake();
|
||
lastTap=now;
|
||
})
|
||
|
||
const rangePieces = document.getElementById('rangePieces');
|
||
const rangePrism = document.getElementById('rangePrism');
|
||
const rangeTube = document.getElementById('rangeTube');
|
||
const rangeScaleMin = document.getElementById('rangeScaleMin');
|
||
const rangeScaleMax = document.getElementById('rangeScaleMax');
|
||
const rangeGravity = document.getElementById('rangeGravity');
|
||
const rangeFriction = document.getElementById('rangeFriction');
|
||
const checkGravity = document.getElementById('checkGravity');
|
||
|
||
const valPieces = document.getElementById('valPieces');
|
||
const valPrism = document.getElementById('valPrism');
|
||
const valTube = document.getElementById('valTube');
|
||
const valScaleMin = document.getElementById('valScaleMin');
|
||
const valScaleMax = document.getElementById('valScaleMax');
|
||
const valGravity = document.getElementById('valGravity');
|
||
const valFriction = document.getElementById('valFriction');
|
||
|
||
const btnApply = document.getElementById('btnApply');
|
||
const btnShake = document.getElementById('btnShake');
|
||
|
||
rangePieces.addEventListener('input',()=> valPieces.innerText = rangePieces.value);
|
||
rangePrism.addEventListener('input',()=> valPrism.innerText = rangePrism.value);
|
||
rangeTube.addEventListener('input',()=> valTube.innerText = Number(rangeTube.value).toFixed(2));
|
||
rangeScaleMin.addEventListener('input',()=> valScaleMin.innerText = Number(rangeScaleMin.value).toFixed(2)+'x');
|
||
rangeScaleMax.addEventListener('input',()=> valScaleMax.innerText = Number(rangeScaleMax.value).toFixed(2)+'x');
|
||
rangeGravity.addEventListener('input',()=> valGravity.innerText = Number(rangeGravity.value).toFixed(2));
|
||
rangeFriction.addEventListener('input',()=> valFriction.innerText = Number(rangeFriction.value).toFixed(2));
|
||
|
||
btnApply.addEventListener('click',()=>{
|
||
config.pieceCount = Number(rangePieces.value);
|
||
config.prismDiv = Number(rangePrism.value);
|
||
config.tubeScale = Number(rangeTube.value);
|
||
config.scaleMin = Number(rangeScaleMin.value);
|
||
config.scaleMax = Number(rangeScaleMax.value);
|
||
config.gravityEnabled = checkGravity.checked;
|
||
config.gravityStrength = Number(rangeGravity.value);
|
||
config.baseStaticFriction = Number(rangeFriction.value);
|
||
|
||
if(config.scaleMin>config.scaleMax){
|
||
const tmp = config.scaleMin;
|
||
config.scaleMin = config.scaleMax;
|
||
config.scaleMax = tmp;
|
||
}
|
||
resetPieces();
|
||
})
|
||
|
||
btnShake.addEventListener('click', startShake);
|
||
|
||
let lastTime=0;
|
||
function animate(time){
|
||
const dt = time-lastTime;
|
||
lastTime=time;
|
||
rotateAngle += (Math.random()-0.5)*0.0006;
|
||
if(shakeTime > 0) shakeTime -= dt;
|
||
updatePhysics(dt);
|
||
drawKaleido();
|
||
requestAnimationFrame(animate);
|
||
}
|
||
|
||
resetPieces();
|
||
requestAnimationFrame(animate);
|
||
</script>
|
||
</body>
|
||
</html>
|