Phaser 4 마이그레이션 체크리스트 (Phaser 3 -> 4)
Phaser 3 프로젝트를 Phaser 4로 옮길 때 놓치기 쉬운 포인트를 체크리스트로 정리했습니다. 사전 점검부터 코드 구조, 렌더링, 배포 검증까지 한 번에 확인할 수 있습니다.
이전 글에서 체크리스트를 정리했다면, 이번 글은 진짜 코드를 바꾸는 단계다.
목표는 간단하다.
Scene + Player 객체 중심 코드ECS(Entity/Component/System) 중심 코드로 변환하는 감각을 익히는 것.
플레이어 핵심 루프 3가지만 다룬다.
이 3개만 옮겨도 전체 구조가 왜 ECS로 바뀌는지 체감된다.
아래는 흔히 쓰는 Phaser 3 스타일이다.
// Phaser 3 스타일 (개념 예시)
class PlayerScene extends Phaser.Scene {
private player!: Phaser.Physics.Arcade.Sprite;
private cursors!: Phaser.Types.Input.Keyboard.CursorKeys;
private attackKey!: Phaser.Input.Keyboard.Key;
private canAttack = true;
create() {
this.player = this.physics.add.sprite(100, 200, 'player');
this.player.setCollideWorldBounds(true);
this.cursors = this.input.keyboard.createCursorKeys();
this.attackKey = this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.Z);
}
update() {
// 이동
if (this.cursors.left.isDown) {
this.player.setVelocityX(-220);
this.player.setFlipX(true);
} else if (this.cursors.right.isDown) {
this.player.setVelocityX(220);
this.player.setFlipX(false);
} else {
this.player.setVelocityX(0);
}
// 점프
if (this.cursors.up.isDown && this.player.body.blocked.down) {
this.player.setVelocityY(-420);
}
// 공격 + 쿨다운
if (Phaser.Input.Keyboard.JustDown(this.attackKey) && this.canAttack) {
this.canAttack = false;
this.playAttackAnimation();
this.time.delayedCall(250, () => {
this.canAttack = true;
});
}
}
private playAttackAnimation() {
this.player.play('attack', true);
}
}
문제는 코드가 커질수록 update()가 비대해진다는 점이다.
이 한 클래스에 계속 붙는다.
먼저 “무엇을 데이터로 분리할지” 정한다.
PositionVelocityMoveSpeedJumpPowerGroundedAttackState (쿨다운/타이머)InputStateFacing즉, Player 객체에 있던 상태를 컴포넌트로 쪼개는 게 1단계다.
// 개념 예시: ECS 컴포넌트
export type Entity = number;
export interface Position {
x: number;
y: number;
}
export interface Velocity {
x: number;
y: number;
}
export interface MoveSpeed {
value: number;
}
export interface JumpPower {
value: number;
}
export interface Grounded {
value: boolean;
}
export interface Facing {
dir: -1 | 1; // -1: left, 1: right
}
export interface InputState {
left: boolean;
right: boolean;
jumpPressed: boolean;
attackPressed: boolean;
}
export interface AttackState {
cooldownMs: number;
remainMs: number;
}
핵심은 “로직 없이 데이터만” 두는 것이다.
export function inputSystem(player: Entity, world: World, keyboard: KeyboardState) {
const input = world.inputState.get(player);
if (!input) return;
input.left = keyboard.left;
input.right = keyboard.right;
input.jumpPressed = keyboard.jumpPressed;
input.attackPressed = keyboard.attackPressed;
}
export function movementSystem(player: Entity, world: World) {
const input = world.inputState.get(player);
const vel = world.velocity.get(player);
const speed = world.moveSpeed.get(player);
const facing = world.facing.get(player);
if (!input || !vel || !speed || !facing) return;
if (input.left === input.right) {
vel.x = 0;
return;
}
if (input.left) {
vel.x = -speed.value;
facing.dir = -1;
} else {
vel.x = speed.value;
facing.dir = 1;
}
}
export function jumpSystem(player: Entity, world: World) {
const input = world.inputState.get(player);
const vel = world.velocity.get(player);
const grounded = world.grounded.get(player);
const jumpPower = world.jumpPower.get(player);
if (!input || !vel || !grounded || !jumpPower) return;
if (input.jumpPressed && grounded.value) {
vel.y = -jumpPower.value;
grounded.value = false;
}
}

export function attackSystem(player: Entity, world: World, dtMs: number) {
const input = world.inputState.get(player);
const attack = world.attackState.get(player);
if (!input || !attack) return;
// 쿨다운 감소
attack.remainMs = Math.max(0, attack.remainMs - dtMs);
// 공격 입력 처리
if (input.attackPressed && attack.remainMs === 0) {
attack.remainMs = attack.cooldownMs;
world.events.emit({ type: 'PLAYER_ATTACK', entity: player });
}
}
delayedCall 대신 상태(remainMs)를 시스템에서 직접 관리하는 게 포인트다.

Phaser 3에서는 Sprite 객체를 직접 조작했지만, ECS에서는 다음처럼 분리한다.
export function spriteSyncSystem(
player: Entity,
world: World,
spriteMap: Map<Entity, Phaser.GameObjects.Sprite>,
) {
const pos = world.position.get(player);
const facing = world.facing.get(player);
const sprite = spriteMap.get(player);
if (!pos || !facing || !sprite) return;
sprite.setPosition(pos.x, pos.y);
sprite.setFlipX(facing.dir < 0);
}
이 구조의 장점은 테스트 가능성이다.
한 번에 전부 갈아엎지 말고, 아래 순서로 안전하게 옮기는 게 좋다.
dt(delta time) 단위를 초/밀리초 혼용Grounded 갱신 타이밍이 충돌 처리보다 빠름특히 InputState는 프레임마다 갱신/초기화 규칙을 고정해두는 게 중요하다.
Phaser 3 -> 4 전환의 핵심은 문법이 아니라 책임 분리다.
기능 추가와 디버깅이 훨씬 쉬워진다.
다음 편에서는 이 구조에
을 붙여서 확장하는 방법까지 이어서 다뤄보겠다.
Phaser 3 프로젝트를 Phaser 4로 옮길 때 놓치기 쉬운 포인트를 체크리스트로 정리했습니다. 사전 점검부터 코드 구조, 렌더링, 배포 검증까지 한 번에 확인할 수 있습니다.
Phaser 4의 핵심 변경사항을 Phaser 3와 비교하여 인디게임 개발자 관점에서 정리합니다. 성능, 구조, 렌더링 변화까지 한 번에 이해할 수 있습니다.
Isometric Minesweeper에 난이도별 최고 기록 저장, Best 표시, New best 상태, 기록 초기화 버튼을 추가하고 이번 지뢰찾기 연재를 마무리했습니다.
블로그 iframe 안에서 Isometric Minesweeper를 모바일로 플레이할 수 있도록 tap reveal, long press flag, landscape 안내, height 기반 board layout을 추가했습니다.
지뢰찾기에 난이도 선택과 타이머를 붙이고, 텍스트로 표시하던 깃발과 지뢰를 Phaser Graphics 기반 아이콘으로 바꿔 게임다운 화면으로 다듬었습니다.
지뢰찾기의 손맛을 만드는 빈 칸 연쇄 오픈을 BFS로 구현하고, 새 게임 버튼과 남은 지뢰 수 UI를 추가했습니다.