지금까지 각 유저들의 라운트 트립 레이턴시를 저장하는 것까지 진행했다.
이번에는 유저의 위치 정보를 업데이트 해야 한다.
그러기 위해선 먼저 두 가지 사항을 고려해야 하는데, 첫번째로는 유저가 본인의 위치를 서버에 업데이트 해야하고, 두번째는 서버가 가지고 있는 유저들의 정보를 다른 유저에게 보내줄 때, 즉, 상태 정보를 동기화 시켜야 한다.
따라서 먼저 유저에게 알림을 보내야 한다.
게임 시작 알림
먼저, 유저에게 어떠한 형태로 알림을 보내야 할지 정해야 한다. 하여, .proto 파일을 만들고, 이를 등록한 뒤에 PACKET_TYPE에 현재 패킷들을 추가한 후 패킷을 만드는 함수를 추가해야 한다.
- protobuf/notification/game.notification.proto
syntax = "proto3";
package gameNotification;
// 위치 정보 메시지 구조
message LocationUpdate {
// repeated: 여러번 반복해서 사용하겠다 = 배열로 보내겠다
repeated UserLocation users = 1;
message UserLocation {
string id = 1;
float x = 2;
float y = 3;
}
}
// 게임 시작 알림
message Start {
string gameId = 1;
int64 timestamp = 2;
}
- packetNames.js
export const packetNames = {
// 중략
gameNotification: {
LocationUpdate: 'gameNotification.LocationUpdate',
Start: 'gameNotification.Start',
},
};
- constants/header.js
export const TOTAL_LENGTH = 4;
export const PACKET_TYPE_LENGTH = 1;
export const PACKET_TYPE = {
// 중략
GAME_START: 2,
LOCATION: 3,
};
- utils/notification/game.notification.js
// 해당 함수는 범용적으로 다른 notificatio들에게도 쓰일 예정이다.
const makeNotification = (message, type) => {
// 중략
};
// 중략
// 위치 패킷
export const createLocationPacket = (users) => {
const protoMessage = getProtoMessage();
const Location = protoMessage.gameNotification.LocationUpdate;
const payload = { users };
const message = Location.create(payload);
const locationPacket = Location.encode(message).finish();
return makeNotification(locationPacket, PACKET_TYPE.LOCATION);
};
// 게임 시작 패킷
export const gameStartNotification = (gameId, timestamp) => {
const protoMessage = getProtoMessages();
const Start = protoMessage.gameNotification.Start;
const payload = { gameId, timestamp };
const message = Start.create(payload);
const startPacket = Start.encode(message).finish();
return makeNotification(startPacket, PACKET_TYPE.GAME_START);
};
여기까지 작성을 했다면, 해당 두 함수는 게임 클래스가 가지고 있는 게 맞다.
getMaxLatency()는 현재 참여한 유저들 중 가장 높은 레이턴시를 조회하는 메서드이다.
가장 높은 레이턴시를 이용해 계산을 하게 되면, 모든 유저가 동일한 지연 시간을 가지게 되어 동기화 문제가 최소화 된다.
class Game {
// 중략
getMacLatency() {
let maxLatency = 0;
this.users.forEach((user) => {
maxLatency = Math.max(maxLatency, user.latency);
});
return maxLatency;
}
startGame() {
this.state = 'inProgress';
const startPacket = gameStartNotification(this.id, Date.now());
console.log(this.getMacLatency());
// 게임에 참가한 유저들에게 게임 시작 알림 보내기
this.users.forEach((user) => {
user.socket.write(startPacket);
});
}
}
유저 위치로 동기화
유저 동기화 방식에는 두가지 방법이 있다.
- 주기적 자동 동기화
서버가 일정 시간 간격으로 모든 유저에게 위치 정보를 전송하는 방식.
그러나 클라이언트의 사양과 관계없이 일정하게 데이터를 보내기 때문에, 사양이 낮은 클라이언트의 경우 데이터를 처리하기에 부담이 될 수 있다. - 요청 기반 동기화
클라이언트가 자신의 위치 정보를 서버로 보낼 때, 서버가 전체 유저의 위치 정보를 업데이트 해 보내주는 방식.
이를 통해 클라이언트는 자신의 사양에 맞는 주기로 서버와 데이터를 주고 받을 수 있다.
이번 프로젝트에서는 두번재 방식을 사용할 것이다.
이는 조금 더 자연스럽고 부드러운 움직임을 제공하기 위해서인데, 클라이언트의 프레임 속도와 일치하는 주기로 위치 정보가 업데이트 되어 화면에 표시되는 움직임과 사용자의 입력 간의 차이가 최소화 되기 때문이다.
하여, 이를 구현하기 위해서는 먼저 유저가 위치를 보낼 수 있도록 패킷의 구조를 정의해야 한다.
- game.proto
// 중략
// 위치 정보 업데이트
message LocationUpdatePayload {
string gameId = 1;
float x = 2;
float y = 3;
}
- packetNames.js
export const packetNames = {
// 중략
game: {
CreateGamePayload: 'game.CreateGamePayload',
JoinGamePayload: 'game.JoinGamePayload',
LocationUpdatePayload: 'game.LocationUpdatePayload',
},
// 중략
};
- handlerIds.js
export const HANDELR_IDS = {
// 중략
UPDATE_LOACTION: 6,
};
여기까지 완성했다면, 이젠 요청을 보낸 유저가, 같이 게임을 진행하고 있는 다른 유저들의 위치를 계산하고, 이를 보내주어야 한다.
이때 다른 유저들의 위치는 추측항법으로 계산하며, 이를 유저 & 게임 클래스의 메서드로 추가한다.
- user.class.js
일단 유저의 속도는 1로 고정하고, 한 방향(x축)으로만 움직인다고 가정한다.
class User {
// 중략
// 추측항법으로 위치를 추정
calculatePosition(latency) {
const timeDiff = latency / 1000; // 레이턴시를 초 단위로 계산
const speed = 1; // 속도 고정
const distance = speed * timeDiff;
return { x: this.x + distance, y: this.y };
}
}
- game.class.js
추측항법으로 구한 유저들의 위치 정보를 보내준다.
class Game {
// 중략
getAllLocation() {
const maxLatency = this.getMaxLatency();
const locationData = this.users.map((user) => {
const { x, y } = user.calculatePosition(maxLatency);
return { id: user.id, x, y };
});
return createLocationPacket(locationData);
}
}
여기까지 완성했다면 이제 패킷을 보내주는 핸들러를 만들어 줄 차례다.
- handlers/game/updateLocation.handler.js
import { getGameSession } from '../../session/game.session.js';
import CustomError from '../../utils/error/customError.js';
import { ErrorCodes } from '../../utils/error/errorCodes.js';
import { handlerError } from '../../utils/error/errorHandler.js';
const updateLocationaHandler = ({ socket, userId, payload }) => {
try {
const { gameId, x, y } = payload;
const gameSession = getGameSession(gameId);
if (!gameSession) {
throw new CustomError(ErrorCodes.GAME_NOT_FOUND, '게임 세션을 찾을 수 없다');
}
const user = gameSession.getUser(userId);
if (!user) {
throw new CustomError(ErrorCodes.USER_NOT_FOUND, '유저를 찾을 수 없다.');
}
user.updatePosition(x, y);
const packet = gameSession.getAllLocation();
socket.write(packet);
} catch (error) {
handlerError;
}
};
export default updateLocationaHandler;
이후 핸들러와 payload를 등록한다.
- handler/index.js
const handlers = {
// 중략
[HANDELR_IDS.UPDATE_LOACTION]: {
handler: updateLocationaHandler,
protoType: 'game.LocationUpdatePayload',
},
};
테스트
먼저, 달라진 클라이언트 코드는 다음과 같다.
- client.js
import net from 'net';
import Long from 'long';
import { getProtoMessages, loadProtos } from './src/init/loadProtos.js';
const TOTAL_LENGTH = 4; // 전체 길이를 나타내는 4바이트
const PACKET_TYPE_LENGTH = 1; // 패킷타입을 나타내는 1바이트
let userId;
let gameId;
let sequence = 0;
const deviceId = 'xxxx1x';
let x = 0.0;
let y = 0.0;
const createPacket = (handlerId, payload, clientVersion = '1.0.0', type, name) => {
const protoMessages = getProtoMessages();
const PayloadType = protoMessages[type][name];
if (!PayloadType) {
throw new Error('PayloadType을 찾을 수 없습니다.');
}
const payloadMessage = PayloadType.create(payload);
const payloadBuffer = PayloadType.encode(payloadMessage).finish();
return {
handlerId,
userId,
clientVersion,
sequence,
payload: payloadBuffer,
};
};
const sendPacket = (socket, packet) => {
const protoMessages = getProtoMessages();
const Packet = protoMessages.common.Packet;
if (!Packet) {
console.error('Packet 메시지를 찾을 수 없습니다.');
return;
}
const buffer = Packet.encode(packet).finish();
// 패킷 길이 정보를 포함한 버퍼 생성
const packetLength = Buffer.alloc(TOTAL_LENGTH);
packetLength.writeUInt32BE(buffer.length + TOTAL_LENGTH + PACKET_TYPE_LENGTH, 0); // 패킷 길이에 타입 바이트 포함
// 패킷 타입 정보를 포함한 버퍼 생성
const packetType = Buffer.alloc(PACKET_TYPE_LENGTH);
packetType.writeUInt8(1, 0); // NORMAL TYPE
// 길이 정보와 메시지를 함께 전송
const packetWithLength = Buffer.concat([packetLength, packetType, buffer]);
socket.write(packetWithLength);
};
const sendPong = (socket, timestamp) => {
const protoMessages = getProtoMessages();
const Ping = protoMessages.common.Ping;
const pongMessage = Ping.create({ timestamp });
const pongBuffer = Ping.encode(pongMessage).finish();
// 패킷 길이 정보를 포함한 버퍼 생성
const packetLength = Buffer.alloc(TOTAL_LENGTH);
packetLength.writeUInt32BE(pongBuffer.length + TOTAL_LENGTH + PACKET_TYPE_LENGTH, 0);
// 패킷 타입 정보를 포함한 버퍼 생성
const packetType = Buffer.alloc(1);
packetType.writeUInt8(0, 0);
// 길이 정보와 메시지를 함께 전송
const packetWithLength = Buffer.concat([packetLength, packetType, pongBuffer]);
socket.write(packetWithLength);
};
const updateLocation = (socket) => {
x += 0.1;
const packet = createPacket(6, { gameId, x, y }, '1.0.0', 'game', 'LocationUpdatePayload');
sendPacket(socket, packet);
};
// 서버에 연결할 호스트와 포트
const HOST = 'localhost';
const PORT = 5555;
const client = new net.Socket();
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
client.connect(PORT, HOST, async () => {
console.log('Connected to server');
await loadProtos();
const successPacket = createPacket(0, { deviceId }, '1.0.0', 'initial', 'InitialPacket');
await sendPacket(client, successPacket);
await delay(500);
const createGamePacket = createPacket(
4,
{ timestamp: Date.now() },
'1.0.0',
'game',
'CreateGamePayload',
);
await sendPacket(client, createGamePacket);
});
client.on('data', (data) => {
// 1. 길이 정보 수신 (4바이트)
const length = data.readUInt32BE(0);
const totalHeaderLength = TOTAL_LENGTH + PACKET_TYPE_LENGTH;
// 2. 패킷 타입 정보 수신 (1바이트)
const packetType = data.readUInt8(4);
const packet = data.slice(totalHeaderLength, totalHeaderLength + length); // 패킷 데이터
const protoMessages = getProtoMessages();
if (packetType === 1) {
const Response = protoMessages.response.Response;
try {
const response = Response.decode(packet);
const responseData = JSON.parse(Buffer.from(response.data).toString());
if (response.handlerId === 0) {
userId = responseData.userId;
}
console.log('응답 데이터:', responseData);
sequence = response.sequence;
} catch (e) {
console.log(e);
}
} else if (packetType === 0) {
try {
const Ping = protoMessages.common.Ping;
const pingMessage = Ping.decode(packet);
const timestampLong = new Long(
pingMessage.timestamp.low,
pingMessage.timestamp.high,
pingMessage.timestamp.unsigned,
);
// console.log('Received ping with timestamp:', timestampLong.toNumber());
sendPong(client, timestampLong.toNumber());
} catch (pongError) {
console.error('Ping 처리 중 오류 발생:', pongError);
}
} else if (packetType === 2) {
try {
const Start = protoMessages.gameNotification.Start;
const startMessage = Start.decode(packet);
console.log('응답 데이터:', startMessage);
if (startMessage.gameId) {
gameId = startMessage.gameId;
}
// 위치 업데이트 패킷 전송
setInterval(() => {
updateLocation(client);
}, 1000);
} catch (error) {
console.error(error);
}
} else if (packetType === 3) {
try {
const locationUpdate = protoMessages.gameNotification.LocationUpdate;
const locationUpdateMessage = locationUpdate.decode(packet);
console.log('응답 데이터:', locationUpdateMessage);
} catch (error) {
console.error(error);
}
}
});
client.on('close', () => {
console.log('Connection closed');
});
client.on('error', (err) => {
console.error('Client error:', err);
});
- client2.js
import net from 'net';
import Long from 'long';
import { getProtoMessages, loadProtos } from './src/init/loadProtos.js';
const TOTAL_LENGTH = 4; // 전체 길이를 나타내는 4바이트
const PACKET_TYPE_LENGTH = 1; // 패킷타입을 나타내는 1바이트
let userId;
let gameId = '3340eaf2-43f2-4200-9fc3-794807573715';
let sequence = 0;
const deviceId = 'xxxxx';
let x = 0.0;
let y = 0.0;
const createPacket = (handlerId, payload, clientVersion = '1.0.0', type, name) => {
const protoMessages = getProtoMessages();
const PayloadType = protoMessages[type][name];
if (!PayloadType) {
throw new Error('PayloadType을 찾을 수 없습니다.');
}
const payloadMessage = PayloadType.create(payload);
const payloadBuffer = PayloadType.encode(payloadMessage).finish();
return {
handlerId,
userId,
clientVersion,
sequence,
payload: payloadBuffer,
};
};
const sendPacket = (socket, packet) => {
const protoMessages = getProtoMessages();
const Packet = protoMessages.common.Packet;
if (!Packet) {
console.error('Packet 메시지를 찾을 수 없습니다.');
return;
}
const buffer = Packet.encode(packet).finish();
// 패킷 길이 정보를 포함한 버퍼 생성
const packetLength = Buffer.alloc(TOTAL_LENGTH);
packetLength.writeUInt32BE(buffer.length + TOTAL_LENGTH + PACKET_TYPE_LENGTH, 0);
// 패킷 타입 정보를 포함한 버퍼 생성
const packetType = Buffer.alloc(PACKET_TYPE_LENGTH);
packetType.writeUInt8(1, 0); // NORMAL TYPE
// 길이 정보와 메시지를 함께 전송
const packetWithLength = Buffer.concat([packetLength, packetType, buffer]);
socket.write(packetWithLength);
};
const sendPong = (socket, timestamp) => {
const protoMessages = getProtoMessages();
const Ping = protoMessages.common.Ping;
const pongMessage = Ping.create({ timestamp });
const pongBuffer = Ping.encode(pongMessage).finish();
// 패킷 길이 정보를 포함한 버퍼 생성
const packetLength = Buffer.alloc(TOTAL_LENGTH);
packetLength.writeUInt32BE(pongBuffer.length + TOTAL_LENGTH + PACKET_TYPE_LENGTH, 0);
// 패킷 타입 정보를 포함한 버퍼 생성
const packetType = Buffer.alloc(PACKET_TYPE_LENGTH);
packetType.writeUInt8(0, 0);
// 길이 정보와 메시지를 함께 전송
const packetWithLength = Buffer.concat([packetLength, packetType, pongBuffer]);
socket.write(packetWithLength);
};
const updateLocation = (socket) => {
x += 0.3;
const packet = createPacket(6, { gameId, x, y }, '1.0.0', 'game', 'LocationUpdatePayload');
sendPacket(socket, packet);
};
// 서버에 연결할 호스트와 포트
const HOST = 'localhost';
const PORT = 5555;
const client = new net.Socket();
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
client.connect(PORT, HOST, async () => {
console.log('Connected to server');
await loadProtos();
const successPacket = createPacket(0, { deviceId }, '1.0.0', 'initial', 'InitialPacket');
await sendPacket(client, successPacket);
await delay(500);
const createGamePacket = createPacket(
5,
{ timestamp: Date.now(), gameId },
'1.0.0',
'game',
'JoinGamePayload',
);
await sendPacket(client, createGamePacket);
});
client.on('data', async (data) => {
// 1. 길이 정보 수신 (4바이트)
const length = data.readUInt32BE(0);
const totalHeaderLength = TOTAL_LENGTH + PACKET_TYPE_LENGTH;
// 2. 패킷 타입 정보 수신 (1바이트)
const packetType = data.readUInt8(4);
const packet = data.slice(totalHeaderLength, totalHeaderLength + length); // 패킷 데이터
const protoMessages = getProtoMessages();
if (packetType === 1) {
const Response = protoMessages.response.Response;
try {
const response = Response.decode(packet);
const responseData = JSON.parse(Buffer.from(response.data).toString());
if (response.handlerId === 0) {
userId = responseData.userId;
}
console.log('응답 데이터:', responseData);
sequence = response.sequence;
} catch (e) {
console.log(e);
}
} else if (packetType === 0) {
try {
const Ping = protoMessages.common.Ping;
const pingMessage = Ping.decode(packet);
const timestampLong = new Long(
pingMessage.timestamp.low,
pingMessage.timestamp.high,
pingMessage.timestamp.unsigned,
);
// console.log('Received ping with timestamp:', timestampLong.toNumber());
await delay(1500);
await sendPong(client, timestampLong.toNumber());
} catch (pongError) {
console.error('Ping 처리 중 오류 발생:', pongError);
}
} else if (packetType === 2) {
try {
const Start = protoMessages.gameNotification.Start;
const startMessage = Start.decode(packet);
console.log('응답 데이터:', startMessage);
if (startMessage.gameId) {
gameId = startMessage.gameId;
}
// 위치 업데이트 패킷 전송
setInterval(() => {
updateLocation(client);
}, 1500);
} catch (error) {
console.error(error);
}
} else if (packetType === 3) {
try {
const locationUpdate = protoMessages.gameNotification.LocationUpdate;
const locationUpdateMessage = locationUpdate.decode(packet);
console.log('응답 데이터:', locationUpdateMessage);
} catch (error) {
console.error(error);
}
}
});
client.on('close', () => {
console.log('Connection closed');
});
client.on('error', (err) => {
console.error('Client error:', err);
});
이후 이전 게시물(링크)과 동일한 순서로 테스트를 진행하면, 다음과 같은 결과를 얻을 수 있다.
(원활한 진행을 위해 게임 시작 인원을 4 → 2 로 줄였다.)
'프로젝트' 카테고리의 다른 글
24/11/13 ~ 15 - [팀] 최종: Bang 게임 기획 (0) | 2024.11.17 |
---|---|
24/11/05 - [개인] 멀티 플레이 과제: 멀티 플레이어 이동 테스트 (0) | 2024.11.05 |
24/11/03 - [실습] Node.js 플러스(2 - 11): 레이턴시(Latency)(feat. 라운드 트립 레이턴시), 인터벌 매니저(Interval Manager) (0) | 2024.11.03 |
24/11/02 - [실습] Node.js 플러스(2 - 11): 게임 로직 추가 (0) | 2024.11.02 |
24/11/01 - [실습] Node.js 플러스(2 - 10): 유저 & 게임 클래스 (0) | 2024.11.01 |