From f0b3b2cc8df80dd73fc37eb49dad8db60a0e2cd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Czerwi=C5=84ski?= <86623851+kubaczerwinski77@users.noreply.github.com> Date: Mon, 4 Dec 2023 17:08:03 +0100 Subject: [PATCH] PR: Implement sudoku game (#87) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement sudoku game with timer and check counter 🧩 ![image](https://github.com/zpi-2023/senso-frontend/assets/86623851/09afad75-88e0-4046-8683-0b3d220a888e) --- app/games/sudoku.tsx | 218 ++++++++++++++++++++++++++++++++++ app/games/wordle.tsx | 3 +- assets/i18n.json | 40 +++++++ common/actions/list.ts | 6 + common/constants/dashboard.ts | 1 + common/constants/routes.ts | 1 + common/score.tsx | 1 - logic/sudoku.ts | 146 +++++++++++++++++++++++ 8 files changed, 413 insertions(+), 3 deletions(-) create mode 100644 app/games/sudoku.tsx create mode 100644 logic/sudoku.ts diff --git a/app/games/sudoku.tsx b/app/games/sudoku.tsx new file mode 100644 index 0000000..1c73a18 --- /dev/null +++ b/app/games/sudoku.tsx @@ -0,0 +1,218 @@ +import { router } from "expo-router"; +import { useEffect, useRef, useState } from "react"; +import { Alert, Dimensions, TextInput, View } from "react-native"; // eslint-disable-line senso-import-sources +import { Text, Button } from "react-native-paper"; + +import { actions } from "@/common/actions"; +import { useMutation } from "@/common/api"; +import { AppRoutes } from "@/common/constants"; +import { useI18n } from "@/common/i18n"; +import { calculateScore } from "@/common/score"; +import { sty } from "@/common/styles"; +import { Header } from "@/components"; +import { + checkBoard, + emptyCellCount, + generateSolvedSudoku, + generateSudoku, +} from "@/logic/sudoku"; + +const screen = { + width: Dimensions.get("window").width, + height: Dimensions.get("window").height, +}; + +const calculateBorder = (rowIndex: number, colIndex: number) => { + let borderStyle = {}; + + if (rowIndex >= 0 && rowIndex <= 7) { + borderStyle = { + ...borderStyle, + borderBottomWidth: rowIndex === 2 || rowIndex === 5 ? 2 : 1, + }; + } + + if (colIndex >= 0 && colIndex <= 7) { + borderStyle = { + ...borderStyle, + borderRightWidth: colIndex === 2 || colIndex === 5 ? 3 : 1, + }; + } + + return borderStyle; +}; + +const SudokuGame = () => { + const { t } = useI18n(); + const styles = useStyles(); + const timer = useRef(null); + const [seconds, setSeconds] = useState(0); + const [checks, setChecks] = useState(0); + const [gameStarted, setGameStarted] = useState(true); + const [solvedBoard] = useState(() => generateSolvedSudoku()); + const [[cellsToFill, board], setBoard] = useState< + [cellsToFill: { row: number; col: number }[], board: number[][]] + >(() => generateSudoku(emptyCellCount, solvedBoard)); + const postNewScore = useMutation("post", "/api/v1/games/{gameName}/score"); + + // Function to handle digit input + const handleDigitInput = (row: number, col: number, value: number) => { + setBoard((prevBoard) => { + const [prevCellsToFill, prevSudokuBoard] = prevBoard; + const newBoard = [...prevSudokuBoard]; + newBoard[row]![col] = value < 1 || value > 9 ? 0 : value; + return [prevCellsToFill, newBoard]; + }); + }; + + const showResult = () => { + const result = checkBoard(board); + setChecks((prevChecks) => prevChecks + 1); + if (result) { + setGameStarted(false); + void postNewScore({ + params: { + path: { + gameName: "sudoku", + }, + }, + body: { + score: calculateScore(checks, seconds), + }, + }); + } + Alert.alert( + result + ? t("games.sudoku.alertTitlePositive") + : t("games.sudoku.alertTitleNegative"), + result ? t("games.sudoku.alertDescription", { time: seconds }) : "", + [ + { + text: result + ? t("games.sudoku.backToMenu") + : t("games.sudoku.continue"), + onPress: () => (result ? router.replace(AppRoutes.Dashboard) : null), + }, + ], + ); + }; + + useEffect(() => { + if (gameStarted) { + timer.current = setInterval(() => { + setSeconds((prevSeconds) => prevSeconds + 1); + }, 1000); + } + + return () => { + if (timer.current) { + clearInterval(timer.current); + } + }; + }, [gameStarted]); + + return ( + +
+ + + {t("games.sudoku.timer", { time: seconds })} + + + {t("games.sudoku.checkCounter", { checks })} + + + + + {board.map((row, rowIndex) => ( + + {row.map((digit, colIndex) => ( + + {cellsToFill.some( + (cell) => cell.row === rowIndex && cell.col === colIndex, + ) ? ( + + handleDigitInput( + rowIndex, + colIndex, + parseInt(value, 10) || 0, + ) + } + /> + ) : ( + {digit} + )} + + ))} + + ))} + + + ); +}; + +const useStyles = sty.themedHook(({ colors }) => ({ + gameWrapper: { + flex: 1, + gap: 10, + }, + digit: { + fontSize: 30, + color: colors.text, + }, + digitInput: { + width: screen.width / 9 - 9, + aspectRatio: 1, + textAlign: "center", + backgroundColor: "lightgray", + borderRadius: 4, + fontSize: 30, + color: "black", + }, + statsText: { + fontSize: 18, + }, + sudokuWrapper: { + flexDirection: "row", + flexWrap: "wrap", + }, + boardRow: { + flexDirection: "row", + borderColor: colors.tertiary, + }, + boardCell: { + width: screen.width / 9, + aspectRatio: 1, + justifyContent: "center", + alignItems: "center", + borderColor: colors.tertiary, + }, + statsLabel: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + marginHorizontal: 20, + }, +})); + +export default SudokuGame; diff --git a/app/games/wordle.tsx b/app/games/wordle.tsx index 0450300..092c8ac 100644 --- a/app/games/wordle.tsx +++ b/app/games/wordle.tsx @@ -61,7 +61,7 @@ const Page = () => { const lastGuessRef = useRef(null); const scrollViewRef = useRef(null); const [seconds, setSeconds] = useState(0); - const [gameStarted, setGameStarted] = useState(false); + const [gameStarted, setGameStarted] = useState(true); const [previousGuesses, setPreviousGuesses] = useState([]); const [currentGuess, setCurrentGuess] = useState( new Array(5).fill(""), @@ -122,7 +122,6 @@ const Page = () => { }; useEffect(() => { - setGameStarted(true); textInputRefs.current[0]?.focus(); }, []); diff --git a/assets/i18n.json b/assets/i18n.json index 791625a..220ce2b 100644 --- a/assets/i18n.json +++ b/assets/i18n.json @@ -269,6 +269,10 @@ "en": "Graydle", "pl": "Graydle" }, + "actions.playSudokuGame": { + "en": "Sudoku", + "pl": "Sudoku" + }, "actions.manageNotes": { "en": "Notes", "pl": "Notatki" @@ -525,6 +529,42 @@ "en": "Play again", "pl": "Zagraj ponownie" }, + "games.sudoku.pageTitle": { + "en": "Sudoku", + "pl": "Sudoku" + }, + "games.sudoku.timer": { + "en": "Time: {time}", + "pl": "Czas: {time}" + }, + "games.sudoku.checkCounter": { + "en": "Checks: {checks}", + "pl": "Sprawdzenia: {checks}" + }, + "games.sudoku.alertTitlePositive": { + "en": "Congratulations!", + "pl": "Gratulacje!" + }, + "games.sudoku.alertTitleNegative": { + "en": "Solution is incorrect", + "pl": "Rozwiązanie jest niepoprawne" + }, + "games.sudoku.alertDescription": { + "en": "You have completed the game in time {time}.", + "pl": "Ukończyłeś grę w czasie {time}." + }, + "games.sudoku.continue": { + "en": "Continue", + "pl": "Kontynuuj" + }, + "games.sudoku.backToMenu": { + "en": "Back to menu", + "pl": "Powrót do menu" + }, + "games.sudoku.check": { + "en": "Check sudoku", + "pl": "Sprawdź sudoku" + }, "medication.pills": { "en": "{count} pills", "en_1": "{count} pill", diff --git a/common/actions/list.ts b/common/actions/list.ts index 819b2be..612fa1e 100644 --- a/common/actions/list.ts +++ b/common/actions/list.ts @@ -100,6 +100,12 @@ export const actions = { handler: ({ router }) => router.push(AppRoutes.WordleGame), hidden: ({ identity }) => isCaretaker(identity.profile), }, + playSudokuGame: { + displayName: (t) => t("actions.playSudokuGame"), + icon: "apps", + handler: ({ router }) => router.push(AppRoutes.SudokuGame), + hidden: ({ identity }) => isCaretaker(identity.profile), + }, manageNotes: { displayName: (t) => t("actions.manageNotes"), icon: "note", diff --git a/common/constants/dashboard.ts b/common/constants/dashboard.ts index 4504c4d..7b900c2 100644 --- a/common/constants/dashboard.ts +++ b/common/constants/dashboard.ts @@ -8,6 +8,7 @@ export const availableGadgets = [ "playGames", "playMemoryGame", "playWordleGame", + "playSudokuGame", "manageNotes", "quickCreateNote", "switchProfile", diff --git a/common/constants/routes.ts b/common/constants/routes.ts index 648035c..e075f7b 100644 --- a/common/constants/routes.ts +++ b/common/constants/routes.ts @@ -16,6 +16,7 @@ export const enum AppRoutes { Games = "/games", MemoryGame = "/games/memory", WordleGame = "/games/wordle", + SudokuGame = "/games/sudoku", NoteList = "/notes", NoteDetails = "/notes/[noteId]", CreateNote = "/notes/create", diff --git a/common/score.tsx b/common/score.tsx index 21854a4..d462efa 100644 --- a/common/score.tsx +++ b/common/score.tsx @@ -1,5 +1,4 @@ export const calculateScore = (moves: number, seconds: number) => { const score = Math.round(1 / (moves * seconds * 1000000)); - console.log("score", score); return score; }; diff --git a/logic/sudoku.ts b/logic/sudoku.ts new file mode 100644 index 0000000..156bb85 --- /dev/null +++ b/logic/sudoku.ts @@ -0,0 +1,146 @@ +// Cells to fill in the board +export const emptyCellCount = 30; + +// Function to check if a value can be placed in a given cell +const isValid = ( + board: number[][], + row: number, + col: number, + num: number, +): boolean => { + // Check if the number is already in the same row or column + for (let i = 0; i < 9; i++) { + if (board[row]![i] === num || board[i]![col] === num) { + return false; + } + } + + // Check if the number is already in the 3x3 subgrid + const startRow = Math.floor(row / 3) * 3; + const startCol = Math.floor(col / 3) * 3; + for (let i = startRow; i < startRow + 3; i++) { + for (let j = startCol; j < startCol + 3; j++) { + if (board[i]![j] === num) { + return false; + } + } + } + + return true; +}; + +// Function to solve the Sudoku puzzle using backtracking +const solveSudoku = (board: number[][]): boolean => { + for (let row = 0; row < 9; row++) { + for (let col = 0; col < 9; col++) { + if (board[row]![col] === 0) { + for (let num = 1; num <= 9; num++) { + if (isValid(board, row, col, num)) { + board[row]![col] = num; + + if (solveSudoku(board)) { + return true; + } + + board[row]![col] = 0; + } + } + + return false; + } + } + } + + return true; // All cells filled +}; + +export const generateSolvedSudoku = (): number[][] => { + const board: number[][] = []; + + // Initialize the board with empty places + for (let i = 0; i < 9; i++) { + board.push(Array(9).fill(0)); + } + + // Solve the board + solveSudoku(board); + + return board; +}; + +export const generateSudoku = ( + emptyCells: number, + solvedBoard: number[][], +): [cellsToFill: { row: number; col: number }[], board: number[][]] => { + const cellsToFill: { row: number; col: number }[] = []; + // Randomly remove numbers to create the desired number of empty cells + for (let i = 0; i < emptyCells; i++) { + const row = Math.floor(Math.random() * 9); + const col = Math.floor(Math.random() * 9); + if (solvedBoard[row]![col] !== 0) { + solvedBoard[row]![col] = 0; + cellsToFill.push({ row, col }); + } else { + // If the cell is already empty, try again + i--; + } + } + + return [cellsToFill, solvedBoard]; +}; + +export const checkBoard = (sudokuBoard: number[][]): boolean => { + // Check if the board is valid + for (let row = 0; row < 9; row++) { + const rowSet = new Set(); + for (let col = 0; col < 9; col++) { + const digit = sudokuBoard[row]![col] as number; + if (digit === 0 || rowSet.has(digit)) { + return false; + } + rowSet.add(digit); + } + } + + // Check rows + for (let row = 0; row < 9; row++) { + const rowSet = new Set(); + for (let col = 0; col < 9; col++) { + const digit = sudokuBoard[row]![col] as number; + if (digit === 0 || rowSet.has(digit)) { + return false; + } + rowSet.add(digit); + } + } + + // Check columns + for (let col = 0; col < 9; col++) { + const colSet = new Set(); + for (let row = 0; row < 9; row++) { + const digit = sudokuBoard[row]![col] as number; + if (digit === 0 || colSet.has(digit)) { + return false; + } + colSet.add(digit); + } + } + + // Check 3x3 grids + for (let gridRow = 0; gridRow < 3; gridRow++) { + for (let gridCol = 0; gridCol < 3; gridCol++) { + const gridSet = new Set(); + for (let row = gridRow * 3; row < gridRow * 3 + 3; row++) { + for (let col = gridCol * 3; col < gridCol * 3 + 3; col++) { + const digit = sudokuBoard[row]![col] as number; + if (digit === 0 || gridSet.has(digit)) { + return false; + } + gridSet.add(digit); + } + } + } + } + + return true; +};