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還有長度和寬度等屬性,可以建立構造函數。
用面向對象建模語言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]的。
枚舉的使用
枚舉最典型的使用場景就是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();
}
}
這樣運行出來的窗體是空的,如何畫出棋盤和貪吃蛇呢?這需要使用GraphicsAPI了。
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編程會有更深入的掌握。
來自:https://zhuanlan.zhihu.com/p/23316639