Skip to content

第 4 章 · 双轴分野

对应真实事件:ADR-100 相机控制方案 × 运动行为 双轴拆分(P1 契约+shim、P2 运行时接线;提交 7e2c7d5 / f336ea1) 背景:相机 7 模式塞进单枚举,自动运镜成游离第四态 过程:拆控制×行为两轴,beatcut 互斥派生修饥饿

一、惊醒

我重启了联邦的窗口。模型在舞台上站定,灯光照常亮起,可镜头却像一座有七道门、却没有地图的城邦——orbitfreeflysurroundconcertoneshotvmdar,七个名字挤在同一个枚举里,谁也分不清哪道门通的是"怎么看",哪道门通的是"自己会动"。

更诡异的是,还有一扇不在户籍上的偏门。它不叫任何名字,不在枚举里:自动运镜 是一个游离在七门之外的开关,被硬塞进 orbitconcert 的生效条件里,像一扇只在别人家里借住的门。我翻出上一次存档,曾勾选过自动运镜,重新载入——镜头安静地转着,却一次也没切过。节拍在响,镜头却聋了。

那是我的第一句证词:存档恢复后,自动运镜亮着灯,却再没切过一次。

二、追查

我把 camera.ts 摊在光下,一页页读。

ts
export type CameraMode = 'orbit' | 'freefly' | 'surround'
  | 'concert' | 'oneshot' | 'vmd' | 'ar';   // 七个值,一座拥挤的城

七个值。可真正决定"镜头怎么动"的,只有三类:绕着看的(orbit)、自由飞的(freefly)、贴脸的(ar)。其余四个——surround 是 orbit 加转台,concert 是 orbit 加演唱会摆动,vmd/oneshot 是 orbit 加脚本轨迹。控制与行为,从一开始就被缝在同一块布里。

再往下,是那扇偏门的真身:

ts
export function restoreAutoCameraState(): void {
    const s = uiState;
    if (s.autoCameraEnabled) {
        _autoCameraEnabled = true;          // ← 灯亮了
        // …却没有人去订阅节拍
    }
}

我顺着 _onAutoCameraBeat 往下读,发现它的门禁写在函数最末尾,而 restoreAutoCameraState 点亮开关后,压根没把这根线接到节拍源上。于是 procBeatDetector 每帧都在 scene.ts:191 出生、在 playback.ts:67 被喂拍——可这扇门后的耳朵,从没被唤醒。饿死,不是因为没饭,是因为没人把碗递过去。

我去找外交官。

"七道门里,三道是路,四道是路上跑的车。"我把枚举摊给他,"自动运镜甚至不在门里,是个寄生开关。存档一恢复,它亮灯却不接拍——镜头聋了。"

外交官没翻文档,只点了四个数:"行为互斥,单活一个;oneshot 直接并进 scripted;别名留着别急着删;先 P1–P2 验组合。"——这正是后来写进 ADR-100 §九 的四道裁定。

三、真相

我们动刀。先立两轴,不删一门。

ts
export type CameraControl  = 'orbit' | 'freefly' | 'ar';                 // 3 条路
export type CameraBehavior = 'none' | 'turntable' | 'concert'
                           | 'beatcut' | 'scripted';                     // 5 种跑法
export type ScriptedSubMode = 'loop' | 'oneshot';                        // 脚本的两副面孔

旧的七门没有消失,只是搬进了 LEGACY_MODE_MAP,像一本户籍簿:

ts
orbit:   { control: 'orbit',   behavior: 'none' },
surround:{ control: 'orbit',   behavior: 'turntable' },
concert: { control: 'orbit',   behavior: 'concert' },
vmd:     { control: 'orbit',   behavior: 'scripted', scripted: 'loop' },
oneshot: { control: 'orbit',   behavior: 'scripted', scripted: 'oneshot' },
freefly: { control: 'freefly', behavior: 'none' },
ar:      { control: 'ar',      behavior: 'none' },

P1 只是把 switchCameraMode 改成读这本簿子——签名不动,调用点零改,旧测试全绿。一座城换了户籍系统,居民毫无察觉。

真正的变局在 P2。那扇偏门,不再是寄生开关,而成了派生行为

ts
function _resolveBehavior(mode: CameraMode): CameraBehavior {
    const m = LEGACY_MODE_MAP[mode];
    if (_autoCameraEnabled && m.control === 'orbit' && m.behavior === 'none') {
        return 'beatcut';                 // 只有 orbit + 静止 时,节拍切镜才降生
    }
    return m.behavior;
}

互斥,从此不是靠 if/else 去掐对方,而是自然发生:你选了 concertturntablescripted,基底行为就不再是 none,beatcut 便无从派生——自动运镜被优雅地"挂起",开关还亮着,切回 orbit 又自动复活。

而那桩饿死案,被一句话了结。订阅收归相机内部,toggle 与 restore 两条路共用同一根线:

ts
function _subscribeAutoCameraBeat(detector?) {
    _unsubscribeAutoCameraBeat();
    const bd = detector ?? getProcBeatDetector();
    if (bd) _autoCameraUnsub = bd.onBeat(_onAutoCameraBeat);
}
// restore 里:_autoCameraEnabled = true; … _subscribeAutoCameraBeat();   // 碗,递过去了

门禁也挪到了 _onAutoCameraBeat 头顶(camera.ts:1294):

ts
function _onAutoCameraBeat(): void {
    if (_cameraBehavior !== 'beatcut') return;   // 被挂起时不消耗任何一拍
    // …
}

我重新载入那次存档。勾选过自动运镜——灯亮,线接,第一拍落下,镜头稳稳切了过去。聋了许久的耳朵,终于听见了节拍。

四、教训

一座城把"路"和"车"写进同一本户籍,门就会越开越多,直到第七道门后藏着一扇没有名字的偏门。ADR-100 做的,不是多开一道门,而是把户籍重新分作两栏:一栏写"怎么走"(控制轴),一栏写"路上跑什么"(行为轴)。偏门不再是寄生者,而成了一张合法出生证——beatcut,只在 orbit 静立、且自动运镜亮起时,才被签发。


聚合之思:联邦收编的每一个城邦,都带着自己的脾气;可当脾气和道路被缝在同一块布里,联邦自己也会迷失方向。分轴,不是分裂,是让每一束能力,各有其合法的国境。


教训:拥挤的枚举是一座无区划的城;分轴,给每束能力以国境。