이벤트 핸들러 :
- 데이터 교환, 메시지 교환을 하는 모든 과정을 '이벤트'라고도 부르는데
이 '이벤트'를 어떻게 관리할 것인지, 어떻게 핸들링 할 것인지
- 기획한 컨텐츠를 처리하기 위한 이벤트 핸들러를 만들 수 있다.
1. 기획 리마인드
⛳️ 스테이지
☑️ 시간에 따른 점수 획득
- 기본적으로 오른쪽을 이동하면서 장애물을 피하는 게임
- 오래 버틸수록 높은 점수 획득 (시간에 따른)
☑️ 스테이지에 따라서 더 높은 점수 획득
- 0점 , 1스테이지
- 1000점, 2스테이지
- 위와 같이 점수로 나뉘어서 스테이지 구분
- 스테이지가 올라갈수록 시간당 높은 점수 획득 가능
- ex) 1스테이지 = 1점 per 1s, 2스테이지 = 2점 per 1s
🎲 아이템
☑️ 아이템 종류에 따라 다른 점수 획득
☑️ 스테이지에 따라 생성되는 아이템 구분
- 1스테이지에는 1번 아이템만, 2스테이지에는 2번 아이템까지 나오는 것
- 높은 스테이지의 아이템에서는 더 높은 점수 획득 가능
2. 핸들러 맵핑 + 스테이지 이동
게임의 규모가 커지면 핸들러가 100개, 200개가 될 텐데 어떻게 관리를 하나요?
- 여려가지의 유지보수 관리방법이 있지만 이번에 적용해볼 것은 핸들러 맵핑입니다.
- 각 핸들러에 고유 ID를 부여하여 호출하는 기법입니다.
- 기획을 다시 한번 리마인드 해봅시다.
- 요청을 처리하는 핸들러 ID를 이벤트 메시지에 같이 보내기로 했습니다
이 핸들러 ID는 클라이언트와 서버 사이의 약속으로 서로 공유하고 있는 값입니다.
어렵지 않아요
1. 우선 스테이지 이동 핸들러를 만들어봅시다.
먼저 스테이지 이동 handler를 만들어보겠습니다.
handler/stage.handler.js를 생성하고
stage.handler.js 파일 내부에
const moveStageHandler = (uuid, payload) => {
return { status: 'success' };
};
// userId 와 payload를 인자로 받는 moveStageHandler 함수를 선언합니다.
// 우선은 아무런 처리도 하지않습니다. 맵핑 이후에 처리하는 코드를 작성할 에정입니다.
export { moveStageHandler };
을 추가해줍니다.
그 다음에 필요한 것은 handerMapping 입니다.
(
각 기능별 핸들러를 정수형 id 붙여주기
클라이언트의 요청(id)를 바탕으로 핸들러가
동작하게 만들어줍니다.
)
handlers/handerMapping.js 파일을 생성하고
import { moveStageHandler } from './stage.handler';
const handlerMappings = {
11: moveStageHandler,
};
// 이벤트에 따른 호출돼야 하는 핸들러 호출을 위한
// 핸들러 모음
// 앞으로 11번을 호출하면 stage와 관련된 handler가 호출된다.
export default handlerMappings;
위와 같이 stagehandler는
11번으로 moveStageHander를 맵핑해줍니다.
만약 클라이언트로 부터 11번이 오면
moveStageHandler를 바탕으로 원하는 요청을
처리해줍니다.
그 다음 helper.js 파일에 들어가
export const handlerEvent = (io, socket, data) => {
};
// 핸들러를 맵핑하는 객체를 생성했으니 사용을 할 곳이 있어야합니다.
// 유저의 모든 메세지를 받아 적절한 핸들러로 보내주는 이벤트 핸들러를 만들어봅시다.
유저의 모든 메세지를 받아 적절한 핸들러로 보내주는 이벤트 핸들러를 만들어줍니다.
이 핸들러를 사용할 Handler가 필요한데 유저의 접속이 이뤄진 이후
사용되는 것이 옳바르므로
Regoster.handler.js의 registerHandler
const registerHandler = (io) => {
io.on('connection', (socket) => {
// 최초 커넥션을 맺은 이후 발생하는 각종 이벤트를 처리하는 곳
const userUUID = uuid();
// v4 메서드를 바탕으로 uuid를 생성 및 담아준다.
addUser({ uuid: userUUID, socketId: socket.id });
// socketId는 socket.id로 받아온다.
//이 시점이 현재 유저가 막 접속을 했을 시점 입니다.
handleConnection(socket, userUUID);
//접속 해제시 이벤트
socket.on('disconnect', (socket) => handleDisconnect(socket, userUUID));
});
// io.on을 사용하면 'connection'이 발생할 때 까지
// 소켓 객체가 대기하겠다는 의미
};
에 생성해주겠습니다.
위치는 유저가 첫 스테이지 진입 이후가 적절하므로
const registerHandler = (io) => {
io.on('connection', (socket) => {
// 최초 커넥션을 맺은 이후 발생하는 각종 이벤트를 처리하는 곳
const userUUID = uuid();
// v4 메서드를 바탕으로 uuid를 생성 및 담아준다.
addUser({ uuid: userUUID, socketId: socket.id });
// socketId는 socket.id로 받아온다.
//이 시점이 현재 유저가 막 접속을 했을 시점 입니다.
handleConnection(socket, userUUID);
socket.on('event', (data) => handlerEvent(io, socket, data));
//접속 해제시 이벤트
socket.on('disconnect', (socket) => handleDisconnect(socket, userUUID));
});
// io.on을 사용하면 'connection'이 발생할 때 까지
// 소켓 객체가 대기하겠다는 의미
};
위와 같이 변경해줍니다.
이제 이벤트가 발생했을 경우 핸들러 끼리는 연결이 됐지만,
아직 해줘야 할 일이 있습니다.
바로 Version Check 입니다.
그래서 src(현재 존재하는 폴더)/constants.js라는 상수값 .js 파일을 생성해줍니다.
contants.js
const CLIENT_VERSION = ['1.0.0', '1.0.1', '1.1.0'];
// 클라이언트가 가질 수 있는 버전들
현재 Client의 버전은 "1.0.0", "1.0.1", "1.1.0"으로 3개 존재한다고 가정하겠습니다.
다시 handlers/helper.js로 이동해
export const handlerEvent = (io, socket, data) => {
};
// 핸들러를 맵핑하는 객체를 생성했으니 사용을 할 곳이 있어야합니다.
// 유저의 모든 메세지를 받아 적절한 핸들러로 보내주는 이벤트 핸들러를 만들어봅시다.
에 클라이언트가 CLIENT_VERSION중 하나를 가지고 있는지 확인하는 코드와
handlerMappings[data.handlerId]
로 handler의 유무도 확인해주겠습니다.
handlerId는 기획에서
공통 부분(필수 내용물)
로 꼭! 넣기로 결정된 부분입니다.
변경 후 :
const handlerEvent = (io, socket, data) => {
if (!CLIENT_VERSION.includes(data.clientVersion)) {
//버전에 포함되지 않은 버전을 들고 있는 클라이언트
socket.emit('response', { status: 'fail', message: 'Client Version MisMatch' });
// http의 response와 다르다(우리가 임의로 정한 이름)
return;
}
// 클라이언트 버전에 포함되지 않은 버전이라면
// 접근 불가능을 알려줍니다.
const handler = handlerMappings[data.handlerId];
//handlerMappings[data.handlerId](필수)로
if (!handler) {
socket.emit('response', { status: 'fail', message: 'Handler Not Found' });
return;
}
// handler를 찾지 못 했을 경우 오류 메시지 전송
// handler를 찾았다면 handler를 실행시킵니다.
const response = handler(data.userId, data.payload);
// 이때, 매개변수로 받은 userId와 payload는 필수적으로 필요합니다.
if (response.broadcast) {
io.emit('response', 'broadcast');
return;
}
// response가 한 Clinet에게가 아닌
// 여러 유저에게 메시지나 결과를 제공해야한다면
// 위와 같이 broadcast를 사용하면 됩니다.
socket.emit('response', response);
// stageHandler라 가정하에
// const moveStageHandler = (uuid, payload) => {
// return { status: 'success' };
// };
// 위의 Handler가 동작하게 돼
// { status : 'success'} Clinet에게 제공될 것 입니다.
};
// 핸들러를 맵핑하는 객체를 생성했으니 사용을 할 곳이 있어야합니다.
// 유저의 모든 메세지를 받아 적절한 핸들러로 보내주는 이벤트 핸들러를 만들어봅시다.
export { handleDisconnect, handleConnection, handlerEvent };
만약 특정 클라이언트(유저) 한 명이 아닌 여러 명의 클라이언트(유저)에게 메시지를 보내야 한다면,
if (response.broadcast) {
io.emit('response', 'broadcast');
return;
}
// response가 한 Clinet에게가 아닌
// 여러 유저에게 메시지나 결과를 제공해야한다면
// 위와 같이 broadcast를 사용하면 됩니다.
해주면 됩니다.
현재 helper.js의 주석 내용 중 moveStageHandler를 보면
stage 를 이동하는 내용이 없다는 사실을 확인할 수 있습니다.
이제 다음 스테이지로 이동 시켜주는 코드를 작성해보겠습니다.
stage.handler.js
import { getStage } from '../models/stage.model.js';
import { getGameAssets } from '../init/assets.js';
const moveStageHandler = (uuid, payload) => {
// 유저는 스테이지를 하나 씩 올라갈 수 있다.
// (1 -> 2, 2-> 3)
// 유저는 일정 점수가 되면 다음 스테이지로 이동한다.
//매개변수로 받은 payload에서는
//const {currentStage, targetStage} = payload로 제공됩니다.
// 유저의 현재 스테이지 정보를 가져온다.
let currentStage = getStage(uuid);
if (!currentStage.length) {
return { status: 'fail', message: 'No Stages Found For User' };
}
// 오름차순 -> 유저의 현재 스테이지 가장 큰 스테이지 ID를 확인
currentStage.sort((a, b) => a.id - b.id);
// 현재 스테이지 id가 올라 갈수록 높은 단계 입니다.
const currentStageId = currentStage[currentStage.length - 1].id;
// 클라이언트 VS 서버 비교
if (currentStageId !== payload.currentStage)
return { status: 'fail', message: 'Current Stage Mismatch' };
// targetStage 대한 검증 <- 게임 에셋에 존재하는가?
const { stages } = getGameAssets();
if (!stages.data.some((stage) => stage.id === payload.targetStage))
return { status: 'fail', message: 'Target Stage Not Found' };
//
// 점수를 비교해서 유저가 스테이지를 이동할 점수를 얻었는지 확인하는 코드 넣기
//
setStage(userId, payload.targetStage);
// 다음 스테이지를 제공하는 함수
return { status: 'success' };
};
// userId 와 payload를 인자로 받는 moveStageHandler 함수를 선언합니다.
// 우선은 아무런 처리도 하지않습니다. 맵핑 이후에 처리하는 코드를 작성할 에정입니다.
export { moveStageHandler };
이제 점수 계산 코드를 구현해보겠습니다.
점수를 계산하기 위해서는 Game을 Start 해야 합니다.
그래야 현재 시간을 바탕으로 점수가 올라가게 만들어줘야 합니다.
그럼 게임 시작 시간을 저장하는 파일과 함수를 만들어보겠습니다.
handlers/game.handler.js 파일을 추가해줍니다.
handlers/game.handler.js
const gameStartHandler = () => {
return { status: 'success' };
};
// 현재 어떠한 로직도 없기 때문에 무조건 성공으로 처리한다.
const gameEndHandler = () => {
return { status: 'success' };
};
// 현재 어떠한 로직도 없기 때문에 무조건 성공으로 처리한다.
export { gameStartHandler, gameEndHandler };
// gameStart handler와 gameEnd handler를
// handlerMapping에 추가해줍니다.
gameStartHandler와 gameEndHandler를 추가해줍니다.
그리고, handlerMapping.js의
handlerMappings에 gameStartHandler와 gameEndHandler를 추가해줍니다.
handlerMapping.js
import { moveStageHandler } from './stage.handler.js';
import { gameStartHandler, gameEndHandler } from './game.handler.js';
const handlerMappings = {
2: gameStartHandler,
3: gameEndHandler,
11: moveStageHandler,
};
// 이벤트에 따른 호출돼야 하는 핸들러 호출을 위한
// 핸들러 모음
// 앞으로 11번을 호출하면 stage와 관련된 handler가 호출된다.
// 앞으로 2번을 호출하면 game 시작과 관련된 handler가 호출된다.
// 앞으로 3번을 호출하면 game 끝내기와 관련된 handler가 호출된다.
export default handlerMappings;
이제 게임을 시작하면 스테이지에서 시간을 같이 저장해야하기 때문에
Stage.model.js의 setStage함수에 timestamp 매개변수를 추가해주겠습니다.
// 유저에게 제공할 스테이지
// 유저에게 제공할 스테이지
const setStage = (uuid, id, timestamp) => {
return stages[uuid].push({ id, timestamp });
};
timestamp 매개변수를 추가했고, 객체 생성 구간에도 넣어주었습니다.
그 다음,
helper.js 파일의 handleConnection 함수에서
const handleConnection = (socket, uuid) => {
console.log(`New user connected ${uuid} with socket Id ${socket.id}`);
console.log('Current users : ', getUser());
const { stages } = getGameAssets();
// stages 배열에서 0번 째 = 첫 번째 스테이지
setStage(uuid, stages.data[0].id);
console.log('Stage : ' + getStage(uuid));
socket.emit('connection', { uuid });
//소켓을 가지고 있는 유저 본인에게 정보를 보내줍니다.
}
현재 우리는 Connection이 되면 바로 게임을 실행해주는데,
const { stages } = getGameAssets();
// stages 배열에서 0번 째 = 첫 번째 스테이지
setStage(uuid, stages.data[0].id);
console.log('Stage : ' + getStage(uuid));
게임 실행 코드를 gameStartHandler에 옮겨줍니다.
game.handler.js
const gameStartHandler = (uuid, payload) => {
const { stages } = getGameAssets();
// stages 배열에서 0번 째 = 첫 번째 스테이지
setStage(uuid, stages.data[0].id, payload.timestemp);
// 클라이언트에서 시작하는 시간을 받아서 저장해줄겁니다.
// => payload.timestemp
console.log('Stage : ' + getStage(uuid));
return { status: 'success' };
};
그리고 매개변수로 uuid와 payload를 받아 올 수 있게 만들어주고,
setStage에 input으로 payload.timestemp로
클라이언트에서 시작하는 시간을 받아와 저장해줍니다.
이제 점수를 구현해보겠습니다.
stage.handler.js의 moveStageHandler를 수정해보겠습니다.
const currentStageId = currentStages[currentStages.length - 1].id;
현재 currentStageId는 id만을 가지고 있기 때문에
id만이 아닌 timestemp도 받을 수 있도록 만들어줍니다.
import { getStage } from '../models/stage.model.js';
import { getGameAssets } from '../init/assets.js';
const moveStageHandler = (uuid, payload) => {
// 유저는 스테이지를 하나 씩 올라갈 수 있다.
// (1 -> 2, 2-> 3)
// 유저는 일정 점수가 되면 다음 스테이지로 이동한다.
//매개변수로 받은 payload에서는
//const {currentStage, targetStage} = payload로 제공됩니다.
// 유저의 현재 스테이지 정보를 가져온다.
let currentStages = getStage(uuid);
if (!currentStages.length) {
return { status: 'fail', message: 'No Stages Found For User' };
}
// 오름차순 -> 유저의 현재 스테이지 가장 큰 스테이지 ID를 확인
currentStages.sort((a, b) => a.id - b.id);
// 현재 스테이지 id가 올라 갈수록 높은 단계 입니다.
const currentStage = currentStages[currentStages.length - 1];
// 클라이언트 VS 서버 비교
if (currentStage !== payload.currentStage)
return { status: 'fail', message: 'Current Stage Mismatch' };
//
//
// 점수 검증 로직
//
// targetStage 대한 검증 <- 게임 에셋에 존재하는가?
const { stages } = getGameAssets();
if (!stages.data.some((stage) => stage.id === payload.targetStage))
return { status: 'fail', message: 'Target Stage Not Found' };
// Todo
// : stage.json 파일 내용물로 내용물 검증하기
// 점수를 비교해서 유저가 스테이지를 이동할 점수를 얻었는지 확인하는 코드 넣기
//
setStage(userId, payload.targetStage);
// 다음 스테이지를 제공하는 함수
return { status: 'success' };
};
// userId 와 payload를 인자로 받는 moveStageHandler 함수를 선언합니다.
// 우선은 아무런 처리도 하지않습니다. 맵핑 이후에 처리하는 코드를 작성할 에정입니다.
export { moveStageHandler };
그럼 주석문으로 점수 검증 로직을 작성해보겠습니다.
const serverTime = Date.now(); // 현재 타임스탬프
const elapsedTime = (serverTime - currentStage.timestamp) / 1000
// 경과 시간 = 서버 시간 - 현재 유저가 있는 스테이지의 timestamp 입니다.
// timestamp는 milli seconds로 1000 -> 1초가 된다
// 따라서 1000을 나눠 1초 당 1점을 얻도록 만들어준다.
(주석문에 넣기)
이제 1스테이지에서 2스테이지로 이동한다는 가정하에 검증 과정을 작성해보겠습니다.
const serverTime = Date.now(); // 현재 타임스탬프
const elapsedTime = (serverTime - currentStage.timestamp) / 1000;
// 경과 시간 = 서버 시간 - 현재 유저가 있는 스테이지의 timestamp 입니다.
// timestamp는 milli seconds로 1000 -> 1초가 된다
// 따라서 1000을 나눠 1초 당 1점을 얻도록 만들어준다.
// 1스테이지 -> 2스테이지로 넘어가는 과정
// 1스테이지 -> 2스테이지로 넘어가는 과정
if (elapsedTime < 100 || elapsedTime > 105) {
return { status: 'fail', message : "Invalid elapsed time" };
}
시간을 계산한 부분 아래에
100미만인 경우 => 스테이지 이동이 불가능한 점수라 "fail"
105보다 큰 경우 => 클라이언트가 오는 데이터가 지연시간이 너무 길어져 오류가 생길 경우 Error 처리
를 해줍니다.
그 후,
(중요)(사실 안 중요한 내용들이 없음)
setStage(userId, payload.targetStage, serverTime);
setStage 함수에 ServerTime로 지금 순간에서 다음 순간으로 넘어간 시간을 제공해
다음 스테이지에서는 시작 시간이 넘어간 순간이 되게 만들어줍니다.
이제 마지막으로 게임이 끝났을 경우 몇 점인지 체크해보겠습니다.
game.handler.js에 gameEndHandler로 이동해줍니다.
import { getStage } from "../models/stage.model";
getStage를 import 받아주고,
// 과제 : item.json 파일에
// stage.json 파일에 "scorePerSecond" : 1 ~ 10등 방식으로 스테이지 당 점수 주기
const gameEndHandler = (uuid, payload) => {
// 클라이언트는 서버로 게임 종료 시점과 타임스탬프와 총 점수를 줄 것 입니다.
const { timestemp: gameEndTime, score } = payload;
// gameEndTime을 호출하면 key에 따른 value가 나오는데
// 이 value를 timestemp라는 새로운 key에 제공해줍니다.
const stages = getStage(uuid);
if (!stages.length) return { status: 'fail', message: 'No Stages Found For User' };
// 각 스테이지의 지속 시간을 계산하여 총 점수 계산
let totalScore = 0;
stages.forEach((stages, index) => {
let stageEndTime;
if (index === stages.length - 1) {
// 마지막 스테이지의 경우 종료 시간이 종료 시간
stageEndTime = gameEndTime;
} else {
// 다음 스테이지의 시작 시간을 현재 스테이지의 종료 시간으로 사용
stageEndTime = stages[index + 1].timestemp;
}
const stageDuration = (stageEndTime - stages.timestemp) / 1000;
// 현재는 1초당 1점씩만 제공되는 중
totalScore += stageDuration;
});
// 점수와 타임 스탬프 검증 (예 : 클라이언트가 보낸 총점과 계산된 총점 비교)
// 오차범위 5
if (Math.abs(score - totalScore) > 5) {
return { status: 'fail', message: 'Score Verification failed' };
}
// 모든 검증이 통과된 후, 클라이언트에서 제공한 점수 저장하는 로직
// saveGameResult(userId, clientScore,gameEndTime)
// 검증이 통과되면 게임 종료 처리
// DB 저장한다고 가정을 한다면,
// 저장
// setResult(userId, score, timestamp)
return { status: 'success', message: 'Game Ended', score };
};
// 현재 어떠한 로직도 없기 때문에 무조건 성공으로 처리한다.
export { gameStartHandler, gameEndHandler };
// gameStart handler와 gameEnd handler를
// handlerMapping에 추가해줍니다.
위와 같이 작성해줍니다.