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