文字冒险与状态机
六个百行级文字引擎的状态机叙事解剖
文字冒险与状态机
文字游戏是最适合用来理解状态机的游戏类型:整个世界就是一张状态转移图,玩家的每次输入是一次转移条件,屏幕上滚动的日志就是状态历史本身。本文用六个百行级的文字引擎,看状态机如何承载叙事。
文字冒险:状态机即叙事
这六个引擎规模从一百行到两百行不等,恰好构成一套从简到繁的教材:一个 Zork 风格的指令解析器与它的互动小说模式(同一组件的两种形态)、一个 MUD 式回合制 RPG、两个思路不同的随机地牢,再加一个把排除法做成交互界面的侦探推理和一个日志即存档的跑团记录。动手版本:文字冒险、文本 RPG、地牢生成器、迷你地牢、侦探推理、跑团日志。
解析器
世界模型
文字冒险的默认模式是一个极小的 Zork 式引擎。
世界模型只有四个房间:小屋、大厅、花园、金库。每个房间三条数据:名字、描述、出口表 exits(方向到房间 id 的映射)、物品列表 items。
房间装进一张 Record<string, Room>,游戏状态只剩四个变量。here(当前房间 id)、inv(背包数组)、log(输出日志)、won(终局标记)。
地图也很克制:小屋往北是大厅、往东是花园,大厅往东通金库。小屋里放着钥匙,花园里有一朵花,宝物在金库中央。
值得停下来想的是:钥匙和花是纯氛围道具。
解析器对它们没有任何机制判定。拿起来放进背包,仅此而已。大门描述里写着「紧锁」,机制上却可以直接走进金库。
真正推动结局的只有宝物本身:take treasure 触发 won,输入框随之禁用。
氛围与机制的这种错位在极简引擎里是常见取舍。物品栏即使不参与判定,它的存在感也足够塑造「探索、收集、变强」的动机结构,也就是叙事道具和机制道具可以分离的证据。
从动词分派到转移表
解析的核心十几行就能写完。cmd() 先 trim、转小写,按空格切开,第一个词是动词,其余拼回宾语。
然后是一条 if-else 长链依次比对:look(别名 l、看)、go/move/方向词、take/get/拿、inv(i、背包)、help(帮助),都不匹配就回落到「我不明白那个命令」。
中文别名靠一张映射表归一。北/南/东/西 在进逻辑前先翻译成 north/south/east/west。
解析器的第一步永远是同义词归一化,而不是什么自然语言理解。动作集合是封闭的小集合,关键词匹配的错误模式玩家完全可以预期(打错指令得到固定报错),可控性比智能更重要。
用状态机的语言复述一遍。
look 是自环。输出描述、状态不变。
go 是跨房间转移,目标不存在时输出「那个方向没有路」并保持原状态。
take 改变的是物品归属,房间 items 与背包做一次搬运。
won 置位后整个输入面进入终态吸收。任何输入都不再有效果。
界面上那排快捷按钮(look、go north、go east、take key、inv、help)是合法转移的显式提示,把状态机每个状态的出边画给新手看,省去盲猜指令的试错成本。
输入框回车提交、onsubmit 里 preventDefault 阻止页面刷新,这些表单事件的基本盘在之前的 JavaScript 笔记里梳理过,此处不重复。
日志面板是这类游戏唯一的「画面」。玩家原始输入以 > 前缀回显,与系统的回应混排在同一个等宽字体区域里。
这套排版是 MUD 时代的直接遗产。服务器与客户端共用一个滚动缓冲区,人机对话在物理上就是同一条日志。
重置的语义
角落里的重置按钮让「重置」第一次成为状态机问题。
只把 here、inv、won 拨回初值是不够的。take 已经把宝物从金库搬进了背包,如果金库的 items 不复原,重开一局玩家将面对一个被掏空的迷宫。
所以重置函数里专门有一段循环把三个房间的物品数组重新播种,玩家状态与世界状态一起回档。
这是状态机重置的完整语义:整个可达状态空间回到初态,而不是只重置玩家可感知的那一半。
漏掉世界那一半的「复活后世界没刷新」类 bug,在带存档的游戏里屡见不鲜,根子都是重置函数只写了半份。
互动小说一侧的重置是对称的:node、历史栈、路径徽章三个变量一起清空。因为在那里,历史本身就是世界。
对话树
同一个组件的第二种模式是互动小说(CYOA):没有输入框,只有按钮。
数据结构换成节点图。每个节点带一段文本、一组 {label, next} 选项、一个可选的结局标记。
story 图本身只有五个节点:起点两条路,进洞穴或上山。洞穴里点灯探进深处拿到胜利结局,退出去则回到起点。上山走吊桥是失败结局。
注意洞穴退回起点的那条回边。这已经不是严格意义的树了,「对话树」是俗称,实现里从来都是带环的图。回边让「选错了重来」成为剧情内行为而不是 UI 操作。
终局节点(胜利/失败)的选项数组为空,渲染层据此把选项列表替换成「重新开始」按钮。终态的界面呈现也是数据驱动的,模板不需要知道剧情长什么样。
这个模式最有价值的功能是撤销栈。
history 数组记录访问过的节点 id,每前进一步压栈,返回按钮弹栈回到上一节点。path 数组平行记录每次选择的选项文字,以徽章形式展示「你走过的路」。
两个数组叠加,把一次性的阅读变成了可回溯的探索。纸书翻页可以倒回去,但无法在「回到第三章重新选」的同时保留一条可视化的足迹,也就是 CYOA 作为电子体裁的立身之本。
五个节点的图当然只是演示,但它顺便暴露了分支叙事的数量经济学。
每个双选项节点向两侧展开,深度 的满二叉树有 个叶子。写十层「真分支」就是一千多个结局节点,任何作者都写不起。
所以真实作品的通用手法是汇流:让不同分支殊途同归,回到同一个后续节点,把树压回近似线性的规模。
本作里洞穴与雪山都有「原路返回」通向起点的那条边,就是最小的汇流单元。它既在剧情内合理解释了「退回去」,又把节点预算省了下来。
交互小说作者真正的手艺,就是在树状展开与图状汇流之间控制支出。高潮处放开分支让玩家感到世界在回应,过场处收紧汇流让成本可控。
与解析器模式对照着看会更清楚。同一个组件壳、同一个模式切换栏,两种输入设备把状态机的呈现方式推向两个极端。
按钮把当前节点所有可能的转移枚举在界面上,玩家看到的是「完整的出边集合」。解析器把出边藏进世界模型,玩家要用 look 去询问。
前者服务叙事节奏:选择、后果、情绪。后者服务探索感:未知、试错、发现。
没有哪一种更高级,它们是同一张转移图的两种投影。
MUD 骨架
文本 RPG 把松散的房间图收紧成一台明确的三态机。
探索态(可移动)、战斗态(敌人在场,只能攻击或治疗)、终局态(倒下,一切禁用)。
move() 的第一行守卫 if (gameover || mob) return 就是状态互斥。战斗中不允许移动,一步都走不了。
状态可见性也做得很完整。顶栏常驻血量、攻击、等级与经验条,各按钮的 disabled 属性随状态实时切换,玩家随时知道自己处在哪个状态、哪些转移可用。
世界是四个房间(村庄、森林、洞穴、神庙)摆成一个环,每走一步掷一次 50% 的遇敌概率。
敌人从一个五项名池(史莱姆、哥布林、骷髅兵、野狼、蝙蝠)里抽名字,数值随等级缩放。血量 ,攻击 。
战斗是回合交换。玩家每刀打出 的随机伤害,砍倒敌人拿 5 点经验。敌人没死就立刻反击 。
升级公式值得单独看。经验攒满 升级,血上限 +8、回满、攻击 +2。
怪物强度线性涨,玩家的容量也线性涨。每级多 8 血 2 攻,交战轮数大致恒定。
难度感来自「怪变强了」的数字对比,而不是「打不动了」的卡壳。数值设计里这叫同步膨胀:所有曲线一起平移,战斗手感保持稳定,成长感全部交给数字本身。
遇敌概率与升级曲线之间还有一层时间结构。
每步 50% 遇敌,相邻两次战斗之间的步数服从几何分布。期望两步,但方差很大,连走四步空手与连遇三场都属正常。
0.5 这个值的意思是「既无法预测也无法规划」。玩家不能像在确定性游戏里那样攒资源等节点,只能维持一个随时可战的状态。
而升级所需击杀数是 (经验条 ,每杀固定 5 点)。一级打两只、二级四只、三级六只,升级肉眼可见地变慢。与此同时每级 +8 血 +2 攻又保证每场战斗的体感时长不变。
节奏放缓、手感恒定,这是经典的「时间稀释」。同样的游戏时长里,等级的绝对数值爬得越来越慢,但玩家感知到的始终是稳定的前进。
治疗是个精巧的资源决策。+10 血不花任何货币,但战斗中治疗等于放弃出手,会被白反击一刀。用一回合行动权换血量。
规则极简,决策真实。残血时「先奶一刀再打」还是「赌这刀先砍死」,期望计算一分钟就能算清,但按钮按下去的手感永远比期望值更紧张。
日志的自动滚动由一个 $effect 负责:监听日志数组变化,把 scrollTop 拉到底。这相当于镜头跟随。画面不存在,日志就是画面,最新的一行永远是聚光灯下的那一行。
加上方向键、攻击、治疗、重开四个按钮组,整个组件没有一处图片资源,全部叙事压在文字与两个 emoji 上。
随机地牢
房间链
地牢生成器是个纯生成组件。没有玩法,只有一块 的 canvas,但它是理解「生成即叙事」的最干净样本。
算法两步。先随机撒 6 到 9 个矩形房间(宽 3–7 格、高 3–6 格),带一格间隔的重叠检测,撒不进就重试,上限 300 次。再把相邻房间两两用走廊连起来。从上一个房间的中心先横后竖走到下一个房间的中心,刻出一条 L 形通道。
连接策略决定了拓扑。这里用的是顺序链(第 个房间只连第 个),生成结果必然连通,且天然形成一条主路径。
链是连通性最便宜的保证。不用并查集、不用生成树,一次 for 循环搞定。代价是没有环路,地牢只能是「一条串着房间的线」。
想更立体,加几条随机房间对之间的额外走廊就能制造环路,但那也意味着迷路的可能性。
从叙事节奏的角度看,房间和走廊是两种截然不同的空间。
房间是开阔地,适合停留、遭遇、事件。走廊是窄道,只适合移动,天然带着压迫感。
房间总面积与走廊面积之比就是这个地牢的「呼吸比」。这部生成器里房间最多占九块、每块最大 42 格,走廊贴着中心走最短路径,玩家的大部分路程都在窄道里,房间成为节奏上的换气点。
BFS 校验
迷你地牢把生成与玩法缝在了一起。
的格子(进阶模式 ),每层先以 25% 概率随机撒墙、四周围死,然后在空地上随机放玩家、下行楼梯和三堆金币。
关键一步:楼梯和全部金币必须从玩家位置可达。用 BFS 从起点洪水扩散标记所有可达格,检查目标点是否都被标记,不达标整张图重来,上限 50 次。
「随机生成 + 可达性校验」是 Roguelike 地牢的标配组合。随机负责新鲜感,校验负责下限。
没有校验的随机地图会出现宝物被封死在墙里的死局。玩家不知道那是设计还是 bug,但体验上都是挫败。
BFS 本身的细节在之前的算法笔记里写过,这里只需要记住它的角色:给随机性兜底。
成本也值得算一下。 的图只有 81 个格点,一次 BFS 顶多几百次基本操作,50 次重试上限意味着最坏情况也只有几万次运算。校验式生成在小图上几乎免费。
图一大,这套「重掷全图」的策略就得换成增量修正(发现不可达就挖一条通道),否则校验成本会随面积失控。
楼层就是章节。踩上楼梯进入下一层,整张地图重新生成。怪物数量 (进阶再 +2)、血量 、攻击 ,数值随深度单调爬坡。而下楼只回 5 点血,补给固定且微薄。
越深怪越强、血线相对越薄,紧张感完全由这两条曲线的剪刀差制造出来。没有一个字的传统剧情文本,「一层比一层深、一层比一层险」的故事全靠数字在讲。
进阶模式再加一条成长线:击杀怪物有 40% 概率掉落剑或甲,直接加攻或减伤,装备跨层保留、重开一局清零。「这一局」与「这一层」的进度被明确分层。
怪物 AI 只有一条规则:贪心逼近。先走横纵差值大的那一轴,被墙挡住再试另一轴,不寻路、不记路、没有视野。
但配合走廊地形,这一条规则已经足以形成「堵路」压力。怪物会卡在玩家与楼梯之间的窄道上,逼玩家正面交换。
顺带一个读源码才能发现的细节:代码里写了怪物命中玩家、玩家掉血乃至阵亡的完整分支,但移动函数同时禁止怪物踏上玩家所在的格子。两相叠加,这个反击分支在当前实现里几乎无法触发。
一个「守卫条件让结局不可达」的典型边界案例,读别人的游戏代码时值得停下来想一想:伤害结算的位置往往比伤害公式更容易出 bug。
操作面是三套输入进同一个 move(dx, dy):方向键、WASD、屏幕方向按钮。键盘监听挂在 window 上并在组件卸载时由框架收尾。
日志栏只保留最近四条。屏幕小、信息密,这是叙事压缩的另一个极端:迷你地牢连「完整历史」都不要了,只留下正在发生的瞬间。
排除法与跑团日志
侦探推理
侦探推理的案子空间是 种组合。四名嫌疑人(格林先生、普拉姆夫人、马斯塔上校、普拉姆教授)、四件凶器(烛台、匕首、绳索、扳手)、四个房间(书房、厨房、温室、舞厅)里各抽一个构成真相。
开局随机定案之后是它最有趣的设计决策:把全部九个「非真相」项(三个非凶手、三个非凶器、三个非房间)作为线索一次性发给玩家,每条线索的措辞是「{名字} 已被排除」。
也就是说线索只携带「不是」的信息,而且给足。
用信息量算一笔账:每条线索砍掉一个维度上四分之一的候选,三个维度各自排除四分之三,剩下的交集恰好 。唯一真相。
它不是烧脑谜题,而是排除法的交互教具。界面本身就是一张三列四行的真值表,线索列表在一侧滚动播放排除结论,中间三列勾选格把排除动作交还给玩家(勾中即划线变灰),右侧三个下拉框组成本次指认。
玩家亲手执行一遍「从 64 到 1」的收敛,比读十条排除法讲义记得牢。
结局反馈同样服务于教学。指认错误时直接公布真相:「凶手是谁、用什么、在哪里」。
教育型游戏的失败反馈是补课而不是惩罚,玩家没有损失任何进度,下一局(重开即换一个随机真相)从头再来。
相比之下,Killer 教授式的硬核推理游戏会隐藏部分线索、迫使玩家赌概率。那是另一种设计取向,赌的是「信息不足时的决策」,与本作的「信息充足时的流程执行」恰好是光谱两端。
跑团日志
跑团日志面向的是另一个传统:桌上角色扮演的主持人助手。
三个模块各司其职。条目日志带时间戳,手动录入发生了什么。骰子模块支持 d4 到 d100 七种面数、单次最多 20 颗,掷完把每个结果和总和写进日志。先攻追踪器录入角色名与修正值,一键全员掷 降序排列,指针循环推进标记当前行动者,「下一个」按钮取模轮转。回合制「轮次」的最小实现。
它的存档机制是六个组件里唯一做了持久化的:localStorage 里一个固定键,$effect 在日志和角色数据每次变化时自动序列化写回,挂载时读回。另有手动保存按钮给出「已保存」的确认,以及一键清空。
跑团的日志本身就是游戏状态。上次团打到哪、谁受了伤、剧情悬在哪个悬念上,读日志就是读档。
文字冒险的日志是纯输出,真正的状态藏在变量里。跑团日志的日志是状态本身,变量反而是日志的投影。
同为滚动的一列文字,一个是故事,一个是存档。
状态机的骨架到此讲完了「结构如何承载叙事」。MUD 骨架一节提到的同步膨胀、几何分布的遇敌间隔、时间稀释,都是「用数字制造手感」的初级样本。完整的 RPG 数值——战斗公式、成长曲线、掉落与资源闸门——拆在了游戏数值设计那篇。