diff --git a/apps/client/src/scenes/result/ResultScene.tsx b/apps/client/src/scenes/result/ResultScene.tsx index 69f90e1..acd206e 100644 --- a/apps/client/src/scenes/result/ResultScene.tsx +++ b/apps/client/src/scenes/result/ResultScene.tsx @@ -8,10 +8,12 @@ import { config } from "../../config"; import { ResultActionBar } from "./components/ResultActionBar"; import { ResultBackground } from "./components/ResultBackground"; -import { ResultPlayerStatsTable } from "./components/ResultPlayerStatsTable"; +import { ResultPlayerRankingTable } from "./components/ResultPlayerRankingTable"; import { ResultRankingTable } from "./components/ResultRankingTable"; +import { ResultTabBar } from "./components/ResultTabBar"; import { RESULT_BACKGROUND_DARK_OVERLAY_STYLE, + RESULT_CONTENT_FADE_STYLE, RESULT_CONTENT_STYLE, RESULT_KEYFRAMES_CSS, RESULT_ROOT_STYLE, @@ -19,6 +21,7 @@ RESULT_TITLE_STYLE, getResultTitleTextStyle, } from "./styles/resultStyles"; +import type { ResultTabMode } from "./types/resultTabMode"; import type { ResultViewMode } from "./types/resultViewMode"; type Props = { @@ -31,10 +34,12 @@ /** 最終結果データを受け取り,順位一覧を表示する */ export const ResultScene = ({ result, onBackToTitle }: Props) => { const [viewMode, setViewMode] = useState("mapPreview"); + const [activeTab, setActiveTab] = useState("teamRanking"); const isRankingVisible = viewMode === "ranking"; useEffect(() => { setViewMode("mapPreview"); + setActiveTab("teamRanking"); }, [result]); if (!result) { @@ -96,15 +101,43 @@ )} {isRankingVisible && ( - + )} - {isRankingVisible && result.playerStats && result.playerStats.length > 0 && ( - + {isRankingVisible && activeTab === "teamRanking" && ( +
+ +
)} + + {isRankingVisible && + activeTab === "paintCount" && + result.playerStats && + result.playerStats.length > 0 && ( +
+ +
+ )} + + {isRankingVisible && + activeTab === "bombHits" && + result.playerStats && + result.playerStats.length > 0 && ( +
+ +
+ )} ); 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..82e7689 --- /dev/null +++ b/apps/client/src/scenes/result/components/ResultPlayerRankingTable.tsx @@ -0,0 +1,72 @@ +/** + * ResultPlayerRankingTable + * リザルト画面のプレイヤー個人ランキング表を表示する + * 塗り回数またはヒット数でソートして順位表示を担当する + */ +import type { PlayerGameStats } from "@repo/shared"; +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 = [...playerStats].sort((a, b) => b[sortKey] - a[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/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";