从零开始实现放置游戏(十三)——实现战斗挂机(4)添加websocket组件
前两张,我们已经实现了登陆界面和游戏的主界面。不过游戏主界面的数据都是在前端写死的文本,本章我们给game模块添加websocket组件,实现前后端通信,这样,前端的数据就可以从后端动态获取到了。
一、添加maven依赖
在game模块的pom中,我们添加3个依赖包如下:
1 <!-- websocket组件 --> 2 <dependency> 3 <groupId>org.springframework</groupId> 4 <artifactId>spring-websocket</artifactId> 5 <version>5.1.6.RELEASE</version> 6 </dependency> 7 <dependency> 8 <groupId>org.springframework</groupId> 9 <artifactId>spring-messaging</artifactId> 10 <version>5.1.6.RELEASE</version> 11 </dependency> 12 <dependency> 13 <groupId>javax.websocket</groupId> 14 <artifactId>javax.websocket-api</artifactId> 15 <version>1.1</version> 16 <scope>provided</scope> 17 </dependency>
二、后端添加MessageHub
在com.idlewow.game.hub下MessageHub,这个类将主要负责接收客户端的websocket信息。代码如下:


1 @Component 2 @ServerEndpoint(value = "/hub", configurator = HttpSessionConfigurator.class) 3 public class MessageHub { 4 private static final Logger logger = LogManager.getLogger(MessageHub.class); 5 6 @Autowired 7 MessageHandler messageHandler; 8 @Autowired 9 CharacterService characterService; 10 11 @OnOpen 12 public void onOpen(Session session, EndpointConfig config) { 13 logger.info("[websocket][" + session.getId() + "]建立连接"); 14 try { 15 HttpSession httpSession = (HttpSession) config.getUserProperties().get(HttpSession.class.getSimpleName()); 16 if (httpSession == null) { 17 logger.error("[websocket][" + session.getId() + "]获取HttpSession失败!"); 18 throw new Exception("获取HttpSession失败!"); 19 } 20 21 22 if (httpSession.getAttribute(GameWorld.SK_CharId) == null) { 23 logger.error("[websocket][" + session.getId() + "]获取角色Id为空!"); 24 throw new Exception("获取角色ID为空!"); 25 } 26 27 String charId = httpSession.getAttribute(GameWorld.SK_CharId).toString(); 28 CommonResult commonResult = characterService.find(charId); 29 if (commonResult.isSuccess()) { 30 Character character = (Character) commonResult.getData(); 31 /* 加载成功,添加缓存 */ 32 GameWorld.OnlineSession.add(session); 33 GameWorld.OnlineCharacter.put(session.getId(), character); 34 GameWorld.MapCharacter.get(character.getMapId()).add(character); 35 } else { 36 logger.error("加载角色信息失败!charId:" + charId + " message:" + commonResult.getMessage()); 37 throw new Exception("加载角色信息失败!"); 38 } 39 } catch (Exception ex) { 40 logger.error("[websocket][" + session.getId() + "]建立连接异常:" + ex.getMessage(), ex); 41 this.closeSession(session, ex.getMessage()); 42 } 43 } 44 45 @OnMessage 46 public void onMessage(Session session, String message) { 47 logger.info("[websocket][" + session.getId() + "]接收消息:" + message); 48 messageHandler.handleMessage(session, message); 49 } 50 51 @OnClose 52 public void onClose(Session session) { 53 logger.info("[websocket][" + session.getId() + "]关闭连接"); 54 /* 清理缓存 */ 55 Character character = GameWorld.OnlineCharacter.get(session.getId()); 56 GameWorld.OnlineSession.remove(session); 57 GameWorld.OnlineCharacter.remove(session.getId()); 58 GameWorld.MapCharacter.get(character.getMapId()).remove(character); 59 } 60 61 @OnError 62 public void onError(Session session, Throwable t) { 63 logger.error("[websocket][" + session.getId() + "]发生异常:" + t.getMessage(), t); 64 } 65 66 private void closeSession(Session session, String message) { 67 try { 68 logger.info("[websocket][" + session.getId() + "]关闭连接,原因:" + message); 69 CloseReason closeReason = new CloseReason(CloseReason.CloseCodes.NORMAL_CLOSURE, message); 70 session.close(closeReason); 71 } catch (Exception ex) { 72 logger.error("[websocket]关闭连接异常:" + ex.getMessage(), ex); 73 } 74 } 75 }
MessageHub
Hub类主要包括OnOpen、OnMessage、OnClose、OnError 4个方法。
在OnOpen建立连接时,我们从HttpSession中获取角色Id,并加载角色信息,更新在线数据等。这里我们创建一个GameWorld类,将在线列表等游戏世界的全局静态数据保存在其中。
在OnMessage方法接收到客户端数据时,我们将消息在MessageHandler中统一处理。
OnClose和OnError对应关闭连接和异常发生事件,关闭连接时,需要将游戏角色从在线列表中清除。发生异常时,我们暂时仅记录日志。
注意:在MesssageHub的注解中,我们给其配置了一个HttpSessionConfigurator。是为了在socket消息中获取到HttpSession数据。如果不加这个配置,HttpSession是获取不到的。其代码如下:
1 public class HttpSessionConfigurator extends SpringConfigurator { 2 @Override 3 public void modifyHandshake(ServerEndpointConfig sec, HandshakeRequest request, HandshakeResponse response) { 4 HttpSession httpSession = (HttpSession) request.getHttpSession(); 5 sec.getUserProperties().put(HttpSession.class.getSimpleName(), httpSession); 6 super.modifyHandshake(sec, request, response); 7 } 8 }
三、定义消息类型
在socket通信时,我们必须定义消息的数据结构,并准备相应文档,方便前后端通信。
这里我们创建消息类WowMessage,并规定其由header和content两部分构成。header中主要包括消息类型,请求时间等通用参数。content主要包括具体的业务数据。
整个消息类的UML图如下,其中例举了4种具体的消息类型,LoadCache缓存加载,Login登陆消息,Chat聊天消息,Move地图移动消息。
四、后端消息处理
在定义好消息类型后,我们即可在后端对相应的消息进行处理。代码如下:
在handleMessage方法中,我们根据header中传入的messageCode,来确定是何种消息,并转入对应的处理子方法。
比如处理地图移动的handleMoveMessage方法,在这个方法中,我们将人物信息缓存数据中的当前地图ID修改为移动后的地图ID,从原地图在线列表中移除此角色,在目标地图在线列表中添加此角色。并返回目标地图的信息给前端以便展示。


@Component public class MessageHandler { private static final Logger logger = LogManager.getLogger(MessageHandler.class); @Autowired CharacterService characterService; @Autowired WowMapService wowMapService; @Autowired MapMobService mapMobService; @Autowired MapCoordService mapCoordService; /** * 消息处理 * * @param session session * @param message 消息 */ public void handleMessage(Session session, String message) { WowMessage<?> wowMessage = JSONObject.parseObject(message, WowMessage.class); WowMessageHeader header = wowMessage.getHeader(); String messageCode = header.getMessageCode(); switch (messageCode) { case WowMessageCode.LoadCache: this.handleLoadCacheMessage(session, (WowMessage<LoadCacheRequest>) wowMessage); break; case WowMessageCode.RefreshOnline: this.handleRefreshOnlineMessage(session, (WowMessage<RefreshOnlineRequest>) wowMessage); break; case WowMessageCode.Login: this.handleLoginMessage(session, (WowMessage<LoginRequest>) wowMessage); break; case WowMessageCode.Chat: this.handleChatMessage(session, (WowMessage<ChatRequest>) wowMessage); break; case WowMessageCode.Move: this.handleMoveMessage(session, (WowMessage<MoveRequest>) wowMessage); break; default: break; } } /** * 给指定客户端发送消息 * * @param session 客户端session * @param message 消息内容 */ private void sendOne(Session session, String message) { try { session.getBasicRemote().sendText(message); } catch (Exception ex) { logger.error(ex.getMessage(), ex); } } /** * 给所有客户端发送消息 * * @param message 消息内容 */ private void sendAll(String message) { try { for (Session session : GameWorld.OnlineSession) { session.getBasicRemote().sendText(message); } } catch (Exception ex) { logger.error(ex.getMessage(), ex); } } /** * 登陆加载 * * @param session session * @param message 消息 */ private void handleLoginMessage(Session session, WowMessage<LoginRequest> message) { WowMessageHeader header = message.getHeader(); header.setResponseTime(DateFormatUtils.format(new Date(), "yyyy-MM-dd HH:mm:ss")); LoginResponse response = new LoginResponse(); Character character = GameWorld.OnlineCharacter.get(session.getId()); String mapId = character.getMapId(); MapInfo mapInfo = this.loadMapInfo(mapId); response.setMapInfo(mapInfo); OnlineInfo onlineInfo = this.loadOnlineInfo(mapId); response.setOnlineInfo(onlineInfo); WowMessage wowMessage = new WowMessage<>(header, response); this.sendOne(session, JSON.toJSONString(wowMessage)); } /** * 发送聊天 * * @param session session * @param message 消息 */ private void handleChatMessage(Session session, WowMessage<ChatRequest> message) { WowMessageHeader header = message.getHeader(); header.setResponseTime(DateFormatUtils.format(new Date(), "yyyy-MM-dd HH:mm:ss")); ChatRequest request = message.getContent(); ChatResponse response = new ChatResponse(); response.setSendId(request.getSendId()); response.setSendName(request.getSendName()); response.setRecvId(request.getRecvId()); response.setRecvName(request.getRecvName()); response.setMessage(request.getMessage()); response.setChannel(request.getChannel()); WowMessage wowMessage = new WowMessage<>(header, response); if (request.getChannel().equals(GameWorld.ChatChannel.WORLD)) { this.sendAll(JSON.toJSONString(wowMessage)); } else if (request.getChannel().equals(GameWorld.ChatChannel.PRIVATE)) { // todo 发送消息给指定玩家 } else if (request.getChannel().equals(GameWorld.ChatChannel.LOCAL)) { // todo 发送消息给当前地图玩家 } } /** * 加载缓存 * * @param session session * @param message 消息 */ private void handleLoadCacheMessage(Session session, WowMessage<LoadCacheRequest> message) { WowMessageHeader header = message.getHeader(); header.setResponseTime(DateFormatUtils.format(new Date(), "yyyy-MM-dd HH:mm:ss")); LoadCacheResponse response = new LoadCacheResponse(); Map<String, Integer> levelExpMap = new HashMap<>(); for (Integer key : CacheUtil.levelExpMap.keySet()) { levelExpMap.put(key.toString(), CacheUtil.levelExpMap.get(key)); } response.setLevelExpMap(levelExpMap); WowMessage wowMessage = new WowMessage<>(header, response); this.sendOne(session, JSON.toJSONString(wowMessage)); } /** * 地图移动 * * @param session session * @param message 消息 */ private void handleMoveMessage(Session session, WowMessage<MoveRequest> message) { WowMessageHeader header = message.getHeader(); header.setResponseTime(DateFormatUtils.format(new Date(), "yyyy-MM-dd HH:mm:ss")); MoveRequest request = message.getContent(); Character character = GameWorld.OnlineCharacter.get(session.getId()); String fromMapId = character.getMapId(); String destMapId = request.getDestMapId(); GameWorld.MapCharacter.get(fromMapId).remove(character); GameWorld.MapCharacter.get(destMapId).add(character); character.setMapId(destMapId); MapInfo mapInfo = this.loadMapInfo(destMapId); OnlineInfo onlineInfo = this.loadOnlineInfo(destMapId); MoveResponse response = new MoveResponse(); response.setMapInfo(mapInfo); response.setOnlineInfo(onlineInfo); WowMessage wowMessage = new WowMessage<>(header, response); this.sendOne(session, JSON.toJSONString(wowMessage)); } /** * 刷新在线列表 * * @param session session * @param message 消息 */ private void handleRefreshOnlineMessage(Session session, WowMessage<RefreshOnlineRequest> message) { WowMessageHeader header = message.getHeader(); header.setResponseTime(DateFormatUtils.format(new Date(), "yyyy-MM-dd HH:mm:ss")); Character character = GameWorld.OnlineCharacter.get(session.getId()); String mapId = character.getMapId(); OnlineInfo onlineInfo = this.loadOnlineInfo(mapId); RefreshOnlineResponse response = new RefreshOnlineResponse(); response.setOnlineInfo(onlineInfo); WowMessage wowMessage = new WowMessage<>(header, response); this.sendOne(session, JSON.toJSONString(wowMessage)); } /** * 读取地图信息 * * @param mapId 地图ID * @return */ private MapInfo loadMapInfo(String mapId) { MapInfo mapInfo = new MapInfo(); CommonResult commonResult = wowMapService.find(mapId); if (commonResult.isSuccess()) { WowMap wowMap = (WowMap) commonResult.getData(); mapInfo.setWowMap(wowMap); } List<MapCoord> mapCoordList = mapCoordService.listByFromMapId(mapId); mapInfo.setMapCoordList(mapCoordList); return mapInfo; } /** * 读取在线列表 * * @param mapId 地图ID * @return */ private OnlineInfo loadOnlineInfo(String mapId) { OnlineInfo onlineInfo = new OnlineInfo(); List<MapMob> mapMobList = mapMobService.listByMapId(mapId); onlineInfo.setMapMobList(mapMobList); List<Character> mapCharacterList = GameWorld.MapCharacter.get(mapId); onlineInfo.setMapCharacterList(mapCharacterList); return onlineInfo; } }
MessageHandler
五、前端socket处理
对应后端的MessageHub,前端也需要一个socket客户端,这里我们创建一个WowClient对象,负责最外层的消息处理逻辑。
1 const WowClient = function () { 2 this.cache = { 3 version: 0, 4 levelExpMap: [] 5 }; 6 this.cacheKey = "idlewow_client_cache"; 7 this.hubUrl = "ws://localhost:20010/hub"; 8 this.webSocket = new WebSocket(this.hubUrl); 9 this.webSocket.onopen = function (event) { 10 console.log('WebSocket建立连接'); 11 wowClient.sendLogin(); 12 wowClient.loadCache(); 13 }; 14 this.webSocket.onmessage = function (event) { 15 console.log('WebSocket收到消息:%c' + event.data, 'color:green'); 16 var message = JSON.parse(event.data) || {}; 17 console.log(message); 18 wowClient.receive(message); 19 }; 20 this.webSocket.onclose = function (event) { 21 console.log('WebSocket关闭连接'); 22 }; 23 this.webSocket.onerror = function (event) { 24 console.log('WebSocket发生异常'); 25 }; 26 };
另外,前端同样也需要定义消息类型,
1 const RequestMessage = function () { 2 this.header = { 3 messageCode: "", 4 requestTime: new Date(), 5 version: "1.0" 6 }; 7 this.content = {}; 8 }; 9 10 const MessageCode = { 11 // 预处理 12 LoadCache: "0010", 13 // 系统命令 14 Login: "1001", 15 RefreshOnline: "1002", 16 // 玩家命令 17 Chat: "2001", 18 Move: "2002", 19 BattleMob: "2100" 20 };
具体的消息处理逻辑和消息实体的创建,通过原型方法生成。完整的js文件如下:


1 const WowClient = function () { 2 this.cache = { 3 version: 0, 4 levelExpMap: [] 5 }; 6 this.cacheKey = "idlewow_client_cache"; 7 this.hubUrl = "ws://localhost:20010/hub"; 8 this.webSocket = new WebSocket(this.hubUrl); 9 this.webSocket.onopen = function (event) { 10 console.log('WebSocket建立连接'); 11 wowClient.sendLogin(); 12 wowClient.loadCache(); 13 }; 14 this.webSocket.onmessage = function (event) { 15 console.log('WebSocket收到消息:%c' + event.data, 'color:green'); 16 var message = JSON.parse(event.data) || {}; 17 console.log(message); 18 wowClient.receive(message); 19 }; 20 this.webSocket.onclose = function (event) { 21 console.log('WebSocket关闭连接'); 22 }; 23 this.webSocket.onerror = function (event) { 24 console.log('WebSocket发生异常'); 25 }; 26 }; 27 28 const RequestMessage = function () { 29 this.header = { 30 messageCode: "", 31 requestTime: new Date(), 32 version: "1.0" 33 }; 34 this.content = {}; 35 }; 36 37 const MessageCode = { 38 // 预处理 39 LoadCache: "0010", 40 // 系统命令 41 Login: "1001", 42 RefreshOnline: "1002", 43 // 玩家命令 44 Chat: "2001", 45 Move: "2002", 46 BattleMob: "2100" 47 }; 48 49 WowClient.prototype = { 50 ////////////////// 51 //// 对外接口 //// 52 ////////////////// 53 // 读取缓存 54 loadCache: function () { 55 let storage = localStorage.getItem(this.cacheKey); 56 let cache = storage ? JSON.parse(storage) : null; 57 if (!cache || (new Date().getTime() - cache.version) > 1000 * 60 * 60 * 24) { 58 this.sendLoadCache(); 59 } else { 60 this.cache = cache; 61 } 62 }, 63 64 ////////////////// 65 //// 消息处理 //// 66 ////////////////// 67 68 // 发送消息 69 send: function (message) { 70 let msg = JSON.stringify(message); 71 this.webSocket.send(msg); 72 }, 73 // 接收消息 74 receive: function (message) { 75 switch (message.header.messageCode) { 76 case MessageCode.LoadCache: 77 this.recvLoadCache(message); 78 break; 79 case MessageCode.RefreshOnline: 80 this.recvRefreshOnline(message); 81 break; 82 case MessageCode.Login: 83 this.recvLogin(message); 84 break; 85 case MessageCode.Chat: 86 this.recvChat(message); 87 break; 88 case MessageCode.Move: 89 this.recvMove(message); 90 break; 91 case MessageCode.BattleMob: 92 this.recvBattleMob(message); 93 break; 94 default: 95 break; 96 } 97 }, 98 99 // 读取缓存 100 sendLoadCache: function () { 101 this.send(new RequestMessage().loadCache()); 102 }, 103 recvLoadCache: function (message) { 104 this.cache.levelExpMap = message.content.levelExpMap; 105 this.cache.version = new Date().getTime(); 106 localStorage.setItem(this.cacheKey, JSON.stringify(this.cache)); 107 }, 108 // 刷新在线列表 109 sendRefreshOnline: function () { 110 this.send(new RequestMessage().refreshOnline()); 111 }, 112 recvRefreshOnline: function (message) { 113 this.refreshOnlineInfo(message.content.onlineInfo); 114 }, 115 // 登陆 116 sendLogin: function () { 117 this.send(new RequestMessage().login()); 118 }, 119 recvLogin: function (message) { 120 this.refreshMapInfo(message.content.mapInfo); 121 this.refreshOnlineInfo(message.content.onlineInfo); 122 }, 123 // 聊天 124 sendChat: function () { 125 this.send(new RequestMessage().chat()); 126 }, 127 recvChat: function (message) { 128 let channel = "【当前】"; 129 let content = "<p>" + channel + message.content.senderName + ": " + message.content.message + "</p>"; 130 $('.msg-chat').append(content); 131 }, 132 // 移动 133 sendMove: function (mapId) { 134 this.send(new RequestMessage().move(mapId)); 135 }, 136 recvMove: function (message) { 137 this.refreshMapInfo(message.content.mapInfo); 138 this.refreshOnlineInfo(message.content.onlineInfo); 139 }, 140 // 战斗 141 sendBattleMob: function (mobId) { 142 this.send(new RequestMessage().battleMob(mobId)); 143 }, 144 recvBattleMob: async function (message) { 145 $('.msg-battle').html(''); 146 let battleResult = message.content.battleResult; 147 if (battleResult.roundList) { 148 var rounds = battleResult.roundList; 149 for (var i = 0; i < rounds.length; i++) { 150 var round = rounds[i]; 151 var content = "<p>【第" + round.round + "回合】</p>"; 152 if (round.atkStage) { 153 content += "<p>" + round.atkStage.desc + "</p>"; 154 } 155 156 if (round.defStage) { 157 content += "<p>" + round.defStage.desc + "</p>"; 158 } 159 160 $('.msg-battle').append(content); 161 await this.sleep(1500); 162 } 163 164 $('.msg-battle').append("<p><strong>战斗结束," + battleResult.winName + " 获得胜利!</strong></p>"); 165 if (battleResult.isPlayerWin) { 166 this.settlement(battleResult); 167 } 168 169 let that = this; 170 await this.sleep(5000).then(function () { 171 that.sendBattleMob(battleResult.atkId, battleResult.defId); 172 }); 173 } 174 }, 175 176 ////////////////// 177 //// 辅助方法 //// 178 ////////////////// 179 180 // 刷新地图信息 181 refreshMapInfo: function (mapInfo) { 182 let wowMap = mapInfo.wowMap; 183 let mapCoordList = mapInfo.mapCoordList; 184 $('#mapName').html(wowMap.name); 185 $('#mapDesc').html(wowMap.description); 186 $('#mapImg').attr('src', '/images/wow/map/' + wowMap.name + '.jpg'); 187 let coordsHtml = ''; 188 for (let index in mapCoordList) { 189 let mapCoord = mapCoordList[index]; 190 coordsHtml += '<area shape="' + mapCoord.shape + '" coords="' + mapCoord.coord + '" onclick="wowClient.sendMove(\'' + mapCoord.destMapId + '\');" href="javascript:void(0);" alt="' + mapCoord.destMapName + '" title="' + mapCoord.destMapName + '"/>'; 191 } 192 193 $('#map-coords').html(coordsHtml); 194 }, 195 // 刷新在线列表 196 refreshOnlineInfo: function (onlineInfo) { 197 let mapCharacterList = onlineInfo.mapCharacterList; 198 let mapMobList = onlineInfo.mapMobList; 199 // 更新在线列表 200 $('#online-all').html(''); 201 $('#online-player').html(''); 202 $('#online-mob').html(''); 203 for (let index in mapCharacterList) { 204 let mapCharacter = mapCharacterList[index]; 205 let row = '<div class="layui-row"><div class="layui-col-md9"><label style="color: blue;">' + mapCharacter.name + '</label><label> - 等级:' + mapCharacter.level + '</label></div><div class="layui-col-md3"><button type="button" style="height:14px;line-height: 14px;">私聊</button></div></div>'; 206 $('#online-all').append(row); 207 $('#online-player').append(row); 208 } 209 210 for (let index in mapMobList) { 211 let mapMob = mapMobList[index]; 212 let row = '<div class="layui-row"><div class="layui-col-md9"><label style="color: red;">' + mapMob.name + '</label><label> - 等级:' + mapMob.level + '</label></div><div class="layui-col-md3"><button type="button" style="height:14px;line-height: 14px;" onclick="wowClient.sendBattleMob(\'' + mapMob.id + '\');">战斗</button><button type="button" style="height:14px;line-height:14px;" onclick="guaji();">挂机</button></div></div>'; 213 $('#online-all').append(row); 214 $('#online-mob').append(row); 215 } 216 }, 217 // 战斗结算 218 settlement: function (battleResult) { 219 $('.lbl-level').html(battleResult.settleLevel); 220 $('.lbl-exp').html(battleResult.settleExp); 221 }, 222 // 休眠 223 sleep: function (milliseconds) { 224 let p = new Promise(function (resolve) { 225 setTimeout(function () { 226 resolve(); 227 }, milliseconds) 228 }); 229 return p; 230 }, 231 // 关闭 232 close: function () { 233 this.webSocket.close(); 234 } 235 }; 236 237 RequestMessage.prototype = { 238 loadCache: function () { 239 this.header.messageCode = MessageCode.LoadCache; 240 }, 241 login: function () { 242 this.header.messageCode = MessageCode.Login; 243 }, 244 chat: function () { 245 this.header.messageCode = MessageCode.Chat; 246 this.content = { 247 senderId: charId, 248 senderName: charName, 249 receiverId: '', 250 receiverName: '', 251 message: $('#msg').val() 252 }; 253 }, 254 move: function (mapId) { 255 this.header.messageCode = MessageCode.Move; 256 this.content = { 257 destMapId: mapId 258 }; 259 }, 260 battleMob: function (mobId) { 261 this.header.messageCode = MessageCode.BattleMob; 262 this.content = { 263 mobId: mobId 264 }; 265 }, 266 refreshOnline: function () { 267 this.header.messageCode = MessageCode.RefreshOnline; 268 } 269 }; 270 271 // wow客户端 272 window.wowClient = new WowClient(); 273 274 // 关闭窗口 275 window.onbeforeunload = function (event) { 276 wowClient.close(); 277 }; 278 279 document.onkeydown = function (event) { 280 let e = event || window.event || arguments.callee.caller.arguments[0]; 281 if (e.keyCode === 13 && document.activeElement.id === 'msg') { 282 wowClient.sendChat(); 283 } 284 };
main.js
小结
本章主要实现的socket的通信逻辑,对消息的处理涉及了游戏的业务处理逻辑,仅简单的讲了一些。
另外因为时隔较长,代码裁剪工作量较大。本章仅对已完成的代码做了粗略裁剪。源代码的一些变动,文中将讲解一些主要的,其他的就不再赘述了。
对一些边角的内容,代码会变化,但文中未体现的,如有问题,可留言咨询。
源码下载地址://545c.com/file/14960372-437680954
本文原文地址://www.cnblogs.com/lyosaki88/p/idlewow_13.html
项目交流群:329989095