「오늘 누가 쏘냐?」에 들어간 이미지와 소리 중 사람이 그리거나 녹음한 것은 없다. 에셋을 사 오지도 않았다. 캐릭터 8종, 폭탄, 효과음 6종, 배경음악, 앱 아이콘, 스토어 그래픽 이미지는 Tools/ 폴더의 Python 스크립트가 만든다. 한글 폰트도 원본 폰트를 스크립트로 잘라 만든다.

이유는 2편에서 UI를 코드로 만든 이유와 같다. 그림을 "픽셀 (12, 11)에 검은 점" 같은 좌표와 색의 목록으로 바꾸면, 그림도 코드처럼 diff로 검토하고 스크립트로 다시 만들 수 있다.

스크립트 결과물 사용 라이브러리
gen_characters.py Resources/Sprites/char_0~7.png (160x32) Pillow
gen_bomb.py Resources/Sprites/bomb.png (128x32, 4프레임) Pillow
gen_sfx.py Resources/Audio/{tick,go,step,pop,win,lose}.wav numpy
gen_bgm.py Resources/Audio/bgm.wav (8마디 루프) numpy
gen_font.py Resources/Fonts/NotoSansKR.ttf + 라이선스 fonttools
gen_icon.py 앱 아이콘, 스토어 아이콘, 그래픽 이미지 Pillow

캐릭터: 배열 한 줄이 캐릭터 하나

캐릭터는 32x32 픽셀아트다. 8종의 차이는 CHARS 배열 한 줄에 다 들어 있다. 피부색 번호, 머리색, 머리 스타일, 상의색, 바지색, 악세서리다.

SKINS = [(255, 220, 185, 255), (240, 200, 160, 255), (210, 160, 120, 255)]

# 캐릭터 정의: (이름, 피부, 머리색, 머리스타일, 상의색, 바지색, 악세서리)
CHARS = [
    ("빨강", 0, (60, 40, 30, 255),    "short",  (230, 70, 70, 255),   (60, 70, 110, 255),  "none"),
    ("파랑", 1, (30, 30, 40, 255),    "spiky",  (70, 130, 230, 255),  (50, 50, 60, 255),   "glasses"),
    ("초록", 0, (200, 120, 60, 255),  "long",   (80, 190, 110, 255),  (90, 70, 60, 255),   "bow"),
    # ... 노랑(cap), 보라(bob), 주황(bun·earring), 청록(beard), 분홍(long·headband)
]

EXPRS = ["idle", "nervous", "lose", "win", "idle2"]

draw_char(ch, expr)가 32x32 캔버스에 몸통, 머리, 머리카락, 악세서리, 눈, 입 순서로 사각형과 점을 찍는다. 표정에 따라 달라지는 부분은 조건문이다. 긴장(nervous)은 찡그린 눈썹과 땀방울, 당첨(lose)은 >_< 눈과 눈물에 고개를 1px 숙이고, 환호(win)는 ^ ^ 눈에 만세 팔과 반짝이를 그린다.

마지막에 외곽선을 두른다. 투명 픽셀 중 상하좌우에 불투명 픽셀이 있는 곳을 외곽선 색으로 칠하는 방식이다. 그리는 코드에서는 외곽선을 신경 쓰지 않아도 된다.

def outline(self):
    src = self.img.copy().load()
    for y in range(32):
        for x in range(32):
            if src[x, y][3] != 0:
                continue
            for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1)):
                nx, ny = x + dx, y + dy
                if 0 <= nx < 32 and 0 <= ny < 32 and src[nx, ny][3] != 0 and src[nx, ny] != OUTLINE:
                    self.p[x, y] = OUTLINE
                    break

캐릭터마다 다섯 프레임을 가로로 붙여 160x32 시트 하나로 저장한다. 함께 6배 확대한 전체 미리보기 preview_characters.png도 만든다. 확대는 Image.NEAREST로 해서 픽셀이 뭉개지지 않는다.

캐릭터 8종 x 5프레임. 왼쪽부터 기본, 긴장, 당첨, 환호, 눈 감음

char_0.png 원본 시트(160x32)를 6배 확대한 모습

첫 커밋(9/16)에는 표정이 4개였다. 다섯 번째 idle2는 9/21에 대기 애니메이션용으로 추가했다. 표정 하나를 늘리는 데 EXPRS에 이름 하나, 눈과 입을 그리는 분기 하나를 더했고, 스크립트를 다시 돌려 PNG 8장을 재생성했다.

Unity 쪽: 픽셀아트 임포트 설정을 자동으로

32x32 이미지를 화면에 수백 픽셀로 키워 보여 주므로 텍스처 설정이 맞지 않으면 흐릿해진다. 편집기에서 파일마다 설정을 바꾸는 대신 AssetPostprocessor가 Resources/Sprites/ 아래 PNG에 같은 설정을 적용한다.

public class PixelSpriteImporter : AssetPostprocessor
{
    void OnPreprocessTexture()
    {
        if (!assetPath.Replace('\\', '/').Contains("/Resources/Sprites/")) return;

        var importer = (TextureImporter)assetImporter;
        importer.textureType = TextureImporterType.Sprite;
        importer.spritePixelsPerUnit = SpriteLibrary.FrameSize;   // 32
        importer.filterMode = FilterMode.Point;                  // 확대해도 또렷하게
        importer.textureCompression = TextureImporterCompression.Uncompressed;
        importer.mipmapEnabled = false;
        importer.npotScale = TextureImporterNPOTScale.None;      // 160x32 그대로
        // ...
    }
}

같은 클래스의 OnPreprocessAudio는 Resources/Audio/ 아래 WAV를 DecompressOnLoad + PCM으로 임포트한다. 런타임에는 SpriteLibrary가 시트를 Resources.Load로 읽어 32픽셀 단위로 잘라 Sprite.Create로 캐시한다. 프레임 수는 텍스처 폭에서 계산하고, 옛 4프레임 시트가 들어오면 Idle2 자리에 Idle을 대신 쓴다.

눈 깜빡임: 2프레임과 무작위 위상

idle2는 idle에서 두 가지만 다르다. 눈이 한 줄로 감기고, 팔이 1px 내려간다. 이 두 프레임을 번갈아 보여 주는 것이 SpriteFlipper다. 평소에는 기본 프레임을 2.6초 안팎(±40%) 보여 주다가 눈 감은 프레임을 0.18초 보여 준다.

void Awake()
{
    _img = GetComponent<Image>();
    _next = Time.time + Random.Range(0.3f, Interval);   // 시작 위상을 무작위로
}

void Update()
{
    if (Time.time < _next) return;
    _showingB = !_showingB;
    _img.sprite = _showingB ? B : A;
    _next = Time.time + (_showingB ? FlipDuration : Interval * Random.Range(0.6f, 1.4f));
}

참가자 설정 화면처럼 캐릭터 여러 명이 한 화면에 나올 때, 시작 시점과 간격을 무작위로 두지 않으면 모두 같은 순간에 눈을 감는다. 대기 캐릭터에는 UIFactory.Idle(img, charIndex)로 붙이고, 게임 중 표정을 바꿀 때는 SpriteFlipper.Remove(img)로 떼고 나서 스프라이트를 넣는다. 그러지 않으면 당첨 표정이 잠시 뒤 기본 표정으로 돌아간다.

폭탄 돌리기의 폭탄(gen_bomb.py)도 같은 방식이다. 대기, 불꽃 작게, 불꽃 크게, 폭발의 4프레임을 ImageDraw의 원과 점으로 그린다.

효과음 6종: 사각파, 삼각파, 스윕

gen_sfx.py는 numpy로 파형을 계산해 16비트 모노 44.1kHz WAV로 저장한다. 파형은 사각파(np.sign(np.sin(...)))와 삼각파 두 가지, 그리고 주파수를 선형으로 바꾸는 스윕 함수 하나가 전부다.

save("tick", tone(880, 0.09, "square", 0.35))                     # 카운트다운
save("step", tone(520, 0.05, "tri", 0.4))                         # 이동·터치
save("win", np.concatenate([tone(784, 0.1, "square", 0.35), tone(988, 0.1, "square", 0.35),
                            tone(1175, 0.1, "square", 0.35), tone(1568, 0.3, "square", 0.4)]))
# 당첨: 트롬본 처럼 내려가는 소리
save("lose", np.concatenate([
    sweep(440, 415, 0.25, "square", 0.45), silence(0.03),
    sweep(415, 392, 0.25, "square", 0.45), silence(0.03),
    sweep(392, 370, 0.25, "square", 0.45), silence(0.03),
    sweep(370, 300, 0.6, "square", 0.5),
]))

이 게임에서 「당첨」은 커피를 사는 사람이다. 그래서 win(세이프)은 올라가는 4음, lose(당첨)는 반음씩 미끄러져 내려가다 마지막에 길게 떨어지는, 실패할 때 나오는 트롬본 느낌의 소리로 만들었다. 나머지는 go(시작 3음), pop(버튼)이다. 재생은 AudioManager.Play("이름") 한 줄이고, 파일이 없으면 조용히 넘어간다.

배경음악: 8마디, 112 BPM, C-G-Am-F

배경음악은 9/21에 진동과 함께 추가했다. gen_bgm.py가 C-G-Am-F 진행을 두 번 도는 8마디 루프를 만든다. 사각파 아르페지오(8분음표), 삼각파 베이스, 노이즈 하이햇의 세 줄이다.

루프 음악에서 신경 쓸 것은 이음새다. 버퍼 길이를 정확히 마디 길이의 배수로 잡는다.

BPM = 112
BEAT = 60.0 / BPM
total = int(SR * BEAT * 4 * len(CHORDS))   # 8마디 = 약 17.1초
mix = np.zeros(total, dtype=np.float64)
# ... 마디마다 베이스·아르페지오·하이햇을 put()으로 더함

# 부드러운 페이드로 루프 이음새 클릭 방지 (양 끝 3ms)
edge = int(SR * 0.003)
mix[:edge] *= np.linspace(0, 1, edge)
mix[-edge:] *= np.linspace(1, 0, edge)

길이가 박자에 딱 맞으니 AudioSource.loop로 이어 붙여도 박이 밀리지 않는다. 양 끝 3ms 페이드는 파형이 0이 아닌 값에서 끊겼다가 다시 시작할 때 나는 딸깍 소리를 없앤다. 하이햇 노이즈는 np.random.seed(7)로 고정해서 다시 생성해도 같은 파일이 나온다. 결과물 bgm.wav는 1.5MB이고, AudioManager가 효과음보다 낮은 볼륨(0.28)으로 반복 재생한다. 타이틀 화면의 토글로 소리, 음악, 진동을 따로 끌 수 있다.

한글 폰트: 가변 폰트를 고정하고 필요한 글자만

폰트는 그리는 대신 원본을 가공한다. 9/17 WebGL 빌드를 브라우저에서 열었더니 한글이 전부 빈칸이었다. Unity 기본 폰트에는 한글 글리프가 없고, Android나 에디터에서는 OS 폰트로 대체되던 것이 WebGL에서는 대체되지 않았기 때문이다. 이 문제는 4편에서 자세히 다루고, 여기서는 폰트를 만드는 방법만 본다.

gen_font.py는 fonttools로 두 단계를 거친다.

font = TTFont(SRC)   # 기본값 C:\Windows\Fonts\NotoSansKR-VF.ttf
if "fvar" in font:
    # 가변 폰트 → Regular(wght 400) 정적 인스턴스로 고정
    font = instancer.instantiateVariableFont(font, {"wght": 400})

RANGES = [
    (0x0020, 0x007E),  # ASCII
    # ... Latin-1, 구두점, 화살표, 도형·기호(★☆♥●), CJK 구두점
    (0x3131, 0x318E),  # 한글 호환 자모(ㄱ~ㅣ)
    (0xAC00, 0xD7A3),  # 한글 완성형 전체
    (0xFF01, 0xFF5E),  # 전각 영숫자·기호
]
subsetter = subset.Subsetter(opts)
subsetter.populate(unicodes=[cp for lo, hi in RANGES for cp in range(lo, hi + 1)])
subsetter.subset(font)

첫째, 가변 폰트를 굵기 400 하나로 고정한다. 가변 폰트는 굵기 축 데이터를 통째로 들고 있어 크고, 이 게임은 Regular 하나만 있으면 된다(굵게는 uGUI의 FontStyle.Bold로 처리한다). 둘째, 필요한 글자만 남긴다. 한글 완성형 11,172자 전체와 초성 퀴즈에 쓰는 호환 자모, ASCII와 자주 쓰는 기호다.

결과 파일은 2.4MB다. Noto Sans KR은 SIL Open Font License 1.1이라 앱에 넣어 배포할 수 있지만 라이선스 문구를 함께 둬야 한다. 스크립트가 폰트의 name 테이블 13번(라이선스 본문)을 읽어 NotoSansKR-LICENSE.txt로 옆에 저장한다. 폰트를 다시 만들 때마다 라이선스 파일도 같이 갱신된다.

아이콘과 스토어 그래픽

Play 스토어 비공개 테스트를 준비하면서 아이콘이 필요해졌다. gen_icon.py는 새로 그리지 않고 이미 있는 캐릭터 시트에서 빨강 캐릭터의 환호 프레임을 잘라 최근접 확대한다.

  • Android 적응형 아이콘: 전경(432x432, 투명 배경에 캐릭터를 7배로, 안전 영역 안에 들어가게)과 배경(노랑 단색)
  • 레거시 아이콘: 512x512 둥근 노랑 사각형 위에 캐릭터 10배
  • 스토어 아이콘 512x512, 스토어 그래픽 이미지 1024x500

배경색과 강조색은 UIFactory의 테마 색(#1E1B2E, #FFD23F)을 그대로 가져왔고, 그래픽 이미지의 제목은 앞에서 만든 NotoSansKR.ttf로 쓴다. 스크립트끼리 결과물을 이어 쓰는 셈이다.

gen_icon.py가 만든 스토어 그래픽 이미지 1024x500

이 방식이 좋았던 점과 한계

좋았던 점은 이렇다.

  • 수정 이력이 diff로 남는다. "눈 감은 프레임 추가"가 PNG 바이너리 교체가 아니라 gen_characters.py의 25줄 변경으로 리뷰된다.
  • 연출 의도가 곧 수정이 된다. "팔을 1px 내려 숨 쉬는 느낌"이나 "당첨 소리는 트롬본처럼" 같은 의도가 그대로 좌표와 주파수 수정으로 이어진다.
  • 재현할 수 있다. 같은 스크립트를 돌리면 같은 파일이 나온다. 하이햇 노이즈도 시드를 고정했다. 캐릭터를 하나 늘리거나 색을 바꾸는 일이 배열 한 줄 수정이다.

한계도 분명하다.

  • 그림 품질은 좌표를 직접 찍을 수 있는 수준까지다. 32x32 픽셀아트, 단순한 칩튠처럼 규칙으로 설명할 수 있는 스타일이라서 가능했다. 해상도가 높은 일러스트나 실제 악기 소리에는 맞지 않는다.
  • 좌표가 숫자로 흩어져 있다. c.rect(9, hy0 + 3, 10, hy0 + 5, hair) 같은 줄을 읽고 결과를 떠올리기는 어렵다. 그래서 6배 확대 미리보기 PNG를 같이 만든다. 스크립트를 고치면 생성된 PNG/WAV도 함께 커밋하는 것이 저장소 규칙이다.
  • 원본이 필요한 것도 있다. 폰트는 Windows에 설치된 NotoSansKR-VF.ttf를 원본으로 쓴다. 다른 환경에서는 원본 경로를 인자로 넘겨야 한다.

정리

  • 캐릭터는 CHARS 배열(피부·머리·옷·악세서리)과 표정별 분기로 그리고, 외곽선은 마지막에 자동으로 두른다. 5프레임을 160x32 시트로 저장한다.
  • Unity 쪽은 AssetPostprocessor로 Point 필터·무압축·밉맵 없음·32 PPU를 자동 적용하고, 런타임에 시트를 잘라 쓴다.
  • 눈 깜빡임은 2프레임과 SpriteFlipper. 시작 위상과 간격을 무작위로 둬야 여러 캐릭터가 따로 깜빡인다.
  • 효과음은 사각파·삼각파·스윕 세 가지로 충분했다. BGM은 마디 길이에 맞춘 버퍼와 양 끝 3ms 페이드로 끊김 없이 반복된다.
  • 한글 폰트는 가변 폰트를 wght 400으로 고정하고 완성형 11,172자와 자모·기호만 남겨 2.4MB로 만들었다. OFL 라이선스 문구를 같이 둔다.
  • 아이콘과 스토어 그래픽은 캐릭터 시트와 폰트를 다시 써서 만든다.

다음 글 4편 (10월 5일 공개)에서는 WebGL 빌드를 IIS에 올리며 겪은 한글 빈칸 문제와 LTE에서 40MB wasm을 받지 못한 문제를 다룬다.