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, + }; +}