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

Pixi.js 游戏开发教程 / Pixi.js UI 系统(按钮/血条/对话框)

18. UI 系统(按钮/血条/对话框)

概述

UI 是游戏与玩家交互的桥梁。Pixi.js 灵活的显示对象系统非常适合构建自定义 UI 组件。本章讲解按钮、血条、对话框、背包等常见 UI 的实现。

UI 层设计

UI 层应独立于游戏世界,不受相机移动影响:

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

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

// 游戏世界(受相机影响)
const world = new Container();
app.stage.addChild(world);

// UI 层(固定在屏幕上)
const uiLayer = new Container();
app.stage.addChild(uiLayer);

// UI 层不随相机移动,始终覆盖在游戏世界上方

💡 提示:UI 层通常放在 app.stage 的最后添加,确保渲染在最上层。

按钮实现

基础精灵按钮

import { Container, Sprite, Text, TextStyle } from 'pixi.js';

class Button extends Container {
    constructor(textureNormal, textureHover, texturePress, label = '') {
        super();

        this.eventMode = 'static';
        this.cursor = 'pointer';

        // 状态精灵
        this.normalSprite = Sprite.from(textureNormal);
        this.hoverSprite = Sprite.from(textureHover || textureNormal);
        this.pressSprite = Sprite.from(texturePress || textureNormal);

        this.addChild(this.normalSprite);
        this.addChild(this.hoverSprite);
        this.addChild(this.pressSprite);

        this.hoverSprite.visible = false;
        this.pressSprite.visible = false;

        // 文字标签
        if (label) {
            const style = new TextStyle({
                fontSize: 16,
                fill: '#ffffff',
                fontWeight: 'bold',
            });
            this.label = new Text({ text: label, style });
            this.label.anchor.set(0.5);
            this.label.x = this.normalSprite.width / 2;
            this.label.y = this.normalSprite.height / 2;
            this.addChild(this.label);
        }

        // 事件
        this.on('pointerover', () => this.setState('hover'));
        this.on('pointerout', () => this.setState('normal'));
        this.on('pointerdown', () => this.setState('press'));
        this.on('pointerup', () => {
            this.setState('hover');
            this.emit('click');
        });
        this.on('pointerupoutside', () => this.setState('normal'));
    }

    setState(state) {
        this.normalSprite.visible = state === 'normal';
        this.hoverSprite.visible = state === 'hover';
        this.pressSprite.visible = state === 'press';
    }
}

// 使用
const playButton = new Button('/image/btn-normal.png', '/image/btn-hover.png', null, '开始游戏');
playButton.x = 400;
playButton.y = 300;
playButton.on('click', () => {
    sceneManager.switchTo('gameplay');
});
uiLayer.addChild(playButton);

九宫格按钮(NineSlicePlane)

九宫格可以将小纹理拉伸为任意尺寸而不变形:

import { NineSlicePlane, Texture } from 'pixi.js';

const btnTexture = Texture.from('/image/btn-9slice.png');

// 参数:左、上、右、下 切片边距
const button = new NineSlicePlane(btnTexture, 16, 16, 16, 16);
button.width = 200;  // 拉伸到任意宽度
button.height = 50;  // 拉伸到任意高度

button.eventMode = 'static';
button.cursor = 'pointer';
button.on('pointerdown', () => console.log('点击!'));

uiLayer.addChild(button);

⚠️ 注意NineSlicePlane 的切片边距应与你的 UI 素材设计匹配。常见值为 8px(16x16 素材)或 16px(32x32 素材)。

图形绘制按钮(无需纹理)

import { Graphics, Text, TextStyle, Container } from 'pixi.js';

function createGraphicsButton(width, height, text, color = 0x3498db) {
    const btn = new Container();
    btn.eventMode = 'static';
    btn.cursor = 'pointer';

    const bg = new Graphics();
    bg.roundRect(0, 0, width, height, 8);
    bg.fill(color);
    btn.addChild(bg);

    const label = new Text({
        text,
        style: new TextStyle({
            fontSize: 18,
            fill: '#ffffff',
            fontWeight: 'bold',
        }),
    });
    label.anchor.set(0.5);
    label.x = width / 2;
    label.y = height / 2;
    btn.addChild(label);

    // 按下缩放效果
    btn.on('pointerdown', () => btn.scale.set(0.95));
    btn.on('pointerup', () => {
        btn.scale.set(1);
        btn.emit('click');
    });
    btn.on('pointerupoutside', () => btn.scale.set(1));

    return btn;
}

血条/进度条

动态缩放血条

import { Container, Graphics, Text } from 'pixi.js';

class HealthBar extends Container {
    constructor(width = 200, height = 20, maxHp = 100) {
        super();

        this.barWidth = width;
        this.barHeight = height;
        this.maxHp = maxHp;
        this.currentHp = maxHp;

        // 背景
        this.bg = new Graphics();
        this.bg.roundRect(0, 0, width, height, 4);
        this.bg.fill(0x333333);
        this.addChild(this.bg);

        // 血条填充
        this.fill = new Graphics();
        this.addChild(this.fill);

        // 边框
        this.border = new Graphics();
        this.border.roundRect(0, 0, width, height, 4);
        this.border.stroke({ color: 0x000000, width: 2 });
        this.addChild(this.border);

        // 数字显示
        this.hpText = new Text({
            text: `${maxHp}/${maxHp}`,
            style: { fontSize: 12, fill: '#ffffff' },
        });
        this.hpText.anchor.set(0.5);
        this.hpText.x = width / 2;
        this.hpText.y = height / 2;
        this.addChild(this.hpText);

        this.renderBar();
    }

    setHp(hp) {
        this.currentHp = Math.max(0, Math.min(this.maxHp, hp));
        this.renderBar();
        this.hpText.text = `${Math.ceil(this.currentHp)}/${this.maxHp}`;
    }

    renderBar() {
        const ratio = this.currentHp / this.maxHp;
        const innerW = this.barWidth - 4;
        const innerH = this.barHeight - 4;

        this.fill.clear();
        this.fill.roundRect(2, 2, innerW * ratio, innerH, 3);

        // 颜色渐变:绿 → 黄 → 红
        let color;
        if (ratio > 0.5) {
            color = 0x44cc44; // 绿色
        } else if (ratio > 0.25) {
            color = 0xcccc44; // 黄色
        } else {
            color = 0xcc4444; // 红色
        }
        this.fill.fill(color);
    }
}

// 使用
const healthBar = new HealthBar(200, 24, 100);
healthBar.x = 10;
healthBar.y = 10;
uiLayer.addChild(healthBar);

// 玩家受伤时
player.on('damage', (amount) => {
    healthBar.setHp(healthBar.currentHp - amount);
});

带动画的血条

class AnimatedHealthBar extends HealthBar {
    constructor(width, height, maxHp) {
        super(width, height, maxHp);
        this.targetHp = maxHp;
        this.delayFill = new Graphics();
        this.addChildAt(this.delayFill, 1); // 在填充层下面
        this.lastDamageTime = 0;
    }

    setHp(hp) {
        this.targetHp = Math.max(0, Math.min(this.maxHp, hp));
        this.lastDamageTime = performance.now();
        super.setHp(hp);
    }

    update() {
        // 延迟填充条(白色,缓慢下降表示伤害量)
        const elapsed = performance.now() - this.lastDamageTime;
        if (elapsed > 500) {
            // 500ms 后延迟条开始追赶
            const ratio = this.targetHp / this.maxHp;
            const currentRatio = this.delayFill._currentRatio ?? 1;
            const newRatio = currentRatio + (ratio - currentRatio) * 0.05;
            this.delayFill._currentRatio = newRatio;

            const innerW = this.barWidth - 4;
            const innerH = this.barHeight - 4;
            this.delayFill.clear();
            this.delayFill.roundRect(2, 2, innerW * newRatio, innerH, 3);
            this.delayFill.fill(0xffffff, 0.3);
        }
    }
}

经验条/进度条

class ProgressBar extends Container {
    constructor(width = 300, height = 12, bgColor = 0x222222, fillColor = 0x00aaff) {
        super();

        this.barWidth = width;
        this.fillColor = fillColor;

        const bg = new Graphics();
        bg.roundRect(0, 0, width, height, height / 2);
        bg.fill(bgColor);
        this.addChild(bg);

        this.fill = new Graphics();
        this.addChild(this.fill);

        this.progress = 0;
        this.renderFill();
    }

    setProgress(value) {
        this.progress = Math.max(0, Math.min(1, value));
        this.renderFill();
    }

    renderFill() {
        const w = (this.barWidth - 4) * this.progress;
        this.fill.clear();
        if (w > 0) {
            this.fill.roundRect(2, 2, w, this.barHeight - 4, (this.barHeight - 4) / 2);
            this.fill.fill(this.fillColor);
        }
    }
}

对话框系统

文字逐字显示

class TypewriterText extends Container {
    constructor(text, style, charDelay = 30) {
        super();
        this.fullText = text;
        this.charDelay = charDelay;
        this.textObj = new Text({ text: '', style });
        this.addChild(this.textObj);
        this.currentIndex = 0;
        this.timer = null;
        this.isComplete = false;
    }

    start() {
        this.currentIndex = 0;
        this.isComplete = false;
        this.tick();
    }

    tick() {
        if (this.currentIndex <= this.fullText.length) {
            this.textObj.text = this.fullText.substring(0, this.currentIndex);
            this.currentIndex++;
            this.timer = setTimeout(() => this.tick(), this.charDelay);
        } else {
            this.isComplete = true;
            this.emit('complete');
        }
    }

    skip() {
        clearTimeout(this.timer);
        this.textObj.text = this.fullText;
        this.isComplete = true;
        this.emit('complete');
    }

    destroy() {
        clearTimeout(this.timer);
        super.destroy();
    }
}

对话框组件

class DialogBox extends Container {
    constructor(width = 700, height = 150) {
        super();

        this.x = 50;
        this.y = 400;

        // 背景面板
        const bg = new Graphics();
        bg.roundRect(0, 0, width, height, 12);
        bg.fill({ color: 0x000000, alpha: 0.8 });
        bg.stroke({ color: 0x666666, width: 2 });
        this.addChild(bg);

        // 角色名
        this.nameText = new Text({
            text: '',
            style: { fontSize: 18, fill: '#ffcc00', fontWeight: 'bold' },
        });
        this.nameText.x = 16;
        this.nameText.y = 12;
        this.addChild(this.nameText);

        // 对话文本区域
        this.contentArea = new Container();
        this.contentArea.x = 16;
        this.contentArea.y = 44;
        this.addChild(this.contentArea);

        // 继续提示
        this.continueHint = new Text({
            text: '▼ 点击继续',
            style: { fontSize: 12, fill: '#888888' },
        });
        this.continueHint.anchor.set(1, 1);
        this.continueHint.x = width - 16;
        this.continueHint.y = height - 12;
        this.continueHint.visible = false;
        this.addChild(this.continueHint);

        // 点击交互
        this.eventMode = 'static';
        this.hitArea = new Rectangle(0, 0, width, height);

        this.dialogQueue = [];
        this.currentTypewriter = null;
    }

    // 显示一段对话
    showDialog(name, text) {
        return new Promise((resolve) => {
            this.nameText.text = name;
            this.continueHint.visible = false;

            // 清除旧文字
            if (this.currentTypewriter) {
                this.currentTypewriter.destroy();
            }

            const typewriter = new TypewriterText(text, {
                fontSize: 16,
                fill: '#ffffff',
                wordWrap: true,
                wordWrapWidth: 668,
            });
            this.contentArea.addChild(typewriter);
            this.currentTypewriter = typewriter;

            typewriter.on('complete', () => {
                this.continueHint.visible = true;
            });

            typewriter.start();

            const onClick = () => {
                if (!typewriter.isComplete) {
                    typewriter.skip();
                } else {
                    this.off('pointerdown', onClick);
                    resolve();
                }
            };
            this.on('pointerdown', onClick);
        });
    }

    // 批量对话
    async playDialogs(dialogs) {
        for (const dialog of dialogs) {
            await this.showDialog(dialog.name, dialog.text);
        }
    }
}

// 使用
const dialog = new DialogBox();
uiLayer.addChild(dialog);

await dialog.playDialogs([
    { name: '勇者', text: '终于到了魔王城的门口...' },
    { name: '勇者', text: '无论如何,今天一定要结束这一切!' },
    { name: '旁白', text: '大门缓缓打开,一股寒气扑面而来。' },
]);

背包/物品栏 UI

class InventoryGrid extends Container {
    constructor(cols = 5, rows = 4, cellSize = 64) {
        super();

        this.cols = cols;
        this.rows = rows;
        this.cellSize = cellSize;
        this.items = new Array(cols * rows).fill(null);

        // 绘制网格
        for (let i = 0; i < cols * rows; i++) {
            const cell = new Container();
            const col = i % cols;
            const row = Math.floor(i / cols);

            cell.x = col * (cellSize + 4);
            cell.y = row * (cellSize + 4);

            const bg = new Graphics();
            bg.roundRect(0, 0, cellSize, cellSize, 4);
            bg.fill({ color: 0x222222, alpha: 0.7 });
            bg.stroke({ color: 0x555555, width: 1 });
            cell.addChild(bg);

            cell.slotIndex = i;
            cell.eventMode = 'static';
            cell.on('pointerdown', () => this.onSlotClick(i));

            this.addChild(cell);
        }
    }

    setItem(slotIndex, itemData) {
        this.items[slotIndex] = itemData;
        const cell = this.children[slotIndex];

        // 移除旧图标
        const oldIcon = cell.getChildByName('itemIcon');
        if (oldIcon) oldIcon.destroy();

        if (itemData) {
            const icon = Sprite.from(itemData.icon);
            icon.name = 'itemIcon';
            icon.width = this.cellSize - 8;
            icon.height = this.cellSize - 8;
            icon.x = 4;
            icon.y = 4;
            cell.addChild(icon);

            if (itemData.count > 1) {
                const countText = new Text({
                    text: String(itemData.count),
                    style: { fontSize: 12, fill: '#ffffff' },
                });
                countText.name = 'countText';
                countText.anchor.set(1, 1);
                countText.x = this.cellSize - 4;
                countText.y = this.cellSize - 4;
                cell.addChild(countText);
            }
        }
    }

    onSlotClick(index) {
        this.emit('slotClick', { index, item: this.items[index] });
    }
}

// 使用
const inventory = new InventoryGrid(5, 4, 64);
inventory.x = 50;
inventory.y = 100;
uiLayer.addChild(inventory);

inventory.setItem(0, { icon: '/image/sword.png', name: '铁剑', count: 1 });
inventory.setItem(1, { icon: '/image/potion.png', name: '生命药水', count: 5 });

浮动文字/伤害数字

class FloatingText extends Container {
    constructor(text, color = 0xffffff, fontSize = 24) {
        super();

        const textObj = new Text({
            text,
            style: {
                fontSize,
                fill: color,
                fontWeight: 'bold',
                stroke: { color: 0x000000, width: 3 },
            },
        });
        textObj.anchor.set(0.5);
        this.addChild(textObj);

        this.vy = -2; // 向上飘动速度
        this.life = 1; // 生命周期(秒)
        this.elapsed = 0;
    }

    update(dt) {
        this.y += this.vy * dt * 60;
        this.elapsed += dt;
        this.alpha = 1 - (this.elapsed / this.life);

        if (this.elapsed >= this.life) {
            this.destroy();
            return false;
        }
        return true;
    }
}

class FloatingTextManager {
    constructor(container) {
        this.container = container;
        this.texts = [];
    }

    spawn(x, y, value, color = 0xff4444) {
        const text = new FloatingText(`-${value}`, color);
        text.x = x + (Math.random() - 0.5) * 20;
        text.y = y;
        this.container.addChild(text);
        this.texts.push(text);
    }

    update(dt) {
        this.texts = this.texts.filter(t => t.update(dt));
    }
}

// 使用:敌人受伤时显示伤害数字
const damageTexts = new FloatingTextManager(uiLayer);

player.on('attack', (enemy, damage) => {
    damageTexts.spawn(enemy.x, enemy.y - 20, damage, 0xff4444);
});

// 治疗显示绿色
player.on('heal', (amount) => {
    damageTexts.spawn(player.x, player.y - 20, `+${amount}`, 0x44ff44);
});

UI 动画

// 缩放弹出效果
function popIn(element, duration = 300) {
    return new Promise((resolve) => {
        element.scale.set(0);
        element.alpha = 0;
        const start = performance.now();

        const tick = () => {
            const t = Math.min(1, (performance.now() - start) / duration);
            const eased = easeOutBack(t);
            element.scale.set(eased);
            element.alpha = t;
            if (t < 1) requestAnimationFrame(tick);
            else resolve();
        };
        tick();
    });
}

function easeOutBack(t) {
    const c1 = 1.70158;
    const c3 = c1 + 1;
    return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
}

// 滑入效果
function slideIn(element, fromX, fromY, toX, toY, duration = 400) {
    return new Promise((resolve) => {
        element.x = fromX;
        element.y = fromY;
        const start = performance.now();

        const tick = () => {
            const t = Math.min(1, (performance.now() - start) / duration);
            const eased = easeOutCubic(t);
            element.x = fromX + (toX - fromX) * eased;
            element.y = fromY + (toY - fromY) * eased;
            if (t < 1) requestAnimationFrame(tick);
            else resolve();
        };
        tick();
    });
}

function easeOutCubic(t) {
    return 1 - Math.pow(1 - t, 3);
}

扩展阅读