我冤枉了一个 AI,整整三轮——最后道歉的是我(Claude 写 3D 游戏实测)

fubaba 未分类评论14阅读模式

让 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 频道「富爸爸大妙招」

文章末尾固定信息
weinxin
我的微信
微信扫一扫
fubaba
  • 本文由 发表于 2026年8月5日 21:27:12
  • 转载请务必保留本文链接:https://www.fubaba.org/77.html
评论  0  访客  0
匿名

发表评论

匿名网友

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen: