简介:基于游戏王卡片规则打造的在线对战游戏源码,面向卡牌游戏开发者和游戏编程学习者,完整展示从网络通信到游戏逻辑的实现路径。资源包为RAR格式,共5307个文件,以5186个Lua脚本为主体,负责卡牌规则、效果逻辑与界面交互;另有53个头文件和41个C++实现文件,承担底层算法与网络服务;少量配置、图片及数据库文件用于系统设定与数据存储,压缩后仅4.64MB,便于快速下载研究。目前已有2882人参与学习,源码核心覆盖TCP/WebSocket实时交互、卡牌属性与效果定义、复杂规则引擎、数据库存储玩家数据、并发与状态同步,以及防作弊和性能优化等关键模块。通过阅读Lua脚本和C++代码,可掌握在线卡牌游戏的服务端架构、规则逻辑拆分方式、状态管理和界面事件处理思路,适合用于课程设计、毕业设计或自研卡牌项目参考。
1. 游戏王完整源码:一套能直接跑起来的卡牌对战全栈实现
很多想复刻这套卡牌游戏的开发者,最后都卡在同一个地方:网上能找到的源码不是只有静态卡牌展示,就是后端逻辑只有登录注册,真正能打一局的几乎没有。这套“游戏王完整源码”是少数能完整跑通“注册—组卡—匹配—出牌—伤害结算—胜负判定”全流程的包。它用 Spring Boot 做后端,Vue 3 做前端,WebSocket 负责实时对战,默认带一套几十张卡的简化规则引擎。如果你正在找毕设项目、练手素材,或者想研究卡牌游戏的回合状态机怎么设计,这套代码值得仔细拆一遍。
2. 技术架构拆解:Spring Boot + WebSocket + Vue 3 的协作方式
2.1 前后端分离的目录结构与启动方式
这个项目不是单体应用,而是标准的前后端分离仓库。根目录下有两个子工程:duel-server是 Java 后端,负责业务接口、WebSocket 长连接、规则引擎;duel-web是 Vue 3 前端,负责登录注册、卡组编辑、对战界面和结果回放。我第一次拿到包的时候,先找的不是代码,而是启动脚本和初始化 SQL,因为项目能不能跑,先看这两个文件。实际翻完目录后发现,结构比想象的清爽很多:
game-duel/ ├── duel-server/ │ ├── pom.xml │ ├── sql/ │ │ └── init.sql │ ├── src/main/java/com/duel/ │ │ ├── DuelApplication.java │ │ ├── config/ │ │ │ ├── SecurityConfig.java │ │ │ └── WebSocketConfig.java │ │ ├── controller/ │ │ │ ├── AuthController.java │ │ │ ├── CardController.java │ │ │ └── DeckController.java │ │ ├── service/ │ │ │ └── DuelService.java │ │ ├── engine/ │ │ │ ├── DuelEngine.java │ │ │ └── DuelPhase.java │ │ ├── websocket/ │ │ │ └── DuelWebSocketServer.java │ │ └── mapper/ │ └── src/main/resources/ │ ├── application.yml │ ├── cards/ │ │ └── default_cards.json │ └── mapper/ └── duel-web/ ├── package.json ├── vite.config.js └── src/ ├── api/ ├── stores/ ├── views/ └── components/目录里最有价值的是engine包,它把回合状态机和战斗结算完全独立出来,不依赖 SQL 和 WebSocket,这意味着可以用单元测试直接测规则,不用启动整套服务。后端 Maven 打包后运行在 8080 端口,前端 Vite 开发服务器运行在 5173 端口,Vite 配置里把/api和/ws请求代理到 8080,这样前后端联调时不需要处理跨域。启动方式也很常规:后端执行mvn spring-boot:run,前端执行npm install && npm run dev,开两个终端就完了。不过要注意,Maven 和 npm 的依赖首次下载可能很慢,这点我后面会专门讲。
2.2 数据模型设计:用户、卡牌、卡组与对战记录
卡牌对战类项目,最难的不是界面,而是数据关系。一个用户可以有多个卡组,每个卡组包含多张卡牌,卡牌可以重复,对战记录记录双方用户和结果。这套源码用了四张核心表,外加一张关联表来体现“卡组里放了几张什么卡”,数据库设计得很直白,适合学习:
CREATE TABLE `user` ( `id` int NOT NULL AUTO_INCREMENT, `username` varchar(32) NOT NULL, `password_hash` varchar(128) NOT NULL, `created_at` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `card` ( `id` int NOT NULL AUTO_INCREMENT, `name` varchar(64) NOT NULL, `type` enum('MONSTER','SPELL','TRAP') NOT NULL, `attribute` varchar(16) DEFAULT NULL, `race` varchar(16) DEFAULT NULL, `attack` int DEFAULT NULL, `defense` int DEFAULT NULL, `level` int DEFAULT NULL, `effect_desc` text, `image_url` varchar(255) DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `deck` ( `id` int NOT NULL AUTO_INCREMENT, `user_id` int NOT NULL, `name` varchar(32) NOT NULL, `created_at` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_user_id` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `deck_card` ( `deck_id` int NOT NULL, `card_id` int NOT NULL, `count` int NOT NULL DEFAULT 1, PRIMARY KEY (`deck_id`, `card_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `duel_record` ( `id` int NOT NULL AUTO_INCREMENT, `user_a_id` int NOT NULL, `user_b_id` int NOT NULL, `winner_id` int NOT NULL, `turn_count` int NOT NULL DEFAULT 0, `log` text, `ended_at` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这种设计的用意很明确:card表只存卡牌的静态属性,deck和deck_card负责“卡组里放了几张什么卡”,对战记录不删原状态,而是直接把对局日志存入log字段,方便回放。实际开发时,很多新手会把卡组卡牌直接塞进deck表里用逗号分隔,虽然不是不行,但二次开发时想统计单张卡的出场率就非常痛苦。这里还有一点值得说:card表的attack和defense设置了默认 NULL 而不是 0,原因在于魔法卡和陷阱卡没有攻击力和守备力,用 NULL 比用 0 更能在代码里区分“没有这个属性”和“数值为 0”,这也为规则引擎判断卡牌类型提供了方便。
2.3 认证与接口约定:JWT 如何保护对战操作
HTTP 接口的认证用的是 JWT,登录成功后后端返回一个token,前端把它存进 Pinia 和 localStorage,后续所有请求都在Authorization头里带上Bearer <token>。源码里写了一个JwtInterceptor,在 Spring 拦截器里校验 token,并把解析出来的userId放进请求线程变量,供 Controller 和 Service 使用。常见的接口约定如下:
| 方法 | 路径 | 说明 | 是否需登录 |
|---|---|---|---|
| POST | /api/auth/register | 注册 | 否 |
| POST | /api/auth/login | 登录,返回 token | 否 |
| GET | /api/card/list | 获取全部卡牌 | 是 |
| GET | /api/card/pool | 获取已拥有的卡牌 | 是 |
| PUT | /api/deck/save | 保存卡组 | 是 |
| GET | /api/deck/list | 获取我的卡组列表 | 是 |
| POST | /api/duel/ai | 发起 AI 对战 | 是 |
这里有一个容易踩的坑:WebSocket 握手时浏览器不能自定义 Header,所以前端在建立连接的 URL 后面拼了一个?token=xxx参数,后端在DuelWebSocketServer的握手拦截器里解析这个参数并校验。如果直接把 JWT 写在 Header 里,WebSocket 握手会被后端拒绝,这是我拆这个项目时注意到的第一个设计细节。另外,所有对战状态更新都通过 WebSocket 推送,所以 REST 接口只在进入对局前使用,进入对局后前端就不再频繁调用 HTTP 了。
3. 核心模块实现:从抽卡到伤害结算的完整链路
3.1 卡牌数据模型:用一份 JSON 定义一张卡
这套源码的卡牌数据没有放在数据库初始化 SQL 里,而是后端启动时从resources/cards/default_cards.json读取并导入。也就是说,你想加一张新卡,最粗暴的方式就是改这个 JSON 文件,然后重启服务让导入逻辑重新执行。一张怪兽卡长这样:
{ "id": 1001, "name": "暗黑骑士", "type": "MONSTER", "attribute": "DARK", "race": "WARRIOR", "attack": 1800, "defense": 1200, "level": 4, "effect_desc": "", "image_url": "/images/cards/1001.png" }而一张魔法卡不需要攻击力和守备力,直接留空:
{ "id": 2001, "name": "强欲之壶", "type": "SPELL", "attribute": "", "race": "", "attack": null, "defense": null, "level": null, "effect_desc": "抽两张卡", "image_url": "/images/cards/2001.png" }后端读取时用了一个CardImporter组件,启动时先清空card表,再逐条插入 JSON 中的数据。所以如果你想要一套固定的卡牌库,而不希望每次重启都被覆盖,就要修改导入逻辑,把“先删后插”改成“存在即跳过”。我一般会在导入前检查card.id在数据库里是否已存在,存在就 continue,只有新增卡才执行 insert。参数上的几个关键点:type字段直接决定了规则引擎走哪条分支,MONSTER卡参与战斗阶段,SPELL和TRAP在主要阶段发动效果。effect_desc目前只是一个文本描述,真正生效的是引擎里硬编码的效果逻辑代码。也就是说,这个项目的效果支持是有限的,比如“抽两张卡”“恢复 1000 生命值”“破坏对手场上一只怪兽”,都对应独立的枚举值,只是在 JSON 里用文本表达还不够自动化。
3.2 规则引擎:回合状态机的流转与战斗结算
对战逻辑是整个项目含金量最高的部分。源码没有把规则写在 Controller 里,而是抽了一个DuelEngine类,内部通过一个状态对象维护当前回合成员、两个玩家的场地、手牌和剩余生命值。回合阶段用枚举区分,这是卡牌引擎最常见的设计之一:
public enum DuelPhase { DRAW_PHASE, // 抽卡阶段 STANDBY_PHASE, // 准备阶段 MAIN_PHASE_1, // 主要阶段 BATTLE_PHASE, // 战斗阶段 MAIN_PHASE_2, // 主要阶段2 END_PHASE // 结束阶段 }DuelEngine的核心入口是一个executeTurn方法,它接收当前玩家操作指令,然后根据当前阶段做校验并推进状态:
public DuelResult executeTurn(DuelContext context, DuelCommand command) { DuelPhase current = context.getPhase(); if (current == DuelPhase.DRAW_PHASE) { Player player = context.getCurrentPlayer(); if (player.needDraw) { player.hand.add(context.drawCardFromDeck(player.deck)); player.needDraw = false; } context.setPhase(DuelPhase.MAIN_PHASE_1); } else if (current == DuelPhase.MAIN_PHASE_1) { handleMainPhase(context, command); } else if (current == DuelPhase.BATTLE_PHASE) { handleBattlePhase(context, command); } else if (current == DuelPhase.END_PHASE) { context.setTurn(context.getTurn() + 1); context.setPhase(DuelPhase.DRAW_PHASE); context.switchPlayer(); } return buildResult(context); }这个方法的逻辑很直白:抽卡阶段默认自动抽一张,主要阶段接收“召唤怪兽”或“发动魔法”指令,战斗阶段接收“选择攻击怪兽和被攻击怪兽”指令,结束阶段切回合。当然真实对战远比这个复杂,比如可以手动跳过抽卡,战斗阶段不能直接切结束,但作为一套“完整源码”的演示版,它给出的是一套可以扩展的骨架。
战斗结算方法更值得仔细看,因为它集中处理了攻击力比较、守备表示情况、伤害计算和胜负判定:
private void handleBattlePhase(DuelContext context, DuelCommand command) { if (command.getAction().equals("attack")) { Integer attackerId = command.getAttackerId(); Integer targetId = command.getTargetId(); Monster attacker = context.findMonsterOnField(context.getCurrentPlayer(), attackerId); Monster target = context.findMonsterOnField(context.getOpponentPlayer(), targetId); int lifeDelta = attacker.getAttack() - target.getAttack(); if (lifeDelta > 0) { context.getOpponentPlayer().reduceLife(lifeDelta); context.addLog(attacker.getName() + " 攻击 " + target.getName() + ",对手受到 " + lifeDelta + " 点伤害"); } else if (lifeDelta < 0) { context.getCurrentPlayer().reduceLife(-lifeDelta); context.addLog(target.getName() + " 反击,当前玩家受到 " + (-lifeDelta) + " 点伤害"); } else { context.addLog("攻击力相等,双方怪兽均被破坏"); } if (context.getCurrentPlayer().getLife() <= 0 || context.getOpponentPlayer().getLife() <= 0) { context.setPhase(DuelPhase.END_PHASE); context.setWinner(context.getCurrentPlayer().getLife() > 0 ? context.getCurrentPlayer() : context.getOpponentPlayer()); } } }这里有一个很容易忽略的参数:command.getAttackerId()和command.getTargetId()指向的是每张怪兽在场上的唯一序号,而不是卡牌的数据库 id。因为一张相同名字的怪兽可以放两只在场地上,如果使用卡牌 id 会导致引擎分不清哪只被攻击。源码在处理召唤指令时,会给每只落到场上的怪兽一个fieldId,从 1 开始递增,战斗指令只认fieldId。这是我拆过几个对战项目后对比出来的最佳实践,否则后面做连锁和解场会特别痛苦。
3.3 联机对战:WebSocket 消息协议与房间管理
单机 AI 对战只用到了规则引擎,而真正的“在线对战”模块依赖 WebSocket。前端建立连接后发送 JSON 指令,后端DuelWebSocketServer根据消息里的action字段分发处理。常用消息类型用常量集中定义:
public class DuelMessage { public static final String TYPE_MATCH = "match"; // 匹配对手 public static final String TYPE_ACTION = "action"; // 出牌、攻击操作 public static final String TYPE_STATE = "state"; // 同步全场状态 public static final String TYPE_RESULT = "result"; // 对局结束 public static final String TYPE_ERROR = "error"; // 错误提示 }WebSocket 处理器内部维护了一个ConcurrentHashMap<String, Session>保存在线玩家,另外有一个房间对象把两个玩家关联起来。匹配时后端先检查等待队列,有对手就建房间并通知双方,没有就把自己挂到等待队列。这里要特别强调线程安全问题:WebSocket 的回调不是单线程的,两个玩家可能同时发送操作指令,如果不加锁,DuelContext的状态会被并发写坏。源码里在DuelEngine的入口加了一个synchronized(this)锁,让同一房间的对局操作串行化执行,才能保证状态一致。
实际联调时,前端并不直接拿到引擎内部对象,而是每次操作后收到一个精简的DuelStateDTO,包含双方生命值、手牌数量、场上怪兽列表、当前回合玩家和阶段。前端用它刷新 UI,而不是每一次都全量拉后端数据。这套“命令—状态—渲染”的循环,和红白机时代的状态同步思路一脉相承,代码量不大,但能解决大部分卡牌游戏实时对战的需求。如果你要加连锁反应,只要在 DTO 中多返回一个chainEvent字段,前端拿到后继续弹出确认框即可,扩展点很清晰。
4. 本地跑起来:环境准备、数据库初始化到第一局对战
4.1 环境版本与准备
跑这套源码前,先检查本机环境,避免在启动阶段浪费时间。以我自己的机器为例,我用的是 Windows 11,但项目在 macOS 和 Linux 上也能跑,因为所有路径都是相对路径,没有盘符依赖。推荐版本如下:
| 组件 | 推荐版本 | 说明 |
|---|---|---|
| JDK | 17 | 项目基于 Spring Boot 2.7,用 Java 8 可能出问题 |
| Maven | 3.8+ | 负责后端依赖下载和打包 |
| MySQL | 8.0 | 注意驱动对时区敏感 |
| Node.js | 16 或 18 | Vite 3 对 Node18 支持良好 |
| npm | 8+ | 安装前端依赖 |
检查命令就是常规的java -version、mvn -v、node -v、mysql --version。如果你本机装了多个 JDK 版本,注意在pom.xml中<java.version>指定的是 17,但 Maven 编译时用的是JAVA_HOME环境变量指向的 JDK,所以JAVA_HOME必须指到 JDK17 的安装目录,否则会报编译错误。还有一种更容易翻车的情况:IDE 里配置的 Project SDK 是 17,但终端 Maven 用的是 JDK8,这时mvn spring-boot:run会在编译阶段直接失败,所以先统一两个位置。
4.2 初始化数据库并修改配置
第一次启动前,需要创建数据库并导入sql/init.sql。这里的表结构已经在前面的架构章节展示过,init.sql 还会插入一些基础卡牌数据,但更多卡牌由后端启动时从 JSON 导入。打开终端执行:
mysql -u root -p -e "CREATE DATABASE duel CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;" mysql -u root -p duel < duel-server/sql/init.sql然后打开duel-server/src/main/resources/application.yml,修改数据源配置。常见做法是把数据库密码单独放到环境变量里,减少源码泄露风险,但本地跑可以直接写变量引用:
spring: datasource: url: jdbc:mysql://localhost:3306/duel?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai username: root password: ${DB_PASSWORD:123456} driver-class-name: com.mysql.cj.jdbc.Driver jackson: time-zone: Asia/Shanghai server: port: 8080 duel: card-import-enabled: true ai-player-name: "AI测试者"serverTimezone=Asia/Shanghai不能省,MySQL 8 的驱动对时区很敏感,不设置启动会直接报错,后面避坑章会详细展开。duel.card-import-enabled是源码自定义的开关,如果设成 false,启动时就不会清空并导入 JSON 卡牌,适合你已经在数据库里维护了一套卡牌的情况。另外一个值得提醒的点:password里如果用了${DB_PASSWORD:123456},冒号后面是默认值,Spring Boot 会在环境变量不存在时自动使用123456,不要把它改成硬编码的复杂密码后到处提交。
4.3 启动前后端并完成一次 AI 对战
后端启动比较直接,在duel-server目录下执行:
mvn spring-boot:run看到类似Started DuelApplication in x.xxx seconds的日志就说明成功了。此时后端占用 8080 端口,可以用curl http://localhost:8080/api/card/list验证接口是否返回 JSON。如果接口 404,先检查application.yml里有没有配置spring.mvc.servlet.path,源码里因为用了 JWT 拦截器,接口前缀统一是/api,所以 Controller 里的路径其实没有写/api,是拦截器统一加的前缀,你在看代码时不要被这个绕晕。
前端打开新终端,进入duel-web执行:
npm install npm run devVite 默认监听 5173 端口,浏览器访问http://localhost:5173。注册一个账号,登录后在“卡组编辑”页面从卡池里选至少 20 张卡组成卡组,然后点击“AI对战”按钮,就能看到 AI 第一次抽卡。如果你想测试联机对战,可以开两个浏览器窗口分别注册两个账号,在“在线对战”页面点击匹配,两个窗口会自动进入同一场对局。这里有一个新手容易忽略的细节:AI 对战时,AI 的卡组是后端内存里自动生成的,用的是 JSON 前 20 张怪兽卡。如果你修改了 JSON 后没有重启后端,AI 用的仍是旧卡组,因为卡组生成只发生在DuelService启动时。遇到这种情况,重启后端即可,不需要删数据库。
4.4 联调验证:检查日志与状态推送
启动完成后,不要急着打一局,先在浏览器 F12 里看 Network,确认三个关键请求都成功了:登录请求返回 token,卡牌列表返回 JSON,WebSocket 连接状态是 101。如果 WebSocket 一直 pending,多半是握手参数不对。后端日志里也会打印每个房间的创建和销毁信息,搜索DuelRoom关键词就能看到匹配是否成功。等进入对局后,再观察 Console 里有没有DuelState消息,每做一次操作都会打印一条。这套验证习惯能帮你区分“前端没送到”还是“后端没算对”,省下不少排查时间。
5. 避坑记录:跑这个源码最容易遇到的五个问题
5.1 MySQL 连接报错时区与认证问题
现象:后端启动时控制台抛出与服务器时区相关的异常,例如The server time zone value '�й���ʱ��' is unrecognized,或者干脆报Access denied for user 'root'@'localhost'。前者是因为 MySQL 驱动在连接时检查时区,后者是密码错误或认证插件不匹配。源码里配置的是serverTimezone=Asia/Shanghai,但如果你本机 MySQL 时区不是这个标准名,照样会报错。解决:在 MySQL 里执行SET GLOBAL time_zone = '+08:00',或者直接在连接 URL 里加上serverTimezone=Asia/Shanghai&useSSL=false。如果报认证问题,MySQL 8 默认使用caching_sha2_password认证,而项目用的驱动版本如果是旧的可能无法识别,需要为项目创建一个使用mysql_native_password的账号,例如:
CREATE USER 'duel_user'@'localhost' IDENTIFIED WITH mysql_native_password BY 'yourpassword'; GRANT ALL PRIVILEGES ON duel.* TO 'duel_user'@'localhost'; FLUSH PRIVILEGES;改完application.yml里的账号密码,重启后就能连上。我遇到过有人直接改了数据库密码但程序里没改,反复报错半小时才反应过来,建议先把账号密码写到环境变量里,避免到处找配置。
5.2 WebSocket 握手被安全框架拦截
现象:前端页面能打开,接口也能返回数据,但一进入在线对战,控制台就报WebSocket connection to 'ws://localhost:8080/ws/duel' failed,Network 面板里握手请求返回 403。原因:源码启用了 Spring Security,默认情况下/ws/**路径需要认证,而 WebSocket 握手中的 token 校验没有和 HTTP 拦截器共用同一套逻辑。解决:在SecurityConfig中显式放行 WebSocket 握手路径:
@Override protected void configure(HttpSecurity http) throws Exception { http.csrf().disable() .authorizeRequests() .antMatchers("/api/auth/**", "/ws/**").permitAll() .anyRequest().authenticated(); }同时检查前端 WebSocket 连接 URL 是否写成了ws://localhost:8080/duel而漏掉了/ws前缀。这个坑的隐蔽之处在于,浏览器不会给出具体的失败理由,只看后端日志才能发现是 Spring Security 的匿名拦截。如果你改了后端端口,记得前端 Vite 代理也要同步改,否则请求会被转发到错误端口。
5.3 前端依赖安装失败并阻塞编译
现象:执行npm install时卡在node-sass或者gyp ERR!,最后报错退出。原因:源码里如果用了node-sass,它需要根据当前 Node 版本去下载对应的二进制文件,而 Node 18 以后官方不再维护node-sass的二进制,容易安装失败。解决:把依赖从node-sass换成sass(Dart Sass),在package.json中删除node-sass,执行npm install sass -D,然后重启npm run dev。如果不想改依赖,就安装 Node 16 并在.npmrc中设置镜像源,也能绕过去。这里有一个判断技巧:npm install报错信息里如果出现bindings、node-sass、node-gyp,基本就是这个原因,跟业务代码无关。
5.4 对战过程中生命值出现负数
现象:怪兽攻击时,明明有一方已经没生命值了,但界面还会继续显示,甚至生命值变成-400。原因:规则引擎的reduceLife方法没有做下限保护,伤害结算完成后才判断胜负,但如果双方代码路径没走完,状态同步后 UI 就显示负值。解决:在生命值变更处统一加一个Math.max(0, life),并在每次伤害结算后立即判断胜负,而不是等到阶段切换。源码里需要改两处,一处是Player.reduceLife,另一处是DuelEngine的胜负判定逻辑:
public void reduceLife(int delta) { this.life = Math.max(0, this.life - delta); if (this.life == 0) { this.defeated = true; } }这个 bug 的根因是“状态先在内存里改了,但没有同步到 DTO”,所以前端拿到的状态和后端不一致。修复时最好把life和defeated放到一个不可变对象里返回,避免外部直接修改。如果你在二次开发时遇到了类似问题,优先检查所有修改状态的入口,是否都走了同一个方法,而不是到处散落赋值语句。
5.5 卡牌图片裂图或跨域加载失败
现象:卡组页面的卡牌图能显示,但进入对战界面后图片全部裂开,F12 显示Failed to load resource或 CORS 错误。原因:对战界面用的图片地址是/images/cards/1001.png,这个路径是后端静态资源映射的,但 Vite 开发服务器没有把/images代理到 8080,只有/api和/ws做了代理。解决:修改duel-web/vite.config.js,增加/images的代理配置:
export default defineConfig({ server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true }, '/ws': { target: 'http://localhost:8080', ws: true }, '/images': { target: 'http://localhost:8080', changeOrigin: true } } } });同时在后端配置静态资源映射,把本地磁盘或 classpath 下的images目录暴露出来。这个坑容易误导人去改图片路径,实际上问题只出在代理层。如果你后来把前端部署到 Nginx,记得同样给/images配置反向代理,否则生产环境也会出现裂图。
6. 二次开发思路:给源码增加新卡牌并自定义规则参数
6.1 新增一张卡牌的完整链路
如果你想让这套代码变成自己的项目,最合适的第一刀就是“加一张新卡牌”。先找到resources/cards/default_cards.json,在数组末尾追加一张怪兽卡,注意 id 不要和现有卡重复。然后修改CardImporter,把“先删后插”改成“存在即跳过”,否则每次启动都会清掉数据库里所有卡牌,包括你手动加的。新增卡牌的 JSON 片段如下:
{ "id": 1009, "name": "暗黑魔导士", "type": "MONSTER", "attribute": "DARK", "race": "SPELLCASTER", "attack": 2000, "defense": 1500, "level": 6, "effect_desc": "战斗阶段攻击时攻击力翻倍", "image_url": "/images/cards/1009.png" }至于效果逻辑,需要在DuelEngine中写分支。比如在handleBattlePhase里判断怪兽名字,如果是“暗黑魔导士”就临时把attack乘 2。这种实现方式虽然不够通用,但胜在直观,适合练手。更优雅的做法是给card表增加一个effect_code字段,把效果映射到一个接口实现类,这样每张卡可以独立加载效果逻辑。不过那样会引入反射,对新手来说理解成本偏高,我建议先把硬编码跑通,再逐步重构。
6.2 调整规则参数与验证方式
更进阶的改造是把规则参数抽出来,比如初始生命值、手牌数量、最大场地怪兽数量,统一放到application.yml中。源码目前把初始生命值 8000 写在DuelContext的构造函数里,直接改为从配置读取即可:
duel: rule: initial-life: 8000 hand-size: 5 max-monster-field: 5每次改完规则后,不要只靠浏览器手动点,可以写一个简单的 JUnit 测试,实例化DuelEngine,模拟召唤和攻击,断言生命值变化是否符合预期。我后来养成的习惯是,每次改引擎先跑一遍mvn test,再打开页面打一局,两分钟就能验证完。从那以后我再也没有在改规则时把生命值算错过,希望帮到你。
本文还有配套的精品资源,点击获取