mcpField
P
phaser-ui

phaser-ui

github.com/yakoub-ai设计与 UI29200 次安装9 周前更新
代码仓库

关于 phaser-ui

phaser-ui 是面向 Phaser 4 游戏界面的开发技能,适用于需要构建 HUD、控件和覆盖层的开发者。它提供基于游戏对象或独立 HUDScene 的实现模式,并涵盖响应式布局与交互层级问题。

主要功能

  1. 01使用游戏对象构建 UI
  2. 02提供固定滚动对象与 HUDScene 模式
  3. 03涵盖血条、按钮、对话框和小地图
  4. 04支持 DOM 输入覆盖层
  5. 05说明响应式布局与交互深度

适用场景

  1. 01为游戏场景添加分数和生命值 HUD
  2. 02实现按钮、对话框或进度条
  3. 03排查覆盖层遮挡交互的问题
  4. 04构建适应窗口变化的界面

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/yakoub-ai/phaser4-gamedev --skill phaser-ui

安装后在 Claude Code 中输入/phaser-ui确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

references/
hit-test-and-depth.md
panel-rebuild-patterns.md
ui-patterns.md

详细介绍

Phaser 4 UI 开发

Phaser 没有内置的 UI 工具包。你可以使用游戏对象(Text、Graphics、Image、Container)构建 UI,也可以使用并行的 HUDScene。两种基本方式:

  1. 滚动固定对象:将游戏对象添加到场景,调用 .setScrollFactor(0),使其在摄像机移动时保持固定。
  2. 并行 HUDScene:在游戏场景旁启动一个独立场景。该场景的摄像机不会移动,因此其中的所有内容天然固定。复杂 UI 适用此方式。

务必对 UI 元素调用 .setDepth(100)(或更高值),确保它们渲染在游戏对象上方。


血条(基于 Graphics)

class HealthBar {
  private bar: Phaser.GameObjects.Graphics;
  private x: number;
  private y: number;
  private width = 200;
  private height = 20;
  private maxHealth: number;
  private health: number;

  constructor(scene: Phaser.Scene, x: number, y: number, maxHealth: number) {
    this.x = x;
    this.y = y;
    this.maxHealth = this.health = maxHealth;
    this.bar = scene.add.graphics().setScrollFactor(0).setDepth(100);
    this.draw();
  }

  setHealth(value: number): void {
    this.health = Phaser.Math.Clamp(value, 0, this.maxHealth);
    this.draw();
  }

  private draw(): void {
    this.bar.clear();
    // Background border
    this.bar.fillStyle(0x000000, 0.6);
    this.bar.fillRect(this.x - 2, this.y - 2, this.width + 4, this.height + 4);
    // Colored fill — green above 50%, yellow above 25%, red below
    const ratio = this.health / this.maxHealth;
    const color = ratio > 0.5 ? 0x00ff00 : ratio > 0.25 ? 0xffff00 : 0xff0000;
    this.bar.fillStyle(color, 1);
    this.bar.fillRect(this.x, this.y, this.width * ratio, this.height);
  }

  destroy(): void {
    this.bar.destroy();
  }
}

每次玩家受到伤害或恢复生命值时,都调用 setHealth(newValue)。


分数和文本显示

// In create():
this.scoreText = this.add.text(16, 16, 'Score: 0', {
  fontSize: '24px',
  color: '#ffffff',
  stroke: '#000000',
  strokeThickness: 3,
}).setScrollFactor(0).setDepth(100);

// When score changes:
this.scoreText.setText(`Score: ${score}`);

为了适应不同布局,请根据 this.scale.width / this.scale.height 定位所有 HUD 文本(参见下文的“响应式 UI”)。


交互式按钮

方法 1 — 将文本用作按钮
const btn = this.add.text(400, 300, 'PLAY', {
  fontSize: '32px',
  backgroundColor: '#4a4a8a',
  padding: { x: 20, y: 10 },
})
  .setOrigin(0.5)
  .setInteractive({ useHandCursor: true })
  .on('pointerover', () => btn.setStyle({ color: '#ffff00' }))
  .on('pointerout',  () => btn.setStyle({ color: '#ffffff' }))
  .on('pointerdown', () => btn.setScale(0.95))
  .on('pointerup',   () => {
    btn.setScale(1);
    this.scene.start('GameScene');
  });
方法 2 — 使用纹理帧状态的图片按钮
const playBtn = this.add.image(400, 300, 'ui', 'btn-play-normal.png')
  .setInteractive({ useHandCursor: true });

playBtn.on('pointerover', () => playBtn.setFrame('btn-play-hover.png'));
playBtn.on('pointerout',  () => playBtn.setFrame('btn-play-normal.png'));
playBtn.on('pointerdown', () => playBtn.setFrame('btn-play-pressed.png'));
playBtn.on('pointerup',   () => {
  playBtn.setFrame('btn-play-normal.png');
  this.scene.start('GameScene');
});

对话框

class DialogBox extends Phaser.GameObjects.Container {
  constructor(scene: Phaser.Scene, text: string, onClose: () => void) {
    super(scene, scene.scale.width / 2, scene.scale.height / 2);
    scene.add.existing(this);
    this.setDepth(200).setScrollFactor(0);

    const bg = scene.add.graphics();
    bg.fillStyle(0x222244, 0.95);
    bg.fillRoundedRect(-200, -80, 400, 160, 16);

    const label = scene.add.text(0, -30, text, {
      fontSize: '20px',
      color: '#ffffff',
      wordWrap: { width: 360 },
      align: 'center',
    }).setOrigin(0.5);

    const closeBtn = scene.add.text(0, 50, 'OK', {
      fontSize: '20px',
      backgroundColor: '#4488aa',
      padding: { x: 20, y: 8 },
    })
      .setOrigin(0.5)
      .setInteractive({ useHandCursor: true })
      .on('pointerup', () => {
        this.destroy();
        onClose();
      });

    this.add([bg, label, closeBtn]);
  }
}

// Usage:
new DialogBox(this, 'You found a treasure chest!', () => {
  // Resume game logic after dialog closes
});

将缩放值从 0 动画过渡到 1,即可实现显示动画——动画版本参见 references/ui-patterns.md。


小地图(基于 Graphics 的点阵地图)

基于摄像机的小地图需要 WebGL 和渲染纹理。对大多数游戏来说,使用 Graphics 对象绘制点阵地图更简单,而且兼容 Canvas 和 WebGL:

class MiniMap {
  private gfx: Phaser.GameObjects.Graphics;
  private mapX = 10;
  private mapY = 10;
  private mapW = 150;
  private mapH = 100;
  private worldW: number;
  private worldH: number;

  constructor(scene: Phaser.Scene, worldW: number, worldH: number) {
    this.worldW = worldW;
    this.worldH = worldH;
    this.gfx = scene.add.graphics().setScrollFactor(0).setDepth(100);
  }

  update(entities: Array<{ x: number; y: number; color: number }>): void {
    this.gfx.clear();
    // Background
    this.gfx.fillStyle(0x000000, 0.5);
    this.gfx.fillRect(this.mapX, this.mapY, this.mapW, this.mapH);
    // Border
    this.gfx.lineStyle(2, 0xffffff, 0.8);
    this.gfx.strokeRect(this.mapX, this.mapY, this.mapW, this.mapH);
    // Entities as colored dots
    for (const e of entities) {
      const mx = this.mapX + (e.x / this.worldW) * this.mapW;
      const my = this.mapY + (e.y / this.worldH) * this.mapH;
      this.gfx.fillStyle(e.color, 1);
      this.gfx.fillCircle(mx, my, 2);
    }
  }

  destroy(): void {
    this.gfx.destroy();
  }
}

// In GameScene.update():
this.miniMap.update([
  { x: this.player.x, y: this.player.y, color: 0x00ff00 },
  ...this.enemies.getChildren().map(e => ({ x: (e as Enemy).x, y: (e as Enemy).y, color: 0xff0000 })),
]);

通用进度条

可用于生命值、XP、加载进度、耐力等任何比例指标:

interface ProgressBarConfig {
  x: number;
  y: number;
  width?: number;
  height?: number;
  fillColor?: number;
  bgColor?: number;
  depth?: number;
}

class ProgressBar {
  private bar: Phaser.GameObjects.Graphics;
  private cfg: Required<ProgressBarConfig>;

  constructor(scene: Phaser.Scene, config: ProgressBarConfig) {
    this.cfg = {
      width: 200, height: 16,
      fillColor: 0x00aaff,
      bgColor: 0x333333,
      depth: 100,
      ...config,
    };
    this.bar = scene.add.graphics().setScrollFactor(0).setDepth(this.cfg.depth);
    this.setValue(1);
  }

  setValue(ratio: number): void {
    const { x, y, width, height, fillColor, bgColor } = this.cfg;
    const clamped = Phaser.Math.Clamp(ratio, 0, 1);
    this.bar.clear();
    this.bar.fillStyle(bgColor, 0.8);
    this.bar.fillRect(x, y, width, height);
    this.bar.fillStyle(fillColor, 1);
    this.bar.fillRect(x, y, width * clamped, height);
  }

  destroy(): void {
    this.bar.destroy();
  }
}

// Examples:
const xpBar   = new ProgressBar(scene, { x: 16, y: 50, fillColor: 0xaa00ff });
const loadBar = new ProgressBar(scene, { x: 160, y: 300, width: 320, height: 24, fillColor: 0x00ff88 });
xpBar.setValue(0.65);    // 65% XP
loadBar.setValue(value); // from load.on('progress', ...)

使用 BitmapText 提升性能

每次调用 setText 时,Phaser.GameObjects.Text 都会重新绘制到画布上。BitmapText 则会在纹理图集中切换 UV 坐标——对于每帧都会更新的数值(分数、帧计数器、伤害数字),性能开销低得多。

// Preload the font (in PreloaderScene):
this.load.bitmapFont('arcade', 'assets/fonts/arcade.png', 'assets/fonts/arcade.xml');

// Create:
const scoreText = this.add.bitmapText(16, 16, 'arcade', 'Score: 0', 32)
  .setScrollFactor(0)
  .setDepth(100);

// Update every frame with zero canvas overhead:
scoreText.setText(`Score: ${this.score}`);

适合使用 BitmapText 的内容:分数、计时器、浮动伤害数字、连击计数器。适合使用 Text 的内容:长段落、对话文本,以及任何使用系统字体或自定义 CSS 样式的内容。


使用 this.add.dom() 创建 DOM 覆盖层

需要使用原生 HTML 输入框的设置表单或聊天输入框:

// Preload (PreloaderScene or scene preload):
this.load.html('settings-form', 'assets/html/settings.html');

// In create():
const domEl = this.add.dom(400, 300).createFromCache('settings-form');
domEl.addListener('click');
domEl.on('click', (event: Event) => {
  const target = event.target as HTMLElement;
  if (target.id === 'submit-btn') {
    const input = domEl.getChildByName('username') as HTMLInputElement;
    console.log('Username:', input.value);
    domEl.destroy();
  }
});

在游戏配置中启用 DOM:

const config: Phaser.Types.Core.GameConfig = {
  // ...
  dom: { createContainer: true },
};

注意:DOM 元素在移动设备和某些浏览器中存在 z-index 方面的问题。除非需要原生输入元素,否则 UI 应优先使用 Phaser 游戏对象。


使用 Scale Manager 实现响应式 UI

关于画布加 HUD 双层响应式尺寸规则的完整说明(包括可避免 iOS PWA 横屏错误的 100dvh + 实时摄像机模式,以及模块级 GAME_WIDTH 常量冻结问题),请参阅 skills/phaser-scene/references/scene-patterns.md → 响应式尺寸:双层。请勿重复该内容——此处为权威说明。

切勿为 HUD 元素硬编码像素位置。请使用 this.scale.width / this.scale.height,并监听尺寸变化:

create(): void {
  const { width, height } = this.scale;

  this.scoreText = this.add.text(width * 0.02, height * 0.02, 'Score: 0', {
    fontSize: `${Math.round(height * 0.04)}px`,
    color: '#ffffff',
  }).setScrollFactor(0).setDepth(100);

  // Re-anchor on resize (e.g. when browser window resizes)
  this.scale.on('resize', (gameSize: Phaser.Structs.Size) => {
    this.scoreText.setPosition(gameSize.width * 0.02, gameSize.height * 0.02);
  });
}
两个独立的尺寸层

Phaser UI 有两个不同的尺寸问题,不可混为一谈:

  1. 画布缩放 — 由 ScaleManager(this.scale.width/height)处理。控制游戏画布如何映射到浏览器窗口。
  2. HUD / 覆盖层位置 — 创建时必须从 this.cameras.main.width/height 获取(并在尺寸变化时更新)。切勿使用模块级常量(const GAME_W = 800)设置 HUD 位置——它们会冻结为启动时的值,无法适应画布尺寸变化。
// BAD — module-level constant freezes at boot dimensions:
const OVERLAY_W = 1280;
backdrop.setSize(OVERLAY_W, OVERLAY_H);

// CORRECT — read from live camera:
const { width, height } = this.cameras.main;
backdrop.setSize(width, height);

HUDScene 作为并行覆盖层时,其摄像机独立于 GameScene 摄像机。在 HUDScene 中从 this.cameras.main 获取的 HUD 位置始终反映 HUD 摄像机当前的视口——即使窗口尺寸变化后也仍然正确。


将 HUD 设为并行场景(复杂 UI 推荐)

对于分数文本和血条以外的 UI,请将所有 UI 移入专用的 HUDScene。这样做的好处包括:清晰分离、无需反复调整滚动因子,以及更方便地暂停或恢复游戏场景而不影响 UI。

// In GameScene.create():
this.scene.launch('HUDScene');

// Communicate via events:
this.events.emit('healthChanged', this.player.health);
this.events.emit('scoreChanged', this.score);
export class HUDScene extends Phaser.Scene {
  private healthBar!: HealthBar;
  private scoreText!: Phaser.GameObjects.Text;

  constructor() { super({ key: 'HUDScene' }); }

  create(): void {
    this.healthBar = new HealthBar(this, 16, 16, 100);
    this.scoreText = this.add.text(16, 50, 'Score: 0', {
      fontSize: '20px', color: '#ffffff',
    }).setDepth(100);

    const game = this.scene.get('GameScene');
    game.events.on('healthChanged', (hp: number) => this.healthBar.setHealth(hp), this);
    game.events.on('scoreChanged',  (s: number)  => this.scoreText.setText(`Score: ${s}`), this);

    // Clean up listeners when GameScene shuts down
    game.events.once(Phaser.Scenes.Events.SHUTDOWN, () => {
      game.events.off('healthChanged');
      game.events.off('scoreChanged');
    }, this);
  }
}

完整的 HUDScene 启动模式和场景通信选项,请参阅 phaser-scene 技能。


拖动覆盖层吞噬事件

用于检测滑动或拖动关闭的全视口透明区域,如果层级高于按钮,就会悄无声息地吞噬所有指针事件。Phaser 默认的 topOnly 命中测试会将该位置的事件专门路由到层级最高的交互对象。

诊断方法: this.input.enableDebug(overlayZone)——如果调试轮廓覆盖整个视口,且其深度高于按钮的深度,原因就在这里。

修复方法(按优先顺序):

  1. overlayZone.setDepth(-9999)——将区域置于所有交互对象后方。
  2. 交互面板打开时销毁覆盖层区域;关闭时重新创建。
  3. 仅在此场景中使用 this.input.setTopOnly(false)——允许所有重叠的交互对象接收事件(注意其他场景中可能会重复触发)。

关于深度和 topOnly 语义的完整说明,请参阅 references/hit-test-and-depth.md。

其他资源

参考文件
  • references/ui-patterns.md — 可用于生产环境的组件类:动画生命值条、按钮类、动画对话框、浮动文本、面板、物品栏网格
  • references/hit-test-and-depth.md — Phaser topOnly 命中测试语义、透明区域深度模式、拖动覆盖层吞噬点击的诊断清单。当按钮或交互子项悄无声息地停止响应时请阅读。
  • references/panel-rebuild-patterns.md — 面板内容的原位重建(切换标签页、购买),避免闪烁;首次访问时跳过打字机对话的模式;保留面板装饰的 container.list.slice(base) 惯用写法。当面板在内容更改时闪闭再打开,请阅读。

评论

支持 Markdown