這不是教程。原由是我想用個人空閒時間作一個MMORPG的玩具,誰尚未一個實現本身遊戲世界的夢想呢,我不是遊戲從業者,全部有關遊戲的知識都是從網上各類碎片化的信息而來的,因此想把製做過程用這種「直播」的方式發佈到網上,那樣若是有什麼很差的,錯誤的認知會被指出來。html
而這個,就是一個前導片,來試驗一下效果以及我是否可以堅持下來。前端
前端我使用React
,後端實現一個Koa
+WebScoket
的服務器。 先後端都採用TypeScript
, 爲何會用TS
? 社區有一堆用或不用的討論,而我這裏理由很簡單, 就是:我喜歡呀。node
遊戲目錄創建3個node package:react
lib包預留,可能放一些先後端都用到的模塊git
使用create-react-app
建立client
包github
cd client yarn create react-app . --template=typescript
初始化server
與lib
包web
cd server yarn init --yes yarn add -D typescript ts-node nodemon npx tsc --init
目前目錄結構看起來是這樣的: typescript
修改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
身份驗證我這裏簡單處理,由用戶輸入名字,服務器判斷有效後返回一個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壓根不是用來解決這種問題的? 若是有高人看到這篇文章還請解惑。
那麼如今看起來是這個樣子的:
有了個不錯的開始,是時候寫一點後端部分的代碼了。
後臺沒有腳手架工具幫忙自動配置好,首先須要作一點點準備工做。
修改package.json
:
"scripts": { "start": "nodemon src/index.ts" }, "nodemonConfig": { "watch": ["src"], "events": { "start": "node -e 'console.clear()'" } }
使用nodemon啓動項目,監視src文件夾。
修改tsconfig.json
"moduleResolution": "node", "target": "ES2015", "module": "commonjs", "lib": ["ES2015"], "outDir": "./lib", "rootDir": "./src",
安裝必要的依賴
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.`) } }
看一下效果
玩家進入大廳,大廳處理器首先要作的事是把當前大廳房間列表發送給玩家。 爲此,服務端須要建立一個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)); }
到目前爲止,實現了一個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) } } }
如今客戶端鏈接已經能夠接收到房間列表了。接下來要作的是建立一個房間。
建立房間的消息很簡單,就一個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組件很簡單,就是展現房間號以及房間內的玩家。我用兩個圈圈表示。看一下如今的效果:
原覺得,搞定這件事應該很快,結果忙了大半天了就開了個頭,暫且先到這吧,基友喊我lol炸魚去,未完待續。