feat(responsive): enhance Minesweeper mobile experience with viewport adjustments and sidebar behavior
This commit is contained in:
parent
67744767ec
commit
4ba545b77b
2 changed files with 101 additions and 13 deletions
|
|
@ -1,8 +1,9 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import type { EodDifficulty, EodStats } from "@/app/(frontend)/eod/actions";
|
import type { EodDifficulty, EodStats } from "@/app/(frontend)/eod/actions";
|
||||||
import { useRealtimePresence } from "@/hooks/useRealtimePresence";
|
import { useRealtimePresence } from "@/hooks/useRealtimePresence";
|
||||||
|
import { useSidebar } from "@/components/ui/sidebar";
|
||||||
import { EodLeaderboard } from "./EodLeaderboard";
|
import { EodLeaderboard } from "./EodLeaderboard";
|
||||||
import { MinesweeperBoard } from "./MinesweeperBoard";
|
import { MinesweeperBoard } from "./MinesweeperBoard";
|
||||||
|
|
||||||
|
|
@ -16,11 +17,25 @@ export function EodArena({
|
||||||
const [refreshKey, setRefreshKey] = useState(0);
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
const [playing, setPlaying] = useState(false);
|
const [playing, setPlaying] = useState(false);
|
||||||
const [selectedDifficulty, setSelectedDifficulty] = useState<EodDifficulty>("training");
|
const [selectedDifficulty, setSelectedDifficulty] = useState<EodDifficulty>("training");
|
||||||
|
const { isMobile, setOpen, setOpenMobile } = useSidebar();
|
||||||
const activeTeams = useRealtimePresence("eod", playing, true);
|
const activeTeams = useRealtimePresence("eod", playing, true);
|
||||||
const onPhaseChange = useCallback((phase: "ready" | "playing" | "over") => {
|
const onPhaseChange = useCallback((phase: "ready" | "playing" | "over") => {
|
||||||
setPlaying(phase === "playing");
|
setPlaying(phase === "playing");
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const landscapeQuery = window.matchMedia("(orientation: landscape)");
|
||||||
|
const hideSidebarForLandscapeGame = () => {
|
||||||
|
if (!playing || !landscapeQuery.matches) return;
|
||||||
|
if (isMobile) setOpenMobile(false);
|
||||||
|
else setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
hideSidebarForLandscapeGame();
|
||||||
|
landscapeQuery.addEventListener("change", hideSidebarForLandscapeGame);
|
||||||
|
return () => landscapeQuery.removeEventListener("change", hideSidebarForLandscapeGame);
|
||||||
|
}, [isMobile, playing, setOpen, setOpenMobile]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center gap-6">
|
<div className="flex flex-col items-center gap-6">
|
||||||
<MinesweeperBoard
|
<MinesweeperBoard
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
// allow: SIZE_OK — the board is a single interactive game state machine required by the route contract.
|
// allow: SIZE_OK — the board is a single interactive game state machine required by the route contract.
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState, type PointerEvent } from "react";
|
import { useCallback, useEffect, useRef, useState, type CSSProperties, type PointerEvent } from "react";
|
||||||
import { Bomb, Flag, RotateCcw, ShieldCheck, Timer, X } from "lucide-react";
|
import { Bomb, Flag, RotateCcw, ShieldCheck, Timer, X } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -37,6 +37,7 @@ const CUSTOM_MIN_SIZE = 9;
|
||||||
const CUSTOM_MAX_SIZE = 500;
|
const CUSTOM_MAX_SIZE = 500;
|
||||||
const CUSTOM_MAX_SAFE_MINES_BUFFER = 9;
|
const CUSTOM_MAX_SAFE_MINES_BUFFER = 9;
|
||||||
const CUSTOM_FULLSCREEN_CELL_THRESHOLD = 900;
|
const CUSTOM_FULLSCREEN_CELL_THRESHOLD = 900;
|
||||||
|
const MOBILE_COLUMN_COUNT = 10;
|
||||||
|
|
||||||
type Cell = {
|
type Cell = {
|
||||||
readonly mine: boolean;
|
readonly mine: boolean;
|
||||||
|
|
@ -91,6 +92,19 @@ const NUMBER_CLASSES: Readonly<Record<number, string>> = {
|
||||||
8: "text-gray-400",
|
8: "text-gray-400",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function getMobileConfig(config: GameConfig, landscape: boolean): GameConfig {
|
||||||
|
if (landscape) return config;
|
||||||
|
|
||||||
|
const rows = Math.max(1, Math.ceil((config.rows * config.cols) / MOBILE_COLUMN_COUNT));
|
||||||
|
const cells = rows * MOBILE_COLUMN_COUNT;
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
rows,
|
||||||
|
cols: MOBILE_COLUMN_COUNT,
|
||||||
|
mines: Math.min(config.mines, cells - CUSTOM_MAX_SAFE_MINES_BUFFER),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function emptyBoard(size: number): Board {
|
function emptyBoard(size: number): Board {
|
||||||
return Array.from({ length: size }, () => ({
|
return Array.from({ length: size }, () => ({
|
||||||
mine: false,
|
mine: false,
|
||||||
|
|
@ -199,12 +213,17 @@ export function MinesweeperBoard({
|
||||||
const [stats, setStats] = useState<EodStats>(serverStats);
|
const [stats, setStats] = useState<EodStats>(serverStats);
|
||||||
const [lastRun, setLastRun] = useState<LastRun | null>(null);
|
const [lastRun, setLastRun] = useState<LastRun | null>(null);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [isMobile, setIsMobile] = useState(false);
|
||||||
|
const [isLandscape, setIsLandscape] = useState(false);
|
||||||
|
const [isNarrow, setIsNarrow] = useState(false);
|
||||||
const generatedRef = useRef(false);
|
const generatedRef = useRef(false);
|
||||||
|
const previousViewportRef = useRef<string | null>(null);
|
||||||
const submittingRef = useRef(false);
|
const submittingRef = useRef(false);
|
||||||
const statsRef = useRef<EodStats>(serverStats);
|
const statsRef = useRef<EodStats>(serverStats);
|
||||||
const longPressRef = useRef(false);
|
const longPressRef = useRef(false);
|
||||||
const longPressTimerRef = useRef<number | null>(null);
|
const longPressTimerRef = useRef<number | null>(null);
|
||||||
const config: GameConfig = difficulty === "custom" ? customConfig : DIFFICULTIES[difficulty];
|
const baseConfig: GameConfig = difficulty === "custom" ? customConfig : DIFFICULTIES[difficulty];
|
||||||
|
const config = isMobile ? getMobileConfig(baseConfig, isLandscape) : baseConfig;
|
||||||
const customUnlocked =
|
const customUnlocked =
|
||||||
stats.bestTrainingTime > 0 && stats.bestPerimeterTime > 0 && stats.bestSupplyRoadTime > 0;
|
stats.bestTrainingTime > 0 && stats.bestPerimeterTime > 0 && stats.bestSupplyRoadTime > 0;
|
||||||
const isLargeCustomGame =
|
const isLargeCustomGame =
|
||||||
|
|
@ -223,6 +242,27 @@ export function MinesweeperBoard({
|
||||||
onPhaseChange("ready");
|
onPhaseChange("ready");
|
||||||
}, [onPhaseChange]);
|
}, [onPhaseChange]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const mobileQuery = window.matchMedia("(max-width: 639px)");
|
||||||
|
const landscapeQuery = window.matchMedia("(orientation: landscape)");
|
||||||
|
const narrowQuery = window.matchMedia("(max-width: 1023px)");
|
||||||
|
const updateViewport = () => {
|
||||||
|
setIsMobile(mobileQuery.matches);
|
||||||
|
setIsLandscape(landscapeQuery.matches);
|
||||||
|
setIsNarrow(narrowQuery.matches);
|
||||||
|
};
|
||||||
|
|
||||||
|
updateViewport();
|
||||||
|
mobileQuery.addEventListener("change", updateViewport);
|
||||||
|
landscapeQuery.addEventListener("change", updateViewport);
|
||||||
|
narrowQuery.addEventListener("change", updateViewport);
|
||||||
|
return () => {
|
||||||
|
mobileQuery.removeEventListener("change", updateViewport);
|
||||||
|
landscapeQuery.removeEventListener("change", updateViewport);
|
||||||
|
narrowQuery.removeEventListener("change", updateViewport);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (phase !== "playing") return;
|
if (phase !== "playing") return;
|
||||||
const timer = window.setInterval(() => {
|
const timer = window.setInterval(() => {
|
||||||
|
|
@ -240,7 +280,11 @@ export function MinesweeperBoard({
|
||||||
const reset = useCallback(
|
const reset = useCallback(
|
||||||
(nextDifficulty: GameMode = difficulty) => {
|
(nextDifficulty: GameMode = difficulty) => {
|
||||||
if (submittingRef.current) return;
|
if (submittingRef.current) return;
|
||||||
const nextConfig = nextDifficulty === "custom" ? customConfig : DIFFICULTIES[nextDifficulty];
|
const selectedConfig =
|
||||||
|
nextDifficulty === "custom" ? customConfig : DIFFICULTIES[nextDifficulty];
|
||||||
|
const nextConfig = isMobile
|
||||||
|
? getMobileConfig(selectedConfig, isLandscape)
|
||||||
|
: selectedConfig;
|
||||||
setDifficulty(nextDifficulty);
|
setDifficulty(nextDifficulty);
|
||||||
if (nextDifficulty !== "custom") onDifficultyChange(nextDifficulty);
|
if (nextDifficulty !== "custom") onDifficultyChange(nextDifficulty);
|
||||||
setBoard(emptyBoard(nextConfig.rows * nextConfig.cols));
|
setBoard(emptyBoard(nextConfig.rows * nextConfig.cols));
|
||||||
|
|
@ -249,9 +293,20 @@ export function MinesweeperBoard({
|
||||||
setLastRun(null);
|
setLastRun(null);
|
||||||
setPhaseSafe("ready");
|
setPhaseSafe("ready");
|
||||||
},
|
},
|
||||||
[customConfig, difficulty, onDifficultyChange, setPhaseSafe],
|
[customConfig, difficulty, isLandscape, isMobile, onDifficultyChange, setPhaseSafe],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const viewportKey = `${isMobile}-${isLandscape}`;
|
||||||
|
if (previousViewportRef.current === null) {
|
||||||
|
previousViewportRef.current = viewportKey;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (previousViewportRef.current === viewportKey) return;
|
||||||
|
previousViewportRef.current = viewportKey;
|
||||||
|
reset();
|
||||||
|
}, [isLandscape, isMobile, reset]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
if (
|
if (
|
||||||
|
|
@ -426,15 +481,23 @@ export function MinesweeperBoard({
|
||||||
const renderGrid = (modal = false) => (
|
const renderGrid = (modal = false) => (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"overflow-auto rounded-lg border border-border bg-background",
|
"min-w-0 max-w-full overflow-auto rounded-lg border border-border bg-background",
|
||||||
modal
|
modal
|
||||||
? "min-h-full min-w-full items-start justify-start p-2"
|
? "min-h-full min-w-full items-start justify-start p-2"
|
||||||
: "flex items-center justify-center p-2",
|
: "flex items-center justify-center p-2",
|
||||||
)}
|
)}
|
||||||
|
style={{ containerType: "inline-size" }}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="grid min-w-max gap-1"
|
className="grid min-w-max grid-cols-[repeat(var(--board-cols),var(--cell-size))] gap-1"
|
||||||
style={{ gridTemplateColumns: `repeat(${config.cols}, 1.75rem)` }}
|
style={
|
||||||
|
{
|
||||||
|
"--board-cols": config.cols,
|
||||||
|
"--cell-size": isNarrow
|
||||||
|
? `min(1.75rem, calc((100cqw - ${(config.cols - 1) * 0.25}rem) / ${config.cols}))`
|
||||||
|
: "1.75rem",
|
||||||
|
} as CSSProperties
|
||||||
|
}
|
||||||
role="grid"
|
role="grid"
|
||||||
aria-label={`${config.label} minefield`}
|
aria-label={`${config.label} minefield`}
|
||||||
>
|
>
|
||||||
|
|
@ -452,7 +515,7 @@ export function MinesweeperBoard({
|
||||||
aria-colindex={col + 1}
|
aria-colindex={col + 1}
|
||||||
disabled={phase === "over" || cell.revealed}
|
disabled={phase === "over" || cell.revealed}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex size-7 items-center justify-center rounded-sm border text-xs font-bold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
"flex size-[var(--cell-size)] items-center justify-center rounded-sm border text-xs font-bold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||||
cell.revealed &&
|
cell.revealed &&
|
||||||
!cell.mine &&
|
!cell.mine &&
|
||||||
(highContrast
|
(highContrast
|
||||||
|
|
@ -740,10 +803,20 @@ export function MinesweeperBoard({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isLargeCustomGame && (
|
{!isLargeCustomGame && (
|
||||||
<div className="overflow-x-auto rounded-lg border border-border bg-background p-2 items-center justify-center flex">
|
|
||||||
<div
|
<div
|
||||||
className="grid min-w-max gap-1"
|
className="flex min-w-0 max-w-full items-center justify-center overflow-auto rounded-lg border border-border bg-background p-2"
|
||||||
style={{ gridTemplateColumns: `repeat(${config.cols}, 1.75rem)` }}
|
style={{ containerType: "inline-size" }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="grid min-w-max grid-cols-[repeat(var(--board-cols),var(--cell-size))] gap-1"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--board-cols": config.cols,
|
||||||
|
"--cell-size": isNarrow
|
||||||
|
? `min(1.75rem, calc((100cqw - ${(config.cols - 1) * 0.25}rem) / ${config.cols}))`
|
||||||
|
: "1.75rem",
|
||||||
|
} as CSSProperties
|
||||||
|
}
|
||||||
role="grid"
|
role="grid"
|
||||||
aria-label={`${config.label} minefield`}
|
aria-label={`${config.label} minefield`}
|
||||||
>
|
>
|
||||||
|
|
@ -761,7 +834,7 @@ export function MinesweeperBoard({
|
||||||
aria-colindex={col + 1}
|
aria-colindex={col + 1}
|
||||||
disabled={phase === "over" || cell.revealed}
|
disabled={phase === "over" || cell.revealed}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex size-7 items-center justify-center rounded-sm border text-xs font-bold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
"flex size-[var(--cell-size)] items-center justify-center rounded-sm border text-xs font-bold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||||
cell.revealed &&
|
cell.revealed &&
|
||||||
!cell.mine &&
|
!cell.mine &&
|
||||||
(highContrast
|
(highContrast
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue