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레이어로 사용.
웹 개발에서 오셨다면
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 — 색상은 16진수 전용, 알파는 마지막 바이트 |
background-image의 linear-gradient(...) | background-**color**의 gradient( ... ) |
@font-face | .ttf를 panorama/fonts/에 넣기만 하면 됨; 선언 불필요 |
:hover | :hover — 예상대로 정확히 작동하는 몇 안 되는 것 중 하나 |
| JS 이벤트 핸들러 | 버튼의 id로 키가 지정된 서버 이벤트 OnCustomHudClicked |
JS에서 읽는 data-* | 서버에서 설정된 {s:name} 대화상자 변수 |
루프나 <template> | 둘 다 없음 — 마크업이나 스타일시트에서 행을 열거 |
calc(), 사용자 정의 속성 | 둘 다 없음 — 스타일시트에서 값을 열거 |
웹 작업에서 비롯된 두 가지 습관이 여기서 시간 낭비를 초래합니다. 계산된 값 사용하기: calc(), 변수, 서버가 숫자를 보낼 수 없으므로 진행 막대는 단계별 클래스, 좌표는 규칙의 리터럴입니다. 그리고 클라이언트 동작 사용하기: 스스로 열리는 드롭다운, 스스로 전환되는 탭, 스스로 유효성 검사하는 폼 — 이러한 것들은 왕복 없이는 불가능하므로, 클릭이 서버로 가고 클래스가 돌아오는 방식으로 상호작용을 설계하십시오.
온전히 살아남는 것은 디자이너가 가장 중요하게 생각하는 부분입니다: 박스 모델, 정렬, 색상, 타이포그래피, 그림자, 둥근 모서리, 전환 및 키프레임 애니메이션은 시각적 작업이 직접 전달될 만큼 웹과 유사하게 동작합니다. 전체 속성 집합과 누락된 항목의 전체 목록은 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가 절대 있어서는 안 됩니다 — 로더가 id를 할당하며, 컴파일러가
reject합니다. 익명의 전체 화면 래퍼로 유지하고 실제 루트를 내부에 배치하십시오.
-->
<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;
}
/* 보조 줄, 두 번째 색상 대신 알파로 억제됩니다. */
.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만 허용됨 — 비디오는 <Movie> 대신 background-image 레이어 사용 |
요소의 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 속성, 단위, 흐름/정렬 값 집합, 사전(중략) |