其實用了很長時間思考了一下一些數據運算方面放在哪里合適。當然,數值方面的運算肯定要放在服務端是正確的,本地的數值計算就會有被修改器修改、數據傳輸中抓包改包等作弊、外掛的問題存在,不過對於我這個小項目目前開發階段來說,只涉及到對游戲角色移動操控這塊。
在我自己所接觸過的網游中,確實存在兩種方式來處理角色移動數據,一個是發出操作指令,然后服務器根據操作指令計算出移動坐標再返給客戶端處理,一個是本地計算移動距離,再將數據交付給服務器。這兩種情況在游戲掉線的時候就能有很明顯的感覺,前者掉線后,角色就無法移動了,做出的任何操作指令都不會有反饋,后者在掉線后,依然可以操縱角色在游戲中活動。
這兩種方式各有利弊,前者可以杜絕各種修改數據的外掛,但是對服務器會有很大的數據計算壓力。后者無法避免一些神仙外掛,但是服務器方面計算成本就會降低很多。
因為自身做過很多年的單機游戲,想學習一下網游的開發理論知識,才決定寫這一系列的博客記錄學習過程,所以決定使用全服務器處理數據的方案,將包括角色移動的數據計算都放到服務器中進行。
在之前的篇章中,已經做好了搖桿,在場景中擺放了個角色。在這個基礎上,接下來擴展一下服務器和客戶端代碼,讓我們可以通過搖桿操作角色,讓服務器運算角色移動后的坐標並返回客戶端,在客戶端中表現出人物行走。同時,也能支持多個客戶端同時訪問,在客戶端中可以看到多個角色的移動。
在確定了方案之后,就需要對服務器和客戶端做出不小的改動。
首先改造一下服務器,因為大部分的數據處理都放在服務器來做,那么就需要讓服務器來主導數據形式。
因為初步設計的模式是以地圖為單位來創建服務器,那么每個地圖都會有自己的信息,包括地圖的名稱,地圖中npc的數據以及地圖中玩家的數據。
在Server中創建MapServerModules文件夾,在里面創建mapObject.js文件,編寫地圖的類代碼。
class MapObject {
constructor(mapName) {
this.mapName = mapName;//地圖名稱
this.npcs = [];//地圖中npc的數組
this.players = [];//地圖中玩家的數組
}
//客戶端連接完畢,創建玩家的數據信息后將其放入地圖的玩家數組中
addPlayer(player) {
for(let i in this.players) {
let playerItem = this.players[i];
if(player.getPlayerData().playerId === playerItem.getPlayerData().playerId) {
return;
}
}
this.players.push(player);
}
/**
* 監聽到玩家離開地圖,要從數組中將玩家信息刪除
* 因為玩家離開后,需要通知其他還在連接中的玩家
* 所以延遲從數組中刪掉,是為了給其他玩家發送地圖玩家數據時標記玩家退出
*/
deletePlayer(player) {
setTimeout(() => {
this.players.splice(this.players.indexOf(player), 1);
}, 1000);
}
//地圖信息,將npc和玩家數據打包成數據集
getMapInfo() {
return {
npcs: this.npcs.map((item) => {return null}),
players: this.players.map((item) => {return item.getPlayerData()})
}
}
}
module.exports = MapObject;
地圖類創建好后,接着創建一個角色類,里面包含了玩家的一些數據,以及相關的數據計算。當然,這些數據計算日后肯定需要抽離出來,但因為現在是一個簡單的demo,暫時放在同一個類中進行處理。
在根目錄的modules文件夾中,創建playerObject.js,用來編寫玩家類的代碼。因為自己設想的demo開發流程,還沒有到加入數據庫的時候,所以在這里,玩家數據初始化都使用了一些隨機參數。
class PlayerObject {
//構造函數中的相關初始化。
constructor() {
this.dt = 1 / 60; //因為游戲的設計幀率是60幀,所以服務器在計算數據的時候,也選擇60幀(即每秒進行60次計算)
this.update = null; //循環計時器
//角色狀態參數,站立,向左右方向行走
this.State = {
STATE_STAND: 1,
STATE_WALK_LEFT: 2,
STATE_WALK_RIGHT: 3
};
//玩家角色數據集,初始化隨機的id和隨機的名字
this.playerData = {
playerId: this.makeUUID(),
playerName: "player" + Math.ceil(Math.random() * 100),
playerAttribute: {
//角色數據
logout: false, //是否退出地圖的標示符
currentState: this.State.STATE_STAND, //角色當前狀態
moveSpeed: 150.0, //角色移動速度
position: {
//角色在地圖中的坐標
x: -500,
y: -460
},
scale: {
//角色動畫的縮放參數,角色的面向是通過縮放參數來控制的
x: 3,
y: 3
}
}
};
this.connection = null; //角色的websocket連接對象
}
//獲取到角色的相關數據,用在map中生成數據合集
getPlayerData() {
return this.playerData;
}
addConnection(connection) {
this.connection = connection;
}
makeUUID() {
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function(c) {
var r = (Math.random() * 16) | 0,
v = c == "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
//接收玩家的操作,目前只有操控移動,所以這里是根據操作改變角色狀態
operation(data) {
this.playerData.playerAttribute.currentState = data.state;
}
/**
* 角色在服務器生成后,需要發送消息到客戶端
* 客戶端進行角色人物動畫加載,加載完成后,再通知服務器可以開始角色數據運算
* 這里使用定時器循環計算
*/
start() {
this.update = setInterval(() => {
if (!this.playerData.playerAttribute) {
return;
}
switch (this.playerData.playerAttribute.currentState) {
case this.State.STATE_STAND:
break;
case this.State.STATE_WALK_LEFT:
this.walkLeft();
break;
case this.State.STATE_WALK_RIGHT:
this.walkRight();
break;
}
//計算完成后,通過websocket的連接對象,連同地圖中全部的npc和角色信息,一並返回給客戶端
if (this.connection) {
let map = this.connection.map;
let mapData = map.getMapInfo();
let data = {
/**
* 數據格式暫定
* dataType 數據操作指令
* data 數據
*/
dataType: "GAME_PLAYER_DATA",
data: mapData
};
this.connection.sendText(JSON.stringify(data));
}
}, this.dt * 1000);
}
//標記角色離開地圖,停止數據計算
end() {
this.playerData.playerAttribute.logout = true;
clearInterval(this.update);
}
//計算角色移動后的坐標,和處理面向
walkLeft() {
let dis = this.playerData.playerAttribute.moveSpeed * this.dt;
this.playerData.playerAttribute.position.x =
this.playerData.playerAttribute.position.x - dis;
this.playerData.playerAttribute.scale.x =
Math.abs(this.playerData.playerAttribute.scale.x) * -1;
}
walkRight() {
let dis = this.playerData.playerAttribute.moveSpeed * this.dt;
this.playerData.playerAttribute.position.x =
this.playerData.playerAttribute.position.x + dis;
this.playerData.playerAttribute.scale.x = Math.abs(
this.playerData.playerAttribute.scale.x
);
}
}
module.exports = PlayerObject;
如此就創建好了一個簡單的地圖類和一個簡單的角色類處理地圖和角色信息。
接下來創建一下地圖的服務器。在Server目錄下,添加mapServer.js。
const wsServer = require("../modules/websocketServer");//引入websocketServer
const mapObject = require("./MapServerModules/mapObject");//引入地圖類
const playerObject = require("../modules/playerObject");//引入角色類
const map_1 = new mapObject("map_1");//初始化地圖信息
//同聊天服務器一樣,幾個回調函數來處理連接的監聽
const textCallback = (server, result, connection) => {
let dataType = result.dataType;
let player = null;
//通過dataType的操作指令,執行不同的任務
/**
* PLAYER_SERVER_INIT 服務器角色初始化,用於客戶端連接成功后,向服務器發送的命令
* PLAYER_SERVER_INIT_OVER 服務器完成角色初始化,向客戶端發送命令,要求客戶端創建角色相關數據
* PLAYER_CLIENT_INIT_OVER 客戶端角色創建完成,通知服務器開始角色數據計算
* PLAYER_OPERATION 客戶端發起操作,控制角色,改變角色狀態
*/
switch (dataType) {
case "PLAYER_SERVER_INIT"://服務器角色初始化,並添加連接對象
player = new playerObject();
player.addConnection(connection);
connection["player"] = player;
connection["map"] = map_1;
connection.sendText(
makeResponseData("PLAYER_SERVER_INIT_OVER", player.getPlayerData())
);
break;
case "PLAYER_CLIENT_INIT_OVER"://客戶端角色創建完成,開啟角色數據計算,並將角色信息添加到地圖信息中心
player = connection.player;
player.start();
map_1.addPlayer(player);
break;
case "PLAYER_OPERATION"://客戶端發來的操作,改變角色狀態
player = connection.player;
player.operation(result.data);
break;
}
};
//通知客戶端連接成功
const connectCallback = (server, result, connection) => {
connection.sendText(makeResponseData("CONNECT_SUCCESS", null));
};
//客戶端取消連接,將該客戶端角色移出地圖數據
const closeConnectCallback = (server, result, connection) => {
connection.player.end();
map_1.deletePlayer(connection.player);
};
//打包數據的公共函數
const makeResponseData = (dataType, data) => {
return JSON.stringify({
dataType,
data
});
};
module.exports = MapServer = port => {
let callbacks = {
textCallback: (server, result, connection) => {
textCallback(server, result, connection);
},
connectCallback: (server, result, connection) => {
connectCallback(server, result, connection);
},
closeConnectCallback: (server, result, connection) => {
closeConnectCallback(server, result, connection);
}
};
const mapServer = wsServer(port, callbacks);
};
因為在地圖服務器的監聽回調中,添加了connection的參數,所以也要修改對應的modules/websocketServer.js中相關代碼,這里就不列出了。
修改index.js文件,添加服務器端口,可以隨着服務器啟動后啟動地圖服務器。
const http = require('http');
const url = require('url');
const chatServer = require('./Server/chatServer');
const mapServer = require('./Server/mapServer');
// const wsServer = require('./websocketServer');
http.createServer(function(req, res){
var request = url.parse(req.url, true).query
var response = {
info: request.input ? request.input + ', hello world' : 'hello world'
};
res.setHeader("Access-Control-Allow-Origin", "*");//跨域
res.write(JSON.stringify(response));
res.end();
}).listen(8181);
const chat = chatServer(8183);
const map = mapServer(8184);
以上,服務器相關代碼就完成了。記錄的內容中可能有些許疏漏,具體可以到文章末尾列出的github地址下載代碼。
接下來修改一下客戶端相關代碼,以便能夠和服務器通信並完成操作響應。
因為服務器端口越來越多,可能在后續的地方會有相當多的地方要填寫連接地址,這里將地址相關的代碼提到公共類中,方便調用和統一維護。
修改Script/Common/Tools.js中的代碼,添加連接地址獲取。
constructor() {
this.webSocketServerUrl = "ws://127.0.0.1";
this.chatPort = "8183";//端口需要和服務器保持一致
this.testMapPort = "8184";
}
getChatServerUrl() {
return this.webSocketServerUrl + ":" + this.chatPort;
}
getTestMapServerUrl() {
return this.webSocketServerUrl + ":" + this.testMapPort;
}
作為聯網游戲,肯定是有多端連接進來的,那么就需要每個連接到游戲中的玩家,在地圖中都要有對應的角色。所以,現在需要把之前添加的女仆長,轉換成預制體,並為其編寫一個Script/Player/Character.js,並拖拽到預制體上。
cc.Class({
extends: cc.Component,
properties: {},
onLoad() {
//和服務器一樣,角色的三個狀態
this.State = cc.Enum({
STATE_STAND: 1,
STATE_WALK_LEFT: -1,
STATE_WALK_RIGHT: -1
});
this.playerData = null;
this.currentState = this.State.STATE_STAND;
this.animDisplay = null;
},
//獲取角色信息
getPlayerData() {
return this.playerData;
},
//創建時,初始化角色信息
initCharacter(data) {
this.playerData = data;
let playerAttribute = data.playerAttribute;
this.animDisplay = this.getComponent(dragonBones.ArmatureDisplay);
this.node.setPosition(
cc.v2(playerAttribute.position.x, playerAttribute.position.y)
);
},
//服務器發送過來角色數據,在這里更新
refreshPlayerData(playerData) {
let playerAttribute = playerData.playerAttribute;
this.resetState(playerAttribute.currentState);
this.node.setPosition(
cc.v2(playerAttribute.position.x, playerAttribute.position.y)
);
this.node.setScale(playerAttribute.scale.x, playerAttribute.scale.y);
},
//根據狀態切換角色動畫,注意,這里切換動畫的的骨骼名稱、動畫名稱都是在龍骨編輯器里定義好的
resetState(state) {
if (this.currentState === state) {
return;
}
switch (state) {
case this.State.STATE_STAND:
this.changeAnimation("SakuyaStand", "SakuyaStand", 0);
break;
case this.State.STATE_WALK_LEFT:
this.changeAnimation("SakuyaWalkFront", "SakuyaWalkFront", 0);
break;
case this.State.STATE_WALK_RIGHT:
this.changeAnimation("SakuyaWalkFront", "SakuyaWalkFront", 0);
break;
}
this.currentState = state;
},
//切換動畫
changeAnimation(armatureName, animationName, playTimes, callbacks) {
if (
this.animDisplay.armatureName === armatureName &&
this.animDisplay.animationName === animationName
) {
return;
}
if (this.animDisplay.armatureName !== armatureName) {
this.animDisplay.armatureName = armatureName;
}
this.animDisplay.playAnimation(animationName, playTimes);
},
//將角色從界面中移除
deleteCharacter() {
this.node.removeFromParent(true);
}
});
然后在編輯器里配置好相關的腳本,並拖拽生成預制體。

同時,需要創建一個玩家類Script/Player/Player.js,用來處理玩家的操控數據。 並綁定到編輯器的RolePlayer層上。
cc.Class({
extends: cc.Component,
properties: {
//將搖桿綁定到Player,記得在編輯器中將對應模塊拖入哦
joyStick: {
default: null,
type: cc.Node
},
//角色動畫的預制體,記得在編輯器中將對應模塊拖入哦
playerCharacterPrefab: {
default: null,
type: cc.Prefab
}
},
// LIFE-CYCLE CALLBACKS:
onLoad() {
//獲得搖桿
this.joyStickController = this.joyStick.getComponent("JoyStick");
//同樣在Player里面添加一下狀態管理,避免在狀態未改變時頻繁像服務器發送操作指令
this.State = cc.Enum({
STATE_STAND: 1,
STATE_WALK_LEFT: -1,
STATE_WALK_RIGHT: -1
});
this.currentState = this.State.STATE_STAND;
this.character = null;
this.playerData = null;
this.wsServer = null;
},
start() {},
//服務器通知客戶端初始化玩家角色時,通過服務器返回的角色數據,用預制體創建Character角色並放入到場景中,同時發送創建完成消息給服務器
initCharacter(data, wsServer) {
this.wsServer = wsServer;
this.playerData = data;
this.character = cc.instantiate(this.playerCharacterPrefab);
this.node.addChild(this.character);
this.character.getComponent("Character").initCharacter(data);
this.sendDataToServer("PLAYER_CLIENT_INIT_OVER", null);
},
//獲取玩家控制的角色信息
getPlayerData() {
return this.playerData;
},
//這個循環是用來監聽搖桿狀態的,當搖桿達到操作要求后,改變角色狀態並發送到服務器
update(dt) {
if (!this.playerData) {
return;
}
let radian = this.joyStickController._radian;
if (radian != -100) {
if (-0.5 <= radian && radian <= 0.5) {
this.changeState(this.State.STATE_WALK_RIGHT);
} else if (
(2.5 <= radian && radian <= Math.PI) ||
(-1 * Math.PI <= radian && radian <= -2.5)
) {
this.changeState(this.State.STATE_WALK_LEFT);
} else {
this.changeState(this.State.STATE_STAND);
}
} else {
this.changeState(this.State.STATE_STAND);
}
},
changeState(state) {
//這里就是狀態未改變時不要發消息給服務器
if(this.currentState === state) {
return ;
}
this.sendDataToServer("PLAYER_OPERATION", {state: state});
},
//接收到服務器返回的數據,更新角色狀態。玩家操縱的角色在這里單獨處理,其他客戶端玩家控制的角色,可以直接操作Character來處理。
refreshPlayerData(playerData) {
this.character.getComponent("Character").refreshPlayerData(playerData);
this.currentState = playerData.playerAttribute.state;
},
//封裝一下向服務器發送消息的函數
sendDataToServer(dataType, data) {
if (this.wsServer.readyState === WebSocket.OPEN) {
let wsServerData = {
dataType,
data
};
this.wsServer.send(JSON.stringify(wsServerData));
}
}
});
接下來創建一個地圖相關的腳本,綁定到Canvas上,相當於客戶端處理地圖數據的腳本。
Script/SceneComponent/MapScript.js
import Tools from '../Common/Tools';
cc.Class({
extends: cc.Component,
properties: {
//綁定角色控制,記得在編輯器中拖入相關模塊
player: {
default: null,
type: cc.Node
},
//綁定預制體,用來創建其他客戶端玩家角色數據,記得在編輯器中拖入相關模塊
playerCharacterPrefab: {
default: null,
type: cc.Prefab
},
//綁定角色們該在的圖層,記得在編輯器中拖入相關模塊
playerLayer: {
default: null,
type: cc.Node
}
},
// LIFE-CYCLE CALLBACKS:
onLoad() {
//非玩家控制角色和npc角色數據合集
this.otherPlayers = [];
this.npcs = [];
//地圖的websocket
this.mapWS = new WebSocket(Tools.getTestMapServerUrl());
this.mapWS.onmessage = event => {
let dataObject = JSON.parse(event.data);
let dataType = dataObject.dataType;
let data = dataObject.data;
//連接成功
if(dataType === "CONNECT_SUCCESS") {
this.openMap();
}
//服務器完成創建后,將角色數據發送給客戶端,客戶端根據數據創建角色
if(dataType === "PLAYER_SERVER_INIT_OVER") {
if(this.player) {
this.player.getComponent("Player").initCharacter(data, this.mapWS);
}
}
//服務器發送回來的游戲過程中的全部角色數據
if(dataType === "GAME_PLAYER_DATA") {
let npcs = data.npcs;
let players = data.players;
//npc數據處理,以后拓展
for(let i in npcs) {
let npc = npcs[i];
}
//玩家角色處理
for(let i in players) {
let player = players[i];
//返回的數據中,如果是玩家操控的角色,單獨處理
if(player.playerId === this.player.getComponent("Player").getPlayerData().playerId) {
this.player.getComponent("Player").refreshPlayerData(player);
continue;
}
//遍歷其他角色數據,判斷是否是新加入的角色,不是的話,刷新角色數據
let isNew = true;
for(let k in this.otherPlayers) {
let otherPlayer = this.otherPlayers[k];
if(player.playerId === otherPlayer.getComponent("Character").getPlayerData().playerId) {
//判斷已加入角色是否為退出地圖狀態,是的話刪數據色,不是的話更新角色
if(!player.playerAttribute.logout) {
otherPlayer.getComponent("Character").refreshPlayerData(player);
}else {
otherPlayer.getComponent("Character").deleteCharacter();
this.otherPlayers.splice(k, 1);
k--;
}
isNew = false;
break;
}
}
//如果是新加入角色,是的話,創建角色並添加到地圖中
if(isNew && !player.playerAttribute.logout) {
let otherPlayer = cc.instantiate(this.playerCharacterPrefab);
this.playerLayer.addChild(otherPlayer);
otherPlayer.getComponent("Character").initCharacter(player);
this.otherPlayers.push(otherPlayer);
}
}
}
};
},
start() {
},
openMap() {
if (this.mapWS.readyState === WebSocket.OPEN) {
let mapWSData = {
dataType: "PLAYER_SERVER_INIT",
data: null
}
this.mapWS.send(JSON.stringify(mapWSData));
}
},
// update (dt) {},
});
最后,因為更改了一些角色信息,將玩家uuid和角色名放在了服務器生成,那么在本地刪除相關代碼,暫時屏蔽掉聊天界面的服務。后續,會在多場景切換中,如何保持聊天內容不被清除的開發期,再次啟用!
Script/UIComponent/ChatComponent/ChatScript.js
onLoad() {
this.chatItems = [];
this.chatItemNodePool = new cc.NodePool();
},
start() {},
啟動服務器和客戶端,可以看到角色加載完成,嘗試在屏幕左邊拖動,試試角色是否可以移動。

可以看到,向右拖拽搖桿,可以發現女仆長切換成行走姿態並向右移動。松開搖桿,恢復到站立狀態。

接下來,保持這個頁面,再打開一個新的頁面,發現不僅在舊的頁面中,出現了新加入的角色,在新的界面中,也可以看到之前已加入的角色。

嘗試在右邊的頁面中操作角色,可以發現,左邊界面中的角色也跟着同步移動了。

接下來關掉左邊的界面,可以在右邊界面中看到,左邊頁面中先打開控制的角色消失了。

如果成功看見了上面的過程,那么證明服務器和客戶端之間的socket連接成功的運行了起來。那么,一個最簡單的網游demo便成功完成了開發。
在后續的demo開發計划中,將會創建兩個場景,並提供場景切換點,實現多地圖的交互。
文章中的代碼:
客戶端: https://github.com/MythosMa/CocosCreator_ClientTest.git
服務端: https://github.com/MythosMa/NodeJS_GameServerTest.git
