<!-- SOLID CITY · part 3419 -->
 && S.phaseT < 2.2)) {   // a ground camera beside the pad watches the lift-off
    forward(camH, S.yaw, 0); camWant.set(-camH.x * 26 + camH.z * 10, 4, -camH.z * 26 - camH.x * 10); camLook.copy(P).y += 3; k = 3;
  } else if (ph === 'landing') {
    forward(camH, S.yaw, 0); camWant.copy(P).addScaledVector(camH, -24); camWant.y += 11; camLook.copy(P).addScaledVector(camH, 14); camLook.y -= 3; k = 4;
  } else if (ph === 'respawn') { camWant.copy(camState.pos); camLook.copy(camState.look); k = 1; }
  else {
    const sf = clamp(S.speed / BOOST_V, 0, 1.6), back = 15 + sf * 3.5 + (S.boosting ? 3 : 0);
    camWant.copy(P).addScaledVector(f, -back); camWant.y += 4.6 + Math.max(0, -f.y) * 4;
    camLook.copy(P).addScaledVector(f, 30); camLook.y += 1.5; k = 7;
  }
  if (!camState.init) { camState.pos.copy(camWant); camState.look.copy(camLook); camState.init = true; }
  camState.pos.lerp(camWant, 1 - Math.exp(-k * dt)); camState.look.lerp(camLook, 1 - Math.exp(-(k + 3) * dt));
  camera.position.copy(camState.pos);
  const flying = ph === 'battle' || ph === 'return' || (ph === 'climb' && S.phaseT >= 2.2);
  if (flying) { const maxD = 30 + clamp(S.speed / 100, 0, 6), dd = camera.position.distanceTo(P); if (dd > maxD) camera.position.lerp(P, 1 - maxD / dd); }
  S.shake = Math.max(0, S.shake - dt * 1.6);
  const sh = S.shake * S.shake * 0.9; if (sh > 0.001) camera.position.add(tv.set((Math.random() - 0.5) * sh, (Math.random() - 0.5) * sh, (Math.random() - 0.5) * sh));
  camera.up.set(0, 1, 0); camera.lookAt(camState.look);
  if (flying) camera.rotateZ(S.roll * 0.25);
  const kick = (S.boosting ? 16 : 0) + clamp((S.speed - BASE_V) / (REENTRY_V - BASE_V), 0, 1) * 10;   // FOV kick
  S.fov = damp(S.fov, baseFov + kick, 4, dt); camera.fov = Math.min(110, S.fov);
  camera.near = clamp(altOf(camera.position.y) * 0.0015, 0.4, 3); camera.updateProjectionMatrix();
}

/* --- environment: sky / fog / clouds / pad --- */
const hazeLow = new THREE.Color().setRGB(0.56, 0.17, 0.2).convertSRGBToLinear(), hazeHigh = new THREE.Color().setRGB(0.28, 0.42, 0.85).convertSRGBToLinear();
scene.fog = new THREE.Fog(hazeLow.clone(), 1500, 16000);
const hemi = new THREE.HemisphereLight(0x8a5a9a, 0x1a0c08, 1.3), sunL = new THREE.DirectionalLight(0xffcfa0, 3.2); sunL.position.copy(SUN).multiplyScalar(100); scene.add(hemi, sunL);
function updateEnv(dt) {
  const alt = altOf(camera.position.y), a01 = smooth((alt - 800) / 6000);
  U.alt01.value = a01; U.dip.value = Math.acos(clamp(PR / (PR + Math.max(alt, 0)), -1, 1));
  U.haze.value.copy(hazeLow).lerp(hazeHigh, smooth((alt - 3000) / 5000)).multiplyScalar(lerp(1, 0.5, a01));
  U.hazeDist.value = lerp(7000, 90000, smooth((alt - 500) / 9000));
  scene.fog.color.copy(U.haze.value); scene.fog.near = lerp(1800, 60000, a01); scene.fog.far = lerp(17000, 400000, a01);
  sky.position.copy(camera.position); stars.position.copy(camera.position);
  stars.material.uniforms.uA.value = smooth((alt - 2500) / 4500) * 1.1 + 0.05; stars.material.uniforms.uPx.value = pixelRatio;
  hemi.intensity = lerp(1.3, 0.45, a01); sunL.intensity = lerp(3.2, 4.2, a01); sunL.color.setRGB(1, lerp(0.81, 0.95, a01), lerp(0.63, 0.9, a01));
  const inC = smooth((alt - CLOUD_LO + 100) / 300) * (1 - smooth((alt - CLOUD_HI + 100) / 300));   // fly-through white-out
  const cfx = inC * (0.45 + 0.35 * Math.sin(camera.position.x * 0.011 + camera.position.z * 0.013) + 0.2 * Math.sin(S.t * 3));
  S.cloudFx = damp(S.cloudFx, clamp(cfx, 0, 0.85), 6, dt); H['h-cloud'].style.opacity = S.cloudFx.toFixed(3);
  const home = S.phase === 'return' || S.phase === 'landing';
  pad.beam.material.opacity = damp(pad.beam.material.opacity, home ? 0.55 : 0.12, 2, dt); const bs = home ? 1 + 0.08 * Math.sin(S.t * 4) : 1; pad.beam.scale.set(bs, 1, bs);
  pad.glow.material.opacity = (home ? 0.35 : 0.14) + 0.12 * Math.sin(S.t * (home ? 5 : 2));
  pad.rings[0].material.opacity = 0.7 + 0.3 * Math.sin(S.t * 3);
  dust.material.uniforms.uCam.value.copy(camera.position); dust.material.uniforms.uA.value = 0.35 + smooth((alt - 3000) / 3000) * 0.6; dust.material.uniforms.uPx.value = pixelRatio;
  const moving = !(S.phase === 'pad' || S.phase === 'parked' || S.phase === 'result' || S.phase === 'landing' || S.phase === 'count');
  const sf = moving ? clamp((S.speed - BASE_V * 1.05) / (BOOST_V - BASE_V), 0, 1.5) : 0;
  const su = streaks.material.uniforms; su.uA.value = damp(su.uA.value, clamp(sf * 0.8 + (S.boosting ? 0.5 : 0) + S.heat, 0, 1.6), 6, dt);
  su.uT.value += dt * (40 + S.speed * 0.5); su.uLen.value = 3 + S.speed * 0.025; su.uCol.value.setRGB(1, lerp(0.9, 0.55, S.heat), lerp(0.8, 0.25, S.heat));
  const sc = renderer.getDrawingBufferSize(tv).y * 0.5 / Math.tan(camera.fov * Math.PI / 360);
  sparks.p.material.uniforms.uScale.value = sc; orbPts.material.uniforms.uScale.value = sc;
}
function updateFx(dt) {
  flushPool(sparks);
  for (const f of flashes) { if (f.t >= f.life) { f.s.visible = false; continue; } f.t += dt; const k = Math.min(1, 