2D Godot 2D 게임 기초
P5 구조와 데이터

18강UI와 HUD

Control 노드, 앵커, 컨테이너, CanvasLayer.

이 강의 목차 (8)
  1. Node2D와 Control은 다른 세계다
  2. 앵커 — 화면 어디에 붙을 것인가
  3. 컨테이너 — 자동으로 배치해 주는 노드
  4. 실습 1 — 깨지지 않는 HUD
  5. 안전 영역 적용하기
  6. 실습 2 — 일시정지 메뉴
  7. 테마 — 스타일을 한 곳에서
  8. UI가 입력을 먹는 문제
이번 강의의 목표
  • Control 노드의 앵커와 컨테이너를 이해한다.
  • 화면 비율이 바뀌어도 안 깨지는 HUD를 만든다.
  • 체력바·점수·일시정지 메뉴를 붙인다.

Node2D와 Control은 다른 세계다#

지금까지 쓴 Node2D좌표 하나만 가집니다. 크기 개념이 없습니다. UI에 쓰는 Control사각형 영역을 가집니다.

Node2D Control
기본 개념 점(position) 사각형(position + size)
배치 좌표로 직접 앵커 + 컨테이너
크기 변화 scale로 확대 size가 실제로 변함
대표 Sprite2D, 캐릭터 Label, Button, Panel
UI를 Node2D로 만들면 반드시 후회한다

LabelNode2D처럼 좌표로 배치하면, 화면 비율이 바뀌는 순간 전부 어긋납니다(15강). UI는 반드시 Control 계열 + CanvasLayer 로 만드세요.

앵커 — 화면 어디에 붙을 것인가#

앵커(Anchor)

"이 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.

text
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 에서 설정합니다.

gdscript
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에 반영합니다.

gdscript
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 — 일시정지 메뉴#

text
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) "나가기"
gdscript
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()
process_mode — 일시정지 중에도 도는 노드

get_tree().paused = true 를 하면 모든 노드의 _process_physics_process가 멈춥니다. 그러면 일시정지 메뉴 자체도 멈춰서 버튼이 안 눌립니다.

동작
INHERIT 부모를 따름 (기본)
PAUSABLE 일시정지되면 멈춤
WHEN_PAUSED 일시정지 중에만 동작
ALWAYS 항상 동작
DISABLED 항상 멈춤

메뉴는 WHEN_PAUSED, 배경음악 관리자는 ALWAYS로 두는 게 보통입니다.

테마 — 스타일을 한 곳에서#

버튼 하나하나에 색을 지정하면 나중에 못 바꿉니다. Theme 리소스를 쓰세요.

  1. 최상위 ControlThemeNew Theme
  2. 테마 편집기에서 Button, Label, Panel 등의 기본 스타일 지정
  3. 자식들이 자동으로 상속받음
gdscript
# 개별 노드만 예외를 두고 싶을 때
label.add_theme_font_size_override("font_size", 28)
label.add_theme_color_override("font_color", Color.RED)
폰트와 한글

기본 폰트는 한글이 나오지만 예쁘지 않습니다. 웹폰트를 쓰려면:

  1. .ttf / .otf 파일을 프로젝트에 넣기
  2. 테마에서 Default Font 로 지정
  3. Multichannel Signed Distance Field 를 켜면 확대해도 안 깨집니다 (단, 픽셀아트 폰트에는 끄세요)

한글 폰트는 용량이 큽니다(2~15MB). 웹 빌드(26강)에서는 로딩 시간에 영향을 주므로 필요한 글자만 담은 서브셋 폰트를 만드는 것도 방법입니다.

UI가 입력을 먹는 문제#

7강에서 _unhandled_input을 쓰라고 한 이유가 여기 있습니다.

Mouse Filter를 확인하세요

Control 노드의 Mouse → Filter 값:

동작
Stop 입력을 여기서 멈춤 (기본값). 뒤로 안 감
Pass 자기도 처리하고 뒤로도 보냄
Ignore 아예 무시하고 통과

화면 전체를 덮는 ControlStop으로 되어 있으면 게임 화면 클릭이 전혀 안 됩니다. 배경 패널이나 장식용 Control은 Ignore로 두세요.

"게임이 갑자기 마우스에 반응 안 함"의 원인 1위입니다.

직접 바꿔보기
  1. 창 크기를 폴드 커버(412×960)와 메인(880×900)으로 바꿔가며 HUD를 확인하세요. 겹치거나 잘리는 것이 있습니까?
  2. TopRight의 앵커를 Top Left로 바꾸고 창을 넓혀보세요. 어떻게 됩니까?
  3. MarginContainer를 빼고 실행해 보세요. UI가 화면 끝에 딱 붙습니까?
  4. 일시정지 메뉴에서 process_modeINHERIT로 되돌려 보세요. 버튼이 눌립니까?
  5. Dim ColorRect의 Mouse FilterStop으로 두고 게임 화면을 클릭해 보세요.
  • 개발자 — UI 노드 경로($Root/Margin/...)를 코드에 길게 쓰지 마세요. 구조를 바꾸면 다 깨집니다. @onready에 한 번만 모아두거나 고유 이름(%HpBar) 기능을 쓰세요. (노드 우클릭 → "고유 이름으로 접근")
  • 기획자 — UI 스펙에 "화면이 좁아지면 무엇을 먼저 숨길지" 우선순위를 적어주세요. 모든 것을 다 보여줄 수 없는 화면이 반드시 존재합니다.
  • 디자이너 — UI는 9-슬라이스(NinePatchRect) 로 만들면 크기가 변해도 모서리가 안 늘어납니다. 버튼 배경은 반드시 9-슬라이스로 제작해 주세요. 고정 크기 이미지는 다국어에서 무너집니다.
자주 나는 오류

"Label에 텍스트를 넣었는데 안 보인다" → ① 크기가 0, ② CanvasLayer 밖이라 화면 밖으로 밀림, ③ 글자색이 배경색과 같음.

"컨테이너 안에서 위치를 옮겨도 제자리로 돌아온다" → 정상입니다. 컨테이너가 배치를 관리합니다. Custom Minimum SizeSize Flags로 조절하세요.

"버튼이 안 눌린다" → 위에 투명한 Control이 덮고 있고 Mouse FilterStop입니다.

"화면 크기를 바꾸면 UI가 겹친다" → 앵커가 전부 좌상단입니다. 컨테이너를 쓰세요.

여기까지 확인
  • Control과 Node2D의 차이를 안다
  • 앵커 프리셋을 쓸 줄 안다
  • 컨테이너로 자동 배치를 만들었다
  • 폴드 두 비율에서 HUD가 안 깨진다
  • 일시정지 메뉴가 동작한다

다음 강의 — 이제 기획자가 직접 밸런스를 만질 수 있는 구조를 만듭니다. 데이터 주도 설계입니다. 이 강좌에서 실무 가치가 가장 높은 회차입니다.