玄学放置 · 学海集市:初版市场设计
起因
市场面板从项目第一版就挂在侧栏里,但一直是个占位符——点进去只有一行"功能开发中"。玩家打到稀有装备和多余材料后无处消化,要么堆在背包里,要么直接丢掉。放置游戏的经济循环需要一个出口,让产出有地方流转,让玩家之间产生交易关联。
上一篇文章预告了商店系统,但仔细想了想,放置游戏的"商店"有两条路:NPC 商店(系统定价、无限供给)和玩家市场(玩家定价、有限供给)。前者实现简单,但缺乏社交张力——你买我买大家买同一个价,没有博弈空间。后者复杂得多,却是放置游戏能形成长期社区的关键:稀有物品的价格由供需决定,有人囤货抬价,有人急售套现,这种动态才有意思。
所以选了玩家市场。名字叫"学海集市"——和游戏"学科"主题呼应。
关键决策
双挂单模型:出售挂牌 + 求购委托
最常见的市场模型有两种:
- 挂单制:卖家标价挂出,买家浏览后购买。即"出售挂牌"
- 委托制:买家出价委托,卖家看到后出售。即"求购委托"
只做出售挂牌的话,买家是被动方——必须等卖家挂出来才能买。如果某件物品没人挂,买家永远买不到。反过来,只做求购委托也有类似问题。所以两个都做了:卖家可以挂牌等买家,买家也可以发委托等卖家。同一种物品的详情页里,出售挂牌和求购委托并列展示,玩家从两个方向都能达成交易。
金币托管
求购委托有一个核心问题:买家下单时如果不扣金币,等卖家来卖的时候买家可能已经花光了,委托变成空头支票。解决方法是下单即托管——发布求购时立刻从买家金币中扣除总价,委托取消时退回。出售挂牌不需要托管物品以外的费用,但物品从背包中移除是必须的——否则卖家可以挂单后把同一件物品再卖给 NPC 或别的玩家。
待收取机制
交易达成后,金币和物品不直接到账,而是进入"待收取"队列。这看起来多此一举,但有几个好处:
- 时间缓冲:买家购买后可能想一次买多件再统一收取,减少背包操作的碎片感
- 通知作用:待收取队列里的数字变化本身就是一种提示——"你有东西可以领了"
- 一致性:无论出售还是求购,交易达成的结果统一走待收取,流程一致
不可售卖标记
不是所有物品都应该上市场。新手任务送的初始装备如果能在市场流通,小号刷初始装备卖金币就会成为问题。ItemDef 加了 sellable?: boolean 字段,默认为 true,标记为 false 的物品不展示在市场物品列表中,装备气泡框里也不出现"去市场"按钮。
实现要点
三张表 + 一个中间态
数据库加了三张表:market_listings(出售挂牌)、market_buy_orders(求购委托)、market_pending(待收取)。每张表都有 status 字段,出售和求购的 status 流转是 active → sold/filled/cancelled,待收取的流转是 collected: false → true。
索引按查询场景设计:挂牌按 itemId + status 查(物品详情页需要),按 sellerId + status 查("我的订单"需要),待收取按 playerId + collected 查。
服务端路由
市场路由 server/src/routes/market.ts 共 8 个端点,每个端点的逻辑都是"校验 → 扣资源 → 写表 → 响应":
// 挂牌出售的校验链
const bagRows = await db.select().from(playerBag)
.where(and(eq(playerBag.playerId, playerId), eq(playerBag.itemId, itemId)))
.limit(1)
if (bagRows.length === 0) return reply.code(404).send({ error: '物品不在背包中' })
if (bagEntry.count < quantity) return reply.code(400).send({ error: '背包中物品数量不足' })购买挂牌时的资金流转值得一提:扣买家金币、标记挂牌为 sold、买家待收取写入物品、卖家待收取写入金币——一次交易产生两条待收取记录,分别属于买方和卖方。求购委托的成交逻辑是镜像的:扣卖家背包、标记委托为 filled、卖家待收取写入金币、买家待收取写入物品。
market store:乐观更新
前端 store 对交易操作采用乐观更新策略——API 调用成功后直接修改本地状态,不等服务端刷新。比如购买挂牌后立刻扣减本地金币:
async buyListing(listingId: number): Promise<boolean> {
const listing = this.listings.find(l => l.id === listingId)
if (!listing) return false
await buyFromMarket(listingId)
inventoryStore.gold -= listing.price * listing.quantity
await this.fetchListings(this.selectedItemId!)
return true
}风险是并发场景下本地状态和服务端不一致——两个玩家同时买同一件挂牌,只有一个能成功。但放置游戏是回合制交互,并发冲突的概率极低,乐观更新换来的体验流畅度更值得。
三 Tab 布局与色系设计
市场面板分三个 Tab:"市场"(浏览和交易)、"我的"(自己的出售和求购)、"待收取"(待领取的金币和物品)。每个 Tab 用不同的主题色:
- 市场 Tab:琥珀色
#f59e0b,温暖的收获感 - 我的 Tab:翡翠色
#14b8a6,求索的寻找感 - 待收取 Tab:玫瑰色
#f472b6,奖励的惊喜感
色系区分不只是装饰——它让玩家在三个 Tab 之间切换时能快速识别当前所处位置。订单卡片左侧有一条 3px 的色带,出售用琥珀色、求购用翡翠色,一眼区分类型。
物品一览 → 物品详情的两级导航
市场 Tab 内部有 overview 和 detail 两种视图模式。一览模式展示所有可交易物品的网格(按类别筛选),点击某个物品进入详情模式,展示该物品的出售挂牌列表、求购委托列表和下单面板。返回按钮用新增的 backArrow 像素图标。
装备面板的"去市场"入口
市场不只是从侧栏进入——装备面板的气泡框里也加了"去市场"按钮。点击后调用 marketStore.goToMarket(itemId),这个 action 会自动切换侧栏到市场菜单、选中对应物品、跳转到详情页。跨 store 的导航用 subjectsStore.selectMenu 实现,和设置页里的学科跳转是同一个模式。
注意事项
竞态条件。 当前实现没有数据库层面的行锁。如果两个玩家同时购买同一件挂牌,两个请求可能都通过了 status === 'active' 的检查,导致同一件物品被卖给两个人。放置游戏的用户量级下这不太可能发生,但如果后续要做正式上线,需要在购买操作上加行级锁(SELECT ... FOR UPDATE)或用 CAS(compare-and-swap)更新。
一键收取的数据一致性。 collectAll action 调用服务端的 /collect-all 端点后,直接用 authStore.loadPlayerState() 重新加载整个玩家状态(背包 + 金币),而不是逐条更新本地 store。这样做的代价是一次额外的 API 请求,但换来了绝对的数据一致性——避免了"收了 5 件物品但本地只更新了 3 件"的脱节问题。
sellable 字段的同步。 sellable 字段同时存在于前端 config/items.ts 和服务端 shared/items.ts,两份物品定义需要保持一致。如果一端加了 sellable: false 而另一端忘了同步,就会出现"前端不展示但服务端允许挂牌"或"前端展示了但服务端拒绝"的矛盾。后续应该考虑把物品定义统一到单一来源。
pixelIcons 的逗号。 这次新增了 backArrow 和 priceTag 两个图标。pixelIcons.ts 是一个 as const satisfies Record<string, PixelIconDef> 的对象字面量,每加一个图标必须确保上一项末尾有逗号。这个坑在之前的文章里记录过,这次没有再犯。
收获
市场系统从占位符变成了一个完整的交易闭环:卖家挂牌 → 买家浏览购买 → 双方各自收取 → 物品和金币到账。求购委托让买家也能主动发起交易,不用干等卖家挂单。金币托管消除了空头委托的风险,待收取机制给了交易结果一个明确的领取步骤。
这次改动最值得记的设计决策是"双挂单"——出售和求购并存。如果只做出售挂牌,系统就是一个简单的跳蚤市场;加上求购委托后,它更接近一个真正的交易所。两种挂单的价格对比本身就提供了信息价值——出售价和求购价之间的价差,就是市场深度和流动性的直观指标。
从工程角度看,这次最大的体会是"乐观更新 + 全量刷新"的混合策略。单项操作用乐观更新保证即时反馈,一键收取这种批量操作用全量刷新保证一致性。两种策略各取所长,不需要在"全部乐观"或"全部等服务端"之间二选一。
下一篇要做的可能是市场的搜索和排序功能——当可交易物品变多之后,纯分类筛选不够用,需要按价格、数量、时间排序,以及按名称搜索。