feat(thought-lab): 新增万花筒与闵可夫斯基画板两个思想实验室

新增两个独立思想实验室:
1. 万花筒模拟器:支持可调参数物理万花筒效果,配置方案SQLite持久化
2. 闵可夫斯基画板:支持世界线绘制、洛伦兹变换、光锥因果判定
同时添加自动绑定实验室后端的逻辑,以及配套的单元测试与前端页面
This commit is contained in:
yangxiangyuan
2026-08-06 17:41:43 +08:00
parent ea379116cc
commit 876a3b8768
17 changed files with 9979 additions and 0 deletions
@@ -0,0 +1,512 @@
<!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>