From 6df517bb76ccf944ffee8fd2c573d260b18d4786 Mon Sep 17 00:00:00 2001 From: waterWang Date: Mon, 17 Aug 2026 07:29:19 +0800 Subject: [PATCH] feat(mobile): add floating joystick, touch gameplay, responsive layout, and PWA support (Closes #21) Signed-off-by: waterWang --- frontend/app/globals.css | 49 +++++- frontend/app/layout.tsx | 9 + frontend/app/manifest.ts | 1 + frontend/app/play/page.tsx | 4 +- .../components/game/core/MapRendererCore.tsx | 20 +++ .../components/game/core/useTouchGameplay.ts | 144 ++++++++++++++++ frontend/components/game/input/Joystick.tsx | 160 ++++++++++++++++++ .../components/game/input/LandscapePrompt.tsx | 61 +++++++ 8 files changed, 446 insertions(+), 2 deletions(-) create mode 100644 frontend/components/game/core/useTouchGameplay.ts create mode 100644 frontend/components/game/input/Joystick.tsx create mode 100644 frontend/components/game/input/LandscapePrompt.tsx diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 585356cf..4802cf39 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -69,4 +69,51 @@ html.game-targeting body *::after { left: 0; -webkit-overflow-scrolling: touch; } -} \ No newline at end of file +} + +/* Mobile-specific: landscape rotation prompt */ +@media (orientation: portrait) and (max-width: 768px) { + .game-shell .mobile-landscape-overlay { + display: flex; + } +} + +@media (orientation: landscape) and (max-width: 768px) { + .game-shell .mobile-landscape-overlay { + display: none; + } +} + +/* Hide joystick on desktop */ +@media (pointer: coarse) { + .mobile-only-joystick { + display: block; + } +} + +@media (pointer: fine) { + .mobile-only-joystick { + display: none; + } +} + +/* Responsive canvas scaling */ +@media screen and (max-width: 768px) { + .game-shell { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + -webkit-overflow-scrolling: touch; + overflow: hidden; + } + + .game-shell canvas, + .game-shell [class*="canvas-"] { + max-width: 100vw; + max-height: 100vh; + width: auto; + height: auto; + } +} diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index b1e72faf..240a865d 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -19,6 +19,15 @@ const geistMono = Geist_Mono({ subsets: ["latin"], }); +export const viewport: import("next").Viewport = { + width: "device-width", + initialScale: 1, + maximumScale: 1, + userScalable: false, + viewportFit: "cover", + themeColor: "#020617", +}; + 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..060dcd0a 100644 --- a/frontend/app/play/page.tsx +++ b/frontend/app/play/page.tsx @@ -68,6 +68,7 @@ import { } from "../../lib/viewport"; const DEFAULT_WS_URL = process.env.NEXT_PUBLIC_WS_URL || "ws://localhost:7666"; +import LandscapePrompt from "../../components/game/input/LandscapePrompt"; const OverviewModal = dynamic(() => import("../../components/OverviewModal"), { ssr: false, }); @@ -3857,6 +3858,7 @@ export default function Home() { return ( }> - + + ); } diff --git a/frontend/components/game/core/MapRendererCore.tsx b/frontend/components/game/core/MapRendererCore.tsx index 0da3d60b..391772c1 100644 --- a/frontend/components/game/core/MapRendererCore.tsx +++ b/frontend/components/game/core/MapRendererCore.tsx @@ -69,6 +69,8 @@ import { useAssetPipeline } from "./useAssetPipeline"; import { useMovementSync, type LocalPendingMove } from "./useMovementSync"; import { useCombatController, type TargetingMode } from "./useCombatController"; import { useKeyboardGameplay } from "./useKeyboardGameplay"; +import { useTouchGameplay } from "./useTouchGameplay"; +import Joystick from "../input/Joystick"; import { useNpcAdminTools } from "./useNpcAdminTools"; import { useHudStateController } from "./useHudStateController"; import { useSceneController } from "./useSceneController"; @@ -1333,6 +1335,18 @@ export default function MapRenderer({ setIsDebugMode, }); + const { + onJoystickMove, + onJoystickMoveEnd, + } = useTouchGameplay({ + isMounted, + engineRef, + movementKeyPriorityRef, + movementPressCountsRef, + canProcessMovementInput, + syncMovementState, + }); + const { clearUseItemQueues } = useOutgoingRequests({ websocketRef, engineRef, @@ -2073,6 +2087,12 @@ export default function MapRenderer({ } /> +
+ +
); } diff --git a/frontend/components/game/core/useTouchGameplay.ts b/frontend/components/game/core/useTouchGameplay.ts new file mode 100644 index 00000000..939b3e93 --- /dev/null +++ b/frontend/components/game/core/useTouchGameplay.ts @@ -0,0 +1,144 @@ +import { useCallback, useEffect, useRef } from "react"; +import type { Engine } from "../engine/Engine"; + +type UseTouchGameplayOptions = { + isMounted: boolean; + engineRef: { current: Engine | null }; + movementKeyPriorityRef: { current: number[] }; + movementPressCountsRef: { current: Map }; + syncMovementState: (engine: Engine) => void; + canProcessMovementInput: () => boolean; +}; + +/** + * Touch gameplay hook that bridges the virtual joystick to the engine's + * movement system, bypassing the keyboard event path (which rejects + * synthetic events via isTrusted). + * + * Instead of dispatching KeyboardEvent, it directly manipulates + * movementPressCountsRef and movementKeyPriorityRef — the same + * refs the keyboard hook uses, so syncMovementState works correctly. + */ +export function useTouchGameplay({ + isMounted, + engineRef, + movementKeyPriorityRef, + movementPressCountsRef, + syncMovementState, + canProcessMovementInput, +}: UseTouchGameplayOptions) { + const isMoving = useRef(false); + const currentDirection = useRef(null); + + /** + * Engine KEY_CODES: W=87, A=65, S=83, D=68 + * The movementKeyPriorityRef stores KEY_CODES values (87, 65, 83, 68), + * and the engine's update loop maps them to DIRECTIONS via moveTo(). + * + * Angles: 0 = right, PI/2 = down, PI = left, -PI/2 = up + */ + const KEY_W = 87; // UP + const KEY_A = 65; // LEFT + const KEY_S = 83; // DOWN + const KEY_D = 68; // RIGHT + + const angleToKeyCode = useCallback((angle: number): number => { + const normalized = ((angle % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2); + const sector = Math.round(normalized / (Math.PI / 2)) % 4; + // 0=right(D), 1=down(S), 2=left(A), 3=up(W) + const keyCodes: Record = { + 0: KEY_D, + 1: KEY_S, + 2: KEY_A, + 3: KEY_W, + }; + return keyCodes[sector]; + }, []); + + const handleMove = useCallback( + (angle: number, _force: number) => { + if (!isMounted) return; + const keyCode = angleToKeyCode(angle); + + // Only update if direction changed + if (keyCode !== currentDirection.current) { + currentDirection.current = keyCode; + + // Clear all previous touch directions + movementPressCountsRef.current.clear(); + + // Set the new direction with count 1 + movementPressCountsRef.current.set(keyCode, 1); + + // Push to front of priority (same as keyboard handleKeyDown) + movementKeyPriorityRef.current = + movementKeyPriorityRef.current.filter( + (code) => code !== keyCode, + ); + movementKeyPriorityRef.current.unshift(keyCode); + + if (!isMoving.current) { + isMoving.current = true; + } + + const activeEngine = engineRef.current; + if (activeEngine && canProcessMovementInput()) { + syncMovementState(activeEngine); + } + } + }, + [ + isMounted, + angleToKeyCode, + movementKeyPriorityRef, + movementPressCountsRef, + engineRef, + canProcessMovementInput, + syncMovementState, + ], + ); + + const handleMoveEnd = useCallback(() => { + if (!isMounted) return; + + isMoving.current = false; + currentDirection.current = null; + + // Clear touch movement (same as keyboard keyup path) + movementPressCountsRef.current.clear(); + movementKeyPriorityRef.current = + movementKeyPriorityRef.current.filter( + (code) => + code !== KEY_W && + code !== KEY_A && + code !== KEY_S && + code !== KEY_D, + ); + + const activeEngine = engineRef.current; + if (activeEngine && canProcessMovementInput()) { + syncMovementState(activeEngine); + } + }, [ + isMounted, + movementKeyPriorityRef, + movementPressCountsRef, + engineRef, + canProcessMovementInput, + syncMovementState, + ]); + + // Cleanup on unmount + useEffect(() => { + return () => { + if (isMoving.current) { + handleMoveEnd(); + } + }; + }, [isMoving, handleMoveEnd]); + + return { + onJoystickMove: handleMove, + onJoystickMoveEnd: handleMoveEnd, + }; +} \ No newline at end of file diff --git a/frontend/components/game/input/Joystick.tsx b/frontend/components/game/input/Joystick.tsx new file mode 100644 index 00000000..f2a47326 --- /dev/null +++ b/frontend/components/game/input/Joystick.tsx @@ -0,0 +1,160 @@ +"use client"; + +import React, { + useCallback, + useEffect, + useRef, + useState, +} from "react"; + +type JoystickProps = { + onMove: (angle: number, force: number) => void; + onMoveEnd: () => void; + size?: number; +}; + +/** + * Floating virtual joystick for touch/mobile input. + * + * Renders a circular base + thumb that follows the user's finger. + * Reports movement angle and force via onMove, and calls onMoveEnd when released. + * Designed to be absolutely positioned over the game canvas. + */ +export default function Joystick({ + onMove, + onMoveEnd, + size = 140, +}: JoystickProps) { + const baseRef = useRef(null); + const thumbRef = useRef(null); + const [active, setActive] = useState(false); + const baseCenter = useRef({ x: 0, y: 0 }); + const touchId = useRef(null); + const animFrame = useRef(0); + + const updatePosition = useCallback( + (clientX: number, clientY: number) => { + if (!baseRef.current || !thumbRef.current) return; + + const dx = clientX - baseCenter.current.x; + const dy = clientY - baseCenter.current.y; + const radius = size / 2; + const distance = Math.sqrt(dx * dx + dy * dy); + const clamped = Math.min(distance, radius); + + const angle = Math.atan2(dy, dx); + const force = clamped / radius; + + // Clamp thumb position to base radius + const thumbX = Math.cos(angle) * clamped; + const thumbY = Math.sin(angle) * clamped; + thumbRef.current.style.transform = `translate(${thumbX}px, ${thumbY}px)`; + + onMove(angle, force); + }, + [onMove, size], + ); + + const handleTouchStart = useCallback( + (e: React.TouchEvent) => { + if (touchId.current !== null) return; + const touch = e.changedTouches[0]; + touchId.current = touch.identifier; + + if (baseRef.current) { + const rect = baseRef.current.getBoundingClientRect(); + baseCenter.current = { + x: rect.left + rect.width / 2, + y: rect.top + rect.height / 2, + }; + } + + setActive(true); + updatePosition(touch.clientX, touch.clientY); + }, + [updatePosition], + ); + + const handleTouchMove = useCallback( + (e: React.TouchEvent) => { + e.preventDefault(); + for (let i = 0; i < e.changedTouches.length; i++) { + const touch = e.changedTouches[i]; + if (touch.identifier === touchId.current) { + updatePosition(touch.clientX, touch.clientY); + break; + } + } + }, + [updatePosition], + ); + + const handleTouchEnd = useCallback( + (e: React.TouchEvent) => { + for (let i = 0; i < e.changedTouches.length; i++) { + if (e.changedTouches[i].identifier === touchId.current) { + touchId.current = null; + setActive(false); + if (thumbRef.current) { + thumbRef.current.style.transform = `translate(0px, 0px)`; + } + onMoveEnd(); + break; + } + } + }, + [onMoveEnd], + ); + + // Cleanup on unmount + useEffect(() => { + return () => { + if (animFrame.current) { + cancelAnimationFrame(animFrame.current); + } + }; + }, []); + + return ( +
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/components/game/input/LandscapePrompt.tsx b/frontend/components/game/input/LandscapePrompt.tsx new file mode 100644 index 00000000..2107c172 --- /dev/null +++ b/frontend/components/game/input/LandscapePrompt.tsx @@ -0,0 +1,61 @@ +"use client"; + +import React from "react"; + +/** + * Landscape rotation prompt for mobile devices. + * Shows when the device is in portrait orientation. + */ +export default function LandscapePrompt() { + return ( +
+
📱
+

+ Rotar dispositivo +

+

+ Para jugar, girá tu teléfono a posición horizontal. +

+
+
+
+ +
+ ); +}