前端渲染地图
联机逻辑开发进度:■■■■■■■■■□□□
本章结束开发进度:■■■■■■■■■■■□
上一章的答案
index.html:
...<script>var app = new Vue({...data: {...roomId: '',},...methods: {...startRoom() {let actions = {"code": 601, 'room_id': this.roomId};this.websocketsend(actions);},...websocketonmessage(e) { //数据接收let message = JSON.parse(e.data);let responseData = message.dataswitch (message.code) {case 1001://匹配成功this.roomId = responseData.room_idthis.startRoom()break;}},...}})</script>...
童鞋们做出来了吗?这次的作业比较简单哦。
美化前端页面
现在的前端页面稍微太简陋了点,需要来美化一下。
做题时间
- 点击匹配时,页面显示
匹配中... - 接收到
room_id时,将匹配中...去掉
index.html:
...<div id="app">...<div v-if="matching" style="display: inline">匹配中……</div></div><script>var app = new Vue({...data: {...matching: false,},...methods: {//匹配玩家matchPlayer() {...this.matching = true;},startRoom() {...this.matching = false;},...}})</script>...
开始游戏
在开始这一节之前,我们再来梳理一次目前的匹配功能进度
- 前端连接时发送
player_id - 服务端连接时保存玩家信息
- 前端发送
code为600的指令 - 服务端将
player_id放入匹配队列 - 服务端发起一个
task进行玩家匹配,当寻找到两个玩家时返回两个player_id到worker进程 - 服务端将两个玩家的连接绑定到同一个
worker中,并通知前端房间room_id - 前端接收到房间
room_id消息后,发送code为601的开始游戏消息
所以说,我们下一步要做的,就是在服务端中接收到开始游戏请求时,调用我们最前面的单机游戏逻辑,创建一个Game对象,并发送游戏数据到玩家客户端。
做大题时间
- 当服务端接收到
code为601的消息时,调用Logic的startRoom($roomId, $playerId)方法。 startRoom()方法创建一个Game对象并保存在DataCenter的$global中。- 当第一个玩家进入
startRoom()方法时,调用Game对象createPlayer()创建玩家并发送请等待消息到客户端 - 当第二个玩家进入
startRoom()方法时,调用Game对象createPlayer()创建玩家并发送游戏开始消息到两个玩家客户端,并开始向双方发送游戏数据。
这次的功能需求有点难,请童鞋们尽力而为。
难点一:保存到
$global中时,如何区分不同的房间Game对象?难点二:为什么需要等待第二个玩家加入呢?这是因为网络消息是会有延迟的,总不能第一个玩家已加入,而第二个玩家还未加入的情况下就直接开始游戏吧~如何获取房间当前玩家数呢?还记得
Game类中的变量$players吗?难点三:发送游戏数据时,如何获取游戏数据呢?大家还记得
printGameMap()方法吗?可以参考一下哦。
Server类:
<?php...class Server{...const CLIENT_CODE_START_ROOM = 601;...public function onMessage($server, $request){...switch ($data['code']) {...case self::CLIENT_CODE_START_ROOM:$this->logic->startRoom($data['room_id'], $playerId);break;}}...}...
Sender类:
<?php...class Sender{...const MSG_WAIT_PLAYER = 1002;const MSG_ROOM_START = 1003;const MSG_GAME_INFO = 1004;const CODE_MSG = [...self::MSG_WAIT_PLAYER => '等待其他玩家中……',self::MSG_ROOM_START => '游戏开始啦~',self::MSG_GAME_INFO => 'game info'];...}
Game类:
<?php...class Game{...public function getPlayers(){return $this->players;}public function getMapData(){return $this->gameMap->getMapData();}...}
Logic类:
<?php...class Logic{...public function startRoom($roomId, $playerId){if (!isset(DataCenter::$global['rooms'][$roomId])) {DataCenter::$global['rooms'][$roomId] = ['id' => $roomId,'manager' => new Game()];}/*** @var Game $gameManager*/$gameManager = DataCenter::$global['rooms'][$roomId]['manager'];if (empty(count($gameManager->getPlayers()))) {//第一个玩家$gameManager->createPlayer($playerId, 6, 1);Sender::sendMessage($playerId, Sender::MSG_WAIT_PLAYER);} else {//第二个玩家$gameManager->createPlayer($playerId, 6, 10);Sender::sendMessage($playerId, Sender::MSG_ROOM_START);$this->sendGameInfo($roomId);}}private function sendGameInfo($roomId){/*** @var Game $gameManager* @var Player $player*/$gameManager = DataCenter::$global['rooms'][$roomId]['manager'];$players = $gameManager->getPlayers();$mapData = $gameManager->getMapData();foreach ($players as $player) {$mapData[$player->getX()][$player->getY()] = $player->getId();}foreach ($players as $player) {$data = ['players' => $players,'map_data' => $mapData];Sender::sendMessage($player->getId(), Sender::MSG_GAME_INFO, $data);}}...}
感觉上好像没有什么问题,我们测试一下。重启Server,在浏览器打开两个前端页面并匹配。

可以看到,成功获取到地图数据啦。
优化地图数据
但是我们这个游戏可是捉迷藏啊!怎么能把对手的地图数据也发送出去呢!我们来优化一下。
做题时间
- 在
Logic中新增私有方法getNearMap($mapData, $x, $y),根据地图数据以及玩家坐标,仅返回玩家坐标附近范围为2的地图数据。 - 在发送游戏地图数据前,调用
getNearMap()方法获取附近地图数据。
小问题:当玩家在地图边缘时该如何获取
两步之外不存在的地图数据?简单粗暴点的办法可以直接通过
$x-2之类的硬编码来直接获取灵活一点的办法就是通过一个算法来计算得出

Logic类:
<?php...class Logic{const PLAYER_DISPLAY_LEN = 2;...private function sendGameInfo($roomId){...foreach ($players as $player) {$data = [...'map_data' => $this->getNearMap($mapData, $player->getX(), $player->getY())];...}}private function getNearMap($mapData, $x, $y){$result = [];for ($i = -1 * self::PLAYER_DISPLAY_LEN; $i <= self::PLAYER_DISPLAY_LEN; $i++) {$tmp = [];for ($j = -1 * self::PLAYER_DISPLAY_LEN; $j <= self::PLAYER_DISPLAY_LEN; $j++) {$tmp[] = $mapData[$x + $i][$y + $j] ?? 0;}$result[] = $tmp;}return $result;}}
重启Server,再看一次发送的地图数据。

可以看到,这次的数据就正常多了,至少不会把对手信息透露出去๑乛◡乛๑。
前端渲染游戏
目前游戏数据已经拿到了,但是前端画面还没渲染出来。
做题时间
- 在
Vue对象中新增数据属性mapData,当接收到服务端发来的地图数据时,保存在这个变量中。 - 当
Vue属性mapData不为空时,渲染地图。
小提示:需要使用
v-for和<template>标签。如果不太熟悉
CSS的童鞋可以使用文字版渲染来练习一下,后面再替换赵童鞋提供的代码。
index.html:
<!DOCTYPE html><html lang="en"><head>...<style>.gameItem {display: inline-block;width: 100px;height: 100px;line-height: 100px;border: 1px solid black;text-align: center;}.wall {background-color: black;}.road {color: white;}.player {}</style></head><body><div id="app">...<br><hr><div v-if="mapData" style="display: flex"><div><template v-for="column in mapData"><div><template v-for="item in column"><div v-if="item==playerId" class="gameItem player">{{playerId}}</div><div v-else-if="item==0" class="gameItem wall">墙</div><div v-else-if="item==1" class="gameItem road">路</div><div v-else class="gameItem player">{{item}}</div></template></div></template></div></div></div><script>var app = new Vue({...data: {...mapData: null,},...methods: {...websocketonmessage(e) { //数据接收...switch (message.code) {...case 1004://游戏数据this.mapData = responseData.map_data;break;}},...}})</script>...
重启游戏服务器,打开两个前端页面尝试匹配。

看到这个页面就证明渲染成功啦。
发送移动指令
做题时间
- 前端新增
上、下、左、右四个按钮,当点击按钮时,发送对应的up、down、left、right指令到服务端。 - 服务端接收到移动指令后,更新对应
$player对象的x、y坐标。 - 再次调用
sendGameInfo()发送游戏数据。
这里会有一个小问题,目前我们通过连接的
fd可以获取到player_id,但似乎无法通过player_id获取到玩家的room_id哦,获取不到room_id将无法获取对应房间的Manager。有两种解决方法:
- 每次发送移动指令时数据都加上
room_id。- 在某一个时刻我们需要将
room_id保存到Redis中,以便后面随时读取。
请童鞋们尽可能独立完成前端页面,但是不做硬性要求,下面先给出前端页面的代码,代码较多,但主要点击功能其实只有clickDirect()方法,其余方法都是为了页面美观。
index.html
<!DOCTYPE html><html lang="en"><head>...<style>.gameItem {display: inline-block;width: 100px;height: 100px;line-height: 100px;border: 1px solid black;text-align: center;}.wall {background-color: black;}.road {color: white;}.player {}.gameButton {background-color: #efefef;}.space {background-color: white;color: white;border: 0;margin: 1px;}.clickButton {background: #dddddd;}</style></head><body><div id="app">...<div v-if="mapData" style="display: flex">...<div><template v-for="i in 5"><div @mouseup="removeClickClass"><template v-for="j in 5"><div v-if="i==2&&j==3" @mousedown="clickDirect('up')" data-direction="up"class="gameItem gameButton">上</div><div v-else-if="i==3&&j==2" @mousedown="clickDirect('left')" data-direction="left"class="gameItem gameButton">左</div><div v-else-if="i==3&&j==4" @mousedown="clickDirect('right')" data-direction="right"class="gameItem gameButton">右</div><div v-else-if="i==4&&j==3" @mousedown="clickDirect('down')" data-direction="down"class="gameItem gameButton">下</div><div v-else class="gameItem space">无</div></template></div></template></div></div></div><script>var app = new Vue({...methods: {...clickDirect(direction) {let actions = {"code": 602, 'direction': direction};this.websocketsend(actions);this.addClickClass(direction);},hasClass(ele, cls) {return ele.className.match(new RegExp("(\\s|^)" + cls + "(\\s|$)"));},//为指定的dom元素添加样式addClass(ele, cls) {if (!this.hasClass(ele, cls)) ele.className += " " + cls;},//删除指定dom元素的样式removeClass(ele, cls) {if (this.hasClass(ele, cls)) {let reg = new RegExp("(\\s|^)" + cls + "(\\s|$)");ele.className = ele.className.replace(reg, " ");}},addClickClass(direction) {let divs = document.getElementsByClassName('gameButton')for (let div of divs) {if (div.dataset.direction === direction) {this.addClass(div, 'clickButton')}}},removeClickClass() {let divs = document.getElementsByClassName('gameButton')for (let div of divs) {this.removeClass(div, 'clickButton')}},}})</script>...
服务端代码就作为今天的Homework啦,请童鞋们尽力独自完成,下一章将会是最后一章。
本章对应Github Commit:第九章结束
当前目录结构:
HideAndSeek├── app│ ├── Lib│ │ └── Redis.php│ ├── Manager│ │ ├── DataCenter.php│ │ ├── Game.php│ │ ├── Logic.php│ │ ├── Sender.php│ │ └── TaskManager.php│ ├── Model│ │ ├── Map.php│ │ └── Player.php│ └── Server.php├── composer.json├── composer.lock├── frontend│ └── index.html├── test.php└── vendor├── autoload.php└── composer
