Java贪吃蛇应用的设计与实现

Java 小游戏之贪吃蛇 Swing 实现 整体架构设计视图与控制层:由GamePanel类承担,该类继承自JPanel,负责游戏状态的更新、图形绘制以及用户交互处理。所有游戏逻辑均在本类中实现。应用层:由SnakeGame类实现,该类用于创建窗口、添加GamePanel并管理窗口的基本行为,确保游戏能够在独立窗口中运行。关键模块与功能设计游戏状态管理游戏中使用两个数组分别存储蛇的每个部分的 x 坐标和 y 坐标,并通过变量dots表示当前蛇的长度。游戏初始化时,蛇的长度固定为 3,位置设置在面板中固定的起始点;随后通过。 阅读详情

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

Untitled.gif

这里把大概实现过程跟大家讲解一下,完整的教程比较长,

在网上能找到不少贪吃蛇的代码,但是往往写得比较乱,甚至有所有代码都包含在一个类中的情况,对于初学者而言即使能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显示。

总体的设计图如下:

alter-text

上面的设计其实是一个典型的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了。这部分做完之后,你应该可以看到一条贪吃蛇静静地躺在棋盘上。

界面@2x.png

 

一个简单的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();
    }
}

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

 

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画出来游戏界面了,接下来就要开始处理用户的按键输入了。

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

基本流程图 (1).png

这里要再次提高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开发,如果你现在也在了解Java,想要学习Java,渴望成为一名合格的Java开发工程师,在入门学习Java的过程当中有遇见类似于:如何自学Java,自学Java到什么程度才能就业,如何高效的学习Java开发等等一系列的问题,你们都可以申请加入的Java学习QQ群697888503,有什么不懂的都可以随时来问我,对于这方面的问题我还是比较在行。缺乏入门基础的视频教程也可以直接来找我要,我这里有一套2020年最新的Java基础精讲视频教程,群文件里面还有我做Java技术这几年整理的一些Java学习手册,面试题,开发工具,PDF文档书籍教程,需要的话都可以来获取下载。

贪吃蛇游戏Java实现详细教程 本文还有配套的精品资源,点击获取 简介:贪吃蛇是一款经典游戏,本文详细介绍了使用Java编程语言实现贪吃蛇的完整过程,包括游戏界面设计、事件处理、游戏循环、数据结构、碰撞检测、速度控制、分数系统等关键技术点。源代码含有注释,适合初学者通过实践理解Java编程和游戏开发。 1. Java游戏开发简介 1.1 Java游戏开发的兴起 Java作为一种高级编... 阅读详情

相关推荐

深入探索Java贪吃蛇游戏源码与程序设计

在贪吃蛇游戏中,核心类的设计与功能划分是实现游戏逻辑的关键。我们主要关注三个核心类:蛇类、食物类和游戏状态类。以下是每个类的详细设计和实现。

weixin_42591908的博客 1076

Java实现贪吃蛇大作战小游戏(完整版)

用swing技术实现编写一个贪吃蛇大作战小游戏,项目供大家参考学习交流。

陈橘又青的博客 7万+

Java贪吃蛇游戏开发详解

本文还有配套的精品资源,点击获取 简介:Java贪吃蛇游戏是一款基于经典“Snake”游戏概念的简单电子游戏。玩家控制一条逐渐增长的蛇,通过吃食物来增加长度,避免撞到边界或自身结束游戏。游戏开发涉及Java基础知识,包括面向对象编程、图形用户界面设计、事件处理等。通过构建贪吃蛇、食物、游戏窗口和控制逻辑类,学习如何使用Swing或JavaFX库来创建GUI,并通过事件监听器...

weixin_30591519的博客 1249

JavaSE项目 | 纯Java实现贪吃蛇小游戏

JavaSE项目 | 纯Java实现贪吃蛇小游戏

m0_61933976的博客 2万+

JAVA贪吃蛇游戏项目实践设计:代码解析与逻辑实现

本文还有配套的精品资源,点击获取 简介:该项目是一个经典的Java编程实践项目,专注于面向对象编程、GUI设计和事件处理等关键技能。它要求学生通过实现贪吃蛇游戏,将所学的Java知识转化为实践技能。项目涉及Java基础语法、类和对象的深入理解,以及图形界面的构建和事件的响应处理。同时,它也涵盖了线程管理、算法实现、数据结构的应用和设计模式的实践,最后还要求学生编写相关文档和...

weixin_42518334的博客 1580

Java贪吃蛇游戏设计与实现

本文还有配套的精品资源,点击获取 简介:贪吃蛇小游戏是一个适用于Java初学者的游戏开发项目,涵盖了游戏编程的核心概念。它包括贪吃蛇的基本功能如移动、食物生成、碰撞检测和游戏结束逻辑。学习这个项目能够帮助初学者掌握面向对象编程、GUI设计、事件处理、线程管理、碰撞检测、数据结构、分数系统、随机数生成以及条件语句和循环等关键编程技巧。 1. 面向对象编程在贪吃蛇游戏中的...

weixin_42174098的博客 1183

Java课程设计——贪吃蛇小游戏

一.项目简介:设计目标及完成功能本软件是针对贪吃蛇小游戏的JAVA程序,利用上、下、左、右方向键来改变蛇的运动方向,在随机的地方产生食物,吃到食物就变成新的蛇体,碰自身则游戏结束,否则正常进行游戏。二.项目构架图三.团队成员任务简介队长:张德群任务:贪吃蛇游戏界面,贪吃蛇图像的引用,团队博客以及git的书写,以及贪吃蛇的移动;队员:李连崧任务:贪吃蛇长度的增加以及食物的生成,以及贪吃蛇的死亡条件。四.代码。1.Startgame类/*绘制一个静态窗口*/

m0_74549522的博客 3301

Java Swing实现经典贪吃蛇小游戏

Java Swing 是 Java 提供的一套轻量级图形用户界面(GUI)开发工具包,广泛用于桌面应用程序的构建。其组件丰富、跨平台兼容性强,适合中小型 GUI 项目的快速开发。贪吃蛇作为经典的益智类游戏,结构清晰、逻辑明确,是学习 Swing 界面编程与事件驱动机制的理想项目载体。本章将介绍 Swing 的基本特性,如组件模型、布局管理和事件处理机制,并阐述为何选择 Swing 实现该游戏。

weixin_32456485的博客 914

基于Java的贪吃蛇游戏设计与实现

本次设计实验完成后,让我对程序的设计、实现、调试与维护进行了切实的体验。深刻的了解到了前期设计对于整个软件编程过程是多么重要。因为设计的不完善,在随后的调试当中走了好多的弯子。而实际上,本次设计代码实现当中所出现的绝大部分问题,都是由于我在前期设计时候的不完善,设计太粗犷所引起的。而实际上整个过程中唯独编程(即实现)所花时间是最少的,在调试阶段所花的时间最多。但幸运的是,因为采用螺旋式设计方式帮助我解决了很大一部分问题,并让我对设计的理解的深化,虽然由于时间问题,我在工作过程中的很多有趣的设想并没有完成。

qq_61141142的博客 2749

Java实现的美化版贪吃蛇游戏:源码与素材全解析

Java自1995年问世以来,凭借其“一次编写,到处运行”的跨平台特性,迅速成为流行的编程语言之一。它不仅在企业级应用、Web开发等领域占据重要地位,同样在游戏开发中也展现出巨大潜力。Java的垃圾回收机制、成熟的API和生态系统使其成为游戏开发者在追求跨平台兼容性和快速开发时的优选语言。Swing提供了一套广泛的组件供开发者使用。这些组件可以根据功能分为多个类别,比如基本组件(按钮、标签、文本框等)、选择组件(复选框、单选按钮等)、容器组件(面板、框架等)以及复杂的组件(表格、列表等)。

weixin_30299319的博客 539

Java贪吃蛇游戏毕业设计完整包:源代码与论文

贪吃蛇游戏是一项简单的游戏,玩家通过键盘控制一条蛇在屏幕上移动,吃掉出现的食物,蛇随着吃掉食物会逐渐变长。游戏的主要挑战在于蛇不断增长的身体和不断加快的速度,玩家必须避免蛇头撞到自己的身体或游戏边界,否则游戏结束。毕业设计项目的主要目标是通过Java语言实现一个功能齐全的贪吃蛇游戏。游戏设计应包括以下几个方面:游戏框架:设计一个稳定的游戏循环和事件处理机制。图形用户界面(GUI):开发一个简洁而直观的游戏界面。游戏逻辑:实现贪吃蛇的移动、成长以及食物的随机生成。

weixin_42518874的博客 1034

使用Java实现一个简单的贪吃蛇小游戏

贪吃蛇小游戏

jay_musu的博客 1万+

基于Java的游戏“贪吃蛇的设计和实现

基于Java的游戏“贪吃蛇的设计和实现附带全部源码附带毕业设计全部文档开题报告任务书,论文,答辩ppt,中期检查,外文翻译

java贪吃蛇难度设计_Java贪吃蛇应用的设计与实现

今天来实现一个简单的贪吃蛇应用,效果如下:在网上能找到不少贪吃蛇的代码,但是往往写得比较乱,甚至有所有代码都包含在一个类中的情况,对于初学者而言即使能Copy后跑起来,也不一定能够真正理解代码的逻辑。实际上实现贪吃蛇的代码并不复杂,如果尝试去给出优雅地实现,比如写出具有清晰的类结构,有助于真正提高大家程序设计的基本功。此外,应该让代码具有良好的扩展性,将来你希望更新你的贪吃蛇应用时,比如:让贪吃蛇...

weixin_39588209的博客 1079

基于Hadoop的NBA篮球球员大数据分析与可视化系统大数据分析系统python的项目(源码+lw+部署文档+讲解等)

本系统以 Hadoop 为基础,致力于对 NBA 篮球球员的海量数据进行深入分析与可视化呈现。它能够收集来自多个数据源的丰富数据,包括球员比赛统计数据、身体素质数据、技术特点数据等。利用 Hadoop 的分布式存储和计算能力,对这些大规模数据进行高效处理。通过数据分析算法,挖掘球员的潜在价值和特点,如球员的得分能力趋势、篮板球效率变化、不同位置球员的技术差异等。同时,借助先进的可视化技术,将分析结果以直观的图表、图形等形式展示出来。教练和球队管理层可以通过可视化界面清晰地了解球员的综合表现和发展态势,从而制

QQ3359892174的博客 1314

MOOS-ivp 实验一 MOOS软件的安装与执行

MOOS-ivp实验一 实验一步骤总结 文章目录MOOS-ivp实验一前言一、实验一1.下载软件2.安装必要的插件3.安装后检查4.路径添加5.执行示例文件总结 前言 在moos学习过程中,我觉得最好把学习的每一步骤都总结一下,方便日后进行回顾。moos官网设置了十八个实验,涉及到了moos的各个方面,完成这些实验也是moos入门的好办法。 一、实验一 实验一主要内容仅仅是软件的安装,仅仅这一步就花费了我接近两天的时间。主要问题发生在linux系统的配置问题上。 1.下载软件 svn co https:

weixin_44151170的博客 6669

Java小游戏——贪吃蛇

Java小游戏之贪吃蛇 系统目标 贪吃蛇是一个益智类游戏,通过本游戏的设计和实现,可以提升Java技术能力,提升自己独立开发的能力及掌握项目的开发流程。 开发环境 系统环境:Windows 开发工具:Eclipse Mars、JDK1.8 需求分析 操作流程需求: 在一定范围内,生成一条蛇和随机生成一个食物,当蛇吃到食物之后,蛇身变长,通过键盘的方向键可以控制蛇的运行方向,当蛇头碰到障碍物或者碰到...

qq_44832215的博客 4359

U8-固定资产月末结账报错:BOF或EOF中有一个是真

度了下,发现此问题一般是由于fa_Vtsobject表缺少对应月份的记录导致,补上即可,但是查看发现此表记录正常。 跟踪后发现定位到以下语句: select * from AccInformation where cSysID = N'FA' and cName =N'bVoucherApply'上述语句执行后没有任何记录显示,但是在正常的账套执行是有记录的,那有可能就是记录丢失导致,执行以下

guizhouakun的博客 5917

java多线程贪吃蛇实验报告_JAVA开放性实验报告贪吃蛇

JAVA开放性实验报告贪吃蛇 JAVA程序设计 开放性实验报告 专 业: 计算机科学与技术 班 级: 2012级2班 学 号: 姓 名: 实验2贪吃蛇游戏设计(4学时) 1.实验内容 (1) 创建软件主窗体和相关控制菜单;在窗体中以图形模拟蛇的形状,使用键盘控制图形在窗体中任意移动,模拟贪吃蛇的行为. (2) 在窗体中可以设置障碍或奖励物品,以达到游戏的娱乐性。 2.预习内容 Java GUI编程...

weixin_42468240的博客 773

IC面试常考题 Verilog三分频电路设计(占空比50%,三分之一,三分之二)

实现三分频电路最简单的是: 利用计数器实现。 时序图分析(本人比较懒,平常科研忙,所以直接手画时序图了,懒得用软件画了): 直接上图分析:利用计数器每隔三个周期信号翻转一次,同时在不同的计数下翻转得到的同步信号 clk_1和clk_2,再利用异或即可实现出一个不同占空比的三分频信号(同样的方法也可扩展到其他奇数分频设计中)。 例如占空比50%: module Div_three( input clk, input rst_n, output div_three ); reg

qq_41884662的博客 1万+

ICC2: pin shape不在track上引起的drc如何解决?

我正在「拾陆楼」和朋友们讨论有趣的话题,你⼀起来吧?

m0_61544122的博客 434

贪吃蛇代码java_Java实现贪吃蛇小游戏(附完整源码)

原标题:Java实现贪吃蛇小游戏(附完整源码)今天我就从零开始来完成这个小游戏,完成的方式也是一步一步的添加功能这样的方式来实现。第一步完成的功能:写一个界面大家见到的贪吃蛇小游戏,界面肯定是少不了的。因此,第一步就是写一个小界面。实现代码如下: 第二步完成的功能:在界面上画成一格一格的我们见过的贪吃蛇游戏,是有一个格子一个格子构成,然后蛇在这个里面运动。重写paint方法,单元格就是横着画几条线...

weixin_39744240的博客 666

22、预条件技术:矩阵分解与多项式预条件器

本文系统介绍了预条件技术中的不完全分裂法、多项式预条件器和近似逆预条件器。内容涵盖矩阵分解的基本原理、不完全LU和Cholesky分解的实现方法、基于填充水平和阈值策略的改进技术,以及多项式和近似逆预条件器的数学基础与应用。通过算法流程图和表格对比,展示了各类预条件器的适用场景与优缺点,同时探讨了其在迭代求解中的关键作用和未来发展方向。

theta的博客 95

JAVA课程设计

近几年,随着一个体闲游戏贪吃蛇大作战的火爆,越来越多的人选择在无聊的时候玩这款游戏,比起以前在那种按键手机玩的贪吃蛇,这款游戏不管是画面还是游戏的玩法都做的升级,在试玩了这款游戏以后,我就在想,我可不可以用这学期学的java界面编程也来做一款类似这样的游戏呢,于是通过查找资料发现可以实现这样的游戏,这样我的项目带有登陆界面的贪吃蛇这款小游戏就产生了。(1)需要这款贪吃蛇小游戏带有登陆界面。(2)实现不同用户通过不同的账号和密码登陆。(3)登陆账号或者密码错误需要有提示。

weixin_51053555的博客 1139

uniapp - 超详细的 H5 公众号网页微信登录示例代码,提供从 0-1 公众号配置及详细注释代码(站在新手小白的角度)第三方微信授权登录的实现!!

uniapp微信浏览器H5授权微信登录,UniApp开发H5接入微信登录的全过程,uniapp的H5页面使用微信登录,uniapp H5微信授权登录,H5之微信授权登陆 (uniapp网页版微信授权登录),uni-app开发H5第三方微信授权登录的实现,uniapp如何实现微信授权登录,公众号H5页面接入微信登录流程,uni-app - 最详细 H5 网页接入微信登录功能,提供公众号配置与详细注释示例代码(移动端网页实现点击登录按钮后 调用微信公众号授权登录功能 详细讲解接入流程与详细示例代码)官方最新超级

✅ 精选推荐文章 1万+

Docker(八):Docker Compose(容器编排) 管理多容器应用—实战案例演示

🟢 Docker(八):Docker Compose(容器编排) 管理多容器应用—实战案例演示

✨ 欢迎来到【Seal ^_^ 的优快云博客】!✨ 6万+
上一篇: 零基础学习Java,有没有什么建议?
下一篇: 程序员必备计算机基础知识总结电子书下载
又是一个特殊的一天
博客等级 码龄7年 287粉丝 0原创
评论 1
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

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

抵扣说明:

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

余额充值