diff --git a/.eslintrc.json b/.eslintrc.json index e37e1e072..13018ddc0 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -3,7 +3,12 @@ "plugins": ["prettier"], "rules": { "camelcase": ["error", { "properties": "never" }], - "prettier/prettier": "error", + "prettier/prettier": [ + "error", + { + "endOfLine": "auto" + } +], "eqeqeq": ["error", "always"], "no-unused-vars": ["error"] } diff --git a/docs/mvp-spec.md b/docs/mvp-spec.md index fab47685e..e083d7788 100644 --- a/docs/mvp-spec.md +++ b/docs/mvp-spec.md @@ -14,9 +14,10 @@ Количество карточек для каждого уровня сложности можете назначать и свои или выбрать готовый пресет. Предлагаем следующее пресеты: - - Легкий уровень - 6 карточек (3 пары) - - Средний уровень - 12 карточек (6 пар) - - Сложный уровень - 18 карточек (9 пар) + +- Легкий уровень - 6 карточек (3 пары) +- Средний уровень - 12 карточек (6 пар) +- Сложный уровень - 18 карточек (9 пар) Как только уровень сложности выбран, игроку показывается на игровой поле. diff --git a/lefthook.yml b/lefthook.yml index 87752f862..3a6272ee7 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -18,3 +18,4 @@ pre-commit: eslint: glob: "*.{js,jsx}" run: npm run lint +"prettier/prettier": ["error", { "endOfLine": "auto" }] diff --git a/package-lock.json b/package-lock.json index edaf5083f..c20f8c388 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", "classnames": "^2.3.2", + "clsx": "^2.1.1", "gh-pages": "^6.0.0", "lodash": "^4.17.21", "react": "^18.2.0", @@ -6111,6 +6112,14 @@ "wrap-ansi": "^7.0.0" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "engines": { + "node": ">=6" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -22658,6 +22667,11 @@ "wrap-ansi": "^7.0.0" } }, + "clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==" + }, "co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", diff --git a/package.json b/package.json index e9b7a089e..f9e347ea6 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", "classnames": "^2.3.2", + "clsx": "^2.1.1", "gh-pages": "^6.0.0", "lodash": "^4.17.21", "react": "^18.2.0", diff --git a/src/API/leaders.js b/src/API/leaders.js new file mode 100644 index 000000000..0846ecf15 --- /dev/null +++ b/src/API/leaders.js @@ -0,0 +1,29 @@ +export async function getLeaders() { + try { + const response = await fetch("https://wedev-api.sky.pro/api/leaderboard/", { + method: "GET", + }); + const isResponseOk = response.ok; + const result = await response.json(); + if (isResponseOk) { + return result.leaders; + } else { + throw new Error(result.error); + } + } catch (error) { + throw new Error(error.message); + } +} + +//добавление лидера в список +export const addLeader = async data => { + const response = await fetch("https://wedev-api.sky.pro/api/leaderboard/", { + method: "POST", + body: JSON.stringify(data), + }); + + if (!response.ok) { + throw new Error("Упс, ошибка"); + } + return response.json(); +}; diff --git a/src/components/Cards/Cards.jsx b/src/components/Cards/Cards.jsx index 7526a56c8..23b2125a4 100644 --- a/src/components/Cards/Cards.jsx +++ b/src/components/Cards/Cards.jsx @@ -1,10 +1,11 @@ import { shuffle } from "lodash"; -import { useEffect, useState } from "react"; +import { useContext, useEffect, useState } from "react"; import { generateDeck } from "../../utils/cards"; import styles from "./Cards.module.css"; import { EndGameModal } from "../../components/EndGameModal/EndGameModal"; import { Button } from "../../components/Button/Button"; import { Card } from "../../components/Card/Card"; +import { EasyContext } from "../../context/Context"; // Игра закончилась const STATUS_LOST = "STATUS_LOST"; @@ -41,6 +42,7 @@ function getTimerValue(startDate, endDate) { * previewSeconds - сколько секунд пользователь будет видеть все карты открытыми до начала игры */ export function Cards({ pairsCount = 3, previewSeconds = 5 }) { + const { isEasyMode, tries, setTries } = useContext(EasyContext); // В cards лежит игровое поле - массив карт и их состояние открыта\закрыта const [cards, setCards] = useState([]); // Текущий статус игры @@ -73,6 +75,9 @@ export function Cards({ pairsCount = 3, previewSeconds = 5 }) { setGameEndDate(null); setTimer(getTimerValue(null, null)); setStatus(STATUS_PREVIEW); + if (isEasyMode) { + setTries(3); + } } /** @@ -127,6 +132,27 @@ export function Cards({ pairsCount = 3, previewSeconds = 5 }) { // "Игрок проиграл", т.к на поле есть две открытые карты без пары if (playerLost) { + if (isEasyMode) { + setTries(tries - 1); + if (tries === 1) { + finishGame(STATUS_LOST); + } else { + const newCards = cards.map(c => { + if (openCardsWithoutPair.find(card => card.id === c.id)) { + return { + ...c, + open: false, + }; + } + return c; + }); + + setTimeout(() => { + setCards(newCards); + }, 500); + } + return; + } finishGame(STATUS_LOST); return; } @@ -196,6 +222,7 @@ export function Cards({ pairsCount = 3, previewSeconds = 5 }) { )} {status === STATUS_IN_PROGRESS ? : null} + {isEasyMode && Колличество жизней: {tries}}
diff --git a/src/components/EndGameModal/EndGameModal.jsx b/src/components/EndGameModal/EndGameModal.jsx index 722394833..4ce815b1c 100644 --- a/src/components/EndGameModal/EndGameModal.jsx +++ b/src/components/EndGameModal/EndGameModal.jsx @@ -4,24 +4,75 @@ import { Button } from "../Button/Button"; import deadImageUrl from "./images/dead.png"; import celebrationImageUrl from "./images/celebration.png"; +import { Link, useNavigate, useParams } from "react-router-dom"; +import { useState } from "react"; +import { addLeader } from "../../API/leaders.js"; export function EndGameModal({ isWon, gameDurationSeconds, gameDurationMinutes, onClick }) { - const title = isWon ? "Вы победили!" : "Вы проиграли!"; + const { pairsCount } = useParams(); + const [error, setError] = useState(); + const nav = useNavigate(); + + const thirdLevelPairs = 9; + const isLeader = isWon && Number(pairsCount) === thirdLevelPairs; + const title = isLeader ? "Вы попали на лидерборд!" : isWon ? "Вы выйграли!" : "Вы проиграли!"; + + //const title = isWon ? "Вы выйграли!" : "Вы проиграли!"; const imgSrc = isWon ? celebrationImageUrl : deadImageUrl; const imgAlt = isWon ? "celebration emodji" : "dead emodji"; + const [leader, setAddLeader] = useState({ + name: "", + time: gameDurationMinutes.toString().padStart("2", "0") + gameDurationSeconds.toString().padStart("2", "0"), + }); + + const addLeaderToList = async e => { + e.preventDefault(); + if (leader.name === "") { + setError("Введите имя пользователя"); + return; + } + try { + await addLeader({ name: leader.name, time: leader.time }).then(res => { + setAddLeader(res.leaders); + nav("/leaderBoard"); + }); + } catch (error) { + setError(error.message); + } + }; + return (
{imgAlt}

{title}

+ {isLeader ? ( +
+ setAddLeader({ ...leader, name: e.target.value })} + id="name-input" + type="text" + name="name" + className={styles.input} + placeholder="Пользователь" + /> +
+ ) : null}

Затраченное время:

- {gameDurationMinutes.toString().padStart("2", "0")}.{gameDurationSeconds.toString().padStart("2", "0")} + {gameDurationMinutes.toString().padStart("2", "0")}: {gameDurationSeconds.toString().padStart("2", "0")}
- - + + {isLeader ? ( + + + + ) : null} + {error &&

{error}

}
); } diff --git a/src/components/EndGameModal/EndGameModal.module.css b/src/components/EndGameModal/EndGameModal.module.css index 9368cb8b5..4d18073f3 100644 --- a/src/components/EndGameModal/EndGameModal.module.css +++ b/src/components/EndGameModal/EndGameModal.module.css @@ -1,12 +1,15 @@ .modal { width: 480px; - height: 459px; + min-height: 459px; border-radius: 12px; background: #c2f5ff; display: flex; + gap: 10px; flex-direction: column; justify-content: center; align-items: center; + padding-top: 18px; + padding-bottom: 28px; } .image { @@ -23,8 +26,9 @@ font-style: normal; font-weight: 400; line-height: 48px; - margin-bottom: 28px; + width: 276px; + text-align: center; } .description { @@ -49,3 +53,37 @@ margin-bottom: 40px; } + +.btnLeaderBoard { + padding-top: 10px; + background: none; + border: none; + text-decoration: underline; + cursor: pointer; + font-size: 18px; + font-family: Roboto; + font-weight: 400; + line-height: 32px; + text-align: left; + color: #565eef; +} +.userBlockName { + margin-bottom: 10px; +} +.input { + width: 276px; + height: 45px; + top: 334px; + left: 374px; + gap: 0px; + border-radius: 10px; + opacity: 0px; + border: none; + margin-bottom: 30px; + font-family: Roboto; + font-size: 24px; + font-weight: 400; + line-height: 32px; + text-align: center; + color: #999999; +} diff --git a/src/context/Context.jsx b/src/context/Context.jsx new file mode 100644 index 000000000..ff14509f5 --- /dev/null +++ b/src/context/Context.jsx @@ -0,0 +1,9 @@ +import { createContext, useState } from "react"; + +export const EasyContext = createContext(false); + +export const EasyProvider = ({ children }) => { + const [tries, setTries] = useState(3); + const [isEasyMode, setEasyMode] = useState(false); + return {children}; +}; diff --git a/src/index.css b/src/index.css index 78f0d3a2b..38e57fd1c 100644 --- a/src/index.css +++ b/src/index.css @@ -1,3 +1,9 @@ +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + html { margin: 0; } @@ -35,3 +41,8 @@ ol { font-weight: 400; font-style: normal; } + +ul { + list-style-type: none; +} + diff --git a/src/index.js b/src/index.js index f689c5f0b..c955c852f 100644 --- a/src/index.js +++ b/src/index.js @@ -3,10 +3,13 @@ import ReactDOM from "react-dom/client"; import "./index.css"; import { RouterProvider } from "react-router-dom"; import { router } from "./router"; +import { EasyProvider } from "./context/Context"; const root = ReactDOM.createRoot(document.getElementById("root")); root.render( - + + + , ); diff --git a/src/pages/LeaderBoard/LeaderBoard.jsx b/src/pages/LeaderBoard/LeaderBoard.jsx new file mode 100644 index 000000000..476cda8f6 --- /dev/null +++ b/src/pages/LeaderBoard/LeaderBoard.jsx @@ -0,0 +1,46 @@ +import React, { useEffect, useState } from "react"; +import { Button } from "../../components/Button/Button"; +import styles from "./LeaderBoard.module.css"; +import { getLeaders } from "../../API/leaders"; +import { Link } from "react-router-dom"; + +const LeaderBoard = () => { + const [leaders, setLeaders] = useState([{ position: "Позиция", name: "Пользователь", time: "Время" }]); + useEffect(() => { + getLeaders().then(data => { + setLeaders([...leaders, ...data.sort((a, b) => a.time - b.time)]); + }); + }, []); + + const timeFormat = digit => { + let minutes = Math.floor(digit / 60); + let seconds = digit % 60; + return [minutes < 10 ? "0" + minutes : minutes, ":", seconds < 10 ? "0" + seconds : seconds]; + }; + + return ( +
+
+

Лидерборд

+ + + +
+
+
    + {leaders.map((player, index) => ( +
  • +
      +
    • {index === 0 ? player.position : player.id}
    • +
    • {player.name}
    • +
    • {index === 0 ? player.time : timeFormat(player.time)}
    • +
    +
  • + ))} +
+
+
+ ); +}; + +export default LeaderBoard; diff --git a/src/pages/LeaderBoard/LeaderBoard.module.css b/src/pages/LeaderBoard/LeaderBoard.module.css new file mode 100644 index 000000000..bac7ded00 --- /dev/null +++ b/src/pages/LeaderBoard/LeaderBoard.module.css @@ -0,0 +1,67 @@ +.leaderBoardBlock { + display: flex; + flex-direction: column; + align-items: center; + font-family: "StratosSkyeng"; +} + +.leaderBoarHeader { + display: flex; + justify-content: space-between; + align-items: center; + color: white; + width: 944px; + height: 142px; +} + +.players { + display: flex; + flex-direction: column; + align-items: center; + gap: 15px; +} + +.statisticItem { + width: 944px; + height: 64px; + background-color: #ffffff; + border-radius: 12px; +} + +.playerStatistic { + display: flex; + align-items: center; + height: 64px; + font-size: 24px; + padding: 20px 16px; +} +.statisticItem:first-child { + color: #999999; +} + +.firstColumn { + width: 244px; +} + +.secondColumn { + width: 558px; +} + +.thirdColumn { + width: 102px; +} + +.leaderBoardTitle { + font-weight: 400; + font-size: 32px; +} + +/* .btnLeaderBoard { + font-family: Roboto; + font-size: 18px; + font-weight: 400; + line-height: 32px; + text-align: left; + background: #565EEF; + +} */ diff --git a/src/pages/SelectLevelPage/SelectLevelPage.jsx b/src/pages/SelectLevelPage/SelectLevelPage.jsx index 758942e51..10f4f451d 100644 --- a/src/pages/SelectLevelPage/SelectLevelPage.jsx +++ b/src/pages/SelectLevelPage/SelectLevelPage.jsx @@ -1,28 +1,55 @@ -import { Link } from "react-router-dom"; +import { Link, useNavigate } from "react-router-dom"; import styles from "./SelectLevelPage.module.css"; - +import { useContext, useState } from "react"; +import { EasyContext } from "../../context/Context"; +import clsx from "clsx"; +import { Button } from "../../components/Button/Button"; +const levels = [ + { + level: 1, + pairs: 3, + }, + { + level: 2, + pairs: 6, + }, + { + level: 3, + pairs: 9, + }, +]; export function SelectLevelPage() { + const { isEasyMode, setEasyMode } = useContext(EasyContext); + const [level, setLevel] = useState(3); + const navigate = useNavigate(); + function onClick(value) { + setLevel(value); + } + function onStart() { + navigate(`/game/${level}`); + } return (

Выбери сложность

    -
  • - - 1 - -
  • -
  • - - 2 - -
  • -
  • - - 3 - -
  • + {levels.map(l => ( +
  • onClick(l.pairs)} className={clsx(styles.level, { [styles.active]: l.pairs === level })}> +
    {l.level}
    +
  • + ))}
+ + + + + + Перейти к Лидерборду +
); diff --git a/src/pages/SelectLevelPage/SelectLevelPage.module.css b/src/pages/SelectLevelPage/SelectLevelPage.module.css index 390ac0def..2475bdfa0 100644 --- a/src/pages/SelectLevelPage/SelectLevelPage.module.css +++ b/src/pages/SelectLevelPage/SelectLevelPage.module.css @@ -62,3 +62,54 @@ .levelLink:visited { color: #0080c1; } + +.active { + outline: 4px dotted teal; +} + +.checkbox { + width: 20px; + height: 20px; + border-radius: 5px; + border: 1px dotted teal; + background-color: white; + position: relative; + color: #7ac100; +} +.label { + display: flex; + gap: 5px; + flex-direction: row-reverse; + padding-bottom: 20px; + font-family: Roboto; + font-size: 24px; + font-weight: 400; + line-height: 32px; + text-align: center; +} +.label input { + appearance: none; +} +.label input:checked + div::after { + content: "\2714"; + display: block; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +} +.Link { + padding-top: 20px; + width: 192px; + height: 32px; + top: 571px; + left: 417px; + gap: 0px; + opacity: 0px; + font-family: Roboto; + font-size: 18px; + font-weight: 400; + line-height: 32px; + text-align: left; + color: #004980; +} diff --git a/src/router.js b/src/router.js index da6e94b51..dee6ea7be 100644 --- a/src/router.js +++ b/src/router.js @@ -1,6 +1,7 @@ import { createBrowserRouter } from "react-router-dom"; import { GamePage } from "./pages/GamePage/GamePage"; import { SelectLevelPage } from "./pages/SelectLevelPage/SelectLevelPage"; +import LeaderBoard from "./pages/LeaderBoard/LeaderBoard"; export const router = createBrowserRouter( [ @@ -12,6 +13,10 @@ export const router = createBrowserRouter( path: "/game/:pairsCount", element: , }, + { + path: "/leaderboard", + element: , + }, ], /** * basename нужен для корректной работы в gh pages