diff --git a/apps/client/src/hooks/useAppFlow.ts b/apps/client/src/hooks/useAppFlow.ts index cc81f24..2864606 100644 --- a/apps/client/src/hooks/useAppFlow.ts +++ b/apps/client/src/hooks/useAppFlow.ts @@ -147,7 +147,6 @@ completeJoinRequest({ reason: payload.reason, roomId: payload.roomId, - playerName: payload.playerName, }); }; diff --git a/apps/client/src/scenes/game/GameView.tsx b/apps/client/src/scenes/game/GameView.tsx index af7abe9..adf497c 100644 --- a/apps/client/src/scenes/game/GameView.tsx +++ b/apps/client/src/scenes/game/GameView.tsx @@ -5,6 +5,7 @@ */ import { GameInputOverlay } from "./input/GameInputOverlay"; import { + GAME_VIEW_FEVER_TEXT_STYLE, GAME_VIEW_PAINT_RATE_ITEM_STYLE, GAME_VIEW_PAINT_RATE_PANEL_STYLE, GAME_VIEW_PAINT_RATE_SQUARE_STYLE, @@ -59,9 +60,13 @@ const TeamPaintRateOverlay = ({ teamPaintRates, + remainingSeconds, }: { teamPaintRates: number[]; + remainingSeconds: number; }) => { + const shouldMaskPaintRate = remainingSeconds <= 30; + return (
{teamPaintRates.map((rate, index) => ( @@ -77,7 +82,7 @@ > ■ - {`${Math.round(rate)}%`} + {shouldMaskPaintRate ? "???%" : `${Math.round(rate)}%`}
))} @@ -94,12 +99,23 @@ onJoystickInput, onPlaceBomb, }: Props) => { + const remainingSeconds = parseRemainingSeconds(timeLeft); + const isFeverTime = + remainingSeconds <= config.GAME_CONFIG.BOMB_FEVER_START_REMAINING_SEC; + return (
- + {/* タイマーUIの表示 */} - + + + {remainingSeconds === 60 && ( +
!Fever Tieme!
+ )} {startCountdownText && (
{startCountdownText}
@@ -111,6 +127,7 @@ {/* 入力UI レイヤー */} diff --git a/apps/client/src/scenes/game/application/lifecycle/GameSessionFacade.ts b/apps/client/src/scenes/game/application/lifecycle/GameSessionFacade.ts index 7a5debe..46b8089 100644 --- a/apps/client/src/scenes/game/application/lifecycle/GameSessionFacade.ts +++ b/apps/client/src/scenes/game/application/lifecycle/GameSessionFacade.ts @@ -4,10 +4,7 @@ * タイマーと入力ゲートの操作窓口を統一する */ import { GameTimer } from "@client/scenes/game/application/GameTimer"; -import { - InputGate, - type JoystickInput, -} from "./InputGate"; +import { InputGate, type JoystickInput } from "./InputGate"; /** ゲーム進行状態と入力可否の窓口を提供する */ export class GameSessionFacade { @@ -17,6 +14,7 @@ constructor() { this.inputGate = new InputGate({ isStartedProvider: () => this.timer.isStarted(), + isPlayableTimeProvider: () => this.timer.getRemainingTime() > 0, }); } @@ -59,4 +57,4 @@ public reset(): void { this.inputGate.reset(); } -} \ No newline at end of file +} diff --git a/apps/client/src/scenes/game/application/lifecycle/InputGate.ts b/apps/client/src/scenes/game/application/lifecycle/InputGate.ts index 360e56e..8f54944 100644 --- a/apps/client/src/scenes/game/application/lifecycle/InputGate.ts +++ b/apps/client/src/scenes/game/application/lifecycle/InputGate.ts @@ -13,20 +13,27 @@ /** InputGate の初期化入力 */ export type InputGateOptions = { isStartedProvider: () => boolean; + isPlayableTimeProvider?: () => boolean; }; /** ゲーム入力の受付可否とロック状態を管理する */ export class InputGate { private readonly isStartedProvider: () => boolean; + private readonly isPlayableTimeProvider: () => boolean; private inputLockCount = 0; - constructor({ isStartedProvider }: InputGateOptions) { + constructor({ isStartedProvider, isPlayableTimeProvider }: InputGateOptions) { this.isStartedProvider = isStartedProvider; + this.isPlayableTimeProvider = isPlayableTimeProvider ?? (() => true); } /** 現在入力を受け付け可能かを返す */ public canAcceptInput(): boolean { - return this.inputLockCount === 0 && this.isStartedProvider(); + return ( + this.inputLockCount === 0 && + this.isStartedProvider() && + this.isPlayableTimeProvider() + ); } /** 入力ロックを取得し,解除関数を返す */ @@ -57,4 +64,4 @@ public reset(): void { this.inputLockCount = 0; } -} \ 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 f337f81..260d65c 100644 --- a/apps/client/src/scenes/game/application/runtime/GameSceneRuntime.ts +++ b/apps/client/src/scenes/game/application/runtime/GameSceneRuntime.ts @@ -177,6 +177,10 @@ } public tick(ticker: Ticker): void { + if (!this.sessionFacade.canAcceptInput()) { + this.clearJoystickInput(); + } + this.gameLoop?.tick(ticker); } diff --git a/apps/client/src/scenes/game/entities/bomb/services/BombPlacementService.ts b/apps/client/src/scenes/game/entities/bomb/services/BombPlacementService.ts index 616d416..098ff3b 100644 --- a/apps/client/src/scenes/game/entities/bomb/services/BombPlacementService.ts +++ b/apps/client/src/scenes/game/entities/bomb/services/BombPlacementService.ts @@ -37,7 +37,13 @@ private readonly bombIdRegistry: BombIdRegistry; private lastBombPlacedElapsedMs = Number.NEGATIVE_INFINITY; - constructor({ players, myId, getElapsedMs, appearanceResolver, bombIdRegistry }: BombPlacementServiceOptions) { + constructor({ + players, + myId, + getElapsedMs, + appearanceResolver, + bombIdRegistry, + }: BombPlacementServiceOptions) { this.players = players; this.myId = myId; this.getElapsedMs = getElapsedMs; @@ -53,13 +59,27 @@ } const elapsedMs = this.getElapsedMs(); - const { BOMB_COOLDOWN_MS, BOMB_FUSE_MS } = config.GAME_CONFIG; - if (elapsedMs - this.lastBombPlacedElapsedMs < BOMB_COOLDOWN_MS) { + const { + BOMB_COOLDOWN_MS, + BOMB_NORMAL_COOLDOWN_MS, + BOMB_FEVER_COOLDOWN_MS, + BOMB_FEVER_START_REMAINING_SEC, + BOMB_FUSE_MS, + GAME_DURATION_SEC, + } = config.GAME_CONFIG; + const remainingSec = Math.max(0, GAME_DURATION_SEC - elapsedMs / 1000); + const isFeverTime = remainingSec <= BOMB_FEVER_START_REMAINING_SEC; + const cooldownMs = isFeverTime + ? BOMB_FEVER_COOLDOWN_MS + : (BOMB_NORMAL_COOLDOWN_MS ?? BOMB_COOLDOWN_MS); + + if (elapsedMs - this.lastBombPlacedElapsedMs < cooldownMs) { return null; } const position = me.getPosition(); - const { requestId, tempBombId } = this.bombIdRegistry.issuePendingOwnBombId(); + const { requestId, tempBombId } = + this.bombIdRegistry.issuePendingOwnBombId(); const payload: PlaceBombPayload = { requestId, x: position.x, @@ -107,4 +127,4 @@ return playerController.getSnapshot().teamId; } -} \ No newline at end of file +} diff --git a/apps/client/src/scenes/game/input/GameInputOverlay.tsx b/apps/client/src/scenes/game/input/GameInputOverlay.tsx index 654daa8..4c8dd51 100644 --- a/apps/client/src/scenes/game/input/GameInputOverlay.tsx +++ b/apps/client/src/scenes/game/input/GameInputOverlay.tsx @@ -12,6 +12,7 @@ /** 入力UIレイヤーの入力プロパティ */ type GameInputOverlayProps = { isInputEnabled: boolean; + isFeverTime: boolean; onJoystickInput: (x: number, y: number) => void; onPlaceBomb: () => boolean; }; @@ -19,10 +20,13 @@ /** 入力UIレイヤーを描画する */ export const GameInputOverlay = ({ isInputEnabled, + isFeverTime, onJoystickInput, onPlaceBomb, }: GameInputOverlayProps) => { - const bombCooldownMs = config.GAME_CONFIG.BOMB_COOLDOWN_MS; + const bombCooldownMs = isFeverTime + ? config.GAME_CONFIG.BOMB_FEVER_COOLDOWN_MS + : config.GAME_CONFIG.BOMB_NORMAL_COOLDOWN_MS; const { cooldownState, markTriggered } = useBombCooldownClock(bombCooldownMs); const layerStyle = buildGameInputOverlayLayerStyle(); @@ -49,6 +53,7 @@ onPress={handlePressBomb} cooldownProgress={cooldownState.progress} isReady={isInputEnabled && cooldownState.isReady} + isFeverTime={isFeverTime} remainingSecText={cooldownState.remainingSecText} />
diff --git a/apps/client/src/scenes/game/input/bomb/presentation/BombButton.styles.ts b/apps/client/src/scenes/game/input/bomb/presentation/BombButton.styles.ts index 0c39482..119e355 100644 --- a/apps/client/src/scenes/game/input/bomb/presentation/BombButton.styles.ts +++ b/apps/client/src/scenes/game/input/bomb/presentation/BombButton.styles.ts @@ -60,17 +60,36 @@ }; /** ボタン活性状態に応じた本体スタイルを生成する */ -export const buildBombButtonStyle = (isReady: boolean): CSSProperties => { +export const buildBombButtonStyle = ( + isReady: boolean, + isFeverTime: boolean, +): CSSProperties => { + const readyBackground = isFeverTime + ? "linear-gradient(145deg, rgba(255, 230, 90, 0.95), rgba(255, 130, 0, 0.92))" + : "rgba(220, 60, 60, 0.85)"; + const coolingBackground = isFeverTime + ? "linear-gradient(145deg, rgba(155, 110, 25, 0.9), rgba(120, 60, 10, 0.88))" + : "rgba(110, 40, 40, 0.85)"; + return { ...BOMB_BUTTON_STYLE, - background: isReady ? "rgba(220, 60, 60, 0.85)" : "rgba(110, 40, 40, 0.85)", + background: isReady ? readyBackground : coolingBackground, + color: isFeverTime ? "#201100" : BOMB_BUTTON_STYLE.color, + border: isFeverTime + ? "2px solid rgba(255,255,190,0.95)" + : BOMB_BUTTON_STYLE.border, + boxShadow: isFeverTime + ? "0 0 14px rgba(255, 195, 60, 0.65), 0 0 26px rgba(255, 120, 0, 0.45)" + : "none", opacity: isReady ? 1 : 0.88, cursor: isReady ? "pointer" : "not-allowed", }; }; /** 入力領域の活性状態スタイルを生成する */ -export const buildBombButtonHitAreaStyle = (isReady: boolean): CSSProperties => { +export const buildBombButtonHitAreaStyle = ( + isReady: boolean, +): CSSProperties => { return { ...BOMB_BUTTON_HIT_AREA_STYLE, cursor: isReady ? "pointer" : "not-allowed", diff --git a/apps/client/src/scenes/game/input/bomb/presentation/BombButton.tsx b/apps/client/src/scenes/game/input/bomb/presentation/BombButton.tsx index 675752a..77c3b54 100644 --- a/apps/client/src/scenes/game/input/bomb/presentation/BombButton.tsx +++ b/apps/client/src/scenes/game/input/bomb/presentation/BombButton.tsx @@ -14,6 +14,7 @@ onPress: () => void; cooldownProgress: number; isReady: boolean; + isFeverTime: boolean; remainingSecText: string | null; }; @@ -22,10 +23,11 @@ onPress, cooldownProgress, isReady, + isFeverTime, remainingSecText, }: BombButtonProps) => { const frameStyle = buildBombButtonFrameStyle(cooldownProgress); - const buttonStyle = buildBombButtonStyle(isReady); + const buttonStyle = buildBombButtonStyle(isReady, isFeverTime); const hitAreaStyle = buildBombButtonHitAreaStyle(isReady); const handleActivate = () => { diff --git a/apps/client/src/scenes/game/styles/GameView.styles.ts b/apps/client/src/scenes/game/styles/GameView.styles.ts index 59b879a..77dd531 100644 --- a/apps/client/src/scenes/game/styles/GameView.styles.ts +++ b/apps/client/src/scenes/game/styles/GameView.styles.ts @@ -89,3 +89,25 @@ WebkitUserSelect: "none", pointerEvents: "none", }; + +/** 画面中央のフィーバー表示スタイル */ +export const GAME_VIEW_FEVER_TEXT_STYLE: CSSProperties = { + position: "absolute", + top: "50%", + left: "50%", + transform: "translate(-50%, -50%)", + zIndex: 31, + color: "#fff27a", + fontSize: "clamp(1.4rem, 7vw, 3.6rem)", + fontWeight: 1000, + letterSpacing: "0.08em", + WebkitTextStroke: "2px rgba(120, 0, 0, 0.9)", + textShadow: + "0 0 6px rgba(255,255,255,0.9), 0 0 18px rgba(255,214,10,0.95), 0 0 32px rgba(255,120,0,0.85), 0 0 48px rgba(255,0,0,0.75)", + fontFamily: "monospace", + whiteSpace: "nowrap", + userSelect: "none", + WebkitUserSelect: "none", + pointerEvents: "none", + animation: "feverPulse 0.9s ease-in-out infinite", +}; diff --git a/apps/server/src/network/handlers/game/createGameOutputAdapter.ts b/apps/server/src/network/handlers/game/createGameOutputAdapter.ts index 6e3945c..530bbe1 100644 --- a/apps/server/src/network/handlers/game/createGameOutputAdapter.ts +++ b/apps/server/src/network/handlers/game/createGameOutputAdapter.ts @@ -28,23 +28,46 @@ type RoomId = domain.room.Room["roomId"]; /** ゲーム出力アダプターのインターフェース */ -export type GameOutputAdapter = Omit & BombOutputPort; +export type GameOutputAdapter = Omit< + GameOutputPort, + "publishPlayerRemovedToRoom" +> & + BombOutputPort; /** ゲーム切断時の出力アダプターのインターフェース */ -export type GameDisconnectOutputAdapter = Pick; +export type GameDisconnectOutputAdapter = Pick< + GameOutputPort, + "publishPlayerRemovedToRoom" +>; /** 共通送信コンテキストからゲーム出力アダプターを生成する */ -export const createGameOutputAdapter = (common: CommonHandlerContext): GameOutputAdapter => { +export const createGameOutputAdapter = ( + common: CommonHandlerContext, +): GameOutputAdapter => { return { publishPongToSocket: (payload: PongPayload) => { common.emitToSocket(protocol.SocketEvents.PONG, payload); }, - publishUpdatePlayersToSocket: (socketId: string, players: UpdatePlayersPayload) => { + publishUpdatePlayersToSocket: ( + socketId: string, + players: UpdatePlayersPayload, + ) => { const sanitizedPlayers = sanitizeUpdatePlayersPayload(players); - common.emitToSocketById(socketId, protocol.SocketEvents.UPDATE_PLAYERS, sanitizedPlayers); + common.emitToSocketById( + socketId, + protocol.SocketEvents.UPDATE_PLAYERS, + sanitizedPlayers, + ); }, - publishMapCellUpdatesToRoom: (roomId: RoomId, cellUpdates: UpdateMapCellsPayload) => { - common.emitToRoom(roomId, protocol.SocketEvents.UPDATE_MAP_CELLS, cellUpdates); + publishMapCellUpdatesToRoom: ( + roomId: RoomId, + cellUpdates: UpdateMapCellsPayload, + ) => { + common.emitToRoom( + roomId, + protocol.SocketEvents.UPDATE_MAP_CELLS, + cellUpdates, + ); }, publishGameEndToRoom: (roomId: RoomId) => { common.emitToRoom(roomId, protocol.SocketEvents.GAME_END); @@ -61,24 +84,59 @@ publishGameStartToSocket: (payload: GameStartPayload) => { common.emitToSocket(protocol.SocketEvents.GAME_START, payload); }, - publishBombPlacedToOthersInRoom: (roomId: RoomId, ownerSocketId: string, payload: BombPlacedPayload) => { - common.emitToRoomExceptSocket(roomId, ownerSocketId, protocol.SocketEvents.BOMB_PLACED, payload); + publishBombPlacedToOthersInRoom: ( + roomId: RoomId, + ownerSocketId: string, + payload: BombPlacedPayload, + ) => { + if (ownerSocketId.startsWith("bot:")) { + common.emitToRoom(roomId, protocol.SocketEvents.BOMB_PLACED, payload); + return; + } + + common.emitToRoomExceptSocket( + roomId, + ownerSocketId, + protocol.SocketEvents.BOMB_PLACED, + payload, + ); }, - publishBombPlacedAckToSocket: (socketId: string, payload: BombPlacedAckPayload) => { - common.emitToSocketById(socketId, protocol.SocketEvents.BOMB_PLACED_ACK, payload); + publishBombPlacedAckToSocket: ( + socketId: string, + payload: BombPlacedAckPayload, + ) => { + common.emitToSocketById( + socketId, + protocol.SocketEvents.BOMB_PLACED_ACK, + payload, + ); }, - publishPlayerDeadToOthersInRoom: (roomId: RoomId, deadPlayerId: string, payload: PlayerDeadPayload) => { - common.emitToRoomExceptSocket(roomId, deadPlayerId, protocol.SocketEvents.PLAYER_DEAD, payload); + publishPlayerDeadToOthersInRoom: ( + roomId: RoomId, + deadPlayerId: string, + payload: PlayerDeadPayload, + ) => { + common.emitToRoomExceptSocket( + roomId, + deadPlayerId, + protocol.SocketEvents.PLAYER_DEAD, + payload, + ); }, }; }; /** ゲーム切断時の送信関数群を生成する */ -export const createGameDisconnectOutputAdapter = (io: Server): GameDisconnectOutputAdapter => { +export const createGameDisconnectOutputAdapter = ( + io: Server, +): GameDisconnectOutputAdapter => { const emitToRoom = createEmitToRoom(io); return { - publishPlayerRemovedToRoom: (roomId: RoomId, removedPlayerId: RemovePlayerPayload) => { + publishPlayerRemovedToRoom: ( + roomId: RoomId, + removedPlayerId: RemovePlayerPayload, + ) => { emitToRoom(roomId, protocol.SocketEvents.REMOVE_PLAYER, removedPlayerId); }, }; diff --git a/packages/shared/src/config/gameConfig.ts b/packages/shared/src/config/gameConfig.ts index 5db3ee4..4b02104 100644 --- a/packages/shared/src/config/gameConfig.ts +++ b/packages/shared/src/config/gameConfig.ts @@ -6,7 +6,7 @@ /** ゲーム全体で利用する共有設定値 */ export const GAME_CONFIG = { // ゲーム進行設定(クライアント/サーバー契約) - GAME_DURATION_SEC: 60, // 1ゲームの制限時間(3分 = 180秒) + GAME_DURATION_SEC: 90, // 1ゲームの制限時間(秒) GAME_START_DELAY_MS: 5000, // 開始通知から実際にゲーム進行を開始するまでの待機時間(ms) // ネットワーク同期設定(クライアント/サーバー契約) @@ -26,7 +26,10 @@ BOMB_RADIUS_GRID: 1.5, // 爆風半径(グリッド単位、円形当たり判定) BOMB_RENDER_SCALE: 1.0, // 爆弾見た目サイズ倍率(1=等倍) BOMB_FUSE_MS: 1000, // 設置から爆発までの時間(ms) - BOMB_COOLDOWN_MS: 3000, // 設置後に次の爆弾を置けるまでの待機時間(ms) + BOMB_COOLDOWN_MS: 4000, // 通常時の後方互換用クールダウン時間(ms) + BOMB_NORMAL_COOLDOWN_MS: 4000, // 通常時に次の爆弾を置けるまでの待機時間(ms) + BOMB_FEVER_COOLDOWN_MS: 2000, // フィーバー時に次の爆弾を置けるまでの待機時間(ms) + BOMB_FEVER_START_REMAINING_SEC: 60, // フィーバー開始の残り時間しきい値(秒) BOMB_DEDUP_EXTRA_TTL_MS: 1000, // 重複排除保持時間の追加分(ms) // チーム設定(クライアント/サーバー契約)