",
7 | "license": "MIT",
8 | "private": true,
9 | "workspaces": [
10 | "server",
11 | "client"
12 | ],
13 | "scripts": {
14 | "test": "yarn workspace client test && yarn workspace server test",
15 | "dev-start": "nodemon server/bin/www"
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/server/constants/gameRule.js:
--------------------------------------------------------------------------------
1 | const GAME_RULE = {
2 | INITIAL_ROUND: 1,
3 | MIN_PLAYER_COUNT: 2,
4 | MAX_PLAYER_COUNT: 4,
5 | ONE_SET_SECONDS: 30,
6 | MAX_ROUND_NUMBER: 1,
7 | MAX_QUIZ_SELECTION_WAITING_SECONDS: 10,
8 | MAX_PEER_CONNECTION_WAITING_SECONDS: 10,
9 | QUIZ_CANDIDATES_COUNT: 3,
10 | SECONDS_BETWEEN_SETS: 5,
11 | SECONDS_AFTER_GAME_END: 10,
12 | MAX_CHAT_LENGTH: 40,
13 | NICKNAME_LENGTH: 8,
14 | DEFAULT_QUIZ: '',
15 | };
16 |
17 | module.exports = GAME_RULE;
18 |
--------------------------------------------------------------------------------
/client/src/hooks/useIsMobile.js:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 | import useInnerWidth from './useInnerWidth';
3 |
4 | const useIsMobile = mobileViewBreakpoint => {
5 | const innerWidth = useInnerWidth();
6 | const [isMobile, setIsMobile] = useState(
7 | window.innerWidth < mobileViewBreakpoint,
8 | );
9 | useEffect(() => {
10 | setIsMobile(innerWidth < mobileViewBreakpoint);
11 | }, [innerWidth]);
12 | return isMobile;
13 | };
14 |
15 | export default useIsMobile;
16 |
--------------------------------------------------------------------------------
/server/constants/api.js:
--------------------------------------------------------------------------------
1 | const API = {
2 | ERROR_500_DATABASE: {
3 | error: {
4 | errors: [
5 | {
6 | domain: 'Database',
7 | reason: 'Internal error',
8 | message: 'Database failed to send data.',
9 | },
10 | ],
11 | code: 500,
12 | message: 'Database failed to send data.',
13 | },
14 | },
15 | PATH: {
16 | RANKING: '/ranking',
17 | RANKING_INFORMATION: '/ranking/information',
18 | },
19 | };
20 |
21 | module.exports = API;
22 |
--------------------------------------------------------------------------------
/client/src/constants/timer.js:
--------------------------------------------------------------------------------
1 | const ONE_SECOND = 1000;
2 | const DEFAULT_INACTIVE_PLAYER_BAN_TIME = 20;
3 | const PRIVATE_ROOM_INACTIVE_PLAYER_BAN_TIME = 300;
4 | const PRIVATE_ROOM_INACTIVE_PLAYER_BAN_TIME_IN_MINUTE =
5 | PRIVATE_ROOM_INACTIVE_PLAYER_BAN_TIME / 60;
6 | const CLIPBOARD_COPY_MESSAGE_DURATION = 1;
7 | export {
8 | ONE_SECOND,
9 | DEFAULT_INACTIVE_PLAYER_BAN_TIME,
10 | PRIVATE_ROOM_INACTIVE_PLAYER_BAN_TIME,
11 | PRIVATE_ROOM_INACTIVE_PLAYER_BAN_TIME_IN_MINUTE,
12 | CLIPBOARD_COPY_MESSAGE_DURATION,
13 | };
14 |
--------------------------------------------------------------------------------
/client/src/utils/copyUrlToClipboard.js:
--------------------------------------------------------------------------------
1 | import { INPUT_TAG, BROWSER_COPY_COMMAND } from '../constants/browser';
2 |
3 | const copyUrlToClipboard = () => {
4 | const temporaryInput = document.createElement(INPUT_TAG);
5 | const urlToCopy = window.location.href;
6 | document.body.appendChild(temporaryInput);
7 | temporaryInput.value = urlToCopy;
8 | temporaryInput.select();
9 | document.execCommand(BROWSER_COPY_COMMAND);
10 | document.body.removeChild(temporaryInput);
11 | };
12 |
13 | export default copyUrlToClipboard;
14 |
--------------------------------------------------------------------------------
/client/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | // eslint-disable-next-line import/no-cycle
4 | import styled from 'styled-components';
5 | import App from './App';
6 | import backgroundImageSource from './assets/background.png';
7 |
8 | const AppBackgroundWrapper = styled.div`
9 | height: 100%;
10 | background-image: url(${backgroundImageSource});
11 | `;
12 |
13 | ReactDOM.render(
14 |
15 |
16 | ,
17 | document.getElementById('root'),
18 | );
19 |
--------------------------------------------------------------------------------
/client/src/constants/webRTC.js:
--------------------------------------------------------------------------------
1 | const PEER_CONNECTION_CONFIG = {
2 | iceServers: [
3 | { urls: 'stun:stun.services.mozilla.com' },
4 | { urls: 'stun:stun.l.google.com:19302' },
5 | {
6 | url: 'turn:numb.viagenie.ca',
7 | credential: 'muazkh',
8 | username: 'webrtc@live.com',
9 | },
10 | ],
11 | };
12 |
13 | const MEDIA_CONSTRAINTS = {
14 | video: true,
15 | audio: false,
16 | };
17 |
18 | const DESCRIPTION_TYPE = {
19 | ANSWER: 'answer',
20 | };
21 |
22 | export { PEER_CONNECTION_CONFIG, MEDIA_CONSTRAINTS, DESCRIPTION_TYPE };
23 |
--------------------------------------------------------------------------------
/client/src/presentation/components/UnderlinedSpace.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { makeStyles } from '@material-ui/core/styles';
3 |
4 | const UnderlinedSpace = () => {
5 | const useStyles = makeStyles(() => ({
6 | underlinedEmpty: {
7 | textDecoration: 'underline',
8 | fontSize: '2rem',
9 | fontWeight: 'bold',
10 | minWidth: '2rem',
11 | },
12 | }));
13 |
14 | const classes = useStyles();
15 |
16 | return ;
17 | };
18 |
19 | export default UnderlinedSpace;
20 |
--------------------------------------------------------------------------------
/server/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "browser": true,
4 | "commonjs": true,
5 | "es6": true,
6 | "node": true
7 | },
8 | "extends": ["airbnb-base", "prettier"],
9 | "globals": {
10 | "Atomics": "readonly",
11 | "SharedArrayBuffer": "readonly"
12 | },
13 | "parserOptions": {
14 | "ecmaVersion": 2018
15 | },
16 | "rules": {
17 | "linebreak-style": 0,
18 | "no-restricted-globals": 0,
19 | "class-methods-use-this": 0,
20 | "no-plusplus": 0,
21 | "no-undef": 0,
22 | "no-param-reassign": 0
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/client/src/presentation/containers/MainTitle.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'styled-components';
3 | import { Logo } from '../components';
4 | import logoSrc from '../../assets/main-logo-large.png';
5 |
6 | const TitleWrapper = styled.div`
7 | margin-top: 2rem;
8 | heigth: auto;
9 | width: 100%;
10 | padding: 0 30px;
11 | box-sizing: border-box;
12 | `;
13 |
14 | const MainTitle = () => {
15 | return (
16 |
17 |
18 |
19 | );
20 | };
21 |
22 | export default MainTitle;
23 |
--------------------------------------------------------------------------------
/server/databaseFiles/databaseModels/seeder/quizzes.csv:
--------------------------------------------------------------------------------
1 | word
2 | 이
3 | 아이
4 | 오이
5 | 우유
6 | 코
7 | 모자
8 | 구두
9 | 나무
10 | 바지
11 | 포도
12 | 사자
13 | 새
14 | 배
15 | 베개
16 | 수박
17 | 눈
18 | 옷
19 | 달
20 | 밤
21 | 공
22 | 빵
23 | 자음
24 | 모음
25 | 감
26 | 강
27 | 밥
28 | 산
29 | 사전
30 | 어머니
31 | 아버지
32 | 한국
33 | 일본
34 | 몽골
35 | 중국
36 | 베트남
37 | 태국
38 | 사람
39 | 국적
40 | 문법
41 | 연습
42 | 직업
43 | 학습
44 | 선생님
45 | 회사원
46 | 의사
47 | 경찰
48 | 공무원
49 | 이름
50 | 동작
51 | 공부
52 | 운동
53 | 수면
54 | 전화
55 | 이야기
56 | 캐러비안의해적
57 | 학교
58 | 오렌지
59 | 명함
60 | 커피
61 | 주스
62 | 스타크래프트
63 | 니모를찾아서
64 | 군대
65 | 병장
66 | 상병
67 | 일병
68 | 이병
69 | 예비군
70 |
--------------------------------------------------------------------------------
/client/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React App",
3 | "name": "Create React App Sample",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | },
10 | {
11 | "src": "logo192.png",
12 | "type": "image/png",
13 | "sizes": "192x192"
14 | },
15 | {
16 | "src": "logo512.png",
17 | "type": "image/png",
18 | "sizes": "512x512"
19 | }
20 | ],
21 | "start_url": ".",
22 | "display": "standalone",
23 | "theme_color": "#000000",
24 | "background_color": "#ffffff"
25 | }
26 |
--------------------------------------------------------------------------------
/client/src/presentation/components/Message.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import styled from 'styled-components';
4 | import styleColors from '../../constants/styleColors';
5 |
6 | const MessageContent = styled.span`
7 | font-size: 1.4rem;
8 | word-wrap: break-word;
9 | color: ${styleColors.BASE_BLACK_COLOR};
10 | line-height: 1.4;
11 | `;
12 |
13 | const Message = ({ children }) => {
14 | return {children};
15 | };
16 |
17 | Message.propTypes = {
18 | children: PropTypes.string.isRequired,
19 | };
20 |
21 | export default Message;
22 |
--------------------------------------------------------------------------------
/client/src/constants/game.js:
--------------------------------------------------------------------------------
1 | const READY_BUTTON_TEXT = {
2 | READY: 'Ready',
3 | CANCEL: 'Cancel',
4 | };
5 |
6 | const STREAMING_PANEL_MESSAGE_TYPE = {
7 | STREAMER_LOADING: 'streamerLoading',
8 | QUIZ_SELECTION: 'quizSelection',
9 | SCORE_BOARD: 'scoreBoard',
10 | };
11 |
12 | const GAME_STATUS = {
13 | WAITING: 'waiting',
14 | PLAYING: 'playing',
15 | SCORE_SHARING: 'scoreSharing',
16 | };
17 |
18 | const PLAYER_TYPES = {
19 | VIEWER: 'viewer',
20 | STREAMER: 'streamer',
21 | };
22 |
23 | export {
24 | READY_BUTTON_TEXT,
25 | STREAMING_PANEL_MESSAGE_TYPE,
26 | GAME_STATUS,
27 | PLAYER_TYPES,
28 | };
29 |
--------------------------------------------------------------------------------
/client/src/hooks/useInnerWidth.js:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 | import EVENTS from '../constants/events';
3 |
4 | const useInnerWidth = () => {
5 | const [innerWidth, setInnerWidth] = useState(window.innerWidth);
6 |
7 | const resizeEventHandler = event => {
8 | setInnerWidth(event.target.innerWidth);
9 | };
10 |
11 | useEffect(() => {
12 | window.addEventListener(EVENTS.RESIZE, resizeEventHandler);
13 | return () => {
14 | window.removeEventListener(EVENTS.RESIZE, resizeEventHandler);
15 | };
16 | }, []);
17 |
18 | return innerWidth;
19 | };
20 |
21 | export default useInnerWidth;
22 |
--------------------------------------------------------------------------------
/client/src/presentation/components/UnderlinedLetter.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { makeStyles } from '@material-ui/core/styles';
3 | import PropTypes from 'prop-types';
4 |
5 | const UnderlinedLetter = ({ letter }) => {
6 | const useStyles = makeStyles(() => ({
7 | underlinedLetter: {
8 | fontSize: '2rem',
9 | fontWeight: 'bold',
10 | },
11 | }));
12 |
13 | const classes = useStyles();
14 |
15 | return {letter};
16 | };
17 |
18 | UnderlinedLetter.propTypes = {
19 | letter: PropTypes.string.isRequired,
20 | };
21 |
22 | export default UnderlinedLetter;
23 |
--------------------------------------------------------------------------------
/client/src/presentation/pages/Ranking/style.js:
--------------------------------------------------------------------------------
1 | import { makeStyles } from '@material-ui/core/styles';
2 |
3 | const useStyle = makeStyles({
4 | mainPage: {
5 | width: '40rem',
6 | display: 'flex',
7 | flexDirection: 'column',
8 | '& > *': {
9 | marginBottom: '2rem',
10 | },
11 | },
12 | mainPageWrapper: {
13 | margin: 0,
14 | width: '100%',
15 | height: '100%',
16 | overflow: 'auto',
17 | },
18 | exitButtonWrapper: {
19 | position: 'fixed',
20 | top: '1.5rem',
21 | right: '2.5rem',
22 | },
23 | MoreButton: {
24 | textAlign: 'center',
25 | marginBottom: '2rem',
26 | },
27 | });
28 |
29 | export default useStyle;
30 |
--------------------------------------------------------------------------------
/client/src/presentation/containers/StreamingPanel/style.js:
--------------------------------------------------------------------------------
1 | import { makeStyles } from '@material-ui/core/styles';
2 | import styleColors from '../../../constants/styleColors';
3 |
4 | const useStyles = makeStyles(theme => ({
5 | container: {
6 | position: 'relative',
7 | height: '100%',
8 | backgroundColor: styleColors.BASE_BLACK_COLOR,
9 | boxShadow: '0 0.2rem 0.7rem 0 rgba(0, 0, 0, 0.5)',
10 | borderRadius: '0.3rem',
11 | [theme.breakpoints.down('xs')]: {
12 | borderRadius: '0',
13 | },
14 | padding: 'none',
15 | display: 'flex',
16 | alignItems: 'center',
17 | justifyContent: 'center',
18 | },
19 | }));
20 |
21 | export default useStyles;
22 |
--------------------------------------------------------------------------------
/client/src/presentation/containers/index.js:
--------------------------------------------------------------------------------
1 | export { default as MainTitle } from './MainTitle';
2 | export { default as Menu } from './Menu';
3 | export { default as Introduction } from './Introduction';
4 | export { default as StreamingPanel } from './StreamingPanel';
5 | export { default as ChattingPanel } from './ChattingPanel';
6 | export { default as PlayerPanel } from './PlayerPanel';
7 | export { default as MobileChattingPanel } from './MobileChattingPanel';
8 | export { default as TopRankPanel } from './TopRankPanel';
9 | export { default as BottomRankPanel } from './BottomRankPanel';
10 | // eslint-disable-next-line import/no-unresolved
11 | export { default as ScoreBoard } from './ScoreBoard';
12 |
--------------------------------------------------------------------------------
/server/service/game/eventHandlers/index.js:
--------------------------------------------------------------------------------
1 | const matchHandler = require('./matchHandler');
2 | const sendReadyHandler = require('./sendReadyHandler');
3 | const sendChattingMessageHandler = require('./sendChattingMessageHandler');
4 | const askSocketIdHandler = require('./askSocketIdHandler');
5 | const connectPeerHandler = require('./connectPeerHandler');
6 | const selectQuizHandler = require('./selectQuizHandler');
7 | const disconnectingHandler = require('./disconnectingHandler');
8 |
9 | module.exports = {
10 | matchHandler,
11 | sendReadyHandler,
12 | sendChattingMessageHandler,
13 | askSocketIdHandler,
14 | connectPeerHandler,
15 | selectQuizHandler,
16 | disconnectingHandler,
17 | };
18 |
--------------------------------------------------------------------------------
/server/databaseFiles/databaseModels/Quiz.js:
--------------------------------------------------------------------------------
1 | const Sequelize = require('sequelize');
2 | const connection = require('../connection');
3 | const { MODEL_QUIZ } = require('../../constants/database');
4 |
5 | class Quiz extends Sequelize.Model {}
6 |
7 | Quiz.init(
8 | {
9 | word: {
10 | type: Sequelize.STRING,
11 | allowNull: false,
12 | defaultValue: '',
13 | },
14 | selected_count: {
15 | type: Sequelize.INTEGER,
16 | defaultValue: 0,
17 | },
18 | hits: {
19 | type: Sequelize.INTEGER,
20 | defaultValue: 0,
21 | },
22 | },
23 | {
24 | sequelize: connection,
25 | modelName: MODEL_QUIZ,
26 | },
27 | );
28 |
29 | module.exports = Quiz;
30 |
--------------------------------------------------------------------------------
/client/src/presentation/components/Slogan.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import { makeStyles } from '@material-ui/core/styles';
4 | import Typography from '@material-ui/core/Typography';
5 |
6 | const useStyle = makeStyles({
7 | slogan: {
8 | margin: '0 0.2rem',
9 | whiteSpace: 'pre-line',
10 | fontWeight: '600',
11 | },
12 | });
13 |
14 | const Slogan = ({ content }) => {
15 | const classes = useStyle();
16 | return (
17 |
18 | {content}
19 |
20 | );
21 | };
22 |
23 | Slogan.propTypes = {
24 | content: PropTypes.string.isRequired,
25 | };
26 |
27 | export default Slogan;
28 |
--------------------------------------------------------------------------------
/client/src/presentation/components/CenterTimer.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { makeStyles } from '@material-ui/core/styles';
3 | import PropTypes from 'prop-types';
4 | import styleColors from '../../constants/styleColors';
5 |
6 | const useStyles = makeStyles(() => ({
7 | timer: {
8 | fontSize: '6rem',
9 | fontWeight: 'bold',
10 | color: styleColors.PURE_WHITE_COLOR,
11 | textAlign: 'center',
12 | },
13 | }));
14 |
15 | const CenterTimer = ({ currentSeconds }) => {
16 | const classes = useStyles();
17 | return {currentSeconds}
;
18 | };
19 |
20 | CenterTimer.propTypes = {
21 | currentSeconds: PropTypes.string.isRequired,
22 | };
23 |
24 | export default CenterTimer;
25 |
--------------------------------------------------------------------------------
/client/src/presentation/components/Nickname.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import styled from 'styled-components';
4 | import styleColors from '../../constants/styleColors';
5 |
6 | const NicknameContent = styled.span`
7 | font-size: 1.6rem;
8 | color: ${props => {
9 | return props.nicknameColor || styleColors.INFORMATION_COLOR;
10 | }};
11 | `;
12 |
13 | const Nickname = ({ children, nicknameColor }) => {
14 | return (
15 | {children}
16 | );
17 | };
18 |
19 | Nickname.propTypes = {
20 | children: PropTypes.string.isRequired,
21 | nicknameColor: PropTypes.string.isRequired,
22 | };
23 |
24 | export default Nickname;
25 |
--------------------------------------------------------------------------------
/server/app.js:
--------------------------------------------------------------------------------
1 | const express = require('express');
2 | const path = require('path');
3 | const logger = require('morgan');
4 | const service = require('./service');
5 | const api = require('./routes/api');
6 |
7 | const app = express();
8 |
9 | app.use(logger('dev'));
10 | app.use(express.json());
11 | app.use(
12 | express.urlencoded({
13 | extended: false,
14 | }),
15 | );
16 |
17 | app.use(express.static(path.join(__dirname, '../client/build')));
18 |
19 | app.use('/api', api);
20 |
21 | app.get('*', (req, res) => {
22 | res.sendFile(path.join(__dirname, '../client/build/index.html'));
23 | });
24 |
25 | app.use((req, res) => {
26 | res.redirect('/');
27 | });
28 |
29 | app.service = service;
30 |
31 | module.exports = app;
32 |
--------------------------------------------------------------------------------
/client/src/utils/makeViewPlayerList.js:
--------------------------------------------------------------------------------
1 | const makeViewPlayerList = (localPlayer, remotePlayers) => {
2 | const viewLocalPlayer = { ...localPlayer, isLocalPlayer: true };
3 | const viewRemotePlayerSocketIds = Object.keys(remotePlayers);
4 | const viewRemotePlayers = viewRemotePlayerSocketIds.reduce(
5 | (accum, remotePlayerSocketId) => {
6 | const viewRemotePlayer = {
7 | ...remotePlayers[remotePlayerSocketId],
8 | isLocalPlayer: false,
9 | socketId: remotePlayerSocketId,
10 | };
11 | return [viewRemotePlayer, ...accum];
12 | },
13 | [],
14 | );
15 | const viewPlayerList = [viewLocalPlayer, ...viewRemotePlayers];
16 | return viewPlayerList;
17 | };
18 |
19 | export default makeViewPlayerList;
20 |
--------------------------------------------------------------------------------
/server/databaseFiles/databaseModels/Ranking.js:
--------------------------------------------------------------------------------
1 | const Sequelize = require('sequelize');
2 | const connection = require('../connection');
3 | const { MODEL_RANKING } = require('../../constants/database');
4 |
5 | class Ranking extends Sequelize.Model {}
6 |
7 | Ranking.init(
8 | {
9 | nickname: {
10 | type: Sequelize.STRING,
11 | allowNull: false,
12 | },
13 | score: {
14 | type: Sequelize.INTEGER,
15 | defaultValue: 0,
16 | allowNull: false,
17 | },
18 | season: {
19 | type: Sequelize.INTEGER,
20 | defaultValue: 1,
21 | allowNull: false,
22 | },
23 | },
24 | {
25 | sequelize: connection,
26 | modelName: MODEL_RANKING,
27 | },
28 | );
29 |
30 | module.exports = Ranking;
31 |
--------------------------------------------------------------------------------
/client/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "browser": true,
4 | "es6": true
5 | },
6 | "extends": ["airbnb", "prettier"],
7 | "globals": {
8 | "Atomics": "readonly",
9 | "SharedArrayBuffer": "readonly"
10 | },
11 | "parserOptions": {
12 | "ecmaFeatures": {
13 | "jsx": true
14 | },
15 | "ecmaVersion": 2018,
16 | "sourceType": "module"
17 | },
18 | "plugins": ["react", "react-hooks"],
19 | "rules": {
20 | "react/jsx-filename-extension": [
21 | 1,
22 | {
23 | "extensions": [".js", ".jsx"]
24 | }
25 | ],
26 | "import/prefer-default-export": 0,
27 | "no-undef": 0,
28 | "react-hooks/rules-of-hooks": "error",
29 | "react-hooks/exhaustive-deps": "warn"
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/client/src/api/index.js:
--------------------------------------------------------------------------------
1 | const getRankings = async (offset, dateTime) => {
2 | try {
3 | const responseData = await fetch(
4 | `/api/ranking?offset=${offset}&datetime=${dateTime}`,
5 | {
6 | method: 'GET',
7 | },
8 | );
9 | const jsonData = await responseData.json();
10 | return jsonData;
11 | } catch (error) {
12 | return [];
13 | }
14 | };
15 |
16 | const getRankingInformation = async () => {
17 | try {
18 | const responseData = await fetch(`/api/ranking/information`, {
19 | method: 'GET',
20 | });
21 | const jsonData = await responseData.json();
22 | return jsonData;
23 | } catch (error) {
24 | return [];
25 | }
26 | };
27 |
28 | export { getRankings, getRankingInformation };
29 |
--------------------------------------------------------------------------------
/client/src/hooks/useToast.js:
--------------------------------------------------------------------------------
1 | /**
2 | * toasType: contants에 있는 toast 타입,
3 | * message: toast에 보여줄 메시지,
4 | * open: global state의 toast open,
5 | * dispatch: global dispatch
6 | * @param {*} param0
7 | */
8 | const useToast = ({ open, dispatch, actions }) => {
9 | const openToastTimeoutHandler = (toastType, message) => {
10 | dispatch(actions.openToast(toastType, message));
11 | };
12 | const openToast = (toastType, message) => {
13 | if (open) {
14 | dispatch(actions.closeToast());
15 | }
16 | setTimeout(openToastTimeoutHandler.bind(null, toastType, message), 0);
17 | };
18 | const closeToast = () => {
19 | dispatch(actions.closeToast());
20 | };
21 | return { openToast, closeToast };
22 | };
23 |
24 | export default useToast;
25 |
--------------------------------------------------------------------------------
/client/config/pnpTs.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const { resolveModuleName } = require('ts-pnp');
4 |
5 | exports.resolveModuleName = (
6 | typescript,
7 | moduleName,
8 | containingFile,
9 | compilerOptions,
10 | resolutionHost
11 | ) => {
12 | return resolveModuleName(
13 | moduleName,
14 | containingFile,
15 | compilerOptions,
16 | resolutionHost,
17 | typescript.resolveModuleName
18 | );
19 | };
20 |
21 | exports.resolveTypeReferenceDirective = (
22 | typescript,
23 | moduleName,
24 | containingFile,
25 | compilerOptions,
26 | resolutionHost
27 | ) => {
28 | return resolveModuleName(
29 | moduleName,
30 | containingFile,
31 | compilerOptions,
32 | resolutionHost,
33 | typescript.resolveTypeReferenceDirective
34 | );
35 | };
36 |
--------------------------------------------------------------------------------
/client/src/presentation/components/Title.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import { makeStyles } from '@material-ui/core/styles';
4 | import Typography from '@material-ui/core/Typography';
5 |
6 | const useStyle = makeStyles({
7 | title: props => ({
8 | margin: '0.5rem 0',
9 | fontSize: props.fontSize || '2rem',
10 | }),
11 | });
12 |
13 | const Title = ({ content, fontSize }) => {
14 | const classes = useStyle({ fontSize });
15 | return (
16 |
17 | {content}
18 |
19 | );
20 | };
21 |
22 | Title.propTypes = {
23 | content: PropTypes.string.isRequired,
24 | fontSize: PropTypes.string.isRequired,
25 | };
26 |
27 | export default Title;
28 |
--------------------------------------------------------------------------------
/client/src/utils/createShareUrlButtonClickHandler.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-alert */
2 | import copyUrlToClipoard from './copyUrlToClipboard';
3 | import { COPY_TO_CLIPBOARD_MESSAGE } from '../constants/message';
4 | import { TOAST_TYPES } from '../constants/toast';
5 | import { CLIPBOARD_COPY_MESSAGE_DURATION } from '../constants/timer';
6 | import Timer from '../service/Timer';
7 |
8 | const createShareUrlButtonClickHandler = (openToast, closeToast) => {
9 | return () => {
10 | copyUrlToClipoard();
11 | openToast(
12 | TOAST_TYPES.INFORMATION,
13 | `${window.location.href} ${COPY_TO_CLIPBOARD_MESSAGE}`,
14 | );
15 | new Timer().startTimeoutTimer(CLIPBOARD_COPY_MESSAGE_DURATION, closeToast);
16 | };
17 | };
18 |
19 | export default createShareUrlButtonClickHandler;
20 |
--------------------------------------------------------------------------------
/server/utils/getCurrentTime.js:
--------------------------------------------------------------------------------
1 | const leadingZeros = (number, digits) => {
2 | let zero = '';
3 | const numberToString = number.toString();
4 |
5 | if (numberToString.length < digits) {
6 | for (i = 0; i < digits - numberToString.length; i++) zero += '0';
7 | }
8 | return zero + numberToString;
9 | };
10 |
11 | const getCurrentTime = () => {
12 | const date = new Date();
13 | const dateString = `${leadingZeros(date.getFullYear(), 4)}-${leadingZeros(
14 | date.getMonth() + 1,
15 | 2,
16 | )}-${leadingZeros(date.getDate(), 2)} ${leadingZeros(
17 | date.getHours(),
18 | 2,
19 | )}:${leadingZeros(date.getMinutes(), 2)}:${leadingZeros(
20 | date.getSeconds(),
21 | 2,
22 | )}`;
23 |
24 | return dateString;
25 | };
26 |
27 | module.exports = { getCurrentTime };
28 |
--------------------------------------------------------------------------------
/server/databaseFiles/connection.js:
--------------------------------------------------------------------------------
1 | const Sequelize = require('sequelize');
2 |
3 | const {
4 | MAXIMUM_CONNECTION_COUNT,
5 | MINIMUM_CONNECTION_COUNT,
6 | MAXIMUM_WAITING_TIME,
7 | MAXIMUM_IDLE_TIME,
8 | } = require('../constants/database');
9 |
10 | const { env } = process;
11 |
12 | const connection = new Sequelize.Sequelize(
13 | env.MYSQL_DATABASE,
14 | env.MYSQL_USER,
15 | env.MYSQL_PASSWORD,
16 | {
17 | host: env.DATABASE_HOST,
18 | dialect: env.DATABASE_DIALECT,
19 | charset: env.DATABASE_CHARSET,
20 | collate: env.DATABASE_COLLATE,
21 | pool: {
22 | max: MAXIMUM_CONNECTION_COUNT,
23 | min: MINIMUM_CONNECTION_COUNT,
24 | acquire: MAXIMUM_WAITING_TIME,
25 | idle: MAXIMUM_IDLE_TIME,
26 | },
27 | },
28 | );
29 |
30 | module.exports = connection;
31 |
--------------------------------------------------------------------------------
/client/src/presentation/components/Buttons/ReadyButton.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import { makeStyles } from '@material-ui/core/styles';
4 | import Button from '@material-ui/core/Button';
5 | import buttonStyle from './style';
6 |
7 | const useStyles = makeStyles({
8 | button: {
9 | ...buttonStyle,
10 | width: '100%',
11 | height: '3.2rem',
12 | },
13 | });
14 |
15 | const ReadyButton = ({ onClick, children }) => {
16 | const classes = useStyles();
17 | return (
18 |
21 | );
22 | };
23 |
24 | ReadyButton.propTypes = {
25 | children: PropTypes.string.isRequired,
26 | onClick: PropTypes.func.isRequired,
27 | };
28 |
29 | export default ReadyButton;
30 |
--------------------------------------------------------------------------------
/client/src/presentation/pages/Game/store/gameReducer.js:
--------------------------------------------------------------------------------
1 | import TYPES from '../../../../constants/actionTypes';
2 |
3 | const gameReducer = (state, action) => {
4 | switch (action.type) {
5 | case TYPES.SET_MOBILE_CHATTING_PANEL_VISIBILITY:
6 | return {
7 | ...state,
8 | mobileChattingPanelVisibility:
9 | action.payload.mobileChattingPanelVisibility,
10 | };
11 | case TYPES.SET_IS_PLAYER_LIST_VISIBLE:
12 | return {
13 | ...state,
14 | isPlayerListVisible: action.payload.isPlayerListVisible,
15 | };
16 | case TYPES.SET_GAME_PAGE_ROOT_HEIGHT:
17 | return {
18 | ...state,
19 | gamePageRootHeight: action.payload.gamePageRootHeight,
20 | };
21 | default:
22 | throw new Error();
23 | }
24 | };
25 |
26 | export default gameReducer;
27 |
--------------------------------------------------------------------------------
/client/src/hooks/useShiftingToWhichView.js:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 | import useIsMobile from './useIsMobile';
3 | import { MOBILE_VIEW, DESKTOP_VIEW } from '../constants/responsiveView';
4 |
5 | const useShiftingToWhichView = mobileViewBreakpoint => {
6 | const isMobile = useIsMobile(mobileViewBreakpoint);
7 | const [shiftingTo, setShiftingTo] = useState('');
8 |
9 | const isViewShiftingToMobile = currentIsMobile => {
10 | return currentIsMobile && currentIsMobile !== shiftingTo;
11 | };
12 |
13 | useEffect(() => {
14 | if (!isViewShiftingToMobile(isMobile)) {
15 | setShiftingTo(DESKTOP_VIEW);
16 | } else if (isViewShiftingToMobile(isMobile)) {
17 | setShiftingTo(MOBILE_VIEW);
18 | }
19 | }, [isMobile]);
20 | return shiftingTo;
21 | };
22 |
23 | export default useShiftingToWhichView;
24 |
--------------------------------------------------------------------------------
/client/src/presentation/components/Description.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import { makeStyles } from '@material-ui/core/styles';
4 | import Typography from '@material-ui/core/Typography';
5 |
6 | const useStyle = makeStyles({
7 | description: props => ({
8 | margin: '0 0.2rem',
9 | fontSize: props.fontSize || '1rem',
10 | whiteSpace: 'pre-line',
11 | }),
12 | });
13 |
14 | const Description = ({ content, fontSize }) => {
15 | const classes = useStyle({ fontSize });
16 | return (
17 |
18 | {content}
19 |
20 | );
21 | };
22 |
23 | Description.propTypes = {
24 | content: PropTypes.string.isRequired,
25 | fontSize: PropTypes.string.isRequired,
26 | };
27 |
28 | export default Description;
29 |
--------------------------------------------------------------------------------
/client/src/presentation/components/Buttons/ExitButton.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import styled from 'styled-components';
4 |
5 | const StyledExitButton = styled.button`
6 | width: 3rem;
7 | padding: 0;
8 | cursor: pointer;
9 | background-color: transparent;
10 | border: none;
11 | outline: none;
12 | img {
13 | width: 100%;
14 | }
15 | `;
16 |
17 | const ExitButton = ({ onClick, imageSource }) => {
18 | return (
19 |
20 |
21 |
22 | );
23 | };
24 |
25 | ExitButton.defaultProps = {
26 | onClick: () => {},
27 | };
28 |
29 | ExitButton.propTypes = {
30 | onClick: PropTypes.func,
31 | imageSource: PropTypes.string.isRequired,
32 | };
33 |
34 | export default ExitButton;
35 |
--------------------------------------------------------------------------------
/client/src/actions/game.js:
--------------------------------------------------------------------------------
1 | import TYPES from '../constants/actionTypes';
2 |
3 | const setMobileChattingPanelVisibility = mobileChattingPanelVisibility => {
4 | return {
5 | type: TYPES.SET_MOBILE_CHATTING_PANEL_VISIBILITY,
6 | payload: {
7 | mobileChattingPanelVisibility,
8 | },
9 | };
10 | };
11 |
12 | const setIsPlayerListVisible = isPlayerListVisible => {
13 | return {
14 | type: TYPES.SET_IS_PLAYER_LIST_VISIBLE,
15 | payload: {
16 | isPlayerListVisible,
17 | },
18 | };
19 | };
20 |
21 | const setGamePageRootHeight = gamePageRootHeight => {
22 | return {
23 | type: TYPES.SET_GAME_PAGE_ROOT_HEIGHT,
24 | payload: {
25 | gamePageRootHeight,
26 | },
27 | };
28 | };
29 |
30 | export default {
31 | setMobileChattingPanelVisibility,
32 | setIsPlayerListVisible,
33 | setGamePageRootHeight,
34 | };
35 |
--------------------------------------------------------------------------------
/server/service/game/eventHandlers/connectPeerHandler.js:
--------------------------------------------------------------------------------
1 | const roomController = require('../controllers/roomController');
2 | const gameController = require('../controllers/gameController');
3 | const GAME_STATUS = require('../../../constants/gameStatus');
4 |
5 | const connectPeerHandler = socket => {
6 | const { gameManager, timer } = roomController.getRoomByRoomId(socket.roomId);
7 | const connectedPlayer = gameManager.getPlayerBySocketId(socket.id);
8 | connectedPlayer.setIsConnectedToStreamer(true);
9 |
10 | if (
11 | gameManager.getStreamer() &&
12 | gameManager.checkAllConnectionsToStreamer() &&
13 | gameManager.getStatus() !== GAME_STATUS.INITIALIZING
14 | ) {
15 | /**
16 | * 연결 준비 후 정상 시작
17 | */
18 | timer.clear();
19 | gameController.prepareSet(gameManager, timer);
20 | }
21 | };
22 |
23 | module.exports = connectPeerHandler;
24 |
--------------------------------------------------------------------------------
/client/src/presentation/components/StreamerVideo.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable jsx-a11y/media-has-caption */
2 | import React, { useEffect } from 'react';
3 | import PropTypes from 'prop-types';
4 | import { makeStyles } from '@material-ui/core/styles';
5 |
6 | const useStyles = makeStyles(() => ({
7 | video: {
8 | maxWidth: '100%',
9 | height: '100%',
10 | transform: 'rotateY(180deg)',
11 | },
12 | }));
13 |
14 | const StreamerVideo = ({ stream }) => {
15 | const classes = useStyles();
16 | const ref = React.createRef();
17 |
18 | useEffect(() => {
19 | const videoElement = ref.current;
20 | videoElement.srcObject = stream;
21 | }, [stream]);
22 |
23 | return ;
24 | };
25 |
26 | StreamerVideo.propTypes = {
27 | stream: PropTypes.shape.isRequired,
28 | };
29 |
30 | export default StreamerVideo;
31 |
--------------------------------------------------------------------------------
/client/src/constants/message.js:
--------------------------------------------------------------------------------
1 | const MAIN_HOW_TO_PLAY_TITLE = 'How to Play';
2 | const MAIN_HOW_TO_PLAY_DESCRIPTION = `자신의 차례가 되면, 세 가지 단어 중에서 한 가지를 선택하고 80 초 안에 그 단어를 몸으로 표현해야 합니다. 또는 다른 누군가가 몸으로 표현할 때 포인트를 얻으려면 채팅으로 정답을 추측해서 입력해야 합니다. 랭킹에 오르기 위해서는 최대한 많은 정답을 맞히세요!`;
3 | const MAIN_SLOGAN = `"Talk is cheap. Show me the move."`;
4 |
5 | const DEFAULT_SCOREBOARD_TITLE = 'Final Score';
6 | const WAITING_FOR_STREAMER = 'Waiting For Streamer...';
7 | const GAME_END_SCOREBOARD_TITLE = '게임 종료';
8 | const ALLOW_CAMERA_MESSAGE = '카메라를 허용해주세요';
9 |
10 | const COPY_TO_CLIPBOARD_MESSAGE = 'is copied to your clipboard';
11 |
12 | const ROOM_UNAVAILABLE_MESSAGE = '방에 연결이 불가능 합니다.';
13 |
14 | export {
15 | MAIN_HOW_TO_PLAY_TITLE,
16 | MAIN_HOW_TO_PLAY_DESCRIPTION,
17 | MAIN_SLOGAN,
18 | DEFAULT_SCOREBOARD_TITLE,
19 | WAITING_FOR_STREAMER,
20 | GAME_END_SCOREBOARD_TITLE,
21 | ALLOW_CAMERA_MESSAGE,
22 | COPY_TO_CLIPBOARD_MESSAGE,
23 | ROOM_UNAVAILABLE_MESSAGE,
24 | };
25 |
--------------------------------------------------------------------------------
/client/src/presentation/containers/MobileChattingPanel.jsx:
--------------------------------------------------------------------------------
1 | import React, { useContext } from 'react';
2 | import Box from '@material-ui/core/Box';
3 | import { makeStyles } from '@material-ui/core/styles';
4 | import { ChattingWindow } from '../components';
5 | import { GlobalContext } from '../../contexts';
6 |
7 | const useStyle = makeStyles(() => ({
8 | mobileChattingPanel: {
9 | height: '100%',
10 | border: 'none',
11 | wordWrap: 'break-word',
12 | },
13 | mobileChattingWindow: {
14 | height: '100%',
15 | },
16 | }));
17 |
18 | const MobileChattingPanel = () => {
19 | const classes = useStyle();
20 | const { chattingList } = useContext(GlobalContext);
21 |
22 | return (
23 |
24 |
25 |
26 |
27 |
28 | );
29 | };
30 |
31 | export default MobileChattingPanel;
32 |
--------------------------------------------------------------------------------
/client/src/store/globalState.js:
--------------------------------------------------------------------------------
1 | const initialState = {
2 | // chatting
3 | chattingList: [],
4 | chattingDisabled: false,
5 |
6 | // player panel
7 | viewPlayerList: [],
8 |
9 | // game information
10 | gameStatus: 'waiting',
11 | currentRound: 0,
12 | currentSet: 0,
13 | currentSeconds: '', // string
14 | quiz: '',
15 | quizLength: 0,
16 |
17 | // video
18 | stream: null,
19 | videoVisibility: false,
20 |
21 | // streaming panel
22 | messageNotice: {
23 | isVisible: false,
24 | message: '',
25 | },
26 | quizCandidatesNotice: {
27 | isVisible: false,
28 | quizCandidates: [],
29 | },
30 | scoreNotice: {
31 | isVisible: false,
32 | message: '',
33 | scoreList: [],
34 | },
35 |
36 | clientManagerInitialized: false,
37 |
38 | toast: {
39 | open: false,
40 | message: '',
41 | toastType: '',
42 | },
43 |
44 | isRoomIdReceived: false,
45 | };
46 |
47 | export default initialState;
48 |
--------------------------------------------------------------------------------
/client/src/presentation/components/Buttons/SendButton.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import { makeStyles } from '@material-ui/core/styles';
4 | import Button from '@material-ui/core/Button';
5 | import buttonStyle from './style';
6 |
7 | const useStyles = makeStyles({
8 | button: {
9 | ...buttonStyle,
10 | flex: 1,
11 | marginLeft: '0.5rem',
12 | },
13 | });
14 |
15 | const SendButton = ({ onClick, children, chattingDisabled }) => {
16 | const classes = useStyles();
17 | return (
18 |
26 | );
27 | };
28 |
29 | SendButton.propTypes = {
30 | children: PropTypes.string.isRequired,
31 | onClick: PropTypes.func.isRequired,
32 | chattingDisabled: PropTypes.bool.isRequired,
33 | };
34 |
35 | export default SendButton;
36 |
--------------------------------------------------------------------------------
/server/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "server",
3 | "version": "0.0.0",
4 | "private": true,
5 | "scripts": {
6 | "start": "node ./bin/www",
7 | "test": "jest",
8 | "nodemon": "nodemon ./bin/www",
9 | "seed": "node databaseFiles/databaseModels/seeder/scripts/seedQuizzes.js"
10 | },
11 | "dependencies": {
12 | "cookie-parser": "~1.4.4",
13 | "csv-parser": "^2.3.2",
14 | "debug": "~2.6.9",
15 | "dotenv": "^8.2.0",
16 | "eslint-config-prettier": "^6.5.0",
17 | "express": "~4.16.1",
18 | "morgan": "~1.9.1",
19 | "mysql2": "^2.0.1",
20 | "sequelize": "^5.21.2",
21 | "short-uuid": "^3.1.1",
22 | "socket.io": "^2.3.0"
23 | },
24 | "devDependencies": {
25 | "eslint": "^6.6.0",
26 | "eslint-config-airbnb-base": "^14.0.0",
27 | "eslint-plugin-import": "^2.18.2",
28 | "jest": "^24.9.0",
29 | "nodemon": "^2.0.1"
30 | },
31 | "eslintConfig": {
32 | "extends": [
33 | "airbnb",
34 | "prettier"
35 | ]
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/client/src/presentation/components/Buttons/CandidateButton.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import { makeStyles } from '@material-ui/core/styles';
4 | import Button from '@material-ui/core/Button';
5 | import buttonStyle from './style';
6 |
7 | const useStyles = makeStyles({
8 | button: {
9 | ...buttonStyle,
10 | width: 'auto',
11 | height: 'auto',
12 | padding: '0.5rem 1rem',
13 | fontSize: '2rem',
14 | zIndex: '10',
15 | },
16 | });
17 |
18 | const CandidateButton = ({ onClick, children }) => {
19 | const classes = useStyles();
20 | return (
21 |
30 | );
31 | };
32 |
33 | CandidateButton.propTypes = {
34 | children: PropTypes.string.isRequired,
35 | onClick: PropTypes.func.isRequired,
36 | };
37 |
38 | export default CandidateButton;
39 |
--------------------------------------------------------------------------------
/client/src/utils/browserLocalStorage.js:
--------------------------------------------------------------------------------
1 | import { LOCALSTORAGE_NICKNAME_KEY } from '../constants/browser';
2 | import { NICKNAME_LENGTH } from '../constants/inputConstraints';
3 |
4 | const getNickname = () => {
5 | if (!localStorage) return '';
6 | return localStorage.getItem(LOCALSTORAGE_NICKNAME_KEY) || '';
7 | };
8 |
9 | const setNickname = nickname => {
10 | if (!localStorage) return;
11 | localStorage.setItem(LOCALSTORAGE_NICKNAME_KEY, nickname);
12 | };
13 |
14 | const verifyNicknameInLocalStorage = () => {
15 | const nickname = getNickname();
16 | if (!nickname) return;
17 |
18 | const trimedNickname = nickname.trim();
19 | if (!trimedNickname) {
20 | localStorage.removeItem(LOCALSTORAGE_NICKNAME_KEY);
21 | } else if (trimedNickname.length > NICKNAME_LENGTH) {
22 | const slicedNickname = trimedNickname.slice(0, NICKNAME_LENGTH);
23 | localStorage.setItem(LOCALSTORAGE_NICKNAME_KEY, slicedNickname);
24 | }
25 | };
26 |
27 | export default { getNickname, setNickname, verifyNicknameInLocalStorage };
28 |
--------------------------------------------------------------------------------
/client/src/presentation/components/Buttons/MenuButton.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import { makeStyles } from '@material-ui/core/styles';
4 | import Button from '@material-ui/core/Button';
5 | import buttonStyle from './style';
6 |
7 | const useStyles = makeStyles({
8 | button: props => ({
9 | ...buttonStyle,
10 | width: '100%',
11 | height: '5rem',
12 | fontSize: props.fontSize || '2rem',
13 | fontWeight: '600',
14 | }),
15 | });
16 |
17 | const MenuButtton = ({ onClick, children, fontSize }) => {
18 | const classes = useStyles({ fontSize });
19 | return (
20 |
23 | );
24 | };
25 |
26 | MenuButtton.defaultProps = {
27 | onClick: () => {},
28 | };
29 |
30 | MenuButtton.propTypes = {
31 | children: PropTypes.string.isRequired,
32 | fontSize: PropTypes.string.isRequired,
33 | onClick: PropTypes.func,
34 | };
35 |
36 | export default MenuButtton;
37 |
--------------------------------------------------------------------------------
/server/databaseFiles/repositories/QuizRepository.js:
--------------------------------------------------------------------------------
1 | const Sequelize = require('sequelize');
2 | const { convertSequelizeArrayData } = require('./utils');
3 | const { Quiz } = require('../databaseModels');
4 | const { QUIZ_CANDIDATES_COUNT } = require('../../constants/gameRule');
5 |
6 | class QuizRepository {
7 | constructor(model = Quiz) {
8 | this.model = model;
9 | }
10 |
11 | /**
12 | * quiz에 필요한 값은
13 | * word(필수), selected_count, hits
14 | *
15 | * @param {quiz} quiz
16 | */
17 | async insertQuiz(quiz) {
18 | await this.model.create(quiz);
19 | }
20 |
21 | async insertQuizzes(quizzes) {
22 | await this.model.bulkCreate(quizzes);
23 | }
24 |
25 | async findRandomQuizzes(size = QUIZ_CANDIDATES_COUNT) {
26 | const randomQuizzes = await this.model.findAll({
27 | order: Sequelize.literal('rand()'),
28 | limit: size,
29 | });
30 | const convertedData = convertSequelizeArrayData(randomQuizzes);
31 | return convertedData;
32 | }
33 | }
34 |
35 | module.exports = QuizRepository;
36 |
--------------------------------------------------------------------------------
/server/service/game/eventHandlers/sendReadyHandler.js:
--------------------------------------------------------------------------------
1 | const { io } = require('../../io');
2 |
3 | const gameController = require('../controllers/gameController');
4 | const roomController = require('../controllers/roomController');
5 | const { MIN_PLAYER_COUNT } = require('../../../constants/gameRule');
6 | const { SEND_READY } = require('../../../constants/event');
7 |
8 | const sendReadyHandler = (socket, { isReady }) => {
9 | const { gameManager, timer } = roomController.getRoomByRoomId(socket.roomId);
10 | const playerCount = gameManager.getPlayers().length;
11 | const player = gameManager.getPlayerBySocketId(socket.id);
12 | player.setIsReady(isReady);
13 |
14 | io.in(gameManager.getRoomId()).emit(SEND_READY, {
15 | socketId: player.getSocketId(),
16 | isReady: player.getIsReady(),
17 | });
18 |
19 | if (
20 | gameManager.checkAllPlayersAreReady() &&
21 | playerCount >= MIN_PLAYER_COUNT
22 | ) {
23 | gameController.prepareGame(gameManager, timer);
24 | }
25 | };
26 |
27 | module.exports = sendReadyHandler;
28 |
--------------------------------------------------------------------------------
/client/src/constants/toast.js:
--------------------------------------------------------------------------------
1 | import successIcon from '../assets/success.png';
2 | import errorIcon from '../assets/error.png';
3 | import informationIcon from '../assets/information.png';
4 | import warningIcon from '../assets/warning.png';
5 |
6 | const SUCCESS = 'success';
7 | const WARNING = 'warning';
8 | const ERROR = 'error';
9 | const INFORMATION = 'info';
10 |
11 | const TOAST_TYPES = {
12 | SUCCESS,
13 | WARNING,
14 | ERROR,
15 | INFORMATION,
16 | };
17 |
18 | const TOAST_ICONS = {
19 | [SUCCESS]: successIcon,
20 | [WARNING]: warningIcon,
21 | [ERROR]: errorIcon,
22 | [INFORMATION]: informationIcon,
23 | };
24 |
25 | const TOAST_POSITION = {
26 | vertical: 'top',
27 | horizontal: 'center',
28 | };
29 |
30 | const TOAST_TIME = 3000;
31 |
32 | const TOAST_MESSAGE = {
33 | INACTIVE_PLAYER_BAN: '장기간 READY를 하지 않아 메인페이지로 이동합니다.',
34 | INACTIVE_PLAYER_WARNING: banTime =>
35 | `${banTime}초 뒤 메인페이지로 이동합니다. READY 버튼을 눌러주세요!`,
36 | };
37 |
38 | export { TOAST_TYPES, TOAST_ICONS, TOAST_POSITION, TOAST_TIME, TOAST_MESSAGE };
39 |
--------------------------------------------------------------------------------
/client/src/presentation/components/Buttons/MoreButton.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import { makeStyles } from '@material-ui/core/styles';
4 | import Button from '@material-ui/core/Button';
5 | import buttonStyle from './style';
6 |
7 | const useStyles = makeStyles({
8 | button: {
9 | ...buttonStyle,
10 | width: '20rem',
11 | height: '3.2rem',
12 | },
13 | });
14 |
15 | const MoreButton = ({ onClick, children, isMoreButtomVisibile }) => {
16 | const classes = useStyles();
17 | return (
18 | <>
19 | {isMoreButtomVisibile ? (
20 |
27 | ) : (
28 | ''
29 | )}
30 | >
31 | );
32 | };
33 |
34 | MoreButton.propTypes = {
35 | children: PropTypes.string.isRequired,
36 | onClick: PropTypes.func.isRequired,
37 | isMoreButtomVisibile: PropTypes.bool.isRequired,
38 | };
39 |
40 | export default MoreButton;
41 |
--------------------------------------------------------------------------------
/server/service/game/registerGameEvents.js:
--------------------------------------------------------------------------------
1 | const {
2 | matchHandler,
3 | sendReadyHandler,
4 | sendChattingMessageHandler,
5 | disconnectingHandler,
6 | askSocketIdHandler,
7 | connectPeerHandler,
8 | selectQuizHandler,
9 | } = require('./eventHandlers');
10 | const EVENT = require('../../constants/event');
11 |
12 | module.exports = socket => {
13 | /**
14 | * 게임 전
15 | */
16 | socket.on(EVENT.ASK_SOCKET_ID, askSocketIdHandler.bind(null, socket));
17 | socket.on(EVENT.MATCH, matchHandler.bind(null, socket));
18 | socket.on(EVENT.SEND_READY, sendReadyHandler.bind(null, socket));
19 | /**
20 | * 세트준비
21 | */
22 | socket.on(EVENT.CONNECT_PEER, connectPeerHandler.bind(null, socket));
23 | socket.on(EVENT.SELECT_QUIZ, selectQuizHandler.bind(null, socket));
24 | /**
25 | * 채팅
26 | */
27 | socket.on(
28 | EVENT.SEND_CHATTING_MESSAGE,
29 | sendChattingMessageHandler.bind(null, socket),
30 | );
31 |
32 | /**
33 | * socket disconnect 관리
34 | */
35 | socket.on(EVENT.DISCONNECTING, disconnectingHandler.bind(null, socket));
36 | };
37 |
--------------------------------------------------------------------------------
/client/src/constants/styleColors.js:
--------------------------------------------------------------------------------
1 | const PURE_WHITE_COLOR = '#FFFFFF';
2 | const BASE_WHITE_COLOR = '#EFEFF1';
3 | const BASE_BLACK_COLOR = '#000000';
4 | const THEME_COLOR = '#5A96FF';
5 | const THEME_HOVER_COLOR = '#3C83FF';
6 | const THEME_BORDER_COLOR = '#005DFD';
7 | const PANEL_COLOR = '#FFFFFF';
8 | const BACKGROUND_COLOR = '#E5F1FF';
9 | const STREAMER_BORDER_COLOR = '#E74C3C';
10 | const SKELETON_COMPONENT_COLOR = '#DFE4EA';
11 | const SKELETON_HIGHLIGHT_COLOR = '#F1F2F6';
12 | const LOADING_DOT_COLOR = '#636e72';
13 | const INFORMATION_COLOR = '#FF0000';
14 | const BASE_BLACK_COLOR_TRANSLUCENT = '#00000080';
15 | const BASE_WHITE_COLOR_TRANSLUCENT = '#FFFFFF99';
16 |
17 | export default {
18 | PURE_WHITE_COLOR,
19 | BASE_WHITE_COLOR,
20 | BASE_BLACK_COLOR,
21 | THEME_COLOR,
22 | THEME_HOVER_COLOR,
23 | THEME_BORDER_COLOR,
24 | PANEL_COLOR,
25 | BACKGROUND_COLOR,
26 | STREAMER_BORDER_COLOR,
27 | SKELETON_COMPONENT_COLOR,
28 | SKELETON_HIGHLIGHT_COLOR,
29 | LOADING_DOT_COLOR,
30 | INFORMATION_COLOR,
31 | BASE_BLACK_COLOR_TRANSLUCENT,
32 | BASE_WHITE_COLOR_TRANSLUCENT,
33 | };
34 |
--------------------------------------------------------------------------------
/server/databaseFiles/databaseModels/seeder/index.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs');
2 | const csv = require('csv-parser');
3 | const path = require('path');
4 | const { Quiz, Ranking } = require('../');
5 |
6 | const initializeQuizzes = async () => {
7 | const quizzes = [];
8 | fs.createReadStream(path.join(__dirname, 'quizzes.csv'))
9 | .pipe(csv())
10 | .on('data', data => {
11 | quizzes.push(data);
12 | })
13 | .on('end', async () => {
14 | try {
15 | await Quiz.bulkCreate(quizzes);
16 | } catch (error) {
17 | console.error(error);
18 | }
19 | });
20 | };
21 |
22 | const initializeRankings = async () => {
23 | const rankings = [];
24 | fs.createReadStream(path.join(__dirname, 'ranking.csv'))
25 | .pipe(csv())
26 | .on('data', data => {
27 | rankings.push(data);
28 | })
29 | .on('end', async () => {
30 | try {
31 | await Ranking.bulkCreate(rankings);
32 | } catch (error) {
33 | console.error(error);
34 | }
35 | });
36 | };
37 |
38 | module.exports = { initializeQuizzes, initializeRankings };
39 |
--------------------------------------------------------------------------------
/client/src/presentation/components/Timer.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { makeStyles } from '@material-ui/core/styles';
3 | import PropTypes from 'prop-types';
4 | import timerImageSource from '../../assets/timer.png';
5 | import styleColors from '../../constants/styleColors';
6 |
7 | const useStyles = makeStyles(() => ({
8 | timer: {
9 | fontSize: '3rem',
10 | fontWeight: 'bold',
11 | color: styleColors.PURE_WHITE_COLOR,
12 | },
13 | timerImage: {
14 | width: '4rem',
15 | verticalAlign: 'middle',
16 | marginRight: '0.5rem',
17 | },
18 | timerText: {
19 | verticalAlign: 'middle',
20 | fontWeight: 400,
21 | },
22 | }));
23 |
24 | const Timer = ({ currentSeconds }) => {
25 | const classes = useStyles();
26 |
27 | return (
28 |
29 |

30 |
{currentSeconds}
31 |
32 | );
33 | };
34 |
35 | Timer.propTypes = {
36 | currentSeconds: PropTypes.string.isRequired,
37 | };
38 |
39 | export default Timer;
40 |
--------------------------------------------------------------------------------
/client/src/constants/actionTypes.js:
--------------------------------------------------------------------------------
1 | const globalTypes = {
2 | RESET: 'reset',
3 | ADD_CHATTING: 'addChatting',
4 | SET_QUIZ: 'setQuiz',
5 | SET_TOAST: 'setToast',
6 | SET_STREAM: 'setStream',
7 | SET_GAME_STATUS: 'setGameStatus',
8 | SET_CURRENT_SET: 'setCurrentSet',
9 | SET_SCORE_NOTICE: 'setScoreNotice',
10 | SET_CURRENT_ROUND: 'setCurrentRound',
11 | SET_MESSAGE_NOTICE: 'setMessageNotice',
12 | SET_CURRENT_SECONDS: 'setCurrentSeconds',
13 | SET_VIEW_PLAYER_LIST: 'setViewPlayerList',
14 | SET_VIDEO_VISIBILITY: 'setVideoVisibility',
15 | SET_CHATTING_DISABLED: 'setChattingDisabled',
16 | SET_QUIZ_CANDIDATES_NOTICE: 'setQuizCandidatesNotice',
17 | CLEAR_WINDOW: 'clearWindow',
18 | SET_CLIENT_MANAGER_INITIALIZED: 'setClientManagerInitialized',
19 | SET_IS_ROOM_ID_RECEIVED: 'setIsRoomIdReceived',
20 | };
21 |
22 | const gameTypes = {
23 | SET_MOBILE_CHATTING_PANEL_VISIBILITY: 'setMobileChattingPanelVisibility',
24 | SET_IS_PLAYER_LIST_VISIBLE: 'setIsPlayerListVisible',
25 | SET_GAME_PAGE_ROOT_HEIGHT: 'setGamePageRootHeight',
26 | };
27 |
28 | export default { ...globalTypes, ...gameTypes };
29 |
--------------------------------------------------------------------------------
/client/src/presentation/components/RankingRow.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'styled-components';
3 | import PropTypes from 'prop-types';
4 | import styleColors from '../../constants/styleColors';
5 |
6 | const Row = styled.div`
7 | height: 4rem;
8 | display: flex;
9 | align-items: center;
10 | background-color: ${styleColors.THEME_COLOR};
11 | color: ${styleColors.BASE_WHITE_COLOR};
12 | margin-bottom: ${props => (props.isHeader ? '0.4rem' : '0.2rem')};
13 | `;
14 |
15 | const Cell = styled.div`
16 | flex: 1;
17 | text-align: center;
18 | padding: 0.5rem 1rem;
19 | font-size: 2rem;
20 | `;
21 |
22 | const RankingRow = ({ rank, nickname, score, isHeader }) => {
23 | return (
24 |
25 | | {rank} |
26 | {nickname} |
27 | {score} |
28 |
29 | );
30 | };
31 |
32 | RankingRow.propTypes = {
33 | rank: PropTypes.string.isRequired,
34 | nickname: PropTypes.string.isRequired,
35 | score: PropTypes.string.isRequired,
36 | isHeader: PropTypes.bool.isRequired,
37 | };
38 |
39 | export default RankingRow;
40 |
--------------------------------------------------------------------------------
/server/constants/event.js:
--------------------------------------------------------------------------------
1 | const EVENT = {
2 | SEND_ROOMID: 'sendRoomId',
3 | SEND_CURRENT_SECONDS: 'sendCurrentSeconds',
4 | ASSIGN_STREAMER: 'assignStreamer',
5 | ASSIGN_VIEWER: 'assignViewer',
6 | END_SET: 'endSet',
7 | CLEAR_WINDOW: 'clearWindow',
8 | START_SET: 'startSet',
9 | PREPARE_SET: 'prepareSet',
10 | SEND_READY: 'sendReady',
11 | START_GAME: 'startGame',
12 | END_GAME: 'endGame',
13 | RESET_GAME: 'resetGame',
14 | SEND_SOCKET_ID: 'sendSocketId',
15 | ASK_SOCKET_ID: 'askSocketId',
16 | MATCH: 'match',
17 | CONNECT_PEER: 'connectPeer',
18 | SELECT_QUIZ: 'selectQuiz',
19 | SEND_CHATTING_MESSAGE: 'sendChattingMessage',
20 | DISCONNECTING: 'disconnecting',
21 | START_CHATTING: 'startChatting',
22 | SEND_PLAYERS: 'sendPlayers',
23 | SEND_NEW_PLAYER: 'sendNewPlayer',
24 | SEND_LEFT_PLAYER: 'sendLeftPlayer',
25 | CORRECT_ANSWER: 'correctAnswer',
26 | UPDATE_PROFILE: 'updateProfile',
27 | SEND_DESCRIPTION: 'sendDescription',
28 | SEND_ICE_CANDIDATE: 'sendIceCandidate',
29 | CONNECTION: 'connection',
30 | ROOM_UNAVAILABLE: 'roomUnavailable',
31 | };
32 |
33 | module.exports = EVENT;
34 |
--------------------------------------------------------------------------------
/client/src/presentation/components/Buttons/ShareUrlButton.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/forbid-prop-types */
2 | import React from 'react';
3 | import PropTypes from 'prop-types';
4 | import { makeStyles } from '@material-ui/core/styles';
5 | import Button from '@material-ui/core/Button';
6 | import buttonStyle from './style';
7 | import shareButtonImageSource from '../../../assets/share.png';
8 |
9 | const useStyles = makeStyles({
10 | button: {
11 | ...buttonStyle,
12 | width: '100%',
13 | height: '3.2rem',
14 | '& img': {
15 | width: '2rem',
16 | },
17 | },
18 | });
19 |
20 | const ShareUrlButton = ({ onClick, classNames }) => {
21 | const classes = useStyles();
22 | return (
23 |
29 | );
30 | };
31 |
32 | ShareUrlButton.defaultProps = {
33 | classNames: [],
34 | };
35 |
36 | ShareUrlButton.propTypes = {
37 | onClick: PropTypes.func.isRequired,
38 | classNames: PropTypes.array,
39 | };
40 |
41 | export default ShareUrlButton;
42 |
--------------------------------------------------------------------------------
/client/src/presentation/components/index.js:
--------------------------------------------------------------------------------
1 | export { default as Logo } from './Logo';
2 | export { default as TextInput } from './TextInput';
3 | export { default as Title } from './Title';
4 | export { default as Description } from './Description';
5 | export { default as Timer } from './Timer';
6 | export { default as QuizDisplay } from './QuizDisplay';
7 | export { default as StreamerVideo } from './StreamerVideo';
8 | export { default as ChattingWindow } from './ChattingWindow';
9 | export { default as InputWindow } from './InputWindow';
10 | export { default as PlayerProfile } from './PlayerProfile';
11 | export { default as Slogan } from './Slogan';
12 | export { default as RankingRow } from './RankingRow';
13 | export { default as RankPodium } from './RankPodium';
14 | export { default as GameMessageBox } from './GameMessageBox';
15 | export { default as CenterTimer } from './CenterTimer';
16 | export { default as ScoreBoardScoreRow } from './ScoreBoardScoreRow';
17 | export { default as Toast } from './Toast';
18 | export {
19 | MenuButton,
20 | SendButton,
21 | ReadyButton,
22 | ExitButton,
23 | CandidateButton,
24 | ShareUrlButton,
25 | } from './Buttons';
26 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3'
2 |
3 | services:
4 | nginx:
5 | image: nginx:1.16.1
6 | container_name: nginx
7 | ports:
8 | - '80:80'
9 | - '443:443'
10 | restart: always
11 | volumes:
12 | - /etc/letsencrypt:/etc/letsencrypt
13 | - ./nginx/default.conf.template:/etc/nginx/conf.d/default.conf.template
14 | env_file:
15 | - ./.env
16 | command: /bin/bash -c "envsubst < /etc/nginx/conf.d/default.conf.template > /etc/nginx/conf.d/default.conf && exec nginx -g 'daemon off;'"
17 |
18 | trycatch:
19 | image: node:12.13.0
20 | container_name: trycatch
21 | volumes:
22 | - ./:/app/
23 | working_dir: /app/server/
24 | user: root
25 | command: bash -c "npm install yarn pm2 -g && yarn install && yarn workspace client build && pm2-runtime bin/www"
26 | restart: always
27 | env_file:
28 | - ./.env
29 |
30 | mysql:
31 | image: mysql:5.7
32 | container_name: mysql
33 | volumes:
34 | - ./mysql.cnf:/etc/mysql/conf.d/custom.cnf
35 | - ./mysql/data:/var/lib/mysql
36 | restart: always
37 | env_file:
38 | - ./.env
39 | ports:
40 | - '3306:3306'
41 |
--------------------------------------------------------------------------------
/nginx/default.conf.template:
--------------------------------------------------------------------------------
1 | upstream node_server {
2 | server trycatch:3001;
3 | }
4 |
5 | server {
6 | listen 80;
7 | server_name ${NGINX_SERVER_NAME};
8 |
9 | location / {
10 | return 301 https://${NGINX_HOST_REQUEST_URI};
11 | }
12 | }
13 |
14 | server {
15 | listen 443 ssl;
16 | ssl_certificate /etc/letsencrypt/live/${NGINX_SERVER_NAME}/fullchain.pem;
17 | ssl_certificate_key /etc/letsencrypt/live/${NGINX_SERVER_NAME}/privkey.pem;
18 | server_name ${NGINX_SERVER_NAME};
19 |
20 | location / {
21 | proxy_pass http://node_server;
22 | proxy_next_upstream error timeout invalid_header http_500 http_502 http_503 http_504;
23 | proxy_http_version 1.1;
24 | proxy_set_header Upgrade ${HTTP_UPGRADE};
25 | proxy_set_header Connection "upgrade";
26 | proxy_set_header Host ${HOST};
27 | }
28 |
29 | gzip on;
30 | gzip_comp_level 2;
31 | gzip_proxied any;
32 | gzip_min_length 1000;
33 | gzip_disable "MSIE [1-6]\."
34 | gzip_types text/plain text/css application/json application/x-javascript text/xml application/xml application/xml+rss text/javascript;
35 | }
--------------------------------------------------------------------------------
/server/routes/api.js:
--------------------------------------------------------------------------------
1 | const express = require('express');
2 | const { RankingRepository } = require('../databaseFiles/repositories');
3 | const { PATH, ERROR_500_DATABASE } = require('../constants/api');
4 | const { getCurrentTime } = require('../utils/getCurrentTime');
5 |
6 | const router = express.Router();
7 | const rankingRepository = new RankingRepository();
8 |
9 | router.get(PATH.RANKING, async (req, res) => {
10 | const offset = +req.query.offset || 0;
11 | try {
12 | const rankings = await rankingRepository.getRankingsBeforeDateTime(
13 | offset,
14 | req.query.datetime,
15 | );
16 | res.status(200).send(rankings);
17 | } catch (error) {
18 | console.error(error);
19 | res.status(500).send(ERROR_500_DATABASE);
20 | }
21 | });
22 |
23 | router.get(PATH.RANKING_INFORMATION, async (req, res) => {
24 | try {
25 | const currentTime = getCurrentTime();
26 | const rankingCount = await rankingRepository.getRankingCount();
27 |
28 | res.status(200).send({ rankingCount, currentTime });
29 | } catch (error) {
30 | console.error(error);
31 | res.status(500).send(ERROR_500_DATABASE);
32 | }
33 | });
34 |
35 | module.exports = router;
36 |
--------------------------------------------------------------------------------
/client/src/App.jsx:
--------------------------------------------------------------------------------
1 | import React, { useReducer } from 'react';
2 | import { Route, BrowserRouter as Router } from 'react-router-dom';
3 | import { MuiThemeProvider, createMuiTheme } from '@material-ui/core';
4 | import { MainPage, Game, Ranking } from './presentation/pages';
5 | import { GlobalContext, DispatchContext } from './contexts';
6 | import { globalState, globalReducer } from './store';
7 |
8 | const App = () => {
9 | const theme = createMuiTheme({
10 | typography: { fontFamily: 'CookieRunOTF-Bold' },
11 | });
12 |
13 | const [state, dispatch] = useReducer(globalReducer, globalState);
14 |
15 | return (
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 | );
29 | };
30 |
31 | export default App;
32 |
--------------------------------------------------------------------------------
/client/src/presentation/containers/WordCandidates.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import Container from '@material-ui/core/Container';
4 | import { makeStyles } from '@material-ui/core/styles';
5 | import { CandidateButton } from '../components';
6 |
7 | const useStyles = makeStyles(() => ({
8 | candidateContainer: {
9 | display: 'flex',
10 | justifyContent: 'space-evenly',
11 | alignItems: 'center',
12 | position: 'absolute',
13 | bottom: '3rem',
14 | left: 0,
15 | right: 0,
16 | },
17 | }));
18 |
19 | const WordCandidates = ({ words, onClick }) => {
20 | const wordCandidates = words;
21 | const classes = useStyles();
22 | return (
23 |
24 | {wordCandidates.map(wordCandidate => {
25 | return (
26 |
27 | {wordCandidate}
28 |
29 | );
30 | })}
31 |
32 | );
33 | };
34 |
35 | WordCandidates.propTypes = {
36 | words: PropTypes.arrayOf.isRequired,
37 | onClick: PropTypes.func.isRequired,
38 | };
39 |
40 | export default WordCandidates;
41 |
--------------------------------------------------------------------------------
/client/src/presentation/components/ChattingRow.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import styled from 'styled-components';
4 | import Message from './Message';
5 | import Nickname from './Nickname';
6 | import styleColors from '../../constants/styleColors';
7 | import { DEFAULT_NICKNAME } from '../../constants/chatting';
8 |
9 | const ChattingRowWrapper = styled.li`
10 | margin-top: 0.5rem;
11 | margin-bottom: 0.5rem;
12 | background-color: ${styleColors.BASE_WHITE_COLOR_TRANSLUCENT};
13 | padding: 0.4rem;
14 | border-radius: 0.3rem;
15 | `;
16 |
17 | const ChattingRow = ({ nickname, nicknameColor, message }) => {
18 | const newNickname = nickname ? `${nickname} : ` : `${DEFAULT_NICKNAME} : `;
19 | return (
20 |
21 | {newNickname}
22 | {message}
23 |
24 | );
25 | };
26 |
27 | ChattingRow.defaultProps = {
28 | nickname: '',
29 | nicknameColor: '',
30 | };
31 |
32 | ChattingRow.propTypes = {
33 | nickname: PropTypes.string,
34 | nicknameColor: PropTypes.string,
35 | message: PropTypes.string.isRequired,
36 | };
37 |
38 | export default ChattingRow;
39 |
--------------------------------------------------------------------------------
/client/src/__test__/Game.test.js:
--------------------------------------------------------------------------------
1 | import React, { useReducer } from 'react';
2 | import { cleanup, render } from '@testing-library/react';
3 | import '@testing-library/jest-dom/extend-expect';
4 | import { BrowserRouter as Router } from 'react-router-dom';
5 | import Game from '../presentation/pages/Game';
6 | import { GlobalContext, DispatchContext } from '../contexts';
7 | import { globalState, globalReducer } from '../store';
8 |
9 | afterEach(cleanup);
10 |
11 | const Test = () => {
12 | const [state, dispatch] = useReducer(globalReducer, globalState);
13 | return (
14 |
15 |
16 |
17 |
21 |
22 |
23 |
24 | );
25 | };
26 |
27 | it('Ready 버튼이 나타난다', () => {
28 | const { queryByText } = render();
29 | expect(queryByText('Ready')).toBeInTheDocument();
30 | });
31 |
32 | it('Send 버튼이 나타난다', () => {
33 | const { queryByText } = render();
34 | expect(queryByText('Send')).toBeInTheDocument();
35 | });
36 |
--------------------------------------------------------------------------------
/client/src/constants/events.js:
--------------------------------------------------------------------------------
1 | const EVENTS = {
2 | // GameManager
3 | MATCH: 'match',
4 | SEND_PLAYERS: 'sendPlayers',
5 | SEND_NEW_PLAYER: 'sendNewPlayer',
6 | SEND_READY: 'sendReady',
7 | START_GAME: 'startGame',
8 | PREPARE_SET: 'prepareSet',
9 | SEND_CURRENT_SECONDS: 'sendCurrentSeconds',
10 | START_SET: 'startSet',
11 | CORRECT_ANSWER: 'correctAnswer',
12 | END_SET: 'endSet',
13 | DISCONNECT: 'disconnect',
14 | SELECT_QUIZ: 'selectQuiz',
15 | UPDATE_PROFILE: 'updateProfile',
16 | // StreamerManager
17 | ASSIGN_STREAMER: 'assignStreamer',
18 | ASSING_VIEWER: 'assignViewer',
19 | SEND_ICE_CANDIDATE: 'sendIceCandidate',
20 | SEND_DESCRIPTION: 'sendDescription',
21 | CONNECT_PEER: 'connectPeer',
22 | // ClientManager
23 | SEND_SOCKET_ID: 'sendSocketId',
24 | SEND_LEFT_PLAYER: 'sendLeftPlayer',
25 | END_GAME: 'endGame',
26 | ASK_SOCKET_ID: 'askSocketId',
27 | ROOM_UNAVAILABLE: 'roomUnavailable',
28 | // ChattingManager
29 | SEND_CHATTING_MESSAGE: 'sendChattingMessage',
30 | START_CHATTING: 'startChatting',
31 | SEND_ROOMID: 'sendRoomId',
32 | // DOM events
33 | RESIZE: 'resize',
34 | POPSTATE: 'popstate',
35 | // 밑의 이벤트 맞는 곳으로 이동 필요
36 | RESET_GAME: 'resetGame',
37 | CLEAR_WINDOW: 'clearWindow',
38 | };
39 |
40 | export default EVENTS;
41 |
--------------------------------------------------------------------------------
/server/service/game/eventHandlers/selectQuizHandler.js:
--------------------------------------------------------------------------------
1 | const roomController = require('../controllers/roomController');
2 | const gameController = require('../controllers/gameController');
3 | const { INITIALIZING } = require('../../../constants/gameStatus');
4 | const { DEFAULT_QUIZ } = require('../../../constants/gameRule');
5 |
6 | const isGameInitializing = status => {
7 | return status === INITIALIZING;
8 | };
9 |
10 | const quizNotSelected = quiz => {
11 | return quiz === DEFAULT_QUIZ;
12 | };
13 |
14 | const isQuizInQuizCandidates = (quizCandidates, quiz) => {
15 | return quizCandidates.find(quizCandidate => {
16 | return quizCandidate === quiz;
17 | });
18 | };
19 |
20 | const selectQuizHandler = (socket, { quiz }) => {
21 | const { gameManager, timer } = roomController.getRoomByRoomId(socket.roomId);
22 | /**
23 | * 전송자가 스트리머인지,
24 | * 게임의 상태가 단어 선택 단계인지,
25 | * 퀴즈의 단어가 선택됐는지,
26 | * 마지막으로 퀴즈의 단어가 목록에 있는지 확인하는 로직
27 | */
28 | if (
29 | gameManager.isStreamer(socket.id) &&
30 | isGameInitializing(gameManager.getStatus()) &&
31 | quizNotSelected(gameManager.getQuiz()) &&
32 | isQuizInQuizCandidates(gameManager.getQuizCandidates(), quiz)
33 | ) {
34 | gameController.startSet(gameManager, timer, quiz);
35 | }
36 | };
37 |
38 | module.exports = selectQuizHandler;
39 |
--------------------------------------------------------------------------------
/client/src/presentation/components/ToastContent.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import styled from 'styled-components';
4 | import { Box } from '@material-ui/core';
5 | import { makeStyles } from '@material-ui/core/styles';
6 | import useMediaQuery from '@material-ui/core/useMediaQuery';
7 |
8 | const IconImage = styled.img`
9 | max-width: 2rem;
10 | height: auto;
11 | margin-right: 1rem;
12 | `;
13 |
14 | const useStyles = makeStyles({
15 | toastContent: props => ({
16 | display: 'flex',
17 | height: 'auto',
18 | alignItems: 'center',
19 | padding: (() => {
20 | return props.matches ? '0.5rem' : '0';
21 | })(),
22 | maxWidth: (() => {
23 | return props.matches ? '60rem' : '28rem';
24 | })(),
25 | }),
26 | message: {
27 | width: '100%',
28 | fontSize: '1.6rem',
29 | },
30 | });
31 |
32 | const ToastContent = ({ icon, message }) => {
33 | const matches = useMediaQuery('(min-width:600px)');
34 | const classes = useStyles({ matches, window });
35 | return (
36 |
37 |
38 | {message}
39 |
40 | );
41 | };
42 |
43 | ToastContent.propTypes = {
44 | icon: PropTypes.string.isRequired,
45 | message: PropTypes.string.isRequired,
46 | };
47 |
48 | export default ToastContent;
49 |
--------------------------------------------------------------------------------
/client/config/jest/fileTransform.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const path = require('path');
4 | const camelcase = require('camelcase');
5 |
6 | // This is a custom Jest transformer turning file imports into filenames.
7 | // http://facebook.github.io/jest/docs/en/webpack.html
8 |
9 | module.exports = {
10 | process(src, filename) {
11 | const assetFilename = JSON.stringify(path.basename(filename));
12 |
13 | if (filename.match(/\.svg$/)) {
14 | // Based on how SVGR generates a component name:
15 | // https://github.com/smooth-code/svgr/blob/01b194cf967347d43d4cbe6b434404731b87cf27/packages/core/src/state.js#L6
16 | const pascalCaseFilename = camelcase(path.parse(filename).name, {
17 | pascalCase: true,
18 | });
19 | const componentName = `Svg${pascalCaseFilename}`;
20 | return `const React = require('react');
21 | module.exports = {
22 | __esModule: true,
23 | default: ${assetFilename},
24 | ReactComponent: React.forwardRef(function ${componentName}(props, ref) {
25 | return {
26 | $$typeof: Symbol.for('react.element'),
27 | type: 'svg',
28 | ref: ref,
29 | key: null,
30 | props: Object.assign({}, props, {
31 | children: ${assetFilename}
32 | })
33 | };
34 | }),
35 | };`;
36 | }
37 |
38 | return `module.exports = ${assetFilename};`;
39 | },
40 | };
41 |
--------------------------------------------------------------------------------
/client/src/presentation/components/QuizDisplay.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { makeStyles } from '@material-ui/core/styles';
3 | import PropTypes from 'prop-types';
4 | import UnderlinedLetter from './UnderlinedLetter';
5 | import UnderlinedSpace from './UnderlinedSpace';
6 | import styleColors from '../../constants/styleColors';
7 |
8 | const QuizDisplay = ({ quiz, quizLength }) => {
9 | const useStyles = makeStyles(() => ({
10 | quizDisplay: {
11 | '& > *': {
12 | marginRight: '0.5rem',
13 | color: styleColors.PURE_WHITE_COLOR,
14 | },
15 | },
16 | }));
17 | const classes = useStyles();
18 | const quizToDisplay = typeof quiz === 'undefined' ? '' : quiz;
19 | const letters =
20 | quizToDisplay !== ''
21 | ? quizToDisplay.split()
22 | : new Array(quizLength).fill(' ');
23 |
24 | return (
25 |
26 | {quizToDisplay === ''
27 | ? letters.map((letter, index) => {
28 | const key = `${letter}${index}`;
29 | return ;
30 | })
31 | : letters.map((letter, index) => {
32 | const key = `${letter}${index}`;
33 | return ;
34 | })}
35 |
36 | );
37 | };
38 |
39 | QuizDisplay.propTypes = {
40 | quiz: PropTypes.string.isRequired,
41 | quizLength: PropTypes.number.isRequired,
42 | };
43 |
44 | export default QuizDisplay;
45 |
--------------------------------------------------------------------------------
/client/src/presentation/containers/Introduction.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { makeStyles } from '@material-ui/core/styles';
3 | import Container from '@material-ui/core/Container';
4 | import { Title, Description, Slogan } from '../components';
5 | import {
6 | MAIN_HOW_TO_PLAY_TITLE,
7 | MAIN_HOW_TO_PLAY_DESCRIPTION,
8 | MAIN_SLOGAN,
9 | } from '../../constants/message';
10 | import styleColors from '../../constants/styleColors';
11 |
12 | const useStyle = makeStyles({
13 | menu: {
14 | backgroundColor: styleColors.PURE_WHITE_COLOR,
15 | width: '100%',
16 | padding: '2rem',
17 | border: `0.3rem solid ${styleColors.THEME_COLOR}`,
18 | display: 'flex',
19 | flexDirection: 'column',
20 | alignItems: 'center',
21 | borderRadius: 5,
22 | boxShadow:
23 | '0px 2px 1px -1px rgba(0,0,0,0.2), 0px 1px 1px 0px rgba(0,0,0,0.14), 0px 1px 3px 0px rgba(0,0,0,0.12)',
24 | },
25 | });
26 |
27 | /**
28 | * font-size 고정 필요 rem 사용하지 말것
29 | */
30 | const titleFontSize = '35px';
31 | const descriptionFontSize = '13px';
32 |
33 | const Introduction = () => {
34 | const classes = useStyle();
35 | return (
36 |
37 |
38 |
42 |
43 |
44 |
45 | );
46 | };
47 |
48 | export default Introduction;
49 |
--------------------------------------------------------------------------------
/client/src/presentation/components/MessageInput.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import { makeStyles } from '@material-ui/core/styles';
4 | import Input from '@material-ui/core/Input';
5 | import styleColors from '../../constants/styleColors';
6 | import { DEFAULT_TEXT_INPUT_MAX_LENGTH } from '../../constants/inputConstraints';
7 | import { CHATTING_INPUT_PLACEHOLER } from '../../constants/chatting';
8 |
9 | const useStyles = makeStyles({
10 | input: {
11 | color: styleColors.BASE_BLACK_COLOR,
12 | fontSize: '1.5rem ',
13 | flex: 8,
14 | },
15 | });
16 | const MessageInput = ({
17 | value,
18 | onChange,
19 | onKeyPress,
20 | chattingDisabled,
21 | maxLength,
22 | }) => {
23 | const classes = useStyles();
24 | return (
25 |
37 | );
38 | };
39 |
40 | MessageInput.defaultProps = {
41 | maxLength: 0,
42 | };
43 |
44 | MessageInput.propTypes = {
45 | value: PropTypes.string.isRequired,
46 | onChange: PropTypes.func.isRequired,
47 | onKeyPress: PropTypes.func.isRequired,
48 | chattingDisabled: PropTypes.bool.isRequired,
49 | maxLength: PropTypes.number,
50 | };
51 |
52 | export default MessageInput;
53 |
--------------------------------------------------------------------------------
/client/scripts/test.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | // Do this as the first thing so that any code reading it knows the right env.
4 | process.env.BABEL_ENV = 'test';
5 | process.env.NODE_ENV = 'test';
6 | process.env.PUBLIC_URL = '';
7 |
8 | // Makes the script crash on unhandled rejections instead of silently
9 | // ignoring them. In the future, promise rejections that are not handled will
10 | // terminate the Node.js process with a non-zero exit code.
11 | process.on('unhandledRejection', err => {
12 | throw err;
13 | });
14 |
15 | // Ensure environment variables are read.
16 | require('../config/env');
17 |
18 |
19 | const jest = require('jest');
20 | const execSync = require('child_process').execSync;
21 | let argv = process.argv.slice(2);
22 |
23 | function isInGitRepository() {
24 | try {
25 | execSync('git rev-parse --is-inside-work-tree', { stdio: 'ignore' });
26 | return true;
27 | } catch (e) {
28 | return false;
29 | }
30 | }
31 |
32 | function isInMercurialRepository() {
33 | try {
34 | execSync('hg --cwd . root', { stdio: 'ignore' });
35 | return true;
36 | } catch (e) {
37 | return false;
38 | }
39 | }
40 |
41 | // Watch unless on CI or explicitly running all tests
42 | if (
43 | !process.env.CI &&
44 | argv.indexOf('--watchAll') === -1 &&
45 | argv.indexOf('--watchAll=false') === -1
46 | ) {
47 | // https://github.com/facebook/create-react-app/issues/5210
48 | const hasSourceControl = isInGitRepository() || isInMercurialRepository();
49 | argv.push(hasSourceControl ? '--watch' : '--watchAll');
50 | }
51 |
52 |
53 | jest.run(argv);
54 |
--------------------------------------------------------------------------------
/client/src/presentation/containers/StreamingPanel/presenter.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/forbid-prop-types */
2 | import React from 'react';
3 | import Box from '@material-ui/core/Box';
4 | import PropTypes from 'prop-types';
5 | import { StreamerVideo, GameMessageBox } from '../../components';
6 | import ScoreBoard from '../ScoreBoard';
7 |
8 | const StreamingPanelPresentation = ({ streamingPanelProps }) => {
9 | const {
10 | classes,
11 | showScoreBoard,
12 | showGameMessageBox,
13 | gameMessageContent,
14 | stream,
15 | videoVisibility,
16 | scoreList,
17 | message,
18 | } = streamingPanelProps;
19 | return (
20 |
21 | {videoVisibility ? : ''}
22 | {showScoreBoard && }
23 | {showGameMessageBox && }
24 |
25 | );
26 | };
27 |
28 | StreamingPanelPresentation.defaultProps = {
29 | streamingPanelProps: {},
30 | classes: {},
31 | showScoreBoard: false,
32 | showGameMessageBox: false,
33 | gameMessageContent: {},
34 | stream: {},
35 | videoVisibility: false,
36 | };
37 |
38 | StreamingPanelPresentation.propTypes = {
39 | streamingPanelProps: PropTypes.object,
40 | classes: PropTypes.object,
41 | showScoreBoard: PropTypes.bool,
42 | showGameMessageBox: PropTypes.bool,
43 | gameMessageContent: PropTypes.object,
44 | stream: PropTypes.object,
45 | videoVisibility: PropTypes.bool,
46 | };
47 |
48 | export default StreamingPanelPresentation;
49 |
--------------------------------------------------------------------------------
/client/src/presentation/components/TextInput.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/forbid-prop-types */
2 | import React from 'react';
3 | import { makeStyles } from '@material-ui/core/styles';
4 | import TextField from '@material-ui/core/TextField';
5 | import PropTypes from 'prop-types';
6 | import { DEFAULT_TEXT_INPUT_MAX_LENGTH } from '../../constants/inputConstraints';
7 |
8 | const TextInput = ({
9 | label,
10 | style,
11 | value,
12 | textChangeHandler,
13 | onKeyPress,
14 | maxLength,
15 | }) => {
16 | const useStyles = makeStyles(theme => ({
17 | textField: {
18 | marginLeft: theme.spacing(1),
19 | marginRight: theme.spacing(1),
20 | boxSizing: 'border-box',
21 | width: style.width || '',
22 | '& > *': {
23 | fontSize: '1.6rem',
24 | fontWeight: '600',
25 | },
26 | },
27 | }));
28 |
29 | const classes = useStyles();
30 |
31 | return (
32 |
43 | );
44 | };
45 |
46 | TextInput.defaultProps = {
47 | maxLength: 0,
48 | style: {},
49 | };
50 |
51 | TextInput.propTypes = {
52 | label: PropTypes.string.isRequired,
53 | style: PropTypes.object,
54 | value: PropTypes.string.isRequired,
55 | textChangeHandler: PropTypes.func.isRequired,
56 | onKeyPress: PropTypes.func.isRequired,
57 | maxLength: PropTypes.number,
58 | };
59 |
60 | export default TextInput;
61 |
--------------------------------------------------------------------------------
/client/src/presentation/components/GameMessageBox.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { makeStyles } from '@material-ui/core/styles';
3 | import PropTypes from 'prop-types';
4 | import Box from '@material-ui/core/Box';
5 | import styleColors from '../../constants/styleColors';
6 |
7 | const useStyle = makeStyles({
8 | gameMessageBox: {
9 | top: '0',
10 | bottom: '0',
11 | left: '0',
12 | right: '0',
13 | backgroundColor: styleColors.BASE_BLACK_COLOR_TRANSLUCENT,
14 | position: 'absolute',
15 | display: 'flex',
16 | justifyContent: 'space-around',
17 | alignItems: 'center',
18 | flexDirection: 'column',
19 | },
20 | imageComponentWrapper: {
21 | maxHeight: '50%',
22 | display: 'flex',
23 | justifyContent: 'center',
24 | alignItems: 'center',
25 | '& > *': {
26 | maxWidth: '100%',
27 | maxHeight: '100%',
28 | },
29 | },
30 | textComponentWrapper: {
31 | width: '100%',
32 | textAlign: 'center',
33 | '& > *': {
34 | fontSize: '3rem',
35 | color: styleColors.PURE_WHITE_COLOR,
36 | },
37 | },
38 | });
39 |
40 | const GameMessageBox = ({ content }) => {
41 | const classes = useStyle();
42 |
43 | return (
44 |
45 |
46 | {content.center && content.center}
47 |
48 |
49 | {content.bottom && content.bottom}
50 |
51 |
52 | );
53 | };
54 |
55 | GameMessageBox.propTypes = {
56 | content: PropTypes.shape.isRequired,
57 | };
58 |
59 | export default GameMessageBox;
60 |
--------------------------------------------------------------------------------
/server/service/game/models/Player.js:
--------------------------------------------------------------------------------
1 | const { VIEWER } = require('../../../constants/player');
2 |
3 | class Player {
4 | constructor({ socketId, nickname, nicknameColor }) {
5 | this.type = VIEWER;
6 | this.isReady = false;
7 | this.score = 0;
8 | this.nickname = nickname;
9 | this.nicknameColor = nicknameColor;
10 | this.socketId = socketId;
11 | this.isConnectedToStreamer = false;
12 | this.isCorrectPlayer = false;
13 | }
14 |
15 | reset() {
16 | this.type = VIEWER;
17 | this.isReady = false;
18 | this.score = 0;
19 | this.isConnectedToStreamer = false;
20 | this.isCorrectPlayer = false;
21 | }
22 |
23 | getNickname() {
24 | return this.nickname;
25 | }
26 |
27 | getNicknameColor() {
28 | return this.nicknameColor;
29 | }
30 |
31 | getScore() {
32 | return this.score;
33 | }
34 |
35 | getType() {
36 | return this.type;
37 | }
38 |
39 | getIsReady() {
40 | return this.isReady;
41 | }
42 |
43 | getSocketId() {
44 | return this.socketId;
45 | }
46 |
47 | getIsConnectedToStreamer() {
48 | return this.isConnectedToStreamer;
49 | }
50 |
51 | getIsCorrectPlayer() {
52 | return this.isCorrectPlayer;
53 | }
54 |
55 | setIsReady(isReady) {
56 | this.isReady = isReady;
57 | }
58 |
59 | setType(type) {
60 | this.type = type;
61 | }
62 |
63 | setScore(score) {
64 | this.score = score;
65 | }
66 |
67 | setIsConnectedToStreamer(isConnectedToStreamer) {
68 | this.isConnectedToStreamer = isConnectedToStreamer;
69 | }
70 |
71 | setIsCorrectPlayer(isCorrectPlayer) {
72 | this.isCorrectPlayer = isCorrectPlayer;
73 | }
74 | }
75 | module.exports = Player;
76 |
--------------------------------------------------------------------------------
/client/src/presentation/containers/ScoreBoard.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { makeStyles } from '@material-ui/core/styles';
3 | import PropTypes from 'prop-types';
4 | import Box from '@material-ui/core/Box';
5 | import Typography from '@material-ui/core/Typography';
6 | import { ScoreBoardScoreRow } from '../components';
7 | import styleColors from '../../constants/styleColors';
8 | import { DEFAULT_SCOREBOARD_TITLE } from '../../constants/message';
9 |
10 | const useStyle = makeStyles({
11 | scoreBoard: {
12 | top: '0',
13 | bottom: '0',
14 | left: '0',
15 | right: '0',
16 | backgroundColor: styleColors.BASE_BLACK_COLOR_TRANSLUCENT,
17 | position: 'absolute',
18 | display: 'flex',
19 | justifyContent: 'space-around',
20 | alignItems: 'center',
21 | flexDirection: 'column',
22 | },
23 | title: {
24 | fontSize: '4rem',
25 | textAlign: 'center',
26 | color: styleColors.PURE_WHITE_COLOR,
27 | },
28 | });
29 |
30 | const ScoreBoard = ({ title, scoreRows }) => {
31 | const classes = useStyle();
32 |
33 | const scoreRowsComponents = scoreRows.map(scoreRow => {
34 | return (
35 |
36 | );
37 | });
38 |
39 | return (
40 |
41 |
42 | {title || DEFAULT_SCOREBOARD_TITLE}
43 |
44 | {scoreRowsComponents}
45 |
46 | );
47 | };
48 |
49 | ScoreBoard.propTypes = {
50 | title: PropTypes.string.isRequired,
51 | scoreRows: PropTypes.shape.isRequired,
52 | };
53 |
54 | export default ScoreBoard;
55 |
--------------------------------------------------------------------------------
/client/src/presentation/components/ChattingWindow.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/forbid-prop-types */
2 | import React, { useEffect, useRef } from 'react';
3 | import { makeStyles } from '@material-ui/core/styles';
4 | import PropTypes from 'prop-types';
5 | import Box from '@material-ui/core/Box';
6 | import ChattingRow from './ChattingRow';
7 |
8 | const useStyle = makeStyles({
9 | chattingWindow: {
10 | overflowY: 'auto',
11 | padding: '1rem',
12 | height: '100%',
13 | boxSizing: 'border-box',
14 | },
15 | });
16 |
17 | const scrollToBottom = messageEndRef => {
18 | if (!messageEndRef.current.scrollIntoView) return;
19 | messageEndRef.current.scrollIntoView({ behavior: 'auto' });
20 | };
21 |
22 | const useScrollToBottom = (messageEndRef, chattingList) => {
23 | useEffect(scrollToBottom.bind(null, messageEndRef), [chattingList]);
24 | };
25 |
26 | const ChattingWindow = ({ chattingList }) => {
27 | const classes = useStyle();
28 | const messageEndRef = useRef();
29 | useScrollToBottom(messageEndRef, chattingList);
30 | const chattingRowList = chattingList.map((chatting, index) => {
31 | const key = `${chatting.id}${index}`;
32 | return (
33 |
39 | );
40 | });
41 | return (
42 |
43 |
44 |
45 |
46 | );
47 | };
48 |
49 | ChattingWindow.defaultProps = {
50 | chattingList: [],
51 | };
52 |
53 | ChattingWindow.propTypes = {
54 | chattingList: PropTypes.array,
55 | };
56 |
57 | export default ChattingWindow;
58 |
--------------------------------------------------------------------------------
/client/src/presentation/components/ScoreBoardScoreRow.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { makeStyles } from '@material-ui/core/styles';
3 | import PropTypes from 'prop-types';
4 | import Box from '@material-ui/core/Box';
5 | import styleColors from '../../constants/styleColors';
6 |
7 | const useStyle = makeStyles({
8 | scoreBoard: {
9 | top: '0',
10 | bottom: '0',
11 | left: '0',
12 | right: '0',
13 | backgroundColor: styleColors.BASE_BLACK_COLOR_TRANSLUCENT,
14 | position: 'absolute',
15 | display: 'flex',
16 | justifyContent: 'space-around',
17 | alignItems: 'center',
18 | flexDirection: 'column',
19 | },
20 | title: {
21 | fontSize: '4rem',
22 | textAlign: 'center',
23 | color: styleColors.PURE_WHITE_COLOR,
24 | },
25 | scoreRow: {
26 | display: 'flex',
27 | fontSize: '2.5rem',
28 | marginBottom: '1rem',
29 | },
30 | nickname: {
31 | width: 'auto',
32 | color: styleColors.PURE_WHITE_COLOR,
33 | textAlign: 'center',
34 | flex: '1',
35 | marginRight: '1rem',
36 | whiteSpace: 'nowrap',
37 | },
38 | score: {
39 | width: 'auto',
40 | color: styleColors.THEME_COLOR,
41 | textAlign: 'center',
42 | flex: '1',
43 | whiteSpace: 'nowrap',
44 | },
45 | });
46 |
47 | const ScoreBoardScoreRow = ({ nickname, score }) => {
48 | const classes = useStyle();
49 | return (
50 |
51 | {nickname}
52 | {score}
53 |
54 | );
55 | };
56 |
57 | ScoreBoardScoreRow.propTypes = {
58 | nickname: PropTypes.string.isRequired,
59 | score: PropTypes.string.isRequired,
60 | };
61 |
62 | export default ScoreBoardScoreRow;
63 |
--------------------------------------------------------------------------------
/client/src/presentation/containers/ChattingPanel.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/forbid-prop-types */
2 | import React, { useContext } from 'react';
3 | import PropTypes from 'prop-types';
4 | import Box from '@material-ui/core/Box';
5 | import { makeStyles } from '@material-ui/core/styles';
6 | import { ChattingWindow, InputWindow } from '../components';
7 | import { GlobalContext } from '../../contexts';
8 | import styleColors from '../../constants/styleColors';
9 |
10 | const useStyle = makeStyles(theme => ({
11 | chattingPanel: {
12 | height: '100%',
13 | position: 'relative',
14 | backgroundColor: styleColors.PANEL_COLOR,
15 | boxShadow: '0 0.2rem 0.7rem 0 rgba(0, 0, 0, 0.5)',
16 | borderRadius: '0.3rem',
17 | [theme.breakpoints.down('xs')]: {
18 | borderRadius: '0',
19 | },
20 | },
21 | chattingWindow: {
22 | height: '90%',
23 | overflow: 'auto',
24 | wordWrap: 'break-word',
25 | },
26 | }));
27 |
28 | const ChattingPanel = ({ clientManager, mobileChattingPanelVisibility }) => {
29 | const classes = useStyle();
30 | const { chattingList, chattingDisabled } = useContext(GlobalContext);
31 |
32 | return (
33 |
34 | {!mobileChattingPanelVisibility && (
35 |
36 |
37 |
38 | )}
39 |
43 |
44 | );
45 | };
46 |
47 | ChattingPanel.defaultProps = {
48 | clientManager: {},
49 | mobileChattingPanelVisibility: false,
50 | };
51 |
52 | ChattingPanel.propTypes = {
53 | clientManager: PropTypes.object,
54 | mobileChattingPanelVisibility: PropTypes.bool,
55 | };
56 |
57 | export default ChattingPanel;
58 |
--------------------------------------------------------------------------------
/server/databaseFiles/repositories/RankingRepository.js:
--------------------------------------------------------------------------------
1 | const Sequelize = require('sequelize');
2 | const { convertSequelizeArrayData } = require('./utils');
3 | const { Ranking } = require('../databaseModels');
4 | const { RANKING_COUNTS, INVALID_DATE } = require('../../constants/database');
5 |
6 | class RankingRepository {
7 | constructor(model = Ranking) {
8 | this.model = model;
9 | }
10 |
11 | checkInvalidDate(dateTime) {
12 | return new Date(dateTime).toString() === INVALID_DATE
13 | ? new Date()
14 | : new Date(dateTime);
15 | }
16 |
17 | /**
18 | * ranking에 필요한 값은
19 | * nickname(필수), score, season
20 | *
21 | * @param {ranking} ranking
22 | */
23 | async insertRanking(ranking) {
24 | await this.model.create(ranking);
25 | }
26 |
27 | async insertRankings(rankings) {
28 | await this.model.bulkCreate(rankings);
29 | }
30 |
31 | async getRankingsBeforeDateTime(offset = 0, dateTime) {
32 | offset *= RANKING_COUNTS;
33 | const convertedDateTime = this.checkInvalidDate(dateTime);
34 | const topRankers = await this.model.findAll({
35 | order: [
36 | ['score', 'DESC'],
37 | ['createdAt', 'ASC'],
38 | ],
39 | limit: RANKING_COUNTS,
40 | offset,
41 | where: {
42 | createdAt: {
43 | [Sequelize.Op.lte]: convertedDateTime,
44 | },
45 | },
46 | });
47 |
48 | const convertedData = convertSequelizeArrayData(topRankers);
49 | return convertedData;
50 | }
51 |
52 | async getAllRankings() {
53 | const rankings = await this.model.findAll();
54 | const convertedData = convertSequelizeArrayData(rankings);
55 | return convertedData;
56 | }
57 |
58 | async getRankingCount() {
59 | const rankingCount = await this.model.count();
60 | return rankingCount;
61 | }
62 | }
63 |
64 | module.exports = RankingRepository;
65 |
--------------------------------------------------------------------------------
/server/service/game/eventHandlers/matchHandler.js:
--------------------------------------------------------------------------------
1 | const Player = require('../models/Player');
2 | const roomController = require('../controllers/roomController');
3 | const { getRandomColor } = require('../../../utils/colorGenerator');
4 | const EVENT = require('../../../constants/event');
5 | const { NICKNAME_LENGTH } = require('../../../constants/gameRule');
6 |
7 | const emitEventsAfterJoin = socket => {
8 | socket.emit(EVENT.START_CHATTING);
9 | socket.emit(EVENT.SEND_ROOMID, { roomId: socket.roomId });
10 | };
11 |
12 | const getRoomId = (roomIdFromUrl, isPrivateRoomCreation) => {
13 | let roomId;
14 | if (!!roomIdFromUrl || isPrivateRoomCreation) {
15 | roomId = roomController.getPrivateRoomInformationToJoin(
16 | roomIdFromUrl,
17 | isPrivateRoomCreation,
18 | );
19 | } else {
20 | roomId = roomController.getPublicRoomInformantionToJoin();
21 | }
22 | return roomId;
23 | };
24 |
25 | const matchHandler = (
26 | socket,
27 | { nickname, roomIdFromUrl, isPrivateRoomCreation },
28 | ) => {
29 | const roomId = getRoomId(roomIdFromUrl, isPrivateRoomCreation);
30 | const slicedNickname = nickname.slice(0, NICKNAME_LENGTH);
31 | const player = new Player({
32 | nickname: slicedNickname,
33 | socketId: socket.id,
34 | nicknameColor: getRandomColor(),
35 | });
36 |
37 | if (roomId) {
38 | const isRoomPrivate = !!roomIdFromUrl || isPrivateRoomCreation;
39 | roomController.joinRoom({ socket, roomId, player, isRoomPrivate });
40 |
41 | const { gameManager } = roomController.getRoomByRoomId(roomId);
42 | const otherPlayers = gameManager.getOtherPlayers(player.socketId);
43 |
44 | socket.broadcast.to(roomId).emit(EVENT.SEND_NEW_PLAYER, player);
45 | socket.emit(EVENT.SEND_PLAYERS, { players: otherPlayers });
46 | emitEventsAfterJoin(socket);
47 | } else {
48 | socket.emit(EVENT.ROOM_UNAVAILABLE);
49 | }
50 | };
51 |
52 | module.exports = matchHandler;
53 |
--------------------------------------------------------------------------------
/client/src/service/Timer.js:
--------------------------------------------------------------------------------
1 | import { ONE_SECOND } from '../constants/timer';
2 |
3 | class Timer {
4 | constructor() {
5 | this.remainingTime = 0;
6 | this.timerId = null;
7 | }
8 |
9 | /**
10 | * @param {*} seconds 설정할 타이머 시간
11 | * @param {*} timeOutCallback 설정한 타이머 시간이 끝난 후 호출될 콜백 함수
12 | * @param {*} intervalCallback 매초 호출될 콜백 함수
13 | */
14 | startIntegrationTimer(seconds, timeOutCallback, intervalCallback) {
15 | this.remainingTime = seconds;
16 | const updateTimer = () => {
17 | intervalCallback(this.remainingTime);
18 | this.remainingTime -= 1;
19 | if (this.remainingTime < 0) {
20 | this.clear();
21 | timeOutCallback();
22 | return;
23 | }
24 | this.timerId = setTimeout(updateTimer, ONE_SECOND);
25 | };
26 | this.timerId = setTimeout(updateTimer);
27 | }
28 |
29 | /**
30 | * @param {*} seconds 설정할 타이머 시간
31 | * @param {*} intervalCallback 매초 호출될 콜백 함수
32 | */
33 | startIntervalTimer(seconds, intervalCallback) {
34 | this.remainingTime = seconds;
35 | const updateTimer = () => {
36 | this.remainingTime -= 1;
37 | if (this.remainingTime < 0) {
38 | this.clear();
39 | return;
40 | }
41 | intervalCallback(this.remainingTime);
42 | this.timerId = setTimeout(updateTimer, ONE_SECOND);
43 | };
44 | this.timerId = setTimeout(updateTimer);
45 | }
46 |
47 | /**
48 | * @param {*} seconds 설정할 타이머 시간
49 | * @param {*} timeOutCallback 설정한 타이머 시간이 끝난 후 호출될 콜백 함수
50 | */
51 | startTimeoutTimer(seconds, timeOutCallback) {
52 | this.timerId = setTimeout(() => {
53 | this.clear();
54 | timeOutCallback();
55 | }, seconds * ONE_SECOND);
56 | }
57 |
58 | getRemainingTime() {
59 | return this.remainingTime;
60 | }
61 |
62 | clear() {
63 | clearTimeout(this.timerId);
64 | this.timerId = null;
65 | }
66 | }
67 |
68 | export default Timer;
69 |
--------------------------------------------------------------------------------
/server/service/game/models/Timer.js:
--------------------------------------------------------------------------------
1 | const { ONE_SECOND_IN_MILLISECONDS } = require('../../../constants/timer');
2 |
3 | class Timer {
4 | constructor(roomId) {
5 | this.roomId = roomId;
6 | this.timerId = null;
7 | this.remainingTime = 0;
8 | }
9 |
10 | /**
11 | * @param {*} seconds 설정할 타이머 시간
12 | * @param {*} timeOutCallback 설정한 타이머 시간이 끝난 후 호출될 콜백 함수
13 | * @param {*} intervalCallback 매초 호출될 콜백 함수
14 | */
15 | startIntegrationTimer(seconds, timeOutCallback, intervalCallback) {
16 | this.remainingTime = seconds;
17 | const updateTimer = () => {
18 | intervalCallback(this.remainingTime, this.roomId);
19 | if (--this.remainingTime < 0) {
20 | this.clear();
21 | timeOutCallback();
22 | return;
23 | }
24 | this.timerId = setTimeout(updateTimer, ONE_SECOND_IN_MILLISECONDS);
25 | };
26 | this.timerId = setTimeout(updateTimer);
27 | }
28 |
29 | /**
30 | * @param {*} seconds 설정할 타이머 시간
31 | * @param {*} intervalCallback 매초 호출될 콜백 함수
32 | */
33 | startIntervalTimer(seconds, intervalCallback) {
34 | this.remainingTime = seconds;
35 | const updateTimer = () => {
36 | if (--this.remainingTime < 0) {
37 | this.clear();
38 | return;
39 | }
40 | intervalCallback(this.remainingTime, this.roomId);
41 | this.timerId = setTimeout(updateTimer, ONE_SECOND_IN_MILLISECONDS);
42 | };
43 | this.timerId = setTimeout(updateTimer);
44 | }
45 |
46 | /**
47 | * @param {*} seconds 설정할 타이머 시간
48 | * @param {*} timeOutCallback 설정한 타이머 시간이 끝난 후 호출될 콜백 함수
49 | */
50 | startTimeoutTimer(seconds, timeOutCallback) {
51 | this.timerId = setTimeout(() => {
52 | this.clear();
53 | timeOutCallback();
54 | }, seconds * ONE_SECOND_IN_MILLISECONDS);
55 | }
56 |
57 | getRemainingTime() {
58 | return this.remainingTime;
59 | }
60 |
61 | clear() {
62 | clearTimeout(this.timerId);
63 | this.timerId = null;
64 | }
65 | }
66 |
67 | module.exports = Timer;
68 |
--------------------------------------------------------------------------------
/server/service/game/eventHandlers/disconnectingHandler.js:
--------------------------------------------------------------------------------
1 | const { io } = require('../../io');
2 | const roomController = require('../controllers/roomController');
3 | const gameController = require('../controllers/gameController');
4 | const GAME_STATUS = require('../../../constants/gameStatus');
5 | const { MIN_PLAYER_COUNT } = require('../../../constants/gameRule');
6 | const { SEND_LEFT_PLAYER } = require('../../../constants/event');
7 |
8 | const leavePlayer = (gameManager, socket) => {
9 | gameManager.leaveRoom(socket.id);
10 | socket.leave(gameManager.getRoomId());
11 | };
12 |
13 | const sendLeftPlayerToRoom = (roomId, socketId) => {
14 | io.in(roomId).emit(SEND_LEFT_PLAYER, {
15 | socketId,
16 | });
17 | };
18 |
19 | const disconnectingHandler = socket => {
20 | try {
21 | const room = roomController.getRoomByRoomId(socket.roomId);
22 | if (!room) {
23 | return;
24 | }
25 | const { gameManager, timer } = room;
26 | const roomStatus = gameManager.getStatus();
27 | leavePlayer(gameManager, socket);
28 | sendLeftPlayerToRoom(gameManager.getRoomId(), socket.id);
29 |
30 | switch (roomStatus) {
31 | case GAME_STATUS.WAITING:
32 | if (
33 | gameManager.checkAllPlayersAreReady() &&
34 | gameManager.getPlayers().length >= MIN_PLAYER_COUNT
35 | ) {
36 | gameController.prepareGame(gameManager, timer);
37 | }
38 | break;
39 |
40 | case GAME_STATUS.CONNECTING:
41 | case GAME_STATUS.INITIALIZING:
42 | case GAME_STATUS.PLAYING:
43 | if (!gameManager.isSetContinuable()) {
44 | gameController.repeatSet(gameManager, timer);
45 | }
46 | break;
47 |
48 | case GAME_STATUS.SCORE_SHARING:
49 | if (!gameManager.isNextSetAvailable()) {
50 | gameController.goToEnding(gameManager, timer);
51 | }
52 | break;
53 | default:
54 | break;
55 | }
56 | } catch (error) {
57 | console.log(error);
58 | }
59 | };
60 |
61 | module.exports = disconnectingHandler;
62 |
--------------------------------------------------------------------------------
/client/src/presentation/components/Toast.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import Snackbar from '@material-ui/core/Snackbar';
4 | import SnackbarContent from '@material-ui/core/SnackbarContent';
5 | import { makeStyles } from '@material-ui/core/styles';
6 | import { amber, green, deepPurple, red } from '@material-ui/core/colors';
7 | import ToastContent from './ToastContent';
8 | import {
9 | TOAST_TYPES,
10 | TOAST_ICONS,
11 | TOAST_POSITION,
12 | TOAST_TIME,
13 | } from '../../constants/toast';
14 |
15 | const useStyles = makeStyles({
16 | success: {
17 | backgroundColor: green[600],
18 | },
19 | error: {
20 | backgroundColor: red[600],
21 | },
22 | info: {
23 | backgroundColor: deepPurple[600],
24 | },
25 | warning: {
26 | backgroundColor: amber[700],
27 | },
28 | test: {
29 | width: '10rem',
30 | },
31 | });
32 |
33 | /**
34 | * Toast 컴포넌트의 타입이 아닌 값이 들어왔을때, 기본값으로 SUCCESS타입을 반환
35 | * @param {*} toastType
36 | */
37 | const checkToastType = toastType => {
38 | const defaultType = TOAST_TYPES.SUCCESS;
39 | return Object.values(TOAST_TYPES).includes(toastType)
40 | ? toastType
41 | : defaultType;
42 | };
43 |
44 | const Toast = ({ open, toastType, message, closeHandler }) => {
45 | const checkedToastType = checkToastType(toastType);
46 | const classes = useStyles();
47 | return (
48 |
54 | {
55 | )}
59 | />
60 | }
61 |
62 | );
63 | };
64 |
65 | Toast.propTypes = {
66 | open: PropTypes.bool.isRequired,
67 | toastType: PropTypes.string.isRequired,
68 | message: PropTypes.string.isRequired,
69 | closeHandler: PropTypes.func.isRequired,
70 | };
71 |
72 | export default Toast;
73 |
--------------------------------------------------------------------------------
/client/src/service/ChattingManager.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable class-methods-use-this */
2 | import { useContext } from 'react';
3 | import { DispatchContext } from '../contexts';
4 | import { MAX_CHAT_LENGTH } from '../constants/inputConstraints';
5 | import EVENTS from '../constants/events';
6 | import { WELCOME_MESSAGE } from '../constants/chatting';
7 | import {
8 | DEFAULT_INACTIVE_PLAYER_BAN_TIME,
9 | PRIVATE_ROOM_INACTIVE_PLAYER_BAN_TIME_IN_MINUTE,
10 | } from '../constants/timer';
11 | import actions from '../actions/global';
12 |
13 | class ChattingManager {
14 | constructor(socket, isRoomPrivate) {
15 | this.dispatch = useContext(DispatchContext);
16 | this.socket = socket;
17 | this.isAvailableChatting = false;
18 | this.isRoomPrivate = isRoomPrivate;
19 | }
20 |
21 | sliceChattingMessage(chat) {
22 | return chat.slice(0, MAX_CHAT_LENGTH);
23 | }
24 |
25 | processChatWithSystemRule(chat) {
26 | const trimmedChat = chat.trim();
27 | if (!trimmedChat) return '';
28 | return this.sliceChattingMessage(trimmedChat);
29 | }
30 |
31 | sendChattingMessage(newChat) {
32 | if (!this.isAvailableChatting) return;
33 | const processedChat = this.processChatWithSystemRule(newChat.message);
34 | if (!processedChat) return;
35 | this.socket.emit(EVENTS.SEND_CHATTING_MESSAGE, {
36 | message: processedChat,
37 | });
38 | }
39 |
40 | registerSocketEvents() {
41 | this.socket.on(
42 | EVENTS.SEND_CHATTING_MESSAGE,
43 | this.sendChattingMessageHandler.bind(this),
44 | );
45 | this.socket.on(EVENTS.START_CHATTING, this.startChattingHandler.bind(this));
46 | }
47 |
48 | sendChattingMessageHandler(newChatting) {
49 | this.dispatch(actions.addChatting(newChatting));
50 | }
51 |
52 | startChattingHandler() {
53 | this.isAvailableChatting = true;
54 | this.dispatch(
55 | actions.addChatting(
56 | WELCOME_MESSAGE(
57 | this.isRoomPrivate,
58 | PRIVATE_ROOM_INACTIVE_PLAYER_BAN_TIME_IN_MINUTE,
59 | DEFAULT_INACTIVE_PLAYER_BAN_TIME,
60 | ),
61 | ),
62 | );
63 | }
64 | }
65 |
66 | export default ChattingManager;
67 |
--------------------------------------------------------------------------------
/client/src/damodata.js:
--------------------------------------------------------------------------------
1 | const rankingList = [
2 | { rank: '1', nickname: '1등', score: '300' },
3 | { rank: '2', nickname: '2등', score: '300' },
4 | { rank: '3', nickname: '3등', score: '300' },
5 | { rank: '4', nickname: '4등', score: '300' },
6 | { rank: '5', nickname: '5등', score: '300' },
7 | { rank: '5', nickname: '5등', score: '300' },
8 | { rank: '5', nickname: '5등', score: '300' },
9 | { rank: '5', nickname: '5등', score: '300' },
10 | { rank: '5', nickname: '5등', score: '300' },
11 | { rank: '5', nickname: '5등', score: '300' },
12 | { rank: '5', nickname: '5등', score: '300' },
13 | { rank: '5', nickname: '5등', score: '300' },
14 | { rank: '5', nickname: '5등', score: '300' },
15 | { rank: '5', nickname: '5등', score: '300' },
16 | { rank: '5', nickname: '5등', score: '300' },
17 | { rank: '5', nickname: '5등', score: '300' },
18 | { rank: '5', nickname: '5등', score: '300' },
19 | { rank: '5', nickname: '5등', score: '300' },
20 | { rank: '5', nickname: '5등', score: '300' },
21 | { rank: '5', nickname: '5등', score: '300' },
22 | { rank: '5', nickname: '5등', score: '300' },
23 | { rank: '5', nickname: '5등', score: '300' },
24 | { rank: '5', nickname: '5등', score: '300' },
25 | { rank: '5', nickname: '5등', score: '300' },
26 | { rank: '5', nickname: '5등', score: '300' },
27 | { rank: '5', nickname: '5등', score: '300' },
28 | { rank: '5', nickname: '5등', score: '300' },
29 | { rank: '5', nickname: '5등', score: '300' },
30 | { rank: '5', nickname: '5등', score: '300' },
31 | { rank: '5', nickname: '5등', score: '300' },
32 | { rank: '5', nickname: '5등', score: '300' },
33 | { rank: '5', nickname: '5등', score: '300' },
34 | { rank: '5', nickname: '5등', score: '300' },
35 | { rank: '5', nickname: '5등', score: '300' },
36 | { rank: '5', nickname: '5등', score: '300' },
37 | { rank: '5', nickname: '5등', score: '300' },
38 | { rank: '5', nickname: '5등', score: '300' },
39 | { rank: '5', nickname: '5등', score: '300' },
40 | ];
41 | const scoreRows = [
42 | { nickname: '1등', score: '300' },
43 | { nickname: '2등', score: '300' },
44 | { nickname: '3등', score: '300' },
45 | { nickname: '4등', score: '300' },
46 | ];
47 | export { rankingList, scoreRows };
48 |
--------------------------------------------------------------------------------
/client/public/reset.css:
--------------------------------------------------------------------------------
1 | /* http://meyerweb.com/eric/tools/css/reset/
2 | v5.0.1 | 20191019
3 | License: none (public domain)
4 | */
5 |
6 | html,
7 | body,
8 | div,
9 | span,
10 | applet,
11 | object,
12 | iframe,
13 | h1,
14 | h2,
15 | h3,
16 | h4,
17 | h5,
18 | h6,
19 | p,
20 | blockquote,
21 | pre,
22 | a,
23 | abbr,
24 | acronym,
25 | address,
26 | big,
27 | cite,
28 | code,
29 | del,
30 | dfn,
31 | em,
32 | img,
33 | ins,
34 | kbd,
35 | q,
36 | s,
37 | samp,
38 | small,
39 | strike,
40 | strong,
41 | sub,
42 | sup,
43 | tt,
44 | var,
45 | b,
46 | u,
47 | i,
48 | center,
49 | dl,
50 | dt,
51 | dd,
52 | menu,
53 | ol,
54 | ul,
55 | li,
56 | fieldset,
57 | form,
58 | label,
59 | legend,
60 | table,
61 | caption,
62 | tbody,
63 | tfoot,
64 | thead,
65 | tr,
66 | th,
67 | td,
68 | article,
69 | aside,
70 | canvas,
71 | details,
72 | embed,
73 | figure,
74 | figcaption,
75 | footer,
76 | header,
77 | hgroup,
78 | main,
79 | menu,
80 | nav,
81 | output,
82 | ruby,
83 | section,
84 | summary,
85 | time,
86 | mark,
87 | audio,
88 | video {
89 | margin: 0;
90 | padding: 0;
91 | border: 0;
92 | font-size: 100%;
93 | font: inherit;
94 | vertical-align: baseline;
95 | }
96 | /* HTML5 display-role reset for older browsers */
97 | article,
98 | aside,
99 | details,
100 | figcaption,
101 | figure,
102 | footer,
103 | header,
104 | hgroup,
105 | main,
106 | menu,
107 | nav,
108 | section {
109 | display: block;
110 | }
111 | /* HTML5 hidden-attribute fix for newer browsers */
112 | *[hidden] {
113 | display: none;
114 | }
115 |
116 | menu,
117 | ol,
118 | ul {
119 | list-style: none;
120 | }
121 | blockquote,
122 | q {
123 | quotes: none;
124 | }
125 | blockquote:before,
126 | blockquote:after,
127 | q:before,
128 | q:after {
129 | content: '';
130 | content: none;
131 | }
132 | table {
133 | border-collapse: collapse;
134 | border-spacing: 0;
135 | }
136 |
137 | html {
138 | height: 100%;
139 | width: 100%;
140 | }
141 | #root {
142 | height: 100%;
143 | width: 100%;
144 | }
145 | body {
146 | line-height: 1;
147 | height: 100%;
148 | width: 100%;
149 | }
150 |
151 | a {
152 | text-decoration: none;
153 | }
154 |
--------------------------------------------------------------------------------
/client/src/presentation/components/InputWindow.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/forbid-prop-types */
2 | import React, { useState } from 'react';
3 | import PropTypes from 'prop-types';
4 | import Box from '@material-ui/core/Box';
5 | import { makeStyles } from '@material-ui/core/styles';
6 | import MessageInput from './MessageInput';
7 | import { SendButton } from './Buttons';
8 | import { ENTER_KEYCODE } from '../../constants/browser';
9 | import { MAX_CHAT_LENGTH } from '../../constants/inputConstraints';
10 |
11 | const useStyles = makeStyles(theme => ({
12 | InputWindow: {
13 | display: 'flex',
14 | padding: '1rem',
15 | height: '10%',
16 | boxSizing: 'border-box',
17 | [theme.breakpoints.down('xs')]: {
18 | height: '100%',
19 | },
20 | },
21 | }));
22 |
23 | const InputWindow = ({ clientManager, chattingDisabled }) => {
24 | const [value, setValue] = useState('');
25 | const classes = useStyles();
26 |
27 | const sendChattingMessageHandler = () => {
28 | if (!value) return;
29 | clientManager.sendChattingMessage({
30 | message: value,
31 | });
32 | setValue('');
33 | };
34 |
35 | const messageInputOnChangeHandler = event => {
36 | if (event.target.value.length <= 40) {
37 | setValue(event.target.value);
38 | }
39 | };
40 |
41 | const messageInputOnKeyPressHandler = event => {
42 | if (event.charCode === ENTER_KEYCODE) {
43 | sendChattingMessageHandler();
44 | }
45 | };
46 |
47 | return (
48 |
49 |
56 |
60 | Send
61 |
62 |
63 | );
64 | };
65 |
66 | InputWindow.defaultProps = {
67 | clientManager: {},
68 | chattingDisabled: false,
69 | };
70 |
71 | InputWindow.propTypes = {
72 | clientManager: PropTypes.object,
73 | chattingDisabled: PropTypes.bool,
74 | };
75 |
76 | export default InputWindow;
77 |
--------------------------------------------------------------------------------
/client/src/presentation/pages/MainPage.jsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useContext } from 'react';
2 | import { makeStyles } from '@material-ui/core/styles';
3 | import MetaTags from 'react-meta-tags';
4 | import Container from '@material-ui/core/Container';
5 | import Box from '@material-ui/core/Box';
6 | import { MainTitle, Menu, Introduction } from '../containers';
7 | import browserLocalStorage from '../../utils/browserLocalStorage';
8 | import Toast from '../components/Toast';
9 | import { GlobalContext, DispatchContext } from '../../contexts';
10 | import actions from '../../actions';
11 | import { useToast } from '../../hooks';
12 |
13 | const useStyle = makeStyles(theme => ({
14 | mainPage: {
15 | width: '40rem',
16 | display: 'flex',
17 | flexDirection: 'column',
18 | '& > *': {
19 | marginBottom: '2rem',
20 | },
21 | [theme.breakpoints.down('xs')]: {
22 | width: '100%',
23 | },
24 | },
25 | mainPageWrapper: {
26 | margin: 0,
27 | width: '100%',
28 | height: '100%',
29 | overflow: 'auto',
30 | },
31 | }));
32 |
33 | const MainPage = () => {
34 | const classes = useStyle();
35 |
36 | const dispatch = useContext(DispatchContext);
37 | const { toast } = useContext(GlobalContext);
38 | const { closeToast } = useToast({
39 | open: toast.open,
40 | dispatch,
41 | actions,
42 | });
43 |
44 | const mainPageLifecycleHandler = () => {
45 | closeToast();
46 | browserLocalStorage.verifyNicknameInLocalStorage();
47 | };
48 |
49 | useEffect(mainPageLifecycleHandler, []);
50 |
51 | return (
52 |
53 |
54 |
58 |
59 |
60 | {
65 | closeToast();
66 | }}
67 | />
68 |
69 |
70 |
71 |
72 |
73 | );
74 | };
75 |
76 | export default MainPage;
77 |
--------------------------------------------------------------------------------
/client/src/presentation/containers/TopRankPanel.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/forbid-prop-types */
2 | import React from 'react';
3 | import PropTypes from 'prop-types';
4 | import { makeStyles } from '@material-ui/core/styles';
5 | import { Container } from '@material-ui/core';
6 | import { RankPodium } from '../components';
7 | import {
8 | DEFAULT_SCORE,
9 | DEFAULT_NICKNAMES,
10 | FIRST_PLACE,
11 | SECOND_PLACE,
12 | THIRD_PLACE,
13 | } from '../../constants/ranking';
14 |
15 | const useStyle = makeStyles(theme => ({
16 | topRankContainer: {
17 | height: '50rem',
18 | display: 'flex',
19 | width: '50rem',
20 | marginBottom: '2.5rem',
21 | [theme.breakpoints.down('xs')]: {
22 | width: '100%',
23 | },
24 | },
25 | }));
26 |
27 | const findRanker = (rank, rankingList) => {
28 | return rankingList.find(ranking => {
29 | return ranking.rank === rank;
30 | });
31 | };
32 |
33 | const TopRankPanel = ({ rankingList }) => {
34 | const classes = useStyle();
35 | const firstPlace = findRanker(FIRST_PLACE, rankingList);
36 | const secondPlace = findRanker(SECOND_PLACE, rankingList);
37 | const thirdPlace = findRanker(THIRD_PLACE, rankingList);
38 | return (
39 |
40 |
47 |
54 |
61 |
62 | );
63 | };
64 |
65 | TopRankPanel.defaultProps = {
66 | rankingList: [],
67 | };
68 |
69 | TopRankPanel.propTypes = {
70 | rankingList: PropTypes.array,
71 | };
72 |
73 | export default TopRankPanel;
74 |
--------------------------------------------------------------------------------
/server/service/game/eventHandlers/sendChattingMessageHandler.js:
--------------------------------------------------------------------------------
1 | const short = require('short-uuid');
2 | const { io } = require('../../io');
3 | const { processChatWithSystemRule } = require('../../../utils/chatUtils');
4 | const roomController = require('../controllers/roomController');
5 | const gameController = require('../controllers/gameController');
6 | const GAME_STATUS = require('../../../constants/gameStatus');
7 | const EVENT = require('../../../constants/event');
8 |
9 | /**
10 | * viewer가 입력한 채팅이 정답이라면 true를 반환하는 함수
11 | */
12 | const isCorrectAnswer = (gameManager, message, socketId) => {
13 | return (
14 | gameManager.getStatus() === GAME_STATUS.PLAYING &&
15 | gameManager.getQuiz() === message &&
16 | !gameManager.isStreamer(socketId)
17 | );
18 | };
19 |
20 | const sendChattingMessageToRoom = (roomId, payload) => {
21 | if (payload.message) {
22 | io.in(roomId).emit(EVENT.SEND_CHATTING_MESSAGE, payload);
23 | }
24 | };
25 |
26 | const sendChattingMessageHandler = (socket, { message }) => {
27 | const { gameManager, timer } = roomController.getRoomByRoomId(socket.roomId);
28 | const roomId = gameManager.getRoomId();
29 | const player = gameManager.getPlayerBySocketId(socket.id);
30 | const playerNickname = player.getNickname();
31 | const playerNicknameColor = player.getNicknameColor();
32 | const payload = { id: short.generate() };
33 |
34 | if (player.getIsCorrectPlayer()) return;
35 |
36 | if (
37 | isCorrectAnswer(gameManager, message, socket.id) &&
38 | gameManager.getStatus() === GAME_STATUS.PLAYING
39 | ) {
40 | payload.nickname = '안내';
41 | payload.message = `${playerNickname}님이 정답을 맞췄습니다!`;
42 | sendChattingMessageToRoom(roomId, payload);
43 |
44 | const score = player.getScore() + timer.getRemainingTime() + 50;
45 | player.setScore(score);
46 | player.setIsCorrectPlayer(true);
47 | io.to(socket.id).emit(EVENT.CORRECT_ANSWER);
48 | io.in(socket.roomId).emit(EVENT.UPDATE_PROFILE, { player });
49 |
50 | if (gameManager.checkAllPlayersAreCorrect()) {
51 | gameController.repeatSet(gameManager, timer);
52 | }
53 | return;
54 | }
55 |
56 | payload.nickname = playerNickname;
57 | payload.nicknameColor = playerNicknameColor;
58 | payload.message = processChatWithSystemRule(message);
59 | sendChattingMessageToRoom(roomId, payload);
60 | };
61 |
62 | module.exports = sendChattingMessageHandler;
63 |
--------------------------------------------------------------------------------
/client/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
13 |
18 |
22 |
23 |
32 | Try Catch
33 |
34 |
35 |
38 |
49 |
50 |
51 |
52 |
53 |
63 |
64 |
65 |
--------------------------------------------------------------------------------
/client/src/presentation/components/RankPodium.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'styled-components';
3 | import PropTypes from 'prop-types';
4 | import styleColors from '../../constants/styleColors';
5 |
6 | const RankPodiumBar = styled.div`
7 | height: ${props => {
8 | if (props.rank === '1') return '75%';
9 | if (props.rank === '2') return '50%';
10 | return '25%';
11 | }};
12 | border-top: 2px solid ${styleColors.THEME_COLOR};
13 | border-bottom: 2px solid ${styleColors.THEME_COLOR};
14 | border-left: ${props => {
15 | if (props.rank === '3') return 'none';
16 | return `2px solid ${styleColors.THEME_COLOR}`;
17 | }};
18 | border-right: ${props => {
19 | if (props.rank === '2') return 'none%';
20 | return `2px solid ${styleColors.THEME_COLOR}`;
21 | }};
22 | display: flex;
23 | font-size: 5rem;
24 | flex-direction: column;
25 | justify-content: center;
26 | align-items: center;
27 | background-color: ${styleColors.PURE_WHITE_COLOR};
28 | color: ${styleColors.THEME_COLOR};
29 | font-weight: bold;
30 | @media (max-width: 600px) {
31 | font-size: 9rem;
32 | }
33 | `;
34 |
35 | const RankPodiumWrapper = styled.div`
36 | height: 100%;
37 | width: 100%;
38 | `;
39 |
40 | const RankerInformation = styled.div`
41 | height: ${props => {
42 | if (props.rank === '1') return '25%';
43 | if (props.rank === '2') return '50%';
44 | return '75%';
45 | }};
46 | display: flex;
47 | flex-direction: column;
48 | justify-content: flex-end;
49 | align-items: center;
50 | `;
51 |
52 | const RankerDescription = styled.div`
53 | font-size: 2.5rem;
54 | text-align: center;
55 | @media (max-width: 600px) {
56 | font-size: 4rem;
57 | }
58 | `;
59 |
60 | const RankPodium = ({ rank, rankerScore, rankerNickname }) => {
61 | return (
62 |
63 |
64 |
65 | {rankerNickname}
66 | {rankerScore}
67 |
68 |
69 | {rank}
70 |
71 | );
72 | };
73 |
74 | RankPodium.defaultProps = {
75 | rankerScore: '',
76 | };
77 |
78 | RankPodium.propTypes = {
79 | rank: PropTypes.string.isRequired,
80 | rankerScore: PropTypes.string,
81 | rankerNickname: PropTypes.string.isRequired,
82 | };
83 |
84 | export default RankPodium;
85 |
--------------------------------------------------------------------------------
/server/databaseFiles/databaseModels/seeder/ranking.csv:
--------------------------------------------------------------------------------
1 | nickname,score
2 | 전설,1
3 | 번개,2
4 | 바람,3
5 | 희망,4
6 | 여름,5
7 | 가을,6
8 | 겨울,7
9 | 바다,8
10 | 사랑,9
11 | 선녀,10
12 | 여신,11
13 | 엘프,12
14 | 요정,13
15 | 우주,14
16 | 백호,15
17 | 청룡,16
18 | 현무,17
19 | 주작,18
20 | 최강,19
21 | 임금,20
22 | 커플,21
23 | 뮤직,22
24 | 지도,23
25 | 나무,24
26 | 아침,25
27 | 안개,26
28 | 자객,27
29 | 검색,28
30 | 명궁,29
31 | 스타,30
32 | 선비,31
33 | 남자,32
34 | 여자,33
35 | 커피,34
36 | 멜론,35
37 | 중력,36
38 | 감성,37
39 | 강철,38
40 | 신하,39
41 | 백성,40
42 | 광고,41
43 | 인증,42
44 | 개미,43
45 | 결혼,44
46 | 고백,45
47 | 공원,46
48 | 구슬,47
49 | 나비,48
50 | 날개,49
51 | 낭만,50
52 | 내일,51
53 | 너울,52
54 | 노을,53
55 | 단풍,54
56 | 달빛,55
57 | 달콤,56
58 | 도도,57
59 | 동화,58
60 | 딸기,59
61 | 땅콩,60
62 | 라임,60
63 | 러브,60
64 | 레몬,60
65 | 로또,60
66 | 로망,60
67 | 로즈,60
68 | 리본,60
69 | 마녀,60
70 | 마음,60
71 | 메론,60
72 | 모래,60
73 | 미소,60
74 | 밍크,60
75 | 반지,60
76 | 보석,60
77 | 불꽃,60
78 | 블랙,60
79 | 블루,60
80 | 레드,60
81 | 그린,60
82 | 비누,60
83 | 사과,60
84 | 사탕,60
85 | 산타,60
86 | 새벽,60
87 | 설렘,60
88 | 세계,60
89 | 소녀,60
90 | 소라,60
91 | 소리,60
92 | 소망,60
93 | 순수,60
94 | 신비,60
95 | 수정,60
96 | 승리,60
97 | 심쿵,60
98 | 아기,60
99 | 아톰,60
100 | 안개,60
101 | 애교,60
102 | 애정,60
103 | 앵두,60
104 | 어둠,60
105 | 엔젤,60
106 | 연애,60
107 | 연인,60
108 | 연필,60
109 | 열매,60
110 | 영원,60
111 | 용기,60
112 | 우유,60
113 | 우정,60
114 | 웃음,60
115 | 유리,60
116 | 은하,60
117 | 이별,60
118 | 이슬,60
119 | 인연,60
120 | 인형,60
121 | 자유,60
122 | 장비,60
123 | 젤리,60
124 | 주인,60
125 | 진실,60
126 | 청순,60
127 | 초코,60
128 | 쿠키,60
129 | 크림,60
130 | 키스,60
131 | 투명,60
132 | 파도,60
133 | 파랑,60
134 | 평화,60
135 | 포도,60
136 | 풀잎,60
137 | 풍선,60
138 | 핑크,60
139 | 하나,60
140 | 하트,60
141 | 해피,60
142 | 햇살,60
143 | 향기,60
144 | 향수,60
145 | 허브,60
146 | 호수,60
147 | 환상,60
148 | 휴지,60
149 | 희망,60
150 | 챔피언,60
151 | 디자인,60
152 | 프로필,60
153 | 블랙홀,60
154 | 건물주,60
155 | 플러스,60
156 | 서비스,60
157 | 커플링,60
158 | 스타일,60
159 | 안개꽃,60
160 | 검사관,60
161 | 여왕벌,60
162 | 태양풍,60
163 | 태양신,60
164 | 바람꽃,60
165 | 마법사,60
166 | 바람개비,60
167 | 스쿨버스,60
168 | 두근두근,60
169 | 다정다감,60
170 | 순간이동,60
171 | 플라스틱,60
172 | Bailey,60
173 | Baldy,60
174 | Bambi,60
175 | Barbarav,60
176 | Barbie,60
177 | Barley,60
178 | Barney,60
179 | Baron,60
180 | Basil,60
181 | Baxter,60
182 | Darin,60
183 | Dario,60
184 | Darwin,60
185 | Dave,60
186 | David,60
187 | Dean,60
188 | Della,60
189 | Delling,60
190 | Delphine,60
191 | Dennis,60
192 | Jasper,60
193 | Jefferson,60
194 | Jeffrey,60
195 | Jenifer,60
196 | Jeremy,60
197 | Jericho,150
198 | Jennie,160
199 | Jerry,170
200 | Jess,180
201 |
--------------------------------------------------------------------------------
/client/src/presentation/containers/BottomRankPanel.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/forbid-prop-types */
2 | import React from 'react';
3 | import { makeStyles } from '@material-ui/core/styles';
4 | import PropTypes from 'prop-types';
5 | import { Container, Box } from '@material-ui/core';
6 | import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
7 | import { BeatLoader } from 'react-spinners';
8 | import { RankingRow } from '../components';
9 | import styleColors from '../../constants/styleColors';
10 |
11 | const useStyle = makeStyles(theme => ({
12 | bottomRankContainer: {
13 | width: '50rem',
14 | marginBottom: '3rem',
15 |
16 | [theme.breakpoints.down('xs')]: {
17 | width: '100%',
18 | },
19 | },
20 | skeleton: {
21 | '& > span > *': {
22 | marginBottom: '0.5rem',
23 | },
24 | },
25 | loading: {
26 | textAlign: 'center',
27 | marginTop: '1rem',
28 | },
29 | }));
30 |
31 | const BottomRankPanel = ({ rankingList, loading, isBottomRankingVisible }) => {
32 | const classes = useStyle();
33 | return (
34 |
35 |
42 |
43 | {isBottomRankingVisible ? (
44 | rankingList.map(ranking => {
45 | return (
46 |
52 | );
53 | })
54 | ) : (
55 |
59 |
60 |
61 |
62 |
63 | )}
64 |
65 |
72 |
73 |
74 | );
75 | };
76 |
77 | BottomRankPanel.defaultProps = {
78 | rankingList: [],
79 | };
80 |
81 | BottomRankPanel.propTypes = {
82 | rankingList: PropTypes.array,
83 | loading: PropTypes.bool.isRequired,
84 | isBottomRankingVisible: PropTypes.bool.isRequired,
85 | };
86 |
87 | export default BottomRankPanel;
88 |
--------------------------------------------------------------------------------
/server/bin/www:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | /**
4 | * Module dependencies.
5 | */
6 | /**
7 | * @todo 추후 DB 용
8 | */
9 | require('dotenv').config();
10 | const debug = require('debug')('server:server');
11 | const http = require('http');
12 | const connection = require('../databaseFiles/connection');
13 | // const { initializeQuizzes } = require('../databaseFiles/databaseModels/seeder');
14 | const app = require('../app');
15 | const { PING_INTERVAL, PING_TIMEOUT } = require('../constants/socket');
16 |
17 | /**
18 | * Create HTTP server.
19 | */
20 |
21 | const server = http.createServer(app);
22 |
23 | /**
24 | * Normalize a port into a number, string, or false.
25 | */
26 |
27 | function normalizePort(val) {
28 | const port = parseInt(val, 10);
29 |
30 | if (isNaN(port)) {
31 | // named pipe
32 | return val;
33 | }
34 |
35 | if (port >= 0) {
36 | // port number
37 | return port;
38 | }
39 |
40 | return false;
41 | }
42 |
43 | /**
44 | * Get port from environment and store in Express.
45 | */
46 |
47 | const port = normalizePort(process.env.PORT || '3001');
48 | app.set('port', port);
49 |
50 | /**
51 | * Event listener for HTTP server "error" event.
52 | */
53 | function onError(error) {
54 | if (error.syscall !== 'listen') {
55 | throw error;
56 | }
57 |
58 | const bind = typeof port === 'string' ? `Pipe ${port}` : `Port ${port}`;
59 |
60 | // handle specific listen errors with friendly messages
61 | switch (error.code) {
62 | case 'EACCES':
63 | console.error(`${bind} requires elevated privileges`);
64 | process.exit(1);
65 | break;
66 | case 'EADDRINUSE':
67 | console.error(`${bind} is already in use`);
68 | process.exit(1);
69 | break;
70 | default:
71 | throw error;
72 | }
73 | }
74 |
75 | /**
76 | * Event listener for HTTP server "listening" event.
77 | */
78 |
79 | function onListening() {
80 | const addr = server.address();
81 | const bind = typeof addr === 'string' ? `pipe ${addr}` : `port ${addr.port}`;
82 | debug(`Listening on ${bind}`);
83 | console.log(`Listening on ${bind}`);
84 | }
85 |
86 | /**
87 | * Listen on provided port, on all network interfaces.
88 | */
89 |
90 | (async () => {
91 | /**
92 | * @todo 추후 DB 용
93 | */
94 | await connection.sync();
95 | // await initializeQuizzes();
96 |
97 | app.service.attach(server, {
98 | pingInterval: PING_INTERVAL,
99 | pingTimeout: PING_TIMEOUT,
100 | });
101 | server.listen(port);
102 | server.on('error', onError);
103 | server.on('listening', onListening);
104 | })();
105 |
--------------------------------------------------------------------------------
/server/service/game/controllers/roomController.js:
--------------------------------------------------------------------------------
1 | const short = require('short-uuid');
2 | const { rooms } = require('../../io');
3 | const { MAX_PLAYER_COUNT } = require('../../../constants/gameRule');
4 | const { WAITING } = require('../../../constants/gameStatus');
5 | const GameManager = require('../models/GameManager');
6 | const Timer = require('../models/Timer');
7 |
8 | const getRoomByRoomId = roomId => {
9 | const room = rooms[roomId];
10 | return room;
11 | };
12 |
13 | /**
14 | * player가 room에 join하기 위한 함수
15 | * 새로운 room일 경우 GameManager생성 및 room.gameManager에 할당
16 | * @param {object} param0
17 | */
18 | const joinRoom = ({ socket, roomId, player, isRoomPrivate }) => {
19 | socket.join(roomId);
20 | socket.roomId = roomId;
21 | const room = getRoomByRoomId(roomId);
22 |
23 | if (!room.gameManager) {
24 | room.gameManager = new GameManager(roomId);
25 | room.timer = new Timer(roomId);
26 | }
27 | room.gameManager.addPlayer(player);
28 | room.gameManager.setIsRoomPrivate(isRoomPrivate);
29 | };
30 |
31 | const generateRoomId = () => {
32 | let roomId = short.uuid();
33 | while (rooms[roomId]) {
34 | roomId = short.uuid();
35 | }
36 | return roomId;
37 | };
38 |
39 | const isRoomJoinable = (gameManager, urlRoomId) => {
40 | if (!gameManager) return false;
41 |
42 | const players = gameManager.getPlayers();
43 | if (players.length === 0) return false;
44 |
45 | let isRoomAccessible = true;
46 | if (gameManager.getIsRoomPrivate()) {
47 | isRoomAccessible = gameManager.getRoomId() === urlRoomId;
48 | }
49 |
50 | const isRoomFull = players.length >= MAX_PLAYER_COUNT;
51 | const isRoomWaiting = gameManager.getStatus() === WAITING;
52 | return !isRoomFull && isRoomWaiting && isRoomAccessible;
53 | };
54 |
55 | /**
56 | * join할 공개방의 정보를 반환해주는 함수
57 | * @return {string} roomId
58 | */
59 | const getPublicRoomInformantionToJoin = () => {
60 | const roomIds = Object.keys(rooms);
61 |
62 | const joinableRoomId = roomIds.find(roomId => {
63 | const room = getRoomByRoomId(roomId);
64 | return isRoomJoinable(room.gameManager);
65 | });
66 | return joinableRoomId || generateRoomId();
67 | };
68 |
69 | /**
70 | * join할 비공개방의 정보를 반환해주는 함수
71 | * @return {string} roomId
72 | */
73 | const getPrivateRoomInformationToJoin = (
74 | roomIdFromUrl,
75 | isPrivateRoomCreation,
76 | ) => {
77 | if (isPrivateRoomCreation) {
78 | return generateRoomId();
79 | }
80 |
81 | const room = getRoomByRoomId(roomIdFromUrl);
82 | if (room) {
83 | if (isRoomJoinable(room.gameManager, roomIdFromUrl)) {
84 | return roomIdFromUrl;
85 | }
86 | }
87 |
88 | return null;
89 | };
90 |
91 | module.exports = {
92 | joinRoom,
93 | generateRoomId,
94 | getRoomByRoomId,
95 | getPublicRoomInformantionToJoin,
96 | getPrivateRoomInformationToJoin,
97 | };
98 |
--------------------------------------------------------------------------------
/client/config/paths.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const path = require('path');
4 | const fs = require('fs');
5 | const url = require('url');
6 |
7 | // Make sure any symlinks in the project folder are resolved:
8 | // https://github.com/facebook/create-react-app/issues/637
9 | const appDirectory = fs.realpathSync(process.cwd());
10 | const resolveApp = relativePath => path.resolve(appDirectory, relativePath);
11 |
12 | const envPublicUrl = process.env.PUBLIC_URL;
13 |
14 | function ensureSlash(inputPath, needsSlash) {
15 | const hasSlash = inputPath.endsWith('/');
16 | if (hasSlash && !needsSlash) {
17 | return inputPath.substr(0, inputPath.length - 1);
18 | } else if (!hasSlash && needsSlash) {
19 | return `${inputPath}/`;
20 | } else {
21 | return inputPath;
22 | }
23 | }
24 |
25 | const getPublicUrl = appPackageJson =>
26 | envPublicUrl || require(appPackageJson).homepage;
27 |
28 | // We use `PUBLIC_URL` environment variable or "homepage" field to infer
29 | // "public path" at which the app is served.
30 | // Webpack needs to know it to put the right