Wednesday, April 2, 2014

Unity Project Starbound Aces Cleanup and Source

When I last left a post, Starbound Aces was working but there were a lot of issues that needed to be fixed. Tonight was spent doing mostly that. It also allowed me to take a look at the code again to discuss how the final combat system works at a lower level.

To recap, I earlier explained that players have one weapon as of right now, a laser. It is a health based system. Each player has health, each weapon has damage. If a player is hit by a weapon, they lose health equal to the damage the weapon does. When a player has no more health, they die and are removed from the current game.

That pretty much covers what was done this morning and tonight.

How was all of this implemented using Unity and SmartFox? Well, this is where the fun part begins.

Laser

In Unity the laser is actually just a simple raycast that is fire from a muzzle point (which I added) or the center of the object if the muzzle point (a Transform) is null. The direction of the laser is that of the muzzle point. The reason this is important is that a muzzle point can be an independent child object of the ship. This means it can fire in directions other than forward. Think of this as a rotating turret.

The raycast returns an out parameter containing information of what it hit, if it hit anything. I then check this with GetComponent<> to see if the hit object has the RemotePlayerScript. If so, I package up some information in a Dictionary of type Dictionary<string, type>. The information I package is the Id of the player hit ( supplied by RemotePlayerScript ), the player's weapon damage, and the position coordinates of where the laser hit the remote player.

  protected void OnFire() {
        laser.enabled = true;
        laser.SetPosition(0, transform.position);
        laser.SetPosition(1, transform.position + transform.forward * range);
        currEffectCooldown = effectCooldown;
        RaycastHit hitInfo = new RaycastHit();
        if(Physics.Raycast(new Ray(weaponMuzzlePoint.position, 
weaponMuzzlePoint.TransformDirection(Vector3.forward)), out hitInfo, range)){
            RemotePlayerScript rmplayer = null;
            try {
                rmplayer = hitInfo.collider.gameObject.GetComponent<RemotePlayerScript> ();
                Dictionary<string, object> data = new Dictionary<string, object> ();
                data.Add ( "damage", damage );
                data.Add ( "player.hit.id", rmplayer.Id );
                Vector3 contactPoint = hitInfo.point;
                data.Add ( "contact.point.x", contactPoint.x );
                data.Add ( "contact.point.y", contactPoint.y );
                data.Add ( "contact.point.z", contactPoint.z );
                server.Send ( DataType.FIRE, data );
            } catch ( System.Exception e ) {
                //here I actually intend to ignore the null reference because that means i hit something that isn't a player.
            }
        }
        currCooldown = cooldown;
    }

Then the SFSClient script handles it from here:

    private void SendFireRequest ( object data ) {
        SFSObject sfsdata = new SFSObject ();
        Dictionary<string, object> firedata = data as Dictionary<string, object>;
        sfsdata.PutFloat ( "damage", ( float ) firedata[ "damage" ] );
        sfsdata.PutInt ( "player.hit.id", ( int ) firedata[ "player.hit.id" ] );
        sfsdata.PutFloat ( "contact.point.x", ( float ) firedata[ "contact.point.x" ] );
        sfsdata.PutFloat ( "contact.point.y", ( float ) firedata[ "contact.point.y" ] );
        sfsdata.PutFloat ( "contact.point.z", ( float ) firedata[ "contact.point.z" ] );
        SFSInstance.Send ( new ExtensionRequest ( "server.fire", sfsdata, SFSInstance.LastJoinedRoom, useUDP ) );
    }

After this is done, a request is sent to the server as part of the extension multi-handler. For right now, the extension simple unpacks the information and repacks it for sending off to all of the other players in the game. In future releases the server will actually verify that it is possible for the player to even hit who he is saying he hit. If not, it will just throw away the message and not respond.

    private void handleFire(User user, ISFSObject params){
        ISFSObject response = SFSObject.newInstance();
        response.putFloat("damage", params.getFloat("damage"));
        response.putInt("player.hit.id", params.getInt("player.hit.id"));
        response.putFloat("contact.point.x", params.getFloat("contact.point.x"));
        response.putFloat("contact.point.y", params.getFloat("contact.point.y"));
        response.putFloat("contact.point.z", params.getFloat("contact.point.z"));
        send("player.hit", response, user.getLastJoinedRoom().getPlayersList());
    }

The finally step is all players getting the information that player A has hit player B (the remote player for player A). Then a message is sent from the SmartFox Event handler client side using the OnEvent method provided by the EventListener/Messenger Interface I designed. When a remote player object realizes that the server says it was hit it does several things. All of this is handled in RemotePlayerScript. Each of these events are as follows: subtract damage from health and instantiate a small explosion at the impact points.

The ClientPlayer scenario:

            case "player.hit":
                if (o.GetType() != typeof(Dictionary<string, object>)) {
                    return;
                }
                Dictionary<string, object> hitdata = o as Dictionary<string, object>;
                float dmg = (float)hitdata["damage"];
                Vector3 contactPoint = new Vector3(
                    (float)hitdata["contact.point.x"],
                    (float)hitdata["contact.point.y"],
                    (float)hitdata["contact.point.z"]);
                
                hullHealth -= dmg;
                Instantiate(Resources.Load("HitPrefab"), contactPoint, transform.rotation);
                Debug.Log("Damage Taken by Client. Damage: " + dmg + " Remaining Health: " + hullHealth);
                break;

The Remote Player scenario:

    private void handlePlayerRemoteHit(object o){
        Dictionary<string, object> data = o as Dictionary<string, object>;
        if ( ( int ) data[ "player.hit.id" ] == remoteId ) {
            localHealth -= ( float ) data[ "damage" ];
            Vector3 contactPoint = new Vector3 (
                ( float ) data[ "contact.point.x" ],
                ( float ) data[ "contact.point.y" ],
                ( float ) data[ "contact.point.z" ] );
            Instantiate ( Resources.Load ( "HitPrefab" ), contactPoint, transform.rotation );
            Debug.Log ( "Damage taken <Damage : Health> <" + ( ( float ) data[ "damage" ] ).ToString () + " : "
                + localHealth.ToString () + "> to remote player." );
        }
    }
Finally, how the SmartFox client delegates these messages:

    private void PlayerHitResponse ( SFSObject sfsdata ) {
        float damage = sfsdata.GetFloat ( "damage" );
        int playerid = sfsdata.GetInt ( "player.hit.id" );
        Debug.Log ( "Player hit: " + playerid.ToString () );
        Dictionary<string, object> fdata = new Dictionary<string, object> ();
        fdata.Add ( "player.hit.id", playerid );
        fdata.Add ( "damage", damage );
        fdata.Add ( "contact.point.x", sfsdata.GetFloat ( "contact.point.x" ) );
        fdata.Add ( "contact.point.y", sfsdata.GetFloat ( "contact.point.y" ) );
        fdata.Add ( "contact.point.z", sfsdata.GetFloat ( "contact.point.z" ) );
        if ( playerid == SFSInstance.MySelf.Id ) {
            OnEvent ( "player.hit", fdata );
        } else {
            OnEvent ( "player.remote.hit", fdata );
        }
    }

Death

Handling the destruction of a player is pretty straight forward here. A player keeps track of his own health in the ClientPlayer script in Unity. When it receives enough "player.hit" messages from the server, its health will eventually reach 0. When this happens the player notifies the server that they have died. Then, the player object is destroyed and the actual user moved back to the lobby.

        if (hullHealth <= 0.0f) {
            server.Send(DataType.DEATH, null);
            //TODO: handle death for player clientside
            server.Send(DataType.JOINGAME, "lobby");
            //TODO: be careful, the camera will be destroyed if the player is
        }
Then the SFSClient handles this as so:

    private void SendDeathRequest ( object data ) {
        SFSObject sfsdata = new SFSObject ();
        SFSInstance.Send ( new ExtensionRequest ( "server.death", sfsdata, SFSInstance.LastJoinedRoom, useUDP ) );
    }

When the server receives the event from the client that the client has died, it notifies the rest of the clients by sending out the dead player's id to each player left in the game.

    private void handleDeath(User user, ISFSObject params){
        ISFSObject response = SFSObject.newInstance();
        response.putInt("id", user.getId());
        
        send("death", response, user.getLastJoinedRoom().getPlayersList());
    }

Finally, when the player's receive the message, they simply ask the GameManager to remove and destroy that player's representing RemotePlayer object.

    private void DeathResponse ( SFSObject sfsdata ) {
        OnEvent ( "player.remote.death", sfsdata.GetInt ( "id" ) );
    }
    private void handlePlayerRemoteDeath ( object o ) {
        if ( ( int ) o == remoteId ) {
            GameManager.gameManager.RemoveRemotePlayer ( remoteId );
        }
    }

To improve this, I would actually store the player's health on the server. That way the server is in charge and tells a player if they are dead or still alive. This would help reduce cheating.

As always, the source code is now up on the repository at https://github.com/hollsteinm/GSPSeniorProject. However, this time I decided to add source code to help pinpoint where I am talking about. Let me know what you think about this approach!

No comments:

Post a Comment