18강UI와 HUD
Control 노드, 앵커, 컨테이너, CanvasLayer.
이 강의 목차 (8)
Control노드의 앵커와 컨테이너를 이해한다.- 화면 비율이 바뀌어도 안 깨지는 HUD를 만든다.
- 체력바·점수·일시정지 메뉴를 붙인다.
Node2D와 Control은 다른 세계다#
지금까지 쓴 Node2D는 좌표 하나만 가집니다. 크기 개념이 없습니다.
UI에 쓰는 Control은 사각형 영역을 가집니다.
Node2D |
Control |
|
|---|---|---|
| 기본 개념 | 점(position) | 사각형(position + size) |
| 배치 | 좌표로 직접 | 앵커 + 컨테이너 |
| 크기 변화 | scale로 확대 | size가 실제로 변함 |
| 대표 | Sprite2D, 캐릭터 | Label, Button, Panel |
Label을 Node2D처럼 좌표로 배치하면, 화면 비율이 바뀌는 순간 전부 어긋납니다(15강).
UI는 반드시 Control 계열 + CanvasLayer 로 만드세요.
앵커 — 화면 어디에 붙을 것인가#
"이 UI가 부모의 어느 지점을 기준으로 위치를 잡을 것인가"
- 좌상단 앵커 +
position (20, 20)→ 항상 좌상단에서 20px - 우상단 앵커 +
offset (-120, 20)→ 화면이 넓어져도 항상 오른쪽 위
Control 노드를 선택하면 툴바에 레이아웃(Layout) 버튼이 생깁니다.
자주 쓰는 프리셋:
| 프리셋 | 결과 |
|---|---|
Top Left |
좌상단 고정 (체력바) |
Top Right |
우상단 고정 (점수, 미니맵) |
Center |
정중앙 (게임오버 문구) |
Bottom Wide |
아래에 가로로 꽉 (대화창) |
Full Rect |
화면 전체 (배경 어둡게, 페이드) |
Label을 하나 놓고 앵커를 Top Left로 둔 뒤 실행해서 창을 넓혀보세요.
그다음 Top Right로 바꾸고 다시 해보세요. 차이가 명확히 보입니다.
컨테이너 — 자동으로 배치해 주는 노드#
앵커를 하나하나 잡는 건 금방 한계가 옵니다. 컨테이너는 자식들을 자동으로 정렬합니다.
| 컨테이너 | 배치 |
|---|---|
VBoxContainer |
세로로 쌓음 |
HBoxContainer |
가로로 나열 |
GridContainer |
격자 (인벤토리) |
MarginContainer |
안쪽 여백 |
CenterContainer |
가운데 정렬 |
PanelContainer |
배경 패널 + 여백 |
AspectRatioContainer |
비율 유지 |
컨테이너 안에 들어간 자식은 위치를 직접 못 정합니다. 컨테이너가 알아서 배치합니다. 대신 자식은 아래 값으로 "요청"합니다.
Custom Minimum Size— 최소한 이만큼은 달라Size Flags → Horizontal/Vertical— 남는 공간을 가질지Fill— 주어진 만큼 채움Expand— 남는 공간을 요구함Shrink Center— 필요한 만큼만 쓰고 가운데
엑셀에서 셀에 값을 넣으면 위치를 직접 지정하지 않습니다. 표가 알아서 배치합니다.
열 너비를 "내용에 맞춤"으로 할지 "고정"으로 할지만 정하죠.
Size Flags가 그 설정입니다.
실습 1 — 깨지지 않는 HUD#
목표: 폴드 커버(21:9)와 메인(1:1) 양쪽에서 멀쩡한 HUD.
HUD (CanvasLayer)
└─ Root (Control) Layout: Full Rect
└─ Margin (MarginContainer) 여백 16
├─ TopLeft (VBoxContainer) Layout: Top Left
│ ├─ HpBar (ProgressBar) min size (200, 18)
│ └─ HpLabel (Label)
└─ TopRight (VBoxContainer) Layout: Top Right
├─ ScoreLabel (Label)
└─ TimeLabel (Label)MarginContainer의 여백은 인스펙터의
Theme Overrides → Constants → Margin Left/Top/Right/Bottom 에서 설정합니다.
extends CanvasLayer
@onready var hp_bar: ProgressBar = $Root/Margin/TopLeft/HpBar
@onready var hp_label: Label = $Root/Margin/TopLeft/HpLabel
@onready var score_label: Label = $Root/Margin/TopRight/ScoreLabel
func _ready() -> void:
var player := get_tree().get_first_node_in_group("player")
if player:
player.health_changed.connect(_on_health_changed)
func _on_health_changed(cur: int, max_v: int) -> void:
hp_label.text = "%d / %d" % [cur, max_v]
# 숫자는 즉시, 막대는 부드럽게 따라오게 (17강)
var tw := create_tween()
tw.set_ease(Tween.EASE_OUT)
tw.set_trans(Tween.TRANS_CUBIC)
tw.tween_property(hp_bar, "value",
float(cur) / max_v * 100.0, 0.25)
func set_score(v: int) -> void:
score_label.text = "점수 %d" % v뒤에 빨간 막대, 앞에 흰 막대를 겹쳐두고 뒤쪽만 느리게 줄이면 "깎인 만큼"이 잠깐 보입니다. 격투·액션 게임의 흔한 기법입니다.
안전 영역 적용하기#
15강에서 본 안전 영역을 HUD에 반영합니다.
extends MarginContainer
func _ready() -> void:
get_tree().root.size_changed.connect(_apply)
_apply()
func _apply() -> void:
var safe := DisplayServer.get_display_safe_area()
var full := DisplayServer.window_get_size()
# 화면 밖으로 밀린 만큼을 여백으로 준다.
var l := maxi(safe.position.x, 16)
var t := maxi(safe.position.y, 16)
var r := maxi(full.x - safe.end.x, 16)
var b := maxi(full.y - safe.end.y, 16)
add_theme_constant_override("margin_left", l)
add_theme_constant_override("margin_top", t)
add_theme_constant_override("margin_right", r)
add_theme_constant_override("margin_bottom", b)size_changed 시그널 덕분에 폴드를 펴고 접을 때 자동으로 다시 계산됩니다.
실습 2 — 일시정지 메뉴#
PauseMenu (CanvasLayer) layer 10
└─ Root (Control) Full Rect
├─ Dim (ColorRect) Full Rect, 검정 알파 0.6
└─ Center (CenterContainer) Full Rect
└─ Panel (PanelContainer)
└─ VBox (VBoxContainer)
├─ Title (Label) "일시정지"
├─ Resume (Button) "계속하기"
├─ Restart (Button) "다시 시작"
└─ Quit (Button) "나가기"extends CanvasLayer
func _ready() -> void:
# 게임이 멈춰도 이 메뉴는 계속 동작해야 한다.
process_mode = Node.PROCESS_MODE_WHEN_PAUSED
hide()
$Root/Center/Panel/VBox/Resume.pressed.connect(close)
$Root/Center/Panel/VBox/Restart.pressed.connect(_restart)
$Root/Center/Panel/VBox/Quit.pressed.connect(_quit)
func _unhandled_input(e: InputEvent) -> void:
if e.is_action_pressed("ui_cancel"):
if get_tree().paused: close()
else: open()
get_viewport().set_input_as_handled()
func open() -> void:
get_tree().paused = true
show()
# 게임패드/키보드로 바로 조작되게 포커스를 준다.
$Root/Center/Panel/VBox/Resume.grab_focus()
func close() -> void:
hide()
get_tree().paused = false
func _restart() -> void:
get_tree().paused = false
get_tree().reload_current_scene()
func _quit() -> void:
get_tree().quit()get_tree().paused = true 를 하면 모든 노드의 _process와 _physics_process가 멈춥니다.
그러면 일시정지 메뉴 자체도 멈춰서 버튼이 안 눌립니다.
| 값 | 동작 |
|---|---|
INHERIT |
부모를 따름 (기본) |
PAUSABLE |
일시정지되면 멈춤 |
WHEN_PAUSED |
일시정지 중에만 동작 |
ALWAYS |
항상 동작 |
DISABLED |
항상 멈춤 |
메뉴는 WHEN_PAUSED, 배경음악 관리자는 ALWAYS로 두는 게 보통입니다.
테마 — 스타일을 한 곳에서#
버튼 하나하나에 색을 지정하면 나중에 못 바꿉니다. Theme 리소스를 쓰세요.
- 최상위
Control의Theme→New Theme - 테마 편집기에서
Button,Label,Panel등의 기본 스타일 지정 - 자식들이 자동으로 상속받음
# 개별 노드만 예외를 두고 싶을 때
label.add_theme_font_size_override("font_size", 28)
label.add_theme_color_override("font_color", Color.RED)폰트와 한글
기본 폰트는 한글이 나오지만 예쁘지 않습니다. 웹폰트를 쓰려면:
.ttf/.otf파일을 프로젝트에 넣기- 테마에서
Default Font로 지정 Multichannel Signed Distance Field를 켜면 확대해도 안 깨집니다 (단, 픽셀아트 폰트에는 끄세요)
한글 폰트는 용량이 큽니다(2~15MB). 웹 빌드(26강)에서는 로딩 시간에 영향을 주므로 필요한 글자만 담은 서브셋 폰트를 만드는 것도 방법입니다.
UI가 입력을 먹는 문제#
7강에서 _unhandled_input을 쓰라고 한 이유가 여기 있습니다.
Control 노드의 Mouse → Filter 값:
| 값 | 동작 |
|---|---|
Stop |
입력을 여기서 멈춤 (기본값). 뒤로 안 감 |
Pass |
자기도 처리하고 뒤로도 보냄 |
Ignore |
아예 무시하고 통과 |
화면 전체를 덮는 Control이 Stop으로 되어 있으면
게임 화면 클릭이 전혀 안 됩니다. 배경 패널이나 장식용 Control은 Ignore로 두세요.
"게임이 갑자기 마우스에 반응 안 함"의 원인 1위입니다.
- 창 크기를 폴드 커버(412×960)와 메인(880×900)으로 바꿔가며 HUD를 확인하세요. 겹치거나 잘리는 것이 있습니까?
TopRight의 앵커를Top Left로 바꾸고 창을 넓혀보세요. 어떻게 됩니까?MarginContainer를 빼고 실행해 보세요. UI가 화면 끝에 딱 붙습니까?- 일시정지 메뉴에서
process_mode를INHERIT로 되돌려 보세요. 버튼이 눌립니까? DimColorRect의Mouse Filter를Stop으로 두고 게임 화면을 클릭해 보세요.
- 개발자 — UI 노드 경로(
$Root/Margin/...)를 코드에 길게 쓰지 마세요. 구조를 바꾸면 다 깨집니다.@onready에 한 번만 모아두거나 고유 이름(%HpBar) 기능을 쓰세요. (노드 우클릭 → "고유 이름으로 접근") - 기획자 — UI 스펙에 "화면이 좁아지면 무엇을 먼저 숨길지" 우선순위를 적어주세요. 모든 것을 다 보여줄 수 없는 화면이 반드시 존재합니다.
- 디자이너 — UI는 9-슬라이스(NinePatchRect) 로 만들면 크기가 변해도 모서리가 안 늘어납니다. 버튼 배경은 반드시 9-슬라이스로 제작해 주세요. 고정 크기 이미지는 다국어에서 무너집니다.
"Label에 텍스트를 넣었는데 안 보인다"
→ ① 크기가 0, ② CanvasLayer 밖이라 화면 밖으로 밀림, ③ 글자색이 배경색과 같음.
"컨테이너 안에서 위치를 옮겨도 제자리로 돌아온다"
→ 정상입니다. 컨테이너가 배치를 관리합니다. Custom Minimum Size와 Size Flags로 조절하세요.
"버튼이 안 눌린다"
→ 위에 투명한 Control이 덮고 있고 Mouse Filter가 Stop입니다.
"화면 크기를 바꾸면 UI가 겹친다" → 앵커가 전부 좌상단입니다. 컨테이너를 쓰세요.
- Control과 Node2D의 차이를 안다
- 앵커 프리셋을 쓸 줄 안다
- 컨테이너로 자동 배치를 만들었다
- 폴드 두 비율에서 HUD가 안 깨진다
- 일시정지 메뉴가 동작한다
다음 강의 — 이제 기획자가 직접 밸런스를 만질 수 있는 구조를 만듭니다. 데이터 주도 설계입니다. 이 강좌에서 실무 가치가 가장 높은 회차입니다.