diff --git a/apps/client/src/app.tsx b/apps/client/src/app.tsx index a44fa3e..1702176 100644 --- a/apps/client/src/app.tsx +++ b/apps/client/src/app.tsx @@ -5,11 +5,12 @@ import { TitleScene } from "./scenes/title/TitleScene"; import { LobbyScene } from "./scenes/lobby/LobbyScene"; import { GameScene } from "./scenes/game/GameScene"; +import { ResultScene } from "./scenes/result/ResultScene"; import { appConsts } from "@repo/shared"; export default function App() { - const { scenePhase, room, myId, joinErrorMessage, isJoining, requestJoin } = useAppFlow(); + const { scenePhase, room, myId, gameResult, joinErrorMessage, isJoining, requestJoin } = useAppFlow(); // タイトル画面分岐 if (scenePhase === appConsts.ScenePhase.TITLE) { @@ -27,6 +28,11 @@ return socketManager.lobby.startGame()} />; } + // 結果画面分岐 + if (scenePhase === appConsts.ScenePhase.RESULT) { + return ; + } + // プレイ画面分岐 return ; } \ No newline at end of file diff --git a/apps/client/src/hooks/useAppFlow.ts b/apps/client/src/hooks/useAppFlow.ts index b27d4c2..12aa756 100644 --- a/apps/client/src/hooks/useAppFlow.ts +++ b/apps/client/src/hooks/useAppFlow.ts @@ -6,7 +6,7 @@ import { useCallback, useReducer, useRef, useState } from "react"; import { socketManager } from "@client/network/SocketManager"; import { appConsts, config } from "@repo/shared"; -import type { appTypes, roomTypes } from "@repo/shared"; +import type { appTypes, roomTypes, GameResultPayload } from "@repo/shared"; import { useSocketSubscriptions } from "./useSocketSubscriptions"; /** アプリフロー管理フックの公開状態と操作を表す型 */ @@ -14,6 +14,7 @@ scenePhase: appTypes.ScenePhase; room: roomTypes.Room | null; myId: string | null; + gameResult: GameResultPayload | null; joinErrorMessage: string | null; isJoining: boolean; requestJoin: (payload: roomTypes.JoinRoomPayload) => void; @@ -63,6 +64,7 @@ const [scenePhase, setScenePhase] = useState(appConsts.ScenePhase.TITLE); const [room, setRoom] = useState(null); const [myId, setMyId] = useState(null); + const [gameResult, setGameResult] = useState(null); const [joinState, dispatchJoin] = useReducer(joinReducer, initialJoinState); const joinTimeoutRef = useRef | null>(null); const joinRejectedHandlerRef = useRef<((payload: roomTypes.JoinRoomRejectedPayload) => void) | null>(null); @@ -138,6 +140,7 @@ useSocketSubscriptions({ completeJoinRequest, + setGameResult, setMyId, setRoom, setScenePhase, @@ -147,6 +150,7 @@ scenePhase, room, myId, + gameResult, joinErrorMessage: getJoinErrorMessage(joinState.joinFailure), isJoining: joinState.isJoining, requestJoin, diff --git a/apps/client/src/hooks/useSocketSubscriptions.ts b/apps/client/src/hooks/useSocketSubscriptions.ts index fdd75ef..8663c7b 100644 --- a/apps/client/src/hooks/useSocketSubscriptions.ts +++ b/apps/client/src/hooks/useSocketSubscriptions.ts @@ -6,10 +6,11 @@ import { useEffect } from "react"; import { socketManager } from "@client/network/SocketManager"; import { appConsts } from "@repo/shared"; -import type { appTypes, roomTypes } from "@repo/shared"; +import type { appTypes, roomTypes, GameResultPayload } from "@repo/shared"; type UseSocketSubscriptionsParams = { completeJoinRequest: () => void; + setGameResult: (payload: GameResultPayload | null) => void; setMyId: (id: string | null) => void; setRoom: (room: roomTypes.Room | null) => void; setScenePhase: (phase: appTypes.ScenePhase) => void; @@ -18,6 +19,7 @@ /** アプリ共通のソケット購読を登録しクリーンアップするフック */ export const useSocketSubscriptions = ({ completeJoinRequest, + setGameResult, setMyId, setRoom, setScenePhase, @@ -34,17 +36,25 @@ }; const handleGameStart = () => { + setGameResult(null); setScenePhase(appConsts.ScenePhase.PLAYING); }; + const handleGameResult = (payload: GameResultPayload) => { + setGameResult(payload); + setScenePhase(appConsts.ScenePhase.RESULT); + }; + socketManager.common.onConnect(handleConnect); socketManager.lobby.onRoomUpdate(handleRoomUpdate); socketManager.game.onceGameStart(handleGameStart); + socketManager.game.onGameResult(handleGameResult); return () => { completeJoinRequest(); socketManager.common.offConnect(handleConnect); socketManager.lobby.offRoomUpdate(handleRoomUpdate); + socketManager.game.offGameResult(handleGameResult); }; - }, [completeJoinRequest, setMyId, setRoom, setScenePhase]); + }, [completeJoinRequest, setGameResult, setMyId, setRoom, setScenePhase]); }; diff --git a/apps/client/src/network/handlers/GameHandler.ts b/apps/client/src/network/handlers/GameHandler.ts index e4b307d..fc91ec8 100644 --- a/apps/client/src/network/handlers/GameHandler.ts +++ b/apps/client/src/network/handlers/GameHandler.ts @@ -7,6 +7,7 @@ import { protocol } from "@repo/shared"; import type { CurrentPlayersPayload, + GameResultPayload, GameStartPayload, MovePayload, NewPlayerPayload, @@ -31,6 +32,10 @@ onGameStart: (callback: (data: GameStartPayload) => void) => void; onceGameStart: (callback: (data: GameStartPayload) => void) => void; offGameStart: (callback: (data: GameStartPayload) => void) => void; + onGameEnd: (callback: () => void) => void; + offGameEnd: (callback: () => void) => void; + onGameResult: (callback: (payload: GameResultPayload) => void) => void; + offGameResult: (callback: (payload: GameResultPayload) => void) => void; sendMove: (x: number, y: number) => void; readyForGame: () => void; }; @@ -79,6 +84,18 @@ offGameStart: (callback) => { offEvent(protocol.SocketEvents.GAME_START, callback); }, + onGameEnd: (callback) => { + onEvent(protocol.SocketEvents.GAME_END, callback); + }, + offGameEnd: (callback) => { + offEvent(protocol.SocketEvents.GAME_END, callback); + }, + onGameResult: (callback) => { + onEvent(protocol.SocketEvents.GAME_RESULT, callback); + }, + offGameResult: (callback) => { + offEvent(protocol.SocketEvents.GAME_RESULT, callback); + }, sendMove: (x, y) => { const payload: MovePayload = { x, y }; emitEvent(protocol.SocketEvents.MOVE, payload); diff --git a/apps/client/src/scenes/game/GameManager.ts b/apps/client/src/scenes/game/GameManager.ts index 4da0b22..6bc8869 100644 --- a/apps/client/src/scenes/game/GameManager.ts +++ b/apps/client/src/scenes/game/GameManager.ts @@ -37,6 +37,7 @@ } public placeBomb(): string | null { + if (this.isInputLocked) return null; if (!this.bombManager) return null; return this.bombManager.placeBomb(); } @@ -53,6 +54,12 @@ private joystickInput = { x: 0, y: 0 }; private isInitialized = false; private isDestroyed = false; + private isInputLocked = false; + + public lockInput() { + this.isInputLocked = true; + this.joystickInput = { x: 0, y: 0 }; + } constructor(container: HTMLDivElement, myId: string) { this.container = container; // 明示的に代入 @@ -88,6 +95,7 @@ myId: this.myId, gameMap: this.gameMap, onGameStart: this.setGameStart.bind(this), + onGameEnd: this.lockInput.bind(this), }); this.networkSync.bind(); @@ -118,6 +126,7 @@ * React側からジョイスティックの入力を受け取る */ public setJoystickInput(x: number, y: number) { + if (this.isInputLocked) return; this.joystickInput = { x, y }; } @@ -140,6 +149,7 @@ this.bombManager?.destroy(); this.bombManager = null; this.players = {}; + this.isInputLocked = false; // イベント購読の解除 this.networkSync?.unbind(); diff --git a/apps/client/src/scenes/game/application/GameNetworkSync.ts b/apps/client/src/scenes/game/application/GameNetworkSync.ts index 9a8a245..ff8f650 100644 --- a/apps/client/src/scenes/game/application/GameNetworkSync.ts +++ b/apps/client/src/scenes/game/application/GameNetworkSync.ts @@ -23,6 +23,7 @@ myId: string; gameMap: GameMapController; onGameStart: (startTime: number) => void; + onGameEnd: () => void; }; /** ゲーム中のネットワークイベント購読と同期処理を管理する */ @@ -32,6 +33,7 @@ private myId: string; private gameMap: GameMapController; private onGameStart: (startTime: number) => void; + private onGameEnd: () => void; private isBound = false; private handleCurrentPlayers = (serverPlayers: CurrentPlayersPayload) => { @@ -80,12 +82,17 @@ this.gameMap.updateCells(updates); }; - constructor({ worldContainer, players, myId, gameMap, onGameStart }: GameNetworkSyncOptions) { + private handleGameEnd = () => { + this.onGameEnd(); + }; + + constructor({ worldContainer, players, myId, gameMap, onGameStart, onGameEnd }: GameNetworkSyncOptions) { this.worldContainer = worldContainer; this.players = players; this.myId = myId; this.gameMap = gameMap; this.onGameStart = onGameStart; + this.onGameEnd = onGameEnd; } public bind() { @@ -97,6 +104,7 @@ socketManager.game.onUpdatePlayers(this.handlePlayerUpdates); socketManager.game.onRemovePlayer(this.handleRemovePlayer); socketManager.game.onUpdateMapCells(this.handleUpdateMapCells); + socketManager.game.onGameEnd(this.handleGameEnd); this.isBound = true; } @@ -110,6 +118,7 @@ socketManager.game.offUpdatePlayers(this.handlePlayerUpdates); socketManager.game.offRemovePlayer(this.handleRemovePlayer); socketManager.game.offUpdateMapCells(this.handleUpdateMapCells); + socketManager.game.offGameEnd(this.handleGameEnd); this.isBound = false; } diff --git a/apps/client/src/scenes/result/ResultScene.tsx b/apps/client/src/scenes/result/ResultScene.tsx new file mode 100644 index 0000000..14388dd --- /dev/null +++ b/apps/client/src/scenes/result/ResultScene.tsx @@ -0,0 +1,74 @@ +import type { GameResultPayload } from "@repo/shared"; + +type Props = { + result: GameResultPayload | null; +}; + +const formatPaintRate = (value: number): string => `${value.toFixed(1)}%`; + +export const ResultScene = ({ result }: Props) => { + if (!result) { + return
結果を読み込み中...
; + } + + return ( +
+

結果発表

+ +
+
+ 順位 + チーム名 + 塗り率 +
+ + {result.rankings.map((row, index) => ( +
+ {row.rank}位 + {row.teamName} + + {formatPaintRate(row.paintRate)} + +
+ ))} +
+
+ ); +}; diff --git a/apps/server/src/application/coordinators/startGameCoordinator.ts b/apps/server/src/application/coordinators/startGameCoordinator.ts index c8edccb..efb030c 100644 --- a/apps/server/src/application/coordinators/startGameCoordinator.ts +++ b/apps/server/src/application/coordinators/startGameCoordinator.ts @@ -21,6 +21,7 @@ | "publishUpdatePlayersToRoom" | "publishMapCellUpdatesToRoom" | "publishGameEndToRoom" + | "publishGameResultToRoom" | "publishGameStartToRoom" >; }; diff --git a/apps/server/src/domains/game/GameManager.ts b/apps/server/src/domains/game/GameManager.ts index 34a577b..389f85c 100644 --- a/apps/server/src/domains/game/GameManager.ts +++ b/apps/server/src/domains/game/GameManager.ts @@ -2,7 +2,7 @@ * GameManager * ゲームセッション集合の生成,更新,参照管理を統括する */ -import type { gameTypes } from "@repo/shared"; +import type { gameTypes, GameResultPayload } from "@repo/shared"; import { Player } from "./entities/player/Player.js"; import { GameRoomSession } from "./application/services/GameRoomSession"; import { GameSessionLifecycleService } from "./application/services/GameSessionLifecycleService"; @@ -50,7 +50,7 @@ roomId: string, playerIds: string[], onTick: (data: gameTypes.TickData) => void, - onGameEnd: () => void + onGameEnd: (payload: GameResultPayload) => void ) { this.lifecycleService.startRoomSession(roomId, playerIds, onTick, onGameEnd); } diff --git a/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts b/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts index 5858507..637ded1 100644 --- a/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts +++ b/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts @@ -7,6 +7,7 @@ playerTypes, roomTypes, CurrentPlayersPayload, + GameResultPayload, GameStartPayload, PongPayload, RemovePlayerPayload, @@ -20,7 +21,7 @@ roomId: string, playerIds: string[], onTick: (data: gameTypes.TickData) => void, - onGameEnd: () => void + onGameEnd: (payload: GameResultPayload) => void ): void; getRoomStartTime(roomId: string): number | undefined; } @@ -65,6 +66,7 @@ cellUpdates: UpdateMapCellsPayload ): void; publishGameEndToRoom(roomId: roomTypes.Room["roomId"]): void; + publishGameResultToRoom(roomId: roomTypes.Room["roomId"], payload: GameResultPayload): void; publishGameStartToRoom(roomId: roomTypes.Room["roomId"], payload: GameStartPayload): void; publishCurrentPlayersToSocket(players: CurrentPlayersPayload): void; publishGameStartToSocket(payload: GameStartPayload): void; diff --git a/apps/server/src/domains/game/application/services/GameRoomSession.ts b/apps/server/src/domains/game/application/services/GameRoomSession.ts index 02fdc48..5f044c2 100644 --- a/apps/server/src/domains/game/application/services/GameRoomSession.ts +++ b/apps/server/src/domains/game/application/services/GameRoomSession.ts @@ -8,7 +8,8 @@ logResults, logScopes, } from "@server/logging/index"; -import type { gameTypes } from "@repo/shared"; +import { config } from "@repo/shared"; +import type { gameTypes, GameResultPayload } from "@repo/shared"; import { GameLoop } from "../../loop/GameLoop"; import { Player } from "../../entities/player/Player.js"; import { MapStore } from "../../entities/map/MapStore"; @@ -21,6 +22,8 @@ // 💡 追加: チーム割り当てサービスをインポート import { TeamAssignmentService } from "../services/TeamAssignmentService.js"; +const TEAM_NAMES = ["赤チーム", "青チーム", "緑チーム", "黄チーム"] as const; + /** ルーム単位のゲーム状態とループ進行を保持するセッションクラス */ export class GameRoomSession { private players: Map; @@ -51,7 +54,7 @@ public start( tickRate: number, onTick: (data: gameTypes.TickData) => void, - onGameEnd: () => void, + onGameEnd: (payload: GameResultPayload) => void, ): void { if (this.gameLoop) { return; @@ -65,8 +68,9 @@ this.mapStore, onTick, () => { + const resultPayload = this.buildGameResultPayload(); this.dispose(); - onGameEnd(); + onGameEnd(resultPayload); }, ); @@ -121,4 +125,49 @@ } this.players.clear(); } + + private buildGameResultPayload(): GameResultPayload { + const { TEAM_COUNT } = config.GAME_CONFIG; + const gridColors = this.mapStore.getGridColorsSnapshot(); + const totalCells = gridColors.length; + const paintedCounts = new Array(TEAM_COUNT).fill(0); + + gridColors.forEach((teamId) => { + if (!Number.isInteger(teamId) || teamId < 0 || teamId >= TEAM_COUNT) { + return; + } + + paintedCounts[teamId] += 1; + }); + + const rankings = paintedCounts + .map((paintedCellCount, teamId) => ({ + rank: 0, + teamId, + teamName: TEAM_NAMES[teamId] ?? `チーム${teamId + 1}`, + paintRate: totalCells > 0 ? (paintedCellCount / totalCells) * 100 : 0, + })) + .sort((a, b) => { + if (b.paintRate !== a.paintRate) { + return b.paintRate - a.paintRate; + } + + return a.teamId - b.teamId; + }); + + let currentRank = 0; + let previousPaintRate: number | null = null; + const epsilon = 1e-9; + + rankings.forEach((item, index) => { + if (previousPaintRate === null || Math.abs(item.paintRate - previousPaintRate) > epsilon) { + currentRank = index + 1; + previousPaintRate = item.paintRate; + } + + item.rank = currentRank; + }); + + return { rankings }; + } } diff --git a/apps/server/src/domains/game/application/services/GameSessionLifecycleService.ts b/apps/server/src/domains/game/application/services/GameSessionLifecycleService.ts index 8eb3670..b4c0e61 100644 --- a/apps/server/src/domains/game/application/services/GameSessionLifecycleService.ts +++ b/apps/server/src/domains/game/application/services/GameSessionLifecycleService.ts @@ -3,7 +3,7 @@ * ゲームセッションの開始,参照,終了時クリーンアップを管理する */ import { config } from "@repo/shared"; -import type { gameTypes } from "@repo/shared"; +import type { gameTypes, GameResultPayload } from "@repo/shared"; import { logEvent } from "@server/logging/logger"; import { gameDomainLogEvents, logResults, logScopes } from "@server/logging/index"; import { GameRoomSession } from "./GameRoomSession"; @@ -32,7 +32,7 @@ roomId: string, playerIds: string[], onTick: (data: gameTypes.TickData) => void, - onGameEnd: () => void + onGameEnd: (payload: GameResultPayload) => void ) { if (this.sessions.has(roomId)) { logEvent(logScopes.GAME_SESSION_LIFECYCLE_SERVICE, { @@ -53,10 +53,10 @@ this.roomToPlayers.set(roomId, roomPlayerSet); this.sessions.set(roomId, session); - session.start(tickRate, onTick, () => { + session.start(tickRate, onTick, (payload) => { this.clearRoomPlayerIndex(roomId); this.sessions.delete(roomId); - onGameEnd(); + onGameEnd(payload); }); logEvent(logScopes.GAME_SESSION_LIFECYCLE_SERVICE, { diff --git a/apps/server/src/domains/game/application/useCases/startGameUseCase.ts b/apps/server/src/domains/game/application/useCases/startGameUseCase.ts index e7bfd81..bb7e8b4 100644 --- a/apps/server/src/domains/game/application/useCases/startGameUseCase.ts +++ b/apps/server/src/domains/game/application/useCases/startGameUseCase.ts @@ -16,6 +16,7 @@ | "publishUpdatePlayersToRoom" | "publishMapCellUpdatesToRoom" | "publishGameEndToRoom" + | "publishGameResultToRoom" | "publishGameStartToRoom" >; }; @@ -40,7 +41,7 @@ output.publishMapCellUpdatesToRoom(roomId, tickData.cellUpdates); } }, - () => { + (resultPayload) => { logEvent(logScopes.GAME_USE_CASE, { event: gameUseCaseLogEvents.GAME_END, result: logResults.EMITTED, @@ -48,6 +49,7 @@ reason: "duration_elapsed", }); output.publishGameEndToRoom(roomId); + output.publishGameResultToRoom(roomId, resultPayload); onGameEnd(); } ); diff --git a/apps/server/src/domains/game/entities/map/MapStore.ts b/apps/server/src/domains/game/entities/map/MapStore.ts index cd244c9..f116f44 100644 --- a/apps/server/src/domains/game/entities/map/MapStore.ts +++ b/apps/server/src/domains/game/entities/map/MapStore.ts @@ -38,4 +38,9 @@ public getAndClearUpdates(): gridMapTypes.CellUpdate[] { return drainPendingUpdates(this.pendingUpdates); } + + /** 現在のマップ塗り状態をスナップショットとして返す */ + public getGridColorsSnapshot(): number[] { + return [...this.gridColors]; + } } \ No newline at end of file diff --git a/apps/server/src/network/handlers/game/createGameOutputAdapter.ts b/apps/server/src/network/handlers/game/createGameOutputAdapter.ts index a81719e..bfa4b8f 100644 --- a/apps/server/src/network/handlers/game/createGameOutputAdapter.ts +++ b/apps/server/src/network/handlers/game/createGameOutputAdapter.ts @@ -6,6 +6,7 @@ import { protocol } from "@repo/shared"; import type { GameStartPayload, + GameResultPayload, PongPayload, roomTypes, CurrentPlayersPayload, @@ -40,6 +41,9 @@ publishGameEndToRoom: (roomId: RoomId) => { common.emitToRoom(roomId, protocol.SocketEvents.GAME_END); }, + publishGameResultToRoom: (roomId: RoomId, payload: GameResultPayload) => { + common.emitToRoom(roomId, protocol.SocketEvents.GAME_RESULT, payload); + }, publishGameStartToRoom: (roomId: RoomId, payload: GameStartPayload) => { common.emitToRoom(roomId, protocol.SocketEvents.GAME_START, payload); }, diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 974358b..67972ff 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -32,6 +32,8 @@ CurrentPlayersPayload, BombPlacedPayload, GameStartPayload, + GameResultPayload, + GameResultRanking, MovePayload, NewPlayerPayload, PingPayload, diff --git a/packages/shared/src/protocol/events.ts b/packages/shared/src/protocol/events.ts index 23c8a9e..c409c8d 100644 --- a/packages/shared/src/protocol/events.ts +++ b/packages/shared/src/protocol/events.ts @@ -36,6 +36,7 @@ PING: "ping", // クライアントからの時刻同期リクエスト(ラグ計算用) PONG: "pong", // サーバーからの現在時刻レスポンス GAME_END: "game-end", // 3分経過時のゲーム終了通知 + GAME_RESULT: "game-result", // 3分終了時の最終結果通知 } as const; /** @@ -81,6 +82,19 @@ serverTime: number; }; +/** GAME_RESULT イベントで送受信するランキング1行 */ +export type GameResultRanking = { + rank: number; + teamId: number; + teamName: string; + paintRate: number; +}; + +/** GAME_RESULT イベントで送受信する最終結果 */ +export type GameResultPayload = { + rankings: GameResultRanking[]; +}; + /** * ------------------------------------------------------------ * イベント方向ごとのペイロード対応表 @@ -116,6 +130,7 @@ [SocketEvents.BOMB_PLACED]: BombPlacedPayload; [SocketEvents.PONG]: PongPayload; [SocketEvents.GAME_END]: undefined; + [SocketEvents.GAME_RESULT]: GameResultPayload; }; /**