From bca0e6866d6ebc36c74d59bfcc653ed5d48aa682 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Sun, 26 Apr 2026 19:16:38 +0700 Subject: [PATCH] feat: add player board for game master Master page now embeds a separate playing card so the host can play along while drawing numbers. Extracted shared grid logic into a reusable PlayerBoard component with a configurable storage prefix. --- app/loto-game-logic.ts | 47 ++++++-- app/loto-player-board.tsx | 235 ++++++++++++++++++++++++++++++++++++ app/master/page.tsx | 14 +++ app/page.tsx | 248 ++------------------------------------ 4 files changed, 293 insertions(+), 251 deletions(-) create mode 100644 app/loto-player-board.tsx diff --git a/app/loto-game-logic.ts b/app/loto-game-logic.ts index 1565de5..98b872d 100644 --- a/app/loto-game-logic.ts +++ b/app/loto-game-logic.ts @@ -76,15 +76,12 @@ export function generateGrid(): number[][] { return cell; } -const STORAGE_KEY_GRID = "loto_grid"; -const STORAGE_KEY_CROSSED = "loto_crossed"; - -export function saveGrid(grid: number[][]): void { - localStorage.setItem(STORAGE_KEY_GRID, JSON.stringify(grid)); +export function saveGrid(grid: number[][], prefix = "loto"): void { + localStorage.setItem(`${prefix}_grid`, JSON.stringify(grid)); } -export function loadGrid(): number[][] | null { - const data = localStorage.getItem(STORAGE_KEY_GRID); +export function loadGrid(prefix = "loto"): number[][] | null { + const data = localStorage.getItem(`${prefix}_grid`); if (!data) return null; try { return JSON.parse(data); @@ -93,12 +90,12 @@ export function loadGrid(): number[][] | null { } } -export function saveCrossedState(crossed: boolean[][]): void { - localStorage.setItem(STORAGE_KEY_CROSSED, JSON.stringify(crossed)); +export function saveCrossedState(crossed: boolean[][], prefix = "loto"): void { + localStorage.setItem(`${prefix}_crossed`, JSON.stringify(crossed)); } -export function loadCrossedState(): boolean[][] | null { - const data = localStorage.getItem(STORAGE_KEY_CROSSED); +export function loadCrossedState(prefix = "loto"): boolean[][] | null { + const data = localStorage.getItem(`${prefix}_crossed`); if (!data) return null; try { return JSON.parse(data); @@ -106,3 +103,31 @@ export function loadCrossedState(): boolean[][] | null { return null; } } + +/** Check if a row has all its numbers crossed */ +export function isRowComplete( + grid: number[][], + crossed: boolean[][], + row: number +): boolean { + for (let col = 0; col < 9; col++) { + if (grid[row][col] > 0 && !crossed[row]?.[col]) return false; + } + return true; +} + +/** Find the single remaining uncrossed number in a row, or null if != 1 remaining */ +export function getWaitingNumber( + grid: number[][], + crossed: boolean[][], + row: number +): number | null { + let remaining: number | null = null; + for (let col = 0; col < 9; col++) { + if (grid[row][col] > 0 && !crossed[row]?.[col]) { + if (remaining !== null) return null; + remaining = grid[row][col]; + } + } + return remaining; +} diff --git a/app/loto-player-board.tsx b/app/loto-player-board.tsx new file mode 100644 index 0000000..68c06ca --- /dev/null +++ b/app/loto-player-board.tsx @@ -0,0 +1,235 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { + generateGrid, + getWaitingNumber, + isRowComplete, + loadCrossedState, + loadGrid, + saveCrossedState, + saveGrid, +} from "./loto-game-logic"; + +interface PlayerBoardProps { + /** localStorage key prefix; allows multiple independent boards (e.g. user vs master) */ + storagePrefix?: string; +} + +export default function PlayerBoard({ storagePrefix = "loto" }: PlayerBoardProps) { + const [grid, setGrid] = useState(null); + const [crossed, setCrossed] = useState([]); + const [showCongrats, setShowCongrats] = useState(false); + const [congratsRow, setCongratsRow] = useState(-1); + const [toast, setToast] = useState(null); + const toastTimer = useRef | null>(null); + const celebratedRows = useRef>(new Set()); + const notifiedWaitingRows = useRef>(new Set()); + + const dismissToast = useCallback(() => { + setToast(null); + if (toastTimer.current) { + clearTimeout(toastTimer.current); + toastTimer.current = null; + } + }, []); + + const showToast = useCallback( + (msg: string) => { + dismissToast(); + setToast(msg); + toastTimer.current = setTimeout(() => setToast(null), 5000); + }, + [dismissToast] + ); + + useEffect(() => { + const savedGrid = loadGrid(storagePrefix); + if (savedGrid) { + setGrid(savedGrid); + const savedCrossed = + loadCrossedState(storagePrefix) ?? + savedGrid.map((row) => row.map(() => false)); + setCrossed(savedCrossed); + celebratedRows.current.clear(); + notifiedWaitingRows.current.clear(); + for (let i = 0; i < savedGrid.length; i++) { + if (isRowComplete(savedGrid, savedCrossed, i)) { + celebratedRows.current.add(i); + } + if (getWaitingNumber(savedGrid, savedCrossed, i) !== null) { + notifiedWaitingRows.current.add(i); + } + } + } + }, [storagePrefix]); + + useEffect(() => { + if (crossed.length > 0) saveCrossedState(crossed, storagePrefix); + }, [crossed, storagePrefix]); + + // Detect newly completed rows and waiting rows + useEffect(() => { + if (!grid || crossed.length === 0) return; + + for (let i = 0; i < grid.length; i++) { + if (!celebratedRows.current.has(i) && isRowComplete(grid, crossed, i)) { + celebratedRows.current.add(i); + notifiedWaitingRows.current.add(i); + setCongratsRow(i + 1); + setShowCongrats(true); + return; + } + const waitNum = getWaitingNumber(grid, crossed, i); + if (waitNum !== null && !notifiedWaitingRows.current.has(i)) { + notifiedWaitingRows.current.add(i); + showToast(`Chờ ${waitNum}`); + return; + } + if ( + waitNum === null && + notifiedWaitingRows.current.has(i) && + !celebratedRows.current.has(i) + ) { + notifiedWaitingRows.current.delete(i); + } + } + }, [grid, crossed, showToast]); + + const handleGenerate = useCallback(() => { + if (grid && !confirm("Bạn có muốn tạo lại bảng không?")) return; + const newGrid = generateGrid(); + const newCrossed = newGrid.map((row) => row.map(() => false)); + setGrid(newGrid); + setCrossed(newCrossed); + saveGrid(newGrid, storagePrefix); + saveCrossedState(newCrossed, storagePrefix); + celebratedRows.current.clear(); + notifiedWaitingRows.current.clear(); + dismissToast(); + }, [grid, dismissToast, storagePrefix]); + + const handleCellClick = useCallback((row: number, col: number) => { + setCrossed((prev) => { + const next = prev.map((r) => [...r]); + next[row][col] = !next[row][col]; + return next; + }); + }, []); + + return ( + <> +
+ +
+ + {grid ? ( +
+
+
+ {grid.flat().map((num, idx) => { + const row = Math.floor(idx / 9); + const col = idx % 9; + const hasNumber = num > 0; + const isCrossed = hasNumber && crossed[row]?.[col]; + const rowComplete = + hasNumber && isRowComplete(grid, crossed, row); + + return ( +
handleCellClick(row, col) : undefined + } + className={` + relative flex items-center justify-center + aspect-square text-base sm:text-xl font-bold + border-r border-b border-slate-200/80 dark:border-slate-700/60 + transition-all select-none + ${ + hasNumber + ? isCrossed + ? rowComplete + ? "cell-crossed bg-emerald-100 dark:bg-emerald-900/40 text-emerald-500 dark:text-emerald-400 cursor-pointer" + : "cell-crossed bg-red-50 dark:bg-red-950/30 text-red-400 dark:text-red-500 cursor-pointer" + : "bg-white dark:bg-slate-800 text-slate-800 dark:text-slate-100 cursor-pointer hover:bg-indigo-50 dark:hover:bg-indigo-950/30 hover:text-indigo-600 dark:hover:text-indigo-400" + : "bg-slate-50 dark:bg-slate-900/60" + } + `} + > + {hasNumber ? num : ""} +
+ ); + })} +
+
+ + {toast && ( +
+
+ {toast} +
+
+ )} +
+ ) : ( +
+ Nhấn “Tạo bảng mới” để bắt đầu chơi +
+ )} + + {showCongrats && ( +
setShowCongrats(false)} + > +
e.stopPropagation()} + > +
+ 🎉 +
+
+ ✨ +
+
+ 🎊 +
+ +

+ Kinh! +

+

+ Hàng {congratsRow}{" "} + đã đầy đủ! +

+

+ Hãy hô to “Kinh!” 🎶 +

+ +
+
+ )} + + ); +} diff --git a/app/master/page.tsx b/app/master/page.tsx index aac8204..2aa0f5f 100644 --- a/app/master/page.tsx +++ b/app/master/page.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import { useCallback, useEffect, useState } from "react"; +import PlayerBoard from "../loto-player-board"; const STORAGE_KEY = "loto_master"; @@ -212,6 +213,19 @@ export default function MasterPage() { )} + {/* Master's own playing card */} +
+
+

+ Bảng của quản trò +

+

+ Quản trò cũng có thể chơi cùng +

+
+ +
+ {/* Footer */}