Phaser 玩家交易市场客户端:列表、竞价、锁定和失败回滚

从游戏内交易市场的客户端角度,讲清 Phaser UI、分页筛选、价格刷新、购买锁定、失败回滚、防重复提交和玩家反馈。

为什么要把它当成系统来做

多人刷宝游戏里,玩家打到一把带火焰词缀的短剑,想挂到市场出售;另一个玩家按攻击速度筛选,看到短剑后点击购买。两个人都在移动端,网络也不稳定。

交易市场看起来是普通列表 UI,但它处理的是玩家资产。客户端必须防重复点击、展示价格变动、处理库存锁定和购买失败,不能把成功动画放在服务端确认之前。 本文不把它写成一个一次性 Demo,而是按可上线、可维护的小系统拆开。重点不是堆 API,而是回答几个真实问题:数据从哪里来,谁有权修改状态,失败时玩家看到什么,调试时程序能看到什么,内容增加后系统还能不能承受。

核心架构

flowchart TD
  Ne7ad9be980["筛选条件"] --> N4d61726b65["MarketQuery"]
  N4d61726b65["MarketQuery"] --> N4c69737469["ListingCache"]
  N4c69737469["ListingCache"] --> Ne58897e8a1["列表 UI"]
  Ne58897e8a1["列表 UI"] --> N5075726368["PurchaseLock"]
  N5075726368["PurchaseLock"] --> N5472616465["TradeService"]
  N5472616465["TradeService"] --> Ne7a1aee8ae["确认或失败"]
  Ne7a1aee8ae["确认或失败"] --> N546f617374["ToastQueue"]
  Ne7a1aee8ae["确认或失败"] --> Ne5ba93e5ad["库存刷新"]

这张图的关键,是把 MarketQuery、FilterState、ListingCache、PurchaseLock、TradeService、ToastQueue 放在单向流里。玩家输入或系统 tick 进入核心模型,模型产出结果,Phaser 再把结果转成动画、粒子、声音和界面。不要让显示对象反向决定规则。只要核心模型能在没有 Canvas 的环境中运行,就能写测试、做编辑器预览,也能在以后接入服务端校验或云存档。

市场 UI 要承认数据会过期

列表中的价格、库存和卖家状态都可能在玩家看到后变化。客户端缓存可以提高滚动体验,但每条 listing 都要有 version 或 updatedAt。购买前发送 listingId 和 version,若服务端返回价格变化,UI 应展示新价格并要求再次确认,而不是默默按新价格扣款。

在实现这一点时,建议先写一组可以复现的输入数据,再接 Phaser 场景。先让控制台打印出正确状态,再让 Graphics、Sprite、Text 和 Tween 去表现它。这样的顺序不炫,但能减少非常多“画面看起来对,规则其实错”的问题。

筛选状态要能复制和恢复

装备市场常有职业、等级、词缀、价格区间、稀有度和排序。筛选状态应是一个可序列化对象,便于分享、返回恢复和埋点。Phaser 里可以用 DOM Overlay 或自绘 UI,但状态不要散在按钮上。按钮只是修改 FilterState,MarketQuery 根据它生成请求。

在实现这一点时,建议先写一组可以复现的输入数据,再接 Phaser 场景。先让控制台打印出正确状态,再让 Graphics、Sprite、Text 和 Tween 去表现它。这样的顺序不炫,但能减少非常多“画面看起来对,规则其实错”的问题。

虚拟列表比一次画完安全

几百条 listing 如果都生成容器、图标、文本和按钮,移动端会明显掉帧。用虚拟列表只保留可见行和少量缓冲行,滚动时复用行组件。每一行绑定 listingId,刷新数据时先检查行当前 id,避免旧请求回来后把错的价格写到新行上。

在实现这一点时,建议先写一组可以复现的输入数据,再接 Phaser 场景。先让控制台打印出正确状态,再让 Graphics、Sprite、Text 和 Tween 去表现它。这样的顺序不炫,但能减少非常多“画面看起来对,规则其实错”的问题。

购买锁定防止重复提交

玩家点击购买后,本地立即给该 listing 加 PurchaseLock,按钮进入等待状态。同一 listing、同一背包物品或同一货币余额相关的操作都应暂时禁用。锁定有超时,服务端确认或失败都会释放。不要只靠按钮 disabled,因为快捷键和手柄也可能触发购买。

在实现这一点时,建议先写一组可以复现的输入数据,再接 Phaser 场景。先让控制台打印出正确状态,再让 Graphics、Sprite、Text 和 Tween 去表现它。这样的顺序不炫,但能减少非常多“画面看起来对,规则其实错”的问题。

失败回滚要有具体原因

购买失败有很多种:已被买走、价格变化、余额不足、背包满、网络超时、账号交易限制。客户端要把它们映射成可理解的提示,并刷新相关区域。已被买走时移除该 listing;余额不足时刷新货币;背包满时引导清理,而不是统一显示操作失败。

在实现这一点时,建议先写一组可以复现的输入数据,再接 Phaser 场景。先让控制台打印出正确状态,再让 Graphics、Sprite、Text 和 Tween 去表现它。这样的顺序不炫,但能减少非常多“画面看起来对,规则其实错”的问题。

出售流程要先锁背包物品

玩家挂售时,物品从背包进入待上架状态。客户端可以先灰掉格子并显示锁图标,服务端确认后移到出售列表。若失败,物品回到背包。不要让玩家在确认前又把同一件物品分解或装备,这会制造资产竞态。

在实现这一点时,建议先写一组可以复现的输入数据,再接 Phaser 场景。先让控制台打印出正确状态,再让 Graphics、Sprite、Text 和 Tween 去表现它。这样的顺序不炫,但能减少非常多“画面看起来对,规则其实错”的问题。

价格输入需要防手滑

市场价格往往有单位陷阱。输入框应显示税费、预计到手、最低价、近期成交参考和确认步骤。对明显异常价格给二次确认,例如低于推荐价 70%。这些规则在客户端是体验保护,最终仍由服务端校验。

在实现这一点时,建议先写一组可以复现的输入数据,再接 Phaser 场景。先让控制台打印出正确状态,再让 Graphics、Sprite、Text 和 Tween 去表现它。这样的顺序不炫,但能减少非常多“画面看起来对,规则其实错”的问题。

TypeScript 实现骨架

interface Listing { id: string; version: number; name: string; price: number; seller: string }
type PurchaseState = "idle" | "locking" | "confirmed" | "failed";
class PurchaseLock {
  private locks = new Map<string, number>();
  acquire(id: string, ttl = 8000) {
    const now = performance.now();
    if ((this.locks.get(id) ?? 0) > now) return false;
    this.locks.set(id, now + ttl);
    return true;
  }
  release(id: string) { this.locks.delete(id); }
  isLocked(id: string) { return (this.locks.get(id) ?? 0) > performance.now(); }
}
async function buyListing(listing: Listing, lock: PurchaseLock, service: { buy(id: string, version: number): Promise<void> }) {
  if (!lock.acquire(listing.id)) return { ok: false, reason: "duplicate" };
  try {
    await service.buy(listing.id, listing.version);
    return { ok: true };
  } catch (error) {
    return { ok: false, reason: String(error) };
  } finally {
    lock.release(listing.id);
  }
}

这段代码只是骨架,真正项目里还要加事件派发、错误码、配置校验和日志。但骨架已经表达了方向:核心概念是普通 TypeScript 对象,Phaser 类型只出现在输入适配或表现需要的地方。若你发现某个函数越来越依赖 Scene、Camera 或 Sprite,就应该停下来判断它是不是被放错层了。

落地步骤

  1. 第一,确认 MarketQuery 的输入输出是否是纯数据。若需要 Phaser.GameObjects 才能计算结果,说明边界还没有切开。
  2. 第二,给 FilterState 或同等级的核心概念写三个最小样例:正常路径、边界路径、失败路径。样例要能在没有浏览器画面的情况下运行。
  3. 第三,把 UI 上每个可点击动作都映射成明确意图,不要让按钮直接修改深层状态。意图里带 requestId,便于防重复和追踪。
  4. 第四,失败反馈要比成功反馈更早接入。成功时玩家通常愿意接受,失败时才会质疑系统是否可靠。
  5. 第五,内容配置要有默认值和校验脚本。字段缺失时宁可在启动时报错,也不要在玩家操作到一半才静默失败。
  6. 第六,性能指标要提前量化:每帧最多处理多少对象、单次刷新允许多少毫秒、低端机是否需要降级显示。

常见坑

  • 最容易踩的坑,是让表现层过早成为事实来源。比如动画播完才算成功、按钮亮着就代表可用、某个 Sprite 存在就说明状态存在。这些判断在演示机上没问题,一到跳过、暂停、断线、切场景和重连就会变成隐性故障。
  • 第二个坑是只为第一关写逻辑。第一关对象少、路径短、输入慢,任何写法都像是正确的。等内容增加到几十张地图、几百个配置和各种活动修正时,临时判断会互相覆盖。写系统时要假设它会被复用、被误用、被配置错。
  • 第三个坑是没有留下证据。玩家反馈“刚才没生效”时,如果没有事件日志、状态快照或 requestId,只能靠猜。哪怕是单机项目,也可以保留最近 50 条关键事件,开发包里导出文本,定位速度会快很多。

项目里的验证方式

把这套系统放进 Phaser 项目时,我会先建一个不依赖 Scene 的核心目录,例如 src/gameplay/marketplace-trading-client。里面只放模型、求解器、状态机和测试夹具。Scene 只负责输入适配、对象池、摄像机、音效和 UI。这个边界看似多写几行代码,但它换来的是可测试、可回放和可迁移。等项目进入内容生产阶段,最值钱的不是某个特效多漂亮,而是当策划说某个状态不对时,程序能在五分钟内复现并解释。

数据格式要尽量像内容团队会填写的表,而不是像程序临时拼出来的对象。每个 id 都要稳定,每个状态都要能序列化,每个失败原因都要有明确枚举。Phaser 的优势是快速把反馈做出来,但反馈越快,越容易掩盖规则层的混乱。先把规则层写清楚,再接动画,后续加新模式、新活动或新平台才不会反复拆墙。

在调试阶段,建议给这个系统加一个小面板:显示当前输入、核心状态、最近事件、最后一次失败原因和关键耗时。面板不需要好看,但要准确。很多客户端问题在日志里只是一句 undefined,在调试面板里却能看到完整链路。尤其是多人、存档、复杂 UI 或长时间运行的玩法,调试可见性直接决定维护成本。

最后检查

做完第一版后,不要只看一次演示是否顺滑。至少准备三组数据:一组正常流程,一组边界流程,一组故意配置错误的流程。正常流程证明体验成立,边界流程证明状态不会漂移,错误流程证明系统会给出可理解的失败原因。玩家交易市场客户端:列表、竞价、锁定和失败回滚 的质量不取决于第一眼多热闹,而取决于玩家反复操作、内容不断扩张、版本持续迭代时,它还能保持清楚、稳定和可解释。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页