diff --git a/apps/client/src/network/handlers/GameHandler.ts b/apps/client/src/network/handlers/GameHandler.ts index 3d0bd9c..eff8959 100644 --- a/apps/client/src/network/handlers/GameHandler.ts +++ b/apps/client/src/network/handlers/GameHandler.ts @@ -12,6 +12,7 @@ ClientToServerEventPayloadMap, ServerToClientEventPayloadMap, CurrentPlayersPayload, + CurrentHurricanesPayload, GameResultPayload, GameStartPayload, HurricaneHitPayload, @@ -46,6 +47,12 @@ offUpdateMapCells: ( callback: (updates: UpdateMapCellsPayload) => void, ) => void; + onCurrentHurricanes: ( + callback: (payload: CurrentHurricanesPayload) => void, + ) => void; + offCurrentHurricanes: ( + callback: (payload: CurrentHurricanesPayload) => void, + ) => void; onUpdateHurricanes: ( callback: (payload: UpdateHurricanesPayload) => void, ) => void; @@ -124,6 +131,9 @@ const updateMapCellsSubscription = createSubscriptionPair( protocol.SocketEvents.UPDATE_MAP_CELLS, ); + const currentHurricanesSubscription = createSubscriptionPair( + protocol.SocketEvents.CURRENT_HURRICANES, + ); const updateHurricanesSubscription = createSubscriptionPair( protocol.SocketEvents.UPDATE_HURRICANES, ); @@ -187,6 +197,12 @@ offUpdateMapCells: (callback) => { updateMapCellsSubscription.off(callback); }, + onCurrentHurricanes: (callback) => { + currentHurricanesSubscription.on(callback); + }, + offCurrentHurricanes: (callback) => { + currentHurricanesSubscription.off(callback); + }, onUpdateHurricanes: (callback) => { updateHurricanesSubscription.on(callback); }, diff --git a/apps/client/src/scenes/game/application/network/NetworkSubscriptions.ts b/apps/client/src/scenes/game/application/network/NetworkSubscriptions.ts index 5334686..47fe065 100644 --- a/apps/client/src/scenes/game/application/network/NetworkSubscriptions.ts +++ b/apps/client/src/scenes/game/application/network/NetworkSubscriptions.ts @@ -8,6 +8,7 @@ BombPlacedAckPayload, BombPlacedPayload, CurrentPlayersPayload, + CurrentHurricanesPayload, GameStartPayload, HurricaneHitPayload, NewPlayerPayload, @@ -33,6 +34,7 @@ updatePlayers: SocketSubscription; removePlayer: SocketSubscription; updateMapCells: SocketSubscription; + currentHurricanes: SocketSubscription; updateHurricanes: SocketSubscription; gameEnd: SocketSubscription; bombPlaced: SocketSubscription; @@ -50,6 +52,7 @@ onUpdatePlayers: (payload: UpdatePlayersPayload) => void; onRemovePlayer: (payload: RemovePlayerPayload) => void; onUpdateMapCells: (payload: UpdateMapCellsPayload) => void; + onCurrentHurricanes: (payload: CurrentHurricanesPayload) => void; onUpdateHurricanes: (payload: UpdateHurricanesPayload) => void; onGameEnd: () => void; onBombPlaced: (payload: BombPlacedPayload) => void; @@ -113,6 +116,15 @@ }), }, { + key: "currentHurricanes", + create: (handlers) => ({ + bind: () => + socketManager.game.onCurrentHurricanes(handlers.onCurrentHurricanes), + unbind: () => + socketManager.game.offCurrentHurricanes(handlers.onCurrentHurricanes), + }), + }, + { key: "updateHurricanes", create: (handlers) => ({ bind: () => diff --git a/apps/client/src/scenes/game/application/network/handlers/GameNetworkStateApplier.ts b/apps/client/src/scenes/game/application/network/handlers/GameNetworkStateApplier.ts index 908e72c..17b2e50 100644 --- a/apps/client/src/scenes/game/application/network/handlers/GameNetworkStateApplier.ts +++ b/apps/client/src/scenes/game/application/network/handlers/GameNetworkStateApplier.ts @@ -159,6 +159,9 @@ const updates = domain.game.gridMap.ungroupCellUpdates(payload); this.mapSyncHandler.handleUpdateMapCells(updates); }, + onReceivedCurrentHurricanes: (payload) => { + this.hurricaneSyncHandler.handleUpdateHurricanes(payload); + }, onReceivedUpdateHurricanes: (payload) => { this.hurricaneSyncHandler.handleUpdateHurricanes(payload); }, diff --git a/apps/client/src/scenes/game/application/network/receivers/GameNetworkEventReceiver.ts b/apps/client/src/scenes/game/application/network/receivers/GameNetworkEventReceiver.ts index 8f4d080..563560b 100644 --- a/apps/client/src/scenes/game/application/network/receivers/GameNetworkEventReceiver.ts +++ b/apps/client/src/scenes/game/application/network/receivers/GameNetworkEventReceiver.ts @@ -7,6 +7,7 @@ BombPlacedAckPayload, BombPlacedPayload, CurrentPlayersPayload, + CurrentHurricanesPayload, GameStartPayload, HurricaneHitPayload, NewPlayerPayload, @@ -30,6 +31,7 @@ onReceivedUpdatePlayers: (payload: UpdatePlayersPayload) => void; onReceivedRemovePlayer: (payload: RemovePlayerPayload) => void; onReceivedUpdateMapCells: (payload: UpdateMapCellsPayload) => void; + onReceivedCurrentHurricanes: (payload: CurrentHurricanesPayload) => void; onReceivedUpdateHurricanes: (payload: UpdateHurricanesPayload) => void; onReceivedGameEnd: () => void; onReceivedBombPlaced: (payload: BombPlacedPayload) => void; @@ -52,6 +54,7 @@ onUpdatePlayers: handlers.onReceivedUpdatePlayers, onRemovePlayer: handlers.onReceivedRemovePlayer, onUpdateMapCells: handlers.onReceivedUpdateMapCells, + onCurrentHurricanes: handlers.onReceivedCurrentHurricanes, onUpdateHurricanes: handlers.onReceivedUpdateHurricanes, onGameEnd: handlers.onReceivedGameEnd, onBombPlaced: handlers.onReceivedBombPlaced, diff --git a/apps/client/src/scenes/game/input/joystick/hooks/useJoystickController.ts b/apps/client/src/scenes/game/input/joystick/hooks/useJoystickController.ts index 36664ac..d97b1d1 100644 --- a/apps/client/src/scenes/game/input/joystick/hooks/useJoystickController.ts +++ b/apps/client/src/scenes/game/input/joystick/hooks/useJoystickController.ts @@ -15,35 +15,6 @@ } from "../common"; import { useJoystickState } from "./useJoystickState"; -const isJoystickDebugEnabled = (): boolean => { - if (import.meta.env.DEV) { - try { - return window.localStorage.getItem("debug:joystick") !== "0"; - } catch { - return true; - } - } - - try { - return window.localStorage.getItem("debug:joystick") === "1"; - } catch { - return false; - } -}; - -const debugJoystick = (label: string, payload?: unknown): void => { - if (!isJoystickDebugEnabled()) { - return; - } - - if (payload === undefined) { - console.log(`[joystick-controller] ${label}`); - return; - } - - console.log(`[joystick-controller] ${label}`, payload); -}; - /** 入力イベントと通知処理を仲介するフック */ export const useJoystickController = ({ onInput, @@ -53,7 +24,6 @@ const emitInput = useCallback( (normalized: NormalizedInput) => { - debugJoystick("emit", normalized); onInput(normalized.x, normalized.y); }, [onInput], @@ -65,12 +35,6 @@ if (last) { const delta = Math.hypot(normalized.x - last.x, normalized.y - last.y); if (delta < JOYSTICK_MIN_MOVEMENT_DELTA) { - debugJoystick("skip-small-delta", { - normalized, - last, - delta, - threshold: JOYSTICK_MIN_MOVEMENT_DELTA, - }); return; } } diff --git a/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts b/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts index 0ff7535..8a924fe 100644 --- a/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts +++ b/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts @@ -8,6 +8,7 @@ BombPlacedPayload, HurricaneHitPayload, PlayerHitPayload, + CurrentHurricanesPayload, UpdateHurricanesPayload, domain, PlaceBombPayload, @@ -69,6 +70,10 @@ roomId: domain.room.Room["roomId"], cellUpdates: domain.game.gridMap.CellUpdate[], ): void; + publishCurrentHurricanesToRoom( + roomId: domain.room.Room["roomId"], + hurricanes: CurrentHurricanesPayload, + ): void; publishUpdateHurricanesToRoom( roomId: domain.room.Room["roomId"], hurricanes: UpdateHurricanesPayload, @@ -125,6 +130,7 @@ GameOutputPort, | "publishUpdatePlayersToRoom" | "publishMapCellUpdatesToRoom" + | "publishCurrentHurricanesToRoom" | "publishUpdateHurricanesToRoom" | "publishGameEndToRoom" | "publishGameResultToRoom" diff --git a/apps/server/src/domains/game/application/useCases/startGameUseCase.ts b/apps/server/src/domains/game/application/useCases/startGameUseCase.ts index 5301633..048ed13 100644 --- a/apps/server/src/domains/game/application/useCases/startGameUseCase.ts +++ b/apps/server/src/domains/game/application/useCases/startGameUseCase.ts @@ -37,22 +37,74 @@ : never; }; +type TickPublishStep = { + key: "currentHurricanes" | "updateHurricanes" | "player" | "map"; + run: (params: TickUpdatePublishParams) => void; +}; + +const TICK_PUBLISH_STEPS: TickPublishStep[] = [ + { + key: "currentHurricanes", + run: ({ roomId, output, tickData }) => { + if (tickData.hurricaneSync.currentUpdates.length === 0) { + return; + } + + output.publishCurrentHurricanesToRoom( + roomId, + tickData.hurricaneSync.currentUpdates, + ); + }, + }, + { + key: "updateHurricanes", + run: ({ roomId, output, tickData }) => { + if (tickData.hurricaneSync.updateUpdates.length === 0) { + return; + } + + output.publishUpdateHurricanesToRoom( + roomId, + tickData.hurricaneSync.updateUpdates, + ); + }, + }, + { + key: "player", + run: ({ roomId, output, tickData }) => { + if (tickData.playerUpdates.length === 0) { + return; + } + + output.publishUpdatePlayersToRoom(roomId, tickData.playerUpdates); + }, + }, + { + key: "map", + run: ({ roomId, output, tickData }) => { + if (tickData.cellUpdates.length === 0) { + return; + } + + output.publishMapCellUpdatesToRoom(roomId, tickData.cellUpdates); + }, + }, +]; + const publishTickUpdates = ({ roomId, output, tickData, }: TickUpdatePublishParams): void => { - if (tickData.playerUpdates.length > 0) { - output.publishUpdatePlayersToRoom(roomId, tickData.playerUpdates); - } + const params: TickUpdatePublishParams = { + roomId, + output, + tickData, + }; - if (tickData.cellUpdates.length > 0) { - output.publishMapCellUpdatesToRoom(roomId, tickData.cellUpdates); - } - - if (tickData.hurricaneUpdates.length > 0) { - output.publishUpdateHurricanesToRoom(roomId, tickData.hurricaneUpdates); - } + TICK_PUBLISH_STEPS.forEach((step) => { + step.run(params); + }); }; /** ゲームセッション開始とティック通知,終了通知を実行する */ diff --git a/apps/server/src/domains/game/loop/GameLoop.ts b/apps/server/src/domains/game/loop/GameLoop.ts index 3a8ae13..d052cb9 100644 --- a/apps/server/src/domains/game/loop/GameLoop.ts +++ b/apps/server/src/domains/game/loop/GameLoop.ts @@ -173,7 +173,7 @@ const gridColorsSnapshot = this.mapStore.getGridColorsSnapshot(); this.updateBotPlayers(wallClockNowMs, elapsedMs, gridColorsSnapshot); this.detectBotBombHits(elapsedMs, wallClockNowMs); - const tickData = this.buildTickData(); + const tickData = this.buildTickData(elapsedMs); this.callbacks.onTick(tickData); } @@ -259,15 +259,16 @@ this.disconnectedBotControlledPlayerIds.delete(playerId); } - private buildTickData(): domain.game.tick.TickData { + private buildTickData(elapsedMs: number): domain.game.tick.TickData { const activePlayerIds = new Set(); const playerUpdates = this.collectChangedPlayerUpdates(activePlayerIds); this.cleanupInactivePlayerSnapshots(activePlayerIds); + const hurricaneSync = this.hurricaneSystem.consumeSyncOutputs(elapsedMs); return { playerUpdates, cellUpdates: this.mapStore.getAndClearUpdates(), - hurricaneUpdates: this.hurricaneSystem.getUpdatePayload(), + hurricaneSync, }; } diff --git a/apps/server/src/domains/game/loop/HurricaneSystem.ts b/apps/server/src/domains/game/loop/HurricaneSystem.ts index 7c49b1a..681cbf4 100644 --- a/apps/server/src/domains/game/loop/HurricaneSystem.ts +++ b/apps/server/src/domains/game/loop/HurricaneSystem.ts @@ -1,89 +1,38 @@ /** * 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"; +import { + HurricaneHitService, + HurricaneMotionService, + HurricaneSyncService, +} from "./hurricane/index.js"; +import type { + HurricaneState, + HurricaneSyncOutputs, + MapGridSize, +} from "./hurricane/index.js"; -const { checkBombHit } = domain.game.bombHit; - -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 - ); -}; +/** 1ティック分のハリケーン同期出力 */ +export type { HurricaneSyncOutputs } from "./hurricane/index.js"; /** ハリケーン状態の生成更新と被弾判定を管理する */ export class HurricaneSystem { private readonly mapSize: MapGridSize; private hasSpawned = false; 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(); } /** 残り時間しきい値到達時にハリケーンを一度だけ生成する */ @@ -99,66 +48,21 @@ } this.hasSpawned = true; + this.syncService.markInitialSyncPending(); this.hurricanes = Array.from( { length: config.GAME_CONFIG.HURRICANE_COUNT }, (_, index) => this.createHurricane(index), ); } - /** ハリケーンを直線移動させ,境界で反射させる */ - 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; - } - }); + /** 1ティック分のハリケーン同期出力をまとめて返す */ + public consumeSyncOutputs(elapsedMs: number): HurricaneSyncOutputs { + return this.syncService.consumeSyncOutputs(elapsedMs, this.hurricanes); } - /** 同期配信用のハリケーン状態配列を返す */ - public getUpdatePayload(): 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, - }; - }); + /** ハリケーンを直線移動させ,境界で反射させる */ + public update(deltaSec: number): void { + this.motionService.update(this.hurricanes, deltaSec); } /** クールダウン付きで被弾プレイヤーID配列を返す */ @@ -166,55 +70,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.hurricanes = []; - this.lastHitAtMsByPlayerId.clear(); - this.lastSentSnapshotByHurricaneId.clear(); + this.syncService.clear(); + this.hitService.clear(); } /** ハリケーン初期状態を生成する */ 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..554e5a2 --- /dev/null +++ b/apps/server/src/domains/game/loop/hurricane/HurricaneSyncService.ts @@ -0,0 +1,142 @@ +/** + * 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 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 readonly lastSentSnapshotByHurricaneId = new Map< + string, + HurricaneSyncSnapshot + >(); + + /** 初回全量同期を次回生成するようマークする */ + public markInitialSyncPending(): void { + this.hasInitialSyncPending = true; + } + + /** 1ティック分の current/update 同期配列を返す */ + public consumeSyncOutputs( + _elapsedMs: number, + hurricanes: HurricaneState[], + ): HurricaneSyncOutputs { + const currentUpdates = this.consumeCurrentUpdates(hurricanes); + const updateUpdates = this.consumeUpdateUpdates(hurricanes); + + return { + currentUpdates, + updateUpdates, + }; + } + + /** 同期状態を初期化する */ + public clear(): void { + this.hasInitialSyncPending = false; + this.lastSentSnapshotByHurricaneId.clear(); + } + + /** current-hurricanes 用の初回全量同期を返す */ + private consumeCurrentUpdates(hurricanes: HurricaneState[]): HurricaneStatePayload[] { + if (hurricanes.length === 0) { + return []; + } + + if (this.hasInitialSyncPending) { + this.hasInitialSyncPending = false; + return this.buildSnapshotPayloadAndCommit(hurricanes); + } + + return []; + } + + /** 差分同期配信用のハリケーン状態配列を返す */ + private consumeUpdateUpdates( + 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..fb99ca4 --- /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 = { + currentUpdates: HurricaneStatePayload[]; + updateUpdates: HurricaneStatePayload[]; +}; diff --git a/apps/server/src/domains/game/loop/hurricane/index.ts b/apps/server/src/domains/game/loop/hurricane/index.ts new file mode 100644 index 0000000..231b825 --- /dev/null +++ b/apps/server/src/domains/game/loop/hurricane/index.ts @@ -0,0 +1,13 @@ +/** + * hurricane index + * ハリケーン関連サービスと型の再公開をまとめる + */ +export { HurricaneMotionService } from "./HurricaneMotionService.js"; +export { HurricaneSyncService } from "./HurricaneSyncService.js"; +export { HurricaneHitService } from "./HurricaneHitService.js"; +export type { + HurricaneState, + HurricaneSyncOutputs, + HurricaneSyncSnapshot, + MapGridSize, +} from "./hurricaneTypes.js"; diff --git a/apps/server/src/network/adapters/socketEmitters.ts b/apps/server/src/network/adapters/socketEmitters.ts index 4fb57bb..dfe1fc3 100644 --- a/apps/server/src/network/adapters/socketEmitters.ts +++ b/apps/server/src/network/adapters/socketEmitters.ts @@ -15,11 +15,6 @@ (roomId: string, event: TEvent, payload: ServerToClientPayloadOf): void; }; -type EmitToRoomVolatile = { - (roomId: string, event: TEvent): void; - (roomId: string, event: TEvent, payload: ServerToClientPayloadOf): void; -}; - type EmitToRoomExceptSocket = { (roomId: string, excludedSocketId: string, event: TEvent): void; (roomId: string, excludedSocketId: string, event: TEvent, payload: ServerToClientPayloadOf): void; @@ -61,17 +56,6 @@ }; }; -/** ルーム単位の volatile 送信関数を生成する */ -export const createEmitToRoomVolatile = (io: Server): EmitToRoomVolatile => { - return (roomId: string, event: SocketEventName, payload?: unknown) => { - emitWithOptionalPayload( - (eventName, body) => io.to(roomId).volatile.emit(eventName, body), - event, - payload, - ); - }; -}; - /** ルーム送信時に特定ソケットを除外する送信関数を生成する */ export const createEmitToRoomExceptSocket = (io: Server): EmitToRoomExceptSocket => { return (roomId: string, excludedSocketId: string, event: SocketEventName, payload?: unknown) => { diff --git a/apps/server/src/network/handlers/CommonHandler.ts b/apps/server/src/network/handlers/CommonHandler.ts index 2636c34..7613892 100644 --- a/apps/server/src/network/handlers/CommonHandler.ts +++ b/apps/server/src/network/handlers/CommonHandler.ts @@ -6,7 +6,6 @@ import { createEmitToAll, createEmitToRoom, - createEmitToRoomVolatile, createEmitToRoomExceptSocket, createEmitToSocket, createEmitToSocketById, @@ -21,15 +20,9 @@ emitToSocketById: ReturnType; }; -/** 鮮度を重視する高頻度送信関数群 */ -export type RealtimeEmitters = { - emitToRoom: ReturnType; -}; - /** ハンドラで共通利用する送信コンテキスト */ export type CommonHandlerContext = { reliable: ReliableEmitters; - realtime: RealtimeEmitters; }; /** 送信先別のエミッタをまとめた共通コンテキストを生成する */ @@ -45,12 +38,7 @@ emitToSocketById: createEmitToSocketById(io), }; - const realtime: RealtimeEmitters = { - emitToRoom: createEmitToRoomVolatile(io), - }; - return { reliable, - realtime, }; }; diff --git a/apps/server/src/network/handlers/game/createGameOutputAdapter.ts b/apps/server/src/network/handlers/game/createGameOutputAdapter.ts index c1a3d03..dd718e4 100644 --- a/apps/server/src/network/handlers/game/createGameOutputAdapter.ts +++ b/apps/server/src/network/handlers/game/createGameOutputAdapter.ts @@ -13,6 +13,7 @@ HurricaneHitPayload, PlayerHitPayload, PongPayload, + CurrentHurricanesPayload, CurrentPlayersPayload, RemovePlayerPayload, UpdateHurricanesPayload, @@ -34,6 +35,16 @@ type RoomId = domain.room.Room["roomId"]; +type ReliableRoomEvent = + | typeof protocol.SocketEvents.UPDATE_PLAYERS + | typeof protocol.SocketEvents.UPDATE_MAP_CELLS + | typeof protocol.SocketEvents.CURRENT_HURRICANES + | typeof protocol.SocketEvents.UPDATE_HURRICANES + | typeof protocol.SocketEvents.GAME_END + | typeof protocol.SocketEvents.GAME_RESULT + | typeof protocol.SocketEvents.GAME_START + | typeof protocol.SocketEvents.HURRICANE_HIT; + /** ゲーム出力アダプターのインターフェース */ export type GameOutputAdapter = Omit< GameOutputPort, @@ -52,9 +63,22 @@ export const createGameOutputAdapter = ( common: CommonHandlerContext, ): GameOutputAdapter => { - const { reliable, realtime } = common; + const { reliable } = common; const realtimeRoomSyncState = createRealtimeRoomSyncStateStore(); + const emitReliableToRoom = ( + roomId: RoomId, + event: ReliableRoomEvent, + payload?: unknown, + ): void => { + if (payload === undefined) { + reliable.emitToRoom(roomId, event); + return; + } + + reliable.emitToRoom(roomId, event, payload as never); + }; + return { publishPongToSocket: (payload: PongPayload) => { reliable.emitToSocket(protocol.SocketEvents.PONG, payload); @@ -73,43 +97,37 @@ return; } - realtime.emitToRoom( - roomId, - protocol.SocketEvents.UPDATE_PLAYERS, - changedPlayers, - ); + emitReliableToRoom(roomId, protocol.SocketEvents.UPDATE_PLAYERS, changedPlayers); }, publishMapCellUpdatesToRoom: ( roomId: RoomId, cellUpdates: domainNs.game.gridMap.CellUpdate[], ) => { const grouped = domainNs.game.gridMap.groupCellUpdates(cellUpdates); - reliable.emitToRoom( - roomId, - protocol.SocketEvents.UPDATE_MAP_CELLS, - grouped, - ); + emitReliableToRoom(roomId, protocol.SocketEvents.UPDATE_MAP_CELLS, grouped); + }, + publishCurrentHurricanesToRoom: ( + roomId: RoomId, + hurricanes: CurrentHurricanesPayload, + ) => { + emitReliableToRoom(roomId, protocol.SocketEvents.CURRENT_HURRICANES, hurricanes); }, publishUpdateHurricanesToRoom: ( roomId: RoomId, hurricanes: UpdateHurricanesPayload, ) => { - realtime.emitToRoom( - roomId, - protocol.SocketEvents.UPDATE_HURRICANES, - hurricanes, - ); + emitReliableToRoom(roomId, protocol.SocketEvents.UPDATE_HURRICANES, hurricanes); }, publishGameEndToRoom: (roomId: RoomId) => { realtimeRoomSyncState.resetRoom(roomId); - reliable.emitToRoom(roomId, protocol.SocketEvents.GAME_END); + emitReliableToRoom(roomId, protocol.SocketEvents.GAME_END); }, publishGameResultToRoom: (roomId: RoomId, payload: GameResultPayload) => { - reliable.emitToRoom(roomId, protocol.SocketEvents.GAME_RESULT, payload); + emitReliableToRoom(roomId, protocol.SocketEvents.GAME_RESULT, payload); }, publishGameStartToRoom: (roomId: RoomId, payload: GameStartPayload) => { realtimeRoomSyncState.resetRoom(roomId); - reliable.emitToRoom(roomId, protocol.SocketEvents.GAME_START, payload); + emitReliableToRoom(roomId, protocol.SocketEvents.GAME_START, payload); }, publishCurrentPlayersToSocket: (players: CurrentPlayersPayload) => { reliable.emitToSocket(protocol.SocketEvents.CURRENT_PLAYERS, players); @@ -163,7 +181,7 @@ roomId: RoomId, payload: HurricaneHitPayload, ) => { - reliable.emitToRoom(roomId, protocol.SocketEvents.HURRICANE_HIT, payload); + emitReliableToRoom(roomId, protocol.SocketEvents.HURRICANE_HIT, payload); }, }; }; diff --git a/packages/shared/src/domains/game/tick/index.ts b/packages/shared/src/domains/game/tick/index.ts index 2b0417e..8da92c2 100644 --- a/packages/shared/src/domains/game/tick/index.ts +++ b/packages/shared/src/domains/game/tick/index.ts @@ -5,4 +5,4 @@ */ /** tick同期関連の型を再公開する */ -export type { PlayerPositionUpdate, TickData } from "./tick.type"; +export type { HurricaneSyncData, PlayerPositionUpdate, TickData } from "./tick.type"; diff --git a/packages/shared/src/domains/game/tick/tick.type.ts b/packages/shared/src/domains/game/tick/tick.type.ts index c1ab933..100342c 100644 --- a/packages/shared/src/domains/game/tick/tick.type.ts +++ b/packages/shared/src/domains/game/tick/tick.type.ts @@ -9,9 +9,17 @@ /** 1ティックで配信するプレイヤー座標差分 */ export type PlayerPositionUpdate = Pick; +/** 1ティック分のハリケーン同期データ */ +export type HurricaneSyncData = { + /** 初回同期や定期再同期で配信する全量更新 */ + currentUpdates: HurricaneStatePayload[]; + /** current 同期間で配信する差分更新 */ + updateUpdates: HurricaneStatePayload[]; +}; + /** 1ティック分のプレイヤー差分更新とマップ差分を表す共有データ */ export interface TickData { playerUpdates: PlayerPositionUpdate[]; cellUpdates: CellUpdate[]; - hurricaneUpdates: HurricaneStatePayload[]; + hurricaneSync: HurricaneSyncData; } diff --git a/packages/shared/src/protocol/eventPayloads.ts b/packages/shared/src/protocol/eventPayloads.ts index dca324e..e74b3d3 100644 --- a/packages/shared/src/protocol/eventPayloads.ts +++ b/packages/shared/src/protocol/eventPayloads.ts @@ -26,6 +26,7 @@ HurricaneStatePayload, HurricaneSnapshotPayload, HurricaneDeltaPayload, + CurrentHurricanesPayload, UpdateHurricanesPayload, NewPlayerPayload, RemovePlayerPayload, diff --git a/packages/shared/src/protocol/maps/gameEventPayloadMap.ts b/packages/shared/src/protocol/maps/gameEventPayloadMap.ts index 41e7e2d..ed21e8b 100644 --- a/packages/shared/src/protocol/maps/gameEventPayloadMap.ts +++ b/packages/shared/src/protocol/maps/gameEventPayloadMap.ts @@ -10,6 +10,7 @@ BombPlacedAckPayload, BombPlacedPayload, CurrentPlayersPayload, + CurrentHurricanesPayload, GameResultPayload, GameStartPayload, HurricaneHitPayload, @@ -42,6 +43,7 @@ [SocketEvents.UPDATE_PLAYERS_SYNC]: UpdatePlayersPayload; [SocketEvents.REMOVE_PLAYER_SYNC]: RemovePlayerPayload; [SocketEvents.UPDATE_MAP_CELLS_SYNC]: UpdateMapCellsPayload; + [SocketEvents.CURRENT_HURRICANES_SYNC]: CurrentHurricanesPayload; [SocketEvents.UPDATE_HURRICANES_SYNC]: UpdateHurricanesPayload; [SocketEvents.BOMB_PLACED]: BombPlacedPayload; [SocketEvents.BOMB_PLACED_ACK]: BombPlacedAckPayload; diff --git a/packages/shared/src/protocol/payloads/gamePayloads.ts b/packages/shared/src/protocol/payloads/gamePayloads.ts index 76a60ee..a7f2cc9 100644 --- a/packages/shared/src/protocol/payloads/gamePayloads.ts +++ b/packages/shared/src/protocol/payloads/gamePayloads.ts @@ -87,6 +87,9 @@ /** update-hurricanes イベントで送受信するハリケーン差分配列 */ export type HurricaneDeltaPayload = HurricaneStatePayload[]; +/** current-hurricanes イベントで送受信するハリケーン全量配列 */ +export type CurrentHurricanesPayload = HurricaneSnapshotPayload; + /** update-hurricanes イベントで送受信するハリケーン差分配列(互換名) */ export type UpdateHurricanesPayload = HurricaneDeltaPayload; diff --git a/packages/shared/src/protocol/socketEvents.ts b/packages/shared/src/protocol/socketEvents.ts index 0648621..242ae61 100644 --- a/packages/shared/src/protocol/socketEvents.ts +++ b/packages/shared/src/protocol/socketEvents.ts @@ -24,6 +24,7 @@ UPDATE_PLAYERS_SYNC: "update-players", REMOVE_PLAYER_SYNC: "remove-player", UPDATE_MAP_CELLS_SYNC: "update-map-cells", + CURRENT_HURRICANES_SYNC: "current-hurricanes", UPDATE_HURRICANES_SYNC: "update-hurricanes", // 互換維持のため残す旧キー名 @@ -35,6 +36,7 @@ PLACE_BOMB: "place-bomb", BOMB_HIT_REPORT: "bomb-hit-report", UPDATE_MAP_CELLS: "update-map-cells", + CURRENT_HURRICANES: "current-hurricanes", UPDATE_HURRICANES: "update-hurricanes", BOMB_PLACED: "bomb-placed", BOMB_PLACED_ACK: "bomb-placed-ack",