diff --git a/apps/client/src/config/index.ts b/apps/client/src/config/index.ts index a786100..8cc2c84 100644 --- a/apps/client/src/config/index.ts +++ b/apps/client/src/config/index.ts @@ -8,8 +8,17 @@ TIMER_DISPLAY_UPDATE_MS: 250, JOIN_REQUEST_TIMEOUT_MS: 8000, + FRAME_DELTA_MAX_MS: 50, + PLAYER_LERP_SMOOTHNESS: 18, PLAYER_LERP_SNAP_THRESHOLD: 0.005, + PLAYER_HIT_EFFECT: { + BLINK_DURATION_MS: sharedConfig.GAME_CONFIG.PLAYER_HIT_STUN_MS, + BLINK_INTERVAL_MS: 100, + BLINK_HIDDEN_ALPHA: 0.2, + BLINK_MAX_DELTA_MS: 50, + DEDUP_WINDOW_MS: 300, + }, GRID_CELL_SIZE: 100, diff --git a/apps/client/src/scenes/game/GameManager.ts b/apps/client/src/scenes/game/GameManager.ts index 74e558c..fd96be4 100644 --- a/apps/client/src/scenes/game/GameManager.ts +++ b/apps/client/src/scenes/game/GameManager.ts @@ -19,6 +19,7 @@ import { BombHitContextProvider } from "./application/BombHitContextProvider"; import { BombHitOrchestrator } from "./application/BombHitOrchestrator"; import { PlayerDeathPolicy } from "./application/PlayerDeathPolicy"; +import { PlayerHitEffectOrchestrator } from "./application/PlayerHitEffectOrchestrator"; import type { BombHitEvaluationResult } from "./application/BombHitOrchestrator"; import type { GamePlayers } from "./application/game.types"; @@ -36,7 +37,8 @@ private bombHitOrchestrator: BombHitOrchestrator | null = null; private networkSync: GameNetworkSync | null = null; private gameLoop: GameLoop | null = null; - private playerDeathPolicy: PlayerDeathPolicy; + private playerDeathPolicy!: PlayerDeathPolicy; + private playerHitEffectOrchestrator!: PlayerHitEffectOrchestrator; private reportedBombHitIds = new Set(); // サーバーからゲーム開始通知(と開始時刻)を受け取った時に呼ぶ @@ -106,11 +108,21 @@ this.app = new Application(); this.worldContainer = new Container(); this.worldContainer.sortableChildren = true; + this.initializeHitSubsystem(); + } + + /** 被弾時の入力制御と演出発火のサブシステムを初期化する */ + private initializeHitSubsystem(): void { this.playerDeathPolicy = new PlayerDeathPolicy({ myId: this.myId, hitStunMs: config.GAME_CONFIG.PLAYER_HIT_STUN_MS, acquireInputLock: this.lockInput.bind(this), }); + this.playerHitEffectOrchestrator = new PlayerHitEffectOrchestrator({ + players: this.players, + blinkDurationMs: config.GAME_CONFIG.PLAYER_HIT_EFFECT.BLINK_DURATION_MS, + dedupWindowMs: config.GAME_CONFIG.PLAYER_HIT_EFFECT.DEDUP_WINDOW_MS, + }); } /** @@ -189,9 +201,7 @@ }, onPlayerDeadFromNetwork: (payload) => { this.playerDeathPolicy.applyPlayerDeadEvent(payload); - if (payload.playerId !== this.myId) { - this.playBombHitBlink(payload.playerId); - } + this.playerHitEffectOrchestrator.handleNetworkPlayerDead(payload.playerId, this.myId); }, }); this.networkSync.bind(); @@ -246,20 +256,11 @@ } this.playerDeathPolicy.applyLocalHitStun(); - this.playBombHitBlink(this.myId); + this.playerHitEffectOrchestrator.handleLocalBombHit(this.myId); socketManager.game.sendBombHitReport({ bombId }); } - private playBombHitBlink(playerId: string): void { - const target = this.players[playerId]; - if (!target) { - return; - } - - target.playBombHitBlink(config.GAME_CONFIG.PLAYER_HIT_STUN_MS); - } - private shouldSendBombHitReport( result: BombHitEvaluationResult | undefined, bombId: string, diff --git a/apps/client/src/scenes/game/application/GameLoop.ts b/apps/client/src/scenes/game/application/GameLoop.ts index b04bdb3..8973ac4 100644 --- a/apps/client/src/scenes/game/application/GameLoop.ts +++ b/apps/client/src/scenes/game/application/GameLoop.ts @@ -4,6 +4,7 @@ * 各 Step を呼び出して更新順序を統制する */ import { Application, Container, Ticker } from "pixi.js"; +import { config } from "@client/config"; import { LocalPlayerController } from "@client/scenes/game/entities/player/PlayerController"; import { BombManager } from "@client/scenes/game/entities/bomb/BombManager"; import type { GamePlayers } from "./game.types"; @@ -11,6 +12,7 @@ import { SimulationStep } from "./loopSteps/SimulationStep"; import { CameraStep } from "./loopSteps/CameraStep"; import { BombStep } from "./loopSteps/BombStep"; +import { resolveFrameDelta } from "./loopSteps/frameDelta"; type GameLoopOptions = { app: Application; @@ -47,7 +49,10 @@ const me = this.players[this.myId]; if (!me || !(me instanceof LocalPlayerController)) return; - const deltaSeconds = ticker.deltaMS / 1000; + const { deltaSeconds } = resolveFrameDelta( + ticker, + config.GAME_CONFIG.FRAME_DELTA_MAX_MS, + ); const { isMoving } = this.inputStep.run({ me, deltaSeconds }); this.simulationStep.run({ @@ -57,7 +62,7 @@ isMoving, }); - this.bombStep.run({ deltaSeconds }); + this.bombStep.run(); this.cameraStep.run({ app: this.app, diff --git a/apps/client/src/scenes/game/application/GameNetworkSync.ts b/apps/client/src/scenes/game/application/GameNetworkSync.ts index 08d62ef..fcda1fe 100644 --- a/apps/client/src/scenes/game/application/GameNetworkSync.ts +++ b/apps/client/src/scenes/game/application/GameNetworkSync.ts @@ -41,73 +41,17 @@ unbind: () => void; }; -type SubscriptionHandlers = { - handleCurrentPlayers: (serverPlayers: CurrentPlayersPayload) => void; - handleNewPlayer: (player: NewPlayerPayload) => void; - handleGameStart: (data: GameStartPayload) => void; - handlePlayerUpdates: (players: UpdatePlayersPayload) => void; - handleRemovePlayer: (id: RemovePlayerPayload) => void; - handleUpdateMapCells: (updates: UpdateMapCellsPayload) => void; - handleGameEnd: () => void; - handleBombPlaced: (payload: BombPlacedPayload) => void; - handleBombPlacedAck: (payload: BombPlacedAckPayload) => void; - handlePlayerDead: (payload: PlayerDeadPayload) => void; -}; - -const createSocketSubscriptions = ({ - handleCurrentPlayers, - handleNewPlayer, - handleGameStart, - handlePlayerUpdates, - handleRemovePlayer, - handleUpdateMapCells, - handleGameEnd, - handleBombPlaced, - handleBombPlacedAck, - handlePlayerDead, -}: SubscriptionHandlers): SocketSubscription[] => { - return [ - { - bind: () => socketManager.game.onCurrentPlayers(handleCurrentPlayers), - unbind: () => socketManager.game.offCurrentPlayers(handleCurrentPlayers), - }, - { - bind: () => socketManager.game.onNewPlayer(handleNewPlayer), - unbind: () => socketManager.game.offNewPlayer(handleNewPlayer), - }, - { - bind: () => socketManager.game.onGameStart(handleGameStart), - unbind: () => socketManager.game.offGameStart(handleGameStart), - }, - { - bind: () => socketManager.game.onUpdatePlayers(handlePlayerUpdates), - unbind: () => socketManager.game.offUpdatePlayers(handlePlayerUpdates), - }, - { - bind: () => socketManager.game.onRemovePlayer(handleRemovePlayer), - unbind: () => socketManager.game.offRemovePlayer(handleRemovePlayer), - }, - { - bind: () => socketManager.game.onUpdateMapCells(handleUpdateMapCells), - unbind: () => socketManager.game.offUpdateMapCells(handleUpdateMapCells), - }, - { - bind: () => socketManager.game.onGameEnd(handleGameEnd), - unbind: () => socketManager.game.offGameEnd(handleGameEnd), - }, - { - bind: () => socketManager.game.onBombPlaced(handleBombPlaced), - unbind: () => socketManager.game.offBombPlaced(handleBombPlaced), - }, - { - bind: () => socketManager.game.onBombPlacedAck(handleBombPlacedAck), - unbind: () => socketManager.game.offBombPlacedAck(handleBombPlacedAck), - }, - { - bind: () => socketManager.game.onPlayerDead(handlePlayerDead), - unbind: () => socketManager.game.offPlayerDead(handlePlayerDead), - }, - ]; +type SocketSubscriptionDictionary = { + currentPlayers: SocketSubscription; + newPlayer: SocketSubscription; + gameStart: SocketSubscription; + updatePlayers: SocketSubscription; + removePlayer: SocketSubscription; + updateMapCells: SocketSubscription; + gameEnd: SocketSubscription; + bombPlaced: SocketSubscription; + bombPlacedAck: SocketSubscription; + playerDead: SocketSubscription; }; /** ゲーム中のネットワークイベント購読と同期処理を管理する */ @@ -122,8 +66,54 @@ private onBombPlacedFromOthers: (payload: BombPlacedPayload) => void; private onBombPlacedAckFromNetwork: (payload: BombPlacedAckPayload) => void; private onPlayerDeadFromNetwork: (payload: PlayerDeadPayload) => void; - private socketSubscriptions: SocketSubscription[]; + private socketSubscriptions: SocketSubscriptionDictionary; private isBound = false; + /** ソケット購読の bind/unbind を辞書形式で構築する */ + private createSocketSubscriptions(): SocketSubscriptionDictionary { + return { + currentPlayers: { + bind: () => socketManager.game.onCurrentPlayers(this.handleCurrentPlayers), + unbind: () => socketManager.game.offCurrentPlayers(this.handleCurrentPlayers), + }, + newPlayer: { + bind: () => socketManager.game.onNewPlayer(this.handleNewPlayer), + unbind: () => socketManager.game.offNewPlayer(this.handleNewPlayer), + }, + gameStart: { + bind: () => socketManager.game.onGameStart(this.handleGameStart), + unbind: () => socketManager.game.offGameStart(this.handleGameStart), + }, + updatePlayers: { + bind: () => socketManager.game.onUpdatePlayers(this.handlePlayerUpdates), + unbind: () => socketManager.game.offUpdatePlayers(this.handlePlayerUpdates), + }, + removePlayer: { + bind: () => socketManager.game.onRemovePlayer(this.handleRemovePlayer), + unbind: () => socketManager.game.offRemovePlayer(this.handleRemovePlayer), + }, + updateMapCells: { + bind: () => socketManager.game.onUpdateMapCells(this.handleUpdateMapCells), + unbind: () => socketManager.game.offUpdateMapCells(this.handleUpdateMapCells), + }, + gameEnd: { + bind: () => socketManager.game.onGameEnd(this.handleGameEnd), + unbind: () => socketManager.game.offGameEnd(this.handleGameEnd), + }, + bombPlaced: { + bind: () => socketManager.game.onBombPlaced(this.handleBombPlaced), + unbind: () => socketManager.game.offBombPlaced(this.handleBombPlaced), + }, + bombPlacedAck: { + bind: () => socketManager.game.onBombPlacedAck(this.handleBombPlacedAck), + unbind: () => socketManager.game.offBombPlacedAck(this.handleBombPlacedAck), + }, + playerDead: { + bind: () => socketManager.game.onPlayerDead(this.handlePlayerDead), + unbind: () => socketManager.game.offPlayerDead(this.handlePlayerDead), + }, + }; + } + private debugLog = (message: string) => { if (!ENABLE_DEBUG_LOG) { @@ -217,24 +207,13 @@ this.onBombPlacedFromOthers = onBombPlacedFromOthers; this.onBombPlacedAckFromNetwork = onBombPlacedAckFromNetwork; this.onPlayerDeadFromNetwork = onPlayerDeadFromNetwork; - this.socketSubscriptions = createSocketSubscriptions({ - handleCurrentPlayers: this.handleCurrentPlayers, - handleNewPlayer: this.handleNewPlayer, - handleGameStart: this.handleGameStart, - handlePlayerUpdates: this.handlePlayerUpdates, - handleRemovePlayer: this.handleRemovePlayer, - handleUpdateMapCells: this.handleUpdateMapCells, - handleGameEnd: this.handleGameEnd, - handleBombPlaced: this.handleBombPlaced, - handleBombPlacedAck: this.handleBombPlacedAck, - handlePlayerDead: this.handlePlayerDead, - }); + this.socketSubscriptions = this.createSocketSubscriptions(); } public bind() { if (this.isBound) return; - this.socketSubscriptions.forEach((subscription) => { + Object.values(this.socketSubscriptions).forEach((subscription) => { subscription.bind(); }); @@ -244,7 +223,7 @@ public unbind() { if (!this.isBound) return; - this.socketSubscriptions.forEach((subscription) => { + Object.values(this.socketSubscriptions).forEach((subscription) => { subscription.unbind(); }); diff --git a/apps/client/src/scenes/game/application/PlayerHitEffectOrchestrator.ts b/apps/client/src/scenes/game/application/PlayerHitEffectOrchestrator.ts new file mode 100644 index 0000000..8ffe11c --- /dev/null +++ b/apps/client/src/scenes/game/application/PlayerHitEffectOrchestrator.ts @@ -0,0 +1,104 @@ +/** + * PlayerHitEffectOrchestrator + * 被弾時のプレイヤー演出発火を管理する + * ローカル被弾とネットワーク通知被弾を同じ窓口で扱う + */ +import type { GamePlayers } from "./game.types"; +import type { + PlayerHitEffectEventName, + PlayerHitEffectEventPayloadMap, +} from "@repo/shared"; + +type PlayerHitEffectOrchestratorOptions = { + players: GamePlayers; + blinkDurationMs: number; + dedupWindowMs: number; + nowMsProvider?: () => number; +}; + +/** 被弾演出イベント入力を表す型 */ +type PlayerHitEffectEvent = { + name: TEventName; + payload: PlayerHitEffectEventPayloadMap[TEventName]; +}; + +/** 被弾演出の発火責務を管理するオーケストレーター */ +export class PlayerHitEffectOrchestrator { + private readonly players: GamePlayers; + private readonly blinkDurationMs: number; + private readonly dedupWindowMs: number; + private readonly nowMsProvider: () => number; + private readonly lastTriggeredAtByPlayerId = new Map(); + + constructor({ + players, + blinkDurationMs, + dedupWindowMs, + nowMsProvider = () => performance.now(), + }: PlayerHitEffectOrchestratorOptions) { + this.players = players; + this.blinkDurationMs = blinkDurationMs; + this.dedupWindowMs = dedupWindowMs; + this.nowMsProvider = nowMsProvider; + } + + /** ローカル被弾時の点滅演出を発火する */ + public handleLocalBombHit(localPlayerId: string): void { + this.dispatch({ + name: "local-bomb-hit", + payload: { + playerId: localPlayerId, + localPlayerId, + }, + }); + } + + /** ネットワーク通知の被弾時に必要な点滅演出を発火する */ + public handleNetworkPlayerDead(playerId: string, localPlayerId: string): void { + this.dispatch({ + name: "network-player-dead", + payload: { + playerId, + localPlayerId, + }, + }); + } + + /** 被弾演出イベント名に応じて処理を分岐する */ + public dispatch(event: PlayerHitEffectEvent): void { + if ( + event.name === "network-player-dead" + && event.payload.playerId === event.payload.localPlayerId + ) { + return; + } + + if (!this.shouldTrigger(event.payload.playerId)) { + return; + } + + this.playBombHitBlink(event.payload.playerId); + } + + /** 指定プレイヤーへ被弾点滅演出を適用する */ + private playBombHitBlink(playerId: string): void { + const target = this.players[playerId]; + if (!target) { + return; + } + + target.playBombHitBlink(this.blinkDurationMs); + } + + /** 同一プレイヤーへの短時間重複発火を抑止する */ + private shouldTrigger(playerId: string): boolean { + const nowMs = this.nowMsProvider(); + const lastTriggeredAt = this.lastTriggeredAtByPlayerId.get(playerId); + if (lastTriggeredAt !== undefined && nowMs - lastTriggeredAt < this.dedupWindowMs) { + return false; + } + + this.lastTriggeredAtByPlayerId.set(playerId, nowMs); + return true; + } +} \ No newline at end of file diff --git a/apps/client/src/scenes/game/application/loopSteps/BombStep.ts b/apps/client/src/scenes/game/application/loopSteps/BombStep.ts index 38c9d0f..f7d9d71 100644 --- a/apps/client/src/scenes/game/application/loopSteps/BombStep.ts +++ b/apps/client/src/scenes/game/application/loopSteps/BombStep.ts @@ -10,11 +10,6 @@ bombManager: BombManager; }; -/** 爆弾更新段の実行入力 */ -type BombStepParams = { - deltaSeconds: number; -}; - /** 爆弾更新処理を担うステップ */ export class BombStep { private bombManager: BombManager; @@ -23,8 +18,8 @@ this.bombManager = bombManager; } - /** 爆弾更新を実行する */ - public run(_params: BombStepParams): void { + /** 爆弾更新を実行する,時間管理は GameTimer 由来の経過時刻を利用する */ + public run(): void { this.bombManager.tick(); } } diff --git a/apps/client/src/scenes/game/application/loopSteps/frameDelta.ts b/apps/client/src/scenes/game/application/loopSteps/frameDelta.ts new file mode 100644 index 0000000..a9db61c --- /dev/null +++ b/apps/client/src/scenes/game/application/loopSteps/frameDelta.ts @@ -0,0 +1,21 @@ +/** + * frameDelta + * フレーム更新で利用する時間差分を正規化する + * clamp を適用して処理系全体の時間進行を安定化する + */ +import type { Ticker } from "pixi.js"; + +/** フレーム更新で利用する時間差分を表す型 */ +export type FrameDelta = { + deltaMs: number; + deltaSeconds: number; +}; + +/** Ticker から clamp 済みのフレーム時間差分を生成する */ +export const resolveFrameDelta = (ticker: Ticker, maxDeltaMs: number): FrameDelta => { + const boundedDeltaMs = Math.min(Math.max(ticker.deltaMS, 0), maxDeltaMs); + return { + deltaMs: boundedDeltaMs, + deltaSeconds: boundedDeltaMs / 1000, + }; +}; diff --git a/apps/client/src/scenes/game/entities/bomb/BombHitBlinkRenderer.ts b/apps/client/src/scenes/game/entities/bomb/BombHitBlinkRenderer.ts index 570f959..964aa70 100644 --- a/apps/client/src/scenes/game/entities/bomb/BombHitBlinkRenderer.ts +++ b/apps/client/src/scenes/game/entities/bomb/BombHitBlinkRenderer.ts @@ -3,57 +3,66 @@ * 爆弾被弾時の点滅演出を描画オブジェクトへ適用する * 点滅開始,停止,破棄を一元管理する */ -import type { Container } from "pixi.js"; +import { Ticker, type Container } from "pixi.js"; type BombHitBlinkRendererOptions = { target: Container; + ticker?: Ticker; blinkIntervalMs?: number; hiddenAlpha?: number; + maxDeltaMs?: number; }; /** 爆弾被弾時の点滅演出を制御するレンダラー */ export class BombHitBlinkRenderer { private readonly target: Container; + private readonly ticker: Ticker; private readonly blinkIntervalMs: number; private readonly hiddenAlpha: number; - private blinkIntervalId: ReturnType | null = null; - private stopTimeoutId: ReturnType | null = null; + private readonly maxDeltaMs: number; private isVisibleFrame = true; + private isPlaying = false; + private remainingDurationMs = 0; + private elapsedSinceToggleMs = 0; - constructor({ target, blinkIntervalMs = 100, hiddenAlpha = 0.2 }: BombHitBlinkRendererOptions) { + constructor({ + target, + ticker = Ticker.shared, + blinkIntervalMs = 100, + hiddenAlpha = 0.2, + maxDeltaMs = 50, + }: BombHitBlinkRendererOptions) { this.target = target; + this.ticker = ticker; this.blinkIntervalMs = blinkIntervalMs; this.hiddenAlpha = hiddenAlpha; + this.maxDeltaMs = maxDeltaMs; } /** 指定時間だけ点滅演出を再生する */ public play(durationMs: number): void { this.stop(); + if (durationMs <= 0) { + return; + } + + this.isPlaying = true; + this.remainingDurationMs = durationMs; + this.elapsedSinceToggleMs = 0; this.isVisibleFrame = true; this.target.alpha = 1; - - this.blinkIntervalId = setInterval(() => { - this.isVisibleFrame = !this.isVisibleFrame; - this.target.alpha = this.isVisibleFrame ? 1 : this.hiddenAlpha; - }, this.blinkIntervalMs); - - this.stopTimeoutId = setTimeout(() => { - this.stop(); - }, durationMs); + this.ticker.add(this.handleTick); } /** 点滅演出を停止し描画状態を初期化する */ public stop(): void { - if (this.blinkIntervalId) { - clearInterval(this.blinkIntervalId); - this.blinkIntervalId = null; + if (this.isPlaying) { + this.ticker.remove(this.handleTick); } - if (this.stopTimeoutId) { - clearTimeout(this.stopTimeoutId); - this.stopTimeoutId = null; - } - + this.isPlaying = false; + this.remainingDurationMs = 0; + this.elapsedSinceToggleMs = 0; this.isVisibleFrame = true; this.target.alpha = 1; } @@ -62,4 +71,24 @@ public destroy(): void { this.stop(); } + + private handleTick = (ticker: Ticker): void => { + if (!this.isPlaying) { + return; + } + + const deltaMs = Math.min(ticker.deltaMS, this.maxDeltaMs); + this.remainingDurationMs -= deltaMs; + this.elapsedSinceToggleMs += deltaMs; + + while (this.elapsedSinceToggleMs >= this.blinkIntervalMs) { + this.elapsedSinceToggleMs -= this.blinkIntervalMs; + this.isVisibleFrame = !this.isVisibleFrame; + this.target.alpha = this.isVisibleFrame ? 1 : this.hiddenAlpha; + } + + if (this.remainingDurationMs <= 0) { + this.stop(); + } + }; } \ No newline at end of file diff --git a/apps/client/src/scenes/game/entities/bomb/BombIdRegistry.ts b/apps/client/src/scenes/game/entities/bomb/BombIdRegistry.ts new file mode 100644 index 0000000..5006495 --- /dev/null +++ b/apps/client/src/scenes/game/entities/bomb/BombIdRegistry.ts @@ -0,0 +1,57 @@ +/** + * BombIdRegistry + * 爆弾の requestId と tempBombId の採番と対応を管理する + * ACK 反映時の ID 解決と後始末を一元管理する + */ +import { PendingBombRequestStore } from "./PendingBombRequestStore"; + +/** 爆弾の仮ID発行結果を表す型 */ +export type IssuedPendingBombId = { + requestId: string; + tempBombId: string; +}; + +/** 爆弾ID関連の採番と対応管理を担うレジストリ */ +export class BombIdRegistry { + private pendingStore = new PendingBombRequestStore(); + private requestSerial = 0; + + /** 新しい requestId と tempBombId を発行して登録する */ + public issuePendingOwnBombId(): IssuedPendingBombId { + const requestId = this.createRequestId(); + const tempBombId = this.createTempBombId(requestId); + this.pendingStore.register(requestId, tempBombId); + return { requestId, tempBombId }; + } + + /** requestId から tempBombId を解決する */ + public resolveTempBombIdByRequestId(requestId: string): string | undefined { + return this.pendingStore.getTempBombIdByRequestId(requestId); + } + + /** requestId 起点で pending 対応を削除する */ + public removeByRequestId(requestId: string): void { + this.pendingStore.removeByRequestId(requestId); + } + + /** bombId 起点で pending 対応を削除する */ + public removeByBombId(bombId: string): void { + this.pendingStore.removeByTempBombId(bombId); + } + + /** すべての pending 対応を削除する */ + public clear(): void { + this.pendingStore.clear(); + } + + /** requestId を採番して文字列化する */ + private createRequestId(): string { + this.requestSerial += 1; + return `${this.requestSerial}`; + } + + /** requestId から tempBombId を構築する */ + private createTempBombId(requestId: string): string { + return `temp:${requestId}`; + } +} diff --git a/apps/client/src/scenes/game/entities/bomb/BombManager.ts b/apps/client/src/scenes/game/entities/bomb/BombManager.ts index a9121bd..1f4b9cb 100644 --- a/apps/client/src/scenes/game/entities/bomb/BombManager.ts +++ b/apps/client/src/scenes/game/entities/bomb/BombManager.ts @@ -14,7 +14,7 @@ import { LocalPlayerController } from "@client/scenes/game/entities/player/PlayerController"; import { AppearanceResolver } from "@client/scenes/game/application/AppearanceResolver"; import { BombController } from "./BombController"; -import { PendingBombRequestStore } from "./PendingBombRequestStore"; +import { BombIdRegistry } from "./BombIdRegistry"; import type { GamePlayers } from "@client/scenes/game/application/game.types"; /** 経過時間ミリ秒を返す関数型 */ @@ -63,9 +63,8 @@ private appearanceResolver: AppearanceResolver; private bombs = new Map(); private bombRenderPayloadById = new Map(); - private pendingBombRequestStore = new PendingBombRequestStore(); + private bombIdRegistry = new BombIdRegistry(); private lastBombPlacedElapsedMs = Number.NEGATIVE_INFINITY; - private requestSerial = 0; private onBombExploded?: (payload: BombExplodedPayload) => void; constructor({ worldContainer, players, myId, getElapsedMs, appearanceResolver, onBombExploded }: BombManagerOptions) { @@ -89,18 +88,16 @@ } const position = me.getPosition(); - const requestId = this.createRequestId(elapsedMs); + const { requestId, tempBombId } = this.bombIdRegistry.issuePendingOwnBombId(); const payload: PlaceBombPayload = { requestId, x: position.x, y: position.y, explodeAtElapsedMs: elapsedMs + BOMB_FUSE_MS, }; - const tempBombId = this.createTempBombId(requestId); // 自分の爆弾は設置時点で teamId を確定して保持する const ownTeamId = this.resolveTeamIdBySocketId(this.myId); - this.pendingBombRequestStore.register(requestId, tempBombId); this.upsertBomb(tempBombId, this.toRenderPayload(payload, ownTeamId)); this.lastBombPlacedElapsedMs = elapsedMs; return { @@ -118,13 +115,13 @@ /** 設置者本人向けACKを反映し,仮IDから正式IDへ置換する */ public applyPlacedBombAck(payload: BombPlacedAckPayload): void { - const tempBombId = this.pendingBombRequestStore.getTempBombIdByRequestId(payload.requestId); + const tempBombId = this.bombIdRegistry.resolveTempBombIdByRequestId(payload.requestId); if (!tempBombId) { return; } const tempPayload = this.bombRenderPayloadById.get(tempBombId); - this.pendingBombRequestStore.removeByRequestId(payload.requestId); + this.bombIdRegistry.removeByRequestId(payload.requestId); if (!tempPayload || tempBombId === payload.bombId) { return; } @@ -161,7 +158,7 @@ bomb.destroy(); this.bombs.delete(bombId); this.bombRenderPayloadById.delete(bombId); - this.pendingBombRequestStore.removeByTempBombId(bombId); + this.bombIdRegistry.removeByBombId(bombId); } /** 爆弾状態を更新し終了済みを破棄する */ @@ -195,7 +192,7 @@ this.bombs.forEach((bomb) => bomb.destroy()); this.bombs.clear(); this.bombRenderPayloadById.clear(); - this.pendingBombRequestStore.clear(); + this.bombIdRegistry.clear(); } private isSameRenderPayload(a: BombRenderPayload, b: BombRenderPayload): boolean { @@ -231,12 +228,4 @@ return playerController.getSnapshot().teamId; } - private createRequestId(_elapsedMs: number): string { - this.requestSerial += 1; - return `${this.requestSerial}`; - } - - private createTempBombId(requestId: string): string { - return `temp:${requestId}`; - } } \ No newline at end of file diff --git a/apps/client/src/scenes/game/entities/player/PlayerController.ts b/apps/client/src/scenes/game/entities/player/PlayerController.ts index 74bce98..3543d25 100644 --- a/apps/client/src/scenes/game/entities/player/PlayerController.ts +++ b/apps/client/src/scenes/game/entities/player/PlayerController.ts @@ -4,6 +4,7 @@ * ローカル入力適用,リモート更新適用,描画同期を分離して扱う */ import type { playerTypes } from "@repo/shared"; +import { config } from "@client/config"; import { AppearanceResolver } from "@client/scenes/game/application/AppearanceResolver"; import { BombHitBlinkRenderer } from "@client/scenes/game/entities/bomb/BombHitBlinkRenderer"; import { PlayerModel } from "./PlayerModel"; @@ -41,6 +42,9 @@ ); this.bombHitBlinkRenderer = new BombHitBlinkRenderer({ target: this.view.displayObject, + blinkIntervalMs: config.GAME_CONFIG.PLAYER_HIT_EFFECT.BLINK_INTERVAL_MS, + hiddenAlpha: config.GAME_CONFIG.PLAYER_HIT_EFFECT.BLINK_HIDDEN_ALPHA, + maxDeltaMs: config.GAME_CONFIG.PLAYER_HIT_EFFECT.BLINK_MAX_DELTA_MS, }); const pos = this.model.getPosition(); diff --git a/packages/shared/src/protocol/eventPayloads.ts b/packages/shared/src/protocol/eventPayloads.ts index 471bbf7..e444c18 100644 --- a/packages/shared/src/protocol/eventPayloads.ts +++ b/packages/shared/src/protocol/eventPayloads.ts @@ -34,3 +34,12 @@ GameResultPayload, GameResultRanking, } from "./payloads/gamePayloads"; + +/** 被弾演出イベントのペイロード型を再公開する */ +export type { + LocalBombHitEffectPayload, + NetworkPlayerDeadEffectPayload, + PlayerHitEffectEventPayloadMap, + PlayerHitEffectEventName, + PlayerHitEffectPayloadOf, +} from "./payloads/playerHitEffectPayloads"; diff --git a/packages/shared/src/protocol/payloads/playerHitEffectPayloads.ts b/packages/shared/src/protocol/payloads/playerHitEffectPayloads.ts new file mode 100644 index 0000000..728add1 --- /dev/null +++ b/packages/shared/src/protocol/payloads/playerHitEffectPayloads.ts @@ -0,0 +1,30 @@ +/** + * playerHitEffectPayloads + * プレイヤー被弾演出で利用するイベント型を定義する + * クライアントとサーバーで共有する語彙を提供する + */ + +/** ローカル被弾演出イベントのペイロード型 */ +export type LocalBombHitEffectPayload = { + playerId: string; + localPlayerId: string; +}; + +/** ネットワーク通知由来の被弾演出イベントのペイロード型 */ +export type NetworkPlayerDeadEffectPayload = { + playerId: string; + localPlayerId: string; +}; + +/** 被弾演出イベント名ごとのペイロード対応表 */ +export type PlayerHitEffectEventPayloadMap = { + "local-bomb-hit": LocalBombHitEffectPayload; + "network-player-dead": NetworkPlayerDeadEffectPayload; +}; + +/** 被弾演出イベント名を表す型 */ +export type PlayerHitEffectEventName = keyof PlayerHitEffectEventPayloadMap; + +/** 被弾演出イベント名からペイロード型を取得するユーティリティ型 */ +export type PlayerHitEffectPayloadOf = + PlayerHitEffectEventPayloadMap[TEvent];