Skip to main content
Summary

화면은 작고, 월드는 클 수 있다

지금까지 만든 예제는 대부분 화면 안에서 모든 일이 벌어졌다. 하지만 플랫폼 게임이나 RPG에서는 맵이 화면보다 훨씬 크다. 플레이어는 넓은 월드 안을 움직이고, 카메라는 그중 일부만 잘라서 보여준다.

여기서 중요한 구분이 나온다.

  • 월드 좌표: 게임 세계 안에서의 실제 위치

  • 화면 좌표: 브라우저 캔버스에 보이는 위치

예를 들어 플레이어가 월드의 x = 1800, y = 420 위치에 있을 수 있다. 하지만 카메라가 그 근처를 보고 있다면 화면에서는 가운데쯤 보인다. 반대로 화면 왼쪽 위에 있는 버튼은 월드가 어디로 스크롤되든 항상 x = 20, y = 20 근처에 있어야 한다.

카메라를 이해한다는 것은 결국 "이 좌표가 월드 기준인가, 화면 기준인가?"를 구분하는 일이다.

카메라, 월드, 좌표계 이해하기AI 생성 이미지

큰 월드 만들기

먼저 화면보다 큰 월드를 가정해 보자. 이전에 만든 Arcade Physics 예제처럼 플레이어와 발판이 있다고 생각하면 된다.

const config = {
  type: Phaser.AUTO,
  width: 800,
  height: 450,
  backgroundColor: '#1d2333',
  physics: {
    default: 'arcade',
    arcade: {
      gravity: { y: 900 },
      debug: false
    }
  },
  scene: GameScene
};

Scene의 create()에서는 먼저 물리 월드의 경계를 설정한다.

create() {
  const worldWidth = 2400;
  const worldHeight = 900;

  this.physics.world.setBounds(0, 0, worldWidth, worldHeight);

  this.player = this.physics.add.sprite(100, 500, 'player');
  this.player.setCollideWorldBounds(true);

  this.platforms = this.physics.add.staticGroup();
  this.platforms.create(400, 850, 'ground').setScale(2).refreshBody();
  this.platforms.create(1100, 700, 'ground').refreshBody();
  this.platforms.create(1800, 560, 'ground').refreshBody();

  this.physics.add.collider(this.player, this.platforms);
}

this.physics.world.setBounds()는 물리 엔진이 사용하는 월드의 크기를 정한다. this.player.setCollideWorldBounds(true)를 사용하면 플레이어가 이 범위 밖으로 나가지 않는다.

하지만 이것만으로 카메라가 자동으로 움직이지는 않는다. 물리 월드와 카메라는 서로 다른 책임을 가진다.

  • 물리 월드: 충돌과 이동 범위 처리

  • 카메라: 어떤 영역을 화면에 보여줄지 결정

실제 게임에서는 두 경계를 같은 크기로 맞추는 일이 많지만, 둘은 같은 설정이 아니다. 플레이어가 나갈 수 없는 범위와 카메라가 보여줄 수 있는 범위는 필요에 따라 다르게 잡을 수도 있다. 그래서 카메라에도 별도로 경계를 지정해야 한다.

카메라가 플레이어를 따라가게 하기

Phaser의 기본 카메라는 this.cameras.main으로 접근한다. 카메라에 월드 경계를 알려 주고, 플레이어를 따라가게 만들면 넓은 맵을 자연스럽게 탐색할 수 있다. Phaser 카메라는 setBounds(), startFollow(), getWorldPoint() 같은 메서드로 스크롤과 좌표 변환을 제어한다.1

create() {
  const worldWidth = 2400;
  const worldHeight = 900;

  this.physics.world.setBounds(0, 0, worldWidth, worldHeight);

  // 플레이어와 발판 생성 코드...

  const camera = this.cameras.main;
  camera.setBounds(0, 0, worldWidth, worldHeight);
  camera.startFollow(this.player, true, 0.1, 0.1);
}

camera.setBounds(0, 0, worldWidth, worldHeight)는 카메라가 스크롤할 수 있는 최대 영역을 제한한다. 이 설정이 없으면 카메라가 플레이어를 계속 따라가다가 월드 바깥의 빈 공간까지 보여줄 수 있다.

startFollow()는 특정 게임 오브젝트를 따라가게 한다.

camera.startFollow(this.player, true, 0.1, 0.1);

인자의 의미를 간단히 보면 다음과 같다.

  • 첫 번째 인자: 따라갈 대상

  • 두 번째 인자: 렌더링 위치를 정수 픽셀에 맞출지 여부

  • 세 번째 인자: X축 따라가기 부드러움

  • 네 번째 인자: Y축 따라가기 부드러움

두 번째 값을 true로 두면 픽셀 아트 게임에서 미세하게 흐릿해 보이는 현상을 줄이는 데 도움이 된다. 다만 모든 게임에 반드시 필요한 것은 아니므로, 화면이 떨리거나 흐릿해 보일 때 확인해 보면 된다.

세 번째와 네 번째 값은 카메라가 대상을 따라가는 속도에 가깝다. 0.1처럼 낮은 값을 주면 카메라가 플레이어를 부드럽게 따라간다. 1에 가까우면 거의 즉시 따라붙는다. 액션 게임에서는 즉시 반응이 좋을 수 있고, 탐험 게임에서는 약간 늦게 따라오는 편이 안정적으로 느껴진다.

데드존과 시선 여백

카메라가 플레이어의 작은 움직임까지 계속 따라가면 화면이 흔들리는 느낌이 날 수 있다. 이때는 데드존을 사용한다. 데드존은 카메라 중앙 근처에 보이지 않는 여유 영역을 두고, 플레이어가 그 영역을 벗어날 때만 카메라가 움직이게 하는 방식이다.

camera.setDeadzone(160, 100);

이렇게 하면 플레이어가 160 x 100 크기의 영역 안에서 조금 움직이는 동안에는 카메라가 바로 반응하지 않는다. 작은 좌우 이동이나 점프 때문에 화면이 계속 흔들리는 느낌을 줄일 수 있다.

플랫폼 게임에서는 플레이어가 바라보는 방향 쪽을 조금 더 보여주고 싶을 때가 많다. 이때는 follow offset을 쓴다.

camera.setFollowOffset(-80, 0);

이 값은 카메라가 대상을 화면의 정확한 중앙이 아니라 약간 치우친 위치에 두도록 만든다. 단, 좌우 방향에 따라 offset을 바꾸고 싶다면 플레이어 방향을 확인해 업데이트해야 한다.

update() {
  const camera = this.cameras.main;

  if (this.cursors.left.isDown) {
    this.player.setVelocityX(-220);
    camera.setFollowOffset(80, 0);
  } else if (this.cursors.right.isDown) {
    this.player.setVelocityX(220);
    camera.setFollowOffset(-80, 0);
  } else {
    this.player.setVelocityX(0);
  }
}

왼쪽으로 갈 때는 왼쪽 시야를 더 보여주고, 오른쪽으로 갈 때는 오른쪽 시야를 더 보여주는 식이다. 위 코드에서는 멈췄을 때도 마지막으로 움직이던 방향의 시야가 유지된다. 숫자의 방향이 직관과 반대로 느껴질 수 있으니, 직접 실행해 보며 조정하는 것이 좋다.

화면 좌표를 월드 좌표로 바꾸기

마우스나 터치 입력을 사용할 때 가장 자주 헷갈리는 부분이 좌표다. 포인터의 x, y는 기본적으로 게임 캔버스 안에서의 화면 좌표다.

예를 들어 카메라가 월드의 오른쪽으로 많이 이동한 상태에서 화면 가운데를 클릭했다고 하자. 포인터 좌표는 여전히 x = 400, y = 225 근처일 수 있다. 하지만 월드 기준으로는 x = 1600, y = 500일 수 있다.

월드 안의 위치를 클릭해야 한다면 카메라를 통해 변환해야 한다.

this.input.on('pointerdown', (pointer) => {
  const worldPoint = this.cameras.main.getWorldPoint(pointer.x, pointer.y);

  console.log('화면 좌표:', pointer.x, pointer.y);
  console.log('월드 좌표:', worldPoint.x, worldPoint.y);
});

이제 클릭한 위치에 효과를 만들 수 있다.

this.input.on('pointerdown', (pointer) => {
  const pos = this.cameras.main.getWorldPoint(pointer.x, pointer.y);
  this.add.image(pos.x, pos.y, 'marker');
});

만약 pointer.x, pointer.y를 그대로 사용하면 카메라가 움직인 뒤에는 엉뚱한 위치에 표시될 수 있다. 월드에 배치할 오브젝트는 월드 좌표를 써야 한다.

Phaser에는 포인터의 월드 좌표를 다루는 속성도 있지만, 여러 카메라를 쓰거나 "어떤 카메라 기준으로 변환할지"를 분명히 하고 싶을 때는 camera.getWorldPoint()를 직접 쓰는 편이 안전하다.

반대로 UI 버튼처럼 화면에 고정될 대상은 화면 좌표를 쓰는 편이 맞다.

카메라 효과로 손맛 만들기

카메라는 위치만 바꾸는 도구가 아니다. 충격, 장면 전환, 피격 표현 같은 연출에도 자주 쓰인다. Phaser 카메라는 흔들림, 번쩍임, 페이드, 줌 전환 같은 효과를 제공한다.2

흔들림: shake

적을 맞히거나 플레이어가 피해를 입었을 때 화면을 짧게 흔들면 충격이 더 잘 느껴진다.

this.cameras.main.shake(150, 0.01);

첫 번째 값은 지속 시간(ms), 두 번째 값은 강도다. 강도가 너무 크면 조작이 불편해진다. 보통 짧고 약하게 쓰는 편이 좋다.

번쩍임: flash

아이템 획득이나 폭발 순간에는 화면을 잠깐 밝게 만들 수 있다.

this.cameras.main.flash(120, 255, 255, 255);

뒤의 세 숫자는 RGB 색상이다. 흰색 번쩍임은 강한 충격, 붉은 번쩍임은 피해, 노란 번쩍임은 보상처럼 의미를 나누어 사용할 수 있다.

페이드: fadeIn, fadeOut

Scene 전환에는 즉시 바꾸는 것보다 페이드를 넣는 편이 자연스럽다.

this.cameras.main.fadeOut(500, 0, 0, 0);

this.cameras.main.once('camerafadeoutcomplete', () => {
  this.scene.start('NextScene');
});

새 Scene이 시작될 때는 반대로 fadeIn()을 호출한다.

create() {
  this.cameras.main.fadeIn(500, 0, 0, 0);
}

줌: zoom과 zoomTo

카메라의 zoom 값을 바꾸면 화면을 확대하거나 축소할 수 있다.

this.cameras.main.setZoom(1.5);

즉시 바꾸면 튀어 보일 수 있으므로 연출에는 zoomTo()가 더 부드럽다.

this.cameras.main.zoomTo(1.3, 300);

다만 줌을 많이 쓰면 보이는 영역이 크게 달라진다. 플랫폼 게임에서는 점프 거리나 적 등장 타이밍을 판단하기 어려워질 수 있으므로 중요한 순간에만 짧게 사용하는 것이 좋다.

스크롤되는 월드와 고정 UI 분리하기

게임 화면에는 월드와 UI가 함께 있다. 플레이어, 적, 발판, 코인은 카메라를 따라 스크롤되어야 한다. 하지만 체력, 점수, 일시정지 버튼은 화면에 고정되어야 한다.

가장 간단한 방법은 UI 오브젝트에 setScrollFactor(0)을 주는 것이다.

this.scoreText = this.add.text(20, 20, 'Score: 0', {
  fontSize: '24px',
  color: '#ffffff'
});

this.scoreText.setScrollFactor(0);
this.scoreText.setDepth(1000);

setScrollFactor(0)은 카메라 스크롤의 영향을 받지 않게 한다. 즉, 월드가 움직여도 화면의 같은 위치에 남는다. setDepth(1000)은 다른 오브젝트보다 앞에 보이도록 하는 설정이다.

배경에도 같은 개념을 응용할 수 있다.

const sky = this.add.image(400, 225, 'sky');
sky.setScrollFactor(0);

const mountains = this.add.tileSprite(400, 300, 800, 300, 'mountains');
mountains.setScrollFactor(0.2);

0이면 완전히 고정되고, 0.2면 월드보다 느리게 움직인다. 이렇게 먼 배경이 느리게 움직이도록 만들면 간단한 패럴랙스 효과가 생긴다.

프로젝트가 커지면 UI를 별도 Scene으로 분리하는 방법도 좋다. 예를 들어 GameScene은 월드와 플레이어를 담당하고, UIScene은 점수와 체력을 담당한다. 이렇게 나누면 카메라 스크롤과 UI 갱신이 섞이지 않는다. 이후 상태 관리 장에서 이 구조를 더 자세히 다룬다.

여러 카메라의 기본 아이디어

Phaser에서는 카메라를 하나만 써야 하는 것은 아니다. this.cameras.add()로 카메라를 추가할 수 있다. 이를 이용하면 미니맵, 분할 화면, 특정 영역 확대 같은 구성을 만들 수 있다.

간단한 미니맵 카메라를 만들어 보자.

const minimap = this.cameras.add(600, 20, 180, 120);
minimap.setBounds(0, 0, 2400, 900);
minimap.setZoom(0.15);
minimap.startFollow(this.player);
minimap.setBackgroundColor(0x000000);

this.cameras.add(600, 20, 180, 120)는 화면의 (600, 20) 위치에 180 x 120 크기의 카메라 뷰포트를 만든다. 이 카메라는 같은 월드를 작게 보여준다.

다만 여러 카메라를 쓰면 주의할 점이 있다. 기본적으로 카메라는 자신이 볼 수 있는 모든 오브젝트를 렌더링한다. 그래서 UI나 미니맵 테두리까지 미니맵 안에 다시 보일 수 있다. 이때는 특정 카메라가 특정 오브젝트를 무시하게 할 수 있다.

const frame = this.add.rectangle(690, 80, 184, 124, 0xffffff, 0);
frame.setStrokeStyle(2, 0xffffff);
frame.setScrollFactor(0);

minimap.ignore(frame);
minimap.ignore(this.scoreText);

ignore()에 넘긴 오브젝트는 그 카메라에는 보이지 않는다. 반대로 메인 카메라에서만 보여야 하는 오브젝트, 미니맵에서만 보여야 하는 표시를 나누어 관리하면 화면 구성이 깔끔해진다.

분할 화면도 같은 원리다. 카메라 두 개를 만들고, 각각 다른 플레이어를 따라가게 하면 된다. 핵심은 카메라가 월드를 복사하는 것이 아니라 "같은 월드를 서로 다른 창으로 본다"는 점이다.

미니 프로젝트: 넓은 플랫폼 맵에 카메라 붙이기

이번 장의 내용을 하나의 흐름으로 합치면 다음과 같다.

create() {
  const worldWidth = 2400;
  const worldHeight = 900;

  this.physics.world.setBounds(0, 0, worldWidth, worldHeight);

  this.player = this.physics.add.sprite(100, 500, 'player');
  this.player.setCollideWorldBounds(true);

  this.platforms = this.physics.add.staticGroup();
  this.platforms.create(400, 850, 'ground').setScale(2).refreshBody();
  this.platforms.create(1100, 700, 'ground').refreshBody();
  this.platforms.create(1800, 560, 'ground').refreshBody();

  this.physics.add.collider(this.player, this.platforms);

  const camera = this.cameras.main;
  camera.setBounds(0, 0, worldWidth, worldHeight);
  camera.startFollow(this.player, true, 0.12, 0.12);
  camera.setDeadzone(160, 100);

  this.scoreText = this.add.text(20, 20, 'Score: 0', {
    fontSize: '24px',
    color: '#ffffff'
  });
  this.scoreText.setScrollFactor(0).setDepth(1000);

  this.input.on('pointerdown', (pointer) => {
    const pos = camera.getWorldPoint(pointer.x, pointer.y);
    this.add.image(pos.x, pos.y, 'marker');
  });
}

이 예제에서 확인할 것은 세 가지다.

  1. 플레이어는 월드 좌표 안에서 움직인다.

  2. 카메라는 플레이어를 따라가며 월드 일부만 보여준다.

  3. UI는 카메라 스크롤과 상관없이 화면에 고정된다.

이 세 가지가 분리되면 넓은 맵을 다루는 코드가 훨씬 안정적이 된다.

정리

카메라는 단순히 플레이어를 따라다니는 기능이 아니다. 카메라는 월드의 어느 부분을 보여줄지 결정하고, 좌표 변환을 도와주며, 화면 연출까지 담당한다.

월드 좌표와 화면 좌표를 구분하면 클릭 위치, UI 배치, 넓은 맵 이동에서 생기는 많은 오류를 피할 수 있다. startFollow()와 setBounds()를 사용하면 플레이어 중심의 탐색 화면을 만들 수 있고, shake, flash, fade, zoom을 적절히 섞으면 게임의 반응이 더 선명해진다.

다음 장에서는 넓은 월드를 손으로 하나씩 배치하는 대신, 타일맵을 이용해 레벨을 만드는 방법으로 넘어간다. 카메라와 좌표계를 이해해 두면 타일맵의 위치, 충돌 레이어, 맵 크기를 훨씬 쉽게 다룰 수 있다.

참고

1Cameras | Phaser Help

2Camera | Phaser API Documentation

책 목록

Phaser로 시작하는 웹 2D 게임 개발

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기