💓 前言
?之前我们用JavaScript写过一个简单的贪吃蛇小游戏,JavaScript写贪吃蛇小游戏!今天我们来用Java写下这个经典的贪吃蛇游戏,同时增加一些新的功能,比如,游戏计分、关卡设置、按空格键实现游戏暂停和继续、穿墙功能等等,先来简单的看下效果动画。
?
接下来我们就看下具体的实现过程吧!
第1??步??游戏背景绘制
🎈窗口绘制
首先创建一个简单的桌面窗口,普通类继承JFrame类便具有了创建窗口、监听鼠标键盘事件的功能。
public class GameWin extends JFrame {
// 创建一个launch方法用来监听窗口信息
public void launch(){
//设置窗口是否可见,默认值为false,窗口不可见
this.setVisible(true);
//设置窗口的大小,宽、高
this.setSize(600,600);
//设置窗口的位置,在屏幕上居中
this.setLocationRelativeTo(null);
//设置窗口的标题
this.setTitle("贪吃蛇");
}
// 创建main方法,获取当前窗口类对象,然后运行launch()方法,
public static void main(String[] args) {
GameWin gameWin = new GameWin();
gameWin.launch();
}
}
🎈 为窗口绘制网格
?窗口宽600,高600,每个小网格都设置为宽高30的正方形,最终窗口会被分割为20行20列,
// 首先重写paint方法,
@Override
public void paint(Graphics g) {
// 在窗口中绘制一个灰色矩形作为背景,灰色矩形要填满窗口,所以宽高都为600
g.setColor(Color.gray);
// 四个参数,起始位置横纵坐标,终点横纵坐标
g.fillRect(0,0,600,600);
// 绘制网格线,和背景颜色灰色区分开,所以选择灰色的网格线,
g.setColor(Color.black);
// 网格是用20条横线,20条纵线相交形成,批量绘制,使用for循环,
for (int i = 0; i <= 20 ; i++) {
// 四个参数,线条的起始位置坐标,终点位置坐标,
// 横线,x都为0,y不同
g.drawLine(0,i * 30,600,i * 30);
// 竖线,同理,改变坐标即可!
g.drawLine(i * 30,0,i * 30,600);
}
}
🎈游戏物体父类的编写
public class GameObj {
// 定义游戏物体的图片
Image img;
// 定义物体的坐标
int x;
int y;
// 定义物体的宽高,将宽高都设置为30,让它和每个格子宽高相同
int width = 30;
int height = 30;
// 窗口类的引用
GameWin frame;
// 然后getter,setter方法....
public Image getImg() {
return img;
}
public void setImg(Image img) {
this.img = img;
}
public int getX() {
return x;
}
public void setX(int x) {
this.x = x;
}
public int getY() {
return y;
}
public void setY(int y) {
this.y = y;
}
public int getWidth() {
return width;
}
public void setWidth(int width) {
this.width = width;
}
public int getHeight() {
return height;
}
public void setHeight(int height) {
this.height = height;
}
public GameWin getFrame() {
return frame;
}
public void setFrame(GameWin frame) {
this.frame = frame;
}
// 构造函数,有参数的构造函数和无参数的构造函数,
public GameObj() {
}
public GameObj(Image img, int x, int y, int width, int height, GameWin frame) {
this.img = img;
this.x = x;
this.y = y;
this.width = width;
this.height = height;
this.frame = frame;
}
// 定义绘制蛇身的方法
public void paintSelf(Graphics g){
// 图片,物体的坐标
g.drawImage(img,x,y,null);
}
}
🎈游戏工具类的创建
先将项目所需要的图片拷进来,然后在我们新建的GameUtils工具类里将这些图片获取进来!
public class GameUtils {
// 蛇头,这里注意,蛇头的方向面向上下左右的时候,图片不同,所以都将其引入进来!
public static Image upImg = Toolkit.getDefaultToolkit().getImage("img/up.png");
public static Image downImg = Toolkit.getDefaultToolkit().getImage("img/down.png");
public static Image leftImg = Toolkit.getDefaultToolkit().getImage("img/left.png");
public static Image rightImg = Toolkit.getDefaultToolkit().getImage("img/right.png");
// 蛇身
public static Image bodyImg = Toolkit.getDefaultToolkit().getImage("img/body.png");
// 食物
public static Image foodImg = Toolkit.getDefaultToolkit().getImage("img/food.png");
// 定义一个方法,用来绘制文字!
public static void drawWord(Graphics g,String str,Color color,int size,int x,int y){
// str:需要绘制的字符串,color:字符串的颜色,size:字符串字体的大小,x,y:字符串的坐标
g.setColor(color);
g.setFont(new Font("仿宋",Font.BOLD,size));
// 将文字绘制到窗口上
g.drawString(str,x,y);
}
}
2?? 蛇身绘制及移动
🎈?蛇头部绘制
先在GameObj中添加一个新的有参构造
public GameObj(Image img, int x, int y, GameWin frame){
this.img = img;
this.x = x;
this.y = y;
this.frame =frame;
}
?然后再创建HeadObj类,继承GameObj
public class HeadObj extends GameObj {
//定义一个控制方向的变量,有四个值,up down left right,设置默认值为右
private String direction = "right";
// 然后是getset方法
public String getDirection() {
return direction;
}
public void setDirection(String direction) {
this.direction = direction;
}
// 重写需要的方法
public HeadObj(Image img, int x, int y, GameWin frame) {
super(img, x, y, frame);
}
@Override
public void paintSelf(Graphics g) {
super.paintSelf(g);
}
}
然后返回到窗口类GameWin中,创建蛇头的对象。
// 蛇头对象, 所需要的参数依次是蛇头的图片,默认是朝右的图片,x,y坐标,窗口引用是this
HeadObj headObj = new HeadObj(GameUtils.rightImg,30,570,this);
再找到paint方法,在paint方法中添加绘制蛇头的方法。
// 绘制蛇头
headObj.paintSelf(g);
效果如图所示:?
🎈?蛇头的简单移动
在HeadObj类的paintSelf方法前,添加一个蛇头移动的move方法:
//蛇的移动
public void move(){
// 对方向变量direction进行判断
switch (direction){
// 如果direction为up,此时蛇头应该向上移动,y-height,
// 我们已经在父类GameObj中规定了宽高都是30,和小网格的宽高是一样的,也就是一次移动一格
case "up":
y -= height;
break;
case "down":
y += height;
break;
case "left":
x -= width;
break;
case "right":
x += width;
default:
break;
}
}
接在在paintSelf中调用move方法
@Override
public void paintSelf(Graphics g){
super.paintSelf(g);
move();
}
蛇的不断移动需要不停的调用repaint方法,所以找到窗口类GameWin在launch方法中添加一个while循环。
public void launch(){
...
while(true){
// 调用repaint方法
repaint();
// 每次调用repaint方法后,要有一个线程休眠,
try {
// 所以1s休眠5次
Thread.sleep(200);
} catch(InterruptedException e) {
e.printStackTrace();
}
}
}
🎈?键盘控制蛇的方向
在HeadObj的方法中,添加键盘监听事件
public HeadObj(Image img, int x, int y, GameWin frame) {
...
//键盘监听事件
this.frame.addKeyListener(new KeyAdapter() {
// keyPressed方法,表示键盘按下,这样就获取到了键盘事件e
@Override
public void keyPressed(KeyEvent e) {
changeDirection(e);
}
});
}
// 如果把控制方向这个函数也写在监听事件里代码太长了,所以单独拿出来。
// 控制移动方向 W -up A - left D -right S-down
public void changeDirection(KeyEvent e){
// 通过switch语句对按下的键进行判断,
switch (e.getKeyCode()){
case KeyEvent.VK_A:
// 注意蛇不能朝当前的反方向进行移动,所以要判断当前方向,不是相反方向就能移动,
if (!"right".equals(direction)){
// 移动了之后要改变当前方向的变量值,以及蛇头图片。
direction = "left";
img = GameUtils.leftImg;
}
break;
case KeyEvent.VK_D:
if (!"left".equals(direction)){
direction = "right";
img = GameUtils.rightImg;
}
break;
case KeyEvent.VK_W:
if (!"down".equals(direction)){
direction = "up";
img = GameUtils.upImg;
}
break;
case KeyEvent.VK_S:
if (!"up".equals(direction)){
direction = "down";
img = GameUtils.downImg;
}
break;
default:
break;
}
}
看下效果:?
🎈?蛇头窗墙功能实现
如果蛇从一个方向消失,会再从另一个方向出来,找到HeadObj类,在它的paintSelf方法中实现
// 窗墙功能
// 很好理解,如果x<0,蛇头要撞左墙,那就让蛇头从右墙出现,x值为600-30=570,
// 以下几种情况同理。
if (x < 0){
x = 570;
} else if (x > 570){
x = 0;
} else if (y < 30){
// 注意这里为什么是30不是0,因为我们的窗口有个标题,高度为30
y = 570;
}else if (y > 570){
y = 30;
}
看下效果:?
🎈?蛇身的添加及移动
首先创建蛇身的类BodyObj,依旧继承GameObj,然后重写需要的方法,paintSelf
public class BodyObj extends GameObj {
public BodyObj(Image img, int x, int y, GameWin frame) {
super(img, x, y, frame);
}
@Override
public void paintSelf(Graphics g) {
super.paintSelf(g);
}
}
?接着返回到窗口类GameWin中,定义一个蛇的身体的集合
// 蛇的身体集合
public List<BodyObj> bodyObjList = new ArrayList<>();
// 注意这里要引入java.util
?然后在launch方法中对蛇身进行初始化
public void launch(){
...
// 蛇身初始化 ,第一节蛇的身体:第一个参数为蛇的图片,x为30,y为570,窗口引用this
bodyObjList.add(new BodyObj(GameUtils.bodyImg, 30, 570, this));
// 因为蛇身和蛇头都是紧挨着的,所以第二节身体改为0即可
bodyObjList.add(new BodyObj(GameUtils.bodyImg, 0, 570, this));
...
}
?接着在窗口类GameWin中的paint方法中反向遍历这个集合,为什么要反向遍历呢,为了防止身体有重叠
@Override
public void paint(Graphics g){
...
// 绘制蛇的身体
for(int i = bodyObjList.size() - 1; i >= 0; i--){
bodyObjList.get(i).paintSelf(g);
}
// 绘制蛇头
...
}
?接着在HeadObj类中,的move方法里,在蛇头移动的代码前,添加蛇身移动的代码,顺序不能变,否则要出错。
//蛇的移动
public void move(){
// 蛇身体的移动
// 先获取bodyObjList
java.util.List<BodyObj> bodyObjList = this.frame.bodyObjList;
// 然后在这里进行遍历,注意这里是从1开始遍历,因为第一个元素要先将它单独拎出来
// 蛇身的移动其实就是,当前元素的坐标,等于它前面元素的坐标,
for (int i = 1; i < bodyObjList.size(); i++) {
bodyObjList.get(i).x = bodyObjList.get(i - 1).x;
bodyObjList.get(i).y = bodyObjList.get(i - 1).y;
}
// 第一个元素,是和蛇头相连的,所以它的坐标要变为改变前的蛇头的坐标。
bodyObjList.get(0).x = this.x;
bodyObjList.get(0).y = this.y;
......
// 一定要注意,蛇身移动的代码要写在蛇头移动之前!
}
3?? 食物的随机位置生成
食物生成的位置是随机的,但是必须得在窗口类GameWin中,x在0-570之间,y在30-570间,并且得是30的倍数,新建一个FoodObj类,继承GameObj类,重写需要的方法,定义一个随机函数,然后再写个方法获取食物
public class FoodObj extends GameObj {
//随机
Random r = new Random();
public FoodObj() {
super();
}
public FoodObj(Image img, int x, int y, GameWin frame) {
super(img, x, y, frame);
}
//获取食物,返回值是类的对象
public FoodObj getFood(){
// 第一个参数是食物的图片,第二个参数是食物随机生成位置的x轴,
// 但得是30的倍数,所以0-20随机生成,再乘以30,y同理,很好理解。
return new FoodObj(GameUtils.foodImg,r.nextInt(20) * 30,(r.nextInt(19) + 1) * 30,this.frame);
}
@Override
public void paintSelf(Graphics g) {
super.paintSelf(g);
}
}
接着回到窗口类GameWin中获取食物的对象
// 食物
public FoodObj foodObj = new FoodObj().getFood();
// getFood这个方法的返回值就是foodObj对象
然后在窗口类GameWin的paint方法中绘制食物
// 食物绘制
foodObj.paintSelf(g);
4?? 蛇身的增长
🎈蛇吃食物
首先找到HeadObj在paintSelf方法中,获取窗口类中的食物对象
public void paintSelf(Graphics g) {
......
// 蛇吃食物,注意这段代码要放在蛇移动的move()方法之前,注意顺序
FoodObj food = this.frame.foodObj;
// 添加判断
if (this.x == food.x && this.y == food.y){
// 蛇头和食物重合了,食物应该被吃掉,
// 接着就将一个新的随机生成的foodObj对象,赋值给窗口类中的foodObj
this.frame.foodObj = food.getFood();
}
......
}
看下效果:?
🎈蛇身的增长
每当蛇吃掉一个食物,蛇的身体就应该增长一节,蛇增长的本质,是给蛇身体的集合添加一个元素,新元素的位置要根据蛇身体的最后一个元素的位置来确定。找到HeadObj的paintSelf方法
public void paintSelf(Graphics g) {
...
//蛇吃食物
...
// 定义两个变量表示蛇身体最后一节的坐标
Integer newX = null;
Integer newY = null;
// 在蛇吃食物的代码中,获取蛇身的最后一节
if (this.x == food.x && this.y == food.y){
this.frame.foodObj = food.getFood();
//获取蛇身的最后一个元素
BodyObj lastBody = this.frame.bodyObjList.get(this.frame.bodyObjList.size() - 1);
// 让增长的这节身体的坐标为蛇身最后一节的坐标!
newX = lastBody.x;
newY = lastBody.y;
}
move();
//move结束后,新的bodyObj对象添加到bodyObjList,添加的前提得是newX和newY值不能为null,
if (newX != null && newY != null){
this.frame.bodyObjList.add(new BodyObj(GameUtils.bodyImg,newX,newY,this.frame));
}
...
}
?最后还要改一个HeadObj中的move方法中,身体的遍历顺序,改为反向遍历?
// 蛇的移动
public void move(){
...
for(int i = bodyObjList.size() - 1; i >= 1; i--){
...
}
...
}
看下效果:?
5??游戏功能设置
🎈?计分面板的实现
计分面板应该记录蛇吃了食物的数量
首先在窗口类GameWin中,定义窗口的宽高变量,接着将之前与窗口大小有关的数字,全部替换成我们现在定义的变量
public class GameWin extends JFrame {
...
// 定义一个记录分数的变量
public static int score = 0;
// 窗口宽高
int winWidth = 800;
int winHeight = 600;
...
}
接着找到paint方法将分数绘制出来
// 分数绘制
GameUtils.drawWord(g,score + "分",Color.BLUE, 50, 650, 300);
最后找到HeadObj中,蛇吃食物的方法
public void paintSelf(Graphics g) {
...
//蛇吃食物
...
if (this.x == food.x && this.y == food.y){
this.frame.foodObj = food.getFood();
//获取蛇身的最后一个元素
BodyObj lastBody = this.frame.bodyObjList.get(this.frame.bodyObjList.size() - 1);
// 让增长的这节身体的坐标为蛇身最后一节的坐标!
newX = lastBody.x;
newY = lastBody.y;
// 每吃一个食物,分数+1
GameWin.score++;
}
...
}
看下效果:?
🎈游戏开始的提示语
首先在窗口类中GameWin定义游戏状态的变量,之后绘制提示语,
public class GameWin extends JFrame {
// 分数
...
// 游戏状态 0 未开始,1 游戏中, 2 暂停, 3 失败, 4 通关
public static int state = 0;
...
paint方法{
...
// 绘制提示语
g.setColor(Color.gray);
prompt(g);
}
// 绘制提示语
void prompt(Graphics g){
// 未开始时的提示语
if(state == 0){
// 先绘制一个矩形填充,x,y,width,height;
g.fillRect(120, 240, 400, 70);
// 然后调用工具类中的方法绘制提示语,字体大小,坐标x,y
GameUtils.drawWord(g, "按下空格开始游戏", Color.yellow, 35,150,290)
}
之后在paint方法中调用prompt
}
看下效果:?
还有一点需要注意,只有在游戏中,才能重复执行repaint方法,所以要加个判断。
public void launch(){
...
while(true){
if(state == 1){
// 调用repaint方法
repaint();
}
// 每次调用repaint方法后,要有一个线程休眠,
...
}
}
🎈?游戏的暂停功能
接下来我们为游戏开始添加键盘事件,按下空格游戏开始,也就是游戏状态state改为1,在窗口类的launch方法中添加键盘事件,要在while循环之上
//键盘事件
this.addKeyListener(new KeyAdapter() {
@Override
public void keyPressed(KeyEvent e) {
if (e.getKeyCode() == KeyEvent.VK_SPACE){
switch (state){
case 0:
//未开始
state = 1;
break;
case 1:
//游戏中
state = 2;
repaint();
break;
case 2:
//游戏暂停
state = 1;
break;
default:
break;
}
}
}
});
🎈?游戏的通关设置
在HeadObj中,蛇吃食物的代码中添加判断
//蛇吃食物
...
//通关判断
if (GameWin.score >= 15){
//通关
GameWin.state = 4;
}
...
接着回到窗口类GameWin中,在prompt方法里添加游戏通关的提示语
// 把游戏暂停、通关、失败的提示语都加上
void prompt(Graphics g){
...
// 未开始
...
// 暂停,
if (state == 2){
g.fillRect(120,240,400,70);
GameUtils.drawWord(g,"按下空格继续游戏",Color.yellow,35,150,290);
}
// 失败
if (state == 3){
g.fillRect(120,240,400,70);
GameUtils.drawWord(g,"游戏失败!" ,Color.yellow,35,150,290);
}
//通关
if (state == 4){
g.fillRect(120,240,400,70); // 颜色改为绿色
GameUtils.drawWord(g,"达成条件,游戏通关",Color.green,35,150,290);
}
}
?🎈?蛇头与蛇身的碰撞判断
在HeadObj的move方法中
//蛇的移动
public void move(){
//蛇身体的移动
...
for (int i = bodyObjList.size() - 1; i >= 1; i--) {
bodyObjList.get(i).x = bodyObjList.get(i - 1).x;
bodyObjList.get(i).y = bodyObjList.get(i - 1).y;
//蛇头与身体的碰撞判断,
if (this.x == bodyObjList.get(i).x && this.y == bodyObjList.get(i).y){
// 蛇头与蛇身撞上了,游戏失败,state状态改为3
GameWin.state = 3;
}
}
//蛇头的移动
...
}
🎈?游戏失败后的重新开始
首先在窗口类中创建一个游戏重置的方法,需要实现两个功能,关闭当前窗口和开启新窗口。
//游戏重置
void resetGame(){
//关闭当前窗口
this.dispose();
//开启新的窗口,可以直接调用main方法,需要一个字符串数组作为参数
String[] args = {};
main(args);
}
然后给游戏再添加一个状态state,5,失败后重新开始
// 游戏状态 0 未开始,1 游戏中, 2 暂停, 3 失败, 4 通关 , 5 失败后重新开始
public static int state = 0;
然后找到键盘事件的switch语句,添加一条判断语句
//键盘事件
...
switch (state){
case 0:
...
case 1:
...
case 2:
...
case 3:
// 如果游戏失败,将游戏状态改为5,失败后重新开始
state = 5;
break;
...
}
然后找到while循环,添加一个if判断
while(true){
if(state == 1){
// 游戏中才调用
repaint();
}
// 失败后重启
if(state == 5){
// 游戏状态改为0,然后调用游戏重启的方法
state = 0;
resetGame();
}
...
}
然后将窗口类GameWin中的一个静态变量score,改为非静态
// 分数
public int score = 0;
那么与分数相关的代码都需要修改,改为this.frame.score++;
然后把游戏提示语句也进行下调整
// 失败
if(state == 3){
g.fillRect(120,240,400,70);
GameUtils.drawWord(g,"游戏失败,按空格重新开始",Color.red,35,150,290);
}
...
🎈游戏的多个关卡设置
首先在工具类GameUtils中定义关卡的变量,level默认1,从第一关开始
// 蛇身
// 食物
...
// 关卡
public static int level = 1;
...
接着找到主窗口GameWin,在主窗口中绘制关卡的文字,
// 绘制蛇头
// 食物绘制
...
// 关卡 参数,字体大小,坐标x,y
GameUtils.drawWord(gImage,"第" + GameUtils.level + "关", Color.orange, 40,650,260)
接着我们再为游戏添加一个新的状态state,6,下一关
...
// 游戏状态 0 未开始,1 游戏中, 2 暂停, 3 失败, 4 通关, 5 失败后重新开始,6 下一关
public static int state = 0;
然后接着找到键盘的switch判断,添加一条新语句,如果游戏通关,状态改为6
...
case 4:
// 下一关,
state = 6;
break;
...
接着在while循环中,再写一条if判断
...
// 通关下一关
// 由于现在这个贪吃蛇游戏,设置的是只有3关,所以多添加个判断,
// 如果当前游戏是第3关,就不能再执行这个方法了,因为第三关已经是最后一关了。
if(state == 6 && GameUtils.level != 3){
state = 1;
GameUtils.level++;
// 接着调用游戏重置的方法
resetGame();
}
然后将通关后的提示语进行相应的修改
// 通关
if(state == 4){
g.fillRect(120,240,400,70);
if(GameUtils.level == 3){
GameUtils.drawWord(g, "达成条件,游戏通关", Color.green, 35, 150,290);
}else{
GameUtils.drawWord(g, "点击空格进入下一关", Color.green, 35, 150,290);
}
}
6?? 优化:双缓存解决画面闪动问题
我们的游戏画面一直在闪,文字很明显,闪动的原因是什么呢?
是因为窗口中所有的元素都是绘制出来的,每次重新绘制的时候,需要将所有的元素重新一个一个绘制到窗口中,所以解决的思路是,重新创建一个空的图片,将所有要绘制的小元素绘制到空图片之上,最后把绘制好的空图片,一次性绘制到主窗口中。
首先在窗口类GameWin中定义双缓存的图片
// 定义双缓存图片
Image offScreenImage = null;
然后在paint方法中进行初始化设置
为防止重复定义双缓存图片,我们加上判断条件
@Override
public void paint(Graphics g){
// 初始化双缓存图片
if(offScreenImage == null){
// 当双缓存图片为null的时候,我们再生成实例对象
// 创建一个宽高和我们窗口大小一样的图片
offScreenImage = this.createImage(winWidth,winHeight);
}
// 获取图片对应的graphics对象
Graphics gImage = offScreenImage.getGraphics();
// 然后将之前的g全部替换为gImage
...
}
最后将双缓存的图片绘制到主窗口中。
...
// 食物绘制
// 分数绘制
// 绘制提示语
...
// 将双缓存图片绘制到窗口中
g.drawImage(offScreenImage, 0, 0,null);
这样窗口就不会有闪动的迹象了!
至此贪吃蛇游戏就基本实现啦!注意这里不同关卡蛇移动的速度是一样的,未进行更改!
啊啊啊我又入榜了,啊啊啊这是第二篇入榜文章啦!
?
?
|