feat(thought-lab): 新增万花筒与闵可夫斯基画板两个思想实验室

新增两个独立思想实验室:
1. 万花筒模拟器:支持可调参数物理万花筒效果,配置方案SQLite持久化
2. 闵可夫斯基画板:支持世界线绘制、洛伦兹变换、光锥因果判定
同时添加自动绑定实验室后端的逻辑,以及配套的单元测试与前端页面
This commit is contained in:
yangxiangyuan
2026-08-06 17:41:43 +08:00
parent ea379116cc
commit 876a3b8768
17 changed files with 9979 additions and 0 deletions
@@ -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|&gt;|Δx|(v&lt;1,可携带因果)</span>
<span class="c-light">■ 类光 |Δt|=|Δx|(光子世界线)</span>
<span class="c-space">■ 类空 |Δx|&gt;|Δt|(v&gt;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>
+22
View File
@@ -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 }
@@ -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
}