从零开发Java坦克大战Ⅱ(上) -- 从单机到联机(架构演进与设计模式剖析)
引言:
- 项目目标: 在本地IDE上基于Java Swing , 多线程以及 Socket编程 实现一个网络版双人坦克大战的小游戏, 具备基本的坦克操作控制, 地图设计, 子弹攻击, 快捷聊天室, 游戏状态提示等功能
- 学习期望: 通过本游戏, 希望对于Java Swing, Socket编程, 多线程有更深刻的认识, 并且能从游戏的设计模式选择, 网络通信原理与优化, 核心算法的实现与优化等方面 进行更深刻的思考探索与剖析总结
- 预期效果: 坦克动荡小游戏,在线玩,4399小游戏
http://www.4399.com/flash/34789_3.htm
-
1. 坦克大战网络版基本架构设计
-
1.1 游戏设计初衷
- 在本文中我将尽可能地展示更新后的游戏功能以及具体实现方法与逻辑, 在此基础上 我还将对其进行深度剖析,纵向探索每一个方法或设计的理由以及底层实现原理, 并横向比对相关的设计模式与常见的方法,思路,算法. 最终能够清晰的呈现此项目中我的设计逻辑, 代码缺陷, 优点总结, 知识的深度挖掘 乃至 对求职的启发与帮助
-
1.2 网络通信协议选型
-
1.2.1 为什么TCP是起点?
- 1. 可靠性是首要要求
- 绝不可以丢失游戏指令: 在TankWar中,任何一个指令的丢失(KeyEvent), 每一个开枪指令都至关重要, 一次指令的丢失可能会让玩家感觉到操控失灵.
- TCP的保障: TCP 通过确认应答(ACK), 以及超时重传等机制保证了数据包都能可靠的送达对端. 这对于NetworkMessage(聊天信息,开火命令)以及初始化位置(InitialPosition) 这类关键指令必不可少!
- 2. 有序性 (Ordering) 的必要性/ 开发的简化
- 状态同步的有序需求:游戏状态(GameState)每一帧都在变化, 客户端需要按顺序应用这些状态快照, UDP并不保证数据报到达的顺序, 如果客户端接受的第n帧比第n+1帧晚到, 这将极大影响实施的对战体验
- TCP 的保障: TCP保证了数据包按发送顺序送达,我无需在应用层处理复杂的乱序问题,极大地降低了初期开发的复杂度。
- 3. 面向连接
- TCP内置的连接建立(三次握手)和断开(四次挥手)机制,完美匹配了TankWar“房间式”的会话模型。玩家需要先通过
NetworkSetupDialog
建立连接,才能开始游戏,游戏结束后断开连接。这个模型与TCP的特性天然契合。
- TCP内置的连接建立(三次握手)和断开(四次挥手)机制,完美匹配了TankWar“房间式”的会话模型。玩家需要先通过
- 4. 流量控制
-
TCP的滑动窗口机制能自动调节发送速率,防止网络拥塞时发送端拖垮接收端。虽然TankWar数据量不大,但这提供了一个基础的防崩溃机制。
-
- 1. 可靠性是首要要求
-
1.2.2 TCP的代价与问题
- 1. 延迟 (Latency) 与队头阻塞 (Head-of-Line Blocking)
- 最致命的问题 ,第n个数据包丢失了:
- TCP会坚守“有序性”,在收到第n个包的重传之前,接收端内核中的缓冲区会一直持有第n+1, n+2...个包,无法交付给我们的应用程序(
in.readObject()
会一直阻塞)。一个丢包就会卡住后续所有数据,导致游戏画面“卡顿”
- TCP会坚守“有序性”,在收到第n个包的重传之前,接收端内核中的缓冲区会一直持有第n+1, n+2...个包,无法交付给我们的应用程序(
- 最致命的问题 ,第n个数据包丢失了:
- 2. 冗余开销
- 为了保证可靠性,每个包都有Seq、Ack等头部开销。对于我发送的非常小的指令包(如:
Set<Integer> input
),这些头部信息可能比有效载荷还大。
- 为了保证可靠性,每个包都有Seq、Ack等头部开销。对于我发送的非常小的指令包(如:
- 3. 缺乏灵活的拥塞控制
- TCP的拥塞控制算法(如Reno、Cubic)是为大文件传输设计的,旨在公平地占满带宽。但对于需要低延迟、小数据量的实时游戏,它显得过于“保守”和“迟钝”
- 1. 延迟 (Latency) 与队头阻塞 (Head-of-Line Blocking)
-
1.2.3 如果想用UDP怎么搞?
- 追求极致的实时性,UDP是必由之路。我需要自己在应用层解决TCP帮我解决的问题。
- 1. 可靠性 (Reliability) - 自己实现ACK和重传
- 选择性重传:我不能像TCP一样所有包都重传。我需要设计自己的ACK/NACK(确认和否定确认)机制。
-
a. ACK (确认-- "我收到了!")
- 工作原理:
-
发送方给每个数据包分配一个唯一序列号 (Seq)。
-
接收方收到包后,向发送方回一个ACK包,包里写着:“我成功收到了Seq=X的包”。
-
发送方收到ACK,就知道这个包安全了,可以从重发队列里移除。
-
-
类型:
-
累计ACK:ACK(N) 表示“N及之前的所有包我都收到了”。效率高(一个ACK确认多个包),但重传不精确(丢包5,ACK4,发送方会重传5及之后所有包)。
-
选择性ACK (SACK):ACK里会明确列出具体收到了哪些序列号的包。重传精确,只重传真正丢失的包。
-
- 工作原理:
- b. NACK (否定确认 -- "我缺这个!")
- 工作原理:
-
接收方发现数据包不连续了(比如收到了Seq=1, 2, 4, 5)。
-
接收方立刻主动发送一个NACK包,包里写着:“我缺了Seq=3的包,快重发!”。
-
发送方收到NACK后,立即重传指定的数据包。
-
-
优点:速度快。不必像ACK超时重传那样等待计时器到期,能大幅降低丢包时的恢复延迟。
- 工作原理:
-
-
为每个关键数据包(指令、状态帧)分配一个唯一的、递增的序列号(SeqNum)。
-
接收端定期发送ACK,告知发送方“我已收到SeqNum <= X的所有包”。或者发送NACK,明确告知“我丢了SeqNum为Y的包”。
-
发送端维护一个发送窗口,只重传那些被确认为丢失的包。
- 选择性重传:我不能像TCP一样所有包都重传。我需要设计自己的ACK/NACK(确认和否定确认)机制。
-
2. 有序性 (Ordering) - 按需处理
-
我不再需要严格的顺序。每个数据包都带有序列号和时间戳。
-
对于状态包:如果收到一个旧的包(序列号比当前应用的状态旧),可以直接丢弃。我们只关心最新的状态。
-
对于指令包:可能需要按顺序处理(比如“建造A”必须在“建造B”之前),但很多时候,游戏的指令是即时的,旧指令也可以丢弃(比如“0.5秒前的移动指令”已经没意义了)。
-
-
3. 引入高级协议:站在巨人的肩膀上
-
自己实现一套完善的可靠UDP协议(RUDP)非常复杂。更明智的做法是使用成熟的第三方库:
-
KCP: 一个高效、可靠的ARQ协议。它纯算法实现,以浪费带宽为代价换取低延迟。它非常适合像TankWar这样对延迟敏感的小规模游戏。我只需要将数据通过KCP发送,它就能为我提供比TCP更低的延迟和可控的重传策略。
-
ENET: 一个轻量级的网络通信层,提供了连接管理、可靠性、多通道等特性。它被许多知名游戏(如《Minecraft》、《英雄联盟》)使用。ENET的多通道特性非常强大:我可以为聊天消息设置一个高可靠通道,为玩家位置同步设置一个低延迟不可靠通道。
-
-
-
-
1.3 网络架构选型:
-
1.3.1 核心架构: 权威服务器模式 (Authoritative Server Architecture)
- 1. 架构图解
-
- 2. 职责划分
-
主机 (Host/Server):
-
权威:是游戏世界的“唯一真相源”。所有核心逻辑(移动、碰撞、子弹更新、胜负判定)都在主机上运行。
-
职责:接收客户端输入、运算游戏逻辑、生成游戏状态快照、将状态广播给所有客户端。
-
-
客户端 (Client):
-
傀儡:不运行任何核心游戏逻辑。它只负责两件事:
-
渲染:将接收到的游戏状态(
GameState
)忠实地渲染到屏幕上。 -
输入采集:将本地玩家的键盘输入打包发送给主机。
-
-
-
- 3. 利弊剖析 -- 为什么是最佳选择?
- 优点:
-
绝对防作弊:这是最大优势。所有关键计算都在主机上完成,客户端无法篡改血量、位置、伤害等数据。客户端只是“显示器”。
-
开发调试简单:游戏逻辑只存在于主机一端,无需处理复杂的网络状态预测和回滚,大大降低了初期开发难度。
-
逻辑一致性:避免了在不同机器上因浮点数精度、随机数种子等差异导致的状态不一致问题(“ desync”)。所有玩家看到的是同一个世界。
-
- 缺点:
-
服务器单点故障:主机如果卡顿或掉线,整个游戏就结束了。
-
服务器性能瓶颈:所有计算压力都在主机上,如果游戏单位非常多,主机可能成为瓶颈。
-
网络延迟敏感:所有操作都要经过“输入发送->服务器计算->状态返回”的回路。延迟高时,玩家会感到操作不跟手。
-
- 优点:
- 4. 那其他架构咋样呢?
- P2P:
- 原理: 没有服务器,每个玩家既是客户端也是服务器,相互直连
- 优: 延迟低、无单点故障
- 缺: 难以防作弊、状态同步复杂易desync、NAT穿透问题
- 场景: 已逐渐被淘汰,早期局域网游戏(如《红色警戒2》)
- 权威服务器 (本项目)
- 原理: 1个主机做服务器,负责所有逻辑和状态
- 优: 防作弊、一致性高、开发简单
- 缺: 延迟高、服务器压力大
- 场景: 小型对战游戏(如TankWar)、MMORPG
- 专用权威服务器 (Dedicated Server)
- 原理: 有一个独立的、不参与游戏的服务器程序
- 优: 专业级防作弊、性能最好、负载均衡
- 缺: 需要额外的服务器成本和运维成本
- 场景: 大型竞技游戏(如CSGO, DOTA2)、大型MMO
- P2P:
-
1.3.2 设计模式的应用
- 我的项目中不经意间使用了几个经典的设计模式, AI说是质量高的体现
- 1. 回调模式 (Callback Pattern) / 观察者模式 (Observer Pattern)
- 应用场景:
ChatPanel
与GamePanel
的通信。-
实现:我定义了
ChatCallback
接口,GamePanel
实现它,并将实例传递给ChatPanel
。 -
解决的问题:解耦。
ChatPanel
只关心“当有消息时要显示”,但它完全不知道消息来自网络还是本地。它不需要持有GamePanel
的引用,只需要调用一个接口方法。这遵循了依赖倒置原则(DIP)——依赖于抽象(接口),而非具体实现。 -
优势:使得
ChatPanel
成为一个高度可复用的组件,可以轻易移植到其他项目中。
-
- 应用场景:
- 2. 快照模式 (Snapshot Pattern)
- 应用场景:网络同步。
GameState
类就是整个游戏世界的一个快照。 -
实现:定期将游戏内所有关键对象(坦克、子弹)的状态序列化到一个
GameState
对象中,然后发送。 -
解决的问题:状态同步。提供了一种在特定时间点保存和恢复整个游戏状态的有效方式,是网络游戏和游戏存档/读档功能的基石。
- 应用场景:网络同步。
- 3 . 工厂方法模式雏形
-
应用场景:
createBullet
方法。 -
实现:将子弹的创建逻辑封装在一个单独的方法中。
-
解决的问题:封装变化。如果未来我想改变子弹的创建过程(比如根据坦克类型创建不同子弹),我只需要修改这一个方法,而不用在整个代码库中搜索
new Bullet()
。这为未来扩展留下了空间。
-
-
1.3.3 模块化设计:高内聚,低耦合
- 1. 高内聚 (High Cohesion)
-
BattleMaps
只负责一件事:管理地图和碰撞检测。它不关心网络,不关心渲染。 -
ChatPanel
只负责一件事:显示和发送聊天消息。 -
这种设计使得每个模块易于理解、开发和测试。
-
- 2. 低耦合 (Low Coupling)
- 模块间通过清晰的API进行通信,而非直接操作内部数据。
-
GamePanel
调用map.isCollidingWithWall(rect)
来查询碰撞,而不是直接访问map1
数组。 -
ChatPanel
通过ChatCallback
接口与核心通信,而不是直接调用GamePanel
的方法。
-
-
优势:一个模块的修改(比如重写碰撞算法)不会轻易波及其他模块,极大地提升了代码的可维护性。
- 模块间通过清晰的API进行通信,而非直接操作内部数据。
- 1. 高内聚 (High Cohesion)
-
-
1.4 代码质量深潜:序列化决策与"SOLID"原则
- 实现功能是第一步,而写出清晰、健壮、易于维护的代码才是专业开发者的追求.
-
1.4.1 序列化的意义:网络世界的“通用语言”
- 1. 核心目的:对象传输与持久化
- 序列化是将内存中的对象状态转换为可以存储或传输的字节流的过程。反序列化则是其逆过程。在我的TankWar中,它的核心作用是:
-
网络传输:将
GameState
、NetworkMessage
等Java对象转换成字节流,通过Socket发送给对端。对端收到字节流后,再将其还原为Java对象。没有序列化,就无法在网络上传递复杂的对象信息。 -
进程间通信:虽然本项目未使用,但序列化也是RPC(远程过程调用)、分布式系统间通信的基础。
-
- 序列化是将内存中的对象状态转换为可以存储或传输的字节流的过程。反序列化则是其逆过程。在我的TankWar中,它的核心作用是:
- 2. 为什么选择
Serializable
?- Java原生序列化机制通过实现
Serializable
接口(Marker Interface)来启用。我选择它是因为:-
极简实现:无需任何方法,只是一个声明,开发成本极低,非常适合项目原型阶段。
-
集成度高:与
ObjectOutputStream
/ObjectInputStream
无缝集成,几行代码就能完成读写。 -
自动递归:序列化一个对象时,会自动序列化其所有引用的可序列化对象(整个对象图)。
-
- Java原生序列化机制通过实现
- 3. Java原生序列化的优缺点剖析
- 优点
- 简单粗暴:如上所述,对于快速实现Demo来说,这是最快捷的路径。
-
Java生态内省:能自动处理复杂的对象关系和继承体系。
-
缺点
-
性能差:生成的字节流非常臃肿,包含大量类元信息、字段名等冗余数据,严重浪费带宽。
-
兼容性噩梦:最致命的问题。如果你修改了一个已序列化的类(比如给
TankA
增加一个字段),反序列化旧数据可能会抛出InvalidClassException
。 -
语言锁定:只能用于Java程序间的通信,无法与其他语言(如C++、Python、前端JS)交互。
-
安全性风险:反序列化过程可以执行任意代码,是著名的安全漏洞来源(如Apache Commons Collections反序列化漏洞)。
-
- 优点
- 4. 更优方案对比
-
方案 工作原理 优点 缺点 JSON (e.g., Gson/Jackson) 将对象转换为JSON字符串 可读性好、语言无关、体积相对较小 字符串解析性能较低、二进制数据需要Base64编码 Protocol Buffers (Protobuf) Google出品,定义 .proto
文件,编译生成序列化代码性能极致、体积最小、前后兼容性好、语言无关 需要预编译、可读性差(二进制格式) FlatBuffers Google出品,无需解析即可访问序列化数据 零解析性能、极速反序列化 使用复杂度高、生态不如Protobuf
-
- 1. 核心目的:对象传输与持久化
-
1.4.2 SOLID原则审视:架构的试金石
- SOLID是面向对象设计的五个基本原则,是编写高质量、可维护代码的准则
- 1. 单一职责原则 (SRP):
GamePanel
的“上帝类”困境现状分析:
GamePanel
当前承担了过多职责,俨然一个“上帝类”(God Class):-
游戏逻辑控制(更新坦克、子弹)
-
输入处理
-
网络通信管理(创建Socket、读写流、线程管理)
-
碰撞检测协调
-
渲染绘制
-
...
-
这严重违反了SRP:“一个类应该有且只有一个引起它变化的原因”。如果网络协议变了、游戏规则变了、渲染方式变了,都需要修改同一个类。
-
重构方案:
应立即进行职责抽离,将GamePanel
拆分为多个协同工作的类:
-
- 2. 开闭原则 (OCP):对扩展开放,对修改关闭
- 现状分析:
-
OCP要求软件实体应该通过扩展(如继承、组合)来适应新需求,而不是通过修改已有代码。
目前我的代码在OCP方面有所考虑,但仍有不足。 -
扩展性评估:以“新增一种坦克”为例
-
需要修改的地方:
-
创建新坦克类
TankC extends MoveObjects
。 -
在
GamePanel
中增加TankC
的实例变量。 -
在
GamePanel
的update()
和draw()
方法中增加对TankC
的处理逻辑。 -
修改
GameState
类,增加TankC
的状态字段。 -
修改网络序列化/反序列化逻辑来处理新坦克。
... 基本上需要修改所有核心类,这不符合OCP。
-
-
-
改进方案:面向接口编程与工厂模式
真正的OCP需要通过抽象和多态来实现。 -
代码示例:
-
// 1. 定义坦克接口 public interface Tank {void update();void draw(Graphics2D g2d);Rectangle getBounds();// ... 其他通用方法 }// 2. 各种坦克实现此接口 public class TankA implements Tank { ... } public class TankB implements Tank { ... } public class TankC implements Tank { ... } // 新增坦克,无需修改现有类// 3. 使用工厂模式创建坦克 public class TankFactory {public static Tank createTank(String tankType, int x, int y) {switch (tankType) {case "A": return new TankA(x, y);case "B": return new TankB(x, y);case "C": return new TankC(x, y); // 扩展时,只修改工厂类default: throw new IllegalArgumentException();}} }// 4. 在GamePanel中,统一管理Tank列表,而非具体类 public class GamePanel {private List<Tank> allTanks; // 持有接口,而非实现private void updateTanks() {for (Tank tank : allTanks) {tank.update(); // 多态调用,无论新增多少种坦克,这里都不需要改}} }
改进好处:未来再新增
TankD
、TankE
,绝大部分现有代码都无需修改,只需要扩展新的坦克类和更新工厂即可。系统的弹性大大增强。
-