Pixi.js 游戏开发教程 / Pixi.js 移动端适配与触控
移动端适配与触控
让你的 Pixi.js 游戏完美运行在手机和平板上。
一、触摸事件
1.1 Pixi.js 事件系统
Pixi.js v8 统一了鼠标和触摸事件,使用 pointer 系列事件:
import { Application, Sprite, Texture } from 'pixi.js';
const app = new Application({ width: 800, height: 600 });
document.body.appendChild(app.canvas);
const button = new Sprite(Texture.from('/image/button.png'));
button.x = 400;
button.y = 300;
button.anchor.set(0.5);
button.eventMode = 'static'; // 启用交互
button.cursor = 'pointer';
// 统一的 pointer 事件(兼容鼠标和触摸)
button.on('pointerdown', (event) => {
console.log('按下', event.global.x, event.global.y);
});
button.on('pointermove', (event) => {
console.log('移动', event.global.x, event.global.y);
});
button.on('pointerup', (event) => {
console.log('抬起', event.global.x, event.global.y);
});
button.on('pointertap', (event) => {
console.log('点击(按下+抬起)');
});
app.stage.addChild(button);
1.2 多点触控
// Pixi.js 自动支持多点触控
// 每个触摸点有独立的 pointerId
button.on('pointerdown', (event) => {
const pointerId = event.pointerId;
console.log(`手指 ${pointerId} 按下`);
});
// 在 stage 上监听所有触摸点
app.stage.eventMode = 'static';
app.stage.hitArea = app.screen;
const activePointers = new Map<number, { x: number; y: number }>();
app.stage.on('pointerdown', (event) => {
activePointers.set(event.pointerId, {
x: event.global.x,
y: event.global.y,
});
});
app.stage.on('pointermove', (event) => {
if (activePointers.has(event.pointerId)) {
activePointers.set(event.pointerId, {
x: event.global.x,
y: event.global.y,
});
}
});
app.stage.on('pointerup', (event) => {
activePointers.delete(event.pointerId);
});
app.ticker.add(() => {
console.log('当前触摸点数:', activePointers.size);
});
1.3 常用手势
| 手势 | 实现方式 |
|---|---|
| 点击(Tap) | pointertap 事件 |
| 长按(Long Press) | pointerdown + 计时器 |
| 拖拽(Drag) | pointerdown → pointermove → pointerup |
| 双指缩放(Pinch) | 计算两个 pointer 距离变化 |
| 滑动(Swipe) | pointerdown → pointerup 计算速度和方向 |
1.4 拖拽实现
function makeDraggable(sprite: Sprite) {
sprite.eventMode = 'static';
sprite.cursor = 'pointer';
let dragging = false;
let offsetX = 0;
let offsetY = 0;
sprite.on('pointerdown', (event) => {
dragging = true;
const pos = event.getLocalPosition(sprite.parent);
offsetX = sprite.x - pos.x;
offsetY = sprite.y - pos.y;
});
sprite.on('pointermove', (event) => {
if (!dragging) return;
const pos = event.getLocalPosition(sprite.parent);
sprite.x = pos.x + offsetX;
sprite.y = pos.y + offsetY;
});
sprite.on('pointerup', () => { dragging = false; });
sprite.on('pointerupoutside', () => { dragging = false; });
}
二、设备像素比(devicePixelRatio)
2.1 什么是 devicePixelRatio
物理像素 = CSS 像素 × devicePixelRatio
iPhone 15 Pro: 393×852 CSS → 1179×2556 物理像素 (dpr=3)
2.2 适配方案
// 方案一:自动适配(默认,高清但性能消耗大)
const app = new Application({
width: 800,
height: 600,
// Pixi.js 默认使用 window.devicePixelRatio
// 高 DPR 设备上纹理会很大
});
// 方案二:限制 DPR(推荐移动端)
const dpr = Math.min(window.devicePixelRatio, 2);
const app = new Application({
width: 800,
height: 600,
resolution: dpr, // 限制最大 2x
autoDensity: true, // 自动调整 CSS 尺寸
});
2.3 性能对比
| DPR 设置 | 分辨率 (iPhone 15 Pro) | GPU 负载 |
|---|---|---|
| dpr = 3(原始) | 1179 × 2556 | 非常高 |
| dpr = 2(推荐) | 786 × 1704 | 中等 |
| dpr = 1(省电) | 393 × 852 | 低 |
⚠️ 注意:dpr = 1 在 Retina 屏幕上会模糊,但对像素风格游戏可能反而更好。
三、响应式布局
3.1 动态 Resize
import { Application } from 'pixi.js';
const app = new Application();
await app.init({
width: 800,
height: 600,
resizeTo: window, // 自动跟随窗口大小
});
// 监听 resize 事件
function onResize() {
const screenWidth = app.screen.width;
const screenHeight = app.screen.height;
// 保持设计比例 16:9
const designRatio = 16 / 9;
const screenRatio = screenWidth / screenHeight;
if (screenRatio > designRatio) {
// 屏幕更宽,以高度为基准
app.stage.scale.set(screenHeight / 600);
} else {
// 屏幕更高,以宽度为基准
app.stage.scale.set(screenWidth / 800);
}
// 居中
app.stage.x = (screenWidth - 800 * app.stage.scale.x) / 2;
app.stage.y = (screenHeight - 600 * app.stage.scale.y) / 2;
}
window.addEventListener('resize', onResize);
onResize();
3.2 使用 Fit 模式
// 三种常见适配模式
type FitMode = 'fit' | 'fill' | 'stretch';
function applyFitMode(
mode: FitMode,
designWidth: number,
designHeight: number,
screenWidth: number,
screenHeight: number,
stage: Container
) {
const scaleX = screenWidth / designWidth;
const scaleY = screenHeight / designHeight;
let scale: number;
switch (mode) {
case 'fit': // 保持比例,完全显示(可能有黑边)
scale = Math.min(scaleX, scaleY);
break;
case 'fill': // 保持比例,填满屏幕(可能裁切)
scale = Math.max(scaleX, scaleY);
break;
case 'stretch': // 拉伸填满(变形)
stage.scale.set(scaleX, scaleY);
stage.x = 0;
stage.y = 0;
return;
}
stage.scale.set(scale);
stage.x = (screenWidth - designWidth * scale) / 2;
stage.y = (screenHeight - designHeight * scale) / 2;
}
四、移动端 UI 适配
4.1 安全区域(Safe Area)
iPhone 的刘海、圆角和底部 Home Indicator 区域:
// 读取 CSS 安全区域(需要页面设置 viewport-fit=cover)
function getSafeAreaInsets() {
const style = getComputedStyle(document.documentElement);
return {
top: parseInt(style.getPropertyValue('env(safe-area-inset-top)') || '0'),
bottom: parseInt(style.getPropertyValue('env(safe-area-inset-bottom)') || '0'),
left: parseInt(style.getPropertyValue('env(safe-area-inset-left)') || '0'),
right: parseInt(style.getPropertyValue('env(safe-area-inset-right)') || '0'),
};
}
// HTML meta 标签必须设置:
// <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
// UI 元素避开安全区域
function positionUI(element: Sprite, app: Application) {
const safe = getSafeAreaInsets();
const margin = 20;
// 右上角按钮
element.x = app.screen.width - safe.right - margin;
element.y = safe.top + margin;
element.anchor.set(1, 0);
}
4.2 响应式 UI 位置
import { Container, Text, Sprite, Texture } from 'pixi.js';
class GameUI extends Container {
private scoreText: Text;
private pauseBtn: Sprite;
private app: Application;
constructor(app: Application) {
super();
this.app = app;
this.scoreText = new Text({
text: 'Score: 0',
style: { fill: 0xffffff, fontSize: 24 },
});
this.addChild(this.scoreText);
this.pauseBtn = new Sprite(Texture.from('/image/pause.png'));
this.pauseBtn.anchor.set(1, 0);
this.addChild(this.pauseBtn);
this.updateLayout();
window.addEventListener('resize', () => this.updateLayout());
}
updateLayout() {
const w = this.app.screen.width;
const safe = getSafeAreaInsets();
const margin = 16;
this.scoreText.x = safe.left + margin;
this.scoreText.y = safe.top + margin;
this.pauseBtn.x = w - safe.right - margin;
this.pauseBtn.y = safe.top + margin;
}
}
五、横竖屏切换
5.1 检测方向
function getOrientation(): 'portrait' | 'landscape' {
if (screen.orientation) {
return screen.orientation.type.includes('portrait')
? 'portrait' : 'landscape';
}
return window.innerWidth > window.innerHeight ? 'landscape' : 'portrait';
}
// 提示用户旋转设备
const rotateOverlay = document.getElementById('rotate-hint')!;
window.addEventListener('resize', () => {
if (getOrientation() === 'portrait') {
rotateOverlay.style.display = 'flex';
} else {
rotateOverlay.style.display = 'none';
}
});
5.2 锁定方向
// 请求锁定横屏(需要全屏状态)
async function lockLandscape() {
try {
await screen.orientation.lock('landscape');
} catch (e) {
console.warn('无法锁定方向:', e);
}
}
// 进入全屏后锁定
async function goFullscreen() {
const canvas = app.canvas;
if (canvas.requestFullscreen) {
await canvas.requestFullscreen();
await lockLandscape();
}
}
⚠️ 注意:screen.orientation.lock() 在 iOS Safari 上不被支持,需要用 CSS orientation media query 做降级处理。
六、虚拟摇杆实现
6.1 经典双摇杆
import { Container, Graphics } from 'pixi.js';
class VirtualJoystick extends Container {
private base: Graphics;
private thumb: Graphics;
private radius = 60;
public direction = { x: 0, y: 0 }; // 归一化方向 [-1, 1]
private pointerId: number | null = null;
constructor(x: number, y: number) {
super();
this.x = x;
this.y = y;
// 底座
this.base = new Graphics();
this.base.circle(0, 0, this.radius).fill({ color: 0xffffff, alpha: 0.2 });
this.base.stroke({ color: 0xffffff, alpha: 0.5, width: 2 });
this.addChild(this.base);
// 摇杆头
this.thumb = new Graphics();
this.thumb.circle(0, 0, 25).fill({ color: 0xffffff, alpha: 0.6 });
this.addChild(this.thumb);
this.eventMode = 'static';
this.hitArea = new PIXI.Circle(0, 0, this.radius + 20);
this.on('pointerdown', this.onDown.bind(this));
this.on('pointermove', this.onMove.bind(this));
this.on('pointerup', this.onUp.bind(this));
this.on('pointerupoutside', this.onUp.bind(this));
}
private onDown(event: FederatedPointerEvent) {
this.pointerId = event.pointerId;
this.updateThumb(event);
}
private onMove(event: FederatedPointerEvent) {
if (event.pointerId !== this.pointerId) return;
this.updateThumb(event);
}
private onUp(event: FederatedPointerEvent) {
if (event.pointerId !== this.pointerId) return;
this.pointerId = null;
this.thumb.x = 0;
this.thumb.y = 0;
this.direction.x = 0;
this.direction.y = 0;
}
private updateThumb(event: FederatedPointerEvent) {
const local = event.getLocalPosition(this);
const dist = Math.sqrt(local.x ** 2 + local.y ** 2);
const maxDist = this.radius;
if (dist > maxDist) {
local.x = (local.x / dist) * maxDist;
local.y = (local.y / dist) * maxDist;
}
this.thumb.x = local.x;
this.thumb.y = local.y;
this.direction.x = local.x / maxDist;
this.direction.y = local.y / maxDist;
}
}
// 使用:左侧移动摇杆,右侧射击摇杆
const moveJoystick = new VirtualJoystick(120, app.screen.height - 120);
const fireJoystick = new VirtualJoystick(app.screen.width - 120, app.screen.height - 120);
app.stage.addChild(moveJoystick, fireJoystick);
// 控制玩家
app.ticker.add(() => {
player.x += moveJoystick.direction.x * 5;
player.y += moveJoystick.direction.y * 5;
if (fireJoystick.direction.x !== 0 || fireJoystick.direction.y !== 0) {
const angle = Math.atan2(fireJoystick.direction.y, fireJoystick.direction.x);
fireBullet(player.x, player.y, angle);
}
});
七、移动端调试
7.1 远程调试工具
| 平台 | 工具 |
|---|---|
| iOS Safari | Mac Safari → 开发菜单 → 远程调试 |
| Android Chrome | chrome://inspect → 选择设备 |
| 通用 | eruda 移动端控制台 |
| 通用 | vConsole |
7.2 注入 eruda 控制台
// 开发模式下注入移动端调试控制台
if (import.meta.env.DEV && /Mobi|Android/i.test(navigator.userAgent)) {
const script = document.createElement('script');
script.src = 'https://unpkg.com/eruda';
script.onload = () => (window as any).eruda.init();
document.head.appendChild(script);
}
八、PWA 配置
8.1 添加 manifest.json
{
"name": "我的 Pixi 游戏",
"short_name": "PixiGame",
"start_url": "/",
"display": "fullscreen",
"orientation": "landscape",
"background_color": "#000000",
"theme_color": "#000000",
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }
]
}
8.2 Service Worker 离线缓存
// service-worker.ts
const CACHE_NAME = 'game-v1';
const ASSETS = [
'/',
'/index.html',
'/assets/game.js',
'/image/spritesheet.png',
];
self.addEventListener('install', (event: any) => {
event.waitUntil(
caches.open(CACHE_NAME).then(cache => cache.addAll(ASSETS))
);
});
self.addEventListener('fetch', (event: any) => {
event.respondWith(
caches.match(event.request).then(response => response || fetch(event.request))
);
});
九、WebView 发布
9.1 Android WebView
// MainActivity.java
WebView webView = findViewById(R.id.webview);
webView.getSettings().setJavaScriptEnabled(true);
webView.getSettings().setDomStorageEnabled(true);
webView.getSettings().setMediaPlaybackRequiresUserGesture(false);
webView.loadUrl("file:///android_asset/www/index.html");
9.2 发布打包建议
| 步骤 | 说明 |
|---|---|
| 1. 构建 | vite build 生成生产版本 |
| 2. 资源压缩 | 压缩图片和音频 |
| 3. 离线化 | Service Worker 缓存所有资源 |
| 4. 打包 | Android → APK/AAB, iOS → IPA |
| 5. 测试 | 真机测试各平台 |
💡 提示:使用 Capacitor 或 Cordova 可以快速将 Web 游戏打包为原生 App。
十、游戏开发场景
| 场景 | 移动端方案 |
|---|---|
| 休闲点击游戏 | pointertap + 简单 UI |
| 跑酷游戏 | 虚拟摇杆 + 点击跳跃 |
| 塔防游戏 | 触摸拖拽建造 + 双指缩放 |
| 音乐游戏 | 多点触控 + 精确计时 |
| 多人对战 | 虚拟双摇杆 |