Back-End(Node.js)

Back-End 생성

  • 서버를 구축하고 websocket을 사용하기 위해 express, socket.io 를 yarn을 통해 프로젝트에 설치해준다!
# yarn 초기화
yarn init -y
# express, socket.io 설치 
yarn add express, socket.io
  • 추가로 유저 아이디를 관리하기 위해 uuid Package도 설치해준다!
yarn add uuid

init folder

  • socket을 생성하고 express로 연 서버와 연결하기 위해 socket.js를 생성해준다!
import { Server as SocketIO } from 'socket.io';
import registerHandler from '../handlers/register.handler.js';

const initSocket = (server) => {
    // 서버 생성
    const io = new SocketIO()
    // initSocket에 받은 server의 포트와 연결함
    io.attach(server)
}
export default initSocket
  • Data Table에 있는 Data들을 불러오기 위한 assets.js 도 생성 해준다!
// 파일 시스템 작업 모듈(Node.js 내장)
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';

//전역변수
let gameAssets = {};

//현재 파일의 절대경로 찾기
const __filename = fileURLToPath(import.meta.url);
//디렉토리 경로(현재 파일위치) 추출
const __dirname = path.dirname(__filename);
// 현재 파일위치 기준으로 assets 폴더 찾기(../../ => 최상위 폴더로 이동)
const basePath = path.join(__dirname, '../../assets')

//파일 읽기 함수
const readFileAsync = (filename) => {
    return new Promise ((resolve, reject) => {
        fs.readFile(path.join(basePath, filename), 'utf8', (err,data) => {
            // 에러의 경우 실패 처리 후 반환
            if (err) {
                reject(err);
                return;
            }
            // 성공 시 JSON 형태로 변환하여 반환
            resolve(JSON.parse(data))
        })
    })
};

//파일 로드!
export const loadGameAssets = async () => {
    try {
        // 파일들을 Promise.all() 을 이용해 병렬적으로 가져옴
        const [stages, unlock, item, monster] = await Promise.all([
            readFileAsync('stage.json'),
            readFileAsync('unlock.json'),
            readFileAsync('item.json'),
            readFileAsync('monster.json'),
        ]);
        gameAssets = { stages, unlock, item, monster }
        return gameAssets
    } catch(err) {
        throw new Error('Failed to load game assets: '+ err.message)
    }
}

//가져온 파일 데이터 읽기
export const getGameAssets = () => {
    return gameAssets;
};

app.js

  • init 에서 생성해둔 파일들을 통해 서버를 시작하며 연결해준다!
import express from "express";
import { createServer } from 'http';
import initSocket from "./init/socket.js";
import { loadGameAssets } from "./init/assets.js";

const app = express();
const server = createServer(app)

const  PORT = 3000;

app.use(express.json())
app.use(express.urlencoded({extended: false}))
app.use(express.static("public"))
// 웹소켓 연결 
initSocket(server)

server.listen(PORT, async () => {
    console.log('Server is running on PORT: ' + PORT)

    try {
        // 서버 구동 시에 게임 Data Table 로드
        const assets = await loadGameAssets();
    } catch (err) {
        console.error('Failed to load game assets: '+ err.message)
    }
})

models folder

  • 서버가 열려있는 동안의 정보를 저장해줄 model들을 생성해준다!
  1. user

    • 서버에 연결된 user들을 특정하고 확인하는 용도로 user model을 만들어 준다
     // 서버에 메모리형식으로 접속되어있는 users 저장
     const users = [];
    
     // users에 접근하는 함수들
     // users setter
     export const addUser = (user) => {
         users.push(user)
     }
     export const removeUser = (socketId) => {
         const index = users.findIndex((user) => user.socketId === socketId);
         // socketId를 통해 찾았을 경우 삭제하고 그 id값을 반환
         if (index !== -1) return users.splice[index, 1](0);
     }
     // users getter
     export const getUser = () => {
         return users
     }
    
  2. stage

    user들이 어느 stage에 있는지를 검증하는데 이용하는 model 이다!

     // 서버에 메모리형식으로 접속되어있는 user들의 현재 stages 저장
     const stages = {};
    
     // stage reset
     export const createStage = (uuid)=> {
         stages[uuid] = [];
     }
    
     // stage get,set
    
     export const getStage = (uuid) => {
         return stages[uuid]
     }
    
     export const setStage = (uuid, level, timestamp) => {
         return stages[uuid].push({ level, timestamp })
     }
    

handlers folder

  • 접속 시 회원가입(uuid 생성 및 부여)이 되는 handler를 생성해준다
// user.model 에 유저를 저장하기 위해 가져옴
import { addUser} from "../models/user.model.js"
// uuid 생성 버전4 
import { v4 as uuidv4 } from "uuid"
// helper 생성(아래 추가 설명)
import { handleConnection, handleDisconnect, handlerEvent } from "./helper.js";

const registerHandler = (io) => {
    // 유저의 '연결'이 발생 시 함수 실행
    io.on('connection', (socket) => {
        //uuid 생성
        const userUUID = uuidv4();
        //유저 추가
        addUser({ uuid: userUUID, socketId: socket.id});
        handleConnection(socket, userUUID)

        // '이벤트' 발생 시 맵핑 실행 (helper)
        socket.on('event', (data) => handlerEvent(io, socket, data));
        // 유저가 '연결해제' 시 실행 (helper)
        socket.on('disconnect', () => handleDisconnect(socket))
    })
}

export default registerHandler
  • stage 이동 시 검증용 handler를 생성해준다
import { getGameAssets } from "../init/assets.js";
import { getStage, setStage } from "../models/stage.model.js"

export const moveStageHandler = (userId, payload) => {

    // 서버 내 유저의 스테이지 존재 확인
    let currentStages = getStage(userId)
    if (!currentStages.length) return { 
            status: "fail", 
            message: "No stages found for user"
        }

    // 내림차순 정렬로 가장 큰 숫자가 현재 스테이지이므로 확인가능
    currentStages.sort((a, b) => a.level - b.level);
    const currentStage = currentStages[currentStages.length -1]
    // 데이터 가져오기
    const { stages } = getGameAssets();

    // 서버<>클라이언트 검증 과정 - 현재 stage 확인
    const currentStageTime = stages.data.reduce((acc, cur) => { 
        if (cur.level === payload.currentStage) acc += cur.time
        if (cur.level === payload.currentStage - 1) acc -= cur.time
    })
    if (currentStage.level !== payload.currentStage) return {
            status: "fail",
            message: "Current Stage mismatch"
        }

    // 서버<>클라이언트 검증 과정 - 다음 스테이지 확인
    const nextStage = stages.data.find((stage) => stage.level === payload.targetStage)
    if (!nextStage) return {
            status: "fail",
            message: "Target stage not found"
        }

    // 시간 검증
    const serverTime = Date.now();
    // 클라이언트의 스테이지 클리어 시간 확인 (ms => s)
    const elapsedTime = (serverTime - currentStage.timestamp) / 1000
    // 추가로 지연시간으로 오차범위로 5초 까지 인정
    if (elapsedTime < currentStageTime || elapsedTime > currentStageTime + 5) return { 
            status: "fail",
            message: "Invalid elapsed time"
        }
    
    setStage(userId, payload.targetStage, serverTime)

    return { status: "success"}
}
  • game의 score와 stage를 검증해주는 handler를 생성해준다!
import { getGameAssets } from "../init/assets.js";
import { createStage, getStage, setStage } from "../models/stage.model.js";

export const gameStart = (uuid, payload) => {
    // stage 정보 추출
    const { stages } = getGameAssets();

    //스테이지 초기화
    createStage(uuid);

    // 첫번째 stage의 정보로 setStage
    /*  payload.timestamp의 경우 원래는 클라이언트에서 들어온 정보이기에 
        서버에 바로 들이면 보안성 부분에서 문제가 일어날 수 있음   */
    setStage(uuid, stages.data[0].level, payload.timestamp)

    console.log("Stage: ", getStage(uuid))

    return { status: "success"}
}

// 게임 종료 시 점수와 시간을 크로스체크
export const gameEnd = (uuid, payload) => {
    const stageInfo = getGameAssets().stages.data;
    const { timestamp: gameEndTime, currentStage, score} = payload;
    // 스테이지 정보 확인
    const stages = getStage(uuid)
    if (!stages.length) return {
            status: "fail",
            message: "No stages found for user"
        }

    let totalScore = 0;

    stages.forEach((stage, index) => {
        // 서버의 스테이지 초당 점수 가져오기 
        const scorePerSecond = stageInfo.find((e) => e.level === stage.level).scorePerSecond
        let stageEndTime;
        // 마지막 스테이지일 경우 마지막 시간을,
        if (index === stages.length -1) {
            stageEndTime = gameEndTime;
        // 아닐 경우 이전 스테이지의 시간을 가져옴
        } else {
            stageEndTime = stages[index+1].timestamp
        }

        // 스테이지당 머문시간 확인 (ms => s)
        const stageDuration = (stageEndTime - stage.timestamp) / 1000
        totalScore += stageDuration * scorePerSecond
    })

    // 점수, 타임스탬프 검증 (오차범위 +-5까지 인정)
    if (Math.abs(score - totalScore) > 5) return {
            status: "fail",
            message: "Score verification failed"
        }

    return { 
        status: "success",
        message: "Game ended",
        score
    }
}

export const getItem = (uuid,payload) => {
    const { unlock: unlockInfo, item: itemInfo} = getGameAssets();
    const { id, score, health, damage, speed, attackSpeed, prob } = payload

    // 스테이지 정보 확인
    const stages = getStage(uuid)
    if (!stages?.length) return {
        status: "fail",
        message: "No stages found for user"
    }

    // 현재 스테이지 레벨 확인
    stages.sort((a, b) => a.level - b.level);
    const currentStage = stages[stages.length - 1]

    // 스테이지와 비교해서 언락된 아이템인지 확인
    const unlocked = unlockInfo.data.find((unlock) => unlock.target_id === id)
    if (!unlocked || unlocked?.stage_level > currentStage.level) return {
        status: "fail",
        message: "Not unlocked item"
    }

    // 아이템 검증
    const item = itemInfo.data.find((item) => item.id === id)
    if (!item
        || item?.score !== score
        || item?.health !== health
        || item?.damage !== damage
        || item?.speed !== speed
        || item?.attackSpeed !== attackSpeed
        || item?.prob !== prob
    ) return {
        status: "fail",
        message: "Item verification failed"
    }

    //점수 검증때 사용
    itemScore += score

    return {
        status: "success"
    }
}
  • 위의 핸들러들과 이벤트들을 묶어주는 mapping 을 생성해준다
import { gameEnd, gameStart, getItem } from "./game.handler.js";
import { moveStageHandler } from "./stage.handler.js";
// key - value 형식으로 알맞은 key값에 매칭되는 handler를 반환
const handlerMappings = {
    2: gameStart,
    3: gameEnd,
    4: getItem,
    11: moveStageHandler,
}
export default handlerMappings
  • mapping 을 이용해 핸들러를 직접 실행시켜주는 helper를 생성해준다
import { CLIENT_VERSION } from "../constant.js"
import { getUser, removeUser } from "../models/user.model.js"
import handlerMappings from "./handler.Mapping.js"

export const handleDisconnect = (socket, uuid) => {
    removeUser(socket.id)
    console.log('User disconnected: ',socket.id)
    console.log('Current users: ',getUser())
}

export const handleConnection = (socket, uuid) => {
    console.log(`New user connected: ${uuid} with socket Id ${socket.id}` );
    console.log('Current users: ', getUser())

    //유저와 연결되면 uuid를 메세지로 전달
    socket.emit('connection', {uuid})
}

export const handlerEvent = (io, socket, data) => {
    //클라이언트 버전 확인
    if (!CLIENT_VERSION.includes(data.clientVersion)) {
        socket.emit('response', { 
            status: "fail",
            message: "Client version not found"
        });
        return;
    }

    const handler = handlerMappings[data.handlerId]
    if (!handler) {
        socket.emit('response', {
            status : "fail",
            message: "Handler not found"
        })
        return;
    }

    const response = handler(data.userId, data.payload);

    // 서버 전 유저에게 알림
    if (response.broadcast) {
        io.emit('response', 'broadcast');
        return;
    }
    // 대상 유저에게만 보냄
    socket.emit('response', response);
}

Front-End 연결

  • 클라이언트(Front-End)에서도 WebSocket으로 연결하기 위해 WebSocket 기능을 CDN을 통해 받아준다
<!-- index.html -->
<!DOCTYPE html>
    <html lang="ko">
    <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>Vampire Survivors Prototype</title>
        <link rel = "stylesheet" href="assets/css/main.css"/>
        <!-- 이부분 입니다! -->
        <script src="https://cdn.socket.io/socket.io-3.0.1.min.js"></script>
        <!--  -->
    </head>
    <body>
        <canvas id="gameCanvas"></canvas>
        <script src = "./index.js" type="module"></script>
  </body>
</html>

socket.js 생성

  • 현재 서버와 웹소켓으로 연결하기 위해 이 작업을 해줄 js 파일이 필요하다!

  • 이전의 data 형식을 기준으로 요청을 보내도록 만들었다!

Field Type Description
handlerId INT 요청을 처리할 서버 핸들러의 ID 값
userId INT 요청을 보낸 유저의 ID 값
clientVersion STRING 클라이언트의 버전 관리용 값
payload JSON 요청 시 서버에게 보내줄 데이터
import { CLIENT_VERSION } from "./constants.js";

// localhost:3000 에 연결하여 CLIENT_VERSION을 넘겨줌
const socket = io('http://localhost:3000', {
    query: {
        clientVersion: CLIENT_VERSION,
    },
});

// 클라이언트에서 저장해둘 userId 선언
let userId = null;

// response로 받아온 데이터들을 console에 출력
socket.on('response', (data) => {
    console.log(data);
});

// 서버에 연결되었을 시, console에 출력하며 userId를 저장
socket.on('connection', (data) => {
    console.log('connection: ', data);
    userId = data.uuid;
});

// 클라이언트에서 총합적으로 server에 보내주는걸 관리
const sendEvent = (handlerId, payload) => {
    socket.emit('event', {
        userId,
        clientVersion: CLIENT_VERSION,
        handlerId,
        payload,
    });
};

export { sendEvent };

기능별 sendEvent 매칭

  • sendEvent를 이용해 요청을 보내게 배치해주었다
// 게임 초기화
function reset() {
    hasAddedEventListenersForRestart = false;
    gameOver = false;
    waitingToStart = false;
    stage = null

    player.reset(PLAYER_MAX_HEALTH, PLAYER_DAMAGE, PLAYER_SPEED);
    bullets.updateAttackSpeed(BULLET_ATTACK_SPEED)
    monsters.reset();
    items.reset();
    score.reset();
    // 서버에 유저 + 스테이지 정보 저장하도록 요청
    sendEvent(2, { timestamp: Date.now() })
}
// 아이템 획득
if (items.colliedWith(player)) {
    const itemIndex = items.items.findIndex((item) => item.pickup === true)
    const item = items.items[itemIndex]
    // 아이템이 올바른가 검증하도록 요청
    sendEvent(4, item)
    // 아이템 스탯 적용 
    player.heal(item.heal)
    player.statUp(item.damage, item.speed)
    bullets.increaseAttackSpeed(item.attackSpeed)
    score.addScore(item.score)
}
// 게임오버
if (!gameOver && player.health < 1) {
    gameOver = true;
    // 게임 종료 시 얻은 점수와 플레이한 시간이 올바른지 확인 요청
    sendEvent(3, { timestamp: Date.now(), score: scorePoint })
    score.setHighScore()
    setupGameReset()
}

해커 내쫓기

  • 만약 검증한 사항들 중 제대로된 응답이 오지 않았을 경우 다른 페이지로 내보낸다
    (ai를 통해 연결이 끊어짐 페이지를 구축하였다)

image

  • socket 에서 on을 통해 일괄적으로 처리하도록 설계 하였다
socket.on('response', (data) => {
    // 응답이 올바르지 않을 시 내쫓기
    if (data.status !== "success") window.location.href = 'serverError.html'
});

한줄 평 + 개선점

  • 서버와 연결하는 과정이 의외로 순탄해서 기분이 좋았다

  • 현재 ai를 통해 디자인한 후, 이를 해석해가며 살을 붙여가는데.. 확실히 모르겠으면 해석에 노력하는 것보다 안쓰는게 맞는거 같다