# 02.联机功能 - 井子棋对战版

## 介绍 `WebSocket`

很多网站为了实现推送技术，所用的技术都是轮询。轮询是在特定的的时间间隔（如每1秒），由浏览器对服务器发出HTTP请求，然后由服务器返回最新的数据给客户端的浏览器。这种传统的模式带来很明显的缺点，即浏览器需要不断的向服务器发出请求，然而HTTP请求可能包含较长的头部，其中真正有效的数据可能只是很小的一部分，显然这样会浪费很多的带宽等资源。
而比较新的技术去做轮询的效果是Comet。这种技术虽然可以双向通信，但依然需要反复发出请求。而且在Comet中，普遍采用的长链接，也会消耗服务器资源。
在这种情况下，HTML5定义了WebSocket协议，能更好的节省服务器资源和带宽，并且能够更实时地进行通讯。

## 如何创建 `WebSocket` 服务

- 创建一个 `NodeJS` 应用
- 安装 `nodejs-websocket`
- 创建 `WebSocket` 服务

在 `NodeJS` 中创建 `WebSocket` 服务

```shell
npm init
npm install nodejs-websocket
```

创建server.js
```javascript
const ws = require("nodejs-websocket");

const server = ws.createServer((conn) => {

	console.log(conn.key + "建立连接");

	conn.on("text", (msg) => {
		console.log(conn.key + "建立连接");
	});

	// 关闭连接																			  
	conn.on("close", (code, reason) => {
		console.log(conn.key + "关闭连接");
	});

	// 异常关闭																	   		
	conn.on("error", (code, reason) => {
		console.log(conn.key + "异常关闭");
	});

});

server.listen(3001, "0.0.0.0", () => {
	console.log("服务启动完毕");
});
```

## 在井字棋中连接我们的服务

```javascript
const ws = new WebSocket("ws://0.0.0.0:3001");
ws.onopen = (event) => {
}
ws.onmessage = (event) => {
	console.log(event.data);
}
ws.onclose = (event) => {
	console.log(event);
}
ws.onerror = (event) => {
	console.log(event);
}
```

WebSocket 基本事件:

| 事件名 | 意义 |
| :----: | :----: |
| open | 连接 |
| message | 收到消息 |
| close | 关闭 |
| error | 异常关闭 |

## 预制一些方法

在服务器中写一些变量，用于保存我们的数据

```javascript
/**
 * 棋盘
 * @type {number}
 */
const mapData = [
		[0, 0, 0],
		[0, 0, 0],
		[0, 0, 0],
	];

/**
 *  当前颜色，0 啥也没有， 1 白棋， 2 黑棋
 */
let curColor = 1;

let whitePlayer = null;   // 白棋玩家
let blackPlayer = null;   // 黑棋玩家

```

因为WebSocket只能发送String或Buffer，所以我们可以创建一个函数来方便发送Json格式的数据

服务端中，如下：

```javascript
/**
 * 发送Json格式的消息
 * @param conn
 * @param {Object} data
 */
function sendJson(conn, data) {
	conn.send(JSON.stringify(data));
}
```

客户端中，如下：

```javascript
/**
 * 发送json格式数据
 * @param {Object} data
 */
function sendJson(data) {
	ws.send(JSON.stringify(data));
}
```

## 完成如下UI

- 1.上坐按钮：在无座时显示"点击上坐"，自己的时候显示"点击站起"，别人在座的时候不显示
- 2.在线人数：显示在线人数

![img.png](img/02_01.png)

## 广播房间

写一个广播函数和广播房间信息函数，用于在局面变化时广播给所有客户端

`server.connections`中存储的是所有连接的客户端，长度就是个数

```javascript

/**
 * 广播JSON格式数据
 * @param data
 */
function broadcastJson(data) {
	const msg = JSON.stringify(data);
	server.connections.forEach((conn) => {
		conn.sendText(msg);
	});
}

function broadcastRoom() {
	broadcastJson({
		type: "roomData",   // type
		onlineCount: server.connections.length,     // 在线人数
		white: whitePlayer ? whitePlayer.key : null,    // 白棋玩家
		black: blackPlayer ? blackPlayer.key : null,    // 黑棋玩家
		mapData, curColor,  // 地图数据，当前期权颜色
	});
}
```

修改客户端中`WebSocket`客户端的`onmessage`函数 并创建`updateData`函数，处理界面展示

```javascript

/**
 * 用户ID
 */
let userId = "";

/**
 * 用户身份 0 观战 1 白棋 2 黑棋
 * @type {number}
 */
let identity = 0;

let whitePlayer = null;
let blackPlayer = null;


ws.onmessage = (event) => {
	const data = JSON.parse(event.data);
	const type = data.type;
	console.log(data);

	switch (type) {
		case "init":
			userId = data.userId;
			break;

		case "roomData":
			updateData(data);
			break;
	}
}

function updateData(data) {
	const {
		onlineCount, mapData, curColor,
		white, black
	} = data;

	color = curColor;
	whitePlayer = white;
	blackPlayer = black;

	// 设置用户身份
	if (white === userId) {
		identity = 1;   // 代表白棋玩家
	} else if (black === userId) {
		identity = 2;   // 代表黑棋玩家
	} else {
		identity = 0;    // 观战
	}

	// TODO 更新在线人数
	// TODO 更新棋盘显示
	// TODO 更新玩家区域显示
}
```

在客户端连接时 发送客户的UserID并广播当前房间信息

```javascript
const server = ws.createServer((conn) => {

	console.log(conn.key + "建立连接");

	sendJson(conn, { type: "init", userId: conn.key });

	broadcastRoom();

	/* ... */

});
```

客户端中点击上下座按钮，根据黑白方发送消息给服务端

```javascript
/**
 * 坐下
 * @param color 颜色
 */
function sitDown(color) {
	sendJson({ type: "sitDown", color, });
}

// 站起
function standUp(color) {
	sendJson({ type: "standUp", color });
}
```

在服务端中收到消息根据type执行上下坐逻辑

```javascript
conn.on("text", (msg) => {
	const data = JSON.parse(msg);
	const type = data.type;

	switch (type) {
		case "sitDown":
			sitDown(conn, data);
			break;

		case "standUp":
			standUp(conn, data);
			break;
	}
});


// 坐下
function sitDown(conn, data) {
	const { color } = data;
	if (color !== 1 && color !== 2) return;

	// 已经有人了啥也不做
	if (
		(color === 1 && whitePlayer)
		|| (color === 2 && blackPlayer)
	) return;

	// 已经有人了啥也不做 如果已经在座位则先下座
	conn === whitePlayer && (whitePlayer = null);
	conn === blackPlayer && (blackPlayer = null);


	color === 1 ? (whitePlayer = conn) : (blackPlayer = conn);
	broadcastRoom();    // 广播新的房间信息
}

// 坐下
function standUp(conn, data) {
	const { color } = data;
	if (color !== 1 && color !== 2) return;

	color === 1 ? (whitePlayer = null) : (blackPlayer = null);
	broadcastRoom();    // 广播新的房间信息
}

```

修改客户端之前的下棋函数， 点击下棋，给服务端发送消息，将行和列和自己的身份传送给后端

```javascript
/**
 * 点击格子
 * @param {MouseEvent} e
 */
function clickGrid(e) {
	const grid = e.target;
	if (identity !== color) return;
	if (grid.color === 0) {  // 如果是空白的
		const { row, col } = grid;
		sendJson({
			type: "chess",
			color: identity,
			row, col,
		});
	}
}
```

在服务端同样根据type去更新局面信息，并将之前客户端的检查输赢函数放到服务端稍作修改

如果落完子之后判定游戏结束，则执行gameOver将结果传给客户端，并重置棋盘

```javascript
function chess(conn, data) {
	const { color, row, col } = data;
	if (color !== curColor) return;
	if ((conn === whitePlayer && color !== 1)) return;
	if ((conn === blackPlayer && color !== 2)) return;

	mapData[row][col] = curColor;
	curColor = 3 - curColor;
	judge();    // 执行检查输赢
	broadcastRoom();
}

/**
 * 判断输赢和
 */
function judge() {
	let draw = mapData[0][0];
	for (let i = 0; i < 3; i++) {
		let rowColor = mapData[i][0]; // 计算横竖输赢
		let colColor = mapData[0][i];
		draw *= mapData[i][0] * mapData[0][i];  // 计算和棋
		for (let j = 1; j < 3; j++) {
			rowColor &= mapData[i][j];    // 计算横竖输赢
			colColor &= mapData[j][i];
			draw *= mapData[i][j] * mapData[j][i];  // 计算和棋
		}

		if (rowColor || colColor) {
			return gameOver(rowColor || colColor);
		}
	}

	// 计算斜线输赢
	const x1 = mapData[0][0] & mapData[1][1] & mapData[2][2];
	const x2 = mapData[2][0] & mapData[1][1] & mapData[0][2];
	if (x1 || x2) {
		return gameOver(x1 || x2);
	}

	// 判断是否和棋
	draw && gameOver(0);
}


/**
 * 游戏结束
 * @param {number} flag 结束标志 0 和棋， 1 白棋赢， 2 黑棋赢
 */
function gameOver(flag) {
	broadcastJson({ type: "gameOver", flag });
	reset();
}

/**
 * 重设
 */
function reset() {
	for (let row = 0; row < 3; row++) {
		for (let col = 0; col < 3; col++) {
			mapData[row][col] = 0;
		}
	}
	curColor = 1;
}
```

在客户端中处理gameOver, 显示结束面板

```javascript
ws.onmessage = (event) => {
	/* ... */

	switch (type) {
		/* ... */

		case "gameOver":
			gameOver(data.flag);
			break;
	}
}
```
