上一小节我们进行了享元模式实战,使用享元模式优化了物体间碰撞检测的范围。从本小节开始,我们将注意力从移动的物体(目前实现的坦克)转移到静态的景物(游戏中的装饰物,俗称游戏地图)上来。很显然,这一小节我们将实现游戏景物的绘制,也就是初步实现游戏地图的绘制。实现这个功能的过程中,我们将引入并应用一个新的设计模式——命令模式。
开发游戏菜单
为了将【开始游戏】和【绘制地图】的功能隔离开来可以单独启用,首先我们会使用Java Swing工具集中菜单相关的组件来构建出一个带有菜单栏的窗体。添加菜单功能很简单,在MyFrame
类的构造器中增加如下代码:
...
// 设置菜单栏
JMenuBar menuBar = new JMenuBar(); // 菜单栏组件
JMenu myMenu = new JMenu("我的菜单"); // 菜单
JMenuItem drawMapMenuItem = new JMenuItem("绘制地图"); // 菜单项
JMenuItem startGameMenuItem = new JMenuItem("开始游戏");
myMenu.add(drawMapMenuItem);
myMenu.add(startGameMenuItem);
menuBar.add(myMenu);
setJMenuBar(menuBar);
...
同时注释掉直接对窗体对象添加JPanel
组件的代码:
// this.panel = new MyPanel(this);
// getContentPane().add(panel);
这段逻辑我们将放在相关菜单项的点击触发事件处理方法中,也就是,点击【绘制地图】或者【开始游戏】菜单后,才为窗体对象添加相应的面板并呈现相应的画面。但这种实现方式,需要我们确保在这两个菜单项的功能切换时,需要对要结束的功能组件进行销毁,而要对新开启的功能组件进行重新添加绑定;除此之外,我们也可以实现在点击一个功能菜单时,另外弹出一个窗体来展现功能。只不过这里我们采用的是前者。关于菜单功能切换时,如何很好的终止当前活动窗口的状态,包括结束相关的用户线程,销毁创建的各种对象并释放占用的资源,不在我们本节要实现的范畴内,随着我们游戏功能的迭代,后续会逐步完善。
除了原有的绘图板组件MyPanel
外,我们还需要开发一个新的面板组件MapPanel
作为地图绘制的面板,实现的基础代码如下:
package com.pf.java.tankbattle;
import ...
/**
* 用于手动绘制地图的绘图板组件
*/
public class MapPanel extends JPanel {
private MyFrame frame;
public MapPanel(MyFrame frame) {
this.frame = frame;
setBackground(Color.BLACK);
// 设置绘图板的宽高(像素单位)
setPreferredSize(new Dimension(MAP_WIDTH, MAP_HEIGHT));
}
@Override
public void paintComponent(Graphics g) {
super.paintComponent(g);
paintGrids(g);
}
public void paintGrids(Graphics g) {
// 绘制网格线的代码省略
...
}
}
最后,再在窗体类MyFrame
构造器中对添加的菜单项绑定相应的点击事件,实现点击后的相关逻辑,涉及的代码如下:
package com.pf.java.tankbattle;
import ...
public class MyFrame extends JFrame {
...
private MyPanel panel;
private MapPanel mapPanel;
public MyFrame() {
...
// 设置窗口的最小尺寸
setMinimumSize(new Dimension(320, 120));
...
// 设置菜单栏代码省略
...
// 菜单事件
startGameMenuItem.addActionListener(e -> {
// 将面板组件添加到窗口对象的内容面板中
this.panel = new MyPanel(this);
getContentPane().add(panel);
// 重置窗口大小和位置
pack();
setLocationRelativeTo(null);
// 开始游戏
startGame();
});
drawMapMenuItem.addActionListener(e -> {
// 添加地图绘制面板
this.mapPanel = new MapPanel(this);
getContentPane().add(mapPanel);
pack();
setLocationRelativeTo(null);
});
...
}
...
}
看下程序运行效果,弹出主界面后,先点击【绘制地图】菜单,会展示绘图界面:
在展示绘图界面后,应该重置我的菜单项为【保存地图】,这里暂未实现。我们姑且点击右上角的【关闭】按钮来结束应用,以便重新运行后再切换到【开始游戏】功能:
而在游戏进行中,我的菜单项应该调整为【退出游戏】,再恢复到主菜单【绘制地图】和【开始游戏】,关于这些功能,后续我们再用状态模式来实现。
绘制景物
通过引入和使用菜单对功能模块较好的拆分之后,我们将关注点放到游戏地图的绘制上来。下面给出一个被各种装饰物填充的完整的地图:
这里包含了经典90坦克大战中所有的景物:石头(Stone)、草垛(Grass)、河流(River)、冰川(Ice)和砖墙(Brick),在程序中我们将以单词首字母来代表一种景物类型。我们将景物图片贴出来:
除了砖块可以拆分为最小单元为8*8
像素外,其他景物单元为16*16
像素。在地图网格中表现为:
为我们的ResourceMgr
类增加加载这些景物的代码:
package com.pf.java.tankbattle;
import ...
public class ResourceMgr {
public static BufferedImage tank, grass, brick, stone, ice, river;
public static BufferedImage[] bricks;
public static BufferedImage[] rivers;
static {
ClassLoader cl = ResourceMgr.class.getClassLoader();
try {
tank = ImageIO.read(cl.getResourceAsStream("images/tank.png"));
grass = ImageIO.read(cl.getResourceAsStream("images/grass.png"));
brick = ImageIO.read(cl.getResourceAsStream("images/brick.png"));
stone = ImageIO.read(cl.getResourceAsStream("images/stone.png"));
ice = ImageIO.read(cl.getResourceAsStream("images/ice.png"));
river = ImageIO.read(cl.getResourceAsStream("images/river.png"));
// 砖块可拆分为4个最小的粒度单元
bricks = new BufferedImage[] {
brick.getSubimage(0, 0, 8, 8),
brick.getSubimage(8, 0, 8, 8),
brick.getSubimage(0, 8, 8, 8),
brick.getSubimage(8, 8, 8, 8)
};
// 河流包含两帧图片,切换可产生“流动”的动画效果
rivers = new BufferedImage[] {
river.getSubimage(0, 0, 16, 16),
river.getSubimage(16, 0, 16, 16)
};
System.out.println("加载完毕...");
} catch (IOException e) {
e.printStackTrace();
}
}
}
为了后续生成地图数据,我们的地图可以这样来标记各种不同的景物:
对于砖块,我们可以单独绘制出图案不同的这4种类型:B0
、B1
、B2
和B3
。而对于其他的景物,它们占据的16*16
像素的格子,作为一个整体,但我们同样将其拆成了4个部分,以便定位一个景物的四个角。
而对于这些景物我们可以总结出其共性,包含的属性:类型、在地图种的坐标(x
、y
),为此我们的景物类Ornament
设计如下:
package com.pf.java.tankbattle.entity;
import ...
/**
* 游戏地图中包含的景物类,它将作为参数传给绘制方法
*/
public class Ornament {
public Ornament(String type, Integer x, Integer y) {
this.type = type;
this.x = x;
this.y = y;
}
/** 景物类型 */
private String type;
/** x坐标 */
private Integer x;
/** y坐标 */
private Integer y;
// 省略按照type、x和y属性生成的equals和hashcode方法
// 省略getter和setter
}
注意
这里的类型属性
type
的取值可以是B
、G
这类单个字母的值,按某个景物类型来填充16*16
格子;也可以类型后接数字,比如绘制四分之一的砖块,取值可以为B1
、B2
,但如果是其他类型的景物,通常会取值为其左上角的地图值进行绘制,如G0
、R0
等。
有了前面的铺垫,咱们的绘制方法也就水到渠成了,看下绘制的工具类:
package com.pf.java.tankbattle.util;
import ...
/**
* 绘制景物的工具类
*/
public class DrawOrnamentUtil {
/**
* 绘制单独的景物的重载方法
* @param g 绘图面板传入的画笔对象
* @param o 要绘制的景物对象
*/
public static void draw(Graphics g, Ornament o) {
String type = o.getType();
int x = o.getX(), y = o.getY();
if (type.startsWith("B")) {
if ("B".equals(type)) {
// 绘制4个小格子中的砖块
draw(g, new Ornament("B0", x, y));
draw(g, new Ornament("B1", x + 8, y));
draw(g, new Ornament("B2", x, y + 8));
draw(g, new Ornament("B3", x + 8, y + 8));
} else {
// 绘制不同类型的砖块
int brickImgIndex = Integer.parseInt(type.substring(1));
g.drawImage(ResourceMgr.bricks[brickImgIndex], x, y, null);
}
} else {
// 注意这里类型可能为类似于R0这样的值,要进行截取
type = type.length() == 2 ? type.substring(0, 1) : type;
switch (type) {
case "G":
g.drawImage(ResourceMgr.grass, x, y, null);
break;
case "I":
g.drawImage(ResourceMgr.ice, x, y, null);
break;
case "R":
g.drawImage(ResourceMgr.rivers[0], x, y, null);
break;
case "S":
g.drawImage(ResourceMgr.stone, x, y, null);
break;
}
}
}
}
命令模式
前面做了那么多前置开发准备,终于轮到我们这一小节的主角——命令模式登场了。当我们的鼠标在绘图板上经过一次拖拽操作后,轨迹上留下的每一个点,我们都可以将其封装成一个命令对象,其中包含了这个点上的景物绘制操作,这是命令模式的封装思想,它封装的是某一个时刻或者一个轨迹上某一个点亦或者是一整个流程中某一个环节所要执行的行为。命令模式将要执行的行为的声明和执行隔离开来,除了命令的封装,客户端关心的更多的是命令如何收集。下面我们以绘制地图为例,具体来说明命令模式的思想和用法。
基本类设计
首先我们得抽象出一个基本的命令接口:
package com.pf.java.tankbattle.pattern.command;
public interface Command {
void execute();
}
我们抽象出一个万能的execute()
方法,由不同的命令子类型去实现。这里入参和返回值都为空,这些都可以作为命令对象的属性进行封装。接下来我们要扩展出一个命令实现类来封装绘制地图中景物的命令,命令的执行也需要依赖其他的操作接口,以组合的方式,将其作为命令实现类的依赖加入进来,为此我们先定义一个景物绘制接口:
package com.pf.java.tankbattle.pattern.command.draw;
import ...
/**
* 绘制小格子中景物的接口
*/
public interface Drawable {
/**
* 绘制景物,注意砖块包含了4个小块
* @param ornament
*/
void draw(Ornament ornament);
}
参数为Ornament
对象,其中分装了其类型和要绘制的坐标位置信息。很显然该接口的实现类就是我们先前定义的地图绘图板MapPanel
类,通过它的实例获取画笔,调用之前定义的DrawOrnamentUtil
工具类来完成绘制某种景物:
package com.pf.java.tankbattle;
import ...
public class MapPanel extends JPanel implements Drawable {
...
@Override
public void draw(Ornament ornament) {
// 调用景物绘制的工具方法
DrawOrnamentUtil.draw(this.getGraphics(), ornament);
}
...
}
有了前面的铺垫,咱们的绘图命令就可以信手拈来了:
package com.pf.java.tankbattle.pattern.command.draw;
import ...
/**
* 封装的绘制命令,注意要实现equals来比较两个命令对象是否相等
*/
public class DrawCommand implements Command {
private Drawable drawable;
private Ornament ornament;
public DrawCommand(Drawable drawable, Ornament ornament) {
this.drawable = drawable;
this.ornament = ornament;
}
@Override
public void execute() {
drawable.draw(ornament);
}
// 省略基于ornament字段的equals和hashcode方法
}
代码说明
该命令实现类有几个关键点:
- 命令涉及的功能类以接口类型组合进来,注意这条重要原则,组合优于继承,尤其体现在命令实现类中,为了实现各种不同类型的命令,我们只需要组合相应的接口即可。
- 命令执行的入参是在命令对象里进行封装的字段,在其构造器中作为参数被传入。
- 命令对象具有高度的可识别性,相同的命令应当只被收集和执行一次。比如这里在某一个坐标点绘制某种类型的景物,坐标点相同,但是要绘制的景物类型不同,则我们认为是两个命令对象,而这个判断依据就是先前实现了
equals
和hashcode
方法的Ornament
类的对象。我们为每个不同的Ornament
实例进行绘制时才会产生一个命令对象。因此,在我们实际通过鼠标拖拽来生成每个点的命令对象时,也就是绘图轨迹出现交叉时,不会再添加重复的命令对象,但是同一位置可以由绘制其他景物的命令对象覆盖。影响仅仅是,先在这个位置绘制了一个类型的景物,再绘制其他类型的景物来覆盖。
宏命令
宏命令是一组命令的集合,它记录了命令的执行历史。就以咱们这里的绘图功能而言,使用宏命令可以在窗口重绘时执行历史的绘制任务以恢复地图,也可以很方便的对绘图操作进行撤销或者恢复。看下这里的基本实现:
package com.pf.java.tankbattle.pattern.command;
import ...
/**
* 宏命令
* 封装了要执行的命令栈,可以对命令栈中的命令进行添加、删除、清空等操作。
*/
public class MacroCommand implements Command {
/** 用栈来保存命令的集合 */
private Stack commands = new Stack();
/**
* 执行栈中的所有的命令
*/
public void execute() {
Iterator it = commands.iterator();
while (it.hasNext()) {
((Command)it.next()).execute();
}
}
/**
* 添加一条待执行的命令
* 需要注意这条绘制命令不可重复添加,即不可存在在同一个格子中绘制同一类型景物的多个命令
* @param cmd
* @return
*/
public boolean append(Command cmd) {
if (cmd != this && !commands.contains(cmd)) {
commands.push(cmd);
return true;
}
return false;
}
/**
* 移除所有的命令
*/
public void clear() {
commands.clear();
}
}
代码说明
宏命令也实现了
Command
接口,只不过它实现的execute
方法会对一个Stack
结构存储的历史命令进行一一执行。类似于对先前依次实时执行的每条命令进行复现,这里采用的栈结构对执行过的命令进行备份,因为栈的好处是可以很方便的实现命令的撤销。这里
Stack
类型的commands
中存储的可以是宏命令对象、复合命令对象(包含一组单一命令)以及单一命令对象。在执行append
方法时,要判断添加的命令对象不能是当前的宏命令自身,还要判断是否被重复添加,如果是复合命令,要判断是否重复比较麻烦,这里我们只考虑添加进来的是单一的DrawCommand
实例。宏命令中会存储大量的对象,在销毁时,可以调用
clear()
方法来清理这些对象。
收集、执行命令
下面我们将使用命令模式来对MapPanel
实现地图绘制。这里我们对命令的使用分两种情况,边收集边实时执行命令,实现绘图;在窗口发生重绘时,对收集的历史命令以宏命令的形式实现地图的复现。
为了触发绘图命令的收集,我们需要为MapPanel
实现和绑定鼠标拖拽事件,看下具体的代码实现:
package com.pf.java.tankbattle;
import ...
public class MapPanel extends JPanel implements Drawable {
/** 维护历史的绘制命令 */
private MacroCommand historyCmd;
private MyFrame frame;
public MapPanel(MyFrame frame) {
this.frame = frame;
...
historyCmd = new MacroCommand();
// 添加自定义的鼠标拖动监听器、键释放监听器
MyMouseListener myMouseListener = new MyMouseListener(this);
addMouseMotionListener(myMouseListener);
}
@Override
public void paintComponent(Graphics g) {
super.paintComponent(g);
// 绘制网格
paintGrids(g);
}
@Override
public void draw(Ornament ornament) {
// 调用景物绘制的工具方法
DrawOrnamentUtil.draw(this.getGraphics(), ornament);
}
public void paintGrids(Graphics g) {
// 逻辑省略
...
}
/**
* 鼠标拖动监听器
*/
class MyMouseListener extends MouseAdapter {
private MapPanel mapPanel;
public MyMouseListener(MapPanel mapPanel) {
this.mapPanel = mapPanel;
}
@Override
public void mouseDragged(MouseEvent e) {
int x = e.getX(), y = e.getY();
// 16×16像素粒度的绘制
x = x / 16 * 16;
y = y / 16 * 16;
Command cmd = new DrawCommand(mapPanel, new Ornament("B", x, y));
if (historyCmd.append(cmd)) {
cmd.execute();
}
}
}
}
代码说明
这里我们以内部类
MyMouseListener
实现鼠标在面板组件上拖拽的事件监听,为其实例化一个监听器并向当前的面板组件注册。我们为面板增加了一个
MacroCommand
类型的成员变量historyCmd
,用来往其中添加绘制时产生的每一个命令对象,在添加成功的同时会实时执行这个命令,以完成地图的绘制。在生成
DrawCommand
实例前,我们先获取鼠标指针在面板中的当前坐标,将其转成地图网格的相应位置的坐标,这里的网格单元为16*16
,以该坐标和景物类型构建我们要绘制的景物对象,并将其和面板对象一并作为构造参数来构建一个绘图命令实例,而命令实际调用时执行的是实现了Drawable
接口的面板类的draw(ornament)
方法。
现在我们运行程序,来看下实际的绘图效果:
这里我们绘制了一个点赞的小手,除了绘制16*16
规格的砖块外,我们还可以绘制其他类型的景物,比如换成草垛,在new
一个Ornament
实例时第一个参数改为G
,再看看效果:
甚至,我们可以减小粒度,绘制出8*8
规格的砖块,代码调整下:
public void mouseDragged(MouseEvent e) {
int x = e.getX(), y = e.getY();
// 8×8像素粒度的绘制
x = x / 8;
y = y / 8;
String type = "";
if (y % 2 == 0) {
if (x % 2 == 0) {
type = "B0";
} else {
type = "B1";
}
} else {
if (x % 2 == 0) {
type = "B2";
} else {
type = "B3";
}
}
Command cmd = new DrawCommand(mapPanel, new Ornament(type, x * 8, y * 8));
if (historyCmd.append(cmd)) {
cmd.execute();
}
}
这里稍微加了些对地图网格系统中最小网格的定位逻辑,对8*8
单元格的索引进行奇偶判断,以确定砖块的类型。这样,运行程序,得到如下的绘制效果:
咱们再看宏命令在窗体重绘时执行历史绘制任务的代码调整:
在MyFrame
类中增加窗口重绘(比如窗口先最小化再弹出)的重写方法:
@Override
public void paint(Graphics g) {
super.paint(g);
if (mapPanel != null) {
mapPanel.drawHistory();
}
}
相应的在MapPanel
中增加下面的方法:
public void drawHistory() {
// 巧用命令模式来执行历史绘制命令,实现重绘
historyCmd.execute();
}
命令的回退和恢复
在我们先前实现的宏命令的基础上,再实现该功能则非常的简单。我们只需要再MacroCommand
中额外定义几个Stack
类型的成员变量,来存储执行回退(undo)和恢复(redo)的命令数据即可。看下MacroCommand
类中的代码调整:
package com.pf.java.tankbattle.pattern.command;
import ...
public class MacroCommand implements Command {
...
/** 用来保存临时撤销的命令集合 */
private Stack tempCommands = new Stack();
/** 保存每次要撤销多少个命令的数量值 */
private Stack<Integer> undoCommandCountStack = new Stack<>();
/** 保存每次要恢复多少个命令的数量值 */
private Stack<Integer> redoCommandCountStack = new Stack<>();
...
public boolean append(Command cmd) {
if (cmd != this && !commands.contains(cmd)) {
// 每次新增一个命令后,记得把临时的栈清空
if (!tempCommands.empty()) tempCommands.clear();
if (!redoCommandCountStack.empty()) redoCommandCountStack.clear();
commands.push(cmd);
return true;
}
return false;
}
/**
* 删除最后一条命令,实现命令回退的功能
*/
public void undo() {
if (!undoCommandCountStack.empty() && !commands.empty()) {
int count = redoCommandCountStack.push(undoCommandCountStack.pop());
// 把回退的命令存到一个临时的栈中
for (int i = 0; i < count; i++) {
tempCommands.push(commands.pop());
}
}
}
/**
* 恢复上一条命令,要确保没有执行一条新的命令操作
*/
public void redo() {
if (!redoCommandCountStack.empty() && !tempCommands.empty()) {
int count = undoCommandCountStack.push(redoCommandCountStack.pop());
for (int i = 0; i < count; i++) {
commands.push(tempCommands.pop());
}
}
}
public void appendUndoCommandCount(int count) {
undoCommandCountStack.push(count);
}
...
}
代码说明
这里我们额外引入了三个
Stack
类型的成员变量,tempCommands
中存放的是临时撤销(这里我们将实现按Ctrl
+Z
)的命令,可以撤销多次,则以栈的形式不断从commands
中pop
出来,再push
到tempCommands
中,而redo
则是相反的操作。这里特别要注意的是,我们是以一次拖拽的绘制命令集合作为
undo
和redo
的单元,也就是说一次操作的是一批命令,而控制这批命令一次取多少,我们提供了一个appendUndoCommandCount(count)
方法,由外部调用,来对这一批命令的size
进行入栈操作,而在执行undo
和redo
时,我们借助undoCommandCountStack
和redoCommandCountStack
来“交接”某一批命令集合的size
值。
为了记录每次回退操作要统计的命令数量,我们再对MapPanel
中的内部类MyMouseListener
重写mousePressed
和mouseReleased
方法,并将监听器对象注册到MapPanel
对象上。另外再为MyFrame
定义和绑定键盘看下的事件监听器,以对按下组合键:Ctrl
+ Z
(回退)和Ctrl
+ Y
(恢复)进行响应和处理,看下代码的调整:
package com.pf.java.tankbattle;
import ...
public class MapPanel extends JPanel implements Drawable {
/** 维护历史的绘制命令 */
private MacroCommand historyCmd;
private MyFrame frame;
public MapPanel(MyFrame frame) {
this.frame = frame;
...
// 添加自定义的鼠标拖动监听器、键释放监听器
MyMouseListener myMouseListener = new MyMouseListener(this);
...
addMouseListener(myMouseListener);
// 添加key监听器
frame.addKeyListener(new MyKeyListener());
}
...
class MyMouseListener extends MouseAdapter {
...
/** 记录每次拖拽绘制的一批命令的数量 */
private int commandCount;
// 省略构造器
...
@Override
public void mousePressed(MouseEvent e) {
// 每次拖拽绘制前先把改计数变量置为0
this.commandCount = 0;
}
@Override
public void mouseReleased(MouseEvent e) {
if (this.commandCount > 0) {
historyCmd.appendUndoCommandCount(this.commandCount);
}
}
@Override
public void mouseDragged(MouseEvent e) {
...
if (historyCmd.append(cmd)) {
cmd.execute();
this.commandCount++;
}
}
}
class MyKeyListener extends KeyAdapter {
@Override
public void keyPressed(KeyEvent e) {
if (e.getModifiers() == InputEvent.CTRL_MASK && e.getKeyCode() == KeyEvent.VK_Y) {
// redo操作
historyCmd.redo();
frame.repaint();
} else if (e.getModifiers() == InputEvent.CTRL_MASK && e.getKeyCode() == KeyEvent.VK_Z) {
// undo操作
historyCmd.undo();
frame.repaint();
}
}
}
}
我们再运行下程序,看下撤销的效果:
当回退到数字2,再写上6、7,原先的3、4、5的记录会被清除,这一点完全与代码编辑器的回退和恢复吻合。
总结
通过这一小节的学习与实践,相信大家对命令模式有了更深刻的理解。对比我们前一小节学习的享元模式,命令模式它是另一个极端,它以命令对象的形式来封装要执行的指令与行为,所以会以不断的制造对象为乐,而尽量避免使用相同的命令对象,这正和享元模式是两个极端,因此它虽说能很好的完成复杂的功能,但很显然也是有性能开销的。最后附上这一节主要的设计类图,大家加油!