적 캐릭터 구현
플레이어 혼자 돌아다니는 게임은 재미가 없습니다. 이제 간단한 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를 활용한 햅틱 피드백