简介:「弹弹堂游戏源码」面向希望从实战项目理解游戏开发流程的C++学习者、课程设计者与初级开发者,基于FunCode平台组织,覆盖键盘输入响应、炮弹物理模拟、碰撞检测、图形渲染、网络同步、数据结构与脚本扩展等核心环节,可用于课程实践、毕业项目参考和源码阅读。压缩包共177个文件、约2.22MB,以dso、cs脚本与配置文件、png/gui/dds界面图像资源,以及cpp/h源码、exe与lib运行库为主,兼顾工程配置和美术素材,目录结构便于按模块梳理。已有4100人学习下载,说明其在同类教学源码中具有一定参考度。读者可借助完整工程观察炮弹发射与击中判定如何落地,理解资源管理、界面搭建、逻辑组织与调试流程的衔接方式,并据此整理自己的游戏开发知识框架,适合作为入门到进阶的学习样本。
1. 弹弹堂游戏源码到手,先搞清楚它能跑出什么
拿到一份弹弹堂游戏源码,不少人的第一反应是双击index.html或者双击那个 exe,然后看到一个黑屏,或者卡在登录界面转圈。这类回合制弹射游戏表面看逻辑很简单——调角度、按力度、看炮弹飞出去炸出一片伤害——但真正让它在本地跑起来,牵扯到的是客户端表现层、服务端回合状态机、物理重算、协议收发这四件事必须同时对上。任意一边的常量差了 0.1,你在客户端看到的落点和服务端算出来的落点就是两个地方,玩家会觉得"这游戏在骗我"。
这份源码的价值也正在这里:它不是单个战斗 Demo,而是把登录、房间匹配、回合制战斗、装备背包、结算发奖串成了一条完整链路。适合两类人:一类是刚接触回合制对战、想照着完整项目理解"服务端权威"到底怎么落地的开发者;另一类是手里有个小游戏想法,想拿一套能跑的骨架换皮改玩法的。前者关心架构,后者关心改哪里,后面几章分别对上这两种需求。
2. 源码骨架:技术栈、目录结构与本地跑通顺序
2.1 技术选型:客户端负责演,服务端负责算
这类项目的技术栈基本分两代。早期版本是 Flash 时代的 ActionScript 3 客户端加 Java 服务端,通信走 Socket 加 AMF3 序列化;后来移植到 H5 的版本,客户端换成 Canvas 渲染(PixiJS、Egret、Laya 这几个都常见),通信换成 WebSocket,服务端有的继续用 Java + Netty,有的换成 Node。源码包里如果同时存在client/src/*.as和client/src/*.ts,那多半是新旧两套都留着,跑的时候别搞混,认准入口文件是哪一个。
真正决定跑不跑得起来的不是渲染引擎,而是那张表里的东西:
| 层次 | 常见技术 | 职责 | 跑不通时最先看 |
|---|---|---|---|
| 客户端渲染 | PixiJS / Egret / AS3 | 地图、角色、弹道动画 | 资源路径、CORS |
| 网络通信 | WebSocket / 原生 Socket | 收发战斗指令 | 端口、协议头 |
| 服务端 | Java + Netty | 房间、回合、物理重算 | 配置表加载 |
| 数据存储 | MySQL + Redis | 账号、背包、在线状态 | 建表脚本 |
| 配置表 | Excel 导出 JSON | 武器、关卡、掉落 | 导表是否执行 |
我个人的判断标准是:客户端用什么引擎不重要,重要的是战斗结果由谁计算。这份源码走的是服务端权威路线,客户端只上报"我用了什么角度、什么力度、什么时候开炮",落点和伤害全部由服务端算完再广播。这个设计在后面的弹道章节会展开,先记住一句话——客户端算的是表现,服务端算的是事实。
2.2 目录结构:从 assets 到 battle 的职责边界
把压缩包解开,通常能看到这么一套布局(包名和目录名各家会有出入,但分层思路大同小异):
ddsource/ ├── client/ │ ├── index.html # H5 版入口 │ ├── assets/ # 贴图、音效、地图碰撞掩码 │ └── src/ │ ├── core/ # 引擎初始化、资源加载、场景切换 │ ├── net/ # socket 封装、协议编解码、心跳 │ ├── battle/ # 战斗表现:弹道插值、受击动作、镜头 │ ├── ui/ # 登录、房间列表、背包、商城 │ └── config/ # 由 Excel 导出的 json 配置表 ├── server/ │ ├── conf/server.properties # 端口、数据库、战斗参数 │ └── src/main/java/com/game/ │ ├── net/ # Netty 编解码、会话管理、断线处理 │ ├── room/ # 房间创建、匹配、准备、开始 │ ├── battle/ # 回合状态机、弹道、伤害、结算 │ ├── player/ # 属性、背包、装备穿戴 │ └── dao/ # 数据库访问 ├── sql/init.sql # 建库建表脚本 └── tools/excel2json/export.py # 配置表导出工具几个容易看走眼的地方。client/src/battle/里不会有伤害公式,只有动画时间轴和插值;真正的公式在server/battle/下。assets/里的地图通常除了视觉图还有一张同名的掩码图,用来做炮弹和地形的碰撞判定,少了这张图,炮弹会直接穿过山体飞到屏幕外。tools/目录经常被忽略,但它决定了配置表改动能不能生效——很多"我改了武器伤害怎么没变"的问题,根子就在这里。
分层的边界很清楚:net只负责字节搬运,不做业务判断;battle只认房间和玩家对象,不碰数据库;dao只做增删改查,不写游戏规则。二次开发时按这个边界改,出问题的概率低很多。血泪经验是别在net层里直接写战斗逻辑,一旦并发上来,会话和房间的状态会互相污染,排查起来像在黑匣子里捞针。
2.3 本地跑通:先建库,再起服务端,最后起客户端
启动顺序是有讲究的。服务端启动时会去连数据库、加载配置表、绑定端口,这三步任何一步失败都会直接退出;客户端只是被动连接,服务端没起来它连不上,报的错会很含糊。所以按下面的顺序来:
# 1. 建库建表,注意指定字符集,否则中文昵称会变问号 mysql -uroot -p --default-character-set=utf8mb4 < sql/init.sql # 2. 改服务端配置,数据库地址、Redis 地址、监听端口都要换成自己的 vim server/conf/server.properties # 3. 打包并启动服务端 cd server && mvn clean package -DskipTests java -jar target/game-server.jar # 4. 另开一个终端,把 client 目录挂成静态服务(不要直接双击 html) cd client && python3 -m http.server 8080server.properties里几个参数值得单独说,它们直接决定了后面战斗手感和排查方向:
# 游戏服监听端口,客户端 net 层配置必须一致,不一致的表现是登录成功后卡住 server.port=9001 # 数据库连接,characterEncoding 不写 utf8 的话中文昵称存进去就是乱码 db.url=jdbc:mysql://127.0.0.1:3306/game_db?useUnicode=true&characterEncoding=utf8 db.user=root db.password=123456 # 战斗逻辑帧间隔(毫秒),客户端表现层按这个值做插值,两边不一致会看到炮弹卡顿 battle.tick=50 # 单回合思考时间(秒),超时由服务端强制结束回合,客户端倒计时只是展示 battle.turn.timeout=30建表脚本里最关键的是两张基础表,其他表都是在它们之上扩展的:
CREATE TABLE `player` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `account` VARCHAR(64) NOT NULL UNIQUE, -- 登录账号,唯一索引防重复注册 `nickname` VARCHAR(32) NOT NULL, `level` INT NOT NULL DEFAULT 1, `exp` INT NOT NULL DEFAULT 0, `gold` INT NOT NULL DEFAULT 0, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `player_item` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `player_id` BIGINT NOT NULL, `item_id` INT NOT NULL, -- 对应 client/src/config/items.json 里的 id `count` INT NOT NULL DEFAULT 1, `equipped` TINYINT NOT NULL DEFAULT 0, -- 同一部位同时只能有一件为 1 KEY `idx_player` (`player_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;item_id外键到配置表而不是数据库表,这是这类项目的通用做法:策划改数值只动 Excel,不动数据库。代价是配置表和数据库可能对不上——删了一件装备的配置但玩家背包里还有,登录时就会读到空指针。稳妥的处理是在加载背包时做一次过滤,配置里不存在的item_id直接跳过并记日志,别让整个登录流程崩掉。
客户端这边要改的通常只有一个文件,里面写着服务端地址:
// client/src/net/config.js export const NET_CONFIG = { host: '127.0.0.1', // 局域网联机时改成服务端内网 IP port: 9001, // 必须和 server.port 一致 reconnectMax: 3, // 断线重连次数,战斗中断线重连要单独处理 heartbeat: 10000 // 心跳间隔(毫秒),太短会被服务端限流 };到这一步,浏览器打开http://127.0.0.1:8080应该能看到登录界面。如果看到的是白屏,八成是直接用了file://协议打开 html,浏览器的同源策略挡住了资源加载,起个静态服务器就好。如果能看到登录界面但点登录没反应,打开开发者工具的 Network 面板,看 WebSocket 有没有连上,连不上就往端口和防火墙方向查。
3. 核心链路:一发炮弹从蓄力到结算的完整过程
3.1 回合状态机:什么时候谁能开炮
整个战斗系统的心脏是一个状态机。玩家看到的是"轮到我了我调角度",服务端看到的是一个带超时的状态迁移。这份源码里状态定义大致长这样:
public enum BattleState { WAIT_START, // 双方进入房间,倒计时结束后迁移到 TURN_AIMING TURN_AIMING, // 当前回合玩家可调角度和力度,接受开炮请求 TURN_FLYING, // 炮弹飞行中,锁定所有输入,只处理超时和断线 TURN_SETTLE, // 结算伤害、播放死亡动画、判定胜负 ROUND_END // 一方全灭,发奖励,回到房间列表 }状态迁移的入口只有一个,就是开炮请求。这个设计的好处是所有非法操作都能在一个地方挡掉:
public class BattleRoom { private BattleState state = BattleState.WAIT_START; private final List<Player> players; private int turnIndex; private long deadline; // 客户端上报:我以什么角度、什么力度开炮 public synchronized void onFire(long playerId, int angle, int power) { Player cur = players.get(turnIndex); // 三道校验:状态对不对、是不是本人、参数在不在合法区间 if (state != BattleState.TURN_AIMING) return; if (cur.getId() != playerId) return; if (angle < 0 || angle > 90 || power < 0 || power > 100) return; state = BattleState.TURN_FLYING; // 关键:不用客户端传的落点,服务端自己重算一遍 ShotResult result = Physics.simulate(cur, angle, power, wind); broadcast(Cmd.SHOT_RESULT, result); settle(result); } private void settle(ShotResult r) { state = BattleState.TURN_SETTLE; applyDamage(r); if (isGameOver()) { state = BattleState.ROUND_END; reward(); } else { turnIndex = (turnIndex + 1) % players.size(); deadline = System.currentTimeMillis() + turnTimeout; state = BattleState.TURN_AIMING; } } }几个参数的作用要说清楚。angle限制在 0 到 90 度,是因为这个游戏的炮口在角色右侧、重力向下,负角度打不到地面;power归一化到 0 到 100,方便客户端进度条和物理常量解耦。deadline是超时时间戳,服务端要有一个定时任务扫这个字段,超时就把当前玩家强制结算、切下一位,不然有人挂机会把整个房间卡死。
settle里有个容易翻车的点:applyDamage如果抛异常,state会永久停在TURN_SETTLE,双方都点不动,表现为"卡回合"。稳妥写法是把这个方法体包在 try-finally 里,异常时也要把状态推进到下一回合,同时打日志。这个问题在测试环境很难复现,线上却会因为某个玩家的属性数据异常偶发一次,查起来相当费劲。
3.2 弹道解算:角度、力度、风力三者的关系
弹道是这个游戏最像"玄学"的部分,但拆开看就是一个带水平加速度的抛物线。角度决定方向,力度决定初速度,风力是持续作用的水平加速度。核心代码就下面这几十行:
public class Physics { // 下面四个常量是调手感的核心,客户端预览和服务端结算必须用同一套 private static final double GRAVITY = 9.8; private static final double POWER_SCALE = 0.12; // 力度值 -> 初速度 private static final double WIND_SCALE = 0.35; // 风力值 -> 水平加速度 private static final double DT = 0.016; // 单步模拟时间(秒) public static ShotResult simulate(Player shooter, int angleDeg, int power, int wind) { double rad = Math.toRadians(angleDeg); double v0 = power * POWER_SCALE; double vx = v0 * Math.cos(rad); double vy = v0 * Math.sin(rad); double x = shooter.getX(), y = shooter.getY(); double windAcc = wind * WIND_SCALE; List<Point> path = new ArrayList<>(); for (int i = 0; i < 1200; i++) { // 最多 1200 步,约 19 秒飞行 vx += windAcc * DT; // 风力持续施加水平加速度 vy -= GRAVITY * DT; // 重力只作用于垂直方向 x += vx * DT; y += vy * DT; path.add(new Point(x, y)); if (y <= 0) break; // 落到地面 if (hitTerrain(x, y)) break; // 撞到山体或障碍 for (Player p : enemiesOf(shooter)) { if (p.circleHit(x, y)) { // 命中判定用圆形包围盒,够用且便宜 return new ShotResult(path, p, x, y); } } } return new ShotResult(path, null, x, y); } }POWER_SCALE这个系数是最需要调的。调大了,满力度能飞出屏幕;调小了,满力度只能打半屏,游戏节奏拖沓。常见做法是先把地图宽度定下来,反推"满力度满角度在无风情况下能打多远",再除以 100 得到系数。WIND_SCALE决定风力对弹道的干扰强度,这个值直接影响玩家的挫败感——太大就是纯运气,太小风就成了摆设。
DT取值也有讲究。取 0.016 相当于 60Hz 的模拟频率,对休闲游戏足够了;如果要更精确的碰撞,可以取 0.008 甚至更小,代价是步数翻倍,服务端一个房间每次开炮都要多算一倍的点。我一般会在battle.tick和DT之间留个映射关系,让配置里改battle.tick就能整体调节精度和开销。
还有一个细节是整条path全部下发给客户端,客户端只做插值播放,不做物理运算。这样做的好处是防作弊——外挂改不了落点;坏处是每炮要传几百个点,流量上来了。折中做法是服务端只发关键节点(比如每 5 步取一个点)加最终落点,客户端用贝塞尔曲线平滑,视觉上看不出差别。这段如果做得糙,玩家会明显感觉炮弹"一跳一跳"的。
3.3 命中判定与伤害结算:服务端为什么必须重算一遍
命中判定用的是圆形包围盒,也就是把角色看成一个圆,炮弹位置到圆心的距离小于半径就算命中。这个简化对休闲游戏足够,代价是打斜坡上的角色偶尔会有偏差,但比精确多边形碰撞省太多 CPU。
伤害公式各家不一样,但结构通常类似:
public static int calcDamage(Player atk, Player def, int base, boolean crit, int distance) { double atkPower = atk.getAttr(AttrType.ATTACK) + base; // 防御减伤用除法而不是减法,避免高防御变成无敌 double mitigation = def.getAttr(AttrType.DEFENSE) / (def.getAttr(AttrType.DEFENSE) + 200.0); double critMul = crit ? 1.75 : 1.0; // 距离衰减:偏离爆心越远伤害越低,最低保留 30%,避免边缘命中毫无意义 double falloff = Math.max(0.3, 1.0 - distance / 120.0); return (int) Math.round(atkPower * (1 - mitigation) * critMul * falloff); }mitigation用除法形式而不是atk - def,是因为减法在数值膨胀后会出问题:防御堆到 500、攻击只有 300,伤害变负数,实际效果等于无敌。除法形式下防御再高也只是无限接近完全减伤,永远打不出负数,平衡性好调很多。那个分母 200 是"防御到达 200 时减伤 50%"的锚点,策划调数值时按这个锚点推算就行。
falloff这一项是把炮弹当成有爆炸半径的 AOE 处理。distance是角色圆心到爆炸点的距离,除以 120 就是衰减梯度,超出半径的直接不算命中。这里有个坑:如果distance取的是炮弹落点到脚下坐标的二维距离,而地图有高低差,站在高台上的角色会被低估伤害。稳妥做法是取三维距离或者在判定时把高度差单独补偿。
暴击的crit ? 1.75 : 1.0里的 1.75 是倍率,很多项目会用 1.5 到 2.0 之间,配合配置表里的暴击率。要注意的是暴击判定必须放在服务端,客户端只播放特效。如果客户端自己判暴击,改个内存就能刀刀暴击。
3.4 战斗回放与断线重连
服务端把每一炮的ShotResult存一份在房间内存里,就自然有了回放能力。回放不是把视频录下来,而是把"谁在第几回合用什么角度力度开炮"这个指令序列重放一遍——因为物理是确定性的,同样的输入必然得到同样的输出。前提是物理常量不能中途变,所以这些常量在服务端启动时加载一次,运行期间不修改。
断线重连的处理是这类项目的常见坑。玩家在TURN_AIMING阶段断线,30 秒内重连回来,应该还轮到他;超过超时时间,服务端已经强制切人了,重连回来要能同步当前状态。做法是客户端重连时带一个roomId,服务端在房间表里查到对应房间和玩家槽位,把当前state、turnIndex、剩余时间、双方血量打包下发,客户端清空本地战斗场景重建。
这里如果偷懒,只在断线时销毁房间,玩家重连回来就发现自己在主界面,房间没了,打了一半的局白费。体验上很致命,尤其是在移动网络下。稳妥点就是房间加一个"离线保护期",期内保留状态,期满再清理。
4. 协议与数据:客户端和服务端怎么对上话
4.1 消息结构与分发:长度、命令号、包体
TCP 是字节流,没有消息边界,所以每条消息必须自带长度。这份源码用的结构很典型:
// 包头:4 字节长度 + 2 字节命令号,包体是序列化后的数据 public class Packet { public static final byte[] MAGIC = {0x55, (byte) 0xAA}; private short cmd; private byte[] body; public static Packet decode(ByteBuf in) { if (in.readableBytes() < 6) return null; // 头都没收全,等下一批 in.markReaderIndex(); short len = in.readShort(); if (in.readableBytes() < len) { // 半包,回退读指针 in.resetReaderIndex(); return null; } short cmd = in.readShort(); byte[] body = new byte[len]; in.readBytes(body); return new Packet(cmd, body); } }markReaderIndex与resetReaderIndex这对调用是处理粘包半包的关键。不写回退,读到半个包就会把读指针推进到错误位置,后面所有消息全乱。这个问题在本地测试时几乎不会出现——本地延迟低,一次 write 通常就是一个完整包;一旦部署到网络差的环境,立刻炸。
命令号到处理器的映射一般维护在一张表里:
// 命令号集中注册,避免散落在各处造成冲突 registry.put(Cmd.LOGIN_REQ, new LoginHandler()); registry.put(Cmd.ENTER_ROOM_REQ, new EnterRoomHandler()); registry.put(Cmd.FIRE_REQ, new FireHandler()); registry.put(Cmd.HEARTBEAT_REQ, new HeartbeatHandler());这样加一个新功能只需要新增一个 Handler 加一行注册,不用改分发逻辑。要注意的是 Handler 里不要做耗时操作,Netty 的 IO 线程被阻塞会拖慢整个连接上所有房间。数据库查询、配置表重载这类活儿,要么丢到业务线程池,要么用异步回调。
4.2 玩家数据与背包/装备
玩家对象在内存里是常驻的,登录时从数据库加载,下线时写回。属性分两部分:基础属性来自等级和职业配置,附加属性来自装备。计算最终属性的顺序不能乱:
public int getAttr(AttrType type) { // 先算基础值,再叠加装备,最后乘百分比加成 int base = baseAttr.getOrDefault(type, 0); for (Item item : equippedItems) { base += item.getFlatBonus(type); // 固定值加成 } double mul = 1.0; for (Item item : equippedItems) { mul += item.getPercentBonus(type); // 百分比加成,累加后再乘 } return (int) Math.round(base * mul); }固定值和百分比分开累加再相乘,和"逐件相乘"的结果差别很大,尤其是装备多了以后。逐件相乘会指数膨胀,这个做法是这类游戏的通行选择。
装备穿戴有个隐性约束:同一部位只能穿一件。判断逻辑不复杂,但如果只在前端做限制,玩家用改包工具就能同时穿两件帽子,属性直接翻倍。服务端必须重新校验,把该部位已有的装备全部卸下再穿新的。写这段的时候顺手加个日志,出问题时能查到谁在什么时候换了什么。
4.3 配置表与热更新
配置表的导出脚本通常长这样,一张 Excel 对应一张 json:
# tools/excel2json/export.py import pandas as pd, json # 第一行字段名,第二行类型说明(给策划看),第三行开始才是数据 df = pd.read_excel("武器表.xlsx", skiprows=2) out = [] for _, row in df.iterrows(): out.append({ "id": int(row["id"]), "name": str(row["name"]), "baseDamage": int(row["base_damage"]), "criticalRate": float(row["critical_rate"]), # 0~1 之间 "weight": int(row["weight"]), }) json.dump(out, open("client/src/config/weapons.json", "w", encoding="utf-8"), ensure_ascii=False, indent=2)skiprows=2那两行是给策划写字段含义和取值范围的,导出时要跳过,不然后面读出来第一行数据就是注释。ensure_ascii=False不能省,否则中文武器名全变成\uXXXX,客户端显示的时候虽然能还原,但调试时看得头疼。
配置热更新要解决的是"改了数值不想重启服务"这个需求。常见做法是配置加载到一个可替换的引用上,收到重载命令时重新读文件、构建新对象、原子替换,正在进行中的战斗继续用旧引用,新开的战斗用新引用。这样就不会出现同一局里两个人拿到的武器伤害不一样。如果图省事直接清空再加载,中间有个空窗期,此时进来的请求会读到 null。
5. 排查手册:跑不起来、进战斗就断、弹道对不上
5.1 启动与环境类
现象:浏览器打开页面纯白,控制台没有明显报错。原因:直接用file://打开 html,浏览器同源策略拦截了脚本和图片加载,Network 面板里会看到一堆blocked或者 0 字节资源。 解决:在client目录起个静态服务器,python3 -m http.server 8080或者npx serve都行,然后走http://访问。别图省事用双击。
现象:服务端启动直接退出,日志里有Table 'game_db.player' doesn't exist。原因:init.sql里通常自带CREATE DATABASE,但导入命令没指定库名时表会建到默认库里,服务端连的却是game_db。 解决:导入时显式指定mysql -uroot -p game_db < sql/init.sql,先建库再导入。导完用show tables确认一眼。
现象:登录能过,进入房间后一直停在"连接战斗服"。原因:客户端net/config.js里的host和port还是原作者环境的值,或者服务端server.port改过但客户端没跟着改。 解决:两边配置对一遍。同一台机器用127.0.0.1,局域网联机改成服务端内网 IP,注意别填成外网地址。
5.2 战斗逻辑类
现象:客户端瞄准时预览的落点和实际落点差一大截,越远偏得越多。原因:客户端和服务端各写了一套物理常量,POWER_SCALE或者WIND_SCALE不一致。这是最常见的翻车点,因为两边代码长得像,改了一边忘了另一边。 解决:把物理常量抽成一份配置,两端都从这份配置读,改完重启两端。验收标准是无风、45 度、满力度这一炮的落点像素级一致。
现象:一炮下去对面全队掉血,或者边缘命中伤害比正中还高。原因:AOE 判定没有做距离衰减,或者falloff里的distance算错了方向(用了绝对值取反之类)。 解决:打日志把每个受击者的distance和最终伤害打出来,正常应该随距离单调递减,且不低于 30% 的保底值。
现象:轮到自己却点不动,界面也不报错。原因:状态机卡在TURN_FLYING。多半是结算方法抛了异常没捕获,跳过状态推进,整个房间的所有输入都被挡在onFire的第一道校验外。 解决:settle用 try-finally 包住,异常也要推进状态。同时在超时扫描里加一条兜底:某个状态持续超过两倍回合时间就强制重置到TURN_AIMING。
5.3 网络与数据类
现象:本地跑没问题,放到云上就偶发断线、消息乱序。原因:协议没有长度字段,或者decode里没处理半包,读指针错位。本地延迟低不容易触发,网络一抖动就露馅。 解决:按 4.1 的结构补长度头和resetReaderIndex回退逻辑。压测时可以用工具限速模拟弱网,别等上线才发现。
现象:玩家掉线重连后,房间没了,打了一半的局丢了。原因:断线时直接销毁房间,没有离线保护期。 解决:给房间加一个保护窗口(比如 60 秒),期间保留状态,重连按roomId恢复。超过窗口再清理并释放内存。
现象:改了 Excel 配置表,游戏里数值没变。原因:配置在服务端启动时加载到静态变量里,改文件不重启不生效;或者导出脚本没跑,json 还是旧的。 解决:跑一遍export.py,确认 json 文件的修改时间更新了,再走热重载命令。养成习惯:改表必导表,导完看时间戳。
6. 二次开发:加一把武器和一套新规则的完整改动路径
先说加武器,这是最小的改动单元,但涉及的文件比想象中多。以新增一把"穿透型"武器为例,改动清单大致是这些:
| 改动点 | 文件 | 说明 |
|---|---|---|
| 数值配置 | tools/excel2json/武器表.xlsx | 加一行,填id、伤害、暴击率 |
| 导出产物 | client/src/config/weapons.json | 跑export.py自动生成 |
| 资源文件 | client/assets/weapon/ | 图标、发射特效、弹道贴图 |
| 表现映射 | client/src/battle/WeaponView.ts | 按id映射到资源路径 |
| 特殊逻辑 | server/battle/effect/ | 穿透需要新增一个Effect实现 |
前四项都是体力活,真正要动脑的是最后一项。建议在伤害结算之后留一个效果注入点:
// 结算完基础伤害后,遍历武器携带的效果依次执行 public void applyEffects(ShotResult r) { for (Effect e : r.getWeapon().getEffects()) { e.onHit(this, r); // 穿透、溅射、吸血都实现这个接口 } }新增穿透效果时,只需要实现onHit,把弹道路径上第一个命中点之后的目标也纳入伤害范围。这样做的好处是新武器不会污染主伤害公式,出问题回滚就是删一个类。
验证方法我一般分三层。第一层是单元测试,直接调Physics.simulate,固定角度、力度、风,断言落点坐标;第二层是 GM 命令,服务端留一个只在内网开的指令,能直接给人发武器、改属性、跳过回合;第三层是双端对拍,同一个输入分别跑客户端预览和服务端结算,把两边的轨迹点导出来做差。
最后这条最关键。我之前吃过一次亏:客户端为了性能把物理步长调大到 32 毫秒,服务端还是 16 毫秒,结果近距离看不出问题,超过半个屏幕之后落点能差出一个身位,玩家天天在反馈区骂"打不中"。从那以后我每次改物理参数,都强制把两端的关键节点全打出来对一遍,差一个像素都不放过。这份源码你拿到手之后,建议先把启动流程走通,再动数值,最后碰战斗逻辑——顺序反了,排查成本会翻好几倍。希望帮到你。
本文还有配套的精品资源,点击获取