2026/10/16 12:27:05

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

开发了一个每天随机对战的宝可梦游戏 本文分享一个浏览器游戏项目的实现与取舍如何生成每天一致的队伍、接入 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 和 BlastoisePikachu 首发其余出场可以因换人而变化。玩家侧从 30 种已配置的 Pokémon 中抽取六种不重复。招式、性格、能力值、特性和携带道具有预设不是把所有世代、所有招式组合都交给随机生成。这个限制同时服务玩法和开发玩家面对的是“用现有队伍解决问题”而不是不断刷新到理想阵容。每套配置能单独检查遇到错误可以定位到具体数据。测试覆盖有明确边界不必一开始就处理无限的配招组合。每天一次计入记录的挑战负责提供共同话题独立练习负责给玩家试错空间。这两种需求需要在状态模型中分开否则“练习一下”也可能意外消耗正式机会。我的经验是先写清楚什么会被计分、什么必须保持一致比先做更多动画更能决定项目是否容易维护。二、技术栈页面、战斗计算和部署分别负责什么项目使用现有 Web 工程承载游戏核心部分如下。这里列的是实际参与游戏链路的技术不是根目录所有依赖。层次使用的技术具体职责网站与页面Next.js 15、React 19页面、玩法说明、元数据、交互组件类型与数据TypeScript、JSON动作协议、队伍配置、战斗快照与事件类型规则计算pkmn/sim0.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 数据为依据。我保留自己的每日选队、对手策略和表现层让成熟模拟器负责基础规则。规则适配中有两处明确选择// 节选自模拟器适配层数据世代和战斗格式要保持一致。constdexDex.mod(gen3);constbattlenewBattle({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_VERSION2;functiondailySeed(dateKey:string):number{returnhashString32(daily-red-pokemon:${dateKey}:v${DATA_VERSION})||1;}constseeddailySeed(dateKey);constteamIdsshuffledCopy(playerPool,seed).slice(0,6).map(pokemonpokemon.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/sim0.10.11的兼容处理会修改相关构造器的name不适合推广成通用做法。升级依赖时需要重新检查上游是否仍依赖类名。回归用例也不能只断言“没有抛错”。项目中的测试会主动改名复现原始失败再验证适配后的副本能够推进回合同时原战斗保持不变。这次问题让我把生产验收的最小单位改成了一个完成的回合按钮可点、请求有响应、日志有推进、画面更新且可以恢复才算这条链路走通。Worker 把异常转成界面错误后浏览器控制台甚至可能很安静所以还要检查页面实际状态。八、把界面可读性和生产部署一起纳入验收工程正确不等于玩家能看懂。经典战场采用交叉布局右上的对手对应左上的血条左下的我方对应右下的血条。单纯还原位置很容易让新玩家看错。后续调整增加了YOUR POKÉMON、RED · OPPONENT和指向箭头让角色与血条用同一事件里的side响应伤害和回复。动画开关与减少动态效果偏好也应该得到尊重。上面的开发截图就是这轮调整留下的记录。音频也有类似问题。浏览器要求先获得用户交互音乐才能启动声音初始化失败不应该阻止战斗加载。于是音频作为独立能力处理游戏不依赖它成功才能继续。玩法、每日刷新和保存方式放在页面正文中帮助玩家在初始化战斗前理解规则。这部分说明与战斗线程分离避免引擎尚未就绪时用户连基本操作和保存限制都无法了解。部署使用 OpenNext 适配 Cloudflare Workers静态资源单独提供。要区分网站服务器侧 Worker 与浏览器计算线程线上要验证的也是完整链路引擎资源是否加载、第一回合能否完成、刷新后能否恢复、结束后是否只计一次结果。以下是我从项目现有验收记录中保留的检查重点它们不代表所有设备和浏览器都已覆盖。检查层代表性问题数据与规则配招是否合法Gen 3 类别、道具与状态是否正确可复现性同配置同动作能否恢复AI 评估是否影响原局保存与流程刷新是否重复计分练习是否污染正式结果交互与呈现动画跳过是否一致血条归属是否清楚窄屏能否操作生产构建压缩后能否执行回合、续局和结算历史验收里已有自动对战、移动端布局和本地生产构建记录但没有据此得出人类胜率、低端设备性能或全浏览器兼容结论。对于下一轮开发测量首次开战加载成本、补真机覆盖、评估旧存档迁移比单纯增加装饰效果更值得优先投入。九、这个项目带来的几条开发体会Daily Red Pokemon 对我最大的提醒是一个小玩法也需要明确的状态边界。什么决定今天的队伍什么决定战斗结果什么只是视觉反馈什么允许重试——这些问题越早说清后面的实现越少互相牵扯。如果重新安排开发顺序我仍会先完成可复现的一局再接动作日志与恢复然后做菜单、动画和声音。规则库负责已有规则自己的代码聚焦每日玩法和用户体验验证时同时保留静态检查与生产回合检查。下一步是否增加账号与服务端校验应由跨设备同步和成绩可信度的需求决定。本地存档降低了后端成本但无法提供可信排行榜若要扩展这两项能力就需要重新设计日期来源、操作验证和结算流程。