diff --git a/apps/client/src/scenes/game/GameInputManager.ts b/apps/client/src/scenes/game/GameInputManager.ts index abf6670..cb5dbde 100644 --- a/apps/client/src/scenes/game/GameInputManager.ts +++ b/apps/client/src/scenes/game/GameInputManager.ts @@ -2,17 +2,15 @@ * GameInputManager * ゲーム側へ入力を集約して橋渡しする */ -import { GameManager } from "./GameManager"; - /** ジョイスティック入力をゲーム管理へ橋渡しするマネージャー */ export class GameInputManager { - private gameManager: GameManager; + private onJoystickInput: (x: number, y: number) => void; - constructor(gameManager: GameManager) { - this.gameManager = gameManager; + constructor(onJoystickInput: (x: number, y: number) => void) { + this.onJoystickInput = onJoystickInput; } public handleJoystickInput = (x: number, y: number) => { - this.gameManager.setJoystickInput(x, y); + this.onJoystickInput(x, y); }; } diff --git a/apps/client/src/scenes/game/GameManager.ts b/apps/client/src/scenes/game/GameManager.ts index 796697c..1ca9fbe 100644 --- a/apps/client/src/scenes/game/GameManager.ts +++ b/apps/client/src/scenes/game/GameManager.ts @@ -1,39 +1,34 @@ import { Application, Container, Ticker } from "pixi.js"; import { socketManager } from "@client/network/SocketManager"; -import { config } from "@repo/shared"; -import type { playerTypes } from "@repo/shared"; -import { LocalPlayerController, RemotePlayerController } from "./entities/player/PlayerController"; import { GameMapController } from "./entities/map/GameMapController"; +import { GameTimer } from "./application/GameTimer"; +import { GameNetworkSync } from "./application/GameNetworkSync"; +import { GameLoop } from "./application/GameLoop"; +import type { GamePlayers } from "./application/game.types"; export class GameManager { private app: Application; private worldContainer: Container; - private players: Record = {}; + private players: GamePlayers = {}; private myId: string; private container: HTMLDivElement; private gameMap!: GameMapController; - private gameStartTime: number | null = null; + private timer = new GameTimer(); + private networkSync: GameNetworkSync | null = null; + private gameLoop: GameLoop | null = null; // サーバーからゲーム開始通知(と開始時刻)を受け取った時に呼ぶ public setGameStart(startTime: number) { - this.gameStartTime = startTime; + this.timer.setGameStart(startTime); } // 現在の残り秒数を取得する public getRemainingTime(): number { - if (!this.gameStartTime) return config.GAME_CONFIG.GAME_DURATION_SEC; - - // 現在のサーバー時刻を推定 (Date.now() + サーバーとの誤差補正が理想ですが、まずは簡易版で) - const elapsedMs = Date.now() - this.gameStartTime; - const remainingSec = config.GAME_CONFIG.GAME_DURATION_SEC - (elapsedMs / 1000); - - return Math.max(0, remainingSec); + return this.timer.getRemainingTime(); } // 入力と状態管理 private joystickInput = { x: 0, y: 0 }; - private lastPositionSentTime = 0; - private wasMoving = false; private isInitialized = false; private isDestroyed = false; @@ -65,14 +60,28 @@ this.worldContainer.addChild(gameMap.getDisplayObject()); this.app.stage.addChild(this.worldContainer); - // ネットワークイベントの登録 - this.setupSocketListeners(); + this.networkSync = new GameNetworkSync({ + worldContainer: this.worldContainer, + players: this.players, + myId: this.myId, + gameMap: this.gameMap, + onGameStart: this.setGameStart.bind(this), + }); + this.networkSync.bind(); + + this.gameLoop = new GameLoop({ + app: this.app, + worldContainer: this.worldContainer, + players: this.players, + myId: this.myId, + getJoystickInput: () => this.joystickInput, + }); // サーバーへゲーム準備完了を通知 socketManager.game.readyForGame(); // メインループの登録 - this.app.ticker.add(this.tick.bind(this)); + this.app.ticker.add(this.tick); this.isInitialized = true; } @@ -84,100 +93,11 @@ } /** - * ソケットイベントの登録 - */ - private setupSocketListeners() { - socketManager.game.onCurrentPlayers((serverPlayers: playerTypes.PlayerData[] | Record) => { - const playersArray = (Array.isArray(serverPlayers) ? serverPlayers : Object.values(serverPlayers)) as playerTypes.PlayerData[]; - playersArray.forEach((p) => { - const playerController = p.id === this.myId ? new LocalPlayerController(p) : new RemotePlayerController(p); - this.worldContainer.addChild(playerController.getDisplayObject()); - this.players[p.id] = playerController; - }); - }); - - socketManager.game.onNewPlayer((p: playerTypes.PlayerData) => { - const playerController = new RemotePlayerController(p); - this.worldContainer.addChild(playerController.getDisplayObject()); - this.players[p.id] = playerController; - }); - - // サーバーからの GAME_START を検知して開始時刻をセットする - socketManager.game.onGameStart((data) => { - if (data && data.startTime) { - this.setGameStart(data.startTime); - console.log(`[GameManager] ゲーム開始時刻同期完了: ${data.startTime}`); - } - }); - - socketManager.game.onUpdatePlayer((data: Partial & { id: string }) => { - if (data.id === this.myId) return; - const target = this.players[data.id]; - if (target && target instanceof RemotePlayerController) { - target.applyRemoteUpdate({ x: data.x, y: data.y }); - } - }); - - socketManager.game.onRemovePlayer((id: string) => { - const target = this.players[id]; - if (target) { - this.worldContainer.removeChild(target.getDisplayObject()); - target.destroy(); - delete this.players[id]; - } - }); - - socketManager.game.onUpdateMapCells((updates) => { - this.gameMap.updateCells(updates); - }); - } - - /** * 毎フレームの更新処理(メインゲームループ) */ - private tick(ticker: Ticker) { - const me = this.players[this.myId]; - if (!me || !(me instanceof LocalPlayerController)) return; - - const deltaSeconds = ticker.deltaMS / 1000; - - // 1. 自プレイヤーの移動と送信 - const { x: dx, y: dy } = this.joystickInput; - const isMoving = dx !== 0 || dy !== 0; - - if (isMoving) { - me.applyLocalInput({ axisX: dx, axisY: dy, deltaTime: deltaSeconds }); - me.tick(); - - const now = performance.now(); - if (now - this.lastPositionSentTime >= config.GAME_CONFIG.PLAYER_POSITION_UPDATE_MS) { - const position = me.getPosition(); - socketManager.game.sendMove(position.x, position.y); - this.lastPositionSentTime = now; - } - } else if (this.wasMoving) { - me.tick(); - const position = me.getPosition(); - socketManager.game.sendMove(position.x, position.y); - } else { - me.tick(); - } - this.wasMoving = isMoving; - - // 2. 全プレイヤーの更新(Lerpなど) - Object.values(this.players).forEach((player) => { - if (player instanceof RemotePlayerController) { - player.tick(deltaSeconds); - } - }); - - // 3. カメラの追従(自分を中心に) - const meDisplay = me.getDisplayObject(); - this.worldContainer.position.set( - -(meDisplay.x - this.app.screen.width / 2), - -(meDisplay.y - this.app.screen.height / 2) - ); - } + private tick = (ticker: Ticker) => { + this.gameLoop?.tick(ticker); + }; /** * クリーンアップ処理(コンポーネントアンマウント時) @@ -190,6 +110,6 @@ this.players = {}; // イベント購読の解除 - socketManager.game.removeAllListeners(); + this.networkSync?.unbind(); } } \ No newline at end of file diff --git a/apps/client/src/scenes/game/GameScene.tsx b/apps/client/src/scenes/game/GameScene.tsx index 96b9d84..e7432ba 100644 --- a/apps/client/src/scenes/game/GameScene.tsx +++ b/apps/client/src/scenes/game/GameScene.tsx @@ -3,11 +3,8 @@ * メインゲーム画面の表示とライフサイクルを管理する * GameManagerの初期化と入力配線を行う */ -import { useCallback, useEffect, useRef, useState } from "react"; -import { GameInputManager } from "./GameInputManager"; -import { GameManager } from "./GameManager"; import { GameView } from "./GameView"; -import { config } from "@repo/shared"; +import { useGameSceneController } from "./hooks/useGameSceneController"; /** GameScene の入力プロパティ */ interface GameSceneProps { @@ -19,52 +16,7 @@ * UIの描画と GameManager への入力伝達のみを担当する */ export function GameScene({ myId }: GameSceneProps) { - const pixiContainerRef = useRef(null); - const gameManagerRef = useRef(null); - const inputManagerRef = useRef(null); - - // gameConfig から初期表示時間文字列を生成する関数 - const getInitialTimeDisplay = () => { - const totalSec = config.GAME_CONFIG.GAME_DURATION_SEC; - const mins = Math.floor(totalSec / 60); - const secs = Math.floor(totalSec % 60); - return `${mins}:${secs.toString().padStart(2, '0')}`; - }; - - // 初期値に関数を使用 - const [timeLeft, setTimeLeft] = useState(getInitialTimeDisplay()); - - useEffect(() => { - if (!pixiContainerRef.current || !myId) return; - - // GameManager のインスタンス化と初期化 - const manager = new GameManager(pixiContainerRef.current, myId); - manager.init(); - - // 参照を保持(入力を渡すため) - gameManagerRef.current = manager; - inputManagerRef.current = new GameInputManager(manager); - - // 描画用のタイマーループ (100msごとに更新して滑らかにする) - const timerInterval = setInterval(() => { - const remaining = manager.getRemainingTime(); - const mins = Math.floor(remaining / 60); - const secs = Math.floor(remaining % 60); - // 2:59 の形式にフォーマット - setTimeLeft(`${mins}:${secs.toString().padStart(2, '0')}`); - }, 100); - - // コンポーネント破棄時のクリーンアップ - return () => { - manager.destroy(); - inputManagerRef.current = null; - clearInterval(timerInterval); // クリーンアップ - }; - }, [myId]); - - const handleInput = useCallback((x: number, y: number) => { - inputManagerRef.current?.handleJoystickInput(x, y); - }, []); + const { pixiContainerRef, timeLeft, handleInput } = useGameSceneController(myId); return ( void; }; +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 UI_LAYER_STYLE: React.CSSProperties = { + position: "absolute", + zIndex: 20, + width: "100%", + height: "100%", +}; + +const TimerOverlay = ({ timeLeft }: { timeLeft: string }) =>
{timeLeft}
; + /** 画面描画と入力UIをまとめて描画する */ export const GameView = ({ timeLeft, pixiContainerRef, onJoystickInput }: Props) => { return ( -
+
{/* タイマーUIの表示 */} -
- {timeLeft} -
+ {/* PixiJS Canvas 配置領域 */} -
+
{/* UI 配置領域 */} -
+
diff --git a/apps/client/src/scenes/game/application/GameLoop.ts b/apps/client/src/scenes/game/application/GameLoop.ts new file mode 100644 index 0000000..77e5fd1 --- /dev/null +++ b/apps/client/src/scenes/game/application/GameLoop.ts @@ -0,0 +1,69 @@ +import { Application, Container, Ticker } from "pixi.js"; +import { config } from "@repo/shared"; +import { socketManager } from "@client/network/SocketManager"; +import { LocalPlayerController, RemotePlayerController } from "../entities/player/PlayerController"; +import type { GamePlayers } from "./game.types"; + +type GameLoopOptions = { + app: Application; + worldContainer: Container; + players: GamePlayers; + myId: string; + getJoystickInput: () => { x: number; y: number }; +}; + +export class GameLoop { + private app: Application; + private worldContainer: Container; + private players: GamePlayers; + private myId: string; + private getJoystickInput: () => { x: number; y: number }; + private lastPositionSentTime = 0; + private wasMoving = false; + + constructor({ app, worldContainer, players, myId, getJoystickInput }: GameLoopOptions) { + this.app = app; + this.worldContainer = worldContainer; + this.players = players; + this.myId = myId; + this.getJoystickInput = getJoystickInput; + } + + public tick = (ticker: Ticker) => { + const me = this.players[this.myId]; + if (!me || !(me instanceof LocalPlayerController)) return; + + const deltaSeconds = ticker.deltaMS / 1000; + + const { x: dx, y: dy } = this.getJoystickInput(); + const isMoving = dx !== 0 || dy !== 0; + + if (isMoving) { + me.applyLocalInput({ axisX: dx, axisY: dy, deltaTime: deltaSeconds }); + me.tick(); + + const now = performance.now(); + if (now - this.lastPositionSentTime >= config.GAME_CONFIG.PLAYER_POSITION_UPDATE_MS) { + const position = me.getPosition(); + socketManager.game.sendMove(position.x, position.y); + this.lastPositionSentTime = now; + } + } else if (this.wasMoving) { + me.tick(); + const position = me.getPosition(); + socketManager.game.sendMove(position.x, position.y); + } else { + me.tick(); + } + this.wasMoving = isMoving; + + Object.values(this.players).forEach((player) => { + if (player instanceof RemotePlayerController) { + player.tick(deltaSeconds); + } + }); + + const meDisplay = me.getDisplayObject(); + this.worldContainer.position.set(-(meDisplay.x - this.app.screen.width / 2), -(meDisplay.y - this.app.screen.height / 2)); + }; +} diff --git a/apps/client/src/scenes/game/application/GameNetworkSync.ts b/apps/client/src/scenes/game/application/GameNetworkSync.ts new file mode 100644 index 0000000..79d2d59 --- /dev/null +++ b/apps/client/src/scenes/game/application/GameNetworkSync.ts @@ -0,0 +1,79 @@ +import { Container } from "pixi.js"; +import type { playerTypes } from "@repo/shared"; +import { socketManager } from "@client/network/SocketManager"; +import { LocalPlayerController, RemotePlayerController } from "../entities/player/PlayerController"; +import { GameMapController } from "../entities/map/GameMapController"; +import type { GamePlayers } from "./game.types"; + +type GameNetworkSyncOptions = { + worldContainer: Container; + players: GamePlayers; + myId: string; + gameMap: GameMapController; + onGameStart: (startTime: number) => void; +}; + +export class GameNetworkSync { + private worldContainer: Container; + private players: GamePlayers; + private myId: string; + private gameMap: GameMapController; + private onGameStart: (startTime: number) => void; + + constructor({ worldContainer, players, myId, gameMap, onGameStart }: GameNetworkSyncOptions) { + this.worldContainer = worldContainer; + this.players = players; + this.myId = myId; + this.gameMap = gameMap; + this.onGameStart = onGameStart; + } + + public bind() { + socketManager.game.onCurrentPlayers((serverPlayers: playerTypes.PlayerData[] | Record) => { + const playersArray = (Array.isArray(serverPlayers) ? serverPlayers : Object.values(serverPlayers)) as playerTypes.PlayerData[]; + playersArray.forEach((p) => { + const playerController = p.id === this.myId ? new LocalPlayerController(p) : new RemotePlayerController(p); + this.worldContainer.addChild(playerController.getDisplayObject()); + this.players[p.id] = playerController; + }); + }); + + socketManager.game.onNewPlayer((p: playerTypes.PlayerData) => { + const playerController = new RemotePlayerController(p); + this.worldContainer.addChild(playerController.getDisplayObject()); + this.players[p.id] = playerController; + }); + + socketManager.game.onGameStart((data) => { + if (data && data.startTime) { + this.onGameStart(data.startTime); + console.log(`[GameManager] ゲーム開始時刻同期完了: ${data.startTime}`); + } + }); + + socketManager.game.onUpdatePlayer((data: Partial & { id: string }) => { + if (data.id === this.myId) return; + const target = this.players[data.id]; + if (target && target instanceof RemotePlayerController) { + target.applyRemoteUpdate({ x: data.x, y: data.y }); + } + }); + + socketManager.game.onRemovePlayer((id: string) => { + const target = this.players[id]; + if (target) { + this.worldContainer.removeChild(target.getDisplayObject()); + target.destroy(); + delete this.players[id]; + } + }); + + socketManager.game.onUpdateMapCells((updates) => { + this.gameMap.updateCells(updates); + }); + } + + public unbind() { + socketManager.game.removeAllListeners(); + } +} diff --git a/apps/client/src/scenes/game/application/GameTimer.ts b/apps/client/src/scenes/game/application/GameTimer.ts new file mode 100644 index 0000000..61a6148 --- /dev/null +++ b/apps/client/src/scenes/game/application/GameTimer.ts @@ -0,0 +1,18 @@ +import { config } from "@repo/shared"; + +export class GameTimer { + private gameStartTime: number | null = null; + + public setGameStart(startTime: number) { + this.gameStartTime = startTime; + } + + public getRemainingTime(): number { + if (!this.gameStartTime) return config.GAME_CONFIG.GAME_DURATION_SEC; + + const elapsedMs = Date.now() - this.gameStartTime; + const remainingSec = config.GAME_CONFIG.GAME_DURATION_SEC - elapsedMs / 1000; + + return Math.max(0, remainingSec); + } +} diff --git a/apps/client/src/scenes/game/application/game.types.ts b/apps/client/src/scenes/game/application/game.types.ts new file mode 100644 index 0000000..f008fdd --- /dev/null +++ b/apps/client/src/scenes/game/application/game.types.ts @@ -0,0 +1,4 @@ +import { LocalPlayerController, RemotePlayerController } from "../entities/player/PlayerController"; + +export type GamePlayerController = LocalPlayerController | RemotePlayerController; +export type GamePlayers = Record; diff --git a/apps/client/src/scenes/game/hooks/useGameSceneController.ts b/apps/client/src/scenes/game/hooks/useGameSceneController.ts new file mode 100644 index 0000000..62eb640 --- /dev/null +++ b/apps/client/src/scenes/game/hooks/useGameSceneController.ts @@ -0,0 +1,52 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { config } from "@repo/shared"; +import { GameInputManager } from "../GameInputManager"; +import { GameManager } from "../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); + +export const useGameSceneController = (myId: string | null) => { + const pixiContainerRef = useRef(null); + const gameManagerRef = useRef(null); + const inputManagerRef = useRef(null); + const [timeLeft, setTimeLeft] = useState(getInitialTimeDisplay()); + + useEffect(() => { + if (!pixiContainerRef.current || !myId) return; + + const manager = new GameManager(pixiContainerRef.current, myId); + manager.init(); + + gameManagerRef.current = manager; + inputManagerRef.current = new GameInputManager((x, y) => { + manager.setJoystickInput(x, y); + }); + + const timerInterval = setInterval(() => { + setTimeLeft(formatRemainingTime(manager.getRemainingTime())); + }, 100); + + return () => { + manager.destroy(); + gameManagerRef.current = null; + inputManagerRef.current = null; + clearInterval(timerInterval); + }; + }, [myId]); + + const handleInput = useCallback((x: number, y: number) => { + inputManagerRef.current?.handleJoystickInput(x, y); + }, []); + + return { + pixiContainerRef, + timeLeft, + handleInput, + }; +}; diff --git "a/docs/01_Env/ENV_01_\347\222\260\345\242\203\346\247\213\347\257\211\343\203\273\346\212\200\350\241\223\343\202\271\343\202\277\343\203\203\343\202\257.txt" "b/docs/01_Env/ENV_01_\347\222\260\345\242\203\346\247\213\347\257\211\343\203\273\346\212\200\350\241\223\343\202\271\343\202\277\343\203\203\343\202\257.txt" index c190a5a..8a7a56b 100644 --- "a/docs/01_Env/ENV_01_\347\222\260\345\242\203\346\247\213\347\257\211\343\203\273\346\212\200\350\241\223\343\202\271\343\202\277\343\203\203\343\202\257.txt" +++ "b/docs/01_Env/ENV_01_\347\222\260\345\242\203\346\247\213\347\257\211\343\203\273\346\212\200\350\241\223\343\202\271\343\202\277\343\203\203\343\202\257.txt" @@ -47,19 +47,45 @@ │ │ │ ├── main.tsx # 起動エントリ │ │ │ ├── assets/ │ │ │ │ └── preact.svg # ロゴ素材 - │ │ │ ├── entities/ - │ │ │ │ ├── GameMap.ts # マップ描画 - │ │ │ │ └── Player.ts # プレイヤー描画 - │ │ │ ├── input/ - │ │ │ │ └── VirtualJoystick.tsx # 仮想スティック - │ │ │ ├── managers/ - │ │ │ │ └── GameManager.ts # ゲーム制御 + │ │ │ ├── hooks/ + │ │ │ │ └── useAppFlow.ts # 画面遷移フック │ │ │ ├── network/ - │ │ │ │ └── SocketClient.ts # WSクライアント + │ │ │ │ ├── SocketManager.ts # WSクライアント + │ │ │ │ └── handlers/ + │ │ │ │ ├── CommonHandler.ts # 共通WSハンドラ + │ │ │ │ ├── GameHandler.ts # ゲームWSハンドラ + │ │ │ │ ├── LobbyHandler.ts # ロビーWSハンドラ + │ │ │ │ └── TitleHandler.ts # タイトルWSハンドラ │ │ │ └── scenes/ - │ │ │ ├── GameScene.tsx # ゲーム画面 - │ │ │ ├── LobbyScene.tsx # ロビー画面 - │ │ │ └── TitleScene.tsx # タイトル画面 + │ │ │ ├── game/ + │ │ │ │ ├── GameInputManager.ts # 入力管理 + │ │ │ │ ├── GameManager.ts # ゲーム制御 + │ │ │ │ ├── GameScene.tsx # ゲーム画面 + │ │ │ │ ├── GameView.tsx # ゲーム描画 + │ │ │ │ ├── entities/ + │ │ │ │ │ ├── map/ + │ │ │ │ │ │ ├── GameMapController.ts # マップ制御 + │ │ │ │ │ │ ├── GameMapModel.ts # マップモデル + │ │ │ │ │ │ └── GameMapView.ts # マップ描画 + │ │ │ │ │ └── player/ + │ │ │ │ │ ├── PlayerController.ts # プレイヤー制御 + │ │ │ │ │ ├── PlayerModel.ts # プレイヤーモデル + │ │ │ │ │ └── PlayerView.ts # プレイヤー描画 + │ │ │ │ └── input/ + │ │ │ │ ├── joystick/ + │ │ │ │ │ ├── JoystickInputPresenter.tsx # 仮想スティックUI + │ │ │ │ │ ├── JoystickModel.ts # スティックモデル + │ │ │ │ │ ├── JoystickView.tsx # スティック描画 + │ │ │ │ │ └── common/ + │ │ │ │ │ ├── index.ts # スティック共通 + │ │ │ │ │ ├── joystick.constants.ts # スティック定数 + │ │ │ │ │ └── joystick.types.ts # スティック型 + │ │ │ │ ├── useJoystickController.ts # スティック制御フック + │ │ │ │ └── useJoystickState.ts # スティック状態 + │ │ │ ├── lobby/ + │ │ │ │ └── LobbyScene.tsx # ロビー画面 + │ │ │ └── title/ + │ │ │ └── TitleScene.tsx # タイトル画面 │ │ ├── tsconfig.app.json # アプリTS設定 │ │ ├── tsconfig.json # TS親設定 │ │ ├── tsconfig.node.json # Node向けTS設定 @@ -110,6 +136,9 @@ │ │ ├── gameConfig.ts # 共通定数 │ │ └── networkConfig.ts # 通信設定 │ ├── domains/ + │ │ ├── app/ + │ │ │ ├── app.const.ts # アプリ定数 + │ │ │ └── app.type.ts # アプリ型 │ │ ├── gridMap/ │ │ │ ├── gridMap.logic.ts # マップ計算 │ │ │ └── gridMap.type.ts # マップ型定義