diff --git a/apps/client/src/managers/GameManager.ts b/apps/client/src/managers/GameManager.ts new file mode 100644 index 0000000..0611b10 --- /dev/null +++ b/apps/client/src/managers/GameManager.ts @@ -0,0 +1,157 @@ +import { Application, Container, Ticker } from "pixi.js"; +import { socketClient } from "../network/SocketClient"; +import { GAME_CONFIG } from "@repo/shared/src/config/gameConfig"; +import type { PlayerData } from "@repo/shared/src/types/player"; +import { BasePlayer, LocalPlayer, RemotePlayer } from "../entities/Player"; +import { GameMap } from "../entities/GameMap"; +import { MAX_DIST } from "../input/VirtualJoystick"; + +export class GameManager { + private app: Application; + private worldContainer: Container; + private players: Record = {}; + private myId: string; + private container: HTMLDivElement; + + // 入力と状態管理 + private joystickInput = { x: 0, y: 0 }; + private lastPositionSentTime = 0; + private wasMoving = false; + private isInitialized = false; + private isDestroyed = false; + + constructor(container: HTMLDivElement, myId: string) { + this.container = container; // 明示的に代入 + this.myId = myId; + this.app = new Application(); + this.worldContainer = new Container(); + } + + /** + * ゲームエンジンの初期化 + */ + public async init() { + // PixiJS本体の初期化 + await this.app.init({ resizeTo: window, backgroundColor: 0x111111, antialias: true }); + + // 初期化完了前に destroy() が呼ばれていたら、ここで処理を中断して破棄する + if (this.isDestroyed) { + this.app.destroy(true, { children: true }); + return; + } + + this.container.appendChild(this.app.canvas); + + // 背景マップの配置 + const gameMap = new GameMap(); + this.worldContainer.addChild(gameMap); + this.app.stage.addChild(this.worldContainer); + + // ネットワークイベントの登録 + this.setupSocketListeners(); + + // サーバーへゲーム準備完了を通知 + socketClient.readyForGame(); + + // メインループの登録 + this.app.ticker.add(this.tick.bind(this)); + this.isInitialized = true; + } + + /** + * React側からジョイスティックの入力を受け取る + */ + public setJoystickInput(x: number, y: number) { + this.joystickInput = { x, y }; + } + + /** + * ソケットイベントの登録 + */ + private setupSocketListeners() { + socketClient.onCurrentPlayers((serverPlayers: PlayerData[] | Record) => { + const playersArray = (Array.isArray(serverPlayers) ? serverPlayers : Object.values(serverPlayers)) as PlayerData[]; + playersArray.forEach((p) => { + const playerSprite = p.id === this.myId ? new LocalPlayer(p) : new RemotePlayer(p); + this.worldContainer.addChild(playerSprite); + this.players[p.id] = playerSprite; + }); + }); + + socketClient.onNewPlayer((p: PlayerData) => { + const playerSprite = new RemotePlayer(p); + this.worldContainer.addChild(playerSprite); + this.players[p.id] = playerSprite; + }); + + socketClient.onUpdatePlayer((data: Partial & { id: string }) => { + if (data.id === this.myId) return; + const target = this.players[data.id]; + if (target && target instanceof RemotePlayer) { + target.setTargetPosition(data.x, data.y); + } + }); + + socketClient.onRemovePlayer((id: string) => { + const target = this.players[id]; + if (target) { + this.worldContainer.removeChild(target); + target.destroy(); + delete this.players[id]; + } + }); + } + + /** + * 毎フレームの更新処理(メインゲームループ) + */ + private tick(ticker: Ticker) { + const me = this.players[this.myId]; + if (!me || !(me instanceof LocalPlayer)) return; + + // 1. 自プレイヤーの移動と送信 + const { x: dx, y: dy } = this.joystickInput; + const isMoving = dx !== 0 || dy !== 0; + + if (isMoving) { + me.move(dx / MAX_DIST, dy / MAX_DIST, ticker.deltaTime); + + const now = performance.now(); + if (now - this.lastPositionSentTime >= GAME_CONFIG.PLAYER_POSITION_UPDATE_MS) { + socketClient.sendMove(me.x, me.y); + this.lastPositionSentTime = now; + } + } else if (this.wasMoving) { + socketClient.sendMove(me.x, me.y); + } + this.wasMoving = isMoving; + + // 2. 全プレイヤーの更新(Lerpなど) + Object.values(this.players).forEach((player) => { + player.update(ticker.deltaTime); + }); + + // 3. カメラの追従(自分を中心に) + this.worldContainer.position.set( + -(me.x - this.app.screen.width / 2), + -(me.y - this.app.screen.height / 2) + ); + } + + /** + * クリーンアップ処理(コンポーネントアンマウント時) + */ + public destroy() { + this.isDestroyed = true; + if (this.isInitialized) { + this.app.destroy(true, { children: true }); + } + this.players = {}; + + // イベント購読の解除 + socketClient.socket.off("current_players"); + socketClient.socket.off("new_player"); + socketClient.socket.off("update_player"); + socketClient.socket.off("remove_player"); + } +} \ No newline at end of file diff --git a/apps/client/src/scenes/GameScene.tsx b/apps/client/src/scenes/GameScene.tsx index b9547be..479d959 100644 --- a/apps/client/src/scenes/GameScene.tsx +++ b/apps/client/src/scenes/GameScene.tsx @@ -1,16 +1,6 @@ import { useEffect, useRef } from "react"; -import { Application, Container } from "pixi.js"; - -// ネットワーク・入力関連 -import { socketClient } from "../network/SocketClient"; -import { VirtualJoystick, MAX_DIST } from "../input/VirtualJoystick"; -import { GAME_CONFIG } from "@repo/shared/src/config/gameConfig"; -import { type PlayerData } from "@repo/shared/src/types/player"; - -// ゲーム描画オブジェクト -import { GameMap } from "../entities/GameMap"; -// 変更点: BasePlayer, LocalPlayer, RemotePlayer をインポート -import { BasePlayer, LocalPlayer, RemotePlayer } from "../entities/Player"; +import { VirtualJoystick } from "../input/VirtualJoystick"; +import { GameManager } from "../managers/GameManager"; interface GameSceneProps { myId: string | null; @@ -18,153 +8,42 @@ /** * メインゲーム画面コンポーネント - * PixiJS 初期化・エンティティ管理・ソケット同期処理 + * UIの描画と GameManager への入力伝達のみを担当する */ export function GameScene({ myId }: GameSceneProps) { const pixiContainerRef = useRef(null); - // ジョイスティック入力値 - const joystickInputRef = useRef({ x: 0, y: 0 }); - - // 変更点: 型を共通基底クラス BasePlayer に変更 - const playersRef = useRef>({}); - - // 位置送信間隔制御用タイムスタンプ - const lastPositionSentTimeRef = useRef(0); - // 前フレーム移動状態 - const wasMovingRef = useRef(false); - - // 💡 削除: targetPositionsRef は RemotePlayer 内にカプセル化されたため不要になりました! + const gameManagerRef = useRef(null); useEffect(() => { - if (!pixiContainerRef.current) return; + if (!pixiContainerRef.current || !myId) return; - let isCancelled = false; - let isInitialized = false; - const app = new Application(); - const worldContainer = new Container(); + // GameManager のインスタンス化と初期化 + const manager = new GameManager(pixiContainerRef.current, myId); + manager.init(); + + // 参照を保持(ジョイスティック入力を渡すため) + gameManagerRef.current = manager; - const initPixi = async () => { - // 背景マップ最背面配置 - const gameMap = new GameMap(); - worldContainer.addChild(gameMap); - - // --- ソケットイベント購読登録 --- - - // 参加済みプレイヤー初期スプライト生成 - socketClient.onCurrentPlayers((serverPlayers: PlayerData[] | Record) => { - const playersArray = (Array.isArray(serverPlayers) ? serverPlayers : Object.values(serverPlayers)) as PlayerData[]; - playersArray.forEach((p) => { - // 変更点: 自身か他プレイヤーかで生成するインスタンスを切り替え - const playerSprite = p.id === myId - ? new LocalPlayer(p) - : new RemotePlayer(p); - - worldContainer.addChild(playerSprite); - playersRef.current[p.id] = playerSprite; - }); - }); - - // 新規参加プレイヤー追加処理 - socketClient.onNewPlayer((p: PlayerData) => { - // 新規参加は必ず他人なので RemotePlayer を生成 - const playerSprite = new RemotePlayer(p); - worldContainer.addChild(playerSprite); - playersRef.current[p.id] = playerSprite; - }); - - // 他プレイヤー目標座標更新 - socketClient.onUpdatePlayer((data: Partial & { id: string }) => { - if (data.id === myId) return; - - const target = playersRef.current[data.id]; - // 変更点: RemotePlayer のメソッドを呼び出して目標座標をセットするだけ - if (target && target instanceof RemotePlayer) { - target.setTargetPosition(data.x, data.y); - } - }); - - // 退出プレイヤー参照削除とオブジェクト破棄 - socketClient.onRemovePlayer((id: string) => { - const target = playersRef.current[id]; - if (target) { - worldContainer.removeChild(target); - target.destroy(); - delete playersRef.current[id]; - } - }); - - // --- PixiJS 本体初期化 --- - await app.init({ resizeTo: window, backgroundColor: 0x111111, antialias: true }); - isInitialized = true; - - if (isCancelled) { - app.destroy(true, { children: true }); - return; - } - - pixiContainerRef.current?.appendChild(app.canvas); - app.stage.addChild(worldContainer); - - socketClient.readyForGame(); - - // --- メインゲームループ --- - app.ticker.add((ticker) => { - if (!myId) return; - const me = playersRef.current[myId]; - // 自身が LocalPlayer であることを担保 - if (!me || !(me instanceof LocalPlayer)) return; - - // 自プレイヤー移動と送信処理 - const { x: dx, y: dy } = joystickInputRef.current; - const isMoving = dx !== 0 || dy !== 0; - - if (isMoving) { - me.move(dx / MAX_DIST, dy / MAX_DIST, ticker.deltaTime); - - const now = performance.now(); - if (now - lastPositionSentTimeRef.current >= GAME_CONFIG.PLAYER_POSITION_UPDATE_MS) { - socketClient.sendMove(me.x, me.y); - lastPositionSentTimeRef.current = now; - } - } else if (wasMovingRef.current) { - socketClient.sendMove(me.x, me.y); - } - wasMovingRef.current = isMoving; - - // 変更点: ループ内の補間処理がなくなり、一律に update を呼ぶだけになりました!(ポリモーフィズム) - Object.values(playersRef.current).forEach((player) => { - player.update(ticker.deltaTime); - }); - - // 自プレイヤー中心表示向けワールド逆方向オフセット(カメラ追従) - worldContainer.position.set( - -(me.x - app.screen.width / 2), - -(me.y - app.screen.height / 2) - ); - }); - }; - - initPixi(); - + // コンポーネント破棄時のクリーンアップ return () => { - isCancelled = true; - if (isInitialized) { - app.destroy(true, { children: true }); - } - playersRef.current = {}; - - socketClient.socket.off("current_players"); - socketClient.socket.off("new_player"); - socketClient.socket.off("update_player"); - socketClient.socket.off("remove_player"); + manager.destroy(); + gameManagerRef.current = null; }; }, [myId]); return (
+ {/* PixiJS Canvas 配置領域 */}
+ + {/* UI 配置領域 */}
- { joystickInputRef.current = { x, y }; }} /> + { + // ジョイスティックの入力を毎フレーム Manager に渡す + gameManagerRef.current?.setJoystickInput(x, y); + }} + />
);