Files
Toolbox/public/tools/thought_lab/labs/three_body_journey/index.html
T
yangxiangyuan 4492b4106a feat: 新增3个思想实验室项目,完善私有剪贴板授权与上传功能,添加配套测试脚本
1. 新增怀旧弹珠台、人性与投资、穿越三体、四维空间四个思想实验室项目
2. 为私有剪贴板添加业务层授权校验,优化上传进度显示与鉴权失败处理
3. 新增多个调试与单元测试脚本,覆盖三体积分、弹珠台物理、集成测试等场景
4. 补全穿越三体项目的完整HTML页面资源
2026-08-07 17:49:24 +08:00

560 lines
25 KiB
HTML

<!-- ============================================================
思想实验室 · 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>