电竞·游戏Unity3D游戏开发

多人网络游戏编程——《坦克大战》

2019-07-15  本文已影响6人  Nino_Lau

I. 游戏简介

我们组设计的《坦克大战》,以二战坦克为题材,既保留了射击类游戏的操作性,也改进了射击类游戏太过于复杂难玩的高门槛特点,是一款集休闲与竞技于一身的游戏。提供二战知名坦克供您选择,各坦克独立个性强化系统,更拥有独特皮肤闪亮登场,让每一个玩家都能找到适合自己的归属。我们还将推出故事背景与真实战役,以及万众期待的火炮系统,传说与梦想中的坦克,一切都值得您的到来。


II. 游戏玩法


III. 设计目标


IV. 运行说明

运行此游戏需要您从我们的仓库下载和安装该游戏,然后在Windows 10系统上进行部署。

一. 安装说明

安装步骤如下:

$ git clone https://gitee.com/paogdfenzu10/GroupProject.git
$ cd ./GroupProject/code/不知道起什么名字/tankWar

二. 部署说明

Win10系统中,在Visual Studio2017(VS2019可能存在版本受限)中打开项目目录,打开./TankWar.sln文件,重新生成解决方案。然后进入./Debug目录下,Shift+右键打开PowShell,进行以下操作:

$ cmd
$ TankWarSever 50000 
$ TankWarClient 127.0.0.1:50000 Alice //也可以用您自己服务器的端口

如果您是游戏的发起者,那么您需要执行上面的操作;如果您是参与的玩家,只需运行客户端即可。


V. 游戏构思

我们的项目服务端主要分为三个模块:客户端-服务器网络交互模块、客户端代理模块和游戏世界状态处理模块。


VI. 实现过程

[图片上传失败...(image-5c7368-1563178730427)]

一. 功能实现

以下简述项目的三个模块的实现过程:

1. 客户端-服务器网络交互

这部分工作主要是在NetworkManagerClient和NetworkManagerServer这两个类中完成的,NetworkManagerClient负责发送连接请求和客户端的指令,并接收处理服务端的数据包,NetworkMangerServer负责接收处理客户端的指令并返回相应的状态信息。

网络交互主要有以下几个功能:

以下介绍实现思路:

首先要实现UDP协议,用于后面发送数据包调用。这里声明一个UDPSocket类,该类有三个主要方法:Bind、SendTo和ReceiveFrom,分别实现绑定地址、发送和接收数据包功能:

class UDPSocket
{
public:

    ~UDPSocket();

    int Bind( const SocketAddress& inToAddress );
    int SendTo( const void* inToSend, int inLength, const SocketAddress& inToAddress );
    int ReceiveFrom( void* inToReceive, int inMaxLength, SocketAddress& outFromAddress );

private:
    friend class SocketUtil;
    UDPSocket( SOCKET inSocket ) : mSocket( inSocket ) {}
    SOCKET mSocket;
};

(1)新客户端加入游戏

新客户端要加入游戏,首先向服务器发送“hello”数据包,数据包内包含数据包类型(供服务端识别)和玩家名字:

void NetworkManagerClient::SendHelloPacket()
{
    OutputMemoryBitStream helloPacket; 
    //写入数据包类型和玩家名字
    helloPacket.Write( kHelloCC );
    helloPacket.Write( mName );

    SendPacket( helloPacket, mServerAddress );
}

服务端接收到“hello”数据包后,便给新玩家分配ID,并创建新的客户端代理记录下相关信息,然后发送“welcome”数据包给客户端:

void NetworkManagerServer::HandlePacketFromNewClient( InputMemoryBitStream& inInputStream, const SocketAddress& inFromAddress )
{
    uint32_t    packetType;
    inInputStream.Read( packetType );
    //判断是否为"hello"数据包
    if(  packetType == kHelloCC )
    {
        string name;
        //读取数据包内玩家名字并创建新的客户端代理用于记录
        inInputStream.Read( name );
        ClientProxyPtr newClientProxy = std::make_shared< ClientProxy >( inFromAddress, name, mNewPlayerId++ );
        mAddressToClientMap[ inFromAddress ] = newClientProxy;
        mPlayerIdToClientMap[ newClientProxy->GetPlayerId() ] = newClientProxy;
        //在游戏世界中创建一辆坦克
        static_cast< Server* > ( Engine::sInstance.get() )->HandleNewClient( newClientProxy );

        //发送"welcome"数据包
        SendWelcomePacket( newClientProxy );

        //在游戏世界中加入该客户端的游戏对象
        for( const auto& pair: mNetworkIdToGameObjectMap )
        {
            newClientProxy->GetReplicationManagerServer().ReplicateCreate( pair.first, pair.second->GetAllStateMask() );
        }
    }
    else
    {
        LOG( "Bad incoming packet from unknown client at socket %s", inFromAddress.ToString().c_str() );
    }
}
//设置数据包类型
void NetworkManagerServer::SendWelcomePacket( ClientProxyPtr inClientProxy )
{
    OutputMemoryBitStream welcomePacket; 

    welcomePacket.Write( kWelcomeCC );
    welcomePacket.Write( inClientProxy->GetPlayerId() );

    LOG( "Server Welcoming, new client '%s' as player %d", inClientProxy->GetName().c_str(), inClientProxy->GetPlayerId() );

    SendPacket( welcomePacket, inClientProxy->GetSocketAddress() );
}

客户端接收"welcome"数据包后读取记录自己的ID,并设置自身状态(已加入游戏):

void NetworkManagerClient::HandleWelcomePacket(
InputMemoryBitStream& inInputStream )
{
    if( mState == NCS_SayingHello )
    {
        //if we got a player id, we've been welcomed!
        int playerId;
        inInputStream.Read( playerId );
        mPlayerId = playerId;
        mState = NCS_Welcomed;
        LOG( "'%s' was welcomed on client as player %d", mName.c_str(), mPlayerId );
    }
}

(2)客户端发送玩家指令,服务器处理

客户端的NetworkManagerClient获取指令队列,设置好数据包格式后发送给服务器:

void NetworkManagerClient::SendInputPacket()
{
    //获取玩家指令
    MoveList& moveList = InputManager::sInstance->GetMoveList();

    if( moveList.HasMoves() )
    {
        OutputMemoryBitStream inputPacket; 
        inputPacket.Write( kInputCC );  //设置数据包类型
         int moveCount = moveList.GetMoveCount();
        int startIndex = moveCount > 3 ? moveCount - 3 - 1 : 0;
        inputPacket.Write( moveCount - startIndex, 2 );
        for( int i = startIndex; i < moveCount; ++i )
        {
            moveList[i].Write( inputPacket );
        }
        //发送指令数据包
        SendPacket( inputPacket, mServerAddress );
        moveList.Clear();
    }
}

服务器该部分的工作是判断是玩家指令数据包后,将其存入到客户端代理供服务器其他部分处理(后面会介绍):

void NetworkManagerServer::HandleInputPacket( ClientProxyPtr inClientProxy, InputMemoryBitStream& inInputStream )
{
    uint32_t moveCount = 0;
    Move move;
    inInputStream.Read( moveCount, 2 );
    
    for( ; moveCount > 0; --moveCount )
    {
        if( move.Read( inInputStream ) )
        {
            if( inClientProxy->GetUnprocessedMoveList().AddMove( move ) )
            {
                inClientProxy->SetIsLastMoveTimestampDirty( true );
            }
        }
    }
}

(3)服务器将游戏对象的复制信息发送给客户端,客户端处理

服务端设置数据包类型后,将游戏对象的复制信息加入到数据包中发送给客户端:

void NetworkManagerServer::SendStatePacketToClient( ClientProxyPtr inClientProxy )
{
    OutputMemoryBitStream   statePacket;
    //数据包类型
    statePacket.Write( kStateCC );
    WriteLastMoveTimestampIfDirty( statePacket, inClientProxy );
    //游戏面板信息(比如生命值,得分等)
    AddScoreBoardStateToPacket( statePacket );
    //游戏对象状态信息
    inClientProxy->GetReplicationManagerServer().Write( statePacket );
    SendPacket( statePacket, inClientProxy->GetSocketAddress() );
}

客户端接收到数据包后便进行解析,读取其中的时间戳、游戏面板数值以及游戏对象复制信息等:

void NetworkManagerClient::HandleStatePacket( InputMemoryBitStream& inInputStream )
{
    if( mState == NCS_Welcomed )
    {
        ReadLastMoveProcessedOnServerTimestamp( inInputStream );
        HandleScoreBoardState( inInputStream );
        mReplicationManagerClient.Read( inInputStream );
    }
}

(4)客户端断开,服务器处理

服务器检验客户端最后发送数据包的时间间隔,若超时未发送新数据包给服务器说明该客户端已断开,此时便删除该客户端的地址信息及ID,并将其从游戏世界中删除:

void NetworkManagerServer::CheckForDisconnects()
{
    vector< ClientProxyPtr > clientsToDC;
    float minAllowedLastPacketFromClientTime = Timing::sInstance.GetTimef() - mClientDisconnectTimeout;
    //检验每一个客户端最后发送数据包的时间,若超过规定时间没有发送则移除该客户端相关数据
    for( const auto& pair: mAddressToClientMap )
    {
        if( pair.second->GetLastPacketFromClientTime() < minAllowedLastPacketFromClientTime )
        {
            clientsToDC.push_back( pair.second );
        }
    }
    for( ClientProxyPtr client: clientsToDC )
    {
        HandleClientDisconnected( client );
    }
}
//清除该客户端代理对应的地址信息及客户端ID,并将其从游戏世界删除
void NetworkManagerServer::HandleClientDisconnected( ClientProxyPtr inClientProxy )
{
    mPlayerIdToClientMap.erase( inClientProxy->GetPlayerId() );
    mAddressToClientMap.erase( inClientProxy->GetSocketAddress() );
    static_cast< Server* > ( Engine::sInstance.get() )->HandleLostClient( inClientProxy );

    if( mAddressToClientMap.empty() )
    {
        Engine::sInstance->SetShouldKeepRunning( false );
    }
}

以上便是客户端与服务器的网络交互的实现。

2. 客户端代理

为了方便降低服务端处理世界状态模块处理客户端指令的难度,这里我们采用了用客户端代理存储网络交互模块从客户端获取的指令。该类中包含了客户端的地址名字ID等信息,这样服务端处理世界状态模块只要从中获取所需信息便可对其对应游戏对象进行操作,同时将处理后的结果返回到该类中,最后网络交互模块也只要将该类对象发送给客户端即可,从而降低了两个模块的工作难度。

核心代码:

class ClientProxy
{
public:
    ClientProxy( const SocketAddress& inSocketAddress, const string& inName, int inPlayerId );
    const SocketAddress& GetSocketAddress() const { return mSocketAddress; }//获取客户端地址
    int GetPlayerId() const { return mPlayerId; }//获取玩家ID
    const string& GetName() const { return mName; }//获取玩家名字
    void UpdateLastPacketTime();//更新客户端发送最后一个数据包的时间
    float GetLastPacketFromClientTime() const { return mLastPacketFromClientTime; }//获取客户端发送最后一个数据包的时间
    ReplicationManagerServer& GetReplicationManagerServer() { return mReplicationManagerServer; }//获取对象复制信息存储类
    const MoveList& GetUnprocessedMoveList() const { return mUnprocessedMoveList; }//获取未处理指令
    MoveList& GetUnprocessedMoveList() { return mUnprocessedMoveList; }//获取已处理指令
    void HandleTankDied();//处理坦克死亡重生

private:
    ReplicationManagerServer mReplicationManagerServer;
    SocketAddress mSocketAddress;
    string mName;
    int mPlayerId;
    float mLastPacketFromClientTime;
    MoveList mUnprocessedMoveList;
};

3. 服务端处理游戏世界状态

(1)游戏对象的创建和销毁

以坦克为例,定义一个函数HandleNewClient,该函数在NetworkMangerServer中接收到新客户端的连接请求时(HandlePacketFromNewClient函数中)调用,以创建新的玩家坦克。根据传入的客户端代理inClientProxy,获取其ID创建新坦克并加入到游戏世界中:

//给NetworkMangerServer类调用
void Server::HandleNewClient( ClientProxyPtr inClientProxy )
{
    int playerId = inClientProxy->GetPlayerId();
    ScoreBoardManager::sInstance->AddEntry( playerId, inClientProxy->GetName() );
    SpawnTankForPlayer( playerId );
}
//在游戏世界中加入新坦克
void Server::SpawnTankForPlayer( int inPlayerId )
{
    TankPtr tank = std::static_pointer_cast<Tank>( GameObjectRegistry::sInstance->CreateGameObject( 'TANK' ) );
    tank->SetColor( ScoreBoardManager::sInstance->GetEntry( inPlayerId )->GetColor() );
    tank->SetPlayerId( inPlayerId );
    tank->SetLocation( Vector3( 1.f - static_cast< float >( inPlayerId ), 0.f, 0.f ) );
}

定义HandleLostClient函数,将该客户端代理对应的客户端从分数板中移除,并销毁坦克:

void Server::HandleLostClient( ClientProxyPtr inClientProxy )
{
    int playerId = inClientProxy->GetPlayerId();
    //将该客户端从分数板中移除,并销毁坦克
    ScoreBoardManager::sInstance->RemoveEntry( playerId );
    TankPtr tank = GetTankForPlayer( playerId );
    if( tank )
    {
        tank->SetDoesWantToDie( true );
    }
}

(2)复制世界状态

我们实现客户端世界状态同步的方法是在服务器创建世界,然后将其发送给客户端,客户端只负责渲染。由于我们的游戏世界并不大,因此可以考虑将整个世界状态装入一个数据包。这里把每次经过处理后的游戏世界状态写入流后,供网络交互模块调用发送给客户端,从而实现各客户端世界状态的同步。

核心代码:

void ReplicationManagerServer::Write( OutputMemoryBitStream& inOutputStream )
{
    //遍历游戏世界
    for( auto& pair: mNetworkIdToReplicationCommand )
    {
        ReplicationCommand& replicationCommand = pair.second;
        if( replicationCommand.HasDirtyState() )
        {
            int networkId = pair.first;
             //先将游戏对象ID写入数据流
            inOutputStream.Write( networkId );
             //获取该对象要执行的动作
            ReplicationAction action = replicationCommand.GetAction();
            inOutputStream.Write( action, 2 );
            uint32_t writtenState = 0;
            uint32_t dirtyState = replicationCommand.GetDirtyState();
            //根据获取到的动作(创建、更新、销毁等),分别执行不同的写入函数
            switch( action )
            {
            case RA_Create:
                writtenState = WriteCreateAction( inOutputStream, networkId, dirtyState );
                replicationCommand.SetAction( RA_Update );
                break;
            case RA_Update:
                writtenState = WriteUpdateAction( inOutputStream, networkId, dirtyState );
                break;
            case RA_Destroy:
                writtenState = WriteDestroyAction( inOutputStream, networkId, dirtyState );
                mNetworkIdsToRemove.emplace_back( networkId );
                break;
            }
            replicationCommand.ClearDirtyState( writtenState );
        }
    }

(3)更新游戏世界状态

这里分为三个部分,分别是坦克、子弹和墙的处理过程,具体对应TankServer、BulletServer和WallServer这三个类。这里以坦克位列,介绍一下我们的实现思路。

首先是实现坦克要进行的相关操作,主要是处理自身销毁、发射子弹和受到伤害这三个功能

//客户端退出游戏则销毁该游戏对象
void TankServer::HandleDying()
{
    NetworkManagerServer::sInstance->UnregisterGameObject( this );
}
//处理发射子弹的相关工作(如创建子弹)
void TankServer::HandleShooting()
{
    float time = Timing::sInstance.GetFrameStartTime();
    if( mIsShooting && Timing::sInstance.GetFrameStartTime() > mTimeOfNextShot )
    {
        mTimeOfNextShot = time + mTimeBetweenShots;

        BulletPtr bullet = std::static_pointer_cast<Bullet>( GameObjectRegistry::sInstance->CreateGameObject( 'BULL' ) );
        bullet->InitFromShooter( this );
    }
}

void TankServer::TakeDamage( int inDamagingPlayerId )
{
    mHealth--;
    //判断生命值是否小于0,如果是,执行死亡和重生
    if( mHealth <= 0.f )
    {
        //计分板变化
        ScoreBoardManager::sInstance->IncScore( inDamagingPlayerId, 1 );
         //设置死亡
        SetDoesWantToDie( true );
        //重生,通知客户端代理更新
        ClientProxyPtr clientProxy = NetworkManagerServer::sInstance->GetClientProxy( GetPlayerId() );
        if( clientProxy )
        {
            clientProxy->HandleTankDied();
        }
    }
    //重置生命值
    NetworkManagerServer::sInstance->SetStateDirty( GetNetworkId(), ECRS_Health );
}

接着便在update函数里执行从客户端代理获取的动作队列,执行坦克的移动转向以及是否发射子弹等操作:

void TankServer::Update()
{
    Tank::Update();
    
    Vector3 oldLocation = GetLocation();
    Vector3 oldVelocity = GetVelocity();
    float oldRotation = GetRotation();

    ClientProxyPtr client = NetworkManagerServer::sInstance->GetClientProxy( GetPlayerId() );
    if( client )
    {
        MoveList& moveList = client->GetUnprocessedMoveList();
        for( const Move& unprocessedMove : moveList )
        {
            const InputState& currentState = unprocessedMove.GetInputState();
            float deltaTime = unprocessedMove.GetDeltaTime();
            ProcessInput( deltaTime, currentState );
            SimulateMovement( deltaTime );
        }
        moveList.Clear();
    }

    HandleShooting();

    if( !TankMath::Is2DVectorEqual( oldLocation, GetLocation() ) ||
        !TankMath::Is2DVectorEqual( oldVelocity, GetVelocity() ) ||
        oldRotation != GetRotation() )
    {
        NetworkManagerServer::sInstance->SetStateDirty( GetNetworkId(), ECRS_Pose );
    }
}

二. 文件架构

接着介绍一下我们的项目文件架构以及各文件实现的工作。项目文件主要分为五类:Objects、Client Proxy、Manager Sever、ServerPCH和Server

1. Objects

游戏主要涉及三种对象,分别是子弹、墙、坦克。每个种类都有不同的属性,我们在其基类上分别添加了它们的服务器,用来管理它们在游戏中的行为。

1.1. Bullet Server

Bullet Sever继承了Bullet类。实现细节如下:

1.2. Wall Server

Wall Sever继承了Wall类。实现细节如下:

1.3. Tank Server

Tank Sever继承了Tank类。Tank的控制者可以从ECatControlType中进行选择,包括真人玩家或者AI控制。这个设计允许在参与人数不够的情况下,自动坦克的参与,增加了游戏趣味和可扩展性。

2. Client Proxy

客户端代理负责服务端和客户端的交互,主要支持对四种交流功能:包信息的交换、获得未处理的Movelist、设置Movelist的DirtyState、以及坦克的毁灭与重生。

3. Manager Sever

管理服务器包括两个:复制管理服务器和网络管理服务器。其中,复制管理服务器用批量复制到机制保证了数据传输的可靠性;而网络管理服务器提供了客户端与服务端的网络连接,保证了两者交互的可能性,并在此实现了对象的注册和下线。以下是它们的详细介绍。

3.1. Replication Manager Server

Replication Manager Server继承了Replication Manager类,是它的服务器,支持批量复制游戏中的请求——对于每一个对象都可以批量地创建、更新和删除所有属性。Replication Manager服务器的实现主要分为以下几个模块:

ReplicationManagerTransmissionData是TransmissionData的继承类。当ReplicationManagerServer发现了一个带有批量命令的网络标识符,就会创建一个ReplicationManagerTransmissionData来保证传输可靠数据。其中包括一个小类——ReplicationTransmission(包含网络ID、动作和状态信息)。具体实现如下:

3.2. Network Manager Sever

Network Manager服务器继承了NetworkManager类,提供了客户端与服务端的网络连接,支持两者之间的传包,并允许游戏中新对象的加入。实现的细节如下:

4. ServerPCH

PHC服务器整合所有需要的头文件,使代码结构更加有条理。整合需要的Inc包有:TankWarShared.hReplicationManagerServer.hClientProxy.hNetworkManagerServer.hServer.hTankServer.hWallServer.hBulletServer.h

5. Server

整合服务器继承了基类中的Engine类,包括以下功能:初始化网络功能、创建游戏世界、处理客户端事宜、为每个玩家分配坦克。具体实现如下:


VII. 问题与解决


VIII. 效果展示

《坦克大战》的玩家将会拥有良好的玩家体验,我们将我们的游戏demo放到了Youtube(视频传输门)。

image image image

IX. 参考资料

  1. 《网络多人游戏架构与编程》

上一篇 下一篇

猜你喜欢

热点阅读