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);
}