create GameField and BottomPanel

This commit is contained in:
Sergey Krylov 2022-07-21 11:56:56 +05:00
parent 7caf1ae57a
commit 1421bc549e
13 changed files with 530 additions and 7 deletions

View File

@ -4,10 +4,13 @@
"private": true, "private": true,
"dependencies": { "dependencies": {
"@types/node": "^16.11.45", "@types/node": "^16.11.45",
"@types/react": "^18.0.15", "@types/react": "^17.0.47",
"@types/react-dom": "^18.0.6", "@types/react-dom": "^18.0.6",
"@types/uuid": "^8.3.4",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0" "react-dom": "^18.2.0",
"sass": "^1.53.0",
"uuid": "^8.3.2"
}, },
"scripts": { "scripts": {
"start": "cross-env BROWSER=none PORT=8080 react-scripts start", "start": "cross-env BROWSER=none PORT=8080 react-scripts start",
@ -35,8 +38,7 @@
}, },
"devDependencies": { "devDependencies": {
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"react-scripts": "5.0.1", "react-scripts": "^5.0.1",
"sass": "^1.53.0",
"typescript": "^4.7.4" "typescript": "^4.7.4"
} }
} }

7
src/App.module.scss Normal file
View File

@ -0,0 +1,7 @@
.App {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 20px;
}

View File

@ -1,9 +1,11 @@
import React from 'react'; import React from 'react';
import cls from './App.module.scss';
import Field from './components/Field/Field';
function App() { function App() {
return ( return (
<div className="App"> <div className={cls.App}>
<h1>React start</h1> <Field/>
</div> </div>
); );
} }

View 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>&#10004;</span> : <span>&#10008;</span>}
</div>
</div>
);
};
export default Alert;

View 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;
}
}

View 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;
}
}
}

View 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;

View 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);}
}
}

View 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;

View 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;
}

View 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;

View File

@ -2,4 +2,5 @@ body {
margin: 0; margin: 0;
padding: 0; padding: 0;
box-sizing: border-box; box-sizing: border-box;
background: #7986cb;
} }

View File

@ -18,7 +18,14 @@
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"noEmit": 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": [ "include": [
"src" "src"