크롬 주소창에 ``chrome://dino/``를 입력하여 플레이할 수 있는 플랫포머 공룡 게임이 있다.
군대에 있을 때, 종종 하곤 했는데 이렇게 다시 만날 줄이야...
``Socket.io``를 이용해 서버에서 플레이 데이터를 관리하게 하는 게 목표인 프로젝트이다.
엎드리기는 없지만, 아이템을 획득해 추가 점수를 얻을 수 있다.
이런 데서 오는 난관이 있기 마련.
내가 힘들었던 부분은 다음과 같다.
1. 아이템 생성 및 검증
기존엔 아이템이 무분별하게 생성됐다.
모든 스테이지에서 전체 아이템 중에서 랜덤 하게 선택돼 생성됐다.
여기선 스테이지 별로 생성되는 아이템을 차별화하고, 그 생성 간격도 관리해야 했다.
이 생성 간격은 클라이언트에서만 관리하는 것이 아니라 서버에서 그 검증 또한 수행해야 한다.
일단 클라이언트에서 정의된 간격에 맞게 아이템을 생성하게 해야 한다.
어뷰징이 있을 경우, 클라이언트에서도 이를 감지해 서버에 정보를 보낼 수 있어야 한다.
먼저 아이템은 다음과 같이 정의되어 있다.
{
"name": "item",
"version": "1.0.0",
"data": [
{
"id": 1,
"score": 10,
"width": 50,
"height": 50,
"interval": 5000,
"image": "images/items/pokeball_red.png"
},
{
"id": 2,
"score": 20,
"width": 50,
"height": 50,
"interval": 7000,
"image": "images/items/pokeball_yellow.png"
},
...
]
...
}
그리고 이렇게 생각했다.
"플레이어가 아이템을 획득한 간격을 관리하면 되지 않을까? 비정상적으로 아이템을 먹으면 어뷰징이니까."
생성 시에도 이 관리 데이터를 참조하게 하면 될 것 같았다.
그리고 그렇게 구현 후 테스트를 해 보니, 문제가 있었다.
아이템 획득 간격에 문제가 없는지 확인은 잘 되는데, 스폰 간격이 정상이 아니었다.
문제를 찾아보니, 그에 맞게 구현을 제대로 못한 것도 있었지만, 정말 중요한 걸 빼먹었다는 것을 깨달았다.
아이템이 마지막으로 언제 생성됐는지도 관리를 해야 했다.
따라서 다음과 같이 2개의 ``Map``을 두기로 했다.
// 플레이어가 아이템을 획득하는 간격과
// 스폰 간격을 모두 추적해 비정상적인 동작 방지
lastSpawnedTimes = new Map(); // 아이템별 마지막 생성 시간을 추적
lastCollectedTimes = new Map(); // 아이템별 마지막 수집 시간을 추적
그리고 다음과 같이 판단해 생성 가능한 아이템 목록을 얻는다.
const availableItems = stageUnlockedItems.filter((item) => {
const lastCollected = this.lastCollectedTimes.get(item.id) || 0; // 마지막 확득 시간 가져오기
const lastSpawned = this.lastSpawnedTimes.get(item.id) || 0; // 마지막 생성 시간 가져오기
const itemInfo = this.itemData.find((data) => data.id === item.id);
if (!itemInfo) return false; // 아이템 정보가 없으면 제외
const itemInterval = itemInfo.interval; // 인터벌 (밀리초)
// 인터벌 확인: 마지막 생성 시간 이후로 인터벌이 경과했는지 확인
return (
currentTime - lastSpawned >= itemInterval &&
currentTime - lastCollected >= itemInterval
);
});
// 아이템 생성 후...
this.lastSpawnedTimes.set(itemInfo.id, currentTime);
부정하게 생성된 아이템을 여러 개 획득할 수도 있기에 획득 간격을 검사하고,
부정하게 생성 주기를 망가뜨릴 수 있기 때문에 마지막 스폰 간격도 검사한다.
2개의 값을 모두 검사해 아이템 생성이 의도대로 수행될 수 있도록 했다.
그리고 생성이 완료되면 ``lastSpawnedTimes``에 데이터를 넣어 준다.
이제 아이템과 충돌 시 처리를 수정해야 한다.
여기선 최소한 생성 된 아이템을 획득했는지, 그에 따른 아이템 생성 간격이 정상인지 검증한다.
// 아이템 생성 후 최소 인터벌 검증
if (lastCollected !== 0) {
if (currentTime - lastSpawned < 0) {
console.warn(
`아이템 ${itemId}을(를) 생성 후 충분한 시간이 경과하지 않았습니다.`,
);
sendEvent(5, {
itemId,
timestamp: currentTime,
reason: 'Invalid item creation interval',
});
return null;
}
// 최근 획득 시간 검증
if (currentTime - lastCollected < itemInterval) {
console.warn(
`아이템 ${itemId}을(를) 너무 빨리 수집했습니다. 간격이 충족되지 않았습니다.`,
);
sendEvent(5, {
itemId,
timestamp: currentTime,
reason: 'Invalid item pickup interval',
});
return null;
}
}
// 검증 통과: 획득 처리
this.lastCollectedTimes.set(itemId, currentTime);
경우의 수 들에 대해 검증하면서 최종적으로 검증이 완료되면 맵에 정보를 넣는다.
그리고 서버에 정보를 보내고 획득한 아이템을 레벨에서 제거하면 충돌 처리도 완료.
이제 머리 아픈 에러에서 탈출했다.
꽤 많이 진행해봐도 문제가 생기지 않은 걸 보면 문제없어 보인다.
2. 서버에서의 스코어 검증
일단 클라이언트에선 다음과 같이 점수를 계산한다.
update(deltaTime) {
const currentStageInfo = this.stageTable.find((stage) => stage.id === this.currentStage);
const scorePerSecond = currentStageInfo ? currentStageInfo.scorePerSecond : 1;
// deltaTime을 초 단위로 변환하여 점수 증가
this.scoreIncrement += (deltaTime / 1000) * scorePerSecond;
// 정수 부분만 점수에 반영
const integerIncrement = Math.floor(this.scoreIncrement);
if (integerIncrement > 0) {
this.score += integerIncrement;
this.scoreIncrement -= integerIncrement;
}
if (this.score > this.localHighScore) {
this.localHighScore = this.score;
}
this.checkStageChange();
}
역시 점수는 간격마다 그 수치만큼 띡... 띡... 오르는 것이 아니라,
좌라락 올라가는 것이 좋지 않은가?
자 그럼 서버에서도 이를 똑같이 검증해 주면 된다.
그럼 타임스탬프 비교로 그냥 검증하면 될까?
시간으로 얻은 점수와 아이템으로 얻은 점수는 구분해서 계산해야 한다.
// 스테이지 지속 시간을 기반으로 총 점수를 계산하는 함수
const calculateTotalScore = (stages, gameEndTime, userItems) => {
let totalScore = 0;
const { stages: stageData, items: itemData } = getGameAssets();
const stageTable = stageData.data;
stages.forEach((stage, index) => {
let stageEndTime;
if (index === stages.length - 1) {
// 마지막 스테이지의 경우 종료 시간이 게임의 종료 시간
stageEndTime = gameEndTime;
} else {
// 다음 스테이지의 시작 시간을 현재 스테이지의 종료 시간으로 사용
stageEndTime = stages[index + 1].timestamp;
}
let stageDuration = (stageEndTime - stage.timestamp) / 1000;
// 현재 스테이지의 초당 점수를 가져옴
const stageInfo = stageTable.find((s) => s.id === stage.id);
const scorePerSecond = stageInfo ? stageInfo.scorePerSecond : 1;
totalScore += stageDuration * scorePerSecond;
});
// ...
}
``forEach``를 활용해 각 스테이지마다 경과 시간을 확인해 점수를 체크한다.
// 아이템 획득 점수 추가
userItems.forEach((userItem) => {
const item = itemData.data.find((item) => item.id === userItem.id);
if (item) {
totalScore += item.score;
}
});
// 정수 점수로 변환
totalScore = Math.round(totalScore);
return totalScore;
아이템도 가져와서 토탈에 합해준다.
이제 이걸 게임 종료 패킷을 받으면 점수 검증에 활용한다.
// 총 점수 계산
const totalScore = calculateTotalScore(stages, gameEndTime, userItems);
// 점수와 타임스탬프 검증
if (Math.abs(totalScore - score) > 5) {
return { status: 'fail', message: 'Score verification failed' };
}
// 현재 최고 점수를 가져와서 비교
const highScore = await getTopHighScore();
const currentHighScore = highScore ? highScore : 0;
if (score > currentHighScore) {
// 새로운 최고 점수인 경우
console.log('New high score detected!');
await addHighScore(uuid, score);
// 브로드캐스트를 handleEvent를 통해 요청
const broadcastData = { uuid, score };
const broadcastPayload = {
handlerId: 6, // broadcastNewHighScore 핸들러 ID
userId: uuid,
payload: broadcastData,
clientVersion: CLIENT_VERSION[0],
};
handleEvent(io, null, broadcastPayload);
}
// 검증이 통과되면 게임 종료 처리
return { status: 'success', message: 'Game ended successfully', score, handler: 3 };
그렇게 검증한 점수를 가져와 최종 검증을 거친다.
만약 기존 최고점보다 높다면 최고점을 갱신한다.
마지막으로 브로드캐스트로 모든 클라이언트에 갱신된 점수를 뿌리며 종료 처리가 완료된다.
3. Redis
"내가 정말 알고 쓰는 것인가?" 싶었던 부분.
누더기로 조립한 느낌이 든다.
Redis 클라이언트는 이렇게 초기화했다.
// src/init/redis.js
import Redis from 'ioredis';
import dotenv from 'dotenv';
dotenv.config();
const redisClient = new Redis({
host: process.env.REDIS_HOST,
port: process.env.REDIS_PORT,
password: process.env.REDIS_PASSWORD,
});
redisClient.on('error', (err) => {
console.error('Redis client error:', err);
});
redisClient.on('connect', () => {
console.log('Connected to Redis');
});
export default redisClient;
여기까진 뭐 문제가 없는데...
import redisClient from '../init/redis.js';
const USER_HASH_KEY = 'users';
// 사용자 추가
export const addUser = async (user) => {
await redisClient.hset(USER_HASH_KEY, user.uuid, JSON.stringify(user));
};
// 사용자 제거
export const removeUser = async (uuid) => {
const user = await redisClient.hget(USER_HASH_KEY, uuid);
if (user) {
await redisClient.hdel(USER_HASH_KEY, uuid);
return JSON.parse(user);
}
return null;
};
// 모든 사용자 조회
export const getUsers = async () => {
const userEntries = await redisClient.hgetall(USER_HASH_KEY);
return Object.values(userEntries).map((user) => JSON.parse(user));
};
// 사용자 조회
export const getUserById = async (uuid) => {
const user = await redisClient.hget(USER_HASH_KEY, uuid);
return user ? JSON.parse(user) : null;
};
import redisClient from '../init/redis.js';
const HIGH_SCORE_KEY = 'MY_HIGH_SCORE_KEY';
// 최고 점수 추가
// 상위 10개만 관리
// 트랜잭션으로 원자성 보장
export const addHighScore = async (uuid, score) => {
try {
const multi = redisClient.multi();
multi.zadd(HIGH_SCORE_KEY, score, uuid);
multi.zremrangebyrank(HIGH_SCORE_KEY, 0, -11);
await multi.exec();
} catch (error) {
console.error('Failed to add high score', error);
}
};
// 최고 점수 조회
export const getTopHighScore = async () => {
const [user, score] = await redisClient.zrevrange(HIGH_SCORE_KEY, 0, 0, 'WITHSCORES'); // 상위 1개만 조회
if (user && score) {
return parseInt(score);
}
return 0; // 데이터가 없을 경우
};
여긴 정말 기워넣었다.
레디스에서도 해시의 성능이 보장되고 실제로 많이 사용된다고 하니 일단 사용했다.
지금같이 데이터셋의 양이 매우 적은 상황에선 다른 방식을 써도 의미는 없겠으나...
해시가 무조건적으로 모든 상황에 좋을 수는 없으니, 정확한 판단을 하고 사용해야 할 것이다.
특히 점수를 추가하며 10개로 관리하는 부분은
복수의 동작이 수행되어야 하기 때문에 트랜잭션을 사용했다.
트랜잭션이 필요한 적소에 제대로 활용할 수 있도록 그 눈도 길러야 할 것이다.
'Camp > T.I.L.' 카테고리의 다른 글
[TIL #24] 행렬 테두리 회전하기 (0) | 2024.10.14 |
---|---|
[TIL #23] Product Price at a Given Date (0) | 2024.10.10 |
[TIL #21] 큰 수 만들기 (0) | 2024.10.04 |
[TIL #20] 쿼드압축 후 개수 세기 (0) | 2024.10.02 |
[TIL #19] 다리를 지나는 트럭 (0) | 2024.10.01 |