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..ec7626b 100644 --- a/apps/server/src/domains/game/GameManager.ts +++ b/apps/server/src/domains/game/GameManager.ts @@ -2,7 +2,7 @@ * 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"; @@ -50,9 +50,10 @@ startRoomSession( 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); } // 現在セッションのプレイヤーを取得 diff --git a/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts b/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts index 797aaa6..f215ddf 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; } /** 準備完了ユースケースが利用するゲーム状態参照入力ポート */ 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..cc30224 --- /dev/null +++ b/apps/server/src/domains/game/application/services/BotAiService.ts @@ -0,0 +1,199 @@ +/** + * 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..f95e829 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; @@ -71,6 +73,7 @@ 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..1e5715f 100644 --- a/apps/server/src/domains/game/application/services/GameSessionLifecycleService.ts +++ b/apps/server/src/domains/game/application/services/GameSessionLifecycleService.ts @@ -3,7 +3,7 @@ * ゲームセッションの開始,参照,終了時クリーンアップを管理する */ 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 { GameRoomSession } from "./GameRoomSession"; @@ -43,7 +43,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, { @@ -67,7 +68,7 @@ this.activePlayerIds.clear(); this.sessionRef.current = null; onGameEnd(payload); - }); + }, onBotPlaceBomb); logEvent(logScopes.GAME_SESSION_LIFECYCLE_SERVICE, { event: gameDomainLogEvents.SESSION_START, diff --git a/apps/server/src/domains/game/application/useCases/startGameUseCase.ts b/apps/server/src/domains/game/application/useCases/startGameUseCase.ts index db39423..e793e05 100644 --- a/apps/server/src/domains/game/application/useCases/startGameUseCase.ts +++ b/apps/server/src/domains/game/application/useCases/startGameUseCase.ts @@ -2,9 +2,14 @@ * 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 { placeBombUseCase } from "./placeBombUseCase"; const excludeRecipientFromPlayerUpdates = ( playerUpdates: TPlayerUpdate[], @@ -16,6 +21,7 @@ type StartGameUseCaseParams = { roomId: string; playerIds: string[]; + recipientPlayerIds?: string[]; gameManager: StartGamePort; onGameEnd: () => void; output: Pick< @@ -25,6 +31,10 @@ | "publishGameEndToRoom" | "publishGameResultToRoom" | "publishGameStartToRoom" + > & Pick< + BombOutputPort, + | "publishBombPlacedToOthersInRoom" + | "publishBombPlacedAckToSocket" >; }; @@ -32,15 +42,18 @@ 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 @@ -68,7 +81,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..d4a6095 100644 --- a/apps/server/src/domains/game/loop/GameLoop.ts +++ b/apps/server/src/domains/game/loop/GameLoop.ts @@ -6,9 +6,11 @@ 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 { BotAiService, isBotPlayerId } from "../application/services/BotAiService"; +import { setPlayerPosition } from "../entities/player/playerMovement.js"; /** ルーム内ゲーム進行を定周期で実行するループ管理クラス */ export class GameLoop { @@ -19,6 +21,7 @@ private nextTickAtMs: number = 0; private readonly maxCatchUpTicks: number = 3; private lastSentPlayers: Map = new Map(); + private botAiService: BotAiService = new BotAiService(); constructor( private roomId: string, @@ -26,7 +29,8 @@ 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() { @@ -94,11 +98,28 @@ 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 +165,7 @@ if (!this.isRunning) return; this.isRunning = false; + this.botAiService.clear(); this.lastSentPlayers.clear(); if (this.loopId) {