feat: Controller hotbars with cross layout, separate storage, and sync with game
- Add controller hotbars: 8 cross bars (L2/R2 style), separate from normal hotbars 1-8 - Controller bar slot data stored in config (not game StandardHotbars) so layouts can differ per mode - Drag-and-drop on controller bars: from game, shift+drag rearrange, release outside to clear - Independent controller bar keybinds with modifier+trigger combinations (e.g. L2+South) - Optional 'Sync bar mode with game client': follow Character Config Mouse/Gamepad toggle (PadMode) - Clone/copy actions: normal hotbars ↔ controller bars - Restore controller bar layout button; deploy to devPlugins on Release build Made-with: Cursor
This commit is contained in:
@@ -7,6 +7,8 @@ using FFXIVClientStructs.FFXIV.Client.Game;
|
||||
using FFXIVClientStructs.FFXIV.Client.System.String;
|
||||
using FFXIVClientStructs.FFXIV.Client.UI;
|
||||
using FFXIVClientStructs.FFXIV.Client.UI.Misc;
|
||||
using HSUI.Config;
|
||||
using HSUI.Interface.GeneralElements;
|
||||
using KamiToolKit.Controllers;
|
||||
using static FFXIVClientStructs.FFXIV.Client.Game.ActionManager;
|
||||
|
||||
@@ -192,6 +194,148 @@ namespace HSUI.Helpers
|
||||
return list;
|
||||
}
|
||||
|
||||
/// <summary>Scratch bar/slot used to build SlotInfo or execute from (type, id) without touching game bars 1-8. Bar 10 slot 0.</summary>
|
||||
internal const int ScratchBarIndex = 10;
|
||||
internal const int ScratchSlotIndex = 0;
|
||||
|
||||
/// <summary>Get SlotInfo for a single (type, id) by temporarily writing to scratch slot and reading. Used for controller bars.</summary>
|
||||
private unsafe SlotInfo GetSlotInfoViaScratch(RaptureHotbarModule.HotbarSlotType slotType, uint commandId)
|
||||
{
|
||||
var module = RaptureHotbarModule.Instance();
|
||||
if (module == null || !module->ModuleReady)
|
||||
return new SlotInfo(0, true, false, 0, 0, 0, 0, "", 0, 0);
|
||||
int barIdx = ScratchBarIndex - 1;
|
||||
var slotPtr = module->GetSlotById((uint)barIdx, (uint)ScratchSlotIndex);
|
||||
if (slotPtr == null)
|
||||
return new SlotInfo(0, true, false, 0, 0, 0, 0, "", 0, 0);
|
||||
uint saveType = (uint)slotPtr->CommandType;
|
||||
uint saveId = slotPtr->CommandId;
|
||||
try
|
||||
{
|
||||
slotPtr->Set(slotType, commandId);
|
||||
slotPtr->LoadIconId();
|
||||
if (slotType == RaptureHotbarModule.HotbarSlotType.Item)
|
||||
slotPtr->LoadCostDataForSlot(true);
|
||||
var list = GetSlotData(ScratchBarIndex, 1);
|
||||
return list.Count > 0 ? list[0] : new SlotInfo(0, true, false, 0, 0, 0, 0, "", 0, 0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
slotPtr->Set((RaptureHotbarModule.HotbarSlotType)saveType, saveId);
|
||||
slotPtr->LoadIconId();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Get slot data for a controller cross bar (1-8) from persisted controller storage. Returns 8 SlotInfos.</summary>
|
||||
public unsafe List<SlotInfo> GetControllerSlotData(int barIndex, int slotCount)
|
||||
{
|
||||
var list = new List<SlotInfo>(slotCount);
|
||||
var barConfig = ControllerBarConfig.GetBarConfig(barIndex);
|
||||
if (barConfig == null)
|
||||
{
|
||||
for (int i = 0; i < slotCount; i++)
|
||||
list.Add(new SlotInfo(0, true, false, 0, 0, 0, 0, "", 0, 0));
|
||||
return list;
|
||||
}
|
||||
barConfig.EnsureSlotsPadded();
|
||||
int count = Math.Min(slotCount, barConfig.Slots.Count);
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var entry = barConfig.Slots[i];
|
||||
var type = (RaptureHotbarModule.HotbarSlotType)entry.SlotType;
|
||||
uint id = entry.CommandId;
|
||||
if (entry.IsEmpty)
|
||||
{
|
||||
list.Add(new SlotInfo(0, true, false, 0, 0, 0, 0, "", 0, 0));
|
||||
continue;
|
||||
}
|
||||
list.Add(GetSlotInfoViaScratch(type, id));
|
||||
}
|
||||
while (list.Count < slotCount)
|
||||
list.Add(new SlotInfo(0, true, false, 0, 0, 0, 0, "", 0, 0));
|
||||
return list;
|
||||
}
|
||||
|
||||
/// <summary>Place payload on a controller bar slot (persisted in config only). Bar 1-8, slot 0-7.</summary>
|
||||
public void PlacePayloadOnControllerSlot(int barIndex, int slotIndex, RaptureHotbarModule.HotbarSlotType slotType, uint commandId)
|
||||
{
|
||||
var barConfig = ControllerBarConfig.GetBarConfig(barIndex);
|
||||
if (barConfig == null) return;
|
||||
barConfig.EnsureSlotsPadded();
|
||||
int s = Math.Clamp(slotIndex, 0, ControllerBarConfig.CrossSlotCount - 1);
|
||||
while (barConfig.Slots.Count <= s)
|
||||
barConfig.Slots.Add(new ControllerSlotData { SlotType = (int)RaptureHotbarModule.HotbarSlotType.Empty, CommandId = 0 });
|
||||
barConfig.Slots[s] = new ControllerSlotData { SlotType = (int)slotType, CommandId = commandId };
|
||||
ConfigurationManager.Instance?.SaveConfigurations();
|
||||
}
|
||||
|
||||
/// <summary>Clear a controller bar slot. Bar 1-8, slot 0-7.</summary>
|
||||
public bool ClearControllerSlot(int barIndex, int slotIndex)
|
||||
{
|
||||
var barConfig = ControllerBarConfig.GetBarConfig(barIndex);
|
||||
if (barConfig == null) return false;
|
||||
barConfig.EnsureSlotsPadded();
|
||||
int s = Math.Clamp(slotIndex, 0, ControllerBarConfig.CrossSlotCount - 1);
|
||||
if (s >= barConfig.Slots.Count) return true;
|
||||
barConfig.Slots[s] = new ControllerSlotData { SlotType = (int)RaptureHotbarModule.HotbarSlotType.Empty, CommandId = 0 };
|
||||
ConfigurationManager.Instance?.SaveConfigurations();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Swap two controller bar slots. Bars 1-8, slots 0-7.</summary>
|
||||
public bool SwapControllerSlots(int barA, int slotA, int barB, int slotB)
|
||||
{
|
||||
var configA = ControllerBarConfig.GetBarConfig(barA);
|
||||
var configB = ControllerBarConfig.GetBarConfig(barB);
|
||||
if (configA == null || configB == null) return false;
|
||||
configA.EnsureSlotsPadded();
|
||||
configB.EnsureSlotsPadded();
|
||||
int a = Math.Clamp(slotA, 0, ControllerBarConfig.CrossSlotCount - 1);
|
||||
int b = Math.Clamp(slotB, 0, ControllerBarConfig.CrossSlotCount - 1);
|
||||
if (barA == barB && a == b) return true;
|
||||
if (a >= configA.Slots.Count || b >= configB.Slots.Count) return false;
|
||||
var tmp = configA.Slots[a];
|
||||
configA.Slots[a] = configB.Slots[b];
|
||||
configB.Slots[b] = tmp;
|
||||
ConfigurationManager.Instance?.SaveConfigurations();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Execute a controller bar slot by (type, id) via scratch slot. Bar 1-8, slot 0-7.</summary>
|
||||
public unsafe bool ExecuteControllerSlot(int barIndex, int slotIndex)
|
||||
{
|
||||
var barConfig = ControllerBarConfig.GetBarConfig(barIndex);
|
||||
if (barConfig == null) return false;
|
||||
barConfig.EnsureSlotsPadded();
|
||||
int s = Math.Clamp(slotIndex, 0, ControllerBarConfig.CrossSlotCount - 1);
|
||||
if (s >= barConfig.Slots.Count) return false;
|
||||
var entry = barConfig.Slots[s];
|
||||
if (entry.IsEmpty) return false;
|
||||
var slotType = (RaptureHotbarModule.HotbarSlotType)entry.SlotType;
|
||||
uint commandId = entry.CommandId;
|
||||
|
||||
var module = RaptureHotbarModule.Instance();
|
||||
if (module == null || !module->ModuleReady) return false;
|
||||
var slotPtr = module->GetSlotById((uint)(ScratchBarIndex - 1), (uint)ScratchSlotIndex);
|
||||
if (slotPtr == null) return false;
|
||||
uint saveType = (uint)slotPtr->CommandType;
|
||||
uint saveId = slotPtr->CommandId;
|
||||
try
|
||||
{
|
||||
slotPtr->Set(slotType, commandId);
|
||||
slotPtr->LoadIconId();
|
||||
if (slotType == RaptureHotbarModule.HotbarSlotType.Item)
|
||||
slotPtr->LoadCostDataForSlot(true);
|
||||
module->ExecuteSlotById((uint)(ScratchBarIndex - 1), (uint)ScratchSlotIndex);
|
||||
return true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
slotPtr->Set((RaptureHotbarModule.HotbarSlotType)saveType, saveId);
|
||||
slotPtr->LoadIconId();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets cooldown for a hotbar slot. For Action/GeneralAction/PetAction, uses ActionManager recast API
|
||||
/// (more accurate for adjusted IDs, recast groups). Falls back to slot's GetSlotActionCooldownPercentage for Items/Macros.
|
||||
@@ -581,7 +725,23 @@ namespace HSUI.Helpers
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Clear a hotbar slot. hotbarIndex 1-10, slotIndex 0-based.</summary>
|
||||
/// <summary>Place a drag-drop payload (game or HSUI) onto a slot. hotbarIndex 1-10, slotIndex 0-based.</summary>
|
||||
public static unsafe void PlacePayloadOnSlot(int hotbarIndex, int slotIndex, RaptureHotbarModule.HotbarSlotType slotType, uint commandId)
|
||||
{
|
||||
var module = RaptureHotbarModule.Instance();
|
||||
if (module == null || !module->ModuleReady) return;
|
||||
int barIdx = Math.Clamp(hotbarIndex, 1, 10) - 1;
|
||||
int slot = Math.Clamp(slotIndex, 0, 11);
|
||||
ref var displayBar = ref module->StandardHotbars[barIdx];
|
||||
var slotPtr = displayBar.GetHotbarSlot((uint)slot);
|
||||
if (slotPtr == null) return;
|
||||
slotPtr->Set(slotType, commandId);
|
||||
slotPtr->LoadIconId();
|
||||
if (slotType == RaptureHotbarModule.HotbarSlotType.Item)
|
||||
slotPtr->LoadCostDataForSlot(true);
|
||||
SetAndSaveSlotInternal(module, (uint)barIdx, (uint)slot, slotType, commandId, slotPtr);
|
||||
}
|
||||
|
||||
public unsafe bool ClearSlot(int hotbarIndex, int slotIndex)
|
||||
{
|
||||
var module = RaptureHotbarModule.Instance();
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Dalamud.Game.ClientState.GamePad;
|
||||
using Dalamud.Game.ClientState.Keys;
|
||||
using Dalamud.Plugin.Services;
|
||||
using HSUI.Config;
|
||||
using HSUI.Interface.GeneralElements;
|
||||
|
||||
namespace HSUI.Helpers
|
||||
{
|
||||
/// <summary>Polls controller bar keybinds and executes slots when keys/buttons are pressed. Supports combinations (e.g. L2+South).</summary>
|
||||
public static class ControllerBarKeybindExecutor
|
||||
{
|
||||
private static readonly Dictionary<string, bool> _triggerWasDown = new Dictionary<string, bool>();
|
||||
|
||||
public static void Process(IFramework framework)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!ControllerHotbarsConfig.GetEffectiveControllerHotbarsEnabled())
|
||||
return;
|
||||
|
||||
var keybindsConfig = ConfigurationManager.Instance?.GetConfigObject<ControllerBarKeybindsConfig>();
|
||||
if (keybindsConfig == null)
|
||||
return;
|
||||
|
||||
if (ActionBarsManager.Instance == null)
|
||||
return;
|
||||
|
||||
for (int bar = 1; bar <= ControllerBarKeybindsConfig.Bars; bar++)
|
||||
{
|
||||
for (int slot = 0; slot < ControllerBarKeybindsConfig.SlotsPerBar; slot++)
|
||||
{
|
||||
string binding = keybindsConfig.GetKeybind(bar, slot);
|
||||
if (string.IsNullOrEmpty(binding)) continue;
|
||||
|
||||
bool triggered = IsKeybindTriggered(binding);
|
||||
if (triggered)
|
||||
ActionBarsManager.Instance.ExecuteControllerSlot(bar, slot);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Plugin.Logger?.Warning($"[HSUI ControllerBarKeybinds] Process: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>True when all modifiers are held and the trigger key/button was just pressed (edge).</summary>
|
||||
private static bool IsKeybindTriggered(string binding)
|
||||
{
|
||||
if (string.IsNullOrEmpty(binding)) return false;
|
||||
|
||||
string[] parts = binding.Split('+');
|
||||
for (int i = 0; i < parts.Length; i++)
|
||||
parts[i] = parts[i].Trim();
|
||||
|
||||
if (parts.Length == 0) return false;
|
||||
if (parts.Length == 1)
|
||||
{
|
||||
bool down = IsSingleKeybindDown(parts[0]);
|
||||
if (!_triggerWasDown.TryGetValue(binding, out bool wasDown)) wasDown = false;
|
||||
_triggerWasDown[binding] = down;
|
||||
return down && !wasDown;
|
||||
}
|
||||
|
||||
string triggerPart = parts[parts.Length - 1];
|
||||
for (int i = 0; i < parts.Length - 1; i++)
|
||||
{
|
||||
if (!IsSingleKeybindDown(parts[i]))
|
||||
return false;
|
||||
}
|
||||
bool triggerDown = IsSingleKeybindDown(triggerPart);
|
||||
if (!_triggerWasDown.TryGetValue(binding, out bool triggerWasDown))
|
||||
triggerWasDown = false;
|
||||
_triggerWasDown[binding] = triggerDown;
|
||||
return triggerDown && !triggerWasDown;
|
||||
}
|
||||
|
||||
private static bool IsSingleKeybindDown(string part)
|
||||
{
|
||||
if (string.IsNullOrEmpty(part)) return false;
|
||||
|
||||
if (part.StartsWith("Key:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
string vkStr = part.Substring(4).Trim();
|
||||
if (Plugin.KeyState == null) return false;
|
||||
if (Enum.TryParse<VirtualKey>(vkStr, true, out var vk)
|
||||
&& Plugin.KeyState.IsVirtualKeyValid((int)vk))
|
||||
return Plugin.KeyState[vk];
|
||||
return false;
|
||||
}
|
||||
|
||||
if (part.StartsWith("Pad:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
string padStr = part.Substring(4).Trim();
|
||||
if (Plugin.GamepadState == null) return false;
|
||||
if (Enum.TryParse<GamepadButtons>(padStr, true, out var btn))
|
||||
return Plugin.GamepadState.Raw(btn) > 0.5f;
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using Dalamud.Game.ClientState.GamePad;
|
||||
using Dalamud.Plugin.Services;
|
||||
using FFXIVClientStructs.FFXIV.Client.System.Framework;
|
||||
using FFXIVClientStructs.FFXIV.Client.UI.Misc;
|
||||
using HSUI.Interface.GeneralElements;
|
||||
|
||||
namespace HSUI.Helpers
|
||||
{
|
||||
/// <summary>Provides current cross hotbar trigger state (L2/R2) and game controller-mode detection for sync with game client.</summary>
|
||||
public static class ControllerHotbarHelper
|
||||
{
|
||||
private static IGamepadState? _gamepadState;
|
||||
|
||||
public static void SetGamepadState(IGamepadState? gamepadState)
|
||||
{
|
||||
_gamepadState = gamepadState;
|
||||
}
|
||||
|
||||
/// <summary>True when the game's Character Configuration has controller (gamepad) mode enabled. Reads the PadMode config option so bar mode follows the toggle and does not switch on input.</summary>
|
||||
public static unsafe bool IsGameInControllerMode()
|
||||
{
|
||||
try
|
||||
{
|
||||
Framework* framework = Framework.Instance();
|
||||
if (framework == null) return false;
|
||||
int option = (int)ConfigOption.PadMode;
|
||||
if (framework->SystemConfig.SystemConfigBase.UiConfig.ConfigCount <= option)
|
||||
return false;
|
||||
uint value = framework->SystemConfig.SystemConfigBase.UiConfig.ConfigEntry[option].Value.UInt;
|
||||
// PadMode: 0 = keyboard/mouse, 1 = gamepad
|
||||
return value != 0;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Returns the current trigger combo for which cross bar should be visible. Uses L2/R2 from gamepad.</summary>
|
||||
public static CrossBarTrigger GetCurrentTrigger()
|
||||
{
|
||||
if (_gamepadState == null)
|
||||
return CrossBarTrigger.None;
|
||||
|
||||
try
|
||||
{
|
||||
// Raw returns float (analog); treat as pressed when > 0.5
|
||||
bool l2 = _gamepadState.Raw(GamepadButtons.L2) > 0.5f;
|
||||
bool r2 = _gamepadState.Raw(GamepadButtons.R2) > 0.5f;
|
||||
|
||||
if (l2 && r2)
|
||||
{
|
||||
// Both held - could distinguish L2 then R2 vs R2 then L2 by order; for now use L2R2
|
||||
return CrossBarTrigger.L2R2;
|
||||
}
|
||||
if (l2) return CrossBarTrigger.L2;
|
||||
if (r2) return CrossBarTrigger.R2;
|
||||
return CrossBarTrigger.None;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return CrossBarTrigger.None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -475,6 +475,9 @@ namespace HSUI.Helpers
|
||||
// When leaving PvP, force load PvE hotbars so HSUI bars don't keep showing PvP actions.
|
||||
ActionBarsManager.TryRestorePvEHotbarsAfterLeavePvP();
|
||||
|
||||
// Controller bar custom keybinds (when controller hotbars enabled)
|
||||
ControllerBarKeybindExecutor.Process(framework);
|
||||
|
||||
// Keep WndProc hooked when: proxy mode (for mouseover) OR we need to block game drag
|
||||
// release (so dropping on HSUI action bar doesn't execute the ability).
|
||||
bool needHook = IsProxyEnabled || ShouldBlockGameDragRelease();
|
||||
@@ -567,6 +570,7 @@ namespace HSUI.Helpers
|
||||
|
||||
_wndHandle = hWnd;
|
||||
_wndProcDelegate = WndProcDetour;
|
||||
_wndProcHandle = GCHandle.Alloc(_wndProcDelegate, GCHandleType.Normal); // Prevent GC of delegate while hooked
|
||||
_wndProcPtr = Marshal.GetFunctionPointerForDelegate(_wndProcDelegate);
|
||||
_imguiWndProcPtr = SetWindowLongPtr(hWnd, GWL_WNDPROC, _wndProcPtr);
|
||||
|
||||
@@ -593,6 +597,8 @@ namespace HSUI.Helpers
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (_wndProcHandle.IsAllocated)
|
||||
_wndProcHandle.Free();
|
||||
_wndHandle = IntPtr.Zero;
|
||||
_imguiWndProcPtr = IntPtr.Zero;
|
||||
_wndProcPtr = IntPtr.Zero;
|
||||
@@ -614,6 +620,7 @@ namespace HSUI.Helpers
|
||||
|
||||
private IntPtr _wndHandle = IntPtr.Zero;
|
||||
private WndProcDelegate _wndProcDelegate = null!;
|
||||
private GCHandle _wndProcHandle; // Keeps delegate alive while WndProc is hooked; freed in RestoreWndProc
|
||||
private IntPtr _wndProcPtr = IntPtr.Zero;
|
||||
private IntPtr _imguiWndProcPtr = IntPtr.Zero;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user