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

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 模拟限速

扩展阅读