NeuroWhAI의 잡블로그

[Web] Node.js와 Socket.io로 멀티플레이 게임 개발 테스트 본문

개발 및 공부

[Web] Node.js와 Socket.io로 멀티플레이 게임 개발 테스트

NeuroWhAI 2019. 2. 7. 19:47



만들고 싶은 멀티플레이 게임이 있는데 동기화 문제가 골치일 거라는 것을 알고 있었기에 연습 삼아 개발했습니다.
좋은 글이 있어서 비교적 빨리 구현할 수 있었습니다.
다만 많이 허술합니다. 데모 링크는 서버가 안좋아서 ㅠㅠ
겨우 공 몇개 동기화하는 것도 이렇게 힘든데 참 어떡할지 고민이네요.

아래는 소스코드입니다.
자바스크립트는 정식으로 공부한 적이 없어서 코드 스타일은 거르고 보시면 되겠습니다 ㅋㅋ..
GitHub에도 있습니다!

서버:
var app = require('http').createServer(handler);
var io = require('socket.io')(app);
var fs = require('fs');

app.listen(process.env.PORT);

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

        res.writeHead(200);
        res.end(data);
    });
}


function getRandomColor() {
    let letters = '0123456789ABCDEF';
    let color = '#';
    for (let i = 0; i < 6; ++i) {
        color += letters[Math.floor(Math.random() * 16)];
    }
    return color;
}


class InputData {
    constructor(num) {
        this.num = num;
        this.w = false;
        this.s = false;
        this.a = false;
        this.d = false;
    }
}

class Ball {
    constructor(socket) {
        this.socket = socket;
        this.x = 0;
        this.y = 0;
        this.color = getRandomColor();
        this.inputMap = {};
        this.inputBuffer = [];
        this.lastInputNum = 0;
    }
    
    get id() {
        return this.socket.id;
    }
    
    checkKey(key) {
        return this.inputMap[key];
    }
    
    pushInput(inputData) {
        this.inputBuffer.push(inputData);
    }
    
    applyInputs() {
        let left = this.inputBuffer.length;
        
        while (left > 0) {
            left -= 1;
            let input = this.inputBuffer.shift();
            
            if (input.num > this.lastInputNum) {
                this.lastInputNum = input.num;
                
                this.inputMap.w = input.w;
                this.inputMap.s = input.s;
                this.inputMap.a = input.a;
                this.inputMap.d = input.d;
            }
        }
    }
    
    handleInput(timeRate) {
        let vx = 0;
        let vy = 0;
        
        if (this.checkKey('w')) {
            vy = -4;
        }
        if (this.checkKey('s')) {
            vy = 4;
        }
        if (this.checkKey('a')) {
            vx = -4;
        }
        if (this.checkKey('d')) {
            vx = 4;
        }
        
        this.x += vx * timeRate;
        this.y += vy * timeRate;
    }
}


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


function joinGame(socket) {
    let ball = new Ball(socket);
    
    balls.push(ball);
    ballMap[socket.id] = ball;
    
    return ball;
}

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

function onInput(socket, data) {
    let ball = ballMap[socket.id];
    
    let inputData = new InputData(data.num);
    inputData.w = data.w || false;
    inputData.s = data.s || false;
    inputData.a = data.a || false;
    inputData.d = data.d || false;
    
    ball.pushInput(inputData);
}


io.on('connection', function(socket) {
    console.log(`${socket.id} has joined!`);
    
    socket.on('disconnect', function(reason) {
        console.log(`${socket.id} has leaved! (${reason})`);
        
        leaveGame(socket);
        
        socket.broadcast.emit('leave_user', socket.id);
    });
    
    socket.on('input', function(data) {
        onInput(socket, data);
    });
    
    
    let newBall = joinGame(socket);
    
    socket.emit('user_id', socket.id);
    
    // Send data of users already in game.
    for (let 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,
        });
    }
    
    // Send data of a new user.
    socket.broadcast.emit('join_user', {
        id: socket.id,
        x: newBall.x,
        y: newBall.y,
        color: newBall.color,
    });
});


var prevUpdateTime = new Date().getTime();
var stateNum = 0;

function updateGame() {
    let currentUpdateTime = new Date().getTime();
    let deltaTime = currentUpdateTime - prevUpdateTime;
    prevUpdateTime = currentUpdateTime;
    
    let timeRate = deltaTime / (1000 / 60);
    
    for (let i = 0; i < balls.length; ++i) {
        let ball = balls[i];
        
        ball.applyInputs();
        
        ball.handleInput(timeRate);
    }
    
    setTimeout(updateGame, 16);
}

function broadcastState() {
    stateNum += 1;
    
    let data = {};
    
    data.state_num = stateNum;
    
    for (let i = 0; i < balls.length; ++i) {
        let ball = balls[i];
        
        data[ball.id] = {
            last_input_num: ball.lastInputNum,
            x: ball.x,
            y: ball.y,
        };
    }
    
    io.sockets.emit('update_state', data);
    
    setTimeout(broadcastState, 33);
}

updateGame();
broadcastState();


클라이언트(script 부분만 발췌):

function Ball(id) {
    this.id = id;
    this.color = "#888888";
    this.x = 0;
    this.y = 0;
}


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

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

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

    return ball;
}

function leaveUser(id) {
    for (let 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();
var lastInputNum = 0;
var lastStateNum = 0;
var stateBuffer = [];

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) {
    let stateNum = data.state_num;

    if (stateNum > lastStateNum) {
        lastStateNum = stateNum;

        // Store server states if valid.
        let time = new Date().getTime();
        if (stateBuffer.length == 0 || stateBuffer[stateBuffer.length - 1][1] < time) {
            stateBuffer.push([data, time]);
            if (stateBuffer.length > 256) {
                stateBuffer.shift();
            }
        }
        else if (stateBuffer.length > 0) {
            stateBuffer[stateBuffer.length - 1] = [data, time];
        }

        // Update my state if last input state
        if (myId) {
            let myBall = data[myId];
            if (myBall && myBall.last_input_num >= lastInputNum) {
                updateState(myId, myBall.x, myBall.y);
            }
        }
    }
});


// Input Cache
// inputMap[KEY] : Press(true), Up(false), None(undefined)
var inputMap = {};

function sendInput() {
    let inputs = "wsad";

    let anyInput = false;
    let inputData = {};

    // Copy input map
    for (let i = 0; i < inputs.length; ++i) {
        let key = inputs.charAt(i);

        if (key in inputMap) {
            anyInput = true;
            inputData[key] = inputMap[key];
        }
    }

    // Send input map if exists
    if (anyInput) {
        lastInputNum += 1;
        inputData.num = lastInputNum;

        socket.emit('input', inputData);
    }
}

function handleInput(timeRate) {
    if (!myId) {
        return;
    }

    let ball = ballMap[myId];

    if (!ball) {
        return;
    }


    sendInput();


    // Delete none key and Predict client state.
    let vx = 0;
    let vy = 0;

    if (inputMap['w']) {
        vy = -4;
    }
    else {
        delete inputMap['w'];
    }

    if (inputMap['s']) {
        vy = 4;
    }
    else {
        delete inputMap['s'];
    }

    if (inputMap['a']) {
        vx = -4;
    }
    else {
        delete inputMap['a'];
    }

    if (inputMap['d']) {
        vx = 4;
    }
    else {
        delete inputMap['d'];
    }

    ball.x += vx * timeRate;
    ball.y += vy * timeRate;
}

function interpolate(now) {
    if (stateBuffer.length < 2) {
        return;
    }

    let renderTime = now - 33;

    // Find state contains time for render.
    let t0Index = -1;
    for (let i = stateBuffer.length - 1; i >= 0; --i) {
        let time = stateBuffer[i][1];
        if (renderTime >= time) {
            t0Index = i;
            break;
        }
    }

    if (t0Index < 0 || t0Index + 1 >= stateBuffer.length) {
        return;
    }

    let s0 = stateBuffer[t0Index][0];
    let s1 = stateBuffer[t0Index + 1][0];
    let t0 = stateBuffer[t0Index][1];
    let t1 = stateBuffer[t0Index + 1][1];
    let deltaState = s1.state_num - s0.state_num;

    if (deltaState <= 0 || t0 >= t1) {
        return;
    }

    for (let i = 0; i < balls.length; ++i) {
        let ball = balls[i];

        if (ball.id == myId) {
            continue;
        }

        if (ball.id in s0 && ball.id in s1) {
            let b0 = s0[ball.id];
            let b1 = s1[ball.id];

            // Interpolation
            ball.x = b0.x + (b1.x - b0.x) * (renderTime - t0) / (t1 - t0);
            ball.y = b0.y + (b1.y - b0.y) * (renderTime - t0) / (t1 - t0);
        }
    }
}


var prevUpdateTime = new Date().getTime();

function updateGame() {
    let currentUpdateTime = new Date().getTime();
    let deltaTime = currentUpdateTime - prevUpdateTime;
    prevUpdateTime = currentUpdateTime;

    let timeRate = deltaTime / (1000 / 60);

    handleInput(timeRate);

    interpolate(currentUpdateTime);
}

function renderGame() {
    ctx.clearRect(0, 0, board.width, board.height);

    // Draw balls
    for (let i = 0; i < balls.length; ++i) {
        let ball = balls[i];

        ctx.fillStyle = ball.color;

        ctx.beginPath();
        ctx.arc(ball.x, ball.y, 16, 0, Math.PI * 2, false);
        ctx.closePath();
        ctx.fill();
    }

    //ctx.font = "16px serif";
    //ctx.fillStyle = "black";
    //ctx.fillText("Last S : " + lastStateNum, 8, 24);
}

function update() {
    updateGame();
    renderGame();

    window.requestAnimationFrame(update);
}


var board;
var ctx;

function initGame() {
    board = document.getElementById('board');
    ctx = board.getContext('2d');

    document.addEventListener('keydown', function(event) {
        if (!inputMap[event.key]) {
            inputMap[event.key] = true;
        }
    });
    document.addEventListener('keyup', function(event) {
        inputMap[event.key] = false;
    });

    update();
}




Comments