(10/10) 2D SRPG 만들기 선행학습 — 연출, 손맛을 만드는 잔기술
Summary
시스템이 같아도 손맛이 다른 게임들이 있어요. 공격이 명중하는 순간 화면이 움찔하고, 크리티컬 때 시간이 잠깐 멈추고, 전투가 끝나면 화면이 부드럽게 어두워지는 — 게임 개발자들이 juice 또는 game feel 이라고 부르는 잔기술들 덕분입니다. 파랜드 택틱스급 고전들도 타격 순간의 번쩍임과 멈칫거림을 빠짐없이 챙겼어요. 이게 없으면 전투가 엑셀 시트처럼 건조해집니다.
좋은 소식은, 연출이 이 시리즈에서 새로 배울 게 가장 적은 편이라는 거예요. 전부 1편 델타타임과 8편 “수명 있는 상태” 패턴의 응용이거든요. 오늘은 화면 흔들림·히트스톱·이징·페이드라는 4대 잔기술을 만들고, 열 편의 선행학습을 마무리합니다.
💡 이 글에서 다루는 것
- 연출의 공통 원리 — 수명 있는 상태 + 델타타임, 그리고 로직 불가침 원칙
- 화면 흔들림 — trauma 제곱 기법으로 자연스러운 감쇠 만들기
- 히트스톱 — 타격 순간 세상을 잠깐 멈추는 기술
- 이징 곡선 — 기계적인 움직임을 생물처럼 만드는 수학 한 줄
- HP 바 lerp — 체력이 스르륵 줄어드는 그 연출
- 페이드 전환 — 전투 시작과 끝을 부드럽게 잇기
1. 연출의 공통 원리 — 로직은 건드리지 않는다
잔기술들을 만들기 전에 대원칙 하나를 세우고 갑시다. 연출은 게임 상태를 읽기만 하고, 절대 바꾸지 않습니다. 화면이 흔들려도 유닛의 그리드 좌표는 그대로고, HP 바가 천천히 줄어도 실제 HP 는 이미 깎여 있어요. 연출이 로직에 손을 대는 순간 — 예를 들어 흔들림 오프셋이 클릭 판정에 섞이면 — 디버깅 지옥이 열립니다.
구조적으로는 8편 데미지 팝업에서 쓴 패턴의 반복이에요.
연출 하나 = { 세기(또는 진행도), 남은 수명 }
매 프레임 : 수명 -= dt, 세기를 수명에 비례해 줄이기
그릴 때 : 세기만큼 화면에 반영 (오프셋/투명도/배율)
수명이 다하면: 목록에서 제거 — 흔적 없이 사라짐
이 패턴 하나로 오늘의 네 가지가 전부 만들어집니다. 새 자료구조도, 새 라이브러리도 없어요.
2. 화면 흔들림 — trauma 는 제곱으로
타격 순간 화면 전체가 움찔하는 화면 흔들림(screen shake) 부터. 나이브한 구현은 “N 프레임 동안 랜덤 오프셋”인데, 이러면 흔들림이 뚝 끊겨서 어색해요. 업계에서 정착된 요령은 trauma(충격량) 변수를 두고, 실제 진폭은 그 제곱을 쓰는 것입니다.
import random
trauma = 0.0 # 0~1. 타격 시 += 0.4~0.6, 크리티컬 += 1.0
def update_shake(dt):
global trauma
trauma = max(0.0, trauma - 2.5 * dt) # 초당 2.5 씩 감쇠
amp = 12 * trauma ** 2 # 제곱이 핵심
return random.uniform(-amp, amp), random.uniform(-amp, amp)
왜 제곱이냐면, 감쇠가 처음엔 크게, 끝엔 아주 잔잔하게 떨어져서 여운이 자연스러워지기 때문이에요. trauma 가 절반으로 줄면 진폭은 4분의 1이 됩니다. 감쇠 과정을 찍어보면:
random.seed(3)
trauma = 1.0
for frame in range(4):
amp = 12 * trauma ** 2
dx, dy = random.uniform(-amp, amp), random.uniform(-amp, amp)
print(f"trauma {trauma:.2f} → 진폭 {amp:4.1f}px, 오프셋 ({dx:+5.1f}, {dy:+5.1f})")
trauma = max(0.0, trauma - 0.25)
trauma 1.00 → 진폭 12.0px, 오프셋 ( -6.3, +1.1)
trauma 0.75 → 진폭 6.8px, 오프셋 ( -1.8, +1.4)
trauma 0.50 → 진폭 3.0px, 오프셋 ( +0.8, -2.6)
trauma 0.25 → 진폭 0.8px, 오프셋 ( -0.7, +0.5)
12픽셀로 시작한 진폭이 6.8 → 3.0 → 0.8 로 잦아들죠. 적용은 렌더링에만 합니다 — 2편 카메라와 똑같이, 그릴 때의 오프셋으로만 더해요.
shake_x, shake_y = update_shake(dt)
# grid_to_screen 결과에 shake_x, shake_y 를 더해 그리기 (클릭 판정에는 절대 안 섞음)
⚠️ 1장의 원칙이 바로 여기서 작동합니다.
screen_to_grid(클릭 판정)에는 shake 오프셋을 넣지 마세요. 흔들리는 동안 클릭이 옆 칸으로 새는 버그가 생깁니다. 흔들림은 눈속임일 뿐, 세상은 제자리에 있어야 해요.
3. 히트스톱 — 잠깐 멈추면 무거워진다
격투 게임과 액션 게임의 오랜 비밀인데, 타격이 명중한 순간 게임을 3~6프레임 멈추면 타격이 훨씬 무겁게 느껴집니다. 히트스톱(hit stop)이라고 불러요. 뇌가 “멈출 만큼 강한 충격”으로 해석하기 때문인데, 구현은 허무할 정도로 짧습니다.
freeze = 0.0 # 남은 정지 시간(초)
def add_hitstop(seconds=0.06): # 크리티컬이면 0.15 로
global freeze
freeze = max(freeze, seconds)
# 게임 루프 맨 앞에서
dt = clock.tick(60) / 1000
if freeze > 0:
freeze -= dt
dt = 0 # 이번 프레임은 세상이 멈춤
dt = 0 한 줄이 전부입니다. 이동 보간도, 애니메이션도, 팝업도 전부 dt 기반이라(1편부터 지켜온 그 원칙!) dt 를 0으로 만들면 세상 전체가 한 번에 얼어요. dt 원칙을 안 지킨 코드가 하나라도 있으면 그것만 움직여서 들통나니, 히트스톱은 dt 규율의 리트머스 시험지이기도 합니다.
💡 타격음(9편) → 히트스톱 → 화면 흔들림 → 데미지 팝업이 한 순간에 겹치면 그게 바로 “손맛”입니다. 각각은 별것 아닌데 겹치는 순간 체감이 곱해져요. 전부
try_attack확정 지점 한 곳에 걸면 됩니다.
4. 이징 — 기계를 생물로 만드는 수학 한 줄
지금 우리 게임의 움직임(4편 경로 보간, 커서 등)은 전부 등속이라 기계적입니다. 현실의 움직임은 빠르게 출발해서 부드럽게 도착하죠. 이걸 흉내내는 게 이징(easing) 곡선이에요. 진행도 t(0~1)를 넣으면 보정된 진행도가 나오는 함수 하나면 됩니다.
def ease_out(t):
return 1 - (1 - t) ** 3 # ease-out cubic
for t in (0, 0.25, 0.5, 0.75, 1):
print(f"t={t} → {ease_out(t):.3f}")
t=0 → 0.000
t=0.25 → 0.578
t=0.5 → 0.875
t=0.75 → 0.984
t=1 → 1.000
시간이 4분의 1 지났을 뿐인데 거리는 이미 58% 를 왔고, 나머지 시간에 마지막 42% 를 서서히 채웁니다 — 이게 “휙 갔다가 사뿐히 멈추는” 움직임의 정체예요. 시작 위치 A 에서 목표 B 로 가는 모든 것에 끼울 수 있습니다.
progress = min(1, elapsed / duration) # 0~1 진행도
pos = A + (B - A) * ease_out(progress) # 메뉴 슬라이드, 커서 점프, 카메라 이동
목표가 계속 바뀌는 값(HP 바)에는 시작/끝이 없는 lerp 추적 방식이 더 잘 맞아요. 매 프레임 “남은 거리의 일정 비율”만큼 따라가는 겁니다. 8편 정보 패널의 HP 를 24에서 9로 깎아보면:
shown, actual = 24.0, 9 # 표시값 / 실제값 (실제는 이미 깎임)
for i in range(6):
shown += (actual - shown) * 0.15
print(round(shown, 1))
21.8
19.8
18.2
16.8
15.7
14.7
표시 HP 가 프레임마다 남은 차이의 15% 씩 실제값을 쫓아갑니다. 처음엔 훅 줄다가 목표에 가까워질수록 느려지는, 게임에서 늘 보던 그 HP 바예요. 실제 HP(actual)는 이미 깎여 있고 표시값만 연출이라는 점 — 역시 1장의 원칙입니다.
⚠️ 위 lerp 는 프레임 기준이라 fps 가 다르면 속도가 달라져요. 정석은 dt 보정을 넣는 것:
shown += (actual - shown) * (1 - 0.001 ** dt). 60fps 기준 감각을 유지하면서 프레임레이트 독립이 됩니다.
5. 페이드 — 장면과 장면을 잇는 검은 커튼
마지막 잔기술은 화면 전환용 페이드입니다. 전투 시작 때 어둠에서 밝아지고(fade in), 승리하면 서서히 어두워지며(fade out) 결과 화면으로 — 8편 UI 의 반투명 Surface 를 화면 전체로 키우면 끝이에요.
fade_alpha, fade_speed = 255, -300 # 255→0 으로 밝아지는 중 (fade in)
def update_and_draw_fade(screen, dt):
global fade_alpha
fade_alpha = max(0, min(255, fade_alpha + fade_speed * dt))
if fade_alpha > 0:
veil = pygame.Surface((960, 540))
veil.set_alpha(int(fade_alpha))
veil.fill((0, 0, 0))
screen.blit(veil, (0, 0)) # 항상 맨 마지막에 — 모든 것 위에 덮는 커튼
fade_speed 부호가 방향입니다. 음수면 밝아지고, 양수로 바꾸면 어두워져요. 5편 상태머신과 묶으면 장면 전환이 완성됩니다 — VICTORY 진입 시 fade_speed = +300 으로 걸고, fade_alpha 가 255에 닿으면 결과 화면 상태로 전이. 9편의 music.fadeout(1500) 을 같은 지점에 걸면 화면과 음악이 함께 잦아드는 고전 게임의 그 마무리가 나옵니다.
6. 실습 — 전투에 손맛 얹기
이번에도 새 시스템은 없고, 기존 확정 지점에 연출 훅을 거는 조립도입니다.
# update_shake / add_hitstop / ease_out / update_and_draw_fade ← 본문 그대로
# 8·9편의 전투/UI/사운드 코드 전부 ← 그대로
while running:
dt = clock.tick(60) / 1000
if freeze > 0: # ① 히트스톱 — 루프 맨 앞
freeze -= dt
dt = 0
# ... 입력/업데이트 (8편 그대로) ...
# TARGETING 공격 확정 지점에 한 줄씩 추가:
# SFX["hit"].play() # 9편
# add_popup(f"-{dmg}", ...) # 8편
# trauma += 1.0 if crit else 0.5 # ② 흔들림
# add_hitstop(0.15 if crit else 0.06) # ③ 히트스톱
shake_x, shake_y = update_shake(dt) # ④ 렌더 오프셋에만 반영
# ... 맵/유닛/UI 그리기 (전부 shake 오프셋 적용) ...
update_and_draw_fade(screen, dt) # ⑤ 페이드는 항상 맨 위
pygame.display.flip()
크리티컬이 터지는 순간을 상상해보세요 — “쿵” 소리와 함께 세상이 0.15초 멈추고, 풀리면서 화면이 크게 흔들리고, 노란 데미지 숫자가 떠오르고, HP 바가 스르륵 깎입니다. 5편에서 콘솔에 print 한 줄 찍던 그 공격이요. 시스템은 하나도 안 바뀌었는데, 게임이 완전히 다른 물건이 됐어요.
7. 정리 — 열 편의 선행학습을 마치며
- 연출 = 수명 있는 상태 + 델타타임, 그리고 로직 불가침 — 읽기만, 쓰기 금지
- 화면 흔들림은 trauma 제곱 — 감쇠의 여운이 자연스러움을 만든다
- 히트스톱은
dt = 0한 줄 — dt 규율을 지킨 코드만 누리는 보상 - 이징 한 줄이 기계를 생물로 — 고정 구간은 ease_out, 추적은 lerp
- 표시값과 실제값 분리 — HP 바 연출의 정체
- 페이드는 모든 것 위의 검은 커튼 — 상태머신·BGM 페이드와 한 지점에서
이것으로 열 편의 선행학습이 끝났습니다. 1편에서 빈 창 하나 띄우고 시작했는데, 지금 우리 손에는 마름모 전장 위에서 유닛이 걷고, 파란 칸이 펼쳐지고, 메뉴로 행동을 고르고, 명중률을 확인하고 공격하면 화면이 흔들리며 소리가 터지는 — 에디터로 맵을 그리고 JSON 으로 밸런스를 만지고 세이브까지 되는 게임의 부품 전체가 있어요. 파랜드 택틱스를 향한 기초 체력은 이제 충분합니다. 남은 배포(pyinstaller)와 엔진 이전(Godot)은 6편 로드맵의 소개로 갈음할게요 — 부품이 아니라 포장이니까요.
마지막 당부는 처음과 같습니다. 맵 하나, 아군 셋, 적 다섯, 승리 조건 하나. 그 1챕터를 끝까지 완성하는 순간, 여러분은 더 이상 선행학습이 필요한 사람이 아니에요.
일단 이 시리즈는 여기까지….. 다음에는 또 다른 만들기 주제로 돌아올게요.