17강게임필(Juice)
흔들림, 히트스톱, 파티클, 트윈.
이 강의 목차 (10)
- 게임필(juice) 이 무엇이고 왜 효과가 있는지 안다.
- Tween으로 스쿼시·플래시·팝업을 만든다.
- 히트스톱과 화면 흔들림을 적절한 양으로 넣는다.
같은 게임, 다른 재미#
두 게임이 있습니다. 코드는 똑같습니다. 적을 때리면 체력이 깎이고 죽습니다.
A — 적이 조용히 사라진다. B — 때리는 순간 화면이 0.05초 멈추고, 적이 하얗게 번쩍이고, 뒤로 밀리며, 파편이 튀고, 화면이 살짝 흔들리고, 점수가 튀어오른다.
B가 압도적으로 재미있습니다. 게임 규칙은 한 줄도 안 바뀌었는데요.
플레이어의 행동에 과장된 즉각 반응을 돌려주는 것. 게임 규칙이 아니라 감각을 만드는 층입니다.
핵심은 "정보"가 아니라 "내가 뭔가 해냈다는 느낌" 을 주는 데 있습니다.
비싼 기계의 스위치는 누르면 딸깍 소리가 나고 살짝 걸리는 저항이 있습니다. 전기적으로는 아무 의미 없는 감촉이지만, 그게 없으면 고장 난 것 같습니다.
게임필은 그 딸깍 소리입니다.
도구 — Tween#
Tween은 "어떤 값을 A에서 B로 시간에 걸쳐 바꾸는" 도구입니다. 17강의 거의 모든 것이 Tween 하나로 만들어집니다.
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() |
병렬 구간을 끊고 다시 순차로 |
같은 거리를 같은 시간에 이동해도 속도 곡선에 따라 느낌이 완전히 다릅니다.
| 조합 | 느낌 | 어디에 |
|---|---|---|
EASE_OUT + TRANS_CUBIC |
빠르게 시작 → 부드럽게 도착 | UI 등장. 가장 많이 씀 |
EASE_OUT + TRANS_BACK |
목표를 살짝 지나쳤다 돌아옴 | 팝업, 도장 찍기 |
EASE_OUT + TRANS_ELASTIC |
통통 튐 | 만화적 연출 |
EASE_IN + TRANS_CUBIC |
천천히 시작 → 빠르게 | 퇴장, 낙하 |
TRANS_LINEAR |
일정 속도 | 기계적. 대개 부자연스러움 |
기본값이 LINEAR라서 어색합니다. 대부분은 EASE_OUT + CUBIC으로 바꾸는 것만으로
훨씬 좋아집니다.
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원칙 중 가장 강력한 하나입니다.
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를 밝게 만드는 것입니다.
func flash() -> void:
var tw := create_tween()
sprite.modulate = Color(6, 6, 6) # 1을 넘으면 과노출
tw.tween_property(sprite, "modulate",
Color.WHITE, 0.12)더 제대로 하려면 셰이더를 씁니다.
// 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);
}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로 충분하고, 캐릭터 색이 어두우면 셰이더를 쓰세요.
재료 3 — 히트스톱 (Hit Stop)#
때리는 순간 게임 전체를 아주 짧게 멈춥니다. 격투 게임의 핵심 기술입니다.
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 애니메이션, 사운드 타이밍, 파티클까지. 그래서 실무에서는 이런 대안을 씁니다.
- 부분 정지 — 때린 쪽과 맞은 쪽의 애니메이션만
AnimationPlayer.speed_scale = 0 - 프레임 스킵 — 물리만 몇 프레임 멈추고 렌더는 유지
Timer의process_always를 켜서 정지 중에도 도는 타이머 확보
간단한 게임에선 time_scale로 충분합니다.
재료 4 — 화면 흔들림#
14강에서 만든 카메라의 shake() 를 씁니다. 여기서는 양의 기준만 정리합니다.
| 상황 | 세기 (픽셀) |
|---|---|
| 약한 타격 | 2~3 |
| 강한 타격 | 5~8 |
| 폭발 | 12~20 |
| 보스 등장 | 20~30 (길게) |
화면 흔들림은 일부 플레이어에게 실제로 멀미와 두통을 유발합니다. 상용 게임은 대부분 옵션에 "화면 흔들림 강도" 슬라이더를 둡니다.
# 설정값을 곱해서 0%까지 끌 수 있게
camera.shake(amount * Settings.shake_scale)재료 5 — 넉백과 파티클#
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 파티클을 코드로 만들면 다 뿜고도 노드가 남습니다.
p.emitting = true
p.finished.connect(p.queue_free)재료 6 — 숫자 팝업#
데미지 숫자가 튀어오르는 연출입니다. 만들기 쉽고 효과가 큽니다.
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)조합 — 하나의 타격에 전부 넣기#
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)좋은 타격감은 여러 반응이 서로 다른 시간대에 겹치는 것입니다.
0.00s ████ 플래시, 파티클, 사운드 ← 즉각
0.04s ██ 히트스톱 ← 아주 짧게
0.06s ███ 화면 흔들림
0.15s ████ 스쿼시 복귀, 넉백
0.55s ██ 데미지 숫자 사라짐 ← 여운전부 동시에 시작해서 동시에 끝나면 한 덩어리로 뭉쳐 오히려 밋밋합니다.
하나씩 얹으며 비교하기#
아무 연출 없는 상태에서 시작해 하나씩 추가하며 매번 플레이해 보세요. 그리고 각 단계에서 "얼마나 좋아졌는지" 1~5점을 매기세요.
- 아무것도 없음
- 사운드
- 히트 플래시
- 파티클
- 넉백
- 히트스톱
- 화면 흔들림
- 데미지 숫자
- 스쿼시
대부분의 사람이 2번(사운드)과 6번(히트스톱)에서 가장 큰 점수 상승을 겪습니다. 가장 싸고 효과가 큰 것이 무엇인지 직접 확인하는 게 이 실습의 목적입니다.
전부 넣은 상태에서 하나씩 빼면서 다시 해보세요. 빼도 티가 안 나는 게 있습니까? 그건 비용만 쓰고 있는 연출입니다.
- 개발자 — 연출은 게임 로직과 완전히 분리하세요.
on_hit()안에서 데미지 계산을 같이 하면, 연출을 끄면 게임이 안 돕니다. 판정은 판정대로 끝내고, 시그널로 연출을 부르는 구조가 안전합니다. - 기획자 — "타격감이 약해요"는 대개 히트스톱과 사운드의 문제입니다. 연출 스펙을 쓸 때 위의 시간대 표처럼 0.0s / 0.05s / 0.15s에 각각 무엇이 일어나는지 나눠 적으면 개발자가 바로 구현합니다.
- 디자이너 — 이펙트는 짧을수록 좋습니다. 0.15초짜리 번쩍임이 0.5초짜리 정교한 애니메이션보다 타격감이 좋은 경우가 많습니다. 또 이펙트 색은 배경과 확실히 대비되어야 눈에 들어옵니다.
"Tween이 중간에 끊긴다"
→ 같은 노드에 create_tween()을 여러 번 부르면 이전 것이 계속 살아있어 서로 싸웁니다.
기존 것을 죽이고 새로 만드세요.
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부터 시작합니다.