diff --git a/src/pages/game/config/index.ts b/src/pages/game/config/index.ts index 6c6fd4f..a45fe62 100644 --- a/src/pages/game/config/index.ts +++ b/src/pages/game/config/index.ts @@ -32,3 +32,62 @@ export const GAME_PAGE_SCROLL_LOCK = { OVERFLOW_LOCKED: "hidden", OVERSCROLL_BEHAVIOR_LOCKED: "none", } as const; + +export const GAME_PAGE_MOBILE_ACTION_PANEL_CLASS_NAMES = { + ROOT: "pointer-events-auto relative flex shrink-0 flex-col items-end", + PROMPT_SLOT: "absolute bottom-full right-0 mb-4 flex w-40 flex-col gap-2", + COMPACT_WIDTH: "w-9", + TABLET_WIDTH: "w-40", + CONTROL_STACK: "flex w-full flex-col items-end gap-2", +} as const; + +export const GAME_PAGE_MOBILE_TOUCH_ACTION_PANEL_CLASS_NAMES = { + ROOT: "flex w-full flex-col gap-2 empty:hidden", + BUTTON: "pointer-events-auto relative w-full font-bold", + BUTTON_LABEL: "block w-full truncate text-center", + INTERACT_BADGE: + "absolute -top-2 -right-2 flex h-4 w-4 animate-pulse items-center justify-center rounded-full bg-dungeon-gold p-0 shadow-[0_0_8px_var(--dungeon-gold)]", + ATTACK_BADGE: + "absolute -top-2 -right-2 flex h-4 w-4 animate-pulse items-center justify-center rounded-full bg-success p-0 shadow-[0_0_8px_var(--success)]", +} as const; + +export const GAME_PAGE_MOBILE_TOUCH_ACTION_PANEL_TEST_IDS = { + ROOT: "game-page-mobile-touch-actions", +} as const; + +export const GAME_PAGE_MOBILE_ACTION_PANEL_TEST_IDS = { + ROOT: "game-page-mobile-action-panel", + PROMPT_SLOT: "game-page-mobile-action-panel-prompt-slot", + CONTROL_STACK: "game-page-mobile-action-panel-control-stack", +} as const; + +export const GAME_PAGE_HOME_ACTION_TEST_IDS = { + ROOT: "game-page-home-action", +} as const; + +export const GAME_PAGE_MOBILE_TOP_BAR_TEST_IDS = { + ROOT: "game-page-mobile-top-bar", + LEFT_CLUSTER: "game-page-mobile-top-bar-left-cluster", + RIGHT_CLUSTER: "game-page-mobile-top-bar-right-cluster", + HP_PANEL: "game-page-mobile-top-bar-hp-panel", +} as const; + +export const GAME_PAGE_DESKTOP_HEADER_TEST_IDS = { + ROOT: "game-page-desktop-header", + ACTIONS: "game-page-desktop-header-actions", +} as const; + +export const GAME_PAGE_MOBILE_OVERLAY_CLASS_NAMES = { + ROOT: "pointer-events-none absolute right-4 bottom-4 z-30 flex items-end gap-2", + JOYSTICK_ANCHOR: "pointer-events-none absolute bottom-4 left-4 z-30", + JOYSTICK_INTERACTIVE_SURFACE: "pointer-events-auto", + RUN_JUMP_ANCHOR: "flex items-end", + RUN_JUMP_INTERACTIVE_SURFACE: "pointer-events-auto", + ACTION_PANEL_ANCHOR: "flex items-end", +} as const; + +export const GAME_PAGE_MOBILE_OVERLAY_TEST_IDS = { + ROOT: "mobile-overlay-row", + RUN_JUMP_ANCHOR: "mobile-run-jump-anchor", + ACTION_PANEL_ANCHOR: "mobile-action-panel-anchor", +} as const; diff --git a/src/pages/game/ui/GamePage.test.tsx b/src/pages/game/ui/GamePage.test.tsx index 4d6b0ed..ae5d1cb 100644 --- a/src/pages/game/ui/GamePage.test.tsx +++ b/src/pages/game/ui/GamePage.test.tsx @@ -468,6 +468,11 @@ describe("GamePage", () => { name: "RUNESTONE", }), ).not.toBeNull(); + expect( + within(screen.getByRole("banner")).getByRole("link", { + name: GAME_PAGE_CONTROLS.NAVIGATION.HOME_ARIA_LABEL, + }), + ).not.toBeNull(); expect( screen.getByRole("button", { diff --git a/src/pages/game/ui/GamePageDesktopHeader.test.tsx b/src/pages/game/ui/GamePageDesktopHeader.test.tsx new file mode 100644 index 0000000..64bf76b --- /dev/null +++ b/src/pages/game/ui/GamePageDesktopHeader.test.tsx @@ -0,0 +1,95 @@ +// @vitest-environment happy-dom + +import { render, screen, within } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { describe, expect, it, vi } from "vitest"; + +import { + GAME_PAGE_CONTROLS, + GAME_PAGE_DESKTOP_HEADER_TEST_IDS, + GAME_PAGE_HOME_ACTION_TEST_IDS, +} from "@/pages/game/config"; + +import { GamePageDesktopHeader } from "./GamePageDesktopHeader"; + +vi.mock("@/pages/game/model", () => ({ + useGamePageDesktopHeaderModel: () => ({ + currentRoomLabel: "Entrance", + handleAudioMuteToggle: vi.fn(), + isAudioMuted: false, + }), +})); + +vi.mock("@tanstack/react-router", () => ({ + Link: ({ children, to, ...props }: { children: ReactNode; to: string }) => ( + + {children} + + ), +})); + +vi.mock("@/shared/ui", async () => { + const actual = + await vi.importActual("@/shared/ui"); + + return { + ...actual, + Tooltip: ({ children }: { children: ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}, + }; +}); + +vi.mock("@/widgets/leaderboard-panel", () => ({ + LeaderboardSheet: ({ children }: { children: ReactNode }) => <>{children}, +})); + +vi.mock("@/widgets/settings-panel", () => ({ + SettingsSheet: ({ children }: { children: ReactNode }) => <>{children}, +})); + +describe("GamePageDesktopHeader", () => { + it("places the home action at the far right beside settings", () => { + render(); + + const root = screen.getByTestId(GAME_PAGE_DESKTOP_HEADER_TEST_IDS.ROOT); + const actions = screen.getByTestId( + GAME_PAGE_DESKTOP_HEADER_TEST_IDS.ACTIONS, + ); + const homeAction = screen.getByTestId(GAME_PAGE_HOME_ACTION_TEST_IDS.ROOT); + + expect(root.className).toContain("justify-between"); + expect(actions.parentElement).toBe(root); + expect(homeAction.parentElement).toBe(actions); + expect(actions.lastElementChild).toBe(homeAction); + expect(homeAction.getAttribute("href")).toBe("/"); + expect(homeAction.getAttribute("data-size")).toBe("icon-sm"); + expect(homeAction.getAttribute("data-variant")).toBe("dungeon-outline"); + expect(homeAction.className).not.toContain("w-fit"); + expect( + within(root).getByRole("link", { + name: "RUNESTONE", + }), + ).toBeTruthy(); + expect( + within(actions).getByRole("button", { + name: GAME_PAGE_CONTROLS.AUDIO.MUTE_ARIA_LABEL, + }), + ).toBeTruthy(); + expect( + within(actions).getByRole("button", { + name: GAME_PAGE_CONTROLS.LEADERBOARD.ARIA_LABEL, + }), + ).toBeTruthy(); + expect( + within(actions).getByRole("button", { + name: GAME_PAGE_CONTROLS.SETTINGS.ARIA_LABEL, + }), + ).toBeTruthy(); + expect( + screen.getByRole("link", { + name: GAME_PAGE_CONTROLS.NAVIGATION.HOME_ARIA_LABEL, + }), + ).toBe(homeAction); + }); +}); diff --git a/src/pages/game/ui/GamePageDesktopHeader.tsx b/src/pages/game/ui/GamePageDesktopHeader.tsx index 11876e2..3360f2b 100644 --- a/src/pages/game/ui/GamePageDesktopHeader.tsx +++ b/src/pages/game/ui/GamePageDesktopHeader.tsx @@ -1,20 +1,26 @@ import { Link } from "@tanstack/react-router"; import { Trophy, Volume2, VolumeX } from "lucide-react"; -import { GAME_PAGE_CONTROLS } from "@/pages/game/config"; +import { + GAME_PAGE_CONTROLS, + GAME_PAGE_DESKTOP_HEADER_TEST_IDS, +} from "@/pages/game/config"; import { useGamePageDesktopHeaderModel } from "@/pages/game/model"; import { Button, Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui"; import { LeaderboardSheet } from "@/widgets/leaderboard-panel"; import { MARKETING_ROUTES } from "@/widgets/marketing-shell"; - import { GamePageDesktopSettingsAction } from "./GamePageDesktopSettingsAction"; +import { GamePageHomeAction } from "./GamePageHomeAction"; export function GamePageDesktopHeader() { const { currentRoomLabel, handleAudioMuteToggle, isAudioMuted } = useGamePageDesktopHeaderModel(); return ( -
+
Floor I
-
+
Room: @@ -80,6 +89,8 @@ export function GamePageDesktopHeader() { + +
); diff --git a/src/pages/game/ui/GamePageHomeAction.tsx b/src/pages/game/ui/GamePageHomeAction.tsx new file mode 100644 index 0000000..4081ba8 --- /dev/null +++ b/src/pages/game/ui/GamePageHomeAction.tsx @@ -0,0 +1,25 @@ +import { Link } from "@tanstack/react-router"; +import { Home } from "lucide-react"; + +import { + GAME_PAGE_CONTROLS, + GAME_PAGE_HOME_ACTION_TEST_IDS, +} from "@/pages/game/config"; +import { Button } from "@/shared/ui"; +import { MARKETING_ROUTES } from "@/widgets/marketing-shell"; + +export function GamePageHomeAction() { + return ( + + ); +} diff --git a/src/pages/game/ui/GamePageMobileActionPanel.test.tsx b/src/pages/game/ui/GamePageMobileActionPanel.test.tsx index 1370e74..784028a 100644 --- a/src/pages/game/ui/GamePageMobileActionPanel.test.tsx +++ b/src/pages/game/ui/GamePageMobileActionPanel.test.tsx @@ -2,7 +2,15 @@ import { render, screen, within } from "@testing-library/react"; import type { ButtonHTMLAttributes, ReactNode } from "react"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + GAME_PAGE_MOBILE_ACTION_PANEL_CLASS_NAMES, + GAME_PAGE_MOBILE_ACTION_PANEL_TEST_IDS, + GAME_PAGE_MOBILE_TOUCH_ACTION_PANEL_CLASS_NAMES, + GAME_PAGE_MOBILE_TOUCH_ACTION_PANEL_TEST_IDS, +} from "@/pages/game/config"; +import type { GamePageMobileActionPanelModel } from "@/pages/game/model"; import { GamePageMobileActionPanel } from "./GamePageMobileActionPanel"; @@ -10,36 +18,48 @@ const handleTouchAttack = vi.fn(); const handleTouchInteract = vi.fn(); const handleAudioMuteToggle = vi.fn(); +let mockIsTabletLayout = true; + +const mockTouchActions: GamePageMobileActionPanelModel["touchActions"] = { + attack: { + handleTouchAttack, + hasTouchAttack: true, + touchAttackPrompt: "Attack", + }, + interact: { + handleTouchInteract, + hasTouchInteract: true, + touchInteractPrompt: "Open Door", + }, +}; + +const resetTouchActionState = () => { + mockTouchActions.attack.hasTouchAttack = true; + mockTouchActions.attack.touchAttackPrompt = "Attack"; + mockTouchActions.interact.hasTouchInteract = true; + mockTouchActions.interact.touchInteractPrompt = "Open Door"; + mockIsTabletLayout = true; +}; + vi.mock("@/pages/game/model", () => ({ useGamePageMobileActionPanelModel: () => ({ audioToggle: { handleAudioMuteToggle, isAudioMuted: false, - isTabletLayout: true, + isTabletLayout: mockIsTabletLayout, }, leaderboardTrigger: { - isTabletLayout: true, + isTabletLayout: mockIsTabletLayout, }, settingsTrigger: { - isTabletLayout: true, + isTabletLayout: mockIsTabletLayout, }, sheetTrigger: { handleMobileSheetOpen: vi.fn(), isMobileSheetOpen: true, - isTabletLayout: true, - }, - touchActions: { - attack: { - handleTouchAttack, - hasTouchAttack: true, - touchAttackPrompt: "Attack", - }, - interact: { - handleTouchInteract, - hasTouchInteract: true, - touchInteractPrompt: "Open Door", - }, + isTabletLayout: mockIsTabletLayout, }, + touchActions: mockTouchActions, }), })); @@ -66,53 +86,146 @@ vi.mock("@/widgets/settings-panel", () => ({ SettingsSheet: ({ children }: { children: ReactNode }) => <>{children}, })); +afterEach(() => { + handleTouchAttack.mockClear(); + handleTouchInteract.mockClear(); + handleAudioMuteToggle.mockClear(); + resetTouchActionState(); +}); + describe("GamePageMobileActionPanel", () => { - it("renders grouped mobile action controls", () => { + it("renders the tablet action stack inside a fixed prompt shell", () => { + mockIsTabletLayout = true; + const { container } = render(); - const panelRoot = container.firstElementChild as HTMLElement; + const view = within(container); + const panelRoot = view.getByTestId( + GAME_PAGE_MOBILE_ACTION_PANEL_TEST_IDS.ROOT, + ); + const promptSlot = view.getByTestId( + GAME_PAGE_MOBILE_ACTION_PANEL_TEST_IDS.PROMPT_SLOT, + ); + const controlStack = view.getByTestId( + GAME_PAGE_MOBILE_ACTION_PANEL_TEST_IDS.CONTROL_STACK, + ); + const touchActionPanel = view.getByTestId( + GAME_PAGE_MOBILE_TOUCH_ACTION_PANEL_TEST_IDS.ROOT, + ); - expect(screen.getByRole("button", { name: "Attack" })).toBeTruthy(); - expect(screen.getByRole("button", { name: "Open Door" })).toBeTruthy(); - expect(screen.getByRole("button", { name: "Mute audio" })).toBeTruthy(); - expect( - screen.getByRole("button", { name: "Open Leaderboard" }), - ).toBeTruthy(); - expect(screen.getByRole("button", { name: "Open Panels" })).toBeTruthy(); - expect(screen.getByRole("button", { name: "Open Settings" })).toBeTruthy(); + expect(panelRoot.className).toContain("pointer-events-auto"); + expect(panelRoot.className).toContain("relative"); + expect(panelRoot.className).toContain( + GAME_PAGE_MOBILE_ACTION_PANEL_CLASS_NAMES.TABLET_WIDTH, + ); + expect(panelRoot.className).not.toContain("absolute"); + expect(promptSlot.className).toBe( + GAME_PAGE_MOBILE_ACTION_PANEL_CLASS_NAMES.PROMPT_SLOT, + ); + expect(controlStack.className).toBe( + GAME_PAGE_MOBILE_ACTION_PANEL_CLASS_NAMES.CONTROL_STACK, + ); + expect(touchActionPanel.parentElement).toBe(promptSlot); + expect(touchActionPanel.className).toBe( + GAME_PAGE_MOBILE_TOUCH_ACTION_PANEL_CLASS_NAMES.ROOT, + ); + expect(view.getByRole("button", { name: "Attack" })).toBeTruthy(); + expect(view.getByRole("button", { name: "Open Door" })).toBeTruthy(); + expect(view.getByRole("button", { name: "Mute audio" })).toBeTruthy(); + expect(view.getByRole("button", { name: "Open Leaderboard" })).toBeTruthy(); + expect(view.getByRole("button", { name: "Open Panels" })).toBeTruthy(); + expect(view.getByRole("button", { name: "Open Settings" })).toBeTruthy(); expect(screen.getByText("Audio")).toBeTruthy(); expect(screen.getByText("Rankings")).toBeTruthy(); expect(screen.getByText("Panels")).toBeTruthy(); expect(screen.getByText("Settings")).toBeTruthy(); - expect(panelRoot.classList.contains("pointer-events-auto")).toBe(true); - expect(panelRoot.classList.contains("absolute")).toBe(false); - expect(panelRoot.className).toContain("w-fit"); expect(panelRoot.getAttribute("data-input-blocks-look")).toBe("true"); + }); + + it("renders the compact action stack without labels on smaller phones", () => { + mockIsTabletLayout = false; + + const { container } = render(); + const view = within(container); + const panelRoot = view.getByTestId( + GAME_PAGE_MOBILE_ACTION_PANEL_TEST_IDS.ROOT, + ); + const promptSlot = view.getByTestId( + GAME_PAGE_MOBILE_ACTION_PANEL_TEST_IDS.PROMPT_SLOT, + ); + const touchActionPanel = view.getByTestId( + GAME_PAGE_MOBILE_TOUCH_ACTION_PANEL_TEST_IDS.ROOT, + ); + + expect(panelRoot.className).toContain( + GAME_PAGE_MOBILE_ACTION_PANEL_CLASS_NAMES.COMPACT_WIDTH, + ); + expect(panelRoot.className).not.toContain( + GAME_PAGE_MOBILE_ACTION_PANEL_CLASS_NAMES.TABLET_WIDTH, + ); + expect(promptSlot.className).toBe( + GAME_PAGE_MOBILE_ACTION_PANEL_CLASS_NAMES.PROMPT_SLOT, + ); + expect(touchActionPanel.parentElement).toBe(promptSlot); + expect(view.getByRole("button", { name: "Attack" })).toBeTruthy(); + expect(view.getByRole("button", { name: "Open Door" })).toBeTruthy(); + expect(view.getByRole("button", { name: "Mute audio" })).toBeTruthy(); + expect(view.getByRole("button", { name: "Open Leaderboard" })).toBeTruthy(); + expect(view.getByRole("button", { name: "Open Panels" })).toBeTruthy(); + expect(view.getByRole("button", { name: "Open Settings" })).toBeTruthy(); + expect(view.queryByText("Audio")).toBeNull(); + expect(view.queryByText("Rankings")).toBeNull(); + expect(view.queryByText("Panels")).toBeNull(); + expect(view.queryByText("Settings")).toBeNull(); + }); + + it("keeps the prompt slot from changing the panel width when prompts toggle", () => { + const { container, rerender } = render(); + const view = within(container); + const panelRoot = view.getByTestId( + GAME_PAGE_MOBILE_ACTION_PANEL_TEST_IDS.ROOT, + ); + const promptSlot = view.getByTestId( + GAME_PAGE_MOBILE_ACTION_PANEL_TEST_IDS.PROMPT_SLOT, + ); + const controlStack = view.getByTestId( + GAME_PAGE_MOBILE_ACTION_PANEL_TEST_IDS.CONTROL_STACK, + ); + const touchActionPanel = view.getByTestId( + GAME_PAGE_MOBILE_TOUCH_ACTION_PANEL_TEST_IDS.ROOT, + ); + + const initialPanelClassName = panelRoot.className; + const initialPromptSlotClassName = promptSlot.className; + const initialControlStackClassName = controlStack.className; + const initialTouchActionPanelClassName = touchActionPanel.className; + expect(view.getByRole("button", { name: "Attack" })).toBeTruthy(); + expect(view.getByRole("button", { name: "Open Door" })).toBeTruthy(); + + mockTouchActions.attack.hasTouchAttack = false; + mockTouchActions.attack.touchAttackPrompt = null; + mockTouchActions.interact.hasTouchInteract = false; + mockTouchActions.interact.touchInteractPrompt = null; + + rerender(); + + expect(view.queryByRole("button", { name: "Attack" })).toBeNull(); + expect(view.queryByRole("button", { name: "Open Door" })).toBeNull(); + expect( + view.getByTestId(GAME_PAGE_MOBILE_ACTION_PANEL_TEST_IDS.ROOT).className, + ).toBe(initialPanelClassName); + expect( + view.getByTestId(GAME_PAGE_MOBILE_ACTION_PANEL_TEST_IDS.PROMPT_SLOT) + .className, + ).toBe(initialPromptSlotClassName); + expect( + view.getByTestId(GAME_PAGE_MOBILE_ACTION_PANEL_TEST_IDS.CONTROL_STACK) + .className, + ).toBe(initialControlStackClassName); expect( - screen - .getAllByRole("button") - .map( - (button) => - button.getAttribute("aria-label") ?? - button.textContent?.trim() ?? - "", - ), - ).toEqual([ - "Open Door", - "Attack", - "Mute audio", - "Open Leaderboard", - "Open Panels", - "Open Settings", - ]); - - const settingsIconClass = - screen - .getByRole("button", { name: "Open Settings" }) - .querySelector("svg") - ?.getAttribute("class") ?? ""; - - expect(settingsIconClass).not.toContain("text-[var(--dungeon-gold)]"); + view.getByTestId(GAME_PAGE_MOBILE_TOUCH_ACTION_PANEL_TEST_IDS.ROOT) + .className, + ).toBe(initialTouchActionPanelClassName); }); it("wires the touch action handlers from the model", () => { diff --git a/src/pages/game/ui/GamePageMobileActionPanel.tsx b/src/pages/game/ui/GamePageMobileActionPanel.tsx index 0496c69..fddc1bc 100644 --- a/src/pages/game/ui/GamePageMobileActionPanel.tsx +++ b/src/pages/game/ui/GamePageMobileActionPanel.tsx @@ -1,9 +1,14 @@ +import { + GAME_PAGE_MOBILE_ACTION_PANEL_CLASS_NAMES, + GAME_PAGE_MOBILE_ACTION_PANEL_TEST_IDS, +} from "@/pages/game/config"; import { useGamePageMobileActionPanelModel } from "@/pages/game/model"; import { INPUT_POINTER_DATA_ATTRIBUTE_VALUES, INPUT_POINTER_DATA_ATTRIBUTES, POINTER_ROLES, } from "@/shared/config"; +import { cn } from "@/shared/lib/utils"; import { GamePageMobileAudioAction } from "./GamePageMobileAudioAction"; import { GamePageMobileLeaderboardAction } from "./GamePageMobileLeaderboardAction"; @@ -13,25 +18,43 @@ import { GamePageMobileTouchActions } from "./GamePageMobileTouchActions"; export function GamePageMobileActionPanel() { const viewModel = useGamePageMobileActionPanelModel(); + const { isTabletLayout } = viewModel.audioToggle; return (
- - - - - +
+ +
+ +
+ + + + +
); } diff --git a/src/pages/game/ui/GamePageMobileCanvasStage.test.tsx b/src/pages/game/ui/GamePageMobileCanvasStage.test.tsx index 1bbb834..cb6f597 100644 --- a/src/pages/game/ui/GamePageMobileCanvasStage.test.tsx +++ b/src/pages/game/ui/GamePageMobileCanvasStage.test.tsx @@ -4,6 +4,10 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { CAMERA_MODES } from "@/features/camera-system"; +import { + GAME_PAGE_MOBILE_OVERLAY_CLASS_NAMES, + GAME_PAGE_MOBILE_OVERLAY_TEST_IDS, +} from "@/pages/game/config"; const INPUT_STATE_KEYS = { READY: "ready", @@ -13,14 +17,25 @@ const INPUT_STATE_KEYS = { RUN_TOGGLE_OFF: "runToggleOff", } as const; +const MOBILE_STAGE_TEST_IDS = { + ACTION_PANEL: "game-page-mobile-action-panel", + CAMERA_CONTROL_ZONE: "camera-control-zone", + GAME_CANVAS: "game-canvas", + MOBILE_ACTION_BUTTON_ZONE: "mobile-action-button-zone", + TOP_BAR: "game-page-mobile-top-bar", + TOUCH_JOYSTICK_ZONE: "touch-joystick-zone", +} as const; + let mockCameraMode: (typeof CAMERA_MODES)[keyof typeof CAMERA_MODES] = CAMERA_MODES.FREE_ORBITAL; let mockIsInputBlocked = false; +let mockTouchPromptsVisible = false; afterEach(() => { cleanup(); mockCameraMode = CAMERA_MODES.FREE_ORBITAL; mockIsInputBlocked = false; + mockTouchPromptsVisible = false; }); vi.mock("@/pages/game/model", () => ({ @@ -66,81 +81,161 @@ vi.mock("@/pages/game/model", () => ({ vi.mock("@/features/input-orchestrator", () => ({ MobileActionButtonZone: ({ isRunEnabled }: { isRunEnabled: boolean }) => (
), })); vi.mock("@/features/touch-input", () => ({ - CameraControlZone: () =>
, - TouchJoystickZone: () =>
, + CameraControlZone: () => ( +
+ ), + TouchJoystickZone: () => ( +
+ ), })); vi.mock("@/widgets/game-canvas", () => ({ - GameCanvas: () =>
, + GameCanvas: () =>
, })); vi.mock("./GamePageMobileActionPanel", () => ({ GamePageMobileActionPanel: () => ( -
+
), })); vi.mock("./GamePageMobileTopBar", () => ({ - GamePageMobileTopBar: () =>
, + GamePageMobileTopBar: () => ( +
+ ), })); import { GamePageMobileCanvasStage } from "./GamePageMobileCanvasStage"; describe("GamePageMobileCanvasStage", () => { - it("renders the mobile controls as siblings of the camera control zone", () => { + it("renders the mobile controls as sibling columns in the overlay row", () => { mockCameraMode = CAMERA_MODES.FREE_ORBITAL; render(); + const overlayRow = screen.getByTestId( + GAME_PAGE_MOBILE_OVERLAY_TEST_IDS.ROOT, + ); const mobileActionButtonZone = screen.getByTestId( - "mobile-action-button-zone", + MOBILE_STAGE_TEST_IDS.MOBILE_ACTION_BUTTON_ZONE, + ); + const actionPanel = screen.getByTestId(MOBILE_STAGE_TEST_IDS.ACTION_PANEL); + const runJumpAnchor = screen.getByTestId( + GAME_PAGE_MOBILE_OVERLAY_TEST_IDS.RUN_JUMP_ANCHOR, + ); + const actionPanelAnchor = screen.getByTestId( + GAME_PAGE_MOBILE_OVERLAY_TEST_IDS.ACTION_PANEL_ANCHOR, ); - const actionPanel = screen.getByTestId("game-page-mobile-action-panel"); - expect(screen.getByTestId("game-canvas")).toBeTruthy(); - expect(screen.getByTestId("camera-control-zone")).toBeTruthy(); - expect(screen.getByTestId("touch-joystick-zone")).toBeTruthy(); + expect(screen.getByTestId(MOBILE_STAGE_TEST_IDS.GAME_CANVAS)).toBeTruthy(); + expect( + screen.getByTestId(MOBILE_STAGE_TEST_IDS.CAMERA_CONTROL_ZONE), + ).toBeTruthy(); + expect( + screen.getByTestId(MOBILE_STAGE_TEST_IDS.TOUCH_JOYSTICK_ZONE), + ).toBeTruthy(); expect(mobileActionButtonZone).toBeTruthy(); expect(actionPanel).toBeTruthy(); - expect(screen.getByTestId("game-page-mobile-top-bar")).toBeTruthy(); + expect(screen.getByTestId(MOBILE_STAGE_TEST_IDS.TOP_BAR)).toBeTruthy(); expect(mobileActionButtonZone.getAttribute("data-run-enabled")).toBe( "false", ); - + expect(overlayRow.className).toBe( + GAME_PAGE_MOBILE_OVERLAY_CLASS_NAMES.ROOT, + ); + expect(runJumpAnchor.className).toBe( + GAME_PAGE_MOBILE_OVERLAY_CLASS_NAMES.RUN_JUMP_ANCHOR, + ); + expect(actionPanelAnchor.className).toBe( + GAME_PAGE_MOBILE_OVERLAY_CLASS_NAMES.ACTION_PANEL_ANCHOR, + ); + expect(runJumpAnchor.parentElement).toBe(overlayRow); + expect(actionPanelAnchor.parentElement).toBe(overlayRow); expect( screen - .getByTestId("touch-joystick-zone") - .closest('[data-testid="camera-control-zone"]'), + .getByTestId(MOBILE_STAGE_TEST_IDS.TOUCH_JOYSTICK_ZONE) + .closest( + `[data-testid="${MOBILE_STAGE_TEST_IDS.CAMERA_CONTROL_ZONE}"]`, + ), ).toBeNull(); expect( - mobileActionButtonZone.closest('[data-testid="camera-control-zone"]'), + mobileActionButtonZone.closest( + `[data-testid="${MOBILE_STAGE_TEST_IDS.CAMERA_CONTROL_ZONE}"]`, + ), ).toBeNull(); expect( - actionPanel.closest('[data-testid="camera-control-zone"]'), + actionPanel.closest( + `[data-testid="${MOBILE_STAGE_TEST_IDS.CAMERA_CONTROL_ZONE}"]`, + ), ).toBeNull(); expect( screen - .getByTestId("game-page-mobile-top-bar") - .closest('[data-testid="camera-control-zone"]'), + .getByTestId(MOBILE_STAGE_TEST_IDS.TOP_BAR) + .closest( + `[data-testid="${MOBILE_STAGE_TEST_IDS.CAMERA_CONTROL_ZONE}"]`, + ), ).toBeNull(); - expect(mobileActionButtonZone.parentElement).toBe( - actionPanel.parentElement, + expect(runJumpAnchor.className.includes("absolute")).toBe(false); + expect(actionPanelAnchor.className.includes("absolute")).toBe(false); + }); + + it("keeps the overlay row stable when touch prompts toggle", () => { + const { rerender } = render(); + const overlayRow = screen.getByTestId( + GAME_PAGE_MOBILE_OVERLAY_TEST_IDS.ROOT, + ); + const runJumpAnchor = screen.getByTestId( + GAME_PAGE_MOBILE_OVERLAY_TEST_IDS.RUN_JUMP_ANCHOR, + ); + const actionPanelAnchor = screen.getByTestId( + GAME_PAGE_MOBILE_OVERLAY_TEST_IDS.ACTION_PANEL_ANCHOR, ); + const actionPanel = screen.getByTestId(MOBILE_STAGE_TEST_IDS.ACTION_PANEL); + const initialOverlayRowClassName = overlayRow.className; + const initialRunJumpAnchorClassName = runJumpAnchor.className; + const initialActionPanelAnchorClassName = actionPanelAnchor.className; + + expect(actionPanel.getAttribute("data-touch-prompts-visible")).toBe( + "false", + ); + + mockTouchPromptsVisible = true; + + rerender(); + + expect( + screen + .getByTestId(MOBILE_STAGE_TEST_IDS.ACTION_PANEL) + .getAttribute("data-touch-prompts-visible"), + ).toBe("true"); expect( - mobileActionButtonZone.parentElement?.classList.contains("absolute"), - ).toBe(true); + screen.getByTestId(GAME_PAGE_MOBILE_OVERLAY_TEST_IDS.RUN_JUMP_ANCHOR) + .parentElement, + ).toBe(screen.getByTestId(GAME_PAGE_MOBILE_OVERLAY_TEST_IDS.ROOT)); expect( - mobileActionButtonZone.parentElement?.classList.contains("right-4"), - ).toBe(true); + screen.getByTestId(GAME_PAGE_MOBILE_OVERLAY_TEST_IDS.ACTION_PANEL_ANCHOR) + .parentElement, + ).toBe(screen.getByTestId(GAME_PAGE_MOBILE_OVERLAY_TEST_IDS.ROOT)); expect( - mobileActionButtonZone.parentElement?.classList.contains("bottom-4"), - ).toBe(true); + screen.getByTestId(GAME_PAGE_MOBILE_OVERLAY_TEST_IDS.ROOT).className, + ).toBe(initialOverlayRowClassName); + expect( + screen.getByTestId(GAME_PAGE_MOBILE_OVERLAY_TEST_IDS.RUN_JUMP_ANCHOR) + .className, + ).toBe(initialRunJumpAnchorClassName); + expect( + screen.getByTestId(GAME_PAGE_MOBILE_OVERLAY_TEST_IDS.ACTION_PANEL_ANCHOR) + .className, + ).toBe(initialActionPanelAnchorClassName); }); it("keeps the camera surface mounted in first-person mode", () => { @@ -148,7 +243,9 @@ describe("GamePageMobileCanvasStage", () => { render(); - expect(screen.getByTestId("camera-control-zone")).toBeTruthy(); + expect( + screen.getByTestId(MOBILE_STAGE_TEST_IDS.CAMERA_CONTROL_ZONE), + ).toBeTruthy(); }); it("hides the mobile gameplay controls in portrait mode", () => { @@ -156,12 +253,29 @@ describe("GamePageMobileCanvasStage", () => { render(); - expect(screen.getByTestId("game-canvas")).toBeTruthy(); - expect(screen.queryByTestId("camera-control-zone")).toBeNull(); - expect(screen.queryByTestId("touch-joystick-zone")).toBeNull(); - expect(screen.queryByTestId("mobile-action-button-zone")).toBeNull(); - expect(screen.queryByTestId("game-page-mobile-action-panel")).toBeNull(); - expect(screen.queryByTestId("game-page-mobile-top-bar")).toBeNull(); + expect(screen.getByTestId(MOBILE_STAGE_TEST_IDS.GAME_CANVAS)).toBeTruthy(); + expect( + screen.queryByTestId(MOBILE_STAGE_TEST_IDS.CAMERA_CONTROL_ZONE), + ).toBeNull(); + expect( + screen.queryByTestId(MOBILE_STAGE_TEST_IDS.TOUCH_JOYSTICK_ZONE), + ).toBeNull(); + expect( + screen.queryByTestId(MOBILE_STAGE_TEST_IDS.MOBILE_ACTION_BUTTON_ZONE), + ).toBeNull(); + expect(screen.queryByTestId(MOBILE_STAGE_TEST_IDS.ACTION_PANEL)).toBeNull(); + expect(screen.queryByTestId(MOBILE_STAGE_TEST_IDS.TOP_BAR)).toBeNull(); + expect( + screen.queryByTestId(GAME_PAGE_MOBILE_OVERLAY_TEST_IDS.RUN_JUMP_ANCHOR), + ).toBeNull(); + expect( + screen.queryByTestId( + GAME_PAGE_MOBILE_OVERLAY_TEST_IDS.ACTION_PANEL_ANCHOR, + ), + ).toBeNull(); + expect( + screen.queryByTestId(GAME_PAGE_MOBILE_OVERLAY_TEST_IDS.ROOT), + ).toBeNull(); }); it("suppresses the mobile gameplay context menu", () => { diff --git a/src/pages/game/ui/GamePageMobileCanvasStage.tsx b/src/pages/game/ui/GamePageMobileCanvasStage.tsx index 465af0f..8ea5c72 100644 --- a/src/pages/game/ui/GamePageMobileCanvasStage.tsx +++ b/src/pages/game/ui/GamePageMobileCanvasStage.tsx @@ -1,5 +1,9 @@ import { MobileActionButtonZone } from "@/features/input-orchestrator"; import { CameraControlZone, TouchJoystickZone } from "@/features/touch-input"; +import { + GAME_PAGE_MOBILE_OVERLAY_CLASS_NAMES, + GAME_PAGE_MOBILE_OVERLAY_TEST_IDS, +} from "@/pages/game/config"; import { useGamePageCameraElements, useGamePageInputContext, @@ -42,8 +46,12 @@ export function GamePageMobileCanvasStage() { -
-
+
+
-
- +
+
+
+ +
+
- +
+ +
) : null} diff --git a/src/pages/game/ui/GamePageMobileTopBar.test.tsx b/src/pages/game/ui/GamePageMobileTopBar.test.tsx new file mode 100644 index 0000000..da005c0 --- /dev/null +++ b/src/pages/game/ui/GamePageMobileTopBar.test.tsx @@ -0,0 +1,81 @@ +// @vitest-environment happy-dom + +import { render, screen, within } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { describe, expect, it, vi } from "vitest"; + +import { + GAME_PAGE_CONTROLS, + GAME_PAGE_HOME_ACTION_TEST_IDS, + GAME_PAGE_MOBILE_TOP_BAR_TEST_IDS, +} from "@/pages/game/config"; + +import { GamePageMobileTopBar } from "./GamePageMobileTopBar"; + +vi.mock("@/pages/game/model", () => ({ + useGamePageMobileTopBarModel: () => ({ + cameraStateSnapshot: { + mode: "free-orbital", + }, + handleCameraModeSwitch: vi.fn(), + handleDungeonRunReset: vi.fn(), + playerHp: 42, + playerMaxHp: 100, + }), +})); + +vi.mock("@/widgets/camera-mode-switcher", () => ({ + MobileCameraModeSwitcher: () =>
, +})); + +vi.mock("@tanstack/react-router", () => ({ + Link: ({ children, to, ...props }: { children: ReactNode; to: string }) => ( + + {children} + + ), +})); + +describe("GamePageMobileTopBar", () => { + it("places the home action to the right of the HP panel", () => { + render(); + + const root = screen.getByTestId(GAME_PAGE_MOBILE_TOP_BAR_TEST_IDS.ROOT); + const leftCluster = screen.getByTestId( + GAME_PAGE_MOBILE_TOP_BAR_TEST_IDS.LEFT_CLUSTER, + ); + const rightCluster = screen.getByTestId( + GAME_PAGE_MOBILE_TOP_BAR_TEST_IDS.RIGHT_CLUSTER, + ); + const hpPanel = screen.getByTestId( + GAME_PAGE_MOBILE_TOP_BAR_TEST_IDS.HP_PANEL, + ); + const homeAction = screen.getByTestId(GAME_PAGE_HOME_ACTION_TEST_IDS.ROOT); + + expect(root.className).toContain("justify-between"); + expect(root.className).toContain("items-start"); + expect(leftCluster.parentElement).toBe(root); + expect(rightCluster.parentElement).toBe(root); + expect(hpPanel.parentElement).toBe(rightCluster); + expect(homeAction.parentElement).toBe(rightCluster); + expect(rightCluster.firstElementChild).toBe(hpPanel); + expect(rightCluster.lastElementChild).toBe(homeAction); + expect(homeAction.getAttribute("href")).toBe("/"); + expect(homeAction.getAttribute("data-size")).toBe("icon-sm"); + expect(homeAction.getAttribute("data-variant")).toBe("dungeon-outline"); + expect(homeAction.className).not.toContain("w-fit"); + expect( + screen.getByRole("button", { + name: "Restart Run", + }), + ).toBeTruthy(); + expect( + screen.getByRole("link", { + name: GAME_PAGE_CONTROLS.NAVIGATION.HOME_ARIA_LABEL, + }), + ).toBe(homeAction); + expect(within(root).getByTestId("mobile-camera-switcher")).toBeTruthy(); + expect(screen.getByText("Camera")).toBeTruthy(); + expect(screen.getByText("HP")).toBeTruthy(); + }); +}); diff --git a/src/pages/game/ui/GamePageMobileTopBar.tsx b/src/pages/game/ui/GamePageMobileTopBar.tsx index 770d25e..fe01ef7 100644 --- a/src/pages/game/ui/GamePageMobileTopBar.tsx +++ b/src/pages/game/ui/GamePageMobileTopBar.tsx @@ -1,30 +1,24 @@ -import { Link } from "@tanstack/react-router"; -import { Home, RotateCcw } from "lucide-react"; +import { RotateCcw } from "lucide-react"; -import { GAME_PAGE_CONTROLS } from "@/pages/game/config"; +import { GAME_PAGE_MOBILE_TOP_BAR_TEST_IDS } from "@/pages/game/config"; import { useGamePageMobileTopBarModel } from "@/pages/game/model"; import { Button } from "@/shared/ui"; import { MobileCameraModeSwitcher } from "@/widgets/camera-mode-switcher"; -import { MARKETING_ROUTES } from "@/widgets/marketing-shell"; + +import { GamePageHomeAction } from "./GamePageHomeAction"; export function GamePageMobileTopBar() { const viewModel = useGamePageMobileTopBarModel(); return ( -
-
- - +
+
Camera @@ -46,15 +40,25 @@ export function GamePageMobileTopBar() {
-
-
- - HP - - - {viewModel.playerHp} / {viewModel.playerMaxHp} - +
+
+
+ + HP + + + {viewModel.playerHp} / {viewModel.playerMaxHp} + +
+ +
); diff --git a/src/pages/game/ui/GamePageMobileTouchActions.tsx b/src/pages/game/ui/GamePageMobileTouchActions.tsx index 3e631d6..4a8e909 100644 --- a/src/pages/game/ui/GamePageMobileTouchActions.tsx +++ b/src/pages/game/ui/GamePageMobileTouchActions.tsx @@ -1,5 +1,8 @@ +import { + GAME_PAGE_MOBILE_TOUCH_ACTION_PANEL_CLASS_NAMES, + GAME_PAGE_MOBILE_TOUCH_ACTION_PANEL_TEST_IDS, +} from "@/pages/game/config"; import { Badge, Button } from "@/shared/ui"; - import type { GamePageMobileActionPanelModel } from "../model"; import { useMobileActionPointerGuards } from "../model/useMobileActionPointerGuards"; @@ -14,17 +17,30 @@ export function GamePageMobileTouchActions({ const { stopActionPointerPropagation } = useMobileActionPointerGuards(); return ( - <> +
{interact.hasTouchInteract ? ( ) : null} @@ -34,12 +50,22 @@ export function GamePageMobileTouchActions({ size="default" onClick={attack.handleTouchAttack} onPointerDown={stopActionPointerPropagation} - className="pointer-events-auto relative w-full font-bold" + className={GAME_PAGE_MOBILE_TOUCH_ACTION_PANEL_CLASS_NAMES.BUTTON} > - {attack.touchAttackPrompt} - + + {attack.touchAttackPrompt} + + ) : null} - +
); } diff --git a/src/pages/home/ui/HomeBootstrapAuthenticatedStatusCard.tsx b/src/pages/home/ui/HomeBootstrapAuthenticatedStatusCard.tsx index 5529973..948ce20 100644 --- a/src/pages/home/ui/HomeBootstrapAuthenticatedStatusCard.tsx +++ b/src/pages/home/ui/HomeBootstrapAuthenticatedStatusCard.tsx @@ -18,7 +18,7 @@ export function HomeBootstrapAuthenticatedStatusCard({ icon={} title={HOME_STATUS_COPY.AUTHENTICATED.title} > -
+
{readyStatusLabel ? ( diff --git a/src/pages/home/ui/HomeBootstrapStatusCard.test.tsx b/src/pages/home/ui/HomeBootstrapStatusCard.test.tsx index 1215bf5..1814960 100644 --- a/src/pages/home/ui/HomeBootstrapStatusCard.test.tsx +++ b/src/pages/home/ui/HomeBootstrapStatusCard.test.tsx @@ -74,5 +74,12 @@ describe("HomeBootstrapStatusCard", () => { expect( screen.getByText(HOME_STATUS_COPY.AUTHENTICATED.badge), ).not.toBeNull(); + + const authenticatedDetailRow = screen + .getByText(HOME_STATUS_COPY.AUTHENTICATED.detail) + .closest("div"); + + expect(authenticatedDetailRow?.className).toContain("justify-center"); + expect(authenticatedDetailRow?.className).toContain("text-center"); }); }); diff --git a/src/pages/home/ui/HomeHeroSection.tsx b/src/pages/home/ui/HomeHeroSection.tsx index de82989..16c17c9 100644 --- a/src/pages/home/ui/HomeHeroSection.tsx +++ b/src/pages/home/ui/HomeHeroSection.tsx @@ -72,7 +72,7 @@ export function HomeHeroSection({ readyStatusLabel={readyStatusLabel} /> -
+

{HOME_COPY.MOBILE_ORIENTATION_NOTICE}

diff --git a/src/pages/home/ui/HomePage.test.tsx b/src/pages/home/ui/HomePage.test.tsx index b2340d9..bcf8f73 100644 --- a/src/pages/home/ui/HomePage.test.tsx +++ b/src/pages/home/ui/HomePage.test.tsx @@ -86,6 +86,13 @@ describe("HomePage", () => { ).getAllByRole("listitem")[0]; expect(firstTeachingCard?.className).toContain("lg:col-span-2"); expect(screen.queryByText(HOME_COPY.RUNTIME_HEADING)).toBeNull(); + + const mobileOrientationNotice = screen + .getByText(HOME_COPY.MOBILE_ORIENTATION_NOTICE) + .closest("div"); + + expect(mobileOrientationNotice?.className).toContain("text-center"); + expect(mobileOrientationNotice?.className).toContain("sm:hidden"); }); it("requests username entry before authentication", () => {