Skip to content

玄学放置 · 后端搭建与技术选型

起因

项目走到第 10 篇,前端已经是功能完整的 SPA——9 个学科、54 个任务、掉落系统、装备栏、任务队列、公屏聊天,全部跑在浏览器里。Pinia store 持有一切状态,刷新页面就从零开始,没有任何持久化。

这对原型阶段没问题,但放置游戏的核心体验是"挂机后回来收菜"。如果刷新就丢档,这个体验就不成立。何况后续要加每日任务池、限时活动、排行榜——这些功能天然需要服务端。

另一个隐性的驱动:掉落系统的概率判定和经验发放目前全在前端跑,这意味着"改个 JS 变量就能刷金"。对单机来说无所谓,但如果后续想做玩家间的比较(哪怕是简单的"你比 80% 的玩家多肝"),数据可信度就是前提。

先定架构,再选技术

写后端之前最重要的问题不是"用什么框架",而是"服务端和客户端怎么分工"。

放置游戏的后端模型大致分三种:

  • 服务端 tick:每秒/每几秒推一次状态,客户端只渲染。最权威,但连接成本高,服务器负载大
  • 客户端 tick + 实时同步:客户端跑逻辑,WebSocket 实时上报,服务端校验。折中,但断线处理复杂
  • 离线进度结算:客户端本地跑 tick,定期存档;下线后服务端只存快照;上线时按时间差补算进度。轻量,适合独立游戏

我选了第三种。理由很简单:这个游戏是单人放置,没有实时对战的场景。玩家在线时客户端跑本地 200ms tick(和现在一样),每 30 秒向服务端 POST 一次状态快照;玩家下线后服务端什么都不做;玩家下次上线时,服务端根据"最后活跃时间"和"当时队列里正在跑什么任务"来补算离线期间的收益。

这意味着服务端不需要后台 tick 进程,不需要 Redis 维护在线状态,数据库里只存一份快照——资源消耗对个人项目友好。

技术选型:四个决策点

框架:Fastify 而不是 Express

Express 生态大,但 2024 年之后的新项目,Fastify 在几乎所有维度上更优:性能快 2-3 倍,原生 TypeScript 支持更干净,schema 校验内置,插件体系按作用域隔离不污染全局。Express 的中间件模型(全局 app.use)在项目变大后容易出"这个中间件到底在哪个路由上生效"的困惑,Fastify 的插件封装更可控。

数据库:PostgreSQL 而不是 MySQL

MySQL 在国内运维更常见,但 PG 对这个项目有两项实际优势:JSONB 类型适合存游戏配置快照(后续运营后台配活动时用得到),行级锁比 MySQL 的表锁更细,对"离线结算批量写入"这种场景更友好。

ORM:Drizzle 而不是 Prisma

Prisma 的声明式 schema 和可视化 Studio 很吸引人,但它有几个让人犹豫的点:查询通过 Prisma Engine(Rust 进程)中转,debug 时看到的是抽象层而不是实际 SQL;迁移生成慢;bundle 体积大。Drizzle 的思路相反——它本质上是带类型推导的 SQL 模板,写法接近原生 SQL,但 TS 类型自动推导出来。对于"我知道我要写什么 SQL,只是不想手写类型"这个需求,Drizzle 刚好对。

校验:Zod

前后端共享类型定义。Fastify 内置的 AJV 校验也不错,但 Zod 的类型推导和 TypeScript 集成更无缝——定义一个 schema,z.infer<typeof schema> 直接拿到类型,请求校验和类型声明合一。

静态数据不入库

一个容易纠结的问题:学科定义、任务配置、物品数据库要不要建表存进去?

最终决定不入库。理由是这些数据随版本发布而变——调整掉率、加新任务、改名,这些都是策划改动,需要发版才能生效。把它们放在 TypeScript 源码里和前端共享,改配置就是改代码,不需要数据库 migration。

但这引出一个后续问题:事件池怎么处理?比如每日任务,每天从现有任务中随机抽 3 个,绑定目标次数和额外奖励。这里的区分很重要——

任务定义是菜单,事件分配是点单记录。 菜单印在纸上(代码),点单记在账本上(数据库)。player_events 表存的不是"口算训练存在",而是"玩家 A 今天被分配了口算训练 3 次"。它引用任务 ID,但不重复任务定义。这种分离让"改任务配置"和"改每日活动规则"变成两个独立的操作,互不影响。

六张表的设计

数据库只存玩家运行时状态,6 张表覆盖全部需求:

  • players — 账号、金币、最后活跃时间(离线结算的时间锚点)
  • player_subjects — 每学科一行,等级/经验/完成数
  • player_bag — 背包物品,itemId + count
  • player_equipment — 8 槽位,每槽存一个 itemId
  • player_queue — 任务队列,position 字段决定顺序
  • player_events — 每日/限时事件分配,含过期时间和领奖状态

值得注意的是 player_equipment 没有独立的 id 列,它用 (player_id, slot_key) 做联合主键——一个槽位最多一件装备,不存在同一槽位两条记录的场景。Drizzle 的 pgTable 支持这种设计,但写 CRUD 时得记住用 and(eq(playerId), eq(slotKey)) 做条件,不能用 eq(id) 这种简写。

离线进度结算算法

这是后端最核心的业务逻辑,也是最容易出错的地方。

算法输入:最后快照时间、当时的队列快照、当时的学科状态。输出:离线期间获得的经验/物品/金币,以及更新后的队列和学科等级。

核心循环:

ts
while (remaining > 0 && workQueue.length > 0) {
  const entry = workQueue[0]
  const taskDef = getTask(entry.subjectId, entry.taskId)
  const need = taskDef.durationMs - entry.progressMs

  if (remaining < need) {
    // 不够完成一轮,推进进度,结束
    entry.progressMs += remaining
    remaining = 0
    break
  }

  // 完成一轮
  remaining -= need
  entry.completedCount += 1
  // 发放经验 + 掉落
  processOneRound(entry.subjectId, taskDef, subjectMap, result)

  // 达到目标次数则移出队列,下一个任务继续
  if (entry.targetCount !== null && entry.completedCount >= entry.targetCount) {
    workQueue.shift()
  }
}

几个容易忽略的点:

  • 首轮剩余进度:队列首位可能已经跑到一半(progressMs > 0),第一轮只需要 durationMs - progressMs 的时间就完成,后续每轮才是完整 durationMs
  • 跨任务边界:一个 dt 可能跨越多个任务的完成,while 循环处理这种极端场景
  • 24 小时上限Math.min(elapsedMs, MAX_OFFLINE_MS),防止数值膨胀
  • 掉落概率复用前端逻辑:万分比判定 + randomInt 数量区间,和前端 _processDrops 同一套计算规则

REST 与 WebSocket 的分工

玩家主动操作走 REST(请求-响应模型),服务端主动推送走 WebSocket:

  • REST:注册、登录、装备、队列操作、事件领奖
  • WebSocket:聊天消息推送、每日任务刷新通知、离线奖励弹窗触发

核心 tick 不走 WebSocket。在线时客户端本地跑,离线时服务端按时间差补算——两边不争抢同一个计算职责,不会出现"服务端算的进度和客户端显示的不一致"这种同步问题。

WebSocket 用的 @fastify/websocket,基于 ws 库,和 Fastify 路由体系统一。比 socket.io 轻很多,放置游戏不需要它那套自动降级和房间机制——只需要一个长连接推送通知。

踩到的坑

playerEquipment 没有 id 列。写装备/卸下接口时第一反应是 where(eq(id)),编译才发现这表没有 id。改用 and(eq(playerId), eq(slotKey)) 做复合条件,Drizzle 的类型系统在这里帮了大忙——如果编译过了,条件就是对的。

学科进度更新的 WHERE 条件POST /save 里更新学科时,最初只写了 where(eq(playerSubjects.playerId, playerId)),结果一条 update 把该玩家所有学科都改成了同一个值。Drizzle 的 update 是"匹配到的所有行都 set",必须加 and(eq(playerSubjects.subjectId, subjectId)) 限定到具体学科。这种错误不会报类型错误,只有跑起来才知道——算是 ORM 层面容易漏的陷阱。

离线服务的无用导入。初始版本从别处复制了几行 import,带了 import type { DB } from './types.js'import { players } from '../db/schema.js'——types.js 这个文件不存在,schema 也不需要。TypeScript 编译直接报错,修掉就好,但提醒了一件事:离线结算算法应该是纯计算函数,不依赖数据库层。它接收快照数据,返回结算结果,写库由调用方负责。这种分离让算法可以脱离数据库做单元测试。

收获

后端骨架从零到 API 全通,实际写码大约两小时。大部分时间花在类型定义和校验 schema 上,而不是业务逻辑——Zod 的 schema 定义和 Drizzle 的表定义是两套独立的类型源,需要手动保持一致。如果后续项目更大,可能会考虑从 Drizzle schema 自动生成 Zod schema。

离线进度结算模型对放置游戏来说是一个"重决策轻实现"的选择:前期想清楚架构,后面每个接口的实现都很直。不需要考虑实时同步、断线重连、状态冲突——这些都是更重架构才会遇到的问题。

从项目整体看,后端搭建让"刷新丢档"这个问题从根本上消失了。下一步是把前端从 Pinia mock 数据源切换到后端 API——这不只是改几个 import,而是整个数据流的入口从本地 store 变成了 GET /api/player/state