这不是教程。原由是我想用个人空闲时间作一个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炸鱼去,未完待续。