feat(thought-lab): 新增万花筒与闵可夫斯基画板两个思想实验室
新增两个独立思想实验室: 1. 万花筒模拟器:支持可调参数物理万花筒效果,配置方案SQLite持久化 2. 闵可夫斯基画板:支持世界线绘制、洛伦兹变换、光锥因果判定 同时添加自动绑定实验室后端的逻辑,以及配套的单元测试与前端页面
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user