享元模式实战

592 阅读11分钟

前面一小节,我们通过迭代器模式实现了坦克碰撞检测前从满足条件的集合中迭代遍历下一个要比较的物体,并对迭代筛选的逻辑进行了很好的封装。这一小节,我们将在此基础上对碰撞检测的选择范围做一些限制,以此来优化碰撞检测的代码执行性能,并给出享元模式在这个实现过程中的应用。

引入绘图板网格系统

为了更好的对绘图板组件进行后续的游戏元素绘图,我们将引入网格系统,将整个绘图板划分为一个个小的格子,最小的格子为8*8像素的单元,大一些的64*64的,以及再大一些的窗格。在做碰撞检测时我们取当前的移动的物体所在的窗格(可以跨多个窗格),只获取涉及的窗格里的所有物体进行碰撞检测,而不是所有的物体了。这里我们做了一个示例程序,会实时的标记出每个窗格中占据的物体的个数:

grid-system.gif

首先,我们在MyPanel类中定义如下相关的常量:

/** 游戏中最小的格子单元 */
public static final int PIXEL_UNIT = 8;
/** 单元格边长 */
public static final int CELL_WIDTH = 32;
/** 窗格的范围 */
public static final int GRID_SIZE = 160;
/** 行数 */
public static final int ROWS = 12;
/** 列数 */
public static final int COLS = 19;
/** 游戏画布宽度 */
public static final int MAP_WIDTH = CELL_WIDTH * COLS;
/** 游戏画布高度 */
public static final int MAP_HEIGHT = CELL_WIDTH * ROWS;
/** 横向数多少格 */
public static final int GRID_COLS = (int)Math.ceil(MAP_WIDTH / (double)GRID_SIZE);
/** 纵向数多少格 */
public static final int GRID_ROWS = (int)Math.ceil(MAP_HEIGHT / (double)GRID_SIZE);

相应可以改动的常量为:

  • GRID_SIZE

    该常量指定了碰撞检测区域的单元大小,也就是我们这里定义的窗格。不宜设置的过大或过小。

  • ROWS

    基本单元格的尺寸为32*32,该常量表示纵向数,多少行,可自行设置,它影响绘图板的高度。

  • COLS

    该常量表示横向数,多少列,可自行设置,它影响绘图板的宽度。

引入了窗格系统的这些基本参数后,相应的咱们的抽象工厂创建坦克的TankConfig配置类中的xy属性,代表的是多少个PIXEL_UNIT,因此在Tank的构造器中,坐标值要做一个转换:

public Tank(Direction direction, int x, int y, ...) {
    ...
    this.x = x * PIXEL_UNIT;
    this.y = y * PIXEL_UNIT;
    ...
}

为保证坦克在行驶时,它的对立方向(行驶的垂直方向)坐标始终落在PIXEL_UNIT的整数倍上,关于这一点可以在红白机坦克大战中体会到,我们可以在坦克转向时,进行如下控制:

public void setDirection(Direction direction) {
    // 如果坦克转向则重新计算坐标
    if (this.direction != direction) {
        // 判断如果时垂直方向
        if (Direction.isVertical(direction)) {
            this.x = Math.round(this.x / PIXEL_UNIT) * PIXEL_UNIT;
        } else {
            this.y = Math.round(this.y / PIXEL_UNIT) * PIXEL_UNIT;
        }
    }
    this.direction = direction;
}

进一步抽象碰撞检测的物体

从前面几个小节,把游戏功能开发到这一步,貌似我们还没有把面向对象的思想继续贯彻好,因为要做碰撞检测的物体不光是目前出现在游戏中的坦克,还有我们后面要实现的子弹,为此,我们进一步进行面向对象的抽象思维,我们抽象出一个代表图形的接口Shaped,上一小节在实现CollisionCollection类时封装的getFourPointsgetCentralPos方法可以抽取为Shaped接口方法,另外我们再加一个getRect方法,该方法接收一个moving参数,表明我们是否要获取物体移动一个像素后的位置。接口定义如下:

package com.pf.java.tankbattle;
​
import ...
​
/**
 * 抽象出来的用于碰撞检测的图形接口,这里暂时只考虑矩形的情况
 */
public interface Shaped {
​
    /**
     * 获取用于碰撞检测的矩形信息
     * @param moving 如果是移动中的物体这里要考虑在移动方向再往前移动一个像素后,再获取矩形信息
     * @return 数组,包含的信息分别为:[x坐标、y坐标、宽度、高度]
     */
    int[] getRect(boolean moving);
​
    /**
     * 获取中心点的坐标
     * @return
     */
    int[] getCentralPos();
​
    /**
     * 获取四个端点的坐标
     * @return
     */
    int[] getFourPoints();
}

这样以后,我们上一小节迭代器的类设计,可以调整为:

image.png

思考片刻

从我们之前的抽象类到进一步抽象为接口,我们把面向对象的思想升华为面向接口编程,大伙儿可以好好体会下这种精髓。

我们对抽象的Tank类实现这个接口:

package com.pf.java.tankbattle.entity.tank;
​
import ...
    
public abstract class Tank implements Shaped {
    ...
    
    @Override
    public int[] getRect(boolean moving) {
        if (!moving) {
            return new int[] {x, y, SIZE, SIZE};
        }
        // 移动中的要往前挪一步
        int tx = x, ty = y; // 临时的x、y坐标
        switch (direction) {
            case LEFT:
                tx--;
                break;
            case UP:
                ty--;
                break;
            case RIGHT:
                tx++;
                break;
            case DOWN:
                ty++;
        }
        return new int[] {tx, ty, SIZE, SIZE};
    }
​
    @Override
    public int[] getCentralPos() {
        return new int[]{x + SIZE / 2, y + SIZE / 2};
    }
​
    @Override
    public int[] getFourPoints() {
        return new int[]{x, x + SIZE, y, y + SIZE};
    }
    ...
}

享元模式登场

前面我们进一步抽象,引入了接口。接下来我们将实现物体在窗格系统中的定位、位置变换以及每个窗格中占据的物体列表存储。

Position类设计

image.png

这里我们设计一个Position类,它记录了物体在绘图板中占据的窗格信息,这四个属性我们可以用下面的示意图来说明下:

image.png

很显然,当坦克位于一个窗格时,x1 == x2y1 == y2;当坦克横跨两个窗格时,x1 + 1 == x2y1 == y2;当坦克纵跨;两个窗格时,y1 + 1 == y2x1 == x2;而当坦克跨4个窗格时,x1 + 1 == x2y1 + 1 == y2

再来看PositionFactory类:

package com.pf.java.tankbattle.pattern.flyweight;

import ...

public class PositionFactory {

    private static final Map<String, Position> cache = new HashMap<>();

    /**
     * 根据物体在游戏绘图区域的x、y坐标,计算出其所在的窗格区域的位置索引
     * @param x
     * @param y
     * @param w
     * @param h
     * @return
     */
    public static Position getPosition(int x, int y, int w, int h) {
        int x1 = x / GRID_SIZE;
        int x2 = ( x + w ) / GRID_SIZE;
        if (x2 > 0 && ( x + w ) % GRID_SIZE == 0) x2--; // 远处的一端只考虑超出的情况
        int y1 = y / GRID_SIZE;
        int y2 = ( y + h ) / GRID_SIZE;
        if (y2 > 0 && ( y + h ) % GRID_SIZE == 0) y2--; // 远处的一端只考虑超出的情况

        StringBuilder sb = new StringBuilder();
        String key = sb.append(x1).append("_").append(x2).append("_").append(y1).append("_").append(y2).toString();

        if (cache.containsKey(key)) {
            return cache.get(key);
        }
        Position position = new Position(x1, x2, y1, y2);
        cache.put(key, position);
        return position;
    }
}

说明

这里的静态工厂用于创建一个Position对象,因此我们把它也叫做对象工厂。对象工厂不同于我们之前讲到的抽象工厂的实现,它可以重复的利用共享的对象,也就是我们说的享元。类似于软件架构设计中的缓存。因为决定Position的位置信息势必会存在着重复的取值,因此,没有必要每次都创建一个相应的对象,也就是能节省内存空间的,我们就不在这方面做无谓的浪费,这就是享元模式的主旨。我们这里的实现是,基于传入的物体的坐标和尺寸信息,计算出其横线和纵向所占据的窗格的索引值,然后判断这些索引值代表的Position对象能否从之前创建的对象中重复利用,这里我们用了一个Map来缓存对象,key采用自定义的命名格式,只要能相互区分即可。

Grid类设计

image.png

Grid类代表了绘图板中的一个窗格,xy属性分别代表了窗格横向和纵向索引。occupiers属性代表了该窗格中所占据的物体列表,所谓的物体则是我们前面抽象出来的所有具有形状的Shaped接口,当然这里我们目前只考虑矩形。

最重要的当属GridHelper类,它提供了窗格发挥作用的核心功能,这里对外提供了三个主要的静态方法,最后的getGrids()方法仅是为了测试绘图提供出来的。

先看下基础的工具方法:

private static Grid getGrid(int x, int y) {
    Grid grid = grids[y][x];
    if (grid != null) return grid;
    grid = new Grid(x, y);
    grids[y][x] = grid;
    return grid;
}
​
private static Grid[] toGrids(Position p) {
    if (p == null) {
        return new Grid[]{};
    }
    int x1 = p.getX1();
    int x2 = p.getX2();
    int y1 = p.getY1();
    int y2 = p.getY2();
    if (x1 != x2 && y1 != y2) {
        return new Grid[]{getGrid(x1, y1), getGrid(x2, y1), getGrid(x1, y2), getGrid(x2, y2)};
    } else if (x1 != x2) {
        return new Grid[]{getGrid(x1, y1), getGrid(x2, y1)};
    } else if (y1 != y2) {
        return new Grid[]{getGrid(x1, y1), getGrid(x1, y2)};
    } else {
        return new Grid[]{getGrid(x1, y1)};
    }
}

代码说明

toGrids(position)方法用于将一个Position对象转成一个Grid数组。前面我们也用示意图说明了Position对象中存储的信息会存在几种跨窗格的情况,这里我们要将所有占据的窗格都返回。而我们发现,getGrid(x, y)方法的实现,完全符合我们前面提到的享元的理念,没错,这里我们又一次学习和巩固了享元模式。我们有一个二维数组grids来从存储相应的索引位置已经创建的窗格对象,当我们发现该位置已经有创建的Grid对象了,我们就直接取用,否则新new一个并放到该位置。

下面再看一个我们很关心的问题,坦克在移动的过程中,Position的值,也就是代表坦克占据的窗格信息的对象会变。当位置变换了以后,如何来判断哪些窗格是被离开的,而哪些窗格又是被进入的呢?用下面一个示意图来说明:

image.png

按照这样的指导思想,我们很容易实现下面的静态工具方法:

/**
 * 求取a1和a2互为补集的结果放到一个类型为列表的数组中
 * @param a1
 * @param a2
 * @return
 */
private static List<Grid>[] diff(Grid[] a1, Grid[] a2) {
​
    List<Grid> r1 = new ArrayList<>();
    List<Grid> r2 = new ArrayList<>();
​
    intersect(a1, a2, r1);
    intersect(a2, a1, r2);
    return new List[] {r1, r2};
}
​
/**
 * 求取数组a1在a2中的补集
 * @param a1
 * @param a2
 * @param r1
 */
private static void intersect(Grid[] a1, Grid[] a2, List<Grid> r1) {
    for (Grid grid : a1) {
        boolean matched = false;
        for (Grid value : a2) {
            if (grid.equals(value)) {
                matched = true;
                break;
            }
        }
        if (!matched) r1.add(grid);
    }
}

有了前面的铺垫,我们的核心方法则水到渠成了:

/**
 * 处理对窗格系统的占据逻辑
 * @param newPosition 移动后新的位置对象
 * @param oldPosition 移动前旧的位置对象
 * @param object Shaped接口实现类对象
 */
public synchronized static void handleOccupy(Position newPosition, Position oldPosition, Shaped object) {
    Grid[] newGrids = toGrids(newPosition);
    Grid[] oldGrids = toGrids(oldPosition);
    // 判断两个grid集合的补集
    List<Grid>[] r = diff(newGrids, oldGrids);
    // 进入的grid
    if (!r[0].isEmpty()) {
        for (Grid grid : r[0]) {
            grid.getOccupiers().add(object);
        }
    }
    // 离开的grid
    if (!r[1].isEmpty()) {
        for (Grid grid : r[1]) {
            grid.getOccupiers().remove(object);
        }
    }
}
​
/**
 * 获取某个位置对象中所有要进行碰撞检测的物体列表
 * @param position
 * @return
 */
public synchronized static List<Shaped> getObjects(Position position) {
    Grid[] grids = toGrids(position);
    List<Shaped> result = new LinkedList<>();
    for (Grid grid : grids) {
        List<Shaped> list = grid.getOccupiers();
        if (list.size() > 1) {
            result.addAll(list);
        }
    }
    return result;
}
​
/**
 * 释放某位置被指定物体占据的窗格的空间
 * @param position
 * @param object
 */
public synchronized static void freeSpace(Position position, Shaped object) {
    Grid[] grids = toGrids(position);
    for (Grid grid : grids) {
        grid.getOccupiers().remove(object);
    }
}

代码说明

这里要注意,在多线程下对这些方法进行调用时要确保线程同步,因此这里我们对静态方法使用了synchronized,使其变为同步方法。

应用层

这里贴出Tank类中调整的核心代码:

package com.pf.java.tankbattle.entity.tank;

import ...

public abstract class Tank implements Shaped {

    ...

    /** 占据的grid的位置索引 */
    private Position position;

    ...

    public Tank(Direction direction, int x, int y, int speed, int picIndex, MyFrame frame) {
        ...
        // 初始化位置信息
        this.position = PositionFactory.getPosition(this.x, this.y, SIZE, SIZE);
        ...
        // 初始化占据逻辑调用
        GridHelper.handleOccupy(this.position, null, this);
    }

    ...

    public boolean move() {
        // 前置处理和判断逻辑省略
        // -------- 执行坦克和其他物体之间的碰撞检测逻辑 --------
        boolean hitted = false; // 判断是否检测到碰撞
        // 先计算当前移动的坦克再往前移动一步后的坐标点,然后基于移动后坐标点与其他物体的坐标点进行碰撞检测
        int[] nextRect = getRect(true);
        // 创建要进行碰撞检测的矩形对象
        Rectangle currRect = new Rectangle(nextRect[0], nextRect[1], SIZE, SIZE);
        // 获取游戏中所有障碍物
        List<Shaped> objects = GridHelper.getObjects(position);
        if (!objects.isEmpty()) {
            // 传入所有障碍物的集合和当前移动的坦克来构造一个自定义的碰撞检测集合对象
            CollisionCollection collection = new CollisionCollection(objects, this, this.direction);
            // 获取遍历器对象
            Iterator<Shaped> iterator = collection.iterator();
            Shaped other;
            // 只管从遍历器中获取下一个障碍物来进行碰撞检测即可
            // 在自定义集合内部封装了系统的过滤逻辑
            while (iterator.hasNext()) {
                other = iterator.next();
                // 创建其他障碍物代表的矩形对象
                int[] otherRect = other.getRect(false);
                // 进行碰撞检测
                if (currRect.intersects(new Rectangle(otherRect[0], otherRect[1], otherRect[2], otherRect[3]))) {
                    hitted = true;
                    afterHit();
                    break;
                }
            }

            if (hitted) return false;
        }

        // 实现在前进方向移动一个像素的距离,逻辑省略
        ...
        // 重新计算和处理对grid占用
        Position p = PositionFactory.getPosition(x, y, SIZE, SIZE);
        if (!p.equals(position)) {
            GridHelper.handleOccupy(p, position, this);
            position = p;
        }
        return true;
    }

    ...
}

怎么样,这种打怪升级式的一步步的把设计模式应用到一个有趣的坦克大战游戏中的过程,是不是让你对于面向对象的设计思想以及模式的理解更进一步了,是不是有种继续攻克下一关的冲动呢,那就请继续关注作者的《Java设计模式活学活用》技术专栏,下一小节咱们一起来学习实践命令模式,用命令模式来diy坦克大战的地图,并实现绘制的撤销回退和向前恢复的功能,大家加油!

draw-bricks.gif