<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>架构设计 on 蜂窝</title><link>https://yufenghuang.tech/tags/%E6%9E%B6%E6%9E%84%E8%AE%BE%E8%AE%A1/</link><description>Recent content in 架构设计 on 蜂窝</description><generator>Hugo -- gohugo.io</generator><language>zh-cn</language><managingEditor>yufenghuang009@gmail.com (Evan Wong)</managingEditor><webMaster>yufenghuang009@gmail.com (Evan Wong)</webMaster><copyright>EvanWong</copyright><lastBuildDate>Mon, 12 Oct 2026 09:20:00 +0800</lastBuildDate><atom:link href="https://yufenghuang.tech/tags/%E6%9E%B6%E6%9E%84%E8%AE%BE%E8%AE%A1/index.xml" rel="self" type="application/rss+xml"/><item><title>HeirCut 开发手记（三）：把剧情从状态机里搬出来</title><link>https://yufenghuang.tech/p/heircut-devlog-encounter-refactor/</link><pubDate>Mon, 12 Oct 2026 09:20:00 +0800</pubDate><author>yufenghuang009@gmail.com (Evan Wong)</author><guid>https://yufenghuang.tech/p/heircut-devlog-encounter-refactor/</guid><description>&lt;blockquote&gt;
&lt;p&gt;「HeirCut 开发手记」第三篇。游戏简介见&lt;a class="link" href="https://yufenghuang.tech/p/heircut-devlog-submit-guidance/" &gt;第一篇&lt;/a&gt;文末。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;《宫廷理发师》的剧情靠换词推动：理发师把宾客说过的词「剪」下来，换进别人的句子里，对方就会做出不同的决定，后面几天来店里的客人和他们说的话也跟着改变。&lt;/p&gt;
&lt;p&gt;这套剧情系统我写过两遍。第一版是每个客人身上挂一台状态机，写起来快，但在换词面前一路膨胀，最后谁也不敢动。第二版把剧情改成了数据，由一个不依赖 Unity 的纯逻辑层来推进。&lt;/p&gt;
&lt;p&gt;&lt;img src="https://yufenghuang.tech/p/heircut-devlog-encounter-refactor/architecture.svg"
loading="lazy"
alt="重构前后对比：之前剧情分支写在 GuestManager 和每个客人的专属状态里；之后剧情是 JSON 内容，由纯逻辑的 EncounterSession 推进，经桥和端口驱动旧视图"
&gt;&lt;/p&gt;
&lt;p&gt;这篇记录两件事：第一版为什么撑不住，以及我们怎么在游戏一直能玩的前提下，把它一点点换掉。&lt;/p&gt;
&lt;h2 id="第一版每个客人一台状态机"&gt;&lt;a href="#%e7%ac%ac%e4%b8%80%e7%89%88%e6%af%8f%e4%b8%aa%e5%ae%a2%e4%ba%ba%e4%b8%80%e5%8f%b0%e7%8a%b6%e6%80%81%e6%9c%ba" class="header-anchor"&gt;&lt;/a&gt;第一版：每个客人一台状态机
&lt;/h2&gt;&lt;h3 id="为什么选状态机"&gt;&lt;a href="#%e4%b8%ba%e4%bb%80%e4%b9%88%e9%80%89%e7%8a%b6%e6%80%81%e6%9c%ba" class="header-anchor"&gt;&lt;/a&gt;为什么选状态机
&lt;/h3&gt;&lt;p&gt;最初的设计里，每位客人的流程都是固定的一套：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;进门 → 寒暄 → 分析需求 → 理发 → 满意 / 不满意 → 闲聊 → 离开&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;这几乎就是为状态机准备的。每位客人身上挂一台状态机，每个阶段一个状态类，一个状态做完自己的事，就切到下一个。在 GameJam 的节奏下，这样写又快又清楚，这个选择在当时没有错。&lt;/p&gt;
&lt;h3 id="换词接不进去"&gt;&lt;a href="#%e6%8d%a2%e8%af%8d%e6%8e%a5%e4%b8%8d%e8%bf%9b%e5%8e%bb" class="header-anchor"&gt;&lt;/a&gt;换词接不进去
&lt;/h3&gt;&lt;p&gt;问题出在换词。换词的效果是「在某句话里换了某个词，剧情就走向另一个分支」。它可能发生在流程的任何位置，可能改变后面的一切，而且每位客人、每个词的后果都不一样。&lt;/p&gt;
&lt;p&gt;状态机里没有一个自然的位置来放它。最后的做法是：&lt;strong&gt;每种换词结果，都接一个专属状态&lt;/strong&gt;。&lt;/p&gt;
&lt;div class="code-block" data-lang="csharp"&gt;&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;span class="lnt"&gt;7
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-csharp" data-lang="csharp"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// 女巫的一次换词生效后，管理器收到一个专属事件，然后临时塞进一个专属状态&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="n"&gt;OnD1WitchSatTimeToDawn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;D1WitchSetTimeToDawnEvent&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;_currentGuest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fsm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;D1WitchDawn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_currentGuest&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;_currentGuest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fsm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SwitchState&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;D1WitchDawn&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;_groupIndex&lt;/span&gt;&lt;span class="p"&gt;++;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;/div&gt;
&lt;p&gt;规则函数里也开始出现写死的角色和线索编号：&lt;/p&gt;
&lt;div class="code-block" data-lang="csharp"&gt;&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-csharp" data-lang="csharp"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// 女巫中午来卷发会烫焦头发，除非线索 4 或 5 被打过勾&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;guestName&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;GuestName&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Witch&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;!&lt;/span&gt;&lt;span class="n"&gt;clue4Ticked&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;!&lt;/span&gt;&lt;span class="n"&gt;clue5Ticked&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;/div&gt;
&lt;p&gt;膨胀来得比我印象中早得多。翻早期的版本记录：状态机文件在建库第二周出现，一开始只有 8 个通用状态；&lt;strong&gt;又过了一周多，接第一天的女巫时，就已经多出了 3 个剧情专属状态&lt;/strong&gt;。之后每接一组客人就长几个，不到三个月涨到 13 个，之后一年多没再变，最后一个加在重构开始的前两天。&lt;/p&gt;
&lt;p&gt;&lt;img src="https://yufenghuang.tech/p/heircut-devlog-encounter-refactor/story-states.svg"
loading="lazy"
alt="剧情专属状态的数量：项目第一个月开始出现，不到三个月涨到 13 个，重构前达到 14 个，重构时一次删光"
&gt;&lt;/p&gt;
&lt;p&gt;其实那时就察觉到不对了。早期的提交记录里能看到几次挣扎：第二个月先后「搭多客人框架，为重构换词系统做准备」「重构客人系统」，第三个月又「准备重置换词功能」。但状态机这个骨架一直没动，专属状态还在继续增加。推倒重来的成本太大，而比赛有截止日期，只能将错就错。&lt;/p&gt;
&lt;h3 id="它到底哪里不好"&gt;&lt;a href="#%e5%ae%83%e5%88%b0%e5%ba%95%e5%93%aa%e9%87%8c%e4%b8%8d%e5%a5%bd" class="header-anchor"&gt;&lt;/a&gt;它到底哪里不好
&lt;/h3&gt;&lt;p&gt;参展之后，我写了一份重构计划，第一节是诊断，归纳下来是四个根因：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;没有唯一的流程所有者。&lt;/strong&gt; 剧情分支、对话播放、UI 动画、异步加载都混在角色状态里。多人同时出场时，谁来推进剧情，取决于哪个角色的资源先加载完。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;剧情写在代码里。&lt;/strong&gt; 线索、道具、对话直接引用 C# 的状态类、事件类和数组下标。策划写好的剧情，必须由我翻译成代码才能生效。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;存档只能存到「第几天第几组客人」。&lt;/strong&gt; 一次会面进行到哪一步，没办法表达，读档就得从这组客人进门重来。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;资源没有稳定的标识。&lt;/strong&gt; 文件路径和文件名承担了运行时语义，改个名字就可能出问题。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;最折磨人的是第二条。策划在表格里写好剧情，我照着表格改代码，每改一处都要翻好几个文件。&lt;/p&gt;
&lt;h2 id="第二版剧情是数据"&gt;&lt;a href="#%e7%ac%ac%e4%ba%8c%e7%89%88%e5%89%a7%e6%83%85%e6%98%af%e6%95%b0%e6%8d%ae" class="header-anchor"&gt;&lt;/a&gt;第二版：剧情是数据
&lt;/h2&gt;&lt;h3 id="一次会面一个推进者"&gt;&lt;a href="#%e4%b8%80%e6%ac%a1%e4%bc%9a%e9%9d%a2%e4%b8%80%e4%b8%aa%e6%8e%a8%e8%bf%9b%e8%80%85" class="header-anchor"&gt;&lt;/a&gt;一次会面，一个推进者
&lt;/h3&gt;&lt;p&gt;新架构的核心概念是&lt;strong&gt;会面&lt;/strong&gt;（Encounter）：一组客人从进门到离开是一次会面，由一个会话对象作为&lt;strong&gt;唯一&lt;/strong&gt;的推进者。会话的状态只有三样：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;里程碑&lt;/strong&gt;：进门、等待对话、等待分析、准备理发、理发中、展示结果、理发后互动、等待离开、结束。所有客人共用这一副骨架。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;事实&lt;/strong&gt;：剧情状态，比如「国王已经做出决定」。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;正在播放的演出&lt;/strong&gt;，以及播到了第几步。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="玩家的操作是命令剧情的分支是规则"&gt;&lt;a href="#%e7%8e%a9%e5%ae%b6%e7%9a%84%e6%93%8d%e4%bd%9c%e6%98%af%e5%91%bd%e4%bb%a4%e5%89%a7%e6%83%85%e7%9a%84%e5%88%86%e6%94%af%e6%98%af%e8%a7%84%e5%88%99" class="header-anchor"&gt;&lt;/a&gt;玩家的操作是命令，剧情的分支是规则
&lt;/h3&gt;&lt;p&gt;玩家的每个操作都被翻译成一条&lt;strong&gt;命令&lt;/strong&gt;：推进对话、确认换词、递道具、点热点、提交分析、完成理发、摇铃，一共九种。命令进来后，会话在内容里找一条匹配的&lt;strong&gt;规则&lt;/strong&gt;：当前在哪个里程碑、是什么命令、对着谁、在哪句话的哪个槽里换进了哪个词。匹配上了，就播放规则指定的那段演出。&lt;/p&gt;
&lt;p&gt;以前「女巫换词后塞进一个专属状态」的写法，现在变成了内容里的一条规则。比如国王的一次换词（节选）：&lt;/p&gt;
&lt;div class="code-block" data-lang="json"&gt;&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-json" data-lang="json"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;ruleId&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;flow.d1.king.confirm_giver&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;fromMilestone&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;PostHaircutInteraction&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;commandType&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;ConfirmReplacement&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;filterLineId&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;line.d1.king.entry.010&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;filterSlotId&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;slot.king.intent&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;conditionId&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;condition.d1.king.giver_available&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;sequenceId&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;seq.d1.king.replace_first&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;priority&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;210&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;factMutations&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;fact.d1.king.decision_ready=Boolean:true&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;/div&gt;
&lt;p&gt;读法是：在理发后的互动阶段，玩家对国王某句话里的某个槽确认了换词，并且条件满足，就播放 &lt;code&gt;replace_first&lt;/code&gt; 这段演出，结束后记下「国王已经做出决定」。&lt;/p&gt;
&lt;p&gt;演出由十几种通用步骤组成：说话、等待推进、换表情、移动角色、设置事实、给线索、开始理发、分支……没有一种是为某个角色写的。&lt;/p&gt;
&lt;p&gt;有两条约定让这套东西比较牢靠：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;同一优先级有两条规则同时匹配时，直接拒绝。&lt;/strong&gt; 这说明内容写错了，不应该让运行时去猜。导入内容时也会先检查一遍。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;事实和里程碑只在一段演出结束时统一提交。&lt;/strong&gt; 演出播到一半存档、读档，看到的永远是「这段开始前」或「这段结束后」的状态，不会出现对话说了一半、剧情只改了一半。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="不依赖-unity-的好处"&gt;&lt;a href="#%e4%b8%8d%e4%be%9d%e8%b5%96-unity-%e7%9a%84%e5%a5%bd%e5%a4%84" class="header-anchor"&gt;&lt;/a&gt;不依赖 Unity 的好处
&lt;/h3&gt;&lt;p&gt;新架构里负责领域模型、内容和推进的几个程序集，都声明了不引用 UnityEngine。这个约束一开始像是自找麻烦，后来回报很大：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;测试不用启动 Unity，直接用 &lt;code&gt;dotnet&lt;/code&gt; 编译运行；&lt;/li&gt;
&lt;li&gt;后来写的&lt;strong&gt;离线推演器&lt;/strong&gt;能在纯逻辑层穷举整局游戏，检查五个结局是否都可达、有没有死路、哪些台词永远播不到；&lt;/li&gt;
&lt;li&gt;策划编辑器里的「试玩」，用的也是同一份逻辑。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="怎么在不停工的情况下换掉它"&gt;&lt;a href="#%e6%80%8e%e4%b9%88%e5%9c%a8%e4%b8%8d%e5%81%9c%e5%b7%a5%e7%9a%84%e6%83%85%e5%86%b5%e4%b8%8b%e6%8d%a2%e6%8e%89%e5%ae%83" class="header-anchor"&gt;&lt;/a&gt;怎么在不停工的情况下换掉它
&lt;/h2&gt;&lt;h3 id="为什么不停下来重写"&gt;&lt;a href="#%e4%b8%ba%e4%bb%80%e4%b9%88%e4%b8%8d%e5%81%9c%e4%b8%8b%e6%9d%a5%e9%87%8d%e5%86%99" class="header-anchor"&gt;&lt;/a&gt;为什么不停下来重写
&lt;/h3&gt;&lt;p&gt;参赛之后我们决定继续做，也终于有了时间重构。但停下来重写不现实：这期间随时可能要出一个能玩的版本，给发行看，或者给玩家试玩。所以必须保证&lt;strong&gt;任何时候游戏都能完整跑通&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;最后的做法是新旧两套并存，一组客人一组客人地迁移。新架构写成纯逻辑；旧架构继续跑还没迁移的客人；中间只有一座&lt;strong&gt;桥&lt;/strong&gt;，把旧的视图和输入翻译成新架构的命令，再把新架构要播放的步骤翻译成对旧视图的调用。&lt;/p&gt;
&lt;p&gt;桥一开始也是直接去拿各种单例的，最多时有 81 处。后来改成&lt;strong&gt;端口&lt;/strong&gt;：桥只声明自己需要哪些能力（对话、需求板、笔记本、理发……），实现放在视图那一侧，由外面构造好交给桥。做完这一步，桥对旧单例的引用降到了 0。&lt;/p&gt;
&lt;h3 id="先迁最难的三组"&gt;&lt;a href="#%e5%85%88%e8%bf%81%e6%9c%80%e9%9a%be%e7%9a%84%e4%b8%89%e7%bb%84" class="header-anchor"&gt;&lt;/a&gt;先迁最难的三组
&lt;/h3&gt;&lt;p&gt;迁移按阶段推进：先以参展版为准，逐组客人记录每条路线该发生什么；再搭纯逻辑骨架和内容校验；然后才开始迁客人。&lt;/p&gt;
&lt;p&gt;第一批没有挑最简单的，而是挑了覆盖面最广的三组：国王（一段换词同时提交两个槽）、公主（两次换词决定后面走剪发线还是卷发线）、下人和公爵（&lt;strong&gt;两个人同时在场&lt;/strong&gt;）。最后一组是故意的：多人会面正是旧架构最混乱的地方——谁推进、谁说话、谁先离场——如果新架构在这里站不住，迁完单人客人也没有意义。&lt;/p&gt;
&lt;p&gt;这三组的实机缺陷四天就清零了，再过一周多，剩下六组也迁完。14 个剧情专属状态一次删掉，状态机文件从两千多行降到一千行左右。&lt;/p&gt;
&lt;h3 id="怎么确认行为没变"&gt;&lt;a href="#%e6%80%8e%e4%b9%88%e7%a1%ae%e8%ae%a4%e8%a1%8c%e4%b8%ba%e6%b2%a1%e5%8f%98" class="header-anchor"&gt;&lt;/a&gt;怎么确认「行为没变」
&lt;/h3&gt;&lt;p&gt;重构最怕的是悄悄改变了行为。我们靠的是几层东西：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;基线&lt;/strong&gt;：迁移前逐组记录了参展版的行为，迁完一组就对照一组。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;按名字比对回归结果&lt;/strong&gt;：项目里有一批重构前就存在的已知失败，判据不是「有没有红的」，而是「红的是不是正好那些」。只看失败数量，会漏掉「多了一个新失败、同时修好了一个旧失败」的情况。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;缺陷表&lt;/strong&gt;：迁移过程中记了大约一百个缺陷，每个都有根因和修法。&lt;/li&gt;
&lt;li&gt;后来又加上了离线推演器，以及一个在真实场景里从新游戏一路玩到结局的机器人。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;自动化测试也从三百多项涨到了七百多项。&lt;/p&gt;
&lt;h2 id="迁移中记住的几件事"&gt;&lt;a href="#%e8%bf%81%e7%a7%bb%e4%b8%ad%e8%ae%b0%e4%bd%8f%e7%9a%84%e5%87%a0%e4%bb%b6%e4%ba%8b" class="header-anchor"&gt;&lt;/a&gt;迁移中记住的几件事
&lt;/h2&gt;&lt;p&gt;&lt;strong&gt;缺内容时不能悄悄退回旧路径。&lt;/strong&gt; 有一组客人，玩家把报纸交给平民之后，报纸被收走了，却没有任何对话。查下来是内容里少了一行排程，这组客人根本没被交给新架构；而代码在找不到排程时会悄悄退回旧状态机，旧路径里接收道具后的对话早就注释掉了。所有检查都是绿的。迁移收尾时，我们删掉了「新架构可以不接管」的开关，缺内容就直接报错。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;能记录的，就不要推断。&lt;/strong&gt; 读档时镜头该在中景还是近景，最初是从「是不是国王、在哪个里程碑、正在播哪段演出」推出来的，靠两张手写名单加一个国王专属分支。名单早就和内容对不上了，而且玩家点镜子切换视角是纯视图层的操作，领域层根本看不到，怎么推都推不出来。最后把镜头直接写进了存档。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;回退之前先证明因果。&lt;/strong&gt; 有一次给公主的一条规则加了过滤条件，三个测试变红，看上去像是新规则抢走了匹配，我就回退了。可是回退之后再跑，失败的还是那三个测试，一字不差——真正的原因是同一批改动里的另一处。两次运行的失败名单，本来可以立刻证伪那个猜测。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;计划会被测量推翻。&lt;/strong&gt; 原计划要把桥里的端口实现挪出去，动手前一量，发现大部分单例引用根本不在端口实现里，照计划做完也降不了多少，于是改成了上面说的端口装配。同一时期，「把四万多行的主场景拆开」那部分被搁置了：只能手工拆、只能实机验证，投入产出比太低。&lt;/p&gt;
&lt;h2 id="现在的样子"&gt;&lt;a href="#%e7%8e%b0%e5%9c%a8%e7%9a%84%e6%a0%b7%e5%ad%90" class="header-anchor"&gt;&lt;/a&gt;现在的样子
&lt;/h2&gt;&lt;p&gt;最直观的变化是&lt;strong&gt;维护成本降下来了&lt;/strong&gt;。以前加一段剧情分支，要新建状态类、注册事件、写处理函数、再加角色判断；现在大部分情况下只需要在内容里写规则和演出。&lt;/p&gt;
&lt;p&gt;更大的收益在内容生产上。剧情先从「策划写表格、我照着改代码」变成了「表格直接编译成数据库」，后来又换成一组 JSON 文件，并有了&lt;strong&gt;策划编辑器&lt;/strong&gt;：策划可以自己编辑剧情、看流程图、在编辑器里试玩，然后导出内容包交给我导入。以前每一处剧情改动都要经过我，现在不用了。&lt;/p&gt;
&lt;h3 id="还没做完的"&gt;&lt;a href="#%e8%bf%98%e6%b2%a1%e5%81%9a%e5%ae%8c%e7%9a%84" class="header-anchor"&gt;&lt;/a&gt;还没做完的
&lt;/h3&gt;&lt;p&gt;&lt;img src="https://yufenghuang.tech/p/heircut-devlog-encounter-refactor/lines-over-time.svg"
loading="lazy"
alt="2026 年 8–10 月两个文件的行数：旧状态机从 2026 行降到约 990 行，桥从 702 行增长到 3962 行"
&gt;&lt;/p&gt;
&lt;p&gt;状态机变小了，但复杂度并没有凭空消失：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;桥越来越大。&lt;/strong&gt; 它承担了把新架构翻译给所有旧视图的工作，后来的一些功能（比如按住快进对话）也加在了它身上。按计划，它要等旧视图都换掉之后才能删。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;旧状态机还有一个外壳。&lt;/strong&gt; 14 个通用状态负责角色入场、离场这类表现，但已经不能再作为剧情入口。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;主场景依然是一个超级场景&lt;/strong&gt;，拆分搁置了。&lt;/li&gt;
&lt;li&gt;「当前是第几组客人」这个推进状态，还留在旧的管理器里。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="回头看"&gt;&lt;a href="#%e5%9b%9e%e5%a4%b4%e7%9c%8b" class="header-anchor"&gt;&lt;/a&gt;回头看
&lt;/h2&gt;&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;第一版的状态机不是错的选择。&lt;/strong&gt; 它对固定流程很合适，错在换词让流程不再固定之后，我们还在往里塞。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;流程要有唯一的所有者。&lt;/strong&gt; 谁推进剧情，必须是一个明确的对象，而不是取决于谁先加载完。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;剧情一旦变成数据，就可以被校验、被穷举、被策划直接编辑。&lt;/strong&gt; 这是这次重构最大的回报，也是策划编辑器能做出来的前提。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;边跑边换是可行的，但离不开一座桥和一份基线。&lt;/strong&gt; 桥让新旧两套能共存，基线让「行为没变」有了具体的含义。代价是桥本身会长成新的遗留，这笔账还没还完。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;后半程的很多工作是和 AI 编程助手一起完成的，这部分我打算单独写一篇。&lt;/p&gt;</description></item></channel></rss>