Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,15 @@ https://skypro-web-developer.github.io/react-memo/

Запускает eslint проверку кода, эта же команда запускается перед каждым коммитом.
Если не получается закоммитить, попробуйте запустить эту команду и исправить все ошибки и предупреждения.

### Время разработки упрощенного режима игры

Планируемое время 12 часов — фактический 4 часа

### Время разработки лидерборда

Планируемое время 16 часа — фактический 10 часов

### Время разработки "Прозрения" и ачивок

Планируемое время 10 часа — фактический 8 часов
7 changes: 4 additions & 3 deletions docs/mvp-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@
Количество карточек для каждого уровня сложности можете назначать и свои или выбрать готовый пресет.

Предлагаем следующее пресеты:
- Легкий уровень - 6 карточек (3 пары)
- Средний уровень - 12 карточек (6 пар)
- Сложный уровень - 18 карточек (9 пар)

- Легкий уровень - 6 карточек (3 пары)
- Средний уровень - 12 карточек (6 пар)
- Сложный уровень - 18 карточек (9 пар)

Как только уровень сложности выбран, игроку показывается на игровой поле.

Expand Down
214 changes: 185 additions & 29 deletions package-lock.json

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.15.0",
"react-scripts": "5.0.1"
"react-scripts": "5.0.1",
"styled-components": "^6.1.12"
},
"scripts": {
"predeploy": "npm run build",
Expand Down
168 changes: 159 additions & 9 deletions src/components/Cards/Cards.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,16 @@ 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 { useSimpleModeContext } from "../../context/hooks/useSimpleMode";
import { getLeaders } from "../../utils/api";
import { EndGameLeaderBoardModal } from "../EndGameLeaderBoardModal/EndGameLeaderBoardModal";
import * as S from "../Cards/Cards.styled";
import { useEpiphanyContext } from "../../context/hooks/useEpiphany";

// Игра закончилась
const STATUS_LOST = "STATUS_LOST";
const STATUS_WON = "STATUS_WON";
const STATUS_LEADERBOARD_WON = "STATUS_LEADERBOARD_WON";
// Идет игра: карты закрыты, игрок может их открыть
const STATUS_IN_PROGRESS = "STATUS_IN_PROGRESS";
// Начало игры: игрок видит все карты в течении нескольких секунд
Expand Down Expand Up @@ -41,15 +47,40 @@ function getTimerValue(startDate, endDate) {
* previewSeconds - сколько секунд пользователь будет видеть все карты открытыми до начала игры
*/
export function Cards({ pairsCount = 3, previewSeconds = 5 }) {
useEffect(() => {
getLeaders()
.then(data => {
const leaderList = data.leaders.sort((a, b) => a.time - b.time).slice(0, 10);
const leaderWithMaxTime = leaderList.reduce((acc, curr) => {
return acc.time > curr.time ? acc : curr;
}, {});
setLastTime(leaderWithMaxTime.time);
return;
})
.catch(error => {
console.log(error.message);
});
}, []);
// В cards лежит игровое поле - массив карт и их состояние открыта\закрыта
const [cards, setCards] = useState([]);
// Текущий статус игры
const [status, setStatus] = useState(STATUS_PREVIEW);

// Получаем контекст упрощенного режима: включен он или нет
const { simpleMode } = useSimpleModeContext();
// Количество оставщихся попыток в упрощенном режиме
const [countGame, setCountGame] = useState(3);
// Получаем наихудший результат в лидерборде
const [lastTime, setLastTime] = useState(null);

// Дата начала игры
const [gameStartDate, setGameStartDate] = useState(null);
// Дата конца игры
const [gameEndDate, setGameEndDate] = useState(null);
// Стейт для паузы в игре
const [isPause, setIsPause] = useState(false);
// Получаем контекст прозрения: был включен он или нет
const { isEpiphany, setIsEpiphany } = useEpiphanyContext();

// Стейт для таймера, высчитывается в setInteval на основе gameStartDate и gameEndDate
const [timer, setTimer] = useState({
Expand All @@ -67,12 +98,14 @@ export function Cards({ pairsCount = 3, previewSeconds = 5 }) {
setGameStartDate(startDate);
setTimer(getTimerValue(startDate, null));
setStatus(STATUS_IN_PROGRESS);
setIsEpiphany(false);
}
function resetGame() {
setGameStartDate(null);
setGameEndDate(null);
setTimer(getTimerValue(null, null));
setStatus(STATUS_PREVIEW);
setIsEpiphany(false);
}

/**
Expand All @@ -82,7 +115,7 @@ export function Cards({ pairsCount = 3, previewSeconds = 5 }) {
* - "Игрок проиграл", если на поле есть две открытые карты без пары
* - "Игра продолжается", если не случилось первых двух условий
*/
const openCard = clickedCard => {
const openCard = async clickedCard => {
// Если карта уже открыта, то ничего не делаем
if (clickedCard.open) {
return;
Expand All @@ -105,6 +138,14 @@ export function Cards({ pairsCount = 3, previewSeconds = 5 }) {

// Победа - все карты на поле открыты
if (isPlayerWon) {
if (pairsCount === 9) {
const timeGame = timer.minutes * 60 + timer.seconds;
if (timeGame < lastTime) {
finishGame(STATUS_LEADERBOARD_WON);
return;
}
}
setCountGame(3);
finishGame(STATUS_WON);
return;
}
Expand All @@ -127,6 +168,18 @@ export function Cards({ pairsCount = 3, previewSeconds = 5 }) {

// "Игрок проиграл", т.к на поле есть две открытые карты без пары
if (playerLost) {
if (simpleMode) {
if (countGame > 1) {
setTimeout(() => {
openCardsWithoutPair.map(card => {
card.open = false;
});
}, 500);
setCountGame(countGame - 1);
return;
}
}
setCountGame(3);
finishGame(STATUS_LOST);
return;
}
Expand All @@ -135,6 +188,7 @@ export function Cards({ pairsCount = 3, previewSeconds = 5 }) {
};

const isGameEnded = status === STATUS_LOST || status === STATUS_WON;
const isGameEndedLeaderBoard = status === STATUS_LEADERBOARD_WON;

// Игровой цикл
useEffect(() => {
Expand Down Expand Up @@ -164,14 +218,38 @@ export function Cards({ pairsCount = 3, previewSeconds = 5 }) {

// Обновляем значение таймера в интервале
useEffect(() => {
const intervalId = setInterval(() => {
setTimer(getTimerValue(gameStartDate, gameEndDate));
}, 300);
return () => {
clearInterval(intervalId);
};
}, [gameStartDate, gameEndDate]);
if (!isPause) {
const intervalId = setInterval(() => {
setTimer(getTimerValue(gameStartDate, gameEndDate));
}, 200);
return () => {
clearInterval(intervalId);
};
}
}, [gameStartDate, gameEndDate, isPause]);

// Функция срабатывания паузы на 5 секунд
const enablePause = () => {
setIsPause(true);
const openedCards = cards;
setCards(
cards.map(card => {
return {
...card,
open: true,
};
}),
);
setTimeout(() => {
const newStartGame = new Date(gameStartDate.getTime() + 5000);
setGameStartDate(newStartGame);
setIsPause(false);
setCards(openedCards);
setIsEpiphany(true);
}, 5000);
};

const timeGame = timer.minutes * 60 + timer.seconds;
return (
<div className={styles.container}>
<div className={styles.header}>
Expand All @@ -195,7 +273,66 @@ export function Cards({ pairsCount = 3, previewSeconds = 5 }) {
</>
)}
</div>
{status === STATUS_IN_PROGRESS ? <Button onClick={resetGame}>Начать заново</Button> : null}
{status === STATUS_IN_PROGRESS ? (
<>
<S.OpenAllCardsImages onClick={!isEpiphany ? enablePause : null} $isEpiphany={isEpiphany}>
<svg width="68" height="68" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="68" height="68" rx="34" fill="#C2F5FF" />
<path
d="m6.064 35.27.001.003C11.817 45.196 22.56 51.389 34 51.389c11.44 0 22.183-6.13 27.935-16.117l.001-.002.498-.87.142-.249-.142-.248-.498-.871-.001-.003C56.183 23.106 45.44 16.913 34 16.913c-11.44 0-22.183 6.193-27.935 16.116l-.001.003-.498.871-.142.248.142.248.498.871Z"
fill="#fff"
stroke="#E4E4E4"
/>
<mask id="a" maskUnits="userSpaceOnUse" x="6" y="17" width="56" height="34">
<path
d="M34 50.889c-11.262 0-21.84-6.098-27.502-15.867L6 34.152l.498-.872C12.16 23.511 22.738 17.413 34 17.413s21.84 6.098 27.502 15.867l.498.871-.498.871C55.84 44.853 45.262 50.89 34 50.89Z"
fill="#fff"
/>
</mask>
<g mask="url(#a)">
<g filter="url(#b)">
<path
d="M34 50.889c-11.262 0-21.84-6.098-27.502-15.867L6 34.152l.498-.872C12.16 23.511 22.738 17.413 34 17.413s21.84 6.098 27.502 15.867l.498.871-.498.871C55.84 44.853 45.262 50.89 34 50.89Z"
fill="#fff"
/>
</g>
<circle cx="34.311" cy="26.187" r="17.111" fill="url(#c)" />
<path
d="M39.29 26.373A5.284 5.284 0 0 1 34 21.084c0-1.057.311-2.115.871-2.924-.31-.062-.622-.062-.87-.062-4.605 0-8.276 3.733-8.276 8.275 0 4.605 3.733 8.276 8.275 8.276 4.605 0 8.276-3.733 8.276-8.276 0-.31 0-.622-.063-.87-.808.56-1.804.87-2.924.87Z"
fill="url(#d)"
/>
</g>
<defs>
<linearGradient id="c" x1="34.311" y1="9.076" x2="34.311" y2="43.298" gradientUnits="userSpaceOnUse">
<stop />
<stop offset="1" />
</linearGradient>
<linearGradient id="d" x1="34" y1="18.098" x2="34" y2="34.649" gradientUnits="userSpaceOnUse">
<stop />
<stop offset="1" />
</linearGradient>
<filter id="b" x="6" y="17.413" width="60" height="35.476" filterUnits="userSpaceOnUse">
<feFlood result="BackgroundImageFix" />
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feColorMatrix
in="SourceAlpha"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dx="4" dy="2" />
<feGaussianBlur stdDeviation="3" />
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
<feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0" />
<feBlend in2="shape" result="effect1_innerShadow_0_1" />
</filter>
</defs>
</svg>
</S.OpenAllCardsImages>
<Button countGame={simpleMode ? countGame : null} onClick={resetGame}>
Начать заново
</Button>
</>
) : null}
</div>

<div className={styles.cards}>
Expand All @@ -210,6 +347,8 @@ export function Cards({ pairsCount = 3, previewSeconds = 5 }) {
))}
</div>

{!simpleMode ? null : <p>Осталось попыток: {countGame}</p>}

{isGameEnded ? (
<div className={styles.modalContainer}>
<EndGameModal
Expand All @@ -220,6 +359,17 @@ export function Cards({ pairsCount = 3, previewSeconds = 5 }) {
/>
</div>
) : null}

{isGameEndedLeaderBoard ? (
<div className={styles.modalContainer}>
<EndGameLeaderBoardModal
gameDurationSeconds={timer.seconds}
gameDurationMinutes={timer.minutes}
timeGame={timeGame}
resetGame={resetGame}
/>
</div>
) : null}
</div>
);
}
12 changes: 11 additions & 1 deletion src/components/Cards/Cards.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,16 @@
font-style: normal;
font-weight: 400;
line-height: 32px;

margin-bottom: -12px;
}

p {
color: #fff;
font-family: StratosSkyeng;
font-style: normal;
font-weight: 400;
line-height: 50px;
text-align: center;
font-size: 24px;
margin-top: 24px;
}
7 changes: 7 additions & 0 deletions src/components/Cards/Cards.styled.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import styled from "styled-components";

export const OpenAllCardsImages = styled.span`
cursor: pointer;
transition: opacity 0.3s;
opacity: ${props => (props.$isEpiphany ? "0.4" : "1")};
`;
58 changes: 58 additions & 0 deletions src/components/EndGameLeaderBoardModal/EndGameLeaderBoardModal.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import styles from "./EndGameLeaderBoardModal.module.css";
import { Button } from "../Button/Button";
import celebrationImageUrl from "../EndGameModal/images/celebration.png";
import { addLeaders } from "../../utils/api";
import { useState } from "react";
import { useLeaderBoardContext } from "../../context/hooks/useLeaderBoard";
import { useNavigate } from "react-router-dom";
import { useSimpleModeContext } from "../../context/hooks/useSimpleMode";

export function EndGameLeaderBoardModal({ gameDurationMinutes, gameDurationSeconds, timeGame, resetGame }) {
const navigate = useNavigate();
const [nameInput, setNameInput] = useState("");
const [errorName, setErrorName] = useState(false);
// Контекст лидерборда
const { setLeaderList } = useLeaderBoardContext();
// Контекст упрощенного режима
const { simpleMode } = useSimpleModeContext();
const addLeaderBoard = reset => {
let achievements = [];
if (!simpleMode) {
achievements.push(1);
}
addLeaders({ name: nameInput, time: timeGame, achievements: achievements })
.then(data => {
setLeaderList(data.leaders.sort((a, b) => a.time - b.time).slice(0, 10));
reset ? resetGame() : navigate("/leaderboard");
})
.catch(error => {
setErrorName(true);
});
};
return (
<div className={styles.modal}>
<img className={styles.image} src={celebrationImageUrl} alt={"celebration emodji"} />
<h2 className={styles.title}>Вы попали на Лидерборд!</h2>
<input
className={styles.inputUser}
type="text"
placeholder="Пользователь"
onChange={e => {
setNameInput(e.target.value);
}}
/>
<p className={styles.description}>Затраченное время:</p>
{/* <div className={styles.time}>
{gameDurationMinutes.toString().padStart("2", "0")}.{gameDurationSeconds.toString().padStart("2", "0")}
</div> */}
<div className={styles.time}>
{gameDurationMinutes.toString().padStart(2, "0")}.{gameDurationSeconds.toString().padStart(2, "0")}
</div>
<Button onClick={() => addLeaderBoard(true)}>Играть снова</Button>
<div className={styles.goLeaderBoard} onClick={() => addLeaderBoard(false)}>
Перейти к лидерборду
</div>
{errorName ? <span className={styles.errorName}>Заполни имя, чтобы продолжить</span> : null}
</div>
);
}
Loading