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

Pixi.js 游戏开发教程 / Pixi.js 视口与相机系统

12. 视口与相机系统

概述

Pixi.js 没有内置的 Camera 对象,但可以通过移动世界容器来模拟相机。本章讲解如何实现平滑跟随、世界边界、缩放、震动、视差滚动等相机功能。

相机核心概念

在 2D 游戏中,相机本质上是「移动世界来模拟视角移动」:

概念实现方式
移动设置世界容器的 xy 的负值
跟随每帧更新世界位置使目标居中
缩放设置世界容器的 scale.xscale.y
旋转设置世界容器的 rotation
import { Application, Container } from 'pixi.js';

const app = new Application();
await app.init({ width: 800, height: 600 });

// 游戏世界容器
const world = new Container();
app.stage.addChild(world);

// 简单的相机:使玩家居中
function centerOn(target) {
    world.x = app.screen.width / 2 - target.x;
    world.y = app.screen.height / 2 - target.y;
}

Camera 类实现

interface CameraOptions {
    screenWidth: number;
    screenHeight: number;
    worldWidth: number;
    worldHeight: number;
}

class Camera {
    public x = 0;
    public y = 0;
    public zoom = 1;
    public rotation = 0;
    public targetX = 0;
    public targetY = 0;

    private world: Container;
    private screenWidth: number;
    private screenHeight: number;
    private worldWidth: number;
    private worldHeight: number;
    private lerpSpeed = 0.1;

    constructor(world: Container, options: CameraOptions) {
        this.world = world;
        this.screenWidth = options.screenWidth;
        this.screenHeight = options.screenHeight;
        this.worldWidth = options.worldWidth;
        this.worldHeight = options.worldHeight;
    }

    follow(targetX: number, targetY: number) {
        this.targetX = targetX;
        this.targetY = targetY;
    }

    update() {
        // 平滑插值
        this.x += (this.targetX - this.x) * this.lerpSpeed;
        this.y += (this.targetY - this.y) * this.lerpSpeed;

        // 应用边界限制
        this.clampToBounds();

        // 应用到世界容器
        this.world.x = -this.x * this.zoom + this.screenWidth / 2;
        this.world.y = -this.y * this.zoom + this.screenHeight / 2;
        this.world.scale.set(this.zoom);
        this.world.rotation = this.rotation;
    }

    private clampToBounds() {
        const halfW = (this.screenWidth / 2) / this.zoom;
        const halfH = (this.screenHeight / 2) / this.zoom;

        this.x = Math.max(halfW, Math.min(this.worldWidth - halfW, this.x));
        this.y = Math.max(halfH, Math.min(this.worldHeight - halfH, this.y));
    }
}

相机跟随与平滑插值 Lerp

线性插值(Lerp)使相机运动更加平滑自然:

function lerp(a, b, t) {
    return a + (b - a) * t;
}

// 每帧更新
app.ticker.add((ticker) => {
    const dt = ticker.deltaTime;

    // 目标位置是玩家位置
    const targetX = player.x;
    const targetY = player.y;

    // 平滑跟随(t 越小越平滑,0.05~0.15 适合大多数游戏)
    camera.x = lerp(camera.x, targetX, 0.08 * dt);
    camera.y = lerp(camera.y, targetY, 0.08 * dt);

    // 应用到世界容器
    world.x = -camera.x + app.screen.width / 2;
    world.y = -camera.y + app.screen.height / 2;
});

高级:带死区的相机

相机只在玩家超出中心区域时才移动(适合平台游戏):

class DeadZoneCamera {
    constructor(world, screenWidth, screenHeight) {
        this.world = world;
        this.x = 0;
        this.y = 0;
        // 死区:屏幕中心区域,相机在此区域内不跟随
        this.deadZoneX = 80;
        this.deadZoneY = 60;
        this.screenW = screenWidth;
        this.screenH = screenHeight;
    }

    follow(target) {
        // 计算目标在屏幕上的相对位置
        const relX = target.x - this.x;
        const relY = target.y - this.y;

        const centerX = this.screenW / 2;
        const centerY = this.screenH / 2;

        // 只在超出死区时移动相机
        if (relX < centerX - this.deadZoneX) {
            this.x = target.x - (centerX - this.deadZoneX);
        } else if (relX > centerX + this.deadZoneX) {
            this.x = target.x - (centerX + this.deadZoneX);
        }

        if (relY < centerY - this.deadZoneY) {
            this.y = target.y - (centerY - this.deadZoneY);
        } else if (relY > centerY + this.deadZoneY) {
            this.y = target.y - (centerY + this.deadZoneY);
        }

        // 应用到世界
        this.world.x = -this.x + centerX;
        this.world.y = -this.y + centerY;
    }
}

世界边界限制

防止相机显示空白区域:

function clampCamera(cameraX, cameraY, screenW, screenH, worldW, worldH, zoom) {
    const halfW = (screenW / 2) / zoom;
    const halfH = (screenH / 2) / zoom;

    // 世界比屏幕小时,居中显示
    let x = cameraX;
    let y = cameraY;

    if (worldW / zoom <= screenW) {
        x = worldW / 2;
    } else {
        x = Math.max(halfW, Math.min(worldW - halfW, x));
    }

    if (worldH / zoom <= screenH) {
        y = worldH / 2;
    } else {
        y = Math.max(halfH, Math.min(worldH - halfH, y));
    }

    return { x, y };
}

缩放 Zoom

const camera = { x: 0, y: 0, zoom: 1 };

// 鼠标滚轮缩放
app.canvas.addEventListener('wheel', (e) => {
    e.preventDefault();
    const delta = -e.deltaY * 0.001;
    const newZoom = Math.max(0.5, Math.min(3, camera.zoom + delta));

    // 以鼠标位置为中心缩放
    const mouseWorldBefore = screenToWorld(e.clientX, e.clientY);
    camera.zoom = newZoom;
    const mouseWorldAfter = screenToWorld(e.clientX, e.clientY);

    // 调整相机位置使鼠标下的世界点保持不变
    camera.x += mouseWorldBefore.x - mouseWorldAfter.x;
    camera.y += mouseWorldBefore.y - mouseWorldAfter.y;
}, { passive: false });

function screenToWorld(screenX, screenY) {
    return {
        x: (screenX - world.x) / camera.zoom,
        y: (screenY - world.y) / camera.zoom,
    };
}

相机震动效果

class CameraShake {
    constructor() {
        this.intensity = 0;
        this.duration = 0;
        this.elapsed = 0;
        this.offsetX = 0;
        this.offsetY = 0;
    }

    shake(intensity, duration) {
        this.intensity = intensity;
        this.duration = duration;
        this.elapsed = 0;
    }

    update(dt) {
        if (this.elapsed < this.duration) {
            this.elapsed += dt;
            const progress = this.elapsed / this.duration;
            // 震动随时间衰减
            const currentIntensity = this.intensity * (1 - progress);
            this.offsetX = (Math.random() - 0.5) * 2 * currentIntensity;
            this.offsetY = (Math.random() - 0.5) * 2 * currentIntensity;
        } else {
            this.offsetX = 0;
            this.offsetY = 0;
        }
    }
}

// 使用示例:爆炸时震动
const shake = new CameraShake();

function onExplosion() {
    shake.shake(15, 0.5); // 强度 15px,持续 0.5 秒
}

app.ticker.add(() => {
    shake.update(1);
    world.x = -camera.x + app.screen.width / 2 + shake.offsetX;
    world.y = -camera.y + app.screen.height / 2 + shake.offsetY;
});

多层视差滚动(Parallax)

不同层以不同速度滚动,营造深度感:

import { Container, TilingSprite } from 'pixi.js';

// 创建多层
const layers = {
    sky:    { speed: 0.1, container: new Container() },
    mountains: { speed: 0.4, container: new Container() },
    trees:  { speed: 0.7, container: new Container() },
    ground: { speed: 1.0, container: new Container() },
};

const world = new Container();
Object.values(layers).forEach(l => world.addChild(l.container));

// 添加瓦片精灵
layers.sky.container.addChild(TilingSprite.from('/image/sky.png', 2000, 600));
layers.mountains.container.addChild(TilingSprite.from('/image/mountains.png', 2000, 400));
layers.trees.container.addChild(TilingSprite.from('/image/trees.png', 2000, 300));
layers.ground.container.addChild(TilingSprite.from('/image/ground.png', 2000, 200));

// 相机移动时各层以不同速度滚动
app.ticker.add(() => {
    const camX = camera.x;
    Object.values(layers).forEach(layer => {
        layer.container.x = -camX * layer.speed;
    });
});

💡 提示:视差滚动是 2D 游戏中营造深度感最经济的方式,无需任何 3D 技术。

@pixi-viewport 库使用

@pixi-viewport 提供完善的视口管理功能:

npm install @pixi-viewport
import { Viewport } from '@pixi-viewport';

const viewport = new Viewport({
    screenWidth: 800,
    screenHeight: 600,
    worldWidth: 4000,
    worldHeight: 3000,
    events: app.renderer.events,
});

app.stage.addChild(viewport);

// 启用插件
viewport
    .drag()             // 鼠标拖拽移动
    .pinch()            // 触屏双指缩放
    .wheel()            // 滚轮缩放
    .decelerate()       // 惯性减速
    .clamp({            // 世界边界限制
        left: true, right: true,
        top: true, bottom: true,
        underflow: 'center',
    })
    .clampZoom({        // 缩放限制
        minScale: 0.3,
        maxScale: 3,
    });

// 跟随目标
viewport.follow(player, {
    speed: 8,          // 跟随速度
    radius: 50,        // 死区半径
});

// 编程式控制
viewport.moveCenter(500, 400); // 移动到世界坐标
viewport.animate({ time: 500, scale: 1.5 }); // 动画缩放

小地图实现

function createMinimap(worldWidth, worldHeight, minimapSize) {
    const minimap = new Container();
    minimap.x = 800 - minimapSize - 10;
    minimap.y = 10;

    // 背景
    const bg = new Graphics();
    bg.rect(0, 0, minimapSize, minimapSize);
    bg.fill({ color: 0x000000, alpha: 0.5 });
    minimap.addChild(bg);

    // 缩放比
    const scaleX = minimapSize / worldWidth;
    const scaleY = minimapSize / worldHeight;

    // 玩家标记
    const playerDot = new Graphics();
    playerDot.circle(0, 0, 3);
    playerDot.fill(0x00ff00);
    minimap.addChild(playerDot);

    // 视口框
    const viewRect = new Graphics();
    minimap.addChild(viewRect);

    minimap.update = (camX, camY, screenW, screenH) => {
        playerDot.x = player.x * scaleX;
        playerDot.y = player.y * scaleY;

        viewRect.clear();
        viewRect.rect(
            (camX - screenW / 2) * scaleX,
            (camY - screenH / 2) * scaleY,
            screenW * scaleX,
            screenH * scaleY
        );
        viewRect.stroke({ color: 0xffffff, width: 1 });
    };

    return minimap;
}

相机与坐标系统

坐标系用途转换方法
屏幕坐标鼠标/触摸事件event.global
世界坐标游戏逻辑位置world.toLocal(event.global)
局部坐标对象内部parent.toLocal(worldPoint)
// 屏幕 → 世界
function screenToWorld(
    screenX: number, screenY: number,
    world: Container
): { x: number; y: number } {
    return world.toLocal({ x: screenX, y: screenY });
}

// 世界 → 屏幕
function worldToScreen(
    worldX: number, worldY: number,
    world: Container
): { x: number; y: number } {
    return world.toGlobal({ x: worldX, y: worldY });
}

扩展阅读