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