diff --git a/BossMod/BossModule/AIHints.cs b/BossMod/BossModule/AIHints.cs index f70fac72f0..33cc0e0779 100644 --- a/BossMod/BossModule/AIHints.cs +++ b/BossMod/BossModule/AIHints.cs @@ -330,5 +330,17 @@ public Func GoalCombined(Func singleTarget, Func GoalProximity(WPos destination, float maxDistance, float maxWeight) + { + var invDist = 1.0f / maxDistance; + return p => + { + var dist = (p - destination).Length(); + var weight = 1 - Math.Clamp(invDist * dist, 0, 1); + return maxWeight * weight; + }; + } + public WPos ClampToBounds(WPos position) => PathfindMapCenter + PathfindMapBounds.ClampToBounds(position - PathfindMapCenter); } diff --git a/BossMod/Debug/MainDebugWindow.cs b/BossMod/Debug/MainDebugWindow.cs index 0526172272..8bf7f7d061 100644 --- a/BossMod/Debug/MainDebugWindow.cs +++ b/BossMod/Debug/MainDebugWindow.cs @@ -189,7 +189,7 @@ private unsafe void DrawStatuses() if (ImGui.Button("Add thin ice")) { var player = (Character*)GameObjectManager.Instance()->Objects.IndexSorted[0].Value; - player->GetStatusManager()->SetStatus(20, 911, 20.0f, 320, 0xE0000000, true); // param = distance * 10 + player->GetStatusManager()->SetStatus(20, 911, 20.0f, 50, 0xE0000000, true); // param = distance * 10 } foreach (var elem in ws.Actors) diff --git a/BossMod/Modules/Dawntrail/Ultimate/FRU/FRUConfig.cs b/BossMod/Modules/Dawntrail/Ultimate/FRU/FRUConfig.cs index 1eca31263c..41c770b765 100644 --- a/BossMod/Modules/Dawntrail/Ultimate/FRU/FRUConfig.cs +++ b/BossMod/Modules/Dawntrail/Ultimate/FRU/FRUConfig.cs @@ -130,7 +130,20 @@ public class FRUConfig() : ConfigNode() [GroupPreset("Default", [0, 2, 5, 3, 4, 6, 7, 1])] public GroupAssignmentUnique P2IntermissionClockSpots = new() { Assignments = [0, 2, 5, 3, 4, 6, 7, 1] }; - [PropertyDisplay("P3 Darkest Dance: baiter")] + [PropertyDisplay("P3 Darkest Dance: baiter", tooltip: "Only used by AI")] [PropertyCombo("MT", "OT")] public bool P3DarkestDanceOTBait; + + [PropertyDisplay("P4 Somber Dance: baiter", tooltip: "Only used by AI")] + [PropertyCombo("MT", "OT")] + public bool P4SomberDanceOTBait = true; + + [PropertyDisplay("P5 Akh Morn: side assignments", tooltip: "Only used by AI")] + [GroupDetails(["Left (looking at boss)", "Right (looking at boss)"])] + public GroupAssignmentLightParties P5AkhMornAssignments = GroupAssignmentLightParties.DefaultLightParties(); + + [PropertyDisplay("P5 Polarizing Strikes: bait order", tooltip: "Only used by AI")] + [GroupDetails(["Left 1", "Left 2", "Left 3", "Left 4", "Right 1", "Right 2", "Right 3", "Right 4"])] + [GroupPreset("TMRH", [0, 4, 3, 7, 1, 5, 2, 6])] + public GroupAssignmentUnique P5PolarizingStrikesAssignments = new() { Assignments = [0, 4, 3, 7, 1, 5, 2, 6] }; } diff --git a/BossMod/Modules/Dawntrail/Ultimate/FRU/FRUEnums.cs b/BossMod/Modules/Dawntrail/Ultimate/FRU/FRUEnums.cs index 08444bb2d7..e0cd7fc56a 100644 --- a/BossMod/Modules/Dawntrail/Ultimate/FRU/FRUEnums.cs +++ b/BossMod/Modules/Dawntrail/Ultimate/FRU/FRUEnums.cs @@ -278,6 +278,15 @@ public enum AID : uint WingsDarkAndLightCleaveDark = 40315, // BossP5->self, no cast, range 100 240?-degree cone on target WingsDarkAndLightTetherLight = 39879, // Helper->players, no cast, range 4 circle on farthest WingsDarkAndLightTetherDark = 39880, // Helper->player, no cast, range 4 circle on closest + + PolarizingStrikes = 40316, // BossP5->self, 6.5+0.5s cast, single-target, visual (line stacks) + CruelPathOfLightBait = 40317, // Helper->self, 0.5s cast, range 100 width 6 rect (left side) + CruelPathOfDarknessBait = 40318, // Helper->self, 0.5s cast, range 100 width 6 rect (right side) + CruelPathOfLightAOE = 40119, // Helper->self, no cast, range 100 width 6 rect (repeated hit) + CruelPathOfDarknessAOE = 40120, // Helper->self, no cast, range 100 width 6 rect (repeated hit) + PolarizingPaths = 40234, // BossP5->self, 2.5+0.5s cast, single-target, visual (second+ hit) + + PandorasBox = 40326, // BossP5->self, 12.0s cast, range 100 circle, raidwide requiring tank LB } public enum SID : uint @@ -310,6 +319,8 @@ public enum SID : uint SpellInWaitingDarkAero = 2463, // none->player, extra=0x0 //SpellInWaitingReturn = 4208, // none->player, extra=0x0 //SpellInWaitingReturnII = 4171, // Helper->UsurperOfFrostP4, extra=0x0 + LightResistanceDown = 4164, // Helper->player, extra=0x0 + DarkResistanceDown = 3323, // Helper->player, extra=0x0 } public enum IconID : uint diff --git a/BossMod/Modules/Dawntrail/Ultimate/FRU/FRUStates.cs b/BossMod/Modules/Dawntrail/Ultimate/FRU/FRUStates.cs index fb4858276e..96282ea8e2 100644 --- a/BossMod/Modules/Dawntrail/Ultimate/FRU/FRUStates.cs +++ b/BossMod/Modules/Dawntrail/Ultimate/FRU/FRUStates.cs @@ -68,6 +68,12 @@ private void Phase5(uint id) P5Start(id, 77); P5FulgentBlade(id + 0x10000, 5.3f); P5ParadiseRegained(id + 0x20000, 4.2f); + P5PolarizingStrikes(id + 0x30000, 7.6f); + P5PandorasBox(id + 0x40000, 5.8f); + P5FulgentBlade(id + 0x50000, 6.2f); + P5ParadiseRegained(id + 0x60000, 8.2f); // TODO: timing... + P5PolarizingStrikes(id + 0x70000, 8); // TODO: timing... + P5FulgentBlade(id + 0x80000, 8); // TODO: timing... SimpleState(id + 0xFF0000, 100, "???"); } @@ -524,9 +530,9 @@ private void P4AkhRhai(uint id, float delay) .ActivateOnEnter() .DeactivateOnExit() .SetHint(StateMachine.StateHint.DowntimeEnd); - ActorCast(id + 0x10, _module.BossP4Usurper, AID.Materialization, 5.1f, 3, true); - ComponentCondition(id + 0x20, 11.2f, comp => comp.AOEs.Count > 0, "Puddle baits") + ActorCast(id + 0x10, _module.BossP4Usurper, AID.Materialization, 5.1f, 3, true) .ActivateOnEnter(); + ComponentCondition(id + 0x20, 11.2f, comp => comp.AOEs.Count > 0, "Puddle baits"); ComponentCondition(id + 0x30, 2.6f, comp => comp.NumCasts > 0); ActorTargetable(id + 0x50, _module.BossP4Oracle, true, 3.6f, "Oracle appears"); ComponentCondition(id + 0x60, 1.6f, comp => comp.NumCasts >= 10 * comp.AOEs.Count, "Puddle resolve") @@ -548,22 +554,22 @@ private void P4DarklitDragonsong(uint id, float delay) ComponentCondition(id + 0x21, 0.8f, comp => comp.NumCasts > 0, "Proteans") .DeactivateOnExit(); ActorCastEnd(id + 0x22, _module.BossP4Oracle, 2.2f, true) - .ActivateOnEnter(); + .ActivateOnEnter(); ActorCastStartMulti(id + 0x23, _module.BossP4Usurper, [AID.HallowedWingsL, AID.HallowedWingsR], 0.1f, true); ComponentCondition(id + 0x24, 0.3f, comp => comp.Spreads.Count == 0, "Jump") .DeactivateOnExit(); ActorCastStart(id + 0x25, _module.BossP4Oracle, AID.SomberDance, 2.8f) .ActivateOnEnter() .ActivateOnEnter() - .ExecOnEnter(comp => comp.ResolveImminent = true); + .ExecOnEnter(comp => comp.Show()); ComponentCondition(id + 0x26, 1.7f, comp => comp.Stacks.Count == 0, "Stacks") .DeactivateOnExit(); ActorCastEnd(id + 0x27, _module.BossP4Usurper, 0.2f, false, "Side cleave") .ActivateOnEnter() .DeactivateOnExit() - .DeactivateOnExit() - .DeactivateOnExit(); - ActorCastEnd(id + 0x28, _module.BossP4Oracle, 3.1f, true); + .DeactivateOnExit(); + ActorCastEnd(id + 0x28, _module.BossP4Oracle, 3.1f, true) + .DeactivateOnExit(); // tethers deactivate ~0.5s before cast end ComponentCondition(id + 0x29, 0.4f, comp => comp.NumCasts > 0, "Tankbuster 1") .SetHint(StateMachine.StateHint.Tankbuster); ComponentCondition(id + 0x2A, 3.2f, comp => comp.NumCasts > 1, "Tankbuster 2") @@ -618,8 +624,7 @@ private void P4CrystallizeTime(uint id, float delay) .ActivateOnEnter(); ComponentCondition(id + 0x50, 1.1f, comp => comp.NumCasts > 4, "Hourglass 3") .DeactivateOnExit() - .DeactivateOnExit() - .ExecOnExit(comp => comp.ShowPuddles = true); + .DeactivateOnExit(); ActorCast(id + 0x60, _module.BossP4Usurper, AID.TidalLight, 2.3f, 3, true, "Exaline NS start") .ActivateOnEnter(); ComponentCondition(id + 0x70, 4.1f, comp => comp.NumCasts > 0) @@ -632,7 +637,7 @@ private void P4CrystallizeTime(uint id, float delay) .DeactivateOnExit(); ActorCastStart(id + 0x90, _module.BossP4Oracle, AID.SpiritTaker, 0.4f); ActorCastStart(id + 0x91, _module.BossP4Usurper, AID.CrystallizeTimeHallowedWings1, 2.2f) - .ActivateOnEnter(); + .ActivateOnEnter(); ActorCastEnd(id + 0x92, _module.BossP4Oracle, 0.8f); ComponentCondition(id + 0x93, 0.3f, comp => comp.Spreads.Count == 0, "Jump") .DeactivateOnExit(); @@ -683,11 +688,38 @@ private void P5ParadiseRegained(uint id, float delay) .ActivateOnEnter(); // first tower appears ~0.9s after cast end, then every 3.5s ActorCastMulti(id + 0x10, _module.BossP5, [AID.WingsDarkAndLightDL, AID.WingsDarkAndLightLD], 3.2f, 6.9f, true) .ActivateOnEnter(); - ComponentCondition(id + 0x20, 0.5f, comp => comp.NumCasts > 0, "Light/dark"); // first tower resolve ~0.1s earlier - ComponentCondition(id + 0x30, 3.7f, comp => comp.NumCasts > 1, "Dark/light") // second tower resolves ~1s earlier + ComponentCondition(id + 0x20, 0.3f, comp => comp.NumCasts > 0, "Light/dark"); // first tower resolves at the same time + ComponentCondition(id + 0x30, 3.7f, comp => comp.NumCasts > 1, "Dark/light") // second tower resolves at the same time .DeactivateOnExit(); - // note: tethers resolve ~0.7s after cleave, but they won't happen if tether target dies to cleave - ComponentCondition(id + 0x40, 2.4f, comp => comp.NumCasts > 2, "Towers resolve") + // note: tethers resolve ~0.8s after cleave, but they won't happen if tether target dies to cleave + ComponentCondition(id + 0x40, 3.4f, comp => comp.NumCasts > 2, "Towers resolve") .DeactivateOnExit(); } + + private void P5PolarizingStrikes(uint id, float delay) + { + ActorCast(id, _module.BossP5, AID.PolarizingStrikes, delay, 6.5f, true) + .ActivateOnEnter(); + ComponentCondition(id + 0x10, 0.6f, comp => comp.NumCasts > 0, "Polarizing bait 1"); + ActorCastStart(id + 0x20, _module.BossP5, AID.PolarizingPaths, 1.5f, true); + ComponentCondition(id + 0x21, 0.5f, comp => comp.NumCasts > 2, "Polarizing AOE 1"); + ActorCastEnd(id + 0x22, _module.BossP5, 2, true); + ComponentCondition(id + 0x30, 0.6f, comp => comp.NumCasts > 4, "Polarizing bait 2"); + ActorCastStart(id + 0x40, _module.BossP5, AID.PolarizingPaths, 1.5f, true); + ComponentCondition(id + 0x41, 0.5f, comp => comp.NumCasts > 6, "Polarizing AOE 2"); + ActorCastEnd(id + 0x42, _module.BossP5, 2, true); + ComponentCondition(id + 0x50, 0.6f, comp => comp.NumCasts > 8, "Polarizing bait 3"); + ActorCastStart(id + 0x60, _module.BossP5, AID.PolarizingPaths, 1.5f, true); + ComponentCondition(id + 0x61, 0.5f, comp => comp.NumCasts > 10, "Polarizing AOE 3"); + ActorCastEnd(id + 0x62, _module.BossP5, 2, true); + ComponentCondition(id + 0x70, 0.6f, comp => comp.NumCasts > 12, "Polarizing bait 4"); + ComponentCondition(id + 0x80, 2.0f, comp => comp.NumCasts > 14, "Polarizing AOE 4") + .DeactivateOnExit(); + } + + private void P5PandorasBox(uint id, float delay) + { + ActorCast(id, _module.BossP5, AID.PandorasBox, delay, 12, true, "Tank LB") + .SetHint(StateMachine.StateHint.Raidwide); + } } diff --git a/BossMod/Modules/Dawntrail/Ultimate/FRU/P2AbsoluteZero.cs b/BossMod/Modules/Dawntrail/Ultimate/FRU/P2AbsoluteZero.cs index e1e72aaf28..cbab2e75b3 100644 --- a/BossMod/Modules/Dawntrail/Ultimate/FRU/P2AbsoluteZero.cs +++ b/BossMod/Modules/Dawntrail/Ultimate/FRU/P2AbsoluteZero.cs @@ -16,16 +16,26 @@ public override IEnumerable Sources(int slot, Actor actor) class P2HiemalStorm(BossModule module) : Components.SimpleAOEs(module, ActionID.MakeSpell(AID.HiemalStormAOE), 7) { + private bool _slowDodges; + public override void AddAIHints(int slot, Actor actor, PartyRolesConfig.Assignment assignment, AIHints hints) { // storms are cast every 3s, ray voidzones appear every 2s; to place voidzones more tightly, we pretend radius is smaller during first half of cast - var deadline = WorldState.FutureTime(1.5f); + // there's no point doing it before first voidzone appears, however + var deadline = _slowDodges ? WorldState.FutureTime(1.5f) : DateTime.MaxValue; foreach (var c in Casters) { var activation = c.Activation; hints.AddForbiddenZone(ShapeDistance.Circle(c.Origin, activation > deadline ? 4 : 7), activation); } } + + public override void OnEventCast(Actor caster, ActorCastEvent spell) + { + base.OnEventCast(caster, spell); + if ((AID)spell.Action.ID == AID.HiemalRay) + _slowDodges = true; + } } class P2HiemalRay(BossModule module) : Components.PersistentVoidzoneAtCastTarget(module, 4, ActionID.MakeSpell(AID.HiemalRay), module => module.Enemies(OID.HiemalRayVoidzone).Where(z => z.EventState != 7), 0.7f); diff --git a/BossMod/Modules/Dawntrail/Ultimate/FRU/P2DiamondDust.cs b/BossMod/Modules/Dawntrail/Ultimate/FRU/P2DiamondDust.cs index 339f06764f..eaf2d3dc72 100644 --- a/BossMod/Modules/Dawntrail/Ultimate/FRU/P2DiamondDust.cs +++ b/BossMod/Modules/Dawntrail/Ultimate/FRU/P2DiamondDust.cs @@ -366,6 +366,7 @@ class P2TwinStillnessSilence(BossModule module) : Components.GenericAOEs(module) private readonly Actor? _source = module.Enemies(OID.OraclesReflection).FirstOrDefault(); private BitMask _thinIce; private P2SinboundHolyVoidzone? _voidzones; // used for hints only + private const float SlideDistance = 32; private readonly AOEShapeCone _shapeFront = new(30, 135.Degrees()); private readonly AOEShapeCone _shapeBack = new(30, 45.Degrees()); @@ -412,29 +413,56 @@ public override void AddAIHints(int slot, Actor actor, PartyRolesConfig.Assignme hints.AddForbiddenZone(ShapeDistance.Circle(Arena.Center, 16), WorldState.FutureTime(50)); hints.AddForbiddenZone(ShapeDistance.InvertedCone(Arena.Center, 100, desiredDir, halfWidth), DateTime.MaxValue); } - return; - } - - if (AOEs.Count == 0) - { - // if we're behind boss, slide over - hints.AddForbiddenZone(ShapeDistance.Rect(_source.Position, _source.Rotation, 20, 20, 20), DateTime.MaxValue); } - else + else if (actor.LastFrameMovement == default) { - // otherwise just dodge next aoe - ref var nextAOE = ref AOEs.Ref(0); - hints.AddForbiddenZone(nextAOE.Shape.Distance(nextAOE.Origin, nextAOE.Rotation), nextAOE.Activation); - } + // at this point, we have thin ice, so we can either stay or move fixed distance + var sourceOffset = _source.Position - Arena.Center; + var needToMove = AOEs.Count > 0 ? AOEs[0].Check(actor.Position) : NumCasts == 0 && sourceOffset.Dot(actor.Position - Arena.Center) > 0; + if (!needToMove) + return; - // ensure we don't slide over voidzones - foreach (var z in _voidzones.Sources(Module)) - { - var offset = z.Position - actor.Position; - var dist = offset.Length(); - if (dist > 6) - hints.AddForbiddenZone(ShapeDistance.Cone(actor.Position, 100, Angle.FromDirection(offset), Angle.Asin(dist / 6))); + var zoneList = new ArcList(actor.Position, SlideDistance); + zoneList.ForbidInverseCircle(Arena.Center, Arena.Bounds.Radius); + + foreach (var z in _voidzones.Sources(Module)) + { + var offset = z.Position - actor.Position; + var dist = offset.Length(); + if (dist >= SlideDistance) + { + // voidzone center is outside slide distance => forbid voidzone itself + zoneList.ForbidCircle(z.Position, 6); + } + else if (dist >= 6) + { + // forbid the voidzone's shadow + zoneList.ForbidArcByLength(Angle.FromDirection(offset), Angle.Asin(6 / dist)); + } + // else: we're already in voidzone, oh well + } + + if (AOEs.Count == 0) + { + // if we're behind boss, slide over + zoneList.ForbidInfiniteRect(Arena.Center, Angle.FromDirection(sourceOffset), Arena.Bounds.Radius); + //zoneList.ForbidCircle(_source.Position, 20); + } + else + { + // dodge next aoe + ref var nextAOE = ref AOEs.Ref(0); + zoneList.ForbidInfiniteCone(nextAOE.Origin, nextAOE.Rotation, ((AOEShapeCone)nextAOE.Shape).HalfAngle); + } + + var best = zoneList.Allowed(1.Degrees()).MaxBy(r => (r.max - r.min).Rad); + if (best.max.Rad > best.min.Rad) + { + var dir = 0.5f * (best.min + best.max); + hints.AddForbiddenZone(ShapeDistance.InvertedCircle(actor.Position + SlideDistance * dir.ToDirection(), 1), DateTime.MaxValue); + } } + // else: we are already sliding, nothing to do... } public override void DrawArenaForeground(int pcSlot, Actor pc) @@ -474,7 +502,7 @@ public override void OnStatusGain(Actor actor, ActorStatus status) } } -class P2ThinIce(BossModule module) : Components.ThinIce(module, 32, true) +class P2ThinIce(BossModule module) : Components.ThinIce(module, 32) { public override bool DestinationUnsafe(int slot, Actor actor, WPos pos) => (Module.FindComponent()?.ActiveAOEs(slot, actor).Any(z => z.Shape.Check(pos, z.Origin, z.Rotation)) ?? false) || !Module.InBounds(pos); diff --git a/BossMod/Modules/Dawntrail/Ultimate/FRU/P3UltimateRelativity.cs b/BossMod/Modules/Dawntrail/Ultimate/FRU/P3UltimateRelativity.cs index fd30e93d45..ed2571ce41 100644 --- a/BossMod/Modules/Dawntrail/Ultimate/FRU/P3UltimateRelativity.cs +++ b/BossMod/Modules/Dawntrail/Ultimate/FRU/P3UltimateRelativity.cs @@ -61,7 +61,7 @@ public override void AddAIHints(int slot, Actor actor, PartyRolesConfig.Assignme foreach (var (i, p) in Raid.WithSlot(false, true, true).Exclude(slot)) { var avoidRadius = avoidBlizzard && States[i].HaveDarkBlizzard ? 12 : 8; - hints.AddForbiddenZone(ShapeDistance.Circle(p.Position, avoidRadius + 1)); + hints.AddForbiddenZone(ShapeDistance.Circle(p.Position, avoidRadius)); } var lasers = Module.FindComponent(); if (lasers != null) @@ -401,7 +401,7 @@ class P3UltimateRelativityDarkBlizzard(BossModule module) : Components.GenericAO private readonly List _sources = []; private DateTime _activation; - private static readonly AOEShapeDonut _shape = new(4, 12); // TODO: verify inner radius + private static readonly AOEShapeDonut _shape = new(3, 12); // TODO: verify inner radius public override IEnumerable ActiveAOEs(int slot, Actor actor) => _sources.Select(s => new AOEInstance(_shape, s.Position, default, _activation)); diff --git a/BossMod/Modules/Dawntrail/Ultimate/FRU/P4AkhRhai.cs b/BossMod/Modules/Dawntrail/Ultimate/FRU/P4AkhRhai.cs index ab80db2686..abc306d7d6 100644 --- a/BossMod/Modules/Dawntrail/Ultimate/FRU/P4AkhRhai.cs +++ b/BossMod/Modules/Dawntrail/Ultimate/FRU/P4AkhRhai.cs @@ -8,6 +8,19 @@ class P4AkhRhai(BossModule module) : Components.GenericAOEs(module, ActionID.Mak public override IEnumerable ActiveAOEs(int slot, Actor actor) => AOEs; + public override void AddAIHints(int slot, Actor actor, PartyRolesConfig.Assignment assignment, AIHints hints) + { + base.AddAIHints(slot, actor, assignment, hints); + if (AOEs.Count == 0) + { + // preposition for baits - note that this is very arbitrary... + var off = 10 * 45.Degrees().ToDirection(); + var p1 = ShapeDistance.Circle(Arena.Center + off, 1); + var p2 = ShapeDistance.Circle(Arena.Center - off, 1); + hints.AddForbiddenZone(p => -Math.Min(p1(p), p2(p)), DateTime.MaxValue); + } + } + public override void OnCastStarted(Actor caster, ActorCastInfo spell) { if ((AID)spell.Action.ID == AID.AkhRhai) diff --git a/BossMod/Modules/Dawntrail/Ultimate/FRU/P4CrystallizeTime.cs b/BossMod/Modules/Dawntrail/Ultimate/FRU/P4CrystallizeTime.cs index 5fc0cc20f1..31c69073c1 100644 --- a/BossMod/Modules/Dawntrail/Ultimate/FRU/P4CrystallizeTime.cs +++ b/BossMod/Modules/Dawntrail/Ultimate/FRU/P4CrystallizeTime.cs @@ -1,6 +1,5 @@ namespace BossMod.Dawntrail.Ultimate.FRU; -// TODO: hints and stuff... class P4CrystallizeTime(BossModule module) : BossComponent(module) { public enum Mechanic { None, FangEruption, FangWater, FangDarkness, FangBlizzard, ClawAir, ClawBlizzard } @@ -13,7 +12,7 @@ public enum Mechanic { None, FangEruption, FangWater, FangDarkness, FangBlizzard public Actor? FindPlayerByAssignment(Mechanic mechanic, int side) { - for (int i = 0; i < PlayerMechanics.Length; ++i) + for (var i = 0; i < PlayerMechanics.Length; ++i) if (PlayerMechanics[i] == mechanic && ClawSides[i] == side) return Raid[i]; return null; @@ -60,8 +59,8 @@ public override void OnStatusLose(Actor actor, ActorStatus status) public override void OnTethered(Actor source, ActorTetherInfo tether) { - if (tether.ID == (uint)TetherID.UltimateRelativitySlow && source.Position.Z < Module.Center.Z) - NorthSlowHourglass = source.Position - Module.Center; + if (tether.ID == (uint)TetherID.UltimateRelativitySlow && source.Position.Z < Arena.Center.Z) + NorthSlowHourglass = source.Position - Arena.Center; } private void AssignMechanic(Actor player, Mechanic mechanic, Mechanic lowerPrio = Mechanic.None, Mechanic higherPrio = Mechanic.None) @@ -99,35 +98,49 @@ void assign(int slot, int prio, ref (int slot, int prio) prev) class P4CrystallizeTimeDragonHead(BossModule module) : BossComponent(module) { - public bool ShowPuddles; + public readonly List<(Actor head, int side)> Heads = []; private readonly P4CrystallizeTime? _ct = module.FindComponent(); - private readonly List<(Actor head, int side)> _heads = []; private readonly List<(Actor puddle, P4CrystallizeTime.Mechanic soaker)> _puddles = []; - public Actor? FindHead(int side) => _heads.FirstOrDefault(v => v.side == side).head; + public Actor? FindHead(int side) => Heads.FirstOrDefault(v => v.side == side).head; public static int NumHeadHits(Actor? head) => head == null ? 2 : head.HitboxRadius < 2 ? 1 : 0; + public Actor? FindInterceptor(Actor head, int side) => _ct?.FindPlayerByAssignment(NumHeadHits(head) > 0 ? P4CrystallizeTime.Mechanic.ClawAir : P4CrystallizeTime.Mechanic.ClawBlizzard, side); + + public override void AddAIHints(int slot, Actor actor, PartyRolesConfig.Assignment assignment, AIHints hints) + { + // avoid wrong puddles and try to grab desired one + if (_ct != null) + { + var pcAssignment = _ct.PlayerMechanics[slot]; + foreach (var p in _puddles.Where(p => p.puddle.EventState != 7)) + { + if (p.soaker == pcAssignment) + hints.GoalZones.Add(hints.GoalProximity(p.puddle.Position, 5, 0.25f)); + else + hints.AddForbiddenZone(ShapeDistance.Circle(p.puddle.Position, 1)); + } + } + } public override void DrawArenaForeground(int pcSlot, Actor pc) { - for (var i = 0; i < _heads.Count; ++i) + foreach (var h in Heads) { - var h = _heads[i]; Arena.Actor(h.head, Colors.Object, true); - var interceptor = _ct?.FindPlayerByAssignment(NumHeadHits(h.head) > 0 ? P4CrystallizeTime.Mechanic.ClawAir : P4CrystallizeTime.Mechanic.ClawBlizzard, h.side); + var interceptor = FindInterceptor(h.head, h.side); if (interceptor != null) Arena.AddCircle(interceptor.Position, 12, Colors.Danger); - } + } - if (ShowPuddles && _ct != null && !_ct.Cleansed[pcSlot]) + public override void DrawArenaBackground(int pcSlot, Actor pc) + { + if (_ct != null /*&& ShowPuddles && !_ct.Cleansed[pcSlot]*/) { var pcAssignment = _ct.PlayerMechanics[pcSlot]; - for (var i = 0; i < _puddles.Count; ++i) - { - var p = _puddles[i]; + foreach (var p in _puddles) if (p.puddle.EventState != 7) - Arena.AddCircle(p.puddle.Position, 1, p.soaker == pcAssignment ? Colors.Safe : Colors.Danger); - } + Arena.ZoneCircle(p.puddle.Position, 1, p.soaker == pcAssignment ? Colors.SafeFromAOE : Colors.AOE); } } @@ -136,7 +149,7 @@ public override void OnActorCreated(Actor actor) switch ((OID)actor.OID) { case OID.DrachenWanderer: - _heads.Add((actor, actor.Position.X > Module.Center.X ? 1 : -1)); + Heads.Add((actor, actor.Position.X > Arena.Center.X ? 1 : -1)); break; case OID.DragonPuddle: // TODO: this is very arbitrary @@ -151,7 +164,7 @@ public override void OnActorCreated(Actor actor) public override void OnEventCast(Actor caster, ActorCastEvent spell) { if ((AID)spell.Action.ID == AID.DrachenWandererDisappear) - _heads.RemoveAll(h => h.head == caster); + Heads.RemoveAll(h => h.head == caster); } private P4CrystallizeTime.Mechanic AssignPuddle(P4CrystallizeTime.Mechanic first, P4CrystallizeTime.Mechanic second) => _puddles.Any(p => p.soaker == first) ? second : first; @@ -159,19 +172,21 @@ public override void OnEventCast(Actor caster, ActorCastEvent spell) class P4CrystallizeTimeMaelstrom(BossModule module) : Components.GenericAOEs(module, ActionID.MakeSpell(AID.CrystallizeTimeMaelstrom)) { - private readonly List _aoes = []; + public readonly List AOEs = []; private static readonly AOEShapeCircle _shape = new(12); - public override IEnumerable ActiveAOEs(int slot, Actor actor) => _aoes.Take(2); + public override IEnumerable ActiveAOEs(int slot, Actor actor) => AOEs.Take(2); + + public override void AddAIHints(int slot, Actor actor, PartyRolesConfig.Assignment assignment, AIHints hints) { } // handled by other components // assuming that this component is activated when speed cast starts - all hourglasses should be already created, and tethers should have appeared few frames ago public override void OnActorCreated(Actor actor) { if ((OID)actor.OID == OID.SorrowsHourglass) { - _aoes.Add(new(_shape, actor.Position, actor.Rotation, WorldState.FutureTime(13.2f))); - _aoes.SortBy(aoe => aoe.Activation); + AOEs.Add(new(_shape, actor.Position, actor.Rotation, WorldState.FutureTime(13.2f))); + AOEs.SortBy(aoe => aoe.Activation); } } @@ -185,11 +200,11 @@ public override void OnTethered(Actor source, ActorTetherInfo tether) }; if (delay != 0) { - var index = _aoes.FindIndex(aoe => aoe.Origin.AlmostEqual(source.Position, 1)); + var index = AOEs.FindIndex(aoe => aoe.Origin.AlmostEqual(source.Position, 1)); if (index >= 0) { - _aoes.Ref(index).Activation = WorldState.FutureTime(delay); - _aoes.SortBy(aoe => aoe.Activation); + AOEs.Ref(index).Activation = WorldState.FutureTime(delay); + AOEs.SortBy(aoe => aoe.Activation); } } } @@ -199,13 +214,15 @@ public override void OnEventCast(Actor caster, ActorCastEvent spell) if (spell.Action == WatchedAction) { ++NumCasts; - _aoes.RemoveAll(aoe => aoe.Origin.AlmostEqual(caster.Position, 1)); + AOEs.RemoveAll(aoe => aoe.Origin.AlmostEqual(caster.Position, 1)); } } } class P4CrystallizeTimeDarkWater(BossModule module) : Components.UniformStackSpread(module, 6, 0, 4, 4) { + public override void AddAIHints(int slot, Actor actor, PartyRolesConfig.Assignment assignment, AIHints hints) { } // handled by other components + public override void OnStatusGain(Actor actor, ActorStatus status) { if ((SID)status.ID == SID.SpellInWaitingDarkWater) @@ -240,6 +257,8 @@ class P4CrystallizeTimeDarkEruption(BossModule module) : Components.GenericBaitA { private static readonly AOEShapeCircle _shape = new(6); + public override void AddAIHints(int slot, Actor actor, PartyRolesConfig.Assignment assignment, AIHints hints) { } // handled by other components + public override void OnStatusGain(Actor actor, ActorStatus status) { if ((SID)status.ID == SID.SpellInWaitingDarkEruption) @@ -270,6 +289,8 @@ public override void OnStatusGain(Actor actor, ActorStatus status) class P4CrystallizeTimeUnholyDarkness(BossModule module) : Components.UniformStackSpread(module, 6, 0, 5, 5) { + public override void AddAIHints(int slot, Actor actor, PartyRolesConfig.Assignment assignment, AIHints hints) { } // handled by other components + public override void OnStatusGain(Actor actor, ActorStatus status) { if ((SID)status.ID == SID.SpellInWaitingUnholyDarkness) @@ -301,7 +322,8 @@ public override void OnEventCast(Actor caster, ActorCastEvent spell) class P4CrystallizeTimeTidalLight : Components.Exaflare { - public WDir StartingOffset; + public List<(WPos pos, Angle dir)> StartingPositions = []; + public WDir StartingOffsetSum; public P4CrystallizeTimeTidalLight(BossModule module) : base(module, new AOEShapeRect(10, 20)) { @@ -313,7 +335,8 @@ public override void OnCastStarted(Actor caster, ActorCastInfo spell) if ((AID)spell.Action.ID is AID.TidalLightAOEFirst) { Lines.Add(new() { Next = caster.Position, Advance = 10 * spell.Rotation.ToDirection(), Rotation = spell.Rotation, NextExplosion = Module.CastFinishAt(spell), TimeToMove = 2.1f, ExplosionsLeft = 4, MaxShownExplosions = 1 }); - StartingOffset += caster.Position - Arena.Center; + StartingPositions.Add((caster.Position, spell.Rotation)); + StartingOffsetSum += caster.Position - Arena.Center; } } @@ -340,15 +363,74 @@ class P4CrystallizeTimeQuietus(BossModule module) : Components.CastCounter(modul class P4CrystallizeTimeHints(BossModule module) : BossComponent(module) { + [Flags] + public enum Hint + { + None = 0, + SafespotRough = 1 << 0, // position roughly around safespot + SafespotPrecise = 1 << 1, // position exactly at safespot + Maelstrom = 1 << 2, // avoid maelstroms + Heads = 1 << 3, // avoid head interceptors + Knockback = 1 << 4, // position to knock back across + Mid = 1 << 5, // position closer to center if possible + } + private readonly P4CrystallizeTime? _ct = module.FindComponent(); private readonly P4CrystallizeTimeDragonHead? _heads = module.FindComponent(); private readonly P4CrystallizeTimeMaelstrom? _hourglass = module.FindComponent(); private bool KnockbacksDone; private bool DarknessDone; + public override void AddAIHints(int slot, Actor actor, PartyRolesConfig.Assignment assignment, AIHints hints) + { + if (WorldState.PendingEffects.PendingKnockbacks(actor.InstanceID)) + return; // don't move while waiting for kb to resolve... + + var hint = CalculateHint(slot); + if (hint.offset != default) + { + // we want to stay really close to border + if (hint.offset.LengthSq() > 18 * 18) + hint.offset *= 19.5f / 19; + + if (hint.hint.HasFlag(Hint.SafespotRough)) + { + hints.AddForbiddenZone(ShapeDistance.InvertedCircle(Arena.Center + hint.offset, 1), DateTime.MaxValue); + } + if (hint.hint.HasFlag(Hint.SafespotPrecise)) + { + hints.AddForbiddenZone(ShapeDistance.PrecisePosition(Arena.Center + hint.offset, new(0, 1), Arena.Bounds.MapResolution, actor.Position, 0.1f)); + } + if (hint.hint.HasFlag(Hint.Maelstrom) && _hourglass != null) + { + foreach (var aoe in _hourglass.AOEs.Take(2)) + hints.AddForbiddenZone(aoe.Shape.Distance(aoe.Origin, aoe.Rotation), aoe.Activation); + } + if (hint.hint.HasFlag(Hint.Heads) && _heads != null) + { + foreach (var h in _heads.Heads) + if (_heads.FindInterceptor(h.head, h.side) is var interceptor && interceptor != null && interceptor != actor) + hints.AddForbiddenZone(ShapeDistance.Circle(interceptor.Position, 12)); + } + if (hint.hint.HasFlag(Hint.Knockback) && _ct != null) + { + var source = _ct.FindPlayerByAssignment(P4CrystallizeTime.Mechanic.ClawAir, _ct.NorthSlowHourglass.X > 0 ? -1 : 1); + var dest = Arena.Center + SafeOffsetDarknessStack(_ct.NorthSlowHourglass.X > 0 ? 1 : -1); + var pos = source != null ? source.Position + 2 * (dest - source.Position).Normalized() : Arena.Center + hint.offset; + hints.AddForbiddenZone(ShapeDistance.PrecisePosition(pos, new(0, 1), Arena.Bounds.MapResolution, actor.Position, 0.1f)); + } + if (hint.hint.HasFlag(Hint.Mid) && _hourglass != null && !_hourglass.AOEs.Take(2).Any(aoe => aoe.Check(actor.Position))) + { + // stay on correct side + var dest = Arena.Center + new WDir(0, hint.offset.Z > 0 ? 18 : -18); + hints.GoalZones.Add(hints.GoalSingleTarget(dest, 2, 0.5f)); + } + } + } + public override void DrawArenaForeground(int pcSlot, Actor pc) { - var safeOffset = SafeOffset(pcSlot); + var safeOffset = CalculateHint(pcSlot).offset; if (safeOffset != default) Arena.AddCircle(Arena.Center + safeOffset, 1, Colors.Safe); } @@ -366,87 +448,114 @@ public override void OnEventCast(Actor caster, ActorCastEvent spell) } } - private WDir SafeOffset(int slot) + // these are all possible 'raw' safespot offsets; they expect valid arguments + private static WDir SafeOffsetDodgeFirstHourglassSouth(int side) => 19 * (side * 40).Degrees().ToDirection(); + private static WDir SafeOffsetPreKnockbackSouth(int side, float radius) => radius * (side * 30).Degrees().ToDirection(); + private static WDir SafeOffsetDarknessStack(int side) => 19 * (side * 140).Degrees().ToDirection(); + private static WDir SafeOffsetDodgeSecondHourglassSouth(int side) => 19 * (side * 20).Degrees().ToDirection(); + private static WDir SafeOffsetDodgeSecondHourglassEW(int side) => 19 * (side * 80).Degrees().ToDirection(); // for ice that doesn't share unholy darkness + private static WDir SafeOffsetFirstHeadBait(int side) => 13 * (side * 90).Degrees().ToDirection(); + private static WDir SafeOffsetSecondHeadBait(int side) => 13 * (side * 45).Degrees().ToDirection(); + private static WDir SafeOffsetChillNorth(int side) => 6 * (side * 150).Degrees().ToDirection(); // final for non-airs + private static WDir SafeOffsetChillSouth(int side) => 6 * (side * 30).Degrees().ToDirection(); // final for 2 airs + + // these determine rough safespot offset (depending on player state and mechanic progress) for drawing on arena or adding ai hints + private (WDir offset, Hint hint) CalculateHint(int slot) { if (_ct == null || _heads == null || _hourglass == null || _ct.NorthSlowHourglass.X == 0) return default; var clawSide = _ct.ClawSides[slot]; + var northSlowSide = _ct.NorthSlowHourglass.X > 0 ? 1 : -1; return _ct.PlayerMechanics[slot] switch { - P4CrystallizeTime.Mechanic.ClawAir => clawSide != 0 ? SafeOffsetClawAir(clawSide, _hourglass.NumCasts, _ct.NorthSlowHourglass.X) : default, - P4CrystallizeTime.Mechanic.ClawBlizzard => clawSide != 0 ? SafeOffsetClawBlizzard(clawSide, _hourglass.NumCasts, _ct.NorthSlowHourglass.X) : default, - P4CrystallizeTime.Mechanic.FangEruption => SafeOffsetFangEruption(_ct.NorthSlowHourglass.X), - _ => SafeOffsetFangOther(_hourglass.NumCasts, _ct.NorthSlowHourglass.X) + P4CrystallizeTime.Mechanic.ClawAir => clawSide != 0 ? HintClawAir(clawSide, _hourglass.NumCasts, northSlowSide) : default, + P4CrystallizeTime.Mechanic.ClawBlizzard => clawSide != 0 ? HintClawBlizzard(clawSide, _hourglass.NumCasts, northSlowSide) : default, + P4CrystallizeTime.Mechanic.FangEruption => HintFangEruption(northSlowSide, _hourglass.NumCasts), + _ => HintFangOther(_hourglass.NumCasts, northSlowSide) }; } - private WDir SafeOffsetClawAir(int side, int numHourglassesDone, float northSlowX) + private (WDir offset, Hint hint) HintClawAir(int clawSide, int numHourglassesDone, int northSlowSide) { if (numHourglassesDone < 2) - return 19 * (side * 40).Degrees().ToDirection(); // dodge first hourglass by the south side + return (SafeOffsetDodgeFirstHourglassSouth(clawSide), Hint.SafespotRough | Hint.Maelstrom); // dodge first hourglass by the south side if (!KnockbacksDone) - return 19 * (side * 30).Degrees().ToDirection(); // preposition to knock party across - if (numHourglassesDone < 4) - return 19 * (side * 20).Degrees().ToDirection(); // dodge second hourglass; note that player on the slow side can't really boop head earlier anyway + return (SafeOffsetPreKnockbackSouth(clawSide, 19), Hint.SafespotPrecise); // preposition to knock party across + if (numHourglassesDone < 4 && clawSide == northSlowSide) + return (SafeOffsetDodgeSecondHourglassSouth(clawSide), Hint.SafespotRough | Hint.Maelstrom); // dodge second hourglass; note that player on the slow side can already go intercept the head // by now, blizzards have booped their heads, so now it's our turn - var head = _heads?.FindHead(side); + var head = _heads?.FindHead(clawSide); if (head != null) - return head.Position - Module.Center; + { + var headOff = head.Position - Arena.Center; + var headDir = Angle.FromDirection(headOff); + return ((clawSide > 0 ? (headDir.Deg > 45) : (headDir.Deg < -45)) ? SafeOffsetSecondHeadBait(clawSide) : headOff, Hint.SafespotPrecise); + } // head is done, so dodge between last two hourglasses - return 6 * (northSlowX > 0 ? 30 : -30).Degrees().ToDirection(); + return (SafeOffsetChillSouth(northSlowSide), Hint.Maelstrom | Hint.Heads | Hint.Mid); } - private WDir SafeOffsetClawBlizzard(int side, int numHourglassesDone, float northSlowX) + private (WDir offset, Hint hint) HintClawBlizzard(int clawSide, int numHourglassesDone, int northSlowSide) { - var head = _heads?.FindHead(side); - if (head != null && P4CrystallizeTimeDragonHead.NumHeadHits(head) == 0) - return (head.Position - Module.Center).Length() * (side * 90).Degrees().ToDirection(); // intercept first head at E/W cardinal - var shareDarknessStack = side * northSlowX > 0; - if (shareDarknessStack) - return SafeOffsetFangEruption(northSlowX); // go stack with eruption after intercepting head - // dodge hourglasses - return numHourglassesDone < 4 ? 19 * (side * 80).Degrees().ToDirection() : SafeOffsetFinalNonAir(northSlowX); + if (P4CrystallizeTimeDragonHead.NumHeadHits(_heads?.FindHead(clawSide)) == 0) + return (SafeOffsetFirstHeadBait(clawSide), Hint.SafespotPrecise); // intercept first head at E/W cardinal + if (clawSide == northSlowSide) + return HintFangEruption(northSlowSide, numHourglassesDone); // go stack with eruption after intercepting head + // non-eruption side - dodge second hourglass, but then immediately dodge head boop + if (numHourglassesDone < 4) + return (SafeOffsetDodgeSecondHourglassEW(clawSide), Hint.SafespotRough | Hint.Maelstrom); // dodge second hourglass + // dodge last hourglass and head + return (SafeOffsetChillNorth(-northSlowSide), Hint.Maelstrom | Hint.Heads | Hint.Mid); } - private WDir SafeOffsetFangEruption(float northSlowX) => !DarknessDone ? SafeOffsetDarknessStack(northSlowX) : SafeOffsetFinalNonAir(northSlowX); + private (WDir offset, Hint hint) HintFangEruption(int northSlowSide, int numHourglassesDone) + { + if (!DarknessDone) + return (SafeOffsetDarknessStack(northSlowSide), Hint.SafespotRough | Hint.Heads | (numHourglassesDone < 4 ? Hint.Maelstrom : Hint.None)); + return (SafeOffsetChillNorth(-northSlowSide), Hint.Maelstrom | Hint.Mid); + } - private WDir SafeOffsetFangOther(int numHourglassesDone, float northSlowX) + private (WDir offset, Hint hint) HintFangOther(int numHourglassesDone, int northSlowSide) { if (numHourglassesDone < 2) - return 19 * (northSlowX > 0 ? -40 : 40).Degrees().ToDirection(); // dodge first hourglass by the south side + return (SafeOffsetDodgeFirstHourglassSouth(-northSlowSide), Hint.SafespotRough | Hint.Maelstrom); // dodge first hourglass by the south side if (!KnockbacksDone) - return 17 * (northSlowX > 0 ? -30 : 30).Degrees().ToDirection(); // preposition to knockback across arena + return (SafeOffsetPreKnockbackSouth(-northSlowSide, 17), Hint.Knockback); // preposition to knockback across arena // from now on move together with eruption - return SafeOffsetFangEruption(northSlowX); + return HintFangEruption(northSlowSide, numHourglassesDone); } - - private static WDir SafeOffsetDarknessStack(float northSlowX) => 19 * (northSlowX > 0 ? 140 : -140).Degrees().ToDirection(); - private static WDir SafeOffsetFinalNonAir(float northSlowX) => 6 * (northSlowX > 0 ? -150 : 150).Degrees().ToDirection(); } -// TODO: better positioning hints -class P4CrystallizeTimeRewind(BossModule module) : BossComponent(module) +class P4CrystallizeTimeRewind(BossModule module) : Components.Knockback(module) { public bool RewindDone; public bool ReturnDone; private readonly P4CrystallizeTime? _ct = module.FindComponent(); private readonly P4CrystallizeTimeTidalLight? _exalines = module.FindComponent(); + public override IEnumerable Sources(int slot, Actor actor) + { + if (!RewindDone && _ct != null && _exalines != null && _ct.Cleansed[slot]) + foreach (var s in _exalines.StartingPositions) + yield return new(s.pos, 20, Direction: s.dir, Kind: Kind.DirForward); + } + public override void AddHints(int slot, Actor actor, TextHints hints) { + base.AddHints(slot, actor, hints); if (!RewindDone && _ct != null && _exalines != null && _ct.Cleansed[slot]) { - var players = Raid.WithoutSlot(false, true, true).ToList(); + var players = Raid.WithoutSlot(false, true, true); players.SortBy(p => p.Position.X); - var xOrder = players.IndexOf(actor); + var xOrder = Array.IndexOf(players, actor); players.SortBy(p => p.Position.Z); - var zOrder = players.IndexOf(actor); + var zOrder = Array.IndexOf(players, actor); if (xOrder >= 0 && zOrder >= 0) { - if (_exalines.StartingOffset.X > 0) - xOrder = players.Count - 1 - xOrder; - if (_exalines.StartingOffset.Z > 0) - zOrder = players.Count - 1 - zOrder; + if (_exalines.StartingOffsetSum.X > 0) + xOrder = players.Length - 1 - xOrder; + if (_exalines.StartingOffsetSum.Z > 0) + zOrder = players.Length - 1 - zOrder; var isFirst = xOrder == 0 || zOrder == 0; var isTank = actor.Role == Role.Tank; @@ -457,19 +566,30 @@ public override void AddHints(int slot, Actor actor, TextHints hints) if (isFirstX == isFirstZ) hints.Add("Position in group properly!"); } + } + } - if (KnockbackSpots(actor.Position).Any(p => !Module.Bounds.Contains(p - Arena.Center))) - hints.Add("About to be knocked into wall!"); + public override void AddAIHints(int slot, Actor actor, PartyRolesConfig.Assignment assignment, AIHints hints) + { + if (!RewindDone && _ct != null && _exalines != null && _ct.Cleansed[slot]) + { + var midpoint = SafeCorner(); + hints.GoalZones.Add(hints.GoalProximity(midpoint, 6, 0.5f)); + var destPoint = midpoint + AssignedPositionOffset(actor, assignment); + hints.GoalZones.Add(hints.GoalProximity(destPoint, 1, 1)); } } public override void DrawArenaForeground(int pcSlot, Actor pc) { - if (!RewindDone && _ct != null && _exalines != null && _ct.Cleansed[pcSlot]) + base.DrawArenaForeground(pcSlot, pc); + if (!RewindDone && _exalines != null) { - var vertices = KnockbackSpots(pc.Position); - Arena.AddQuad(pc.Position, vertices[0], vertices[2], vertices[1], Colors.Danger); - Arena.AddCircle(Arena.Center + 0.5f * _exalines.StartingOffset, 1, Colors.Safe); + var midpoint = SafeCorner(); + Arena.AddCircle(midpoint, 1, Colors.Danger); + var offset = AssignedPositionOffset(pc, Service.Config.Get()[Module.Raid.Members[pcSlot].ContentId]); + if (offset != default) + Arena.AddCircle(midpoint + offset, 1, Colors.Safe); } } @@ -486,17 +606,20 @@ public override void OnStatusGain(Actor actor, ActorStatus status) } } - private List KnockbackSpots(WPos starting) + private WPos SafeCorner() => _exalines != null ? Arena.Center + 0.5f * _exalines.StartingOffsetSum : default; + + private WDir AssignedPositionOffset(Actor actor, PartyRolesConfig.Assignment assignment) { - List list = new(3); - if (_exalines != null) - { - var dx = _exalines.StartingOffset.X > 0 ? -20 : +20; - var dz = _exalines.StartingOffset.Z > 0 ? -20 : +20; - list.Add(starting + new WDir(dx, 0)); - list.Add(starting + new WDir(0, dz)); - list.Add(starting + new WDir(dx, dz)); - } - return list; + if (_exalines == null || assignment == PartyRolesConfig.Assignment.Unassigned) + return default; + // TODO: make configurable?.. + var isLeft = assignment is PartyRolesConfig.Assignment.MT or PartyRolesConfig.Assignment.H1 or PartyRolesConfig.Assignment.M1 or PartyRolesConfig.Assignment.R1; + var offDir = (Angle.FromDirection(_exalines.StartingOffsetSum) + (isLeft ? 45 : -45).Degrees()).ToDirection(); + var normDir = isLeft ? offDir.OrthoL() : offDir.OrthoR(); + var (offX, offY) = actor.Role == Role.Tank ? (4, 1) : (2, 3); + return offX * offDir + offY * normDir; } } + +// TODO: custom preposition ai hints +class P4CrystallizeTimeSpiritTaker(BossModule module) : SpiritTaker(module); diff --git a/BossMod/Modules/Dawntrail/Ultimate/FRU/P4DarklitDragonsong.cs b/BossMod/Modules/Dawntrail/Ultimate/FRU/P4DarklitDragonsong.cs index 8ab7c6cd59..2220b1376c 100644 --- a/BossMod/Modules/Dawntrail/Ultimate/FRU/P4DarklitDragonsong.cs +++ b/BossMod/Modules/Dawntrail/Ultimate/FRU/P4DarklitDragonsong.cs @@ -64,21 +64,20 @@ private void InitAssignments() AssignS.Set(Raid.FindSlot(t.from.InstanceID)); } - // remaining assignments (N/S for baits, E/W for everyone) in prio order - int numAssignedSoakers = 0, numAssignedBaits = 0; + // E/W assignments for tower soakers + AssignE.Set(SelectEastTowerSoaker(TowerSoakers & AssignS, playerPrios)); + AssignE.Set(SelectEastTowerSoaker(TowerSoakers & ~AssignS, playerPrios)); + + // assignments for baiters in prio order + int numAssigned = 0; foreach (var slot in ccwOrderSlots) { - if (TowerSoakers[slot]) - { - // first and last prio go E - AssignE[slot] = numAssignedSoakers++ is 0 or 3; - } - else + if (!TowerSoakers[slot]) { // first and last go N, last two go E - AssignS[slot] = numAssignedBaits is 1 or 2; - AssignE[slot] = numAssignedBaits >= 2; - ++numAssignedBaits; + AssignS[slot] = numAssigned is 1 or 2; + AssignE[slot] = numAssigned >= 2; + ++numAssigned; } } @@ -93,34 +92,59 @@ private void InitAssignments() AssignS ^= flexMask; } } + + private int SelectEastTowerSoaker(BitMask soakers, ReadOnlySpan prios) + { + if (soakers.NumSetBits() != 2) + return -1; + var s1 = soakers.LowestSetBit(); + var s2 = soakers.HighestSetBit(); + var p1 = prios[s1]; + var p2 = prios[s2]; + return p1 < 4 && p2 < 4 ? (p1 < p2 ? s1 : s2) // both 'western' (one is anchor) - lower (more cw) is east + : p1 >= 4 && p2 >= 4 ? (p1 > p2 ? s1 : s2) // both 'eastern' (neither is anchor) - higher (more cw) is east + : p1 < 4 ? s2 : s1; // whoever is more eastern + } } class P4DarklitDragonsongBrightHunger(BossModule module) : Components.GenericTowers(module, ActionID.MakeSpell(AID.BrightHunger)) { + private readonly P4DarklitDragonsong? _darklit = module.FindComponent(); private int _numTethers; + private const float TowerOffset = 8; + + public override void AddAIHints(int slot, Actor actor, PartyRolesConfig.Assignment assignment, AIHints hints) + { + if (_darklit == null || Towers.Count == 0 || _darklit.AssignS.None() || !_darklit.TowerSoakers[slot]) + return; // we only provide hints for soakers + + // stay on the far side of assigned tower, on the correct E/W side + var towerPos = Module.Center + new WDir(0, _darklit.AssignS[slot] ? +TowerOffset : -TowerOffset); + hints.AddForbiddenZone(ShapeDistance.InvertedCircle(towerPos, 3), Towers[0].Activation); + hints.AddForbiddenZone(ShapeDistance.Circle(Module.Center, TowerOffset), Towers[0].Activation); + hints.AddForbiddenZone(ShapeDistance.HalfPlane(Module.Center, new(_darklit.AssignE[slot] ? +1 : -1, 0)), Towers[0].Activation); + } + public override void OnTethered(Actor source, ActorTetherInfo tether) { - if (tether.ID == (uint)TetherID.LightRampantChains && ++_numTethers == 4) + if (tether.ID == (uint)TetherID.LightRampantChains && ++_numTethers == 4 && _darklit != null) { - var assignments = Module.FindComponent(); - if (assignments != null) - { - var allowedN = assignments.TowerSoakers & ~assignments.AssignS; - var allowedS = assignments.TowerSoakers & assignments.AssignS; - if (assignments.AssignS.None()) - allowedN = allowedS = assignments.TowerSoakers; // no assignments, just mark both towers as good - - var towerOffset = new WDir(0, 8); - Towers.Add(new(Arena.Center - towerOffset, 4, 4, 4, new BitMask(0xFF) ^ allowedN, WorldState.FutureTime(10.4f))); - Towers.Add(new(Arena.Center + towerOffset, 4, 4, 4, new BitMask(0xFF) ^ allowedS, WorldState.FutureTime(10.4f))); - } + var allowedN = _darklit.TowerSoakers & ~_darklit.AssignS; + var allowedS = _darklit.TowerSoakers & _darklit.AssignS; + if (_darklit.AssignS.None()) + allowedN = allowedS = _darklit.TowerSoakers; // no assignments, just mark both towers as good + + var towerOffset = new WDir(0, TowerOffset); + Towers.Add(new(Arena.Center - towerOffset, 4, 2, 2, new BitMask(0xFF) ^ allowedN, WorldState.FutureTime(10.4f))); + Towers.Add(new(Arena.Center + towerOffset, 4, 2, 2, new BitMask(0xFF) ^ allowedS, WorldState.FutureTime(10.4f))); } } } class P4DarklitDragonsongPathOfLight(BossModule module) : Components.GenericBaitAway(module, ActionID.MakeSpell(AID.PathOfLightAOE)) { + private readonly P4DarklitDragonsong? _darklit = module.FindComponent(); private Actor? _source; private DateTime _activation; @@ -157,7 +181,15 @@ public override void AddHints(int slot, Actor actor, TextHints hints) hints.Add("GTFO from baited cone!"); } - public override void AddAIHints(int slot, Actor actor, PartyRolesConfig.Assignment assignment, AIHints hints) { } + public override void AddAIHints(int slot, Actor actor, PartyRolesConfig.Assignment assignment, AIHints hints) + { + if (_darklit == null || _source == null || _darklit.AssignS.None() || ForbiddenPlayers[slot]) + return; // we only provide hints for baiters + + // do not clip either tower (it's visible at half-angle = asin(4/8) = 30) or each other + var dir = (_darklit.AssignE[slot] ? +1 : -1) * (_darklit.AssignS[slot] ? 60 : 120).Degrees(); + hints.AddForbiddenZone(ShapeDistance.PrecisePosition(Module.Center + 4 * dir.ToDirection(), new(0, 1), Arena.Bounds.MapResolution, actor.Position, 0.1f), _activation); + } public override void OnCastStarted(Actor caster, ActorCastInfo spell) { @@ -175,29 +207,100 @@ public override void OnTethered(Actor source, ActorTetherInfo tether) } } +class P4DarklitDragonsongSpiritTaker(BossModule module) : SpiritTaker(module) +{ + //private readonly P4DarklitDragonsong? _darklit = module.FindComponent(); + + public override void AddAIHints(int slot, Actor actor, PartyRolesConfig.Assignment assignment, AIHints hints) + { + //if (_darklit != null && _darklit.AssignS.Any() && Spreads.Count > 0 && Spreads[0].Activation > WorldState.FutureTime(1)) + //{ + // // preposition + // hints.AddForbiddenZone(ShapeDistance.InvertedCircle(Module.Center + PrepositionOffset(_darklit, slot, actor), 1), Spreads[0].Activation); + //} + //else + { + // just adjust and dodge... + base.AddAIHints(slot, actor, assignment, hints); + } + } + + // TODO: this is very arbitrary... + //private WDir PrepositionOffset(P4DarklitDragonsong darklit, int slot, Actor actor) + //{ + // if (darklit.TowerSoakers[slot]) + // { + // // tower soakers go to fixed spots, skewed not to hit the fragment + // var dir = (darklit.AssignE[slot] ? 1 : -1) * (darklit.AssignS[slot] ? 25 : 135).Degrees(); + // return 9 * dir.ToDirection(); + // } + // else + // { + // // baiting healer goes far west, tank goes near west, northern dd goes center, southern dd goes near east + // var off = actor.Role switch + // { + // Role.Healer => -12, + // Role.Tank => -6, + // _ => darklit.AssignS[slot] ? 6 : 0 + // }; + // return new(off, 0); + // } + //} +} + class P4DarklitDragonsongDarkWater(BossModule module) : Components.UniformStackSpread(module, 6, 0, 4, includeDeadTargets: true) { - public bool ResolveImminent; private readonly P4DarklitDragonsong? _assignments = module.FindComponent(); + private bool _resolveImminent; + + public void Show() + { + _resolveImminent = true; + if (_assignments != null && _assignments.AssignS.Any()) + { + foreach (ref var s in Stacks.AsSpan()) + { + var isSouth = _assignments.AssignS[Raid.FindSlot(s.Target.InstanceID)]; + s.ForbiddenPlayers = _assignments.AssignS ^ new BitMask(isSouth ? 0xFF : 0u); + } + } + } public override void AddHints(int slot, Actor actor, TextHints hints) { - if (ResolveImminent) + if (_resolveImminent) base.AddHints(slot, actor, hints); } - public override void OnStatusGain(Actor actor, ActorStatus status) + public override void AddAIHints(int slot, Actor actor, PartyRolesConfig.Assignment assignment, AIHints hints) { - if ((SID)status.ID == SID.SpellInWaitingDarkWater) + if (!_resolveImminent || _assignments == null || _assignments.AssignS.None()) + return; + + var stack = Stacks.FindIndex(s => !s.ForbiddenPlayers[slot]); + if (stack < 0) + return; + + if (Stacks[stack].Target == actor || Stacks[stack].Activation > WorldState.FutureTime(1.5f)) { - BitMask forbidden = default; - if (_assignments != null && _assignments.AssignS.Any()) - { - var isSouth = _assignments.AssignS[Raid.FindSlot(actor.InstanceID)]; - forbidden = _assignments.AssignS ^ new BitMask(isSouth ? 0xFF : 0u); - } - AddStack(actor, status.ExpireAt, forbidden); + // preposition + var off = 9 * (_assignments.AssignS[slot] ? 20 : 130).Degrees().ToDirection(); + var p1 = ShapeDistance.Circle(Arena.Center + off, 1); + var p2 = ShapeDistance.Circle(Arena.Center + new WDir(-off.X, off.Z), 1); + hints.AddForbiddenZone(p => -MathF.Min(p1(p), p2(p)), Stacks[stack].Activation); } + else + { + // otherwise just stack tightly with our target, and avoid other + foreach (var s in Stacks) + hints.AddForbiddenZone(s.ForbiddenPlayers[slot] ? ShapeDistance.Circle(s.Target.Position, s.Radius) : ShapeDistance.InvertedCircle(s.Target.Position, 2), s.Activation); + } + } + + public override void OnStatusGain(Actor actor, ActorStatus status) + { + if ((SID)status.ID == SID.SpellInWaitingDarkWater) + AddStack(actor, status.ExpireAt); } public override void OnEventCast(Actor caster, ActorCastEvent spell) @@ -209,6 +312,7 @@ public override void OnEventCast(Actor caster, ActorCastEvent spell) class P4SomberDance(BossModule module) : Components.GenericBaitAway(module, centerAtTarget: true) { + private readonly FRUConfig _config = Service.Config.Get(); private Actor? _source; private DateTime _activation; @@ -225,6 +329,18 @@ public override void Update() CurrentBaits.Add(new(_source, target, _shape, _activation)); } + public override void AddAIHints(int slot, Actor actor, PartyRolesConfig.Assignment assignment, AIHints hints) + { + base.AddAIHints(slot, actor, assignment, hints); + + if (assignment == (_config.P4SomberDanceOTBait ? PartyRolesConfig.Assignment.OT : PartyRolesConfig.Assignment.MT)) + { + // go far east/west + var pos = Module.Center + new WDir(actor.Position.X > Module.Center.X ? 19 : -19, 0); + hints.AddForbiddenZone(ShapeDistance.InvertedCircle(pos, 1), _activation); + } + } + public override void OnCastStarted(Actor caster, ActorCastInfo spell) { if ((AID)spell.Action.ID == AID.SomberDance) diff --git a/BossMod/Modules/Dawntrail/Ultimate/FRU/P5AkhMorn.cs b/BossMod/Modules/Dawntrail/Ultimate/FRU/P5AkhMorn.cs index e2f72e9dcc..87435b3c53 100644 --- a/BossMod/Modules/Dawntrail/Ultimate/FRU/P5AkhMorn.cs +++ b/BossMod/Modules/Dawntrail/Ultimate/FRU/P5AkhMorn.cs @@ -3,6 +3,9 @@ class P5AkhMorn(BossModule module) : Components.UniformStackSpread(module, 4, 0, 4) { public Actor? Source; + private readonly FRUConfig _config = Service.Config.Get(); + private readonly P5FulgentBlade? _fulgent = module.FindComponent(); + private BitMask _leftSoakers; private DateTime _activation; public override void Update() @@ -33,12 +36,23 @@ public override void Update() } } + public override void AddAIHints(int slot, Actor actor, PartyRolesConfig.Assignment assignment, AIHints hints) + { + if (Source != null && _leftSoakers.Any() && _fulgent?.NumCasts > 6) + { + var dir = Source.Rotation + (_leftSoakers[slot] ? -45 : 45).Degrees(); // note that left group go to boss right! + hints.AddForbiddenZone(ShapeDistance.InvertedCircle(Source.Position + 5 * dir.ToDirection(), 1), _activation); + } + } + public override void OnCastStarted(Actor caster, ActorCastInfo spell) { if ((AID)spell.Action.ID == AID.AkhMornPandora) { Source = caster; _activation = Module.CastFinishAt(spell, 0.1f); + foreach (var (slot, group) in _config.P5AkhMornAssignments.Resolve(Raid)) + _leftSoakers[slot] = group == 0; } } diff --git a/BossMod/Modules/Dawntrail/Ultimate/FRU/P5FulgentBlade.cs b/BossMod/Modules/Dawntrail/Ultimate/FRU/P5FulgentBlade.cs index bade6798ea..f9d97b6421 100644 --- a/BossMod/Modules/Dawntrail/Ultimate/FRU/P5FulgentBlade.cs +++ b/BossMod/Modules/Dawntrail/Ultimate/FRU/P5FulgentBlade.cs @@ -2,7 +2,8 @@ class P5FulgentBlade : Components.Exaflare { - private readonly List _lines = []; + private readonly List<(Actor actor, WDir dir)> _lines = []; // before first line starts, it is sorted either in correct or reversed order - i don't think we can predict it?.. + private WDir _initialSafespot; private DateTime _nextBundle; public P5FulgentBlade(BossModule module) : base(module, new AOEShapeRect(5, 40)) @@ -10,16 +11,28 @@ class P5FulgentBlade : Components.Exaflare ImminentColor = Colors.AOE; } - public override void DrawArenaForeground(int pcSlot, Actor pc) + public override void AddAIHints(int slot, Actor actor, PartyRolesConfig.Assignment assignment, AIHints hints) { - //// debug - //foreach (var l in _lines) + base.AddAIHints(slot, actor, assignment, hints); + // add an extra hint to move to safe spot (TODO: reconsider? this can fuck up positionals for melee etc) + if (Lines.Count != 0 && NumCasts <= 6 && SafeSpot() is var safespot && safespot != default) + hints.AddForbiddenZone(ShapeDistance.InvertedCircle(safespot, 1), DateTime.MaxValue); + //if (Lines.Count > 0 && NumCasts <= 6 && _lines.Count == 6) //{ - // var d = (Module.Center - l.Position).Normalized().OrthoL(); - // Arena.AddLine(l.Position - 50 * d, l.Position + 50 * d, ArenaColor.Object); + // var shape = NumCasts switch + // { + // < 2 => ShapeDistance.InvertedCircle(SafeSpot(), 1), + // < 4 => LineIntersection(0, 1), + // < 6 => LineIntersection(2, 3), + // _ => LineIntersection(4, 5), + // }; + // hints.AddForbiddenZone(shape, DateTime.MaxValue); //} + } - var safespot = SafeSpots().FirstOrDefault(); + public override void DrawArenaForeground(int pcSlot, Actor pc) + { + var safespot = SafeSpot(); if (safespot != default) Arena.AddCircle(safespot, 1, Colors.Safe); } @@ -28,9 +41,14 @@ public override void OnActorCreated(Actor actor) { if ((OID)actor.OID == OID.FulgentBladeLine) { - _lines.Add(actor); - //if (_lines.Count == 6) - // _lines.SortByReverse(l => (l.Position - Module.Center).LengthSq()); // TODO: this isn't right, there are lines with same distance... + var dir = (actor.Position - Arena.Center).Normalized(); + _lines.Add((actor, dir)); + _initialSafespot -= dir; + if (_lines.Count == 6) + { + // sort in arbitrary order (say, CW), until we know better + _lines.SortBy(l => l.dir.Cross(_initialSafespot)); + } } } @@ -38,8 +56,11 @@ public override void OnCastStarted(Actor caster, ActorCastInfo spell) { if ((AID)spell.Action.ID is AID.PathOfLightFirst or AID.PathOfDarknessFirst) { + if (Lines.Count == 0) + UpdateOrder(caster.Position); // first line - we should have all 6 line actors already created, and it should match position of first or last two + var dir = spell.Rotation.ToDirection(); - var distanceToBorder = Intersect.RayCircle(caster.Position - Module.Center, dir, 22); + var distanceToBorder = Intersect.RayCircle(caster.Position - Arena.Center, dir, 22); Lines.Add(new() { Next = spell.LocXZ, Advance = 5 * dir, Rotation = spell.Rotation, NextExplosion = Module.CastFinishAt(spell), TimeToMove = 2, ExplosionsLeft = (int)(distanceToBorder / 5) + 1, MaxShownExplosions = 1 }); } } @@ -54,7 +75,7 @@ public override void OnEventCast(Actor caster, ActorCastEvent spell) _nextBundle = WorldState.FutureTime(1); } - var index = Lines.FindIndex(item => item.Next.AlmostEqual(caster.Position, 1) && item.Rotation.AlmostEqual(spell.Rotation, 0.1f)); + int index = Lines.FindIndex(item => item.Next.AlmostEqual(caster.Position, 1) && item.Rotation.AlmostEqual(spell.Rotation, 0.1f)); if (index == -1) { ReportError($"Failed to find entry for {caster.InstanceID:X}"); @@ -67,35 +88,64 @@ public override void OnEventCast(Actor caster, ActorCastEvent spell) } } - private IEnumerable SafeSpots() + private void UpdateOrder(WPos firstPos) { if (_lines.Count != 6) - yield break; - //if (NumCasts < 2) - // yield return SafeSpot(_lines[0], _lines[1]); - //if (NumCasts < 4) - // yield return SafeSpot(_lines[2], _lines[3]); - //if (NumCasts < 6) - // yield return SafeSpot(_lines[4], _lines[5]); - - if (NumCasts == 0) { - WDir avgOff = default; - foreach (var l in _lines) - avgOff += (l.Position - Arena.Center).Normalized(); - yield return Arena.Center - 5 * avgOff; + ReportError($"Unexpected number of lines at cast start: {_lines.Count}"); + } + else if (_lines[^1].actor.Position.AlmostEqual(firstPos, 1) || _lines[^2].actor.Position.AlmostEqual(firstPos, 1)) + { + _lines.Reverse(); // we guessed incorrectly, update the order } + else if (!_lines[0].actor.Position.AlmostEqual(firstPos, 1) && !_lines[1].actor.Position.AlmostEqual(firstPos, 1)) + { + ReportError($"First cast at {firstPos} does not correspond to any of the first/last two lines"); + } + } + + private WPos SafeSpot() + { + if (_lines.Count != 6) + return default; // not enough data + + if (Lines.Count == 0) + { + // we don't yet know the direction, so just give the approximate safespot (average direction of missing lines) + return Module.Center + 5 * _initialSafespot; + } + + return NumCasts switch + { + < 2 => SafeSpot(0, 1, 11), // prepare to dodge into third exaline of the first pair + 2 => SafeSpot(0, 1, 9), // dodge into third exaline of the first pair + 3 => SafeSpot(2, 3, 11), // prepare to dodge into third exaline of the second pair + 4 => SafeSpot(2, 3, 9), // dodge into third exaline of the second pair + 5 => SafeSpot(4, 5, 11), // prepare to dodge into third exaline of the third pair + _ => SafeSpot(4, 5, 9), // dodge into third exaline of the third pair + }; + } + + private WPos SafeSpot(int i1, int i2, float distance) + { + var l1 = _lines[i1]; + var l2 = _lines[i2]; + var p1 = l1.actor.Position - distance * l1.dir; + var p2 = l2.actor.Position - distance * l2.dir; + var d1 = l1.dir.OrthoL(); + var d2 = l2.dir.OrthoL(); + p1 -= 50 * d1; // rays are 0 to infinity, oh well + p2 -= 50 * d2; + var t = Intersect.RayLine(p1, d1, p2, d2); + return t is > 0 and < 100 ? p1 + t * d1 : default; } - //private WPos SafeSpot(Actor line1, Actor line2) + //private Func LineIntersection(int i1, int i2, float distance = 5, float cushion = 1) //{ - // var d1 = (Module.Center - line1.Position).Normalized(); - // var d2 = (Module.Center - line2.Position).Normalized(); - // var n1 = d1.OrthoL(); - // var n2 = d2.OrthoL(); - // var p1 = line1.Position + 11 * d1 - 50 * n1; - // var p2 = line2.Position + 11 * d2 - 50 * n2; - // var t = Intersect.RayLine(p1, n1, p2, n2); - // return t is > 0 and < 100 ? p1 + t * n1 : default; + // var l1 = _lines[i1]; + // var l2 = _lines[i2]; + // var r1 = ShapeDistance.Rect(l1.actor.Position - distance * l1.dir, -l1.dir, 5 - cushion, -cushion, 40); + // var r2 = ShapeDistance.Rect(l2.actor.Position - distance * l2.dir, -l2.dir, 5 - cushion, -cushion, 40); + // return p => -Math.Max(r1(p), r2(p)); //} } diff --git a/BossMod/Modules/Dawntrail/Ultimate/FRU/P5ParadiseRegained.cs b/BossMod/Modules/Dawntrail/Ultimate/FRU/P5ParadiseRegained.cs index 022c399cef..b9561b03f1 100644 --- a/BossMod/Modules/Dawntrail/Ultimate/FRU/P5ParadiseRegained.cs +++ b/BossMod/Modules/Dawntrail/Ultimate/FRU/P5ParadiseRegained.cs @@ -2,6 +2,11 @@ class P5ParadiseRegainedTowers(BossModule module) : Components.GenericTowers(module, ActionID.MakeSpell(AID.WingsDarkAndLightExplosion)) { + public override void AddAIHints(int slot, Actor actor, PartyRolesConfig.Assignment assignment, AIHints hints) + { + // TODO: implement hints for non-tanks here... + } + public override void OnEventEnvControl(byte index, uint state) { if (index is >= 51 and <= 53 && state == 0x00020001) @@ -80,6 +85,15 @@ public override void AddHints(int slot, Actor actor, TextHints hints) base.AddHints(slot, actor, hints); } + public override void AddAIHints(int slot, Actor actor, PartyRolesConfig.Assignment assignment, AIHints hints) + { + if (!ForbiddenPlayers[slot]) + { + // just go to the next safespot + hints.AddForbiddenZone(ShapeDistance.InvertedCircle(Module.Center + SafeOffset(slot, actor), 1)); + } + } + public override void DrawArenaForeground(int pcSlot, Actor pc) { base.DrawArenaForeground(pcSlot, pc); @@ -103,7 +117,7 @@ public override void OnCastStarted(Actor caster, ActorCastInfo spell) _source = caster; _firstTarget = WorldState.Actors.Find(caster.TargetID); _curCleave = shape; - _activation = Module.CastFinishAt(spell, 0.5f); + _activation = Module.CastFinishAt(spell, 0.3f); _tetherClosest = closest; } } diff --git a/BossMod/Modules/Dawntrail/Ultimate/FRU/P5PolarizingStrikes.cs b/BossMod/Modules/Dawntrail/Ultimate/FRU/P5PolarizingStrikes.cs new file mode 100644 index 0000000000..e3e87aef05 --- /dev/null +++ b/BossMod/Modules/Dawntrail/Ultimate/FRU/P5PolarizingStrikes.cs @@ -0,0 +1,143 @@ +namespace BossMod.Dawntrail.Ultimate.FRU; + +class P5PolarizingStrikes(BossModule module) : Components.GenericAOEs(module) +{ + private readonly FRUConfig _config = Service.Config.Get(); + private readonly List _aoes = []; // 'afterglow' + private readonly Actor?[] _baiters = [null, null]; // light/left, dark/right + private readonly BitMask[] _forbidden = [default, default]; + private Actor? _source; + private bool _baitsActive; + + private static readonly AOEShapeRect _shape = new(100, 3); + + public override IEnumerable ActiveAOEs(int slot, Actor actor) => _aoes; + + public override void Update() + { + _baiters[0] = _baiters[1] = null; + if (_source != null && _aoes.Count == 0 && _baitsActive) + { + var left = _source.Rotation.ToDirection().OrthoL(); + float distL = float.MaxValue, distR = float.MaxValue; + foreach (var p in Raid.WithoutSlot(false, true, true)) + { + var off = p.Position - _source.Position; + var side = left.Dot(off) > 0; + ref var target = ref _baiters[side ? 0 : 1]; + ref var dist = ref side ? ref distL : ref distR; + var d = off.LengthSq(); + if (d < dist) + { + dist = d; + target = p; + } + } + } + } + + public override void AddHints(int slot, Actor actor, TextHints hints) + { + if (_source != null && _aoes.Count == 0 && _baitsActive) + { + if (_baiters.Contains(actor) && (_forbidden[0] | _forbidden[1])[slot]) + hints.Add("Hide behind party!"); + + var inLight = InAOE(_source, _baiters[0], actor); + var inDark = InAOE(_source, _baiters[1], actor); + if (inLight == inDark) + hints.Add("Stay in group!"); + else if (_forbidden[inLight ? 0 : 1][slot]) + hints.Add("Go to correct group!"); + } + else + { + base.AddHints(slot, actor, hints); + } + } + + public override void AddAIHints(int slot, Actor actor, PartyRolesConfig.Assignment assignment, AIHints hints) + { + if (_source == null) + return; + if (_aoes.Count == 0 && _baitsActive) + { + var role = _config.P5PolarizingStrikesAssignments[assignment]; + if (role >= 0) + { + var left = role < 4; + var order = role & 3; + var currentBaitOrder = NumCasts >> 2; + var front = currentBaitOrder == order; + if (currentBaitOrder > order) + left ^= true; + var distance = front ? 4 : 7; + var dir = _source.Rotation + (left ? 135 : -135).Degrees(); + hints.AddForbiddenZone(ShapeDistance.InvertedCircle(_source.Position + distance * dir.ToDirection(), 1)); + } + } + else + { + // avoid aftershock aoe by moving behind boss + base.AddAIHints(slot, actor, assignment, hints); + hints.AddForbiddenZone(ShapeDistance.InvertedCone(_source.Position, 100, _source.Rotation + 180.Degrees(), 45.Degrees()), DateTime.MaxValue); + } + } + + public override void DrawArenaForeground(int pcSlot, Actor pc) + { + if (_source == null || !_baitsActive) + return; + foreach (var (baiter, forbidden) in _baiters.Zip(_forbidden)) + if (baiter != null) + _shape.Outline(Arena, _source.Position, Angle.FromDirection(baiter.Position - _source.Position), forbidden[pcSlot] ? Colors.Danger : Colors.Safe); + } + + public override void OnCastStarted(Actor caster, ActorCastInfo spell) + { + switch ((AID)spell.Action.ID) + { + case AID.PolarizingStrikes: + case AID.PolarizingPaths: + _source = caster; + _baitsActive = true; + break; + case AID.CruelPathOfLightBait: + case AID.CruelPathOfDarknessBait: + _baitsActive = false; + break; + } + } + + public override void OnEventCast(Actor caster, ActorCastEvent spell) + { + switch ((AID)spell.Action.ID) + { + case AID.CruelPathOfLightBait: + case AID.CruelPathOfDarknessBait: + ++NumCasts; + _aoes.Add(new(_shape, caster.Position, spell.Rotation, WorldState.FutureTime(2))); + break; + case AID.CruelPathOfLightAOE: + case AID.CruelPathOfDarknessAOE: + ++NumCasts; + _aoes.Clear(); + break; + } + } + + public override void OnStatusGain(Actor actor, ActorStatus status) + { + switch ((SID)status.ID) + { + case SID.LightResistanceDown: + _forbidden[0].Set(Raid.FindSlot(actor.InstanceID)); + break; + case SID.DarkResistanceDown: + _forbidden[1].Set(Raid.FindSlot(actor.InstanceID)); + break; + } + } + + private bool InAOE(Actor source, Actor? target, Actor player) => target != null && (target == player || _shape.Check(player.Position, source.Position, Angle.FromDirection(target.Position - source.Position))); +} diff --git a/BossMod/Modules/Dawntrail/Ultimate/FRU/SpiritTaker.cs b/BossMod/Modules/Dawntrail/Ultimate/FRU/SpiritTaker.cs index 8bba78b75a..847d4a533e 100644 --- a/BossMod/Modules/Dawntrail/Ultimate/FRU/SpiritTaker.cs +++ b/BossMod/Modules/Dawntrail/Ultimate/FRU/SpiritTaker.cs @@ -1,7 +1,9 @@ namespace BossMod.Dawntrail.Ultimate.FRU; -abstract class SpiritTaker(BossModule module) : Components.UniformStackSpread(module, 0, 5) +abstract class SpiritTaker(BossModule module) : Components.GenericStackSpread(module) { + public const float Radius = 5; + public override void AddAIHints(int slot, Actor actor, PartyRolesConfig.Assignment assignment, AIHints hints) { foreach (var spread in ActiveSpreads.Where(s => s.Target != actor)) @@ -11,7 +13,14 @@ public override void AddAIHints(int slot, Actor actor, PartyRolesConfig.Assignme public override void OnCastStarted(Actor caster, ActorCastInfo spell) { if ((AID)spell.Action.ID == AID.SpiritTaker) - AddSpreads(Raid.WithoutSlot(true, true, true), Module.CastFinishAt(spell, 0.3f)); + { + var activation = Module.CastFinishAt(spell, 0.3f); + foreach (var (i, p) in Raid.WithSlot(true, true, true)) + { + // TODO: i think this is right - we can't clip the entire hitbox of the fragment?.. + Spreads.Add(new(p, Radius + (i < PartyState.MaxPartySize ? 0 : p.HitboxRadius), activation)); + } + } } public override void OnEventCast(Actor caster, ActorCastEvent spell) @@ -20,5 +29,3 @@ public override void OnEventCast(Actor caster, ActorCastEvent spell) Spreads.Clear(); } } - -class DefaultSpiritTaker(BossModule module) : SpiritTaker(module); // TODO: remove... diff --git a/BossMod/Util/ArcList.cs b/BossMod/Util/ArcList.cs index 85ad780ac7..a96f366001 100644 --- a/BossMod/Util/ArcList.cs +++ b/BossMod/Util/ArcList.cs @@ -43,8 +43,18 @@ public void ForbidCircle(WPos origin, float radius) var cos = (oo.LengthSq() + Radius * Radius - radius * radius) / (2 * oo.Length() * Radius); if (cos is <= 1 and >= -1) { - var halfWidth = MathF.Acos(cos); - ForbidArcByLength(center, halfWidth.Radians()); + ForbidArcByLength(center, Angle.Acos(cos)); + } + } + + public void ForbidInverseCircle(WPos origin, float radius) + { + var oo = origin - Center; + var center = Angle.FromDirection(oo); + var cos = (oo.LengthSq() + Radius * Radius - radius * radius) / (2 * oo.Length() * Radius); + if (cos is <= 1 and >= -1) + { + ForbidArcByLength(center + 180.Degrees(), 180.Degrees() - Angle.Acos(cos)); } } @@ -59,6 +69,13 @@ public void ForbidInfiniteRect(WPos origin, Angle dir, float halfWidth) ForbidArc(i2.Item2, i1.Item2); } + public void ForbidInfiniteCone(WPos origin, Angle dir, Angle halfAngle) + { + var min = IntersectLine(origin, (dir - halfAngle).ToDirection()).Item2; + var max = IntersectLine(origin, (dir + halfAngle).ToDirection()).Item2; + ForbidArc(min, max); + } + public IEnumerable<(Angle min, Angle max)> Allowed(Angle cushion) { if (Forbidden.Segments.Count == 0) diff --git a/UIDev/ConfigTest.cs b/UIDev/ConfigTest.cs index 85f2ba61e1..a6fc6fdbf3 100644 --- a/UIDev/ConfigTest.cs +++ b/UIDev/ConfigTest.cs @@ -1,4 +1,5 @@ using BossMod; +using BossMod.Autorotation; using ImGuiNET; namespace UIDev; @@ -8,9 +9,11 @@ class ConfigTest : TestWindow private string _command = ""; private readonly ConfigUI _ui; + public static RotationDatabase? RotationDB; + public ConfigTest() : base("Config", new(400, 400), ImGuiWindowFlags.None) { - _ui = new(Service.Config, new(TimeSpan.TicksPerSecond, "fake"), null, null); + _ui = new(Service.Config, new(TimeSpan.TicksPerSecond, "fake"), null, RotationDB); } protected override void Dispose(bool disposing) diff --git a/UIDev/UITestWindow.cs b/UIDev/UITestWindow.cs index 45cc1c5636..1f858a1016 100644 --- a/UIDev/UITestWindow.cs +++ b/UIDev/UITestWindow.cs @@ -39,6 +39,8 @@ private bool ConfigModified dir = dir.Parent; _rotationDB = new(new(rotationRoot), new(dir!.FullName + defaultPresets)); _replayManager = new(_rotationDB, "."); + + ConfigTest.RotationDB = _rotationDB; } protected override void Dispose(bool disposing)