From e8b3172197b72642dae7f52318ba32ad6db48617 Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Wed, 10 Jun 2026 23:53:21 +0330 Subject: [PATCH] Game: landscape-first table with rotate-phone prompt + orientation lock Hokm plays best wide (UNO-style). On phones held in portrait, the game screen shows a "rotate your phone" overlay (with a play-anyway escape hatch so OS rotation-lock can't trap anyone). Best-effort screen.orientation.lock('landscape') on Android/PWA; iOS/desktop reject it harmlessly. i18n rotate.* (fa+en). Co-Authored-By: Claude Opus 4.8 --- src/components/online/RotatePrompt.tsx | 58 ++++++++++++++++++++++++++ src/components/screens/GameScreen.tsx | 18 ++++++++ src/lib/i18n.tsx | 6 +++ 3 files changed, 82 insertions(+) create mode 100644 src/components/online/RotatePrompt.tsx diff --git a/src/components/online/RotatePrompt.tsx b/src/components/online/RotatePrompt.tsx new file mode 100644 index 0000000..d890a33 --- /dev/null +++ b/src/components/online/RotatePrompt.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { RotateCcw } from "lucide-react"; +import { useI18n } from "@/lib/i18n"; + +/** + * Landscape-first nudge for the game table. The Hokm table plays best wide + * (UNO-style), so when a phone is held in portrait we cover the table with a + * "rotate your device" prompt. iOS/Safari can't be force-rotated, so this is the + * reliable cross-platform path. A "play anyway" escape hatch avoids trapping + * users whose OS rotation-lock is on. + */ +export function RotatePrompt() { + const { t } = useI18n(); + const [portrait, setPortrait] = useState(false); + const [dismissed, setDismissed] = useState(false); + + useEffect(() => { + const check = () => { + const isPortrait = window.innerHeight > window.innerWidth; + // Only nudge on phone-sized screens (smaller side ≤ ~820px); desktops/wide + // tablets are already roomy enough. + const isPhone = Math.min(window.innerWidth, window.innerHeight) <= 820; + setPortrait(isPortrait && isPhone); + }; + check(); + window.addEventListener("resize", check); + window.addEventListener("orientationchange", check); + return () => { + window.removeEventListener("resize", check); + window.removeEventListener("orientationchange", check); + }; + }, []); + + if (!portrait || dismissed) return null; + + return ( +
+ +

{t("rotate.title")}

+

{t("rotate.desc")}

+ + + +
+ ); +} diff --git a/src/components/screens/GameScreen.tsx b/src/components/screens/GameScreen.tsx index 4b2872e..cf87c57 100644 --- a/src/components/screens/GameScreen.tsx +++ b/src/components/screens/GameScreen.tsx @@ -5,6 +5,7 @@ import { useEffect, useRef, useState } from "react"; import { GameTable } from "@/components/GameTable"; import { PostMatchRewardsModal } from "@/components/online/PostMatchRewardsModal"; import { MatchIntroOverlay } from "@/components/online/MatchIntroOverlay"; +import { RotatePrompt } from "@/components/online/RotatePrompt"; import { useGameStore } from "@/lib/game-store"; import { useSessionStore } from "@/lib/session-store"; import { useUIStore } from "@/lib/ui-store"; @@ -58,6 +59,20 @@ export function GameScreen() { }; }, []); + // Landscape-first table: best-effort lock to landscape on Android/PWA (iOS & + // desktop reject it — harmless). Restored to portrait/auto when leaving. + useEffect(() => { + const o = (screen as unknown as { orientation?: { lock?: (m: string) => Promise; unlock?: () => void } }).orientation; + o?.lock?.("landscape").catch(() => {}); + return () => { + try { + o?.unlock?.(); + } catch { + /* unsupported — ignore */ + } + }; + }, []); + const notifyAchievements = (r: RewardResult) => { for (const a of r.newAchievements) pushNotification({ @@ -142,6 +157,9 @@ export function GameScreen() { onForfeit={canForfeit ? () => useGameStore.getState().forfeit() : undefined} /> + {/* Landscape-first: nudge to rotate when held in portrait on a phone */} + + {/* UNO-style "players joining the table" intro (online matches, once) */} {introPending && mode === "online" && ( diff --git a/src/lib/i18n.tsx b/src/lib/i18n.tsx index e638e82..5145013 100644 --- a/src/lib/i18n.tsx +++ b/src/lib/i18n.tsx @@ -327,6 +327,9 @@ const fa: Dict = { "settings.audio": "تنظیمات صدا", "settings.sound": "افکت صدا", "settings.music": "موسیقی پس‌زمینه", + "rotate.title": "گوشی را بچرخانید", + "rotate.desc": "برای تجربهٔ بهترِ میز بازی، گوشی را افقی (چرخانده) نگه دارید.", + "rotate.anyway": "همین‌طور عمودی بازی می‌کنم", "settings.musicStyle": "سبک موسیقی", "settings.trackSantoor": "سنتی (سنتور)", "settings.trackPlayful": "شاد", @@ -663,6 +666,9 @@ const en: Dict = { "settings.audio": "Audio", "settings.sound": "Sound effects", "settings.music": "Background music", + "rotate.title": "Rotate your phone", + "rotate.desc": "The table plays best in landscape — turn your phone sideways for the full experience.", + "rotate.anyway": "Keep playing in portrait", "settings.musicStyle": "Music style", "settings.trackSantoor": "Traditional (Santoor)", "settings.trackPlayful": "Playful",