feat(thought-lab): 新增万花筒与闵可夫斯基画板两个思想实验室
新增两个独立思想实验室: 1. 万花筒模拟器:支持可调参数物理万花筒效果,配置方案SQLite持久化 2. 闵可夫斯基画板:支持世界线绘制、洛伦兹变换、光锥因果判定 同时添加自动绑定实验室后端的逻辑,以及配套的单元测试与前端页面
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
// 单元测试:kaleidoscope lab 后端(sqlite 方案 CRUD + 路由)
|
||||
// 运行:node dev_test_scripts/unit/test_kaleidoscope_db.js
|
||||
// 说明:直接调用 db.js 验证 CRUD;再挂载 express 验证路由层(无上游守卫,仅测路由本身)
|
||||
const path = require('path')
|
||||
const http = require('http')
|
||||
const express = require(path.join(process.cwd(), 'node_modules', 'express'))
|
||||
const db = require(path.join(process.cwd(), 'src', 'server', 'thought_lab', 'labs', 'kaleidoscope', 'db.js'))
|
||||
const kaleido = require(path.join(process.cwd(), 'src', 'server', 'thought_lab', 'labs', 'kaleidoscope', 'index.js'))
|
||||
|
||||
let fail = 0
|
||||
const check = (name, cond, extra) => {
|
||||
console.log(`${cond ? 'PASS' : 'FAIL'} - ${name}${extra ? ' | ' + extra : ''}`)
|
||||
if (!cond) fail++
|
||||
}
|
||||
|
||||
// ---- 1) db 层 CRUD ----
|
||||
const created = db.createScheme({ name: '测试方案A', params: { segments: 8, mirror: true }, seed: 42 })
|
||||
check('create 返回 id 与北京时间戳', !!created.id && /\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/.test(created.created_at), JSON.stringify(created.created_at))
|
||||
const updated = db.updateScheme(created.id, { name: '测试方案A改', params: { segments: 12 }, seed: 43 })
|
||||
check('update 生效', updated && updated.name === '测试方案A改' && updated.params.segments === 12)
|
||||
const listed = db.listSchemes()
|
||||
check('list 包含新建方案', listed.some(s => s.id === created.id))
|
||||
const got = db.getScheme(created.id)
|
||||
check('get 单条一致', got && got.seed === 43)
|
||||
|
||||
// ---- 2) 路由层 ----
|
||||
const app = express()
|
||||
app.use(express.json())
|
||||
kaleido.bindRoutes(app)
|
||||
const server = app.listen(0, async () => {
|
||||
const port = server.address().port
|
||||
const req = (method, p, body) => new Promise((resolve, reject) => {
|
||||
const data = body ? JSON.stringify(body) : null
|
||||
const r = http.request({ host: '127.0.0.1', port, path: p, method, headers: data ? { 'Content-Type': 'application/json' } : {} }, res => {
|
||||
let b = ''
|
||||
res.on('data', c => { b += c })
|
||||
res.on('end', () => resolve({ status: res.statusCode, body: b }))
|
||||
})
|
||||
r.on('error', reject)
|
||||
if (data) r.write(data)
|
||||
r.end()
|
||||
})
|
||||
|
||||
try {
|
||||
const m = await req('GET', '/api/thought_lab/kaleidoscope/meta')
|
||||
check('meta 返回 ui_mode', m.status === 200 && JSON.parse(m.body).ui_mode === 'debug', m.body)
|
||||
|
||||
const c = await req('POST', '/api/thought_lab/kaleidoscope/schemes', { name: '路由方案', params: { tube: 0.5 }, seed: 7 })
|
||||
const cj = JSON.parse(c.body)
|
||||
check('POST 创建成功', c.status === 200 && cj.ok && cj.scheme.id > 0)
|
||||
|
||||
const bad = await req('POST', '/api/thought_lab/kaleidoscope/schemes', { name: '', params: {} })
|
||||
check('空名称 400', bad.status === 400)
|
||||
|
||||
const u = await req('PUT', `/api/thought_lab/kaleidoscope/schemes/${cj.scheme.id}`, { name: '路由方案2', params: { tube: 0.6 }, seed: 8 })
|
||||
check('PUT 更新成功', u.status === 200 && JSON.parse(u.body).scheme.name === '路由方案2')
|
||||
|
||||
const l = await req('GET', '/api/thought_lab/kaleidoscope/schemes')
|
||||
check('GET 列表包含路由方案', JSON.parse(l.body).schemes.some(s => s.id === cj.scheme.id))
|
||||
|
||||
const d = await req('DELETE', `/api/thought_lab/kaleidoscope/schemes/${cj.scheme.id}`)
|
||||
check('DELETE 成功', d.status === 200)
|
||||
} catch (e) {
|
||||
fail++
|
||||
console.log('FAIL - 请求异常 |', e.message)
|
||||
}
|
||||
|
||||
// 清理本测试创建的 db 记录
|
||||
try { db.deleteScheme(created.id) } catch {}
|
||||
|
||||
server.close()
|
||||
console.log(fail === 0 ? 'ALL PASS' : `${fail} FAILED`)
|
||||
process.exit(fail === 0 ? 0 : 1)
|
||||
})
|
||||
@@ -0,0 +1,92 @@
|
||||
// 单元测试:kaleidoscope 纯函数(种子可复现 + 棱镜分割)
|
||||
// 运行:node dev_test_scripts/unit/test_kaleidoscope_pure.js
|
||||
// 从 HTML 中抽取 PURE-BEGIN..PURE-END 区块执行
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const vm = require('vm')
|
||||
|
||||
const html = fs.readFileSync(path.join(process.cwd(), 'public', 'tools', 'thought_lab', 'labs', 'kaleidoscope', 'index.html'), 'utf-8')
|
||||
const m = html.match(/\/\/ ==== PURE-BEGIN[\s\S]*?\/\/ ==== PURE-END/)
|
||||
if (!m) { console.log('FAIL - 未找到 PURE-BEGIN..PURE-END'); process.exit(1) }
|
||||
|
||||
const sandbox = { TAU: Math.PI * 2, console };
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(m[0], sandbox);
|
||||
const { TAU, mulberry32, buildPieces } = sandbox;
|
||||
// 注:PALETTES 在 sandbox 中为 const 声明,vm.runInContext 不暴露为 sandbox 属性
|
||||
// 色板仅用于绘制,此处不验证具体颜色值
|
||||
|
||||
let fail = 0;
|
||||
const check = (name, cond, extra) => {
|
||||
console.log(`${cond ? 'PASS' : 'FAIL'} - ${name}${extra ? ' | ' + extra : ''}`);
|
||||
if (!cond) fail++;
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// 1) 种子可复现性:同种子+同参数 → 输出完全一致
|
||||
// ------------------------------------------------------------
|
||||
const P1 = { segments: 8, mirror: true, shape: 'mix', palette: 'neon', count: 12 };
|
||||
const A = buildPieces(P1, 42);
|
||||
const B = buildPieces(P1, 42);
|
||||
check('种子可复现:同种子 42 两次产出 length 一致', A.length === B.length, `A=${A.length} B=${B.length}`);
|
||||
let identical = A.length === B.length && A.length > 0;
|
||||
for (let i = 0; i < A.length && identical; i++) {
|
||||
if (A[i].shape !== B[i].shape || A[i].color !== B[i].color) identical = false;
|
||||
}
|
||||
check('同种子纸片字段完全一致', identical, 'shape + color 逐张对比');
|
||||
|
||||
const C = buildPieces(P1, 43);
|
||||
check('不同种子产出不同', C.length > 0 && (C[0].shape !== A[0].shape || C[0].color !== A[0].color));
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// 2) 棱镜镜像严格跟随旋转次数
|
||||
// ------------------------------------------------------------
|
||||
// 基础楔角 = TAU / segments
|
||||
// 镜像开启时纸片只撒半楔角 [0, copy/2),绘制时镜像副本补满另一半 → 镜面数 = 2×segments
|
||||
// 不勾镜像时纸片撒满整楔角 [0, copy) → 面数 = segments
|
||||
const wedgeAngle = (P) => TAU / P.segments / (P.mirror ? 2 : 1);
|
||||
|
||||
const P_mirror = { segments: 8, mirror: true, shape: 'mix', palette: 'neon', count: 20 };
|
||||
const pieces_mirror = buildPieces(P_mirror, 777);
|
||||
const maxAngle_mirror = Math.max(...pieces_mirror.map(p => p.an));
|
||||
check('镜像 on:纸片角度均 < 半楔角', maxAngle_mirror + 0.001 < TAU / 8, `max_an=${maxAngle_mirror.toFixed(4)} half=${(TAU/8/2).toFixed(4)}`);
|
||||
|
||||
const P_nomirror = { segments: 8, mirror: false, shape: 'mix', palette: 'neon', count: 20 };
|
||||
const pieces_nomirror = buildPieces(P_nomirror, 777);
|
||||
const maxAngle_nomirror = Math.max(...pieces_nomirror.map(p => p.an));
|
||||
check('镜像 off:纸片角度 < 整楔角', maxAngle_nomirror + 0.001 < TAU / 8, `max_an=${maxAngle_nomirror.toFixed(4)} full=${(TAU/8).toFixed(4)}`);
|
||||
|
||||
// 换不同旋转次数验证
|
||||
for (const seg of [3, 6, 12, 24]) {
|
||||
const P = { segments: seg, mirror: true, shape: 'mix', palette: 'neon', count: 10 };
|
||||
const pcs = buildPieces(P, 999);
|
||||
const semi = TAU / seg / 2;
|
||||
const maxA = Math.max(...pcs.map(p => p.an));
|
||||
if (maxA + 0.001 >= semi) {
|
||||
check(`旋转 ${seg} 次 镜像 at ${semi.toFixed(4)}`, false, `max_an=${maxA.toFixed(4)} 超出半楔角`);
|
||||
fail++;
|
||||
}
|
||||
}
|
||||
console.log(`旋转 3/6/12/24 次镜像 all PASS`);
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// 3) 验证:纸片首张字段可稳定哈希
|
||||
// ------------------------------------------------------------
|
||||
const D = buildPieces({ segments: 4, mirror: true, shape: 'rect', palette: 'contrast', count: 5 }, 20260806);
|
||||
check('首张纸片 shape 确定性', D[0].shape === 'rect');
|
||||
check('首张纸片有 color 字段', typeof D[0].color === 'string' && D[0].color.length > 0);
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// 4) 老万花筒模式:经典模式强制 mirror=false,纸片应撒满整楔角
|
||||
// ------------------------------------------------------------
|
||||
const E = buildPieces({ segments: 8, mirror: false, shape: 'mix', palette: 'neon', count: 20 }, 12345);
|
||||
const maxA = Math.max(...E.map(p => p.an));
|
||||
check('经典模式纸片角度 < 整楔角', maxA + 0.001 < TAU / 8, `max=${maxA.toFixed(4)} wedge=${(TAU/8).toFixed(4)}`);
|
||||
// 经典模式下旋转次数必须为偶数,否则奇偶交替不闭合
|
||||
// 本测试不验证 UI 层,仅验证纯函数:同种子同参数可复现
|
||||
const F1 = buildPieces({ segments: 6, mirror: false, shape: 'mix', palette: 'neon', count: 10 }, 555);
|
||||
const F2 = buildPieces({ segments: 6, mirror: false, shape: 'mix', palette: 'neon', count: 10 }, 555);
|
||||
check('经典模式(6 扇区)可复现', F1.length === F2.length && F1[0].shape === F2[0].shape);
|
||||
|
||||
console.log(fail === 0 ? 'ALL PASS' : `${fail} FAILED`);
|
||||
process.exit(fail === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,507 @@
|
||||
<!-- ============================================================
|
||||
思想实验室 · labs/kaleidoscope · 老式物理万花筒模拟器
|
||||
单文件自包含(Canvas 2D,零外部依赖),可直接浏览器打开运行。
|
||||
模拟实体万花筒:碎片物理、重力摩擦可调、多种自然碎片形状、
|
||||
棱镜镜像、拖拽旋转、摇晃交互。
|
||||
服务端配置:src/server/thought_lab/labs/kaleidoscope/config.json
|
||||
============================================================ -->
|
||||
<!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>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
background: #1a1a2e; color: #c9d0e8;
|
||||
font-family: "Segoe UI", "Microsoft YaHei", sans-serif;
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
min-height: 100vh; user-select: none; -webkit-user-select: none;
|
||||
}
|
||||
h1 { font-size: 18px; font-weight: 600; margin: 12px 0 6px; color: #e0e4f8; }
|
||||
#panel {
|
||||
display: flex; flex-wrap: wrap; justify-content: center; gap: 8px 14px;
|
||||
max-width: 820px; padding: 8px 16px;
|
||||
background: #12122a; border: 1px solid #2a2a52; border-radius: 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.pg { display: flex; align-items: center; gap: 4px; }
|
||||
.pg label { color: #8890b8; white-space: nowrap; }
|
||||
.pg input[type=range] { width: 90px; accent-color: #8b6fc0; }
|
||||
.pg .rv { width: 36px; text-align: right; color: #d4b8ff; font-family: Consolas, monospace; font-size: 11px; }
|
||||
.pg input[type=checkbox] { accent-color: #8b6fc0; }
|
||||
button {
|
||||
background: #28284a; color: #d0d4f0; border: 1px solid #4a4a7a;
|
||||
border-radius: 6px; padding: 3px 12px; font-size: 12px; cursor: pointer;
|
||||
}
|
||||
button:hover { background: #38386a; }
|
||||
button.primary { background: #5a3f8a; border-color: #8b6fc0; }
|
||||
button.primary:hover { background: #6b4faa; }
|
||||
#canvas-wrap {
|
||||
position: relative; margin: 16px 0; border-radius: 50%;
|
||||
box-shadow: 0 0 30px rgba(0,0,0,.6), 0 0 80px rgba(0,0,0,.3), 0 8px 32px rgba(0,0,0,.5);
|
||||
background: #0a0a18; overflow: hidden;
|
||||
}
|
||||
#cv { display: block; }
|
||||
#hint {
|
||||
font-size: 12px; color: #667090; margin-bottom: 18px;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
/* 木壳外圈 */
|
||||
#canvas-wrap::after {
|
||||
content: ''; position: absolute; inset: -8px; border-radius: 50%;
|
||||
border: 8px solid #3a2a1a; box-shadow: inset 0 0 12px rgba(0,0,0,.4);
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>🔮 老式物理万花筒</h1>
|
||||
|
||||
<div id="panel">
|
||||
<div class="pg"><label>碎片数量</label><input id="c-count" type="range" min="30" max="260" value="120"><span class="rv" id="v-count"></span></div>
|
||||
<div class="pg"><label>棱镜份数</label><input id="c-segments" type="range" min="3" max="12" value="6"><span class="rv" id="v-segments"></span></div>
|
||||
<div class="pg"><label>桶宽</label><input id="c-tube" type="range" min="0.40" max="1.00" step="0.02" value="1.00"><span class="rv" id="v-tube"></span></div>
|
||||
<div class="pg"><label>最小倍率</label><input id="c-minScale" type="range" min="0.20" max="1.20" step="0.05" value="0.50"><span class="rv" id="v-minScale"></span></div>
|
||||
<div class="pg"><label>最大倍率</label><input id="c-maxScale" type="range" min="1.00" max="4.00" step="0.05" value="3.00"><span class="rv" id="v-maxScale"></span></div>
|
||||
<div class="pg"><label>重力强度</label><input id="c-gravity" type="range" min="0" max="0.30" step="0.01" value="0.07"><span class="rv" id="v-gravity"></span></div>
|
||||
<div class="pg"><label>静摩擦</label><input id="c-friction" type="range" min="0.02" max="0.20" step="0.01" value="0.11"><span class="rv" id="v-friction"></span></div>
|
||||
<div class="pg"><label>启用重力</label><input id="c-gravityOn" type="checkbox"></div>
|
||||
<button id="btn-apply" class="primary" type="button">应用设置</button>
|
||||
<button id="btn-shake" type="button">手动摇晃</button>
|
||||
</div>
|
||||
|
||||
<div id="canvas-wrap">
|
||||
<canvas id="cv"></canvas>
|
||||
</div>
|
||||
|
||||
<div id="hint">按住鼠标拖动旋转镜筒 | 双击画面摇晃万花筒</div>
|
||||
|
||||
<script>
|
||||
'use strict';
|
||||
const TAU = Math.PI * 2;
|
||||
|
||||
// ============================================================
|
||||
// 1. 参数读取(从 UI 控件)
|
||||
// ============================================================
|
||||
const $ = id => document.getElementById(id);
|
||||
const readParams = () => {
|
||||
const minScale = parseFloat($('c-minScale').value);
|
||||
const maxScale = parseFloat($('c-maxScale').value);
|
||||
if (minScale > maxScale) {
|
||||
alert('最小倍率不能大于最大倍率');
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
count: parseInt($('c-count').value) || 120,
|
||||
segments: parseInt($('c-segments').value) || 6,
|
||||
tube: parseFloat($('c-tube').value) || 1.0,
|
||||
minScale: minScale,
|
||||
maxScale: maxScale,
|
||||
gravity: parseFloat($('c-gravity').value) || 0,
|
||||
staticFriction: parseFloat($('c-friction').value) || 0.11,
|
||||
gravityOn: $('c-gravityOn').checked
|
||||
};
|
||||
};
|
||||
|
||||
// 初始化控件显示值
|
||||
const initControlLabels = () => {
|
||||
$('v-count').textContent = $('c-count').value;
|
||||
$('v-segments').textContent = $('c-segments').value;
|
||||
$('v-tube').textContent = $('c-tube').value;
|
||||
$('v-minScale').textContent = $('c-minScale').value;
|
||||
$('v-maxScale').textContent = $('c-maxScale').value;
|
||||
$('v-gravity').textContent = $('c-gravity').value;
|
||||
$('v-friction').textContent = $('c-friction').value;
|
||||
};
|
||||
// 滑块实时更新数值显示
|
||||
['c-count','c-segments','c-tube','c-minScale','c-maxScale','c-gravity','c-friction'].forEach(id => {
|
||||
$(id).addEventListener('input', () => {
|
||||
$('v-' + id.slice(2)).textContent = $(id).value;
|
||||
});
|
||||
});
|
||||
initControlLabels();
|
||||
|
||||
// ============================================================
|
||||
// 2. 画布与状态
|
||||
// ============================================================
|
||||
const cv = $('cv');
|
||||
const ctx = cv.getContext('2d');
|
||||
const SIZE = 520;
|
||||
let DPR = Math.min(window.devicePixelRatio || 1, 2);
|
||||
const CANVAS_SIZE = Math.round(SIZE * DPR);
|
||||
cv.width = CANVAS_SIZE; cv.height = CANVAS_SIZE;
|
||||
cv.style.width = SIZE + 'px'; cv.style.height = SIZE + 'px';
|
||||
const wrap = $('canvas-wrap');
|
||||
wrap.style.width = (SIZE + 16) + 'px'; wrap.style.height = (SIZE + 16) + 'px';
|
||||
|
||||
const CX = CANVAS_SIZE / 2, CY = CANVAS_SIZE / 2;
|
||||
const RADIUS = (SIZE / 2 - 10) * DPR;
|
||||
|
||||
// 全局状态
|
||||
let pieces = []; // 碎片数组(原始,不包含镜像)
|
||||
let params = readParams(); // 当前生效参数
|
||||
let rot = 0; // 万花筒整体旋转角度(弧度)
|
||||
let shakeTimer = 0; // 摇晃剩余时间(秒)
|
||||
let shakeIntensity = 0; // 摇晃强度
|
||||
|
||||
// ============================================================
|
||||
// 3. 复古色池
|
||||
// ============================================================
|
||||
const COLORS = [
|
||||
'#e63946','#f4a261','#e9c46a','#2a9d8f','#264653',
|
||||
'#d62828','#f77f00','#fcbf49','#003049','#669bbc',
|
||||
'#780000','#c1121f','#fdf0d5','#003049','#669bbc',
|
||||
'#606c38','#283618','#fefae0','#dda15e','#bc6c25',
|
||||
'#cdb4db','#ffc8dd','#ffafcc','#bde0fe','#a2d2ff',
|
||||
'#ff595e','#ffca3a','#8ac926','#1982c4','#6a4c93'
|
||||
];
|
||||
|
||||
// ============================================================
|
||||
// 4. 碎片生成(严格按概率配比)
|
||||
// ============================================================
|
||||
const randomType = () => {
|
||||
const r = Math.random();
|
||||
if (r < 0.50) return 'poly';
|
||||
if (r < 0.75) return 'ellipse';
|
||||
if (r < 0.85) return 'nearCircle';
|
||||
if (r < 0.95) return 'mixedDrop';
|
||||
return 'prim';
|
||||
};
|
||||
|
||||
// 生成不规则多角碎片顶点(3-7 边,带随机扰动)
|
||||
const polyVerts = (sides, r, jitter) => {
|
||||
const verts = [];
|
||||
for (let i = 0; i < sides; i++) {
|
||||
const a = (i / sides) * TAU - Math.PI / 2;
|
||||
const jr = r * (1 + (Math.random() - 0.5) * jitter * 2);
|
||||
verts.push({ x: Math.cos(a) * jr, y: Math.sin(a) * jr });
|
||||
}
|
||||
return verts;
|
||||
};
|
||||
|
||||
const resetPieces = (p) => {
|
||||
params = p;
|
||||
const wedge = TAU / p.segments;
|
||||
pieces = [];
|
||||
for (let i = 0; i < p.count; i++) {
|
||||
const type = randomType();
|
||||
const baseSize = p.minScale + Math.random() * (p.maxScale - p.minScale);
|
||||
const sizeScale = baseSize * 16 * DPR;
|
||||
let verts = null;
|
||||
let shape = type;
|
||||
|
||||
if (type === 'poly') {
|
||||
const sides = 3 + Math.floor(Math.random() * 5); // 3-7
|
||||
verts = polyVerts(sides, sizeScale, 0.35);
|
||||
} else if (type === 'ellipse') {
|
||||
// 云母薄片:长短轴差距明显
|
||||
const rx = sizeScale * (0.5 + Math.random() * 0.5);
|
||||
const ry = sizeScale * (0.1 + Math.random() * 0.25);
|
||||
verts = { rx, ry };
|
||||
} else if (type === 'nearCircle') {
|
||||
// 8 顶点近圆,每个半径小幅扰动
|
||||
verts = polyVerts(8, sizeScale, 0.12);
|
||||
shape = 'poly';
|
||||
} else if (type === 'mixedDrop') {
|
||||
const sub = Math.floor(Math.random() * 3);
|
||||
if (sub === 0) {
|
||||
// 水滴:3 顶点,1 个拉长
|
||||
verts = polyVerts(3, sizeScale, 0.5);
|
||||
shape = 'poly';
|
||||
} else if (sub === 1) {
|
||||
verts = polyVerts(3, sizeScale, 0.2);
|
||||
shape = 'poly';
|
||||
} else {
|
||||
// 梯形:4 顶点
|
||||
verts = polyVerts(4, sizeScale, 0.3);
|
||||
shape = 'poly';
|
||||
}
|
||||
} else if (type === 'prim') {
|
||||
const sub = Math.floor(Math.random() * 3);
|
||||
if (sub === 0) { verts = polyVerts(3, sizeScale, 0); shape = 'poly'; }
|
||||
else if (sub === 1) { verts = { rx: sizeScale, ry: sizeScale }; shape = 'ellipse'; }
|
||||
else { verts = polyVerts(4, sizeScale, 0); shape = 'poly'; }
|
||||
}
|
||||
|
||||
// 位置:楔角内随机
|
||||
const angle = Math.random() * wedge;
|
||||
const r = 0.05 + Math.random() * 0.9 * RADIUS;
|
||||
pieces.push({
|
||||
x: Math.cos(angle) * r,
|
||||
y: Math.sin(angle) * r,
|
||||
vx: 0, vy: 0,
|
||||
rotation: Math.random() * TAU,
|
||||
rotSpeed: 0,
|
||||
sizeScale,
|
||||
shape,
|
||||
verts,
|
||||
color: COLORS[Math.floor(Math.random() * COLORS.length)],
|
||||
alpha: 0.62 + Math.random() * 0.32,
|
||||
sleep: false
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 5. 物理引擎
|
||||
// ============================================================
|
||||
const WEDGE = () => TAU / Math.max(1, params.segments);
|
||||
|
||||
const updatePhysics = (dt) => {
|
||||
const wedge = WEDGE();
|
||||
const g = params.gravityOn ? params.gravity * 800 * DPR : 0;
|
||||
const sFric = params.staticFriction;
|
||||
|
||||
// 摇晃:给所有碎片随机速度
|
||||
if (shakeTimer > 0) {
|
||||
shakeTimer -= dt;
|
||||
for (const p of pieces) {
|
||||
p.sleep = false;
|
||||
if (shakeTimer > 0) {
|
||||
const s = shakeIntensity * 400 * DPR;
|
||||
p.vx += (Math.random() - 0.5) * s * dt * 2;
|
||||
p.vy += (Math.random() - 0.5) * s * dt * 2;
|
||||
p.rotSpeed += (Math.random() - 0.5) * shakeIntensity * 8 * dt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const p of pieces) {
|
||||
if (p.sleep) continue;
|
||||
|
||||
// 重力:方向跟随万花筒旋转角度(重力在屏幕空间向下,变换到碎片坐标系)
|
||||
if (g > 0) {
|
||||
const gx = Math.sin(rot) * g;
|
||||
const gy = Math.cos(rot) * g;
|
||||
p.vx += gx * dt;
|
||||
p.vy += gy * dt;
|
||||
}
|
||||
|
||||
// 动摩擦衰减
|
||||
const dynFric = 0.92;
|
||||
p.vx *= dynFric;
|
||||
p.vy *= dynFric;
|
||||
p.rotSpeed *= 0.94;
|
||||
|
||||
// 更新位置
|
||||
p.x += p.vx * dt;
|
||||
p.y += p.vy * dt;
|
||||
p.rotation += p.rotSpeed * dt;
|
||||
|
||||
// 楔角边界约束(碎片在未旋转坐标系中,楔角 = [0, TAU/segments])
|
||||
const a = Math.atan2(p.y, p.x);
|
||||
let angle = ((a % TAU) + TAU) % TAU;
|
||||
let bounced = false;
|
||||
if (angle > wedge) {
|
||||
angle = 2 * wedge - angle;
|
||||
bounced = true;
|
||||
}
|
||||
if (angle < 0) {
|
||||
angle = -angle;
|
||||
bounced = true;
|
||||
}
|
||||
if (bounced) {
|
||||
const newA = angle;
|
||||
const dist = Math.hypot(p.x, p.y);
|
||||
p.x = Math.cos(newA) * dist;
|
||||
p.y = Math.sin(newA) * dist;
|
||||
// 反弹阻尼
|
||||
const nx = Math.cos(newA);
|
||||
const ny = Math.sin(newA);
|
||||
const dot = p.vx * nx + p.vy * ny;
|
||||
p.vx -= 2 * dot * nx * 0.5;
|
||||
p.vy -= 2 * dot * ny * 0.5;
|
||||
}
|
||||
|
||||
// 圆形边界反弹
|
||||
const dist = Math.hypot(p.x, p.y);
|
||||
if (dist > RADIUS * 0.96) {
|
||||
const nx = p.x / dist, ny = p.y / dist;
|
||||
p.x = nx * RADIUS * 0.96;
|
||||
p.y = ny * RADIUS * 0.96;
|
||||
const dot = p.vx * nx + p.vy * ny;
|
||||
if (dot > 0) { p.vx -= dot * nx * 1.3; p.vy -= dot * ny * 1.3; }
|
||||
}
|
||||
if (dist < RADIUS * 0.02 && dist > 0) {
|
||||
const nx = p.x / dist, ny = p.y / dist;
|
||||
p.x = nx * RADIUS * 0.02;
|
||||
p.y = ny * RADIUS * 0.02;
|
||||
p.vx = Math.abs(p.vx) * 0.5;
|
||||
p.vy = Math.abs(p.vy) * 0.5;
|
||||
}
|
||||
|
||||
// 休眠检测:速度 + 重力驱动力 < 静摩擦阈值
|
||||
const speed = Math.hypot(p.vx, p.vy);
|
||||
const gForce = g > 0 ? g * p.sizeScale * 0.01 : 0;
|
||||
// 小碎片额外叠加更高静摩擦
|
||||
const extraFric = (1 - Math.min(1, p.sizeScale / (4 * 16 * DPR))) * 0.08;
|
||||
const threshold = (sFric + extraFric) * 200 * DPR;
|
||||
if (speed < threshold && gForce < threshold) {
|
||||
p.sleep = true;
|
||||
p.vx = 0; p.vy = 0; p.rotSpeed = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 旋转衰减
|
||||
rot *= 0.995;
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 6. 摇晃
|
||||
// ============================================================
|
||||
const shake = (intensity) => {
|
||||
shakeTimer = 0.8;
|
||||
shakeIntensity = Math.min(1, intensity || 1);
|
||||
for (const p of pieces) p.sleep = false;
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 7. 绘制
|
||||
// ============================================================
|
||||
const draw = () => {
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
ctx.clearRect(0, 0, CANVAS_SIZE, CANVAS_SIZE);
|
||||
|
||||
// 背景
|
||||
ctx.fillStyle = '#0a0a18';
|
||||
ctx.fillRect(0, 0, CANVAS_SIZE, CANVAS_SIZE);
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(CX, CY);
|
||||
ctx.rotate(rot);
|
||||
|
||||
const wedge = WEDGE();
|
||||
const seg = params.segments;
|
||||
const innerR = RADIUS * (1 - params.tube);
|
||||
|
||||
// 环形裁剪 = 镜筒可视区
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, RADIUS, 0, TAU);
|
||||
if (innerR > 0) ctx.arc(0, 0, innerR, 0, TAU, true);
|
||||
ctx.clip();
|
||||
|
||||
// 奇偶扇区交替镜像绘制
|
||||
for (let i = 0; i < seg; i++) {
|
||||
ctx.save();
|
||||
ctx.rotate(i * wedge);
|
||||
if (i % 2 === 1) ctx.scale(-1, 1);
|
||||
|
||||
for (const p of pieces) {
|
||||
ctx.save();
|
||||
ctx.translate(p.x, p.y);
|
||||
ctx.rotate(p.rotation);
|
||||
ctx.globalAlpha = p.alpha;
|
||||
ctx.fillStyle = p.color;
|
||||
|
||||
if (p.shape === 'ellipse' && p.verts && p.verts.rx) {
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(0, 0, p.verts.rx, p.verts.ry, 0, 0, TAU);
|
||||
ctx.fill();
|
||||
} else if (p.verts && Array.isArray(p.verts)) {
|
||||
ctx.beginPath();
|
||||
p.verts.forEach((v, k) => {
|
||||
k === 0 ? ctx.moveTo(v.x, v.y) : ctx.lineTo(v.x, v.y);
|
||||
});
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
|
||||
// 玻璃光晕叠层(screen 混合模式弱模糊)
|
||||
ctx.save();
|
||||
ctx.globalCompositeOperation = 'screen';
|
||||
ctx.globalAlpha = 0.06;
|
||||
const grd = ctx.createRadialGradient(CX, CY, RADIUS * 0.3, CX, CY, RADIUS);
|
||||
grd.addColorStop(0, '#ffffff');
|
||||
grd.addColorStop(0.5, '#8866cc');
|
||||
grd.addColorStop(1, 'transparent');
|
||||
ctx.fillStyle = grd;
|
||||
ctx.fillRect(0, 0, CANVAS_SIZE, CANVAS_SIZE);
|
||||
ctx.restore();
|
||||
|
||||
// 黑色遮罩:只保留圆形视窗
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.arc(CX, CY, RADIUS, 0, TAU);
|
||||
ctx.rect(CANVAS_SIZE, 0, -CANVAS_SIZE, CANVAS_SIZE);
|
||||
ctx.fillStyle = '#0a0a18';
|
||||
ctx.fill('evenodd');
|
||||
ctx.restore();
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 8. 交互
|
||||
// ============================================================
|
||||
let dragging = false, lastDragAngle = 0;
|
||||
|
||||
const getCanvasAngle = (e) => {
|
||||
const r = cv.getBoundingClientRect();
|
||||
const px = (e.clientX - r.left) * DPR;
|
||||
const py = (e.clientY - r.top) * DPR;
|
||||
return Math.atan2(py - CY, px - CX);
|
||||
};
|
||||
|
||||
cv.addEventListener('pointerdown', e => {
|
||||
dragging = true;
|
||||
lastDragAngle = getCanvasAngle(e);
|
||||
for (const p of pieces) p.sleep = false;
|
||||
cv.setPointerCapture(e.pointerId);
|
||||
});
|
||||
|
||||
cv.addEventListener('pointermove', e => {
|
||||
if (!dragging) return;
|
||||
const a = getCanvasAngle(e);
|
||||
rot += a - lastDragAngle;
|
||||
lastDragAngle = a;
|
||||
});
|
||||
|
||||
cv.addEventListener('pointerup', () => { dragging = false; });
|
||||
|
||||
// 双击摇晃
|
||||
let lastTap = 0;
|
||||
cv.addEventListener('pointerdown', e => {
|
||||
const now = Date.now();
|
||||
if (now - lastTap < 350) {
|
||||
shake(1);
|
||||
lastTap = 0;
|
||||
return;
|
||||
}
|
||||
lastTap = now;
|
||||
});
|
||||
|
||||
// 按钮
|
||||
$('btn-apply').addEventListener('click', () => {
|
||||
const p = readParams();
|
||||
if (!p) return;
|
||||
resetPieces(p);
|
||||
shake(0.3);
|
||||
});
|
||||
$('btn-shake').addEventListener('click', () => shake(1));
|
||||
|
||||
// ============================================================
|
||||
// 9. 主循环
|
||||
// ============================================================
|
||||
let lastT = performance.now();
|
||||
const loop = (now) => {
|
||||
const dt = Math.min(0.05, (now - lastT) / 1000);
|
||||
lastT = now;
|
||||
updatePhysics(dt);
|
||||
draw();
|
||||
requestAnimationFrame(loop);
|
||||
};
|
||||
|
||||
// 初始化
|
||||
resetPieces(params);
|
||||
requestAnimationFrame(loop);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,716 @@
|
||||
<!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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,512 @@
|
||||
<!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>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{
|
||||
background:#1a1a1a;
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
min-height:100vh;
|
||||
font-family:system-ui;
|
||||
padding:16px;
|
||||
}
|
||||
.kaleido-wrap{
|
||||
position:relative;
|
||||
width:520px;
|
||||
height:520px;
|
||||
}
|
||||
#canvas{
|
||||
border-radius:50%;
|
||||
box-shadow:0 0 0 16px #6b4423, 0 0 0 22px #4a2e16, 0 12px 45px #000;
|
||||
cursor:grab;
|
||||
}
|
||||
#canvas:active{cursor:grabbing}
|
||||
.tip{
|
||||
color:#aaa;
|
||||
margin-top:12px;
|
||||
font-size:14px;
|
||||
line-height:1.6;
|
||||
text-align:center;
|
||||
}
|
||||
.control-panel{
|
||||
background:#2c2c2c;
|
||||
padding:14px 20px;
|
||||
border-radius:10px;
|
||||
margin-bottom:18px;
|
||||
color:#eee;
|
||||
display:flex;
|
||||
gap:14px;
|
||||
align-items:center;
|
||||
flex-wrap:wrap;
|
||||
justify-content:center;
|
||||
}
|
||||
.ctrl-item{
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
gap:4px;
|
||||
}
|
||||
.ctrl-item label{
|
||||
font-size:13px;
|
||||
color:#ddd;
|
||||
}
|
||||
input[type="range"]{
|
||||
width:100px;
|
||||
}
|
||||
.check-item{
|
||||
display:flex;
|
||||
align-items:center;
|
||||
gap:8px;
|
||||
}
|
||||
button{
|
||||
background:#784c28;
|
||||
color:#fff;
|
||||
border:none;
|
||||
padding:7px 12px;
|
||||
border-radius:6px;
|
||||
cursor:pointer;
|
||||
}
|
||||
button:hover{background:#8d5b32;}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="control-panel">
|
||||
<div class="ctrl-item">
|
||||
<label>碎片数量: <span id="valPieces">120</span></label>
|
||||
<input id="rangePieces" type="range" min="30" max="260" value="120">
|
||||
</div>
|
||||
<div class="ctrl-item">
|
||||
<label>棱镜分割份数: <span id="valPrism">6</span></label>
|
||||
<input id="rangePrism" type="range" min="3" max="12" value="6">
|
||||
</div>
|
||||
<div class="ctrl-item">
|
||||
<label>桶宽(视窗孔径): <span id="valTube">1.00</span></label>
|
||||
<input id="rangeTube" type="range" min="0.40" max="1.00" step="0.02" value="1.00">
|
||||
</div>
|
||||
<div class="ctrl-item">
|
||||
<label>碎片最小倍率: <span id="valScaleMin">0.50x</span></label>
|
||||
<input id="rangeScaleMin" type="range" min="0.2" max="1.2" step="0.05" value="0.5">
|
||||
</div>
|
||||
<div class="ctrl-item">
|
||||
<label>碎片最大倍率: <span id="valScaleMax">3.00x</span></label>
|
||||
<input id="rangeScaleMax" type="range" min="1.0" max="4.0" step="0.05" value="3.0">
|
||||
</div>
|
||||
|
||||
<div class="ctrl-item">
|
||||
<label>重力强度: <span id="valGravity">0.07</span></label>
|
||||
<input id="rangeGravity" type="range" min="0" max="0.30" step="0.01" value="0.07">
|
||||
</div>
|
||||
<div class="ctrl-item">
|
||||
<label>静摩擦系数: <span id="valFriction">0.11</span></label>
|
||||
<input id="rangeFriction" type="range" min="0.02" max="0.20" step="0.01" value="0.11">
|
||||
</div>
|
||||
|
||||
<div class="check-item">
|
||||
<input id="checkGravity" type="checkbox">
|
||||
<label for="checkGravity">启用重力</label>
|
||||
</div>
|
||||
|
||||
<button id="btnApply">应用设置</button>
|
||||
<button id="btnShake">手动摇晃万花筒</button>
|
||||
</div>
|
||||
|
||||
<div class="kaleido-wrap">
|
||||
<canvas id="canvas" width="520" height="520"></canvas>
|
||||
</div>
|
||||
<div class="tip">按住鼠标拖动旋转镜筒|双击画面摇晃万花筒</div>
|
||||
|
||||
<script>
|
||||
const canvas = document.getElementById('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const W = canvas.width;
|
||||
const H = canvas.height;
|
||||
const cx = W/2, cy = H/2;
|
||||
const baseRadius = W/2;
|
||||
|
||||
let config = {
|
||||
pieceCount: 120,
|
||||
prismDiv: 6,
|
||||
tubeScale: 1.00,
|
||||
scaleMin: 0.5,
|
||||
scaleMax: 3.0,
|
||||
gravityEnabled: false,
|
||||
gravityStrength: 0.07,
|
||||
baseStaticFriction: 0.11
|
||||
};
|
||||
|
||||
let pieces = [];
|
||||
let rotateAngle = 0;
|
||||
let isDrag = false;
|
||||
let lastMouseAngle = 0;
|
||||
let shakeTime = 0;
|
||||
const SHAKE_DURATION = 700;
|
||||
|
||||
const colorPool = [
|
||||
'#c82423','#f2b138','#287233','#2b549c','#883488','#d86080','#42a5b8','#e6c250','#992840','#366947'
|
||||
];
|
||||
|
||||
/**
|
||||
碎片概率分配:
|
||||
50% poly 不规则多角碎片 3‑7边
|
||||
25% ellipse 椭圆/扁片云母薄片
|
||||
10% nearCircle 近圆、轻微变形圆
|
||||
10% mixedDrop 水滴/圆角不规则片/三角/梯形
|
||||
5% prim:正三角形,正圆,正方形
|
||||
*/
|
||||
function makeNaturalShard(){
|
||||
const r = Math.random();
|
||||
let type;
|
||||
if(r < 0.50) type = 'poly';
|
||||
else if(r < 0.75) type = 'ellipse';
|
||||
else if(r < 0.85) type = 'nearCircle';
|
||||
else if(r < 0.95) type = 'mixedDrop';
|
||||
else type = 'prim';
|
||||
|
||||
let shapeData;
|
||||
if(type === 'poly'){
|
||||
const sides = 3 + Math.floor(Math.random()*5);
|
||||
const pts = [];
|
||||
for(let i=0;i<sides;i++){
|
||||
const ang = (i / sides) * Math.PI*2 + (Math.random()-0.5)*0.45;
|
||||
const rad = 4 + Math.random()*6;
|
||||
pts.push({x:Math.cos(ang)*rad, y:Math.sin(ang)*rad});
|
||||
}
|
||||
shapeData = {pts};
|
||||
}else if(type === 'ellipse'){
|
||||
const r1 = 3 + Math.random()*7;
|
||||
const r2 = 1.5 + Math.random()*3.5;
|
||||
shapeData = {rx:r1, ry:r2, rot:Math.random()*Math.PI};
|
||||
}else if(type === 'nearCircle'){
|
||||
const baseR = 3.5 + Math.random()*5;
|
||||
const pts = [];
|
||||
const seg = 8;
|
||||
for(let i=0;i<seg;i++){
|
||||
const ang = (i/seg)*Math.PI*2;
|
||||
const rr = baseR * (0.82 + Math.random()*0.36);
|
||||
pts.push({x:Math.cos(ang)*rr, y:Math.sin(ang)*rr});
|
||||
}
|
||||
shapeData = {pts};
|
||||
}else if(type === 'mixedDrop'){
|
||||
const sub = Math.floor(Math.random()*3);
|
||||
const pts = [];
|
||||
if(sub === 0){
|
||||
//水滴
|
||||
const seg = 7;
|
||||
for(let i=0;i<seg;i++){
|
||||
const ang = (i/seg)*Math.PI*2;
|
||||
let rr = 3 + Math.random()*5;
|
||||
if(ang>0 && ang < Math.PI*0.75) rr *= 1.35;
|
||||
pts.push({x:Math.cos(ang)*rr, y:Math.sin(ang)*rr});
|
||||
}
|
||||
}else if(sub ===1){
|
||||
//圆角三角
|
||||
pts.push({x:0,y:-5},{x:-4,y:4},{x:4,y:3});
|
||||
}else{
|
||||
//梯形
|
||||
pts.push({x:-5,y:-3},{x:5,y:-3},{x:3,y:4},{x:-3,y:4});
|
||||
}
|
||||
shapeData = {pts};
|
||||
}else if(type === 'prim'){
|
||||
const sub = Math.floor(Math.random()*3);
|
||||
if(sub===0){
|
||||
//正三角
|
||||
const pts=[];
|
||||
for(let i=0;i<3;i++){
|
||||
const ang = (i/3)*Math.PI*2 - Math.PI/2;
|
||||
pts.push({x:Math.cos(ang)*5,y:Math.sin(ang)*5});
|
||||
}
|
||||
shapeData={pts,primKind:'tri'};
|
||||
}else if(sub===1){
|
||||
//正圆
|
||||
shapeData={primKind:'circle',r:4.5+Math.random()*2};
|
||||
}else{
|
||||
//正方形
|
||||
const s=4.5;
|
||||
shapeData={pts:[{x:-s,y:-s},{x:s,y:-s},{x:s,y:s},{x:-s,y:s}],primKind:'sq'};
|
||||
}
|
||||
}
|
||||
return {type, shapeData};
|
||||
}
|
||||
|
||||
function resetPieces(){
|
||||
pieces = [];
|
||||
const maxPieceRange = baseRadius * config.tubeScale * 0.75;
|
||||
for(let i=0;i<config.pieceCount;i++){
|
||||
const s = config.scaleMin + Math.random()*(config.scaleMax - config.scaleMin);
|
||||
const shard = makeNaturalShard();
|
||||
pieces.push({
|
||||
x: (Math.random()-0.5)*maxPieceRange,
|
||||
y: (Math.random()-0.5)*maxPieceRange,
|
||||
shard: shard,
|
||||
sizeScale: s,
|
||||
rot: Math.random()*Math.PI*2,
|
||||
color: colorPool[Math.floor(Math.random()*colorPool.length)],
|
||||
alpha: 0.62 + Math.random()*0.32,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
vrot: 0,
|
||||
mass: s,
|
||||
isSleep: false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function startShake(){
|
||||
shakeTime = SHAKE_DURATION;
|
||||
pieces.forEach(p=>{
|
||||
p.vx = (Math.random()-0.5)*14;
|
||||
p.vy = (Math.random()-0.5)*14;
|
||||
p.vrot = (Math.random()-0.5)*0.2;
|
||||
p.isSleep = false;
|
||||
})
|
||||
}
|
||||
|
||||
function updatePhysics(dt){
|
||||
const maxR = baseRadius * config.tubeScale * 0.75;
|
||||
const dtFactor = dt / 16.67;
|
||||
|
||||
pieces.forEach(p=>{
|
||||
if(p.isSleep && !shakeTime) return;
|
||||
|
||||
let gx = 0, gy = 0;
|
||||
if(config.gravityEnabled){
|
||||
gx = Math.sin(rotateAngle) * config.gravityStrength * p.mass;
|
||||
gy = Math.cos(rotateAngle) * config.gravityStrength * p.mass;
|
||||
}
|
||||
|
||||
p.vx += gx * dtFactor;
|
||||
p.vy += gy * dtFactor;
|
||||
|
||||
p.x += p.vx * dtFactor;
|
||||
p.y += p.vy * dtFactor;
|
||||
p.rot += p.vrot * dtFactor;
|
||||
|
||||
p.vx *= 0.975;
|
||||
p.vy *= 0.975;
|
||||
p.vrot *= 0.975;
|
||||
|
||||
const velMag = Math.hypot(p.vx, p.vy);
|
||||
const pieceStaticFric = config.baseStaticFriction + (1.0 / p.sizeScale) * 0.08;
|
||||
const gravityDrive = Math.hypot(gx, gy);
|
||||
|
||||
if(velMag < pieceStaticFric && gravityDrive < pieceStaticFric){
|
||||
p.vx = 0;
|
||||
p.vy = 0;
|
||||
p.vrot *= 0.6;
|
||||
if(!shakeTime) p.isSleep = true;
|
||||
}
|
||||
|
||||
const dist = Math.hypot(p.x, p.y);
|
||||
if(dist > maxR){
|
||||
const scale = maxR / dist;
|
||||
p.x *= scale;
|
||||
p.y *= scale;
|
||||
const nx = p.x / maxR;
|
||||
const ny = p.y / maxR;
|
||||
const dot = p.vx * nx + p.vy * ny;
|
||||
if(dot>0){
|
||||
p.vx -= 1.15*dot*nx;
|
||||
p.vy -= 1.15*dot*ny;
|
||||
p.vx *=0.82;
|
||||
p.vy *=0.82;
|
||||
p.vrot *=0.85;
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function drawGlassPiece(p){
|
||||
ctx.save();
|
||||
ctx.translate(p.x, p.y);
|
||||
ctx.rotate(p.rot);
|
||||
ctx.scale(p.sizeScale, p.sizeScale);
|
||||
ctx.globalAlpha = p.alpha;
|
||||
ctx.fillStyle = p.color;
|
||||
|
||||
const shard = p.shard;
|
||||
const sd = shard.shapeData;
|
||||
if(shard.type === 'ellipse'){
|
||||
ctx.beginPath();
|
||||
ctx.rotate(sd.rot);
|
||||
ctx.ellipse(0,0, sd.rx, sd.ry,0,0,Math.PI*2);
|
||||
ctx.fill();
|
||||
}else if(shard.type === 'prim' && sd.primKind === 'circle'){
|
||||
ctx.beginPath();
|
||||
ctx.arc(0,0,sd.r,0,Math.PI*2);
|
||||
ctx.fill();
|
||||
}else{
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(sd.pts[0].x, sd.pts[0].y);
|
||||
for(let i=1;i<sd.pts.length;i++){
|
||||
ctx.lineTo(sd.pts[i].x, sd.pts[i].y);
|
||||
}
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawKaleido(){
|
||||
ctx.clearRect(0,0,W,H);
|
||||
const viewRadius = baseRadius * config.tubeScale;
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(cx,cy);
|
||||
ctx.rotate(rotateAngle);
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(0,0, viewRadius, 0, Math.PI*2);
|
||||
ctx.clip();
|
||||
|
||||
const seg = Math.PI * 2 / config.prismDiv;
|
||||
for(let s=0;s<config.prismDiv;s++){
|
||||
ctx.save();
|
||||
ctx.rotate(seg * s);
|
||||
if(s%2===1){
|
||||
ctx.scale(1,-1);
|
||||
}
|
||||
pieces.forEach(p=> drawGlassPiece(p));
|
||||
ctx.restore();
|
||||
}
|
||||
ctx.restore();
|
||||
|
||||
ctx.save();
|
||||
ctx.globalCompositeOperation = 'screen';
|
||||
ctx.filter = 'blur(3.2px)';
|
||||
ctx.globalAlpha = 0.20;
|
||||
ctx.drawImage(canvas,0,0);
|
||||
ctx.filter = 'none';
|
||||
|
||||
const grad = ctx.createRadialGradient(cx,cy, viewRadius*0.62, cx,cy, viewRadius);
|
||||
grad.addColorStop(0,'rgba(0,0,0,0)');
|
||||
grad.addColorStop(1,'rgba(0,0,0,0.48)');
|
||||
ctx.globalCompositeOperation='multiply';
|
||||
ctx.globalAlpha=1;
|
||||
ctx.fillStyle = grad;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx,cy, viewRadius,0,Math.PI*2);
|
||||
ctx.fill();
|
||||
|
||||
ctx.globalCompositeOperation='source-over';
|
||||
ctx.fillStyle="#000000";
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx,cy,baseRadius,0,Math.PI*2);
|
||||
ctx.arc(cx,cy,viewRadius,0,Math.PI*2,true);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function getMouseAngle(e){
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const t = e.touches ? e.touches[0] : e;
|
||||
const mx = t.clientX - rect.left - cx;
|
||||
const my = t.clientY - rect.top - cy;
|
||||
return Math.atan2(my,mx);
|
||||
}
|
||||
|
||||
canvas.addEventListener('mousedown',(e)=>{
|
||||
isDrag = true;
|
||||
lastMouseAngle = getMouseAngle(e);
|
||||
})
|
||||
window.addEventListener('mousemove',(e)=>{
|
||||
if(!isDrag) return;
|
||||
const nowAngle = getMouseAngle(e);
|
||||
rotateAngle += (nowAngle - lastMouseAngle);
|
||||
lastMouseAngle = nowAngle;
|
||||
pieces.forEach(p=>p.isSleep=false);
|
||||
})
|
||||
window.addEventListener('mouseup',()=>isDrag=false);
|
||||
canvas.addEventListener('dblclick', startShake);
|
||||
|
||||
canvas.addEventListener('touchstart',(e)=>{
|
||||
e.preventDefault();
|
||||
isDrag=true;
|
||||
lastMouseAngle = getMouseAngle(e);
|
||||
})
|
||||
canvas.addEventListener('touchmove',(e)=>{
|
||||
e.preventDefault();
|
||||
if(!isDrag) return;
|
||||
const nowAngle = getMouseAngle(e);
|
||||
rotateAngle += (nowAngle - lastMouseAngle);
|
||||
lastMouseAngle = nowAngle;
|
||||
pieces.forEach(p=>p.isSleep=false);
|
||||
})
|
||||
canvas.addEventListener('touchend',()=>isDrag=false);
|
||||
|
||||
let lastTap=0;
|
||||
canvas.addEventListener('touchstart',()=>{
|
||||
const now=Date.now();
|
||||
if(now-lastTap<320) startShake();
|
||||
lastTap=now;
|
||||
})
|
||||
|
||||
const rangePieces = document.getElementById('rangePieces');
|
||||
const rangePrism = document.getElementById('rangePrism');
|
||||
const rangeTube = document.getElementById('rangeTube');
|
||||
const rangeScaleMin = document.getElementById('rangeScaleMin');
|
||||
const rangeScaleMax = document.getElementById('rangeScaleMax');
|
||||
const rangeGravity = document.getElementById('rangeGravity');
|
||||
const rangeFriction = document.getElementById('rangeFriction');
|
||||
const checkGravity = document.getElementById('checkGravity');
|
||||
|
||||
const valPieces = document.getElementById('valPieces');
|
||||
const valPrism = document.getElementById('valPrism');
|
||||
const valTube = document.getElementById('valTube');
|
||||
const valScaleMin = document.getElementById('valScaleMin');
|
||||
const valScaleMax = document.getElementById('valScaleMax');
|
||||
const valGravity = document.getElementById('valGravity');
|
||||
const valFriction = document.getElementById('valFriction');
|
||||
|
||||
const btnApply = document.getElementById('btnApply');
|
||||
const btnShake = document.getElementById('btnShake');
|
||||
|
||||
rangePieces.addEventListener('input',()=> valPieces.innerText = rangePieces.value);
|
||||
rangePrism.addEventListener('input',()=> valPrism.innerText = rangePrism.value);
|
||||
rangeTube.addEventListener('input',()=> valTube.innerText = Number(rangeTube.value).toFixed(2));
|
||||
rangeScaleMin.addEventListener('input',()=> valScaleMin.innerText = Number(rangeScaleMin.value).toFixed(2)+'x');
|
||||
rangeScaleMax.addEventListener('input',()=> valScaleMax.innerText = Number(rangeScaleMax.value).toFixed(2)+'x');
|
||||
rangeGravity.addEventListener('input',()=> valGravity.innerText = Number(rangeGravity.value).toFixed(2));
|
||||
rangeFriction.addEventListener('input',()=> valFriction.innerText = Number(rangeFriction.value).toFixed(2));
|
||||
|
||||
btnApply.addEventListener('click',()=>{
|
||||
config.pieceCount = Number(rangePieces.value);
|
||||
config.prismDiv = Number(rangePrism.value);
|
||||
config.tubeScale = Number(rangeTube.value);
|
||||
config.scaleMin = Number(rangeScaleMin.value);
|
||||
config.scaleMax = Number(rangeScaleMax.value);
|
||||
config.gravityEnabled = checkGravity.checked;
|
||||
config.gravityStrength = Number(rangeGravity.value);
|
||||
config.baseStaticFriction = Number(rangeFriction.value);
|
||||
|
||||
if(config.scaleMin>config.scaleMax){
|
||||
const tmp = config.scaleMin;
|
||||
config.scaleMin = config.scaleMax;
|
||||
config.scaleMax = tmp;
|
||||
}
|
||||
resetPieces();
|
||||
})
|
||||
|
||||
btnShake.addEventListener('click', startShake);
|
||||
|
||||
let lastTime=0;
|
||||
function animate(time){
|
||||
const dt = time-lastTime;
|
||||
lastTime=time;
|
||||
rotateAngle += (Math.random()-0.5)*0.0006;
|
||||
if(shakeTime > 0) shakeTime -= dt;
|
||||
updatePhysics(dt);
|
||||
drawKaleido();
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
resetPieces();
|
||||
requestAnimationFrame(animate);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,623 @@
|
||||
<!-- ============================================================
|
||||
思想实验室 · labs/minkowski_inkscape · 闵可夫斯基画板
|
||||
「闵可夫斯基空间里的 Inkscape」:
|
||||
- 钢笔/节点工具画世界线,线段按类时/类光/类空实时着色
|
||||
- 快度 φ 滑块做洛伦兹 boost(主动/被动双视图 + 扫掠动画)
|
||||
- 点击事件展开光锥,选中两事件做因果判定
|
||||
独立 lab:单文件自包含(Canvas 2D,零依赖),不引用其他 lab 的任何资源。
|
||||
服务端配置:src/server/thought_lab/labs/minkowski_inkscape/config.json
|
||||
============================================================ -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>闵可夫斯基画板 · 世界线 / 洛伦兹 boost / 光锥</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
display: flex; flex-direction: column;
|
||||
background: #0b1020; color: #dfe7ff;
|
||||
font-family: "Segoe UI", "Microsoft YaHei", sans-serif;
|
||||
overflow: hidden;
|
||||
}
|
||||
header {
|
||||
padding: 8px 16px; display: flex; align-items: baseline; gap: 12px;
|
||||
background: #101733; border-bottom: 1px solid #26305c;
|
||||
}
|
||||
header h1 { font-size: 16px; font-weight: 600; }
|
||||
header .sub { font-size: 12px; color: #8fa1d8; }
|
||||
#toolbar {
|
||||
display: flex; flex-wrap: wrap; gap: 14px; align-items: center;
|
||||
padding: 8px 16px; background: #0e142c; border-bottom: 1px solid #26305c;
|
||||
font-size: 12px;
|
||||
}
|
||||
.tgroup { display: flex; align-items: center; gap: 6px; }
|
||||
.tgroup .glabel { color: #7f92c9; margin-right: 2px; }
|
||||
button {
|
||||
background: #1b2547; color: #dfe7ff; border: 1px solid #33407a;
|
||||
border-radius: 6px; padding: 4px 10px; font-size: 12px; cursor: pointer;
|
||||
}
|
||||
button:hover { background: #24315e; }
|
||||
button.active { background: #3b57c4; border-color: #6d84e8; }
|
||||
button.danger { border-color: #7a3340; }
|
||||
button.danger:hover { background: #5a2531; }
|
||||
input[type=range] { width: 160px; accent-color: #6d84e8; }
|
||||
.phi-read { min-width: 118px; color: #ffd54a; font-family: Consolas, monospace; }
|
||||
#main { flex: 1; display: flex; min-height: 0; }
|
||||
#canvas-wrap { flex: 1; position: relative; min-width: 0; }
|
||||
#cv { position: absolute; inset: 0; display: block; }
|
||||
#inspector {
|
||||
width: 300px; background: #0e142c; border-left: 1px solid #26305c;
|
||||
padding: 12px; overflow-y: auto; font-size: 12px; line-height: 1.7;
|
||||
}
|
||||
#inspector h2 { font-size: 13px; margin: 10px 0 4px; color: #9db1ea; }
|
||||
#inspector h2:first-child { margin-top: 0; }
|
||||
#inspector .card {
|
||||
background: #131b3a; border: 1px solid #26305c; border-radius: 8px;
|
||||
padding: 8px 10px; margin-bottom: 8px; font-family: Consolas, monospace;
|
||||
white-space: pre-wrap; word-break: break-all;
|
||||
}
|
||||
#inspector .empty { color: #66779f; font-family: "Segoe UI", "Microsoft YaHei", sans-serif; }
|
||||
.c-time { color: #4da3ff; } .c-light { color: #ffd54a; } .c-space { color: #ff5d5d; }
|
||||
#help { color: #8fa1d8; }
|
||||
#help li { margin-left: 16px; }
|
||||
/* 危险操作确认弹窗(项目规范:禁用原生 confirm) */
|
||||
#confirm-mask {
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,.55);
|
||||
display: flex; align-items: center; justify-content: center; z-index: 99;
|
||||
}
|
||||
#confirm-mask[hidden] { display: none; }
|
||||
#confirm-box {
|
||||
background: #131b3a; border: 1px solid #7a3340; border-radius: 10px;
|
||||
padding: 18px 22px; max-width: 340px; text-align: center;
|
||||
}
|
||||
#confirm-box p { margin-bottom: 14px; font-size: 13px; }
|
||||
#confirm-box .row { display: flex; gap: 10px; justify-content: center; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>📐 闵可夫斯基画板</h1>
|
||||
<span class="sub">闵可夫斯基空间里的 Inkscape —— 画世界线 · 做洛伦兹变换 · 看光锥(c = 1)</span>
|
||||
</header>
|
||||
|
||||
<div id="toolbar">
|
||||
<div class="tgroup">
|
||||
<span class="glabel">工具</span>
|
||||
<button id="tool-pen" class="active" type="button">钢笔</button>
|
||||
<button id="tool-select" type="button">选择/节点</button>
|
||||
<button id="tool-cone" type="button">光锥</button>
|
||||
</div>
|
||||
<div class="tgroup">
|
||||
<span class="glabel">世界线</span>
|
||||
<button id="btn-newline" type="button">新建世界线</button>
|
||||
<button id="btn-delnode" type="button">删除选中节点</button>
|
||||
<button id="btn-clear" class="danger" type="button">清空画布</button>
|
||||
</div>
|
||||
<div class="tgroup">
|
||||
<span class="glabel">视图</span>
|
||||
<button id="view-active" class="active" type="button">主动</button>
|
||||
<button id="view-passive" type="button">被动</button>
|
||||
</div>
|
||||
<div class="tgroup">
|
||||
<span class="glabel">boost φ</span>
|
||||
<input id="phi" type="range" min="-2" max="2" step="0.01" value="0">
|
||||
<span class="phi-read" id="phi-read">φ=0.00 v=0.00c</span>
|
||||
<button id="btn-sweep" type="button">扫掠动画</button>
|
||||
</div>
|
||||
<div class="tgroup">
|
||||
<button id="btn-clearcone" type="button">清除光锥</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="main">
|
||||
<div id="canvas-wrap"><canvas id="cv"></canvas></div>
|
||||
<aside id="inspector">
|
||||
<h2>线段检查器</h2>
|
||||
<div class="card" id="ins-seg"><span class="empty">选择模式下点击某条线段,读出 Δt / Δx / Δs² / v / τ。</span></div>
|
||||
<h2>事件检查器</h2>
|
||||
<div class="card" id="ins-node"><span class="empty">点击节点查看 S 系与 S′ 系坐标。</span></div>
|
||||
<h2>因果判定</h2>
|
||||
<div class="card" id="ins-pair"><span class="empty">选择模式下:点击节点选中,Shift+点击第二个节点,判定两事件因果关系。</span></div>
|
||||
<h2>图例</h2>
|
||||
<div class="card"><span class="c-time">■ 类时 |Δt|>|Δx|(v<1,可携带因果)</span>
|
||||
<span class="c-light">■ 类光 |Δt|=|Δx|(光子世界线)</span>
|
||||
<span class="c-space">■ 类空 |Δx|>|Δt|(v>1,无因果)</span>
|
||||
<span style="color:#b48cff">■ 紫色斜轴 = S′ 系(被动视图)</span></div>
|
||||
<h2>帮助</h2>
|
||||
<div id="help"><ul>
|
||||
<li>钢笔:点击画布放置事件节点,自动连成世界线。</li>
|
||||
<li>选择/节点:拖动节点;点线段看读数;Shift+点两节点做因果判定;Delete 删除选中节点;Esc 取消选择。</li>
|
||||
<li>光锥:点击任意位置(自动吸附附近节点)放置光锥事件,可拖动。</li>
|
||||
<li>主动视图:轴不动,整幅画被 boost;被动视图:画不动,t′/x′ 轴与网格剪刀式倾斜。</li>
|
||||
<li>间隔分类是洛伦兹不变量:boost 不改变线段颜色。</li>
|
||||
</ul></div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div id="confirm-mask" hidden>
|
||||
<div id="confirm-box">
|
||||
<p>确定清空画布上的所有世界线与光锥吗?此操作不可撤销。</p>
|
||||
<div class="row">
|
||||
<button id="confirm-yes" class="danger" type="button">清空</button>
|
||||
<button id="confirm-no" type="button">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
'use strict';
|
||||
// ------------------------------------------------------------
|
||||
// 状态与常量
|
||||
// ------------------------------------------------------------
|
||||
const state = {
|
||||
tool: 'pen', // pen | select | cone
|
||||
view: 'active', // active | passive
|
||||
phi: 0, // 快度(boost 参数),v = tanh(phi)
|
||||
scale: 46, // 像素 / 单位
|
||||
lines: [], // [{id, nodes:[{t,x}]}]
|
||||
nextLineId: 1,
|
||||
activeLineId: 1,
|
||||
selectedNode: null, // {line, node}
|
||||
pair: [], // 因果判定的两个节点对象
|
||||
selectedSeg: null, // {line, i}
|
||||
cone: null // {t,x}
|
||||
};
|
||||
const COLORS = { time: '#4da3ff', light: '#ffd54a', space: '#ff5d5d' };
|
||||
const LIGHT_TOL = 0.01; // |Δt|-|Δx| 小于该值视为类光
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// 物理工具
|
||||
// ------------------------------------------------------------
|
||||
const boost = (t, x, phi) => ({
|
||||
t: Math.cosh(phi) * t - Math.sinh(phi) * x,
|
||||
x: -Math.sinh(phi) * t + Math.cosh(phi) * x
|
||||
});
|
||||
const classify = (a, b) => {
|
||||
const dt = b.t - a.t, dx = b.x - a.x;
|
||||
if (Math.abs(Math.abs(dt) - Math.abs(dx)) < LIGHT_TOL) return 'light';
|
||||
return Math.abs(dt) > Math.abs(dx) ? 'time' : 'space';
|
||||
};
|
||||
const CLASS_NAME = { time: '类时', light: '类光', space: '类空' };
|
||||
const fmt = (v, n) => (Number.isFinite(v) ? v.toFixed(n === undefined ? 2 : n) : '—');
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// 画布与坐标变换
|
||||
// ------------------------------------------------------------
|
||||
const wrap = document.getElementById('canvas-wrap');
|
||||
const cv = document.getElementById('cv');
|
||||
const ctx = cv.getContext('2d');
|
||||
let W = 0, H = 0, DPR = 1;
|
||||
|
||||
const rest2disp = p => state.view === 'active' ? boost(p.t, p.x, state.phi) : { t: p.t, x: p.x };
|
||||
const disp2rest = p => state.view === 'active' ? boost(p.t, p.x, -state.phi) : { t: p.t, x: p.x };
|
||||
const d2s = p => ({ x: W / 2 + p.x * state.scale, y: H / 2 - p.t * state.scale });
|
||||
const s2d = (px, py) => ({ t: (H / 2 - py) / state.scale, x: (px - W / 2) / state.scale });
|
||||
const nodeScreen = n => d2s(rest2disp(n));
|
||||
|
||||
function resize() {
|
||||
DPR = window.devicePixelRatio || 1;
|
||||
const r = wrap.getBoundingClientRect();
|
||||
W = Math.max(1, r.width); H = Math.max(1, r.height);
|
||||
cv.width = Math.round(W * DPR); cv.height = Math.round(H * DPR);
|
||||
draw();
|
||||
}
|
||||
window.addEventListener('resize', resize);
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// 绘制
|
||||
// ------------------------------------------------------------
|
||||
function drawSquareGrid() {
|
||||
const xMax = (W / 2) / state.scale, tMax = (H / 2) / state.scale;
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.07)'; ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
for (let k = Math.ceil(-xMax); k <= xMax; k++) {
|
||||
const p = d2s({ t: -tMax, x: k }), q = d2s({ t: tMax, x: k });
|
||||
ctx.moveTo(p.x, p.y); ctx.lineTo(q.x, q.y);
|
||||
}
|
||||
for (let k = Math.ceil(-tMax); k <= tMax; k++) {
|
||||
const p = d2s({ t: k, x: -xMax }), q = d2s({ t: k, x: xMax });
|
||||
ctx.moveTo(p.x, p.y); ctx.lineTo(q.x, q.y);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
function drawAxes() {
|
||||
const p0 = d2s({ t: 0, x: -(W / 2) / state.scale }), p1 = d2s({ t: 0, x: (W / 2) / state.scale });
|
||||
const q0 = d2s({ t: -(H / 2) / state.scale, x: 0 }), q1 = d2s({ t: (H / 2) / state.scale, x: 0 });
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.45)'; ctx.lineWidth = 1.2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(p0.x, p0.y); ctx.lineTo(p1.x, p1.y);
|
||||
ctx.moveTo(q0.x, q0.y); ctx.lineTo(q1.x, q1.y);
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.6)'; ctx.font = '12px Consolas, monospace';
|
||||
const lbl = state.view === 'active' ? ["x′", "t′"] : ['x', 't'];
|
||||
ctx.fillText(lbl[0], p1.x - 14, p1.y - 6);
|
||||
ctx.fillText(lbl[1], q1.x + 6, q1.y + 12);
|
||||
}
|
||||
|
||||
function drawLightRef() {
|
||||
// 过原点的 45° 光线参考(洛伦兹不变)
|
||||
const L = Math.max(W, H);
|
||||
const o = d2s({ t: 0, x: 0 });
|
||||
ctx.strokeStyle = 'rgba(255,213,74,0.28)'; ctx.setLineDash([5, 6]); ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(o.x - L, o.y - L); ctx.lineTo(o.x + L, o.y + L);
|
||||
ctx.moveTo(o.x - L, o.y + L); ctx.lineTo(o.x + L, o.y - L);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
|
||||
function drawTiltedGrid() {
|
||||
// 主动视图:原 S 系整数网格经 boost 后的倾斜网格(展示"画被变换")
|
||||
const R = Math.max(W, H) / state.scale;
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.06)'; ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
for (let k = Math.ceil(-R); k <= R; k++) {
|
||||
let a = boost(-R, k, state.phi), b = boost(R, k, state.phi); // x=k 竖线
|
||||
let p = d2s(a), q = d2s(b);
|
||||
ctx.moveTo(p.x, p.y); ctx.lineTo(q.x, q.y);
|
||||
a = boost(k, -R, state.phi); b = boost(k, R, state.phi); // t=k 横线
|
||||
p = d2s(a); q = d2s(b);
|
||||
ctx.moveTo(p.x, p.y); ctx.lineTo(q.x, q.y);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
function drawPrimedAxes() {
|
||||
// 被动视图:S′ 系斜轴 + 斜网格
|
||||
const R = Math.max(W, H) / state.scale;
|
||||
const sh = Math.sinh(state.phi), ch = Math.cosh(state.phi);
|
||||
ctx.strokeStyle = 'rgba(180,140,255,0.20)'; ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
for (let k = Math.ceil(-R); k <= R; k++) {
|
||||
// x′=k:x = (k + sh*t)/ch,t 从 -R..R
|
||||
let a = { t: -R, x: (k + sh * -R) / ch }, b = { t: R, x: (k + sh * R) / ch };
|
||||
let p = d2s(a), q = d2s(b);
|
||||
ctx.moveTo(p.x, p.y); ctx.lineTo(q.x, q.y);
|
||||
// t′=k:t = (k + sh*x)/ch,x 从 -R..R
|
||||
a = { t: (k + sh * -R) / ch, x: -R }; b = { t: (k + sh * R) / ch, x: R };
|
||||
p = d2s(a); q = d2s(b);
|
||||
ctx.moveTo(p.x, p.y); ctx.lineTo(q.x, q.y);
|
||||
}
|
||||
ctx.stroke();
|
||||
// 斜主轴
|
||||
const tA = d2s({ t: -R, x: Math.tanh(state.phi) * -R }), tB = d2s({ t: R, x: Math.tanh(state.phi) * R });
|
||||
const xA = d2s({ t: Math.tanh(state.phi) * -R, x: -R }), xB = d2s({ t: Math.tanh(state.phi) * R, x: R });
|
||||
ctx.strokeStyle = 'rgba(180,140,255,0.75)'; ctx.lineWidth = 1.4;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(tA.x, tA.y); ctx.lineTo(tB.x, tB.y);
|
||||
ctx.moveTo(xA.x, xA.y); ctx.lineTo(xB.x, xB.y);
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = 'rgba(180,140,255,0.9)'; ctx.font = '12px Consolas, monospace';
|
||||
ctx.fillText("t′", tB.x + 6, tB.y + 12);
|
||||
ctx.fillText("x′", xB.x - 16, xB.y - 6);
|
||||
}
|
||||
|
||||
function drawCone() {
|
||||
if (!state.cone) return;
|
||||
const p = d2s(rest2disp(state.cone));
|
||||
const L = Math.max(W, H);
|
||||
// 未来/过去楔形
|
||||
ctx.fillStyle = 'rgba(255,213,74,0.10)';
|
||||
ctx.beginPath(); ctx.moveTo(p.x, p.y); ctx.lineTo(p.x - L, p.y - L); ctx.lineTo(p.x + L, p.y - L); ctx.closePath(); ctx.fill();
|
||||
ctx.beginPath(); ctx.moveTo(p.x, p.y); ctx.lineTo(p.x - L, p.y + L); ctx.lineTo(p.x + L, p.y + L); ctx.closePath(); ctx.fill();
|
||||
// 锥面
|
||||
ctx.strokeStyle = 'rgba(255,213,74,0.65)'; ctx.setLineDash([6, 5]); ctx.lineWidth = 1.2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(p.x - L, p.y - L); ctx.lineTo(p.x + L, p.y + L);
|
||||
ctx.moveTo(p.x - L, p.y + L); ctx.lineTo(p.x + L, p.y - L);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
// 事件标记
|
||||
ctx.fillStyle = '#ffd54a';
|
||||
ctx.beginPath(); ctx.arc(p.x, p.y, 4, 0, Math.PI * 2); ctx.fill();
|
||||
}
|
||||
|
||||
function drawLines() {
|
||||
state.lines.forEach((line, li) => {
|
||||
// 线段
|
||||
for (let i = 0; i < line.nodes.length - 1; i++) {
|
||||
const a = nodeScreen(line.nodes[i]), b = nodeScreen(line.nodes[i + 1]);
|
||||
const cls = classify(line.nodes[i], line.nodes[i + 1]);
|
||||
if (state.selectedSeg && state.selectedSeg.line === line && state.selectedSeg.i === i) {
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.30)'; ctx.lineWidth = 7;
|
||||
ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke();
|
||||
}
|
||||
ctx.strokeStyle = COLORS[cls]; ctx.lineWidth = 2.4;
|
||||
ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke();
|
||||
}
|
||||
// 节点
|
||||
line.nodes.forEach(n => {
|
||||
const p = nodeScreen(n);
|
||||
ctx.fillStyle = '#e8f0ff'; ctx.strokeStyle = '#0b1020'; ctx.lineWidth = 1.5;
|
||||
ctx.beginPath(); ctx.arc(p.x, p.y, line.id === state.activeLineId ? 5 : 4, 0, Math.PI * 2);
|
||||
ctx.fill(); ctx.stroke();
|
||||
if (state.selectedNode && state.selectedNode.node === n) {
|
||||
ctx.strokeStyle = '#ffffff'; ctx.lineWidth = 2;
|
||||
ctx.beginPath(); ctx.arc(p.x, p.y, 8, 0, Math.PI * 2); ctx.stroke();
|
||||
}
|
||||
if (state.pair.includes(n)) {
|
||||
ctx.strokeStyle = '#7dff9b'; ctx.lineWidth = 2;
|
||||
ctx.beginPath(); ctx.arc(p.x, p.y, 8, 0, Math.PI * 2); ctx.stroke();
|
||||
}
|
||||
});
|
||||
// 世界线编号
|
||||
if (line.nodes.length) {
|
||||
const p = nodeScreen(line.nodes[0]);
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.55)'; ctx.font = '11px Consolas, monospace';
|
||||
ctx.fillText('线' + line.id, p.x + 8, p.y - 8);
|
||||
}
|
||||
});
|
||||
// 因果对连线
|
||||
if (state.pair.length === 2) {
|
||||
const a = nodeScreen(state.pair[0]), b = nodeScreen(state.pair[1]);
|
||||
ctx.strokeStyle = 'rgba(125,255,155,0.5)'; ctx.setLineDash([4, 4]); ctx.lineWidth = 1.2;
|
||||
ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
}
|
||||
|
||||
function draw() {
|
||||
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
ctx.fillStyle = '#0b1020'; ctx.fillRect(0, 0, W, H);
|
||||
drawSquareGrid();
|
||||
if (state.view === 'active') drawTiltedGrid(); else drawPrimedAxes();
|
||||
drawLightRef();
|
||||
drawAxes();
|
||||
drawCone();
|
||||
drawLines();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// 命中检测
|
||||
// ------------------------------------------------------------
|
||||
function hitNode(px, py, radius) {
|
||||
let best = null, bestD = radius;
|
||||
state.lines.forEach(line => line.nodes.forEach(n => {
|
||||
const p = nodeScreen(n);
|
||||
const d = Math.hypot(p.x - px, p.y - py);
|
||||
if (d < bestD) { bestD = d; best = { line, node: n }; }
|
||||
}));
|
||||
return best;
|
||||
}
|
||||
function hitSeg(px, py, radius) {
|
||||
let best = null, bestD = radius;
|
||||
state.lines.forEach(line => {
|
||||
for (let i = 0; i < line.nodes.length - 1; i++) {
|
||||
const a = nodeScreen(line.nodes[i]), b = nodeScreen(line.nodes[i + 1]);
|
||||
const d = distToSeg(px, py, a, b);
|
||||
if (d < bestD) { bestD = d; best = { line, i }; }
|
||||
}
|
||||
});
|
||||
return best;
|
||||
}
|
||||
function distToSeg(px, py, a, b) {
|
||||
const vx = b.x - a.x, vy = b.y - a.y;
|
||||
const L2 = vx * vx + vy * vy;
|
||||
if (L2 < 1e-9) return Math.hypot(px - a.x, py - a.y);
|
||||
let u = ((px - a.x) * vx + (py - a.y) * vy) / L2;
|
||||
u = Math.max(0, Math.min(1, u));
|
||||
return Math.hypot(px - (a.x + u * vx), py - (a.y + u * vy));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// 指针交互
|
||||
// ------------------------------------------------------------
|
||||
let dragNode = null, dragCone = false;
|
||||
|
||||
cv.addEventListener('pointerdown', e => {
|
||||
const r = cv.getBoundingClientRect();
|
||||
const px = e.clientX - r.left, py = e.clientY - r.top;
|
||||
|
||||
if (state.tool === 'pen') {
|
||||
const line = state.lines.find(l => l.id === state.activeLineId) || newLine();
|
||||
const w = disp2rest(s2d(px, py));
|
||||
line.nodes.push({ t: w.t, x: w.x });
|
||||
draw(); updateInspector();
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.tool === 'cone') {
|
||||
const hit = hitNode(px, py, 12);
|
||||
if (hit) { state.cone = { t: hit.node.t, x: hit.node.x }; dragCone = true; }
|
||||
else if (state.cone) {
|
||||
const c = nodeScreen(state.cone);
|
||||
if (Math.hypot(c.x - px, c.y - py) < 14) dragCone = true;
|
||||
else { const w = disp2rest(s2d(px, py)); state.cone = { t: w.t, x: w.x }; dragCone = true; }
|
||||
} else { const w = disp2rest(s2d(px, py)); state.cone = { t: w.t, x: w.x }; dragCone = true; }
|
||||
draw();
|
||||
return;
|
||||
}
|
||||
|
||||
// select 模式
|
||||
const hit = hitNode(px, py, 9);
|
||||
if (hit) {
|
||||
if (e.shiftKey) {
|
||||
if (state.pair.includes(hit.node)) state.pair = state.pair.filter(n => n !== hit.node);
|
||||
else { state.pair.push(hit.node); if (state.pair.length > 2) state.pair = state.pair.slice(-2); }
|
||||
} else {
|
||||
state.selectedNode = hit;
|
||||
}
|
||||
dragNode = hit;
|
||||
cv.setPointerCapture(e.pointerId);
|
||||
draw(); updateInspector();
|
||||
return;
|
||||
}
|
||||
const seg = hitSeg(px, py, 6);
|
||||
if (seg) { state.selectedSeg = seg; state.selectedNode = null; draw(); updateInspector(); return; }
|
||||
state.selectedSeg = null; state.selectedNode = null; state.pair = [];
|
||||
draw(); updateInspector();
|
||||
});
|
||||
|
||||
cv.addEventListener('pointermove', e => {
|
||||
if (!dragNode && !dragCone) return;
|
||||
const r = cv.getBoundingClientRect();
|
||||
const px = e.clientX - r.left, py = e.clientY - r.top;
|
||||
const w = disp2rest(s2d(px, py));
|
||||
if (dragNode) { dragNode.node.t = w.t; dragNode.node.x = w.x; }
|
||||
if (dragCone && state.cone) { state.cone.t = w.t; state.cone.x = w.x; }
|
||||
draw(); updateInspector();
|
||||
});
|
||||
|
||||
cv.addEventListener('pointerup', () => { dragNode = null; dragCone = false; });
|
||||
|
||||
window.addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape') { state.selectedNode = null; state.selectedSeg = null; state.pair = []; draw(); updateInspector(); }
|
||||
if (e.key === 'Delete') deleteSelectedNode();
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// 检查器
|
||||
// ------------------------------------------------------------
|
||||
function segInfo(a, b) {
|
||||
const dt = b.t - a.t, dx = b.x - a.x;
|
||||
const ds2 = dt * dt - dx * dx;
|
||||
const cls = classify(a, b);
|
||||
const v = dt !== 0 ? dx / dt : Infinity;
|
||||
const tau = ds2 > 0 ? Math.sqrt(ds2) : NaN;
|
||||
return { dt, dx, ds2, cls, v, tau };
|
||||
}
|
||||
|
||||
function updateInspector() {
|
||||
const insSeg = document.getElementById('ins-seg');
|
||||
const insNode = document.getElementById('ins-node');
|
||||
const insPair = document.getElementById('ins-pair');
|
||||
|
||||
if (state.selectedSeg) {
|
||||
const { line, i } = state.selectedSeg;
|
||||
const a = line.nodes[i], b = line.nodes[i + 1];
|
||||
if (a && b) {
|
||||
const s = segInfo(a, b);
|
||||
insSeg.innerHTML =
|
||||
`分类:<span class="c-${s.cls}">${CLASS_NAME[s.cls]}</span>\n` +
|
||||
`Δt = ${fmt(s.dt)} Δx = ${fmt(s.dx)}\n` +
|
||||
`Δs² = Δt²−Δx² = ${fmt(s.ds2, 3)}\n` +
|
||||
`v = ${fmt(s.v)} c${Math.abs(s.v) > 1 ? '(超光速→类空)' : ''}\n` +
|
||||
`τ(固有时)= ${Number.isFinite(s.tau) ? fmt(s.tau, 3) : '—(非类时)'}`;
|
||||
} else insSeg.innerHTML = '<span class="empty">线段已不存在。</span>';
|
||||
} else {
|
||||
insSeg.innerHTML = '<span class="empty">选择模式下点击某条线段,读出 Δt / Δx / Δs² / v / τ。</span>';
|
||||
}
|
||||
|
||||
if (state.selectedNode) {
|
||||
const n = state.selectedNode.node;
|
||||
const p = boost(n.t, n.x, state.phi);
|
||||
insNode.innerHTML =
|
||||
`S 系:t = ${fmt(n.t)} x = ${fmt(n.x)}\n` +
|
||||
`S′ 系:t′ = ${fmt(p.t)} x′ = ${fmt(p.x)}`;
|
||||
} else {
|
||||
insNode.innerHTML = '<span class="empty">点击节点查看 S 系与 S′ 系坐标。</span>';
|
||||
}
|
||||
|
||||
if (state.pair.length === 2) {
|
||||
const s = segInfo(state.pair[0], state.pair[1]);
|
||||
let verdict = '';
|
||||
if (s.cls === 'time') verdict = '类时间隔:两事件可因果影响;存在某参考系使二者同地。时间顺序在所有参考系中固定。';
|
||||
else if (s.cls === 'light') verdict = '类光间隔:恰可由光信号联系;任何有质量物体都无法往返。';
|
||||
else verdict = '类空间隔:两事件互不影响;存在某参考系使二者同时。';
|
||||
insPair.innerHTML =
|
||||
`Δt = ${fmt(s.dt)} Δx = ${fmt(s.dx)}\n` +
|
||||
`Δs² = ${fmt(s.ds2, 3)} → <span class="c-${s.cls}">${CLASS_NAME[s.cls]}</span>\n` +
|
||||
verdict;
|
||||
} else {
|
||||
insPair.innerHTML = '<span class="empty">选择模式下:点击节点选中,Shift+点击第二个节点,判定两事件因果关系。</span>';
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// 工具栏
|
||||
// ------------------------------------------------------------
|
||||
function newLine() {
|
||||
const line = { id: state.nextLineId++, nodes: [] };
|
||||
state.lines.push(line);
|
||||
state.activeLineId = line.id;
|
||||
return line;
|
||||
}
|
||||
function deleteSelectedNode() {
|
||||
if (!state.selectedNode) return;
|
||||
const { line, node } = state.selectedNode;
|
||||
line.nodes = line.nodes.filter(n => n !== node);
|
||||
if (!line.nodes.length) state.lines = state.lines.filter(l => l !== line);
|
||||
state.pair = state.pair.filter(n => n !== node);
|
||||
state.selectedNode = null;
|
||||
state.selectedSeg = null;
|
||||
draw(); updateInspector();
|
||||
}
|
||||
|
||||
const toolBtns = { pen: document.getElementById('tool-pen'), select: document.getElementById('tool-select'), cone: document.getElementById('tool-cone') };
|
||||
Object.keys(toolBtns).forEach(k => toolBtns[k].addEventListener('click', () => {
|
||||
state.tool = k;
|
||||
Object.keys(toolBtns).forEach(j => toolBtns[j].classList.toggle('active', j === k));
|
||||
}));
|
||||
|
||||
document.getElementById('btn-newline').addEventListener('click', () => {
|
||||
newLine();
|
||||
state.tool = 'pen';
|
||||
Object.keys(toolBtns).forEach(j => toolBtns[j].classList.toggle('active', j === 'pen'));
|
||||
draw();
|
||||
});
|
||||
document.getElementById('btn-delnode').addEventListener('click', deleteSelectedNode);
|
||||
|
||||
// 清空画布:危险操作,自定义 confirm 弹窗(项目规范禁用原生 confirm)
|
||||
const mask = document.getElementById('confirm-mask');
|
||||
document.getElementById('btn-clear').addEventListener('click', () => { mask.hidden = false; });
|
||||
document.getElementById('confirm-no').addEventListener('click', () => { mask.hidden = true; });
|
||||
document.getElementById('confirm-yes').addEventListener('click', () => {
|
||||
state.lines = []; state.pair = []; state.selectedNode = null; state.selectedSeg = null; state.cone = null;
|
||||
newLine();
|
||||
mask.hidden = true;
|
||||
draw(); updateInspector();
|
||||
});
|
||||
|
||||
// 视图切换
|
||||
const viewBtns = { active: document.getElementById('view-active'), passive: document.getElementById('view-passive') };
|
||||
Object.keys(viewBtns).forEach(k => viewBtns[k].addEventListener('click', () => {
|
||||
state.view = k;
|
||||
Object.keys(viewBtns).forEach(j => viewBtns[j].classList.toggle('active', j === k));
|
||||
draw(); updateInspector();
|
||||
}));
|
||||
|
||||
// boost 滑块与读数
|
||||
const phiInput = document.getElementById('phi');
|
||||
const phiRead = document.getElementById('phi-read');
|
||||
function updatePhiRead() {
|
||||
phiRead.textContent = `φ=${fmt(state.phi)} v=${fmt(Math.tanh(state.phi))}c`;
|
||||
}
|
||||
phiInput.addEventListener('input', () => {
|
||||
state.phi = parseFloat(phiInput.value) || 0;
|
||||
updatePhiRead(); draw(); updateInspector();
|
||||
});
|
||||
|
||||
// 扫掠动画:φ 从 0 缓动到滑块目标值
|
||||
let animId = null;
|
||||
document.getElementById('btn-sweep').addEventListener('click', () => {
|
||||
if (animId) cancelAnimationFrame(animId);
|
||||
const target = state.phi;
|
||||
const t0 = performance.now(), dur = 1500;
|
||||
const step = now => {
|
||||
const k = Math.min(1, (now - t0) / dur);
|
||||
const e = k < 0.5 ? 2 * k * k : 1 - Math.pow(-2 * k + 2, 2) / 2; // easeInOutQuad
|
||||
state.phi = target * e;
|
||||
phiInput.value = String(state.phi);
|
||||
updatePhiRead(); draw(); updateInspector();
|
||||
animId = k < 1 ? requestAnimationFrame(step) : null;
|
||||
};
|
||||
animId = requestAnimationFrame(step);
|
||||
});
|
||||
|
||||
document.getElementById('btn-clearcone').addEventListener('click', () => { state.cone = null; draw(); });
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// 初始化
|
||||
// ------------------------------------------------------------
|
||||
newLine();
|
||||
resize();
|
||||
updatePhiRead();
|
||||
updateInspector();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -144,6 +144,28 @@ const bindRoutes = (app) => {
|
||||
|
||||
// 4) 其余 /api/thought_lab 接口:默认导航鉴权
|
||||
app.use('/api/thought_lab', navAuthApi)
|
||||
|
||||
// 5) lab 后端自动绑定:labs/<lab_id>/index.js 若导出 bindRoutes 则独立注册
|
||||
// (注册在 API 守卫之后,自动继承导航鉴权;各 lab 互不干扰)
|
||||
bindLabBackends(app)
|
||||
}
|
||||
|
||||
const bindLabBackends = (app) => {
|
||||
try {
|
||||
if (!fs.existsSync(LABS_DIR)) return
|
||||
const dirs = fs.readdirSync(LABS_DIR, { withFileTypes: true })
|
||||
for (const d of dirs) {
|
||||
if (!d.isDirectory()) continue
|
||||
const entry = path.join(LABS_DIR, d.name, 'index.js')
|
||||
if (!fs.existsSync(entry)) continue
|
||||
try {
|
||||
const mod = require(entry)
|
||||
if (mod && typeof mod.bindRoutes === 'function') mod.bindRoutes(app)
|
||||
} catch (e) {
|
||||
try { console.error(`[thought_lab] 绑定 lab 后端失败: ${d.name} ${String(e.message || e)}`) } catch {}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
module.exports = { bindRoutes, listLabs, loadLabConfig }
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"_comment": "思想实验室 · 万花筒模拟器:Canvas 万花筒(纸片形状/颜色、旋转次数、棱镜镜像与旋扭、镜筒宽度全部可调),配置方案经 sqlite 持久化到本文件夹的 kaleidoscope.db,支持多套方案保存/加载/删除,刷新不丢。本 lab 完全独立;数据库、OSS 等后台逻辑统一放在本文件夹内。",
|
||||
"id_comment": "lab 唯一标识(snake_case),与文件夹名保持一致",
|
||||
"id": "kaleidoscope",
|
||||
"name_comment": "首页卡片显示名称",
|
||||
"name": "万花筒模拟器",
|
||||
"icon_comment": "首页卡片图标(emoji)",
|
||||
"icon": "🌈",
|
||||
"desc_comment": "一句话简介,作为卡片 tooltip 展示",
|
||||
"desc": "纸片/棱镜/镜筒可调 · 方案 sqlite 持久化",
|
||||
"sort_order_comment": "卡片排序,数值越小越靠前",
|
||||
"sort_order": 70,
|
||||
"enabled_comment": "是否启用(false 时列表接口不返回该 lab)",
|
||||
"enabled": true,
|
||||
"ui_mode_comment": "弹窗交互模式:debug=交互详情弹窗(3s 自动关闭);normal=成功降级 Toast(2s),失败仍弹窗",
|
||||
"ui_mode": "debug"
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
// ============================================================
|
||||
// kaleidoscope/db.js - 万花筒方案 SQLite 持久化(lab 独立,不共享其他模块)
|
||||
// 数据库文件:本文件夹下 kaleidoscope.db(WAL 模式)
|
||||
// 表:kaleidoscope_schemes —— 保存多套配置方案(参数 JSON + 随机种子)
|
||||
// 时间字段统一北京时间(Asia/Shanghai)
|
||||
// ============================================================
|
||||
const path = require('path')
|
||||
const Database = require('better-sqlite3')
|
||||
|
||||
const DB_PATH = path.join(__dirname, 'kaleidoscope.db')
|
||||
|
||||
let db = null
|
||||
|
||||
// 北京时间格式化:YYYY-MM-DD HH:mm:ss
|
||||
const BJ_FMT = new Intl.DateTimeFormat('zh-CN', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
hour12: false,
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit'
|
||||
})
|
||||
const nowBeijing = () => {
|
||||
const parts = BJ_FMT.formatToParts(new Date())
|
||||
const get = t => (parts.find(p => p.type === t) || {}).value || ''
|
||||
return `${get('year')}-${get('month')}-${get('day')} ${get('hour')}:${get('minute')}:${get('second')}`
|
||||
}
|
||||
|
||||
const getDB = () => {
|
||||
if (db) return db
|
||||
db = new Database(DB_PATH)
|
||||
db.pragma('journal_mode = WAL')
|
||||
db.pragma('synchronous = NORMAL')
|
||||
initSchema(db)
|
||||
return db
|
||||
}
|
||||
|
||||
const initSchema = (database) => {
|
||||
database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS kaleidoscope_schemes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
params TEXT NOT NULL,
|
||||
seed INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
`)
|
||||
database.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_kaleido_schemes_updated_at ON kaleidoscope_schemes(updated_at DESC)
|
||||
`)
|
||||
}
|
||||
|
||||
const rowToScheme = (row) => {
|
||||
if (!row) return null
|
||||
let params = {}
|
||||
try { params = JSON.parse(row.params) } catch {}
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
params,
|
||||
seed: row.seed,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at
|
||||
}
|
||||
}
|
||||
|
||||
const listSchemes = () => {
|
||||
const rows = getDB().prepare(`
|
||||
SELECT id, name, params, seed, created_at, updated_at
|
||||
FROM kaleidoscope_schemes
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 200
|
||||
`).all()
|
||||
return rows.map(rowToScheme)
|
||||
}
|
||||
|
||||
const getScheme = (id) => {
|
||||
const row = getDB().prepare(`
|
||||
SELECT id, name, params, seed, created_at, updated_at
|
||||
FROM kaleidoscope_schemes WHERE id = ?
|
||||
`).get(id)
|
||||
return rowToScheme(row)
|
||||
}
|
||||
|
||||
const createScheme = ({ name, params, seed }) => {
|
||||
const now = nowBeijing()
|
||||
const result = getDB().prepare(`
|
||||
INSERT INTO kaleidoscope_schemes (name, params, seed, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(name, JSON.stringify(params), seed, now, now)
|
||||
return getScheme(Number(result.lastInsertRowid))
|
||||
}
|
||||
|
||||
const updateScheme = (id, { name, params, seed }) => {
|
||||
const now = nowBeijing()
|
||||
const result = getDB().prepare(`
|
||||
UPDATE kaleidoscope_schemes
|
||||
SET name = ?, params = ?, seed = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`).run(name, JSON.stringify(params), seed, now, id)
|
||||
return result.changes > 0 ? getScheme(id) : null
|
||||
}
|
||||
|
||||
const deleteScheme = (id) => {
|
||||
const result = getDB().prepare(`DELETE FROM kaleidoscope_schemes WHERE id = ?`).run(id)
|
||||
return result.changes > 0
|
||||
}
|
||||
|
||||
module.exports = { getDB, listSchemes, getScheme, createScheme, updateScheme, deleteScheme }
|
||||
@@ -0,0 +1,110 @@
|
||||
// ============================================================
|
||||
// kaleidoscope/index.js - 万花筒模拟器 lab 后端路由
|
||||
// 挂载点:/api/thought_lab/kaleidoscope/*
|
||||
// 由 thought_lab/index.js 自动扫描绑定(注册在 nav_gate API 守卫之后,继承导航鉴权)
|
||||
// 数据库逻辑见同目录 db.js(独立 kaleidoscope.db)
|
||||
// ============================================================
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const db = require('./db')
|
||||
|
||||
const CONFIG_PATH = path.join(__dirname, 'config.json')
|
||||
const NAME_MAX = 60
|
||||
const PARAMS_MAX_BYTES = 10 * 1024
|
||||
|
||||
const readConfig = () => {
|
||||
try {
|
||||
if (fs.existsSync(CONFIG_PATH)) return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'))
|
||||
} catch {}
|
||||
return {}
|
||||
}
|
||||
|
||||
// 原型污染防护(项目统一风格)
|
||||
const PROTO_KEYS = new Set(['__proto__', 'constructor', 'prototype', '__defineGetter__', '__defineSetter__', '__lookupGetter__', '__lookupSetter__'])
|
||||
const sanitizeObject = (obj, depth = 0) => {
|
||||
if (depth > 10) return {}
|
||||
if (obj === null || typeof obj !== 'object') return obj
|
||||
if (Array.isArray(obj)) return obj.map(item => sanitizeObject(item, depth + 1))
|
||||
const clean = {}
|
||||
for (const [key, val] of Object.entries(obj)) {
|
||||
if (PROTO_KEYS.has(key)) continue
|
||||
clean[key] = sanitizeObject(val, depth + 1)
|
||||
}
|
||||
return clean
|
||||
}
|
||||
|
||||
// 校验并规范化入参,非法返回 { error }
|
||||
const parsePayload = (body) => {
|
||||
const name = String((body && body.name) || '').trim()
|
||||
if (!name) return { error: '方案名称不能为空' }
|
||||
if (name.length > NAME_MAX) return { error: `名称超过 ${NAME_MAX} 字符` }
|
||||
|
||||
let params = body && body.params
|
||||
if (typeof params !== 'object' || params === null || Array.isArray(params)) return { error: 'params 必须是 JSON 对象' }
|
||||
params = sanitizeObject(params)
|
||||
if (Buffer.byteLength(JSON.stringify(params), 'utf8') > PARAMS_MAX_BYTES) return { error: 'params 超过 10KB 限制' }
|
||||
|
||||
const seed = Number.isFinite(Number(body && body.seed)) ? Math.trunc(Number(body.seed)) : 0
|
||||
return { name, params, seed }
|
||||
}
|
||||
|
||||
const bindRoutes = (app) => {
|
||||
const B = '/api/thought_lab/kaleidoscope'
|
||||
|
||||
// 元信息:ui_mode 等(前端弹窗双模式依据)
|
||||
app.get(B + '/meta', (req, res) => {
|
||||
const cfg = readConfig()
|
||||
return res.json({ ok: true, ui_mode: String(cfg.ui_mode || 'debug') })
|
||||
})
|
||||
|
||||
// 方案列表
|
||||
app.get(B + '/schemes', (req, res) => {
|
||||
try {
|
||||
return res.json({ ok: true, schemes: db.listSchemes() })
|
||||
} catch (e) {
|
||||
return res.status(500).json({ ok: false, error: 'internal_error' })
|
||||
}
|
||||
})
|
||||
|
||||
// 新建方案
|
||||
app.post(B + '/schemes', (req, res) => {
|
||||
const p = parsePayload(req.body)
|
||||
if (p.error) return res.status(400).json({ ok: false, error: p.error })
|
||||
try {
|
||||
const scheme = db.createScheme(p)
|
||||
return res.json({ ok: true, scheme })
|
||||
} catch (e) {
|
||||
return res.status(500).json({ ok: false, error: 'internal_error' })
|
||||
}
|
||||
})
|
||||
|
||||
// 更新方案
|
||||
app.put(B + '/schemes/:id', (req, res) => {
|
||||
const id = Number(req.params.id)
|
||||
if (!Number.isFinite(id)) return res.status(400).json({ ok: false, error: 'invalid id' })
|
||||
const p = parsePayload(req.body)
|
||||
if (p.error) return res.status(400).json({ ok: false, error: p.error })
|
||||
try {
|
||||
const scheme = db.updateScheme(id, p)
|
||||
if (!scheme) return res.status(404).json({ ok: false, error: 'not_found' })
|
||||
return res.json({ ok: true, scheme })
|
||||
} catch (e) {
|
||||
return res.status(500).json({ ok: false, error: 'internal_error' })
|
||||
}
|
||||
})
|
||||
|
||||
// 删除方案
|
||||
app.delete(B + '/schemes/:id', (req, res) => {
|
||||
const id = Number(req.params.id)
|
||||
if (!Number.isFinite(id)) return res.status(400).json({ ok: false, error: 'invalid id' })
|
||||
try {
|
||||
const ok = db.deleteScheme(id)
|
||||
if (!ok) return res.status(404).json({ ok: false, error: 'not_found' })
|
||||
return res.json({ ok: true })
|
||||
} catch (e) {
|
||||
return res.status(500).json({ ok: false, error: 'internal_error' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = { bindRoutes }
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"_comment": "思想实验室 · 闵可夫斯基画板:『闵可夫斯基空间里的 Inkscape』——在时空画布上画世界线(钢笔/节点工具,线段按类时/类光/类空实时着色),用快度滑块做洛伦兹变换(主动/被动双视图 + 扫掠动画),点击事件展开光锥并对两事件做因果判定。本 lab 完全独立;未来若需要数据库、OSS 等后台逻辑,统一放在本文件夹内。",
|
||||
"id_comment": "lab 唯一标识(snake_case),与文件夹名保持一致",
|
||||
"id": "minkowski_inkscape",
|
||||
"name_comment": "首页卡片显示名称",
|
||||
"name": "闵可夫斯基画板",
|
||||
"icon_comment": "首页卡片图标(emoji)",
|
||||
"icon": "📐",
|
||||
"desc_comment": "一句话简介,作为卡片 tooltip 展示",
|
||||
"desc": "世界线绘制 · 洛伦兹 boost · 光锥因果判定",
|
||||
"sort_order_comment": "卡片排序,数值越小越靠前",
|
||||
"sort_order": 60,
|
||||
"enabled_comment": "是否启用(false 时列表接口不返回该 lab)",
|
||||
"enabled": true
|
||||
}
|
||||
Reference in New Issue
Block a user