From 062f8c2010ab114787b1b7d2aeedea0943e6645b Mon Sep 17 00:00:00 2001 From: ColdCoffee83 <53914355+ColdCoffee83@users.noreply.github.com> Date: Sat, 5 Sep 2026 21:40:00 -0400 Subject: [PATCH 1/2] add positional highlighting driven by the next weaponskill Adds an opt-in mode that colours the guidelines by whether the next weaponskill actually wants that side of the target, rather than by a fixed per-direction colour. The correct cone is drawn in one colour and every other side in another, and the area the guidelines enclose can optionally be shaded so the correct cone reads as a solid wedge instead of something to infer from which lines changed. Monk is the only job wired up so far. As of Dawntrail its positionals both live in coeurl form - Demolish wants the rear, Snap Punch / Pouncing Coeurl wants a flank - and every other monk weaponskill has lost its positional entirely, so coeurl form is the whole surface. Which of the two is coming is read off MNKGauge.CoeurlFury rather than guessed from the rotation: Demolish grants the stacks and Pouncing Coeurl spends them, so a stack on the gauge means the flank button is next. Perfect Balance suppresses the form status, so that window is handled separately and is behind its own toggle. Positionals.Detect dispatches on job id, so other jobs can be added without touching the rendering path. Defaults to off, so nobody's existing colours change on update. The shading is a triangle fan whose segments share their edges exactly, so it draws with AntiAliasedFill cleared and the flag restored right after - otherwise each triangle's AA fringe blends over its neighbour's and leaves spokes radiating out of the target. Co-Authored-By: Claude Opus 5 --- PositionalGuide/ConfigWindow.cs | 82 +++++++++++++++++++++ PositionalGuide/Configuration.cs | 10 +++ PositionalGuide/Plugin.cs | 119 +++++++++++++++++++++++++++++-- PositionalGuide/Positionals.cs | 58 +++++++++++++++ 4 files changed, 263 insertions(+), 6 deletions(-) create mode 100644 PositionalGuide/Positionals.cs diff --git a/PositionalGuide/ConfigWindow.cs b/PositionalGuide/ConfigWindow.cs index c5eb6e0..c1b9dd0 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,61 @@ 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 the plugin understands, 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" + + "\nMonk is currently the only job with support. As of Dawntrail its only positionals are both in coeurl form:" + + " Demolish wants the rear, and Snap Punch / Pouncing Coeurl wants a flank. The plugin reads your Coeurl's Fury" + + " stacks to know which of the two is coming, since Demolish grants them and Pouncing Coeurl spends them." + + "\n" + + "\nOn any other job, or outside coeurl form, your normal colours above are used instead."); + + 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("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 +366,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 +388,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..2336098 --- /dev/null +++ b/PositionalGuide/Positionals.cs @@ -0,0 +1,58 @@ +using Dalamud.Game.ClientState.JobGauge.Types; +using Dalamud.Game.ClientState.Objects.SubKinds; +using Dalamud.Game.ClientState.Statuses; + +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. +/// +internal static class Positionals { + private const uint JobMonk = 20; + + private const uint + StatusCoeurlForm = 109, + StatusPerfectBalance = 110; + + internal static RequiredPositional Detect(IPlayerCharacter player, Configuration config) + => player.ClassJob.RowId is JobMonk ? detectMonk(player, config) : RequiredPositional.None; + + 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; + } +} From df97d54a46ed13f542274ea465acc4bf948d245d Mon Sep 17 00:00:00 2001 From: ColdCoffee83 <53914355+ColdCoffee83@users.noreply.github.com> Date: Sun, 6 Sep 2026 19:24:22 -0400 Subject: [PATCH 2/2] extend positional highlighting to the other five positional jobs Adds dragoon, ninja, reaper, samurai and viper alongside monk, which together cover every positional currently in the game. Each job reads whatever state actually settles the choice rather than guessing at a rotation, and returns None wherever the next weaponskill has no positional, so the guides fall back to their configured colours. dragoon - combo state. Chaotic Spring and Wheeling Thrust want the rear, Fang and Claw wants a flank; all three sit at fixed steps of the two branches, so the combo alone settles it. ninja - combo state plus NINGauge.Kazematoi. Aeolian Edge and Armor Crush are the same combo step; Armor Crush grants two against a cap of five, so it goes out at three or fewer. reaper - Soul Reaver / Executioner gates the window, and the Enhanced Gibbet / Enhanced Gallows buffs pick the side. With neither up both hit for the same, so nothing is advised. samurai - combo state. Gekko follows Jinpu and wants the rear, Kasha follows Shifu and wants a flank. viper - combo state. Both finishers on each branch share a side, so only the branch matters: Hunter's Sting leads to the flank pair, Swiftskin's Sting to the rear pair. The dragoon action list includes the pre-upgrade ids (Disembowel, Chaos Thrust, Full Thrust) so the guides still work below max level. Every positional above was checked against the live action descriptions rather than recalled, which is also how monk's came out as coeurl-only. Co-Authored-By: Claude Opus 5 --- PositionalGuide/ConfigWindow.cs | 22 +++-- PositionalGuide/Positionals.cs | 146 +++++++++++++++++++++++++++++++- 2 files changed, 157 insertions(+), 11 deletions(-) diff --git a/PositionalGuide/ConfigWindow.cs b/PositionalGuide/ConfigWindow.cs index c1b9dd0..7d529c7 100644 --- a/PositionalGuide/ConfigWindow.cs +++ b/PositionalGuide/ConfigWindow.cs @@ -246,15 +246,21 @@ public override void Draw() { // 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 the plugin understands, 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." + 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" - + "\nMonk is currently the only job with support. As of Dawntrail its only positionals are both in coeurl form:" - + " Demolish wants the rear, and Snap Punch / Pouncing Coeurl wants a flank. The plugin reads your Coeurl's Fury" - + " stacks to know which of the two is coming, since Demolish grants them and Pouncing Coeurl spends them." + + "\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" - + "\nOn any other job, or outside coeurl form, your normal colours above are used instead."); + + "\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(); @@ -262,7 +268,7 @@ public override void Draw() { 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("Keep highlighting during Perfect Balance?", ref highlightDuringPerfectBalance); + 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" diff --git a/PositionalGuide/Positionals.cs b/PositionalGuide/Positionals.cs index 2336098..a8896ef 100644 --- a/PositionalGuide/Positionals.cs +++ b/PositionalGuide/Positionals.cs @@ -2,6 +2,8 @@ using Dalamud.Game.ClientState.Objects.SubKinds; using Dalamud.Game.ClientState.Statuses; +using FFXIVClientStructs.FFXIV.Client.Game; + namespace VariableVixen.PositionalGuide; /// @@ -15,16 +17,78 @@ internal enum RequiredPositional { /// /// 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 JobMonk = 20; + private const uint + JobDragoon = 22, + JobMonk = 20, + JobNinja = 30, + JobReaper = 39, + JobSamurai = 34, + JobViper = 41; + // monk private const uint StatusCoeurlForm = 109, StatusPerfectBalance = 110; - internal static RequiredPositional Detect(IPlayerCharacter player, Configuration config) - => player.ClassJob.RowId is JobMonk ? detectMonk(player, config) : RequiredPositional.None; + // 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, @@ -55,4 +119,80 @@ private static RequiredPositional detectMonk(IPlayerCharacter player, Configurat ? 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, + }; }