diff --git a/apps/client/src/hooks/useAppFlow.ts b/apps/client/src/hooks/useAppFlow.ts index a891fc9..b68228b 100644 --- a/apps/client/src/hooks/useAppFlow.ts +++ b/apps/client/src/hooks/useAppFlow.ts @@ -15,12 +15,26 @@ const [myId, setMyId] = useState(null); useEffect(() => { - socketManager.common.onConnect((id) => setMyId(id)); - socketManager.lobby.onRoomUpdate((updatedRoom) => { + const handleConnect = (id: string) => { + setMyId(id); + }; + const handleRoomUpdate = (updatedRoom: roomTypes.Room) => { setRoom(updatedRoom); setScenePhase(appConsts.ScenePhase.LOBBY); - }); - socketManager.game.onGameStart(() => setScenePhase(appConsts.ScenePhase.PLAYING)); + }; + const handleGameStart = () => { + setScenePhase(appConsts.ScenePhase.PLAYING); + }; + + socketManager.common.onConnect(handleConnect); + socketManager.lobby.onRoomUpdate(handleRoomUpdate); + socketManager.game.onGameStart(handleGameStart); + + return () => { + socketManager.common.offConnect(handleConnect); + socketManager.lobby.offRoomUpdate(handleRoomUpdate); + socketManager.game.offGameStart(handleGameStart); + }; }, []); return { scenePhase, room, myId }; diff --git a/apps/client/src/network/handlers/CommonHandler.ts b/apps/client/src/network/handlers/CommonHandler.ts index d76fe24..0c6d10b 100644 --- a/apps/client/src/network/handlers/CommonHandler.ts +++ b/apps/client/src/network/handlers/CommonHandler.ts @@ -3,18 +3,31 @@ type CommonHandler = { onConnect: (callback: (id: string) => void) => void; + offConnect: (callback: (id: string) => void) => void; }; export const createCommonHandler = (socket: Socket): CommonHandler => { + const connectListenerMap = new Map<(id: string) => void, () => void>(); + return { onConnect: (callback: (id: string) => void) => { if (socket.connected) { callback(socket.id || ""); } - socket.on(protocol.SocketEvents.CONNECT, () => { + const listener = () => { callback(socket.id || ""); - }); + }; + + connectListenerMap.set(callback, listener); + socket.on(protocol.SocketEvents.CONNECT, listener); + }, + offConnect: (callback: (id: string) => void) => { + const listener = connectListenerMap.get(callback); + if (!listener) return; + + socket.off(protocol.SocketEvents.CONNECT, listener); + connectListenerMap.delete(callback); } }; }; diff --git a/apps/client/src/network/handlers/GameHandler.ts b/apps/client/src/network/handlers/GameHandler.ts index 77abe3d..2d5b226 100644 --- a/apps/client/src/network/handlers/GameHandler.ts +++ b/apps/client/src/network/handlers/GameHandler.ts @@ -4,14 +4,19 @@ type GameHandler = { onCurrentPlayers: (callback: (players: playerTypes.PlayerData[] | Record) => void) => void; + offCurrentPlayers: (callback: (players: playerTypes.PlayerData[] | Record) => void) => void; onNewPlayer: (callback: (player: playerTypes.PlayerData) => void) => void; + offNewPlayer: (callback: (player: playerTypes.PlayerData) => void) => void; onUpdatePlayer: (callback: (data: Partial & { id: string }) => void) => void; + offUpdatePlayer: (callback: (data: Partial & { id: string }) => void) => void; onRemovePlayer: (callback: (id: string) => void) => void; + offRemovePlayer: (callback: (id: string) => void) => void; onUpdateMapCells: (callback: (updates: gridMapTypes.CellUpdate[]) => void) => void; + offUpdateMapCells: (callback: (updates: gridMapTypes.CellUpdate[]) => void) => void; onGameStart: (callback: (data: { startTime: number }) => void) => void; + offGameStart: (callback: (data: { startTime: number }) => void) => void; sendMove: (x: number, y: number) => void; readyForGame: () => void; - removeAllListeners: () => void; }; export const createGameHandler = (socket: Socket): GameHandler => { @@ -19,34 +24,45 @@ onCurrentPlayers: (callback) => { socket.on(protocol.SocketEvents.CURRENT_PLAYERS, callback); }, + offCurrentPlayers: (callback) => { + socket.off(protocol.SocketEvents.CURRENT_PLAYERS, callback); + }, onNewPlayer: (callback) => { socket.on(protocol.SocketEvents.NEW_PLAYER, callback); }, + offNewPlayer: (callback) => { + socket.off(protocol.SocketEvents.NEW_PLAYER, callback); + }, onUpdatePlayer: (callback) => { socket.on(protocol.SocketEvents.UPDATE_PLAYER, callback); }, + offUpdatePlayer: (callback) => { + socket.off(protocol.SocketEvents.UPDATE_PLAYER, callback); + }, onRemovePlayer: (callback) => { socket.on(protocol.SocketEvents.REMOVE_PLAYER, callback); }, + offRemovePlayer: (callback) => { + socket.off(protocol.SocketEvents.REMOVE_PLAYER, callback); + }, onUpdateMapCells: (callback) => { socket.on(protocol.SocketEvents.UPDATE_MAP_CELLS, callback); }, + offUpdateMapCells: (callback) => { + socket.off(protocol.SocketEvents.UPDATE_MAP_CELLS, callback); + }, onGameStart: (callback) => { socket.on(protocol.SocketEvents.GAME_START, callback); }, + offGameStart: (callback) => { + socket.off(protocol.SocketEvents.GAME_START, callback); + }, sendMove: (x, y) => { const payload: playerTypes.MovePayload = { x, y }; socket.emit(protocol.SocketEvents.MOVE, payload); }, readyForGame: () => { socket.emit(protocol.SocketEvents.READY_FOR_GAME); - }, - removeAllListeners: () => { - socket.off(protocol.SocketEvents.CURRENT_PLAYERS); - socket.off(protocol.SocketEvents.NEW_PLAYER); - socket.off(protocol.SocketEvents.UPDATE_PLAYER); - socket.off(protocol.SocketEvents.REMOVE_PLAYER); - socket.off(protocol.SocketEvents.UPDATE_MAP_CELLS); } }; }; diff --git a/apps/client/src/network/handlers/LobbyHandler.ts b/apps/client/src/network/handlers/LobbyHandler.ts index 863ab5b..b315f6c 100644 --- a/apps/client/src/network/handlers/LobbyHandler.ts +++ b/apps/client/src/network/handlers/LobbyHandler.ts @@ -4,6 +4,7 @@ type LobbyHandler = { onRoomUpdate: (callback: (room: roomTypes.Room) => void) => void; + offRoomUpdate: (callback: (room: roomTypes.Room) => void) => void; startGame: () => void; }; @@ -12,6 +13,9 @@ onRoomUpdate: (callback: (room: roomTypes.Room) => void) => { socket.on(protocol.SocketEvents.ROOM_UPDATE, callback); }, + offRoomUpdate: (callback: (room: roomTypes.Room) => void) => { + socket.off(protocol.SocketEvents.ROOM_UPDATE, callback); + }, startGame: () => { socket.emit(protocol.SocketEvents.START_GAME); } 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..f9145b5 --- /dev/null +++ b/apps/client/src/scenes/game/application/GameLoop.ts @@ -0,0 +1,55 @@ +import { Application, Container, Ticker } from "pixi.js"; +import { LocalPlayerController } from "../entities/player/PlayerController"; +import type { GamePlayers } from "./game.types"; +import { InputStep } from "./loopSteps/InputStep"; +import { SimulationStep } from "./loopSteps/SimulationStep"; +import { CameraStep } from "./loopSteps/CameraStep"; + +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 inputStep: InputStep; + private simulationStep: SimulationStep; + private cameraStep: CameraStep; + + constructor({ app, worldContainer, players, myId, getJoystickInput }: GameLoopOptions) { + this.app = app; + this.worldContainer = worldContainer; + this.players = players; + this.myId = myId; + this.inputStep = new InputStep({ getJoystickInput }); + this.simulationStep = new SimulationStep(); + this.cameraStep = new CameraStep(); + } + + public tick = (ticker: Ticker) => { + const me = this.players[this.myId]; + if (!me || !(me instanceof LocalPlayerController)) return; + + const deltaSeconds = ticker.deltaMS / 1000; + const { isMoving } = this.inputStep.run({ me, deltaSeconds }); + + this.simulationStep.run({ + me, + players: this.players, + deltaSeconds, + isMoving, + }); + + this.cameraStep.run({ + app: this.app, + worldContainer: this.worldContainer, + me, + }); + }; +} 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..f401c1c --- /dev/null +++ b/apps/client/src/scenes/game/application/GameNetworkSync.ts @@ -0,0 +1,100 @@ +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; + private isBound = false; + + private handleCurrentPlayers = (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; + }); + }; + + private handleNewPlayer = (p: playerTypes.PlayerData) => { + const playerController = new RemotePlayerController(p); + this.worldContainer.addChild(playerController.getDisplayObject()); + this.players[p.id] = playerController; + }; + + private handleGameStart = (data: { startTime: number }) => { + if (data && data.startTime) { + this.onGameStart(data.startTime); + console.log(`[GameManager] ゲーム開始時刻同期完了: ${data.startTime}`); + } + }; + + private handleUpdatePlayer = (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 }); + } + }; + + private handleRemovePlayer = (id: string) => { + const target = this.players[id]; + if (target) { + this.worldContainer.removeChild(target.getDisplayObject()); + target.destroy(); + delete this.players[id]; + } + }; + + private handleUpdateMapCells = (updates: Parameters[0]) => { + this.gameMap.updateCells(updates); + }; + + constructor({ worldContainer, players, myId, gameMap, onGameStart }: GameNetworkSyncOptions) { + this.worldContainer = worldContainer; + this.players = players; + this.myId = myId; + this.gameMap = gameMap; + this.onGameStart = onGameStart; + } + + public bind() { + if (this.isBound) return; + + socketManager.game.onCurrentPlayers(this.handleCurrentPlayers); + socketManager.game.onNewPlayer(this.handleNewPlayer); + socketManager.game.onGameStart(this.handleGameStart); + socketManager.game.onUpdatePlayer(this.handleUpdatePlayer); + socketManager.game.onRemovePlayer(this.handleRemovePlayer); + socketManager.game.onUpdateMapCells(this.handleUpdateMapCells); + + this.isBound = true; + } + + public unbind() { + if (!this.isBound) return; + + socketManager.game.offCurrentPlayers(this.handleCurrentPlayers); + socketManager.game.offNewPlayer(this.handleNewPlayer); + socketManager.game.offGameStart(this.handleGameStart); + socketManager.game.offUpdatePlayer(this.handleUpdatePlayer); + socketManager.game.offRemovePlayer(this.handleRemovePlayer); + socketManager.game.offUpdateMapCells(this.handleUpdateMapCells); + + this.isBound = false; + } +} 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/application/loopSteps/CameraStep.ts b/apps/client/src/scenes/game/application/loopSteps/CameraStep.ts new file mode 100644 index 0000000..4871923 --- /dev/null +++ b/apps/client/src/scenes/game/application/loopSteps/CameraStep.ts @@ -0,0 +1,15 @@ +import { Application, Container } from "pixi.js"; +import { LocalPlayerController } from "../../entities/player/PlayerController"; + +type CameraStepParams = { + app: Application; + worldContainer: Container; + me: LocalPlayerController; +}; + +export class CameraStep { + public run({ app, worldContainer, me }: CameraStepParams) { + const meDisplay = me.getDisplayObject(); + worldContainer.position.set(-(meDisplay.x - app.screen.width / 2), -(meDisplay.y - app.screen.height / 2)); + } +} \ No newline at end of file diff --git a/apps/client/src/scenes/game/application/loopSteps/InputStep.ts b/apps/client/src/scenes/game/application/loopSteps/InputStep.ts new file mode 100644 index 0000000..6ca5551 --- /dev/null +++ b/apps/client/src/scenes/game/application/loopSteps/InputStep.ts @@ -0,0 +1,33 @@ +import { LocalPlayerController } from "../../entities/player/PlayerController"; + +type InputStepOptions = { + getJoystickInput: () => { x: number; y: number }; +}; + +type InputStepParams = { + me: LocalPlayerController; + deltaSeconds: number; +}; + +type InputStepResult = { + isMoving: boolean; +}; + +export class InputStep { + private getJoystickInput: () => { x: number; y: number }; + + constructor({ getJoystickInput }: InputStepOptions) { + this.getJoystickInput = getJoystickInput; + } + + public run({ me, deltaSeconds }: InputStepParams): InputStepResult { + const { x: axisX, y: axisY } = this.getJoystickInput(); + const isMoving = axisX !== 0 || axisY !== 0; + + if (isMoving) { + me.applyLocalInput({ axisX, axisY, deltaTime: deltaSeconds }); + } + + return { isMoving }; + } +} \ No newline at end of file diff --git a/apps/client/src/scenes/game/application/loopSteps/SimulationStep.ts b/apps/client/src/scenes/game/application/loopSteps/SimulationStep.ts new file mode 100644 index 0000000..adcdc27 --- /dev/null +++ b/apps/client/src/scenes/game/application/loopSteps/SimulationStep.ts @@ -0,0 +1,43 @@ +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 SimulationStepParams = { + me: LocalPlayerController; + players: GamePlayers; + deltaSeconds: number; + isMoving: boolean; +}; + +export class SimulationStep { + private lastPositionSentTime = 0; + private wasMoving = false; + + public run({ me, players, deltaSeconds, isMoving }: SimulationStepParams) { + if (isMoving) { + 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(players).forEach((player) => { + if (player instanceof RemotePlayerController) { + player.tick(deltaSeconds); + } + }); + } +} \ 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 new file mode 100644 index 0000000..a688a92 --- /dev/null +++ b/apps/client/src/scenes/game/hooks/useGameSceneController.ts @@ -0,0 +1,53 @@ +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(() => { + const nextDisplay = formatRemainingTime(manager.getRemainingTime()); + setTimeLeft((prev) => (prev === nextDisplay ? prev : nextDisplay)); + }, config.GAME_CONFIG.TIMER_DISPLAY_UPDATE_MS); + + 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 # マップ型定義 diff --git a/packages/shared/src/config/gameConfig.ts b/packages/shared/src/config/gameConfig.ts index ada6e0b..32b91e2 100644 --- a/packages/shared/src/config/gameConfig.ts +++ b/packages/shared/src/config/gameConfig.ts @@ -3,6 +3,9 @@ MAX_PLAYERS_PER_ROOM: 4, // ルーム収容人数設定 GAME_DURATION_SEC: 180, // 1ゲームの制限時間(3分 = 180秒) + // UI表示更新設定 + TIMER_DISPLAY_UPDATE_MS: 250, // 残り時間表示の更新間隔(ms) + // ネットワーク・描画補間設定 PLAYER_POSITION_UPDATE_MS: 50, // 座標送信間隔(20Hz) PLAYER_LERP_SMOOTHNESS: 18, // 補間の滑らかさ(秒基準、目安: 12〜20)