Comprehensive actual combat in command mode - perfect reproduction of topographic maps of tank battles

In the previous section, we realized the preliminary drawing of the tank battle map scene through the use of command mode . In order to further consolidate the learning results, in this section, we will write a practical demo to improve the drawing of maps and the generation of terrain data.

Drawing function display

Take a look at the renderings we made:

image.png

For the drawing function, based on the previous section, we have implemented the brush setting panel on the right. You can choose different scenery to be drawn, and choose the size to be drawn. It should be noted that the size of the brick can be selected 8*8, but other scenery types cannot. Look at the drawing effect:

draw-map1.gif

It is found that when we select the first button in the size column, some scene buttons are disabled in the corresponding scene type column, and these settings are related. Similarly, the setting of the scene type above will also affect the selectable state of the first button of the size. We continue to select other scenes to draw, here we can realize the overlay drawing of the scene on the map:

draw-map2.gif

We can also erase the drawn area, and the erased area can also choose various "strokes":

draw-map3.gif

It should be noted that when 8*8the size of the erase stroke is selected, the scenery other than the bricks cannot be erased, because their size is the smallest 16*16unit, and 16*16the size of the erase stroke can be replaced.

Finally, we can also implement the undo operation for repainting:

draw-map4.gif

class design

After reading the drawing functions implemented above, let's look at the class design again. Here is a principle we must always follow. No matter how complicated the function is, we must have object-oriented thinking and try to encapsulate the details that the outside world does not need to know. It needs to provide the minimum API to the outside. Take a look at our overall design class diagram:

image.png

Compared with the previous section, for the design ideas of the drawing command class, we have made some optimizations in this section. The drawing is divided into two parts. The map generation command is responsible for generating the terrain data of the local unit area, and the specific drawing The logic is handed over to a tool class, which is responsible for drawing based on the map data generated by the map generation command. For this kind of drawing, we will start a separate redrawing thread to draw with the method MapPanelof continuous calling. paintComponentTo do this, we first MyFramestart a thread in the menu click handler method of the class that initializes the map drawing component:

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();
});

In this way, when the mouse cursor is dragged in the drawing area, the command to generate the map will be generated and executed, or press the key combination: Ctrl+ Z(Back) and Ctrl+ Y(Resume), after performing undothe redosum operation, execute the history generation command to regenerate the map data. The drawing thread is responsible for continuously redrawing the map panel according to the set interval, drawing the interface of the map data and drawing the pen head that moves with the cursor. See the calling logic of the redraw method of the middle component MapPanel:

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

The interface effect is as follows:

draw-map5.gif

In the previous section, the drawing command we implemented directly draws the terrain without generating map data; however, the optimization processing in this section makes our mouse drag and drop only responsible for the generation and calling of the command object that generates the map. Specifically The drawing is handed over to a thread for continuous redrawing, which improves the drawing experience, and there will be no lag during undoexecution . At the design level, we have well implemented the single-responsibility design principle, allowing different classes and components to do what they care about, and improve the overall execution efficiency.redo

command-related classes

Look at the class design related to generating map data

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<OrnamentTypeSettings> 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 < 0 || x + width > 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[][]Finally, the core method of the tool class for map drawing based on terrain data (a type of two-dimensional array) DrawOrnamentUtil:

public static void draw(Graphics g, String[][] map, Sandwich s) {
​
    ...
​
    // 绘制在最里层的景物
    List<Ornament> back = new ArrayList<>();
    // 绘制在最外层的景物
    List<Ornament> 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 < 4; k++) {
                    int x = j * 2, y = i * 2;
                    if (k % 2 == 1) x++;
                    if (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);
}

code description

Here we divide the drawing of the scene into two parts, because with the addition of tanks and bullets, the drawn content will overlap and cover. For example, bullets and tanks can be hidden in haystacks, but displayed on glaciers and rivers The outside cannot be covered, so we divide the foreground and background here, and provide an anonymous callback function for externally drawing movable objects such as tanks and bullets.

Summarize

Through the coding practice of the command mode in this section, I believe that while enjoying the interesting map drawing through code, the practice of Java object-oriented design ideas has been deeply imprinted in the mind, so keep it. In the next section, we will recognize and practice the Builder mode, and use it to realize the drawing of our big bird base. Come on, everybody!

Guess you like

Origin juejin.im/post/7253605936144056381