一种基于反向RPC的简易可重连通用的非延迟重视型单权威服务器客户端架构的多人游戏实现

没错 本文标题就是搞笑的

前言

相比较单机游戏,联网多人游戏要考虑的事情就多了:如何在多个玩家之间正确的同步游戏状态、如何处理玩家掉线和重连、如何防止网络延迟给玩家带来的不好体验。因此,编写多人游戏时,其业务逻辑常常比单人游戏更加复杂,许多时候不得不引入许多与游戏逻辑代无关的代码,对于初学者来是很头疼的一件事。

在网络游戏中,有一类游戏不需要特别考虑延迟的影响(相反:枪战、手速、竞技游戏等则对延迟很重视),比如卡牌游戏、桌面游戏(如大富翁)、互动小游戏(如你画我猜等),一般情况下只要玩家的延迟小于一个回合的规定时间都不会有很大的游戏体验损失。这类游戏相对比较简单,也比较适合初学者实现。本文将针对这一类游戏的通用框架进行设计,希望读者阅读完后可以开发出自己的网络游戏。

前置知识

Single Authority Server-Client 架构是指:有一个中央服务器是“王”,掌握着所有的数据,他向客户端发送数据,客户端只是负责渲染(显示)这些数据,如果想要修改数据,需要向服务器“请示”。服务器和客户端是独立的(有的时候可以是完全不同的软件),这样做的好处包括:允许双端分离部署,不需要P2P等其他技术、双端可以使用不同的编程语言书写、具有一定的防止非法客户端或者恶意改装后的客户端的破坏的作用

Remote Procedure Call(RPC) 可以理解为:你可以调用一个远程主机上对应程序的函数,就像调用本地的函数一样轻松。

Websocket 是一种通信技术,允许两方互相向对方发送信息。基于TCP的Websocket会保证信息一定按照顺序到达并且不会丢失。

面向对象编程(OOP) 请自行上网搜索。

接下来,我们就用一个具体的例子说明我们该怎么实现一个卡牌网络游戏。

样例分析

游戏逻辑

考虑这样一个卡牌游戏,双方每人有自己的手牌,只有自己可见。双方轮流行动,每人先抽两张牌(只有自己可见),从中选择一张加入自己的手牌另一张牌丢弃,然后打出一张牌到桌子上(桌子上的牌双方可见)并和桌子上打出的上一张牌进行比较,如果比上一张牌大就得一分。谁先得10分谁就赢了。

Step I. 用RPC建模

我们先设计服务器,假设我们手上有两个客户端的对象:p1p2,我们需要基于面向对象的设计理念,把客户端当成可以调用的目标。

我们假设所有操作都是合法的,不需要合法性判断;先假设所有操作不会因为玩家掉线而失败。这样做我们可以尽可能简化游戏主逻辑。

要注意哪些内容是双方可见的,哪些内容是单方可见的。

注意我们的操作都是Blocking的,也就是说只有得到了一个函数的结果才会继续执行下面的语句。

记住RPC就是远程调用的函数。因为客户端和服务端不在一起,所以很多操作都是RPC。

//服务端
void gameLogic(PlayerClient p1, PlayerClient p2){
    int score1=0,score2=0;
    int lastCard=-1;
    while(true){
        int drawnWhat=p1.selectCard(randomCard(),randomCard()); //让P1抽一张牌(RPC)
        p1.addCardToHand(drawnWhat); //加入抽的牌到手牌(RPC)
        int played=p1.playOneCard(); //打一张(RPC)
        if(player>lastCard){ //比较
            score1++;
            if(score1>=10){
                break;
            }
        }
        lastCard=played;
        p1.addCardToTable(played); //RPC
        p2.addCardToTable(played); //RPC,记得通知玩家2有新卡牌

        drawnWhat=p2.selectCard(randomCard(),randomCard());
        p2.addCardToHand(drawnWhat);
        played=p2.playOneCard();
        if(player>lastCard){
            score2++;
            if(score2>=10){
                break;
            }
        }
        lastCard=played;
        p1.addCardToTable(played);
        p2.addCardToTable(played);
    }

    p1.notifyGameEnd();
    p2.notifyGameEnd();
}
//客户端
IntArray myHand;
int selectCard(int card1, int card2){
    展示选项给玩家
    return 玩家选择的值;
}

void addCardToHand(int card){
    myHand.push(card);
    渲染这张新的手牌(可能包括获得牌的动画之类的)给玩家看
}

int playOneCard(){
    让玩家选择myHand中的一张牌
    myHand.remove(选择的牌);
    return 选择的牌;
}

//......

很直观是不是?代码几乎完全反应游戏逻辑,没有任何废话!

RPC的功能就是让远程调用和本地调用一样简单。所以如果你的语言有现成的RPC库,那么恭喜你,你已经完成了大部分内容!

Step II. 注意Single Authority

在上述代码中,你可能已经注意到了,客户端掌握着myHand。因为客户端程序是发给玩家的,我们可以认为玩家可以任意操纵源代码。因此,他们可以,例如,打出牌后不更新myHand,或者往myHand中插入114514个114514,破坏对手的游戏体验。因此,我们要记住:一切数据都应收归服务器!客户端拿到的,之应该是数据的副本。我们继续补足内容并添加内容相关的检查:

记住RPC就是远程调用的函数。

//服务端
class PlayerClient{
    IntArray myHand; //服务器管理手牌数据
    NetworkClient socket; //真正的网络意义上的客户端

    int selectCard(int card1, int card2){
        //This is a RPC!!!
        int x=socket.selectCard(card1,card2);
        if(x!=card1 && x!=card2){
            客户端你开挂了!!
        }
        return x;
    }

    void addCardToHand(int card){
        myHand.push(card);
        //通知客户端我的手牌变动了,也是RPC
        socket.addCardToHand(card);
    }

    int playOneCard(){
        让玩家选择myHand中的一张牌
        if(选择的牌不合法){
            客户端怎么又开挂?
        }
        myHand.remove(选择的牌);
        return 选择的牌;
    }

    //......
}

客户端代码不变,但是这个时候客户端再修改自己的myHand不会影响到服务器的myHand变量,因此没有任何影响。就像你用F12修改网页数据一样,只会害了自己。

Step III. 重连时发送所有数据吧!

假如客户端异常关闭,重新打开时由于数据丢失就会出现客户端和服务端数据不同步的情况(因为我们之前的操作都是增量更新的),所以重连时要记得发送所有数据:

简单思考题:在客户端掉线的时候在客户端本地保存现在的状态,下次启动时恢复。为什么不可行?

//服务端
void onPlayerReconnect(NetworkClient nc){
    if(验证身份通过){
        if(原来是P1){
            p1.socket=nc;
            p1.sendFullState(); //包括自己已经有的手牌和桌子上已经有的牌,底层也是RPC哦
        }else if(原来是P2){
            p2.socket=nc;
            p2.sendFullState();
        }else{
            //你谁啊
            reject();
        }
    }else{
        reject();
    }
}

Step IV. 一种基于Websocket的底层实现

从本章开始我们将介绍具体的底层RPC实现,将会基于Websocket技术进行实现。因为Websocket技术本身不支持RPC,所以需要我们自己设计自己的RPC协议。

假如你不使用Websocket(例如Godot有原生RPC实现)或者不想阅读底层实现,可以跳过。

请记住,你唯一需要知道的知识是Websocket是一种允许双向发送消息的渠道。

本文假设你的服务器采用了Websocket,并有着这样的基本架构:

//服务端主函数
while(true){
    if(有新的客户端连接){
        开一个新线程/协程处理:{
            NetworkClient nc=getConnection();
            //We can save and use this NetworkClient later!
        }
    }
}

那么,一种非常简单的RPC协议实现如下:

//假设RpcBody结构体中存储了所有与Rpc相关的信息
RpcResponse performRpc(NetworkClient target, RpcBody body){
    target.send(body);
    //假设receive()是Blocking的,也就是说在得到新的消息前不会继续执行后面的代码
    return target.receive().toRpcResponse();
}

但是这种实现有一些问题:

服务器:「给你发了个请求,我等你哈」
客户端:「好的,我收到了,我看看」 (然后掉线了)
客户端重启后失忆:「我要干啥来着?服务器怎么不给我指示啊!」
服务器:「这小子怎么还没搞完?等的我花都谢了!」

这启示我们要将目前正在进行的RPC请求保存到状态当中,回顾第三步,在客户端重新连接时将正在执行的RPC请求发送给客户端。

void onPlayerReconnect(NetworkClient nc){
    if(验证身份通过){
        if(原来是P1){
            p1.socket=nc;
            p1.sendFullState(); //包括自己已经有的手牌和桌子上已经有的牌,底层也是RPC哦
            if(正在执行P1的RPC){
                p1.resendRpcQuery();
            }
        }else if(原来是P2){
            p2.socket=nc;
            p2.sendFullState();
            if(正在执行P2的RPC){
                p2.resendRpcQuery();
            }
        }else{
            //你谁啊
            reject();
        }
    }else{
        reject();
    }
}
RpcResponse performRpc(NetworkClient target, RpcBody body){
    saveCurrentRpc(body); //保存到状态中
    target.send(body);
    //假设receive()是Blocking的,也就是说在得到新的消息前不会继续执行后面的代码
    return target.receive().toRpcResponse().also{
        saveCurrentRpc(null); //处理完了Rpc,从状态中移除
    };
}

但是这样仍然存在问题:

服务器:「给你发了个请求,我等你哈」
客户端:「好的,我收到了,我看看」
客户端:「好了,我选择选项1」(然后掉线)
客户端重启后:「哦对,服务器让我选择个选项,我选择选项1」
服务器:「这小子发两遍?」

这样会导致客户端的回复有的时候回复两遍,假如服务器运行神速,容易导致被服务器理解为自动回答了下一个问题,导致错误。所以,我们可以给每个请求添加一个ID,要求客户端回答的时候必须带上这个ID,如果ID不对就自动舍弃回答。

int currentId;
RpcResponse performRpc(NetworkClient target, RpcBody body){
    body.id=currentId++;
    saveCurrentRpc(body); //保存到状态中
    target.send(body);

    while(true){
    //假设receive()是Blocking的,也就是说在得到新的消息前不会继续执行后面的代码
        RpcResponse res=target.receive().toRpcResponse();
        if(res.id!=body.id){
            continue;
        }

        return res.also{
            saveCurrentRpc(null); //处理完了Rpc,从状态中移除
        }
    }
}

这样哪怕客户端重启后发送了多次回答,只有一次会被记录和使用。

此外,还有一些细节:假如在target.receive()过程中掉线了,那么target.receive()可能发生异常,或者返回奇怪的值,容易导致服务器崩溃。我们可能通过异常处理+一定的封装来处理,下面给出一个实例:

//将receive封装到PlayerClient,不直接在NetworkClient上调用
class PlayerClient{
    //...
    RpcResponse receiveRpc(){
        while(true){
            try{
                return socket.receive().toRpcResponse();
            }catch{
                //retry again in 1second or sth
                sleep(1000);
            }
        }
    }
}

思考题:假如我的Websocket框架不是blocking的,而是每当收到一个信息就调用一次我的某个函数(aka回传式)怎么办?

恭喜你,至此已经完成了所有的内容!如果有什么疑问可以在下方的评论区里提问。

拓展

下列内容可能需要一定的并发知识。

I. 同时操作的设计方式

类似宝可梦之类的游戏需要双方同时操作,然后等双方都操作完成后继续下一步操作,这可以表示成线程/协程的 join操作。Rust中的Join

II. 回合限时的设计方式

一回合最多使用60秒,怎么设计?这可以表示成select操作:选择二者较先返回的那一个。Rust中的Select

版权声明:
作者:XGN
链接:https://blog.hellholestudios.top/archives/1776
来源:Hell Hole Studios Blog
文章版权归作者所有,未经允许请勿转载。

THE END
分享
二维码
< <上一篇
下一篇>>
文章目录
关闭
目 录