Skip to content

玄学放置 · 第一版接口对接

起因

上一篇写登录页面,auth store 已经能调后端的 register/login 接口了,但登录成功后前端还是跑 mock 数据——subjects store 从 SUBJECTS_SEED 初始化,inventory store 从 INITIAL_EQUIPPED 初始化,刷新就回到出厂设置。后端的 GET /api/player/statePOST /api/player/save 无人问津,等于桥修好了但两端的路还没接上。

这次要把这条路接通:登录后从服务端拉状态,游戏过程中定期存档,离线再上线时自动结算奖励。前端从"纯本地 SPA"变成"有后端存档的应用"。

架构确认:客户端权威 + 定期同步

上一篇后端搭建时选了"离线进度结算"模型,这次具体落地:

  • 登录时:调 GET /api/player/state,后端根据 lastActiveAt 时间差补算离线收益,连状态一起返回
  • 在线时:客户端跑 200ms tick(和以前一样),每 30 秒调 POST /api/player/save 全量同步
  • 离开时:组件卸载或登出前保存一次

为什么不每个操作都调 API(比如装备调 POST /api/inventory/equip)?因为放置游戏的交互频率很高——每 200ms 一次 tick,每完成一次任务就掉落物品,如果每次掉落都走网络请求,延迟和失败率会严重影响体验。客户端本地跑逻辑、定期批量同步,是放置游戏的标准做法。

前后端数据模型对齐——最大的一块骨头

对接 API 看起来只是"把 mock 数据换成 fetch 结果",但实际做的时候发现:前端 mock 和后端 game-config 的任务定义根本不一样。

维度前端 mock后端 game-config
任务数/学科2-4 个6 个(tier 0-5)
任务 IDrecite, words, compose, parsephrase-copy, rhetoric-practice, story-outline...
掉落物品ink, dictPagephraseCard, inkBottle
任务属性无 requiredLevel有 requiredLevel + tierIndex

这不是简单的"字段名不同",而是两套完全不同的任务体系。后端有 6 个 tier,按等级解锁——tier 0 的 requiredLevel: 1,tier 5 的 requiredLevel: 70;前端 mock 只有 2-4 个任务,没有等级门槛。

不对齐会怎样? 离线结算崩掉。后端 computeOfflineProgressgetTask(subjectId, taskId) 查找任务定义,如果前端存的队列里 taskId 是 recite,后端 game-config 里没有这个 ID,getTask 返回 undefined,结算直接跳过。存档写回去,队列被清空,玩家挂了一晚上的进度没了。

所以必须对齐。方案是前端的学科/任务定义改为使用后端的 game-config——9 个学科各 6 个任务,tier 递进,掉落表一致。这同时带来了一个体验提升:低等级时只显示 tier 0 的入门任务(比如语言学科的"词句摘抄"),高等级才解锁"命名仪式"这种后期内容,而不是一开始就把所有任务摊开。

三个新文件

api/index.ts — API 客户端

之前 auth store 里直接写 fetch('http://localhost:3000/api/auth/login'),没有统一的请求封装。这次抽了一个 API 客户端,统一注入 Authorization Bearer 头,处理 401 自动登出,网络错误包装成统一结构:

ts
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
  const token = localStorage.getItem('token')
  const headers = { 'Content-Type': 'application/json', ... }
  if (token) headers['Authorization'] = `Bearer ${token}`

  const res = await fetch(`${API_BASE}${path}`, { ...options, headers })
  if (res.status === 401) {
    localStorage.removeItem('token')
    window.location.hash = '#/login'
    throw { status: 401, error: '登录已过期' }
  }
  // ...
}

401 处理是容易被忽略的点。token 存在 localStorage 里,7 天过期。如果过期了但前端还在跑,下一次 API 请求会返回 401,此时需要清掉本地 token 并跳回登录页。如果不处理这个,用户会卡在一个"数据加载失败但页面还在"的半死状态。

config/game-config.ts — 游戏静态配置

从后端 shared/game-config.ts 整体移植。这个文件包含 9 个学科 × 6 个任务的定义、TASK_TIERS 档位配置、掉落表构建函数 buildDrops、以及 getTask/getSubject/nextExpMax 辅助函数。

移植而不是共享包,是因为前后端目前是两个独立的 pnpm workspace,没有 monorepo 基础设施。手动保持同步当然有风险,但对个人项目来说,一个 config/game-config.ts 的变更频率很低——只有加新学科或调数值时才改,而且改的时候前后端都要改,复制粘贴比搭 monorepo 快。

config/items.ts — 物品定义

mock/inventory.ts 演化而来,做了三件事:

  1. 保留 ITEMS_DB、SLOTS、RARITIES、CATEGORIES 和所有类型定义——组件和 store 依赖这些做展示
  2. 移除 INITIAL_EQUIPPED 和 INITIAL_INVENTORY——初始状态现在从服务端 PlayerState 获取,本地不再硬编码
  3. 扩展 约 54 种新材料物品——后端 game-config 的掉落表引用了大量新 item ID(phraseCard、inkBottle、mathDraft...),前端 ITEMS_DB 里没有对应定义会导致名称和图标显示为 raw ID

新材料物品的定义很精简——id、name、category='material'、iconKey 统一用 'material' 占位图标、rarity、desc、stats。用一个辅助函数批量构造:

ts
function mat(id: string, name: string, rarity: Rarity, desc: string): ItemDef {
  return { id, name, category: 'material', iconKey: MATERIAL_ICON, rarity, desc, stats: { 用途: '合成材料' } }
}

这样做的前提是 PixelIcon 对未知 iconKey 能优雅降级——以前未知 key 返回空数组,SVG 的 v-if 直接不渲染,什么都不显示。这次加了个灰色占位方块,至少能看出"这里有个物品"。

Store 重构:hydrate 模式

两个核心 store 的重构思路一致:静态定义 + 服务端运行时数据 = 完整状态

subjects store

旧的初始化:

ts
state: () => ({
  list: SUBJECTS_SEED.map(s => ({ ...s, level: 1, exp: 0, expMax: 100, completedCount: 0 })),
  queue: []
})

新的初始化(默认空,等服务端数据):

ts
state: () => ({
  list: SUBJECTS.map(s => ({ ...s, level: 1, exp: 0, expMax: 100, completedCount: 0 })),
  queue: [],
  hydrated: false
})

新增 hydrateFromServer(state: PlayerState) action:

ts
hydrateFromServer(state: PlayerState): void {
  // 静态定义来自 game-config,运行时数据来自服务端
  this.list = SUBJECTS.map(def => {
    const server = state.subjects.find(s => s.subjectId === def.id)
    return { ...def, level: server?.level ?? 1, exp: server?.exp ?? 0, ... }
  })
  // 队列从服务端恢复
  this.queue = state.queue.map(q => ({
    subjectId: q.subjectId, taskId: q.taskId,
    targetCount: q.targetCount, completedCount: q.completedCount,
    progressMs: q.progressMs, addedAt: Date.now()
  }))
  this.hydrated = true
}

addedAt 字段前端独有(用于排序备用),服务端没有返回,本地生成一个当前时间戳即可。

新增 toSavePayload() —— 从两个 store 拼装 POST /api/player/save 的请求体。Subjects store 负责拼 gold/subjects/bag/equipped/queue,inventory store 提供原始数据。

新增 availableTasks getter —— 按等级过滤可见任务:

ts
availableTasks(): Task[] {
  return this.current.tasks.filter(t => this.current.level >= t.requiredLevel)
}

SubjectPanel 模板里把 v-for="t in subject.tasks" 改成 v-for="t in store.availableTasks",低等级任务不再展示。

inventory store

初始化改为空值——所有装备槽位为 null,背包为空,金币为 0。hydrateFromServer 从服务端 PlayerState 填充 equipped/bag/gold。其他逻辑(equip/unequip/addItem/addGold)不变。

auth store 的串联

之前 auth store 只管登录注册,不管游戏状态。这次加了两个方法:

  • loadPlayerState():调 API 拿 PlayerState,注入 subjects 和 inventory 两个 store
  • saveState():从两个 store 拼 payload,调 API 保存

login/register/guestLogin 成功后自动调用 loadPlayerState(),这样路由跳转到 Home 时,store 已经有服务端数据了。

Home/index.vue 的定时保存

Home 组件之前只跑 tick 定时器。这次加了:

  1. onMounted:如果 store 还没 hydrated,调 loadPlayerState(),顺便检查 offlineReward 弹通知
  2. 30s setInterval:调 saveState(),用 isSaving ref 防止重入
  3. onBeforeUnmount:先 saveState() 再清理定时器

离线奖励用 ElNotification 显示——列出离线时长、获得的金币、各学科经验、物品。虽然目前物品名还是 raw ID(比如 phraseCard×3),但比什么提示都没有好,后续 ITEMS_DB 补全后自然变成中文名。

顺手修了一个后端 Bug

server/src/routes/events.ts 里清理过期事件的 WHERE 条件写反了:

ts
// 错误:删除 expiresAt > now 的(还没过期的)
.where(and(eq(playerEvents.playerId, playerId), gt(playerEvents.expiresAt, now)))
// 正确:删除 expiresAt < now 的(已经过期的)
.where(and(eq(playerEvents.playerId, playerId), lt(playerEvents.expiresAt, now)))

gt 应该是 lt。这个 Bug 意味着每次获取事件列表时,会把未过期的事件删掉、过期的保留——完全反了。因为目前事件功能还没接入前端,线上没有真实数据,所以影响是零,但如果不修,接上事件功能后每天任务会被清空。

踩到的坑

逗号遗漏导致构建失败。在 pixelIcons.ts 里新增 material 图标时,inspirationCrystal 的结束花括号后面少了逗号——JS 对象最后一个属性后面不需要逗号,但中间插入新属性就必须有。vue-tsc --noEmit 不报这个错(类型检查不关心对象字面量的逗号),但 esbuild 会报,vite build 直接失败。教训:改对象字面量时养成习惯,每个属性后面都加逗号。

QueueEntry 类型差异。服务端的 QueueEntry 有 id: number(数据库主键),前端的 QueueEntry 有 addedAt: number(本地时间戳)。hydrateFromServer 时需要做适配——忽略服务端的 id,本地生成 addedAt。反过来 toSavePayload 时不传 addedAt,服务端 schema 不接受这个字段。这种前后端类型微妙的差异,如果不仔细看 Zod schema 和 TS 类型定义,很容易写出 "property does not exist" 的运行时报错。

ITEMS_DB 展示兜底。新物品的 iconKey 统一用 'material',但 pixelIcons.ts 里一开始没有定义这个图标。PixelIcon 的 parseIcon 对未知 key 返回空数组,v-if="pixels.length" 直接不渲染 SVG——什么都不显示。虽然不会报错,但用户看不到背包里有东西。加了灰色占位方块后好多了,后续逐个绘制像素图标替换即可。

收获

这次改动让项目走完了"前后端接通"的最后一公里。之前是"有桥无路"——后端 API 写好了但没人调;现在是完整的数据流:登录 → 拉状态 → 跑 tick → 定期保存 → 离线结算 → 再上线收菜。

hydrate 模式是一个值得记住的 store 设计模式:静态定义(学科/任务的名称、耗时、掉落表)放在前端 config 里,运行时数据(等级、经验、队列进度)从服务端拉取覆盖。这样 store 的初始值可以是默认值(等级 1、经验 0),hydrateFromServer 一次性覆盖成服务端状态,不需要为每个字段写单独的 setter。

下一个要解决的问题是材料物品的像素图标——现在 54 种新材料全用同一个紫色菱形占位,背包里一排一样的图标很难辨认。以及服务端 ITEMS_MAP 需要扩展,目前只校验了 9 件装备和 2 个消耗品,材料类物品完全没有校验信息,后续做商店交易时会出问题。