Java Swing 贪吃蛇:从 3 个核心类到 100 分通关的 MVC 架构实战

当我们在学习 Java Swing 开发时,贪吃蛇游戏无疑是一个绝佳的练手项目。它不仅涵盖了图形界面编程的基础知识,还能让我们深入理解游戏开发的核心逻辑。但今天,我们要做的不仅仅是实现一个简单的贪吃蛇游戏,而是要从软件工程的角度,将其重构为一个符合 MVC(Model-View-Controller)设计模式的清晰架构。

1. 为什么需要 MVC 架构?

在传统的贪吃蛇实现中,我们常常会看到将所有代码都塞在一个类里的情况。这种"大杂烩"式的代码虽然能跑起来,但随着功能增加,代码会变得越来越难以维护。MVC 架构通过将程序分为三个核心组件,解决了这个问题:

  • Model(模型) :负责游戏数据和业务逻辑
  • View(视图) :负责界面展示
  • Controller(控制器) :负责处理用户输入和协调模型与视图

这种分离带来的好处是显而易见的:

  1. 代码可维护性 :各模块职责清晰,修改一个部分不会影响其他部分
  2. 可测试性 :模型逻辑可以独立于界面进行测试
  3. 可扩展性 :可以轻松更换不同的视图(比如从 Swing 切换到 JavaFX)

2. 核心类设计与实现

2.1 Model 层设计

Model 层是整个游戏的大脑,它需要管理以下核心数据:

public class GameModel {
    private Snake snake;      // 蛇的数据
    private Food food;        // 食物的位置
    private int score;        // 当前得分
    private boolean isOver;   // 游戏是否结束
    private boolean isPaused; // 游戏是否暂停
    
    // 游戏状态更新逻辑
    public void update() {
        if (!isPaused && !isOver) {
            snake.move();
            checkCollision();
            checkFood();
        }
    }
    
    // 碰撞检测
    private void checkCollision() { /*...*/ }
    
    // 食物检测
    private void checkFood() { /*...*/ }
}

2.2 View 层设计

View 层负责将 Model 的状态可视化。在 Swing 中,我们通常会继承 JPanel 来实现自定义绘制:

public class GameView extends JPanel {
    private GameModel model;
    
    @Override
    protected void paintComponent(Graphics g) {
        super.paintComponent(g);
        drawBackground(g);
        drawSnake(g);
        drawFood(g);
        drawScore(g);
        if (model.isOver()) {
            drawGameOver(g);
        }
    }
    
    private void drawSnake(Graphics g) {
        for (Point segment : model.getSnake().getBody()) {
            g.setColor(Color.GREEN);
            g.fillRect(segment.x * CELL_SIZE, segment.y * CELL_SIZE, 
                      CELL_SIZE, CELL_SIZE);
        }
    }
}

2.3 Controller 层设计

Controller 负责处理用户输入并更新 Model:

public class GameController implements KeyListener {
    private GameModel model;
    private GameView view;
    
    public GameController(GameModel model, GameView view) {
        this.model = model;
        this.view = view;
        view.addKeyListener(this);
    }
    
    @Override
    public void keyPressed(KeyEvent e) {
        switch (e.getKeyCode()) {
            case KeyEvent.VK_UP:
                model.getSnake().setDirection(Direction.UP);
                break;
            case KeyEvent.VK_DOWN:
                model.getSnake().setDirection(Direction.DOWN);
                break;
            // 其他方向键处理...
            case KeyEvent.VK_SPACE:
                model.setPaused(!model.isPaused());
                break;
        }
    }
}

3. 重构前后的代码对比

让我们通过一个具体例子来看看 MVC 架构带来的改进。以下是传统实现中处理蛇移动的代码:

// 传统实现(所有逻辑在一个类中)
public void actionPerformed(ActionEvent e) {
    if (!isPaused && !isOver) {
        // 移动蛇身
        for (int i = body.size() - 1; i > 0; i--) {
            body.set(i, body.get(i - 1));
        }
        // 移动蛇头
        Point head = body.get(0);
        switch (direction) {
            case UP: head.y--; break;
            case DOWN: head.y++; break;
            // ...
        }
        // 碰撞检测
        if (checkCollision()) {
            isOver = true;
        }
        // 食物检测
        if (head.equals(food)) {
            grow();
            spawnFood();
        }
        repaint();
    }
}

而在 MVC 架构中,同样的逻辑被清晰地分配到不同类中:

// Model 层
public void update() {
    snake.move();
    if (checkWallCollision() || checkSelfCollision()) {
        isOver = true;
    }
    if (snake.getHead().equals(food.getPosition())) {
        snake.grow();
        score += 10;
        food.spawn(snake.getBody());
    }
}

// View 层
public void updateView() {
    repaint(); // 触发重绘
}

4. 完整可运行示例

下面是一个完整的 MVC 架构贪吃蛇实现框架:

4.1 主入口类

public class SnakeGame {
    public static void main(String[] args) {
        GameModel model = new GameModel(20, 20); // 20x20 的网格
        GameView view = new GameView(model);
        GameController controller = new GameController(model, view);
        
        JFrame frame = new JFrame("MVC 贪吃蛇");
        frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
        frame.add(view);
        frame.pack();
        frame.setLocationRelativeTo(null);
        frame.setVisible(true);
        
        // 游戏循环
        new Timer(100, e -> {
            model.update();
            view.repaint();
        }).start();
    }
}

4.2 关键配置参数

下表列出了游戏中常用的配置参数及其说明:

参数名 默认值 说明
GRID_WIDTH 20 网格的宽度(单位:格子数)
GRID_HEIGHT 20 网格的高度(单位:格子数)
CELL_SIZE 25 每个格子的像素大小
INITIAL_SPEED 100 初始移动速度(毫秒)
SPEED_INCREMENT 5 每得10分速度增加量
INITIAL_LENGTH 3 蛇的初始长度

4.3 游戏循环的实现

一个稳定的游戏循环对游戏体验至关重要。在 Java Swing 中,我们可以使用 javax.swing.Timer 来实现:

// 在 Controller 中初始化游戏循环
Timer gameTimer = new Timer(initialDelay, e -> {
    model.update();
    if (model.isGameOver()) {
        ((Timer)e.getSource()).stop();
        view.showGameOver();
    } else {
        view.repaint();
    }
});
gameTimer.start();

注意:Swing 的 Timer 是在事件分发线程(EDT)上执行的,因此不需要额外的线程同步处理。但这也意味着如果游戏逻辑过于复杂,可能会导致界面卡顿。

5. 进阶优化技巧

5.1 游戏状态的保存与恢复

实现游戏存档功能可以大大提升用户体验。我们可以通过序列化 Model 状态来实现:

// 保存游戏状态
public void saveGame(String filename) throws IOException {
    try (ObjectOutputStream out = new ObjectOutputStream(
            new FileOutputStream(filename))) {
        out.writeObject(model);
    }
}

// 加载游戏状态
public void loadGame(String filename) throws IOException, ClassNotFoundException {
    try (ObjectInputStream in = new ObjectInputStream(
            new FileInputStream(filename))) {
        this.model = (GameModel) in.readObject();
        view.setModel(model);
    }
}

5.2 性能优化

当蛇身变得很长时,绘制效率可能会成为问题。以下是一些优化建议:

  1. 双缓冲技术 :减少画面闪烁

    public class GameView extends JPanel {
        private BufferedImage buffer;
        
        public GameView() {
            setDoubleBuffered(true); // 启用双缓冲
        }
        
        @Override
        protected void paintComponent(Graphics g) {
            if (buffer == null || buffer.getWidth() != getWidth() || 
                buffer.getHeight() != getHeight()) {
                buffer = new BufferedImage(getWidth(), getHeight(), 
                                         BufferedImage.TYPE_INT_ARGB);
            }
            Graphics bg = buffer.getGraphics();
            // 在缓冲图像上绘制
            drawGame(bg);
            // 将缓冲图像绘制到屏幕上
            g.drawImage(buffer, 0, 0, null);
        }
    }
    
  2. 局部重绘 :只重绘发生变化的部分区域

5.3 添加音效和动画

虽然 Swing 不是专业的游戏开发框架,但我们仍然可以添加简单的音效和动画效果:

// 播放吃食物音效
public void playEatSound() {
    try {
        AudioInputStream audioIn = AudioSystem.getAudioInputStream(
            getClass().getResource("/sounds/eat.wav"));
        Clip clip = AudioSystem.getClip();
        clip.open(audioIn);
        clip.start();
    } catch (Exception e) {
        // 静默处理音效加载失败
    }
}

// 在 Model 中
public void checkFood() {
    if (snake.getHead().equals(food.getPosition())) {
        snake.grow();
        score += 10;
        food.spawn(snake.getBody());
        view.playEatSound(); // 播放音效
        view.showEatAnimation(); // 显示动画
    }
}

6. 常见问题与解决方案

在开发过程中,我遇到了几个典型的坑,这里分享解决方案:

  1. 方向切换过于灵敏导致蛇"自杀"

    • 问题:快速连续按相反方向键时,蛇会反向移动撞到自己
    • 解决:在 Controller 中添加方向切换限制
      public void keyPressed(KeyEvent e) {
          Direction current = model.getSnake().getDirection();
          switch (e.getKeyCode()) {
              case KeyEvent.VK_UP:
                  if (current != Direction.DOWN) 
                      model.getSnake().setDirection(Direction.UP);
                  break;
              // 其他方向类似处理
          }
      }
      
  2. 游戏速度随得分提高后变得太快

    • 问题:简单的线性加速导致后期游戏无法进行
    • 解决:使用对数函数调整速度变化曲线
      public int calculateSpeed(int score) {
          int maxSpeed = 50; // 最快速度
          int minSpeed = 200; // 最慢速度
          // 对数函数使速度变化更平滑
          double speed = minSpeed - (Math.log(score + 1) * 20);
          return (int) Math.max(maxSpeed, Math.min(minSpeed, speed));
      }
      
  3. 食物生成在蛇身上

    • 问题:随机生成的食物可能出现在蛇身位置
    • 解决:在 Food 类中排除蛇身位置
      public void spawn(List<Point> snakeBody) {
          List<Point> available = new ArrayList<>();
          // 收集所有可用位置
          for (int x = 0; x < width; x++) {
              for (int y = 0; y < height; y++) {
                  Point p = new Point(x, y);
                  if (!snakeBody.contains(p)) {
                      available.add(p);
                  }
              }
          }
          // 从可用位置随机选择
          if (!available.isEmpty()) {
              position = available.get(random.nextInt(available.size()));
          }
      }
      

7. 扩展思考:从 MVC 到更现代的架构

虽然 MVC 已经大大改善了代码结构,但我们还可以考虑更现代的架构模式:

  1. 事件总线模式 :使用事件驱动的方式解耦组件

    // 定义事件
    public class SnakeMovedEvent {
        private List<Point> newBody;
        // getters/setters...
    }
    
    // 发布事件
    eventBus.post(new SnakeMovedEvent(snake.getBody()));
    
    // 订阅事件
    @Subscribe
    public void onSnakeMoved(SnakeMovedEvent event) {
        updateView();
    }
    
  2. ECS(实体-组件-系统)架构 :更适合复杂游戏开发

    • 实体:蛇、食物、墙等
    • 组件:位置组件、渲染组件、移动组件等
    • 系统:渲染系统、移动系统、碰撞系统等
  3. 响应式编程 :使用 RxJava 处理游戏事件流

    Observable<Direction> directionStream = Observable.create(emitter -> {
        view.addKeyListener(new KeyAdapter() {
            @Override
            public void keyPressed(KeyEvent e) {
                Direction dir = convertKeyToDirection(e);
                if (dir != null) emitter.onNext(dir);
            }
        });
    });
    
    directionStream
        .throttleLast(100, TimeUnit.MILLISECONDS) // 限流
        .subscribe(model::setSnakeDirection);
    

8. 测试策略

良好的架构应该便于测试。以下是一些测试示例:

8.1 模型层测试

@Test
public void testSnakeMovement() {
    GameModel model = new GameModel(10, 10);
    model.getSnake().setDirection(Direction.RIGHT);
    Point initialHead = model.getSnake().getHead();
    
    model.update(); // 移动一步
    
    assertEquals(initialHead.x + 1, model.getSnake().getHead().x);
    assertEquals(initialHead.y, model.getSnake().getHead().y);
}

@Test
public void testFoodCollision() {
    GameModel model = new GameModel(10, 10);
    Point foodPos = model.getFood().getPosition();
    model.getSnake().setHead(foodPos.x - 1, foodPos.y);
    model.getSnake().setDirection(Direction.RIGHT);
    int initialLength = model.getSnake().getLength();
    
    model.update(); // 应该吃到食物
    
    assertEquals(initialLength + 1, model.getSnake().getLength());
    assertNotEquals(foodPos, model.getFood().getPosition());
}

8.2 视图层测试

视图层测试可以使用"Headless"模式:

@Test
public void testViewRendering() {
    System.setProperty("java.awt.headless", "true");
    GameModel model = new GameModel(10, 10);
    GameView view = new GameView(model);
    
    view.setSize(500, 500);
    BufferedImage image = new BufferedImage(
        view.getWidth(), view.getHeight(), 
        BufferedImage.TYPE_INT_ARGB);
    view.paint(image.getGraphics());
    
    // 检查是否绘制了蛇头
    int headColor = image.getRGB(
        model.getSnake().getHead().x * CELL_SIZE + 1,
        model.getSnake().getHead().y * CELL_SIZE + 1);
    assertEquals(Color.GREEN.getRGB(), headColor);
}

9. 进一步优化的方向

如果你已经实现了基础版本,可以考虑以下扩展:

  1. 多人游戏模式 :添加网络功能实现双人对战
  2. AI 对手 :实现自动玩贪吃蛇的算法
  3. 关卡设计 :添加不同类型的障碍物和特殊食物
  4. 皮肤系统 :允许玩家自定义蛇和食物的外观
  5. 排行榜功能 :记录玩家最高分

10. 项目结构与构建建议

一个良好的项目结构能大大提高代码的可维护性。推荐如下结构:

src/
├── main/
│   ├── java/
│   │   ├── snake/
│   │   │   ├── model/       # 模型类
│   │   │   ├── view/        # 视图类
│   │   │   ├── controller/  # 控制器类
│   │   │   ├── util/        # 工具类
│   │   │   └── Main.java    # 入口类
│   │   └── resources/       # 资源文件
│   │       ├── images/      # 图片资源
│   │       └── sounds/      # 音效资源
├── test/                    # 测试代码
└── build.gradle             # 构建配置

对于构建工具,推荐使用 Gradle 并添加以下依赖:

dependencies {
    implementation 'com.google.guava:guava:31.1-jre' // 实用工具库
    testImplementation 'org.junit.jupiter:junit-jupiter:5.8.2' // 测试框架
}

11. 性能监控与调优

即使是简单的贪吃蛇游戏,性能问题也可能出现。我们可以添加简单的性能监控:

public class PerformanceMonitor {
    private long lastFrameTime;
    private double fps;
    
    public void startFrame() {
        long current = System.nanoTime();
        if (lastFrameTime != 0) {
            double frameTime = (current - lastFrameTime) / 1e6;
            fps = 1000.0 / frameTime;
        }
        lastFrameTime = current;
    }
    
    public void draw(Graphics g) {
        g.setColor(Color.WHITE);
        g.drawString(String.format("FPS: %.1f", fps), 10, 20);
    }
}

// 在游戏循环中使用
monitor.startFrame();
model.update();
view.repaint();

如果发现 FPS 下降,可以使用 Java 内置的 VisualVM 或 JProfiler 等工具进行分析。

12. 跨平台注意事项

为了让游戏在不同操作系统上表现一致,需要注意:

  1. 键盘输入差异 :不同系统可能有不同的键盘码
  2. UI 缩放问题 :高DPI显示器上可能需要特殊处理
  3. 行尾符差异 :如果保存游戏数据,注意换行符
  4. 字体可用性 :避免使用特定平台字体

一个简单的解决方案是使用系统属性检测平台:

String os = System.getProperty("os.name").toLowerCase();
if (os.contains("mac")) {
    // Mac 特定设置
} else if (os.contains("win")) {
    // Windows 特定设置
} else {
    // Linux/其他默认设置
}

13. 发布与分发

完成开发后,你可以考虑以下分发方式:

  1. 可执行 JAR :最简单的分发方式

    jar cvfe SnakeGame.jar snake.Main -C build/classes/java/main .
    
  2. JPackage (Java 14+):生成原生安装包

    jpackage --name SnakeGame --input lib --main-jar SnakeGame.jar \
             --main-class snake.Main --type dmg
    
  3. WebStart :虽然已被废弃,但仍有替代方案

  4. Applet :已淘汰,不推荐使用

14. 学习资源推荐

如果你想进一步深入学习:

  1. 书籍

    • 《Java 游戏开发实践》- 详细讲解游戏开发技巧
    • 《设计模式:可复用面向对象软件的基础》- 深入理解 MVC 等模式
  2. 在线课程

    • Coursera 的"Java 面向对象程序设计"
    • Udemy 的"Java Game Development"系列
  3. 开源项目

    • GitHub 上的开源 Java 游戏项目
    • LibGDX 框架的示例代码

15. 总结与个人体会

通过这次 MVC 架构的贪吃蛇实现,我深刻体会到良好架构的重要性。最初将所有代码混在一起时,添加一个小功能都可能引发意想不到的 bug。而采用 MVC 分离后,每个模块的职责变得清晰,测试和维护都变得更加容易。

几个特别有价值的收获:

  1. 模型应该完全独立于视图 :这使得我们可以轻松替换界面(如从 Swing 切换到 JavaFX)而不影响游戏逻辑
  2. 控制器应该尽可能薄 :它只负责转发用户输入,不包含业务逻辑
  3. 事件驱动优于直接调用 :通过事件通知状态变化,可以进一步降低耦合度

在实际项目中,我还发现了一些有趣的优化点。比如,使用位运算来加速碰撞检测,或者使用空间分区技术来优化长蛇的绘制性能。这些技巧虽然对小型游戏影响不大,但在开发更复杂的游戏时非常有用。

最后要提醒的是,架构不是银弹。对于非常简单的项目,过度设计反而会增加复杂性。MVC 在这里更多是作为学习设计模式的案例。在实际工作中,我们应该根据项目规模和团队习惯选择合适的架构。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐