ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

开发了一个每天随机对战的宝可梦游戏

开发了一个每天随机对战的宝可梦游戏

本文分享一个浏览器游戏项目的实现与取舍:如何生成每天一致的队伍、接入 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.11Poké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 数据为依据。我保留自己的每日选队、对手策略和表现层,让成熟模拟器负责基础规则。

规则适配中有两处明确选择:

// 节选自模拟器适配层:数据世代和战斗格式要保持一致。constdex=Dex.mod("gen3");constbattle=newBattle({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 实现。constDATA_VERSION=2;functiondailySeed(dateKey:string):number{returnhashString32(`daily-red-pokemon:${dateKey}:v${DATA_VERSION}`)||1;}constseed=dailySeed(dateKey);constteamIds=shuffledCopy(playerPool,seed).slice(0,6).map(pokemon=>pokemon.id);

相同日期能得到相同队伍,前提是候选池内容及顺序、哈希、洗牌算法和版本都一致。只保留同一个数字种子,却悄悄调整候选池顺序,仍可能改变当天队伍。

我把重置时间设为玩家当地午夜,让“每天一次”贴近日常使用。代价是不同时区并非同时换题,所以页面显示挑战日期和 NEXT TEAM 倒计时,方便对照。

另外,数据种子版本与存档结构版本解决的是不同问题。前者参与选队,后者用于识别日志格式;当前都有版本标识,但并不会自动迁移所有旧数据。规则或数据升级时,旧动作日志是否还能复算,仍然需要单独处理。

这套本地日期方案适合轻量日常挑战。如果以后要做可信排行榜,就需要服务器确定挑战日期、验证操作和结算结果,不能直接把本地战绩当作可信成绩。

五、Web Worker 与动作日志:让刷新成为恢复流程

我希望玩家中途关闭页面后还能继续,因此存档不只记录“还剩多少 HP”,而是保存开局配置和已经提交的动作。

只存 HP 会丢失很多上下文:剩余 PP、状态、道具是否消耗、强化阶段、随机数进度等。配置加动作序列可以让模拟器重新走到相同位置,前提是相关规则和算法保持兼容。

下面是日志的核心结构节选:

typeAction=|{kind:"move";slot:number}|{kind:"switch";slot:number}|{kind:"forfeit"};interfaceJournal{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 对我最大的提醒,是一个小玩法也需要明确的状态边界。什么决定今天的队伍,什么决定战斗结果,什么只是视觉反馈,什么允许重试——这些问题越早说清,后面的实现越少互相牵扯。

如果重新安排开发顺序,我仍会先完成可复现的一局,再接动作日志与恢复,然后做菜单、动画和声音。规则库负责已有规则,自己的代码聚焦每日玩法和用户体验;验证时同时保留静态检查与生产回合检查。

下一步是否增加账号与服务端校验,应由跨设备同步和成绩可信度的需求决定。本地存档降低了后端成本,但无法提供可信排行榜;若要扩展这两项能力,就需要重新设计日期来源、操作验证和结算流程。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进