玄学放置 · 第一版接口对接
起因
上一篇写登录页面,auth store 已经能调后端的 register/login 接口了,但登录成功后前端还是跑 mock 数据——subjects store 从 SUBJECTS_SEED 初始化,inventory store 从 INITIAL_EQUIPPED 初始化,刷新就回到出厂设置。后端的 GET /api/player/state 和 POST /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) |
| 任务 ID | recite, words, compose, parse | phrase-copy, rhetoric-practice, story-outline... |
| 掉落物品 | ink, dictPage | phraseCard, inkBottle |
| 任务属性 | 无 requiredLevel | 有 requiredLevel + tierIndex |
这不是简单的"字段名不同",而是两套完全不同的任务体系。后端有 6 个 tier,按等级解锁——tier 0 的 requiredLevel: 1,tier 5 的 requiredLevel: 70;前端 mock 只有 2-4 个任务,没有等级门槛。
不对齐会怎样? 离线结算崩掉。后端 computeOfflineProgress 用 getTask(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 自动登出,网络错误包装成统一结构:
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 演化而来,做了三件事:
- 保留 ITEMS_DB、SLOTS、RARITIES、CATEGORIES 和所有类型定义——组件和 store 依赖这些做展示
- 移除 INITIAL_EQUIPPED 和 INITIAL_INVENTORY——初始状态现在从服务端 PlayerState 获取,本地不再硬编码
- 扩展 约 54 种新材料物品——后端 game-config 的掉落表引用了大量新 item ID(phraseCard、inkBottle、mathDraft...),前端 ITEMS_DB 里没有对应定义会导致名称和图标显示为 raw ID
新材料物品的定义很精简——id、name、category='material'、iconKey 统一用 'material' 占位图标、rarity、desc、stats。用一个辅助函数批量构造:
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
旧的初始化:
state: () => ({
list: SUBJECTS_SEED.map(s => ({ ...s, level: 1, exp: 0, expMax: 100, completedCount: 0 })),
queue: []
})新的初始化(默认空,等服务端数据):
state: () => ({
list: SUBJECTS.map(s => ({ ...s, level: 1, exp: 0, expMax: 100, completedCount: 0 })),
queue: [],
hydrated: false
})新增 hydrateFromServer(state: PlayerState) action:
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 —— 按等级过滤可见任务:
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 两个 storesaveState():从两个 store 拼 payload,调 API 保存
login/register/guestLogin 成功后自动调用 loadPlayerState(),这样路由跳转到 Home 时,store 已经有服务端数据了。
Home/index.vue 的定时保存
Home 组件之前只跑 tick 定时器。这次加了:
- onMounted:如果 store 还没 hydrated,调
loadPlayerState(),顺便检查offlineReward弹通知 - 30s setInterval:调
saveState(),用isSavingref 防止重入 - onBeforeUnmount:先
saveState()再清理定时器
离线奖励用 ElNotification 显示——列出离线时长、获得的金币、各学科经验、物品。虽然目前物品名还是 raw ID(比如 phraseCard×3),但比什么提示都没有好,后续 ITEMS_DB 补全后自然变成中文名。
顺手修了一个后端 Bug
server/src/routes/events.ts 里清理过期事件的 WHERE 条件写反了:
// 错误:删除 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 个消耗品,材料类物品完全没有校验信息,后续做商店交易时会出问题。