diff --git a/apps/server/src/domains/game/loop/HurricaneSystem.ts b/apps/server/src/domains/game/loop/HurricaneSystem.ts index 4f0b946..cca4002 100644 --- a/apps/server/src/domains/game/loop/HurricaneSystem.ts +++ b/apps/server/src/domains/game/loop/HurricaneSystem.ts @@ -1,99 +1,36 @@ /** * HurricaneSystem - * ハリケーンの生成,移動,同期データ化,被弾検知を管理する + * ハリケーンの生成と各サービス呼び出しを管理する * GameLoop からハリケーン専用責務を分離する */ import { config } from "@server/config"; -import { collectSyncDeltaEntries } from "@server/common/syncDelta"; -import { domain, type HurricaneStatePayload } from "@repo/shared"; import { Player } from "../entities/player/Player.js"; - -const { checkBombHit } = domain.game.bombHit; -const HURRICANE_RELIABLE_RESYNC_INTERVAL_MS = - config.GAME_CONFIG.NETWORK_SYNC.HURRICANE_RELIABLE_RESYNC_INTERVAL_MS; +import { HurricaneMotionService } from "./hurricane/HurricaneMotionService.js"; +import { HurricaneSyncService } from "./hurricane/HurricaneSyncService.js"; +import { HurricaneHitService } from "./hurricane/HurricaneHitService.js"; +import type { + HurricaneState, + HurricaneSyncOutputs, + MapGridSize, +} from "./hurricane/hurricaneTypes.js"; /** 1ティック分のハリケーン同期出力 */ -export type HurricaneSyncOutputs = { - snapshotUpdates: HurricaneStatePayload[]; - deltaUpdates: HurricaneStatePayload[]; -}; - -type HurricaneState = { - id: string; - x: number; - y: number; - vx: number; - vy: number; - radius: number; - rotationRad: number; -}; - -type MapGridSize = { - gridCols: number; - gridRows: number; -}; - -type HurricaneSyncSnapshot = { - x: number; - y: number; - radius: number; - rotationRad: number; -}; - -const quantizeValue = (value: number, scale: number): number => { - return Math.round(value * scale) / scale; -}; - -const toHurricaneSyncSnapshot = ( - state: HurricaneState, -): HurricaneSyncSnapshot => { - return { - x: quantizeValue( - state.x, - config.GAME_CONFIG.NETWORK_SYNC.HURRICANE_POSITION_QUANTIZE_SCALE, - ), - y: quantizeValue( - state.y, - config.GAME_CONFIG.NETWORK_SYNC.HURRICANE_POSITION_QUANTIZE_SCALE, - ), - radius: quantizeValue( - state.radius, - config.GAME_CONFIG.NETWORK_SYNC.HURRICANE_POSITION_QUANTIZE_SCALE, - ), - rotationRad: quantizeValue( - state.rotationRad, - config.GAME_CONFIG.NETWORK_SYNC.HURRICANE_ROTATION_QUANTIZE_SCALE, - ), - }; -}; - -const isSameHurricaneSyncSnapshot = ( - left: HurricaneSyncSnapshot, - right: HurricaneSyncSnapshot, -): boolean => { - return ( - left.x === right.x - && left.y === right.y - && left.radius === right.radius - && left.rotationRad === right.rotationRad - ); -}; +export type { HurricaneSyncOutputs } from "./hurricane/hurricaneTypes.js"; /** ハリケーン状態の生成更新と被弾判定を管理する */ export class HurricaneSystem { private readonly mapSize: MapGridSize; private hasSpawned = false; - private hasInitialSyncPending = false; - private lastReliableSyncElapsedMs = -1; private hurricanes: HurricaneState[] = []; - private readonly lastHitAtMsByPlayerId = new Map(); - private readonly lastSentSnapshotByHurricaneId = new Map< - string, - HurricaneSyncSnapshot - >(); + private readonly motionService: HurricaneMotionService; + private readonly syncService: HurricaneSyncService; + private readonly hitService: HurricaneHitService; constructor(mapSize: MapGridSize) { this.mapSize = mapSize; + this.motionService = new HurricaneMotionService(mapSize); + this.syncService = new HurricaneSyncService(); + this.hitService = new HurricaneHitService(); } /** 残り時間しきい値到達時にハリケーンを一度だけ生成する */ @@ -109,7 +46,7 @@ } this.hasSpawned = true; - this.hasInitialSyncPending = true; + this.syncService.markInitialSyncPending(); this.hurricanes = Array.from( { length: config.GAME_CONFIG.HURRICANE_COUNT }, (_, index) => this.createHurricane(index), @@ -118,69 +55,12 @@ /** 1ティック分のハリケーン同期出力をまとめて返す */ public consumeSyncOutputs(elapsedMs: number): HurricaneSyncOutputs { - const snapshotUpdates = this.consumeSnapshotUpdates(elapsedMs); - const deltaUpdates = this.consumeDeltaUpdates(); - - return { - snapshotUpdates, - deltaUpdates, - }; + return this.syncService.consumeSyncOutputs(elapsedMs, this.hurricanes); } /** ハリケーンを直線移動させ,境界で反射させる */ public update(deltaSec: number): void { - if (this.hurricanes.length === 0) { - return; - } - - const maxX = this.mapSize.gridCols; - const maxY = this.mapSize.gridRows; - - this.hurricanes.forEach((hurricane) => { - hurricane.x += hurricane.vx * deltaSec; - hurricane.y += hurricane.vy * deltaSec; - hurricane.rotationRad += - config.GAME_CONFIG.HURRICANE_VISUAL_ROTATION_SPEED * deltaSec; - - if (hurricane.x - hurricane.radius < 0) { - hurricane.x = hurricane.radius; - hurricane.vx *= -1; - } else if (hurricane.x + hurricane.radius > maxX) { - hurricane.x = maxX - hurricane.radius; - hurricane.vx *= -1; - } - - if (hurricane.y - hurricane.radius < 0) { - hurricane.y = hurricane.radius; - hurricane.vy *= -1; - } else if (hurricane.y + hurricane.radius > maxY) { - hurricane.y = maxY - hurricane.radius; - hurricane.vy *= -1; - } - }); - } - - /** 差分同期配信用のハリケーン状態配列を返す */ - private consumeDeltaUpdates(): HurricaneStatePayload[] { - return collectSyncDeltaEntries( - this.hurricanes, - this.lastSentSnapshotByHurricaneId, - { - selectId: (hurricane) => hurricane.id, - toSnapshot: (hurricane) => toHurricaneSyncSnapshot(hurricane), - isSameSnapshot: (left, right) => - isSameHurricaneSyncSnapshot(left, right), - }, - ).map((entry) => { - const { item, snapshot } = entry; - return { - id: item.id, - x: snapshot.x, - y: snapshot.y, - radius: snapshot.radius, - rotationRad: snapshot.rotationRad, - }; - }); + this.motionService.update(this.hurricanes, deltaSec); } /** クールダウン付きで被弾プレイヤーID配列を返す */ @@ -188,57 +68,15 @@ players: Map, nowMs: number, ): string[] { - if (this.hurricanes.length === 0) { - return []; - } - - const hitPlayerIds: string[] = []; - const hitCooldownMs = config.GAME_CONFIG.HURRICANE_HIT_COOLDOWN_MS; - - players.forEach((player) => { - const lastHitAtMs = this.lastHitAtMsByPlayerId.get(player.id); - if (lastHitAtMs !== undefined && nowMs - lastHitAtMs < hitCooldownMs) { - return; - } - - const isHit = this.hurricanes.some((hurricane) => { - const result = checkBombHit({ - bomb: { - x: hurricane.x, - y: hurricane.y, - radius: hurricane.radius, - teamId: -1, - }, - player: { - x: player.x, - y: player.y, - radius: config.GAME_CONFIG.PLAYER_RADIUS, - teamId: player.teamId, - }, - }); - - return result.isHit; - }); - - if (!isHit) { - return; - } - - this.lastHitAtMsByPlayerId.set(player.id, nowMs); - hitPlayerIds.push(player.id); - }); - - return hitPlayerIds; + return this.hitService.collectHitPlayerIds(this.hurricanes, players, nowMs); } /** 状態を初期化する */ public clear(): void { this.hasSpawned = false; - this.hasInitialSyncPending = false; - this.lastReliableSyncElapsedMs = -1; this.hurricanes = []; - this.lastHitAtMsByPlayerId.clear(); - this.lastSentSnapshotByHurricaneId.clear(); + this.syncService.clear(); + this.hitService.clear(); } /** ハリケーン初期状態を生成する */ @@ -263,51 +101,4 @@ private randomInRange(min: number, max: number): number { return min + Math.random() * Math.max(0, max - min); } - - /** - * current-hurricanes 用の全量同期を返す - * 初回同期と定期再同期の両方で同じ全量ペイロードを利用する - */ - private consumeSnapshotUpdates(elapsedMs: number): HurricaneStatePayload[] { - if (this.hurricanes.length === 0) { - return []; - } - - if (this.hasInitialSyncPending) { - this.hasInitialSyncPending = false; - this.lastReliableSyncElapsedMs = elapsedMs; - return this.buildSnapshotPayloadAndCommit(); - } - - if (this.lastReliableSyncElapsedMs < 0) { - this.lastReliableSyncElapsedMs = elapsedMs; - return []; - } - - if ( - elapsedMs - this.lastReliableSyncElapsedMs - < HURRICANE_RELIABLE_RESYNC_INTERVAL_MS - ) { - return []; - } - - this.lastReliableSyncElapsedMs = elapsedMs; - return this.buildSnapshotPayloadAndCommit(); - } - - /** 現在状態を量子化スナップショットへ変換して送信済み状態へ反映する */ - private buildSnapshotPayloadAndCommit(): HurricaneStatePayload[] { - return this.hurricanes.map((hurricane) => { - const snapshot = toHurricaneSyncSnapshot(hurricane); - this.lastSentSnapshotByHurricaneId.set(hurricane.id, snapshot); - - return { - id: hurricane.id, - x: snapshot.x, - y: snapshot.y, - radius: snapshot.radius, - rotationRad: snapshot.rotationRad, - }; - }); - } } diff --git a/apps/server/src/domains/game/loop/hurricane/HurricaneHitService.ts b/apps/server/src/domains/game/loop/hurricane/HurricaneHitService.ts new file mode 100644 index 0000000..182c97e --- /dev/null +++ b/apps/server/src/domains/game/loop/hurricane/HurricaneHitService.ts @@ -0,0 +1,69 @@ +/** + * HurricaneHitService + * ハリケーン被弾判定を担当する + */ +import { config } from "@server/config"; +import { domain } from "@repo/shared"; +import { Player } from "../../entities/player/Player.js"; +import type { HurricaneState } from "./hurricaneTypes.js"; + +const { checkBombHit } = domain.game.bombHit; + +/** ハリケーン被弾判定を実行する */ +export class HurricaneHitService { + private readonly lastHitAtMsByPlayerId = new Map(); + + /** クールダウン付きで被弾プレイヤーID配列を返す */ + public collectHitPlayerIds( + hurricanes: HurricaneState[], + players: Map, + nowMs: number, + ): string[] { + if (hurricanes.length === 0) { + return []; + } + + const hitPlayerIds: string[] = []; + const hitCooldownMs = config.GAME_CONFIG.HURRICANE_HIT_COOLDOWN_MS; + + players.forEach((player) => { + const lastHitAtMs = this.lastHitAtMsByPlayerId.get(player.id); + if (lastHitAtMs !== undefined && nowMs - lastHitAtMs < hitCooldownMs) { + return; + } + + const isHit = hurricanes.some((hurricane) => { + const result = checkBombHit({ + bomb: { + x: hurricane.x, + y: hurricane.y, + radius: hurricane.radius, + teamId: -1, + }, + player: { + x: player.x, + y: player.y, + radius: config.GAME_CONFIG.PLAYER_RADIUS, + teamId: player.teamId, + }, + }); + + return result.isHit; + }); + + if (!isHit) { + return; + } + + this.lastHitAtMsByPlayerId.set(player.id, nowMs); + hitPlayerIds.push(player.id); + }); + + return hitPlayerIds; + } + + /** 被弾判定状態を初期化する */ + public clear(): void { + this.lastHitAtMsByPlayerId.clear(); + } +} diff --git a/apps/server/src/domains/game/loop/hurricane/HurricaneMotionService.ts b/apps/server/src/domains/game/loop/hurricane/HurricaneMotionService.ts new file mode 100644 index 0000000..785d587 --- /dev/null +++ b/apps/server/src/domains/game/loop/hurricane/HurricaneMotionService.ts @@ -0,0 +1,44 @@ +/** + * HurricaneMotionService + * ハリケーンの移動と境界反射を担当する + */ +import { config } from "@server/config"; +import type { HurricaneState, MapGridSize } from "./hurricaneTypes.js"; + +/** ハリケーン移動ロジックを提供する */ +export class HurricaneMotionService { + constructor(private readonly mapSize: MapGridSize) {} + + /** ハリケーンを直線移動させ,境界で反射させる */ + public update(hurricanes: HurricaneState[], deltaSec: number): void { + if (hurricanes.length === 0) { + return; + } + + const maxX = this.mapSize.gridCols; + const maxY = this.mapSize.gridRows; + + hurricanes.forEach((hurricane) => { + hurricane.x += hurricane.vx * deltaSec; + hurricane.y += hurricane.vy * deltaSec; + hurricane.rotationRad += + config.GAME_CONFIG.HURRICANE_VISUAL_ROTATION_SPEED * deltaSec; + + if (hurricane.x - hurricane.radius < 0) { + hurricane.x = hurricane.radius; + hurricane.vx *= -1; + } else if (hurricane.x + hurricane.radius > maxX) { + hurricane.x = maxX - hurricane.radius; + hurricane.vx *= -1; + } + + if (hurricane.y - hurricane.radius < 0) { + hurricane.y = hurricane.radius; + hurricane.vy *= -1; + } else if (hurricane.y + hurricane.radius > maxY) { + hurricane.y = maxY - hurricane.radius; + hurricane.vy *= -1; + } + }); + } +} diff --git a/apps/server/src/domains/game/loop/hurricane/HurricaneSyncService.ts b/apps/server/src/domains/game/loop/hurricane/HurricaneSyncService.ts new file mode 100644 index 0000000..343e341 --- /dev/null +++ b/apps/server/src/domains/game/loop/hurricane/HurricaneSyncService.ts @@ -0,0 +1,164 @@ +/** + * HurricaneSyncService + * ハリケーンの同期ペイロード生成を担当する + */ +import { config } from "@server/config"; +import { collectSyncDeltaEntries } from "@server/common/syncDelta"; +import type { HurricaneStatePayload } from "@repo/shared"; +import type { + HurricaneState, + HurricaneSyncOutputs, + HurricaneSyncSnapshot, +} from "./hurricaneTypes.js"; + +const HURRICANE_RELIABLE_RESYNC_INTERVAL_MS = + config.GAME_CONFIG.NETWORK_SYNC.HURRICANE_RELIABLE_RESYNC_INTERVAL_MS; + +const quantizeValue = (value: number, scale: number): number => { + return Math.round(value * scale) / scale; +}; + +const toHurricaneSyncSnapshot = ( + state: HurricaneState, +): HurricaneSyncSnapshot => { + return { + x: quantizeValue( + state.x, + config.GAME_CONFIG.NETWORK_SYNC.HURRICANE_POSITION_QUANTIZE_SCALE, + ), + y: quantizeValue( + state.y, + config.GAME_CONFIG.NETWORK_SYNC.HURRICANE_POSITION_QUANTIZE_SCALE, + ), + radius: quantizeValue( + state.radius, + config.GAME_CONFIG.NETWORK_SYNC.HURRICANE_POSITION_QUANTIZE_SCALE, + ), + rotationRad: quantizeValue( + state.rotationRad, + config.GAME_CONFIG.NETWORK_SYNC.HURRICANE_ROTATION_QUANTIZE_SCALE, + ), + }; +}; + +const isSameHurricaneSyncSnapshot = ( + left: HurricaneSyncSnapshot, + right: HurricaneSyncSnapshot, +): boolean => { + return ( + left.x === right.x + && left.y === right.y + && left.radius === right.radius + && left.rotationRad === right.rotationRad + ); +}; + +/** ハリケーン同期データを生成する */ +export class HurricaneSyncService { + private hasInitialSyncPending = false; + private lastReliableSyncElapsedMs = -1; + private readonly lastSentSnapshotByHurricaneId = new Map< + string, + HurricaneSyncSnapshot + >(); + + /** 初回全量同期を次回生成するようマークする */ + public markInitialSyncPending(): void { + this.hasInitialSyncPending = true; + } + + /** 1ティック分の current/update 同期配列を返す */ + public consumeSyncOutputs( + elapsedMs: number, + hurricanes: HurricaneState[], + ): HurricaneSyncOutputs { + const snapshotUpdates = this.consumeSnapshotUpdates(elapsedMs, hurricanes); + const deltaUpdates = this.consumeDeltaUpdates(hurricanes); + + return { + snapshotUpdates, + deltaUpdates, + }; + } + + /** 同期状態を初期化する */ + public clear(): void { + this.hasInitialSyncPending = false; + this.lastReliableSyncElapsedMs = -1; + this.lastSentSnapshotByHurricaneId.clear(); + } + + /** current-hurricanes 用の全量同期を返す */ + private consumeSnapshotUpdates( + elapsedMs: number, + hurricanes: HurricaneState[], + ): HurricaneStatePayload[] { + if (hurricanes.length === 0) { + return []; + } + + if (this.hasInitialSyncPending) { + this.hasInitialSyncPending = false; + this.lastReliableSyncElapsedMs = elapsedMs; + return this.buildSnapshotPayloadAndCommit(hurricanes); + } + + if (this.lastReliableSyncElapsedMs < 0) { + this.lastReliableSyncElapsedMs = elapsedMs; + return []; + } + + if ( + elapsedMs - this.lastReliableSyncElapsedMs + < HURRICANE_RELIABLE_RESYNC_INTERVAL_MS + ) { + return []; + } + + this.lastReliableSyncElapsedMs = elapsedMs; + return this.buildSnapshotPayloadAndCommit(hurricanes); + } + + /** 差分同期配信用のハリケーン状態配列を返す */ + private consumeDeltaUpdates( + hurricanes: HurricaneState[], + ): HurricaneStatePayload[] { + return collectSyncDeltaEntries( + hurricanes, + this.lastSentSnapshotByHurricaneId, + { + selectId: (hurricane) => hurricane.id, + toSnapshot: (hurricane) => toHurricaneSyncSnapshot(hurricane), + isSameSnapshot: (left, right) => + isSameHurricaneSyncSnapshot(left, right), + }, + ).map((entry) => { + const { item, snapshot } = entry; + return { + id: item.id, + x: snapshot.x, + y: snapshot.y, + radius: snapshot.radius, + rotationRad: snapshot.rotationRad, + }; + }); + } + + /** 現在状態を量子化して送信済み状態へ反映する */ + private buildSnapshotPayloadAndCommit( + hurricanes: HurricaneState[], + ): HurricaneStatePayload[] { + return hurricanes.map((hurricane) => { + const snapshot = toHurricaneSyncSnapshot(hurricane); + this.lastSentSnapshotByHurricaneId.set(hurricane.id, snapshot); + + return { + id: hurricane.id, + x: snapshot.x, + y: snapshot.y, + radius: snapshot.radius, + rotationRad: snapshot.rotationRad, + }; + }); + } +} diff --git a/apps/server/src/domains/game/loop/hurricane/hurricaneTypes.ts b/apps/server/src/domains/game/loop/hurricane/hurricaneTypes.ts new file mode 100644 index 0000000..329741f --- /dev/null +++ b/apps/server/src/domains/game/loop/hurricane/hurricaneTypes.ts @@ -0,0 +1,36 @@ +/** + * hurricaneTypes + * ハリケーン処理で共有する型を定義する + */ +import type { HurricaneStatePayload } from "@repo/shared"; + +/** ハリケーンの内部状態 */ +export type HurricaneState = { + id: string; + x: number; + y: number; + vx: number; + vy: number; + radius: number; + rotationRad: number; +}; + +/** マップ境界サイズ */ +export type MapGridSize = { + gridCols: number; + gridRows: number; +}; + +/** 差分判定用の量子化スナップショット */ +export type HurricaneSyncSnapshot = { + x: number; + y: number; + radius: number; + rotationRad: number; +}; + +/** 1ティック分のハリケーン同期出力 */ +export type HurricaneSyncOutputs = { + snapshotUpdates: HurricaneStatePayload[]; + deltaUpdates: HurricaneStatePayload[]; +};