diff --git a/apps/client/src/scenes/game/GameManager.ts b/apps/client/src/scenes/game/GameManager.ts index 12d3d86..7e3b51a 100644 --- a/apps/client/src/scenes/game/GameManager.ts +++ b/apps/client/src/scenes/game/GameManager.ts @@ -221,6 +221,7 @@ isInputEnabled: this.runtime.isInputEnabled(), teamPaintRates: this.runtime.getPaintRatesByTeam(), localBombHitCount: this.localBombHitCount, + localPlayerPosition: this.runtime.getLocalPlayerPosition(), }; } diff --git a/apps/client/src/scenes/game/GameScene.tsx b/apps/client/src/scenes/game/GameScene.tsx index 0703325..ae6640e 100644 --- a/apps/client/src/scenes/game/GameScene.tsx +++ b/apps/client/src/scenes/game/GameScene.tsx @@ -20,6 +20,7 @@ isInputEnabled, teamPaintRates, localBombHitCount, + localPlayerPosition, handleInput, handlePlaceBomb, } = useGameSceneController(myId); @@ -31,6 +32,7 @@ isInputEnabled={isInputEnabled} teamPaintRates={teamPaintRates} localBombHitCount={localBombHitCount} + localPlayerPosition={localPlayerPosition} 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 c78b209..dbd6967 100644 --- a/apps/client/src/scenes/game/GameView.tsx +++ b/apps/client/src/scenes/game/GameView.tsx @@ -8,6 +8,7 @@ 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, @@ -18,6 +19,7 @@ } from "./styles/GameView.styles"; import { config } from "@client/config"; import { buildRespawnHeartGauge } from "./input/presentation/GameUiPresenter"; +import { MiniMapPanel } from "./input/minimap/presentation/MiniMapPanel"; /** 表示と入力に必要なプロパティ */ type Props = { @@ -26,6 +28,7 @@ isInputEnabled: boolean; teamPaintRates: number[]; localBombHitCount: number; + localPlayerPosition: { x: number; y: number } | null; pixiContainerRef: React.RefObject; onJoystickInput: (x: number, y: number) => void; onPlaceBomb: () => boolean; @@ -100,6 +103,7 @@ isInputEnabled, teamPaintRates, localBombHitCount, + localPlayerPosition, pixiContainerRef, onJoystickInput, onPlaceBomb, @@ -115,10 +119,13 @@ {/* タイマー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 260d65c..c145d5b 100644 --- a/apps/client/src/scenes/game/application/runtime/GameSceneRuntime.ts +++ b/apps/client/src/scenes/game/application/runtime/GameSceneRuntime.ts @@ -193,6 +193,20 @@ return this.gameMap.getPaintRatesByTeam(); } + /** ローカルプレイヤーの現在座標を返す */ + public getLocalPlayerPosition(): { x: number; y: number } | null { + const localPlayer = this.playerRepository.getById(this.myId); + if (!localPlayer) { + return null; + } + + const position = localPlayer.getPosition(); + return { + x: Math.round(position.x * 10) / 10, + y: Math.round(position.y * 10) / 10, + }; + } + /** 実行系サブシステムを破棄する */ 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 0152bb0..e131a4c 100644 --- a/apps/client/src/scenes/game/application/ui/GameUiStateSyncService.ts +++ b/apps/client/src/scenes/game/application/ui/GameUiStateSyncService.ts @@ -17,6 +17,22 @@ isInputEnabled: boolean; teamPaintRates: number[]; localBombHitCount: number; + localPlayerPosition: { x: number; y: number } | null; +}; + +const isSameLocalPlayerPosition = ( + a: { x: number; y: number } | null, + b: { x: number; y: number } | null, +): boolean => { + if (a === null && b === null) { + return true; + } + + if (a === null || b === null) { + return false; + } + + return a.x === b.x && a.y === b.y; }; const isSamePaintRates = (a: number[], b: number[]): boolean => { @@ -74,7 +90,11 @@ this.lastState.startCountdownSec === snapshot.startCountdownSec && this.lastState.isInputEnabled === snapshot.isInputEnabled && this.lastState.localBombHitCount === snapshot.localBombHitCount && - isSamePaintRates(this.lastState.teamPaintRates, snapshot.teamPaintRates) + isSamePaintRates(this.lastState.teamPaintRates, snapshot.teamPaintRates) && + isSameLocalPlayerPosition( + this.lastState.localPlayerPosition, + snapshot.localPlayerPosition, + ) ) { return; } diff --git a/apps/client/src/scenes/game/hooks/useGameSceneController.ts b/apps/client/src/scenes/game/hooks/useGameSceneController.ts index 3b1b718..55b3cb1 100644 --- a/apps/client/src/scenes/game/hooks/useGameSceneController.ts +++ b/apps/client/src/scenes/game/hooks/useGameSceneController.ts @@ -29,6 +29,10 @@ DEFAULT_TEAM_PAINT_RATES, ); const [localBombHitCount, setLocalBombHitCount] = useState(0); + const [localPlayerPosition, setLocalPlayerPosition] = useState<{ + x: number; + y: number; + } | null>(null); useEffect(() => { if (!pixiContainerRef.current || !myId) return; @@ -53,6 +57,7 @@ setTeamPaintRates(state.teamPaintRates); setLocalBombHitCount(state.localBombHitCount); + setLocalPlayerPosition(state.localPlayerPosition); }); return () => { @@ -63,6 +68,7 @@ setIsInputEnabled(false); setTeamPaintRates(DEFAULT_TEAM_PAINT_RATES); setLocalBombHitCount(0); + setLocalPlayerPosition(null); }; }, [myId]); @@ -81,6 +87,7 @@ isInputEnabled, teamPaintRates, localBombHitCount, + localPlayerPosition, handleInput, handlePlaceBomb, }; diff --git a/apps/client/src/scenes/game/input/minimap/presentation/MiniMapPanel.styles.ts b/apps/client/src/scenes/game/input/minimap/presentation/MiniMapPanel.styles.ts new file mode 100644 index 0000000..f73e59b --- /dev/null +++ b/apps/client/src/scenes/game/input/minimap/presentation/MiniMapPanel.styles.ts @@ -0,0 +1,63 @@ +/** + * MiniMapPanel.styles + * ミニマップパネルの描画スタイルを集約する + * トグルボタンとマップ表示の見た目を定義する + */ +import type { CSSProperties } from "react"; + +/** ミニマップコンテナのスタイル */ +export const MINIMAP_PANEL_ROOT_STYLE: CSSProperties = { + display: "flex", + flexDirection: "column", + alignItems: "flex-end", + gap: "8px", + pointerEvents: "auto", +}; + +/** ミニマップ開閉ボタンのスタイルを返す */ +export const buildMiniMapToggleButtonStyle = ( + isOpen: boolean, +): CSSProperties => ({ + minWidth: "84px", + height: "32px", + padding: "0 10px", + borderRadius: "999px", + border: "1px solid rgba(255,255,255,0.55)", + background: isOpen ? "rgba(55, 18, 18, 0.82)" : "rgba(14, 14, 14, 0.82)", + color: "#ffffff", + fontFamily: "monospace", + fontWeight: 800, + fontSize: "12px", + cursor: "pointer", + textShadow: "1px 1px 2px rgba(0,0,0,0.65)", +}); + +/** ミニマップ表示領域のスタイル */ +export const MINIMAP_FRAME_STYLE: CSSProperties = { + position: "relative", + width: "128px", + height: "128px", + border: "2px solid rgba(255,255,255,0.65)", + borderRadius: "10px", + background: + "linear-gradient(160deg, rgba(34,34,34,0.92), rgba(12,12,12,0.92))", + boxShadow: "0 0 8px rgba(0,0,0,0.45)", + overflow: "hidden", +}; + +/** ミニマップ内の現在地ドットスタイルを返す */ +export const buildMiniMapDotStyle = ( + leftPx: number, + topPx: number, +): CSSProperties => ({ + position: "absolute", + left: `${leftPx}px`, + top: `${topPx}px`, + width: "10px", + height: "10px", + borderRadius: "50%", + border: "2px solid rgba(0,0,0,0.7)", + background: "#ffffff", + boxShadow: "0 0 6px rgba(255,255,255,0.8)", + transform: "translate(-50%, -50%)", +}); diff --git a/apps/client/src/scenes/game/input/minimap/presentation/MiniMapPanel.tsx b/apps/client/src/scenes/game/input/minimap/presentation/MiniMapPanel.tsx new file mode 100644 index 0000000..feaec53 --- /dev/null +++ b/apps/client/src/scenes/game/input/minimap/presentation/MiniMapPanel.tsx @@ -0,0 +1,78 @@ +/** + * MiniMapPanel + * ミニマップの開閉操作と表示を担うプレゼンテーションコンポーネント + * 全体マップ枠とローカルプレイヤー現在地のみを描画する + */ +import { useMemo, useState } from "react"; +import { config } from "@client/config"; +import { + buildMiniMapDotStyle, + buildMiniMapToggleButtonStyle, + MINIMAP_FRAME_STYLE, + MINIMAP_PANEL_ROOT_STYLE, +} from "./MiniMapPanel.styles"; + +const MINIMAP_FRAME_SIZE_PX = 128; + +/** ミニマップの入力プロパティ */ +export type MiniMapPanelProps = { + localPlayerPosition: { x: number; y: number } | null; +}; + +const clamp = (value: number, min: number, max: number): number => { + return Math.max(min, Math.min(value, max)); +}; + +/** 全体マップ上のローカル位置を示すミニマップを描画する */ +export const MiniMapPanel = ({ localPlayerPosition }: MiniMapPanelProps) => { + const [isOpen, setIsOpen] = useState(false); + + const markerPosition = useMemo(() => { + if (!localPlayerPosition) { + return null; + } + + const width = config.GAME_CONFIG.MAP_WIDTH_PX; + const height = config.GAME_CONFIG.MAP_HEIGHT_PX; + if (width <= 0 || height <= 0) { + return null; + } + + const normalizedX = clamp(localPlayerPosition.x / width, 0, 1); + const normalizedY = clamp(localPlayerPosition.y / height, 0, 1); + + return { + leftPx: normalizedX * MINIMAP_FRAME_SIZE_PX, + topPx: normalizedY * MINIMAP_FRAME_SIZE_PX, + }; + }, [localPlayerPosition]); + + const buttonStyle = buildMiniMapToggleButtonStyle(isOpen); + + return ( +
+ + + {isOpen && ( +
+ {markerPosition && ( +
+ )} +
+ )} +
+ ); +}; diff --git a/apps/client/src/scenes/game/styles/GameView.styles.ts b/apps/client/src/scenes/game/styles/GameView.styles.ts index 5be6c99..f6d723d 100644 --- a/apps/client/src/scenes/game/styles/GameView.styles.ts +++ b/apps/client/src/scenes/game/styles/GameView.styles.ts @@ -32,12 +32,19 @@ WebkitUserSelect: "none", }; -/** 右上のチーム塗り率パネルスタイル */ -export const GAME_VIEW_PAINT_RATE_PANEL_STYLE: CSSProperties = { +/** 右上オーバーレイのコンテナスタイル */ +export const GAME_VIEW_TOP_RIGHT_OVERLAY_STYLE: CSSProperties = { position: "absolute", top: "20px", right: "16px", zIndex: 12, + display: "flex", + alignItems: "flex-start", + gap: "12px", +}; + +/** 右上のチーム塗り率パネルスタイル */ +export const GAME_VIEW_PAINT_RATE_PANEL_STYLE: CSSProperties = { color: "white", fontSize: "14px", fontWeight: 700,