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 で配信するプレイヤー全体スナップショット */