diff --git a/apps/client/src/scenes/game/application/runtime/GameSceneRuntime.ts b/apps/client/src/scenes/game/application/runtime/GameSceneRuntime.ts index 58a1db8..d5cab4c 100644 --- a/apps/client/src/scenes/game/application/runtime/GameSceneRuntime.ts +++ b/apps/client/src/scenes/game/application/runtime/GameSceneRuntime.ts @@ -7,7 +7,10 @@ import { AppearanceResolver } from "../AppearanceResolver"; import { GameNetworkSync } from "../GameNetworkSync"; import { GameLoop } from "../GameLoop"; -import { type GameSceneEventPorts, type GameSceneFactoryOptions } from "../orchestrators/GameSceneOrchestrator"; +import { + type GameSceneEventPorts, + type GameSceneFactoryOptions, +} from "../orchestrators/GameSceneOrchestrator"; import type { GamePlayers } from "../game.types"; import type { BombManager } from "../../entities/bomb/BombManager"; import type { MoveSender } from "../network/PlayerMoveSender"; @@ -177,7 +180,7 @@ this.lifecycleState = "destroyed"; if (this.tickerHandler) { - this.app.ticker.remove(this.tickerHandler); + this.app.ticker?.remove(this.tickerHandler); this.tickerHandler = null; } this.disposableRegistry.disposeAll(); diff --git a/apps/client/src/scenes/result/ResultScene.tsx b/apps/client/src/scenes/result/ResultScene.tsx index c1c9546..67873ce 100644 --- a/apps/client/src/scenes/result/ResultScene.tsx +++ b/apps/client/src/scenes/result/ResultScene.tsx @@ -5,6 +5,7 @@ */ import type { GameResultPayload } from "@repo/shared"; import type { CSSProperties } from "react"; +import { useEffect, useState } from "react"; import { config } from "../../config"; type Props = { @@ -12,6 +13,10 @@ onBackToTitle: () => void; }; +type GameResultWithFinalMap = GameResultPayload & { + finalGridColors?: number[]; +}; + const formatPaintRate = (value: number): string => `${value.toFixed(1)}%`; const ROW_GRID_TEMPLATE = "120px 1fr 180px"; @@ -66,43 +71,36 @@ pointerEvents: "none", }; +const MAP_BACKGROUND_LAYER_STYLE: CSSProperties = { + ...EFFECT_LAYER_BASE_STYLE, + display: "flex", + alignItems: "center", + justifyContent: "center", + zIndex: 0, + opacity: 0.9, +}; + +const BACKGROUND_DARK_OVERLAY_STYLE: CSSProperties = { + ...EFFECT_LAYER_BASE_STYLE, + background: "rgba(0, 0, 0, 0.62)", +}; + const CONFETTI_LAYER_STYLE: CSSProperties = { ...EFFECT_LAYER_BASE_STYLE, overflow: "hidden", zIndex: 0, }; -const BACKGROUND_IMAGE_LAYER_STYLE: CSSProperties = { - ...EFFECT_LAYER_BASE_STYLE, - backgroundImage: "url('/LobbyAni.webp')", - backgroundSize: "cover", - backgroundPosition: "center", - backgroundRepeat: "no-repeat", - opacity: 0.45, +const 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", }; -const BACKGROUND_DARK_OVERLAY_STYLE: CSSProperties = { - ...EFFECT_LAYER_BASE_STYLE, - background: - "linear-gradient(180deg, rgba(0, 0, 0, 0.56) 0%, rgba(0, 0, 0, 0.72) 55%, rgba(0, 0, 0, 0.82) 100%)", -}; - -const getPulseLayerStyle = ( - winnerColorRgba: string, - phase: "a" | "b", -): CSSProperties => ({ - ...EFFECT_LAYER_BASE_STYLE, - background: - phase === "a" - ? `radial-gradient(circle at 25% 25%, ${winnerColorRgba} 0%, rgba(0, 0, 0, 0) 55%)` - : `radial-gradient(circle at 80% 75%, ${winnerColorRgba} 0%, rgba(0, 0, 0, 0) 60%)`, - opacity: phase === "a" ? 0.22 : 0.16, - animation: - phase === "a" - ? "winnerPulseA 4.8s ease-in-out infinite" - : "winnerPulseB 6.2s ease-in-out infinite", -}); - const TABLE_STYLE: CSSProperties = { width: "100%", maxWidth: "720px", @@ -226,25 +224,27 @@ }; }; -const toRgba = (hex: string, alpha: number): string => { - const normalized = hex.replace("#", ""); - if (normalized.length !== 6) { - return `rgba(255, 255, 255, ${alpha})`; - } +const getMapWrapperStyle = (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)", +}); - const r = Number.parseInt(normalized.slice(0, 2), 16); - const g = Number.parseInt(normalized.slice(2, 4), 16); - const b = Number.parseInt(normalized.slice(4, 6), 16); - - if ([r, g, b].some((value) => Number.isNaN(value))) { - return `rgba(255, 255, 255, ${alpha})`; - } - - return `rgba(${r}, ${g}, ${b}, ${alpha})`; -}; +const getMapCellStyle = (teamId: number): CSSProperties => ({ + background: config.GAME_CONFIG.TEAM_COLORS[teamId] ?? "#1b1b1b", +}); /** 最終結果データを受け取り,順位一覧を表示する */ export const ResultScene = ({ result, onBackToTitle }: Props) => { + const [isRankingVisible, setIsRankingVisible] = useState(false); + + useEffect(() => { + setIsRankingVisible(false); + }, [result]); + if (!result) { return (
結果を読み込み中...
@@ -256,20 +256,31 @@ result.rankings[0]?.teamId; const winnerColor = config.GAME_CONFIG.TEAM_COLORS[winnerTeamId ?? -1] ?? "#888888"; - const winnerColorRgba = toRgba(winnerColor, 0.32); + const resultWithFinalMap = result as GameResultWithFinalMap; + 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 = resultWithFinalMap.finalGridColors?.[index]; + return typeof teamId === "number" ? teamId : -1; + }); return ( -
+
{ + if (isRankingVisible) { + return; + } + + setIsRankingVisible(true); + }} + > -
-
+
+
+ {finalGridColors.map((teamId, index) => ( + + ))} +
+
{Array.from({ length: CONFETTI_COUNT }, (_, index) => ( ))}
-
-
+
- + {isRankingVisible && ( +
+ + + +
+ )}

結果発表

-
-
- 順位 - チーム名 - 塗り率 -
+ {!isRankingVisible &&
Tap To Result
} -
- {result.rankings.map((row, index) => ( -
- {row.rank}位 - - - {row.teamName} - - {formatPaintRate(row.paintRate)} -
- ))} + {isRankingVisible && ( +
+
+ 順位 + チーム名 + 塗り率 +
+ +
+ {result.rankings.map((row, index) => ( +
+ {row.rank}位 + + + {row.teamName} + + + {formatPaintRate(row.paintRate)} + +
+ ))} +
-
+ )}
); diff --git a/apps/server/src/domains/game/application/services/gameResultCalculator.ts b/apps/server/src/domains/game/application/services/gameResultCalculator.ts index ac6bdcf..090cf71 100644 --- a/apps/server/src/domains/game/application/services/gameResultCalculator.ts +++ b/apps/server/src/domains/game/application/services/gameResultCalculator.ts @@ -7,7 +7,9 @@ import type { GameResultPayload } from "@repo/shared"; /** グリッド色配列からゲーム結果ペイロードを生成する */ -export const buildGameResultPayload = (gridColors: number[]): GameResultPayload => { +export const buildGameResultPayload = ( + gridColors: number[], +): GameResultPayload => { const { TEAM_COUNT } = config.GAME_CONFIG; const totalCells = gridColors.length; const paintedCounts = new Array(TEAM_COUNT).fill(0); @@ -40,7 +42,10 @@ const epsilon = 1e-9; rankings.forEach((item, index) => { - if (previousPaintRate === null || Math.abs(item.paintRate - previousPaintRate) > epsilon) { + if ( + previousPaintRate === null || + Math.abs(item.paintRate - previousPaintRate) > epsilon + ) { currentRank = index + 1; previousPaintRate = item.paintRate; } @@ -48,5 +53,8 @@ item.rank = currentRank; }); - return { rankings }; + return { + rankings, + finalGridColors: [...gridColors], + }; }; diff --git a/packages/shared/src/config/gameConfig.ts b/packages/shared/src/config/gameConfig.ts index 7c11d92..4522680 100644 --- a/packages/shared/src/config/gameConfig.ts +++ b/packages/shared/src/config/gameConfig.ts @@ -6,7 +6,7 @@ /** ゲーム全体で利用する共有設定値 */ export const GAME_CONFIG = { // ゲーム進行設定(クライアント/サーバー契約) - GAME_DURATION_SEC: 180, // 1ゲームの制限時間(3分 = 180秒) + GAME_DURATION_SEC: 30, // 1ゲームの制限時間(3分 = 180秒) GAME_START_DELAY_MS: 5000, // 開始通知から実際にゲーム進行を開始するまでの待機時間(ms) // ネットワーク同期設定(クライアント/サーバー契約) diff --git a/packages/shared/src/protocol/payloads/gamePayloads.ts b/packages/shared/src/protocol/payloads/gamePayloads.ts index 6e1d6b2..2d8c492 100644 --- a/packages/shared/src/protocol/payloads/gamePayloads.ts +++ b/packages/shared/src/protocol/payloads/gamePayloads.ts @@ -21,6 +21,7 @@ /** game-result イベントで送受信する最終結果 */ export type GameResultPayload = { rankings: GameResultRanking[]; + finalGridColors?: number[]; }; /** current-players で配信するプレイヤー全体スナップショット */