前端渲染地图

联机逻辑开发进度:■■■■■■■■■□□□

本章结束开发进度:■■■■■■■■■■■□

上一章的答案

index.html

  1. ...
  2. <script>
  3. var app = new Vue({
  4. ...
  5. data: {
  6. ...
  7. roomId: '',
  8. },
  9. ...
  10. methods: {
  11. ...
  12. startRoom() {
  13. let actions = {"code": 601, 'room_id': this.roomId};
  14. this.websocketsend(actions);
  15. },
  16. ...
  17. websocketonmessage(e) { //数据接收
  18. let message = JSON.parse(e.data);
  19. let responseData = message.data
  20. switch (message.code) {
  21. case 1001://匹配成功
  22. this.roomId = responseData.room_id
  23. this.startRoom()
  24. break;
  25. }
  26. },
  27. ...
  28. }
  29. })
  30. </script>
  31. ...

童鞋们做出来了吗?这次的作业比较简单哦。

美化前端页面

现在的前端页面稍微太简陋了点,需要来美化一下。

做题时间

  1. 点击匹配时,页面显示匹配中...
  2. 接收到room_id时,将匹配中...去掉

index.html

  1. ...
  2. <div id="app">
  3. ...
  4. <div v-if="matching" style="display: inline">
  5. 匹配中……
  6. </div>
  7. </div>
  8. <script>
  9. var app = new Vue({
  10. ...
  11. data: {
  12. ...
  13. matching: false,
  14. },
  15. ...
  16. methods: {
  17. //匹配玩家
  18. matchPlayer() {
  19. ...
  20. this.matching = true;
  21. },
  22. startRoom() {
  23. ...
  24. this.matching = false;
  25. },
  26. ...
  27. }
  28. })
  29. </script>
  30. ...

开始游戏

在开始这一节之前,我们再来梳理一次目前的匹配功能进度

  • 前端连接时发送player_id
  • 服务端连接时保存玩家信息
  • 前端发送code600的指令
  • 服务端将player_id放入匹配队列
  • 服务端发起一个task进行玩家匹配,当寻找到两个玩家时返回两个player_idworker进程
  • 服务端将两个玩家的连接绑定到同一个worker中,并通知前端房间room_id
  • 前端接收到房间room_id消息后,发送code601的开始游戏消息

所以说,我们下一步要做的,就是在服务端中接收到开始游戏请求时,调用我们最前面的单机游戏逻辑,创建一个Game对象,并发送游戏数据到玩家客户端。

做大题时间

  1. 当服务端接收到code601的消息时,调用LogicstartRoom($roomId, $playerId)方法。
  2. startRoom()方法创建一个Game对象并保存在DataCenter$global中。
  3. 当第一个玩家进入startRoom()方法时,调用Game对象createPlayer()创建玩家并发送请等待消息到客户端
  4. 当第二个玩家进入startRoom()方法时,调用Game对象createPlayer()创建玩家并发送游戏开始消息到两个玩家客户端,并开始向双方发送游戏数据。

这次的功能需求有点难,请童鞋们尽力而为。

难点一:保存到$global中时,如何区分不同的房间Game对象?

难点二:为什么需要等待第二个玩家加入呢?这是因为网络消息是会有延迟的,总不能第一个玩家已加入,而第二个玩家还未加入的情况下就直接开始游戏吧~如何获取房间当前玩家数呢?还记得Game类中的变量$players吗?

难点三:发送游戏数据时,如何获取游戏数据呢?大家还记得printGameMap()方法吗?可以参考一下哦。

Server类:

  1. <?php
  2. ...
  3. class Server
  4. {
  5. ...
  6. const CLIENT_CODE_START_ROOM = 601;
  7. ...
  8. public function onMessage($server, $request)
  9. {
  10. ...
  11. switch ($data['code']) {
  12. ...
  13. case self::CLIENT_CODE_START_ROOM:
  14. $this->logic->startRoom($data['room_id'], $playerId);
  15. break;
  16. }
  17. }
  18. ...
  19. }
  20. ...

Sender类:

  1. <?php
  2. ...
  3. class Sender
  4. {
  5. ...
  6. const MSG_WAIT_PLAYER = 1002;
  7. const MSG_ROOM_START = 1003;
  8. const MSG_GAME_INFO = 1004;
  9. const CODE_MSG = [
  10. ...
  11. self::MSG_WAIT_PLAYER => '等待其他玩家中……',
  12. self::MSG_ROOM_START => '游戏开始啦~',
  13. self::MSG_GAME_INFO => 'game info'
  14. ];
  15. ...
  16. }

Game类:

  1. <?php
  2. ...
  3. class Game
  4. {
  5. ...
  6. public function getPlayers()
  7. {
  8. return $this->players;
  9. }
  10. public function getMapData()
  11. {
  12. return $this->gameMap->getMapData();
  13. }
  14. ...
  15. }

Logic类:

  1. <?php
  2. ...
  3. class Logic
  4. {
  5. ...
  6. public function startRoom($roomId, $playerId)
  7. {
  8. if (!isset(DataCenter::$global['rooms'][$roomId])) {
  9. DataCenter::$global['rooms'][$roomId] = [
  10. 'id' => $roomId,
  11. 'manager' => new Game()
  12. ];
  13. }
  14. /**
  15. * @var Game $gameManager
  16. */
  17. $gameManager = DataCenter::$global['rooms'][$roomId]['manager'];
  18. if (empty(count($gameManager->getPlayers()))) {
  19. //第一个玩家
  20. $gameManager->createPlayer($playerId, 6, 1);
  21. Sender::sendMessage($playerId, Sender::MSG_WAIT_PLAYER);
  22. } else {
  23. //第二个玩家
  24. $gameManager->createPlayer($playerId, 6, 10);
  25. Sender::sendMessage($playerId, Sender::MSG_ROOM_START);
  26. $this->sendGameInfo($roomId);
  27. }
  28. }
  29. private function sendGameInfo($roomId)
  30. {
  31. /**
  32. * @var Game $gameManager
  33. * @var Player $player
  34. */
  35. $gameManager = DataCenter::$global['rooms'][$roomId]['manager'];
  36. $players = $gameManager->getPlayers();
  37. $mapData = $gameManager->getMapData();
  38. foreach ($players as $player) {
  39. $mapData[$player->getX()][$player->getY()] = $player->getId();
  40. }
  41. foreach ($players as $player) {
  42. $data = [
  43. 'players' => $players,
  44. 'map_data' => $mapData
  45. ];
  46. Sender::sendMessage($player->getId(), Sender::MSG_GAME_INFO, $data);
  47. }
  48. }
  49. ...
  50. }

感觉上好像没有什么问题,我们测试一下。重启Server,在浏览器打开两个前端页面并匹配。

9 前端渲染地图 - 图1

可以看到,成功获取到地图数据啦。

优化地图数据

但是我们这个游戏可是捉迷藏啊!怎么能把对手的地图数据也发送出去呢!我们来优化一下。

做题时间

  1. Logic中新增私有方法getNearMap($mapData, $x, $y),根据地图数据以及玩家坐标,仅返回玩家坐标附近范围为2的地图数据。
  2. 在发送游戏地图数据前,调用getNearMap()方法获取附近地图数据。

小问题:当玩家在地图边缘时该如何获取两步之外不存在的地图数据?

简单粗暴点的办法可以直接通过$x-2之类的硬编码来直接获取

灵活一点的办法就是通过一个算法来计算得出

9 前端渲染地图 - 图2

Logic类:

  1. <?php
  2. ...
  3. class Logic
  4. {
  5. const PLAYER_DISPLAY_LEN = 2;
  6. ...
  7. private function sendGameInfo($roomId)
  8. {
  9. ...
  10. foreach ($players as $player) {
  11. $data = [
  12. ...
  13. 'map_data' => $this->getNearMap($mapData, $player->getX(), $player->getY())
  14. ];
  15. ...
  16. }
  17. }
  18. private function getNearMap($mapData, $x, $y)
  19. {
  20. $result = [];
  21. for ($i = -1 * self::PLAYER_DISPLAY_LEN; $i <= self::PLAYER_DISPLAY_LEN; $i++) {
  22. $tmp = [];
  23. for ($j = -1 * self::PLAYER_DISPLAY_LEN; $j <= self::PLAYER_DISPLAY_LEN; $j++) {
  24. $tmp[] = $mapData[$x + $i][$y + $j] ?? 0;
  25. }
  26. $result[] = $tmp;
  27. }
  28. return $result;
  29. }
  30. }

重启Server,再看一次发送的地图数据。

9 前端渲染地图 - 图3

可以看到,这次的数据就正常多了,至少不会把对手信息透露出去๑乛◡乛๑。

前端渲染游戏

目前游戏数据已经拿到了,但是前端画面还没渲染出来。

做题时间

  1. Vue对象中新增数据属性mapData,当接收到服务端发来的地图数据时,保存在这个变量中。
  2. Vue属性mapData不为空时,渲染地图。

小提示:需要使用v-for<template>标签。

如果不太熟悉CSS的童鞋可以使用文字版渲染来练习一下,后面再替换赵童鞋提供的代码。

index.html

  1. <!DOCTYPE html>
  2. <html lang="en">
  3. <head>
  4. ...
  5. <style>
  6. .gameItem {
  7. display: inline-block;
  8. width: 100px;
  9. height: 100px;
  10. line-height: 100px;
  11. border: 1px solid black;
  12. text-align: center;
  13. }
  14. .wall {
  15. background-color: black;
  16. }
  17. .road {
  18. color: white;
  19. }
  20. .player {
  21. }
  22. </style>
  23. </head>
  24. <body>
  25. <div id="app">
  26. ...
  27. <br>
  28. <hr>
  29. <div v-if="mapData" style="display: flex">
  30. <div>
  31. <template v-for="column in mapData">
  32. <div>
  33. <template v-for="item in column">
  34. <div v-if="item==playerId" class="gameItem player">{{playerId}}</div>
  35. <div v-else-if="item==0" class="gameItem wall"></div>
  36. <div v-else-if="item==1" class="gameItem road"></div>
  37. <div v-else class="gameItem player">{{item}}</div>
  38. </template>
  39. </div>
  40. </template>
  41. </div>
  42. </div>
  43. </div>
  44. <script>
  45. var app = new Vue({
  46. ...
  47. data: {
  48. ...
  49. mapData: null,
  50. },
  51. ...
  52. methods: {
  53. ...
  54. websocketonmessage(e) { //数据接收
  55. ...
  56. switch (message.code) {
  57. ...
  58. case 1004://游戏数据
  59. this.mapData = responseData.map_data;
  60. break;
  61. }
  62. },
  63. ...
  64. }
  65. })
  66. </script>
  67. ...

重启游戏服务器,打开两个前端页面尝试匹配。

9 前端渲染地图 - 图4

看到这个页面就证明渲染成功啦。

发送移动指令

做题时间

  1. 前端新增四个按钮,当点击按钮时,发送对应的updownleftright指令到服务端。
  2. 服务端接收到移动指令后,更新对应$player对象的xy坐标。
  3. 再次调用sendGameInfo()发送游戏数据。

这里会有一个小问题,目前我们通过连接的fd可以获取到player_id,但似乎无法通过player_id获取到玩家的room_id哦,获取不到room_id将无法获取对应房间的Manager

有两种解决方法:

  1. 每次发送移动指令时数据都加上room_id
  2. 在某一个时刻我们需要将room_id保存到Redis中,以便后面随时读取。

请童鞋们尽可能独立完成前端页面,但是不做硬性要求,下面先给出前端页面的代码,代码较多,但主要点击功能其实只有clickDirect()方法,其余方法都是为了页面美观。

index.html

  1. <!DOCTYPE html>
  2. <html lang="en">
  3. <head>
  4. ...
  5. <style>
  6. .gameItem {
  7. display: inline-block;
  8. width: 100px;
  9. height: 100px;
  10. line-height: 100px;
  11. border: 1px solid black;
  12. text-align: center;
  13. }
  14. .wall {
  15. background-color: black;
  16. }
  17. .road {
  18. color: white;
  19. }
  20. .player {
  21. }
  22. .gameButton {
  23. background-color: #efefef;
  24. }
  25. .space {
  26. background-color: white;
  27. color: white;
  28. border: 0;
  29. margin: 1px;
  30. }
  31. .clickButton {
  32. background: #dddddd;
  33. }
  34. </style>
  35. </head>
  36. <body>
  37. <div id="app">
  38. ...
  39. <div v-if="mapData" style="display: flex">
  40. ...
  41. <div>
  42. <template v-for="i in 5">
  43. <div @mouseup="removeClickClass">
  44. <template v-for="j in 5">
  45. <div v-if="i==2&&j==3" @mousedown="clickDirect('up')" data-direction="up"
  46. class="gameItem gameButton">
  47. </div>
  48. <div v-else-if="i==3&&j==2" @mousedown="clickDirect('left')" data-direction="left"
  49. class="gameItem gameButton">
  50. </div>
  51. <div v-else-if="i==3&&j==4" @mousedown="clickDirect('right')" data-direction="right"
  52. class="gameItem gameButton">
  53. </div>
  54. <div v-else-if="i==4&&j==3" @mousedown="clickDirect('down')" data-direction="down"
  55. class="gameItem gameButton">
  56. </div>
  57. <div v-else class="gameItem space"></div>
  58. </template>
  59. </div>
  60. </template>
  61. </div>
  62. </div>
  63. </div>
  64. <script>
  65. var app = new Vue({
  66. ...
  67. methods: {
  68. ...
  69. clickDirect(direction) {
  70. let actions = {"code": 602, 'direction': direction};
  71. this.websocketsend(actions);
  72. this.addClickClass(direction);
  73. },
  74. hasClass(ele, cls) {
  75. return ele.className.match(new RegExp("(\\s|^)" + cls + "(\\s|$)"));
  76. },
  77. //为指定的dom元素添加样式
  78. addClass(ele, cls) {
  79. if (!this.hasClass(ele, cls)) ele.className += " " + cls;
  80. },
  81. //删除指定dom元素的样式
  82. removeClass(ele, cls) {
  83. if (this.hasClass(ele, cls)) {
  84. let reg = new RegExp("(\\s|^)" + cls + "(\\s|$)");
  85. ele.className = ele.className.replace(reg, " ");
  86. }
  87. },
  88. addClickClass(direction) {
  89. let divs = document.getElementsByClassName('gameButton')
  90. for (let div of divs) {
  91. if (div.dataset.direction === direction) {
  92. this.addClass(div, 'clickButton')
  93. }
  94. }
  95. },
  96. removeClickClass() {
  97. let divs = document.getElementsByClassName('gameButton')
  98. for (let div of divs) {
  99. this.removeClass(div, 'clickButton')
  100. }
  101. },
  102. }
  103. })
  104. </script>
  105. ...

服务端代码就作为今天的Homework啦,请童鞋们尽力独自完成,下一章将会是最后一章。

本章对应Github Commit第九章结束

当前目录结构:

  1. HideAndSeek
  2. ├── app
  3. ├── Lib
  4. └── Redis.php
  5. ├── Manager
  6. ├── DataCenter.php
  7. ├── Game.php
  8. ├── Logic.php
  9. ├── Sender.php
  10. └── TaskManager.php
  11. ├── Model
  12. ├── Map.php
  13. └── Player.php
  14. └── Server.php
  15. ├── composer.json
  16. ├── composer.lock
  17. ├── frontend
  18. └── index.html
  19. ├── test.php
  20. └── vendor
  21. ├── autoload.php
  22. └── composer