feat: 新增3个思想实验室项目,完善私有剪贴板授权与上传功能,添加配套测试脚本
1. 新增怀旧弹珠台、人性与投资、穿越三体、四维空间四个思想实验室项目 2. 为私有剪贴板添加业务层授权校验,优化上传进度显示与鉴权失败处理 3. 新增多个调试与单元测试脚本,覆盖三体积分、弹珠台物理、集成测试等场景 4. 补全穿越三体项目的完整HTML页面资源
This commit is contained in:
@@ -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>0,冷色 = w<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>红区(>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>
|
||||
Reference in New Issue
Block a user