Zum Inhalt

src/engine/engine/scenes/city.ts

// CITY RECON — port of city-recon-flyover.html v2 patterns.
// Seeded chunks, 2-tile infinite scroll, setbacks, antennae+beacons,
// blinking windows, skybridges, power lines, rooftop tanks.
import * as THREE from 'three';
import type { SceneCtx, SceneModule, SceneUpdater } from './scene-base';
import { ALT_VALUES, SPEED_VALUES } from '../../data/defaults';
import { CameraFly } from '../camera-fly';
import { mkRng } from '../rng';

const CL = 320;  // chunk length
const SW = 22;   // corridor half-width

interface BlinkRef {
  mesh: THREE.Mesh; phase: number; speed: number; hi: number; lo: number;
}

function buildChunk(grp: THREE.Group, mats: SceneCtx['mats'], seed: number, blinks: BlinkRef[], enemyMats?: SceneCtx['enemyMats']) {
  const rng = mkRng(seed);
  const half = CL / 2;

  // Ground
  const gg = new THREE.PlaneGeometry(220, CL, 22, 44); gg.rotateX(-Math.PI / 2);
  grp.add(new THREE.Mesh(gg, mats.M({ transparent: true, opacity: 0.12 })));

  // Street grid
  const lp: number[] = [];
  for (const x of [-SW * 0.55, -SW * 0.27, 0, SW * 0.27, SW * 0.55])
    lp.push(x, 0.08, -half, x, 0.08, half);
  for (let z = -half + 18; z < half; z += 36) {
    lp.push(-SW * 0.6, 0.08, z, SW * 0.6, 0.08, z);
    if (rng() > 0.45) for (let s = 0; s < 6; s++) {
      const lx = -SW * 0.5 + s * (SW / 5);
      lp.push(lx, 0.08, z - 0.4, lx, 0.08, z - 1.3, lx, 0.08, z + 0.4, lx, 0.08, z + 1.3);
    }
  }
  const lg = new THREE.BufferGeometry();
  lg.setAttribute('position', new THREE.Float32BufferAttribute(lp, 3));
  grp.add(new THREE.LineSegments(lg, mats.ML(0.25)));

  // Buildings
  const BS = 22;
  const nb = Math.floor(CL / BS);
  for (let b = 0; b < nb; b++) {
    const bz = -half + b * BS + BS / 2;
    for (const side of [-1, 1]) {
      const nB = 1 + (rng() > 0.45 ? 1 : 0);
      for (let i = 0; i < nB; i++) {
        const w = 10 + rng() * 22, h = 14 + rng() * 78, d = 10 + rng() * 16, gap = 3 + rng() * 13;
        const xp = side * (SW + gap + w / 2);
        const zp = bz + (nB > 1 ? (i - 0.5) * BS * 0.42 : 0) + (rng() - 0.5) * 4;

        // Body
        const bx = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), mats.M({ transparent: true, opacity: 0.45 + rng() * 0.3 }));
        bx.position.set(xp, h / 2, zp); grp.add(bx);

        // Setback
        if (h > 40 && rng() > 0.45) {
          const tw = w * 0.56, td = d * 0.56, th = h * 0.2;
          const t1 = new THREE.Mesh(new THREE.BoxGeometry(tw, th, td), mats.M({ transparent: true, opacity: 0.52 + rng() * 0.28 }));
          t1.position.set(xp, h + th / 2, zp); grp.add(t1);
          if (rng() > 0.5) {
            const tw2 = tw * 0.54, th2 = th * 0.65;
            const t2 = new THREE.Mesh(new THREE.BoxGeometry(tw2, th2, tw2), mats.M({ transparent: true, opacity: 0.54 + rng() * 0.26 }));
            t2.position.set(xp, h + th + th2 / 2, zp); grp.add(t2);
          }
        }

        // Antenna + beacon
        if (h > 26 && rng() > 0.4) {
          const ah = 5 + rng() * 20;
          const ant = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.1, ah, 4), mats.M({ transparent: true, opacity: 0.88 }));
          ant.position.set(xp, h + ah / 2, zp); grp.add(ant);
          const bc = new THREE.Mesh(new THREE.SphereGeometry(0.22, 4, 4), mats.MSolid(0.9));
          bc.position.set(xp, h + ah, zp); grp.add(bc);
          blinks.push({ mesh: bc, phase: rng() * Math.PI * 2, speed: 0.5 + rng() * 2, hi: 0.9, lo: 0.04 });
        }

        // Windows — ~25% of buildings are "infectable": their windows crossfade to
        // the enemy colour as the narrative threat rises (a whole building goes red).
        const fl = Math.floor(h / 4.2), wc = Math.floor(w / 3.2);
        const wm = ((enemyMats && rng() < 0.25) ? enemyMats : mats).MSolid(0.9);
        for (let f = 0; f < fl; f++) for (let c = 0; c < wc; c++) if (rng() < 0.35) {
          const wx = new THREE.Mesh(new THREE.BoxGeometry(0.46, 0.56, 0.09), wm);
          wx.position.set(xp - w / 2 + 0.55 + c * (w / wc), 2 + f * 4.2, zp + (rng() > 0.5 ? d / 2 + 0.04 : -d / 2 - 0.04));
          grp.add(wx);
          if (rng() > 0.72) blinks.push({ mesh: wx, phase: rng() * Math.PI * 2, speed: 0.15 + rng() * 3.5, hi: 0.9, lo: 0.03 });
        }
      }
    }

    // Skybridge
    if (rng() > 0.7) {
      const lx = -(SW + 6 + rng() * 6), rx = SW + 6 + rng() * 6, by = 12 + rng() * 26;
      const pts = [
        new THREE.Vector3(lx, by, bz),
        new THREE.Vector3(0, by + 3 + rng() * 12, bz),
        new THREE.Vector3(rx, by, bz),
      ];
      grp.add(new THREE.Mesh(
        new THREE.TubeGeometry(new THREE.QuadraticBezierCurve3(pts[0], pts[1], pts[2]), 18, 0.11, 4, false),
        mats.M({ transparent: true, opacity: 0.65 }),
      ));
      for (const px of [lx, rx]) {
        const pil = new THREE.Mesh(new THREE.CylinderGeometry(0.07, 0.12, by, 4), mats.M({ transparent: true, opacity: 0.55 }));
        pil.position.set(px, by / 2, bz); grp.add(pil);
      }
    }

    // Power lines
    if (rng() > 0.62) {
      const wy = 9 + rng() * 16, lp2: number[] = [];
      for (let wire = 0; wire < 3; wire++) {
        const yo = wire * 0.45, sag = 0.75 + rng() * 0.4;
        for (let seg = 0; seg < 10; seg++) {
          const t0 = seg / 10, t1 = (seg + 1) / 10;
          lp2.push(
            -SW * 0.55 + t0 * SW * 1.1, wy + yo - 4 * sag * t0 * (1 - t0), bz,
            -SW * 0.55 + t1 * SW * 1.1, wy + yo - 4 * sag * t1 * (1 - t1), bz,
          );
        }
      }
      const lg2 = new THREE.BufferGeometry();
      lg2.setAttribute('position', new THREE.Float32BufferAttribute(lp2, 3));
      grp.add(new THREE.LineSegments(lg2, mats.ML(0.18)));
    }

    // Rooftop tank
    if (rng() > 0.74) {
      const side = rng() > 0.5 ? 1 : -1, tx = side * (SW + 8 + rng() * 10), th = 16 + rng() * 28;
      const tank = new THREE.Mesh(new THREE.CylinderGeometry(1.3, 1.3, 3.8, 8), mats.M({ transparent: true, opacity: 0.58 }));
      tank.position.set(tx, th + 1.9, bz + (rng() - 0.5) * 8);
      grp.add(tank);
    }
  }
}

export const CityScene: SceneModule = {
  modeLabels: ['LOW LEVEL', 'URBAN', 'HIGH PASS'] as const,
  triCount: '~52K',

  build(ctx: SceneCtx): SceneUpdater {
    const { world, cam, mats, scene, settings } = ctx;
    scene.fog = new THREE.FogExp2(0x000000, 0.008);

    const blinks: BlinkRef[] = [];
    const cA = new THREE.Group(), cB = new THREE.Group();
    // Use seeded rng for chunk seeds, but each chunk has its own RNG to keep layouts reproducible
    const baseSeed = (ctx.rng() * 0xffffffff) | 0;
    buildChunk(cA, mats, baseSeed + 42, blinks, ctx.enemyMats);
    buildChunk(cB, mats, baseSeed + 137, blinks, ctx.enemyMats);
    cA.position.z = -CL / 2;
    cB.position.z = -CL / 2 - CL;
    world.add(cA, cB);

    // Stars
    {
      const sv: number[] = [];
      const r = ctx.rng;
      for (let i = 0; i < 1200; i++) sv.push((r() - 0.5) * 700, 18 + r() * 200, -(r() * 800 + 60));
      const g = new THREE.BufferGeometry();
      g.setAttribute('position', new THREE.Float32BufferAttribute(sv, 3));
      world.add(new THREE.Points(g, mats.MP(0.14)));
    }

    // Skyline silhouette
    {
      const sg = new THREE.PlaneGeometry(550, 120, 85, 8); sg.rotateX(-Math.PI / 2);
      const sp = sg.attributes.position;
      const r = ctx.rng;
      for (let i = 0; i < sp.count; i++)
        sp.setY(i, Math.abs(Math.sin(sp.getX(i) * 0.024) * Math.cos(sp.getX(i) * 0.006)) * 76 + r() * 3);
      sp.needsUpdate = true;
      const sil = new THREE.Mesh(sg, mats.M({ transparent: true, opacity: 0.12 }));
      sil.position.z = -900; world.add(sil);
    }

    // Dust (streamed toward the camera in the updater — see below)
    const dustG = new THREE.BufferGeometry();
    {
      const dv: number[] = [];
      const r = ctx.rng;
      for (let i = 0; i < 600; i++) dv.push((r() - 0.5) * 80, r() * 55, -(r() * 700 + 20));
      dustG.setAttribute('position', new THREE.Float32BufferAttribute(dv, 3));
      world.add(new THREE.Points(dustG, mats.MP(0.065)));
    }

    cam.position.set(0, ALT_VALUES[settings.cityAltitude], 0);
    cam.rotation.set(-0.08, 0, 0);
    const fly = new CameraFly(Math.floor(ctx.rng() * 2e9), 8, 1.6, ALT_VALUES[settings.cityAltitude], -0.08, ctx.settings.bankStrength);

    let blinkTick = 0;

    return (t: number, _dt: number) => {
      const altTarget = ALT_VALUES[ctx.settings.cityAltitude];
      const spd = (ctx.speed?.() ?? SPEED_VALUES[ctx.settings.speed]) * 0.24;
      cA.position.z += spd; cB.position.z += spd;
      // Stream dust past the camera (same rate as the city scroll) — no constant-distance hang.
      const dp = dustG.attributes.position as THREE.BufferAttribute;
      for (let i = 0; i < dp.count; i++) {
        let dz = dp.getZ(i) + spd;
        if (dz > 10) dz -= 720;
        dp.setZ(i, dz);
      }
      dp.needsUpdate = true;
      if (cA.position.z > CL / 2 + 8) cA.position.z -= CL * 2;
      if (cB.position.z > CL / 2 + 8) cB.position.z -= CL * 2;

      fly.vertBase = altTarget;
      const f = fly.sample(t, spd * 60);
      cam.position.x = f.x; cam.position.y = Math.max(1.8, f.y);   // clamp above the street
      cam.rotation.set(f.pitch, f.yaw, f.roll);

      blinkTick++;
      if (blinkTick % 2 === 0) {
        for (const bw of blinks) {
          const on = Math.sin(t * bw.speed + bw.phase) > 0;
          (bw.mesh.material as any).opacity = on ? bw.hi : bw.lo;
        }
      }
    };
  },
};