Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 48 additions & 1 deletion frontend/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -69,4 +69,51 @@ html.game-targeting body *::after {
left: 0;
-webkit-overflow-scrolling: touch;
}
}
}

/* 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;
}
}
9 changes: 9 additions & 0 deletions frontend/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions frontend/app/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 3 additions & 1 deletion frontend/app/play/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand Down Expand Up @@ -3857,6 +3858,7 @@ export default function Home() {
return (
<Suspense fallback={<main className="min-h-screen bg-black" />}>
<HomeContent />
</Suspense>
<LandscapePrompt />
</Suspense>
);
}
20 changes: 20 additions & 0 deletions frontend/components/game/core/MapRendererCore.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -1333,6 +1335,18 @@ export default function MapRenderer({
setIsDebugMode,
});

const {
onJoystickMove,
onJoystickMoveEnd,
} = useTouchGameplay({
isMounted,
engineRef,
movementKeyPriorityRef,
movementPressCountsRef,
canProcessMovementInput,
syncMovementState,
});

const { clearUseItemQueues } = useOutgoingRequests({
websocketRef,
engineRef,
Expand Down Expand Up @@ -2073,6 +2087,12 @@ export default function MapRenderer({
}
/>
</div>
<div className="mobile-only-joystick">
<Joystick
onMove={onJoystickMove}
onMoveEnd={onJoystickMoveEnd}
/>
</div>
</div>
);
}
144 changes: 144 additions & 0 deletions frontend/components/game/core/useTouchGameplay.ts
Original file line number Diff line number Diff line change
@@ -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<number, number> };
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<number | null>(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<number, number> = {
0: KEY_D,
1: KEY_S,
2: KEY_A,
3: KEY_W,
};
return keyCodes[sector];
}, []);

const handleMove = useCallback(
(angle: number, _force: number) => {

Check warning on line 59 in frontend/components/game/core/useTouchGameplay.ts

View workflow job for this annotation

GitHub Actions / Frontend (typecheck, lint, build)

'_force' is defined but never used
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,
};
}
Loading
Loading