常用控件
界面组件是基础积木。本页将其组合为常用控件,每个示例给出可在编辑器或代码中构建的层级,以及所需行为代码。
进度条与血条
health bar 图像元素:背景,300 × 30
└── track 组元素:拉伸锚点,边距为 4,定义填充可用空间
└── fill 图像元素:拉伸锚点,边距为 0,使用进度条颜色
填充元素的右锚点随 0 到 1 的值变化,使其占轨道对应比例,并由轨道在条内保留边框:
function setHealth(value) {
fill.element.anchor = new pc.Vec4(0, 0, value, 1);
}
setHealth(0.7);
这种方法会挤压填充纹理。若要裁切而非压缩,可将填充锚点和轴心都设在轨道左侧,同时修改宽度与纹理矩形:fill.element.width = value * 292,其中 292 为轨道宽;再设置 fill.element.rect = new pc.Vec4(0, 0, value, 1)。详见进度条与圆形加载条教程。
滑块
滑块比轨道短的滚动条就是滑块控件,拖动时 value 在 0 到 1 之间变化,垂直方向的 0 位于顶部。
volume 图像元素:轨道,300 × 20,并带水平滚动条组件
└── handle 启用输入的图像元素:沿轨道高度锚定
volume.scrollbar.handleSize = 0.1;
volume.scrollbar.value = 0.8;
volume.scrollbar.on('set:value', (value) => {
app.systems.sound.volume = value;
});
开关与单选组
开关是通过外观显示状态的按钮:
music toggle 启用输入的图像元素,并带按钮组件
└── check 图像元素:勾选标记,开关启用时显示
let musicOn = true;
musicToggle.button.on('click', () => {
musicOn = !musicOn;
musicToggle.findByName('check').enabled = musicOn;
});
单选组由这类按钮组成的组元素构成,同一时间仅开启一个:
const options = difficulty.children;
for (const option of options) {
option.button.on('click', () => {
for (const other of options) {
other.findByName('check').enabled = other === option;
}
});
}
模态对话框
模态对话框覆盖其余界面,并在关闭前阻止底层输入:
screen
├── hud …
└── dialog 组元素:拉伸锚点
├── backdrop 图像元素:拉伸锚点,黑色,不透明度 0.6,启用输入
└── panel 启用输入的图像元素:对话框,包含文字与按钮
对话框位于层级末尾,因此显示在最上方并优先接收输入。背景填满屏幕且启用输入,捕获面板之外的点击,使下方 HUD 无法接收。面板也应启用输入,避免空白区域点击传到背景。要同时隔离游戏输入,应停止事件传播,详见防止界面输入传到游戏。
function openDialog() {
dialog.enabled = true;
}
function closeDialog() {
dialog.enabled = false;
}
// Clicking outside the panel closes the dialog
dialog.findByName('backdrop').element.on('click', closeDialog);
要覆盖多个屏幕,可将对话框放在独立且优先级更高的屏幕中。
工具提示
工具提示是在指针悬停元素时显示的面板:
// The tooltip is a child of the icon, anchored above it
icon.element.useInput = true;
icon.element.on('mouseenter', () => {
tooltip.enabled = true;
});
icon.element.on('mouseleave', () => {
tooltip.enabled = false;
});
子项先于父项后方的同级项绘制,可能被覆盖。要让提示置顶,应将其作为屏幕最后一个子项,显示时再移到目标元素处。触摸屏没有悬停,可改为长按显示,或将信息直接放在屏幕上。
动态列表
排行榜或背包等由数据生成的列表,可以使用布局组,按数据创建子项。可能超出区域时,放入滚动视图。
- 引擎
- 编辑器
- React
- Web Components
function showScores(scores) {
// Remove the old rows, then add one for each score
for (const row of [...list.children]) {
row.destroy();
}
for (const { name, score } of scores) {
const row = new pc.Entity(name);
row.addComponent('element', {
type: pc.ELEMENTTYPE_TEXT,
fontAsset: font.id,
text: `${name} ${score}`,
fontSize: 28
});
list.addChild(row);
}
}
showScores([{ name: 'Ada', score: 1250 }, { name: 'Grace', score: 990 }]);
在层级中创建一行,包含名为 name 和 score 的两个文本元素,转换为模板,再通过脚本创建各行:
import { Asset, Entity, Script } from 'playcanvas';
export class Leaderboard extends Script {
static scriptName = 'leaderboard';
/**
* The template of a row.
*
* @attribute
* @type {Asset}
* @resource template
*/
rowTemplate;
/**
* The layout group to add the rows to.
*
* @attribute
* @type {Entity}
*/
list;
showScores(scores) {
for (const row of [...this.list.children]) {
row.destroy();
}
for (const { name, score } of scores) {
const row = this.rowTemplate.resource.instantiate();
row.findByName('name').element.text = name;
row.findByName('score').element.text = String(score);
this.list.addChild(row);
}
}
}
在布局组内为每个条目渲染 <Entity>,并提供稳定 key:
export function Scores({ font, scores }) {
return (
<Entity name="list">
<Element type="group" anchor={[0.5, 0.5, 0.5, 0.5]} pivot={[0.5, 0.5]} width={300} height={400} />
<LayoutGroup orientation={ORIENTATION_VERTICAL} spacing={[0, 10]} />
{scores.map(({ name, score }) => (
<Entity key={name} name={name}>
<Element type="text" fontAsset={font} text={`${name} ${score}`} fontSize={28} />
</Entity>
))}
</Entity>
);
}
LayoutGroup 来自创建布局组,其中还说明了插入条目时保持顺序的方法。
通过 DOM API 创建各行:
function showScores(scores) {
const list = document.querySelector('pc-entity[name="list"]');
// Remove the old rows, but keep the list's own components
list.querySelectorAll(':scope > pc-entity').forEach(row => row.remove());
for (const { name, score } of scores) {
const row = document.createElement('pc-entity');
row.setAttribute('name', name);
const text = document.createElement('pc-element');
text.setAttribute('type', 'text');
text.setAttribute('font-asset', 'arial');
text.setAttribute('text', `${name} ${score}`);
text.setAttribute('font-size', '28');
row.appendChild(text);
list.appendChild(row);
}
}
拖放
ElementDragHelper 可使元素在拖动期间跟随指针。元素需要启用输入,应用需要鼠标或触摸设备:
const drag = new pc.ElementDragHelper(card.element);
const home = card.getLocalPosition().clone();
// The bounds of an element, in CSS pixels from the top-left of the canvas
const bounds = (entity) => {
const [bottomLeft, , topRight] = entity.element.canvasCorners;
return { left: bottomLeft.x, right: topRight.x, top: topRight.y, bottom: bottomLeft.y };
};
drag.on('drag:end', () => {
const a = bounds(card);
const b = bounds(slot);
const overSlot = a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top;
// Snap the card into the slot, or back to where it came from
if (overSlot) {
card.setPosition(slot.getPosition());
} else {
card.setLocalPosition(home);
}
});
第二参数传入 'x' 或 'y',可限制单轴拖动。开始和持续拖动时分别触发 drag:start 与 drag:move。
文本框
界面系统没有文本输入元素。可将 HTML <input> 覆盖到画布上,获得浏览器键盘、选区、自动补全与无障碍支持。详见 HTML 与 CSS;界面文本输入教程展示与画布界面风格一致的文本框。
界面动画
通过实体位置、旋转、缩放,以及元素颜色、不透明度、锚点和边距制作动画,可每帧小幅修改,或使用补间库:
// Slide a panel in from the left over half a second
let t = 0;
const handle = app.on('update', (dt) => {
t = Math.min(t + dt / 0.5, 1);
const eased = 1 - Math.pow(1 - t, 3);
panel.setLocalPosition(pc.math.lerp(-600, 0, eased), 0, 0);
if (t === 1) handle.off();
});
不透明度不传递给子项,因此淡出面板需遍历其下所有元素:
function setOpacity(entity, opacity) {
if (entity.element && entity.element.type !== pc.ELEMENTTYPE_GROUP) {
entity.element.opacity = opacity;
}
for (const child of entity.children) {
setOpacity(child, opacity);
}
}
示例将所有元素设为同一不透明度。原始值不同则应先保存。按钮通过 fadeDuration 在状态颜色间过渡;补间库详见 Tween。
加载画面与场景切换
- 加载画面。 编辑器项目在场景和界面都未加载前显示加载画面,因此使用 HTML 与 CSS,详见加载画面。
- 场景切换。 属于场景的界面会随场景销毁。要保留 HUD,应将新场景层级叠加加载到旁边,而非替换全部内容。详见加载场景与切换场景教程。
触屏摇杆
屏幕摇杆和触屏按钮本质上是读取触摸事件的元素。触屏摇杆教程构建了可复用的双摇杆布局。
另请参阅
- 按钮:状态、染色和事件。
- 布局组:列表和网格。
- 滚动视图:滚动内容与滚动条。
- HTML 与 CSS:DOM 界面。