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

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)pointerdownpointermovepointerup
双指缩放(Pinch)计算两个 pointer 距离变化
滑动(Swipe)pointerdownpointerup 计算速度和方向

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 SafariMac Safari → 开发菜单 → 远程调试
Android Chromechrome://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
跑酷游戏虚拟摇杆 + 点击跳跃
塔防游戏触摸拖拽建造 + 双指缩放
音乐游戏多点触控 + 精确计时
多人对战虚拟双摇杆

扩展阅读