Javascript와 canvas를 이용해서 심플한 슈팅 게임을 만들어 볼까 한다.
캔버스에 키입력과 마우스 클릭, 마우스 이동 이벤트를 모두 받아서 처리했고,
주기적으로 업데이트를 해서 객체를 이동, 충돌 체크(거리 계산)를 하고
화면을 업데이트해서 게임이 진행되도록 했다.
게임해보기 → JS Shooting Game (sparrow-lee.github.io)
JS Shooting Game
sparrow-lee.github.io
- 유저의 유닛은 마우스 포인터를 향해 조준을 하도록 함
- 랜덤의 시간이 지날 때마다 적 유닛이 생성되고 (최대 20개), 랜덤의 시간마다 적 유닛은 유저 유닛을 향해 미사일을 쏘도록 함
- 적 미사일을 유저 유닛이 맞을 때마다 에너지가 감소하고 0%가 되면 게임 종료
- 적 유닛을 격추할 때 마다 score 1점 증가
body 영역은 canvas tag 하나만 존재한다
<body onload="onload()">
<canvas id="canvas" style="background-color:rgb(185, 185, 185);" width="1000" height="600"></canvas>
</body>
초기화 함수에서는 canvas와 context는 전역으로 유지. 플레이어 초기화, 이벤트 핸들러 등록, 적 유닛 초기화.
모든 유닛(플레이어, 적 유닛, 미사일 등)을 업데이트 하는 타이머 등록
→ 유닛들을 이동시키고, 미사일과 유닛간에 충돌 여부 판단등을 하게 된다.
화면 갱신 함수(draw) 등록
function onload() {
console.log('> onload')
gCanvas = document.getElementById('canvas');
gContext = gCanvas.getContext("2d");
let { width, height } = gCanvas;
gCanvasWidth = width;
gCanvasHeight = height;
initPlayer();
initEvent();
initEnemy();
gUpdateIntervalId = setInterval(updateUnits, UNIT_UPDATE_INTERVAL);
window.requestAnimationFrame(draw);
}
W,A,S,D 또는 화살표 커서로 플레이어 유닛을 이동시키게 하기 위해서, 키 업/다운 이벤트를 등록했다.
그냥 keypressed 이벤트에 등록을 할 수 도 있겠지만, 키를 매번 눌럿다 뗏다를 해야만 이동이 되는 문제가 있어서,
키를 눌렀을 때, keydown으로 상태를 저장해 놓고, 키를 뗏을 때는 다시 keyup 상태로 저장을 해두게 하고, 타이머로, 키 상태를 주기적으로 체크해서 눌려진 상태이면 계속 이동을 하게 했다.
→ 동시에 좌,우키가 둘다 눌려졌을 때도 처리를 한다. 좌표를 +,- 각각 해주어서 결국 제자리가 되게 함.
function initEvent() {
console.log('> initEvent');
document.addEventListener('keydown', eventKeydown);
document.addEventListener('keyup', eventKeyup);
gCanvas.addEventListener('mousedown', eventMousedown);
gCanvas.addEventListener('mousemove', eventMousemove);
}
function initPlayer() {
console.log('> initPlayer');
gPlayer = {
x:50, y:50, size:PLAYER_SIZE, energy:PLAYER_ENERGY, speed:PLAYER_SPEED, score: 0, alive: true,
}
for (let i = 0; i < 20; i += 1) {
gPlayerMissiles.push(new Missile({index:i, speed: PLAYER_MISSILE_SPEED}));
}
}
function initEnemy() {
console.log('> initEnemy');
for (let i = 0; i < MAX_ENEMY_MISSILE; i += 1) {
gEnemyMissiles.push(new Missile({index:i, speed: ENEMY_MISSILE_SPEED}));
}
for (let i = 0; i < MAX_ENEMY; i += 1) {
gEnemy.push(new Enemy({index:i, speed: ENEMY_SPEED}));
}
for (let i = 0; i < MAX_ENEMY; i += 1) {
let params = {
x: Math.random() * gCanvasWidth / 2 + (gCanvasWidth / 2),
y: Math.random() * gCanvasHeight / 2 + (gCanvasHeight / 2),
size: ENEMY_SIZE,
speed: ENEMY_SPEED,
worldW: gCanvasWidth,
worldH: gCanvasHeight
}
gEnemy[i].launch(params);
}
}
마우스가 움직일 때는 유저 유닛의 조준 상태를 마우스 위치로 업데이트 되도록 했다. 마우스의 최종 위치를 전역 변수에 업데이트 시킨 뒤에, 유저 유닛의 조준 각도를 계산하도록 했다.
function eventMousemove(event) {
const x = event.clientX - gCanvas.offsetLeft;
const y = event.clientY - gCanvas.offsetTop;
gPlayer.lastMouseX = x;
gPlayer.lastMouseY = y;
updateGunPos();
}
function updateGunPos() {
let dx = Math.abs(gPlayer.x - gPlayer.lastMouseX);
let dy = Math.abs(gPlayer.y - gPlayer.lastMouseY);
let distance = Math.sqrt((dx * dx) + (dy * dy));
gPlayer.gunX = (dx * gGunSize / distance) * (gPlayer.lastMouseX > gPlayer.x ? 1 : -1);
gPlayer.gunY = (dy * gGunSize / distance) * (gPlayer.lastMouseY > gPlayer.y ? 1 : -1);
// console.log(`mouse move gPlayer.gunX=${gPlayer.gunX}, gPlayer.gunY=${gPlayer.gunY}`);
}
마우스 클릭을 하면, 유저의 유닛의 좌표에서 마우스 좌표를 향해서 미사일을 발사 한다.
x,y 방향 속도를 계산해서 가지고 있고, 타이머로 유닛 업데이트시에 그 속도값을 미사일 좌표에 더하기를 하게된다.
적 유닛 중 하나와 부딛히거나, 화면 밖으로 나가면 destroy 된다. 한번에 20개까지 쏠 수 있도록 클래스 인스턴스를 배열로 20개를 준비해 놓게 해두었다.
function eventMousedown(e) {
if (!gPlayer.alive) return;
const x = event.clientX - gCanvas.offsetLeft;
const y = event.clientY - gCanvas.offsetTop;
// console.log(`eventMousedown click x=${x}, y=${y}`);
let missile = gPlayerMissiles.find(m => {
if (!m.alive) return true;
})
if (missile) {
// console.log('eventMousedown. missile id = ', missile.index);
const speed = missile.speed;
let dx = Math.abs(gPlayer.x - gPlayer.lastMouseX);
let dy = Math.abs(gPlayer.y - gPlayer.lastMouseY);
let distance = Math.sqrt((dx * dx) + (dy * dy));
let vx = (dx * speed / distance) * (gPlayer.lastMouseX > gPlayer.x ? 1 : -1);
let vy = (dy * speed / distance) * (gPlayer.lastMouseY > gPlayer.y ? 1 : -1);
missile.launch({x: gPlayer.x, y: gPlayer.y, size: PLAYER_MISSILE_SIZE, vx, vy})
} else {
console.log('no missile');
}
}
updateUnits라는 함수는 15ms마다 반복해서 호출되며, 유닛의 이동 처리, 충돌 계산 등을 하게 된다.
function updateUnits() {
// console.log('> updateUnits')
// for player
let speed = gPlayer.speed + (gKeyStatus.shiftKey ? 3 : 0);
let mvX = 0; mvY = 0;
if (gKeyStatus.left) mvX -= speed;
if (gKeyStatus.right) mvX += speed;
if (gKeyStatus.up) mvY -= speed;
if (gKeyStatus.down) mvY += speed;
if (mvX != 0 && mvY != 0) {
mvX = mvX / Math.sqrt(2);
mvY = mvY / Math.sqrt(2);
}
gPlayer.x += mvX;
gPlayer.y += mvY;
if (gPlayer.x < 0) gPlayer.x = 0;
else if (gPlayer.x > gCanvasWidth) gPlayer.x = gCanvasWidth-1;
if (gPlayer.y < 0) gPlayer.y = 0;
else if (gPlayer.y > gCanvasHeight) gPlayer.y = gCanvasHeight-1;
//console.log(`x=${gPlayer.x}, y=${gPlayer.y}`);
updateGunPos();
// for player's Missiles
gPlayerMissiles.forEach(missile => {
if (missile.alive) {
let {x, y} = missile.move();
if (x < 0 || y < 0) missile.destroy();
else if (x > gCanvasWidth || y > gCanvasHeight) missile.destroy();
}
})
// collision check
gPlayerMissiles.forEach(missile => {
if (missile.alive) {
let {x:mx, y:my} = missile.pos;
gEnemy.some(enemy => {
if (enemy.alive) {
let {x:ex, y:ey} = enemy.pos;
let checkDist = missile.size + enemy.size;
checkDist = checkDist * checkDist;
// console.log(`checkDist = ${checkDist}`);
// console.log(`dist = ` + ((ex - mx) * (ex - mx)) + ((ey - my) * (ey - my)));
if (((ex - mx) * (ex - mx)) + ((ey - my) * (ey - my)) < checkDist) {
// collision occurred.
missile.destroy();
enemy.destroy();
gPlayer.score += 1;
console.log(`gPlayer.score = ${gPlayer.score}`);
return true;
}
}
})
}
})
// for enemy's Missiles move
gEnemyMissiles.forEach(missile => {
if (missile.alive) {
let {x, y} = missile.move();
if (x < 0 || y < 0) missile.destroy();
else if (x > gCanvasWidth || y > gCanvasHeight) missile.destroy();
}
})
// collision check
gEnemyMissiles.forEach(missile => {
if (missile.alive) {
let {x:mx, y:my} = missile.pos;
if (gPlayer.alive) {
let {x:px, y:py} = gPlayer;
let checkDist = missile.size + gPlayer.size;
checkDist = checkDist * checkDist;
// console.log(`checkDist = ${checkDist}`);
// console.log(`dist = ` + ((ex - mx) * (ex - mx)) + ((ey - my) * (ey - my)));
if (((px - mx) * (px - mx)) + ((py - my) * (py - my)) < checkDist) {
// collision occurred.
missile.destroy();
gPlayer.energy -= 1;
console.log(`gPlayer.energy = ${gPlayer.energy}`);
if (gPlayer.energy <= 0) {
gPlayer.energy = 0;
// destroy
console.log('destroyed. game over !!!')
gPlayer.alive = false;
}
return true;
}
}
}
})
// enemy 이동
let aliveEnemy = 0;
gEnemy.forEach(enemy => {
if (enemy.alive) {
aliveEnemy += 1;
enemy.move();
}
})
... 길어서 잘림
미사일과 적 유닛은 class로 구현을 했다.
class MovingObject {
constructor({index, speed}) {
this.x = 0;
this.y = 0;
this.size = 0;
this.vx = 0;
this.vy = 0;
this.speed = speed;
this.index = index;
this.active = false;
}
destroy() {
this.active = false;
}
get pos() {
return {x: this.x, y: this.y};
}
get alive() {
return this.active;
}
setAlive(bAlive) {
this.active = bAlive;
}
}
class Missile extends MovingObject {
constructor(...args) {
super(...args);
this.energy = 0;
}
launch({x, y, size, vx, vy}) {
this.x = x;
this.y = y;
this.size = size;
this.vx = vx;
this.vy = vy;
this.active = true;
this.energy = 1;
}
move() {
if (this.active) {
this.x = this.x + this.vx;
this.y = this.y + this.vy;
return {x: this.x, y: this.y};
} else {
return {x: -1, y: -1};
}
}
}
class Enemy extends MovingObject {
constructor(...args) {
super(...args);
}
launch({x, y, size, speed, worldW, worldH}) {
// console.log(`launch x=${x}, y=${y}, worldW=${worldW}, worldH=${worldH}`)
this.x = x;
this.y = y;
this.size = size;
this.speed = speed;
this.vx = 0;
this.vy = 0;
this.active = true;
this.worldW = worldW;
this.worldH = worldH;
this.moveCount = 0;
this.setNextSpeed(Math.random() * worldW, Math.random() * worldH);
}
move() {
if (this.active) {
this.x = this.x + this.vx;
this.y = this.y + this.vy;
this.moveCount -= 1;
if (this.moveCount < 0) {
this.setNextSpeed(Math.random() * this.worldW, Math.random() * this.worldH);
}
return {x: this.x, y: this.y};
} else {
return {x: -1, y: -1};
}
}
setNextSpeed(tx, ty) {
// console.log(`setNextSpeed tx=${tx}, ty=${ty}`);
let dx = Math.abs(this.x - tx);
let dy = Math.abs(this.y - ty);
let distance = Math.sqrt((dx * dx) + (dy * dy));
this.vx = (dx * this.speed / distance) * (tx > this.x ? 1 : -1);
this.vy = (dy * this.speed / distance) * (ty > this.y ? 1 : -1);
this.moveCount = Math.abs(dx / this.vx);
// console.log(`setNextSpeed this.vx = ${this.vx}, this.vy = ${this.vy}, this.moveCount=${this.moveCount}`)
}
}
화면 업데이트는 draw 함수에서 수행되는데,
window.requestAnimationFrame(draw);
draw 마지막에 호출해주면, 화면 업데이트 후 준비가 되는대로 다시 draw를 호출되게 자동으로 간격도 조절을 한다고 한다. 대략 1초에 60번이라고 본 것 같다.
즉, 이 게임에서는 2개의 interval 펑션이 동작하고 있다고 보면 된다. 화면을 그리는 draw함수와, 유닛 처리를 하는 updateUnits 함수.
draw에서는 유저유닛, 미사일들, 적유닛 등을 그려준다. 유저유닛과 미사일은 파란색, 적유닛과 미사일은 붉은색 계열로 그리고, 하단에 현재 energy와 score도 표시하게 했다.
function draw() {
// console.log('> draw');
gContext.clearRect(0, 0, gCanvas.width, gCanvas.height);
gContext.fillStyle = 'blue';
gContext.strokeStyle = 'black';
gContext.beginPath();
gContext.arc(gPlayer.x, gPlayer.y, gPlayer.size, 0, 2 * Math.PI);
gContext.fill();
gContext.moveTo(gPlayer.x, gPlayer.y);
gContext.lineTo(gPlayer.x + gPlayer.gunX, gPlayer.y + gPlayer.gunY);
gContext.stroke();
gContext.strokeStyle = 'blue';
gPlayerMissiles.forEach(missile => {
if (missile.alive) {
gContext.beginPath();
gContext.arc(missile.x, missile.y, missile.size, 0, 2 * Math.PI);
gContext.stroke();
}
});
gContext.fillStyle = 'orange';
gContext.strokeStyle = 'black';
gEnemy.forEach(enemy => {
if (enemy.alive) {
gContext.beginPath();
gContext.arc(enemy.x, enemy.y, enemy.size, 0, 2 * Math.PI);
gContext.stroke();
gContext.fill();
}
});
gContext.strokeStyle = 'red';
gEnemyMissiles.forEach(missile => {
if (missile.alive) {
gContext.beginPath();
gContext.arc(missile.x, missile.y, missile.size, 0, 2 * Math.PI);
gContext.stroke();
}
});
// draw energy area
if (gPlayer.alive) {
gContext.fillStyle = 'red';
gContext.fillRect(45, gCanvasHeight - 40, 100 * (gPlayer.energy / PLAYER_ENERGY), 20);
gContext.strokeStyle = 'yellow';
gContext.strokeRect(45, gCanvasHeight - 40, 100, 20);
gContext.fillStyle = 'black';
gContext.font = "16px arial";
gContext.fillText(`E`, 30, gCanvasHeight - 25);
gContext.fillText(`${parseInt((gPlayer.energy / PLAYER_ENERGY) * 100)}%`, 77, gCanvasHeight - 25);
} else {
gContext.fillStyle = 'red';
gContext.font = "20px arial";
gContext.fillText(`Game Over !`, 30, gCanvasHeight - 25);
}
gContext.fillStyle = 'black';
gContext.font = "16px arial";
gContext.fillText(`Score ${gPlayer.score}`, 160, gCanvasHeight - 25);
window.requestAnimationFrame(draw);
}
나름 디테일에도 신경을 썼는데,
예를 들어, 스피드가 10이라고 하면, 오른쪽 방향키를 누르고 있다면 x좌표에 10을 계속 더하고, 아래쪽 방향키를 누르고 있으면 y좌표에 10을 더하게 된다.
그런데, 오른쪽키와 아래쪽 방향키를 동시에 누르고 있으면 x도 10증가, y도 10증가를 하게되어 사실 루트2배 만큼 더 움직인 꼴이 된다. 그럴 경우에는 x, y 각각 루트2로 나눈값으로 조절하여 이동 거리가 동일하도록 계산했다.
→ 두변의 길이가 1인 직각 삼각형의 빗변의 길이는 루트2 = sqrt(2) 이므로, 반대로 빗변의 길이가 속도 10일 때는 루트2로 나누면 두 빗변의 길이가 되겠다.
그리고, 쉬프트키를 누르고 있을 때는 부스터로, 좀 더 빠르게 움직이도록 속도를 3 더 증가 시켰다.
function updateUnits() {
// console.log('> updateUnits')
// for player
let speed = gPlayer.speed + (gKeyStatus.shiftKey ? 3 : 0);
let mvX = 0; mvY = 0;
if (gKeyStatus.left) mvX -= speed;
if (gKeyStatus.right) mvX += speed;
if (gKeyStatus.up) mvY -= speed;
if (gKeyStatus.down) mvY += speed;
if (mvX != 0 && mvY != 0) {
mvX = mvX / Math.sqrt(2);
mvY = mvY / Math.sqrt(2);
}
gPlayer.x += mvX;
gPlayer.y += mvY;
전체 코드는 아래에서 볼 수 있다.
'SW 개발 참고 > Javascript' 카테고리의 다른 글
Javascript Canvas로 그림 그리기 (0) | 2023.11.19 |
---|---|
나만의 비트코인 차트 만들기 (HTML, JS) (0) | 2023.11.19 |
Bootstrap을 이용하여 손쉽게 예쁜 UI 구성 (0) | 2023.11.19 |
window.localStorage 사용으로 이전 선택 저장하기 (0) | 2023.11.19 |
브라우저 Javascript Candle Chart 그리기 (0) | 2023.11.19 |