4492b4106a
1. 新增怀旧弹珠台、人性与投资、穿越三体、四维空间四个思想实验室项目 2. 为私有剪贴板添加业务层授权校验,优化上传进度显示与鉴权失败处理 3. 新增多个调试与单元测试脚本,覆盖三体积分、弹珠台物理、集成测试等场景 4. 补全穿越三体项目的完整HTML页面资源
560 lines
25 KiB
HTML
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>
|