diff --git a/apps/client/src/scenes/game/GameManager.ts b/apps/client/src/scenes/game/GameManager.ts index a250d36..8f627c9 100644 --- a/apps/client/src/scenes/game/GameManager.ts +++ b/apps/client/src/scenes/game/GameManager.ts @@ -21,6 +21,10 @@ type MoveSender, } from "./application/network/PlayerMoveSender"; import type { GamePlayers } from "./application/game.types"; +import { + GameUiStateSyncService, + type GameUiState, +} from "./application/ui/GameUiStateSyncService"; /** GameManager の依存注入オプション型 */ export type GameManagerDependencies = { @@ -31,12 +35,8 @@ sceneFactories?: GameSceneFactoryOptions; }; -/** GameScene の UI 表示に必要な状態 */ -export type GameUiState = { - remainingTimeSec: number; - startCountdownSec: number; - isInputEnabled: boolean; -}; +/** GameScene の UI 表示状態型を外部参照向けに再公開する */ +export type { GameUiState } from "./application/ui/GameUiStateSyncService"; /** ゲームシーンの実行ライフサイクルを管理するマネージャー */ export class GameManager { @@ -50,8 +50,7 @@ private gameEventFacade: GameEventFacade; private combatFacade: CombatLifecycleFacade; private lifecycleState: SceneLifecycleState; - private uiStateListeners = new Set<(state: GameUiState) => void>(); - private lastUiState: GameUiState | null = null; + private uiStateSyncService: GameUiStateSyncService; public getStartCountdownSec(): number { return this.sessionFacade.getStartCountdownSec(); @@ -73,8 +72,12 @@ public lockInput(): () => void { this.runtime.clearJoystickInput(); const release = this.sessionFacade.lockInput(); - this.emitUiStateIfChanged(true); - return release; + this.uiStateSyncService.emitIfChanged(); + + return () => { + release(); + this.uiStateSyncService.emitIfChanged(); + }; } constructor( @@ -95,6 +98,7 @@ this.gameEventFacade = new GameEventFacade({ onGameStart: (startTime) => { this.sessionFacade.setGameStart(startTime); + this.uiStateSyncService.emitIfChanged(); }, getBombManager: () => this.runtime.getBombManager(), }); @@ -115,22 +119,27 @@ gameActionSender, moveSender, getElapsedMs: () => this.sessionFacade.getElapsedMs(), - onGameStart: this.gameEventFacade.handleGameStart.bind(this.gameEventFacade), - onGameEnd: this.lockInput.bind(this), - onBombPlacedFromOthers: (payload) => { - this.gameEventFacade.handleBombPlacedFromOthers(payload); - }, - onBombPlacedAckFromNetwork: (payload) => { - this.gameEventFacade.handleBombPlacedAck(payload); - }, - onPlayerDeadFromNetwork: (payload) => { - this.combatFacade.handleNetworkPlayerDead(payload); - }, - onBombExploded: (payload) => { - this.combatFacade.handleBombExploded(payload); + eventPorts: { + onGameStart: this.gameEventFacade.handleGameStart.bind(this.gameEventFacade), + onGameEnd: this.lockInput.bind(this), + onBombPlacedFromOthers: (payload) => { + this.gameEventFacade.handleBombPlacedFromOthers(payload); + }, + onBombPlacedAckFromNetwork: (payload) => { + this.gameEventFacade.handleBombPlacedAck(payload); + }, + onPlayerDeadFromNetwork: (payload) => { + this.combatFacade.handleNetworkPlayerDead(payload); + }, + onBombExploded: (payload) => { + this.combatFacade.handleBombExploded(payload); + }, }, sceneFactories, }); + this.uiStateSyncService = new GameUiStateSyncService({ + getSnapshot: () => this.getUiStateSnapshot(), + }); } /** @@ -160,7 +169,8 @@ // メインループの登録 this.app.ticker.add(this.tick); this.lifecycleState.markInitialized(); - this.emitUiStateIfChanged(true); + this.uiStateSyncService.startTicker(); + this.uiStateSyncService.emitIfChanged(true); } /** @@ -175,17 +185,11 @@ */ private tick = (ticker: Ticker) => { this.runtime.tick(ticker); - this.emitUiStateIfChanged(); }; /** UI状態購読を登録し,解除関数を返す */ public subscribeUiState(listener: (state: GameUiState) => void): () => void { - this.uiStateListeners.add(listener); - listener(this.getUiStateSnapshot()); - - return () => { - this.uiStateListeners.delete(listener); - }; + return this.uiStateSyncService.subscribe(listener); } private getUiStateSnapshot(): GameUiState { @@ -196,33 +200,12 @@ }; } - private emitUiStateIfChanged(force = false): void { - if (this.uiStateListeners.size === 0 && !force) { - return; - } - - const snapshot = this.getUiStateSnapshot(); - if ( - !force - && this.lastUiState - && this.lastUiState.remainingTimeSec === snapshot.remainingTimeSec - && this.lastUiState.startCountdownSec === snapshot.startCountdownSec - && this.lastUiState.isInputEnabled === snapshot.isInputEnabled - ) { - return; - } - - this.lastUiState = snapshot; - this.uiStateListeners.forEach((listener) => { - listener(snapshot); - }); - } - /** * クリーンアップ処理(コンポーネントアンマウント時) */ public destroy() { this.lifecycleState.markDestroyed(); + this.uiStateSyncService.stopTicker(); if (this.lifecycleState.shouldDestroyApp()) { this.app.destroy(true, { children: true }); } @@ -230,7 +213,6 @@ this.combatFacade.dispose(); this.sessionFacade.reset(); this.players = {}; - this.uiStateListeners.clear(); - this.lastUiState = null; + this.uiStateSyncService.clear(); } } diff --git a/apps/client/src/scenes/game/GameView.tsx b/apps/client/src/scenes/game/GameView.tsx index b84cb17..9120496 100644 --- a/apps/client/src/scenes/game/GameView.tsx +++ b/apps/client/src/scenes/game/GameView.tsx @@ -4,6 +4,12 @@ * タイマー表示,PixiJSの描画領域,入力UIの配置のみを担当する */ import { GameInputOverlay } from "./input/GameInputOverlay"; +import { + PIXI_LAYER_STYLE, + ROOT_STYLE, + START_COUNTDOWN_STYLE, + TIMER_STYLE, +} from "./styles/GameView.styles"; /** 表示と入力に必要なプロパティ */ type Props = { @@ -15,54 +21,6 @@ onPlaceBomb: () => boolean; }; -const ROOT_STYLE: React.CSSProperties = { - width: "100vw", - height: "100vh", - overflow: "hidden", - position: "relative", - backgroundColor: "#000", - userSelect: "none", - WebkitUserSelect: "none", -}; - -const TIMER_STYLE: React.CSSProperties = { - position: "absolute", - top: "20px", - left: "50%", - transform: "translateX(-50%)", - zIndex: 10, - color: "white", - fontSize: "32px", - fontWeight: "bold", - textShadow: "2px 2px 4px rgba(0,0,0,0.5)", - fontFamily: "monospace", - userSelect: "none", - WebkitUserSelect: "none", -}; - -const PIXI_LAYER_STYLE: React.CSSProperties = { - position: "absolute", - top: 0, - left: 0, - zIndex: 1, -}; - -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}
); diff --git a/apps/client/src/scenes/game/application/orchestrators/GameSceneOrchestrator.ts b/apps/client/src/scenes/game/application/orchestrators/GameSceneOrchestrator.ts index 8e43682..c670f80 100644 --- a/apps/client/src/scenes/game/application/orchestrators/GameSceneOrchestrator.ts +++ b/apps/client/src/scenes/game/application/orchestrators/GameSceneOrchestrator.ts @@ -41,6 +41,16 @@ onBombExploded: (payload: BombExplodedPayload) => void; }; +/** シーン層で扱うイベント通知ポート群 */ +export type GameSceneEventPorts = { + onGameStart: (startTime: number) => void; + onGameEnd: () => void; + onBombPlacedFromOthers: (payload: BombPlacedPayload) => void; + onBombPlacedAckFromNetwork: (payload: BombPlacedAckPayload) => void; + onPlayerDeadFromNetwork: (payload: PlayerDeadPayload) => void; + onBombExploded: (payload: BombExplodedPayload) => void; +}; + /** GameLoop 生成入力型 */ export type CreateGameLoopOptions = { app: Application; @@ -69,12 +79,7 @@ getElapsedMs: () => number; getJoystickInput: () => { x: number; y: number }; moveSender: MoveSender; - onGameStart: (startTime: number) => void; - onGameEnd: () => void; - onBombPlacedFromOthers: (payload: BombPlacedPayload) => void; - onBombPlacedAckFromNetwork: (payload: BombPlacedAckPayload) => void; - onPlayerDeadFromNetwork: (payload: PlayerDeadPayload) => void; - onBombExploded: (payload: BombExplodedPayload) => void; + eventPorts: GameSceneEventPorts; factories?: GameSceneFactoryOptions; }; @@ -96,12 +101,7 @@ private readonly getElapsedMs: () => number; private readonly getJoystickInput: () => { x: number; y: number }; private readonly moveSender: MoveSender; - private readonly onGameStart: (startTime: number) => void; - private readonly onGameEnd: () => void; - private readonly onBombPlacedFromOthers: (payload: BombPlacedPayload) => void; - private readonly onBombPlacedAckFromNetwork: (payload: BombPlacedAckPayload) => void; - private readonly onPlayerDeadFromNetwork: (payload: PlayerDeadPayload) => void; - private readonly onBombExploded: (payload: BombExplodedPayload) => void; + private readonly eventPorts: GameSceneEventPorts; private readonly createNetworkSync: (options: CreateNetworkSyncOptions) => GameNetworkSync; private readonly createBombManager: (options: CreateBombManagerOptions) => BombManager; private readonly createGameLoop: (options: CreateGameLoopOptions) => GameLoop; @@ -115,12 +115,7 @@ getElapsedMs, getJoystickInput, moveSender, - onGameStart, - onGameEnd, - onBombPlacedFromOthers, - onBombPlacedAckFromNetwork, - onPlayerDeadFromNetwork, - onBombExploded, + eventPorts, factories, }: GameSceneOrchestratorOptions) { this.app = app; @@ -131,12 +126,7 @@ this.getElapsedMs = getElapsedMs; this.getJoystickInput = getJoystickInput; this.moveSender = moveSender; - this.onGameStart = onGameStart; - this.onGameEnd = onGameEnd; - this.onBombPlacedFromOthers = onBombPlacedFromOthers; - this.onBombPlacedAckFromNetwork = onBombPlacedAckFromNetwork; - this.onPlayerDeadFromNetwork = onPlayerDeadFromNetwork; - this.onBombExploded = onBombExploded; + this.eventPorts = eventPorts; this.createNetworkSync = factories?.createNetworkSync ?? ((options) => new GameNetworkSync(options)); this.createBombManager = factories?.createBombManager ?? ((options) => new BombManager(options)); this.createGameLoop = factories?.createGameLoop ?? ((options) => new GameLoop(options)); @@ -172,11 +162,11 @@ myId: this.myId, gameMap, appearanceResolver: this.appearanceResolver, - onGameStart: this.onGameStart, - onGameEnd: this.onGameEnd, - onBombPlacedFromOthers: this.onBombPlacedFromOthers, - onBombPlacedAckFromNetwork: this.onBombPlacedAckFromNetwork, - onPlayerDeadFromNetwork: this.onPlayerDeadFromNetwork, + onGameStart: this.eventPorts.onGameStart, + onGameEnd: this.eventPorts.onGameEnd, + onBombPlacedFromOthers: this.eventPorts.onBombPlacedFromOthers, + onBombPlacedAckFromNetwork: this.eventPorts.onBombPlacedAckFromNetwork, + onPlayerDeadFromNetwork: this.eventPorts.onPlayerDeadFromNetwork, }); networkSync.bind(); return networkSync; @@ -190,7 +180,7 @@ myId: this.myId, getElapsedMs: this.getElapsedMs, appearanceResolver: this.appearanceResolver, - onBombExploded: this.onBombExploded, + onBombExploded: this.eventPorts.onBombExploded, }); } diff --git a/apps/client/src/scenes/game/application/presentation/GameUiPresenter.ts b/apps/client/src/scenes/game/application/presentation/GameUiPresenter.ts new file mode 100644 index 0000000..b9b8cec --- /dev/null +++ b/apps/client/src/scenes/game/application/presentation/GameUiPresenter.ts @@ -0,0 +1,25 @@ +/** + * GameUiPresenter + * ゲーム画面の表示用データ変換を提供する + * 残り時間と開始カウントダウンの表示生成を扱う + */ +import { config } from "@client/config"; + +/** 残り秒数を mm:ss 形式へ変換する */ +export const formatRemainingTime = (remainingSec: number): string => { + const mins = Math.floor(remainingSec / 60); + const secs = Math.floor(remainingSec % 60); + return `${mins}:${secs.toString().padStart(2, "0")}`; +}; + +/** 開始カウントダウン表示文字列を生成する */ +export const buildStartCountdownText = ( + remainingSec: number, +): string | null => { + return remainingSec > 0 ? String(remainingSec) : null; +}; + +/** ゲーム画面の初期残り時間表示を返す */ +export const getInitialTimeDisplay = (): string => { + return formatRemainingTime(config.GAME_CONFIG.GAME_DURATION_SEC); +}; \ No newline at end of file diff --git a/apps/client/src/scenes/game/application/runtime/GameSceneRuntime.ts b/apps/client/src/scenes/game/application/runtime/GameSceneRuntime.ts index 245113b..977e0d5 100644 --- a/apps/client/src/scenes/game/application/runtime/GameSceneRuntime.ts +++ b/apps/client/src/scenes/game/application/runtime/GameSceneRuntime.ts @@ -4,17 +4,16 @@ * 入力状態,ネットワーク同期,ループ更新の実行責務を集約する */ import { Application, Container, Ticker } from "pixi.js"; -import type { - BombPlacedAckPayload, - BombPlacedPayload, - PlayerDeadPayload, -} from "@repo/shared"; import { AppearanceResolver } from "../AppearanceResolver"; import { GameNetworkSync } from "../GameNetworkSync"; import { GameLoop } from "../GameLoop"; -import { GameSceneOrchestrator, type GameSceneFactoryOptions } from "../orchestrators/GameSceneOrchestrator"; +import { + GameSceneOrchestrator, + type GameSceneEventPorts, + type GameSceneFactoryOptions, +} from "../orchestrators/GameSceneOrchestrator"; import type { GamePlayers } from "../game.types"; -import type { BombExplodedPayload, BombManager } from "../../entities/bomb/BombManager"; +import type { BombManager } from "../../entities/bomb/BombManager"; import type { MoveSender } from "../network/PlayerMoveSender"; import type { GameActionSender } from "../network/GameActionSender"; import type { GameSessionFacade } from "../lifecycle/GameSessionFacade"; @@ -28,12 +27,7 @@ gameActionSender: GameActionSender; moveSender: MoveSender; getElapsedMs: () => number; - onGameStart: (startTime: number) => void; - onGameEnd: () => void; - onBombPlacedFromOthers: (payload: BombPlacedPayload) => void; - onBombPlacedAckFromNetwork: (payload: BombPlacedAckPayload) => void; - onPlayerDeadFromNetwork: (payload: PlayerDeadPayload) => void; - onBombExploded: (payload: BombExplodedPayload) => void; + eventPorts: GameSceneEventPorts; sceneFactories?: GameSceneFactoryOptions; }; @@ -47,12 +41,7 @@ private readonly gameActionSender: GameActionSender; private readonly moveSender: MoveSender; private readonly getElapsedMs: () => number; - private readonly onGameStart: (startTime: number) => void; - private readonly onGameEnd: () => void; - private readonly onBombPlacedFromOthers: (payload: BombPlacedPayload) => void; - private readonly onBombPlacedAckFromNetwork: (payload: BombPlacedAckPayload) => void; - private readonly onPlayerDeadFromNetwork: (payload: PlayerDeadPayload) => void; - private readonly onBombExploded: (payload: BombExplodedPayload) => void; + private readonly eventPorts: GameSceneEventPorts; private readonly sceneFactories?: GameSceneFactoryOptions; private readonly appearanceResolver = new AppearanceResolver(); @@ -70,12 +59,7 @@ gameActionSender, moveSender, getElapsedMs, - onGameStart, - onGameEnd, - onBombPlacedFromOthers, - onBombPlacedAckFromNetwork, - onPlayerDeadFromNetwork, - onBombExploded, + eventPorts, sceneFactories, }: GameSceneRuntimeOptions) { this.app = app; @@ -86,12 +70,7 @@ this.gameActionSender = gameActionSender; this.moveSender = moveSender; this.getElapsedMs = getElapsedMs; - this.onGameStart = onGameStart; - this.onGameEnd = onGameEnd; - this.onBombPlacedFromOthers = onBombPlacedFromOthers; - this.onBombPlacedAckFromNetwork = onBombPlacedAckFromNetwork; - this.onPlayerDeadFromNetwork = onPlayerDeadFromNetwork; - this.onBombExploded = onBombExploded; + this.eventPorts = eventPorts; this.sceneFactories = sceneFactories; } @@ -106,12 +85,7 @@ getElapsedMs: this.getElapsedMs, getJoystickInput: () => this.joystickInput, moveSender: this.moveSender, - onGameStart: this.onGameStart, - onGameEnd: this.onGameEnd, - onBombPlacedFromOthers: this.onBombPlacedFromOthers, - onBombPlacedAckFromNetwork: this.onBombPlacedAckFromNetwork, - onPlayerDeadFromNetwork: this.onPlayerDeadFromNetwork, - onBombExploded: this.onBombExploded, + eventPorts: this.eventPorts, factories: this.sceneFactories, }); diff --git a/apps/client/src/scenes/game/application/ui/GameUiStateSyncService.ts b/apps/client/src/scenes/game/application/ui/GameUiStateSyncService.ts new file mode 100644 index 0000000..5d3034b --- /dev/null +++ b/apps/client/src/scenes/game/application/ui/GameUiStateSyncService.ts @@ -0,0 +1,97 @@ +/** + * GameUiStateSyncService + * ゲーム画面向けUI状態の購読と差分通知を管理する + * 秒境界に揃えた定期通知と購読解除を提供する + */ +const UI_STATE_SECOND_MS = 1000; + +/** ゲーム画面UIへ通知する状態スナップショット */ +export type GameUiState = { + remainingTimeSec: number; + startCountdownSec: number; + isInputEnabled: boolean; +}; + +type GameUiStateSyncServiceOptions = { + getSnapshot: () => GameUiState; +}; + +/** UI状態の購読と定期通知を管理するサービス */ +export class GameUiStateSyncService { + private readonly getSnapshot: () => GameUiState; + private readonly listeners = new Set<(state: GameUiState) => void>(); + private lastState: GameUiState | null = null; + private alignTimeoutId: number | null = null; + private timerId: number | null = null; + + constructor({ getSnapshot }: GameUiStateSyncServiceOptions) { + this.getSnapshot = getSnapshot; + } + + public subscribe(listener: (state: GameUiState) => void): () => void { + this.listeners.add(listener); + listener(this.getSnapshot()); + + return () => { + this.listeners.delete(listener); + }; + } + + public emitIfChanged(force = false): void { + if (this.listeners.size === 0 && !force) { + return; + } + + const snapshot = this.getSnapshot(); + if ( + !force + && this.lastState + && this.lastState.remainingTimeSec === snapshot.remainingTimeSec + && this.lastState.startCountdownSec === snapshot.startCountdownSec + && this.lastState.isInputEnabled === snapshot.isInputEnabled + ) { + return; + } + + this.lastState = snapshot; + this.listeners.forEach((listener) => { + listener(snapshot); + }); + } + + public startTicker(): void { + if (this.alignTimeoutId !== null || this.timerId !== null) { + return; + } + + const nowMs = Date.now(); + const delayToNextSecond = UI_STATE_SECOND_MS - (nowMs % UI_STATE_SECOND_MS); + + this.alignTimeoutId = window.setTimeout(() => { + this.alignTimeoutId = null; + this.emitIfChanged(); + this.timerId = window.setInterval(() => { + this.emitIfChanged(); + }, UI_STATE_SECOND_MS); + }, delayToNextSecond); + } + + public stopTicker(): void { + if (this.alignTimeoutId !== null) { + window.clearTimeout(this.alignTimeoutId); + this.alignTimeoutId = null; + } + + if (this.timerId === null) { + return; + } + + window.clearInterval(this.timerId); + this.timerId = null; + } + + public clear(): void { + this.listeners.clear(); + this.lastState = null; + } +} \ No newline at end of file diff --git a/apps/client/src/scenes/game/hooks/useGameSceneController.ts b/apps/client/src/scenes/game/hooks/useGameSceneController.ts index 421751d..4a1ff5b 100644 --- a/apps/client/src/scenes/game/hooks/useGameSceneController.ts +++ b/apps/client/src/scenes/game/hooks/useGameSceneController.ts @@ -4,17 +4,12 @@ * Pixi描画領域,残り時間表示,入力橋渡しを提供する */ import { useCallback, useEffect, useRef, useState } from "react"; -import { config } from "@client/config"; import { GameManager } from "@client/scenes/game/GameManager"; - -const formatRemainingTime = (remaining: number) => { - const mins = Math.floor(remaining / 60); - const secs = Math.floor(remaining % 60); - return `${mins}:${secs.toString().padStart(2, "0")}`; -}; - -const getInitialTimeDisplay = () => - formatRemainingTime(config.GAME_CONFIG.GAME_DURATION_SEC); +import { + buildStartCountdownText, + formatRemainingTime, + getInitialTimeDisplay, +} from "@client/scenes/game/application/presentation/GameUiPresenter"; /** ゲーム画面の状態と入力ハンドラを提供するフック */ export const useGameSceneController = (myId: string | null) => { @@ -37,8 +32,7 @@ const nextDisplay = formatRemainingTime(state.remainingTimeSec); setTimeLeft((prev) => (prev === nextDisplay ? prev : nextDisplay)); - const remainingSec = state.startCountdownSec; - const nextCountdown = remainingSec > 0 ? String(remainingSec) : null; + const nextCountdown = buildStartCountdownText(state.startCountdownSec); setStartCountdownText((prev) => prev === nextCountdown ? prev : nextCountdown, ); diff --git a/apps/client/src/scenes/game/input/GameInputOverlay.tsx b/apps/client/src/scenes/game/input/GameInputOverlay.tsx index b1e7bb3..ec1df11 100644 --- a/apps/client/src/scenes/game/input/GameInputOverlay.tsx +++ b/apps/client/src/scenes/game/input/GameInputOverlay.tsx @@ -3,10 +3,10 @@ * ゲーム入力UIレイヤーを構成する * ジョイスティック層と爆弾ボタン層を分離して配置する */ -import { useEffect, useMemo, useState } from "react"; import { config } from "@client/config"; import { JoystickInputPresenter } from "./joystick/JoystickInputPresenter"; import { BombButton } from "./bomb/BombButton"; +import { useCooldownClock } from "./hooks/useCooldownClock"; /** 入力UIレイヤーの入力プロパティ */ type GameInputOverlayProps = { @@ -22,8 +22,6 @@ height: "100%", }; -const COOLDOWN_TICK_MS = 50; - /** 入力UIレイヤーを描画する */ export const GameInputOverlay = ({ isInputEnabled, @@ -31,54 +29,7 @@ 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 { cooldownState, markTriggered } = useCooldownClock(bombCooldownMs); const handlePressBomb = () => { if (!isInputEnabled || !cooldownState.isReady) { @@ -90,8 +41,7 @@ return; } - setLastBombPressedAt(Date.now()); - setNowMs(Date.now()); + markTriggered(); }; return ( diff --git a/apps/client/src/scenes/game/input/hooks/useCooldownClock.ts b/apps/client/src/scenes/game/input/hooks/useCooldownClock.ts new file mode 100644 index 0000000..452c1de --- /dev/null +++ b/apps/client/src/scenes/game/input/hooks/useCooldownClock.ts @@ -0,0 +1,70 @@ +/** + * useCooldownClock + * クールダウン経過率と残り秒表示を計算するフック + * トリガー時刻を保持し一定間隔で再計算する + */ +import { useCallback, useEffect, useMemo, useState } from "react"; + +const COOLDOWN_TICK_MS = 50; + +/** クールダウンUI描画に必要な状態 */ +export type CooldownState = { + progress: number; + isReady: boolean; + remainingSecText: string | null; +}; + +const READY_STATE: CooldownState = { + progress: 1, + isReady: true, + remainingSecText: null, +}; + +/** クールダウン状態とトリガー操作を提供するフック */ +export const useCooldownClock = (cooldownMs: number) => { + const [lastTriggeredAt, setLastTriggeredAt] = useState(null); + const [nowMs, setNowMs] = useState(() => Date.now()); + + useEffect(() => { + if (lastTriggeredAt === null || cooldownMs <= 0) { + return; + } + + const timerId = window.setInterval(() => { + setNowMs(Date.now()); + }, COOLDOWN_TICK_MS); + + return () => { + window.clearInterval(timerId); + }; + }, [cooldownMs, lastTriggeredAt]); + + const cooldownState = useMemo(() => { + if (cooldownMs <= 0 || lastTriggeredAt === null) { + return READY_STATE; + } + + const elapsed = nowMs - lastTriggeredAt; + const clampedElapsed = Math.max(0, Math.min(elapsed, cooldownMs)); + const progress = clampedElapsed / cooldownMs; + const remainingMs = Math.max(0, cooldownMs - clampedElapsed); + const isReady = remainingMs === 0; + + return { + progress, + isReady, + remainingSecText: isReady ? null : String(Math.ceil(remainingMs / 1000)), + }; + }, [cooldownMs, lastTriggeredAt, nowMs]); + + const markTriggered = useCallback(() => { + const now = Date.now(); + setLastTriggeredAt(now); + setNowMs(now); + }, []); + + return { + cooldownState, + markTriggered, + }; +}; \ No newline at end of file diff --git a/apps/client/src/scenes/game/styles/GameView.styles.ts b/apps/client/src/scenes/game/styles/GameView.styles.ts new file mode 100644 index 0000000..5fb2e0a --- /dev/null +++ b/apps/client/src/scenes/game/styles/GameView.styles.ts @@ -0,0 +1,58 @@ +/** + * GameView.styles + * GameView の描画スタイル定数を集約する + * 画面全体レイアウトとオーバーレイ表示の見た目を定義する + */ +import type { CSSProperties } from "react"; + +/** ゲーム画面全体のルートスタイル */ +export const ROOT_STYLE: CSSProperties = { + width: "100vw", + height: "100vh", + overflow: "hidden", + position: "relative", + backgroundColor: "#000", + userSelect: "none", + WebkitUserSelect: "none", +}; + +/** 上部中央のタイマー表示スタイル */ +export const TIMER_STYLE: CSSProperties = { + position: "absolute", + top: "20px", + left: "50%", + transform: "translateX(-50%)", + zIndex: 10, + color: "white", + fontSize: "32px", + fontWeight: "bold", + textShadow: "2px 2px 4px rgba(0,0,0,0.5)", + fontFamily: "monospace", + userSelect: "none", + WebkitUserSelect: "none", +}; + +/** Pixi描画レイヤーの配置スタイル */ +export const PIXI_LAYER_STYLE: CSSProperties = { + position: "absolute", + top: 0, + left: 0, + zIndex: 1, +}; + +/** 画面中央の開始カウントダウン表示スタイル */ +export const START_COUNTDOWN_STYLE: 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", +}; \ No newline at end of file