7 분 소요

🎮 2D SRPG 만들기 선행학습 6부작 — 파랜드 택틱스 같은 쿼터뷰 SRPG 를 직접 만들기 위한 기초 체력을 쌓습니다. 게임 루프부터 세이브/로드, 맵 에디터, UI, 사운드, 연출까지 편마다 실행되는 코드로 하나씩. 전체 10편.
  1. 게임 루프와 델타타임 — 모든 게임의 심장
  2. 타일맵과 쿼터뷰 좌표계 — 마름모 세상의 수학
  3. 스프라이트와 애니메이션 — 캐릭터에 생명 불어넣기
  4. 이동 범위와 A* 길찾기 — SRPG 의 파란 칸
  5. 턴제 전투와 상태머신 — 턴 큐·데미지·적 AI
  6. 데이터 설계와 세이브/로드 — 게임을 콘텐츠로 채우기
  7. Tiled 맵 에디터 — 배열 손편집 졸업하기
  8. UI 와 메뉴 — 정보를 보여주는 기술
  9. 사운드 — 타격감은 귀에서 나온다
  10. 연출 — 손맛을 만드는 잔기술지금 글

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챕터를 끝까지 완성하는 순간, 여러분은 더 이상 선행학습이 필요한 사람이 아니에요.

일단 이 시리즈는 여기까지….. 다음에는 또 다른 만들기 주제로 돌아올게요.


← 이전 글: (9/10) 2D SRPG 만들기 선행학습 — 사운드, 타격감은 귀에서 나온다