feat: 新增3个思想实验室项目,完善私有剪贴板授权与上传功能,添加配套测试脚本

1. 新增怀旧弹珠台、人性与投资、穿越三体、四维空间四个思想实验室项目
2. 为私有剪贴板添加业务层授权校验,优化上传进度显示与鉴权失败处理
3. 新增多个调试与单元测试脚本,覆盖三体积分、弹珠台物理、集成测试等场景
4. 补全穿越三体项目的完整HTML页面资源
This commit is contained in:
yangxiangyuan
2026-08-07 17:49:24 +08:00
parent 0f8fe2c2e2
commit 4492b4106a
22 changed files with 6935 additions and 34 deletions
@@ -0,0 +1,100 @@
/**
* 调试:retro_pinball 拱形导轨几何搜索
* 目的:找到一组 ARCH(cx,cy,r,a0,a1),让满力发射的球能绕弧 -> 左侧落入 -> 静止落球区
* 运行:node dev_test_scripts/debug/debug_retro_pinball_arch_search.js
*/
'use strict'
const fs = require('fs')
const path = require('path')
const vm = require('vm')
const HTML_PATH = path.join(__dirname, '..', '..', 'public', 'tools', 'thought_lab', 'labs', 'retro_pinball', 'index.html')
const html = fs.readFileSync(HTML_PATH, 'utf-8')
const grab = (a, b) => {
const s = html.indexOf(a), e = html.indexOf(b)
if (s < 0 || e < 0 || e <= s) throw new Error('未找到标记块: ' + a)
return html.slice(s, e)
}
const code =
grab('/* ==PHYSICS_CORE_START== */', '/* ==PHYSICS_CORE_END== */') + '\n' +
grab('/* ==TABLE_GEOM_START== */', '/* ==TABLE_GEOM_END== */')
const sandbox = { Math, console }
vm.createContext(sandbox)
vm.runInContext(code, sandbox)
const PC = sandbox.PhysicsCore
const { BALL_R, G_PX, SPRING_X, SPRING_TOP, LANE_X, buildTableStatics, slotIndexAt, SLOT_X0, SLOT_X1 } = sandbox
const DT = 1 / 360
function defaultParams() {
return {
g: G_PX * Math.sin(14 * Math.PI / 180),
e: 0.55, mu: 0.12, airK: 0.02, rollR: 0.25,
restV: 50, iterations: 8, slop: 0.3, correction: 0.8, maxV: 4200
};
}
// 三点定圆
function fitCircle(p1, p2, p3) {
const [x1, y1] = p1, [x2, y2] = p2, [x3, y3] = p3
const d = 2 * (x1 * (y2 - y3) + x2 * (y3 - y1) + x3 * (y1 - y2))
if (Math.abs(d) < 1e-9) return null
const ux = ((x1 * x1 + y1 * y1) * (y2 - y3) + (x2 * x2 + y2 * y2) * (y3 - y1) + (x3 * x3 + y3 * y3) * (y1 - y2)) / d
const uy = ((x1 * x1 + y1 * y1) * (x3 - x2) + (x2 * x2 + y2 * y2) * (x1 - x3) + (x3 * x3 + y3 * y3) * (x2 - x1)) / d
const r = Math.hypot(x1 - ux, y1 - uy)
return { cx: ux, cy: uy, r: r }
}
const deg = a => a * 180 / Math.PI
function angleOf(c, p) { return deg(Math.atan2(p[1] - c.cy, p[0] - c.cx)) }
// 候选:三点(左出口 / 弧顶 / 右端),右端放在发射道上方,让球顺势切入
// laneTop = 发射道左壁顶 (LANE_X,190);右端在右外壁 x=442 附近
const candidates = []
function addCandidate(leftExit, top, rightEnd, label) {
const c = fitCircle(leftExit, top, rightEnd)
if (!c) return
// 角度范围:从右端 -> 顶部 -> 左出口(逆时针,y 向下故角度递减)
let aR = angleOf(c, rightEnd)
let aT = angleOf(c, top)
let aL = angleOf(c, leftExit)
// 归一化:弧要从左出口经弧顶(-90°)扫到右端,需 aL < -90 < aR(角度递增)
while (aL > aR) aL -= 360
candidates.push({ label, arch: { cx: c.cx, cy: c.cy, r: c.r, a0: aL, a1: aR }, _top: aT, c })
}
// 右端扫过发射道:从 (442,180) 到 (442,230),左出口落在钉阵左侧,顶部留 >=30 余量
addCandidate([56, 330], [230, 42], [442, 180], 'C1 右端(442,180)')
addCandidate([56, 330], [235, 45], [442, 200], 'C2 右端(442,200)')
addCandidate([60, 340], [235, 48], [442, 210], 'C3 右端(442,210)')
addCandidate([60, 340], [240, 50], [442, 220], 'C4 右端(442,220)')
addCandidate([50, 320], [225, 40], [442, 190], 'C5 右端(442,190)')
addCandidate([56, 335], [230, 44], [440, 195], 'C6 右端(440,195)')
function simulate(arch, v0, seconds, seedVx) {
sandbox.ARCH = arch
const table = buildTableStatics(5, 1)
const b = PC.createBall(SPRING_X, SPRING_TOP - BALL_R, BALL_R)
b.vy = -v0; b.vx = seedVx || 0
const tr = { minY: 1e9, enteredLeft: false, nan: false }
for (let t = 0; t < seconds; t += DT) {
PC.stepWorld([b], table.statics, defaultParams(), DT)
tr.minY = Math.min(tr.minY, b.y)
if (b.x < 90 && b.y > 200 && b.y < 450) tr.enteredLeft = true
if (!isFinite(b.x) || !isFinite(b.y)) { tr.nan = true; break }
}
tr.b = b
tr.sp = Math.hypot(b.vx, b.vy)
tr.inField = b.x >= SLOT_X0 && b.x <= SLOT_X1
tr.slot = slotIndexAt(b.x, b.y)
return tr
}
for (const cd of candidates) {
const a = cd.arch
const tr = simulate(a, 2900, 10, 5)
const topOk = tr.minY < 90
const ok = !tr.nan && topOk && tr.enteredLeft && tr.inField && tr.sp < 30
console.log(`${ok ? '★' : ' '} ${cd.label} cx=${a.cx.toFixed(0)} cy=${a.cy.toFixed(0)} r=${a.r.toFixed(0)} a:[${a.a0.toFixed(0)}..${a.a1.toFixed(0)}] topAng=${cd._top.toFixed(0)}`)
console.log(` minY=${tr.minY.toFixed(0)} enteredLeft=${tr.enteredLeft} final=(${tr.b.x.toFixed(0)},${tr.b.y.toFixed(0)}) sp=${tr.sp.toFixed(1)} inField=${tr.inField} slot=${tr.slot} nan=${tr.nan}`)
}
@@ -0,0 +1,83 @@
/**
* 调试:retro_pinball 拱形候选的力度扫描 + 精确参数输出
* 运行:node dev_test_scripts/debug/debug_retro_pinball_arch_sweep.js
*/
'use strict'
const fs = require('fs')
const path = require('path')
const vm = require('vm')
const HTML_PATH = path.join(__dirname, '..', '..', 'public', 'tools', 'thought_lab', 'labs', 'retro_pinball', 'index.html')
const html = fs.readFileSync(HTML_PATH, 'utf-8')
const grab = (a, b) => {
const s = html.indexOf(a), e = html.indexOf(b)
if (s < 0 || e < 0 || e <= s) throw new Error('未找到标记块: ' + a)
return html.slice(s, e)
}
const code = grab('/* ==PHYSICS_CORE_START== */', '/* ==PHYSICS_CORE_END== */') + '\n' + grab('/* ==TABLE_GEOM_START== */', '/* ==TABLE_GEOM_END== */')
const sandbox = { Math, console }
vm.createContext(sandbox)
vm.runInContext(code, sandbox)
const PC = sandbox.PhysicsCore
const { BALL_R, G_PX, SPRING_X, SPRING_TOP, LANE_X, buildTableStatics, slotIndexAt, SLOT_X0, SLOT_X1 } = sandbox
const DT = 1 / 360
function params() {
return { g: G_PX * Math.sin(14 * Math.PI / 180), e: 0.55, mu: 0.12, airK: 0.02, rollR: 0.25, restV: 50, iterations: 8, slop: 0.3, correction: 0.8, maxV: 4200 };
}
function fitCircle(p1, p2, p3) {
const [x1, y1] = p1, [x2, y2] = p2, [x3, y3] = p3
const d = 2 * (x1 * (y2 - y3) + x2 * (y3 - y1) + x3 * (y1 - y2))
const ux = ((x1 * x1 + y1 * y1) * (y2 - y3) + (x2 * x2 + y2 * y2) * (y3 - y1) + (x3 * x3 + y3 * y3) * (y1 - y2)) / d
const uy = ((x1 * x1 + y1 * y1) * (x3 - x2) + (x2 * x2 + y2 * y2) * (x1 - x3) + (x3 * x3 + y3 * y3) * (x2 - x1)) / d
return { cx: ux, cy: uy, r: Math.hypot(x1 - ux, y1 - uy) }
}
const deg = a => a * 180 / Math.PI
function makeArch(leftExit, top, rightEnd) {
const c = fitCircle(leftExit, top, rightEnd)
let aR = deg(Math.atan2(rightEnd[1] - c.cy, rightEnd[0] - c.cx))
let aL = deg(Math.atan2(leftExit[1] - c.cy, leftExit[0] - c.cx))
while (aL > aR) aL -= 360
return { cx: c.cx, cy: c.cy, r: c.r, a0: aL, a1: aR }
}
function simulate(arch, v0, seedVx) {
sandbox.ARCH = arch
const table = buildTableStatics(5, 1)
const b = PC.createBall(SPRING_X, SPRING_TOP - BALL_R, BALL_R)
b.vy = -v0; b.vx = seedVx || 0
let minY = 1e9, enteredLeft = false, nan = false
for (let t = 0; t < 10; t += DT) {
PC.stepWorld([b], table.statics, params(), DT)
minY = Math.min(minY, b.y)
if (b.x < 90 && b.y > 200 && b.y < 450) enteredLeft = true
if (!isFinite(b.x) || !isFinite(b.y)) { nan = true; break }
}
const sp = Math.hypot(b.vx, b.vy)
return { minY, enteredLeft, nan, x: b.x, y: b.y, sp, inField: b.x >= SLOT_X0 && b.x <= SLOT_X1, slot: slotIndexAt(b.x, b.y), fellBackLane: b.x > LANE_X };
}
// 聚焦最优候选 C4,输出精确值
const CHOSEN = { leftExit: [60, 340], top: [240, 50], rightEnd: [442, 220] }
const arch = makeArch(CHOSEN.leftExit, CHOSEN.top, CHOSEN.rightEnd)
console.log('选中拱形精确参数:')
console.log(` cx=${arch.cx.toFixed(3)} cy=${arch.cy.toFixed(3)} r=${arch.r.toFixed(3)} a0=${arch.a0.toFixed(3)} a1=${arch.a1.toFixed(3)}`)
console.log(` 左出口=(${CHOSEN.leftExit}) 弧顶=(${CHOSEN.top}) 右端=(${CHOSEN.rightEnd})`)
console.log('')
console.log('力度扫描(seedVx=5):')
const powers = [900, 1200, 1500, 1800, 2100, 2400, 2700, 2900, 3200]
for (const v0 of powers) {
const r = simulate(arch, v0, 5)
const tag = r.nan ? 'NaN!' : r.fellBackLane ? '落回发射道' : r.enteredLeft ? '绕弧左侧落入' : '其他'
console.log(` v0=${v0} minY=${r.minY.toFixed(0)} ${tag} final=(${r.x.toFixed(0)},${r.y.toFixed(0)}) sp=${r.sp.toFixed(0)} slot=${r.slot}`)
}
console.log('')
console.log('满力微扰扫描(v0=2900,落点分布):')
const slots = {}
for (let sv = -8; sv <= 8; sv += 2) {
const r = simulate(arch, 2900, sv)
slots[r.slot] = (slots[r.slot] || 0) + 1
console.log(` seedVx=${sv} 落点=(${r.x.toFixed(0)},${r.y.toFixed(0)}) slot=${r.slot} enteredLeft=${r.enteredLeft}`)
}
console.log(' 落坑分布:', JSON.stringify(slots))
@@ -0,0 +1,70 @@
/**
* 调试:retro_pinball 满力发射轨迹追踪
* 目的:定位能量增益 bug(理论上满力发射到不了顶部,实测 minY=28)
* 运行:node dev_test_scripts/debug/debug_retro_pinball_launch.js
*/
'use strict'
const fs = require('fs')
const path = require('path')
const vm = require('vm')
const HTML_PATH = path.join(__dirname, '..', '..', 'public', 'tools', 'thought_lab', 'labs', 'retro_pinball', 'index.html')
const html = fs.readFileSync(HTML_PATH, 'utf-8')
const grab = (a, b) => {
const s = html.indexOf(a), e = html.indexOf(b)
if (s < 0 || e < 0 || e <= s) throw new Error('未找到标记块: ' + a)
return html.slice(s, e)
}
const code =
grab('/* ==PHYSICS_CORE_START== */', '/* ==PHYSICS_CORE_END== */') + '\n' +
grab('/* ==TABLE_GEOM_START== */', '/* ==TABLE_GEOM_END== */')
const sandbox = { Math, console }
vm.createContext(sandbox)
vm.runInContext(code, sandbox)
const PC = sandbox.PhysicsCore
const { BALL_R, G_PX, SPRING_X, SPRING_TOP, buildTableStatics } = sandbox
const DT = 1 / 360
function defaultParams() {
return {
g: G_PX * Math.sin(14 * Math.PI / 180),
e: 0.55, mu: 0.12, airK: 0.02, rollR: 0.25,
restV: 50, iterations: 8, slop: 0.3, correction: 0.8, maxV: 4200
};
}
const gEff = G_PX * Math.sin(14 * Math.PI / 180)
console.log('g_eff =', gEff.toFixed(1), 'px/s^2')
const table = buildTableStatics(5, 1)
const b = PC.createBall(SPRING_X, SPRING_TOP - BALL_R, BALL_R)
b.vy = -2900
b.vx = 5
const balls = [b]
let maxSp = 0, maxSpAt = ''
let minY = 1e9, minYAt = ''
let maxEnergy = -1e9, maxEnergyAt = ''
const startEnergy = 0.5 * (b.vx * b.vx + b.vy * b.vy) + gEff * b.y
console.log('初始动能+势能基准 E0 =', startEnergy.toFixed(0))
for (let i = 0; i < 360 * 3; i++) {
PC.stepWorld(balls, table.statics, defaultParams(), DT)
const sp = Math.hypot(b.vx, b.vy)
const t = ((i + 1) / 360).toFixed(2)
// 机械能 = 动能 + 势能(y 向下为正,势能取 g*y,越高 y 越小能量越小)
const E = 0.5 * sp * sp + gEff * b.y
if (sp > maxSp) { maxSp = sp; maxSpAt = `t=${t} pos=(${b.x.toFixed(1)},${b.y.toFixed(1)})` }
if (b.y < minY) { minY = b.y; minYAt = `t=${t} pos=(${b.x.toFixed(1)},${b.y.toFixed(1)}) sp=${sp.toFixed(0)}` }
if (E > maxEnergy) { maxEnergy = E; maxEnergyAt = `t=${t} pos=(${b.x.toFixed(1)},${b.y.toFixed(1)}) sp=${sp.toFixed(0)}` }
if (i % 36 === 0) {
console.log(`t=${t} pos=(${b.x.toFixed(1)},${b.y.toFixed(1)}) v=(${b.vx.toFixed(0)},${b.vy.toFixed(0)}) sp=${sp.toFixed(0)} E=${E.toFixed(0)}`)
}
}
console.log('----')
console.log('maxSp =', maxSp.toFixed(1), '@', maxSpAt)
console.log('minY =', minY.toFixed(1), '@', minYAt)
console.log('maxE =', maxEnergy.toFixed(0), '@', maxEnergyAt, '(E0=' + startEnergy.toFixed(0) + ')')
@@ -0,0 +1,222 @@
// ============================================================
// dev_test_scripts/integration/test_retro_pinball_integration.js
// 怀旧弹珠台 · 台面几何 + 物理核心 集成测试(无头模拟)
// 从 index.html 提取 PHYSICS_CORE 与 TABLE_GEOM 两个纯函数块,
// 模拟真实玩法流程,验证台面几何与物理手感:
// A. 满力发射:球沿发射道上行 -> 绕过弧形导轨 -> 左侧落入钉阵 -> 静止在落球区
// B. 弱力发射:球到不了顶部,落回弹簧待发射(同真实玩具)
// C. 连续多球:不爆炸(无 NaN)、可堆叠静止
// 运行:node dev_test_scripts/integration/test_retro_pinball_integration.js
// ============================================================
'use strict';
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const HTML_PATH = path.join(__dirname, '..', '..', 'public', 'tools', 'thought_lab', 'labs', 'retro_pinball', 'index.html');
function loadBlocks() {
const html = fs.readFileSync(HTML_PATH, 'utf-8');
const grab = (a, b) => {
const s = html.indexOf(a), e = html.indexOf(b);
if (s < 0 || e < 0 || e <= s) throw new Error('未找到标记块: ' + a);
return html.slice(s, e);
};
const code =
grab('/* ==PHYSICS_CORE_START== */', '/* ==PHYSICS_CORE_END== */') + '\n' +
grab('/* ==TABLE_GEOM_START== */', '/* ==TABLE_GEOM_END== */');
const sandbox = { Math, console };
vm.createContext(sandbox);
vm.runInContext(code, sandbox);
return sandbox;
}
const sb = loadBlocks();
const PC = sb.PhysicsCore;
const { W, H, LANE_X, BALL_R, SLOT_TOP, SLOT_X0, SLOT_X1, SPRING_X, SPRING_TOP, G_PX, buildTableStatics, slotIndexAt, WIPERS, makeWiperSegs, updateWiperSeg } = sb;
let passed = 0, failed = 0;
function check(name, cond, detail) {
if (cond) { passed++; console.log(` [PASS] ${name}`); }
else { failed++; console.error(` [FAIL] ${name}${detail ? ' -> ' + detail : ''}`); }
}
const DT = 1 / 360;
function defaultParams() {
return {
g: G_PX * Math.sin(14 * Math.PI / 180),
e: 0.55, mu: 0.12, airK: 0.02, rollR: 0.25,
restV: 50, iterations: 8, slop: 0.3, correction: 0.8, maxV: 4200
};
}
function simulate(v0, seconds, seedVx) {
const table = buildTableStatics(5, 1);
const b = PC.createBall(SPRING_X, SPRING_TOP - BALL_R, BALL_R);
b.vy = -v0;
b.vx = seedVx || 0;
const trace = { minY: 1e9, enteredLeft: false, maxX: -1e9 };
for (let t = 0; t < seconds; t += DT) {
PC.stepWorld([b], table.statics, defaultParams(), DT);
trace.minY = Math.min(trace.minY, b.y);
trace.maxX = Math.max(trace.maxX, b.x);
if (b.x < 90 && b.y > 200 && b.y < 450) trace.enteredLeft = true;
if (!isFinite(b.x) || !isFinite(b.y)) { trace.nan = true; break; }
}
trace.ball = b;
return trace;
}
// ---------- A. 满力发射完整一圈 ----------
(function testFullLaunch() {
console.log('A) 满力发射:绕弧 -> 左侧落入 -> 静止落球区');
const tr = simulate(2900, 10, 5);
check('无 NaN', !tr.nan);
// 球沿拱形内侧绕行,满力时球心最低约 y≈90(拱顶 y=50 + 球半径 + 脱弧抛体),
// 故以「越过发射道顶(y=190)进入拱形区」为到达判据
check(`到达拱形区 minY=${tr.minY.toFixed(0)} < 150`, tr.minY < 150, `minY=${tr.minY}`);
check('从左侧落入钉阵', tr.enteredLeft);
const b = tr.ball;
const sp = Math.hypot(b.vx, b.vy);
check(`10s 后基本静止 sp=${sp.toFixed(1)} < 30`, sp < 30, `sp=${sp}`);
check(`静止在落球区 y=${b.y.toFixed(0)} > 500`, b.y > 500, `y=${b.y}`);
check(`静止在台面内 x=${b.x.toFixed(0)} in [18,378]`, b.x >= SLOT_X0 && b.x <= SLOT_X1, `x=${b.x}`);
console.log(` -> 落点坑位: ${slotIndexAt(b.x, b.y)}`);
})();
// ---------- B. 弱力发射落回弹簧 ----------
(function testWeakLaunch() {
console.log('B) 弱力发射:到不了顶部,落回发射道');
const tr = simulate(1400, 5, 0);
check('无 NaN', !tr.nan);
check(`到不了顶部 minY=${tr.minY.toFixed(0)} > 200`, tr.minY > 200, `minY=${tr.minY}`);
const b = tr.ball;
check(`落回发射道 x=${b.x.toFixed(0)} > ${LANE_X}`, b.x > LANE_X, `x=${b.x}`);
check(`停在弹簧附近 y=${b.y.toFixed(0)} > 650`, b.y > 650, `y=${b.y}`);
})();
// ---------- C. 连续多球不爆炸、可堆叠 ----------
(function testMultiBall() {
console.log('C) 连续 4 球:数值稳定、落坑堆叠');
const table = buildTableStatics(5, 1);
const balls = [];
let nan = false;
for (let n = 0; n < 4; n++) {
const b = PC.createBall(SPRING_X, SPRING_TOP - BALL_R, BALL_R);
b.vy = -2900;
b.vx = (n % 2 === 0 ? -1 : 1) * 8; // 微小扰动让落点分散
balls.push(b);
// 模拟到该球基本静止(最多 9s),期间所有球一起步进
for (let t = 0; t < 9; t += DT) {
PC.stepWorld(balls, table.statics, defaultParams(), DT);
for (const x of balls) if (!isFinite(x.x) || !isFinite(x.y)) nan = true;
const sp = Math.hypot(b.vx, b.vy);
if (t > 2 && sp < 15) break;
}
}
check('无 NaN', !nan);
const settled = balls.filter(b => Math.hypot(b.vx, b.vy) < 30 && b.y > 500 && b.x <= SLOT_X1);
check(`至少 3 球静止在落球区(实际 ${settled.length})`, settled.length >= 3, `settled=${settled.length}`);
// 堆叠检查:同一坑允许多球且互不重叠穿透
for (let i = 0; i < balls.length; i++) {
for (let j = i + 1; j < balls.length; j++) {
const d = Math.hypot(balls[i].x - balls[j].x, balls[i].y - balls[j].y);
if (d < (BALL_R * 2) - 2) check(`球${i}/${j} 不深叠 d=${d.toFixed(1)}`, false, `d=${d}`);
}
}
check('多球间距正常', true);
console.log(' -> 落点: ' + balls.map(b => `(${b.x.toFixed(0)},${b.y.toFixed(0)})`).join(' '));
})();
// ---------- E. 力度曲线:20%/60% 蓄力都能进盘面(与游戏层 launch() 同式映射) ----------
(function testChargeCurve() {
console.log('E) 力度曲线:20%/60% 蓄力均进盘面');
const L = 2900, ENTER_V = 2400;
const map = ch => ch <= 0.2 ? 1000 + (ch / 0.2) * (ENTER_V - 1000) : ENTER_V + ((ch - 0.2) / 0.8) * (L - ENTER_V);
for (const ch of [0.2, 0.6]) {
const tr = simulate(map(ch), 10, 5);
const b = tr.ball;
// 进盘面判据:越过发射道顶(y=190);弱力对照 B 的 minY=491 远大于此
check(`蓄力${(ch * 100).toFixed(0)}% v0=${map(ch).toFixed(0)} 进入盘面`, tr.minY < 190, `minY=${tr.minY}`);
check(`蓄力${(ch * 100).toFixed(0)}% 静止落球区`, Math.hypot(b.vx, b.vy) < 30 && b.y > 500 && b.x <= SLOT_X1, `(${b.x.toFixed(0)},${b.y.toFixed(0)})`);
}
})();
// 拨片回合模拟:3 连满力发射 + 3s 沉降冷却(同真实回合等静止结算)
function runWiperRound(spin) {
const table = buildTableStatics(5, 1);
const wsegs = makeWiperSegs();
const statics = table.statics.concat(wsegs);
const balls = [];
let nan = false;
let t = 0;
const step = () => {
t += DT;
for (let i = 0; i < wsegs.length; i++) updateWiperSeg(wsegs[i], WIPERS[i], t, spin);
PC.stepWorld(balls, statics, defaultParams(), DT);
for (const x of balls) if (!isFinite(x.x) || !isFinite(x.y)) nan = true;
};
for (let n = 0; n < 3; n++) {
const b = PC.createBall(SPRING_X, SPRING_TOP - BALL_R, BALL_R);
b.vy = -2900; b.vx = 5;
balls.push(b);
for (let s = 0; s < 10; s += DT) {
step();
const sp = Math.hypot(b.vx, b.vy);
if (s > 2 && sp < 15) break;
}
}
for (let s = 0; s < 3; s += DT) step(); // 沉降冷却
return { balls, nan };
}
// ---------- F. 雨刮拨片:运动碰撞体数值稳定、球最终落下静止 ----------
(function testWipers() {
console.log('F) 雨刮拨片开:3 连满力发射稳定、无 NaN、落底静止');
const { balls, nan } = runWiperRound(false);
check('拨片无 NaN', !nan);
const settled = balls.filter(b => Math.hypot(b.vx, b.vy) < 30 && b.y > 400);
check(`拨片下 3 球全部落底静止(实际 ${settled.length}/3)`, settled.length === 3, `settled=${settled.length}`);
console.log(' -> 落点: ' + balls.map(b => `(${b.x.toFixed(0)},${b.y.toFixed(0)})`).join(' '));
})();
// ---------- G. 防穿透回归:maxV=4200 垂直砸向胶囊拨片不得穿模 ----------
(function testNoTunnel() {
console.log('G) 4200px/s 垂直砸拨片不穿透');
const seg = { type: 'seg', x1: 90, y1: 300, x2: 210, y2: 300, thk: 4, mov: { px: 150, py: 300, omega: 0 } };
const b = PC.createBall(150, 100, BALL_R);
b.vy = 4200;
let maxY = -1e9;
for (let t = 0; t < 1.5; t += DT) {
PC.stepWorld([b], [seg], defaultParams(), DT);
maxY = Math.max(maxY, b.y);
}
// 未穿透:球心不得越过 线y-(r+thk)+slop 容忍带(300-15+0.3≈285)
check(`maxY=${maxY.toFixed(1)} < 292(未穿到线下)`, maxY < 292, `maxY=${maxY}`);
})();
// ---------- H. 转动模式:3 连满力发射稳定、无 NaN、落底静止 ----------
(function testSpin() {
console.log('H) 转动模式:3 连满力发射稳定');
const { balls, nan } = runWiperRound(true);
check('转动无 NaN', !nan);
const settled = balls.filter(b => Math.hypot(b.vx, b.vy) < 30 && b.y > 400);
check(`转动下 3 球全部落底静止(实际 ${settled.length}/3)`, settled.length === 3, `settled=${settled.length}`);
console.log(' -> 落点: ' + balls.map(b => `(${b.x.toFixed(0)},${b.y.toFixed(0)})`).join(' '));
})();
// ---------- D. 无卡球缝:旧缝隙位置(378..398)垂直落球必须落底静止 ----------
(function testNoWedge() {
console.log('D) 无卡球缝:旧缝隙 x=388/395 垂直落球落底静止');
const table = buildTableStatics(5, 1);
for (const x0 of [388, 395]) {
const b = PC.createBall(x0, 400, BALL_R);
for (let t = 0; t < 5; t += DT) PC.stepWorld([b], table.statics, defaultParams(), DT);
const sp = Math.hypot(b.vx, b.vy);
check(`x0=${x0} 落底 y=${b.y.toFixed(0)} > 600`, b.y > 600, `y=${b.y}`);
check(`x0=${x0} 静止 sp=${sp.toFixed(1)} < 30`, sp < 30, `sp=${sp}`);
}
})();
console.log(`\n结果: ${passed} passed, ${failed} failed`);
process.exit(failed > 0 ? 1 : 0);
@@ -0,0 +1,83 @@
// ============================================================
// test_four_dim_polytopes.js - 四维空间实验室数学块校验
// 从 public/tools/thought_lab/labs/four_dim_space/index.html 抽取
// POLYTOPE_MATH_BEGIN/END 之间的纯数学代码执行校验,保证测试对象 = 上线代码。
// 校验:5 个凸正多胞体的顶点数/边数(严格已知值)、外接半径全等、
// 旋转保范、投影 w=0 时缩放为 1、偶排列数 = 12。
// 运行:node dev_test_scripts/unit/test_four_dim_polytopes.js
// ============================================================
'use strict'
const fs = require('fs')
const path = require('path')
const vm = require('vm')
const HTML = path.resolve(__dirname, '..', '..', 'public', 'tools', 'thought_lab', 'labs', 'four_dim_space', 'index.html')
const src = fs.readFileSync(HTML, 'utf8')
const m = src.match(/\/\/ ==== POLYTOPE_MATH_BEGIN ====\n([\s\S]*?)\/\/ ==== POLYTOPE_MATH_END ====/)
if (!m) { console.error('FAIL - 未找到 POLYTOPE_MATH 标记块'); process.exit(1) }
const api = vm.runInNewContext(m[1] + `
;({ POLYTOPES, edgesByMinDist, rotate4, project4to3, project3to2, evenPerms4, perms4, PHI })
`)
let pass = 0, fail = 0
const check = (name, cond) => {
if (cond) { pass++; console.log('PASS -', name) }
else { fail++; console.log('FAIL -', name) }
}
const norm = v => Math.sqrt(v.reduce((s, x) => s + x * x, 0))
// 排列数
check('4 元素全排列 = 24', api.perms4.length === 24)
check('偶排列 = 12', api.evenPerms4.length === 12)
// 各多胞体:顶点数 / 边数 / 外接半径全等
for (const key of Object.keys(api.POLYTOPES)) {
const P = api.POLYTOPES[key]
const verts = P.verts()
const edges = api.edgesByMinDist(verts)
check(`${key} 顶点数 = ${P.expect.v}`, verts.length === P.expect.v)
check(`${key} 边数 = ${P.expect.e}`, edges.length === P.expect.e)
const rs = verts.map(norm)
const rMin = Math.min(...rs), rMax = Math.max(...rs)
check(`${key} 外接半径全等(${rMin.toFixed(6)})`, rMax - rMin < 1e-9)
}
// 600-胞体最小边长应 = 1/φ(单位外接半径)
{
const verts = api.POLYTOPES.p600.verts()
let min = Infinity
for (let i = 0; i < verts.length; i++)
for (let j = i + 1; j < verts.length; j++) {
let s = 0
for (let k = 0; k < 4; k++) { const d = verts[i][k] - verts[j][k]; s += d * d }
min = Math.min(min, s)
}
check('600-胞体边长 = 1/φ', Math.abs(Math.sqrt(min) - 1 / api.PHI) < 1e-9)
}
// 旋转保范:六个平面各转 0.7 rad
{
const v = [1, 2, 3, 4]
const planes = [[0, 1], [0, 2], [1, 2], [0, 3], [1, 3], [2, 3]]
let ok = true
for (const [i, j] of planes) {
const q = api.rotate4(v, i, j, 0.7)
if (Math.abs(norm(q) - norm(v)) > 1e-12) ok = false
}
check('六平面旋转均保范', ok)
}
// 投影:w=0 时 4D→3D 缩放为 1;w>0 放大
{
const a = api.project4to3([1, 1, 1, 0], 3)
const b = api.project4to3([1, 1, 1, 0.9], 3)
check('project4to3 w=0 缩放=1', Math.abs(a[0] - 1) < 1e-12)
check('project4to3 w>0 放大', b[0] > 1)
const c = api.project3to2([1, 1, 0], 6)
check('project3to2 z=0 缩放=1', Math.abs(c[0] - 1) < 1e-12)
}
console.log(fail === 0 ? `ALL PASS (${pass})` : `FAILED: ${fail} / ${pass + fail}`)
process.exit(fail === 0 ? 0 : 1)
@@ -0,0 +1,152 @@
// ============================================================
// dev_test_scripts/unit/test_retro_pinball_physics.js
// 怀旧弹珠台 · 物理核心单元测试
// 从 public/tools/thought_lab/labs/retro_pinball/index.html 中
// 提取 ==PHYSICS_CORE_START== / ==PHYSICS_CORE_END== 之间的纯函数核心,
// 在 node vm 中运行并断言物理正确性。
// 运行:node dev_test_scripts/unit/test_retro_pinball_physics.js
// ============================================================
'use strict';
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const HTML_PATH = path.join(__dirname, '..', '..', 'public', 'tools', 'thought_lab', 'labs', 'retro_pinball', 'index.html');
function loadCore() {
const html = fs.readFileSync(HTML_PATH, 'utf-8');
const s = html.indexOf('/* ==PHYSICS_CORE_START== */');
const e = html.indexOf('/* ==PHYSICS_CORE_END== */');
if (s < 0 || e < 0 || e <= s) throw new Error('未找到物理核心标记块');
const code = html.slice(s, e);
const sandbox = { Math, console };
vm.createContext(sandbox);
vm.runInContext(code, sandbox);
if (!sandbox.PhysicsCore) throw new Error('PhysicsCore 未导出');
return sandbox.PhysicsCore;
}
const PC = loadCore();
let passed = 0, failed = 0;
function check(name, cond, detail) {
if (cond) { passed++; console.log(` [PASS] ${name}`); }
else { failed++; console.error(` [FAIL] ${name}${detail ? ' -> ' + detail : ''}`); }
}
const DT = 1 / 360;
function baseParams(over) {
return Object.assign({
g: 2000, e: 0.55, mu: 0.12, airK: 0, rollR: 0,
restV: 1, iterations: 8, slop: 0.3, correction: 0.8, maxV: 6000
}, over || {});
}
const FLOOR = { type: 'seg', x1: -500, y1: 500, x2: 500, y2: 500 };
// ---------- 1. 恢复系数:回弹高度 ≈ e²·h ----------
(function testRestitution() {
console.log('1) 恢复系数回弹高度');
const e = 0.5, h = 300;
const p = baseParams({ e, mu: 0, restV: 1 });
const b = PC.createBall(0, 500 - 11 - h, 11);
const statics = [FLOOR];
let bounced = false, apex = 1e9, done = false, t = 0;
while (t < 3 && !done) {
PC.stepWorld([b], statics, p, DT);
t += DT;
if (!bounced && b.vy < 0) bounced = true; // 回弹开始(y 向下,vy<0 为向上)
if (bounced) {
apex = Math.min(apex, b.y);
if (b.vy > 0) done = true; // 到达顶点
}
}
const rebound = (500 - 11) - apex;
const expect = e * e * h;
check(`回弹高度 ${rebound.toFixed(1)} ≈ e²h=${expect.toFixed(1)} (±10%)`, Math.abs(rebound - expect) <= expect * 0.1, `got=${rebound}`);
})();
// ---------- 2. 能量不增 ----------
(function testEnergy() {
console.log('2) 机械能不自发增加');
const p = baseParams({ e: 0.5, mu: 0 });
const b = PC.createBall(0, 100, 11);
const statics = [FLOOR];
const E0 = 0.5 * b.m * (b.vx * b.vx + b.vy * b.vy) + b.m * p.g * (500 - b.y);
let maxE = 0;
for (let t = 0; t < 2; t += DT) {
PC.stepWorld([b], statics, p, DT);
const E = 0.5 * b.m * (b.vx * b.vx + b.vy * b.vy) + b.m * p.g * (500 - b.y);
maxE = Math.max(maxE, E);
}
check(`maxE=${maxE.toFixed(0)} <= E0*1.02=${(E0 * 1.02).toFixed(0)}`, maxE <= E0 * 1.02);
})();
// ---------- 3. 静止稳定:不沉陷、不抖动 ----------
(function testRest() {
console.log('3) 水平面静止稳定性');
const p = baseParams({ e: 0.55, mu: 0.2 });
const b = PC.createBall(0, 200, 11);
const statics = [FLOOR];
for (let t = 0; t < 3; t += DT) PC.stepWorld([b], statics, p, DT);
const sp = Math.hypot(b.vx, b.vy);
check(`静止速度 ${sp.toFixed(2)} < 20`, sp < 20);
check(`不沉陷 y=${b.y.toFixed(2)} <= 489+1`, b.y <= 500 - 11 + 1);
})();
// ---------- 4. 等质量弹性碰撞速度交换 ----------
(function testMomentum() {
console.log('4) 等质量 e=1 正碰速度交换');
const p = baseParams({ g: 0, e: 1, mu: 0 });
const a = PC.createBall(0, 0, 11); a.vx = 100;
const b = PC.createBall(30, 0, 11);
for (let t = 0; t < 0.5; t += DT) PC.stepWorld([a, b], [], p, DT);
check(`a.vx=${a.vx.toFixed(1)}≈0`, Math.abs(a.vx) < 3, `got=${a.vx}`);
check(`b.vx=${b.vx.toFixed(1)}≈100`, Math.abs(b.vx - 100) < 3, `got=${b.vx}`);
})();
// ---------- 5. 高速不穿透 ----------
(function testNoTunnel() {
console.log('5) 3000px/s 不穿透薄壁');
const p = baseParams({ g: 0, e: 0.5, mu: 0 });
const wall = { type: 'seg', x1: 100, y1: -1000, x2: 100, y2: 1000 };
const b = PC.createBall(0, 0, 11); b.vx = 3000;
let maxX = -1e9;
for (let t = 0; t < 0.5; t += DT) {
PC.stepWorld([b], [wall], p, DT);
maxX = Math.max(maxX, b.x);
}
check(`maxX=${maxX.toFixed(1)} <= 100`, maxX <= 100 + 0.5);
})();
// ---------- 6. 摩擦减速 + 滚动建立 ----------
(function testFrictionRoll() {
console.log('6) 摩擦使滑动减速并建立滚动');
const mk = (mu) => {
const p = baseParams({ e: 0.3, mu, rollR: 0 });
const b = PC.createBall(0, 500 - 11, 11); b.vx = 500;
for (let t = 0; t < 0.5; t += DT) PC.stepWorld([b], [FLOOR], p, DT);
return b;
};
const noF = mk(0), withF = mk(0.4);
check(`无摩擦保持滑动 |vx|=${noF.vx.toFixed(0)} > 400`, noF.vx > 400);
check(`有摩擦减速 vx=${withF.vx.toFixed(0)} < ${noF.vx.toFixed(0)}`, withF.vx < noF.vx - 50);
check(`滚动建立 omega=${withF.omega.toFixed(1)} > 5`, withF.omega > 5);
})();
// ---------- 7. 斜坡:无摩擦加速下滑 ----------
(function testSlope() {
console.log('7) 斜坡下滑加速度 ≈ g·sinθ');
const theta = Math.PI / 6; // 30°
const p = baseParams({ g: 2000, e: 0, mu: 0 });
const slope = { type: 'seg', x1: 0, y1: 0, x2: 300, y2: 300 * Math.tan(theta) };
const b = PC.createBall(50, 50 * Math.tan(theta) - 11, 11);
const v0 = Math.hypot(b.vx, b.vy);
for (let t = 0; t < 0.3; t += DT) PC.stepWorld([b], [slope], p, DT);
const v1 = Math.hypot(b.vx, b.vy);
const dv = v1 - v0;
const expect = p.g * Math.sin(theta) * 0.3;
check(`dv=${dv.toFixed(0)} ≈ g·sinθ·t=${expect.toFixed(0)} (±8%)`, Math.abs(dv - expect) <= expect * 0.08);
})();
console.log(`\n结果: ${passed} passed, ${failed} failed`);
process.exit(failed > 0 ? 1 : 0);
@@ -0,0 +1,53 @@
// ============================================================
// test_three_body_integrator.js - 穿越三体 · 三体积分器校验
// 从 public/tools/thought_lab/labs/three_body_journey/index.html 抽取
// THREEBODY_MATH_BEGIN/END 之间的纯物理代码执行校验(测试对象 = 上线代码)。
// 校验:RK4 积分动量守恒、能量漂移极小、加速度满足牛顿第三定律对称性。
// 运行:node dev_test_scripts/unit/test_three_body_integrator.js
// ============================================================
'use strict'
const fs = require('fs')
const path = require('path')
const vm = require('vm')
const HTML = path.resolve(__dirname, '..', '..', 'public', 'tools', 'thought_lab', 'labs', 'three_body_journey', 'index.html')
const src = fs.readFileSync(HTML, 'utf8')
const m = src.match(/\/\/ ==== THREEBODY_MATH_BEGIN ====\n([\s\S]*?)\/\/ ==== THREEBODY_MATH_END ====/)
if (!m) { console.error('FAIL - 未找到 THREEBODY_MATH 标记块'); process.exit(1) }
const api = vm.runInNewContext(m[1] + ';({ tbAccel, tbStep, tbEnergy, tbMomentum })')
let pass = 0, fail = 0
const check = (name, cond) => {
if (cond) { pass++; console.log('PASS -', name) }
else { fail++; console.log('FAIL -', name) }
}
const masses = [1, 1, 1]
const eps2 = 0.02
let pos = [1, 0, -0.5, Math.sqrt(3) / 2, -0.5, -Math.sqrt(3) / 2]
let vel = [0, 0.42, -0.38, -0.2, 0.38, -0.22]
// 牛顿第三定律:Σ mᵢaᵢ = 0(作用力反作用力,任意位形)
{
const ms = [1, 2, 0.5]
const a = api.tbAccel([0, 0, 1, 0.3, -0.7, 1.2], ms, 0)
let sx = 0, sy = 0
for (let i = 0; i < 3; i++) { sx += ms[i] * a[2 * i]; sy += ms[i] * a[2 * i + 1] }
check('Σ mᵢaᵢ = 0(作用反作用)', Math.abs(sx) < 1e-12 && Math.abs(sy) < 1e-12)
}
// 动量守恒:跑 5000 步
const p0 = api.tbMomentum(vel, masses)
const E0 = api.tbEnergy(pos, vel, masses, eps2)
for (let i = 0; i < 5000; i++) [pos, vel] = api.tbStep(pos, vel, masses, 0.002, eps2)
const p1 = api.tbMomentum(vel, masses)
const E1 = api.tbEnergy(pos, vel, masses, eps2)
check('动量守恒(5000 步,|Δp|<1e-9)', Math.abs(p1[0] - p0[0]) < 1e-9 && Math.abs(p1[1] - p0[1]) < 1e-9)
check(`能量漂移小(|ΔE/E0|<1e-3,实际 ${Math.abs((E1 - E0) / E0).toExponential(2)})`, Math.abs((E1 - E0) / E0) < 1e-3)
// 有限性:5000 步后位置/速度均为有限数(软化避免奇点崩溃)
check('积分稳定(全部有限)', [...pos, ...vel].every(Number.isFinite))
console.log(fail === 0 ? `ALL PASS (${pass})` : `FAILED: ${fail} / ${pass + fail}`)
process.exit(fail === 0 ? 0 : 1)
+123 -32
View File
@@ -77,7 +77,26 @@ const setStatus = (text, type) => {
el.className = 'pc-status' + (type ? ` ${type}` : '') el.className = 'pc-status' + (type ? ` ${type}` : '')
} }
const setProgress = (show, done, total) => { // 鉴权失效时统一回到登录界面,避免“贴和传没有响应”的假死体验
const showAuthScreen = (message) => {
const auth = $('pc-auth')
const app = $('pc-app')
if (app) app.style.display = 'none'
if (auth) auth.style.display = ''
const status = $('pc-auth-status')
if (status && message) {
status.textContent = message
status.className = 'pc-auth-status'
status.style.display = ''
}
}
const notifyUnauthorized = () => {
showAuthScreen('登录状态已失效,请重新登录;若仍无法使用,请从首页九宫格重新进入')
}
// byteInfo: { loaded, total } 当前文件的字节进度,用于大文件上传时实时更新进度条
const setProgress = (show, done, total, byteInfo) => {
const box = $('pc-progress') const box = $('pc-progress')
const text = $('pc-progress-text') const text = $('pc-progress-text')
const percent = $('pc-progress-percent') const percent = $('pc-progress-percent')
@@ -92,9 +111,17 @@ const setProgress = (show, done, total) => {
} }
const safeTotal = Math.max(1, Number(total || 0)) const safeTotal = Math.max(1, Number(total || 0))
const safeDone = Math.min(safeTotal, Math.max(0, Number(done || 0))) const safeDone = Math.min(safeTotal, Math.max(0, Number(done || 0)))
const pct = Math.round((safeDone / safeTotal) * 100) let ratio = safeDone / safeTotal
let label = `上传进度 ${safeDone}/${safeTotal}`
if (byteInfo && Number(byteInfo.total) > 0) {
const totalBytes = Number(byteInfo.total)
const loaded = Math.min(totalBytes, Math.max(0, Number(byteInfo.loaded || 0)))
ratio = (safeDone + loaded / totalBytes) / safeTotal
label = `上传进度 ${safeDone}/${safeTotal} · ${fmtSize(loaded)} / ${fmtSize(totalBytes)}`
}
const pct = Math.min(100, Math.round(ratio * 100))
box.className = 'pc-progress show' box.className = 'pc-progress show'
text.textContent = `上传进度 ${safeDone}/${safeTotal}` text.textContent = label
percent.textContent = `${pct}%` percent.textContent = `${pct}%`
fill.style.width = `${pct}%` fill.style.width = `${pct}%`
} }
@@ -179,9 +206,12 @@ const stepViewer = (delta) => {
const apiJson = async (url, options) => { const apiJson = async (url, options) => {
const res = await fetch(url, options || {}) const res = await fetch(url, options || {})
if (res.status === 401) {
notifyUnauthorized()
throw new Error('unauthorized')
}
const contentType = res.headers.get('content-type') || '' const contentType = res.headers.get('content-type') || ''
if (!contentType.includes('application/json')) { if (!contentType.includes('application/json')) {
if (res.status === 401) throw new Error('unauthorized')
throw new Error(`unexpected_response_${res.status}`) throw new Error(`unexpected_response_${res.status}`)
} }
const data = await res.json() const data = await res.json()
@@ -370,7 +400,9 @@ const uploadFiles = async (files, { uploadingText = '正在上传...', successMe
try { try {
let done = 0 let done = 0
for (const file of list) { for (const file of list) {
await uploadFile(file, groupId) await uploadFile(file, groupId, (loaded, totalBytes) => {
setProgress(true, done, list.length, { loaded, total: totalBytes })
})
done += 1 done += 1
setProgress(true, done, list.length) setProgress(true, done, list.length)
} }
@@ -704,6 +736,10 @@ const saveText = async (text) => {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text }) body: JSON.stringify({ text })
}) })
if (res.status === 401) {
notifyUnauthorized()
throw new Error('unauthorized')
}
const data = await res.json() const data = await res.json()
if (!data || !data.ok) throw new Error((data && data.error) || 'save_failed') if (!data || !data.ok) throw new Error((data && data.error) || 'save_failed')
} }
@@ -716,29 +752,68 @@ const resolveFileName = (file) => {
return `clipboard-${Date.now()}${suffix}` return `clipboard-${Date.now()}${suffix}`
} }
const uploadFile = async (file, groupId) => { // 使用 XHR 上传,利用 upload.onprogress 获取字节级进度(fetch 不支持上传进度事件)
if (!file) return const uploadFile = (file, groupId, onProgress) => new Promise((resolve, reject) => {
if (!file) { resolve(); return }
const name = resolveFileName(file) const name = resolveFileName(file)
const buf = await file.arrayBuffer()
const groupPart = Number.isFinite(groupId) ? `&group_id=${encodeURIComponent(String(groupId))}` : '' const groupPart = Number.isFinite(groupId) ? `&group_id=${encodeURIComponent(String(groupId))}` : ''
const url = `${apiBase}/upload?filename=${encodeURIComponent(name)}&mime=${encodeURIComponent(file.type || 'application/octet-stream')}${groupPart}` const url = `${apiBase}/upload?filename=${encodeURIComponent(name)}&mime=${encodeURIComponent(file.type || 'application/octet-stream')}${groupPart}`
const res = await fetch(url, { const xhr = new XMLHttpRequest()
method: 'POST', xhr.open('POST', url)
headers: { 'Content-Type': 'application/octet-stream' }, xhr.setRequestHeader('Content-Type', 'application/octet-stream')
body: new Uint8Array(buf) if (xhr.upload && typeof onProgress === 'function') {
}) xhr.upload.addEventListener('progress', (e) => {
const data = await res.json() if (e && e.lengthComputable) onProgress(e.loaded, e.total)
if (!data || !data.ok) throw new Error((data && data.error) || 'upload_failed') })
} }
xhr.onload = () => {
let data = null
try { data = JSON.parse(xhr.responseText || '') } catch {}
if (xhr.status === 401) {
notifyUnauthorized()
reject(new Error('unauthorized'))
return
}
if (xhr.status >= 200 && xhr.status < 300 && data && data.ok) {
resolve(data)
return
}
reject(new Error((data && data.error) || `upload_failed_${xhr.status}`))
}
xhr.onerror = () => reject(new Error('upload_network_error'))
xhr.onabort = () => reject(new Error('upload_aborted'))
xhr.send(file)
})
const deleteItem = async (id) => { const deleteItem = async (id) => {
if (!id) return if (!id) return
const res = await fetch(`${apiBase}/${id}`, { method: 'DELETE' }) const res = await fetch(`${apiBase}/${id}`, { method: 'DELETE' })
if (res.status === 401) {
notifyUnauthorized()
throw new Error('unauthorized')
}
const data = await res.json() const data = await res.json()
if (!data || !data.ok) throw new Error((data && data.error) || 'delete_failed') if (!data || !data.ok) throw new Error((data && data.error) || 'delete_failed')
await fetchList() await fetchList()
} }
// 某些粘贴来源下 getAsString 回调可能永不触发,加超时保护避免“正在保存...”假死
const getStringFromItem = (item, timeoutMs) => new Promise((resolve) => {
let settled = false
const timer = setTimeout(() => finish(null), timeoutMs || 1500)
const finish = (value) => {
if (settled) return
settled = true
clearTimeout(timer)
resolve(value)
}
try {
item.getAsString(text => finish(text))
} catch {
finish(null)
}
})
const handlePaste = async (event) => { const handlePaste = async (event) => {
if (!isQuickEntryMode()) return if (!isQuickEntryMode()) return
const data = event.clipboardData const data = event.clipboardData
@@ -757,31 +832,28 @@ const handlePaste = async (event) => {
setProgress(true, 0, files.length) setProgress(true, 0, files.length)
let done = 0 let done = 0
for (const f of files) { for (const f of files) {
await uploadFile(f, groupId) await uploadFile(f, groupId, (loaded, totalBytes) => {
setProgress(true, done, files.length, { loaded, total: totalBytes })
})
done += 1 done += 1
setProgress(true, done, files.length) setProgress(true, done, files.length)
} }
} }
for (const s of strings) { for (const s of strings) {
await new Promise((resolve) => { try {
s.getAsString(async (text) => { const text = await getStringFromItem(s, 1500)
try { const v = String(text || '').trim()
const v = String(text || '').trim() if (v) await saveText(v)
if (v) await saveText(v) } catch (err) {
} catch (err) { stringSaveError = err
stringSaveError = err }
} finally {
resolve()
}
})
})
} }
if (stringSaveError) throw stringSaveError if (stringSaveError) throw stringSaveError
await refreshCurrentView({ successMessage: '已保存到服务器' }) await refreshCurrentView({ successMessage: '已保存到服务器' })
setProgress(false)
} catch (e) { } catch (e) {
setProgress(false)
setStatus('保存失败', 'error') setStatus('保存失败', 'error')
} finally {
setProgress(false)
} }
} }
@@ -822,7 +894,11 @@ const bindPasteInput = () => {
input.select() input.select()
} }
if (btn) btn.addEventListener('click', focusInput) if (btn) btn.addEventListener('click', focusInput)
input.addEventListener('paste', handlePaste) // 阻止冒泡到 document 级 paste 监听,避免同一次粘贴被处理两遍
input.addEventListener('paste', (event) => {
event.stopPropagation()
handlePaste(event)
})
const handleInput = async () => { const handleInput = async () => {
if (!isQuickEntryMode()) return if (!isQuickEntryMode()) return
const value = String(input.value || '').trim() const value = String(input.value || '').trim()
@@ -1065,6 +1141,12 @@ const init = async () => {
// 先检查是否已登录 // 先检查是否已登录
try { try {
const res = await fetch(apiBase + '/auth/me', { credentials: 'same-origin', cache: 'no-store' }) const res = await fetch(apiBase + '/auth/me', { credentials: 'same-origin', cache: 'no-store' })
if (res.status === 401) {
// 外层授权或登录态失效:回到登录界面并尝试自动填充
showAuthScreen('访问授权或登录已失效,请重新登录')
await tryAutoFill()
return
}
const data = await res.json() const data = await res.json()
if (data && data.ok) { if (data && data.ok) {
$auth.style.display = 'none' $auth.style.display = 'none'
@@ -1078,7 +1160,16 @@ const init = async () => {
await tryAutoFill() await tryAutoFill()
} }
let appReady = false
const initApp = async () => { const initApp = async () => {
// 登录态失效后重新登录时,避免重复绑定事件,只刷新数据
if (appReady) {
try {
await Promise.all([fetchFolders(), fetchList()])
} catch {}
return
}
appReady = true
clearUrlQuery() clearUrlQuery()
const isMobile = detectMobile() const isMobile = detectMobile()
let keywordTimer = null let keywordTimer = null
@@ -0,0 +1,539 @@
<!-- ============================================================
思想实验室 · labs/four_dim_space · 四维空间实验室
第四空间维的数学可视化(科学教研用),单文件自包含(Canvas 2D,零依赖)。
内容:正多胞体严格坐标 + 六旋转平面 + 等倾双旋转的 4D→3D→2D 透视投影;
四维超球被三维空间扫过的切片(r=√(R²−w²));维度类比表。
科学边界:观测空间为 3 维;额外空间维仅见于弦论/卡鲁扎-克莱因且
紧致于普朗克尺度(~1.6e-35 m),无直接观测证据。本 lab 是数学可视化工具。
服务端配置:src/server/thought_lab/labs/four_dim_space/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: #0b0e1a; color: #c9d0e8;
font-family: "Segoe UI", "Microsoft YaHei", sans-serif;
display: flex; flex-direction: column; height: 100vh; overflow: hidden;
}
header {
display: flex; align-items: center; gap: 12px; padding: 10px 18px;
background: #0d1226; border-bottom: 1px solid #232c4e;
}
header h1 { font-size: 16px; color: #e8f0ff; font-weight: 600; }
header .sub { font-size: 12px; color: #66779f; }
header button {
margin-left: auto; background: #1a2140; color: #9db1ea; border: 1px solid #2c3763;
border-radius: 6px; padding: 4px 12px; font-size: 12px; cursor: pointer;
}
header button:hover { background: #232c52; }
#tab-bar { display: flex; background: #0d1226; border-bottom: 2px solid #232c4e; padding: 0 16px; }
.tab-btn {
background: none; color: #66779f; border: none; border-bottom: 2px solid transparent;
padding: 8px 18px; font-size: 13px; cursor: pointer; margin-bottom: -2px;
}
.tab-btn:hover { color: #9db1ea; }
.tab-btn.active { color: #e8f0ff; border-bottom-color: #6d84e8; }
.tab-content { flex: 1; display: flex; min-height: 0; }
.tab-content[hidden] { display: none; }
.stage { flex: 1; position: relative; min-width: 0; }
.stage canvas { position: absolute; inset: 0; display: block; }
aside {
width: 260px; background: #0d1226; border-left: 1px solid #232c4e;
padding: 12px; overflow-y: auto; font-size: 12px;
}
aside h2 { font-size: 13px; color: #9db1ea; margin: 12px 0 6px; }
aside h2:first-child { margin-top: 0; }
.row { display: flex; align-items: center; gap: 6px; margin: 6px 0; }
.row label { width: 76px; color: #7f92c9; flex: none; }
.row input[type=range] { flex: 1; accent-color: #6d84e8; }
.row .val { width: 44px; text-align: right; color: #b9c7f2; font-family: Consolas, monospace; font-size: 11px; }
.row select, .row button {
background: #1a2140; color: #c9d0e8; border: 1px solid #2c3763; border-radius: 6px;
padding: 4px 8px; font-size: 12px; cursor: pointer;
}
.row input[type=checkbox] { accent-color: #6d84e8; }
#facts {
background: #101736; border: 1px solid #232c4e; border-radius: 8px;
padding: 8px 10px; line-height: 1.7; color: #b9c7f2; font-family: Consolas, monospace; font-size: 11px;
}
.group-note { color: #55628a; font-size: 11px; margin: 2px 0 4px; }
/* 类比页签 */
#tab-analogy { overflow-y: auto; padding: 20px 28px; display: block; }
#tab-analogy table { border-collapse: collapse; margin: 12px 0; font-size: 13px; }
#tab-analogy th, #tab-analogy td { border: 1px solid #2c3763; padding: 6px 14px; text-align: center; }
#tab-analogy th { background: #101736; color: #9db1ea; }
#tab-analogy td.f { color: #55628a; font-family: Consolas, monospace; font-size: 12px; }
#tab-analogy p { margin: 10px 0; line-height: 1.8; max-width: 860px; }
#tab-analogy .hl { color: #ffd479; }
/* 信息抽屉 */
#drawer {
position: fixed; top: 0; right: -420px; width: 400px; height: 100vh;
background: #0d1226; border-left: 1px solid #2c3763; padding: 18px;
overflow-y: auto; transition: right .25s; z-index: 50; font-size: 13px; line-height: 1.8;
}
#drawer.open { right: 0; }
#drawer h3 { color: #9db1ea; margin: 14px 0 6px; font-size: 14px; }
#drawer h3:first-child { margin-top: 0; }
#drawer .warn { color: #ffb0b0; }
#drawer-close { float: right; background: none; border: none; color: #66779f; font-size: 16px; cursor: pointer; }
</style>
</head>
<body>
<header>
<h1>💠 四维空间实验室</h1>
<span class="sub">第四空间维的数学可视化 · 科学教研</span>
<button id="btn-info" type="button">科学说明</button>
</header>
<div id="tab-bar">
<button class="tab-btn active" data-tab="poly" type="button">正多胞体投影</button>
<button class="tab-btn" data-tab="slice" type="button">超球切片</button>
<button class="tab-btn" data-tab="analogy" type="button">维度类比</button>
</div>
<div id="tab-poly" class="tab-content">
<div class="stage"><canvas id="cv"></canvas></div>
<aside>
<h2>对象</h2>
<div class="row"><label>正多胞体</label>
<select id="sel-poly">
<option value="p8" selected>8-胞体(超立方体)</option>
<option value="p5">5-胞体(四维单形)</option>
<option value="p16">16-胞体(正八胞体)</option>
<option value="p24">24-胞体</option>
<option value="p600">600-胞体</option>
</select>
</div>
<div id="facts"></div>
<h2>旋转平面</h2>
<div class="group-note">3D 里就有的 3 个平面:</div>
<div class="row"><label>XY 平面</label><input id="r-xy" type="range" min="-180" max="180" step="1" value="0"><span class="val" id="v-xy">0°</span></div>
<div class="row"><label>XZ 平面</label><input id="r-xz" type="range" min="-180" max="180" step="1" value="0"><span class="val" id="v-xz">0°</span></div>
<div class="row"><label>YZ 平面</label><input id="r-yz" type="range" min="-180" max="180" step="1" value="0"><span class="val" id="v-yz">0°</span></div>
<div class="group-note">涉及第四维 W 的 3 个新平面(4D 特有):</div>
<div class="row"><label>XW 平面</label><input id="r-xw" type="range" min="-180" max="180" step="1" value="0"><span class="val" id="v-xw">0°</span></div>
<div class="row"><label>YW 平面</label><input id="r-yw" type="range" min="-180" max="180" step="1" value="0"><span class="val" id="v-yw">0°</span></div>
<div class="row"><label>ZW 平面</label><input id="r-zw" type="range" min="-180" max="180" step="1" value="0"><span class="val" id="v-zw">0°</span></div>
<h2>运动</h2>
<div class="row"><label>自动旋转</label><input id="c-auto" type="checkbox" checked><span class="val" style="width:auto;color:#55628a">XW 慢速</span></div>
<div class="row"><label>等倾旋转</label><input id="c-iso" type="checkbox"><span class="val" style="width:auto;color:#55628a">XY+ZW 同步(4D 特有)</span></div>
<h2>投影</h2>
<div class="row"><label>透视强度</label><input id="r-d4" type="range" min="1.6" max="8" step="0.1" value="3"><span class="val" id="v-d4">3.0</span></div>
<div class="row"><button id="btn-reset" type="button">重置视角</button></div>
</aside>
</div>
<div id="tab-slice" class="tab-content" hidden>
<div class="stage"><canvas id="cv-slice"></canvas></div>
<aside>
<h2>四维超球切片</h2>
<div class="row"><label>球心位置 w</label><input id="r-w" type="range" min="-1" max="1" step="0.01" value="0"><span class="val" id="v-w">0.00</span></div>
<div id="slice-facts" style="background:#101736;border:1px solid #232c4e;border-radius:8px;padding:8px 10px;line-height:1.7;color:#b9c7f2;font-family:Consolas,monospace;font-size:11px"></div>
<h2>说明</h2>
<p style="color:#7f92c9;line-height:1.7">
四维球(半径 R=1)被我们的三维空间「扫过」:当球心在第四维坐标 w 处,
截面是一个三维球,半径 r = √(R²−w²)(勾股定理)。<br><br>
左图是降一维的类比:三维球扫过二维平面,截面是圆;
右图是我们的处境:四维球扫过三维空间,截面是球。
</p>
</aside>
</div>
<div id="tab-analogy" class="tab-content" hidden>
<p>从 0 维到 4 维,「超立方体家族」的计数有严格公式:顶点 = 2ⁿ,边 = n·2ⁿ⁻¹,正方形面 = C(n,2)·2ⁿ⁻²,立方体胞 = C(n,3)·2ⁿ⁻³。下表数值全部由公式算出:</p>
<table>
<tr><th>维度</th><th>名称</th><th>顶点</th><th>边</th><th>正方形面</th><th>立方体胞</th></tr>
<tr><td>0</td><td>点</td><td>1</td><td>—</td><td>—</td><td>—</td></tr>
<tr><td>1</td><td>线段</td><td>2</td><td>1</td><td>—</td><td>—</td></tr>
<tr><td>2</td><td>正方形</td><td>4</td><td>4</td><td>1</td><td>—</td></tr>
<tr><td>3</td><td>立方体</td><td>8</td><td>12</td><td>6</td><td>1</td></tr>
<tr><td>4</td><td class="hl">超立方体</td><td>16</td><td>32</td><td>24</td><td>8</td></tr>
</table>
<p><span class="hl">为什么 4D 特别?</span>凸正多胞体(各维的「正多面体」)的个数:2D 有无穷多种(正 n 边形),3D 有 5 种(柏拉图立体),<b>4D 有 6 种</b>(本 lab 渲染其中 5 种),而 5 维及以上只剩 3 种。第四维是正多胞体最「丰富」的维度。</p>
<p><span class="hl">两种认知手段</span>(Flatland 类比):一个生活在二维平面里的正方形,无法直接看到三维立方体,但它可以 (1) 看立方体在平面上的<b>投影</b>(影子),(2) 观察立方体穿过平面时的<b>切片</b>(一段段变化的线段)。我们对第四维做同样的事:投影页签 = 手段 1,切片页签 = 手段 2。</p>
<p><span class="hl">旋转平面数</span>:n 维空间的独立旋转平面数 = n(n−1)/2。3D 有 3 个(XY/XZ/YZ),4D 有 <b>6 个</b>——多出的 XW/YW/ZW 三个平面正是投影页签里「4D 特有」的那组滑块。</p>
</div>
<div id="drawer">
<button id="drawer-close" type="button">✕</button>
<h3>这个 lab 在做什么</h3>
<p>可视化<b>第四个空间维</b>——一个与 x/y/z 都垂直的纯数学方向。所有几何对象(正多胞体坐标、超球切片半径)都是严格数学,顶点数/边数由代码实算并经单元测试校验,不是示意性的瞎画。</p>
<h3>科学边界(重要)</h3>
<p class="warn">迄今所有观测都表明宏观空间是 3 维的。额外空间维只出现在卡鲁扎-克莱因理论与弦论中,且被紧致化在普朗克尺度(约 1.6×10⁻³⁵ m),目前没有任何直接观测证据。本 lab 是<b>数学可视化工具</b>,用于理解「如果存在第四空间维,几何会怎样」,不宣称宇宙真的如此。</p>
<h3>与时空 4D 的区别</h3>
<p>相对论的「四维」是 3 维空间 + 1 维时间(闵可夫斯基时空,时间维带负号度规),见本实验室的「闵可夫斯基画板」。本 lab 的第四维是<b>空间维</b>(正定度规),两者数学结构不同,不要混淆。</p>
<h3>投影方法</h3>
<p>两级透视投影:4D→3D(相机在 w 轴距离 d 处,s = d/(d−w)),再 3D→2D。w 越接近相机,投影越大——正如超立方体投影里「外面的大立方体」其实离我们在第四维上更近。颜色编码 w 坐标:暖色 = w&gt;0,冷色 = w&lt;0。</p>
<h3>等倾旋转</h3>
<p>4D 特有现象:两个完全正交的平面(如 XY 与 ZW)可以同时以相同角速度旋转、互不干扰,称为等倾(双)旋转。3D 中任何旋转都只有一个旋转平面,做不到这一点。</p>
</div>
<script>
'use strict';
// ==== POLYTOPE_MATH_BEGIN ====
// 纯数学块:本块被 dev_test_scripts/unit/test_four_dim_polytopes.js 抽取校验
const PHI = (1 + Math.sqrt(5)) / 2
const permParity = (p) => {
let inv = 0
for (let i = 0; i < p.length; i++)
for (let j = i + 1; j < p.length; j++)
if (p[i] > p[j]) inv++
return inv % 2 === 0 ? 1 : -1
}
const perms4 = (() => {
const all = []
const rec = (used, cur) => {
if (cur.length === 4) { all.push(cur.slice()); return }
for (let i = 0; i < 4; i++) {
if (used[i]) continue
used[i] = true; cur.push(i); rec(used, cur); cur.pop(); used[i] = false
}
}
rec([false, false, false, false], [])
return all
})()
const evenPerms4 = perms4.filter(p => permParity(p) === 1)
// 5-胞体(四维单形):5 顶点,边长 2√2,外接半径 4/√5
const verts5Cell = () => {
const a = 1 / Math.sqrt(5)
return [
[1, 1, 1, -a], [1, -1, -1, -a], [-1, 1, -1, -a], [-1, -1, 1, -a], [0, 0, 0, 4 * a]
]
}
// 8-胞体(超立方体):(±1)⁴
const vertsTesseract = () => {
const out = []
for (let m = 0; m < 16; m++) out.push([m & 1 ? 1 : -1, m & 2 ? 1 : -1, m & 4 ? 1 : -1, m & 8 ? 1 : -1])
return out
}
// 16-胞体(正八胞体):6 个轴向 ±1
const verts16Cell = () => {
const out = []
for (let i = 0; i < 4; i++) for (const s of [1, -1]) { const v = [0, 0, 0, 0]; v[i] = s; out.push(v) }
return out
}
// 24-胞体:所有 (±1,±1,0,0) 型排列
const verts24Cell = () => {
const out = []
for (let i = 0; i < 4; i++) for (let j = i + 1; j < 4; j++)
for (const si of [1, -1]) for (const sj of [1, -1]) {
const v = [0, 0, 0, 0]; v[i] = si; v[j] = sj; out.push(v)
}
return out
}
// 600-胞体 = 二进二十面体群(120 个单位四元数),外接半径 1:
// 8 轴向 + 16 半立方 + 96 偶排列(±φ/2, ±1/(2φ), ±1/2, 0)
const verts600Cell = () => {
const out = []
for (let i = 0; i < 4; i++) for (const s of [1, -1]) { const v = [0, 0, 0, 0]; v[i] = s; out.push(v) }
for (let m = 0; m < 16; m++) out.push([m & 1 ? 0.5 : -0.5, m & 2 ? 0.5 : -0.5, m & 4 ? 0.5 : -0.5, m & 8 ? 0.5 : -0.5])
const base = [PHI / 2, 1 / (2 * PHI), 0.5, 0]
for (const p of evenPerms4) for (let m = 0; m < 8; m++) {
const signed = [
(m & 1 ? 1 : -1) * base[0],
(m & 2 ? 1 : -1) * base[1],
(m & 4 ? 1 : -1) * base[2],
0
]
out.push([signed[p[0]], signed[p[1]], signed[p[2]], signed[p[3]]])
}
const seen = new Set(); const uniq = []
for (const v of out) {
const k = v.map(x => x.toFixed(9)).join(',')
if (!seen.has(k)) { seen.add(k); uniq.push(v) }
}
return uniq
}
// 凸正多胞体的边 = 最小距离顶点对(对全部 6 个凸正多胞体成立)
const edgesByMinDist = (verts, relTol = 1e-5) => {
const d2 = (a, b) => { let s = 0; for (let i = 0; i < 4; i++) { const d = a[i] - b[i]; s += d * d } return s }
let min = Infinity
for (let i = 0; i < verts.length; i++)
for (let j = i + 1; j < verts.length; j++)
min = Math.min(min, d2(verts[i], verts[j]))
const edges = []
const tol = min * relTol
for (let i = 0; i < verts.length; i++)
for (let j = i + 1; j < verts.length; j++)
if (d2(verts[i], verts[j]) - min < tol) edges.push([i, j])
return edges
}
// 在 (i,j) 平面旋转 ang 弧度
const rotate4 = (v, i, j, ang) => {
const c = Math.cos(ang), s = Math.sin(ang)
const out = v.slice()
out[i] = v[i] * c - v[j] * s
out[j] = v[i] * s + v[j] * c
return out
}
// 4D→3D 透视投影(相机在 w 轴 d 处)
const project4to3 = (v, d) => {
const s = d / (d - v[3])
return [v[0] * s, v[1] * s, v[2] * s, v[3]]
}
// 3D→2D 透视投影(相机在 z 轴 d 处)
const project3to2 = (v, d) => {
const s = d / (d - v[2])
return [v[0] * s, v[1] * s]
}
const POLYTOPES = {
p5: { name: '5-胞体(四维单形)', verts: verts5Cell, expect: { v: 5, e: 10 } },
p8: { name: '8-胞体(超立方体)', verts: vertsTesseract, expect: { v: 16, e: 32 } },
p16: { name: '16-胞体(正八胞体)', verts: verts16Cell, expect: { v: 8, e: 24 } },
p24: { name: '24-胞体', verts: verts24Cell, expect: { v: 24, e: 96 } },
p600: { name: '600-胞体', verts: verts600Cell, expect: { v: 120, e: 720 } }
}
// ==== POLYTOPE_MATH_END ====
// ============================================================
// 渲染与交互
// ============================================================
const $ = id => document.getElementById(id)
const cv = $('cv'), ctx = cv.getContext('2d')
const cvS = $('cv-slice'), ctxS = cvS.getContext('2d')
const DPR = Math.min(window.devicePixelRatio || 1, 2)
const state = {
tab: 'poly',
polyKey: 'p8',
angles: { xy: 0, xz: 0, yz: 0, xw: 0, yw: 0, zw: 0 },
auto: true,
iso: false,
isoT: 0,
d4: 3,
w: 0
}
// 多胞体缓存(坐标/边只算一次)
const cache = {}
const getPoly = (key) => {
if (!cache[key]) {
const verts = POLYTOPES[key].verts()
cache[key] = { verts, edges: edgesByMinDist(verts) }
}
return cache[key]
}
const resize = () => {
for (const [c, el] of [[cv, cv.parentElement], [cvS, cvS.parentElement]]) {
const r = el.getBoundingClientRect()
c.width = Math.max(1, Math.round(r.width * DPR))
c.height = Math.max(1, Math.round(r.height * DPR))
}
}
window.addEventListener('resize', resize)
// w 坐标 → 颜色(暖 = w>0,冷 = w<0)
const wColor = (w, alpha) => {
const t = Math.max(-1, Math.min(1, w / 1.4))
const r = t > 0 ? 255 : Math.round(110 + 145 * (1 + t))
const g = Math.round(150 + 60 * (1 - Math.abs(t)))
const b = t < 0 ? 255 : Math.round(110 + 145 * (1 - t))
return `rgba(${r},${g},${b},${alpha})`
}
const drawPoly = () => {
const W = cv.width, H = cv.height
ctx.setTransform(1, 0, 0, 1, 0, 0)
ctx.fillStyle = '#0b0e1a'
ctx.fillRect(0, 0, W, H)
const P = getPoly(state.polyKey)
const a = state.angles
const isoXY = state.iso ? state.isoT : 0
const isoZW = state.iso ? state.isoT : 0
const pts2d = []
const ws = []
for (const v of P.verts) {
let q = v
q = rotate4(q, 0, 1, a.xy + isoXY)
q = rotate4(q, 0, 2, a.xz)
q = rotate4(q, 1, 2, a.yz)
q = rotate4(q, 0, 3, a.xw)
q = rotate4(q, 1, 3, a.yw)
q = rotate4(q, 2, 3, a.zw + isoZW)
const p3 = project4to3(q, state.d4)
const p2 = project3to2(p3, 6)
pts2d.push(p2)
ws.push(q[3])
}
const R = Math.min(W, H) * 0.30
const cx = W / 2, cy = H / 2
// 边
for (const [i, j] of P.edges) {
const wm = (ws[i] + ws[j]) / 2
const alpha = 0.25 + 0.45 * ((wm + 1.4) / 2.8)
ctx.strokeStyle = wColor(wm, alpha)
ctx.lineWidth = Math.max(1, DPR * (0.6 + 0.8 * ((wm + 1.4) / 2.8)))
ctx.beginPath()
ctx.moveTo(cx + pts2d[i][0] * R, cy - pts2d[i][1] * R)
ctx.lineTo(cx + pts2d[j][0] * R, cy - pts2d[j][1] * R)
ctx.stroke()
}
// 顶点
for (let i = 0; i < pts2d.length; i++) {
const t = (ws[i] + 1.4) / 2.8
ctx.fillStyle = wColor(ws[i], 0.5 + 0.5 * t)
ctx.beginPath()
ctx.arc(cx + pts2d[i][0] * R, cy - pts2d[i][1] * R, Math.max(1.5, DPR * (1 + 2 * t)), 0, Math.PI * 2)
ctx.fill()
}
}
const drawSlice = () => {
const W = cvS.width, H = cvS.height
ctxS.setTransform(1, 0, 0, 1, 0, 0)
ctxS.fillStyle = '#0b0e1a'
ctxS.fillRect(0, 0, W, H)
const w = state.w
const R = 1
const r = Math.sqrt(Math.max(0, R * R - w * w))
const cxL = W * 0.28, cxR = W * 0.72, cy = H * 0.42
const S = Math.min(W, H) * 0.26
// ---- 左:2D 类比(3D 球扫过 2D 平面) ----
ctxS.strokeStyle = '#3a4670'; ctxS.lineWidth = DPR
ctxS.beginPath(); ctxS.moveTo(cxL - S * 1.6, cy); ctxS.lineTo(cxL + S * 1.6, cy); ctxS.stroke()
// 3D 球轮廓(侧视)
ctxS.strokeStyle = 'rgba(120,140,220,0.5)'
ctxS.beginPath(); ctxS.arc(cxL, cy - 0, S, 0, Math.PI * 2); ctxS.stroke()
// w 高度线(球心在平面上方 w 处 → 这里画球心抬升 w*S,截面在平面线上)
const chord = r * S
ctxS.strokeStyle = '#ffd479'; ctxS.lineWidth = 2 * DPR
ctxS.beginPath(); ctxS.moveTo(cxL - chord, cy); ctxS.lineTo(cxL + chord, cy); ctxS.stroke()
ctxS.fillStyle = '#7f92c9'; ctxS.font = `${12 * DPR}px "Microsoft YaHei"`
ctxS.textAlign = 'center'
ctxS.fillText('类比:3D 球扫过 2D 平面 → 截面是圆(平面里看到线段)', cxL, cy + S * 1.45)
// ---- 右:我们的处境(4D 球扫过 3D 空间) ----
if (r > 0.001) {
const g = ctxS.createRadialGradient(cxR - r * S * 0.3, cy - r * S * 0.3, r * S * 0.1, cxR, cy, r * S)
g.addColorStop(0, 'rgba(160,190,255,0.95)')
g.addColorStop(1, 'rgba(50,70,160,0.85)')
ctxS.fillStyle = g
ctxS.beginPath(); ctxS.arc(cxR, cy, r * S, 0, Math.PI * 2); ctxS.fill()
ctxS.strokeStyle = '#8fb0ff'; ctxS.lineWidth = DPR
ctxS.beginPath(); ctxS.arc(cxR, cy, r * S, 0, Math.PI * 2); ctxS.stroke()
} else {
ctxS.fillStyle = '#8fb0ff'
ctxS.beginPath(); ctxS.arc(cxR, cy, 2 * DPR, 0, Math.PI * 2); ctxS.fill()
}
ctxS.fillStyle = '#7f92c9'
ctxS.fillText('我们:4D 球扫过 3D 空间 → 截面是 3D 球(轮廓是圆)', cxR, cy + S * 1.45)
// ---- 底部:r(w) 曲线 ----
const gx = W * 0.5, gy = H * 0.88, gw = W * 0.3, gh = H * 0.09
ctxS.strokeStyle = '#3a4670'; ctxS.lineWidth = DPR
ctxS.beginPath(); ctxS.moveTo(gx - gw / 2, gy); ctxS.lineTo(gx + gw / 2, gy); ctxS.stroke()
ctxS.strokeStyle = '#6d84e8'
ctxS.beginPath()
for (let i = 0; i <= 100; i++) {
const ww = -1 + 2 * i / 100
const rr = Math.sqrt(Math.max(0, 1 - ww * ww))
const px = gx + (ww / 1) * gw / 2
const py = gy - rr * gh
i === 0 ? ctxS.moveTo(px, py) : ctxS.lineTo(px, py)
}
ctxS.stroke()
ctxS.fillStyle = '#ffd479'
ctxS.beginPath(); ctxS.arc(gx + w * gw / 2, gy - r * gh, 3 * DPR, 0, Math.PI * 2); ctxS.fill()
ctxS.fillStyle = '#55628a'
ctxS.fillText('r(w) = √(R² − w²)', gx, gy + 14 * DPR)
$('slice-facts').innerHTML =
`w = ${w.toFixed(2)}<br>r = √(1 − w²) = ${r.toFixed(3)}<br>` +
(r < 0.001 ? '状态:相切(一个点)' : (Math.abs(w) < 0.001 ? '状态:球心穿过,截面最大' : '状态:截面球存在'))
}
// ---- 面板绑定 ----
const PLANE_IDS = ['xy', 'xz', 'yz', 'xw', 'yw', 'zw']
PLANE_IDS.forEach(k => {
$('r-' + k).addEventListener('input', function () {
state.angles[k] = parseFloat(this.value) * Math.PI / 180
$('v-' + k).textContent = this.value + '°'
})
})
$('sel-poly').addEventListener('change', function () {
state.polyKey = this.value
updateFacts()
})
$('c-auto').addEventListener('change', function () { state.auto = this.checked })
$('c-iso').addEventListener('change', function () { state.iso = this.checked; state.isoT = 0 })
$('r-d4').addEventListener('input', function () {
state.d4 = parseFloat(this.value)
$('v-d4').textContent = state.d4.toFixed(1)
})
$('r-w').addEventListener('input', function () {
state.w = parseFloat(this.value)
$('v-w').textContent = state.w.toFixed(2)
})
$('btn-reset').addEventListener('click', () => {
state.angles = { xy: 0, xz: 0, yz: 0, xw: 0, yw: 0, zw: 0 }
state.isoT = 0
PLANE_IDS.forEach(k => { $('r-' + k).value = '0'; $('v-' + k).textContent = '0°' })
})
const updateFacts = () => {
const P = getPoly(state.polyKey)
const norms = P.verts.map(v => Math.sqrt(v.reduce((s, x) => s + x * x, 0)))
const rMin = Math.min(...norms).toFixed(4), rMax = Math.max(...norms).toFixed(4)
$('facts').innerHTML =
`${POLYTOPES[state.polyKey].name}<br>` +
`顶点 ${P.verts.length} · 边 ${P.edges.length}<br>` +
`外接半径 ${rMin}(全部相等:${rMin === rMax ? '是' : '否'})`
}
// ---- 页签 ----
document.querySelectorAll('#tab-bar .tab-btn').forEach(btn => {
btn.addEventListener('click', () => {
state.tab = btn.dataset.tab
document.querySelectorAll('#tab-bar .tab-btn').forEach(b => b.classList.toggle('active', b === btn))
$('tab-poly').hidden = state.tab !== 'poly'
$('tab-slice').hidden = state.tab !== 'slice'
$('tab-analogy').hidden = state.tab !== 'analogy'
resize()
})
})
// ---- 信息抽屉 ----
$('btn-info').addEventListener('click', () => $('drawer').classList.add('open'))
$('drawer-close').addEventListener('click', () => $('drawer').classList.remove('open'))
// ---- 主循环 ----
let lastT = performance.now()
const loop = (now) => {
const dt = Math.min(0.05, (now - lastT) / 1000)
lastT = now
if (state.auto) {
state.angles.xw += dt * 0.35
const deg = Math.round(((state.angles.xw * 180 / Math.PI + 180) % 360 + 360) % 360 - 180)
$('r-xw').value = String(deg); $('v-xw').textContent = deg + '°'
}
if (state.iso) state.isoT += dt * 0.5
if (state.tab === 'poly') drawPoly()
else if (state.tab === 'slice') drawSlice()
requestAnimationFrame(loop)
}
resize()
updateFacts()
requestAnimationFrame(loop)
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,920 @@
<!-- ============================================================
思想实验室 · labs/retro_pinball · 怀旧弹珠台
老式木制桌面弹珠台(Bagatelle)物理模拟:竖向台面、右下弹簧拉杆发射、
顶部弧形导轨、交错圆钉阵、底部 6 落球坑计分。
物理核心为自研冲量求解器(半隐式欧拉 + 固定子步 + 序贯冲量 + 位置校正),
纯函数、零依赖,可被 dev_test_scripts/unit/test_retro_pinball_physics.js 提取做单元测试。
独立 lab:单文件自包含,不引用其他 lab 的任何资源。
服务端配置:src/server/thought_lab/labs/retro_pinball/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>怀旧弹珠台 · 木制 Bagatelle 物理模拟</title>
<style>
:root { --bg:#221a12; --panel:#2e2418; --line:rgba(255,220,160,.16); --txt:#e8dcc8; --dim:#b3a289; --acc:#e0a35c; --warn:#e8b84a; }
* { margin:0; padding:0; box-sizing:border-box; }
body { background:var(--bg); color:var(--txt); font-family:"PingFang SC","Microsoft YaHei",sans-serif; padding:16px; }
h1 { font-size:21px; color:#ffe9c4; }
.sub { color:var(--dim); font-size:12.5px; margin:4px 0 14px; }
.wrap { display:flex; gap:16px; flex-wrap:wrap; align-items:flex-start; }
#stage { position:relative; background:#171009; border:1px solid var(--line); border-radius:12px; overflow:hidden; }
canvas#cv { display:block; touch-action:none; user-select:none; -webkit-user-select:none; }
#hud { font-size:12px; line-height:1.7; color:var(--dim); flex-wrap:wrap;
background:rgba(23,16,9,.5); border:1px solid var(--line); border-radius:8px; padding:8px 12px; margin-bottom:14px; }
#hud b { color:#ffe9c4; }
#overlay { position:absolute; inset:0; display:none; align-items:center; justify-content:center; background:rgba(20,13,7,.66); }
#overlay.show { display:flex; }
#overlay .card { background:var(--panel); border:1px solid var(--line); border-radius:12px; padding:22px 34px; text-align:center; }
#overlay .card h2 { color:#ffe9c4; font-size:20px; margin-bottom:6px; }
#overlay .card p { color:var(--dim); font-size:13px; margin-bottom:14px; }
#overlay .card b { color:var(--warn); font-size:22px; }
.panel { width:420px; background:var(--panel); border:1px solid var(--line); border-radius:12px; padding:16px; }
.panel h2 { font-size:14px; color:var(--acc); margin:14px 0 8px; }
.panel h2:first-child { margin-top:0; }
.panel p, .panel li { font-size:12.8px; line-height:1.75; color:#cdbfa8; }
.panel ul { padding-left:20px; }
.row { display:flex; gap:8px; align-items:center; margin:8px 0; flex-wrap:wrap; }
.row label { font-size:12.5px; color:var(--dim); white-space:nowrap; min-width:76px; }
button { background:rgba(224,163,92,.14); border:1px solid rgba(224,163,92,.45); color:#ffe2b8;
padding:6px 12px; border-radius:8px; font-size:12.5px; cursor:pointer; }
button:hover { background:rgba(224,163,92,.3); }
input[type=range] { flex:1; accent-color:var(--acc); min-width:120px; }
.mono { font-family:Consolas,monospace; font-size:12px; color:#ffe9c4; min-width:64px; text-align:right; }
code { background:rgba(255,255,255,.08); color:#ffd479; padding:1px 6px; border-radius:4px; font-size:12px; }
</style>
</head>
<body>
<h1>怀旧弹珠台 · 木制 Bagatelle 物理模拟</h1>
<div class="sub">按住空格 / 按住台面蓄力,松开发射玻璃弹珠 · 冲量法刚体物理 · 全部参数实时可调</div>
<div class="wrap">
<div id="stage">
<canvas id="cv"></canvas>
<div id="overlay"><div class="card">
<h2>本局结束</h2>
<p>总得分 <b id="final-score">0</b></p>
<button id="btn-again">再来一局</button>
</div></div>
</div>
<div class="panel">
<div id="hud"></div>
<h2>回合控制</h2>
<div class="row">
<button id="btn-reset">重新开始本局</button>
<button id="btn-clear">清空桌面弹珠</button>
</div>
<h2>物理参数</h2>
<div class="row"><label>台面倾角</label><input type="range" id="p-tilt" min="5" max="30" step="0.5" value="14"><span class="mono" id="v-tilt"></span></div>
<div class="row"><label>碰撞弹性 e</label><input type="range" id="p-rest" min="0.1" max="0.9" step="0.05" value="0.55"><span class="mono" id="v-rest"></span></div>
<div class="row"><label>摩擦系数 μ</label><input type="range" id="p-mu" min="0.02" max="0.6" step="0.02" value="0.12"><span class="mono" id="v-mu"></span></div>
<div class="row"><label>空气阻尼</label><input type="range" id="p-air" min="0" max="0.3" step="0.01" value="0.02"><span class="mono" id="v-air"></span></div>
<div class="row"><label>滚动阻力</label><input type="range" id="p-roll" min="0" max="1" step="0.05" value="0.25"><span class="mono" id="v-roll"></span></div>
<div class="row"><label>最大力度</label><input type="range" id="p-launch" min="2500" max="3400" step="50" value="2900"><span class="mono" id="v-launch"></span></div>
<h2>台面布局</h2>
<div class="row"><label>钉子尺寸</label><input type="range" id="p-pegr" min="3" max="8" step="0.5" value="5"><span class="mono" id="v-pegr"></span></div>
<div class="row"><label>钉子间距</label><input type="range" id="p-pegs" min="0.8" max="1.3" step="0.05" value="1"><span class="mono" id="v-pegs"></span></div>
<p class="sub" style="margin:4px 0 0">修改布局参数会重新开局。</p>
<div class="row"><label>雨刮拨片</label><input type="checkbox" id="p-wipers"><span class="mono" id="v-wipers">关</span><label style="margin-left:12px">转动</label><input type="checkbox" id="p-wiperspin"><span class="mono" id="v-wiperspin">关</span></div>
<p class="sub" style="margin:4px 0 0">勾选拨片后两根摆杆如雨刮器刮动;再勾「转动」改为绕轴连续旋转(两杆反向)。随时开关、不重开局。</p>
<h2>物理原理</h2>
<ul>
<li>积分:半隐式欧拉,固定子步 <code>dt=1/360s</code>,高速发射不穿透薄木条。</li>
<li>碰撞:序贯冲量法。法向冲量 <code>j=-(1+e)·v_n/m⁻¹</code>,低速接触时 <code>e=0</code> 防抖动。</li>
<li>摩擦:切向冲量按库仑摩擦 <code>|j_t| ≤ μ·j_n</code> 钳制,同时驱动角速度,弹珠会真实地「滚动」而非滑动。</li>
<li>倾角:真实玩具斜放在桌面上,沿台面有效重力 <code>g·sinθ</code>,θ 即倾角滑块。</li>
<li>弹簧发射:蓄力压缩量线性映射初速(胡克定律手感),松手即发射;力度不足掉回发射道的球<strong>不消耗机会</strong>,落在弹簧上可重新蓄力再弹——只有真正进入盘面的球才计一次机会。</li>
<li>力度曲线:蓄力 20% 恰好进盘面,20%~100% 线性到最大力,低力度也有用;蓄力条<strong>红区(&gt;85%)</strong>弹簧过压缩——初速随机损耗、横向抖动剧增,<strong>满力≠高分</strong>,甜区约 80%~90%。</li>
<li>雨刮拨片(可选):勾选后两根摆杆像汽车雨刮器一样刮来刮去;再勾「转动」改为绕轴连续旋转(两杆反向)。拨片是<strong>运动碰撞体</strong>,表面线速度 v=ω×r 参与冲量求解,物理上真实地「扇飞」弹珠;拨片带 4px 胶囊厚度,高速球也不会穿透。</li>
<li>静止判定:速度持续低于阈值且存在接触 → 休眠为静态碰撞体,落球坑内可堆叠(如同实物照片)。</li>
</ul>
</div>
</div>
<script>
/* ==PHYSICS_CORE_START== */
// ------------------------------------------------------------
// 物理核心(纯函数区,不依赖 DOM / 渲染,供 node 单元测试提取)
// 单位:px、s。质量 ∝ r²(二维圆盘),转动惯量 I = 0.5·m·r²。
// ------------------------------------------------------------
var PhysicsCore = (function () {
'use strict';
function createBall(x, y, r) {
var m = r * r;
var I = 0.5 * m * r * r;
return {
x: x, y: y, r: r,
vx: 0, vy: 0,
angle: 0, omega: 0,
m: m, invM: 1 / m,
I: I, invI: 1 / I,
sleeping: false, sleepTimer: 0, contacts: 0
};
}
function closestOnSeg(px, py, s) {
var dx = s.x2 - s.x1, dy = s.y2 - s.y1;
var L2 = dx * dx + dy * dy;
var t = L2 > 1e-9 ? ((px - s.x1) * dx + (py - s.y1) * dy) / L2 : 0;
if (t < 0) t = 0; else if (t > 1) t = 1;
return { x: s.x1 + dx * t, y: s.y1 + dy * t };
}
// 接触法线约定:由「静态物」指向球;ball-ball 时由 a 指向 b
function contactBallStatic(b, s) {
var dx, dy, R, d2, d;
if (s.type === 'circle') {
dx = b.x - s.x; dy = b.y - s.y;
R = b.r + s.r;
d2 = dx * dx + dy * dy;
if (d2 >= R * R) return null;
d = Math.sqrt(d2) || 1e-9;
return { nx: dx / d, ny: dy / d, pen: R - d };
}
var c = closestOnSeg(b.x, b.y, s);
dx = b.x - c.x; dy = b.y - c.y;
d2 = dx * dx + dy * dy;
// 胶囊厚度 thk:检测带宽 2*(r+thk)。零厚度线段会让高速球在一个子步内
// 跨过中线导致法线翻转、位置校正反向"助推"穿模;thk>0 使带宽大于最大
// 相对位移(maxV+拨片线速度)/360,数学上杜绝穿透
var R2 = b.r + (s.thk || 0);
if (d2 >= R2 * R2) return null;
d = Math.sqrt(d2) || 1e-9;
var ct = { nx: dx / d, ny: dy / d, pen: R2 - d };
// 运动碰撞体(雨刮拨片):按接触点附表面线速度 v=ω×r,供相对速度冲量求解
if (s.mov) { ct.svx = -s.mov.omega * (c.y - s.mov.py); ct.svy = s.mov.omega * (c.x - s.mov.px); }
return ct;
}
function contactBalls(a, b) {
var dx = b.x - a.x, dy = b.y - a.y;
var R = a.r + b.r;
var d2 = dx * dx + dy * dy;
if (d2 >= R * R) return null;
var d = Math.sqrt(d2) || 1e-9;
return { nx: dx / d, ny: dy / d, pen: R - d };
}
// 序贯冲量:法向(含恢复系数与静止阈值)+ 切向库仑摩擦(驱动角速度)
// 法线约定:ball-static 由静态物指向球(vn<0 为接近);ball-ball 由 a 指向 b(vn>0 为接近)
function solveContact(c, p) {
var a = c.a, b = c.b;
var nx = c.nx, ny = c.ny;
var tx = -ny, ty = nx;
var rvx = a.vx, rvy = a.vy;
if (b) { rvx -= b.vx; rvy -= b.vy; }
else { rvx -= (c.svx || 0); rvy -= (c.svy || 0); } // 对运动表面(拨片)取相对速度
var vn = rvx * nx + rvy * ny;
if (b) {
// ---- ball-ball:vn>0 接近 ----
if (vn > 0) {
var e1 = (vn > p.restV) ? p.e : 0;
var invM1 = a.invM + b.invM;
var jn1 = (1 + e1) * vn / invM1;
a.vx -= nx * jn1 * a.invM; a.vy -= ny * jn1 * a.invM;
b.vx += nx * jn1 * b.invM; b.vy += ny * jn1 * b.invM;
c.jn = (c.jn || 0) + jn1;
}
var jnAcc1 = c.jn || 0;
if (jnAcc1 <= 0) return;
var vt1 = rvx * tx + rvy * ty + a.omega * a.r - b.omega * b.r;
var Kt1 = a.invM + a.r * a.r * a.invI + b.invM + b.r * b.r * b.invI;
var jt1 = -vt1 / Kt1;
var maxF1 = p.mu * jnAcc1;
if (jt1 > maxF1) jt1 = maxF1; else if (jt1 < -maxF1) jt1 = -maxF1;
a.vx += tx * jt1 * a.invM; a.vy += ty * jt1 * a.invM;
b.vx -= tx * jt1 * b.invM; b.vy -= ty * jt1 * b.invM;
a.omega += a.r * jt1 * a.invI;
b.omega += b.r * jt1 * b.invI;
} else {
// ---- ball-static:vn<0 接近 ----
if (vn < 0) {
var e0 = (-vn > p.restV) ? p.e : 0;
var jn0 = -(1 + e0) * vn / a.invM;
a.vx += nx * jn0 * a.invM; a.vy += ny * jn0 * a.invM;
c.jn = (c.jn || 0) + jn0;
}
var jnAcc0 = c.jn || 0;
if (jnAcc0 <= 0) return;
var vt0 = rvx * tx + rvy * ty - a.omega * a.r;
var Kt0 = a.invM + a.r * a.r * a.invI;
var jt0 = -vt0 / Kt0;
var maxF0 = p.mu * jnAcc0;
if (jt0 > maxF0) jt0 = maxF0; else if (jt0 < -maxF0) jt0 = -maxF0;
a.vx += tx * jt0 * a.invM; a.vy += ty * jt0 * a.invM;
a.omega -= a.r * jt0 * a.invI;
}
}
// 推进一步 dt:积分 -> 收集接触 -> 冲量迭代 -> 位置校正 -> 滚动阻力
function stepWorld(balls, statics, p, dt) {
var i, j, b, o, c;
for (i = 0; i < balls.length; i++) {
b = balls[i];
if (b.sleeping) continue;
b.vy += p.g * dt;
if (p.airK > 0) { var f = Math.exp(-p.airK * dt); b.vx *= f; b.vy *= f; }
var sp2 = b.vx * b.vx + b.vy * b.vy;
if (sp2 > p.maxV * p.maxV) { var s = p.maxV / Math.sqrt(sp2); b.vx *= s; b.vy *= s; }
b.x += b.vx * dt;
b.y += b.vy * dt;
b.angle += b.omega * dt;
b.contacts = 0;
}
var contacts = [];
for (i = 0; i < balls.length; i++) {
b = balls[i];
if (b.sleeping) continue;
for (j = 0; j < statics.length; j++) {
c = contactBallStatic(b, statics[j]);
if (c) { c.a = b; c.b = null; c.jn = 0; contacts.push(c); b.contacts++; }
}
for (j = 0; j < balls.length; j++) {
o = balls[j];
if (o === b) continue;
if (o.sleeping) {
c = contactBallStatic(b, { type: 'circle', x: o.x, y: o.y, r: o.r });
if (c) { c.a = b; c.b = null; c.jn = 0; contacts.push(c); b.contacts++; }
} else if (j > i) {
c = contactBalls(b, o);
if (c) { c.a = b; c.b = o; c.jn = 0; contacts.push(c); b.contacts++; o.contacts++; }
}
}
}
for (var it = 0; it < p.iterations; it++) {
for (j = 0; j < contacts.length; j++) solveContact(contacts[j], p);
}
for (j = 0; j < contacts.length; j++) {
c = contacts[j];
var corr = Math.max(c.pen - p.slop, 0) * p.correction;
if (corr <= 0) continue;
if (c.b) {
var tm = c.a.invM + c.b.invM;
c.a.x -= c.nx * corr * (c.a.invM / tm);
c.a.y -= c.ny * corr * (c.a.invM / tm);
c.b.x += c.nx * corr * (c.b.invM / tm);
c.b.y += c.ny * corr * (c.b.invM / tm);
} else {
c.a.x += c.nx * corr;
c.a.y += c.ny * corr;
}
}
for (i = 0; i < balls.length; i++) {
b = balls[i];
if (b.sleeping || b.contacts === 0) continue;
var sp = Math.sqrt(b.vx * b.vx + b.vy * b.vy);
if (sp > 1e-6) {
var dec = Math.min(sp, p.rollR * p.g * dt);
var ff = (sp - dec) / sp;
b.vx *= ff; b.vy *= ff;
}
b.omega *= Math.exp(-1.5 * dt);
}
return contacts;
}
return {
createBall: createBall,
closestOnSeg: closestOnSeg,
contactBallStatic: contactBallStatic,
contactBalls: contactBalls,
solveContact: solveContact,
stepWorld: stepWorld
};
})();
/* ==PHYSICS_CORE_END== */
/* ==TABLE_GEOM_START== */
// ------------------------------------------------------------
// 台面几何(纯数据 / 纯函数,不依赖 DOM,供集成测试提取)
// 逻辑坐标,单位 px,y 向下
// ------------------------------------------------------------
var W = 460, H = 800; // 画布逻辑尺寸(含框外拉杆把手)
var FRAME = 18; // 外框厚度
var L = FRAME, R = W - FRAME, T = FRAME, B = 760 - FRAME; // 内场边界 (18..442, 18..742)
var LANE_X = 398; // 发射道左壁 x
var BALL_R = 11;
// 弧由三点解出:左出口(60,340) / 弧顶(240,50) / 右端(442,220)
// 右端覆盖发射道顶部开口,球沿切线切入绕弧、从左侧落入钉阵;弱力则落回发射道
var ARCH = { cx: 242.294, cy: 252.286, r: 202.299, a0: -205.695, a1: -9.183 }; // 顶部弧形导轨(角度制)
var SLOT_TOP = 495, SLOT_BOT = 665, BAR_Y = 660;
// 右边界与发射道左壁(LANE_X)并齐:最右坑右壁即发射道壁,杜绝 20px 卡球缝
var SLOT_X0 = 18, SLOT_X1 = 398, SLOT_N = 6;
var SLOT_VALUES = [100, 50, 25, 25, 50, 100];
var SPRING_X = 420, SPRING_TOP = 706, SPRING_BOT = 742;
var TOTAL_BALLS = 8;
var G_PX = 9.81 * 2000; // 像素尺度:2000 px ≈ 1 m
function seg(x1, y1, x2, y2) { return { type: 'seg', x1: x1, y1: y1, x2: x2, y2: y2 }; }
// 生成整张台面的静态碰撞体与钉子渲染信息
function buildTableStatics(pegR, pegSpacing) {
var statics = [];
var pegs = [];
var i;
// 外框四壁
statics.push(seg(L, T, R, T), seg(L, B, R, B), seg(L, T, L, B), seg(R, T, R, B));
// 发射道左壁(顶端接弧形导轨右端,底端接底壁封死,杜绝壁底缺口侧逃)
statics.push(seg(LANE_X, 190, LANE_X, B));
// 弧形导轨(离散为线段链)
var steps = 40;
var a0 = ARCH.a0 * Math.PI / 180, a1 = ARCH.a1 * Math.PI / 180;
var px = 0, py = 0;
for (i = 0; i <= steps; i++) {
var a = a0 + (a1 - a0) * (i / steps);
var x = ARCH.cx + ARCH.r * Math.cos(a);
var y = ARCH.cy + ARCH.r * Math.sin(a);
if (i > 0) statics.push(seg(px, py, x, y));
px = x; py = y;
}
// 落球坑:5 根分隔木条(右边界复用发射道左壁,不另设右长条,避免与道壁重叠/留缝)
var slotW = (SLOT_X1 - SLOT_X0) / SLOT_N;
for (i = 1; i < SLOT_N; i++) statics.push(seg(SLOT_X0 + slotW * i, SLOT_TOP, SLOT_X0 + slotW * i, SLOT_BOT));
// 底部横挡木条(球停在它上面)
statics.push(seg(SLOT_X0, BAR_Y, SLOT_X1, BAR_Y));
// 圆钉阵(交错,间距/尺寸可调)
var cx = 205, cy = 412;
var rows = [
{ y: 372, xs: [100, 170, 240, 310] },
{ y: 412, xs: [65, 135, 205, 275, 345] },
{ y: 452, xs: [100, 170, 240, 310] }
];
for (var rI = 0; rI < rows.length; rI++) {
var row = rows[rI];
var yy = cy + (row.y - cy) * pegSpacing;
for (var xI = 0; xI < row.xs.length; xI++) {
var xx = cx + (row.xs[xI] - cx) * pegSpacing;
statics.push({ type: 'circle', x: xx, y: yy, r: pegR });
pegs.push({ x: xx, y: yy, r: pegR, metal: ((rI + xI) % 3 === 2) });
}
}
return { statics: statics, pegs: pegs };
}
function slotIndexAt(x, y) {
if (x < SLOT_X0 || x > SLOT_X1 || y < SLOT_TOP) return -1;
var w = (SLOT_X1 - SLOT_X0) / SLOT_N;
var idx = Math.floor((x - SLOT_X0) / w);
return idx < 0 ? 0 : (idx >= SLOT_N ? SLOT_N - 1 : idx);
}
// ------------------------------------------------------------
// 雨刮拨片(可选组件,checkbox 开启):两根绕中心摆动的木杆,
// 像汽车雨刮器刮来刮去,球被扫到即改变轨迹,增加随机性。
// px,py=摆心;half=半长;A=摆幅(rad);T=周期(s);ph=相位
// ------------------------------------------------------------
var WIPERS = [
{ px: 140, py: 262, half: 75, A: 0.8, T: 1.5, ph: 0, spinK: 2.2 },
{ px: 298, py: 258, half: 45, A: 0.8, T: 1.15, ph: 1.7, spinK: -1.7 }
];
function makeWiperSegs() {
var out = [];
for (var i = 0; i < WIPERS.length; i++) {
// thk=4 胶囊厚度:防高速球穿透(见 contactBallStatic 注释)
out.push({ type: 'seg', x1: 0, y1: 0, x2: 0, y2: 0, thk: 4, mov: { px: 0, py: 0, omega: 0 } });
}
return out;
}
// spin=false:雨刮摆动;spin=true:绕摆心连续转动(两杆反向)
function updateWiperSeg(sg, w, t, spin) {
var th, om;
if (spin) { th = w.spinK * t + w.ph; om = w.spinK; }
else {
var k = 2 * Math.PI / w.T;
th = w.A * Math.sin(k * t + w.ph);
om = w.A * k * Math.cos(k * t + w.ph);
}
var c = Math.cos(th), sn = Math.sin(th);
sg.x1 = w.px - c * w.half; sg.y1 = w.py - sn * w.half;
sg.x2 = w.px + c * w.half; sg.y2 = w.py + sn * w.half;
sg.mov.px = w.px; sg.mov.py = w.py; sg.mov.omega = om;
}
/* ==TABLE_GEOM_END== */
// ------------------------------------------------------------
// 游戏 / 渲染层(浏览器专用)
// ------------------------------------------------------------
(function () {
'use strict';
// ---------- 参数(面板实时读取) ----------
var params = {
g: 0, e: 0.55, mu: 0.12, airK: 0.02, rollR: 0.25,
restV: 50, iterations: 8, slop: 0.3, correction: 0.8, maxV: 4200
};
var launchMax = 2900;
function refreshG() {
var tilt = Number(el('p-tilt').value) * Math.PI / 180;
params.g = G_PX * Math.sin(tilt);
}
// ---------- DOM ----------
function el(id) { return document.getElementById(id); }
var cv = el('cv'), ctx = cv.getContext('2d');
var hud = el('hud'), overlay = el('overlay');
// ---------- 状态 ----------
var statics = [];
var pegs = []; // 渲染用钉子信息
var balls = [];
var score = 0, launched = 0; // launched = 已入场(消耗机会)的球数
var spawned = 0; // spawned = 已生成的球数(上限 TOTAL_BALLS,防止在途球未计数时多生球)
var charging = false, charge = 0;
var springAnim = 0;
var roundOver = false;
var wipersOn = false, wiperSpin = false, wiperSegs = [], simT = 0;
// ---------- 台面碰撞体 ----------
function buildTable() {
var t = buildTableStatics(Number(el('p-pegr').value), Number(el('p-pegs').value));
statics = t.statics;
pegs = t.pegs;
wiperSegs = [];
if (wipersOn) {
wiperSegs = makeWiperSegs();
for (var i = 0; i < wiperSegs.length; i++) {
updateWiperSeg(wiperSegs[i], WIPERS[i], simT);
statics.push(wiperSegs[i]);
}
}
renderStaticLayer();
}
// ---------- 回合逻辑 ----------
function readyBall() {
for (var i = 0; i < balls.length; i++) if (balls[i].ready) return balls[i];
return null;
}
function spawnReady() {
// 用 spawned 而非 launched 做上限:在途未入场的球也算已占用名额,
// 否则第 8 球在途时 launched 仍为 7,会多生第 9 个球(剩余变 -1 的根因)
if (roundOver || readyBall() || spawned >= TOTAL_BALLS) return;
var b = PhysicsCore.createBall(SPRING_X, SPRING_TOP - BALL_R, BALL_R);
b.ready = true;
b.sleeping = true; // 待发射球作为静态碰撞体(可被落回的球堆叠)
balls.push(b);
spawned++;
}
function settleBall(b) {
b.sleeping = true;
b.ready = false;
b.vx = 0; b.vy = 0; b.omega = 0;
// 掉回发射道 → 回到弹簧上待发射(未入场,不消耗机会,可重新蓄力)
if (b.x > LANE_X && b.y > 500) {
b.x = SPRING_X; b.y = SPRING_TOP - BALL_R;
b.ready = true; b.trail = [];
return;
}
var idx = slotIndexAt(b.x, b.y);
if (idx >= 0) {
score += SLOT_VALUES[idx];
b.slot = idx;
}
}
function launch() {
var b = readyBall();
if (!b || roundOver) return;
// 过压机理:charge>0.85(蓄力条红区)弹簧过压缩,
// 初速随机损耗、横向抖动剧增 → 满力≠高分,力度要拿捏(甜区约 0.8~0.9)
var over = Math.max(0, charge - 0.85) / 0.15; // 0..1 过压程度
// 力度曲线:0~20% 弱力区(到不了盘面落回,不消耗机会);
// 20% 恰好进盘面(ENTER_V);20%~100% 线性到最大力 —— 低力度也有用,手感区间拉满
var ENTER_V = 2400;
var v0 = charge <= 0.2
? 1000 + (charge / 0.2) * (ENTER_V - 1000)
: ENTER_V + ((charge - 0.2) / 0.8) * (launchMax - ENTER_V);
v0 *= 1 - 0.12 * over * over * Math.random(); // 过压随机损耗初速 0~12%
b.ready = false;
b.sleeping = false;
b.sleepTimer = 0;
b.vy = -v0;
var jitter = 12 + 25 * charge + 150 * over * over; // 横向抖动幅值 px/s
b.vx = (Math.random() - 0.5) * 2 * jitter;
b.trail = [];
// 注意:此处不计数。只有球完全离开发射道进入盘面才计一次机会(见主循环),
// 力度不足落回弹簧不消耗机会,可重新蓄力发射
springAnim = 1;
}
function resetRound() {
balls = [];
score = 0; launched = 0; spawned = 0;
charge = 0; charging = false;
roundOver = false;
overlay.classList.remove('show');
buildTable();
spawnReady();
}
function clearBalls() {
balls = [];
roundOver = false;
overlay.classList.remove('show');
launched = 0; spawned = 0; score = 0;
spawnReady();
}
// ---------- 主循环 ----------
var lastT = 0;
var SUB_DT = 1 / 360;
function frame(t) {
requestAnimationFrame(frame);
if (!lastT) lastT = t;
var dt = Math.min(0.05, (t - lastT) / 1000);
lastT = t;
refreshG();
params.e = Number(el('p-rest').value);
params.mu = Number(el('p-mu').value);
params.airK = Number(el('p-air').value);
params.rollR = Number(el('p-roll').value);
launchMax = Number(el('p-launch').value);
if (charging) charge = Math.min(1, charge + dt / 1.0);
if (springAnim > 0) springAnim = Math.max(0, springAnim - dt / 0.15);
var acc = dt;
while (acc > 1e-6) {
var h = Math.min(SUB_DT, acc);
// 拨片按子步(360Hz)更新端点与角速度:运动体位移逐子步解析,防跨帧跳变
simT += h;
if (wipersOn) for (var wi = 0; wi < wiperSegs.length; wi++) updateWiperSeg(wiperSegs[wi], WIPERS[wi], simT, wiperSpin);
PhysicsCore.stepWorld(activeBalls(), statics, params, h);
acc -= h;
}
// 残影记录:高速运动时采样轨迹点;慢速/静止时逐渐消退
for (i = 0; i < balls.length; i++) {
b = balls[i];
if (!b.trail) b.trail = [];
if (!b.sleeping && !b.ready && (b.vx * b.vx + b.vy * b.vy) > 2500) {
b.trail.push({ x: b.x, y: b.y });
if (b.trail.length > 14) b.trail.shift();
} else if (b.trail.length) b.trail.shift();
}
// 静止判定 / 出界保护 / 入场计数
for (var i = 0; i < balls.length; i++) {
var b = balls[i];
// 机会计数:球完全越出发射道左壁(进入盘面)才计一次;
// 力度不足落回发射道不计数,回到弹簧可重新蓄力
if (!b.counted && !b.ready && !b.sleeping && b.x < LANE_X - b.r) { b.counted = true; launched++; }
if (b.sleeping) continue;
var sp = Math.sqrt(b.vx * b.vx + b.vy * b.vy);
if (sp < 15 && b.contacts > 0) b.sleepTimer += dt; else b.sleepTimer = 0;
if (b.sleepTimer > 0.5) settleBall(b);
if (b.y > B + BALL_R + 8 || b.y > H + 40 || b.x < -40 || b.x > W + 40) { // 出界保护:回到弹簧
b.x = SPRING_X; b.y = SPRING_TOP - BALL_R;
b.vx = 0; b.vy = 0; b.omega = 0;
b.ready = true; b.sleeping = true; b.trail = [];
}
}
if (!readyBall() && !roundOver) spawnReady();
if (!roundOver && launched >= TOTAL_BALLS) {
var anyActive = false;
for (i = 0; i < balls.length; i++) if (!balls[i].sleeping) { anyActive = true; break; }
if (!anyActive && !readyBall()) {
roundOver = true;
el('final-score').textContent = score;
overlay.classList.add('show');
}
}
draw();
updateHud();
}
function activeBalls() {
// 待发射球不参与积分(sleeping=true 已跳过),直接传全部即可
return balls;
}
// ---------- HUD ----------
function updateHud() {
var counts = [0, 0, 0, 0, 0, 0];
for (var i = 0; i < balls.length; i++) {
if (balls[i].sleeping && balls[i].slot !== undefined) counts[balls[i].slot]++;
}
hud.innerHTML =
'剩余弹珠 <b>' + (TOTAL_BALLS - launched) + '</b> / ' + TOTAL_BALLS +
' ・ 得分 <b>' + score + '</b>' +
' ・ 落坑 ' + counts.map(function (c, i) { return (i + 1) + ':' + c; }).join(' ');
}
// ---------- 渲染 ----------
var scale = 1, staticLayer = null;
function fit() {
var availH = Math.max(480, window.innerHeight - 140);
scale = Math.min(1.4, availH / H);
var dpr = window.devicePixelRatio || 1;
cv.width = Math.round(W * scale * dpr);
cv.height = Math.round(H * scale * dpr);
cv.style.width = Math.round(W * scale) + 'px';
cv.style.height = Math.round(H * scale) + 'px';
renderStaticLayer();
}
function mulberry32(seed) {
return function () {
seed |= 0; seed = seed + 0x6D2B79F5 | 0;
var t = Math.imul(seed ^ seed >>> 15, 1 | seed);
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
return ((t ^ t >>> 14) >>> 0) / 4294967296;
};
}
function woodGrain(g, x, y, w, h, rnd, n, dark) {
g.strokeStyle = dark ? 'rgba(120,80,40,.18)' : 'rgba(255,240,210,.25)';
g.lineWidth = 1;
for (var i = 0; i < n; i++) {
var yy = y + rnd() * h;
g.beginPath();
g.moveTo(x, yy);
for (var xx = x; xx <= x + w; xx += 20) {
g.lineTo(xx, yy + Math.sin(xx * 0.05 + i) * 1.5 + (rnd() - 0.5) * 2);
}
g.stroke();
}
}
function renderStaticLayer() {
var dpr = window.devicePixelRatio || 1;
staticLayer = document.createElement('canvas');
staticLayer.width = Math.round(W * scale * dpr);
staticLayer.height = Math.round(H * scale * dpr);
var g = staticLayer.getContext('2d');
g.setTransform(scale * dpr, 0, 0, scale * dpr, 0, 0);
var rnd = mulberry32(20260807);
// 外框
var fg = g.createLinearGradient(0, 0, W, 0);
fg.addColorStop(0, '#b98d58'); fg.addColorStop(0.5, '#d3a86e'); fg.addColorStop(1, '#a97e4b');
g.fillStyle = fg;
roundRect(g, 0, 0, W, 760, 10); g.fill();
woodGrain(g, 0, 0, W, 20, rnd, 14, true);
woodGrain(g, 0, 740, W, 20, rnd, 14, true);
woodGrain(g, 0, 0, 20, 760, rnd, 14, true);
woodGrain(g, W - 20, 0, 20, 760, rnd, 14, true);
// 内场底板
var bg = g.createLinearGradient(0, 0, 0, 760);
bg.addColorStop(0, '#e9cf9f'); bg.addColorStop(1, '#dfc08a');
g.fillStyle = bg;
g.fillRect(L, T, R - L, B - T);
woodGrain(g, L, T, R - L, B - T, rnd, 40, false);
// 弧形导轨
g.strokeStyle = '#8a6238'; g.lineWidth = 11; g.lineCap = 'round';
g.beginPath();
g.arc(ARCH.cx, ARCH.cy, ARCH.r, ARCH.a0 * Math.PI / 180, ARCH.a1 * Math.PI / 180);
g.stroke();
g.strokeStyle = '#c89a62'; g.lineWidth = 7;
g.beginPath();
g.arc(ARCH.cx, ARCH.cy, ARCH.r, ARCH.a0 * Math.PI / 180, ARCH.a1 * Math.PI / 180);
g.stroke();
// 发射道左壁(与物理一致,通到底壁)
woodBar(g, LANE_X - 3, 190, 6, B - 190);
// 落球坑木条(右边界即发射道左壁,已随道壁绘制)
var slotW = (SLOT_X1 - SLOT_X0) / SLOT_N;
for (var i = 1; i < SLOT_N; i++) woodBar(g, SLOT_X0 + slotW * i - 3, SLOT_TOP, 6, SLOT_BOT - SLOT_TOP);
// 底部横挡 + 分值
woodBar(g, SLOT_X0, BAR_Y, SLOT_X1 - SLOT_X0, 10);
g.fillStyle = '#6b4a26';
g.font = 'bold 11px Consolas, monospace';
g.textAlign = 'center';
var w = (SLOT_X1 - SLOT_X0) / SLOT_N;
for (i = 0; i < SLOT_N; i++) {
g.fillText(String(SLOT_VALUES[i]), SLOT_X0 + w * (i + 0.5), BAR_Y + 8.5);
}
// 左下楔形斜坡(装饰,同实物)
g.fillStyle = '#cfa76e';
g.beginPath();
g.moveTo(L, B); g.lineTo(L, 696); g.lineTo(SLOT_X1, B); g.closePath();
g.fill();
g.strokeStyle = '#8a6238'; g.lineWidth = 2; g.stroke();
woodGrain(g, L, 700, SLOT_X1 - L, 42, rnd, 8, true);
// 圆钉
for (i = 0; i < pegs.length; i++) {
var p = pegs[i];
if (p.metal) {
var mg = g.createRadialGradient(p.x - 1, p.y - 1, 1, p.x, p.y, p.r);
mg.addColorStop(0, '#e8e8e8'); mg.addColorStop(1, '#8a8a8a');
g.fillStyle = mg;
} else {
g.fillStyle = '#a97e4b';
}
g.beginPath(); g.arc(p.x, p.y, p.r, 0, Math.PI * 2); g.fill();
g.strokeStyle = 'rgba(60,35,10,.5)'; g.lineWidth = 1; g.stroke();
if (p.metal) {
g.strokeStyle = '#555'; g.beginPath();
g.moveTo(p.x - p.r * 0.5, p.y); g.lineTo(p.x + p.r * 0.5, p.y); g.stroke();
}
}
// 弹簧底板
g.fillStyle = '#8a6238';
g.fillRect(SPRING_X - 16, SPRING_BOT - 4, 32, 6);
// 注意:拨片是动态件,必须在 draw() 每帧绘制,不能烘进静态层
}
function roundRect(g, x, y, w, h, r) {
g.beginPath();
g.moveTo(x + r, y);
g.arcTo(x + w, y, x + w, y + h, r);
g.arcTo(x + w, y + h, x, y + h, r);
g.arcTo(x, y + h, x, y, r);
g.arcTo(x, y, x + w, y, r);
g.closePath();
}
function woodBar(g, x, y, w, h) {
g.fillStyle = '#c89a62';
g.fillRect(x, y, w, h);
g.strokeStyle = '#8a6238';
g.lineWidth = 1.5;
g.strokeRect(x, y, w, h);
}
function draw() {
var dpr = window.devicePixelRatio || 1;
ctx.setTransform(scale * dpr, 0, 0, scale * dpr, 0, 0);
ctx.clearRect(0, 0, W, H);
ctx.drawImage(staticLayer, 0, 0, W, H);
// 雨刮拨片(动态件,每帧按当前摆角/转角绘制,画在球之下)
for (var wi2 = 0; wi2 < wiperSegs.length; wi2++) {
var ws = wiperSegs[wi2];
ctx.strokeStyle = '#7a5230'; ctx.lineWidth = 9; ctx.lineCap = 'round';
ctx.beginPath(); ctx.moveTo(ws.x1, ws.y1); ctx.lineTo(ws.x2, ws.y2); ctx.stroke();
ctx.strokeStyle = '#c89a62'; ctx.lineWidth = 5;
ctx.beginPath(); ctx.moveTo(ws.x1, ws.y1); ctx.lineTo(ws.x2, ws.y2); ctx.stroke();
ctx.fillStyle = '#5a5a5a';
ctx.beginPath(); ctx.arc(ws.mov.px, ws.mov.py, 4, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#2e2e2e';
ctx.beginPath(); ctx.arc(ws.mov.px, ws.mov.py, 1.8, 0, Math.PI * 2); ctx.fill();
}
// 弹簧 + 压板(蓄力下压 / 释放回弹)
var comp = charging ? charge : springAnim * 0.4;
var headY = SPRING_TOP + comp * 34;
ctx.strokeStyle = '#666';
ctx.lineWidth = 2.5;
ctx.beginPath();
var coils = 6;
for (var i = 0; i <= coils; i++) {
var yy = headY + (SPRING_BOT - headY) * (i / coils);
var xx = SPRING_X + ((i % 2 === 0) ? -9 : 9);
if (i === 0 || i === coils) xx = SPRING_X;
if (i === 0) ctx.moveTo(xx, yy); else ctx.lineTo(xx, yy);
}
ctx.stroke();
ctx.fillStyle = '#8a6238';
ctx.fillRect(SPRING_X - 14, headY - 4, 28, 5);
// 框外拉杆把手
ctx.fillStyle = '#a97e4b';
ctx.fillRect(SPRING_X - 5, 760, 10, 20);
var kg = ctx.createRadialGradient(SPRING_X - 3, 788, 2, SPRING_X, 791, 11);
kg.addColorStop(0, '#d3a86e'); kg.addColorStop(1, '#8a6238');
ctx.fillStyle = kg;
ctx.beginPath(); ctx.arc(SPRING_X, 791, 10, 0, Math.PI * 2); ctx.fill();
// 蓄力条
if (charging || charge > 0) {
ctx.fillStyle = 'rgba(0,0,0,.35)';
ctx.fillRect(R - 12, 620, 8, 100);
ctx.fillStyle = charge > 0.85 ? '#e05c5c' : '#e0a35c';
var hh = 100 * (charging ? charge : 0);
ctx.fillRect(R - 12, 720 - hh, 8, hh);
}
// 尾部残影(越旧越透明越小,画在球体之下)
for (i = 0; i < balls.length; i++) {
var tb = balls[i];
if (!tb.trail || tb.trail.length < 2) continue;
for (var k = 0; k < tb.trail.length; k++) {
var f = (k + 1) / tb.trail.length;
ctx.fillStyle = 'rgba(140,230,170,' + (0.26 * f * f).toFixed(3) + ')';
ctx.beginPath();
ctx.arc(tb.trail[k].x, tb.trail[k].y, tb.r * (0.3 + 0.6 * f), 0, Math.PI * 2);
ctx.fill();
}
}
// 弹珠(绿色玻璃,纹理随角速度旋转)
for (i = 0; i < balls.length; i++) {
var b = balls[i];
var by = b.y;
if (b.ready) by = SPRING_TOP - BALL_R + comp * 34;
var grd = ctx.createRadialGradient(b.x - 4, by - 4, 2, b.x, by, b.r);
grd.addColorStop(0, '#b8f0c0');
grd.addColorStop(0.35, '#3faf5f');
grd.addColorStop(1, '#0d5c2a');
ctx.fillStyle = grd;
ctx.beginPath(); ctx.arc(b.x, by, b.r, 0, Math.PI * 2); ctx.fill();
// 内部旋纹
ctx.save();
ctx.translate(b.x, by);
ctx.rotate(b.angle);
ctx.strokeStyle = 'rgba(255,255,255,.4)';
ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.arc(0, 0, b.r * 0.55, 0.3, 2.6); ctx.stroke();
ctx.beginPath(); ctx.arc(0, 0, b.r * 0.3, 3.4, 5.6); ctx.stroke();
ctx.restore();
// 高光
ctx.fillStyle = 'rgba(255,255,255,.75)';
ctx.beginPath(); ctx.arc(b.x - b.r * 0.35, by - b.r * 0.4, b.r * 0.22, 0, Math.PI * 2); ctx.fill();
}
}
// ---------- 交互 ----------
function pressStart(e) {
if (roundOver) return;
if (!readyBall()) return;
charging = true;
charge = 0;
e.preventDefault();
}
function pressEnd() {
if (!charging) return;
charging = false;
launch();
charge = 0;
}
cv.addEventListener('pointerdown', pressStart);
window.addEventListener('pointerup', pressEnd);
window.addEventListener('pointercancel', pressEnd);
window.addEventListener('keydown', function (e) {
if (e.code === 'Space' && !e.repeat) pressStart(e);
});
window.addEventListener('keyup', function (e) {
if (e.code === 'Space') pressEnd();
});
el('btn-reset').addEventListener('click', resetRound);
el('btn-clear').addEventListener('click', clearBalls);
el('btn-again').addEventListener('click', resetRound);
el('p-pegr').addEventListener('input', function () { el('v-pegr').textContent = el('p-pegr').value + 'px'; resetRound(); });
el('p-pegs').addEventListener('input', function () { el('v-pegs').textContent = 'x' + el('p-pegs').value; resetRound(); });
el('p-wipers').addEventListener('change', function () {
wipersOn = this.checked;
if (!wipersOn && wiperSpin) { // 拨片关 → 转动一并清零
wiperSpin = false;
el('p-wiperspin').checked = false;
el('v-wiperspin').textContent = '关';
}
el('v-wipers').textContent = wipersOn ? '开' : '关';
buildTable(); // 仅增删拨片碰撞体,不重开局
});
el('p-wiperspin').addEventListener('change', function () {
wiperSpin = this.checked;
el('v-wiperspin').textContent = wiperSpin ? '开' : '关';
if (wiperSpin && !wipersOn) { // 勾转动自动启用拨片
wipersOn = true;
el('p-wipers').checked = true;
el('v-wipers').textContent = '开';
buildTable();
}
});
function syncLabels() {
el('v-tilt').textContent = el('p-tilt').value + '°';
el('v-rest').textContent = 'e=' + Number(el('p-rest').value).toFixed(2);
el('v-mu').textContent = 'μ=' + Number(el('p-mu').value).toFixed(2);
el('v-air').textContent = el('p-air').value;
el('v-roll').textContent = el('p-roll').value;
el('v-launch').textContent = el('p-launch').value;
el('v-pegr').textContent = el('p-pegr').value + 'px';
el('v-pegs').textContent = 'x' + el('p-pegs').value;
}
['p-tilt', 'p-rest', 'p-mu', 'p-air', 'p-roll', 'p-launch'].forEach(function (id) {
el(id).addEventListener('input', syncLabels);
});
window.addEventListener('resize', fit);
// ---------- 启动 ----------
syncLabels();
fit();
resetRound();
requestAnimationFrame(frame);
})();
</script>
</body>
</html>
@@ -0,0 +1,598 @@
<!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:#050510;
color:#e0e8ff;
font-family:"Microsoft Yahei",system-ui,sans-serif;
overflow:hidden;
height:100vh;
}
#container{
width:100vw;height:100vh;
position:relative;
}
#canvas{
width:100%;height:100%;
display:block;
}
.ui-wrap{
position:absolute;
inset:0;
pointer-events:none;
}
.text-panel{
position:absolute;
left:24px;bottom:120px;
max-width:620px;
background:rgba(0,8,30,0.72);
border:1px solid #304470;
border-radius:10px;
padding:18px 22px;
pointer-events:auto;
}
.narrative{
font-size:16px;
line-height:1.7;
min-height:80px;
color:#d4e2ff;
}
.cursor{
display:inline-block;
width:8px;height:18px;
background:#73a8ff;
animation:blink 0.7s infinite;
vertical-align:text-bottom;
}
@keyframes blink{0%,49%{opacity:1}50%,100%{opacity:0}}
.info-panel{
position:absolute;
right:20px;top:20px;
width:340px;
background:rgba(0,8,30,0.74);
border:1px solid #304470;
border-radius:10px;
padding:16px;
pointer-events:auto;
font-size:13px;
line-height:1.55;
}
.info-title{
font-weight:bold;margin-bottom:8px;color:#94b8ff;
}
.real-sci{color:#88e8aa;margin-bottom:10px;}
.fiction{color:#ffbc73;}
.ctrl-bar{
position:absolute;
bottom:24px;left:50%;transform:translateX(-50%);
display:flex;gap:12px;
pointer-events:auto;
}
button{
background:#162850;
color:#e0e8ff;
border:1px solid #4060a0;
padding:9px 18px;
border-radius:6px;
cursor:pointer;
font-size:14px;
}
button:hover{background:#243c70;}
.dot-group{
display:flex;gap:8px;align-items:center;margin-left:16px;
}
.dot{
width:10px;height:10px;border-radius:50%;background:#223355;
}
.dot.active{background:#60a0ff;box-shadow:0 0 6px #60a0ff;}
.hint{
position:absolute;left:20px;top:16px;font-size:12px;color:#7788aa;
}
</style>
</head>
<body>
<div id="container">
<canvas id="canvas"></canvas>
<div class="ui-wrap">
<div class="hint">键盘 ← → 切换场景 | 思想实验室 · 穿越三体</div>
<div class="info-panel">
<div class="info-title">场景解析</div>
<div class="real-sci">【真实科学】<div id="sci-list"></div></div>
<div class="fiction">【小说想象】<div id="fic-list"></div></div>
</div>
<div class="text-panel">
<div class="narrative" id="narration"></div>
<span class="cursor"></span>
</div>
<div class="ctrl-bar">
<button id="prevBtn">上一幕</button>
<button id="nextBtn">下一幕</button>
<div class="dot-group" id="dotWrap"></div>
</div>
</div>
</div>
<script>
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const narrationEl = document.getElementById('narration');
const sciListEl = document.getElementById('sci-list');
const ficListEl = document.getElementById('fic-list');
const prevBtn = document.getElementById('prevBtn');
const nextBtn = document.getElementById('nextBtn');
const dotWrap = document.getElementById('dotWrap');
// 画布适配
function resize(){
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
}
resize();
window.addEventListener('resize',resize);
// ---------------------- 场景定义 ----------------------
const SCENES = [
{
name:"地球出发",
narrative:`我站在近地轨道的飞船舷窗边。蓝色的地球静静悬浮在黑暗里,云海缠绕大陆,细碎城市灯火在夜晚半球闪烁。
人类文明的全部重量,都蜷缩在这颗小小的岩石行星之上。我即将启程,奔赴4.37光年之外的半人马座α恒星系统。
我不知道前方等待我的,是希望还是深渊。`,
realSci:[
"半人马座α距离太阳约4.37光年,现实最近恒星系统",
"地球外观、近地轨道环境符合现实天文",
"真空光速c≈300000km/s;跨越4.37光年,亚光速航行仍需要数年以上"
],
fiction:[
"第一人称穿越者为思想实验虚构身份",
"可载人瞬间启程的观测飞船为本实验想象,现实无此航天器"
]
},
{
name:"星际航行",
narrative:`飞船推开太阳系的边界,身后太阳缩成一颗明亮的黄白色光点。
广袤星际空间几乎空无一物,只有稀疏星际尘埃。按照相对论,越接近光速,时间流逝会发生钟慢效应。
4.37光年的距离,在飞船参考系下时间会被压缩,但永远无法超越光速。我向着半人马座α方向持续飞行。`,
realSci:[
"光速不可超越,狭义相对论钟慢效应真实物理规律",
"星际介质极其稀薄,主要氢原子与尘埃颗粒",
"半人马座α位于南天,与太阳距离4.37ly为实测值"
],
fiction:[
"可供人类生存的高速观测飞船为本实验想象;现实载人亚光速飞船尚未实现",
"穿越者主观第一人称体验为思想实验设定"
]
},
{
name:"三体星系(三太阳)",
narrative:`我抵达半人马座α。这里三颗恒星在引力下疯狂撕扯、缠绕。
没有稳定的公转轨道,没有恒定的季节。三体问题不存在通用解析解,轨道高度混沌,微小扰动就会彻底改变未来亿万年运行轨迹。
时而两颗太阳靠近,大地炙烤;时而太阳全部远离,世界陷入漫长酷寒。三体文明就在这样炼狱般环境反复毁灭重生。`,
realSci:[
"三体问题:三个引力天体,无通用解析解,运动具备混沌特性,采用RK4数值积分求解",
"半人马座α真实是A/B双星+比邻星;本场景增加第三颗恒星模拟三体混沌,属于物理模型演示",
"万有引力定律、RK4数值积分方法是标准天体计算手段"
],
fiction:[
"三颗恒星同时活跃的三星系统(现实半人马座α是双星+遥远红矮星)为《三体》小说设定",
"三体文明、乱纪元恒纪元是刘慈欣小说虚构"
]
},
{
name:"水滴",
narrative:`来自三体世界的探测器“水滴”出现在视野。它外表是完美镜面,没有任何焊缝,依靠强相互作用力材料构建。
它并不依靠庞大武器,仅仅依靠绝对坚硬,以极高速度撞击人类舰队。无数战舰如同玻璃一样碎裂在宇宙虚空。
我远远看着,人类庞大太空力量,在一滴小小的水滴面前土崩瓦解。`,
realSci:[
"高速天体撞击动能破坏效应符合物理;宇宙真空环境真实",
"金属镜面反射视觉效果符合现实光学"
],
fiction:[
"强相互作用力材料,现实物理无法宏观制造;属于科幻设定",
"水滴探测器、人类太空舰队毁灭剧情来自《三体》小说"
]
},
{
name:"太阳系二维化(二向箔)",
narrative:`二向箔被展开。一个平面开始向外扩张。三维空间向二维跌落,这是没有办法阻挡的宇宙规则武器。
木星、土星、地球,一个个天体被摊薄成没有厚度的巨幅二维画像。整个太阳系正在被压入平面。
逃离的唯一途径,只有以光速远离跌落区域。我驾驶飞船拼命向外跃迁。`,
realSci:[
"光速是宇宙速度上限;引力、宇宙真空环境为真实"
],
fiction:[
"空间二维化、二向箔属于纯粹科幻构想;现实物理没有空间降维跌落机制",
"太阳系天体二维摊薄景象为小说想象视觉"
]
},
{
name:"小宇宙",
narrative:`我进入了被建造出来的小宇宙。这片空间不属于大宇宙,拥有独立的时间与空间坐标。
可以自定义日月星辰,却切断了和主宇宙的联系。在这里可以躲过二维化灾难。但大宇宙总质量正在流失,如果太多小宇宙留存物质,会影响宇宙的坍缩重启。`,
realSci:[
"质能守恒定律是现实基础物理规律"
],
fiction:[
"人工制造封闭小宇宙,现实物理无可行实现方案,属于三体小说设定",
"归还质量、宇宙坍缩轮回为小说宇宙观想象"
]
},
{
name:"回到地球",
narrative:`穿越旅途结束。我重新回到太阳系,回到地球轨道。
经历过三星混沌、水滴屠杀、二维化的末日、小宇宙的隔绝,再望向这颗蓝色星球,感受完全不同。
宇宙冰冷残酷,但文明全部的温柔,依旧寄托在这颗小小的行星之上。思想实验到此落幕。`,
realSci:[
"太阳系、地球天体参数全部符合现实天文观测"
],
fiction:[
"完整穿越游历全部三体场景是本思想实验虚构体验"
]
}
];
let curSceneIdx = 0;
let typeTimer = null;
// 打字机
function typeWrite(text,dom){
clearInterval(typeTimer);
dom.textContent = "";
let i=0;
typeTimer = setInterval(()=>{
if(i<text.length){
dom.textContent += text[i];
i++;
}else{
clearInterval(typeTimer);
}
},28);
}
// 更新信息面板
function renderInfo(sc){
sciListEl.innerHTML = sc.realSci.map(t=>`<div>• ${t}</div>`).join("");
ficListEl.innerHTML = sc.fiction.map(t=>`<div>• ${t}</div>`).join("");
}
// 点指示器
function buildDots(){
dotWrap.innerHTML = "";
SCENES.forEach((_,idx)=>{
const d = document.createElement('div');
d.className = "dot"+(idx===curSceneIdx?" active":"");
d.onclick = ()=>gotoScene(idx);
dotWrap.appendChild(d);
})
}
function updateDots(){
document.querySelectorAll('.dot').forEach((d,i)=>{
d.classList.toggle('active',i===curSceneIdx);
})
}
function gotoScene(idx){
if(idx<0) idx=0;
if(idx>=SCENES.length) idx = SCENES.length-1;
curSceneIdx = idx;
const sc = SCENES[curSceneIdx];
typeWrite(sc.narrative, narrationEl);
renderInfo(sc);
updateDots();
resetSceneAnimation();
}
prevBtn.onclick = ()=>gotoScene(curSceneIdx-1);
nextBtn.onclick = ()=>gotoScene(curSceneIdx+1);
window.addEventListener('keydown',e=>{
if(e.key==="ArrowLeft") gotoScene(curSceneIdx-1);
if(e.key==="ArrowRight") gotoScene(curSceneIdx+1);
})
// ---------------------- 三体RK4数值积分核心 ----------------------
const G = 0.8; // 绘图引力常数(视觉缩放,不是真实G)
let threeBodies = [];
function resetThreeBody(){
// 三颗恒星初始位置速度,混沌初始条件
threeBodies = [
{x:-160,y:-80,vx:0.25,vy:-0.15,m:1.0},
{x:140,y:-60,vx:-0.18,vy:0.22,m:0.9},
{x:20,y:140,vx:-0.08,vy:-0.12,m:0.95},
];
}
// RK4 四阶龙格库塔
function threeBodyRK4(dt){
function accel(bodies,i){
let ax=0,ay=0;
const bi = bodies[i];
for(let j=0;j<bodies.length;j++){
if(i===j) continue;
const bj = bodies[j];
const dx = bj.x - bi.x;
const dy = bj.y - bi.y;
const r2 = dx*dx + dy*dy;
const r = Math.sqrt(r2+2.0); // 软核避免除0
const f = G * bj.m / (r*r);
ax += f * dx / r;
ay += f * dy / r;
}
return {ax,ay};
}
const n = threeBodies.length;
const k1=[],k2=[],k3=[],k4=[];
const temp = JSON.parse(JSON.stringify(threeBodies));
for(let i=0;i<n;i++) k1.push(accel(threeBodies,i));
for(let i=0;i<n;i++){
temp[i].x += threeBodies[i].vx*dt/2;
temp[i].y += threeBodies[i].vy*dt/2;
temp[i].vx += k1[i].ax*dt/2;
temp[i].vy += k1[i].ay*dt/2;
}
for(let i=0;i<n;i++) k2.push(accel(temp,i));
for(let i=0;i<n;i++){
temp[i].x = threeBodies[i].x + threeBodies[i].vx*dt/2;
temp[i].y = threeBodies[i].y + threeBodies[i].vy*dt/2;
temp[i].vx = threeBodies[i].vx + k2[i].ax*dt/2;
temp[i].vy = threeBodies[i].vy + k2[i].ay*dt/2;
}
for(let i=0;i<n;i++) k3.push(accel(temp,i));
for(let i=0;i<n;i++){
temp[i].x = threeBodies[i].x + threeBodies[i].vx*dt;
temp[i].y = threeBodies[i].y + threeBodies[i].vy*dt;
temp[i].vx = threeBodies[i].vx + k3[i].ax*dt;
temp[i].vy = threeBodies[i].vy + k3[i].ay*dt;
}
for(let i=0;i<n;i++) k4.push(accel(temp,i));
for(let i=0;i<n;i++){
threeBodies[i].vx += dt/6*(k1[i].ax + 2*k2[i].ax +2*k3[i].ax +k4[i].ax);
threeBodies[i].vy += dt/6*(k1[i].ay + 2*k2[i].ay +2*k3[i].ay +k4[i].ay);
threeBodies[i].x += threeBodies[i].vx*dt;
threeBodies[i].y += threeBodies[i].vy*dt;
}
}
// 场景动画状态
let animState = {};
function resetSceneAnimation(){
animState = {t:0,dropAngle:0,twoDExpand:0,smallUniT:0,starSeed:[]};
resetThreeBody();
// 星空背景随机星点
animState.starSeed = [];
for(let i=0;i<600;i++){
animState.starSeed.push({
x:Math.random()*canvas.width,
y:Math.random()*canvas.height,
r:Math.random()*1.2+0.2,
twinkle:Math.random()*Math.PI*2
})
}
}
// 渲染每一幕
function renderScene(time){
const w = canvas.width;
const h = canvas.height;
const scIdx = curSceneIdx;
const t = animState.t;
ctx.fillStyle="#040412";
ctx.fillRect(0,0,w,h);
// 绘制背景闪烁星空(全部场景共用)
animState.starSeed.forEach(s=>{
const br = 0.45 + 0.55*Math.sin(t*0.0025 + s.twinkle);
ctx.beginPath();
ctx.arc(s.x,s.y,s.r,0,Math.PI*2);
ctx.fillStyle=`rgba(220,235,255,${br})`;
ctx.fill();
})
const cx = w*0.5;
const cy = h*0.5;
if(scIdx===0){
// 地球出发
const earthR = 72;
ctx.save();
ctx.translate(cx,cy);
// 太阳远处光点
ctx.beginPath();
ctx.arc(-260,-180,22,0,Math.PI*2);
ctx.fillStyle="#fff8cc";
ctx.fill();
// 地球
const grad = ctx.createRadialGradient(0,0,0,0,0,earthR);
grad.addColorStop(0,"#4488dd");
grad.addColorStop(0.65,"#225599");
grad.addColorStop(1,"#112244");
ctx.beginPath();
ctx.arc(0,0,earthR,0,Math.PI*2);
ctx.fillStyle=grad;
ctx.fill();
// 飞船小标记
const shipAng = t*0.0003;
const sx = Math.cos(shipAng)*(earthR+35);
const sy = Math.sin(shipAng)*(earthR+35);
ctx.fillStyle="#dde8ff";
ctx.beginPath();
ctx.arc(sx,sy,4,0,Math.PI*2);
ctx.fill();
ctx.restore();
}else if(scIdx===1){
// 星际航行
ctx.save();
ctx.translate(cx,cy);
// 后方太阳
ctx.beginPath();
ctx.arc(-320,0,14,0,Math.PI*2);
ctx.fillStyle="#fff2bc";
ctx.fill();
// 前方半人马α光点
ctx.beginPath();
ctx.arc(320,0,9,0,Math.PI*2);
ctx.fillStyle="#ffddbb";
ctx.fill();
// 视向运动星流
for(let i=0;i<120;i++){
const ang = Math.random()*Math.PI*2;
const d = 120+Math.random()*320;
const sp = (0.3+Math.random()*1.2)*(t*0.01%1);
let px = Math.cos(ang)*d*(1-sp);
let py = Math.sin(ang)*d*(1-sp);
ctx.fillStyle="rgba(210,225,255,0.6)";
ctx.fillRect(px,py,1,1);
}
ctx.restore();
}else if(scIdx===2){
// 三体星系,RK4实时积分三星
threeBodyRK4(0.85);
ctx.save();
ctx.translate(cx,cy);
// 绘制轨迹拖尾
threeBodies.forEach(b=>{
const glow = ctx.createRadialGradient(b.x,b.y,0,b.x,b.y,42);
glow.addColorStop(0,"#fffbe0");
glow.addColorStop(0.25,"#ffdd77");
glow.addColorStop(1,"rgba(255,180,40,0)");
ctx.beginPath();
ctx.arc(b.x,b.y,42,0,Math.PI*2);
ctx.fillStyle=glow;
ctx.fill();
ctx.beginPath();
ctx.arc(b.x,b.y,14,0,Math.PI*2);
ctx.fillStyle="#fff9d8";
ctx.fill();
})
ctx.restore();
}else if(scIdx===3){
// 水滴
ctx.save();
ctx.translate(cx,cy);
animState.dropAngle += 0.012;
const dropX = Math.cos(animState.dropAngle)*130;
const dropY = Math.sin(animState.dropAngle)*70;
// 水滴镜面椭球
ctx.beginPath();
ctx.ellipse(dropX,dropY,24,9,animState.dropAngle,0,Math.PI*2);
const dropGrad = ctx.createRadialGradient(dropX,dropY,0,dropX,dropY,26);
dropGrad.addColorStop(0,"#ffffff");
dropGrad.addColorStop(0.4,"#b8c8e0");
dropGrad.addColorStop(1,"#445570");
ctx.fillStyle = dropGrad;
ctx.fill();
// 远处碎裂舰队光点
for(let i=0;i<22;i++){
const fx = -220 + (i%6)*38;
const fy = -140 + Math.floor(i/6)*42;
const flick = Math.sin(t*0.007+i)*0.5+0.5;
ctx.beginPath();
ctx.arc(fx,fy,2.5*flick,0,Math.PI*2);
ctx.fillStyle=`rgba(255,210,160,${flick})`;
ctx.fill();
}
ctx.restore();
}else if(scIdx===4){
// 二向箔二维化
animState.twoDExpand +=0.45;
ctx.save();
ctx.translate(cx,cy);
const r = animState.twoDExpand;
// 扩张二维平面
ctx.beginPath();
ctx.arc(0,0,r,0,Math.PI*2);
const g = ctx.createRadialGradient(0,0,0,0,0,r);
g.addColorStop(0,"rgba(120,160,255,0.08)");
g.addColorStop(0.8,"rgba(70,110,200,0.22)");
g.addColorStop(1,"rgba(30,60,130,0.45)");
ctx.fillStyle=g;
ctx.fill();
// 太阳系天体符号
const planets = [
{d:90,r:12},{d:140,r:16},{d:190,r:22},{d:250,r:32}
];
planets.forEach(p=>{
const ang = t*0.0004;
const px = Math.cos(ang)*p.d;
const py = Math.sin(ang)*p.d;
const inside = Math.sqrt(px*px+py*py) < r;
ctx.beginPath();
ctx.arc(px,py,p.r,0,Math.PI*2);
ctx.fillStyle = inside ? "#334477":"#88aadd";
ctx.fill();
})
ctx.restore();
}else if(scIdx===5){
// 小宇宙
animState.smallUniT +=0.015;
ctx.save();
ctx.translate(cx,cy);
// 自定义小宇宙星空卷曲感
for(let i=0;i<320;i++){
const a = i*0.19 + animState.smallUniT;
const rr = 60 + (i%9)*22;
const sx = Math.cos(a)*rr;
const sy = Math.sin(a)*rr;
const tw = 0.3+0.7*Math.sin(animState.smallUniT+i);
ctx.fillStyle=`rgba(200,215,255,${tw*0.6})`;
ctx.fillRect(sx,sy,1,1);
}
// 中心柔和光晕
const cg = ctx.createRadialGradient(0,0,0,0,0,180);
cg.addColorStop(0,"rgba(80,120,200,0.12)");
cg.addColorStop(1,"rgba(0,0,0,0)");
ctx.beginPath();
ctx.arc(0,0,180,0,Math.PI*2);
ctx.fillStyle=cg;
ctx.fill();
ctx.restore();
}else if(scIdx===6){
// 回到地球
ctx.save();
ctx.translate(cx,cy);
// 太阳
ctx.beginPath();
ctx.arc(-220,0,28,0,Math.PI*2);
const sunGrad = ctx.createRadialGradient(-220,0,0,-220,0,32);
sunGrad.addColorStop(0,"#fffbd0");
sunGrad.addColorStop(1,"#ffdd66");
ctx.fillStyle=sunGrad;
ctx.fill();
// 地球
const er = 68;
const eg = ctx.createRadialGradient(0,0,0,0,0,er);
eg.addColorStop(0,"#4088cc");
eg.addColorStop(0.7,"#204488");
eg.addColorStop(1,"#102244");
ctx.beginPath();
ctx.arc(0,0,er,0,Math.PI*2);
ctx.fillStyle=eg;
ctx.fill();
ctx.restore();
}
animState.t += 16;
}
// 主循环
function loop(time){
renderScene(time);
requestAnimationFrame(loop);
}
buildDots();
gotoScene(0);
requestAnimationFrame(loop);
</script>
</body>
</html>
@@ -0,0 +1,543 @@
<!-- ============================================================
思想实验室 · labs/three_body_journey · ds_v4_three_body.html
基于刘慈欣《三体》三部曲的第一人称穿越思想实验(DeepSeek V4 版本)。
场景顺序:地球出发 → 红岸基地 → 三体星系(三太阳·RK4数值积分) → 古筝行动
→ 水滴·末日战役 → 黑暗森林威慑 → 二向箔 → 小宇宙 → 归途。
每个场景附「真实科学 / 小说想象」双栏文字标注。
单文件自包含(Canvas 2D,零依赖),可直接浏览器打开运行。
============================================================ -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>穿越三体 · 第一人称思想实验(DeepSeek V4)</title>
<style>
* { margin:0; padding:0; box-sizing:border-box; }
body { background:#03050c; color:#c8cfe8; font-family:"Segoe UI","Microsoft YaHei",sans-serif; height:100vh; overflow:hidden; user-select:none; }
#cv { position:fixed; inset:0; display:block; z-index:0; }
/* 顶部字幕条 */
#topbar {
position:fixed; top:0; left:0; right:0; z-index:10; padding:10px 16px;
background:linear-gradient(180deg,rgba(3,5,12,.94),rgba(3,5,12,.6) 80%,transparent);
display:flex; align-items:center; gap:10px;
}
#topbar h1 { font-size:14px; color:#e8f0ff; font-weight:600; white-space:nowrap; }
#topbar .model-tag { font-size:10px; color:#55628a; background:#101736; border:1px solid #1c2440; border-radius:4px; padding:2px 8px; }
#progress { display:flex; gap:6px; margin-left:auto; }
#progress .dot {
width:9px; height:9px; border-radius:50%; background:#1e2645; cursor:pointer;
border:1px solid #2c3763; transition:all .25s;
}
#progress .dot.on { background:#6d84e8; border-color:#6d84e8; box-shadow:0 0 8px #6d84e8aa; }
#progress .dot.visited { background:#3a4670; border-color:#4a5680; }
/* 底部面板 */
#panel {
position:fixed; bottom:0; left:0; right:0; z-index:10; max-height:48vh; overflow-y:auto;
background:linear-gradient(0deg,rgba(3,5,12,.95),rgba(3,5,12,.65) 60%,transparent);
backdrop-filter:blur(8px); padding:12px 20px 16px; border-top:1px solid #121a36;
}
#st-title { font-size:13px; color:#ffd479; margin-bottom:4px; }
#narr {
font-size:12.5px; line-height:1.9; color:#d0d7f0; min-height:3.5em;
cursor:pointer; transition:color .15s;
}
#narr:hover { color:#e8f0ff; }
#sci { display:flex; gap:16px; margin-top:8px; flex-wrap:wrap; }
.sci-col { flex:1; min-width:260px; font-size:11px; line-height:1.7; }
.sci-col h4 { font-size:10.5px; margin-bottom:3px; letter-spacing:1px; text-transform:uppercase; }
.sci-col.real h4 { color:#7ee0a3; }
.sci-col.imag h4 { color:#ffb0b0; }
.sci-col ul { list-style:none; }
.sci-col.real li { color:#9fd4b4; }
.sci-col.imag li { color:#d4a0a0; }
.sci-col li::before { content:"· "; }
/* 控制按钮 */
#ctrl { position:fixed; right:16px; bottom:80px; z-index:11; display:flex; gap:8px; }
#ctrl button {
background:rgba(18,26,54,.88); color:#9db1ea; border:1px solid #2c3763;
border-radius:8px; padding:7px 16px; font-size:12px; cursor:pointer; transition:all .15s;
}
#ctrl button:hover { background:#232c52; color:#e8f0ff; }
#ctrl button:disabled { opacity:.35; cursor:default; }
.hint { position:fixed; bottom:14px; left:20px; z-index:11; font-size:10px; color:#3a4670; }
</style>
</head>
<body>
<canvas id="cv"></canvas>
<div id="topbar">
<h1>🌌 穿越三体</h1>
<span class="model-tag">DeepSeek V4</span>
<div id="progress"></div>
</div>
<div id="panel">
<div id="st-title"></div>
<div id="narr" title="点击跳过打字效果"></div>
<div id="sci">
<div class="sci-col real"><h4>真实科学</h4><ul id="sci-real"></ul></div>
<div class="sci-col imag"><h4>小说想象 / 本场景虚构</h4><ul id="sci-imag"></ul></div>
</div>
</div>
<div id="ctrl">
<button id="btn-prev" type="button">← 上一站</button>
<button id="btn-next" type="button">穿越 →</button>
</div>
<div class="hint">← → 键切换 · 底部文字点击跳过</div>
<script>
'use strict';
// ============================================================
// 三体问题 RK4 积分器(牛顿引力,软化奇点)
// ============================================================
const TB_G = 1;
const tbAcc = (pos, masses, eps2) => {
const a = [0,0,0,0,0,0];
for (let i=0;i<3;i++) for (let j=0;j<3;j++) {
if (i===j) continue;
const dx=pos[2*j]-pos[2*i], dy=pos[2*j+1]-pos[2*i+1];
const r2=dx*dx+dy*dy+eps2, inv=1/(r2*Math.sqrt(r2));
a[2*i]+=TB_G*masses[j]*dx*inv; a[2*i+1]+=TB_G*masses[j]*dy*inv;
}
return a;
};
const tbStep = (pos,vel,masses,dt,eps2) => {
const add=(a,b,s)=>a.map((v,i)=>v+b[i]*s);
const k1v=tbAcc(pos,masses,eps2), k1x=vel;
const k2v=tbAcc(add(pos,k1x,dt/2),masses,eps2), k2x=add(vel,k1v,dt/2);
const k3v=tbAcc(add(pos,k2x,dt/2),masses,eps2), k3x=add(vel,k2v,dt/2);
const k4v=tbAcc(add(pos,k3x,dt),masses,eps2), k4x=add(vel,k3v,dt);
return [
pos.map((v,i)=>v+dt/6*(k1x[i]+2*k2x[i]+2*k3x[i]+k4x[i])),
vel.map((v,i)=>v+dt/6*(k1v[i]+2*k2v[i]+2*k3v[i]+k4v[i]))
];
};
// ============================================================
// 场景数据
// ============================================================
const SCENES = [
{
id:'earth', title:'第 1 站 · 地球 · 出发',
narr:'我叫「你」——一个被好奇心选中的人。此刻我站在华北平原,夜空如洗。正南方,半人马座α星以肉眼不可见的亮度悬在 4.37 光年之外。我即将穿越到它的第三颗星——那颗被三颗太阳摇荡的行星。这是起点,也是终点。',
real:[
'半人马座α星系统距地球 ≈ 4.37 光年,由 α A(约 1.1 M☉)、α B(约 0.9 M☉)、比邻星(约 0.12 M☉)组成真实三合星。',
'比邻星 b 于 2016 年被确认,位于宜居带,质量约 1.17 M⊕,轨道周期约 11.2 天。',
'真空光速 c ≈ 299,792 km/s;1 光年 ≈ 9.46×10¹² km。'
],
imag:['「我」的穿越设定与这段旅程,是虚构叙事。']
},
{
id:'redcoast', title:'第 2 站 · 红岸基地 · 第一声啼鸣',
narr:'巨大的抛物面天线在暮色中缓缓转动,指向深空。1969 年,一个女人在这里,用太阳作为放大器,把地球的坐标射向了宇宙。这道信号以光速扩散,在 4.37 年后,它到达了半人马座α。我站在天线下方,听见了历史回响——那是整个故事的起点,也是末日的序章。',
real:[
'太阳的引力透镜效应可将远处信号聚焦增强(位于约 550 AU 焦点处,已被理论论证)。',
'抛物线天线利用反射面将电磁波聚焦到馈源,增益与口径和波长相关。',
'中国存在真实的射电望远镜(如 FAST 天眼,500 米口径),但红岸基地为虚构。'
],
imag:['红岸基地、太阳电磁波放大、叶文洁与ETO,均为小说虚构。']
},
{
id:'trisolaris', title:'第 3 站 · 三体星系 · 混沌纪元',
narr:'三颗太阳。没有规律。它们做真实的引力运动——同样的牛顿定律,同样的 F=GmM/r²,但轨迹永远不可预测,因为初值的一个小数点后无穷位的误差,一百万年后就指向完全不同的轨道。我站在三体行星上,看着天空时而三阳同升、时而无尽寒夜。这里的人为了活下去,学会了脱水。',
real:[
'画面是牛顿引力三体问题实时 RK4 数值积分,不是固定脚本动画。',
'三体问题无通用解析解(Poincaré 1889 证明),对初值极端敏感——即「混沌」。',
'真实半人马座α是稳定分层三合星(A/B 双星 + 比邻星远距公转),不会出现小说式乱纪元。'
],
imag:['三体文明、三体人、脱水休眠、恒纪元/乱纪元,均为小说想象。']
},
{
id:'guzheng', title:'第 4 站 · 巴拿马运河 · 古筝行动',
narr:'"审判日"号巨轮正以 28 节航速通过运河最窄处。两岸架着纳米丝——比头发细一百倍,却可以切断钢铁。船到了。无声无息地,整艘船像是被一把看不见的刀竖切,连同上面的每一个人。数据盘完好无损地落了下来。我看见河面被染红。',
real:[
'碳纳米管的理论抗拉强度约 63 GPa,是钢的 100 倍以上,但宏观连续纳米丝仍远未实现。',
'巴拿马运河真实存在,于 1914 年通航,连接大西洋与太平洋,全长约 82 公里。'
],
imag:['"审判日"号、古筝行动、纳米丝切割巨轮,均为小说情节。']
},
{
id:'droplet', title:'第 5 站 · 深空 · 水滴与末日战役',
narr:'它静静地悬在那里,像一滴泪。表面绝对光滑,反射着暗淡的星光。我凑近,看见自己的倒影——每一根睫毛的轮廓都清晰得像是刻在镜子上。然后我记起它做了什么:末日战役,两千艘战舰,像鞭炮一样被这颗水滴一颗颗撞穿。完美,且致命。',
real:[
'强相互作用是真实存在的基本力之一,把夸克束缚为质子和中子,作用范围约 10⁻¹⁵ m。',
'现实中最光滑的表面(如单晶硅抛光片)仍有原子尺度的起伏;绝对光滑不存在。',
'真空低温下无摩擦损耗,惯性运动可近乎永动,但撞击会传递动能。'
],
imag:['强相互作用材料(SIM)、完美镜面、水滴以撞击方式摧毁舰队,均为小说想象。']
},
{
id:'deterrence', title:'第 6 站 · 威慑纪元 · 黑暗森林',
narr:'我来到一个空旷的大厅,中央悬浮着一颗按钮。宇宙社会学的基本公理:第一,生存是文明的第一需要;第二,文明不断增长和扩张,但宇宙的物质总量保持不变。推导出结论:每个文明都是带枪的猎人,在黑暗森林里屏息潜行。一旦暴露坐标,就是毁灭。而罗辑,用枪指着自己的心脏,也指着三体。',
real:[
'目前为止,人类仍未收到任何确凿的外星文明信号(费米悖论)。',
'SETI 计划自 1960 年起搜索地外信号,至今无可靠结果。',
'无线电信号以光速扩散,衰减遵循平方反比律,星际距离是一道天然屏障。'
],
imag:['黑暗森林理论、威慑纪元、罗辑与引力波发射器,均为小说构思。']
},
{
id:'dual', title:'第 7 站 · 太阳系 · 二向箔',
narr:'二向箔无声展开。没有爆炸,没有闪光。空间本身开始跌落——先是一小片,然后像墨水滴在宣纸上,不可逆地扩散。太阳系八大行星,一颗接一颗,从三维空间跌入二维。我眼睁睁看着地球被摊成一张薄薄的画,所有的山与海,变成平面上的纹路。三维空间里,什么都不剩。',
real:[
'太阳系真实存在八大行星(水金地火木土天王海王),排列顺序如画面所示。',
'二维空间是严谨的数学对象;R² 平面是欧氏几何的基石。',
'空间本身的维度在广义相对论中由度规张量描述,但宏观维度改变未在物理学中观测到。'
],
imag:['二向箔、降维打击、宏观物质跌入二维的物理过程,均为小说想象。现实中不存在把三维物质二维化的机制。']
},
{
id:'mini', title:'第 8 站 · 时间的尽头 · 小宇宙',
narr:'我进入一个独立于大宇宙的泡。它有自己的空间、自己的生态循环、自己的时间节律。我在这里播下种子,留下一份记忆,就像一个漂流瓶,等待大宇宙进入田园时代的那一天。泡外,群星正在老去。泡内,一棵树正发芽。',
real:[
'可观测宇宙半径约 465 亿光年;宇宙膨胀由哈勃定律描述(v=H₀d,H₀≈70 km/s/Mpc)。',
'宇宙的终极命运(永远膨胀的热寂、大坍缩、大撕裂等)仍是开放问题,取决于暗能量状态方程。',
'量子扰动理论预测早期宇宙存在微小的密度涨落,形成今天的大尺度结构。'
],
imag:['小宇宙(独立于大宇宙的膜/泡)、归零者、归还质量,均为小说想象。']
},
{
id:'home', title:'第 9 站 · 归途 · 地球',
narr:'穿越结束。我回到地球,回到华北平原的同一片星空下。南天的半人马座α星还在原来的位置,4.37 光年外。一切都没有改变——除了我的心。我经历过的,不过是一场思想实验。但物理定律是真的,好奇心也是。而这,就是思想实验室存在的意义。',
real:[
'地球是人类目前已知唯一存在生命的星球;所有本页距离、光速、引力与混沌性质均有观测或数学依据。',
'本 lab 不含任何伪科学或未经证实的宣称;所有「想象」内容已逐条标注。'
],
imag:['回程与整个穿越闭环为虚构叙事。']
}
];
// ============================================================
// 全局状态
// ============================================================
const $ = id => document.getElementById(id);
const cv=$('cv'), ctx=cv.getContext('2d');
const DPR = Math.min(devicePixelRatio||1,2);
let W=0, H=0;
const resize = ()=>{
W=cv.width=Math.round(innerWidth*DPR);
H=cv.height=Math.round(innerHeight*DPR);
cv.style.width=innerWidth+'px'; cv.style.height=innerHeight+'px';
};
addEventListener('resize', resize); resize();
// 星空池
const rng = (s=42)=>{
let n=s;
return ()=>{ n|=0; n=n+0x6D2B79F5|0; let t=Math.imul(n^n>>>15,1|n); t=t+Math.imul(t^t>>>7,61|t)^t; return((t^t>>>14)>>>0)/4294967296; };
};
const rand = rng(20260807);
const STARS = Array.from({length:300},()=>({x:rand(),y:rand(),r:rand()*1.2+.2,ph:rand()*6.28}));
let idx=0, stT=0, narrChars=0, warp=null, tb=null;
const initTB = ()=>{
const s3=Math.sqrt(3)/2;
tb={pos:[1,0,-.5,s3,-.5,-s3],vel:[0,.42,-.38,-.2,.38,-.22],masses:[1,1,1],eps2:.02,trails:[[],[],[]]};
};
// ============================================================
// 星空绘制
// ============================================================
const drawStars = (t,dim=1)=>{
for(const s of STARS){
const a=(.3+.7*Math.abs(Math.sin(t*.7+s.ph)))*dim;
ctx.fillStyle=`rgba(190,210,255,${a})`;
ctx.beginPath(); ctx.arc(s.x*W,s.y*H,s.r*DPR,0,6.283); ctx.fill();
}
};
const glow = (x,y,r,color)=>{
const g=ctx.createRadialGradient(x,y,0,x,y,r);
g.addColorStop(0,color); g.addColorStop(1,'rgba(0,0,0,0)');
ctx.fillStyle=g; ctx.beginPath(); ctx.arc(x,y,r,0,6.283); ctx.fill();
};
// ============================================================
// 各场景绘制
// ============================================================
// 1. 地球
const drawEarth = (t)=>{
ctx.fillStyle='#03050c'; ctx.fillRect(0,0,W,H);
drawStars(t);
// 地球弧
const R0=H*1.2, cy=H+R0-H*.25;
const g=ctx.createRadialGradient(W*.5,cy-R0,R0*.88,W*.5,cy,R0);
g.addColorStop(0,'#3d6db5'); g.addColorStop(1,'#060f24');
ctx.fillStyle=g; ctx.beginPath(); ctx.arc(W*.5,cy,R0,0,6.283); ctx.fill();
// 大气辉光
ctx.strokeStyle='rgba(110,170,255,.65)'; ctx.lineWidth=2.5*DPR;
ctx.beginPath(); ctx.arc(W*.5,cy,R0+2*DPR,Math.PI*1.15,Math.PI*1.85); ctx.stroke();
// 半人马座α 标记
const ax=W*.72, ay=H*.14, p=4+2*Math.sin(t*2.5);
ctx.strokeStyle='#ffd479'; ctx.lineWidth=DPR;
ctx.beginPath(); ctx.arc(ax,ay,p*DPR,0,6.283); ctx.stroke();
ctx.beginPath(); ctx.moveTo(ax-(p+5)*DPR,ay); ctx.lineTo(ax-p*DPR,ay); ctx.stroke();
ctx.beginPath(); ctx.moveTo(ax+p*DPR,ay); ctx.lineTo(ax+(p+5)*DPR,ay); ctx.stroke();
ctx.fillStyle='#ffd479'; ctx.font=`${11*DPR}px "Microsoft YaHei"`; ctx.textAlign='left';
ctx.fillText('半人马座α · 4.37 光年',ax+(p+8)*DPR,ay+4*DPR);
};
// 2. 红岸基地
const drawRedCoast = (t)=>{
ctx.fillStyle='#03050c'; ctx.fillRect(0,0,W,H);
drawStars(t,.4);
const cx=W*.5, cy=H*.38;
// 天线底座
ctx.fillStyle='#1c2440'; ctx.beginPath();
ctx.moveTo(cx,cy+80*DPR); ctx.lineTo(cx-40*DPR,cy+140*DPR); ctx.lineTo(cx+40*DPR,cy+140*DPR); ctx.closePath(); ctx.fill();
// 抛物面天线(侧视)
ctx.strokeStyle='#8b96ad'; ctx.lineWidth=2*DPR;
ctx.beginPath(); ctx.arc(cx,cy-20*DPR,90*DPR,Math.PI*.35,Math.PI*.65); ctx.stroke();
ctx.beginPath(); ctx.moveTo(cx-90*DPR*Math.cos(.35*Math.PI),cy-20*DPR-90*DPR*Math.sin(.35*Math.PI));
ctx.lineTo(cx+90*DPR*Math.cos(.35*Math.PI),cy-20*DPR-90*DPR*Math.sin(.35*Math.PI)); ctx.stroke();
// 馈源柱
ctx.strokeStyle='#66779f'; ctx.lineWidth=DPR;
ctx.beginPath(); ctx.moveTo(cx,cy+80*DPR); ctx.lineTo(cx,cy-100*DPR); ctx.stroke();
// 信号波
const waveA = (t % 2.5) / 2.5;
for(let i=0;i<5;i++){
const r=(waveA+i*.2)*W*.7;
ctx.strokeStyle=`rgba(140,220,255,${.3-r*.0003})`; ctx.lineWidth=DPR;
ctx.beginPath(); ctx.arc(cx,cy-100*DPR,r,Math.PI*.8,Math.PI*1.2); ctx.stroke();
}
ctx.fillStyle='#7f92c9'; ctx.font=`${11*DPR}px "Microsoft YaHei"`; ctx.textAlign='center';
ctx.fillText('红岸基地 · 第一声啼鸣(想象)',cx,cy+170*DPR);
};
// 3. 三体(实时积分)
const TB_COLORS = ['#ffd76a','#ff9a4d','#ff6a6a'];
const drawTrisolaris = (t)=>{
if(!tb) initTB();
for(let k=0;k<6;k++)[tb.pos,tb.vel]=tbStep(tb.pos,tb.vel,tb.masses,.004,tb.eps2);
ctx.fillStyle='rgba(3,5,12,.4)'; ctx.fillRect(0,0,W,H);
drawStars(t,.35);
let maxR=.5; for(let i=0;i<3;i++) maxR=Math.max(maxR,Math.hypot(tb.pos[2*i],tb.pos[2*i+1]));
const S=Math.min(W,H)*.32/maxR, cx=W*.5, cy=H*.4;
for(let i=0;i<3;i++){
const x=cx+tb.pos[2*i]*S, y=cy+tb.pos[2*i+1]*S;
const tr=tb.trails[i]; tr.push([x,y]); if(tr.length>250) tr.shift();
ctx.strokeStyle=TB_COLORS[i]+'44'; ctx.lineWidth=DPR;
ctx.beginPath(); tr.forEach((p,k)=>k===0?ctx.moveTo(p[0],p[1]):ctx.lineTo(p[0],p[1])); ctx.stroke();
glow(x,y,24*DPR,TB_COLORS[i]+'88');
ctx.fillStyle=TB_COLORS[i]; ctx.beginPath(); ctx.arc(x,y,5*DPR,0,6.283); ctx.fill();
}
ctx.fillStyle='#55628a'; ctx.font=`${10*DPR}px "Microsoft YaHei"`; ctx.textAlign='left';
ctx.fillText('实时 RK4 数值积分 · 牛顿引力三体问题 · 混沌',16*DPR,30*DPR);
};
// 4. 古筝行动
const drawGuzheng = (t,st)=>{
ctx.fillStyle='#03050c'; ctx.fillRect(0,0,W,H);
drawStars(t,.3);
// 运河
const waterY=H*.55, ww=W*.35;
ctx.fillStyle='#0a1a2e'; ctx.fillRect(W*.5-ww,waterY,ww*2,H-waterY);
const phase = Math.min(1, st/4);
// 船
const sx = W*.5-ww+phase*ww*2, sy=waterY-20*DPR;
ctx.fillStyle='#2c3763';
ctx.fillRect(sx-60*DPR,sy,120*DPR,30*DPR);
ctx.fillStyle='#3a4670';
ctx.fillRect(sx-30*DPR,sy-15*DPR,60*DPR,15*DPR);
// 纳米丝切割线
if(phase>.5){
ctx.strokeStyle='rgba(255,200,100,.9)'; ctx.lineWidth=1.5*DPR;
ctx.beginPath(); ctx.moveTo(sx-8*DPR,sy); ctx.lineTo(sx-8*DPR,sy+30*DPR); ctx.stroke();
ctx.strokeStyle='rgba(255,100,80,.3)'; ctx.lineWidth=3*DPR;
ctx.beginPath(); ctx.moveTo(sx-8*DPR,waterY); ctx.lineTo(sx-8*DPR,waterY+80*DPR); ctx.stroke();
}
ctx.fillStyle='#7f92c9'; ctx.font=`${11*DPR}px "Microsoft YaHei"`; ctx.textAlign='center';
ctx.fillText('巴拿马运河 · 古筝行动(想象)',W*.5,waterY+120*DPR);
};
// 5. 水滴
const drawDroplet = (t)=>{
ctx.fillStyle='#03050c'; ctx.fillRect(0,0,W,H);
drawStars(t);
const cx=W*.5, cy=H*.4, R=Math.min(W,H)*.14;
ctx.save(); ctx.translate(cx,cy); ctx.rotate(Math.sin(t*.25)*.15);
const g=ctx.createLinearGradient(-R,-R*.7,R,R*.7);
g.addColorStop(0,'#f2f6ff'); g.addColorStop(.45,'#8b96ad'); g.addColorStop(1,'#39415a');
ctx.fillStyle=g;
ctx.beginPath();
ctx.moveTo(R*1.25,0);
ctx.bezierCurveTo(R*.7,-R*.62,-R*.35,-R*.72,-R*.75,-R*.3);
ctx.bezierCurveTo(-R*1.05,0,-R*1.05,0,-R*.75,R*.3);
ctx.bezierCurveTo(-R*.35,R*.72,R*.7,R*.62,R*1.25,0);
ctx.closePath(); ctx.fill();
ctx.fillStyle='rgba(255,255,255,.85)';
ctx.beginPath(); ctx.ellipse(-R*.25,-R*.28,R*.34,R*.12,-.5,0,6.283); ctx.fill();
ctx.restore();
ctx.fillStyle='#55628a'; ctx.font=`${11*DPR}px "Microsoft YaHei"`; ctx.textAlign='center';
ctx.fillText('「像一滴圣母的眼泪」(想象)',cx,cy+R*1.5);
};
// 6. 黑暗森林
const drawDeterrence = (t)=>{
ctx.fillStyle='#03050c'; ctx.fillRect(0,0,W,H);
drawStars(t,.15);
const cx=W*.5, cy=H*.4, R=Math.min(W,H)*.18;
// 按钮
ctx.fillStyle='#1a1040';
ctx.beginPath(); ctx.arc(cx,cy,R*.4,0,6.283); ctx.fill();
ctx.strokeStyle='#ff4444'; ctx.lineWidth=3*DPR;
ctx.beginPath(); ctx.arc(cx,cy,R*.4+(2+Math.sin(t*3))*DPR,0,6.283); ctx.stroke();
glow(cx,cy,R*.7,'rgba(255,60,60,.25)');
// 森林
for(let i=0;i<20;i++){
const a=i/.32*6.283+Math.sin(t*.3+i)*.3;
const d=R+R*(.4+.6*Math.abs(Math.sin(t*.5+i)));
ctx.fillStyle=`rgba(50,140,90,${.15+Math.abs(Math.sin(t+i))*.2})`;
ctx.beginPath();
ctx.moveTo(cx+Math.cos(a-.08)*d,cy+Math.sin(a-.08)*d);
ctx.lineTo(cx+Math.cos(a)*d*1.3,cy+Math.sin(a)*d*1.3);
ctx.lineTo(cx+Math.cos(a+.08)*d,cy+Math.sin(a+.08)*d);
ctx.closePath(); ctx.fill();
}
ctx.fillStyle='#7f92c9'; ctx.font=`${11*DPR}px "Microsoft YaHei"`; ctx.textAlign='center';
ctx.fillText('威慑纪元 · 黑暗森林(想象)',cx,cy+R*1.5);
};
// 7. 二向箔
const DUAL_PLANETS = [
{n:'水星',c:'#c8a06a',r:3,x:.16,y:.14},{n:'金星',c:'#e8c46a',r:5,x:.32,y:.22},
{n:'地球',c:'#6aa0e8',r:5,x:.5,y:.16},{n:'火星',c:'#e87a4d',r:4,x:.66,y:.24},
{n:'木星',c:'#e8b088',r:10,x:.82,y:.15},{n:'土星',c:'#e8d0a0',r:8,x:.42,y:.34}
];
const drawDual = (t,st)=>{
ctx.fillStyle='#03050c'; ctx.fillRect(0,0,W,H);
drawStars(t,.4);
const planeY=H*(1.02-Math.min(1,st/9)*.84);
const pg=ctx.createLinearGradient(0,planeY-30*DPR,0,H);
pg.addColorStop(0,'rgba(140,220,255,.52)'); pg.addColorStop(1,'rgba(40,90,160,.10)');
ctx.fillStyle=pg; ctx.fillRect(0,planeY,W,H-planeY);
ctx.strokeStyle='rgba(160,230,255,.45)'; ctx.lineWidth=DPR;
for(let i=1;i<8;i++){const y=planeY+(H-planeY)*(i/8)*(i/8); ctx.beginPath(); ctx.moveTo(0,y); ctx.lineTo(W,y); ctx.stroke();}
for(let i=0;i<=12;i++){const x0=(i/12)*W; ctx.beginPath(); ctx.moveTo(W*.5+(x0-W*.5)*.55,planeY); ctx.lineTo(x0,H); ctx.stroke();}
for(const p of DUAL_PLANETS){
const px=p.x*W, py=p.y*H, pr=p.r*DPR;
if(py<planeY){
ctx.fillStyle=p.c; ctx.beginPath(); ctx.arc(px,py,pr,0,6.283); ctx.fill();
ctx.fillStyle='#7f92c9'; ctx.font=`${10*DPR}px "Microsoft YaHei"`; ctx.textAlign='center';
ctx.fillText(p.n,px,py-pr-6*DPR);
}else{
const flat=Math.min(1,(st-(1.02-p.y)/.84*9)*1.5+.4);
ctx.fillStyle=p.c+'cc'; ctx.beginPath(); ctx.ellipse(px,planeY+4*DPR,pr*(2+flat*2),pr*.35,0,0,6.283); ctx.fill();
ctx.strokeStyle='rgba(200,240,255,.6)'; ctx.beginPath(); ctx.ellipse(px,planeY+4*DPR,pr*(2+flat*2),pr*.35,0,0,6.283); ctx.stroke();
}
}
ctx.fillStyle='#ffd479'; ctx.font=`${11*DPR}px "Microsoft YaHei"`; ctx.textAlign='left';
ctx.fillText('二向箔展开中 · 3D→2D 投影动画(想象)',16*DPR,H*.08);
};
// 8. 小宇宙
const drawMini = (t)=>{
ctx.fillStyle='#03050c'; ctx.fillRect(0,0,W,H);
drawStars(t,.2);
const cx=W*.5, cy=H*.4, R=Math.min(W,H)*.22*(1+.03*Math.sin(t*1.2));
ctx.save(); ctx.beginPath(); ctx.arc(cx,cy,R,0,6.283); ctx.clip();
ctx.fillStyle='#060d24'; ctx.fillRect(cx-R,cy-R,R*2,R*2);
for(let i=0;i<60;i++){const s=STARS[i]; ctx.fillStyle=`rgba(200,215,255,${.4+.5*Math.abs(Math.sin(t+s.ph))})`; ctx.beginPath(); ctx.arc(cx+(s.x-.5)*2*R*.9,cy+(s.y-.5)*2*R*.9,s.r*DPR,0,6.283); ctx.fill();}
ctx.fillStyle='#6aa0e8'; ctx.beginPath(); ctx.arc(cx+R*.3,cy+R*.25,4*DPR,0,6.283); ctx.fill();
ctx.restore();
ctx.strokeStyle='rgba(140,220,255,.85)'; ctx.lineWidth=2*DPR; ctx.beginPath(); ctx.arc(cx,cy,R,0,6.283); ctx.stroke();
glow(cx,cy,R*1.5,'rgba(90,140,255,.10)');
ctx.fillStyle='#55628a'; ctx.font=`${11*DPR}px "Microsoft YaHei"`; ctx.textAlign='center';
ctx.fillText('小宇宙 · 独立泡(想象)',cx,cy+R+24*DPR);
};
// 9. 归途
const drawHome = (t)=>{
ctx.fillStyle='#03050c'; ctx.fillRect(0,0,W,H);
drawStars(t,.5);
const R0=H*1.2, cy=H+R0-H*.25;
const g=ctx.createRadialGradient(W*.5,cy-R0,R0*.88,W*.5,cy,R0);
g.addColorStop(0,'#3d6db5'); g.addColorStop(1,'#060f24');
ctx.fillStyle=g; ctx.beginPath(); ctx.arc(W*.5,cy,R0,0,6.283); ctx.fill();
ctx.strokeStyle='rgba(255,170,90,.7)'; ctx.lineWidth=2.5*DPR;
ctx.beginPath(); ctx.arc(W*.5,cy,R0+2*DPR,Math.PI*1.15,Math.PI*1.85); ctx.stroke();
glow(W*.5,H*.74,H*.3,'rgba(255,140,60,.45)');
const ax=W*.72, ay=H*.14, p=4+2*Math.sin(t*2.5);
ctx.strokeStyle='#ffd479'; ctx.lineWidth=DPR;
ctx.beginPath(); ctx.arc(ax,ay,p*DPR,0,6.283); ctx.stroke();
ctx.fillStyle='#ffd479'; ctx.font=`${11*DPR}px "Microsoft YaHei"`; ctx.textAlign='left';
ctx.fillText('半人马座α · 依然在那里',ax+(p+8)*DPR,ay+4*DPR);
ctx.fillStyle='#7ee0a3'; ctx.font=`${12*DPR}px "Microsoft YaHei"`; ctx.textAlign='center';
ctx.fillText('穿越结束 · 物理定律是真的,好奇心也是',W*.5,H*.32);
};
const DRAW = {
earth: drawEarth, redcoast: drawRedCoast, trisolaris: drawTrisolaris,
guzheng: drawGuzheng, droplet: drawDroplet, deterrence: drawDeterrence,
dual: drawDual, mini: drawMini, home: drawHome
};
// ============================================================
// 状态机与交互
// ============================================================
const renderDots = ()=>{
const box=$('progress'); box.innerHTML='';
SCENES.forEach((s,i)=>{
const d=document.createElement('div');
d.className='dot'+(i===idx?' on':i<idx?' visited':'');
d.title=s.title; d.onclick=()=>{if(!warp) enter(i);};
box.appendChild(d);
});
};
const renderPanel = ()=>{
const s=SCENES[idx];
$('st-title').textContent=s.title;
$('sci-real').innerHTML=s.real.map(x=>`<li>${x}</li>`).join('');
$('sci-imag').innerHTML=s.imag.map(x=>`<li>${x}</li>`).join('');
narrChars=0;
$('btn-prev').disabled=idx===0;
$('btn-next').disabled=idx===SCENES.length-1;
renderDots();
};
const enter = i=>{
idx=i; stT=0;
if(SCENES[i].id==='trisolaris') initTB();
renderPanel();
};
$('btn-next').onclick=()=>{if(!warp&&idx<SCENES.length-1) warp={t:0,target:idx+1};};
$('btn-prev').onclick=()=>{if(!warp&&idx>0) enter(idx-1);};
addEventListener('keydown',e=>{
if(e.key==='ArrowRight') $('btn-next').onclick();
if(e.key==='ArrowLeft') $('btn-prev').onclick();
});
$('narr').onclick=()=>{ narrChars=SCENES[idx].narr.length; };
// ============================================================
// 主循环
// ============================================================
let last=performance.now(), T=0;
const loop = now=>{
const dt=Math.min(.05,(now-last)/1000);
last=now; T+=dt; stT+=dt;
if(warp){
warp.t+=dt;
ctx.fillStyle='#03050c'; ctx.fillRect(0,0,W,H);
const cx=W*.5, cy=H*.5, k=Math.min(1,warp.t/1.6);
for(let i=0;i<200;i++){
const a=(i/200)*6.283;
const r0=((T*(.4+(i%7)*.1)+i*.037)%1)*Math.max(W,H)*.7;
const len=(10+k*140)*DPR;
ctx.strokeStyle=`rgba(180,200,255,${.2+k*.7})`; ctx.lineWidth=DPR;
ctx.beginPath(); ctx.moveTo(cx+Math.cos(a)*r0,cy+Math.sin(a)*r0);
ctx.lineTo(cx+Math.cos(a)*(r0+len),cy+Math.sin(a)*(r0+len)); ctx.stroke();
}
ctx.fillStyle='#ffd479'; ctx.font=`${13*DPR}px "Microsoft YaHei"`; ctx.textAlign='center';
ctx.fillText('穿越中...',cx,cy-20*DPR);
ctx.fillStyle='#55628a'; ctx.font=`${10*DPR}px "Microsoft YaHei"`;
ctx.fillText('(过渡场景 · 想象)',cx,cy+8*DPR);
if(warp.t>1.8){const tg=warp.target; warp=null; enter(tg);}
}else{
DRAW[SCENES[idx].id](T,stT);
}
narrChars=Math.min(SCENES[idx].narr.length,narrChars+dt*38);
$('narr').textContent=SCENES[idx].narr.slice(0,Math.floor(narrChars));
requestAnimationFrame(loop);
};
enter(0);
requestAnimationFrame(loop);
</script>
</body>
</html>
@@ -0,0 +1,665 @@
<!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>
@import url('https://fonts.googleapis.com/css2?family=Noto+Serif+SC:wght@300;500&display=swap');
body, html {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
background-color: #020205;
color: #ddd;
font-family: 'Noto Serif SC', serif;
overflow: hidden;
user-select: none;
}
#stage {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1;
}
.overlay {
position: absolute;
z-index: 10;
pointer-events: none;
box-sizing: border-box;
}
/* 顶部/底部叙事框 */
#narrative-box {
bottom: 50px;
left: 50%;
transform: translateX(-50%);
width: 60%;
max-width: 800px;
background: rgba(10, 15, 25, 0.85);
border: 1px solid rgba(100, 150, 255, 0.3);
padding: 25px 35px;
border-radius: 8px;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.8);
font-size: 1.1rem;
line-height: 1.8;
letter-spacing: 1px;
text-align: justify;
min-height: 120px;
text-shadow: 0 0 5px rgba(255,255,255,0.2);
}
/* 侧边信息栏 */
.info-panel {
top: 50px;
width: 260px;
background: rgba(5, 5, 10, 0.7);
border: 1px solid rgba(255, 255, 255, 0.1);
padding: 20px;
border-radius: 5px;
backdrop-filter: blur(5px);
font-family: sans-serif;
font-size: 0.9rem;
line-height: 1.5;
}
#science-panel { left: 30px; border-top: 3px solid #4a90e2; }
#fiction-panel { right: 30px; border-top: 3px solid #e24a4a; }
.info-panel h3 {
margin-top: 0;
margin-bottom: 15px;
font-size: 1.1rem;
text-align: center;
letter-spacing: 2px;
}
#science-panel h3 { color: #4a90e2; }
#fiction-panel h3 { color: #e24a4a; }
.info-panel ul {
margin: 0;
padding-left: 20px;
}
.info-panel li { margin-bottom: 12px; color: #ccc; }
/* 控件 */
#controls {
bottom: 50px;
right: 50px;
display: flex;
gap: 15px;
pointer-events: auto;
}
button {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.3);
color: #fff;
padding: 10px 20px;
cursor: pointer;
font-family: 'Noto Serif SC', serif;
transition: all 0.3s;
border-radius: 4px;
}
button:hover { background: rgba(255, 255, 255, 0.2); }
button:disabled { opacity: 0.3; cursor: not-allowed; }
/* 场景标题 */
#scene-title {
top: 40px;
left: 50%;
transform: translateX(-50%);
font-size: 1.5rem;
letter-spacing: 4px;
color: rgba(255,255,255,0.9);
text-shadow: 0 0 10px rgba(255,255,255,0.5);
text-align: center;
}
#intro {
position: absolute;
top: 0; left: 0; width: 100%; height: 100%;
background: #000;
z-index: 100;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
transition: opacity 1s;
}
#intro h1 { font-size: 3rem; letter-spacing: 10px; margin-bottom: 20px; font-weight: 300;}
#intro p { font-size: 1.2rem; color: #888; }
#start-btn { margin-top: 40px; font-size: 1.2rem; padding: 10px 40px; pointer-events: auto;}
</style>
</head>
<body>
<canvas id="stage"></canvas>
<div id="intro">
<h1>思想实验室 · 穿越三体</h1>
<p>一场基于物理与想象的第一人称视觉实验</p>
<button id="start-btn">启程</button>
</div>
<div id="scene-title" class="overlay"></div>
<div id="science-panel" class="overlay info-panel">
<h3>真实科学</h3>
<ul id="science-list"></ul>
</div>
<div id="fiction-panel" class="overlay info-panel">
<h3>小说想象</h3>
<ul id="fiction-list"></ul>
</div>
<div id="narrative-box" class="overlay">
<span id="narrative-text"></span><span class="cursor">_</span>
</div>
<div id="controls" class="overlay">
<button id="prev-btn" disabled>上一跃迁</button>
<button id="next-btn">下一跃迁</button>
</div>
<script>
/**
* 核心逻辑:画布与渲染
*/
const canvas = document.getElementById('stage');
const ctx = canvas.getContext('2d');
let width, height;
function resize() {
width = window.innerWidth;
height = window.innerHeight;
canvas.width = width;
canvas.height = height;
}
window.addEventListener('resize', resize);
resize();
// 宇宙背景星空
const stars = Array.from({length: 300}, () => ({
x: Math.random() * 2000,
y: Math.random() * 2000,
r: Math.random() * 1.5,
alpha: Math.random(),
speed: Math.random() * 0.05
}));
function drawBackground() {
ctx.fillStyle = '#020205';
ctx.fillRect(0, 0, width, height);
stars.forEach(star => {
star.alpha += star.speed;
let a = Math.abs(Math.sin(star.alpha));
ctx.fillStyle = `rgba(255, 255, 255, ${a})`;
ctx.beginPath();
ctx.arc(star.x % width, star.y % height, star.r, 0, Math.PI*2);
ctx.fill();
});
}
/**
* 打字机效果管理器
*/
let typeWriterTimer = null;
function typeWriter(text, elementId, speed = 40) {
const el = document.getElementById(elementId);
el.innerHTML = "";
if(typeWriterTimer) clearInterval(typeWriterTimer);
let i = 0;
typeWriterTimer = setInterval(() => {
if(i < text.length) {
el.innerHTML += text.charAt(i);
i++;
} else {
clearInterval(typeWriterTimer);
}
}, speed);
}
/**
* 场景定义与逻辑
*/
const scenes = [
{
title: "第一幕:红岸的回声",
narrative: "我凝视着夜空,那是一块深邃的黑天鹅绒。这里的海拔很高,风中带着寒意。红岸基地的巨型抛物面天线在缓慢转动。4.37光年外的星光刚刚抵达我的视网膜。我收到了一条无声的警告:『不要回答。』但我知道,文明的孤岛已被打破,我将化身幽灵,沿着电波的轨迹,去见证那场跨越数百个世纪的史诗。",
science: [
"半人马座α星是距离太阳系最近的恒星系统,距离约 4.37 光年(约 41.3 万亿公里)。",
"电磁波在真空中的传播速度为光速(约 300,000 km/s),信号一来一回需近9年。"
],
fiction: [
"太阳可以作为超级天线,利用恒星大气的等离子体将电波信号放大亿倍进行星际广播。",
"三体文明截获了广播,其监听员发出了警告。"
],
state: { earthR: 200, alpha: 1 },
init() { this.state.earthR = Math.min(width, height) * 0.4; this.state.alpha = 1; },
draw(ctx, width, height) {
// 画一个巨大的地球边缘
let cx = width / 2;
let cy = height + this.state.earthR * 0.5;
let grd = ctx.createRadialGradient(cx, cy, this.state.earthR * 0.8, cx, cy, this.state.earthR);
grd.addColorStop(0, "rgba(10, 30, 80, 1)");
grd.addColorStop(0.8, "rgba(40, 100, 200, 0.8)");
grd.addColorStop(1, "rgba(0, 0, 0, 0)");
ctx.fillStyle = grd;
ctx.beginPath();
ctx.arc(cx, cy, this.state.earthR, 0, Math.PI*2);
ctx.fill();
// 雷达红点
this.state.alpha = 0.5 + 0.5 * Math.sin(Date.now() / 300);
ctx.fillStyle = `rgba(255, 50, 50, ${this.state.alpha})`;
ctx.beginPath();
ctx.arc(cx, cy - this.state.earthR * 0.95, 3, 0, Math.PI*2);
ctx.fill();
// 发射波纹
ctx.strokeStyle = `rgba(255, 255, 255, ${this.state.alpha * 0.5})`;
ctx.lineWidth = 1;
let time = Date.now() / 1000;
for(let i=1; i<=3; i++) {
ctx.beginPath();
ctx.arc(cx, cy - this.state.earthR * 0.95, (time * 50 % 100) * i, Math.PI*1.2, Math.PI*1.8);
ctx.stroke();
}
}
},
{
title: "第二幕:乱纪元的炼狱 (三体问题)",
narrative: "经过漫长的意识跃迁,我坠入了这片毫无规律的炼狱。三颗巨大的恒星像狂舞的巨兽,在牛顿引力定律的诅咒下做着永不重复的混沌运动。刚刚还是飞星不动的三日凌空,瞬间又被撕裂成极寒的漫漫长夜。这里的文明在恒纪元与乱纪元的交替中痛苦轮回,而它们唯一的生路,就是逃离。",
science: [
"三体问题(Three-body problem):三个质量、初始位置和初始速度都是任意的天体,在相互之间万有引力的作用下的运动规律问题。",
"庞加莱已证明,三体问题没有一般解析解,其系统具备极端的混沌性对初始条件极度敏感。",
"注:背景动画采用纯粹的 RK4 (龙格-库塔) 数值积分实时演算,并非预设动画。"
],
fiction: [
"在一个如此极度不稳定的混沌星系中,居然存在一颗未被吞噬或甩出的行星。",
"该行星上诞生了能够脱水复苏,并进化出高度发达科技的三体文明。"
],
state: { bodies: [], trails: [] },
init() {
// 初始化三个恒星 (位置,速度,质量,颜色)
this.state.bodies = [
{x: -150, y: 50, vx: 0, vy: -30, m: 3000, color: '#ff4d4d', r: 15},
{x: 150, y: -50, vx: 0, vy: 30, m: 3000, color: '#fdb813', r: 15},
{x: 0, y: 0, vx: 20, vy: 0, m: 2500, color: '#4da6ff', r: 12}
];
this.state.trails = [[], [], []];
},
// RK4 引力积分演算核心
calculateAccelerations(bodies) {
const G = 10; // 引力常数调节
const softening = 20; // 软化参数防止奇点爆炸
let accels = bodies.map(() => ({ax: 0, ay: 0}));
for(let i=0; i<bodies.length; i++) {
for(let j=0; j<bodies.length; j++) {
if(i===j) continue;
let dx = bodies[j].x - bodies[i].x;
let dy = bodies[j].y - bodies[i].y;
let distSq = dx*dx + dy*dy + softening*softening;
let dist = Math.sqrt(distSq);
let force = (G * bodies[j].m) / (distSq * dist);
accels[i].ax += force * dx;
accels[i].ay += force * dy;
}
}
return accels;
},
draw(ctx, width, height) {
const dt = 0.05; // 时间步长
let b = this.state.bodies;
// RK4 步骤
// k1
let a1 = this.calculateAccelerations(b);
let k1 = b.map((body, i) => ({vx: a1[i].ax*dt, vy: a1[i].ay*dt, x: body.vx*dt, y: body.vy*dt}));
// k2
let b2 = b.map((body, i) => ({x: body.x + k1[i].x/2, y: body.y + k1[i].y/2, m: body.m}));
let a2 = this.calculateAccelerations(b2);
let k2 = b.map((body, i) => ({vx: a2[i].ax*dt, vy: a2[i].ay*dt, x: (body.vx + k1[i].vx/2)*dt, y: (body.vy + k1[i].vy/2)*dt}));
// k3
let b3 = b.map((body, i) => ({x: body.x + k2[i].x/2, y: body.y + k2[i].y/2, m: body.m}));
let a3 = this.calculateAccelerations(b3);
let k3 = b.map((body, i) => ({vx: a3[i].ax*dt, vy: a3[i].ay*dt, x: (body.vx + k2[i].vx/2)*dt, y: (body.vy + k2[i].vy/2)*dt}));
// k4
let b4 = b.map((body, i) => ({x: body.x + k3[i].x, y: body.y + k3[i].y, m: body.m}));
let a4 = this.calculateAccelerations(b4);
let k4 = b.map((body, i) => ({vx: a4[i].ax*dt, vy: a4[i].ay*dt, x: (body.vx + k3[i].vx)*dt, y: (body.vy + k3[i].vy)*dt}));
// 更新状态
for(let i=0; i<b.length; i++) {
b[i].x += (k1[i].x + 2*k2[i].x + 2*k3[i].x + k4[i].x)/6;
b[i].y += (k1[i].y + 2*k2[i].y + 2*k3[i].y + k4[i].y)/6;
b[i].vx += (k1[i].vx + 2*k2[i].vx + 2*k3[i].vx + k4[i].vx)/6;
b[i].vy += (k1[i].vy + 2*k2[i].vy + 2*k3[i].vy + k4[i].vy)/6;
this.state.trails[i].push({x: b[i].x, y: b[i].y});
if(this.state.trails[i].length > 150) this.state.trails[i].shift();
}
// 寻找中心点用于摄像机跟随
let cx = width/2, cy = height/2;
let sumX = 0, sumY = 0;
b.forEach(body => { sumX += body.x; sumY += body.y; });
let comX = sumX/3, comY = sumY/3;
ctx.save();
ctx.translate(cx - comX, cy - comY);
// 绘制轨迹和恒星
for(let i=0; i<b.length; i++) {
ctx.beginPath();
ctx.strokeStyle = b[i].color;
ctx.lineWidth = 1.5;
let tr = this.state.trails[i];
for(let j=0; j<tr.length; j++) {
ctx.globalAlpha = j / tr.length; // 轨迹渐隐
if(j===0) ctx.moveTo(tr[j].x, tr[j].y);
else ctx.lineTo(tr[j].x, tr[j].y);
}
ctx.stroke();
ctx.globalAlpha = 1;
ctx.beginPath();
ctx.arc(b[i].x, b[i].y, b[i].r, 0, Math.PI*2);
ctx.fillStyle = b[i].color;
ctx.shadowColor = b[i].color;
ctx.shadowBlur = 30;
ctx.fill();
}
ctx.restore();
}
},
{
title: "第三幕:绝对光滑 (水滴)",
narrative: "它静静地悬浮在太空中,完美得不包含任何瑕疵。这是一种无法用人类语言描述的美,表面绝对光滑,全反射着周围深邃的宇宙。它就像是一滴死神的眼泪,没有任何温度。我眼睁睁看着这颗长约三米的水滴,在瞬间撕碎了人类两千艘恒星级战舰。在物理学的铁壁面前,傲慢付出了最惨痛的代价。",
science: [
"强相互作用力:自然界四大基本力中最强的一种,负责将质子和中子结合在原子核中。作用距离极短(飞米级)。",
"绝对零度与全反射:微观粒子热运动几乎停止,若表面粗糙度低于光波长,可呈现高反射率。"
],
fiction: [
"水滴(探测器)的材料是由强相互作用力锁死的物质,这使得它的原子像被钉死般排列,坚不可摧。",
"在宏观层面运用强相互作用力,使其硬度远超自然界任何常规物质,并能进行锐角转向无工质推进。"
],
state: { angle: 0 },
init() { this.state.angle = 0; },
draw(ctx, width, height) {
this.state.angle += 0.01;
let cx = width / 2;
let cy = height / 2;
ctx.save();
ctx.translate(cx, cy);
// 微弱的上下浮动
let floatY = Math.sin(this.state.angle * 2) * 10;
ctx.translate(0, floatY);
// 绘制水滴 (贝塞尔曲线)
let w = 200;
let h = 80;
// 建立金属渐变反射
let grd = ctx.createLinearGradient(-w/2, -h, w/2, h);
grd.addColorStop(0, "#fff");
grd.addColorStop(0.2, "#88aaff");
grd.addColorStop(0.5, "#112244");
grd.addColorStop(0.8, "#5577aa");
grd.addColorStop(1, "#000");
ctx.beginPath();
// 左侧尾部
ctx.moveTo(-w/2, 0);
// 上半部分弧线
ctx.bezierCurveTo(-w/4, -h/2, w/4, -h/2, w/2, 0);
// 下半部分弧线
ctx.bezierCurveTo(w/4, h/2, -w/4, h/2, -w/2, 0);
ctx.closePath();
ctx.fillStyle = grd;
ctx.shadowColor = 'rgba(150, 180, 255, 0.5)';
ctx.shadowBlur = 20;
ctx.fill();
// 高光
ctx.beginPath();
ctx.moveTo(-w/2 + 20, -5);
ctx.bezierCurveTo(-w/4, -h/2 + 10, w/4, -h/2 + 10, w/2 - 20, -5);
ctx.strokeStyle = "rgba(255,255,255,0.7)";
ctx.lineWidth = 3;
ctx.filter = 'blur(2px)';
ctx.stroke();
ctx.restore();
}
},
{
title: "第四幕:梵高的星空 (二向箔)",
narrative: "一张明信片大小的纸条,剥夺了整个太阳系的深度。我绝望地看着前方的空间本身开始坍塌。木星、地球、逃亡的飞船……所有宏大的3D结构无可挽回地跌落,被死死地压在了一个二维的平面上。信息的细节在降维展开中铺陈,星海失去了深邃,变成了一幅绚丽而死寂的无边画卷,宛如梵高的《星月夜》。毁灭你,与你何干。",
science: [
"维度与空间:弦理论预测宇宙包含更多维度,但宏观上我们只体验到三维空间加一维时间。",
"热力学第二定律:孤立系统熵增(混乱度增加)。任何物理降维过程都会释放惊人的能量与信息。"
],
fiction: [
"将降维(从三维降至二维)作为一种宇宙规律武器,使得卷曲的维度展开。",
"逃逸降维打击的唯一方法是达到光速(即需要曲率驱动飞船)。二维化后的世界呈现出内部结构的完全平面展示。"
],
state: { radius: 10, planets: [] },
init() {
this.state.radius = 10;
this.state.planets = [
{x: 200, y: 100, r: 40, col: '#3498db'}, // 地球
{x: -150, y: -80, r: 60, col: '#e67e22'}, // 木星
{x: -50, y: 150, r: 25, col: '#bdc3c7'} // 飞船/月球
];
},
draw(ctx, width, height) {
let cx = width / 2;
let cy = height / 2;
// 扩张的二维化光环
this.state.radius += 2.5;
let r = this.state.radius;
ctx.save();
ctx.translate(cx, cy);
// 绘制尚未被吞噬的 3D 星球
this.state.planets.forEach(p => {
let dist = Math.sqrt(p.x*p.x + p.y*p.y);
if(dist > r) {
// 3D 效果
let grd = ctx.createRadialGradient(p.x - p.r/3, p.y - p.r/3, p.r/10, p.x, p.y, p.r);
grd.addColorStop(0, p.col);
grd.addColorStop(1, '#000');
ctx.fillStyle = grd;
ctx.beginPath();
ctx.arc(p.x, p.y, p.r, 0, Math.PI*2);
ctx.fill();
} else {
// 二维化效果:摊大、同心环状结构、纯色无光影
ctx.strokeStyle = p.col;
ctx.lineWidth = 1;
ctx.globalAlpha = 0.5;
for(let i=1; i<=5; i++) {
ctx.beginPath();
ctx.arc(p.x, p.y, p.r * 1.5 * i/3, 0, Math.PI*2);
ctx.stroke();
}
ctx.globalAlpha = 1;
}
});
// 降维打击的边界 (二向箔边缘)
ctx.beginPath();
ctx.arc(0, 0, r, 0, Math.PI*2);
ctx.strokeStyle = 'rgba(255, 255, 255, 0.8)';
ctx.lineWidth = 2;
ctx.shadowColor = '#fff';
ctx.shadowBlur = 15;
ctx.stroke();
// 二维区内部网格 (失去深度的象征)
ctx.clip();
ctx.strokeStyle = 'rgba(0, 255, 255, 0.1)';
ctx.lineWidth = 1;
ctx.shadowBlur = 0;
for(let i=-r; i<r; i+=20) {
ctx.beginPath(); ctx.moveTo(i, -r); ctx.lineTo(i, r); ctx.stroke();
ctx.beginPath(); ctx.moveTo(-r, i); ctx.lineTo(r, i); ctx.stroke();
}
ctx.restore();
}
},
{
title: "最终幕:时间闭环 (小宇宙)",
narrative: "几百亿年,又或者只是一瞬。在宇宙大撕裂的边缘,我推开了那扇第647号小宇宙的门。智子在一旁静静地泡着茶。我留下了一封信,一个装有五公斤生态的小球。我知道,为了大宇宙的重生,必须归还所有的质量。伴随着引力的坍缩,一切都回到了起点。时间闭环,我又看到了那颗蓝色的水球。一切都结束了,一切又将重新开始。",
science: [
"宇宙终局理论:大坍缩(Big Crunch)或热寂(Heat Death)。如果宇宙总质量超过临界值,引力最终会使其坍缩为一个奇点。",
"物质与质量守恒:在宏观闭合系统中,总质能是守恒的。"
],
fiction: [
"超级文明能够创造独立于大宇宙之外的『小宇宙』以躲避大爆炸或大坍缩。",
"回归运动声明书呼吁所有小宇宙归还质量,以便大宇宙能够收缩并引发新的大爆炸。"
],
state: { size: 0 },
init() { this.state.size = 0; },
draw(ctx, width, height) {
let cx = width / 2;
let cy = height / 2;
this.state.size += (1 - this.state.size) * 0.02; // 缓动展开
ctx.save();
ctx.translate(cx, cy);
// 小宇宙的门
let doorWidth = 120 * this.state.size;
let doorHeight = 200 * this.state.size;
ctx.fillStyle = "#fff";
ctx.shadowColor = "#fff";
ctx.shadowBlur = 50;
ctx.fillRect(-doorWidth/2, -doorHeight/2, doorWidth, doorHeight);
// 门中的微型新地球
if (this.state.size > 0.8) {
ctx.shadowBlur = 0;
ctx.fillStyle = "#3498db";
ctx.beginPath();
ctx.arc(0, 0, 30, 0, Math.PI*2);
ctx.fill();
// 生态球光晕
ctx.strokeStyle = "rgba(100, 255, 100, 0.8)";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(0, 0, 40, 0, Math.PI*2);
ctx.stroke();
}
ctx.restore();
}
}
];
let currentSceneIndex = 0;
function updatePanels() {
const scene = scenes[currentSceneIndex];
document.getElementById('scene-title').innerText = scene.title;
// 渲染科学与想象列表
const sList = document.getElementById('science-list');
sList.innerHTML = scene.science.map(item => `<li>${item}</li>`).join('');
const fList = document.getElementById('fiction-list');
fList.innerHTML = scene.fiction.map(item => `<li>${item}</li>`).join('');
// 打字机叙事
typeWriter(scene.narrative, 'narrative-text');
// 按钮状态
document.getElementById('prev-btn').disabled = currentSceneIndex === 0;
const nextBtn = document.getElementById('next-btn');
if (currentSceneIndex === scenes.length - 1) {
nextBtn.innerText = "重新循环";
} else {
nextBtn.innerText = "下一跃迁";
}
scene.init();
}
/**
* 主循环
*/
function loop() {
drawBackground();
// 渲染当前场景
scenes[currentSceneIndex].draw(ctx, width, height);
requestAnimationFrame(loop);
}
/**
* 交互控制
*/
document.getElementById('start-btn').addEventListener('click', () => {
document.getElementById('intro').style.opacity = 0;
setTimeout(() => {
document.getElementById('intro').style.display = 'none';
updatePanels();
loop();
}, 1000);
});
document.getElementById('next-btn').addEventListener('click', () => {
if (currentSceneIndex < scenes.length - 1) {
currentSceneIndex++;
} else {
currentSceneIndex = 0; // 循环
}
updatePanels();
});
document.getElementById('prev-btn').addEventListener('click', () => {
if (currentSceneIndex > 0) {
currentSceneIndex--;
updatePanels();
}
});
// 键盘支持
window.addEventListener('keydown', (e) => {
if(document.getElementById('intro').style.display !== 'none') return;
if(e.key === 'ArrowRight' || e.key === 'Space') {
document.getElementById('next-btn').click();
} else if(e.key === 'ArrowLeft') {
document.getElementById('prev-btn').click();
}
});
</script>
</body>
</html>
@@ -0,0 +1,976 @@
<!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>
:root {
--bg: #040814;
--panel: rgba(8, 14, 30, 0.78);
--panel-strong: rgba(10, 18, 38, 0.92);
--line: rgba(138, 171, 255, 0.18);
--text: #d9e3ff;
--muted: #93a3ca;
--accent: #8ea8ff;
--accent-2: #f6c46e;
--good: #7de2b2;
--warn: #ffb7b7;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
width: 100%;
height: 100%;
overflow: hidden;
background: radial-gradient(circle at top, #0a1330 0%, #040814 48%, #02040b 100%);
color: var(--text);
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
}
canvas {
display: block;
width: 100vw;
height: 100vh;
}
.hud {
position: fixed;
inset: 0;
pointer-events: none;
}
.topbar,
.sidebar,
.controls,
.hint {
pointer-events: auto;
}
.topbar {
position: absolute;
top: 16px;
left: 16px;
right: 16px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.brand {
padding: 14px 18px;
border: 1px solid var(--line);
border-radius: 18px;
background: var(--panel);
backdrop-filter: blur(12px);
box-shadow: 0 14px 40px rgba(0, 0, 0, 0.28);
}
.brand .kicker {
font-size: 12px;
letter-spacing: 0.2em;
color: var(--accent);
text-transform: uppercase;
margin-bottom: 6px;
}
.brand h1 {
margin: 0 0 6px;
font-size: 28px;
line-height: 1.1;
}
.brand p {
margin: 0;
font-size: 13px;
color: var(--muted);
}
.progress {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
justify-content: flex-end;
max-width: 50vw;
}
.dot {
width: 12px;
height: 12px;
border-radius: 999px;
border: 1px solid rgba(151, 178, 255, 0.35);
background: rgba(89, 109, 166, 0.25);
cursor: pointer;
transition: transform 0.2s ease, background 0.2s ease, box-shadow 0.2s ease;
}
.dot.active {
transform: scale(1.16);
background: linear-gradient(135deg, #9ab0ff, #6f86ff);
box-shadow: 0 0 18px rgba(131, 156, 255, 0.55);
}
.sidebar {
position: absolute;
left: 16px;
bottom: 16px;
width: min(560px, calc(100vw - 32px));
max-height: calc(100vh - 150px);
overflow: auto;
padding: 18px 18px 16px;
border-radius: 20px;
border: 1px solid var(--line);
background: linear-gradient(180deg, rgba(10, 18, 38, 0.82), rgba(6, 11, 24, 0.94));
backdrop-filter: blur(14px);
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.32);
}
.scene-title {
margin: 0 0 10px;
color: var(--accent-2);
font-size: 14px;
letter-spacing: 0.08em;
}
.narrative {
margin: 0;
min-height: 126px;
line-height: 1.95;
font-size: 15px;
color: #eef3ff;
white-space: pre-wrap;
}
.meta {
margin-top: 14px;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.card {
padding: 12px 13px;
border-radius: 16px;
background: rgba(12, 21, 45, 0.72);
border: 1px solid rgba(138, 171, 255, 0.12);
}
.card h3 {
margin: 0 0 8px;
font-size: 13px;
letter-spacing: 0.08em;
}
.card.real h3 { color: var(--good); }
.card.imag h3 { color: var(--warn); }
.card ul {
margin: 0;
padding-left: 18px;
color: #d7e2ff;
line-height: 1.75;
font-size: 13px;
}
.controls {
position: absolute;
right: 16px;
bottom: 16px;
display: flex;
gap: 10px;
}
button {
appearance: none;
border: 1px solid rgba(138, 171, 255, 0.22);
background: var(--panel-strong);
color: var(--text);
padding: 12px 16px;
border-radius: 14px;
cursor: pointer;
font-size: 14px;
transition: transform 0.18s ease, background 0.18s ease, border-color 0.18s ease;
}
button:hover {
transform: translateY(-1px);
border-color: rgba(138, 171, 255, 0.4);
background: rgba(15, 26, 54, 0.95);
}
.hint {
position: absolute;
right: 16px;
top: 102px;
padding: 12px 14px;
border-radius: 16px;
border: 1px solid var(--line);
background: rgba(8, 14, 30, 0.7);
color: var(--muted);
font-size: 12px;
max-width: 280px;
line-height: 1.7;
}
.scene-tag {
display: inline-flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
padding: 6px 10px;
border-radius: 999px;
border: 1px solid rgba(138, 171, 255, 0.18);
background: rgba(25, 37, 71, 0.38);
font-size: 12px;
color: var(--muted);
}
.legend {
margin-top: 10px;
padding: 10px 12px;
border-radius: 14px;
background: rgba(8, 14, 30, 0.54);
color: #b8c8f4;
font-size: 12px;
line-height: 1.7;
}
@media (max-width: 960px) {
.topbar {
flex-direction: column;
align-items: stretch;
}
.progress {
max-width: none;
justify-content: flex-start;
}
.hint {
display: none;
}
.sidebar {
width: calc(100vw - 24px);
left: 12px;
right: 12px;
bottom: 74px;
max-height: 50vh;
}
.meta {
grid-template-columns: 1fr;
}
.controls {
left: 12px;
right: 12px;
bottom: 12px;
justify-content: space-between;
}
button {
flex: 1;
}
}
</style>
</head>
<body>
<canvas id="stage"></canvas>
<div class="hud">
<div class="topbar">
<div class="brand">
<div class="kicker">Thought Lab</div>
<h1>思想实验室 · 穿越三体</h1>
<p>第一人称穿越叙事;真实科学与小说想象分栏标注;三太阳场景为实时牛顿引力积分。</p>
</div>
<div id="progress" class="progress"></div>
</div>
<div class="hint">
左右方向键切换场景,空格可重播打字机效果,点击叙事区可直接显示全文。<br />
三太阳场景右下角会显示积分步长、总能量与总动量,方便观察数值稳定性。
</div>
<aside class="sidebar">
<div class="scene-tag" id="sceneTag"></div>
<h2 class="scene-title" id="sceneTitle"></h2>
<p class="narrative" id="narrative"></p>
<div class="legend" id="legend"></div>
<div class="meta">
<section class="card real">
<h3>真实科学</h3>
<ul id="scienceReal"></ul>
</section>
<section class="card imag">
<h3>小说想象</h3>
<ul id="scienceImag"></ul>
</section>
</div>
</aside>
<div class="controls">
<button id="prevBtn" type="button">上一幕</button>
<button id="replayBtn" type="button">重播叙事</button>
<button id="nextBtn" type="button">下一幕</button>
</div>
</div>
<script>
'use strict';
const canvas = document.getElementById('stage');
const ctx = canvas.getContext('2d');
const dpr = Math.min(window.devicePixelRatio || 1, 2);
let width = 0;
let height = 0;
let cssWidth = 0;
let cssHeight = 0;
function resize() {
cssWidth = window.innerWidth;
cssHeight = window.innerHeight;
width = Math.round(cssWidth * dpr);
height = Math.round(cssHeight * dpr);
canvas.width = width;
canvas.height = height;
canvas.style.width = cssWidth + 'px';
canvas.style.height = cssHeight + 'px';
}
window.addEventListener('resize', resize);
resize();
function rng(seed) {
let s = seed >>> 0;
return function next() {
s += 0x6D2B79F5;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const random = rng(5407);
const starField = Array.from({ length: 520 }, () => ({
x: random(),
y: random(),
z: 0.25 + random() * 0.95,
twinkle: random() * Math.PI * 2
}));
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
function lerp(a, b, t) {
return a + (b - a) * t;
}
function fmt(n, digits = 2) {
return Number(n).toFixed(digits);
}
function clearSky(top = '#081123', bottom = '#02040b') {
const g = ctx.createLinearGradient(0, 0, 0, height);
g.addColorStop(0, top);
g.addColorStop(1, bottom);
ctx.fillStyle = g;
ctx.fillRect(0, 0, width, height);
}
function drawStarField(time, strength = 1) {
for (const s of starField) {
const alpha = (0.16 + 0.7 * Math.abs(Math.sin(time * 0.35 + s.twinkle))) * strength;
const r = s.z * 1.5 * dpr;
ctx.fillStyle = `rgba(220,230,255,${alpha})`;
ctx.beginPath();
ctx.arc(s.x * width, s.y * height, r, 0, Math.PI * 2);
ctx.fill();
}
}
function glow(x, y, r, color0, color1 = 'rgba(0,0,0,0)') {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, color0);
g.addColorStop(1, color1);
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fill();
}
function drawPlanet(x, y, r, color, shadow = 'rgba(0,0,0,0.32)') {
const g = ctx.createRadialGradient(x - r * 0.3, y - r * 0.4, r * 0.18, x, y, r);
g.addColorStop(0, '#ffffff');
g.addColorStop(0.05, color);
g.addColorStop(1, shadow);
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fill();
}
function drawOrbitRing(cx, cy, rx, ry, color, lineWidth = 1) {
ctx.strokeStyle = color;
ctx.lineWidth = lineWidth * dpr;
ctx.beginPath();
ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2);
ctx.stroke();
}
const scenes = [
{
id: 'earth',
order: 'Scene 1 / 7',
title: '地球 · 出发前夜',
tag: '太阳系 / 观测起点',
legend: '这一幕没有超技术。只有地球、月球、太阳光的真实比例关系提示,以及我作为“当事人”的主观出发。',
narrative: '我站在地球的夜面上,城市的灯火像一层薄薄的神经网,贴在行星表面。月光跨过约 1.28 秒的真空落到海上,太阳光则用了约 8 分 20 秒才抵达这里。南方的天穹深处,半人马座α星仍旧只是一个肉眼难辨的亮点,距离我大约 4.37 光年。那不是神话里的门,只是一组真实存在的恒星;可在我的心里,它已经是穿越的起点。我知道,今晚离开的并不是肉身,而是视角。我要以“我”的名义,从地球出发,一站一站,去穿过《三体》留在现代人脑海里的宇宙阴影。',
real: [
'地月平均距离约 384,400 km,月光传播时间约 1.28 秒。',
'日地平均距离 1 AU,约 1.496 亿 km;太阳光抵达地球约需 8 分 20 秒。',
'半人马座α星系统距离地球约 4.37 光年,其中比邻星约 4.246 光年。'
],
imag: [
'“我”作为亲历者出发,是叙事视角设定,不是真实事件。',
'此页把天文尺度压缩成可观看的画面,属于可视化,不代表真实比例同屏。'
],
draw(time) {
clearSky('#071225', '#010308');
drawStarField(time, 0.95);
glow(width * 0.12, height * 0.18, 140 * dpr, 'rgba(255,220,146,0.95)');
ctx.fillStyle = '#fff8d2';
ctx.beginPath();
ctx.arc(width * 0.12, height * 0.18, 10 * dpr, 0, Math.PI * 2);
ctx.fill();
const earthX = width * 0.74;
const earthY = height * 0.46;
const earthR = Math.min(width, height) * 0.18;
glow(earthX - earthR * 0.18, earthY - earthR * 0.24, earthR * 1.45, 'rgba(88,140,255,0.2)');
drawPlanet(earthX, earthY, earthR, '#2f6fff', '#03111f');
ctx.fillStyle = 'rgba(104, 200, 255, 0.35)';
for (let i = 0; i < 8; i++) {
const a = i / 8 * Math.PI * 2 + time * 0.08;
ctx.beginPath();
ctx.arc(earthX + Math.cos(a) * earthR * 0.76, earthY + Math.sin(a * 1.4) * earthR * 0.2, earthR * 0.12, 0, Math.PI * 2);
ctx.fill();
}
const moonAngle = time * 0.22;
const moonX = earthX + Math.cos(moonAngle) * earthR * 1.95;
const moonY = earthY + Math.sin(moonAngle) * earthR * 0.78;
drawOrbitRing(earthX, earthY, earthR * 1.95, earthR * 0.78, 'rgba(145,170,255,0.18)');
drawPlanet(moonX, moonY, earthR * 0.22, '#d6d8df', '#434855');
ctx.fillStyle = 'rgba(255,255,255,0.82)';
ctx.font = `${14 * dpr}px Segoe UI`;
ctx.fillText('半人马座α方向', width * 0.44, height * 0.19);
ctx.strokeStyle = 'rgba(164,190,255,0.5)';
ctx.beginPath();
ctx.moveTo(width * 0.55, height * 0.2);
ctx.lineTo(width * 0.67, height * 0.3);
ctx.stroke();
}
},
{
id: 'voyage',
order: 'Scene 2 / 7',
title: '星际航行 · 我把自己交给时间',
tag: '过渡场景 / 明确想象',
legend: '这是一段明确标注为想象的过渡场景:我用一艘接近相对论速度的飞船,把几十年的地球时间压成一段个人感受更短的旅程。',
narrative: '我进入飞船,舱壁在低温与金属的气味里发出沉默的回响。为了不违背现行物理,我没有让它超光速,只让它在想象中艰难地逼近那个上限:光速的十分之一、再多一点。于是星空不再像古人头顶的静图,而是成为一部缓慢移动的深海纪录片。4.37 光年不是一句诗,是约 4.13 × 10^13 千米;即使以 0.15c 前进,地球参考系里我也仍需飞行近三十年。真正穿过这段距离的,不是奇迹,而是时间本身。',
real: [
'真空光速 c ≈ 299,792.458 km/s;有静质量的物体不能加速到光速。',
'4.37 光年约等于 4.13 × 10^13 km。',
'若以 0.15c 匀速飞行 4.37 光年,地球参考系所需时间约 29.1 年;时间膨胀存在,但在 0.15c 时仍不极端。'
],
imag: [
'这一整幕的飞船、冬眠与我在舱内的感知,都是叙事想象。',
'画面中的星光拉伸是艺术化表达,不是严格的相对论视效计算。'
],
draw(time) {
clearSky('#020612', '#010207');
drawStarField(time, 1);
const centerX = width * 0.5;
const centerY = height * 0.5;
for (let i = 0; i < 260; i++) {
const a = (i / 260) * Math.PI * 2;
const len = (60 + (i % 13) * 36 + (Math.sin(time * 1.2 + i) + 1) * 28) * dpr;
const x1 = centerX + Math.cos(a) * 14 * dpr;
const y1 = centerY + Math.sin(a) * 14 * dpr;
const x2 = centerX + Math.cos(a) * len;
const y2 = centerY + Math.sin(a) * len;
ctx.strokeStyle = `rgba(160,180,255,${0.08 + (i % 7) * 0.018})`;
ctx.lineWidth = ((i % 5) + 1) * 0.42 * dpr;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
}
const shipX = width * 0.58;
const shipY = height * 0.53;
ctx.save();
ctx.translate(shipX, shipY);
ctx.rotate(-0.12 + Math.sin(time * 0.4) * 0.02);
ctx.fillStyle = '#dfe7ff';
ctx.beginPath();
ctx.moveTo(-24 * dpr, -8 * dpr);
ctx.lineTo(30 * dpr, 0);
ctx.lineTo(-24 * dpr, 8 * dpr);
ctx.closePath();
ctx.fill();
glow(-34 * dpr, 0, 46 * dpr, 'rgba(122,164,255,0.46)');
ctx.restore();
}
},
{
id: 'arrival',
order: 'Scene 3 / 7',
title: '抵达前的校正 · 真实的半人马座α',
tag: '现实星系 / 先把科学摆正',
legend: '在进入小说的“三体”之前,我先停下来校正:现实中的半人马座α并不是小说里的混乱三星系统,而是一个更接近层级稳定结构的真实系统。',
narrative: '我并没有直接冲进小说,而是先在现实里刹车。眼前这组恒星中,半人马座α A 与 B 彼此环绕,周期大约八十年;比邻星则在更远处,以极长的周期与它们维持松散的引力关联。真实宇宙没有义务配合文学隐喻。真正迷人的地方恰恰在这里:小说选择了一个真实坐标,却把它推向了现实没有走到的方向。我站在这条分叉口上,意识到思想实验最需要的不是激情,而是边界感。',
real: [
'半人马座α A 质量约 1.1 个太阳质量,B 约 0.9 个太阳质量;二者构成近距双星。',
'A 与 B 的轨道半长轴约 23.4 AU,公转周期约 79.9 年。',
'比邻星质量约 0.122 个太阳质量,距离 AB 双星约 13,000 AU,呈层级三体结构。'
],
imag: [
'我在星系外“刹车观察”只是叙事构图。',
'这一步把现实星系与小说三体星系人为地并置,是思想实验的设计,不是现实天文流程。'
],
draw(time) {
clearSky('#050a18', '#010207');
drawStarField(time, 0.95);
const cx = width * 0.45;
const cy = height * 0.47;
const rx = Math.min(width, height) * 0.18;
const ry = rx * 0.62;
drawOrbitRing(cx, cy, rx, ry, 'rgba(141,167,255,0.22)', 1.3);
const angle = time * 0.28;
const ax = cx + Math.cos(angle) * rx;
const ay = cy + Math.sin(angle) * ry;
const bx = cx - Math.cos(angle) * rx;
const by = cy - Math.sin(angle) * ry;
glow(ax, ay, 82 * dpr, 'rgba(255,235,190,0.75)');
glow(bx, by, 72 * dpr, 'rgba(255,206,144,0.68)');
drawPlanet(ax, ay, 18 * dpr, '#ffe1a9', '#7b522d');
drawPlanet(bx, by, 14 * dpr, '#ffc97f', '#6e4422');
const px = width * 0.78 + Math.cos(time * 0.06) * 6 * dpr;
const py = height * 0.24 + Math.sin(time * 0.08) * 10 * dpr;
glow(px, py, 44 * dpr, 'rgba(255,100,110,0.55)');
drawPlanet(px, py, 8 * dpr, '#ff8a7f', '#712e34');
ctx.strokeStyle = 'rgba(162,185,255,0.34)';
ctx.beginPath();
ctx.moveTo(width * 0.58, height * 0.38);
ctx.lineTo(px - 16 * dpr, py + 6 * dpr);
ctx.stroke();
ctx.fillStyle = 'rgba(235,241,255,0.88)';
ctx.font = `${13 * dpr}px Segoe UI`;
ctx.fillText('比邻星(远距成员)', px - 40 * dpr, py - 16 * dpr);
}
},
{
id: 'threebody',
order: 'Scene 4 / 7',
title: '三太阳 · 乱纪元不是脚本,而是混沌',
tag: '实时数值积分 / 牛顿引力',
legend: '这一幕的三颗恒星位置由实时积分器计算,不是预制动画。为突出混沌,采用的是一个理想化、无量纲的牛顿三体系统,而不是现实中稳定的半人马座α精确星历。',
narrative: '我终于走进小说的核心图景:三颗太阳在同一片天空里彼此拖拽,轨道像被撕裂又重新缝合。这里最打动我的并不是灾难,而是不可预测本身。决定它们下一步位置的,仍旧是牛顿留下的万有引力;可只要初始条件有极小偏差,未来就会被放大得面目全非。我看着这三颗火球在我的视野里实时演算,忽然明白《三体》最冷的地方并不在于敌意,而在于宇宙根本不承诺稳定。',
real: [
'本场景使用牛顿引力方程并以 RK4 做实时数值积分,恒星位置每帧更新。',
'三体问题一般没有通用解析解;其对初始条件的高敏感性是混沌动力学的重要例子。',
'右下角显示总能量与总动量,用于监看积分过程的数值误差。'
],
imag: [
'这里展示的是理想化三体系统,用来说明混沌,不是现实半人马座α的真实长期轨道。',
'小说中的“三体文明”“恒纪元/乱纪元”与行星生存史,属于文学想象。'
],
draw: null
},
{
id: 'droplet',
order: 'Scene 5 / 7',
title: '水滴 · 完美外形背后的物理不可能',
tag: '探测器遭遇 / 小说核心意象',
legend: '我把“水滴”画成一颗近乎完美的镜面体,但明确标注:宏观强相互作用材料与其机械性能,仍然属于小说设定。',
narrative: '我看见它的时候,它不像武器,甚至不像机器。它更像一滴从真空里滴落下来的银色液体,静止,光洁,毫不费力地反射周围所有星光。可正因为这种完美,它才令人恐惧:现实世界中的材料总有晶格、缺陷、热振动与断裂,而它仿佛从这些常识之外造出来。我在它面前第一次意识到,文学真正高明的地方,是把一种物理上几乎不可能成立的东西,写得像天经地义一样自然。',
real: [
'强相互作用是真实存在的基本相互作用,负责束缚原子核内部的夸克与核子。',
'现实材料会有缺陷、表面粗糙度、热振动与断裂问题,不存在宏观“绝对完美镜面”。'
],
imag: [
'“水滴”及其宏观强相互作用材料设定,是小说想象。',
'我与水滴在深空对视的遭遇是本页原创叙事。'
],
draw(time) {
clearSky('#040814', '#010207');
drawStarField(time, 0.72);
const x = width * 0.62 + Math.sin(time * 0.8) * 12 * dpr;
const y = height * 0.42 + Math.cos(time * 0.7) * 10 * dpr;
const r = Math.min(width, height) * 0.12;
for (let i = 0; i < 18; i++) {
const rr = r * (1.15 + i * 0.07);
ctx.strokeStyle = `rgba(152, 178, 255, ${0.03 - i * 0.0012})`;
ctx.lineWidth = 2 * dpr;
ctx.beginPath();
ctx.ellipse(x, y, rr * 0.62, rr * 1.02, 0.08, 0, Math.PI * 2);
ctx.stroke();
}
const g = ctx.createRadialGradient(x - r * 0.28, y - r * 0.42, r * 0.08, x, y, r * 1.02);
g.addColorStop(0, '#ffffff');
g.addColorStop(0.12, '#eef4ff');
g.addColorStop(0.45, '#96add7');
g.addColorStop(1, '#162136');
ctx.fillStyle = g;
ctx.beginPath();
ctx.ellipse(x, y, r * 0.56, r * 0.98, 0.08, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.48)';
ctx.lineWidth = 1.4 * dpr;
ctx.beginPath();
ctx.ellipse(x - r * 0.08, y - r * 0.16, r * 0.2, r * 0.56, 0.08, -0.6, 0.7);
ctx.stroke();
glow(x, y, r * 1.8, 'rgba(146,174,255,0.12)');
}
},
{
id: 'dualvector',
order: 'Scene 6 / 7',
title: '太阳系终局 · 二维化的视觉隐喻',
tag: '降维打击 / 明确非现实',
legend: '这一幕把“二维化”画成整个太阳系沿纵深被压扁的视觉隐喻。它不是现实物理过程,只是为了呈现小说里那种令人窒息的几何恐惧。',
narrative: '我赶回太阳系时,已经来不及了。二向箔像一张看不见边缘的平面,在黑暗里缓缓展开;行星的厚度被抹去,轨道被抹去,连“前后”这种感觉也开始失效。现实里我们熟悉的空间感,原来如此脆弱:它并非永恒背景,而只是生命恰好生存在其中的一种几何条件。我站在这场想象中的毁灭里,看见海洋、山脉、云层和城市,一起变成一幅没有厚度的图像。那一刻我第一次真切理解:在《三体》里,最可怕的并不是爆炸,而是维度本身也会成为武器。',
real: [
'二维空间是数学上严格定义的对象,三维到二维投影也是标准几何操作。',
'太阳系八大行星、行星公转这一基本天文学结构是真实的。'
],
imag: [
'“二向箔”与三维宇宙被局部降为二维的机制,是小说设定。',
'画面中的压缩波前是文学视觉化,而非现行物理理论。'
],
draw(time) {
clearSky('#040612', '#010207');
drawStarField(time, 0.65);
const cx = width * 0.34;
const cy = height * 0.48;
const base = Math.min(width, height) * 0.042;
const orbits = [1.8, 2.4, 3.2, 4.2, 6.2, 8.1];
const flatten = clamp((Math.sin(time * 0.55) + 1) * 0.5, 0, 1);
const scaleY = lerp(1, 0.06, flatten * 0.82);
glow(cx, cy, 80 * dpr, 'rgba(255, 214, 132, 0.78)');
drawPlanet(cx, cy, base * 0.7, '#ffd27b', '#70461f');
orbits.forEach((o, i) => {
drawOrbitRing(cx, cy, base * o * 3.8, base * o * 1.7 * scaleY, 'rgba(133,158,233,0.2)');
const a = time * (0.28 / Math.sqrt(o)) + i * 0.7;
const px = cx + Math.cos(a) * base * o * 3.8;
const py = cy + Math.sin(a) * base * o * 1.7 * scaleY;
drawPlanet(px, py, base * (0.18 + i * 0.03), i % 2 ? '#82b2ff' : '#d2d7df', '#1f2740');
});
const waveX = lerp(width * 1.1, width * 0.52, clamp((time % 7) / 7, 0, 1));
const wave = ctx.createLinearGradient(waveX - 120 * dpr, 0, waveX + 20 * dpr, 0);
wave.addColorStop(0, 'rgba(255,255,255,0)');
wave.addColorStop(0.4, 'rgba(255, 222, 154, 0.16)');
wave.addColorStop(1, 'rgba(255,255,255,0.02)');
ctx.fillStyle = wave;
ctx.fillRect(waveX - 120 * dpr, 0, 140 * dpr, height);
}
},
{
id: 'return',
order: 'Scene 7 / 7',
title: '回到地球 · 思想实验的意义',
tag: '归返 / 现实落点',
legend: '最后一幕把所有惊异重新落回现实:我回到地球,不是因为我真的完成了宇宙旅行,而是因为思想实验在理性边界内完成了情感穿越。',
narrative: '我最终还是回到地球。风穿过树梢,海仍旧在月光下起伏,头顶的星空又恢复成肉眼熟悉的尺度。半人马座α依然在 4.37 光年外,真空光速依然是约 3 × 10^5 千米每秒,牛顿引力与相对论也依然没有因为文学而退让半分。可是我知道自己已经和出发时不同了:我带回来的不是答案,而是一种更清醒的敬畏。真正有力量的思想实验,从来不是把幻想冒充现实,而是让幻想逼着我更认真地看待现实。',
real: [
'本页所有标注为真实科学的距离、速度、质量级别与动力学性质,都基于现行科学知识。',
'思想实验本身是一种真实有效的认知方法:在不违背已知定律的前提下推演后果。'
],
imag: [
'整段穿越闭环是叙事结构,不是真实经历。',
'回程中的“我”带着小说场景归来,是文学化总结。'
],
draw(time) {
clearSky('#081123', '#03060d');
drawStarField(time, 0.72);
const horizon = height * 0.78;
const g = ctx.createLinearGradient(0, horizon - 180 * dpr, 0, height);
g.addColorStop(0, 'rgba(18, 50, 92, 0)');
g.addColorStop(0.55, 'rgba(18, 44, 77, 0.55)');
g.addColorStop(1, 'rgba(8, 18, 30, 0.98)');
ctx.fillStyle = g;
ctx.fillRect(0, horizon - 180 * dpr, width, height - horizon + 180 * dpr);
ctx.fillStyle = '#07111d';
ctx.beginPath();
ctx.moveTo(0, height);
for (let i = 0; i <= 10; i++) {
const x = (i / 10) * width;
const y = horizon - (Math.sin(i * 0.8 + time * 0.2) * 18 + (i % 2 ? 26 : 8)) * dpr;
ctx.lineTo(x, y);
}
ctx.lineTo(width, height);
ctx.closePath();
ctx.fill();
const earthGlowX = width * 0.72;
const earthGlowY = height * 0.62;
glow(earthGlowX, earthGlowY, 120 * dpr, 'rgba(86, 145, 255, 0.18)');
drawPlanet(earthGlowX, earthGlowY, Math.min(width, height) * 0.1, '#3e77ff', '#061323');
}
}
];
const progressEl = document.getElementById('progress');
const sceneTagEl = document.getElementById('sceneTag');
const sceneTitleEl = document.getElementById('sceneTitle');
const narrativeEl = document.getElementById('narrative');
const legendEl = document.getElementById('legend');
const scienceRealEl = document.getElementById('scienceReal');
const scienceImagEl = document.getElementById('scienceImag');
const prevBtn = document.getElementById('prevBtn');
const nextBtn = document.getElementById('nextBtn');
const replayBtn = document.getElementById('replayBtn');
let activeScene = 0;
let typeTimer = null;
let typeIndex = 0;
let fullNarrative = '';
let isTyping = false;
function fillList(el, items) {
el.innerHTML = '';
items.forEach((item) => {
const li = document.createElement('li');
li.textContent = item;
el.appendChild(li);
});
}
function renderDots() {
progressEl.innerHTML = '';
scenes.forEach((scene, index) => {
const dot = document.createElement('button');
dot.type = 'button';
dot.className = 'dot' + (index === activeScene ? ' active' : '');
dot.title = `${scene.order} · ${scene.title}`;
dot.addEventListener('click', () => goToScene(index));
progressEl.appendChild(dot);
});
}
function stopTyping() {
if (typeTimer) {
window.clearTimeout(typeTimer);
typeTimer = null;
}
isTyping = false;
}
function finishTyping() {
stopTyping();
narrativeEl.textContent = fullNarrative;
}
function startTyping(text) {
stopTyping();
fullNarrative = text;
typeIndex = 0;
narrativeEl.textContent = '';
isTyping = true;
const tick = () => {
typeIndex += 2;
narrativeEl.textContent = text.slice(0, typeIndex);
if (typeIndex < text.length) {
typeTimer = window.setTimeout(tick, 26);
} else {
stopTyping();
}
};
tick();
}
function updateScenePanel() {
const scene = scenes[activeScene];
sceneTagEl.textContent = `${scene.order} · ${scene.tag}`;
sceneTitleEl.textContent = scene.title;
legendEl.textContent = scene.legend;
fillList(scienceRealEl, scene.real);
fillList(scienceImagEl, scene.imag);
startTyping(scene.narrative);
renderDots();
prevBtn.disabled = activeScene === 0;
nextBtn.disabled = activeScene === scenes.length - 1;
}
function goToScene(index) {
activeScene = clamp(index, 0, scenes.length - 1);
updateScenePanel();
}
prevBtn.addEventListener('click', () => goToScene(activeScene - 1));
nextBtn.addEventListener('click', () => goToScene(activeScene + 1));
replayBtn.addEventListener('click', () => startTyping(scenes[activeScene].narrative));
narrativeEl.addEventListener('click', () => {
if (isTyping) finishTyping();
});
window.addEventListener('keydown', (event) => {
if (event.key === 'ArrowLeft') goToScene(activeScene - 1);
if (event.key === 'ArrowRight') goToScene(activeScene + 1);
if (event.key === ' ') {
event.preventDefault();
startTyping(scenes[activeScene].narrative);
}
});
// ---- 三体积分器:理想化无量纲牛顿三体 ----
const tb = {
G: 1,
eps2: 0.0008,
dt: 0.0052,
masses: [1.08, 0.96, 0.74],
pos: [-0.9, -0.16, 0.82, -0.22, 0.1, 0.82],
vel: [0.38, -0.31, -0.31, 0.35, -0.07, -0.08],
trails: [[], [], []],
energy0: 0,
momentum0: [0, 0]
};
function tbAcceleration(pos) {
const out = [0, 0, 0, 0, 0, 0];
for (let i = 0; i < 3; i += 1) {
for (let j = 0; j < 3; j += 1) {
if (i === j) continue;
const dx = pos[j * 2] - pos[i * 2];
const dy = pos[j * 2 + 1] - pos[i * 2 + 1];
const r2 = dx * dx + dy * dy + tb.eps2;
const inv = 1 / (r2 * Math.sqrt(r2));
out[i * 2] += tb.G * tb.masses[j] * dx * inv;
out[i * 2 + 1] += tb.G * tb.masses[j] * dy * inv;
}
}
return out;
}
function addScaled(a, b, s) {
return a.map((v, i) => v + b[i] * s);
}
function tbStep() {
const p = tb.pos;
const v = tb.vel;
const dt = tb.dt;
const k1v = tbAcceleration(p);
const k1p = v.slice();
const k2v = tbAcceleration(addScaled(p, k1p, dt / 2));
const k2p = addScaled(v, k1v, dt / 2);
const k3v = tbAcceleration(addScaled(p, k2p, dt / 2));
const k3p = addScaled(v, k2v, dt / 2);
const k4v = tbAcceleration(addScaled(p, k3p, dt));
const k4p = addScaled(v, k3v, dt);
tb.pos = p.map((value, i) => value + dt / 6 * (k1p[i] + 2 * k2p[i] + 2 * k3p[i] + k4p[i]));
tb.vel = v.map((value, i) => value + dt / 6 * (k1v[i] + 2 * k2v[i] + 2 * k3v[i] + k4v[i]));
for (let i = 0; i < 3; i += 1) {
const trail = tb.trails[i];
trail.push({ x: tb.pos[i * 2], y: tb.pos[i * 2 + 1] });
if (trail.length > 460) trail.shift();
}
}
function tbEnergy() {
let ke = 0;
for (let i = 0; i < 3; i += 1) {
ke += 0.5 * tb.masses[i] * (tb.vel[i * 2] ** 2 + tb.vel[i * 2 + 1] ** 2);
}
let pe = 0;
for (let i = 0; i < 3; i += 1) {
for (let j = i + 1; j < 3; j += 1) {
const dx = tb.pos[j * 2] - tb.pos[i * 2];
const dy = tb.pos[j * 2 + 1] - tb.pos[i * 2 + 1];
pe -= tb.G * tb.masses[i] * tb.masses[j] / Math.sqrt(dx * dx + dy * dy + tb.eps2);
}
}
return ke + pe;
}
function tbMomentum() {
let px = 0;
let py = 0;
for (let i = 0; i < 3; i += 1) {
px += tb.masses[i] * tb.vel[i * 2];
py += tb.masses[i] * tb.vel[i * 2 + 1];
}
return [px, py];
}
tb.energy0 = tbEnergy();
tb.momentum0 = tbMomentum();
scenes[3].draw = function drawThreeBody(time) {
clearSky('#04070f', '#010207');
drawStarField(time, 0.72);
for (let i = 0; i < 5; i += 1) tbStep();
const centerX = width * 0.63;
const centerY = height * 0.45;
const scale = Math.min(width, height) * 0.16;
const colors = ['#ffe07d', '#ffb366', '#ff7e79'];
for (let i = 0; i < 3; i += 1) {
const trail = tb.trails[i];
ctx.strokeStyle = ['rgba(255,224,125,0.22)', 'rgba(255,179,102,0.22)', 'rgba(255,126,121,0.22)'][i];
ctx.lineWidth = 1.6 * dpr;
ctx.beginPath();
trail.forEach((pt, idx) => {
const x = centerX + pt.x * scale;
const y = centerY + pt.y * scale;
if (idx === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
});
ctx.stroke();
}
for (let i = 0; i < 3; i += 1) {
const x = centerX + tb.pos[i * 2] * scale;
const y = centerY + tb.pos[i * 2 + 1] * scale;
const radius = (12 + tb.masses[i] * 6) * dpr;
glow(x, y, radius * 4.3, `rgba(${i === 0 ? '255,224,125' : i === 1 ? '255,179,102' : '255,126,121'},0.3)`);
drawPlanet(x, y, radius, colors[i], '#522c1d');
}
const energy = tbEnergy();
const momentum = tbMomentum();
const eDrift = energy - tb.energy0;
const pMag = Math.hypot(momentum[0], momentum[1]);
const boxX = width * 0.71;
const boxY = height * 0.73;
const boxW = Math.min(width * 0.24, 320 * dpr);
const boxH = 112 * dpr;
ctx.fillStyle = 'rgba(6,12,24,0.78)';
ctx.strokeStyle = 'rgba(138,171,255,0.18)';
ctx.lineWidth = 1 * dpr;
ctx.beginPath();
ctx.roundRect(boxX, boxY, boxW, boxH, 18 * dpr);
ctx.fill();
ctx.stroke();
ctx.fillStyle = 'rgba(226,236,255,0.95)';
ctx.font = `${13 * dpr}px Segoe UI`;
ctx.fillText(`积分步长 dt = ${fmt(tb.dt, 4)}`, boxX + 16 * dpr, boxY + 28 * dpr);
ctx.fillText(`总能量 E = ${fmt(energy, 5)}`, boxX + 16 * dpr, boxY + 52 * dpr);
ctx.fillText(`能量漂移 ΔE = ${fmt(eDrift, 6)}`, boxX + 16 * dpr, boxY + 76 * dpr);
ctx.fillText(`总动量 |P| = ${fmt(pMag, 6)}`, boxX + 16 * dpr, boxY + 100 * dpr);
ctx.fillStyle = 'rgba(184,201,245,0.82)';
ctx.font = `${12 * dpr}px Segoe UI`;
ctx.fillText('理想化混沌三体示意(非真实半人马座α星历)', width * 0.42, height * 0.18);
};
function animationLoop(ms) {
const t = ms / 1000;
const scene = scenes[activeScene];
scene.draw(t);
requestAnimationFrame(animationLoop);
}
updateScenePanel();
requestAnimationFrame(animationLoop);
</script>
</body>
</html>
@@ -0,0 +1,559 @@
<!-- ============================================================
思想实验室 · labs/three_body_journey · 穿越三体
基于刘慈欣《三体》三部曲的第一人称穿越思想实验。
单文件自包含(Canvas 2D,零依赖),不引用其他 lab 的任何资源。
场景顺序:地球出发 → 星际航行(想象过渡) → 三体星系三太阳(真实三体问题数值积分)
→ 水滴 → 太阳系二维化 → 小宇宙 → 回到地球。
每个场景附「真实科学 / 小说想象」双栏文字标注;想象内容明确文字说明。
服务端配置:src/server/thought_lab/labs/three_body_journey/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: #04060d; color: #c9d0e8; font-family: "Segoe UI", "Microsoft YaHei", sans-serif; height: 100vh; overflow: hidden; }
header {
position: fixed; top: 0; left: 0; right: 0; z-index: 10;
display: flex; align-items: center; gap: 14px; padding: 8px 18px;
background: rgba(6, 9, 20, .82); backdrop-filter: blur(6px);
border-bottom: 1px solid #1c2440;
}
header h1 { font-size: 15px; color: #e8f0ff; font-weight: 600; white-space: nowrap; }
header .sub { font-size: 11px; color: #55628a; }
#dots { display: flex; gap: 8px; margin-left: auto; }
#dots .dot {
width: 10px; height: 10px; border-radius: 50%; background: #232c4e; cursor: pointer;
border: 1px solid #2c3763; transition: all .2s;
}
#dots .dot.on { background: #6d84e8; box-shadow: 0 0 8px #6d84e8; }
#cv { position: fixed; inset: 0; display: block; }
#panel {
position: fixed; left: 0; right: 0; bottom: 0; z-index: 10;
max-height: 46vh; overflow-y: auto;
background: linear-gradient(180deg, rgba(6,9,20,.55), rgba(6,9,20,.94) 30%);
backdrop-filter: blur(8px); padding: 14px 22px 16px; border-top: 1px solid #1c2440;
}
#st-title { font-size: 14px; color: #ffd479; margin-bottom: 6px; }
#narr { font-size: 13px; line-height: 1.9; color: #d5ddf5; min-height: 3.8em; cursor: pointer; }
#sci { display: flex; gap: 18px; margin-top: 10px; flex-wrap: wrap; }
.sci-col { flex: 1; min-width: 280px; font-size: 11.5px; line-height: 1.7; }
.sci-col h4 { font-size: 11px; margin-bottom: 4px; letter-spacing: 1px; }
.sci-col.real h4 { color: #7ee0a3; }
.sci-col.imag h4 { color: #ffb0b0; }
.sci-col ul { list-style: none; }
.sci-col.real li { color: #9fd4b4; }
.sci-col.imag li { color: #d4a0a0; }
.sci-col li::before { content: "· "; }
#ctrl { position: fixed; right: 18px; bottom: 14px; z-index: 11; display: flex; gap: 10px; }
#ctrl button {
background: rgba(26,33,64,.9); color: #9db1ea; border: 1px solid #2c3763;
border-radius: 8px; padding: 8px 18px; font-size: 13px; cursor: pointer;
}
#ctrl button:hover { background: #232c52; color: #e8f0ff; }
#ctrl button:disabled { opacity: .4; cursor: default; }
</style>
</head>
<body>
<header>
<h1>🌌 穿越三体</h1>
<span class="sub">《三体》同人思想实验 · 真实与想象已分栏标注</span>
<div id="dots"></div>
</header>
<canvas id="cv"></canvas>
<div id="panel">
<div id="st-title"></div>
<div id="narr" title="点击跳过打字效果"></div>
<div id="sci">
<div class="sci-col real"><h4>真实科学</h4><ul id="sci-real"></ul></div>
<div class="sci-col imag"><h4>小说想象 / 本场景虚构</h4><ul id="sci-imag"></ul></div>
</div>
</div>
<div id="ctrl">
<button id="btn-prev" type="button">← 上一站</button>
<button id="btn-next" type="button">穿越 →</button>
</div>
<script>
'use strict';
// ==== THREEBODY_MATH_BEGIN ====
// 纯物理块:牛顿引力三体问题 RK4 积分器(被 dev_test_scripts/unit/test_three_body_integrator.js 抽取校验)
const TB_G = 1
const tbAccel = (pos, masses, eps2) => {
const a = [0, 0, 0, 0, 0, 0]
for (let i = 0; i < 3; i++) {
for (let j = 0; j < 3; j++) {
if (i === j) continue
const dx = pos[2 * j] - pos[2 * i], dy = pos[2 * j + 1] - pos[2 * i + 1]
const r2 = dx * dx + dy * dy + eps2
const inv = 1 / (r2 * Math.sqrt(r2))
a[2 * i] += TB_G * masses[j] * dx * inv
a[2 * i + 1] += TB_G * masses[j] * dy * inv
}
}
return a
}
const tbStep = (pos, vel, masses, dt, eps2) => {
const add = (a, b, s) => a.map((v, i) => v + b[i] * s)
const k1v = tbAccel(pos, masses, eps2)
const k1x = vel.slice()
const k2v = tbAccel(add(pos, k1x, dt / 2), masses, eps2)
const k2x = add(vel, k1v, dt / 2)
const k3v = tbAccel(add(pos, k2x, dt / 2), masses, eps2)
const k3x = add(vel, k2v, dt / 2)
const k4v = tbAccel(add(pos, k3x, dt), masses, eps2)
const k4x = add(vel, k3v, dt)
const np = pos.map((v, i) => v + dt / 6 * (k1x[i] + 2 * k2x[i] + 2 * k3x[i] + k4x[i]))
const nv = vel.map((v, i) => v + dt / 6 * (k1v[i] + 2 * k2v[i] + 2 * k3v[i] + k4v[i]))
return [np, nv]
}
const tbEnergy = (pos, vel, masses, eps2) => {
let ke = 0
for (let i = 0; i < 3; i++) ke += 0.5 * masses[i] * (vel[2 * i] * vel[2 * i] + vel[2 * i + 1] * vel[2 * i + 1])
let pe = 0
for (let i = 0; i < 3; i++) for (let j = i + 1; j < 3; j++) {
const dx = pos[2 * j] - pos[2 * i], dy = pos[2 * j + 1] - pos[2 * i + 1]
pe -= TB_G * masses[i] * masses[j] / Math.sqrt(dx * dx + dy * dy + eps2)
}
return ke + pe
}
const tbMomentum = (vel, masses) => {
let px = 0, py = 0
for (let i = 0; i < 3; i++) { px += masses[i] * vel[2 * i]; py += masses[i] * vel[2 * i + 1] }
return [px, py]
}
// ==== THREEBODY_MATH_END ====
// ============================================================
// 场景数据(文案:第一人称;科学标注分真实/想象)
// ============================================================
const STATIONS = [
{
id: 'earth', title: '第 1 站 · 地球 · 出发',
narr: '我是地球上的一个普通人。今夜我抬头,在南方星空里找它——半人马座α星,4.37 光年外。此刻进入我眼睛的光,是它 4.37 年前发出的。我就要出发去那里。这是穿越的起点,也是人类唯一已知的家园。',
real: [
'日地平均距离 1 天文单位 ≈ 1.5 亿 km,光行约 8.3 分钟;月地平均距离约 38.4 万 km,光行约 1.3 秒。',
'1 光年 ≈ 9.46 万亿 km;半人马座α星 A/B 距地球约 4.37 光年,是除比邻星外最近的恒星。',
'半人马座α是真实三合星系统:α A、α B 与比邻星(Proxima,约 4.246 光年)。'
],
imag: ['「我」这个当事人设定与整段穿越旅程,是虚构叙事。'],
warp: ''
},
{
id: 'voyage', title: '第 2 站 · 航行 · 星际穿越(过渡场景 · 想象)',
narr: '飞船加速到光速的 15%。星辰被拉成线,四十多年的航程被冬眠压缩成一场梦。我知道,无论飞船多快,都追不上光——光速是这个宇宙因果律的天花板。',
real: [
'真空光速 c ≈ 299,792 km/s;狭义相对论禁止静质量物体达到或超过 c。',
'以 0.15c 飞越 4.37 光年,地球参考系约需 29 年,飞船钟因时间膨胀略短。',
'真实存在的「突破摄星」(Breakthrough Starshot) 计划设想用激光阵列把克级探测器加速到 0.2c 飞向半人马座。'
],
imag: [
'本过渡场景为想象:冬眠飞船与星线视觉。',
'小说中的「曲率驱动」属想象;Alcubierre 度规只是广义相对论的一个理论解,需负能量,未被实现。'
],
warp: '离开太阳系。太阳在身后,渐渐成为一颗普通的亮星。'
},
{
id: 'trisolaris', title: '第 3 站 · 三体星系 · 三太阳与混沌纪元',
narr: '我抵达三体。三颗太阳在天上毫无规律地起舞:有时同升,有时尽落。这里的文明把规律的日子叫恒纪元,把混乱的叫乱纪元。我盯着轨道模拟器看——同样的引力定律,未来却不可预测。',
real: [
'画面是牛顿引力三体问题的实时数值积分(RK4),不是动画脚本;轨迹对初值极端敏感,即「混沌」。',
'三体问题无通用解析解(Poincaré,1889)。',
'真实原型:半人马座α A 约 1.1 M☉、B 约 0.9 M☉、比邻星约 0.12 M☉;比邻星 b 是已确认的候选宜居带行星(2016)。'
],
imag: [
'真实的半人马座α是稳定的分层三合星,不会出现小说式「乱纪元」。',
'三体文明、三体行星与「脱水」是小说想象。'
],
warp: '4.37 光年走完。前方,三颗太阳。'
},
{
id: 'droplet', title: '第 4 站 · 水滴 · 强相互作用材料',
narr: '它安静得像一滴夜里的眼泪。凑近时,我在它表面看见自己的倒影——完美光滑的镜面,连一个原子的起伏都没有。我记得它在末日战役里做过什么。美丽,且致命。',
real: [
'强相互作用是真实存在的基本力,把夸克束缚成质子和中子,作用范围约 10⁻¹⁵ m。',
'现实最光滑的表面(如单晶硅片)仍有原子尺度的起伏;绝对光滑不存在。'
],
imag: ['宏观尺度「锁死原子振动」的强相互作用材料、完美镜面与水滴本体,均为小说想象。'],
warp: '离开三体。深空里,我遇见了它。'
},
{
id: 'dual', title: '第 5 站 · 太阳系 · 二向箔',
narr: '我赶回太阳系,目睹终局。二向箔无声展开,行星一幅一幅跌入平面,摊成没有厚度的画。我看见地球变成一幅扁平的画,所有的山与海,成为平面上的纹路。',
real: [
'太阳系八大行星及顺序为真:水、金、地、火、木、土、天王、海王。',
'平面(二维空间)是严谨的数学对象;画面中的「跌落」是 3D→2D 的投影动画。'
],
imag: ['降维打击、二向箔、宏观物质跌入二维的物理,均为小说想象;现实中不存在把三维物质二维化的机制。'],
warp: '回程。我来不及阻止任何事。'
},
{
id: 'mini', title: '第 6 站 · 终点 · 小宇宙',
narr: '在宇宙的尽头,我进入一个小宇宙。它是独立于大宇宙的泡,有自己的星空与生态循环。泡外,大宇宙正在老去。我在这里留下一份记忆,等待归还质量的那一天。',
real: [
'可观测宇宙半径约 465 亿光年;宇宙膨胀是已被观测证实的事实(Ia 型超新星、CMB 等)。',
'宇宙的终极命运(永远膨胀或大坍缩)仍是开放问题。'
],
imag: ['小宇宙、归还质量、归零者,均为小说想象。'],
warp: '离开二维化的太阳系,去往时间尽头。'
},
{
id: 'home', title: '第 7 站 · 回到地球(过渡场景 · 想象)',
narr: '穿越结束,我回到地球。南天的星空还在,半人马座α星还在 4.37 光年外。所有经历过的,不过是一场思想实验——但物理定律是真的,好奇心也是。这,就是思想实验室的意义。',
real: ['地球仍是那个地球;本页出现的所有距离、光速、引力与混沌性质,均有观测或数学依据。'],
imag: ['回程与整个穿越闭环为想象。'],
warp: '归程,像一场梦。'
}
]
// ============================================================
// 渲染
// ============================================================
const cv = document.getElementById('cv'), ctx = cv.getContext('2d')
const DPR = Math.min(window.devicePixelRatio || 1, 2)
let W = 0, H = 0
const resize = () => {
W = cv.width = Math.round(innerWidth * DPR)
H = cv.height = Math.round(innerHeight * DPR)
cv.style.width = innerWidth + 'px'
cv.style.height = innerHeight + 'px'
}
addEventListener('resize', resize)
resize()
const mulberry32 = s => () => {
s |= 0; s = s + 0x6D2B79F5 | 0
let t = Math.imul(s ^ s >>> 15, 1 | s)
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t
return ((t ^ t >>> 14) >>> 0) / 4294967296
}
const rand = mulberry32(20260807)
const STARS = Array.from({ length: 240 }, () => ({ x: rand(), y: rand(), r: rand() * 1.3 + 0.3, tw: rand() * 6.28 }))
const drawStars = (t, dim = 1) => {
for (const s of STARS) {
const a = (0.35 + 0.65 * Math.abs(Math.sin(t * 0.8 + s.tw))) * dim
ctx.fillStyle = `rgba(200,215,255,${a})`
ctx.beginPath()
ctx.arc(s.x * W, s.y * H, s.r * DPR, 0, 6.283)
ctx.fill()
}
}
const glow = (x, y, r, color) => {
const g = ctx.createRadialGradient(x, y, 0, x, y, r)
g.addColorStop(0, color)
g.addColorStop(1, 'rgba(0,0,0,0)')
ctx.fillStyle = g
ctx.beginPath(); ctx.arc(x, y, r, 0, 6.283); ctx.fill()
}
// ---- 第 1/7 站:地球 ----
const drawEarth = (t, sunrise) => {
ctx.fillStyle = '#04060d'; ctx.fillRect(0, 0, W, H)
drawStars(t, sunrise ? 0.5 : 1)
// 太阳
glow(W * 0.12, H * 0.26, 60 * DPR, 'rgba(255,240,200,0.9)')
ctx.fillStyle = '#fff6dd'
ctx.beginPath(); ctx.arc(W * 0.12, H * 0.26, 5 * DPR, 0, 6.283); ctx.fill()
// 月球
ctx.fillStyle = '#b9bfd0'
ctx.beginPath(); ctx.arc(W * 0.8, H * 0.2, 7 * DPR, 0, 6.283); ctx.fill()
ctx.fillStyle = 'rgba(90,100,125,0.7)'
ctx.beginPath(); ctx.arc(W * 0.8 + 2.5 * DPR, H * 0.2 - 1.5 * DPR, 2.4 * DPR, 0, 6.283); ctx.fill()
// 地球弧
const R0 = H * 1.15, cy = H + R0 - H * 0.28
const g = ctx.createRadialGradient(W * 0.5, cy - R0, R0 * 0.9, W * 0.5, cy, R0)
g.addColorStop(0, sunrise ? '#3d6db5' : '#2a5aa8')
g.addColorStop(1, '#0a1c3d')
ctx.fillStyle = g
ctx.beginPath(); ctx.arc(W * 0.5, cy, R0, 0, 6.283); ctx.fill()
// 大气辉光
ctx.strokeStyle = sunrise ? 'rgba(255,170,90,0.8)' : 'rgba(110,170,255,0.7)'
ctx.lineWidth = 2.5 * DPR
ctx.beginPath(); ctx.arc(W * 0.5, cy, R0 + 2 * DPR, Math.PI * 1.15, Math.PI * 1.85); ctx.stroke()
if (sunrise) glow(W * 0.5, H * 0.74, H * 0.3, 'rgba(255,140,60,0.5)')
// 半人马座α标记
const ax = W * 0.7, ay = H * 0.16, p = 4 + 2 * Math.sin(t * 2.5)
ctx.strokeStyle = '#ffd479'; ctx.lineWidth = DPR
ctx.beginPath(); ctx.arc(ax, ay, p * DPR, 0, 6.283); ctx.stroke()
ctx.beginPath()
ctx.moveTo(ax - (p + 5) * DPR, ay); ctx.lineTo(ax - p * DPR, ay)
ctx.moveTo(ax + p * DPR, ay); ctx.lineTo(ax + (p + 5) * DPR, ay)
ctx.moveTo(ax, ay - (p + 5) * DPR); ctx.lineTo(ax, ay - p * DPR)
ctx.moveTo(ax, ay + p * DPR); ctx.lineTo(ax, ay + (p + 5) * DPR)
ctx.stroke()
ctx.fillStyle = '#ffd479'; ctx.font = `${11 * DPR}px "Microsoft YaHei"`; ctx.textAlign = 'left'
ctx.fillText('半人马座α · 4.37 光年', ax + (p + 8) * DPR, ay + 4 * DPR)
}
// ---- 第 2 站:航行 ----
const drawVoyage = (t, st) => {
ctx.fillStyle = '#04060d'; ctx.fillRect(0, 0, W, H)
const cx = W / 2, cy = H / 2
for (let i = 0; i < 160; i++) {
const a = (i / 160) * 6.283 + (i % 7) * 0.13
const sp = 0.25 + (i % 5) * 0.12
const r = ((t * sp + i * 0.061) % 1)
const r0 = r * Math.max(W, H) * 0.62
const len = (6 + r * 60) * DPR
ctx.strokeStyle = `rgba(180,200,255,${0.15 + r * 0.6})`
ctx.lineWidth = DPR
ctx.beginPath()
ctx.moveTo(cx + Math.cos(a) * r0, cy + Math.sin(a) * r0)
ctx.lineTo(cx + Math.cos(a) * (r0 + len), cy + Math.sin(a) * (r0 + len))
ctx.stroke()
}
// 飞船 + 光帆
ctx.strokeStyle = '#9db1ea'; ctx.lineWidth = 1.5 * DPR
ctx.beginPath()
ctx.moveTo(cx - 40 * DPR, cy + 60 * DPR); ctx.lineTo(cx + 40 * DPR, cy + 60 * DPR)
ctx.stroke()
ctx.fillStyle = '#2c3763'
ctx.beginPath()
ctx.moveTo(cx, cy + 78 * DPR); ctx.lineTo(cx - 10 * DPR, cy + 96 * DPR); ctx.lineTo(cx + 10 * DPR, cy + 96 * DPR)
ctx.closePath(); ctx.fill()
const d = Math.min(4.37, st * 0.35)
ctx.fillStyle = '#7f92c9'; ctx.font = `${12 * DPR}px Consolas, monospace`; ctx.textAlign = 'center'
ctx.fillText(`航程 ${d.toFixed(2)} / 4.37 光年 · 速度 0.15c`, cx, cy + 130 * DPR)
}
// ---- 第 3 站:三体(实时数值积分) ----
let tb = null
const initTB = () => {
const s3 = Math.sqrt(3) / 2
tb = {
pos: [1, 0, -0.5, s3, -0.5, -s3],
vel: [0, 0.42, -0.38, -0.2, 0.38, -0.22],
masses: [1, 1, 1],
eps2: 0.02,
trails: [[], [], []]
}
}
const TB_COLORS = ['#ffd76a', '#ff9a4d', '#ff6a6a']
const drawTrisolaris = (t, dt) => {
if (!tb) initTB()
const sub = 6, h = 0.004
for (let k = 0; k < sub; k++) [tb.pos, tb.vel] = tbStep(tb.pos, tb.vel, tb.masses, h, tb.eps2)
ctx.fillStyle = 'rgba(4,6,13,0.35)'; ctx.fillRect(0, 0, W, H)
drawStars(t, 0.4)
let maxR = 0.5
for (let i = 0; i < 3; i++) maxR = Math.max(maxR, Math.hypot(tb.pos[2 * i], tb.pos[2 * i + 1]))
const S = Math.min(W, H) * 0.34 / maxR
const cx = W / 2, cy = H * 0.42
for (let i = 0; i < 3; i++) {
const x = cx + tb.pos[2 * i] * S, y = cy + tb.pos[2 * i + 1] * S
const tr = tb.trails[i]
tr.push([x, y])
if (tr.length > 220) tr.shift()
ctx.strokeStyle = TB_COLORS[i] + '55'
ctx.lineWidth = DPR
ctx.beginPath()
tr.forEach((p, k) => k === 0 ? ctx.moveTo(p[0], p[1]) : ctx.lineTo(p[0], p[1]))
ctx.stroke()
glow(x, y, 26 * DPR, TB_COLORS[i] + 'aa')
ctx.fillStyle = TB_COLORS[i]
ctx.beginPath(); ctx.arc(x, y, 6 * DPR, 0, 6.283); ctx.fill()
}
ctx.fillStyle = '#55628a'; ctx.font = `${11 * DPR}px "Microsoft YaHei"`; ctx.textAlign = 'left'
ctx.fillText('实时数值积分 · 牛顿引力三体问题 · 初值敏感(混沌)', 16 * DPR, 24 * DPR + 40 * DPR)
}
// ---- 第 4 站:水滴 ----
const drawDroplet = (t) => {
ctx.fillStyle = '#04060d'; ctx.fillRect(0, 0, W, H)
drawStars(t)
const cx = W / 2, cy = H * 0.42, R = Math.min(W, H) * 0.16
ctx.save()
ctx.translate(cx, cy)
ctx.rotate(Math.sin(t * 0.25) * 0.15)
// 泪滴形
const g = ctx.createLinearGradient(-R, -R * 0.7, R, R * 0.7)
g.addColorStop(0, '#f2f6ff')
g.addColorStop(0.45, '#8b96ad')
g.addColorStop(1, '#39415a')
ctx.fillStyle = g
ctx.beginPath()
ctx.moveTo(R * 1.25, 0)
ctx.bezierCurveTo(R * 0.7, -R * 0.62, -R * 0.35, -R * 0.72, -R * 0.75, -R * 0.3)
ctx.bezierCurveTo(-R * 1.05, 0, -R * 1.05, 0, -R * 0.75, R * 0.3)
ctx.bezierCurveTo(-R * 0.35, R * 0.72, R * 0.7, R * 0.62, R * 1.25, 0)
ctx.closePath()
ctx.fill()
// 镜面高光
ctx.fillStyle = 'rgba(255,255,255,0.85)'
ctx.beginPath(); ctx.ellipse(-R * 0.25, -R * 0.28, R * 0.34, R * 0.12, -0.5, 0, 6.283); ctx.fill()
// 映射的星点(镜面感)
ctx.fillStyle = 'rgba(220,230,255,0.8)'
for (let i = 0; i < 6; i++) {
const a = t * 0.1 + i * 1.05
ctx.beginPath(); ctx.arc(Math.cos(a) * R * 0.4, Math.sin(a) * R * 0.22, DPR, 0, 6.283); ctx.fill()
}
ctx.restore()
ctx.fillStyle = '#55628a'; ctx.font = `${11 * DPR}px "Microsoft YaHei"`; ctx.textAlign = 'center'
ctx.fillText('「像一滴圣母的眼泪」—— 完美光滑,是想象', cx, cy + R * 1.6)
}
// ---- 第 5 站:二向箔 ----
const DUAL_PLANETS = [
{ n: '水星', c: '#c8a06a', r: 3, x: 0.16, y: 0.14 },
{ n: '金星', c: '#e8c46a', r: 5, x: 0.32, y: 0.22 },
{ n: '地球', c: '#6aa0e8', r: 5, x: 0.5, y: 0.16 },
{ n: '火星', c: '#e87a4d', r: 4, x: 0.66, y: 0.24 },
{ n: '木星', c: '#e8b088', r: 10, x: 0.82, y: 0.15 },
{ n: '土星', c: '#e8d0a0', r: 8, x: 0.42, y: 0.34 }
]
const drawDual = (t, st) => {
ctx.fillStyle = '#04060d'; ctx.fillRect(0, 0, W, H)
drawStars(t, 0.5)
const planeY = H * (1.02 - Math.min(1, st / 9) * 0.86)
// 二向箔平面(发光带 + 透视网格)
const pg = ctx.createLinearGradient(0, planeY - 30 * DPR, 0, H)
pg.addColorStop(0, 'rgba(140,220,255,0.55)')
pg.addColorStop(1, 'rgba(40,90,160,0.12)')
ctx.fillStyle = pg
ctx.fillRect(0, planeY, W, H - planeY)
ctx.strokeStyle = 'rgba(160,230,255,0.5)'; ctx.lineWidth = DPR
for (let i = 1; i < 8; i++) {
const y = planeY + (H - planeY) * (i / 8) * (i / 8)
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke()
}
for (let i = 0; i <= 12; i++) {
const x0 = (i / 12) * W
ctx.beginPath(); ctx.moveTo(W / 2 + (x0 - W / 2) * 0.55, planeY); ctx.lineTo(x0, H); ctx.stroke()
}
// 行星:平面之上为球,触及平面后摊成画
for (const p of DUAL_PLANETS) {
const px = p.x * W, py = p.y * H, pr = p.r * DPR
if (py < planeY) {
ctx.fillStyle = p.c
ctx.beginPath(); ctx.arc(px, py, pr, 0, 6.283); ctx.fill()
ctx.fillStyle = '#7f92c9'; ctx.font = `${10 * DPR}px "Microsoft YaHei"`; ctx.textAlign = 'center'
ctx.fillText(p.n, px, py - pr - 6 * DPR)
} else {
const flat = Math.min(1, (st - (1.02 - p.y) / 0.86 * 9) * 1.5 + 0.4)
ctx.fillStyle = p.c + 'cc'
ctx.beginPath(); ctx.ellipse(px, planeY + 4 * DPR, pr * (2 + flat * 2), pr * 0.35, 0, 0, 6.283); ctx.fill()
ctx.strokeStyle = 'rgba(200,240,255,0.6)'
ctx.beginPath(); ctx.ellipse(px, planeY + 4 * DPR, pr * (2 + flat * 2), pr * 0.35, 0, 0, 6.283); ctx.stroke()
}
}
ctx.fillStyle = '#ffd479'; ctx.font = `${11 * DPR}px "Microsoft YaHei"`; ctx.textAlign = 'left'
ctx.fillText('二向箔展开中 · 3D→2D 投影动画(想象)', 16 * DPR, H * 0.08)
}
// ---- 第 6 站:小宇宙 ----
const drawMini = (t) => {
ctx.fillStyle = '#04060d'; ctx.fillRect(0, 0, W, H)
drawStars(t, 0.25)
const cx = W / 2, cy = H * 0.42, R = Math.min(W, H) * 0.24 * (1 + 0.03 * Math.sin(t * 1.2))
// 泡内星空
ctx.save()
ctx.beginPath(); ctx.arc(cx, cy, R, 0, 6.283); ctx.clip()
ctx.fillStyle = '#0a1024'; ctx.fillRect(cx - R, cy - R, R * 2, R * 2)
for (let i = 0; i < 60; i++) {
const s = STARS[i]
ctx.fillStyle = `rgba(200,215,255,${0.4 + 0.5 * Math.abs(Math.sin(t + s.tw))})`
ctx.beginPath(); ctx.arc(cx + (s.x - 0.5) * 2 * R * 0.9, cy + (s.y - 0.5) * 2 * R * 0.9, s.r * DPR, 0, 6.283); ctx.fill()
}
ctx.fillStyle = '#6aa0e8'
ctx.beginPath(); ctx.arc(cx + R * 0.3, cy + R * 0.25, 4 * DPR, 0, 6.283); ctx.fill()
ctx.restore()
ctx.strokeStyle = 'rgba(140,220,255,0.9)'; ctx.lineWidth = 2 * DPR
ctx.beginPath(); ctx.arc(cx, cy, R, 0, 6.283); ctx.stroke()
glow(cx, cy, R * 1.5, 'rgba(90,140,255,0.12)')
ctx.fillStyle = '#55628a'; ctx.font = `${11 * DPR}px "Microsoft YaHei"`; ctx.textAlign = 'center'
ctx.fillText('小宇宙 · 独立于大宇宙的泡(想象)', cx, cy + R + 24 * DPR)
}
const DRAW = {
earth: (t, st) => drawEarth(t, false),
voyage: drawVoyage,
trisolaris: drawTrisolaris,
droplet: (t) => drawDroplet(t),
dual: drawDual,
mini: (t) => drawMini(t),
home: (t) => drawEarth(t, true)
}
// ============================================================
// 状态机 / 面板 / 交互
// ============================================================
let idx = 0, stT = 0, narrChars = 0, warp = null
const $ = id => document.getElementById(id)
const renderDots = () => {
const box = $('dots')
box.innerHTML = ''
STATIONS.forEach((s, i) => {
const d = document.createElement('div')
d.className = 'dot' + (i === idx ? ' on' : '')
d.title = s.title
d.onclick = () => { if (!warp) enter(i) }
box.appendChild(d)
})
}
const renderPanel = () => {
const s = STATIONS[idx]
$('st-title').textContent = s.title
$('sci-real').innerHTML = s.real.map(x => `<li>${x}</li>`).join('')
$('sci-imag').innerHTML = s.imag.map(x => `<li>${x}</li>`).join('')
narrChars = 0
$('btn-prev').disabled = idx === 0
$('btn-next').disabled = idx === STATIONS.length - 1
renderDots()
}
const enter = i => {
idx = i; stT = 0
if (STATIONS[i].id === 'trisolaris') initTB()
renderPanel()
}
$('btn-next').onclick = () => { if (!warp && idx < STATIONS.length - 1) warp = { t: 0, target: idx + 1 } }
$('btn-prev').onclick = () => { if (!warp && idx > 0) enter(idx - 1) }
addEventListener('keydown', e => {
if (e.key === 'ArrowRight') $('btn-next').onclick()
if (e.key === 'ArrowLeft') $('btn-prev').onclick()
})
$('narr').onclick = () => { narrChars = STATIONS[idx].narr.length }
// ---- 主循环 ----
let last = performance.now(), T = 0
const loop = now => {
const dt = Math.min(0.05, (now - last) / 1000)
last = now; T += dt; stT += dt
if (warp) {
warp.t += dt
// 穿越星线
ctx.fillStyle = '#04060d'; ctx.fillRect(0, 0, W, H)
const cx = W / 2, cy = H / 2, k = Math.min(1, warp.t / 1.6)
for (let i = 0; i < 200; i++) {
const a = (i / 200) * 6.283
const r0 = ((T * (0.4 + (i % 7) * 0.1) + i * 0.037) % 1) * Math.max(W, H) * 0.7
const len = (10 + k * 140) * DPR
ctx.strokeStyle = `rgba(180,200,255,${0.2 + k * 0.7})`
ctx.lineWidth = DPR
ctx.beginPath()
ctx.moveTo(cx + Math.cos(a) * r0, cy + Math.sin(a) * r0)
ctx.lineTo(cx + Math.cos(a) * (r0 + len), cy + Math.sin(a) * (r0 + len))
ctx.stroke()
}
ctx.fillStyle = '#ffd479'; ctx.font = `${14 * DPR}px "Microsoft YaHei"`; ctx.textAlign = 'center'
ctx.fillText(STATIONS[warp.target].warp, cx, cy - 20 * DPR)
ctx.fillStyle = '#55628a'; ctx.font = `${11 * DPR}px "Microsoft YaHei"`
ctx.fillText('(过渡场景 · 想象)', cx, cy + 8 * DPR)
if (warp.t > 1.8) { const tg = warp.target; warp = null; enter(tg) }
} else {
DRAW[STATIONS[idx].id](T, stT, dt)
}
// 打字机
narrChars = Math.min(STATIONS[idx].narr.length, narrChars + dt * 40)
$('narr').textContent = STATIONS[idx].narr.slice(0, Math.floor(narrChars))
requestAnimationFrame(loop)
}
enter(0)
requestAnimationFrame(loop)
</script>
</body>
</html>
+11 -1
View File
@@ -2514,13 +2514,23 @@ const hasValidPrivateClipboardGuid = (req) => {
return incoming === value return incoming === value
} catch { return false } } catch { return false }
} }
const hasPrivateClipboardBusinessAuth = (req) => {
try {
const mod = require('./private_clipboard')
if (mod && typeof mod.hasBusinessAuth === 'function') return !!mod.hasBusinessAuth(req)
} catch {}
return false
}
const hasPrivateClipboardAuth = (req) => { const hasPrivateClipboardAuth = (req) => {
try { try {
const cfg = readPrivateClipboardAuthConfig() const cfg = readPrivateClipboardAuthConfig()
if (cfg.enable_auth === false) return true if (cfg.enable_auth === false) return true
const cookies = parseCookie(req.headers.cookie || '') const cookies = parseCookie(req.headers.cookie || '')
const name = getPrivateClipboardCookieName() const name = getPrivateClipboardCookieName()
return cookies[name] === '1' if (cookies[name] === '1') return true
// Rule 12.10:第4层业务登录态(HMAC 签名校验通过)同样视为外层有效授权,
// 避免 gate cookie 过期后已登录用户“贴和传没有响应”
return hasPrivateClipboardBusinessAuth(req)
} catch { return false } } catch { return false }
} }
const isPrivateClipboardForceGuid = () => { const isPrivateClipboardForceGuid = () => {
+1 -1
View File
@@ -696,4 +696,4 @@ const createRouter = () => {
return router return router
} }
module.exports = { createRouter } module.exports = { createRouter, hasBusinessAuth: hasAuth }
@@ -0,0 +1,15 @@
{
"_comment": "思想实验室 · 四维空间实验室:第四空间维的数学可视化(科学教研用)。正多胞体(5/8/16/24/600-胞体)严格坐标 + 六旋转平面 + 等倾双旋转的 4D→3D→2D 透视投影;四维超球被三维空间扫过的切片动画(r=√(R²−w²));维度类比表。所有顶点/边数由代码实算,页面明确标注科学边界:观测空间为 3 维,额外空间维仅见于弦论/卡鲁扎-克莱因且紧致于普朗克尺度,本 lab 是数学可视化工具。本 lab 完全独立;未来若需要数据库、OSS 等后台逻辑,统一放在本文件夹内。",
"id_comment": "lab 唯一标识(snake_case),与文件夹名保持一致",
"id": "four_dim_space",
"name_comment": "首页卡片显示名称",
"name": "四维空间实验室",
"icon_comment": "首页卡片图标(emoji)",
"icon": "💠",
"desc_comment": "一句话简介,作为卡片 tooltip 展示",
"desc": "正多胞体旋转投影 · 超球切片 · 维度类比",
"sort_order_comment": "卡片排序,数值越小越靠前",
"sort_order": 70,
"enabled_comment": "是否启用(false 时列表接口不返回该 lab)",
"enabled": true
}
@@ -0,0 +1,15 @@
{
"_comment": "思想实验室 · 人性与投资:长卷式培训网页。用通俗方式讲现金与机会、六种投资风格、人性弱点、纪律节奏和培训场景。强调金融常识,不做荐股,不做收益承诺;涉及名人时附书籍或公开来源线索。本 lab 完全独立;未来若需要后台逻辑,统一放在本文件夹内。",
"id_comment": "lab 唯一标识(snake_case),与文件夹名保持一致",
"id": "humanity_and_investing",
"name_comment": "首页卡片显示名称",
"name": "人性与投资",
"icon_comment": "首页卡片图标(emoji)",
"icon": "📈",
"desc_comment": "一句话简介,作为卡片 tooltip 展示",
"desc": "长卷培训页 · 投资风格 · 人性镜子 · 纪律节奏",
"sort_order_comment": "卡片排序,数值越小越靠前",
"sort_order": 90,
"enabled_comment": "是否启用(false 时列表接口不返回该 lab)",
"enabled": true
}
@@ -0,0 +1,15 @@
{
"_comment": "思想实验室 · 怀旧弹珠台:HTML + Canvas 2D 模拟老式桌面弹珠台。竖向台面,右下弹簧拉杆发射玻璃弹珠,盘面密布圆形钉子,左下 6 个落球坑计分,支持重力、台面偏坡、碰撞弹性、空气阻尼、碰撞摩擦、发射弹力、钉子尺寸与密度微调。本 lab 完全独立;未来若需要后台逻辑,统一放在本文件夹内。",
"id_comment": "lab 唯一标识(snake_case),与文件夹名保持一致",
"id": "retro_pinball",
"name_comment": "首页卡片显示名称",
"name": "怀旧弹珠台",
"icon_comment": "首页卡片图标(emoji)",
"icon": "🎯",
"desc_comment": "一句话简介,作为卡片 tooltip 展示",
"desc": "桌面弹珠台 · 可调参数 · 玻璃弹珠碰撞模拟",
"sort_order_comment": "卡片排序,数值越小越靠前",
"sort_order": 96,
"enabled_comment": "是否启用(false 时列表接口不返回该 lab)",
"enabled": true
}
@@ -0,0 +1,15 @@
{
"_comment": "思想实验室 · 穿越三体:基于刘慈欣《三体》三部曲的第一人称穿越思想实验。从地球出发,依次经历星际航行、三体星系三太阳(真实三体问题实时数值积分,展示混沌)、水滴、二向箔二维化、小宇宙,最终回到地球。每个场景附『真实科学 / 小说想象』双栏文字标注,过渡场景为 imagination 并明确文字说明。本 lab 完全独立;未来若需要数据库、OSS 等后台逻辑,统一放在本文件夹内。",
"id_comment": "lab 唯一标识(snake_case),与文件夹名保持一致",
"id": "three_body_journey",
"name_comment": "首页卡片显示名称",
"name": "穿越三体",
"icon_comment": "首页卡片图标(emoji)",
"icon": "🌌",
"desc_comment": "一句话简介,作为卡片 tooltip 展示",
"desc": "第一人称穿越 · 三体场景 · 真实/想象标注",
"sort_order_comment": "卡片排序,数值越小越靠前",
"sort_order": 80,
"enabled_comment": "是否启用(false 时列表接口不返回该 lab)",
"enabled": true
}