876a3b8768
新增两个独立思想实验室: 1. 万花筒模拟器:支持可调参数物理万花筒效果,配置方案SQLite持久化 2. 闵可夫斯基画板:支持世界线绘制、洛伦兹变换、光锥因果判定 同时添加自动绑定实验室后端的逻辑,以及配套的单元测试与前端页面
734 lines
25 KiB
HTML
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> |