做程序猿,情商比智商重要。
——茂叔
上一篇我们创建了角色,现在,是时候让我们的角色进入游戏世界了。
角色进入游戏世界
所以现在,我们在服务器端增加一个通信指令JOINGAME
,对于通信指令的维护,如果大家从头开始看到这里的话,应该是非常熟悉了,修改我们的Type.cs
:
public enum GameCommand : byte
{
ERROR = 0,
CONNECTED = 1,
LOGIN,
MAKENAMES,
CREATECHARACTER,
JOINGAME,//这条
REFRESH,//还有这条,顺便一起了
MOVE,
ATTACK,
SEARCH,
TAKE,
QUIT
}
我们顺便也把游戏进行中数据刷新的指令也添加好,等会儿会用到。
然后,在我们的客户端里面,对于角色创建指令回传处理代码这里,对角色创建成功回传的消息做如下处理:
……
if (_g.Player.Name === "@") {
let nextUI = require('/UI/makename.js')
nextUI.initialize()
} else {
var data = {
command: _g.COMMAND.JOINGAME,
data: ""
}
wx.sendSocketMessage({
data: JSON.stringify(data)
})
}
……
这样就把加入游戏的指令传给了服务端。
在服务端,我们添加对于这个指令的解析和处理:
GameServer.cs
……
case GameCommand.JOINGAME:
{
uint playerID = ((GameConnection)connection).PlayerID;
((GameConnection)connection).SendMessage(cmd, gGame.JoinGame(playerID));
}
break;
……
Game.cs
……
public string JoinGame(uint PlayerID)
{
GamePlayer Player = PlayerPool.Find(x => x.PlayerID == PlayerID);
if (Player == null || Player.Name == "@") return "FAIL";
Player.X = 7;
Player.Y = 7;
gWorld.AddObjectIntoMap(Player, Player.MapEID);
return "OK";
}
……
GameWorld.cs
……
internal bool AddObjectIntoMap(GameObject Obj, ExistenceID MapEID)
{
GameMap map = GetMap(MapEID);
if (map == null)
{
map = gMaps[0];
}
map.AddObject(Obj);
return true;
}
……
GameMap.cs
……
internal void AddObject(GameObject obj)
{
obj.MapEID = EID;
lock (gObjects)
{
gObjects.Add(obj);
}
}
……
我么一共修改了4个地方。代码解析不用说了,这个是标配,然后由我们的Game
对象来统一处理指令与游戏世界的交互,游戏世界GameWorld
再告知相应的地图GameMap
来添加这个角色,最后一次向上反馈结果。
这一个流程也是所有与角色相关的通信指令处理流程的标准流程。所有的实际指令执行都是GameWorld
来实施的。
这样的机制保证了各个层次的相对封闭性,各个层次的逻辑在只在内部产生效果,对外隔绝,便于今后的扩展(如果有这个必要的话)。例如,我们一个服务器可以提供多个游戏,而一个游戏可以有多个平行世界,这些策略导致今后的扩展会相对容易很多。
有进,就有出,所以,我们要把QUIT
指令也顺便实现了。按照上面的思路:
GameServer.cs
……
case GameCommand.QUIT:
gGame.PlayerQuit(uint.Parse(data));
((GameConnection)connection).mSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "{\"result\":\"fail\",\"msg\":\"离线断开\"}", CancellationToken.None);
ConnectionPool.Remove((GameConnection)connection);
break;
……
Game.cs
……
public string PlayerQuit(uint PlayerID)
{
GamePlayer Player = PlayerPool.Find(x => x.PlayerID == PlayerID);
if (Player == null || Player.Name == "@") return "NULLPLAYER";
gWorld.RemoveObjectFromMap(Player, Player.MapEID);
return "OK";
}
……
GameWorld.cs
……
internal void RemoveObjectFromMap(GameObject Obj, ExistenceID MapEID)
{
GameMap map = GetMap(MapEID);
if (map != null)
{
map.RemoveObject(Obj);
Obj.LOG(Obj.Name + " 离开了……");
}
}
……
GameMap.cs
……
internal void RemoveObject(GameObject obj)
{
lock (gObjects)
{
gObjects.Remove(obj);
}
}
……
是不是很容易……
注意,这里因为要动态修改GameMap
的内容列表,而心跳的时候是把当时的内容列表复制出来进行遍历的,所以,在心跳遍历的时候需要判断一下内容是否为null
:
GameMap.cs
……
public override void HeartBeat()
{
GameObject[] ObjectList;
lock (gObjects)
{
ObjectList = gObjects.ToArray();
}
foreach (GameObject OBJ in ObjectList)
{
if (OBJ != null)
OBJ.HeartBeat();
}
}
……
这样,我们如果调试一下的话,就能在监控端看到角色的心跳信息了:
到这一步,我们的角色就正式进入了游戏世界了,下一步,我们的角色应该把TA在游戏世界"感知"到的一切告诉我们的客户端,以便客户端将游戏场景渲染出来。当然,我们让每一个GameObject
都有感知周围世界和被感知的能力,这样将来或许我们可以在游戏中扮演一颗树也说不一定。
让角色感知游戏世界
服务端组织渲染信息
首先要定义我们发送给客户端的渲染信息,因为不是全部GameObject
的信息都需要,或者都允许发送给客户端的。
由于客户端游戏场景的渲染需要非常频繁的与服务端交互,为了提高性能,我们在发送给客户端之前,把这些信息都编码为byte
数组,然后以二进制形式发送给客户端。
所以,我们先在Type.cs
里定义一个信息结构RenderInfo
:
……
[StructLayout(LayoutKind.Sequential,Pack =1)]
public struct RenderInfo
{
public byte ImgFileID;
public byte ImgID;
public byte X;
public byte Y;
public ObjectStatus Status;
public Direction Dir;
public byte StatusStep;
}
……
其中,标签[StructLayout(LayoutKind.Sequential,Pack =1)]
的意思就是要求改结构以逐字节的方式连续储存在内存中,以便最大限度的压缩数据的长度。而这些数据已经足够客户端去渲染场景内容了。注意,这里的X
和Y
是指在客户端场景界面上的坐标,不是游戏地图上的坐标。
然后我们修改GameObject.cs
如下:
……
public List<RenderInfo> RenderInfos = new List<RenderInfo>();
public RenderInfo RenderInfo
{
get
{
return new RenderInfo
{
X = 0,
Y = 0,
ImgFileID = ImgFileID,
ImgID = ImgID,
Dir = Dir,
Status = Status,
StatusStep = StatusStep
};
}
}
……
接下来,我们在游戏世界GameWorld
每次心跳完成后,为每个玩家角色生成TA的渲染信息。由于客户端请求渲染信息的频率可能会很高,而渲染信息仅仅在每次游戏世界GameWorld
心跳后才有可能改变,因此,我们不需要实时的生成渲染信息。
当然,这种策略会导致每个角色无论是否需要,都会在每次游戏世界GameWorld
心跳时都会重新生成渲染信息,如何取舍,要看具体的情况了。在我们的例子中,采用每次游戏世界GameWorld
心跳的时候来生成。
在GameWorld.cs
里添加生成渲染信息的方法:
internal void MakeRenderInfos(GameObject Obj)
{
lock (Obj.RenderInfos)
{
Obj.RenderInfos.Clear();
GameMap map = GetMap(Obj.MapEID);
if (map != null)
{
for (byte vx = 0; vx < 13; vx++)
for (byte vy = 0; vy < 13; vy++)
{
GameObject[] Objs = GetObjs(map, Obj.X - 6 + vx, Obj.Y - 6 + vy);
if (Objs != null)
{
foreach (GameObject obj in Objs)
{
RenderInfo info = obj.RenderInfo;
info.X = vx;
info.Y = vy;
Obj.RenderInfos.Add(info);
}
}
else
{
RenderInfo info = new RenderInfo()
{
ImgID = 9
};
info.X = vx;
info.Y = vy;
Obj.RenderInfos.Add(info);
}
}
}
}
}
非常简单,就是把角色周围13*13范围的GameObject
的渲染信息RenderInfo
都放进角色的RenderInfos
里面。
这里用到了GetObjs
方法,这个方法具体代码如下:
internal GameObject[] GetObjs(GameMap map, int x, int y)
{
GameMap ActualMap = map;
if ((x > G.MapSize - 1) && (y > G.MapSize - 1))
{
ActualMap = GetMap(map.EastMap);
if (ActualMap == null)
{
ActualMap = GetMap(map.SouthMap);
if (ActualMap == null) return null;
ActualMap = GetMap(ActualMap.EastMap);
if (ActualMap == null) return null;
}
else
{
ActualMap = GetMap(map.SouthMap);
if (ActualMap == null) return null;
}
x = x - G.MapSize;
y = y - G.MapSize;
}
if ((x > G.MapSize - 1) && (y < 0))
{
ActualMap = GetMap(map.EastMap);
if (ActualMap == null)
{
ActualMap = GetMap(map.NorthMap);
if (ActualMap == null) return null;
ActualMap = GetMap(ActualMap.EastMap);
if (ActualMap == null) return null;
}
else
{
ActualMap = GetMap(map.NorthMap);
if (ActualMap == null) return null;
}
x = x - G.MapSize;
y = y + G.MapSize;
}
if ((x < 0) && (y > G.MapSize - 1))
{
ActualMap = GetMap(map.WestMap);
if (ActualMap == null)
{
ActualMap = GetMap(map.SouthMap);
if (ActualMap == null) return null;
ActualMap = GetMap(ActualMap.WestMap);
if (ActualMap == null) return null;
}
else
{
ActualMap = GetMap(map.SouthMap);
if (ActualMap == null) return null;
}
x = x + G.MapSize;
y = y - G.MapSize;
}
if ((x < 0) && (y < 0))
{
ActualMap = GetMap(map.WestMap);
if (ActualMap == null)
{
ActualMap = GetMap(map.NorthMap);
if (ActualMap == null) return null;
ActualMap = GetMap(ActualMap.WestMap);
if (ActualMap == null) return null;
}
else
{
ActualMap = GetMap(map.NorthMap);
if (ActualMap == null) return null;
}
x = x + G.MapSize;
y = y + G.MapSize;
}
if (x > G.MapSize - 1)
{
ActualMap = GetMap(map.EastMap);
if (ActualMap == null) return null;
x = x - G.MapSize;
}
if (x < 0)
{
ActualMap = GetMap(map.WestMap);
if (ActualMap == null) return null;
x = x + G.MapSize;
}
if (y > G.MapSize - 1)
{
ActualMap = GetMap(map.SouthMap);
if (ActualMap == null) return null;
y = y - G.MapSize;
}
if (y < 0)
{
ActualMap = GetMap(map.NorthMap);
if (ActualMap == null) return null;
y = y + G.MapSize;
}
return ActualMap.GetObjsAtXY((byte)x, (byte)y);
}
这个方法的逻辑是:首先要根据传入的X
和Y
值,找到这个位置实际的地图以及在地图内的真实坐标,然后调用地图的GetObjsAtXY
方法来获得该坐标上的GameObject
。而GetObjsAtXY
方法的具体代码就是遍历地图的内容,然后把该坐标的内容返回回来。
GameMap.cs
internal GameObject[] GetObjsAtXY(byte x, byte y)
{
List<GameObject> ret = new List<GameObject>();
GameObject[] ObjectList;
lock (gObjects)
{
ObjectList = gObjects.ToArray();
}
foreach (GameObject OBJ in ObjectList)
{
if (OBJ != null && OBJ.X == x && OBJ.Y == y)
ret.Add(OBJ);
}
return ret.ToArray();
}
在客户端发来REFRESH
指令时,我们在GameServer
的REFRESH
指令解析代码中用这些方法来实现渲染信息的组织和发送:
GameServer.cs
case GameCommand.REFRESH:
{
uint playerID = ((GameConnection)connection).PlayerID;
RenderInfo[] infos = gGame.GetRenderInfo(playerID);
if (infos == null)
{
gGame.PlayerQuit(uint.Parse(data));
((GameConnection)connection).mSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "{\"result\":\"fail\",\"msg\":\"断开\"}", CancellationToken.None);
ConnectionPool.Remove((GameConnection)connection);
}
else
{
int size = Marshal.SizeOf(typeof(RenderInfo));
byte[] bytes = new byte[size * infos.Length + 2];
bytes[0] = (byte)(size & 255);
bytes[1] = (byte)(size >> 8);
IntPtr buffer = Marshal.AllocHGlobal(size);
for (int i = 0; i < infos.Length; i++)
{
Marshal.StructureToPtr(infos[i], buffer, true);
Marshal.Copy(buffer, bytes, i * size + 2, size);
}
Marshal.FreeHGlobal(buffer);
((GameConnection)connection).SendMessage(bytes);
}
}
break;
其中的GetRenderInfo
方法非常简单:
Game.cs
public RenderInfo[] GetRenderInfo(uint PlayerID)
{
GamePlayer Player = PlayerPool.Find(x => x.PlayerID == PlayerID);
if (Player != null) return Player.RenderInfos.ToArray();
return null;
}
取得角色的渲染信息后,使用Marshal
类在非托管内存将渲染信息数组转换为byte[]
,然后把RenderInfo
结构的长度放进前两个字节,以方便客户端解析,拼装好后,就可以发给客户端了。
最后,在游戏世界心跳完成后更新每个玩家的渲染信息:
Game.cs
public void Run()
{
IsRunning = true;
if (RunThread == null)
{
RunThread = new Thread(() =>
{
while (IsRunning)
{
gWorld.HeartBeat();
foreach (GamePlayer Player in PlayerPool)
{
gWorld.MakeRenderInfos(Player);
}
_ = G.GlobeTime == uint.MaxValue ? G.GlobeTime = 0 : G.GlobeTime++;
GC.Collect();
Thread.Sleep(20);
}
LOG("Game 即将停止");
});
}
RunThread.Start();
}
客户端
在客户端,我们需要调整一下界面,还记得那个游戏主界面正中间的按钮么,我们需要挪一挪,把场景部分的位置留出来。
我们为主界面类UIGame
添加一个场景画布scenecanvas
用来专门渲染场景。然后添加相应的渲染方法drawScene
。当然,还要调整一下构造函数和redraw
方法,完整代码如下:
UIOBJECT.js
function UIGame(x = 0, y = 0, w = 188, h = 300) {
baseUIObj.call(this, undefined, x, y, w, h)
this.text = "UIGame"
this.name = "Game"
this.borderWidth = 0
var time = new Date()
this.lastDrawScene = time.getTime()
this.frameCount = 0
this.scenecanvas = wx.createCanvas()
this.scenecanvas.width = 208
this.scenecanvas.height = 208
this.sceneCtx = this.scenecanvas.getContext('2d')
}
UIGame.prototype = new baseUIObj()
UIGame.prototype.constructor = UIGame
UIGame.prototype.redraw = function(needrender = true, caller = this) {
if (caller.isRedrawing) return
caller.isRedrawing = true
if (!caller.isHidden) {
baseUIObj.prototype.redraw(caller)
caller.children.forEach(function(child, index, array) {
if (!child.isHidden) {
child.redraw(false)
caller.gCtx.drawImage(child.canvas, child.x, child.y, child.width, child.height)
}
})
caller.gCtx.drawImage(caller.scenecanvas, 16, 16, 176, 176, 6, 46, 176, 176)
}
if (needrender)
caller.render()
caller.isRedrawing = false
}
UIGame.prototype.drawScene = function(SceneData, caller = this) {
caller.frameCount++;
let time = new Date()
let now = time.getTime()
if (now - caller.lastDrawScene > 1000) {
console.log("fps:" + caller.frameCount + "(" + now + ")")
caller.frameCount = 0
caller.lastDrawScene = now
}
let objsize = SceneData[0] + 255 * SceneData[1]
let offset = 2
caller.sceneCtx.fillStyle = "#123"
caller.sceneCtx.fillRect(0, 0, caller.scenecanvas.width, caller.scenecanvas.height)
while (offset < SceneData.length) {
let ImgFileID = SceneData[offset]
let ImgID = SceneData[offset + 1]+""
let X = SceneData[offset + 2]
let Y = SceneData[offset + 3]
let Status = SceneData[offset + 4]
let Dir = SceneData[offset + 5]
let StatusStep = SceneData[offset + 6]
drawText(caller.sceneCtx, ImgID, X * _g.ImgSize, Y * _g.ImgSize)
offset += objsize
}
caller.redraw()
}
收到服务端传来的数据后,根据前两个字节计算出每个渲染结构的大小,然后逐个取出并进行渲染。
在渲染的时候,我们还加入了一个简单的帧频
功能,可以大致算出每秒渲染了多少次。
我们的游戏场景实际上是一个13×13的图片矩阵,每个图片为16px×16px,所以场景的大小实际上是208px×208px。最外部的一圈是用来完成滚动效果的,也就是说,当场景滚动时,为了避免出现空白,我们多渲染了一圈。在主界面没有滚动的时候,我们只显示11×11的矩阵,也就是176px×176px的大小。
由于现在我们还没有图片资源,所以,上面的代码只是把地图内容的图片编号ImgID
写出来而已。
注意,由于采用了我们自己的字体输出策略,所以输出数字或字母的时候要把内码转换一下,把ASCII编码转换为区位码,调整之后的字库方法如下:
han.js
GameGlobal.drawText = (ctx, str, sx, sy) => {
var cx = 0
var quwei;
for (var i = 0; i < str.length; i++) {
var charCode = str.charCodeAt(i)
if (charCode >= 33 && charCode <= 127) {
quwei = {
"qu": 2,
"wei": charCode - 33
}
} else {
quwei = CodeMap.get(charCode)
}
ctx.drawImage(fontImg, quwei.wei * 12, quwei.qu * 12, 12, 12, sx + cx, sy, 12, 12)
cx += 14
}
}
然后调整UI/main.js,把按钮移开,同时按钮按下时第一时间把界面换掉,从而停止Refresh
:
var btn
exports.initialize = () => {
if (GameGlobal.GameMainUI === undefined) {
GameGlobal.GameMainUI = new UIObj.UIGame()
let image = wx.createImage();
image.src = "images/UI.png"
image.onload = () => {
GameMainUI.background = image
btn = new UIObj.UIButton(GameMainUI)
btn.x = 44
btn.y = 235
btn.text = _g.Player.Name
btn.onClick = () => {
GameWindow = GameCover
wx.closeSocket()
}
}
} else {
btn.text = _g.Player.Name
}
GameWindow = GameMainUI
GameWindow.show()
}
在net.js
里面添加Refresh
方法,然后在进入主画面时调用:
……
case _g.COMMAND.JOINGAME:
{
if (msg.data === "OK") {
let nextUI = require('/UI/main.js')
nextUI.initialize()
Refresh()//在这里调用
} else {
let nextUI = require('/UI/makename.js')
nextUI.initialize()
}
}
break
……
function Refresh() {
if (GameWindow === GameMainUI) {//只有在主界面才需要刷新场景
var data = {
command: _g.COMMAND.REFRESH,
data: ""
}
wx.sendSocketMessage({
data: JSON.stringify(data)
})
}
}
……
在接收到服务器端消息是根据消息类型来判断是否是刷新场景的消息。
wx.onSocketMessage((res) => {
if (typeof (res.data) == "object") {
var bytes = new Uint8Array(res.data)
GameMainUI.drawScene(bytes)
setTimeout(Refresh, 5)
} else {
console.log("msg recevied:")
console.log(res)
parseMessage(JSON.parse(res.data))
}
}
注意,因为微信的WebSocket发送接口只能在主线程调用,因此,我们在每次调用时要间隔一定时间,让主线程有空闲去处理其他任务。因此我们在接收完渲染信息并触发渲染方法后,并不是直接调用Refresh
方法,而是等待了5毫秒再进行下一次调用。
好,我们现在调试一下:
可以看到,在本机调试时帧频高大145左右。当然,后续还有很多功能需要添加,我们的目标是把最终远程服务时的帧频控制在60fps以上,因为,这样比较不伤眼睛。
其他还有一些小的调整,包括心跳节奏,动物的速度策略等,这里就不详细讲了,都很简单,而且最终完成之前肯定还要调整,请大家下载最新的代码指教。
下一篇,我们要把玩家和小猫小狗的图像加上去,用数字表示也太阳春了点,连我自己都看不下去了。
截止这一篇的全部代码,请大家去我的GitHub下载:
>>第十五篇代码连接<<