(9/10) 2D SRPG 만들기 선행학습 — 사운드, 타격감은 귀에서 나온다
Summary
8편까지 만든 게임을 플레이해보면 화면은 제법 그럴듯한데 뭔가 허전합니다. 소리가 없어요. 공격이 명중해도, 메뉴를 눌러도, 턴이 바뀌어도 완전한 무음이죠. 게임의 체감에서 소리가 차지하는 비중은 생각보다 커서, 잘 만든 타격음 하나가 화면 연출보다 손맛을 더 크게 바꿉니다. 파랜드 택틱스의 검격음과 마법 영창음을 기억하신다면 무슨 말인지 아실 거예요.
다행히 사운드는 이 시리즈에서 가장 배우기 쉬운 부품입니다. pygame 의 mixer 모듈이 거의 다 해주거든요. 오늘은 BGM 과 효과음의 구조 차이, 초보가 반드시 밟는 지연(latency) 함정, 그리고 시리즈 전통대로 에셋 없이 코드로 효과음을 만드는 법까지 다룹니다.
💡 이 글에서 다루는 것
- 소리의 두 종류 — 스트리밍되는 BGM 과 메모리에 올리는 효과음
- 믹서 초기화 — 버퍼 크기가 만드는 “늦게 들리는 타격음” 함정
- 에셋 없이 효과음 만들기 — 표준 라이브러리
wave로 사인파 합성- 효과음 재생 원칙 — 소리는 상태 전이에 건다
- BGM — 무한 루프, 페이드아웃, 전투/승리 전환
- 무료 음원 구하는 곳 3곳 + 라이선스 확인 포인트
1. 소리의 두 종류 — BGM 과 효과음
pygame 의 오디오는 쓰임새에 따라 API 가 둘로 나뉘어 있습니다. 처음엔 헷갈리는데, 나뉜 이유를 알면 안 잊혀요.
BGM (pygame.mixer.music) |
효과음 (pygame.mixer.Sound) |
|
|---|---|---|
| 재생 방식 | 디스크에서 스트리밍 | 메모리에 통째로 로드 |
| 동시 재생 | 한 곡만 | 기본 8채널 동시 재생 |
| 쓰임새 | 전장 음악, 승리 팡파레 | 타격음, 메뉴 클릭음 |
| 길이 | 몇 분짜리 | 0.05~1초짜리 |
BGM 은 3분짜리 파일을 메모리에 다 올리면 낭비라 조금씩 읽으며 재생하고, 그래서 동시에 한 곡뿐입니다. 효과음은 반대로 짧은 파일을 메모리에 올려두고 지연 없이, 여러 개를 겹쳐 터뜨려요. 적 세 명이 연달아 맞으면 타격음 세 개가 겹쳐 나야 하니까요.
2. 믹서 초기화 — 버퍼가 만드는 지연 함정
믹서는 pygame.init() 이 알아서 켜주지만, 기본 설정에 함정이 하나 있습니다. 오디오 버퍼가 커서 소리가 늦게 나요. 공격 모션과 타격음이 어긋나는 “싱크 밀림”의 주범입니다. 해결은 pre_init 으로 버퍼를 줄이는 것 — 반드시 pygame.init() 전에 불러야 해서 이름이 pre 입니다.
import pygame
pygame.mixer.pre_init(44100, -16, 2, 512) # 샘플레이트, 16비트, 스테레오, 버퍼
pygame.init()
print(pygame.mixer.get_init(), "/ 채널", pygame.mixer.get_num_channels())
(44100, -16, 2) / 채널 8
버퍼 512 면 44100Hz 기준 약 12ms 지연이라 사람 귀에 즉각으로 느껴집니다. 기본값(환경에 따라 2048~4096)은 50~90ms 라 확실히 “늦게” 들려요.
⚠️ 버퍼를 무작정 줄이면(128 이하) 소리가 지글거리거나 끊길 수 있습니다. 512 가 게임용 표준 절충값이에요. 지글거림이 들리면 1024 로 올려보세요.
3. 에셋 없이 효과음 만들기 — 사인파 합성
효과음 파일이 없어도 시리즈 전통대로 코드로 만들 수 있습니다. 소리의 정체는 결국 초당 수만 개의 진폭 숫자라서, 사인파에 감쇠를 곱해 WAV 로 저장하면 그럴듯한 레트로 효과음이 나와요. 표준 라이브러리 wave 만 씁니다.
import wave, struct, math
def make_sound(path, freq=220, duration=0.15, volume=0.6, rate=22050):
n = int(rate * duration)
with wave.open(path, "w") as w:
w.setnchannels(1) # 모노
w.setsampwidth(2) # 16비트
w.setframerate(rate)
frames = bytearray()
for i in range(n):
t = i / rate
decay = math.exp(-t * 18) # 지수 감쇠 — 타격의 '탁' 느낌
sample = volume * decay * math.sin(2 * math.pi * freq * t)
frames += struct.pack("<h", int(sample * 32767))
w.writeframes(frames)
make_sound("hit.wav", freq=180) # 낮고 둔탁한 타격음
make_sound("select.wav", freq=880, duration=0.06, volume=0.3) # 높고 짧은 선택음
import os
print(os.path.getsize("hit.wav"), "bytes")
6658 bytes
7KB 도 안 되는 파일이지만 재생해보면 분명히 “탁” 소리가 납니다. 핵심은 두 파라미터예요. 주파수(freq) 가 낮으면 둔탁하고 높으면 쨍하며, 감쇠 속도(exp(-t * 18) 의 18)가 클수록 짧게 끊깁니다. 용도별 추천값을 정리하면:
| 용도 | freq | duration | 느낌 |
|---|---|---|---|
| 타격음 | 150~220 | 0.15 | 둔탁한 “탁” |
| 메뉴 선택음 | 700~1000 | 0.06 | 가벼운 “삑” |
| 크리티컬 | 100 + 300 두 번 겹치기 | 0.25 | 묵직한 “쿵” |
| 회복음 | 520 → 780 연속 재생 | 0.1 × 2 | 밝은 “띠링” |
진짜 게임용 효과음을 만들고 싶어지면 jsfxr 같은 브라우저 레트로 효과음 생성기를 추천드려요. 슬라이더를 굴리다 마음에 드는 소리를 WAV 로 내려받으면 됩니다.
4. 효과음 재생 — 소리는 상태 전이에 건다
만든 WAV 는 Sound 로 올려서 play() 하면 끝입니다.
hit = pygame.mixer.Sound("hit.wav")
print(round(hit.get_length(), 2), "초, 볼륨", hit.get_volume())
hit.set_volume(0.7)
print(hit.get_volume())
0.15 초, 볼륨 1.0
0.6953125
set_volume(0.7) 을 넣었는데 0.6953 이 나온 게 버그가 아니에요 — 내부적으로 볼륨이 128단계로 양자화돼서 가장 가까운 눈금에 앉습니다. 귀로는 구분 안 되니 신경 쓰지 않아도 됩니다.
재생보다 중요한 건 어디서 재생하느냐입니다. 원칙은 하나예요.
✅ 소리는 상태 전이와 이벤트 확정 순간에 건다. 5편 상태머신의 전이 지점과 8편의 확정 지점이 곧 사운드 훅입니다 — 메뉴가 열릴 때(
ACTION진입), 항목을 고를 때, 공격이 확정될 때(try_attack안), 턴이 바뀔 때. 반대로 매 프레임 도는 update/draw 안에서play()를 부르면 초당 60번 겹쳐 재생되는 소음 폭탄이 됩니다.
8편 코드 기준으로 연결 지점을 짚으면 이렇습니다.
SFX = {"hit": pygame.mixer.Sound("hit.wav"),
"select": pygame.mixer.Sound("select.wav")}
# 8편의 확정 지점들에 한 줄씩
# ACTION 메뉴 항목 클릭 시 → SFX["select"].play()
# TARGETING 공격 확정 시 → SFX["hit"].play() (add_popup 바로 옆)
# 턴 교대(ENEMY_TURN 진입) 시 → SFX["select"].play()
데미지 팝업(add_popup)과 타격음이 같은 줄에서 나가는 게 포인트예요. 눈과 귀가 같은 순간을 가리켜야 타격감이 삽니다.
5. BGM — 깔고, 반복하고, 페이드아웃
BGM 은 mixer.music 으로 다룹니다. API 가 단출해서 게임에 필요한 건 사실상 네 줄이에요.
pygame.mixer.music.load("battle_theme.ogg")
pygame.mixer.music.set_volume(0.4) # BGM 은 효과음보다 낮게
pygame.mixer.music.play(-1) # -1 = 무한 반복
# ... 전투 종료 시 ...
pygame.mixer.music.fadeout(1500) # 1.5초에 걸쳐 서서히 종료
play(-1) 의 -1 이 무한 루프입니다. 전투가 끝나면 뚝 끊지 말고 fadeout 으로 잦아들게 한 뒤 승리 팡파레를 play() 하면, 그 흐름만으로 고전 SRPG 의 전투 종료 연출이 완성돼요. BGM 전환도 상태 전이에 거는 겁니다 — VICTORY 상태 진입이 곧 팡파레 재생 지점이에요.
💡 BGM 볼륨은 효과음보다 확실히 낮게(0.3~0.5) 잡는 게 정석입니다. BGM 이 크면 타격음이 묻혀서, 애써 만든 타격감이 사라져요.
음원은 어디서 구하나
BGM 은 합성으로 만들기 어려우니 무료 음원을 씁니다. 3편의 그림 에셋과 같은 요령이고, 라이선스 먼저 확인하는 것도 같아요.
| 사이트 | 특징 | 주의점 |
|---|---|---|
| Kenney.nl | 효과음 팩 CC0, 출처 표기도 불필요 | BGM 은 적음 |
| OpenGameArt.org | 게임용 BGM·효과음 방대 | 트랙마다 라이선스 제각각 |
| freesound.org | 현실 녹음 효과음의 보고 | CC-BY 가 많아 출처 표기 필요 |
🚨 BGM 은 특히 라이선스 사고가 잦은 영역입니다. “무료 다운로드”와 “게임에 넣어 배포 가능”은 다른 말이에요. CC0 또는 CC-BY 트랙만 쓰고, CC-BY 는 크레딧 화면에 출처를 적어두는 습관을 들이세요.
6. 실습 — 무음 전투에 소리 입히기
8편 미니 전투에 이번 편을 얹는 조립도입니다. 새 함수는 없고, 초기화 두 줄과 기존 확정 지점의 play() 한 줄씩이 전부예요.
import pygame, wave, struct, math
pygame.mixer.pre_init(44100, -16, 2, 512) # ① init 전에 버퍼 축소
pygame.init()
screen = pygame.display.set_mode((960, 540))
clock = pygame.time.Clock()
# make_sound ← 본문 3장 그대로
# 8편의 전투/UI 코드 전부 ← 8편 그대로
make_sound("hit.wav", freq=180)
make_sound("select.wav", freq=880, duration=0.06, volume=0.3)
SFX = {k: pygame.mixer.Sound(f"{k}.wav") for k in ("hit", "select")}
SFX["hit"].set_volume(0.7)
# ② 8편 코드에서 딱 세 곳만 수정:
# - ACTION 메뉴 클릭 처리 안에 SFX["select"].play()
# - TARGETING 공격 확정(add_popup 옆)에 SFX["hit"].play()
# - ENEMY_TURN 의 try_attack 안에도 SFX["hit"].play()
실행해서 공격을 확정하는 순간 — 데미지 숫자가 떠오르면서 “탁” 소리가 같이 터집니다. 코드로 합성한 7KB 짜리 소리인데도 손맛이 확 달라지는 게 느껴질 거예요. 소리가 없던 게임에 처음 소리를 넣는 순간은 마름모 격자가 처음 떴을 때만큼 짜릿합니다.
7. 정리
- BGM 은 스트리밍(한 곡), 효과음은 메모리(8채널 동시) — 나뉜 이유부터 이해
pre_init버퍼 512 — 타격음 싱크 밀림은 버퍼 탓- 효과음은 사인파 × 지수 감쇠로 합성 가능 — freq 와 감쇠가 소리의 성격
- 소리는 상태 전이·확정 순간에만 — update/draw 안의 play() 는 소음 폭탄
- 팝업과 타격음은 같은 줄에서 — 눈과 귀가 같은 순간을 가리키게
- BGM 볼륨은 효과음보다 낮게, 라이선스는 배포 가능 여부까지 확인
눈에 정보가 보이고 귀에 소리가 들리니 게임이 제법 완성형에 가까워졌어요. 마지막 남은 한 끗이 연출입니다. 크리티컬이 터졌는데 화면이 미동도 없으면 아깝잖아요 — 화면이 흔들리고, 순간 멈추고, 부드럽게 넘어가는 잔기술들이 손맛을 완성합니다.
일단 오늘은 여기까지….. 다음 글에서는 로드맵의 4번, 연출을 정리해볼게요.
← 이전 글: (8/10) 2D SRPG 만들기 선행학습 — UI 와 메뉴, 정보를 보여주는 기술 | 다음 글 →: (10/10) 2D SRPG 만들기 선행학습 — 연출, 손맛을 만드는 잔기술