diff --git a/apps/client/src/scenes/game/input/GameInputOverlay.tsx b/apps/client/src/scenes/game/input/GameInputOverlay.tsx index 17e30b8..da31f27 100644 --- a/apps/client/src/scenes/game/input/GameInputOverlay.tsx +++ b/apps/client/src/scenes/game/input/GameInputOverlay.tsx @@ -3,6 +3,8 @@ * ゲーム入力UIレイヤーを構成する * ジョイスティック層と爆弾ボタン層を分離して配置する */ +import { useEffect, useMemo, useState } from "react"; +import { config } from "@client/config"; import { JoystickInputPresenter } from "./joystick/JoystickInputPresenter"; import { BombButton } from "./bomb/BombButton"; @@ -19,12 +21,82 @@ height: "100%", }; +const COOLDOWN_TICK_MS = 50; + /** 入力UIレイヤーを描画する */ -export const GameInputOverlay = ({ onJoystickInput, onPlaceBomb }: GameInputOverlayProps) => { +export const GameInputOverlay = ({ + onJoystickInput, + onPlaceBomb, +}: GameInputOverlayProps) => { + const bombCooldownMs = config.GAME_CONFIG.BOMB_COOLDOWN_MS; + const [lastBombPressedAt, setLastBombPressedAt] = useState( + null, + ); + const [nowMs, setNowMs] = useState(() => Date.now()); + + useEffect(() => { + if (lastBombPressedAt === null) { + return; + } + + const timerId = window.setInterval(() => { + setNowMs(Date.now()); + }, COOLDOWN_TICK_MS); + + return () => { + window.clearInterval(timerId); + }; + }, [lastBombPressedAt]); + + const cooldownState = useMemo(() => { + if (bombCooldownMs <= 0) { + return { + progress: 1, + isReady: true, + remainingSecText: null, + }; + } + + if (lastBombPressedAt === null) { + return { + progress: 1, + isReady: true, + remainingSecText: null, + }; + } + + const elapsed = nowMs - lastBombPressedAt; + const clampedElapsed = Math.max(0, Math.min(elapsed, bombCooldownMs)); + const progress = clampedElapsed / bombCooldownMs; + const remainingMs = Math.max(0, bombCooldownMs - clampedElapsed); + const isReady = remainingMs === 0; + + return { + progress, + isReady, + remainingSecText: isReady ? null : String(Math.ceil(remainingMs / 1000)), + }; + }, [bombCooldownMs, lastBombPressedAt, nowMs]); + + const handlePressBomb = () => { + if (!cooldownState.isReady) { + return; + } + + onPlaceBomb(); + setLastBombPressedAt(Date.now()); + setNowMs(Date.now()); + }; + return (
- +
); }; diff --git a/apps/client/src/scenes/game/input/bomb/BombButton.tsx b/apps/client/src/scenes/game/input/bomb/BombButton.tsx index 68bd4ad..05335b2 100644 --- a/apps/client/src/scenes/game/input/bomb/BombButton.tsx +++ b/apps/client/src/scenes/game/input/bomb/BombButton.tsx @@ -7,12 +7,26 @@ /** 爆弾設置ボタンの入力プロパティ */ type BombButtonProps = { onPress: () => void; + cooldownProgress: number; + isReady: boolean; + remainingSecText: string | null; +}; + +const BOMB_BUTTON_FRAME_STYLE: React.CSSProperties = { + position: "fixed", + right: "30px", + bottom: "34px", + width: "108px", + height: "108px", + borderRadius: "50%", + display: "flex", + alignItems: "center", + justifyContent: "center", + zIndex: 9999, + pointerEvents: "none", }; const BOMB_BUTTON_STYLE: React.CSSProperties = { - position: "fixed", - right: "36px", - bottom: "40px", width: "96px", height: "96px", borderRadius: "50%", @@ -21,16 +35,43 @@ color: "white", fontSize: "18px", fontWeight: "bold", - zIndex: 9999, pointerEvents: "auto", touchAction: "manipulation", + display: "flex", + alignItems: "center", + justifyContent: "center", }; /** 画面右下の爆弾設置ボタンを描画する */ -export const BombButton = ({ onPress }: BombButtonProps) => { +export const BombButton = ({ + onPress, + cooldownProgress, + isReady, + remainingSecText, +}: BombButtonProps) => { + const progressDeg = Math.max(0, Math.min(1, cooldownProgress)) * 360; + const frameStyle: React.CSSProperties = { + ...BOMB_BUTTON_FRAME_STYLE, + background: `conic-gradient(rgba(255,255,255,0.95) ${progressDeg}deg, rgba(255,255,255,0.2) ${progressDeg}deg 360deg)`, + }; + + const buttonStyle: React.CSSProperties = { + ...BOMB_BUTTON_STYLE, + background: isReady ? "rgba(220, 60, 60, 0.85)" : "rgba(110, 40, 40, 0.85)", + opacity: isReady ? 1 : 0.88, + cursor: isReady ? "pointer" : "not-allowed", + }; + return ( - +
+ +
); }; diff --git a/packages/shared/src/config/gameConfig.ts b/packages/shared/src/config/gameConfig.ts index f81bf92..b74abdc 100644 --- a/packages/shared/src/config/gameConfig.ts +++ b/packages/shared/src/config/gameConfig.ts @@ -9,20 +9,20 @@ GAME_DURATION_SEC: 30, // 1ゲームの制限時間(3分 = 180秒) // ネットワーク同期設定(クライアント/サーバー契約) - PLAYER_POSITION_UPDATE_MS: 50, // 座標送信間隔(20Hz) + PLAYER_POSITION_UPDATE_MS: 50, // 座標送信間隔(20Hz) // グリッド(マス)設定(クライアント/サーバー契約) - GRID_COLS: 20, // 横のマス数(グリッド単位) - GRID_ROWS: 20, // 縦のマス数(グリッド単位) + GRID_COLS: 20, // 横のマス数(グリッド単位) + GRID_ROWS: 20, // 縦のマス数(グリッド単位) // プレイヤー挙動設定(内部座標はグリッド単位、契約値) - PLAYER_RADIUS: 0.1, // プレイヤー半径(グリッド単位、目安: 0.05〜0.2) - PLAYER_SPEED: 3, // 1秒当たりの移動量(グリッド単位) + PLAYER_RADIUS: 0.1, // プレイヤー半径(グリッド単位、目安: 0.05〜0.2) + PLAYER_SPEED: 3, // 1秒当たりの移動量(グリッド単位) // 爆弾設定(内部座標はグリッド単位、時間はms、契約値) - BOMB_RADIUS_GRID: 1.5, // 爆風半径(グリッド単位、円形当たり判定) - BOMB_FUSE_MS: 1000, // 設置から爆発までの時間(ms) - BOMB_COOLDOWN_MS: 1200, // 設置後に次の爆弾を置けるまでの待機時間(ms) + BOMB_RADIUS_GRID: 1.5, // 爆風半径(グリッド単位、円形当たり判定) + BOMB_FUSE_MS: 1000, // 設置から爆発までの時間(ms) + BOMB_COOLDOWN_MS: 3000, // 設置後に次の爆弾を置けるまでの待機時間(ms) BOMB_DEDUP_EXTRA_TTL_MS: 1000, // 重複排除保持時間の追加分(ms) // チーム設定(クライアント/サーバー契約) @@ -30,7 +30,12 @@ } as const; /** teamId インデックス順のチーム名配列 */ -export const TEAM_NAMES = ["赤チーム", "青チーム", "緑チーム", "黄チーム"] as const; +export const TEAM_NAMES = [ + "赤チーム", + "青チーム", + "緑チーム", + "黄チーム", +] as const; /** TEAM_COUNT と TEAM_NAMES の整合性を検証する */ export const validateTeamConfig = (): void => { @@ -51,4 +56,4 @@ if (!Number.isInteger(teamId) || teamId < 0 || teamId >= TEAM_COUNT) { throw new Error(`Invalid teamId: ${teamId}`); } -}; \ No newline at end of file +};