暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

Java贪吃蛇的设计与实现

天码营 2016-11-01
825

点击上面“天码营”,加入我们,快速成长~

「内容简介」实现Java贪吃蛇游戏,可以了解用面向对象的方式来编程解决问题,学习如何设计类,如何选择数据结构以及Java多线程与界面编程的基础知识。

今天来实现一个简单的贪吃蛇应用,效果如下:


这里把大概实现过程跟大家讲解一下,完整的教程比较长,大家参考Java贪吃蛇

在网上能找到不少贪吃蛇的代码,但是往往写得比较乱,甚至有所有代码都包含在一个类中的情况,对于初学者而言即使能Copy后跑起来,也不一定能够真正理解代码的逻辑。

实际上实现贪吃蛇的代码并不复杂,如果尝试去给出优雅地实现,比如写出具有清晰的类结构,有助于真正提高大家程序设计的基本功。

此外,应该让代码具有良好的扩展性,将来你希望更新你的贪吃蛇应用时,比如:

  • 让贪吃蛇显示出不同的样子

  • 增加或者修改积分规则

应该要做到修改尽量少的代码。实际的应用都是不断演化的,良好的设计能够让应用更易于维护。

所以贪吃蛇应用非常适合入门Java编程的同学,可以了解用面向对象的方式来编程解决问题,学习如何设计类,如何选择数据结构以及Java Swing的基础知识。

开始设计

Java是一门面向对象语言,一个Java程序就是一系列对象(Object)的集合,对象通过方法调用来彼此协作完成特定的功能。面向对象是一种非常符合人类思维的编程方法,因为现实世界就是由对象和对象之间的交互来构成的,所以我们其实很容易将现实世界映射到软件开发中。其实我们可以把Java语言当成是一门普通的语言,学习英语是为了与世界交流,而学习Java就是与计算机交流。我们需要把自己的思维,通过Java语言表达出来,让计算机理解。

那现在我们怎么用Java,用面向对象的思维,来表达出贪吃蛇这个游戏呢?

贪吃蛇游戏的规则无需多言,我们马上能想到两个对象,一条蛇和一个棋盘,我们可以定义两个类:

public class Snake {
}
复制
public class Grid {
}
复制

棋盘里有一条蛇,这其实就是棋盘和蛇的关系,所以可以给棋盘定义一个成员变量,类型为Snake
Grid
的代码变为:

public class Grid {
    private Snake snake;
}
复制

Grid
还有长度和宽度等属性,可以建立构造函数。面向对象的知识大家可以复习Java面向对象基础

用面向对象建模语言UML来表达这两个类的关系如下:

我们要创建的是一个窗体应用,整个负责与用户交互的窗体,可以设计一个类来表示:

public class SnakeApp {
}
复制

这些类内部定义基本还没有,不过没关系,在练习过程中就会慢慢充实起来。

表达虚拟概念的类

刚接触面向对象编程的同学,从现实世界往Java世界做对象映射往往不是什么问题,因为比较直观。比如一个人和一张桌子,对应地设计一个对应的类即可。

其实一个系统用Java语言来表达的话,往往要设计一些表达虚拟概念的类。将来大家学习到更高级的面向对象设计知识,比如设计模式,就会发现其实这些表达虚拟概念的类才往往是设计一个优秀系统的关键。

SnakeApp
作为一个窗体应用,会接收到用户的输入(比如控制贪吃蛇方向的按键操作),需要展示当前游戏的界面和状态。而Grid
则需要随机生成食物,维护着贪吃蛇的状态。那么Grid
就要根据SnakeApp
中的用户交互来控制游戏状态,因为我们可以设计一个GameController
来表示这种控制。

public class GameController {
}
复制

GameController
的职责在于接收窗体SnakeApp
传递过来的有意义的事件(比如用户改变方向),然后传递给Grid
,让Grid
即时地更新状态,同时根据最新状态渲染出游戏界面让SnakeApp
显示。

总体的设计图如下:

上面的设计其实是一个典型的MVC模式,MVC模式(Model-View-Controller)软件工程中的一种软件架构模式,把软件系统分为三个基本部分:模型(Model)、视图(View)和控制器(Controller):

  • Controller——负责转发请求,对请求进行处理:对应于GameController

  • View——负责界面显示,对应于SnakeApp

  • Model——业务功能编写(例如算法实现)、数据库设计以及数据存取操作实现,对应于Grid
    Snake

将来大家学习Java Web开发,也会接触到Spring MVC。当然对于贪吃蛇游戏最终的类设计并非如此,这只是一个最初的概览,后面我们不仅仅会充实类,而且会增加一些新的类。

贪吃蛇的方向

接下来考虑贪吃蛇的行进方向问题。贪吃蛇行进的方向可以为上下左右。一种常见的做法是定义一个包含静态常量的类或者接口,比如:

class Direction {
    public static final UP = 0;
    public static final RIGHT = 1;
    public static final DOWN = 2;
    public static final LEFT = 3;
}
复制

这是一种典型的取值范围在一个有限的数据集中的场景,这种场景有一种更好的处理方式:枚举(即Enum
)。类似的场景还有比如一周包含从星期一到星期日7个取值。

Enum
本质上是一种特殊的类,可以有更多丰富的操作,相比使用静态常量而言功能更加强大,而且具有更好的维护性。

使用枚举定义Direction

通过枚举来定义方向的代码如下:

/**
 * 贪吃蛇前进的方向
 */
public enum Direction {
    UP,
    RIGHT,
    DOWN,
    LEFT;
}
复制

相比前面的代码简洁了许多。

其实UP
RIGHT
等枚举值默认就是public
[static](https://course.tianmaying.com/java-basic+static-variable-and-method)
[final](https://course.tianmaying.com/java-basic+data-types#7)
的。

枚举的使用

枚举最典型的使用场景就是Switch
语句,比如根据贪吃蛇移动的方法来变化它的坐标位置:

switch (direction) {
    case UP:
        // 向上移动
        break;
    case RIGHT:
        // 向右移动
        break;
    case DOWN:
        // 向下移动
        break;
    case LEFT:
        // 向左移动
        break;
}
复制

我们也可以遍历一个枚举的所有取值,如:

for (Direction direction: Direction.values()) {
    System.out.println(direction);
}
复制

给枚举添加成员变量、方法和构造函数

方向有时需要进行运算,因此赋予一定的值操作起来会更加方便,比如判断两个方向是否相邻。

这里我们给Direction
中的每一个取值关联一个整数值。这时需要给枚举添加成员变量、方法和构造函数了。我们说过,Enum
是一种特殊的Class
,所以做这些事情毫无压力。

/**
 * 贪吃蛇前进的方向
 */
public enum Direction {
    UP(0),
    RIGHT(1),
    DOWN(2),
    LEFT(3);
    // 成员变量
    private final int directionCode;
    // 成员方法
    public int directionCode() {
        return directionCode;
    }
    // 构造函数
    Direction(int directionCode) {
        this.directionCode = directionCode;
    }
}
复制

上面的代码添加了一个私有的成员directionCode
作为方向的整数代码,在后面的编码中你会看到这样的代码对于运算的话会非常方便。

成员方法directionCode()
使得外部可以访问到方向的整数代码,比如:

int code = Direction.UP.directionCode();
复制

增加成员变量后,构造函数就需要传入一个代码参数进行初始化。注意枚举的构造函数不能用Public
修饰,否则在外部也能创建新的枚举值不是就会乱套了。

这时枚举的定义就可以调用新的构造函数了,传入一个整数值来初始化directionCode
,比如UP(0)
就表示向上的方向的整数代码为0。

如何设计一个类

在总体设计中我们给出了几个类,构成了应用的整体概览。具体到每一个类,则需要我们继续去定义其内部结构。

设计一个类时,往往还要考虑它的接口和继承层次,这里我们暂时无需考虑。简单地理解,一个类的内部无外乎两部分:

  • 成员变量:一个类操作的数据和内容应该被定义为成员变量,这些成员变量共同构成了一个对象的状态。

  • 成员方法:公有方法就是这个类提供给外部世界的接口,系统中的其他类可以通过公有方法来操作这个类的数据,因此需要考虑这个类的职责和功能,从而确定公有方法。私有方法则一般为公有方法的辅助方法,供内部调用。

现在我们来考虑如何编写Snake
类。

设计成员变量

一条贪吃蛇是由一个一个的节点组成的,在传统的贪吃蛇应用中这个节点通常展示为一个黑色的小方块。所以我们需要选择一种数据结构来表示这些相互连接的节点。不过在这之前,需要先定义出节点这个东西。

显然,表示节点状态的就是它的X坐标和Y坐标,那么我们通过一个类来定义节点:

package com.tianmaying.snake;
public class Node {
    private final int x;
    private final int y;
    public Node(int x, int y) {
        this.x = x;
        this.y = y;
    }
    public int getX() {
        return x;
    }
    public int getY() {
        return y;
    }
}
复制

成员变量x
y
构成了一个Node
的状态。注意这两个成员变量使用final
修饰了,表示进行初始赋值之后就不能改变。

选择数据结构

为了表示相互连接在一起的节点,我们可以为Snake
定义一个集合类型的成员变量,让集合来保存所有节点。

常用的集合类包括Map
、 List
Set
,这里显然List
是比较适合的,它提供了一系列操作一个元素序列的方法。

结合我们自己的应用场景可以发现,贪吃蛇不断变长小经常做插入操作,而且我们不需要随机去访问贪吃蛇中的某一个节点。因此选择LinkedList

有了这个思考过程,接下来Snake
的成员变量就很清晰了:

package com.tianmaying.snake;
import java.util.LinkedList;
public class Snake {
    private LinkedList<Node> body = new LinkedList<>();
}
复制

设计Snake方法

Snake
应该提供什么方法来操作自己的状态呢?贪吃蛇有两种情况下下会有状态的变化,一种是吃到食物的时候, 一种就是做了一次移动的时候。

此外,贪吃蛇也需要定一些查询自己状态和信息的公有方法。比如获取贪吃蛇的头部,获取贪吃蛇的body
,对应可以加入这些方法。

一开始可能定义的方法不够完整,没关系,在编码过程中你会很自然地发现需要Snake
提供更多方法来完成特定功能,这个时候你再添加即可。

把这些方法加入进去之后,Snake
的代码看起来就丰富多了:

package com.tianmaying.snake;
import java.util.LinkedList;
public class Snake {
    private LinkedList<Node> body = new LinkedList<>();
    public Node eat(Node food) {
        // 如果food与头部相邻,则将food这个Node加入到body中,返回food
        // 否则不做任何操作,返回null
    }
    public Node move(Direction direction) {
        // 根据方向更新贪吃蛇的body
        // 返回移动之前的尾部Node
    }
    public Node getHead() {
        return body.getFirst();
    }
    public Node addTail(Node area) {
        this.body.addLast(area);
        return area;
    }
    public LinkedList<Node> getBody() {
        return body;
    }
}
复制

eat
move
方法都给出了详细的处理流程,你自己来尝试一下吧。

这里简单解释一下贪吃蛇移动一格的处理。第一感觉是让body
中每个Node
的坐标都改变一次,这是一个很笨的o(n)的做法,其实只需要在头部增加一个Node
,尾部删除一个Node
即可。

定义意义明确的私有方法

一般情况下类中的每个方法不应该做太多的事情,体现在代码量上就是一个方法不要包含太多的代码。

一种最简单也是非常有用的方法就是提取出意义明确的私有方法,这样会让代码更加易懂,调试和维护都会更加方便。

大家可以对比一下下面两种写法:

    public Node eat(Node food) {
        if (Math.abs(a.getX() - b.getX()) + Math.abs(a.getY() - b.getY()) == 1) {
            // 相邻情况下的处理
        }
    }
复制
    public Node eat(Node food) {
        if (!isNeighbor(body.getFirst(), food)) {
            // 相邻情况下的处理
        }
    }
    private boolean isNeighbor(Node a, Node b) {
        return Math.abs(a.getX() - b.getX()) + Math.abs(a.getY() - b.getY()) == 1;
    }
复制

我们推崇第二种写法,将节点相邻判断的逻辑提取到一个新的方法中,阅读eat()
方法的代码时,一眼就知道if
语句块要处理的问题。而第一种情况下,时间长了,你可能会一时想不起来这个长长的条件语句用来干嘛的了。如果你说可以加注释的话,那么你想想让方法命名本身就成为有意义的“注释”是不是一种更好的方式呢?

Grid的数据成员

你现在的Grid
代码应该是这个样子:

package com.tianmaying.snake;
import java.util.Arrays;
public class Grid {
    private final int width;
    private final int height;
    private Snake snake;
    public Grid(int width, int height) {
        this.width = width;
        this.height = height;
    }
}
`
复制

显然这样成员变量是不足以表达一个棋盘的所有状态的,还需要以下信息:

  • 棋盘的方格是否被贪吃蛇覆盖

  • 食物的位置在哪个方格

  • 贪吃蛇目前的移动方向

一个Grid
创建后,它的长宽就是固定不变了,方格的覆盖可以用一个boolean
类型的二维数组来表示,如果一个Node被贪吃蛇覆盖,则对应坐标的数组元素为true
,否则为false

为了表达信息后,Grid
需要增加一些成员变量:

public class Grid {
    public final boolean status[][];
    private final int width;
    private final int height;
    private Snake snake;
    private Node food;
    // 初始方向默认设置为向左
    private Direction snakeDirection = Direction.LEFT;
}
复制

Grid的构造函数

创建一个棋盘时,需要做一些必要的初始化工作,比如:

  • 根据width
    height
    初始化二维数组

  • 初始化一条贪吃蛇

  • 初始化食物

这些工作都可以在构造函数中完成,构造函数就是用来初始化一个类的地方。

public Grid(int width, int height) {
   this.width = width;
   this.height = height;
   status = new boolean[width][height];
   initSnake();
   createFood();
}
复制

接下来看initSnake()
createFood()
如何实现。

关键方法:初始化贪吃蛇

我们可以根据棋盘大小来创建一只大小合适的贪吃蛇,并将其放置在棋盘的某些位置。

我们设定的规则如下:

  • 贪吃蛇的长度为棋盘宽度的三分之一

  • 贪吃蛇为水平放置,即包含的所有Node
    的Y坐标相同,Y坐标为棋盘垂直中间位置(即height / 2
    ),最左边的X
    为棋盘水平中间位置(即width / 2

所有initSnake()
的代码逻辑如下:

private Snake initSnake() {
   snake = new Snake();
   // 设置Snake的Body
   // 更新棋盘覆盖状态
   return snake;
}
复制

关键方法:随机创建食物

随机创建食物,即随机生成食物的X坐标和Y坐标。我们可以使用Java提供的Random
类来生成随机数。

这里需要注意两点:

  • 生成的X坐标和Y坐标必须在有效的范围之内,不能超过棋盘大小

  • 食物的位置不能喝贪吃蛇的位置重叠

public Node createFood() {
    int x, y;
    // 使用Random设置x和y
    food = new Node(x, y);
    return food;
}
复制

关键方法:一次移动

Sanke
move
方法中,我们只是让贪吃蛇进行移动,移动方向是否有效以及移动后游戏能否继续并没有判断,我们把这些逻辑都放到Grid
类的实现中,由Grid
类来驱动Snake
move
操作,Snake
只管执行命令即可。

每一次移动可以认为是游戏的下一步,因此我们将这个函数定义为nextRound()

如何移动后能够继续,返回true
,否则返回false

public boolean nextRound() {
    按当前方向移动贪吃蛇
    if (头部的位置是否有效) {
        if (头部原来是食物) {
            把原来move操作时删除的尾部添加回来
            创建一个新的食物
        } 
        更新棋盘状态并返回游戏是否结束的标志
    }    
}
复制

头部位置无效有两种情况:

  • 碰到边界

  • 碰到自己

吃到食物时,食物添加到原来的头部,贪吃蛇身长+1,所以之前move
操作删除的尾部添加回来就是最新的贪吃蛇状态了,而之前的实现中Snake.move()
操作已经给我们返回尾部的Node
了。

同时Grid
需要提供一个外部修改贪吃蛇行进方向的方法,如下:

public void changeDirection(Direction newDirection) {
    if (snakeDirection.compatibleWith(newDirection)) {
        snakeDirection = newDirection;
    }
}
复制

这个方法将来在处理用户的键盘输入时需要用到。我们之前实现的Direction.compatibleWith()
方法在这个时候派上用场了。

应用界面

编写完Grid
Snake
之后,我们开始考虑应用的界面展示。棋盘和贪吃蛇要在一个窗口中显示,需要使用Java Swing编程的知识。

Swing 是一个为Java提供的GUI(Graphics User Interface,图形化界面)编程工具包,是J2SE类库中的一部分,它包含了诸如文本框和按钮等一系列GUI组件。

Swing编程是一个比较大的主题,这里我们只介绍能够实现贪吃蛇效果的必要知识。此外,Java Swing编程目前来说也不能说是应用非常广泛的技术(比如相比Java Web开发),如果只是练习Java基础,了解一些基本原理和常用组件的用法即可。

我们提到过MVC模式(Model-View-Controller)。下面要实现的就是View了。这部分做完之后,你应该可以看到一条贪吃蛇静静地躺在棋盘上。

一个简单的Swing程序

SnakeApp
是我们希望用来实现界面的类,我们也将其作为整个应用初始化的地方。

下面是创建一个窗体的典型代码:

// 创建JFrame
JFrame window = new JFrame("天码营贪吃蛇游戏");
// 设置窗口大小
window.setPreferredSize(new Dimension(200, 200));
// 往窗口中添加组件
JLabel label = new JLabel("欢迎访问tianmaying.com");
window.getContentPane().add(label);
// 设置窗口为大小不可变化
window.setResizable(false);
// 窗口关闭的行为
window.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
// 渲染和显示窗口
window.pack();
window.setVisible(true);
复制

JFrame
: GUI应用的窗口对象,能够最大化、最小化和关闭,它是一个容器,允许添加其他组件,并将它们组织起来呈现给用户。

默认情况下,关闭窗口,只隐藏界面,不释放占用的内存,window.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
表示关闭窗口时直接关闭应用程序,相当于调用System.exit(0)

SnakeApp的实现

了解了如何创建一个GUI程序之后,我们可以在SnakeApp
中实现一个init()
函数骨架了:

package com.tianmaying.snake;
import javax.swing.*;
public class SnakeApp {
    public void init() {
        //创建游戏窗体
        JFrame window = new JFrame("天码营贪吃蛇游戏");
        // 画出棋盘和贪吃蛇
        window.pack();
        window.setResizable(false);
        window.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
        window.setVisible(true);
    }
    public static void main(String[] args) {
        SnakeApp snakeApp = new SnakeApp();
        snakeApp.init();
    }
}
复制

这样运行出来的窗体是空的,如何画出棋盘和贪吃蛇呢?这需要使用Graphics
API了。

Graphics API

这里有一个官方的Graphics API基础教程

从这个教程中,你可以知道Graphics
可以帮助我们画出各种图形和图像。

分析第一节中展示的界面,其实只包含了两种元素:圆形和矩形。食物是一个圆形,棋盘的背景是一个大矩形,蛇是由多个小矩形组成的。

那让我们了解一下如何画矩形和圆形吧。

  • 画一个实体的圆形,可以使用fillOval(int x,int y,int width,int height)
    方法,它用预定的颜色填充的椭圆形,当横轴和纵轴相等时,所画的椭圆形即为圆形。

  • 画一个实体的矩形,可以使用fillRect(int x,int y,int width,int height)
    方法,它用预定的颜色填充一个矩形。

为了使用Graphics API画图,我们创建一个新类GameView
来做这件事情:

package com.tianmaying.snake;
import javax.swing.*;
import java.awt.*;
public class GameView {
    private final Grid grid;
    public GameView(Grid grid) {
        this.grid = grid;
    }
    public void draw(Graphics graphics) {
        drawGridBackground(graphics);
        drawSnake(graphics, grid.getSnake());
        drawFood(graphics, grid.getFood());
    }
    public void drawSnake(Graphics graphics, Snake snake) {
    }
    public void drawFood(Graphics graphics, Node squareArea) {
    }
    public void drawGridBackground(Graphics graphics) {
    }
}
复制

可以看到在GameView
draw()
方法中,分别去画背景、贪吃蛇和食物即可,画这些东西的时候,就需要使用fillOval
fillRect
方法了。这里可以实现两个私有的辅助类:

    private void drawSquare(Graphics graphics, Node squareArea, Color color) {
        graphics.setColor(color);
        int size = Settings.DEFAULT_NODE_SIZE;
        graphics.fillRect(squareArea.getX() * size, squareArea.getY() * size, size - 1, size - 1);
    }
    private void drawCircle(Graphics graphics, Node squareArea, Color color) {
        graphics.setColor(color);
        int size = Settings.DEFAULT_NODE_SIZE;
        graphics.fillOval(squareArea.getX() * size, squareArea.getY() * size, size, size);
    }
复制

基于drawSquare()
drawCircle()
就能很容易地画出界面了。

在窗口中显示界面

知道了如何通过Graphics
画界面之后,我们还面临一个问题,如何显示在JFrame
中。

这就是使用JPanel
了,它也是一种容器类,可以加入到JFrame窗体中,而且它具有一个接口:

public void paintComponent(Graphics graphics);
复制

在这个接口中可以拿到当前面板的Graphics
实例,基于之前介绍的API就能画图了,我们按照如下方式修改GameView
的代码:

package com.tianmaying.snake;
import javax.swing.*;
import java.awt.*;
public class GameView {
    private JPanel canvas;
    public void init() {
        canvas = new JPanel() {
            @Override
            public void paintComponent(Graphics graphics) {
                drawGridBackground(graphics);
                drawSnake(graphics, grid.getSnake());
                drawFood(graphics, grid.getFood());
            }
        };
    }
    public void draw() {
        canvas.repaint();
    }
    public JPanel getCanvas() {
        return canvas;
    }
    // ...
}
复制

这部分代码需要着重解释一下,因为涉及到一种回调和匿名类几个概念。

  • GameView
    新增了一个JPanel
    类型的成员变量canvas

  • 新增了一个init()
    方法用以初始化canvas

  • 原来的draw(Graphics graphics)
    方法改为了draw()
    ,此时不需要传入参数,只需调用canvas
    repaint()
    方法即可。因为JPanel
    repaint()
    方法可以自动刷新界面

  • 原来的draw(Graphics graphics)
    实现代码移到public void paintComponent(Graphics graphics)
    方法的内部了,只要放进去即可,Swing会在合适的时机去调用这个方法,展示出合适的界面,这就是典型的回调(callback)的概念。

再来分析一下下面这个代码:

canvas = new JPanel() {
   @Override
   public void paintComponent(Graphics graphics) {
       drawGridBackground(graphics);
       drawSnake(graphics, grid.getSnake());
       drawFood(graphics, grid.getFood());
   }
};
复制

这段代码其实等价于创建一个CanvasPanel
(任何合法的命名都可以),然后在GameView
中使用。

因为这个CanvasPanel
仅仅在这里使用一次,我们就可以使用匿名类的方式,现场定义现场使用用完即走,就有了这种写法。对这样的代码了然于心的时候,说明你已经有不错的Java编程经验啦。

最后,在SankeApp
中,只需要将这个JPanel
添加到JFrame
中就行了。

    public void init() {
        // 初始化grid
        ...
        JFrame window = new JFrame("天码营贪吃蛇游戏");
        Container contentPane = window.getContentPane();
        // 基于Grid初始化gamaView
        gameView = new GameView(grid);
        gameView.init();
        // 设置gameView中JPanel的大小
        gameView.getCanvas().setPreferredSize(new Dimension(Settings.DEFAULT_GRID_WIDTH, Settings.DEFAULT_GRID_HEIGHT));
        // 将gameView中JPanel加入到窗口中
        contentPane.add(gameView.getCanvas(), BorderLayout.CENTER);
        window.pack();
        // ...
    }
复制

好了,一条呆萌的贪吃蛇已经静静躺在漆黑一片的棋盘中了。

GameController的作用

你已经可以根据一个Grid
画出来游戏界面了,接下来就要开始处理用户的按键输入了。

还记得总体设计概览图吗? 我们已经实现了大部分的类,也增加了一些新的类,现在应该是这个样子了:

这里要再次提高MVC模式,系统可以分为三个部分,模型(Model)、视图(View)和控制器(Controller):

  • Model:业务功能、核心数据结构与算法,对应蓝色部分

  • View:负责界面显示,对应黄色部分

  • Controller:负责转发用户操作事件,对事件进行处理,对应红色部分

模型和视图已经基本完成了,我们在界面中画出了贪吃蛇以及它的食物,现在,让我们学习如何通过键盘操作让贪吃蛇动起来。这就需要GameController粉墨登场了。

接收键盘事件

首先我们需要一个按键监听器,当玩家敲击键盘的时候,就可以通过按键监听器知道玩家敲击了什么按键。

Java已经为我们提供好了键盘监听的接口,其接口定义如下:

public interface KeyListener extends EventListener {
    public void keyPressed(KeyEvent e);
    public void keyReleased(KeyEvent e);
    public void keyTyped(KeyEvent e);
}
复制

Java将键盘输入分成了三个步骤,按下(press),释放(release),键入(type),对应了KeyListener
的三个方法:

  • keyPressed
    : 按下某个键时会调用该方法

  • keyReleased
    : 释放某个键时会调用该方法

  • keyTyped
    : 键入某个键时会调用该方法

我们只需要让GameController
实现该接口,即可完成一个按键监听器的实现:

public class GameController implements KeyListener {
    @Override
    public void keyPressed(KeyEvent e) {
        // 这里处理按键
    }
    @Override
    public void keyReleased(KeyEvent e) {
    }
    @Override
    public void keyTyped(KeyEvent e) {
    }
}
复制

keyReleased()
keyTyped()
方法不需要用到,我们只需要在keyPressed()
方法中进行事件处理。

这样GameController
就可以我们的游戏控制中心,我们可以通过它监听键盘并实现对界面的控制,

当然,我们需要通过下列语句在SnakeApp
进行init()
初始化时将GameController
注册进window中:

SnakeApp.java

window.addKeyListener(gameController);
复制

处理键盘事件

现在贪吃蛇还不能自动动起来,因此我们先让贪吃蛇接收到一个方向键时,就进行移动。所以keyPressed()
方法的核心逻辑是:

  • 收到按键事件

  • 根据按键情况,做一次移动

  • 移动后重现显示界面

比如处理向上移动的代码逻辑如下:

public class GameController implements KeyListener {
    @Override
    public void keyPressed(KeyEvent e) {
        int keyCode = e.getKeyCode();
        if (keyCode == KeyEvent.VK_UP) {
            grid.changeDirection(Direction.UP);
        }
        // repaint the canvas
    }
}
复制

处理好所有影响游戏状态的事件,你已经拥有了一只跟着你按键移动的贪吃蛇,不过你不按键它是静止不动的,你离完成一个完整的贪吃蛇游戏只差最后一步了。

如何让贪吃蛇移动起来

让贪吃蛇不断地移动,一个直观的处理方式是,在一个while
循环中不断调用Grid.nextRound()
方法:

while (running) {
    grid.nextRound();
}
复制

不过每次调用nextRound()
之间需要有一个时间间隔,需要给游戏玩家反应时间来在下一次移动之前进行操作,比如改变方向。

这时就可以使用Thread.sleep()
方法来让当前的执行暂时停止:

while (running) {
    try {
        Thread.sleep(Settings.DEFAULT_MOVE_INTERVAL);
    } catch (InterruptedException e) {
        break;
    }
    grid.nextRound();
}
复制

Settings.DEFAULT_MOVE_INTERVAL
的值为200,这样玩家每一次移动有0.2秒的时间来进行操作。

上面这段代码显然需要在一个新的线程中跑,否则其他线程就可能被影响,比如在接收用户输入的线程中跑这段代码的话,就无法接收用户输入了,因为都在那Sleep了。

实现游戏线程

要实现游戏线程,其实就是把第一节中的while
循环代码放入到一个线程类的run()
方法中。

那么哪个类适合作为线程类呢? 这个线程里不断调用Grid.nextRun()
方法,并且还要即时地更新界面,显然这也是术语GameController
的职责,所以让GameController
实现Runnable
接口,让它成为一个线程类。

同时为了控制一次游戏是否结束,增加一个boolean
类型的标志running

public class GameController implements Runnable, KeyListener {
    private final Grid grid;
    private final GameView gameView;
    private boolean running;
    public GameController(Grid grid, GameView gameView) {
        this.grid = grid;
        this.gameView = gameView;
        this.running = true;
    }
    @Override
    public void run() {
        while (running) {
            try {
                Thread.sleep(Settings.DEFAULT_MOVE_INTERVAL);
            } catch (InterruptedException e) {
                break;
            }
            // 进入游戏下一步
            // 如果结束,则退出游戏
            // 如果继续,则绘制新的游戏页面
        }
        running = false;
    }
}
复制

run()
函数中的核心逻辑是典型的控制器(Controller)逻辑:

  • 修改模型(Model):调用Grid
    的方法使游戏进入下一步

  • 更新视图(View):调用GameView
    的方法刷新页面

我们可以给GameView
增加一个结束游戏时的处理方法,可以在run()
方法中调用:

public void showGameOverMessage() {
    JOptionPane.showMessageDialog(null, "游戏结束", "游戏结束", JOptionPane.INFORMATION_MESSAGE);
}
复制

这里简单弹出一个显示游戏结束信息的对话框。

启动线程

如何启动线程呢?在SnakeApp
init()
方法中增加一条语句即可:

...
gameController = new GameController(grid, gameView);
window.addKeyListener(gameController);
// 启动线程
new Thread(gameController).start();
复制

至此,一个完整的贪吃蛇游戏就搞定了。

需要注意的是,多线程程序往往涉及到线程同步的问题,多个线程同时访问一个变量会影响业务逻辑时,就需要专门的同步处理。在贪吃蛇应用中,事件处理线程和现在实现的GameController
线程都会访问Grid
direction
变量,只不过访问和修改的顺序对游戏并没有什么影响,所以我们可以不做同步处理。

更进一步

如果你希望更进一步地优化贪吃蛇应用,现在的应用已经非常好扩展了,比如:

  • 积分功能:可以创建得分规则的类(模型类的一部分), 在GameController
    run()
    方法中计算得分

  • 变速功能:比如加速功能,减速功能,可以在GameController
    keyPressed()
    方法中针对特定的按键设置每一次移动之间的时间间隔,将Thread.sleep(Settings.DEFAULT_MOVE_INTERVAL);
    替换为动态的时间间隔即可

  • 更漂亮的游戏界面:修改GameView
    中的drawXXX
    方法,比如可以将食物渲染为一张图片,Graphics
    drawImage
    方法

  • 如果希望更多了解Swing编程,则可以在游戏界面上增加更多的组件,比如积分的Lable和启动结束的按钮等

所以贪吃蛇应用非常适合入门Java编程的同学。可以了解用面向对象的方式来编程解决问题,学习如何设计类,如何选择数据结构、Java Swing编程和多线程编程的基础知识。这中间也涉及很多Java编程经常碰到的问题,比如匿名类和回调方法等,你会发现你对于Java SE编程会有更深入的掌握。

想进一步深入了解贪吃蛇应用的更多细节知识,请阅读完整的教程(或者戳原文链接)



了解更多Java Web开发内容:

点击下方“阅读原文”,可以获得更多天码营教程。

文章转载自天码营,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论