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
1 change: 1 addition & 0 deletions .prettierrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ module.exports = {
bracketSpacing: true,
arrowParens: "avoid",
htmlWhitespaceSensitivity: "ignore",
endOfLine: 'auto'
};
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,10 @@ https://skypro-web-developer.github.io/react-memo/

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

### Оценочное время выполнения

20 часов

### Фактическое время выполнения
13ч
Binary file added public/assets/fonts/Poppins-Regular.woff2
Binary file not shown.
Binary file added public/assets/fonts/Roboto-Regular.woff2
Binary file not shown.
27 changes: 27 additions & 0 deletions src/api.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
const URL = "https://wedev-api.sky.pro/api/leaderboard";

export async function getLeaders() {
const response = await fetch(URL);

if (response.status !== 200) {
throw new Error("Ошибка");
}
const data = await response.json();
return data.leaders;
}

export async function postLeader(data) {
try {
const response = await fetch("https://wedev-api.sky.pro/api/leaderboard", {
method: "POST",
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(`Ошибка: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error("Ошибка при отправке данных:", error);
throw error;
}
}
67 changes: 58 additions & 9 deletions src/components/Cards/Cards.jsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
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 { LightContext } from "../../context/easyMode";
// import { useNavigate } from "react-router-dom";

// Игра закончилась
const STATUS_LOST = "STATUS_LOST";
Expand Down Expand Up @@ -41,8 +43,12 @@ function getTimerValue(startDate, endDate) {
* previewSeconds - сколько секунд пользователь будет видеть все карты открытыми до начала игры
*/
export function Cards({ pairsCount = 3, previewSeconds = 5 }) {
const { isLight, tries, setTries } = useContext(LightContext);
// В cards лежит игровое поле - массив карт и их состояние открыта\закрыта
const [cards, setCards] = useState([]);

const [playerLost, setPlayerLost] = useState(false);

// Текущий статус игры
const [status, setStatus] = useState(STATUS_PREVIEW);

Expand All @@ -68,7 +74,12 @@ export function Cards({ pairsCount = 3, previewSeconds = 5 }) {
setTimer(getTimerValue(startDate, null));
setStatus(STATUS_IN_PROGRESS);
}
// const navigate = useNavigate();

function resetGame() {
// navigate("/");
setTries(isLight ? 3 : 1);
setPlayerLost(false);
setGameStartDate(null);
setGameEndDate(null);
setTimer(getTimerValue(null, null));
Expand All @@ -77,16 +88,26 @@ export function Cards({ pairsCount = 3, previewSeconds = 5 }) {

/**
* Обработка основного действия в игре - открытие карты.
* После открытия карты игра может пепереходит в следующие состояния
* После открытия карты игра может переходить в следующие состояния
* - "Игрок выиграл", если на поле открыты все карты
* - "Игрок проиграл", если на поле есть две открытые карты без пары
* - "Игра продолжается", если не случилось первых двух условий
*/

useEffect(() => {
if (tries === 0) setPlayerLost(true);
}, [tries, playerLost]);

useEffect(() => {
if (playerLost) finishGame(STATUS_LOST);
}, [playerLost]);

const openCard = clickedCard => {
// Если карта уже открыта, то ничего не делаем
if (clickedCard.open) {
return;
}

// Игровое поле после открытия кликнутой карты
const nextCards = cards.map(card => {
if (card.id !== clickedCard.id) {
Expand Down Expand Up @@ -123,13 +144,37 @@ export function Cards({ pairsCount = 3, previewSeconds = 5 }) {
return false;
});

const playerLost = openCardsWithoutPair.length >= 2;
function tryLost() {
if (openCardsWithoutPair.length === 2) {
setTries(tries - 1);
setTimeout(() => {
setCards(
cards.reduce((acc, card) => {
if (card.id === clickedCard.id) {
return [...acc, { ...card, open: false }];
}
return [...acc, card];
}, []),
);
setCards(
cards.reduce((acc, card) => {
const previousCard = openCardsWithoutPair.find(item => item.id !== clickedCard.id);
if (card.id === previousCard.id) {
return [...acc, { ...card, open: false }];
}
return [...acc, card];
}, []),
);
}, 1000);
}
}
tryLost();

// "Игрок проиграл", т.к на поле есть две открытые карты без пары
if (playerLost) {
finishGame(STATUS_LOST);
return;
}
// if (lost) {
// finishGame(STATUS_LOST);
// return;
// }

// ... игра продолжается
};
Expand Down Expand Up @@ -195,9 +240,13 @@ export function Cards({ pairsCount = 3, previewSeconds = 5 }) {
</>
)}
</div>
{status === STATUS_IN_PROGRESS ? <Button onClick={resetGame}>Начать заново</Button> : null}
{status === STATUS_IN_PROGRESS ? (
<div className="tries">
<Button onClick={resetGame}>Начать заново</Button>
{isLight && <p className={styles.tries}>Осталось попыток: {tries}</p>}
</div>
) : null}
</div>

<div className={styles.cards}>
{cards.map(card => (
<Card
Expand Down
10 changes: 7 additions & 3 deletions src/components/Cards/Cards.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,13 @@
.header {
display: flex;
justify-content: space-between;
align-items: end;
align-items: flex-end;
margin-bottom: 35px;
}

.timer {
display: flex;
align-items: end;

align-items: flex-end;
color: #fff;
font-family: StratosSkyeng;
font-size: 64px;
Expand Down Expand Up @@ -70,3 +69,8 @@

margin-bottom: -12px;
}

.tries {
color: #fff;
margin-left: 50px;
}
102 changes: 88 additions & 14 deletions src/components/EndGameModal/EndGameModal.jsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,101 @@
import styles from "./EndGameModal.module.css";

import { Button } from "../Button/Button";

import deadImageUrl from "./images/dead.png";
import celebrationImageUrl from "./images/celebration.png";
import { postLeader, getLeaders } from "../../api";
import { useContext, useState, useEffect } from "react";
import { LightContext } from "../../context/easyMode";
import { useNavigate } from "react-router-dom";
import { usePairsCount } from "../../context/PairsCountContext";

export function EndGameModal({ isWon, gameDurationSeconds, onClick }) {
const { isLight } = useContext(LightContext);
const { pairsCount } = usePairsCount();
const [shouldAddToLeaderboard, setShouldAddToLeaderboard] = useState(false);
const [addPlayer, setAddPlayer] = useState({
name: "",
time: gameDurationSeconds,
});
const navigate = useNavigate();

export function EndGameModal({ isWon, gameDurationSeconds, gameDurationMinutes, onClick }) {
const title = isWon ? "Вы победили!" : "Вы проиграли!";
useEffect(() => {
if (isWon && !isLight && pairsCount >= 9) {
const checkLeaderboard = async () => {
try {
const leaders = await getLeaders();
if (Array.isArray(leaders) && leaders.length >= 10) {
const sortedLeaders = leaders.sort((a, b) => a.time - b.time);
const slowestTimeInTopTen = sortedLeaders[9].time;
if (addPlayer.time < slowestTimeInTopTen) {
setShouldAddToLeaderboard(true);
}
} else {
setShouldAddToLeaderboard(true);
}
} catch (error) {
console.error("Ошибка при проверке лидерборда:", error);
}
};
checkLeaderboard();
}
}, [isWon, isLight, pairsCount, addPlayer.time]);

const title = isWon
? shouldAddToLeaderboard
? "Поздравляю, вы попали в Лидерборд!"
: "Вы выиграли!"
: "Вы проиграли!";

const imgSrc = isWon ? celebrationImageUrl : deadImageUrl;
const imgAlt = isWon ? "celebration emoji" : "dead emoji";

const handleLeaderboardRedirect = async e => {
e.preventDefault();
if (isWon && shouldAddToLeaderboard) {
try {
await postLeader(addPlayer);
} catch (error) {
console.error("Ошибка при добавлении игрока:", error);
}
}
navigate("/leaderboard");
};

const imgAlt = isWon ? "celebration emodji" : "dead emodji";
const handleKeyDown = e => {
if (e.key === "Enter") {
e.preventDefault();
handleLeaderboardRedirect(e);
}
};

return (
<div className={styles.modal}>
<img className={styles.image} src={imgSrc} alt={imgAlt} />
<h2 className={styles.title}>{title}</h2>
<p className={styles.description}>Затраченное время:</p>
<div className={styles.time}>
{gameDurationMinutes.toString().padStart("2", "0")}.{gameDurationSeconds.toString().padStart("2", "0")}
<form>
<div className={styles.modal}>
<img className={styles.image} src={imgSrc} alt={imgAlt} />
<h2 className={styles.title}>{title}</h2>
{shouldAddToLeaderboard && (
<input
onChange={e => setAddPlayer({ ...addPlayer, name: e.target.value })}
onKeyDown={handleKeyDown}
className={styles.input}
placeholder="Введите имя"
type="text"
value={addPlayer.name}
/>
)}
<p className={styles.description}>Затраченное время:</p>
<div className={styles.time}>
{`${Math.floor(gameDurationSeconds / 60)
.toString()
.padStart(2, "0")}:${(gameDurationSeconds % 60).toString().padStart(2, "0")}`}
</div>
<Button type="button" onClick={onClick}>
Начать сначала
</Button>
<button className={styles.btnLeaderBoard} type="button" onClick={handleLeaderboardRedirect}>
Перейти к лидерборду
</button>
</div>

<Button onClick={onClick}>Начать сначала</Button>
</div>
</form>
);
}
29 changes: 28 additions & 1 deletion src/components/EndGameModal/EndGameModal.module.css
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
.modal {
width: 480px;
height: 459px;
height: 634px;
border-radius: 12px;
background: #c2f5ff;
display: flex;
Expand All @@ -16,6 +16,7 @@
}

.title {
text-align: center;
color: #004980;
font-variant-numeric: lining-nums proportional-nums;
font-family: StratosSkyeng;
Expand Down Expand Up @@ -49,3 +50,29 @@

margin-bottom: 40px;
}
.input {
border-radius: 10px;
border: none;
margin-bottom: 30px;
width: 276px;
height: 45px;
text-align: center;
}
input::placeholder{
color: #999999;
font-weight: 400;
font-size: 24px;
}

.btnLeaderBoard{
padding-top: 20px;
background: none;
border: none;
text-decoration: underline;
cursor: pointer;
font-size: 18px;
}
.btnLeaderBoard:hover{
transform: scale(1.05);
color: rgb(113, 113, 255);
}
12 changes: 12 additions & 0 deletions src/context/PairsCountContext.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { createContext, useContext, useState } from "react";

const PairsCountContext = createContext();

export function usePairsCount() {
return useContext(PairsCountContext);
}

export function PairsCountProvider({ children, initialPairsCount }) {
const [pairsCount, setPairsCount] = useState(initialPairsCount);
return <PairsCountContext.Provider value={{ pairsCount, setPairsCount }}>{children}</PairsCountContext.Provider>;
}
14 changes: 14 additions & 0 deletions src/context/easyMode.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { createContext, useEffect, useState } from "react";

export const LightContext = createContext();

export const LightProvider = ({ children }) => {
const [isLight, setIsLight] = useState(true);
const [tries, setTries] = useState(3);

useEffect(() => {
isLight ? setTries(3) : setTries(1);
}, [isLight]);

return <LightContext.Provider value={{ isLight, setIsLight, tries, setTries }}>{children}</LightContext.Provider>;
};
Loading