PixiJS로 2D 플랫폼 게임 만들기: 적 AI, 체력 시스템 및 사운드

적 캐릭터 구현

플레이어 혼자 돌아다니는 게임은 재미가 없습니다. 이제 간단한 AI를 가진 적 캐릭터를 추가해보겠습니다.

왕복 이동하는 적

수평으로 왕복 이동하는 적을 만들어 봅시다. Enemy 클래스는 Box를 상속받아 구현합니다:

class Enemy extends Box {
  constructor(graphic, hitbox) {
    super(graphic, hitbox);
    
    this.patrolRange = 200;
    this.movingLeft = true;
    this.speed = 2;
  }

  update(gameState) {
    const direction = this.movingLeft ? -1 : 1;
    this.hitbox.x += this.speed * direction;
    this.patrolRange -= this.speed;

    if (this.patrolRange <= 0) {
      this.movingLeft = !this.movingLeft;
      this.patrolRange = 200;
    }

    this.graphic.x = this.hitbox.x;
    this.graphic.y = this.hitbox.y;
  }
}

적은 생성 시 설정된 거리만큼 한 방향으로 이동한 후 반대 방향으로 전환합니다. patrolRange가 소진되면 방향을 바꿔 다시 원래 거리만큼 이동합니다.

게임에 적을 추가하는 방법:

const slime = new Enemy(
  PIXI.Sprite.from('assets/slime-idle.png'),
  new PIXI.Rectangle(600, 400, 48, 48)
);

game.addEntity(slime);

발사체와의 충돌 처리

플레이어가 발사하는 투사체가 적을 맞추면 제거되도록 합시다. Projectile 클래스에 충돌 로직을 추가합니다:

class Projectile extends Decal {
  update(gameState) {
    const playerRect = gameState.player.hitbox;
    
    this.originX = this.originX ?? playerRect.x + playerRect.width;
    this.originY = this.originY ?? playerRect.y + playerRect.height / 2;
    this.trajectory = this.trajectory ?? gameState.aimAngle;
    
    this.distance = (this.distance ?? 0) + 12;
    
    const destX = this.originX + Math.cos(this.trajectory) * this.distance;
    const destY = this.originY - Math.sin(this.trajectory) * this.distance;
    
    this.hitbox.x = destX;
    this.hitbox.y = destY;
    this.graphic.x = destX;
    this.graphic.y = destY;

    gameState.entities.forEach((entity) => {
      if (entity === this) return;
      
      const projectileBox = this.hitbox;
      const targetBox = entity.hitbox;
      
      const isOverlapping = 
        projectileBox.x < targetBox.x + targetBox.width &&
        projectileBox.x + projectileBox.width > targetBox.x &&
        projectileBox.y < targetBox.y + targetBox.height &&
        projectileBox.y + projectileBox.height > targetBox.y;

      if (isOverlapping && entity.constructor.name === 'Enemy') {
        gameState.engine.removeEntity(entity);
        gameState.engine.removeEntity(this);
      }
    });
  }
}

AABB 충돌 검사를 통해 투사체와 적의 겹침을 확인하고, 충돌 시 둘 다 게임에서 제거합니다.

체력 시스템 구축

즉사 게임은 플레이어에게 학습의 여지를 주지 않습니다. 체력 시스템을 도입해 여러 번의 실수를 허용합시다.

피해 받기

플레이어가 적과 접촉하면 넉백되고 일시적으로 무적 상태가 됩니다:

// Player 클래스 내부
if (other.constructor.name === 'Enemy' && !this.isInvincible) {
  const knockbackDir = this.velocityX >= 0 ? -1 : 1;
  this.velocityX = 15 * knockbackDir;
  this.velocityY = -8;
  
  this.isInvincible = true;
  this.graphic.alpha = 0.4;
  
  setTimeout(() => {
    this.isInvincible = false;
    this.graphic.alpha = 1;
  }, 1500);
  
  if (typeof this.onDamaged === 'function') {
    this.onDamaged.call(this);
  }
}

적과 충돌하면 반대 방향으로 튕겨나가고, 1.5초간 무적 상태가 됩니다. onDamaged 콜백을 통해 외부에서 체력 UI를 제어할 수 있습니다.

HTML 기반 체력 표시

PixiJS 캔버스 대신 DOM 요소로 력바를 구현합니다:

<div class="game-canvas"></div>
<div class="ui-layer">
  <span class="hp-icon" id="hp-1"></span>
  <span class="hp-icon" id="hp-2"></span>
  <span class="hp-icon" id="hp-3"></span>
</div>
.ui-layer {
  position: absolute;
  top: 16px;
  left: 16px;
  pointer-events: none;
}

.hp-icon {
  width: 28px;
  height: 24px;
  display: inline-block;
  background: url('assets/heart-full.png') no-repeat;
  margin-right: 8px;
}

.hp-icon.depleted {
  background-image: url('assets/heart-empty.png');
}

JavaScript에서 체력 변화를 처리:

let currentHP = 3;

hero.onDamaged = function() {
  const heart = document.getElementById(`hp-${currentHP}`);
  if (heart) heart.classList.add('depleted');
  
  currentHP--;
  
  if (currentHP <= 0) {
    showGameOver();
    game.removeEntity(hero);
    game.removeEntity(aimCursor);
  }
};

스프라이트 애니메이션

정적인 이미지는 게임에 생동감이 없습니다. PixiJS의 AnimatedSprite로 다양한 동작을 표현합시다.

기본 애니메이션 설정

const loadAnimation = (frames, speed = 0.1) => {
  const textures = frames.map(f => PIXI.Texture.from(f));
  const anim = new PIXI.AnimatedSprite(textures);
  anim.animationSpeed = speed;
  anim.play();
  return anim;
};

const idleAnim = loadAnimation([
  'assets/hero/idle-1.png',
  'assets/hero/idle-2.png',
  'assets/hero/idle-3.png',
  'assets/hero/idle-4.png'
], 0.08);

const runAnim = loadAnimation([
  'assets/hero/run-1.png',
  'assets/hero/run-2.png',
  'assets/hero/run-3.png',
  'assets/hero/run-4.png',
  'assets/hero/run-5.png',
  'assets/hero/run-6.png'
], 0.15);

상태에 따른 애니메이션 전환

플레이어의 속도에 따라 적절한 애니메이션을 표시:

update(gameState) {
  // 물리 업데이트...
  this.hitbox.x += this.velocityX;
  
  if (!this.onLadder && !this.onSlope) {
    this.hitbox.y += this.velocityY;
  }

  const speed = Math.abs(this.velocityX);
  const isMoving = speed > 0.3;
  
  if (this.grounded && !isMoving && this.currentAnim !== 'idle') {
    this.swapSprite(this.idleAnim);
    this.currentAnim = 'idle';
  }
  
  if (this.grounded && isMoving && this.currentAnim !== 'run') {
    this.swapSprite(this.runAnim);
    this.currentAnim = 'run';
  }
  
  this.graphic.x = this.hitbox.x;
  this.graphic.y = this.hitbox.y;
}

swapSprite(newSprite) {
  const parent = this.graphic.parent;
  const oldSprite = this.graphic;
  
  newSprite.x = oldSprite.x;
  newSprite.y = oldSprite.y;
  newSprite.scale.x = oldSprite.scale.x;
  
  parent.removeChild(oldSprite);
  parent.addChild(newSprite);
  this.graphic = newSprite;
}

오디오 시스템

사운드는 게임의 몰입도를 크게 높입니다. Web Audio API와 HTMLAudioElement를 활용합니다.

배경음악 재생

const bgm = new Audio('audio/level-theme.mp3');
bgm.loop = true;
bgm.volume = 0.6;

// 게임 시작 시
document.addEventListener('visibilitychange', () => {
  if (document.hidden) {
    bgm.pause();
  } else {
    bgm.play().catch(() => {});
  }
});

효과음 사전 로딩

지연 없는 재생을 위해 사운드를 미리 로드:

const sfxLibrary = {
  jump: new Audio('audio/jump.wav'),
  shoot: new Audio('audio/shoot.wav'),
  hit: new Audio('audio/hit.wav')
};

// 미리 로드
Object.values(sfxLibrary).forEach(sound => {
  sound.load();
});

// 사용
const playSfx = (name) => {
  const sound = sfxLibrary[name];
  if (sound) {
    sound.currentTime = 0;
    sound.play();
  }
};

// 입력 처리에서
input.onAction('jump', () => {
  if (hero.grounded) {
    hero.velocityY = -12;
    playSfx('jump');
  }
});

게임패드 지원

Gamepad API를 통해 컨트롤러 입력을 받을 수 있습니다.

class InputManager {
  constructor() {
    this.padIndex = null;
    this.buttons = {};
    
    window.addEventListener('gamepadconnected', (e) => {
      this.padIndex = e.gamepad.index;
    });
  }
  
  poll() {
    if (this.padIndex === null) return;
    
    const pad = navigator.getGamepads()[this.padIndex];
    if (!pad) return;
    
    pad.buttons.forEach((btn, i) => {
      this.buttons[i] = btn.pressed;
    });
  }
  
  get horizontal() {
    const kbLeft = keys.ArrowLeft || keys.KeyA;
    const kbRight = keys.ArrowRight || keys.KeyD;
    const padLeft = this.buttons[14];
    const padRight = this.buttons[15];
    
    if (kbLeft || padLeft) return -1;
    if (kbRight || padRight) return 1;
    return 0;
  }
  
  get jumpPressed() {
    return keys.Space || this.buttons[0];
  }
}

매 프레임 poll()을 호출해 게임패드 상태를 갱신하고, 키보드와 동일한 방식으로 입력을 처리합니다.

확장 아이디어

  • 적에게 중력 적용 및 점프 AI 추가
  • 체 회복 아이템과 장애물 함정 구현
  • 애니메이션 블렌딩으로 자연스러운 전환 효과
  • Web Audio API로 위치 기반 3D 사운드
  • 진동 API를 활용한 햅틱 피드백

태그: PixiJS JavaScript Game Development Game AI Sprite Animation Web Audio API

6월 6일 16:21에 게시됨