每日算法 — 使用java实现雷电:弹幕轨迹预测与四叉树碰撞检测

雷电(Raiden)是街机时代最经典的纵向卷轴射击游戏之一。满屏飞舞的弹幕、高速穿梭的战机、密集而又规律的敌机编队,构成了独特的”弹幕美学”。本文将用 Java 实现雷电游戏的核心算法,重点讲解弹幕轨迹的抛物线运动预测、四叉树空间分割加速碰撞检测,以及对象池优化技术。

一、游戏实体建模

雷电中的实体分为三类:玩家战机、敌机、子弹(弹幕)。所有实体共享位置、速度、半径等基本属性。

/**
 * 二维向量(复用工具类)
 */
public class Vec2 {
    public float x, y;
    public Vec2(float x, float y) { this.x = x; this.y = y; }
    public Vec2 add(Vec2 v) { return new Vec2(x + v.x, y + v.y); }
    public Vec2 sub(Vec2 v) { return new Vec2(x - v.x, y - v.y); }
    public Vec2 scale(float s) { return new Vec2(x * s, y * s); }
    public float length() { return (float)Math.sqrt(x*x + y*y); }
}

/**
 * 游戏实体基类
 */
public abstract class Entity {
    public Vec2 position;   // 位置
    public Vec2 velocity;   // 速度(像素/帧)
    public float radius;    // 碰撞半径
    public boolean alive;   // 是否存活

    public Entity(float x, float y, float r) {
        this.position = new Vec2(x, y);
        this.velocity = new Vec2(0, 0);
        this.radius = r;
        this.alive = true;
    }

    /** 每帧更新位置 */
    public void update() {
        position = position.add(velocity);
    }

    /** 圆形碰撞检测:两实体圆心距 < 半径和 */
    public boolean collidesWith(Entity other) {
        float dx = this.position.x - other.position.x;
        float dy = this.position.y - other.position.y;
        float distSq = dx*dx + dy*dy;
        float minDist = this.radius + other.radius;
        return distSq < minDist * minDist;
    }
}

/** 玩家战机 */
public class Player extends Entity {
    public Player(float x, float y) {
        super(x, y, 12);
    }
}

/** 敌机 */
public class Enemy extends Entity {
    public int hp;
    public Enemy(float x, float y, float r, int hp) {
        super(x, y, r);
        this.hp = hp;
    }
}

/** 子弹/弹幕 */
public class Bullet extends Entity {
    public boolean isPlayerBullet; // true=玩家子弹,false=敌机弹幕
    public int damage;

    public Bullet(float x, float y, float r, boolean playerBullet, int dmg) {
        super(x, y, r);
        this.isPlayerBullet = playerBullet;
        this.damage = dmg;
    }
}

二、弹幕轨迹预测:参数方程与抛物线运动

敌机发射的弹幕并非简单直线,而是遵循预设轨迹模式。常见的模式包括:直线弹、扇形扩散弹、自机狙(瞄准玩家)、抛物线弹、螺旋弹等。我们通过参数方程描述弹幕轨迹,在游戏循环中根据时间参数 t 计算实时位置。

/**
 * 弹幕轨迹接口:给定时间参数返回速度和位置偏移
 */
public interface BulletPattern {
    /** 计算第 t 帧的速度向量 */
    Vec2 getVelocity(int t, Vec2 spawnPos, Vec2 targetPos);
}

/** 直线弹:匀速直线运动 */
public class StraightPattern implements BulletPattern {
    private Vec2 dir;
    private float speed;

    public StraightPattern(float angleDeg, float speed) {
        float rad = (float)Math.toRadians(angleDeg);
        this.dir = new Vec2((float)Math.cos(rad), (float)Math.sin(rad));
        this.speed = speed;
    }

    @Override
    public Vec2 getVelocity(int t, Vec2 spawnPos, Vec2 targetPos) {
        return dir.scale(speed);
    }
}

/** 自机狙:始终瞄准玩家当前位置发射 */
public class AimPattern implements BulletPattern {
    private float speed;

    public AimPattern(float speed) { this.speed = speed; }

    @Override
    public Vec2 getVelocity(int t, Vec2 spawnPos, Vec2 targetPos) {
        Vec2 toTarget = targetPos.sub(spawnPos);
        float len = toTarget.length();
        if (len < 0.001f) return new Vec2(0, speed);
        return toTarget.scale(speed / len);
    }
}

/** 扇形扩散弹:以固定角度间隔向扇形区域发射 */
public class FanPattern implements BulletPattern {
    private float baseAngle;
    private float spreadAngle;
    private float speed;
    private int bulletIndex; // 该弹幕在扇形中的序号
    private int totalBullets;

    public FanPattern(float baseAngle, float spreadAngle, float speed, int index, int total) {
        this.baseAngle = baseAngle;
        this.spreadAngle = spreadAngle;
        this.speed = speed;
        this.bulletIndex = index;
        this.totalBullets = total;
    }

    @Override
    public Vec2 getVelocity(int t, Vec2 spawnPos, Vec2 targetPos) {
        float angle = baseAngle - spreadAngle/2 
            + spreadAngle * bulletIndex / Math.max(1, totalBullets - 1);
        float rad = (float)Math.toRadians(angle);
        return new Vec2((float)Math.cos(rad) * speed, (float)Math.sin(rad) * speed);
    }
}

/** 螺旋弹:速度方向随时间旋转 */
public class SpiralPattern implements BulletPattern {
    private float initAngle;
    private float rotSpeed; // 每帧旋转角度
    private float speed;

    public SpiralPattern(float initAngle, float rotSpeed, float speed) {
        this.initAngle = initAngle;
        this.rotSpeed = rotSpeed;
        this.speed = speed;
    }

    @Override
    public Vec2 getVelocity(int t, Vec2 spawnPos, Vec2 targetPos) {
        float angle = initAngle + rotSpeed * t;
        float rad = (float)Math.toRadians(angle);
        return new Vec2((float)Math.cos(rad) * speed, (float)Math.sin(rad) * speed);
    }
}

三、弹幕发射器

/**
 * 敌机弹幕发射器,按时间轴发射不同模式的弹幕
 */
public class BulletSpawner {
    private Enemy owner;
    private int frameCounter;

    public BulletSpawner(Enemy owner) {
        this.owner = owner;
        this.frameCounter = 0;
    }

    /**
     * 每帧调用,生成新的弹幕
     * @param playerPos 玩家当前位置(用于自机狙)
     * @param bulletPool 子弹对象池
     * @return 本帧新生成的弹幕列表
     */
    public java.util.List<Bullet> tick(Vec2 playerPos, BulletPool bulletPool) {
        java.util.List<Bullet> spawned = new java.util.ArrayList<>();
        frameCounter++;

        // 模式1:每60帧发射3发自机狙
        if (frameCounter % 60 == 0) {
            for (int i = 0; i < 3; i++) {
                BulletPattern pattern = new AimPattern(3.0f + i * 0.5f);
                Vec2 vel = pattern.getVelocity(0, owner.position, playerPos);
                Bullet b = bulletPool.obtain();
                b.position = new Vec2(owner.position.x, owner.position.y + owner.radius);
                b.velocity = vel;
                b.isPlayerBullet = false;
                b.damage = 1;
                b.alive = true;
                spawned.add(b);
            }
        }

        // 模式2:每90帧发射5发扇形弹
        if (frameCounter % 90 == 0) {
            for (int i = 0; i < 5; i++) {
                BulletPattern pattern = new FanPattern(90, 60, 2.5f, i, 5);
                Vec2 vel = pattern.getVelocity(0, owner.position, playerPos);
                Bullet b = bulletPool.obtain();
                b.position = new Vec2(owner.position.x, owner.position.y + owner.radius);
                b.velocity = vel;
                b.isPlayerBullet = false;
                b.damage = 1;
                b.alive = true;
                spawned.add(b);
            }
        }

        // 模式3:每120帧发射螺旋弹
        if (frameCounter % 120 == 0) {
            for (int i = 0; i < 8; i++) {
                BulletPattern pattern = new SpiralPattern(i * 45, 3, 2.0f);
                Vec2 vel = pattern.getVelocity(0, owner.position, playerPos);
                Bullet b = bulletPool.obtain();
                b.position = new Vec2(owner.position.x, owner.position.y);
                b.velocity = vel;
                b.isPlayerBullet = false;
                b.damage = 1;
                b.alive = true;
                spawned.add(b);
            }
        }

        return spawned;
    }
}

四、四叉树空间分割碰撞检测

满屏数百发弹幕时,$O(N^2)$ 的暴力碰撞检测会严重拖慢帧率。四叉树(Quadtree) 将二维空间递归划分为四个象限,每个实体只与同一象限内的其他实体进行碰撞检测,将复杂度降至 $O(N \log N)$。

/**
 * 轴对齐包围盒
 */
public class AABB {
    public float minX, minY, maxX, maxY;

    public AABB(float minX, float minY, float maxX, float maxY) {
        this.minX = minX; this.minY = minY;
        this.maxX = maxX; this.maxY = maxY;
    }

    public boolean contains(float x, float y) {
        return x >= minX && x <= maxX && y >= minY && y <= maxY;
    }

    public boolean intersects(AABB other) {
        return this.minX <= other.maxX && this.maxX >= other.minX
            && this.minY <= other.maxY && this.maxY >= other.minY;
    }
}

/**
 * 四叉树节点
 */
public class Quadtree {
    private static final int MAX_OBJECTS = 10; // 单节点最大实体数
    private static final int MAX_LEVELS = 5;   // 最大深度

    private int level;
    private AABB bounds;
    private java.util.List<Entity> objects;
    private Quadtree[] nodes; // 四个子节点:NW, NE, SW, SE

    public Quadtree(int level, AABB bounds) {
        this.level = level;
        this.bounds = bounds;
        this.objects = new java.util.ArrayList<>();
        this.nodes = null;
    }

    /** 清空四叉树(每帧重建) */
    public void clear() {
        objects.clear();
        if (nodes != null) {
            for (Quadtree node : nodes) node.clear();
            nodes = null;
        }
    }

    /** 将当前节点分裂为四个子节点 */
    private void split() {
        float subW = (bounds.maxX - bounds.minX) / 2;
        float subH = (bounds.maxY - bounds.minY) / 2;
        float x = bounds.minX;
        float y = bounds.minY;

        nodes = new Quadtree[4];
        nodes[0] = new Quadtree(level + 1, new AABB(x + subW, y, x + subW*2, y + subH)); // NE
        nodes[1] = new Quadtree(level + 1, new AABB(x, y, x + subW, y + subH));         // NW
        nodes[2] = new Quadtree(level + 1, new AABB(x, y + subH, x + subW, y + subH*2)); // SW
        nodes[3] = new Quadtree(level + 1, new AABB(x + subW, y + subH, x + subW*2, y + subH*2)); // SE
    }

    /** 判断实体属于哪个子节点索引(-1表示跨越多个子节点) */
    private int getIndex(Entity e) {
        float midX = (bounds.minX + bounds.maxX) / 2;
        float midY = (bounds.minY + bounds.maxY) / 2;

        boolean top = e.position.y - e.radius < midY && e.position.y + e.radius < midY;
        boolean bottom = e.position.y - e.radius > midY;
        boolean left = e.position.x - e.radius < midX && e.position.x + e.radius < midX;
        boolean right = e.position.x - e.radius > midX;

        if (top && right) return 0;    // NE
        if (top && left) return 1;     // NW
        if (bottom && left) return 2;  // SW
        if (bottom && right) return 3; // SE
        return -1; // 跨越边界,留在当前节点
    }

    /** 插入实体 */
    public void insert(Entity e) {
        if (nodes != null) {
            int index = getIndex(e);
            if (index != -1) {
                nodes[index].insert(e);
                return;
            }
        }
        objects.add(e);

        if (objects.size() > MAX_OBJECTS && level < MAX_LEVELS) {
            if (nodes == null) split();

            java.util.Iterator<Entity> it = objects.iterator();
            while (it.hasNext()) {
                Entity obj = it.next();
                int index = getIndex(obj);
                if (index != -1) {
                    nodes[index].insert(obj);
                    it.remove();
                }
            }
        }
    }

    /**
     * 检索可能与给定实体碰撞的所有实体
     */
    public void retrieve(java.util.List<Entity> returnList, Entity e) {
        int index = getIndex(e);
        if (index != -1 && nodes != null) {
            nodes[index].retrieve(returnList, e);
        }
        returnList.addAll(objects);
    }
}

五、对象池:避免 GC 压力

射击游戏中子弹的创建与销毁极为频繁,频繁的 new 和 GC 会导致卡顿。对象池(Object Pool) 预先分配一批对象,重复利用,彻底消除运行时分配开销。

/**
 * 子弹对象池
 */
public class BulletPool {
    private java.util.Queue<Bullet> pool;
    private int capacity;

    public BulletPool(int capacity) {
        this.capacity = capacity;
        this.pool = new java.util.ArrayDeque<>();
        // 预分配
        for (int i = 0; i < capacity; i++) {
            pool.add(new Bullet(0, 0, 4, false, 1));
        }
    }

    /** 获取一个可用子弹 */
    public Bullet obtain() {
        if (pool.isEmpty()) {
            return new Bullet(0, 0, 4, false, 1); // 池耗尽时临时创建
        }
        Bullet b = pool.poll();
        b.alive = true;
        return b;
    }

    /** 回收子弹 */
    public void free(Bullet b) {
        b.alive = false;
        pool.add(b);
    }
}

六、游戏主循环与碰撞检测整合

public class RaidenEngine {
    private Player player;
    private java.util.List<Enemy> enemies;
    private java.util.List<Bullet> bullets;
    private BulletPool bulletPool;
    private Quadtree quadtree;
    private int score;

    public RaidenEngine() {
        player = new Player(240, 400);
        enemies = new java.util.ArrayList<>();
        bullets = new java.util.ArrayList<>();
        bulletPool = new BulletPool(500);
        quadtree = new Quadtree(0, new AABB(0, 0, 480, 640));
        score = 0;
    }

    /** 每帧主循环 */
    public void tick() {
        // 1. 更新玩家
        player.update();

        // 2. 更新敌机并生成弹幕
        for (Enemy e : enemies) {
            e.update();
            if (e.alive) {
                BulletSpawner spawner = new BulletSpawner(e);
                bullets.addAll(spawner.tick(player.position, bulletPool));
            }
        }

        // 3. 更新子弹位置
        for (Bullet b : bullets) {
            if (b.alive) b.update();
        }

        // 4. 构建四叉树
        quadtree.clear();
        quadtree.insert(player);
        for (Enemy e : enemies) if (e.alive) quadtree.insert(e);
        for (Bullet b : bullets) if (b.alive) quadtree.insert(b);

        // 5. 碰撞检测
        checkCollisions();

        // 6. 清理死亡实体
        cleanup();
    }

    private void checkCollisions() {
        java.util.List<Entity> candidates = new java.util.ArrayList<>();

        // 玩家 vs 敌机弹幕
        candidates.clear();
        quadtree.retrieve(candidates, player);
        for (Entity e : candidates) {
            if (e instanceof Bullet && !((Bullet)e).isPlayerBullet && e.alive) {
                if (player.collidesWith(e)) {
                    e.alive = false;
                    // 玩家受伤逻辑
                }
            }
        }

        // 玩家子弹 vs 敌机
        for (Bullet b : bullets) {
            if (!b.alive || b.isPlayerBullet) continue;
            // 这里简化处理:实际应遍历所有敌机
        }
    }

    private void cleanup() {
        java.util.Iterator<Bullet> it = bullets.iterator();
        while (it.hasNext()) {
            Bullet b = it.next();
            if (!b.alive || b.position.y < -50 || b.position.y > 700) {
                it.remove();
                bulletPool.free(b);
            }
        }
        enemies.removeIf(e -> !e.alive);
    }
}

七、复杂度对比分析

检测方式 时间复杂度 100实体 500实体 说明
暴力 O(N²) O(N²) 10,000 250,000 每对都检测
四叉树 O(N log N) ~460 ~3,100 空间分割过滤
对象池 O(1) 获取/回收 – – 消除内存分配

在 500 实体的密集弹幕场景中,四叉树将碰撞检测开销降低约 80 倍,配合对象池可稳定维持 60 FPS。

八、扩展方向

  1. 八叉树/网格哈希:3D 弹幕游戏(如东方 Project 的立体弹幕)可升级为八叉树,或改用均匀网格(Uniform Grid)进一步简化。
  2. 预测射击 AI:敌机根据玩家速度向量进行线性外插,提前瞄准玩家未来位置。
  3. 弹幕编织算法:用参数方程组描述复杂的弹幕花纹,如玫瑰线、利萨茹图形等。

雷电游戏算法的核心在于用空间分割管理海量实体、用参数方程描述运动轨迹、用对象池消除性能抖动。这套技术栈不仅适用于射击游戏,也是粒子系统、大规模群体模拟(Boids)的通用解决方案。