feat: implement Wildfire spell
This commit is contained in:
parent
a969ac0d45
commit
57774b1f38
3 changed files with 575 additions and 1 deletions
|
|
@ -0,0 +1,285 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using Server.Items;
|
||||
using Server.Mobiles;
|
||||
using Server.Spells;
|
||||
using Server.Spells.First;
|
||||
using Server.Spells.Spellweaving;
|
||||
using Xunit;
|
||||
|
||||
namespace Server.Tests.Spells.Spellweaving;
|
||||
|
||||
[Collection("Sequential UOContent Tests")]
|
||||
public class WildfireSpellTests
|
||||
{
|
||||
[Fact]
|
||||
public void SpellMetadata_MatchesWildfireSources()
|
||||
{
|
||||
var caster = NewCaster();
|
||||
var spell = new WildfireSpell(caster);
|
||||
|
||||
Assert.Equal("Wildfire", spell.Name);
|
||||
Assert.Equal("Haelyn", spell.Mantra);
|
||||
Assert.Equal(TimeSpan.FromSeconds(2.5), spell.CastDelayBase);
|
||||
Assert.Equal(66.0, spell.RequiredSkill);
|
||||
Assert.Equal(50, spell.GetMana());
|
||||
Assert.Equal(SkillName.Spellweaving, spell.CastSkill);
|
||||
Assert.Equal(SkillName.Spellweaving, spell.DamageSkill);
|
||||
Assert.False(spell.ClearHandsOnCast);
|
||||
|
||||
caster.Delete();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpellweavingBookAndScroll_UseWildfireSlot()
|
||||
{
|
||||
var book = new SpellweavingBook(1UL << (609 - 600));
|
||||
var scroll = new WildfireScroll();
|
||||
|
||||
Assert.Equal(600, book.BookOffset);
|
||||
Assert.Equal(16, book.BookCount);
|
||||
Assert.True(book.HasSpell(609));
|
||||
Assert.Equal(609, GetSpellScrollId(scroll));
|
||||
|
||||
book.Delete();
|
||||
scroll.Delete();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegisterSpellweaving_PreMlDoesNotExposeWildfire()
|
||||
{
|
||||
var previousExpansion = Core.Expansion;
|
||||
var caster = NewCaster();
|
||||
|
||||
try
|
||||
{
|
||||
ResetSpellRegistry();
|
||||
Core.Expansion = Expansion.SE;
|
||||
Initializer.Configure();
|
||||
|
||||
Assert.Null(SpellRegistry.NewSpell(609, caster, null));
|
||||
Assert.Equal(-1, SpellRegistry.GetRegistryNumber(typeof(WildfireSpell)));
|
||||
Assert.IsType<MagicArrowSpell>(SpellRegistry.NewSpell(4, caster, null));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Core.Expansion = previousExpansion;
|
||||
ResetSpellRegistry();
|
||||
Initializer.Configure();
|
||||
caster.Delete();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegisterSpellweaving_MlExposesWildfireAtSpellId609()
|
||||
{
|
||||
var previousExpansion = Core.Expansion;
|
||||
var caster = NewCaster();
|
||||
|
||||
try
|
||||
{
|
||||
ResetSpellRegistry();
|
||||
Core.Expansion = Expansion.ML;
|
||||
Initializer.Configure();
|
||||
|
||||
Assert.Same(typeof(WildfireSpell), SpellRegistry.Types[609]);
|
||||
Assert.Equal(609, SpellRegistry.GetRegistryNumber(typeof(WildfireSpell)));
|
||||
Assert.IsType<WildfireSpell>(SpellRegistry.NewSpell(609, caster, null));
|
||||
Assert.IsType<EssenceOfWindSpell>(SpellRegistry.NewSpell(610, caster, null));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Core.Expansion = previousExpansion;
|
||||
ResetSpellRegistry();
|
||||
Initializer.Configure();
|
||||
caster.Delete();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckCast_UsesSkillAndManaRequirements()
|
||||
{
|
||||
var previousExpansion = Core.Expansion;
|
||||
var caster = NewCaster(65.9);
|
||||
|
||||
try
|
||||
{
|
||||
Core.Expansion = Expansion.ML;
|
||||
var spell = new WildfireSpell(caster);
|
||||
|
||||
Assert.False(spell.CheckCast());
|
||||
|
||||
caster.Skills.Spellweaving.Base = 66.0;
|
||||
caster.Mana = 0;
|
||||
Assert.False(spell.CheckCast());
|
||||
|
||||
caster.Mana = caster.ManaMax;
|
||||
Assert.True(spell.CheckCast());
|
||||
}
|
||||
finally
|
||||
{
|
||||
Core.Expansion = previousExpansion;
|
||||
caster.Delete();
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0.0, 0, 10, 1, 5)]
|
||||
[InlineData(66.0, 0, 12, 2, 5)]
|
||||
[InlineData(120.0, 0, 15, 5, 5)]
|
||||
[InlineData(66.0, 3, 15, 5, 8)]
|
||||
public void FocusAndSkillScaleBaseDamageDurationAndRadius(
|
||||
double skill,
|
||||
int focus,
|
||||
int expectedDamage,
|
||||
int expectedDurationSeconds,
|
||||
int expectedRadius
|
||||
)
|
||||
{
|
||||
Assert.Equal(expectedDamage, WildfireSpell.GetBaseDamage(skill, focus));
|
||||
Assert.Equal(TimeSpan.FromSeconds(expectedDurationSeconds), WildfireSpell.GetDuration(skill, focus));
|
||||
Assert.Equal(expectedRadius, WildfireSpell.GetRadius(focus));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(15, 1, 15)]
|
||||
[InlineData(15, 2, 7)]
|
||||
[InlineData(15, 3, 5)]
|
||||
[InlineData(11, 3, 5)]
|
||||
public void DamageSplitsAcrossMultipleTargets(int baseDamage, int targetCount, int expectedDamage)
|
||||
{
|
||||
Assert.Equal(expectedDamage, WildfireSpell.GetDamageForTargetCount(baseDamage, targetCount));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpellDamageIncreaseIsAppliedAfterSplitAndCappedInPlayerCombat()
|
||||
{
|
||||
Assert.Equal(11, WildfireSpell.GetDamageAfterSdi(10, 100, true));
|
||||
Assert.Equal(20, WildfireSpell.GetDamageAfterSdi(10, 100, false));
|
||||
Assert.Equal(8, WildfireSpell.GetDamageAfterSdi(7, 15, true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TickDamagesVisibleHostileTargetsButNotHiddenTargets()
|
||||
{
|
||||
var caster = NewCaster();
|
||||
var target = new TestOrc();
|
||||
var location = new Point3D(100, 100, 0);
|
||||
var spell = new WildfireSpell(caster);
|
||||
|
||||
try
|
||||
{
|
||||
caster.MoveToWorld(location, Map.Felucca);
|
||||
target.MoveToWorld(new Point3D(101, 100, 0), Map.Felucca);
|
||||
target.Hits = target.HitsMax;
|
||||
|
||||
WildfireSpell.ClearAllForTests();
|
||||
spell.StartTimerForTests(location, Map.Felucca, 5, 10, TimeSpan.FromSeconds(1));
|
||||
var visibleHits = target.Hits;
|
||||
spell.TickForTests();
|
||||
|
||||
Assert.True(target.Hits < visibleHits);
|
||||
|
||||
WildfireSpell.ClearAllForTests();
|
||||
target.Hits = visibleHits;
|
||||
target.Hidden = true;
|
||||
spell.TickForTests();
|
||||
|
||||
Assert.Equal(visibleHits, target.Hits);
|
||||
}
|
||||
finally
|
||||
{
|
||||
WildfireSpell.ClearAllForTests();
|
||||
DeleteFireItems(target.Location);
|
||||
target.Delete();
|
||||
caster.Delete();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FireItemsStopTheirTimerOnDeletionAndDoNotSurviveDeserialization()
|
||||
{
|
||||
var item = new WildfireFireItem(new Point3D(100, 100, 0), Map.Felucca, TimeSpan.FromMinutes(1));
|
||||
var timerField = typeof(WildfireFireItem).GetField("_timer", BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
var timer = timerField!.GetValue(item);
|
||||
|
||||
item.Delete();
|
||||
|
||||
Assert.Null(timerField.GetValue(item));
|
||||
Assert.NotNull(timer);
|
||||
|
||||
var deserializedItem = new WildfireFireItem(new Point3D(100, 100, 0), Map.Felucca, TimeSpan.FromMinutes(1));
|
||||
var afterDeserialization = typeof(WildfireFireItem).GetMethod(
|
||||
"AfterDeserialization",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic
|
||||
);
|
||||
|
||||
afterDeserialization!.Invoke(deserializedItem, null);
|
||||
|
||||
Assert.True(deserializedItem.Deleted);
|
||||
DeleteFireItems(new Point3D(100, 100, 0));
|
||||
}
|
||||
|
||||
private static Mobile NewCaster(double spellweaving = 120.0)
|
||||
{
|
||||
var caster = new Mobile(World.NewMobile);
|
||||
caster.DefaultMobileInit();
|
||||
caster.InitStats(100, 100, 100);
|
||||
caster.Mana = caster.ManaMax;
|
||||
caster.Skills.Spellweaving.Base = spellweaving;
|
||||
return caster;
|
||||
}
|
||||
|
||||
private static void DeleteFireItems(Point3D location)
|
||||
{
|
||||
var items = new List<WildfireFireItem>();
|
||||
|
||||
foreach (var item in Map.Felucca.GetItemsInRange(location, 2))
|
||||
{
|
||||
if (item is WildfireFireItem)
|
||||
{
|
||||
items.Add((WildfireFireItem)item);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
item.Delete();
|
||||
}
|
||||
}
|
||||
|
||||
private static int GetSpellScrollId(SpellScroll scroll)
|
||||
{
|
||||
var field = typeof(SpellScroll).GetField("_spellID", BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
return (int)field!.GetValue(scroll)!;
|
||||
}
|
||||
|
||||
private static void ResetSpellRegistry()
|
||||
{
|
||||
var types = (Type[])typeof(SpellRegistry)
|
||||
.GetField("m_Types", BindingFlags.Static | BindingFlags.NonPublic)!
|
||||
.GetValue(null)!;
|
||||
Array.Clear(types);
|
||||
|
||||
var idsFromTypes = (Dictionary<Type, int>)typeof(SpellRegistry)
|
||||
.GetField("m_IDsFromTypes", BindingFlags.Static | BindingFlags.NonPublic)!
|
||||
.GetValue(null)!;
|
||||
idsFromTypes.Clear();
|
||||
|
||||
typeof(SpellRegistry)
|
||||
.GetField("m_Count", BindingFlags.Static | BindingFlags.NonPublic)!
|
||||
.SetValue(null, 0);
|
||||
|
||||
SpellRegistry.SpecialMoves.Clear();
|
||||
}
|
||||
|
||||
private sealed class TestOrc : Orc
|
||||
{
|
||||
public override void GetSpeeds(out double activeSpeed, out double passiveSpeed)
|
||||
{
|
||||
activeSpeed = 0.2;
|
||||
passiveSpeed = 0.4;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -169,7 +169,7 @@ namespace Server.Spells
|
|||
Register(606, typeof(SummonFeySpell));
|
||||
Register(607, typeof(SummonFiendSpell));
|
||||
Register(608, typeof(ReaperFormSpell));
|
||||
// Register(609, typeof(WildfireSpell));
|
||||
Register(609, typeof(WildfireSpell));
|
||||
Register(610, typeof(EssenceOfWindSpell));
|
||||
Register(611, typeof(DryadAllureSpell));
|
||||
Register(612, typeof(EtherealVoyageSpell));
|
||||
|
|
|
|||
289
Projects/UOContent/Spells/Spellweaving/Wildfire.cs
Normal file
289
Projects/UOContent/Spells/Spellweaving/Wildfire.cs
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using ModernUO.CodeGeneratedEvents;
|
||||
using ModernUO.Serialization;
|
||||
using Server.Collections;
|
||||
using Server.Items;
|
||||
using Server.Mobiles;
|
||||
using Server.Misc;
|
||||
|
||||
namespace Server.Spells.Spellweaving;
|
||||
|
||||
public class WildfireSpell : ArcanistSpell, ITargetingSpell<IPoint3D>
|
||||
{
|
||||
private const int BaseRadius = 5;
|
||||
private const int MaxBaseDamage = 15;
|
||||
private const int TargetCooldownMilliseconds = 1000;
|
||||
|
||||
private static readonly SpellInfo _info = new("Wildfire", "Haelyn", -1);
|
||||
private static readonly Dictionary<Mobile, long> _targetCooldowns = new();
|
||||
private WildfireTimer _timer;
|
||||
|
||||
public WildfireSpell(Mobile caster, Item scroll = null) : base(caster, scroll, _info)
|
||||
{
|
||||
}
|
||||
|
||||
public override TimeSpan CastDelayBase => TimeSpan.FromSeconds(2.5);
|
||||
|
||||
public int TargetRange => Core.T2A ? 10 : 12;
|
||||
|
||||
public override double RequiredSkill => 66.0;
|
||||
|
||||
public override int RequiredMana => 50;
|
||||
|
||||
public override void OnCast()
|
||||
{
|
||||
Caster.Target = new SpellTarget<IPoint3D>(this, allowGround: true);
|
||||
}
|
||||
|
||||
public void Target(IPoint3D p)
|
||||
{
|
||||
if (!TryGetTargetLocation(p, out var location, out var map))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!CheckSequence())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SpellHelper.Turn(Caster, location);
|
||||
|
||||
var focusLevel = FocusLevel;
|
||||
var radius = GetRadius(focusLevel);
|
||||
var duration = GetDuration(Caster.Skills.Spellweaving.Value, focusLevel);
|
||||
var damage = GetBaseDamage(Caster.Skills.Spellweaving.Value, focusLevel);
|
||||
|
||||
PlaceFieldVisuals(location, map, radius, duration);
|
||||
Effects.PlaySound(location, map, 0x5CF);
|
||||
_timer = new WildfireTimer(this, location, map, radius, damage, duration);
|
||||
_timer.Start();
|
||||
}
|
||||
|
||||
internal static int GetBaseDamage(double spellweaving, int focusLevel) =>
|
||||
Math.Min(MaxBaseDamage, 10 + (int)(spellweaving / 24) + focusLevel);
|
||||
|
||||
internal static TimeSpan GetDuration(double spellweaving, int focusLevel) =>
|
||||
TimeSpan.FromSeconds(Math.Min(5, Math.Max(1, (int)(spellweaving / 24))) + focusLevel);
|
||||
|
||||
internal static int GetRadius(int focusLevel) => BaseRadius + focusLevel;
|
||||
|
||||
internal static int GetDamageForTargetCount(int baseDamage, int targetCount) =>
|
||||
targetCount switch
|
||||
{
|
||||
<= 1 => baseDamage,
|
||||
2 => Math.Min(10, baseDamage / 2),
|
||||
_ => Math.Max(5, baseDamage / 3)
|
||||
};
|
||||
|
||||
internal static int GetDamageAfterSdi(int damage, int spellDamageIncrease, bool playerVsPlayer)
|
||||
{
|
||||
if (playerVsPlayer)
|
||||
{
|
||||
spellDamageIncrease = Math.Min(spellDamageIncrease, 15);
|
||||
}
|
||||
|
||||
return damage * (100 + spellDamageIncrease) / 100;
|
||||
}
|
||||
|
||||
internal static bool IsValidTarget(Mobile caster, Mobile target) =>
|
||||
target is { Deleted: false, Alive: true, Hidden: false } &&
|
||||
caster != target &&
|
||||
caster.Map == target.Map &&
|
||||
caster.CanSee(target) &&
|
||||
caster.InLOS(target) &&
|
||||
SpellHelper.ValidIndirectTarget(caster, target) &&
|
||||
caster.CanBeHarmful(target, false);
|
||||
|
||||
internal static void ClearTargetCooldown(Mobile target)
|
||||
{
|
||||
if (target != null)
|
||||
{
|
||||
_targetCooldowns.Remove(target);
|
||||
}
|
||||
}
|
||||
|
||||
internal static void ClearAllForTests()
|
||||
{
|
||||
_targetCooldowns.Clear();
|
||||
}
|
||||
|
||||
internal void StartTimerForTests(Point3D location, Map map, int radius, int damage, TimeSpan duration)
|
||||
{
|
||||
_timer = new WildfireTimer(this, location, map, radius, damage, duration);
|
||||
}
|
||||
|
||||
internal void TickForTests() => _timer?.TickForTests();
|
||||
|
||||
private bool TryGetTargetLocation(IPoint3D target, out Point3D location, out Map map)
|
||||
{
|
||||
location = default;
|
||||
map = Caster.Map;
|
||||
|
||||
if (target == null || map == null || map == Map.Internal)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var targetPoint = target;
|
||||
SpellHelper.GetSurfaceTop(ref targetPoint);
|
||||
location = new Point3D(targetPoint);
|
||||
|
||||
if (!Caster.InRange(location, TargetRange) || !Caster.CanSee(location) || !Caster.InLOS(location))
|
||||
{
|
||||
Caster.SendLocalizedMessage(500237); // Target can not be seen.
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!SpellHelper.CheckTown(location, Caster) || !SpellHelper.AdjustField(ref location, map, 12, false))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void PlaceFieldVisuals(Point3D location, Map map, int radius, TimeSpan duration)
|
||||
{
|
||||
for (var x = -1; x <= 1; x++)
|
||||
{
|
||||
for (var y = -1; y <= 1; y++)
|
||||
{
|
||||
if (x == 0 && y == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var visualLocation = new Point3D(location.X + x * radius, location.Y + y * radius, location.Z);
|
||||
|
||||
if (SpellHelper.AdjustField(ref visualLocation, map, 12, false))
|
||||
{
|
||||
new WildfireFireItem(visualLocation, map, duration);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void DefragTargetCooldowns()
|
||||
{
|
||||
using var expired = PooledRefList<Mobile>.Create();
|
||||
|
||||
foreach (var (target, expiresAt) in _targetCooldowns)
|
||||
{
|
||||
if (target.Deleted || Core.TickCount >= expiresAt)
|
||||
{
|
||||
expired.Add(target);
|
||||
}
|
||||
}
|
||||
|
||||
for (var i = 0; i < expired.Count; i++)
|
||||
{
|
||||
_targetCooldowns.Remove(expired[i]);
|
||||
}
|
||||
}
|
||||
|
||||
[OnEvent(nameof(PlayerMobile.PlayerDeathEvent))]
|
||||
[OnEvent(nameof(PlayerMobile.PlayerDeletedEvent))]
|
||||
[OnEvent(nameof(BaseCreature.CreatureDeathEvent))]
|
||||
[OnEvent(nameof(BaseCreature.CreatureDeletedEvent))]
|
||||
public static void OnTargetRemoved(Mobile target) => ClearTargetCooldown(target);
|
||||
|
||||
private sealed class WildfireTimer : Timer
|
||||
{
|
||||
private readonly WildfireSpell _spell;
|
||||
private readonly Mobile _caster;
|
||||
private readonly Point3D _location;
|
||||
private readonly Map _map;
|
||||
private readonly int _radius;
|
||||
private readonly int _baseDamage;
|
||||
|
||||
public WildfireTimer(
|
||||
WildfireSpell spell,
|
||||
Point3D location,
|
||||
Map map,
|
||||
int radius,
|
||||
int baseDamage,
|
||||
TimeSpan duration
|
||||
) : base(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1), (int)duration.TotalSeconds)
|
||||
{
|
||||
_spell = spell;
|
||||
_caster = spell.Caster;
|
||||
_location = location;
|
||||
_map = map;
|
||||
_radius = radius;
|
||||
_baseDamage = baseDamage;
|
||||
}
|
||||
|
||||
protected override void OnTick()
|
||||
{
|
||||
if (_caster.Deleted || _map == null || _map == Map.Internal)
|
||||
{
|
||||
Stop();
|
||||
return;
|
||||
}
|
||||
|
||||
DefragTargetCooldowns();
|
||||
|
||||
using var targets = PooledRefQueue<Mobile>.Create();
|
||||
|
||||
foreach (var target in _map.GetMobilesInRange(_location, _radius))
|
||||
{
|
||||
if (!_targetCooldowns.ContainsKey(target) && IsValidTarget(_caster, target))
|
||||
{
|
||||
targets.Enqueue(target);
|
||||
}
|
||||
}
|
||||
|
||||
var targetCount = targets.Count;
|
||||
|
||||
while (targets.Count > 0)
|
||||
{
|
||||
var target = targets.Dequeue();
|
||||
_caster.DoHarmful(target);
|
||||
|
||||
var damage = GetDamageForTargetCount(_baseDamage, targetCount);
|
||||
var playerVsPlayer = _caster.Player && target.Player;
|
||||
damage = GetDamageAfterSdi(
|
||||
damage,
|
||||
AosAttributes.GetValue(_caster, AosAttribute.SpellDamage),
|
||||
playerVsPlayer
|
||||
);
|
||||
|
||||
SpellHelper.Damage(_spell, target, damage, 0, 100, 0, 0, 0);
|
||||
new WildfireFireItem(target.Location, _map, TimeSpan.FromSeconds(1));
|
||||
_targetCooldowns[target] = Core.TickCount + TargetCooldownMilliseconds;
|
||||
}
|
||||
}
|
||||
|
||||
internal void TickForTests() => OnTick();
|
||||
}
|
||||
}
|
||||
|
||||
[DispellableField]
|
||||
[SerializationGenerator(0, false)]
|
||||
public partial class WildfireFireItem : Item
|
||||
{
|
||||
private Timer _timer;
|
||||
|
||||
public WildfireFireItem(Point3D location, Map map, TimeSpan duration)
|
||||
: base(Utility.RandomBool() ? 0x398C : 0x3996)
|
||||
{
|
||||
Movable = false;
|
||||
MoveToWorld(location, map);
|
||||
_timer = Timer.DelayCall(duration, Delete);
|
||||
}
|
||||
|
||||
public override void OnAfterDelete()
|
||||
{
|
||||
_timer?.Stop();
|
||||
_timer = null;
|
||||
base.OnAfterDelete();
|
||||
}
|
||||
|
||||
[AfterDeserialization]
|
||||
private void AfterDeserialization()
|
||||
{
|
||||
Delete();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue