2D Godot 2D 게임 기초
P4 화면 연출

17강게임필(Juice)

흔들림, 히트스톱, 파티클, 트윈.

이 강의 목차 (10)
  1. 같은 게임, 다른 재미
  2. 도구 — Tween
  3. 재료 1 — 스쿼시 & 스트레치
  4. 재료 2 — 히트 플래시
  5. 재료 3 — 히트스톱 (Hit Stop)
  6. 재료 4 — 화면 흔들림
  7. 재료 5 — 넉백과 파티클
  8. 재료 6 — 숫자 팝업
  9. 조합 — 하나의 타격에 전부 넣기
  10. 하나씩 얹으며 비교하기
이번 강의의 목표
  • 게임필(juice) 이 무엇이고 왜 효과가 있는지 안다.
  • Tween으로 스쿼시·플래시·팝업을 만든다.
  • 히트스톱화면 흔들림을 적절한 양으로 넣는다.

같은 게임, 다른 재미#

두 게임이 있습니다. 코드는 똑같습니다. 적을 때리면 체력이 깎이고 죽습니다.

A — 적이 조용히 사라진다. B — 때리는 순간 화면이 0.05초 멈추고, 적이 하얗게 번쩍이고, 뒤로 밀리며, 파편이 튀고, 화면이 살짝 흔들리고, 점수가 튀어오른다.

B가 압도적으로 재미있습니다. 게임 규칙은 한 줄도 안 바뀌었는데요.

게임필(Game Feel / Juice)

플레이어의 행동에 과장된 즉각 반응을 돌려주는 것. 게임 규칙이 아니라 감각을 만드는 층입니다.

핵심은 "정보"가 아니라 "내가 뭔가 해냈다는 느낌" 을 주는 데 있습니다.

좋은 스위치

비싼 기계의 스위치는 누르면 딸깍 소리가 나고 살짝 걸리는 저항이 있습니다. 전기적으로는 아무 의미 없는 감촉이지만, 그게 없으면 고장 난 것 같습니다.

게임필은 그 딸깍 소리입니다.

도구 — Tween#

Tween은 "어떤 값을 A에서 B로 시간에 걸쳐 바꾸는" 도구입니다. 17강의 거의 모든 것이 Tween 하나로 만들어집니다.

gdscript
var tw := create_tween()
tw.tween_property(self, "scale", Vector2(1.4, 0.7), 0.08)
tw.tween_property(self, "scale", Vector2.ONE, 0.16)

기본은 순차 실행입니다. 위 코드는 0.08초 동안 납작해졌다가, 0.16초 동안 돌아옵니다.

메서드 하는 일
tween_property(대상, "속성", 목표, 시간) 속성을 변화시킴
tween_interval(초) 그냥 기다림
tween_callback(함수) 함수 호출
set_parallel(true) 이후 것들을 동시에
set_loops(n) 반복
set_ease() / set_trans() 가감속 곡선
chain() 병렬 구간을 끊고 다시 순차로
이징(Easing) — 움직임의 성격

같은 거리를 같은 시간에 이동해도 속도 곡선에 따라 느낌이 완전히 다릅니다.

조합 느낌 어디에
EASE_OUT + TRANS_CUBIC 빠르게 시작 → 부드럽게 도착 UI 등장. 가장 많이 씀
EASE_OUT + TRANS_BACK 목표를 살짝 지나쳤다 돌아옴 팝업, 도장 찍기
EASE_OUT + TRANS_ELASTIC 통통 튐 만화적 연출
EASE_IN + TRANS_CUBIC 천천히 시작 → 빠르게 퇴장, 낙하
TRANS_LINEAR 일정 속도 기계적. 대개 부자연스러움

기본값이 LINEAR라서 어색합니다. 대부분은 EASE_OUT + CUBIC으로 바꾸는 것만으로 훨씬 좋아집니다.

gdscript
var tw := create_tween()
tw.set_ease(Tween.EASE_OUT)
tw.set_trans(Tween.TRANS_BACK)
tw.tween_property(popup, "scale", Vector2.ONE, 0.25)

재료 1 — 스쿼시 & 스트레치#

물체가 눌리고 늘어나는 것. 애니메이션의 12원칙 중 가장 강력한 하나입니다.

gdscript
func squash(amount := 0.3, time := 0.12) -> void:
	var s := Vector2(1.0 + amount, 1.0 - amount)
	var tw := create_tween()
	tw.set_ease(Tween.EASE_OUT)
	tw.set_trans(Tween.TRANS_CUBIC)
	tw.tween_property(sprite, "scale", s, time * 0.35)
	tw.tween_property(sprite, "scale", Vector2.ONE, time)
언제 방향
착지 가로로 넓게, 세로로 납작
점프 시작 세로로 길게, 가로로 좁게
피격 짧고 강하게
아이템 획득 커졌다 사라짐
스케일은 스프라이트에만 준다

루트(CharacterBody2D)의 scale을 바꾸면 콜라이더도 같이 변형됩니다(5강). 물리 판정이 이상해지고 경고가 뜹니다.

반드시 Sprite2D 자식 노드의 scale만 조정하세요.

재료 2 — 히트 플래시#

맞은 순간 하얗게 번쩍입니다. 가장 싸고 효과가 큰 연출입니다.

가장 쉬운 방법은 modulate를 밝게 만드는 것입니다.

gdscript
func flash() -> void:
	var tw := create_tween()
	sprite.modulate = Color(6, 6, 6)  # 1을 넘으면 과노출
	tw.tween_property(sprite, "modulate",
		Color.WHITE, 0.12)

더 제대로 하려면 셰이더를 씁니다.

text
// hit_flash.gdshader
shader_type canvas_item;
uniform float amount : hint_range(0.0, 1.0) = 0.0;
uniform vec4 flash_color : source_color = vec4(1.0);

void fragment() {
	vec4 c = texture(TEXTURE, UV);
	// 알파는 유지하고 색만 흰색 쪽으로 섞는다.
	COLOR = vec4(mix(c.rgb, flash_color.rgb, amount), c.a);
}
gdscript
func flash() -> void:
	var m: ShaderMaterial = sprite.material
	m.set_shader_parameter("amount", 1.0)
	var tw := create_tween()
	tw.tween_method(
		func(v): m.set_shader_parameter("amount", v),
		1.0, 0.0, 0.15)
modulate 방식과 셰이더 방식의 차이

modulate는 색을 곱합니다. 검은 부분은 아무리 곱해도 검은색이라 안 밝아집니다. 셰이더는 색을 섞으므로 어두운 캐릭터도 확실히 하얘집니다.

간단한 게임은 modulate로 충분하고, 캐릭터 색이 어두우면 셰이더를 쓰세요.

재료 3 — 히트스톱 (Hit Stop)#

때리는 순간 게임 전체를 아주 짧게 멈춥니다. 격투 게임의 핵심 기술입니다.

gdscript
func hit_stop(duration := 0.06, scale := 0.05) -> void:
	Engine.time_scale = scale
	# 시간이 느려졌으므로 타이머도 느려진다.
	# 실제 시간으로 재려면 보정한다.
	await get_tree().create_timer(
		duration * scale, true, false, true).timeout
	Engine.time_scale = 1.0
히트스톱은 적을수록 좋다
시간 느낌
0.02~0.04초 기본 타격. 눈치채지 못하지만 묵직해짐
0.06~0.10초 강타격, 크리티컬
0.15초 이상 끊겨 보임. 렉으로 오해받음

"더 넣으면 더 좋겠지"가 통하지 않는 대표적인 연출입니다.

Engine.time_scale의 부작용

전역 시간 배율이라 모든 것이 느려집니다. UI 애니메이션, 사운드 타이밍, 파티클까지. 그래서 실무에서는 이런 대안을 씁니다.

  1. 부분 정지 — 때린 쪽과 맞은 쪽의 애니메이션만 AnimationPlayer.speed_scale = 0
  2. 프레임 스킵 — 물리만 몇 프레임 멈추고 렌더는 유지
  3. Timerprocess_always 를 켜서 정지 중에도 도는 타이머 확보

간단한 게임에선 time_scale로 충분합니다.

재료 4 — 화면 흔들림#

14강에서 만든 카메라의 shake() 를 씁니다. 여기서는 양의 기준만 정리합니다.

상황 세기 (픽셀)
약한 타격 2~3
강한 타격 5~8
폭발 12~20
보스 등장 20~30 (길게)
흔들림은 접근성 문제이기도 하다

화면 흔들림은 일부 플레이어에게 실제로 멀미와 두통을 유발합니다. 상용 게임은 대부분 옵션에 "화면 흔들림 강도" 슬라이더를 둡니다.

gdscript
# 설정값을 곱해서 0%까지 끌 수 있게
camera.shake(amount * Settings.shake_scale)

재료 5 — 넉백과 파티클#

gdscript
func apply_knockback(from: Vector2, force := 260.0) -> void:
	var dir := (global_position - from).normalized()
	velocity = dir * force
	# 상태 머신(16강)에서 HURT 상태로
	change(State.HURT)

파티클은 GPUParticles2D 를 씁니다. 핵심 설정 몇 가지만:

항목 의미
Amount 개수
One Shot 한 번만 뿜고 끝 (타격 이펙트는 켜기)
Explosiveness 1.0이면 전부 동시에 (폭발)
Lifetime 입자 수명
Spread 퍼지는 각도
Gravity 아래로 떨어지게
Scale Curve 시간에 따라 작아지게
파티클은 자기 자신을 지우게 만든다

One Shot 파티클을 코드로 만들면 다 뿜고도 노드가 남습니다.

gdscript
p.emitting = true
p.finished.connect(p.queue_free)

재료 6 — 숫자 팝업#

데미지 숫자가 튀어오르는 연출입니다. 만들기 쉽고 효과가 큽니다.

gdscript
extends Label

func pop(amount: int, crit := false) -> void:
	text = str(amount)
	modulate = Color(1, 0.85, 0.2) if crit else Color.WHITE
	scale = Vector2.ONE * (1.6 if crit else 1.0)

	var up := Vector2(randf_range(-18, 18), -46)
	var tw := create_tween()
	tw.set_parallel(true)
	tw.set_ease(Tween.EASE_OUT)
	tw.set_trans(Tween.TRANS_CUBIC)
	tw.tween_property(self, "position",
		position + up, 0.55)
	tw.tween_property(self, "scale", Vector2.ONE, 0.2)
	tw.chain().tween_property(self, "modulate:a", 0.0, 0.25)
	tw.tween_callback(queue_free)

조합 — 하나의 타격에 전부 넣기#

gdscript
func on_hit(damage: int, from: Vector2, crit: bool) -> void:
	# 1. 즉시 (0초)
	flash()
	spawn_particles(from)
	spawn_damage_number(damage, crit)
	SoundManager.play("hit")

	# 2. 아주 짧게 (0.03~0.08초)
	hit_stop(0.08 if crit else 0.04)
	camera.shake(8.0 if crit else 3.0)

	# 3. 짧게 (0.1~0.3초)
	squash(0.35, 0.15)
	apply_knockback(from, 300.0 if crit else 160.0)
순서와 시간대가 중요하다

좋은 타격감은 여러 반응이 서로 다른 시간대에 겹치는 것입니다.

text
0.00s  ████ 플래시, 파티클, 사운드   ← 즉각
0.04s  ██   히트스톱                ← 아주 짧게
0.06s  ███  화면 흔들림
0.15s  ████ 스쿼시 복귀, 넉백
0.55s  ██   데미지 숫자 사라짐       ← 여운

전부 동시에 시작해서 동시에 끝나면 한 덩어리로 뭉쳐 오히려 밋밋합니다.

하나씩 얹으며 비교하기#

이번 강의의 핵심 실습

아무 연출 없는 상태에서 시작해 하나씩 추가하며 매번 플레이해 보세요. 그리고 각 단계에서 "얼마나 좋아졌는지" 1~5점을 매기세요.

  1. 아무것도 없음
    • 사운드
    • 히트 플래시
    • 파티클
    • 넉백
    • 히트스톱
    • 화면 흔들림
    • 데미지 숫자
    • 스쿼시

대부분의 사람이 2번(사운드)과 6번(히트스톱)에서 가장 큰 점수 상승을 겪습니다. 가장 싸고 효과가 큰 것이 무엇인지 직접 확인하는 게 이 실습의 목적입니다.

그리고 반대로 해보기

전부 넣은 상태에서 하나씩 빼면서 다시 해보세요. 빼도 티가 안 나는 게 있습니까? 그건 비용만 쓰고 있는 연출입니다.

  • 개발자 — 연출은 게임 로직과 완전히 분리하세요. on_hit() 안에서 데미지 계산을 같이 하면, 연출을 끄면 게임이 안 돕니다. 판정은 판정대로 끝내고, 시그널로 연출을 부르는 구조가 안전합니다.
  • 기획자 — "타격감이 약해요"는 대개 히트스톱과 사운드의 문제입니다. 연출 스펙을 쓸 때 위의 시간대 표처럼 0.0s / 0.05s / 0.15s에 각각 무엇이 일어나는지 나눠 적으면 개발자가 바로 구현합니다.
  • 디자이너 — 이펙트는 짧을수록 좋습니다. 0.15초짜리 번쩍임이 0.5초짜리 정교한 애니메이션보다 타격감이 좋은 경우가 많습니다. 또 이펙트 색은 배경과 확실히 대비되어야 눈에 들어옵니다.
자주 나는 오류

"Tween이 중간에 끊긴다" → 같은 노드에 create_tween()을 여러 번 부르면 이전 것이 계속 살아있어 서로 싸웁니다. 기존 것을 죽이고 새로 만드세요.

gdscript
if _tw and _tw.is_valid():
	_tw.kill()
_tw = create_tween()

"노드가 사라진 뒤에도 Tween이 돌아 오류가 난다"create_tween() 은 노드에 묶이므로 노드가 죽으면 같이 죽습니다. SceneTree에 직접 만든 Tween은 안 그렇습니다. 노드 메서드를 쓰세요.

"히트스톱을 넣었더니 게임이 아예 멈춘다"Engine.time_scale = 0 이면 타이머도 멈춰 영원히 안 돌아옵니다. 0.05처럼 0보다 큰 값을 쓰거나, process_always 타이머를 쓰세요.

"스케일을 줬더니 물리가 이상하다" → 루트가 아니라 스프라이트에 주세요.

여기까지 확인
  • Tween으로 스쿼시와 팝업을 만들었다
  • EASE_OUT + CUBIC이 기본값보다 자연스럽다는 걸 확인했다
  • 히트스톱을 0.04초 넣어봤다
  • 연출을 하나씩 얹으며 점수를 매겨봤다
  • 연출과 게임 로직이 분리되어 있다

P4 정리 — 이제 "게임처럼 보입니다." 여기까지가 강좌의 중간 하이라이트입니다.

다음 강의 — P5로 갑니다. 혼자 만들던 것을 팀이 양산할 수 있는 구조로 바꿉니다. UI부터 시작합니다.