QCF+Punch: Implementing Street Fighter-style Input


Ah, the QCF+Punch (Quarter-circle Forward and Punch). Also known as the “Hadouken” motion or “Fireball” motion.  It’s a standard in fighting games to have a tier of special moves tied to complicated input commands like QCF+Punch.  The extra difficulty in executing the command lends a greater sense of accomplishment that, when paired with a suitably powerful attack outcome, can make a game feel more visceral than if a powerful attack were launched with a single button press.

In my latest game prototype, I decided to implement a simple Street Fighter-like command input system for executing special attacks.  The big trick here is to take a stream of (imperfect) human input, compare it against a list of canonical actions, and match in such a way that a human player will be satisfied that the system is responding accurately to their input.  This is how I put it together.

For the command input system, I start off with a series of commands that I will recognize.

  1. Super Beam (HCF+ATK)
  2. Energy Ball (QCF+ATK)
  3. Spin Attack (QCB+ATK)
  4. Normal Attack (ATK)
The list above contains the names of attacks with their corresponding input strings written in parentheses.  The abbreviations for the attack strings mean the following:
  • ATK = Attack button
  • HCF = Half-circle forward
  • QCF = Quarter-circle forward
  • QCB = Quarter-circle backward
So, “QCF+ATK” would be a quarter-circle forward motion following by an attack button, the canonical “fireball motion”.

I specifically ordered the attacks starting from the most complex inputs going to the simplest.  This is the order in which I will test actions against my input buffer and I want to make sure my more complex actions will be recognized over the simpler actions.  This is especially important for HCF+ATK versus QCF+ATK because the latter is actually a subset of the former.

With a list of actions in hand, the next step is to fill a buffer with input from the player.  Ideally, I would like to have input capture running at a fixed rate independent of my visual framerate.  However, since I’m using the UDK for my prototype, it would be a bit difficult (not to mention wasteful) to implement a custom input capture system.  So, I settled for extending the existing PlayerInput implementation to log input and throw out commands older than some arbitrary age (about 0.5 seconds in my initial implementation.)  This gives me a record of the inputs executed by the player over the last half a second.

The final step in command recognition is to actually match actions against my player’s input buffer.  In a world of perfect player input, I would just have to search the buffer for occurrences of sequences like Down + Down-Right + Right + Attack in order to match QCF+ATK.  In reality, player input has a lot more variance.  While a player may intend to enter Down + Down-Right + Right + Attack in order to launch a QCF+ATK, they are just as likely to enter something like Down + Down + Down-Right + Right + Up-Right + Attack.  To a computer, that sequence looks nothing like a QCF+ATK, but the player is most likely going to be convinced they entered a QCF+ATK, if not perfectly, at least well enough.

In order to give the player a suitable level of forgiveness in action matching, I search across the input buffer for matching inputs while ignoring non-matching inputs.  As long as a subset of the buffer matches the target action’s input string (in the proper order), then I consider the action matched and end my search.  I also clear the input buffer as I know consider it “consumed” for the purpose of recognizing command actions.

With the input Down + Down + Down-Right + Right + Up-Right + Attack as an example, I would check against the “Super Beam” action (HCF+ATK) and fail to match.  Then, I would check against the “Energy Ball” action (QCF+ATK) and match against the buffer since Down + Down-Right + Right + Attack occurs in the buffer once I ignore the extraneous Down and Up-Right.

The end result of this implementation is a fairly convincing input matching system that should satisfy most player’s with its mix of accuracy and forgiveness.  By tuning the length of time to preserve the input buffer, you can control the tightness of the input recognition to match your tastes.

The UnrealScript source code for the input matching system follows:

class BPG_PlayerInput extends PlayerInput;

enum BPG_InputCommand
{
        BIC_Up,
        BIC_UpRight,
        BIC_Right,
        BIC_DownRight,
        BIC_Down,
        BIC_DownLeft,
        BIC_Left,
        BIC_UpLeft,
        BIC_Attack
};

struct BPG_InputCommandEntry
{
        var BPG_InputCommand InputCommand;
        var float AgeSeconds;
};

struct BPG_ActionDescription
{
        var name ActionName;
        var array Inputs;
};

var transient bool LastAttackPressed;
var transient bool AttackPressed;
var transient array CommandBuffer;

var array Actions;
var transient int LastMatchIndex;

const COMMAND_INPUT_MAX_AGE = 0.5f;
const MATCH_REMEMBER_TIME = 1.0f;

function string CommandToString(BPG_InputCommand inputCommand)
{
        switch( inputCommand )
        {
        case BIC_Up:
                return "U_";
                break;
        case BIC_UpRight:
                return "UR";
                break;
        case BIC_Right:
                return "R_";
                break;
        case BIC_DownRight:
                return "DR";
                break;
        case BIC_Down:
                return "D_";
                break;
        case BIC_DownLeft:
                return "DL";
                break;
        case BIC_Left:
                return "L_";
                break;
        case BIC_UpLeft:
                return "UL";
                break;
        case BIC_Attack:
                return "AT";
                break;
        }
        return "";
}

function string GetBestMatchAsString()
{
        if( LastMatchIndex == -1 )
                return "NO MATCH";

        return string(Actions[LastMatchIndex].ActionName);
}

function string GetActionsAsString()
{
        local String actionString;
        local int index, index2;

        for(index = 0; index < Actions.Length; ++index)
        {
                actionString $= Actions[index].ActionName;
                actionString $= "(";
                for(index2 = 0; index2 < Actions[index].Inputs.Length; ++index2)                 {                         if( index2 > 0 )
                        {
                                actionString $= ", ";
                        }
                        actionString $= CommandToString( Actions[index].Inputs[index2] );
                }
                actionString $= ")\n";
        }

        return actionString;
}

function string GetCommandBufferAsString()
{
        local String commandBufferString;
        local int i;

        for( i = 0; i < CommandBuffer.Length; ++i )
        {
                commandBufferString $= " " $ CommandToString( CommandBuffer[i].InputCommand );
        }

        return commandBufferString;
}

function AddCommand(BPG_InputCommand inputCommand)
{
        local BPG_InputCommandEntry commandEntry;
        commandEntry.InputCommand = inputCommand;
        commandEntry.AgeSeconds = 0.0f;
        CommandBuffer.AddItem(commandEntry);
}

//Hooked to action in DefaultInput.ini
exec function AttackStart()
{
        AttackPressed = true;
}

//Hooked to action onrelease in DefaultInput.ini
exec function AttackStop()
{
        AttackPressed = false;
}

// Postprocess the player's input.
event PlayerInput( float DeltaTime )
{
        local int index;
        local array indexesToRemove;

        super.PlayerInput(DeltaTime);

        for( index = 0; index < CommandBuffer.Length; ++index)         {                 CommandBuffer[index].AgeSeconds += DeltaTime;                 if( CommandBuffer[index].AgeSeconds >= COMMAND_INPUT_MAX_AGE )
                {
                        indexesToRemove.AddItem(index);
                }
        }

        for( index = 0; index < indexesToRemove.Length; ++index)         {                 CommandBuffer.Remove(indexesToRemove[index], 1);         }         if( aBaseY > 0.0f )
        {
                if( aStrafe > 0.0f )
                {
                        AddCommand(BIC_UpRight);
                }
                else if( aStrafe < 0.0f )
                {
                        AddCommand(BIC_UpLeft);
                }
                else            {
                        AddCommand(BIC_Up);
                }
        }
        else if( aBaseY < 0.0f )         {                 if( aStrafe > 0.0f )
                {
                        AddCommand(BIC_DownRight);
                }
                else if( aStrafe < 0.0f )                 {                         AddCommand(BIC_DownLeft);                 }                 else            {                         AddCommand(BIC_Down);                 }         }         else         {                 if( aStrafe > 0.0f )
                {
                        AddCommand(BIC_Right);
                }
                else if( aStrafe < 0.0f )
                {
                        AddCommand(BIC_Left);
                }
        }

        if( AttackPressed && !LastAttackPressed)
        {
                AddCommand(BIC_Attack);
        }
        LastAttackPressed = AttackPressed;

        FindBestActionMatch();
}

function FindBestActionMatch()
{
        local int actionIndex;
        local int inputIndex;
        local int commandBufferIndex;
        local int matchedInputCount;

        //For each action (ordered by priority)
        for(actionIndex = 0; actionIndex < Actions.Length; ++actionIndex)         {                 commandBufferIndex = CommandBuffer.Length - 1;                 matchedInputCount = 0;                 //Walk backwards through input on list and find in command buffer                 for( inputIndex = Actions[actionIndex].Inputs.Length - 1; inputIndex >= 0; inputIndex-- )
                {
                        //Consume commandBuffer until we find our target input (or run out of buffer)
                        while( commandBufferIndex >= 0 )
                        {
                                if( Actions[actionIndex].Inputs[inputIndex] == CommandBuffer[commandBufferIndex].InputCommand )
                                {
                                        matchedInputCount++;
                                        break;  
                                }
                                commandBufferIndex--;
                        }
                }

                //If we matched all inputs in an action, record that action and stop searching
                //Also clear the command buffer since it's all processed
                if( matchedInputCount == Actions[actionIndex].Inputs.Length )
                {
                        LastMatchIndex = actionIndex;
                        SecondsSinceLastMatch = 0.0f;
                        CommandBuffer.Length = 0;
                        break;
                }
        }
}

defaultproperties
{
        Actions(0)=(ActionName="EX1", Inputs=(BIC_Left, BIC_DownLeft, BIC_Down, BIC_DownRight, BIC_Right, BIC_Attack))
        Actions(1)=(ActionName="SP2", Inputs=(BIC_Down, BIC_DownRight, BIC_Right, BIC_Attack))
        Actions(2)=(ActionName="SP1", Inputs=(BIC_Down, BIC_DownLeft, BIC_Left, BIC_Attack))
        Actions(3)=(ActionName="ATK", Inputs=(BIC_Attack))

        LastMatchIndex=-1

        LastAttackPressed=false
        AttackPressed=false
}
Share this Article:
  • Digg
  • StumbleUpon
  • del.icio.us
  • Facebook
  • Yahoo! Buzz
  • Twitter
  • Google Bookmarks
  • Print