围绕复杂模拟设计每日浏览器游戏
Designing a Daily Browser Game Around a Complex Simulation
玩家打开一款回合制游戏时,会面对两个不同的问题:理解规则,以及决定先做什么。一个细致的模拟器可以忠实地解决第一个问题,而把第二个问题完全留给玩家。
本文探讨一种不同的出发点:约束准备环节,保留游戏内部的决策,并让中断与试验变得容易理解。真正有用的设计问题是:如何降低启动的成本,同时不消除思考的理由。
这些证据来自一个本地 TypeScript 实现,以及使用 @pkmn/sim 0.10.11 进行的受控测试。这是一项基于源代码的设计分析,而不是由用户访谈或留存数据支持的回顾性研究。该实现展示了相关机制;这些机制是否有助于新玩家,仍是一个有待检验的假设。
让首个决策比规则集更小
组建队伍在策略上可能很有意思。它还会要求新手在尚未看到任何后果之前就做出许多相互依赖的决策。每日模式可以把一些准备工作移入应用:提供一个六人名单,让玩家选择起始成员。
这并不会让模拟变得肤浅。招式、换人、道具、特性与状态效果依然重要。它把最初的问题从“我该构建什么?”转变为“我能用这个做什么?”
与daily red pokemon相关的本地实现提供了一个具体示例:game/lobby.ts 生成名单,而 game/TeamPreview.tsx 展示其详细信息和首发选择。这些是对所检查源代码的观察,而不是对已部署网站的验证。
预览将信息分为招式和属性/能力标签页。选择一名成员会显示其详细信息;在全新会话中,该选择还会确定首发。这是一种有用的渐进式披露形式:玩家可以查看可用的决策,而无需同时阅读每一项数值属性。
然而,将查看与承诺结合在一起是有代价的。有人可能只是想查看某个成员,而并不打算以它作为首发。因此界面需要有一个可见的首发标记和清晰的措辞。在恢复期间,实现则相反,将选择标记为查看,并标出原来的首发。同一个视觉组件具有不同的语义,因为底层会话已经存在。
所提议的首局循环刻意保持狭窄:查看阵容、选择队长、采取行动,并读取结果。这是一个设计目标,而非实测的新手引导结果。一项有用的可用性研究应当检验玩家能否解释他们的第一个行动为何产生那样的结果,而不是仅仅检验他们是否到达了战斗界面。
每日挑战需要的不仅仅是由日期生成的种子
按日期生成种子这一点在 Zenn 上已有讨论,例如这个仅在浏览器端运行的每日挑战实现。这里额外要问的是:当玩家切换视图、重试或跨过午夜时,哪些内容应当保持稳定。
经检查的生成器会对命名空间、日期键和数据版本进行哈希,然后对玩家池执行带种子的洗牌,并选出六名成员。可重复性取决于相同的日期、版本、玩家池内容和排序。仅靠种子并不能冻结规则或数据。
日期键使用设备的本地日历日期。这使得切换对应本地午夜,但并不会创造一个全球同时的挑战边界。处于不同时区的两个人可以在同一时刻合理地看到不同的日期键。全球同步的竞赛将需要不同的时间策略。
更微妙的是,即使未完成的非练习会话日期是昨天,大厅仍会优先处理它。这区分了“今天”的两种含义:当前可用的挑战和玩家已经开始的挑战。
| 情境 | 来源行为 | 设计后果 |
|---|---|---|
| 无未完成会话 | 生成当前日期的配置 | 一个有边界的起点 |
| 昨天未完成的每日会话 | 恢复其已保存的配置 | 午夜不会静默替换进度 |
| 已完成每日挑战 | 提供一次全新的练习重放 | 学习可以在记录结果后继续 |
| 重新审视练习视图 | 保留其由日期/运行派生的配置 | 导航不会意外变成重新随机 |
保留昨天的会话确实存在权衡:它会优先于开始今天的挑战。这一策略值得在界面中明确说明。只有当玩家明白自己正在继续哪一项挑战时,连续性才有价值。
这是可重复的本地游玩,而非权威性竞赛。客户端控制其日期和存储,这些机制并未建立服务器裁决。种子有助于重建一次体验;但它并不能证明提交的结果可信。
将记录在案的尝试与学习循环分开
每日结果和邀请进行实验满足的是不同需求。如果每次重试都替换已记录的结果,完成的意义就会变得模糊。如果完全禁止重试,玩家就失去了立即应用所学内容的机会。
该实现通过将已完成挑战的重玩设为练习会话,化解了这一矛盾。freshJournal 保留名单,接受选定的队长,清空操作与结果,并用 practiceKind: "daily-replay" 标记新配置。结果记录路径不包含练习。
注意被重置的是什么:是那次尝试,而不是挑战本身。保留队伍能让下一次尝试具有可解释性。不同的领队或走法可以拿熟悉的起始情境作比较。生成一套全新阵容则会回答另一个问题。
练习生成还有一个明确的运行标识符。重新进入其视图不会推进该标识符。这个看似很小的细节,避免了导航操作改变玩家正在推理的对象。
两种替代方案是合理的。无限制的全新随机对局会优先考虑多样性,但会削弱直接比较。严格的仅一次尝试形式使每日结果简单,但限制了演练。将记录下来的尝试与练习分开,能保留一个稳定的参照点,代价是更多状态和更细致的标签。这些是从实现中推断出的设计权衡,并非声称已与用户评估过这些替代方案。
让产品契约定义状态边界
“恢复我的游戏”是一个带有技术后果的产品承诺。持久化表示需要独立于当前碰巧挂载的任意 React 组件来描述会话。
在这里,日志(journal)存储一份配置和玩家操作:移动、切换或弃权。一个 Worker 持有运行中的 BattleSession。在初始化时,它根据该配置创建一个会话,并按顺序应用已保存的操作。
这给出了一个紧凑的概念边界:
saved configuration + ordered actions
↓
Worker-owned simulator
↓
state updates → presentation
现有的 Worker 客户端使用请求标识符、待定 Promise 和 45 秒超时。这些细节支撑的是异步接口;它们并不能证明速度有所提升。超时本身也不会取消计算。本文没有运行任何浏览器延迟基准测试。
通过动作进行重建,避免持久化保存引擎实例的实时图。其代价是依赖兼容的引擎行为、数据、AI 决策以及随机数消耗。更长的动作列表也需要更多的重放工作,尽管本文并未测量这一成本。
保存完整的引擎快照可以减少重放工作,但会将持久化与引擎的序列化格式耦合在一起。服务器权威会话可以提供共享权威,但会引入基础设施以及不同的故障模型。对于本地单人体验,当兼容性被显式处理时,配置加动作是一种有用的边界。
源端会校验日志结构与配置版本,加载路径会检查被跟踪的引擎版本。拒绝并不等于迁移。只有当与重放相关的行为变更被反映到兼容性策略中时,版本号才能保护某个边界。它并不会自动保留旧引擎。
配套测试用一个合法操作演练了同样的重建思路。以下内容摘自可运行的 TypeScript,位于一个会创建并销毁两个会话的测试中:
const move = original.snapshot().moves.find(m => !m.disabled)!;
assert.ok(move, "scenario must offer a legal move");
const actions: Action[] = [{ kind: "move", slot: move.slot }];
for (const action of actions) original.act(action);
for (const action of actions) restored.act(action);
assert.ok(original.snapshot().turn > 1);
assert.deepEqual(restored.snapshot(), original.snapshot());
assert.equal(restored.battle.prng.getSeed(), original.battle.prng.getSeed());
assert.deepEqual(restored.history, original.history);
这检查的不只是初始画面保持不变:两个会话都确实向前推进。它仍然只演示了一个同一版本下的动作序列,而不是每一场战斗或每一个历史存档。
预览不得改变被预览的内容
状态边界在模拟器内部也很重要。AI 使用克隆的战斗来评估各种可能性。从概念上说,这些计算不应改变真实战斗的随机状态或历史。
一项受控实验暴露出的失败范围比“克隆已损坏”更窄。当前适配器调用 Battle.fromJSON(battle.toJSON())。在已安装的依赖项中,序列化赋值 state.log = battle.log,反序列化赋值 battle.log = state.log。因此,新的战斗对象可能保留原始日志数组。
在一个固定的双参与者场景中,推进克隆使原始回合和随机状态保持不变,但向其日志追加了 11 行。成功重建行为并未确立所有权隔离。
一个实验性文本边界 Battle.fromJSON(JSON.stringify(original.toJSON())) 使日志脱离,并在同一模拟回合后使原始序列化状态保持不变。该测试首先初始化现有适配器的构造函数名称兼容设置;文本转换并未被呈现为一个完整的替代适配器。
这一替代方案仅限于该示例。应用程序源代码未被修补,也没有断言任何生产事故或玩家可见的影响。工程上的教训比这个库更广泛:诸如“检查不会改变任何东西”这样的承诺需要变异测试,而不仅仅是检查预览是否返回看似合理的输出。
在修饰表面之前先验证承诺
配套文件包含五项受控检查,并使用仓库现有的 lobby 回归测试套件。请在已安装依赖项的情况下从仓库根目录运行:
node docs/daily-red/zenn/ideas/verify.cjs
以下观察结果由受控检查生成,而不是估算得出:
| 受控检查 | 实际观察结果 |
|---|---|
| 重放一步合法走法 | 一次合法走法后快照、历史记录和 RNG 均相同 |
| 跨日期边界恢复 | 2026-10-13 未完成的配置保留到 2026-10-14 |
| 保持练习稳定,重试时全新开始 | 练习跨午夜保持稳定;重试会清除 actions/outcome,而不会改动已保存的结果 |
| 对象克隆所有权失败 | 原对象中仅日志发生变化;追加了 11 行;原始 RNG 未改变 |
| 实验性文本边界 | 探针到达第 2 回合;原始序列化状态保持不变 |
lobby 套件还检查全新的战斗资源、选定的 leads、显式的练习重建,以及已完成结果的处理。TypeScript 检查覆盖该示例以及作用域内的游戏源码。这些是本地检查;它们不会涉及浏览器存储失败、Worker 调度、跨标签页竞态、部署或人工理解。
对于构建类似应用的人,一个有用的构建顺序是:先实现一个有效会话,然后让它的身份显式化,再加入重建,最后才加入备用模式与呈现。这是基于这些契约之间依赖关系的建议,并非对本项目开发历史的重构。
下一项产品调研应聚焦于仍然存在的不确定性:新手能否识别出领先者,区分继续与重放,并解释第一回合?观察这些任务将检验所提出的启动投入缩减。确定性种子和通过的测试都无法回答那个可用性问题。
一个小型每日游戏仍然包含相当多的复杂性。设计机会在于决定哪些复杂性应由玩家现在面对,哪些应保留下来供查看,以及哪些必须由实现来吸收。稳定的会话标识和诚实的恢复语义为这一决定提供了具体的工程基础。
来源:zenn · 游戏开发社区 · zenn.dev