create GameField and BottomPanel
This commit is contained in:
parent
7caf1ae57a
commit
1421bc549e
10
package.json
10
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"
|
||||
}
|
||||
}
|
||||
|
||||
7
src/App.module.scss
Normal file
7
src/App.module.scss
Normal file
@ -0,0 +1,7 @@
|
||||
.App {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
}
|
||||
@ -1,9 +1,11 @@
|
||||
import React from 'react';
|
||||
import cls from './App.module.scss';
|
||||
import Field from './components/Field/Field';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<div className="App">
|
||||
<h1>React start</h1>
|
||||
<div className={cls.App}>
|
||||
<Field/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
22
src/components/Alert/Alert.tsx
Normal file
22
src/components/Alert/Alert.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import cls from './Alerts.module.scss';
|
||||
|
||||
export type AlertProps = {
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
const Alert: React.FC<AlertProps> = (props) => {
|
||||
const classes = useMemo(() => {
|
||||
return props.success ? [cls.AlertMessage, cls.AlertMessageSuccess] : [cls.AlertMessage, cls.AlertMessageWrong];
|
||||
}, [props.success])
|
||||
|
||||
return (
|
||||
<div className={cls.AlertWrapper}>
|
||||
<div className={classes.join(' ')}>
|
||||
{props.success ? <span>✔</span> : <span>✘</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Alert;
|
||||
42
src/components/Alert/Alerts.module.scss
Normal file
42
src/components/Alert/Alerts.module.scss
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
46
src/components/BottomPanel/BottomPanel.module.scss
Normal file
46
src/components/BottomPanel/BottomPanel.module.scss
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
64
src/components/BottomPanel/BottomPanel.tsx
Normal file
64
src/components/BottomPanel/BottomPanel.tsx
Normal file
@ -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<BottomPanelProps> = (props) => {
|
||||
const [startTime, setStartTime] = useState<number | null>(null);
|
||||
const [timeInterval, setTimeInterval] = useState<NodeJS.Timeout>();
|
||||
|
||||
|
||||
function onStartBtnClickHandler(event: React.MouseEvent<HTMLButtonElement>) {
|
||||
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
|
||||
? <p className={cls.Timer}>{getTimeString(new Date(startTime))}</p>
|
||||
: <button className={cls.StartButton} onClick={onStartBtnClickHandler}>START</button>
|
||||
}
|
||||
|
||||
function onRetryBtnClickHandler() {
|
||||
setStartTime(null);
|
||||
props.retry?.();
|
||||
}
|
||||
|
||||
function renderRetryButton() {
|
||||
return (
|
||||
<>
|
||||
<button className={cls.StartButton} onClick={onRetryBtnClickHandler}>Retry</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cls.BottomPanel}>
|
||||
{ props.isFinishedGame ? renderRetryButton() : renderActiveStatePanel() }
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
||||
59
src/components/Card/Card.module.scss
Normal file
59
src/components/Card/Card.module.scss
Normal file
@ -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);}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
39
src/components/Card/Card.tsx
Normal file
39
src/components/Card/Card.tsx
Normal file
@ -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<CardProps> = (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 (
|
||||
<div className={cls.CardWrapper} style={{
|
||||
opacity: props.isMatched ? 0 : 1
|
||||
}}>
|
||||
<div className={classes.join(' ')} onClick={() => canClick && props.onReverse?.(props)}>
|
||||
<div className={cls.frontside}>{props.title}</div>
|
||||
<div className={cls.backside}>
|
||||
<img src={props.imageURL} alt={props.title}/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Card;
|
||||
51
src/components/Field/Field.module.scss
Normal file
51
src/components/Field/Field.module.scss
Normal file
@ -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;
|
||||
}
|
||||
181
src/components/Field/Field.tsx
Normal file
181
src/components/Field/Field.tsx
Normal file
@ -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<CardProps | null>(null)
|
||||
|
||||
const [alertState, setAlertState] = useState<boolean | null>(null);
|
||||
|
||||
const [cards, setCards] = useState<CardProps[]>(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<boolean>(false);
|
||||
|
||||
const [isGameStarted, setGameStart] = useState<boolean>(false);
|
||||
|
||||
const [mistakesCount, setMistakesCount] = useState<number>(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) => <Card key={el.id} {...el} title={index + 1 + ''} disabled={isGameStarted ? el.disabled : true}></Card>);
|
||||
}
|
||||
|
||||
function renderAlert(state: boolean | null) {
|
||||
if (state === null) return;
|
||||
|
||||
if (state) return <Alert success={true}/>;
|
||||
|
||||
return <Alert success={false}/>
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<div className={getWrapperClassList().join(' ')} style={{marginBottom: 20}}>
|
||||
{
|
||||
isGameFinished
|
||||
? (
|
||||
<div className={cls.FinishBlock}>
|
||||
<h1>GAME IS FINISHED !</h1>
|
||||
<p>Время: <span>10:10</span></p>
|
||||
<p>Количество ошибок: <span>{mistakesCount}</span></p>
|
||||
<p>Сложность: <span>Легко</span></p>
|
||||
<p>Размер поля: <span>4х4</span></p>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
{renderCards()}
|
||||
{renderAlert(alertState)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
{<BottomPanel start={startGame} isFinishedGame={isGameFinished} retry={retryGame}/>}
|
||||
</>
|
||||
|
||||
);
|
||||
};
|
||||
|
||||
function randomizeElementInArray (arr: any[]) {
|
||||
return arr.sort(() => {
|
||||
return Math.random() > 0.5 ? 1 : -1;
|
||||
})
|
||||
}
|
||||
|
||||
export default Field;
|
||||
@ -2,4 +2,5 @@ body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
background: #7986cb;
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user