죄수의 딜레마
개념을 활용한 XY 게임을 웹 콘텐츠로 개발했다. 4팀이 10라운드를 거쳐 최대의 이익을 달성하는 멀티 게임이며 기업 교육(한국사회복지공제회, 경기문화재단 등)용으로 서비스 중이다.
실제로 서비스되는 웹 콘텐츠 개발에 관심이 있어서 팀에 참여하게 되었다. 프로젝트에서 어떤 것을 했는지 기록해보려 한다.
https://github.com/X-y-game/x-y-game
api로 채널리스트와 룸 정보를 가진 객체를 받고 map
을 통해서 list 컴포넌트를 생성해준다.
api를 작업한 동료분이 편리하게 api 함수를 만들어줘서 편하게 사용할 수 있었다.
export const getChannelsAPI = () => { const options = { method: "GET", }; . return fetch(CHANNELS, options); };
import { getRoomAPI } from "../../api/api"; . . const [rooms, setRooms] = useState([]); useEffect(() => { async function getRoomList() { const response = await (await getRoomAPI(channelId)).json(); setRooms(response.roomLists); } getRoomList(); }, []); . . . return ( <Body> . . . <WrapChannelUL> {rooms?.map(({ _id, title }, index) => ( <RoomList key={_id} id={_id} channelIndex={indexId} text={title} roomNum={index + 1} channelId={channelId} /> ))} </WrapChannelUL> . . </Body> );
작업 중에 제대로 데이터가 넘어오지 않는 문제가 있었고, 옵셔널 체이닝
의 존재를 알게 되었다. 옵셔널 체이닝
은 대상의 undefined나 null이면 뒤의 코드를 진행하지 않고 바로 undefined를 반환하는 연산자이다. 프로퍼티가 없는 중첩 객체를 에러 없이 안전하게 접근할 수 있다. ?.
를 사용하면 된다.
백엔드 작업자분이 만들어 둔 데이터를 사용해서 채널 리스트를 생성해준다.
채널에는 비밀번호가 존재하는데, 사실 비밀번호 보다는 입장 코드에 가깝다. 최소한의 보안을 위해 이를 확인하는 코드를 작성했다.
먼저 비밀번호를 확인하는 컴포넌트를 만들어 둔다음, 채널에서 그 컴포넌트를 껐다 키는 함수를 작성한다. 비밀번호 컴포넌트에 해당 함수를 보내서 state를 관리해준다.
openPwForm이 true일 때 컴포넌트가 보이도록 작성
const [openPwForm, setOpenPwForm] = useState(false); const handleClick = () => { setOpenPwForm(!openPwForm); }; return ( <> {openPwForm && ( <CheckPw passWord={pw} index={index} channelId={channelId} title={text} handleClick={handleClick} />)} <li onClick={handleClick} onKeyDown={handleClick} aria-hidden="true"> {text} </li> </> );
비밀번호 컴포넌트에서 input에 들어온 문자열과 props로 받은 password를 비교해 맞는 경우에만 다음 페이지로 이동하도록 했다. useState
와 input 태그의 onChange
, value
를 통해 입력 값을 데이터로 사용할 수 있도록 한다.
const [wrongPw, setWrongPw] = useState(true); const [inputText, setInputText] = useState(""); const onChange = (event) => { setInputText(event.target.value); }; . . . return ( <Wrap> <CheckPassWard> <p>비밀번호를 입력하세요 👀</p> <input type="text" onChange={onChange} value={inputText} /> <Message check={wrongPw}>비밀번호가 틀렸습니다.</Message> <EnterButton type="submit" onClick={checkPw}> 입력 </EnterButton> </CheckPassWard> <Dimmed onClick={handleClick}>dimmed</Dimmed> </Wrap> ); . . .
비밀번호가 틀린 경우에는 경고 문구를 띄워주었다. 그리고 dimd처리를 해줘서 비밀번호 컴포넌트 이 외의 공간을 누르면 handleClick
이 작동되어 OpenPwForm
의 상태가 변하고, 컴포넌트가 사라진다.
모달이나 팝업을 강조해주기 위해 뒷 화면에 생기는 음영효과를 뜻하는 딤드처리를 해주는 작업을 진행했다. 모달을 닫을 때 굳이 X버튼이 없어도 모달을 제외한 다른 화면을 누르면 딤드가 눌리면서 다시 꺼지게 한다던지 간편하게 활용할 수 있다.
<Dimmed onClick={handleClick}>dimmed</Dimmed>
.
.
.
const Dimmed = styled.div`
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
font-size: 0;
background-color: rgba(0, 0, 0, 0.7);
`;
비밀번호가 맞으면 history.push로 다음 페이지로 이동한다.
이때 채널이름 props를 다음페이지에 보내서 어떤 채널에 들어왔는지 표시하기 위해 코드를 작성했다.
. . .//비밀번호 맞는 경우 ... history.push({ pathname: `/channel/${index}`, state: { channel: channelId, title: `${title}` }, });
state를 통해 channel, title을 다음페이지에서 활용할 수 있다.
채널, 룸, 팀 선택 페이지에는 상단 부분이 중복되어서 이를 헤더 컴포넌트로 분리 했다.
//channels
<Header title="채널을 선택하세요" channel="" roomId="" />
//room
<Header title="룸을 선택하세요" channel={channelTitle} roomId="" />
//team
<Header title="팀 선택" channel={channelIndex} roomId={roomTitle} />
헤더에서는 삼항연산자
를 통해 주소에 channel/이 포함된 경우를 따져 각 페이지에 정보를 표시했다.
export default function Header({ title, channel, roomId }) {
const location = useLocation();
return (
<Title>
<GoBackButton />
{location.pathname.includes("channel/") ? (
<Infodata>{channel}</Infodata>
) : (
<Infodata>
<Infodata>{roomId}</Infodata>
</Infodata>
)}
{title}
</Title>
);
}
테스트 채널로 들어온 경우 룸 선택 때 채널명이 위에 나온다. 팀 선택인 경우는 들어온 룸 이름이 나온다.
게임이 시작된 후 게임에 필요한 모달은 세 가지이다.
하나의 모달에서 조건에 맞는 정보를 보여주기 위해 조건부 렌더링을 작업했다.
라운드가 끝나는 시점에 모달을 보여줘서 각 팀의 선택카드와 이익, 손실을 표시해준다.
모달컴포넌트에 선택한 카드와 그 라운드의 점수가 props로 전달된다. 그 정보를 가지고 라운드 결과를 배열에 넣어주었다.
const roundResultData = () => {
const pushData = [];
for (let i = 0; i < 4; i += 1) {
pushData.push({
team: i + 1,
cardXY: selectCard[i],
point: roundScore[i],
});
}
return pushData;
};
삼항연산자
를 통해 현재 상황이 중간결과인지 조건에 맞게 출력한다. 아까 만들어둔 정보를 map
으로 순회하며 출력 해주었다.
//isCurrentResult <- 중간 결과(현황 모달에 대한 boolean값)
{isCurrentResult ? (
.
.
.
) : (
//중간 결과가 아닌 경우 라운드 결과 모달이 나온다.
<WrapResult>
{roundResultData().map((data) => (
<TeamResult
key={`modal_${data.team}`}
team={data.team}
cardXY={data.cardXY}
point={data.point}
round={round}
handleFinishedModal={handleFinishedModal}
/>
))}
</WrapResult>
)}
아까 삼항연산자
를 통해 isCurrentResult가 true일때는 중간 결과 모달을 보여주도록 한다.
{isCurrentResult ? (
<CurrentResult
scoreData={scoreBoard}
selectData={selectBoard}
round={round}
isFinishResult={isFinishResult}
/>
) : (
.
.
.
)}
중간 결과 모달에는 각 라운드 별 점수와 선택한 카드, 라운드 정보, 최종결과여부가 props로 전달된다.
전달된 데이터를 활용해서 중간 결과 모달에 맞게 빈 배열에 정보를 넣어주었다. 팀 별 선택카드와 라운드 점수를 넣어주었다. styled-components 로 이익인 경우는 파란색, 손해인 경우는 붉게 적용해서 이익/손해를 파악하기 쉽게 해주었다.
const roundData = () => {
const pushData = [];
if (!isFinishResult) {
for (let i = 0; i < round - 1; i += 1) {
pushData.push({
id: i + 1,
roundNum: i + 1,
teamOneCardXY: selectData[i][0],
teamTwoCardXY: selectData[i][1],
teamThreeCardXY: selectData[i][2],
teamFourCardXY: selectData[i][3],
.
.
.
});
}
당시에는 빨리 기능을 마무리하고싶어서 이런식으로 데이터를 정제했는데 더 좋은 방법이 있었을 것 같다.
발생한 문제
원래는 5라운드라면 4라운드까지만 중간결과를 보여주게 되어 있었지만 데이터 상의 문제로 for문 조건이 바뀌면서 5라운드의 점수까지 보여주게 되었다. 그런데 아직 선택을 하지 않은 팀이 중간 결과 모달을 열어서 다른 팀이 선택한 카드를 볼 수 있는 문제가 있었다.
모든 팀이 선택하지 않으면 점수가 계산되지 않는 점을 활용해서 삼항 연산자로 점수가 계산되지 않은 경우(0점)이면 물음표를 표시하게 해주었다.
. . {teamOneScore === 0 ? "?" : teamOneScore}
모달에서 라운드결과인지 중간결과인지를 판단하기 전에, 최종결과를 보여줄지 말지를 결정해주는 것을 먼저 진행한다. 이 역시 삼항연산자
를 활용했다.
최종 결과는 각 팀의 점수 합계를 보여주었는데 나중에 피드백으로 수정되어 중간 결과의 형태에서 각 합계를 보여주는 것으로 바뀌었다.
최종 결과를 보여줄 때는 데이터 정제 시 계산을 더 해줘서 배열에 저장했다.
for (let i = 0; i < round; i += 1) {
one += scoreData[i][0];
two += scoreData[i][1];
three += scoreData[i][2];
four += scoreData[i][3];
pushData.push({
id: i + 1,
roundNum: i + 1,
teamOneCardXY: selectData[i][0],
teamTwoCardXY: selectData[i][1],
teamThreeCardXY: selectData[i][2],
teamFourCardXY: selectData[i][3],
.
.
.
.
.
.
.
total: one + two + three + four,
});
}
}
return pushData;
};
게임 로직은 뛰어난 팀원분이 잘 구현해주셨지만 문제가 있었다. 모두가 느끼고 있었던 것인데, 페이지가 너무 이쁘지 않다는 것이다.
보기 좋게 하기 위해 대대적인 공사에 들어갔다. 폰트를 적용하고 컨텐츠 별 화면을 개선시켰다.
선택 시 위에 보이던 팀 별 ? 표시를 없애고 선택한 카드를 내는 것 처럼 애니메이션 작업을 해주었다. keyframes
로 애니메이션을 설정해주고 styled-components에서 animation
으로 실행시켜 준다.
const cardAnim = keyframes`
0%{
transform: translate(-50%, 50%);
}
100%{
transform: translate(-50%, 0%);
}
`;
const SelectCard = styled.div`
display: flex;
.
.
.
animation: ${cardAnim} 1s forwards;
`;
개선 전
개선 후
<RotateContainer>
<BackCard className="back card">
<CardBackGround src={CardBackground} alt="card-back" />
</BackCard>
<SelectCard name={cardXY} className="front card">
{cardXY}
</SelectCard>
</RotateContainer>
.
.
const RotateAnimFront = keyframes`
0%{
transform: rotateY(-180deg);
}
50%{
transform: rotateY(-180deg);
}
100%{
transform: rotateY(0);
}
`;
const RotateAnimBack = keyframes`
0%{
transform: rotateY(0);
}
50%{
transform: rotateY(0);
}
100%{
transform: rotateY(180deg);
}
`;
.
.const RotateContainer = styled.div`
position: relative;
.card {
-webkit-backface-visibility: hidden;
-webkit-transform: translate3d(0, 0, 0);
-webkit-perspective: 0;
-webkit-transition: 1s;
backface-visibility: hidden;
visibility: visible;
}
.front {
transform: rotateY(-180deg);
animation: ${RotateAnimFront} 2s forwards;
}
.back {
transform: rotateY(0);
animation: ${RotateAnimBack} 2s forwards;
}
`;
개선 전
개선 후
또 최종 결과가 나오는 방식을 바꾸었는데 기존에는 마지막 라운드가 종료되면 결과를 보여주지 않고 바로 최종 결과가 나왔다. 그래서 마지막 라운드 결과를 먼저 보여주고 최종결과 모달을 볼 수 있는 버튼을 만들었다.
이 프로젝트를 하기 전에는 왜 styled-components써야 하는지 체감하기 어려웠는데 프로젝트 이후 styled-components를 활용하면서 편리함을 느꼈다.
예를 들어 props를 정해둔 뒤 styled-components에서 props에 맞게 스타일을 적용해줄 수 있어서 편리했다.
//승패에 따라 배경 색상을 다르게 주기
<CheckCard name={teamOneScore < 0 ? "패" : "승"}>
.
.
.
background-color: ${(props) => (props.name === "승" ? "#c3e8fb" : "#ffb7b7")};
리액트에서 props 데이터를 넘길 때 type을 명시하라고 eslint가 잔소리를 하는데 익숙하지 않아서 이를 해결하기 위해 꽤나 고생했다.
넘어온 props data에 타입을 명시해주면 된다.
CalenderContorol.propTypes = {
year: PropTypes.string.isRequired,
month: PropTypes.string.isRequired,
handlePrevMonth: PropTypes.func.isRequired,
handleNextMonth: PropTypes.func.isRequired,
};
특정 타입의 행렬이라면(ex. [1,2,3,4,5])
DayofTheWeek: PropTypes.arrayOf(PropTypes.number).isRequired,
데이터가 하나가 아닌 경우 오류 메세지가 떴고 이를 해결하기 위해 oneOfType
을 활용했다. 여러 종류중 하나의 종류가 될 수 있는 객체에 활용한다고 한다.(https://ko.reactjs.org/docs/typechecking-with-proptypes.html)
channels: PropTypes.oneOfType([PropTypes.objectOf(PropTypes.array), PropTypes.arrayOf(PropTypes.array)]).isRequired,
이런식으로 보낸 데이터를 uselocation
으로 받아서 사용했는데, 가끔 여러개의 props를 보내려고 하면 eslint에서 오류를 발생시켰다.(같은 방식인데도 어떤데서는 안되는 문제가 있었다.)
리터널 문법을 활용해서 이를 해결했다.
이런식으로 하나의 props에 여러 데이터를 묶어 보낸 뒤
split
으로 나눠서 데이터를 받아 활용할 수 있었다. 원인을 찾으면 좋았겠지만 시간이 부족하고 원인도 찾기 힘들어서 일단은 임시방편으로 처리 해두었다.
이 프로젝트에 처음 참여할 때만 해도 객체와 map
을 활용하지 못해서 그냥 수작업으로 작업할 정도로 react에 익숙하지 못했다. 점차 react에 익숙해지면서 컴포넌트 분리나 state관리 등을 활용해서 작업을 할 수 있었고 react를 왜 쓰는지 체감으로 느낄 수 있는 프로젝트 였다.
같이 작업한 분들의 실력이 좋아서 데이터도 쓰기 편하게 넘겨주시고 여러모로 많이 배울 수 있었다.
또 이 프로젝트는 socket
을 활용한 프로젝트였는데 이 부분에서는 1도 도움이 되지 못했다.. 프론트쪽 작업이라도 열심히 하고자 했는데 socket
을 좀 더 공부해서 문제해결에 도움이 되었으면 더 좋았을 것 같다.