ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

游戏开发实战:如何提前显示关卡怪物已捕获状态?详解表设计与左连接

游戏开发实战:如何提前显示关卡怪物已捕获状态?详解表设计与左连接 最近在做一个收集养成玩法时遇到了一个非常典型的需求场景“以防你不知道可以提前知道这关的怪你有没有抓到过。”这句话看起来像一句绕口令但放到项目里其实是一个很具体的功能玩家进入关卡之前界面上就要能显示出当前关卡里出现的怪物自己以前有没有捕获过。如果捕获过就显示“已捕获”如果没捕获过就不显示或者显示“未捕获”。很多图鉴收集、关卡刷怪类的游戏都有类似需求。这个功能说起来不复杂真正落地时却涉及数据表设计、左连接查询、状态幂等写入、前端展示、缓存兼容等一系列问题。下面我把这套完整实现思路拆开讲一遍代码、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.sqlCREATE 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 { ListMonsterStatusVO 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 version1.0 encodingUTF-8? !DOCTYPE mapper PUBLIC -//mybatis.org//DTD Mapper 3.0//EN http://mybatis.org/dtd/mybatis-3-mapper.dtd mapper namespacecom.example.gametrack.mapper.MonsterMapper select idlistMonsterStatus resultTypecom.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 idinsertCapture 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 ListMonsterStatusVO 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 ListMonsterStatusVO listMonsters(RequestParam Long userId, PathVariable Long levelId) { return monsterCaptureService.listMonsterStatus(userId, levelId); } PostMapping(/monster/{monsterId}/capture) public MapString, Object capture(RequestParam Long userId, PathVariable Long monsterId) { boolean success monsterCaptureService.capture(userId, monsterId); MapString, Object result new HashMap(); result.put(success, success); return result; } }4.4 配置文件文件路径src/main/resources/application.propertiesspring.application.namegametrack server.port8080 spring.datasource.urljdbc:mysql://localhost:3306/game_db?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai spring.datasource.usernameroot spring.datasource.password你的数据库密码 spring.datasource.driver-class-namecom.mysql.cj.jdbc.Driver mybatis.mapper-locationsclasspath:mapper/*.xml4.5 前端演示页面为了快速验证功能我写了一个原生 HTML 页面放在src/main/resources/static/index.html下。!DOCTYPE html html langzh-CN head meta charsetUTF-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 idapp h3关卡怪物状态查询/h3 div classtoolbar input iduserId placeholder玩家ID value10001 / input idlevelId placeholder关卡ID value1 / button onclickloadData()查询/button /div div idresult/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?userId10001这个接口会让玩家 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 缓存减少数据库压力。把玩家状态表从关系型数据库迁移到更适合集合操作的存储。做好接口安全和权限校验防止越权调用。针对百万级玩家数据量设计更合理的分表分库方案。基本思路就是这样。你可以先按这套代码跑通一遍再根据自己项目的真实数据结构调整表名、字段和查询条件。只要状态写入够幂等、查询条件不写错这个功能落地就非常快。
返回列表