Overnap
Builds for 1 pipeline passed in 7 minutes 53 seconds

Merge branch 'feature/room' into develop

...@@ -2,6 +2,7 @@ import React from 'react'; ...@@ -2,6 +2,7 @@ import React from 'react';
2 import { BrowserRouter as Router, Switch, Route } from 'react-router-dom'; 2 import { BrowserRouter as Router, Switch, Route } from 'react-router-dom';
3 import { socket, SocketProvider } from './contexts/SocketContext'; 3 import { socket, SocketProvider } from './contexts/SocketContext';
4 import { Login } from './pages/Login'; 4 import { Login } from './pages/Login';
5 +import { Room } from './pages/Room';
5 import { Rooms } from './pages/Rooms'; 6 import { Rooms } from './pages/Rooms';
6 7
7 const App: React.FC = () => { 8 const App: React.FC = () => {
...@@ -11,7 +12,7 @@ const App: React.FC = () => { ...@@ -11,7 +12,7 @@ const App: React.FC = () => {
11 <Switch> 12 <Switch>
12 <Route exact path='/' component={Login}/> 13 <Route exact path='/' component={Login}/>
13 <Route path='/rooms' component={Rooms}></Route> 14 <Route path='/rooms' component={Rooms}></Route>
14 - <Route path='/:roomId'></Route> 15 + <Route path='/:roomId' component={Room}></Route>
15 </Switch> 16 </Switch>
16 </Router> 17 </Router>
17 </SocketProvider> 18 </SocketProvider>
......
...@@ -13,7 +13,9 @@ export const MessageType = { ...@@ -13,7 +13,9 @@ export const MessageType = {
13 LOGIN: "login", 13 LOGIN: "login",
14 ROOM_LIST_REQUEST: "roomList", 14 ROOM_LIST_REQUEST: "roomList",
15 ROOM_JOIN: "joinRoom", 15 ROOM_JOIN: "joinRoom",
16 - ROOM_LEAVE: "room_leave", 16 + ROOM_LEAVE: "leaveRoom",
17 - ROOM_USER_UPDATE: "room_user_update", 17 + ROOM_USER_UPDATE: "updateRoomUser",
18 - ROOM_CHAT: "room_chat", 18 + ROOM_CHAT: "chat",
19 + ROOM_READY: "ready",
20 + ROOM_START: "startGame"
19 } as const 21 } as const
...\ No newline at end of file ...\ No newline at end of file
......
1 +import React, { useCallback, useEffect, useRef, useState } from 'react';
2 +import { Vector } from './types';
3 +
4 +// 참고 : https://basketdeveloper.tistory.com/79
5 +
6 +export const Canvas: React.FC = () => {
7 + const canvasRef = useRef<HTMLCanvasElement>(null);
8 +
9 + const [mousePosition, setMousePosition] = useState<Vector>({ x:0, y:0 });
10 + const [isPainting, setIsPainting] = useState(false);
11 +
12 + const getCoordinates = useCallback((event: MouseEvent): Vector | undefined => {
13 + if (!canvasRef.current) {
14 + return;
15 + } else {
16 + return {
17 + x: event.pageX - canvasRef.current.offsetLeft,
18 + y: event.pageY - canvasRef.current.offsetTop
19 + };
20 + }
21 + }, []);
22 +
23 + const drawLine = useCallback((prev: Vector, current: Vector) => {
24 + if (canvasRef.current) {
25 + const context = canvasRef.current!.getContext('2d');
26 + if (context) {
27 + context.strokeStyle = 'black';
28 + context.lineJoin = 'round';
29 + context.lineWidth = 5;
30 +
31 + context.beginPath();
32 + context.moveTo(prev.x, prev.y);
33 + context.lineTo(current.x, current.y);
34 + context.closePath();
35 +
36 + context.stroke();
37 + }
38 + }
39 + }, []);
40 +
41 + const clearCanvas = useCallback(() => {
42 + if (canvasRef.current) {
43 + const context = canvasRef.current.getContext('2d');
44 + if (context) {
45 + context.clearRect(0, 0, canvasRef.current.width, canvasRef.current.height);
46 + }
47 + }
48 + }, []);
49 +
50 + const startPaint = useCallback((event: MouseEvent) => {
51 + const coordinates = getCoordinates(event);
52 + if (coordinates) {
53 + setIsPainting(true);
54 + setMousePosition(coordinates);
55 + }
56 + }, []);
57 +
58 + const paint = useCallback(
59 + (event: MouseEvent) => {
60 + // 드래그 방지
61 + event.preventDefault();
62 + event.stopPropagation();
63 +
64 + if (isPainting) {
65 + const newMousePosition = getCoordinates(event);
66 + if (mousePosition && newMousePosition) {
67 + drawLine(mousePosition, newMousePosition);
68 + setMousePosition(newMousePosition);
69 + }
70 + }
71 + },
72 + [isPainting, mousePosition]
73 + );
74 +
75 + const exitPaint = useCallback(() => {
76 + setIsPainting(false);
77 + }, []);
78 +
79 + useEffect(() => {
80 + if (canvasRef.current) {
81 + const canvas: HTMLCanvasElement = canvasRef.current;
82 +
83 + canvas.addEventListener('mousedown', startPaint);
84 + canvas.addEventListener('mousemove', paint);
85 + canvas.addEventListener('mouseup', exitPaint);
86 + canvas.addEventListener('mouseleave', exitPaint);
87 +
88 + return () => {
89 + canvas.removeEventListener('mousedown', startPaint);
90 + canvas.removeEventListener('mousemove', paint);
91 + canvas.removeEventListener('mouseup', exitPaint);
92 + canvas.removeEventListener('mouseleave', exitPaint);
93 + };
94 + }
95 + }, [startPaint, paint, exitPaint]);
96 +
97 + return (
98 + <div className='mx-3 px-2 py-1 rounded shadow'>
99 + <canvas ref={canvasRef} width='512' height='384' />
100 + </div>
101 + );
102 +}
...\ No newline at end of file ...\ No newline at end of file
1 +import React, { useCallback, useContext, useEffect, useRef, useState } from 'react';
2 +import SocketContext from '../../contexts/SocketContext';
3 +import { MessageType, RawMessage } from '../common/types';
4 +import { ChatLine } from './ChatLine';
5 +import { ChatData } from './types';
6 +
7 +interface ChatProps {
8 + w: string;
9 + h: string;
10 +}
11 +
12 +export const Chat: React.FC<ChatProps> = (props) => {
13 + const socket = useContext(SocketContext);
14 + const [ input, setInput ] = useState('');
15 + const [ chatLines, setChatLines ] = useState<ChatData[]>([]);
16 + const messageEndRef = useRef<HTMLDivElement | null>(null);
17 +
18 + useEffect(() => {
19 + const handleChatData = (message: RawMessage) => {
20 + if (message.type === MessageType.ROOM_CHAT) {
21 + setChatLines(oldChatLines => [...oldChatLines, message.message as ChatData]);
22 + }
23 + }
24 +
25 + socket.on('msg', handleChatData);
26 +
27 + return () => {
28 + socket.off('msg', handleChatData);
29 + }
30 + }, []);
31 +
32 + const handleAutoScroll = useCallback(() => {
33 + messageEndRef.current?.scrollIntoView({ behavior: 'smooth' });
34 + }, []);
35 +
36 + useEffect(handleAutoScroll, [chatLines])
37 +
38 + const handleEnter = useCallback((e: React.KeyboardEvent) => {
39 + if (e.key === 'Enter') {
40 + // setChatLines([...chatLines, { sender: 'me', message: input }]);
41 +
42 + const rawMessage: RawMessage = {
43 + type: MessageType.ROOM_CHAT,
44 + message: { message: input }
45 + }
46 + socket.emit('msg', rawMessage, () => {});
47 +
48 + setInput('');
49 + }
50 + }, [input]);
51 +
52 + return (
53 + <div className={props.w}>
54 + <div className={`${props.h} w-full rounded shadow flex flex-col overflow-y-scroll`}>
55 + {chatLines.map((line, i) => (<ChatLine key={16383+i} chatData={line}/>))}
56 + <div ref={messageEndRef} />
57 + </div>
58 + <input className='w-full px-3 py-2 bg-white
59 + placeholder-gray-400 text-gray-700 text-sm
60 + rounded shadow outline-none focus:outline-none'
61 + placeholder='Enter the answer'
62 + onChange={e => setInput(e.target.value)}
63 + value={input}
64 + onKeyPress={handleEnter}></input>
65 + </div>
66 + );
67 +}
...\ No newline at end of file ...\ No newline at end of file
1 +import React from 'react';
2 +import { ChatData } from './types';
3 +
4 +interface ChatLineProps {
5 + chatData: ChatData;
6 +}
7 +
8 +export const ChatLine: React.FC<ChatLineProps> = ({ chatData }) => {
9 + return (
10 + <div className='w-full px-3 py-1.5 bg-white
11 + text-gray-700 text-sm'>{chatData.sender} : {chatData.message}</div>
12 + );
13 +}
1 +import React, { useCallback, useContext, useEffect, useState } from 'react';
2 +import { useLocation } from 'react-router';
3 +import SocketContext from '../../contexts/SocketContext';
4 +import { MessageType, RawMessage } from '../common/types';
5 +import { User } from './types';
6 +
7 +interface ReadyLocation {
8 + state: { username: string }
9 +}
10 +
11 +interface ReadyProps {
12 + users: User[];
13 +}
14 +
15 +export const Ready: React.FC<ReadyProps> = ({ users }) => {
16 + const socket = useContext(SocketContext);
17 + const location: ReadyLocation = useLocation();
18 +
19 + const [ isAdmin, setIsAdmin ] = useState(false);
20 + const [ isReady, setIsReady ] = useState(false);
21 + const [ isAllReady, setIsAllReady ] = useState(false);
22 +
23 + useEffect(() => {
24 + const me = users.find(x => x.username === location.state.username);
25 + setIsAdmin(me?.admin || false);
26 + setIsReady(me?.ready || false);
27 +
28 + const test = true;
29 + users.forEach(x => test && x.ready);
30 + setIsAllReady(test);
31 + });
32 +
33 + const handleReady = useCallback(() => {
34 + if (isAdmin && isAllReady) {
35 + const rawMessage: RawMessage = {
36 + type: MessageType.ROOM_READY,
37 + message: {}
38 + }
39 + socket.emit('msg', rawMessage, () => {});
40 + } else {
41 + const rawMessage: RawMessage = {
42 + type: MessageType.ROOM_READY,
43 + message: { ready: !isReady }
44 + }
45 + socket.emit('msg', rawMessage, () => {});
46 + }
47 + }, [isAdmin, isReady, isAllReady]);
48 +
49 + return (
50 + <button className={`${isAdmin ? isAllReady ? 'bg-green-500' : 'bg-gray-400'
51 + : isReady ? 'bg-green-600'
52 + : 'bg-green-500 active:bg-green-600'}
53 + text-white font-bold uppercase
54 + px-7 py-3 m-8 rounded shadow
55 + outline-none focus:outline-none hover:shadow-md
56 + ease-linear transition-all duration-100`}
57 + type="button"
58 + onClick={() => handleReady()}>{isAdmin ? 'Start' : 'Ready'}</button>
59 + );
60 +}
1 +import React, { useCallback, useContext, useEffect, useState } from 'react';
2 +import { useHistory, useLocation } from 'react-router';
3 +import SocketContext from '../../contexts/SocketContext';
4 +import { MessageResponse, MessageType, RawMessage } from '../common/types';
5 +import { RoomData, UpdateRoomUser } from './types';
6 +
7 +interface RoomInfoProps {
8 + roomData: RoomData;
9 +}
10 +
11 +export const RoomInfo: React.FC<RoomInfoProps> = ({ roomData }) => {
12 + return (
13 + <div className='m-3 mb-8 w-5/6 flex items-center place-content-between'>
14 + <div>{roomData.name}</div>
15 + <div>{roomData.users.length}/{roomData.maxUsers}</div>
16 + </div>
17 + );
18 +}
...\ No newline at end of file ...\ No newline at end of file
1 +import React from 'react';
2 +import { User } from './types';
3 +import { UserStatus } from './UserStatus';
4 +
5 +interface UserInfoProps {
6 + users: User[];
7 +}
8 +
9 +export const UserInfo: React.FC<UserInfoProps> = ({ users }) => {
10 + return (
11 + <div className='w-7/12 h-60 flex justify-center'>
12 + {users.map((user) => (<UserStatus key={user.username} user={user} />))}
13 + </div>
14 + );
15 +}
...\ No newline at end of file ...\ No newline at end of file
1 +import React from 'react';
2 +import { User } from './types';
3 +
4 +interface UserStatusProps {
5 + user: User;
6 +}
7 +
8 +export const UserStatus: React.FC<UserStatusProps> = ({ user }) => {
9 + return (
10 + <div className='p-3 h-12 m-4 rounded-lg shadow'>
11 + <div className={`${user.admin ? 'text-blue-500' :
12 + user.ready ? 'text-green-500' : 'text-black'}
13 + text-lg text-center align-middle
14 + ease-linear transition-all duration-100`}>
15 + {user.username}</div>
16 + </div>
17 + )
18 +}
...\ No newline at end of file ...\ No newline at end of file
...@@ -2,5 +2,30 @@ export interface RoomData { ...@@ -2,5 +2,30 @@ export interface RoomData {
2 uuid: string; 2 uuid: string;
3 name: string; 3 name: string;
4 maxUsers: number; 4 maxUsers: number;
5 - users: string[]; 5 + users: User[];
6 +}
7 +
8 +export interface User {
9 + username: string;
10 + admin: boolean;
11 + ready: boolean;
12 +}
13 +
14 +export interface UpdateRoomUser {
15 + state: "added" | "updated" | "removed";
16 + user: {
17 + username: string;
18 + admin: boolean;
19 + ready: boolean;
20 + };
21 +}
22 +
23 +export interface ChatData {
24 + sender: string;
25 + message: string;
26 +}
27 +
28 +export interface Vector {
29 + x: number;
30 + y: number;
6 } 31 }
......
1 import React, { useCallback, useContext } from 'react'; 1 import React, { useCallback, useContext } from 'react';
2 -import { useHistory } from 'react-router'; 2 +import { useHistory, useLocation } from 'react-router';
3 import SocketContext from '../../contexts/SocketContext'; 3 import SocketContext from '../../contexts/SocketContext';
4 import { MessageResponse, MessageType, RawMessage } from '../common/types'; 4 import { MessageResponse, MessageType, RawMessage } from '../common/types';
5 import { RoomData } from '../room/types'; 5 import { RoomData } from '../room/types';
6 import { Room } from './types'; 6 import { Room } from './types';
7 7
8 +interface RoomBlockLocation {
9 + state: { username: string }
10 +}
11 +
8 interface RoomBlockProps { 12 interface RoomBlockProps {
9 room: Room 13 room: Room
10 } 14 }
...@@ -12,6 +16,8 @@ interface RoomBlockProps { ...@@ -12,6 +16,8 @@ interface RoomBlockProps {
12 export const RoomBlock: React.FC<RoomBlockProps> = ({ room }) => { 16 export const RoomBlock: React.FC<RoomBlockProps> = ({ room }) => {
13 const history = useHistory(); 17 const history = useHistory();
14 const socket = useContext(SocketContext); 18 const socket = useContext(SocketContext);
19 + const location: RoomBlockLocation = useLocation();
20 +
15 const joinRoom = useCallback(() => { 21 const joinRoom = useCallback(() => {
16 if (room.currentUsers < room.maxUsers) { 22 if (room.currentUsers < room.maxUsers) {
17 const rawMessage: RawMessage = { 23 const rawMessage: RawMessage = {
...@@ -22,7 +28,10 @@ export const RoomBlock: React.FC<RoomBlockProps> = ({ room }) => { ...@@ -22,7 +28,10 @@ export const RoomBlock: React.FC<RoomBlockProps> = ({ room }) => {
22 if (response.ok) { 28 if (response.ok) {
23 history.push({ 29 history.push({
24 pathname: '/' + room.uuid, 30 pathname: '/' + room.uuid,
25 - state: {roomData: response.result!} 31 + state: {
32 + username: location.state.username,
33 + roomData: response.result!
34 + }
26 }); 35 });
27 } else { 36 } else {
28 //TODO: 에러 MODAL을 어케띄우지? 하위컴포넌트에서 훅을 쓰면 어떻게 되는지 확인 37 //TODO: 에러 MODAL을 어케띄우지? 하위컴포넌트에서 훅을 쓰면 어떻게 되는지 확인
......
...@@ -16,7 +16,10 @@ export const Login: React.FC = () => { ...@@ -16,7 +16,10 @@ export const Login: React.FC = () => {
16 } 16 }
17 socket.emit('msg', rawMessage, (response : MessageResponse<undefined>) => { 17 socket.emit('msg', rawMessage, (response : MessageResponse<undefined>) => {
18 if (response.ok) { 18 if (response.ok) {
19 - history.push('/rooms'); 19 + history.push({
20 + pathname: '/rooms',
21 + state: { username: username }
22 + });
20 } else { 23 } else {
21 console.error('login error!'); // TODO: 팝업 에러? 24 console.error('login error!'); // TODO: 팝업 에러?
22 } 25 }
......
1 +import React, { useCallback, useContext, useEffect, useState } from 'react';
2 +import { useHistory, useLocation } from 'react-router';
3 +import { Main } from '../components/common/Main';
4 +import { MessageResponse, MessageType, RawMessage } from '../components/common/types';
5 +import { Canvas } from '../components/room/Canvas';
6 +import { Chat } from '../components/room/Chat';
7 +import { Ready } from '../components/room/Ready';
8 +import { RoomInfo } from '../components/room/RoomInfo';
9 +import { RoomData, UpdateRoomUser } from '../components/room/types';
10 +import { UserInfo } from '../components/room/UserInfo';
11 +import { UserStatus } from '../components/room/UserStatus';
12 +import SocketContext from '../contexts/SocketContext';
13 +
14 +interface RoomLocation {
15 + state: { roomData: RoomData }
16 +}
17 +
18 +export const Room: React.FC = () => {
19 + const history = useHistory();
20 + const socket = useContext(SocketContext);
21 + const location: RoomLocation = useLocation();
22 +
23 + const [ roomData, setRoomData ] = useState<RoomData>({
24 + // 기본값
25 + uuid: '0',
26 + name: 'loading...',
27 + maxUsers: 9,
28 + users: []
29 + });
30 + const [ isInGame, setIsInGame ] = useState(false);
31 +
32 + const handleUpdateRoomUser = useCallback((rawMessage: RawMessage) => {
33 + if (rawMessage.type == MessageType.ROOM_USER_UPDATE) {
34 + const data = rawMessage.message as UpdateRoomUser;
35 + if (data.state == 'removed') {
36 + const newUsers = roomData.users;
37 + const index = newUsers.indexOf(data.user);
38 + if (index < 0) {
39 + console.log('존재하지 않는 유저를 제거 시도');
40 + } else {
41 + newUsers.splice(index, 1);
42 + }
43 + setRoomData({
44 + ...roomData,
45 + users: newUsers
46 + });
47 + } else if (data.state === 'updated') {
48 + const newUsers = roomData.users;
49 + const index = newUsers.findIndex(x => x.username === data.user.username);
50 + if (index < 0) {
51 + console.log('존재하지 않는 유저를 업데이트 시도');
52 + } else {
53 + newUsers[index] = data.user;
54 + }
55 + setRoomData({
56 + ...roomData,
57 + users: newUsers
58 + })
59 + } else {
60 + setRoomData({
61 + ...roomData,
62 + users: [data.user, ...roomData.users]
63 + });
64 + }
65 + }
66 + }, [roomData]);
67 +
68 + useEffect(() => {
69 + socket.on('msg', handleUpdateRoomUser);
70 +
71 + return () => {
72 + socket.off('msg', handleUpdateRoomUser);
73 + }
74 + }, [roomData]);
75 +
76 + useEffect(() => {
77 + // 비정상적인 루트로 방을 들어오면 로그인 화면으로 푸시
78 + if (location.state === undefined) {
79 + history.push('/');
80 + return;
81 + }
82 +
83 + setRoomData(location.state.roomData);
84 +
85 + return () => {
86 + const rawMessage: RawMessage = {
87 + type: MessageType.ROOM_LEAVE,
88 + message: ''
89 + }
90 + socket.emit('msg', rawMessage, (response : MessageResponse<undefined>) => {});
91 + }
92 + }, [])
93 +
94 + return (
95 + <Main>
96 + <RoomInfo roomData={roomData}/>
97 + {
98 + isInGame ? (
99 + <div className='w-full flex'>
100 + <Canvas />
101 + <Chat w='w-4/12' h='h-80' />
102 + </div>
103 + ) : (
104 + <div className='w-full flex flex-col justify-center items-center'>
105 + <UserInfo users={roomData.users}/>
106 + <Ready users={roomData.users} />
107 + <Chat w='w-7/12' h='h-96' />
108 + </div>
109 + )
110 + }
111 + </Main>
112 + );
113 +}
...\ No newline at end of file ...\ No newline at end of file