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";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { EodDifficulty, EodStats } from "@/app/(frontend)/eod/actions";
|
||||
import { useRealtimePresence } from "@/hooks/useRealtimePresence";
|
||||
import { useSidebar } from "@/components/ui/sidebar";
|
||||
import { EodLeaderboard } from "./EodLeaderboard";
|
||||
import { MinesweeperBoard } from "./MinesweeperBoard";
|
||||
|
||||
|
|
@ -16,11 +17,25 @@ export function EodArena({
|
|||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [selectedDifficulty, setSelectedDifficulty] = useState<EodDifficulty>("training");
|
||||
const { isMobile, setOpen, setOpenMobile } = useSidebar();
|
||||
const activeTeams = useRealtimePresence("eod", playing, true);
|
||||
const onPhaseChange = useCallback((phase: "ready" | "playing" | "over") => {
|
||||
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 (
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
<MinesweeperBoard
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
// 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 { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -37,6 +37,7 @@ const CUSTOM_MIN_SIZE = 9;
|
|||
const CUSTOM_MAX_SIZE = 500;
|
||||
const CUSTOM_MAX_SAFE_MINES_BUFFER = 9;
|
||||
const CUSTOM_FULLSCREEN_CELL_THRESHOLD = 900;
|
||||
const MOBILE_COLUMN_COUNT = 10;
|
||||
|
||||
type Cell = {
|
||||
readonly mine: boolean;
|
||||
|
|
@ -91,6 +92,19 @@ const NUMBER_CLASSES: Readonly<Record<number, string>> = {
|
|||
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 {
|
||||
return Array.from({ length: size }, () => ({
|
||||
mine: false,
|
||||
|
|
@ -199,12 +213,17 @@ export function MinesweeperBoard({
|
|||
const [stats, setStats] = useState<EodStats>(serverStats);
|
||||
const [lastRun, setLastRun] = useState<LastRun | null>(null);
|
||||
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 previousViewportRef = useRef<string | null>(null);
|
||||
const submittingRef = useRef(false);
|
||||
const statsRef = useRef<EodStats>(serverStats);
|
||||
const longPressRef = useRef(false);
|
||||
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 =
|
||||
stats.bestTrainingTime > 0 && stats.bestPerimeterTime > 0 && stats.bestSupplyRoadTime > 0;
|
||||
const isLargeCustomGame =
|
||||
|
|
@ -223,6 +242,27 @@ export function MinesweeperBoard({
|
|||
onPhaseChange("ready");
|
||||
}, [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(() => {
|
||||
if (phase !== "playing") return;
|
||||
const timer = window.setInterval(() => {
|
||||
|
|
@ -240,7 +280,11 @@ export function MinesweeperBoard({
|
|||
const reset = useCallback(
|
||||
(nextDifficulty: GameMode = difficulty) => {
|
||||
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);
|
||||
if (nextDifficulty !== "custom") onDifficultyChange(nextDifficulty);
|
||||
setBoard(emptyBoard(nextConfig.rows * nextConfig.cols));
|
||||
|
|
@ -249,9 +293,20 @@ export function MinesweeperBoard({
|
|||
setLastRun(null);
|
||||
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(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
|
|
@ -426,15 +481,23 @@ export function MinesweeperBoard({
|
|||
const renderGrid = (modal = false) => (
|
||||
<div
|
||||
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
|
||||
? "min-h-full min-w-full items-start justify-start p-2"
|
||||
: "flex items-center justify-center p-2",
|
||||
)}
|
||||
style={{ containerType: "inline-size" }}
|
||||
>
|
||||
<div
|
||||
className="grid min-w-max gap-1"
|
||||
style={{ gridTemplateColumns: `repeat(${config.cols}, 1.75rem)` }}
|
||||
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"
|
||||
aria-label={`${config.label} minefield`}
|
||||
>
|
||||
|
|
@ -452,7 +515,7 @@ export function MinesweeperBoard({
|
|||
aria-colindex={col + 1}
|
||||
disabled={phase === "over" || cell.revealed}
|
||||
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.mine &&
|
||||
(highContrast
|
||||
|
|
@ -740,10 +803,20 @@ export function MinesweeperBoard({
|
|||
</div>
|
||||
|
||||
{!isLargeCustomGame && (
|
||||
<div className="overflow-x-auto rounded-lg border border-border bg-background p-2 items-center justify-center flex">
|
||||
<div
|
||||
className="grid min-w-max gap-1"
|
||||
style={{ gridTemplateColumns: `repeat(${config.cols}, 1.75rem)` }}
|
||||
className="flex min-w-0 max-w-full items-center justify-center overflow-auto rounded-lg border border-border bg-background p-2"
|
||||
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"
|
||||
aria-label={`${config.label} minefield`}
|
||||
>
|
||||
|
|
@ -761,7 +834,7 @@ export function MinesweeperBoard({
|
|||
aria-colindex={col + 1}
|
||||
disabled={phase === "over" || cell.revealed}
|
||||
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.mine &&
|
||||
(highContrast
|
||||
|
|
|
|||
Loading…
Reference in a new issue