diff --git a/package.json b/package.json
index 4481461..afa689c 100644
--- a/package.json
+++ b/package.json
@@ -4,10 +4,13 @@
"private": true,
"dependencies": {
"@types/node": "^16.11.45",
- "@types/react": "^18.0.15",
+ "@types/react": "^17.0.47",
"@types/react-dom": "^18.0.6",
+ "@types/uuid": "^8.3.4",
"react": "^18.2.0",
- "react-dom": "^18.2.0"
+ "react-dom": "^18.2.0",
+ "sass": "^1.53.0",
+ "uuid": "^8.3.2"
},
"scripts": {
"start": "cross-env BROWSER=none PORT=8080 react-scripts start",
@@ -35,8 +38,7 @@
},
"devDependencies": {
"cross-env": "^7.0.3",
- "react-scripts": "5.0.1",
- "sass": "^1.53.0",
+ "react-scripts": "^5.0.1",
"typescript": "^4.7.4"
}
}
diff --git a/src/App.module.scss b/src/App.module.scss
new file mode 100644
index 0000000..ef99aff
--- /dev/null
+++ b/src/App.module.scss
@@ -0,0 +1,7 @@
+.App {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ padding: 20px;
+}
diff --git a/src/App.tsx b/src/App.tsx
index 2f7be08..746342c 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,9 +1,11 @@
import React from 'react';
+import cls from './App.module.scss';
+import Field from './components/Field/Field';
function App() {
return (
-
-
React start
+
+
);
}
diff --git a/src/components/Alert/Alert.tsx b/src/components/Alert/Alert.tsx
new file mode 100644
index 0000000..912ec15
--- /dev/null
+++ b/src/components/Alert/Alert.tsx
@@ -0,0 +1,22 @@
+import React, { useMemo } from 'react';
+import cls from './Alerts.module.scss';
+
+export type AlertProps = {
+ success: boolean;
+}
+
+const Alert: React.FC
= (props) => {
+ const classes = useMemo(() => {
+ return props.success ? [cls.AlertMessage, cls.AlertMessageSuccess] : [cls.AlertMessage, cls.AlertMessageWrong];
+ }, [props.success])
+
+ return (
+
+
+ {props.success ? ✔ : ✘}
+
+
+ );
+};
+
+export default Alert;
diff --git a/src/components/Alert/Alerts.module.scss b/src/components/Alert/Alerts.module.scss
new file mode 100644
index 0000000..77512e6
--- /dev/null
+++ b/src/components/Alert/Alerts.module.scss
@@ -0,0 +1,42 @@
+.AlertWrapper {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ //background: rgba(0, 0, 0, 0.3);
+ z-index: 5;
+ //transition: 500ms;
+
+ .AlertMessage {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ color: #fff;
+ height: 100%;
+ animation-duration: 1.5s;
+ animation-name: slidein;
+ opacity: 0;
+ user-select: none;
+
+ &.AlertMessageSuccess {
+ color: green;
+ }
+
+ &.AlertMessageWrong {
+ color: red;
+ }
+ }
+}
+
+@keyframes slidein {
+ from {
+ font-size: 40px;
+ opacity: 1;
+ }
+
+ to {
+ font-size: 300px;
+ opacity: 0;
+ }
+}
diff --git a/src/components/BottomPanel/BottomPanel.module.scss b/src/components/BottomPanel/BottomPanel.module.scss
new file mode 100644
index 0000000..ca57b39
--- /dev/null
+++ b/src/components/BottomPanel/BottomPanel.module.scss
@@ -0,0 +1,46 @@
+.BottomPanel {
+ width: 500px;
+ height: 130px;
+ background: #283593;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ border: 2px solid #fff;
+ border-radius: 10px;
+
+ .Timer {
+ font-size: 40px;
+ font-weight: bold;
+ font-family: "Pacifico", sans-serif;
+ color: #ffffff;
+ }
+
+ .StartButton {
+ text-decoration: none;
+ display: inline-block;
+ width: 140px;
+ height: 45px;
+ border-radius: 45px;
+ margin: 10px 20px;
+ font-family: "Pacifico", sans-serif;
+ font-size: 22px;
+ text-transform: uppercase;
+ text-align: center;
+ letter-spacing: 3px;
+ font-weight: 600;
+ color: #524f4e;
+ background: white;
+ box-shadow: 0 8px 15px rgba(0, 0, 0, .1);
+ //background: #7986cb;
+ transition: .3s;
+
+ &:hover {
+ background: #7986cb;
+ box-shadow: 0 15px 20px rgba(121, 134, 203, 0.4);
+ color: white;
+ transform: translateY(-7px);
+ cursor: pointer;
+ }
+ }
+}
+
diff --git a/src/components/BottomPanel/BottomPanel.tsx b/src/components/BottomPanel/BottomPanel.tsx
new file mode 100644
index 0000000..1042b1b
--- /dev/null
+++ b/src/components/BottomPanel/BottomPanel.tsx
@@ -0,0 +1,64 @@
+import React, { useEffect, useState } from 'react';
+import cls from './BottomPanel.module.scss';
+
+type BottomPanelProps = {
+ start: () => void;
+ retry?: () => void;
+ isFinishedGame?: boolean;
+}
+
+const BottomPanel: React.FC = (props) => {
+ const [startTime, setStartTime] = useState(null);
+ const [timeInterval, setTimeInterval] = useState();
+
+
+ function onStartBtnClickHandler(event: React.MouseEvent) {
+ setStartTime(0);
+ const interval = setInterval(() => {
+ setStartTime(prev => prev !== null ? prev + 1000 : 0)
+ }, 1000);
+ setTimeInterval(interval);
+ props.start();
+ }
+
+ useEffect(() => {
+ clearInterval(timeInterval)
+ }, [props.isFinishedGame])
+
+ function renderActiveStatePanel() {
+ return startTime !== null
+ ? {getTimeString(new Date(startTime))}
+ :
+ }
+
+ function onRetryBtnClickHandler() {
+ setStartTime(null);
+ props.retry?.();
+ }
+
+ function renderRetryButton() {
+ return (
+ <>
+
+ >
+ )
+ }
+
+ return (
+
+ { props.isFinishedGame ? renderRetryButton() : renderActiveStatePanel() }
+
+ );
+};
+
+function getTimeString(time: Date): string {
+ let minutes = time.getMinutes().toString();
+ if (+minutes < 10) minutes = '0' + minutes;
+
+ let seconds = time.getSeconds().toString();
+ if (+seconds < 10) seconds = '0' + seconds;
+
+ return `${minutes}:${seconds}`
+}
+
+export default BottomPanel;
diff --git a/src/components/Card/Card.module.scss b/src/components/Card/Card.module.scss
new file mode 100644
index 0000000..0590320
--- /dev/null
+++ b/src/components/Card/Card.module.scss
@@ -0,0 +1,59 @@
+@import url('https://fonts.googleapis.com/css2?family=Pacifico&display=swap');
+
+.CardWrapper{
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: opacity 1300ms ease-in;
+
+ .Card {
+ width: 100px;
+ height: 100px;
+ font-family: 'Pacifico', sans-serif;
+ font-size: 48px;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ position: relative;
+ perspective: 1000px;
+ cursor: pointer;
+
+ .frontside, .backside {
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ left: 0;
+ top: 0;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ transition: 1s;
+ backface-visibility: hidden;
+ border-radius: 10px;
+ border: 2px solid #fff;
+ }
+ .frontside {
+ background: #7986cb;
+
+ &:hover {
+ transform: translateY(-10px);
+ }
+ }
+ .backside {
+ //background: #aee571;
+ transform: rotateY(180deg);
+ font-size: 40px;
+
+ img {
+ max-width: 100%;
+ max-height: 100%;
+ }
+ }
+
+ &.reverse .frontside {transform: rotateY(180deg);}
+ &.reverse .backside {transform: rotateY(360deg);}
+ }
+
+}
+
+
diff --git a/src/components/Card/Card.tsx b/src/components/Card/Card.tsx
new file mode 100644
index 0000000..dc7cf2a
--- /dev/null
+++ b/src/components/Card/Card.tsx
@@ -0,0 +1,39 @@
+import React, {useMemo} from 'react';
+import cls from './Card.module.scss'
+
+export type CardProps = {
+ title: string;
+ code: string;
+ onReverse?: (a: any) => void;
+ imageURL: string;
+ id: string;
+ toggled: boolean;
+ disabled: boolean;
+ isMatched: boolean;
+}
+
+const Card: React.FC = (props) => {
+
+ const canClick = useMemo(() => {
+ return !props.disabled && !props.isMatched
+ }, [props.disabled, props.isMatched]);
+
+ const classes = useMemo(() => {
+ return props.toggled ? [cls.Card, cls.reverse] : [cls.Card];
+ }, [props.toggled])
+
+ return (
+
+
canClick && props.onReverse?.(props)}>
+
{props.title}
+
+

+
+
+
+ );
+};
+
+export default Card;
diff --git a/src/components/Field/Field.module.scss b/src/components/Field/Field.module.scss
new file mode 100644
index 0000000..d076ec6
--- /dev/null
+++ b/src/components/Field/Field.module.scss
@@ -0,0 +1,51 @@
+.Field {
+ background: #283593;
+ position: relative;
+ border: 2px solid #fff;
+ border-radius: 10px;
+ width: 500px;
+ height: 500px;
+ display: grid;
+ grid-template-columns: 1fr 1fr 1fr;
+ grid-template-rows: 1fr 1fr 1fr 1fr;
+ grid-auto-flow: row;
+ grid-template-areas:
+ ". . ."
+ ". . ."
+ ". . ."
+ ". . .";
+}
+
+.FieldFinish {
+ width: 500px;
+ height: 500px;
+ background: #283593;
+ position: relative;
+ border: 2px solid #fff;
+ box-sizing: border-box;
+ padding: 10px;
+ border-radius: 10px;
+
+ h1 {
+ text-align: center;
+ font-size: 40px;
+ }
+ p {
+ display: flex;
+ justify-content: space-between;
+ font-size: 24px;
+
+
+ span {
+ font-style: italic;
+ font-weight: bold;
+ }
+ }
+}
+
+.FinishBlock {
+ color: #ffffff;
+ max-width: 320px;
+ margin: 0 auto;
+ font-family: 'Pacifico', sans-serif;
+}
diff --git a/src/components/Field/Field.tsx b/src/components/Field/Field.tsx
new file mode 100644
index 0000000..83592fa
--- /dev/null
+++ b/src/components/Field/Field.tsx
@@ -0,0 +1,181 @@
+import BottomPanel from '../BottomPanel/BottomPanel';
+import Alert from '../Alert/Alert';
+import React, { useEffect, useState } from 'react';
+import Card, { CardProps } from '../Card/Card';
+import cls from './Field.module.scss';
+import { v4 as uuidv4 } from 'uuid';
+
+const Field: React.FC = () => {
+
+ const img = {
+ react: 'https://w7.pngwing.com/pngs/79/518/png-transparent-js-react-js-logo-react-react-native-logos-icon-thumbnail.png',
+ js: 'https://www.freepnglogos.com/uploads/javascript-png/javascript-logo-transparent-logo-javascript-images-3.png',
+ angular: 'https://upload.wikimedia.org/wikipedia/commons/thumb/c/cf/Angular_full_color_logo.svg/1200px-Angular_full_color_logo.svg.png',
+ }
+
+ function matchHandler(prev: CardProps, card: CardProps) {
+ setAlertState(true);
+ setCards(prevState => prevState.map(el => {
+ if (el.id === card.id || el.id === prev.id) return {...el, disabled: true, isMatched: true}
+ else return {...el, disabled: true}
+ }))
+ setTimeout(enableCards, 1000)
+ }
+
+ function notMatchHandler() {
+ setAlertState(false);
+ setMistakesCount(prev => prev + 1);
+ disableCards();
+ setTimeout(() => {
+ setAlertState(null);
+ setCards(prevState => prevState.map(el => {
+ return {...el, toggled: false, disabled: false, isMatched: false};
+ }))
+ }, 1000)
+ }
+
+ function reverseCardHandler(card: CardProps) {
+ setCards(prevState => prevState.map(el => el.id === card.id ? {...el, toggled: !el.toggled, disabled: true} : el))
+
+ setCurrentCard(prev => {
+ if (!prev) return card;
+ else {
+ if (prev.code === card.code) matchHandler(prev, card);
+ else notMatchHandler();
+
+ return null;
+ }
+ });
+ }
+
+ function disableCards() {
+ setCards(prevState => prevState.map(el => ({...el, disabled: true})));
+ }
+
+ function enableCards() {
+ setAlertState(null);
+ setCards(prevState => prevState.map(el => ({...el, disabled: false})));
+ }
+
+ function showCards() {
+ setCards(prevState => prevState.map(el => ({...el, toggled: true})));
+ }
+
+ function hideCards() {
+ setCards(prevState => prevState.map(el => ({...el, toggled: false})));
+ }
+
+ function hideCard(id: string) {
+ setCards(prevState => prevState.map((el) => (
+ el.id === id ? {...el, toggled: false} : el
+ )))
+ }
+
+ function createCard (title: string, code: string, imageURL: string): CardProps {
+ return {id: uuidv4(), title, code, imageURL, onReverse: reverseCardHandler, toggled: true, disabled: false, isMatched: false};
+ }
+
+ const [currentCard, setCurrentCard] = useState(null)
+
+ const [alertState, setAlertState] = useState(null);
+
+ const [cards, setCards] = useState(randomizeElementInArray([
+ createCard('1', 'react', img.react),
+ createCard('2', 'angular', img.angular),
+ createCard('3', 'angular', img.angular),
+ createCard('4', 'react', img.react),
+ createCard('5', 'vue', img.js),
+ createCard('6', 'angular', img.angular),
+ createCard('7', 'react', img.react),
+ createCard('8', 'vue', img.js),
+ createCard('9', 'angular', img.angular),
+ createCard('10', 'react', img.react),
+ createCard('11', 'vue', img.js),
+ createCard('12', 'vue', img.js),
+ ]));
+
+ const [isGameFinished, setGameFinish] = useState(false);
+
+ const [isGameStarted, setGameStart] = useState(false);
+
+ const [mistakesCount, setMistakesCount] = useState(0);
+
+
+ useEffect(() => {
+ const isFinished = cards.reduce((res, cur) => res = res && cur.isMatched, true);
+ isFinished && setTimeout(() => setGameFinish(true), 1500);
+ }, [cards]);
+
+ useEffect(() => {
+ showCards();
+ }, [])
+
+ function renderCards() {
+ return cards.map((el, index) => );
+ }
+
+ function renderAlert(state: boolean | null) {
+ if (state === null) return;
+
+ if (state) return ;
+
+ return
+ }
+
+ function startGame() {
+ setGameStart(true);
+ cards.forEach((card, index) => {
+ setTimeout(() => hideCard(card.id), index*100)
+ })
+ }
+
+ function retryGame() {
+ setGameFinish(false);
+ setGameStart(false);
+ setCards(prev => randomizeElementInArray(prev.map(el => ({...el, disabled: false, isMatched: false, toggled: true}))))
+ }
+
+ function getWrapperClassList() {
+ let classes = [cls.Field];
+
+ if (isGameFinished) classes = [cls.FieldFinish];
+
+ return classes;
+ }
+
+
+ return (
+ <>
+
+ {
+ isGameFinished
+ ? (
+
+
GAME IS FINISHED !
+
Время: 10:10
+
Количество ошибок: {mistakesCount}
+
Сложность: Легко
+
Размер поля: 4х4
+
+ )
+ : (
+ <>
+ {renderCards()}
+ {renderAlert(alertState)}
+ >
+ )
+ }
+
+ {}
+ >
+
+ );
+};
+
+function randomizeElementInArray (arr: any[]) {
+ return arr.sort(() => {
+ return Math.random() > 0.5 ? 1 : -1;
+ })
+}
+
+export default Field;
diff --git a/src/index.scss b/src/index.scss
index 7fe479c..9b4fb53 100644
--- a/src/index.scss
+++ b/src/index.scss
@@ -2,4 +2,5 @@ body {
margin: 0;
padding: 0;
box-sizing: border-box;
+ background: #7986cb;
}
diff --git a/tsconfig.json b/tsconfig.json
index a273b0c..4abc702 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -18,7 +18,14 @@
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
- "jsx": "react-jsx"
+ "jsx": "react-jsx",
+ "paths": {
+ "components/*": ["src/components/*"],
+ "core/*": ["src/core/*"],
+ "pages/*": ["src/pages/*"],
+ "redux/*": ["src/redux/*"],
+ "src/*": ["src/*"],
+ }
},
"include": [
"src"