Arena/ArenaFight.cs
using System;
/// <summary>Intro = the VS screen before the first turn.</summary>
public enum FightState { Waiting, Intro, Fighting, Over }
/// <summary>Duel: slave fights in the dungeon, only armour gets hurt and whoever's armour breaks loses.
/// Arena: the real thing, health and armour, to the death.</summary>
public enum FightMode { Duel, Arena }
public enum FightAction
{
MoveBack, MoveForward, Leap, Rest, QuickAttack, NormalAttack, PowerAttack, Taunt, Push,
CastFireball, CastLightning, CastFreeze, CastHeal, CastDrain, CastTremor,
DrinkHealth, DrinkEnergy, DrinkMagicka
}
public sealed class ArenaFight : Component
{
[Property] public float StepWidth { get; set; } = 250f;
[Property] public int ArenaSteps { get; set; } = 10;
[Property] public float TurnDelay { get; set; } = 1.1f;
[Sync( SyncFlags.FromHost )] public Gladiator Left { get; set; }
[Sync( SyncFlags.FromHost )] public Gladiator Right { get; set; }
[Sync( SyncFlags.FromHost )] public FightState State { get; set; }
[Sync( SyncFlags.FromHost )] public FightMode Mode { get; set; }
[Sync( SyncFlags.FromHost )] public bool LeftTurn { get; set; }
[Sync( SyncFlags.FromHost )] public bool Busy { get; set; }
[Sync( SyncFlags.FromHost )] public TimeUntil NextTurnAt { get; set; }
[Sync( SyncFlags.FromHost )] public string LastMessage { get; set; }
[Sync( SyncFlags.FromHost )] public Gladiator Winner { get; set; }
[Sync( SyncFlags.FromHost )] public int Crowd { get; set; }
/// <summary>Turns taken so far (a long duel costs less gold when you lose it).</summary>
[Sync( SyncFlags.FromHost )] public int Turns { get; set; }
public bool IsDuel => Mode == FightMode.Duel;
public Gladiator Current => LeftTurn ? Left : Right;
public int Distance => Left.IsValid() && Right.IsValid() ? Math.Abs( Left.Step - Right.Step ) : 0;
public bool InRange => Distance <= 1;
public bool WaitingForInput => State == FightState.Fighting && !Busy && NextTurnAt;
public string CrowdMood => Crowd switch
{
< 15 => "bored to tears",
< 35 => "restless",
< 60 => "watching closely",
< 85 => "cheering",
_ => "going wild"
};
public Gladiator OpponentOf( Gladiator g ) => g == Left ? Right : Left;
public float StepToX( int step ) => (step - ArenaSteps / 2f) * StepWidth;
public bool IsTurnOf( Gladiator g ) => g.IsValid() && g == Current && WaitingForInput;
public int ForwardDir( Gladiator g ) => g == Left ? 1 : -1;
// ---------------- setup ----------------
public void Clear()
{
Left?.GameObject.Destroy();
Right?.GameObject.Destroy();
Left = null;
Right = null;
State = FightState.Waiting;
}
public void StartFight( Profile player, FightMode mode )
{
if ( !Networking.IsHost ) return;
Clear();
Mode = mode;
Left = Spawn( player.Name, false, Connection.Local.Id, mode == FightMode.Duel ? 3 : 2 );
Left.Level = player.Level;
Left.Title = player.Enslaved ? "Slave" : $"Level {player.Level} gladiator";
Left.Strength = player.Strength; Left.Attack = player.Attack; Left.Defence = player.Defence; Left.Vitality = player.Vitality;
Left.Agility = player.Agility; Left.Stamina = player.Stamina; Left.Charisma = player.Charisma; Left.Magicka = player.Magicka;
Left.SetLook( player.Look );
var ench = player.EnchantFor( player.Weapon );
Left.Enchant = ench?.Type ?? Enchant.None;
Left.EnchantTier = ench?.Tier ?? 0;
Left.SpellBits = player.Spells.Aggregate( 0, ( bits, s ) => bits | (1 << s) );
Left.HealthPotions = player.Potions[0];
Left.EnergyPotions = player.Potions[1];
Left.MagicPotions = player.Potions[2];
Left.ResetVitals();
Right = Spawn( "", true, Guid.Empty, mode == FightMode.Duel ? ArenaSteps - 3 : ArenaSteps - 2 );
EnemyMaker.Build( Right, player, mode );
Right.ResetVitals();
Left.RollPower();
Right.RollPower();
LeftTurn = Left.Agility >= Right.Agility;
Winner = null;
Busy = false;
Turns = 0;
Crowd = 15;
LastMessage = $"{Left.DisplayName} vs {Right.DisplayName}";
State = FightState.Intro;
}
/// <summary>The tick on the VS screen.</summary>
[Rpc.Host]
public void BeginFight()
{
if ( State != FightState.Intro ) return;
NextTurnAt = 0.8f;
LastMessage = IsDuel ? "Break their armour to win the duel!" : $"{Left.DisplayName} vs {Right.DisplayName}. Fight!";
State = FightState.Fighting;
}
Gladiator Spawn( string name, bool ai, Guid controller, int step )
{
var go = new GameObject( true, string.IsNullOrEmpty( name ) ? "Enemy" : name );
var g = go.AddComponent<Gladiator>();
g.DisplayName = name;
g.IsAI = ai;
g.ControllerId = controller;
g.Step = step;
return g;
}
// ---------------- turn loop ----------------
protected override void OnUpdate()
{
if ( !Networking.IsHost || State != FightState.Fighting || !NextTurnAt )
return;
if ( Busy )
{
FinishTurn();
return;
}
if ( Current.Frozen > 0 )
{
Current.Frozen--;
Current.ShowPopup( "FROZEN", PopupKind.Text, "#7fd0ff" );
LastMessage = $"{Current.DisplayName} is frozen solid and can't move!";
Busy = true;
NextTurnAt = TurnDelay;
return;
}
if ( Current.Energy <= 0 )
{
Exhausted( Current );
return;
}
if ( Current.IsAI )
Perform( Current, FightAI.Choose( this, Current ) );
}
[Rpc.Host]
public void RequestAction( FightAction action )
{
var g = Current;
if ( !WaitingForInput || !g.IsValid() || g.IsAI || g.Energy <= 0 || g.ControllerId != Rpc.CallerId ) return;
if ( !CanUse( g, action ) ) return;
Perform( g, action );
}
// ---------------- rules ----------------
public static int Cost( FightAction a ) => a switch
{
FightAction.QuickAttack => 8,
FightAction.NormalAttack => 14,
FightAction.PowerAttack => 24,
FightAction.Leap => 18,
FightAction.Taunt => 10,
FightAction.Push => 8,
_ => 0
};
public static bool IsAttack( FightAction a ) => a is FightAction.QuickAttack or FightAction.NormalAttack or FightAction.PowerAttack;
public static bool IsSpell( FightAction a ) => a >= FightAction.CastFireball && a <= FightAction.CastTremor;
public static bool IsPotion( FightAction a ) => a >= FightAction.DrinkHealth;
public static Spell SpellOf( FightAction a ) => (Spell)(a - FightAction.CastFireball);
public static FightAction CastOf( Spell s ) => FightAction.CastFireball + (int)s;
public static Potion PotionOf( FightAction a ) => (Potion)(a - FightAction.DrinkHealth);
/// <summary>Doing something you can't afford still works, it just works worse.</summary>
public static float EnergyFactor( Gladiator g, FightAction a )
{
int cost = Cost( a );
if ( cost <= 0 || g.Energy >= cost ) return 1f;
return 0.35f + 0.65f * g.Energy / cost;
}
public bool PushRoom( Gladiator target )
{
int next = target.Step + ForwardDir( OpponentOf( target ) );
return next >= 0 && next <= ArenaSteps;
}
public bool CanUse( Gladiator g, FightAction a )
{
if ( !g.IsValid() ) return false;
if ( IsSpell( a ) )
{
var s = SpellOf( a );
return g.Knows( s ) && g.Magic >= Catalog.SpellInfo( s ).Cost;
}
if ( IsPotion( a ) ) return g.Potions( PotionOf( a ) ) > 0;
return a switch
{
FightAction.MoveForward => Distance > 1,
FightAction.Leap => Distance > 2 && g.Energy >= Cost( a ),
FightAction.MoveBack => g.Step - ForwardDir( g ) >= 0 && g.Step - ForwardDir( g ) <= ArenaSteps,
FightAction.QuickAttack or FightAction.NormalAttack or FightAction.PowerAttack => InRange,
FightAction.Push => InRange,
_ => true
};
}
public float HitChance( Gladiator attacker, Gladiator defender, FightAction a )
{
float chance = a switch
{
FightAction.QuickAttack => 0.9f,
FightAction.NormalAttack => 0.5f,
FightAction.PowerAttack => attacker.PowerRoll,
_ => 0f
};
chance += (attacker.Attack - defender.Agility) * 0.02f;
chance *= EnergyFactor( attacker, a );
return Math.Clamp( chance, 0.05f, 0.98f );
}
public float TauntChance( Gladiator taunter, Gladiator target )
{
float c = 0.5f + (taunter.Charisma - target.Charisma) * 0.05f;
return Math.Clamp( c * EnergyFactor( taunter, FightAction.Taunt ), 0.05f, 0.92f );
}
public float PushChance( Gladiator pusher ) => EnergyFactor( pusher, FightAction.Push );
public float FreezeChance( Gladiator caster, Gladiator target ) => Math.Clamp( 0.7f + (caster.Magicka - target.Magicka) * 0.03f, 0.25f, 0.95f );
public static float DamageMult( FightAction a ) => a switch
{
FightAction.QuickAttack => 0.6f,
FightAction.PowerAttack => 2f,
_ => 1f
};
public static (int Min, int Max) DamageRange( Gladiator attacker, Gladiator defender, FightAction a )
{
var w = attacker.Weapon;
float mult = DamageMult( a ) * 100f / (100f + defender.Defence * 3f);
int min = Math.Max( 1, (int)((w.Min + attacker.Strength * 2) * mult) );
int max = Math.Max( 1, (int)((w.Max + attacker.Strength * 2) * mult) );
return (min, max);
}
/// <summary>What a spell does, for its power (damage, healing, armour smashed).</summary>
public static (int Min, int Max) SpellRange( Gladiator caster, Spell s ) => s switch
{
Spell.Fireball => (4 + caster.Magicka * 2, 8 + caster.Magicka * 3),
Spell.Lightning => (10 + caster.Magicka * 3, 18 + caster.Magicka * 5),
Spell.Heal => (10 + caster.Magicka * 3, 14 + caster.Magicka * 4),
Spell.Drain => (3 + caster.Magicka * 2, 6 + caster.Magicka * 2),
Spell.Tremor => (8 + caster.Magicka * 2, 12 + caster.Magicka * 3),
_ => (0, 0)
};
void AddCrowd( int amount ) => Crowd = Math.Clamp( Crowd + amount, 0, 100 );
/// <summary>
/// Arena: armour soaks damage first, then health takes the rest.
/// Duel: only armour gets hurt; once it's gone (or if there was none) that's the end of the duel.
/// </summary>
void ApplyDamage( Gladiator target, int dmg )
{
if ( IsDuel )
{
target.ArmourPoints = Math.Max( 0, target.ArmourPoints - dmg );
if ( target.ArmourPoints <= 0 ) target.Broken = true;
return;
}
int soaked = Math.Min( target.ArmourPoints, dmg );
target.ArmourPoints -= soaked;
target.Health = Math.Max( 0, target.Health - (dmg - soaked) );
}
/// <summary>Magic goes past armour in the arena; in a duel it still only breaks armour.</summary>
void ApplyMagic( Gladiator target, int dmg )
{
if ( IsDuel ) ApplyDamage( target, dmg );
else target.Health = Math.Max( 0, target.Health - dmg );
}
/// <summary>Healing: health in the arena, armour in a duel.</summary>
int Mend( Gladiator g, int amount )
{
if ( IsDuel )
{
int got = Math.Min( amount, g.MaxArmour - g.ArmourPoints );
g.ArmourPoints += got;
return got;
}
int heal = Math.Min( amount, g.MaxHealth - g.Health );
g.Health += heal;
return heal;
}
void Perform( Gladiator g, FightAction a )
{
var opp = OpponentOf( g );
g.Energy = Math.Max( 0, g.Energy - Cost( a ) );
Turns++;
switch ( a )
{
case FightAction.MoveForward:
{
int steps = Math.Min( 1 + g.Agility / 6, Distance - 1 );
g.Step += ForwardDir( g ) * steps;
LastMessage = $"{g.DisplayName} walks forward.";
AddCrowd( -1 );
break;
}
case FightAction.MoveBack:
{
int steps = 1 + g.Agility / 8;
g.Step = Math.Clamp( g.Step - ForwardDir( g ) * steps, 0, ArenaSteps );
LastMessage = $"{g.DisplayName} walks backward.";
AddCrowd( -3 );
break;
}
case FightAction.Leap:
{
int steps = Math.Min( 3 + g.Agility / 5, Distance - 1 );
g.Step += ForwardDir( g ) * steps;
g.PlayLeap();
g.SetMood( Mood.Grin, 1.5f );
LastMessage = $"{g.DisplayName} leaps through the air!";
AddCrowd( 7 );
break;
}
case FightAction.Rest:
{
int energy = Math.Min( g.MaxEnergy - g.Energy, 12 + g.Stamina * 3 );
g.Energy += energy;
int health = 0;
if ( !IsDuel )
{
health = Math.Min( g.MaxHealth - g.Health, g.Vitality );
g.Health += health;
}
g.PlayAnim( "rest" );
g.ShowPopup( $"+{energy} energy", PopupKind.Gain, null );
LastMessage = $"{g.DisplayName} catches their breath.";
AddCrowd( -5 );
break;
}
case FightAction.QuickAttack:
case FightAction.NormalAttack:
case FightAction.PowerAttack:
Swing( g, opp, a );
break;
case FightAction.Taunt:
{
g.PlayAnim( "taunt" );
if ( Random.Shared.Float() <= TauntChance( g, opp ) )
{
int drain = 6 + g.Charisma * 2;
opp.Energy = Math.Max( 0, opp.Energy - drain );
if ( !IsDuel ) opp.Health = Math.Max( 0, opp.Health - Math.Max( 1, g.Charisma / 2 ) );
opp.ShowPopup( $"-{drain} energy", PopupKind.Text, null );
opp.SetMood( Mood.Upset, 2f );
g.SetMood( Mood.Grin, 2f );
LastMessage = $"{g.DisplayName}'s taunt gets to {opp.DisplayName}!";
AddCrowd( 10 + g.Charisma / 3 );
}
else
{
g.ShowPopup( "IGNORED", PopupKind.Miss, null );
g.SetMood( Mood.Upset, 1.5f );
opp.SetMood( Mood.Happy, 1.5f );
LastMessage = $"{opp.DisplayName} ignores {g.DisplayName}'s taunt.";
AddCrowd( -2 );
}
break;
}
case FightAction.Push:
{
g.PlayAnim( "push" );
if ( PushRoom( opp ) && Random.Shared.Float() <= PushChance( g ) )
{
opp.Step += ForwardDir( g );
opp.PlayAnim( "hit" );
opp.ShowPopup( "PUSHED", PopupKind.Text, null );
g.SetMood( Mood.Grin, 1.2f );
LastMessage = $"{g.DisplayName} shoves {opp.DisplayName} back.";
AddCrowd( 3 );
}
else
{
opp.ShowPopup( "HOLDS", PopupKind.Miss, null );
g.SetMood( Mood.Upset, 1.2f );
LastMessage = PushRoom( opp ) ? $"{opp.DisplayName} stands firm." : $"{opp.DisplayName} has nowhere to go!";
}
break;
}
case >= FightAction.CastFireball and <= FightAction.CastTremor:
Cast( g, opp, SpellOf( a ) );
break;
default:
Drink( g, PotionOf( a ) );
break;
}
if ( opp.IsDead )
{
g.SetMood( Mood.Grin, 30f );
AddCrowd( 20 );
}
Busy = true;
NextTurnAt = a == FightAction.Leap ? MathF.Max( TurnDelay, Gladiator.LeapTime + 0.4f ) : a == FightAction.Rest ? 1.4f : TurnDelay;
// the free swing your opponent gets is the point of running out of energy
if ( g.Energy <= 0 && !opp.IsDead )
LastMessage += $" {g.DisplayName} is out of energy!";
}
void Swing( Gladiator g, Gladiator opp, FightAction a )
{
g.PlayAnim( a == FightAction.QuickAttack ? "quick" : a == FightAction.NormalAttack ? "normal" : "power" );
if ( Random.Shared.Float() > HitChance( g, opp, a ) )
{
opp.ShowPopup( "MISS", PopupKind.Miss, null );
opp.SetMood( Mood.Happy, 1.5f );
g.SetMood( Mood.Upset, 1.8f );
LastMessage = $"{g.DisplayName} misses!";
AddCrowd( -3 );
return;
}
var (min, max) = DamageRange( g, opp, a );
int dmg = Random.Shared.Int( min, max );
bool hadArmour = opp.ArmourPoints > 0;
ApplyDamage( opp, dmg );
opp.PlayAnim( "hit" );
opp.ShowPopup( dmg.ToString(), PopupKind.Damage, null );
opp.SetMood( Mood.Hurt, 1.2f );
g.SetMood( a == FightAction.PowerAttack ? Mood.Grin : Mood.Happy, 1.8f );
string msg = IsDuel ? $"{g.DisplayName} hits {opp.DisplayName}'s armour for {dmg}" : $"{g.DisplayName} hits {opp.DisplayName} for {dmg}";
if ( !IsDuel && g.Enchant != Enchant.None && g.EnchantTier > 0 )
{
var (emin, emax) = Catalog.EnchantRange( g.EnchantTier );
int extra = Random.Shared.Int( emin, emax );
opp.Health = Math.Max( 0, opp.Health - extra );
opp.ShowPopup( $"+{extra} {g.Enchant.ToString().ToLower()}", PopupKind.Enchant, Catalog.EnchantColour( g.Enchant ) );
msg += $" (+{extra} {g.Enchant.ToString().ToLower()})";
}
if ( IsDuel && opp.Broken )
msg = hadArmour ? $"{g.DisplayName} smashes {opp.DisplayName}'s armour to pieces" : $"{g.DisplayName} lands a clean hit on the unarmoured {opp.DisplayName}";
LastMessage = msg + "!";
AddCrowd( a == FightAction.PowerAttack ? 12 : a == FightAction.NormalAttack ? 6 : 3 );
}
void Cast( Gladiator g, Gladiator opp, Spell s )
{
var info = Catalog.SpellInfo( s );
g.Magic = Math.Max( 0, g.Magic - info.Cost );
g.PlayAnim( "cast" );
var (min, max) = SpellRange( g, s );
int amount = Random.Shared.Int( min, max );
switch ( s )
{
case Spell.Fireball:
case Spell.Lightning:
ApplyMagic( opp, amount );
opp.PlayAnim( "hit" );
opp.ShowPopup( amount.ToString(), PopupKind.Damage, null );
opp.SetMood( Mood.Hurt, 1.5f );
g.SetMood( Mood.Grin, 1.8f );
LastMessage = $"{g.DisplayName} casts {info.Name}! {amount} damage!";
AddCrowd( s == Spell.Lightning ? 15 : 8 );
break;
case Spell.Freeze:
if ( Random.Shared.Float() <= FreezeChance( g, opp ) )
{
opp.Frozen = 1;
opp.ShowPopup( "FROZEN", PopupKind.Text, "#7fd0ff" );
opp.SetMood( Mood.Upset, 2f );
LastMessage = $"{g.DisplayName} freezes {opp.DisplayName} solid!";
AddCrowd( 8 );
}
else
{
opp.ShowPopup( "RESISTED", PopupKind.Miss, null );
LastMessage = $"{opp.DisplayName} shakes off the freeze spell.";
}
break;
case Spell.Heal:
{
int got = Mend( g, amount );
g.ShowPopup( IsDuel ? $"+{got} armour" : $"+{got} health", PopupKind.Gain, "#7fff7f" );
g.SetMood( Mood.Happy, 1.5f );
LastMessage = IsDuel ? $"{g.DisplayName} magically mends their armour." : $"{g.DisplayName} heals their wounds.";
AddCrowd( -2 );
break;
}
case Spell.Drain:
{
ApplyMagic( opp, amount );
int got = Mend( g, amount );
opp.PlayAnim( "hit" );
opp.ShowPopup( amount.ToString(), PopupKind.Damage, null );
g.ShowPopup( $"+{got}", PopupKind.Gain, "#7fff7f" );
LastMessage = $"{g.DisplayName} drains the life out of {opp.DisplayName}!";
AddCrowd( 8 );
break;
}
case Spell.Tremor:
{
// smashes armour; in the arena whatever's left over hits health
int smashed = Math.Min( opp.ArmourPoints, amount * 2 );
if ( IsDuel ) ApplyDamage( opp, amount * 2 );
else opp.ArmourPoints -= smashed;
opp.PlayAnim( "hit" );
opp.ShowPopup( $"-{amount * 2} armour", PopupKind.Text, "#e8f060" );
LastMessage = $"The ground shakes! {opp.DisplayName}'s armour cracks!";
AddCrowd( 10 );
break;
}
}
}
void Drink( Gladiator g, Potion p )
{
g.UsePotion( p );
g.PlayAnim( "rest" );
switch ( p )
{
case Potion.Health:
{
int got = Mend( g, IsDuel ? (int)(g.MaxArmour * 0.35f) : (int)(g.MaxHealth * 0.35f) );
g.ShowPopup( $"+{got}", PopupKind.Gain, "#ff6a6a" );
break;
}
case Potion.Energy:
{
int got = Math.Min( 40, g.MaxEnergy - g.Energy );
g.Energy += got;
g.ShowPopup( $"+{got} energy", PopupKind.Gain, null );
break;
}
default:
{
int got = Math.Min( 30, g.MaxMagic - g.Magic );
g.Magic += got;
g.ShowPopup( $"+{got} magicka", PopupKind.Gain, "#b07fff" );
break;
}
}
LastMessage = $"{g.DisplayName} drinks a {Catalog.PotionName( p ).ToLower()}.";
}
void Exhausted( Gladiator g )
{
int energy = Math.Min( g.MaxEnergy, 12 + g.Stamina * 3 );
g.Energy = energy;
g.PlayAnim( "rest" );
g.ShowPopup( "EXHAUSTED", PopupKind.Text, null );
g.SetMood( Mood.Upset, 2f );
OpponentOf( g ).SetMood( Mood.Grin, 2f );
LastMessage = $"{g.DisplayName} is exhausted and must rest!";
AddCrowd( -5 );
Busy = true;
NextTurnAt = 1.4f;
}
void FinishTurn()
{
Busy = false;
var loser = Left.IsDead ? Left : Right.IsDead ? Right : null;
if ( loser is not null )
{
Winner = OpponentOf( loser );
State = FightState.Over;
LastMessage = IsDuel ? $"{Winner.DisplayName} wins the duel!" : $"{Winner.DisplayName} is victorious!";
return;
}
LeftTurn = !LeftTurn;
Current.RollPower();
}
}