diff --git a/PositionalGuide/ConfigWindow.cs b/PositionalGuide/ConfigWindow.cs
index c5eb6e0..7d529c7 100644
--- a/PositionalGuide/ConfigWindow.cs
+++ b/PositionalGuide/ConfigWindow.cs
@@ -28,6 +28,9 @@ public const sbyte
MinTetherLengthInner = -1,
MinTetherLengthOuter = -2,
MaxTetherLength = 32;
+ public const byte
+ MinFillOpacity = byte.MinValue,
+ MaxFillOpacity = byte.MaxValue;
private const ImGuiWindowFlags WindowFlags = ImGuiWindowFlags.None
//| ImGuiWindowFlags.NoScrollbar
@@ -105,6 +108,16 @@ public override void Draw() {
tetherLengthInner = this.conf.TetherLengthInner,
tetherLengthOuter = this.conf.TetherLengthOuter;
+ byte positionalFillOpacity = this.conf.PositionalFillOpacity;
+
+ bool highlightPositionals = this.conf.HighlightPositionals;
+ bool highlightDuringPerfectBalance = this.conf.HighlightDuringPerfectBalance;
+ bool positionalIncludeBoundaryLines = this.conf.PositionalIncludeBoundaryLines;
+ bool positionalColourCircles = this.conf.PositionalColourCircles;
+ bool fillPositionalZones = this.conf.FillPositionalZones;
+ Vector4 positionalGoodColour = this.conf.PositionalGoodColour;
+ Vector4 positionalBadColour = this.conf.PositionalBadColour;
+
bool active = this.conf.Enabled;
bool tether = this.conf.DrawTetherLine;
bool flattenTether = this.conf.FlattenTether;
@@ -230,6 +243,67 @@ public override void Draw() {
ImGui.PopStyleVar();
+ // live positional highlighting, driven by whichever weaponskill is coming up next
+ ImGui.TextUnformatted("");
+ changed |= ImGui.Checkbox("Highlight the positional your next attack wants?", ref highlightPositionals);
+ utils.Tooltip("While this is on and you're playing a job with positionals, the guides stop using the colours set"
+ + " above and instead colour the correct side of your target with the 'correct' colour below, and every other"
+ + " side with the 'incorrect' colour."
+ + "\n"
+ + "\nSupported jobs, and what each one watches:"
+ + "\n Dragoon - the combo. Chaotic Spring and Wheeling Thrust want the rear, Fang and Claw wants a flank."
+ + "\n Monk - coeurl form and your Coeurl's Fury. Demolish wants the rear, Pouncing Coeurl wants a flank."
+ + "\n Ninja - the combo and your Kazematoi. Aeolian Edge wants the rear, Armor Crush wants a flank."
+ + "\n Reaper - your Soul Reaver window. Gallows wants the rear, Gibbet wants a flank."
+ + "\n Samurai - the combo. Gekko wants the rear, Kasha wants a flank."
+ + "\n Viper - the combo. Swiftskin's Sting leads to the rear pair, Hunter's Sting to the flank pair."
+ + "\n"
+ + "\nAnywhere the next weaponskill has no positional - a different job, the wrong point in a combo, a reaper"
+ + " with neither Gibbet nor Gallows enhanced - your normal colours above are used instead, so nothing is"
+ + " highlighted when nothing needs to be.");
+
+ ImGui.PushStyleVar(ImGuiStyleVar.Alpha, highlightPositionals ? 1 : InactiveOptionAlpha);
+ ImGui.Indent();
+
+ changed |= ImGui.ColorEdit4("Colour for the correct side", ref positionalGoodColour, ImGuiColorEditFlags.NoInputs);
+ changed |= ImGui.ColorEdit4("Colour for the incorrect sides", ref positionalBadColour, ImGuiColorEditFlags.NoInputs);
+
+ changed |= ImGui.Checkbox("Monk: keep highlighting during Perfect Balance?", ref highlightDuringPerfectBalance);
+ utils.Tooltip("Perfect Balance hides your form and lets you use any of them, so there's no form to read."
+ + " With this on, the rear is highlighted for the whole window, because Demolish is the coeurl-chakra filler"
+ + " and nothing else monk presses under Perfect Balance has a positional at all, so the rear is never the"
+ + " wrong place to be standing."
+ + "\n"
+ + "\nWith this off, highlighting simply switches off until the window ends.");
+
+ changed |= ImGui.Checkbox("Count the diagonal guidelines as part of the correct side?", ref positionalIncludeBoundaryLines);
+ utils.Tooltip("Each positional cone is 90 degrees wide, bounded by two diagonal guidelines and cut down the middle"
+ + " by a cardinal one."
+ + "\n"
+ + "\nWith this on, a cone's two boundary lines are coloured as part of it, so you can see exactly how far it"
+ + " extends. With this off, only the cardinal line through the middle of the cone is coloured.");
+
+ changed |= ImGui.Checkbox("Recolour the circles to match?", ref positionalColourCircles);
+ utils.Tooltip("While a positional is being highlighted, colour the target circle and outer circle by side as well,"
+ + " overriding the circle colour options above.");
+
+ changed |= ImGui.Checkbox("Shade in the correct and incorrect sides?", ref fillPositionalZones);
+ utils.Tooltip("Fills the area enclosed by the guidelines, so the correct side reads as a solid wedge of colour"
+ + " instead of something you have to work out from which lines changed.");
+
+ ImGui.PushStyleVar(ImGuiStyleVar.Alpha, highlightPositionals && fillPositionalZones ? 1 : InactiveOptionAlpha);
+ ImGui.PushItemWidth(300 * scale);
+ changed |= ImGui.SliderScalar("Shading opacity", ImGuiDataType.U8, ref positionalFillOpacity, MinFillOpacity, MaxFillOpacity, "%i", ImGuiSliderFlags.AlwaysClamp);
+ utils.Tooltip("How solid the shading is, from 0 (invisible) to 255 (completely opaque)."
+ + "\n"
+ + "\nLow values are recommended, since the shading covers your target.");
+ ImGui.PopItemWidth();
+ ImGui.PopStyleVar();
+
+ ImGui.Unindent();
+ ImGui.PopStyleVar();
+ ImGui.TextUnformatted("");
+
// sliders for numeric modifiers to the lines being drawn
ImGui.PushItemWidth(470 * scale);
@@ -298,6 +372,12 @@ public override void Draw() {
ImGui.TextUnformatted("");
ImGui.TextUnformatted("Additionally, you can use 'cardinal', 'cardinals', 'diagonal', 'diagonals', 'lines', and 'all' to affect multiple lines with a single command.");
ImGui.TextUnformatted("");
+ ImGui.TextUnformatted("Positional highlighting has its own targets:");
+ ImGui.Indent();
+ ImGui.TextUnformatted("For the highlighting itself: p, positional, positionals");
+ ImGui.TextUnformatted("For the shading: fill, shading");
+ ImGui.Unindent();
+ ImGui.TextUnformatted("");
ImGui.TextUnformatted("Finally, you can use the target 'tether' to toggle tether line rendering, and the target 'render' (the default) to toggle showing guides at all without losing your settings.");
ImGui.PopTextWrapPos();
}
@@ -314,6 +394,14 @@ public override void Draw() {
this.conf.AlwaysUseCircleColours = circleColours[0];
this.conf.AlwaysUseCircleColoursTarget = circleColours[1];
this.conf.AlwaysUseCircleColoursOuter = circleColours[2];
+ this.conf.HighlightPositionals = highlightPositionals;
+ this.conf.HighlightDuringPerfectBalance = highlightDuringPerfectBalance;
+ this.conf.PositionalIncludeBoundaryLines = positionalIncludeBoundaryLines;
+ this.conf.PositionalColourCircles = positionalColourCircles;
+ this.conf.FillPositionalZones = fillPositionalZones;
+ this.conf.PositionalFillOpacity = positionalFillOpacity;
+ this.conf.PositionalGoodColour = positionalGoodColour;
+ this.conf.PositionalBadColour = positionalBadColour;
this.conf.ExtraDrawRange = guideLengthMod;
this.conf.MinDrawRange = minGuideLength;
this.conf.MaxDrawRange = maxGuideLength;
diff --git a/PositionalGuide/Configuration.cs b/PositionalGuide/Configuration.cs
index d649719..6812bfa 100644
--- a/PositionalGuide/Configuration.cs
+++ b/PositionalGuide/Configuration.cs
@@ -46,6 +46,16 @@ public const int
public bool AlwaysUseCircleColoursTarget { get; set; } = false;
public bool AlwaysUseCircleColoursOuter { get; set; } = false;
+ public bool HighlightPositionals { get; set; } = false;
+ public bool HighlightDuringPerfectBalance { get; set; } = true;
+ public bool PositionalIncludeBoundaryLines { get; set; } = true;
+ public bool PositionalColourCircles { get; set; } = true;
+ public bool FillPositionalZones { get; set; } = true;
+ public byte PositionalFillOpacity { get; set; } = 48;
+
+ public Vector4 PositionalGoodColour { get; set; } = new(0, 1, 0, 1);
+ public Vector4 PositionalBadColour { get; set; } = new(1, 0, 0, 1);
+
///
/// Starts at front then goes clockwise up to index=7, then circle at index=8
///
diff --git a/PositionalGuide/Plugin.cs b/PositionalGuide/Plugin.cs
index 81ac78e..f43bc14 100644
--- a/PositionalGuide/Plugin.cs
+++ b/PositionalGuide/Plugin.cs
@@ -44,6 +44,7 @@ private enum CircleTypes { Target, Outer };
[PluginService] public static IPluginLog Log { get; private set; } = null!;
[PluginService] public static INotificationManager Notifications { get; private set; } = null!;
[PluginService] public static IObjectTable Objects { get; private set; } = null!;
+ [PluginService] public static IJobGauges Gauges { get; private set; } = null!;
public Configuration Config { get; private set; }
@@ -164,15 +165,31 @@ internal void Draw() {
if (!targetOnScreen && !limitEither && limitInner)
return;
+ // which side of the target does the next weaponskill want to be hit from, if any
+ RequiredPositional positional = this.Config.HighlightPositionals
+ ? Positionals.Detect(player, this.Config)
+ : RequiredPositional.None;
+
// +X = east, -X = west
// +Z = south, -Z = north
Vector3 guidelineBasePoint2 = targetPos + new Vector3(0, 0, length);
Vector3 circleBasePoint = targetPos + new Vector3(0, 0, target.HitboxRadius);
+
bool anyLineActive = false;
+ for (int lineIndex = Configuration.IndexFront; lineIndex <= Configuration.IndexFrontLeft; ++lineIndex) {
+ if (this.Config.DrawGuides[lineIndex]) {
+ anyLineActive = true;
+ break;
+ }
+ }
+
+ // the shaded wedges go down first so that every line and circle draws on top of them
+ if (positional is not RequiredPositional.None && this.Config.FillPositionalZones && targetOnScreen)
+ this.fillPositionalZones(drawing, targetPos, centre, targetFacing, length, positional);
+
for (int lineIndex = Configuration.IndexFront; lineIndex <= Configuration.IndexFrontLeft; ++lineIndex) {
if (!this.Config.DrawGuides[lineIndex])
continue;
- anyLineActive = true;
Vector3 rotated = rotatePoint(targetPos, guidelineBasePoint2, targetFacing + this.lineIndexToAngle[lineIndex]);
bool endpointOnScreen = Gui.WorldToScreen(rotated, out Vector2 coord);
@@ -183,14 +200,14 @@ internal void Draw() {
else if (limitOuter && !endpointOnScreen) {
continue;
}
- drawing.AddLine(centre, coord, ImGui.GetColorU32(this.Config.LineColours[lineIndex]), this.Config.LineThickness);
+ drawing.AddLine(centre, coord, ImGui.GetColorU32(this.resolveLineColour(lineIndex, positional)), this.Config.LineThickness);
}
if (this.Config.DrawCircle)
- this.drawCircle(drawing, targetPos, circleBasePoint, targetFacing, anyLineActive, CircleTypes.Target);
+ this.drawCircle(drawing, targetPos, circleBasePoint, targetFacing, anyLineActive, CircleTypes.Target, positional);
if (this.Config.DrawOuterCircle)
- this.drawCircle(drawing, targetPos, circleBasePoint, targetFacing, anyLineActive, CircleTypes.Outer);
+ this.drawCircle(drawing, targetPos, circleBasePoint, targetFacing, anyLineActive, CircleTypes.Outer, positional);
// the tether line itself used to be pretty simple: from our position to the target's
// then I decided to allow controlling the maximum length of it and suddenly everything got Complicated
@@ -247,10 +264,89 @@ internal void Draw() {
ImGui.End();
}
- private void drawCircle(ImDrawListPtr drawing, Vector3 targetPos, Vector3 basePoint, float targetFacing, bool anyLineActive, CircleTypes circleType) {
+ ///
+ /// Whether a guideline belongs to the zone the next weaponskill wants. Each 90° positional cone is
+ /// bounded by two diagonal guidelines and bisected by a cardinal one, so the boundaries can either
+ /// count as part of the cone they enclose or be left to the cone on the other side of them.
+ ///
+ private static bool lineIsInZone(int lineIndex, RequiredPositional positional, bool includeBoundaries) => positional switch {
+ RequiredPositional.Rear => lineIndex is Configuration.IndexBack
+ || (includeBoundaries && lineIndex is Configuration.IndexBackRight or Configuration.IndexBackLeft),
+ RequiredPositional.Flank => lineIndex is Configuration.IndexRight or Configuration.IndexLeft
+ || (includeBoundaries && lineIndex is Configuration.IndexFrontRight or Configuration.IndexBackRight
+ or Configuration.IndexBackLeft or Configuration.IndexFrontLeft),
+ _ => false,
+ };
+
+ ///
+ /// Whether a circle/fill segment falls inside the cone the next weaponskill wants. Segment 0 sits on
+ /// the target's front and they run clockwise, so eighths of the circle line up with the guidelines.
+ ///
+ private static bool segmentIsInZone(int segmentIndex, RequiredPositional positional) {
+ int eighth = segmentIndex * 8 / CircleSegmentCount;
+ return positional switch {
+ RequiredPositional.Rear => eighth is 3 or 4,
+ RequiredPositional.Flank => eighth is 1 or 2 or 5 or 6,
+ _ => false,
+ };
+ }
+
+ private Vector4 resolveLineColour(int lineIndex, RequiredPositional positional)
+ => positional is RequiredPositional.None
+ ? this.Config.LineColours[lineIndex]
+ : lineIsInZone(lineIndex, positional, this.Config.PositionalIncludeBoundaryLines)
+ ? this.Config.PositionalGoodColour
+ : this.Config.PositionalBadColour;
+
+ private Vector4 resolveSegmentColour(int segmentIndex, RequiredPositional positional)
+ => segmentIsInZone(segmentIndex, positional)
+ ? this.Config.PositionalGoodColour
+ : this.Config.PositionalBadColour;
+
+ ///
+ /// Shades the area enclosed by the guidelines, so the correct cone reads as a solid wedge of colour
+ /// rather than something to be inferred from which lines changed.
+ ///
+ private void fillPositionalZones(ImDrawListPtr drawing, Vector3 targetPos, Vector2 centre, float targetFacing, float radius, RequiredPositional positional) {
+ if (this.Config.PositionalFillOpacity is 0)
+ return;
+
+ Vector3 startPoint = rotatePoint(targetPos, targetPos + new Vector3(0, 0, radius), targetFacing);
+ Vector3[] points = circlePoints(targetPos, startPoint, this.circleSegmentIdxToAngle).ToArray();
+
+ (Vector2 point, bool render)[] screenPoints = new (Vector2 point, bool render)[points.Length];
+ for (int i = 0; i < points.Length; ++i) {
+ bool render = Gui.WorldToScreen(points[i], out Vector2 screenPoint);
+ screenPoints[i] = (screenPoint, render);
+ }
+
+ float opacity = this.Config.PositionalFillOpacity / 255f;
+
+ // the fan's triangles share their edges exactly, so they tile seamlessly - but each one's antialiasing
+ // fringe would blend over its neighbour's and leave 128 spokes radiating out of the target, so the fill
+ // gets drawn unantialiased and the flag is put back immediately afterwards
+ ImDrawListFlags originalFlags = drawing.Flags;
+ drawing.Flags &= ~ImDrawListFlags.AntiAliasedFill;
+
+ for (int i = 0; i < screenPoints.Length; ++i) {
+ int nextIndex = (i + 1) % screenPoints.Length;
+ if (!screenPoints[i].render || !screenPoints[nextIndex].render)
+ continue;
+
+ Vector4 colour = this.resolveSegmentColour(i, positional);
+ colour.W *= opacity;
+
+ drawing.AddTriangleFilled(centre, screenPoints[i].point, screenPoints[nextIndex].point, ImGui.GetColorU32(colour));
+ }
+
+ drawing.Flags = originalFlags;
+ }
+
+ private void drawCircle(ImDrawListPtr drawing, Vector3 targetPos, Vector3 basePoint, float targetFacing, bool anyLineActive, CircleTypes circleType, RequiredPositional positional) {
Vector4 circleColour = new(1, 0, 0, 1);
Vector3 circleBasePoint = basePoint;
bool forceCircleColour = false;
+ bool usePositionalColours = positional is not RequiredPositional.None && this.Config.PositionalColourCircles;
switch (circleType) {
case CircleTypes.Target:
@@ -281,7 +377,9 @@ private void drawCircle(ImDrawListPtr drawing, Vector3 targetPos, Vector3 basePo
#pragma warning restore IDE0042 // Deconstruct variable declaration
if (curPoint.render && nextPoint.render) {
- Vector4 colour = forceCircleColour ? circleColour : this.circleSegmentIdxToColour[i];
+ Vector4 colour = usePositionalColours
+ ? this.resolveSegmentColour(i, positional)
+ : forceCircleColour ? circleColour : this.circleSegmentIdxToColour[i];
drawing.AddLine(curPoint.point, nextPoint.point, ImGui.GetColorU32(colour), this.Config.LineThickness + 2);
}
}
@@ -434,6 +532,15 @@ internal void OnPluginCommand(string command, string arguments) {
this.Config.DrawCircle = state ?? !this.Config.DrawCircle;
this.Config.DrawOuterCircle = state ?? !this.Config.DrawOuterCircle;
break;
+ case "p":
+ case "positional":
+ case "positionals":
+ this.Config.HighlightPositionals = state ?? !this.Config.HighlightPositionals;
+ break;
+ case "fill":
+ case "shading":
+ this.Config.FillPositionalZones = state ?? !this.Config.FillPositionalZones;
+ break;
case "tether":
this.Config.DrawTetherLine = state ?? !this.Config.DrawTetherLine;
//Notifications.AddNotification(new() {
diff --git a/PositionalGuide/Positionals.cs b/PositionalGuide/Positionals.cs
new file mode 100644
index 0000000..a8896ef
--- /dev/null
+++ b/PositionalGuide/Positionals.cs
@@ -0,0 +1,198 @@
+using Dalamud.Game.ClientState.JobGauge.Types;
+using Dalamud.Game.ClientState.Objects.SubKinds;
+using Dalamud.Game.ClientState.Statuses;
+
+using FFXIVClientStructs.FFXIV.Client.Game;
+
+namespace VariableVixen.PositionalGuide;
+
+///
+/// Which side of the target the player's next positional weaponskill wants to be hit from.
+///
+internal enum RequiredPositional {
+ None,
+ Rear,
+ Flank,
+}
+
+///
+/// Works out, from live job state, which positional the player's next weaponskill is going to want.
+/// Every job here returns unless the next weaponskill genuinely
+/// has a positional, so the guides fall back to their configured colours the rest of the time.
+///
+internal static class Positionals {
+ private const uint
+ JobDragoon = 22,
+ JobMonk = 20,
+ JobNinja = 30,
+ JobReaper = 39,
+ JobSamurai = 34,
+ JobViper = 41;
+
+ // monk
+ private const uint
+ StatusCoeurlForm = 109,
+ StatusPerfectBalance = 110;
+
+ // dragoon - the pre-upgrade actions are listed too so the guides still work below max level
+ private const uint
+ ActionDisembowel = 87,
+ ActionSpiralBlow = 36903,
+ ActionChaosThrust = 88,
+ ActionChaoticSpring = 25772,
+ ActionFullThrust = 84,
+ ActionHeavensThrust = 25771;
+
+ // ninja
+ private const uint ActionGustSlash = 2242;
+
+ ///
+ /// Armor Crush grants two Kazematoi against a cap of five, so it goes out while three or fewer are
+ /// banked and Aeolian Edge spends them the rest of the time.
+ ///
+ private const byte KazematoiSpendThreshold = 4;
+
+ // samurai
+ private const uint
+ ActionJinpu = 7478,
+ ActionShifu = 7479;
+
+ // reaper
+ private const uint
+ StatusSoulReaver = 2587,
+ StatusEnhancedGibbet = 2588,
+ StatusEnhancedGallows = 2589,
+ StatusExecutioner = 3858;
+
+ // viper
+ private const uint
+ ActionHuntersSting = 34608,
+ ActionSwiftskinsSting = 34609;
+
+ internal static RequiredPositional Detect(IPlayerCharacter player, Configuration config) => player.ClassJob.RowId switch {
+ JobDragoon => detectDragoon(),
+ JobMonk => detectMonk(player, config),
+ JobNinja => detectNinja(),
+ JobReaper => detectReaper(player),
+ JobSamurai => detectSamurai(),
+ JobViper => detectViper(),
+ _ => RequiredPositional.None,
+ };
+
+ ///
+ /// The action that most recently advanced the player's combo, or zero if the combo has lapsed.
+ ///
+ private static unsafe uint comboAction() {
+ ActionManager* manager = ActionManager.Instance();
+ if (manager is null)
+ return 0;
+
+ return manager->Combo.Timer > 0 ? manager->Combo.Action : 0;
+ }
+
+ private static RequiredPositional detectMonk(IPlayerCharacter player, Configuration config) {
+ // As of Dawntrail, coeurl form holds monk's only two positionals: Demolish wants the rear,
+ // Snap Punch / Pouncing Coeurl wants a flank. Nothing in opo-opo or raptor form has one at all.
+ bool coeurlForm = false;
+ bool perfectBalance = false;
+ foreach (IStatus status in player.StatusList) {
+ if (status is null)
+ continue;
+ if (status.StatusId is StatusCoeurlForm)
+ coeurlForm = true;
+ else if (status.StatusId is StatusPerfectBalance)
+ perfectBalance = true;
+ }
+
+ // Perfect Balance suppresses forms and lets any of them be used. The coeurl-chakra filler is
+ // Demolish (Pouncing Coeurl would spend a Fury stack for the same chakra), so the rear is the
+ // only side that can be wrong to stand on during the window.
+ if (perfectBalance)
+ return config.HighlightDuringPerfectBalance ? RequiredPositional.Rear : RequiredPositional.None;
+
+ if (!coeurlForm)
+ return RequiredPositional.None;
+
+ // Demolish grants Coeurl's Fury, Pouncing Coeurl spends it, so a stack on the gauge means the
+ // next coeurl-form button is the flank one.
+ return Plugin.Gauges.Get().CoeurlFury > 0
+ ? RequiredPositional.Flank
+ : RequiredPositional.Rear;
+ }
+
+ ///
+ /// Dragoon's positionals sit at fixed steps of its two combo branches, so the combo alone settles it.
+ ///
+ private static RequiredPositional detectDragoon() => comboAction() switch {
+ ActionDisembowel or ActionSpiralBlow => RequiredPositional.Rear, // into Chaotic Spring
+ ActionChaosThrust or ActionChaoticSpring => RequiredPositional.Rear, // into Wheeling Thrust
+ ActionFullThrust or ActionHeavensThrust => RequiredPositional.Flank, // into Fang and Claw
+ _ => RequiredPositional.None,
+ };
+
+ ///
+ /// Aeolian Edge (rear) and Armor Crush (flank) are the same step of the same combo, picked apart by
+ /// how much Kazematoi is banked.
+ ///
+ private static RequiredPositional detectNinja() {
+ if (comboAction() is not ActionGustSlash)
+ return RequiredPositional.None;
+
+ return Plugin.Gauges.Get().Kazematoi >= KazematoiSpendThreshold
+ ? RequiredPositional.Rear
+ : RequiredPositional.Flank;
+ }
+
+ ///
+ /// Gibbet (flank) and Gallows (rear) alternate: each one grants the buff that makes the other the
+ /// stronger follow-up. With neither buff up they hit for the same, so there is nothing to advise.
+ ///
+ private static RequiredPositional detectReaper(IPlayerCharacter player) {
+ bool reaverWindow = false;
+ bool enhancedGibbet = false;
+ bool enhancedGallows = false;
+ foreach (IStatus status in player.StatusList) {
+ if (status is null)
+ continue;
+ switch (status.StatusId) {
+ case StatusSoulReaver or StatusExecutioner:
+ reaverWindow = true;
+ break;
+ case StatusEnhancedGibbet:
+ enhancedGibbet = true;
+ break;
+ case StatusEnhancedGallows:
+ enhancedGallows = true;
+ break;
+ }
+ }
+
+ if (!reaverWindow)
+ return RequiredPositional.None;
+ if (enhancedGallows)
+ return RequiredPositional.Rear;
+ if (enhancedGibbet)
+ return RequiredPositional.Flank;
+
+ return RequiredPositional.None;
+ }
+
+ ///
+ /// Gekko follows Jinpu and wants the rear, Kasha follows Shifu and wants a flank.
+ ///
+ private static RequiredPositional detectSamurai() => comboAction() switch {
+ ActionJinpu => RequiredPositional.Rear,
+ ActionShifu => RequiredPositional.Flank,
+ _ => RequiredPositional.None,
+ };
+
+ ///
+ /// Both finishers on each viper branch share a side, so the branch is all that matters: Hunter's Sting
+ /// leads into the flank pair, Swiftskin's Sting into the rear pair.
+ ///
+ private static RequiredPositional detectViper() => comboAction() switch {
+ ActionHuntersSting => RequiredPositional.Flank,
+ ActionSwiftskinsSting => RequiredPositional.Rear,
+ _ => RequiredPositional.None,
+ };
+}