diff --git a/apps/client/src/scenes/game/GameInputManager.ts b/apps/client/src/scenes/game/GameInputManager.ts index cb5dbde..9017d1e 100644 --- a/apps/client/src/scenes/game/GameInputManager.ts +++ b/apps/client/src/scenes/game/GameInputManager.ts @@ -5,12 +5,18 @@ /** ジョイスティック入力をゲーム管理へ橋渡しするマネージャー */ export class GameInputManager { private onJoystickInput: (x: number, y: number) => void; + private onPlaceBomb: () => void; - constructor(onJoystickInput: (x: number, y: number) => void) { + constructor(onJoystickInput: (x: number, y: number) => void, onPlaceBomb: () => void) { this.onJoystickInput = onJoystickInput; + this.onPlaceBomb = onPlaceBomb; } public handleJoystickInput = (x: number, y: number) => { this.onJoystickInput(x, y); }; + + public handlePlaceBomb = () => { + this.onPlaceBomb(); + }; } diff --git a/apps/client/src/scenes/game/GameManager.ts b/apps/client/src/scenes/game/GameManager.ts index 5ee9da1..e560ca8 100644 --- a/apps/client/src/scenes/game/GameManager.ts +++ b/apps/client/src/scenes/game/GameManager.ts @@ -5,7 +5,10 @@ */ import { Application, Container, Ticker } from "pixi.js"; import { socketManager } from "@client/network/SocketManager"; +import { config } from "@repo/shared"; import { GameMapController } from "./entities/map/GameMapController"; +import { BombController } from "./entities/bomb/BombController"; +import { LocalPlayerController } from "./entities/player/PlayerController"; import { GameTimer } from "./application/GameTimer"; import { GameNetworkSync } from "./application/GameNetworkSync"; import { GameLoop } from "./application/GameLoop"; @@ -20,6 +23,8 @@ private container: HTMLDivElement; private gameMap!: GameMapController; private timer = new GameTimer(); + private bombs: BombController[] = []; + private lastBombPlacedElapsedMs = Number.NEGATIVE_INFINITY; private networkSync: GameNetworkSync | null = null; private gameLoop: GameLoop | null = null; @@ -32,6 +37,30 @@ public getRemainingTime(): number { return this.timer.getRemainingTime(); } + + public placeBomb() { + const me = this.players[this.myId]; + if (!me || !(me instanceof LocalPlayerController)) return; + + const elapsedMs = this.timer.getElapsedMs(); + const { BOMB_COOLDOWN_MS, BOMB_FUSE_MS, BOMB_RADIUS_GRID } = config.GAME_CONFIG; + if (elapsedMs - this.lastBombPlacedElapsedMs < BOMB_COOLDOWN_MS) { + return; + } + + const position = me.getPosition(); + const bomb = new BombController({ + x: position.x, + y: position.y, + radiusGrid: BOMB_RADIUS_GRID, + placedAtElapsedMs: elapsedMs, + explodeAtElapsedMs: elapsedMs + BOMB_FUSE_MS, + }); + + this.bombs.push(bomb); + this.worldContainer.addChild(bomb.getDisplayObject()); + this.lastBombPlacedElapsedMs = elapsedMs; + } // 入力と状態管理 private joystickInput = { x: 0, y: 0 }; @@ -103,8 +132,28 @@ */ private tick = (ticker: Ticker) => { this.gameLoop?.tick(ticker); + this.updateBombs(); }; + private updateBombs() { + const elapsedMs = this.timer.getElapsedMs(); + + const nextBombs: BombController[] = []; + this.bombs.forEach((bomb) => { + bomb.tick(elapsedMs); + + if (bomb.isFinished()) { + this.worldContainer.removeChild(bomb.getDisplayObject()); + bomb.destroy(); + return; + } + + nextBombs.push(bomb); + }); + + this.bombs = nextBombs; + } + /** * クリーンアップ処理(コンポーネントアンマウント時) */ @@ -113,6 +162,8 @@ if (this.isInitialized) { this.app.destroy(true, { children: true }); } + this.bombs.forEach((bomb) => bomb.destroy()); + this.bombs = []; this.players = {}; // イベント購読の解除 diff --git a/apps/client/src/scenes/game/GameScene.tsx b/apps/client/src/scenes/game/GameScene.tsx index 0beeaa6..827b1d1 100644 --- a/apps/client/src/scenes/game/GameScene.tsx +++ b/apps/client/src/scenes/game/GameScene.tsx @@ -13,13 +13,14 @@ /** メインゲーム画面を描画し入力をゲーム制御へ橋渡しする */ export function GameScene({ myId }: GameSceneProps) { - const { pixiContainerRef, timeLeft, handleInput } = useGameSceneController(myId); + const { pixiContainerRef, timeLeft, handleInput, handlePlaceBomb } = useGameSceneController(myId); return ( ); } \ No newline at end of file diff --git a/apps/client/src/scenes/game/GameView.tsx b/apps/client/src/scenes/game/GameView.tsx index 8e67e0a..e2880c7 100644 --- a/apps/client/src/scenes/game/GameView.tsx +++ b/apps/client/src/scenes/game/GameView.tsx @@ -10,6 +10,7 @@ timeLeft: string; pixiContainerRef: React.RefObject; onJoystickInput: (x: number, y: number) => void; + onPlaceBomb: () => void; }; const ROOT_STYLE: React.CSSProperties = { @@ -51,10 +52,27 @@ height: "100%", }; +const BOMB_BUTTON_STYLE: React.CSSProperties = { + position: "fixed", + right: "36px", + bottom: "40px", + width: "96px", + height: "96px", + borderRadius: "50%", + border: "2px solid rgba(255,255,255,0.75)", + background: "rgba(220, 60, 60, 0.85)", + color: "white", + fontSize: "18px", + fontWeight: "bold", + zIndex: 9999, + pointerEvents: "auto", + touchAction: "manipulation", +}; + const TimerOverlay = ({ timeLeft }: { timeLeft: string }) =>
{timeLeft}
; /** 画面描画と入力UIをまとめて描画する */ -export const GameView = ({ timeLeft, pixiContainerRef, onJoystickInput }: Props) => { +export const GameView = ({ timeLeft, pixiContainerRef, onJoystickInput, onPlaceBomb }: Props) => { return (
{/* タイマー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; };