From cd5613c050bfa4b0e750f4f06b479c1a724e62ae Mon Sep 17 00:00:00 2001 From: Nikolay Golovin Date: Sun, 6 Sep 2026 17:25:24 +0300 Subject: [PATCH] feat(mobile): compact play layout, touch stick, and action pad (#21) Make /play usable on phones without changing desktop: scale the canvas in compact layout, overlay the HUD panel, bridge a virtual stick into the existing movement refs (no synthetic keyboards), add touch actions, map long-press to right-click, and clear stuck movement on hide/blur. --- frontend/app/globals.css | 14 +- frontend/app/layout.tsx | 12 +- frontend/app/manifest.ts | 1 + frontend/app/play/page.tsx | 150 ++++++++++- .../components/game/core/MapRendererCore.tsx | 68 +++++ .../game/core/useKeyboardGameplay.ts | 13 + .../game/core/useRendererBootstrap.ts | 253 +++++++++++++----- .../game/core/useTouchStickMovement.ts | 197 ++++++++++++++ .../game/mobile/LandscapePlayGate.tsx | 50 ++++ .../game/mobile/MobilePlayControls.tsx | 56 ++++ .../game/mobile/TouchActionCluster.tsx | 101 +++++++ .../game/mobile/VirtualAnalogStick.tsx | 141 ++++++++++ frontend/lib/mobile/playability.test.ts | 111 ++++++++ frontend/lib/mobile/playability.ts | 135 ++++++++++ 14 files changed, 1217 insertions(+), 85 deletions(-) create mode 100644 frontend/components/game/core/useTouchStickMovement.ts create mode 100644 frontend/components/game/mobile/LandscapePlayGate.tsx create mode 100644 frontend/components/game/mobile/MobilePlayControls.tsx create mode 100644 frontend/components/game/mobile/TouchActionCluster.tsx create mode 100644 frontend/components/game/mobile/VirtualAnalogStick.tsx create mode 100644 frontend/lib/mobile/playability.test.ts create mode 100644 frontend/lib/mobile/playability.ts diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 585356cf..61a9ec31 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -69,4 +69,16 @@ html.game-targeting body *::after { left: 0; -webkit-overflow-scrolling: touch; } -} \ No newline at end of file +} + +/* Compact mobile play: keep shell fixed without changing desktop. */ +.game-shell.mobile-compact-play { + position: fixed; + inset: 0; + overflow: hidden; +} + +.game-shell.mobile-compact-play .mobile-panel-sheet { + pointer-events: auto; +} + diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index b1e72faf..47b9a8a8 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -1,4 +1,4 @@ -import type { Metadata } from "next"; +import type { Metadata, Viewport } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import AppChrome from "@/components/AppChrome"; import { @@ -19,6 +19,16 @@ const geistMono = Geist_Mono({ subsets: ["latin"], }); +export const viewport: Viewport = { + width: "device-width", + initialScale: 1, + maximumScale: 1, + userScalable: false, + viewportFit: "cover", + themeColor: "#08111f", +}; + + export const metadata: Metadata = { ...buildPageMetadata({ title: siteTitle, diff --git a/frontend/app/manifest.ts b/frontend/app/manifest.ts index c4f63e41..707b1378 100644 --- a/frontend/app/manifest.ts +++ b/frontend/app/manifest.ts @@ -8,6 +8,7 @@ export default function manifest(): MetadataRoute.Manifest { description: siteDescription, start_url: "/", display: "standalone", + orientation: "landscape", background_color: "#08111f", theme_color: "#08111f", lang: "es-AR", diff --git a/frontend/app/play/page.tsx b/frontend/app/play/page.tsx index 2be1d752..bffefe89 100644 --- a/frontend/app/play/page.tsx +++ b/frontend/app/play/page.tsx @@ -14,6 +14,11 @@ import React, { useState, } from "react"; import { MapRenderer } from "../../components/game"; +import LandscapePlayGate from "../../components/game/mobile/LandscapePlayGate"; +import { + computeCompactCanvasScale, + shouldUseCompactPlayLayout, +} from "../../lib/mobile/playability"; import AdminIntervalsModal from "../../components/AdminIntervalsModal"; import BuffStatusSidebar from "../../components/BuffStatusSidebar"; import InventoryFloatingPanel from "../../components/InventoryFloatingPanel"; @@ -863,6 +868,8 @@ function HomeContent() { const [deathHomePromptOpen, setDeathHomePromptOpen] = useState(false); const [arenaLeavePending, setArenaLeavePending] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false); + const [isCompactPlayLayout, setIsCompactPlayLayout] = useState(false); + const [mobilePanelOpen, setMobilePanelOpen] = useState(false); const [fullscreenError, setFullscreenError] = useState(null); const [showFullscreenHint, setShowFullscreenHint] = useState(false); const [showFullscreenPrompt, setShowFullscreenPrompt] = useState(false); @@ -1083,6 +1090,33 @@ function HomeContent() { return () => window.removeEventListener("resize", updateViewport); }, []); + useEffect(() => { + if (typeof window === "undefined") { + return; + } + + const coarseQuery = window.matchMedia("(pointer: coarse)"); + const updateCompact = () => { + setIsCompactPlayLayout( + shouldUseCompactPlayLayout({ + pointerCoarse: coarseQuery.matches, + maxTouchPoints: navigator.maxTouchPoints || 0, + viewportWidth: window.innerWidth, + }), + ); + }; + + updateCompact(); + coarseQuery.addEventListener("change", updateCompact); + window.addEventListener("resize", updateCompact); + window.addEventListener("orientationchange", updateCompact); + return () => { + coarseQuery.removeEventListener("change", updateCompact); + window.removeEventListener("resize", updateCompact); + window.removeEventListener("orientationchange", updateCompact); + }; + }, []); + useEffect(() => { const handleFullscreenChange = () => { const shellElement = gameShellRef.current; @@ -1474,17 +1508,20 @@ function HomeContent() { HUD_GAP + COLUMN_SECTION_GAP; - const isDesktopConsoleLayout = isFullscreen - ? viewport.width > 768 - : viewport.width > 768 && viewport.height >= minimumPinnedConsoleHeight; + const isDesktopConsoleLayout = isCompactPlayLayout + ? false + : isFullscreen + ? viewport.width > 768 + : viewport.width > 768 && + viewport.height >= minimumPinnedConsoleHeight; - const shellTopPadding = isFullscreen + const shellTopPadding = isFullscreen || isCompactPlayLayout ? SHELL_TOP_PADDING_FULLSCREEN : SHELL_VERTICAL_PADDING; - const shellBottomPadding = isFullscreen + const shellBottomPadding = isFullscreen || isCompactPlayLayout ? SHELL_BOTTOM_PADDING_FULLSCREEN : SHELL_VERTICAL_PADDING; - const shellHorizontalPadding = isFullscreen + const shellHorizontalPadding = isFullscreen || isCompactPlayLayout ? SHELL_HORIZONTAL_PADDING_FULLSCREEN : SHELL_HORIZONTAL_PADDING; @@ -1528,7 +1565,24 @@ function HomeContent() { }, []); const hudScale = useMemo(() => { - if (!isFullscreen || !viewport.width || !viewport.height) { + if (!viewport.width || !viewport.height) { + return 1; + } + + // Compact mobile: scale the canvas alone (HUD becomes overlays). + if (isCompactPlayLayout) { + return computeCompactCanvasScale({ + viewportWidth: viewport.width, + viewportHeight: viewport.height, + canvasBaseWidth: CANVAS_BASE_WIDTH, + canvasBaseHeight: CANVAS_BASE_HEIGHT, + horizontalPadding: shellHorizontalPadding, + verticalPadding: 8, + reservedBottomPx: Math.min(96, macroBarSize.height), + }); + } + + if (!isFullscreen) { return 1; } @@ -1559,6 +1613,7 @@ function HomeContent() { return Math.min(MAX_FULLSCREEN_HUD_SCALE, nextScale); }, [ + isCompactPlayLayout, isDesktopConsoleLayout, isFullscreen, macroBarSize.height, @@ -1581,7 +1636,7 @@ function HomeContent() { }; } - if (!isFullscreen) { + if (!isFullscreen && !isCompactPlayLayout) { return { canvasWidth: CANVAS_BASE_WIDTH, canvasHeight: CANVAS_BASE_HEIGHT, @@ -1597,7 +1652,13 @@ function HomeContent() { canvasWidth: scaledCanvasSize, canvasHeight: scaledCanvasSize, }; - }, [hudScale, isFullscreen, viewport.height, viewport.width]); + }, [ + hudScale, + isCompactPlayLayout, + isFullscreen, + viewport.height, + viewport.width, + ]); const toggleFullscreen = useCallback(async () => { const shellElement = gameShellRef.current; @@ -2630,7 +2691,7 @@ function HomeContent() { return (
{ event.preventDefault(); }} @@ -2638,6 +2699,7 @@ function HomeContent() { event.preventDefault(); }} > +
setIsChatOpen(true)} + onMobileTogglePanel={() => + setMobilePanelOpen((open) => !open) + } + onMobileCastSpell={() => { + const spell = + selectedSpellSlot === null + ? null + : (hud?.spells.find( + (entry) => + entry.slot === + selectedSpellSlot, + ) ?? null); + if (!spell) { + setMobilePanelOpen(true); + return; + } + setSpellTargetRequest((current) => ({ + slot: spell.slot, + manaRequired: spell.manaRequired, + name: spell.name, + token: (current?.token ?? 0) + 1, + })); + }} + onMobileUseItem={() => { + const equipped = + hud?.inventory.find( + (item) => item.equipped, + ) ?? hud?.inventory[0]; + if (!equipped) { + setMobilePanelOpen(true); + return; + } + setUseItemURequest((current) => ({ + slot: equipped.slot, + token: (current?.token ?? 0) + 1, + })); + }} /> {!arenaMode && @@ -3095,7 +3196,8 @@ function HomeContent() { ) : null}
- {isCharacterSettingsLoading ? ( + {!isCompactPlayLayout ? ( + isCharacterSettingsLoading ? ( - )} + ) + ) : null}
+ {(!isCompactPlayLayout || mobilePanelOpen) && ( +
+ {isCompactPlayLayout ? ( +
+ +
+ ) : null} @@ -3460,6 +3582,8 @@ function HomeContent() {
+ + )} diff --git a/frontend/components/game/core/MapRendererCore.tsx b/frontend/components/game/core/MapRendererCore.tsx index 0da3d60b..8be9a90f 100644 --- a/frontend/components/game/core/MapRendererCore.tsx +++ b/frontend/components/game/core/MapRendererCore.tsx @@ -16,7 +16,9 @@ import { } from "../../../utils/gameLoader"; import { getApiBaseUrl } from "../../../lib/api-base-url"; import { + createChangeSeguroPacket, createDialogPacket, + createPickupItemPacket, type ChatChannel, type CharacterStatsSnapshot, type PanelSnapshot, @@ -69,6 +71,8 @@ import { useAssetPipeline } from "./useAssetPipeline"; import { useMovementSync, type LocalPendingMove } from "./useMovementSync"; import { useCombatController, type TargetingMode } from "./useCombatController"; import { useKeyboardGameplay } from "./useKeyboardGameplay"; +import { useTouchStickMovement } from "./useTouchStickMovement"; +import MobilePlayControls from "../mobile/MobilePlayControls"; import { useNpcAdminTools } from "./useNpcAdminTools"; import { useHudStateController } from "./useHudStateController"; import { useSceneController } from "./useSceneController"; @@ -209,8 +213,14 @@ interface MapRendererProps { onAdminOverviewSnapshot?: (snapshot: PanelSnapshot) => void; onCharacterStatsSnapshot?: (snapshot: CharacterStatsSnapshot) => void; onPerformanceSample?: (sample: PerformanceSample) => void; + mobileControlsEnabled?: boolean; + onMobileOpenChat?: () => void; + onMobileTogglePanel?: () => void; + onMobileCastSpell?: () => void; + onMobileUseItem?: () => void; } + interface ManualConnectionConfig { wsUrl: string; ticket: string; @@ -704,6 +714,11 @@ export default function MapRenderer({ onAdminOverviewSnapshot, onCharacterStatsSnapshot, onPerformanceSample, + mobileControlsEnabled = false, + onMobileOpenChat, + onMobileTogglePanel, + onMobileCastSpell, + onMobileUseItem, }: MapRendererProps) { const canvasRef = useRef(null); const rendererRootRef = useRef(null); @@ -1333,6 +1348,47 @@ export default function MapRenderer({ setIsDebugMode, }); + const { applyStickVector, releaseStick } = useTouchStickMovement({ + isMounted, + enabled: mobileControlsEnabled, + engineRef, + movementKeyMapRef, + movementPressCountsRef, + movementKeyPriorityRef, + canProcessMovementInput, + clearMovementInputState, + syncMovementState, + }); + + const handleMobileAttack = React.useCallback(() => { + const activeEngine = engineRef.current; + if (hasEquippedMeleeWeapon()) { + activeEngine?.sendMeleeAttackPacket?.(); + return; + } + if (hasEquippedRangedWeapon()) { + setTargetingMode({ type: "range" }); + } + }, [hasEquippedMeleeWeapon, hasEquippedRangedWeapon, setTargetingMode]); + + const handleMobilePickup = React.useCallback(() => { + const socket = websocketRef.current; + if (!socket || socket.readyState !== WebSocket.OPEN) { + return; + } + socket.send(createPickupItemPacket()); + recordClientGameAction("pickup_item", { source: "mobile" }); + }, [recordClientGameAction]); + + const handleMobileToggleSeguro = React.useCallback(() => { + const socket = websocketRef.current; + if (!socket || socket.readyState !== WebSocket.OPEN) { + return; + } + socket.send(createChangeSeguroPacket()); + recordClientGameAction("toggle_seguro", { source: "mobile" }); + }, [recordClientGameAction]); + const { clearUseItemQueues } = useOutgoingRequests({ websocketRef, engineRef, @@ -2072,6 +2128,18 @@ export default function MapRenderer({ ) } /> + onMobileCastSpell?.()} + onUseItem={() => onMobileUseItem?.()} + onPickup={handleMobilePickup} + onToggleSeguro={handleMobileToggleSeguro} + onOpenChat={() => onMobileOpenChat?.()} + onTogglePanel={() => onMobileTogglePanel?.()} + /> ); diff --git a/frontend/components/game/core/useKeyboardGameplay.ts b/frontend/components/game/core/useKeyboardGameplay.ts index c7b88664..42c6635c 100644 --- a/frontend/components/game/core/useKeyboardGameplay.ts +++ b/frontend/components/game/core/useKeyboardGameplay.ts @@ -315,14 +315,27 @@ export function useKeyboardGameplay({ clearMovementInputState(engineRef.current); }; + const handleVisibilityChange = () => { + if (document.visibilityState === "hidden") { + clearMovementInputState(engineRef.current); + } + }; + document.addEventListener("keydown", handleKeyDown, true); document.addEventListener("keyup", handleKeyUp, true); window.addEventListener("blur", handleBlur); + document.addEventListener("visibilitychange", handleVisibilityChange); + window.addEventListener("pagehide", handleBlur); return () => { document.removeEventListener("keydown", handleKeyDown, true); document.removeEventListener("keyup", handleKeyUp, true); window.removeEventListener("blur", handleBlur); + document.removeEventListener( + "visibilitychange", + handleVisibilityChange, + ); + window.removeEventListener("pagehide", handleBlur); }; }, [ canProcessMovementInput, diff --git a/frontend/components/game/core/useRendererBootstrap.ts b/frontend/components/game/core/useRendererBootstrap.ts index f0a43306..803acb8e 100644 --- a/frontend/components/game/core/useRendererBootstrap.ts +++ b/frontend/components/game/core/useRendererBootstrap.ts @@ -17,6 +17,11 @@ import { createPositionPacket, } from "../../../lib/aowProtocol"; import { TILE_SIZE } from "../../../lib/viewport"; +import { + TOUCH_LONG_PRESS_MS, + TOUCH_LONG_PRESS_MOVE_TOLERANCE_PX, + shouldTreatPointerAsTouch, +} from "../../../lib/mobile/playability"; import { createDebugGrid } from "../rendering/debugGrid"; import { createEntityFXRowContainers, @@ -448,6 +453,102 @@ export function useRendererBootstrap(options: UseRendererBootstrapOptions) { }); }; + const handleSecondaryWorldClick = ( + interaction: { + socket: WebSocket; + targetTileX: number; + targetTileY: number; + }, + event: FederatedPointerEvent, + ) => { + const clickedNpc = findInspectableNpcAtTile( + engine, + interaction.targetTileX, + interaction.targetTileY, + ); + const clickedDeadCharacter = findRevivableCharacterAtTile( + engine, + interaction.targetTileX, + interaction.targetTileY, + ); + const allowAdminNpcInspect = isAdminInspector( + engine, + options.playerHudRef.current, + ); + + if (allowAdminNpcInspect && clickedDeadCharacter) { + const containerRect = + options.rendererRootRef.current?.getBoundingClientRect(); + const rawX = + event.clientX - (containerRect?.left ?? 0); + const rawY = + event.clientY - (containerRect?.top ?? 0); + options.setDeadCharacterContextMenu({ + x: Math.max(12, rawX), + y: Math.max(12, rawY), + character: { + entityId: clickedDeadCharacter.id, + name: + clickedDeadCharacter.nameCharacter?.trim() || + `Entity-${clickedDeadCharacter.id}`, + }, + }); + options.setNpcContextMenu(null); + options.npcContextMenuOpenedAtRef.current = + event.timeStamp; + return; + } + + if ( + clickedNpc && + canInspectNpc( + engine, + clickedNpc, + allowAdminNpcInspect, + ) + ) { + const containerRect = + options.rendererRootRef.current?.getBoundingClientRect(); + const rawX = + event.clientX - (containerRect?.left ?? 0); + const rawY = + event.clientY - (containerRect?.top ?? 0); + options.setNpcContextMenu({ + x: Math.max(12, rawX), + y: Math.max(12, rawY), + npc: buildInspectableNpc(engine, clickedNpc), + }); + options.setDeadCharacterContextMenu(null); + options.npcContextMenuOpenedAtRef.current = + event.timeStamp; + return; + } + + sendInteractionClickPacket( + interaction.socket, + interaction.targetTileX, + interaction.targetTileY, + 2, + ); + }; + + let touchLongPressTimer: number | null = null; + let touchLongPressFired = false; + let touchPointerId: number | null = null; + let touchStartClient = { x: 0, y: 0 }; + let touchStartInteraction: { + socket: WebSocket; + targetTileX: number; + targetTileY: number; + } | null = null; + + const clearTouchLongPress = () => { + if (touchLongPressTimer !== null) { + window.clearTimeout(touchLongPressTimer); + touchLongPressTimer = null; + } + }; + mapContainer.on("pointerdown", (event) => { const interaction = getInteractionContext(event); if (!interaction) { @@ -465,76 +566,7 @@ export function useRendererBootstrap(options: UseRendererBootstrapOptions) { const isRightClick = event.button === 2; if (isRightClick) { - const clickedNpc = findInspectableNpcAtTile( - engine, - interaction.targetTileX, - interaction.targetTileY, - ); - const clickedDeadCharacter = - findRevivableCharacterAtTile( - engine, - interaction.targetTileX, - interaction.targetTileY, - ); - const allowAdminNpcInspect = isAdminInspector( - engine, - options.playerHudRef.current, - ); - - if (allowAdminNpcInspect && clickedDeadCharacter) { - const containerRect = - options.rendererRootRef.current?.getBoundingClientRect(); - const rawX = - event.clientX - (containerRect?.left ?? 0); - const rawY = - event.clientY - (containerRect?.top ?? 0); - options.setDeadCharacterContextMenu({ - x: Math.max(12, rawX), - y: Math.max(12, rawY), - character: { - entityId: clickedDeadCharacter.id, - name: - clickedDeadCharacter.nameCharacter?.trim() || - `Entity-${clickedDeadCharacter.id}`, - }, - }); - options.setNpcContextMenu(null); - options.npcContextMenuOpenedAtRef.current = - event.timeStamp; - return; - } - - if ( - clickedNpc && - canInspectNpc( - engine, - clickedNpc, - allowAdminNpcInspect, - ) - ) { - const containerRect = - options.rendererRootRef.current?.getBoundingClientRect(); - const rawX = - event.clientX - (containerRect?.left ?? 0); - const rawY = - event.clientY - (containerRect?.top ?? 0); - options.setNpcContextMenu({ - x: Math.max(12, rawX), - y: Math.max(12, rawY), - npc: buildInspectableNpc(engine, clickedNpc), - }); - options.setDeadCharacterContextMenu(null); - options.npcContextMenuOpenedAtRef.current = - event.timeStamp; - return; - } - - sendInteractionClickPacket( - interaction.socket, - interaction.targetTileX, - interaction.targetTileY, - 2, - ); + handleSecondaryWorldClick(interaction, event); return; } @@ -542,6 +574,33 @@ export function useRendererBootstrap(options: UseRendererBootstrapOptions) { return; } + const isTouchLike = shouldTreatPointerAsTouch( + (event as FederatedPointerEvent & { pointerType?: string }) + .pointerType, + ); + + if (isTouchLike) { + clearTouchLongPress(); + touchLongPressFired = false; + touchPointerId = event.pointerId; + touchStartClient = { + x: event.clientX, + y: event.clientY, + }; + touchStartInteraction = interaction; + touchLongPressTimer = window.setTimeout(() => { + touchLongPressTimer = null; + touchLongPressFired = true; + if (touchStartInteraction) { + handleSecondaryWorldClick( + touchStartInteraction, + event, + ); + } + }, TOUCH_LONG_PRESS_MS); + return; + } + sendInteractionClickPacket( interaction.socket, interaction.targetTileX, @@ -549,7 +608,61 @@ export function useRendererBootstrap(options: UseRendererBootstrapOptions) { ); }); + mapContainer.on("globalpointermove", (event) => { + if ( + touchPointerId === null || + event.pointerId !== touchPointerId || + touchLongPressFired + ) { + return; + } + + const dx = event.clientX - touchStartClient.x; + const dy = event.clientY - touchStartClient.y; + if ( + Math.hypot(dx, dy) < TOUCH_LONG_PRESS_MOVE_TOLERANCE_PX + ) { + return; + } + + clearTouchLongPress(); + touchPointerId = null; + const start = touchStartInteraction; + touchStartInteraction = null; + if (start && !options.targetingModeRef.current) { + sendInteractionClickPacket( + start.socket, + start.targetTileX, + start.targetTileY, + ); + } + }); + mapContainer.on("pointerup", (event) => { + if ( + touchPointerId !== null && + event.pointerId === touchPointerId + ) { + const wasLongPress = touchLongPressFired; + clearTouchLongPress(); + touchPointerId = null; + const start = touchStartInteraction; + touchStartInteraction = null; + + if ( + !wasLongPress && + start && + !options.targetingModeRef.current && + event.button !== 2 + ) { + sendInteractionClickPacket( + start.socket, + start.targetTileX, + start.targetTileY, + ); + } + } + const targetingMode = options.targetingModeRef.current; if (!targetingMode || event.button === 2) { return; diff --git a/frontend/components/game/core/useTouchStickMovement.ts b/frontend/components/game/core/useTouchStickMovement.ts new file mode 100644 index 00000000..baee1da1 --- /dev/null +++ b/frontend/components/game/core/useTouchStickMovement.ts @@ -0,0 +1,197 @@ +import { useCallback, useEffect, useRef } from "react"; +import type { Engine } from "../engine/Engine"; +import { + TOUCH_STICK_SOURCE_CODE, + headingToMovementKeyCode, + vectorToCardinalHeading, + type CardinalHeading, +} from "../../../lib/mobile/playability"; + +type UseTouchStickMovementOptions = { + isMounted: boolean; + enabled: boolean; + engineRef: { current: Engine | null }; + movementKeyMapRef: { current: Map }; + movementPressCountsRef: { current: Map }; + movementKeyPriorityRef: { current: number[] }; + canProcessMovementInput: () => boolean; + clearMovementInputState: (engine?: Engine | null) => void; + syncMovementState: (engine: Engine) => void; +}; + +/** + * Bridges a virtual stick into the same movement refs the keyboard hook uses. + * Uses a dedicated source code so keyboard presses are not wiped on direction changes. + * Never synthesizes KeyboardEvents (rejected via isTrusted). + */ +export function useTouchStickMovement({ + isMounted, + enabled, + engineRef, + movementKeyMapRef, + movementPressCountsRef, + movementKeyPriorityRef, + canProcessMovementInput, + clearMovementInputState, + syncMovementState, +}: UseTouchStickMovementOptions) { + const activeHeadingRef = useRef(null); + + const releaseStick = useCallback(() => { + const previousKey = movementKeyMapRef.current.get( + TOUCH_STICK_SOURCE_CODE, + ); + if (previousKey === undefined) { + activeHeadingRef.current = null; + return; + } + + movementKeyMapRef.current.delete(TOUCH_STICK_SOURCE_CODE); + + const nextCount = Math.max( + 0, + (movementPressCountsRef.current.get(previousKey) ?? 1) - 1, + ); + if (nextCount === 0) { + movementPressCountsRef.current.delete(previousKey); + movementKeyPriorityRef.current = + movementKeyPriorityRef.current.filter( + (code) => code !== previousKey, + ); + } else { + movementPressCountsRef.current.set(previousKey, nextCount); + } + + activeHeadingRef.current = null; + + const activeEngine = engineRef.current; + if (activeEngine && canProcessMovementInput()) { + syncMovementState(activeEngine); + } + }, [ + canProcessMovementInput, + engineRef, + movementKeyMapRef, + movementKeyPriorityRef, + movementPressCountsRef, + syncMovementState, + ]); + + const applyStickVector = useCallback( + (x: number, y: number) => { + if (!isMounted || !enabled) { + return; + } + + const activeEngine = engineRef.current; + if (!activeEngine) { + return; + } + + const heading = vectorToCardinalHeading(x, y); + if (!heading) { + releaseStick(); + return; + } + + if (heading === activeHeadingRef.current) { + return; + } + + const nextKey = headingToMovementKeyCode( + heading, + activeEngine.KEY_CODES, + ); + const previousKey = movementKeyMapRef.current.get( + TOUCH_STICK_SOURCE_CODE, + ); + + if (previousKey !== undefined && previousKey !== nextKey) { + const nextCount = Math.max( + 0, + (movementPressCountsRef.current.get(previousKey) ?? 1) - 1, + ); + if (nextCount === 0) { + movementPressCountsRef.current.delete(previousKey); + movementKeyPriorityRef.current = + movementKeyPriorityRef.current.filter( + (code) => code !== previousKey, + ); + } else { + movementPressCountsRef.current.set(previousKey, nextCount); + } + } + + if (previousKey !== nextKey) { + movementKeyMapRef.current.set( + TOUCH_STICK_SOURCE_CODE, + nextKey, + ); + movementPressCountsRef.current.set( + nextKey, + (movementPressCountsRef.current.get(nextKey) ?? 0) + 1, + ); + } + + movementKeyPriorityRef.current = + movementKeyPriorityRef.current.filter( + (code) => code !== nextKey, + ); + movementKeyPriorityRef.current.unshift(nextKey); + activeHeadingRef.current = heading; + + if (canProcessMovementInput()) { + syncMovementState(activeEngine); + } + }, + [ + canProcessMovementInput, + enabled, + engineRef, + isMounted, + movementKeyMapRef, + movementKeyPriorityRef, + movementPressCountsRef, + releaseStick, + syncMovementState, + ], + ); + + useEffect(() => { + if (!isMounted || !enabled) { + releaseStick(); + return; + } + + const handleHidden = () => { + clearMovementInputState(engineRef.current); + activeHeadingRef.current = null; + }; + + const onVisibility = () => { + if (document.visibilityState === "hidden") { + handleHidden(); + } + }; + + document.addEventListener("visibilitychange", onVisibility); + window.addEventListener("pagehide", handleHidden); + + return () => { + document.removeEventListener("visibilitychange", onVisibility); + window.removeEventListener("pagehide", handleHidden); + releaseStick(); + }; + }, [ + clearMovementInputState, + enabled, + engineRef, + isMounted, + releaseStick, + ]); + + return { + applyStickVector, + releaseStick, + }; +} diff --git a/frontend/components/game/mobile/LandscapePlayGate.tsx b/frontend/components/game/mobile/LandscapePlayGate.tsx new file mode 100644 index 00000000..cb7aa19c --- /dev/null +++ b/frontend/components/game/mobile/LandscapePlayGate.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { shouldRequireLandscape } from "../../../lib/mobile/playability"; + +/** + * Portrait overlay for compact play. Does not remount the game tree — + * rotation must not drop the WebSocket session. + */ +export default function LandscapePlayGate({ enabled }: { enabled: boolean }) { + const [portrait, setPortrait] = useState(false); + + useEffect(() => { + if (!enabled) { + setPortrait(false); + return; + } + + const update = () => { + setPortrait( + shouldRequireLandscape(window.innerWidth, window.innerHeight), + ); + }; + + update(); + window.addEventListener("resize", update); + window.addEventListener("orientationchange", update); + return () => { + window.removeEventListener("resize", update); + window.removeEventListener("orientationchange", update); + }; + }, [enabled]); + + if (!enabled || !portrait) { + return null; + } + + return ( +
+
+ ⟳ +
+

Girá el dispositivo

+

+ OpenAO en móvil se juega en horizontal. Rotá la pantalla para + seguir — la sesión no se corta al rotar. +

+
+ ); +} diff --git a/frontend/components/game/mobile/MobilePlayControls.tsx b/frontend/components/game/mobile/MobilePlayControls.tsx new file mode 100644 index 00000000..4ffc0b8e --- /dev/null +++ b/frontend/components/game/mobile/MobilePlayControls.tsx @@ -0,0 +1,56 @@ +"use client"; + +import VirtualAnalogStick from "./VirtualAnalogStick"; +import TouchActionCluster from "./TouchActionCluster"; + +type MobilePlayControlsProps = { + enabled: boolean; + onStickVector: (x: number, y: number) => void; + onStickRelease: () => void; + onAttack: () => void; + onCastSpell: () => void; + onUseItem: () => void; + onPickup: () => void; + onToggleSeguro: () => void; + onOpenChat: () => void; + onTogglePanel: () => void; +}; + +export default function MobilePlayControls({ + enabled, + onStickVector, + onStickRelease, + onAttack, + onCastSpell, + onUseItem, + onPickup, + onToggleSeguro, + onOpenChat, + onTogglePanel, +}: MobilePlayControlsProps) { + if (!enabled) { + return null; + } + + return ( +
+
+ +
+
+ +
+
+ ); +} diff --git a/frontend/components/game/mobile/TouchActionCluster.tsx b/frontend/components/game/mobile/TouchActionCluster.tsx new file mode 100644 index 00000000..dbec31c9 --- /dev/null +++ b/frontend/components/game/mobile/TouchActionCluster.tsx @@ -0,0 +1,101 @@ +"use client"; + +type TouchActionClusterProps = { + onAttack: () => void; + onCastSpell: () => void; + onUseItem: () => void; + onPickup: () => void; + onToggleSeguro: () => void; + onOpenChat: () => void; + onTogglePanel: () => void; +}; + +function ActionButton({ + label, + sublabel, + onPress, + className, +}: { + label: string; + sublabel?: string; + onPress: () => void; + className: string; +}) { + return ( + + ); +} + +export default function TouchActionCluster({ + onAttack, + onCastSpell, + onUseItem, + onPickup, + onToggleSeguro, + onOpenChat, + onTogglePanel, +}: TouchActionClusterProps) { + return ( +
+
+ + + +
+
+ + + + +
+
+ ); +} diff --git a/frontend/components/game/mobile/VirtualAnalogStick.tsx b/frontend/components/game/mobile/VirtualAnalogStick.tsx new file mode 100644 index 00000000..8079f30b --- /dev/null +++ b/frontend/components/game/mobile/VirtualAnalogStick.tsx @@ -0,0 +1,141 @@ +"use client"; + +import { useCallback, useRef, useState } from "react"; +import { TOUCH_STICK_DEAD_ZONE } from "../../../lib/mobile/playability"; + +type VirtualAnalogStickProps = { + onVector: (x: number, y: number) => void; + onRelease: () => void; + size?: number; +}; + +export default function VirtualAnalogStick({ + onVector, + onRelease, + size = 128, +}: VirtualAnalogStickProps) { + const baseRef = useRef(null); + const pointerIdRef = useRef(null); + const [thumb, setThumb] = useState({ x: 0, y: 0 }); + const [active, setActive] = useState(false); + + const updateFromClient = useCallback( + (clientX: number, clientY: number) => { + const base = baseRef.current; + if (!base) { + return; + } + + const rect = base.getBoundingClientRect(); + const centerX = rect.left + rect.width / 2; + const centerY = rect.top + rect.height / 2; + const dx = clientX - centerX; + const dy = clientY - centerY; + const radius = size / 2; + const distance = Math.hypot(dx, dy); + const clamped = Math.min(distance, radius); + const angle = Math.atan2(dy, dx); + const thumbX = Math.cos(angle) * clamped; + const thumbY = Math.sin(angle) * clamped; + const normX = distance > 0 ? (dx / distance) * (clamped / radius) : 0; + const normY = distance > 0 ? (dy / distance) * (clamped / radius) : 0; + + setThumb({ x: thumbX, y: thumbY }); + + if (Math.hypot(normX, normY) < TOUCH_STICK_DEAD_ZONE) { + onVector(0, 0); + return; + } + + onVector(normX, normY); + }, + [onVector, size], + ); + + const handlePointerDown = useCallback( + (event: React.PointerEvent) => { + if (pointerIdRef.current !== null) { + return; + } + + pointerIdRef.current = event.pointerId; + event.currentTarget.setPointerCapture(event.pointerId); + setActive(true); + updateFromClient(event.clientX, event.clientY); + event.preventDefault(); + event.stopPropagation(); + }, + [updateFromClient], + ); + + const handlePointerMove = useCallback( + (event: React.PointerEvent) => { + if (pointerIdRef.current !== event.pointerId) { + return; + } + + updateFromClient(event.clientX, event.clientY); + event.preventDefault(); + event.stopPropagation(); + }, + [updateFromClient], + ); + + const endPointer = useCallback( + (event: React.PointerEvent) => { + if (pointerIdRef.current !== event.pointerId) { + return; + } + + pointerIdRef.current = null; + setActive(false); + setThumb({ x: 0, y: 0 }); + onRelease(); + event.preventDefault(); + event.stopPropagation(); + }, + [onRelease], + ); + + return ( +
+
+
+ ); +} diff --git a/frontend/lib/mobile/playability.test.ts b/frontend/lib/mobile/playability.test.ts new file mode 100644 index 00000000..c80d0f6c --- /dev/null +++ b/frontend/lib/mobile/playability.test.ts @@ -0,0 +1,111 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + computeCompactCanvasScale, + headingToMovementKeyCode, + shouldRequireLandscape, + shouldTreatPointerAsTouch, + shouldUseCompactPlayLayout, + vectorToCardinalHeading, +} from "./playability.ts"; + +const KEY_CODES = { W: 87, A: 65, S: 83, D: 68 }; + +describe("shouldUseCompactPlayLayout", () => { + it("enables for coarse pointers regardless of width", () => { + assert.equal( + shouldUseCompactPlayLayout({ + pointerCoarse: true, + maxTouchPoints: 0, + viewportWidth: 1400, + }), + true, + ); + }); + + it("enables for narrow touch viewports", () => { + assert.equal( + shouldUseCompactPlayLayout({ + pointerCoarse: false, + maxTouchPoints: 5, + viewportWidth: 390, + }), + true, + ); + }); + + it("keeps desktop fine-pointer unchanged", () => { + assert.equal( + shouldUseCompactPlayLayout({ + pointerCoarse: false, + maxTouchPoints: 0, + viewportWidth: 1440, + }), + false, + ); + }); +}); + +describe("vectorToCardinalHeading", () => { + it("returns null inside the dead zone", () => { + assert.equal(vectorToCardinalHeading(0.1, 0.1), null); + }); + + it("maps dominant axes to WASD headings", () => { + assert.equal(vectorToCardinalHeading(1, 0), "right"); + assert.equal(vectorToCardinalHeading(-1, 0.2), "left"); + assert.equal(vectorToCardinalHeading(0.1, -1), "up"); + assert.equal(vectorToCardinalHeading(0.2, 1), "down"); + }); +}); + +describe("headingToMovementKeyCode", () => { + it("uses engine KEY_CODES, not hardcoded assumptions beyond the fixture", () => { + assert.equal(headingToMovementKeyCode("up", KEY_CODES), 87); + assert.equal(headingToMovementKeyCode("left", KEY_CODES), 65); + assert.equal(headingToMovementKeyCode("down", KEY_CODES), 83); + assert.equal(headingToMovementKeyCode("right", KEY_CODES), 68); + }); +}); + +describe("computeCompactCanvasScale", () => { + it("scales a phone landscape box to fit the 672 canvas", () => { + const scale = computeCompactCanvasScale({ + viewportWidth: 844, + viewportHeight: 390, + canvasBaseWidth: 672, + canvasBaseHeight: 672, + horizontalPadding: 8, + verticalPadding: 8, + reservedBottomPx: 0, + }); + assert.ok(scale < 1); + assert.ok(scale > 0.4); + }); + + it("does not exceed max scale on large screens", () => { + const scale = computeCompactCanvasScale({ + viewportWidth: 2000, + viewportHeight: 1200, + canvasBaseWidth: 672, + canvasBaseHeight: 672, + horizontalPadding: 24, + verticalPadding: 24, + maxScale: 1.35, + }); + assert.equal(scale, 1.35); + }); +}); + +describe("orientation and pointer helpers", () => { + it("requires landscape in portrait", () => { + assert.equal(shouldRequireLandscape(390, 844), true); + assert.equal(shouldRequireLandscape(844, 390), false); + }); + + it("treats touch/pen as touch pointers", () => { + assert.equal(shouldTreatPointerAsTouch("touch"), true); + assert.equal(shouldTreatPointerAsTouch("pen"), true); + assert.equal(shouldTreatPointerAsTouch("mouse"), false); + }); +}); diff --git a/frontend/lib/mobile/playability.ts b/frontend/lib/mobile/playability.ts new file mode 100644 index 00000000..55449b65 --- /dev/null +++ b/frontend/lib/mobile/playability.ts @@ -0,0 +1,135 @@ +export const MOBILE_LAYOUT_MAX_WIDTH_PX = 900; +export const TOUCH_STICK_SOURCE_CODE = "__touch_stick__"; +export const TOUCH_STICK_DEAD_ZONE = 0.28; +export const TOUCH_LONG_PRESS_MS = 480; +export const TOUCH_LONG_PRESS_MOVE_TOLERANCE_PX = 14; +export const MAX_COMPACT_CANVAS_SCALE = 1.35; + +export type CardinalHeading = "up" | "down" | "left" | "right"; + +export type MovementKeyCodes = { + W: number; + A: number; + S: number; + D: number; +}; + +export type CompactLayoutHints = { + pointerCoarse: boolean; + maxTouchPoints: number; + viewportWidth: number; +}; + +/** + * Compact play layout: coarse pointer, or a narrow touch viewport. + * Desktop fine-pointer stays on the classic side-by-side HUD. + */ +export function shouldUseCompactPlayLayout(hints: CompactLayoutHints): boolean { + if (hints.pointerCoarse) { + return true; + } + + return ( + hints.maxTouchPoints > 0 && + hints.viewportWidth > 0 && + hints.viewportWidth <= MOBILE_LAYOUT_MAX_WIDTH_PX + ); +} + +export function shouldRequireLandscape( + width: number, + height: number, +): boolean { + return width > 0 && height > 0 && height > width; +} + +/** + * Map a normalized stick vector to a cardinal heading. + * Returns null inside the dead zone so the stick can idle without walking. + */ +export function vectorToCardinalHeading( + x: number, + y: number, + deadZone: number = TOUCH_STICK_DEAD_ZONE, +): CardinalHeading | null { + const magnitude = Math.hypot(x, y); + if (!Number.isFinite(magnitude) || magnitude < deadZone) { + return null; + } + + if (Math.abs(x) >= Math.abs(y)) { + return x >= 0 ? "right" : "left"; + } + + return y >= 0 ? "down" : "up"; +} + +export function headingToMovementKeyCode( + heading: CardinalHeading, + keyCodes: MovementKeyCodes, +): number { + switch (heading) { + case "up": + return keyCodes.W; + case "down": + return keyCodes.S; + case "left": + return keyCodes.A; + case "right": + return keyCodes.D; + } +} + +/** + * Fit the fixed 21x21 canvas into the available viewport. + * Does not enlarge the server-visible field — CSS/container scale only. + */ +export function computeCompactCanvasScale(options: { + viewportWidth: number; + viewportHeight: number; + canvasBaseWidth: number; + canvasBaseHeight: number; + horizontalPadding: number; + verticalPadding: number; + reservedBottomPx?: number; + maxScale?: number; +}): number { + const { + viewportWidth, + viewportHeight, + canvasBaseWidth, + canvasBaseHeight, + horizontalPadding, + verticalPadding, + reservedBottomPx = 0, + maxScale = MAX_COMPACT_CANVAS_SCALE, + } = options; + + if ( + viewportWidth <= 0 || + viewportHeight <= 0 || + canvasBaseWidth <= 0 || + canvasBaseHeight <= 0 + ) { + return 1; + } + + const availableWidth = Math.max(1, viewportWidth - horizontalPadding * 2); + const availableHeight = Math.max( + 1, + viewportHeight - verticalPadding * 2 - reservedBottomPx, + ); + const widthScale = availableWidth / canvasBaseWidth; + const heightScale = availableHeight / canvasBaseHeight; + const nextScale = Math.min(widthScale, heightScale, maxScale); + + if (!Number.isFinite(nextScale) || nextScale <= 0) { + return 1; + } + + return nextScale; +} + +export function shouldTreatPointerAsTouch(pointerType: string | undefined): boolean { + return pointerType === "touch" || pointerType === "pen"; +}