注:经过上期的学习,我们学习了如何创建JFrame窗体、如何添加监听事件。根据大家给我的反馈,这一期给大家讲述:如何添加地图、地图如何根据人物移动。
为了更直观的讲解,我们先看看我今晚写的小案例(为了更好的理解,大家要看过之后再浏览后续文章,闪烁问题使用双缓冲即可解决,我这里仅为演示,暂且没有写)
1.如何将自己的游戏合理的拆解成多个类?
答:
1.首先,分析我们这个小案例都有哪些部分组成:
(我这里直接将程序截图给大家,是便于大家的理解,实际情况,这个分析要在脑海里进行,并且借用一些工具如(微软自带的画图工具、记事本记录)进行记录)
分析过程:
首先肯定有游戏窗体,游戏窗体我们最好继承JFrame便于我们根据自己的需求去定制窗体。
其次根据我们的游戏,我们有地图,地图需不需要独立成一个类呢? 这取决于你要做什么游戏,若为棋牌类、泡泡堂等地图本身并无改变需求,反之像魂斗罗、超级玛丽等地图会发生改变的游戏,我们则需要将地图独立出来,便于我们后续更改。还需要有人物,因为人物有移动、停止等状态切换,所以人物类也许独立出来。
所以:
组成部分:1. 游戏的窗体 2. 人物 3. 地图 4. 边缘类(更类似于工具类)
我们这个小案例功能很小,但是不妨碍我们去分析,同时大家要训练自己举一反三的能力(任何能力均可以在重复中习得)。
如下图所示,我们的小案例分为以下部分,分别是:
1.MarioFrame继承自JFrame(游戏主窗体)
2.MarioMap (地图类)
3.Player (人物类)
4.一些边缘类:MarioImageUtils(图片加载类)、Dir(方向枚举类)、PlayerStatus(人物状态枚举)
在经过上一步的分析和拆解后,下一步我们需要思考每个类应该做好什么。
人物应该直接画在地图上吗? 假如我们将人物直接画在地图上(地图中创建一个Player对象),那么当我们需要更改地图时,难道我们再去创建一个Player对象吗?
显然:人物和地图是独立的类,需要思考他们之间的关系,在学习过程中,要尽量避免两个独立的类中持有一方的对象,除非他们之间有必然联系。
做好自己的工作(单一原则)这样做的好处:
1.代码耦合度降低(可百度耦合度)
2.提高类的可维护性和可读性
3.降低变更风险:当需要更改一个类的行为或者属性,只需要在这个类中去更改,不会影响到其他地方对此类的调用
所以:
人物要做的是拥有自身的属性(x,y,width,height,image) 和画自己(paint)的行为
地图也拥有自身的属性和画自己的行为
那么,问题来了,人物和地图都有独立的方法和行为且不互相调用,那么怎么画到窗体上呢?
答案是:有MarioFrame进行调用,MarioFrame类中应当有人物和地图类的对象引用。
具体怎么实现:
在MarioFame初始化时,将人物和地图类的对象实例化
MarioFrame拥有paint方法,在paint方法中调用人物和地图类对象的paint方法,游戏具体的逻辑(人物与地图的关系)应该由MarioFrame进行控制
首先 游戏中确实是人物在移动,但只说对了一半,人物的移动是在地图只有切换无需延申的基础上,超级玛丽、魂斗罗类横板2D游戏当人物走到地图边界时就需要移动地图了。
地图是如何移动的呢?
非常简单,在一定的规则下改变地图paint时图片的X坐标即可 x+speed地图向右移动、x-speed地图向左边移动
那么地图怎么移动的规则是什么呢?
看下图↓
这是我们地图的图片 width 1000 height 358
案例中 窗体的大小为width500 height358 ,左边界为X(0-100),右边界X(400-500),自由移动区域X(100-400)
当我们在width=500的窗体上画width=1000的图片时,会根据图片的x坐标来显示图片,所以对比地图和窗体的两张图片发现,当地图的x=0时,地图只显示了一半。
1.当人物在自由移动区域时,地图无需移动
2.当人物抵达左右边界X坐标时,地图移动。
具体规则(在MarioFrame的paint方法中进行控制):
人物在自由移动区域移动时,地图不移动
当人物抵达左边界时,判断地图的坐标是否<=0(即:判断人物是否已经到达地图的左尽头),向右移动原理相同,判断条件做出更改地图x坐标>= - (1000-speed)
以上就是地图移动的思想了,剩下要做的就是如何将思想转化为代码了。
1.创建窗体类
public class MarioFrame extends JFrame {
public MarioFrame() {
//
init();
}
// 初始化窗体函数
public void init() {
// 清空默认布局管理器
this.setLayout(null);
// 设置窗体的大小
this.setSize(GameConstant.GAME_CLIENT_WIDTH, GameConstant.GAME_CLIENT_HEIGHT);
// 设置窗体显示的位置,这里
this.setLocation((int) (Toolkit.getDefaultToolkit().getScreenSize().getWidth() - this.getWidth()) / 2,
(int) (Toolkit.getDefaultToolkit().getScreenSize().getHeight() - this.getHeight()) / 2);
// 设置窗体关闭按钮点击时窗体的状态
this.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
// 设置不可更改大小
this.setResizable(false);
}
}
2.创建图片加载类(边缘类) 用以存储游戏中所用到的图片
/**
* 图片加载类
*/
public class MarioImageUtils {
// 地图
public static BufferedImage MAP_IMG = null;
// 人物向左
public static BufferedImage PLAYER_LEFT_IMG = null;
// 人物向右
public static BufferedImage PLAYER_RIGHT_IMG = null;
// 加载图片
static {
try {
MAP_IMG = ImageIO.read(MarioImageUtils.class.getResource("/images/backGround_img.png"));
PLAYER_LEFT_IMG = ImageIO.read(MarioImageUtils.class.getResource("/images/player_left.png"));
PLAYER_RIGHT_IMG = ImageIO.read(MarioImageUtils.class.getResource("/images/player_right.png"));
} catch (IOException e) {
e.printStackTrace();
}
}
}
3.游戏窗体常量类(边缘类)存储游戏中的常量
public class GameConstant {
// 窗体宽度
public static final int GAME_CLIENT_WIDTH=500;
// 窗体高度
public static final int GAME_CLIENT_HEIGHT=358;
}
4.创建地图类
/**
* 地图类
*/
public class MarioMap {
// 地图属性
public int speed = 5, x = 0, y = 0, width = GameConstant.GAME_CLIENT_WIDTH, height = GameConstant.GAME_CLIENT_HEIGHT;
// 画自身,由MarioFrame调用
public void paint(Graphics g) {
// 使用窗体的画笔,将自身画到窗体上
g.drawImage(MarioImageUtils.MAP_IMG, x, y, 1000, height, null);
}
// 地图的移动方法,右MarioFrame 调用
public void move(Dir dir) {
switch (dir) {
case RIGHT:// 人物向右移动,图片向左位移
// 判断地图是否在右尽头
if (x > -(width - speed))
x -= speed;
break;
case LEFT:// 人物向左移动,图片向右位移
// 判断地图是否在左尽头
if (x <= 0)
x += speed;
break;
}
}
}
5.创建人物类及所需的边缘类
1.人物方向枚举类
/**
* 方向类
*/
public enum Dir {
RIGHT,LEFT
}
2.人物状态枚举类
/**
* 人物状态类
*/
public enum PlayerStatus {
RUN, STOP
}
3.人物类,当人物走到窗体(注意!不是地图的左右边界)的左右边缘时,人物停止移动
public class Player {
// 人物的属性
public int x, y, speed, width, height;
// 人物的方向
public Dir dir = Dir.LEFT;
// 人物的状态
public PlayerStatus playerStatus = PlayerStatus.STOP;
// 人物的图片
private BufferedImage playerImage = MarioImageUtils.PLAYER_RIGHT_IMG;
// 初始化对象
public Player() {
speed = 5;
width = 32;
height = 32;
x = (GameConstant.GAME_CLIENT_WIDTH - width) / 2;
y = GameConstant.GAME_CLIENT_HEIGHT - 77;
}
// 画自身
public void paint(Graphics g) {
// 画的同时,调用人物移动的方法
move();
// 将自身画在屏幕上
g.drawImage(playerImage, x, y, width, height, null);
}
// 自身移动
public void move() {
// 若自身的状态为RUN移动,这个状态由MarioFrame控制
if (playerStatus == PlayerStatus.RUN) {
// 根据方向进行移动
switch (dir) {
case LEFT:
// 设置人物左面向 图
playerImage = MarioImageUtils.PLAYER_LEFT_IMG;
// 向左时,判断自身是否已经到达了最左边
if (x > 0)
x -= speed;
break;
case RIGHT:
// 设置人物右面向 图
playerImage = MarioImageUtils.PLAYER_RIGHT_IMG;
// 向右时,要判断自身位置是不是到达了最右边,所以是窗体宽度减去自身宽度
if (x < GameConstant.GAME_CLIENT_WIDTH - width)
x += speed;
break;
}
}
}
}
6.在MarioFrame中实现我们之前的思想
地图、人物、边缘类均已准备就绪,而人物、地图又是要画在窗体上的,那么我们的MarioFrame窗体类是不是应该持有人物、地图的对象引用,以便我们画图。
有了这个思想基础,那么我们需要先做两步事情
1.在我们的MarioFrame中创建地图、人物对象,作为MarioFrame的属性
// 地图 对象
private MarioMap marioMap;
// 人物 对象
private Player player;
2.在MarioFrame的init(初始化方法中)将地图类、人物类对象进行初始化
public void init() {
// 初始化地图
marioMap = new MarioMap();
// 创建人物
player = new Player();
}
而后怎么做呢?我们的地图、人物类都有画自己的方法,那么画的方法在哪里调用呢?
我们的MarioFrame类继承自JFrame,而JFrame有paint方法,所以在MarioFrame中重写继承自JFrame的paint方法,并在MarioFrame的paint方法中调用地图、人物的paint方法。
现在,我们知道在哪里调用地图、人物的paint了,那么这个调用paint方法有没有什么原则呢?或者说到底是先画地图还是先画人物呢?
paint画笔原则:paint 中画笔的层级顺序,和栈相同(先进后出),先画底显,就是一层一层的画,先画的显示在底层
我们是不是应该先把地图画到窗体上?
如果我们画人物,再画地图,根据paint画笔的原则,人物会被地图覆盖。
违反原则的展示:
// 重写JFrame的paint方法
@Override
public void paint(Graphics g) {
// 先画人物
player.paint(g);
// 再画地图
marioMap.paint(g);
}
运行:
其实地图、人物都画到了窗体,只是人物的图片先画,地图后画,先画的人物被后画的地图遮挡住了
遵循原则的展示:
// 重写JFrame的paint方法
@Override
public void paint(Graphics g) {
// 再画地图
marioMap.paint(g);
// 先画人物
player.paint(g);
}
让人物动起来
我们的人物类中,拥有move方法,且被自身的paint方法调用,move方法中,我们制定了移动的规则,这个规则的关键是人物的状态(PlayerStatus)、人物的方向(Dir),且在人物类中,我们已经将这两个枚举类的对象设置为public。
接下来要做的就是在MarioFrame中通过改变人物对象的这两个状态来控制人物了
我们知道怎么让人物动起来,那么在MarioFrame的哪一部分去控制人物的这两个状态呢?
显然,我们需要获取用户的输入,即添加监听器
在MarioFrame的init方法中添加键盘监听
public void init(){
this.addKeyListener(new KeyAdapter() {
// 当键盘按下时
@Override
public void keyPressed(KeyEvent e) {
switch (e.getKeyCode()) {
case KeyEvent.VK_LEFT:// 左键
player.playerStatus = PlayerStatus.RUN;
player.dir = Dir.LEFT;
break;
case KeyEvent.VK_RIGHT:// 右键
player.playerStatus = PlayerStatus.RUN;
player.dir = Dir.RIGHT;
break;
}
}
@Override
public void keyReleased(KeyEvent e) {
// 键盘抬起左或右键时,人物的状态设置为STOP
switch (e.getKeyCode()) {
case KeyEvent.VK_LEFT:
case KeyEvent.VK_RIGHT:
player.playerStatus = PlayerStatus.STOP;
break;
}
}
});
}
Nice! 经过一番努力,我们知道了:
1.要在MarioFrame的paint方法中调用地图、人物的paint
2.地图、人物画的顺序:根据画笔原则先画地图在画人物
3.在人物类(Player)中制定了移动(move)的规则
4.在MarioFrame中添加键盘监听器
哈哈哈哈哈~~!这下我们的人物该动起来了吧!!
运行吧! 冲啊!!马里奥! 运行----->
????????????????????????????
啊!!马里奥,你怎么了,你的腿摔断了吗???
为什么马里奥还是动不了?
这个问题的答案,我在第一期:什么是游戏中有具体讲述。
当我们运行程序时,java确实调用了MarioFrame的paint方法 ,MarioFrame.panit方法中也确实调用了地图、人物的paint方法。
关键在于,仅仅画了一次(一帧)!
人物的paint方法也只调用了一次,paint中的move方法也只调用了一次,窗体上也只有一帧画面(帧在第一期中也有讲述)那画面怎么可能动起来呢?
所以我们要将画面的每一次改变都画到窗体上,这时候就需要用到线程,持续的去重画整个窗体,也就是把每帧都显示出来
解决:在MarioFrame的init方法中添加线程,重画窗体
public void init(){
// 保证游戏的流畅性,每30毫秒刷新一次
new Thread(() -> {
while (true) {
try {
// 重画窗体
repaint();
// 每30ms执行一次
Thread.sleep(30);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();
}
这下,我们的人物终于能移动了!!!
剩下的就是将我们左右边界、地图移动的思想(人物与地图的关系)添加到MarioFrame中了
// 判断人物的状态
if (player.playerStatus == PlayerStatus.RUN) {
// 当人物向右,且抵达右边界时
if (player.x + player.width > 400 && player.dir == Dir.RIGHT) {
// 地图移动
marioMap.move(Dir.RIGHT);
// 因为地图和人物的速度都是5,同时移动就是10速度了,所以将人物的速度设置为0,仅移动地图即可
player.speed = 0;
// 如果向右的同时,地图到达了右尽头时,人物的速度设置为5
if (marioMap.x <= -(GameConstant.GAME_CLIENT_WIDTH - 5)) player.speed = 5;
} else if (player.x < 100 && player.dir == Dir.LEFT) {// 当人物向左,且抵达左边界时
marioMap.move(Dir.LEFT);
player.speed = 0;
if (marioMap.x >= 0) player.speed = 5;
} else player.speed = 5; //自由移动区域
}
最终MarioFrame类的所有代码
public class MarioFrame extends JFrame {
// 地图 对象
private MarioMap marioMap;
// 人物 对象
private Player player;
public MarioFrame() {
// 初始化窗体
init();
}
public void init() {
// 清空默认布局管理器
this.setLayout(null);
// 设置窗体的大小
this.setSize(GameConstant.GAME_CLIENT_WIDTH, GameConstant.GAME_CLIENT_HEIGHT);
// 设置窗体显示的位置,这里
this.setLocation((int) (Toolkit.getDefaultToolkit().getScreenSize().getWidth() - this.getWidth()) / 2,
(int) (Toolkit.getDefaultToolkit().getScreenSize().getHeight() - this.getHeight()) / 2);
// 设置窗体关闭按钮点击时窗体的状态
this.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
// 设置不可更改大小
this.setResizable(false);
// 初始化地图
marioMap = new MarioMap();
// 创建人物
player = new Player();
this.setVisible(true);
// 添加键盘监听事件
this.addKeyListener(new KeyAdapter() {
// 当键盘按下时
@Override
public void keyPressed(KeyEvent e) {
switch (e.getKeyCode()) {
case KeyEvent.VK_LEFT:// 左键
player.playerStatus = PlayerStatus.RUN;
player.dir = Dir.LEFT;
break;
case KeyEvent.VK_RIGHT:// 右键
player.playerStatus = PlayerStatus.RUN;
player.dir = Dir.RIGHT;
break;
}
}
@Override
public void keyReleased(KeyEvent e) {
// 键盘抬起左或右键时,人物的状态设置为STOP
switch (e.getKeyCode()) {
case KeyEvent.VK_LEFT:
case KeyEvent.VK_RIGHT:
player.playerStatus = PlayerStatus.STOP;
break;
}
}
});
// 保证游戏的流畅性,每30毫秒刷新一次
new Thread(() -> {
while (true) {
try {
repaint();
Thread.sleep(30);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();
}
// 重写JFrame的paint方法
@Override
public void paint(Graphics g) {
marioMap.paint(g);
player.paint(g);
// 判断人物的状态
if (player.playerStatus == PlayerStatus.RUN) {
// 当人物向右,且抵达右边界时
if (player.x + player.width > 400 && player.dir == Dir.RIGHT) {
// 地图移动
marioMap.move(Dir.RIGHT);
// 因为地图和人物的速度都是5,同时移动就是10速度了,所以将人物的速度设置为0,仅移动地图即可
player.speed = 0;
// 如果向右的同时,地图到达了右尽头时,人物的速度设置为5
if (marioMap.x <= -(GameConstant.GAME_CLIENT_WIDTH - 5)) player.speed = 5;
} else if (player.x < 100 && player.dir == Dir.LEFT) {// 当人物向左,且抵达左边界时
marioMap.move(Dir.LEFT);
player.speed = 0;
if (marioMap.x >= 0) player.speed = 5;
} else player.speed = 5; //自由移动区域
}
}
// 测试
public static void main(String[] args) {
MarioFrame marioFrame = new MarioFrame();
}
}
注:以上就是本期的所有内容了,我看到大家的反馈中有许多关于碰撞检测的,这篇文章虽然在将地图的移动,其中也已涉及到了碰撞检测。
java游戏阶段的主要作用是:帮助大家编程入门,这个入门是指:培养程序思维、发现及解决问题的能力、自学能力,所以,在日常的学习中,要多思多想,多回顾。教程上尽可能的给大家讲详细些,是为了我把如何思考,如何解决问题的过程给大家拿出来,供大家参考,大家要看到思想,而不仅仅是看到代码。
若发现文章中存在错误还请及时联系我,有什么问题欢迎大家钉钉我,或在本文章下评论——zwk