Source 2 | 自定义 HUD 布局
custom_hud_layout 实体(C++ CCSCustomHudLayout)允许地图或服务端插件向玩家显示使用 Panorama XML + VCSS 编写的 HUD。
请先阅读: 自定义 HUD 并非普通的 Panorama 文档。加载的 XML 会经过专用的白名单验证器,仅允许四种面板类型和固定的属性集。标记中不支持 style=、<script> 和事件处理程序。所有动态行为均由服务端驱动。
完整管道
custom_hud_layout (服务端, keyvalue layout="...")
│ m_strLayout 复制到客户端
▼
CSGOCustomHuds (Panorama 窗口) → layout/hud/customhuds.xml
└─ <Panel class="WindowRoot" hittest="false">
└─ <CSGOCustomHud id="CustomHud" style="width:100%; height:100%; flow-children:none;">
└─ CSGOCustomHudLayoutRoot ← 由 C++ 创建,每个实体一个,
└─ 你的 XML 无 id 和 class
返回路径(点击):用户消息 CS_UM_CustomHudClicked (390) → 服务端 JS 事件 OnCustomHudClicked,携带 { player, layout, buttonId }。
两种 HUD
每个自定义 HUD 都是以下两种类型之一,区别仅在于每个玩家的调用:
| 覆盖型 — 默认 | 光标型 — SetInputCaptureEnabled(slot, true) | |
|---|---|---|
| 是什么 | 屏幕上的像素 | 玩家可使用的屏幕 |
| 鼠标 | 无;准星继续瞄准 | 光标出现在 HUD 上 |
| 点击 | 无任何内容可点击 | Button 的点击到达服务端 |
:hover | 从不匹配 | 正常工作,且不消耗服务端资源 |
| 构建 | 计时器、记分板、击杀消息、横幅 | 商店、投票对话框、选取器、菜单 |
每次布局构建时捕获功能都是关闭的,因此实体生成后需要重新开启——参见 entity.md,输入捕获。在已捕获的 HUD 中,hittest="false" 决定哪些面板鼠标可以穿透;将其写在所有元素上,并从必须捕获鼠标的少数容器上移除。
HUD 实际可包含的内容
标记词汇只有四个标签,但可以通过它们放置的内容比看起来更丰富:
- SVG 图标 (
.vsvg):在任何 UI 缩放比例下保持清晰,运行时使用wash-color着色; - 自定义字体:将
.ttf文件放入插件的panorama/fonts/目录; - 每个玩家的本地化文本:通过
text="#Token"在客户端按玩家各自语言解析,可使用自定义字符串或整个基础游戏库; - 基础游戏的艺术资源:通过
s2r://路径引用,无需向下载添加任何内容; - 动画和过渡:包括通过类切换驱动的进入/退出组合;
- 视频:作为普通
Panel上的background-image图层。
如果你来自 Web 开发
Panorama 在形式上类似 HTML/CSS,这既是帮助也是陷阱:语法看起来很熟悉,然后某个属性却悄悄不起作用。有效的思维模型是一个没有客户端脚本的服务端渲染页面,仅有的两种更新是切换面板上的类和在面板上设置字符串。其他一切——每个变体、每个状态、每个分支——都预先编写好,仅通过揭示来显示。
| 你通常会写 | 这里你写 |
|---|---|
<div> | <Panel> |
<span>,文本节点 | <Label text="..."> — 文本存在于属性中,而非标签之间 |
<img src> | <Image src>,或任何面板上的 background-image |
<button onclick> | <Button id> — 点击发送到服务端,而非脚本 |
style="..." | 无;class 是唯一的样式钩子 |
display: none | visibility: collapse |
visibility: hidden | opacity: 0 — 仍在布局中,仍然可点击 |
position: absolute | x / y 加上 ignore-parent-flow: true |
| flexbox, grid | 父元素上的 flow-children,子元素上的 horizontal-align / vertical-align |
gap | 子元素上的 margin |
filter: blur(4px) | blur: gaussian( 4, 4, 1 ) |
rgba(0, 0, 0, 0.5) | #00000080 — 颜色仅限十六进制,透明度为最后一个字节 |
background-image 中的 linear-gradient(...) | background-**color** 中的 gradient( ... ) |
@font-face | 将 .ttf 放入 panorama/fonts/;无需声明 |
:hover | :hover — 少数完全按预期工作的功能之一 |
| JS 事件处理程序 | 服务端事件 OnCustomHudClicked,通过按钮的 id 键绑定 |
data-* 由 JS 读取 | {s:name} 对话框变量,由服务端设置 |
循环或 <template> | 两者都不存在——在标记或样式表中枚举行 |
calc(),自定义属性 | 两者都不存在——在样式表中枚举值 |
来自 Web 开发的两个习惯会浪费大量时间。使用计算值:没有 calc(),没有变量,服务端无法发送数字,因此进度条是每步一个类,坐标是规则中的字面量。使用客户端行为:自打开的下拉菜单、自切换的标签页、自验证的表单——这些都需要往返才能实现,因此设计交互时应围绕点击到服务端和类返回。
设计师最关心的部分完好无损:盒模型、对齐、颜色、排版、阴影、圆角、过渡和关键帧动画的行为与 Web 非常接近,视觉工作可以直接迁移。请参阅 references/css.md 获取完整属性集和所有缺失属性的完整列表。
快速入门
一个具体示例:占领点指示器——一个只读条,固定在屏幕底部,带有径向进度环,完全由对话框变量和类切换驱动。它不需要输入捕获,因为没有可点击的内容。
源文件位于插件内容根目录下的 panorama/layout/custom_game/ 和 panorama/styles/custom_game/;编译器将它们转换为 .vxml_c / .vcss_c:
panorama/layout/custom_game/capture_point.xml
panorama/styles/custom_game/capture_point.css
1. 标记 — panorama/layout/custom_game/capture_point.xml
<root>
<styles>
<include src="s2r://panorama/styles/custom_game/capture_point.vcss_c" />
</styles>
<!--
外部面板不能有 id——加载器会分配它,编译器会拒绝。
保持为匿名的全屏包装器,将真正的根元素放在内部。
-->
<Panel class="cp-screen" hittest="false">
<!-- 初始隐藏:实体生成时立即向每个玩家显示此布局,因此初始状态必须为空。 -->
<Panel id="CaptureRoot" class="cp-root hidden" hittest="false">
<Panel class="cp-ring">
<Panel class="cp-ring-track" hittest="false" />
<!-- 服务端将 cp-fill 的类重写为 cp-fill-0 … cp-fill-100;
样式表将每个步骤转换为 clip: radial() 扫描。 -->
<Panel id="CaptureFill" class="cp-ring-fill cp-fill-0" hittest="false" />
<Label id="CapturePct" class="cp-pct" text="{s:pct}" />
</Panel>
<Panel class="cp-copy">
<Label id="CaptureName" class="cp-name" text="{s:point_name}" />
<Label id="CaptureHint" class="cp-hint" text="{s:hint}" />
</Panel>
</Panel>
</Panel>
</root>
2. 样式 — panorama/styles/custom_game/capture_point.css(完全没有内联样式)
/* 全屏包装器。noclip 允许圆环的光晕溢出面板边缘而不会被裁剪;
包装器本身从不进行命中测试。 */
.cp-screen
{
width: 100%;
height: 100%;
overflow: noclip;
}
/* 条本身,位于底部边缘上方。初始不可见并偏移,服务端添加 "show" 来播放揭示效果——
这就是为什么它是 .hidden 的替代方案,而非添加:折叠的面板不在布局中,没有动画起点。 */
.cp-root
{
flow-children: right;
horizontal-align: center;
vertical-align: bottom;
margin-bottom: 96px;
opacity: 0;
transform: translateY(20px);
transition: opacity 0.15s ease-out 0.0s, transform 0.15s ease-out 0.0s;
}
/* 揭示状态。两个属性都列在上面的过渡中,因此添加和移除类会双向动画。 */
.cp-root.show
{
opacity: 1;
transform: translateY(0px);
}
/* 将面板完全从布局中移除,以便邻居元素合拢而不是留下空白。
没有其他方法折叠面板——opacity: 0 会保留空间。 */
.hidden
{
visibility: collapse;
}
/* 两个圆环图片堆叠的方形盒子。两者都填充它,因此完全对齐。 */
.cp-ring
{
width: 56px;
height: 56px;
}
/* 下方的未填充圆环,变暗以使上方的填充显示为进度。艺术作品来自 s2r:// 上的编译纹理——标准形式。 */
.cp-ring-track
{
width: 100%;
height: 100%;
background-image: url( "s2r://panorama/images/custom_game/ring_png.vtex" );
background-size: contain;
background-repeat: no-repeat;
opacity: 0.25;
}
/* 上方的相同艺术作品,通过 wash-color 重新着色,并由下面的 .cp-fill-* 规则逐个扇区揭示。
着色一个灰色源比每个团队提供纹理更好。 */
.cp-ring-fill
{
width: 100%;
height: 100%;
background-image: url( "s2r://panorama/images/custom_game/ring_png.vtex" );
background-size: contain;
background-repeat: no-repeat;
wash-color: #4caf6dff;
}
/* 服务端可以选择的每个步骤一个规则,因为它可以切换类但不能发送数字。
clip 没有布局成本并且可以动画,因此更多步骤是廉价的。 */
.cp-fill-0
{
clip: radial( 50% 50%, 0deg, 0deg );
}
.cp-fill-25
{
clip: radial( 50% 50%, 0deg, 90deg );
}
.cp-fill-50
{
clip: radial( 50% 50%, 0deg, 180deg );
}
.cp-fill-75
{
clip: radial( 50% 50%, 0deg, 270deg );
}
.cp-fill-100
{
clip: radial( 50% 50%, 0deg, 360deg );
}
/* 圆环右侧的文本列,居中于圆环。 */
.cp-copy
{
flow-children: down;
vertical-align: center;
margin-left: 12px;
}
/* 点名称——玩家首先阅读的文本。 */
.cp-name
{
font-size: 20px;
font-weight: bold;
color: #ffffffff;
}
/* 辅助文本,使用 alpha 而非第二颜色来削弱。 */
.cp-hint
{
font-size: 14px;
color: #ffffff99;
}
/* 团队强调色。服务端在根上设置 team-ct 或 team-t,后代选择器重绘填充——
因此团队颜色和进度保持独立。 */
.cp-root.team-ct .cp-ring-fill
{
wash-color: #56a0ddff;
}
.cp-root.team-t .cp-ring-fill
{
wash-color: #f0a531ff;
}
3. 实体 — 在 Hammer 中或通过 CreateEntityByName
| Keyvalue | 值 |
|---|---|
layout | panorama/layout/custom_game/capture_point.vxml |
4. 从服务端驱动(cs_script 下的服务端 JS)——参见 entity.md
const hud = Instance.FindEntityByName("capture_hud"); // JS 包装类:CustomHudLayout
function showCapture(slot, name, pct, team) {
hud.SetHasClassForPlayer(slot, "CaptureRoot", "show", true);
hud.SetHasClassForPlayer(slot, "CaptureRoot", "team-" + team, true);
// 切换步骤类:清除旧的,设置新的
hud.SetHasClassForPlayer(slot, "CaptureFill", "cp-fill-" + roundTo25(pct), true);
hud.SetDialogVariableStringForPlayer(slot, "CapturePct", "pct", pct + "%");
hud.SetDialogVariableStringForPlayer(slot, "CaptureName", "point_name", name);
hud.SetDialogVariableStringForPlayer(slot, "CaptureHint", "hint", "占领该点");
}
function hideCapture(slot) {
hud.SetHasClassForPlayer(slot, "CaptureRoot", "show", false);
}
由于服务端只能切换类和设置字符串——无法发送数字——像进度环这样的连续值必须量化为每个步骤一个类。五个步骤对于占领环已足够;更平滑的条只需要更多规则。
常见拒绝原因
| 你想要的 | 失败原因 |
|---|---|
<TextEntry>、<ProgressBar>、<Movie>、<ToggleButton> 等任何其他标签 | 仅允许 Panel、Label、Image、Button——对于视频,使用 background-image 图层代替 <Movie> |
元素上的 style="..." | 没有面板类型具有 style 属性 |
onactivate="..." 或任何标记事件处理程序 | 无;点击到达服务端,键值为 id |
<scripts>、<snippets>、<snippet> | 在 AST 节点类型级别被拒绝 |
另一个 .xml 的 <include> | 只能引用 .vcss 资源 |
dialogvariable="..." 作为属性 | 服务端通过 SetDialogVariableString 设置变量 |
<Button> 上的 hittest | Button 仅接受 id 和 class |
完整允许集:xml.md。
调试
验证器将日志记录到 custom_hud 日志通道,严重级别为警告:
| 消息 | 原因 |
|---|---|
Layout xml is an invalid resource name "%s" | layout 未解析为资源名称 |
Failed to load layout '%s'. | 文件丢失或未编译 |
Layout contains disallowed node '%s' (type: %d). | <scripts>/<snippets>/<snippet> 等 |
Layout contains disallowed panel type '%s'. | 标签不在四个允许标签之外 |
Layout contains disallowed attribute %s for panel type '%s'. | 属性不在该标签的列表中 |
Layout contains reference to disallowed resource type '%s'. | <include> 目标不是 .vcss 资源 |
Layout xml did not pass CustomHud validation "%s" | 最终拒绝 |
Layout is invalid. | 父节点不是面板的属性节点 |
验证失败时,面板永远不会创建——空白屏幕,无部分渲染。将实时布局热重载为无效状态会销毁现有的 HUD。
参考
| 文件 | 内容 |
|---|---|
| xml.md | 完整的标签/属性白名单、AST 节点类型、验证器规则 |
| css.md | 所有 140 个 Panorama CSS 属性、单位、flow/align 值集、预处理器... |