이번 추석에 재미있는 해커톤(?)을 발견했습니다. 바로 항해 플러스 : 제 1회 코육대 인데요.
이번 추석, 굳어버린 코딩 근육을 깨울
코딩 육상 대회가 왔다!
라는 재밌는 문구와 함께 추석을 알차게 보낼 수 있는 귀여운 대회입니다.
대회 링크
종목은 6가지로 아래와 같습니다.
저는 테트리스 종목에 참가하려고 합니다.
항해 플러스 코육대에는 게임을 구현하고 배포해야하는 미션이 있는데요.
html 내에 js script을 작성하여 로직을 구현하고 flask 웹 프레임워크로 html 파일을 렌더링해주는 방식으로 배포를 진행했습니다.
기본 로직 들은 Basic Tetris HTML and JavaScript Game의 코드를 클론했습니다. 위 코드에 구현되어있는 로직은 다음과 같습니다.
테트리스는 4개의 블럭으로 이루어진 7종류의 테트로미노가 있습니다. 테트로미노의 종류와 색깔은 아래와 같습니다.
const tetrominos = {
'I': [
[0,0,0,0],
[1,1,1,1],
[0,0,0,0],
[0,0,0,0]
],
'J': [
[1,0,0],
[1,1,1],
[0,0,0],
],
'L': [
[0,0,1],
[1,1,1],
[0,0,0],
],
'O': [
[1,1],
[1,1],
],
'S': [
[0,1,1],
[1,1,0],
[0,0,0],
],
'Z': [
[1,1,0],
[0,1,1],
[0,0,0],
],
'T': [
[0,1,0],
[1,1,1],
[0,0,0],
]
};
// color of each tetromino
const colors = {
'I': 'cyan',
'O': 'yellow',
'T': 'purple',
'S': 'green',
'Z': 'red',
'J': 'blue',
'L': 'orange'
};
좌 우 키로 현재 테트로미노를 움직일 수 있고, 위 방향키로 현재 테트로미노를 90도(시계방향) 회전시킬 수 있습니다. 아래 방향키를 누르면 현재 테트로미노가 아래로 한 칸 이동합니다.
// listen to keyboard events to move the active tetromino
document.addEventListener('keydown', function(e) {
if (gameOver) return;
// left and right arrow keys (move)
if (e.which === 37 || e.which === 39) {
const col = e.which === 37
? tetromino.col - 1
: tetromino.col + 1;
if (isValidMove(tetromino.matrix, tetromino.row, col)) {
tetromino.col = col;
}
}
// up arrow key (rotate)
if (e.which === 38) {
const matrix = rotate(tetromino.matrix);
if (isValidMove(matrix, tetromino.row, tetromino.col)) {
tetromino.matrix = matrix;
}
}
// down arrow key (drop)
if(e.which === 40) {
const row = tetromino.row + 1;
if (!isValidMove(tetromino.matrix, row, tetromino.col)) {
tetromino.row = row - 1;
placeTetromino();
return;
}
tetromino.row = row;
}
});
위 방향키를 누르면 현재 테트로미노가 시계 방향으로 90도 회전합니다.
// rotate an NxN matrix 90deg
// @see https://codereview.stackexchange.com/a/186834
function rotate(matrix) {
const N = matrix.length - 1;
const result = matrix.map((row, i) =>
row.map((val, j) => matrix[N - j][i])
);
return result;
}
테트리스에는 7가지 테트로미노가 한 세트를 이루어 랜덤으로 등장하는 7-back규칙이 있습니다.
// generate a new tetromino sequence
// @see https://tetris.fandom.com/wiki/Random_Generator
function generateSequence() {
const sequence = ['I', 'J', 'L', 'O', 'S', 'T', 'Z'];
while (sequence.length) {
const rand = getRandomInt(0, sequence.length - 1);
const name = sequence.splice(rand, 1)[0];
tetrominoSequence.push(name);
}
}
// get the next tetromino in the sequence
function getNextTetromino() {
if (tetrominoSequence.length === 0) {
generateSequence();
}
const name = tetrominoSequence.pop();
const matrix = tetrominos[name];
// I and O start centered, all others start in left-middle
const col = playfield[0].length / 2 - Math.ceil(matrix[0].length / 2);
// I starts on row 21 (-1), all others start on row 22 (-2)
const row = name === 'I' ? -1 : -2;
return {
name: name, // name of the piece (L, O, etc.)
matrix: matrix, // the current rotation matrix
row: row, // current row (starts offscreen)
col: col // current col
};
}
테트로미노는 사용자의 입력에 따라 좌, 우, 회전, 아래로 이동할 수 있습니다. 이 때 과거의 테트로미노로 이루어진 블록들이나 좌, 우의 벽을 벗어나면 안되기 때문에 해당 입력에 대한 이동이 가능한지를 확인해야합니다.
이 코드에서는 현재 테트로미노의 위치를 이용하여 이동 되었을 때 다른 블럭이 있거나, 벽을 벗어난 경우 현재 테트로미노의 위치를 변경하지 않는 방식으로 이동 가능 여부를 구현했습니다.
// check to see if the new matrix/row/col is valid
function isValidMove(matrix, cellRow, cellCol) {
for (let row = 0; row < matrix.length; row++) {
for (let col = 0; col < matrix[row].length; col++) {
if (matrix[row][col] && (
// outside the game bounds
cellCol + col < 0 ||
cellCol + col >= playfield[0].length ||
cellRow + row >= playfield.length ||
// collides with another piece
playfield[cellRow + row][cellCol + col])
) {
return false;
}
}
}
return true;
}
// 키보드 이벤트 콜백 함수 속 구현
// 좌, 우 방향키를 입력했을 때 isValidMove()로 좌, 우 이동이 가능하면 변경, 가능하지 않을 경우 변경 X
const col = e.which === 37
? tetromino.col - 1
: tetromino.col + 1;
if (isValidMove(tetromino.matrix, tetromino.row, col)) {
tetromino.col = col;
}
// 위 방향키를 입력했을 때 isValidMove()로 회전한 것이 가능하면 변경, 가능하지 않을 경우 변경 X
const matrix = rotate(tetromino.matrix);
if (isValidMove(matrix, tetromino.row, tetromino.col)) {
tetromino.matrix = matrix;
}
// 아래 방향키를 입력했을 때 isValidMove()로 아래로 이동이 가능하면 변경, 가능하지 않을 경우 변경 X
const row = tetromino.row + 1;
if (!isValidMove(tetromino.matrix, row, tetromino.col)) {
tetromino.row = row - 1;
placeTetromino();
return;
}
보드는 width: 10, height: 20으로 이루어진 직사각형 모양이고 보드에 채워져 있는 숫자에 따라 다른 색의 사각형을 그리고 35프레임에 한번 씩 현재 테트로미노를 1칸 떨어뜨려 테트리스 애니메이션을 구현했습니다.
// game loop
function loop() {
rAF = requestAnimationFrame(loop);
context.clearRect(0,0,canvas.width,canvas.height);
// draw the playfield
for (let row = 0; row < 20; row++) {
for (let col = 0; col < 10; col++) {
if (playfield[row][col]) {
const name = playfield[row][col];
context.fillStyle = colors[name];
// drawing 1 px smaller than the grid creates a grid effect
context.fillRect(col * grid, row * grid, grid-1, grid-1);
}
}
}
// draw the active tetromino
if (tetromino) {
// tetromino falls every 35 frames
if (++count > 35) {
tetromino.row++;
count = 0;
// place piece if it runs into anything
if (!isValidMove(tetromino.matrix, tetromino.row, tetromino.col)) {
tetromino.row--;
placeTetromino();
}
}
context.fillStyle = colors[tetromino.name];
for (let row = 0; row < tetromino.matrix.length; row++) {
for (let col = 0; col < tetromino.matrix[row].length; col++) {
if (tetromino.matrix[row][col]) {
// drawing 1 px smaller than the grid creates a grid effect
context.fillRect((tetromino.col + col) * grid, (tetromino.row + row) * grid, grid-1, grid-1);
}
}
}
}
}
테트로미노가 생성되었을 때 아무 움직임도 할 수 없다면 게임을 종료합니다.
// show the game over screen
function showGameOver() {
cancelAnimationFrame(rAF);
gameOver = true;
context.fillStyle = 'black';
context.globalAlpha = 0.75;
context.fillRect(0, canvas.height / 2 - 30, canvas.width, 60);
context.globalAlpha = 1;
context.fillStyle = 'white';
context.font = '36px monospace';
context.textAlign = 'center';
context.textBaseline = 'middle';
context.fillText('GAME OVER!', canvas.width / 2, canvas.height / 2);
}
지금까지 오픈소스에 구현되어 있는 코드들을 살펴보았고, 코육대 미션들에 맞게 기능을 몇가지 추가해주어야 합니다.
코육대 테트리스 종목의 미션은 다음과 같습니다.
이 중 오픈소스에 구현되어 있지 않은 기능들은
이렇게 두가지 입니다.
싱글 페이지로 구현하고 싶었기 때문에 기존 코드에서 Game Over 코드에서 영감을 받아 Game Over 화면과 비슷하게 구현했습니다. 게임 서버에 접속하면 "Press Enter to Start" 문구가 뜨고 Enter를 입력하여 시작하도록 구현했습니다.
또한 게임을 시작할 때 진행하는 리소스 초기화와 loop 함수 실행을 restart 함수로 모듈화하여 게임 시작시 호출하고, 후에 restart 기능에 대한 확장성을 고려했습니다.
function showMenu(){
context.fillStyle = 'black';
context.globalAlpha = 0.75;
context.fillRect(0, canvas.height / 2 - 30, canvas.width, 60);
context.globalAlpha = 1;
context.fillStyle = 'white';
context.font = '25px monospace';
context.textAlign = 'center';
context.textBaseline = 'middle';
context.fillText('Press Enter to start', canvas.width / 2, canvas.height / 2);
// 엔터 눌렀을 때 다시 시작하도록 이벤트 핸들러 추가
document.addEventListener('keydown', function(e) {
if (e.which === 13) { // 엔터 키를 누르면
restartGame(); // 게임 다시 시작
document.removeEventListener('keydown', arguments.callee); // 이벤트 핸들러 제거
}
});
}
// 게임의 리소스를 모두 초기화 하고 loop 함수를 실행하는 restart함수를 구현
// restart game
function restartGame() {
gameOver = false;
startTime = performance.now();
tetrominoSequence.length = 0;
for (let row = -2; row < 20; row++) {
for (let col = 0; col < 10; col++) {
playfield[row][col] = 0;
}
}
while (tetrominoSequence.length != 0) {
tetrominoSequence.pop();
}
count = 0;
score = 0;
tetromino = getNextTetromino();
nextTetromino = getNextTetromino();
rAF = null;
gameOver = false;
isPaused = false;
isAnimating = true;
tetromino = nextTetromino;
nextTetromino = getNextTetromino();
rAF = requestAnimationFrame(loop);
}
이 기능을 구현하기 위해 기존에 없던 score와 elapsedTime 변수를 추가했습니다. 그리고 Game Over 을 수정하여 게임 종료 시 최종 삭제한 줄의 수와 버틴 시간을 볼 수 있도록 했습니다.
let score = 0;
let startTime = 0;
let time = 0;
function restartGame() {
// 리소스 초기화...
startTime = performance.now();
score = 0;
}
function loop() {
// other logic...
// 현재 시간을 가져와서 게임 시작 시간과의 차이를 계산합니다.
const currentTime = performance.now();
const elapsedTime = (currentTime - startTime) / 1000; // 밀리초를 초로 변환
}
function placeTetromino() {
// other logic...
// check for line clears starting from the bottom and working our way up
for (let row = playfield.length - 1; row >= 0; ) {
if (playfield[row].every(cell => !!cell)) {
score++;
}
else {
row--;
}
}
}
function showGameOver() {
// other logic...
context.fillText('GAME OVER!', canvas.width / 2, canvas.height / 2 - 50);
context.fillText('SCORE: ' + score, canvas.width / 2, canvas.height / 2);
context.fillText('TIME: ' + time, canvas.width / 2, canvas.height / 2 + 50);
}
이제 코육대 테트리스 종목의 미션들은 모두 완료했습니다. 하지만 현재까지 구현한 테트리스는 한 번 게임이 끝나면 아무것도 할 수 없고, 일시 중지 기능도 없으며, 다음 테트로미노를 볼 수도 없어서 전략적으로 플레이 하기가 불가능합니다.
때문에 완성도 있는 테트리스 게임을 위해 다음의 기능을 추가했습니다.
- 일시 중지 기능(p 버튼)
1-1. restart
1-2. resume- 사이드 바 UI
2-1. Next Tetromino
2-2. 현재 score
2-3. 현재 time- 게임 종료 후 restart 기능
테트리스 게임 도중 p를 눌러 일시 중지하는 기능을 추가했습니다. pause 상태에서는 R을 눌러서 게임을 재시작할 수 있고, 다시 p를 눌러서 게임을 재개할 수 있습니다.
document.addEventListener('keydown', function(e) {
// other logic...
// p key (pause and resume)
if (e.which === 80) {
if (isAnimating) {
pauseGame(); // 게임 일시 정지
} else {
resumeGame(); // 게임 재개
}
}
});
// game 재개
function resumeGame() {
if (!isAnimating) {
isAnimating = true;
rAF = requestAnimationFrame(loop);
}
}
// show the game pause screen
function pauseGame(){
if (isAnimating) {
cancelAnimationFrame(rAF);
isAnimating = false;
context.fillStyle = 'black';
context.globalAlpha = 0.75;
context.fillRect(0, canvas.height / 2 - 30 - 50, canvas.width, 60);
context.globalAlpha = 1;
context.fillStyle = 'white';
context.font = '25px monospace';
context.textAlign = 'center';
context.textBaseline = 'middle';
context.fillText('RESUME : Press P!', canvas.width / 2, canvas.height / 2 - 50);
context.fillStyle = 'black';
context.globalAlpha = 0.75;
context.fillRect(0, canvas.height / 2 - 30 + 50, canvas.width, 60);
context.globalAlpha = 1;
context.fillStyle = 'white';
context.font = '25px monospace';
context.textAlign = 'center';
context.textBaseline = 'middle';
context.fillText('RESTART : Press R!', canvas.width / 2, canvas.height / 2 + 50);
// R 를 눌렀을 때 다시 시작하도록 이벤트 핸들러 추가
document.addEventListener('keydown', function(e) {
if (e.which === 82) {
console.log("PRESSED R");
isAnimating = true;
restartGame();
document.removeEventListener('keydown', arguments.callee); // 이벤트 핸들러 제거
}
});
return;
}
}
테트리스를 플레이 해보면 다음 테트로미노를 미리 알 수 있어 현재 테트로미노를 전략적으로 놓을 수 있습니다. 다음 테트로미노와 현재 점수, 시간이 표기되는 사이드 바 UI를 구현하여 완성도를 높였습니다.
// index.html
<canvas width="320" height="640" id="game"></canvas>
<canvas width="220" height="640" id="sidebar"></canvas>
// css
#sidebar {
width: 220px;
height: 640px;
margin-left: 10px;
border: 1px solid white;
}
// sidebar context
const sidebar = document.getElementById('sidebar');
const sideContext = sidebar.getContext('2d');
// restart game
function restartGame() {
// other logic...
tetromino = getNextTetromino();
nextTetromino = getNextTetromino();
}
// game loop
function loop() {
// other logic...
// draw the next tetromino
if(nextTetromino){
//
sideContext.fillStyle = colors[nextTetromino.name];
for (let row = 0; row < nextTetromino.matrix.length; row++) {
for (let col = 0; col < nextTetromino.matrix[row].length; col++) {
if (nextTetromino.matrix[row][col]) {
// drawing 1 px smaller than the grid creates a grid effect
sideContext.fillRect((col + 2)*grid, (3 + row) * grid, grid-1, grid-1);
}
}
}
}
// draw NEXT, SCORE, TIME
if(isAnimating){
sideContext.globalAlpha = 1;
sideContext.fillStyle = 'white';
sideContext.font = '25px monospace';
sideContext.textAlign = 'center';
sideContext.textBaseline = 'middle';
let scoreTimeOffset = 100
sideContext.fillText('NEXT', sidebar.width / 2, 50);
sideContext.fillText('SCORE', sidebar.width / 2, sidebar.height / 2 + scoreTimeOffset);
sideContext.fillText(score, sidebar.width / 2, sidebar.height / 2 + 50 + scoreTimeOffset);
sideContext.fillText('TIME', sidebar.width / 2, sidebar.height / 2 + 100 + scoreTimeOffset);
sideContext.fillText(time, sidebar.width / 2, sidebar.height / 2 + 150 + scoreTimeOffset);
}
}
만들어 놓은 restart 함수를 사용하여 게임이 종료되었을 때 재시작이 가능하도록 기능을 추가했습니다.
// show the game over screen
function showGameOver() {
// other logic...
context.fillText('Press R to Restart', canvas.width / 2, canvas.height - 80);
document.addEventListener('keydown', function(e) {
if (e.which === 82) {
restartGame();
document.removeEventListener('keydown', arguments.callee);
}
});
}
이번 추석 연휴가 길어서 지루한 감이 없지 않아 있었는데, 이렇게 항해 플러스에서 하루 시간내어 가볍게 참가할 수 있는 대회를 열어주어서 개인적으로는 재밌었습니다.
만약 1등이 된다면 wall-kick, multi-play, ranking 등의 기능을 더 추가하여 사라진 한게임 테트리스를 최대한 비슷하게 구현할 충분한 동기가 될 것 같습니다...ㅎㅎㅎ
또한 예전에 강화학습 수업에서 프로젝트로 진행했던 'DQN을 이용한 테트리스 AI' 를 활용해 AI 대전 기능도 구현해 보고 싶습니다.
Basic Tetris HTML and JavaScript Game
파이썬 - 한게임 테트리스 만들기 (네트워크 대전 게임)