diff --git a/apps/client/src/scenes/game/GameManager.ts b/apps/client/src/scenes/game/GameManager.ts
index a250d36..861ead2 100644
--- a/apps/client/src/scenes/game/GameManager.ts
+++ b/apps/client/src/scenes/game/GameManager.ts
@@ -8,6 +8,7 @@
import { SceneLifecycleState } from "./application/lifecycle/SceneLifecycleState";
import { GameSessionFacade } from "./application/lifecycle/GameSessionFacade";
import { CombatLifecycleFacade } from "./application/combat/CombatLifecycleFacade";
+import { DisposableRegistry } from "./application/lifecycle/DisposableRegistry";
import {
type GameSceneFactoryOptions,
} from "./application/orchestrators/GameSceneOrchestrator";
@@ -21,6 +22,10 @@
type MoveSender,
} from "./application/network/PlayerMoveSender";
import type { GamePlayers } from "./application/game.types";
+import {
+ GameUiStateSyncService,
+ type GameUiState,
+} from "./application/ui/GameUiStateSyncService";
/** GameManager の依存注入オプション型 */
export type GameManagerDependencies = {
@@ -31,12 +36,8 @@
sceneFactories?: GameSceneFactoryOptions;
};
-/** GameScene の UI 表示に必要な状態 */
-export type GameUiState = {
- remainingTimeSec: number;
- startCountdownSec: number;
- isInputEnabled: boolean;
-};
+/** GameScene の UI 表示状態型を外部参照向けに再公開する */
+export type { GameUiState } from "./application/ui/GameUiStateSyncService";
/** ゲームシーンの実行ライフサイクルを管理するマネージャー */
export class GameManager {
@@ -50,8 +51,8 @@
private gameEventFacade: GameEventFacade;
private combatFacade: CombatLifecycleFacade;
private lifecycleState: SceneLifecycleState;
- private uiStateListeners = new Set<(state: GameUiState) => void>();
- private lastUiState: GameUiState | null = null;
+ private uiStateSyncService: GameUiStateSyncService;
+ private disposableRegistry: DisposableRegistry;
public getStartCountdownSec(): number {
return this.sessionFacade.getStartCountdownSec();
@@ -73,8 +74,12 @@
public lockInput(): () => void {
this.runtime.clearJoystickInput();
const release = this.sessionFacade.lockInput();
- this.emitUiStateIfChanged(true);
- return release;
+ this.uiStateSyncService.emitIfChanged();
+
+ return () => {
+ release();
+ this.uiStateSyncService.emitIfChanged();
+ };
}
constructor(
@@ -95,6 +100,7 @@
this.gameEventFacade = new GameEventFacade({
onGameStart: (startTime) => {
this.sessionFacade.setGameStart(startTime);
+ this.uiStateSyncService.emitIfChanged();
},
getBombManager: () => this.runtime.getBombManager(),
});
@@ -115,22 +121,51 @@
gameActionSender,
moveSender,
getElapsedMs: () => this.sessionFacade.getElapsedMs(),
- onGameStart: this.gameEventFacade.handleGameStart.bind(this.gameEventFacade),
- onGameEnd: this.lockInput.bind(this),
- onBombPlacedFromOthers: (payload) => {
- this.gameEventFacade.handleBombPlacedFromOthers(payload);
- },
- onBombPlacedAckFromNetwork: (payload) => {
- this.gameEventFacade.handleBombPlacedAck(payload);
- },
- onPlayerDeadFromNetwork: (payload) => {
- this.combatFacade.handleNetworkPlayerDead(payload);
- },
- onBombExploded: (payload) => {
- this.combatFacade.handleBombExploded(payload);
+ eventPorts: {
+ onGameStart: this.gameEventFacade.handleGameStart.bind(this.gameEventFacade),
+ onGameEnd: this.lockInput.bind(this),
+ onBombPlacedFromOthers: (payload) => {
+ this.gameEventFacade.handleBombPlacedFromOthers(payload);
+ },
+ onBombPlacedAckFromNetwork: (payload) => {
+ this.gameEventFacade.handleBombPlacedAck(payload);
+ },
+ onPlayerDeadFromNetwork: (payload) => {
+ this.combatFacade.handleNetworkPlayerDead(payload);
+ },
+ onBombExploded: (payload) => {
+ this.combatFacade.handleBombExploded(payload);
+ },
},
sceneFactories,
});
+ this.uiStateSyncService = new GameUiStateSyncService({
+ getSnapshot: () => this.getUiStateSnapshot(),
+ });
+ this.disposableRegistry = new DisposableRegistry();
+ this.disposableRegistry.add(() => {
+ this.uiStateSyncService.clear();
+ });
+ this.disposableRegistry.add(() => {
+ this.players = {};
+ });
+ this.disposableRegistry.add(() => {
+ this.sessionFacade.reset();
+ });
+ this.disposableRegistry.add(() => {
+ this.combatFacade.dispose();
+ });
+ this.disposableRegistry.add(() => {
+ this.runtime.destroy();
+ });
+ this.disposableRegistry.add(() => {
+ if (this.lifecycleState.shouldDestroyApp()) {
+ this.app.destroy(true, { children: true });
+ }
+ });
+ this.disposableRegistry.add(() => {
+ this.uiStateSyncService.stopTicker();
+ });
}
/**
@@ -160,7 +195,8 @@
// メインループの登録
this.app.ticker.add(this.tick);
this.lifecycleState.markInitialized();
- this.emitUiStateIfChanged(true);
+ this.uiStateSyncService.startTicker();
+ this.uiStateSyncService.emitIfChanged(true);
}
/**
@@ -175,17 +211,11 @@
*/
private tick = (ticker: Ticker) => {
this.runtime.tick(ticker);
- this.emitUiStateIfChanged();
};
/** UI状態購読を登録し,解除関数を返す */
public subscribeUiState(listener: (state: GameUiState) => void): () => void {
- this.uiStateListeners.add(listener);
- listener(this.getUiStateSnapshot());
-
- return () => {
- this.uiStateListeners.delete(listener);
- };
+ return this.uiStateSyncService.subscribe(listener);
}
private getUiStateSnapshot(): GameUiState {
@@ -196,41 +226,11 @@
};
}
- private emitUiStateIfChanged(force = false): void {
- if (this.uiStateListeners.size === 0 && !force) {
- return;
- }
-
- const snapshot = this.getUiStateSnapshot();
- if (
- !force
- && this.lastUiState
- && this.lastUiState.remainingTimeSec === snapshot.remainingTimeSec
- && this.lastUiState.startCountdownSec === snapshot.startCountdownSec
- && this.lastUiState.isInputEnabled === snapshot.isInputEnabled
- ) {
- return;
- }
-
- this.lastUiState = snapshot;
- this.uiStateListeners.forEach((listener) => {
- listener(snapshot);
- });
- }
-
/**
* クリーンアップ処理(コンポーネントアンマウント時)
*/
public destroy() {
this.lifecycleState.markDestroyed();
- if (this.lifecycleState.shouldDestroyApp()) {
- this.app.destroy(true, { children: true });
- }
- this.runtime.destroy();
- this.combatFacade.dispose();
- this.sessionFacade.reset();
- this.players = {};
- this.uiStateListeners.clear();
- this.lastUiState = null;
+ this.disposableRegistry.disposeAll();
}
}
diff --git a/apps/client/src/scenes/game/GameView.tsx b/apps/client/src/scenes/game/GameView.tsx
index b84cb17..9120496 100644
--- a/apps/client/src/scenes/game/GameView.tsx
+++ b/apps/client/src/scenes/game/GameView.tsx
@@ -4,6 +4,12 @@
* タイマー表示,PixiJSの描画領域,入力UIの配置のみを担当する
*/
import { GameInputOverlay } from "./input/GameInputOverlay";
+import {
+ PIXI_LAYER_STYLE,
+ ROOT_STYLE,
+ START_COUNTDOWN_STYLE,
+ TIMER_STYLE,
+} from "./styles/GameView.styles";
/** 表示と入力に必要なプロパティ */
type Props = {
@@ -15,54 +21,6 @@
onPlaceBomb: () => boolean;
};
-const ROOT_STYLE: React.CSSProperties = {
- width: "100vw",
- height: "100vh",
- overflow: "hidden",
- position: "relative",
- backgroundColor: "#000",
- userSelect: "none",
- WebkitUserSelect: "none",
-};
-
-const TIMER_STYLE: React.CSSProperties = {
- position: "absolute",
- top: "20px",
- left: "50%",
- transform: "translateX(-50%)",
- zIndex: 10,
- color: "white",
- fontSize: "32px",
- fontWeight: "bold",
- textShadow: "2px 2px 4px rgba(0,0,0,0.5)",
- fontFamily: "monospace",
- userSelect: "none",
- WebkitUserSelect: "none",
-};
-
-const PIXI_LAYER_STYLE: React.CSSProperties = {
- position: "absolute",
- top: 0,
- left: 0,
- zIndex: 1,
-};
-
-const START_COUNTDOWN_STYLE: React.CSSProperties = {
- position: "absolute",
- top: "50%",
- left: "50%",
- transform: "translate(-50%, -50%)",
- zIndex: 30,
- color: "white",
- fontSize: "clamp(3rem, 14vw, 8rem)",
- fontWeight: 900,
- textShadow: "0 0 16px rgba(0,0,0,0.85)",
- fontFamily: "monospace",
- userSelect: "none",
- WebkitUserSelect: "none",
- pointerEvents: "none",
-};
-
const TimerOverlay = ({ timeLeft }: { timeLeft: string }) => (
{timeLeft}
);
diff --git a/apps/client/src/scenes/game/application/GameLoop.ts b/apps/client/src/scenes/game/application/GameLoop.ts
index 1ac43ba..74d807f 100644
--- a/apps/client/src/scenes/game/application/GameLoop.ts
+++ b/apps/client/src/scenes/game/application/GameLoop.ts
@@ -12,6 +12,7 @@
import { SimulationStep } from "./loopSteps/SimulationStep";
import { CameraStep } from "./loopSteps/CameraStep";
import { BombStep } from "./loopSteps/BombStep";
+import type { LoopFrameContext, LoopStep } from "./loopSteps/LoopStep";
import { resolveFrameDelta } from "./loopSteps/frameDelta";
import type { MoveSender } from "./network/PlayerMoveSender";
@@ -35,6 +36,7 @@
private simulationStep: SimulationStep;
private bombStep: BombStep;
private cameraStep: CameraStep;
+ private steps: LoopStep[];
constructor({ app, worldContainer, players, myId, getJoystickInput, bombManager, moveSender }: GameLoopOptions) {
this.app = app;
@@ -47,6 +49,12 @@
});
this.bombStep = new BombStep({ bombManager });
this.cameraStep = new CameraStep();
+ this.steps = [
+ this.inputStep,
+ this.simulationStep,
+ this.bombStep,
+ this.cameraStep,
+ ];
}
public tick = (ticker: Ticker) => {
@@ -57,21 +65,17 @@
ticker,
config.GAME_CONFIG.FRAME_DELTA_MAX_MS,
);
- const { isMoving } = this.inputStep.run({ me, deltaSeconds });
-
- this.simulationStep.run({
- me,
- players: this.players,
- deltaSeconds,
- isMoving,
- });
-
- this.bombStep.run();
-
- this.cameraStep.run({
+ const frameContext: LoopFrameContext = {
app: this.app,
worldContainer: this.worldContainer,
+ players: this.players,
me,
+ deltaSeconds,
+ isMoving: false,
+ };
+
+ this.steps.forEach((step) => {
+ step.run(frameContext);
});
};
}
diff --git a/apps/client/src/scenes/game/application/lifecycle/DisposableRegistry.ts b/apps/client/src/scenes/game/application/lifecycle/DisposableRegistry.ts
new file mode 100644
index 0000000..bf7601b
--- /dev/null
+++ b/apps/client/src/scenes/game/application/lifecycle/DisposableRegistry.ts
@@ -0,0 +1,36 @@
+/**
+ * DisposableRegistry
+ * 破棄処理を登録して一括実行する
+ * 登録順の逆順で実行して依存順の安全なクリーンアップを行う
+ */
+
+/** 後始末として実行する破棄処理の関数型 */
+export type Disposer = () => void;
+
+/** 破棄処理の登録と一括実行を管理するレジストリ */
+export class DisposableRegistry {
+ private disposers: Disposer[] = [];
+
+ /** 破棄処理を登録し登録解除関数を返す */
+ public add(disposer: Disposer): () => void {
+ this.disposers.push(disposer);
+
+ return () => {
+ this.disposers = this.disposers.filter((target) => target !== disposer);
+ };
+ }
+
+ /** 登録済み破棄処理を逆順で実行して登録をクリアする */
+ public disposeAll(): void {
+ for (let index = this.disposers.length - 1; index >= 0; index -= 1) {
+ this.disposers[index]();
+ }
+
+ this.disposers = [];
+ }
+
+ /** 破棄処理を実行せず登録のみをクリアする */
+ public clear(): void {
+ this.disposers = [];
+ }
+}
\ 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 f7d9d71..3166a0c 100644
--- a/apps/client/src/scenes/game/application/loopSteps/BombStep.ts
+++ b/apps/client/src/scenes/game/application/loopSteps/BombStep.ts
@@ -4,6 +4,7 @@
* 爆弾エンティティの時間更新と状態遷移を実行する
*/
import { BombManager } from "@client/scenes/game/entities/bomb/BombManager";
+import type { LoopFrameContext, LoopStep } from "./LoopStep";
/** BombStep の初期化入力 */
type BombStepOptions = {
@@ -11,7 +12,7 @@
};
/** 爆弾更新処理を担うステップ */
-export class BombStep {
+export class BombStep implements LoopStep {
private bombManager: BombManager;
constructor({ bombManager }: BombStepOptions) {
@@ -19,7 +20,7 @@
}
/** 爆弾更新を実行する,時間管理は GameTimer 由来の経過時刻を利用する */
- public run(): void {
+ public run(_context: LoopFrameContext): void {
this.bombManager.tick();
}
}
diff --git a/apps/client/src/scenes/game/application/loopSteps/CameraStep.ts b/apps/client/src/scenes/game/application/loopSteps/CameraStep.ts
index e229842..a72d16f 100644
--- a/apps/client/src/scenes/game/application/loopSteps/CameraStep.ts
+++ b/apps/client/src/scenes/game/application/loopSteps/CameraStep.ts
@@ -5,6 +5,7 @@
*/
import { Application, Container } from "pixi.js";
import { LocalPlayerController } from "@client/scenes/game/entities/player/PlayerController";
+import type { LoopFrameContext, LoopStep } from "./LoopStep";
type CameraStepParams = {
app: Application;
@@ -13,8 +14,16 @@
};
/** カメラ追従更新を担うステップ */
-export class CameraStep {
- public run({ app, worldContainer, me }: CameraStepParams) {
+export class CameraStep implements LoopStep {
+ /** ローカルプレイヤー位置へカメラを追従させる */
+ public run(context: LoopFrameContext): void {
+ const params: CameraStepParams = {
+ app: context.app,
+ worldContainer: context.worldContainer,
+ me: context.me,
+ };
+
+ const { app, worldContainer, me } = params;
const meDisplay = me.getDisplayObject();
worldContainer.position.set(-(meDisplay.x - app.screen.width / 2), -(meDisplay.y - app.screen.height / 2));
}
diff --git a/apps/client/src/scenes/game/application/loopSteps/InputStep.ts b/apps/client/src/scenes/game/application/loopSteps/InputStep.ts
index 515e1c3..5b2ace4 100644
--- a/apps/client/src/scenes/game/application/loopSteps/InputStep.ts
+++ b/apps/client/src/scenes/game/application/loopSteps/InputStep.ts
@@ -4,6 +4,7 @@
* ジョイスティック入力をローカルプレイヤーへ適用する
*/
import { LocalPlayerController } from "@client/scenes/game/entities/player/PlayerController";
+import type { LoopFrameContext, LoopStep } from "./LoopStep";
type InputStepOptions = {
getJoystickInput: () => { x: number; y: number };
@@ -14,19 +15,26 @@
deltaSeconds: number;
};
-type InputStepResult = {
- isMoving: boolean;
-};
-
/** 入力段の更新処理を担うステップ */
-export class InputStep {
+export class InputStep implements LoopStep {
private getJoystickInput: () => { x: number; y: number };
constructor({ getJoystickInput }: InputStepOptions) {
this.getJoystickInput = getJoystickInput;
}
- public run({ me, deltaSeconds }: InputStepParams): InputStepResult {
+ /** 入力文脈を適用して移動状態を更新する */
+ public run(context: LoopFrameContext): void {
+ const params: InputStepParams = {
+ me: context.me,
+ deltaSeconds: context.deltaSeconds,
+ };
+
+ const isMoving = this.applyInput(params);
+ context.isMoving = isMoving;
+ }
+
+ private applyInput({ me, deltaSeconds }: InputStepParams): boolean {
const { x: axisX, y: axisY } = this.getJoystickInput();
const isMoving = axisX !== 0 || axisY !== 0;
@@ -34,6 +42,6 @@
me.applyLocalInput({ axisX, axisY, deltaTime: deltaSeconds });
}
- return { isMoving };
+ return isMoving;
}
}
\ No newline at end of file
diff --git a/apps/client/src/scenes/game/application/loopSteps/LoopStep.ts b/apps/client/src/scenes/game/application/loopSteps/LoopStep.ts
new file mode 100644
index 0000000..f94e9c4
--- /dev/null
+++ b/apps/client/src/scenes/game/application/loopSteps/LoopStep.ts
@@ -0,0 +1,23 @@
+/**
+ * LoopStep
+ * ゲームループの共通ステップ契約を定義する
+ * 各ステップ間で受け渡すフレーム文脈を提供する
+ */
+import type { Application, Container } from "pixi.js";
+import type { LocalPlayerController } from "@client/scenes/game/entities/player/PlayerController";
+import type { GamePlayers } from "@client/scenes/game/application/game.types";
+
+/** 1フレーム分の更新文脈を表す型 */
+export type LoopFrameContext = {
+ app: Application;
+ worldContainer: Container;
+ players: GamePlayers;
+ me: LocalPlayerController;
+ deltaSeconds: number;
+ isMoving: boolean;
+};
+
+/** ゲームループ内で実行されるステップ共通インターフェース */
+export type LoopStep = {
+ run: (context: LoopFrameContext) => void;
+};
\ No newline at end of file
diff --git a/apps/client/src/scenes/game/application/loopSteps/SimulationStep.ts b/apps/client/src/scenes/game/application/loopSteps/SimulationStep.ts
index 92b13b3..2f5a34c 100644
--- a/apps/client/src/scenes/game/application/loopSteps/SimulationStep.ts
+++ b/apps/client/src/scenes/game/application/loopSteps/SimulationStep.ts
@@ -7,6 +7,7 @@
import { LocalPlayerController, RemotePlayerController } from "@client/scenes/game/entities/player/PlayerController";
import type { MoveSender } from "@client/scenes/game/application/network/PlayerMoveSender";
import type { GamePlayers } from "../game.types";
+import type { LoopFrameContext, LoopStep } from "./LoopStep";
/** SimulationStep の初期化入力 */
type SimulationStepOptions = {
@@ -22,7 +23,7 @@
};
/** シミュレーション段の更新処理を担うステップ */
-export class SimulationStep {
+export class SimulationStep implements LoopStep {
private readonly moveSender: MoveSender;
private readonly nowMsProvider: () => number;
private lastPositionSentTime = 0;
@@ -33,9 +34,17 @@
this.nowMsProvider = nowMsProvider;
}
- public run({ me, players, deltaSeconds, isMoving }: SimulationStepParams) {
- this.runLocalSimulation({ me, isMoving });
- this.runRemoteSimulation({ players, deltaSeconds });
+ /** ローカル更新とリモート補間更新を実行する */
+ public run(context: LoopFrameContext): void {
+ const params: SimulationStepParams = {
+ me: context.me,
+ players: context.players,
+ deltaSeconds: context.deltaSeconds,
+ isMoving: context.isMoving,
+ };
+
+ this.runLocalSimulation({ me: params.me, isMoving: params.isMoving });
+ this.runRemoteSimulation({ players: params.players, deltaSeconds: params.deltaSeconds });
}
private runLocalSimulation({ me, isMoving }: Pick) {
diff --git a/apps/client/src/scenes/game/application/orchestrators/GameSceneOrchestrator.ts b/apps/client/src/scenes/game/application/orchestrators/GameSceneOrchestrator.ts
index 8e43682..c670f80 100644
--- a/apps/client/src/scenes/game/application/orchestrators/GameSceneOrchestrator.ts
+++ b/apps/client/src/scenes/game/application/orchestrators/GameSceneOrchestrator.ts
@@ -41,6 +41,16 @@
onBombExploded: (payload: BombExplodedPayload) => void;
};
+/** シーン層で扱うイベント通知ポート群 */
+export type GameSceneEventPorts = {
+ onGameStart: (startTime: number) => void;
+ onGameEnd: () => void;
+ onBombPlacedFromOthers: (payload: BombPlacedPayload) => void;
+ onBombPlacedAckFromNetwork: (payload: BombPlacedAckPayload) => void;
+ onPlayerDeadFromNetwork: (payload: PlayerDeadPayload) => void;
+ onBombExploded: (payload: BombExplodedPayload) => void;
+};
+
/** GameLoop 生成入力型 */
export type CreateGameLoopOptions = {
app: Application;
@@ -69,12 +79,7 @@
getElapsedMs: () => number;
getJoystickInput: () => { x: number; y: number };
moveSender: MoveSender;
- onGameStart: (startTime: number) => void;
- onGameEnd: () => void;
- onBombPlacedFromOthers: (payload: BombPlacedPayload) => void;
- onBombPlacedAckFromNetwork: (payload: BombPlacedAckPayload) => void;
- onPlayerDeadFromNetwork: (payload: PlayerDeadPayload) => void;
- onBombExploded: (payload: BombExplodedPayload) => void;
+ eventPorts: GameSceneEventPorts;
factories?: GameSceneFactoryOptions;
};
@@ -96,12 +101,7 @@
private readonly getElapsedMs: () => number;
private readonly getJoystickInput: () => { x: number; y: number };
private readonly moveSender: MoveSender;
- private readonly onGameStart: (startTime: number) => void;
- private readonly onGameEnd: () => void;
- private readonly onBombPlacedFromOthers: (payload: BombPlacedPayload) => void;
- private readonly onBombPlacedAckFromNetwork: (payload: BombPlacedAckPayload) => void;
- private readonly onPlayerDeadFromNetwork: (payload: PlayerDeadPayload) => void;
- private readonly onBombExploded: (payload: BombExplodedPayload) => void;
+ private readonly eventPorts: GameSceneEventPorts;
private readonly createNetworkSync: (options: CreateNetworkSyncOptions) => GameNetworkSync;
private readonly createBombManager: (options: CreateBombManagerOptions) => BombManager;
private readonly createGameLoop: (options: CreateGameLoopOptions) => GameLoop;
@@ -115,12 +115,7 @@
getElapsedMs,
getJoystickInput,
moveSender,
- onGameStart,
- onGameEnd,
- onBombPlacedFromOthers,
- onBombPlacedAckFromNetwork,
- onPlayerDeadFromNetwork,
- onBombExploded,
+ eventPorts,
factories,
}: GameSceneOrchestratorOptions) {
this.app = app;
@@ -131,12 +126,7 @@
this.getElapsedMs = getElapsedMs;
this.getJoystickInput = getJoystickInput;
this.moveSender = moveSender;
- this.onGameStart = onGameStart;
- this.onGameEnd = onGameEnd;
- this.onBombPlacedFromOthers = onBombPlacedFromOthers;
- this.onBombPlacedAckFromNetwork = onBombPlacedAckFromNetwork;
- this.onPlayerDeadFromNetwork = onPlayerDeadFromNetwork;
- this.onBombExploded = onBombExploded;
+ 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));
@@ -172,11 +162,11 @@
myId: this.myId,
gameMap,
appearanceResolver: this.appearanceResolver,
- onGameStart: this.onGameStart,
- onGameEnd: this.onGameEnd,
- onBombPlacedFromOthers: this.onBombPlacedFromOthers,
- onBombPlacedAckFromNetwork: this.onBombPlacedAckFromNetwork,
- onPlayerDeadFromNetwork: this.onPlayerDeadFromNetwork,
+ onGameStart: this.eventPorts.onGameStart,
+ onGameEnd: this.eventPorts.onGameEnd,
+ onBombPlacedFromOthers: this.eventPorts.onBombPlacedFromOthers,
+ onBombPlacedAckFromNetwork: this.eventPorts.onBombPlacedAckFromNetwork,
+ onPlayerDeadFromNetwork: this.eventPorts.onPlayerDeadFromNetwork,
});
networkSync.bind();
return networkSync;
@@ -190,7 +180,7 @@
myId: this.myId,
getElapsedMs: this.getElapsedMs,
appearanceResolver: this.appearanceResolver,
- onBombExploded: this.onBombExploded,
+ onBombExploded: this.eventPorts.onBombExploded,
});
}
diff --git a/apps/client/src/scenes/game/application/presentation/GameUiPresenter.ts b/apps/client/src/scenes/game/application/presentation/GameUiPresenter.ts
new file mode 100644
index 0000000..b9b8cec
--- /dev/null
+++ b/apps/client/src/scenes/game/application/presentation/GameUiPresenter.ts
@@ -0,0 +1,25 @@
+/**
+ * GameUiPresenter
+ * ゲーム画面の表示用データ変換を提供する
+ * 残り時間と開始カウントダウンの表示生成を扱う
+ */
+import { config } from "@client/config";
+
+/** 残り秒数を mm:ss 形式へ変換する */
+export const formatRemainingTime = (remainingSec: number): string => {
+ const mins = Math.floor(remainingSec / 60);
+ const secs = Math.floor(remainingSec % 60);
+ return `${mins}:${secs.toString().padStart(2, "0")}`;
+};
+
+/** 開始カウントダウン表示文字列を生成する */
+export const buildStartCountdownText = (
+ remainingSec: number,
+): string | null => {
+ return remainingSec > 0 ? String(remainingSec) : null;
+};
+
+/** ゲーム画面の初期残り時間表示を返す */
+export const getInitialTimeDisplay = (): string => {
+ return formatRemainingTime(config.GAME_CONFIG.GAME_DURATION_SEC);
+};
\ No newline at end of file
diff --git a/apps/client/src/scenes/game/application/runtime/GameSceneRuntime.ts b/apps/client/src/scenes/game/application/runtime/GameSceneRuntime.ts
index 245113b..224ea18 100644
--- a/apps/client/src/scenes/game/application/runtime/GameSceneRuntime.ts
+++ b/apps/client/src/scenes/game/application/runtime/GameSceneRuntime.ts
@@ -4,20 +4,20 @@
* 入力状態,ネットワーク同期,ループ更新の実行責務を集約する
*/
import { Application, Container, Ticker } from "pixi.js";
-import type {
- BombPlacedAckPayload,
- BombPlacedPayload,
- PlayerDeadPayload,
-} from "@repo/shared";
import { AppearanceResolver } from "../AppearanceResolver";
import { GameNetworkSync } from "../GameNetworkSync";
import { GameLoop } from "../GameLoop";
-import { GameSceneOrchestrator, type GameSceneFactoryOptions } from "../orchestrators/GameSceneOrchestrator";
+import {
+ GameSceneOrchestrator,
+ type GameSceneEventPorts,
+ type GameSceneFactoryOptions,
+} from "../orchestrators/GameSceneOrchestrator";
import type { GamePlayers } from "../game.types";
-import type { BombExplodedPayload, BombManager } from "../../entities/bomb/BombManager";
+import type { BombManager } from "../../entities/bomb/BombManager";
import type { MoveSender } from "../network/PlayerMoveSender";
import type { GameActionSender } from "../network/GameActionSender";
import type { GameSessionFacade } from "../lifecycle/GameSessionFacade";
+import { DisposableRegistry } from "../lifecycle/DisposableRegistry";
export type GameSceneRuntimeOptions = {
app: Application;
@@ -28,12 +28,7 @@
gameActionSender: GameActionSender;
moveSender: MoveSender;
getElapsedMs: () => number;
- onGameStart: (startTime: number) => void;
- onGameEnd: () => void;
- onBombPlacedFromOthers: (payload: BombPlacedPayload) => void;
- onBombPlacedAckFromNetwork: (payload: BombPlacedAckPayload) => void;
- onPlayerDeadFromNetwork: (payload: PlayerDeadPayload) => void;
- onBombExploded: (payload: BombExplodedPayload) => void;
+ eventPorts: GameSceneEventPorts;
sceneFactories?: GameSceneFactoryOptions;
};
@@ -47,13 +42,9 @@
private readonly gameActionSender: GameActionSender;
private readonly moveSender: MoveSender;
private readonly getElapsedMs: () => number;
- private readonly onGameStart: (startTime: number) => void;
- private readonly onGameEnd: () => void;
- private readonly onBombPlacedFromOthers: (payload: BombPlacedPayload) => void;
- private readonly onBombPlacedAckFromNetwork: (payload: BombPlacedAckPayload) => void;
- private readonly onPlayerDeadFromNetwork: (payload: PlayerDeadPayload) => void;
- private readonly onBombExploded: (payload: BombExplodedPayload) => void;
+ private readonly eventPorts: GameSceneEventPorts;
private readonly sceneFactories?: GameSceneFactoryOptions;
+ private readonly disposableRegistry = new DisposableRegistry();
private readonly appearanceResolver = new AppearanceResolver();
private bombManager: BombManager | null = null;
@@ -70,12 +61,7 @@
gameActionSender,
moveSender,
getElapsedMs,
- onGameStart,
- onGameEnd,
- onBombPlacedFromOthers,
- onBombPlacedAckFromNetwork,
- onPlayerDeadFromNetwork,
- onBombExploded,
+ eventPorts,
sceneFactories,
}: GameSceneRuntimeOptions) {
this.app = app;
@@ -86,13 +72,15 @@
this.gameActionSender = gameActionSender;
this.moveSender = moveSender;
this.getElapsedMs = getElapsedMs;
- this.onGameStart = onGameStart;
- this.onGameEnd = onGameEnd;
- this.onBombPlacedFromOthers = onBombPlacedFromOthers;
- this.onBombPlacedAckFromNetwork = onBombPlacedAckFromNetwork;
- this.onPlayerDeadFromNetwork = onPlayerDeadFromNetwork;
- this.onBombExploded = onBombExploded;
+ this.eventPorts = eventPorts;
this.sceneFactories = sceneFactories;
+
+ this.disposableRegistry.add(() => {
+ this.clearJoystickInput();
+ });
+ this.disposableRegistry.add(() => {
+ this.gameLoop = null;
+ });
}
/** シーン実行に必要なサブシステムを初期化する */
@@ -106,12 +94,7 @@
getElapsedMs: this.getElapsedMs,
getJoystickInput: () => this.joystickInput,
moveSender: this.moveSender,
- onGameStart: this.onGameStart,
- onGameEnd: this.onGameEnd,
- onBombPlacedFromOthers: this.onBombPlacedFromOthers,
- onBombPlacedAckFromNetwork: this.onBombPlacedAckFromNetwork,
- onPlayerDeadFromNetwork: this.onPlayerDeadFromNetwork,
- onBombExploded: this.onBombExploded,
+ eventPorts: this.eventPorts,
factories: this.sceneFactories,
});
@@ -119,6 +102,15 @@
this.networkSync = initializedScene.networkSync;
this.bombManager = initializedScene.bombManager;
this.gameLoop = initializedScene.gameLoop;
+
+ this.disposableRegistry.add(() => {
+ this.networkSync?.unbind();
+ this.networkSync = null;
+ });
+ this.disposableRegistry.add(() => {
+ this.bombManager?.destroy();
+ this.bombManager = null;
+ });
}
public isInputEnabled(): boolean {
@@ -157,11 +149,6 @@
/** 実行系サブシステムを破棄する */
public destroy(): void {
- this.bombManager?.destroy();
- this.bombManager = null;
- this.networkSync?.unbind();
- this.networkSync = null;
- this.gameLoop = null;
- this.clearJoystickInput();
+ this.disposableRegistry.disposeAll();
}
}
diff --git a/apps/client/src/scenes/game/application/time/TimeProvider.ts b/apps/client/src/scenes/game/application/time/TimeProvider.ts
new file mode 100644
index 0000000..f02a1e9
--- /dev/null
+++ b/apps/client/src/scenes/game/application/time/TimeProvider.ts
@@ -0,0 +1,15 @@
+/**
+ * TimeProvider
+ * 時刻取得の依存を抽象化する
+ * テスト時に任意時刻を注入できるようにする
+ */
+
+/** 現在時刻ミリ秒を返す時刻取得インターフェース */
+export type TimeProvider = {
+ now: () => number;
+};
+
+/** 実行環境の現在時刻を返す既定の時刻取得実装 */
+export const SYSTEM_TIME_PROVIDER: TimeProvider = {
+ now: () => Date.now(),
+};
\ No newline at end of file
diff --git a/apps/client/src/scenes/game/application/ui/GameUiStateSyncService.ts b/apps/client/src/scenes/game/application/ui/GameUiStateSyncService.ts
new file mode 100644
index 0000000..3f23c7c
--- /dev/null
+++ b/apps/client/src/scenes/game/application/ui/GameUiStateSyncService.ts
@@ -0,0 +1,105 @@
+/**
+ * GameUiStateSyncService
+ * ゲーム画面向けUI状態の購読と差分通知を管理する
+ * 秒境界に揃えた定期通知と購読解除を提供する
+ */
+import {
+ SYSTEM_TIME_PROVIDER,
+ type TimeProvider,
+} from "@client/scenes/game/application/time/TimeProvider";
+
+const UI_STATE_SECOND_MS = 1000;
+
+/** ゲーム画面UIへ通知する状態スナップショット */
+export type GameUiState = {
+ remainingTimeSec: number;
+ startCountdownSec: number;
+ isInputEnabled: boolean;
+};
+
+type GameUiStateSyncServiceOptions = {
+ getSnapshot: () => GameUiState;
+ timeProvider?: TimeProvider;
+};
+
+/** UI状態の購読と定期通知を管理するサービス */
+export class GameUiStateSyncService {
+ private readonly getSnapshot: () => GameUiState;
+ private readonly timeProvider: TimeProvider;
+ private readonly listeners = new Set<(state: GameUiState) => void>();
+ private lastState: GameUiState | null = null;
+ private alignTimeoutId: number | null = null;
+ private timerId: number | null = null;
+
+ constructor({ getSnapshot, timeProvider }: GameUiStateSyncServiceOptions) {
+ this.getSnapshot = getSnapshot;
+ this.timeProvider = timeProvider ?? SYSTEM_TIME_PROVIDER;
+ }
+
+ public subscribe(listener: (state: GameUiState) => void): () => void {
+ this.listeners.add(listener);
+ listener(this.getSnapshot());
+
+ return () => {
+ this.listeners.delete(listener);
+ };
+ }
+
+ public emitIfChanged(force = false): void {
+ if (this.listeners.size === 0 && !force) {
+ return;
+ }
+
+ const snapshot = this.getSnapshot();
+ if (
+ !force
+ && this.lastState
+ && this.lastState.remainingTimeSec === snapshot.remainingTimeSec
+ && this.lastState.startCountdownSec === snapshot.startCountdownSec
+ && this.lastState.isInputEnabled === snapshot.isInputEnabled
+ ) {
+ return;
+ }
+
+ this.lastState = snapshot;
+ this.listeners.forEach((listener) => {
+ listener(snapshot);
+ });
+ }
+
+ public startTicker(): void {
+ if (this.alignTimeoutId !== null || this.timerId !== null) {
+ return;
+ }
+
+ const nowMs = this.timeProvider.now();
+ const delayToNextSecond = UI_STATE_SECOND_MS - (nowMs % UI_STATE_SECOND_MS);
+
+ this.alignTimeoutId = window.setTimeout(() => {
+ this.alignTimeoutId = null;
+ this.emitIfChanged();
+ this.timerId = window.setInterval(() => {
+ this.emitIfChanged();
+ }, UI_STATE_SECOND_MS);
+ }, delayToNextSecond);
+ }
+
+ public stopTicker(): void {
+ if (this.alignTimeoutId !== null) {
+ window.clearTimeout(this.alignTimeoutId);
+ this.alignTimeoutId = null;
+ }
+
+ if (this.timerId === null) {
+ return;
+ }
+
+ window.clearInterval(this.timerId);
+ this.timerId = null;
+ }
+
+ public clear(): void {
+ this.listeners.clear();
+ this.lastState = null;
+ }
+}
\ No newline at end of file
diff --git a/apps/client/src/scenes/game/hooks/useGameSceneController.ts b/apps/client/src/scenes/game/hooks/useGameSceneController.ts
index 421751d..4a1ff5b 100644
--- a/apps/client/src/scenes/game/hooks/useGameSceneController.ts
+++ b/apps/client/src/scenes/game/hooks/useGameSceneController.ts
@@ -4,17 +4,12 @@
* Pixi描画領域,残り時間表示,入力橋渡しを提供する
*/
import { useCallback, useEffect, useRef, useState } from "react";
-import { config } from "@client/config";
import { GameManager } from "@client/scenes/game/GameManager";
-
-const formatRemainingTime = (remaining: number) => {
- const mins = Math.floor(remaining / 60);
- const secs = Math.floor(remaining % 60);
- return `${mins}:${secs.toString().padStart(2, "0")}`;
-};
-
-const getInitialTimeDisplay = () =>
- formatRemainingTime(config.GAME_CONFIG.GAME_DURATION_SEC);
+import {
+ buildStartCountdownText,
+ formatRemainingTime,
+ getInitialTimeDisplay,
+} from "@client/scenes/game/application/presentation/GameUiPresenter";
/** ゲーム画面の状態と入力ハンドラを提供するフック */
export const useGameSceneController = (myId: string | null) => {
@@ -37,8 +32,7 @@
const nextDisplay = formatRemainingTime(state.remainingTimeSec);
setTimeLeft((prev) => (prev === nextDisplay ? prev : nextDisplay));
- const remainingSec = state.startCountdownSec;
- const nextCountdown = remainingSec > 0 ? String(remainingSec) : null;
+ const nextCountdown = buildStartCountdownText(state.startCountdownSec);
setStartCountdownText((prev) =>
prev === nextCountdown ? prev : nextCountdown,
);
diff --git a/apps/client/src/scenes/game/input/GameInputOverlay.tsx b/apps/client/src/scenes/game/input/GameInputOverlay.tsx
index b1e7bb3..ec1df11 100644
--- a/apps/client/src/scenes/game/input/GameInputOverlay.tsx
+++ b/apps/client/src/scenes/game/input/GameInputOverlay.tsx
@@ -3,10 +3,10 @@
* ゲーム入力UIレイヤーを構成する
* ジョイスティック層と爆弾ボタン層を分離して配置する
*/
-import { useEffect, useMemo, useState } from "react";
import { config } from "@client/config";
import { JoystickInputPresenter } from "./joystick/JoystickInputPresenter";
import { BombButton } from "./bomb/BombButton";
+import { useCooldownClock } from "./hooks/useCooldownClock";
/** 入力UIレイヤーの入力プロパティ */
type GameInputOverlayProps = {
@@ -22,8 +22,6 @@
height: "100%",
};
-const COOLDOWN_TICK_MS = 50;
-
/** 入力UIレイヤーを描画する */
export const GameInputOverlay = ({
isInputEnabled,
@@ -31,54 +29,7 @@
onPlaceBomb,
}: GameInputOverlayProps) => {
const bombCooldownMs = config.GAME_CONFIG.BOMB_COOLDOWN_MS;
- const [lastBombPressedAt, setLastBombPressedAt] = useState(
- null,
- );
- const [nowMs, setNowMs] = useState(() => Date.now());
-
- useEffect(() => {
- if (lastBombPressedAt === null) {
- return;
- }
-
- const timerId = window.setInterval(() => {
- setNowMs(Date.now());
- }, COOLDOWN_TICK_MS);
-
- return () => {
- window.clearInterval(timerId);
- };
- }, [lastBombPressedAt]);
-
- const cooldownState = useMemo(() => {
- if (bombCooldownMs <= 0) {
- return {
- progress: 1,
- isReady: true,
- remainingSecText: null,
- };
- }
-
- if (lastBombPressedAt === null) {
- return {
- progress: 1,
- isReady: true,
- remainingSecText: null,
- };
- }
-
- const elapsed = nowMs - lastBombPressedAt;
- const clampedElapsed = Math.max(0, Math.min(elapsed, bombCooldownMs));
- const progress = clampedElapsed / bombCooldownMs;
- const remainingMs = Math.max(0, bombCooldownMs - clampedElapsed);
- const isReady = remainingMs === 0;
-
- return {
- progress,
- isReady,
- remainingSecText: isReady ? null : String(Math.ceil(remainingMs / 1000)),
- };
- }, [bombCooldownMs, lastBombPressedAt, nowMs]);
+ const { cooldownState, markTriggered } = useCooldownClock(bombCooldownMs);
const handlePressBomb = () => {
if (!isInputEnabled || !cooldownState.isReady) {
@@ -90,8 +41,7 @@
return;
}
- setLastBombPressedAt(Date.now());
- setNowMs(Date.now());
+ markTriggered();
};
return (
diff --git a/apps/client/src/scenes/game/input/hooks/useCooldownClock.ts b/apps/client/src/scenes/game/input/hooks/useCooldownClock.ts
new file mode 100644
index 0000000..2a5243b
--- /dev/null
+++ b/apps/client/src/scenes/game/input/hooks/useCooldownClock.ts
@@ -0,0 +1,84 @@
+/**
+ * useCooldownClock
+ * クールダウン経過率と残り秒表示を計算するフック
+ * トリガー時刻を保持し一定間隔で再計算する
+ */
+import { useCallback, useEffect, useMemo, useState } from "react";
+import {
+ SYSTEM_TIME_PROVIDER,
+ type TimeProvider,
+} from "@client/scenes/game/application/time/TimeProvider";
+
+const COOLDOWN_TICK_MS = 50;
+
+/** クールダウンUI描画に必要な状態 */
+export type CooldownState = {
+ progress: number;
+ isReady: boolean;
+ remainingSecText: string | null;
+};
+
+const READY_STATE: CooldownState = {
+ progress: 1,
+ isReady: true,
+ remainingSecText: null,
+};
+
+/** useCooldownClock の依存注入オプション */
+export type UseCooldownClockOptions = {
+ timeProvider?: TimeProvider;
+};
+
+/** クールダウン状態とトリガー操作を提供するフック */
+export const useCooldownClock = (
+ cooldownMs: number,
+ options?: UseCooldownClockOptions,
+) => {
+ const timeProvider = options?.timeProvider ?? SYSTEM_TIME_PROVIDER;
+ const getNow = useCallback(() => timeProvider.now(), [timeProvider]);
+ const [lastTriggeredAt, setLastTriggeredAt] = useState(null);
+ const [nowMs, setNowMs] = useState(() => getNow());
+
+ useEffect(() => {
+ if (lastTriggeredAt === null || cooldownMs <= 0) {
+ return;
+ }
+
+ const timerId = window.setInterval(() => {
+ setNowMs(getNow());
+ }, COOLDOWN_TICK_MS);
+
+ return () => {
+ window.clearInterval(timerId);
+ };
+ }, [cooldownMs, getNow, lastTriggeredAt]);
+
+ const cooldownState = useMemo(() => {
+ if (cooldownMs <= 0 || lastTriggeredAt === null) {
+ return READY_STATE;
+ }
+
+ const elapsed = nowMs - lastTriggeredAt;
+ const clampedElapsed = Math.max(0, Math.min(elapsed, cooldownMs));
+ const progress = clampedElapsed / cooldownMs;
+ const remainingMs = Math.max(0, cooldownMs - clampedElapsed);
+ const isReady = remainingMs === 0;
+
+ return {
+ progress,
+ isReady,
+ remainingSecText: isReady ? null : String(Math.ceil(remainingMs / 1000)),
+ };
+ }, [cooldownMs, lastTriggeredAt, nowMs]);
+
+ const markTriggered = useCallback(() => {
+ const now = getNow();
+ setLastTriggeredAt(now);
+ setNowMs(now);
+ }, [getNow]);
+
+ return {
+ cooldownState,
+ markTriggered,
+ };
+};
\ No newline at end of file
diff --git a/apps/client/src/scenes/game/styles/GameView.styles.ts b/apps/client/src/scenes/game/styles/GameView.styles.ts
new file mode 100644
index 0000000..5fb2e0a
--- /dev/null
+++ b/apps/client/src/scenes/game/styles/GameView.styles.ts
@@ -0,0 +1,58 @@
+/**
+ * GameView.styles
+ * GameView の描画スタイル定数を集約する
+ * 画面全体レイアウトとオーバーレイ表示の見た目を定義する
+ */
+import type { CSSProperties } from "react";
+
+/** ゲーム画面全体のルートスタイル */
+export const ROOT_STYLE: CSSProperties = {
+ width: "100vw",
+ height: "100vh",
+ overflow: "hidden",
+ position: "relative",
+ backgroundColor: "#000",
+ userSelect: "none",
+ WebkitUserSelect: "none",
+};
+
+/** 上部中央のタイマー表示スタイル */
+export const TIMER_STYLE: CSSProperties = {
+ position: "absolute",
+ top: "20px",
+ left: "50%",
+ transform: "translateX(-50%)",
+ zIndex: 10,
+ color: "white",
+ fontSize: "32px",
+ fontWeight: "bold",
+ textShadow: "2px 2px 4px rgba(0,0,0,0.5)",
+ fontFamily: "monospace",
+ userSelect: "none",
+ WebkitUserSelect: "none",
+};
+
+/** Pixi描画レイヤーの配置スタイル */
+export const PIXI_LAYER_STYLE: CSSProperties = {
+ position: "absolute",
+ top: 0,
+ left: 0,
+ zIndex: 1,
+};
+
+/** 画面中央の開始カウントダウン表示スタイル */
+export const START_COUNTDOWN_STYLE: CSSProperties = {
+ position: "absolute",
+ top: "50%",
+ left: "50%",
+ transform: "translate(-50%, -50%)",
+ zIndex: 30,
+ color: "white",
+ fontSize: "clamp(3rem, 14vw, 8rem)",
+ fontWeight: 900,
+ textShadow: "0 0 16px rgba(0,0,0,0.85)",
+ fontFamily: "monospace",
+ userSelect: "none",
+ WebkitUserSelect: "none",
+ pointerEvents: "none",
+};
\ No newline at end of file