DO-49 socket io 기능 업데이트

- 방 생성 로직 개선
  방 생성시 급수를 전달 받아서 비슷한 급수 유저와 매칭 시도함
- 항복 기능 추가
  항복 메세지 요청시 상대 플레이어에게 메세지 전달
- 무승부 제안 기능 추가
  무승부 제안 요청시 상대방에게 트리거 전달
- 무승부 제안 받을 경우 수락 혹은 거절
- 재대결 요청 기능 추가
This commit is contained in:
fiore 2025-03-24 18:28:14 +09:00
parent 21f1f0c624
commit 43a7ecfaa6
10 changed files with 380 additions and 60 deletions

4
app.js
View File

@ -78,12 +78,12 @@ app.use('/users', usersRouter);
app.use('/leaderboard', leaderboardRouter); app.use('/leaderboard', leaderboardRouter);
app.use('/coins', coinsRouter); app.use('/coins', coinsRouter);
// catch 404 and forward to error handler // catch 404 and forward to error handlers
app.use(function(req, res, next) { app.use(function(req, res, next) {
next(createError(404)); next(createError(404));
}); });
// error handler // error handlers
app.use(function(err, req, res, next) { app.use(function(err, req, res, next) {
// set locals, only providing error in development // set locals, only providing error in development
res.locals.message = err.message; res.locals.message = err.message;

View File

@ -7,7 +7,7 @@
var app = require('../app'); var app = require('../app');
var debug = require('debug')('tictactoe-server:server'); var debug = require('debug')('tictactoe-server:server');
var http = require('http'); var http = require('http');
var game = require('../game'); var socketModule = require('../socket');
/** /**
* Get port from environment and store in Express. * Get port from environment and store in Express.
@ -25,7 +25,7 @@ var server = http.createServer(app);
/** /**
* 게임 서버 실행 * 게임 서버 실행
*/ */
game(server); socketModule(server);
/** /**
* Listen on provided port, on all network interfaces. * Listen on provided port, on all network interfaces.

56
game.js
View File

@ -1,56 +0,0 @@
const {v4: uuidv4} = require('uuid');
module.exports = function(server) {
const io = require('socket.io')(server, {
transports: ['websocket']
});
// 방 정보
var rooms = [];
var socketRooms = new Map();
io.on('connection', function(socket) {
console.log('Connected: ' + socket.id);
if (rooms.length > 0) {
var roomId = rooms.shift();
socket.join(roomId)
socket.emit('joinRoom', { roomId: roomId });
socket.to(roomId).emit('startGame', { roomId: socket.id });
socketRooms.set(socket.id, roomId);
} else {
var roomId = uuidv4();
socket.join(roomId);
socket.emit('createRoom', { room: roomId });
rooms.push(roomId);
socketRooms.set(socket.id, roomId);
}
socket.on('leaveRoom', function(roomData) {
socket.leave(roomData.roomId);
socket.emit('exitRoom');
socket.to(roomData.roomId).emit('endGame');
// 방 만든 후 혼자 들어갔다가 나갈 때 rooms에서 방 삭제
var roomId = socketRooms.get(socket.id);
const roomIdx = rooms.indexOf(roomId);
if (roomIdx !== -1) {
rooms.splice(roomIdx, 1);
console.log('Room removed:', roomId);
}
socketRooms.delete(socket.id);
});
socket.on('doPlayer', function(moveData) {
const roomId = moveData.roomId;
const position = moveData.position;
socket.to(roomId).emit('doOpponent', { position: position });
});
socket.on('disconnect', function(reason) {
console.log('Disconnected: ' + socket.id + ', Reason: ' + reason);
});
});
};

View File

@ -210,6 +210,7 @@ router.post("/score-update", async function (req, res, next) {
score: Number(userScore), score: Number(userScore),
win: Number(winCount), win: Number(winCount),
lose: Number(loseCount), lose: Number(loseCount),
// TODO : 승급, 강등 여부 추가 -1 :강등, 0: 변화 없음 , 1: 승급
}); });
} catch(err) { } catch(err) {

View File

@ -0,0 +1,130 @@
const { logger } = require('../../utils/logger');
module.exports = function(io, socket, gameState) {
// 플레이어 움직임 처리
socket.on('doPlayer', function(moveData) {
try {
const roomId = moveData.roomId;
const position = moveData.position;
socket.to(roomId).emit('doOpponent', { position: position });
logger.debug(`플레이어 ${socket.id}의 움직임, 위치: ${position}, 방: ${roomId}`);
} catch (err) {
logger.error(`플레이어 움직임 처리 중 오류: ${err}`);
socket.emit('error', { message: "움직임 처리 중 오류가 발생했습니다." });
}
});
// 항복 요청
socket.on('requestSurrender', function(data) {
try {
const roomId = data.roomId;
const message = "상대방이 항복했습니다.";
socket.to(roomId).emit('doSurrender', { message });
socket.emit('surrenderConfirmed', { message: "항복 요청이 전송되었습니다." });
logger.info(`항복 요청: 플레이어 ${socket.id}, 방 ${roomId}`);
} catch (err) {
logger.error(`항복 처리 중 오류: ${err}`);
socket.emit('error', { message: "항복 처리 중 오류가 발생했습니다." });
}
});
// 무승부 신청 보내기
socket.on('requestDraw', function(data) {
try {
const roomId = data.roomId;
const message = "상대방이 무승부 요청을 보냈습니다.";
socket.to(roomId).emit('receiveDrawRequest', { message });
socket.emit('drawRequestSent', { message: "무승부 요청이 전송되었습니다." });
logger.info(`무승부 요청: 플레이어 ${socket.id}, 방 ${roomId}`);
} catch (err) {
logger.error(`무승부 요청 처리 중 오류: ${err}`);
socket.emit('error', { message: "무승부 요청 처리 중 오류가 발생했습니다." });
}
});
// 무승부 수락
socket.on('acceptDraw', function(data) {
try {
const roomId = data.roomId;
const message = "상대방이 무승부를 수락했습니다.";
socket.to(roomId).emit('drawAccepted', { message });
socket.emit('drawConfirmed', { message: "무승부 수락이 완료되었습니다." });
logger.info(`무승부 수락: 플레이어 ${socket.id}, 방 ${roomId}`);
} catch (err) {
logger.error(`무승부 수락 처리 중 오류: ${err}`);
socket.emit('error', { message: "무승부 수락 처리 중 오류가 발생했습니다." });
}
});
// 무승부 거절
socket.on('rejectDraw', function(data) {
try {
const roomId = data.roomId;
const message = "상대방이 무승부를 거절했습니다.";
socket.to(roomId).emit('drawRejected', { message });
socket.emit('drawRejectionConfirmed', { message: "무승부 거절이 완료되었습니다." });
logger.info(`무승부 거절: 플레이어 ${socket.id}, 방 ${roomId}`);
} catch(err) {
logger.error(`무승부 거절 처리 중 오류: ${err}`);
socket.emit('error', { message: "무승부 거절 처리 중 오류가 발생했습니다." });
}
});
// 재대결 신청
socket.on('requestRevenge', function(data) {
try {
const roomId = data.roomId;
const message = "상대방이 재대결을 신청했습니다.";
socket.to(roomId).emit('receiveRevengeRequest', { message });
socket.emit('revengeRequestSent', { message: "재대결 신청이 전송되었습니다." });
logger.info(`재대결 요청: 플레이어 ${socket.id}, 방 ${roomId}`);
} catch (err) {
logger.error(`재대결 신청 처리 중 오류: ${err}`);
socket.emit('error', { message: "재대결 신청 처리 중 오류가 발생했습니다." });
}
});
// 재대결 수락
socket.on('acceptRevenge', function(data) {
try {
const roomId = data.roomId;
const message = "상대방이 재대결을 수락했습니다.";
socket.to(roomId).emit('revengeAccepted', { message });
socket.emit('revengeConfirmed', { message: "재대결 수락이 완료되었습니다."});
logger.info(`재대결 수락: 플레이어 ${socket.id}, 방 ${roomId}`);
} catch (err) {
logger.error(`재대결 수락 처리 중 오류: ${err}`);
socket.emit('error', { message: "재대결 수락 처리 중 오류가 발생했습니다." });
}
});
// 재대결 거절
socket.on('rejectRevenge', function(data) {
try {
const roomId = data.roomId;
const message = "상대방이 재대결을 거절했습니다.";
socket.to(roomId).emit('revengeRejected', { message });
socket.emit('revengeRejectionConfirmed', {message: "재대결 거절이 완료되었습니다."});
logger.info(`재대결 거절: 플레이어 ${socket.id}, 방 ${roomId}`);
} catch (err) {
logger.error(`재대결 거절 처리 중 오류: ${err}`);
socket.emit('error', { message: "재대결 거절 처리 중 오류가 발생했습니다." });
}
});
};

View File

@ -0,0 +1,78 @@
const { v4: uuidv4 } = require('uuid');
const { logger } = require('../../utils/logger');
module.exports = function(io, socket, gameState) {
socket.on('registerPlayer', function(data) {
try {
const rating = data.rating;
gameState.playerRating.set(socket.id, rating);
logger.info(`플레이어 등록: ID ${socket.id}, 급수 ${rating}`);
// 급수에 따른 매칭 진행
findMatch(socket, rating);
} catch (err) {
logger.error(`플레이어 등록 중 오류: ${err}`);
socket.emit('error', { message: "플레이어 등록 중 오류가 발생했습니다." });
}
});
const findMatch = (socket, playerRating) => {
let matchedRoom = null;
// 1. 같은 급수의 방 찾기
matchedRoom = gameState.rooms.find(room =>
Math.abs(gameState.playerRating.get(room.hostId) - playerRating) === 0);
// 2. 같은 급수 방이 없으면 ±1급 범위로 확장
if (!matchedRoom) {
matchedRoom = gameState.rooms.find(room =>
Math.abs(gameState.playerRating.get(room.hostId) - playerRating) <= 1);
}
if (matchedRoom) {
const roomId = matchedRoom.roomId;
// 매칭된 방은 대기 목록에서 제거
gameState.rooms = gameState.rooms.filter(room => room.roomId !== roomId);
// 방 입장
socket.join(roomId);
gameState.socketRooms.set(socket.id, roomId);
// 클라이언트에게 방 정보 전송 (상대 급수 정보 포함)
socket.emit('joinRoom', {
roomId: roomId,
opponentRating: gameState.playerRating.get(matchedRoom.hostId)
});
// 상대방에게 게임 시작 알림 (내 급수 정보 포함)
socket.to(roomId).emit('startGame', {
opponentId: socket.id,
opponentRating: playerRating
});
logger.info(`매칭 성공: ${socket.id}(${playerRating}급) - ${matchedRoom.hostId}(${gameState.playerRating.get(matchedRoom.hostId)}급)`);
} else { // 4. 매칭된 방이 없으면 새 방 생성
logger.info("매칭된 방 없음!")
const roomId = uuidv4();
socket.join(roomId);
gameState.rooms.push({
roomId: roomId,
hostId: socket.id,
rating: playerRating
});
gameState.socketRooms.set(socket.id, roomId);
socket.emit('createRoom', {
roomId: roomId,
message: "대기 중... 비슷한 상대를 찾고 있습니다."
});
logger.info(`대기방 생성: ID ${socket.id}, 급수 ${playerRating}, 방 ID ${roomId}`);
}
}
};

View File

@ -0,0 +1,25 @@
const { logger } = require('../../utils/logger');
module.exports = function(io, socket, gameState) {
// 방 나가기 이벤트
socket.on('leaveRoom', function(roomData) {
try {
const roomId = roomData.roomId;
socket.leave(roomId);
socket.emit('exitRoom');
socket.to(roomId).emit('endGame');
// 대기방 목록에서 제거
gameState.rooms = gameState.rooms.filter(room => room.roomId !== roomId);
// 매핑 정보 삭제
gameState.socketRooms.delete(socket.id);
logger.info(`방 나가기 처리: 플레이어 ${socket.id}, 방 ${roomId}`);
} catch (err) {
logger.error(`방 나가기 처리 중 오류: ${err}`);
socket.emit('error', { message: "방 나가기 처리 중 오류가 발생했습니다." });
}
});
};

70
socket/index.js Normal file
View File

@ -0,0 +1,70 @@
const {v4: uuidv4} = require('uuid');
const matchmakingHandlers = require('./handlers/matchmaking');
const roomEventHandlers = require('./handlers/roomEvents');
const gameEventHandlers = require('./handlers/gameEvents');
const { logger } = require('../utils/logger');
// 전역 상태 객체 (모든 핸들러에서 공유)
const gameState = {
rooms: [], // {roomId, hostId, rating} 형태로 저장
socketRooms: new Map(), // 소켓ID와 방ID 매핑
playerRating: new Map() // 소켓ID와 플레이어 급수 매핑
};
module.exports = function(server) {
const io = require('socket.io')(server, {
transports: ['websocket']
});
io.on('connection', function(socket) {
console.log('Connected: ' + socket.id);
// 기존 단순 매칭 코드
// if (rooms.length > 0) {
// var roomId = rooms.shift();
// socket.join(roomId)
// socket.emit('joinRoom', { roomId: roomId });
// socket.to(roomId).emit('startGame', { roomId: socket.id });
// socketRooms.set(socket.id, roomId);
// } else {
// var roomId = uuidv4();
// socket.join(roomId);
// socket.emit('createRoom', { room: roomId });
// rooms.push(roomId);
// socketRooms.set(socket.id, roomId);
// }
// 매칭 관련 이벤트 핸들러 등록
matchmakingHandlers(io, socket, gameState)
// 방 관련 이벤트 핸들러 등록
roomEventHandlers(io, socket, gameState);
// 게임 내 이벤트 핸들러 등록
gameEventHandlers(io, socket, gameState);
// 연결 해제 시 정리
socket.on('disconnect', function(reason) {
logger.info('Disconnected: ' + socket.id + ', Reason: ' + reason);
// 해당 소켓이 속한 방 찾기
const roomId = gameState.socketRooms.get(socket.id);
if (roomId) {
// 상대방에게 연결 끊김 알림
socket.to(roomId).emit('opponentDisconnected', {
message: "상대방의 연결이 끊어졌습니다."
});
// 대기방 목록에서 제거
gameState.rooms = gameState.rooms.filter(room => room.roomId !== roomId);
gameState.socketRooms.delete(socket.id);
}
// 플레이어 급수 정보 제거
gameState.playerRating.delete(socket.id);
});
});
};

32
socket/models/Room.js Normal file
View File

@ -0,0 +1,32 @@
/**
* 게임 클래스
*/
class Room {
/**
* 새로운 객체 생성
* @param {string} roomId - 고유 ID
* @param {string} hostId - 방장 소켓 ID
* @param {number} rating - 방장의 급수 (1-18)
*/
constructor(roomId, hostId, rating) {
this.roomId = roomId;
this.hostId = hostId;
this.rating = rating;
this.createdAt = new Date();
}
/**
* 해당 방의 정보를 객체로 반환
* @returns {Object} 정보 객체
*/
toJSON() {
return {
roomId: this.roomId,
hostId: this.hostId,
rating: this.rating,
createdAt: this.createdAt
};
}
}
module.exports = Room;

40
utils/logger.js Normal file
View File

@ -0,0 +1,40 @@
/**
* 간단한 로거 유틸리티
*/
const logger = {
/**
* 디버그 레벨 로그
* @param {string} message - 로그 메시지
*/
debug: (message) => {
if (process.env.LOG_LEVEL === 'debug') {
console.log(`[DEBUG] ${new Date().toISOString()}: ${message}`);
}
},
/**
* 정보 레벨 로그
* @param {string} message - 로그 메시지
*/
info: (message) => {
console.log(`[INFO] ${new Date().toISOString()}: ${message}`);
},
/**
* 경고 레벨 로그
* @param {string} message - 로그 메시지
*/
warn: (message) => {
console.warn(`[WARN] ${new Date().toISOString()}: ${message}`);
},
/**
* 에러 레벨 로그
* @param {string} message - 로그 메시지
*/
error: (message) => {
console.error(`[ERROR] ${new Date().toISOString()}: ${message}`);
}
};
module.exports = { logger };