diff --git a/apps/server/src/application/coordinators/startGameCoordinator.ts b/apps/server/src/application/coordinators/startGameCoordinator.ts index 7e835ba..08f8fa4 100644 --- a/apps/server/src/application/coordinators/startGameCoordinator.ts +++ b/apps/server/src/application/coordinators/startGameCoordinator.ts @@ -4,24 +4,34 @@ */ import { type GameOutputPort, + type BombOutputPort, } from "@server/domains/game/application/ports/gameUseCasePorts"; import type { StartGameCoordinatorDeps } from "./coordinatorDeps"; import { startGameUseCase } from "@server/domains/game/application/useCases/startGameUseCase"; +import { createBalancedSessionPlayerIds } from "@server/domains/game/application/services/BotRosterService"; import { logEvent } from "@server/logging/logger"; -import { gameUseCaseLogEvents, logResults, logScopes } from "@server/logging/index"; +import { + gameUseCaseLogEvents, + logResults, + logScopes, +} from "@server/logging/index"; type StartGameCoordinatorParams = { ownerId: string; } & StartGameCoordinatorDeps & { - output: Pick< - GameOutputPort, - | "publishUpdatePlayersToSocket" - | "publishMapCellUpdatesToRoom" - | "publishGameEndToRoom" - | "publishGameResultToRoom" - | "publishGameStartToRoom" - >; -}; + output: Pick< + GameOutputPort, + | "publishUpdatePlayersToSocket" + | "publishMapCellUpdatesToRoom" + | "publishGameEndToRoom" + | "publishGameResultToRoom" + | "publishGameStartToRoom" + > & + Pick< + BombOutputPort, + "publishBombPlacedToOthersInRoom" | "publishBombPlacedAckToSocket" + >; + }; /** START_GAME受信時にルーム状態遷移を判定し,ゲーム開始ユースケースを実行する */ export const startGameCoordinator = ({ @@ -74,15 +84,22 @@ totalPlayers: updatedRoom.players.length, }); - const playerIds = updatedRoom.players.map((player) => player.id); - const gameManager = runtimeRegistry.getGameManagerByRoomId(updatedRoom.roomId); + const humanPlayerIds = updatedRoom.players.map((player) => player.id); + const sessionPlayerIds = createBalancedSessionPlayerIds( + updatedRoom.roomId, + humanPlayerIds, + ); + const gameManager = runtimeRegistry.getGameManagerByRoomId( + updatedRoom.roomId, + ); if (!gameManager) { return; } startGameUseCase({ roomId: updatedRoom.roomId, - playerIds, + playerIds: sessionPlayerIds, + recipientPlayerIds: humanPlayerIds, gameManager, onGameEnd: () => { roomManager.markRoomWaiting(updatedRoom.roomId); diff --git a/apps/server/src/config/index.ts b/apps/server/src/config/index.ts index fa86999..92a3533 100644 --- a/apps/server/src/config/index.ts +++ b/apps/server/src/config/index.ts @@ -11,8 +11,16 @@ CORS_METHODS: ["GET", "POST"], } as const; +const BOT_AI_CONFIG = { + BOMB_PLACE_PROBABILITY_PER_TICK: 0.06, + UNPAINTED_PRIORITY_STRENGTH: 1, + MOVE_SMOOTHNESS: 1, + TARGET_REACHED_EPSILON: 0.15, +} as const; + export const config = { ...sharedConfig, GAME_CONFIG, NETWORK_CONFIG, + BOT_AI_CONFIG, } as const; diff --git a/apps/server/src/domains/game/GameManager.ts b/apps/server/src/domains/game/GameManager.ts index 0591e50..b495a92 100644 --- a/apps/server/src/domains/game/GameManager.ts +++ b/apps/server/src/domains/game/GameManager.ts @@ -2,7 +2,11 @@ * GameManager * ゲームセッション集合の生成,更新,参照管理を統括する */ -import type { gameTypes, GameResultPayload } from "@repo/shared"; +import type { + gameTypes, + GameResultPayload, + PlaceBombPayload, +} from "@repo/shared"; import { Player } from "./entities/player/Player.js"; import { GameRoomSession } from "./application/services/GameRoomSession"; import { GameSessionLifecycleService } from "./application/services/GameSessionLifecycleService"; @@ -23,8 +27,15 @@ constructor(roomId: string) { this.sessionRef = { current: null }; this.activePlayerIds = new Set(); - this.lifecycleService = new GameSessionLifecycleService(this.sessionRef, this.activePlayerIds, roomId); - this.playerOperationService = new GamePlayerOperationService(this.sessionRef, this.activePlayerIds); + this.lifecycleService = new GameSessionLifecycleService( + this.sessionRef, + this.activePlayerIds, + roomId, + ); + this.playerOperationService = new GamePlayerOperationService( + this.sessionRef, + this.activePlayerIds, + ); } // 外部(GameHandlerなど)から開始時刻を取得できるようにする @@ -48,11 +59,17 @@ * @param onTick 毎フレーム実行される送信用のコールバック関数 */ startRoomSession( - playerIds: string[], + playerIds: string[], onTick: (data: gameTypes.TickData) => void, - onGameEnd: (payload: GameResultPayload) => void + onGameEnd: (payload: GameResultPayload) => void, + onBotPlaceBomb?: (ownerId: string, payload: PlaceBombPayload) => void, ) { - this.lifecycleService.startRoomSession(playerIds, onTick, onGameEnd); + this.lifecycleService.startRoomSession( + playerIds, + onTick, + onGameEnd, + onBotPlaceBomb, + ); } // 現在セッションのプレイヤーを取得 @@ -73,4 +90,4 @@ dispose(): void { this.lifecycleService.dispose(); } -} \ No newline at end of file +} diff --git a/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts b/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts index 797aaa6..a6eefb5 100644 --- a/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts +++ b/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts @@ -23,9 +23,12 @@ startRoomSession( playerIds: string[], onTick: (data: gameTypes.TickData) => void, - onGameEnd: (payload: GameResultPayload) => void + onGameEnd: (payload: GameResultPayload) => void, + onBotPlaceBomb?: (ownerId: string, payload: PlaceBombPayload) => void, ): void; getRoomStartTime(): number | undefined; + shouldBroadcastBombPlaced(dedupeKey: string, nowMs: number): boolean; + issueServerBombId(): string; } /** 準備完了ユースケースが利用するゲーム状態参照入力ポート */ @@ -49,18 +52,27 @@ publishPongToSocket(payload: PongPayload): void; publishUpdatePlayersToSocket( socketId: string, - players: UpdatePlayersPayload + players: UpdatePlayersPayload, ): void; publishMapCellUpdatesToRoom( roomId: roomTypes.Room["roomId"], - cellUpdates: UpdateMapCellsPayload + cellUpdates: UpdateMapCellsPayload, ): void; publishGameEndToRoom(roomId: roomTypes.Room["roomId"]): void; - publishGameResultToRoom(roomId: roomTypes.Room["roomId"], payload: GameResultPayload): void; - publishGameStartToRoom(roomId: roomTypes.Room["roomId"], payload: GameStartPayload): void; + publishGameResultToRoom( + roomId: roomTypes.Room["roomId"], + payload: GameResultPayload, + ): void; + publishGameStartToRoom( + roomId: roomTypes.Room["roomId"], + payload: GameStartPayload, + ): void; publishCurrentPlayersToSocket(players: CurrentPlayersPayload): void; publishGameStartToSocket(payload: GameStartPayload): void; - publishPlayerRemovedToRoom(roomId: roomTypes.Room["roomId"], removedPlayerId: RemovePlayerPayload): void; + publishPlayerRemovedToRoom( + roomId: roomTypes.Room["roomId"], + removedPlayerId: RemovePlayerPayload, + ): void; } /** 爆弾ユースケースが利用する送信出力ポート */ @@ -68,9 +80,12 @@ publishBombPlacedToOthersInRoom( roomId: roomTypes.Room["roomId"], ownerSocketId: string, - payload: BombPlacedPayload + payload: BombPlacedPayload, ): void; - publishBombPlacedAckToSocket(socketId: string, payload: BombPlacedAckPayload): void; + publishBombPlacedAckToSocket( + socketId: string, + payload: BombPlacedAckPayload, + ): void; } /** 爆弾設置ユースケースが利用する爆弾状態入力ポート */ diff --git a/apps/server/src/domains/game/application/services/BotAiService.ts b/apps/server/src/domains/game/application/services/BotAiService.ts new file mode 100644 index 0000000..96132c7 --- /dev/null +++ b/apps/server/src/domains/game/application/services/BotAiService.ts @@ -0,0 +1,209 @@ +/** + * BotAiService + * Botの移動・爆弾行動を決定する + */ +import type { PlaceBombPayload } from "@repo/shared"; +import { config } from "@server/config"; +import { isBotPlayerId } from "./BotRosterService"; +import type { Player } from "../../entities/player/Player"; + +type BotState = { + targetCol: number; + targetRow: number; + lastBombPlacedAtMs: number; + bombSeq: number; +}; + +type BotDecision = { + nextX: number; + nextY: number; + placeBombPayload: PlaceBombPayload | null; +}; + +const UNPAINTED_TEAM_ID = -1; + +const clamp = (value: number, min: number, max: number): number => { + return Math.max(min, Math.min(max, value)); +}; + +const toGridIndex = (col: number, row: number, cols: number): number => { + return row * cols + col; +}; + +const getCellTeamId = ( + gridColors: number[], + col: number, + row: number, + cols: number, +): number => { + return gridColors[toGridIndex(col, row, cols)] ?? UNPAINTED_TEAM_ID; +}; + +const chooseNextTarget = ( + col: number, + row: number, + gridColors: number[], +): { col: number; row: number } => { + const { UNPAINTED_PRIORITY_STRENGTH } = config.BOT_AI_CONFIG; + const { GRID_COLS, GRID_ROWS } = config.GAME_CONFIG; + const candidates = [ + { col: col + 1, row }, + { col: col - 1, row }, + { col, row: row + 1 }, + { col, row: row - 1 }, + ].filter((candidate) => { + return ( + candidate.col >= 0 && + candidate.col < GRID_COLS && + candidate.row >= 0 && + candidate.row < GRID_ROWS + ); + }); + + if (candidates.length === 0) { + return { col, row }; + } + + const unpaintedCandidates = candidates.filter((candidate) => { + return ( + getCellTeamId(gridColors, candidate.col, candidate.row, GRID_COLS) === + UNPAINTED_TEAM_ID + ); + }); + + if ( + unpaintedCandidates.length > 0 && + Math.random() < clamp(UNPAINTED_PRIORITY_STRENGTH, 0, 1) + ) { + return ( + unpaintedCandidates[ + Math.floor(Math.random() * unpaintedCandidates.length) + ] ?? { col, row } + ); + } + + return ( + candidates[Math.floor(Math.random() * candidates.length)] ?? { col, row } + ); +}; + +const moveTowardsTarget = ( + x: number, + y: number, + targetCol: number, + targetRow: number, +): { nextX: number; nextY: number } => { + const targetX = targetCol + 0.5; + const targetY = targetRow + 0.5; + const diffX = targetX - x; + const diffY = targetY - y; + const distance = Math.hypot(diffX, diffY); + + const maxStep = + config.GAME_CONFIG.PLAYER_SPEED * + (config.GAME_CONFIG.PLAYER_POSITION_UPDATE_MS / 1000) * + clamp(config.BOT_AI_CONFIG.MOVE_SMOOTHNESS, 0.1, 2); + + if (distance <= maxStep || distance === 0) { + return { nextX: targetX, nextY: targetY }; + } + + const ratio = maxStep / distance; + const nextX = x + diffX * ratio; + const nextY = y + diffY * ratio; + + return { + nextX: clamp(nextX, 0, config.GAME_CONFIG.GRID_COLS - 0.001), + nextY: clamp(nextY, 0, config.GAME_CONFIG.GRID_ROWS - 0.001), + }; +}; + +/** Botの移動・爆弾行動を管理するサービス */ +export class BotAiService { + private states = new Map(); + + public decide( + player: Player, + gridColors: number[], + nowMs: number, + elapsedMs: number, + ): BotDecision { + const { GRID_COLS, GRID_ROWS, BOMB_COOLDOWN_MS, BOMB_FUSE_MS } = + config.GAME_CONFIG; + const currentCol = clamp(Math.floor(player.x), 0, GRID_COLS - 1); + const currentRow = clamp(Math.floor(player.y), 0, GRID_ROWS - 1); + + const currentState = this.states.get(player.id) ?? { + targetCol: currentCol, + targetRow: currentRow, + lastBombPlacedAtMs: Number.NEGATIVE_INFINITY, + bombSeq: 0, + }; + + const targetCenterX = currentState.targetCol + 0.5; + const targetCenterY = currentState.targetRow + 0.5; + const reachedTarget = + Math.hypot(targetCenterX - player.x, targetCenterY - player.y) <= + config.BOT_AI_CONFIG.TARGET_REACHED_EPSILON; + + const nextTarget = reachedTarget + ? chooseNextTarget(currentCol, currentRow, gridColors) + : { col: currentState.targetCol, row: currentState.targetRow }; + + const moved = moveTowardsTarget( + player.x, + player.y, + nextTarget.col, + nextTarget.row, + ); + + let placeBombPayload: PlaceBombPayload | null = null; + const canPlaceBomb = + nowMs - currentState.lastBombPlacedAtMs >= BOMB_COOLDOWN_MS; + if ( + canPlaceBomb && + Math.random() < + clamp(config.BOT_AI_CONFIG.BOMB_PLACE_PROBABILITY_PER_TICK, 0, 1) + ) { + const nextBombSeq = currentState.bombSeq + 1; + placeBombPayload = { + requestId: `bot-${player.id}-${nextBombSeq}`, + x: moved.nextX, + y: moved.nextY, + explodeAtElapsedMs: elapsedMs + BOMB_FUSE_MS, + }; + + this.states.set(player.id, { + targetCol: nextTarget.col, + targetRow: nextTarget.row, + bombSeq: nextBombSeq, + lastBombPlacedAtMs: nowMs, + }); + + return { + nextX: moved.nextX, + nextY: moved.nextY, + placeBombPayload, + }; + } + + this.states.set(player.id, { + targetCol: nextTarget.col, + targetRow: nextTarget.row, + bombSeq: currentState.bombSeq, + lastBombPlacedAtMs: currentState.lastBombPlacedAtMs, + }); + + return { + nextX: moved.nextX, + nextY: moved.nextY, + placeBombPayload, + }; + } + + public clear(): void { + this.states.clear(); + } +} + +export { isBotPlayerId }; diff --git a/apps/server/src/domains/game/application/services/BotRosterService.ts b/apps/server/src/domains/game/application/services/BotRosterService.ts new file mode 100644 index 0000000..8c5e665 --- /dev/null +++ b/apps/server/src/domains/game/application/services/BotRosterService.ts @@ -0,0 +1,45 @@ +/** + * BotRosterService + * 4チームの人数差をなくすためのBot ID補充ロジックを提供する + */ +import { config } from "@server/config"; + +const BOT_PLAYER_ID_PREFIX = "bot:"; + +/** BotプレイヤーIDかどうかを判定する */ +export const isBotPlayerId = (playerId: string): boolean => { + return playerId.startsWith(BOT_PLAYER_ID_PREFIX); +}; + +const getRequiredBotCount = (humanPlayerCount: number): number => { + const teamCount = config.GAME_CONFIG.TEAM_COUNT; + if (teamCount <= 0) { + return 0; + } + + const remainder = humanPlayerCount % teamCount; + if (remainder === 0) { + return 0; + } + + return teamCount - remainder; +}; + +/** + * 人間プレイヤーIDに必要数のBot IDを補充し,チーム人数差が0になる構成を返す + */ +export const createBalancedSessionPlayerIds = ( + roomId: string, + humanPlayerIds: string[], +): string[] => { + const requiredBotCount = getRequiredBotCount(humanPlayerIds.length); + if (requiredBotCount === 0) { + return [...humanPlayerIds]; + } + + const botIds = Array.from({ length: requiredBotCount }, (_, index) => { + return `${BOT_PLAYER_ID_PREFIX}${roomId}:${index + 1}`; + }); + + return [...humanPlayerIds, ...botIds]; +}; diff --git a/apps/server/src/domains/game/application/services/GameRoomSession.ts b/apps/server/src/domains/game/application/services/GameRoomSession.ts index 5759e6f..d6a2d72 100644 --- a/apps/server/src/domains/game/application/services/GameRoomSession.ts +++ b/apps/server/src/domains/game/application/services/GameRoomSession.ts @@ -20,6 +20,7 @@ } from "../../entities/player/playerMovement.js"; import { buildGameResultPayload } from "./gameResultCalculator.js"; import { TeamAssignmentService } from "../services/TeamAssignmentService.js"; +import type { PlaceBombPayload } from "@repo/shared"; /** ルーム単位のゲーム状態とループ進行を保持するセッションクラス */ export class GameRoomSession { @@ -54,6 +55,7 @@ tickRate: number, onTick: (data: gameTypes.TickData) => void, onGameEnd: (payload: GameResultPayload) => void, + onBotPlaceBomb?: (ownerId: string, payload: PlaceBombPayload) => void, ): void { if (this.gameLoop) { return; @@ -67,10 +69,13 @@ this.mapStore, onTick, () => { - const resultPayload = buildGameResultPayload(this.mapStore.getGridColorsSnapshot()); + const resultPayload = buildGameResultPayload( + this.mapStore.getGridColorsSnapshot(), + ); this.dispose(); onGameEnd(resultPayload); }, + onBotPlaceBomb, ); this.gameLoop.start(); diff --git a/apps/server/src/domains/game/application/services/GameSessionLifecycleService.ts b/apps/server/src/domains/game/application/services/GameSessionLifecycleService.ts index 0f86e1b..3fb9d09 100644 --- a/apps/server/src/domains/game/application/services/GameSessionLifecycleService.ts +++ b/apps/server/src/domains/game/application/services/GameSessionLifecycleService.ts @@ -3,9 +3,17 @@ * ゲームセッションの開始,参照,終了時クリーンアップを管理する */ import { config } from "@server/config"; -import type { gameTypes, GameResultPayload } from "@repo/shared"; +import type { + gameTypes, + GameResultPayload, + PlaceBombPayload, +} from "@repo/shared"; import { logEvent } from "@server/logging/logger"; -import { gameDomainLogEvents, logResults, logScopes } from "@server/logging/index"; +import { + gameDomainLogEvents, + logResults, + logScopes, +} from "@server/logging/index"; import { GameRoomSession } from "./GameRoomSession"; type GameSessionRef = { current: GameRoomSession | null }; @@ -16,7 +24,7 @@ constructor( private sessionRef: GameSessionRef, private activePlayerIds: ActivePlayerIndex, - private roomId: string + private roomId: string, ) {} public getRoomStartTime(): number | undefined { @@ -28,7 +36,10 @@ } public shouldBroadcastBombPlaced(dedupeKey: string, nowMs: number): boolean { - return this.sessionRef.current?.shouldBroadcastBombPlaced(dedupeKey, nowMs) ?? false; + return ( + this.sessionRef.current?.shouldBroadcastBombPlaced(dedupeKey, nowMs) ?? + false + ); } public issueServerBombId(): string { @@ -43,7 +54,8 @@ public startRoomSession( playerIds: string[], onTick: (data: gameTypes.TickData) => void, - onGameEnd: (payload: GameResultPayload) => void + onGameEnd: (payload: GameResultPayload) => void, + onBotPlaceBomb?: (ownerId: string, payload: PlaceBombPayload) => void, ) { if (this.sessionRef.current) { logEvent(logScopes.GAME_SESSION_LIFECYCLE_SERVICE, { @@ -63,11 +75,16 @@ }); this.sessionRef.current = session; - session.start(tickRate, onTick, (payload) => { - this.activePlayerIds.clear(); - this.sessionRef.current = null; - onGameEnd(payload); - }); + session.start( + tickRate, + onTick, + (payload) => { + this.activePlayerIds.clear(); + this.sessionRef.current = null; + onGameEnd(payload); + }, + onBotPlaceBomb, + ); logEvent(logScopes.GAME_SESSION_LIFECYCLE_SERVICE, { event: gameDomainLogEvents.SESSION_START, @@ -82,4 +99,4 @@ this.sessionRef.current = null; this.activePlayerIds.clear(); } -} \ 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 db39423..9956be5 100644 --- a/apps/server/src/domains/game/application/useCases/startGameUseCase.ts +++ b/apps/server/src/domains/game/application/useCases/startGameUseCase.ts @@ -2,20 +2,34 @@ * startGameUseCase * ルーム内プレイヤーでゲームセッションを開始し,進行イベントを通知する */ -import type { GameOutputPort, StartGamePort } from "../ports/gameUseCasePorts"; +import type { + BombOutputPort, + GameOutputPort, + StartGamePort, +} from "../ports/gameUseCasePorts"; import { logEvent } from "@server/logging/logger"; -import { gameUseCaseLogEvents, logResults, logScopes } from "@server/logging/index"; +import { + gameUseCaseLogEvents, + logResults, + logScopes, +} from "@server/logging/index"; +import { placeBombUseCase } from "./placeBombUseCase"; -const excludeRecipientFromPlayerUpdates = ( +const excludeRecipientFromPlayerUpdates = < + TPlayerUpdate extends { id: string }, +>( playerUpdates: TPlayerUpdate[], - recipientId: string + recipientId: string, ): TPlayerUpdate[] => { - return playerUpdates.filter((playerUpdate) => playerUpdate.id !== recipientId); + return playerUpdates.filter( + (playerUpdate) => playerUpdate.id !== recipientId, + ); }; type StartGameUseCaseParams = { roomId: string; playerIds: string[]; + recipientPlayerIds?: string[]; gameManager: StartGamePort; onGameEnd: () => void; output: Pick< @@ -25,25 +39,32 @@ | "publishGameEndToRoom" | "publishGameResultToRoom" | "publishGameStartToRoom" - >; + > & + Pick< + BombOutputPort, + "publishBombPlacedToOthersInRoom" | "publishBombPlacedAckToSocket" + >; }; /** ゲームセッション開始とティック通知,終了通知を実行する */ export const startGameUseCase = ({ roomId, playerIds, + recipientPlayerIds, gameManager, onGameEnd, output, }: StartGameUseCaseParams) => { + const updateRecipients = recipientPlayerIds ?? playerIds; + gameManager.startRoomSession( playerIds, (tickData) => { if (tickData.playerUpdates.length > 0) { - playerIds.forEach((playerId) => { + updateRecipients.forEach((playerId) => { const updatesForPlayer = excludeRecipientFromPlayerUpdates( tickData.playerUpdates, - playerId + playerId, ); if (updatesForPlayer.length === 0) { @@ -68,7 +89,19 @@ output.publishGameEndToRoom(roomId); output.publishGameResultToRoom(roomId, resultPayload); onGameEnd(); - } + }, + (ownerId, payload) => { + placeBombUseCase({ + roomId, + bombStore: gameManager, + input: { + socketId: ownerId, + payload, + nowMs: Date.now(), + }, + output, + }); + }, ); const startTime = gameManager.getRoomStartTime() || Date.now(); diff --git a/apps/server/src/domains/game/loop/GameLoop.ts b/apps/server/src/domains/game/loop/GameLoop.ts index 9673e75..7c32b3f 100644 --- a/apps/server/src/domains/game/loop/GameLoop.ts +++ b/apps/server/src/domains/game/loop/GameLoop.ts @@ -6,9 +6,18 @@ import { MapStore } from "../entities/map/MapStore"; import { getPlayerGridIndex } from "../entities/player/playerPosition.js"; import { config } from "@server/config"; -import type { gameTypes } from "@repo/shared"; +import type { gameTypes, PlaceBombPayload } from "@repo/shared"; import { logEvent } from "@server/logging/logger"; -import { gameDomainLogEvents, logResults, logScopes } from "@server/logging/index"; +import { + gameDomainLogEvents, + logResults, + logScopes, +} from "@server/logging/index"; +import { + BotAiService, + isBotPlayerId, +} from "../application/services/BotAiService"; +import { setPlayerPosition } from "../entities/player/playerMovement.js"; /** ルーム内ゲーム進行を定周期で実行するループ管理クラス */ export class GameLoop { @@ -18,7 +27,9 @@ private endMonotonicTimeMs: number = 0; private nextTickAtMs: number = 0; private readonly maxCatchUpTicks: number = 3; - private lastSentPlayers: Map = new Map(); + private lastSentPlayers: Map = + new Map(); + private botAiService: BotAiService = new BotAiService(); constructor( private roomId: string, @@ -26,7 +37,11 @@ private players: Map, private mapStore: MapStore, private onTick: (data: gameTypes.TickData) => void, - private onGameEnd: () => void // ゲーム終了時のコールバック + private onGameEnd: () => void, + private onBotPlaceBomb?: ( + ownerId: string, + payload: PlaceBombPayload, + ) => void, ) {} start() { @@ -35,7 +50,8 @@ const nowMs = performance.now(); this.startMonotonicTimeMs = nowMs; - this.endMonotonicTimeMs = nowMs + config.GAME_CONFIG.GAME_DURATION_SEC * 1000; + this.endMonotonicTimeMs = + nowMs + config.GAME_CONFIG.GAME_DURATION_SEC * 1000; this.nextTickAtMs = nowMs + this.tickRate; this.lastSentPlayers.clear(); this.isRunning = true; @@ -71,7 +87,10 @@ let processedTicks = 0; - while (nowMs >= this.nextTickAtMs && processedTicks < this.maxCatchUpTicks) { + while ( + nowMs >= this.nextTickAtMs && + processedTicks < this.maxCatchUpTicks + ) { this.processSingleTick(); this.nextTickAtMs += this.tickRate; processedTicks += 1; @@ -94,11 +113,31 @@ private processSingleTick(): void { const changedPlayers: gameTypes.TickData["playerUpdates"] = []; const activePlayerIds = new Set(); + const nowMs = performance.now(); + const elapsedMs = Math.max( + 0, + Math.round(nowMs - this.startMonotonicTimeMs), + ); + const gridColorsSnapshot = this.mapStore.getGridColorsSnapshot(); // 1. 各プレイヤーの座標処理とマス塗りの判定 this.players.forEach((player) => { activePlayerIds.add(player.id); + if (isBotPlayerId(player.id)) { + const decision = this.botAiService.decide( + player, + gridColorsSnapshot, + nowMs, + elapsedMs, + ); + setPlayerPosition(player, decision.nextX, decision.nextY); + + if (decision.placeBombPayload && this.onBotPlaceBomb) { + this.onBotPlaceBomb(player.id, decision.placeBombPayload); + } + } + const gridIndex = getPlayerGridIndex(player); if (gridIndex !== null) { this.mapStore.paintCell(gridIndex, player.teamId); @@ -144,6 +183,7 @@ if (!this.isRunning) return; this.isRunning = false; + this.botAiService.clear(); this.lastSentPlayers.clear(); if (this.loopId) { @@ -155,7 +195,10 @@ event: gameDomainLogEvents.GAME_LOOP, result: logResults.STOPPED, roomId: this.roomId, - elapsedMs: Math.max(0, Math.round(performance.now() - this.startMonotonicTimeMs)), + elapsedMs: Math.max( + 0, + Math.round(performance.now() - this.startMonotonicTimeMs), + ), }); } -} \ No newline at end of file +}