Rotating Pickups: Difference between revisions

From Valve Developer Community
Jump to navigation Jump to search
No edit summary
m (→‎top: clean up, added orphan tag)
 
(6 intermediate revisions by 3 users not shown)
Line 1: Line 1:
In this tutorial we'll create an entity that will rotate at a fixed position above ground, and will act as a health pickup, like the ones in TF2. When picked up it will disappear, before it respawns after a certain amount of seconds. This code was made for and tested with OB HL2MP sdk.
{{Orphan|date=January 2024}}


Before you start this tutorial you should have:
{{toc-right}}
*Completed and understood all the beginner tutorials
*Know how to make an FGD entry


In this tutorial we'll create a '''rotating health pickup''' entity, like the ones in TF2. When a player walks over it they will gain some health and the item will disappear until a respawn timer completes.


<div style="font-size:120%;">'''[[Rotating Pickups/Code|Get the code here.]]''' You should be familiar with [[Your First Entity|creating entities]], so only the interesting parts will be picked out.</div>


== Functionality ==


; Server
: Initialise the entity as a [[trigger]]
: Ensure that the item floats above any walkable surfaces
: Create a [[decal]] to mark the item's position when it is invisible
: Handle touches from players (give health, play a respawn sound)
; Client
: Rotate the model
: Handle model visibility


== Includes and definitions ==
== Server ==
<pre>
#include "cbase.h" // The base for all entities
#include "player.h" // The player itself
#include "items.h" // Where we derive our class from
#include "engine/IEngineSound.h" // This takes care of the sounds


// memdbgon must be the last include file in a .cpp file!!!
=== Spawn() ===
#include "tier0/memdbgon.h"


// This defines the size of a box around our pickup
* Note how some of <code>[[CItem]]</code>'s changes have to be undone (it's not designed with impossible floating objects in mind). Source is a big and mature engine, and it's always worth checking what your base classes are doing.
#define ITEM_PICKUP_BOX_BLOAT          24
* We are setting <code>MdlTop</code> before fiddling about with the bounding box. This will be used when repositioning the entity; if we just used the origin, then if/when a larger model is used it might reach so high that it obscures the player's view.


// A full rotation divided by the number of seconds it takes to rotate
=== Activate() ===
#define ITEM_ROTATION_RATE            ( 360.0f / 9.0f )


// Define what will be the default health inside
* Here we trace down to our desired height, adding to the entity's Z position if we come up short. This is done in <code>Activate()</code> instead of <code>Spawn()</code> in case we spawned before the entity beneath us.
#define        DEFAULT_HEALTH_TO_GIVE        25
* This is also where we fire a decal onto the ground. This might look bad in some cases, so a flag to bypass the call is provided.


// Define what will be the default respawn time
=== Precache() ===
#define        DEFAULT_RESPAWN_TIME          20
</pre>


First off create a new file. What you name it is up to you. Begin by putting the above code in the file. The includes should be fairly easy to understand why. The player.h is included because we'll access the player code to set the players health.
* We need to get the return value from <code>UTIL_PrecacheDecal()</code>, but <code>Precace()</code> is called statically (i.e. directly, not through any instance of the class). Storing the value in a global variable is the only sensible solution.


The ITEM_PICKUP_BOX_BLOAT is the most confusing one here. It's really just a number that defines the size of a box shape around the model. If you change the model, you might need to raise/lower this value to prevent the player from having to walk into the model before being able to pick it up.
== Client ==


ITEM_ROTATION_RATE controls how fast our model should rotate. It's written in it's mathematical manner to show the logic behind it. You could really just put 40 in there and it would still work. However, as the calculation is done by the preprocessor, there isn't much overhead to writing it this way. The parentheses are there as a safety measure to make sure the preprocessor reads it correctly. The last two are just fallback values, i.e. if a mapmaker doesn't specify any other values, those will be used instead.
=== ClientThink() ===


== Class declaration and datadesc ==
* <code>C_RotatingPickup::ClientRotAng</code> is used to rotate the model, not <code>CBaseEntity</code>'s built-in variable. This is because even though the latter has been excluded from this entity's [[datatable]], it is still zeroed when the entity materialises (I suspect that <code>CItem</code> is resetting the entity's location through some sort of back door). Not calling <code>GetAbsAngles()</code> also provides a small performance boost.


<pre>
=== PostDataUpdate() ===
//-----------------------------------------------------------------------------
// Small rotating health kit. Heals the player when picked up.
//-----------------------------------------------------------------------------
class CRotatingPickup : public CItem
{
public:
DECLARE_CLASS( CRotatingPickup, CItem );
DECLARE_DATADESC();


CRotatingPickup();
* If you have looked into [[Networking Entities#Send & Receive Proxies|receive proxies]], you may be wondering why we are testing against a cached value when we could simply hook into the receipt of an updated one. Two reasons:
*# You can't have proxies on <code>RecvPropBool()</code>.
*# Proxies are static and should never, ''ever'' be used to run entity logic. Receive proxies are called not only when receiving entity updates, but also when validating [[Prediction|predicted]] values. It's quite possible for a proxy to be re-used by another entity too.


void Spawn( void );
[[Category:Programming]]
void Precache( void );
[[Category:Free source code]]
 
[[Category:Tutorials]]
bool MyTouch( CBasePlayer *pPlayer );
void FallThink( void ) { return; } // Override the function that makes items fall to the ground
void RotateThink( void );
 
CBaseEntity* Respawn( void );
void Materialize( void );
 
protected:
 
int m_iHealthToGive;
int m_iRespawnTime;
Vector RespawnPosition;
 
private:
 
void UpdateSpawnPosition( Vector originalSpawnPosition );
 
};
 
LINK_ENTITY_TO_CLASS( item_rotating, CRotatingPickup );
 
PRECACHE_REGISTER( item_rotating );
 
BEGIN_DATADESC( CRotatingPickup )
 
DEFINE_KEYFIELD( m_iHealthToGive, FIELD_INTEGER, "health"),
DEFINE_KEYFIELD( m_iRespawnTime, FIELD_INTEGER, "respawntime"),
 
DEFINE_THINKFUNC( RotateThink ),
 
END_DATADESC()
</pre>
 
Most of these functions are just overrides of fuctions defined in our base class, CItem. The interesting one there is FallThink(), which just contains a simple return. The reason for this is because the FallThink() functions causes the item to fall to the ground before it activates. Therefore, it's overridden with a simple return, so if it ever gets called within our class, it won't do any harm. The datadesc should not be new to you if you've finished the beginner tutorials.
 
== Constructor ==
 
<pre>
//-----------------------------------------------------------------------------
// Purpose: Initialize member variables
//-----------------------------------------------------------------------------
CRotatingPickup::CRotatingPickup()
{
m_bShouldFall = false;
 
if ( m_iHealthToGive <= 0 )
m_iHealthToGive = DEFAULT_HEALTH_TO_GIVE;
 
if ( m_iRespawnTime <= 0 )
m_iRespawnTime = DEFAULT_RESPAWN_TIME;
}
</pre>
 
Here we have the variables we need to initialize, and it's pretty straightforward code. Note that m_bShouldFall is a member of the base class, and it's set as an extra precaution.
 
 
== Spawn() and Precache() ==
 
<pre>
//-----------------------------------------------------------------------------
// Purpose:
//-----------------------------------------------------------------------------
void CRotatingPickup::Spawn( void )
{
BaseClass::Spawn(); //Spawn the baseclass
 
Precache(); // Make sure the assets are loaded
 
SetMoveType( MOVETYPE_NONE ); // It will only rotate, not move
SetSolid( SOLID_BBOX ); // It is solid
SetCollisionGroup( COLLISION_GROUP_WEAPON ); // And it can collide with stuff
 
CollisionProp()->UseTriggerBounds( true, ITEM_PICKUP_BOX_BLOAT ); // Create a collision trigger around the object
SetTouch(&CRotatingPickup::ItemTouch); // ItemTouch is a function in our base class that takes care of touches
 
UpdateSpawnPosition( GetAbsOrigin() ); // We update our position relative to the ground
 
// Set the x angle, since it will never change
QAngle angle = GetAbsAngles();
angle.x = 45;
SetAbsAngles( angle );
 
m_takedamage = DAMAGE_EVENTS_ONLY;
 
SetModel( "models/items/healthkit.mdl" ); // Set the model we'll use
 
// Start thinking in 0.01 seconds
SetThink( &CRotatingPickup::RotateThink );
SetNextThink( gpGlobals->curtime + 0.01f );
}
 
//-----------------------------------------------------------------------------
// Purpose: Make sure the engine loads the sounds and models before they are used
//-----------------------------------------------------------------------------
void CRotatingPickup::Precache( void )
{
PrecacheModel( "models/items/healthkit.mdl" ); // Change this to get another model
PrecacheScriptSound( "HealthKit.Touch" );  // scripts/game_sounds_items.txt
}
</pre>
 
You should feel familiar here. We set the movetypes, solid states and collision data accordingly. CollisionProp is what creates the trigger around the model, and SetTouch() is the function that will get called every time the trigger is touched. ItemTouch() isn't specified within our class and is again a member of the base class.
 
Why are we using that and not our own touch function? The difference between ItemTouch and MyTouch is that ItemTouch is fired every time it gets touched by anything. ItemTouch calls MyTouch whenever there's a player picking up the item. If you have a look at CItem you'll see that MyTouch does nothing. Therefore we override it and place our own code in there.
 
The next part is where we update both position and angle. We'll define the UpdateSpawnPosition() later. As for now, you can see that it takes the entity's current origin as the input. This function will make sure that the healthkit is always at the same level above ground, no matter how high or low the level designer place it.
 
QAngles might be something new to you. QAngles are basically what's used to define angles in Source. As you can see, we start by making the angle to be exactly as the level designer placed it. Then we override the X-axis to tilt it. You might have to change what axis and how much you tilt it if you use another model. Next we set the model to be the HL2 health kit, and tell the entity to start thinking. The precache is just two lines, one for the sound the item uses and another one for the model. Let's continue to the UpdateSpawnPosition() function.
 
 
== UpdateSpawnPosition() ==
 
<pre>
void CRotatingPickup::UpdateSpawnPosition( Vector originalPosition )
{
// Create local variables
trace_t tr; // The trace
Vector end, dir, final; // The vectors
QAngle down; // The angles
 
down.y = -90; //Make angle point down
 
AngleVectors( down, &dir); //Make the vector point to the angle
 
end = originalPosition + dir * MAX_TRACE_LENGTH; // Get the end point
 
// Trace a line down to the ground
UTIL_TraceLine( originalPosition, end, MASK_SOLID, NULL, COLLISION_GROUP_NONE, &tr );
 
final = tr.endpos; // final is now the position of the ground right beneath our entity
 
final.z += 50; // Add 50 units in height
 
SetAbsOrigin( final ); // Update our position to be 50 units above the ground
RespawnPosition = final; // Store our position for easy access later
}
</pre>
 
In this function we use two important things from the source engine to move the item to the correct position above the ground. If you haven't done so already, you should read up on how TraceLines work.
 
After defining our local variables, lets get to the real business. For our traceline to get the position below, we have to make an angle that points down, and then transfer that direction to a vector. The vector math used to get the end point is somewhat advanced, and I'll not go in depth of that topic here. Search for "Vector addition" on google if you want to learn more about it.
 
By multiplying the sum of the two vectors we can define the length of it. The we create an actual trace line. We don't want our model to spawn in the ground, so we add 50 units in height from the ground position. The last thing we do in the function is to set our new position, and update the position it will respawn at.
 
 
== MyTouch() ==
 
<pre>
//-----------------------------------------------------------------------------
// Purpose: Give the player health and plays a sound
// Input  : *pPlayer -
// Output :
//-----------------------------------------------------------------------------
bool CRotatingPickup::MyTouch( CBasePlayer *pPlayer )
{
//Check the pointer and check if the player needs more health
if ( pPlayer && pPlayer->GetHealth() < pPlayer->GetMaxHealth() )
{
pPlayer->TakeHealth( m_iHealthToGive, DMG_GENERIC );
 
// This code is related to the hud
CSingleUserRecipientFilter user( pPlayer );
user.MakeReliable();
 
UserMessageBegin( user, "ItemPickup" );
WRITE_STRING( GetClassname() );
MessageEnd();
 
// Output the sound sound
CPASAttenuationFilter filter( pPlayer, "HealthKit.Touch" );
EmitSound( filter, pPlayer->entindex(), "HealthKit.Touch" );
 
//Msg("A player picked up something!\n" ); //Uncomment this line to get a note in the console when picked up
 
Respawn(); // Respawn our pickup
 
return true;
}
 
return false;
}
</pre>
 
This is where the player will get the health he deserves. We want to make sure we have a valid player and that he doesn't have max hp when he picks it up. If he doesn't, we give him the specified health. The next few lines of code is a usermessage. What this does in this case is to inform the player that he picked up something. This is usually done via a hud element. Since there's no hud element defined for it, you won't see anything before you actually add one. The next lines takes care of the sound output that comes every time a player picks up the health kit, and finally it calls the Respawn() function to make sure our health kit respawns.
 
== Respawn() ==
 
<pre>
/-----------------------------------------------------------------------------
// Purpose: Initiate the respawning process
//-----------------------------------------------------------------------------
CBaseEntity* CRotatingPickup::Respawn( void )
{
// It can't be touched and it can't be seen
SetTouch( NULL );
AddEffects( EF_NODRAW );
 
//Reset the movetypes
SetMoveType( MOVETYPE_NONE );
SetSolid( SOLID_BBOX );
SetCollisionGroup( COLLISION_GROUP_WEAPON );
 
UTIL_SetOrigin( this, RespawnPosition ); //Get our respawn position from earlier
 
// Reset the angles
QAngle angle = GetAbsAngles();
angle.x = 45;
SetAbsAngles( angle );
 
RemoveAllDecals(); //Remove any decals
 
//Start thinking when the pickup should appear again
SetThink ( &CRotatingPickup::Materialize );
SetNextThink( gpGlobals->curtime + m_iRespawnTime );
 
return this;
}
</pre>
 
This function takes care of the respawning process. It should be noted that our entity is never actually removed, but it's just set to an invisible state. The function acts as a secondary spawn function, where the position and angles are reset.
 
 
== Materialize() ==
 
<pre>
//-----------------------------------------------------------------------------
// Purpose: Finalize the respawning process
//-----------------------------------------------------------------------------
void CRotatingPickup::Materialize( void )
{
if ( IsEffectActive( EF_NODRAW ) )
{
//Changing from invisible state to visible.
RemoveEffects( EF_NODRAW );
}
 
EmitSound( "AlyxEmp.Charge" ); //Emit a sound
 
SetTouch( &CRotatingPickup::ItemTouch ); //Reset our think functions
 
SetThink( &CRotatingPickup::RotateThink ); // Start rotating again
SetNextThink( gpGlobals->curtime + 0.01f ); // Think in 0.01 sec
}
 
 
</pre>
 
In reality, it's the Materialize() function that does the actual "respawning". Although what it really does is just remove the EF_NODRAW flag we set earlier in Respawn(). It also resets our touch function and emits a sound, before it set the next think function, that will take care of our rotation.
 
 
== RotateThink() ==
 
<pre>
//-----------------------------------------------------------------------------
// Purpose: Make our model rotate
//-----------------------------------------------------------------------------
void CRotatingPickup::RotateThink( void )
{
// This makes sure the model rotates independent of the fps
float dt = gpGlobals->curtime - GetLastThink();
 
QAngle angles = GetAbsAngles(); //Get the current angles
 
// Set the angles according to the rotation rate and fps
angles.y += ( ITEM_ROTATION_RATE * dt );
 
if ( angles.y >= 360 ) // If the rotation is more than 360,
angles.y -= 360; // subtract 360 to avoid large variables
 
SetAbsAngles( angles ); // Set the angles now
SetNextThink( gpGlobals->curtime + 0.01f ); // Think again in 0.01 sec
}
</pre>
 
We only have one more function left to define, and that is the think function that will rotate the model. To make sure the model can rotate at correct speed independent of the fps, we use something called Delta Time, which is basically the current time - the last time the entity was thinking. Which means that if this think was delayed for some reason, it will still compensate for it. To avoid the angle variables to get insanely large, we subtract 360 every time it's above 360.
 
 
== When you're done ==
 
After completing this tutorial, you can expand this code to do more. Here's a list of things you can do:
 
*Create an entry in your FGD file
*Make it rotate at another speed
*Reverse the rotation direction
*Modify the height above ground
*Improve the RotateThink() (It executes even though the model is invisible. What can you do about it?)
*Change the sounds
*Change the model
*Create a sprite that surrounds the model (Advanced)
*Make it bounce up and down along with the rotation (Advanced)
*Make it give you a weapon instead of health (Advanced)
 
== See Also ==
 
*[[Rotating Pickups/Code|Tutorial code in full]]
 
[[Category:Tutorials]][[Category:Programming]]

Latest revision as of 22:52, 21 January 2024

In this tutorial we'll create a rotating health pickup entity, like the ones in TF2. When a player walks over it they will gain some health and the item will disappear until a respawn timer completes.

Get the code here. You should be familiar with creating entities, so only the interesting parts will be picked out.

Functionality

Server
Initialise the entity as a trigger
Ensure that the item floats above any walkable surfaces
Create a decal to mark the item's position when it is invisible
Handle touches from players (give health, play a respawn sound)
Client
Rotate the model
Handle model visibility

Server

Spawn()

  • Note how some of CItem's changes have to be undone (it's not designed with impossible floating objects in mind). Source is a big and mature engine, and it's always worth checking what your base classes are doing.
  • We are setting MdlTop before fiddling about with the bounding box. This will be used when repositioning the entity; if we just used the origin, then if/when a larger model is used it might reach so high that it obscures the player's view.

Activate()

  • Here we trace down to our desired height, adding to the entity's Z position if we come up short. This is done in Activate() instead of Spawn() in case we spawned before the entity beneath us.
  • This is also where we fire a decal onto the ground. This might look bad in some cases, so a flag to bypass the call is provided.

Precache()

  • We need to get the return value from UTIL_PrecacheDecal(), but Precace() is called statically (i.e. directly, not through any instance of the class). Storing the value in a global variable is the only sensible solution.

Client

ClientThink()

  • C_RotatingPickup::ClientRotAng is used to rotate the model, not CBaseEntity's built-in variable. This is because even though the latter has been excluded from this entity's datatable, it is still zeroed when the entity materialises (I suspect that CItem is resetting the entity's location through some sort of back door). Not calling GetAbsAngles() also provides a small performance boost.

PostDataUpdate()

  • If you have looked into receive proxies, you may be wondering why we are testing against a cached value when we could simply hook into the receipt of an updated one. Two reasons:
    1. You can't have proxies on RecvPropBool().
    2. Proxies are static and should never, ever be used to run entity logic. Receive proxies are called not only when receiving entity updates, but also when validating predicted values. It's quite possible for a proxy to be re-used by another entity too.