Zum Inhalt

City 90° Corner — Implementation Plan

For agentic workers: this is the deferred hardest brick. It needs a build→look→tune loop (ideally on-device) — the corner geometry is bespoke and the seam where the two corridors meet is visually critical. Do NOT ship blind; verify each step with screenshots.

Goal: A convincing real 90° turn in the City scene — fly down a street canyon, round a corner at an intersection, continue down the cross-street. Per Johannes' choice: "echter 90°-Abzweig (groß)", not a banked approximation.

Architecture: Convert the City from the fixed +Z scroll-treadmill to the Void heading-frame model (camPath + heading + fwd/right), with a periodic ±π/2 heading turn at "intersections". Chunks become world-static and recycle heading-relative, rotated to the heading at respawn. A bespoke intersection piece bridges the two corridors at the turn.

Tech Stack: TypeScript/three.js (src/engine/engine/scenes/city.ts) + Swift/Metal (native/macos/KuroNativeSaver/Core/CityScene.swift). Templates: void.ts heading-frame (verified 2026-06-05), rift.ts seam continuity.

Recon map (full hooks, file:line): the roadmap-recon workflow output — section "recon:city-90". Read it before starting; it documents all 8 risks in detail.


Key decisions (resolved, so the build is unambiguous)

  1. Heading-frame, not scroll-axis-swap. The recon map's scroll-axis-swap (z↔x treadmills) needs two parallel treadmill systems + a bespoke corner. The Void heading-frame is already proven in this repo (void/wreckage curve + dodge) and unifies both legs into one model. Reuse it.
  2. Repeating turns, not a one-shot + warp-out. Keep the endless-screensaver model: heading turns ±π/2 periodically (every ~18-28s of flight) at "intersections". Avoids FilmDirector coordination and infinite-cross-street content worries.
  3. The corner is a rounded turn over ~2-3s, not a hard right-angle. A hard right-angle needs walls that meet at 90° (impossible to tile cleanly with straight chunks). A rounded turn (heading eases 0→±π/2 like void's turns, but a full quarter) lets the existing straight chunks approximate the bend as they recycle into the new heading — same trick that makes void's curve read. The "intersection" is the open plaza at the bend's apex.
  4. Plaza apex piece. At the apex, spawn a one-off open square (ground only, buildings pulled back) so the bend reads as a real intersection, not a kink in a wall. This is the only bespoke geometry.

Risks carried from the recon map (mitigations baked into the steps)

  • R1 treadmill not heading-aware → replaced wholesale by heading-frame (Task 2).
  • R3 CameraFly weave must be right-frame → apply f.x via right (Task 2, Step 3).
  • R4 stars/skyline hardcoded in z → re-seed stars isotropic; drop the z=-900 skyline plane (Task 4).
  • R6 native speedPerSec baked at init → switch to per-frame ctx.speed() (Task 5, Step 1).
  • R7 dust recycle is z-only → make heading-relative like void's speed particles (Task 3).
  • R8 chunk seam density jump → acceptable (already true on z); note in log, don't over-engineer.

Task 1: Heading-frame state + remove the +Z scroll (web)

Files: Modify src/engine/engine/scenes/city.ts (build closure + updater).

  • [ ] Add build-scope state mirroring void: let heading=0, headTarget=0, prevHeading=0, nextTurnT = 16 + rng()*10; const camPath = new THREE.Vector3(); const fwd = new THREE.Vector3(0,0,-1), right = new THREE.Vector3(1,0,0);
  • [ ] In the updater, replace cA.position.z += spd; cB.position.z += spd and the z-wrap with:
  • periodic turn: if (t > nextTurnT) { headTarget += (rng()<0.5?-1:1)*Math.PI/2; nextTurnT = t + 18 + rng()*10; } (a full quarter-turn).
  • ease: heading += (headTarget-heading)*(1-Math.exp(-dts/2.2)); (slower than void's 1.8 — a city turn is statelier). Set fwd/right from heading.
  • camPath.addScaledVector(fwd, spd*?) — match the old effective forward speed (old scroll was spd on a 320-long chunk; pick a forward rate that feels equivalent, ~spd*1.0, tune visually).
  • [ ] Verify: npm run typecheck. Scene still renders (turns will look wrong until Task 2).

Task 2: Chunk recycle heading-relative + rotated (web)

Files: Modify city.ts updater + chunk init.

  • [ ] Track each chunk's world center. Recycle when (center - camPath)·fwd < -CL/2: reposition to camPath + fwd*(CL*1.5) and set chunk.rotation.y = heading (so the corridor aligns to the current heading), and re-roll its seed for variety (rebuild or pre-build a small pool of 3-4 chunk groups and cycle them).
  • [ ] Camera pose: cam.position = camPath + right*(f.x) (weave in the right-frame, R3), cam.position.y = max(1.8, f.y), cam.rotation.y = heading + f.yaw, bank into the turn like void (f.roll + clamp(-turnRate*2.5, ±0.5)).
  • [ ] Verify with screenshots: fly straight, then a turn — the canyon should bend and the camera round it. Tune forward speed, turn ease, and chunk spacing until the bend reads.

Task 3: Dust heading-relative (web)

Files: city.ts updater dust loop.

  • [ ] Replace the dz > 10 → dz -= 720 z-recycle with void's speed-particle model: recycle when (pos - camPath)·fwd < near → respawn at camPath + fwd*far + right*spread.
  • [ ] Verify: dust streams along the flight direction through a turn (no pile-up — R7).

Task 4: Plaza apex piece + stars/skyline fix (web)

Files: city.ts.

  • [ ] Build a one-off plaza group (large ground plane, no buildings, maybe 2-4 landmark towers at the block corners). When a turn fires, position it at camPath + fwd*(turn lookahead) so the bend's apex lands on the open square. Hide/reposition it between turns.
  • [ ] Re-seed stars isotropic ((r()-0.5)*700 on BOTH x and z, not z-biased). Drop the z=-900 skyline plane (it only made sense for a fixed heading — R4).
  • [ ] Verify: a full turn reads as rounding an intersection, not clipping a wall.

Task 5: Native parity (CityScene.swift)

Files: Modify native/macos/KuroNativeSaver/Core/CityScene.swift; touch Renderer.swift only if needed.

  • [ ] Step 1: fix speedPerSec baked-at-init → read ctx.speed() per frame (R6). Mirror the void heading-frame fields + logic added in Tasks 1-3. The Chunk struct keeps items in local coords; per frame set model = translation(camPath-relative) * rotationY(heading).
  • [ ] Plaza piece as a non-recycling [DrawItem] added to items, repositioned per turn.
  • [ ] Verify: bash scripts/run-native-tests.sh (ALL PASS) + the harness compiles all Core/*.swift.

Task 6: Tune + commit

  • [ ] On-device (or web screenshots) tune: turn cadence, ease rate, plaza scale, seam at the bend. This is the build→look→tune loop the brick was deferred for.
  • [ ] Commit web + native together once the turn reads convincingly.

Self-review notes

  • The heading-frame conversion is the same shape as the void curve fix shipped 2026-06-05 (commit 348ba7f) — reuse that code as the literal template, including the lateral-recycle insight (LAT_MAX) so the canyon stays dense through the turn.
  • If the rounded-turn approximation looks too loose for "echter 90°-Abzweig", the fallback is the recon map's bespoke two-treadmill + hard corner — more work, sharper result. Decide after seeing the rounded version on-device.