-
Notifications
You must be signed in to change notification settings - Fork 0
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[3주차 기본/심화/생각 과제] 춘식이 카드 뒤집기 게임 #7
base: main
Are you sure you want to change the base?
Changes from all commits
df1a2af
9da325b
be6e941
e17541b
e74b7fb
590c8bf
1e98aa8
32609ce
5f3afd3
e86e4b2
7bae2c1
63dfb21
bafa3d4
d7e5eb8
2bd128f
5319f66
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,24 @@ | ||
# Logs | ||
logs | ||
*.log | ||
npm-debug.log* | ||
yarn-debug.log* | ||
yarn-error.log* | ||
pnpm-debug.log* | ||
lerna-debug.log* | ||
|
||
node_modules | ||
dist | ||
dist-ssr | ||
*.local | ||
|
||
# Editor directories and files | ||
.vscode/* | ||
!.vscode/extensions.json | ||
.idea | ||
.DS_Store | ||
*.suo | ||
*.ntvs* | ||
*.njsproj | ||
*.sln | ||
*.sw? |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,14 @@ | ||
<!DOCTYPE html> | ||
<html lang="ko"> | ||
<head> | ||
<meta charset="UTF-8" /> | ||
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> | ||
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> | ||
<title>카드 맞추기 게임</title> | ||
</head> | ||
<body> | ||
<div id="root"></div> | ||
<div id="modal-root"></div> | ||
<script type="module" src="/src/main.jsx"></script> | ||
</body> | ||
</html> |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,27 @@ | ||
{ | ||
"name": "cardmatchinggame", | ||
"private": true, | ||
"version": "0.0.0", | ||
"type": "module", | ||
"scripts": { | ||
"dev": "vite", | ||
"build": "vite build", | ||
"lint": "eslint src --ext js,jsx --report-unused-disable-directives --max-warnings 0", | ||
"preview": "vite preview" | ||
}, | ||
"dependencies": { | ||
"react": "^18.2.0", | ||
"react-dom": "^18.2.0", | ||
"styled-components": "^5.3.10" | ||
}, | ||
"devDependencies": { | ||
"@types/react": "^18.0.28", | ||
"@types/react-dom": "^18.0.11", | ||
"@vitejs/plugin-react": "^4.0.0", | ||
"eslint": "^8.38.0", | ||
"eslint-plugin-react": "^7.32.2", | ||
"eslint-plugin-react-hooks": "^4.6.0", | ||
"eslint-plugin-react-refresh": "^0.3.4", | ||
"vite": "^4.3.2" | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,61 @@ | ||
import React, { useEffect } from "react"; | ||
import styled, { ThemeProvider } from "styled-components"; | ||
import GlobalStyle from "./styles/reset"; | ||
import Cards from "./components/Cards"; | ||
import theme from "./styles/theme"; | ||
import Header from "./components/Header"; | ||
import { useState } from "react"; | ||
import Modal from "./components/Modal"; | ||
|
||
export default function App() { | ||
const [score, setScore] = useState(0); | ||
const [level, setLevel] = useState("EASY"); | ||
const [resetClicked, setResetClicked] = useState(false); | ||
const [showModal, setShowModal] = useState(false); | ||
|
||
const levels = { | ||
EASY: 5, | ||
NORMAL: 7, | ||
HARD: 9, | ||
}; | ||
const changeLevel = (level) => { | ||
setLevel(level); | ||
}; | ||
const changeScore = (score) => { | ||
setScore(score); | ||
}; | ||
const changeResetClicked = () => { | ||
setResetClicked(!resetClicked); | ||
}; | ||
|
||
const handleClose = () => { | ||
setShowModal(false); | ||
}; | ||
|
||
useEffect(() => { | ||
if(score === levels[level]){ | ||
setShowModal(true) | ||
} | ||
}, [score]); | ||
|
||
return ( | ||
<ThemeProvider theme={theme}> | ||
<GlobalStyle /> | ||
<Header | ||
changeLevel={changeLevel} | ||
levels={levels} | ||
level={level} | ||
score={score} | ||
changeResetClicked={changeResetClicked} | ||
/> | ||
<Cards | ||
levels={levels} | ||
level={level} | ||
score={score} | ||
changeScore={changeScore} | ||
resetClicked={resetClicked} | ||
/> | ||
<Modal isOpen={showModal} onClose={handleClose} /> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 전체적으로 하위 -> 상위로 전달되는 핸들러는 최상단 컴포넌트에서 지정해주고, pr point를 읽으면서도 구현계획이 명확해서 신기했는데, 적재적소에 잘 불러서 깔끔하게 짠 것 같아서 넘 잘 읽었어 🥹🔥 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 최상단에서 필요한 state와 변수, 함수들을 딱 정리해서 필요한 컴포넌트에 내려줬다는게 한눈에 보인다! 너무 깔끔해..!!!! |
||
</ThemeProvider> | ||
); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
import React from "react"; | ||
import ReactDOM from "react-dom"; | ||
|
||
const ModalPortal = ({ children }) => { | ||
const modalRoot = document.getElementById("modal-root"); | ||
const el = document.createElement("div"); | ||
|
||
React.useEffect(() => { | ||
modalRoot.appendChild(el); | ||
return () => { | ||
modalRoot.removeChild(el); | ||
}; | ||
}, [modalRoot, el]); | ||
|
||
return ReactDOM.createPortal(children, el); | ||
}; | ||
|
||
export default ModalPortal; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,74 @@ | ||
export const data = [ | ||
{ | ||
id: 0, | ||
cardImg: "https://tenor.com/ko/view/춘식-춘식이-chunsik-gif-24452854.gif", | ||
}, | ||
{ | ||
id: 1, | ||
cardImg: "https://tenor.com/ko/view/춘식-춘식이-chunsik-gif-24452854.gif", | ||
}, | ||
{ | ||
id: 2, | ||
cardImg: "https://tenor.com/ko/view/chunsik-춘식-춘식이-gif-24725088.gif", | ||
}, | ||
{ | ||
id: 3, | ||
cardImg: "https://tenor.com/ko/view/chunsik-춘식-춘식이-gif-24725088.gif", | ||
}, | ||
{ | ||
id: 4, | ||
cardImg: "https://tenor.com/ko/view/춘식-춘식이-chunsik-gif-24725094.gif", | ||
}, | ||
{ | ||
id: 5, | ||
cardImg: "https://tenor.com/ko/view/춘식-춘식이-chunsik-gif-24725094.gif", | ||
}, | ||
{ | ||
id: 6, | ||
cardImg: "https://tenor.com/ko/view/chunsik-춘식-춘식이-gif-24452956.gif", | ||
}, | ||
{ | ||
id: 7, | ||
cardImg: "https://tenor.com/ko/view/chunsik-춘식-춘식이-gif-24452956.gif", | ||
}, | ||
{ | ||
id: 8, | ||
cardImg: "https://tenor.com/ko/view/춘식-춘식이-chunsik-gif-24452902.gif", | ||
}, | ||
{ | ||
id: 9, | ||
cardImg: "https://tenor.com/ko/view/춘식-춘식이-chunsik-gif-24452902.gif", | ||
}, | ||
{ | ||
id: 10, | ||
cardImg: "https://tenor.com/ko/view/춘식이-춘식-chunsik-gif-24691909.gif", | ||
}, | ||
{ | ||
id: 11, | ||
cardImg: "https://tenor.com/ko/view/춘식이-춘식-chunsik-gif-24691909.gif", | ||
}, | ||
{ | ||
id: 12, | ||
cardImg: "https://tenor.com/ko/view/춘식이-춘식-chunsik-gif-24452916.gif", | ||
}, | ||
{ | ||
id: 13, | ||
cardImg: "https://tenor.com/ko/view/춘식이-춘식-chunsik-gif-24452916.gif", | ||
}, | ||
{ | ||
id: 14, | ||
cardImg: "https://tenor.com/ko/view/춘식-춘식이-chunsik-gif-24452897.gif", | ||
}, | ||
{ | ||
id: 15, | ||
cardImg: "https://tenor.com/ko/view/춘식-춘식이-chunsik-gif-24452897.gif", | ||
}, | ||
{ | ||
id: 16, | ||
cardImg: "https://tenor.com/ko/view/춘식-춘식이-chunsik-gif-24691897.gif", | ||
}, | ||
{ | ||
id: 17, | ||
cardImg: "https://tenor.com/ko/view/춘식-춘식이-chunsik-gif-24691897.gif", | ||
}, | ||
] |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,125 @@ | ||
import React, { useEffect, useState, useMemo } from "react"; | ||
import { data } from "../assets/data"; | ||
import styled from "styled-components"; | ||
import theme from "../styles/theme"; | ||
import backCardImg from "../assets/backCardImg.jpg"; | ||
|
||
export default function Cards(props) { | ||
const { levels, level, score, changeScore, resetClicked } = props; | ||
const [cards, setCards] = useState([]); | ||
const [clickedCards, setClickedCards] = useState([]); | ||
const [matchedCards, setMatchedCards] = useState([]); | ||
|
||
//쌍을 유지하면서 순서 섞기 | ||
function shufflePairs(arr) { | ||
const pairs = []; | ||
for (let i = 0; i < arr.length; i += 2) { | ||
pairs.push([arr[i], arr[i + 1]]); | ||
} | ||
for (let i = pairs.length - 1; i > 0; i--) { | ||
const j = Math.floor(Math.random() * (i + 1)); | ||
[pairs[i], pairs[j]] = [pairs[j], pairs[i]]; | ||
} | ||
const shuffled = pairs.flatMap((pair) => pair); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
return shuffled; | ||
} | ||
|
||
//카드 클릭 시, clickedCards 배열에 카드 id 추가 | ||
const handleCardClick = (cardId) => { | ||
//clickedCards 최대 길이 2로 제한 | ||
if (clickedCards.length < 2) { | ||
setClickedCards((prev) => [...prev, cardId]); | ||
} | ||
}; | ||
|
||
//레벨에 따라 카드 랜덤하게 선택하고 랜덤하게 배열 | ||
useEffect(() => { | ||
setCards( | ||
shufflePairs(data) | ||
.slice(0, levels[level] * 2) | ||
.sort(() => Math.random() - 0.5) | ||
); | ||
}, [level]); | ||
|
||
//클릭된 카드가 서로 같은 카드인지 체크하고, 같은 카드이면 matchedCards 배열에 저장 | ||
useEffect(() => { | ||
//클릭된 카드가 2개 미만이거나 같은 카드를 연속 클릭 시 조기 리턴 | ||
if (clickedCards.length < 2) { | ||
return; | ||
} | ||
Comment on lines
+46
to
+49
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 조기리턴으로 2개 미만의 카드 선택시의 상황을 아주 깔끔하게 만들어주었구나.. 똒똒해 ... ### 👍 |
||
|
||
const [card1, card2] = clickedCards; | ||
if (card1 === card2) { | ||
clickedCards.splice(1, 1); | ||
return; | ||
} | ||
Comment on lines
+52
to
+55
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 연속클릭되는 상황은 생각을 못했는데 꼼꼼한 예외처리 진짜 멋지다!! 그냥 냅두고 넘어갈수도 있었을텐데!!! 👍 나도 리팩토링해보께 |
||
|
||
if (data[card1].cardImg === data[card2].cardImg) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 모든 card 각각을 구분하는 것과 card img의 일치 여부를 분리하는게 되게 복잡했는데, 이런식으로 한건 또 처음본다!! 나는 카드가 섞이면서 계속 번호가 바뀌니까 이렇게 할 생각은 못했는데.... 굉장히 합리적인 방법인 것 같아 오래 고민한게 느껴지는 깔끔한 로직...👍 |
||
if (!matchedCards.includes(card1) && card1 !== card2) { | ||
changeScore(score + 1); | ||
} | ||
setMatchedCards((prev) => [...prev, card1, card2]); | ||
setTimeout(() => setClickedCards([]), 1000); | ||
} else { | ||
setTimeout(() => setClickedCards([]), 1000); | ||
} | ||
}, [clickedCards]); | ||
|
||
//게임 도중에 레벨을 바꾸면 진행 상황 초기화 | ||
useEffect(() => { | ||
changeScore(0); | ||
setClickedCards([]); | ||
setMatchedCards([]); | ||
}, [level, resetClicked]); | ||
|
||
return ( | ||
<CardsContainer> | ||
{cards.map((item) => ( | ||
<Card | ||
key={item.id} | ||
id={item.id} | ||
cardImg={item.cardImg} | ||
onClick={() => handleCardClick(item.id)} | ||
matchedCards={matchedCards.includes(item.id) ? true : false} | ||
clickedCards={clickedCards.includes(item.id) ? true : false} | ||
/> | ||
))} | ||
</CardsContainer> | ||
); | ||
} | ||
|
||
const CardsContainer = styled.div` | ||
display: flex; | ||
flex-wrap: wrap; | ||
background-color: ${theme.colors.lightYellow}; | ||
& > div { | ||
& > img { | ||
width: 25rem; | ||
height: 25rem; | ||
} | ||
} | ||
`; | ||
|
||
const Card = styled.div` | ||
background-image: url(${(props) => | ||
props.matchedCards | ||
? props.cardImg | ||
: props.clickedCards | ||
? props.cardImg | ||
: backCardImg}); | ||
Comment on lines
+104
to
+109
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. style에서 props나 비교연산자를 쓸 생각은 못했는데 똑똑한 방법 배워간다..!! |
||
width: 20rem; | ||
height: 20rem; | ||
background-size: cover; | ||
background-position: center; | ||
cursor: pointer; | ||
margin: 6rem; | ||
transform-style: preserve-3d; | ||
transition: transform 0.6s ease-in-out; | ||
transform: ${(props) => | ||
props.matchedCards | ||
? "rotateY(360deg)" | ||
: props.clickedCards | ||
? "rotateY(360deg)" // card1 | ||
: "rotateY(0deg)"}; // card2 | ||
backface-visibility: ${(props) => props.cardImg}; | ||
`; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
아니 상태관리 진짜 깔끔하다. 나는 현수랑 다르게 카드와 관련된 상태를 모두 최상단에서 만들어서 내려줬거든. 그렇게 했던 이유가 level이 바뀔 때마다 카드도 바꿔줘야하니까 그랬던건데 밑에 보니까 현수는 useState로 level이 바뀌면 카드 리스트를 새로 만들어내도록 했구나... useState를 어떻게 써야하는지 잘 몰라서 복잡하게 구현했던 것 같아 ㅋㅋㅋ 이렇게 깔끔하게 할 수 있군!!