Player 아키텍처
Romergo Player가 일반 DOM에서 작동하는 이유
비주얼 노벨 엔진은 대체로 3D 슈팅 게임이나 실시간 전략 게임의 엔진만큼 복잡하지 않습니다. 거대한 세계의 물리, 수백 개 오브젝트의 동작, 복잡한 조명을 매초 계산할 필요가 없습니다. 그렇다고 클릭할 때마다 이미지만 바꾸면 충분하다는 뜻은 아닙니다.
일반적인 비주얼 노벨 장면은 비교적 이해하기 쉬운 요소로 이루어집니다.
- 하나 이상의 배경;
- 캐릭터와 각 캐릭터의 위치;
- 대사, 화자의 이름과 초상화;
- 시각 효과와 전환;
- 음악과 효과음;
- 선택지 또는 플레이어의 다른 행동.
모든 마법은 이 매개변수들이 시간에 따라 바뀌면서 생겨납니다. 무엇을 보여 줄지뿐 아니라 언제 보여 줄지, 무엇을 그대로 둘지, 장면 사이에서 음악을 어떻게 이어 갈지, 어떤 언어를 선택할지, 플레이어의 행동 뒤에 어디로 이동할지가 중요합니다.
오랫동안 엔진을 찾다가 브라우저를 선택했습니다
Godot, PixiJS, Three.js로 장면을 만들어 보았습니다. 성공 정도는 달랐지만 모두 작동했고, 엔진 자체의 잘못은 아니었습니다. 다만 제가 quest를 만드는 방식에서는 별도로 조율해야 하는 계층이 하나 더 생겼습니다.
저에게 필요한 것은 완성된 게임 화면만이 아니었습니다. Builder에서 모든 변경 사항을 즉시 확인하고, 게임 전체를 빌드하지 않고 한 장면만 실행하며, 미리보기에서 요소를 직접 선택하고 이동한 뒤 게시된 Player에서도 같은 동작을 얻는 것이 중요했습니다.
결국 가장 효과적인 해법은 가장 단순한 것이었습니다. 바로 브라우저의 일반 DOM입니다.
배경, 캐릭터, 대화는 익숙한 브라우저 레이어로 남습니다. 구성과 효과의 대부분은 CSS로 만듭니다. Canvas 2D는 개별 시각 요소에 사용하고, 이미지 사이에 셰이더 전환이 실제로 필요한 곳에서만 WebGL canvas를 연결합니다.
사운드는 브라우저의 HTMLAudioElement를 통해 작동합니다. Runtime은 활성 클립의 오디오를 만들고, 음량과 반복 재생을 제어하며, asset이 바뀌지 않았다면 전환 중에도 같은 음악을 유지합니다.
이것이 모든 게임을 위한 보편적인 방법은 아닙니다. 하지만 비주얼 노벨에서 DOM은 타협안이 아니라 매우 정확한 도구였습니다.
단순한 장면과 똑똑한 runtime
저는 Player를 개념적으로 두 부분으로 나눕니다.
- “단순한” 장면은 전달받은 내용을 표시하고 재생합니다;
- “똑똑한” runtime은 이야기 상태를 저장하고 다음에 일어날 일을 결정합니다.
장면은 특정 캐릭터가 왜 나타났는지, 어떤 조건이 답변을 열었는지, 다음 장이 무엇인지 알 필요가 없습니다. 클립, 현재 시간, 언어, 화면 모드, 미디어 참조를 받은 뒤 결과를 렌더링하고 플레이어의 행동을 돌려줍니다.
Runtime은 quest 설명과 플레이 상태를 받습니다. 현재 장, 노드와 장면, 변수 값, 선택 내역, 체크포인트, 완료된 장을 알고 있습니다. 플레이어가 장면을 누르거나 답을 선택하면 runtime은 효과를 적용하고 다음 단계를 찾은 뒤 준비된 상태를 다시 장면에 전달합니다.
아주 단순화하면 흐름은 다음과 같습니다.
Quest JSON + 저장 데이터
↓
runtime이 현재 단계를 결정
↓
장면이 클립과 미디어를 표시
↓
플레이어 행동이 runtime으로 복귀
↓
다음 단계 — 결말까지 반복
게시된 게임에서 Player는 quest 전체의 고정된 runtime 스냅샷을 불러옵니다. Builder의 미리보기는 현재 초안에서 호환되는 runtime을 만듭니다. 따라서 시간이 지나며 서로 다르게 동작하게 되는 비슷한 두 플레이어가 아니라, 서로 다른 셸 안에 있는 하나의 공통 RuntimePlayer, SceneStage, viewport입니다.
장면 사이의 연속성을 유지하는 방법
전환할 때 runtime은 새 장면의 상태를 다시 계산합니다. 반복되는 음악은 asset ID로 식별되며 새로 시작하거나 fade-in을 반복하지 않고 계속 재생됩니다. 이미지는 브라우저가 불러오고 캐시하므로 같은 배경을 다시 사용해도 네트워크에서 파일을 다시 다운로드하지 않습니다.
즉, 최적화는 하나의 거대한 “아무것도 보내지 않기” 조건에 있는 것이 아니라 올바른 계층에 있습니다. Runtime은 재생의 연속성을 유지하고, resolver는 안정적인 리소스를 반환하며, 브라우저 캐시는 이미 알고 있는 미디어를 다시 다운로드하지 않습니다.
하나의 장면을 위한 두 화면
저에게 가장 까다로운 작업은 렌더링 자체가 아니라 휴대전화 적응이었습니다. 가로 장면을 단순히 축소하면 캐릭터가 너무 작아지고 대화 영역이 비좁아지며, 배경의 중요한 세부 요소가 쉽게 화면 밖으로 나갑니다.
Romergo의 장면에는 두 개의 고정 가상 viewport가 있습니다. 가로 1280 × 720과 세로 390 × 693입니다. Player는 기기와 방향에 따라 적절한 모드를 선택한 뒤 완성된 장면 전체를 확대하거나 축소합니다.
Desktop 레이아웃이 기본으로 유지됩니다. 휴대전화에서는 캐릭터 위치와 크기를 별도로 지정할 수 있으며, 재정의가 없으면 desktop 버전을 추가로 축소해 사용합니다. 따라서 제작자는 서로 독립된 장면 두 개가 아니라 필요한 모바일 조정이 더해진 장면 하나를 편집합니다.
배경의 bgX, bgY, 크기는 두 모드에서 공유하고, 세로 viewport가 같은 이미지를 자체 방식으로 잘라 냅니다. 따라서 출시 전에 두 형식에서 프레이밍을 확인하고 공통 초점을 선택해야 합니다. 별도의 모바일 배경 설정은 runtime 계약에 포함되지 않습니다.
반복적인 캐릭터 조정은 MCP를 통해 AI 에이전트에게 맡긴 뒤 두 viewport의 실제 미리보기에서 확인할 수 있습니다. 그래서 저는 이 적응 방식을 완전 자동이 아니라 반자동이라고 부릅니다.
플레이어에게 검은 화면을 보여 주지 않는 방법
필요한 이미지가 아직 네트워크를 통해 도착하지 않았다면 단순한 장면도 도움이 되지 않습니다. 그래서 로딩도 runtime 계약의 일부가 되었습니다.
첫 렌더링 전에 Player는 시작 장면의 활성 미디어를 수집하고 로딩이 끝날 때까지 기다립니다. 이때 플레이어는 빈 배경 대신 정상적인 진행 표시기를 봅니다. 시작 후 runtime은 잠시 기다린 다음 현재 장면과 그래프의 다음 두 단계 안에서 도달 가능한 장면의 리소스를 미리 준비합니다. 기본 깊이는 두 번의 전환이며, 두 단계에서 가능한 모든 분기를 포함합니다.
프리로딩은 전환 그래프에 연결되며 깊이는 별도로 설정할 수 있습니다. 고정된 장면 수를 사용하지 않는 이유는 선형 순서와 분기가 형식적으로 같은 수의 다음 단계를 가져도 서로 다른 부하를 만들기 때문입니다.
오프라인 플레이에는 다른 모드가 있습니다. 사용자가 전체 quest를 미리 다운로드할 수 있습니다. 그러면 runtime 스냅샷, 미디어, PWA 셸이 브라우저 캐시에 남아 네트워크 없이 열립니다. 다음 장면 프리로딩은 부드러운 온라인 플레이를, 전체 다운로드는 진정한 offline 플레이를 담당합니다.
셸로서의 Telegram, 별도 시스템으로서의 Discord
Player를 Telegram과 Discord에 삽입할 때 브라우저를 좋아한 보람이 특히 컸습니다. 두 경우 모두 플랫폼 안에서 같은 web runtime이 열리므로 새로운 게임 엔진에 맞춰 장면과 진행 규칙을 다시 작성할 필요가 없었습니다.
Telegram에서는 주로 플랫폼 세션, quest 시작, 로컬 진행 상황, Mini App과 일반 Player 사이의 이동이 필요했습니다. 게임 자체는 그대로 유지되었습니다.
Discord는 여러 사람이 이야기의 같은 지점을 봐야 하므로 더 복잡합니다. 각 참여자는 실제로 자신의 로컬 runtime을 실행하지만 host가 단일 기준이 됩니다. Host의 Player는 상태가 바뀔 때와 약 750밀리초마다 상태를 보냅니다. Realtime 방은 WebSocket으로 명령을 받고 스냅샷을 저장한 뒤 참여자들에게 전송합니다. 관전자 runtime은 원격 상태를 적용하고 장면을 복원하며 동기화 사이에는 로컬 시간 계산을 계속합니다.
이 분리는 유용한 결과를 만들었습니다. 이야기 상태는 공유하지만 언어와 화면 크기는 로컬로 유지됩니다. 한 참여자는 휴대전화에서 한국어로 장면을 보고, 다른 참여자는 큰 화면에서 영어로 보면서도 둘 다 host와 동기화 상태를 유지할 수 있습니다. 관전자는 두 번째 host가 되지 않고도 답변 선택지에 투표할 수 있습니다.
모든 모드를 위한 하나의 기반
Player는 오래전에 “배경, 캐릭터, 대사”라는 기본 구성을 넘어섰습니다. 이제 분기, 변수, 체크포인트, 되감기, 대화형 인터페이스 장면, 번역, offline, 동기화된 플레이를 포함합니다.
하지만 기본 결정은 이러한 성장을 견뎌 냈습니다. 장면은 여전히 이미지와 사운드를 담당하고, runtime은 여전히 상태와 전환을 담당합니다. Browser API는 렌더링, 미디어, 캐시, 삽입을 처리하며 전문 계층은 정말 필요한 곳에만 추가됩니다.
덕분에 같은 Player를 Builder preview, 테스트, 일반 브라우저 플레이, Telegram, Discord에서 유지할 수 있습니다. 저에게는 책임의 경계를 올바르게 그으면 가장 단순하고 다소 투박한 해법이 가장 유연한 해법이 될 수 있다는 좋은 사례입니다.