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