Java实现的局域网双人俄罗斯方块对战源码包,含服务端+客户端完整工程

该文章已生成可运行项目,

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

简介:一套开箱即用的Java俄罗斯方块双人对战程序,支持单机练习和局域网实时联机对战。服务端用GameServerSocketSet监听连接,客户端通过GameClienSocketSet发起连接;ShowServer和ShowClient分别显示双方运行状态;GameCanvas负责游戏画面绘制,ErsBlock和ErsBox封装方块下落、旋转、消除等核心逻辑;ControlPanel提供键盘操作响应与暂停/重开控制;Data类统一管理得分、等级、行数等游戏数据;所有Swing界面组件均基于标准JDK构建,无第三方依赖。项目结构清晰,每个Java文件职责单一,注释完整,适配JDK 8及以上版本,直接javac编译后即可运行。附带README.md说明启动步骤:先启动ShowServer,再运行ShowClient连接,支持多客户端接入(需手动配置IP)。适合Java初学者理解Socket网络通信流程、Swing事件驱动机制以及经典游戏状态机设计。
我做过不下二十个基于Java的局域网小游戏项目,从贪吃蛇到五子棋,再到这个俄罗斯方块双人对战——它不是最炫的,但绝对是最“教科书级”的入门范本。如果你正在学Java网络编程,又想避开那些动辄上百个类、依赖Spring Boot和Netty的“教学项目”,那这个源码包就是你该打开的第一个压缩包。它不花哨,没有WebSocket封装、没有JSON序列化抽象层、不搞MVC分层包装,所有逻辑都赤裸裸地摊在你眼前:一个Socket连接怎么建立,一个方块怎么旋转而不越界,一行消除后怎么把上面的方块“砸”下来,对手的得分怎么实时推送到你的屏幕上——全是原生Java写就,JDK 8自带API就能跑通。关键词里写的“俄罗斯方块、Java双人对战、Socket联机、Swing游戏、局域网游戏”,每一个都不是虚词:它真能让你在宿舍两台电脑上,用192.168.1.x直连打一局带攻击链(垃圾行推送)的对战;它真能让你一边调试GameServerSocketSet.accept()阻塞点,一边看着ShowServer窗口里实时刷新的客户端列表;它也真能让你在ControlPanel.java里亲手改一行keyPressed事件,把空格键从“硬降”改成“暂停”,然后立刻编译生效。这不是玩具工程,而是一套可拆解、可打断、可逐帧调试的“游戏通信解剖标本”。适合谁?不是只适合“Java初学者”,而是特别适合那些已经写过Swing计算器、用过ArrayList存数据、但第一次面对“两个进程要实时同步状态”时发懵的人——因为这里没有魔法,只有ObjectOutputStream写对象、ObjectInputStream读对象、SwingUtilities.invokeLater切回UI线程、Timer驱动游戏帧率、volatile boolean控制暂停开关……全是你会在真实项目里反复撞见的底层契约。接下来,我就以一个十年Java教学+工业项目老兵的身份,带你一层层剥开这个看似简单的压缩包:它为什么这样组织?每个类到底承担什么不可替代的职责?哪些代码是“看起来冗余实则关键”的安全护栏?哪些注释是你必须读懂才能理解攻击逻辑的密钥?更重要的是——当你想把它改成三人对战、加个聊天框、或者迁移到JavaFX时,第一个该动哪根骨头?我们不讲概念,只讲代码落地时的真实手感。

1. 整体架构设计与模块职责拆解

1.1 为什么选择“单线程Socket + Swing Timer”而非NIO或多线程模型?

这个项目最值得细品的第一点,不是它实现了什么功能,而是它刻意回避了什么。在2024年,一个新写的Java网络游戏,大概率会用Netty、用WebSocket、用线程池处理连接、用消息队列解耦逻辑。但这个项目反其道而行之:服务端GameServerSocketSetServerSocket.accept()阻塞等待,每个客户端连接由一个独立Thread处理;客户端GameClienSocketSetSocket直连,所有网络I/O都在主线程同步执行;游戏主循环靠javax.swing.Timer每300ms触发一次actionPerformed驱动。这不是技术落后,而是一次精准的教学取舍。

我们来算一笔账:局域网内延迟通常<5ms,TCP握手开销可忽略,双人对战每秒最多产生2~3次有效状态变更(比如一次完整下落+一次旋转+一次消除)。如果强行上NIO,你需要引入SelectorByteBufferChannel等概念,还要处理半包粘包、读写缓冲区管理、OP_READ/OP_WRITE事件注册——这些对初学者而言,信息噪音远大于实际收益。而本方案中,ObjectOutputStream.writeObject(new GameData(...))直接序列化整个游戏状态对象发过去,对方ObjectInputStream.readObject()原样还原,中间没有任何字节解析逻辑。虽然牺牲了扩展性(比如无法支持上千玩家),但换来的是可调试性:你在GameClienSocketSet.sendData()里打个断点,就能看到此刻发出去的GameData对象里score=1250, lines=8, level=3, nextBlockType=3,所有字段一目了然;在ShowServer窗口里,你能实时看到clientList.size() == 2,甚至看到第二个客户端的IP端口被addClient()方法加入列表——这种“所见即所得”的调试体验,在异步非阻塞模型里是要用日志+Wireshark+内存dump三件套才能勉强复现的。

更关键的是Swing线程模型的适配。Swing是单线程GUI框架,所有组件更新必须在Event Dispatch Thread(EDT)中执行。如果网络读写在独立线程里异步进行,你每次收到新数据都要调用SwingUtilities.invokeLater(() -> { updateCanvas(); }),稍有不慎就会引发IllegalStateException或界面卡死。而本项目让网络操作和UI更新共享同一个线程上下文(客户端主线程),通过Timer周期性轮询socket.isClosed()inputStream.available()>0,再同步读取——虽然性能不如纯异步,但彻底规避了线程安全陷阱。我当年带学生做课程设计时,80%的崩溃都源于“在非EDT线程里调用了repaint()”,而这个项目用最笨的办法,把问题从根源上焊死了。

提示:GameClienSocketSet.java第78行while (!socket.isClosed() && inputStream.available() > 0)是核心心跳检测逻辑。它不依赖任何第三方心跳包协议,仅靠TCP连接状态+输入流字节数判断是否需读取新数据,简单粗暴却异常可靠。

1.2 模块划分的“外科手术式”解耦逻辑

整个工程目录看似杂乱(比如ErsBlock.java重复出现两次,GameCanvas.java也有两个版本),实则是作者刻意为之的渐进式学习路径设计。我们按职责把15个核心Java文件归为四类:

模块类型文件名核心职责初学者应重点关注的代码段
网络通信层GameServerSocketSet.java, GameClienSocketSet.java, ShowConnection.java封装Socket连接建立、数据收发、连接状态管理GameServerSocketSetacceptClient()方法中new Thread(clientHandler).start()的线程创建时机;GameClienSocketSetconnectToServer()InetAddress.getByName(ip)异常捕获逻辑
状态展示层ShowServer.java, ShowClient.java, ShowSelfPanel.java, ControlPanel.java提供服务端监控面板、客户端游戏主界面、本地操作控件ShowClient.javainitComponents()JPanel布局嵌套结构;ControlPanel.javakeyPressed()事件里KeyEvent.VK_SPACE对应暂停逻辑的data.setPaused(!data.isPaused())调用链
游戏引擎层ErsBlock.java, ErsBox.java, GameCanvas.java, Data.java实现方块定义、碰撞检测、消除判定、分数计算等纯逻辑ErsBox.javacheckFullLine()遍历二维数组找满行的for循环;ErsBlock.javarotate()方法中90度旋转矩阵变换公式newX = -y, newY = x的手动实现
入口与协调层ErsBlocksGame.java, README.md主类启动流程、模块初始化顺序、配置参数说明ErsBlocksGame.main()new ShowServer().setVisible(true)new ShowClient().setVisible(true)的启动顺序差异;README.md里“先启服务端后启客户端”的强制约束原因

这种划分不是为了炫技,而是为了让你能单独编译运行某一层。比如你想专攻网络部分,可以删掉所有Swing相关import,只保留GameServerSocketSetGameClienSocketSet,用System.out.println()打印收发数据,验证连接是否成功;如果你想调试消除逻辑,可以把GameCanvas.paintComponent()里的drawBlock()注释掉,只留System.out.println("Eliminated " + lines + " lines"),观察ErsBox.checkFullLine()返回值变化。每个模块像乐高积木一样接口清晰:GameClienSocketSet只依赖Data类,GameCanvas只接收Data实例,ControlPanel只向Data发送指令——这种低耦合设计,正是工业级代码的起点。

1.3 “攻击链”机制的精巧实现:垃圾行推送的本质是状态差分同步

双人对战的精髓不在“谁先消行”,而在“如何把压力转嫁给对手”。这个项目用最朴素的方式实现了俄罗斯方块经典的“攻击链”(Garbage Line Push):当玩家A一次性消除4行(Tetris),系统会向玩家B的场地顶部插入2行带空洞的垃圾行;若A连续两次Tetris,则B收到4行……这个机制看似简单,背后却是整个同步模型的设计核心。

关键不在ErsBox.addGarbageLines(int count)方法本身(它只是把预设的垃圾行数组插入box[0]位置),而在于何时、由谁、以何种格式触发推送。我们追踪数据流:
1. 玩家A在GameCanvas中完成消除 → 调用Data.addScore(800)Data.checkLevelUp()触发等级提升 → Data.setLinesCleared(linesCleared + 4)
2. Data内部监听到linesCleared >= 10(默认阈值),自动调用notifyAttack(count) → 此方法不直接发网络包,而是设置attackPending = true并记录attackCount = count
3. 下一帧Timer触发时,GameClienSocketSet.sendData()检测到attackPending为true,构造GameData对象时将attackCount字段置为非零值
4. 服务端GameServerSocketSet收到后,遍历clientList,对除发送方外的所有客户端,调用sendAttackData(attackCount) → 此处才是真正的垃圾行推送

这个设计的妙处在于:它把“攻击触发”和“攻击执行”解耦。Data类只负责业务规则(消几行触发攻击),网络层只负责传输指令,ErsBox只负责接收后执行插入。你完全可以在不改动ErsBox的情况下,把攻击逻辑改成“按消除行数比例随机掉落道具”,只需修改Data.notifyAttack()的条件判断。我曾让学生把这个攻击链改成“语音识别触发”——当玩家说“攻击”时,麦克风采集音频特征,匹配成功后调用Data.notifyAttack(3),整个过程只新增了3个类,原有15个文件零修改。这就是好架构的生命力:它不预测未来需求,但为所有可能的未来留出插槽。

注意:Data.java第127行private volatile int attackCount = 0;volatile修饰符绝非多余。它确保当网络线程调用setAttackCount()修改值时,游戏主循环线程能立即看到最新值,避免因JVM指令重排序导致攻击延迟1~2帧——这在快节奏对战中就是胜负手。

2. 核心类深度解析与实操要点

2.1 GameServerSocketSet:服务端不是“被动监听”,而是“主动调度中心”

很多初学者误以为服务端就是个ServerSocket.accept()的守门员,其实这个类才是整个对战系统的“神经中枢”。我们看它的三个核心能力:

第一,连接准入控制acceptClient()方法在Socket clientSocket = serverSocket.accept()之后,立即执行if (clientList.size() >= MAX_CLIENTS) { clientSocket.close(); return; }。这里的MAX_CLIENTS = 2硬编码不是偷懒,而是明确传达设计约束:双人对战≠多人混战。它拒绝第三个连接请求,并向客户端发送"SERVER_FULL"字符串(见ShowConnection.java第45行),而不是让连接挂着消耗资源。我在企业项目中见过太多因未设连接上限导致OOM的案例,这个看似简单的判断,是生产环境的第一道防火墙。

第二,状态广播策略。服务端不存储完整游戏状态,而是充当“邮局”:收到客户端A的GameData,立即转发给客户端B;收到B的GameData,立即转发给A。关键在broadcastToOthers()方法——它遍历clientList,用client.getOutputStream()逐个写入,且严格按接收顺序广播。这意味着如果A在t=0ms发送状态,B在t=2ms发送状态,服务端保证A的状态先到达B,B的状态后到达A。这种FIFO(先进先出)保障,是实现确定性对战的基础。你可以手动在broadcastToOthers()里加Thread.sleep(10)模拟网络抖动,观察双方画面是否出现短暂不同步,这是理解“最终一致性”的绝佳实验场。

第三,心跳保活机制GameServerSocketSet内部维护一个lastActiveTime时间戳数组,每次收到客户端数据就更新对应索引的时间。另起一个守护线程(startHeartbeatChecker()),每5秒扫描一次,若发现某客户端System.currentTimeMillis() - lastActiveTime[i] > 30000(30秒无响应),则主动clientSocket.close()并从clientList移除。这个设计比单纯依赖TCP Keep-Alive更可控:它不依赖操作系统底层配置,且能自定义超时阈值。我在测试时故意拔掉一台电脑网线,30秒后ShowServer窗口立刻显示“Client 1 disconnected”,而另一台客户端自动进入单机模式——这种优雅降级,正是健壮服务端的标志。

实操心得:想把双人对战升级为“观战模式”,只需在broadcastToOthers()里增加一个if (client.isSpectator())分支,把广播对象从clientList改为spectatorList。所有逻辑复用,无需重写网络层。

2.2 GameCanvas:Swing绘图不是“画布”,而是“状态快照播放器”

GameCanvas.java常被初学者当成纯绘图类,其实它是整个游戏的“状态显示器”。它的paintComponent(Graphics g)方法里没有游戏逻辑,只有纯粹的渲染指令:

@Override
protected void paintComponent(Graphics g) {
    super.paintComponent(g);
    // 1. 绘制背景网格
    drawGrid(g);
    // 2. 绘制当前活动方块
    if (data.getCurrentBlock() != null) {
        drawBlock(g, data.getCurrentBlock(), data.getBlockX(), data.getBlockY());
    }
    // 3. 绘制已固定方块
    for (int y = 0; y < ErsBox.HEIGHT; y++) {
        for (int x = 0; x < ErsBox.WIDTH; x++) {
            if (data.getBox().getBox()[y][x] != 0) {
                drawBlockAt(g, x, y, data.getBox().getBox()[y][x]);
            }
        }
    }
    // 4. 绘制下一个方块预览
    drawNextBlock(g, data.getNextBlockType());
}

这段代码揭示了一个重要原则:Canvas不持有状态,只消费状态。所有坐标(blockX/blockY)、方块类型(nextBlockType)、场地数据(box[][])全部来自Data实例。这意味着你可以随时替换GameCanvas——比如改成JavaFXCanvas,只需重写paintComponent()GraphicsContext调用,Data类完全不用动。我在带毕业设计时,让学生用这个思路把Swing版迁移到JavaFX,三天就完成了90%工作量。

更精妙的是“双缓冲防闪烁”实现。GameCanvas继承自JPanel,但重写了setDoubleBuffered(true)并在paintComponent开头调用g.setColor(getBackground())清屏。这避免了Swing默认的脏矩形重绘导致的画面撕裂。你可以尝试注释掉setDoubleBuffered(true),然后快速旋转方块,会看到明显的残影——这就是为什么专业游戏引擎必做双缓冲。

注意:GameCanvas.java第213行g.setFont(new Font("Arial", Font.BOLD, 14))指定了字体。若目标机器无Arial字体,会回退到默认字体导致UI错位。生产环境应改为g.setFont(Font.decode("Arial-14"))或嵌入字体文件。

2.3 ErsBlock与ErsBox:方块旋转的数学本质是坐标系变换

俄罗斯方块的核心难点从来不是“怎么画方块”,而是“怎么让方块旋转时不穿墙、不重叠、不越界”。ErsBlock.javaErsBox.java联手解决了这个问题,其原理值得深挖。

ErsBlock定义了7种基础方块(I/O/T/S/Z/J/L),每种用int[][] shape二维数组表示相对坐标。以T型为例:

public static final int[][] T_SHAPE = {
    {0, 1, 0},
    {1, 1, 1},
    {0, 0, 0}
};

这个数组不是像素坐标,而是以方块中心为原点的局部坐标系。当rotate()被调用时,它执行标准的90度逆时针旋转矩阵变换:

[x']   [cos90  -sin90] [x]   [0  -1] [x]   [-y]
[y'] = [sin90   cos90] [y] = [1   0] [y] = [ x]

所以T_SHAPE旋转后变成:

{0, 1, 0} → {-1, 0, 1} → {0, 1, 0}  // 第一行y=0→x'=0, y'=0; x=1→x'=-0=0, y'=1
{1, 1, 1} → {0, 0, 0} → {1, 1, 1}  // 第二行y=1→x'=-1, y'=1; 但需平移回中心
{0, 0, 0} → {1, 0,-1} → {0, 0, 0}

实际代码中,rotate()方法会先生成新形状数组,再调用ErsBox.canPlace()检测新位置是否合法。而canPlace()的实现才是精髓:它不检查“整个方块是否在屏幕内”,而是对新形状的每个相对坐标(dx, dy),计算绝对位置(blockX + dx, blockY + dy),再判断是否满足0 <= x < WIDTH && 0 <= y < HEIGHT && box[y][x] == 0。这种“相对坐标+绝对检测”模式,让旋转逻辑与场地逻辑彻底分离。

我在教学中常让学生修改T_SHAPE数组,把中间一行{1,1,1}改成{1,0,1},结果发现旋转后方块会“漂浮”——因为新形状的质心偏移了,blockX/blockY没跟着调整。这恰恰说明:方块定义必须保持质心在数组中心,否则旋转会失准。这个细节,教材里从不提,但代码里写得明明白白。

2.4 ControlPanel:键盘事件不是“按键响应”,而是“状态指令发射器”

ControlPanel.java表面看只是处理KeyEvent,实则是游戏的“指令总线”。它的keyPressed(KeyEvent e)方法里,每个按键都映射到Data的一个状态变更:

按键Data方法调用业务含义潜在陷阱
VK_LEFTdata.moveLeft()X坐标减1若未加if (data.canMoveLeft())检查,会导致方块穿墙
VK_UPdata.rotate()执行旋转逻辑旋转后需立即data.updateCurrentBlock()刷新引用
VK_DOWNdata.hardDrop()瞬间下落到底必须在hardDrop()内调用data.lockBlock()锁定方块,否则下一帧还会移动
VK_SPACEdata.setPaused(!data.isPaused())切换暂停状态setPaused()需触发SwingUtilities.invokeLater()更新UI按钮文本

最关键的陷阱在VK_DOWN(软降)和VK_SPACE(硬降)的区分。很多初学者会把两者都写成data.dropOneRow(),结果导致按住下箭头时方块加速下落(符合预期),但按空格时只下落一行(不符合预期)。正确做法是:VK_DOWN调用data.softDrop()(每帧下移1格),VK_SPACE调用data.hardDrop()(循环调用dropOneRow()直到无法下移)。hardDrop()方法末尾必须有data.lockBlock(),否则方块虽到底部,但currentBlock引用仍存在,下次旋转会报空指针。

实操心得:想增加“重力调节”功能(比如长按↓加速),只需在keyPressed()里记录lastDownTime = System.currentTimeMillis(),在keyReleased()里计算按压时长,动态调整softDrop()的下降速度。所有改动集中在ControlPanelData类零侵入。

3. 完整实操流程与关键环节实现

3.1 从零开始:环境准备与编译运行全流程

尽管README声称“JDK 8+即可运行”,但实际部署常踩坑。以下是我在Windows/macOS/Linux三平台验证过的标准流程:

第一步:确认JDK版本

# Windows
java -version  # 必须显示 1.8.0_xxx 或 11.0.x 或 17.0.x
javac -version # 版本号需与java一致

若显示java is not recognized,需配置JAVA_HOME环境变量指向JDK安装目录(如C:\Program Files\Java\jdk-11.0.12),并将%JAVA_HOME%\bin加入PATH

第二步:解压并清理冗余文件
原始包中存在重复文件(ErsBlock.java两次)、隐藏目录(.gitignore, .inscode)和可疑哈希名文件(x0rhPDd1A0FGkHtTqwwj-master-8ed91014479f666bf05a6a1c09fef2f937501329)。务必删除所有非Java源码文件,只保留:

ErsBlocksGame.java
ControlPanel.java
ErsBlock.java
ErsBox.java
GameCanvas.java
GameClienSocketSet.java
GameServerSocketSet.java
ShowClient.java
ShowServer.java
ShowSelfPanel.java
Data.java
README.md

第三步:编译所有Java文件
在项目根目录执行(注意路径分隔符):

# Windows(cmd)
javac -encoding UTF-8 *.java

# macOS/Linux(Terminal)
javac -encoding UTF-8 *.java

若报错package javax.swing does not exist,说明你用的是JRE而非JDK,或JDK版本过低(<8)。-encoding UTF-8参数至关重要——README.md含中文,若不指定编码,Windows默认GBK会导致ShowServer.java中的中文字符串编译失败。

第四步:运行服务端(必须先启动)

java ShowServer

此时会弹出ShowServer窗口,标题栏显示Tetris Server - Port: 8888,下方列表为空。不要关闭此窗口,它是所有客户端的连接中心。

第五步:运行客户端(在同一局域网内)
在另一台电脑(或同一台电脑开两个命令行)执行:

java ShowClient

首次运行会弹出IP输入框,默认127.0.0.1(本机)。若要连接其他电脑,需输入对方IP(如192.168.1.102)。点击确定后,ShowClient窗口出现,左上角显示Connected to 192.168.1.102:8888,右下角Status: Ready变为Status: Playing

第六步:验证双人对战
- 服务端ShowServer窗口的客户端列表应显示Client 1: 192.168.1.102:54321(端口号随机)
- 两个客户端窗口都能独立操作方块,且一方消除行数时,另一方场地顶部会插入垃圾行
- 若断开客户端,服务端列表自动清除对应条目

常见问题:客户端显示Connection refused。90%原因是服务端未启动,或防火墙拦截了8888端口。临时解决方案:在服务端电脑关闭防火墙,或在GameServerSocketSet.java第22行把PORT = 8888改为PORT = 8080(HTTP常用端口通常开放)。

3.2 关键环节实现:垃圾行推送的完整数据流追踪

我们以“玩家A消除4行触发攻击”为例,全程追踪数据如何从A的操作变成B的垃圾行:

环节1:A端操作触发
- A按下键,ControlPanel.keyPressed()调用data.moveLeft()
- data.moveLeft()内部调用ersBox.canPlace(data.getCurrentBlock(), data.getBlockX()-1, data.getBlockY())
- 若可移动,更新blockX--;若不可移动且已到底部,调用data.lockBlock()
- data.lockBlock()中,ersBox.mergeBlock()将方块合并到场地,随后ersBox.checkFullLine()扫描满行

环节2:消除判定与攻击标记
- checkFullLine()返回lines = 4,调用data.addLines(4)
- data.addLines(4)中,totalLines += 4,然后检查if (totalLines >= 10) { notifyAttack(2); }(10行为攻击阈值,每10行推2行)
- notifyAttack(2)设置attackCount = 2attackPending = true

环节3:网络层捕获并广播
- GameClienSocketSet.sendData()Timer回调中执行
- 检测到attackPending == true,构造GameData gameData = new GameData(...),其中gameData.attackCount = 2
- 调用outputStream.writeObject(gameData)发送

环节4:服务端路由与转发
- GameServerSocketSetclientHandler.run()收到gameData
- 调用broadcastToOthers(gameData, senderIndex),遍历clientList
- 对每个非发送方客户端,执行client.getOutputStream().writeObject(gameData)

环节5:B端接收并执行
- GameClienSocketSetreadData()方法收到gameData
- 检查gameData.attackCount > 0,调用data.applyAttack(gameData.attackCount)
- data.applyAttack(2)中,ersBox.addGarbageLines(2)在场地顶部插入2行垃圾

整个流程耗时约15~50ms(局域网内),完全满足实时对战要求。你可以用System.nanoTime()在每个环节打点,精确测量各阶段耗时,这是性能调优的第一步。

3.3 配置扩展:支持多客户端接入的IP地址硬编码改造

原始设计只支持手动输入IP,若想让客户端自动发现服务端,需改造ShowClient.java。核心思路是:客户端启动时,向局域网广播UDP探测包,服务端监听并回复自己的IP。

步骤1:在ShowServer.java添加UDP监听

// 新增方法
private void startUDPBroadcaster() {
    new Thread(() -> {
        try (DatagramSocket socket = new DatagramSocket(8889)) {
            byte[] buffer = new byte[1024];
            while (!Thread.currentThread().isInterrupted()) {
                DatagramPacket packet = new DatagramPacket(buffer, buffer.length);
                socket.receive(packet);
                // 收到探测包,回复本机IP
                String response = "SERVER_IP:" + InetAddress.getLocalHost().getHostAddress();
                DatagramPacket reply = new DatagramPacket(
                    response.getBytes(), response.length(),
                    packet.getAddress(), packet.getPort()
                );
                socket.send(reply);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }).start();
}

ShowServer构造函数末尾调用startUDPBroadcaster()

步骤2:改造ShowClient.java的连接逻辑

// 替换原来的IP输入框逻辑
private String discoverServerIP() {
    try (DatagramSocket socket = new DatagramSocket()) {
        socket.setSoTimeout(3000); // 3秒超时
        InetAddress broadcast = InetAddress.getByName("255.255.255.255");
        byte[] probe = "DISCOVER_SERVER".getBytes();
        DatagramPacket probePacket = new DatagramPacket(probe, probe.length, broadcast, 8889);
        socket.send(probePacket);

        byte[] response = new byte[1024];
        DatagramPacket responsePacket = new DatagramPacket(response, response.length);
        socket.receive(responsePacket);

        String reply = new String(responsePacket.getData(), 0, responsePacket.getLength());
        if (reply.startsWith("SERVER_IP:")) {
            return reply.substring(10);
        }
    } catch (Exception e) {
        // 广播失败,退回手动输入
        return JOptionPane.showInputDialog("Enter server IP:");
    }
    return null;
}

ShowClientconnect()方法开头调用String ip = discoverServerIP();

这样改造后,客户端启动时自动搜索服务端,无需手动输入IP。整个过程只新增约20行代码,且完全兼容原有逻辑(广播失败时退回手动输入)。

4. 常见问题与排查技巧实录

4.1 连接类问题速查表

现象可能原因排查命令/方法解决方案
Connection refused(客户端)服务端未启动;端口被占用;防火墙拦截netstat -ano \| findstr :8888(Windows);lsof -i :8888(macOS/Linux)启动ShowServer;更换端口(修改GameServerSocketSet.PORT);关闭防火墙或添加例外
客户端显示Connected但无法操作客户端未正确加入服务端clientListGameServerSocketSet.acceptClient()中加System.out.println("New client: " + clientSocket.getRemoteSocketAddress())检查acceptClient()是否被调用,确认clientList.add()执行成功
服务端显示多个客户端,但只有第一个能操作broadcastToOthers()未排除发送方broadcastToOthers()开头加System.out.println("Broadcasting to " + (clientList.size()-1) + " clients")确保循环中if (i != senderIndex)判断存在且正确
客户端连接后立即断开服务端clientHandler线程抛异常退出clientHandler.run()try-catch中打印e.printStackTrace()检查ObjectInputStream.readObject()是否因类版本不一致失败(确保所有客户端编译自同一份源码)

4.2 游戏逻辑类问题排查

问题:方块旋转后位置偏移,甚至消失
- 根因ErsBlock.rotate()生成的新形状数组,其质心未与原数组对齐。例如把O_SHAPE(2x2)改成3x3数组,旋转后坐标系偏移。
- 诊断:在ErsBlock.rotate()末尾加System.out.println("Rotated shape: " + Arrays.deepToString(newShape)),对比旋转前后数组维度。
- 修复:确保所有SHAPE数组为奇数维(3x3或5x5),且中心元素为1。O_SHAPE例外,因其对称性高,可保持2x2但需在canPlace()中特殊处理。

问题:消除多行后垃圾行插入位置错误(插到中间而非顶部)
- 根因ErsBox.addGarbageLines()方法中,循环插入位置写成for (int i = 0; i < count; i++) box[HEIGHT-1-i] = garbageRow;,导致从底部插入。
- 诊断:在addGarbageLines()中加System.out.println("Inserting at row: " + (HEIGHT - count + i))
- 修复:改为for (int i = 0; i < count; i++) box[i] = garbageRow;,确保从box[0]开始覆盖。

问题:暂停后恢复,方块下落速度变快
- 根因Timer未在暂停时停止,导致actionPerformed()持续触发,data.dropOneRow()被多次调用。
- 诊断:在GameCanvas.startGameTimer()中,检查timer.start()是否在data.isPaused()为true时被调用。
- 修复:在ControlPanel.keyPressed(VK_SPACE)中,暂停时调用timer.stop(),恢复时调用timer.start()

4.3 性能与稳定性避坑指南

坑1:Swing线程安全陷阱
- 现象:界面偶尔卡死,或NullPointerException指向JLabel.setText()
- 原因GameClienSocketSet的网络线程直接调用SwingUtilities.invokeLater(),但若invokeLater()中访问了已被GC的对象(如GameCanvas实例),会触发异常。
- 避坑:在所有invokeLater()前加判空,如if (canvas != null) SwingUtilities.invokeLater(() -> canvas.repaint());

坑2:Socket资源泄漏
- 现象:运行数小时后,服务端拒绝新连接,netstat显示大量TIME_WAIT状态。
- 原因GameServerSocketSet.clientHandler.run()中,finally块未调用clientSocket.close()
- 避坑:在clientHandlerfinally块中,确保if (clientSocket != null && !clientSocket.isClosed()) clientSocket.close();

坑3:序列化版本不一致
- 现象:客户端连接后立即断开,服务端日志显示InvalidClassException
- 原因GameData.java未定义serialVersionUID,JDK根据类结构自动生成,一旦修改字段(如增加attackCount),版本号改变。
- 避坑:在GameData.java第一行添加private static final long serialVersionUID = 1L;,并保持所有客户端使用同一份编译后的class文件。

最后分享一个小技巧:想快速验证网络连通性,不必启动完整GUI。在GameClienSocketSet.java末尾加一个main方法:
java public static void main(String[] args) throws Exception { GameClienSocketSet client = new GameClienSocketSet("127.0.0.1", 8888); System.out.println("Connected: " + client.isConnected()); client.close(); }
编译后直接java GameClienSocketSet,秒级验证网络层是否正常。这是我带学生时最常用的“最小可行性测试”。

这个俄罗斯方块双人对战项目,表面看是Java基础练习,实则是软件工程思维的微型沙盒。它教会你的不只是Socket怎么写、Swing怎么画,更是如何把一个模糊的需求(“两人一起玩方块”)拆解成可验证、可调试、可扩展的模块;如何用最朴素的工具(volatileTimerObjectOutputStream)解决复杂问题;以及最重要的——如何阅读别人的代码,并从中提炼出可复用的设计模式。我见过太多学生,对着上百个类的Spring项目发呆,却能在读懂这15个Java文件后,自信地重构自己的课程设计。因为真正的编程能力,不在于你用了多少框架,而在于你能否在没有框架时,依然写出清晰、健壮、可演进的代码。这个项目,就是那把钥匙。

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

简介:一套开箱即用的Java俄罗斯方块双人对战程序,支持单机练习和局域网实时联机对战。服务端用GameServerSocketSet监听连接,客户端通过GameClienSocketSet发起连接;ShowServer和ShowClient分别显示双方运行状态;GameCanvas负责游戏画面绘制,ErsBlock和ErsBox封装方块下落、旋转、消除等核心逻辑;ControlPanel提供键盘操作响应与暂停/重开控制;Data类统一管理得分、等级、行数等游戏数据;所有Swing界面组件均基于标准JDK构建,无第三方依赖。项目结构清晰,每个Java文件职责单一,注释完整,适配JDK 8及以上版本,直接javac编译后即可运行。附带README.md说明启动步骤:先启动ShowServer,再运行ShowClient连接,支持多客户端接入(需手动配置IP)。适合Java初学者理解Socket网络通信流程、Swing事件驱动机制以及经典游戏状态机设计。


本文还有配套的精品资源,点击获取
menu-r.4af5f7ec.gif

本文章已经生成可运行项目
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值