目录
前言
在上一篇博客中 开发Windows贪吃蛇游戏——(一)前期准备 我们对贪吃蛇游戏有了一定的分析,这次就对其中的功能做一下具体的 代码实现。
窗体部分
首先创建主类,命名为Game.java,代码如下。
public class Game {
public static void main(String[] args){
new Frame();
}
}
此处只需简单调用Frame的构造方法,创建一个新的Frame框架即可。
Frame部分
建立Frame类,命名为Frame.java。
在Frame类中 暂时 只需要简单调用生成一个新的Panel面板,后续在实现了基本功能后再对Frame进行一些修改。
//generate game main panel
this.add(new Panel());
this.setTitle("Snake Game");
//define the close operation
this.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
this.setResizable(false);
//adaptive size
this.pack();
this.setVisible(true);
//set location to center
this.setLocationRelativeTo(null);
接下来我们使用Panel生成主体,在Panel.java中生成以下方法。
public class Panel extends JPanel implements ActionListener{
public void startGame();
public void paintComponent(Graphics g);
public void drawGame(Graphics g);
public void newSnake();
public void newFood();
public void moveSnake();
public void checkFoods();
public void checkCollisions();
public void gameOver(Graphics g);
public void gamePause(Graphics g);
public void gameWelcome(Graphics g);
@Override
public void actionPerformed(ActionEvent e);
}
public class GameKeyAdapter extends KeyAdapter{
@Override
public void keyPressed(KeyEvent e);
}
声明全局变量
本项目中有很多全局变量,有些是boolean类型,作为Flag标志位;有些则是final的,单纯作为常量使用,后面会详细的一一介绍。
//basic configuration, mostly used parameters are down below
//800*600 1020*760 1360*760 1400*1040
//1600*900 1920*1080 2560*1440 4080*2160
static int SCREEN_WIDTH = 800;
static int SCREEN_HEIGHT = 600;
static final int UNIT_SIZE = 20;
static final int GAME_UNITS =
SCREEN_WIDTH * SCREEN_HEIGHT / UNIT_SIZE;
//speed | update gap
static int DELAY = 200;
//snake storage
final int[] x =new int[GAME_UNITS];
final int[] y =new int[GAME_UNITS];
//original snake length
int bodyParts = 4;
//spawn detection
int tempSnake = 1;
//score & part of the body length
int foodEaten = 0;
//food position
int foodX = 0;
int foodY = 0;
//direction
char runningDirection = 'R';
//serve for single input judgement
char reservedDirection = 'R';
//determine the whole game state
boolean runningState = false;
//determine pause state
boolean pauseTrigger = false;
//determine whether the game is at the initial window
boolean welcomeTrigger = true;
//serve for difficulty change by time
int tempDifficultyCounts = 0;
//colorful snake temp
boolean redColorDecrease = true;
boolean greenColorDecrease = true;
boolean blueColorDecrease = true;
int redColor = 102;
int greenColor = 204;
int blueColor = 255;
//game clock
Timer timer;
//random generate for food position
Random random;
Panel的初始化
在Panel的构造方法中,初始化random随机对象,调用startGame方法,启动整个游戏。
Panel(){
random = new Random();
//set resolution
this.setPreferredSize(
new Dimension(SCREEN_WIDTH, SCREEN_HEIGHT));
//set background color
this.setBackground(Color.black);
this.setFocusable(true);
this.addKeyListener(new GameKeyAdapter());
startGame();
}
startGame方法
调用newSnake方法,创建一个新的蛇,调用newFood方法,创建一个新的食物。接着设置游戏状态标志位runningState为true,此标志位一旦为false,即代表游戏结束。最后初始化timer,但是不启动timer,等用户跳过欢迎界面时再启动timer,开始定时刷新。
//new game initialization with default snake & food
newSnake();
newFood();
//set running state
runningState = true;
timer = new Timer(DELAY, this);
//start clock and stop when game over
paintComponent方法
要注意,paintComponent是Java Swing的一个方法,类似于Main方法,是自动执行的,执行后调用drawGame,用于绘制当前帧。
//set graphics
super.paintComponent(g);
drawGame(g);
drawGame方法
在drawGame中需要绘制地图、墙、食物、蛇、欢迎界面、暂停界面、结束界面,具体实现如下。
遍历整个地图,利用drawLine方法划线,生成网格状地图。
//draw grid
for (int i = 0; i < SCREEN_WIDTH / UNIT_SIZE; i++) {
g.drawLine(i * UNIT_SIZE, 0,
i * UNIT_SIZE, SCREEN_HEIGHT);
}
for (int i = 0; i < SCREEN_HEIGHT / UNIT_SIZE; i++) {
g.drawLine(0, i * UNIT_SIZE,
SCREEN_WIDTH, i * UNIT_SIZE);
}
将四周最外围的格子作为墙,利用fillRect方法填充成矩形,颜色要和背景颜色区分开。
//draw walls
g.setColor(new Color(212, 242, 231));
for (int i = 0; i < SCREEN_WIDTH / UNIT_SIZE; i++) {
g.fillRect(i * UNIT_SIZE, 0,
UNIT_SIZE, UNIT_SIZE);
g.fillRect(i * UNIT_SIZE, SCREEN_HEIGHT - UNIT_SIZE,
UNIT_SIZE, UNIT_SIZE);
}
for (int i = 0; i < SCREEN_HEIGHT / UNIT_SIZE; i++) {
g.fillRect(0, i * UNIT_SIZE,
UNIT_SIZE, UNIT_SIZE);
g.fillRect(SCREEN_WIDTH - UNIT_SIZE, i * UNIT_SIZE,
UNIT_SIZE, UNIT_SIZE);
}
当用户未跳过欢迎界面时,绘制欢迎界面,调用gameWelcome方法。
if(welcomeTrigger){
gameWelcome(g);
}
当用户已跳过欢迎界面时,绘制剩余内容,首先绘制食物和蛇。此处的蛇头和蛇身颜色有所区分,绘制前初始化全局变量redColo、greenColor、blueColor为指定的RGB颜色,蛇身本身由两个if循环和一个标志位限制,产生渐变色。其中关键是控制RGB的值在0~255之间,且能够每次有小幅变动。
else{
//draw food
g.setColor(new Color(0, 255, 127));
g.fillRoundRect(foodX + 2, foodY + 2,
UNIT_SIZE - 4, UNIT_SIZE - 4,
UNIT_SIZE - 4, UNIT_SIZE - 4);
//draw snake with unique head color
redColor = 102;
greenColor = 204;
blueColor = 255;
redColorDecrease = true;
greenColorDecrease = true;
blueColorDecrease = true;
for(int i = 0; i < bodyParts + foodEaten; i++){
if(i == 0){
g.setColor(Color.blue);
}
else{
if(redColorDecrease){
redColor -= 3;
if(redColor < 10){
redColorDecrease = false;
}
}
else{
redColor += 3;
if(redColor > 245){
redColorDecrease = true;
}
}
if(greenColorDecrease){
greenColor -= 4;
if(greenColor < 10){
greenColorDecrease = false;
}
}
else{
greenColor += 4;
if(greenColor > 245){
greenColorDecrease = true;
}
}
if(blueColorDecrease){
blueColor -= 5;
if(blueColor < 10){
blueColorDecrease = false;
}
}
else{
blueColor += 5;
if(blueColor > 245){
blueColorDecrease = true;
}
}
g.setColor(new Color(redColor, greenColor, blueColor));
}
g.fillRect(x[i], y[i], UNIT_SIZE, UNIT_SIZE);
}
}
当蛇死亡时,将蛇头设置为红色,同时调用gameOver方法,绘制结束界面。
//draw dead snake head
if(!runningState){
g.setColor(Color.red);
g.fillRect(x[0], y[0], UNIT_SIZE, UNIT_SIZE);
gameOver(g);
}
当在运行中触发游戏暂停时,调用gamePause方法,停止下一帧的运算,并绘制暂停界面。
//pause without stop clock
if(pauseTrigger){
gamePause(g);
}
newSnake方法
在屏幕中央附近绘制蛇,方向为横向,且蛇头朝右,若蛇长度超过了横向的单元格个数,将报错。
if(tempSnake > SCREEN_WIDTH / UNIT_SIZE){
JOptionPane.showMessageDialog(this,
"Spawn snake error!", "Error",
JOptionPane.ERROR_MESSAGE);
}
for(int i = bodyParts; i > 0; i--){
//set default position
x[i - 1] = tempSnake * UNIT_SIZE;
y[i - 1] = (SCREEN_HEIGHT / UNIT_SIZE / 2)
* UNIT_SIZE;
tempSnake ++;
}
//reset to default
tempSnake = 1;
newFood方法
绘制食物可以利用之前的random产生随机数,但是要注意,生成后需要检测是否和墙重叠,或者和蛇头重叠,一旦重叠需要重新生成。
//set random position in map except the walls
foodX = random.nextInt((SCREEN_WIDTH / UNIT_SIZE) - 2)
* UNIT_SIZE + UNIT_SIZE;
foodY = random.nextInt((SCREEN_HEIGHT / UNIT_SIZE) - 2)
* UNIT_SIZE + UNIT_SIZE;
//detect pre-collision with snake head & respawn
if((x[0] == foodX) && (y[0] == foodY)){
newFood();
}
moveSnake方法
在重绘每一帧之前,都需要重新计算蛇的移动,利用runningDirection标志位,判断蛇的移动,然后利用for循环,将蛇身前一节的位置赋给后一节,若此时蛇吃到了食物,会被后续的函数判断,并增长蛇身,在本方法中无需额外考虑。
//copy the former one from the tail to the neck
for(int i = bodyParts + foodEaten; i > 0; i--){
x[i] = x[i - 1];
y[i] = y[i - 1];
}
//set the head position
switch(runningDirection){
case 'U':
y[0] = y[0] - UNIT_SIZE;
break;
case 'D':
y[0] = y[0] + UNIT_SIZE;
break;
case 'L':
x[0] = x[0] - UNIT_SIZE;
break;
case 'R':
x[0] = x[0] + UNIT_SIZE;
break;
}
checkFood方法
检测是否蛇头和食物重叠,若重叠说明吃到食物,需要重新生成食物,并增加蛇身长度,此处也减少了每一帧绘制的延迟,增加了游戏难度。
if((x[0] == foodX) && (y[0] == foodY)){
//score & snake length increase
foodEaten++;
//increase difficulty
if(DELAY > 50){
DELAY--;
}
//respawn food
newFood();
}
checkCollisions方法
检测蛇是否与墙或蛇身碰撞,一旦碰撞后将runningState 标志位置为false,同时停止计时器,准备进入结束界面。
//check snake body with snake head
for(int i = 1; i < bodyParts + foodEaten; i++){
if ((x[i] == x[0]) && (y[i] == y[0])) {
runningState = false;
break;
}
}
//check the whole snake with the walls
if((x[0] == 0) || (x[0] == SCREEN_WIDTH - UNIT_SIZE)
|| (y[0] == 0) || (y[0] == SCREEN_HEIGHT - UNIT_SIZE)){
runningState = false;
}
if(!runningState){
timer.stop();
}
gameOver方法
被调用后再屏幕中央绘制Game Over字样,在下方绘制分数。
//ending marks
g.setColor(new Color(139,0,0));
g.setFont(new Font("Consolas", Font.BOLD, 70));
FontMetrics metricsUp = getFontMetrics(g.getFont());
g.drawString("Game Over",
(SCREEN_WIDTH -
metricsUp.stringWidth("Game Over")) / 2,
SCREEN_HEIGHT / 2 - 50);
//final scores
g.setColor(new Color(255,250,250));
g.setFont(new Font("Consolas", Font.BOLD, 30));
FontMetrics metricsDown = getFontMetrics(g.getFont());
g.drawString("Your Score: " + foodEaten,
(SCREEN_WIDTH -
metricsDown.stringWidth(
"Your Score: " + foodEaten)) / 2,
SCREEN_HEIGHT / 2 + 50);
gamePause和gameWelcome方法
gamePause和gameOver类似,被调用后再屏幕中央绘制Game Pause字样,在下方绘制分数,具体代码参考上文。
gameWelcome则不显示分数,显示Press space to start。
actionPerformed方法
actionPerformed是游戏能运行的关键,当游戏处于运行状态时,先锁存runningDirection到reservedDirection,再调用moveSnake方法移动蛇的位置;调用checkFoods方法检测是否吃到食物;调用checkCollisions方法检测是否撞到墙或自身;同时引入tempDifficultyCounts标志位,每五次刷新就增加一些难度,以上都执行完毕后调用repaint方法重绘新的窗口。此处锁存是由于键盘可以在两帧之间输入多次,需要过滤冗余的输入。
//frame change
if((!pauseTrigger) && runningState) {
reservedDirection = runningDirection;
moveSnake();
checkFoods();
checkCollisions();
//increase difficulty by time
if (tempDifficultyCounts == 5) {
tempDifficultyCounts = 0;
if(DELAY > 50){
DELAY--;
}
}
else{
tempDifficultyCounts++;
}
}
repaint();
GameKeyAdapter类
这个类比较特殊,主要是用来接收键盘输入的,需要extends继承KeyAdapter类,重载keyPressed方法,获得KeyEvent对象,然后加以判别。此处涉及到键盘的上下左右键,用来修改runningDirection标志位,其中,当锁存的reservedDirection标志位和键盘输入的方向相反时,将此次输入视为无效,此处仅给出一个方向的例子,其他方向类似。
switch(e.getKeyCode()){
//4 directions
case KeyEvent.VK_LEFT:
if(reservedDirection != 'R'){
runningDirection = 'L';
}
if(reservedDirection == 'L'){
if(!welcomeTrigger){
if((pauseTrigger) && (runningState)) {
pauseTrigger = false;
}
}
}
break;
}
当按下键盘的P键,若此时游戏已经运行,需要将pauseTrigger取反,即P键可以暂停,也可以继续游戏。
case KeyEvent.VK_P:
//pause
if(!welcomeTrigger){
pauseTrigger = (!pauseTrigger) && (runningState);
}
break;
类似的还有空格键,按下后将welcomeTrigger置为false,R键,按下后需要先将timer停止,接着将welcomeTrigger置为false,然后将前面除了welcomeTrigger以外的全局变量重新初始化,最后重新调用startGame方法,并启动timer。
Frame的改进
书接上文,至此,绝大部分功能都已实现,但是为了增加实用性,我们需要在Frame上添加菜单,添加背景音乐,修改程序图标,最终实现修改分辨率。
菜单及子菜单的生成
菜单的设计共有两栏,分别为Game和Settings,其中Game包含一级子菜单Start、Pause & Resume、Quit;Settings包含一级子菜单Resolution和Music,Resolution包含二级子菜单800 x 600、1020 x 760、1360 x 760、1400 x 1040、1600 x 900、1920 x 1080、2560 x 1440、3840 x 2160;Music包含二级子菜单On和Off。
其中的关键是需要注意MenuItem的类型,是按钮、单选还是复选,若是后两者则需绑定MenuItem到同一Group内,单选框可以提前设置默认选择。
JMenuBar menuBar = new JMenuBar();
//menu game control
JMenu gameMenu = new JMenu("Game");
//sub menu buttons
JMenuItem startMenuItem = new JMenuItem("Start");
JMenuItem pauseMenuItem = new JMenuItem("Pause & Resume");
JMenuItem quitMenuItem = new JMenuItem("Quit");
//binding sub button to main button
gameMenu.add(startMenuItem);
gameMenu.add(pauseMenuItem);
gameMenu.add(quitMenuItem);
//menu game settings
JMenu settingMenu = new JMenu("Settings");
//sub menu resolution
JMenu resolutionMenu = new JMenu("Resolution");
//resolution choices with only single choice
JRadioButtonMenuItem resolutionMenuItem1 =
new JRadioButtonMenuItem("800 x 600");
JRadioButtonMenuItem resolutionMenuItem2 =
new JRadioButtonMenuItem("1020 x 760");
JRadioButtonMenuItem resolutionMenuItem3 =
new JRadioButtonMenuItem("1360 x 760");
JRadioButtonMenuItem resolutionMenuItem4 =
new JRadioButtonMenuItem("1400 x 1040");
JRadioButtonMenuItem resolutionMenuItem5 =
new JRadioButtonMenuItem("1600 x 900");
JRadioButtonMenuItem resolutionMenuItem6 =
new JRadioButtonMenuItem("1920 x 1080");
JRadioButtonMenuItem resolutionMenuItem7 =
new JRadioButtonMenuItem("2560 x 1440");
JRadioButtonMenuItem resolutionMenuItem8 =
new JRadioButtonMenuItem("3840 x 2160");
//binding into one group to activate single choice
ButtonGroup resolutionGroup = new ButtonGroup();
resolutionGroup.add(resolutionMenuItem1);
resolutionGroup.add(resolutionMenuItem2);
resolutionGroup.add(resolutionMenuItem3);
resolutionGroup.add(resolutionMenuItem4);
resolutionGroup.add(resolutionMenuItem5);
resolutionGroup.add(resolutionMenuItem6);
resolutionGroup.add(resolutionMenuItem7);
resolutionGroup.add(resolutionMenuItem8);
//default choice
resolutionMenuItem1.setSelected(true);
//binding choices to sub button
resolutionMenu.add(resolutionMenuItem1);
resolutionMenu.add(resolutionMenuItem2);
resolutionMenu.add(resolutionMenuItem3);
resolutionMenu.add(resolutionMenuItem4);
resolutionMenu.add(resolutionMenuItem5);
resolutionMenu.add(resolutionMenuItem6);
resolutionMenu.add(resolutionMenuItem7);
resolutionMenu.add(resolutionMenuItem8);
//binding sub button to main button
settingMenu.add(resolutionMenu);
JMenu bgmMenu = new JMenu("Music");
JRadioButtonMenuItem bgmMenuItem1 =
new JRadioButtonMenuItem("On");
JRadioButtonMenuItem bgmMenuItem2 =
new JRadioButtonMenuItem("Off");
ButtonGroup bgmGroup = new ButtonGroup();
bgmGroup.add(bgmMenuItem1);
bgmGroup.add(bgmMenuItem2);
bgmMenuItem1.setSelected(true);
bgmMenu.add(bgmMenuItem1);
bgmMenu.add(bgmMenuItem2);
settingMenu.add(bgmMenu);
//binding main button to main bar
menuBar.add(gameMenu);
//binding main button to main bar
menuBar.add(settingMenu);
设计好菜单内容后,需要将菜单的触发绑定到对应的监听上。若触发Start,则模拟键盘输入R,利用预先写好的Panel内的方法帮助我们实现启动游戏的功能;同理,触发Pause & Resume后模拟输入P;触发Quit则直接调用System.exit(0)结束进程。触发对应的分辨率,则强制设置Frame大小为对应的分辨率,修改Panel内的全局变量SCREEN_WIDTH、SCREEN_HEIGHT为对应大小,最后调用后文的restart方法重启游戏;当触发On和Off时,调用start和stop方法控制背景音乐,这里下文会详细讲解。
//hit start to trigger Key_R and restart the panel
startMenuItem.addActionListener(e -> {
try {
Robot robot = new Robot();
robot.keyPress(KeyEvent.VK_R);
robot.keyRelease(KeyEvent.VK_R);
}
catch (AWTException eA) {
eA.printStackTrace();
}
});
//hit pause to trigger Key_P and pause the panel
pauseMenuItem.addActionListener(e -> {
try {
Robot robot = new Robot();
robot.keyPress(KeyEvent.VK_P);
robot.keyRelease(KeyEvent.VK_P);
}
catch (AWTException eA) {
eA.printStackTrace();
}
});
//hit quit to exit
quitMenuItem.addActionListener(e -> System.exit(0));
//hit resolution to trigger restart method
resolutionMenuItem1.addActionListener(e -> {
Panel.SCREEN_WIDTH = 800;
Panel.SCREEN_HEIGHT = 540;
restart(Panel.SCREEN_WIDTH, Panel.SCREEN_HEIGHT);
});
resolutionMenuItem2.addActionListener(e -> {
Panel.SCREEN_WIDTH = 1020;
Panel.SCREEN_HEIGHT = 700;
restart(Panel.SCREEN_WIDTH, Panel.SCREEN_HEIGHT);
});
resolutionMenuItem3.addActionListener(e -> {
Panel.SCREEN_WIDTH = 1360;
Panel.SCREEN_HEIGHT = 700;
restart(Panel.SCREEN_WIDTH, Panel.SCREEN_HEIGHT);
});
resolutionMenuItem4.addActionListener(e -> {
Panel.SCREEN_WIDTH = 1400;
Panel.SCREEN_HEIGHT = 980;
restart(Panel.SCREEN_WIDTH, Panel.SCREEN_HEIGHT);
});
resolutionMenuItem5.addActionListener(e -> {
Panel.SCREEN_WIDTH = 1600;
Panel.SCREEN_HEIGHT = 840;
restart(Panel.SCREEN_WIDTH, Panel.SCREEN_HEIGHT);
});
resolutionMenuItem6.addActionListener(e -> {
Panel.SCREEN_WIDTH = 1920;
Panel.SCREEN_HEIGHT = 1020;
restart(Panel.SCREEN_WIDTH, Panel.SCREEN_HEIGHT);
});
resolutionMenuItem7.addActionListener(e -> {
Panel.SCREEN_WIDTH = 2560;
Panel.SCREEN_HEIGHT = 1380;
restart(Panel.SCREEN_WIDTH, Panel.SCREEN_HEIGHT);
});
resolutionMenuItem8.addActionListener(e -> {
Panel.SCREEN_WIDTH = 3840;
Panel.SCREEN_HEIGHT = 2100;
restart(Panel.SCREEN_WIDTH, Panel.SCREEN_HEIGHT);
});
bgmMenuItem1.addActionListener(e -> {
bgm.start();
bgm.loop(Clip.LOOP_CONTINUOUSLY);
});
bgmMenuItem2.addActionListener(e -> bgm.stop());
最后别忘了将MenuBar绑定到Frame上。
//binding main bar
this.setJMenuBar(menuBar);
背景音乐
首先需要创建全局变量,方便随时控制背景音乐。
static Clip bgm;
接着调用音频文件,尝试播放,并设置循环播放。特别需要注意的是,此处的音频文件需要填写相对地址,方便后续打包发布。
File bgmPath = new File("bgm.wav");
try {
AudioInputStream aIS = AudioSystem.getAudioInputStream(bgmPath);
bgm = AudioSystem.getClip();
bgm.open(aIS);
bgm.start();
bgm.loop(Clip.LOOP_CONTINUOUSLY);
}
catch (Exception ex){
ex.printStackTrace();
}
restart方法
将新的分辨率传入,设置Frame的新大小,最后模拟R键输入,重启游戏。
//set new resolution with restart the game and resize the frame
public void restart(int SCREEN_WIDTH, int SCREEN_HEIGHT){
this.setSize(SCREEN_WIDTH + 15, SCREEN_HEIGHT + 60);
//restart by Key_R hit in panel
try {
Robot robot = new Robot();
robot.keyPress(KeyEvent.VK_R);
robot.keyRelease(KeyEvent.VK_R);
}
catch (AWTException eA) {
eA.printStackTrace();
}
}
修改图标
和背景音乐类似,需要填写图片的相对路径,尝试获取文件资源后,绑定到Frame上。
BufferedImage image = null;
try {
image = ImageIO.read(this.getClass()
.getResource("./icon.png"));
} catch (IOException e) {
e.printStackTrace();
}
this.setIconImage(image);
可以改进的地方
这次仍然时间很紧张,整体开发流程比较简单,没有计算器那样复杂的纠错检错,仅仅在美观与实用性上做了改进。后续可以在游戏性和可玩性上进行优化,例如引入多线程,开发多条蛇的联机;生成不同的食物,给予不同的效果;设计不同的障碍物、墙壁,允许蛇穿墙而过等等。
详细代码
本项目的全部源码以及打包程序均上传至GitHub,并附有说明文档,具体信息请前往Github查看,测试程序请前往Release下载,项目遵循MIT协议。