最近在做一个收集养成玩法时,遇到了一个非常典型的需求场景:“以防你不知道,可以提前知道这关的怪你有没有抓到过。”
这句话看起来像一句绕口令,但放到项目里其实是一个很具体的功能:玩家进入关卡之前,界面上就要能显示出当前关卡里出现的怪物,自己以前有没有捕获过。如果捕获过,就显示“已捕获”;如果没捕获过,就不显示或者显示“未捕获”。很多图鉴收集、关卡刷怪类的游戏都有类似需求。
这个功能说起来不复杂,真正落地时却涉及数据表设计、左连接查询、状态幂等写入、前端展示、缓存兼容等一系列问题。下面我把这套完整实现思路拆开讲一遍,代码、SQL、排错都会覆盖到。
1. 背景与核心概念
1.1 这个需求到底要解决什么
我先说一个直观场景。
玩家进入“第 3 关”前,界面右侧展示出这个关卡会出现的怪物列表。玩家想知道自己以前刷这关时,有没有抓过其中某只怪。如果抓过,他想直接跳过,不愿意再浪费时间重复刷;如果没有抓过,他就有目标地进本。
这里的核心不是“能不能抓到”,而是“抓没抓到过”。
所以功能上要解决两件事:
- 关卡与怪物之间要有确定的关联关系。
- 玩家与怪物之间的捕获关系要能被查询出来。
一句话总结:系统需要在进入关卡前,把“该关卡下的怪物列表”和“当前玩家已经捕获的怪物集合”做一次匹配,再把匹配结果以状态的形式展示给玩家。
1.2 数据维度拆解
这个需求里其实存在三个数据维度:
- 怪物维度:怪物 ID、怪物名称、所属关卡。
- 玩家维度:用户 ID。
- 捕获关系维度:哪个玩家在哪只怪物上形成了“已捕获”记录。
从表面上看,只需要给怪物表加一个字段,比如user_id,表示这个用户抓住了这只怪。但仔细一想就会发现,这种设计完全不能复用。怪物是公共数据,玩家是个体数据,两个人的捕获记录不能写在同一条怪物记录上。
正确的数据设计思路是:
- 怪物表:存放全局怪物信息以及它所属的关卡。
- 玩家捕获表:记录“哪个玩家捕获了哪只怪物”。
- 通过
monster_id把两张表关联起来。
这也是绝大多数收集类游戏的标准做法。
1.3 常见概念误区
在实际开发中,最容易混淆的是两个状态:
- 已见过:玩家在图鉴、战斗或者剧情里看到过这只怪物。
- 已捕获:玩家主动操作后,怪物成为玩家的收集物。
以“提前知道这关的怪你有没有抓到过”这个需求来说,业务上要的是已捕获,不是已见过。如果只记录“遇到过”,那玩家进入关卡前看到“已捕获”的提示,到了战斗里却发现这只怪连捕获条件都还没触发,体验就很奇怪。
所以捕获记录的写入时机非常重要,必须放在真正的捕获事件成功之后,不能放在进入战斗或者看到怪物时。
2. 环境准备与数据结构设计
2.1 技术选型说明
这个功能并没有非常强的技术栈绑定,后端、前端、数据库都有多种方案。常见组合有:
- 后端使用 Java + Spring Boot,接口层提供查询和写入。
- 数据库使用 MySQL,负责存储怪物和捕获关系。
- 前端使用简单的 HTML + JavaScript 做状态展示,也可以换成 Vue、React。
- 如果玩家数量大,还可以在服务层叠加 Redis 缓存。
本文以 Spring Boot + MyBatis + MySQL 为例,重点是配置思路和代码结构。版本可以根据你的项目实际情况调整,不用照搬某一个固定版本,只要配套关系正确即可。
2.2 数据表设计
核心表是两张:monster和user_capture。
先看怪物表。
CREATE TABLE monster ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT '怪物ID', name VARCHAR(64) NOT NULL COMMENT '怪物名称', level_id BIGINT NOT NULL COMMENT '所属关卡ID', PRIMARY KEY (id), KEY idx_level (level_id) ) ENGINE = InnoDB COMMENT '怪物表';这里把level_id作为怪物所属关卡的标识。一个关卡可以有多只怪物,一只怪物也可以出现在多个关卡,如果业务是一对多关系,可以单独建一张level_monster关联表。为了演示简洁,这里直接用monster.level_id表示所属关卡。
再看玩家捕获表。
CREATE TABLE user_capture ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL COMMENT '玩家ID', monster_id BIGINT NOT NULL COMMENT '怪物ID', captured_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '捕获时间', PRIMARY KEY (id), UNIQUE KEY uk_user_monster (user_id, monster_id), KEY idx_monster (monster_id) ) ENGINE = InnoDB COMMENT '玩家捕获记录表';这里的关键点有两个:
uk_user_monster (user_id, monster_id)是唯一索引,保证同一个玩家对同一只怪物只能有一条捕获记录,避免重复插入。- 捕获记录表只存“成功捕获”这一动作,不存“未捕获态”。未捕获是一个默认状态,通过查不到记录来表示。
2.3 表结构设计的取舍
可能有人会问:为什么不在monster表里加一个capture_count或者user_id?
原因很简单:
- 全局怪物数据属于公共配置,不应该被玩家个性化状态污染。
- 一张怪物表如果既要存公共属性,又要存每个玩家的捕获状态,数据量和维护成本都会失控。
- 单独建
user_capture表后,后续扩展非常方便,比如记录捕获时间、捕获方式、是否进图鉴、是否使用过该怪物等。
所以表结构上采用“公共数据 + 用户行为记录”的拆分方式,这也是最稳妥的设计。
3. 核心逻辑实现
3.1 查询状态:左连接匹配
要查询某个关卡下所有怪物的捕获状态,SQL 思路是:
- 从怪物表查出该关卡的所有怪物。
- 把当前玩家的捕获记录作为关联表挂上去。
- 能关联上的怪物,说明已捕获;不能关联上的,说明未捕获。
SQL 可以写成这样:
SELECT m.id AS monster_id, m.name AS monster_name, CASE WHEN uc.id IS NULL THEN 0 ELSE 1 END AS captured FROM monster m LEFT JOIN user_capture uc ON uc.monster_id = m.id AND uc.user_id = #{userId} WHERE m.level_id = #{levelId} ORDER BY m.id;这里最容易出错的是LEFT JOIN的ON条件。
捕获记录表的两个条件都应该放在ON中,而不是把uc.user_id = #{userId}放到WHERE中。如果放到WHERE里,那些未匹配到的行会被过滤掉,最终结果就只剩已捕获的怪物,没有捕获的怪物反而不显示了,这就不符合需求。
3.2 写入状态:幂等插入
捕获动作的写入是高频操作,玩家可能因为网络重试、按钮双击等原因,在同一时间对同一只怪物发起多次捕获请求。如果每次都插入一条新纪录,就会出现脏数据。
所以插入时要用唯一索引做幂等控制:
INSERT INTO user_capture(user_id, monster_id, captured_at) VALUES (#{userId}, #{monsterId}, NOW()) ON DUPLICATE KEY UPDATE captured_at = captured_at;ON DUPLICATE KEY UPDATE captured_at = captured_at表示:如果唯一索引冲突,说明这条捕获记录已经存在,这时不产生新数据,只保持原记录不变。
3.3 前端展示规则
查询接口返回的数据是一个怪物状态列表,前端只需要根据captured字段判断即可。
展示规则通常如下:
captured = true:在卡片上显示“已捕获”,可以加绿色边框或角标。captured = false:只展示怪物名称或图鉴图标,不显示“已捕获”标记。- 如果产品不希望提前暴露未捕获怪物的完整信息,可以让未捕获怪物的名称以“???”形式展示,只告诉玩家“这个关卡的怪物有没有收集完”,而不展示具体怪物名。
技术上是完全可行的,细节取决于产品交互设计。
4. 完整实战案例
下面用一个完整案例,把上面这套逻辑串起来。
4.1 项目结构
假设项目名为game-track,结构如下:
game-track/ ├── pom.xml ├── sql/ │ └── init.sql └── src/main/ ├── java/com/example/gametrack/ │ ├── GameTrackApplication.java │ ├── controller/MonsterController.java │ ├── mapper/MonsterMapper.java │ ├── model/MonsterStatusVO.java │ └── service/MonsterCaptureService.java └── resources/ ├── application.properties └── mapper/MonsterMapper.xml4.2 初始化数据库脚本
文件路径:sql/init.sql
CREATE DATABASE IF NOT EXISTS game_db DEFAULT CHARACTER SET utf8mb4; USE game_db; DROP TABLE IF EXISTS user_capture; DROP TABLE IF EXISTS monster; CREATE TABLE monster ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT '怪物ID', name VARCHAR(64) NOT NULL COMMENT '怪物名称', level_id BIGINT NOT NULL COMMENT '所属关卡ID', PRIMARY KEY (id), KEY idx_level (level_id) ) ENGINE = InnoDB COMMENT '怪物表'; CREATE TABLE user_capture ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL COMMENT '玩家ID', monster_id BIGINT NOT NULL COMMENT '怪物ID', captured_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '捕获时间', PRIMARY KEY (id), UNIQUE KEY uk_user_monster (user_id, monster_id), KEY idx_monster (monster_id) ) ENGINE = InnoDB COMMENT '玩家捕获记录表'; INSERT INTO monster (name, level_id) VALUES ('史莱姆', 1), ('野猪', 1), ('蝙蝠', 1), ('幽灵', 2), ('骷髅', 2);这里有两关:第 1 关有 3 只怪,第 2 关有 2 只怪。后面验证时,可以让玩家 10001 捕获第 1 关的史莱姆,然后查询效果。
4.3 后端代码
首先是启动类。
package com.example.gametrack; import org.mybatis.spring.annotation.MapperScan; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication @MapperScan("com.example.gametrack.mapper") public class GameTrackApplication { public static void main(String[] args) { SpringApplication.run(GameTrackApplication.class, args); } }然后是返回值对象。
package com.example.gametrack.model; public class MonsterStatusVO { private Long monsterId; private String monsterName; private boolean captured; public Long getMonsterId() { return monsterId; } public void setMonsterId(Long monsterId) { this.monsterId = monsterId; } public String getMonsterName() { return monsterName; } public void setMonsterName(String monsterName) { this.monsterName = monsterName; } public boolean isCaptured() { return captured; } public void setCaptured(boolean captured) { this.captured = captured; } }接着是 Mapper 接口。
package com.example.gametrack.mapper; import com.example.gametrack.model.MonsterStatusVO; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import java.util.List; @Mapper public interface MonsterMapper { List<MonsterStatusVO> listMonsterStatus(@Param("userId") Long userId, @Param("levelId") Long levelId); int insertCapture(@Param("userId") Long userId, @Param("monsterId") Long monsterId); }Mapper XML 文件路径:src/main/resources/mapper/MonsterMapper.xml
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="com.example.gametrack.mapper.MonsterMapper"> <select id="listMonsterStatus" resultType="com.example.gametrack.model.MonsterStatusVO"> SELECT m.id AS monsterId, m.name AS monsterName, CASE WHEN uc.id IS NULL THEN 0 ELSE 1 END AS captured FROM monster m LEFT JOIN user_capture uc ON uc.monster_id = m.id AND uc.user_id = #{userId} WHERE m.level_id = #{levelId} ORDER BY m.id </select> <insert id="insertCapture"> INSERT INTO user_capture(user_id, monster_id, captured_at) VALUES (#{userId}, #{monsterId}, NOW()) ON DUPLICATE KEY UPDATE captured_at = captured_at </insert> </mapper>然后是 Service 层。
package com.example.gametrack.service; import com.example.gametrack.mapper.MonsterMapper; import com.example.gametrack.model.MonsterStatusVO; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import java.util.Collections; import java.util.List; @Service public class MonsterCaptureService { @Autowired private MonsterMapper monsterMapper; public List<MonsterStatusVO> listMonsterStatus(Long userId, Long levelId) { if (userId == null || levelId == null) { return Collections.emptyList(); } return monsterMapper.listMonsterStatus(userId, levelId); } public boolean capture(Long userId, Long monsterId) { if (userId == null || monsterId == null) { return false; } return monsterMapper.insertCapture(userId, monsterId) > 0; } }最后是 Controller。
package com.example.gametrack.controller; import com.example.gametrack.model.MonsterStatusVO; import com.example.gametrack.service.MonsterCaptureService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.*; import java.util.HashMap; import java.util.List; import java.util.Map; @RestController @RequestMapping("/api/level") public class MonsterController { @Autowired private MonsterCaptureService monsterCaptureService; @GetMapping("/{levelId}/monsters") public List<MonsterStatusVO> listMonsters(@RequestParam Long userId, @PathVariable Long levelId) { return monsterCaptureService.listMonsterStatus(userId, levelId); } @PostMapping("/monster/{monsterId}/capture") public Map<String, Object> capture(@RequestParam Long userId, @PathVariable Long monsterId) { boolean success = monsterCaptureService.capture(userId, monsterId); Map<String, Object> result = new HashMap<>(); result.put("success", success); return result; } }4.4 配置文件
文件路径:src/main/resources/application.properties
spring.application.name=gametrack server.port=8080 spring.datasource.url=jdbc:mysql://localhost:3306/game_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai spring.datasource.username=root spring.datasource.password=你的数据库密码 spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver mybatis.mapper-locations=classpath:mapper/*.xml4.5 前端演示页面
为了快速验证功能,我写了一个原生 HTML 页面,放在src/main/resources/static/index.html下。
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>关卡怪物捕获状态提示</title> <style> body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; max-width: 800px; margin: 40px auto; padding: 0 16px; } .toolbar { display: flex; gap: 8px; margin-bottom: 16px; } input { width: 140px; padding: 6px 10px; border: 1px solid #d9d9d9; border-radius: 4px; } button { padding: 6px 14px; border: 1px solid #1890ff; background: #1890ff; color: #fff; border-radius: 4px; cursor: pointer; } .monster-card { display: inline-block; margin: 8px; padding: 16px; border-radius: 8px; background: #f7f7f7; min-width: 120px; text-align: center; } .captured { border: 1px solid #52c41a; background: #f6ffed; } .not-captured { border: 1px solid #d9d9d9; color: #888; background: #fafafa; } .badge { font-size: 12px; margin-top: 8px; } </style> </head> <body> <div id="app"> <h3>关卡怪物状态查询</h3> <div class="toolbar"> <input id="userId" placeholder="玩家ID" value="10001" /> <input id="levelId" placeholder="关卡ID" value="1" /> <button onclick="loadData()">查询</button> </div> <div id="result"></div> </div> <script> async function loadData() { const userId = document.getElementById('userId').value; const levelId = document.getElementById('levelId').value; const res = await fetch(`/api/level/${levelId}/monsters?userId=${userId}`); const data = await res.json(); render(data); } function render(list) { const result = document.getElementById('result'); result.innerHTML = ''; list.forEach(item => { const div = document.createElement('div'); div.className = 'monster-card ' + (item.captured ? 'captured' : 'not-captured'); const name = document.createElement('div'); name.textContent = item.monsterName; const badge = document.createElement('div'); badge.className = 'badge'; badge.textContent = item.captured ? '已捕获' : '未捕获'; div.appendChild(name); div.appendChild(badge); result.appendChild(div); }); } </script> </body> </html>页面会调用后端查询接口,把每一个怪物渲染成一张卡片。已捕获的怪物显示绿色边框和“已捕获”文字,未捕获的怪物显示灰色边框和“未捕获”文字。
4.6 运行与验证
- 执行
sql/init.sql初始化数据库。 - 修改
application.properties中的数据库账号密码。 - 启动
GameTrackApplication。 - 浏览器访问
http://localhost:8080。 - 在页面输入玩家 ID 为
10001,关卡 ID 为2,点击查询。 - 此时数据库里还没有玩家 10001 的捕获记录,所以两只怪物都应该显示“未捕获”。
然后手动调用一下捕获接口:
curl -X POST "http://localhost:8080/api/level/monster/4/capture?userId=10001"这个接口会让玩家 10001 捕获怪物 ID 为 4 的幽灵。再次刷新页面,查询关卡 ID 为 2 时,幽灵显示“已捕获”,骷髅显示“未捕获”。
这里就实现了“提前知道这关的怪你有没有抓到过”的核心体验。
5. 常见问题与排查思路
| 问题现象 | 常见原因 | 排查思路与解决方式 |
|---|---|---|
| 查询结果里所有怪物都显示“未捕获” | LEFT JOIN 的关联条件写错,或者把user_id条件放到了 WHERE 里 | 检查 ON 条件是否同时包含monster_id和user_id;不要用 WHERE 过滤左表的 NULL 数据 |
| 玩家重复捕获后出现了多条捕获记录 | 没有建立唯一索引,或唯一索引字段不对 | 确认user_capture表存在UNIQUE KEY uk_user_monster (user_id, monster_id) |
| 捕获接口返回成功,但查询还是未捕获 | 先插入捕获记录,再查询时事务没有提交 | 检查 Service 方法是否有事务控制;如果是自测,直接查数据库看记录是否存在 |
| 前端展示的还是旧状态 | 浏览器缓存了 GET 接口结果 | 查询接口可以加Cache-Control: no-cache,或在前端请求 URL 上追加时间戳参数 |
| 关卡内怪物数量多,查询变慢 | 没有依赖索引,或者没有做缓存 | 执行EXPLAIN查看 SQL 执行计划;确认monster.level_id、user_capture.user_id、user_capture.monster_id都有索引 |
| 未捕获的怪物名称被接口完整返回,玩家可以直接猜出来 | 接口数据结构包含 name 字段 | 如果产品不希望提前暴露怪物信息,后端需要根据captured字段决定是否返回monsterName,未捕获时显示占位符 |
排查时可以按照“数据是否正确写入 → SQL 是否能查出结果 → 接口返回是否正常 → 前端展示是否正确”的顺序逐层定位,能节省大量时间。
6. 最佳实践与工程建议
6.1 以服务端记录为准
玩家捕获状态的最终依据,必须是服务端数据库里的记录。客户端本地缓存可以用于临时加速展示,但不能作为全量数据源。否则玩家换设备、清缓存、重装后,状态就会丢失。
6.2 捕获接口要做幂等
捕获动作天然适合幂等设计。前后端都需要考虑网络重试:
- 前端:捕获按钮在请求返回前置为禁用状态。
- 后端:依靠唯一索引和
ON DUPLICATE KEY UPDATE做兜底。
6.3 校验玩家权限
玩家只能操作自己的捕获记录。如果接口直接暴露userId参数,生产环境就必须校验当前登录用户是否等于传入的userId,避免越权。
安全边界的问题是很多接口最容易忽视的。开发时可以便捷,上线前要把这些风险标记清楚。
6.4 缓存策略要分级
如果一份怪物状态被大量玩家同时查询,数据库压力会很大。
比较常见的优化思路是:
- 怪物和关卡的基础关系属于低频变化数据,可以缓存怪物的全部 ID 和名称。
- 玩家的捕获记录更适合用 Redis 存储一个集合,比如
capture:set:{userId},集合里存放该玩家已捕获的怪物 ID。 - 查询时,直接通过集合判断怪物 ID 是否在集合内。
但缓存不是越复杂越好。如果项目初期玩家量不大,先做单表查询,完成需求后再引入缓存也不迟。
6.5 埋点与日志
建议在捕获成功的位置加日志,记录玩家 ID、怪物 ID、捕获时间、来源渠道。日志可以帮助后续排查问题,也能为游戏运营提供数据支撑。
对于查询接口,不建议每次查询都打印全量日志,避免日志量过大。
6.6 兼容历史数据
如果是给已有系统加这个功能,通常还要考虑旧数据问题。比如以前玩家已经捕获过怪物,但没有捕获记录,这时需要从历史战斗记录中回流数据,或者做一个补偿程序,把已有数据补写进user_capture表。
处理这类数据时,一定要先备份,再做幂等补偿,不能在未确认数据规则的情况下直接批量修改。
6.7 状态字段的可读性
不要把captured字段和“今天抓到过几次”“最近一次捕获时间”混在一个地方。职责单一的表结构,后续维护会轻松很多。
7. 总结与下一步学习方向
这个“提前知道本关怪物是否已捕获”的功能,本质上是一个很典型的用户状态查询需求。只要理解了怪物表、玩家捕获表、左连接匹配三件事,代码实现反而很简单。
如果其他项目要复用,我建议先画清楚两个问题:
- 怪物和关卡的关系是简单的一对多,还是更复杂的多对多。
- 捕获状态是否有过期或回退逻辑。
这两个问题确定了,数据库表结构和查询方案基本就能定下来。
下一步如果想深入优化,可以优先学习这几个方向:
- 给查询接口加 Redis 缓存,减少数据库压力。
- 把玩家状态表从关系型数据库迁移到更适合集合操作的存储。
- 做好接口安全和权限校验,防止越权调用。
- 针对百万级玩家数据量,设计更合理的分表分库方案。
基本思路就是这样。你可以先按这套代码跑通一遍,再根据自己项目的真实数据结构调整表名、字段和查询条件。只要状态写入够幂等、查询条件不写错,这个功能落地就非常快。