简介:Java泡泡堂联机版是一款基于Java开发的多人在线对战游戏,借鉴了泡泡堂与QQ堂的经典玩法,适合Java学习者与游戏开发爱好者用来理解网络编程和游戏逻辑设计。游戏核心包括角色移动、泡泡发射、爆炸效果及障碍物交互,均通过面向对象方式建模;联机部分使用Socket/ServerSocket实现客户端与服务器通信,并处理位置同步与动作指令,体现了网络延迟应对思路。压缩包大小仅507KB,其中版本标识为bomb_man0.3,具体文件清单暂无细分数据,但项目规模小巧,便于快速浏览核心代码结构。目前已获得646人学习浏览,适合作为课程设计或自学项目的参考。通过分析该项目,可掌握Java多线程协作、状态同步、碰撞检测等知识点,并了解多人联机游戏从单机到网络化的改造过程,对想完成类似联机小游戏的开发者具有直接借鉴价值。
1. 写给做联机卡壳的人:这份 Java 泡泡堂联机版源码能给你什么
很多人做完单机版泡泡堂就以为掌握了游戏开发,可真放到联机上立刻露馅:两个客户端各跑各的,炸弹爆炸时机对不上,角色坐标互相打架。这份 Java 泡泡堂联机版源码解决的正是这件事——它搭好了一套完整的 C/S 骨架,服务端统一裁决位置和炸弹,客户端只负责发操作指令和渲染画面,局域网里开两个客户端就能直接对战。适合刚学完 Java 多线程和 Socket、想拿完整项目上手的人,也适合做课程设计时需要一个可扩展联机底板的开发者。想搞清楚联机游戏消息到底怎么传,这份源码会是一个很好的起步参考。
2. 联机骨架:先定 TCP 连接与消息协议,再往上叠玩法
2.1 线程模型选择:一客户端一线程为什么够用
写服务端第一步不是写玩法,而是定网络模型。常见方案无非三种:单线程轮询、每个客户端一个独立线程、基于 NIO 的 Reactor 模型。单线程轮询写起来最简单,但只要有客户端慢包,整个服务端就跟着阻塞,泡泡堂这种实时对战根本撑不住。Reactor 模型线程利用率高,可代码复杂度和调试成本对入门项目不友好,一个状态没管好就是死锁和空指针的连环坑。
我一般会用每个客户端一个线程的方式。理由很直接:泡泡堂一局最多四到八人,连接数少,每线程阻塞在 read() 上完全没压力;某个客户端断线只会影响自己的线程,不会拖累全局。更重要的一点是,这种模型的心智负担最小——每个 ClientHandler 就是一个独立玩家,逻辑上天然隔离,写起来像在单机版里给每个玩家开了一个事件循环。等整套跑通后再去换 NIO 也来得及。
网络层协议选 TCP 而不是 UDP,也是从泡泡堂的场景出发的。位置和炸弹消息一旦丢包,画面就会瞬移、炸弹会凭空消失,而 TCP 有顺序保证和重传机制,虽然多了一点延迟,但在局域网环境下延迟几乎可忽略。UDP 要自己处理丢包、乱序和重传,这个工作量对入门项目来说完全不划算。
2.2 服务端接入代码与客户端线程实现
先看服务端入口的代码。这份源码里由 GameServer 负责监听端口、接待新连接,并把每个连接包装成一个 ClientHandler 交给独立线程跑。
// 服务端入口:接收连接、分配玩家 ID、启动客户端线程 public class GameServer { private static final int PORT = 8888; // CopyOnWriteArrayList 保证广播遍历时不会抛 ConcurrentModificationException private static final List<ClientHandler> clients = new CopyOnWriteArrayList<>(); private static final Map<Integer, PlayerState> states = new ConcurrentHashMap<>(); private static int nextPlayerId = 1; public static void main(String[] args) throws IOException { ServerSocket serverSocket = new ServerSocket(PORT); System.out.println("[服务端] 已启动,监听 " + PORT + " 端口"); // accept() 是阻塞方法,每来一个客户端就分配一个独立线程 while (true) { Socket socket = serverSocket.accept(); ClientHandler handler = new ClientHandler(socket, nextPlayerId++); clients.add(handler); new Thread(handler).start(); } } public static void broadcast(String message) { for (ClientHandler handler : clients) { handler.send(message); } } }这里有两个关键细节。一是 nextPlayerId 用普通 int 自增,因为 accept() 在主线程单线程执行,不存在竞争;但如果后续把 accept 逻辑拆到多线程,就要换成 AtomicInteger。二是 broadcast() 方法遍历的是 CopyOnWriteArrayList,因为客户端线程在断线时可能并发触发 remove,如果用 ArrayList,遍历或移除时会抛出并发修改异常——这是联机服务端最典型的翻车点之一。
接下来是 ClientHandler 的核心逻辑。它做的事比较简单:读取客户端发来的原始消息,按前缀分发。
public class ClientHandler implements Runnable { private final Socket socket; private final int playerId; private BufferedReader reader; private PrintWriter writer; private volatile boolean running = true; public ClientHandler(Socket socket, int playerId) { this.socket = socket; this.playerId = playerId; } public void send(String msg) { writer.println(msg); } @Override public void run() { try { reader = new BufferedReader( new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8)); writer = new PrintWriter( new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8), true); // 连接建立后,第一时间把玩家编号发给客户端 send("INIT|" + playerId); String line; while (running && (line = reader.readLine()) != null) { handle(line); } } catch (IOException e) { System.out.println("[服务端] 玩家 " + playerId + " 连接异常:" + e.getMessage()); } finally { cleanup(); } } private void handle(String line) { // 只做消息分发,真正的玩法裁决由服务端的 GameLogic 统一处理 System.out.println("[服务端] 收到玩家 " + playerId + " 消息:" + line); if (line.startsWith("MOVE")) { String[] parts = line.split("\\|"); if (parts.length == 3) { GameServer.broadcast("MOVE|" + playerId + "|" + parts[1] + "|" + parts[2]); } } // BOMB、CHAT 等消息类型按同样方式扩展 } private void cleanup() { running = false; GameServer.removeClient(this); } }参数说明:固定使用 UTF-8 是为了中文消息不乱码,PrintWriter 第二个参数传 true 表示自动 flush,保证 println 之后数据立刻写进 socket。handle() 里用 startsWith 做前缀匹配,不用 switch 是因为消息长度短,分支少,字符串前缀判断的可读性更好,而且后续扩展新消息类型只需增加分支。cleanup() 里要手动置 running 为 false 并移除自己的引用,否则线程虽然退出,但列表里残留的对象不会被回收,时间长了会积累内存占用。
2.3 消息协议设计:竖线分隔的文本协议扩展性最好
联机游戏的消息协议有两条路:JSON 字符串或自定义文本协议。JSON 的优点是可读性和结构化强,缺点是序列化开销大,而且 Java 端解析要引第三方库。这份源码用的是竖线分隔的文本协议,格式非常轻量:
INIT|playerId MOVE|playerId|col|row BOMB|playerId|col|row EXPLODE|bombId|col|row|radius选这个格式的另一个原因是调试方便。服务端控制台能直接打印出原始消息流,出问题时拿日志一看就知道是哪一步丢消息或发错字段。消息协议的第一段固定是类型名,后面按字段顺序追加。客户端解析时就 split("\|"),取第一段分支,其余段转成对应参数——效率足够,代码也直观。
协议设计上有两个值得注意的边界。一是字段类型全部用字符串传输,数字类字段在客户端侧解析时做校验,parseInt 失败就丢弃这条消息,不能让它进入游戏逻辑层;二是消息结尾用换行符分隔,每行一条消息,协议里禁止出现换行符,否则 readLine() 会被截断。后来我给这套协议加过心跳消息PING|timestamp,客户端定期发一条,服务端不回,只在服务端记录最近活跃时间,超过 10 秒没收到心跳就强制踢掉连接。这个机制对防止僵尸连接很管用。
3. 玩法核心:地图生成、炸弹计时与碰撞检测的三个关键实现
3.1 地图数据结构:二维数组加随机种子生成
泡泡堂的地图是网格制,天然适合用二维数组表达。这份源码用的是 int 数组,0 代表空地、1 代表可炸砖块、2 代表固定铁块、3 代表出生点。为什么不用 boolean 数组?因为后续要叠加可破砖块、可毁道具、出生点等多种状态,boolean 表达不了;用二维 int 数组还能方便做序列化,重连同步时直接把整个数组拼成字符串发给客户端。
地图尺寸我建议固定用 15 列 11 行,这是泡泡堂比较经典的规格:宽度大一点给跑位留空间,高度适中让炸弹扩散效果不会被边界切得太碎。生成算法遵循一个规则:奇数行列固定放铁块,偶数位置用随机数决定砖块。这样保证地图始终存在一条可以从出生点到达任何位置的路径。
public class GameMap { public static final int COLS = 15; public static final int ROWS = 11; private final int[][] tiles = new int[ROWS][COLS]; // 0=空地 1=可炸砖块 2=固定铁块 3=出生点 public void generate(int seed) { java.util.Random rand = new java.util.Random(seed); for (int row = 0; row < ROWS; row++) { for (int col = 0; col < COLS; col++) { // 奇数列、奇数行固定放铁块,形成天然屏障 if (row % 2 == 1 && col % 2 == 1) { tiles[row][col] = 2; } else if (rand.nextDouble() < 0.35) { tiles[row][col] = 1; } else { tiles[row][col] = 0; } } } // 强制把四个出生点周围清理成空地,避免玩家出生就被堵死 clearSpawn(0, 0); clearSpawn(0, ROWS - 1); clearSpawn(COLS - 1, 0); clearSpawn(COLS - 1, ROWS - 1); } private void clearSpawn(int col, int row) { for (int r = Math.max(0, row - 1); r <= Math.min(ROWS - 1, row + 1); r++) { for (int c = Math.max(0, col - 1); c <= Math.min(COLS - 1, col + 1); c++) { tiles[r][c] = 0; } } } }参数说明:seed 是随机种子,同样种子必然生成同样地图。联机时由服务端生成种子并广播给所有客户端,客户端用同一个种子生成地图,天然保证两端地图一致,省去整张地图的传输。这对重连也很关键,后面第 4 章还会提到。
3.2 炸弹计时与火焰扩散:时间戳比对比倒计时线程靠谱
炸弹逻辑最忌讳的做法是给每个炸弹开一个线程,sleep 三秒后再爆炸。线程多了容易出并发问题,而且 sleep 结束时间并不精确——正好 3000 毫秒时线程被调度可能延迟几十毫秒,一延迟两个客户端的爆炸时机就错开了。这份源码的处理方式用时间戳比对统一裁决,服务端有一个每秒跑一次的检查方法,遍历所有活跃炸弹,当前时间减放置时间超过阈值就触发爆炸。
public class BombManager { private static final int BOMB_DELAY_MS = 3000; private static final int FIRE_RADIUS = 2; private final List<Bomb> activeBombs = new CopyOnWriteArrayList<>(); private final GameMap gameMap; public BombManager(GameMap gameMap) { this.gameMap = gameMap; } public void placeBomb(int playerId, int col, int row) { // 同格已有炸弹就拒绝放置,避免无限叠雷 for (Bomb b : activeBombs) { if (b.col == col && b.row == row) { return; } } activeBombs.add(new Bomb(playerId, col, row, System.currentTimeMillis(), FIRE_RADIUS)); } public void tick() { long now = System.currentTimeMillis(); List<Bomb> exploded = new ArrayList<>(); for (Bomb bomb : activeBombs) { if (now - bomb.placeTime >= BOMB_DELAY_MS) { exploded.add(bomb); } } for (Bomb bomb : exploded) { List<int[]> fires = calcFire(bomb); GameServer.broadcast("EXPLODE|" + bomb.col + "|" + bomb.row + "|" + formatFires(fires)); activeBombs.remove(bomb); } } private List<int[]> calcFire(Bomb bomb) { // 上下左右各扩散 radius 格,遇到墙体或铁块就截断 List<int[]> fires = new ArrayList<>(); fires.add(new int[]{bomb.col, bomb.row}); int[][] dirs = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}}; for (int[] dir : dirs) { for (int step = 1; step <= bomb.radius; step++) { int nc = bomb.col + dir[0] * step; int nr = bomb.row + dir[1] * step; if (nc < 0 || nc >= GameMap.COLS || nr < 0 || nr >= GameMap.ROWS) { break; } int tile = gameMap.tiles[nr][nc]; if (tile == 2) { break; // 铁块不炸穿 } fires.add(new int[]{nc, nr}); if (tile == 1) { gameMap.tiles[nr][nc] = 0; // 砖块被炸掉,变成空地 break; } } } return fires; } }逻辑说明:tick() 方法没有新开线程,而是挂在服务端主循环里每帧调用一次,检查是否有到期的炸弹。炸弹本身只是个数据对象,记录放置时间和爆炸半径。calcFire() 向四个方向逐格扩散,遇到铁块直接停,遇到砖块炸掉并停住,遇到空地继续。火焰坐标列表通过 EXPLODE 消息广播给所有客户端。
参数说明:BOMB_DELAY_MS 是 3000 毫秒,FIRE_RADIUS 是 2 格,这两个值都是可调参数。调大延迟会让节奏变慢,适合新手局;调大扩散半径会让火力范围更猛,比赛节奏更快。同格拒绝放雷是为了防止几个人叠在一起无限放炸弹导致死局。
3.3 移动与碰撞检测:网格对齐是保证两端一致的前提
泡泡堂的角色移动不是像素级自由移动,而是网格跳跃。一个角色要么在一个格子中心,要么正在从一个格子走向另一个格子。实现时只要保证输入方向键后,目标格坐标已经换算成格子位置,再交给服务端校验,就不会出现两端坐标漂移。
public class Player { private int col; private int row; private boolean alive = true; public boolean tryMove(int dx, int dy, GameMap map, List<Player> players) { int nc = this.col + dx; int nr = this.row + dy; // 边界检查 if (nc < 0 || nc >= GameMap.COLS || nr < 0 || nr >= GameMap.ROWS) { return false; } // 墙体检查:砖块和铁块都不能走 int tile = map.tiles[nr][nc]; if (tile == 1 || tile == 2) { return false; } // 其他玩家所在格子不可重叠 for (Player p : players) { if (p != this && p.col == nc && p.row == nr && p.alive) { return false; } } this.col = nc; this.row = nr; return true; } }这里有个容易漏掉的细节:出生点保护。4 个玩家出生在四个角落,如果出生点周边 3x3 范围没有清空,就会出现一开局就有玩家被铁块卡死,连动都动不了。所以游戏初始化时,generateSpawn() 要把出生点周边清理出安全区域。
碰撞检测的服务端裁决还有一层深意:所有移动结果都只能由服务端计算后广播,客户端本地不直接改坐标。如果客户端把自己算好的坐标直接发给别人,就等于给了外挂入口——改一条消息就能穿墙。服务端校验通过后广播的坐标才是唯一合法坐标,客户端只管在收到广播后平滑移动到对应位置。
4. 联机同步常见问题排查:三个翻车场景复盘与修复
4.1 现象:角色在对面屏幕上瞬移闪烁
第一次联调时最容易看到的现象是,本地操作手感正常,但另一个客户端上角色一卡一卡,有时从 A 格瞬间跳到了 C 格,中间 B 格完全没显示。严重时角色还会“倒退”,本地明明往前走了,对面却显示后退一步。
原因有两层。第一层是客户端收到 MOVE 消息后直接把角色坐标设置成目标格,没有做插值渲染,坐标从 (3,4) 跳到 (5,4) 时中间过程完全没有画面过渡。第二层是客户端本地提前做了位置预测,操作方向键后角色立刻移动到了新格子,但这条移动消息要经过服务端校验再广播回来,如果校验不通过(比如有个隐藏砖块),客户端就必须撤回刚才的预测位置,一退一进就造成了闪烁。
解决思路分两步:本地不再预测坐标,只把方向键输入发给服务端,等服务端广播回来再更新;渲染层维护一个“当前坐标 + 目标坐标”的双变量,每帧按步长朝目标点插值。虽然多花了一趟网络往返才看到移动,但换来的是两端画面的绝对一致,闪烁问题从根上消失。这个方法在拼手感上要加一些客户端预测逻辑,但入门项目优先保一致性,不必勉强做预测。
4.2 现象:炸弹爆炸时间两个客户端各说各话
联机测试时另一个高频 bug:玩家 A 的屏幕上火焰已经烧到通道口,玩家 B 的屏幕上炸弹还在倒计时;等 B 端爆炸动画出来时,A 已经被判定死亡。甚至极端情况下,B 端炸掉了一个砖块,A 端那个砖块还在原地,导致地图状态两边不一致。
原因非常明确,炸弹的倒计时被放在了客户端本地。客户端 A 按下空格键后,在自己端开始 3 秒倒计时;客户端 B 收到 BOMB 消息后,也本地起了 3 秒倒计时。但由于网络延迟和线程调度差异,这两个 3 秒的起点不同,终点自然不同。更麻烦的是,如果我们让客户端各自计算爆炸后哪些角色被炸死,那结果也会五花八门。
解决方式就是前面 3.2 节讲的:爆炸判定完全由服务端负责,客户端只负责播放动画。服务端 BombManager 统一记录炸弹放置时间,每帧检查是否有炸弹到期,到期后计算火焰格子,再广播EXPLODE消息。客户端收到 EXPLODE 后一律以消息里的火焰坐标为基准去更新画面,不自己算爆炸。这样无论网络延迟多少,所有客户端都会在同一帧收到同一份爆炸结果,时间差最多一个网络往返。
4.3 现象:断线重连后地图状态与角色位置错乱
测试中另一个常见坑是玩家网络抖动后自动重连,重新进入对局却发现地图和战局状态完全对不上。自己刚复活,但面前一块砖块明明是开局就被炸掉的,现在又变回来了;队友的位置也好几分钟没更新。
原因在于地图生成虽然用了随机种子,但砖块被炸掉后的变化状态没有做全量同步。种子只能保证初始地图一致,不能保证过程状态一致。重连的客户端拿到 INIT 消息后用种子重新生成了一张初始地图,但服务端地图里的砖块已经被炸掉一堆了,两张地图在状态上就产生了偏差。
解决方式是在服务端维护一个 GameState 全量对象,包含当前地图所有格子状态、所有玩家坐标、存活状态、炸弹列表。客户端重连时,服务端不会只用种子发一张初始地图,而是要等重连客户端发送REJOIN请求后,把当前 GameState 序列化成一条完整快照推送下去。快照消息大概长这样:
SNAPSHOT|0,0,1,2,0,0,...|3,4,1,5|1,1,7,3第一段是地图整行拼接,第二段是玩家坐标队列,第三段是炸弹坐标队列。客户端收到快照后整体替换本地状态,之后继续接收增量消息。快照的传输量不大,15 乘 11 的地图拼成字符串也就几百字节,完全不用担心。
5. 扩展改造:把直连对战升级成房间大厅,再加快照同步
如果只是两个人开一局,前面的代码已经够用了。但要做成可以应用的联机游戏,还得有房间系统:玩家不是一进来就绑定对手,而是先进大厅、创建或加入房间、凑满人再开始。我把房间系统加进去之后,整个项目就从“能跑的技术演示”变成了“可玩的对战平台”。
改造的第一步是给所有消息加一个房间号前缀,服务端在分发消息时先按房间号定位到对应玩家列表,再只向这个列表广播。房间管理类需要维护一个 ConcurrentHashMap,键是房间号,值是一个 List 类型的玩家线程列表。创建房间、加入房间、离开房间三个操作各对应一套方法,核心代码片段如下:
public class RoomManager { // 房间号 -> 房间内玩家处理器列表 private static final Map<Integer, List<ClientHandler>> rooms = new ConcurrentHashMap<>(); private static final AtomicInteger roomSeq = new AtomicInteger(1); private static final int MAX_PLAYERS = 4; public static int createRoom(ClientHandler creator) { int roomId = roomSeq.getAndIncrement(); List<ClientHandler> players = new CopyOnWriteArrayList<>(); players.add(creator); rooms.put(roomId, players); return roomId; } public static boolean joinRoom(int roomId, ClientHandler player) { List<ClientHandler> players = rooms.get(roomId); if (players == null || players.size() >= MAX_PLAYERS) { return false; // 房间不存在或已满 } players.add(player); return true; } public static List<ClientHandler> getPlayers(int roomId) { return rooms.getOrDefault(roomId, Collections.emptyList()); } }房间号用 AtomicInteger 自增获取,避免并发创建房间时拿到重复编号。MAX_PLAYERS 设成 4,是因为泡泡堂一局最多 4 人,超过 4 人地图会太挤。加入房间失败的回包状态码建议设计成枚举,不要直接用布尔值返回,这样客户端可以区分“房间不存在”和“房间已满”两种错误,给玩家不同的提示文案。
第二个值得加的功能是消息序号。客户端每发一条指令就带一个自增序号,服务端缓存最近已处理的序号,重复序号直接丢弃。这个机制能消除 TCP 粘包重传时客户端重复收到消息而导致的玩家抖动。
我自己一直在用的一个验证方法是:在服务端加一个调试开关,打开后把所有消息记录到日志文件,按“发送时间-客户端IP-playerId-消息内容”排列。联机排查问题时,把客户端 1 的日志和服务端日志放一起对比,看消息到达顺序是否一致。大多数同步 bug 在这一步就能定位。
快照同步也顺手升级一下:不只是重连时才发快照,每隔 30 秒自动向所有客户端广播一次全量快照,作为兜底。增量消息偶尔丢一两条也不怕,30 秒内的快照会自动纠正一次。从那以后我每次做联机项目,都强制自己把“分钟级快照 + 消息序号 + 服务端裁决”这三件事列进清单,先跑通最小链路再加玩法。这套思路从这份泡泡堂源码里捡到的,后面写其他联机 Demo 也一直用着,希望帮到你。
本文还有配套的精品资源,点击获取