建造者模式实践

2023年 7月 14日 30.1k 0

上一小节,我们进行了命令模式的综合实战,完成了坦克大战地图的绘制功能,这一小节我们将在此基础上再锦上添花的增加地图数据的保存和开始游戏时加载先前编辑好的地图这两个功能。另外,我们还会绘制大鸟基地,并通过建造者模式来增加基地的防御工事,也就是在基地外围生成围墙。

我们来看下实现的效果,普通砖块修筑的基地防御工事:

image.png

100米厚的花岗岩防御工事:

image.png

再完整演示下:

draw-map-save-start-game.gif

当然这里还暂时没实现坦克和地形的碰撞检测,这正是我们下一小节要完成的,是不是有点期待~

保存和加载地图

首先,我们继续完善地图的基本功能,实现地图数据的保存,并在开始游戏时加载已保存的地图。要注意,当从绘制地图切换到开始游戏,我们需要对MapPanel做一些销毁工作,为此我们将一些组件和事件监听器对象需要提升为成员变量,以方便对其移除。

另外,我们将在MapPanel组件类中增加基地类House和与建造者模式相关的建筑师类Architect。看下MapPanel类中新增的成员变量:

/** 是否可用,可用则会不断执行重绘线程 */
private volatile boolean available = true;
​
...
​
private House house = House.getInstance();
​
private Architect architect;
​
private MyMouseListener myMouseListener;
​
private MyKeyListener myKeyListener;

这里引入了一个available成员变量,当设置为false时,则表示要结束当前绘图板组件的重绘线程,并对该组件进行销毁处理,因此,多个线程访问它,要加volatile关键字,确保可见性。House类看调用形式就知道这里我们用了单例模式,毕竟大鸟基地只有一个。

再看下MapPanel的构造器:

public MapPanel(MyFrame frame) {
​
    ...
​
    // 实例化建筑师对象时传入一个Builder实例
    architect = new Architect(new BrickWallBuilder());
​
    // 初始化地图二维数组,全部用E填充,并为坦克初始位置预留出占位空间
    initMap();
​
    // 添加自定义的鼠标拖动监听器、键释放监听器
    myMouseListener = new MyMouseListener(this);
    addMouseMotionListener(myMouseListener);
    addMouseListener(myMouseListener);
​
    // 添加key监听器
    myKeyListener = new MyKeyListener();
    frame.addKeyListener(myKeyListener);
}

这里我们看到建造者模式的调用形式,我们需要为建筑师传入一个特定的工种实例,由它负责具体的实施工作,而建筑师的工作就是全局性的建筑架构设计,指定建筑的结构、框架,具体制作材料和砌墙的工作交给指定的建造者(工种)即可。在初始化地图数据的方法中,我们为大鸟基地构筑防御工事:

private void initMap() {
    // 初始化地图
    ...
    // 将二维地图数组对象传给建筑师
    architect.setMap(map);
    // 建筑师指挥相应的工种按照其架构的蓝图进行实时,即,在大鸟基地外层构筑围墙
    architect.buildWall();
}

初始化地图后,我们将得到为基地构建好防御工事的地图,在此基础上我们可以实现进一步的地图绘制。因为后期我们要在地图上开32*32几个固定位置的方格来容纳坦克出场的空间,因此,在绘制地图时我们将以黑色背景图作为占位来覆盖:

private void paintTankPlaceholder(Graphics g, int x, int y) {
    g.drawImage(ResourceMgr.tiles[5], x, y, null);
    // 绘制上边框
    g.drawRect(x, y, 32, 32);
}

在重绘的方法中,我们将地图中几个特定的位置用黑色背景图覆盖:

@Override
public void paintComponent(Graphics g) {
    super.paintComponent(g);
    paintGrids(g);
    // 执行地图绘制,不需要回调
    DrawOrnamentUtil.draw(g, map, () -> {});
​
    // 绘制基地(一只大鸟)
    house.paint(g);
​
    // 留出坦克的占位,以基地的位置作为参考
    int hx = house.getX();
    int hy = house.getY();
    // 空出左上角
    paintTankPlaceholder(g, 0, 0);
    // 空出顶部中间位置
    paintTankPlaceholder(g, hx, 0);
    // 空出右上角位置
    paintTankPlaceholder(g,MAP_WIDTH - 32, 0);
    // 空出英雄出场的位置
    paintTankPlaceholder(g, hx - 64, hy);
​
    // 如果笔头命令可利用(包含必要景物类型和笔触的两个设置),则执行笔头的绘制
    if (drawPenHeadCommand.isAvailable()) drawPenHeadCommand.execute(g);
}

而对于这些空出位置的方格,我们需要在生成地图的文件(对二位数组对象写入文件)之前,对这些区域使用E来覆盖,以便在开始游戏加载地图后看到这些区域留空,后续我们实现坦克生产者消费者模式时,坦克会从这些位置出场。看下处理方法:

public void updateAndSaveMap() {
    int hx = house.getX() / PIXEL_UNIT;
    int hy = house.getY() / PIXEL_UNIT;
    updateMapUnitAsEmpty(0, 0);
    updateMapUnitAsEmpty(hx, 0);
    updateMapUnitAsEmpty((MAP_WIDTH - 32) / PIXEL_UNIT, 0);
    updateMapUnitAsEmpty(hx - 8, hy);
    updateMapUnitAsEmpty(hx, hy);
    // 保存地图数据
    ObjectStoreUtil.writeObject(map, "map_data/map1");
}
​
private void updateMapUnitAsEmpty(int x, int y) {
    for (int i = 0; i < 2; i++) {
        for (int j = 0; j < 2; j++) {
            int x2 = x + 2 * j;
            int y2 = y + 2 * i;
            map[y2][x2] = map[y2][x2 + 1] = map[y2 + 1][x2] = map[y2 + 1][x2 + 1] = "E";
        }
    }
}

updateAndSaveMap方法完成空白区域的覆盖后,则调用我们写的一个工具类ObjectStoreUtil的写入对象的方法writeObject把二维数组写入要指定的路径文件中。对象的读写借助了流ObjectInputStream的读写对象的API,具体实现:

package com.pf.java.tankbattle.util;
​
import ...
​
public class ObjectStoreUtil {
​
    public static void writeObject(Object obj, String path) {
        // 自动关闭流的写法
        try (FileOutputStream fileOut = new FileOutputStream(path);
             ObjectOutputStream outStream = new ObjectOutputStream(fileOut)) {
            outStream.writeObject(obj);
        } catch (IOException ex) {
            ex.printStackTrace();
            System.err.println("写入对象失败");
        }
    }
​
    public static Object readObject(String path) {
        // 自动关闭流的写法
        try (FileInputStream fileIn = new FileInputStream(path);
             ObjectInputStream in = new ObjectInputStream(fileIn)) {
            return in.readObject();
        } catch (Exception ex) {
            ex.printStackTrace();
            System.out.println("读取对象失败");
        }
        return null;
    }
​
}

MapPanel类的最后一个调整,我们加了一个销毁的方法,通过手动设置空引用,加速垃圾回收器来回收不用的对象:

public void destroy() {
    house = null;
    architect = null;
    map = null;
    drawPenHeadCommand.setPenHeadSettings(null);
    drawPenHeadCommand.setTypeSettings(null);
    drawPenHeadCommand = null;
    removeMouseMotionListener(myMouseListener);
    removeMouseListener(myMouseListener);
    frame.removeKeyListener(myKeyListener);
    historyCmd.clear();
    historyCmd = null;
    System.out.println("销毁MapPanel成功!");
}

再来看主窗体MyFrame类中的调整代码:

package com.pf.java.tankbattle;
​
import ...
​
public class MyFrame extends JFrame {
    
    // 其他成员变量声明省略
    ...
    
    // 将地图绘制板提升为成员变量
    private MapPanel mapPanel;
​
    public MyFrame() {
        // 窗体基本设置省略
        ...
​
        // 设置菜单栏
        JMenuBar menuBar = new JMenuBar(); // 菜单栏组件
        JMenu myMenu = new JMenu("我的菜单"); // 菜单
​
        // 绑定菜单项
        JMenuItem drawMapMenuItem = new JMenuItem("绘制地图"); // 菜单项
        JMenuItem startGameMenuItem = new JMenuItem("开始游戏");
        JMenuItem saveMapMenuItem = new JMenuItem("保存地图");
        // 窗体初始设置【绘制地图】和【开始游戏】两个菜单项
        myMenu.add(drawMapMenuItem);
        myMenu.add(startGameMenuItem);
        menuBar.add(myMenu);
        setJMenuBar(menuBar);
​
        // 菜单事件
        startGameMenuItem.addActionListener(e -> {
            // 开始游戏前,如果当前正在进行地图绘制,先触发结束绘制和相关销毁工作
            if (mapPanel != null) {
                mapPanel.setAvailable(false);
            }
​
            // 加载保存的地图数据
            String[][] map = (String[][]) ObjectStoreUtil.readObject("map_data/map1");
            // 将面板组件添加到窗口对象的内容面板中
            this.panel = new MyPanel(this, map);
            getContentPane().add(panel);
            // 重置窗口大小和位置
            pack();
            setLocationRelativeTo(null);
            // 开始游戏
            startGame();
        });
        drawMapMenuItem.addActionListener(e -> {
            this.mapPanel = new MapPanel(this);
            getContentPane().add(mapPanel);
​
            // 绘图设置工具箱的组件初始化和相关设置代码省略
            ...
​
            // 变更菜单项
            myMenu.remove(drawMapMenuItem);
            myMenu.add(saveMapMenuItem);
​
            // 开启一个不断重绘的线程
            Thread t = new Thread(() -> {
                // 这里判断绘图板可用才不断重绘,可以在【开始游戏】时改变这个值
                while (mapPanel.isAvailable()) {
                    // 休眠120ms
                    repaint();
                }
                // 销毁绘图板组件
                getContentPane().remove(mapPanel);
                mapPanel.destroy();
                remove(toolbox);
                mapPanel = null;
                pack(); // 重新包裹
            });
            t.start();
        });
        saveMapMenuItem.addActionListener(e -> {
            // 将地图数据保存到文件
            mapPanel.updateAndSaveMap();
            System.out.println("保存地图成功!!");
        });
​
        // 其他代码省略
        ...
​
    }
​
    ...
}

再来看游戏绘图板MyPanel中的调整:

package com.pf.java.tankbattle;
​
import ...
​
public class MyPanel extends JPanel {
​
    ...
​
    private String[][] map;
​
    private House house = House.getInstance();
​
    ...
​
    public MyPanel(MyFrame frame, String[][] map) {
        ...
        // 由外部菜单在开始游戏前先获取和加载先前保存的地图数据
        this.map = map;
​
        // 设置绘图板组件的外观和实例化坦克代码省略
        ...
    }
​
    @Override
    protected void paintComponent(Graphics g) {
        // 必须要调用父类方法来完场组件的基本绘制,比如构造器中设置的背景色等等
        super.paintComponent(g);
        // 调用实际绘制方法
        doPaint(g);
    }
​
    public void doPaint(Graphics g) {
        // 绘制基地
        house.paint(g);
        // 绘制地图,并在提供的夹层回调中绘制坦克
        DrawOrnamentUtil.draw(g, map, () -> {
            for (Tank tank : tanks) {
                tank.paint(g);
            }
        });
    }
​
    ...
}

建造者模式

看完了基础功能的实现,我们再看回建造基地所采用的建造者模式,看下相关的类图:

image.png

这里的House是一个不相关的类,但是Architect中的buildWall()方法会调用其静态方法calcHousePosition来获取要生成防御工事地形数据的参照位置,而在House中在实例化对象、初始化要绘制的坐标位置时也会调用该方法,看下House类源码:

package com.pf.java.tankbattle.entity;
​
import ...
​
​
/**
 * 我方军营类
 * 采用单例模式
 */
public class House {
​
    // 实现为单例的静态非公开成员变量
    private static final House INSTANCE = new House();
​
    /** 起点x坐标 */
    private int x;
    /** 起点y坐标 */
    private int y;
    /** 鸟图尺寸 */
    private int size = 32;
​
    private House() {
        int[] hp = calcHousePosition();
        this.x = (hp[0] + 2) * PIXEL_UNIT;
        this.y = (hp[1] + 2) * PIXEL_UNIT;
    }
​
    /**
     * 确定绘制我方军营的起点坐标
     * @return
     */
    public static int[] calcHousePosition() {
        int x = (MAP_WIDTH / PIXEL_UNIT - 8) / 2;
        int y = (MAP_HEIGHT / PIXEL_UNIT - 6);
        return new int[] {x, y};
    }
​
    public void paint(Graphics g) {
        g.drawImage(ResourceMgr.home[0], x, y, null);
    }
​
    public int getX() {
        return x;
    }
​
    public int getY() {
        return y;
    }
​
    public static House getInstance() {
        return INSTANCE;
    }
}

我们为这里的建造者提供了一个抽象类WallBuilder

package com.pf.java.tankbattle.pattern.builder;
​
public abstract class WallBuilder {
    public abstract void makeWall(String[][] map, int x, int y);
}

传入二维数组的地图以及要绘制的16*16方格地形(砖头或者石头)的左上角索引位置。具体的实现,则交给两个建造者工种来完成:

一个垒普通砖墙的工种:

package com.pf.java.tankbattle.pattern.builder.impl;
​
import ...
​
public class BrickWallBuilder extends WallBuilder {
​
    @Override
    public void makeWall(String[][] map, int x, int y) {
        map[y][x] = "B0";
        map[y][x + 1] = "B1";
        map[y + 1][x] = "B2";
        map[y + 1][x + 1] = "B3";
    }
}

还有一个切割和粘合花岗岩防御工事的工种:

package com.pf.java.tankbattle.pattern.builder.impl;
​
import ...
​
public class StoneWallBuilder extends WallBuilder {
​
    @Override
    public void makeWall(String[][] map, int x, int y) {
        map[y][x] = "S0";
        map[y][x + 1] = "S1";
        map[y + 1][x] = "S2";
        map[y + 1][x + 1] = "S3";
    }
}

再看我们的建筑师类Architect,他负责总设计,这里的设计为基地左右和上面三个方位进行围墙的加固,具体的实施则交给依赖的工种去做即可。

package com.pf.java.tankbattle.pattern.builder;
​
import ...
​
/**
 * 建筑师类
 */
public class Architect {
​
    private String[][] map;
​
    private WallBuilder builder;
​
    public Architect(WallBuilder builder) {
        this.builder = builder;
    }
​
    public void setMap(String[][] map) {
        this.map = map;
    }
​
    // todo 待实现
    public void cleanWall() {
​
    }
​
    public void buildWall() {
        cleanWall();
        int[] hp = House.calcHousePosition();
        int x0 = hp[0], y0 = hp[1];
        for (int i = 0; i < 4; i++) {
            int x = x0 + i * 2;
            builder.makeWall(map, x, y0);
        }
        y0 += 2;
        for (int i = 0; i < 2; i++) {
            int y = y0 + i * 2;
            builder.makeWall(map, x0, y);
        }
        x0 += 6;
        for (int i = 0; i < 2; i++) {
            int y = y0 + i * 2;
            builder.makeWall(map, x0, y);
        }
    }
}

总体来说,这一小节的建造者这个设计模式我们通过给基地加固防御工事的实践后,还是很有趣也很好理解的。后续我们将继续以亲身实践、不断迭代咱们的坦克大战游戏功能的形式给大家介绍更多设计模式的用法。下一小节,我们将绘制一些迷宫地图,再实现坦克与地形的碰撞检测,并利用迷宫回溯算法求取穿过地形障碍的最短算法以提升敌方坦克的智商,让坦克大战游戏变得更刺激些!学习的动力源于玩的心态,大家加油!

相关文章

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

发布评论