让 Claude 写 3D 游戏,写出来了,还把我上了一课。这篇有完整的冤案时间线、全部验收数据,文末附两个游戏的完整代码——各存成一个 html 文件,浏览器打开就能玩,零安装。
▶ 视频直达:https://youtu.be/AHMQkPrmVD4
第一幕:简单题过了七项验收,却根本没法玩
题目是 three.js 单文件 3D 跑酷,5 分钟交卷、一把能跑。十项可判定验收单过了七项——但障碍物贴脸才出现,玩家毫无反应时间。这个致命缺陷,十项验收单一项都没抓住:能量化的都在单子上,可玩性恰恰量不出来。把问题说给它,第二轮四条全修对。
第二幕:无缝行星着陆,它一把写对了
上难度:程序化星系、七颗行星互不重复、看中哪颗直接降落,无加载无黑屏。这是公认的图形学硬骨头。587 行代码、11 分钟交卷,穿过大气层画面没有任何切换——第一把就写对了。
第三幕:冤案
它自报帧率 120,我实测 14.4,差 8 倍。我判它吹牛,连续打回两轮。第三轮它没有改代码——它在游戏里加了 drawCalls / resScale 探针,反过来说:「如果分辨率降到底帧率还不动,问题就在你的测试环境。」
一查,我的验收浏览器 WebGL 渲染器是 SwiftShader(纯 CPU 软件渲染)。换真机 GPU(Apple M5 Max / Metal)重测:三个版本全部 120fps 满帧。它报的数字从头到尾一字不差,说谎的是我的测量环境。
它留下的话:「数字对不上的时候,别急着改代码——先验你的测量环境,它也是系统的一部分。」
冤案的意外收获:被冤出来的三轮优化,让游戏在纯软渲染环境也能跑 33fps——老电脑直接受益。
游戏一:3D 跑酷(Round 2 终版)
把下面整段代码存为 game.html,浏览器打开即玩。← → 或 A/D 换道。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>跑酷小游戏</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
html, body { margin:0; padding:0; overflow:hidden; width:100%; height:100%; background:#0a0e1a; font-family:"Microsoft YaHei","PingFang SC",Arial,sans-serif; }
#canvas-wrap { position:fixed; inset:0; }
canvas { display:block; }
#hud {
position:fixed; top:18px; left:18px; color:#fff; z-index:10;
text-shadow:0 2px 6px rgba(0,0,0,.6);
user-select:none; pointer-events:none;
}
#hud .score { font-size:38px; font-weight:700; letter-spacing:1px; }
#hud .sub { font-size:15px; opacity:.85; margin-top:4px; }
#hint {
position:fixed; bottom:22px; left:50%; transform:translateX(-50%);
color:#fff; font-size:14px; opacity:.75; z-index:10;
text-shadow:0 2px 6px rgba(0,0,0,.6); user-select:none; pointer-events:none;
background:rgba(0,0,0,.25); padding:8px 16px; border-radius:20px;
backdrop-filter: blur(2px);
}
#overlay {
position:fixed; inset:0; display:none; align-items:center; justify-content:center;
flex-direction:column; background:rgba(5,8,16,.72); z-index:20;
backdrop-filter: blur(3px);
}
#overlay.show { display:flex; }
#overlay h1 { color:#ff5c5c; font-size:44px; margin:0 0 6px; letter-spacing:2px; text-shadow:0 4px 14px rgba(255,60,60,.4); }
#overlay .final-score { color:#fff; font-size:22px; margin:10px 0 26px; opacity:.9; }
#overlay .final-score b { font-size:34px; color:#ffd35c; display:block; margin-top:4px; }
#restart-btn {
padding:14px 42px; font-size:18px; font-weight:700; color:#0a0e1a;
background:linear-gradient(135deg,#ffd35c,#ff9d5c); border:none; border-radius:30px;
cursor:pointer; box-shadow:0 8px 20px rgba(255,157,92,.35); transition:transform .15s ease;
}
#restart-btn:hover { transform:scale(1.05); }
#restart-btn:active { transform:scale(0.97); }
#start-overlay {
position:fixed; inset:0; display:flex; align-items:center; justify-content:center;
flex-direction:column; background:rgba(5,8,16,.55); z-index:15;
}
#start-overlay h1 { color:#fff; font-size:32px; margin:0 0 10px; letter-spacing:2px; }
#start-overlay p { color:#cfd6e6; font-size:15px; margin:4px 0; }
</style>
</head>
<body>
<div id="canvas-wrap"></div>
<div id="hud">
<div class="score">距离 <span id="score-val">0</span> m</div>
<div class="sub">速度 <span id="speed-val">0</span> m/s</div>
</div>
<div id="hint">← → 或 A / D 换道</div>
<div id="start-overlay">
<h1>跑酷小游戏</h1>
<p>← → 或 A / D 键切换跑道,躲开障碍物</p>
<p style="margin-top:14px;opacity:.7;">按任意键或点击开始</p>
</div>
<div id="overlay">
<h1>游戏结束</h1>
<div class="final-score">本次存活距离<b><span id="final-score">0</span> m</b></div>
<button id="restart-btn">重新开始</button>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script>
(function () {
"use strict";
// ---------------------------------------------------------------------
// 基础常量
// ---------------------------------------------------------------------
const LANE_WIDTH = 2.6;
const LANE_X = [-LANE_WIDTH, 0, LANE_WIDTH]; // 0=左 1=中 2=右
const BASE_SPEED = 11;
const MAX_SPEED = 34;
const SPAWN_Z = -140; // 障碍物生成位置
const REMOVE_Z = 9; // 越过相机后回收
const PLAYER_Z_FRONT = 0.55; // 玩家碰撞带(前)
const PLAYER_Z_BACK = -0.55; // 玩家碰撞带(后)
// 雾效范围:修复"障碍物贴脸才出现"——near/far 都大幅推远,
// 保证障碍物在 MAX_SPEED 下距玩家 >=60 世界单位处就已完全无雾、清晰可辨,
// 60 / 34 ≈ 1.76s,满足 >=1.5s 可视反应时间要求(留有余量)。
const FOG_NEAR = 80;
const FOG_FAR = 170;
// ---------------------------------------------------------------------
// 场景 / 相机 / 渲染器
// ---------------------------------------------------------------------
const scene = new THREE.Scene();
const skyColor = 0x9fd3ff;
scene.background = new THREE.Color(skyColor);
scene.fog = new THREE.Fog(skyColor, FOG_NEAR, FOG_FAR);
const camera = new THREE.PerspectiveCamera(58, window.innerWidth / window.innerHeight, 0.1, 320);
const CAM_OFFSET = new THREE.Vector3(0, 4.4, 7.4);
const CAM_LOOK_OFFSET = new THREE.Vector3(0, 1.3, -6);
camera.position.copy(CAM_OFFSET);
// 性能优化:关闭抗锯齿、像素比锁定为 1、阴影贴图降规格——
// 实测 65s 中位数仅 28fps(要求 >=30),这三项是渲染开销大头,优先砍。
const renderer = new THREE.WebGLRenderer({ antialias: false });
renderer.setPixelRatio(1);
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFShadowMap; // 比 PCFSoftShadowMap 更省
document.getElementById('canvas-wrap').appendChild(renderer.domElement);
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
// ---------------------------------------------------------------------
// 灯光
// ---------------------------------------------------------------------
const hemiLight = new THREE.HemisphereLight(0xbfe3ff, 0x2b3a2b, 0.75);
scene.add(hemiLight);
const ambient = new THREE.AmbientLight(0xffffff, 0.25);
scene.add(ambient);
const sunLight = new THREE.DirectionalLight(0xfff2d8, 1.05);
sunLight.position.set(-8, 16, 8);
sunLight.castShadow = true;
sunLight.shadow.mapSize.set(1024, 1024); // 由 1536 降至 1024,减少阴影渲染开销
sunLight.shadow.camera.left = -14;
sunLight.shadow.camera.right = 14;
sunLight.shadow.camera.top = 14;
sunLight.shadow.camera.bottom = -14;
sunLight.shadow.camera.near = 1;
sunLight.shadow.camera.far = 40;
sunLight.shadow.bias = -0.0015;
scene.add(sunLight);
scene.add(sunLight.target);
sunLight.target.position.set(0, 0, -4);
// ---------------------------------------------------------------------
// 地面(程序化贴图,带跑道分隔线 + 颗粒质感)
// ---------------------------------------------------------------------
function makeGroundTexture() {
const w = 256, h = 512;
const c = document.createElement('canvas');
c.width = w; c.height = h;
const ctx = c.getContext('2d');
ctx.fillStyle = '#3c4348';
ctx.fillRect(0, 0, w, h);
// 颗粒噪点,做出沥青质感
for (let i = 0; i < 3200; i++) {
const x = Math.random() * w, y = Math.random() * h;
const v = Math.random() * 26 - 13;
ctx.fillStyle = `rgba(${20 + v},${22 + v},${25 + v},0.5)`;
ctx.fillRect(x, y, 1.4, 1.4);
}
// 三条跑道的分隔虚线
ctx.strokeStyle = 'rgba(255,255,255,0.85)';
ctx.lineWidth = 4;
ctx.setLineDash([22, 20]);
const laneLineX = [w / 3, (w / 3) * 2];
laneLineX.forEach((x) => {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, h);
ctx.stroke();
});
// 两侧边缘实线
ctx.setLineDash([]);
ctx.strokeStyle = 'rgba(255,210,90,0.9)';
ctx.lineWidth = 6;
ctx.beginPath(); ctx.moveTo(6, 0); ctx.lineTo(6, h); ctx.stroke();
ctx.beginPath(); ctx.moveTo(w - 6, 0); ctx.lineTo(w - 6, h); ctx.stroke();
const tex = new THREE.CanvasTexture(c);
tex.wrapS = THREE.RepeatWrapping;
tex.wrapT = THREE.RepeatWrapping;
tex.repeat.set(1, 26);
return tex;
}
const groundTexture = makeGroundTexture();
const groundGeo = new THREE.PlaneGeometry(LANE_WIDTH * 3 + 1.4, 400, 1, 1);
const groundMat = new THREE.MeshStandardMaterial({ map: groundTexture, roughness: 0.95, metalness: 0.02 });
const ground = new THREE.Mesh(groundGeo, groundMat);
ground.rotation.x = -Math.PI / 2;
ground.position.set(0, 0, -160);
ground.receiveShadow = true;
scene.add(ground);
// 护栏纹理(发光竖条,随速度滚动,强化速度感)
function makeRailTexture() {
const w = 32, h = 256;
const c = document.createElement('canvas');
c.width = w; c.height = h;
const ctx = c.getContext('2d');
ctx.fillStyle = '#20242c';
ctx.fillRect(0, 0, w, h);
ctx.fillStyle = '#ff9d3d';
ctx.fillRect(0, 0, w, 14);
ctx.fillRect(0, 128, w, 14);
const tex = new THREE.CanvasTexture(c);
tex.wrapS = THREE.RepeatWrapping;
tex.wrapT = THREE.RepeatWrapping;
tex.repeat.set(1, 40);
return tex;
}
const railTexture = makeRailTexture();
const railGeo = new THREE.BoxGeometry(0.32, 0.7, 400);
const railMat = new THREE.MeshStandardMaterial({ map: railTexture, roughness: 0.6, metalness: 0.3 });
const railX = LANE_WIDTH * 1.5 + 0.35;
[-railX, railX].forEach((x) => {
const rail = new THREE.Mesh(railGeo, railMat);
rail.position.set(x, 0.35, -160);
// 护栏不参与投射阴影(体积大、贯穿全场,投影开销高且视觉收益低),只接收阴影
rail.castShadow = false;
rail.receiveShadow = true;
scene.add(rail);
});
// ---------------------------------------------------------------------
// 主角:可辨识的低多边形跑者(非方块)
// ---------------------------------------------------------------------
function buildPlayer() {
const group = new THREE.Group();
const skinMat = new THREE.MeshStandardMaterial({ color: 0xf0b48a, roughness: 0.6 });
const jacketMat = new THREE.MeshStandardMaterial({ color: 0x2e6fd6, roughness: 0.5, metalness: 0.1 });
const pantsMat = new THREE.MeshStandardMaterial({ color: 0x2b2f3a, roughness: 0.7 });
const shoeMat = new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 0.5 });
const capMat = new THREE.MeshStandardMaterial({ color: 0xe3453b, roughness: 0.5 });
const scarfMat = new THREE.MeshStandardMaterial({ color: 0xffd35c, roughness: 0.5, side: THREE.DoubleSide });
// 髋部(整体高度基准)
const hips = new THREE.Group();
hips.position.y = 0.92;
group.add(hips);
// 躯干(分段数由 14 降至 10,视觉几乎无差别)
const torso = new THREE.Mesh(new THREE.CylinderGeometry(0.30, 0.24, 0.62, 10), jacketMat);
torso.position.y = 0.66;
torso.castShadow = true;
hips.add(torso);
// 头(分段数由 16x14 降至 12x10)
const head = new THREE.Mesh(new THREE.SphereGeometry(0.26, 12, 10), skinMat);
head.position.y = 1.28;
head.castShadow = true;
hips.add(head);
// 帽子(识别度:红色小帽;装饰件,不投影以省开销)
const cap = new THREE.Mesh(new THREE.SphereGeometry(0.27, 12, 8, 0, Math.PI * 2, 0, Math.PI / 2.1), capMat);
cap.position.y = 1.34;
hips.add(cap);
const capBrim = new THREE.Mesh(new THREE.CylinderGeometry(0.16, 0.16, 0.05, 10), capMat);
capBrim.position.set(0, 1.28, 0.22);
capBrim.rotation.x = Math.PI / 2.4;
hips.add(capBrim);
// 围巾(飘动,增强速度感与识别度;装饰件,不投影)
const scarf = new THREE.Mesh(new THREE.PlaneGeometry(0.34, 0.5, 1, 4), scarfMat);
scarf.position.set(0, 0.95, -0.22);
scarf.rotation.x = 0.3;
hips.add(scarf);
// 手臂(挂点用于摆动动画)
function makeLimb(mat, len, radiusTop, radiusBottom) {
const pivot = new THREE.Group();
const mesh = new THREE.Mesh(new THREE.CylinderGeometry(radiusTop, radiusBottom, len, 8), mat);
mesh.position.y = -len / 2;
mesh.castShadow = true;
pivot.add(mesh);
return pivot;
}
const leftArm = makeLimb(jacketMat, 0.5, 0.085, 0.07);
leftArm.position.set(-0.36, 0.92, 0);
hips.add(leftArm);
const rightArm = makeLimb(jacketMat, 0.5, 0.085, 0.07);
rightArm.position.set(0.36, 0.92, 0);
hips.add(rightArm);
const leftHand = new THREE.Mesh(new THREE.SphereGeometry(0.08, 6, 6), skinMat);
leftHand.position.y = -0.5;
leftArm.add(leftHand);
const rightHand = new THREE.Mesh(new THREE.SphereGeometry(0.08, 6, 6), skinMat);
rightHand.position.y = -0.5;
rightArm.add(rightHand);
// 腿
const leftLeg = makeLimb(pantsMat, 0.62, 0.12, 0.095);
leftLeg.position.set(-0.14, 0.34, 0);
hips.add(leftLeg);
const rightLeg = makeLimb(pantsMat, 0.62, 0.12, 0.095);
rightLeg.position.set(0.14, 0.34, 0);
hips.add(rightLeg);
const leftShoe = new THREE.Mesh(new THREE.BoxGeometry(0.16, 0.1, 0.26), shoeMat);
leftShoe.position.set(0, -0.62, 0.06);
leftLeg.add(leftShoe);
const rightShoe = new THREE.Mesh(new THREE.BoxGeometry(0.16, 0.1, 0.26), shoeMat);
rightShoe.position.set(0, -0.62, 0.06);
rightLeg.add(rightShoe);
group.userData.limbs = { leftArm, rightArm, leftLeg, rightLeg, scarf, hips };
return group;
}
const player = buildPlayer();
player.position.set(LANE_X[1], 0, 0);
scene.add(player);
// ---------------------------------------------------------------------
// 障碍物:4 种肉眼可区分外形(原木箱/岩石因颜色与地面接近、
// 又被雾效过早遮住而"实测中几乎看不见"——本轮已提高饱和度对比 + 加大雾效可视距离)
// ---------------------------------------------------------------------
const crateMat = new THREE.MeshStandardMaterial({ color: 0xc97a3d, roughness: 0.8 }); // 暖橙棕木箱,区别于灰色地面
const spikeMat = new THREE.MeshStandardMaterial({ color: 0xe0453f, roughness: 0.5, metalness: 0.15 }); // 红色尖锥
const barrierMat = new THREE.MeshStandardMaterial({ color: 0xd9c23a, roughness: 0.6, metalness: 0.2 }); // 黄色横杆
const rockMat = new THREE.MeshStandardMaterial({ color: 0x5c7a52, roughness: 0.95, flatShading: true }); // 苔绿岩石,区别于灰色地面/天空
const OBSTACLE_BUILDERS = [
// 木箱(略放大,提升远距离辨识度)
() => {
const m = new THREE.Mesh(new THREE.BoxGeometry(1.2, 1.2, 1.2), crateMat);
m.position.y = 0.6;
return m;
},
// 尖刺
() => {
const m = new THREE.Mesh(new THREE.ConeGeometry(0.62, 1.5, 8), spikeMat);
m.position.y = 0.75;
return m;
},
// 横向路障
() => {
const m = new THREE.Mesh(new THREE.CylinderGeometry(0.32, 0.32, 2.1, 10), barrierMat);
m.rotation.z = Math.PI / 2;
m.position.y = 0.6;
return m;
},
// 碎石
() => {
const m = new THREE.Mesh(new THREE.DodecahedronGeometry(0.72, 0), rockMat);
m.position.y = 0.58;
m.rotation.set(Math.random() * Math.PI, Math.random() * Math.PI, 0);
return m;
},
];
const obstaclePool = [];
const activeObstacles = [];
function spawnObstacle() {
let group;
if (obstaclePool.length > 0) {
group = obstaclePool.pop();
} else {
group = new THREE.Group();
}
// 清空旧内容
while (group.children.length) group.remove(group.children[0]);
const typeIndex = Math.floor(Math.random() * OBSTACLE_BUILDERS.length);
const mesh = OBSTACLE_BUILDERS[typeIndex]();
// 障碍物只接收阴影、不投射阴影:同屏可能有多个障碍物,
// 投影是阴影渲染通道的主要开销来源之一,砍掉后对画面质感影响很小。
mesh.castShadow = false;
mesh.receiveShadow = true;
group.add(mesh);
const lane = Math.floor(Math.random() * 3);
group.position.set(LANE_X[lane], 0, SPAWN_Z);
group.userData.lane = lane;
group.userData.prevZ = SPAWN_Z;
scene.add(group);
activeObstacles.push(group);
}
function recycleObstacle(group) {
scene.remove(group);
obstaclePool.push(group);
}
// ---------------------------------------------------------------------
// 游戏状态 + 调试接口
// ---------------------------------------------------------------------
const gameState = {
score: 0,
speed: BASE_SPEED,
lane: 1,
alive: true,
fps: 0,
};
window.__game = gameState;
let started = false;
let distance = 0;
let elapsed = 0;
let currentLane = 1; // 实际逻辑车道(用于碰撞判断,切换瞬间即更新)
let nextSpawnDist = 10;
let fpsSmooth = 60;
const scoreEl = document.getElementById('score-val');
const speedEl = document.getElementById('speed-val');
const overlayEl = document.getElementById('overlay');
const finalScoreEl = document.getElementById('final-score');
const startOverlayEl = document.getElementById('start-overlay');
function resetGame() {
activeObstacles.forEach(recycleObstacle);
activeObstacles.length = 0;
distance = 0;
elapsed = 0;
currentLane = 1;
nextSpawnDist = 10;
gameState.score = 0;
gameState.speed = BASE_SPEED;
gameState.lane = currentLane;
gameState.alive = true;
player.position.set(LANE_X[currentLane], 0, 0);
player.rotation.z = 0;
overlayEl.classList.remove('show');
}
function gameOver() {
if (!gameState.alive) return;
gameState.alive = false;
finalScoreEl.textContent = gameState.score;
overlayEl.classList.add('show');
}
// ---------------------------------------------------------------------
// 输入
// ---------------------------------------------------------------------
function moveLane(dir) {
if (!gameState.alive) return;
currentLane = Math.max(0, Math.min(2, currentLane + dir));
gameState.lane = currentLane;
}
function startGame() {
if (started) return;
started = true;
startOverlayEl.style.display = 'none';
}
// 修复"开始遮罩吃掉第一次按键":
// 原实现里 startGame 挂在独立的 {once:true} keydown 监听器上,
// 且注册顺序在移动监听器之后——第一次按 ArrowLeft 时,移动监听器先跑,
// 那一刻 started 还是 false,moveLane 被静默拦下,随后 startGame 才把
// started 置 true,导致这次按键"只用来开始、没有真正换道"。
// 现在合并进同一个 handler:先无条件 startGame()(任意键都能开始),
// 再处理换道,同一次事件内 started 已为 true,按键立即生效,不再被吞。
window.addEventListener('keydown', (e) => {
if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', ' '].includes(e.key)) e.preventDefault();
if (!started) startGame();
if (e.key === 'ArrowLeft' || e.key === 'a' || e.key === 'A') moveLane(-1);
if (e.key === 'ArrowRight' || e.key === 'd' || e.key === 'D') moveLane(1);
}, { passive: false });
startOverlayEl.addEventListener('click', startGame);
document.getElementById('restart-btn').addEventListener('click', () => {
resetGame();
});
// ---------------------------------------------------------------------
// 主循环
// ---------------------------------------------------------------------
const clock = new THREE.Clock();
function updatePlayerVisual(delta, speedRatio) {
const targetX = LANE_X[currentLane];
const lerpFactor = 1 - Math.pow(0.0008, delta);
player.position.x += (targetX - player.position.x) * lerpFactor;
const diff = targetX - player.position.x;
const targetTilt = THREE.MathUtils.clamp(-diff * 0.35, -0.35, 0.35);
player.rotation.z += (targetTilt - player.rotation.z) * Math.min(1, delta * 10);
const limbs = player.userData.limbs;
if (gameState.alive) {
const runSpeed = 8 + speedRatio * 10;
const t = elapsed * runSpeed;
const swing = 0.85;
limbs.leftArm.rotation.x = Math.sin(t) * swing;
limbs.rightArm.rotation.x = -Math.sin(t) * swing;
limbs.leftLeg.rotation.x = -Math.sin(t) * swing;
limbs.rightLeg.rotation.x = Math.sin(t) * swing;
limbs.hips.position.y = 0.92 + Math.abs(Math.sin(t)) * 0.05;
limbs.scarf.rotation.x = 0.3 + Math.sin(t * 0.5) * 0.15;
limbs.scarf.rotation.y = Math.sin(t * 0.7) * 0.2;
}
}
function updateCamera(delta) {
const desired = new THREE.Vector3(
player.position.x * 0.6 + CAM_OFFSET.x,
CAM_OFFSET.y,
CAM_OFFSET.z
);
const lerpFactor = 1 - Math.pow(0.0005, delta);
camera.position.x += (desired.x - camera.position.x) * lerpFactor;
camera.position.y = desired.y;
camera.position.z = desired.z;
const lookTarget = new THREE.Vector3(
player.position.x * 0.4,
CAM_LOOK_OFFSET.y,
CAM_LOOK_OFFSET.z
);
camera.lookAt(lookTarget);
}
function checkCollision(group, prevZ, newZ) {
if (group.userData.lane !== currentLane) return false;
const lo = Math.min(prevZ, newZ);
const hi = Math.max(prevZ, newZ);
return hi >= PLAYER_Z_BACK && lo <= PLAYER_Z_FRONT;
}
function animate() {
requestAnimationFrame(animate);
let delta = clock.getDelta();
delta = Math.min(delta, 0.1); // 防止切后台/卡顿导致大跳变
const instFps = delta > 0 ? 1 / delta : 60;
fpsSmooth += (instFps - fpsSmooth) * 0.1;
gameState.fps = Math.round(fpsSmooth);
if (started && gameState.alive) {
elapsed += delta;
gameState.speed = Math.min(MAX_SPEED, BASE_SPEED + elapsed * 0.55);
const moveDist = gameState.speed * delta;
distance += moveDist;
gameState.score = Math.floor(distance);
// 地面 / 护栏纹理滚动,强化速度感
groundTexture.offset.y = (groundTexture.offset.y + moveDist * 0.09) % 1;
railTexture.offset.y = (railTexture.offset.y + moveDist * 0.09) % 1;
// 障碍物推进 + 碰撞检测 + 回收
for (let i = activeObstacles.length - 1; i >= 0; i--) {
const ob = activeObstacles[i];
const prevZ = ob.userData.prevZ;
const newZ = prevZ + moveDist;
if (checkCollision(ob, prevZ, newZ)) {
gameOver();
}
ob.position.z = newZ;
ob.userData.prevZ = newZ;
if (newZ > REMOVE_Z) {
activeObstacles.splice(i, 1);
recycleObstacle(ob);
}
}
// 生成新障碍物(按存活距离节奏,越往后间隔略微缩短但有下限)
if (distance >= nextSpawnDist) {
spawnObstacle();
const minGap = Math.max(7, 13 - distance * 0.01);
nextSpawnDist = distance + minGap + Math.random() * 5;
}
}
const speedRatio = (gameState.speed - BASE_SPEED) / (MAX_SPEED - BASE_SPEED);
updatePlayerVisual(delta, Math.max(0, speedRatio));
updateCamera(delta);
scoreEl.textContent = gameState.score;
speedEl.textContent = gameState.speed.toFixed(1);
renderer.render(scene, camera);
}
animate();
})();
</script>
</body>
</html>
游戏二:微缩无人深空(Round 3 终版)
存为 space.html。W/S 推进减速,方向键俯仰偏航,Shift 加速,N 领航对准,靠近行星自动无缝降落,拉升回太空。
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Stellar Drift — 无缝行星降落</title>
<style>
html,body{margin:0;padding:0;height:100%;overflow:hidden;background:#000;}
canvas{position:fixed;inset:0;width:100vw !important;height:100vh !important;display:block;}
#hud{
position:fixed;top:16px;left:16px;z-index:10;
font-family:"SF Mono",Menlo,Consolas,monospace;font-size:13px;color:#8fe8ff;
background:rgba(4,14,24,0.55);border:1px solid rgba(90,200,255,0.35);
border-radius:8px;padding:10px 14px;min-width:240px;
text-shadow:0 0 6px rgba(80,200,255,0.6);pointer-events:none;
}
#hud .row{display:flex;justify-content:space-between;gap:12px;line-height:1.7;}
#hud .lab{color:#4a9cc0;}
#hud .unit{color:#3d7d99;}
#hud #mode.low{color:#ffd07a;text-shadow:0 0 6px rgba(255,190,90,0.7);}
#hud #nav.on{color:#7dffb0;}
#help{
position:fixed;bottom:14px;left:50%;transform:translateX(-50%);z-index:10;
font-family:"SF Mono",Menlo,Consolas,monospace;font-size:12px;color:#5c94ad;
background:rgba(4,14,24,0.5);border:1px solid rgba(90,200,255,0.2);
border-radius:6px;padding:6px 14px;white-space:nowrap;pointer-events:none;
}
#err{position:fixed;top:40%;width:100%;text-align:center;color:#f66;font-family:monospace;display:none;z-index:20;}
</style>
</head>
<body>
<div id="hud">
<div class="row"><span class="lab">速度</span><span><span id="spd">0.0</span> <span class="unit">u/s</span></span></div>
<div class="row"><span class="lab">最近行星</span><span id="pname">—</span></div>
<div class="row"><span class="lab">距离</span><span><span id="pdist">—</span> <span class="unit">u</span></span></div>
<div class="row"><span class="lab">高度</span><span><span id="palt">—</span> <span class="unit">u</span></span></div>
<div class="row"><span class="lab">状态</span><span id="mode">太空</span></div>
<div class="row"><span class="lab">领航 [N]</span><span id="nav">关</span></div>
<div class="row"><span class="lab">FPS</span><span id="fps">—</span></div>
</div>
<div id="help">W/S 推进·减速 · ↑↓←→ 或鼠标拖拽 俯仰偏航 · A/D 横滚 · Shift 加速 · N 领航对准 · 靠近行星即无缝降落,拉升回太空</div>
<div id="err">WebGL 初始化失败,请换浏览器或开启硬件加速。</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script>
'use strict';
/* =====================================================================
Stellar Drift — 单文件太空探索(round_3 CPU/顶点侧优化版)
无缝着陆方案:行星始终以真实几何存在于同一世界(无场景切换),
大气/雾/天空色/操控参数随高度连续插值;地形碰撞与网格共用同一
种子噪声函数,保证贴地飞行严格贴合可见地形。
round_3 性能策略(针对「分辨率无效 → CPU/顶点瓶颈」线索):
- 顶点总量削 ~70%:地形高模 detail 5→4,装饰物换轻量几何体减量
- 装饰物本就是 InstancedMesh 单 draw call/类;地形每星单 mesh
- 远行星(>3500u)更新完全跳过:轨道 8Hz 低频推进 + 矩阵冻结,
静态子对象 matrixAutoUpdate=false,不进任何每帧计算
- 手动三级 LOD(不再依赖 THREE.LOD,按缓存距离直接切 visible)
- 动态分辨率回路重写:45 帧一评、清采样缓冲、0.35 下限,
到底仍 <28fps 自动进低画质档(隐藏大气壳/轨道线、LOD 提前切换)
- 调试口新增 resScale / drawCalls / quality 字段便于归因
===================================================================== */
(function(){
if(!window.THREE){ document.getElementById('err').style.display='block'; return; }
/* ---------------- 工具 & 种子噪声 ---------------- */
const clamp=(x,a,b)=>x<a?a:(x>b?b:x);
const lerp=(a,b,t)=>a+(b-a)*t;
const smoothstep=(a,b,x)=>{const t=clamp((x-a)/(b-a),0,1);return t*t*(3-2*t);};
function mulberry32(a){return function(){a|=0;a=a+0x6D2B79F5|0;let t=Math.imul(a^a>>>15,1|a);t=t+Math.imul(t^t>>>7,61|t)^t;return((t^t>>>14)>>>0)/4294967296;};}
function hash3i(x,y,z,s){
let h=(Math.imul(x,374761393)+Math.imul(y,668265263)+Math.imul(z,1274126177)+Math.imul(s,362437))|0;
h=Math.imul(h^h>>>13,1274126177);h^=h>>>16;return(h>>>0)/4294967296;
}
function vnoise(x,y,z,s){
const xi=Math.floor(x),yi=Math.floor(y),zi=Math.floor(z);
let xf=x-xi,yf=y-yi,zf=z-zi;
xf=xf*xf*(3-2*xf);yf=yf*yf*(3-2*yf);zf=zf*zf*(3-2*zf);
const c000=hash3i(xi,yi,zi,s), c100=hash3i(xi+1,yi,zi,s),
c010=hash3i(xi,yi+1,zi,s),c110=hash3i(xi+1,yi+1,zi,s),
c001=hash3i(xi,yi,zi+1,s),c101=hash3i(xi+1,yi,zi+1,s),
c011=hash3i(xi,yi+1,zi+1,s),c111=hash3i(xi+1,yi+1,zi+1,s);
const x00=c000+(c100-c000)*xf, x10=c010+(c110-c010)*xf,
x01=c001+(c101-c001)*xf, x11=c011+(c111-c011)*xf;
const y0=x00+(x10-x00)*yf, y1=x01+(x11-x01)*yf;
return y0+(y1-y0)*zf; // 0..1
}
function fbm(x,y,z,s,oct){
let a=0.5,f=1,sum=0,norm=0;
for(let i=0;i<oct;i++){ sum+=a*vnoise(x*f,y*f,z*f,s+i*1013); norm+=a; f*=2.03; a*=0.5; }
return sum/norm; // 0..1
}
function freeze(o){ o.updateMatrix(); o.matrixAutoUpdate=false; } // 静态对象出更新循环
/* ---------------- 基础场景 ---------------- */
const SEED=20260804;
const scene=new THREE.Scene();
const camera=new THREE.PerspectiveCamera(70, innerWidth/innerHeight, 0.5, 60000);
let renderer;
try{
renderer=new THREE.WebGLRenderer({antialias:false, powerPreference:'high-performance', precision:'mediump'});
}catch(e){ document.getElementById('err').style.display='block'; return; }
let resScale=1; // 动态分辨率比例(0.35 ~ 1.0)
let quality=1; // 1=正常 0=保底低画质档
function applyRes(){
renderer.setPixelRatio(resScale);
renderer.setSize(innerWidth,innerHeight,false); // CSS 尺寸由样式表固定为全屏
}
applyRes();
renderer.setClearColor(0x000004);
document.body.appendChild(renderer.domElement);
scene.fog=new THREE.FogExp2(0x000004, 0.0);
const sunLight=new THREE.PointLight(0xfff2dd, 1.55, 0, 0);
scene.add(sunLight);
const ambient=new THREE.AmbientLight(0x8899bb, 0.38);
scene.add(ambient);
/* ---------------- 星空背景 ---------------- */
let starMat;
(function makeStars(){
const rng=mulberry32(SEED^0x5f5f);
const N=2400, pos=new Float32Array(N*3), col=new Float32Array(N*3);
const c=new THREE.Color();
for(let i=0;i<N;i++){
let x,y,z,l;
do{ x=rng()*2-1; y=rng()*2-1; z=rng()*2-1; l=x*x+y*y+z*z; }while(l<0.05||l>1);
l=Math.sqrt(l);
const r=17000+rng()*8000;
pos[i*3]=x/l*r; pos[i*3+1]=y/l*r; pos[i*3+2]=z/l*r;
const t=rng();
if(t<0.72) c.setHSL(0.62,0.05+rng()*0.1,0.75+rng()*0.25);
else if(t<0.88) c.setHSL(0.58,0.55,0.72);
else c.setHSL(0.08,0.6,0.7);
col[i*3]=c.r; col[i*3+1]=c.g; col[i*3+2]=c.b;
}
const g=new THREE.BufferGeometry();
g.setAttribute('position',new THREE.BufferAttribute(pos,3));
g.setAttribute('color',new THREE.BufferAttribute(col,3));
starMat=new THREE.PointsMaterial({size:1.7,sizeAttenuation:false,vertexColors:true,transparent:true,opacity:1,depthWrite:false});
const pts=new THREE.Points(g,starMat);
freeze(pts);
scene.add(pts);
})();
/* ---------------- 恒星 ---------------- */
const SUN_R=300;
(function makeSun(){
const sun=new THREE.Mesh(
new THREE.SphereGeometry(SUN_R,24,16),
new THREE.MeshBasicMaterial({color:0xffdf9e})
);
freeze(sun);
scene.add(sun);
const cv=document.createElement('canvas'); cv.width=cv.height=128;
const ctx=cv.getContext('2d');
const gr=ctx.createRadialGradient(64,64,4,64,64,64);
gr.addColorStop(0,'rgba(255,240,200,0.9)');
gr.addColorStop(0.35,'rgba(255,190,110,0.35)');
gr.addColorStop(1,'rgba(255,150,60,0)');
ctx.fillStyle=gr; ctx.fillRect(0,0,128,128);
const spr=new THREE.Sprite(new THREE.SpriteMaterial({
map:new THREE.CanvasTexture(cv), blending:THREE.AdditiveBlending,
transparent:true, depthWrite:false
}));
spr.scale.setScalar(SUN_R*4.5);
freeze(spr);
scene.add(spr);
})();
/* ---------------- 行星生成 ---------------- */
const terrainMat=new THREE.MeshLambertMaterial({vertexColors:true});
const NAME_SYL=['ka','ze','ron','vel','ta','or','um','eli','tha','nox','ir','qua','bel','dra','os','myn'];
function genName(rng){
let n='';
const k=2+Math.floor(rng()*2);
for(let i=0;i<k;i++) n+=NAME_SYL[Math.floor(rng()*NAME_SYL.length)];
n=n.charAt(0).toUpperCase()+n.slice(1);
return n+'-'+(100+Math.floor(rng()*900));
}
const PLANET_N=7;
const planets=[];
function makeHeightFn(P){
const f=P.freq, ox=P.ox, oy=P.oy, oz=P.oz, s=P.nseed, oct=P.oct, amp=P.amp, style=P.style;
return function(dx,dy,dz){
const b=fbm(dx*f+ox, dy*f+oy, dz*f+oz, s, oct); // 0..1
let h;
if(style===0){ // 丘陵/海洋世界
h=b*2-1;
}else if(style===1){ // 山脊世界
let r=1-Math.abs(b*2-1); r=r*r;
h=r*1.7-0.55;
}else{ // 台地/梯田世界
const n=b*2-1, t=n*3, fl=Math.floor(t);
let fr=t-fl; fr=fr*fr*(3-2*fr);
h=(fl+fr)/3;
}
return h*amp;
};
}
function buildTerrainMesh(P, detail){
const geo=new THREE.IcosahedronGeometry(1, detail); // 非索引,天然逐面
const pos=geo.attributes.position, n=pos.count;
const colors=new Float32Array(n*3);
const hs=new Float32Array(n);
const d=new THREE.Vector3();
for(let i=0;i<n;i++){
d.set(pos.getX(i),pos.getY(i),pos.getZ(i)).normalize();
const h=P.heightRaw(d.x,d.y,d.z);
hs[i]=h;
const eff=P.ocean?Math.max(h,P.sea):h;
const R=P.radius+eff;
pos.setXYZ(i, d.x*R, d.y*R, d.z*R);
}
const c=new THREE.Color();
for(let f=0;f<n;f+=3){
const havg=(hs[f]+hs[f+1]+hs[f+2])/3;
const t=clamp(havg/P.amp*0.5+0.5,0,1);
if(P.ocean && havg<=P.sea+1e-6){
c.copy(P.cWater);
const dep=clamp((P.sea-havg)/P.amp,0,1);
c.multiplyScalar(1-0.4*dep);
}else{
if(t<0.5){ c.copy(P.cLow).lerp(P.cMid,t*2); }
else{ c.copy(P.cMid).lerp(P.cHigh,(t-0.5)*2); }
if(t>0.82) c.lerp(P.cCap,(t-0.82)/0.18*0.8);
}
const j=(hash3i(f,P.idx,7,999)-0.5)*0.07;
c.offsetHSL(0,0,j);
for(let k=0;k<3;k++){ colors[(f+k)*3]=c.r; colors[(f+k)*3+1]=c.g; colors[(f+k)*3+2]=c.b; }
}
geo.setAttribute('color',new THREE.BufferAttribute(colors,3));
geo.computeVertexNormals();
const mesh=new THREE.Mesh(geo, terrainMat);
freeze(mesh);
return mesh;
}
function scatterFeatures(P, grp){
const rng=mulberry32(P.seed*911+55);
for(const type of P.featureTypes){
let geo, mat;
if(type==='rock'){ // 轻量:八面体压扁当岩石(24 顶点/个)
geo=new THREE.OctahedronGeometry(1,0); geo.scale(1.1,0.65,0.9);
mat=new THREE.MeshLambertMaterial({color:P.cRock});
}else if(type==='crystal'){
geo=new THREE.OctahedronGeometry(1,0); geo.scale(0.55,1.9,0.55); geo.translate(0,0.7,0);
mat=new THREE.MeshLambertMaterial({color:P.cCrystal, emissive:P.cCrystalE});
}else{ // tree(5 棱锥,30 顶点/个)
geo=new THREE.ConeGeometry(0.75,2.4,5); geo.translate(0,1.0,0);
mat=new THREE.MeshLambertMaterial({color:P.cTree});
}
const COUNT=220; // 每类一个 InstancedMesh = 1 draw call
const im=new THREE.InstancedMesh(geo,mat,COUNT);
const m=new THREE.Matrix4(), q=new THREE.Quaternion(), qy=new THREE.Quaternion(),
up=new THREE.Vector3(0,1,0), d=new THREE.Vector3(),
sc=new THREE.Vector3(), p=new THREE.Vector3(), Y=new THREE.Vector3(0,1,0);
let placed=0,tries=0;
while(placed<COUNT && tries<COUNT*8){
tries++;
d.set(rng()*2-1,rng()*2-1,rng()*2-1);
const l2=d.lengthSq();
if(l2<0.04||l2>1) continue;
d.normalize();
const h=P.heightRaw(d.x,d.y,d.z);
if(P.ocean && h<P.sea+P.amp*0.08) continue;
q.setFromUnitVectors(up,d);
qy.setFromAxisAngle(Y,rng()*Math.PI*2);
q.multiply(qy);
const base=P.radius*(type==='crystal'?0.013:0.016);
const s=base*(0.6+rng()*1.6);
sc.set(s,s*(type==='tree'?1.25:1),s);
// detail4 网格较粗,噪声高度与三角面有误差 → 加大下沉量防悬浮
const sink=type==='rock'?s*0.5:s*0.2;
p.copy(d).multiplyScalar(P.radius+h-sink);
m.compose(p,q,sc);
im.setMatrixAt(placed++,m);
}
im.count=placed;
im.visible=false;
freeze(im);
grp.add(im);
P.featureMeshes.push(im);
}
}
const FEATURE_SETS=[['rock','tree'],['rock','crystal'],['crystal','tree']];
const orbitSegs=[]; // 所有轨道线合并成一份顶点数据
for(let i=0;i<PLANET_N;i++){
const rng=mulberry32(SEED+i*7919+13);
const P={idx:i, seed:SEED+i*101};
P.name=genName(rng);
P.radius=58+rng()*72; // 58 ~ 130
P.orbitR=1150+i*560+rng()*220;
P.orbitSpeed=(9+rng()*7)/P.orbitR; // 线速度 ~9-16 u/s
P.phase=rng()*Math.PI*2;
P.orbitTilt=P.orbitR*(rng()*0.12-0.06);
P.tiltPhase=rng()*Math.PI*2;
P.spinSpeed=(rng()*0.5+0.3)*0.035*(rng()<0.5?1:-1);
P.spinAngle=rng()*Math.PI*2;
P.style=i%3;
P.freq=1.5+rng()*2.2;
P.oct=4+(rng()<0.5?1:0);
P.amp=P.radius*(0.05+rng()*0.055);
P.ox=rng()*80; P.oy=rng()*80; P.oz=rng()*80;
P.nseed=(SEED+i*3571)|0;
P.ocean=(P.style===0)||(rng()<0.35);
P.sea=P.amp*(-0.12+rng()*0.22);
const hue=(i*0.618034+0.11)%1;
P.cLow =new THREE.Color().setHSL(hue,0.52,0.30);
P.cMid =new THREE.Color().setHSL((hue+0.05)%1,0.44,0.46);
P.cHigh =new THREE.Color().setHSL((hue+0.09)%1,0.28,0.64);
P.cCap =new THREE.Color().setHSL(hue,0.08,0.93);
P.cWater=new THREE.Color().setHSL((hue+0.5)%1,0.62,0.34);
P.cSky =new THREE.Color().setHSL(hue,0.45,0.62);
P.cRock =new THREE.Color().setHSL(hue,0.14,0.36);
P.cTree =new THREE.Color().setHSL((hue+0.28)%1,0.5,0.32);
P.cCrystal =new THREE.Color().setHSL((hue+0.45)%1,0.8,0.6);
P.cCrystalE=new THREE.Color().setHSL((hue+0.45)%1,0.9,0.22);
P.featureTypes=FEATURE_SETS[i%3];
P.heightRaw=makeHeightFn(P);
P.heightEff=function(dx,dy,dz){
const h=P.heightRaw(dx,dy,dz);
return P.ocean?Math.max(h,P.sea):h;
};
P.featureMeshes=[];
P.pos=new THREE.Vector3(); P.prevPos=new THREE.Vector3(); P.delta=new THREE.Vector3();
P.orbAng=P.phase; P.lag=0; P._dist=Infinity;
const grp=new THREE.Group();
grp.matrixAutoUpdate=false; // 由 updatePlanet 手动提交矩阵
// 手动三级 LOD:detail 4 / 2 / 1,按缓存距离直接切 visible
P.terrHi =buildTerrainMesh(P,4);
P.terrMid=buildTerrainMesh(P,2);
P.terrLo =buildTerrainMesh(P,1);
P.terrHi.visible=false; P.terrMid.visible=false; P.terrLo.visible=true;
grp.add(P.terrHi,P.terrMid,P.terrLo);
// 大气壳(外视角淡淡光晕;远处不渲染)
const atm=new THREE.Mesh(
new THREE.SphereGeometry(P.radius*1.28,18,12),
new THREE.MeshBasicMaterial({color:P.cSky,transparent:true,opacity:0.10,side:THREE.BackSide,depthWrite:false})
);
freeze(atm);
grp.add(atm);
P.atm=atm;
scatterFeatures(P,grp);
scene.add(grp);
P.grp=grp;
// 轨道参考线(累积进合批数组)
{
const SEG=128;
let px,py,pz;
for(let k=0;k<=SEG;k++){
const a=k/SEG*Math.PI*2;
const x=Math.cos(a)*P.orbitR,
y=Math.sin(a+P.tiltPhase)*P.orbitTilt,
z=Math.sin(a)*P.orbitR;
if(k>0){ orbitSegs.push(px,py,pz,x,y,z); }
px=x;py=y;pz=z;
}
}
planets.push(P);
}
// 全部轨道线 → 1 个 draw call
let orbitLines;
{
const g=new THREE.BufferGeometry();
g.setAttribute('position',new THREE.BufferAttribute(new Float32Array(orbitSegs),3));
orbitLines=new THREE.LineSegments(g,new THREE.LineBasicMaterial({color:0x1d4a63,transparent:true,opacity:0.35}));
freeze(orbitLines);
scene.add(orbitLines);
}
/* 远行星更新完全跳过:>3500u 的行星按 ~8Hz 低频推进轨道,其余帧零计算 */
function updatePlanet(P,dt,isNear){
P.lag+=dt;
if(!isNear && P._dist>3500 && P.lag<0.12) return;
const step=P.lag; P.lag=0;
if(step<=0) return;
P.orbAng+=P.orbitSpeed*step;
P.prevPos.copy(P.pos);
P.pos.set(Math.cos(P.orbAng)*P.orbitR, Math.sin(P.orbAng+P.tiltPhase)*P.orbitTilt, Math.sin(P.orbAng)*P.orbitR);
P.delta.subVectors(P.pos,P.prevPos);
P.grp.position.copy(P.pos);
P.spinAngle+=P.spinSpeed*step;
P.grp.rotation.y=P.spinAngle;
P.grp.updateMatrix();
}
// 初始化位置(delta 归零)
planets.forEach(P=>{ P.lag=1e-6; updatePlanet(P,0,true); P.prevPos.copy(P.pos); P.delta.set(0,0,0); });
/* ---------------- 飞船 ---------------- */
const ship=new THREE.Group();
(function buildShip(){
const bodyMat=new THREE.MeshLambertMaterial({color:0xa9bfd4});
const trimMat=new THREE.MeshLambertMaterial({color:0x2dd4bf,emissive:0x0b3a38});
const nose=new THREE.Mesh(new THREE.ConeGeometry(0.55,1.6,8),bodyMat);
nose.rotation.x=-Math.PI/2; nose.position.z=-1.55; freeze(nose); ship.add(nose);
const hull=new THREE.Mesh(new THREE.CylinderGeometry(0.55,0.78,2.3,8),bodyMat);
hull.rotation.x=-Math.PI/2; hull.position.z=0.1; freeze(hull); ship.add(hull);
const wing=new THREE.Mesh(new THREE.BoxGeometry(4.4,0.09,1.15),trimMat);
wing.position.z=0.62; freeze(wing); ship.add(wing);
const fin=new THREE.Mesh(new THREE.BoxGeometry(0.09,1.15,0.95),trimMat);
fin.position.set(0,0.62,0.75); freeze(fin); ship.add(fin);
const glow=new THREE.Mesh(new THREE.SphereGeometry(0.34,10,8),
new THREE.MeshBasicMaterial({color:0x8ef4ff}));
glow.position.z=1.45; ship.add(glow); // glow 每帧改 scale,保持自动矩阵
ship.userData.glow=glow;
ship.scale.setScalar(1.35);
})();
scene.add(ship);
// 初始:停在 1 号行星外侧,机头对准它
(function placeShip(){
const P=planets[1];
const off=new THREE.Vector3(0.8,0.35,0.6).normalize().multiplyScalar(P.radius*5.5);
ship.position.copy(P.pos).add(off);
const m=new THREE.Matrix4();
m.lookAt(ship.position,P.pos,new THREE.Vector3(0,1,0));
ship.quaternion.setFromRotationMatrix(m);
camera.position.copy(ship.position).add(new THREE.Vector3(0,4,14).applyQuaternion(ship.quaternion));
})();
/* ---------------- 输入 ---------------- */
const keys={};
let navOn=false;
const PREVENT=['ArrowUp','ArrowDown','ArrowLeft','ArrowRight','Space'];
addEventListener('keydown',e=>{
if(e.code==='KeyN'&&!e.repeat) navOn=!navOn;
keys[e.code]=true;
if(PREVENT.indexOf(e.code)>=0) e.preventDefault();
});
addEventListener('keyup',e=>{ keys[e.code]=false; });
let dragging=false,lastMX=0,lastMY=0,mYaw=0,mPitch=0;
renderer.domElement.addEventListener('mousedown',e=>{dragging=true;lastMX=e.clientX;lastMY=e.clientY;});
addEventListener('mouseup',()=>{dragging=false;});
addEventListener('mousemove',e=>{
if(!dragging) return;
mYaw =clamp(mYaw -(e.clientX-lastMX)*0.006,-2.5,2.5);
mPitch=clamp(mPitch-(e.clientY-lastMY)*0.006,-2.5,2.5);
lastMX=e.clientX; lastMY=e.clientY;
});
/* ---------------- 飞行状态 ---------------- */
const vel=new THREE.Vector3();
const angVel=new THREE.Vector3();
const _q=new THREE.Quaternion(), _e=new THREE.Euler(), _m=new THREE.Matrix4();
const _v1=new THREE.Vector3(), _v2=new THREE.Vector3(), _v3=new THREE.Vector3();
window.__game={mode:'space',speed:0,nearestPlanet:'',nearestDistance:0,altitude:0,planetCount:PLANET_N,fps:0,resScale:1,drawCalls:0,quality:1};
const hud={
spd:document.getElementById('spd'), pname:document.getElementById('pname'),
pdist:document.getElementById('pdist'), palt:document.getElementById('palt'),
mode:document.getElementById('mode'), nav:document.getElementById('nav'),
fps:document.getElementById('fps')
};
const fpsBuf=[];
let fpsMed=0, frameCnt=0, lowStreak=0;
let simT=0, lastT=performance.now();
let nearestRef=null;
function enterLowQuality(){
if(quality===0) return;
quality=0;
orbitLines.visible=false;
for(const P of planets){ P.atm.visible=false; }
}
/* ---------------- 主循环 ---------------- */
function tick(){
requestAnimationFrame(tick);
const now=performance.now();
const dtRaw=(now-lastT)/1000; lastT=now;
const dt=clamp(dtRaw,0.0001,0.05);
simT+=dt;
// FPS(中位数)
if(dtRaw>0){ fpsBuf.push(1/dtRaw); if(fpsBuf.length>90) fpsBuf.shift(); }
if(++frameCnt%10===0&&fpsBuf.length>4){
const s=fpsBuf.slice().sort((a,b)=>a-b);
fpsMed=s[s.length>>1];
}
// 动态分辨率回路:45 帧一评,动过就清采样缓冲让反馈生效
if(frameCnt%45===0&&fpsBuf.length>=40){
if(fpsMed<31){
if(resScale>0.35){ resScale=Math.max(0.35,resScale-0.2); applyRes(); fpsBuf.length=0; }
else if(++lowStreak>=2) enterLowQuality(); // 分辨率到底仍不行 → 低画质档
}else{
lowStreak=0;
if(fpsMed>52&&resScale<1){ resScale=Math.min(1,resScale+0.1); applyRes(); fpsBuf.length=0; }
}
}
// 行星公转 + 自转(远行星低频,近行星每帧)
for(const P of planets) updatePlanet(P,dt,P===nearestRef);
// 最近行星(按表面距离)
let nearest=null, nDist=Infinity;
for(const P of planets){
const d=_v1.subVectors(ship.position,P.pos).length()-P.radius;
P._dist=d;
if(d<nDist){ nDist=d; nearest=P; }
}
nearestRef=nearest;
// 地形高度 / 高度角
let altitude=nDist, atmoF=0, groundR=0, wDir=null;
if(nearest){
_v1.subVectors(ship.position,nearest.pos);
const r=_v1.length();
wDir=_v2.copy(_v1).normalize();
// 世界向 → 行星本地向(逆自转)
const ca=Math.cos(-nearest.spinAngle), sa=Math.sin(-nearest.spinAngle);
_v3.set(wDir.x*ca+wDir.z*sa, wDir.y, -wDir.x*sa+wDir.z*ca);
const hEff=nearest.heightEff(_v3.x,_v3.y,_v3.z);
groundR=nearest.radius+hEff;
altitude=r-groundR;
atmoF=1-smoothstep(nearest.radius*0.18,nearest.radius*1.1,altitude);
}
/* ---- 姿态控制(含惯性) ---- */
const dec=Math.exp(-3.2*dt);
mYaw*=dec; mPitch*=dec;
const inPitch=(keys['ArrowUp']?1:0)-(keys['ArrowDown']?1:0)+mPitch;
const inYaw =(keys['ArrowLeft']?1:0)-(keys['ArrowRight']?1:0)+mYaw;
const inRoll =(keys['KeyA']?1:0)-(keys['KeyD']?1:0);
const rotK=1-Math.exp(-9*dt);
angVel.x+=(inPitch*1.25-angVel.x)*rotK;
angVel.y+=(inYaw*1.05-angVel.y)*rotK;
angVel.z+=(inRoll*1.9-angVel.z)*rotK;
_e.set(angVel.x*dt,angVel.y*dt,angVel.z*dt,'XYZ');
_q.setFromEuler(_e);
ship.quaternion.multiply(_q).normalize();
// 领航辅助:对准最近行星(低空自动暂停,避免俯冲)
if(navOn&&nearest&&atmoF<0.5){
_v3.set(0,1,0).applyQuaternion(ship.quaternion);
_m.lookAt(ship.position,nearest.pos,_v3);
_q.setFromRotationMatrix(_m);
ship.quaternion.rotateTowards(_q,1.5*dt);
}
/* ---- 推进 / 阻力 / 惯性滑行 ---- */
const fwd=_v3.set(0,0,-1).applyQuaternion(ship.quaternion);
const boost=keys['ShiftLeft']||keys['ShiftRight'];
const accel=lerp(90,42,atmoF)*(boost?2.1:1);
let throttle=0;
if(keys['KeyW']){ vel.addScaledVector(fwd,accel*dt); throttle=boost?1:0.6; }
if(keys['KeyS']){ vel.addScaledVector(fwd,-accel*0.75*dt); }
const drag=lerp(0.045,0.85,atmoF);
vel.multiplyScalar(Math.exp(-drag*dt));
const maxSpeed=lerp(340,44+(nearest?nearest.radius*0.06:0),atmoF)*(boost?1.6:1);
const sp=vel.length();
if(sp>maxSpeed) vel.multiplyScalar(1/(1+((sp-maxSpeed)/maxSpeed)*3.2*dt));
ship.position.addScaledVector(vel,dt);
// 行星携带(进入引力圈随行星平移 → 降落不漂移)
if(nearest){
const carryF=1-smoothstep(nearest.radius*0.5,nearest.radius*2.3,altitude);
if(carryF>0) ship.position.addScaledVector(nearest.delta,carryF);
}
/* ---- 地形碰撞(与网格同源噪声,严格贴地) ---- */
if(nearest&&altitude<nearest.radius){
const minClear=1.3+nearest.radius*0.008;
if(altitude<minClear){
ship.position.copy(nearest.pos).addScaledVector(wDir,groundR+minClear);
const rv=vel.dot(wDir);
if(rv<0) vel.addScaledVector(wDir,-rv*1.15); // 消除向下分量+微弹
altitude=minClear;
}
}
// 恒星防撞
{
const d=ship.position.length();
if(d<SUN_R*1.4){
_v1.copy(ship.position).normalize();
ship.position.copy(_v1).multiplyScalar(SUN_R*1.4);
const rv=vel.dot(_v1);
if(rv<0) vel.addScaledVector(_v1,-rv*1.2);
}
}
/* ---- 相机跟随 ---- */
_v1.set(0,3.1,10.5).applyQuaternion(ship.quaternion).add(ship.position);
camera.position.lerp(_v1,1-Math.exp(-5.5*dt));
_v2.copy(ship.position).addScaledVector(fwd,7);
_v3.set(0,1,0).applyQuaternion(ship.quaternion);
_m.lookAt(camera.position,_v2,_v3);
_q.setFromRotationMatrix(_m);
camera.quaternion.slerp(_q,1-Math.exp(-7*dt));
/* ---- 无缝大气过渡(连续插值,无任何切换) ---- */
const skyC=nearest?nearest.cSky:null;
const fogCol=scene.fog.color;
if(skyC){ fogCol.setRGB(skyC.r*atmoF*0.85,skyC.g*atmoF*0.85,skyC.b*atmoF*0.85); }
else fogCol.setRGB(0,0,0);
scene.fog.density=atmoF*0.0032;
renderer.setClearColor(fogCol);
starMat.opacity=1-atmoF*0.97;
ambient.intensity=0.38+atmoF*0.5;
// 手动 LOD + 表面元素 / 大气壳显隐(低画质档提前降级)
const lodK=quality===0?0.55:1;
for(const P of planets){
const d=P._dist;
const hi=d<P.radius*5*lodK, mid=!hi&&d<P.radius*16;
if(P.terrHi.visible!==hi) P.terrHi.visible=hi;
if(P.terrMid.visible!==mid) P.terrMid.visible=mid;
const lo=!hi&&!mid;
if(P.terrLo.visible!==lo) P.terrLo.visible=lo;
const show=P===nearest&&nDist<P.radius*6;
for(const fm of P.featureMeshes) if(fm.visible!==show) fm.visible=show;
if(quality===1){
const atmShow=d<P.radius*30;
if(P.atm.visible!==atmShow) P.atm.visible=atmShow;
}
}
// 引擎光
ship.userData.glow.scale.setScalar(0.55+throttle*1.1);
/* ---- HUD & 调试接口 ---- */
const speed=vel.length();
const mode=atmoF>0.55?'low':'space';
const g=window.__game;
g.mode=mode; g.speed=+speed.toFixed(1);
g.nearestPlanet=nearest?nearest.name:'';
g.nearestDistance=+nDist.toFixed(1);
g.altitude=+altitude.toFixed(1);
g.fps=+fpsMed.toFixed(1);
g.resScale=resScale; g.quality=quality;
if(frameCnt%3===0){ // DOM 降频
hud.spd.textContent=speed.toFixed(1);
hud.pname.textContent=nearest?nearest.name:'—';
hud.pdist.textContent=nDist.toFixed(0);
hud.palt.textContent=altitude.toFixed(1);
hud.mode.textContent=mode==='low'?'低空':'太空';
hud.mode.className=mode==='low'?'low':'';
hud.nav.textContent=navOn?(atmoF>=0.5?'开·低空暂停':'开'):'关';
hud.nav.className=navOn?'on':'';
hud.fps.textContent=fpsMed?fpsMed.toFixed(0):'—';
}
renderer.render(scene,camera);
g.drawCalls=renderer.info.render.calls;
}
addEventListener('resize',()=>{
camera.aspect=innerWidth/innerHeight;
camera.updateProjectionMatrix();
applyRes();
});
tick();
})();
</script>
</body>
</html>
更多真机实测视频,见 YouTube 频道「富爸爸大妙招」。


评论