Overnap
Builds for 1 pipeline passed in 6 minutes 37 seconds

Merge branch 'develop' of http://khuhub.khu.ac.kr/2020105578/nodejs-game into feature/room

......@@ -26,8 +26,8 @@
방에 접속중인 유저의 목록에 변화가 생기면, `updateRoomUser`가 수신됩니다. `state`는 다음 3가지 값 중 하나의 값을 가집니다.
- `added`: 새로운 유저가 접속하였습니다.
- `updated`: 기존 유저의 정보가 업데이트되었습니다. (아직 사용되지 않음)
- `added`: 새로운 유저가 접속하였습니다. 자신이 접속했을 때에는 수신되지 않습니다. `joinRoom`의 결과인 `RoomInfo`를 사용하세요.
- `updated`: 기존 유저의 정보가 업데이트되었습니다. 해당 유저의 방장, 준비 여부가 변경되면 수신됩니다.
- `removed`: 유저가 방에서 퇴장하였습니다.
유저가 채팅을 입력했다면 `chat`을 보내면 됩니다. 다른 사람이 채팅을 입력한 경우에도 `chat`이 수신됩니다(`ServerInboundMessage<"chat">``ServerOutboundMessage<"chat">`이 다르게 정의되었다는 점에 유의하세요). 자신이 보낸 채팅에 대해서는 수신되지 않으므로 이 경우 오프라인으로 메세지를 추가하여야 합니다.
......@@ -36,10 +36,14 @@
## 게임
### 루프
### 준비
방장을 제외한 모든 플레이어는 준비를 해야 게임이 시작될 수 있습니다. 서버에 `ready` 메세지를 보내서 준비 상태를 설정할 수 있습니다. 준비 상태로 설정하려면 `ready` 속성을 참, 그렇지 않으면 거짓으로 담아 보내야 합니다. 누군가 `ready`를 하면 `updateRoomUser`를 통해 해당 유저의 준비 상태가 변경됩니다. 방장에게는 준비할 수 있는 버튼 대신에 게임을 시작할 수 있는 버튼이 주어집니다. 모든 플레이어가 준비해야만 버튼이 활성화 되어야 합니다. 방장이 게임 시작 버튼을 누르면 서버에 `startGame`가 전송됩니다. 만약 게임 시작에 실패하면 Response의 `reason`값으로 실패 사유가 전달되므로 이를 유저에게 보여줄 수도 있습니다. 성공적으로 게임이 시작되면 모든 유저에게 `startRound`가 전달됩니다.
### 라운드 진행
준비 화면에서 라운드가 시작되면 `startRound`가 수신됩니다. `round`는 현재 라운드 넘버 (1부터 시작), `duration`은 현재 라운드의 길이를 초 단위로 나타냅니다. `roles`는 각 플레이어가 이번 라운드에서 맡게 된 역할입니다(후술). 항상 라운드가 시작되면 타이머를 라운드의 길이로 맞춘 뒤 타이머를 정지해주세요. 이때 그림을 그리는 사람이 단어를 선택하게 됩니다. 단어 선택이 끝나면 타이머의 시간이 흐르게 됩니다.
서버는 클라이언트 타이머의 상태를 `timer`를 보내서 동기화합니다. `state``started`이면 메세지를 수신한 즉시 타이머를 동작시키고, `stopped`이면 타이머를 일시 정지합니다. 이때 `time`에 남은 시간이 초 단위로 포함되므로, 항상 이 메세지를 수신할 때마다 타이머의 남은 시간을 `time`값으로 동기화해주세요. 일반적으로 이 메세지는 단어 선택이 완료되어 라운드의 시간이 흐르기 시작하는 시점에 한 번만 전송됩니다. 라운드가 종료되면 `state: stopped``timer`가 수신됩니다.
서버는 클라이언트 타이머의 상태를 `timer`를 보내서 동기화합니다. `state``started`이면 메세지를 수신한 즉시 타이머를 동작시키고, `stopped`이면 타이머를 일시 정지합니다. 이때 `time`에 남은 시간이 초 단위로 포함되므로, 항상 이 메세지를 수신할 때마다 타이머의 남은 시간을 `time`값으로 동기화해주세요. 일반적으로 이 메세지는 단어 선택이 완료되어 라운드의 시간이 흐르기 시작하는 시점과 라운드가 종료되는 시점에 전송됩니다. 라운드가 종료되면 `state: stopped``timer`가 수신됩니다.
모든 플레이어가 단어를 맞추거나, 타이머의 시간이 0으로 떨어지면 라운드가 종료되면서 `finishRound`가 수신됩니다. 이 메세지는 이번 라운드의 정답을 포함하고 있습니다. 만약 진행할 라운드가 더 남았다면 몇 초 뒤에 다시 `startRound`가 수신될 것입니다. 그러나 이번 라운드가 마지막이었다면 `finishGame`가 수신됩니다. 이는 게임이 정상적으로 종료되었다는 의미이며, 다시 준비 화면으로 전환해주시면 됩니다.
### 역할
......@@ -53,7 +57,7 @@
### drawer
`drawer`는 라운드가 시작된 뒤 바로 `wordSet`을 통해 선택 가능한 단어들을 수신받습니다. 수신받는 단어 수는 3개입니다. `drawer`는 이 중 하나를 선택해서 그림을 그릴 수 있습니다. 단어를 선택하면 해당 단어를 `chooseWord`에 담아 서버로 송신합니다. 서버는 이를 확인하고 타이머를 동작시킵니다. 나머지 참가자들은 오직 정답의 글자수만을 담고 있는 `wordChosen`을 수신받게 됩니다.
`drawer`는 라운드가 시작된 뒤 바로 `wordSet`을 통해 선택 가능한 단어들을 수신받습니다. 수신받는 단어 수는 3개입니다. `drawer`는 이 중 하나를 선택해서 그림을 그릴 수 있습니다. 단어를 선택하면 해당 단어를 `chooseWord`에 담아 서버로 송신합니다. 서버는 이를 확인하고 타이머를 동작시킵니다. 이때 모든 플레이어는 오직 정답의 글자수만을 담고 있는 `wordChosen`과 타이머의 시작을 알리는 `timer`를 수신받습니다.
그림은 `drawer`의 브러시 움직임을 그대로 시뮬레이션하여 만들어집니다. `drawer`가 색깔, 굵기를 바꾸면 `setBrush`가 서버로 전송되고, 나머지 플레이어들은 이를 수신받게 됩니다. `size`는 브러시의 지름을 픽셀 단위로 나타내고, `color`는 브러시의 색상을 6자리 소문자 16진수로 나타냅니다. 이 메세지는 `drawing` 필드도 담고 있는데, 이는 마우스가 눌린 상태인지, 즉 브러시로 칠을 하는 상태인지를 나타냅니다. 중요한 것은 `drawer`가 캔버스 위에 마우스를 누르는 순간 `drawing``true`로 설정된 메세지가, 캔버스에서 마우스를 떼는 순간 `false`로 설정된 메세지가 서버로 전송되어야 한다는 점입니다.
만약 `drawer`가 캔버스 위에서 그림을 그리는 중이라면 실시간으로 `moveBrush`가 서버로 전송되어야 합니다. `x``y`는 캔버스 오른쪽 아래 지점을 (0, 0)로 설정했을 때의 마우스의 좌표를 픽셀 단위로 나타냅니다.
다른 플레이어들은 `setBrush`를 통해 `drawing``true`로 설정된 시점부터, 다시 `drawing``false`로 설정되는 시점까지, 마우스가 움직이는 모든 위치에 대해 점이 찍히게 됩니다. 정확히는 마우스의 좌표가 업데이트 될 때 이전 지점과 현재 지점을 선으로 이어 칠해주는 방식으로 보간을 해야 할 것입니다.
......
......@@ -14,7 +14,11 @@ export class Server {
const app = express();
const server = createServer(app);
this.io = new socketIo.Server(server);
this.io = new socketIo.Server(server, {
cors: {
origin: '*'
}
});
const roomManager = new RoomManager();
......
......@@ -38,6 +38,9 @@ export class Connection {
}
public handleRaw(raw: RawMessage): ServerResponse<any> {
if (!raw || !raw.message || !raw.type) {
return { ok: false };
}
const type = raw.type as ServerInboundMessageKey;
const message = raw.message;
......
......@@ -40,6 +40,12 @@ export class Room {
this.handler = new MessageHandler({
chat: (user, message) => {
if (
message.message.length > 300 ||
message.message.trim().length == 0
) {
return { ok: false };
}
this.sendChat(user, message.message);
return { ok: true };
},
......
......@@ -24,12 +24,12 @@ describe("방장", () => {
const response = socket2.test("joinRoom", { uuid: room.uuid });
expect(response.ok).eq(true);
expect(room.admin).eq(user1);
expect(response.result?.users[0]).eq({
expect(response.result?.users[0]).deep.eq({
username: user1.username,
admin: true,
ready: false,
});
expect(response.result?.users[1]).eq({
expect(response.result?.users[1]).deep.eq({
username: user2.username,
admin: false,
ready: false,
......
......@@ -35,9 +35,10 @@ describe("채팅", () => {
socket1.testOk("chat", { message: "Hello World" });
expect(socket2.socket.received("chat").message).eq("Hello World");
expect(socket2.socket.received("chat").sender).eq(user1.username);
socket1.socket.notReceived("chat");
expect(socket2.socket.received("chat")).deep.eq({
message: "Hello World",
sender: user1.username,
});
});
it("빈 채팅은 보낼 수 없습니다", () => {
const {
......
......@@ -48,12 +48,12 @@ describe("유효하지 않은 메세지", () => {
}).ok
).eq(false);
});
it("유효한 타입이지만 불필요한 속성이 포함된 메세지는 실패합니다", () => {
const socket = new SocketTester(roomManager);
const response = socket.testRaw({
type: "login",
message: { username: "guest", hello: "world" },
});
expect(response.ok).eq(false);
});
// it("유효한 타입이지만 불필요한 속성이 포함된 메세지는 실패합니다", () => {
// const socket = new SocketTester(roomManager);
// const response = socket.testRaw({
// type: "login",
// message: { username: "guest", hello: "world" },
// });
// expect(response.ok).eq(false);
// });
});
......
......@@ -50,6 +50,11 @@ describe("준비", () => {
room.setAdmin(user2);
expect(room.isReady(user2)).eq(false);
});
it("혼자 있는 방에서는 게임을 시작할 수 없습니다", () => {
const { room } = prepareJoinedRoom(1);
expect(room.canStart()).eq(false);
});
it("모두가 준비해야 게임을 시작할 수 있습니다", () => {
const {
sockets: [socket1, socket2, socket3],
......
......@@ -34,10 +34,4 @@ export class User {
},
});
}
public getData(): UserData {
return {
username: this.username,
};
}
}
......
......@@ -21,7 +21,7 @@ export const Login: React.FC = () => {
console.error('login error!'); // TODO: 팝업 에러?
}
});
}, []);
}, [username]);
return (
<Main>
......
import React, { useCallback, useContext, useEffect, useState } from 'react';
import { useHistory } from 'react-router';
import { Main } from '../components/common/Main';
import { MessageResponse, MessageType, RawMessage } from '../components/common/types';
import { RoomBlock } from '../components/rooms/RoomBlock';
......@@ -6,6 +7,7 @@ import { Room } from '../components/rooms/types';
import SocketContext from '../contexts/SocketContext';
export const Rooms: React.FC = () => {
const history = useHistory();
const socket = useContext(SocketContext);
const [ rooms, setRooms ] = useState<Room[]>([]);
......@@ -18,9 +20,8 @@ export const Rooms: React.FC = () => {
if (response.ok) {
setRooms(response.result!);
} else {
// TODO: 에러 핸들링
console.log("방 목록을 수신하지 못함");
console.log(response);
// 로그인하지 않고 방 목록으로 왔다고 판단
history.push('/');
}
});
}, []);
......