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

734 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, user-scalable=no">
<title>老式物理万花筒模拟器</title>
<style>
* {
box-sizing: border-box;
user-select: none;
-webkit-user-select: none;
margin: 0;
padding: 0;
}
body {
background-color: #121214;
color: #d1d1d6;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
min-height: 100vh;
padding: 15px;
overflow-x: hidden;
}
/* 控制面板样式 */
.control-panel {
background: #1e1e24;
border: 1px solid #2d2d38;
border-radius: 12px;
padding: 12px 18px;
max-width: 900px;
width: 100%;
display: flex;
flex-wrap: wrap;
gap: 12px 20px;
align-items: center;
justify-content: center;
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
margin-bottom: 15px;
}
.control-group {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
}
.control-group label {
color: #a0a0ab;
white-space: nowrap;
}
.control-group input[type="range"] {
width: 90px;
accent-color: #d4af37;
cursor: pointer;
}
.control-group .val-dis {
display: inline-block;
min-width: 34px;
text-align: right;
color: #e5c158;
font-family: monospace;
font-size: 12px;
}
.checkbox-group {
cursor: pointer;
}
.checkbox-group input {
cursor: pointer;
accent-color: #d4af37;
}
.btn-group {
display: flex;
gap: 10px;
}
button {
background: linear-gradient(135deg, #3a3a48, #2a2a36);
color: #f0f0f5;
border: 1px solid #4a4a5a;
padding: 6px 14px;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 2px 6px rgba(0,0,0,0.3);
}
button:hover {
background: linear-gradient(135deg, #4a4a5a, #3a3a48);
border-color: #d4af37;
color: #fff;
}
button:active {
transform: translateY(1px);
}
button.btn-primary {
background: linear-gradient(135deg, #8a6d20, #5c4711);
border-color: #d4af37;
color: #fff;
}
button.btn-primary:hover {
background: linear-gradient(135deg, #a68429, #735917);
}
/* 画布容器及木壳阴影模拟 */
.canvas-container {
position: relative;
display: flex;
justify-content: center;
align-items: center;
padding: 12px;
background: radial-gradient(circle, #2c1d11 0%, #150d07 100%);
border-radius: 50%;
box-shadow:
0 0 0 6px #3d2817,
0 0 0 10px #1a1009,
inset 0 0 30px rgba(0,0,0,0.9),
0 12px 36px rgba(0,0,0,0.8);
margin: 5px 0 15px 0;
touch-action: none;
}
canvas {
display: block;
border-radius: 50%;
cursor: grab;
background-color: #050508;
}
canvas:active {
cursor: grabbing;
}
/* 底部提示 */
.footer-tip {
font-size: 12px;
color: #72727e;
letter-spacing: 0.5px;
text-align: center;
}
</style>
</head>
<body>
<!-- 控制面板 -->
<div class="control-panel">
<div class="control-group">
<label for="cnt">碎片数量</label>
<input type="range" id="cnt" min="30" max="260" value="120" step="1">
<span class="val-dis" id="cnt-val">120</span>
</div>
<div class="control-group">
<label for="seg">棱镜分割</label>
<input type="range" id="seg" min="3" max="12" value="6" step="1">
<span class="val-dis" id="seg-val">6</span>
</div>
<div class="control-group">
<label for="aperture">桶宽孔径</label>
<input type="range" id="aperture" min="0.40" max="1.00" value="1.00" step="0.02">
<span class="val-dis" id="aperture-val">1.00</span>
</div>
<div class="control-group">
<label for="minScale">最小倍率</label>
<input type="range" id="minScale" min="0.20" max="1.20" value="0.50" step="0.05">
<span class="val-dis" id="minScale-val">0.50</span>
</div>
<div class="control-group">
<label for="maxScale">最大倍率</label>
<input type="range" id="maxScale" min="1.00" max="4.00" value="3.00" step="0.05">
<span class="val-dis" id="maxScale-val">3.00</span>
</div>
<div class="control-group">
<label for="grav">重力强度</label>
<input type="range" id="grav" min="0.00" max="0.30" value="0.07" step="0.01">
<span class="val-dis" id="grav-val">0.07</span>
</div>
<div class="control-group">
<label for="fric">静摩擦</label>
<input type="range" id="fric" min="0.02" max="0.20" value="0.11" step="0.01">
<span class="val-dis" id="fric-val">0.11</span>
</div>
<div class="control-group checkbox-group">
<input type="checkbox" id="enableGrav">
<label for="enableGrav">启用重力</label>
</div>
<div class="btn-group">
<button class="btn-primary" id="btnApply">应用设置</button>
<button id="btnShake">手动摇晃万花筒</button>
</div>
</div>
<!-- 画布容器 -->
<div class="canvas-container">
<canvas id="kaleidoCanvas" width="600" height="600"></canvas>
</div>
<!-- 底部提示文字 -->
<div class="footer-tip">按住鼠标拖动旋转镜筒 | 双击画面摇晃万花筒</div>
<script>
// --- 1. 全局配置与色池定义 ---
const CANVAS_SIZE = 600;
const CENTER = CANVAS_SIZE / 2;
const BASE_RADIUS = 280; // 基础视窗半径
// 复古宝石/玻璃暗色调与亮色调色池
const VINTAGE_PALETTE = [
"#e63946", "#f1faee", "#a8dadc", "#457b9d", "#1d3557",
"#d4af37", "#2a9d8f", "#e76f51", "#f4a261", "#e9c46a",
"#9b5de5", "#f15bb5", "#00bbf9", "#00f5d4", "#ff006e",
"#8338ec", "#3a86ff", "#fb5607", "#ff006e", "#38b000"
];
// 运行时引擎参数 (应用设置时更新)
let config = {
count: 120,
segments: 6,
aperture: 1.00,
minScale: 0.50,
maxScale: 3.00,
gravity: 0.07,
staticFriction: 0.11,
enableGravity: false
};
// 万花筒旋转与摇晃状态
let state = {
rotation: 0, // 整体旋转弧度
shakeTimer: 0, // 摇晃倒计时
shakeIntensity: 0 // 摇晃强度
};
let pieces = [];
const canvas = document.getElementById('kaleidoCanvas');
const ctx = canvas.getContext('2d');
// --- 2. 碎片生成与物理逻辑 ---
/**
* 严格按照概率配比生成碎片形状路径
*/
function generatePieceGeometry(scale) {
const baseSize = 8 * scale;
const rand = Math.random();
let shapeType = '';
let pathData = [];
if (rand < 0.50) {
// 50%: 不规则多角碎片 (poly: 3-7边,带扰动)
shapeType = 'poly';
const sides = Math.floor(Math.random() * 5) + 3;
for (let i = 0; i < sides; i++) {
const angle = (i / sides) * Math.PI * 2;
const dist = baseSize * (0.5 + Math.random() * 0.8);
pathData.push({
x: Math.cos(angle) * dist,
y: Math.sin(angle) * dist
});
}
} else if (rand < 0.75) {
// 25%: 椭圆/扁片 (ellipse: 明显长短轴)
shapeType = 'ellipse';
const rx = baseSize * (0.8 + Math.random() * 0.8);
const ry = baseSize * (0.2 + Math.random() * 0.3); // 长短轴差距明显
pathData = { rx, ry, rot: Math.random() * Math.PI };
} else if (rand < 0.85) {
// 10%: 近圆 (nearCircle: 8顶点,微扰动)
shapeType = 'nearCircle';
const pts = 8;
for (let i = 0; i < pts; i++) {
const angle = (i / pts) * Math.PI * 2;
const dist = baseSize * (0.85 + Math.random() * 0.3); // 小幅扰动
pathData.push({
x: Math.cos(angle) * dist,
y: Math.sin(angle) * dist
});
}
} else if (rand < 0.95) {
// 10%: mixedDrop (水滴/三角/梯形 三选一)
const subRand = Math.random();
if (subRand < 0.33) {
shapeType = 'drop'; // 水滴
pathData = { r: baseSize * 0.7, length: baseSize * 1.5 };
} else if (subRand < 0.66) {
shapeType = 'simpleTriangle'; // 简易三角
pathData = [
{ x: 0, y: -baseSize },
{ x: baseSize * (0.6 + Math.random()*0.4), y: baseSize },
{ x: -baseSize * (0.6 + Math.random()*0.4), y: baseSize }
];
} else {
shapeType = 'trapezoid'; // 简易梯形
const topW = baseSize * 0.5;
const botW = baseSize * 1.1;
const h = baseSize * 0.8;
pathData = [
{ x: -topW, y: -h },
{ x: topW, y: -h },
{ x: botW, y: h },
{ x: -botW, y: h }
];
}
} else {
// 5%: 标准几何图形 (prim: 正三角/完美正圆/正方形 三选一均等)
const subRand = Math.random();
if (subRand < 0.333) {
shapeType = 'regularTriangle';
for(let i=0; i<3; i++) {
const a = (i/3) * Math.PI * 2 - Math.PI/2;
pathData.push({ x: Math.cos(a)*baseSize*1.1, y: Math.sin(a)*baseSize*1.1 });
}
} else if (subRand < 0.666) {
shapeType = 'perfectCircle';
pathData = { r: baseSize * 0.8 };
} else {
shapeType = 'square';
const s = baseSize * 0.75;
pathData = [
{x: -s, y: -s}, {x: s, y: -s}, {x: s, y: s}, {x: -s, y: s}
];
}
}
return { shapeType, pathData };
}
/**
* 重置并重新生成全部实体碎片
*/
function resetPieces() {
pieces = [];
const activeRadius = BASE_RADIUS * config.aperture;
for (let i = 0; i < config.count; i++) {
// 在扇区/圆形孔径内均匀随机分布
const r = Math.sqrt(Math.random()) * (activeRadius - 15);
const theta = Math.random() * Math.PI * 2;
const scale = config.minScale + Math.random() * (config.maxScale - config.minScale);
const { shapeType, pathData } = generatePieceGeometry(scale);
// 透明度范围:0.62 - 0.94
const alpha = 0.62 + Math.random() * (0.94 - 0.62);
const color = VINTAGE_PALETTE[Math.floor(Math.random() * VINTAGE_PALETTE.length)];
pieces.push({
x: Math.cos(theta) * r,
y: Math.sin(theta) * r,
vx: (Math.random() - 0.5) * 1.5,
vy: (Math.random() - 0.5) * 1.5,
angle: Math.random() * Math.PI * 2,
vAngle: (Math.random() - 0.5) * 0.05,
scale: scale,
mass: scale, // 质量正比于缩放倍率
shapeType: shapeType,
pathData: pathData,
color: color,
alpha: alpha,
isSleeping: false
});
}
}
/**
* 唤醒所有休眠碎片
*/
function wakeAllPieces() {
for (let i = 0; i < pieces.length; i++) {
pieces[i].isSleeping = false;
}
}
/**
* 触发摇晃逻辑
*/
function triggerShake() {
state.shakeTimer = 35; // 持续约 35 帧
state.shakeIntensity = 12.0;
wakeAllPieces();
for (let i = 0; i < pieces.length; i++) {
const p = pieces[i];
p.vx += (Math.random() - 0.5) * 16;
p.vy += (Math.random() - 0.5) * 16;
p.vAngle += (Math.random() - 0.5) * 0.4;
}
}
/**
* 物理引擎更新 (仅对原始碎片生效)
*/
function updatePhysics() {
const activeRadius = BASE_RADIUS * config.aperture;
// 计算世界重力在碎片局部坐标系下的方向 (考虑到筒体旋转)
// 当筒旋转角度为 state.rotation 时,重力在视窗本地坐标系的向量:
const gX = config.enableGravity ? Math.sin(state.rotation) * config.gravity : 0;
const gY = config.enableGravity ? Math.cos(state.rotation) * config.gravity : 0;
// 处理摇晃衰减
if (state.shakeTimer > 0) {
state.shakeTimer--;
}
for (let i = 0; i < pieces.length; i++) {
const p = pieces[i];
if (p.isSleeping) continue;
// 叠加重力加速度
p.vx += gX;
p.vy += gY;
// 动摩擦衰减 (空气/接触阻尼)
p.vx *= 0.96;
p.vy *= 0.96;
p.vAngle *= 0.94;
// 计算小碎片的额外静摩擦:大碎片更容易滑动,小碎片更容易卡住
// scale 越小,effectiveStaticFriction 越高
const sizeFactor = Math.max(0.2, 2.0 - p.scale);
const effectiveStaticFriction = config.staticFriction * (1.0 + sizeFactor * 0.8);
const speed = Math.hypot(p.vx, p.vy);
const gravForce = Math.hypot(gX, gY);
// 休眠机制判定:当速度和驱动力都小于静摩擦阈值且不在摇晃时进入休眠
if (speed + gravForce < effectiveStaticFriction * 0.35 && state.shakeTimer <= 0) {
p.vx = 0;
p.vy = 0;
p.vAngle = 0;
p.isSleeping = true;
continue;
}
// 位置与旋转更新
p.x += p.vx;
p.y += p.vy;
p.angle += p.vAngle;
// 边界碰撞检测 (圆形活动边界)
const distFromCenter = Math.hypot(p.x, p.y);
const maxAllowedDist = activeRadius - (p.scale * 6); // 预留碎片半径大小
if (distFromCenter > maxAllowedDist && maxAllowedDist > 0) {
// 推回边界
const nx = p.x / distFromCenter;
const ny = p.y / distFromCenter;
p.x = nx * maxAllowedDist;
p.y = ny * maxAllowedDist;
// 速度沿着法线方向反弹 (带有弹性/阻尼系数)
const dot = p.vx * nx + p.vy * ny;
if (dot > 0) {
const bounce = 0.45;
p.vx -= (1 + bounce) * dot * nx;
p.vy -= (1 + bounce) * dot * ny;
}
}
}
}
// --- 3. 绘制逻辑 (矩阵镜像变换) ---
/**
* 绘制单个碎片实体
*/
function drawPieceShape(p) {
ctx.fillStyle = p.color;
ctx.globalAlpha = p.alpha;
ctx.beginPath();
const shape = p.shapeType;
const data = p.pathData;
if (shape === 'ellipse') {
ctx.ellipse(0, 0, data.rx, data.ry, data.rot, 0, Math.PI * 2);
} else if (shape === 'drop') {
ctx.arc(0, 0, data.r, 0, Math.PI);
ctx.lineTo(0, -data.length);
ctx.closePath();
} else if (shape === 'perfectCircle') {
ctx.arc(0, 0, data.r, 0, Math.PI * 2);
} else if (Array.isArray(data)) {
ctx.moveTo(data[0].x, data[0].y);
for (let i = 1; i < data.length; i++) {
ctx.lineTo(data[i].x, data[i].y);
}
ctx.closePath();
}
ctx.fill();
// 细微高光轮廓增强质感
ctx.lineWidth = 0.5;
ctx.strokeStyle = "rgba(255,255,255,0.25)";
ctx.stroke();
}
/**
* 渲染主循环
*/
function render() {
ctx.clearRect(0, 0, CANVAS_SIZE, CANVAS_SIZE);
ctx.save();
ctx.translate(CENTER, CENTER);
// 1. 万花筒整体拖拽旋转角度
ctx.rotate(state.rotation);
// 摇晃时的视窗震动效果
if (state.shakeTimer > 0) {
const shakeX = (Math.random() - 0.5) * state.shakeIntensity;
const shakeY = (Math.random() - 0.5) * state.shakeIntensity;
ctx.translate(shakeX, shakeY);
}
// 2. 棱镜分割与反射绘制
const segAngle = (Math.PI * 2) / config.segments;
for (let i = 0; i < config.segments; i++) {
ctx.save();
// 旋转到对应的扇区
ctx.rotate(i * segAngle);
// 奇数扇区做 Y 轴翻转,实现镜像对称
if (i % 2 === 1) {
ctx.scale(1, -1);
}
// 在扇区内绘制所有原始碎片
for (let j = 0; j < pieces.length; j++) {
const p = pieces[j];
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate(p.angle);
drawPieceShape(p);
ctx.restore();
}
ctx.restore();
}
ctx.restore();
// 3. Screen 混合模式弱模糊叠层 (模拟万花筒前端玻璃光晕与微弱漫反射)
ctx.save();
ctx.globalCompositeOperation = 'screen';
const glassGlow = ctx.createRadialGradient(CENTER, CENTER, 50, CENTER, CENTER, BASE_RADIUS);
glassGlow.addColorStop(0, 'rgba(255, 255, 255, 0.08)');
glassGlow.addColorStop(0.75, 'rgba(200, 220, 255, 0.03)');
glassGlow.addColorStop(1, 'rgba(0, 0, 0, 0)');
ctx.fillStyle = glassGlow;
ctx.beginPath();
ctx.arc(CENTER, CENTER, BASE_RADIUS, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
// 4. 外圈黑色遮罩 (限制视窗为圆形孔径)
ctx.save();
ctx.beginPath();
ctx.arc(CENTER, CENTER, CANVAS_SIZE, 0, Math.PI * 2, false);
// 逆时针裁切内部视窗 (利用奇偶填充法则/剪裁)
const currentApertureRadius = BASE_RADIUS * config.aperture;
ctx.arc(CENTER, CENTER, currentApertureRadius, 0, Math.PI * 2, true);
ctx.fillStyle = '#0a0a0d';
ctx.fill();
// 视窗孔径内边缘暗角
ctx.beginPath();
ctx.arc(CENTER, CENTER, currentApertureRadius, 0, Math.PI * 2);
ctx.lineWidth = 6;
ctx.strokeStyle = 'rgba(0,0,0,0.6)';
ctx.stroke();
ctx.restore();
}
/**
* 帧循环
*/
function loop() {
updatePhysics();
render();
requestAnimationFrame(loop);
}
// --- 4. 交互控制逻辑 ---
let isDragging = false;
let lastMouseAngle = 0;
let lastTouchTime = 0;
function getAngle(clientX, clientY) {
const rect = canvas.getBoundingClientRect();
const x = clientX - rect.left - rect.width / 2;
const y = clientY - rect.top - rect.height / 2;
return Math.atan2(y, x);
}
// 鼠标事件
canvas.addEventListener('mousedown', (e) => {
isDragging = true;
lastMouseAngle = getAngle(e.clientX, e.clientY);
wakeAllPieces();
});
window.addEventListener('mousemove', (e) => {
if (!isDragging) return;
const currentAngle = getAngle(e.clientX, e.clientY);
let delta = currentAngle - lastMouseAngle;
// 处理跨越 -PI / PI 边界突变
if (delta > Math.PI) delta -= Math.PI * 2;
if (delta < -Math.PI) delta += Math.PI * 2;
state.rotation += delta;
lastMouseAngle = currentAngle;
wakeAllPieces();
});
window.addEventListener('mouseup', () => { isDragging = false; });
canvas.addEventListener('dblclick', () => {
triggerShake();
});
// 触摸事件 (移动端支持)
canvas.addEventListener('touchstart', (e) => {
if (e.touches.length === 1) {
isDragging = true;
const touch = e.touches[0];
lastMouseAngle = getAngle(touch.clientX, touch.clientY);
wakeAllPieces();
// 双击摇晃判定
const now = Date.now();
if (now - lastTouchTime < 300) {
triggerShake();
}
lastTouchTime = now;
}
}, { passive: true });
canvas.addEventListener('touchmove', (e) => {
if (!isDragging || e.touches.length !== 1) return;
const touch = e.touches[0];
const currentAngle = getAngle(touch.clientX, touch.clientY);
let delta = currentAngle - lastMouseAngle;
if (delta > Math.PI) delta -= Math.PI * 2;
if (delta < -Math.PI) delta += Math.PI * 2;
state.rotation += delta;
lastMouseAngle = currentAngle;
wakeAllPieces();
}, { passive: true });
canvas.addEventListener('touchend', () => { isDragging = false; });
// --- 5. UI 控件绑定 ---
// 简易 UI 实时数值显示联动
const inputs = ['cnt', 'seg', 'aperture', 'minScale', 'maxScale', 'grav', 'fric'];
inputs.forEach(id => {
const el = document.getElementById(id);
const valDis = document.getElementById(id + '-val');
el.addEventListener('input', () => {
valDis.textContent = el.value;
});
});
// 【应用设置】按钮
document.getElementById('btnApply').addEventListener('click', () => {
let minS = parseFloat(document.getElementById('minScale').value);
let maxS = parseFloat(document.getElementById('maxScale').value);
// 参数校验:最小倍率不能大于最大倍率
if (minS > maxS) {
const temp = minS;
minS = maxS;
maxS = temp;
document.getElementById('minScale').value = minS.toFixed(2);
document.getElementById('maxScale').value = maxS.toFixed(2);
document.getElementById('minScale-val').textContent = minS.toFixed(2);
document.getElementById('maxScale-val').textContent = maxS.toFixed(2);
}
config.count = parseInt(document.getElementById('cnt').value);
config.segments = parseInt(document.getElementById('seg').value);
config.aperture = parseFloat(document.getElementById('aperture').value);
config.minScale = minS;
config.maxScale = maxS;
config.gravity = parseFloat(document.getElementById('grav').value);
config.staticFriction = parseFloat(document.getElementById('fric').value);
config.enableGravity = document.getElementById('enableGrav').checked;
resetPieces();
});
// 【手动摇晃万花筒】按钮
document.getElementById('btnShake').addEventListener('click', () => {
triggerShake();
});
// --- 6. 初始化运行 ---
resetPieces();
loop();
</script>
</body>
</html>