Fighter System
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.
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.
C#
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);
}
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.
C#
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.
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.
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:
- 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
- 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.
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.
C#
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.
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.
C#
// 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.
C#
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.
C#
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;
}