Pixi.js 游戏开发教程 / Pixi.js 调试与测试
调试与测试
高效调试 Pixi.js 应用,用自动化测试保障代码质量。
一、Pixi DevTools 浏览器扩展
1.1 安装与使用
Pixi DevTools 是 Pixi.js 官方提供的 Chrome DevTools 扩展,可以检查场景树、查看纹理、测量性能。
1. Chrome Web Store 搜索 "Pixi DevTools"
2. 安装后打开 DevTools → "Pixi" 标签页
3. 运行你的 Pixi.js 应用即可自动连接
1.2 核心功能
| 功能 | 说明 |
|---|---|
| 场景树 | 查看 Container / Sprite 层级结构 |
| 属性面板 | 实时修改 x, y, alpha, visible 等属性 |
| 纹理查看 | 查看 Sprite 使用的纹理和纹理图集 |
| 性能统计 | Draw Call 数量、顶点数、纹理数 |
| 搜索 | 按名称搜索 DisplayObject |
1.3 命名对象便于调试
import { Sprite, Text, Container } from 'pixi.js';
// 给对象命名,在 DevTools 中更容易找到
const player = new Sprite(texture);
player.label = 'player';
const healthBar = new Container();
healthBar.label = 'healthBar';
const scoreText = new Text({ text: '0', style: { fill: 0xffffff } });
scoreText.label = 'scoreText';
💡 提示:给所有关键游戏对象设置 label,调试时可以快速定位。
二、控制台调试技巧
2.1 暴露游戏实例到全局
// 开发模式下暴露到 window,方便控制台调试
if (import.meta.env.DEV) {
(window as any).__GAME__ = {
app,
player,
stage: app.stage,
enemies,
bullets,
};
}
// 控制台中使用:
// __GAME__.player.x = 100
// __GAME__.enemies.length
// __GAME__.app.stage.children
2.2 条件断点与日志
// 在 update 循环中添加调试断点
function update(delta: number) {
updatePlayer(delta);
updateEnemies(delta);
// 条件断点:当玩家 HP 异常时触发
if (player.hp < 0) {
debugger; // 浏览器会在此处暂停
}
// 性能标记
console.time('collision');
checkCollisions();
console.timeEnd('collision');
}
2.3 渲染边界查看
// 查看 Sprite 的实际渲染边界
function debugBounds(sprite: Sprite) {
const bounds = sprite.getBounds();
console.log('Bounds:', {
x: bounds.x.toFixed(1),
y: bounds.y.toFixed(1),
width: bounds.width.toFixed(1),
height: bounds.height.toFixed(1),
});
// 本地边界
const local = sprite.getLocalBounds();
console.log('Local Bounds:', {
x: local.x.toFixed(1),
y: local.y.toFixed(1),
width: local.width.toFixed(1),
height: local.height.toFixed(1),
});
}
三、可视化调试
3.1 碰撞体可视化
import { Graphics, Container, Sprite } from 'pixi.js';
class DebugOverlay extends Container {
private graphics: Graphics;
private enabled = false;
constructor() {
super();
this.graphics = new Graphics();
this.addChild(this.graphics);
}
toggle() {
this.enabled = !this.enabled;
this.visible = this.enabled;
}
drawColliders(colliders: { x: number; y: number; radius: number }[]) {
this.graphics.clear();
for (const c of colliders) {
// 碰撞圆
this.graphics.circle(c.x, c.y, c.radius);
this.graphics.stroke({ color: 0x00ff00, width: 1, alpha: 0.8 });
}
}
drawRects(rects: { x: number; y: number; w: number; h: number }[]) {
for (const r of rects) {
this.graphics.rect(r.x, r.y, r.w, r.h);
this.graphics.stroke({ color: 0xff0000, width: 1, alpha: 0.8 });
}
}
drawAnchor(sprite: Sprite) {
// 锚点十字线
this.graphics.moveTo(sprite.x - 8, sprite.y);
this.graphics.lineTo(sprite.x + 8, sprite.y);
this.graphics.moveTo(sprite.x, sprite.y - 8);
this.graphics.lineTo(sprite.x, sprite.y + 8);
this.graphics.stroke({ color: 0xffff00, width: 1 });
}
}
// 使用
const debug = new DebugOverlay();
app.stage.addChild(debug);
// 快捷键切换
window.addEventListener('keydown', (e) => {
if (e.key === 'F3') debug.toggle();
});
app.ticker.add(() => {
if (debug.visible) {
debug.drawColliders(enemies.map(e => ({
x: e.x, y: e.y, radius: e.collisionRadius,
})));
debug.drawAnchor(player);
}
});
3.2 物理世界可视化
// 显示速度向量
function drawVelocityVector(g: Graphics, x: number, y: number, vx: number, vy: number) {
const scale = 5;
g.moveTo(x, y);
g.lineTo(x + vx * scale, y + vy * scale);
g.stroke({ color: 0x00ffff, width: 2 });
// 箭头
const angle = Math.atan2(vy, vx);
const arrowLen = 8;
g.moveTo(x + vx * scale, y + vy * scale);
g.lineTo(
x + vx * scale - arrowLen * Math.cos(angle - 0.5),
y + vy * scale - arrowLen * Math.sin(angle - 0.5),
);
g.moveTo(x + vx * scale, y + vy * scale);
g.lineTo(
x + vx * scale - arrowLen * Math.cos(angle + 0.5),
y + vy * scale - arrowLen * Math.sin(angle + 0.5),
);
g.stroke({ color: 0x00ffff, width: 2 });
}
3.3 网格与坐标系
function drawGrid(g: Graphics, width: number, height: number, cellSize: number) {
g.clear();
// 垂直线
for (let x = 0; x <= width; x += cellSize) {
g.moveTo(x, 0);
g.lineTo(x, height);
}
// 水平线
for (let y = 0; y <= height; y += cellSize) {
g.moveTo(0, y);
g.lineTo(width, y);
}
g.stroke({ color: 0x444444, width: 0.5, alpha: 0.5 });
// 坐标标签(每隔 N 格)
for (let x = 0; x <= width; x += cellSize * 5) {
for (let y = 0; y <= height; y += cellSize * 5) {
const label = new Text({
text: `${x},${y}`,
style: { fill: 0x666666, fontSize: 10 },
});
label.x = x + 2;
label.y = y + 2;
g.addChild(label);
}
}
}
四、单元测试
4.1 测试框架配置(Vitest)
npm install -D vitest @vitest/ui jsdom
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./tests/setup.ts'],
},
});
4.2 测试环境设置
// tests/setup.ts
// Mock canvas for Node.js environment
import { vi } from 'vitest';
// Mock HTMLCanvasElement
HTMLCanvasElement.prototype.getContext = vi.fn(() => ({
fillRect: vi.fn(),
clearRect: vi.fn(),
getImageData: vi.fn(() => ({ data: new Uint8ClampedArray(4) })),
putImageData: vi.fn(),
createImageData: vi.fn(() => ({ data: new Uint8ClampedArray(4) })),
setTransform: vi.fn(),
drawImage: vi.fn(),
save: vi.fn(),
restore: vi.fn(),
beginPath: vi.fn(),
moveTo: vi.fn(),
lineTo: vi.fn(),
closePath: vi.fn(),
stroke: vi.fn(),
fill: vi.fn(),
measureText: vi.fn(() => ({ width: 0 })),
transform: vi.fn(),
translate: vi.fn(),
scale: vi.fn(),
rotate: vi.fn(),
arc: vi.fn(),
rect: vi.fn(),
clip: vi.fn(),
})) as any;
4.3 游戏逻辑单元测试
// tests/game-logic.test.ts
import { describe, it, expect } from 'vitest';
// 纯函数,不依赖 Pixi.js
class CollisionDetector {
static circleCollision(
x1: number, y1: number, r1: number,
x2: number, y2: number, r2: number,
): boolean {
const dx = x2 - x1;
const dy = y2 - y1;
const dist = Math.sqrt(dx * dx + dy * dy);
return dist < r1 + r2;
}
static rectCollision(
ax: number, ay: number, aw: number, ah: number,
bx: number, by: number, bw: number, bh: number,
): boolean {
return ax < bx + bw && ax + aw > bx &&
ay < by + bh && ay + ah > by;
}
static pointInCircle(
px: number, py: number,
cx: number, cy: number, r: number,
): boolean {
const dx = px - cx;
const dy = py - cy;
return dx * dx + dy * dy < r * r;
}
}
describe('CollisionDetector', () => {
describe('circleCollision', () => {
it('should detect overlapping circles', () => {
expect(CollisionDetector.circleCollision(0, 0, 10, 15, 0, 10)).toBe(true);
});
it('should not detect separated circles', () => {
expect(CollisionDetector.circleCollision(0, 0, 5, 20, 0, 5)).toBe(false);
});
it('should handle touching circles', () => {
expect(CollisionDetector.circleCollision(0, 0, 5, 10, 0, 5)).toBe(false);
});
});
describe('rectCollision', () => {
it('should detect overlapping rectangles', () => {
expect(CollisionDetector.rectCollision(0, 0, 10, 10, 5, 5, 10, 10)).toBe(true);
});
it('should not detect separated rectangles', () => {
expect(CollisionDetector.rectCollision(0, 0, 10, 10, 20, 20, 10, 10)).toBe(false);
});
});
});
// 测试对象池
describe('ObjectPool', () => {
class TestPool<T> {
private pool: T[] = [];
constructor(private factory: () => T) {}
acquire(): T { return this.pool.pop() ?? this.factory(); }
release(obj: T) { this.pool.push(obj); }
get size() { return this.pool.length; }
}
it('should create new objects when pool is empty', () => {
const pool = new TestPool(() => ({ id: Math.random() }));
const obj = pool.acquire();
expect(obj).toBeDefined();
});
it('should reuse released objects', () => {
const pool = new TestPool(() => ({ id: Math.random() }));
const obj = pool.acquire();
const id = obj.id;
pool.release(obj);
const reused = pool.acquire();
expect(reused.id).toBe(id);
});
});
五、端到端测试(Playwright)
5.1 Playwright 配置
npm install -D @playwright/test
npx playwright install
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
use: {
baseURL: 'http://localhost:5173',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
webServer: {
command: 'npm run dev',
port: 5173,
reuseExistingServer: !process.env.CI,
},
});
5.2 E2E 测试示例
// e2e/game.spec.ts
import { test, expect } from '@playwright/test';
test('游戏加载并显示标题', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveTitle(/Pixi Game/);
// 等待 canvas 渲染
const canvas = page.locator('canvas');
await expect(canvas).toBeVisible({ timeout: 5000 });
});
test('点击开始按钮进入游戏', async ({ page }) => {
await page.goto('/');
const canvas = page.locator('canvas');
// 模拟点击 canvas 中心(假设按钮在中心)
const box = await canvas.boundingBox();
if (box) {
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
}
// 验证游戏状态(通过页面标题或特定 DOM 元素)
await page.waitForTimeout(1000);
// 可以截图对比
await expect(page).toHaveScreenshot('game-started.png', {
maxDiffPixelRatio: 0.05, // 允许 5% 像素差异
});
});
test('玩家可以通过键盘移动', async ({ page }) => {
await page.goto('/');
// 先点击 canvas 获取焦点
const canvas = page.locator('canvas');
await canvas.click();
// 按下方向键
await page.keyboard.down('ArrowRight');
await page.waitForTimeout(500);
await page.keyboard.up('ArrowRight');
// 截图验证位置变化
await expect(page).toHaveScreenshot('player-moved.png', {
maxDiffPixelRatio: 0.05,
});
});
六、测试策略
6.1 测试金字塔
┌─────────┐
│ E2E 测试 │ 少量、覆盖关键流程
├─────────┤
│ 集成测试 │ 中等数量、测试模块协作
├───────────┤
│ 单元测试 │ 大量、测试纯逻辑函数
└─────────────┘
6.2 测试分类
| 类型 | 测试内容 | 工具 |
|---|---|---|
| 纯逻辑 | 碰撞检测、伤害计算、路径寻址 | Vitest |
| 渲染测试 | 截图对比 | Playwright + toHaveScreenshot |
| 交互测试 | 触摸、键盘输入 | Playwright |
| 性能测试 | FPS、内存 | Chrome Performance |
| 可访问性 | 键盘导航、屏幕阅读器 | Playwright + axe |
6.3 可测试设计
// ❌ 难以测试:逻辑耦合在渲染中
class Player extends Sprite {
update(delta: number) {
this.x += this.speed * delta;
if (this.hp <= 0) {
this.visible = false; // 渲染和逻辑混合
}
}
}
// ✅ 易于测试:逻辑与渲染分离
class PlayerModel {
x = 0;
y = 0;
hp = 100;
speed = 5;
update(delta: number) {
this.x += this.speed * delta;
}
takeDamage(amount: number) {
this.hp = Math.max(0, this.hp - amount);
}
get isDead(): boolean {
return this.hp <= 0;
}
}
class PlayerView {
constructor(private model: PlayerModel, public sprite: Sprite) {}
sync() {
this.sprite.x = this.model.x;
this.sprite.y = this.model.y;
this.sprite.visible = !this.model.isDead;
}
}
// 测试模型(不需要 Pixi.js)
describe('PlayerModel', () => {
it('should take damage correctly', () => {
const player = new PlayerModel();
player.takeDamage(30);
expect(player.hp).toBe(70);
});
it('should not go below 0 hp', () => {
const player = new PlayerModel();
player.takeDamage(200);
expect(player.hp).toBe(0);
expect(player.isDead).toBe(true);
});
});
七、CI 集成
7.1 GitHub Actions 配置
# .github/workflows/test.yml
name: Test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Install dependencies
run: npm ci
- name: Unit tests
run: npm run test:unit -- --coverage
- name: Build
run: npm run build
- name: E2E tests
run: npx playwright install --with-deps && npm run test:e2e
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results
path: test-results/
八、错误监控(Sentry)
8.1 集成 Sentry
npm install @sentry/browser
import * as Sentry from '@sentry/browser';
Sentry.init({
dsn: 'https://[email protected]/project-id',
environment: import.meta.env.MODE,
tracesSampleRate: 0.1,
});
// 捕获游戏异常
function gameLoop(delta: number) {
try {
update(delta);
render();
} catch (error) {
Sentry.captureException(error);
// 显示错误 UI 或暂停游戏
}
}
// 手动上报上下文
function onLevelLoad(levelId: string) {
Sentry.setContext('level', { levelId });
Sentry.addBreadcrumb({
category: 'game',
message: `Loading level ${levelId}`,
level: 'info',
});
}
九、调试模式
9.1 完整调试面板
import { Container, Text, Graphics } from 'pixi.js';
class DebugPanel extends Container {
private fpsText: Text;
private objectCountText: Text;
private drawCallText: Text;
private memoryText: Text;
private visible = false;
constructor() {
super();
const bg = new Graphics();
bg.rect(0, 0, 200, 120).fill({ color: 0x000000, alpha: 0.7 });
this.addChild(bg);
this.fpsText = this.createLine(10, 10);
this.objectCountText = this.createLine(10, 30);
this.drawCallText = this.createLine(10, 50);
this.memoryText = this.createLine(10, 70);
this.visible = false;
// F1 切换
window.addEventListener('keydown', (e) => {
if (e.key === 'F1') {
this.visible = !this.visible;
e.preventDefault();
}
});
}
private createLine(x: number, y: number): Text {
const text = new Text({ text: '', style: { fill: 0x00ff00, fontSize: 12, fontFamily: 'monospace' } });
text.x = x;
text.y = y;
this.addChild(text);
return text;
}
update(fps: number, objectCount: number) {
if (!this.visible) return;
this.fpsText.text = `FPS: ${fps.toFixed(1)}`;
this.objectCountText.text = `Objects: ${objectCount}`;
// 内存信息(仅 Chrome)
if ((performance as any).memory) {
const mem = (performance as any).memory;
this.memoryText.text = `Memory: ${(mem.usedJSHeapSize / 1024 / 1024).toFixed(1)} MB`;
}
}
}
// 使用
const debugPanel = new DebugPanel();
app.stage.addChild(debugPanel);
app.ticker.add(() => {
const objectCount = app.stage.children.length;
debugPanel.update(app.ticker.FPS, objectCount);
});
游戏开发场景
| 场景 | 调试方案 |
|---|---|
| 碰撞不准 | 可视化碰撞体 + 减速测试 |
| 帧率下降 | Chrome Performance 录制 + 调试面板 |
| 内存泄漏 | Chrome Memory 快照对比 |
| 渲染异常 | Pixi DevTools 检查场景树 |
| 网络延迟 | Chrome Network 模拟限速 |