使用React與WebSocket來作一個多人對戰的五子棋吧

這不是教程。原由是我想用個人空閒時間作一個MMORPG的玩具,誰尚未一個實現本身遊戲世界的夢想呢,我不是遊戲從業者,全部有關遊戲的知識都是從網上各類碎片化的信息而來的,因此想把製做過程用這種「直播」的方式發佈到網上,那樣若是有什麼很差的,錯誤的認知會被指出來。html

而這個,就是一個前導片,來試驗一下效果以及我是否可以堅持下來。前端

關於五子棋對戰遊戲

前端我使用React,後端實現一個Koa+WebScoket的服務器。 先後端都採用TypeScript, 爲何會用TS? 社區有一堆用或不用的討論,而我這裏理由很簡單, 就是:我喜歡呀。node

項目搭建

遊戲目錄創建3個node package:react

  • client
  • server
  • lib

lib包預留,可能放一些先後端都用到的模塊git

  1. 使用create-react-app建立clientgithub

    cd client
    yarn create react-app . --template=typescript
  2. 初始化serverlibweb

    cd server
    yarn init --yes
    yarn add -D typescript ts-node nodemon 
    npx tsc --init

目前目錄結構看起來是這樣的: init.pngtypescript

Step1: 把前端UI搞出來,有個看得見的界面才踏實

修改create-react-app生成的App.tsxnpm

export default function App() {
  return (
    <div className="App">
      <BrowserRouter>
        <Switch>
          <Route path="/" exact component={Login} />
          <Route path="/lobby" component={Lobby} />
          <Route path="/battle" component={BattleField} />
        </Switch>
      </BrowserRouter>
    </div>
  );
}

三個頁面分別是入口,大廳與對戰房間。json

Login模塊

身份驗證我這裏簡單處理,由用戶輸入名字,服務器判斷有效後返回一個token,以後client憑藉name與token鏈接WebSocket Server。 不搞註冊登錄這麼複雜的一套。

Login.tsx

export default function Login() {
    const [name, setName] = useState('');
    const ref = createRef<HTMLInputElement>();
    const [{ logged, loading, error }, doLogin] = Connect();
    const history = useHistory();
    useEffect(() => {
        if (logged) {
            history.push('/lobby');
        }
        ref.current?.focus();
    }, [history, logged, ref]);
    return (
        <div className="login">
            <form onSubmit={e => {
                e.preventDefault();
                doLogin(name);
            }}>
                <h3>FIVE IN A ROW</h3>
                <input
                    ref={ref}
                    placeholder="Enter your name"
                    value={name}
                    onChange={e => setName(e.target.value)}
                    disabled={loading}
                />
                <button type="submit" disabled={loading}>Login</button>
                <div className="err">{error}</div>
            </form>
        </div>
    )
}

登錄部分的邏輯放在Networking模塊裏,我將會在那初始化一個WebSocket鏈接

Networking.ts

type State = {
    loading: boolean,
    error: string,
    logged: boolean,
    name: string,
    token: string,
}

const Connect = (): [State, React.Dispatch<string>] => {
    const [loading, setLoading] = useState(false);
    const [error, setError] = useState('');
    const [logged, setLogged] = useState(false);
    const [name, setName] = useState('');
    const [token, setToken] = useState('');
    useEffect(() => {
        const request = async () => {
            setError('');
            setLoading(true);
            const res = await fetch('/api/login', {
                body: JSON.stringify({ name }),
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json'
                }
            });
            setLoading(false);

            if (res.status === 200) {
                const { token } = await res.json();
                setToken(token);
                setLogged(true);
            } else {
                const errmsg = await res.text();
                setError(errmsg);
            }

        }
        if (name && !logged) {
            request();
        }
    }, [name, logged]);
    return [{ loading, error, logged, name, token }, setName];
}

export { Connect }

事實上我用React Hooks實現這種click and send request這種需求老是感受到很彆扭,也許是打開方式不對,也許是理解有誤? useEffect壓根不是用來解決這種問題的? 若是有高人看到這篇文章還請解惑。

那麼如今看起來是這個樣子的:

login1.gif

有了個不錯的開始,是時候寫一點後端部分的代碼了。

Step2 服務端的第一次

後臺沒有腳手架工具幫忙自動配置好,首先須要作一點點準備工做。

  1. 修改package.json:

    "scripts": {
       "start": "nodemon src/index.ts"
    },
    "nodemonConfig": {
      "watch": ["src"],
      "events": {
         "start": "node -e 'console.clear()'"
       }
    }

    使用nodemon啓動項目,監視src文件夾。

  2. 修改tsconfig.json

    "moduleResolution": "node",
     "target": "ES2015", 
     "module": "commonjs", 
     "lib": ["ES2015"], 
     "outDir": "./lib", 
     "rootDir": "./src",
  3. 安裝必要的依賴

    yarn add koa koa-route ws koa-bodyparser

    PS:我安裝依賴的時候喜歡加上-E,這些npm上的庫變化太快,有時候隔段時間升了個小版本原來的用法就不對了。

開始寫代碼吧,先把登錄功能完成。 index.ts

import Koa from 'koa';
import bodyParser from 'koa-bodyparser';
import routes from 'koa-route';
import GameServer from './game-server';
import tokenUtil from './token';
const gameServer = new GameServer();
const app = new Koa();
app.use(bodyParser());
app.use(routes.post('/api/login', ctx => {
    const name: string = ctx.request.body.name || '';
    if (!/^[a-zA-Z\u4E00-\u9FA5][a-zA-Z0-9\u4E00-\u9FA5_-]{3,8}$/.test(name)) {
        ctx.status = 406;
        ctx.body = 'Invalid player name supplied.';
        return;
    }
    if (gameServer.isPlayerExists(name)) {
        ctx.status = 409;
        ctx.body = `${name} already exists.`;
        return;
    }
    ctx.body = { token: tokenUtil.create(name) };
}));

const PORT = process.env.PORT || 5000;
const httpServer = app.listen(PORT, () => {
    console.log(`Server started on port ${PORT}.`);
})

前面說過的,用一個簡單的token意思一下。

token.ts

import crypto from 'crypto';
const SECRET = 'puppy and kitten';
export default {
    create(name: string) {
        return crypto.createHash('sha256').update(name + SECRET).digest('base64');
    },

    check(name: string, token: string) {
        return this.create(name) === token;
    }
}

GameServer目前是空白的,我慢慢來填充。

export default class GameServer {
    initialize(httpServer: Server) {

    }

    isPlayerExists(name: string) {
        return true;
    }
}

initialize方法中利用koa的HttpServer建立WebSocketServer,因爲身份驗證的方式沒有使用session,那麼能在握手階段進行驗證的方式只有兩種了url帶參數和使用Sec-WesbSocket-Protocl傳參,我選擇第二種。

initialize(httpServer: Server) {
    const wss = new WebSocket.Server({ noServer: true });
    httpServer.on('upgrade', async (req: IncomingMessage, socket: Socket, head: Buffer) => {
        try {
            const conn = await this.acceptConnection(wss, req, socket, head);
            this.addConnection(conn);
        } catch (_) {
            console.error(_);
        }
    })
}

acceptConnection(wss: WebSocket.Server, req: IncomingMessage, socket: Socket, head: Buffer) {
    return new Promise<Connection>((resolve, reject) => {
        const [name, authenticated] = this.authenticate(req);
        if (authenticated) {
            wss.handleUpgrade(req, socket, head, ws => {
                const conn = new Connection(ws, name);
                resolve(conn);
            });
            if (socket.destroyed) {
                reject('Handshake failed.')
            }
        } else {
            socket.write('HTTP/1.1 401\r\n');
            socket.destroy();
            reject('Unauthorized.');
        }
    });
}

authenticate(req: IncomingMessage):[string, boolean] {
   const userInfo = (req.headers['sec-websocket-protocol'] as string) ?? '';
   const [name, token] = userInfo.split(', ');
   return [name, tokenUtil.check(decodeURIComponent(name), decodeURIComponent(token))];
}

利用協議傳參數須要進行URL編碼轉換,不然瀏覽器會報錯。改一下client代碼來試試。

Networking.ts 添加:

const connection = new Connection();

修改一下原來登錄的代碼

if (res.status === 200) {
   const { token } = await res.json();
 + connection.name = name;
 + connection.token = token;
 + connection.connect();
   setToken(token);
   setLogged(true);
}

Connection.ts

export default class Connection extends EventEmitter {
    name?: string;
    token?: string;
    ws?: WebSocket;
    readyState = -1;
    connect(url = 'ws://localhost:5000') {
        if (!this.name || !this.token)
            return;
        const ws = this.ws = new WebSocket(url, [encodeURIComponent(this.name), encodeURIComponent(this.token)]);
        this.ws.onopen = () => {
            this.readyState = ws.readyState;
            this.emit('open');
            console.log('connected to server.')
        }
        this.ws.onclose = (e) => {
            this.readyState = ws.readyState;
            this.emit('close', e.code, e.reason);
        }
        this.ws.onerror = () => {
            this.emit('error');
            this.readyState = ws.readyState;
        }
    }
}

看起來沒問題了,接下來繼續服務端的工做。

game-server.ts

let uniqueId = 100;
export default class GameServer {
    connections: Connection[] = [];

    addConnection(conn: Connection) {
        conn.id = uniqueId++;
        conn.on('close', id => {
            this.removeConnection(id);
        })
        this.connections.push(conn);
    }

    removeConnection(id: number) {
        for(let i=this.connections.length-1; i>-1; i--) {
            let conn = this.connections[i];
            if (conn.id === id) {
                conn.destory();
                this.connections.splice(i, 1);
                break;
            }
        }
    }

    isPlayerExists(name: string) {
        return this.connections.find(c => c.name === name);
    }
}

我須要把鏈接保存起來,在鏈接被關閉以後可以刪它, isPlayerExists方法順帶也就完成了,接下來是Connection類(Server side)

export default class Connection extends EventEmitter {
    on!: (event: 'close', listener: (this: Connection, id: number) => void) => this;

    id: number = 0;
    isClosed = false;
    constructor(public socket: WebSocket, public name: string) {
        super();
        socket.on('close', () => this.close());
    }

    close() {
        this.isClosed = true;
        this.emit('close', this.id);
    }

    // close socket && clean it up
    destory(code?: number) {
        this.socket.removeAllListeners();
        this.socket.close(code);
        this.removeAllListeners();
    }
}

destory方法把一切清理乾淨,這裏的close方法並不會主動關閉socket鏈接,只是發出事件由GameServer統一管理。

接下來要考慮怎麼處理數據交互了,考慮到玩家在大廳裏與在對戰房間裏是徹底不一樣的行爲,那麼爲不一樣的行爲使用不一樣的處理模塊會是一個不錯的方式, 給Connection類增長一個Processor對象用來處理不一樣狀態的交互。處理器對象有Enter, Leave, Hungup方法,好比大廳處理器的Enter方法裏面能夠發送當前開放房間的列表給client,離開方法裏能夠廣播給大廳用戶更新列表。

定義 Processor

export abstract class Processor {
    abstract handle(message:any):void;
    abstract enter(): void;
    abstract leave(): void;
    abstract hungup(): void;
}

修改Connection類

close() {
    ...
    this.processor.hungup();
}

_processor!: Processor;

get processor() {
    return this._processor;
}

set processor(current: Processor) {
    if (this._processor) {
        this._processor.leave();
    }
    this._processor = current;
    current.enter();
}

GameServer建立Connection時給一個初始processor

addConnection(conn: Connection) {
    conn.id = uniqueId++;
    conn.processor = new LobbyProcessor(conn, this);
    conn.on('close', id => {
        this.removeConnection(id);
    })
    this.connections.push(conn);
}

LobbyProcess

export default class LobbyProcessor extends Processor {
    constructor(public conn: Connection, public gs: GameServer) {
        super()
    }
    enter() {
        console.log(`Player ${this.conn.name} joined. num of players:${this.gs.connections.length}`);
    }

    handle(message:any) {}

    leave() {}

    hungup() {
        console.log(`Player ${this.conn.name} left.`)
    }
}

看一下效果
connmanager.gif

玩家進入大廳,大廳處理器首先要作的事是把當前大廳房間列表發送給玩家。 爲此,服務端須要建立一個Player對象處理遊戲相關的信息,若是把這些統統都綁定在Connection對象上無疑是很憨的。

建立一個Lobby類,用來管理遊戲大廳,LobbyProcessor傳入的對象就再也不是GameServer了,GameServer專一管理網絡鏈接的建立與刪除。

export default class Player extends EventEmitter {
    roomId: number = 0;
    id: number;
    name: string;
    constructor(public connection: Connection) {
        super();
        this.id = connection.id;
        this.name = connection.name;
    }
}

GameServer.ts

lobby: Lobby = new Lobby();
addConnection(conn: Connection) {
    ....
    conn.processor = new LobbyProcessor(conn, this.lobby);
}

Lobby.ts

export default class Lobby {
    players: Player[] = [];

    get numOfPlayers() {
        return this.players.length;
    }

    addPlayer(player: Player) {
        this.players.push(player);
    }

    removePlayer(player: Player) {
        for (let i = this.players.length - 1; i > -1; i--) {
            if (player.id === this.players[i].id) {
                this.players.splice(i, 1);
                break;
            }
        }
    }
}

大廳處理器建立一個Player對象,向大廳添加Player,而且廣播給大廳全部用戶

export default class LobbyProcessor extends Processor {
    player: Player;
    constructor(public conn: Connection, public lobby: Lobby) {
        super();
        this.player = new Player(conn);
    }
    enter() {
        this.lobby.addPlayer(this.player);
        this.lobby.boardcast(`Player ${this.player.name} joined.`, this.player.id);
        console.log(`Player ${this.player.name} joined. num of players:${this.lobby.numOfPlayers}`);
    }

    leave() {

    }

    hungup() {
        this.lobby.removePlayer(this.player);
        console.log(`Player ${this.player.name} left.`);
    }
}

來實現Lobby.boardcast

boardcast(message: any, ignoreId?: number) {
    this.players.forEach(player => {
        if (player.id !== ignoreId) {
            player.connection.send(message);
        }
    })
}

Connection.ts

send(message: any) {
    this.socket.send(JSON.stringify(message));
}

Step3 消息結構的處理

到目前爲止,實現了一個Http服務器與WebSocket服務器,他們的身份驗證系統相關聯,WebSocket服務器實現了鏈接管理器,以及一套管理鏈接交互的方式(Processor),是時候考慮考慮消息結構的問題了,前面的代碼只是簡單的廣播了一個文本字符串,這對於實時網絡應用是遠遠不夠的,咱們須要約定一套消息結構,服務端與客戶端遵循規定來序列化/反序列化消息包。

這方面的解決方案有不少,如ProtoBuf,msgpack,這裏我用Javascript Runtime原生的JSON,爲了充分利用typescript的類型檢查與智能提示,須要作一點小小的輔助工做。

基本思路是這樣的,利用typescript interface 聲明消息包結構,而後解析聲明文件自動生成用於序列化,反序列化的ts代碼。以前建立的lib包終於派上用場了,消息包的序列化與反序列化是客戶端/服務端都用得上的功能。 在lib內我會建立一個Protocol類,用它來對消息進行處理。以後client與server都引用這個包。

PS: lib 包內完成內容以後須要tsc build很不方便,貌似若是使用 Project References就能很好解決這個痛點,可是我試了下好像ts-node並不能很好的支持。是個人打開方式不對嗎? 期待有人能告訴我更好的解決方案。

創建一個packet.ts用來定義消息結構

export enum Types {
    ROOM_LIST = 1
}

type Room = {
    id: number,
    members: number[]
}

export interface HELLO_WORLD {
    action: Types.ROOM_LIST,
    rooms: Room[]
}

我指望能用下面的形式使用:

const msg = Protocol.CREATE_HELLO_WORLD(roomList);
// msg = {action:1, rooms:roomList}
let message = Protocol.decode(jsonString); 
// message的類型爲Message
// 這樣就能利用TypeScript的感知功能當message.action=1時,獲得HELLO_WORLD對應的智能提示了。

PS: decode方法內甚至能夠加入包的合法性檢查,檢查每個字段的值是否符合,這樣稍顯複雜了,我暫且不作。

typescript編譯器能夠根據ts文件生成AST,這讓事情變得簡單了,只須要拿到消息包的每個字段,而後使用對應的類型寫入方法便可。

建立generater.js (方便起見,生成器就不用ts了)

const fs = require('fs');
const ts = require('typescript');
const spawn = require('cross-spawn');
console.clear();
const args = process.argv.splice(2);
const source = args[0] || 'proto.ts';
const dest = args[3] || 'protocol.ts';

if (!fs.existsSync(source)) {
    console.error('Source declaration file does not exists');
    process.exit(0);
}

const program = ts.createProgram([source], {
    target: ts.ScriptTarget.Latest
});
const checker = program.getTypeChecker();
const sourceFile = program.getSourceFile(source);

let packets = [];
const creates = [];
ts.forEachChild(sourceFile, n => {
    if (ts.isInterfaceDeclaration(n)) {
        const symbol = checker.getSymbolAtLocation(n.name);
        const packetName = symbol.getName();
        let fn_args = [];
        let members = [];
        symbol.members.forEach(member => {
            const type = checker.getTypeOfSymbolAtLocation(member, member.valueDeclaration);
            const memberName = member.getName();
            if (!type.isLiteral()) {
                members.push(memberName)
                fn_args.push(`${memberName}:${checker.typeToString(type)}`)
            } else {
                members.push(`${memberName}:${type.value}`);
            }
        });
        const template = `export function CREATE_${packetName}(${fn_args.join(',')}): ${packetName} {
            return {${members.join(',')}}}`;
        creates.push(template);
        packets.push(packetName);
    }
});

let sourceContent = fs.readFileSync(source).toString();
const exportAllMessage = `export type Message = ` + packets.join(' | ') + ';';
sourceContent += '\r\n' + exportAllMessage + '\r\n';

sourceContent += creates.join('\r\n');
sourceContent += `\r\nexport function decode(raw: string): Message | undefined {
    let obj = undefined;
    try {
        obj = JSON.parse(raw);
    } catch (_) {
        return undefined;
    }
    if (!obj || !('action' in obj)) return undefined;
    return obj as Message;
}
`
fs.writeFileSync(dest, sourceContent);

const child = spawn('npx', ['tsc', '-d', 'protocol.ts'], { stdio: 'inherit' });
child.on('close', code => {
    if (code !== 0) {
        console.error('creation failed.')
    }
    console.log('done');
});

接下來要去server試一下發包了(server項目引用lib須要npm link或者使用yarn workspace)

首先把相關的方法參數類型加上。

import { Message } from 'lib/protocol';

//Connection.ts
send(message: Message) 

//Lobby.ts
 boardcast(message: Message, ignoreId?: number)

修改LobbyProcessor.enter,用戶進入大廳後向其發送大廳房間列表

enter() {
    this.lobby.addPlayer(this.player);
    console.log(`Player ${this.player.name} joined. num of players:${this.lobby.numOfPlayers}`);
    this.conn.send(CREATE_ROOM_LIST(this.lobby.serializeRoomList()));
}

serializeRoomList就是把當前房間列表變爲Protocol裏面定義的格式。

rooms: Room[] = [];
serializeRoomList() {
    return this.rooms.map(room => room.serialize())
}

新增長Room.ts,慢慢給他填充功能。

import Player from "./Player";
let uniqueId = 100;
export default class Room {
    id: number;
    members: Player[] = [];
    constructor() {
        this.id = uniqueId++;
    }

    serialize() {
        return {
            id: this.id,
            members: this.members.map(p => p.id)
        }
    }
}

Step4 建立房間

如今客戶端鏈接已經能夠接收到房間列表了。接下來要作的是建立一個房間。

建立房間的消息很簡單,就一個action。 把Connection類的接收方法補上

constructor() {
    ....
    socket.on('message', data => this.receiveData(data));
}

receiveData(data: WebSocket.Data) {
    const message = decode(data as string);
    message && this.processor.handle(message);
}

若是收到CREATE_ROOM包,通知大廳建立房間,當前玩家做爲主人進入房間,而且把鏈接處理器改爲RoomProcessor

LobbyProcessor.ts

handle(message: Message) {
    switch (message.action) {
        case Types.CREATE_ROOM:
            const room = this.lobby.createRoom(this.player);
            this.player.enterRoom(room);
            this.conn.processor = new RoomProcessor(this.conn, this.lobby, this.player);
            break;
    }
}

Lobby.createRoom

createRoom(host: Player) {
    const room = new Room();
    room.host = host;
    this.rooms.push(room);
    this.boardcast(CREATE_ROOM_LIST(this.serializeRoomList()), host.id);
    return room;
}

Player.ts

enterRoom(room: Room) {
    this.roomId = room.id;
    room.members.push(this);
}

leaveRoom(room: Room) {
    this.roomId = 0;
    room.removePlayer(this);
}

RoomProcessor中,enter方法發送給玩家進入房間的消息,hungup方法 在玩家退出以後作一些清理工做。

enter() {
    const room = this.lobby.findRoom(this.player.roomId);
    room && this.conn.send(CREATE_ENTER_ROOM(room.id, room.host === this.player));
}

hungup() {
    this.lobby.removePlayer(this.player);
    if (this.player.roomId) {
        const room = this.lobby.findRoom(this.player.roomId);
        if (room) {
            this.player.leaveRoom(room);
            this.lobby.removeOfResetRoom(room);
        }
    }
    console.log(`Player ${this.player.name} left.`);
}

在玩家退出房間後,若是房間沒有其餘人了就刪房間,若是房間主人不在了那麼順位繼承大統。 Lobby.ts

removeRoom(id: number) {
    for (let i = this.rooms.length - 1; i > -1; i--) {
        if (this.rooms[i].id === id) {
            this.rooms.splice(i, 1);
            break;
        }
    }
    this.boardcast(CREATE_ROOM_LIST(this.serializeRoomList()));
}

transferRoom(room: Room) {

}

removeOfResetRoom(room: Room) {
    if (room.members.length === 0) {
        this.removeRoom(room.id);
    } else if (!room.host) {
        this.transferRoom(room);
    }
}

前端須要作一點工做來現實房間列表和發送建立房間命令

Connection.ts

connect() {
    ...
    this.ws.onopen = () => {
        this.readyState = ws.readyState;
        this.emit('connect');
    }

    this.ws.onmessage = (e) => {
        const message = decode(e.data);
        message && this.onMessage(message);
    }
}

onMessage(message: Message) {
    switch (message.action) {
        case Types.ROOM_LIST:
            this.emit('roomList', message.rooms);
            break;
        case Types.ENTER_ROOM:
            this.emit('enterRoom', message.roomId, message.isHost);
            break;
    }
}

send(message: Message) {
        this.ws?.send(JSON.stringify(message));
    }

createRoom() {
    this.send(CREATE_CREATE_ROOM());
}

enterRoom(id: number) {
    //
}

Lobby.tsx

export default function Lobby() {
    const history = useHistory();
    const [roomList, setRoomList] = useState<Room[]>([]);
    const [connected, setConnected] = useState(false);
    useEffect(() => {
        connection.on('connect', () => {
            setConnected(true);
        });
        connection.on('roomList', (rooms: Room[]) => {
            setRoomList(rooms);
        });
        connection.on('enterRoom', (roomId: number, isHost: boolean) => {
            history.push('/battle')
        });
        if (!connection.token) {
            history.push('/');

        } else {
            connection.connect();
        }
        return () => {
            connection.removeAllListeners();
        }
    }, [history])
    return (
        <div className="lobby">
            {
                roomList.map(room =>
                    (
                        <RoomCompoent
                            key={room.id}
                            {...room}
                            onClick={roomId => connection.enterRoom(roomId)}
                        />
                    )
                )
            }
            <button className="newRoom" disabled={!connected} onClick={() => connection.createRoom()}>NEW</button>
        </div>
    )
}

connect的調用放到這裏來,在鏈接成功以前,NEW按鈕不可點擊

Room.tsx

export default (props: RoomProps) => {
    const { id, members } = props;
    let className = 'room';
    if (members.length > 1)
        className += ' busy';

    const onClick = () => {
        (members.length === 1) && props.onClick(id);
    }
    return (
        <div className={className} onClick={onClick}>
            {members.map((m, i) => {
                const cls = i === 0 ? 'member' : 'member sec';
                return <div key={i} className={cls}></div>
            })}
            <div className="roomId">{id}</div>
        </div>
    )
}

Room組件很簡單,就是展現房間號以及房間內的玩家。我用兩個圈圈表示。看一下如今的效果:
image

原覺得,搞定這件事應該很快,結果忙了大半天了就開了個頭,暫且先到這吧,基友喊我lol炸魚去,未完待續。

代碼戳這裏

系列之二

相關文章
相關標籤/搜索