이번 실습의 완성 기준은 시작 → 표적 누르기 → 20초 종료 → 다시 시작입니다. 그림을 따로 구하거나 회원가입·광고 SDK를 붙일 필요가 없습니다. 기존에 출시한 게임과 별도로 만든 작은 프로젝트입니다.
전체 프로젝트 ZIP 받기 · 파일별 SHA-256·검증 범위
이 페이지의 코드는 Godot 프로젝트 코드입니다. HTML 예제와 달리 브라우저에 붙여 넣어 실행할 수 없습니다. ZIP에는 한글 폰트와 라이선스, Android 테스트용 내보내기 프리셋도 들어 있습니다. APK는 직접 빌드하도록 안내하며 다운로드 파일에는 포함하지 않았습니다.
어디까지 실제로 확인했나요
| 항목 | 이번에 확인한 범위 |
|---|---|
| 에디터 | Windows, Godot 4.7.2.stable.official.ed1daf0bf 일반판 |
| 렌더링 | Compatibility, 480×800 창의 실제 화면 캡처 |
| 게임 규칙 | 시작, 중복 시작 방지, 40회 점수 처리, 표적 경계, 종료 후 입력, 재시작 |
| 시간 | 실제 20초를 기다리는 자동 검사 |
| Android | 같은 버전 템플릿으로 arm64 디버그 APK 생성·서명·내보내기 검증 |
| 아직 하지 않은 것 | 실제 휴대폰 설치·터치·뒤로가기·복귀, Play 제출용 AAB와 출시 심사 |
자동 검사는 버튼의 신호를 직접 발생시키는 부분이 있습니다. 마우스나 손가락으로 정확히 누를 수 있는지는 별도로 확인해야 합니다. 실습은 Codex의 도움으로 작성했고, 위 표는 실제 실행 결과만 구분해 기록했습니다.
1. 다운로드한 프로젝트를 먼저 실행합니다
- Godot Windows 다운로드에서 일반판을 준비합니다. 이 실습은 C#을 사용하지 않습니다. 같은 결과를 비교하려면 검증 버전인 4.7.2를 사용하세요.
- ZIP을 새 폴더에 풉니다. ZIP 안에서 파일 하나만 꺼내지 말고 폰트까지 함께 풉니다.
- Godot 프로젝트 관리자의 Import에서
project.godot을 선택합니다. - 가져오기가 끝나면 F5로 프로젝트 전체를 실행합니다.
main.tscn이 기본 장면으로 지정돼 있습니다.

첫 화면에서 주황 표적이 보이지 않는 것은 정상입니다. 시작을 누르면 표적이 나타나고, 한 번 누를 때마다 점수가 1씩 올라야 합니다. 20초 후에는 표적이 사라지고 최종 점수가 표시됩니다.
2. 파일과 노드의 역할을 이해합니다
| 파일 | 역할 |
|---|---|
| project.godot | 시작 장면, 세로 화면 크기, 렌더러, Android 텍스처 설정 |
| main.tscn | 화면 전체를 차지하는 Control 노드 하나 |
| main.gd | 화면 만들기와 게임 규칙 |
| NotoSansKR-Regular.ttf | 다른 PC에서도 한글을 표시할 폰트 |
| export_presets.cfg | Android 테스트 APK 프리셋 |
| verify.gd | 규칙 자동 검사 |
| capture.gd | 문서용 화면 캡처. 일반 실행에는 사용하지 않음 |
이 예제는 _ready()에서 UI 노드를 만듭니다. 실행 전 Local 장면 트리에 TapLab 하나만 보이는 것이 정상입니다. 실행 중 Remote 트리를 선택하면 만들어진 노드가 나타납니다. 위 그림은 구조 설명도이며 에디터 캡처는 아닙니다.
3. 빈 프로젝트에서 직접 구성하려면
다운로드본이 실행된 뒤 새 폴더에서 다음 순서로 다시 만들어 보세요.
- 새 프로젝트를 만들고 렌더러는 Compatibility를 선택합니다.
- User Interface 장면을 만들고 루트 이름을
TapLab으로 바꿉니다.main.tscn으로 저장합니다. - 루트 Control에
main.gd를 연결하고 아래 전체 코드를 넣습니다. - ZIP의
NotoSansKR-Regular.ttf와OFL.txt를 프로젝트 루트로 복사합니다. 코드의 경로와 파일 이름이 같아야 합니다. - Project Settings에서 viewport를 480×800, stretch mode를
canvas_items로 설정합니다. 아래 설정 파일과 비교할 수 있습니다. - F5를 누르고 메인 장면 선택 창이 나오면
main.tscn을 선택합니다.
파일 이름은 대소문자까지 맞추세요. 한글 폰트는 시스템 폰트 이름을 참조하지 않고 프로젝트에 포함한 파일을 읽습니다.
project.godot 전체 설정
전체 코드 펼치기 · 복사
config_version=5
[application]
config/name="Tap Lab"
run/main_scene="res://main.tscn"
[display]
window/size/viewport_width=480
window/size/viewport_height=800
window/size/window_width_override=480
window/size/window_height_override=800
window/stretch/mode="canvas_items"
window/handheld/orientation=1
[rendering]
renderer/rendering_method="gl_compatibility"
textures/vram_compression/import_etc2_astc=true
environment/defaults/default_clear_color=Color(0.95, 0.96, 0.92, 1)main.gd 전체 코드
전체 코드 펼치기 · 복사
extends Control
const ROUND_MS := 20000
const TARGET_SIZE := 76.0
var score := 0
var deadline := 0
var playing := false
var hud: Label
var message: Label
var arena: Control
var target: Button
var start: Button
var rng := RandomNumberGenerator.new()
func _ready() -> void:
theme = Theme.new()
theme.default_font = preload("res://NotoSansKR-Regular.ttf")
theme.default_font_size = 20
add_theme_color_override("font_color", Color("233a32"))
var margin := MarginContainer.new()
margin.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
for side in ["left", "right", "top", "bottom"]:
margin.add_theme_constant_override("margin_" + side, 24)
add_child(margin)
var column := VBoxContainer.new()
column.add_theme_constant_override("separation", 18)
margin.add_child(column)
var title := make_label("20초 표적 찾기", 32)
column.add_child(title)
column.add_child(make_label("주황 표적을 눌러 점수를 모으세요.", 19))
hud = make_label("점수 0 · 남은 시간 20.0초", 22)
column.add_child(hud)
arena = Control.new()
arena.name = "Arena"
arena.size_flags_vertical = Control.SIZE_EXPAND_FILL
arena.custom_minimum_size = Vector2(0, 200)
column.add_child(arena)
var board := ColorRect.new()
board.color = Color("e0e9dd")
board.mouse_filter = Control.MOUSE_FILTER_IGNORE
board.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
arena.add_child(board)
target = Button.new()
target.name = "Target"
target.text = "+1"
target.size = Vector2.ONE * TARGET_SIZE
target.focus_mode = Control.FOCUS_NONE
var style := StyleBoxFlat.new()
style.bg_color = Color("ed8c46")
style.set_corner_radius_all(38)
for state in ["normal", "hover", "pressed", "disabled"]:
target.add_theme_stylebox_override(state, style)
target.add_theme_color_override("font_color", Color("233a32"))
arena.add_child(target)
target.hide()
# One pressed signal handles mouse and touch; no second input handler.
target.pressed.connect(hit)
arena.resized.connect(keep_inside)
message = make_label("시작을 누르면 20초 동안 진행합니다.", 19)
column.add_child(message)
start = Button.new()
start.name = "Start"
start.text = "게임 시작"
start.custom_minimum_size.y = 60
start.add_theme_color_override("font_color", Color("ffffff"))
var button_style := StyleBoxFlat.new()
button_style.bg_color = Color("385b48")
button_style.set_corner_radius_all(12)
start.add_theme_stylebox_override("normal", button_style)
column.add_child(start)
start.pressed.connect(begin)
rng.randomize()
func make_label(text_value: String, font_size: int) -> Label:
var label := Label.new()
label.text = text_value
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
label.add_theme_font_size_override("font_size", font_size)
label.add_theme_color_override("font_color", Color("233a32"))
return label
func begin() -> void:
if playing:
return
score = 0
deadline = Time.get_ticks_msec() + ROUND_MS
playing = true
start.disabled = true
message.text = "빈 곳을 눌러도 점수는 바뀌지 않습니다."
target.show()
move_target()
refresh()
func hit() -> void:
if not playing:
return
if Time.get_ticks_msec() >= deadline:
finish()
return
score += 1
move_target()
refresh()
func move_target() -> void:
var available := (arena.size - target.size).max(Vector2.ZERO)
target.position = Vector2(rng.randf_range(0, available.x), rng.randf_range(0, available.y))
func keep_inside() -> void:
if is_instance_valid(target):
target.position = target.position.clamp(Vector2.ZERO, (arena.size - target.size).max(Vector2.ZERO))
func _process(_delta: float) -> void:
if playing:
if Time.get_ticks_msec() >= deadline:
finish()
else:
refresh()
func refresh() -> void:
var remaining := maxf(0.0, (deadline - Time.get_ticks_msec()) / 1000.0)
hud.text = "점수 %d · 남은 시간 %.1f초" % [score, remaining]
func finish() -> void:
playing = false
target.hide()
start.disabled = false
start.text = "다시 시작"
hud.text = "최종 점수 %d · 남은 시간 0.0초" % score
message.text = "끝났습니다! 다시 시작하면 점수가 0이 됩니다."4. 시간을 프레임 수로 세지 않는 이유
deadline은 시작 시각에 20,000밀리초를 더한 종료 시각입니다. _process()는 매 프레임 현재 시각과 비교합니다. 느린 PC에서 프레임 수가 줄어도 20초 판이 30초로 늘어나지 않게 하려는 것입니다.
표적을 눌렀을 때도 종료 시각을 다시 검사합니다. 화면 갱신 직전 만료된 입력이 들어와도 점수를 더하지 않습니다. 단, 모바일 앱 중단·기기 절전까지 이 데스크톱 검사로 보장하지는 않습니다. 휴대폰에서는 복귀 직후 동작을 따로 검사해야 합니다.
입력 처리는 Button.pressed 한 군데에 연결합니다. 여기에 터치 입력 함수를 또 붙여 같은 점수를 더하면 한 번 눌렀는데 2점이 오를 수 있습니다.
5. 표적이 화면 밖으로 나가지 않게 합니다
표적의 왼쪽 위 좌표를 경기장 전체 크기에서 뽑으면 오른쪽이나 아래가 잘립니다. 경기장 크기에서 표적 크기를 뺀 범위를 사용합니다. 표적은 76×76 논리 픽셀입니다.
창 크기가 바뀌면 keep_inside()가 위치를 다시 경계 안으로 제한합니다. 이 예제는 세로 화면용이며, 가로 방향 게임으로 확장할 때는 글자·버튼·경기장 높이를 새로 확인해야 합니다. 표적이 작아질수록 어려워지는지 확인할 때는 화면 비율도 함께 기록하세요.
6. 종료와 재시작을 확인합니다

이 캡처는 종료 상태를 자동으로 재현한 것입니다. 별도 규칙 검사는 실제 20초를 기다렸습니다.
| 직접 해 볼 동작 | 기대 결과 |
|---|---|
| 시작 전 빈 곳 누르기 | 점수 0 유지 |
| 시작 후 표적 한 번 누르기 | 정확히 1점 증가 |
| 진행 중 시작 버튼 누르기 | 비활성 상태, 판 중복 생성 없음 |
| 20초 뒤 표적 자리 누르기 | 추가 점수 없음 |
| 다시 시작 | 점수 0, 새 20초 시작 |
| 창 크기 변경 | 표적 전체가 경기장 안에 있음 |
자동 검사는 ZIP의 README에 실행 명령이 있습니다. TAP_LAB_PASS가 출력되는지 확인하세요. 여기에는 저장 기능이 없으므로 앱을 닫았다 열면 점수는 사라집니다. 이를 최고 점수 저장 기능으로 오해하지 않도록 UI에도 최고 점수는 표시하지 않았습니다.
7. 실제로 만난 Android 내보내기 오류
처음 --export-debug Android를 실행했을 때 ETC2/ASTC 텍스처 압축이 필요하다는 오류로 실패했습니다. 별도 그림 파일이 없는 작은 프로젝트라도 Android 내보내기의 설정 검사를 통과해야 했습니다.
Project Settings의 Advanced Settings를 켠 뒤 ETC2를 검색하고 Rendering → Textures → VRAM Compression → Import ETC2 ASTC를 활성화했습니다. 설정 파일에는 다음 한 줄이 추가됩니다.
textures/vram_compression/import_etc2_astc=true
그 뒤 같은 명령으로 APK 생성, 디버그 서명, Godot 내보내기 검증까지 통과했습니다. 다운로드 프로젝트에는 이미 수정된 설정이 들어 있습니다. 이 그림은 오류와 수정의 설명도이며 Console 승인 화면은 아닙니다.
8. 본인 PC에서 Android APK 만들기
- Manage Export Templates에서 에디터와 같은 버전의 템플릿을 설치합니다. 이번에는
4.7.2.stable을 사용했습니다. - Godot Android 공식 안내에 따라 Java SDK와 Android SDK를 준비합니다. 공식 권장은 JDK 17입니다. 이번 로컬 검증은 이미 설치된 Android Studio JBR 21.0.9와 Build Tools 36.0.0으로 수행했습니다. 이 조합이 모든 환경의 필수 조건은 아닙니다.
- Editor Settings의 Export → Android에서 Java SDK Path와 Android SDK Path를 지정합니다. 다른 사람의 PC 경로를 복사하지 마세요.
- Project → Export → Android를 열어 프리셋을 확인합니다. 이 실습은 Gradle 빌드 없이 디버그 APK를 만드는 경로입니다.
- Export Project에서
.apk경로를 고르고 Export With Debug를 켭니다. 완료 후 출력 파일과 오류 로그를 확인합니다.
org.example.taplab은 학습용 예시 패키지 이름입니다. 출시 프로젝트에서는 자신의 고유 이름을 정하세요. 이 프리셋은 arm64용이며 x86 에뮬레이터 지원을 검증하지 않았습니다. 릴리스 서명 키와 비밀번호는 ZIP에 포함하지 않습니다.
9. 휴대폰에서 이어서 확인할 것
이번 작업에서는 실제 폰에 설치하지 않았습니다. 본인 기기에 APK를 설치한 뒤 표적 한 번에 1점, 시스템 뒤로가기, 홈 화면으로 나갔다 돌아오기, 화면 잠금, 긴 화면과 짧은 화면의 버튼 잘림을 확인하세요. 서명이 다른 기존 앱이 있으면 설치가 실패할 수 있고, 앱 삭제는 그 앱의 저장 데이터를 지울 수 있습니다.
APK 빌드 성공은 Google Play 출시 준비 완료가 아닙니다. 제출용 AAB, 업로드 키, 정책·스토어 정보는 Android 출시 가이드와 Play 체크리스트에서 이어집니다.
다음에는 무엇을 바꿔 볼까요
먼저 ROUND_MS를 10,000으로 바꾸고 실제 10초에 끝나는지 재보세요. 자동 검사의 대기 시간과 기대 조건도 함께 고쳐야 합니다. 다음에는 TARGET_SIZE를 바꾸고 작은 창에서 잘리는지 확인합니다. 마지막으로 목표 점수에 도달하면 즉시 끝나는 규칙을 추가하되, 시간 만료와 같은 종료 함수를 쓰면 재시작 처리가 갈라지지 않습니다.
변경할 값, 예상 결과, 실제 결과를 개발 워크북에 기록하세요. 안 된 경우에는 Godot 버전과 첫 오류 메시지, 어떤 단계에서 막혔는지를 문의로 알려 주세요.