diff --git a/apps/client/src/scenes/result/ResultScene.tsx b/apps/client/src/scenes/result/ResultScene.tsx index 69f90e1..d52f159 100644 --- a/apps/client/src/scenes/result/ResultScene.tsx +++ b/apps/client/src/scenes/result/ResultScene.tsx @@ -4,12 +4,12 @@ * 背景演出と順位表の切り替え表示を制御する */ import type { GameResultPayload } from "@repo/shared"; -import { useEffect, useState } from "react"; +import { useMemo } from "react"; 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 { ResultTabContent } from "./components/ResultTabContent"; +import { ResultTabBar } from "./components/ResultTabBar"; import { RESULT_BACKGROUND_DARK_OVERLAY_STYLE, RESULT_CONTENT_STYLE, @@ -19,23 +19,22 @@ RESULT_TITLE_STYLE, getResultTitleTextStyle, } from "./styles/resultStyles"; -import type { ResultViewMode } from "./types/resultViewMode"; +import { useResultView } from "./hooks/useResultView"; type Props = { result: GameResultPayload | null; onBackToTitle: () => void; }; -const formatPaintRate = (value: number): string => `${value.toFixed(1)}%`; - /** 最終結果データを受け取り,順位一覧を表示する */ export const ResultScene = ({ result, onBackToTitle }: Props) => { - const [viewMode, setViewMode] = useState("mapPreview"); - const isRankingVisible = viewMode === "ranking"; - - useEffect(() => { - setViewMode("mapPreview"); - }, [result]); + const { + activeTab, + isRankingVisible, + showMapPreview, + showRanking, + setActiveTab, + } = useResultView(result); if (!result) { return ( @@ -43,18 +42,27 @@ ); } - const winnerTeamId = - result.rankings.find((row) => row.rank === 1)?.teamId ?? - result.rankings[0]?.teamId; - const winnerColor = - config.GAME_CONFIG.TEAM_COLORS[winnerTeamId ?? -1] ?? "#888888"; + const winnerTeamId = useMemo( + () => + result.rankings.find((row) => row.rank === 1)?.teamId ?? + result.rankings[0]?.teamId, + [result.rankings], + ); + const winnerColor = useMemo( + () => config.GAME_CONFIG.TEAM_COLORS[winnerTeamId ?? -1] ?? "#888888", + [winnerTeamId], + ); const gridCols = config.GAME_CONFIG.GRID_COLS; const gridRows = config.GAME_CONFIG.GRID_ROWS; const totalCells = gridCols * gridRows; - const finalGridColors = Array.from({ length: totalCells }, (_, index) => { - const teamId = result.finalGridColors?.[index]; - return typeof teamId === "number" ? teamId : -1; - }); + const finalGridColors = useMemo( + () => + Array.from({ length: totalCells }, (_, index) => { + const teamId = result.finalGridColors?.[index]; + return typeof teamId === "number" ? teamId : -1; + }), + [result.finalGridColors, totalCells], + ); return (
@@ -83,7 +91,7 @@ {isRankingVisible && ( setViewMode("mapPreview")} + onShowMapPreview={showMapPreview} /> )} @@ -96,14 +104,15 @@ )} {isRankingVisible && ( - + )} - {isRankingVisible && result.playerStats && result.playerStats.length > 0 && ( - + {isRankingVisible && ( + )}
diff --git a/apps/client/src/scenes/result/components/ResultPlayerRankingTable.tsx b/apps/client/src/scenes/result/components/ResultPlayerRankingTable.tsx new file mode 100644 index 0000000..3f6059d --- /dev/null +++ b/apps/client/src/scenes/result/components/ResultPlayerRankingTable.tsx @@ -0,0 +1,76 @@ +/** + * ResultPlayerRankingTable + * リザルト画面のプレイヤー個人ランキング表を表示する + * 塗り回数またはヒット数でソートして順位表示を担当する + */ +import type { PlayerGameStats } from "@repo/shared"; +import { useMemo } from "react"; +import { config } from "@client/config"; +import { + RESULT_PLAYER_RANKING_HEADER_ROW_STYLE, + RESULT_PLAYER_RANKING_SCROLL_BODY_STYLE, + RESULT_TABLE_STYLE, + RESULT_RIGHT_ALIGN_STYLE, + RESULT_TEAM_CELL_STYLE, + getResultPlayerRankingBodyRowStyle, + getResultTeamColorDotStyle, + RESULT_PLAYER_RANKING_VALUE_STYLE, + getResultRankStyle, +} from "../styles/resultStyles"; + +type Props = { + playerStats: PlayerGameStats[]; + sortKey: "paintCount" | "bombHitCount"; + valueLabel: string; +}; + +/** プレイヤー個人ランキング表を描画するコンポーネント */ +export const ResultPlayerRankingTable = ({ + playerStats, + sortKey, + valueLabel, +}: Props) => { + const sorted = useMemo( + () => [...playerStats].sort((a, b) => b[sortKey] - a[sortKey]), + [playerStats, sortKey], + ); + + return ( +
+
+ 順位 + プレイヤー + {valueLabel} +
+ +
+ {sorted.map((player, index) => { + const rank = index + 1; + const value = player[sortKey]; + const displayValue = + sortKey === "paintCount" ? value.toLocaleString() : value; + + return ( +
+ {rank}位 + + + {player.playerName} + + + {displayValue} + +
+ ); + })} +
+
+ ); +}; diff --git a/apps/client/src/scenes/result/components/ResultTabBar.tsx b/apps/client/src/scenes/result/components/ResultTabBar.tsx new file mode 100644 index 0000000..4d91ca6 --- /dev/null +++ b/apps/client/src/scenes/result/components/ResultTabBar.tsx @@ -0,0 +1,48 @@ +/** + * ResultTabBar + * リザルト画面のタブバーを表示する + * チーム順位・塗り回数・ヒット数の3つのタブを提供する + */ +import type { ResultTabMode } from "../types/resultTabMode"; +import { + RESULT_TAB_BAR_CONTAINER_STYLE, + getResultTabButtonStyle, + RESULT_TAB_ICON_STYLE, + RESULT_TAB_LABEL_STYLE, +} from "../styles/resultStyles"; + +type Props = { + activeTab: ResultTabMode; + onTabChange: (tab: ResultTabMode) => void; +}; + +type TabInfo = { + mode: ResultTabMode; + icon: string; + label: string; +}; + +const TABS: TabInfo[] = [ + { mode: "teamRanking", icon: "🏆", label: "チーム順位" }, + { mode: "paintCount", icon: "🎨", label: "塗り回数" }, + { mode: "bombHits", icon: "💣", label: "ヒット数" }, +]; + +/** タブバーを描画するコンポーネント */ +export const ResultTabBar = ({ activeTab, onTabChange }: Props) => { + return ( +
+ {TABS.map((tab) => ( + + ))} +
+ ); +}; diff --git a/apps/client/src/scenes/result/components/ResultTabContent.tsx b/apps/client/src/scenes/result/components/ResultTabContent.tsx new file mode 100644 index 0000000..6941c67 --- /dev/null +++ b/apps/client/src/scenes/result/components/ResultTabContent.tsx @@ -0,0 +1,61 @@ +/** + * ResultTabContent + * リザルト画面のタブ内容表示を一元化する + */ +import type { GameResultRanking, PlayerGameStats } from "@repo/shared"; +import { ResultPlayerRankingTable } from "./ResultPlayerRankingTable"; +import { ResultRankingTable } from "./ResultRankingTable"; +import { RESULT_CONTENT_FADE_STYLE } from "../styles/resultStyles"; +import type { ResultTabMode } from "../types/resultTabMode"; + +type Props = { + activeTab: ResultTabMode; + rankings: GameResultRanking[]; + playerStats: PlayerGameStats[]; +}; + +const formatPaintRate = (value: number): string => `${value.toFixed(1)}%`; + +/** 選択中タブに応じたコンテンツを描画する */ +export const ResultTabContent = ({ + activeTab, + rankings, + playerStats, +}: Props) => { + if (activeTab === "teamRanking") { + return ( +
+ +
+ ); + } + + if (playerStats.length === 0) { + return null; + } + + if (activeTab === "paintCount") { + return ( +
+ +
+ ); + } + + return ( +
+ +
+ ); +}; diff --git a/apps/client/src/scenes/result/hooks/useResultView.ts b/apps/client/src/scenes/result/hooks/useResultView.ts new file mode 100644 index 0000000..b6e46b0 --- /dev/null +++ b/apps/client/src/scenes/result/hooks/useResultView.ts @@ -0,0 +1,37 @@ +/** + * useResultView + * リザルト画面の表示モードとタブ状態を管理する + */ +import { useEffect, useState } from "react"; +import type { ResultTabMode } from "../types/resultTabMode"; +import type { ResultViewMode } from "../types/resultViewMode"; + +type UseResultViewReturn = { + viewMode: ResultViewMode; + activeTab: ResultTabMode; + isRankingVisible: boolean; + showRanking: () => void; + showMapPreview: () => void; + setActiveTab: (tab: ResultTabMode) => void; +}; + +/** リザルト画面のビュー状態を返す */ +export const useResultView = (resultToken: unknown): UseResultViewReturn => { + const [viewMode, setViewMode] = useState("mapPreview"); + const [activeTab, setActiveTab] = useState("teamRanking"); + const isRankingVisible = viewMode === "ranking"; + + useEffect(() => { + setViewMode("mapPreview"); + setActiveTab("teamRanking"); + }, [resultToken]); + + return { + viewMode, + activeTab, + isRankingVisible, + showRanking: () => setViewMode("ranking"), + showMapPreview: () => setViewMode("mapPreview"), + setActiveTab, + }; +}; diff --git a/apps/client/src/scenes/result/styles/resultStyles.ts b/apps/client/src/scenes/result/styles/resultStyles.ts index 7e8d8b8..14048a8 100644 --- a/apps/client/src/scenes/result/styles/resultStyles.ts +++ b/apps/client/src/scenes/result/styles/resultStyles.ts @@ -244,7 +244,9 @@ }; /** プレイヤースタッツ表本文行のスタイルを返す */ -export const getResultPlayerStatsBodyRowStyle = (index: number): CSSProperties => ({ +export const getResultPlayerStatsBodyRowStyle = ( + index: number, +): CSSProperties => ({ display: "grid", gridTemplateColumns: RESULT_PLAYER_STATS_ROW_GRID_TEMPLATE, padding: "12px 16px", @@ -252,6 +254,87 @@ background: index % 2 === 0 ? "#171717" : "#1d1d1d", }); +/** タブバーコンテナのスタイル */ +export const RESULT_TAB_BAR_CONTAINER_STYLE: CSSProperties = { + display: "flex", + width: "100%", + maxWidth: "720px", + gap: "4px", + marginBottom: "16px", +}; + +/** タブボタンのスタイルを返す */ +export const getResultTabButtonStyle = (isActive: boolean): CSSProperties => ({ + flex: 1, + display: "flex", + flexDirection: "column", + alignItems: "center", + gap: "4px", + padding: "12px 8px", + background: isActive ? "rgba(80, 80, 80, 0.9)" : "rgba(50, 50, 50, 0.6)", + border: "none", + borderBottom: isActive ? "3px solid #FFD95A" : "3px solid transparent", + borderRadius: "8px 8px 0 0", + color: isActive ? "#fff" : "#aaa", + cursor: "pointer", + transition: "all 0.2s ease", + fontWeight: isActive ? 700 : 400, + backdropFilter: "blur(2px)", +}); + +/** タブアイコンのスタイル */ +export const RESULT_TAB_ICON_STYLE: CSSProperties = { + fontSize: "1.4rem", + lineHeight: 1, +}; + +/** タブラベルのスタイル */ +export const RESULT_TAB_LABEL_STYLE: CSSProperties = { + fontSize: "clamp(0.75rem, 2vw, 0.9rem)", + letterSpacing: "0.05em", + whiteSpace: "nowrap", +}; + +/** プレイヤーランキング表のグリッド列定義 */ +export const RESULT_PLAYER_RANKING_ROW_GRID_TEMPLATE = "100px 1fr 140px"; + +/** プレイヤーランキング表ヘッダー行のスタイル */ +export const RESULT_PLAYER_RANKING_HEADER_ROW_STYLE: CSSProperties = { + display: "grid", + gridTemplateColumns: RESULT_PLAYER_RANKING_ROW_GRID_TEMPLATE, + background: "#222", + padding: "12px 16px", + fontWeight: "bold", +}; + +/** プレイヤーランキング表本文スクロール領域のスタイル */ +export const RESULT_PLAYER_RANKING_SCROLL_BODY_STYLE: CSSProperties = { + maxHeight: "min(52dvh, 460px)", + overflowY: "auto", +}; + +/** プレイヤーランキング数値セルのスタイル */ +export const RESULT_PLAYER_RANKING_VALUE_STYLE: CSSProperties = { + textAlign: "right", + fontVariantNumeric: "tabular-nums", +}; + +/** プレイヤーランキング表本文行のスタイルを返す */ +export const getResultPlayerRankingBodyRowStyle = ( + index: number, +): CSSProperties => ({ + display: "grid", + gridTemplateColumns: RESULT_PLAYER_RANKING_ROW_GRID_TEMPLATE, + padding: "12px 16px", + borderTop: "1px solid #333", + background: index % 2 === 0 ? "#171717" : "#1d1d1d", +}); + +/** タブコンテンツのフェードアニメーション用スタイル */ +export const RESULT_CONTENT_FADE_STYLE: CSSProperties = { + animation: "tabContentFadeIn 0.2s ease-in", +}; + /** 紙吹雪1片のスタイルを返す */ export const getResultConfettiStyle = ( index: number, @@ -313,4 +396,8 @@ @keyframes titleGleam { 0%, 100% { transform: scale(1); filter: brightness(1); } 50% { transform: scale(1.03); filter: brightness(1.1); } +} +@keyframes tabContentFadeIn { + 0% { opacity: 0; transform: translateY(8px); } + 100% { opacity: 1; transform: translateY(0); } }`; diff --git a/apps/client/src/scenes/result/types/resultTabMode.ts b/apps/client/src/scenes/result/types/resultTabMode.ts new file mode 100644 index 0000000..0a32300 --- /dev/null +++ b/apps/client/src/scenes/result/types/resultTabMode.ts @@ -0,0 +1,7 @@ +/** + * resultTabMode + * リザルト画面のタブ表示種別を定義する + */ + +/** リザルト画面のタブモードを表す型 */ +export type ResultTabMode = "teamRanking" | "paintCount" | "bombHits";