Available in the Gaming / Industries Circle
quantum | v2 switch to V1  

Fighter System

Contents

Overview

The FighterSystem is the place where all the aspects governing the gameplay converge. It is therefore the only multi-purpose system in the Fighting Sample and is at the core of the game loop; any gameplay customization will be done in here.

Back To Top
 

Match

The FighterSystem initializes the global match settings in OnInit() by reading from the RuntimeConfig associated with this game instance.

State Assets

All match states are defined by a concrete implementation of MatchStateData asset base class. The states are chained together to create the match flow.

public abstract unsafe partial class MatchStateData{
   public FP timerLength = FP._0;
   public bool ignoreInput;

   public void OnEnter(Frame f){
       f.Global->ignoreInput = ignoreInput;
       f.Global->matchStateTimer = timerLength;
       OnStateEnter(f);
   }

   public virtual void OnStateEnter(Frame f) { }

   public void OnUpdate(Frame f) {
       f.Global->matchStateTimer -= f.DeltaTime;
       if (f.Global->matchStateTimer <= FP._0) {
           OnStateTimerComplete(f);
       } else {
           OnStateUpdate(f);
       }
   }

   public virtual void OnStateTimerComplete(Frame f) { }
   public virtual void OnStateUpdate(Frame f) { }
   public virtual void OnExit(Frame f) { }

   public void GoToState(Frame f, AssetRefMatchStateData nextStateAsset) {
       OnExit(f);
       f.Global->matchState = nextStateAsset;
       var nextState = f.FindAsset<MatchStateData>(nextStateAsset.Id);
       nextState.OnEnter(f);
   }

   public static void GoToStateCall(Frame f, AssetRefMatchStateData nextStateAsset) {
       var state = f.FindAsset<MatchStateData>(f.Global->matchState.Id);
       state.GoToState(f, nextStateAsset);
   }

Back To Top
 

Initialization And Updating

The FighterSystem initializes the first state in OnInit() by using the one found in the RuntimeConfig.startingState field.

In the FighterSystem’s Update(), the current state’s update is run as well unless one of the players is currently frozen.

var matchState = f.FindAsset<MatchStateData>(f.Global->matchState.Id);
var freezeList = f.ResolveList(f.Global->freeze);
if (freezeList[0] <= FP._0 && freezeList[1] <= FP._0){
   matchState.OnUpdate(f);
}

The freezeList is used to handle hitstops; i.e. freeze the game momentarily after a player gets hit to help players recognize the hit has landed. Flow

The Fighting Sample implements the match flow as depicted in the diagram below.

Match State Flow
Default Match State Flow implemented in the Fighting Sample.

The RuntimeConfig knows of the Starting State and the FighterSystem initializes the game using that asset’s reference. All other assets only know about the next available state they can transition to but not which comes before them. Most of the match will be spent in the 4_Match Active State. Upon meeting one of the end conditions (timer has run out or at least one player is KO), the current match ends and moves on to the next state.

  • If there are rounds left to be played in the current match, the 7_Reset state resets the players and starts another round.
  • If the match has played out all of the available rounds or a player has won 2 consecutive times, a new match is started.

Back To Top
 

Keep Characters In Bounds

To ensure the characters stay within the playing area, the FighterSystem clamps them within the minBounds and maxBounds defined in the RuntimeConfig. This is done in the KeepFightersInBounds() method. The method also takes care of character separation to avoid having both models overlap. It performs the clamp check on both characters simultaneously; this is done to handle two edge cases:

  1. a character is moved back into the arena bounds and the model is pushed into the opponent’s character due to their proximity to one another; and
  2. giving precedence to one character over the other would enable the first character to move freely about the arena whilst dragging the second character with them but not vice-versa.

Back To Top
 

Projectiles & Interactions

In the Fighting Sample, projectiles are spawned when players perform a special move which has a QTABCreateProjectile asset associated with that move’s animation state behaviour. UpdateProjectiles() filters out the active projectiles with a HitBox component and updates them using the ProjectileData asset referenced on the Projectile component.

private static void UpdateProjectiles(Frame f){
   var projectiles = f.Filter<Projectile, HitBox, Transform3D>();
   while (projectiles.NextUnsafe(out EntityRef proj, out var p, out var hb, out var t)){
       var projData = f.FindAsset<ProjectileData>(p->data.Id);
       projData.Update(f, proj, p, t, hb);
   }
}

Collectibles, environmental actions and other player interactable elements should be handled in a similar fashion. The crucial part is to update all elements which can impact the hit registration BEFORE the hit related methods are executed.

Back To Top
 

Hits

The FighterSystem implements a custom hit detection / collision check. It does an overlap check between the currently performed attack’s hitboxes and the opponent’s hurtboxes as set up for their current animation state (see the State Behaviour Editor page for more information).

The Hit registration takes place in 3 steps:

  • Hit detection
  • KO check
  • Attack damage application.

Whenever an animation is performed which can result in a hit -e.g. shoot a projectile (QTABCreateProjectile) or launch an attack (ATABAttackState)-, an entity with a HitBox component is created. This is used to perform an overlap for hit detection.

// Extracted from QTABAttackState.cs
var hitBoxEntity = frame.Create();
var hitBoxComponent = new HitBox();

If an overlap is found:

  • the opponent is provided with the data of the attack they just got hit with; and,
  • the entity carrying the hitbox data for the current attack is destroyed to avoid having the same attack dealing damage multiple times after it has already landed.
var hitboxFilter = f.Filter<HitBox>();
while (hitboxFilter.Next(out EntityRef e, out HitBox hb)){
   if (hb.active == false)
       continue;

   if (!f.Unsafe.TryGetPointer(hb.target, out Fighter* targetFighter)) continue;
   var hurtBoxList = f.ResolveList(targetFighter->hurtBoxList);

   for (int h = 0; h < hurtBoxList.Count && targetFighter->hitByAttack == false; h++){
       if (!hb.bounds.Intersects(hurtBoxList[h])) continue;
       if (!f.FindAsset<AttackData>(hb.attackData.Id).CanTestCollision(f, targetFighter)) continue;
      
       targetFighter->hitByAttack = true;
       targetFighter->hitPosition = FP._0_50 * (hb.bounds.Center + hurtBoxList[h].Center);
       targetFighter->hitByAttackData = hb.attackData;
       f.Destroy(e);
       break;
   }
}

To avoid edge cases when players land a throw attack in the same frame each attack is given a priority in AttackData.Priority which is evaluated prior to checking whether the attack landed.

Finally the damage is applied with the TestIfPlayerHitByAttack() method.

private static void TestIfPlayerHitByAttack(Frame f, ref QList<FP> freezeList, ref bool kod, EntityRef pE, Fighter* pF, AttackData p1AtkDataHitBy){
   if (!pF->hitByAttack) return;
  
   var pA = f.Unsafe.GetPointer<CustomAnimator>(pE);
   var pT = f.Unsafe.GetPointer<Transform3D>(pE);
   if (p1AtkDataHitBy.OnHit(ref freezeList, pF->index, f, pE, pF, pA, pT)) {
       kod = true;
   }

   pF->hitByAttack = false;
}

To Document Top