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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 (
|
||||
<div className="fixed inset-0 z-[80] flex flex-col items-center justify-center gap-6 bg-navy-950/96 backdrop-blur-md p-8 text-center safe-top safe-x">
|
||||
<RotateCcw className="size-20 text-gold-400 motion-safe:animate-[rotateHint_2.4s_ease-in-out_infinite]" />
|
||||
<h2 className="text-2xl font-black gold-text">{t("rotate.title")}</h2>
|
||||
<p className="max-w-xs leading-8 text-cream/70">{t("rotate.desc")}</p>
|
||||
<button
|
||||
onClick={() => setDismissed(true)}
|
||||
className="mt-2 text-sm text-cream/45 underline underline-offset-4 hover:text-cream/70"
|
||||
>
|
||||
{t("rotate.anyway")}
|
||||
</button>
|
||||
|
||||
<style>{`
|
||||
@keyframes rotateHint {
|
||||
0%, 40% { transform: rotate(0deg); }
|
||||
60%, 100% { transform: rotate(-90deg); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<void>; 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 */}
|
||||
<RotatePrompt />
|
||||
|
||||
{/* UNO-style "players joining the table" intro (online matches, once) */}
|
||||
<AnimatePresence>
|
||||
{introPending && mode === "online" && (
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user