diff --git a/apps/client/src/scenes/game/GameManager.ts b/apps/client/src/scenes/game/GameManager.ts index 3fd2f58..2b83226 100644 --- a/apps/client/src/scenes/game/GameManager.ts +++ b/apps/client/src/scenes/game/GameManager.ts @@ -37,13 +37,21 @@ this.timer.setGameStart(startTime); } + public getStartCountdownSec(): number { + return this.timer.getPreStartRemainingSec(); + } + + private canAcceptInput(): boolean { + return !this.isInputLocked && this.timer.isStarted(); + } + // 現在の残り秒数を取得する public getRemainingTime(): number { return this.timer.getRemainingTime(); } public placeBomb(): string | null { - if (this.isInputLocked) return null; + if (!this.canAcceptInput()) return null; if (!this.bombManager) return null; const placed = this.bombManager.placeBomb(); if (!placed) return null; @@ -115,7 +123,10 @@ * React側からジョイスティックの入力を受け取る */ public setJoystickInput(x: number, y: number) { - if (this.isInputLocked) return; + if (!this.canAcceptInput()) { + this.joystickInput = { x: 0, y: 0 }; + return; + } this.joystickInput = { x, y }; } @@ -194,7 +205,9 @@ } /** 爆弾当たり判定の評価結果を受け取り,後続処理へ接続する */ - private handleBombHitEvaluation(_result: BombHitEvaluationResult | undefined): void { + private handleBombHitEvaluation( + _result: BombHitEvaluationResult | undefined, + ): void { // 次フェーズでサーバー通知や被弾演出の接続に利用する } diff --git a/apps/client/src/scenes/game/GameScene.tsx b/apps/client/src/scenes/game/GameScene.tsx index 827b1d1..84663e1 100644 --- a/apps/client/src/scenes/game/GameScene.tsx +++ b/apps/client/src/scenes/game/GameScene.tsx @@ -13,14 +13,21 @@ /** メインゲーム画面を描画し入力をゲーム制御へ橋渡しする */ export function GameScene({ myId }: GameSceneProps) { - const { pixiContainerRef, timeLeft, handleInput, handlePlaceBomb } = useGameSceneController(myId); + const { + pixiContainerRef, + timeLeft, + startCountdownText, + handleInput, + handlePlaceBomb, + } = useGameSceneController(myId); return ( ); -} \ No newline at end of file +} diff --git a/apps/client/src/scenes/game/GameView.tsx b/apps/client/src/scenes/game/GameView.tsx index 3556e13..904118f 100644 --- a/apps/client/src/scenes/game/GameView.tsx +++ b/apps/client/src/scenes/game/GameView.tsx @@ -8,6 +8,7 @@ /** 表示と入力に必要なプロパティ */ type Props = { timeLeft: string; + startCountdownText: string | null; pixiContainerRef: React.RefObject; onJoystickInput: (x: number, y: number) => void; onPlaceBomb: () => void; @@ -45,20 +46,51 @@ zIndex: 1, }; -const TimerOverlay = ({ timeLeft }: { timeLeft: string }) =>
{timeLeft}
; +const START_COUNTDOWN_STYLE: React.CSSProperties = { + position: "absolute", + top: "50%", + left: "50%", + transform: "translate(-50%, -50%)", + zIndex: 30, + color: "white", + fontSize: "clamp(3rem, 14vw, 8rem)", + fontWeight: 900, + textShadow: "0 0 16px rgba(0,0,0,0.85)", + fontFamily: "monospace", + userSelect: "none", + WebkitUserSelect: "none", + pointerEvents: "none", +}; + +const TimerOverlay = ({ timeLeft }: { timeLeft: string }) => ( +
{timeLeft}
+); /** 画面描画と入力UIをまとめて描画する */ -export const GameView = ({ timeLeft, pixiContainerRef, onJoystickInput, onPlaceBomb }: Props) => { +export const GameView = ({ + timeLeft, + startCountdownText, + pixiContainerRef, + onJoystickInput, + onPlaceBomb, +}: Props) => { return (
{/* タイマーUIの表示 */} + {startCountdownText && ( +
{startCountdownText}
+ )} + {/* PixiJS Canvas 配置領域 */}
{/* 入力UI レイヤー */} - +
); }; diff --git a/apps/client/src/scenes/game/application/GameTimer.ts b/apps/client/src/scenes/game/application/GameTimer.ts index 7a63c81..b31d726 100644 --- a/apps/client/src/scenes/game/application/GameTimer.ts +++ b/apps/client/src/scenes/game/application/GameTimer.ts @@ -21,11 +21,37 @@ this.gameStartTime = startTime; } + public getStartTime(): number | null { + return this.gameStartTime; + } + + public isStarted(): boolean { + if (!this.gameStartTime) { + return false; + } + + return this.nowMsProvider() >= this.gameStartTime; + } + + public getPreStartRemainingSec(): number { + if (!this.gameStartTime) { + return 0; + } + + const remainingMs = this.gameStartTime - this.nowMsProvider(); + if (remainingMs <= 0) { + return 0; + } + + return Math.ceil(remainingMs / 1000); + } + public getRemainingTime(): number { if (!this.gameStartTime) return config.GAME_CONFIG.GAME_DURATION_SEC; const elapsedMs = this.nowMsProvider() - this.gameStartTime; - const remainingSec = config.GAME_CONFIG.GAME_DURATION_SEC - elapsedMs / 1000; + const remainingSec = + config.GAME_CONFIG.GAME_DURATION_SEC - elapsedMs / 1000; return Math.max(0, remainingSec); } diff --git a/apps/client/src/scenes/game/hooks/useGameSceneController.ts b/apps/client/src/scenes/game/hooks/useGameSceneController.ts index bc060f1..f2ddb49 100644 --- a/apps/client/src/scenes/game/hooks/useGameSceneController.ts +++ b/apps/client/src/scenes/game/hooks/useGameSceneController.ts @@ -14,7 +14,8 @@ return `${mins}:${secs.toString().padStart(2, "0")}`; }; -const getInitialTimeDisplay = () => formatRemainingTime(config.GAME_CONFIG.GAME_DURATION_SEC); +const getInitialTimeDisplay = () => + formatRemainingTime(config.GAME_CONFIG.GAME_DURATION_SEC); /** ゲーム画面の状態と入力ハンドラを提供するフック */ export const useGameSceneController = (myId: string | null) => { @@ -22,6 +23,9 @@ const gameManagerRef = useRef(null); const inputManagerRef = useRef(null); const [timeLeft, setTimeLeft] = useState(getInitialTimeDisplay()); + const [startCountdownText, setStartCountdownText] = useState( + null, + ); useEffect(() => { if (!pixiContainerRef.current || !myId) return; @@ -36,12 +40,18 @@ }, () => { manager.placeBomb(); - } + }, ); const timerInterval = setInterval(() => { const nextDisplay = formatRemainingTime(manager.getRemainingTime()); setTimeLeft((prev) => (prev === nextDisplay ? prev : nextDisplay)); + + const remainingSec = manager.getStartCountdownSec(); + const nextCountdown = remainingSec > 0 ? String(remainingSec) : null; + setStartCountdownText((prev) => + prev === nextCountdown ? prev : nextCountdown, + ); }, config.GAME_CONFIG.TIMER_DISPLAY_UPDATE_MS); return () => { @@ -49,6 +59,7 @@ gameManagerRef.current = null; inputManagerRef.current = null; clearInterval(timerInterval); + setStartCountdownText(null); }; }, [myId]); @@ -63,6 +74,7 @@ return { pixiContainerRef, timeLeft, + startCountdownText, handleInput, handlePlaceBomb, }; diff --git a/apps/server/src/config/index.ts b/apps/server/src/config/index.ts index 92a3533..414d45f 100644 --- a/apps/server/src/config/index.ts +++ b/apps/server/src/config/index.ts @@ -1,7 +1,13 @@ import { config as sharedConfig } from "@repo/shared"; +const sharedGameConfig = + sharedConfig.GAME_CONFIG as typeof sharedConfig.GAME_CONFIG & { + GAME_START_DELAY_MS?: number; + }; + const GAME_CONFIG = { - ...sharedConfig.GAME_CONFIG, + ...sharedGameConfig, + GAME_START_DELAY_MS: sharedGameConfig.GAME_START_DELAY_MS ?? 5000, MAX_PLAYERS_PER_ROOM: 100, } as const; diff --git a/apps/server/src/domains/game/application/services/GameRoomSession.ts b/apps/server/src/domains/game/application/services/GameRoomSession.ts index d6a2d72..107f3ab 100644 --- a/apps/server/src/domains/game/application/services/GameRoomSession.ts +++ b/apps/server/src/domains/game/application/services/GameRoomSession.ts @@ -9,6 +9,7 @@ logScopes, } from "@server/logging/index"; import type { gameTypes, GameResultPayload } from "@repo/shared"; +import { config } from "@server/config"; import { GameLoop } from "../../loop/GameLoop"; import { Player } from "../../entities/player/Player.js"; import { MapStore } from "../../entities/map/MapStore"; @@ -29,6 +30,7 @@ private bombStateStore: BombStateStore; private gameLoop: GameLoop | null = null; private startTime: number | undefined; + private startDelayTimer: NodeJS.Timeout | null = null; constructor( private roomId: string, @@ -61,7 +63,13 @@ return; } - this.startTime = Date.now(); + const gameStartDelayMs = ( + config.GAME_CONFIG as typeof config.GAME_CONFIG & { + GAME_START_DELAY_MS?: number; + } + ).GAME_START_DELAY_MS; + const startDelayMs = Math.max(0, gameStartDelayMs ?? 0); + this.startTime = Date.now() + startDelayMs; this.gameLoop = new GameLoop( this.roomId, tickRate, @@ -78,10 +86,28 @@ onBotPlaceBomb, ); - this.gameLoop.start(); + if (startDelayMs === 0) { + this.gameLoop.start(); + return; + } + + this.startDelayTimer = setTimeout(() => { + this.startDelayTimer = null; + this.gameLoop?.start(); + }, startDelayMs); } public movePlayer(id: string, x: number, y: number): void { + if (this.startTime && Date.now() < this.startTime) { + logEvent(logScopes.GAME_ROOM_SESSION, { + event: gameDomainLogEvents.MOVE, + result: logResults.IGNORED_INVALID_PAYLOAD, + roomId: this.roomId, + socketId: id, + }); + return; + } + const player = this.players.get(id); if (!player) { logEvent(logScopes.GAME_ROOM_SESSION, { @@ -131,6 +157,11 @@ } public dispose(): void { + if (this.startDelayTimer) { + clearTimeout(this.startDelayTimer); + this.startDelayTimer = null; + } + if (this.gameLoop) { this.gameLoop.stop(); this.gameLoop = null; diff --git a/packages/shared/src/config/gameConfig.ts b/packages/shared/src/config/gameConfig.ts index c9ac7ac..8f0ff0f 100644 --- a/packages/shared/src/config/gameConfig.ts +++ b/packages/shared/src/config/gameConfig.ts @@ -7,6 +7,7 @@ export const GAME_CONFIG = { // ゲーム進行設定(クライアント/サーバー契約) GAME_DURATION_SEC: 30, // 1ゲームの制限時間(3分 = 180秒) + GAME_START_DELAY_MS: 5000, // 開始通知から実際にゲーム進行を開始するまでの待機時間(ms) // ネットワーク同期設定(クライアント/サーバー契約) PLAYER_POSITION_UPDATE_MS: 50, // 座標送信間隔(20Hz)