본문 바로가기
프로젝트/탄막 피하기 멀티게임 (웹)

Node.js와 Socket.io를 이용한 멀티플레이 탄막피하기 게임 개발기 (1) | 21-05-06

by Godgil 2021. 5. 6.

개발 동기

  이번 학기에 외국인 교수님의 수업을 듣고있는데, 이 수업에서 협업 방법, OOA, OOD 설계 방법과 디자인 패턴들을 배우면서 실제로 적용해보는 팀 프로젝트를 하고있다. 프로젝트는 어떤 만들고싶은 게임을 하나 정해서 제안서를 작성하고, OOA, OOD를 설계 한 뒤, 설계된 구조에 맞춰서 개발을 하는 것이다. 프로젝트를 하면서, 몇 주 간격으로 Milestone을 진행하고, 한 주 간격으로 Sprint를 진행하면서 개발 상황을 계속해서 브리핑을 해야한다. 중간고사가 끝나고, 이제 실제로 개발할 단계가 되어서, 개발기와 함께 개발 과정을 작성해보려 한다. 

  게임에 사용되는 툴은 자유롭게 선택하면 되고, 다만 Client-Sever Architecture는 필수로 들어가야 한다. 먼저, 우리 팀은 Web 기반으로 돌아가는 탄막피하기 게임을 선정했다. 게임인데 굳이 웹을 선택한 이유는, 다른게 없고 나는 웹 개발자가 되고싶기 때문이었다.

 

다음글 

[프로젝트/학교 수업] - Node.js와 Socket.io를 이용한 멀티플레이 탄막피하기 게임 개발기 (2) | 21-05-10

[프로젝트/학교 수업] - Node.js와 Socket.io를 이용한 멀티플레이 탄막피하기 게임 개발기 (3)

게임 개요

  우리 게임은 탄막피하기 게임으로, 사방에서 튀어나오는 총알들을 피하면서 계속해서 살아남는 게임이다. 아는 사람들은 아는 곰플레이어 게임인 닷지나 플래시 게임인 죽림고수와 비슷한 게임이다. 다만 우리 게임은 멀티플레이로 진행되고, 아이템을 획득하여 다른 플레이어들을 살릴수도, 유용한 효과를 동시에 얻을 수도 있다.

 

 

코드

일단, Node.js 서버를 통해 여러 브라우저 창에서 플레이어들이 움직이고, 실시간으로 동기화 하는 과정까지 작성했다.

먼저, 우리 게임은 실시간으로 클라이언트-서버 사이에서 플레이어들의 위치가 동기화가 되어야한다. 따라서, 기존 http연결은 적합하지 않다. 우리는 socket.io를 통한 클라이언트-서버 통신을 선택했다. 나는 첫 프로젝트라 많이 헤맸지만, 구글링, socket.io공식문서와 mozilla재단의 튜토리얼을 통해서 하나씩 만들어 가고있다. 

 

서버 사이드

 

//server.js
const app = require('http').createServer(handler);
const io = require('socket.io')(app);
const fs = require('fs');

app.listen(8000);

function handler (req, res) {
    fs.readFile(__dirname + '/views/index.html', function( err, data) {
        if(err){
            res.writeHead(500);
            return res.end('Error loading index.html');
        }
        res.writeHead(200);
        res.end(data);
    });
}

function getPlayerColor(){
    return "#" + Math.floor(Math.random() * 16777215).toString(16);
}


const startX = 1024/2;
const startY = 768/2;

class PlayerBall{
    constructor(socket){
        this.socket = socket;
        this.x = startX;
        this.y = startY;
        this.color = getPlayerColor();
    }

    get id() {
        return this.socket.id;
    }
}

var balls = [];
var ballMap = {};

function joinGame(socket){
    let ball = new PlayerBall(socket);

    balls.push(ball);
    ballMap[socket.id] = ball;

    return ball;
}

function endGame(socket){
    for( var i = 0 ; i < balls.length; i++){
        if(balls[i].id == socket.id){
            balls.splice(i,1);
            break
        }
    }
    delete ballMap[socket.id];
}

io.on('connection', function(socket) {
    console.log(`${socket.id}님이 입장하셨습니다.`);

    socket.on('disconnect', function(reason){
        console.log(`${socket.id}님이 ${reason}의 이유로 퇴장하셨습니다. `)
        endGame(socket);
        socket.broadcast.emit('leave_user', socket.id);
    });

    let newBall = joinGame(socket);
    socket.emit('user_id', socket.id);

    for (var i = 0 ; i < balls.length; i++){
        let ball = balls[i];
        socket.emit('join_user', {
            id: ball.id,
            x: ball.x,
            y: ball.y,
            color: ball.color,
        });
    }
    socket.broadcast.emit('join_user',{
        id: socket.id,
        x: newBall.x,
        y: newBall.y,
        color: newBall.color,
    });

    socket.on('send_location', function(data) {
            socket.broadcast.emit('update_state', {
                id: data.id,
                x: data.x,
                y: data.y,
            })
    })
})

더보기

노드는 설치되어 있다고 가정하고, 먼저 npm을 통해 socket.io를 설치해야 한다.

npm install socket.io

 

이후에는 서버가 켜지고, 플레이어가 접속했을 때, index.html페이지를 불러오고, 에러처리를 하는 부분이다.

const app = require('http').createServer(handler);
const io = require('socket.io')(app);
const fs = require('fs');

app.listen(8000);

function handler (req, res) {
    fs.readFile(__dirname + '/views/index.html', function( err, data) {
        if(err){
            res.writeHead(500);
            return res.end('Error loading index.html');
        }
        res.writeHead(200);
        res.end(data);
    });
}

- 8000번 포트에서 실행한다.

- __dirname 는현재 실행하고 있는 파일의 절대경로이다.

- handler 함수는 그 경로에서 views폴더를 찾은 뒤,  index.html을 읽어오는 코드이다.

 

먼저, 아래 스크린샷처럼 플레이어들은 브라우저에 접속시, 각자의 색을 가진 원이 되고 키보드 방향키를 통해 캔버스의 상하좌우로 움직일 것이다.

 

그러기 위해서는 플레이어들을 표현할 클래스가 필요하다.

const startX = 1024/2;
const startY = 768/2;

function getPlayerColor(){
    return "#" + Math.floor(Math.random() * 16777215).toString(16);
}

class PlayerBall{
    constructor(socket){
        this.socket = socket;
        this.x = startX;
        this.y = startY;
        this.color = getPlayerColor();
    }

    get id() {
        return this.socket.id;
    }
}

- startX, startY는 나중에 나올 html에서 canvas의 가운데서 실행하기 위한 상수이다. 우리 게임의 canvas 크기는 1025*768이다.

- PlayerBall을 만들고 생성자를 통해, 초기 위치를 받아 온 뒤, getPlayerColor()를 통해 색상을 지정받는다.

- getPlayerColor()은 RBG색상을 랜덤으로 뽑아내기 위한 함수이다. RGB색상은 # 'RED' "BLUE' "GREEN"으로 16진수를 통해 표현되고, #FFFFFF까지 표현할 수 있기 때문에 FFFFFF를 10진수로 변환한 16777215까지의 범위에서 랜덤한 값을 뽑아온다.

- Math.random()함수는 0~1사이의 값을 랜덤으로 반환해주는 자바스크립트 함수이다.

- toString(16)은 16진수로 변환해주는 코드이다.

 

이후, 플레이어들을 담을 배열과 객체를 선언해준다.

var balls = [];
var ballMap = {};

-balls는 PlayerBall의 객체를 담을 배열이다.

-ballMap은 socket에 접속했을때 부여되는 id를 통해 해당 ball을 찾을 수 있는 객체이다.

 

function joinGame(socket){
    let ball = new PlayerBall(socket);

    balls.push(ball);
    ballMap[socket.id] = ball;

    return ball;
}

-joinGame 함수는 새로운 플레이어가 접속했을때, ball을 만들어서 ball 배열에 집어넣는 코드이다.

 

function endGame(socket){
    for( var i = 0 ; i < balls.length; i++){
        if(balls[i].id == socket.id){
            balls.splice(i,1);
            break
        }
    }
    delete ballMap[socket.id];
}

- endGame() 함수는 플레이어가 접속을 종료했을 때, ball 배열에서 해당 볼을 찾아서 없애고, ballMap 객체에서 id를 통해 해당하는 ball을 찾아서 삭제시켜주는 함수이다.

 

 

마지막으로, socket을 통해 연결하는 코드

기본적으로 socket에서 on은 데이터를 받는 역할, emit은 데이터를 보내는 역할이다.

위 사항을 기억해서 코드를 보자.

io.on('connection', function(socket) {
    console.log(`${socket.id}님이 입장하셨습니다.`);

    socket.on('disconnect', function(reason){
        console.log(`${socket.id}님이 ${reason}의 이유로 퇴장하셨습니다. `)
        endGame(socket);
        socket.broadcast.emit('leave_user', socket.id);
    });

    let newBall = joinGame(socket);
    socket.emit('user_id', socket.id);

    for (var i = 0 ; i < balls.length; i++){
        let ball = balls[i];
        socket.emit('join_user', {
            id: ball.id,
            x: ball.x,
            y: ball.y,
            color: ball.color,
        });
    }
    
    socket.broadcast.emit('join_user',{
        id: socket.id,
        x: newBall.x,
        y: newBall.y,
        color: newBall.color,
    });

    socket.on('send_location', function(data) {
            socket.broadcast.emit('update_state', {
                id: data.id,
                x: data.x,
                y: data.y,
            })
    })
})

- 첫번째 줄의 'connection'은 socket.io의 기본 이벤트이다. 플레이어가 접속했을때 자동으로 발생하는 이벤트이다. 접속했으면 부여되면 유니크한 id를 통해, 해당 사용자가 입장했음을 console.log를 통해 알려준다.

- connection안에서 코드를(이벤트를) 작성할 때는 socket.on("이벤트 이름", 함수 or 데이터) 또는 socket.emit("이벤트 이름", 데이터)의 형식으로 작성하면 된다.

- 'disconnect'역시 socket.io의 기본 이벤트이다. 플레이어가 접속을 종료했을때 발생하는 이벤트이고, 이 이벤트를 서버가 받게되면, 어떤 이유로 퇴장했는지 알려주고, endGame() 함수를 통해, 플레이어를 삭제시켜준다.

 

- 새로운 플레이어가 접속했으면(connection함수 안에 있으니까) 서버상에서 먼저 joinGame()함수를 통해 새로운 ball을 반환받고, socket.emit()을 사용하여 'user_id'라는 이벤트와 socket의 id를 클라이언트상으로 보낸다. 이 이벤트는 접속한 해당 클라이언트에 보내는 메세지이다.

- 이미 있는 유저들의 정보를 새로 접속한 클라이언트에게 보내준다. 이 이벤트는 socket.emit()과 반복문을 통해 진행된다. socket.emit()으로 'join_user'라는 이벤트와 함께 socket.id, x,y좌표, 색깔이 담긴 JSON객체를 클라이언트로 보낸다. 

- 새로운 유저의 정보 또한 이미 있는 유저들에게 전달해야 한다. 이 때 쓰이는 함수는 socket.broadcast.emit()인데, 해당 클라이언트를 제외한 다른 모든 클라이언트에게 말 그대로 방송을 하는것이다. 이는 join_user이벤트를 통해 아까 생성한 새로운 플레이어의 id, x,y좌표, color를 다른 모든 클라이언트에게 보낸다.

- 이후에 접속해있는 플레이어들이 움직이면, 그에 따라서 계속해서 정보가 업데이트되어야 한다. 이는 socket.on을 통해 먼저 해당 클라이언트에서 데이터를 받아 온뒤, 그 정보를 socket.broadcast.emit()을 통해 다른 모든 플레이어들에게 전달해준다. 

 

서버쪽은 일단 여기까지만 구현했다.

 

클라이언트 사이드

<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Professor VS Student</title>
    <style>
        * {padding : 0; margin: 0;}
        canvas {background: #eee; display: block; margin : 0 auto;}
    </style>
    
    <script src="/socket.io/socket.io.js"></script>

</head>
<body>
    <canvas id = "myCanvas" width ="1024" height = "768"></canvas>
    <script>
        var canvas = document.getElementById("myCanvas");
        var ctx = canvas.getContext("2d");
        var radius = 16
        var playerSpeed = 4

        var rightPressed = false;
        var leftPressed = false;
        var upPressed = false;
        var downPressed = false;
        function PlayerBall(id){
            this.id = id;
            this.color = "#FF00FF";
            this.x = 1024/2;
            this.y = 768/2;
        }
        
        var balls  = [];
        var ballMap = {};
        var myId;

        document.addEventListener("keydown", keyDownHandler,false);
        document.addEventListener("keyup", keyUpHandler,false);

        function keyDownHandler(e){
            if (e.code == 'ArrowRight'){
                rightPressed = true;
            }
            if (e.code == 'ArrowLeft'){
                leftPressed = true;
            }
            if(e.code == "ArrowDown"){
                downPressed = true;
            }
            if(e.code == "ArrowUp"){
                upPressed = true;
            }
        }

        function keyUpHandler(e){
            if (e.code == "ArrowRight"){
                rightPressed = false;
            }
            if (e.code == "ArrowLeft"){
                leftPressed = false;
            }
            if(e.code == "ArrowDown"){
                downPressed = false;
            }
            if(e.code == "ArrowUp"){
                upPressed = false;
            }
        }

        function joinUser(id,color,x,y){
            let ball = new PlayerBall(id);
            ball.color = color;
            ball.x = x;
            ball.y = y;

            balls.push(ball);
            ballMap[id] = ball;

            return ball;
        }

        function leaveUser(id){
            for(var i = 0 ; i < balls.length; i++){
                if(balls[i].id == id){
                    balls.splice(i,1);
                    break;
                }
            }
            delete ballMap[id];
        }

        function updateState(id,x,y){
            let ball = ballMap[id];
            if(!ball){
                return;
            }
            ball.x = x;
            ball.y = y;

        }

        var socket = io();

        socket.on('user_id', function(data){
            myId = data;
        });
        socket.on('join_user', function(data){
            joinUser(data.id, data.color, data.x, data.y);
        })
        socket.on('leave_user', function(data){
            leaveUser(data);
        })
        socket.on('update_state', function(data){
            updateState(data.id, data.x, data.y);
        })
        
        function sendData() {
            let curPlayer = ballMap[myId];
            let data = {};
            data = {
                id : curPlayer.id,
                x: curPlayer.x,
                y: curPlayer.y,
            };
            if(data){
                socket.emit("send_location", data);
            }
        }

        function renderPlayer() {
                ctx.clearRect(0, 0, canvas.width, canvas.height);
            
                for (let i = 0; i < balls.length; i++) {
                    let ball = balls[i];
                    
                    ctx.fillStyle = ball.color;
                    ctx.beginPath();
                    ctx.arc(ball.x, ball.y, radius, 0, Math.PI * 2, false);
                    ctx.closePath();
                    ctx.fill();
                    
                    ctx.beginPath();
                    ctx.font = '15px Arial';
                    ctx.fillText(`player ${i}`,ball.x-radius-7, ball.y-radius);
                    ctx.closePath();
                }

                let curPlayer = ballMap[myId];
                
                if (rightPressed){
                    curPlayer.x += playerSpeed;
                }
                if (leftPressed ){
                    curPlayer.x -= playerSpeed;
                }
                if(upPressed ){
                    curPlayer.y -= playerSpeed;
                }
                if(downPressed ){
                    curPlayer.y += playerSpeed;
                }
                sendData();
            }

        function update() {
            renderPlayer();
        }
        
        setInterval(update, 10);

    </script>

</body>
</html>
더보기

먼저 html기본 형태를 vscode의 기능을 통해 만들어주고, head 태그 안에 socket.io를 이용할 수 있게 

아래 스크립트를 넣어준다.

<script src="/socket.io/socket.io.js"></script>

 

이후에 body태그안에서, canvas를 생성해준다. 우리 게임은 canvas를 통해 맵이 구현이 될 것이기 때문이다.

<canvas id = "myCanvas" width ="1024" height = "768"></canvas>

게임 맵의 크기는 1024X768이다

그 다음, 스크립트 코드를 작성해야한다.

 

당연히, 전체 플레이어들을 담을 ball과 ballMap은 서버랑 똑같이 들어가고, 자신을 판단할 수 있는 socket.id를 담을 변수가 필요하다.

var canvas = document.getElementById("myCanvas");
        var ctx = canvas.getContext("2d");
        var radius = 16
        var playerSpeed = 4

        function PlayerBall(id){
            this.id = id;
            this.color = "#FF00FF";
            this.x = 1024/2;
            this.y = 768/2;
        }
        
        var balls  = [];
        var ballMap = {};
        var myId;

또한, 화면을 그리는것은 클라이언트에서 일어나기 때문에 ball을 그릴때 필요한 반지름과, 플레이어가 움직일때의 속도, 그리고 켄버스를 다룰 변수가 필요하고, 마지막으로 PlayerBall의 객체 또한 같이 들어간다. 초기값은 나중에 서버에서 지정되기 때문에 아무 값이나 넣어도 상관없다.

 

이후, 우리는 화살표를 통해 공을 움직이게 할 것이기 때문에, 키를 입력할 이벤트와, 키 입력을 판단할 변수가 필요하다. 

var rightPressed = false;
var leftPressed = false;
var upPressed = false;
var downPressed = false;

document.addEventListener("keydown", keyDownHandler,false);
document.addEventListener("keyup", keyUpHandler,false);

function keyDownHandler(e){
	if (e.code == 'ArrowRight'){
		rightPressed = true;
	}
	if (e.code == 'ArrowLeft'){
		leftPressed = true;
	}
	if(e.code == "ArrowDown"){
		downPressed = true;
	}
	if(e.code == "ArrowUp"){
		upPressed = true;
	}
}

function keyUpHandler(e){
	if (e.code == "ArrowRight"){
		rightPressed = false;
	}
	if (e.code == "ArrowLeft"){
		leftPressed = false;
	}
	if(e.code == "ArrowDown"){
		downPressed = false;
	}
	if(e.code == "ArrowUp"){
		upPressed = false;
	}
}

 해당 설명은 mozilla재단에 자세히 나와있다. 단순하게 키가 입력되면 이벤트를 통해 변수들을 true로 바꾸고 키가 다시 입력이 풀리면 변수들을 false로 바꾸는 함수들이다. 

 

 

        function joinUser(id,color,x,y){
            let ball = new PlayerBall(id);
            ball.color = color;
            ball.x = x;
            ball.y = y;

            balls.push(ball);
            ballMap[id] = ball;

            return ball;
        }

        function leaveUser(id){
            for(var i = 0 ; i < balls.length; i++){
                if(balls[i].id == id){
                    balls.splice(i,1);
                    break;
                }
            }
            delete ballMap[id];
        }

        function updateState(id,x,y){
            let ball = ballMap[id];
            if(!ball){
                return;
            }
            ball.x = x;
            ball.y = y;

        }
        function sendData() {
            let curPlayer = ballMap[myId];
            let data = {};
            data = {
                id : curPlayer.id,
                x: curPlayer.x,
                y: curPlayer.y,
            };
            if(data){
                socket.emit("send_location", data);
            }
        }

 

-joinUser()함수는 기본적으로 새로운 플레이어를 balls 배열에 넣어주는 역할을 한다. 

-leaveUser()함수는 id를 받아서 balls 배열에서 떠난 유저를 삭제시켜주는 함수이다.

-updateState()함수는 id와 x,y좌표를 받아서 새로운 좌표로 업데이트 시켜주는 함수이다.

-sendData()함수는 해당하는 플레이어(접속한 브라우저)의 id를 찾아서 서버에 socket.emit()을 통해 이동된 좌표를 보내주는 함수이다.

 

이후에 소켓을 생성하고

        var socket = io();

        socket.on('user_id', function(data){
            myId = data;
        });
        socket.on('join_user', function(data){
            joinUser(data.id, data.color, data.x, data.y);
        })
        socket.on('leave_user', function(data){
            leaveUser(data);
        })
        socket.on('update_state', function(data){
            updateState(data.id, data.x, data.y);
        })
        

on은 기본적으로 받는 역할이다. 여기는 클라이언트 사이드인 만큼 서버에서 데이터를 받아온다.

- 'user_id'라는 이벤트를 통해 data를 받아서 myId 변수에 data를 입력해준다.

- 'join_user'라는 이벤트를 통해 data를 받아서 새로운 플레이어의 정보를 joinUser()를 통해 입력해준다.

- 'leave_user'라는 이벤트를 통해 data를 받아서 떠나간 플레이어를 leaveUser()를 통해 삭제시킨다.

- 'update_state'라는 이벤트를 통해 data를 받아서, 정보가 업데이트 된 플레이어의 정보를 updateState()를 통해 업데이트 한다.

 

 

마지막으로, 해당하는 정보를 통해 플레이어들을 그리는것을 반복하는 부분이 남았다.

        function renderPlayer() {
                ctx.clearRect(0, 0, canvas.width, canvas.height);
            
                for (let i = 0; i < balls.length; i++) {
                    let ball = balls[i];
                    
                    ctx.fillStyle = ball.color;
                    ctx.beginPath();
                    ctx.arc(ball.x, ball.y, radius, 0, Math.PI * 2, false);
                    ctx.closePath();
                    ctx.fill();
                    
                    ctx.beginPath();
                    ctx.font = '15px Arial';
                    ctx.fillText(`player ${i}`,ball.x-radius-7, ball.y-radius);
                    ctx.closePath();
                }

                let curPlayer = ballMap[myId];
                
                if (rightPressed){
                    curPlayer.x += playerSpeed;
                }
                if (leftPressed ){
                    curPlayer.x -= playerSpeed;
                }
                if(upPressed ){
                    curPlayer.y -= playerSpeed;
                }
                if(downPressed ){
                    curPlayer.y += playerSpeed;
                }
                sendData();
            }

        function update() {
            renderPlayer();
        }
        
        setInterval(update, 10);

 - 현재 balls배열에 들어있는 수 만큼 반복문을 돌면서 계속해서 플레이어들을 그려준다. 이것도 또한 mozilla에 자세하게 나와있다.

- curPlayer변수는 현재 접속한 해당 클라이언트를 찾고, 그 플레이어의 볼을 움직이게 하기 위한 변수이다.

- 각각의 키들이 눌러지면 해당 스피드만큼 좌표가 변경되고, 그 변경된 좌표는 sendData()를 통해 서버로 전달된다.

 

-이후 renderPlayer()를 update()에서 실행해주고, setInterval을 통해 update() 함수를 10ms마다 계속해서 실행해준다. 

 

 

-- 위 코드의 플레이 영상

 

 

--짧은 기간에 여러 블로그들과 공식문서를 보면서 코드를 구현해서, 코드 스타일이 정상적이지 않을 수도 있습니다. 혹시 비정상적인 코드가 있으면 피드백 주시면 감사하겠습니다. 

 

-- 전체 코드는 제 Github에서 보실 수 있습니다. 

댓글