diff --git a/apps/client/src/managers/GameManager.ts b/apps/client/src/managers/GameManager.ts index 0602885..4ad76f7 100644 --- a/apps/client/src/managers/GameManager.ts +++ b/apps/client/src/managers/GameManager.ts @@ -13,6 +13,23 @@ private myId: string; private container: HTMLDivElement; private gameMap!: GameMap; + private gameStartTime: number | null = null; + + // サーバーからゲーム開始通知(と開始時刻)を受け取った時に呼ぶ + public setGameStart(startTime: number) { + this.gameStartTime = startTime; + } + + // 現在の残り秒数を取得する + public getRemainingTime(): number { + if (!this.gameStartTime) return GAME_CONFIG.GAME_DURATION_SEC; + + // 現在のサーバー時刻を推定 (Date.now() + サーバーとの誤差補正が理想ですが、まずは簡易版で) + const elapsedMs = Date.now() - this.gameStartTime; + const remainingSec = GAME_CONFIG.GAME_DURATION_SEC - (elapsedMs / 1000); + + return Math.max(0, remainingSec); + } // 入力と状態管理 private joystickInput = { x: 0, y: 0 }; @@ -86,6 +103,14 @@ this.players[p.id] = playerSprite; }); + // サーバーからの GAME_START を検知して開始時刻をセットする + socketClient.onGameStart((data) => { + if (data && data.startTime) { + this.setGameStart(data.startTime); + console.log(`[GameManager] ゲーム開始時刻同期完了: ${data.startTime}`); + } + }); + socketClient.onUpdatePlayer((data: Partial & { id: string }) => { if (data.id === this.myId) return; const target = this.players[data.id]; diff --git a/apps/client/src/network/SocketClient.ts b/apps/client/src/network/SocketClient.ts index 17b2628..2f52f8f 100644 --- a/apps/client/src/network/SocketClient.ts +++ b/apps/client/src/network/SocketClient.ts @@ -96,7 +96,7 @@ /** * ゲーム開始通知イベント購読 */ - onGameStart(callback: () => void) { + onGameStart(callback: (data: { startTime: number }) => void) { this.socket.on(SocketEvents.GAME_START, callback); } diff --git a/apps/client/src/scenes/GameScene.tsx b/apps/client/src/scenes/GameScene.tsx index 479d959..3e3fa2f 100644 --- a/apps/client/src/scenes/GameScene.tsx +++ b/apps/client/src/scenes/GameScene.tsx @@ -1,6 +1,7 @@ -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import { VirtualJoystick } from "../input/VirtualJoystick"; import { GameManager } from "../managers/GameManager"; +import { GAME_CONFIG } from "@repo/shared/src/config/gameConfig"; interface GameSceneProps { myId: string | null; @@ -14,6 +15,17 @@ const pixiContainerRef = useRef(null); const gameManagerRef = useRef(null); + // gameConfig から初期表示時間文字列を生成する関数 + const getInitialTimeDisplay = () => { + const totalSec = 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; @@ -24,15 +36,33 @@ // 参照を保持(ジョイスティック入力を渡すため) gameManagerRef.current = 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(); - gameManagerRef.current = null; + clearInterval(timerInterval); // クリーンアップ }; }, [myId]); return (
+ {/* タイマーUIの表示 */} +
+ {timeLeft} +
+ {/* PixiJS Canvas 配置領域 */}
diff --git a/apps/server/src/domains/game/GameHandler.ts b/apps/server/src/domains/game/GameHandler.ts index 3d67bd4..19481df 100644 --- a/apps/server/src/domains/game/GameHandler.ts +++ b/apps/server/src/domains/game/GameHandler.ts @@ -7,6 +7,11 @@ export const registerGameHandlers = (io: Server, socket: Socket, gameManager: GameManager, roomManager: RoomManager) => { + // クライアントから送られてきた時刻をそのまま返しつつ、サーバーの現在時刻も添える + socket.on(SocketEvents.PING, (clientTime: number) => { + socket.emit(SocketEvents.PONG, { clientTime, serverTime: Date.now() }); + }); + // ゲーム開始要求処理 socket.on(SocketEvents.START_GAME, () => { const room = roomManager.getRoomByOwnerId(socket.id); @@ -21,12 +26,11 @@ gameManager.addPlayer(p.id); }); - // ルーム全員向けゲーム開始通知 - io.to(room.roomId).emit(SocketEvents.GAME_START); - // 20Hzのゲームループを開始し、毎フレームの送信処理を定義 - gameManager.startGameLoop(room.roomId, playerIds, (tickData) => { - + gameManager.startGameLoop( + room.roomId, + playerIds, + (tickData) => { // 1. 各プレイヤーの最新座標をクライアントに送信 tickData.players.forEach((playerData) => { io.to(room.roomId).emit(SocketEvents.UPDATE_PLAYER, playerData); @@ -36,8 +40,19 @@ if (tickData.cellUpdates.length > 0) { io.to(room.roomId).emit(SocketEvents.UPDATE_MAP_CELLS, tickData.cellUpdates); } + }, + + () => { + // 3分経過時に GameLoop から呼ばれる処理 + console.log(`[GameHandler] ルーム ${room.roomId} のゲームが終了しました (3分経過)`); + io.to(room.roomId).emit(SocketEvents.GAME_END); // クライアントへ終了通知 + room.status = RoomStatus.WAITING; // ルーム状態を待機に戻す + } + ); - }); + // GameManagerから開始時刻を取得し、GAME_STARTイベントにデータを乗せて送る + const startTime = gameManager.getRoomStartTime(room.roomId) || Date.now(); + io.to(room.roomId).emit(SocketEvents.GAME_START, { startTime }); } }); @@ -45,6 +60,17 @@ socket.on(SocketEvents.READY_FOR_GAME, () => { const allPlayers = gameManager.getAllPlayers(); socket.emit(SocketEvents.CURRENT_PLAYERS, allPlayers); + + // 準備が完了したクライアントに対して、改めて開始時刻を個別に教える + // Socket.ioの仕様上、socket.roomsには自身のIDと参加中のルームIDが含まれるため、そこからルームIDを特定する + const roomId = Array.from(socket.rooms).find(room => room !== socket.id); + if (roomId) { + const startTime = gameManager.getRoomStartTime(roomId); + if (startTime) { + // io.to() による全員への一斉送信ではなく、socket.emit() でこの本人にだけ送る + socket.emit(SocketEvents.GAME_START, { startTime }); + } + } }); // ゲームプレイ中イベント群 diff --git a/apps/server/src/domains/game/GameLoop.ts b/apps/server/src/domains/game/GameLoop.ts index 50c81f8..a74e8f4 100644 --- a/apps/server/src/domains/game/GameLoop.ts +++ b/apps/server/src/domains/game/GameLoop.ts @@ -2,6 +2,7 @@ import { MapStore } from "./states/MapStore"; import { getGridIndexFromPosition } from "@repo/shared/src/domains/gridMap/gridMap.logic"; import type { CellUpdate } from "@repo/shared/src/domains/gridMap/gridMap.type"; +import { GAME_CONFIG } from "@repo/shared/src/config/gameConfig"; // コールバックで渡すデータの型定義 export interface TickData { @@ -16,6 +17,7 @@ export class GameLoop { private loopId: NodeJS.Timeout | null = null; + private startTime: number = 0; constructor( private roomId: string, @@ -23,14 +25,26 @@ private playerIds: string[], private players: Map, private mapStore: MapStore, - private onTick: (data: TickData) => void + private onTick: (data: TickData) => void, + private onGameEnd: () => void // ゲーム終了時のコールバック ) {} start() { // 既にループが回っている場合は何もしない if (this.loopId) return; + this.startTime = Date.now(); + this.loopId = setInterval(() => { + // 時間経過のチェック + const elapsedTimeMs = Date.now() - this.startTime; + if (elapsedTimeMs >= GAME_CONFIG.GAME_DURATION_SEC * 1000) { + // ゲーム終了時にループを止めて終了処理へ + this.stop(); + this.onGameEnd(); + return; // 今回のフレームの座標更新はスキップ + } + const playersData: TickData["players"] = []; // 1. 各プレイヤーの座標処理とマス塗りの判定 diff --git a/apps/server/src/domains/game/GameManager.ts b/apps/server/src/domains/game/GameManager.ts index 7b010e1..8496a89 100644 --- a/apps/server/src/domains/game/GameManager.ts +++ b/apps/server/src/domains/game/GameManager.ts @@ -10,11 +10,18 @@ private players: Map; private mapStore: MapStore; private gameLoops: Map; // NodeJS.Timeout から変更 + private roomStartTimes: Map; // ルームごとのゲーム開始時間を保持する constructor() { this.players = new Map(); this.mapStore = new MapStore(); this.gameLoops = new Map(); + this.roomStartTimes = new Map(); + } + + // 外部(GameHandlerなど)から開始時刻を取得できるようにする + getRoomStartTime(roomId: string): number | undefined { + return this.roomStartTimes.get(roomId); } // 新規プレイヤー登録と初期位置設定処理 @@ -56,10 +63,18 @@ * @param playerIds このルームに参加しているプレイヤーのIDリスト * @param onTick 毎フレーム実行される送信用のコールバック関数 */ - startGameLoop(roomId: string, playerIds: string[], onTick: (data: TickData) => void) { + startGameLoop( + roomId: string, + playerIds: string[], + onTick: (data: TickData) => void, + onGameEnd: () => void + ) { if (this.gameLoops.has(roomId)) return; const tickRate = GAME_CONFIG.PLAYER_POSITION_UPDATE_MS; + + // ループ開始時に、このルームの開始時刻を記憶する + this.roomStartTimes.set(roomId, Date.now()); // GameLoopインスタンスを生成し、参照を渡す const loop = new GameLoop( @@ -68,7 +83,13 @@ playerIds, this.players, this.mapStore, - onTick + onTick, + () => { + // GameLoopが終了した時の処理 + this.roomStartTimes.delete(roomId); + this.gameLoops.delete(roomId); + onGameEnd(); // GameHandlerへ終了を伝える + } ); loop.start(); @@ -83,6 +104,7 @@ if (loop) { loop.stop(); this.gameLoops.delete(roomId); + this.roomStartTimes.delete(roomId); // 停止時も忘れずクリア } } 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 cf5888b..f412115 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" @@ -32,51 +32,102 @@ 2-1. 構成一覧 root/ - ├── .devcontainer/ # Dev Containers設定 - ├── docker-compose.yml # 開発用コンテナ構成定義 - ├── Dockerfile # 本番サーバー・デプロイ用定義 (Multi-stage) - ├── package.json # pnpm workspace 定義 - ├── pnpm-workspace.yaml # ワークスペース設定 - ├── .npmrc # 依存関係解決の設定 - │ + ├── .devcontainer/ + │ └── devcontainer.json # 開発コンテナ設定 ├── apps/ - │ ├── client/ # 【演出】フロントエンド (Browser) + │ ├── client/ # 【演出】フロントエンド (Browser) + │ │ ├── .gitignore # 除外設定 + │ │ ├── index.html # HTMLエントリ + │ │ ├── package.json # 依存・スクリプト + │ │ ├── public/ + │ │ │ └── vite.svg # 公開アイコン │ │ ├── src/ - │ │ │ ├── assets/ # 画像・音声リソース - │ │ │ ├── entities/ # ゲーム内オブジェクト(Player, GameMap) - │ │ │ ├── input/ # 入力処理 (VirtualJoystick) - │ │ │ ├── managers # 状態管理・エンジン制御(GameManager) - │ │ │ ├── network/ # 通信処理 (SocketClient) - │ │ │ ├── scenes/ # 画面管理 (Title, Lobby, GameScene) - │ │ │ └── main.ts # エントリーポイント - │ │ ├── index.html - │ │ ├── vite.config.ts - │ │ └── package.json - │ │ - │ └── server/ # 【権限】バックエンド (Node.js) - │ ├── src/ - │ │ ├── domains/ # ドメイン別実装 - │ │ │ ├── game/ # ゲーム進行 (GameHandler, GameLoop, GameManager) - │ │ │ │ ├── entities/ # ゲーム内エンティティ (Player) - │ │ │ │ └── states/ # ゲーム状態ストア (MapStore) - │ │ │ └── room/ # ルーム管理 (RoomHandler, RoomManager) - │ │ ├── network/ # WebSocket処理 (SocketManager) - │ │ └── index.ts # エントリーポイント - │ ├── tsconfig.json - │ └── package.json - │ - └── packages/ - └── shared/ # 【最重要】「真実」の定義場所(型、定数、純粋ロジック) - ├── src/ - │ ├── config/ # 共通定数 (gameConfig.ts) - │ ├── domains/ # ドメイン別ロジック・型定義 - │ │ ├── gridMap/# マップ・グリッド関連 (gridMap.logic.ts, gridMap.type.ts) - │ │ ├── player/ # プレイヤー関連 (player.type.ts) - │ │ └── room/ # ルーム・進行管理 (room.type.ts) - │ ├── protocol/ # 通信イベント・バイナリ定義 (events.ts) - │ └── index.ts # エントリーポイント (tsupビルド対象) - ├── tsup.config.ts # ビルド設定 - └── package.json # パッケージ定義 + │ │ │ ├── app.tsx # ルートUI + │ │ │ ├── index.css # 全体スタイル + │ │ │ ├── main.tsx # 起動エントリ + │ │ │ ├── assets/ + │ │ │ │ └── preact.svg # ロゴ素材 + │ │ │ ├── entities/ + │ │ │ │ ├── GameMap.ts # マップ描画 + │ │ │ │ └── Player.ts # プレイヤー描画 + │ │ │ ├── input/ + │ │ │ │ └── VirtualJoystick.tsx # 仮想スティック + │ │ │ ├── managers/ + │ │ │ │ └── GameManager.ts # ゲーム制御 + │ │ │ ├── network/ + │ │ │ │ └── SocketClient.ts # WSクライアント + │ │ │ └── scenes/ + │ │ │ ├── GameScene.tsx # ゲーム画面 + │ │ │ ├── LobbyScene.tsx # ロビー画面 + │ │ │ └── TitleScene.tsx # タイトル画面 + │ │ ├── tsconfig.app.json # アプリTS設定 + │ │ ├── tsconfig.json # TS親設定 + │ │ ├── tsconfig.node.json # Node向けTS設定 + │ │ └── vite.config.ts # Vite設定 + │ └── server/ # 【権限】バックエンド (Node.js) + │ ├── package.json # 依存・スクリプト + │ ├── tsconfig.json # TS設定 + │ └── src/ + │ ├── index.ts # サーバー起点 + │ ├── domains/ + │ │ ├── game/ + │ │ │ ├── GameHandler.ts # ゲーム入出力 + │ │ │ ├── GameLoop.ts # 固定ループ + │ │ │ ├── GameManager.ts # ゲーム状態管理 + │ │ │ ├── entities/ + │ │ │ │ └── Player.ts # サーバーPlayer + │ │ │ └── states/ + │ │ │ └── MapStore.ts # マップ状態保持 + │ │ └── room/ + │ │ ├── RoomHandler.ts # ルーム入出力 + │ │ └── RoomManager.ts # ルーム管理 + │ └── network/ + │ └── SocketManager.ts # WS接続管理 + ├── docs/ + │ ├── 01_Env/ + │ │ ├── ENV_01_環境構築・技術スタック.txt # 環境・技術定義 + │ │ ├── ENV_02_環境構築手順書.txt # 初期構築手順 + │ │ ├── ENV_03_TypeScript概要.txt # TS基礎説明 + │ │ ├── ENV_04_スマホ実機デバッグ手順.txt # 実機デバッグ手順 + │ │ ├── ENV_05_Docker運用操作ガイド.txt # Docker運用手順 + │ │ └── ENV_06_管理者用環境構築手順.txt # 管理者向け手順 + │ ├── 02_Guide/ + │ │ ├── GUIDE_01_ドキュメント作成ガイド.txt # 文書作成ルール + │ │ ├── GUIDE_02_ファイル命名規則.txt # 命名規則 + │ │ └── GUIDE_03_Git運用ルール.txt # Git運用ルール + │ ├── 03_Plan/ + │ │ └── PLAN_01_移動テスト実装計画.txt # 実装計画 + │ └── 04_Spec/ + │ └── SPEC_03_プロトタイプ_移動テスト.txt # 機能仕様 + ├── packages/ + │ └── shared/ # 【最重要】「真実」の定義場所(型、定数、純粋ロジック) + │ ├── package.json # 依存・公開設定 + │ ├── tsconfig.json # TS設定 + │ └── src/ + │ ├── index.ts # 共有エクスポート + │ ├── config/ + │ │ └── gameConfig.ts # 共通定数 + │ ├── domains/ + │ │ ├── gridMap/ + │ │ │ ├── gridMap.logic.ts # マップ計算 + │ │ │ └── gridMap.type.ts # マップ型定義 + │ │ ├── player/ + │ │ │ └── player.type.ts # プレイヤー型 + │ │ └── room/ + │ │ └── room.type.ts # ルーム型 + │ └── protocol/ + │ └── events.ts # 通信イベント定義 + ├── .gitignore # Git除外設定 + ├── .npmrc # pnpm設定 + ├── docker-compose.prod.yml # 本番Compose定義 + ├── docker-compose.yml # 開発Compose定義 + ├── Dockerfile # 本番イメージ定義 + ├── package.json # ワークスペース定義 + ├── pnpm-lock.yaml # lockファイル + ├── pnpm-workspace.yaml # workspace設定 + └── README.md # プロジェクト概要 + + ※ 右側コメントは,役割が一行で分かる短文で記載する. ※ shared は client/server 両方から import して使用する. diff --git a/packages/shared/src/config/gameConfig.ts b/packages/shared/src/config/gameConfig.ts index 46c80df..7d0ef50 100644 --- a/packages/shared/src/config/gameConfig.ts +++ b/packages/shared/src/config/gameConfig.ts @@ -1,4 +1,13 @@ export const GAME_CONFIG = { + // ゲーム設定 + MAX_PLAYERS_PER_ROOM: 4, // ルーム収容人数設定 + GAME_DURATION_SEC: 180, // 1ゲームの制限時間(3分 = 180秒) + + // ネットワーク・描画補間設定 + PLAYER_POSITION_UPDATE_MS: 50, // 座標送信間隔(20Hz) + PLAYER_LERP_SMOOTHNESS: 0.3, // 補間の滑らかさ + PLAYER_LERP_SNAP_THRESHOLD: 0.5, // 吸着距離閾値 + // 画面サイズ設定 SCREEN_WIDTH: 1280, SCREEN_HEIGHT: 720, @@ -11,19 +20,11 @@ // マップサイズはグリッド設定から自動計算させる(ハードコーディングを避ける) get MAP_WIDTH() { return this.GRID_COLS * this.GRID_CELL_SIZE; }, get MAP_HEIGHT() { return this.GRID_ROWS * this.GRID_CELL_SIZE; }, - - // ルーム収容人数設定 - MAX_PLAYERS_PER_ROOM: 4, // プレイヤー挙動設定 PLAYER_RADIUS: 10, // プレイヤー半径 PLAYER_SPEED: 5, // 移動速度(ピクセル/秒) - // ネットワーク・描画補間設定 - PLAYER_POSITION_UPDATE_MS: 50, // 座標送信間隔(20Hz) - PLAYER_LERP_SMOOTHNESS: 0.3, // 補間の滑らかさ - PLAYER_LERP_SNAP_THRESHOLD: 0.5, // 吸着距離閾値 - // チームカラー設定 // teamId インデックス順カラー配列 TEAM_COLORS: ['#FF4B4B', '#4B4BFF', '#4BFF4B', '#FFD700'], diff --git a/packages/shared/src/protocol/events.ts b/packages/shared/src/protocol/events.ts index a69f60f..5e90344 100644 --- a/packages/shared/src/protocol/events.ts +++ b/packages/shared/src/protocol/events.ts @@ -17,4 +17,9 @@ REMOVE_PLAYER: "remove_player", MOVE: "move", UPDATE_MAP_CELLS: "update_map_cells", + + // 時間同期・ゲーム進行関連 + PING: "ping", // クライアントからの時刻同期リクエスト(ラグ計算用) + PONG: "pong", // サーバーからの現在時刻レスポンス + GAME_END: "game-end", // 3分経過時のゲーム終了通知 } as const;