diff --git a/apps/client/src/hooks/application/appFlowReducer.ts b/apps/client/src/hooks/application/appFlowReducer.ts new file mode 100644 index 0000000..d9e4478 --- /dev/null +++ b/apps/client/src/hooks/application/appFlowReducer.ts @@ -0,0 +1,63 @@ +/** + * appFlowReducer + * アプリフロー状態の遷移ロジックを管理する + * 画面状態と付随データの更新を一箇所で扱う + */ +import { domain } from "@repo/shared"; +import type { AppFlowAction, AppFlowData } from "../types/appFlowState"; + +/** アプリフロー状態の初期値 */ +export const initialAppFlowData: AppFlowData = { + scenePhase: domain.app.ScenePhase.TITLE, + room: null, + myId: null, + gameResult: null, +}; + +/** アプリフロー状態をアクションに応じて更新する */ +export const appFlowReducer = ( + state: AppFlowData, + action: AppFlowAction, +): AppFlowData => { + if (action.type === "setMyId") { + return { + ...state, + myId: action.myId, + }; + } + + if (action.type === "setRoomAndLobby") { + return { + ...state, + room: action.room, + scenePhase: domain.app.ScenePhase.LOBBY, + }; + } + + if (action.type === "setPlaying") { + return { + ...state, + gameResult: null, + scenePhase: domain.app.ScenePhase.PLAYING, + }; + } + + if (action.type === "setResult") { + return { + ...state, + gameResult: action.result, + scenePhase: domain.app.ScenePhase.RESULT, + }; + } + + if (action.type === "resetToTitle") { + return { + scenePhase: domain.app.ScenePhase.TITLE, + room: null, + myId: action.clearMyId ? null : state.myId, + gameResult: null, + }; + } + + return state; +}; diff --git a/apps/client/src/hooks/types/appFlowState.ts b/apps/client/src/hooks/types/appFlowState.ts new file mode 100644 index 0000000..16d3356 --- /dev/null +++ b/apps/client/src/hooks/types/appFlowState.ts @@ -0,0 +1,23 @@ +/** + * appFlowState + * アプリフローの状態型とアクション型を定義する + * 画面遷移とルーム同期の契約を集約する + */ +import { domain } from "@repo/shared"; +import type { GameResultPayload } from "@repo/shared"; + +/** アプリフローの状態データ型 */ +export type AppFlowData = { + scenePhase: domain.app.ScenePhaseType; + room: domain.room.Room | null; + myId: string | null; + gameResult: GameResultPayload | null; +}; + +/** アプリフローを更新するアクション型 */ +export type AppFlowAction = + | { type: "setMyId"; myId: string | null } + | { type: "setRoomAndLobby"; room: domain.room.Room } + | { type: "setPlaying" } + | { type: "setResult"; result: GameResultPayload } + | { type: "resetToTitle"; clearMyId: boolean }; diff --git a/apps/client/src/hooks/useAppFlow.ts b/apps/client/src/hooks/useAppFlow.ts index 9857beb..b90b523 100644 --- a/apps/client/src/hooks/useAppFlow.ts +++ b/apps/client/src/hooks/useAppFlow.ts @@ -3,11 +3,15 @@ * アプリ全体の画面遷移と参加フロー状態を管理するフック * 参加要求の成功失敗と接続状態を統合してシーンへ渡す */ -import { useCallback, useReducer, useRef, useState } from "react"; +import { useCallback, useReducer, useRef } from "react"; import { socketManager } from "@client/network/SocketManager"; import { domain } from "@repo/shared"; import { config } from "@client/config"; import type { GameResultPayload } from "@repo/shared"; +import { + appFlowReducer, + initialAppFlowData, +} from "./application/appFlowReducer"; import { useSocketSubscriptions } from "./useSocketSubscriptions"; /** アプリフロー管理フックの公開状態と操作を表す型 */ @@ -65,12 +69,10 @@ /** アプリ全体のシーン状態と参加要求フローを管理するフック */ export const useAppFlow = (): AppFlowState => { - const [scenePhase, setScenePhase] = useState( - domain.app.ScenePhase.TITLE, + const [appFlow, dispatchAppFlow] = useReducer( + appFlowReducer, + initialAppFlowData, ); - 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< @@ -160,15 +162,15 @@ const returnToTitle = useCallback( (options?: { leaveRoom?: boolean }) => { completeJoinRequest(); - setRoom(null); - setGameResult(null); - setScenePhase(domain.app.ScenePhase.TITLE); + dispatchAppFlow({ + type: "resetToTitle", + clearMyId: Boolean(options?.leaveRoom), + }); if (!options?.leaveRoom) { return; } - setMyId(null); socketManager.socket.disconnect(); socketManager.socket.connect(); }, @@ -177,17 +179,14 @@ useSocketSubscriptions({ completeJoinRequest, - setGameResult, - setMyId, - setRoom, - setScenePhase, + dispatchAppFlow, }); return { - scenePhase, - room, - myId, - gameResult, + scenePhase: appFlow.scenePhase, + room: appFlow.room, + myId: appFlow.myId, + gameResult: appFlow.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 8d8009f..7a874ba 100644 --- a/apps/client/src/hooks/useSocketSubscriptions.ts +++ b/apps/client/src/hooks/useSocketSubscriptions.ts @@ -7,6 +7,11 @@ import { socketManager } from "@client/network/SocketManager"; import { domain } from "@repo/shared"; import type { GameResultPayload } from "@repo/shared"; +import type { AppFlowAction } from "./types/appFlowState"; + +type UseSocketSubscriptionsParams = { + completeJoinRequest: () => void; + dispatchAppFlow: (action: AppFlowAction) => void; import type { Dispatch, SetStateAction } from "react"; type UseSocketSubscriptionsParams = { @@ -67,19 +72,17 @@ /** アプリ共通のソケット購読を登録しクリーンアップするフック */ export const useSocketSubscriptions = ({ completeJoinRequest, - setGameResult, - setMyId, - setRoom, - setScenePhase, + dispatchAppFlow, }: UseSocketSubscriptionsParams): void => { useEffect(() => { const handlers: AppSocketHandlers = { handleConnect: (id: string) => { - setMyId(id); + dispatchAppFlow({ type: "setMyId", myId: id }); }, handleRoomUpdate: (updatedRoom: domain.room.Room) => { completeJoinRequest(); + dispatchAppFlow({ type: "setRoomAndLobby", room: updatedRoom }); setRoom(updatedRoom); setScenePhase((currentPhase) => { if ( @@ -94,13 +97,11 @@ }, handleGameStart: () => { - setGameResult(null); - setScenePhase(domain.app.ScenePhase.PLAYING); + dispatchAppFlow({ type: "setPlaying" }); }, handleGameResult: (payload: GameResultPayload) => { - setGameResult(payload); - setScenePhase(domain.app.ScenePhase.RESULT); + dispatchAppFlow({ type: "setResult", result: payload }); }, }; @@ -114,5 +115,5 @@ unregisterRoomSubscriptions(handlers); unregisterGameSubscriptions(handlers); }; - }, [completeJoinRequest, setGameResult, setMyId, setRoom, setScenePhase]); + }, [completeJoinRequest, dispatchAppFlow]); }; diff --git a/apps/client/src/scenes/game/GameManager.ts b/apps/client/src/scenes/game/GameManager.ts index 22c388f..3d73b97 100644 --- a/apps/client/src/scenes/game/GameManager.ts +++ b/apps/client/src/scenes/game/GameManager.ts @@ -9,6 +9,7 @@ import { GameSessionFacade } from "./application/lifecycle/GameSessionFacade"; import { CombatLifecycleFacade } from "./application/combat/CombatLifecycleFacade"; import { DisposableRegistry } from "./application/lifecycle/DisposableRegistry"; +import { registerGameManagerDisposers } from "./application/lifecycle/registerGameManagerDisposers"; import { type GameSceneFactoryOptions } from "./application/orchestrators/GameSceneOrchestrator"; import { GameSceneRuntime } from "./application/runtime/GameSceneRuntime"; import { GameManagerBootstrapper } from "./application/runtime/GameManagerBootstrapper"; @@ -146,28 +147,17 @@ getSnapshot: () => this.getUiStateSnapshot(), }); this.disposableRegistry = new DisposableRegistry(); - this.disposableRegistry.add(() => { - this.uiStateSyncService.clear(); - }); - this.disposableRegistry.add(() => { - this.players = {}; - }); - this.disposableRegistry.add(() => { - this.sessionFacade.reset(); - }); - this.disposableRegistry.add(() => { - this.combatFacade.dispose(); - }); - this.disposableRegistry.add(() => { - if (this.lifecycleState.shouldDestroyApp()) { - this.app.destroy(true, { children: true }); - } - }); - this.disposableRegistry.add(() => { - this.runtime.destroy(); - }); - this.disposableRegistry.add(() => { - this.uiStateSyncService.stopTicker(); + registerGameManagerDisposers({ + disposableRegistry: this.disposableRegistry, + uiStateSyncService: this.uiStateSyncService, + resetPlayers: () => { + this.players = {}; + }, + sessionFacade: this.sessionFacade, + combatFacade: this.combatFacade, + runtime: this.runtime, + lifecycleState: this.lifecycleState, + app: this.app, }); } diff --git a/apps/client/src/scenes/game/application/lifecycle/registerGameManagerDisposers.ts b/apps/client/src/scenes/game/application/lifecycle/registerGameManagerDisposers.ts new file mode 100644 index 0000000..85befdf --- /dev/null +++ b/apps/client/src/scenes/game/application/lifecycle/registerGameManagerDisposers.ts @@ -0,0 +1,60 @@ +/** + * registerGameManagerDisposers + * GameManager の破棄処理登録を集約する + * 破棄順序を固定して安全にクリーンアップする + */ +import type { Application } from "pixi.js"; +import type { DisposableRegistry } from "./DisposableRegistry"; +import type { GameUiStateSyncService } from "../ui/GameUiStateSyncService"; +import type { GameSessionFacade } from "./GameSessionFacade"; +import type { CombatLifecycleFacade } from "../combat/CombatLifecycleFacade"; +import type { GameSceneRuntime } from "../runtime/GameSceneRuntime"; +import type { SceneLifecycleState } from "./SceneLifecycleState"; + +/** GameManager の破棄登録に必要な依存型 */ +export type RegisterGameManagerDisposersParams = { + disposableRegistry: DisposableRegistry; + uiStateSyncService: GameUiStateSyncService; + resetPlayers: () => void; + sessionFacade: GameSessionFacade; + combatFacade: CombatLifecycleFacade; + runtime: GameSceneRuntime; + lifecycleState: SceneLifecycleState; + app: Application; +}; + +/** GameManager の破棄処理を逆順実行前提で登録する */ +export const registerGameManagerDisposers = ({ + disposableRegistry, + uiStateSyncService, + resetPlayers, + sessionFacade, + combatFacade, + runtime, + lifecycleState, + app, +}: RegisterGameManagerDisposersParams): void => { + disposableRegistry.add(() => { + uiStateSyncService.clear(); + }); + disposableRegistry.add(() => { + resetPlayers(); + }); + disposableRegistry.add(() => { + sessionFacade.reset(); + }); + disposableRegistry.add(() => { + combatFacade.dispose(); + }); + disposableRegistry.add(() => { + if (lifecycleState.shouldDestroyApp()) { + app.destroy(true, { children: true }); + } + }); + disposableRegistry.add(() => { + runtime.destroy(); + }); + disposableRegistry.add(() => { + uiStateSyncService.stopTicker(); + }); +}; diff --git a/apps/client/src/scenes/lobby/LobbyScene.tsx b/apps/client/src/scenes/lobby/LobbyScene.tsx index 4249d00..1bdbb14 100644 --- a/apps/client/src/scenes/lobby/LobbyScene.tsx +++ b/apps/client/src/scenes/lobby/LobbyScene.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useState } from "react"; import { domain } from "@repo/shared"; +import { OVERLAY_BUTTON_STYLE } from "@client/scenes/shared/styles/overlayStyles"; type Props = { room: domain.room.Room | null; @@ -143,16 +144,9 @@ - - - + setViewMode("mapPreview")} + /> )} -

- 結果発表 +

+ 結果発表

- {!isRankingVisible &&
Tap To Result
} + {!isRankingVisible && ( +
Tap To Result
+ )} {isRankingVisible && ( -
-
- 順位 - チーム名 - 塗り率 -
- -
- {result.rankings.map((row, index) => ( -
- {row.rank}位 - - - {row.teamName} - - - {formatPaintRate(row.paintRate)} - -
- ))} -
-
+ )} diff --git a/apps/client/src/scenes/result/components/ResultActionBar.tsx b/apps/client/src/scenes/result/components/ResultActionBar.tsx new file mode 100644 index 0000000..952cb48 --- /dev/null +++ b/apps/client/src/scenes/result/components/ResultActionBar.tsx @@ -0,0 +1,29 @@ +/** + * ResultActionBar + * リザルト画面の操作ボタンを表示する + * タイトル遷移と最終マップ表示への切り替えを担当する + */ +import { + OVERLAY_BUTTON_ROW_STYLE, + OVERLAY_BUTTON_STYLE, +} from "@client/scenes/shared/styles/overlayStyles"; + +type Props = { + onBackToTitle: () => void; + onShowMapPreview: () => void; +}; + +/** リザルト画面の操作ボタン行を描画するコンポーネント */ +export const ResultActionBar = ({ onBackToTitle, onShowMapPreview }: Props) => { + return ( +
+ + + +
+ ); +}; diff --git a/apps/client/src/scenes/result/components/ResultBackground.tsx b/apps/client/src/scenes/result/components/ResultBackground.tsx new file mode 100644 index 0000000..c97fd4a --- /dev/null +++ b/apps/client/src/scenes/result/components/ResultBackground.tsx @@ -0,0 +1,52 @@ +/** + * ResultBackground + * リザルト画面の背景演出を表示する + * 最終マップと優勝チーム色の紙吹雪を描画する + */ +import { + RESULT_CONFETTI_COUNT, + RESULT_CONFETTI_LAYER_STYLE, + RESULT_MAP_BACKGROUND_LAYER_STYLE, + getResultConfettiStyle, + getResultMapCellStyle, + getResultMapWrapperStyle, +} from "../styles/resultStyles"; + +type Props = { + gridCols: number; + gridRows: number; + finalGridColors: number[]; + winnerColor: string; +}; + +/** 背景マップと紙吹雪を描画するコンポーネント */ +export const ResultBackground = ({ + gridCols, + gridRows, + finalGridColors, + winnerColor, +}: Props) => { + return ( + <> +
+
+ {finalGridColors.map((teamId, index) => ( + + ))} +
+
+ +
+ {Array.from({ length: RESULT_CONFETTI_COUNT }, (_, index) => ( + + ))} +
+ + ); +}; diff --git a/apps/client/src/scenes/result/components/ResultRankingTable.tsx b/apps/client/src/scenes/result/components/ResultRankingTable.tsx new file mode 100644 index 0000000..b0526e4 --- /dev/null +++ b/apps/client/src/scenes/result/components/ResultRankingTable.tsx @@ -0,0 +1,58 @@ +/** + * ResultRankingTable + * リザルト画面の順位表を表示する + * 順位,チーム名,塗り率の一覧描画を担当する + */ +import type { GameResultRanking } from "@repo/shared"; +import { config } from "@client/config"; +import { + RESULT_HEADER_ROW_STYLE, + RESULT_RANKING_SCROLL_BODY_STYLE, + RESULT_RATE_STYLE, + RESULT_RIGHT_ALIGN_STYLE, + RESULT_TABLE_STYLE, + RESULT_TEAM_CELL_STYLE, + getResultBodyRowStyle, + getResultRankStyle, + getResultTeamColorDotStyle, +} from "../styles/resultStyles"; + +type Props = { + rankings: GameResultRanking[]; + formatPaintRate: (value: number) => string; +}; + +/** 順位表を描画するコンポーネント */ +export const ResultRankingTable = ({ rankings, formatPaintRate }: Props) => { + return ( +
+
+ 順位 + チーム名 + 塗り率 +
+ +
+ {rankings.map((row, index) => ( +
+ {row.rank}位 + + + {row.teamName} + + + {formatPaintRate(row.paintRate)} + +
+ ))} +
+
+ ); +}; diff --git a/apps/client/src/scenes/result/styles/resultStyles.ts b/apps/client/src/scenes/result/styles/resultStyles.ts new file mode 100644 index 0000000..8df9dad --- /dev/null +++ b/apps/client/src/scenes/result/styles/resultStyles.ts @@ -0,0 +1,273 @@ +/** + * resultStyles + * リザルト画面専用のスタイル定義を集約する + * 背景演出,テーブル表示,文字装飾を一元管理する + */ +import type { CSSProperties } from "react"; +import { config } from "@client/config"; + +/** 順位表のグリッド列定義 */ +export const RESULT_ROW_GRID_TEMPLATE = "120px 1fr 180px"; + +/** 紙吹雪の表示数 */ +export const RESULT_CONFETTI_COUNT = 36; + +/** リザルト画面ルートのスタイル */ +export const RESULT_ROOT_STYLE: CSSProperties = { + width: "100vw", + height: "100dvh", + position: "relative", + overflow: "hidden", + background: "#111", + color: "white", + display: "flex", + flexDirection: "column", + alignItems: "center", + padding: "24px", + boxSizing: "border-box", +}; + +/** 結果発表タイトルのスタイル */ +export const RESULT_TITLE_STYLE: CSSProperties = { + margin: "0 0 20px 0", + fontSize: "clamp(1.8rem, 4.4vw, 2.6rem)", + fontFamily: "'Yu Mincho', 'Hiragino Mincho ProN', serif", + letterSpacing: "0.14em", + fontWeight: 800, + textShadow: "0 4px 14px rgba(0, 0, 0, 0.5)", + animation: "titleGleam 3.6s ease-in-out infinite", +}; + +/** リザルト画面本文レイヤーのスタイル */ +export const RESULT_CONTENT_STYLE: CSSProperties = { + width: "100%", + display: "flex", + flexDirection: "column", + alignItems: "center", + position: "relative", + zIndex: 1, +}; + +/** 背景エフェクト共通レイヤーの基底スタイル */ +export const RESULT_EFFECT_LAYER_BASE_STYLE: CSSProperties = { + position: "absolute", + inset: 0, + pointerEvents: "none", +}; + +/** 最終マップ背景レイヤーのスタイル */ +export const RESULT_MAP_BACKGROUND_LAYER_STYLE: CSSProperties = { + ...RESULT_EFFECT_LAYER_BASE_STYLE, + display: "flex", + alignItems: "center", + justifyContent: "center", + zIndex: 0, + opacity: 0.9, +}; + +/** 暗幕オーバーレイのスタイル */ +export const RESULT_BACKGROUND_DARK_OVERLAY_STYLE: CSSProperties = { + ...RESULT_EFFECT_LAYER_BASE_STYLE, + background: "rgba(0, 0, 0, 0.62)", +}; + +/** 紙吹雪レイヤーのスタイル */ +export const RESULT_CONFETTI_LAYER_STYLE: CSSProperties = { + ...RESULT_EFFECT_LAYER_BASE_STYLE, + overflow: "hidden", + zIndex: 0, +}; + +/** タップ案内文のスタイル */ +export const RESULT_TAP_GUIDE_STYLE: CSSProperties = { + marginTop: "6px", + fontSize: "clamp(1rem, 2.8vw, 1.35rem)", + letterSpacing: "0.14em", + fontWeight: 700, + color: "rgba(255, 255, 255, 0.92)", + textShadow: "0 2px 10px rgba(0, 0, 0, 0.6)", + animation: "tapPromptPulse 1.6s ease-in-out infinite", +}; + +/** 順位表コンテナのスタイル */ +export const RESULT_TABLE_STYLE: CSSProperties = { + width: "100%", + maxWidth: "720px", + border: "1px solid rgba(255, 255, 255, 0.18)", + borderRadius: "8px", + overflow: "hidden", + backdropFilter: "blur(2px)", + background: "rgba(16, 16, 16, 0.62)", +}; + +/** 順位表本文スクロール領域のスタイル */ +export const RESULT_RANKING_SCROLL_BODY_STYLE: CSSProperties = { + maxHeight: "min(52dvh, 420px)", + overflowY: "auto", +}; + +/** 順位表ヘッダー行のスタイル */ +export const RESULT_HEADER_ROW_STYLE: CSSProperties = { + display: "grid", + gridTemplateColumns: RESULT_ROW_GRID_TEMPLATE, + background: "#222", + padding: "12px 16px", + fontWeight: "bold", +}; + +/** 右寄せ表記のスタイル */ +export const RESULT_RIGHT_ALIGN_STYLE: CSSProperties = { textAlign: "right" }; + +/** 塗り率セルのスタイル */ +export const RESULT_RATE_STYLE: CSSProperties = { + textAlign: "right", + fontVariantNumeric: "tabular-nums", +}; + +/** 順位文字列の基底スタイル */ +export const RESULT_RANK_BASE_STYLE: CSSProperties = { + fontFamily: "serif", + fontWeight: 800, + letterSpacing: "0.04em", + fontVariantNumeric: "tabular-nums", + fontSize: "1.15rem", + textShadow: "0 2px 8px rgba(0, 0, 0, 0.42)", +}; + +/** チーム列の表示スタイル */ +export const RESULT_TEAM_CELL_STYLE: CSSProperties = { + display: "inline-flex", + alignItems: "center", + gap: "8px", +}; + +/** タイトル演出テキストのスタイルを返す */ +export const getResultTitleTextStyle = ( + winnerColor: string, +): CSSProperties => ({ + display: "inline-block", + background: `linear-gradient(120deg, #FFF8D9 0%, #FFD95A 42%, ${winnerColor} 100%)`, + WebkitBackgroundClip: "text", + backgroundClip: "text", + color: "transparent", + WebkitTextFillColor: "transparent", + WebkitTextStroke: "1px rgba(255, 255, 255, 0.22)", + filter: "drop-shadow(0 0 8px rgba(255, 220, 120, 0.35))", +}); + +/** 順位に応じた文字装飾スタイルを返す */ +export const getResultRankStyle = (rank: number): CSSProperties => { + if (rank === 1) { + return { + ...RESULT_RANK_BASE_STYLE, + color: "#FFD95A", + textShadow: + "0 0 10px rgba(255, 217, 90, 0.5), 0 2px 8px rgba(0, 0, 0, 0.42)", + }; + } + + if (rank === 2) { + return { + ...RESULT_RANK_BASE_STYLE, + color: "#E2E8F0", + textShadow: + "0 0 8px rgba(226, 232, 240, 0.35), 0 2px 8px rgba(0, 0, 0, 0.42)", + }; + } + + if (rank === 3) { + return { + ...RESULT_RANK_BASE_STYLE, + color: "#E7A977", + textShadow: + "0 0 8px rgba(231, 169, 119, 0.35), 0 2px 8px rgba(0, 0, 0, 0.42)", + }; + } + + return { + ...RESULT_RANK_BASE_STYLE, + color: "#F5F5F5", + }; +}; + +/** チーム色ドットのスタイルを返す */ +export const getResultTeamColorDotStyle = (color: string): CSSProperties => ({ + width: "10px", + height: "10px", + borderRadius: "9999px", + background: color, + border: "1px solid rgba(255, 255, 255, 0.35)", + flexShrink: 0, +}); + +/** 順位表本文行のスタイルを返す */ +export const getResultBodyRowStyle = (index: number): CSSProperties => ({ + display: "grid", + gridTemplateColumns: RESULT_ROW_GRID_TEMPLATE, + padding: "12px 16px", + borderTop: "1px solid #333", + background: index % 2 === 0 ? "#171717" : "#1d1d1d", +}); + +/** 紙吹雪1片のスタイルを返す */ +export const getResultConfettiStyle = ( + index: number, + winnerColor: string, +): CSSProperties => { + const leftPercent = (index * 19 + 7) % 100; + const size = 6 + (index % 5); + const durationSec = 6 + (index % 6) * 0.55; + const delaySec = -((index % 9) * 0.8); + const rotateStartDeg = (index * 37) % 360; + + return { + position: "absolute", + top: "-12%", + left: `${leftPercent}%`, + width: `${size}px`, + height: `${Math.round(size * 1.8)}px`, + background: winnerColor, + borderRadius: "2px", + opacity: 0.9, + transform: `rotate(${rotateStartDeg}deg)`, + animation: `confettiFall ${durationSec}s linear ${delaySec}s infinite, confettiSway ${2.4 + (index % 4) * 0.35}s ease-in-out ${delaySec}s infinite`, + boxShadow: "0 0 8px rgba(255, 255, 255, 0.18)", + }; +}; + +/** 最終マップ全体コンテナのスタイルを返す */ +export const getResultMapWrapperStyle = ( + cols: number, + rows: number, +): CSSProperties => ({ + width: `min(94vw, calc(92dvh * ${cols / rows}))`, + maxHeight: "92dvh", + aspectRatio: `${cols} / ${rows}`, + display: "grid", + gridTemplateColumns: `repeat(${cols}, 1fr)`, + border: "1px solid rgba(255, 255, 255, 0.14)", +}); + +/** マップ1セルのスタイルを返す */ +export const getResultMapCellStyle = (teamId: number): CSSProperties => ({ + background: config.GAME_CONFIG.TEAM_COLORS[teamId] ?? "#1b1b1b", +}); + +/** リザルト画面のキーフレーム定義を返す */ +export const RESULT_KEYFRAMES_CSS = `@keyframes confettiFall { + 0% { transform: translate3d(0, -8vh, 0) rotate(0deg); opacity: 0; } + 8% { opacity: 0.95; } + 100% { transform: translate3d(0, 115vh, 0) rotate(540deg); opacity: 0.95; } +} +@keyframes confettiSway { + 0%, 100% { margin-left: -8px; } + 50% { margin-left: 8px; } +} +@keyframes tapPromptPulse { + 0%, 100% { opacity: 0.5; transform: translateY(0); } + 50% { opacity: 1; transform: translateY(-3px); } +} +@keyframes titleGleam { + 0%, 100% { transform: scale(1); filter: brightness(1); } + 50% { transform: scale(1.03); filter: brightness(1.1); } +}`; diff --git a/apps/client/src/scenes/result/types/resultViewMode.ts b/apps/client/src/scenes/result/types/resultViewMode.ts new file mode 100644 index 0000000..f5ca5bd --- /dev/null +++ b/apps/client/src/scenes/result/types/resultViewMode.ts @@ -0,0 +1,7 @@ +/** + * resultViewMode + * リザルト画面で表示するビュー種別を定義する + */ + +/** リザルト画面の表示モードを表す型 */ +export type ResultViewMode = "mapPreview" | "ranking"; diff --git a/apps/client/src/scenes/shared/styles/overlayStyles.ts b/apps/client/src/scenes/shared/styles/overlayStyles.ts new file mode 100644 index 0000000..f965dbf --- /dev/null +++ b/apps/client/src/scenes/shared/styles/overlayStyles.ts @@ -0,0 +1,27 @@ +/** + * overlayStyles + * オーバーレイ系UIで再利用する共通スタイルを定義する + */ +import type { CSSProperties } from "react"; + +/** オーバーレイボタンの共通スタイル */ +export const OVERLAY_BUTTON_STYLE: CSSProperties = { + padding: "10px 14px", + fontSize: "0.95rem", + cursor: "pointer", + borderRadius: "8px", + border: "1px solid rgba(255,255,255,0.45)", + background: "rgba(0,0,0,0.55)", + color: "white", + fontWeight: 700, +}; + +/** オーバーレイボタン行の共通スタイル */ +export const OVERLAY_BUTTON_ROW_STYLE: CSSProperties = { + width: "100%", + display: "flex", + justifyContent: "space-between", + alignItems: "center", + gap: "10px", + marginBottom: "10px", +};