-
+
{/* タイマー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",