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) {
// 生成命中特效
}
}