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