diff --git a/apps/client/public/hurricane.svg b/apps/client/public/hurricane.svg new file mode 100644 index 0000000..b4affa9 --- /dev/null +++ b/apps/client/public/hurricane.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/apps/client/src/network/handlers/GameHandler.ts b/apps/client/src/network/handlers/GameHandler.ts index ea957ad..3d0bd9c 100644 --- a/apps/client/src/network/handlers/GameHandler.ts +++ b/apps/client/src/network/handlers/GameHandler.ts @@ -14,11 +14,13 @@ CurrentPlayersPayload, GameResultPayload, GameStartPayload, + HurricaneHitPayload, MovePayload, NewPlayerPayload, PlaceBombPayload, PlayerHitPayload, RemovePlayerPayload, + UpdateHurricanesPayload, UpdateMapCellsPayload, UpdatePlayersPayload, } from "@repo/shared"; @@ -26,16 +28,30 @@ /** ゲームシーンが利用するソケット操作の契約 */ export type GameHandler = { - onCurrentPlayers: (callback: (players: CurrentPlayersPayload) => void) => void; - offCurrentPlayers: (callback: (players: CurrentPlayersPayload) => void) => void; + onCurrentPlayers: ( + callback: (players: CurrentPlayersPayload) => void, + ) => void; + offCurrentPlayers: ( + callback: (players: CurrentPlayersPayload) => void, + ) => void; onNewPlayer: (callback: (player: NewPlayerPayload) => void) => void; offNewPlayer: (callback: (player: NewPlayerPayload) => void) => void; onUpdatePlayers: (callback: (players: UpdatePlayersPayload) => void) => void; offUpdatePlayers: (callback: (players: UpdatePlayersPayload) => void) => void; onRemovePlayer: (callback: (id: RemovePlayerPayload) => void) => void; offRemovePlayer: (callback: (id: RemovePlayerPayload) => void) => void; - onUpdateMapCells: (callback: (updates: UpdateMapCellsPayload) => void) => void; - offUpdateMapCells: (callback: (updates: UpdateMapCellsPayload) => void) => void; + onUpdateMapCells: ( + callback: (updates: UpdateMapCellsPayload) => void, + ) => void; + offUpdateMapCells: ( + callback: (updates: UpdateMapCellsPayload) => void, + ) => void; + onUpdateHurricanes: ( + callback: (payload: UpdateHurricanesPayload) => void, + ) => void; + offUpdateHurricanes: ( + callback: (payload: UpdateHurricanesPayload) => void, + ) => void; onGameStart: (callback: (data: GameStartPayload) => void) => void; onceGameStart: (callback: (data: GameStartPayload) => void) => void; offGameStart: (callback: (data: GameStartPayload) => void) => void; @@ -49,6 +65,8 @@ offBombPlacedAck: (callback: (payload: BombPlacedAckPayload) => void) => void; onPlayerHit: (callback: (payload: PlayerHitPayload) => void) => void; offPlayerHit: (callback: (payload: PlayerHitPayload) => void) => void; + onHurricaneHit: (callback: (payload: HurricaneHitPayload) => void) => void; + offHurricaneHit: (callback: (payload: HurricaneHitPayload) => void) => void; sendMove: (x: number, y: number) => void; sendPlaceBomb: (payload: PlaceBombPayload) => void; sendBombHitReport: (payload: BombHitReportPayload) => void; @@ -57,16 +75,23 @@ /** ソケットインスタンスからゲーム向けハンドラを生成する */ export const createGameHandler = (socket: Socket): GameHandler => { - const { onEvent, onceEvent, offEvent, emitEvent } = createClientSocketEventBridge(socket); + const { onEvent, onceEvent, offEvent, emitEvent } = + createClientSocketEventBridge(socket); type ReceiveEventName = Extract; type SendEventName = Extract; - const createSubscriptionPair = (event: TEvent) => { + const createSubscriptionPair = ( + event: TEvent, + ) => { return { - on: (callback: (payload: ServerToClientEventPayloadMap[TEvent]) => void) => { + on: ( + callback: (payload: ServerToClientEventPayloadMap[TEvent]) => void, + ) => { onEvent(event, callback); }, - off: (callback: (payload: ServerToClientEventPayloadMap[TEvent]) => void) => { + off: ( + callback: (payload: ServerToClientEventPayloadMap[TEvent]) => void, + ) => { offEvent(event, callback); }, }; @@ -85,41 +110,51 @@ }; const currentPlayersSubscription = createSubscriptionPair( - protocol.SocketEvents.CURRENT_PLAYERS + protocol.SocketEvents.CURRENT_PLAYERS, ); const newPlayerSubscription = createSubscriptionPair( - protocol.SocketEvents.NEW_PLAYER + protocol.SocketEvents.NEW_PLAYER, ); const updatePlayersSubscription = createSubscriptionPair( - protocol.SocketEvents.UPDATE_PLAYERS + protocol.SocketEvents.UPDATE_PLAYERS, ); const removePlayerSubscription = createSubscriptionPair( - protocol.SocketEvents.REMOVE_PLAYER + protocol.SocketEvents.REMOVE_PLAYER, ); const updateMapCellsSubscription = createSubscriptionPair( - protocol.SocketEvents.UPDATE_MAP_CELLS + protocol.SocketEvents.UPDATE_MAP_CELLS, + ); + const updateHurricanesSubscription = createSubscriptionPair( + protocol.SocketEvents.UPDATE_HURRICANES, ); const gameEndSubscription = createSubscriptionPair( - protocol.SocketEvents.GAME_END + protocol.SocketEvents.GAME_END, ); const gameResultSubscription = createSubscriptionPair( - protocol.SocketEvents.GAME_RESULT + protocol.SocketEvents.GAME_RESULT, ); const bombPlacedSubscription = createSubscriptionPair( - protocol.SocketEvents.BOMB_PLACED + protocol.SocketEvents.BOMB_PLACED, ); const bombPlacedAckSubscription = createSubscriptionPair( - protocol.SocketEvents.BOMB_PLACED_ACK + protocol.SocketEvents.BOMB_PLACED_ACK, ); const playerHitSubscription = createSubscriptionPair( - protocol.SocketEvents.PLAYER_HIT + protocol.SocketEvents.PLAYER_HIT, + ); + const hurricaneHitSubscription = createSubscriptionPair( + protocol.SocketEvents.HURRICANE_HIT, ); const sendMovePayload = createPayloadSender(protocol.SocketEvents.MOVE); - const sendPlaceBombPayload = createPayloadSender(protocol.SocketEvents.PLACE_BOMB); - const sendBombHitReportPayload = createPayloadSender( - protocol.SocketEvents.BOMB_HIT_REPORT + const sendPlaceBombPayload = createPayloadSender( + protocol.SocketEvents.PLACE_BOMB, ); - const sendReadyForGame = createVoidSender(protocol.SocketEvents.READY_FOR_GAME); + const sendBombHitReportPayload = createPayloadSender( + protocol.SocketEvents.BOMB_HIT_REPORT, + ); + const sendReadyForGame = createVoidSender( + protocol.SocketEvents.READY_FOR_GAME, + ); return { onCurrentPlayers: (callback) => { @@ -152,6 +187,12 @@ offUpdateMapCells: (callback) => { updateMapCellsSubscription.off(callback); }, + onUpdateHurricanes: (callback) => { + updateHurricanesSubscription.on(callback); + }, + offUpdateHurricanes: (callback) => { + updateHurricanesSubscription.off(callback); + }, onGameStart: (callback) => { onEvent(protocol.SocketEvents.GAME_START, callback); }, @@ -191,6 +232,12 @@ offPlayerHit: (callback) => { playerHitSubscription.off(callback); }, + onHurricaneHit: (callback) => { + hurricaneHitSubscription.on(callback); + }, + offHurricaneHit: (callback) => { + hurricaneHitSubscription.off(callback); + }, sendMove: (x, y) => { const payload: MovePayload = { x, y }; sendMovePayload(payload); @@ -203,6 +250,6 @@ }, readyForGame: () => { sendReadyForGame(); - } + }, }; }; diff --git a/apps/client/src/scenes/game/GameManager.ts b/apps/client/src/scenes/game/GameManager.ts index ac20562..12d3d86 100644 --- a/apps/client/src/scenes/game/GameManager.ts +++ b/apps/client/src/scenes/game/GameManager.ts @@ -22,11 +22,12 @@ type MoveSender, } from "./application/network/PlayerMoveSender"; import type { GamePlayers } from "./application/game.types"; +import type { HurricaneHitPayload } from "@repo/shared"; import { GameUiStateSyncService, type GameUiState, } from "./application/ui/GameUiStateSyncService"; -import { loadRespawnEffectTexture } from "./entities/player/RespawnEffectTextureCache"; +import { preloadGameStartAssets } from "./application/assets/GameAssetPreloader"; /** GameManager の依存注入オプション型 */ export type GameManagerDependencies = { @@ -104,9 +105,7 @@ this.gameEventFacade = new GameEventFacade({ onGameStarted: (startTime) => { // ゲーム開始カウントダウン中に先読みして初回被弾時の負荷を抑える - void loadRespawnEffectTexture( - `${import.meta.env.BASE_URL}bakuhatueffe.svg`, - ); + preloadGameStartAssets(); this.sessionFacade.setGameStart(startTime); this.uiStateSyncService.emitIfChanged(); }, @@ -147,6 +146,9 @@ onRemotePlayerHit: (payload) => { this.combatFacade.handleNetworkPlayerHit(payload); }, + onRemoteHurricaneHit: (payload: HurricaneHitPayload) => { + this.combatFacade.handleNetworkHurricaneHit(payload); + }, onBombExploded: (payload) => { this.combatFacade.handleBombExploded(payload); }, diff --git a/apps/client/src/scenes/game/GameView.tsx b/apps/client/src/scenes/game/GameView.tsx index 1d67d7a..c78b209 100644 --- a/apps/client/src/scenes/game/GameView.tsx +++ b/apps/client/src/scenes/game/GameView.tsx @@ -7,6 +7,7 @@ import { GAME_VIEW_BOMB_HIT_DEBUG_STYLE, GAME_VIEW_FEVER_TEXT_STYLE, + GAME_VIEW_HURRICANE_WARNING_STYLE, GAME_VIEW_PAINT_RATE_ITEM_STYLE, GAME_VIEW_PAINT_RATE_PANEL_STYLE, GAME_VIEW_PAINT_RATE_SQUARE_STYLE, @@ -110,7 +111,7 @@ return (
- + {/* タイマーUIの表示 */}
HP: {heartGauge}
@@ -123,6 +124,14 @@
!Fever Tieme!
)} + {config.GAME_CONFIG.HURRICANE_ENABLED && + remainingSeconds === + config.GAME_CONFIG.HURRICANE_SPAWN_REMAINING_SEC && ( +
+ WARNING:ハリケーン出現 +
+ )} + {startCountdownText && (
{startCountdownText}
)} diff --git a/apps/client/src/scenes/game/application/GameNetworkSync.ts b/apps/client/src/scenes/game/application/GameNetworkSync.ts index 14839dd..864d6c5 100644 --- a/apps/client/src/scenes/game/application/GameNetworkSync.ts +++ b/apps/client/src/scenes/game/application/GameNetworkSync.ts @@ -7,6 +7,7 @@ import type { BombPlacedAckPayload, BombPlacedPayload, + HurricaneHitPayload, PlayerHitPayload, } from "@repo/shared"; import { AppearanceResolver } from "./AppearanceResolver"; @@ -28,6 +29,7 @@ onRemoteBombPlaced: (payload: BombPlacedPayload) => void; onBombPlacementAcknowledged: (payload: BombPlacedAckPayload) => void; onRemotePlayerHit: (payload: PlayerHitPayload) => void; + onRemoteHurricaneHit: (payload: HurricaneHitPayload) => void; }; /** ゲーム中のネットワークイベント購読と同期処理を管理する */ @@ -54,6 +56,7 @@ onRemoteBombPlaced, onBombPlacementAcknowledged, onRemotePlayerHit, + onRemoteHurricaneHit, }: GameNetworkSyncOptions) { this.stateApplier = new GameNetworkStateApplier({ worldContainer, @@ -66,6 +69,7 @@ onRemoteBombPlaced, onBombPlacementAcknowledged, onRemotePlayerHit, + onRemoteHurricaneHit, onDebugLog: this.debugLog, }); @@ -80,5 +84,6 @@ public unbind() { this.eventReceiver.unbind(); + this.stateApplier.dispose(); } } diff --git a/apps/client/src/scenes/game/application/assets/GameAssetPreloader.ts b/apps/client/src/scenes/game/application/assets/GameAssetPreloader.ts new file mode 100644 index 0000000..01f7f37 --- /dev/null +++ b/apps/client/src/scenes/game/application/assets/GameAssetPreloader.ts @@ -0,0 +1,13 @@ +/** + * GameAssetPreloader + * ゲーム開始前に必要な画像アセットの先読みを集約する + * 開始直後の描画負荷スパイクを抑える + */ +import { loadHurricaneTexture } from "@client/scenes/game/entities/hurricane/HurricaneTextureCache"; +import { loadRespawnEffectTexture } from "@client/scenes/game/entities/player/RespawnEffectTextureCache"; + +/** ゲーム開始前に必要なアセットを先読みする */ +export const preloadGameStartAssets = (): void => { + void loadRespawnEffectTexture(`${import.meta.env.BASE_URL}bakuhatueffe.svg`); + void loadHurricaneTexture(`${import.meta.env.BASE_URL}hurricane.svg`); +}; diff --git a/apps/client/src/scenes/game/application/combat/CombatLifecycleFacade.ts b/apps/client/src/scenes/game/application/combat/CombatLifecycleFacade.ts index f26326c..4b0c005 100644 --- a/apps/client/src/scenes/game/application/combat/CombatLifecycleFacade.ts +++ b/apps/client/src/scenes/game/application/combat/CombatLifecycleFacade.ts @@ -4,7 +4,7 @@ * ゲームマネージャーから被弾関連の責務を分離する */ import { config } from "@client/config"; -import type { PlayerHitPayload } from "@repo/shared"; +import type { HurricaneHitPayload, PlayerHitPayload } from "@repo/shared"; import type { BombExplodedPayload } from "@client/scenes/game/entities/bomb/BombManager"; import { BombHitOrchestrator } from "@client/scenes/game/application/BombHitOrchestrator"; import { PlayerHitPolicy } from "@client/scenes/game/application/PlayerHitPolicy"; @@ -21,6 +21,8 @@ onLocalBombHitCountChanged: (count: number) => void; }; +type NetworkDamageSource = "bomb" | "hurricane"; + /** 被弾関連ライフサイクルの制御を担当する */ export class CombatLifecycleFacade { private readonly myId: string; @@ -89,19 +91,12 @@ /** ネットワーク被弾通知を適用する */ public handleNetworkPlayerHit(payload: PlayerHitPayload): void { - this.playerHitPolicy.applyPlayerHitEvent(payload); + this.applyNetworkDamage(payload.playerId, "bomb"); + } - if (this.respawnManager.isRespawning(payload.playerId)) return; - - const hitCount = this.respawnManager.incrementHitCount(payload.playerId); - this.playerHitEffectOrchestrator.handleNetworkPlayerHit( - payload.playerId, - this.myId, - ); - - if (hitCount >= config.GAME_CONFIG.PLAYER_RESPAWN_HIT_COUNT) { - this.respawnManager.startSequence(payload.playerId); - } + /** ハリケーン被弾通知を適用する */ + public handleNetworkHurricaneHit(payload: HurricaneHitPayload): void { + this.applyNetworkDamage(payload.playerId, "hurricane"); } /** 管理中リソースを破棄する */ @@ -123,4 +118,47 @@ this.localBombHitCount >= config.GAME_CONFIG.PLAYER_RESPAWN_HIT_COUNT ); } + + /** ネットワーク由来のダメージ適用を統一して実行する */ + private applyNetworkDamage( + targetPlayerId: string, + source: NetworkDamageSource, + ): void { + const isLocalTarget = targetPlayerId === this.myId; + + if (source === "bomb") { + this.playerHitPolicy.applyPlayerHitEvent({ playerId: targetPlayerId }); + } + + if (this.respawnManager.isRespawning(targetPlayerId)) { + return; + } + + const hitCount = this.respawnManager.incrementHitCount(targetPlayerId); + + if (isLocalTarget) { + this.localBombHitCount = hitCount; + this.onLocalBombHitCountChanged(this.localBombHitCount); + this.playerHitEffectOrchestrator.handleLocalBombHit(this.myId); + } else { + this.playerHitEffectOrchestrator.handleNetworkPlayerHit( + targetPlayerId, + this.myId, + ); + } + + if (hitCount >= config.GAME_CONFIG.PLAYER_RESPAWN_HIT_COUNT) { + if (isLocalTarget) { + this.playerHitPolicy.applyLocalHitStun( + config.GAME_CONFIG.PLAYER_RESPAWN_STUN_MS, + ); + } + this.respawnManager.startSequence(targetPlayerId); + return; + } + + if (source === "hurricane" && isLocalTarget) { + this.playerHitPolicy.applyLocalHitStun(); + } + } } diff --git a/apps/client/src/scenes/game/application/network/NetworkSubscriptions.ts b/apps/client/src/scenes/game/application/network/NetworkSubscriptions.ts index 35746cd..87104b5 100644 --- a/apps/client/src/scenes/game/application/network/NetworkSubscriptions.ts +++ b/apps/client/src/scenes/game/application/network/NetworkSubscriptions.ts @@ -9,9 +9,11 @@ BombPlacedPayload, CurrentPlayersPayload, GameStartPayload, + HurricaneHitPayload, NewPlayerPayload, PlayerHitPayload, RemovePlayerPayload, + UpdateHurricanesPayload, UpdateMapCellsPayload, UpdatePlayersPayload, } from "@repo/shared"; @@ -30,10 +32,12 @@ updatePlayers: SocketSubscription; removePlayer: SocketSubscription; updateMapCells: SocketSubscription; + updateHurricanes: SocketSubscription; gameEnd: SocketSubscription; bombPlaced: SocketSubscription; bombPlacedAck: SocketSubscription; playerHit: SocketSubscription; + hurricaneHit: SocketSubscription; }; /** 購読辞書生成に必要なハンドラ群 */ @@ -44,10 +48,12 @@ onUpdatePlayers: (payload: UpdatePlayersPayload) => void; onRemovePlayer: (payload: RemovePlayerPayload) => void; onUpdateMapCells: (payload: UpdateMapCellsPayload) => void; + onUpdateHurricanes: (payload: UpdateHurricanesPayload) => void; onGameEnd: () => void; onBombPlaced: (payload: BombPlacedPayload) => void; onBombPlacedAck: (payload: BombPlacedAckPayload) => void; onPlayerHit: (payload: PlayerHitPayload) => void; + onHurricaneHit: (payload: HurricaneHitPayload) => void; }; type SubscriptionDefinition = { @@ -59,8 +65,10 @@ { key: "currentPlayers", create: (handlers) => ({ - bind: () => socketManager.game.onCurrentPlayers(handlers.onCurrentPlayers), - unbind: () => socketManager.game.offCurrentPlayers(handlers.onCurrentPlayers), + bind: () => + socketManager.game.onCurrentPlayers(handlers.onCurrentPlayers), + unbind: () => + socketManager.game.offCurrentPlayers(handlers.onCurrentPlayers), }), }, { @@ -81,7 +89,8 @@ key: "updatePlayers", create: (handlers) => ({ bind: () => socketManager.game.onUpdatePlayers(handlers.onUpdatePlayers), - unbind: () => socketManager.game.offUpdatePlayers(handlers.onUpdatePlayers), + unbind: () => + socketManager.game.offUpdatePlayers(handlers.onUpdatePlayers), }), }, { @@ -94,8 +103,19 @@ { key: "updateMapCells", create: (handlers) => ({ - bind: () => socketManager.game.onUpdateMapCells(handlers.onUpdateMapCells), - unbind: () => socketManager.game.offUpdateMapCells(handlers.onUpdateMapCells), + bind: () => + socketManager.game.onUpdateMapCells(handlers.onUpdateMapCells), + unbind: () => + socketManager.game.offUpdateMapCells(handlers.onUpdateMapCells), + }), + }, + { + key: "updateHurricanes", + create: (handlers) => ({ + bind: () => + socketManager.game.onUpdateHurricanes(handlers.onUpdateHurricanes), + unbind: () => + socketManager.game.offUpdateHurricanes(handlers.onUpdateHurricanes), }), }, { @@ -116,7 +136,8 @@ key: "bombPlacedAck", create: (handlers) => ({ bind: () => socketManager.game.onBombPlacedAck(handlers.onBombPlacedAck), - unbind: () => socketManager.game.offBombPlacedAck(handlers.onBombPlacedAck), + unbind: () => + socketManager.game.offBombPlacedAck(handlers.onBombPlacedAck), }), }, { @@ -126,14 +147,24 @@ unbind: () => socketManager.game.offPlayerHit(handlers.onPlayerHit), }), }, + { + key: "hurricaneHit", + create: (handlers) => ({ + bind: () => socketManager.game.onHurricaneHit(handlers.onHurricaneHit), + unbind: () => socketManager.game.offHurricaneHit(handlers.onHurricaneHit), + }), + }, ]; /** ソケット購読辞書を生成する */ export const createNetworkSubscriptions = ( handlers: NetworkSubscriptionHandlers, ): SocketSubscriptionDictionary => { - return SUBSCRIPTION_DEFINITIONS.reduce((dictionary, definition) => { - dictionary[definition.key] = definition.create(handlers); - return dictionary; - }, {} as SocketSubscriptionDictionary); -}; \ No newline at end of file + return SUBSCRIPTION_DEFINITIONS.reduce( + (dictionary, definition) => { + dictionary[definition.key] = definition.create(handlers); + return dictionary; + }, + {} as SocketSubscriptionDictionary, + ); +}; diff --git a/apps/client/src/scenes/game/application/network/adapters/GameNetworkEventAdapter.ts b/apps/client/src/scenes/game/application/network/adapters/GameNetworkEventAdapter.ts index d4929da..c2e3aa9 100644 --- a/apps/client/src/scenes/game/application/network/adapters/GameNetworkEventAdapter.ts +++ b/apps/client/src/scenes/game/application/network/adapters/GameNetworkEventAdapter.ts @@ -7,6 +7,7 @@ BombPlacedAckPayload, BombPlacedPayload, GameStartPayload, + HurricaneHitPayload, PlayerHitPayload, } from "@repo/shared"; @@ -43,4 +44,11 @@ payload: PlayerHitPayload, ): PlayerHitPayload => { return payload; -}; \ No newline at end of file +}; + +/** ハリケーン被弾受信ペイロードを内部ペイロードへ正規化する */ +export const toRemoteHurricaneHitPayload = ( + payload: HurricaneHitPayload, +): HurricaneHitPayload => { + return payload; +}; diff --git a/apps/client/src/scenes/game/application/network/handlers/CombatSyncHandler.ts b/apps/client/src/scenes/game/application/network/handlers/CombatSyncHandler.ts index 8ae9e60..47d0e86 100644 --- a/apps/client/src/scenes/game/application/network/handlers/CombatSyncHandler.ts +++ b/apps/client/src/scenes/game/application/network/handlers/CombatSyncHandler.ts @@ -6,6 +6,7 @@ import type { BombPlacedAckPayload, BombPlacedPayload, + HurricaneHitPayload, PlayerHitPayload, } from "@repo/shared"; @@ -14,22 +15,28 @@ onRemoteBombPlaced: (payload: BombPlacedPayload) => void; onBombPlacementAcknowledged: (payload: BombPlacedAckPayload) => void; onRemotePlayerHit: (payload: PlayerHitPayload) => void; + onRemoteHurricaneHit: (payload: HurricaneHitPayload) => void; }; /** 戦闘関連イベントの橋渡しを担当する */ export class CombatSyncHandler { private readonly onRemoteBombPlaced: (payload: BombPlacedPayload) => void; - private readonly onBombPlacementAcknowledged: (payload: BombPlacedAckPayload) => void; + private readonly onBombPlacementAcknowledged: ( + payload: BombPlacedAckPayload, + ) => void; private readonly onRemotePlayerHit: (payload: PlayerHitPayload) => void; + private readonly onRemoteHurricaneHit: (payload: HurricaneHitPayload) => void; constructor({ onRemoteBombPlaced, onBombPlacementAcknowledged, onRemotePlayerHit, + onRemoteHurricaneHit, }: CombatSyncHandlerOptions) { this.onRemoteBombPlaced = onRemoteBombPlaced; this.onBombPlacementAcknowledged = onBombPlacementAcknowledged; this.onRemotePlayerHit = onRemotePlayerHit; + this.onRemoteHurricaneHit = onRemoteHurricaneHit; } /** 他プレイヤーの爆弾設置受信イベントを橋渡しする */ @@ -38,7 +45,9 @@ }; /** 爆弾設置ACK受信イベントを橋渡しする */ - public handleReceivedBombPlacedAck = (payload: BombPlacedAckPayload): void => { + public handleReceivedBombPlacedAck = ( + payload: BombPlacedAckPayload, + ): void => { this.onBombPlacementAcknowledged(payload); }; @@ -46,4 +55,9 @@ public handleReceivedPlayerHit = (payload: PlayerHitPayload): void => { this.onRemotePlayerHit(payload); }; -} \ No newline at end of file + + /** ハリケーン被弾受信イベントを橋渡しする */ + public handleReceivedHurricaneHit = (payload: HurricaneHitPayload): void => { + this.onRemoteHurricaneHit(payload); + }; +} 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 3c3ee2a..4deec45 100644 --- a/apps/client/src/scenes/game/application/network/handlers/GameNetworkStateApplier.ts +++ b/apps/client/src/scenes/game/application/network/handlers/GameNetworkStateApplier.ts @@ -7,6 +7,7 @@ import type { BombPlacedAckPayload, BombPlacedPayload, + HurricaneHitPayload, PlayerHitPayload, } from "@repo/shared"; import { domain } from "@repo/shared"; @@ -17,9 +18,11 @@ toBombPlacementAcknowledgedPayload, toGameStartedAt, toRemoteBombPlacedPayload, + toRemoteHurricaneHitPayload, toRemotePlayerHitPayload, } from "@client/scenes/game/application/network/adapters/GameNetworkEventAdapter"; import { CombatSyncHandler } from "./CombatSyncHandler"; +import { HurricaneSyncHandler } from "./HurricaneSyncHandler"; import { MapSyncHandler } from "./MapSyncHandler"; import { PlayerSyncHandler } from "./PlayerSyncHandler"; import type { ReceivedGameEventHandlers } from "../receivers/GameNetworkEventReceiver"; @@ -36,6 +39,7 @@ onRemoteBombPlaced: (payload: BombPlacedPayload) => void; onBombPlacementAcknowledged: (payload: BombPlacedAckPayload) => void; onRemotePlayerHit: (payload: PlayerHitPayload) => void; + onRemoteHurricaneHit: (payload: HurricaneHitPayload) => void; onDebugLog?: (message: string) => void; }; @@ -44,6 +48,7 @@ private readonly playerSyncHandler: PlayerSyncHandler; private readonly mapSyncHandler: MapSyncHandler; private readonly combatSyncHandler: CombatSyncHandler; + private readonly hurricaneSyncHandler: HurricaneSyncHandler; private readonly onGameStarted: (startTime: number) => void; private readonly onGameEnded: () => void; private readonly onDebugLog: (message: string) => void; @@ -60,6 +65,7 @@ onRemoteBombPlaced, onBombPlacementAcknowledged, onRemotePlayerHit, + onRemoteHurricaneHit, onDebugLog, }: GameNetworkStateApplierOptions) { this.playerSyncHandler = new PlayerSyncHandler({ @@ -69,21 +75,63 @@ appearanceResolver, }); this.mapSyncHandler = new MapSyncHandler({ gameMap }); - this.combatSyncHandler = new CombatSyncHandler({ - onRemoteBombPlaced: (payload) => { - onRemoteBombPlaced(toRemoteBombPlacedPayload(payload)); - }, - onBombPlacementAcknowledged: (payload) => { - onBombPlacementAcknowledged(toBombPlacementAcknowledgedPayload(payload)); - }, - onRemotePlayerHit: (payload) => { - onRemotePlayerHit(toRemotePlayerHitPayload(payload)); - }, + this.hurricaneSyncHandler = new HurricaneSyncHandler({ worldContainer }); + this.combatSyncHandler = this.createCombatSyncHandler({ + onRemoteBombPlaced, + onBombPlacementAcknowledged, + onRemotePlayerHit, + onRemoteHurricaneHit, }); this.onGameStarted = onGameStarted; this.onGameEnded = onGameEnded; this.onDebugLog = onDebugLog ?? (() => undefined); - this.receivedEventHandlers = { + this.receivedEventHandlers = this.createReceivedEventHandlers(); + } + + /** 受信イベント配信先ハンドラ群を返す */ + public getReceivedEventHandlers(): ReceivedGameEventHandlers { + return this.receivedEventHandlers; + } + + /** 状態反映層が保持するリソースを破棄する */ + public dispose(): void { + this.hurricaneSyncHandler.destroy(); + } + + /** 戦闘イベント橋渡しハンドラを生成する */ + private createCombatSyncHandler({ + onRemoteBombPlaced, + onBombPlacementAcknowledged, + onRemotePlayerHit, + onRemoteHurricaneHit, + }: Pick< + GameNetworkStateApplierOptions, + | "onRemoteBombPlaced" + | "onBombPlacementAcknowledged" + | "onRemotePlayerHit" + | "onRemoteHurricaneHit" + >): CombatSyncHandler { + return new CombatSyncHandler({ + onRemoteBombPlaced: (payload) => { + onRemoteBombPlaced(toRemoteBombPlacedPayload(payload)); + }, + onBombPlacementAcknowledged: (payload) => { + onBombPlacementAcknowledged( + toBombPlacementAcknowledgedPayload(payload), + ); + }, + onRemotePlayerHit: (payload) => { + onRemotePlayerHit(toRemotePlayerHitPayload(payload)); + }, + onRemoteHurricaneHit: (payload) => { + onRemoteHurricaneHit(toRemoteHurricaneHitPayload(payload)); + }, + }); + } + + /** 受信イベントハンドラをまとめて生成する */ + private createReceivedEventHandlers(): ReceivedGameEventHandlers { + return { onReceivedCurrentPlayers: (payload) => { this.playerSyncHandler.handleCurrentPlayers(payload); }, @@ -97,7 +145,9 @@ } this.onGameStarted(startTime); - this.onDebugLog(`[GameNetworkSync] ゲーム開始時刻同期完了: ${startTime}`); + this.onDebugLog( + `[GameNetworkSync] ゲーム開始時刻同期完了: ${startTime}`, + ); }, onReceivedUpdatePlayers: (payload) => { this.playerSyncHandler.handlePlayerUpdates(payload); @@ -109,6 +159,9 @@ const updates = domain.game.gridMap.ungroupCellUpdates(payload); this.mapSyncHandler.handleUpdateMapCells(updates); }, + onReceivedUpdateHurricanes: (payload) => { + this.hurricaneSyncHandler.handleUpdateHurricanes(payload); + }, onReceivedGameEnd: () => { this.onGameEnded(); }, @@ -121,11 +174,9 @@ onReceivedPlayerHit: (payload) => { this.combatSyncHandler.handleReceivedPlayerHit(payload); }, + onReceivedHurricaneHit: (payload) => { + this.combatSyncHandler.handleReceivedHurricaneHit(payload); + }, }; } - - /** 受信イベント配信先ハンドラ群を返す */ - public getReceivedEventHandlers(): ReceivedGameEventHandlers { - return this.receivedEventHandlers; - } } diff --git a/apps/client/src/scenes/game/application/network/handlers/HurricaneSyncHandler.ts b/apps/client/src/scenes/game/application/network/handlers/HurricaneSyncHandler.ts new file mode 100644 index 0000000..ab58a78 --- /dev/null +++ b/apps/client/src/scenes/game/application/network/handlers/HurricaneSyncHandler.ts @@ -0,0 +1,32 @@ +/** + * HurricaneSyncHandler + * ハリケーン同期イベントの受信処理を担当する + * 受信配列を描画コントローラーへ橋渡しする + */ +import { Container } from "pixi.js"; +import type { UpdateHurricanesPayload } from "@repo/shared"; +import { HurricaneOverlayController } from "@client/scenes/game/entities/hurricane/HurricaneOverlayController"; + +/** HurricaneSyncHandler の初期化入力 */ +export type HurricaneSyncHandlerOptions = { + worldContainer: Container; +}; + +/** ハリケーン同期イベントの適用を担当する */ +export class HurricaneSyncHandler { + private readonly overlayController: HurricaneOverlayController; + + constructor({ worldContainer }: HurricaneSyncHandlerOptions) { + this.overlayController = new HurricaneOverlayController(worldContainer); + } + + /** ハリケーン状態配列を描画へ反映する */ + public handleUpdateHurricanes = (payload: UpdateHurricanesPayload): void => { + this.overlayController.applyUpdates(payload); + }; + + /** 管理中リソースを破棄する */ + public destroy(): void { + this.overlayController.destroy(); + } +} 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 38ef476..f2c782e 100644 --- a/apps/client/src/scenes/game/application/network/receivers/GameNetworkEventReceiver.ts +++ b/apps/client/src/scenes/game/application/network/receivers/GameNetworkEventReceiver.ts @@ -8,9 +8,11 @@ BombPlacedPayload, CurrentPlayersPayload, GameStartPayload, + HurricaneHitPayload, NewPlayerPayload, PlayerHitPayload, RemovePlayerPayload, + UpdateHurricanesPayload, UpdateMapCellsPayload, UpdatePlayersPayload, } from "@repo/shared"; @@ -27,10 +29,12 @@ onReceivedUpdatePlayers: (payload: UpdatePlayersPayload) => void; onReceivedRemovePlayer: (payload: RemovePlayerPayload) => void; onReceivedUpdateMapCells: (payload: UpdateMapCellsPayload) => void; + onReceivedUpdateHurricanes: (payload: UpdateHurricanesPayload) => void; onReceivedGameEnd: () => void; onReceivedBombPlaced: (payload: BombPlacedPayload) => void; onReceivedBombPlacedAck: (payload: BombPlacedAckPayload) => void; onReceivedPlayerHit: (payload: PlayerHitPayload) => void; + onReceivedHurricaneHit: (payload: HurricaneHitPayload) => void; }; /** 受信イベント購読の管理を担当する */ @@ -46,10 +50,12 @@ onUpdatePlayers: handlers.onReceivedUpdatePlayers, onRemovePlayer: handlers.onReceivedRemovePlayer, onUpdateMapCells: handlers.onReceivedUpdateMapCells, + onUpdateHurricanes: handlers.onReceivedUpdateHurricanes, onGameEnd: handlers.onReceivedGameEnd, onBombPlaced: handlers.onReceivedBombPlaced, onBombPlacedAck: handlers.onReceivedBombPlacedAck, onPlayerHit: handlers.onReceivedPlayerHit, + onHurricaneHit: handlers.onReceivedHurricaneHit, }); } diff --git a/apps/client/src/scenes/game/application/orchestrators/GameSceneOrchestrator.ts b/apps/client/src/scenes/game/application/orchestrators/GameSceneOrchestrator.ts index c81cc5c..5520abf 100644 --- a/apps/client/src/scenes/game/application/orchestrators/GameSceneOrchestrator.ts +++ b/apps/client/src/scenes/game/application/orchestrators/GameSceneOrchestrator.ts @@ -7,12 +7,16 @@ import type { BombPlacedAckPayload, BombPlacedPayload, + HurricaneHitPayload, PlayerHitPayload, } from "@repo/shared"; import { AppearanceResolver } from "@client/scenes/game/application/AppearanceResolver"; import { GameMapController } from "@client/scenes/game/entities/map/GameMapController"; import { GameNetworkSync } from "@client/scenes/game/application/GameNetworkSync"; -import { BombManager, type BombExplodedPayload } from "@client/scenes/game/entities/bomb/BombManager"; +import { + BombManager, + type BombExplodedPayload, +} from "@client/scenes/game/entities/bomb/BombManager"; import { GameLoop } from "@client/scenes/game/application/GameLoop"; import type { MoveSender } from "@client/scenes/game/application/network/PlayerMoveSender"; import type { GamePlayers } from "@client/scenes/game/application/game.types"; @@ -30,6 +34,7 @@ onRemoteBombPlaced: (payload: BombPlacedPayload) => void; onBombPlacementAcknowledged: (payload: BombPlacedAckPayload) => void; onRemotePlayerHit: (payload: PlayerHitPayload) => void; + onRemoteHurricaneHit: (payload: HurricaneHitPayload) => void; }; /** BombManager 生成入力型 */ @@ -49,6 +54,7 @@ onRemoteBombPlaced: (payload: BombPlacedPayload) => void; onBombPlacementAcknowledged: (payload: BombPlacedAckPayload) => void; onRemotePlayerHit: (payload: PlayerHitPayload) => void; + onRemoteHurricaneHit: (payload: HurricaneHitPayload) => void; onBombExploded: (payload: BombExplodedPayload) => void; }; @@ -105,8 +111,12 @@ private readonly getJoystickInput: () => { x: number; y: number }; private readonly moveSender: MoveSender; private readonly eventPorts: GameSceneEventPorts; - private readonly createNetworkSync: (options: CreateNetworkSyncOptions) => GameNetworkSync; - private readonly createBombManager: (options: CreateBombManagerOptions) => BombManager; + private readonly createNetworkSync: ( + options: CreateNetworkSyncOptions, + ) => GameNetworkSync; + private readonly createBombManager: ( + options: CreateBombManagerOptions, + ) => BombManager; private readonly createGameLoop: (options: CreateGameLoopOptions) => GameLoop; constructor({ @@ -132,9 +142,13 @@ this.getJoystickInput = getJoystickInput; this.moveSender = moveSender; this.eventPorts = eventPorts; - this.createNetworkSync = factories?.createNetworkSync ?? ((options) => new GameNetworkSync(options)); - this.createBombManager = factories?.createBombManager ?? ((options) => new BombManager(options)); - this.createGameLoop = factories?.createGameLoop ?? ((options) => new GameLoop(options)); + this.createNetworkSync = + factories?.createNetworkSync ?? + ((options) => new GameNetworkSync(options)); + this.createBombManager = + factories?.createBombManager ?? ((options) => new BombManager(options)); + this.createGameLoop = + factories?.createGameLoop ?? ((options) => new GameLoop(options)); } /** シーン配線を順序どおり初期化し,参照を返す */ @@ -172,6 +186,7 @@ onRemoteBombPlaced: this.eventPorts.onRemoteBombPlaced, onBombPlacementAcknowledged: this.eventPorts.onBombPlacementAcknowledged, onRemotePlayerHit: this.eventPorts.onRemotePlayerHit, + onRemoteHurricaneHit: this.eventPorts.onRemoteHurricaneHit, }); networkSync.bind(); return networkSync; @@ -201,4 +216,4 @@ moveSender: this.moveSender, }); } -} \ No newline at end of file +} diff --git a/apps/client/src/scenes/game/entities/hurricane/HurricaneOverlayController.ts b/apps/client/src/scenes/game/entities/hurricane/HurricaneOverlayController.ts new file mode 100644 index 0000000..cc68f75 --- /dev/null +++ b/apps/client/src/scenes/game/entities/hurricane/HurricaneOverlayController.ts @@ -0,0 +1,112 @@ +/** + * HurricaneOverlayController + * ハリケーン状態配列を受け取り,Pixi描画オブジェクトへ反映する + * 生成,更新,削除を同一コントローラーで管理する + */ +import type { UpdateHurricanesPayload } from "@repo/shared"; +import { config } from "@client/config"; +import { Container, Sprite, Texture } from "pixi.js"; +import { loadHurricaneTexture } from "./HurricaneTextureCache"; + +type HurricaneDisplay = { + container: Container; + sprite: Sprite; + radiusGrid: number; +}; + +/** ハリケーン描画オーバーレイを管理する */ +export class HurricaneOverlayController { + private readonly layer: Container; + private readonly displayById = new Map(); + private readonly imageUrl = `${import.meta.env.BASE_URL}hurricane.svg`; + + constructor(worldContainer: Container) { + this.layer = new Container(); + this.layer.sortableChildren = false; + worldContainer.addChild(this.layer); + } + + /** ハリケーン状態を描画へ同期する */ + public applyUpdates(states: UpdateHurricanesPayload): void { + const activeIds = new Set(); + + states.forEach((state) => { + activeIds.add(state.id); + + let target = this.displayById.get(state.id); + if (!target) { + const created = this.createDisplay(); + this.layer.addChild(created.container); + this.displayById.set(state.id, created); + target = created; + } + + if (Math.abs(target.radiusGrid - state.radius) > 0.0001) { + this.applySpriteSize(target.sprite, state.radius); + target.radiusGrid = state.radius; + } + + target.container.x = state.x * config.GAME_CONFIG.GRID_CELL_SIZE; + target.container.y = state.y * config.GAME_CONFIG.GRID_CELL_SIZE; + target.container.rotation = state.rotationRad; + }); + + this.displayById.forEach((display, id) => { + if (activeIds.has(id)) { + return; + } + + this.layer.removeChild(display.container); + display.container.destroy({ children: true }); + this.displayById.delete(id); + }); + } + + /** 描画リソースを破棄する */ + public destroy(): void { + this.displayById.forEach((display) => { + this.layer.removeChild(display.container); + display.container.destroy({ children: true }); + }); + this.displayById.clear(); + this.layer.destroy({ children: true }); + } + + private createDisplay(): HurricaneDisplay { + const container = new Container(); + const sprite = new Sprite(Texture.WHITE); + sprite.anchor.set(0.5, 0.5); + this.applySpriteSize(sprite, 0); + container.addChild(sprite); + + void this.applyTexture(sprite); + + return { + container, + sprite, + radiusGrid: 0, + }; + } + + /** ハリケーン画像を読み込んでスプライトへ適用する */ + private async applyTexture(sprite: Sprite): Promise { + try { + const texture = await loadHurricaneTexture(this.imageUrl); + sprite.texture = texture; + } catch { + // 読み込み失敗時は白テクスチャのまま描画を継続する + } + } + + /** 当たり判定半径に一致する見た目サイズを適用する */ + private applySpriteSize(sprite: Sprite, radiusGrid: number): void { + const sizePx = this.toSpriteSizePx(radiusGrid); + sprite.width = sizePx; + sprite.height = sizePx; + } + + /** 半径グリッド値をスプライト直径ピクセルへ変換する */ + private toSpriteSizePx(radiusGrid: number): number { + return radiusGrid * 2 * config.GAME_CONFIG.GRID_CELL_SIZE; + } +} diff --git a/apps/client/src/scenes/game/entities/hurricane/HurricaneTextureCache.ts b/apps/client/src/scenes/game/entities/hurricane/HurricaneTextureCache.ts new file mode 100644 index 0000000..9e3ecde --- /dev/null +++ b/apps/client/src/scenes/game/entities/hurricane/HurricaneTextureCache.ts @@ -0,0 +1,21 @@ +/** + * HurricaneTextureCache + * ハリケーン描画テクスチャの共有キャッシュ + * 複数ハリケーン表示での重複ロードを防ぐ + */ +import { Assets, Texture } from "pixi.js"; + +let texturePromise: Promise | null = null; +let cachedTexture: Texture | null = null; + +/** ハリケーン描画テクスチャをキャッシュ経由で取得する */ +export async function loadHurricaneTexture(imageUrl: string): Promise { + if (cachedTexture) return cachedTexture; + + if (!texturePromise) { + texturePromise = Assets.load(imageUrl); + } + + cachedTexture = await texturePromise; + return cachedTexture; +} diff --git a/apps/client/src/scenes/game/styles/GameView.styles.ts b/apps/client/src/scenes/game/styles/GameView.styles.ts index 15ddc98..948e134 100644 --- a/apps/client/src/scenes/game/styles/GameView.styles.ts +++ b/apps/client/src/scenes/game/styles/GameView.styles.ts @@ -127,3 +127,25 @@ pointerEvents: "none", animation: "feverPulse 0.9s ease-in-out infinite", }; + +/** 画面中央のハリケーン警告表示スタイル */ +export const GAME_VIEW_HURRICANE_WARNING_STYLE: CSSProperties = { + position: "absolute", + top: "50%", + left: "50%", + transform: "translate(-50%, -50%)", + zIndex: 32, + color: "#ff2a2a", + fontSize: "clamp(1.1rem, 5.6vw, 2.8rem)", + fontWeight: 900, + letterSpacing: "0.04em", + WebkitTextStroke: "1px rgba(60, 0, 0, 0.9)", + textShadow: + "0 0 8px rgba(255,120,120,0.92), 0 0 18px rgba(255,40,40,0.96), 0 0 32px rgba(180,0,0,0.85)", + fontFamily: "monospace", + whiteSpace: "nowrap", + userSelect: "none", + WebkitUserSelect: "none", + pointerEvents: "none", + animation: "hurricaneWarningBlink 0.6s step-end infinite", +}; diff --git a/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts b/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts index 74bf4db..e23c295 100644 --- a/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts +++ b/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts @@ -6,7 +6,9 @@ BombHitReportPayload, BombPlacedAckPayload, BombPlacedPayload, + HurricaneHitPayload, PlayerHitPayload, + UpdateHurricanesPayload, domain, PlaceBombPayload, CurrentPlayersPayload, @@ -56,6 +58,10 @@ roomId: domain.room.Room["roomId"], cellUpdates: domain.game.gridMap.CellUpdate[], ): void; + publishUpdateHurricanesToRoom( + roomId: domain.room.Room["roomId"], + hurricanes: UpdateHurricanesPayload, + ): void; publishGameEndToRoom(roomId: domain.room.Room["roomId"]): void; publishGameResultToRoom( roomId: domain.room.Room["roomId"], @@ -93,6 +99,14 @@ deadPlayerId: string, payload: PlayerHitPayload, ): void; + publishPlayerHitToRoom( + roomId: domain.room.Room["roomId"], + payload: PlayerHitPayload, + ): void; + publishHurricaneHitToRoom( + roomId: domain.room.Room["roomId"], + payload: HurricaneHitPayload, + ): void; } /** start-game 系フローで利用する送信出力ポート */ @@ -100,6 +114,7 @@ GameOutputPort, | "publishUpdatePlayersToRoom" | "publishMapCellUpdatesToRoom" + | "publishUpdateHurricanesToRoom" | "publishGameEndToRoom" | "publishGameResultToRoom" | "publishGameStartToRoom" diff --git a/apps/server/src/domains/game/application/services/GameRoomSession.ts b/apps/server/src/domains/game/application/services/GameRoomSession.ts index 6598066..dd009b1 100644 --- a/apps/server/src/domains/game/application/services/GameRoomSession.ts +++ b/apps/server/src/domains/game/application/services/GameRoomSession.ts @@ -29,6 +29,7 @@ onGameEnd: (payload: GameResultPayload) => void; onBotPlaceBomb?: (ownerId: string, payload: PlaceBombPayload) => void; onBotBombHit?: (targetPlayerId: string, bombId: string) => void; + onHurricanePlayerHit?: (targetPlayerId: string) => void; }; /** ルーム単位のゲーム状態とループ進行を保持するセッションクラス */ @@ -63,10 +64,7 @@ }); } - public start( - tickRate: number, - callbacks: GameSessionCallbacks, - ): void { + public start(tickRate: number, callbacks: GameSessionCallbacks): void { if (this.gameLoop) { return; } @@ -91,6 +89,7 @@ }, onBotPlaceBomb: callbacks.onBotPlaceBomb, onBotBombHit: callbacks.onBotBombHit, + onHurricanePlayerHit: callbacks.onHurricanePlayerHit, }; this.gameLoop = new GameLoop({ diff --git a/apps/server/src/domains/game/application/useCases/startGameUseCase.ts b/apps/server/src/domains/game/application/useCases/startGameUseCase.ts index 276fe9e..0163655 100644 --- a/apps/server/src/domains/game/application/useCases/startGameUseCase.ts +++ b/apps/server/src/domains/game/application/useCases/startGameUseCase.ts @@ -48,34 +48,37 @@ }); }; - gameSession.startRoomSession( - playerIds, - playerNamesById, - { - onTick: (tickData) => { - if (tickData.playerUpdates.length > 0) { - output.publishUpdatePlayersToRoom(roomId, tickData.playerUpdates); - } + gameSession.startRoomSession(playerIds, playerNamesById, { + onTick: (tickData) => { + if (tickData.playerUpdates.length > 0) { + output.publishUpdatePlayersToRoom(roomId, tickData.playerUpdates); + } - if (tickData.cellUpdates.length > 0) { - output.publishMapCellUpdatesToRoom(roomId, tickData.cellUpdates); - } - }, - onGameEnd: (resultPayload) => { - logEvent(logScopes.GAME_USE_CASE, { - event: gameUseCaseLogEvents.GAME_END, - result: logResults.EMITTED, - roomId, - reason: "duration_elapsed", - }); - output.publishGameEndToRoom(roomId); - output.publishGameResultToRoom(roomId, resultPayload); - onGameEnd(); - }, - onBotPlaceBomb: handleBotBombAction, - onBotBombHit: handleBotBombHit, + if (tickData.cellUpdates.length > 0) { + output.publishMapCellUpdatesToRoom(roomId, tickData.cellUpdates); + } + + output.publishUpdateHurricanesToRoom(roomId, tickData.hurricaneUpdates); }, - ); + onGameEnd: (resultPayload) => { + logEvent(logScopes.GAME_USE_CASE, { + event: gameUseCaseLogEvents.GAME_END, + result: logResults.EMITTED, + roomId, + reason: "duration_elapsed", + }); + output.publishGameEndToRoom(roomId); + output.publishGameResultToRoom(roomId, resultPayload); + onGameEnd(); + }, + onBotPlaceBomb: handleBotBombAction, + onBotBombHit: handleBotBombHit, + onHurricanePlayerHit: (targetPlayerId) => { + output.publishHurricaneHitToRoom(roomId, { + playerId: targetPlayerId, + }); + }, + }); const startTime = gameSession.getRoomStartTime() || Date.now(); output.publishGameStartToRoom(roomId, { startTime, serverNow: Date.now() }); diff --git a/apps/server/src/domains/game/loop/GameLoop.ts b/apps/server/src/domains/game/loop/GameLoop.ts index 731bdca..0cbfaad 100644 --- a/apps/server/src/domains/game/loop/GameLoop.ts +++ b/apps/server/src/domains/game/loop/GameLoop.ts @@ -26,6 +26,7 @@ } from "../application/services/bot/index.js"; import { setPlayerPosition } from "../entities/player/playerMovement.js"; import type { ActiveBombRegistry } from "../entities/bomb/ActiveBombRegistry.js"; +import { HurricaneSystem } from "./HurricaneSystem"; const { checkBombHit } = domain.game.bombHit; @@ -45,11 +46,14 @@ onGameEnd: () => void; onBotPlaceBomb?: (ownerId: string, payload: PlaceBombPayload) => void; onBotBombHit?: (targetPlayerId: string, bombId: string) => void; + onHurricanePlayerHit?: (targetPlayerId: string) => void; }; /** プレイヤーのグリッド位置キャッシュを含むエントリ */ type PlayerGridCacheEntry = PlayerGridEntry & { player: Player }; +type DamageSource = "bomb" | "hurricane"; + /** ルーム内ゲーム進行を定周期で実行するループ管理クラス */ export class GameLoop { private loopId: NodeJS.Timeout | null = null; @@ -63,6 +67,7 @@ private disconnectedBotControlledPlayerIds: Set = new Set(); private botTurnOrchestrator: BotTurnOrchestrator = new BotTurnOrchestrator(); private readonly botReceivedHitCountById = new Map(); + private readonly hurricaneSystem = new HurricaneSystem(); private readonly roomId: string; private readonly tickRate: number; @@ -153,6 +158,9 @@ 0, Math.round(monotonicNowMs - this.startMonotonicTimeMs), ); + this.hurricaneSystem.ensureSpawned(elapsedMs); + this.hurricaneSystem.update(this.tickRate / 1000); + this.detectHurricaneHits(wallClockNowMs); const gridColorsSnapshot = this.mapStore.getGridColorsSnapshot(); this.updateBotPlayers(wallClockNowMs, elapsedMs, gridColorsSnapshot); this.detectBotBombHits(elapsedMs, wallClockNowMs); @@ -218,23 +226,7 @@ }); if (result.isHit) { - // 被弾カウントを更新し,閾値到達でリスポーンスタン,それ以外は通常スタンを適用する - const prevCount = this.botReceivedHitCountById.get(player.id) ?? 0; - const nextCount = prevCount + 1; - - if (nextCount >= config.GAME_CONFIG.PLAYER_RESPAWN_HIT_COUNT) { - this.botReceivedHitCountById.set(player.id, 0); - this.botTurnOrchestrator.applyRespawnStun( - player.id as BotPlayerId, - nowMs, - ); - } else { - this.botReceivedHitCountById.set(player.id, nextCount); - this.botTurnOrchestrator.applyHitStun( - player.id as BotPlayerId, - nowMs, - ); - } + this.applyBotDamage(player.id, nowMs, "bomb"); // 爆弾所有者の bombHitCount を加算する const owner = this.players.get(bomb.ownerPlayerId); @@ -266,9 +258,49 @@ return { playerUpdates, cellUpdates: this.mapStore.getAndClearUpdates(), + hurricaneUpdates: this.hurricaneSystem.getUpdatePayload(), }; } + /** ハリケーン接触を検知し,被弾通知を配信する */ + private detectHurricaneHits(nowMs: number): void { + const hitPlayerIds = this.hurricaneSystem.collectHitPlayerIds( + this.players, + nowMs, + ); + + hitPlayerIds.forEach((playerId) => { + if ( + isBotPlayerId(playerId) || + this.disconnectedBotControlledPlayerIds.has(playerId) + ) { + this.applyBotDamage(playerId, nowMs, "hurricane"); + } + + this.callbacks.onHurricanePlayerHit?.(playerId); + }); + } + + /** Bot被弾時のスタン適用とカウント更新を行う */ + private applyBotDamage( + playerId: string, + nowMs: number, + _source: DamageSource, + ): void { + const botPlayerId = playerId as BotPlayerId; + const prevCount = this.botReceivedHitCountById.get(playerId) ?? 0; + const nextCount = prevCount + 1; + + if (nextCount >= config.GAME_CONFIG.PLAYER_RESPAWN_HIT_COUNT) { + this.botReceivedHitCountById.set(playerId, 0); + this.botTurnOrchestrator.applyRespawnStun(botPlayerId, nowMs); + return; + } + + this.botReceivedHitCountById.set(playerId, nextCount); + this.botTurnOrchestrator.applyHitStun(botPlayerId, nowMs); + } + private collectChangedPlayerUpdates( activePlayerIds: Set, ): domain.game.tick.TickData["playerUpdates"] { @@ -342,6 +374,7 @@ this.botTurnOrchestrator.clear(); this.disconnectedBotControlledPlayerIds.clear(); this.lastSentPlayers.clear(); + this.hurricaneSystem.clear(); if (this.loopId) { clearTimeout(this.loopId); diff --git a/apps/server/src/domains/game/loop/HurricaneSystem.ts b/apps/server/src/domains/game/loop/HurricaneSystem.ts new file mode 100644 index 0000000..d959001 --- /dev/null +++ b/apps/server/src/domains/game/loop/HurricaneSystem.ts @@ -0,0 +1,168 @@ +/** + * HurricaneSystem + * ハリケーンの生成,移動,同期データ化,被弾検知を管理する + * GameLoop からハリケーン専用責務を分離する + */ +import { config } from "@server/config"; +import { domain, type HurricaneStatePayload } from "@repo/shared"; +import { Player } from "../entities/player/Player.js"; + +const { checkBombHit } = domain.game.bombHit; + +type HurricaneState = { + id: string; + x: number; + y: number; + vx: number; + vy: number; + radius: number; + rotationRad: number; +}; + +/** ハリケーン状態の生成更新と被弾判定を管理する */ +export class HurricaneSystem { + private hasSpawned = false; + private hurricanes: HurricaneState[] = []; + private readonly lastHitAtMsByPlayerId = new Map(); + + /** 残り時間しきい値到達時にハリケーンを一度だけ生成する */ + public ensureSpawned(elapsedMs: number): void { + if (!config.GAME_CONFIG.HURRICANE_ENABLED || this.hasSpawned) { + return; + } + + const remainingSec = + config.GAME_CONFIG.GAME_DURATION_SEC - elapsedMs / 1000; + if (remainingSec > config.GAME_CONFIG.HURRICANE_SPAWN_REMAINING_SEC) { + return; + } + + this.hasSpawned = true; + 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 = config.GAME_CONFIG.GRID_COLS; + const maxY = config.GAME_CONFIG.GRID_ROWS; + + 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; + } + }); + } + + /** 同期配信用のハリケーン状態配列を返す */ + public getUpdatePayload(): HurricaneStatePayload[] { + return this.hurricanes.map((hurricane) => ({ + id: hurricane.id, + x: hurricane.x, + y: hurricane.y, + radius: hurricane.radius, + rotationRad: hurricane.rotationRad, + })); + } + + /** クールダウン付きで被弾プレイヤーID配列を返す */ + public collectHitPlayerIds( + 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; + } + + /** 状態を初期化する */ + public clear(): void { + this.hasSpawned = false; + this.hurricanes = []; + this.lastHitAtMsByPlayerId.clear(); + } + + /** ハリケーン初期状態を生成する */ + private createHurricane(index: number): HurricaneState { + const radius = config.GAME_CONFIG.HURRICANE_DIAMETER_GRID / 2; + const x = this.randomInRange(radius, config.GAME_CONFIG.GRID_COLS - radius); + const y = this.randomInRange(radius, config.GAME_CONFIG.GRID_ROWS - radius); + const directionRad = this.randomInRange(0, Math.PI * 2); + const speed = config.GAME_CONFIG.HURRICANE_MOVE_SPEED; + + return { + id: `hurricane-${index + 1}`, + x, + y, + vx: Math.cos(directionRad) * speed, + vy: Math.sin(directionRad) * speed, + radius, + rotationRad: directionRad, + }; + } + + private randomInRange(min: number, max: number): number { + return min + Math.random() * Math.max(0, max - min); + } +} diff --git a/apps/server/src/network/handlers/game/createGameOutputAdapter.ts b/apps/server/src/network/handlers/game/createGameOutputAdapter.ts index 6af56f4..e0ee30f 100644 --- a/apps/server/src/network/handlers/game/createGameOutputAdapter.ts +++ b/apps/server/src/network/handlers/game/createGameOutputAdapter.ts @@ -10,10 +10,12 @@ domain, GameStartPayload, GameResultPayload, + HurricaneHitPayload, PlayerHitPayload, PongPayload, CurrentPlayersPayload, RemovePlayerPayload, + UpdateHurricanesPayload, UpdatePlayersPayload, } from "@repo/shared"; import type { @@ -72,6 +74,16 @@ grouped, ); }, + publishUpdateHurricanesToRoom: ( + roomId: RoomId, + hurricanes: UpdateHurricanesPayload, + ) => { + common.emitToRoom( + roomId, + protocol.SocketEvents.UPDATE_HURRICANES, + hurricanes, + ); + }, publishGameEndToRoom: (roomId: RoomId) => { common.emitToRoom(roomId, protocol.SocketEvents.GAME_END); }, @@ -126,6 +138,15 @@ payload, ); }, + publishPlayerHitToRoom: (roomId: RoomId, payload: PlayerHitPayload) => { + common.emitToRoom(roomId, protocol.SocketEvents.PLAYER_HIT, payload); + }, + publishHurricaneHitToRoom: ( + roomId: RoomId, + payload: HurricaneHitPayload, + ) => { + common.emitToRoom(roomId, protocol.SocketEvents.HURRICANE_HIT, payload); + }, }; }; diff --git a/packages/shared/src/config/gameConfig.ts b/packages/shared/src/config/gameConfig.ts index 2550486..6226512 100644 --- a/packages/shared/src/config/gameConfig.ts +++ b/packages/shared/src/config/gameConfig.ts @@ -35,6 +35,15 @@ BOMB_FEVER_START_REMAINING_SEC: 60, // フィーバー開始の残り時間しきい値(秒) BOMB_DEDUP_EXTRA_TTL_MS: 1000, // 重複排除保持時間の追加分(ms) + // ハリケーンイベント設定(クライアント/サーバー契約) + HURRICANE_ENABLED: true, // ハリケーンイベント有効フラグ + HURRICANE_SPAWN_REMAINING_SEC: 120, // ハリケーン出現開始の残り時間しきい値(秒) + HURRICANE_COUNT: 5, // 同時出現数 + HURRICANE_DIAMETER_GRID: 2.2, // 見た目と判定に共通で利用する直径(グリッド単位) + HURRICANE_MOVE_SPEED: 1.5, // 1秒あたりの移動速度(グリッド単位) + HURRICANE_HIT_COOLDOWN_MS: 3000, // 同一対象への連続被弾クールダウン(ms) + HURRICANE_VISUAL_ROTATION_SPEED: 2.6, // 描画回転速度(rad/s) + // チーム設定(クライアント/サーバー契約) TEAM_COUNT: 4, } as const; diff --git a/packages/shared/src/domains/game/tick/tick.type.ts b/packages/shared/src/domains/game/tick/tick.type.ts index 38e0c63..c1ab933 100644 --- a/packages/shared/src/domains/game/tick/tick.type.ts +++ b/packages/shared/src/domains/game/tick/tick.type.ts @@ -4,6 +4,7 @@ */ import type { CellUpdate } from "../gridMap/gridMap.type"; import type { PlayerData } from "../player/player.type"; +import type { HurricaneStatePayload } from "../../../protocol/payloads/gamePayloads"; /** 1ティックで配信するプレイヤー座標差分 */ export type PlayerPositionUpdate = Pick; @@ -12,4 +13,5 @@ export interface TickData { playerUpdates: PlayerPositionUpdate[]; cellUpdates: CellUpdate[]; + hurricaneUpdates: HurricaneStatePayload[]; } diff --git a/packages/shared/src/protocol/eventPayloads.ts b/packages/shared/src/protocol/eventPayloads.ts index bbf9c2e..e5ab7db 100644 --- a/packages/shared/src/protocol/eventPayloads.ts +++ b/packages/shared/src/protocol/eventPayloads.ts @@ -23,6 +23,8 @@ UpdatePlayersPayload, CurrentPlayersPayload, UpdateMapCellsPayload, + HurricaneStatePayload, + UpdateHurricanesPayload, NewPlayerPayload, RemovePlayerPayload, GameStartPayload, @@ -33,6 +35,7 @@ BombPlacedAckPayload, BombHitReportPayload, PlayerHitPayload, + HurricaneHitPayload, GameResultPayload, GameResultRanking, PlayerGameStats, diff --git a/packages/shared/src/protocol/maps/gameEventPayloadMap.ts b/packages/shared/src/protocol/maps/gameEventPayloadMap.ts index 3b46475..41e7e2d 100644 --- a/packages/shared/src/protocol/maps/gameEventPayloadMap.ts +++ b/packages/shared/src/protocol/maps/gameEventPayloadMap.ts @@ -4,10 +4,7 @@ * 開始進行終了,入力,同期の契約を集約する */ import { SocketEvents } from "../socketEvents"; -import type { - PingPayload, - PongPayload, -} from "../payloads/commonPayloads"; +import type { PingPayload, PongPayload } from "../payloads/commonPayloads"; import type { BombHitReportPayload, BombPlacedAckPayload, @@ -15,6 +12,7 @@ CurrentPlayersPayload, GameResultPayload, GameStartPayload, + HurricaneHitPayload, StartGameRequestPayload, MovePayload, NewPlayerPayload, @@ -22,6 +20,7 @@ PlayerHitPayload, RemovePlayerPayload, UpdateMapCellsPayload, + UpdateHurricanesPayload, UpdatePlayersPayload, } from "../payloads/gamePayloads"; @@ -43,9 +42,11 @@ [SocketEvents.UPDATE_PLAYERS_SYNC]: UpdatePlayersPayload; [SocketEvents.REMOVE_PLAYER_SYNC]: RemovePlayerPayload; [SocketEvents.UPDATE_MAP_CELLS_SYNC]: UpdateMapCellsPayload; + [SocketEvents.UPDATE_HURRICANES_SYNC]: UpdateHurricanesPayload; [SocketEvents.BOMB_PLACED]: BombPlacedPayload; [SocketEvents.BOMB_PLACED_ACK]: BombPlacedAckPayload; [SocketEvents.PLAYER_HIT]: PlayerHitPayload; + [SocketEvents.HURRICANE_HIT]: HurricaneHitPayload; [SocketEvents.PONG]: PongPayload; [SocketEvents.GAME_END]: undefined; [SocketEvents.GAME_RESULT]: GameResultPayload; diff --git a/packages/shared/src/protocol/payloads/gamePayloads.ts b/packages/shared/src/protocol/payloads/gamePayloads.ts index d7bbe29..afb03bd 100644 --- a/packages/shared/src/protocol/payloads/gamePayloads.ts +++ b/packages/shared/src/protocol/payloads/gamePayloads.ts @@ -68,6 +68,18 @@ /** update-map-cells イベントで送受信するグループ化マップ差分 */ export type UpdateMapCellsPayload = GroupedCellUpdates; +/** update-hurricanes イベントで送受信するハリケーン状態 */ +export type HurricaneStatePayload = { + id: string; + x: number; + y: number; + radius: number; + rotationRad: number; +}; + +/** update-hurricanes イベントで送受信するハリケーン状態配列 */ +export type UpdateHurricanesPayload = HurricaneStatePayload[]; + /** * new-player イベントで送受信するプレイヤー情報 * 初回参加通知のため teamId を含む完全な PlayerData を配信する @@ -125,3 +137,8 @@ export type PlayerHitPayload = { playerId: string; }; + +/** hurricane-hit イベントで送受信する被弾プレイヤー情報 */ +export type HurricaneHitPayload = { + playerId: string; +}; diff --git a/packages/shared/src/protocol/socketEvents.ts b/packages/shared/src/protocol/socketEvents.ts index 34c4a1f..0648621 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", + UPDATE_HURRICANES_SYNC: "update-hurricanes", // 互換維持のため残す旧キー名 CURRENT_PLAYERS: "current-players", @@ -34,9 +35,11 @@ PLACE_BOMB: "place-bomb", BOMB_HIT_REPORT: "bomb-hit-report", UPDATE_MAP_CELLS: "update-map-cells", + UPDATE_HURRICANES: "update-hurricanes", BOMB_PLACED: "bomb-placed", BOMB_PLACED_ACK: "bomb-placed-ack", PLAYER_HIT: "player-hit", + HURRICANE_HIT: "hurricane-hit", // 時間同期・ゲーム進行関連 PING: "ping",