diff --git a/apps/client/src/scenes/result/ResultScene.tsx b/apps/client/src/scenes/result/ResultScene.tsx index 71772ce..69f90e1 100644 --- a/apps/client/src/scenes/result/ResultScene.tsx +++ b/apps/client/src/scenes/result/ResultScene.tsx @@ -8,6 +8,7 @@ import { config } from "../../config"; import { ResultActionBar } from "./components/ResultActionBar"; import { ResultBackground } from "./components/ResultBackground"; +import { ResultPlayerStatsTable } from "./components/ResultPlayerStatsTable"; import { ResultRankingTable } from "./components/ResultRankingTable"; import { RESULT_BACKGROUND_DARK_OVERLAY_STYLE, @@ -100,6 +101,10 @@ formatPaintRate={formatPaintRate} /> )} + + {isRankingVisible && result.playerStats && result.playerStats.length > 0 && ( + + )} ); diff --git a/apps/client/src/scenes/result/components/ResultPlayerStatsTable.tsx b/apps/client/src/scenes/result/components/ResultPlayerStatsTable.tsx new file mode 100644 index 0000000..4ef41c3 --- /dev/null +++ b/apps/client/src/scenes/result/components/ResultPlayerStatsTable.tsx @@ -0,0 +1,64 @@ +/** + * ResultPlayerStatsTable + * リザルト画面のプレイヤー個人スタッツ表を表示する + * 各プレイヤーの塗り回数と爆弾ヒット回数の一覧描画を担当する + */ +import type { PlayerGameStats } from "@repo/shared"; +import { config } from "@client/config"; +import { + RESULT_PLAYER_STATS_HEADER_ROW_STYLE, + RESULT_PLAYER_STATS_SCROLL_BODY_STYLE, + RESULT_TABLE_STYLE, + RESULT_RIGHT_ALIGN_STYLE, + RESULT_TEAM_CELL_STYLE, + getResultPlayerStatsBodyRowStyle, + getResultTeamColorDotStyle, + RESULT_PLAYER_STATS_SECTION_TITLE_STYLE, + RESULT_PLAYER_STATS_VALUE_STYLE, +} from "../styles/resultStyles"; + +type Props = { + playerStats: PlayerGameStats[]; +}; + +/** プレイヤー個人スタッツ表を描画するコンポーネント */ +export const ResultPlayerStatsTable = ({ playerStats }: Props) => { + const sorted = [...playerStats].sort((a, b) => b.paintCount - a.paintCount); + + return ( + <> +

個人スタッツ

+
+
+ プレイヤー + 塗り回数 + ヒット数 +
+ +
+ {sorted.map((player, index) => ( +
+ + + {player.playerName} + + + {player.paintCount.toLocaleString()} + + + {player.bombHitCount} + +
+ ))} +
+
+ + ); +}; diff --git a/apps/client/src/scenes/result/styles/resultStyles.ts b/apps/client/src/scenes/result/styles/resultStyles.ts index 8df9dad..7e8d8b8 100644 --- a/apps/client/src/scenes/result/styles/resultStyles.ts +++ b/apps/client/src/scenes/result/styles/resultStyles.ts @@ -209,6 +209,49 @@ background: index % 2 === 0 ? "#171717" : "#1d1d1d", }); +/** プレイヤースタッツ表のグリッド列定義 */ +export const RESULT_PLAYER_STATS_ROW_GRID_TEMPLATE = "1fr 120px 120px"; + +/** プレイヤースタッツセクションタイトルのスタイル */ +export const RESULT_PLAYER_STATS_SECTION_TITLE_STYLE: CSSProperties = { + margin: "24px 0 10px 0", + fontSize: "clamp(1rem, 2.6vw, 1.3rem)", + fontWeight: 700, + letterSpacing: "0.08em", + color: "rgba(255, 255, 255, 0.88)", + textShadow: "0 2px 8px rgba(0, 0, 0, 0.4)", +}; + +/** プレイヤースタッツ表ヘッダー行のスタイル */ +export const RESULT_PLAYER_STATS_HEADER_ROW_STYLE: CSSProperties = { + display: "grid", + gridTemplateColumns: RESULT_PLAYER_STATS_ROW_GRID_TEMPLATE, + background: "#222", + padding: "12px 16px", + fontWeight: "bold", +}; + +/** プレイヤースタッツ表本文スクロール領域のスタイル */ +export const RESULT_PLAYER_STATS_SCROLL_BODY_STYLE: CSSProperties = { + maxHeight: "min(36dvh, 320px)", + overflowY: "auto", +}; + +/** プレイヤースタッツ数値セルのスタイル */ +export const RESULT_PLAYER_STATS_VALUE_STYLE: CSSProperties = { + textAlign: "right", + fontVariantNumeric: "tabular-nums", +}; + +/** プレイヤースタッツ表本文行のスタイルを返す */ +export const getResultPlayerStatsBodyRowStyle = (index: number): CSSProperties => ({ + display: "grid", + gridTemplateColumns: RESULT_PLAYER_STATS_ROW_GRID_TEMPLATE, + padding: "12px 16px", + borderTop: "1px solid #333", + background: index % 2 === 0 ? "#171717" : "#1d1d1d", +}); + /** 紙吹雪1片のスタイルを返す */ export const getResultConfettiStyle = ( index: number, diff --git a/apps/server/src/domains/game/GameManager.ts b/apps/server/src/domains/game/GameManager.ts index 2f0c8cf..c6aa198 100644 --- a/apps/server/src/domains/game/GameManager.ts +++ b/apps/server/src/domains/game/GameManager.ts @@ -101,6 +101,11 @@ this.lifecycleService.registerActiveBomb(registration); } + /** 指定爆弾の所有者の bombHitCount を加算する */ + recordBombHitForOwner(bombId: string): void { + this.lifecycleService.recordBombHitForOwner(bombId); + } + dispose(): void { this.lifecycleService.dispose(); } diff --git a/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts b/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts index ebfd42f..990f18e 100644 --- a/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts +++ b/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts @@ -128,6 +128,11 @@ shouldBroadcastBombHitReport(dedupeKey: string, nowMs: number): boolean; } +/** 被弾時に爆弾所有者のスタッツを更新するポート */ +export interface BombHitStatsPort { + recordBombHitForOwner(bombId: string): void; +} + /** 爆弾設置ユースケースの入力値 */ export type PlaceBombInput = { socketId: string; diff --git a/apps/server/src/domains/game/application/services/GameRoomSession.ts b/apps/server/src/domains/game/application/services/GameRoomSession.ts index f89c7da..6598066 100644 --- a/apps/server/src/domains/game/application/services/GameRoomSession.ts +++ b/apps/server/src/domains/game/application/services/GameRoomSession.ts @@ -84,6 +84,7 @@ onGameEnd: () => { const resultPayload = buildGameResultPayload( this.mapStore.getGridColorsSnapshot(), + Array.from(this.players.values()), ); this.dispose(); callbacks.onGameEnd(resultPayload); @@ -195,11 +196,26 @@ const ownerTeamId = player?.teamId ?? -1; this.bombStateStore.activeBombRegistry.registerBomb({ bombId: registration.bombId, + ownerPlayerId: registration.ownerPlayerId, x: registration.x, y: registration.y, explodeAtElapsedMs: registration.explodeAtElapsedMs, ownerTeamId, }); + this.bombStateStore.registerBombOwner( + registration.bombId, + registration.ownerPlayerId, + ); + } + + /** 指定爆弾の所有者の bombHitCount を加算する */ + public recordBombHitForOwner(bombId: string): void { + const ownerPlayerId = this.bombStateStore.getBombOwnerPlayerId(bombId); + if (!ownerPlayerId) return; + const owner = this.players.get(ownerPlayerId); + if (owner) { + owner.bombHitCount += 1; + } } public dispose(): void { diff --git a/apps/server/src/domains/game/application/services/GameSessionLifecycleService.ts b/apps/server/src/domains/game/application/services/GameSessionLifecycleService.ts index 6a66cdc..f6e826c 100644 --- a/apps/server/src/domains/game/application/services/GameSessionLifecycleService.ts +++ b/apps/server/src/domains/game/application/services/GameSessionLifecycleService.ts @@ -66,6 +66,11 @@ this.sessionRef.current?.registerActiveBomb(registration); } + /** 指定爆弾の所有者の bombHitCount を加算する */ + public recordBombHitForOwner(bombId: string): void { + this.sessionRef.current?.recordBombHitForOwner(bombId); + } + public startRoomSession( playerIds: string[], playerNamesById: Record, diff --git a/apps/server/src/domains/game/application/services/gameResultCalculator.ts b/apps/server/src/domains/game/application/services/gameResultCalculator.ts index 090cf71..c5287fb 100644 --- a/apps/server/src/domains/game/application/services/gameResultCalculator.ts +++ b/apps/server/src/domains/game/application/services/gameResultCalculator.ts @@ -4,11 +4,21 @@ * 塗り率計算,同率順位付け,チーム名解決を一箇所で扱う */ import { config } from "@server/config"; -import type { GameResultPayload } from "@repo/shared"; +import type { GameResultPayload, PlayerGameStats } from "@repo/shared"; -/** グリッド色配列からゲーム結果ペイロードを生成する */ +/** プレイヤースタッツ構築に必要な最小情報 */ +type PlayerStatsSource = { + id: string; + name: string; + teamId: number; + paintCount: number; + bombHitCount: number; +}; + +/** グリッド色配列とプレイヤー情報からゲーム結果ペイロードを生成する */ export const buildGameResultPayload = ( gridColors: number[], + players?: PlayerStatsSource[], ): GameResultPayload => { const { TEAM_COUNT } = config.GAME_CONFIG; const totalCells = gridColors.length; @@ -53,8 +63,17 @@ item.rank = currentRank; }); + const playerStats: PlayerGameStats[] | undefined = players?.map((p) => ({ + playerId: p.id, + playerName: p.name, + teamId: p.teamId, + paintCount: p.paintCount, + bombHitCount: p.bombHitCount, + })); + return { rankings, finalGridColors: [...gridColors], + ...(playerStats ? { playerStats } : {}), }; }; diff --git a/apps/server/src/domains/game/application/useCases/reportBombHitUseCase.ts b/apps/server/src/domains/game/application/useCases/reportBombHitUseCase.ts index e47b9eb..5f393de 100644 --- a/apps/server/src/domains/game/application/useCases/reportBombHitUseCase.ts +++ b/apps/server/src/domains/game/application/useCases/reportBombHitUseCase.ts @@ -6,6 +6,7 @@ import type { PlayerDeadOutputPort, BombHitReportValidationPort, + BombHitStatsPort, ReportBombHitInput, } from "../ports/gameUseCasePorts"; import { shouldPublishPlayerDeadFromBombHit } from "./reportBombHitValidation"; @@ -13,6 +14,7 @@ type ReportBombHitUseCaseParams = { roomId: string; validation: BombHitReportValidationPort; + stats: BombHitStatsPort; input: ReportBombHitInput; output: PlayerDeadOutputPort; }; @@ -21,6 +23,7 @@ export const reportBombHitUseCase = ({ roomId, validation, + stats, input, output, }: ReportBombHitUseCaseParams): void => { @@ -28,6 +31,8 @@ return; } + stats.recordBombHitForOwner(input.payload.bombId); + const deadPlayerId = input.socketId; output.publishPlayerDeadToOthersInRoom(roomId, deadPlayerId, { diff --git a/apps/server/src/domains/game/entities/bomb/ActiveBombRegistry.ts b/apps/server/src/domains/game/entities/bomb/ActiveBombRegistry.ts index 446b082..a4c38f2 100644 --- a/apps/server/src/domains/game/entities/bomb/ActiveBombRegistry.ts +++ b/apps/server/src/domains/game/entities/bomb/ActiveBombRegistry.ts @@ -7,6 +7,7 @@ /** アクティブ爆弾の状態表現 */ export type ActiveBomb = { bombId: string; + ownerPlayerId: string; x: number; y: number; explodeAtElapsedMs: number; diff --git a/apps/server/src/domains/game/entities/bomb/BombStateStore.ts b/apps/server/src/domains/game/entities/bomb/BombStateStore.ts index 19610bd..e36bb1a 100644 --- a/apps/server/src/domains/game/entities/bomb/BombStateStore.ts +++ b/apps/server/src/domains/game/entities/bomb/BombStateStore.ts @@ -18,6 +18,9 @@ /** アクティブ爆弾のライフサイクルを追跡するレジストリ */ public readonly activeBombRegistry = new ActiveBombRegistry(); + /** 爆弾IDから設置者プレイヤーIDを引くためのマップ(爆発後も保持する) */ + private bombOwnerMap = new Map(); + /** 爆弾設置イベントを配信すべきか判定し,配信時は重複排除状態を更新する */ public shouldBroadcastBombPlaced(dedupeKey: string, nowMs: number): boolean { return shouldBroadcastBombPlaced({ @@ -44,4 +47,14 @@ this.bombSerial = nextSerial; return bombId; } + + /** 爆弾IDと設置者プレイヤーIDを紐づけて記録する */ + public registerBombOwner(bombId: string, ownerPlayerId: string): void { + this.bombOwnerMap.set(bombId, ownerPlayerId); + } + + /** 爆弾IDから設置者プレイヤーIDを取得する */ + public getBombOwnerPlayerId(bombId: string): string | undefined { + return this.bombOwnerMap.get(bombId); + } } diff --git a/apps/server/src/domains/game/entities/map/MapStore.ts b/apps/server/src/domains/game/entities/map/MapStore.ts index 191417a..ec06150 100644 --- a/apps/server/src/domains/game/entities/map/MapStore.ts +++ b/apps/server/src/domains/game/entities/map/MapStore.ts @@ -21,9 +21,10 @@ /** * マスを塗り,色が変化した場合のみ差分キューに追加する + * @returns 色が実際に変わった場合 true */ - public paintCell(index: number, teamId: number): void { - paintCellIfChanged({ + public paintCell(index: number, teamId: number): boolean { + return paintCellIfChanged({ gridColors: this.gridColors, pendingUpdates: this.pendingUpdates, index, diff --git a/apps/server/src/domains/game/entities/map/mapPainting.ts b/apps/server/src/domains/game/entities/map/mapPainting.ts index 6135b8f..d30c0a5 100644 --- a/apps/server/src/domains/game/entities/map/mapPainting.ts +++ b/apps/server/src/domains/game/entities/map/mapPainting.ts @@ -11,17 +11,18 @@ teamId: number; }; -/** マップセルの色が変わった場合のみ差分へ追加する */ +/** マップセルの色が変わった場合のみ差分へ追加し,変更の有無を返す */ export const paintCellIfChanged = ({ gridColors, pendingUpdates, index, teamId, -}: PaintCellParams): void => { +}: PaintCellParams): boolean => { if (gridColors[index] === teamId) { - return; + return false; } gridColors[index] = teamId; pendingUpdates.push({ index, teamId }); + return true; }; diff --git a/apps/server/src/domains/game/entities/player/Player.ts b/apps/server/src/domains/game/entities/player/Player.ts index 2c658c8..79a4a3e 100644 --- a/apps/server/src/domains/game/entities/player/Player.ts +++ b/apps/server/src/domains/game/entities/player/Player.ts @@ -11,6 +11,11 @@ public y: number = 0; public teamId: number; + /** セルの色を塗り替えた回数 */ + public paintCount: number = 0; + /** 爆弾を敵に当てた回数 */ + public bombHitCount: number = 0; + // 💡 コンストラクタで teamId を受け取るように変更 constructor(id: string, name: string, teamId: number) { this.id = id; diff --git a/apps/server/src/domains/game/loop/GameLoop.ts b/apps/server/src/domains/game/loop/GameLoop.ts index a9c21ad..cb2b7d9 100644 --- a/apps/server/src/domains/game/loop/GameLoop.ts +++ b/apps/server/src/domains/game/loop/GameLoop.ts @@ -222,6 +222,13 @@ player.id as BotPlayerId, nowMs, ); + + // 爆弾所有者の bombHitCount を加算する + const owner = this.players.get(bomb.ownerPlayerId); + if (owner) { + owner.bombHitCount += 1; + } + onBotBombHit(player.id, bomb.bombId); } } @@ -299,7 +306,10 @@ for (const { gridIndex, player } of gridEntries) { if (gridIndex !== null && isCellPaintable(cellTeamMap, gridIndex)) { - this.mapStore.paintCell(gridIndex, player.teamId); + const changed = this.mapStore.paintCell(gridIndex, player.teamId); + if (changed) { + player.paintCount += 1; + } } } } diff --git a/apps/server/src/domains/room/application/ports/roomUseCasePorts.ts b/apps/server/src/domains/room/application/ports/roomUseCasePorts.ts index af90407..3de357d 100644 --- a/apps/server/src/domains/room/application/ports/roomUseCasePorts.ts +++ b/apps/server/src/domains/room/application/ports/roomUseCasePorts.ts @@ -5,6 +5,7 @@ import { domain } from "@repo/shared"; import type { BombHitReportValidationPort, + BombHitStatsPort, BombPlacementPort, DisconnectPlayerPort, MovePlayerPort, @@ -19,6 +20,7 @@ & MovePlayerPort & BombPlacementPort & BombHitReportValidationPort + & BombHitStatsPort & DisconnectPlayerPort; /** ルーム参加処理の実行結果 */ diff --git a/apps/server/src/network/handlers/game/gameEventOrchestrators.ts b/apps/server/src/network/handlers/game/gameEventOrchestrators.ts index 206ac49..bb6b4ae 100644 --- a/apps/server/src/network/handlers/game/gameEventOrchestrators.ts +++ b/apps/server/src/network/handlers/game/gameEventOrchestrators.ts @@ -143,6 +143,7 @@ reportBombHitUseCase({ roomId, validation: gameManager, + stats: gameManager, input: { socketId: deps.socketId, payload, diff --git a/packages/shared/src/protocol/eventPayloads.ts b/packages/shared/src/protocol/eventPayloads.ts index 3276144..dc59f39 100644 --- a/packages/shared/src/protocol/eventPayloads.ts +++ b/packages/shared/src/protocol/eventPayloads.ts @@ -35,6 +35,7 @@ PlayerDeadPayload, GameResultPayload, GameResultRanking, + PlayerGameStats, } from "./payloads/gamePayloads"; /** 被弾演出イベントのペイロード型を再公開する */ diff --git a/packages/shared/src/protocol/payloads/gamePayloads.ts b/packages/shared/src/protocol/payloads/gamePayloads.ts index 80752ad..48b115a 100644 --- a/packages/shared/src/protocol/payloads/gamePayloads.ts +++ b/packages/shared/src/protocol/payloads/gamePayloads.ts @@ -18,11 +18,24 @@ paintRate: number; }; +/** game-result イベントで送受信するプレイヤー個人スタッツ */ +export type PlayerGameStats = { + playerId: string; + playerName: string; + teamId: number; + /** セルの色を塗り替えた回数 */ + paintCount: number; + /** 爆弾を敵に当てた回数 */ + bombHitCount: number; +}; + /** game-result イベントで送受信する最終結果 */ export type GameResultPayload = { rankings: GameResultRanking[]; /** ゲーム終了時点のマップ色配列,index はセル位置に対応する */ finalGridColors?: number[]; + /** プレイヤー個人スタッツ一覧 */ + playerStats?: PlayerGameStats[]; }; /** current-players で配信するプレイヤー全体スナップショット */