diff --git a/apps/client/src/scenes/game/GameManager.ts b/apps/client/src/scenes/game/GameManager.ts index 6bff087..9429ad1 100644 --- a/apps/client/src/scenes/game/GameManager.ts +++ b/apps/client/src/scenes/game/GameManager.ts @@ -215,14 +215,21 @@ } private getUiStateSnapshot(): GameUiState { + const miniMapTeamIds = this.runtime.getMiniMapTeamIds(); + return { - remainingTimeSec: Math.floor(this.sessionFacade.getRemainingTime()), - startCountdownSec: this.sessionFacade.getStartCountdownSec(), - isInputEnabled: this.runtime.isInputEnabled(), - teamPaintRates: this.runtime.getPaintRatesByTeam(), - miniMapTeamIds: this.runtime.getMiniMapTeamIds(), - localBombHitCount: this.localBombHitCount, - localPlayerPosition: this.runtime.getLocalPlayerPosition(), + hud: { + remainingTimeSec: Math.floor(this.sessionFacade.getRemainingTime()), + startCountdownSec: this.sessionFacade.getStartCountdownSec(), + isInputEnabled: this.runtime.isInputEnabled(), + teamPaintRates: this.runtime.getPaintRatesByTeam(), + localBombHitCount: this.localBombHitCount, + }, + miniMap: { + mapRevision: this.runtime.getMiniMapRevision(), + teamIds: miniMapTeamIds, + localPlayerPosition: this.runtime.getLocalPlayerPosition(), + }, }; } diff --git a/apps/client/src/scenes/game/GameView.tsx b/apps/client/src/scenes/game/GameView.tsx index e17cc44..55e51a6 100644 --- a/apps/client/src/scenes/game/GameView.tsx +++ b/apps/client/src/scenes/game/GameView.tsx @@ -8,10 +8,6 @@ GAME_VIEW_BOMB_HIT_DEBUG_STYLE, GAME_VIEW_FEVER_TEXT_STYLE, GAME_VIEW_HURRICANE_WARNING_STYLE, - GAME_VIEW_TOP_RIGHT_OVERLAY_STYLE, - GAME_VIEW_PAINT_RATE_ITEM_STYLE, - GAME_VIEW_PAINT_RATE_PANEL_STYLE, - GAME_VIEW_PAINT_RATE_SQUARE_STYLE, GAME_VIEW_PIXI_LAYER_STYLE, GAME_VIEW_ROOT_STYLE, GAME_VIEW_START_COUNTDOWN_STYLE, @@ -19,7 +15,7 @@ } from "./styles/GameView.styles"; import { config } from "@client/config"; import { buildRespawnHeartGauge } from "./input/presentation/GameUiPresenter"; -import { MiniMapPanel } from "./input/minimap/presentation/MiniMapPanel"; +import { TopRightHud } from "./presentation/TopRightHud"; /** 表示と入力に必要なプロパティ */ type Props = { @@ -66,37 +62,6 @@ ); }; -const TeamPaintRateOverlay = ({ - teamPaintRates, - remainingSeconds, -}: { - teamPaintRates: number[]; - remainingSeconds: number; -}) => { - const shouldMaskPaintRate = remainingSeconds <= 30; - - return ( -
- {teamPaintRates.map((rate, index) => ( -
- - ■ - - {shouldMaskPaintRate ? "???%" : `${Math.round(rate)}%`} -
- ))} -
- ); -}; - /** 画面描画と入力UIをまとめて描画する */ export const GameView = ({ timeLeft, @@ -121,16 +86,12 @@ {/* タイマーUIの表示 */}
HP: {heartGauge}
-
- - -
+ {remainingSeconds === 60 && (
!Fever Tieme!
diff --git a/apps/client/src/scenes/game/application/runtime/GameSceneRuntime.ts b/apps/client/src/scenes/game/application/runtime/GameSceneRuntime.ts index 89f0c5f..6fb417e 100644 --- a/apps/client/src/scenes/game/application/runtime/GameSceneRuntime.ts +++ b/apps/client/src/scenes/game/application/runtime/GameSceneRuntime.ts @@ -60,6 +60,12 @@ private joystickInput = { x: 0, y: 0 }; private tickerHandler: ((ticker: Ticker) => void) | null = null; private lifecycleState: RuntimeLifecycleState = "created"; + private miniMapCache = { + revision: -1, + teamIds: new Array( + config.GAME_CONFIG.GRID_COLS * config.GAME_CONFIG.GRID_ROWS, + ).fill(-1), + }; constructor({ app, @@ -196,11 +202,23 @@ /** ミニマップ描画用の全セルteamId配列を返す */ public getMiniMapTeamIds(): number[] { if (!this.gameMap) { - const totalCells = config.GAME_CONFIG.GRID_COLS * config.GAME_CONFIG.GRID_ROWS; - return new Array(totalCells).fill(-1); + return this.miniMapCache.teamIds; } - return this.gameMap.getAllCellTeamIds(); + const revision = this.gameMap.getMapRevision(); + if (revision !== this.miniMapCache.revision) { + this.miniMapCache = { + revision, + teamIds: this.gameMap.getAllCellTeamIds(), + }; + } + + return this.miniMapCache.teamIds; + } + + /** ミニマップ描画用のマップ更新リビジョンを返す */ + public getMiniMapRevision(): number { + return this.miniMapCache.revision; } /** ローカルプレイヤーの現在座標を返す */ diff --git a/apps/client/src/scenes/game/application/ui/GameUiStateSyncService.ts b/apps/client/src/scenes/game/application/ui/GameUiStateSyncService.ts index 16065fd..0ccf12e 100644 --- a/apps/client/src/scenes/game/application/ui/GameUiStateSyncService.ts +++ b/apps/client/src/scenes/game/application/ui/GameUiStateSyncService.ts @@ -11,28 +11,25 @@ const UI_STATE_SECOND_MS = 1000; /** ゲーム画面UIへ通知する状態スナップショット */ -export type GameUiState = { +export type GameHudState = { remainingTimeSec: number; startCountdownSec: number; isInputEnabled: boolean; teamPaintRates: number[]; - miniMapTeamIds: number[]; localBombHitCount: number; +}; + +/** ミニマップへ通知する状態スナップショット */ +export type MiniMapState = { + mapRevision: number; + teamIds: number[]; localPlayerPosition: { x: number; y: number } | null; }; -const isSameMiniMapTeamIds = (a: number[], b: number[]): boolean => { - if (a.length !== b.length) { - return false; - } - - for (let index = 0; index < a.length; index += 1) { - if (a[index] !== b[index]) { - return false; - } - } - - return true; +/** ゲーム画面UIへ通知する状態スナップショット */ +export type GameUiState = { + hud: GameHudState; + miniMap: MiniMapState; }; const isSameLocalPlayerPosition = ( @@ -101,18 +98,18 @@ if ( !force && this.lastState && - this.lastState.remainingTimeSec === snapshot.remainingTimeSec && - this.lastState.startCountdownSec === snapshot.startCountdownSec && - this.lastState.isInputEnabled === snapshot.isInputEnabled && - this.lastState.localBombHitCount === snapshot.localBombHitCount && - isSamePaintRates(this.lastState.teamPaintRates, snapshot.teamPaintRates) && - isSameMiniMapTeamIds( - this.lastState.miniMapTeamIds, - snapshot.miniMapTeamIds, + this.lastState.hud.remainingTimeSec === snapshot.hud.remainingTimeSec && + this.lastState.hud.startCountdownSec === snapshot.hud.startCountdownSec && + this.lastState.hud.isInputEnabled === snapshot.hud.isInputEnabled && + this.lastState.hud.localBombHitCount === snapshot.hud.localBombHitCount && + isSamePaintRates( + this.lastState.hud.teamPaintRates, + snapshot.hud.teamPaintRates, ) && + this.lastState.miniMap.mapRevision === snapshot.miniMap.mapRevision && isSameLocalPlayerPosition( - this.lastState.localPlayerPosition, - snapshot.localPlayerPosition, + this.lastState.miniMap.localPlayerPosition, + snapshot.miniMap.localPlayerPosition, ) ) { return; diff --git a/apps/client/src/scenes/game/entities/map/GameMapController.ts b/apps/client/src/scenes/game/entities/map/GameMapController.ts index 5d6733d..337fca4 100644 --- a/apps/client/src/scenes/game/entities/map/GameMapController.ts +++ b/apps/client/src/scenes/game/entities/map/GameMapController.ts @@ -61,6 +61,11 @@ return this.model.getAllTeamIds(); } + /** 現在のマップ更新リビジョンを取得する */ + public getMapRevision(): number { + return this.model.getRevision(); + } + /** すべてのセルteamIdを描画色へ変換する */ private resolveAllCellColors(teamIds: number[]): Array { return teamIds.map((teamId) => this.resolveCellColor(teamId)); diff --git a/apps/client/src/scenes/game/entities/map/GameMapModel.ts b/apps/client/src/scenes/game/entities/map/GameMapModel.ts index c9ad37c..c95324d 100644 --- a/apps/client/src/scenes/game/entities/map/GameMapModel.ts +++ b/apps/client/src/scenes/game/entities/map/GameMapModel.ts @@ -9,6 +9,7 @@ /** マップセル状態の計算責務を担うモデル */ export class GameMapModel { private readonly cellTeamIds: number[]; + private revision = 0; /** 設定値に基づいて初期セル状態を構築する */ constructor() { @@ -18,21 +19,42 @@ /** 全体マップ状態を適用する */ public applyMapState(state: domain.game.gridMap.MapState): void { + let hasChanged = false; const maxLength = Math.min( this.cellTeamIds.length, state.gridColors.length, ); for (let index = 0; index < maxLength; index++) { - this.cellTeamIds[index] = state.gridColors[index]; + const nextTeamId = state.gridColors[index]; + if (this.cellTeamIds[index] === nextTeamId) { + continue; + } + + this.cellTeamIds[index] = nextTeamId; + hasChanged = true; + } + + if (hasChanged) { + this.revision += 1; } } /** 差分セル更新を適用する */ public applyUpdates(updates: domain.game.gridMap.CellUpdate[]): void { + let hasChanged = false; updates.forEach(({ index, teamId }) => { if (!this.isValidIndex(index)) return; + if (this.cellTeamIds[index] === teamId) { + return; + } + this.cellTeamIds[index] = teamId; + hasChanged = true; }); + + if (hasChanged) { + this.revision += 1; + } } /** 指定セルのチームIDを取得する */ @@ -46,6 +68,11 @@ return [...this.cellTeamIds]; } + /** 現在のマップ更新リビジョンを返す */ + public getRevision(): number { + return this.revision; + } + /** チームごとの塗り率配列を取得する */ public getPaintRatesByTeam(teamCount: number): number[] { if (teamCount <= 0) { diff --git a/apps/client/src/scenes/game/hooks/useGameSceneController.ts b/apps/client/src/scenes/game/hooks/useGameSceneController.ts index f253f00..f285bdd 100644 --- a/apps/client/src/scenes/game/hooks/useGameSceneController.ts +++ b/apps/client/src/scenes/game/hooks/useGameSceneController.ts @@ -48,23 +48,23 @@ gameManagerRef.current = manager; const unsubscribeUiState = manager.subscribeUiState((state) => { - const nextDisplay = formatRemainingTime(state.remainingTimeSec); + const nextDisplay = formatRemainingTime(state.hud.remainingTimeSec); setTimeLeft((prev) => (prev === nextDisplay ? prev : nextDisplay)); - const nextCountdown = buildStartCountdownText(state.startCountdownSec); + const nextCountdown = buildStartCountdownText(state.hud.startCountdownSec); setStartCountdownText((prev) => prev === nextCountdown ? prev : nextCountdown, ); - const nextInputEnabled = state.isInputEnabled; + const nextInputEnabled = state.hud.isInputEnabled; setIsInputEnabled((prev) => prev === nextInputEnabled ? prev : nextInputEnabled, ); - setTeamPaintRates(state.teamPaintRates); - setMiniMapTeamIds(state.miniMapTeamIds); - setLocalBombHitCount(state.localBombHitCount); - setLocalPlayerPosition(state.localPlayerPosition); + setTeamPaintRates(state.hud.teamPaintRates); + setMiniMapTeamIds(state.miniMap.teamIds); + setLocalBombHitCount(state.hud.localBombHitCount); + setLocalPlayerPosition(state.miniMap.localPlayerPosition); }); return () => { diff --git a/apps/client/src/scenes/game/input/bomb/presentation/BombButton.tsx b/apps/client/src/scenes/game/input/bomb/presentation/BombButton.tsx index 77c3b54..43887d0 100644 --- a/apps/client/src/scenes/game/input/bomb/presentation/BombButton.tsx +++ b/apps/client/src/scenes/game/input/bomb/presentation/BombButton.tsx @@ -8,6 +8,7 @@ buildBombButtonHitAreaStyle, buildBombButtonStyle, } from "./BombButton.styles"; +import { useImmediatePressHandlers } from "@client/scenes/game/input/presentation/useImmediatePressHandlers"; /** 爆弾設置ボタンの入力プロパティ */ export type BombButtonProps = { @@ -38,39 +39,24 @@ onPress(); }; - const handlePointerDown = (event: React.PointerEvent) => { - event.preventDefault(); - handleActivate(); - }; - - const handleTouchStart = (event: React.TouchEvent) => { - event.preventDefault(); - handleActivate(); - }; - - const handleMouseDown = (event: React.MouseEvent) => { - event.preventDefault(); - handleActivate(); - }; + const { onPointerDown: onHitAreaPointerDown, onClick: onHitAreaClick } = + useImmediatePressHandlers(handleActivate, { + stopPropagation: false, + }); + const { onPointerDown: onButtonPointerDown, onClick: onButtonClick } = + useImmediatePressHandlers(handleActivate); return (
@@ -119,8 +58,8 @@
{markerPosition && ( diff --git a/apps/client/src/scenes/game/input/minimap/presentation/minimapUiConfig.ts b/apps/client/src/scenes/game/input/minimap/presentation/minimapUiConfig.ts new file mode 100644 index 0000000..51175af --- /dev/null +++ b/apps/client/src/scenes/game/input/minimap/presentation/minimapUiConfig.ts @@ -0,0 +1,18 @@ +/** + * minimapUiConfig + * ミニマップUIの見た目定数を集約する + * サイズや透過度など調整しやすい値を定義する + */ + +/** ミニマップUIで利用する見た目定数 */ +export const MINIMAP_UI_CONFIG = { + FRAME_SIZE_PX: 128, + FRAME_BORDER_RADIUS_PX: 10, + FRAME_BORDER_COLOR: "rgba(255,255,255,0.65)", + FRAME_BACKGROUND_GRADIENT: + "linear-gradient(160deg, rgba(34,34,34,0.5), rgba(12,12,12,0.5))", + FRAME_OPACITY: 0.82, + BUTTON_MIN_WIDTH_PX: 84, + BUTTON_HEIGHT_PX: 32, + DOT_SIZE_PX: 10, +} as const; diff --git a/apps/client/src/scenes/game/input/presentation/useImmediatePressHandlers.ts b/apps/client/src/scenes/game/input/presentation/useImmediatePressHandlers.ts new file mode 100644 index 0000000..e871421 --- /dev/null +++ b/apps/client/src/scenes/game/input/presentation/useImmediatePressHandlers.ts @@ -0,0 +1,45 @@ +/** + * useImmediatePressHandlers + * タップ即時発火の押下イベントハンドラを生成するフック + * pointerdown 起点で入力を確定し click の二重発火を抑止する + */ +import { useCallback } from "react"; + +/** 即時押下ハンドラ生成の入力プロパティ */ +export type UseImmediatePressHandlersOptions = { + stopPropagation?: boolean; +}; + +/** pointerdown と click 抑止のハンドラを生成する */ +export const useImmediatePressHandlers = ( + onPress: () => void, + options?: UseImmediatePressHandlersOptions, +) => { + const stopPropagation = options?.stopPropagation ?? true; + + const onPointerDown = useCallback( + (event: React.PointerEvent) => { + event.preventDefault(); + if (stopPropagation) { + event.stopPropagation(); + } + onPress(); + }, + [onPress, stopPropagation], + ); + + const onClick = useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + if (stopPropagation) { + event.stopPropagation(); + } + }, + [stopPropagation], + ); + + return { + onPointerDown, + onClick, + }; +}; diff --git a/apps/client/src/scenes/game/presentation/TopRightHud.tsx b/apps/client/src/scenes/game/presentation/TopRightHud.tsx new file mode 100644 index 0000000..013f3ea --- /dev/null +++ b/apps/client/src/scenes/game/presentation/TopRightHud.tsx @@ -0,0 +1,73 @@ +/** + * TopRightHud + * 右上HUDの描画責務を担うプレゼンテーションコンポーネント + * ミニマップとチーム塗り率パネルを横並びで表示する + */ +import { config } from "@client/config"; +import { MiniMapPanel } from "@client/scenes/game/input/minimap/presentation/MiniMapPanel"; +import { + GAME_VIEW_PAINT_RATE_ITEM_STYLE, + GAME_VIEW_PAINT_RATE_PANEL_STYLE, + GAME_VIEW_PAINT_RATE_SQUARE_STYLE, + GAME_VIEW_TOP_RIGHT_OVERLAY_STYLE, +} from "@client/scenes/game/styles/GameView.styles"; + +/** TopRightHud の入力プロパティ */ +export type TopRightHudProps = { + teamPaintRates: number[]; + remainingSeconds: number; + miniMapTeamIds: number[]; + localPlayerPosition: { x: number; y: number } | null; +}; + +const TeamPaintRateOverlay = ({ + teamPaintRates, + remainingSeconds, +}: { + teamPaintRates: number[]; + remainingSeconds: number; +}) => { + const shouldMaskPaintRate = remainingSeconds <= 30; + + return ( +
+ {teamPaintRates.map((rate, index) => ( +
+ + ■ + + {shouldMaskPaintRate ? "???%" : `${Math.round(rate)}%`} +
+ ))} +
+ ); +}; + +/** 右上HUDを描画する */ +export const TopRightHud = ({ + teamPaintRates, + remainingSeconds, + miniMapTeamIds, + localPlayerPosition, +}: TopRightHudProps) => { + return ( +
+ + +
+ ); +};