简介:面向 Twine 2 与 SugarCube 的 RPG 引擎 Another-RPG-Engine,专为互动叙事与文字游戏开发者设计。它将 Twine 的叙事骨架与 SugarCube 的扩展能力结合,让创作者无需从零编写复杂机制,即可实现角色创建、回合制战斗、物品管理、地图探索、多分支对话与存档系统。压缩包共 198 个文件、约 6.46MB,主要包含 70 个 tw 故事脚本负责剧情流程,37 个 JavaScript 文件承担战斗与逻辑判定,28 个 CSS 文件定义界面样式,另有大量 PNG 图片、字体与音频用于素材占位与视觉反馈,打开即可在浏览器中运行。已有 336 人学习,特别适合具备基础 HTML/CSS 知识、想快速搭建可玩 RPG 原型的中级开发者。资源附带交互式 HTML 帮助文档,可对照了解整体目录结构和各模块用法;通过对示例项目进行编辑,可快速定制角色属性、技能、物品和战斗平衡,大幅缩短从构思到试玩验证的周期。无论是用于原型验证还是扩展成完整作品,都能在此基础上直接展开。
1. 另一个 RPG 引擎:为什么在 Twine 2 和 SugarCube 里做 RPG 需要它
A同学在 Twine 2 里写完一个二十来个 passage 的互动故事后想加战斗系统,结果发现每个 passage 都要手写<<set $hp -= ...>>、用状态变量自己管回合、再手动把结果塞回存档。改一次伤害公式,要把几十个 passage 翻一遍,最后他的结论是:SugarCube 做叙事极其顺手,但离一个能用的 RPG 引擎还差一层完整的封装。Another-RPG-Engine 这个项目标题想解决的,正是这一层的空白——在 Twine 2 + SugarCube 的生态里,把属性、战斗、背包这类 RPG 基础模块从你手写的脚本里抽出来,变成可复用、可调整的规则层。它适合已经上手 SugarCube、但不想为每个游戏重写一遍数值逻辑的作者,也适合想用可视化小说工具快速产出 RPG Demo 的人。网站的交互式自述文件是这个项目最值得先看的东西:它把引擎的每个能力直接跑在网页里,比你下载源码通读一遍快得多。
2. SugarCube 离一个 RPG 引擎还差什么:从叙事到战斗的跨度
2.1 从互动叙事到 RPG:SugarCube 原生能力的真实边界
SugarCube 作为 Twine 2 里最成熟的故事格式之一,底层做得很扎实:$变量的读写、<<if>>分支、<<switch>>多路选择、<<link>>交互按钮、<<goto>>页面跳转,再加上Story JavaScript、Story Stylesheet、StoryInit这些预留的扩展区,这套东西几乎覆盖了互动小说的全部需求。但 RPG 意味着你需要的不是给读者“呈现一条故事路径”,而是维护一个持续变化、多个系统互相影响的世界模型。
举个最直观的例子:互动小说里“玩家手臂受伤”只是一个把状态置为hurt的开关;到了 RPG 里,“受伤”则要进入一个状态结算链——它影响到攻击命中率,持续若干回合,每回合结束扣血,可以被解毒药或休息解除,还要参与到存档里保证读档后状态一致。SugarCube 没有告诉你这些规则应该放在哪里,也没有提供通用的数据结构。它的State只保证一件事:存在$变量里的数据会跟着存档走。至于那些数据是什么结构、数值怎么算、回合怎么切,都是作者自己的事。
不管是用setup还是State.variables,你能在 SugarCube 上搭建出任何系统,但代价是你自己发明整套架构。做两个、三个项目之后就会发现,战斗、背包、升级、Buff 这些模块的逻辑高度相似,复制粘贴改变量名的时间和重新发明一样多。引擎的价值就在这里:把常见的 RPG 规则抽象掉,让你专注于故事和内容配置,这就是标题里 "Another" 的由来——它是在既有方案之外,再把这一层封装往前推了一步。
2.2 引擎的封装边界:规则归引擎,故事归 Twine
使用任何基于 Twine 的引擎,第一件事就是要清楚它管什么、不管什么。以这个标题描述的项目为例,从交互式自述文件的呈现方式看,它更可能把以下模块封装起来了:角色属性模型(等级、经验、HP/MP、攻防速)、伤害计算与战斗流程、物品栏和装备槽、状态效果与回合计数、战斗日志和用户界面组件。这些是跨项目高度可复用的部分。
而它不应该碰的部分同样清晰:故事文本和剧情结构仍然是你在 passage 里写,页面的视觉风格仍然由 CSS 控制,具体的敌人配置、物品价格、技能倍率这些数值仍然属于你的游戏设计,不该被引擎锁死。一个好的 SugarCube 引擎通常是继续用 SugarCube 宏来驱动体验,比如<<link>>、<<dialog>>、<<replace>>这些 UI 宏仍然由作者直接使用,引擎则在背后管理状态流转。我的经验是,判断一个引擎是否合格,不看它实现了多少功能,而看它的功能边界是否清晰:规则逻辑可以黑匣子化,但内容数据必须全部开放。
落实到实际选择上,需要确认的就是这个引擎对外暴露了什么接口。常见套路是把核心对象挂到 SugarCube 的setup上,或者通过自定义宏暴露命令。接入前把它的交互式自述文件当成 API 文档看一遍,比看源码更有效率,因为交互式的示例多半已经把“怎么调用、传哪些参数、改哪里配置”演出来了。
2.3 三种不要用引擎的情况
不是所有 Twine RPG 项目都需要引入额外的引擎,先做一次排除再决定方向,省掉后面很多麻烦。
第一种情况:你只是想在互动叙事里带几个属性检查,比如“力量 8 以上可以通过这个门”。这种情况用 SugarCube 原生的$变量加一个<<if>>判断就够了,引入引擎反而会把简单的判定流程拆散到各个模块里,调试时得多跳好几层。第二种情况:你的战斗是纯粹的线性演出,比如“选 A 扣 10 血,选 B 扣 20 血”,没有回合拉扯和状态累积,这类设计用不着完整的战斗系统。第三种情况:你准备把游戏做到非常独特,战斗机制完全定制,比如时间轴读条、卡牌构筑这类非传统回合制。此时引擎自带的战斗调度很可能变成束缚,你花在绕开它规则上的时间比从零写还多。
判断原则可以归结成一句话:你的项目里可复用的“规则”多不多。如果只有一处需要战斗、没有背包、没有成长曲线,那就是单场景事件,不值得为它引入抽象层;如果每个章节都要打、要攒装备、要养成,那么引擎的收益就会随项目规模放大。这个标题描述的引擎做的是通用抽象,适合多数常规回合制 RPG,不适合为特殊设计反复改底层的项目。
3. 把引擎装进 Twine 2:最小战斗闭环与三个验证点
3.1 装进故事的三种常见接法,把引擎挂到故事上
拿到这类引擎项目后,把它装进 Twine 2 的常见做法有三种,具体用哪一种取决于它发布的文件形态。如果交付的是.html或.twee文件,直接在 Twine 2 的左侧菜单选导入,整个故事连同引擎一起进来,这是最省事的方式。如果交付的是独立的 JavaScript 文件,正确位置是每个故事左下角的 Story JavaScript 区域,引擎的全部核心代码都该贴在那。如果交付的是宏定义文件,那宏注册代码必须在任何包含该宏的 passage 渲染之前执行,Story JavaScript 区域恰好满足这个前置条件,因为 Twine 会保证它先于页面内容运行。
在把外部代码贴进 Story JavaScript 之前,值得先看一下它的结构,因为它可能通过Macro.add注册自定义宏。SugarCube 里一个自定义宏的最小注册方式是这样的:
// Story JavaScript:注册一个名为 rpg-info 的宏 Macro.add('rpg-info', { handler() { const player = State.variables.player; const content = `${player.name} Lv.${player.level} HP ${player.hp}/${player.maxHp}`; this.output.append(config.passage ? content : document.createTextNode(content)); } });这段代码的作用是把“玩家当前状态”封装成一个可以随时插入 passage 的宏。Macro.add是 SugarCube 留给作者的宏注册入口,第一个参数是宏名,第二个参数是配置对象,handler就是宏被调用时执行的方法。State.variables.player拿到的是当前故事变量里的 player 对象。this.output.append往页面当前位置写入内容,如果传入纯文本需要用document.createTextNode包一层,避免被当成 HTML 解析。
参数说明:宏名rpg-info是给作者自己看的,在实际 passage 里写作<<rpg-info>>。如果引擎的设计是注册这类宏,说明它的调用方式是 SugarCube 风格的,对熟悉<<if>>、<<set>>的作者基本零学习成本。需要注意State.variables.player必须在宏被调用前已经初始化,否则会报player is undefined,这也就是后面要讲到的 StoryInit 初始化顺序问题。
3.2 最小闭环:一个房间、一场战斗、一次升级
装好引擎之后,先用最小闭环验证它是否真的能跑。我习惯搭一个能手动走通的 Demo:一个房间、一只敌人、一场战斗、一次升级判定。先用 Story JavaScript 定义一个最简规则层,它模拟引擎要做的事:
// Story JavaScript:最简规则层 setup.rpg = { encounter(name) { const table = { slime: { name: '史莱姆', hp: 30, atk: 6, def: 2 }, wolf: { name: '野狼', hp: 45, atk: 9, def: 3 } }; return Object.assign({}, table[name]); }, computeDamage(atk, def) { return Math.max(1, atk - Math.floor(def / 2)); } };setup是 SugarCube 为作者准备的全局对象,跨 passage 存活且不参与存档序列化,适合放这种无状态的计算工具。encounter方法和computeDamage方法就是你游戏的“规则内核”,完整引擎做的事情比这多得多,但结构长这样:配置表加计算函数。敌人数据放表里而不是硬写在 passage 中,这样调数值时只需改一处。
接着用一个 StoryInit passage 初始化玩家角色:
:: StoryInit <<set $player = { name: '旅人', hp: 100, maxHp: 100, atk: 12, def: 6, level: 1, exp: 0, gold: 50 }>>StoryInit 是 SugarCube 在故事启动时自动执行一次的特殊 passage,适合放初始化逻辑。这里$player存储了玩家全部基础属性,它会被 SugarCube 默认序列化机制纳入存档。命名建议统一用对象而不是散落的$hp、$atk变量,读档和调试都直观得多。
最后写三个 passage 组成完整闭环:
:: 村庄 [start] 你站在村庄入口,一只史莱姆堵住了去路。 [[拔剑迎战->史莱姆战]] [[绕路回营地->营地]] :: 史莱姆战 <<set $enemy = setup.rpg.encounter('史莱姆')>> <<set $turn = 0>> <<for $turn = 0; $turn < 8; $turn++>> <<set $dmg = setup.rpg.computeDamage($player.atk, $enemy.def)>> <<set $enemy.hp -= $dmg>> <<if $enemy.hp <= 0>> 你造成 <<print $dmg>> 点伤害,史莱姆倒下了! <<set $player.exp += 20>> <<set $player.gold += 10>> <<goto 胜利结算>> <</if>> <<set $back = setup.rpg.computeDamage($enemy.atk, $player.def)>> <<set $player.hp -= $back>> <<if $player.hp <= 0>> 你倒下了…… <<goto 失败结算>> <</if>> <</for>> :: 胜利结算 <<set $need = Math.round(30 * Math.pow(1.2, $player.level - 1))>> <<if $player.exp >= $need>> <<set $player.exp -= $need>> <<set $player.level += 1>> <<set $player.maxHp += 12>> <<set $player.hp = $player.maxHp>> <<set $player.atk += 3>> 你升级了!当前等级 <<print $player.level>>。 <<else>> 距离下一级还需 <<print $need - $player.exp>> 点经验。 <</if>> [[返回村庄->村庄]] :: 失败结算 你被击倒了…… [[重来->村庄]]这个闭环里<<for $turn = 0; $turn < 8; $turn++>>就是回合循环,最多打 8 回合防止死循环。<<goto>>会中断当前 passage 的渲染并跳转,因此战斗结束后流程会立刻切到结算页面。注意这里战斗过程字符串直接写在了 passage 文本里,SugarCube 渲染到这几行时才执行对应输出。
参数调整的要点:30 * Math.pow(1.2, $player.level - 1)是升级所需经验公式,1.2是成长系数,决定了越到后期升级越慢的程度;+12和+3是每级生命和攻击成长值。整套逻辑就是引擎内部最常见的那套模板,你把这里的配置改成 SU 自己的数值,再把computeDamage换成更复杂的公式,就是一个可以扩展的战斗原型。
3.3 三个验证点:控制台干净、版本匹配、状态可回卷
把代码贴进 Twine 2 后,不要急着写更多内容,先在浏览器里走一遍并检查三个地方。第一个是预览页面打开后按 F12 看控制台有没有红色报错,报错常见于setup方法定义里用了引擎不存在的依赖,或者 passage 里调用了未注册的宏。控制台干净说明基础加载没问题。
第二个是确认 SugarCube 版本和引擎要求的一致。这类引擎通常会写明自己基于 SugarCube 的哪个版本开发,老版本缺宏、新版本改 API 都很常见。版本不匹配的表现很奇葩:功能大半能用,但某个特定宏或方法始终报错。检查方式是看故事顶部的故事格式设置里的 SugarCube 版本号,而不是只看 Twine 2 的版本。
第三个是存档回卷测试:玩到战斗结束后存档,然后读档,确认$player.hp、$player.exp、$enemy状态都正确恢复。这个测试尤其重要,因为引擎如果额外维护了内部状态但没放进$变量,存档就会把这些状态丢掉,读档后出现“血满、背包空”的怪象。控制台里执行State.variables可以直接看到当前全部存档变量,对照存档前后的差异就能定位问题。
4. 用引擎搭 RPG 核心系统:属性、战斗调度与背包的落地参数
4.1 属性与伤害公式:先定曲线再写代码
数值设计要在写代码之前决定。很多翻车现场都是边写边调数值,最后属性之间失去平衡。搭 RPG 属性时,我通常先列一张参数表,把每个属性的含义和作用范围写死,再来实现计算逻辑。
| 属性 | 建议初始范围 | 作用 |
|---|---|---|
| hp / maxHp | 50~120 | 生命值与上限,战斗失败判定依据 |
| mp / maxMp | 10~50 | 技能资源,没有技能系统时可省略 |
| atk | 8~20 | 物理攻击力,进入伤害公式 |
| def | 3~12 | 物理防御,减伤来源 |
| level | 1 | 等级,驱动成长曲线 |
| exp | 0 | 当前等级累计经验 |
伤害公式建议从最简单的减伤模型起步,再逐步叠加变量。下面这段设计了技能倍率和随机浮动,它模拟了引擎内部伤害计算的基本结构:
// Story JavaScript:伤害计算 setup.rpg.computeDamage = function(attacker, defender, skillRate) { const rate = skillRate || 1; const base = attacker.atk * rate; const mitigation = Math.floor(defender.def * 0.7); const variance = Math.floor(Math.random() * Math.max(1, base * 0.1)); return Math.max(1, base + variance - mitigation); };逻辑说明:先算基础攻击attacker.atk * rate,skillRate是技能倍率,普攻时传 1。防御按 70% 折扣转化为固定减伤,实测这个比例比满额减伤舒服,不会出现防御堆满后完全打不动的情况。variance是 ±10% 的随机浮动,用Math.random()生成,配合Math.max(1, ...)保证伤害最低值为 1,避免战斗进入无法结束的僵局。参数调整时先动0.7这个防御系数,观察攻防两端的伤害数据再决定是否修改公式结构。
成长曲线方面,我会避开纯指数模型。30 * Math.pow(1.2, level - 1)这种曲线到后期数值膨胀非常快,10 级以后经验需求翻倍增长,玩家挫败感很强。更平滑的做法是用二次函数或分段线性,比如expNeed = 30 + (level - 1) * 25 + (level - 1) * (level - 1) * 5,前 10 级平滑,中期开始加速。曲线选择没有绝对答案,但至少要意识到它决定了游戏的节奏,不是随手填个乘数。
4.2 回合制战斗调度:有限状态机替代定时器
回合制战斗最容易翻车的实现是用setInterval驱动回合。SugarCube 的页面是动态渲染的,定时器回调里操作 DOM 或页面跳转会有一堆竞态问题,passage 切换后定时器还可能残留,导致战斗在剧情对话中间突然跳一下。更可靠的做法是维护一个有限状态机,把战斗生命周期拆成阶段,每个阶段渲染对应界面,玩家的每个操作只是触发状态迁移。
下面是一个挂在 window 上的战斗状态机示例,不依赖定时器:
// Story JavaScript:战斗状态机 window.rpgBattle = { phase: 'idle', transitions: { idle: { attack: 'playerHit' }, playerHit: { done: 'enemyTurn' }, enemyTurn: { done: 'result' } }, transition(action) { const next = this.transitions[this.phase][action]; if (!next) { console.warn('非法的状态迁移', this.phase, action); return false; } this.phase = next; return true; } };逻辑说明:状态集合被硬编码在transitions里,键是当前状态,值是允许的动作和对应目标状态。比如idle时只允许执行attack,执行后迁移到playerHit。transition方法先查表再迁移,非法动作会被拦截并输出警告,这在调试期非常有用,它会让逻辑错误尽早暴露而不是静静变成一个诡异行为。
实际页面中,把同一个战斗 passage 内部按状态渲染:
:: 战斗舞台 <<set $battlePhase = $battle.phase>> <<switch $battlePhase>> <<case "playerHit">> <<set $dmg = setup.rpg.computeDamage($player, $enemy)>> <<set $enemy.hp -= $dmg>> 你造成 <<print $dmg>> 点伤害。敌人剩余 <<print $enemy.hp>> 点生命。 <<run rpgBattle.transition('done')>> [[继续->战斗舞台]] <<case "enemyTurn">> <<set $back = setup.rpg.computeDamage($enemy, $player)>> <<set $player.hp -= $back>> 敌人对你造成 <<print $back>> 点伤害。你剩余 <<print $player.hp>> 点生命。 <<run rpgBattle.transition('done')>> [[继续->战斗舞台]] <<default>> <<run rpgBattle.transition('attack')>> [[发起攻击->战斗舞台]] <</switch>>这里每个 case 都是状态机里一个阶段。玩家点击[[发起攻击->战斗舞台]]时,页面先执行<<run>>里的状态迁移,再跳转重渲染,所以每次刷新页面看到的内容就是当前阶段该显示的。这个模式的优点是状态全部可序列化,存档时只要连着$battle.phase一起存,读档后战斗照样能继续。你甚至可以手动改$battle.phase来测试某个阶段的 UI,对调 battle 布局特别方便。
4.3 背包、装备与 Buff:能用三张表讲清楚就不要塞进一个对象
背包系统最重要的决定是数据结构。新手常见的做法是往$player里塞一个数组,每个元素存“物品名+数量”,然后写一堆查找逻辑。项目一变大,物品种类过百后,那种结构的查找和维护都是一场灾难。我的建议是拆成三张独立的表,对应关系型思维,而不是一个对象包打天下。
// Story JavaScript:物品系统的三张表 setup.rpg.Items = { potion: { name: '治疗药水', type: 'consumable', effect: { hp: 40 }, price: 30 }, ironSword: { name: '铁剑', type: 'weapon', atk: 6, price: 120 }, leatherArmor: { name: '皮甲', type: 'armor', def: 4, price: 80 } }; setup.rpg.EquipSlots = { weapon: { name: '武器', allowedTypes: ['weapon'] }, armor: { name: '护甲', allowedTypes: ['armor'] } }; setup.rpg.BuffPool = { rage: { name: '狂暴', effect: { buffAtk: 3 }, duration: 3 } };物品表是静态配置,写清楚名称、类型、效果、价格;装备槽表定义角色身上能穿哪些位置;Buff 池则存放状态效果的模板。玩家实例上只保存当前持有的物品清单和生效的装备 id,不重复存物品的完整信息:
// StoryInit 中扩展背包与Buff <<set $player.bag = { potion: 2, ironSword: 1 }>> <<set $player.equipped = { weapon: 'ironSword', armor: null }>> <<set $player.buffs = []>>这套结构的优势是改数值只改setup.rpg.Items,玩家存档里只有 id 和数量,体积小、不会出现存档膨胀。使用物品时把 id 转到效果再应用,三尺长的switch就消失了。Buff 的处理则建议在战斗回合初始化时统一遍历$player.buffs,每次回合递减 duration,减到 0 就移除,别在每次受伤时临时去查。
需要特别提醒的是:背包并入存档以后,存档文件里的数据看起来就是一堆 id 和数字,这对调试并不友好。但是保留这种“存档数据只有 id,配置数据全部在 setup”的分离,是长期维护项目最省心的架构,想加一个新物品完全不用动存档结构,只加一行配置。
5. 接入引擎的常见问题与避坑排查:四处最容易翻车的地方
5.1 写 document.write 把页面搞挂
现象:在 Story JavaScript 里写了document.write或document.createElement('script')加外部资源,预览时整个画面空白,或者页面渲染到一半突然全部被替换掉。
原因:SugarCube 的页面是控制台应用式的动态渲染,passage 内容会被注入到容器的 DOM 里。document.write这种文档级 API 会把整个文档当作文本来重写,直接在运行时炸掉当前渲染。它也不是创建动态脚本的合理方式,Twine 外部资源应该在 Story JavaScript 顶部用<script>标签先声明。
解决:场景切换用<<replace>>、<<append>>、<<prepend>>这几个局部更新宏;弹窗信息用DialogAPI;需要动态引入脚本时,在 Story JavaScript 顶部一次性打包好,不要运行时再加载。接入引擎后如果发现某段代码里有document.write的痕迹,直接移除换成 SugarCube 的 UI API。
5.2 读档之后背包清空、血回满
现象:打了一场战斗,存档再读档,金币和经验都在,但背包物品全部消失,或者血量不是存档时的值而是满的。
原因:SugarCube 存档序列化的是$变量和它内部能识别的结构。部分引擎或作者的代码把临时状态放在setup对象上而不是$变量里,读档时setup是从当前加载的源码里重新构建的,自然丢了运行时数据。血量回满的原因则是把“当前血量”放在了setup里,而存档恢复的是$player.maxHp初始值对应的满血状态,两者对不上。
解决:凡是需要跨存档存活的数据,一律放进$变量树里,特别是$player的子字段。setup只放工具函数和配置表。排查方法是在读档前后分别执行JSON.stringify(State.variables)对比,字段少了哪一个,就是哪一个存放位置不对。
5.3 同名字段互相覆盖:setup 与 State.variables 的边界
现象:定义了一个player挂在State.variables.player下,同时setup.rpg里某个方法也写了一个player字段,运行时发现两个值不一致,或者干脆一个赋值把另一个覆盖成 undefined。
原因:setup和State.variables是两个独立的命名空间。State.variables.player是存档数据,setup.player对 SugarCube 来说只是一个普通对象字段。混用后最常见的错误是在setup.rpg.setupPlayer()里写入setup.player = {...},结果所有用$player的地方都拿到 undefined,控制台报错却又指向别处。
解决:遵守一个约定即可——$变量只存数据,setup只存方法和配置,永远不要往setup上挂需要跨存档存活的状态。对外暴露的常量命名也做前缀区分,比如引擎内部对象统一叫rpg,玩家的状态统一放在$player,这样读完代码一眼就能看出当前变量是配置还是数据。
5.4 CSS 样式互相污染:引擎界面和你自己写的布局打架
现象:引擎自带的对话框或状态栏显示错位,按钮被背景图盖住,字体大小异常;你明明没有定义过相关样式,某些文本却突然被改变。
原因:引擎的样式表如果写得太宽泛,比如直接修改.dialog、.menu这类 SugarCube 通用类名,而故事自己的 Story Stylesheet 也在改同样的类,后加载的样式会把先加载的覆盖掉。Twine 2 的样式加载顺序可控,但优先级冲突仍然容易出现。
解决:给引擎的根元素加一个独立的命名空间类,比如.erpg-root。所有引擎内部选择的写法都是.erpg-root .dialog,不要裸写.dialog。你自己的样式同样加上故事专属的类名前缀,两套样式同时加载时互不干扰。验证方式是在浏览器控制台选中对应的元素,看 Computed 面板里哪条规则生效、来自哪个文件,根据来源做针对性调整。
6. 把交互式自述文件当沙盘:几个值得养成的高阶用法
6.1 把自述文件的示例当测试夹具
标题里特意提到网站链接到交互式自述文件,这不是一句装饰,它意味着你可以直接在网页里操作引擎的每一个功能,而不必先下载源码、跑本地环境。我的习惯是在决定接入前,先把交互式自述文件里的每个示例都点一遍,记录它调用了哪些宏、传了哪些参数、结果界面长什么样。示例代码本身就是天然的测试夹具:把它的片段复制到本地 Twine 里的空 passage,跑通一次,就等于做完了回归测试。之后改数值、改逻辑时,把这个 passage 当作脚手架反复跑,能避免很多隐藏的破坏性改动。
6.2 在浏览器控制台里直接改引擎状态
交互式自述文件页面通常和本地 Twine 预览一样运行在浏览器里,这意味着你在那个页面上可以直接打开控制台,查看和修改引擎的内部状态。比如观察当前角色对象、手动给一个物品、强制触发一次升级判定,这比在本地 Twine 里一遍遍地试要快。SugarCube 的State.variables全局暴露了存档数据,任何页面都能直接读。我自己的调试习惯是先在控制台里把状态改成目标值,再触发界面跳转,确认新功能的显示逻辑是否正常。
接入任何基于 SugarCube 的引擎后,我还有一个习惯:永远保留一个只放官方示例的最简故事文件,不掺入自己的剧情内容。每次升级 SugarCube 或引擎版本,先用它跑一遍示例;跑不通就是版本兼容问题,跑通了才继续动我的真实项目。这个习惯帮我挡掉过一整个下午的排查,最后发现只是某次顺手升级把引擎依赖的宏改掉了。希望帮到你。
本文还有配套的精品资源,点击获取