{/* タイマーUIの表示 */}
@@ -66,6 +84,9 @@
{/* UI 配置領域 */}
+
);
diff --git a/apps/client/src/scenes/game/application/GameTimer.ts b/apps/client/src/scenes/game/application/GameTimer.ts
index b3fed35..f880cac 100644
--- a/apps/client/src/scenes/game/application/GameTimer.ts
+++ b/apps/client/src/scenes/game/application/GameTimer.ts
@@ -21,4 +21,9 @@
return Math.max(0, remainingSec);
}
+
+ public getElapsedMs(): number {
+ if (!this.gameStartTime) return 0;
+ return Math.max(0, Date.now() - this.gameStartTime);
+ }
}
diff --git a/apps/client/src/scenes/game/entities/bomb/BombController.ts b/apps/client/src/scenes/game/entities/bomb/BombController.ts
new file mode 100644
index 0000000..fe2cf19
--- /dev/null
+++ b/apps/client/src/scenes/game/entities/bomb/BombController.ts
@@ -0,0 +1,47 @@
+/**
+ * BombController
+ * 爆弾のModelとViewの橋渡しを担うコントローラー
+ * 時間更新ごとの状態遷移と描画同期を統合する
+ */
+import { BombModel } from "./BombModel";
+import { BombView } from "./BombView";
+
+type BombControllerOptions = {
+ x: number;
+ y: number;
+ radiusGrid: number;
+ placedAtElapsedMs: number;
+ explodeAtElapsedMs: number;
+};
+
+/** 爆弾1つ分の状態と描画同期を管理するコントローラー */
+export class BombController {
+ private readonly model: BombModel;
+ private readonly view: BombView;
+
+ constructor({ x, y, radiusGrid, placedAtElapsedMs, explodeAtElapsedMs }: BombControllerOptions) {
+ this.model = new BombModel({ x, y, radiusGrid, placedAtElapsedMs, explodeAtElapsedMs });
+ this.view = new BombView();
+
+ const pos = this.model.getPosition();
+ this.view.syncPosition(pos.x, pos.y);
+ this.view.renderState(this.model.getState(), this.model.getExplosionRadiusGrid());
+ }
+
+ public getDisplayObject() {
+ return this.view.displayObject;
+ }
+
+ public tick(elapsedMs: number): void {
+ this.model.update(elapsedMs);
+ this.view.renderState(this.model.getState(), this.model.getExplosionRadiusGrid());
+ }
+
+ public isFinished(): boolean {
+ return this.model.isFinished();
+ }
+
+ public destroy(): void {
+ this.view.destroy();
+ }
+}
diff --git a/apps/client/src/scenes/game/entities/bomb/BombModel.ts b/apps/client/src/scenes/game/entities/bomb/BombModel.ts
new file mode 100644
index 0000000..82768f0
--- /dev/null
+++ b/apps/client/src/scenes/game/entities/bomb/BombModel.ts
@@ -0,0 +1,71 @@
+/**
+ * BombModel
+ * 爆弾の状態遷移と時間管理を担うモデル
+ * 設置中 → 爆発中 → 終了 のライフサイクルを管理する
+ */
+export type BombState = "armed" | "exploded" | "finished";
+
+const EXPLOSION_VISIBLE_MS = 250;
+
+type BombModelOptions = {
+ x: number;
+ y: number;
+ radiusGrid: number;
+ placedAtElapsedMs: number;
+ explodeAtElapsedMs: number;
+};
+
+/** 爆弾の状態と寿命を管理するモデル */
+export class BombModel {
+ private x: number;
+ private y: number;
+ private radiusGrid: number;
+ private placedAtElapsedMs: number;
+ private explodeAtElapsedMs: number;
+ private state: BombState = "armed";
+
+ constructor({ x, y, radiusGrid, placedAtElapsedMs, explodeAtElapsedMs }: BombModelOptions) {
+ this.x = x;
+ this.y = y;
+ this.radiusGrid = radiusGrid;
+ this.placedAtElapsedMs = placedAtElapsedMs;
+ this.explodeAtElapsedMs = explodeAtElapsedMs;
+ }
+
+ public getPosition() {
+ return { x: this.x, y: this.y };
+ }
+
+ public getState(): BombState {
+ return this.state;
+ }
+
+ public getExplosionRadiusGrid(): number {
+ return this.radiusGrid;
+ }
+
+ public update(elapsedMs: number): void {
+ if (this.state === "finished") return;
+
+ if (this.state === "armed" && elapsedMs >= this.explodeAtElapsedMs) {
+ this.state = "exploded";
+ return;
+ }
+
+ if (this.state === "exploded" && elapsedMs >= this.explodeAtElapsedMs + EXPLOSION_VISIBLE_MS) {
+ this.state = "finished";
+ }
+ }
+
+ public isFinished(): boolean {
+ return this.state === "finished";
+ }
+
+ public getPlacedAtElapsedMs(): number {
+ return this.placedAtElapsedMs;
+ }
+
+ public getExplodeAtElapsedMs(): number {
+ return this.explodeAtElapsedMs;
+ }
+}
diff --git a/apps/client/src/scenes/game/entities/bomb/BombView.ts b/apps/client/src/scenes/game/entities/bomb/BombView.ts
new file mode 100644
index 0000000..d88d673
--- /dev/null
+++ b/apps/client/src/scenes/game/entities/bomb/BombView.ts
@@ -0,0 +1,58 @@
+/**
+ * BombView
+ * 爆弾の描画責務を担うビュー
+ * 設置中の見た目と爆風円の表示を管理する
+ */
+import { Container, Graphics } from "pixi.js";
+import { config } from "@repo/shared";
+import type { BombState } from "./BombModel";
+
+/** 爆弾の描画表現を管理するビュー */
+export class BombView {
+ public readonly displayObject: Container;
+
+ private bombGraphic: Graphics;
+ private explosionGraphic: Graphics;
+
+ constructor() {
+ this.displayObject = new Container();
+ this.bombGraphic = new Graphics();
+ this.explosionGraphic = new Graphics();
+
+ this.displayObject.addChild(this.explosionGraphic);
+ this.displayObject.addChild(this.bombGraphic);
+ }
+
+ public syncPosition(gridX: number, gridY: number): void {
+ const { GRID_CELL_SIZE } = config.GAME_CONFIG;
+
+ this.displayObject.x = gridX * GRID_CELL_SIZE + GRID_CELL_SIZE / 2;
+ this.displayObject.y = gridY * GRID_CELL_SIZE + GRID_CELL_SIZE / 2;
+ }
+
+ public renderState(state: BombState, radiusGrid: number): void {
+ const { GRID_CELL_SIZE } = config.GAME_CONFIG;
+ const bombRadiusPx = GRID_CELL_SIZE * 0.2;
+ const explosionRadiusPx = radiusGrid * GRID_CELL_SIZE;
+
+ this.bombGraphic.clear();
+ this.explosionGraphic.clear();
+
+ if (state === "armed") {
+ this.bombGraphic.circle(0, 0, bombRadiusPx);
+ this.bombGraphic.fill({ color: 0x111111, alpha: 0.95 });
+ this.bombGraphic.stroke({ color: 0xffffff, width: 2 });
+ return;
+ }
+
+ if (state === "exploded") {
+ this.explosionGraphic.circle(0, 0, explosionRadiusPx);
+ this.explosionGraphic.fill({ color: 0xffcc00, alpha: 0.35 });
+ this.explosionGraphic.stroke({ color: 0xff9900, width: 3 });
+ }
+ }
+
+ public destroy(): void {
+ this.displayObject.destroy({ children: true });
+ }
+}
diff --git a/apps/client/src/scenes/game/hooks/useGameSceneController.ts b/apps/client/src/scenes/game/hooks/useGameSceneController.ts
index a133661..fe97d0b 100644
--- a/apps/client/src/scenes/game/hooks/useGameSceneController.ts
+++ b/apps/client/src/scenes/game/hooks/useGameSceneController.ts
@@ -30,9 +30,14 @@
manager.init();
gameManagerRef.current = manager;
- inputManagerRef.current = new GameInputManager((x, y) => {
- manager.setJoystickInput(x, y);
- });
+ inputManagerRef.current = new GameInputManager(
+ (x, y) => {
+ manager.setJoystickInput(x, y);
+ },
+ () => {
+ manager.placeBomb();
+ }
+ );
const timerInterval = setInterval(() => {
const nextDisplay = formatRemainingTime(manager.getRemainingTime());
@@ -51,9 +56,14 @@
inputManagerRef.current?.handleJoystickInput(x, y);
}, []);
+ const handlePlaceBomb = useCallback(() => {
+ inputManagerRef.current?.handlePlaceBomb();
+ }, []);
+
return {
pixiContainerRef,
timeLeft,
handleInput,
+ handlePlaceBomb,
};
};
diff --git a/apps/client/src/scenes/game/input/joystick/useJoystickState.ts b/apps/client/src/scenes/game/input/joystick/useJoystickState.ts
index 7f38616..d675876 100644
--- a/apps/client/src/scenes/game/input/joystick/useJoystickState.ts
+++ b/apps/client/src/scenes/game/input/joystick/useJoystickState.ts
@@ -35,6 +35,7 @@
const handleStart = useCallback((e: JoystickPointerEvent) => {
const point = getClientPoint(e);
if (!point) return;
+ if (point.x > window.innerWidth / 2) return;
setCenter(point);
setKnobOffset({ x: 0, y: 0 });
diff --git a/packages/shared/src/config/gameConfig.ts b/packages/shared/src/config/gameConfig.ts
index eac31d5..b4b821d 100644
--- a/packages/shared/src/config/gameConfig.ts
+++ b/packages/shared/src/config/gameConfig.ts
@@ -34,6 +34,11 @@
get PLAYER_RADIUS_PX() { return this.PLAYER_RADIUS * this.GRID_CELL_SIZE; },
PLAYER_SPEED: 3, // 1秒当たりの移動量(グリッド単位)
+ // 爆弾設定(内部座標はグリッド単位、時間はms)
+ BOMB_RADIUS_GRID: 1.5, // 爆風半径(グリッド単位、円形当たり判定)
+ BOMB_FUSE_MS: 1000, // 設置から爆発までの時間(ms)
+ BOMB_COOLDOWN_MS: 1200, // 設置後に次の爆弾を置けるまでの待機時間(ms)
+
// チームカラー設定
// teamId インデックス順カラー配列
TEAM_COLORS: ['#FF4B4B', '#4B4BFF', '#4BFF4B', '#FFD700'],
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index 1b3fd0f..974358b 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -30,6 +30,7 @@
ServerToClientEventPayloadMap,
ServerToClientPayloadOf,
CurrentPlayersPayload,
+ BombPlacedPayload,
GameStartPayload,
MovePayload,
NewPlayerPayload,
diff --git a/packages/shared/src/protocol/events.ts b/packages/shared/src/protocol/events.ts
index f1f207e..23c8a9e 100644
--- a/packages/shared/src/protocol/events.ts
+++ b/packages/shared/src/protocol/events.ts
@@ -28,7 +28,9 @@
UPDATE_PLAYERS: "update_players",
REMOVE_PLAYER: "remove_player",
MOVE: "move",
+ PLACE_BOMB: "place-bomb",
UPDATE_MAP_CELLS: "update_map_cells",
+ BOMB_PLACED: "bomb-placed",
// 時間同期・ゲーム進行関連
PING: "ping", // クライアントからの時刻同期リクエスト(ラグ計算用)
@@ -63,6 +65,13 @@
/** MOVE イベントで送受信する移動入力情報 */
export type MovePayload = PlayerMovePayload;
+/** PLACE_BOMB / BOMB_PLACED イベントで送受信する爆弾情報 */
+export type BombPlacedPayload = {
+ x: number;
+ y: number;
+ explodeAtElapsedMs: number;
+};
+
/** PING イベントで送受信する時刻同期リクエスト */
export type PingPayload = number;
@@ -90,6 +99,7 @@
[SocketEvents.START_GAME]: undefined;
[SocketEvents.READY_FOR_GAME]: undefined;
[SocketEvents.MOVE]: MovePayload;
+ [SocketEvents.PLACE_BOMB]: BombPlacedPayload;
[SocketEvents.PING]: PingPayload;
};
@@ -103,6 +113,7 @@
[SocketEvents.UPDATE_PLAYERS]: UpdatePlayersPayload;
[SocketEvents.REMOVE_PLAYER]: RemovePlayerPayload;
[SocketEvents.UPDATE_MAP_CELLS]: UpdateMapCellsPayload;
+ [SocketEvents.BOMB_PLACED]: BombPlacedPayload;
[SocketEvents.PONG]: PongPayload;
[SocketEvents.GAME_END]: undefined;
};