强曰为道
与天地相似,故不违。知周乎万物,而道济天下,故不过。旁行而不流,乐天知命,故不忧.
文档目录

Pixi.js 游戏开发教程 / Pixi.js 碰撞检测

13. 碰撞检测

概述

碰撞检测是游戏开发的核心系统。Pixi.js 提供基础的交互区域(hitArea),但不包含完整的游戏碰撞系统。本章从 AABB 到 SAT,从空间分区到碰撞响应,全面讲解 2D 碰撞检测的实现。

AABB 包围盒检测

轴对齐包围盒(Axis-Aligned Bounding Box)是最简单高效的碰撞检测方式:

function aabbCollision(a, b) {
    return (
        a.x < b.x + b.width &&
        a.x + a.width > b.x &&
        a.y < b.y + b.height &&
        a.y + a.height > b.y
    );
}

// 使用示例
const enemy = { x: 100, y: 200, width: 48, height: 48 };
const bullet = { x: 120, y: 190, width: 8, height: 8 };

if (aabbCollision(enemy, bullet)) {
    console.log('命中!');
}

基于 Pixi.js 精灵的 AABB

function spriteAABB(a, b) {
    const ab = a.getBounds();
    const bb = b.getBounds();

    return (
        ab.x < bb.x + bb.width &&
        ab.x + ab.width > bb.x &&
        ab.y < bb.y + bb.height &&
        ab.y + ab.height > bb.y
    );
}

⚠️ 注意getBounds() 会触发世界矩阵计算,频繁调用有性能开销。大量碰撞检测时建议自行维护包围盒数据。

圆形碰撞检测

适合子弹、球体等圆形物体:

function circleCollision(ax, ay, ar, bx, by, br) {
    const dx = bx - ax;
    const dy = by - ay;
    const distance = Math.sqrt(dx * dx + dy * dy);
    const minDist = ar + br;

    return {
        collides: distance < minDist,
        overlap: minDist - distance,
        nx: dx / distance, // 碰撞法线 X
        ny: dy / distance, // 碰撞法线 Y
    };
}

// 使用示例
const result = circleCollision(
    player.x, player.y, player.radius,
    orb.x, orb.y, orb.radius
);

if (result.collides) {
    // 推开玩家
    player.x += result.nx * result.overlap;
    player.y += result.ny * result.overlap;
    collectOrb(orb);
}

游戏场景:拾取物品

class PickupSystem {
    constructor(player, pickupRadius = 24) {
        this.player = player;
        this.items = [];
        this.pickupRadius = pickupRadius;
    }

    addItem(item) {
        this.items.push(item);
    }

    update() {
        for (let i = this.items.length - 1; i >= 0; i--) {
            const item = this.items[i];
            const dx = this.player.x - item.x;
            const dy = this.player.y - item.y;
            const dist = Math.sqrt(dx * dx + dy * dy);

            if (dist < this.pickupRadius + item.radius) {
                this.onPickup(item);
                this.items.splice(i, 1);
            }
        }
    }

    onPickup(item) {
        // 触发拾取效果
        item.sprite.destroy();
        spawnPickupEffect(item.x, item.y);
        playSound('pickup');
    }
}

像素级碰撞

精确到非透明像素的碰撞检测:

function pixelCollision(spriteA, spriteB) {
    // 获取精灵边界
    const boundsA = spriteA.getBounds();
    const boundsB = spriteB.getBounds();

    // 计算重叠区域
    const overlap = {
        x: Math.max(boundsA.x, boundsB.x),
        y: Math.max(boundsA.y, boundsB.y),
        width: Math.min(boundsA.x + boundsA.width, boundsB.x + boundsB.width) -
               Math.max(boundsA.x, boundsB.x),
        height: Math.min(boundsA.y + boundsA.height, boundsB.y + boundsB.height) -
                Math.max(boundsA.y, boundsB.y),
    };

    if (overlap.width <= 0 || overlap.height <= 0) return false;

    // 创建离屏 Canvas 做像素检测
    const canvas = document.createElement('canvas');
    canvas.width = Math.ceil(overlap.width);
    canvas.height = Math.ceil(overlap.height);
    const ctx = canvas.getContext('2d');

    // 绘制两个精灵在重叠区域(需要提取纹理数据)
    // ⚠️ 这里简化处理,实际需要获取纹理像素数据
    // 建议使用预生成的碰撞遮罩位图

    return checkPixelOverlap(ctx, canvas.width, canvas.height);
}

⚠️ 注意:像素级碰撞非常昂贵。实际游戏中建议用 AABB 初筛 + 像素级精检的两阶段方案,或者使用预烘焙的简化碰撞形状。

SAT 分离轴定理

分离轴定理(Separating Axis Theorem)可以检测任意凸多边形的碰撞:

class SAT {
    // 获取多边形所有边的法线作为投影轴
    static getAxes(vertices) {
        const axes = [];
        for (let i = 0; i < vertices.length; i++) {
            const next = (i + 1) % vertices.length;
            const edge = {
                x: vertices[next].x - vertices[i].x,
                y: vertices[next].y - vertices[i].y,
            };
            // 法线(垂直于边)
            axes.push({ x: -edge.y, y: edge.x });
        }
        return axes;
    }

    // 将多边形投影到轴上
    static project(vertices, axis) {
        let min = Infinity;
        let max = -Infinity;
        for (const v of vertices) {
            const dot = v.x * axis.x + v.y * axis.y;
            min = Math.min(min, dot);
            max = Math.max(max, dot);
        }
        return { min, max };
    }

    // 检测两个凸多边形是否碰撞
    static test(polygonA, polygonB) {
        const axesA = SAT.getAxes(polygonA);
        const axesB = SAT.getAxes(polygonB);
        const axes = [...axesA, ...axesB];

        let minOverlap = Infinity;
        let smallestAxis = null;

        for (const axis of axes) {
            const projA = SAT.project(polygonA, axis);
            const projB = SAT.project(polygonB, axis);

            const overlap = Math.min(projA.max - projB.min, projB.max - projA.min);

            if (overlap <= 0) {
                return { collides: false }; // 分离轴找到,无碰撞
            }

            if (overlap < minOverlap) {
                minOverlap = overlap;
                smallestAxis = axis;
            }
        }

        return {
            collides: true,
            overlap: minOverlap,
            axis: smallestAxis, // 碰撞分离方向
        };
    }
}

// 使用示例:三角形与四边形碰撞
const triangle = [
    { x: 100, y: 100 },
    { x: 150, y: 50 },
    { x: 200, y: 100 },
];

const rect = [
    { x: 160, y: 80 },
    { x: 220, y: 80 },
    { x: 220, y: 140 },
    { x: 160, y: 140 },
];

const result = SAT.test(triangle, rect);
if (result.collides) {
    console.log('碰撞深度:', result.overlap);
    console.log('分离轴:', result.axis);
}

空间分区(四叉树 / Grid)

当场景中有大量对象时,逐一检测碰撞代价过高。空间分区可以大幅减少检测次数:

网格分区(Grid)

class SpatialGrid {
    constructor(cellSize, worldWidth, worldHeight) {
        this.cellSize = cellSize;
        this.cols = Math.ceil(worldWidth / cellSize);
        this.rows = Math.ceil(worldHeight / cellSize);
        this.cells = new Map();
    }

    clear() {
        this.cells.clear();
    }

    getKey(col, row) {
        return `${col},${row}`;
    }

    insert(entity) {
        const x = entity.x - entity.radius;
        const y = entity.y - entity.radius;
        const w = entity.radius * 2;
        const h = entity.radius * 2;

        const startCol = Math.floor(x / this.cellSize);
        const startRow = Math.floor(y / this.cellSize);
        const endCol = Math.floor((x + w) / this.cellSize);
        const endRow = Math.floor((y + h) / this.cellSize);

        for (let col = startCol; col <= endCol; col++) {
            for (let row = startRow; row <= endRow; row++) {
                const key = this.getKey(col, row);
                if (!this.cells.has(key)) this.cells.set(key, []);
                this.cells.get(key).push(entity);
            }
        }
    }

    getNearby(entity) {
        const col = Math.floor(entity.x / this.cellSize);
        const row = Math.floor(entity.y / this.cellSize);
        const nearby = new Set();

        for (let dc = -1; dc <= 1; dc++) {
            for (let dr = -1; dr <= 1; dr++) {
                const key = this.getKey(col + dc, row + dr);
                const cell = this.cells.get(key);
                if (cell) cell.forEach(e => nearby.add(e));
            }
        }

        nearby.delete(entity); // 排除自身
        return [...nearby];
    }
}

游戏场景:大量子弹碰撞检测

const grid = new SpatialGrid(100, 4000, 3000);

app.ticker.add(() => {
    grid.clear();

    // 插入所有实体
    enemies.forEach(e => grid.insert(e));
    bullets.forEach(b => grid.insert(b));

    // 只检测附近实体
    for (const bullet of bullets) {
        const nearby = grid.getNearby(bullet);
        for (const enemy of nearby) {
            if (circleCollision(bullet, enemy)) {
                onHit(enemy, bullet);
                break;
            }
        }
    }
});

碰撞响应

反弹

function bounce(entity, wall, normal) {
    // 反射速度向量
    const dot = entity.vx * normal.x + entity.vy * normal.y;
    entity.vx -= 2 * dot * normal.x;
    entity.vy -= 2 * dot * normal.y;

    // 应用弹性系数
    entity.vx *= 0.8; // 损失 20% 能量
    entity.vy *= 0.8;
}

穿透分离

function resolveCollision(a, b) {
    const dx = b.x - a.x;
    const dy = b.y - a.y;
    const dist = Math.sqrt(dx * dx + dy * dy);
    const overlap = (a.radius + b.radius) - dist;

    if (overlap > 0) {
        const nx = dx / dist;
        const ny = dy / dist;

        // 各推开一半
        a.x -= nx * overlap / 2;
        a.y -= ny * overlap / 2;
        b.x += nx * overlap / 2;
        b.y += ny * overlap / 2;
    }
}

物理碰撞 vs 游戏碰撞

类型特点适用场景
物理碰撞精确模拟、考虑质量/速度/旋转物理沙盒、模拟游戏
游戏碰撞简化规则、高效可控平台跳跃、射击、RPG

💡 提示:大多数 2D 游戏不需要真实物理碰撞。平台游戏的跳跃用固定曲线比物理模拟更好控制手感。

Pixi.js 内置 hitArea

import { Container, Graphics, Rectangle, Circle, Polygon } from 'pixi.js';

const button = new Container();

// 矩形交互区域
button.hitArea = new Rectangle(0, 0, 200, 50);

// 圆形交互区域
const orb = new Container();
orb.hitArea = new Circle(0, 0, 30);

// 多边形交互区域(三角形)
const triangle = new Container();
triangle.hitArea = new Polygon([0, -30, -25, 20, 25, 20]);

// 使用 Graphics 作为 hitArea(自动计算路径)
const complexShape = new Graphics();
complexShape.circle(0, 0, 40);
complexShape.fill(0xff0000);
complexShape.hitArea = complexShape; // Graphics 自身作为 hitArea

button.eventMode = 'static';
button.cursor = 'pointer';
button.on('pointerdown', () => {
    console.log('点击!');
});

碰撞检测优化策略

策略说明性能提升
粗筛 AABB先做 AABB 快速排除2-5x
空间分区只检测附近对象10-100x
层级碰撞不同层之间不检测视层数而定
静动分离静态对象之间不检测2-3x
频率降低不每帧检测(如 10ms 一次)2-6x

实战:子弹命中系统

interface Bullet {
    x: number;
    y: number;
    vx: number;
    vy: number;
    radius: number;
    damage: number;
    alive: boolean;
    sprite: Container;
}

interface Enemy {
    x: number;
    y: number;
    hp: number;
    radius: number;
    alive: boolean;
    sprite: Container;
}

class BulletHitSystem {
    private grid = new SpatialGrid(128, 4000, 3000);

    update(bullets: Bullet[], enemies: Enemy[]) {
        this.grid.clear();
        enemies.forEach(e => { if (e.alive) this.grid.insert(e); });

        for (const bullet of bullets) {
            if (!bullet.alive) continue;

            bullet.x += bullet.vx;
            bullet.y += bullet.vy;

            const nearby = this.grid.getNearby(bullet);
            for (const enemy of nearby) {
                if (!enemy.alive) continue;

                const result = circleCollision(
                    bullet.x, bullet.y, bullet.radius,
                    enemy.x, enemy.y, enemy.radius
                );

                if (result.collides) {
                    bullet.alive = false;
                    enemy.hp -= bullet.damage;

                    if (enemy.hp <= 0) {
                        enemy.alive = false;
                        this.onEnemyDeath(enemy);
                    }

                    this.spawnHitEffect(bullet.x, bullet.y);
                    break;
                }
            }
        }
    }

    private onEnemyDeath(enemy: Enemy) {
        spawnExplosion(enemy.x, enemy.y);
        dropLoot(enemy);
    }

    private spawnHitEffect(x: number, y: number) {
        // 生成命中特效
    }
}

扩展阅读