从零开发Java坦克大战Ⅱ (下)-- 从单机到联机(完整架构功能实现)

引言:

随着代码量的增加, 在项目重温的文章中我尽可能以需求实现与技术选型的思路为线索进行演示, 而非粗暴的功能代码罗列, 这也是一个优秀的技术人员博客撰写的基本素养.

完整的源码在我的GitHub中: http://github.com/xhz-fake/TankWarhttp://github.com/xhz-fake/TankWar

1. 第一件事: 把C-S (Socket) 连接建立起来

1.1 核心类及实现: NetworkSetupDialog

复制代码
#### **1.1.1 角色选择:**

> * 为了在游戏开始时人性化的建立连接并选取自己想要承担的角色, 我们继承JDialog类进行开发
> * 在构造器中, 以最外层JFrame中作为父组件, 编写基本的界面属性, 需要两个按钮来选择即: Host / Client , 为他们增加监听器(直接使用Lambda表达式进行构造) , 当点击对应的按钮时**将主机的属性赋值为isHost 或 ! isHost** 并 **将判断是否为服务器的方法公开**
复制代码
#### **1.1.2 实现在客户端输入服务端的IP地址建立连接**

> * 当你选择Client时: 将弹出一个**"**InputDialog " 组件, 如果输入为空, 我们设置其默认值为 "localhost"
> * 代码如下:
> * 自此: 借助ObjectInputStream / ObjectOutputStream 类以及实现序列化接口 Serializable 两个工具, C-S 连接的基础设置就完成了
> *
>
>   ```java
>   JButton hostButton = new JButton("Host Game");
>    hostButton.addActionListener(e -> {
>       isHost = true;
>       setVisible(false);
>    });
>
>   JButton joinButton = new JButton("Join Game");
>   joinButton.addActionListener(e -> {
>       isHost = false;
>       serverIP = JOptionPane.showInputDialog(this, "请键入服务端IP地址: ", "localhost");
>       if (serverIP == null || serverIP.trim().isEmpty()) {//.trim 去除了字符两端空白字符(空格,Tab,\n等)
>           serverIP = "localhost";
>       }
>       setVisible(false);
>   });
>   ```
复制代码
#### **1.1.3 在gamePanel中初始化网络(C-S连接)**

> * 核心方法展示: 这里代码写的很清晰, 易懂
> *
>
>   ```java
>   private void initNetwork() {//初始化CS架构
>       try {
>           if (isHost) {
>               //作为主机
>               serverSocket = new ServerSocket(8881);
>               System.out.println("等待客户端连接中...");
>               socket = serverSocket.accept();//进入阻塞直到返回一个Socket对象
>               System.out.println("客户端已连接!");
>
>               out = new ObjectOutputStream(socket.getOutputStream());
>               in = new ObjectInputStream(socket.getInputStream());
>           } else {
>               //作为客户端
>               System.out.println("正在连接服务器: " + serverIP);
>               socket = new Socket(serverIP, 8881);
>               System.out.println("已连接到服务器!");
>
>               in = new ObjectInputStream(socket.getInputStream());
>               out = new ObjectOutputStream(socket.getOutputStream());
>
>               // 客户端等待初始位置,即客户端的坦克位置由主机确定并与之同步
>               Object initObj = in.readObject();
>               if (initObj instanceof InitialPosition init) {//接收到服务端发送的初始位置, 客户端初始化两个坦克
>                   tankA.setX(init.tankAX);
>                   tankB.setX(init.tankBX);
>                   tankA.setY(init.tankAY);
>                   tankB.setY(init.tankBY);
>
>                   //保存初始位置
>                   prevTankAX = tankA.getX();
>                   prevTankAY = tankA.getY();
>                   prevTankBX = tankB.getX();
>                   prevTankBY = tankB.getY();
>               }
>           }
>
>           //启动网络线程
>           networkThread = new Thread(this::networkReceiveLoop);
>           networkThread.setDaemon(true); //作用:将networkThread设置为守护线程,JVM会在所有用户线程结束后终止所有守护线程
>           // ,适用于后台支持性任务(如网络心跳、日志写入、监控等),这些线程不应该阻止程序退出
>           networkThread.start();
>
>       } catch (Exception e) {
>           JOptionPane.showMessageDialog(this, "网络异常:  " + e.getMessage(),
>                   "连接失败!", JOptionPane.ERROR_MESSAGE);
>           System.exit(1);
>       }
>   }
>   ```
>
> *
>
>   ```java
>   private void networkReceiveLoop() {//处理网络循环的核心方法
>           try {
>               while (running.get() && !Thread.currentThread().isInterrupted()) {//只要C-S的连接仍然存在并且当前网络线程未中断就一直读取并处理信息
>                   try {
>                       if (in == null) {
>                           break;
>                       }
>                       Object obj = in.readObject();//获取读入流中的对象(一共有三个需要获取即客户端输入的键,网络信息(开火和发消息), 游戏状态)
>                       // 1. 处理网络重置信号
>                       if (obj instanceof NetworkResetSignal) {
>                           pressedKeys.clear();
>                           inputQueue.clear();
>                           resetTankMovement();
>                           continue;//关键:跳过后续处理
>                       }
>
>                       // 2. 处理游戏状态重置信号
>                       if (obj instanceof GameStateReset) {
>                           // 收到重置信号,清除所有状态
>                           pressedKeys.clear();
>                           inputQueue.clear();
>                           resetTankMovement();
>                           continue;//关键:跳过后续处理
>                       }
>
>                       if (isHost) {
>                           // 主机接收客户端输入(包括键盘输入及网络消息)
>                           if (obj instanceof Set) {//处理客户端键盘输入
>                               @SuppressWarnings("unchecked")
>                               Set<Integer> input = (Set<Integer>) obj;
>                               inputQueue.add(input);
>                           } else if (obj instanceof NetworkMessage) {//处理网络消息
>                               handleNetworkMessage((NetworkMessage) obj);
>                           }
>                       } else {
>                           if (obj instanceof GameState state) {
>                               state.apply(this);//apply() 方法将接收到的游戏状态应用到客户端的游戏实例上:
>                           } else if (obj instanceof NetworkMessage) {//处理网络消息
>                               handleNetworkMessage((NetworkMessage) obj);
>                           }
>                       }
>                   } catch (SocketException e) {
>                       System.out.println("连接断开: " + e.getMessage());
>                       break;
>                   } catch (EOFException e) {
>                       System.out.println("连接正常关闭");
>                       break;
>                   }
>               }
>           } catch (Exception e) {
>               e.printStackTrace();
>           } finally {
>               System.out.println("网络线程退出");
>               closeNetwork();//关闭网络连接
>           }
>       }
>   ```
>
> * **重点代码详解:**
>   * networkThread = new Thread(this::networkReceiveLoop);
>   * **这行代码创建并启动了一个新的线程(`networkThread`),这个线程的任务是执行当前对象(`this`)的 `networkReceiveLoop` 方法。**
>   * **`networkReceiveLoop` 是一个阻塞方法** :它的核心是 `in.readObject()`,这个方法会一直等待,直到网络上有数据传来。这是一个**耗时且不确定何时结束**的操作。
>   * **如果放在UI线程执行** :UI线程会被 `in.readObject()` 无限期阻塞,导致整个Swing界面**完全卡住、无响应**。用户无法操作,程序像"死了一样"。
>
>   * **解决方案** :就是你这行代码------**将耗时的、阻塞的操作放到一个独立的后台线程中去执行**。这样:
>
>     * **UI线程**:保持自由,可以流畅地响应用户的点击、按键和渲染画面。
>
>     * **网络线程**:在后台默默地等待数据,收到数据后再通过线程安全的方式通知UI更新。
>
> * **细节解释:**
>
>   * **initNetwork** 方法只会在gamePanel的构造器中创建一次, 但是是最先开启的线程**, `networkReceiveLoop`方法会在网络线程存在并且双端持续连接时一直运行!**
>
>   * 在**`networkReceiveLoop`**方法中, 对象读取流 "in" 会将所有读取到的obj对象进行类型的判断, 明确收到的信息是那个类型的, 以决定让哪个端执行哪个对应的操作
>
>   *
>
>     ```java
>     // 1. 处理网络重置信号
>     if (obj instanceof NetworkResetSignal){
>     }
>
>      // 2. 处理游戏状态重置信号
>      if (obj instanceof GameStateReset) {
>     }
>
>     //处理客户端键盘输入
>     if (obj instanceof Set) {
>     }
>
>     //处理网络消息
>     if (obj instanceof NetworkMessage) {
>     }
>
>     //处理游戏状态
>     if (obj instanceof GameState state) {
>     }
>
>     //处理网络消息
>     if (obj instanceof NetworkMessage) {
>     }
>     ```

1.2 涉及到的属性(我们目前考虑集成到核心类: GamePanel中)

java 复制代码
//网络相关变量
    private final boolean isHost;
    private final String serverIP;
    private ServerSocket serverSocket;
    private Socket socket;
    private ObjectInputStream in;
    private ObjectOutputStream out;
    private Thread networkThread;
    private final AtomicBoolean running = new AtomicBoolean(true);//标记网络是否还在连接中
  • 值得注意的是: 我们定义了一个自动化布尔型:"AtomicBoolean running"

  • 那为什么不用boolean ???

    • 假设你用了 private boolean running = true;,会在多线程环境下引发两个致命问题:

    • 1. 可见性问题 (Visibility Problem)

      • 场景 :主线程(如Event Dispatch Thread)执行 gameOver = true; 然后想设置 running = false; 来通知网络线程停止。

      • 问题 :由于Java内存模型(JMM),每个线程都有自己的工作内存(缓存)。主线程修改了running的值,这个修改可能不会立即写回主内存 。网络线程因此可能永远看不到这个变化,导致循环无法退出,线程无法终止("死循环")。

      • AtomicBoolean 的解决AtomicBoolean 内部使用 volatile 关键字修饰 value,保证了修改能立即对其他所有线程可见。你set(false),另一个线程马上就能看到。

    • 2. 原子性问题 (Atomicity Problem)

      • 场景:检查并设置(check-then-act)不是一个原子操作。

      java 复制代码
      // 网络线程的循环
      while (running) { // <-- 步骤1:检查
          // ...       // <-- 步骤2:执行逻辑
      }
      • 问题 :如果在步骤1步骤2 之间,主线程将running设为false,网络线程依然会执行完步骤2 的整个逻辑。虽然这可能不会造成错误,但失去了精确控制的时机

      • AtomicBoolean 的解决AtomicBoolean 提供的所有操作(如get()set())都是原子操作 。这意味着while (running.get())这个"读"操作是原子的,能获取到最新且唯一的值。

    • 3. 具体使用:

      • 在你的 GamePanel 中:

      • 线程1networkThread (执行 networkReceiveLoop 方法)

        • 它的循环条件是 while (running.get() && ...)

        • 它需要及时看到主线程发出的停止信号。

      • 线程2:主线程(EDT线程)

        • closeNetwork() 方法中调用 running.set(false); 来发出停止信号。

        • showGameOver() 等方法中也会检查 running.get()

  • 我们最终希望: 两台电脑同时打开这个程序, 选择自己的网络角色并建立连接进行激战, 因此既然只是在本地IDE上部署这个游戏,我们也没有必要清晰的在项目中区分Host与Client的代码, 双方直接使用同一份代码简化了部署的复杂度

2. 界面的叠放设计与聊天区的开发

2.1 界面设计

  • 继TankWar版本一 ,我们在界面上增加了聊天区域, 具体的叠放如下图
  • JFrame 是底层容器,承载所有的组件
  • centerPanel 承载用来交互 / 操作 的界面
  • scorePanel 包含logo 图案, 玩家分数等信息
  • gamePanel 承载游戏地图, 坦克AB , 子弹 等核心元素
  • chatPanel 承载chatArea(JTextArea类) 和 inputField (JTextField)

2.2 聊天区开发 (chatPanel)

  • 完成了2.1 C-S 端的来连接建立, 我们就可以发送/ 接收 / 处理所有的消息类型啦!
复制代码
#### **2.2.1 基本框架**

> * 我们将底层界面(chatPanel) 继承JPanel 并在构造器中设置基本的界面属性, 同时将构造器的参数设置为gamePanel对象.
复制代码
#### **2.2.2 聊天组件**

> * 创建chatArea对象(JTextArea类)
>   * 以下两个方法至关重要:关乎到聊天信息是否能正常显示:
>   *
>
>     ```java
>     //用于控制文本自动换行,当文本到达组件右边界时自动转到下一行
>     chatArea.setLineWrap(true);
>     //与上方法配合使用, 用于检测文本换行时单词是否保持完整, 及在单词边界换行(不会切断单词)
>     chatArea.setWrapStyleWord(true);
>     ```
>
> * 创建inputField对象(JTextField类)
复制代码
#### **2.2.3 聊天消息处理器 -- 关键!**

> * 首先, 正如我们在上一篇文章中所提到的, 在**回调模式/ 观察者模式** 的思路下实现chatPanel与gamePanel的通信 , 因此, 我们定义ChatCallBack接口, **让GamePanel 将其实例化** , 并**在chatPanel中借助gamePanel对接口的方法进行重写** (这里的内容有些绕, 看看源码有助于理解)
> * 接口类:ChatCallBack
> *
>
>   ```java
>   public interface ChatCallback {//聊天消息处理器
>       void onMessageReceived(String message);
>       void requestChatFocus();
>   }
>   ```
>
> * chatPanel中对接口方法进行重写:
>
> *
>
>   ```java
>   //设置聊天消息处理器
>   gamePanel.setChatCallback(new ChatCallback() {
>       @Override
>       public void onMessageReceived(String message) {//接受并显示消息的方法
>           chatArea.append(message + "\n");//将指定文本追加到 JTextArea 的末尾
>           chatArea.setCaretPosition(chatArea.getDocument().getLength());//根据文档中当前包含的字符总数设置文本插入符(光标)的位置
>           //即获取文本区域当前内容的长度并将光标移动到文本的末尾
>       }
>
>       @Override
>       public void requestChatFocus() {
>           inputField.requestFocus();
>       }
>   });
>   ```
复制代码
#### **2.2.4 处理"发送按钮"的监听器**

> *
>
>   ```java
>   //发送消息的按钮事件处理
>   ActionListener sendAction = e -> {
>       String message = inputField.getText().trim();//表示输入的文本不包括(空格, Tab, 回车)
>       if (!message.isEmpty()) {
>           String formattedMessage = (gamePanel.isHost() ? "[Host]: " : "[Client]: ") + message;
>           chatArea.append(formattedMessage + "\n");
>           gamePanel.sendChatMessage(formattedMessage);
>           inputField.setText("");//清空输入框
>       }
>       gamePanel.requestFocus();
>   };
>   ```
>
> * 注意! : 这个发送消息的操作不仅要求点击按钮后触发, 我们也希望**直接通过输完消息后直接敲回车**发送, 因此你需要大致知道inputField(JTextField的特性):
>
> *
>
>   ```java
>   sendButton.addActionListener(sendAction);
>   inputField.addActionListener(sendAction);
>   // JTextField 的内置行为
>   // 当获得焦点的 JTextField 检测到回车键时:
>   // 1. 自动触发所有注册的 ActionListener
>   // 2. 执行 sendAction 逻辑
>   ```

2.3 聊天信息的传输

  • 上面的代码中有一个核心的功能没有实现: 到底消息怎么相互传输???
复制代码
#### **2.3.1 枚举类的创建**

> * 首先我们定义一个枚举类: MessageType .游戏的消息类型有很多个, 但我们只将最重要的两个定义为枚举对象:
> *
>
>   ```java
>   public enum MessageType {
>       CHAT_MESSAGE,  // 聊天消息
>       PLAYER_FIRE    //开火请求
>   }
>   ```
复制代码
#### **2.3.2 网络消息的包装类: NetworkMessage**

> * 由于我们的核心逻辑还是使用对象输入输出流对消息进行互传, 因此一个枚举的对象很显然不便于我们传输, 因此我们**需要一个辅助的类对他们进行包装**
>
> * 由于游戏特性于聊天特性的要求, 我们必须将**网络消息类实现序列化接口** , 将`GameState`、`NetworkMessage`等Java对象转换成字节流,通过Socket发送给对端。对端收到字节流后,再将其还原为Java对象。没有序列化,就无法在网络上传递复杂的对象信息。
>
> * NetworkMessage类 必须要有两个私有化属性: **即消息类型与消息内容**
>
> *
>
>   ```java
>
>   public class NetworkMessage implements Serializable {
>       final MessageType type;
>       final Object data;
>
>       public NetworkMessage(MessageType type, Object data) {
>           this.type = type;
>           this.data = data;
>       }
>   }
>   ```
复制代码
#### **2.3.3 消息传递流程:**

* a. 输入非空的消息内容, 点击按钮或按下回车**触发监听器方法**

  *
    >
    > ```java
    > String formattedMessage = (gamePanel.isHost() ? "[Host]: " : "[Client]: ") + message;
    > chatArea.append(formattedMessage + "\n");
    > gamePanel.sendChatMessage(formattedMessage);
    > inputField.setText("");//清空输入
    > ```

* b. gamePanel的sendChatMessage方法: **C/S端发送消息!**

  *
    >
    > ```java
    >  //发送聊天消息
    >     public void sendChatMessage(String message) {
    >         sendNetworkMessage(new NetworkMessage(MessageType.CHAT_MESSAGE, message));
    >     }
    >
    >     //发送网络信息
    >     private void sendNetworkMessage(NetworkMessage message) {
    >         try {
    >             if (socket != null && socket.isConnected()) {
    >                 out.writeObject(message);
    >                 out.flush();
    >             }
    >         } catch (IOException e) {
    >             System.err.println("发送网络消息失败: " + e.getMessage());
    >         }
    >     }
    > ```

* c. **C/S端接收消息**

  > * 1. 网络循环中, **C/ S都要接收消息**
  >
  > *
  >
  >   ```java
  >   if (isHost) {
  >      // 主机接收客户端输入(包括键盘输入及网络消息)
  >      if (obj instanceof Set) {//处理客户端键盘输入
  >          @SuppressWarnings("unchecked")
  >          Set<Integer> input = (Set<Integer>) obj;
  >          inputQueue.add(input);
  >      } else if (obj instanceof NetworkMessage) {//处理网络消息
  >           handleNetworkMessage((NetworkMessage) obj);
  >      }
  >   } else {
  >       if (obj instanceof GameState state) {//处理游戏状态
  >           state.apply(this);//apply() 方法将接收到的游戏状态应用到客户端的游戏实例上:
  >        } else if (obj instanceof NetworkMessage) {//处理网络消息
  >             handleNetworkMessage((NetworkMessage) obj);
  >        }
  >   }
  >   ```
  >
  > * 2. 调用**handelNetworkMessage()**方法
  >
  > *
  >
  >   ```java
  >   //主机和客户端都要处理接收到的网络信息
  >   private void handleNetworkMessage(NetworkMessage message) {
  >           if (isHost && message.type == MessageType.PLAYER_FIRE) {
  >               // 主机为客户端生成子弹
  >               Bullet bullet = createBullet(tankB, false);
  >               synchronized (bullets) {
  >                   bullets.add(bullet);
  >               }
  >           } else if (message.type == MessageType.CHAT_MESSAGE && chatCallback != null) {
  >               chatCallback.onMessageReceived((String) message.data);//调用ChatCallback接口所提供的方法
  >           }
  >       }
  >   ```
  >
  > * 3. chatCallBack对象 调用**onMessageReceive**方法 ,我们刚才已经重写过了

3. 坦克的初始化与双端同步显示

3.1 坦克的生成与初始化

复制代码
#### **3.1.1. 第一件事: gamePanel都需要私有化那些核心对象与属性?**

*
  >
  > ```java
  >  //游戏实体
  >     private TankA tankA;
  >     private TankB tankB;
  >     private final CopyOnWriteArrayList<Bullet> bullets = new CopyOnWriteArrayList<>();//线程安全的集合实现,特别适合读多写少的并发场景
  >     //内置线程安全机制,无需额外同步, 修改操作(add/remove)会创建底层数组的新副本
  >
  > //游戏状态
  >     private final Set<Integer> pressedKeys = new HashSet<>();
  >     private final Timer gameTimer;
  >     private final Random ran = new Random();
  >     private final BattleMaps map;
  >     private final ScorePanel sPanel;
  >     public boolean gameOver = false;
  >     private String winner = "";
  >
  > //碰撞检测变量
  >     private int prevTankAX, prevTankAY;
  >     private int prevTankBX, prevTankBY;
  > ```
复制代码
#### **3.1.2 坦克AB类的构建与继承MoveObject类**

*
  > 这里的代码和我们的坦克大战(Ⅰ)几乎一模一样, 只需照搬过来即可, 不过要注意, TankA, TankB ,MoveObject类**都需要实现Serializable接口以实现序列化.**
复制代码
#### **3.1.3 是时候看看引擎类对象: gamePanel的构造器啦!**

* **a. 参数我们需要两个: IP地址和用户身份**
*
  >
  > ```java
  >  public GamePanel(boolean isHost, String serverIP) {
  >      this.isHost = isHost;
  >      this.serverIP = serverIP;
  > ```

* **b. 初始化地图与积分面板**
*
  >
  > ```java
  >  map = new BattleMaps();
  > sPanel = new ScorePanel();
  > ```

* **c. 初始化网络**(上面的initNetwork): 此时就已经开启了网络线程并循环接收网络信息
*
  >
  > ```java
  > //初始化网络
  > initNetwork();//包含了实时接收信息的线程
  > ```

* **d. 服务端与客户端的操作**
  > *
  >
  >   ```java
  >    if (isHost) {//服务端生成坦克A/B的合法位置
  >      tankA = generatePositionA(45, 35);
  >      tankB = generatePositionB(45, 35);
  >
  >       //保存初始位置
  >       prevTankAX = tankA.getX();
  >       prevTankAY = tankA.getY();
  >       prevTankBX = tankB.getX();
  >       prevTankBY = tankB.getY();
  >       sendInitialPosition();//Host端发送初始化坦克位置的网络信息
  >   } else {
  >       // 客户端等待初始位置,具体生成方法(同步的坐标)在上面的initNetwork()中
  >       tankA = new TankA(0, 0);
  >       tankB = new TankB(0, 0);
  >   }
  >   ```
  >
  > * **发送消息核心方法: 发送初始化的坦克信息 "sendInitialPosition()"**
  >
  >   * 这个方法要求我们把两个坦克对象发给客户端, 那么就如NetworkMessage一样, 我们也**需要一个容器类来承载**这两个坦克
  >
  >   * **InitialPosition类:**
  >
  >     *
  >
  >       ```java
  >       public class InitialPosition implements Serializable {
  >           final int tankAX, tankAY;
  >           final int tankBX, tankBY;
  >
  >           public InitialPosition(TankA a, TankB b) {
  >               this.tankAX = a.getX();
  >               this.tankAY = a.getY();
  >               this.tankBX = b.getX();
  >               this.tankBY = b.getY();
  >           }
  >       }
  >       ```
  >
  >     * 他将Host传入的两个坦克的位置信息传入了这个类的参数中
  >
  >   *
  >
  >     ```java
  >     private void sendInitialPosition() {//发送初始化坦克坐标的网络信息
  >         try {
  >             if (isHost && socket != null && !socket.isConnected()) {
  >                 out.writeObject(new InitialPosition(tankA, tankB));
  >                 out.flush();
  >             }
  >         } catch (IOException e) {
  >             System.err.println("发送初始位置失败" + e.getMessage());
  >         }
  >     }
  >     ```
  >
  > * **接收消息核心方法:**
  >
  >   * **在initNetwork方法中, 每个网络线程只会调用一次**
  >
  >   *
  >
  >     ```java
  >     // 客户端等待初始位置,即客户端的坦克位置由主机确定并与之同步
  >     //接收到服务端发送的初始位置, 客户端初始化两个坦克
  >
  >     Object initObj = in.readObject();
  >     if (initObj instanceof InitialPosition init) {
  >          tankA.setX(init.tankAX);
  >          tankB.setX(init.tankBX);
  >          tankA.setY(init.tankAY);
  >          tankB.setY(init.tankBY);
  >
  >          //保存初始位置
  >          prevTankAX = tankA.getX();
  >          prevTankAY = tankA.getY();
  >          prevTankBX = tankB.getX();
  >          prevTankBY = tankB.getY();
  >     }
  >     ```
  >
  >   * 这里的prevTank A/B X/Y 是用来后面处理坦克的碰撞的参数, 用来将坦克的**位置重置为上一刻的位置**
  >
  >   * 这几个参数不仅会在初始化坦克时被更新**, 在gameTimer的每个循环都会被更新一次**

* **e. 游戏逻辑循环**

  > *
  >   >
  >   > ```java
  >   > //使用游戏循环(Timer)来定期处理按键状态,更新坦克位置。
  >   > gameTimer = new Timer(13, e -> { // 初始化游戏定时器(每16ms≈60FPS),定期处理游戏逻辑
  >   >     if (gameOver) {
  >   >         try {
  >   >             out.flush();
  >   >         } catch (IOException ex) {
  >   >             throw new RuntimeException(ex);
  >   >         }
  >   >         pressedKeys.clear();
  >   >         inputQueue.clear();
  >   >     else {
  >   >         //保存坦克移动前的位置
  >   >         prevTankAX = tankA.getX();
  >   >         prevTankAY = tankA.getY();
  >   >         prevTankBX = tankB.getX();
  >   >         prevTankBY = tankB.getY();
  >   >         if (isHost) {
  >   >             // 主机:处理所有游戏逻辑
  >   >             processHostLogic();
  >   >         } else {
  >   >             //客户端 只处理渲染和输出
  >   >             processClientLogic();
  >   >         }
  >   >         // 请求重绘
  >   >         repaint();
  >   >     }
  >   > });
  >   > gameTimer.start();
  >   > ```
  >
  > * 在客户端接收到初始化的坦克信息之后:
  >
  > * 当游戏循环gameTimer执行到**repaint()** 方法时 paintCompenent方法被触发, **把整个当前的游戏信息绘制出来, 我们就第一次看到游戏界面啦!**
  >
  >   * **paintCompenent():**
  >
  >   *
  >
  >     ```java
  >      @Override
  >     protected void paintComponent(Graphics g) {//自动启用Swing双缓冲,避免闪烁
  >         super.paintComponent(g);// 清空背景,清除前一帧画面 确保每次绘制都是全新的画面,避免画面残留
  >         //底层原理:默认会使用组件的背景色填充整个区域
  >         Graphics2D g2d = (Graphics2D) g;//创建图形上下文副本
  >
  >         // 绘制地图
  >         map.paintMap(g2d);
  >
  >         // 绘制坦克
  >         tankA.drawTankA(g2d);
  >         tankB.drawTankB(g2d);
  >
  >          //绘制所有子弹
  >          for (Bullet bullet : bullets) {
  >             if (bullet.isActive()) {
  >                 bullet.draw(g2d);
  >             }
  >         }
  >
  >         //绘制分数面板
  >         sPanel.drawTankPicture(g2d);
  >
  >         // 绘制玩家信息
  >         drawPlayerInfo(g2d);
  >     }
  >     ```

* **f. 网络线程启动与接收信息循环开启**

  *
    >
    > ```java
    >  // 网络信息发送的同步循环 (30FPS)
    > networkSendTimer = new Timer(1000 / NETWORK_FPS, e -> {
    >      if (running.get()) {
    >         sendNetworkUpdate();
    >      }
    > });
    > networkSendTimer.start();
    >
    > setFocusable(true);
    > addKeyListener(this);
    > ```

3.2 坦克的同步与显示

  • 上面我们谈到坦克的第一次初始化进行完成, 下面我们继续以坦克的移动,碰撞,同步为线索进行分析:
复制代码
#### 3.2.1 按键指令的存储与传输

* **1. 键盘监听器监听两个端的操作**
  > * 依旧在gamePanel类中, 与上版本的监听存储逻辑相同, 将键盘的指令用Set\<Integer\>存储起来,
  > *
  >
  >   ```java
  >   private final Set<Integer> pressedKeys = new HashSet<>()
  >   ```
  >
  > * 不要忘记实现键盘监听器接口哦
  >
  > * 某一方当按下前后左右以及炮弹发射键按钮时, 将这int 型的键盘指令存储进去
  >
  > *
  >
  >   ```java
  >    @Override
  >       public void keyPressed(KeyEvent e) {
  >           if (gameOver) {
  >               return; // 游戏结束时忽略按键输入
  >           }
  >
  >   //每个端自己的pressedKeys存储自己按下的键
  >           pressedKeys.add(e.getKeyCode());
  >           if (isHost && e.getKeyCode() == KeyEvent.VK_Q) {
  >               // 主机本地开火
  >               fireBullet(tankA, true);
  >           } else if (!isHost && e.getKeyCode() == KeyEvent.VK_SLASH) {
  >               // 客户端发送开火请求
  >               sendNetworkMessage(new NetworkMessage(MessageType.PLAYER_FIRE, null));
  >           } else if (e.getKeyCode() == KeyEvent.VK_ENTER && chatCallback != null) {
  >               chatCallback.requestChatFocus();
  >           }
  >       }
  >   ```
  >
  > * 当某一方释放按键时, 将这个**按键数据从Set\<\>中移除**
  >
  > *
  >
  >   ```java
  >   @Override
  >       public void keyReleased(KeyEvent e) {
  >           int keyCode = e.getKeyCode();
  >           pressedKeys.remove(keyCode);
  >           if (gameOver) {
  >               pressedKeys.clear();
  >           }
  >       }
  >   ```

* **2. 定期处理/发送按键信息**
  > * 在刚才谈到的构造器中有一个专用网络线程, 用来定期更新网络信息:
  > *
  >
  >   ```java
  >   // 网络信息发送的同步循环 (30FPS)
  >           networkSendTimer = new Timer(1000 / NETWORK_FPS, e -> {
  >               if (running.get()) {
  >                   sendNetworkUpdate();
  >               }
  >           });
  >           networkSendTimer.start();
  >   ```
  >
  > * sendNetworkUpdate() 方法, 对于**Host定期发送游戏状态信息** ,**Client定期发送当前客户端的键盘输入Set集合**
  >
  > *
  >
  >   ```java
  >   //根据网络循环定期发送网络信息更新的信息
  >       private void sendNetworkUpdate() {
  >           if (gameOver || !running.get()) {
  >               return;
  >           }
  >
  >           try {
  >               if (isHost) {// 主机:发送完整游戏状态
  >                   if (socket != null && socket.isConnected()) {
  >                       out.writeObject(new GameState(tankA, tankB, bullets));
  >                       out.flush();
  >                   }
  >               } else {// 客户端:发送按键输入
  >                   Set<Integer> currentInput = new HashSet<>(pressedKeys);
  >                   if (socket != null && socket.isConnected() && !gameOver) {
  >                       out.writeObject(currentInput);
  >                       out.flush();
  >                   } else {
  >                       currentInput.clear();
  >                   }
  >               }
  >           } catch (SocketException e) {
  >               System.err.println("连接已关闭,停止发送");
  >               running.set(false);
  >           } catch (Exception e) {
  >               System.err.println("网络发送错误" + e.getMessage());
  >           }
  >       }
  >   ```
  >
  > * 那么这个时候接受网络信息循环 networkReceiveLoop 又要发力了, 当主机接收到客户端发来的按键集合时, 将其**存入到Set\<Integer\> input中** , 并再次**转存到 " Queue\<Set\<Integer\>\> inputQueue = newConcurrentLinkedQueue\<\>(); " 中, 特性如下**
  >
  > *
  >
  >   ```java
  >    private final CopyOnWriteArrayList<Bullet> bullets = new CopyOnWriteArrayList<>();
  >   //线程安全的集合实现,特别适合读多写少的并发场景
  >   //内置线程安全机制,无需额外同步, 修改操作(add/remove)会创建底层数组的新副本
  >   ```
  >
  > *
复制代码
#### 3.2.2 定期读取按键信息并更新坦克

> * 在我们的gameTimer定时器中,会定期处理Host逻辑和Client逻辑 , 即
>   *
>
>     ```java
>     if (isHost) {
>         // 主机:处理所有游戏逻辑
>         processHostLogic();
>     } else {
>         //客户端 只处理渲染和输出
>         processClientLogic();
>     }
>     ```
>
>   * **a. processHostLogic()**
>
>     *
>
>       ```java
>       private void processHostLogic() {
>               //1.处理客户端输入, 设置当前坦克B的状态属性
>               processClientInput();
>
>               //2.处理本地坦克移动(TankA), 设置当前坦克A的状态属性
>               processTankInput(tankA, KeyEvent.VK_A, KeyEvent.VK_D, KeyEvent.VK_W, KeyEvent.VK_S);
>
>               //3. 更新服务端上的坦克位置
>               updateTankPosition(tankA);
>               updateTankPosition(tankB);
>
>               //4. 更新子弹
>               updateBullets();
>           }
>       ```
>
>     * 其中的 processClientInput() 方法, 用于处理客户端按键逻辑, 即**定期读取inputQueue, 将其倒入Set\<Integer\> input 中, 并继续调用处理坦克操作的方法**
>
>     *
>
>       ```java
>       private void processClientInput() {//服务端处理客户端输入
>               if (gameOver) {
>                   pressedKeys.clear();
>                   inputQueue.clear();
>               }
>               //处理所有排队中的客户端输入
>               while (!inputQueue.isEmpty()) {
>                   Set<Integer> input = inputQueue.poll();
>                   // 使用专门的按键处理方法
>                   processByTankInput(tankB, input, KeyEvent.VK_LEFT, KeyEvent.VK_RIGHT, KeyEvent.VK_UP, KeyEvent.VK_DOWN);
>               }
>           }
>       ```
>
>     *
>
>       ```java
>       private void processByTankInput(MoveObjects tank, Set<Integer> keys,
>                                           int leftKey, int rightKey, int upKey, int downKey) {
>               if (keys == null) return;
>
>               tank.setSpeedX(0);
>               tank.setSpeedY(0);
>
>               if (keys.contains(leftKey)) {
>                   tank.setDirection(0);
>                   tank.setSpeedX(-5);
>               }
>               if (keys.contains(rightKey)) {
>                   tank.setDirection(2);
>                   tank.setSpeedX(5);
>               }
>               if (keys.contains(upKey)) {
>                   tank.setDirection(1);
>                   tank.setSpeedY(-5);
>               }
>               if (keys.contains(downKey)) {
>                   tank.setDirection(3);
>                   tank.setSpeedY(5);
>               }
>           }
>       ```
>
>     * 那么这里就已经实现了坦克核心属性的重新赋值, paintCompenent方法就会定期绘制对应的坦克图片
>
>   * **b. processClientLogic()**
>
>     *
>
>       ```java
>       private void processClientLogic() {//客户端只收集输入,不执行游戏逻辑
>               //处理本地输入
>               processTankInput(tankB, KeyEvent.VK_LEFT, KeyEvent.VK_RIGHT, KeyEvent.VK_UP, KeyEvent.VK_DOWN);
>
>               //更新坦克位置
>               updateTankPosition(tankB);
>           }
>       ```
>
>     *
>
>       ```java
>        private void processTankInput(MoveObjects tank, int leftKey, int rightKey, int upKey, int downKey) {
>               tank.setSpeedX(0);
>               tank.setSpeedY(0);
>
>               if (pressedKeys.contains(leftKey)) {
>                   tank.setDirection(0);
>                   tank.setSpeedX(-5);
>               }
>               if (pressedKeys.contains(rightKey)) {
>                   tank.setDirection(2);
>                   tank.setSpeedX(5);
>               }
>               if (pressedKeys.contains(upKey)) {
>                   tank.setDirection(1);
>                   tank.setSpeedY(-5);
>               }
>               if (pressedKeys.contains(downKey)) {
>                   tank.setDirection(3);
>                   tank.setSpeedY(5);
>               }
>           }
>       ```
>
>     * 这里Client的逻辑同上
  • s
  • s
  • s
  • s
  • s
  • s
  • s
  • s
  • s
  • s
  • s
  • s
  • s
  • ss
  • s
  • s
  • s
  • s
  • s
  • s
  • s
  • s
相关推荐
S妖O风F12 分钟前
IDEA报JDK版本问题
java·ide·intellij-idea
Mr. Cao code15 分钟前
使用Tomcat Clustering和Redis Session Manager实现Session共享
java·linux·运维·redis·缓存·tomcat
纪莫17 分钟前
DDD领域驱动设计的理解
java·ddd领域驱动设计
zcz160712782118 分钟前
Linux 网络命令大全
linux·运维·网络
VVVVWeiYee20 分钟前
BGP高级特性
运维·服务器·网络
山中月侣1 小时前
Java多线程编程——基础篇
java·开发语言·经验分享·笔记·学习方法
java水泥工1 小时前
Java项目:基于SpringBoot和VUE的在线拍卖系统(源码+数据库+文档)
java·vue.js·spring boot
程序员岳焱1 小时前
使用 JPype 实现 Java 与 Python 的深度交互
java·后端·python
neoooo2 小时前
JDK 新特性全景指南:从古早版本到 JDK 17 的华丽变身
java·spring boot·后端
心月狐的流火号2 小时前
深入剖析 Java NIO Selector 处理可读事件
java