diff --git a/apps/client/src/app.tsx b/apps/client/src/app.tsx index 5c6272a..a44fa3e 100644 --- a/apps/client/src/app.tsx +++ b/apps/client/src/app.tsx @@ -9,11 +9,17 @@ import { appConsts } from "@repo/shared"; export default function App() { - const { scenePhase, room, myId } = useAppFlow(); + const { scenePhase, room, myId, joinErrorMessage, isJoining, requestJoin } = useAppFlow(); // タイトル画面分岐 if (scenePhase === appConsts.ScenePhase.TITLE) { - return socketManager.title.joinRoom(payload)} />; + return ( + + ); } // ロビー画面分岐 diff --git a/apps/client/src/hooks/useAppFlow.ts b/apps/client/src/hooks/useAppFlow.ts index b68228b..ed9d840 100644 --- a/apps/client/src/hooks/useAppFlow.ts +++ b/apps/client/src/hooks/useAppFlow.ts @@ -1,41 +1,90 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { socketManager } from "@client/network/SocketManager"; -import { appConsts } from "@repo/shared"; +import { appConsts, config } from "@repo/shared"; import type { appTypes, roomTypes } from "@repo/shared"; type AppFlowState = { scenePhase: appTypes.ScenePhase; room: roomTypes.Room | null; myId: string | null; + joinErrorMessage: string | null; + isJoining: boolean; + requestJoin: (payload: roomTypes.JoinRoomPayload) => void; }; export const useAppFlow = (): AppFlowState => { const [scenePhase, setScenePhase] = useState(appConsts.ScenePhase.TITLE); const [room, setRoom] = useState(null); const [myId, setMyId] = useState(null); + const [joinErrorMessage, setJoinErrorMessage] = useState(null); + const [isJoining, setIsJoining] = useState(false); + const joinTimeoutRef = useRef | null>(null); + + const clearJoinTimeout = () => { + if (!joinTimeoutRef.current) { + return; + } + + clearTimeout(joinTimeoutRef.current); + joinTimeoutRef.current = null; + }; + + const requestJoin = (payload: roomTypes.JoinRoomPayload) => { + if (isJoining) { + return; + } + + clearJoinTimeout(); + setJoinErrorMessage(null); + setIsJoining(true); + joinTimeoutRef.current = setTimeout(() => { + setIsJoining(false); + setJoinErrorMessage("参加要求がタイムアウトしました,もう一度お試しください"); + joinTimeoutRef.current = null; + }, config.GAME_CONFIG.JOIN_REQUEST_TIMEOUT_MS); + socketManager.title.joinRoom(payload); + }; useEffect(() => { const handleConnect = (id: string) => { setMyId(id); }; const handleRoomUpdate = (updatedRoom: roomTypes.Room) => { + clearJoinTimeout(); setRoom(updatedRoom); + setIsJoining(false); + setJoinErrorMessage(null); setScenePhase(appConsts.ScenePhase.LOBBY); }; + const handleJoinRejected = (payload: roomTypes.JoinRoomRejectedPayload) => { + clearJoinTimeout(); + setIsJoining(false); + if (payload.reason === "full") { + setJoinErrorMessage(`ルーム ${payload.roomId} は満員です`); + return; + } + + if (payload.reason === "duplicate") { + setJoinErrorMessage(`ルーム ${payload.roomId} への参加要求が重複しました`); + } + }; const handleGameStart = () => { setScenePhase(appConsts.ScenePhase.PLAYING); }; socketManager.common.onConnect(handleConnect); + socketManager.title.onJoinRejected(handleJoinRejected); socketManager.lobby.onRoomUpdate(handleRoomUpdate); socketManager.game.onGameStart(handleGameStart); return () => { + clearJoinTimeout(); socketManager.common.offConnect(handleConnect); + socketManager.title.offJoinRejected(handleJoinRejected); socketManager.lobby.offRoomUpdate(handleRoomUpdate); socketManager.game.offGameStart(handleGameStart); }; }, []); - return { scenePhase, room, myId }; + return { scenePhase, room, myId, joinErrorMessage, isJoining, requestJoin }; }; diff --git a/apps/client/src/network/handlers/TitleHandler.ts b/apps/client/src/network/handlers/TitleHandler.ts index 32248a8..3246817 100644 --- a/apps/client/src/network/handlers/TitleHandler.ts +++ b/apps/client/src/network/handlers/TitleHandler.ts @@ -4,12 +4,20 @@ type TitleHandler = { joinRoom: (payload: roomTypes.JoinRoomPayload) => void; + onJoinRejected: (callback: (payload: roomTypes.JoinRoomRejectedPayload) => void) => void; + offJoinRejected: (callback: (payload: roomTypes.JoinRoomRejectedPayload) => void) => void; }; export const createTitleHandler = (socket: Socket): TitleHandler => { return { joinRoom: (payload: roomTypes.JoinRoomPayload) => { socket.emit(protocol.SocketEvents.JOIN_ROOM, payload); + }, + onJoinRejected: (callback: (payload: roomTypes.JoinRoomRejectedPayload) => void) => { + socket.on(protocol.SocketEvents.ROOM_JOIN_REJECTED, callback); + }, + offJoinRejected: (callback: (payload: roomTypes.JoinRoomRejectedPayload) => void) => { + socket.off(protocol.SocketEvents.ROOM_JOIN_REJECTED, callback); } }; }; diff --git a/apps/client/src/scenes/title/TitleScene.tsx b/apps/client/src/scenes/title/TitleScene.tsx index d475c08..ffb2328 100644 --- a/apps/client/src/scenes/title/TitleScene.tsx +++ b/apps/client/src/scenes/title/TitleScene.tsx @@ -5,9 +5,13 @@ type Props = { // 入室実行時呼び出しコールバック onJoin: (payload: roomTypes.JoinRoomPayload) => void; + // 入室失敗時の表示メッセージ + joinErrorMessage: string | null; + // 入室リクエスト送信中フラグ + isJoining: boolean; }; -export const TitleScene = ({ onJoin }: Props) => { +export const TitleScene = ({ onJoin, joinErrorMessage, isJoining }: Props) => { // プレイヤー名入力値 const [playerName, setPlayerName] = useState(""); // ルームID入力値 @@ -44,12 +48,12 @@ + + {joinErrorMessage && ( +

{joinErrorMessage}

+ )} ); }; \ No newline at end of file diff --git a/apps/server/src/domains/game/GameManager.ts b/apps/server/src/domains/game/GameManager.ts index 97ef4cf..43eee46 100644 --- a/apps/server/src/domains/game/GameManager.ts +++ b/apps/server/src/domains/game/GameManager.ts @@ -1,5 +1,4 @@ import { GameLoop, type TickData } from "./GameLoop"; -import type { gridMapTypes } from "@repo/shared"; import { Player } from "./entities/Player.js"; import { PlayerRegistry } from "./application/services/PlayerRegistry"; import { GameSessionService } from "./application/services/GameSessionService"; @@ -29,11 +28,6 @@ this.playerRegistry.removePlayer(id); } - // 指定IDプレイヤー参照取得 - getPlayer(id: string) { - return this.playerRegistry.getPlayer(id); - } - // 指定プレイヤー座標更新処理 movePlayer(id: string, x: number, y: number) { this.playerRegistry.movePlayer(id, x, y); @@ -54,20 +48,10 @@ this.gameSessionService.startGameLoop(roomId, playerIds, onTick, onGameEnd); } - /** - * ゲームループを停止する - */ - stopGameLoop(roomId: string) { - this.gameSessionService.stopGameLoop(roomId); - } - - // 登録中全プレイヤー配列取得 - getAllPlayers() { - return this.playerRegistry.getAllPlayers(); - } - - // 【一時的】移動したプレイヤーの足元を塗り、差分を返すメソッド - public paintAndGetUpdates(playerId: string): gridMapTypes.CellUpdate[] { - return this.gameSessionService.paintAndGetUpdates(playerId); + // 指定ID配列のプレイヤーを取得 + getPlayersByIds(playerIds: string[]) { + return playerIds + .map((playerId) => this.playerRegistry.getPlayer(playerId)) + .filter((player): player is Player => player !== undefined); } } \ No newline at end of file diff --git a/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts b/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts index bbeb4f2..851750a 100644 --- a/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts +++ b/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts @@ -13,7 +13,7 @@ } export interface ReadyForGamePort { - getAllPlayers(): playerTypes.PlayerData[]; + getPlayersByIds(playerIds: string[]): playerTypes.PlayerData[]; getRoomStartTime(roomId: string): number | undefined; } diff --git a/apps/server/src/domains/game/application/services/GameSessionService.ts b/apps/server/src/domains/game/application/services/GameSessionService.ts index d91ee70..48191cf 100644 --- a/apps/server/src/domains/game/application/services/GameSessionService.ts +++ b/apps/server/src/domains/game/application/services/GameSessionService.ts @@ -1,17 +1,16 @@ -import { config, gridMapLogic } from "@repo/shared"; -import type { gridMapTypes } from "@repo/shared"; +import { config } from "@repo/shared"; import { GameLoop, type TickData } from "../../GameLoop"; import { Player } from "../../entities/Player.js"; import { MapStore } from "../../states/MapStore"; import { logEvent } from "@server/logging/logEvent"; export class GameSessionService { - private mapStore: MapStore; + private mapStores: Map; private gameLoops: Map; private roomStartTimes: Map; constructor(private players: Map) { - this.mapStore = new MapStore(); + this.mapStores = new Map(); this.gameLoops = new Map(); this.roomStartTimes = new Map(); } @@ -37,17 +36,20 @@ const tickRate = config.GAME_CONFIG.PLAYER_POSITION_UPDATE_MS; this.roomStartTimes.set(roomId, Date.now()); + const mapStore = this.mapStores.get(roomId) ?? new MapStore(); + this.mapStores.set(roomId, mapStore); const loop = new GameLoop( roomId, tickRate, playerIds, this.players, - this.mapStore, + mapStore, onTick, () => { this.roomStartTimes.delete(roomId); this.gameLoops.delete(roomId); + this.mapStores.delete(roomId); onGameEnd(); } ); @@ -61,36 +63,4 @@ playerCount: playerIds.length, }); } - - public stopGameLoop(roomId: string) { - const loop = this.gameLoops.get(roomId); - if (loop) { - loop.stop(); - this.gameLoops.delete(roomId); - this.roomStartTimes.delete(roomId); - logEvent("GameSessionService", { - event: "STOP_GAME_LOOP", - result: "stopped", - roomId, - }); - } else { - logEvent("GameSessionService", { - event: "STOP_GAME_LOOP", - result: "ignored_not_running", - roomId, - }); - } - } - - public paintAndGetUpdates(playerId: string): gridMapTypes.CellUpdate[] { - const player = this.players.get(playerId); - if (!player) return []; - - const gridIndex = gridMapLogic.getGridIndexFromPosition(player.x, player.y); - if (gridIndex !== null) { - this.mapStore.paintCell(gridIndex, player.teamId); - } - - return this.mapStore.getAndClearUpdates(); - } } diff --git a/apps/server/src/domains/game/application/services/PlayerRegistry.ts b/apps/server/src/domains/game/application/services/PlayerRegistry.ts index abef2f5..fc263ce 100644 --- a/apps/server/src/domains/game/application/services/PlayerRegistry.ts +++ b/apps/server/src/domains/game/application/services/PlayerRegistry.ts @@ -74,10 +74,6 @@ } } - public getAllPlayers(): Player[] { - return Array.from(this.players.values()); - } - public getPlayersRef(): Map { return this.players; } diff --git a/apps/server/src/domains/game/application/useCases/readyForGameUseCase.ts b/apps/server/src/domains/game/application/useCases/readyForGameUseCase.ts index dfae3fd..1084d78 100644 --- a/apps/server/src/domains/game/application/useCases/readyForGameUseCase.ts +++ b/apps/server/src/domains/game/application/useCases/readyForGameUseCase.ts @@ -5,6 +5,7 @@ type ReadyForGameUseCaseParams = { socketId: string; roomId?: string; + playerIds: string[]; gameManager: ReadyForGamePort; publishCurrentPlayers: (players: playerTypes.PlayerData[]) => void; publishGameStart: (payload: { startTime: number }) => void; @@ -13,19 +14,20 @@ export const readyForGameUseCase = ({ socketId, roomId, + playerIds, gameManager, publishCurrentPlayers, publishGameStart, }: ReadyForGameUseCaseParams) => { - const allPlayers = gameManager.getAllPlayers(); - publishCurrentPlayers(allPlayers); + const roomPlayers = gameManager.getPlayersByIds(playerIds); + publishCurrentPlayers(roomPlayers); logEvent("GameUseCase", { event: "READY_FOR_GAME", result: "received", socketId, roomId, - totalPlayers: allPlayers.length, + totalPlayers: roomPlayers.length, }); if (!roomId) { diff --git a/apps/server/src/domains/room/RoomManager.ts b/apps/server/src/domains/room/RoomManager.ts index defaf38..4197846 100644 --- a/apps/server/src/domains/room/RoomManager.ts +++ b/apps/server/src/domains/room/RoomManager.ts @@ -1,8 +1,14 @@ +/** + * RoomManager + * ルーム状態の保持とルーム操作サービスへの委譲を担うマネージャ + */ import type { roomTypes } from "@repo/shared"; import { RoomJoinService } from "./application/services/RoomJoinService"; import { RoomExitService } from "./application/services/RoomExitService"; import { RoomQueryService } from "./application/services/RoomQueryService"; +import type { JoinRoomResult } from "./application/ports/roomUseCasePorts"; +/** ルーム操作の公開インターフェースを提供するマネージャ */ export class RoomManager { private rooms: Map = new Map(); private roomJoinService: RoomJoinService; @@ -15,18 +21,28 @@ this.roomQueryService = new RoomQueryService(this.rooms); } - // ルームにプレイヤーを追加(なければ作成) - public addPlayerToRoom(roomId: string, socketId: string, playerName: string): roomTypes.Room { + // ルームにプレイヤーを追加する,ルームが未作成なら新規作成する + public addPlayerToRoom(roomId: string, socketId: string, playerName: string): JoinRoomResult { return this.roomJoinService.addPlayerToRoom(roomId, socketId, playerName); } - // プレイヤーをルームから削除し、更新があったルームの配列を返す + // プレイヤーをルームから削除し,更新が発生したルーム配列を返す public removePlayer(socketId: string): roomTypes.Room[] { return this.roomExitService.removePlayer(socketId); } - // オーナーIDからルームを取得 + // オーナーIDからルームを取得する public getRoomByOwnerId(ownerId: string): roomTypes.Room | undefined { return this.roomQueryService.getRoomByOwnerId(ownerId); } + + // ルームIDからルームを取得する + public getRoomById(roomId: string): roomTypes.Room | undefined { + return this.roomQueryService.getRoomById(roomId); + } + + // プレイヤーIDから所属ルームを取得する + public getRoomByPlayerId(playerId: string): roomTypes.Room | undefined { + return this.roomQueryService.getRoomByPlayerId(playerId); + } } \ No newline at end of file diff --git a/apps/server/src/domains/room/application/ports/roomUseCasePorts.ts b/apps/server/src/domains/room/application/ports/roomUseCasePorts.ts index 37d7416..b00b3c8 100644 --- a/apps/server/src/domains/room/application/ports/roomUseCasePorts.ts +++ b/apps/server/src/domains/room/application/ports/roomUseCasePorts.ts @@ -1,9 +1,21 @@ +/** + * roomUseCasePorts + * ルームユースケースが依存する操作ポートを定義する + */ import type { roomTypes } from "@repo/shared"; +/** ルーム参加処理の実行結果 */ +export type JoinRoomResult = { + room: roomTypes.Room; + status: "joined" | "duplicate" | "full"; +}; + +/** ルーム参加ユースケースが利用する参加操作ポート */ export interface JoinRoomPort { - addPlayerToRoom(roomId: string, socketId: string, playerName: string): roomTypes.Room; + addPlayerToRoom(roomId: string, socketId: string, playerName: string): JoinRoomResult; } +/** ルーム切断ユースケースが利用する退出操作ポート */ export interface DisconnectRoomPort { removePlayer(socketId: string): roomTypes.Room[]; } diff --git a/apps/server/src/domains/room/application/services/RoomExitService.ts b/apps/server/src/domains/room/application/services/RoomExitService.ts index f7b8106..36696e3 100644 --- a/apps/server/src/domains/room/application/services/RoomExitService.ts +++ b/apps/server/src/domains/room/application/services/RoomExitService.ts @@ -1,6 +1,11 @@ +/** + * RoomExitService + * ルーム退出処理とオーナー移譲処理を担うサービス + */ import type { roomTypes } from "@repo/shared"; import { logEvent } from "@server/logging/logEvent"; +/** 退出要求に応じてプレイヤー削除とルーム整理を行うサービス */ export class RoomExitService { constructor(private rooms: Map) {} diff --git a/apps/server/src/domains/room/application/services/RoomJoinService.ts b/apps/server/src/domains/room/application/services/RoomJoinService.ts index c9082d7..d096dcc 100644 --- a/apps/server/src/domains/room/application/services/RoomJoinService.ts +++ b/apps/server/src/domains/room/application/services/RoomJoinService.ts @@ -1,11 +1,17 @@ +/** + * RoomJoinService + * ルーム作成とプレイヤー参加処理を担うサービス + */ import { config, roomConsts } from "@repo/shared"; import type { roomTypes } from "@repo/shared"; import { logEvent } from "@server/logging/logEvent"; +import type { JoinRoomResult } from "../ports/roomUseCasePorts"; +/** 参加要求に応じてルーム作成と参加者追加を行うサービス */ export class RoomJoinService { constructor(private rooms: Map) {} - public addPlayerToRoom(roomId: string, socketId: string, playerName: string): roomTypes.Room { + public addPlayerToRoom(roomId: string, socketId: string, playerName: string): JoinRoomResult { let room = this.rooms.get(roomId); if (!room) { room = { @@ -25,6 +31,32 @@ }); } + // 同一ソケットの重複参加を防止する + const alreadyJoined = room.players.some((player) => player.id === socketId); + if (alreadyJoined) { + logEvent("RoomJoinService", { + event: "PLAYER_JOIN", + result: "ignored_duplicate", + roomId, + socketId, + totalPlayers: room.players.length, + }); + return { room, status: "duplicate" }; + } + + // ルーム満員時の参加を拒否する + if (room.players.length >= room.maxPlayers) { + logEvent("RoomJoinService", { + event: "PLAYER_JOIN", + result: "ignored_room_full", + roomId, + socketId, + maxPlayers: room.maxPlayers, + totalPlayers: room.players.length, + }); + return { room, status: "full" }; + } + const newPlayer: roomTypes.RoomMember = { id: socketId, name: playerName, @@ -42,6 +74,6 @@ totalPlayers: room.players.length, }); - return room; + return { room, status: "joined" }; } } diff --git a/apps/server/src/domains/room/application/services/RoomQueryService.ts b/apps/server/src/domains/room/application/services/RoomQueryService.ts index 3ad5dc2..6881df8 100644 --- a/apps/server/src/domains/room/application/services/RoomQueryService.ts +++ b/apps/server/src/domains/room/application/services/RoomQueryService.ts @@ -1,8 +1,27 @@ +/** + * RoomQueryService + * ルーム状態の参照系クエリを提供するサービス + */ import type { roomTypes } from "@repo/shared"; +/** ルームの参照クエリを提供するサービス */ export class RoomQueryService { constructor(private rooms: Map) {} + public getRoomById(roomId: string): roomTypes.Room | undefined { + return this.rooms.get(roomId); + } + + public getRoomByPlayerId(playerId: string): roomTypes.Room | undefined { + for (const room of this.rooms.values()) { + if (room.players.some((player) => player.id === playerId)) { + return room; + } + } + + return undefined; + } + public getRoomByOwnerId(ownerId: string): roomTypes.Room | undefined { for (const room of this.rooms.values()) { if (room.ownerId === ownerId) { diff --git a/apps/server/src/domains/room/application/useCases/joinRoomUseCase.ts b/apps/server/src/domains/room/application/useCases/joinRoomUseCase.ts index c65239f..ecca7bd 100644 --- a/apps/server/src/domains/room/application/useCases/joinRoomUseCase.ts +++ b/apps/server/src/domains/room/application/useCases/joinRoomUseCase.ts @@ -1,5 +1,9 @@ +/** + * joinRoomUseCase + * ルーム参加要求を処理し,状態更新を配信するユースケース + */ import type { roomTypes } from "@repo/shared"; -import type { JoinRoomPort } from "../ports/roomUseCasePorts"; +import type { JoinRoomPort, JoinRoomResult } from "../ports/roomUseCasePorts"; import { logEvent } from "@server/logging/logEvent"; type JoinRoomUseCaseParams = { @@ -9,12 +13,13 @@ publishRoomUpdate: (roomId: roomTypes.Room["roomId"], room: roomTypes.Room) => void; }; +/** 参加イベントを受け取り,ルーム更新を配信する */ export const joinRoomUseCase = ({ roomManager, socketId, data, publishRoomUpdate, -}: JoinRoomUseCaseParams) => { +}: JoinRoomUseCaseParams): JoinRoomResult => { const { roomId, playerName } = data; logEvent("RoomUseCase", { event: "JOIN_ROOM", @@ -24,15 +29,19 @@ playerName, }); - const room = roomManager.addPlayerToRoom(roomId, socketId, playerName); + const joinResult = roomManager.addPlayerToRoom(roomId, socketId, playerName); + if (joinResult.status !== "joined") { + logEvent("RoomUseCase", { + event: "JOIN_ROOM", + result: "rejected", + reason: joinResult.status, + roomId, + socketId, + }); + return joinResult; + } - publishRoomUpdate(roomId, room); - logEvent("RoomUseCase", { - event: "ROOM_UPDATE", - result: "emitted", - roomId, - socketId, - ownerId: room.ownerId, - totalPlayers: room.players.length, - }); + publishRoomUpdate(roomId, joinResult.room); + + return joinResult; }; diff --git a/apps/server/src/domains/room/application/useCases/roomDisconnectUseCase.ts b/apps/server/src/domains/room/application/useCases/roomDisconnectUseCase.ts index 2e54090..1f3a4e1 100644 --- a/apps/server/src/domains/room/application/useCases/roomDisconnectUseCase.ts +++ b/apps/server/src/domains/room/application/useCases/roomDisconnectUseCase.ts @@ -1,3 +1,7 @@ +/** + * roomDisconnectUseCase + * 切断時のルーム退出処理と状態更新配信を行うユースケース + */ import type { DisconnectRoomPort } from "../ports/roomUseCasePorts"; import type { roomTypes } from "@repo/shared"; import { logEvent } from "@server/logging/logEvent"; @@ -8,6 +12,7 @@ publishRoomUpdate: (roomId: roomTypes.Room["roomId"], room: roomTypes.Room) => void; }; +/** 切断ソケットを各ルームから退出させ,更新ルームを配信する */ export const roomDisconnectUseCase = ({ roomManager, socketId, diff --git a/apps/server/src/network/handlers/game/createGameEventPublisher.ts b/apps/server/src/network/handlers/game/createGameEventPublisher.ts index c8b1927..8d36b1e 100644 --- a/apps/server/src/network/handlers/game/createGameEventPublisher.ts +++ b/apps/server/src/network/handlers/game/createGameEventPublisher.ts @@ -5,7 +5,7 @@ import { Server } from "socket.io"; import { protocol } from "@repo/shared"; import type { gridMapTypes, playerTypes, roomTypes } from "@repo/shared"; -import { createEmitToAll } from "@server/network/adapters/socketEmitters"; +import { createEmitToRoom } from "@server/network/adapters/socketEmitters"; import type { CommonHandlerContext } from "../CommonHandler"; type RoomId = roomTypes.Room["roomId"]; @@ -29,7 +29,7 @@ /** 切断時に配信するゲームイベントの送信インターフェース */ export type GameDisconnectPublisher = { - publishPlayerRemovedToAll: (removedPlayerId: SocketId) => void; + publishPlayerRemovedToRoom: (roomId: RoomId, removedPlayerId: SocketId) => void; }; /** 共通送信コンテキストからゲームイベント送信関数群を生成する */ @@ -61,11 +61,11 @@ /** ゲーム切断時の送信関数群を生成する */ export const createGameDisconnectPublisher = (io: Server): GameDisconnectPublisher => { - const emitToAll = createEmitToAll(io); + const emitToRoom = createEmitToRoom(io); return { - publishPlayerRemovedToAll: (removedPlayerId: SocketId) => { - emitToAll(protocol.SocketEvents.REMOVE_PLAYER, removedPlayerId); + publishPlayerRemovedToRoom: (roomId: RoomId, removedPlayerId: SocketId) => { + emitToRoom(roomId, protocol.SocketEvents.REMOVE_PLAYER, removedPlayerId); }, }; }; diff --git a/apps/server/src/network/handlers/game/handleGameDisconnect.ts b/apps/server/src/network/handlers/game/handleGameDisconnect.ts index 3cb2c11..0a7be66 100644 --- a/apps/server/src/network/handlers/game/handleGameDisconnect.ts +++ b/apps/server/src/network/handlers/game/handleGameDisconnect.ts @@ -11,6 +11,7 @@ export const handleGameDisconnect = ( io: Server, gameManager: GameManager, + roomId: string | undefined, playerId: string ) => { const gameDisconnectPublisher = createGameDisconnectPublisher(io); @@ -18,6 +19,12 @@ disconnectUseCase({ gameManager, playerId, - publishPlayerRemoved: gameDisconnectPublisher.publishPlayerRemovedToAll, + publishPlayerRemoved: (removedPlayerId) => { + if (!roomId) { + return; + } + + gameDisconnectPublisher.publishPlayerRemovedToRoom(roomId, removedPlayerId); + }, }); }; diff --git a/apps/server/src/network/handlers/game/registerGameHandlers.ts b/apps/server/src/network/handlers/game/registerGameHandlers.ts index 29d3511..859a451 100644 --- a/apps/server/src/network/handlers/game/registerGameHandlers.ts +++ b/apps/server/src/network/handlers/game/registerGameHandlers.ts @@ -59,10 +59,14 @@ // 参加者の準備完了通知を受けて現在状態を返す socket.on(protocol.SocketEvents.READY_FOR_GAME, () => { const roomId = Array.from(socket.rooms).find((room) => room !== socket.id); + const playerIds = roomId + ? (roomManager.getRoomById(roomId)?.players ?? []).map((p) => p.id) + : []; readyForGameUseCase({ socketId: socket.id, roomId, + playerIds, gameManager, publishCurrentPlayers: gamePublisher.publishCurrentPlayersToSocket, publishGameStart: gamePublisher.publishGameStartToSocket, diff --git a/apps/server/src/network/handlers/registerConnectionHandlers.ts b/apps/server/src/network/handlers/registerConnectionHandlers.ts index 690c439..fa871f9 100644 --- a/apps/server/src/network/handlers/registerConnectionHandlers.ts +++ b/apps/server/src/network/handlers/registerConnectionHandlers.ts @@ -41,7 +41,9 @@ socketId: socket.id, }); - handleGameDisconnect(io, gameManager, socket.id); + const roomId = roomManager.getRoomByPlayerId(socket.id)?.roomId; + + handleGameDisconnect(io, gameManager, roomId, socket.id); handleRoomDisconnect(io, socket, roomManager); }); }); diff --git a/apps/server/src/network/handlers/room/createRoomEventPublisher.ts b/apps/server/src/network/handlers/room/createRoomEventPublisher.ts index 3184664..5e2bf4d 100644 --- a/apps/server/src/network/handlers/room/createRoomEventPublisher.ts +++ b/apps/server/src/network/handlers/room/createRoomEventPublisher.ts @@ -14,6 +14,7 @@ /** ルーム更新イベントの送信インターフェース */ export type RoomEventPublisher = { publishRoomUpdate: (roomId: RoomId, room: RoomUpdatePayload) => void; + publishJoinRejected: (payload: roomTypes.JoinRoomRejectedPayload) => void; }; /** 共通送信コンテキストからルームイベント送信関数を生成する */ @@ -24,6 +25,9 @@ publishRoomUpdate: (roomId: RoomId, room: RoomUpdatePayload) => { common.emitToRoom(roomId, protocol.SocketEvents.ROOM_UPDATE, room); }, + publishJoinRejected: (payload: roomTypes.JoinRoomRejectedPayload) => { + common.emitToSocket(protocol.SocketEvents.ROOM_JOIN_REJECTED, payload); + }, }; }; @@ -35,5 +39,8 @@ publishRoomUpdate: (roomId: RoomId, room: RoomUpdatePayload) => { emitToRoom(roomId, protocol.SocketEvents.ROOM_UPDATE, room); }, + publishJoinRejected: () => { + return; + }, }; }; diff --git a/apps/server/src/network/handlers/room/registerRoomHandlers.ts b/apps/server/src/network/handlers/room/registerRoomHandlers.ts index 0c3dd03..d1000ce 100644 --- a/apps/server/src/network/handlers/room/registerRoomHandlers.ts +++ b/apps/server/src/network/handlers/room/registerRoomHandlers.ts @@ -33,14 +33,60 @@ } const { roomId } = data; + let joinedRoom: roomTypes.Room | null = null; - socket.join(roomId); - - joinRoomUseCase({ + const joinResult = joinRoomUseCase({ roomManager, socketId: socket.id, data, - publishRoomUpdate: roomPublisher.publishRoomUpdate, + publishRoomUpdate: (_roomId, room) => { + joinedRoom = room; + }, }); + + // 参加拒否時は理由を通知する + switch (joinResult.status) { + case "full": + roomPublisher.publishJoinRejected({ + roomId, + reason: "full", + }); + logEvent("Network", { + event: "JOIN_ROOM", + result: "rejected_room_full", + roomId, + socketId: socket.id, + }); + return; + + case "duplicate": + roomPublisher.publishJoinRejected({ + roomId, + reason: "duplicate", + }); + logEvent("Network", { + event: "JOIN_ROOM", + result: "rejected_duplicate", + roomId, + socketId: socket.id, + }); + return; + + case "joined": + socket.join(roomId); + roomPublisher.publishRoomUpdate(roomId, joinResult.room); + logEvent("RoomUseCase", { + event: "ROOM_UPDATE", + result: "emitted", + roomId, + socketId: socket.id, + ownerId: joinResult.room.ownerId, + totalPlayers: joinResult.room.players.length, + }); + return; + + default: + return; + } }); }; diff --git a/packages/shared/src/config/gameConfig.ts b/packages/shared/src/config/gameConfig.ts index 32b91e2..eac31d5 100644 --- a/packages/shared/src/config/gameConfig.ts +++ b/packages/shared/src/config/gameConfig.ts @@ -1,10 +1,11 @@ export const GAME_CONFIG = { // ゲーム設定 - MAX_PLAYERS_PER_ROOM: 4, // ルーム収容人数設定 + MAX_PLAYERS_PER_ROOM: 100, // ルーム収容人数設定 GAME_DURATION_SEC: 180, // 1ゲームの制限時間(3分 = 180秒) // UI表示更新設定 TIMER_DISPLAY_UPDATE_MS: 250, // 残り時間表示の更新間隔(ms) + JOIN_REQUEST_TIMEOUT_MS: 8000, // ルーム参加要求の待機タイムアウト(ms) // ネットワーク・描画補間設定 PLAYER_POSITION_UPDATE_MS: 50, // 座標送信間隔(20Hz) diff --git a/packages/shared/src/domains/room/room.type.ts b/packages/shared/src/domains/room/room.type.ts index 4c1ac67..f69dcbd 100644 --- a/packages/shared/src/domains/room/room.type.ts +++ b/packages/shared/src/domains/room/room.type.ts @@ -22,4 +22,13 @@ export interface JoinRoomPayload { roomId: string; playerName: string; +} + +// ルーム参加拒否理由型 +export type JoinRoomRejectedReason = "full" | "duplicate"; + +// ルーム参加拒否通知ペイロード型 +export interface JoinRoomRejectedPayload { + roomId: string; + reason: JoinRoomRejectedReason; } \ No newline at end of file diff --git a/packages/shared/src/protocol/events.ts b/packages/shared/src/protocol/events.ts index 5e90344..39a1c4a 100644 --- a/packages/shared/src/protocol/events.ts +++ b/packages/shared/src/protocol/events.ts @@ -5,6 +5,7 @@ // ロビー・ルーム関連イベント名 JOIN_ROOM: "join-room", + ROOM_JOIN_REJECTED: "room-join-rejected", ROOM_UPDATE: "room-update", START_GAME: "start-game", GAME_START: "game-start",