命令模式综合实战——完美复刻坦克大战地形图

2023年 7月 12日 62.0k 0

上一小节我们通过命令模式的使用实现了坦克大战地图景物的初步绘制。为了进一步巩固学习成果,这一小节,我们将写一个实战的demo来完善地图的绘制和地形数据的生成。

绘图功能展示

看下我们做出的效果图:

image.png

绘图功能,我们在上一小节的基础上,实现了右侧画笔设置面板。可以选择要绘制的不同的景物,并选择要绘制的尺寸,需要注意的是,砖块可以选择8*8的尺寸,而其他景物类型则不可以。看下绘制效果:

draw-map1.gif

发现,当尺寸一栏,我们选中第一个按钮时,相应的景物类型一栏,有些景物按钮被禁用了,这些设置有关联的。同样上面景物类型的设置也会影响尺寸第一个按钮的可选状态。我们继续选择其他的景物来绘制,这里我们可以实现景物在地图上的覆盖绘制:

draw-map2.gif

我们还可以对已绘制的区域进行擦除,擦除的区域同样可以选择各种各样的“笔触”:

draw-map3.gif

需要注意的是,当选择8*8尺寸的擦除笔触时,无法擦除除砖块之前外的景物,因为它们的尺寸最小为16*16的单元,换成16*16尺寸的擦除笔触即可。

最后我们还可以实现撤销操作,以便进行重新绘制:

draw-map4.gif

类设计

看完了以上实现的绘图功能,我们再来看类设计,这里有一个我们始终要遵循的原则,不管多复杂的功能,我们都要有面向对象的思想,尽量的封装外部不需要知道的细节,只需要提供给外部最小限度的api即可。 来看下我们整体的设计类图:

image.png

相比于上一小节,对于绘制命令类的设计思路,这一小节,我们做了一定的优化,绘制分为两个部分,由地图生成命令负责生成局部单元区域的地形数据,而具体的绘制逻辑交给一个工具类,由它负责基于地图生成命令生成好的地图数据进行绘制。这种绘制我们将启动一个单独的重绘线程,以不断的调用MapPanelpaintComponent方法进行绘制。为此,我们首先要在MyFrame类中初始化地图绘制组件的菜单点击处理方法中启动一个线程:

drawMapMenuItem.addActionListener(e -> {
    MapPanel mapPanel = new MapPanel(this);
    getContentPane().add(mapPanel);
​
    // 绘图面板初始化代码省略
    ...
    
    // 开启一个不断重绘的线程
    Thread t = new Thread(() -> {
        while (true) {
            try {
                Thread.sleep(120);
            } catch (InterruptedException ex) {
                ex.printStackTrace();
            }
            repaint();
        }
    });
    t.start();
});

这样当鼠标光标在绘图区域拖拽时将产生并执行生成地图的命令,或者按组合键:Ctrl + Z(回退)和Ctrl + Y(恢复)时,进行undoredo操作后,再执行历史生成命令,重新生成地图数据。而绘制线程则负责按设置的间隔时间不断的重绘地图面板,对地图数据进行界面绘制以及绘制随光标移动的笔头,看下MapPanel中组件重绘方法的调用逻辑:

@Override
public void paintComponent(Graphics g) {
    super.paintComponent(g);
    paintGrids(g);
    // 执行地图绘制
    DrawOrnamentUtil.draw(g, map, () -> {});
​
    // 如果笔头命令可利用(包含必要景物类型和笔触的两个设置),则执行笔头的绘制
    if (drawPenHeadCommand.isAvailable()) drawPenHeadCommand.execute(g);
}

界面效果如下:

draw-map5.gif

上一小节,我们实现的绘制命令,直接对地形进行绘制,并没有生成地图数据;而本节的这种优化处理,让我们的鼠标拖拽只负责生成地图的命令对象的生成和调用,具体的绘制交给了一个线程以不断的重绘,提升了绘图的体验,执行undoredo时也不会出现卡顿的情况。站在设计的层面,我们很好的实现了单一职责设计原则,让不同的类、不同的组件做它自己关心的事情,提高整体的执行效率。

命令相关的类

看下生成地图数据相关的类设计

image.png

这里我们基于基本的命令接口Command实现了一个生成地图数据的命令实现类GeneratorCommand,在它内部我们以私有的成员变量的形式,封装了执行具体生成任务的Generator接口依赖(实际执行命令时调用的是依赖对象generator.generate(x, y, width, height, type)方法)以及跟生成地图相关的坐标位置、生成地图区域的宽高和景物的类型字段。同样我们要对这心信息字段重写equalshashcode方法,以避免在拖拽时对同一个位置和区域生成相同景物的命令对象。

而宏命令MacroCommand依然负责收集生成命令并提供undoredoclear和历史绘制方法,唯一调整的是append方法增加了一个参数forceAppend来强制添加命令对象,以修复在被其他景物覆盖的位置再覆盖回原来的景物时的bug:

public boolean append(Command cmd, boolean forceAppend) {
    if (cmd != this && (!commands.contains(cmd) || forceAppend)) {
        // 省略执行逻辑
        ...
        return true;
    }
    return false;
}

另外为了优化绘图体验,我们添加了绘制笔头的功能,也就是,光标在绘图板上移动时,有一个跟随的笔头效果,笔头会以设定好的景物类型的颜色和笔触(要绘制的形状)来展示。这里我们以单命令的形式来对其实现的。看下类图:

image.png

先定义一个绘制命令接口:

package com.pf.java.tankbattle.pattern.command.map;
​
import ...
​
public interface DrawCommand {
    void execute(Graphics g);
}

这里的参数为画笔对象,会由MapPanel类的paintComponent(Graphics g)方法的入参传入进来。绘制笔头命令实现类DrawPenHeadCommand中包含了要绘制的坐标位置(xy属性)以及当前笔头的设置对象typeSettingspenHeadSettings

这里提供了一个方法来判断当前的笔头是否设置可用,即,判断这两个属性都要设置上:

public boolean isAvailable() {
    return this.typeSettings != null && this.penHeadSettings != null;
}

该类中最主要的就是命令执行方法:

@Override
public synchronized void execute(Graphics g) {
    Color color = typeSettings.getColor();
    // 最后一个参数设置透明度
    g.setColor(new Color(color.getRed(), color.getGreen(), color.getBlue(), 180));
    int width = penHeadSettings.getWidth();
    int height = penHeadSettings.getHeight();
    // 绘制跟随光标内嵌于网格中的笔触
    g.fillRect(x, y, width, height);
    // 绘制一个显眼的白色边框
    g.setColor(Color.lightGray);
    g.drawRect(x, y, width, height);
}

该方法的绘制工作依赖了外部的设置对象,注意,这里的坐标xy的值是经过计算的,具体的计算由不同的笔触设置所决定,我们可以在光标在绘图板上移动或者拖动时,从事件对象中获取鼠标指针的当前坐标,再按照当前设置的笔触来计算,具体的计算由笔头设置对象来完成,提供一个给外部调用的方法:

public int[] calcPoint(int x, int y) {
    return penHeadSettings.handle(x, y);
}

设置相关的类

再来看设置面板,这里包含了两个设置按钮组:

image.png

我们同样采用面向对象的思想来设计设置类并封装设置的参数。这里涉及到的类图:

image.png

与第一组按钮对应的,我们设计了一个OrnamentTypeSettings类,其中包含了景物的类型type、笔头的颜色color和对应的按钮组件button。在其构造器中我们还将传入其位于左侧面板的坐标信息以及按钮的Icon图片资源,用于button对象的构造:

public OrnamentTypeSettings(String type, Icon icon, Color color, int x, int y) {
    this.type = type;
    this.color = color;
    this.button = new JButton();
    this.button.setIcon(icon);
    this.button.setBounds(x, y, 32, 32);
}

再来看笔触设置类PenHeadSettings,它的属性包括了尺寸信息、对应的设置按钮和一个用于原始坐标转换计算的Handler接口类型,这个handler由外部在实例化时作为一个匿名接口实现类对象的入参传入,这里我们实现为一个箭头函数调用的形式。看下完整的类:

package com.pf.java.tankbattle.entity.settings;
​
import ...
​
public class PenHeadSettings {
​
    private int width;
    private int height;
    private JButton button;
    private Handler handler;
​
    public PenHeadSettings(int width, int height, Icon icon, int x, int y, Handler handler) {
        this.width = width;
        this.height = height;
        this.button = new JButton();
        this.button.setIcon(icon);
        this.button.setBounds(x, y, 32, 32);
        this.handler = handler;
    }
​
    public int[] handle(int x, int y) {
        return this.handler.handle(x, y);
    }
​
    // 省略width、height和button的getter
    ...
​
    public interface Handler {
        int[] handle(int x, int y);
    }
}

有了上述两个与每个按钮对应的设置类后,我们还需要有两个相应的设置组类,来封装当前的设置对象、触发一组按钮的点击事件并在其中实现处理逻辑、启用或者禁用一些按钮以及在切换选中设置后对外进行回调设置。

先来看OrnamentTypeSettingsGroup设置组类,先看下基本定义:

package com.pf.java.tankbattle.entity.settings;
​
import ...
​
public class OrnamentTypeSettingsGroup {
​
    /** 当前的设置,可以为null */
    private OrnamentTypeSettings currSettings;
​
    /** 所有设置的列表 */
    private List group;
​
    /** 引用的笔头设置组对象 */
    private PenHeadSettingsGroup penHeadSettingsGroup;
​
    /** 触发设置后的回调匿名实现类对象(箭头函数) */
    private Callable callable;
​
    // 其他方法暂时省略
    ...
​
    public void setPenHeadSettingsGroup(PenHeadSettingsGroup penHeadSettingsGroup) {
        this.penHeadSettingsGroup = penHeadSettingsGroup;
    }
​
    public interface Callable {
        void call(OrnamentTypeSettings settings);
    }
}

在其构造器中,我们将实例化并添加各个设置按钮以及绑定点击事件和编写事件处理逻辑,具体的代码包含了必要的注释,方便大家理解:

public OrnamentTypeSettingsGroup(JPanel container, Callable callable) {
    this.callable = callable;
    group = new ArrayList();
    group.add(new OrnamentTypeSettings("B", new ImageIcon(ResourceMgr.tiles[0]), new Color(199, 81, 17), 10, 40));
    // 省略其他按钮的实例化和添加逻辑
    ...
    for (OrnamentTypeSettings settings : group) {
        // 在左侧面板中添加按钮对象
        container.add(settings.getButton());
        // 处理点击事件
        settings.getButton().addActionListener(e -> {
            // 首先重置当前的设置对象,将选中的边框高亮样式去掉
            if (currSettings != null) {
                currSettings.getButton().setBorder(BorderFactory.createEmptyBorder());
            }
            // 如果切换了选中
            if (settings != currSettings) {
                // 执行切换并高亮显示边框
                currSettings = settings;
                settings.getButton().setBorder(BorderFactory.createLineBorder(Color.YELLOW, 3));
                // 如果当前选中项为Grass、Stone、River或者Ice中的一项,要禁用笔头设置组中的第一个按钮
                if ("GSRI".contains(settings.getType())) {
                    penHeadSettingsGroup.disableSettings(0);
                } else {
                    penHeadSettingsGroup.enableSettings(0);
                }
            } else { // 反选的情况
                currSettings = null;
                // 恢复笔头设置组的禁用项
                if ("GSRI".contains(settings.getType())) {
                    penHeadSettingsGroup.enableSettings(0);
                }
            }
            // 进行选中项的外部回调
            callable.call(currSettings);
        });
    }
}

然后,我们提供两个方法来对组中的某些选项进行启用和禁用,这里的参数我们采用了可变长度的int类型来传入按钮所在列表的索引列表:

public void disableSettings(int... indexArr) {
    for (int index : indexArr) {
        OrnamentTypeSettings settings = group.get(index);
        settings.getButton().setEnabled(false);
        // 如果当前选中项被禁用,要取消选中,并回调通知外部组件
        if (settings == currSettings) {
            currSettings.getButton().setBorder(BorderFactory.createEmptyBorder());
            currSettings = null;
            callable.call(null);
        }
    }
}
​
public void enableSettings(int... indexArr) {
    for (int index : indexArr) {
        OrnamentTypeSettings settings = group.get(index);
        settings.getButton().setEnabled(true);
    }
}

PenHeadSettingsGroup设置组类的大部分代码实现同上,这里粘贴出一部分关键代码:

package com.pf.java.tankbattle.entity.settings;
​
import ...
​
public class PenHeadSettingsGroup {
​
    ...
​
    /** 引用的景物类型设置组对象 */
    private OrnamentTypeSettingsGroup ornamentTypeSettingsGroup;
​
    ...
​
    public PenHeadSettingsGroup(JPanel container, Callable callable) {
        this.callable = callable;
        group = new ArrayList();
        // 实例化并添加每种笔触对应的按钮,注意这里通过箭头函数封装了每种笔触对原始坐标进行转换的计算逻辑
        group.add(new PenHeadSettings(8, 8, new ImageIcon(ResourceMgr.penHeads[0]), 10, 160, (x, y) -> {
            x = x / 8 * 8;
            y = y / 8 * 8;
            return new int[]{x, y};
        }));
        // 省略其他按钮的实例化和添加逻辑
        ...
​
        for (PenHeadSettings settings : group) {
            ...
            settings.getButton().addActionListener(e -> {
                // 首先重置当前的设置对象,将选中的边框高亮样式去掉
                ...
                // 如果切换了选中
                if (settings != currSettings) {
                    // 执行切换并高亮显示边框
                    ...
                    // 如果选中的是第一个按钮,也就是8*8尺寸的笔触,要禁用景物类型设置按钮组中的Grass、Stone、River和Ice
                    if (settings.getWidth() == 8) {
                        ornamentTypeSettingsGroup.disableSettings(1, 2, 3, 4);
                    } else {
                        ornamentTypeSettingsGroup.enableSettings(1, 2, 3, 4);
                    }
                } else { // 反选的情况
                    currSettings = null;
                    // 恢复景物类型设置组的禁用项
                    if (settings.getWidth() == 8) {
                        ornamentTypeSettingsGroup.enableSettings(1, 2, 3, 4);
                    }
                }
                ...
            });
        }
​
    }
​
    ...
​
}

这部分最后给出MyFrame类的构造器中对左侧设置面板进行初始化的逻辑:

drawMapMenuItem.addActionListener(e -> {
    ...    
    // 绘图工具盒
    JPanel toolbox = new JPanel();
    toolbox.setLayout(null);
    toolbox.setBackground(new Color(238, 238, 238)); // 灰色背景
    // 初始化宽度
    toolbox.setPreferredSize(new Dimension(200,0));
    // 添加到窗体布局的右侧
    add(toolbox, BorderLayout.EAST);
​
    // 设置景物类型标题
    JLabel ornamentTypeTitle = new JLabel("景物类型");
    ornamentTypeTitle.setBounds(10, 10, 80, 20);
    toolbox.add(ornamentTypeTitle);
​
    // 创建一个景物类型设置组对象,箭头函数为回调设置
    OrnamentTypeSettingsGroup ornamentTypeSettingsGroup = new OrnamentTypeSettingsGroup(toolbox, settings -> {
        mapPanel.setTypeSettings(settings);
        // 让当前窗体上绑定的键盘事件继续有效
        requestFocus();
    });
​
    JLabel sizeTitle = new JLabel("尺寸");
    sizeTitle.setBounds(10, 130, 40, 20);
    toolbox.add(sizeTitle);
​
    // 创建一个画笔设置组对象,箭头函数为回调设置
    PenHeadSettingsGroup penHeadSettingsGroup = new PenHeadSettingsGroup(toolbox, settings -> {
        mapPanel.setPenHeadSettings(settings);
        requestFocus();
    });
​
    // 让两组设置相互关联
    ornamentTypeSettingsGroup.setPenHeadSettingsGroup(penHeadSettingsGroup);
    penHeadSettingsGroup.setOrnamentTypeSettingsGroup(ornamentTypeSettingsGroup);
​
    ...
});

MapPanel类

最后再回到我们的绘图板主类上来。我们为该类提供了对DrawPenHeadCommand类的一个依赖drawPenHeadCommand。从前面的回调设置我们也可以发现,这里我们将提供两个方法来完成设置:

public void setTypeSettings(OrnamentTypeSettings settings) {
    drawPenHeadCommand.setTypeSettings(settings);
}
public void setPenHeadSettings(PenHeadSettings settings) {
    drawPenHeadCommand.setPenHeadSettings(settings);
}

MyMouseListener中触发绘制的方法中包含了我们发起命令生成、变更和执行的最核心的控制逻辑:

class MyMouseListener extends MouseAdapter {
​
    private MapPanel mapPanel;
​
    /** 记录每次拖拽绘制的一批命令的数量 */
    private int commandCount;
​
    private int oldX, oldY;
​
    public MyMouseListener(MapPanel mapPanel) {
        this.mapPanel = mapPanel;
    }
​
    /**
     * 更新笔头的落点
     * @param e
     */
    private void updatePenHead(MouseEvent e) {
        int x = e.getX(), y = e.getY();
        int[] p = drawPenHeadCommand.calcPoint(x, y);
        x = p[0];
        y = p[1];
        updatePenHead(x, y);
    }
    private void updatePenHead(int x, int y) {
        if (x != oldX || y != oldY) {
            synchronized (drawPenHeadCommand) {
                drawPenHeadCommand.setX(x);
                drawPenHeadCommand.setY(y);
            }
            oldX = x;
            oldY = y;
        }
    }
​
    @Override
    public void mouseMoved(MouseEvent e) {
        if (!drawPenHeadCommand.isAvailable()) return;
        updatePenHead(e);
    }
​
    @Override
    public void mousePressed(MouseEvent e) {
        // 每次拖拽绘制前先把改计数变量置为0
        this.commandCount = 0;
​
        doExecution(e);
    }
​
    @Override
    public void mouseReleased(MouseEvent e) {
        if (this.commandCount > 0) {
            historyCmd.appendUndoCommandCount(this.commandCount);
        }
    }
​
    @Override
    public void mouseDragged(MouseEvent e) {
        doExecution(e);
    }
​
    private void doExecution(MouseEvent e) {
        if (!drawPenHeadCommand.isAvailable()) return;
​
        int x = e.getX(), y = e.getY();
​
        // 计算光标拖拽的指针位置坐标内嵌于窗格中的坐标位置
        int[] p = drawPenHeadCommand.calcPoint(x, y);
        x = p[0];
        y = p[1];
        int width = drawPenHeadCommand.getWidth();
        int height = drawPenHeadCommand.getHeight();
        // 检查是否超过边界
        if (x < 0 || y  MAP_WIDTH || y + height > MAP_HEIGHT) {
            return;
        }
​
        String type = drawPenHeadCommand.getType();
        String existType = map[y / 8][x / 8];
        // 处理8*8笔头的绘制限制
        if (width == 8 && !existType.equals("E") && !existType.startsWith("B")) {
            return;
        }
​
        Command cmd = new GeneratorCommand(mapPanel, x, y, width, height, type);
        // 第二个参数实现可覆盖绘制
        if (historyCmd.append(cmd, !existType.startsWith(type))) {
            // 边拖拽边执行地图数据生成
            cmd.execute();
            this.commandCount++;
        }
​
        updatePenHead(x, y);
    }
}

代码说明

这里我们提供了两个updatePenHead的重载方法,用于光标在移动或者拖拽绘制时在网格中内嵌位置的设置,用两个变量oldXoldY记录了一次移动后的结果,如果位置发生了变动则更新drawPenHeadCommand中的位置信息,注意,这里的更新要加同步代码块,以确保原子性,同时同步代码块也保证了修改变量的可见性,而我们对DrawPenHeadCommand中的execute(graphics)方法同样采用了同步代码来控制,来确保读写是互斥的。

这里最核心的是doExecution方法,在它其中包含了生成地形命令对象前的校验逻辑,比如笔头是否可用、绘制是否超过边界以及处理8*8规格的笔头的绘制限制逻辑。在添加生成命令之前时,还要考虑这块区域之前绘制的景物被其他景物覆盖然后再用先前的景物类型进行绘制的bug问题,解决办法是,判断绘制起点的地形元素类型和要绘制的类型不一样,就可以强制覆盖。

最后不要忘了,我们的MapPanel类要负责每个生成地形命令的最终执行,也就是要实现Generator接口。生成地形数据的核心逻辑如下:

@Override
public void generate(int x, int y, int width, int height, String type) {
    // 转换地图坐标为二维数组的索引
    x /= 8;
    y /= 8;
    // 计算处理单元(16*16像素)的数量
    int cols = width / 16;
    int rows = height / 16;
    if ("E".equals(type)) {
        if (cols == 0) {
            map[y][x] = "E";
            return;
        }
    } else if ("B".equals(type)) {
        if (cols == 0) {
            if (y % 2 == 0) {
                if (x % 2 == 0) {
                    type = "B0";
                } else {
                    type = "B1";
                }
            } else {
                if (x % 2 == 0) {
                    type = "B2";
                } else {
                    type = "B3";
                }
            }
            map[y][x] = type;
            return;
        }
    }
    for (int i = 0; i < rows; i++) {
        for (int j = 0; j < cols; j++) {
            int x2 = x + 2 * j;
            int y2 = y + 2 * i;
            map[y2][x2] = "E".equals(type) ? "E" : type + "0";
            map[y2][x2 + 1] = "E".equals(type) ? "E" : type + "1";
            map[y2 + 1][x2] = "E".equals(type) ? "E" : type + "2";
            map[y2 + 1][x2 + 1] = "E".equals(type) ? "E" : type + "3";
        }
    }
}

代码说明

关于地形数据的形式,在前面的小节有图示说明,这里在绘制时要考虑8*8的单个地形元素的生成以及以16*16像素为单元的相邻的4个地形元素的生成逻辑。

最后是基于地形数据(一个String[][]类型的二维数组)进行地图绘制的工具类DrawOrnamentUtil的核心方法:

public static void draw(Graphics g, String[][] map, Sandwich s) {
​
    ...
​
    // 绘制在最里层的景物
    List back = new ArrayList();
    // 绘制在最外层的景物
    List front = new ArrayList();
​
    for (int i = 0; i < ROWS * 2; i++) {
        for (int j = 0; j < COLS * 2; j++) {
            // 获取左上角四分之一格子的类型
            String type = map[i * 2][j * 2];
            // 如果是空白或者砖块类型
            if ("E".equals(type) || type.startsWith("B")) {
                // 再细化判断四个格子
                for (int k = 0; k  1) y++;
                    type = map[y][x];
                    // 跳过空白的格子
                    if ("E".equals(type)) continue;
                    // 添加要绘制的砖块元素
                    front.add(new Ornament(type, x * 8, y * 8));
                }
            } else { // 判断16×16格子为其他景物类型
                int x = j * 16, y = i * 16;
                // 河流和冰川要画在最底层
                if (type.startsWith("I") || type.startsWith("R")) {
                    back.add(new Ornament(type, x, y));
                } else {
                    front.add(new Ornament(type, x, y));
                }
            }
        }
    }
​
    draw(g, back);
    // 坦克、子弹等在这个回调方法中绘制
    s.draw();
    draw(g, front);
}

代码说明

这里我们将景物的绘制分为两部分,因为再加上坦克和子弹这些元素,绘制的内容会存在叠加覆盖的情况,比如,子弹和坦克可以藏在草垛里,而要显示在冰川和河流的外面,不能被覆盖,因此我们这里分了前景和后景,而又提供了一个匿名回调函数用于外部绘制坦克、子弹等可移动的物体。

总结

通过本小节命令模式的编码实战,相信大家在享受通过代码来实现趣味性的地图绘制的同时,对于Java面向对象设计思想的践行在脑海里打上了深深的烙印,那就保持下去吧。下一小节,我们将认识和实践建造者(Builder)模式,用它来实现我方大鸟基地的绘制。大家加油!

相关文章

JavaScript2024新功能:Object.groupBy、正则表达式v标志
PHP trim 函数对多字节字符的使用和限制
新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
为React 19做准备:WordPress 6.6用户指南
如何删除WordPress中的所有评论

发布评论