오늘은 어떤 패킷명세를 기준으로 서버로직을 구축했는지에 대해 작성해볼려고 합니다.
S2C_ConnectResponse
{
unit32 userId = 1;
string token =2;
}
현재는 임시로 저희가 userId를 주면서 시작하였습니다. 추후 로그인 기능이 생기면 클라측에서 주는 방향으로 바뀔 것 같습니다.
export const onConnection = (socket) => {
...
...
socket.buffer = Buffer.alloc(0);
// 현재는 테스트용도 연결된 클라이언트에게 토큰 넘겨주고 나중엔 로그인 시에 jwt토큰을 생성하여 클라에게 넘겨주고 서버 및 세션에 참가할떄 인증검증으로 사용할 예정
const testToken = config.test.test_token;
const data = serializer(
PACKET_TYPE.ConnectResponse,
{ userId: getUUID(), token: testToken },
0,
); // sequence는 임시로 0
socket.write(data);
...
...
};
그리고 일시적으로 jwt토큰을 이용하기 보단 현재는 임시로 test토큰을 넘겨주면서 확인용으로 로직을 작성해 보았습니다.
C2S_ConnectGameRequest - 게임 시작 요청
{
uint32 userId = 1;
string token = 2;
}
S2C_ConnectGameResponse - 게임 시작에 대한 응답
{
uint32 gameId = 1;
uint32 hostId = 2;
repeated uint32 existUserIds = 3;
repeated uint32 ghostTypeIds = 4;
GlobalFailCode globalFailCode = 5;
UserState userState = 6;
GameSessionState gameSessionState = 7;
string message = 8;
}
해당 응답패킷이 왔을때를 대비한 connectGameRequestHandler 핸들러 함수입니다.
export const connectGameRequestHandler = ({ socket, payload }) => {
try {
const { userId, token } = payload;
if (config.test.test_token !== token) {
invalidTokenResponse(socket);
throw new CustomError(ErrorCodesMaps.AUTHENTICATION_ERROR);
}
// 유저 생성 및 인메모리 세션 저장
const user = addUser(userId, socket);
socket.userId = user.id;
// 게임 세션 참가 로직 (임시 로직)
// 현재 init/index.js에서 게임 세션 하나를 임시로 생성해 두었습니다.
const gameSession = getGameSession();
// 본인 제외 이미 게임에 존재하는 유저들
const existUserIds = gameSession.users.map((user) => user.id);
gameSession.addUser(user);
sendConnectGameResponse(socket, gameSession, existUserIds);
} catch (e) {
handleError(e);
}
};
invalidTokenResponse, sendConnectGameResponse 패킷의 내용을 담을 함수들입니다. 함수를 따로 관리하여 토큰이 일치하지 않을 떄는 invalidTokenResponse, 아무 문제가 없을떄는 sendConnectGameResponse 이 함수를 실행하여 패킷을 보내주게 하였습니다.
/**
* 토큰이 유효하지 않을때 실패 응답 보내주는 함수입니다.
* @param {*} socket
*/
export const invalidTokenResponse = (socket) => {
const data = {
gameId: null,
hostId: null,
existUserIds: null,
ghostTypeIds: null, // 임시 고스트 타입
gloalFailCode: GLOBAL_FAIL_CODE.INVALID_REQUEST,
userState: USER_STATE.STAY,
gameSessionState: GAME_SESSION_STATE.PREPARE,
message: '해당 토큰이 일치하지 않아 게임을 입장할 수 없습니다.',
};
const responseData = serializer(PACKET_TYPE.ConnectGameResponse, data, 0); // sequence도 임시로
socket.write(responseData);
};
/**
* 호스트에게 게임 초기 정보를 응답으로 보내주는 함수입니다.
* @param {*} socket
* @param {*} gameSession
*/
export const sendConnectGameResponse = (socket, gameSession, existUserIds) => {
const data = {
gameId: gameSession.id,
hostId: gameSession.hostId,
existUserIds: existUserIds,
ghostTypeIds: [1, 2, 3, 4, 5], // 임시 고스트 타입 5마리 소환하라고 보냅니다.
gloalFailCode: GLOBAL_FAIL_CODE.NONE,
userState: USER_STATE.INGAME,
gameSessionState: gameSession.state,
message: '게임 세션 입장에 성공하였습니다.',
};
const responseData = serializer(PACKET_TYPE.ConnectGameResponse, data, 0); // sequence도 임시로
socket.write(responseData);
};
S2C_ConnectNewPlayerNotification
{
uint32 userId = 1;
}
connectNewPlayerNotification 함수 입니다.
export const connectNewPlayerNotification = async (gameSession, newUser) => {
const userId = newUser.id;
const responseData = serializer(
PACKET_TYPE.ConnectNewPlayerNotification,
{ userId },
0,
);
gameSession.users.forEach((user) => {
user.socket.write(responseData);
});
};
해당 클라이언트(Host)는 고스트 타입에 맞게 고스트를 생성했다는 패킷을 줍니다.
C2S_SpawnInitialGhostRequest
{
repeated Ghost ghosts = 1;
}
spawnInitialGhostRequestHandler를 통해 고스트의 생성된 정보를 서버에서 세션에 저장하고 관리합니다.
export const spawnInitialGhostRequestHandler = ({ socket, payload }) => {
try {
const { ghosts } = payload;
const gameSession = getGameSession();
if (!gameSession) {
throw new CustomError(ErrorCodesMaps.GAME_NOT_FOUND);
}
ghosts.forEach((ghostInfo) => {
const ghost = new Ghost(
ghostInfo.ghostId,
ghostInfo.ghostTypeId,
ghostInfo.moveInfo.position,
ghostInfo.moveInfo.rotation,
ghostInfo.moveInfo.state,
);
gameSession.addGhost(ghost);
});
gameSession.startGame();
} catch (e) {
handleError(e);
}
};
지금 보니깐 순서를 한번 정리하면 좋을 것 같네요..
S2C_StartGameNotification
{
uint32 mapId = 1;
GameSessionState gameSessionState = 2; // PREPARE, INPRGRESS, END
}
/**
* 게임 시작을 알리는 함수
*/
export const startGameNotification = (gameSession) => {
const payload = {
mapId: 1,
gameSessionState: gameSession.state,
};
const packet = serializer(PACKET_TYPE.StartGameNotification, payload, 0);
gameSession.users.forEach((user) => {
user.socket.write(packet);
});
};
C2S_PlayerMoveRequest
{
PlayerMoveInfo playerMoveInfo = 1;
}
S2C_PlayerMoveNotification
{
repeated PlayerMoveInfo playerMoveInfos = 1;
}
해당 클라이언트에서 움직임 값을 받으면 서버에선 그값에 맞게 다른 클라이언트에게 움직임 값을 전달해 줍니다.
// 플레이어 이동 요청에 따른 핸들러 함수
export const movePlayerRequestHandler = ({ socket, payload }) => {
try {
const { playerMoveInfo } = payload;
const { userId, position, rotation } = playerMoveInfo;
// 현재 프로토빌드로 게임 첫번째 세션을 반환하도록 함.
const gameSession = getGameSession();
// 게임 세션에서 유저 찾기
const user = gameSession.getUser(userId);
if (!user) {
console.error('user가 존재하지 않습니다.');
return;
}
//이전 값 저장
user.character.lastPosition.updateClassPosition(user.character.position);
user.character.lastRotation.updateClassRotation(user.character.rotation);
//수정 해야함
user.character.position.updatePosition(position.x, position.y, position.z);
user.character.rotation.updateRotation(rotation.x, rotation.y, rotation.z);
//시간 저장
user.character.lastUpdateTime = Date.now();
} catch (e) {
console.error(e.message);
}
};
C2S_GhostMoveRequest
{
repeated GhostMoveInfo ghostMoveInfos = 1;
}
S2C_GhostMoveNotification
{
repeated GhostMoveInfo ghostMoveinfos = 1;
}
호스트가 요청을 보내면 호스트 기준의 고스트 위치값을 다른 클라이언트에게 전달해 줍니다. (호스트 기준)
// 호스트 유저만 요청을 보냅니다.
export const moveGhostRequestHandler = ({ socket, payload }) => {
try {
const { ghostMoveInfos } = payload;
const gameSession = getGameSession();
if (!gameSession) {
throw new CustomError(ErrorCodesMaps.GAME_NOT_FOUND);
}
// 해당 게임 세션에 고스트들의 정보 저장
ghostMoveInfos.forEach((ghostMoveInfo) => {
const { ghostId, position, rotation } = ghostMoveInfo;
const ghost = gameSession.getGhost(ghostId);
if (!ghost) {
console.error('해당 귀신 정보가 존재하지 않습니다.');
}
ghost.position.updatePosition(position.x, position.y, position.z);
ghost.rotation.updateRotation(rotation.x, rotation.y, rotation.z);
});
} catch (e) {
handleError(e);
}
};
게임 세션이 시작될떄 인터벌로 noti를 날려서 다른 클라이언트들의 위치를 알려주게 해주었습니다.
class Game {
constructor(id) {
this.id = id;
this.hostId = null;
this.users = [];
this.ghosts = [];
this.state = GAME_SESSION_STATE.PREPARE;
}
startGame() {
// 귀신 5마리 정도 세팅
// 게임 상태 변경
this.state = GAME_SESSION_STATE.INPROGRESS;
IntervalManager.getInstance().addPlayersInterval(
this.id,
() => usersLocationNotification(this),
1000 / 60,
);
IntervalManager.getInstance().addGhostsInterval(
this.id,
() => ghostsLocationNotification(this),
100,
);
startGameNotification(this);
}
...
...
...
오늘의 회고
이제 슬슬 Prototype Build Ver.2 또는 Mvp를 대비해 패킷명세추가 작업을 생각하면서 이어 나가야 될 것 같습니다.
하하.. 그리고 중간 테스트를 진행하면서 잠깐의 회의를 통해 패킷명세가 자주 바뀌면서 로직도 바뀌고 정신이 없네요.. 확실히 게임하나를 만드는 일은 오래걸리는 작업일 수 밖에 없겠구나라고 생각이 들었습니다. 으악
오늘도 화이팅