Node 강의/심화

1-8 이벤트 핸들러 ( 점프 게임 )

kagan-draca 2024. 9. 27. 22:51

이벤트 핸들러 : 

 

-  데이터 교환, 메시지 교환을 하는 모든 과정을 '이벤트'라고도 부르는데
이 '이벤트'를 어떻게 관리할 것인지, 어떻게 핸들링 할 것인지

 

- 기획한 컨텐츠를 처리하기 위한 이벤트 핸들러를 만들 수 있다.

 

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에 추가해줍니다.

위와 같이 작성해줍니다.