Skip to content

玄学放置 · 市场高密度改造:从卡片页到盘口式集市

起因 / 背景

上一篇已经把市场从占位符推进到可交易的双挂牌模型:出售挂牌、求购委托、金币托管、待收取队列都跑通了。那一版解决的是“能不能交易”的问题,但界面还更像一个管理面板:物品以卡片呈现,详情页用单列订单卡片,下单表单嵌在页面底部。

放置游戏里的市场并不是偶尔打开一次的表单页,而是玩家反复扫货、比价、补材料、清库存的地方。它需要的信息密度更高,操作路径更短,也需要让玩家一眼看出“哪里有货、哪里有人收、价差是多少”。这次改造参考了像素风放置游戏的市场界面,把重点从“漂亮的订单卡片”转向“可快速阅读的盘口”。

因此这次目标很明确:首页变成物品墙,详情页变成左右买卖盘口,发布出售或求购通过弹窗完成,“我的挂牌”和“待收取”也改成紧凑表格。视觉上保留项目原有暗色科技底色和像素图标,但交互节奏向更高密度的游戏市场靠拢。

关键决策

先补行情摘要,而不是前端逐个请求。 如果首页每个物品都要请求一次出售挂牌和求购委托,物品数量一多就会变成瀑布式请求。更合理的方式是让服务端一次性聚合活动订单,返回每个物品的最低卖价、最高买价和盘口数量。首页只需要一份摘要数据,就能把“有没有行情”直接画在物品墙上。

详情页采用左右盘口。 出售挂牌按价格从低到高排序,求购委托按价格从高到低排序。这个顺序符合交易直觉:买家最关心最低卖价,卖家最关心最高收购价。左右并列后,价差会自然浮现出来,玩家不需要在两个 Tab 之间来回切。

下单从嵌入表单改为弹窗。 市场详情页的主任务是读盘口,不应该被常驻表单占掉空间。弹窗只在玩家明确要“新出售挂牌”或“新购买挂牌”时出现,并提供价格和数量的快捷按钮,让调整数字这件事更接近游戏里的步进器,而不是普通后台表单。

我的挂牌不再依赖当前物品。 旧版“我的”页实际是从当前详情页已加载的订单里筛选出自己的挂牌。如果没有选中物品,或者刚好只看了某一个物品,就会漏掉其他挂牌。新版专门加载全市场活动订单,再筛选当前玩家,保证“我的挂牌”是一个真正的个人管理页。

实现要点

行情摘要接口

新增的摘要接口不改数据库结构,只读取活动中的出售和求购订单,在服务端用 Map 聚合。这样既避免前端逐个物品打接口,也不会把聚合逻辑散落到 UI 层。

ts
const summaryMap = new Map<string, MarketItemSummary>()

const ensureSummary = (itemId: string): MarketItemSummary => {
  const existing = summaryMap.get(itemId)
  if (existing) return existing

  const summary: MarketItemSummary = {
    itemId,
    sellCount: 0,
    sellQuantity: 0,
    minSellPrice: null,
    buyCount: 0,
    buyQuantity: 0,
    maxBuyPrice: null
  }
  summaryMap.set(itemId, summary)
  return summary
}

这里用 null 表示没有价格,而不是用 0。因为 0 在交易语义里像一个真实价格,会让前端不得不再猜一次“这是无报价,还是免费”。明确的空值让模板显示 -- 更自然。

store 按页面用途拆分

市场 store 以前只有 listingsbuyOrders 两组数据,既服务详情页,也服务个人订单页。改造后拆成四块:summaries 给首页,detailListingsdetailBuyOrders 给当前物品盘口,myListingsmyBuyOrders 给我的挂牌。

ts
interface MarketState {
  summaries: MarketItemSummary[]
  detailListings: MarketListing[]
  detailBuyOrders: MarketBuyOrder[]
  myListings: MarketListing[]
  myBuyOrders: MarketBuyOrder[]
}

这个拆分看起来只是命名更细,实际解决的是状态污染问题。详情页刷新不会误伤我的挂牌页,我的挂牌页也不再假设当前选中了某个物品。后续如果要加成交历史或价格曲线,也能继续沿着“页面用途分区”的方式扩展。

物品墙的排序逻辑

首页物品墙不是简单按配置顺序展示,而是优先把有行情的物品放到前面,再按分类、品质和名称稳定排序。这样市场打开时,最有交易价值的东西会自然浮上来。

ts
return Object.values(ITEMS_DB)
  .filter(item => item.sellable !== false && item.category !== 'currency')
  .sort((a, b) => {
    const aHot = hasMarket(a.id) ? 1 : 0
    const bHot = hasMarket(b.id) ? 1 : 0
    if (aHot !== bHot) return bHot - aHot
    if (a.category !== b.category) return a.category.localeCompare(b.category)
    return a.name.localeCompare(b.name, 'zh-Hans-CN')
  })

这个排序不是为了“聪明”,而是为了减少玩家扫视成本。放置游戏的市场往往有大量材料,如果有行情和没行情混在一起,玩家每次都要自己过滤一遍。

弹窗里的默认价格

出售弹窗的默认价优先取当前最低卖价,其次取最高求购价,最后回退到 1;求购弹窗则反过来,优先取最高求购价,其次取最低卖价。这个小规则让玩家打开弹窗时不会面对一个完全空白的价格输入。

ts
dialogPrice.value = mode === 'sell'
  ? summary.minSellPrice ?? summary.maxBuyPrice ?? 1
  : summary.maxBuyPrice ?? summary.minSellPrice ?? 1

它不是自动定价系统,只是一个顺手的参考值。真正的价格仍然由玩家通过 -10-1+1+10 这些快捷按钮微调。

注意事项 / 踩到的坑

盘口行不能用按钮包按钮。 详情页的每一行原本写成可点击按钮,行内又有“购买”“出售”“取消”按钮。HTML 不允许按钮嵌套按钮,浏览器会自动重排 DOM,表现可能变得不可控。最后把盘口行改成普通 div,只保留行内操作按钮。

服务端构建暴露了类型导入边界。 市场本身的后端代码没有问题,但跑 pnpm -C server build 时发现 index.tsws 直接导入 WebSocket 类型,而 server/package.json 并没有声明 ws。项目已经通过 @fastify/websocket 使用 WebSocket,所以改成从 @fastify/websocket 引类型,避免新增依赖,也让构建重新通过。

构建产物不应该成为文章的一部分。 前端 build 会生成 frontend/dist,博客 build 也会生成 .vitepress/dist。这些产物只用于验证,不应该被当成这次市场改造的正文内容。写文章时只记录设计决策和工程取舍,不把构建输出贴进来。

收获 / 总结

这次改造让市场从“功能已经有了”走向“像一个玩家会反复使用的交易场”。首页物品墙负责快速发现行情,详情页左右盘口负责比较价格,弹窗负责完成下单,我的挂牌页负责管理自己的挂单,待收取页负责收束交易结果。每一层都有明确职责,操作路径也比旧版更短。

最大的收获是:市场 UI 的核心不是展示订单,而是展示供需关系。出售价和求购价并列出现后,玩家才能理解某个物品当前的流动性和价差;行情摘要出现在物品墙上后,玩家也不必点进每个物品才知道有没有交易机会。

后续如果继续完善市场,下一步可以考虑成交历史和价格趋势。当前版本已经有最低卖价、最高买价和盘口数量,离“价格曲线”只差一层历史记录。等交易数据积累起来,市场就不只是买卖入口,也会变成玩家判断材料价值的情报面板。