迭代器模式在游戏碰撞检测中的应用

384 阅读10分钟

前一小节我们完成了基于抽象工厂来创建游戏中各种类型的坦克。这一小节,我们继续开发坦克大战游戏功能,先让敌方坦克可以在游戏画面中随机的移动和转向,同时我们确保,所有的坦克必须在游戏窗口的边界内活动,且相互有碰撞的情况下会阻止继续前进。我们将先快速完成一个开发版本,然后在此基础上使用迭代器模式来优化我们代码的执行效率。

基础功能完善

首先将前面小节实现坦克启动的线程变量moveThread,放到基类Tank中,并改个名字,叫tankThread,因为对于敌方坦克而言,在这个线程中不光有移动,还有开火,甚至我们后期还会给其注入“思考”的意识,再用一个单独的start()方法来启动坦克线程。

package com.pf.java.tankbattle.entity.tank;
​
import ...
​
public abstract class Tank {
​
    ...
​
    /** 坦克线程 */
    protected Thread tankThread;
    
    ...
        
    /**
     * 启动坦克线程
     */
    public void start() {
        tankThread.start();
    }
​
    ...
}

为了获取所有坦克的列表以便进行后面的碰撞检测,我们将之前在MyPanel中分开定义坦克存储变量的地方,改成用一个List<Tank>类型的列表来统一存储所有的坦克,调整的代码这里略过。并为MyFrame添加一个startGame()方法,来启动游戏画面重绘和坦克线程:

public void startGame() {
    paintThread.start();
    List<Tank> tanks = getTanks();
    for (Tank tank : tanks) {
        tank.start();
    }
}

再看下敌方坦克的移动控制:

package com.pf.java.tankbattle.entity.tank;
​
import ...
​
public class EnemyTank extends Tank {
​
    ...
​
    public EnemyTank(EnemyType enemyType, Direction direction, int x, int y, int speed, int picIndex, MyFrame frame) {
        ...
        this.init();
    }
​
    public void init() {
​
        tankThread = new Thread(() -> {
            while (true) {
                move();
                try {
                    // 计算每走一个像素花费的毫秒数,并以此作为休眠时间
                    Thread.sleep(1000 / getSpeed());
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
    }
​
    ...
}

运行程序的效果:

enemy-moving.gif

可以看到敌方坦克齐刷刷的向下进发了,接下来我们要控制所有坦克必须在绘图板边界范围内移动。在基类Tank中进行实现:

package com.pf.java.tankbattle.entity.tank;
​
import ...
​
public abstract class Tank {
​
    ...
​
    public boolean move() {
        ...
        // 判断是否碰到边界
        boolean hitEdge = false;
        switch (direction) {
            case LEFT:
                hitEdge = x <= 0;
                break;
            case UP:
                hitEdge = y <= 0;
                break;
            case RIGHT:
                hitEdge = x + SIZE >= MAP_WIDTH;
                break;
            case DOWN:
                hitEdge = y + SIZE >= MAP_HEIGHT;
        }
        // 如果碰到边界则直接返回
        if (hitEdge) {
            afterHit();
            return false;
        }
        // 省略move实现
        ...
    }
​
    protected abstract void afterHit();
​
    ...
}

MyPanel中定义了代表绘图板宽高的静态常量MAP_WIDTHMAP_HEIGHT。在实际move一个像素之前,先判断是否已经到达边界,如果是,则调用一个由子类实现的afterHit()方法,然后直接返回falseafterHit()方法主要由敌方坦克来实现,比如当敌方坦克碰触到边界后,会改变方向。而敌方坦克在前进过程中,也会小概率的改变方向,看具体实现:

package com.pf.java.tankbattle.entity.tank;
​
import ...
​
public class EnemyTank extends Tank {
​
    ...
​
    private Random random = new Random();
​
    ...
​
    public void init() {
​
        tankThread = new Thread(() -> {
            while (true) {
                // 移动过程中移动的转向概率(概率很小)
                if (Math.random() > 0.998) {
                    this.turnToOtherDirection();
                }
                // 调用move()方法并休眠下
                ...
            }
        });
    }
​
    @Override
    protected void afterHit() {
        this.turnToOtherDirection();
    }
​
    private void turnToOtherDirection() {
        // 获取当前的坦克方向枚举值的索引
        int currDirectionIndex = getDirection().ordinal();
        // 随机转向其他方向
        int nextDirectionIndex = (currDirectionIndex + random.nextInt(3) + 1) % 4;
        // 转向
        turnRound(Direction.values()[nextDirectionIndex]);
    }
​
    ...
}

现在我们再看下游戏的运行效果:

enemy-moving2.gif

剩下的问题,只要修复坦克能相互穿过就完美了。很自然,我们想到的思路是:对游戏画面中每一个坦克与其他所有的坦克进行碰撞检测

实现坦克间的碰撞检测

接下来我们看坦克之间碰撞检测的核心代码的实现:

package com.pf.java.tankbattle.entity.tank;
​
import ...
​
public abstract class Tank {
​
    ...
        
    public boolean move() {
        // 前置逻辑省略
        ...
        // -------- 执行坦克之间的碰撞检测逻辑 --------
        boolean hitted = false; // 判断是否检测到碰撞
        // 先计算当前移动的坦克再往前移动一步后的坐标点,然后基于移动后坐标点与其他坦克的坐标点进行碰撞检测
        int[] nextCoordinate = calcNextStepCoordinate(x, y);
        // 创建要进行碰撞检测的矩形对象
        Rectangle currRect = new Rectangle(nextCoordinate[0], nextCoordinate[1], SIZE, SIZE);
        // 获取游戏中所有坦克
        List<Tank> tanks = frame.getTanks();
        for (Tank tank : tanks) {
            if (tank == this) continue; // 排除自身
            // 创建其他坦克代表的矩形对象
            Rectangle otherRect = new Rectangle(tank.getX(), tank.getY(), SIZE, SIZE);
            // 进行碰撞检测
            if (currRect.intersects(otherRect)) {
                hitted = true;
                afterHit();
                break;
            }
        }
​
        if (hitted) return false;
​
        // 实际move一步的代码省略
    }
​
    /**
     * 计算坦克再前进一步的坐标,以此来判断是否和其他移动的物体相撞
     * @param x 当前x轴坐标值
     * @param y 当前y轴坐标值
     * @return int数组,计算出的移动后的坐标点
     */
    private int[] calcNextStepCoordinate(int x, int y) {
        switch (direction) {
            case LEFT:
                x--;
                break;
            case UP:
                y--;
                break;
            case RIGHT:
                x++;
                break;
            case DOWN:
                y++;
        }
        return new int[] {x, y};
    }
​
    ...
}

这块碰撞检测逻辑,在实现的核心代码中加了注释,不难理解,就不再赘述。直接看效果:

hit-eachOther.gif

敌方坦克碰撞到前面的物体,则会自动转向其他方向,这是咱们的实现逻辑。

从画面中看似我们的程序没有问题,但真是这样吗?我们可以在初始化敌方坦克时,可以把它们的起始位置的坐标重叠在一块儿,再来看看程序运行效果:

hit-eachOther-bug.gif

很糟糕,它们一开始则发生了碰撞检测,都出不来了,这可不是我们想要的结果。也就是说并不是所有的情况我们都要进行碰撞检测,更明确的说,要进行碰撞检测的坦克列表应该满足一定的条件,我们要排除掉现有列表中不满足碰撞检测条件的一些坦克。

迭代器模式实践

因此,这里会引入一个新的设计模式——迭代器模式。我们将一个集合在内部进行一定的筛选,而对外则是无感知的,只管用一个作为遍历用途的“指针”,也就是迭代器对象,调用next()方法获取下一个元素,直到取到为null结束。

迭代器API使用

看下应用了迭代器模式后Tank.move()的关键代码:

// 获取游戏中所有坦克
List<Tank> tanks = frame.getTanks();
​
// 传入所有坦克的集合和当前移动的坦克来构造一个自定义的碰撞检测集合对象
CollisionCollection collection = new CollisionCollection(tanks, this);
// 获取遍历器对象
Iterator<Tank> iterator = collection.iterator();
Tank other;
// 只管从遍历器中获取下一个坦克来进行碰撞检测即可
// 在自定义集合内部封装了系统的过滤逻辑
while (iterator.hasNext()) {
    other = iterator.next();
    // 创建其他坦克代表的矩形对象
    Rectangle otherRect = new Rectangle(other.getX(), other.getY(), SIZE, SIZE);
    // 进行碰撞检测
    if (currRect.intersects(otherRect)) {
        hitted = true;
        afterHit();
        break;
    }
}
if (hitted) return false;

很显然,原先对于碰撞检测是否满足基本条件的排除逻辑在这不见了,我们将其放在了使用迭代器的相关类的内部了,这对客户端的使用来说是透明的。

API设计

迭代器接口:

package com.pf.java.tankbattle.pattern.iterator;
​
/**
 * 遍历器接口
 * @param <T> 泛型指代要遍历的集合类型
 */
public interface Iterator<T> {
    /**
     * 遍历方法
     * @return
     */
    T next();
    
    /**
     * 是否有下一个元素
     * @return
     */
    boolean hasNext();
}

该接口直接由用户使用,对特定的集合进行遍历,这里的泛型T指代要遍历的集合的元素类型。除了用于遍历的next()方法外,还提供了一个判断是否有下一个元素的方法hasNext()

集合接口:

package com.pf.java.tankbattle.pattern.iterator;
​
/**
 * 要遍历的集合接口
 * @param <T> 集合中的元素类型
 */
public interface Collection<T> {
    /**
     * 获取迭代器对象
     * @return
     */
    Iterator<T> iterator();
​
    /**
     * 遍历方法
     * @return
     */
    T next();
    
    /**
     * 是否有下一个元素
     * @return
     */
    boolean hasNext();
}

该接口由具体的集合类实现,其中包含了获取迭代器对象进行遍历,具体遍历的方法则是这里的next()方法。下面再来看具体的实现类:

迭代器实现类

package com.pf.java.tankbattle.pattern.iterator.impl;
​
import ...
​
/**
 * 碰撞物体迭代器实现
 */
public class CollisionIterator implements Iterator<Tank> {
​
    private Collection<Tank> collection;
​
    public CollisionIterator(Collection<Tank> collection) {
        this.collection = collection;
    }
​
    @Override
    public Tank next() {
        return collection.next();
    }
    
    @Override
    public boolean hasNext() {
        return collection.hasNext();
    }
}

该迭代器中关联了一个集合对象,调用迭代器的next()方法实际则是调用的Collectionnext()方法,hasNext()方法也是如此。最后再来看我们最主要的碰撞物集合类CollisionCollection

package com.pf.java.tankbattle.pattern.iterator.impl;
​
import ...
​
/**
 * 碰撞物集合
 */
public class CollisionCollection implements Collection<Tank> {
​
    private Iterator<Tank> iterator;
​
    /** 要进行碰撞检测的坦克集合 */
    private List<Tank> tanks;
​
    /** 当前移动的坦克 */
    private Tank currTank;
​
    /** 记录当前遍历的碰撞物的索引 */
    private int currIndex;
​
    /** 用于临时保存调用hasNext()方法的结果,以便调用next()时直接返回 */
    private Tank nextElement;
​
    /** 在调用hasNext()方法时会记录下一个位置 */
    private Integer nextIndex;
​
    public CollisionCollection(List<Tank> tanks, Tank currTank) {
        this.tanks = tanks;
        this.currTank = currTank;
    }
​
    /**
     * 遍历下一个碰撞物,这里包含了过滤逻辑
     * @return
     */
    @Override
    public Tank next() {
        if (this.nextIndex != null) {
            // 要对之前调用hasNext()方法预遍历的下一个索引设置到当前索引变量上
            this.currIndex = this.nextIndex;
            // 控制下一次调用hasNext()则进行新的一轮预遍历
            this.nextIndex = null;
            return this.nextElement;
        }
        int size = tanks.size();
        if (currIndex >= size) return null;
        // exclude方法包含了要排除碰撞检测的逻辑
        while (currIndex < size && exclude(tanks.get(currIndex))) {
            currIndex++;
        }
        if (currIndex < size) {
            return tanks.get(currIndex++);
        }
        return null;
    }
​
    /**
     * 排除碰撞检测的逻辑
     * @param other
     * @return
     */
    private boolean exclude(Tank other) {
        // todo 待实现的集合元素排除逻辑
        return false;
    }
​
    @Override
    public boolean hasNext() {
        // 已经遍历过,则取预遍历的结果
        if (this.nextIndex != null) {
            return this.nextElement != null;
        }
        // 记录下遍历之前的位置
        int tempIndex = this.currIndex;
        this.nextElement = next();
        // 记录预遍历后的位置
        this.nextIndex = this.currIndex;
        // 注意这里的当前遍历的索引一定要再恢复回去
        this.currIndex = tempIndex;
        return this.nextElement != null;
    }
​
    @Override
    public Iterator<Tank> iterator() {
        if (this.iterator != null) {
            return this.iterator;
        }
        this.iterator = new CollisionIterator(this);
        return iterator;
    }
}

代码详解

  1. 该集合实现类实现了iterator()方法,来获取一个迭代器对象,客户端用它来对当前的集合对象进行遍历,因此这里在构建迭代器实例时,会将自身(以this指代的对象)作为构造器参数传入。
  2. 该类的功能是对外部传入的集合进行内部的排除逻辑,因此需要注意,获取下一个元素并不是单纯意义上的currIndex自增取下一个位置的元素那么简单,中间会按照我们的判断逻辑跳过若干个要被排除的元素。为此,我们的hasNext()方法要实现预遍历的逻辑,并将预遍历的结果保存起来,供next()方法取用,因此我们引入了额外的两个成员变量nextElementnextIndex,对它们的赋值和判断逻辑见代码的注释说明,也不是很复杂。
  3. 从我们的实现来看,很显然该集合的遍历是线程不安全的。

我们实现的关联逻辑在exclude(otherTank)方法中,我们要考虑以下几种情况:

  1. otherTanknull的情况

  2. otherTankcurrTank是同一个引用

  3. 在移动方向错开,这里以水平方向为例,垂直方向也一样判断

    image.png

  4. 相向前进,没有碰撞上的情况

    image.png

  5. 中心点背离的情况

    image.png

    如果初始化坦克的位置有重叠,使用这种判断可以解决前面的bug

核心的排除逻辑,处理方法如下:

/**
 * 排除碰撞检测的逻辑
 * @param other
 * @return
 */
private boolean exclude(Tank other) {
    // 要碰撞的物体不存在,则排除
    if (other == null) return true;
    // 如果就是自身,排除
    if (currTank == other) return true;
​
    int n1, n2, m1, m2;
    int[] fp1 = getFourPoints(currTank);
    int[] fp2 = getFourPoints(other);
    // 判断要碰撞的方向获取相应的坐标
    if (Direction.isVertical(currTank.getDirection())) {
        n1 = fp1[0];
        n2 = fp1[1];
        m1 = fp2[0];
        m2 = fp2[1];
    } else {
        n1 = fp1[2];
        n2 = fp1[3];
        m1 = fp2[2];
        m2 = fp2[3];
    }
​
    // 在移动方向错开,则排除
    if (m2 < n1 || m1 > n2) {
        return true;
    }
​
    int[] c1 = getCentralPos(currTank);
    int[] c2 = getCentralPos(other);
​
    boolean exclude = false;
    // 排除相向而行没有撞上以及中心点背离的情况
    switch (currTank.getDirection()) {
        case UP:
            exclude = fp1[2] - 1 > fp2[3] || c1[1] - 1 <= c2[1];
            break;
        case RIGHT:
            exclude = fp1[1] + 1 < fp2[0] || c1[0] + 1 >= c2[0];
            break;
        case DOWN:
            exclude = fp1[3] + 1 < fp2[2] || c1[1] + 1 >= c2[1];
            break;
        case LEFT:
            exclude = fp1[0] - 1 > fp2[1] || c1[0] - 1 <= c2[0];
            break;
    }
    return exclude;
}
​
...
​
/**
 * 获取中心坐标点
 * @param tank
 * @return
 */
private int[] getCentralPos(Tank tank) {
    return new int[]{ tank.getX() + SIZE / 2, tank.getY() + SIZE / 2};
}
​
/**
 * 获取矩形四个角的位置
 * @param tank
 * @return
 */
private int[] getFourPoints(Tank tank) {
    int x = tank.getX();
    int y = tank.getY();
    return new int[]{x, x + SIZE, y, y + SIZE};
}

运行程序,效果如下:

hit-eachOther-fixed.gif

最后给出较为完整的设计类图, 大家加油!

image.png