diff --git a/apps/client/src/scenes/game/GameManager.ts b/apps/client/src/scenes/game/GameManager.ts index 3d73b97..a468fe4 100644 --- a/apps/client/src/scenes/game/GameManager.ts +++ b/apps/client/src/scenes/game/GameManager.ts @@ -194,6 +194,7 @@ */ private tick = (ticker: Ticker) => { this.runtime.tick(ticker); + this.uiStateSyncService.emitIfChanged(); }; /** UI状態購読を登録し,解除関数を返す */ @@ -206,6 +207,7 @@ remainingTimeSec: Math.floor(this.sessionFacade.getRemainingTime()), startCountdownSec: this.sessionFacade.getStartCountdownSec(), isInputEnabled: this.runtime.isInputEnabled(), + teamPaintRates: this.runtime.getPaintRatesByTeam(), }; } diff --git a/apps/client/src/scenes/game/GameScene.tsx b/apps/client/src/scenes/game/GameScene.tsx index e5e7876..80aaea7 100644 --- a/apps/client/src/scenes/game/GameScene.tsx +++ b/apps/client/src/scenes/game/GameScene.tsx @@ -18,6 +18,7 @@ timeLeft, startCountdownText, isInputEnabled, + teamPaintRates, handleInput, handlePlaceBomb, } = useGameSceneController(myId); @@ -27,6 +28,7 @@ timeLeft={timeLeft} startCountdownText={startCountdownText} isInputEnabled={isInputEnabled} + teamPaintRates={teamPaintRates} pixiContainerRef={pixiContainerRef} onJoystickInput={handleInput} onPlaceBomb={handlePlaceBomb} diff --git a/apps/client/src/scenes/game/GameView.tsx b/apps/client/src/scenes/game/GameView.tsx index 2604944..a86760a 100644 --- a/apps/client/src/scenes/game/GameView.tsx +++ b/apps/client/src/scenes/game/GameView.tsx @@ -5,17 +5,22 @@ */ import { GameInputOverlay } from "./input/GameInputOverlay"; import { + 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, GAME_VIEW_TIMER_STYLE, } from "./styles/GameView.styles"; +import { config } from "@client/config"; /** 表示と入力に必要なプロパティ */ type Props = { timeLeft: string; startCountdownText: string | null; isInputEnabled: boolean; + teamPaintRates: number[]; pixiContainerRef: React.RefObject; onJoystickInput: (x: number, y: number) => void; onPlaceBomb: () => boolean; @@ -25,11 +30,39 @@
{timeLeft}
); +const TeamPaintRateOverlay = ({ + teamPaintRates, +}: { + teamPaintRates: number[]; +}) => { + return ( +
+ {teamPaintRates.map((rate, index) => ( +
+ + ■ + + {`${Math.round(rate)}%`} +
+ ))} +
+ ); +}; + /** 画面描画と入力UIをまとめて描画する */ export const GameView = ({ timeLeft, startCountdownText, isInputEnabled, + teamPaintRates, pixiContainerRef, onJoystickInput, onPlaceBomb, @@ -38,6 +71,7 @@
{/* タイマーUIの表示 */} + {startCountdownText && (
{startCountdownText}
diff --git a/apps/client/src/scenes/game/application/runtime/GameSceneRuntime.ts b/apps/client/src/scenes/game/application/runtime/GameSceneRuntime.ts index d5cab4c..f337f81 100644 --- a/apps/client/src/scenes/game/application/runtime/GameSceneRuntime.ts +++ b/apps/client/src/scenes/game/application/runtime/GameSceneRuntime.ts @@ -19,6 +19,8 @@ import { DisposableRegistry } from "../lifecycle/DisposableRegistry"; import { GameSceneRuntimeWiring } from "./GameSceneRuntimeWiring"; import { PlayerRepository } from "@client/scenes/game/entities/player/PlayerRepository"; +import type { GameMapController } from "@client/scenes/game/entities/map/GameMapController"; +import { config } from "@client/config"; type RuntimeLifecycleState = "created" | "initialized" | "destroyed"; @@ -52,6 +54,7 @@ private readonly appearanceResolver = new AppearanceResolver(); private bombManager: BombManager | null = null; + private gameMap: GameMapController | null = null; private networkSync: GameNetworkSync | null = null; private gameLoop: GameLoop | null = null; private joystickInput = { x: 0, y: 0 }; @@ -111,12 +114,17 @@ }); const initializedScene = runtimeWiring.wire(); + this.gameMap = initializedScene.gameMap; this.networkSync = initializedScene.networkSync; this.bombManager = initializedScene.bombManager; this.gameLoop = initializedScene.gameLoop; this.lifecycleState = "initialized"; this.disposableRegistry.add(() => { + this.gameMap?.destroy(); + this.gameMap = null; + }); + this.disposableRegistry.add(() => { this.networkSync?.unbind(); this.networkSync = null; }); @@ -172,6 +180,15 @@ this.gameLoop?.tick(ticker); } + /** チームごとの塗り率配列を返す */ + public getPaintRatesByTeam(): number[] { + if (!this.gameMap) { + return new Array(config.GAME_CONFIG.TEAM_COUNT).fill(0); + } + + return this.gameMap.getPaintRatesByTeam(); + } + /** 実行系サブシステムを破棄する */ public destroy(): void { if (this.lifecycleState === "destroyed") { diff --git a/apps/client/src/scenes/game/application/ui/GameUiStateSyncService.ts b/apps/client/src/scenes/game/application/ui/GameUiStateSyncService.ts index 3f23c7c..f324b59 100644 --- a/apps/client/src/scenes/game/application/ui/GameUiStateSyncService.ts +++ b/apps/client/src/scenes/game/application/ui/GameUiStateSyncService.ts @@ -15,6 +15,21 @@ remainingTimeSec: number; startCountdownSec: number; isInputEnabled: boolean; + teamPaintRates: number[]; +}; + +const isSamePaintRates = (a: number[], b: number[]): boolean => { + if (a.length !== b.length) { + return false; + } + + for (let index = 0; index < a.length; index += 1) { + if (Math.abs(a[index] - b[index]) > 0.01) { + return false; + } + } + + return true; }; type GameUiStateSyncServiceOptions = { @@ -52,11 +67,12 @@ const snapshot = this.getSnapshot(); if ( - !force - && this.lastState - && this.lastState.remainingTimeSec === snapshot.remainingTimeSec - && this.lastState.startCountdownSec === snapshot.startCountdownSec - && this.lastState.isInputEnabled === snapshot.isInputEnabled + !force && + this.lastState && + this.lastState.remainingTimeSec === snapshot.remainingTimeSec && + this.lastState.startCountdownSec === snapshot.startCountdownSec && + this.lastState.isInputEnabled === snapshot.isInputEnabled && + isSamePaintRates(this.lastState.teamPaintRates, snapshot.teamPaintRates) ) { return; } @@ -102,4 +118,4 @@ this.listeners.clear(); this.lastState = null; } -} \ No newline at end of file +} diff --git a/apps/client/src/scenes/game/entities/map/GameMapController.ts b/apps/client/src/scenes/game/entities/map/GameMapController.ts index da2d52c..9faa689 100644 --- a/apps/client/src/scenes/game/entities/map/GameMapController.ts +++ b/apps/client/src/scenes/game/entities/map/GameMapController.ts @@ -3,11 +3,12 @@ * 外部からのマップ更新入力をModelとViewへ仲介するコントローラー * 全体更新と差分更新を統一的に扱い,描画同期を提供する */ -import type { domain } from '@repo/shared'; -import type { Container } from 'pixi.js'; -import { AppearanceResolver } from '@client/scenes/game/application/AppearanceResolver'; -import { GameMapModel } from './GameMapModel'; -import { GameMapView } from './GameMapView'; +import type { domain } from "@repo/shared"; +import type { Container } from "pixi.js"; +import { config } from "@client/config"; +import { AppearanceResolver } from "@client/scenes/game/application/AppearanceResolver"; +import { GameMapModel } from "./GameMapModel"; +import { GameMapView } from "./GameMapView"; /** マップ更新の仲介責務を担うコントローラー */ export class GameMapController { @@ -50,6 +51,11 @@ this.view.destroy(); } + /** チームごとの塗り率配列を取得する */ + public getPaintRatesByTeam(): number[] { + return this.model.getPaintRatesByTeam(config.GAME_CONFIG.TEAM_COUNT); + } + /** すべてのセルteamIdを描画色へ変換する */ private resolveAllCellColors(teamIds: number[]): Array { return teamIds.map((teamId) => this.resolveCellColor(teamId)); @@ -59,4 +65,4 @@ private resolveCellColor(teamId: number): number | null { return this.appearanceResolver.resolveMapCellColor(teamId); } -} \ No newline at end of file +} diff --git a/apps/client/src/scenes/game/entities/map/GameMapModel.ts b/apps/client/src/scenes/game/entities/map/GameMapModel.ts index 886bdbb..2b1dd3d 100644 --- a/apps/client/src/scenes/game/entities/map/GameMapModel.ts +++ b/apps/client/src/scenes/game/entities/map/GameMapModel.ts @@ -3,8 +3,8 @@ * マップセルの色状態を管理する計算モデル * 全体更新と差分更新を適用して描画入力用の状態を保持する */ -import { config } from '@client/config'; -import type { domain } from '@repo/shared'; +import { config } from "@client/config"; +import type { domain } from "@repo/shared"; /** マップセル状態の計算責務を担うモデル */ export class GameMapModel { @@ -18,7 +18,10 @@ /** 全体マップ状態を適用する */ public applyMapState(state: domain.gridMap.MapState): void { - const maxLength = Math.min(this.cellTeamIds.length, state.gridColors.length); + const maxLength = Math.min( + this.cellTeamIds.length, + state.gridColors.length, + ); for (let index = 0; index < maxLength; index++) { this.cellTeamIds[index] = state.gridColors[index]; } @@ -43,8 +46,34 @@ return [...this.cellTeamIds]; } + /** チームごとの塗り率配列を取得する */ + public getPaintRatesByTeam(teamCount: number): number[] { + if (teamCount <= 0) { + return []; + } + + const paintedCounts = new Array(teamCount).fill(0); + const totalCells = this.cellTeamIds.length; + + this.cellTeamIds.forEach((teamId) => { + if (!Number.isInteger(teamId) || teamId < 0 || teamId >= teamCount) { + return; + } + + paintedCounts[teamId] += 1; + }); + + if (totalCells <= 0) { + return paintedCounts.map(() => 0); + } + + return paintedCounts.map((count) => (count / totalCells) * 100); + } + /** セル添字が有効範囲内かを判定する */ private isValidIndex(index: number): boolean { - return Number.isInteger(index) && index >= 0 && index < this.cellTeamIds.length; + return ( + Number.isInteger(index) && index >= 0 && index < this.cellTeamIds.length + ); } -} \ 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 079c232..07ac595 100644 --- a/apps/client/src/scenes/game/hooks/useGameSceneController.ts +++ b/apps/client/src/scenes/game/hooks/useGameSceneController.ts @@ -5,12 +5,17 @@ */ import { useCallback, useEffect, useRef, useState } from "react"; import { GameManager } from "@client/scenes/game/GameManager"; +import { config } from "@client/config"; import { buildStartCountdownText, formatRemainingTime, getInitialTimeDisplay, } from "@client/scenes/game/input/presentation/GameUiPresenter"; +const DEFAULT_TEAM_PAINT_RATES = new Array( + config.GAME_CONFIG.TEAM_COUNT, +).fill(0); + /** ゲーム画面の状態と入力ハンドラを提供するフック */ export const useGameSceneController = (myId: string | null) => { const pixiContainerRef = useRef(null); @@ -20,6 +25,9 @@ null, ); const [isInputEnabled, setIsInputEnabled] = useState(false); + const [teamPaintRates, setTeamPaintRates] = useState( + DEFAULT_TEAM_PAINT_RATES, + ); useEffect(() => { if (!pixiContainerRef.current || !myId) return; @@ -41,6 +49,8 @@ setIsInputEnabled((prev) => prev === nextInputEnabled ? prev : nextInputEnabled, ); + + setTeamPaintRates(state.teamPaintRates); }); return () => { @@ -49,6 +59,7 @@ gameManagerRef.current = null; setStartCountdownText(null); setIsInputEnabled(false); + setTeamPaintRates(DEFAULT_TEAM_PAINT_RATES); }; }, [myId]); @@ -65,6 +76,7 @@ timeLeft, startCountdownText, isInputEnabled, + teamPaintRates, handleInput, handlePlaceBomb, }; diff --git a/apps/client/src/scenes/game/styles/GameView.styles.ts b/apps/client/src/scenes/game/styles/GameView.styles.ts index d13d802..59b879a 100644 --- a/apps/client/src/scenes/game/styles/GameView.styles.ts +++ b/apps/client/src/scenes/game/styles/GameView.styles.ts @@ -32,6 +32,39 @@ WebkitUserSelect: "none", }; +/** 右上のチーム塗り率パネルスタイル */ +export const GAME_VIEW_PAINT_RATE_PANEL_STYLE: CSSProperties = { + position: "absolute", + top: "20px", + right: "16px", + zIndex: 12, + color: "white", + fontSize: "14px", + fontWeight: 700, + textShadow: "2px 2px 4px rgba(0,0,0,0.5)", + fontFamily: "monospace", + display: "flex", + flexDirection: "column", + gap: "4px", + userSelect: "none", + WebkitUserSelect: "none", + pointerEvents: "none", +}; + +/** チーム塗り率1行のスタイル */ +export const GAME_VIEW_PAINT_RATE_ITEM_STYLE: CSSProperties = { + display: "flex", + alignItems: "center", + justifyContent: "flex-end", + gap: "4px", +}; + +/** チーム色四角マーカーのスタイル */ +export const GAME_VIEW_PAINT_RATE_SQUARE_STYLE: CSSProperties = { + lineHeight: 1, + fontSize: "14px", +}; + /** Pixi描画レイヤーの配置スタイル */ export const GAME_VIEW_PIXI_LAYER_STYLE: CSSProperties = { position: "absolute", @@ -55,4 +88,4 @@ userSelect: "none", WebkitUserSelect: "none", pointerEvents: "none", -}; \ No newline at end of file +};