Skip to main content
Summary

직접 배치에서 맵 데이터로 이동하기

이전 장까지는 바닥, 코인, 적을 코드 안에서 직접 배치했다. 예를 들면 platforms.create(400, 568, 'ground')처럼 좌표를 하나씩 적는 방식이다. 작은 예제에서는 충분하지만, 레벨이 길어지면 곧 문제가 생긴다.

발판 하나를 오른쪽으로 옮기려면 코드를 찾아 수정해야 한다. 코인을 30개 배치하면 create()가 좌표 목록으로 가득 찬다. 배경, 벽, 장식, 적, 시작 지점까지 모두 코드로 관리하면 레벨 디자인을 실험하기 어렵다.

타일맵은 이 문제를 해결한다. 레벨을 코드가 아니라 데이터로 저장한다. 코드는 그 데이터를 읽어 화면에 그리고, 충돌을 연결하고, 오브젝트를 생성한다. 즉, 코드의 역할은 "레벨을 직접 만드는 것"에서 "레벨 데이터를 해석하는 것"으로 바뀐다.

Phaser에서 많이 쓰는 흐름은 다음과 같다.

  1. Tiled 같은 맵 편집 도구에서 맵을 만든다.

  2. 맵을 JSON 파일로 내보낸다.

  3. Phaser에서 JSON과 타일셋 이미지를 불러온다.

  4. 타일 레이어를 화면에 그린다.

  5. 충돌 레이어와 오브젝트 레이어를 게임 규칙에 연결한다.

이번 장에서는 이 흐름으로 간단한 플랫폼 게임 레벨을 만든다.

타일맵으로 레벨 만들기AI 생성 이미지

타일맵의 세 가지 재료

타일맵을 이해하려면 먼저 세 가지 단어를 알아야 한다.

타일셋은 작은 타일 이미지들이 모여 있는 큰 이미지다. 바닥, 벽, 풀, 상자, 가시 같은 조각들이 일정한 크기로 배열되어 있다. 타일 크기가 32×32라면, 맵 편집기는 이 이미지를 32픽셀 단위로 잘라 하나의 블록처럼 사용한다.

타일 레이어는 격자 위에 타일을 배치한 층이다. 보통 레이어를 역할별로 나눈다.

  • Background: 배경 벽, 먼 장식

  • Ground: 플레이어가 밟는 바닥과 벽

  • Decoration: 풀, 표지판, 기둥 같은 장식

Phaser에서는 createLayer()를 호출한 순서대로 레이어가 화면에 쌓인다. 먼저 만든 레이어가 뒤에 있고, 나중에 만든 레이어가 앞에 보인다. 따라서 배경을 먼저 만들고, 바닥과 장식을 그다음에 만드는 식으로 구성한다. Tiled 안에서도 같은 의도로 레이어 순서를 정리해 두면 나중에 맵을 고칠 때 덜 헷갈린다.

오브젝트 레이어는 격자에 딱 맞지 않아도 되는 정보를 담는 층이다. 플레이어 시작 위치, 적 위치, 코인, 문, 이벤트 영역은 보통 오브젝트 레이어에 둔다. 이런 요소들은 타일처럼 반복되는 배경 조각이 아니라 게임 규칙과 직접 연결되는 대상이기 때문이다.

맵 데이터 안에는 맵의 가로·세로 타일 개수, 타일 크기, 레이어 목록, 각 칸에 놓인 타일 번호, 오브젝트 위치, 타일 속성 등이 들어 있다. Phaser는 이 데이터를 읽어 실제 게임 오브젝트와 물리 충돌로 바꾼다.

Tiled에서 JSON 맵 만들기

Tiled에서 새 맵을 만들 때는 처음에는 단순하게 시작하는 것이 좋다.

  • 방향: Orthogonal

  • 타일 크기: 32×32

  • 맵 크기: 예를 들어 100×20

  • 타일셋 이름: dungeon_tiles

  • 레이어 이름: Background, Ground, Decoration, Objects

중요한 점은 Tiled의 타일셋 이름과 Phaser 코드의 이름이 맞아야 한다는 것이다. 예를 들어 Tiled에서 타일셋 이름을 dungeon_tiles로 만들었다면, Phaser의 map.addTilesetImage() 첫 번째 인자도 dungeon_tiles여야 한다.

처음 연동할 때는 타일셋을 맵에 포함시키는 방식으로 저장하면 경로 문제를 줄일 수 있다. 또한 JSON으로 내보낼 때 타일 레이어 데이터는 압축되지 않은 형식으로 두는 편이 안전하다. Tiled에서 바꾼 뒤에는 반드시 다시 저장하거나 JSON으로 다시 내보내야 Phaser가 최신 맵을 읽는다1.

파일 구조는 예를 들어 이렇게 둔다.

public/
  assets/
    maps/
      level-1.json
    tiles/
      dungeon.png
    sprites/
      player.png
      coin.png
      enemy.png

이제 Phaser에서 맵을 불러온다.

preload() {
  this.load.image('tiles-dungeon', 'assets/tiles/dungeon.png')
  this.load.tilemapTiledJSON('map-level-1', 'assets/maps/level-1.json')

  this.load.spritesheet('player', 'assets/sprites/player.png', {
    frameWidth: 32,
    frameHeight: 32
  })
  this.load.image('coin', 'assets/sprites/coin.png')
  this.load.image('enemy', 'assets/sprites/enemy.png')
}

tiles-dungeon은 Phaser에서 타일셋 이미지를 부르는 키다. map-level-1은 JSON 맵을 부르는 키다. 실제 파일 이름과 같을 필요는 없지만, 프로젝트가 커지면 키 이름을 규칙적으로 맞추는 것이 좋다.

create()에서는 맵을 만들고, 타일셋 이미지를 연결하고, 레이어를 생성한다.

create() {
  const map = this.make.tilemap({ key: 'map-level-1' })
  const tileset = map.addTilesetImage('dungeon_tiles', 'tiles-dungeon')

  const background = map.createLayer('Background', tileset, 0, 0)
  const ground = map.createLayer('Ground', tileset, 0, 0)
  const decoration = map.createLayer('Decoration', tileset, 0, 0)
}

여기서 가장 자주 하는 실수는 addTilesetImage()의 두 이름을 헷갈리는 것이다.

map.addTilesetImage('Tiled에서 정한 타일셋 이름', 'Phaser에서 로드한 이미지 키')

맵이 보이지 않는다면 먼저 이 둘을 확인하자. JSON 경로, PNG 경로, 타일셋 이름, 레이어 이름 중 하나만 틀려도 화면이 비어 보일 수 있다.

타일 속성으로 충돌 만들기

타일맵을 화면에 그렸다고 해서 자동으로 충돌이 생기지는 않는다. 어떤 타일이 막히는 타일인지 Phaser에 알려줘야 한다.

가장 관리하기 쉬운 방법은 Tiled에서 타일 속성을 지정하는 것이다. 타일셋 편집 화면에서 바닥이나 벽 타일을 선택하고 사용자 속성을 추가한다.

collides = true

이때 collides는 문자열이 아니라 Boolean 타입의 true로 두는 것이 좋다. Phaser에서 { collides: true }로 찾을 것이기 때문이다. 속성 이름도 Collides, collision, collide처럼 조금이라도 달라지면 매칭되지 않는다.

그러면 Phaser에서는 이 속성을 가진 타일만 충돌 대상으로 만들 수 있다.

const ground = map.createLayer('Ground', tileset, 0, 0)
ground.setCollisionByProperty({ collides: true })

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

this.physics.add.collider(this.player, ground)

setCollisionByProperty()는 타일의 사용자 속성을 기준으로 충돌 타일을 고른다. 타일 번호로 충돌을 지정하는 방법도 있지만, 속성을 쓰면 레벨을 수정하기 쉽다. 나중에 새 벽 타일을 추가해도 Tiled에서 collides만 체크하면 코드 수정 없이 충돌이 적용된다.

충돌이 이상하다면 디버그 표시를 켜서 확인한다.

const debugGraphics = this.add.graphics().setAlpha(0.7)

ground.renderDebug(debugGraphics, {
  tileColor: null,
  collidingTileColor: new Phaser.Display.Color(255, 0, 0, 255)
})

빨간색으로 표시되는 타일이 실제 충돌 타일이다. 플레이어가 보이지 않는 벽에 막히거나 바닥을 통과한다면, Tiled의 속성이 제대로 들어갔는지 먼저 확인하자.

이제 맵 크기를 월드와 카메라에도 연결한다. 지난 장에서 다룬 월드 경계와 같은 개념이다.

this.physics.world.setBounds(0, 0, map.widthInPixels, map.heightInPixels)
this.cameras.main.setBounds(0, 0, map.widthInPixels, map.heightInPixels)
this.cameras.main.startFollow(this.player)

맵이 100×20타일이고 타일 크기가 32픽셀이라면 월드 크기는 3200×640픽셀이다. 카메라가 이 범위 안에서만 움직이게 하면 맵 밖의 빈 공간이 보이지 않는다.

오브젝트 레이어로 시작 위치와 아이템 배치하기

타일 레이어는 바닥과 배경에 적합하다. 하지만 플레이어 시작 위치, 코인, 적, 출구는 타일보다 오브젝트 레이어에 두는 편이 좋다. 이런 정보는 "그림을 그리는 데이터"가 아니라 "게임 규칙을 시작하는 데이터"에 가깝다.

Tiled에서 Objects라는 오브젝트 레이어를 만들고 다음 오브젝트를 배치해 보자.

  • PlayerStart: 플레이어 시작 위치

  • Coin: 코인 위치

  • Enemy: 적 위치

  • Exit: 다음 레벨로 가는 영역

시작 위치나 코인은 Point 오브젝트로 배치하면 다루기 쉽다. 출구처럼 넓이가 필요한 대상은 Rectangle 오브젝트로 만든다. Tiled의 사각형 오브젝트 좌표는 보통 왼쪽 위 모서리를 기준으로 저장되지만, Phaser의 Zone은 기본 원점이 가운데다. 그래서 사각형 영역을 만들 때는 원점을 맞추거나 위치를 보정해야 한다.

Phaser에서는 getObjectLayer()로 오브젝트 목록을 읽을 수 있다. 오브젝트 레이어는 createFromObjects() 같은 메서드로 바로 스프라이트를 만들 수도 있지만, 처음에는 직접 순회하며 생성하는 방식이 이해하기 쉽다2.

function readProperty(obj, name, fallback) {
  const property = obj.properties?.find((item) => item.name === name)
  return property ? property.value : fallback
}

createObjectsFromMap(map) {
  const objectLayer = map.getObjectLayer('Objects')
  const objects = objectLayer?.objects ?? []

  this.coins = this.physics.add.staticGroup()
  this.enemies = this.physics.add.group()

  let spawn = { x: 64, y: 64 }

  objects.forEach((obj) => {
    if (obj.name === 'PlayerStart') {
      spawn = { x: obj.x, y: obj.y }
    }

    if (obj.name === 'Coin') {
      this.coins.create(obj.x, obj.y, 'coin')
    }

    if (obj.name === 'Enemy') {
      const enemy = this.enemies.create(obj.x, obj.y, 'enemy')
      const speed = readProperty(obj, 'speed', 60)
      enemy.setData('speed', speed)
    }

    if (obj.name === 'Exit') {
      this.exitZone = this.add.zone(obj.x, obj.y, obj.width, obj.height)
      this.exitZone.setOrigin(0, 0)
      this.physics.add.existing(this.exitZone, true)
    }
  })

  this.player = this.physics.add.sprite(spawn.x, spawn.y, 'player')
}

이렇게 만들면 레벨 디자이너는 Tiled에서 코인을 옮기고 적을 추가할 수 있다. 코드를 수정하지 않아도 된다. 적의 속도처럼 개별 오브젝트마다 달라지는 값은 사용자 속성으로 넣어 읽으면 된다.

오브젝트를 만든 뒤에는 물리 규칙을 연결한다.

this.physics.add.collider(this.player, ground)
this.physics.add.collider(this.enemies, ground)

this.physics.add.overlap(this.player, this.coins, this.collectCoin, null, this)
this.physics.add.overlap(this.player, this.enemies, this.hitEnemy, null, this)

if (this.exitZone) {
  this.physics.add.overlap(this.player, this.exitZone, this.goNextLevel, null, this)
}

여기서 다시 collider와 overlap의 차이가 중요해진다. 바닥은 통과하면 안 되므로 collider를 쓴다. 코인, 적 피격, 출구 진입은 겹침을 감지하면 되므로 overlap을 쓴다.

예제 코드를 하나로 묶으면 순서는 대략 이렇게 된다.

create() {
  const map = this.make.tilemap({ key: 'map-level-1' })
  const tileset = map.addTilesetImage('dungeon_tiles', 'tiles-dungeon')

  map.createLayer('Background', tileset, 0, 0)
  const ground = map.createLayer('Ground', tileset, 0, 0)
  map.createLayer('Decoration', tileset, 0, 0)

  ground.setCollisionByProperty({ collides: true })

  this.createObjectsFromMap(map)

  this.physics.world.setBounds(0, 0, map.widthInPixels, map.heightInPixels)
  this.cameras.main.setBounds(0, 0, map.widthInPixels, map.heightInPixels)
  this.cameras.main.startFollow(this.player)

  this.physics.add.collider(this.player, ground)
  this.physics.add.collider(this.enemies, ground)
  this.physics.add.overlap(this.player, this.coins, this.collectCoin, null, this)

  if (this.exitZone) {
    this.physics.add.overlap(this.player, this.exitZone, this.goNextLevel, null, this)
  }
}

핵심은 레이어를 만든 뒤 충돌을 지정하고, 오브젝트 레이어에서 실제 스프라이트를 만든 다음, 마지막에 물리 관계를 연결하는 것이다. 이 순서를 지키면 this.player나 this.coins가 아직 없어서 생기는 오류를 줄일 수 있다.

여러 레벨을 같은 코드로 관리하기

타일맵의 장점은 레벨이 늘어날수록 더 커진다. level-1.json, level-2.json, level-3.json을 같은 규칙으로 만들면 하나의 Scene 코드로 여러 레벨을 실행할 수 있다.

핵심은 레벨마다 같은 이름을 유지하는 것이다.

  • 충돌 레이어 이름은 항상 Ground

  • 오브젝트 레이어 이름은 항상 Objects

  • 시작 위치 이름은 항상 PlayerStart

  • 출구 이름은 항상 Exit

  • 타일셋 이름은 가능하면 동일하게 유지

그다음 Scene에 현재 레벨 정보를 전달한다.

const LEVELS = {
  'level-1': {
    mapKey: 'map-level-1',
    mapUrl: 'assets/maps/level-1.json',
    next: 'level-2'
  },
  'level-2': {
    mapKey: 'map-level-2',
    mapUrl: 'assets/maps/level-2.json',
    next: null
  }
}
init(data) {
  this.levelId = data.levelId ?? 'level-1'
}

preload() {
  const level = LEVELS[this.levelId]

  this.load.image('tiles-dungeon', 'assets/tiles/dungeon.png')
  this.load.image('coin', 'assets/sprites/coin.png')
  this.load.image('enemy', 'assets/sprites/enemy.png')
  this.load.tilemapTiledJSON(level.mapKey, level.mapUrl)
}

create() {
  const level = LEVELS[this.levelId]
  const map = this.make.tilemap({ key: level.mapKey })

  // 여기에서 레이어 생성, 충돌 연결, 오브젝트 배치를 수행한다.
}

goNextLevel() {
  const nextLevelId = LEVELS[this.levelId].next

  if (nextLevelId) {
    this.scene.restart({ levelId: nextLevelId })
  }
}

작은 게임에서는 이 정도 구조면 충분하다. 더 큰 프로젝트에서는 Loading Scene에서 모든 공통 에셋을 먼저 불러오고, Play Scene은 현재 레벨의 JSON만 선택해 읽는 방식으로 나눌 수 있다. 여러 레벨을 한 번에 미리 로드할 수도 있지만, 레벨 수가 많아지면 필요한 JSON만 불러오는 편이 초기 로딩 시간을 줄이는 데 유리하다.

미니 프로젝트: 짧은 플랫폼 레벨 만들기

이번 장의 목표는 긴 맵이 아니라 "수정하기 쉬운 맵"을 만드는 것이다. 다음 조건으로 짧은 레벨을 만들어 보자.

  1. Tiled에서 32×32 타일 크기의 맵을 만든다.

  2. Background, Ground, Decoration, Objects 레이어를 만든다.

  3. 바닥과 벽 타일에 Boolean 타입의 collides = true 속성을 추가한다.

  4. Objects 레이어에 PlayerStart, Coin, Enemy, Exit을 배치한다.

  5. Phaser에서 JSON 맵을 불러와 레이어를 그린다.

  6. Ground 레이어와 플레이어 충돌을 연결한다.

  7. 코인과 출구는 overlap으로 처리한다.

  8. 카메라와 물리 월드 경계를 map.widthInPixels, map.heightInPixels로 설정한다.

완성 후에는 코드를 건드리지 말고 Tiled에서만 레벨을 수정해 보자. 발판을 옮기고, 코인을 추가하고, 적 위치를 바꾼 뒤 JSON을 다시 내보낸다. 게임을 새로고침했을 때 변경 사항이 반영된다면 타일맵 기반 제작 흐름을 제대로 만든 것이다.

타일맵은 단순히 배경을 편하게 그리는 기능이 아니다. 레벨 디자인, 충돌, 오브젝트 배치, 레벨 전환을 데이터 중심으로 바꾸는 구조다. 이 구조를 갖추면 다음 장에서 UI와 게임 상태를 붙일 때도 코드가 훨씬 덜 복잡해진다.

참고

1A Noob's Guide to Loading Tiled Tilemaps in Phaser 3

2Phaser Tilemap notes

책 목록

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

전체 목차 보기

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

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

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

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

참여하기