diff --git a/apps/client/src/network/handlers/GameHandler.ts b/apps/client/src/network/handlers/GameHandler.ts index e87bf04..0360d91 100644 --- a/apps/client/src/network/handlers/GameHandler.ts +++ b/apps/client/src/network/handlers/GameHandler.ts @@ -5,7 +5,7 @@ */ import type { Socket } from "socket.io-client"; import { protocol } from "@repo/shared"; -import type { playerTypes, gridMapTypes } from "@repo/shared"; +import type { playerTypes, gridMapTypes, UpdatePlayersPayload } from "@repo/shared"; /** ゲームシーンが利用するソケット操作の契約 */ export type GameHandler = { @@ -13,8 +13,8 @@ offCurrentPlayers: (callback: (players: playerTypes.PlayerData[] | Record) => void) => void; onNewPlayer: (callback: (player: playerTypes.PlayerData) => void) => void; offNewPlayer: (callback: (player: playerTypes.PlayerData) => void) => void; - onUpdatePlayers: (callback: (players: playerTypes.PlayerData[]) => void) => void; - offUpdatePlayers: (callback: (players: playerTypes.PlayerData[]) => void) => void; + onUpdatePlayers: (callback: (players: UpdatePlayersPayload) => void) => void; + offUpdatePlayers: (callback: (players: UpdatePlayersPayload) => void) => void; onRemovePlayer: (callback: (id: string) => void) => void; offRemovePlayer: (callback: (id: string) => void) => void; onUpdateMapCells: (callback: (updates: gridMapTypes.CellUpdate[]) => void) => void; diff --git a/apps/client/src/scenes/game/application/GameNetworkSync.ts b/apps/client/src/scenes/game/application/GameNetworkSync.ts index 6e7422a..45b6e07 100644 --- a/apps/client/src/scenes/game/application/GameNetworkSync.ts +++ b/apps/client/src/scenes/game/application/GameNetworkSync.ts @@ -49,8 +49,9 @@ } }; - private handleUpdatePlayers = (serverPlayers: playerTypes.PlayerData[]) => { - serverPlayers.forEach((playerData) => { + private handlePlayerUpdates = (changedPlayers: playerTypes.PlayerData[]) => { + // UPDATE_PLAYERS は差分のみ届くため,対象IDだけ上書き更新する + changedPlayers.forEach((playerData) => { if (playerData.id === this.myId) return; const target = this.players[playerData.id]; @@ -87,7 +88,7 @@ socketManager.game.onCurrentPlayers(this.handleCurrentPlayers); socketManager.game.onNewPlayer(this.handleNewPlayer); socketManager.game.onGameStart(this.handleGameStart); - socketManager.game.onUpdatePlayers(this.handleUpdatePlayers); + socketManager.game.onUpdatePlayers(this.handlePlayerUpdates); socketManager.game.onRemovePlayer(this.handleRemovePlayer); socketManager.game.onUpdateMapCells(this.handleUpdateMapCells); @@ -100,7 +101,7 @@ socketManager.game.offCurrentPlayers(this.handleCurrentPlayers); socketManager.game.offNewPlayer(this.handleNewPlayer); socketManager.game.offGameStart(this.handleGameStart); - socketManager.game.offUpdatePlayers(this.handleUpdatePlayers); + socketManager.game.offUpdatePlayers(this.handlePlayerUpdates); socketManager.game.offRemovePlayer(this.handleRemovePlayer); socketManager.game.offUpdateMapCells(this.handleUpdateMapCells); diff --git a/apps/server/src/domains/game/GameManager.ts b/apps/server/src/domains/game/GameManager.ts index c6f5a6f..34a577b 100644 --- a/apps/server/src/domains/game/GameManager.ts +++ b/apps/server/src/domains/game/GameManager.ts @@ -2,7 +2,7 @@ * GameManager * ゲームセッション集合の生成,更新,参照管理を統括する */ -import { type TickData } from "./loop/GameLoop"; +import type { gameTypes } from "@repo/shared"; import { Player } from "./entities/player/Player.js"; import { GameRoomSession } from "./application/services/GameRoomSession"; import { GameSessionLifecycleService } from "./application/services/GameSessionLifecycleService"; @@ -13,14 +13,16 @@ export class GameManager { private sessions: Map; private playerToRoom: Map; + private roomToPlayers: Map>; private lifecycleService: GameSessionLifecycleService; private playerOperationService: GamePlayerOperationService; constructor() { this.sessions = new Map(); this.playerToRoom = new Map(); - this.lifecycleService = new GameSessionLifecycleService(this.sessions, this.playerToRoom); - this.playerOperationService = new GamePlayerOperationService(this.sessions, this.playerToRoom); + this.roomToPlayers = new Map(); + this.lifecycleService = new GameSessionLifecycleService(this.sessions, this.playerToRoom, this.roomToPlayers); + this.playerOperationService = new GamePlayerOperationService(this.sessions, this.playerToRoom, this.roomToPlayers); } // 外部(GameHandlerなど)から開始時刻を取得できるようにする @@ -47,7 +49,7 @@ startRoomSession( roomId: string, playerIds: string[], - onTick: (data: TickData) => void, + onTick: (data: gameTypes.TickData) => void, onGameEnd: () => void ) { this.lifecycleService.startRoomSession(roomId, playerIds, onTick, onGameEnd); diff --git a/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts b/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts index 4d03233..617d04b 100644 --- a/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts +++ b/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts @@ -2,15 +2,14 @@ * gameUseCasePorts * ゲーム系ユースケースが利用する入力ポートと出力ポートの契約を定義する */ -import type { TickData } from "../../loop/GameLoop"; -import type { gridMapTypes, playerTypes, roomTypes } from "@repo/shared"; +import type { gameTypes, gridMapTypes, playerTypes, roomTypes, UpdatePlayersPayload } from "@repo/shared"; /** ゲーム開始ユースケースが利用するゲーム管理入力ポート */ export interface StartGamePort { startRoomSession( roomId: string, playerIds: string[], - onTick: (data: TickData) => void, + onTick: (data: gameTypes.TickData) => void, onGameEnd: () => void ): void; getRoomStartTime(roomId: string): number | undefined; @@ -49,7 +48,7 @@ publishPongToSocket(payload: { clientTime: number; serverTime: number }): void; publishUpdatePlayersToRoom( roomId: roomTypes.Room["roomId"], - players: playerTypes.PlayerData[] + players: UpdatePlayersPayload ): void; publishMapCellUpdatesToRoom( roomId: roomTypes.Room["roomId"], diff --git a/apps/server/src/domains/game/application/services/GamePlayerOperationService.ts b/apps/server/src/domains/game/application/services/GamePlayerOperationService.ts index e6af25b..9874b80 100644 --- a/apps/server/src/domains/game/application/services/GamePlayerOperationService.ts +++ b/apps/server/src/domains/game/application/services/GamePlayerOperationService.ts @@ -7,19 +7,21 @@ type SessionStore = Map; type PlayerRoomIndex = Map; +type RoomPlayersIndex = Map>; /** プレイヤー移動とセッション離脱処理を提供するサービス */ export class GamePlayerOperationService { constructor( private sessions: SessionStore, - private playerToRoom: PlayerRoomIndex + private playerToRoom: PlayerRoomIndex, + private roomToPlayers: RoomPlayersIndex ) {} public movePlayer(id: string, x: number, y: number): void { const roomId = this.playerToRoom.get(id); if (!roomId) { - logEvent("GameSessionService", { - event: "MOVE", + logEvent("GamePlayerOperationService", { + event: "PLAYER_MOVE", result: "ignored_player_not_in_session", socketId: id, }); @@ -32,32 +34,51 @@ public removePlayer(id: string): void { const roomId = this.playerToRoom.get(id); if (!roomId) { - logEvent("GameSessionService", { - event: "REMOVE_PLAYER", + logEvent("GamePlayerOperationService", { + event: "PLAYER_REMOVE", result: "ignored_player_not_in_session", socketId: id, }); return; } + const roomPlayerSet = this.roomToPlayers.get(roomId); const session = this.sessions.get(roomId); if (!session) { - this.playerToRoom.delete(id); + this.removePlayerFromIndexes(roomId, id, roomPlayerSet); return; } const removed = session.removePlayer(id); - this.playerToRoom.delete(id); + this.removePlayerFromIndexes(roomId, id, roomPlayerSet); if (removed && session.isEmpty()) { session.dispose(); this.sessions.delete(roomId); - logEvent("GameSessionService", { - event: "REMOVE_PLAYER", + this.roomToPlayers.delete(roomId); + logEvent("GamePlayerOperationService", { + event: "PLAYER_REMOVE", result: "session_disposed_empty_room", roomId, socketId: id, }); } } + + private removePlayerFromIndexes( + roomId: string, + playerId: string, + roomPlayerSet: Set | undefined + ): void { + this.playerToRoom.delete(playerId); + + if (!roomPlayerSet) { + return; + } + + roomPlayerSet.delete(playerId); + if (roomPlayerSet.size === 0) { + this.roomToPlayers.delete(roomId); + } + } } \ No newline at end of file diff --git a/apps/server/src/domains/game/application/services/GameRoomSession.ts b/apps/server/src/domains/game/application/services/GameRoomSession.ts index 4e31055..efe7e8e 100644 --- a/apps/server/src/domains/game/application/services/GameRoomSession.ts +++ b/apps/server/src/domains/game/application/services/GameRoomSession.ts @@ -3,7 +3,8 @@ * 1ルーム分のゲーム進行状態とゲームループ実行を管理する */ import { logEvent } from "@server/logging/logEvent"; -import { GameLoop, type TickData } from "../../loop/GameLoop"; +import type { gameTypes } from "@repo/shared"; +import { GameLoop } from "../../loop/GameLoop"; import { Player } from "../../entities/player/Player.js"; import { MapStore } from "../../entities/map/MapStore"; import { createSpawnedPlayer } from "../../entities/player/playerSpawn.js"; @@ -31,7 +32,7 @@ public start( tickRate: number, - onTick: (data: TickData) => void, + onTick: (data: gameTypes.TickData) => void, onGameEnd: () => void ): void { if (this.gameLoop) { @@ -42,7 +43,6 @@ this.gameLoop = new GameLoop( this.roomId, tickRate, - this.getPlayerIds(), this.players, this.mapStore, onTick, diff --git a/apps/server/src/domains/game/application/services/GameSessionLifecycleService.ts b/apps/server/src/domains/game/application/services/GameSessionLifecycleService.ts index 0b22870..7cc15e3 100644 --- a/apps/server/src/domains/game/application/services/GameSessionLifecycleService.ts +++ b/apps/server/src/domains/game/application/services/GameSessionLifecycleService.ts @@ -3,18 +3,20 @@ * ゲームセッションの開始,参照,終了時クリーンアップを管理する */ import { config } from "@repo/shared"; -import { type TickData } from "../../loop/GameLoop"; +import type { gameTypes } from "@repo/shared"; import { logEvent } from "@server/logging/logEvent"; import { GameRoomSession } from "./GameRoomSession"; type SessionStore = Map; type PlayerRoomIndex = Map; +type RoomPlayersIndex = Map>; /** ゲームセッションのライフサイクル操作を提供するサービス */ export class GameSessionLifecycleService { constructor( private sessions: SessionStore, - private playerToRoom: PlayerRoomIndex + private playerToRoom: PlayerRoomIndex, + private roomToPlayers: RoomPlayersIndex ) {} public getRoomStartTime(roomId: string): number | undefined { @@ -28,12 +30,12 @@ public startRoomSession( roomId: string, playerIds: string[], - onTick: (data: TickData) => void, + onTick: (data: gameTypes.TickData) => void, onGameEnd: () => void ) { if (this.sessions.has(roomId)) { - logEvent("GameSessionService", { - event: "START_GAME_LOOP", + logEvent("GameSessionLifecycleService", { + event: "SESSION_START", result: "ignored_already_running", roomId, }); @@ -42,10 +44,12 @@ const tickRate = config.GAME_CONFIG.PLAYER_POSITION_UPDATE_MS; const session = new GameRoomSession(roomId, playerIds); + const roomPlayerSet = new Set(playerIds); playerIds.forEach((playerId) => { this.playerToRoom.set(playerId, roomId); }); + this.roomToPlayers.set(roomId, roomPlayerSet); this.sessions.set(roomId, session); session.start(tickRate, onTick, () => { @@ -54,8 +58,8 @@ onGameEnd(); }); - logEvent("GameSessionService", { - event: "START_GAME_LOOP", + logEvent("GameSessionLifecycleService", { + event: "SESSION_START", result: "started", roomId, playerCount: playerIds.length, @@ -63,10 +67,14 @@ } private clearRoomPlayerIndex(roomId: string): void { - Array.from(this.playerToRoom.entries()).forEach(([playerId, mappedRoomId]) => { - if (mappedRoomId === roomId) { - this.playerToRoom.delete(playerId); - } + const roomPlayerSet = this.roomToPlayers.get(roomId); + if (!roomPlayerSet) { + return; + } + + roomPlayerSet.forEach((playerId) => { + this.playerToRoom.delete(playerId); }); + this.roomToPlayers.delete(roomId); } } \ No newline at end of file diff --git a/apps/server/src/domains/game/application/useCases/startGameUseCase.ts b/apps/server/src/domains/game/application/useCases/startGameUseCase.ts index 9f95b56..d6e871f 100644 --- a/apps/server/src/domains/game/application/useCases/startGameUseCase.ts +++ b/apps/server/src/domains/game/application/useCases/startGameUseCase.ts @@ -31,7 +31,9 @@ roomId, playerIds, (tickData) => { - output.publishUpdatePlayersToRoom(roomId, tickData.players); + if (tickData.playerUpdates.length > 0) { + output.publishUpdatePlayersToRoom(roomId, tickData.playerUpdates); + } if (tickData.cellUpdates.length > 0) { output.publishMapCellUpdatesToRoom(roomId, tickData.cellUpdates); diff --git a/apps/server/src/domains/game/loop/GameLoop.ts b/apps/server/src/domains/game/loop/GameLoop.ts index c5a9f9e..0ddb36e 100644 --- a/apps/server/src/domains/game/loop/GameLoop.ts +++ b/apps/server/src/domains/game/loop/GameLoop.ts @@ -6,83 +6,39 @@ import { MapStore } from "../entities/map/MapStore"; import { getPlayerGridIndex } from "../entities/player/playerPosition.js"; import { config } from "@repo/shared"; -import type { gridMapTypes } from "@repo/shared"; +import type { gameTypes } from "@repo/shared"; import { logEvent } from "@server/logging/logEvent"; -// コールバックで渡すデータの型定義 -/** 1ティック分のプレイヤー情報とマップ差分を表すデータ */ -export interface TickData { - players: { - id: string; - x: number; - y: number; - teamId: number; - }[]; - cellUpdates: gridMapTypes.CellUpdate[]; -} - /** ルーム内ゲーム進行を定周期で実行するループ管理クラス */ export class GameLoop { private loopId: NodeJS.Timeout | null = null; - private startTime: number = 0; + private isRunning: boolean = false; + private startMonotonicTimeMs: number = 0; + private endMonotonicTimeMs: number = 0; + private nextTickAtMs: number = 0; + private readonly maxCatchUpTicks: number = 3; + private lastSentPlayers: Map = new Map(); constructor( private roomId: string, private tickRate: number, - private playerIds: string[], private players: Map, private mapStore: MapStore, - private onTick: (data: TickData) => void, + private onTick: (data: gameTypes.TickData) => void, private onGameEnd: () => void // ゲーム終了時のコールバック ) {} start() { // 既にループが回っている場合は何もしない - if (this.loopId) return; + if (this.isRunning) return; - this.startTime = Date.now(); - - this.loopId = setInterval(() => { - // 時間経過のチェック - const elapsedTimeMs = Date.now() - this.startTime; - if (elapsedTimeMs >= config.GAME_CONFIG.GAME_DURATION_SEC * 1000) { - // ゲーム終了時にループを止めて終了処理へ - this.stop(); - this.onGameEnd(); - return; // 今回のフレームの座標更新はスキップ - } - - const playersData: TickData["players"] = []; - - // 1. 各プレイヤーの座標処理とマス塗りの判定 - this.playerIds.forEach(id => { - const player = this.players.get(id); - if (!player) return; - - const gridIndex = getPlayerGridIndex(player); - if (gridIndex !== null) { - this.mapStore.paintCell(gridIndex, player.teamId); - } - - // 送信用のプレイヤーデータを構築 - playersData.push({ - id: player.id, - x: player.x, - y: player.y, - teamId: player.teamId, - }); - }); - - // 2. マスの差分(Diff)を取得 - const cellUpdates = this.mapStore.getAndClearUpdates(); - - // 3. 通信層(GameHandler)へデータを渡す - this.onTick({ - players: playersData, - cellUpdates: cellUpdates, - }); - - }, this.tickRate); + const nowMs = performance.now(); + this.startMonotonicTimeMs = nowMs; + this.endMonotonicTimeMs = nowMs + config.GAME_CONFIG.GAME_DURATION_SEC * 1000; + this.nextTickAtMs = nowMs + this.tickRate; + this.lastSentPlayers.clear(); + this.isRunning = true; + this.scheduleNextTick(); logEvent("GameLoop", { event: "GAME_LOOP", @@ -92,15 +48,113 @@ }); } - stop() { - if (this.loopId) { - clearInterval(this.loopId); + private scheduleNextTick(): void { + if (!this.isRunning) return; + + const delayMs = Math.max(0, this.nextTickAtMs - performance.now()); + this.loopId = setTimeout(() => { this.loopId = null; - logEvent("GameLoop", { - event: "GAME_LOOP", - result: "stopped", - roomId: this.roomId, - }); + this.runTickCycle(); + }, delayMs); + } + + private runTickCycle(): void { + if (!this.isRunning) return; + + let nowMs = performance.now(); + if (nowMs >= this.endMonotonicTimeMs) { + this.stop(); + this.onGameEnd(); + return; } + + let processedTicks = 0; + + while (nowMs >= this.nextTickAtMs && processedTicks < this.maxCatchUpTicks) { + this.processSingleTick(); + this.nextTickAtMs += this.tickRate; + processedTicks += 1; + + nowMs = performance.now(); + if (nowMs >= this.endMonotonicTimeMs) { + this.stop(); + this.onGameEnd(); + return; + } + } + + if (processedTicks === this.maxCatchUpTicks && nowMs >= this.nextTickAtMs) { + this.nextTickAtMs = nowMs + this.tickRate; + } + + this.scheduleNextTick(); + } + + private processSingleTick(): void { + const changedPlayers: gameTypes.TickData["playerUpdates"] = []; + + // 1. 各プレイヤーの座標処理とマス塗りの判定 + this.players.forEach((player) => { + + const gridIndex = getPlayerGridIndex(player); + if (gridIndex !== null) { + this.mapStore.paintCell(gridIndex, player.teamId); + } + + // 送信用のプレイヤーデータを構築 + const playerData = { + id: player.id, + x: player.x, + y: player.y, + teamId: player.teamId, + }; + + const lastSentPlayer = this.lastSentPlayers.get(player.id); + const isChanged = + !lastSentPlayer || + lastSentPlayer.x !== playerData.x || + lastSentPlayer.y !== playerData.y || + lastSentPlayer.teamId !== playerData.teamId; + + if (isChanged) { + changedPlayers.push(playerData); + this.lastSentPlayers.set(player.id, playerData); + } + }); + + // ルームから離脱したプレイヤーの送信状態をクリーンアップする + Array.from(this.lastSentPlayers.keys()).forEach((playerId) => { + if (!this.players.has(playerId)) { + this.lastSentPlayers.delete(playerId); + } + }); + + // 2. マスの差分(Diff)を取得 + const cellUpdates = this.mapStore.getAndClearUpdates(); + + // 3. 通信層(GameHandler)へデータを渡す + this.onTick({ + playerUpdates: changedPlayers, + cellUpdates: cellUpdates, + }); + } + + stop() { + if (!this.isRunning) return; + + this.isRunning = false; + this.lastSentPlayers.clear(); + + if (this.loopId) { + clearTimeout(this.loopId); + this.loopId = null; + } + + logEvent("GameLoop", { + event: "GAME_LOOP", + result: "stopped", + roomId: this.roomId, + elapsedMs: Math.max(0, Math.round(performance.now() - this.startMonotonicTimeMs)), + }); } } \ No newline at end of file diff --git a/apps/server/src/network/handlers/game/createGameOutputAdapter.ts b/apps/server/src/network/handlers/game/createGameOutputAdapter.ts index 4e43e44..58310c4 100644 --- a/apps/server/src/network/handlers/game/createGameOutputAdapter.ts +++ b/apps/server/src/network/handlers/game/createGameOutputAdapter.ts @@ -4,7 +4,7 @@ */ import { Server } from "socket.io"; import { protocol } from "@repo/shared"; -import type { gridMapTypes, playerTypes, roomTypes } from "@repo/shared"; +import type { gridMapTypes, playerTypes, roomTypes, UpdatePlayersPayload } from "@repo/shared"; import type { GameOutputPort } from "@server/domains/game/application/ports/gameUseCasePorts"; import { createEmitToRoom } from "@server/network/adapters/socketEmitters"; import type { CommonHandlerContext } from "../CommonHandler"; @@ -14,7 +14,6 @@ type PongPayload = { clientTime: number; serverTime: number }; type GameStartPayload = { startTime: number }; type CurrentPlayersPayload = playerTypes.PlayerData[]; -type UpdatePlayersPayload = playerTypes.PlayerData[]; type MapCellUpdatesPayload = gridMapTypes.CellUpdate[]; /** ゲーム出力アダプターのインターフェース */ diff --git a/packages/shared/src/domains/game/game.type.ts b/packages/shared/src/domains/game/game.type.ts new file mode 100644 index 0000000..ed51d9f --- /dev/null +++ b/packages/shared/src/domains/game/game.type.ts @@ -0,0 +1,12 @@ +/** + * game.type + * ゲーム進行で利用する共有型を定義する + */ +import type { CellUpdate } from "../gridMap/gridMap.type"; +import type { PlayerData } from "../player/player.type"; + +/** 1ティック分のプレイヤー差分更新とマップ差分を表す共有データ */ +export interface TickData { + playerUpdates: PlayerData[]; + cellUpdates: CellUpdate[]; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 266bcd1..48c6e17 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -2,9 +2,11 @@ export * as gridMapTypes from "./domains/gridMap/gridMap.type"; export * as gridMapLogic from "./domains/gridMap/gridMap.logic"; export * as playerTypes from "./domains/player/player.type"; +export * as gameTypes from "./domains/game/game.type"; export * as appTypes from "./domains/app/app.type"; export * as appConsts from "./domains/app/app.const"; export * as roomTypes from "./domains/room/room.type"; export * as roomConsts from "./domains/room/room.const"; export * as protocol from "./protocol/events"; +export type { UpdatePlayersPayload } from "./protocol/events"; export * as config from "./config"; \ No newline at end of file diff --git a/packages/shared/src/protocol/events.ts b/packages/shared/src/protocol/events.ts index 6082e05..28a710f 100644 --- a/packages/shared/src/protocol/events.ts +++ b/packages/shared/src/protocol/events.ts @@ -3,6 +3,8 @@ * ソケット通信で利用するイベント名定数を定義する * クライアントとサーバー間のイベント契約を共有する */ +import type { TickData } from "../domains/game/game.type"; + /** ソケットイベント名の一覧定数 */ export const SocketEvents = { // 接続・切断イベント名 @@ -31,3 +33,6 @@ PONG: "pong", // サーバーからの現在時刻レスポンス GAME_END: "game-end", // 3分経過時のゲーム終了通知 } as const; + +/** UPDATE_PLAYERS イベントで送受信するプレイヤー差分配列 */ +export type UpdatePlayersPayload = TickData["playerUpdates"];