1. 환경 설정
Vscode
http, css, js
DB :
mySQL
prima
localhost:3306
# 프로젝트 시작
yarn init -y
# express socket.io 설치
yarn add express socket.io
# prettier nodemon 설치 (위에서 이미 설치됨으로 중복 제거)
yarn add -D nodemon prettier
# prisma
$ yarn prisma init
// package.json
// main 서버파일로 수정
"main": "src/app.js",
// 옵션 추가
"type": "module",
"scripts": {
"dev":"nodemon ./src/app.js"
}
// .prettierrc
{
"singleQuote": true,
"semi": true,
"useTabs": false,
"tabWidth": 2,
"trailingComma": "all",
"printWidth": 100,
"arrowParens": "always",
"orderedImports": true,
"bracketSpacing": true,
"jsxBracketSameLine": false
}
express 서버 세팅하기
// ./src/app.js 서버 기본 틀
import express from "express";
import { createServer } from "http";
const app = express();
const server = createServer(app);
const PORT = 3000;
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
server.listen(PORT, async () => {
console.log(`Server is running on port ${PORT}`);
});
그 외 코드
# .gitignore
# .gitignore
# compiled output
/dist
/node_modules
# Logs
logs
*.log
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# OS
.DS_Store
# Tests
/coverage
/.nyc_output
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
./assets 폴더
// ./assets/item_unlock.json
{
"name": "item_unlock",
"version": "1.0.0",
"data": [
{ "id": 101, "stage_id": 1001, "item_id": 1 },
{ "id": 201, "stage_id": 1002, "item_id": 2 }
]
}
// ./assets/item.json
{
"name": "item",
"version": "1.0.0",
"data": [
{ "id": 1, "score": 10 },
{ "id": 2, "score": 20 },
{ "id": 3, "score": 30 },
{ "id": 4, "score": 40 },
{ "id": 5, "score": 50 },
{ "id": 6, "score": 60 }
]
}
// ./assets/stage.json
{
"name": "stage",
"version": "1.0.0",
"data": [
{ "id": 1000, "score": 0 },
{ "id": 1001, "score": 100 },
{ "id": 1002, "score": 200 },
{ "id": 1003, "score": 300 },
{ "id": 1004, "score": 400 },
{ "id": 1005, "score": 500 },
{ "id": 1006, "score": 600 }
]
}
./src 폴더
// ./src/app.js
// ./src/app.js
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.static('public'));
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
initSocket(server);
app.get('/', (req, res) => {
res.send('<h1>Hello World</h1>');
});
server.listen(PORT, async () => {
console.log(`Server is running on port ${PORT}`);
try {
const assets = await loadGameAssets();
console.log(assets);
console.log('Assets loaded successfully');
} catch (error) {
console.error('Failed to load game assets:', error);
}
});
// ./src/constants.js
// ./src/constants.js
export const CLIENT_VERSION = ['1.0.0', '1.0.1', '1.1.0'];
./src/handlers 폴더
// ./src/handlers/game.handler.js
// // ./src/handlers/game.handler.js
import { getGameAssets } from '../init/assets.js';
import { clearStage, getStage, setStage } from '../models/stage.model.js';
export const gameStart = (uuid, payload) => {
const { stages } = getGameAssets();
clearStage(uuid);
setStage(uuid, stages.data[0].id, payload.timestamp);
console.log('Stage:', getStage(uuid));
return { status: 'success' };
};
export const gameEnd = (uuid, payload) => {
// 클라이언트에서 받은 게임 종료 시 타임스탬프와 총 점수
const { timestamp: gameEndTime, 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) => {
let stageEndTime;
if (index === stages.length - 1) {
// 마지막 스테이지의 경우 종료 시간이 게임의 종료 시간
stageEndTime = gameEndTime;
} else {
// 다음 스테이지의 시작 시간을 현재 스테이지의 종료 시간으로 사용
stageEndTime = stages[index + 1].timestamp;
}
const stageDuration = (stageEndTime - stage.timestamp) / 1000; // 스테이지 지속 시간 (초 단위)
totalScore += stageDuration; // 1초당 1점
});
// 점수와 타임스탬프 검증 (예: 클라이언트가 보낸 총점과 계산된 총점 비교)
// 오차범위 5
if (Math.abs(score - totalScore) > 5) {
return { status: 'fail', message: 'Score verification failed' };
}
// 모든 검증이 통과된 후, 클라이언트에서 제공한 점수 저장하는 로직
// saveGameResult(userId, clientScore, gameEndTime);
// 검증이 통과되면 게임 종료 처리
return { status: 'success', message: 'Game ended successfully', score };
};
// ./src/handlers/handlerMapping.js
// ./src/handlers/handlerMapping.js
import { moveStageHandler } from './stage.handler.js';
import { gameEnd, gameStart } from './game.handler.js';
const handlerMappings = {
2: gameStart,
3: gameEnd,
11: moveStageHandler,
};
export default handlerMappings;
// ./src/handlers/helper.js
// ./src/handlers/helper.js
import { getUsers, removeUser } from '../models/user.model.js';
import { CLIENT_VERSION } from '../constants.js';
import handlerMappings from './handlerMapping.js';
import { createStage } from '../models/stage.model.js';
export const handleConnection = (socket, userUUID) => {
console.log(`New user connected: ${userUUID} with socket ID ${socket.id}`);
console.log('Current users:', getUsers());
// 스테이지 빈 배열 생성
createStage(userUUID);
socket.emit('connection', { uuid: userUUID });
};
export const handleDisconnect = (socket, uuid) => {
removeUser(socket.id); // 사용자 삭제
console.log(`User disconnected: ${socket.id}`);
console.log('Current users:', getUsers());
};
export const handleEvent = (io, socket, data) => {
if (!CLIENT_VERSION.includes(data.clientVersion)) {
socket.emit('response', { status: 'fail', message: 'Client version mismatch' });
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);
};
// ./src/handlers/register.handler.js
// ./src/handlers/register.handler.js
import { v4 as uuidv4 } from 'uuid';
import { addUser } from '../models/user.model.js';
import { handleConnection, handleDisconnect, handleEvent } from './helper.js';
const registerHandler = (io) => {
io.on('connection', (socket) => {
// 최초 커넥션을 맺은 이후 발생하는 각종 이벤트를 처리하는 곳
const userUUID = uuidv4(); // UUID 생성
addUser({ uuid: userUUID, socketId: socket.id }); // 사용자 추가
handleConnection(socket, userUUID);
// 모든 서비스 이벤트 처리
socket.on('event', (data) => handleEvent(io, socket, data));
// 접속 해제시 이벤트 처리
socket.on('disconnect', () => handleDisconnect(socket, userUUID));
});
};
export default registerHandler;
// ./src/handlers/stage.handler.js
// ./src/handlers/stage.handler.js
import { getStage, setStage } from '../models/stage.model.js';
import { getGameAssets } from '../init/assets.js';
export const moveStageHandler = (userId, payload) => {
// 유저의 현재 스테이지 배열을 가져오고, 최대 스테이지 ID를 찾는다.
let currentStages = getStage(userId);
if (!currentStages.length) {
return { status: 'fail', message: 'No stages found for user' };
}
// 오름차순 정렬 후 가장 큰 스테이지 ID 확인 = 가장 상위의 스테이지 = 현재 스테이지
currentStages.sort((a, b) => a.id - b.id);
const currentStage = currentStages[currentStages.length - 1];
// payload 의 currentStage 와 비교
if (currentStage.id !== payload.currentStage) {
return { status: 'fail', message: 'Current stage mismatch' };
}
// 점수 검증
const serverTime = Date.now();
const elapsedTime = (serverTime - currentStage.timestamp) / 1000; // 초 단위로 계산
// 1초당 1점, 100점이상 다음스테이지 이동, 오차범위 5
// 클라이언트와 서버 간의 통신 지연시간을 고려해서 오차범위 설정
// elapsedTime 은 100 이상 105 이하 일 경우만 통과
if (elapsedTime < 100 || elapsedTime > 105) {
return { status: 'fail', message: 'Invalid elapsed time' };
}
// 게임 에셋에서 다음 스테이지의 존재 여부 확인
const { stages } = getGameAssets();
if (!stages.data.some((stage) => stage.id === payload.targetStage)) {
return { status: 'fail', message: 'Target stage does not exist' };
}
// 유저의 다음 스테이지 정보 업데이트 + 현재 시간
setStage(userId, payload.targetStage, serverTime);
return { status: 'success' };
};
./src/init 폴더
// ./src/init/assets.js
// ./src/init/assets.js
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
// import.meta.url은 현재 모듈의 URL을 나타내는 문자열
// fileURLToPath는 URL 문자열을 파일 시스템의 경로로 변환
// 현재 파일의 절대 경로. 이 경로는 파일의 이름을 포함한 전체 경로
const __filename = fileURLToPath(import.meta.url);
// path.dirname() 함수는 파일 경로에서 디렉토리 경로만 추출 (파일 이름을 제외한 디렉토리의 전체 경로)
const __dirname = path.dirname(__filename);
const basePath = path.join(__dirname, '../../assets');
let gameAssets = {}; // 전역함수로 선언
const readFileAsync = (filename) => {
return new Promise((resolve, reject) => {
fs.readFile(path.join(basePath, filename), 'utf8', (err, data) => {
if (err) {
reject(err);
return;
}
resolve(JSON.parse(data));
});
});
};
export const loadGameAssets = async () => {
try {
const [stages, items, itemUnlocks] = await Promise.all([
readFileAsync('stage.json'),
readFileAsync('item.json'),
readFileAsync('item_unlock.json'),
]);
gameAssets = { stages, items, itemUnlocks };
return gameAssets;
} catch (error) {
throw new Error('Failed to load game assets: ' + error.message);
}
};
export const getGameAssets = () => {
return gameAssets;
};
// ./src/init/socket.js
// ./src/init/socket.js
import { Server as SocketIO } from 'socket.io';
import registerHandler from '../handlers/register.handler.js';
const initSocket = (server) => {
const io = new SocketIO();
io.attach(server);
registerHandler(io);
};
export default initSocket;
./src/model 폴더
// ./src/models/stage.model.js
// ./src/models/stage.model.js
const stages = {};
export const createStage = (uuid) => {
stages[uuid] = []; // 초기 스테이지 배열 생성
};
export const getStage = (uuid) => {
return stages[uuid];
};
export const setStage = (uuid, id, timestamp) => {
return stages[uuid].push({ id, timestamp });
};
export const clearStage = (uuid) => {
return (stages[uuid] = []);
};
// ./src/model/user.model.js
// ./src/model/user.model.js
const users = [];
export const addUser = (user) => {
users.push(user);
};
export const removeUser = (socketId) => {
const index = users.findIndex((user) => user.socketId === socketId);
if (index !== -1) {
return users.splice(index, 1)[0];
}
};
export const getUsers = () => {
return users;
};
// ./src/init/socket.js
// ./src/init/socket.js
import { Server as SocketIO } from "socket.io";
const initSocket = (server) => {
const io = new SocketIO();
io.attach(server);
};
export default initSocket;
실행 명령어
// 서버 실행
yarn dev
2. 문서화
DB
https://drawsql.app/teams/yewon-yoon/diagrams/ch4-1-2
와이어 프레임
https://app.eraser.io/workspace/e3wJkFL4vCMaAdlGDsnz?origin=share&elements=wJwOhwamJ_fiaqYzeLpi2A
3. 오류 정리
참조무결성 제약 On Delete / On Update
1) On Delete
Cascade : 부모 데이터 삭제 시 자식 데이터도 삭제
Set null : 부모 데이터 삭제 시 자식 테이블의 참조 컬럼을 Null로 업데이트
Set default : 부모 데이터 삭제 시 자식 테이블의 참조 컬럼을 Default 값으로 업데이트
Restrict : 자식 테이블이 참조하고 있을 경우, 데이터 삭제 불가
No Action : Restrict와 동일, 옵션을 지정하지 않았을 경우 자동으로 선택된다.
2) On Update
Cascade : 부모 데이터 업데이트 시 자식 데이터도 업데이트
Set null : 부모 데이터 업데이트 시 자식 테이블의 참조 컬럼을 Null로 업데이트
Set default : 부모 데이터 업데이트 시 자식 테이블의 참조 컬럼을 Default 값으로 업데이트
Restrict : 자식 테이블이 참조하고 있을 경우, 업데이트 불가
No Action : Restrict와 동일, 옵션을 지정하지 않았을 경우 자동으로 선택된다.
model Users {
userId Int @id @default(autoincrement()) @map("userId")
name String @unique @map("name")
currentStage Stages? @relation("StagesToUsersCurrent", fields: [stageId], references: [currentStage], onDelete: SetNull)
targetStage Stages? @relation("StagesToUsersTarget", fields: [stageId], references: [targetStage], onDelete: SetNull)
payload Payloads[] @relation("UsersToPayloads")
@@map("Users")
}
model Stages {
stageId Int @id @default(autoincrement())
targetStage Users[] @relation("StagesToUsersTarget")
currentStage Users[] @relation("StagesToUsersCurrent")
@@map("Stages")
}
4. 시현
참고
참조 무결성 제약 : https://swingswing.tistory.com/5
프리즈마 문서 : https://www.prisma.io/docs/orm/prisma-client/queries/relation-queries