Every game development tutorial starts the same way: colored rectangles sliding across a gray canvas. That is fine for learning syntax, but it teaches you nothing about how a game engine actually breathes. I wanted out of the rectangle business. I wanted to build something that felt like a real game—a top-down dungeon crawler with movement, combat, a HUD, and sound.

I committed to Phaser v4 and gave myself one hard rule. Zero external assets. No image files, no audio clips, and no build tools like Webpack or Vite. The entire project had to live inside a single HTML file written with plain JavaScript. That constraint was not about minimalism for its own sake. It was about removing every excuse and every black box. When you cannot download a sprite pack to cover up a gap in your knowledge, you are forced to learn how the engine manages textures, animations, audio, and state under the hood.

코드로 세상 그리기

일반적인 Phaser 프로젝트에서는 preload 함수 내부에서 this.load.image()를 호출하고 엔진에 PNG 파일을 가리키도록 합니다. 그런 옵션이 없다면 Graphics 객체를 사용해야 합니다. 객체를 인스턴스화하고, 기본 도형(바닥 타일을 위한 사각형, 벽을 위한 굵은 선, 플레이어 토큰을 위한 원 등)을 그린 다음 generateTexture를 호출합니다. 이 메서드는 그래픽 버퍼를 캡처하여 사용자가 선택한 키로 Phaser의 텍스처 매니저에 등록합니다.

그 순간부터 엔진은 생성된 비트맵을 로드된 이미지 파일과 똑같이 취급합니다. 이를 타일맵에 할당하거나, 스프라이트로 자르거나, 색조(tint)를 입힐 수 있습니다. 던전 크롤러의 경우, 코드 에디터를 떠나지 않고도 바닥 그리드를 절차적으로 생성하고, 벽 세그먼트를 찍어내며, 컬러 팔레트를 반복해서 수정할 수 있음을 의미했습니다. 실질적인 교훈은 텍스처란 메모리에 있는 비트맵 데이터의 덩어리일 뿐이라는 점입니다. Phaser는 그것이 HTTP 요청을 통해 왔는지, 직접 코딩한 Graphics 호출을 통해 왔는지 상관하지 않습니다.

이 접근 방식은 그리기 순서(draw order)와 배칭(batching)에 대해 의도적으로 생각하게 만듭니다. 모든 벽과 바닥 타일이 동일한 생성된 텍스처 제품군에서 나오면, Phaser가 렌더링 호출을 어떻게 그룹화하는지에 주의를 기울이게 됩니다. 어떤 것이 텍스처 인스턴스로 존재할 가치가 있는지 직접 결정해야 하기 때문에, 정적 타일맵 레이어와 개별 객체의 스프라이트맵 사이의 차이를 깨닫게 됩니다.

스프라이트 시트 없이 애니메이션 만들기

정적인 사각형은 금방 지루해지지만, Phaser의 애니메이션 시스템은 보통 그리드에 프레임이 배치된 단일 PNG인 스프라이트 시트를 기대합니다. 저는 오프스크린(offscreen) HTML canvas 요소를 사용하여 이 스트립을 재현했습니다. 애니메이션의 각 프레임마다 캔버스를 지우고 새로운 포즈(단순한 칼 휘두르기, 두 단계 걷기 사이클, 또는 적의 대기 동작 등)를 그렸습니다. 스트립이 완성되면 이를 Phaser에 스프라이트 시트로 등록하고, 엔진이 한 포즈의 끝과 다음 포즈의 시작을 알 수 있도록 프레임의 너비와 높이를 정의했습니다.

이를 수동으로 수행하면 애니메이션의 실체가 무엇인지 정확히 드러납니다. 바로 공유된 텍스처 위의 프레임 경계 집합이라는 점입니다. Phaser의 애니메이션 컴포넌트는 시작 프레임, 종료 프레임, 프레임 레이트를 요구합니다. 그런 다음 게임 클록의 매 틱(tick)마다 해당 직사각형 슬라이스들을 통해 포인터를 이동시킵니다. 스프라이트 시트를 아티스트가 만든 마법 같은 에셋으로 생각하는 대신, 좌표 수학으로 보기 시작하게 됩니다. 이러한 관점은 나중에 실제 아트워크를 자르거나 애니메이션이 순서대로 재생되지 않는 이유를 디버깅할 때 매우 귀중합니다.

무(無)에서 만드는 사운드

오디오 파일은 금지되었으므로 Web Audio API를 직접 사용했습니다. 몇 줄의 JavaScript만으로 오실레이터 노드(oscillator node)를 생성하고, 이를 사각파(square wave)나 사인파(sine wave)로 설정하며, 게인 노드(gain node)를 통과시켜 짧은 소리 버스트를 예약할 수 있습니다. 저는 일반적인 이벤트를 위한 작은 헬퍼 함수들을 작성했습니다. 발소리를 위한 저주파 비프음, 전리품을 획득할 때의 상승하는 짹짹 소리, 데미지를 입었을 때의 거친 사각파 톤 같은 것들 말이죠.

이러한 합성된 사운드는 설계상 플레이스홀더(placeholder)이지만, 게임에 즉각적인 기계적 피드백을 제공합니다. 실제 오디오를 녹음하거나 소싱하기 전에 타이밍이 적절한지 느낄 수 있습니다. 진짜 보상은 아키텍처에서 옵니다. 사운드 트리거를 간단한 함수로 감싸두었기 때문에, 나중에 합성된 비프음을 로드된 사운드 버퍼로 교체하는 것은 단 한 줄의 코드 변경만으로 가능합니다. 충돌 이벤트, UI 깜빡임, 점수 증가 등 게임의 나머지 부분은 그대로 유지됩니다. 먼저 느낌(feel)을 프로토타이핑한 다음, 충실도(fidelity)를 높이는 것입니다.

여러 월드 관리하기

진짜 게임은 하나 이상의 화면이 필요하므로, 프로젝트를 Menu, Game, UI, Pause와 같은 별도의 Phaser scene으로 나누었습니다. UI scene은 Game scene과 병렬로 실행되며, 동시에 시작되어 던전 탐험이 진행되는 동안 체력 바와 점수 카운터가 자신만의 샌드박스 내에서 작동하도록 합니다. 이들은 오직 이벤트를 통해서만 엄격하게 통신합니다. 플레이어가 데미지를 입으면 Game scene이 변경 사항을 방출(emit)합니다. UI scene은 이를 수신하여 텍스트 객체를 업데이트합니다. Game scene은 UI를 임포트하거나, 메서드를 호출하거나, 심지어 UI의 존재 여부를 확인하지도 않습니다. 그저 허공에 데이터를 던질 뿐입니다. 이러한 디커플링(decoupling) 덕분에 코어 게임 루프를 건드리지 않고도 테스트를 위해 HUD를 떼어내거나 완전히 교체할 수 있습니다.

일시 정지를 위해 Game scene 위에 쌓이는(stacked) Pause scene을 사용했습니다. 결정적으로, Game scene에서 scene.pause()를 호출하면 실제로 물리 엔진(physics world)이 얼어붙고 타이머가 중단됩니다. Game scene은 업데이트를 멈추지만, Pause scene은 깨어 있는 상태를 유지하여 메뉴를 렌더링하고 일시 정지 해제 신호를 기다립니다. 만약 지금까지 거대한 하나의 update loop 안에서 불리언(boolean) 플래그로만 일시 정지 상태를 관리해 왔다면, 이는 마치 전등 스위치를 발견한 것과 같은 기분일 것입니다. 엔진은 코드 곳곳에 if (isPaused) return 같은 가드(guard) 코드를 뿌려 넣어야 하는 대신, 실제적인 pause lifecycle을 제공합니다.

실제 버그를 통해 얻은 뼈아픈 교훈

하드웨어에 가깝게 작업하다 보니 바꿔야 할 두 가지 습관이 드러났습니다.

첫째, Phaser v4에서 공개(public) 메서드처럼 보이지만 문서화된 API의 일부가 아닌 메서드를 사용하려 했습니다. 이 메서드는 버전 간에 변경되어 빌드를 깨뜨렸습니다. 저는 안정적이고 문서화된 공개 메서드인 getChildren()을 사용하도록 리팩터링했고, 불안정성은 사라졌습니다. 교훈은 명확합니다. 공식 문서에 없는 메서드라면 그것을 기반으로 게임을 만들지 마십시오. 내부(internal) API가 내부용인 데에는 이유가 있습니다. 공개된 영역(public surface area)만 고수한다면 엔진 업데이트 속에서도 프로젝트를 지켜낼 수 있을 것입니다.

둘째, 모든 것을 이벤트에 의존해서는 안 된다는 것을 배웠습니다. 이벤트 콜백은 코인을 줍거나 상자를 여는 것과 같은 낮은 중요도의 상호작용에는 완벽하게 작동합니다. 하지만 게임 오버(Game Over)와 같은 중요한 상태 전환(state transitions)을 위해서는 메인 update loop 내에 중복 체크 로직을 추가했습니다. 리스너가 제거되거나, scene이 애매한 마이크로초 단위에서 일시 정지되거나, 방출(emission)과 처리(handling) 사이에 레이스 컨디션(race condition)이 발생하는 경우 이벤트가 잘못 작동할 수 있기 때문입니다. update loop에서 플레이어의 체력을 직접 확인하고 체력이 0이 되면 게임 오버 상태를 강제함으로써, 이벤트가 실행되지 않더라도 게임이 불확실한 상태(limbo state)에 빠지지 않도록 보장했습니다. 이벤트는 여전히 화면 흔들림, 사운드 큐, 점수 제출과 같은 부차적인 효과를 처리하지만, 권한을 가진 핵심 로직(authoritative logic)은 게임 클록이 작동하는 곳에 존재합니다.

왜 이 방식을 시도해야 하는가

게임 개발을 배우고 있다면, 다음 프로젝트에 이 제약 조건을 그대로 적용해 보십시오: 외부 에셋 없이, 단 하나의 HTML 파일로만 만들기. 제한적으로 들릴 수 있지만, 모든 변명을 제거해 줍니다. 번들러(bundler)를 설정하거나, 로컬 오디오 파일의 CORS 오류와 씨름하거나, 오후 내내 무료 에셋 팩을 수집할 필요가 없습니다. 코드를 작성하고, 브라우저를 새로고침하면, 바로 결과를 볼 수 있습니다.

더 중요한 것은, 엔진이 왜 그렇게 동작하는지 이해하게 된다는 점입니다. generateTexture를 호출했기 때문에 텍스처가 어떻게 GPU로 들어가는지 알게 될 것입니다. 애니메이션 프레임의 경계를 직접 등록했기 때문에 프레임이 어떻게 인덱싱되는지 알게 될 것입니다. 오실레이터(oscillator)를 직접 연결했기 때문에 오디오가 어떻게 스피커에 도달하는지 알게 될 것입니다. 이러한 지식은 외부 에셋을 사용하는 더 큰 프로젝트에도 직접적으로 전이됩니다. 근본적인 메커니즘은 결코 변하지 않기 때문입니다—엔진이