Skip to main content
Summary

적은 움직이는 장애물이 아니다

플레이어가 움직이고, 코인이 있고, 충돌이 된다고 해서 곧바로 게임이 되지는 않습니다. 게임다운 흐름은 '무엇을 하면 이기고', '무엇을 하면 위험해지며', '상황이 변할수록 어떤 선택을 해야 하는가'에서 나옵니다.

적 AI는 이 흐름을 만드는 중요한 장치입니다. 여기서 말하는 AI는 복잡한 인공지능이 아닙니다. 적이 플레이어를 보고, 가까워지면 따라오고, 닿으면 피해를 주고, 멀어지면 다시 돌아가는 정도면 충분합니다. 핵심은 적의 행동을 한 덩어리로 쓰지 않고 상태별로 나누는 것입니다.

이번 장에서는 다음 네 가지를 연결합니다.

  • 적의 상태: 대기, 순찰, 추적, 공격

  • 플레이어와의 거리: 반응할지 말지 결정하는 기준

  • 난이도: 적 수, 속도, 등장 간격 조절

  • 게임 규칙: 승리, 패배, 제한 시간, 목표 수집

적 AI와 게임 규칙 설계AI 생성 이미지

상태 머신으로 적 행동 나누기

상태 머신은 현재 상태에 따라 다른 코드를 실행하는 구조입니다. 말은 어렵지만 실제로는 enemy.state 값 하나를 보고 행동을 나누는 방식입니다.

예를 들어 적은 다음 상태를 가질 수 있습니다.

  • idle: 가만히 대기

  • patrol: 정해진 범위를 순찰

  • chase: 플레이어를 추적

  • attack: 플레이어를 공격

먼저 적을 만들 때 필요한 값을 함께 저장합니다. 아래 예제에서는 this.enemies라는 그룹이 이미 만들어져 있다고 가정합니다.

createEnemy(x, y) {
  const enemy = this.physics.add.sprite(x, y, 'enemy');

  enemy.state = 'patrol';
  enemy.speed = 70;
  enemy.direction = -1;
  enemy.detectRange = 180;
  enemy.attackRange = 36;
  enemy.patrolLeft = x - 80;
  enemy.patrolRight = x + 80;
  enemy.attackCooldown = 0;

  enemy.setCollideWorldBounds(true);
  this.physics.add.collider(enemy, this.groundLayer);

  this.enemies.add(enemy);
  return enemy;
}

여기서 중요한 점은 적마다 자신의 판단 기준을 가진다는 것입니다. 모든 적이 같은 속도와 같은 감지 거리를 가져도 되지만, 값을 조금씩 다르게 주면 더 다양한 레벨을 만들 수 있습니다.

이제 update()에서 모든 적을 갱신합니다.

update(time, delta) {
  this.updatePlayer();

  this.enemies.children.iterate((enemy) => {
    if (!enemy || !enemy.active) return;
    this.updateEnemy(enemy, time, delta);
  });
}

적 행동은 별도 함수로 분리합니다. 그래야 플레이어 조작, UI, 충돌 코드와 섞이지 않습니다.

updateEnemy(enemy, time, delta) {
  const distance = Phaser.Math.Distance.Between(
    enemy.x, enemy.y,
    this.player.x, this.player.y
  );

  if (distance <= enemy.attackRange) {
    enemy.state = 'attack';
  } else if (distance <= enemy.detectRange) {
    enemy.state = 'chase';
  } else if (enemy.state !== 'patrol') {
    enemy.state = 'patrol';
  }

  switch (enemy.state) {
    case 'idle':
      this.enemyIdle(enemy);
      break;
    case 'patrol':
      this.enemyPatrol(enemy);
      break;
    case 'chase':
      this.enemyChase(enemy);
      break;
    case 'attack':
      this.enemyAttack(enemy, time);
      break;
  }
}

위 예제의 거리는 두 스프라이트의 중심점 사이 거리입니다. 실제 게임에서는 이미지 크기나 몸통 판정에 따라 attackRange를 조금 넉넉하게 잡거나, 물리 바디의 겹침 판정을 함께 쓰면 더 자연스럽습니다.

이 구조의 장점은 명확합니다. 적이 이상하게 움직일 때 어느 상태에서 문제가 생겼는지 바로 찾을 수 있습니다. 또 나중에 stun, return, dead 같은 상태를 추가하기도 쉽습니다.

순찰과 추적 구현하기

순찰은 적이 정해진 좌우 범위 안에서 오가는 행동입니다. 플랫폼 게임에서는 가장 기본적인 적 행동입니다.

enemyPatrol(enemy) {
  enemy.setVelocityX(enemy.speed * enemy.direction);

  if (enemy.x <= enemy.patrolLeft) {
    enemy.direction = 1;
    enemy.setFlipX(false);
  }

  if (enemy.x >= enemy.patrolRight) {
    enemy.direction = -1;
    enemy.setFlipX(true);
  }

  enemy.play('enemy-walk', true);
}

추적은 플레이어의 위치를 기준으로 방향을 정합니다. 플레이어가 오른쪽에 있으면 오른쪽으로, 왼쪽에 있으면 왼쪽으로 이동합니다.

enemyChase(enemy) {
  const direction = this.player.x < enemy.x ? -1 : 1;

  enemy.setVelocityX(enemy.speed * 1.4 * direction);
  enemy.setFlipX(direction < 0);
  enemy.play('enemy-run', true);
}

여기서 1.4는 추적 상태에서 더 빠르게 움직이게 하는 배율입니다. 단, 적이 너무 빠르면 플레이어는 대응할 시간이 없습니다. 좋은 추적 속도는 플레이어보다 약간 느리거나, 짧은 구간에서만 빠른 정도입니다.

거리 기반 AI를 만들 때는 가로 거리만 볼 수도 있고, 실제 거리를 볼 수도 있습니다. 플랫폼 게임에서는 위아래 층이 나뉘는 경우가 많으므로 단순 거리만 쓰면 아래층 적이 위층 플레이어를 보고 달려오는 어색한 상황이 생길 수 있습니다. 이런 경우에는 y 차이를 함께 제한합니다.

const dx = Math.abs(this.player.x - enemy.x);
const dy = Math.abs(this.player.y - enemy.y);

if (dx < enemy.detectRange && dy < 60) {
  enemy.state = 'chase';
}

완벽한 판단보다 중요한 것은 납득 가능한 판단입니다. 플레이어가 보기에 '저 정도면 적이 나를 발견할 만하다'고 느끼면 충분합니다.

공격과 쿨다운 만들기

공격은 매 프레임 피해를 주면 안 됩니다. 적과 닿아 있는 동안 1초에 60번 체력이 깎이면 게임이 불공정해집니다. 그래서 공격에는 쿨다운이 필요합니다. 쿨다운은 다음 공격까지 기다리는 시간입니다.

enemyAttack(enemy, time) {
  enemy.setVelocityX(0);
  enemy.play('enemy-attack', true);

  if (time < enemy.attackCooldown) return;

  this.damagePlayer(1);
  enemy.attackCooldown = time + 1000;
}

time은 Phaser가 update(time, delta)에 넘겨주는 현재 시간입니다. 위 코드는 한 번 공격한 뒤 1000밀리초, 즉 1초 동안 다시 피해를 주지 않습니다.

피해를 받은 플레이어에게도 무적 시간을 줄 수 있습니다.

damagePlayer(amount) {
  if (this.playerInvincible) return;

  this.health -= amount;
  this.events.emit('health-changed', this.health);

  this.playerInvincible = true;
  this.player.setTint(0xff6666);

  this.time.delayedCall(800, () => {
    this.playerInvincible = false;
    this.player.clearTint();
  });

  if (this.health <= 0) {
    this.loseGame();
  }
}

지난 장에서 다룬 사운드, 카메라 흔들림, 피격 깜빡임은 이런 지점에 연결하면 좋습니다. 규칙이 바뀌는 순간에 피드백을 주면 플레이어는 방금 무슨 일이 일어났는지 바로 이해합니다.

난이도는 숫자의 조합이다

난이도는 적을 무작정 많이 배치한다고 좋아지지 않습니다. 적 수, 속도, 감지 거리, 공격력, 등장 간격, 회복 아이템 수가 함께 난이도를 만듭니다.

가장 다루기 쉬운 방법은 난이도 설정 객체를 만드는 것입니다.

this.difficulty = {
  enemySpeed: 80,
  spawnDelay: 2500,
  maxEnemies: 5,
  detectRange: 180
};

스폰은 일정 시간마다 적을 생성하는 방식입니다.

startEnemySpawner() {
  this.spawnTimer = this.time.addEvent({
    delay: this.difficulty.spawnDelay,
    loop: true,
    callback: () => {
      if (this.gameEnded) return;
      if (this.enemies.countActive(true) >= this.difficulty.maxEnemies) return;
      this.spawnEnemy();
    }
  });
}

적을 생성할 위치는 무작위로 정할 수 있습니다. 하지만 완전한 무작위는 위험합니다. 플레이어 바로 위에 적이 생기거나, 피할 수 없는 위치에 적이 나타나면 운 나쁜 게임이 됩니다.

spawnEnemy() {
  const spawnPoints = this.enemySpawnPoints.filter((point) => {
    const distance = Phaser.Math.Distance.Between(
      point.x, point.y,
      this.player.x, this.player.y
    );

    return distance > 220;
  });

  if (spawnPoints.length === 0) return;

  const point = Phaser.Utils.Array.GetRandom(spawnPoints);
  const enemy = this.createEnemy(point.x, point.y);

  enemy.speed = this.difficulty.enemySpeed;
  enemy.detectRange = this.difficulty.detectRange;
}

무작위 요소는 재미를 만들지만, 플레이어가 대응할 시간을 빼앗으면 불공정해집니다. 좋은 기준은 단순합니다.

  • 플레이어 시야 안이나 근처에 갑자기 적을 만들지 않는다.

  • 적이 등장한 뒤 행동하기 전 짧은 예고 시간을 준다.

  • 한 번에 여러 위험이 겹치지 않게 제한한다.

  • 실패했을 때 원인을 이해할 수 있어야 한다.

무작위는 '매번 조금 다른 상황'을 만들기 위한 도구이지, 플레이어를 기습하기 위한 장치가 아닙니다.

승리와 패배 조건 설계하기

규칙은 게임의 목적을 정합니다. 플레이어가 무엇을 해야 하는지 모르면 좋은 조작과 예쁜 효과도 힘을 잃습니다.

대표적인 승리 조건은 다음과 같습니다.

  • 코인 10개 수집

  • 출구에 도착

  • 제한 시간 동안 생존

  • 모든 적 처치

  • 열쇠를 얻고 문에 도착

패배 조건은 보통 더 단순합니다.

  • 체력이 0이 됨

  • 제한 시간이 0이 됨

  • 목표물을 지키지 못함

  • 낙사 구간 아래로 떨어짐

예를 들어 코인을 모두 모은 뒤 출구에 닿아야 클리어되는 규칙을 만들어 보겠습니다.

create() {
  this.coinsCollected = 0;
  this.coinsRequired = 10;
  this.levelCleared = false;
  this.gameEnded = false;

  this.physics.add.overlap(
    this.player,
    this.exitZone,
    this.tryClearLevel,
    null,
    this
  );
}

collectCoin(player, coin) {
  if (this.gameEnded) return;

  coin.disableBody(true, true);
  this.coinsCollected += 1;
  this.events.emit('coins-changed', this.coinsCollected);
}

tryClearLevel() {
  if (this.gameEnded || this.levelCleared) return;
  if (this.coinsCollected < this.coinsRequired) return;

  this.levelCleared = true;
  this.winGame();
}

조건을 함수로 분리하면 나중에 규칙이 바뀌어도 수정하기 쉽습니다. 예를 들어 '코인 10개'가 아니라 '열쇠 3개'가 되어도 tryClearLevel()만 바꾸면 됩니다.

제한 시간과 긴장감 만들기

제한 시간은 단순하지만 강력한 규칙입니다. 플레이어에게 움직일 이유를 줍니다. 다만 시간이 너무 짧으면 탐험할 여유가 없고, 너무 길면 의미가 없습니다.

startLevelTimer() {
  this.timeLeft = 60;
  this.events.emit('time-changed', this.timeLeft);

  this.levelTimer = this.time.addEvent({
    delay: 1000,
    loop: true,
    callback: () => {
      if (this.gameEnded || this.levelCleared) return;

      this.timeLeft -= 1;
      this.events.emit('time-changed', this.timeLeft);

      if (this.timeLeft <= 0) {
        this.loseGame();
      }
    }
  });
}

UI Scene을 사용하고 있다면 time-changed 이벤트를 받아 화면의 시간 텍스트만 갱신하면 됩니다. Play Scene은 규칙을 관리하고, UI Scene은 표시만 담당합니다. 이 책임 분리는 프로젝트가 커질수록 큰 도움이 됩니다.

게임 흐름을 끝내는 함수

승리와 패배는 한 번만 실행되어야 합니다. 체력이 0이 된 뒤에도 충돌이 계속되거나, 타이머가 동시에 끝나면 게임 오버 처리가 여러 번 실행될 수 있습니다. 그래서 gameEnded 같은 플래그를 둡니다.

winGame() {
  if (this.gameEnded) return;
  this.gameEnded = true;

  this.levelTimer?.remove(false);
  this.spawnTimer?.remove(false);
  this.physics.pause();
  this.sound.play('clear');
  this.scene.launch('GameOverScene', { result: 'win' });
}

loseGame() {
  if (this.gameEnded) return;
  this.gameEnded = true;

  this.levelTimer?.remove(false);
  this.spawnTimer?.remove(false);
  this.physics.pause();
  this.sound.play('fail');
  this.scene.launch('GameOverScene', { result: 'lose' });
}

이름은 GameOverScene이어도 결과 값에 따라 '클리어'와 '실패'를 다르게 보여 줄 수 있습니다. 중요한 것은 게임 규칙이 끝났을 때 물리, 입력, 타이머, UI가 서로 충돌하지 않게 정리하는 것입니다. 위 예제처럼 타이머를 제거해 두면 종료 화면으로 넘어간 뒤에도 적 스폰이나 시간 감소가 뒤늦게 실행되는 일을 줄일 수 있습니다.

미니 프로젝트: 순찰하는 적과 코인 목표 만들기

이번 장의 내용을 작게 묶어 보면 다음 흐름이 됩니다.

  1. 타일맵 오브젝트 레이어에서 적 시작 위치와 스폰 지점을 읽는다.

  2. 적마다 state, speed, detectRange, attackRange를 저장한다.

  3. updateEnemy()에서 거리 기준으로 상태를 바꾼다.

  4. 적과 플레이어가 가까우면 공격하고, 쿨다운으로 피해 간격을 제한한다.

  5. 코인을 일정 수 이상 모으고 출구에 닿으면 승리한다.

  6. 체력이나 시간이 0이 되면 패배한다.

이 정도만 있어도 레벨은 훨씬 게임다워집니다. 플레이어는 코인을 모으기 위해 움직이고, 적을 피하거나 유도하며, 남은 체력과 시간을 의식하게 됩니다.

적 AI와 게임 규칙은 거창하게 시작할 필요가 없습니다. 먼저 단순한 상태와 명확한 조건을 만드세요. 그다음 적 속도, 감지 거리, 스폰 간격, 목표 수를 조금씩 바꾸며 테스트하면 됩니다. 좋은 난이도는 복잡한 공식에서 나오지 않습니다. 플레이어가 실패했을 때 '다음에는 이렇게 해 봐야겠다'고 느끼는 규칙에서 나옵니다.

책 목록

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

전체 목차 보기

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

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

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

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

참여하기