dotgimon 제작기 05. 웹 게임의 React Native 이식
dotgimon은 Vite와 React로 만든 웹 픽셀 펫 게임을 Expo와 React Native로 옮긴 프로젝트다. 육성과 진화 규칙이 들어 있는 엔진을 다시 쓰기보다, 엔진이 그림을 그리는 대상과 소리를 재생하는 부분을 바꾸고 싶었다. 브라우저의 Canvas와 Web Audio를 그대로 가져갈 수 없어 연결 방식을 나눴다.
이식 환경은 Expo SDK 57, React Native 0.86, React 19.2.3이었다. 화면 구성과 에셋, 통신, 연출은 여러 날에 걸쳐 옮겼다.
엔진에는 사각형을 그리는 기능만 남겼다
웹 엔진은 Canvas 2D 컨텍스트에 fillRect를 호출해 64×64 LCD를 그렸다. RN 화면에서도 같은 도트 좌표를 쓰려면 게임 규칙과 그리기 API를 분리해야 했다. 엔진이 쓰는 인터페이스를 색상과 두 메서드로 좁혔다.
export interface PixelCtx {
fillStyle: string
fillRect(x: number, y: number, w: number, h: number): void
clearRect(x: number, y: number, w: number, h: number): void
}웹의 Canvas 컨텍스트와 RN의 Skia 어댑터가 이 모양을 만족하게 했다. 엔진은 같은 좌표에 같은 사각형을 그렸고, RN 쪽 객체가 이를 Skia 캔버스의 호출로 바꾸었다. 육성과 진화 규칙을 화면 구현에 맞춰 다시 작성하는 범위를 줄일 수 있었다.
매 프레임의 그림을 React 상태에 넣으면 화면 갱신마다 컴포넌트도 다시 렌더링했다. Skia의 PictureRecorder로 그리기 명령을 기록하고, 결과를 Reanimated의 shared value에 넣는 방식을 썼다. shared value는 React 상태 갱신과 별도로 애니메이션 값을 전달하는 데 사용했다.
const rec = Skia.PictureRecorder()
ctx.canvas = rec.beginRecording(Skia.XYWHRect(0, 0, LCD_SIZE, LCD_SIZE))
game.render(ctx, now)
ctx.canvas = null
picture.value = rec.finishRecordingAsPicture()어댑터의 canvas에는 녹화 중인 캔버스만 잠깐 연결했다. 엔진의 렌더가 끝나면 연결을 비우고 완성된 그림을 넘겼다. 메뉴나 몬스터 위치처럼 별도로 전달하는 값은 이전 값과 비교한 뒤 바뀔 때만 React 상태를 갱신했다.
게임 시간은 실제 Date.now()를 썼다. 화면을 그리는 방식이 바뀌어도 엔진에 넘기는 시간 기준은 유지하고, 앱을 닫아 둔 동안은 catchUp이 정산하도록 연결했다.
앱 진입점과 스타일 설정을 옮겼다
기본 템플릿의 App.tsx와 index.ts 대신 Expo Router를 진입점으로 삼았다. package.json의 main은 expo-router/entry로 바꾸고, app/index.tsx에서 게임기 본체인 Device를 그렸다. 실제 화면은 하나라 Stack 헤더는 숨겼다.
레이아웃에는 SafeAreaProvider와 상태 표시줄을 놓고 배경색을 어둡게 맞췄다. 게임기 바깥이 밝게 남지 않도록 앱 설정의 인터페이스 스타일도 dark로 바꾸었다. URL scheme과 typed routes 설정도 함께 넣었다.
NativeWind는 Tailwind 클래스 이름으로 RN 스타일을 작성하기 위해 붙였다. Babel의 JSX 변환과 Metro의 CSS 입력, Tailwind의 검색 경로가 같은 구성을 가리키게 했다. 당시 Babel과 Metro 설정에는 각각 다음 값이 들어갔다.
presets: [
['babel-preset-expo', { jsxImportSource: 'nativewind' }],
'nativewind/babel',
]Babel에서는 NativeWind의 JSX 처리를 사용했다. Metro에서는 전역 CSS 파일을 입력으로 지정했다.
module.exports = withNativeWind(getDefaultConfig(__dirname), {
input: './global.css',
})Tailwind는 app과 src 아래의 JSX·TSX 파일을 읽게 했다. 전역 CSS에는 base, components, utilities를 넣고 타입 선언도 추가했다. 설정 파일 하나만 붙이는 작업이 아니라 코드 변환과 스타일 수집 경로를 함께 맞추는 작업이었다.
테스트에는 Vitest를 추가했다. 엔진이 RN UI에 직접 의존하지 않아 네이티브 앱을 실행하지 않고도 육성과 진화 규칙을 검사할 수 있었다. 타입 검사는 tsc --noEmit으로 분리했다.
몬스터 이미지는 같은 규격으로 생성했다
몬스터 그림은 계통별 파일의 16×16 문자열 도트맵에서 만들었다. #이 검정 칸이고 공백은 빈칸이었다. 원본을 두 배로 키운 뒤 사방에 2px 여백을 더해 36×36 GIF로 만들었다.
도감 87종에 공용 알과 버전별 알을 합쳐 같은 생성기를 썼다. 스크립트가 도감 ID와 아트 ID를 비교해 빠지거나 남는 종을 찾았다. 행 길이와 허용 문자, 최소 잉크 수를 검사하고, 그림의 검정 영역을 기준으로 가운데를 맞췄다.
애니메이션은 두 프레임이었다. 두 번째 그림을 모두 손으로 그리지 않고 가운데 행을 하나 지우고 윗부분을 한 칸 내렸다. 원본을 세로로 조금 눌러 숨쉬거나 발을 구르는 모습으로 보이게 하는 방식이었다.
GIF 인코더도 필요한 형식만 직접 만들었다. 색은 흰색과 검정으로 제한하고 압축 효율보다 단순한 구현을 택했다. 생성이 끝나면 종별 이미지를 정적 require로 연결하는 spriteMap.ts도 함께 만들었다. RN 번들러가 파일 경로를 미리 알 수 있게 하려는 형태였다.
몬스터는 LCD의 도트 그림 위에 별도 레이어로 올렸다. Skia의 useAnimatedImageValue로 GIF를 읽고, 확대 샘플링은 nearest로 고정했다. 보간으로 검정 도트 경계가 흐려지는 것을 피하려 했다.
흰 배경은 multiply 합성으로 LCD 색에 맞췄다. 곱하기 합성에서 흰색은 바탕을 유지하고 검정은 검정으로 남는다. 투명 배경을 만들지 않아도 두 색의 이미지를 화면 위에 얹을 수 있었다. 알 수 없는 종 ID가 들어오면 공용 알 이미지를 사용했다.
웹에서 합성하던 소리는 WAV로 바꿨다
웹에서는 Web Audio 오실레이터로 효과음을 만들었다. RN에서는 같은 브라우저 코드를 그대로 쓰기 어려워 음표의 주파수와 길이, 파형을 WAV 파일로 미리 생성했다. 재생은 expo-audio에 맡겼다.
생성기는 사각파와 삼각파, 톱니파를 합성했다. 22,050Hz, 모노 16비트 PCM으로 만들고 짧은 어택 뒤에 소리가 줄어드는 곡선을 적용했다. 이동, 선택, 먹기, 부화, 진화 등 열 가지 효과음을 같은 프리셋 구조로 관리했다.
엔진에서 부르는 Sound의 메서드 이름은 유지했다. 웹에서 쓰던 nav()와 select() 등을 RN에서도 같은 방식으로 호출하게 했다. 엔진이 어떤 오디오 라이브러리를 쓰는지 알 필요가 없었다.
웹의 unlock()은 자동 재생 제한에 대비해 첫 입력 뒤 오디오를 활성화하는 메서드였다. RN 이식에서는 빈 메서드로 남겨 호출부를 유지했다. iOS 무음 모드에서도 게임 소리가 재생되도록 setAudioModeAsync({ playsInSilentMode: true })를 설정했다.
QR을 두 번 읽으면 양쪽에서 결과를 계산했다
링크 대전과 합체에는 서버 대신 QR 왕복을 썼다. 한쪽이 코드를 보여 주고 상대가 읽은 뒤 응답 코드를 만들었다. 처음 코드를 보여 준 쪽이 응답을 읽으면 두 기기에 같은 입력이 모였다.
양쪽이 같은 결과를 계산하도록 해시와 난수 알고리즘, 데이터 순서를 맞췄다. 이 프로젝트에서는 FNV-1a와 mulberry32 구현을 공유했다. 직접 구현했다는 사실보다 두 기기가 같은 규칙을 쓴다는 점이 통신의 조건이었다.
모달은 준비, HOST 코드 표시, 스캔, GUEST 준비, HOST 준비 상태로 나눴다. 대전과 합체 모드는 LCD 메뉴에서 정해진 값을 받았다. 상대 코드의 모드가 다르면 거부하고 맞는 메뉴를 안내했다.
읽은 문자열은 형식과 체크섬, 게임 식별값, 버전, 종 ID를 확인했다. 자기 코드인지, 현재 세션의 응답인지, 이미 사용한 세션인지도 검사했다. 알이거나 죽고 아프거나 잠든 몬스터, 성장기 미만은 시작하지 못하게 했고, 합체는 완전체와 정해진 조합만 허용했다.
카메라는 같은 QR을 연속 프레임에서 여러 번 읽을 수 있었다. scanLock ref로 처리 중인 스캔을 잠그고 성공하면 유지했다. 실패한 경우에는 다시 읽을 수 있게 풀었다. 같은 코드를 여러 번 읽는 문제와 같은 거래를 다시 쓰는 문제를 각각 처리했다.
리셋은 확인 뒤 3초 동안 화면을 가렸다
기기 아래에는 RESET 버튼을 달았다. 바로 펫을 지우지 않고 확인 창을 거친 뒤 forceReset(now)를 호출했다. 작은 버튼이어서 hitSlop으로 터치 영역을 넓히고, 눌린 모습은 그림자와 위치 이동으로 표현했다.
엔진은 리셋을 누른 순간 새 알을 만들지 않았다. resetAnim을 켜고 먹기·청소·훈련·대전·치료·진화 연출을 비운 뒤 3초 동안 암전했다. 그동안 입력과 일반 육성 진행을 막았다.
경계 테스트는 2,999ms와 3,000ms를 나누었다. 전자에서는 아직 암전 상태이고 후자에서는 새 알로 바뀌도록 했다. 세대가 하나 늘고 부화 전 상태가 되며 생애 경로가 비워지는지도 함께 검사했다.
진화 커튼은 GIF 위에 그렸다
진화 연출에는 레이어 문제가 있었다. 엔진이 도트로 화면을 덮어도 몬스터 GIF는 그 위의 레이어라 계속 보였다. 화면 전체를 암전하려면 GIF보다 위에서 그릴 영역이 필요했다.
renderTop()을 만들어 몬스터 위에 그리는 도트 레이어를 분리했다. 연출은 몸을 흔든 뒤 아래에서 잉크가 차오르고, 완전히 가려진 순간 종을 바꾸고, 위에서부터 걷히는 커튼으로 만들었다.
| 구간 | 시간 | 화면 변화 |
|---|---|---|
| 흔들림 | 0~900ms | 기존 몬스터가 좌우로 흔들림 |
| 상승 | 900~1,700ms | 아래에서 화면을 덮음 |
| 암전 | 1,700~2,000ms | 가려진 상태에서 종을 교체 |
| 하강 | 2,000~2,800ms | 위에서부터 새 몬스터를 드러냄 |
커튼이 끝나면 새 몬스터를 가운데에 두고 기뻐하는 동작을 연결했다. 시간표는 엔진 값으로 정했으며 실제 기기에서의 시각 검수까지 자동 테스트로 대신하지는 않았다.
최상층 그림도 shared value로 전달했다. 진화나 리셋 중에만 매 프레임 새 그림을 기록하고, 끝나면 빈 그림으로 한 번 되돌렸다. 리셋의 전체 암전도 같은 레이어에서 처리했다.
사용하지 않게 된 개발용 배속 상수도 삭제했다. 앱에서는 개발과 일반 실행의 시간 기준을 맞추고, 긴 경과 시간은 테스트에 now를 직접 넘겨 확인하는 구조가 남았다. 화면을 바꿔도 엔진의 인터페이스를 작게 유지한 덕분에, 이식과 함께 필요한 연출을 별도 레이어로 더할 수 있었다.