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들을 생성해준다!
-
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 }
-
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를 통해 연결이 끊어짐 페이지를 구축하였다)
- socket 에서 on을 통해 일괄적으로 처리하도록 설계 하였다
socket.on('response', (data) => {
// 응답이 올바르지 않을 시 내쫓기
if (data.status !== "success") window.location.href = 'serverError.html'
});
한줄 평 + 개선점
-
서버와 연결하는 과정이 의외로 순탄해서 기분이 좋았다
-
현재 ai를 통해 디자인한 후, 이를 해석해가며 살을 붙여가는데.. 확실히 모르겠으면 해석에 노력하는 것보다 안쓰는게 맞는거 같다