From 1421bc549e3b73bee625d88e4f68b8a3057a268a Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Thu, 21 Jul 2022 11:56:56 +0500 Subject: [PATCH] create GameField and BottomPanel --- package.json | 10 +- src/App.module.scss | 7 + src/App.tsx | 6 +- src/components/Alert/Alert.tsx | 22 +++ src/components/Alert/Alerts.module.scss | 42 ++++ .../BottomPanel/BottomPanel.module.scss | 46 +++++ src/components/BottomPanel/BottomPanel.tsx | 64 +++++++ src/components/Card/Card.module.scss | 59 ++++++ src/components/Card/Card.tsx | 39 ++++ src/components/Field/Field.module.scss | 51 +++++ src/components/Field/Field.tsx | 181 ++++++++++++++++++ src/index.scss | 1 + tsconfig.json | 9 +- 13 files changed, 530 insertions(+), 7 deletions(-) create mode 100644 src/App.module.scss create mode 100644 src/components/Alert/Alert.tsx create mode 100644 src/components/Alert/Alerts.module.scss create mode 100644 src/components/BottomPanel/BottomPanel.module.scss create mode 100644 src/components/BottomPanel/BottomPanel.tsx create mode 100644 src/components/Card/Card.module.scss create mode 100644 src/components/Card/Card.tsx create mode 100644 src/components/Field/Field.module.scss create mode 100644 src/components/Field/Field.tsx 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}
+
+ {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"