This mod only covers the physics, but that is the most important part (in my opinion). If the physics don't feel right, then the game is much less fun to play (at least for beginners). All code provided on this page is licensed under GPL v2+ unless otherwise specified. I am using OpenArena in this example, but the code should work just as well with Quake III Arena.
Code
If you need an example, the code for the OpenArena version of the mod can be found here.
Create the Cvars
In cg_main.c
, add these lines to the long list of vmCvar_t cg_…
. Around line 300 would be good if you are using OA gamecode 0.8.8. This will create some client game variables so that it is possible to use the server Cvars in bg_pmove.c
. The reason for this seems to be that bg_pmove.c
is used by both the client and server VMs.
Also, pretty much all of these variables are optional if you don't care about being able to configure settings.
vmCvar_t g_promode;
vmCvar_t g_friction;
vmCvar_t g_accelerate;
vmCvar_t g_airaccelerate;
vmCvar_t g_strafeaccelerate;
vmCvar_t g_wishspeed;
vmCvar_t g_aircontrol;
vmCvar_t g_doublejump;
vmCvar_t g_rampboost;
Add the code above to g_main.c
as well. This will create the actual server Cvars.
Add the code below to g_local.h
and bg_local.h
so that we can use the Cvars in bg_pmove.c
.
extern vmCvar_t g_promode;
extern vmCvar_t g_friction;
extern vmCvar_t g_accelerate;
extern vmCvar_t g_airaccelerate;
extern vmCvar_t g_strafeaccelerate;
extern vmCvar_t g_wishspeed;
extern vmCvar_t g_aircontrol;
extern vmCvar_t g_doublejump;
extern vmCvar_t g_rampboost;
And back in g_main.c
, we will add these lines to static cvarTable_t cvarTable[]
to tell the server to configure some Cvar settings and use our variables as Cvars. Be sure to add a comma to the end of the line that you are placing this code after.
The first element of the array is the Cvar variable. The second is used for console completion and similar tasks. The third is the default value. The fourth, in this case CVAR_ARCHIVE
, tells the game to save these settings in a config file. The next element is always zero. It is the number of times the Cvar has changed. The last element when set announces when the Cvar has changed to every player.
{ &g_promode, "g_promode", "0", CVAR_ARCHIVE, 0, qtrue},
{ &g_friction, "g_friction", "6", CVAR_ARCHIVE, 0, qtrue},
{ &g_accelerate, "g_accelerate", "10", CVAR_ARCHIVE, 0, qtrue},
{ &g_airaccelerate, "g_airaccelerate", "1", CVAR_ARCHIVE, 0, qtrue},
{ &g_strafeaccelerate, "g_strafeaccelerate", "1", CVAR_ARCHIVE, 0, qtrue},
{ &g_wishspeed, "g_wishspeed", "30", CVAR_ARCHIVE, 0, qtrue},
{ &g_aircontrol, "g_aircontrol", "0", CVAR_ARCHIVE, 0, qtrue},
{ &g_doublejump, "g_doublejump", "0", CVAR_ARCHIVE, 0, qtrue},
{ &g_rampboost, "g_rampboost", "0", CVAR_ARCHIVE, 0, qtrue}
And that's it for the Cvars. If you compile this, you will find that these Cvars can be set in the console, but nothing else happens. Time for the fun part.
Add bunny hop
Open bg_pmove.c
. We will be spending a lot of time here.
Copy PM_Accelerate
and rename it PM_AirAccelerate
. The only difference between PM_AirAccelerate
and PM_Accelerate
will be that PM_AirAccelerate
will limit the max speed in any one direction. Of course, this limit is easily broken using bunny hopping.
Right after the line float addspeed, accelspeed, currentspeed;
, add this code:
if (wishspeed > g_wishspeed.value)
wishspeed = g_wishspeed.value;
Wishspeed is the maximum speed we can move without utilizing engine bugs. In Q3A and OA, wishspeed is 320 u/s. In Quake, the wishspeed in the air is 30 u/s. In CPMA, the wishspeed is the same as in Quake, but the acceleration is much higher, which is what allows us to bunny hop with ease. If you want Quake movement, you can simply replace PM_Accelerate
with PM_AirAccelerate
in PM_AirMove
, however, if you want CPM movement, then the code will be a little more complicated.
In ProMode physics, we can only bunny hop if the forward or backward keys are not pressed but the left and right keys are. If so, then we use PM_AirAccelerate
, otherwise we use the original PM_Accelerate
.
Put the code below in PM_AirMove
in place of the line PM_Accelerate (wishdir, wishspeed, pm_airaccelerate);
.
if ( g_promode.integer && pm->cmd.rightmove != 0 && pm->cmd.forwardmove == 0 )
PM_AirAccelerate (wishdir, wishspeed, g_strafeaccelerate.value);
else
PM_Accelerate (wishdir, wishspeed, g_airaccelerate.value);
That should do it. Try it now to see if it works. Your Cvars will actually do something now, so try these values:
g_promode 1
g_wishspeed 30
g_airaccelerate 1
g_strafeaccelerate 100
Add air control
This is one of the more difficult parts of replicating ProMode since the only real implementation in Q3A is CPMA. The first version of CPMA (CPM1) did have air control, and the sources are available (see bottom of page). It is licensed under the Q3A SDK license though, so use at your own risk. Xonotic has their own implementation (see bottom of page), which is very similar to CPM; in fact, one of the developers of Xonotic's physics previously worked on the CPMA mod. Xonotic's license is GPL v3, so if you are willing to use the GPL v3 license for your mod, then this solution should work just fine. Otherwise, beyond reverse engineering air control, you are out of luck. I will provide a simple version here under GPL v2+, but the Xonotic one is far more accurate.
Homebrew GPL v2+ air control:
// Air control
if ( g_promode.integer && pm->cmd.rightmove == 0 && pm->cmd.forwardmove != 0 &&
wishspeed <= DotProduct (pm->ps->velocity, wishdir) ) {
zspeed = pm->ps->velocity[2];
pm->ps->velocity[2] = 0;
speed = VectorLength(pm->ps->velocity);
pm->ps->velocity[0] += wishdir[0] * speed * g_aircontrol.value;
pm->ps->velocity[1] += wishdir[1] * speed * g_aircontrol.value;
VectorNormalize(pm->ps->velocity);
for (i=0 ; i<2 ; i++)
pm->ps->velocity[i] = speed*pm->ps->velocity[i];
pm->ps->velocity[2] = zspeed;
}
Add that code to PM_AirMove
after the line that reads PM_Accelerate (wishdir, wishspeed, g_airaccelerate.value);
. Declare speed
and zspeed
as floats at the top of the function.
I recommend using a value of 0.02 for g_aircontrol.
TODO: Add GPL v3 Xonotic version.
Add double jump
Pretty simple. Did we jump within 400 ms? Add a boost.
There is a catch though. We can't simply declare a global variable that stores the elapsed time because bg_pmove.c
is used for every client. If two players jump within 400 ms, the last player to jump will jump higher without having done a double jump. To prevent this, the timer must be specific to each client. The easiest way to do this is to use pm->ps->stats
, which is made for exactly this purpose. To use it though, we will have to create a new stat index since pm->ps->stats
is an array.
After the line STAT_PERSISTANT_POWERUP
in the file bg_public.c
, declare the timer index STAT_JUMPTIME
. It should look like this:
// player_state->stats[] indexes
// NOTE: may not have more than 16
typedef enum {
STAT_HEALTH,
STAT_HOLDABLE_ITEM,
STAT_WEAPONS, // 16 bit fields
STAT_ARMOR,
STAT_DEAD_YAW, // look this direction when dead (FIXME: get rid of?)
STAT_CLIENTS_READY, // bit mask of clients wishing to exit the intermission (FIXME: configstring?)
STAT_MAX_HEALTH, // health / armor limit, changable by handicap
STAT_PERSISTANT_POWERUP,
STAT_JUMPTIME
} statIndex_t;
Then, after the line pm->ps->velocity[2] = JUMP_VELOCITY;
in PM_CheckJump
, add:
// Double jump
if (g_doublejump.value && g_promode.integer) {
if (pm->ps->stats[STAT_JUMPTIME] > 0)
pm->ps->velocity[2] += g_doublejump.value;
pm->ps->stats[STAT_JUMPTIME] = 400;
}
Now we have to decrement the timer by the proper amount each frame. Add this code at the end of the function PM_DropTimers
:
// drop post-jump counter
if ( pm->ps->stats[STAT_JUMPTIME] > 0 ) {
pm->ps->stats[STAT_JUMPTIME] -= pml.msec;
if ( pm->ps->stats[STAT_JUMPTIME] < 0 ) {
pm->ps->stats[STAT_JUMPTIME] = 0;
}
}
Set g_doublejump to 100
Add ramp jump
Ramp jump gives an upward boost when you are jumping up ramps. The code is pretty simple. If you look closely, you may notice that the only significant difference is that JUMP_VELOCITY
is added instead of assigned to pm->ps->velocity[2]
. PM_ClipVelocity
does the rest for us.
Replace pm->ps->velocity[2] = JUMP_VELOCITY;
in PM_CheckJump
with the following code.
// Ramp boost
if (g_rampboost.integer && g_promode.integer) {
pm->ps->velocity[2] += JUMP_VELOCITY;
if (pm->ps->velocity[2] < JUMP_VELOCITY)
pm->ps->velocity[2] = JUMP_VELOCITY;
}
else
pm->ps->velocity[2] = JUMP_VELOCITY;
Set g_rampboost to 1
Add slick movement
In Q3A, movement on slick surfaces is the same as in the air. CPM doesn't make a distinction between normal surfaces and slick surfaces when calculating acceleration. So to complete this step all that is needed is to replace this (in PM_WalkMove):
if ( ( pml.groundTrace.surfaceFlags & SURF_SLICK ) || pm->ps->pm_flags & PMF_TIME_KNOCKBACK ) {
accelerate = pm_airaccelerate;
}
else {
accelerate = pm_accelerate;
}
with this:
if ( (( pml.groundTrace.surfaceFlags & SURF_SLICK ) || pm->ps->pm_flags & PMF_TIME_KNOCKBACK ) && !g_promode.integer) {
accelerate = g_airaccelerate.value;
}
else {
accelerate = g_accelerate.value;
}
While we're at it, replace all instances of pm_accelerate
with g_accelerate.value
. Replace pm_airaccelerate
with g_airaccelerate.value
. And finally, replace pm_friction
with g_friction.value
.
And we're done.
Now have some fun and try it on a map like dfwc2017-6 or xcm_tricks2.
You may have to find information of configuring things like acceleration from the internet or the files below.
Extras
Kill overbounce
Overbounce is an odd bug and is not really useful for anything besides trick jumping. Because of that, it may be desirable to disable it in normal play. As explained in my page on overbounce, overbounce is really a combination of three bugs. In a normal collision, PM_AirMove
is supposed to kill all velocity normal to the object. When an overbounce occurs, PM_AirMove
manages to move to the ground without colliding with it. The next frame PM_WalkMove
is called with the full pre-collision velocity. This would be fine, but there is a bit of code that prevents changes in velocity when moving up or down ramps. The effect of this is that the velocity is redirected in another direction instead of clipped. The simplest effective solution that I have found is to prevent execution of PM_WalkMove
and call PM_AirMove
in an overbounce situation. The first step is to test if PM_AirMove
collided with an object. This is done by comparing vertical speed before and after PM_ClipVelocity
and PM_StepSlideMove
. If the player didn't collide with anything, then set the overbounce flag. The second step is to check if the flag is set in PM_WalkMove
. If it is set, PM_AirMove
is called instead.
First add another Cvar called g_killoverbounce
.
Next, create another stat constant like when we added double jump.
In PM_WalkMove
, change the line that reads
if ( PM_CheckJump () ) {
to
if ( PM_CheckJump () || ( pm->ps->stats[STAT_OVERBOUNCE] && g_killoverbounce.integer ) ) {
and add
pm->ps->stats[STAT_OVERBOUNCE] = qfalse;
after the PM_AirMove();
that is right below.
Near the end of PM_AirMove
, but before PM_ClipVelocity
, add the line
zspeed = pm->ps->velocity[2];
This will be our speed reference.
After PM_StepSlideMove
:
// Did we collide with the ground? No? Set the overbounce flag.
// zspeed and pm->ps->veloicty[2] are both negative in a fall.
// If they are both positive, the result shouldn't matter.
if ( zspeed < pm->ps->velocity[2] )
pm->ps->stats[STAT_OVERBOUNCE] = qfalse;
else
pm->ps->stats[STAT_OVERBOUNCE] = qtrue;
Other implementations
CPM1 Development Docs
Do you want to know how to properly do air control? Then here are the original docs.
Warning!
These documents were released under the Quake 3 SDK license. That means that these sources cannot be used in mods using GPL source code. Use at your own risk!