Skip to content

Commit

Permalink
FRU WIP
Browse files Browse the repository at this point in the history
  • Loading branch information
awgil committed Dec 23, 2024
1 parent 5e63ce4 commit c1a229f
Show file tree
Hide file tree
Showing 6 changed files with 174 additions and 68 deletions.
1 change: 1 addition & 0 deletions BossMod/Data/Actor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ public sealed class Actor(ulong instanceID, uint oid, int spawnIndex, string nam
public ClassCategory ClassCategory => Class.GetClassCategory();
public WPos Position => new(PosRot.X, PosRot.Z);
public WPos PrevPosition => new(PrevPosRot.X, PrevPosRot.Z);
public WDir LastFrameMovement => Position - PrevPosition;
public Angle Rotation => PosRot.W.Radians();
public bool Omnidirectional => Utils.CharacterIsOmnidirectional(OID);
public bool IsDeadOrDestroyed => IsDead || IsDestroyed;
Expand Down
12 changes: 4 additions & 8 deletions BossMod/Modules/Dawntrail/Ultimate/FRU/FRUStates.cs
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,6 @@ private void P2DiamondDust(uint id, float delay)
.ActivateOnEnter<P2FrigidStone>()
.ActivateOnEnter<P2DiamondDustHouseOfLight>()
.ActivateOnEnter<P2DiamondDustSafespots>()
.ExecOnEnter<P2IcicleImpact>(comp => comp.Risky = false) // it's fine to bait stuff into these aoes...
.SetHint(StateMachine.StateHint.DowntimeStart);
Condition(id + 0x30, 5.7f, () => Module.FindComponent<P2AxeKick>()?.NumCasts > 0 || Module.FindComponent<P2ScytheKick>()?.NumCasts > 0, "In/out")
.DeactivateOnExit<P2AxeKick>()
Expand All @@ -232,10 +231,9 @@ private void P2DiamondDust(uint id, float delay)
ComponentCondition<P2FrigidStone>(id + 0x32, 1.6f, comp => comp.NumCasts > 0, "Ice baits")
.DeactivateOnExit<P2FrigidStone>()
.DeactivateOnExit<P2DiamondDustSafespots>();
ComponentCondition<P2IcicleImpact>(id + 0x33, 0.4f, comp => comp.NumCasts > 0, "Ice circle 1")
.ActivateOnEnter<P2HeavenlyStrike>() // activate a bit early, so that it can observe first circle direction
.ExecOnEnter<P2IcicleImpact>(comp => comp.Risky = true);
ComponentCondition<P2IcicleImpact>(id + 0x33, 0.4f, comp => comp.NumCasts > 0, "Ice circle 1");
ComponentCondition<P2HeavenlyStrike>(id + 0x40, 3.9f, comp => comp.NumCasts > 0, "Knockback")
.ActivateOnEnter<P2HeavenlyStrike>()
.ActivateOnEnter<P2FrigidNeedleCircle>()
.ActivateOnEnter<P2FrigidNeedleCross>()
.ActivateOnEnter<P2TwinStillnessSilence>() // show the cone caster early, to simplify finding movement direction...
Expand All @@ -248,16 +246,14 @@ private void P2DiamondDust(uint id, float delay)
.ExecOnEnter<P2FrigidNeedleCross>(comp => comp.Risky = true)
.DeactivateOnExit<P2FrigidNeedleCircle>()
.DeactivateOnExit<P2FrigidNeedleCross>();
ComponentCondition<P2SinboundHoly>(id + 0x60, 1.3f, comp => comp.NumCasts > 0)
.ActivateOnEnter<P2SinboundHolyAIBait>();
ComponentCondition<P2SinboundHoly>(id + 0x60, 1.3f, comp => comp.NumCasts > 0);
ComponentCondition<P2SinboundHoly>(id + 0x70, 4.7f, comp => comp.NumCasts >= 4)
.ActivateOnEnter<P2SinboundHolyVoidzone>()
.ActivateOnEnter<P2ShiningArmor>()
.DeactivateOnExit<P2IcicleImpact>() // last icicle explodes together with first stack
.DeactivateOnExit<P2SinboundHolyAIBait>()
.DeactivateOnExit<P2SinboundHoly>();
ComponentCondition<P2ShiningArmor>(id + 0x80, 3.7f, comp => comp.NumCasts > 0, "Gaze")
.ExecOnEnter<P2TwinStillnessSilence>(comp => comp.EnableAIHints = true)
.ExecOnEnter<P2TwinStillnessSilence>(comp => comp.EnableAIHints())
.DeactivateOnExit<P2ShiningArmor>();
ComponentCondition<P2TwinStillnessSilence>(id + 0x90, 3.0f, comp => comp.AOEs.Count > 0);
ComponentCondition<P2TwinStillnessSilence>(id + 0x91, 3.5f, comp => comp.NumCasts > 0, "Front/back");
Expand Down
204 changes: 149 additions & 55 deletions BossMod/Modules/Dawntrail/Ultimate/FRU/P2DiamondDust.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,49 @@

class P2AxeKick(BossModule module) : Components.SelfTargetedAOEs(module, ActionID.MakeSpell(AID.AxeKick), new AOEShapeCircle(16));
class P2ScytheKick(BossModule module) : Components.SelfTargetedAOEs(module, ActionID.MakeSpell(AID.ScytheKick), new AOEShapeDonut(4, 20));
class P2IcicleImpact(BossModule module) : Components.SelfTargetedAOEs(module, ActionID.MakeSpell(AID.IcicleImpact), new AOEShapeCircle(10));

class P2IcicleImpact(BossModule module) : Components.GenericAOEs(module, ActionID.MakeSpell(AID.IcicleImpact))
{
public readonly List<AOEInstance> AOEs = []; // note: we don't remove finished aoes, since we use them in other components to detect safespots

private static readonly AOEShapeCircle _shape = new(10);

public override IEnumerable<AOEInstance> ActiveAOEs(int slot, Actor actor) => AOEs.Skip(NumCasts);

public override void OnCastStarted(Actor caster, ActorCastInfo spell)
{
if (spell.Action == WatchedAction)
{
// initially all aoes start as non-risky
AOEs.Add(new(_shape, caster.Position, default, Module.CastFinishAt(spell), 0, false));
}
}

public override void OnEventCast(Actor caster, ActorCastEvent spell)
{
switch ((AID)spell.Action.ID)
{
case AID.IcicleImpact:
++NumCasts;
break;
case AID.HouseOfLight:
// after proteans are baited, first two aoes become risky; remaining are still not - stones are supposed to be baited into them
MarkAsRisky(0, Math.Min(2, AOEs.Count));
break;
case AID.FrigidStone:
// after stones are baited, all aoes should be marked as risky
MarkAsRisky(2, AOEs.Count);
break;
}
}

private void MarkAsRisky(int start, int end)
{
for (int i = start; i < end; ++i)
AOEs.Ref(i).Risky = true;
}
}

class P2FrigidNeedleCircle(BossModule module) : Components.SelfTargetedAOEs(module, ActionID.MakeSpell(AID.FrigidNeedleCircle), new AOEShapeCircle(5));
class P2FrigidNeedleCross(BossModule module) : Components.SelfTargetedAOEs(module, ActionID.MakeSpell(AID.FrigidNeedleCross), new AOEShapeCross(40, 2.5f));

Expand Down Expand Up @@ -166,20 +208,18 @@ private void InitIfReady()

class P2HeavenlyStrike(BossModule module) : Components.Knockback(module, ActionID.MakeSpell(AID.HeavenlyStrike))
{
private readonly FRUConfig _config = Service.Config.Get<FRUConfig>();
private readonly WDir[] _safeDirs = new WDir[PartyState.MaxPartySize];
private DateTime _activation;
private readonly WDir[] _safeDirs = BuildSafeDirs(module);
private readonly DateTime _activation = module.WorldState.FutureTime(3.9f);

public override IEnumerable<Source> Sources(int slot, Actor actor)
{
if (_activation != default)
yield return new(Module.Center, 12, _activation);
yield return new(Module.Center, 12, _activation);
}

public override void AddAIHints(int slot, Actor actor, PartyRolesConfig.Assignment assignment, AIHints hints)
{
if (_safeDirs[slot] != default)
hints.AddForbiddenZone(ShapeDistance.InvertedCircle(Module.Center + 6 * _safeDirs[slot], 1), _activation);
hints.AddForbiddenZone(ShapeDistance.PrecisePosition(Module.Center + 6 * _safeDirs[slot], new(1, 0), Module.Bounds.MapResolution, actor.Position, 0.25f), _activation);
}

public override void DrawArenaForeground(int pcSlot, Actor pc)
Expand All @@ -189,26 +229,74 @@ public override void DrawArenaForeground(int pcSlot, Actor pc)
Arena.AddCircle(Module.Center + 18 * _safeDirs[pcSlot], 1, ArenaColor.Safe);
}

public override void OnCastFinished(Actor caster, ActorCastInfo spell)
private static WDir[] BuildSafeDirs(BossModule module)
{
if ((AID)spell.Action.ID == AID.IcicleImpact && _activation == default)
var res = new WDir[PartyState.MaxPartySize];
var icicle = module.FindComponent<P2IcicleImpact>();
if (icicle?.AOEs.Count > 0)
{
_activation = WorldState.FutureTime(3.9f);
var safeDir = (caster.Position - Module.Center).Normalized();
var safeDir = (icicle.AOEs[0].Origin - module.Center).Normalized();
if (safeDir.X > 0.5f || safeDir.Z > 0.8f)
safeDir = -safeDir; // G1
foreach (var (slot, group) in _config.P2DiamondDustKnockbacks.Resolve(Raid))
_safeDirs[slot] = group == 1 ? -safeDir : safeDir;
foreach (var (slot, group) in Service.Config.Get<FRUConfig>().P2DiamondDustKnockbacks.Resolve(module.Raid))
res[slot] = group == 1 ? -safeDir : safeDir;
}
return res;
}
}

class P2SinboundHoly(BossModule module) : Components.UniformStackSpread(module, 6, 0, 4, 4)
{
public int NumCasts;
private DateTime _nextExplosion;
private readonly WDir _destinationDir = CalculateDestination(module);

public override void AddAIHints(int slot, Actor actor, PartyRolesConfig.Assignment assignment, AIHints hints) { } // resolves automatically
private static WDir CalculateDestination(BossModule module)
{
// if oracle jumps directly to one of the initial safespots, both groups run opposite in one (arbitrary, CW) direction, and the one that ends up behind boss slides across - in that case we return zero destination
// note: we assume that when this is called oracle is already at position
var icicles = module.FindComponent<P2IcicleImpact>();
var oracle = module.Enemies(OID.OraclesReflection).FirstOrDefault();
if (icicles == null || icicles.AOEs.Count == 0 || oracle == null)
return default;

var idealDir = (module.Center - oracle.Position).Normalized(); // ideally we wanna stay as close as possible to across the oracle
var destDir = (icicles.AOEs[0].Origin - module.Center).Normalized().OrthoL(); // actual destination is one of the last icicles
return destDir.Dot(idealDir) switch
{
> 0.5f => destDir,
< -0.5f => -destDir,
_ => default, // fast movement mode
};
}

public override void AddAIHints(int slot, Actor actor, PartyRolesConfig.Assignment assignment, AIHints hints)
{
var master = actor.Role != Role.Healer ? Stacks.MinBy(s => (s.Target.Position - actor.Position).LengthSq()).Target : null;
if (master != null && ((master.Position - actor.Position).LengthSq() > 100 || (master.Position - Module.Center).LengthSq() < 196))
master = null; // our closest healer is too far away or too close to center, something is wrong (maybe kb didn't finish yet, or healer fucked up)

if (master != null)
{
// non-healers should just stack with whatever closest healer is
var moveDir = master.LastFrameMovement.Normalized();
var capsule = ShapeDistance.Capsule(master.Position + 2 * moveDir, moveDir, 4, 2);
hints.AddForbiddenZone(p => -capsule(p), DateTime.MaxValue);
}

// note: other hints have to be 'later' than immediate (to make getting out of voidzones higher prio), but 'earlier' than stack-with-healer:
// healer's position is often overlapped by new voidzones, if healer is moving slowly - in that case we still need to dodge in correct direction
var hintTime = WorldState.FutureTime(50);

// stay near border
hints.AddForbiddenZone(ShapeDistance.Circle(Module.Center, 16), hintTime);

// prefer moving towards safety (CW is arbitrary)
var moveQuickly = _destinationDir == default;
var preferredDir = !moveQuickly ? _destinationDir : (actor.Position - Module.Center).Normalized().OrthoR();
var planeOffset = moveQuickly && master == null && NumCasts > 0 ? 2 : -2; // if we're moving quickly, mark our current spot as forbidden (don't bother if we have master or waiting for first cast, though)
hints.AddForbiddenZone(ShapeDistance.HalfPlane(Module.Center + planeOffset * preferredDir, preferredDir), hintTime);
}

public override void OnCastStarted(Actor caster, ActorCastInfo spell)
{
Expand All @@ -230,40 +318,6 @@ public override void OnEventCast(Actor caster, ActorCastEvent spell)

class P2SinboundHolyVoidzone(BossModule module) : Components.PersistentVoidzone(module, 6, m => m.Enemies(OID.SinboundHolyVoidzone).Where(z => z.EventState != 7));

class P2SinboundHolyAIBait(BossModule module) : BossComponent(module)
{
private WDir _finalDir; // if oracle jumps directly to one of the safespots, both groups run opposite in one (arbitrary, CW) direction, and the one that ends up behind boss slides across - in that case this is kept zeroed

public override void AddAIHints(int slot, Actor actor, PartyRolesConfig.Assignment assignment, AIHints hints)
{
// stay on border
hints.AddForbiddenZone(ShapeDistance.Circle(Module.Center, 18));

// and move towards safety (CW is arbitrary)
var preferredDir = _finalDir != default ? _finalDir : (actor.Position - Module.Center).Normalized().OrthoR();
hints.AddForbiddenZone(ShapeDistance.HalfPlane(Module.Center - 2 * preferredDir, preferredDir));
}

public override void OnCastStarted(Actor caster, ActorCastInfo spell)
{
// note: we assume that when this component is activated, only last pair of icicles remain (orthogonal to original safe spots), and oracle is already at position
if ((AID)spell.Action.ID == AID.IcicleImpact && Module.Enemies(OID.OraclesReflection).FirstOrDefault() is var oracle && oracle != null && _finalDir == default)
{
var destDir = (caster.Position - Module.Center).Normalized();
var idealDir = (Module.Center - oracle.Position).Normalized();
var dot = destDir.Dot(idealDir);
if (dot < 0)
{
destDir = -destDir;
dot = -dot;
}
if (dot > 0.5f)
_finalDir = destDir;
// else: single direction mode
}
}
}

class P2ShiningArmor(BossModule module) : Components.GenericGaze(module, ActionID.MakeSpell(AID.ShiningArmor))
{
private Actor? _source;
Expand All @@ -288,25 +342,56 @@ public override void OnActorPlayActionTimelineEvent(Actor actor, ushort id)
class P2TwinStillnessSilence(BossModule module) : Components.GenericAOEs(module)
{
public readonly List<AOEInstance> AOEs = [];
public bool EnableAIHints;
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());

public void EnableAIHints()
{
_voidzones = Module.FindComponent<P2SinboundHolyVoidzone>();
}

public override IEnumerable<AOEInstance> ActiveAOEs(int slot, Actor actor) => AOEs.Take(1);

public override void AddAIHints(int slot, Actor actor, PartyRolesConfig.Assignment assignment, AIHints hints)
{
if (!EnableAIHints || _source == null)
if (_voidzones == null || _source == null)
return;

if (!_thinIce[slot])
{
// preposition
// this is a bit hacky - we need to stay either far away from boss, or close (and slide over at the beginning of the ice)
hints.AddForbiddenZone(ShapeDistance.Rect(_source.Position, _source.Rotation, 25, 5, 20));
// the actual shape is quite complicated ('primary' shape is a set of points at distance X from a cone behind boss, 'secondary' is a set of points at distance X from primary), so we use a rough approximation

// first, find a set of allowed angles along the border
var zoneList = new ArcList(Module.Center, 17);
foreach (var z in _voidzones.Sources(Module))
zoneList.ForbidCircle(z.Position, _voidzones.Shape.Radius);

// now find closest allowed zone
var actorDir = Angle.FromDirection(actor.Position - Module.Center);
var closest = zoneList.Allowed(5.Degrees()).MinBy(z => actorDir.DistanceToRange(z.min, z.max).Abs().Rad);
if (closest != default)
{
var desiredDir = (closest.min + closest.max) * 0.5f;
var halfWidth = (closest.max - closest.min) * 0.5f;
if (halfWidth.Deg > 5)
{
// destination is very wide, narrow it down a bit to be in line with the boss
halfWidth = 5.Degrees();
var sourceDir = Angle.FromDirection(_source.Position - Module.Center);
var sourceDist = sourceDir.DistanceToRange(closest.min + halfWidth, closest.max - halfWidth);
var oppositeDist = (sourceDir + 180.Degrees()).DistanceToRange(closest.min + halfWidth, closest.max - halfWidth);
desiredDir = oppositeDist.Abs().Rad < sourceDist.Abs().Rad ? (sourceDir + 180.Degrees() + oppositeDist) : (sourceDir + sourceDist);
}
hints.AddForbiddenZone(ShapeDistance.Circle(Module.Center, 16), WorldState.FutureTime(50));
hints.AddForbiddenZone(ShapeDistance.InvertedCone(Module.Center, 100, desiredDir, halfWidth), DateTime.MaxValue);
}
return;
}

Expand All @@ -317,23 +402,32 @@ public override void AddAIHints(int slot, Actor actor, PartyRolesConfig.Assignme
if (AOEs.Count == 0)
{
// if we're behind boss, slide over
hints.AddForbiddenZone(ShapeDistance.Rect(_source.Position, _source.Rotation, 10, 20, 20));
hints.AddForbiddenZone(ShapeDistance.Rect(_source.Position, _source.Rotation, 20, 20, 20), DateTime.MaxValue);
}
else
{
// otherwise just dodge next aoe
ref var nextAOE = ref AOEs.Ref(0);
hints.AddForbiddenZone(nextAOE.Shape.Distance(nextAOE.Origin, nextAOE.Rotation), nextAOE.Activation);
}

// 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 > _voidzones.Shape.Radius)
hints.AddForbiddenZone(ShapeDistance.Cone(actor.Position, 100, Angle.FromDirection(offset), Angle.Asin(dist / _voidzones.Shape.Radius)));
}
}

public override void DrawArenaForeground(int pcSlot, Actor pc)
{
Arena.Actor(_source, ArenaColor.Object, true);
if (AOEs.Count > 0)
if (_thinIce[pcSlot])
{
Arena.AddCircle(pc.Position, 32, ArenaColor.Vulnerable);
Arena.AddLine(pc.Position, pc.Position - 32 * WorldState.Client.CameraAzimuth.ToDirection(), ArenaColor.Vulnerable);
Arena.AddCircle(pc.Position, SlideDistance, ArenaColor.Vulnerable);
Arena.AddLine(pc.Position, pc.Position - SlideDistance * WorldState.Client.CameraAzimuth.ToDirection(), ArenaColor.Vulnerable);
}
}

Expand Down
11 changes: 11 additions & 0 deletions BossMod/Util/Angle.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,17 @@ public readonly Angle Normalized()

public readonly bool AlmostEqual(Angle other, float epsRad) => Math.Abs((this - other).Normalized().Rad) <= epsRad;

// closest distance to move from this angle to destination (== 0 if equal, >0 if moving in positive/CCW dir, <0 if moving in negative/CW dir)
public readonly Angle DistanceToAngle(Angle other) => (other - this).Normalized();

// returns 0 if angle is within range, positive value if min is closest, negative if max is closest
public readonly Angle DistanceToRange(Angle min, Angle max)
{
var width = (max - min) * 0.5f;
var midDist = DistanceToAngle((min + max) * 0.5f);
return midDist.Rad > width.Rad ? midDist - width : midDist.Rad < -width.Rad ? midDist + width : default;
}

public override readonly string ToString() => Deg.ToString("f0");
}

Expand Down
Loading

0 comments on commit c1a229f

Please sign in to comment.