Files
Toolbox/public/tools/thought_lab/labs/kaleidoscope/gemini-code-1786004109825.html
T
yangxiangyuan 876a3b8768 feat(thought-lab): 新增万花筒与闵可夫斯基画板两个思想实验室
新增两个独立思想实验室:
1. 万花筒模拟器:支持可调参数物理万花筒效果,配置方案SQLite持久化
2. 闵可夫斯基画板:支持世界线绘制、洛伦兹变换、光锥因果判定
同时添加自动绑定实验室后端的逻辑,以及配套的单元测试与前端页面
2026-08-06 17:41:43 +08:00

716 lines
25 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>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
user-select: none;
-webkit-user-select: none;
}
body {
background-color: #121214;
color: #d0d0d5;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
min-height: 100vh;
padding: 15px;
overflow-x: hidden;
}
/* 顶部控制面板 */
.control-panel {
background-color: #1d1d21;
border: 1px solid #33333b;
border-radius: 8px;
padding: 12px 16px;
max-width: 900px;
width: 100%;
display: flex;
flex-wrap: wrap;
gap: 12px 20px;
align-items: center;
justify-content: center;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
margin-bottom: 15px;
}
.control-group {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
}
.control-group label {
white-space: nowrap;
color: #aaa;
}
.control-group input[type="range"] {
width: 90px;
accent-color: #d4a359;
cursor: pointer;
}
.control-group input[type="checkbox"] {
accent-color: #d4a359;
cursor: pointer;
width: 16px;
height: 16px;
}
.val-disp {
display: inline-block;
min-width: 32px;
text-align: right;
color: #d4a359;
font-family: monospace;
font-size: 13px;
}
.btn-group {
display: flex;
gap: 10px;
width: 100%;
justify-content: center;
margin-top: 4px;
}
button {
background: linear-gradient(135deg, #3a3a42, #2a2a30);
color: #e5c185;
border: 1px solid #555560;
padding: 6px 16px;
border-radius: 4px;
cursor: pointer;
font-weight: bold;
font-size: 13px;
transition: all 0.2s ease;
}
button:hover {
background: linear-gradient(135deg, #4a4a54, #3a3a42);
border-color: #d4a359;
box-shadow: 0 0 8px rgba(212, 163, 89, 0.3);
}
button:active {
transform: scale(0.98);
}
/* 居中画布视口容器 */
.canvas-container {
position: relative;
display: flex;
justify-content: center;
align-items: center;
margin: auto;
/* 外层模拟万花筒木壳阴影效果 */
border-radius: 50%;
padding: 12px;
background: radial-gradient(circle, #3d2a1d 0%, #1a100a 80%, #0d0805 100%);
box-shadow:
0 0 0 4px #5c4028,
0 0 0 8px #26180e,
0 15px 35px rgba(0, 0, 0, 0.8),
inset 0 0 15px rgba(0, 0, 0, 0.9);
cursor: grab;
touch-action: none;
}
.canvas-container:active {
cursor: grabbing;
}
canvas {
display: block;
border-radius: 50%;
background-color: #050508;
}
/* 底部提示信息 */
.footer-tip {
margin-top: 15px;
font-size: 13px;
color: #777788;
letter-spacing: 1px;
text-align: center;
}
</style>
</head>
<body>
<!-- 控制面板 -->
<div class="control-panel">
<div class="control-group">
<label for="pCount">碎片数量:</label>
<input type="range" id="pCount" min="30" max="260" step="1" value="120">
<span class="val-disp" id="pCountVal">120</span>
</div>
<div class="control-group">
<label for="pSides">棱镜分割:</label>
<input type="range" id="pSides" min="3" max="12" step="1" value="6">
<span class="val-disp" id="pSidesVal">6</span>
</div>
<div class="control-group">
<label for="pAperture">桶宽孔径:</label>
<input type="range" id="pAperture" min="0.40" max="1.00" step="0.02" value="1.00">
<span class="val-disp" id="pApertureVal">1.00</span>
</div>
<div class="control-group">
<label for="pMinScale">最小倍率:</label>
<input type="range" id="pMinScale" min="0.2" max="1.2" step="0.05" value="0.50">
<span class="val-disp" id="pMinScaleVal">0.50</span>
</div>
<div class="control-group">
<label for="pMaxScale">最大倍率:</label>
<input type="range" id="pMaxScale" min="1.0" max="4.0" step="0.05" value="3.00">
<span class="val-disp" id="pMaxScaleVal">3.00</span>
</div>
<div class="control-group">
<label for="pGravity">重力强度:</label>
<input type="range" id="pGravity" min="0" max="0.30" step="0.01" value="0.07">
<span class="val-disp" id="pGravityVal">0.07</span>
</div>
<div class="control-group">
<label for="pFriction">静摩擦力:</label>
<input type="range" id="pFriction" min="0.02" max="0.20" step="0.01" value="0.11">
<span class="val-disp" id="pFrictionVal">0.11</span>
</div>
<div class="control-group">
<input type="checkbox" id="pEnableGravity">
<label for="pEnableGravity" style="color: #e5c185; cursor: pointer;">启用重力</label>
</div>
<div class="btn-group">
<button id="btnApply">应用设置</button>
<button id="btnShake">手动摇晃万花筒</button>
</div>
</div>
<!-- 画布容器与 Canvas -->
<div class="canvas-container" id="canvasContainer">
<canvas id="kaleidoscopeCanvas" width="600" height="600"></canvas>
</div>
<!-- 底部提示文字 -->
<div class="footer-tip">
按住鼠标拖动旋转镜筒 | 双击画面摇晃万花筒
</div>
<script>
// ==========================================
// 全局配置与复古色池定义
// ==========================================
const COLOR_POOL = [
"#e63946", "#f1faee", "#a8dadc", "#457b9d", "#1d3557",
"#f4a261", "#e76f51", "#2a9d8f", "#e9c46a", "#f72585",
"#7209b7", "#3f37c9", "#4cc9f0", "#d4a359", "#b5e2fa",
"#ed254e", "#f9dc5c", "#c2efb3", "#9b5de5", "#f15bb5"
];
// UI 参数对象(应用生效的值)
let CONFIG = {
count: 120,
sides: 6,
aperture: 1.0,
minScale: 0.5,
maxScale: 3.0,
gravity: 0.07,
friction: 0.11,
enableGravity: false
};
// DOM 元素获取
const canvas = document.getElementById('kaleidoscopeCanvas');
const ctx = canvas.getContext('2d');
const container = document.getElementById('canvasContainer');
// UI 控件元素
const inputs = {
count: document.getElementById('pCount'),
sides: document.getElementById('pSides'),
aperture: document.getElementById('pAperture'),
minScale: document.getElementById('pMinScale'),
maxScale: document.getElementById('pMaxScale'),
gravity: document.getElementById('pGravity'),
friction: document.getElementById('pFriction'),
enableGravity: document.getElementById('pEnableGravity')
};
// 绑定 Range 数值实时显示
Object.keys(inputs).forEach(key => {
const input = inputs[key];
if (input.type === 'range') {
const disp = document.getElementById(key + 'Val');
input.addEventListener('input', () => {
disp.textContent = parseFloat(input.value).toFixed(input.step.includes('.') ? input.step.split('.')[1].length : 0);
});
}
});
// 运行状态变量
let pieces = [];
let kaleidoscopeAngle = 0; // 万花筒镜筒旋转角度
let isDragging = false;
let lastMouseAngle = 0;
let shakeFrames = 0; // 摇晃动画持续帧数
// 物理视口大小参数
const CANVAS_SIZE = 600;
const RADIUS = CANVAS_SIZE / 2;
// ==========================================
// 碎片形状生成逻辑(符合硬性概率配比)
// ==========================================
function createPiece() {
const scale = CONFIG.minScale + Math.random() * (CONFIG.maxScale - CONFIG.minScale);
const baseSize = 8 * scale;
const randType = Math.random();
let shapeType = '';
let shapeData = {};
// 严格执行硬性概率配比
if (randType < 0.50) {
// 50%: 不规则多角碎片 (3-7边,碎玻璃)
shapeType = 'poly';
const sides = Math.floor(Math.random() * 5) + 3;
const verts = [];
for (let i = 0; i < sides; i++) {
const ang = (Math.PI * 2 / sides) * i + (Math.random() - 0.5) * 0.5;
const r = baseSize * (0.6 + Math.random() * 0.8);
verts.push({ x: Math.cos(ang) * r, y: Math.sin(ang) * r });
}
shapeData = { verts };
} else if (randType < 0.75) {
// 25%: 椭圆 / 扁片 (长短轴差距明显)
shapeType = 'ellipse';
shapeData = {
rx: baseSize * (1.2 + Math.random() * 0.8),
ry: baseSize * (0.3 + Math.random() * 0.4),
rot: Math.random() * Math.PI
};
} else if (randType < 0.85) {
// 10%: 近圆 / 轻微变形圆 (8顶点扰动)
shapeType = 'nearCircle';
const verts = [];
const sides = 8;
for (let i = 0; i < sides; i++) {
const ang = (Math.PI * 2 / sides) * i;
const r = baseSize * (0.85 + Math.random() * 0.3); // 小幅随机扰动
verts.push({ x: Math.cos(ang) * r, y: Math.sin(ang) * r });
}
shapeData = { verts };
} else if (randType < 0.95) {
// 10%: 水滴 / 圆角不规则片状 / 三角形 / 梯形 (内部三种子形态均等)
shapeType = 'mixedDrop';
const subType = Math.floor(Math.random() * 3);
shapeData = { subType, size: baseSize };
} else {
// 5%: 标准几何图形 (正三角形 / 完美正圆 / 正方形)
shapeType = 'prim';
const subType = Math.floor(Math.random() * 3);
shapeData = { subType, size: baseSize };
}
// 在有效孔径半径内随机分布初始位置
const currentApertureRadius = RADIUS * CONFIG.aperture * 0.85;
const spawnAng = Math.random() * Math.PI * 2;
const spawnDist = Math.random() * currentApertureRadius;
return {
x: Math.cos(spawnAng) * spawnDist,
y: Math.sin(spawnAng) * spawnDist,
vx: (Math.random() - 0.5) * 2,
vy: (Math.random() - 0.5) * 2,
angle: Math.random() * Math.PI * 2,
vRot: (Math.random() - 0.5) * 0.05,
scale: scale,
mass: scale, // 质量等于 sizeScale
color: COLOR_POOL[Math.floor(Math.random() * COLOR_POOL.length)],
alpha: 0.62 + Math.random() * (0.94 - 0.62), // 透明度 0.62-0.94
shapeType: shapeType,
shapeData: shapeData,
isSleeping: false
};
}
// 重新生成全部碎片列表
function resetPieces() {
pieces = [];
for (let i = 0; i < CONFIG.count; i++) {
pieces.push(createPiece());
}
}
// 唤醒所有碎片休眠状态
function wakeAllPieces() {
for (let p of pieces) {
p.isSleeping = false;
}
}
// 摇晃万花筒:赋予随机速度与角速度
function shakeKaleidoscope() {
for (let p of pieces) {
const speedMult = 8 + Math.random() * 12;
const ang = Math.random() * Math.PI * 2;
p.vx = Math.cos(ang) * speedMult;
p.vy = Math.sin(ang) * speedMult;
p.vRot = (Math.random() - 0.5) * 0.4;
p.isSleeping = false;
}
shakeFrames = 25; // 设置摇晃状态持续帧数
}
// ==========================================
// 物理引擎逻辑 (2D 物理更新)
// ==========================================
function updatePhysics() {
const boundRadius = RADIUS * CONFIG.aperture;
// 计算重力向量方向(重力方向跟随万花筒整体旋转角度)
const gx = CONFIG.enableGravity ? Math.sin(-kaleidoscopeAngle) * CONFIG.gravity : 0;
const gy = CONFIG.enableGravity ? Math.cos(-kaleidoscopeAngle) * CONFIG.gravity : 0;
for (let p of pieces) {
if (p.isSleeping) continue;
// 1. 重力驱动
p.vx += gx;
p.vy += gy;
// 2. 静摩擦力计算:小碎片叠加更高静摩擦 (基础 / scale)
const effectiveStaticFriction = CONFIG.friction * (1.2 / Math.max(0.2, p.scale));
const currentSpeed = Math.hypot(p.vx, p.vy);
const gravityForce = Math.hypot(gx, gy);
// 3. 休眠判断机制:速度 + 重力驱动力小于静摩擦阈值
if (CONFIG.enableGravity) {
if (currentSpeed < 0.08 && gravityForce < effectiveStaticFriction * 0.5) {
p.vx = 0;
p.vy = 0;
p.vRot = 0;
p.isSleeping = true;
continue;
}
} else {
if (currentSpeed < 0.05) {
p.vx = 0;
p.vy = 0;
p.vRot = 0;
p.isSleeping = true;
continue;
}
}
// 4. 动摩擦衰减 (速度与角速度同步衰减)
p.vx *= 0.96;
p.vy *= 0.96;
p.vRot *= 0.94;
// 位置与角度更新
p.x += p.vx;
p.y += p.vy;
p.angle += p.vRot;
// 5. 圆形边界碰撞与反弹阻尼
const distFromCenter = Math.hypot(p.x, p.y);
const maxDist = boundRadius - (p.scale * 6); // 预留碎片半径尺寸
if (distFromCenter > maxDist && maxDist > 0) {
// 法线向量
const nx = p.x / distFromCenter;
const ny = p.y / distFromCenter;
// 推回边界内
p.x = nx * maxDist;
p.y = ny * maxDist;
// 计算反射速度(带阻尼)
const dot = p.vx * nx + p.vy * ny;
if (dot > 0) {
const bounceDamping = 0.4;
p.vx = (p.vx - 2 * dot * nx) * bounceDamping;
p.vy = (p.vy - 2 * dot * ny) * bounceDamping;
p.vRot += (Math.random() - 0.5) * 0.1; // 碰撞时赋予微小旋转
}
}
}
if (shakeFrames > 0) shakeFrames--;
}
// ==========================================
// 碎片单体绘制逻辑
// ==========================================
function drawSinglePiece(p) {
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate(p.angle);
ctx.fillStyle = p.color;
ctx.globalAlpha = p.alpha;
const shape = p.shapeData;
ctx.beginPath();
if (p.shapeType === 'poly' || p.shapeType === 'nearCircle') {
ctx.moveTo(shape.verts[0].x, shape.verts[0].y);
for (let i = 1; i < shape.verts.length; i++) {
ctx.lineTo(shape.verts[i].x, shape.verts[i].y);
}
ctx.closePath();
} else if (p.shapeType === 'ellipse') {
ctx.rotate(shape.rot);
ctx.ellipse(0, 0, shape.rx, shape.ry, 0, 0, Math.PI * 2);
} else if (p.shapeType === 'mixedDrop') {
if (shape.subType === 0) {
// 水滴形态
ctx.arc(0, shape.size * 0.3, shape.size * 0.5, 0, Math.PI);
ctx.lineTo(0, -shape.size * 0.8);
ctx.closePath();
} else if (shape.subType === 1) {
// 简易三角形
ctx.moveTo(0, -shape.size);
ctx.lineTo(shape.size * 0.8, shape.size * 0.8);
ctx.lineTo(-shape.size * 0.8, shape.size * 0.8);
ctx.closePath();
} else {
// 简易梯形
ctx.moveTo(-shape.size * 0.5, -shape.size * 0.6);
ctx.lineTo(shape.size * 0.5, -shape.size * 0.6);
ctx.lineTo(shape.size * 0.9, shape.size * 0.6);
ctx.lineTo(-shape.size * 0.9, shape.size * 0.6);
ctx.closePath();
}
} else if (p.shapeType === 'prim') {
if (shape.subType === 0) {
// 正三角形
const r = shape.size;
ctx.moveTo(0, -r);
ctx.lineTo(r * 0.866, r * 0.5);
ctx.lineTo(-r * 0.866, r * 0.5);
ctx.closePath();
} else if (shape.subType === 1) {
// 完美正圆
ctx.arc(0, 0, shape.size * 0.6, 0, Math.PI * 2);
} else {
// 正方形
const s = shape.size * 0.9;
ctx.rect(-s / 2, -s / 2, s, s);
}
}
ctx.fill();
// 细微高光边缘强化玻璃质感
ctx.strokeStyle = "rgba(255, 255, 255, 0.25)";
ctx.lineWidth = 0.5;
ctx.stroke();
ctx.restore();
}
// ==========================================
// 万花筒渲染与镜像矩阵逻辑
// ==========================================
function render() {
// 清空画布
ctx.clearRect(0, 0, CANVAS_SIZE, CANVAS_SIZE);
ctx.save();
// 将原点平移至画布中心
ctx.translate(RADIUS, RADIUS);
// 应用整体万花筒旋转
ctx.rotate(kaleidoscopeAngle);
const sectorAngle = (Math.PI * 2) / CONFIG.sides;
// 根据棱镜分割份数进行镜像复制绘制
for (let i = 0; i < CONFIG.sides; i++) {
ctx.save();
// 旋转到对应的扇区角度
ctx.rotate(i * sectorAngle);
// 奇数扇区做 Y 轴翻转,实现镜像对称
if (i % 2 === 1) {
ctx.scale(1, -1);
}
// 在当前扇区内绘制全部原始实体碎片
for (let p of pieces) {
drawSinglePiece(p);
}
ctx.restore();
}
ctx.restore();
// 绘制Screen混合模式弱模糊叠层(模拟玻璃光晕)
ctx.save();
ctx.globalCompositeOperation = 'screen';
const blurGlow = ctx.createRadialGradient(RADIUS, RADIUS, RADIUS * 0.2, RADIUS, RADIUS, RADIUS);
blurGlow.addColorStop(0, 'rgba(255, 255, 255, 0.05)');
blurGlow.addColorStop(0.7, 'rgba(180, 200, 255, 0.03)');
blurGlow.addColorStop(1, 'rgba(0, 0, 0, 0)');
ctx.fillStyle = blurGlow;
ctx.beginPath();
ctx.arc(RADIUS, RADIUS, RADIUS, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
// 绘制画布外圈黑色遮罩,仅保留当前孔径的圆形视窗
ctx.save();
ctx.fillStyle = '#050508';
ctx.beginPath();
// 顺时针绘制外围方形
ctx.rect(0, 0, CANVAS_SIZE, CANVAS_SIZE);
// 逆时针绘制内圈圆形孔径
const currentRadius = RADIUS * CONFIG.aperture;
ctx.arc(RADIUS, RADIUS, currentRadius, 0, Math.PI * 2, true);
ctx.closePath();
ctx.fill();
// 视窗边框阴影线条
ctx.strokeStyle = '#120d0a';
ctx.lineWidth = 3;
ctx.beginPath();
ctx.arc(RADIUS, RADIUS, currentRadius, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
}
// 主循环动画
function loop() {
updatePhysics();
render();
requestAnimationFrame(loop);
}
// ==========================================
// 交互逻辑 (鼠标拖拽旋转 / 双击 / 触屏支持)
// ==========================================
// 计算鼠标相对画布中心的角度
function getPosAngle(e) {
const rect = canvas.getBoundingClientRect();
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
const clientY = e.touches ? e.touches[0].clientY : e.clientY;
const x = clientX - rect.left - rect.width / 2;
const y = clientY - rect.top - rect.height / 2;
return Math.atan2(y, x);
}
function startDrag(e) {
isDragging = true;
lastMouseAngle = getPosAngle(e);
wakeAllPieces();
}
function dragMove(e) {
if (!isDragging) return;
const currentAngle = getPosAngle(e);
let delta = currentAngle - lastMouseAngle;
// 处理角度突变越界 (-PI 到 PI)
if (delta > Math.PI) delta -= Math.PI * 2;
if (delta < -Math.PI) delta += Math.PI * 2;
kaleidoscopeAngle += delta;
lastMouseAngle = currentAngle;
// 拖拽旋转时唤醒碎片物理计算
wakeAllPieces();
}
function stopDrag() {
isDragging = false;
}
// 事件监听绑定
container.addEventListener('mousedown', startDrag);
window.addEventListener('mousemove', dragMove);
window.addEventListener('mouseup', stopDrag);
// 触摸屏事件支持
container.addEventListener('touchstart', (e) => {
startDrag(e);
detectTouchTap();
}, { passive: true });
window.addEventListener('touchmove', (e) => {
dragMove(e);
}, { passive: true });
window.addEventListener('touchend', stopDrag);
// 双击画布摇晃
container.addEventListener('dblclick', () => {
shakeKaleidoscope();
});
// 触屏双击判定
let lastTouchTime = 0;
function detectTouchTap() {
const now = Date.now();
if (now - lastTouchTime < 300) {
shakeKaleidoscope();
}
lastTouchTime = now;
}
// UI 按钮绑定:【应用设置】
document.getElementById('btnApply').addEventListener('click', () => {
let minS = parseFloat(inputs.minScale.value);
let maxS = parseFloat(inputs.maxScale.value);
// 参数校验:最小倍率不能大于最大倍率
if (minS > maxS) {
minS = maxS;
inputs.minScale.value = minS;
document.getElementById('pMinScaleVal').textContent = minS.toFixed(2);
}
// 更新配置对象
CONFIG.count = parseInt(inputs.count.value);
CONFIG.sides = parseInt(inputs.sides.value);
CONFIG.aperture = parseFloat(inputs.aperture.value);
CONFIG.minScale = minS;
CONFIG.maxScale = maxS;
CONFIG.gravity = parseFloat(inputs.gravity.value);
CONFIG.friction = parseFloat(inputs.friction.value);
CONFIG.enableGravity = inputs.enableGravity.checked;
// 重新生成全部碎片
resetPieces();
wakeAllPieces();
});
// UI 按钮绑定:【手动摇晃万花筒】
document.getElementById('btnShake').addEventListener('click', () => {
shakeKaleidoscope();
});
// ==========================================
// 初始化运行
// ==========================================
resetPieces();
loop();
</script>
</body>
</html>