前一末节,咱们完成了一个包括能够制作坦克图片的绘图板的游戏窗体小程序。这一末节,咱们要完成的方针如下:
- 规划坦克的基类
- 完成各种类型带血条坦克的制作
- 完成坦克移动
- 完成经过方向键操控玩家坦克移动
先调整上一末节的类规划,将MyPanel
作为MyFrame
的成员变量,在MyFrame
无参结构中对其进行实例化和赋值;而MyPanel
中也持有对MyFrame
的依赖,调整如下:
package com.pf.java.tankbattle;
import ...
public class MyFrame extends JFrame {
private MyPanel panel;
public MyFrame() {
...
// 将面板组件添加到窗口目标的内容面板中
this.panel = new MyPanel(this);
getContentPane().add(panel);
...
}
}
package com.pf.java.tankbattle;
import ...
public class MyPanel extends JPanel {
private MyFrame frame;
public MyPanel(MyFrame frame) {
this.frame = frame;
...
}
}
这样主类就简化为:
package com.pf.java.tankbattle;
import ...
public class GameMain {
public static void main(String[] args) {
...
// 创立窗体目标
new MyFrame();
}
}
以上的操作仍是遵从面向目标封装的思想,客户端(游戏主类)不需要关心游戏窗体组件内部的部件,这也不应该对客户端暴露出来。复习了下Java中面向目标的封装思想,咱们再来看看面向目标中的继承。
坦克类规划
这儿咱们首先考虑一个坦克有哪些特点和行为,为其规划一个基类。然后再扩展两个详细的坦克类:玩家的英豪坦克和电脑的敌军坦克来继承这个基类。一起来看下基类中的特点(这儿省略了getter和setter办法)和办法(省略了特定的结构器)。
根本的特点:
-
x、y坐标
代表坦克在绘图板中被制作时的左上角的坐标方位,坦克在行进时会导致某个方向的坐标值改变,转向时也可能导致坐标点的改变。
-
speed
坦克行进的速度,也便是每1000毫秒坦克移动的像素数,假如坦克的速度是40,则移动一个像素需要25毫秒。假如经过多线程来操控坦克移动,则只要每休眠25毫秒让坦克往前移动一个像素即可。
-
direction
枚举类型,坦克行进的方向。
-
blood
坦克的血点,表现坦克血条的长度,被敌方坦克炮弹击中后会掉血,掉到0则坦克会被炸毁(调用其
die()
办法)。 -
picIndex
在制作坦克时要确定的索引方位,取值范围0至13。
-
gearToggle
记录履带交替改动的布尔变量,坦克每向前移动一个像素,就会在两个只有履带纹样不同的坦克图片之间进行切换:
-
frame
游戏窗体目标
关于坦克根本的行为,这儿咱们暂时供给几个办法:
/**
* 坦克移动的办法,每次移动一个像素的间隔
* @return 是否被阻挠的布尔值,假如被阻挠则不会往前移动一个像素
*/
public boolean move() {
// todo 待完成
return false;
}
/**
* 坦克转向的办法
* @param direction 调转的方向
*/
public void turnRound(Direction direction) {
// 调用direction特点的setter办法设置新的方向
setDirection(direction);
}
/**
* 坦克被制作的办法
* @param g 绘图板的画笔目标
*/
public void paint(Graphics g) {
// todo 待完成
}
/**
* 坦克被炸毁的办法
*/
public void die() {
// todo 待完成
}
接下来咱们侧重完成paint(Graphics g)
和move()
办法。
完成paint办法
首先咱们封装一个制作各种类型坦克的办法,完成代码如下:
public void paint(Graphics g) {
// 依据坦克的方向获取其索引 ↓1处
int index = direction.ordinal();
// 核算截取坦克图片的开始方位 ↓2处
int subX = (picIndex + 28 * index + (gearToggle ? 14 : 0)) * SIZE;
// 抠图并制作 ↓3处
g.drawImage(ResourceMgr.tank.getSubimage(subX, 0, SIZE, SIZE), x, y, null);
}
代码详解:
-
1处获取枚举项地点的索引值,咱们在界说方向枚举时是依照上、右、下、左的顺序界说的:
package com.pf.java.tankbattle.enums; /** * 方向枚举类 */ public enum Direction { UP, RIGHT, DOWN, LEFT; }
方向和索引的关系如下:
因而,假如坦克的方向为
DOWN
,则经过direction.ordinal()
咱们将得到索引值2
。 -
2处核算要制作的坦克的开始方位
从下图中不难发现,当
picIndex
确定后,即要制作的坦克类型确定后,假设picIndex
为0
,咱们发现每经过28个SIZE的像素单位后坦克的方向发生了改变,自然依照第一步确定的index
核算出的偏移量为28 * index
,再加上操控履带改变的变量,索引的偏移量为28 * index + (gearToggle ? 14 : 0)
,再算上picIndex
和坦克的SIZE
,最终得到核算坦克抠图的开始方位的表达式为:(picIndex + 28 * index + (gearToggle ? 14 : 0)) * SIZE
-
3处依照第二步核算出来的坦克图片的扣取区域的起点方位,扣取坦克
SIZE
宽高的区域,并以坦克当时的(x, y)
坐标点进行制作。
下面测验下坦克的制作:
package com.pf.java.tankbattle;
import ...
public class MyPanel extends JPanel {
// 暂时在绘图板中界说一个英豪
private HeroTank heroTank;
...
public MyPanel(MyFrame frame) {
...
// 实例化咱们的英豪
heroTank = new HeroTank(Direction.DOWN, 32, 32, 80, 0, frame);
}
@Override
protected void paintComponent(Graphics g) {
...
// 测验坦克的制作,因为该办法会被调用多次,为避免被覆盖,x坐标每次都设置下
heroTank.setX(32);
heroTank.paint(g);
// 改动履带和x坐标方位再制作一次
heroTank.setGearToggle(!heroTank.isGearToggle());
heroTank.setX(64);
heroTank.paint(g);
}
}
程序运转截图:
现在咱再给坦克安上血条,首先咱们编写一个工具类,以不同的色彩代表不同的血量范围,工具类代码如下:
package com.pf.java.tankbattle.util;
import ...
public class LifeColorUtil {
/**
* 依据血量核算出要显示的血条色彩
* @param blood
* @return
*/
public static Color parseColor(int blood) {
Color c;
if (blood >= 90) {
c = new Color(127, 255, 0);
} else if (blood >= 80) {
c = new Color(118, 238, 0);
} else if (blood >= 60) {
c = new Color(179, 238, 58);
} else if (blood >= 50) {
c = new Color(238, 238, 0);
} else if (blood >= 40) {
c = new Color(238, 220, 130);
} else if (blood >= 30) {
c = new Color(255, 193, 37);
} else if (blood >= 15) {
c = new Color(255, 127, 36);
} else {
c = new Color(255, 48, 48);
}
return c;
}
}
好在idea支撑色值展示,咱们能够看到随着血量的削减,相应的色值的改变:
在Tank
类的paint
办法的最终再制作上血条:
public void paint(Graphics g) {
...
// 依据血量核算出血条色彩
g.setColor(LifeColorUtil.parseColor(blood));
// 制作血条
g.fillRect(x, y == 0 ? y : y - 2, 32 * blood / 100, 2);
}
在MyPanel
类中完善测验代码:
package com.pf.java.tankbattle;
import ...
public class MyPanel extends JPanel {
...
@Override
protected void paintComponent(Graphics g) {
...
...
// 设置血量
heroTank.setBlood(80);
heroTank.paint(g);
...
// 设置血量
heroTank.setBlood(40);
heroTank.paint(g);
...
heroTank.setBlood(12);
heroTank.paint(g);
}
}
作用:
完成坦克移动
要完成坦克的移动很简单,暂时不考虑与边界和障碍物的碰撞检测,在Tank
基类中完成如下:
public boolean move() {
// 让坦克履带转动起来
gearToggle = !gearToggle;
// 完成在行进方向移动一个像素的间隔
switch (direction) {
case LEFT:
x--;
break;
case UP:
y--;
break;
case RIGHT:
x++;
break;
case DOWN:
y++;
}
return true;
}
为了让坦克在绘图板中“活”起来,咱们需要不断的刷新绘图板的画面,也便是先清除画布,再重新在新的方位制作坦克,这样坦克就动起来了。为此咱们在游戏窗体中创立一个线程,来对整个窗体进行不断的重绘:
package com.pf.java.tankbattle;
import ...
public class MyFrame extends JFrame {
private Thread paintThread;
...
public MyFrame() {
...
// 创立一个线程,不停履行对游戏窗体进行重绘
paintThread = new Thread(() -> {
while (true) {
try {
// 刷新的频率越快,动画越流通,但也要考虑CPU的开销
Thread.sleep(20);
} catch (InterruptedException e) {
e.printStackTrace();
}
repaint();
}
});
paintThread.start();
}
}
对当时的窗体目标进行repaint
时,MyPanel
中的paintComponent
办法会主动被调用,因而该办法只要简化为如下即可:
protected void paintComponent(Graphics g) {
super.paintComponent(g);
heroTank.paint(g);
}
剩余的事则是,在游戏发动后,操控坦克移动(调用其move()
办法)即可。
package com.pf.java.tankbattle.entity.tank;
import ...
public class HeroTank extends Tank {
/** 坦克发动机线程 */
private Thread moveThread;
public HeroTank(Direction direction, int x, int y, int speed, int picIndex, MyFrame frame) {
super(direction, x, y, speed, picIndex, frame);
// 结构和发动坦克引擎
moveThread = new Thread(() -> {
// todo 这儿先不考虑坦克被炸毁的状况,引擎发动后就一向持续下去
while (true) {
// 只管向前冲
move();
try {
// 核算每走一个像素花费的毫秒数,并以此作为休眠时刻
Thread.sleep(1000 / speed);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
moveThread.start();
}
}
作用如下:
经过方向键操控玩家坦克
是时分将咱们的意志注入给英豪的坦克了。接下来咱们要完成经过上下左右方向键操控玩家坦克移动。玩家能够一起按下多个方向键,最终按下的起作用,当松开一个方向键后,最近一次按下的起作用,而当悉数方向键都松开后,坦克停下来,能够参考下面的示意图:
咱们将经过Java AWT组件供给的键盘事情监听器,再结合多线程来完成上述需求。详细代码如下:
package com.pf.java.tankbattle.entity.tank;
import ...
public class HeroTank extends Tank {
/** 坦克发动机线程 */
private Thread moveThread;
public HeroTank(Direction direction, int x, int y, int speed, int picIndex, MyFrame frame) {
super(direction, x, y, speed, picIndex, frame);
// 注册键盘事情
frame.addKeyListener(new MyKeyListener());
}
/**
* 内部类,完成了键盘事情(键按下、键松开)的处理办法
*/
class MyKeyListener extends KeyAdapter {
/** 记录已按下的方向键的数值 */
private LinkedList<Integer> oprs;
/** 坦克是否处于静止状况,留意必需要确保多线程的可见性,用volatile修饰 */
private volatile boolean stop = true;
public MyKeyListener() {
oprs = new LinkedList<>();
moveThread = new Thread(() -> {
// todo 这儿先不考虑坦克被炸毁的状况
while (true) {
// 假如坦克处于中止状况则将线程park住
if (stop) {
LockSupport.park();
}
// 在行进方向移动坦克
move();
try {
// 核算每走一个像素花费的毫秒数,并以此作为休眠时刻
Thread.sleep(1000 / getSpeed());
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
moveThread.start();
}
@Override
public void keyPressed(KeyEvent e) {
int key = e.getKeyCode();
switch (key) {
case KeyEvent.VK_LEFT:
case KeyEvent.VK_UP:
case KeyEvent.VK_RIGHT:
case KeyEvent.VK_DOWN:
break;
default:
return;
}
// 假如不包括在操控列表中则添加进来
if (!oprs.contains(key)) {
oprs.add(key);
}
// 预备发动坦克
if (stop) {
stop = false;
LockSupport.unpark(moveThread);
}
// 设置坦克转向为最新按下的方向键
setDirection(getDirectionByKey(key));
}
@Override
public void keyReleased(KeyEvent e) {
// 留意下面调用oprs.remove办法传入的参数有必要是包装类型
Integer key = e.getKeyCode();
switch (key) {
case KeyEvent.VK_LEFT:
case KeyEvent.VK_UP:
case KeyEvent.VK_RIGHT:
case KeyEvent.VK_DOWN:
break;
default:
return;
}
// 移除松开的方向键
oprs.remove(key);
if (oprs.isEmpty()) {
// 一切方向键都松开,则操控线程的状况变量设为中止
stop = true;
} else {
// 否则取方向操控列表中最近一次添加的
setDirection(getDirectionByKey(oprs.getLast()));
}
}
private Direction getDirectionByKey(int key) {
switch (key) {
case KeyEvent.VK_LEFT:
return Direction.LEFT;
case KeyEvent.VK_UP:
return Direction.UP;
case KeyEvent.VK_RIGHT:
return Direction.RIGHT;
case KeyEvent.VK_DOWN:
return Direction.DOWN;
default:
return null;
}
}
}
}
阐明
- 这儿操控
moveThread
线程的运转和中止采用的是juc包中的LockSupport
类,调用其pack()
挂起当时线程,可是持有的锁不会被释放,和Thread.sleep(millis)
相似,仅仅前者唤醒能够由其他线程操控,调用LockSupport.unpark(thread)
即可唤醒从前被park
住的线程。- 这儿界说的
stop
变量会有多个线程访问,监听键盘事情的后台线程会对该变量进行读写,而咱们创立的moveThread
也会读取它,因而有必要用volatile
关键字来修饰它,确保其可见性。- 对方向键的存取这儿采用的是
LinkedList
,而不是ArrayList
,因为有频频的刺进和删去操作,自然链表结构完成的效率会更高。
运转程序,玩家能够顺利的操作方向键来灵活的操控玩家坦克,手感杠杠滴,作用如下:
但存在一个很明显的瑕疵,当时间短的切换方向键时,无法操控坦克只转向而不移动,实践坦克仍是会移动一段间隔,作用如下:
修复办法:当坦克由静止状况时,按下一个方向键,moveThread
线程会持续履行LockSupport.park()
后续的代码,此刻能够恰当休眠下,在这个时刻间隙里,坦克不会移动,而超过这个时刻间隔后才持续调用move()
办法。添加的操控逻辑:
moveThread = new Thread(() -> {
while (true) {
if (stop) {
LockSupport.park();
// 操控坦克只转向而不移动
try {
// 这儿时间短休眠下再进行下一轮判别,以便完成时间短按键下只转向不移动
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
continue;
}
...
}
});
作用如下:
经过这一末节的学习,相信大伙儿在敲代码中慢慢找到了学习Java的趣味,把多线程和调集的常识也运用进来了,对于面向目标也了解的更深入些了吧。不过这才是开始,后续咱们将逐步的过渡到规划形式的实操上来,大家加油!