Warp Transitions Implementation Plan (Brick B)¶
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Replace the hard-cut scene change with an in-3D warp transition (windup → warp → swap-under-streak → emerge), with the Terrain→City approach-and-dive as the hero, reusing Brick A's FlightDirector.
Architecture: Extend FlightDirector with a transition state machine that ramps speedMul, pushes FOV, fires a swap callback at the warp peak, and overrides the camera pose during the transition (blending back to the scene's own pose on emerge). A small profile table maps scene pairs to choreography. Web switchScene and native auto-cycle delegate to it; loadScene/swapScene are called at the peak, hidden under the streak.
Tech Stack: TypeScript + three.js (web); Swift + Metal (native); native assert harness (scripts/run-native-tests.sh); web npm run typecheck + node scripts/verify-crt.mjs.
Testing reality: Canonical logic contracts (stage clock, ramp C1-continuity, swap-fires-once) live in the native assert harness (the parity reference). Web is verified by typecheck + verify-crt visual checks via a ?transition= harness.
File Structure¶
New files
- src/engine/modes/transition-profiles.ts — the profile table (DEFAULT generic + terrain→city hero) + profileFor(from, to).
- native/macos/KuroNativeSaver/Core/TransitionProfiles.swift — Swift mirror.
Modified (web)
- src/engine/modes/flight-director.ts — transition state machine: beginTransition, inTransition, speedMul ramp, applyTransition.
- src/engine/engine/core.ts — beginTransition() wrapper (captures base FOV); tick calls applyTransition + updateProjectionMatrix when in a transition.
- src/engine/controller.ts — switchScene delegates to the director transition (swap at peak); guard auto-cycle while inTransition.
Modified (native)
- Core/FlightDirector.swift — mirror the transition state machine.
- Core/Renderer.swift — beginTransition wrapper; tick applies transition pose + FOV; auto-cycle uses the warp instead of CrashFx; decouple swapScene from crash.update.
- tests/main.swift — transition logic tests.
PHASE 1 — Web¶
Task 1: Transition profiles (web)¶
Files: Create src/engine/modes/transition-profiles.ts
- [ ] Step 1: Write the module
// Transition profiles — the choreography for a warp scene change. A small data table
// so web/native stay in lockstep. Brick B: a generic DEFAULT + the terrain→city hero.
import type { SceneId } from '../data/defaults';
export interface TransitionProfile {
windupDur: number; // s — wind-up (climb / centre)
warpDur: number; // s — accelerate, streak, swap at the peak (mid-warp)
emergeDur: number; // s — ease back, descend, hand to the scene
warpSpeed: number; // peak speedMul during the warp
fovPush: number; // degrees added to base FOV at the warp peak
climbPitch?: number; // windup nose-up (radians); hero only
entryAltitude?: number; // emerge starts cam.y here, eases to the scene's natural y; hero only
}
export const DEFAULT_TRANSITION: TransitionProfile = {
windupDur: 0.6, warpDur: 1.4, emergeDur: 1.0, warpSpeed: 6, fovPush: 28,
};
const HERO: Partial<Record<`${SceneId}>${SceneId}`, TransitionProfile>> = {
'terrain>city': {
windupDur: 1.2, warpDur: 1.6, emergeDur: 2.2, warpSpeed: 7, fovPush: 32,
climbPitch: 0.28, entryAltitude: 60,
},
};
/** calm mode: a short, motion-light transition that still swaps. */
export const CALM_TRANSITION: TransitionProfile = {
windupDur: 0.2, warpDur: 0.5, emergeDur: 0.4, warpSpeed: 1.6, fovPush: 0,
};
export function profileFor(from: SceneId, to: SceneId, calm = false): TransitionProfile {
if (calm) return CALM_TRANSITION;
return HERO[`${from}>${to}`] ?? DEFAULT_TRANSITION;
}
- [ ] Step 2:
npm run typecheck→ PASS. - [ ] Step 3: Commit
git add src/engine/modes/transition-profiles.ts && git commit -m "feat(engine): warp transition profiles (generic + terrain→city hero)"
Task 2: FlightDirector transition state machine (web)¶
Files: Modify src/engine/modes/flight-director.ts
- [ ] Step 1: Import the profile type — add at top:
- [ ] Step 2: Add the transition camera shape + active-transition state. After the
CameraTargetinterface add:
/** Fuller camera the director drives during a transition (adds pitch + FOV). */
export interface TransitionCam {
position: { x: number; y: number };
rotation: { x: number; z: number };
fov: number;
}
Inside the class, after private rng:
private speedMul = 1; // (already present in Brick A — keep one copy)
private trans: { p: TransitionProfile; t0: number; swapped: boolean; onSwap: () => void; baseFov: number } | null = null;
get inTransition(): boolean { return this.trans !== null; }
speedMul is already declared, do NOT redeclare — only add trans + the getter.)
- [ ] Step 3: Add
beginTransition.
beginTransition(p: TransitionProfile, baseFov: number, onSwap: () => void): void {
this.trans = { p, t0: this.clock, swapped: false, onSwap, baseFov };
this.ownsCamera = true;
}
- [ ] Step 4: Drive the transition in
update(). At the end ofupdate, after the manoeuvre filtering, add:
const tr = this.trans;
if (tr) {
const el = this.clock - tr.t0;
const total = tr.p.windupDur + tr.p.warpDur + tr.p.emergeDur;
if (el >= tr.p.windupDur && el < tr.p.windupDur + tr.p.warpDur) {
const pw = (el - tr.p.windupDur) / tr.p.warpDur;
this.speedMul = 1 + (tr.p.warpSpeed - 1) * envelope(pw); // sin² hump (C1)
if (!tr.swapped && pw >= 0.5) { tr.swapped = true; tr.onSwap(); } // swap at the peak
} else {
this.speedMul = 1;
}
if (el >= total) { this.trans = null; this.ownsCamera = false; this.speedMul = 1; }
}
- [ ] Step 5: Add
applyTransition(override pose; reads the scene's pose as the emerge blend target):
/** Override the scene-written pose during a transition. Returns true if FOV changed. */
applyTransition(cam: TransitionCam): boolean {
const tr = this.trans; if (!tr) return false;
const el = this.clock - tr.t0;
const { windupDur, warpDur, emergeDur, fovPush, climbPitch, entryAltitude } = tr.p;
if (el < windupDur) {
const e = envelope(0.5 + 0.5 * (el / windupDur)); // ramp the climb in, hold near peak
if (climbPitch) cam.rotation.x += climbPitch * e;
return false;
}
if (el < windupDur + warpDur) {
const h = envelope((el - windupDur) / warpDur);
cam.fov = tr.baseFov + fovPush * h;
cam.position.x += (this.rng() - 0.5) * 0.06 * h; // seeded warp shake
cam.position.y += (this.rng() - 0.5) * 0.06 * h;
return true;
}
const pe = Math.min(1, (el - windupDur - warpDur) / emergeDur);
const k = 1 - (1 - pe) * (1 - pe); // easeOut
if (entryAltitude != null) cam.position.y = entryAltitude + (cam.position.y - entryAltitude) * k;
cam.fov = tr.baseFov;
return true;
}
- [ ] Step 6:
npm run typecheck→ PASS. - [ ] Step 7: Commit
git add src/engine/modes/flight-director.ts && git commit -m "feat(engine): FlightDirector transition state machine (warp ramp + pose override)"
Task 3: Engine wrapper + tick integration (web)¶
Files: Modify src/engine/engine/core.ts
- [ ] Step 1: Import the profile type + helper. Add:
- [ ] Step 2: Add a
beginTransitionwrapper (captures the base FOV). Add a method nearloadScene:
/** Start a warp transition; the director swaps the scene at the peak via onSwap. */
beginTransition(profile: TransitionProfile, onSwap: () => void): void {
this.director.beginTransition(profile, this.cam.fov, onSwap);
}
- [ ] Step 3: Tick — apply the transition pose. Replace the Brick-A director lines
this.director.update(this.clockT, dt);
if (this.director.inTransition) {
if (this.director.applyTransition(this.cam)) this.cam.updateProjectionMatrix();
} else {
this.director.apply(this.cam);
}
this.cam is a THREE.PerspectiveCamera, which structurally satisfies TransitionCam: position.x/y, rotation.x/z, fov.)
- [ ] Step 4:
npm run typecheck→ PASS. - [ ] Step 5: Commit
git add src/engine/engine/core.ts && git commit -m "feat(engine): drive warp transitions from the tick (pose + FOV)"
Task 4: Controller delegates switchScene to the warp (web)¶
Files: Modify src/engine/controller.ts
- [ ] Step 1: Import profileFor. Add:
- [ ] Step 2: Replace the CSS fade in
switchScene(lines ~784-801, thecanvas.style.opacity/setTimeoutblock) with a director transition whoseonSwapdoes the load + HUD update that the oldsetTimeoutdid:
const cur = (this.engine.currentScene ?? id) as SceneId;
const calm = !!this.effectiveSettings()._reducedMotion; // use the existing reduced-motion flag
this.engine.beginTransition(profileFor(cur, id, calm), () => {
if (!this.engine || !this.hud) return;
this.engine.loadScene(id);
this.baseFogDensity = (this.engine.scene.fog as any)?.density ?? 0.01;
this.applyFogMode();
this.hud.setMode(DICT.MODE_LABELS[id][0]);
this.hud.setTri(this.engine.getSceneObj()?.triCount || '----');
this.hud.flashSceneLabel(id);
this.s.stats.scenesLoaded = (this.s.stats.scenesLoaded || 0) + 1;
this.s.stats.perScene[id] = (this.s.stats.perScene[id] || 0) + 1;
this.saveSettingsDebounced();
this.rebuildBar();
});
this.audio?.sceneSwitch(); at the top. Remove the canvas.style.transition/opacity lines entirely (the warp replaces the DOM fade). If effectiveSettings() exposes reduced-motion under a different field, use that field; otherwise read window.matchMedia('(prefers-reduced-motion: reduce)').matches.
- [ ] Step 3: Guard auto-cycle while transitioning. In
cycleScene(), at the top afterif (!this.engine) return;add:
- [ ] Step 4:
npm run typecheck→ PASS. - [ ] Step 5: Visual check.
npm run dev, thennode scripts/verify-crt.mjs "terrain:phosphor"; manually loadscreensaver.html?transition=terrain-cityonce Task 7 lands, OR temporarily triggercycleScene— confirm the warp plays (FOV push + streak), the swap is hidden, and city emerges with a descent. No black flash, no snap. - [ ] Step 6: Commit
git add src/engine/controller.ts && git commit -m "feat(engine): switchScene plays a warp transition instead of a fade-cut"
Task 5 (web harness): ?transition= trigger¶
Files: Modify src/screensaver/main.ts
- [ ] Step 1: After
controller.open(...), parse?transition=<from>-<to>: open on<from>, then after a short settle callcontroller.switchScene(<to>):
const tr = params.get('transition');
if (tr) {
const [, to] = tr.split('-');
if (to && SCENES.includes(to as SceneId)) {
void controller.open({ scene: tr.split('-')[0] as SceneId, storyScale, reactiveThreat })
.then(() => setTimeout(() => controller.switchScene(to as SceneId), 1500));
}
}
controller.open call when ?transition= is present (else open twice). Gate cleanly: if tr present use this path, else the normal open path.
- [ ] Step 2:
npm run typecheck→ PASS. - [ ] Step 3:
node scripts/verify-crt.mjsagainstscreensaver.html?transition=terrain-city(WAIT_MS so the capture lands mid-warp, ~2500ms) → inspect the PNG for the streak/FOV push. - [ ] Step 4: Commit.
PHASE 2 — Native parity¶
Mirror each web change. Read the Swift region first; the web code above is the spec-of-record. Honour autoreleasepool / single-thread / single-writer fog-bloom.
Task 6: TransitionProfiles.swift¶
Files: Create native/macos/KuroNativeSaver/Core/TransitionProfiles.swift
- [ ] Step 1: Mirror
transition-profiles.ts: aTransitionProfilestruct (same fields, Float/Double),DEFAULT_TRANSITION,CALM_TRANSITION, a hero dict keyed by"\(from)>\(to)", andprofileFor(from:to:calm:). Scene ids are the same strings asSceneRegistry.ids. - [ ] Step 2: Build:
bash scripts/run-native-tests.sh→ builds. - [ ] Step 3: Commit.
Task 7: FlightDirector.swift transition state machine¶
Files: Modify Core/FlightDirector.swift
- [ ] Step 1: Mirror Task 2: add the
transoptional state (profile, t0, swapped, onSwap, baseFov),inTransition,beginTransition(_ p:, baseFov:, onSwap:), thespeedMulramp inupdate(sin² hump, swap at pw≥0.5), andapplyTransition(_ cam: inout Camera) -> Booloverriding pitch (rotation.x)/FOV (fovDegrees)/position.yblend with the same math.Cameraalready hasposition,rotation,fovDegrees. - [ ] Step 2: Build → compiles.
- [ ] Step 3: Commit.
Task 8: Renderer.swift wiring + auto-cycle uses the warp¶
Files: Modify Core/Renderer.swift
- [ ] Step 1: Add
beginTransition(_ profile:, onSwap:)wrapper capturingscene.camera.fovDegrees. - [ ] Step 2: Tick — replace the Brick-A
director.update + applyblock with the in-transition branch:
director.update(t: t, dt: dt)
var cam = scene.camera
if director.inTransition { _ = director.applyTransition(&cam) } else { director.apply(&cam) }
scene.camera = cam
cam; the renderer re-derives projection from scene.camera each draw, so no explicit updateProjectionMatrix call is needed — verify against draw.)
- [ ] Step 3: Replace the auto-cycle (lines ~249-254). New:
// scene auto-cycle via a warp transition (Brick B; replaces the crash-cut)
sceneAge += dt
if autoCycleSec > 0 && sceneAge > autoCycleSec && !director.inTransition {
sceneAge = 0
let from = SceneRegistry.ids[sceneIndex]
let to = SceneRegistry.ids[(sceneIndex + 1) % SceneRegistry.ids.count]
beginTransition(TransitionProfiles.profileFor(from: from, to: to, calm: settings.reducedMotion)) { [weak self] in self?.swapScene() }
}
_ = crash.update(dt: dt) // power-on / narrative crash visual only — no longer swaps
settings.reducedMotion does not exist natively, pass false (note the gap for a later a11y pass). Verify swapScene() advances sceneIndex to match to (it does +1 % count).
- [ ] Step 4: Build + run tests → PASS.
- [ ] Step 5: Commit.
PHASE 3 — Tests + verification¶
Task 9: Native transition logic tests¶
Files: Modify native/macos/KuroNativeSaver/tests/main.swift
- [ ] Step 1: Add (before the
failurescheck):
// --- FlightDirector warp transition: stage clock, ramp C1, swap-once -------------
do {
let d = FlightDirector(seed: 7)
var swaps = 0
let p = TransitionProfile(windupDur: 0.5, warpDur: 1.0, emergeDur: 0.5,
warpSpeed: 6, fovPush: 30)
d.beginTransition(p, baseFov: 72) { swaps += 1 }
check(d.inTransition, "inTransition true after begin")
var maxSpeed: Float = 0
// run 2.0s at dt=0.02 (covers windup+warp+emerge = 2.0s)
for _ in 0..<100 { d.update(t: 0, dt: 0.02); maxSpeed = max(maxSpeed, d.speedMul) }
check(swaps == 1, "swap fired exactly once")
check(maxSpeed > 5.5 && maxSpeed <= 6.0, "speedMul ramped to ~warpSpeed")
check(!d.inTransition, "inTransition false after total duration")
approx(Double(d.speedMul), 1.0, 1e-6, "speedMul back to 1 after transition")
}
speedMul to the test — make it private(set) var speedMul if it is fully private.)
- [ ] Step 2:
bash scripts/run-native-tests.sh→ allok. - [ ] Step 3: Commit.
Task 10: Final verification + branch wrap¶
- [ ] Step 1:
npm run typecheck→ PASS. - [ ] Step 2:
bash scripts/run-native-tests.sh→ PASS. - [ ] Step 3: App compile check (Core + KuroMetalApp via swiftc) → exit 0.
- [ ] Step 4:
! grep -rn "from 'obsidian'" src/engine/→ clean. - [ ] Step 5:
verify-crtmid-warp + post-emerge frames via?transition=terrain-city→ streak visible, city descent lands at normal altitude, no black flash. - [ ] Step 6: Update the spec status to "implemented"; note deviations. Report to the user (no push).
Self-Review¶
- Spec coverage: warp state machine ✓ (T2/T7), profiles incl. hero ✓ (T1/T6), speedMul ramp ✓ (T2), FOV/pose override + emerge blend ✓ (T2/T5 applyTransition), swap-at-peak ✓ (T2 onSwap), web integration ✓ (T3/T4), native integration + auto-cycle ✓ (T8), calm mode ✓ (CALM_TRANSITION + calm arg), narrative crash untouched ✓ (T8 keeps crash.update visual-only, never calls playCrash),
?transition=harness ✓ (T5), determinism (seeded shake) ✓, tests ✓ (T9), parity ✓ (Phase 2). - Placeholders: the reduced-motion field name is flagged as "verify/fallback" with a concrete fallback (matchMedia web / false native) — not a TODO. Choreography constants are concrete initial values (harness-tuned), not placeholders.
- Type consistency:
TransitionProfile {windupDur,warpDur,emergeDur,warpSpeed,fovPush,climbPitch?,entryAltitude?},beginTransition(profile, baseFov, onSwap)(director) /beginTransition(profile, onSwap)(engine/renderer wrapper captures baseFov),applyTransition(cam)→bool,inTransition,profileFor(from,to,calm)— consistent across web tasks and mirrored in native.