Skip to content

Script: different model per player

Victor Luchits edited this page Aug 22, 2019 · 4 revisions

This small tutorial is mirrored from here

Warning: Do never modify the ca file itself. Copy and rename it to whatever you want before modifying it. The original gametypes must always stay unmodified.


This tutorial will show hot to assign models to entities, and more in particular, how to assign player models to different players. The use of a feature like this would more frequently be for making a class based gametype with so specific differences between players that is preferable to know which each player's class to the clarity of force models.

There are 2 basic things to learn in this tutorial. How to disable force models for 1 gametype, and how to assign a model to a Entity object. The first one is a simple as disabling the force models option in the gametype descriptor.

As this tutorial continues from the tutorial 1, I will be refering to the ca gametype, so to the file basewsw/progs/gametypes/ca.as.

In the ca script, we are going to work in the gametype descriptor settings, which are set up at initializing the gametype, so we need to find the function GT_InitGametype.

Inside this function we see how the gametype descriptor is initialized, assigning the basic parameters that will guide the interal code decisions in many aspects, like what item types can be spawned, what will be their respawn times, if the gametype is team based, etc.

For the purpose of this tutorial we are interested in disabling force models, so we locate the option:

gametype.canForceModels = true;

We change it to false.

The players will not see the player models they forced anymore, but the player model assigned by the server, which are, by now, the ones set by each client as their own player model in the player setup menu.

So we can proceed into the step 2 of our task, assigning the player model we want for each of the player classes. For the matters of this example, we will assign bigvic to the grunt class, monada to the spammer class and padpork to the camper class.

This time, we will create a function to update each client player model instead of adding our modification inside the respawn function (I'll explain why later). So, we open our gametype script file and find any space outside functions to create our own (I'm putting it the first after it says "LOCAL FUNCTIONS", right before CA_SetUpWarmup).

We declare a function that takes as parameter a Client class object reference and returns nothing, and, in this example, we call it CA_UpdatePlayerModels. Looks like this:

void CA_UpdatePlayerModels( Client @client )
{
}

The function asumes it will receive a valid reference to a Client object, and will operate with it.

Assigning a model is made to Entity objects using the method Entity::setupModel( &String filePath ). It wants the model file path relative to basewsw, and it does all the work for us.

All Client objects can retrieve the Entity object associated to the client by using the method Entity @Client::getEnt(). So we always have access to the client's entity and that's what we will use to assign the player model.

There are a few things to consider, tho. The first is that when we are assigning a player model we don't have to set the full path to the actual model file, but to the directory where the model is located. When assigning a player model to a non-player entity, we will have to set the path to the model file, including the filename.

The second is that not all Entity object types are able to display models of all types. For example, in our case, player models can only be displayed by Entities of type ET_PLAYER or ET_CORPSE.

Setting up a entity with a md3 model would look like this:

ent.type = ET_GENERIC;
ent.setupModel( "models/effects/dash_burst.md3" );

Setting a player model, like this:

ent.type = ET_PLAYER;
ent.setupModel( "models/players/bigvic" );

The Entity type doesn't affect us at this tutorial, because the entity we receive from the internal code is already displaying a player model, so it is already of type ET_PLAYER.

So, for writting our function, we repeat what we learnt at tutorial 1 about finding the client class:

void CA_UpdatePlayerModels( Client @client )
{
    int myPlayerClass = GENERIC_GetClientClass( client );

    if ( myPlayerClass == PLAYERCLASS_SPAMMER )
    {
    }
    else if ( myPlayerClass == PLAYERCLASS_CAMPER )
    {
    }
    else if ( myPlayerClass == PLAYERCLASS_GRUNT )
    {
    }
}

And set a different player model for each of the classes.

void CA_UpdatePlayerModels( Client @client )
{
    int myPlayerClass = GENERIC_GetClientClass( client );

    if ( myPlayerClass == PLAYERCLASS_SPAMMER )
    {
        // spammer looks like monada just because
        client.getEnt().setupModel( "models/players/monada" );
    }
    else if ( myPlayerClass == PLAYERCLASS_CAMPER )
    {
        // camper looks like a padpork cause he's slow
        client.getEnt().setupModel( "models/players/padpork" );
    }
    else if ( myPlayerClass == PLAYERCLASS_GRUNT )
    {
        // grunt looks like a bigvic because he loves rockets
        client.getEnt().setupModel( "models/players/bigvic" );
    }
}

That's it. We have a function that receives a Client object and sets a different player model for each class. Now we just need to call our function so they are updated.

We did our changes for the player speeds at GT_playerRespawn, but in this case, doing them there would not be enough, because the player model of a client can not only be assigned at respawning by the internal code. The user could change his "model" cvar at any time and that would trigger a player model update at any give time.

So we need to update the player model at a function that is called continuously, and this function is GT_ThinkRules. It looks like this:

void GT_ThinkRules()
{
    if ( match.scoreLimitHit() || match.timeLimitHit() || match.suddenDeathFinished() )
        match.launchState( match.getState() + 1 );
    
    // print count of players alive and show class icon in the HUD
    
    Team @team;
    int[] alive( GS_MAX_TEAMS );
    
    alive[TEAM_SPECTATOR] = 0;
    alive[TEAM_PLAYERS] = 0;
    alive[TEAM_ALPHA] = 0;
    alive[TEAM_BETA] = 0;
    
    for ( int t = TEAM_ALPHA; t < GS_MAX_TEAMS; t++ )
    {
        @team = @G_GetTeam( t );
        for ( int i = 0; @team.ent( i ) != null; i++ )
        {
            if( !team.ent( i ).isGhosting() )
                alive[t]++;
        }
    }
    
    G_ConfigString( CS_GENERAL, "- " + alive[TEAM_ALPHA] + " -" );
    G_ConfigString( CS_GENERAL + 1, "- " + alive[TEAM_BETA] + " -" );
    
    for ( int i = 0; i < maxClients; i++ )
    {
        Client @client = @G_GetClient( i );

        if ( match.getState() >= MATCH_STATE_POSTMATCH || match.getState() < MATCH_STATE_PLAYTIME )
        {
            client.setHUDStat( STAT_MESSAGE_ALPHA, 0 );
            client.setHUDStat( STAT_MESSAGE_BETA, 0 );
            client.setHUDStat( STAT_IMAGE_BETA, 0 );
        }
        else
        {
            client.setHUDStat( STAT_MESSAGE_ALPHA, CS_GENERAL );
            client.setHUDStat( STAT_MESSAGE_BETA, CS_GENERAL + 1 );
        }
        
        if( client.getEnt().isGhosting() || match.getState() >= MATCH_STATE_POSTMATCH )
        {
            client.setHUDStat( STAT_IMAGE_ALPHA, 0 );
        }
        else
        {
            client.setHUDStat( STAT_IMAGE_BETA, GENERIC_GetClientClassIcon( client ) );
        }
    }

    if ( match.getState() >= MATCH_STATE_POSTMATCH )
        return;

    for ( int i = 0; i < maxClients; i++ )
    {
        GENERIC_ChargeGunblade( @G_GetClient( i ) );
    }

    caRound.think();
}

We are interested in updating the player models of all clients that are playing and have a player model. In this function we see there are already several for loops that run through all the clients. We will add our change to the first one doing all clients. This one:

//... function code
    
    G_ConfigString( CS_GENERAL, "- " + alive[TEAM_ALPHA] + " -" );
    G_ConfigString( CS_GENERAL + 1, "- " + alive[TEAM_BETA] + " -" );
    
    for ( int i = 0; i < maxClients; i++ )
    {
        Client @client = @G_GetClient( i );
        
// function code continues....

That code already found for us the Client object reference, so all we have to do is using it to call our function, BUT, we have to keep in mind that we want to change only the models to the clients who are playing, not to the spectators nor to the dead players in team cam. So what we do is calling our function only to those players who are not in ghost state.

Ghost state is how we call a entity that isn't visible nor solid. So isn't interfering with the game physics nor visible to the players. Knowing if a entity is in ghost state can be made by calling the Entity method bool Entity::isGhosting().

So we go and add our call only if that client isn't ghosting

//... function code
    
    G_ConfigString( CS_GENERAL, "- " + alive[TEAM_ALPHA] + " -" );
    G_ConfigString( CS_GENERAL + 1, "- " + alive[TEAM_BETA] + " -" );
    
    for ( int i = 0; i < maxClients; i++ )
    {
        Client @client = @G_GetClient( i );

        if( client.getEnt().isGhosting() == false )
        {
            CA_UpdatePlayerModels( client );
        }
        
// function code continues....

Done! Our classes will be easy to understand now, because all of them look different. You can save and go trying it yourself. :)


There's a small bug in the above tutorial however. The bug consists in using the class value in real time, where the changes to the player should only be updated at respawning. We will fix this in the next examples.

Notice: I will most likely implement this changes in the default ca and tdo gametypes myself. If some time in the future you are trying to follow the tutorials and find the code I mention being already in the gametype script you know this is the reason

First thing first. There are several posible ways of handling the case. The most correct one would probably be changing the code in the classes manager, but that's Warsow's generic script code and we should never modify the scripts provided by Warsow because it could make other scripts fail.

Having left out the option of implementing latched class change in the classes manager itself, we will now decide how to handle it in our script. We have two basic approaches to fix our particular bug:

  • a) We retrieve and store the class the player has when he is respawned, store it, and use the stored information to update the player models, so it is not changed at any time but at respawning.

  • b) We modify the "class" command to not directly set the class into the classes manager, but to store the new player class name and update the class manager when the player is respawned.

The "b" option has some clear advantages over the "a" option. First is that it won't only affect the player models, but also improve the class icons shown in the scoreboard, since they are suffering of the same problem. And that it will prevent any other error like this one to happen in the future. Also the "class" command is code specific of this gametype and was written for it.

So we go with option "b". Delay the application of the class command.

Commands can be created by the scripts. This is the case of the "class" command. If you want to know how: The command must be first registered at GT_InitGametype, so the internal code knows it's not an unknown command and is aware the script will know how to deal with it. So the internal code, when received a registered command, forwards it to the script function GT_Command.

So in GT_Command the script handles the execution of user commands. In this case, the "class" command. Let's take a look at it:

bool GT_Command( Client @client, String @cmdString, String @argsString, int argc )
{
    // drop command is always forwarded to the script, it doesn't need to be registered
    if ( cmdString == "drop" )
    {
    }
    // example of registered command
    else if ( cmdString == "gametype" )
    {
        String response = "";

        response += "";
        response += "Gametype " + gametype.getName() + " : " + gametype.getTitle() + "";
        response += "----------------";
        response += "Version: " + gametype.getVersion() + "";
        response += "Author: " + gametype.getAuthor() + "";
        response += "----------------";

        G_PrintMsg( client.getEnt(), response );
        return true;
    }
    else if ( cmdString == "gamemenu" )
    {
        if ( client.getEnt().team < TEAM_PLAYERS )
        {
            G_PrintMsg( client.getEnt(), "You must joing a team before selecting a class" );
            return true;
        }

        if ( !gametype.isInstagib() )
            client.addGameCommand( "mecu \"Select Class\" Grunt \"class grunt\" Camper \"class camper\" Spammer \"class spammer\"" );

        return true;
    }
    else if ( cmdString == "class" )
    {
        if ( !gametype.isInstagib() )
            GENERIC_PlayerclassCommand( client, argsString );

        return true;
    }

    return false;
}

We can see 4 commands are handled there: "drop", "gametype", "gamemenu" and "class". We will, of course be only interested in the "class" zone.

As you see, what it does is calling a function of the generic classes manager. We will replace that function with our own. So we replace the function call GENERIC_PlayerclassCommand( client, argsString ); with one we will call CA_PlayerclassCommand( client, argsString );. We call it CA_ because we are working with the Clan Arena gametype script. The name you choose is up to you, of course.

So we will now go creating our CA_PlayerclassCommand function. I did this just below the CA_UpdatePlayerModels we created in the first part of this tutorial. We wrote the function call using the same arguments of the generic function, they are: the Client object from the client who is using the command, and the argument of the command as String.

So we declare our new function:

void CA_PlayerclassCommand( Client @client, String @argsString )
{
}

What we want to do in this function is to get the name of the player class in the command arguments and store it so it's used when the player is respawned. So the first thing we will need is a container for the classes names of all players.

The simplest way is declaring an array of String objects with the lenght of sv_maxclients. Just like this:

String[] latchedPlayerClasses( maxClients );

And, since we want this information to remain in there all the time, and not only while the function is called, we place that declaration outside of any function call. I've myself put it right above the CA_PlayerclassCommand function declaration.

String[] latchedPlayerClasses( maxClients ); // latched class of each player

void CA_PlayerclassCommand( Client @client, String @argsString )
{
}

So we have, a function which is called to store the player class name when the class command is used, and the containers where to store them. Let's just do it:

String[] latchedPlayerClasses( maxClients ); // latched class of each player

void CA_PlayerclassCommand( Client @client, String @argsString )
{
    String token = argsString.getToken( 0 );

    latchedPlayerClasses[ client.playerNum() ] = token;

    G_PrintMsg( client.getEnt(), "You will respawn as " + token + "" );
}

The first line in that function is declaring a new string called token. It uses it to extract the first word from the arguments string. Imagine that the user instead of typing "class grunt" in the console, he typed "class grunt oh yeah". The arguments string would be "grunt oh yeah", and comparing it against the player class name "grunt" will produce a false result.

So it extracts the "grunt" from "grunt oh yeah" by calling the String method String::getToken( int index ). Index being the position of the word we want to extract. We want the first one, so we use 0.

After that, we store the word contained in token in the String objects array, indexed in the position of the client who has used the command. This corresponds to: latchedPlayerClasses[ client.playerNum() ] = token. If you don't know what an array is, this is the right time to stop and google it. I'm sorry but I won't go into explaining what arrays are. This is basic programing knowledge and should be easy to figure it out.

And finally we send a print message to the client letting him know his command has reached its objective.

The basic functionality of the function is done. But, what if the client doesn't write the command correctly? We need to prevent the human errors, so we need to validate the argument the user has given us. For a start, let's check that token corresponds to a known player class name.

Notice: cPlayerClassInfos is an array of player class objects declared at generic/playerclasses.as and containing the information that defines each class

String[] latchedPlayerClasses( maxClients ); // latched class of each player

void CA_PlayerclassCommand( Client @client, String @argsString )
{
    String token = argsString.getToken( 0 );

    // Check all classes information and see if any
    // name matches the command argument.
    for ( int i = 0; i < PLAYERCLASS_TOTAL; i++ )
    {
        if ( cPlayerClassInfos.name == token )
        {
            // found it: we store the class name to update when respawning

            latchedPlayerClasses[ client.playerNum() ] = token;

            G_PrintMsg( client.getEnt(), "You will respawn as " + token + "" );

            return; // it's valid. We are done.
        }
    }

    // didn't find it
    G_PrintMsg( client.getEnt(), "Unkown class " + token + "" );
}

That's good. And what if the player is a spectator? We don't want spectators using this function! Since TEAM_SPECTATOR is the lowest value of all the teams, we make sure the client team is bigger than it.

String[] latchedPlayerClasses( maxClients ); // latched class of each player

void CA_PlayerclassCommand( Client @client, String @argsString )
{
    String token = argsString.getToken( 0 );

    // the player has to be in a team to select a class
    if ( client.getEnt().team < TEAM_PLAYERS )
    {
        G_PrintMsg( client.getEnt(), "You must join a team before selecting a class" );
        return;
    }

    // Check all classes information and see if any
    // name matches the command argument.
    for ( int i = 0; i < PLAYERCLASS_TOTAL; i++ )
    {
        if ( cPlayerClassInfos.name == token )
        {
            // found it: we store the class name to update when respawning

            latchedPlayerClasses[ client.playerNum() ] = token;

            G_PrintMsg( client.getEnt(), "You will respawn as " + token + "" );

            return; // it's valid. We are done.
        }
    }

    // didn't find it
    G_PrintMsg( client.getEnt(), "Unkown class " + token + "" );
}

That's better.

As a last refinement, during warmup the players can change class instantly at any time, so there's no need to delay the command, so, when being in warmup (or waiting for other players) we just call the generic function and ignore ours.

String[] latchedPlayerClasses( maxClients ); // latched class of each player

void CA_PlayerclassCommand( Client @client, String @argsString )
{
    String token = argsString.getToken( 0 );

    // during warmup the player will be instantly respawned
    // so the generic function does a fine job already
    if ( match.getState() < MATCH_STATE_COUNTDOWN )
    {
        GENERIC_PlayerclassCommand( client, token );
        return;
    }

    // the player has to be in a team to select a class
    if ( client.getEnt().team < TEAM_PLAYERS )
    {
        G_PrintMsg( client.getEnt(), "You must join a team before selecting a class" );
        return;
    }

    // Check all classes information and see if any
    // name matches the command argument.
    for ( int i = 0; i < PLAYERCLASS_TOTAL; i++ )
    {
        if ( cPlayerClassInfos.name == token )
        {
            // found it: we store the class name to update when respawning

            latchedPlayerClasses[ client.playerNum() ] = token;

            G_PrintMsg( client.getEnt(), "You will respawn as " + token + "" );

            return; // it's valid. We are done.
        }
    }

    // didn't find it
    G_PrintMsg( client.getEnt(), "Unkown class " + token + "" );
}

And that's it. Our function is completed. It can be safely used to store the player class name to be used when respaning.

So let's go using it.

We're going to create another function to update the latched player class when respawning. I did it right above the one we just created, and called it CA_UpdatePlayerClass. It takes as argument a Client object corresponding to the player who is being respawned. Declaration looks like this:

void CA_UpdatePlayerClass( Client @client )
{
}

What we're doing here is quite simple. We take the client, find if it has a latchedPlayerClass name waiting, and if it has any we apply the class change and delete the latchedPlayerClass name. Step by Step:

void CA_UpdatePlayerClass( Client @client )
{
    String className;

    // make a copy of the one in the latched array
    className = latchedPlayerClasses[ client.playerNum() ];
}

As the comment says, it copies the content of the String array at player's position into the just declared String className. Notice that it doesn't check if there's any content or not, it just simply copies whatever there is. This is not really important, but I just want to make clear it can be empty. We do the copying for emptying the content of the array position soon so we can forget about it. We do clear the content so the class change is not called again the next respawn.

void CA_UpdatePlayerClass( Client @client )
{
    String className;

    // make a copy of the one in the latched array
    className = latchedPlayerClasses[ client.playerNum() ];
    
    if( className.len() == 0 ) // nothing to change to
        return;

    // delete the one in the latched array so it's not updated anymore
    latchedPlayerClasses[ client.playerNum() ] = "";
}

And the last thing missing in the function is calling the generic player classes manager to assign the class to that client.

void CA_UpdatePlayerClass( Client @client )
{
    String className;

    // make a copy of the one in the latched array
    className = latchedPlayerClasses[ client.playerNum() ];
    
    if( className.len() == 0 )
        return;

    // delete the one in the latched array so it's not updated anymore
    latchedPlayerClasses[ client.playerNum() ] = "";

    // do the class update using the generic manager
    GENERIC_SetClientClass( client, className );
}

The function for updating classes at respawning is completed. Only thing we have to do now is calling it. So we move to GT_playerRespawn and we add a call to CA_UpdatePlayerClass right after checking the player is not a ghost.

void GT_playerRespawn( Entity @ent, int old_team, int new_team )
{
    if ( old_team != new_team )
    {
        // show the class selection menu
        if ( old_team == TEAM_SPECTATOR && !gametype.isInstagib() )
        {
            if ( @ent.client.getBot() != null )
                GENERIC_SetClientClassIndex( ent.client, brandom( 0, 3 ) );
            else
                ent.client.addGameCommand( "mecu \"Select Class\" Grunt \"class grunt\" Camper \"class camper\" Spammer \"class spammer\"" );
        }
    }

    if ( ent.isGhosting() )
        return;

    CA_UpdatePlayerClass( ent.client );

    // function code continues...

Mission accomplished. The troubles caused by the player class being updated before respawning are now solved. You can go testing it yourself.

Clone this wiki locally