Pixi.js 游戏开发教程 / Pixi.js 视口与相机系统
12. 视口与相机系统
概述
Pixi.js 没有内置的 Camera 对象,但可以通过移动世界容器来模拟相机。本章讲解如何实现平滑跟随、世界边界、缩放、震动、视差滚动等相机功能。
相机核心概念
在 2D 游戏中,相机本质上是「移动世界来模拟视角移动」:
| 概念 | 实现方式 |
|---|---|
| 移动 | 设置世界容器的 x、y 的负值 |
| 跟随 | 每帧更新世界位置使目标居中 |
| 缩放 | 设置世界容器的 scale.x、scale.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 });
}