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