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.
This commit is contained in:
2026-04-26 19:16:38 +07:00
parent 30554d21a2
commit bca0e6866d
4 changed files with 293 additions and 251 deletions
+36 -11
View File
@@ -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;
}
+235
View File
@@ -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<number[][] | null>(null);
const [crossed, setCrossed] = useState<boolean[][]>([]);
const [showCongrats, setShowCongrats] = useState(false);
const [congratsRow, setCongratsRow] = useState<number>(-1);
const [toast, setToast] = useState<string | null>(null);
const toastTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const celebratedRows = useRef<Set<number>>(new Set());
const notifiedWaitingRows = useRef<Set<number>>(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 (
<>
<div className="flex justify-center mb-6">
<button
onClick={handleGenerate}
className="px-8 py-3 rounded-full font-semibold text-white
bg-gradient-to-r from-indigo-500 to-purple-500
hover:from-indigo-600 hover:to-purple-600
active:scale-95 transition-all shadow-lg shadow-indigo-500/25"
>
Tạo bảng mới
</button>
</div>
{grid ? (
<div className="relative">
<div className="rounded-2xl overflow-hidden shadow-xl shadow-slate-200/50 dark:shadow-black/30 border border-slate-200 dark:border-slate-700">
<div className="loto-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 (
<div
key={idx}
onClick={
hasNumber ? () => 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 : ""}
</div>
);
})}
</div>
</div>
{toast && (
<div
onClick={dismissToast}
className="absolute inset-0 flex items-center justify-center pointer-events-auto cursor-pointer z-10"
>
<div className="px-6 py-3 rounded-2xl bg-amber-500/90 dark:bg-amber-600/90 text-white text-xl sm:text-2xl font-black shadow-xl animate-toast">
{toast}
</div>
</div>
)}
</div>
) : (
<div className="text-center text-slate-400 dark:text-slate-500 py-20 text-sm">
Nhấn &ldquo;Tạo bảng mới&rdquo; đ bắt đu chơi
</div>
)}
{showCongrats && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm animate-fade-in"
onClick={() => setShowCongrats(false)}
>
<div
className="relative mx-4 max-w-sm w-full rounded-3xl bg-white dark:bg-slate-800 p-8 text-center shadow-2xl animate-pop-in"
onClick={(e) => e.stopPropagation()}
>
<div className="absolute -top-6 left-1/2 -translate-x-1/2 text-6xl animate-bounce-slow">
🎉
</div>
<div className="absolute top-2 left-4 text-2xl animate-spin-slow">
</div>
<div className="absolute top-2 right-4 text-2xl animate-spin-slow-reverse">
🎊
</div>
<h2 className="mt-6 text-3xl font-black bg-gradient-to-r from-amber-500 via-pink-500 to-purple-500 bg-clip-text text-transparent">
Kinh!
</h2>
<p className="mt-3 text-lg text-slate-600 dark:text-slate-300">
Hàng <span className="font-bold text-pink-500">{congratsRow}</span>{" "}
đã đy đ!
</p>
<p className="mt-1 text-sm text-slate-400 dark:text-slate-500">
Hãy to &ldquo;Kinh!&rdquo; 🎶
</p>
<button
onClick={() => setShowCongrats(false)}
className="mt-6 px-8 py-2.5 rounded-full font-semibold text-white
bg-gradient-to-r from-pink-500 to-purple-500
hover:from-pink-600 hover:to-purple-600
active:scale-95 transition-all shadow-lg"
>
Tuyệt vời! 🥳
</button>
</div>
</div>
)}
</>
);
}
+14
View File
@@ -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() {
</div>
)}
{/* Master's own playing card */}
<div className="mt-10">
<div className="text-center mb-4">
<h2 className="text-lg font-bold text-slate-700 dark:text-slate-200">
Bảng của quản trò
</h2>
<p className="text-xs text-slate-400 dark:text-slate-500">
Quản trò cũng thể chơi cùng
</p>
</div>
<PlayerBoard storagePrefix="loto_master_card" />
</div>
{/* Footer */}
<footer className="mt-10 text-center text-xs text-slate-400 dark:text-slate-600">
Made with by{" "}
+8 -240
View File
@@ -1,141 +1,11 @@
"use client";
import Link from "next/link";
import { useCallback, useEffect, useRef, useState } from "react";
import {
generateGrid,
loadCrossedState,
loadGrid,
saveCrossedState,
saveGrid,
} from "./loto-game-logic";
/** Check if a row has all its numbers crossed */
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 */
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; // more than 1 remaining
remaining = grid[row][col];
}
}
return remaining;
}
import { useState } from "react";
import PlayerBoard from "./loto-player-board";
export default function Home() {
const [grid, setGrid] = useState<number[][] | null>(null);
const [crossed, setCrossed] = useState<boolean[][]>([]);
const [showInstructions, setShowInstructions] = useState(false);
const [showCongrats, setShowCongrats] = useState(false);
const [congratsRow, setCongratsRow] = useState<number>(-1);
const [toast, setToast] = useState<string | null>(null);
const toastTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const celebratedRows = useRef<Set<number>>(new Set());
const notifiedWaitingRows = useRef<Set<number>>(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();
if (savedGrid) {
setGrid(savedGrid);
const savedCrossed =
loadCrossedState() ?? savedGrid.map((row) => row.map(() => false));
setCrossed(savedCrossed);
// Mark already-completed and already-waiting rows
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);
}
}
}
}, []);
useEffect(() => {
if (crossed.length > 0) saveCrossedState(crossed);
}, [crossed]);
// Detect newly completed rows and waiting rows
useEffect(() => {
if (!grid || crossed.length === 0) return;
for (let i = 0; i < grid.length; i++) {
// Check for bingo first
if (!celebratedRows.current.has(i) && isRowComplete(grid, crossed, i)) {
celebratedRows.current.add(i);
notifiedWaitingRows.current.add(i);
setCongratsRow(i + 1);
setShowCongrats(true);
return;
}
// Check for 4/5 (waiting)
const waitNum = getWaitingNumber(grid, crossed, i);
if (waitNum !== null && !notifiedWaitingRows.current.has(i)) {
notifiedWaitingRows.current.add(i);
showToast(`Chờ ${waitNum}`);
return;
}
// Reset waiting notification if row no longer has exactly 1 remaining
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);
saveCrossedState(newCrossed);
celebratedRows.current.clear();
notifiedWaitingRows.current.clear();
dismissToast();
}, [grid, dismissToast]);
const handleCellClick = useCallback((row: number, col: number) => {
setCrossed((prev) => {
const next = prev.map((r) => [...r]);
next[row][col] = !next[row][col];
return next;
});
}, []);
return (
<div className="flex flex-col flex-1 items-center px-3 py-8 sm:py-12">
@@ -171,7 +41,11 @@ export default function Home() {
<div className="mb-6 rounded-xl bg-indigo-50 dark:bg-indigo-950/30 border border-indigo-100 dark:border-indigo-900 p-4 text-sm text-slate-600 dark:text-slate-400">
<ul className="space-y-1 list-disc list-inside">
<li>
Nhấn <strong className="text-slate-800 dark:text-slate-200">Tạo bảng mới</strong> đ tạo bảng
Nhấn{" "}
<strong className="text-slate-800 dark:text-slate-200">
Tạo bảng mới
</strong>{" "}
đ tạo bảng
</li>
<li>Nhấn vào ô số đ đánh dấu khi số đưc xổ</li>
<li>Nhấn lại đ bỏ đánh dấu</li>
@@ -180,75 +54,7 @@ export default function Home() {
</div>
)}
{/* Generate button */}
<div className="flex justify-center mb-8">
<button
onClick={handleGenerate}
className="px-8 py-3 rounded-full font-semibold text-white
bg-gradient-to-r from-indigo-500 to-purple-500
hover:from-indigo-600 hover:to-purple-600
active:scale-95 transition-all shadow-lg shadow-indigo-500/25"
>
Tạo bảng mới
</button>
</div>
{/* Grid with toast overlay */}
{grid ? (
<div className="relative">
<div className="rounded-2xl overflow-hidden shadow-xl shadow-slate-200/50 dark:shadow-black/30 border border-slate-200 dark:border-slate-700">
<div className="loto-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 (
<div
key={idx}
onClick={hasNumber ? () => 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 : ""}
</div>
);
})}
</div>
</div>
{/* "Chờ X" toast overlay */}
{toast && (
<div
onClick={dismissToast}
className="absolute inset-0 flex items-center justify-center pointer-events-auto cursor-pointer z-10"
>
<div className="px-6 py-3 rounded-2xl bg-amber-500/90 dark:bg-amber-600/90 text-white text-xl sm:text-2xl font-black shadow-xl animate-toast">
{toast}
</div>
</div>
)}
</div>
) : (
<div className="text-center text-slate-400 dark:text-slate-500 py-20 text-sm">
Nhấn &ldquo;Tạo bảng mới&rdquo; đ bắt đu chơi
</div>
)}
<PlayerBoard />
{/* Footer */}
<footer className="mt-10 text-center text-xs text-slate-400 dark:text-slate-600">
@@ -263,44 +69,6 @@ export default function Home() {
</a>
</footer>
</div>
{/* Congratulations popup */}
{showCongrats && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm animate-fade-in"
onClick={() => setShowCongrats(false)}
>
<div
className="relative mx-4 max-w-sm w-full rounded-3xl bg-white dark:bg-slate-800 p-8 text-center shadow-2xl animate-pop-in"
onClick={(e) => e.stopPropagation()}
>
<div className="absolute -top-6 left-1/2 -translate-x-1/2 text-6xl animate-bounce-slow">
🎉
</div>
<div className="absolute top-2 left-4 text-2xl animate-spin-slow"></div>
<div className="absolute top-2 right-4 text-2xl animate-spin-slow-reverse">🎊</div>
<h2 className="mt-6 text-3xl font-black bg-gradient-to-r from-amber-500 via-pink-500 to-purple-500 bg-clip-text text-transparent">
Kinh!
</h2>
<p className="mt-3 text-lg text-slate-600 dark:text-slate-300">
Hàng <span className="font-bold text-pink-500">{congratsRow}</span> đã đy đ!
</p>
<p className="mt-1 text-sm text-slate-400 dark:text-slate-500">
Hãy to &ldquo;Kinh!&rdquo; 🎶
</p>
<button
onClick={() => setShowCongrats(false)}
className="mt-6 px-8 py-2.5 rounded-full font-semibold text-white
bg-gradient-to-r from-pink-500 to-purple-500
hover:from-pink-600 hover:to-purple-600
active:scale-95 transition-all shadow-lg"
>
Tuyệt vời! 🥳
</button>
</div>
</div>
)}
</div>
);
}