本文分享一个浏览器游戏项目的实现与取舍:如何生成每天一致的队伍、接入 Gen 3 战斗模拟器、用动作日志恢复对局,以及处理一个只在生产构建中出现的序列化问题。技术栈以 Next.js、React、TypeScript 和 Web Worker 为主。
开发 Daily Red Pokemon 时,我最先确定的是游戏范围:每天给玩家六只 Pokémon,让他挑战一次 Red。没有练级流程,没有大地图,也不要求先注册账号。
这个范围听起来很小,但做成可反复使用的作品,需要回答不少工程问题:刷新后能否继续?跳过动画会不会改变结果?AI 评估招式时会不会消耗真正战斗的随机数?本地能运行,压缩上线后是否还能完成一个回合?
这是一个独立的 Pokémon 同人学习项目,不代表 Pokémon 权利方的官方发行或授权。下文结合代码节选和历史故障记录,说明实现方式及其适用边界。

下面按几个实际影响架构的决定来复盘。玩法只保留理解设计所需要的部分,重点是这些决定为什么值得做,以及它们带来了什么限制。
一、先把玩法收窄,再确定数据边界
Daily Red Pokemon 是回合制单打:双方各六只,等级统一为 100;玩家选择首发、出招或换人,直到产生胜负或平局。Red 的阵容固定为 Pikachu、Espeon、Snorlax、Venusaur、Charizard 和 Blastoise,Pikachu 首发,其余出场可以因换人而变化。
玩家侧从 30 种已配置的 Pokémon 中抽取六种,不重复。招式、性格、能力值、特性和携带道具有预设,不是把所有世代、所有招式组合都交给随机生成。
这个限制同时服务玩法和开发:
- 玩家面对的是“用现有队伍解决问题”,而不是不断刷新到理想阵容。
- 每套配置能单独检查,遇到错误可以定位到具体数据。
- 测试覆盖有明确边界,不必一开始就处理无限的配招组合。
每天一次计入记录的挑战,负责提供共同话题;独立练习负责给玩家试错空间。这两种需求需要在状态模型中分开,否则“练习一下”也可能意外消耗正式机会。
我的经验是,先写清楚什么会被计分、什么必须保持一致,比先做更多动画更能决定项目是否容易维护。
二、技术栈:页面、战斗计算和部署分别负责什么
项目使用现有 Web 工程承载游戏,核心部分如下。这里列的是实际参与游戏链路的技术,不是根目录所有依赖。
| 层次 | 使用的技术 | 具体职责 |
|---|---|---|
| 网站与页面 | Next.js 15、React 19 | 页面、玩法说明、元数据、交互组件 |
| 类型与数据 | TypeScript、JSON | 动作协议、队伍配置、战斗快照与事件类型 |
| 规则计算 | @pkmn/sim@0.10.11 |
Pokémon Showdown 的 Gen 3 战斗模拟 |
| 计算隔离 | 浏览器 Web Worker | 初始化对局、执行动作、恢复操作序列 |
| 界面与音频 | CSS、Web Audio API | 战场、菜单、血条、动画和程序合成音效 |
| 本地持久化 | localStorage |
正式对局日志、战绩与用户设置 |
| 网站部署 | OpenNext、Cloudflare Workers、Wrangler | 构建适配、静态资源和网站请求 |
浏览器 Web Worker 与 Cloudflare Worker 是两件事:前者在玩家设备里计算对战,后者承载网站部署。当前游戏没有把每次出招发到 Cloudflare 做服务端裁决。
页面也不必全部等待战斗引擎。玩法说明可以作为页面内容提供,浏览器在开始或恢复战斗时创建计算线程。这样做能把入口页面与重一些的模拟器分开,但不意味着引擎下载没有成本;首次开战仍需要关注弱网体验。
我把代码分成三条主要链路:
页面与交互:DailyRedGame → useBattle → BattleStage / BattleMenus
战斗计算:BattleClient → battle.worker → BattleSession → @pkmn/sim
本地记录:useBattle → journal / storage → localStorage
页面不用知道模拟器内部对象怎么组织;模拟器也不用知道血条用哪种 CSS 动画。两侧通过动作、快照和事件通信。
三、规则引擎:接入成熟模拟器,自己实现每日玩法
项目早期有过自写伤害和状态逻辑,后来改为固定版本的 @pkmn/sim。原因是规则组合比一条伤害公式复杂得多:招式命中、能力、道具、异常状态、换人、PP 和回合结束效果会互相影响。
以 Gen 3 为例,招式类型决定物理或特殊类别。Shadow Ball 是物理,Crunch 和 Dragon Claw 是特殊。如果界面沿用后续世代的分类,即使引擎算对了,玩家也会基于错误信息做决策。
因此,Daily Red Pokemon 的招式说明与计算都以 Gen 3 数据为依据。我保留自己的每日选队、对手策略和表现层,让成熟模拟器负责基础规则。
规则适配中有两处明确选择:
// 节选自模拟器适配层:数据世代和战斗格式要保持一致。
const dex = Dex.mod("gen3");
const battle = new Battle({
formatid: toID("gen3customgame"),
seed: battleSeed,
p1: {
name: "Player", team: playerSets },
p2: {
name: "RED", team: redSets },
});
这里的 battleSeed 和队伍对象由适配层生成,上面只是接入位置的精简示例。输入层还要检查等级、招式合法性、EV、IV、性格与道具,不能把“用了成熟库”等同于“业务配置一定正确”。
接入模拟器也不等于运行 FireRed ROM。当前实现使用浏览器中的规则模拟,不加载 ROM,也不承诺与原版卡带逐位一致。
四、每日队伍:可复现比“看起来随机”更重要
如果直接在页面加载时使用 Math.random(),刷新就可能换队,也无法让同一天的玩家比较结果。这里需要的是由日期确定的伪随机序列。
当前实现把本地日期和数据版本组合成种子,再用固定的伪随机数发生器做 Fisher–Yates 洗牌,最后取前六个。核心代码很短:
// 依赖 seed.ts 中固定的 hashString32 和 shuffledCopy 实现。
const DATA_VERSION = 2;
function dailySeed(dateKey: string): number {
return hashString32(`daily-red-pokemon:${
dateKey}:v${
DATA_VERSION}`) || 1;
}
const seed = dailySeed(dateKey);
const teamIds = shuffledCopy(playerPool, seed)
.slice(0, 6)
.map(pokemon => pokemon.id);
相同日期能得到相同队伍,前提是候选池内容及顺序、哈希、洗牌算法和版本都一致。只保留同一个数字种子,却悄悄调整候选池顺序,仍可能改变当天队伍。
我把重置时间设为玩家当地午夜,让“每天一次”贴近日常使用。代价是不同时区并非同时换题,所以页面显示挑战日期和 NEXT TEAM 倒计时,方便对照。
另外,数据种子版本与存档结构版本解决的是不同问题。前者参与选队,后者用于识别日志格式;当前都有版本标识,但并不会自动迁移所有旧数据。规则或数据升级时,旧动作日志是否还能复算,仍然需要单独处理。
这套本地日期方案适合轻量日常挑战。如果以后要做可信排行榜,就需要服务器确定挑战日期、验证操作和结算结果,不能直接把本地战绩当作可信成绩。
五、Web Worker 与动作日志:让刷新成为恢复流程
我希望玩家中途关闭页面后还能继续,因此存档不只记录“还剩多少 HP”,而是保存开局配置和已经提交的动作。
只存 HP 会丢失很多上下文:剩余 PP、状态、道具是否消耗、强化阶段、随机数进度等。配置加动作序列可以让模拟器重新走到相同位置,前提是相关规则和算法保持兼容。
下面是日志的核心结构节选:
type Action =
| {
kind: "move"; slot: number }
| {
kind: "switch"; slot: number }
| {
kind: "forfeit" };
interface Journal {
config: BattleConfig; // 日期、种子、六个 ID、首发、模式和版本
actions: Action[];
updatedAt: string;
outcome?: "won" | "lost" | "tied";
turns?: number;
}
Worker 收到初始化请求后,创建 BattleSession 并依次重放 actions。战斗运行时,每次请求带递增 ID,主线程通过 ID 找到对应 Promise;线程错误和超时也要反馈给界面,不能让按钮一直显示加载中。
这里真正需要仔细处理的是失败顺序。当前流程会先把新动作加入日志,再请求 Worker;如果动作执行失败,就恢复旧动作列表、写回日志并销毁当前客户端,供后续从已保存日志重新初始化。
读取 localStorage 时,也不能只写一个 JSON.parse() 就继续执行。当前代码会检查版本、日期格式、六个有效且不重复的 Pokémon ID、首发范围和动作结构;具体动作在当前局面是否可执行,由模拟器再检查。
还有几个容易漏掉的边界:
- 重复结算: 用挑战日期阻止常规刷新、恢复流程重复累计战绩。
- 多标签页: 监听存储变化,并在行动前比对日志;检测到旧状态就要求恢复。它能减少误覆盖,但不是数据库级的并发事务。
- 存储失败: 页面显示警告,让玩家知道本次进度可能无法恢复。
- 跨日续局: 午夜产生新队伍,但已保存的旧局保留原日期;先结束旧局,再开始新挑战。
记录当前只在同一浏览器中保存,也没有完整历史对局归档。对于一个小项目,这降低了账号和后端复杂度;同时也意味着换设备不会自动同步,恢复还要承担重放日志的成本。
六、把战斗结果、动画和 AI 评估分开
实现交互时,一个关键决定是同时保留两种状态:state 表示引擎已计算出的局面,frame 表示玩家此刻看到的画面。
例如,一次操作可能依次产生出招、伤害、回复和濒死事件。引擎可以先算完,界面再逐个播放。Normal、Fast、Instant 只是播放策略;跳过动画时直接显示最终状态,不能重新调用伤害计算。
玩家动作 → Worker 计算 → 最终局面 + 事件序列
├─ 正常速度:逐事件显示
├─ 快速模式:缩短事件间隔
└─ 跳过动画:显示最终局面
这样,战斗录像可以重用事件播放器;重新挑战则新建对局,让玩家再次提交动作。两者看起来相似,底层含义却不同。
| 入口 | 实现上的含义 | 是否改变正式战绩 |
|---|---|---|
| 正式每日挑战 | 使用当天配置,保存日志并结算 | 是 |
| 随机练习 | 使用独立练习种子 | 否 |
| Replay today's challenge | 用当天队伍新建练习对局 | 否 |
| Watch battle replay | 播放已产生的事件 | 否 |
这张表也是划分测试用例的依据:重新挑战应创建独立对局,观看录像应只读取已有事件,两者都不应再次写入正式战绩。按钮文案则要明确区分“重新做选择”和“重看过去”。
对手策略也要遵守同样的隔离原则。Red 的决策在提交玩家本次动作之前生成;评估在战斗副本上完成,计算伤害时保存并恢复副本的 PRNG 状态,避免评估过程推进正式战斗的随机序列。
当前策略是可解释的启发式评分,考虑伤害、命中率、击杀、回复和换人收益,不是训练出来的模型,也不是完整的最优搜索。对这个规模的游戏,先保证行为可追踪和可复现,比追求一个难以解释的对手更有价值。
七、一次真实踩坑:开发正常,生产压缩后战斗失败
项目开发记录里最值得保留的故障,发生在生产构建后的一个回合中。初始战场能加载,开发环境和普通 Node 测试也正常,但执行动作时界面出现:
getMoveRequestData is not a function
最终定位到战斗副本的序列化。固定版本的模拟器在 State.toRef 中使用 obj.constructor.name 识别对象,反序列化则匹配 Pokemon、Side、Battle 等固定字符串。生产压缩改变类名后,恢复出来的对象引用不完整,后面调用实例方法才出错。
这解释了为什么“首页加载成功”和“构建成功”都没能发现问题:只有真正推进回合、触发 AI 的副本评估,才走到这段逻辑。
修复放在单独的 cloneBattle.ts 适配层:复制前恢复序列化依赖的九类构造器名称,再调用 Battle.fromJSON(battle.toJSON())。没有关闭整个网站的压缩,也没有直接编辑安装目录里的依赖文件。
这是一项针对 @pkmn/sim@0.10.11 的兼容处理,会修改相关构造器的 name,不适合推广成通用做法。升级依赖时,需要重新检查上游是否仍依赖类名。
回归用例也不能只断言“没有抛错”。项目中的测试会主动改名复现原始失败,再验证适配后的副本能够推进回合,同时原战斗保持不变。
这次问题让我把生产验收的最小单位改成了一个完成的回合:按钮可点、请求有响应、日志有推进、画面更新且可以恢复,才算这条链路走通。Worker 把异常转成界面错误后,浏览器控制台甚至可能很安静,所以还要检查页面实际状态。
八、把界面可读性和生产部署一起纳入验收
工程正确不等于玩家能看懂。经典战场采用交叉布局:右上的对手,对应左上的血条;左下的我方,对应右下的血条。单纯还原位置,很容易让新玩家看错。
后续调整增加了 YOUR POKÉMON、RED · OPPONENT 和指向箭头,让角色与血条用同一事件里的 side 响应伤害和回复。动画开关与减少动态效果偏好也应该得到尊重。上面的开发截图就是这轮调整留下的记录。
音频也有类似问题。浏览器要求先获得用户交互,音乐才能启动;声音初始化失败不应该阻止战斗加载。于是音频作为独立能力处理,游戏不依赖它成功才能继续。
玩法、每日刷新和保存方式放在页面正文中,帮助玩家在初始化战斗前理解规则。这部分说明与战斗线程分离,避免引擎尚未就绪时,用户连基本操作和保存限制都无法了解。
部署使用 OpenNext 适配 Cloudflare Workers,静态资源单独提供。要区分网站服务器侧 Worker 与浏览器计算线程,线上要验证的也是完整链路:引擎资源是否加载、第一回合能否完成、刷新后能否恢复、结束后是否只计一次结果。
以下是我从项目现有验收记录中保留的检查重点;它们不代表所有设备和浏览器都已覆盖。
| 检查层 | 代表性问题 |
|---|---|
| 数据与规则 | 配招是否合法?Gen 3 类别、道具与状态是否正确? |
| 可复现性 | 同配置同动作能否恢复?AI 评估是否影响原局? |
| 保存与流程 | 刷新是否重复计分?练习是否污染正式结果? |
| 交互与呈现 | 动画跳过是否一致?血条归属是否清楚?窄屏能否操作? |
| 生产构建 | 压缩后能否执行回合、续局和结算? |
历史验收里已有自动对战、移动端布局和本地生产构建记录,但没有据此得出人类胜率、低端设备性能或全浏览器兼容结论。对于下一轮开发,测量首次开战加载成本、补真机覆盖、评估旧存档迁移,比单纯增加装饰效果更值得优先投入。
九、这个项目带来的几条开发体会
Daily Red Pokemon 对我最大的提醒,是一个小玩法也需要明确的状态边界。什么决定今天的队伍,什么决定战斗结果,什么只是视觉反馈,什么允许重试——这些问题越早说清,后面的实现越少互相牵扯。
如果重新安排开发顺序,我仍会先完成可复现的一局,再接动作日志与恢复,然后做菜单、动画和声音。规则库负责已有规则,自己的代码聚焦每日玩法和用户体验;验证时同时保留静态检查与生产回合检查。
下一步是否增加账号与服务端校验,应由跨设备同步和成绩可信度的需求决定。本地存档降低了后端成本,但无法提供可信排行榜;若要扩展这两项能力,就需要重新设计日期来源、操作验证和结算流程。