diff --git a/MenuAPI/HeaderGlare.cs b/MenuAPI/HeaderGlare.cs index 078aadb..dbbb73a 100644 --- a/MenuAPI/HeaderGlare.cs +++ b/MenuAPI/HeaderGlare.cs @@ -1,5 +1,4 @@ using CitizenFX.FiveM.Client; -using CitizenFX.FiveM.Shared.Data; namespace MenuAPI; diff --git a/MenuAPI/Menu.cs b/MenuAPI/Menu.cs index f717c1c..41edc36 100644 --- a/MenuAPI/Menu.cs +++ b/MenuAPI/Menu.cs @@ -1,5 +1,4 @@ -using CitizenFX.FiveM.Client; -using CitizenFX.FiveM.Client.Extensions; +using CitizenFX.FiveM.Client; namespace MenuAPI; @@ -346,11 +345,29 @@ internal virtual void PageChangeEvent(Menu menu, int oldPage, int newPage, bool private int index = 0; + private int viewIndexOffset = 0; + + private int pageIndex = 0; + private bool visible = false; - public int ViewIndexOffset { get; private set; } = 0; + public int ViewIndexOffset + { + get => viewIndexOffset; + private set + { + if (viewIndexOffset == value) + { + return; + } + + viewIndexOffset = value; + + MenuNui.Invalidate(); + } + } - private List VisibleMenuItems + internal List VisibleMenuItems { get { @@ -406,7 +423,12 @@ private List ActiveItems private bool pageItemsDirty = true; /// Anything that changes what belongs on the current page calls this. - private void InvalidatePage() => pageItemsDirty = true; + private void InvalidatePage() + { + pageItemsDirty = true; + + MenuNui.Invalidate(); + } // What is currently loaded into ColorPanelScaleform, so the 64 swatches are only re-sent when // they would actually differ. See DrawColorAndOpacityPanel. Static because the scaleform is. @@ -422,11 +444,29 @@ private List ActiveItems #endregion #region Public Variables - public string? MenuTitle { get; set; } + public string? MenuTitle + { + get => _menuTitle; + set => MenuNui.Change(ref _menuTitle, value); + } + + private string? _menuTitle; + + public string? MenuSubtitle + { + get => _menuSubtitle; + set => MenuNui.Change(ref _menuSubtitle, value); + } + + private string? _menuSubtitle; - public string? MenuSubtitle { get; set; } + public KeyValuePair HeaderTexture + { + get => _headerTexture; + set => MenuNui.Change(ref _headerTexture, value); + } - public KeyValuePair HeaderTexture { get; set; } = new KeyValuePair(); + private KeyValuePair _headerTexture = new KeyValuePair(); #region Menu title styling // Nullable, and falling back to the matching MenuController.Default*, so a resource can style @@ -438,10 +478,22 @@ private List ActiveItems // hangs off its own header helps nobody. /// The font the title is drawn in. See . - public int? MenuTitleFont { get; set; } + public int? MenuTitleFont + { + get => _menuTitleFont; + set => MenuNui.Change(ref _menuTitleFont, value); + } + + private int? _menuTitleFont; /// Where the title sits inside the header. - public TitleAlignmentOption? MenuTitleAlignment { get; set; } + public TitleAlignmentOption? MenuTitleAlignment + { + get => _menuTitleAlignment; + set => MenuNui.Change(ref _menuTitleAlignment, value); + } + + private TitleAlignmentOption? _menuTitleAlignment; public enum TitleAlignmentOption { @@ -451,18 +503,30 @@ public enum TitleAlignmentOption } /// Whether GTA Online's moving header glow is drawn over the banner. - public bool? ShowHeaderGlare { get; set; } + public bool? ShowHeaderGlare + { + get => _showHeaderGlare; + set => MenuNui.Change(ref _showHeaderGlare, value); + } + + private bool? _showHeaderGlare; - private int ResolvedTitleFont => MenuTitleFont ?? MenuController.DefaultTitleFont; + internal int ResolvedTitleFont => MenuTitleFont ?? MenuController.DefaultTitleFont; - private TitleAlignmentOption ResolvedTitleAlignment => MenuTitleAlignment ?? MenuController.DefaultTitleAlignment; + internal TitleAlignmentOption ResolvedTitleAlignment => MenuTitleAlignment ?? MenuController.DefaultTitleAlignment; - private bool ResolvedShowHeaderGlare => ShowHeaderGlare ?? MenuController.DefaultShowHeaderGlare; + internal bool ResolvedShowHeaderGlare => ShowHeaderGlare ?? MenuController.DefaultShowHeaderGlare; #endregion public bool IgnoreDontOpenMenus { get; set; } = false; - public int MaxItemsOnScreen { get; internal set; } = 10; + public int MaxItemsOnScreen + { + get => _maxItemsOnScreen; + internal set => MenuNui.Change(ref _maxItemsOnScreen, value); + } + + private int _maxItemsOnScreen = 10; public int Size => ActiveItems.Count; @@ -473,7 +537,21 @@ public enum TitleAlignmentOption public bool Paginated => PageSize > 0; /// The page currently being shown, counting from 0. - public int PageIndex { get; private set; } = 0; + public int PageIndex + { + get => pageIndex; + private set + { + if (pageIndex == value) + { + return; + } + + pageIndex = value; + + MenuNui.Invalidate(); + } + } /// Always at least 1, so an empty paginated menu still reads as "page 1 of 1". public int PageCount => Paginated ? Math.Max(1, (SourceItems.Count + PageSize - 1) / PageSize) : 1; @@ -499,6 +577,8 @@ public bool Visible } visible = value; + MenuNui.Invalidate(); + // The single funnel for OpenMenu, CloseMenu, GoBack and CloseAllMenus, so every one of // MenuAPI's ticks learns about a menu opening or closing from right here. Safe to call // from a setter: it only starts a loop, and a loop always waits a frame before its first @@ -514,33 +594,94 @@ public bool Visible public float MenuItemsYOffset { get; private set; } = 0f; - public string? CounterPreText { get; set; } + public string? CounterPreText + { + get => _counterPreText; + set => MenuNui.Change(ref _counterPreText, value); + } + + private string? _counterPreText; public Menu? ParentMenu { get; internal set; } = null; - public int CurrentIndex { get { return index; } internal set { index = Math.Clamp(value, 0, Math.Max(0, Size - 1)); } } + public int CurrentIndex + { + get => index; + internal set + { + var clamped = Math.Clamp(value, 0, Math.Max(0, Size - 1)); + + if (index == clamped) + { + return; + } + + index = clamped; + + MenuNui.Invalidate(); + } + } public bool EnableInstructionalButtons { get; set; } = true; /// /// Should contain 4 floats. /// - public float[] WeaponStats { get; private set; } = new float[4] { 0f, 0f, 0f, 0f }; + public float[] WeaponStats + { + get => _weaponStats; + private set => MenuNui.Change(ref _weaponStats, value); + } + + private float[] _weaponStats = new float[4] { 0f, 0f, 0f, 0f }; /// /// Should contain 4 floats. /// - public float[] WeaponComponentStats { get; private set; } = new float[4] { 0f, 0f, 0f, 0f }; + public float[] WeaponComponentStats + { + get => _weaponComponentStats; + private set => MenuNui.Change(ref _weaponComponentStats, value); + } + + private float[] _weaponComponentStats = new float[4] { 0f, 0f, 0f, 0f }; /// /// Should contain 4 floats. /// - public float[] VehicleStats { get; private set; } = new float[4] { 0f, 0f, 0f, 0f }; + public float[] VehicleStats + { + get => _vehicleStats; + private set => MenuNui.Change(ref _vehicleStats, value); + } + + private float[] _vehicleStats = new float[4] { 0f, 0f, 0f, 0f }; /// /// Should contain 4 floats. /// - public float[] VehicleUpgradeStats { get; private set; } = new float[4] { 0f, 0f, 0f, 0f }; + public float[] VehicleUpgradeStats + { + get => _vehicleUpgradeStats; + private set => MenuNui.Change(ref _vehicleUpgradeStats, value); + } - public bool ShowWeaponStatsPanel { get; set; } = false; - public bool ShowVehicleStatsPanel { get; set; } = false; + private float[] _vehicleUpgradeStats = new float[4] { 0f, 0f, 0f, 0f }; + + public bool ShowWeaponStatsPanel + { + get => _showWeaponStatsPanel; + set => MenuNui.Change(ref _showWeaponStatsPanel, value); + } + + public bool ShowVehicleStatsPanel + { + get => _showVehicleStatsPanel; + set => MenuNui.Change(ref _showVehicleStatsPanel, value); + } + + private bool _showWeaponStatsPanel = false; + private bool _showVehicleStatsPanel = false; + + internal string StatLabelKey(int index) => + ShowWeaponStatsPanel ? weaponStatNames[index] : vehicleStatNames[index]; private readonly string[] weaponStatNames = new string[4] { "PM_DAMAGE", "PM_FIRERATE", "PM_ACCURACY", "PM_RANGE" }; private readonly string[] vehicleStatNames = new string[4] { "FMMC_VEHST_0", "FMMC_VEHST_1", "FMMC_VEHST_2", "FMMC_VEHST_3" }; @@ -989,6 +1130,8 @@ public void SelectItem(MenuItem item) { Native.PlaySoundFrontend(-1, "SELECT", "HUD_FRONTEND_DEFAULT_SOUNDSET", false); item.Select(); + + MenuNui.Invalidate(); if (MenuController.MenuButtons.TryGetValue(item, out var value)) { // Read once. The old code asked for the current menu twice, so the second call could @@ -1131,6 +1274,8 @@ public void GoLeft() if (item != null) { item.GoLeft(); + + MenuNui.Invalidate(); } // If the item is not any of the above, return to parent menu. else if (MenuController.NavigateMenuUsingArrows && !MenuController.DisableBackButton && !(MenuController.PreventExitingMenu && ParentMenu == null)) @@ -1156,7 +1301,12 @@ public void GoRight() return; } - item?.GoRight(); + if (item != null) + { + item.GoRight(); + + MenuNui.Invalidate(); + } } /// @@ -1227,10 +1377,10 @@ public void SetWeaponStats(float damage, float fireRate, float accuracy, float r { WeaponStats = new float[4] { - Math.Clamp(damage, 0f, 1f), - Math.Clamp(fireRate, 0f, 1f), - Math.Clamp(accuracy, 0f, 1f), - Math.Clamp(range, 0f, 1f) + ClampStat(damage), + ClampStat(fireRate), + ClampStat(accuracy), + ClampStat(range) }; } @@ -1245,10 +1395,10 @@ public void SetWeaponComponentStats(float damage, float fireRate, float accuracy { WeaponComponentStats = new float[4] { - Math.Clamp(WeaponStats[0] + damage, 0f, 1f), - Math.Clamp(WeaponStats[1] + fireRate, 0f, 1f), - Math.Clamp(WeaponStats[2] + accuracy, 0f, 1f), - Math.Clamp(WeaponStats[3] + range, 0f, 1f) + ClampStat(WeaponStats[0] + damage), + ClampStat(WeaponStats[1] + fireRate), + ClampStat(WeaponStats[2] + accuracy), + ClampStat(WeaponStats[3] + range) }; } @@ -1263,10 +1413,10 @@ public void SetVehicleStats(float topSpeed, float acceleration, float braking, f { VehicleStats = new float[4] { - Math.Clamp(topSpeed, 0f, 1f), - Math.Clamp(acceleration, 0f, 1f), - Math.Clamp(braking, 0f, 1f), - Math.Clamp(traction, 0f, 1f) + ClampStat(topSpeed), + ClampStat(acceleration), + ClampStat(braking), + ClampStat(traction) }; } @@ -1284,19 +1434,21 @@ public void SetVehicleUpgradeStats(float topSpeed, float acceleration, float bra { VehicleUpgradeStats = new float[4] { - Math.Clamp(VehicleStats[0] + topSpeed, 0f, 1f), - Math.Clamp(VehicleStats[1] + acceleration, 0f, 1f), - Math.Clamp(VehicleStats[2] + braking, 0f, 1f), - Math.Clamp(VehicleStats[3] + traction, 0f, 1f) + ClampStat(VehicleStats[0] + topSpeed), + ClampStat(VehicleStats[1] + acceleration), + ClampStat(VehicleStats[2] + braking), + ClampStat(VehicleStats[3] + traction) }; } + + private static float ClampStat(float value) => float.IsFinite(value) ? Math.Clamp(value, 0f, 1f) : 0f; #endregion #region internal/private task functions /// /// Processes any custom button press handlers for this menu. /// - private void ProcessButtonPressHandlers() + internal void ProcessButtonPressHandlers() { if (ButtonPressHandlers.Count != 0) { @@ -1944,7 +2096,7 @@ private void DrawColorAndOpacityPanel(float descriptionYOffset) Native.BeginScaleformMovieMethod(OpacityPanelScaleform, "SET_TITLE"); Native.PushScaleformMovieMethodParameterString("Opacity"); Native.PushScaleformMovieMethodParameterString(""); - Native.ScaleformMovieMethodAddParamInt(listItem.ListIndex * 10); // opacity percent + Native.ScaleformMovieMethodAddParamInt(listItem.ResolvedOpacityPercent); Native.EndScaleformMovieMethod(); float width = Width / MenuLayout.ScreenWidth; diff --git a/MenuAPI/MenuAPI.csproj b/MenuAPI/MenuAPI.csproj index 0b193fb..a15bae3 100644 --- a/MenuAPI/MenuAPI.csproj +++ b/MenuAPI/MenuAPI.csproj @@ -36,6 +36,15 @@ + + + + diff --git a/MenuAPI/MenuController.cs b/MenuAPI/MenuController.cs index 198a8bf..2f45f62 100644 --- a/MenuAPI/MenuController.cs +++ b/MenuAPI/MenuController.cs @@ -1,6 +1,4 @@ -using CitizenFX.FiveM.Client; -using CitizenFX.FiveM.Client.Extensions; -using CitizenFX.FiveM.Shared.Data; +using CitizenFX.FiveM.Client; using CitizenFX.FiveM.Shared.Script; namespace MenuAPI; @@ -34,6 +32,10 @@ public class MenuController : IScript // to be often enough that the menu has settled by the time they look at it again. private const long LayoutRefreshIntervalMs = 500; + private const long TextureRefreshIntervalMs = 250; + + private const long InstructionalButtonsConfigureIntervalMs = 300; + private static float AspectRatio => Native.GetScreenAspectRatio(false); public static float ScreenWidth => 1080 * AspectRatio; public static float ScreenHeight => 1080; @@ -46,11 +48,11 @@ public class MenuController : IScript public static bool AreMenuButtonsEnabled => IsAnyMenuOpen() && - !Native.IsPauseMenuActive() && - Native.IsScreenFadedIn() && - !Native.IsPlayerSwitchInProgress() && + !FrameState.IsPauseMenuActive && + FrameState.IsScreenFadedIn && + !FrameState.IsPlayerSwitchInProgress && !DisableMenuButtons && - !API.Players.Local.IsDead && + !FrameState.IsDead && !IsF8ConsoleLikelyOpen; public static bool NavigateMenuUsingArrows { get; set; } = true; @@ -64,15 +66,51 @@ public class MenuController : IScript // drawn with, so leaving them alone changes nothing. /// The font menu titles are drawn in. See . - public static int DefaultTitleFont { get; set; } = MenuFont.HouseScript; + public static int DefaultTitleFont + { + get => _defaultTitleFont; + set => MenuNui.Change(ref _defaultTitleFont, value); + } + + private static int _defaultTitleFont = MenuFont.HouseScript; /// Where menu titles sit inside the header. - public static Menu.TitleAlignmentOption DefaultTitleAlignment { get; set; } = Menu.TitleAlignmentOption.Center; + public static Menu.TitleAlignmentOption DefaultTitleAlignment + { + get => _defaultTitleAlignment; + set => MenuNui.Change(ref _defaultTitleAlignment, value); + } + + private static Menu.TitleAlignmentOption _defaultTitleAlignment = Menu.TitleAlignmentOption.Center; /// Whether GTA Online's moving header glow is drawn over menu banners. - public static bool DefaultShowHeaderGlare { get; set; } = false; + public static bool DefaultShowHeaderGlare + { + get => _defaultShowHeaderGlare; + set => MenuNui.Change(ref _defaultShowHeaderGlare, value); + } + + private static bool _defaultShowHeaderGlare = false; #endregion + private static MenuRenderMode _renderMode = MenuRenderMode.Native; + + public static MenuRenderMode RenderMode + { + get => _renderMode; + set + { + if (_renderMode == value) + { + return; + } + + _renderMode = value; + + MenuTicks.Reevaluate(); + } + } + private static bool _dontOpenAnyMenu = false; // Backed by a field rather than an auto property because the controller toggle tick is gated on @@ -123,6 +161,8 @@ public static bool EnableMenuToggleKeyOnController internal static int _scale = Native.RequestScaleformMovie("INSTRUCTIONAL_BUTTONS"); + private static Menu? _instructionalButtonsMenu; + // Whether the mouse button was pressed down while a menu was open, see IsMouseButtonUsed. private static bool mouseSelectArmed = false; private static bool mouseBackArmed = false; @@ -139,14 +179,14 @@ public static MenuAlignmentOption MenuAlignment if (AspectRatio < 1.888888888888889f) { // alignment can be whatever the resource wants it to be because this aspect ratio is supported. - _alignment = value; + MenuNui.Change(ref _alignment, value); } // right aligned menus are not supported for aspect ratios 17:9 or 21:9. else { // no matter what the new value would've been, the aspect ratio does not support right aligned menus, // so (re)set it to be left aligned. - _alignment = MenuAlignmentOption.Left; + MenuNui.Change(ref _alignment, MenuAlignmentOption.Left); // In case the value was being changed to be right aligned, notify the user properly. if (value == MenuAlignmentOption.Right) @@ -181,12 +221,17 @@ public void Initialize() MenuTicks.Register("Menu.Layout", MenuLayout.Refresh, MenuTickRate.Every(LayoutRefreshIntervalMs), IsAnyMenuOpen, onStarted: MenuLayout.Refresh); - MenuTicks.Register("Menu.Draw", ProcessMenus, MenuTickRate.PerFrame, IsAnyMenuOpen, - onStopped: () => - { - UnloadAssets(); - HeaderGlare.Dispose(); - }); + MenuTicks.Register("Menu.Textures", RefreshTextures, + MenuTickRate.Every(TextureRefreshIntervalMs), IsAnyMenuOpen, + onStopped: UnloadAssets); + + MenuTicks.Register("Menu.Draw", ProcessMenus, MenuTickRate.PerFrame, + () => IsAnyMenuOpen() && RenderMode == MenuRenderMode.Native, + onStopped: HeaderGlare.Dispose); + + MenuTicks.Register("Menu.DrawNui", ProcessMenusNui, MenuTickRate.PerFrame, + () => IsAnyMenuOpen() && RenderMode == MenuRenderMode.Nui, + onStopped: MenuNui.Hide); MenuTicks.Register("Menu.InstructionalButtons", DrawInstructionalButtons, MenuTickRate.PerFrame, IsAnyMenuOpen, onStopped: () => @@ -195,6 +240,9 @@ public void Initialize() InstructionalButtonIcons.Clear(); }); + MenuTicks.Register("Menu.InstructionalButtonsData", ConfigureInstructionalButtons, + MenuTickRate.Every(InstructionalButtonsConfigureIntervalMs), IsAnyMenuOpen); + MenuTicks.Register("Menu.Select", ProcessMainButtons, MenuTickRate.PerFrame, IsAnyMenuOpen, // Nothing drains input while every menu is closed, so a menu has to open from a clean // slate rather than acting on presses that arrived when there was nothing to act on. @@ -456,7 +504,6 @@ private static void UnloadAssets() /// public static bool IsAnyMenuOpen() => VisibleMenus.Count != 0; - #region Process Menu Buttons /// /// Process the select & go back/cancel buttons. @@ -469,7 +516,7 @@ private static async Task ProcessMainButtons() bool selectPressed = MenuKeyBindings.ConsumeSelect(); bool backPressed = MenuKeyBindings.ConsumeBack(); - if (Native.IsPauseMenuActive()) + if (FrameState.IsPauseMenuActive) { return; } @@ -566,7 +613,7 @@ private static void HandlePreventExit() /// private static bool IsUsingWeaponWheel() { - if (API.Players.Local.Ped.IsPedInAnyVehicle()) + if (FrameState.IsInVehicle) { return false; } @@ -681,7 +728,7 @@ private static void ProcessToggleMenuButton() return; } - if (Native.IsPauseMenuActive() || Native.IsPauseMenuRestarting() || !Native.IsScreenFadedIn() || Native.IsPlayerSwitchInProgress() || API.Players.Local.IsDead || DisableMenuButtons) + if (FrameState.IsPauseMenuActive || Native.IsPauseMenuRestarting() || !FrameState.IsScreenFadedIn || FrameState.IsPlayerSwitchInProgress || FrameState.IsDead || DisableMenuButtons) { return; } @@ -714,7 +761,7 @@ private static async Task ProcessControllerToggle() return; } - if (Native.IsPauseMenuActive() || Native.IsPauseMenuRestarting() || !Native.IsScreenFadedIn() || Native.IsPlayerSwitchInProgress() || API.Players.Local.IsDead || DisableMenuButtons) + if (FrameState.IsPauseMenuActive || Native.IsPauseMenuRestarting() || !FrameState.IsScreenFadedIn || FrameState.IsPlayerSwitchInProgress || FrameState.IsDead || DisableMenuButtons) { return; } @@ -955,7 +1002,7 @@ private static async Task HandleDownNavigation(Menu currentMenu) private static async Task HandleMenuToggleKeyForController() { int tmpTimer = Native.GetGameTimer(); - while ((Native.IsControlPressed(0, (int)Control.InteractionMenu) || Native.IsDisabledControlPressed(0, (int)Control.InteractionMenu)) && !Native.IsPauseMenuActive() && Native.IsScreenFadedIn() && !API.Players.Local.IsDead && !Native.IsPlayerSwitchInProgress() && !DontOpenAnyMenu) + while ((Native.IsControlPressed(0, (int)Control.InteractionMenu) || Native.IsDisabledControlPressed(0, (int)Control.InteractionMenu)) && !FrameState.IsPauseMenuActive && FrameState.IsScreenFadedIn && !FrameState.IsDead && !FrameState.IsPlayerSwitchInProgress && !DontOpenAnyMenu) { if (Native.GetGameTimer() - tmpTimer > 400) { @@ -1025,7 +1072,7 @@ private static async Task HandleUpNavigation(Menu currentMenu) private static async Task MenuButtonsDisableChecks() { - static bool isInputVisible() => Native.UpdateOnscreenKeyboard() == 0; + static bool isInputVisible() => FrameState.OnscreenKeyboard == 0; if (isInputVisible()) { bool buttonsState = DisableMenuButtons; @@ -1070,7 +1117,7 @@ private static void DisableControls() return; } - if (API.Players.Local.IsDead) + if (FrameState.IsDead) { // Close all menus when the player dies. CloseAllMenus(); @@ -1087,7 +1134,7 @@ private static void DisableControls() Native.DisableControlAction(0, (int)Control.InteractionMenu, false); // When in a vehicle - if (API.Players.Local.Ped.IsPedInAnyVehicle()) + if (FrameState.IsInVehicle) { Native.DisableControlAction(0, (int)Control.VehicleSelectNextWeapon, false); Native.DisableControlAction(0, (int)Control.VehicleSelectPrevWeapon, false); @@ -1106,7 +1153,7 @@ private static void DisableGenericControls(Menu currMenu) { Native.DisableControlAction(0, (int)Control.MultiplayerInfo, false); // when in a vehicle. - if (API.Players.Local.Ped.IsPedInAnyVehicle()) + if (FrameState.IsInVehicle) { Native.DisableControlAction(0, (int)Control.VehicleHeadlight, false); Native.DisableControlAction(0, (int)Control.VehicleDuck, false); @@ -1221,12 +1268,69 @@ private static async Task ProcessMenus() await DrawMenus(); } + private static void ProcessMenusNui() + { + MenuLayout.EnsureComputed(); + + if (!CanDraw()) + { + MenuNui.Hide(); + + return; + } + + DisableControls(); + + Menu? menu = GetCurrentMenu(); + + if (menu == null) + { + MenuNui.Hide(); + + return; + } + + if (DontOpenAnyMenu) + { + if (menu.Visible && !menu.IgnoreDontOpenMenus) + { + menu.CloseMenu(); + } + + MenuNui.Hide(); + + return; + } + + if (!menu.Visible) + { + MenuNui.Hide(); + + return; + } + + menu.ProcessButtonPressHandlers(); + + MenuNui.SendChanges(menu); + } + + private static void RefreshTextures() + { + TextureDictionaries.RequestAll(menuTextureAssets); + + MenuNui.RequestPendingTextures(); + } + + // For the one thing that cannot say so itself: something the description is built from that + // MenuAPI does not own, such as a label you resolve yourself. + public static void RefreshNui() => MenuNui.Invalidate(); + /// The game states that stop a menu being drawn, none of which announce a change. private static bool CanDraw() => - Native.IsScreenFadedIn() && - !Native.IsPauseMenuActive() && - !API.Players.Local.IsDead && - !Native.IsPlayerSwitchInProgress(); + FrameState.IsScreenFadedIn && + !FrameState.IsPauseMenuActive && + !FrameState.IsDead && + !FrameState.IsPlayerSwitchInProgress; private static async Task DrawMenus() { @@ -1248,42 +1352,65 @@ private static async Task DrawMenus() } } - internal static async Task DrawInstructionalButtons() + internal static void DrawInstructionalButtons() { - // Whether a menu is open is the tick's own condition. What is left is volatile game state - // that changes every frame, so it stays inline. - if ( - Native.IsPlayerSwitchInProgress() || - API.Players.Local.IsDead || - !Native.IsScreenFadedIn() || - Native.IsWarningMessageActive() || - Native.UpdateOnscreenKeyboard() == 0 - ) + Menu? menu = GetCurrentMenu(); + + if (menu == null || !CanShowInstructionalButtons(menu) || !Native.HasScaleformMovieLoaded(_scale)) { - DisposeInstructionalButtonsScaleform(); return; } + + if (!ReferenceEquals(menu, _instructionalButtonsMenu)) + { + FillInstructionalButtonSlots(menu); + } + + Native.DrawScaleformMovieFullscreen(_scale, 255, 255, 255, 255, 0); + } + + // On a slow loop: what an icon looks like only changes when the player swaps between keyboard + // and controller or rebinds a key. Drawing the bar still has to happen every frame. + internal static async Task ConfigureInstructionalButtons() + { Menu? menu = GetCurrentMenu(); - if (menu == null || !menu.Visible || !menu.EnableInstructionalButtons) + + if (menu == null || !CanShowInstructionalButtons(menu)) { DisposeInstructionalButtonsScaleform(); + return; } + if (!Native.HasScaleformMovieLoaded(_scale)) { _scale = Native.RequestScaleformMovie("INSTRUCTIONAL_BUTTONS"); + + while (!Native.HasScaleformMovieLoaded(_scale)) + { + await API.Delay(0); + } } - while (!Native.HasScaleformMovieLoaded(_scale)) - { - await API.Delay(0); - } - Native.DrawScaleformMovieFullscreen(_scale, 255, 255, 255, 0, 0); + FillInstructionalButtonSlots(menu); + } + + private static bool CanShowInstructionalButtons(Menu menu) + { + return menu.Visible && + menu.EnableInstructionalButtons && + !FrameState.IsPlayerSwitchInProgress && + !FrameState.IsDead && + FrameState.IsScreenFadedIn && + !Native.IsWarningMessageActive() && + FrameState.OnscreenKeyboard != 0; + } + private static void FillInstructionalButtonSlots(Menu menu) + { Native.BeginScaleformMovieMethod(_scale, "CLEAR_ALL"); Native.EndScaleformMovieMethod(); - // Once here rather than at each icon below, and the only place that has to run every frame. InstructionalButtonIcons.Refresh(); int slot = 0; @@ -1305,7 +1432,6 @@ internal static async Task DrawInstructionalButtons() } // Enumerated rather than indexed: ElementAt on a dictionary walks it from the start every - // time, so indexing it in a loop re-walked the whole thing once per button, every frame. foreach (KeyValuePair button in menu.InstructionalButtons) { SetInstructionalButtonSlot(slot++, InstructionalButtonIcons.For((int)button.Key), button.Value); @@ -1321,7 +1447,7 @@ internal static async Task DrawInstructionalButtons() Native.ScaleformMovieMethodAddParamInt(0); Native.EndScaleformMovieMethod(); - Native.DrawScaleformMovieFullscreen(_scale, 255, 255, 255, 255, 0); + _instructionalButtonsMenu = menu; } private static void SetInstructionalButtonSlot(int slot, string buttonString, string text) @@ -1339,5 +1465,7 @@ private static void DisposeInstructionalButtonsScaleform() { Native.SetScaleformMovieAsNoLongerNeeded(ref _scale); } + + _instructionalButtonsMenu = null; } } \ No newline at end of file diff --git a/MenuAPI/TextureDictionaries.cs b/MenuAPI/TextureDictionaries.cs index a910e58..d221712 100644 --- a/MenuAPI/TextureDictionaries.cs +++ b/MenuAPI/TextureDictionaries.cs @@ -29,6 +29,9 @@ internal static bool IsLoaded(string dict) } Loaded.Add(dict); + + MenuNui.Invalidate(); + return true; } diff --git a/MenuAPI/items/MenuCheckboxItem.cs b/MenuAPI/items/MenuCheckboxItem.cs index 649c091..0311bb3 100644 --- a/MenuAPI/items/MenuCheckboxItem.cs +++ b/MenuAPI/items/MenuCheckboxItem.cs @@ -10,8 +10,20 @@ namespace MenuAPI; /// public class MenuCheckboxItem(string text, string? description, bool _checked) : MenuItem(text, description) { - public bool Checked { get; set; } = _checked; - public CheckboxStyle Style { get; set; } = CheckboxStyle.Tick; + public bool Checked + { + get => _isChecked; + set => MenuNui.Change(ref _isChecked, value); + } + + public CheckboxStyle Style + { + get => _style; + set => MenuNui.Change(ref _style, value); + } + + private bool _isChecked = _checked; + private CheckboxStyle _style = CheckboxStyle.Tick; public enum CheckboxStyle { Cross, @@ -41,7 +53,9 @@ private int GetSpriteColour() return Enabled ? 255 : 109; } - private string GetSpriteName(bool selected) + internal const float SpriteSizePx = 45f; + + internal string GetSpriteName(bool selected) { if (Checked) { @@ -100,10 +114,14 @@ private static float GetSpriteX(Menu parent) } } - internal override void Draw(int offset) + internal override void PrepareForDisplay() { RightIcon = Icon.NONE; Label = null; + } + + internal override void Draw(int offset) + { base.Draw(offset); if (ParentMenu is not Menu parent) @@ -122,8 +140,8 @@ internal override void Draw(int offset) float spriteY = (((index - offset) * RowHeight) + 20f + yOffset) / MenuLayout.ScreenHeight; float spriteX = GetSpriteX(parent); - float spriteHeight = 45f / MenuLayout.ScreenHeight; - float spriteWidth = 45f / MenuLayout.ScreenWidth; + float spriteHeight = SpriteSizePx / MenuLayout.ScreenHeight; + float spriteWidth = SpriteSizePx / MenuLayout.ScreenWidth; int color = GetSpriteColour(); Native.DrawSprite("commonmenu", name, spriteX, spriteY, spriteWidth, spriteHeight, 0f, color, color, color, 255, false, false); Native.ResetScriptGfxAlign(); diff --git a/MenuAPI/items/MenuDynamicListItem.cs b/MenuAPI/items/MenuDynamicListItem.cs index bcf5119..80fff50 100644 --- a/MenuAPI/items/MenuDynamicListItem.cs +++ b/MenuAPI/items/MenuDynamicListItem.cs @@ -4,8 +4,20 @@ namespace MenuAPI; public class MenuDynamicListItem(string text, string? initialValue, MenuDynamicListItem.ChangeItemCallback callback, string? description) : MenuItem(text, description) { - public bool HideArrowsWhenNotSelected { get; set; } = false; - public string? CurrentItem { get; set; } = initialValue; + public bool HideArrowsWhenNotSelected + { + get => _hideArrowsWhenNotSelected; + set => MenuNui.Change(ref _hideArrowsWhenNotSelected, value); + } + + public string? CurrentItem + { + get => _currentItem; + set => MenuNui.Change(ref _currentItem, value); + } + + private bool _hideArrowsWhenNotSelected = false; + private string? _currentItem = initialValue; public delegate string ChangeItemCallback(MenuDynamicListItem item, bool left); @@ -13,7 +25,7 @@ public class MenuDynamicListItem(string text, string? initialValue, MenuDynamicL public MenuDynamicListItem(string text, string? initialValue, ChangeItemCallback callback) : this(text, initialValue, callback, null) { } - internal override void Draw(int indexOffset) + internal override void PrepareForDisplay() { if (HideArrowsWhenNotSelected && !Selected) { @@ -23,7 +35,6 @@ internal override void Draw(int indexOffset) { Label = $"~s~← {CurrentItem ?? "~r~N/A~s~"} ~s~→"; } - base.Draw(indexOffset); } internal override void GoRight() diff --git a/MenuAPI/items/MenuItem.cs b/MenuAPI/items/MenuItem.cs index 3c6d710..b3fc625 100644 --- a/MenuAPI/items/MenuItem.cs +++ b/MenuAPI/items/MenuItem.cs @@ -187,22 +187,47 @@ public enum Icon BRAND_ZIRCONIUM, INFO } - public string Text { get; set; } - public string? Label { get; set; } - public Icon LeftIcon { get; set; } - public Icon RightIcon { get; set; } - public bool Enabled { get; set; } = true; + public string Text + { + get => _text; + set => MenuNui.Change(ref _text, value); + } + + public string? Label + { + get => _label; + set => MenuNui.Change(ref _label, value); + } + + public Icon LeftIcon + { + get => _leftIcon; + set => MenuNui.Change(ref _leftIcon, value); + } + + public Icon RightIcon + { + get => _rightIcon; + set => MenuNui.Change(ref _rightIcon, value); + } + + public bool Enabled + { + get => _enabled; + set => MenuNui.Change(ref _enabled, value); + } + public string? Description { - get - { - return _description; - } - set - { - _description = value; - } + get => _description; + set => MenuNui.Change(ref _description, value); } + + private string _text = ""; + private string? _label; + private Icon _leftIcon; + private Icon _rightIcon; + private bool _enabled = true; private string? _description; public int Index => ParentMenu?.IndexOf(this) ?? -1; public bool Selected { get { if (ParentMenu != null) { return ParentMenu.CurrentIndex == Index; } return false; } } @@ -236,7 +261,7 @@ public MenuItem(string text, string? description) /// /// /// - protected string GetSpriteDictionary(Icon icon) + protected internal string GetSpriteDictionary(Icon icon) { return icon switch { @@ -259,7 +284,7 @@ protected string GetSpriteDictionary(Icon icon) /// /// /// - protected string GetSpriteName(Icon icon, bool selected) + protected internal string GetSpriteName(Icon icon, bool selected) { switch (icon) { @@ -455,14 +480,17 @@ protected string GetSpriteName(Icon icon, bool selected) /// /// /// - protected float GetSpriteSize(Icon icon, bool width) + protected float GetSpriteSize(Icon icon, bool width) => + GetSpriteSizePx(icon) / (width ? MenuLayout.ScreenWidth : MenuLayout.ScreenHeight); + + protected internal static float GetSpriteSizePx(Icon icon) { return icon switch { - Icon.CASH or Icon.COKE or Icon.CROWN or Icon.HEROIN or Icon.METH or Icon.WEED or Icon.ADVERSARY or Icon.BASE_JUMPING or Icon.BRIEFCASE or Icon.MISSION_STAR or Icon.DEATHMATCH or Icon.CASTLE or Icon.TROPHY or Icon.RACE_FLAG or Icon.RACE_FLAG_PLANE or Icon.RACE_FLAG_BICYCLE or Icon.RACE_FLAG_PERSON or Icon.RACE_FLAG_CAR or Icon.RACE_FLAG_BOAT_ANCHOR or Icon.ROCKSTAR or Icon.STUNT or Icon.STUNT_PREMIUM or Icon.RACE_FLAG_STUNT_JUMP or Icon.SHIELD or Icon.TEAM_DEATHMATCH or Icon.VEHICLE_DEATHMATCH or Icon.AUDIO_MUTE or Icon.AUDIO_INACTIVE or Icon.AUDIO_VOL1 or Icon.AUDIO_VOL2 or Icon.AUDIO_VOL3 or Icon.BRAND_ALBANY or Icon.BRAND_ANNIS or Icon.BRAND_BANSHEE or Icon.BRAND_BENEFACTOR or Icon.BRAND_BF or Icon.BRAND_BOLLOKAN or Icon.BRAND_BRAVADO or Icon.BRAND_BRUTE or Icon.BRAND_BUCKINGHAM or Icon.BRAND_CANIS or Icon.BRAND_CHARIOT or Icon.BRAND_CHEVAL or Icon.BRAND_CLASSIQUE or Icon.BRAND_COIL or Icon.BRAND_DECLASSE or Icon.BRAND_DEWBAUCHEE or Icon.BRAND_DILETTANTE or Icon.BRAND_DINKA or Icon.BRAND_DUNDREARY or Icon.BRAND_EMPORER or Icon.BRAND_ENUS or Icon.BRAND_FATHOM or Icon.BRAND_GALIVANTER or Icon.BRAND_GROTTI or Icon.BRAND_HIJAK or Icon.BRAND_HVY or Icon.BRAND_IMPONTE or Icon.BRAND_INVETERO or Icon.BRAND_JACKSHEEPE or Icon.BRAND_JOBUILT or Icon.BRAND_KARIN or Icon.BRAND_LAMPADATI or Icon.BRAND_MAIBATSU or Icon.BRAND_MAMMOTH or Icon.BRAND_MTL or Icon.BRAND_NAGASAKI or Icon.BRAND_OBEY or Icon.BRAND_OCELOT or Icon.BRAND_OVERFLOD or Icon.BRAND_PED or Icon.BRAND_PEGASSI or Icon.BRAND_PFISTER or Icon.BRAND_PRINCIPE or Icon.BRAND_PROGEN or Icon.BRAND_SCHYSTER or Icon.BRAND_SHITZU or Icon.BRAND_SPEEDOPHILE or Icon.BRAND_STANLEY or Icon.BRAND_TRUFFADE or Icon.BRAND_UBERMACHT or Icon.BRAND_VAPID or Icon.BRAND_VULCAR or Icon.BRAND_WEENY or Icon.BRAND_WESTERN or Icon.BRAND_WESTERNMOTORCYCLE or Icon.BRAND_WILLARD or Icon.BRAND_ZIRCONIUM or Icon.BRAND_GROTTI2 or Icon.BRAND_LCC or Icon.BRAND_PROGEN2 or Icon.BRAND_RUNE or Icon.COUNTRY_USA or Icon.COUNTRY_UK or Icon.COUNTRY_SWEDEN or Icon.COUNTRY_KOREA or Icon.COUNTRY_JAPAN or Icon.COUNTRY_ITALY or Icon.COUNTRY_GERMANY or Icon.COUNTRY_FRANCE => 30f / (width ? MenuLayout.ScreenWidth : MenuLayout.ScreenHeight), - Icon.STAR or Icon.LOCK_ARENA => 52f / (width ? MenuLayout.ScreenWidth : MenuLayout.ScreenHeight), - Icon.MEDAL_SILVER or Icon.MP_AMMO_PICKUP or Icon.MP_AMMO or Icon.MP_CASH or Icon.MP_RP or Icon.GLOBE_WHITE or Icon.GLOBE_BLUE or Icon.GLOBE_GREEN or Icon.GLOBE_ORANGE or Icon.GLOBE_RED or Icon.GLOBE_YELLOW or Icon.INV_ARM_WRESTLING or Icon.INV_BASEJUMP or Icon.INV_MISSION or Icon.INV_DARTS or Icon.INV_DEATHMATCH or Icon.INV_DRUG or Icon.INV_CASTLE or Icon.INV_GOLF or Icon.INV_BIKE or Icon.INV_BOAT or Icon.INV_ANCHOR or Icon.INV_CAR or Icon.INV_DOLLAR or Icon.INV_COKE or Icon.INV_KEY or Icon.INV_DATA or Icon.INV_HELI or Icon.INV_HEORIN or Icon.INV_KEYCARD or Icon.INV_METH or Icon.INV_BRIEFCASE or Icon.INV_LINK or Icon.INV_PERSON or Icon.INV_PLANE or Icon.INV_PLANE2 or Icon.INV_QUESTIONMARK or Icon.INV_REMOTE or Icon.INV_SAFE or Icon.INV_STEER_WHEEL or Icon.INV_WEAPON or Icon.INV_WEED or Icon.INV_RACE_FLAG_PLANE or Icon.INV_RACE_FLAG_BICYCLE or Icon.INV_RACE_FLAG_BOAT_ANCHOR or Icon.INV_RACE_FLAG_PERSON or Icon.INV_RACE_FLAG_CAR or Icon.INV_RACE_FLAG_HELMET or Icon.INV_SHOOTING_RANGE or Icon.INV_SURVIVAL or Icon.INV_TEAM_DEATHMATCH or Icon.INV_TENNIS or Icon.INV_VEHICLE_DEATHMATCH => 22f / (width ? MenuLayout.ScreenWidth : MenuLayout.ScreenHeight), - _ => 38f / (width ? MenuLayout.ScreenWidth : MenuLayout.ScreenHeight), + Icon.CASH or Icon.COKE or Icon.CROWN or Icon.HEROIN or Icon.METH or Icon.WEED or Icon.ADVERSARY or Icon.BASE_JUMPING or Icon.BRIEFCASE or Icon.MISSION_STAR or Icon.DEATHMATCH or Icon.CASTLE or Icon.TROPHY or Icon.RACE_FLAG or Icon.RACE_FLAG_PLANE or Icon.RACE_FLAG_BICYCLE or Icon.RACE_FLAG_PERSON or Icon.RACE_FLAG_CAR or Icon.RACE_FLAG_BOAT_ANCHOR or Icon.ROCKSTAR or Icon.STUNT or Icon.STUNT_PREMIUM or Icon.RACE_FLAG_STUNT_JUMP or Icon.SHIELD or Icon.TEAM_DEATHMATCH or Icon.VEHICLE_DEATHMATCH or Icon.AUDIO_MUTE or Icon.AUDIO_INACTIVE or Icon.AUDIO_VOL1 or Icon.AUDIO_VOL2 or Icon.AUDIO_VOL3 or Icon.BRAND_ALBANY or Icon.BRAND_ANNIS or Icon.BRAND_BANSHEE or Icon.BRAND_BENEFACTOR or Icon.BRAND_BF or Icon.BRAND_BOLLOKAN or Icon.BRAND_BRAVADO or Icon.BRAND_BRUTE or Icon.BRAND_BUCKINGHAM or Icon.BRAND_CANIS or Icon.BRAND_CHARIOT or Icon.BRAND_CHEVAL or Icon.BRAND_CLASSIQUE or Icon.BRAND_COIL or Icon.BRAND_DECLASSE or Icon.BRAND_DEWBAUCHEE or Icon.BRAND_DILETTANTE or Icon.BRAND_DINKA or Icon.BRAND_DUNDREARY or Icon.BRAND_EMPORER or Icon.BRAND_ENUS or Icon.BRAND_FATHOM or Icon.BRAND_GALIVANTER or Icon.BRAND_GROTTI or Icon.BRAND_HIJAK or Icon.BRAND_HVY or Icon.BRAND_IMPONTE or Icon.BRAND_INVETERO or Icon.BRAND_JACKSHEEPE or Icon.BRAND_JOBUILT or Icon.BRAND_KARIN or Icon.BRAND_LAMPADATI or Icon.BRAND_MAIBATSU or Icon.BRAND_MAMMOTH or Icon.BRAND_MTL or Icon.BRAND_NAGASAKI or Icon.BRAND_OBEY or Icon.BRAND_OCELOT or Icon.BRAND_OVERFLOD or Icon.BRAND_PED or Icon.BRAND_PEGASSI or Icon.BRAND_PFISTER or Icon.BRAND_PRINCIPE or Icon.BRAND_PROGEN or Icon.BRAND_SCHYSTER or Icon.BRAND_SHITZU or Icon.BRAND_SPEEDOPHILE or Icon.BRAND_STANLEY or Icon.BRAND_TRUFFADE or Icon.BRAND_UBERMACHT or Icon.BRAND_VAPID or Icon.BRAND_VULCAR or Icon.BRAND_WEENY or Icon.BRAND_WESTERN or Icon.BRAND_WESTERNMOTORCYCLE or Icon.BRAND_WILLARD or Icon.BRAND_ZIRCONIUM or Icon.BRAND_GROTTI2 or Icon.BRAND_LCC or Icon.BRAND_PROGEN2 or Icon.BRAND_RUNE or Icon.COUNTRY_USA or Icon.COUNTRY_UK or Icon.COUNTRY_SWEDEN or Icon.COUNTRY_KOREA or Icon.COUNTRY_JAPAN or Icon.COUNTRY_ITALY or Icon.COUNTRY_GERMANY or Icon.COUNTRY_FRANCE => 30f, + Icon.STAR or Icon.LOCK_ARENA => 52f, + Icon.MEDAL_SILVER or Icon.MP_AMMO_PICKUP or Icon.MP_AMMO or Icon.MP_CASH or Icon.MP_RP or Icon.GLOBE_WHITE or Icon.GLOBE_BLUE or Icon.GLOBE_GREEN or Icon.GLOBE_ORANGE or Icon.GLOBE_RED or Icon.GLOBE_YELLOW or Icon.INV_ARM_WRESTLING or Icon.INV_BASEJUMP or Icon.INV_MISSION or Icon.INV_DARTS or Icon.INV_DEATHMATCH or Icon.INV_DRUG or Icon.INV_CASTLE or Icon.INV_GOLF or Icon.INV_BIKE or Icon.INV_BOAT or Icon.INV_ANCHOR or Icon.INV_CAR or Icon.INV_DOLLAR or Icon.INV_COKE or Icon.INV_KEY or Icon.INV_DATA or Icon.INV_HELI or Icon.INV_HEORIN or Icon.INV_KEYCARD or Icon.INV_METH or Icon.INV_BRIEFCASE or Icon.INV_LINK or Icon.INV_PERSON or Icon.INV_PLANE or Icon.INV_PLANE2 or Icon.INV_QUESTIONMARK or Icon.INV_REMOTE or Icon.INV_SAFE or Icon.INV_STEER_WHEEL or Icon.INV_WEAPON or Icon.INV_WEED or Icon.INV_RACE_FLAG_PLANE or Icon.INV_RACE_FLAG_BICYCLE or Icon.INV_RACE_FLAG_BOAT_ANCHOR or Icon.INV_RACE_FLAG_PERSON or Icon.INV_RACE_FLAG_CAR or Icon.INV_RACE_FLAG_HELMET or Icon.INV_SHOOTING_RANGE or Icon.INV_SURVIVAL or Icon.INV_TEAM_DEATHMATCH or Icon.INV_TENNIS or Icon.INV_VEHICLE_DEATHMATCH => 22f, + _ => 38f, }; } @@ -472,7 +500,7 @@ protected float GetSpriteSize(Icon icon, bool width) /// /// /// - protected (int R, int G, int B) GetSpriteColour(Icon icon, bool selected) + protected internal (int R, int G, int B) GetSpriteColour(Icon icon, bool selected) { return icon switch { @@ -553,6 +581,8 @@ internal virtual void Select() ParentMenu?.ItemSelectedEvent(this, Index); } + internal virtual void PrepareForDisplay() { } + /// How far down the menu the first row starts. Identical for every row in a menu. internal static float RowYOffset(Menu parent) => parent.MenuItemsYOffset + 1f - (RowHeight * Math.Clamp(parent.Size, 0, parent.MaxItemsOnScreen)); @@ -562,6 +592,8 @@ internal static float RowYOffset(Menu parent) => /// internal virtual void Draw(int indexOffset) { + PrepareForDisplay(); + if (ParentMenu is not Menu parent) { return; diff --git a/MenuAPI/items/MenuItemList.cs b/MenuAPI/items/MenuItemList.cs new file mode 100644 index 0000000..bc0c1a7 --- /dev/null +++ b/MenuAPI/items/MenuItemList.cs @@ -0,0 +1,185 @@ +using System.Collections; + +namespace MenuAPI; + +public sealed class MenuItemList : IList +{ + private readonly List _items; + + public MenuItemList() => _items = new List(); + + public MenuItemList(IEnumerable items) => _items = new List(items); + + public static implicit operator MenuItemList(List items) => new(items); + + public int Count => _items.Count; + public bool IsReadOnly => false; + + public string this[int index] + { + get => _items[index]; + set + { + if (_items[index] == value) + { + return; + } + + _items[index] = value; + + MenuNui.Invalidate(); + } + } + + public void Add(string item) + { + _items.Add(item); + + MenuNui.Invalidate(); + } + + public void AddRange(IEnumerable items) + { + _items.AddRange(items); + + MenuNui.Invalidate(); + } + + public void Insert(int index, string item) + { + _items.Insert(index, item); + + MenuNui.Invalidate(); + } + + public bool Remove(string item) + { + if (!_items.Remove(item)) + { + return false; + } + + MenuNui.Invalidate(); + + return true; + } + + public void RemoveAt(int index) + { + _items.RemoveAt(index); + + MenuNui.Invalidate(); + } + + public void Clear() + { + if (_items.Count == 0) + { + return; + } + + _items.Clear(); + + MenuNui.Invalidate(); + } + + public void Sort() => Sort(null); + + public void Sort(Comparison? compare) + { + if (compare is null) + { + _items.Sort(); + } + else + { + _items.Sort(compare); + } + + MenuNui.Invalidate(); + } + + public void InsertRange(int index, IEnumerable items) + { + var before = _items.Count; + + _items.InsertRange(index, items); + + if (_items.Count != before) + { + MenuNui.Invalidate(); + } + } + + public int RemoveAll(Predicate match) + { + var removed = _items.RemoveAll(match); + + if (removed > 0) + { + MenuNui.Invalidate(); + } + + return removed; + } + + public void RemoveRange(int index, int count) + { + if (count <= 0) + { + return; + } + + _items.RemoveRange(index, count); + + MenuNui.Invalidate(); + } + + public void Reverse() + { + if (_items.Count < 2) + { + return; + } + + _items.Reverse(); + + MenuNui.Invalidate(); + } + + public bool Contains(string item) => _items.Contains(item); + + public int IndexOf(string item) => _items.IndexOf(item); + + public int LastIndexOf(string item) => _items.LastIndexOf(item); + + public bool Exists(Predicate match) => _items.Exists(match); + + public string? Find(Predicate match) => _items.Find(match); + + public string? FindLast(Predicate match) => _items.FindLast(match); + + public int FindIndex(Predicate match) => _items.FindIndex(match); + + public int FindLastIndex(Predicate match) => _items.FindLastIndex(match); + + public List FindAll(Predicate match) => _items.FindAll(match); + + public bool TrueForAll(Predicate match) => _items.TrueForAll(match); + + public List ConvertAll(Converter converter) => _items.ConvertAll(converter); + + public List GetRange(int index, int count) => _items.GetRange(index, count); + + public void ForEach(Action action) => _items.ForEach(action); + + public void CopyTo(string[] array, int arrayIndex) => _items.CopyTo(array, arrayIndex); + + public string[] ToArray() => _items.ToArray(); + + public List ToList() => new(_items); + + public IEnumerator GetEnumerator() => _items.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => _items.GetEnumerator(); +} diff --git a/MenuAPI/items/MenuListItem.cs b/MenuAPI/items/MenuListItem.cs index 4e244bd..faa29cb 100644 --- a/MenuAPI/items/MenuListItem.cs +++ b/MenuAPI/items/MenuListItem.cs @@ -4,12 +4,59 @@ namespace MenuAPI; public class MenuListItem(string text, List items, int index, string? description) : MenuItem(text, description) { - public int ListIndex { get; set; } = index; - public List ListItems { get; set; } = items; - public bool HideArrowsWhenNotSelected { get; set; } = false; - public bool ShowOpacityPanel { get; set; } = false; - public bool ShowColorPanel { get; set; } = false; - public ColorPanelType ColorPanelColorType = ColorPanelType.Hair; + public int ListIndex + { + get => _listIndex; + set => MenuNui.Change(ref _listIndex, value); + } + + public MenuItemList ListItems + { + get => _listItems; + set => MenuNui.Change(ref _listItems, value ?? new MenuItemList()); + } + + public bool HideArrowsWhenNotSelected + { + get => _hideArrowsWhenNotSelected; + set => MenuNui.Change(ref _hideArrowsWhenNotSelected, value); + } + + public bool ShowOpacityPanel + { + get => _showOpacityPanel; + set => MenuNui.Change(ref _showOpacityPanel, value); + } + + public bool ShowColorPanel + { + get => _showColorPanel; + set => MenuNui.Change(ref _showColorPanel, value); + } + + public ColorPanelType ColorPanelColorType + { + get => _colorPanelColorType; + set => MenuNui.Change(ref _colorPanelColorType, value); + } + + private int _listIndex = index; + private MenuItemList _listItems = new(items); + private bool _hideArrowsWhenNotSelected = false; + private bool _showOpacityPanel = false; + private bool _showColorPanel = false; + private ColorPanelType _colorPanelColorType = ColorPanelType.Hair; + + private int opacityPercent; + + public int OpacityPercent + { + get => opacityPercent; + set => MenuNui.Change(ref opacityPercent, Math.Clamp(value, 0, 100)); + } + + internal int ResolvedOpacityPercent => + ShowColorPanel ? OpacityPercent : Math.Clamp(ListIndex * 10, 0, 100); public enum ColorPanelType { Hair, @@ -28,7 +75,7 @@ public enum ColorPanelType public MenuListItem(string text, List items, int index) : this(text, items, index, null) { } - internal override void Draw(int indexOffset) + internal override void PrepareForDisplay() { if (ItemsCount < 1) { @@ -54,8 +101,6 @@ internal override void Draw(int indexOffset) { Label = $"~s~← {GetCurrentSelection() ?? "~r~N/A~s~"} ~s~→"; } - - base.Draw(indexOffset); } internal override void GoRight() diff --git a/MenuAPI/items/MenuSliderItem.cs b/MenuAPI/items/MenuSliderItem.cs index d85768e..7c96869 100644 --- a/MenuAPI/items/MenuSliderItem.cs +++ b/MenuAPI/items/MenuSliderItem.cs @@ -6,14 +6,49 @@ public class MenuSliderItem(string name, string? description, int min, int max, { public int Min { get; private set; } = min; public int Max { get; private set; } = max; - public bool ShowDivider { get; set; } = showDivider; - public int Position { get; set; } = startPosition; - public Icon SliderLeftIcon { get; set; } = Icon.NONE; - public Icon SliderRightIcon { get; set; } = Icon.NONE; + public bool ShowDivider + { + get => _showDivider; + set => MenuNui.Change(ref _showDivider, value); + } + + public int Position + { + get => _position; + set => MenuNui.Change(ref _position, value); + } + + public Icon SliderLeftIcon + { + get => _sliderLeftIcon; + set => MenuNui.Change(ref _sliderLeftIcon, value); + } - public System.Drawing.Color BackgroundColor { get; set; } = System.Drawing.Color.FromArgb(255, 24, 93, 151); - public System.Drawing.Color BarColor { get; set; } = System.Drawing.Color.FromArgb(255, 53, 165, 223); + public Icon SliderRightIcon + { + get => _sliderRightIcon; + set => MenuNui.Change(ref _sliderRightIcon, value); + } + + public System.Drawing.Color BackgroundColor + { + get => _backgroundColor; + set => MenuNui.Change(ref _backgroundColor, value); + } + + public System.Drawing.Color BarColor + { + get => _barColor; + set => MenuNui.Change(ref _barColor, value); + } + + private bool _showDivider = showDivider; + private int _position = startPosition; + private Icon _sliderLeftIcon = Icon.NONE; + private Icon _sliderRightIcon = Icon.NONE; + private System.Drawing.Color _backgroundColor = System.Drawing.Color.FromArgb(255, 24, 93, 151); + private System.Drawing.Color _barColor = System.Drawing.Color.FromArgb(255, 53, 165, 223); public MenuSliderItem(string name, int min, int max, int startPosition) : this(name, min, max, startPosition, false) { } public MenuSliderItem(string name, int min, int max, int startPosition, bool showDivider) : this(name, null, min, max, startPosition, showDivider) { } @@ -33,11 +68,19 @@ private static float Map(float val, float in_min, float in_max, float out_min, f return (val - in_min) * (out_max - out_min) / (in_max - in_min) + out_min; } - internal override void Draw(int indexOffset) + internal override void PrepareForDisplay() { RightIcon = SliderRightIcon; Label = null; + if (Position > Max || Position < Min) + { + Position = (Max - Min) / 2; + } + } + + internal override void Draw(int indexOffset) + { base.Draw(indexOffset); if (ParentMenu is not Menu parent) @@ -45,11 +88,6 @@ internal override void Draw(int indexOffset) return; } - if (Position > Max || Position < Min) - { - Position = (Max - Min) / 2; - } - int index = Index; bool selected = parent.CurrentIndex == index; diff --git a/MenuAPI/items/SeparatorMenuItem.cs b/MenuAPI/items/SeparatorMenuItem.cs index 9f590f8..6398809 100644 --- a/MenuAPI/items/SeparatorMenuItem.cs +++ b/MenuAPI/items/SeparatorMenuItem.cs @@ -17,7 +17,13 @@ public class SeparatorMenuItem : MenuItem private const int TextOnHighlight = 0; /// Draws the text as ↓ Text ↓. On by default. - public bool ShowArrows { get; set; } = true; + public bool ShowArrows + { + get => _showArrows; + set => MenuNui.Change(ref _showArrows, value); + } + + private bool _showArrows = true; /// /// Creates a with the down arrows around its text. diff --git a/MenuAPI/nui/MenuNui.cs b/MenuAPI/nui/MenuNui.cs new file mode 100644 index 0000000..1073a93 --- /dev/null +++ b/MenuAPI/nui/MenuNui.cs @@ -0,0 +1,514 @@ +using System.Globalization; + +using CitizenFX.FiveM.Client; + +namespace MenuAPI; + +internal static class MenuNui +{ + private const string MessageType = "menuapi"; + + private const string HideMessage = "{\"type\":\"menuapi\",\"visible\":false}"; + + private const float RotationTolerance = 0.5f; + private const int FreemodeHudColour = 116; + private const int PauseBackgroundHudColour = 117; + private const int WhiteHudColour = 1; + private const int PaletteSize = 64; + + private static string? _sent; + private static readonly NuiJson _snapshot = new(); + private static readonly NuiJson _glare = new(); + private static bool _dirty = true; + private static readonly List _pendingTextures = new(); + private static float _heading = float.NaN; + private static string? _freemode; + private static string? _theme; + private static string? _accent; + private static MenuListItem.ColorPanelType? _palette; + + internal static void Invalidate() => _dirty = true; + + internal static void Change(ref T field, T value) + { + if (EqualityComparer.Default.Equals(field, value)) + { + return; + } + + field = value; + + _dirty = true; + } + + internal static void RequestPendingTextures() + { + for (var i = _pendingTextures.Count - 1; i >= 0; i--) + { + if (TextureDictionaries.Request(_pendingTextures[i])) + { + _pendingTextures.RemoveAt(i); + } + } + } + + internal static void SendChanges(Menu menu) + { + if (_dirty) + { + SendSnapshot(menu); + } + + if (menu.ResolvedShowHeaderGlare) + { + SendHeading(); + } + } + + internal static void SendSnapshot(Menu menu) + { + _dirty = false; + + Build(menu); + + if (_snapshot.Matches(_sent)) + { + return; + } + + _sent = _snapshot.ToString(); + + Native.SendNuiMessage(_sent); + } + + internal static void Hide() + { + if (_sent == HideMessage) + { + return; + } + + _sent = HideMessage; + + _dirty = true; + + _heading = float.NaN; + + _palette = null; + + _pendingTextures.Clear(); + + Native.SendNuiMessage(HideMessage); + } + + private static void SendHeading() + { + var heading = Wrap(Native.GetFinalRenderedCamRot(2).Z); + + if (!float.IsNaN(_heading) && Math.Abs(_heading - heading) <= RotationTolerance) + { + return; + } + + _heading = heading; + + var json = _glare + .Reset() + .Object() + .Prop("type", "menuapi:glare") + .Prop("heading", heading) + .EndObject() + .ToString(); + + Native.SendNuiMessage(json); + } + + private static void Build(Menu menu) + { + _pendingTextures.Clear(); + + var json = _snapshot + .Reset() + .Object() + .Prop("type", MessageType) + .Prop("visible", true) + .Prop("align", menu.LeftAligned ? "left" : "right"); + + WriteOrigin(json, menu); + + json.Prop("panelBackground", PanelBackground()) + .Prop("panelAccent", PanelAccent()); + + json.Object("text") + .Prop("size", NuiTuning.TextSize) + .Prop("brightness", NuiTuning.TextBrightness) + .Prop("weight", NuiTuning.WeightName) + .EndObject(); + + WriteHeader(json, menu); + WriteSubtitle(json, menu); + WriteRows(json, menu); + + json.Prop("overflow", menu.Size > menu.MaxItemsOnScreen); + + json.Prop("description", menu.GetCurrentMenuItem()?.Description); + + WritePanel(json, menu); + WriteStats(json, menu); + + json.EndObject(); + } + + private static void WriteOrigin(NuiJson json, Menu menu) + { + var inset = (1f - MenuLayout.SafeZone) / 2f; + + var x = menu.LeftAligned + ? inset + : 1f - inset - (Menu.Width / MenuLayout.ScreenWidth); + + json.Object("origin") + .Prop("x", x) + .Prop("y", inset) + .EndObject(); + } + + private static void WriteHeader(NuiJson json, Menu menu) + { + if (string.IsNullOrEmpty(menu.MenuTitle)) + { + json.Null("header"); + + return; + } + + json.Object("header") + .Prop("title", menu.MenuTitle) + .Prop("font", menu.ResolvedTitleFont) + .Prop("titleAlign", menu.ResolvedTitleAlignment switch + { + Menu.TitleAlignmentOption.Left => "left", + Menu.TitleAlignmentOption.Right => "right", + _ => "center", + }) + .Prop("glare", menu.ResolvedShowHeaderGlare); + + var custom = !string.IsNullOrEmpty(menu.HeaderTexture.Key) && !string.IsNullOrEmpty(menu.HeaderTexture.Value); + var dictionary = custom ? menu.HeaderTexture.Key : MenuController._texture_dict; + + if (TextureReady(dictionary)) + { + json.Object("texture") + .Prop("dict", dictionary) + .Prop("name", custom ? menu.HeaderTexture.Value : MenuController._header_texture) + .EndObject(); + } + else + { + json.Null("texture"); + } + + json.EndObject(); + } + + private static void WriteSubtitle(NuiJson json, Menu menu) + { + var counter = !string.IsNullOrEmpty(menu.CounterPreText) || menu.MaxItemsOnScreen < menu.Size + ? (menu.CounterPreText ?? "") + (menu.CurrentIndex + 1) + " / " + menu.Size + : null; + + json.Object("subtitle") + .Prop("text", menu.MenuSubtitle) + .Prop("counter", counter) + .Prop("colour", Freemode()) + .Prop("freemode", !(menu.MenuSubtitle ?? "").Contains('~') + && !(menu.CounterPreText ?? "").Contains('~') + && !string.IsNullOrEmpty(menu.MenuTitle)) + .EndObject(); + } + + private static void WriteRows(NuiJson json, Menu menu) + { + json.Array("rows"); + + var visible = menu.VisibleMenuItems; + + for (var i = 0; i < visible.Count; i++) + { + var item = visible[i]; + + item.PrepareForDisplay(); + + var selected = menu.CurrentIndex == menu.ViewIndexOffset + i; + + json.Object() + .Prop("kind", item switch + { + MenuCheckboxItem => "checkbox", + MenuSliderItem => "slider", + SeparatorMenuItem => "separator", + _ => "item", + }) + .Prop("text", item.Text) + .Prop("label", item.Label) + .Prop("enabled", item.Enabled) + .Prop("selected", selected); + + WriteIcon(json, "leftIcon", item, item.LeftIcon, selected); + WriteIcon(json, "rightIcon", item, item.RightIcon, selected); + + switch (item) + { + case MenuCheckboxItem checkbox when TextureReady(MenuController._texture_dict): + json.Object("checkbox") + .Prop("dict", MenuController._texture_dict) + .Prop("name", checkbox.GetSpriteName(selected)) + .Prop("size", MenuCheckboxItem.SpriteSizePx) + .Prop("shade", checkbox.Enabled ? 255 : 109) + .EndObject(); + + break; + + case MenuSliderItem slider: + json.Object("slider") + .Prop("min", slider.Min) + .Prop("max", slider.Max) + .Prop("position", slider.Position) + .Prop("divider", slider.ShowDivider) + .Prop("background", Hex(slider.BackgroundColor)) + .Prop("bar", Hex(slider.BarColor)); + + WriteIcon(json, "sliderLeftIcon", slider, slider.SliderLeftIcon, selected); + + json.EndObject(); + + break; + + case SeparatorMenuItem separator: + json.Prop("arrows", separator.ShowArrows); + + break; + } + + json.EndObject(); + } + + json.EndArray(); + } + + private static bool TextureReady(string dict) + { + if (TextureDictionaries.Request(dict)) + { + return true; + } + + if (!_pendingTextures.Contains(dict)) + { + _pendingTextures.Add(dict); + } + + return false; + } + + private static void WriteIcon(NuiJson json, string name, MenuItem item, MenuItem.Icon icon, bool selected) + { + if (icon == MenuItem.Icon.NONE) + { + json.Null(name); + + return; + } + + var dictionary = item.GetSpriteDictionary(icon); + + if (!TextureReady(dictionary)) + { + json.Null(name); + + return; + } + + var colour = item.GetSpriteColour(icon, selected); + + json.Object(name) + .Prop("dict", dictionary) + .Prop("name", item.GetSpriteName(icon, selected)) + .Prop("size", MenuItem.GetSpriteSizePx(icon)) + .Prop("r", colour.R) + .Prop("g", colour.G) + .Prop("b", colour.B) + .EndObject(); + } + + private static void WritePanel(NuiJson json, Menu menu) + { + if (menu.GetCurrentMenuItem() is not MenuListItem item + || (!item.ShowColorPanel && !item.ShowOpacityPanel)) + { + json.Null("panel"); + + return; + } + + json.Object("panel") + .Prop("colours", item.ShowColorPanel) + .Prop("index", item.ListIndex) + .Prop("title", "Opacity"); + + if (item.ShowOpacityPanel) + { + json.Prop("opacity", item.ResolvedOpacityPercent); + } + else + { + json.Null("opacity"); + } + + json.Prop("name", item.ShowColorPanel ? ColourName(item.ListIndex + 1, item.ItemsCount) : null); + + json.EndObject(); + + if (item.ShowColorPanel) + { + SendPalette(item.ColorPanelColorType); + } + } + + private static void SendPalette(MenuListItem.ColorPanelType type) + { + if (_palette == type) + { + return; + } + + _palette = type; + + var json = new NuiJson() + .Object() + .Prop("type", "menuapi:palette") + .Array("colours"); + + for (var i = 0; i < PaletteSize; i++) + { + int r; + int g; + int b; + + if (type == MenuListItem.ColorPanelType.Hair) + { + Native.GetHairRgbColor(i, out r, out g, out b); + } + else + { + Native.GetMakeupRgbColor(i, out r, out g, out b); + } + + json.Array().Value(r).Value(g).Value(b).EndArray(); + } + + Native.SendNuiMessage(json.EndArray().EndObject().ToString()); + } + + private static string ColourName(int position, int count) + { + var template = Native.GetLabelText("FACE_COLOUR"); + + if (string.IsNullOrEmpty(template) || template == "NULL") + { + return position + " / " + count; + } + + return Substitute(Substitute(template, position), count); + } + + private static string Substitute(string text, int value) + { + var at = text.IndexOf("~1~", StringComparison.Ordinal); + + return at < 0 ? text : text[..at] + value + text[(at + 3)..]; + } + + private static void WriteStats(NuiJson json, Menu menu) + { + if (menu.GetCurrentMenuItem() is MenuListItem { ShowColorPanel: true } or MenuListItem { ShowOpacityPanel: true }) + { + json.Null("stats"); + + return; + } + + if (!menu.ShowWeaponStatsPanel && !menu.ShowVehicleStatsPanel) + { + json.Null("stats"); + + return; + } + + var weapon = menu.ShowWeaponStatsPanel; + var values = weapon ? menu.WeaponStats : menu.VehicleStats; + var upgrades = weapon ? menu.WeaponComponentStats : menu.VehicleUpgradeStats; + + json.Array("stats"); + + for (var i = 0; i < 4; i++) + { + json.Object() + .Prop("label", Native.GetLabelText(menu.StatLabelKey(i))) + .Prop("value", values[i]) + .Prop("upgrade", upgrades[i]) + .EndObject(); + } + + json.EndArray(); + } + + private static string Freemode() + { + if (_freemode is null) + { + Native.GetHudColour(FreemodeHudColour, out var r, out var g, out var b, out _); + + _freemode = $"rgb({r} {g} {b})"; + } + + return _freemode; + } + + private static string PanelBackground() + { + if (_theme is null) + { + Native.GetHudColour(PauseBackgroundHudColour, out var r, out var g, out var b, out var a); + + var alpha = (a / 255f).ToString("0.###", CultureInfo.InvariantCulture); + + _theme = "rgb(" + r + " " + g + " " + b + " / " + alpha + ")"; + } + + return _theme; + } + + private static string PanelAccent() + { + if (_accent is null) + { + Native.GetHudColour(WhiteHudColour, out var r, out var g, out var b, out _); + + // Comma separated: it goes into rgba(var(--accent), 0.3). + _accent = r + ", " + g + ", " + b; + } + + return _accent; + } + + private static string Hex(System.Drawing.Color colour) => + "#" + colour.R.ToString("x2") + colour.G.ToString("x2") + colour.B.ToString("x2"); + + private static float Wrap(float degrees) + { + var wrapped = degrees % 360f; + + return wrapped < 0f ? wrapped + 360f : wrapped; + } +} diff --git a/MenuAPI/nui/MenuRenderMode.cs b/MenuAPI/nui/MenuRenderMode.cs new file mode 100644 index 0000000..180bf11 --- /dev/null +++ b/MenuAPI/nui/MenuRenderMode.cs @@ -0,0 +1,7 @@ +namespace MenuAPI; + +public enum MenuRenderMode +{ + Native, + Nui, +} diff --git a/MenuAPI/nui/NuiJson.cs b/MenuAPI/nui/NuiJson.cs new file mode 100644 index 0000000..f4d38b8 --- /dev/null +++ b/MenuAPI/nui/NuiJson.cs @@ -0,0 +1,230 @@ +using System.Globalization; +using System.Text; + +namespace MenuAPI; + +internal sealed class NuiJson +{ + private readonly StringBuilder _builder = new(); + private bool _needsComma; + + internal NuiJson Reset() + { + _builder.Clear(); + _needsComma = false; + + return this; + } + + internal bool Matches(string? value) => value is not null && _builder.Equals(value.AsSpan()); + + internal NuiJson Object() + { + Separate(); + _builder.Append('{'); + _needsComma = false; + + return this; + } + + internal NuiJson EndObject() + { + _builder.Append('}'); + _needsComma = true; + + return this; + } + + internal NuiJson Array() + { + Separate(); + _builder.Append('['); + _needsComma = false; + + return this; + } + + internal NuiJson EndArray() + { + _builder.Append(']'); + _needsComma = true; + + return this; + } + + internal NuiJson Object(string name) + { + Key(name); + _builder.Append('{'); + _needsComma = false; + + return this; + } + + internal NuiJson Array(string name) + { + Key(name); + _builder.Append('['); + _needsComma = false; + + return this; + } + + internal NuiJson Prop(string name, string? value) + { + Key(name); + + if (value is null) + { + _builder.Append("null"); + } + else + { + Escape(value); + } + + _needsComma = true; + + return this; + } + + internal NuiJson Prop(string name, bool value) + { + Key(name); + _builder.Append(value ? "true" : "false"); + _needsComma = true; + + return this; + } + + internal NuiJson Prop(string name, int value) + { + Key(name); + _builder.Append(value); + _needsComma = true; + + return this; + } + + internal NuiJson Prop(string name, float value) + { + Key(name); + _builder.Append(value.ToString("0.####", CultureInfo.InvariantCulture)); + _needsComma = true; + + return this; + } + + internal NuiJson Null(string name) + { + Key(name); + _builder.Append("null"); + _needsComma = true; + + return this; + } + + internal NuiJson Value(int value) + { + Separate(); + _builder.Append(value); + _needsComma = true; + + return this; + } + + internal NuiJson Value(string value) + { + Separate(); + Escape(value); + _needsComma = true; + + return this; + } + + public override string ToString() => _builder.ToString(); + + private void Key(string name) + { + Separate(); + Escape(name); + _builder.Append(':'); + } + + private void Separate() + { + if (_needsComma) + { + _builder.Append(','); + } + } + + private void Escape(string value) + { + _builder.Append('"'); + + if (!NeedsEscaping(value)) + { + _builder.Append(value).Append('"'); + + return; + } + + for (var i = 0; i < value.Length; i++) + { + var character = value[i]; + + switch (character) + { + case '"': + _builder.Append("\\\""); + break; + + case '\\': + _builder.Append("\\\\"); + break; + + case '\n': + _builder.Append("\\n"); + break; + + case '\r': + _builder.Append("\\r"); + break; + + case '\t': + _builder.Append("\\t"); + break; + + default: + if (character < ' ') + { + _builder.Append("\\u").Append(((int)character).ToString("x4")); + } + else + { + _builder.Append(character); + } + + break; + } + } + + _builder.Append('"'); + } + + private static bool NeedsEscaping(string value) + { + for (var i = 0; i < value.Length; i++) + { + var character = value[i]; + + if (character == '"' || character == '\\' || character < ' ') + { + return true; + } + } + + return false; + } +} diff --git a/MenuAPI/nui/NuiTuning.cs b/MenuAPI/nui/NuiTuning.cs new file mode 100644 index 0000000..b05eaa7 --- /dev/null +++ b/MenuAPI/nui/NuiTuning.cs @@ -0,0 +1,70 @@ +namespace MenuAPI; + +public static class NuiTuning +{ + private static float _textSize = DefaultTextSize; + private static int _textBrightness = DefaultTextBrightness; + private static TextWeightMode _textWeight = TextWeightMode.Default; + + public const float DefaultTextSize = 21f; + public const int DefaultTextBrightness = 225; + + public enum TextWeightMode + { + Default, + GeometricPrecision, + Supersampled, + } + + public static float TextSize + { + get => _textSize; + set + { + _textSize = Math.Clamp(value, 8f, 48f); + + MenuNui.Invalidate(); + } + } + + public static int TextBrightness + { + get => _textBrightness; + set + { + _textBrightness = Math.Clamp(value, 0, 255); + + MenuNui.Invalidate(); + } + } + + public static TextWeightMode TextWeight + { + get => _textWeight; + set + { + _textWeight = value; + + MenuNui.Invalidate(); + } + } + + public static void Reset() + { + _textSize = DefaultTextSize; + _textBrightness = DefaultTextBrightness; + _textWeight = TextWeightMode.Default; + + MenuNui.Invalidate(); + } + + public static string Describe() => + $"size {_textSize:0.##}px, brightness {_textBrightness}, weight {_textWeight}"; + + internal static string WeightName => _textWeight switch + { + TextWeightMode.GeometricPrecision => "geometric", + TextWeightMode.Supersampled => "supersampled", + _ => "default", + }; +} diff --git a/MenuAPI/ticks/FrameState.cs b/MenuAPI/ticks/FrameState.cs new file mode 100644 index 0000000..55087f5 --- /dev/null +++ b/MenuAPI/ticks/FrameState.cs @@ -0,0 +1,130 @@ +using CitizenFX.FiveM.Client; + +namespace MenuAPI; + +internal static class FrameState +{ + private static int _ped; + private static bool _hasPed; + private static bool _isDead; + private static bool _hasIsDead; + private static bool _isInVehicle; + private static bool _hasIsInVehicle; + private static bool _isScreenFadedIn; + private static bool _hasIsScreenFadedIn; + private static bool _isPauseMenuActive; + private static bool _hasIsPauseMenuActive; + private static bool _isPlayerSwitchInProgress; + private static bool _hasIsPlayerSwitchInProgress; + private static int _onscreenKeyboard; + private static bool _hasOnscreenKeyboard; + + internal static void Invalidate() + { + _hasPed = false; + _hasIsDead = false; + _hasIsInVehicle = false; + _hasIsScreenFadedIn = false; + _hasIsPauseMenuActive = false; + _hasIsPlayerSwitchInProgress = false; + _hasOnscreenKeyboard = false; + } + + internal static int Ped + { + get + { + if (!_hasPed) + { + _ped = Native.PlayerPedId(); + _hasPed = true; + } + + return _ped; + } + } + + internal static bool IsDead + { + get + { + if (!_hasIsDead) + { + _isDead = Native.IsPlayerDead(Native.PlayerId()); + _hasIsDead = true; + } + + return _isDead; + } + } + + internal static bool IsInVehicle + { + get + { + if (!_hasIsInVehicle) + { + _isInVehicle = Native.IsPedInAnyVehicle(Ped, false); + _hasIsInVehicle = true; + } + + return _isInVehicle; + } + } + + internal static bool IsScreenFadedIn + { + get + { + if (!_hasIsScreenFadedIn) + { + _isScreenFadedIn = Native.IsScreenFadedIn(); + _hasIsScreenFadedIn = true; + } + + return _isScreenFadedIn; + } + } + + internal static bool IsPauseMenuActive + { + get + { + if (!_hasIsPauseMenuActive) + { + _isPauseMenuActive = Native.IsPauseMenuActive(); + _hasIsPauseMenuActive = true; + } + + return _isPauseMenuActive; + } + } + + internal static bool IsPlayerSwitchInProgress + { + get + { + if (!_hasIsPlayerSwitchInProgress) + { + _isPlayerSwitchInProgress = Native.IsPlayerSwitchInProgress(); + _hasIsPlayerSwitchInProgress = true; + } + + return _isPlayerSwitchInProgress; + } + } + + internal static int OnscreenKeyboard + { + get + { + if (!_hasOnscreenKeyboard) + { + _onscreenKeyboard = Native.UpdateOnscreenKeyboard(); + _hasOnscreenKeyboard = true; + } + + return _onscreenKeyboard; + } + } +} diff --git a/MenuAPI/ticks/InstructionalButtonIcons.cs b/MenuAPI/ticks/InstructionalButtonIcons.cs index d7b244d..2abf9e9 100644 --- a/MenuAPI/ticks/InstructionalButtonIcons.cs +++ b/MenuAPI/ticks/InstructionalButtonIcons.cs @@ -33,7 +33,7 @@ internal static void Refresh() // The key mapping settings live in the pause menu, so a rebind is always followed by the pause // menu closing. Catching that edge is what keeps a rebound key's icon from staying wrong for as // long as the menu is open. - bool pauseMenuActive = Native.IsPauseMenuActive(); + bool pauseMenuActive = FrameState.IsPauseMenuActive; bool leftPauseMenu = pauseMenuWasActive && !pauseMenuActive; pauseMenuWasActive = pauseMenuActive; diff --git a/MenuAPI/ticks/MenuLayout.cs b/MenuAPI/ticks/MenuLayout.cs index f112f04..c3b24b9 100644 --- a/MenuAPI/ticks/MenuLayout.cs +++ b/MenuAPI/ticks/MenuLayout.cs @@ -1,4 +1,4 @@ -using CitizenFX.FiveM.Client; +using CitizenFX.FiveM.Client; namespace MenuAPI; @@ -61,11 +61,19 @@ internal static class MenuLayout /// Reads the screen values back from the game and works the rest out from them. internal static void Refresh() { + var previousWidth = ScreenWidth; + var previousSafeZone = SafeZone; + AspectRatio = Native.GetScreenAspectRatio(false); ScreenWidth = 1080f * AspectRatio; ScreenHeight = 1080f; SafeZone = Native.GetSafeZoneSize(); + if (ScreenWidth != previousWidth || SafeZone != previousSafeZone) + { + MenuNui.Invalidate(); + } + MenuWidthN = Menu.Width / ScreenWidth; HeaderHeightN = HeaderHeight / ScreenHeight; RowHeightN = RowHeight / ScreenHeight; diff --git a/MenuAPI/ticks/MenuTicks.cs b/MenuAPI/ticks/MenuTicks.cs index 3a3ee11..74c0fde 100644 --- a/MenuAPI/ticks/MenuTicks.cs +++ b/MenuAPI/ticks/MenuTicks.cs @@ -1,4 +1,4 @@ -using CitizenFX.FiveM.Client; +using CitizenFX.FiveM.Client; using CitizenFX.FiveM.Shared; namespace MenuAPI; @@ -66,6 +66,8 @@ internal static void Initialize() private static void Flush() { + FrameState.Invalidate(); + if (!_reevaluatePending) { return; diff --git a/MenuAPI/ui/assets/arrow.svg b/MenuAPI/ui/assets/arrow.svg new file mode 100644 index 0000000..6b28474 --- /dev/null +++ b/MenuAPI/ui/assets/arrow.svg @@ -0,0 +1,3 @@ + + + diff --git a/MenuAPI/ui/assets/glint.png b/MenuAPI/ui/assets/glint.png new file mode 100644 index 0000000..664da57 Binary files /dev/null and b/MenuAPI/ui/assets/glint.png differ diff --git a/MenuAPI/ui/colour-list.css b/MenuAPI/ui/colour-list.css new file mode 100644 index 0000000..7faed59 --- /dev/null +++ b/MenuAPI/ui/colour-list.css @@ -0,0 +1,196 @@ +/* Everything is in the scaleform's own units. --u is a plain number that scales the whole component. */ + +.gcl { + --u: 1; + + --bg: rgba(0, 0, 0, 0.73); + --accent: 240, 240, 240; + --text: #f0f0f0; + + --palette-offset: 0; + + position: relative; + width: calc(288px * var(--u)); + font-family: "Chalet London 1960", "Helvetica Neue", Arial, sans-serif; + color: var(--text); + user-select: none; +} + +.gcl__title { + position: relative; + height: calc(50px * var(--u)); + background: var(--bg); +} + +.gcl__title[hidden] { + display: none; +} + +.gcl__label, +.gcl__min, +.gcl__max { + position: absolute; + font-size: calc(13px * var(--u)); + line-height: calc(20px * var(--u)); + white-space: nowrap; +} + +.gcl__label { + left: calc(7px * var(--u)); + top: calc(6px * var(--u)); +} + +.gcl__min, +.gcl__max { + top: calc(36px * var(--u)); + font-size: calc(9px * var(--u)); + line-height: calc(13px * var(--u)); + opacity: 0.7; +} + +.gcl__min { + left: calc(7px * var(--u)); +} + +.gcl__max { + right: calc(7px * var(--u)); +} + +.gcl__bar { + position: absolute; + left: calc(7px * var(--u)); + top: calc(30px * var(--u)); + width: calc(274px * var(--u)); + height: calc(6px * var(--u)); +} + +.gcl__bar-black, +.gcl__bar-alpha, +.gcl__bar-fill { + position: absolute; + left: 0; + top: 0; + height: 100%; +} + +.gcl__bar-black { + width: 100%; + background: rgba(0, 0, 0, 0.5); +} + +.gcl__bar-alpha { + width: 100%; + background: rgba(var(--accent), 0.3); +} + +.gcl__bar-fill { + width: 0; + background: rgb(var(--accent)); +} + +.gcl__palette { + position: relative; + height: calc(74px * var(--u)); + background: var(--bg); + top: calc(1px * var(--palette-offset) * var(--u)); +} + +.gcl__palette[hidden] { + display: none; +} + +.gcl__strip { + position: absolute; + left: calc(9px * var(--u)); + top: 0; + width: calc(270px * var(--u)); + height: calc(35px * var(--u)); +} + +.gcl__swatch { + --sw: 30; + position: absolute; + top: 0; + width: calc(1px * var(--sw) * var(--u)); + height: calc(35px * var(--u)); +} + +.gcl__swatch[hidden] { + display: none; +} + +.gcl__highlight { + position: absolute; + left: 0; + top: 0; + width: 100%; + height: calc(5px * var(--u)); + background: #fff; + clip-path: polygon( + 0 0, + calc(50% - 5px * var(--u)) 0, + 50% 100%, + calc(50% + 5px * var(--u)) 0, + 100% 0, + 100% 100%, + 0 100% + ); +} + +.gcl__highlight[hidden] { + display: none; +} + +.gcl__colour { + position: absolute; + left: 0; + top: calc(5px * var(--u)); + width: 100%; + height: calc(30px * var(--u)); + background: #888; +} + +.gcl--pc .gcl__colour { + cursor: pointer; +} + +.gcl__name { + position: absolute; + left: calc(9px * var(--u)); + top: calc(41px * var(--u)); + font-size: calc(12px * var(--u)); + line-height: calc(18px * var(--u)); + white-space: nowrap; +} + +.gcl__arrow { + position: absolute; + top: calc(14.6px * var(--u)); + width: calc(15.5px * var(--u)); + height: calc(13.75px * var(--u)); + padding: 0; + border: 0; + background: none; + cursor: pointer; + background-image: url("assets/arrow.svg"); + background-repeat: no-repeat; + background-size: 100% 100%; +} + +.gcl__arrow[hidden] { + display: none; +} + +.gcl__arrow--left { + left: calc(7.35px * var(--u)); +} + +.gcl__arrow--right { + left: calc(282px * var(--u)); + transform: scaleX(-1); + transform-origin: left center; +} + +.gcl__arrow:hover { + filter: brightness(1.4); +} diff --git a/MenuAPI/ui/colour-list.js b/MenuAPI/ui/colour-list.js new file mode 100644 index 0000000..61f71ed --- /dev/null +++ b/MenuAPI/ui/colour-list.js @@ -0,0 +1,346 @@ +// Port of Rockstar's COLOUR_SWITCHER scaleform to HTML. Method names, numbers and units are the +// scaleform's own, so what the C# side sends maps across one for one. + + +const COMPONENT_W = 288; +const TITLE_H = 50; +const PALETTE_H = 74; + +const SWATCH_W = 30; +const HIGHLIGHT_H = 5; +const SWATCH_COLOUR_H = 30; + +const VISIBLE_ITEMS = 9; +const STRIP_W = VISIBLE_ITEMS * SWATCH_W; + +const BAR_X = 7; +const BAR_Y = 30; +const BAR_W = 274; +const BAR_H = 6; + +const ARROW_LEFT_X = 7.35; +const ARROW_RIGHT_X = 282; +const ARROW_Y = 14.6; + +const HIGHLIGHT_DROP = 10; +const HIGHLIGHT_DURATION = 0.3; + +const BAR_TWEEN_DURATION = 0.175; + +function circEaseOut(t, b, c, d) { + t = t / d - 1; + return c * Math.sqrt(1 - t * t) + b; +} + +function quadEaseOut(t, b, c, d) { + t /= d; + return -c * t * (t - 2) + b; +} + +function el(tag, cls, parent) { + const node = document.createElement(tag); + if (cls) node.className = cls; + if (parent) parent.appendChild(node); + return node; +} + +function clamp(v, lo, hi) { + return Math.max(lo, Math.min(v, hi)); +} + +class GtaColourList { + constructor(root, options) { + options = options || {}; + this.root = root; + this.arrowsAllowed = options.arrows !== false; + this.visibleItems = options.visibleItems || VISIBLE_ITEMS; + this.onSelect = options.onSelect || function () {}; + this.onScroll = options.onScroll || function () {}; + + this.colourData = []; + this.swatches = []; + this.highlightIndex = 0; + this.highlightPosIndex = 0; + this.topEdge = 0; + this.pcActive = false; + + this.tweens = []; + this.raf = 0; + this._tick = this._tick.bind(this); + + this._build(); + } + + _build() { + const r = this.root; + r.classList.add('gcl'); + r.innerHTML = ''; + + this.titleEl = el('div', 'gcl__title', r); + this.titleEl.hidden = true; + this.titleLabel = el('div', 'gcl__label', this.titleEl); + this.minLabel = el('div', 'gcl__min', this.titleEl); + this.maxLabel = el('div', 'gcl__max', this.titleEl); + + this.barEl = el('div', 'gcl__bar', this.titleEl); + this.barBlack = el('div', 'gcl__bar-black', this.barEl); + this.barAlpha = el('div', 'gcl__bar-alpha', this.barEl); + this.barFill = el('div', 'gcl__bar-fill', this.barEl); + this.barWidth = BAR_W; + + this.paletteEl = el('div', 'gcl__palette', r); + this.paletteEl.hidden = true; + this.stripEl = el('div', 'gcl__strip', this.paletteEl); + this.nameLabel = el('div', 'gcl__name', this.paletteEl); + + this.leftArrow = el('button', 'gcl__arrow gcl__arrow--left', this.paletteEl); + this.rightArrow = el('button', 'gcl__arrow gcl__arrow--right', this.paletteEl); + this.leftArrow.type = this.rightArrow.type = 'button'; + this.leftArrow.hidden = this.rightArrow.hidden = true; + this.leftArrow.addEventListener('click', () => this.onScroll(-1)); + this.rightArrow.addEventListener('click', () => this.onScroll(1)); + + this.stripEl.addEventListener('mouseleave', () => { + if (this.pcActive) this.onSelect(-1); + }); + } + + SET_IS_PC(isPc) { + this.pcActive = !!isPc && this.arrowsAllowed; + this.leftArrow.hidden = this.rightArrow.hidden = !this.pcActive; + this.root.classList.toggle('gcl--pc', this.pcActive); + return this; + } + + SET_TITLE(title, paletteLabel, percent, showArrows) { + if (paletteLabel !== undefined) this.nameLabel.textContent = paletteLabel; + + if (percent === undefined || percent === -1 || isNaN(percent)) { + this.titleEl.hidden = true; + this.root.classList.add('gcl--no-title'); + } else { + if (title !== undefined) this.titleLabel.textContent = title; + this.minLabel.textContent = '0%'; + this.maxLabel.textContent = '100%'; + this.percent(percent); + this.titleEl.hidden = false; + this.root.classList.remove('gcl--no-title'); + } + + if (showArrows) this.SET_IS_PC(true); + return this; + } + + SHOW_OPACITY(show, opacityPosTop) { + this.titleEl.hidden = !show; + this.root.classList.toggle('gcl--no-title', !show); + this.root.style.setProperty( + '--palette-offset', opacityPosTop ? 0 : PALETTE_H + ); + return this; + } + + percent(p, tween) { + const clamped = clamp(p, 0, 100); + const target = Math.round(this.barWidth * (clamped / 100)); + if (tween) { + this._tween(this.barFill, this.barFillWidth || 0, target, + BAR_TWEEN_DURATION, quadEaseOut, (node, v) => { + node.style.width = 'calc(' + v + 'px * var(--u))'; + }); + } else { + this.barFill.style.width = 'calc(' + target + 'px * var(--u))'; + } + this.barFillWidth = target; + return this; + } + + SET_DATA_SLOT(index, r, g, b) { + this.colourData[index] = [index, r, g, b]; + return this; + } + + SET_DATA_SLOT_EMPTY() { + this.tweens.length = 0; + this.stripEl.innerHTML = ''; + this.colourData = []; + this.swatches = []; + this.paletteEl.hidden = true; + return this; + } + + DISPLAY_VIEW() { + const count = Math.min(this.colourData.length, this.visibleItems); + this.stripEl.innerHTML = ''; + this.swatches = []; + + for (let i = 0; i < count; i++) { + const swatch = el('div', 'gcl__swatch', this.stripEl); + const highlight = el('div', 'gcl__highlight', swatch); + const colour = el('div', 'gcl__colour', swatch); + + swatch.dataset.index = String(i); + highlight.hidden = true; + + colour.addEventListener('mouseenter', () => { + if (this.pcActive) this.onSelect(this._dataIndexAt(i)); + }); + colour.addEventListener('click', () => { + this.onSelect(this._dataIndexAt(i)); + }); + + this.swatches.push({ root: swatch, highlight: highlight, colour: colour }); + this.itemSetData(i, this.swatches[i], this.colourData[i]); + } + + this.repositionPalettes(); + this.paletteEl.hidden = false; + this._startLoop(); + return this; + } + + UPDATE_SLOT(index, r, g, b) { + this.SET_DATA_SLOT(index, r, g, b); + const local = index - this.topEdge; + if (this.swatches[local]) { + this.itemSetData(local, this.swatches[local], this.colourData[index]); + } + return this; + } + + CLEAR_HIGHLIGHT() { + this.highlightIndex = 0; + this.highlightPosIndex = 0; + this.topEdge = 0; + return this; + } + + SET_HIGHLIGHT(index) { + const total = this.colourData.length; + if (!total) return this; + + index = clamp(index, 0, total - 1); + let local = index; + let firstVisible = this.topEdge; + + if (total > this.visibleItems) { + if (local > this.topEdge + this.visibleItems - 1) { + firstVisible = local - (this.visibleItems - 1); + this.topEdge = firstVisible; + local = this.visibleItems - 1; + } else if (local < this.topEdge) { + firstVisible = local; + this.topEdge = firstVisible; + local = 0; + } else { + firstVisible = this.topEdge; + local -= this.topEdge; + } + for (let i = 0; i < this.swatches.length; i++) { + this.itemSetData(i, this.swatches[i], this.colourData[firstVisible + i]); + } + } + + for (let i = 0; i < this.swatches.length; i++) { + const on = i === local; + const highlight = this.swatches[i].highlight; + highlight.hidden = !on; + if (on) { + if (this.highlightPosIndex !== local) { + this._tween(highlight, HIGHLIGHT_DROP, 0, + HIGHLIGHT_DURATION, circEaseOut, (node, v) => { + node.style.transform = 'translateY(calc(' + v + 'px * var(--u)))'; + }); + } else { + this.tweens = this.tweens.filter((t) => t.node !== highlight); + highlight.style.transform = 'translateY(0)'; + } + } + } + + this.highlightIndex = index; + this.highlightPosIndex = local; + return this; + } + + itemSetData(i, swatch, data) { + if (!swatch) return; + if (!data) { + swatch.root.hidden = true; + return; + } + swatch.root.hidden = false; + const r = data[1], g = data[2], b = data[3]; + if (r !== undefined) { + swatch.colour.style.background = 'rgb(' + r + ',' + g + ',' + b + ')'; + } + } + + repositionPalettes() { + const count = this.swatches.length; + if (!count) return this; + const w = count <= this.visibleItems + ? (this.visibleItems * SWATCH_W) / count + : SWATCH_W; + + for (let i = 0; i < count; i++) { + const s = this.swatches[i]; + s.root.style.setProperty('--sw', w); + s.root.style.left = 'calc(' + (i * w) + 'px * var(--u))'; + } + return this; + } + + _dataIndexAt(local) { + return this.topEdge + local; + } + + _tween(node, from, to, duration, ease, apply) { + this.tweens = this.tweens.filter((t) => t.node !== node); + this.tweens.push({ + node: node, from: from, to: to, + duration: duration * 1000, start: performance.now(), + ease: ease, apply: apply, + }); + apply(node, from); + this._startLoop(); + } + + _startLoop() { + if (!this.raf) this.raf = requestAnimationFrame(this._tick); + } + + _tick(now) { + this.raf = 0; + for (let i = this.tweens.length - 1; i >= 0; i--) { + const t = this.tweens[i]; + const elapsed = now - t.start; + if (elapsed >= t.duration) { + t.apply(t.node, t.to); + this.tweens.splice(i, 1); + } else { + t.apply(t.node, t.ease(elapsed / 1000, t.from, t.to - t.from, t.duration / 1000)); + } + } + if (this.tweens.length) this._startLoop(); + } + + destroy() { + if (this.raf) cancelAnimationFrame(this.raf); + this.raf = 0; + this.tweens.length = 0; + return this; + } +} + +Object.assign(GtaColourList.prototype, { + setIsPc: GtaColourList.prototype.SET_IS_PC, + setTitle: GtaColourList.prototype.SET_TITLE, + showOpacity: GtaColourList.prototype.SHOW_OPACITY, + setDataSlot: GtaColourList.prototype.SET_DATA_SLOT, + clearData: GtaColourList.prototype.SET_DATA_SLOT_EMPTY, + displayView: GtaColourList.prototype.DISPLAY_VIEW, + updateSlot: GtaColourList.prototype.UPDATE_SLOT, + clearHighlight: GtaColourList.prototype.CLEAR_HIGHLIGHT, + setHighlight: GtaColourList.prototype.SET_HIGHLIGHT, +}); diff --git a/MenuAPI/ui/fonts/OFL.txt b/MenuAPI/ui/fonts/OFL.txt new file mode 100644 index 0000000..3d85383 --- /dev/null +++ b/MenuAPI/ui/fonts/OFL.txt @@ -0,0 +1,96 @@ +Copyright 2013 The Antonio Project Authors (https://github.com/googlefonts/antonioFont) +Copyright © 2010 by Dharma Type (Bebas Neue) +Copyright 2016 The Dancing Script Project Authors (https://github.com/googlefonts/DancingScript), with Reserved Font Name 'Dancing Script'. + + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/MenuAPI/ui/fonts/antonio-latin-ext.woff2 b/MenuAPI/ui/fonts/antonio-latin-ext.woff2 new file mode 100644 index 0000000..2825535 Binary files /dev/null and b/MenuAPI/ui/fonts/antonio-latin-ext.woff2 differ diff --git a/MenuAPI/ui/fonts/antonio-latin.woff2 b/MenuAPI/ui/fonts/antonio-latin.woff2 new file mode 100644 index 0000000..4fbb342 Binary files /dev/null and b/MenuAPI/ui/fonts/antonio-latin.woff2 differ diff --git a/MenuAPI/ui/fonts/bebas-neue-latin-ext.woff2 b/MenuAPI/ui/fonts/bebas-neue-latin-ext.woff2 new file mode 100644 index 0000000..23b344f Binary files /dev/null and b/MenuAPI/ui/fonts/bebas-neue-latin-ext.woff2 differ diff --git a/MenuAPI/ui/fonts/bebas-neue-latin.woff2 b/MenuAPI/ui/fonts/bebas-neue-latin.woff2 new file mode 100644 index 0000000..92d6a0f Binary files /dev/null and b/MenuAPI/ui/fonts/bebas-neue-latin.woff2 differ diff --git a/MenuAPI/ui/fonts/chalet-london.woff b/MenuAPI/ui/fonts/chalet-london.woff new file mode 100644 index 0000000..e55e897 Binary files /dev/null and b/MenuAPI/ui/fonts/chalet-london.woff differ diff --git a/MenuAPI/ui/fonts/dancing-script-latin-ext.woff2 b/MenuAPI/ui/fonts/dancing-script-latin-ext.woff2 new file mode 100644 index 0000000..853834d Binary files /dev/null and b/MenuAPI/ui/fonts/dancing-script-latin-ext.woff2 differ diff --git a/MenuAPI/ui/fonts/dancing-script-latin.woff2 b/MenuAPI/ui/fonts/dancing-script-latin.woff2 new file mode 100644 index 0000000..5353684 Binary files /dev/null and b/MenuAPI/ui/fonts/dancing-script-latin.woff2 differ diff --git a/MenuAPI/ui/fonts/dancing-script-vietnamese.woff2 b/MenuAPI/ui/fonts/dancing-script-vietnamese.woff2 new file mode 100644 index 0000000..31075f9 Binary files /dev/null and b/MenuAPI/ui/fonts/dancing-script-vietnamese.woff2 differ diff --git a/MenuAPI/ui/fonts/pricedown.woff b/MenuAPI/ui/fonts/pricedown.woff new file mode 100644 index 0000000..fd48b47 Binary files /dev/null and b/MenuAPI/ui/fonts/pricedown.woff differ diff --git a/MenuAPI/ui/glare.js b/MenuAPI/ui/glare.js new file mode 100644 index 0000000..7cbcc4b --- /dev/null +++ b/MenuAPI/ui/glare.js @@ -0,0 +1,341 @@ +// Port of Rockstar's mp_menu_glare scaleform to canvas 2D. Every number came out of the +// decompiled SWF, and everything is laid out in the scaleform's own 288 by 75 unit space. + +const DESIGN_W = 288; +const DESIGN_H = 65; + +const DESIGN_BLEED = 10; +const GLINT_CLIP_H = DESIGN_H + DESIGN_BLEED; + +const GLOBE_X = 196; +const GLOBE_Y = -9; + +const GLOBE_ALPHA = 38 / 256; + +const GLOBE_D = + 'M31.4 41.95 L30.75 47.3 30.35 51.45 Q30.0 55.55 29.9 59.9 L68.5 64.0 ' + + 'Q69.75 57.2 70.3 51.55 L71.1 40.35 32.3 36.25 31.4 41.95 M9.45 33.8 ' + + 'Q8.1 39.1 7.5 44.85 6.8 50.9 7.2 57.5 L23.0 59.15 23.4 51.45 23.85 46.6 ' + + '24.4 41.95 25.4 35.5 9.45 33.8 M32.85 9.25 Q26.5 12.05 21.25 16.6 ' + + '16.0 21.2 12.45 27.15 L12.5 27.15 26.8 28.7 Q29.15 18.25 32.8 9.45 ' + + 'L32.85 9.25 M92.0 35.65 L92.0 42.6 78.0 41.1 77.2 52.25 Q76.5 58.8 ' + + '75.35 64.75 L91.2 66.4 92.0 63.3 92.0 74.0 87.85 74.0 88.4 73.1 ' + + '73.9 71.55 73.3 74.0 66.2 74.0 67.0 70.8 29.9 66.85 30.3 74.0 23.4 74.0 ' + + '23.0 66.15 8.65 64.6 8.6 64.6 Q10.35 69.55 13.2 74.0 L6.1 74.0 ' + + 'Q-1.35 60.7 0.35 44.65 2.55 23.95 18.75 10.85 L21.2 9.0 40.55 9.0 ' + + 'Q36.4 17.65 33.7 29.4 L71.2 33.4 Q71.0 19.65 68.1 9.5 L68.0 9.2 67.55 9.0 ' + + 'L79.7 9.0 Q85.05 12.9 89.5 18.4 L92.0 21.75 92.0 34.3 Q89.8 28.5 ' + + '86.05 23.45 81.9 17.85 76.2 13.8 L76.3 14.2 Q77.95 23.35 78.1 34.15 ' + + 'L92.0 35.65 Z'; + +const GLARE_D = + 'M-53.4 15.75 Q-69.15 -45.9 -97.8 -99.6 L20.2 59.35 -35.9 124.5 ' + + 'Q-40.5 66.5 -53.4 15.75 Z'; + +const GLARE_GRAD_FROM = [11.66, 45.87]; +const GLARE_GRAD_TO = [-62.55, 99.02]; + +const GLARE_RAMP = [ + 1.0, 0.9882, 0.9686, 0.9451, 0.9216, 0.8941, 0.8627, 0.8314, 0.8, 0.7686, + 0.7333, 0.6941, 0.6588, 0.6196, 0.5804, 0.5412, 0.5059, 0.4667, 0.4275, + 0.3882, 0.349, 0.3137, 0.2745, 0.2392, 0.2078, 0.1765, 0.1451, 0.1137, + 0.0863, 0.0627, 0.0392, 0.0196, 0.0078, +]; + +const GX1 = 230, GR1 = -55, GA1 = 20; +const GX2 = 290, GR2 = -30, GA2 = 20; +const GLARE_Y = -25; + +const FPS = 30; +const FRAME_MS = 1000 / FPS; + +const GLINT_TOTAL_FRAMES = 270; +const GLINT_LOOP_FRAME = 42; +const GLINT_MIN_DELAY = 60; + +const GLINT_FRAMES = [ + {ga:0, m:null, s:[-15.0, 0.25, 0.25]}, + {ga:0.0, m:[-0.44732, -1.6911, 14.89977, -3.94117, -153.55, 225.75], s:[4.75, 0.2901, 0.31943]}, + {ga:0.0312, m:[-0.44732, -1.6911, 14.89977, -3.94117, -148.41667, 226.28333], s:[23.4, 0.3287, 0.38625]}, + {ga:0.0703, m:[-0.44732, -1.6911, 14.89977, -3.94117, -143.28333, 226.81667], s:[40.95, 0.3658, 0.45049]}, + {ga:0.1094, m:[-0.44732, -1.6911, 14.89977, -3.94117, -138.15, 227.35], s:[57.35, 0.40146, 0.51219]}, + {ga:0.1406, m:[-0.44732, -1.6911, 14.89977, -3.94117, -133.01667, 227.88333], s:[72.8, 0.4357, 0.57147]}, + {ga:0.1797, m:[-0.44732, -1.6911, 14.89977, -3.94117, -127.88333, 228.41667], s:[87.25, 0.46857, 0.62837]}, + {ga:0.2188, m:[-0.44732, -1.6911, 14.89977, -3.94117, -122.75, 228.95], s:[100.8, 0.50011, 0.68297]}, + {ga:0.25, m:[-0.44732, -1.6911, 14.89977, -3.94117, -117.61667, 229.48333], s:[113.5, 0.53035, 0.73531]}, + {ga:0.2891, m:[-0.44732, -1.6911, 14.89977, -3.94117, -112.48216, 230.01679], s:[125.45, 0.55934, 0.78552]}, + {ga:0.3281, m:[-0.44732, -1.6911, 14.89977, -3.94117, -107.34883, 230.55012], s:[136.6, 0.58713, 0.8336]}, + {ga:0.3711, m:[-0.44732, -1.6911, 14.89977, -3.94117, -102.21549, 231.08346], s:[147.1, 0.61374, 0.87967]}, + {ga:0.3984, m:[-0.44732, -1.6911, 14.89977, -3.94117, -97.08216, 231.61679], s:[156.9, 0.63922, 0.9238]}, + {ga:0.4414, m:[-0.44732, -1.6911, 14.89977, -3.94117, -91.94883, 232.15012], s:[166.1, 0.66362, 0.96603]}, + {ga:0.4805, m:[-0.44732, -1.6911, 14.89977, -3.94117, -86.81549, 232.68346], s:[174.8, 0.68697, 1.00644]}, + {ga:0.5117, m:[-0.44732, -1.6911, 14.89977, -3.94117, -81.68216, 233.21679], s:[182.95, 0.68407, 1.00815]}, + {ga:0.5508, m:[-0.44732, -1.6911, 14.89977, -3.94117, -76.55, 233.75], s:[190.6, 0.64749, 0.96025]}, + {ga:0.5898, m:[-0.39647, -1.68586, 14.85357, -3.49318, -75.26857, 229.95365], s:[197.85, 0.61253, 0.91447]}, + {ga:0.6211, m:[-0.34564, -1.68062, 14.80738, -3.04535, -73.98762, 226.15868], s:[204.65, 0.57907, 0.87067]}, + {ga:0.6602, m:[-0.2948, -1.67537, 14.76117, -2.59735, -72.70619, 222.36233], s:[211.15, 0.54704, 0.82875]}, + {ga:0.6992, m:[-0.24395, -1.67013, 14.71496, -2.14936, -71.42477, 218.56597], s:[217.3, 0.51642, 0.78865]}, + {ga:0.7383, m:[-0.19312, -1.66488, 14.66877, -1.70153, -70.14381, 214.77101], s:[223.1, 0.48712, 0.75029]}, + {ga:0.7695, m:[-0.14227, -1.65964, 14.62256, -1.25353, -68.86238, 210.97465], s:[228.7, 0.45905, 0.71355]}, + {ga:0.8086, m:[-0.09143, -1.6544, 14.57635, -0.80554, -67.58096, 207.1783], s:[234.0, 0.43214, 0.6783]}, + {ga:0.8516, m:[-0.0406, -1.64915, 14.53016, -0.35771, -66.3, 203.38333], s:[239.15, 0.40634, 0.64453]}, + {ga:0.8789, m:[0.01025, -1.64391, 14.48395, 0.09028, -65.01857, 199.58698], s:[244.05, 0.38159, 0.61214]}, + {ga:0.9219, m:[0.06109, -1.63866, 14.43775, 0.53828, -63.73715, 195.79062], s:[248.85, 0.3578, 0.58098]}, + {ga:0.9609, m:[0.11192, -1.63342, 14.39155, 0.98611, -62.45619, 191.99566], s:[253.45, 0.34384, 0.55101]}, + {ga:1.0, m:[0.16277, -1.62818, 14.34535, 1.4341, -61.17477, 188.1993], s:[257.95, 0.33482, 0.52209]}, + {ga:0.9102, m:[0.21361, -1.62293, 14.29914, 1.8821, -59.89334, 184.40295], s:[262.4, 0.32613, 0.49419]}, + {ga:0.8281, m:[0.26444, -1.61769, 14.25295, 2.32993, -58.61238, 180.60799], s:[266.7, 0.3177, 0.46719]}, + {ga:0.75, m:[0.31529, -1.61244, 14.20674, 2.77792, -57.33096, 176.81163], s:[270.95, 0.30956, 0.44102]}, + {ga:0.6602, m:[0.36614, -1.6072, 14.16053, 3.22591, -56.04953, 173.01528], s:[275.15, 0.30161, 0.41556]}, + {ga:0.5781, m:[0.41696, -1.60196, 14.11434, 3.67374, -54.76857, 169.22031], s:[279.35, 0.29388, 0.39076]}, + {ga:0.5, m:[0.46781, -1.59671, 14.06813, 4.12174, -53.48715, 165.42396], s:[283.45, 0.2863, 0.36647]}, + {ga:0.4102, m:[0.51866, -1.59147, 14.02193, 4.56973, -52.20572, 161.6276], s:[287.6, 0.27888, 0.34265]}, + {ga:0.3281, m:[0.56948, -1.58623, 13.97573, 5.01756, -50.92477, 157.83264], s:[291.7, 0.27155, 0.31914]}, + {ga:0.25, m:[0.62033, -1.58098, 13.92953, 5.46556, -49.64334, 154.03628], s:[295.8, 0.26433, 0.29596]}, + {ga:0.1602, m:[0.67118, -1.57574, 13.88332, 5.91355, -48.36191, 150.23993], s:[299.85, 0.25716, 0.27295]}, + {ga:0.0781, m:[0.72201, -1.57049, 13.83713, 6.36138, -47.08096, 146.44496], s:[304.0, 0.25, 0.25]}, +]; + +function quadEaseOut(t, b, c, d) { + t /= d; + return -c * t * (t - 2) + b; +} + +function quadEaseInOut(t, b, c, d) { + t /= d / 2; + if (t < 1) return (c / 2) * t * t + b; + t -= 1; + return (-c / 2) * (t * (t - 2) - 1) + b; +} + +function loadImage(src) { + return new Promise(function (resolve, reject) { + const img = new Image(); + img.onload = function () { resolve(img); }; + img.onerror = function () { reject(new Error('could not load ' + src)); }; + img.src = src; + }); +} + +class GtaMenuGlare { + constructor(canvas, options) { + options = options || {}; + this.canvas = canvas; + this.ctx = canvas.getContext('2d'); + + this.glintTextureSrc = options.glintTexture || 'menuapi/assets/glint.png'; + this.autoGlint = options.autoGlint !== false; + this.fadeIn = options.fadeIn !== false; + this.bleed = options.bleed === undefined ? DESIGN_BLEED : options.bleed; + + this.globePath = new Path2D(GLOBE_D); + this.glarePath = new Path2D(GLARE_D); + + this.targetAngle = 0; + this.position = 0; + this.easedPosition = 0; + this.contentAlpha = this.fadeIn ? 0 : 1; + this.easeInCur = this.fadeIn ? 0 : 30; + this.easeInDuration = 30; + + this.glintFrame = GLINT_LOOP_FRAME; + this.glintPattern = null; + + this.running = false; + this.accumulator = 0; + this.lastTime = 0; + this.raf = 0; + + this._tick = this._tick.bind(this); + } + + load() { + const self = this; + return loadImage(this.glintTextureSrc).then(function (img) { + self.glintPattern = self.ctx.createPattern(img, 'repeat'); + return self; + }); + } + + start() { + if (this.running) return this; + this.running = true; + this.lastTime = performance.now(); + this.accumulator = 0; + this.raf = requestAnimationFrame(this._tick); + return this; + } + + stop() { + this.running = false; + if (this.raf) cancelAnimationFrame(this.raf); + return this; + } + + open() { + this.easeInCur = 0; + this.contentAlpha = 0; + return this; + } + + setHeading(angle, triggerGlint) { + if (triggerGlint) this.triggerGlint(); + this.targetAngle = angle % 360; + return this; + } + + triggerGlint() { + this.glintFrame = 2; + return this; + } + + toDataURL() { + return this.canvas.toDataURL('image/png'); + } + + _resize() { + const dpr = window.devicePixelRatio || 1; + const rect = this.canvas.getBoundingClientRect(); + const w = Math.max(1, Math.round(rect.width * dpr)); + const h = Math.max(1, Math.round(rect.height * dpr)); + if (this.canvas.width !== w || this.canvas.height !== h) { + this.canvas.width = w; + this.canvas.height = h; + } + } + + _step() { + if (this.easeInCur < this.easeInDuration) { + this.contentAlpha = + quadEaseOut(this.easeInCur++, 0, 100, this.easeInDuration) / 100; + } else { + this.contentAlpha = 1; + } + + let targetPosition = (this.targetAngle % 180) / 180; + if (this.targetAngle > 180) targetPosition = 1 - targetPosition; + + this.position += (targetPosition - this.position) / 16; + this.easedPosition = quadEaseInOut(this.position, 0, 1, 1); + + this.glintFrame += 1; + if (this.glintFrame === GLINT_LOOP_FRAME && this.autoGlint) { + const span = GLINT_TOTAL_FRAMES - GLINT_LOOP_FRAME - GLINT_MIN_DELAY; + this.glintFrame = Math.round(Math.random() * span) + GLINT_LOOP_FRAME; + } + if (this.glintFrame > GLINT_TOTAL_FRAMES) this.glintFrame = 1; + } + + _tick(now) { + if (!this.running) return; + this.raf = requestAnimationFrame(this._tick); + + this.accumulator += now - this.lastTime; + this.lastTime = now; + if (this.accumulator > FRAME_MS * 5) this.accumulator = FRAME_MS * 5; + + let stepped = false; + while (this.accumulator >= FRAME_MS) { + this.accumulator -= FRAME_MS; + this._step(); + stepped = true; + } + if (stepped) this.draw(); + } + + draw() { + this._resize(); + const ctx = this.ctx; + const w = this.canvas.width; + const h = this.canvas.height; + + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.clearRect(0, 0, w, h); + if (this.contentAlpha <= 0) return; + + ctx.save(); + ctx.scale(w / DESIGN_W, h / (DESIGN_H + this.bleed)); + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = 'high'; + + this._drawGlobe(ctx); + this._drawGlint(ctx); + this._drawGlare(ctx); + + ctx.restore(); + } + + _drawGlobe(ctx) { + ctx.save(); + ctx.globalAlpha = GLOBE_ALPHA * this.contentAlpha; + ctx.fillStyle = '#000'; + ctx.translate(GLOBE_X, GLOBE_Y); + ctx.fill(this.globePath, 'evenodd'); + ctx.restore(); + } + + _drawGlint(ctx) { + const frame = GLINT_FRAMES[this.glintFrame - 1]; + if (!frame || !this.glintPattern) return; + + ctx.save(); + ctx.beginPath(); + ctx.rect(0, 0, DESIGN_W, GLINT_CLIP_H); + ctx.clip(); + + if (frame.m && frame.ga > 0) { + ctx.save(); + ctx.globalAlpha = frame.ga * this.contentAlpha; + ctx.translate(GLOBE_X, GLOBE_Y); + this.glintPattern.setTransform(new DOMMatrix(frame.m)); + ctx.fillStyle = this.glintPattern; + ctx.fill(this.globePath, 'evenodd'); + ctx.restore(); + } + + if (frame.s) { + const x = frame.s[0], sx = frame.s[1], sy = frame.s[2]; + ctx.save(); + ctx.globalCompositeOperation = 'lighter'; + ctx.globalAlpha = this.contentAlpha; + ctx.translate(x, 65); + ctx.scale(sx, sy); + this.glintPattern.setTransform(new DOMMatrix([1, 0, 0, 1, -64, -8])); + ctx.fillStyle = this.glintPattern; + ctx.fillRect(-64, -8, 128, 16); + ctx.restore(); + } + + ctx.restore(); + } + + _drawGlare(ctx) { + const p = this.easedPosition; + const x = GX1 + (GX2 - GX1) * p; + const rotation = GR1 + (GR2 - GR1) * p; + const alpha = (GA1 + (GA2 - GA1) * p) / 100; + + ctx.save(); + ctx.beginPath(); + ctx.rect(0, 0, DESIGN_W, DESIGN_H); + ctx.clip(); + + ctx.globalAlpha = alpha * this.contentAlpha; + ctx.translate(x, GLARE_Y); + ctx.rotate((rotation * Math.PI) / 180); + + const grad = ctx.createLinearGradient( + GLARE_GRAD_FROM[0], GLARE_GRAD_FROM[1], + GLARE_GRAD_TO[0], GLARE_GRAD_TO[1] + ); + for (let i = 0; i < GLARE_RAMP.length; i++) { + grad.addColorStop( + i / (GLARE_RAMP.length - 1), + 'rgba(255,255,255,' + GLARE_RAMP[i] + ')' + ); + } + ctx.fillStyle = grad; + ctx.fill(this.glarePath); + ctx.restore(); + } +} diff --git a/MenuAPI/ui/menuapi.css b/MenuAPI/ui/menuapi.css new file mode 100644 index 0000000..f146f3a --- /dev/null +++ b/MenuAPI/ui/menuapi.css @@ -0,0 +1,528 @@ +@font-face { + font-family: "GTA Chalet"; + src: url("fonts/chalet-london.woff") format("woff"); + font-display: block; +} + +@font-face { + font-family: "GTA Pricedown"; + src: url("fonts/pricedown.woff") format("woff"); + font-display: block; +} + +/* Stand-ins for the game fonts that are licensed and cannot be shipped. */ +@font-face { + font-family: "Antonio"; + font-style: normal; + font-weight: 100 700; + font-display: block; + src: url("fonts/antonio-latin.woff2") format("woff2"); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: "Antonio"; + font-style: normal; + font-weight: 100 700; + font-display: block; + src: url("fonts/antonio-latin-ext.woff2") format("woff2"); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +@font-face { + font-family: "Dancing Script"; + font-style: normal; + font-weight: 400 700; + font-display: block; + src: url("fonts/dancing-script-latin.woff2") format("woff2"); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: "Dancing Script"; + font-style: normal; + font-weight: 400 700; + font-display: block; + src: url("fonts/dancing-script-latin-ext.woff2") format("woff2"); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +@font-face { + font-family: "Dancing Script"; + font-style: normal; + font-weight: 400 700; + font-display: block; + src: url("fonts/dancing-script-vietnamese.woff2") format("woff2"); + unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB; +} + +@font-face { + font-family: "Bebas Neue"; + font-style: normal; + font-weight: 400; + font-display: block; + src: url("fonts/bebas-neue-latin.woff2") format("woff2"); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: "Bebas Neue"; + font-style: normal; + font-weight: 400; + font-display: block; + src: url("fonts/bebas-neue-latin-ext.woff2") format("woff2"); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +/* :where() so this carries no specificity and cannot outrank the class rules below. */ +:where(#menuapi, #menuapi *, #menuapi *::before, #menuapi *::after) { + box-sizing: content-box; + margin: 0; + padding: 0; + border: 0; +} + +#menuapi { + --menuapi-origin-x: 0.0125; + --menuapi-origin-y: 0.0185; + + --menuapi-scale: 1; + --menuapi-aspect: 1.7777778; + + --menuapi-freemode: rgb(255 255 255); + + /* The game insets text from where it is told to draw it and CSS does not. Both measured. */ + --menuapi-text-inset: 2.5px; + + --menuapi-title-inset: 1px; + + /* 225, not 255: measured off the game's own white menu text. */ + --menuapi-text: rgb(var(--menuapi-text-rgb, 225 225 225)); + + font-size: calc(var(--menuapi-text-size, 21) * 1px); + + position: fixed; + top: calc(var(--menuapi-origin-y) * 100vh); + left: calc(var(--menuapi-origin-x) * 100vw); + width: 500px; + + transform-origin: top left; + transform: scale(var(--menuapi-scale)); + + pointer-events: none; + + font-family: "GTA Chalet", "Helvetica Neue", Arial, sans-serif; + + line-height: 1; + color: var(--menuapi-text); +} + +#menuapi[data-text-weight="geometric"] { + text-rendering: geometricPrecision; +} + +#menuapi[data-text-weight="supersampled"] { + zoom: 2; + transform: scale(calc(var(--menuapi-scale) / 2)); + will-change: transform; +} + +#menuapi[hidden] { + display: none; +} + +/* A class display rule outranks the browser's own hidden rule, so put it back. */ +#menuapi [hidden] { + display: none; +} + +.menuapi-header { + position: relative; + width: 500px; + height: 110px; +} + +.menuapi-header__bg { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: fill; +} + +/* Taller than the header on purpose: half the light bar's glow belongs below the lower edge. */ +.menuapi-header__glare { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 115.385%; +} + +.menuapi-header__title { + position: absolute; + inset: 0; + display: flex; + align-items: center; + padding: 0 calc(10px + var(--menuapi-text-inset)) 0 calc(10px + var(--menuapi-title-inset)); + color: var(--menuapi-text); + transform: translateY(var(--menuapi-title-nudge, 0px)); +} + +/* Sizes and nudges are measured per font against the game. They differ by up to 5px, do not merge them. + Fonts 1, 2 and 4 are stand-ins, so their sizes are matched to the game rather than shared with it. */ +.menuapi-header__title[data-font="0"] { + font-family: "GTA Chalet", Arial, sans-serif; + font-size: 52.4px; + --menuapi-title-nudge: 2.5px; +} + +.menuapi-header__title[data-font="1"] { + font-family: "Dancing Script", cursive; + font-weight: 700; + font-size: 58px; + --menuapi-title-nudge: 3.9px; +} + +/* The game draws font 2 in capitals whatever you type, so the page does the same. */ +.menuapi-header__title[data-font="2"] { + font-family: "Bebas Neue", "Arial Narrow", Arial, sans-serif; + text-transform: uppercase; + font-size: 60.5px; + --menuapi-title-nudge: 4.6px; +} + +.menuapi-header__title[data-font="4"] { + font-family: "Antonio", "Arial Narrow", Arial, sans-serif; + font-weight: 600; + font-size: 43px; + --menuapi-title-nudge: 2.2px; +} + +.menuapi-header__title[data-font="7"] { + font-family: "GTA Pricedown", Impact, sans-serif; + font-size: 62.9px; + --menuapi-title-nudge: 2.8px; +} + +.menuapi-header__title[data-align="center"] { + justify-content: center; +} + +.menuapi-header__title[data-align="right"] { + justify-content: flex-end; +} + +.menuapi-subtitle { + display: flex; + align-items: center; + justify-content: space-between; + height: 38px; + padding: 0 calc(10px + var(--menuapi-text-inset)); + background: rgb(0 0 0 / 98%); + + text-transform: uppercase; +} + +.menuapi-subtitle--freemode { + color: var(--menuapi-freemode); +} + +.menuapi-rows { + background: rgb(0 0 0 / 70.5%); +} + +.menuapi-row { + position: relative; + height: 38px; + color: var(--menuapi-text); +} + +.menuapi-row--selected { + background: rgb(255 255 255 / 88%); + color: #000; +} + +.menuapi-row--disabled { + color: rgb(109 109 109); +} + +.menuapi-row--selected.menuapi-row--disabled { + color: rgb(50 50 50); +} + +.menuapi-row--separator { + line-height: 38px; + text-align: center; +} + +.menuapi-row--separator.menuapi-row--disabled { + color: var(--menuapi-text); +} + +.menuapi-row--separator.menuapi-row--selected { + color: #000; +} + +.menuapi-row__text { + position: absolute; + top: 0; + left: 10px; + right: 10px; + padding-left: var(--menuapi-text-inset); + height: 100%; + line-height: 38px; + overflow: hidden; + white-space: nowrap; +} + +.menuapi-row--icon-left .menuapi-row__text { + left: 35px; +} + +.menuapi-row__label { + position: absolute; + top: 0; + right: 10px; + padding-right: var(--menuapi-text-inset); + height: 100%; + line-height: 38px; + white-space: nowrap; +} + +.menuapi-row--icon-right .menuapi-row__label { + right: 35px; +} + +.menuapi-icon { + position: absolute; + top: 50%; + background-image: var(--sprite); + background-position: center; + background-repeat: no-repeat; + background-size: contain; + + isolation: isolate; +} + +/* Tint multiplied over the texture and masked to its alpha, so a shaded sprite keeps its detail. */ +.menuapi-icon::after { + content: ""; + position: absolute; + inset: 0; + background-color: var(--tint); + mix-blend-mode: multiply; + mask-image: var(--sprite); + mask-position: center; + mask-repeat: no-repeat; + mask-size: contain; + -webkit-mask-image: var(--sprite); + -webkit-mask-position: center; + -webkit-mask-repeat: no-repeat; + -webkit-mask-size: contain; +} + +.menuapi-icon--left { + left: 20px; + transform: translate(-50%, -50%); +} + +.menuapi-icon--right, +.menuapi-icon--checkbox { + left: 480px; + transform: translate(-50%, -50%); +} + +.menuapi-slider { + position: absolute; + top: 50%; + right: 8px; + width: 150px; + height: 10px; + transform: translateY(-50%); +} + +.menuapi-row--slider-icons .menuapi-slider { + right: 48px; +} + +/* Always half the track, slid end to end rather than grown from nothing. */ +.menuapi-slider__bar { + position: absolute; + top: 0; + width: 50%; + height: 100%; +} + +.menuapi-slider__divider { + position: absolute; + top: 50%; + left: 77px; + width: 4px; + height: 19px; + background: #fff; + transform: translate(-50%, -50%); +} + +.menuapi-icon--slider { + right: 202px; + transform: translateY(-50%); +} + +.menuapi-overflow { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: calc(60px / var(--menuapi-aspect)); + margin-top: 4px; + background: rgb(0 0 0 / 70.5%); + color: var(--menuapi-text); +} + +.menuapi-overflow > span { + line-height: 11px; +} + +.menuapi-desc { + position: relative; + padding: 8px calc(10px + var(--menuapi-text-inset)) 2.5px; + margin-top: 8px; + background: rgb(0 0 0 / 70.5%); + + white-space: pre-wrap; + overflow-wrap: anywhere; + line-height: 26.5px; +} + +#menuapi.menuapi--overflow .menuapi-desc { + margin-top: 4px; +} + +.menuapi-desc::before { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 4px; + background: rgb(0 0 0 / 78%); +} + +.menuapi-panel { + --u: calc(500 / 288); + + margin-top: 4px; + + --bg: var(--menuapi-panel-bg, rgb(0 0 0 / 73%)); + --accent: var(--menuapi-panel-accent, 240, 240, 240); + + --text: var(--menuapi-text); + + font-family: inherit; +} + +#menuapi .gcl__label, +#menuapi .gcl__min, +#menuapi .gcl__max { + top: calc(6px * var(--u)); + font-size: calc(11px * var(--u)); + line-height: calc(16px * var(--u)); + opacity: 1; +} + +#menuapi .gcl__name { + top: calc(6px * var(--u)); + font-size: calc(14.5px * var(--u)); + line-height: calc(21px * var(--u)); + opacity: 1; +} + +#menuapi .gcl__label, +#menuapi .gcl__name { + left: 0; + right: 0; + text-align: center; +} + +#menuapi .gcl__min { + left: calc(7px * var(--u)); +} + +#menuapi .gcl__max { + right: calc(7px * var(--u)); +} + +#menuapi .gcl__strip { + top: calc(29px * var(--u)); +} + +#menuapi .gcl__title { + height: calc(47px * var(--u)); +} + +#menuapi .menuapi-panel:not(.menuapi-panel--no-colours) .gcl__title { + height: calc(42px * var(--u)); +} + +.menuapi-panel--no-colours .gcl__palette { + display: none; +} + +.menuapi-stats { + height: 130px; + padding-top: 10px; + margin-top: 4px; + background: rgb(0 0 0 / 70.5%); +} + +#menuapi.menuapi--overflow .menuapi-stats { + margin-top: calc((60px / var(--menuapi-aspect)) - 26px); +} + +#menuapi.menuapi--no-desc:not(.menuapi--overflow) .menuapi-stats { + margin-top: 8px; +} + +.menuapi-stat { + position: relative; + height: 30px; +} + +.menuapi-stat__label { + position: absolute; + top: 2px; + left: 10px; + padding-left: var(--menuapi-text-inset); + line-height: 26.5px; + white-space: nowrap; +} + +.menuapi-stat__track { + position: absolute; + top: 10px; + right: 10px; + width: 250px; + height: 10px; + background: rgb(100 100 100 / 70.5%); +} + +.menuapi-stat__upgrade, +.menuapi-stat__value { + position: absolute; + top: 0; + left: 0; + height: 100%; +} + +.menuapi-stat__upgrade { + background: rgb(93 182 229); +} + +.menuapi-stat__upgrade--reduced { + background: rgb(224 50 50); +} + +.menuapi-stat__value { + background: #fff; +} diff --git a/MenuAPI/ui/menuapi.js b/MenuAPI/ui/menuapi.js new file mode 100644 index 0000000..c8160f6 --- /dev/null +++ b/MenuAPI/ui/menuapi.js @@ -0,0 +1,500 @@ +"use strict"; + +(() => { + const root = document.getElementById("menuapi"); + + if (!root) { + return; + } + + root.hidden = true; + + function spriteUrl(dict, name) { + return typeof window.MENUAPI_SPRITE_URL === "function" + ? window.MENUAPI_SPRITE_URL(dict, name) + : `https://nui-img/${dict}/${name}`; + } + + // Probed through an Image first: a CSS background that failed is never retried, and the url + // does not change when the dictionary finally streams in. + const sprites = new Map(); + const SPRITE_TRIES = 5; + const SPRITE_RETRY_MS = 200; + + function spriteReady(url) { + const state = sprites.get(url); + + if (state) { + return state.ok; + } + + const next = { ok: false, tries: 0 }; + + sprites.set(url, next); + loadSprite(url, next); + + return false; + } + + function loadSprite(url, state) { + const probe = new Image(); + + probe.onload = () => { + state.ok = true; + showSprite(url); + }; + + probe.onerror = () => { + state.tries += 1; + + if (state.tries >= SPRITE_TRIES) { + console.warn(`[menuapi] sprite never arrived: ${url}`); + + return; + } + + setTimeout(() => loadSprite(url, state), state.tries * SPRITE_RETRY_MS); + }; + + probe.src = url; + } + + function showSprite(url) { + for (const node of root.querySelectorAll(".menuapi-icon")) { + if (node.dataset.sprite === url) { + node.style.setProperty("--sprite", `url("${url}")`); + } + } + + if (bannerUrl === url) { + headerBg.src = url; + headerBg.hidden = false; + } + } + + function rescale() { + root.style.setProperty("--menuapi-scale", window.innerHeight / 1080); + root.style.setProperty("--menuapi-aspect", window.innerWidth / window.innerHeight); + } + + rescale(); + window.addEventListener("resize", rescale); + + let glare = null; + let glareRunning = false; + + function setGlare(wanted) { + if (wanted && !glare && typeof GtaMenuGlare !== "undefined") { + glare = new GtaMenuGlare(headerGlare, {}); + glare.load().then(() => { + if (glareRunning) { + glare.start(); + } + }).catch(() => { + glare = null; + }); + } + + if (!glare) { + headerGlare.hidden = !wanted; + + return; + } + + headerGlare.hidden = !wanted; + + if (wanted === glareRunning) { + return; + } + + glareRunning = wanted; + + if (wanted) { + glare.open(); + glare.start(); + } else { + glare.stop(); + } + } + + const header = element("div", "menuapi-header"); + const headerBg = element("img", "menuapi-header__bg"); + const headerGlare = element("canvas", "menuapi-header__glare"); + const headerTitle = element("div", "menuapi-header__title"); + const subtitle = element("div", "menuapi-subtitle"); + const subtitleText = element("span", "menuapi-subtitle__text"); + const subtitleCounter = element("span", "menuapi-subtitle__counter"); + const rows = element("div", "menuapi-rows"); + const overflow = element("div", "menuapi-overflow"); + const description = element("div", "menuapi-desc"); + const stats = element("div", "menuapi-stats"); + const panel = element("div", "menuapi-panel"); + + headerBg.alt = ""; + header.append(headerBg, headerGlare, headerTitle); + + let bannerUrl = null; + subtitle.append(subtitleText, subtitleCounter); + overflow.append(text("↑"), text("↓")); + root.append(header, subtitle, rows, overflow, description, panel, stats); + + function element(tag, className) { + const node = document.createElement(tag); + + if (className) { + node.className = className; + } + + return node; + } + + function text(value) { + const node = document.createElement("span"); + + node.textContent = value; + + return node; + } + + const TOKENS = { + r: "#e03232", + g: "#42a05b", + b: "#3f9dd4", + y: "#f0c419", + o: "#e8910e", + p: "#9b59b6", + w: "#ffffff", + h: null, + n: null, + s: null + }; + + function markup(value) { + const fragment = document.createDocumentFragment(); + + if (!value) { + return fragment; + } + + let colour = null; + + for (const part of String(value).split("~")) { + if (part === "") { + continue; + } + + if (part.length <= 24 && !part.includes(" ") && Object.hasOwn(TOKENS, part.toLowerCase())) { + colour = TOKENS[part.toLowerCase()]; + + continue; + } + + if (part.startsWith("HUD_COLOUR_")) { + colour = null; + + continue; + } + + const node = document.createElement("span"); + + node.textContent = part; + + if (colour) { + node.style.color = colour; + } + + fragment.append(node); + } + + return fragment; + } + + function icon(spec, className) { + const node = element("span", className ? "menuapi-icon " + className : "menuapi-icon"); + + const url = spriteUrl(spec.dict, spec.name); + + node.style.width = `${spec.size}px`; + node.style.height = `${spec.size}px`; + node.style.setProperty("--tint", `rgb(${spec.r} ${spec.g} ${spec.b})`); + node.dataset.sprite = url; + + if (spriteReady(url)) { + node.style.setProperty("--sprite", `url("${url}")`); + } + + return node; + } + + function renderHeader(data) { + if (!data) { + header.hidden = true; + setGlare(false); + + return; + } + + header.hidden = false; + + bannerUrl = data.texture ? spriteUrl(data.texture.dict, data.texture.name) : null; + headerBg.hidden = !bannerUrl || !spriteReady(bannerUrl); + + if (!headerBg.hidden) { + headerBg.src = bannerUrl; + } + + headerTitle.dataset.align = data.titleAlign; + headerTitle.dataset.font = data.font; + headerTitle.replaceChildren(markup(data.title)); + + setGlare(!!data.glare); + } + + function renderSubtitle(data) { + subtitle.classList.toggle("menuapi-subtitle--freemode", !!data.freemode); + + if (data.colour) { + root.style.setProperty("--menuapi-freemode", data.colour); + } + subtitleText.replaceChildren(markup(data.text)); + subtitleCounter.replaceChildren(markup(data.counter)); + } + + function renderRow(data) { + const node = element("div", "menuapi-row"); + + node.classList.toggle("menuapi-row--selected", data.selected); + node.classList.toggle("menuapi-row--disabled", !data.enabled); + + node.classList.toggle("menuapi-row--icon-left", !!data.leftIcon); + node.classList.toggle("menuapi-row--icon-right", !!data.rightIcon || !!data.checkbox); + + node.classList.toggle("menuapi-row--slider-icons", !!(data.slider?.sliderLeftIcon && data.rightIcon)); + + if (data.kind === "separator") { + node.classList.add("menuapi-row--separator"); + node.append(text(data.arrows ? `↓ ${data.text ?? ""} ↓` : data.text ?? "")); + + return node; + } + + if (data.leftIcon) { + node.append(icon(data.leftIcon, "menuapi-icon--left")); + } + + const label = element("span", "menuapi-row__text"); + + label.append(markup(data.text)); + node.append(label); + + if (data.label) { + const value = element("span", "menuapi-row__label"); + + value.append(markup(data.label)); + node.append(value); + } + + if (data.slider) { + if (data.slider.sliderLeftIcon) { + node.append(icon(data.slider.sliderLeftIcon, "menuapi-icon--slider")); + } + + node.append(renderSlider(data.slider)); + } + + if (data.checkbox) { + node.append(icon({ + dict: data.checkbox.dict, + name: data.checkbox.name, + size: data.checkbox.size, + r: data.checkbox.shade, + g: data.checkbox.shade, + b: data.checkbox.shade + }, "menuapi-icon--checkbox")); + } + + if (data.rightIcon) { + node.append(icon(data.rightIcon, "menuapi-icon--right")); + } + + return node; + } + + function renderSlider(data) { + const node = element("span", "menuapi-slider"); + const bar = element("span", "menuapi-slider__bar"); + const span = Math.max(1, data.max - data.min); + + node.style.backgroundColor = data.background; + bar.style.backgroundColor = data.bar; + + bar.style.left = `${((data.position - data.min) / span) * 50}%`; + + node.append(bar); + + if (data.divider) { + node.append(element("span", "menuapi-slider__divider")); + } + + return node; + } + + let colours = null; + + let palette = null; + + let paletteIndex = 0; + + function renderPanel(data) { + if (!data) { + panel.hidden = true; + + return; + } + + if (!colours && typeof GtaColourList !== "undefined") { + colours = new GtaColourList(panel, { arrows: false }); + applyPalette(); + } + + if (!colours) { + panel.hidden = true; + + return; + } + + panel.hidden = false; + + colours.SET_TITLE(data.title, data.name ?? "", data.opacity ?? -1); + colours.SHOW_OPACITY(data.opacity !== null && data.opacity !== undefined, true); + + panel.classList.toggle("menuapi-panel--no-colours", !data.colours); + + if (data.colours) { + paletteIndex = data.index; + colours.SET_HIGHLIGHT(paletteIndex); + } + } + + function applyPalette() { + if (!colours || !palette) { + return; + } + + colours.SET_DATA_SLOT_EMPTY(); + + palette.forEach((rgb, i) => colours.SET_DATA_SLOT(i, rgb[0], rgb[1], rgb[2])); + + colours.DISPLAY_VIEW(); + + colours.SET_HIGHLIGHT(paletteIndex); + } + + function applyText(data) { + if (!data) { + return; + } + + root.style.setProperty("--menuapi-text-size", data.size); + root.style.setProperty("--menuapi-text-rgb", `${data.brightness} ${data.brightness} ${data.brightness}`); + root.dataset.textWeight = data.weight; + } + + function renderStats(data) { + if (!data) { + stats.hidden = true; + + return; + } + + stats.hidden = false; + stats.replaceChildren(...data.map(entry => { + const node = element("div", "menuapi-stat"); + const label = element("span", "menuapi-stat__label"); + const track = element("div", "menuapi-stat__track"); + const upgrade = element("div", "menuapi-stat__upgrade"); + const value = element("div", "menuapi-stat__value"); + + const reduced = entry.upgrade < entry.value; + + upgrade.classList.toggle("menuapi-stat__upgrade--reduced", reduced); + upgrade.style.width = `${Math.max(entry.upgrade, entry.value) * 100}%`; + value.style.width = `${Math.min(entry.upgrade, entry.value) * 100}%`; + + label.textContent = entry.label; + + track.append(upgrade, value); + node.append(label, track); + + return node; + })); + } + + function render(data) { + if (!data.visible) { + root.hidden = true; + setGlare(false); + + for (const [url, state] of sprites) { + if (!state.ok) { + sprites.delete(url); + } + } + + return; + } + + root.hidden = false; + root.dataset.align = data.align; + + root.style.setProperty("--menuapi-origin-x", data.origin.x); + root.style.setProperty("--menuapi-origin-y", data.origin.y); + + renderHeader(data.header); + renderSubtitle(data.subtitle); + + rows.replaceChildren(...data.rows.map(renderRow)); + + overflow.hidden = !data.overflow; + + root.classList.toggle("menuapi--overflow", !!data.overflow); + + description.hidden = !data.description; + + root.classList.toggle("menuapi--no-desc", !data.description); + description.replaceChildren(markup(data.description)); + + renderPanel(data.panel); + renderStats(data.stats); + + root.style.setProperty("--menuapi-panel-bg", data.panelBackground); + root.style.setProperty("--menuapi-panel-accent", data.panelAccent); + + applyText(data.text); + } + + window.addEventListener("message", event => { + let data = event.data; + + if (typeof data === "string") { + try { + data = JSON.parse(data); + } catch { + return; + } + } + + if (!data || typeof data !== "object") { + return; + } + + if (data.type === "menuapi") { + render(data); + } else if (data.type === "menuapi:glare" && glare) { + glare.setHeading(data.heading || 0, false); + } else if (data.type === "menuapi:palette") { + palette = data.colours; + applyPalette(); + } + }); +})(); diff --git a/TestMenu/ExampleMenu.cs b/TestMenu/ExampleMenu.cs index cc50cf2..4c11851 100644 --- a/TestMenu/ExampleMenu.cs +++ b/TestMenu/ExampleMenu.cs @@ -1,7 +1,4 @@ -using System; -using System.Collections.Generic; - -using CitizenFX.FiveM.Client; +using CitizenFX.FiveM.Client; using CitizenFX.FiveM.Shared.Script; using MenuAPI; @@ -12,8 +9,12 @@ namespace TestMenu; public class ExampleMenu : IScript { + private static MenuCheckboxItem? renderModeBox; + public ExampleMenu() { + RegisterRenderModeToggle(); + // Setting the menu alignment to be right aligned. This can be changed at any time and it'll update instantly. // To test this, checkout one of the checkbox items in this example menu. Clicking it will toggle the menu alignment. MenuController.MenuAlignment = MenuController.MenuAlignmentOption.Right; @@ -145,9 +146,30 @@ string ChangeCallback(MenuDynamicListItem item, bool left) ShowOpacityPanel = true }; + List combinedList = new List(); + for (var i = 0; i < 64; i++) + { + combinedList.Add($"Color #{i}"); + } + MenuListItem combined = new MenuListItem("Color + Opacity", combinedList, 0, "Left and right move the colour. The bar above it is its own value, driven by the Opacity Panel row.") + { + ShowColorPanel = true, + ShowOpacityPanel = true, + OpacityPercent = 0 + }; + + menu.OnListIndexChange += (_menu, _item, _oldIndex, _newIndex, _itemIndex) => + { + if (_item == opacity) + { + combined.OpacityPercent = _newIndex * 10; + } + }; + menu.AddMenuItem(hairColors); menu.AddMenuItem(makeupColors); menu.AddMenuItem(opacity); + menu.AddMenuItem(combined); // Normal List normalList = new List() { "Item #1", "Item #2", "Item #3" }; MenuListItem normalListItem = new MenuListItem( @@ -673,6 +695,146 @@ void SortProduce(Comparison compareProduce) }; menu.AddMenuItem(separators); MenuController.BindMenuItem(menu, menu10, separators); + Menu menu11 = new Menu("Render Mode", "Native vs NUI"); + + renderModeBox = new MenuCheckboxItem( + "Draw with NUI", + "Off draws the menu with the game's own natives, on draws it as a web page. F7 does the same thing without moving your selection, which is what you want when comparing the two.", + MenuController.RenderMode == MenuRenderMode.Nui + ); + menu11.AddMenuItem(renderModeBox); + + menu11.AddMenuItem(new MenuItem("A normal row", "Something to look at while you flick between the two.")); + menu11.AddMenuItem(new MenuCheckboxItem("A checkbox", "Ticked, so the box sprite is on screen too.", true)); + menu11.AddMenuItem(new MenuItem("A row with an icon", "So an icon's tint can be compared as well.") { LeftIcon = MenuItem.Icon.STAR }); + menu11.AddMenuItem(new MenuItem("A disabled row", "Greyed out, so the disabled colour is on screen too.") { Enabled = false }); + + menu11.OnCheckboxChange += (_menu, _item, _index, _checked) => + { + if (_item == renderModeBox) + { + MenuController.RenderMode = _checked ? MenuRenderMode.Nui : MenuRenderMode.Native; + } + }; + + MenuController.AddSubmenu(menu, menu11); + MenuItem renderMode = new MenuItem("Render mode", "Switch between drawing the menu with game natives and drawing it as a web page.") + { + Label = "→→→" + }; + menu.AddMenuItem(renderMode); + MenuController.BindMenuItem(menu, menu11, renderMode); + + Menu menu12 = new Menu("NUI Text", "Weight & size"); + + List weightNames = new List() { "Default", "Geometric precision", "Supersampled" }; + + MenuListItem weightMode = new MenuListItem( + "Stem treatment", + weightNames, + 0, + "How the page is asked for a thinner stem. Flip to native with F7 after each one and see which matches." + ); + menu12.AddMenuItem(weightMode); + + MenuDynamicListItem textSize = new MenuDynamicListItem( + "Text size", + NuiTuning.TextSize.ToString("0.##"), + (item, left) => + { + NuiTuning.TextSize += left ? -0.5f : 0.5f; + + return NuiTuning.TextSize.ToString("0.##"); + }, + "Pixels at 1080p. The rows measured the same height as the game's at the value it starts on." + ); + menu12.AddMenuItem(textSize); + + MenuDynamicListItem textBrightness = new MenuDynamicListItem( + "Text brightness", + NuiTuning.TextBrightness.ToString(), + (item, left) => + { + NuiTuning.TextBrightness += left ? -5 : 5; + + return NuiTuning.TextBrightness.ToString(); + }, + "0 to 255. The game's own white menu text peaks at the value this starts on, not at 255." + ); + menu12.AddMenuItem(textBrightness); + + menu12.AddMenuItem(new MenuItem("Print the values", "Writes what everything is set to into the console, so it can be pasted back.")); + menu12.AddMenuItem(new MenuItem("Reset", "Puts every knob back to what the renderer ships with.")); + + menu12.AddMenuItem(new SeparatorMenuItem("Something to look at")); + menu12.AddMenuItem(new MenuItem("Handgun ammunition", "A row with descenders and round letters in it.")); + menu12.AddMenuItem(new MenuItem("Vehicles Menu", "The same words the weight was measured against.")); + menu12.AddMenuItem(new MenuItem("A disabled row") { Enabled = false }); + + menu12.OnListIndexChange += (_menu, _item, _oldIndex, _newIndex, _itemIndex) => + { + if (_item == weightMode) + { + NuiTuning.TextWeight = (NuiTuning.TextWeightMode)_newIndex; + } + }; + + menu12.OnItemSelect += (_menu, _item, _index) => + { + if (_item.Text == "Print the values") + { + API.Log.Info($"[TestMenu] NUI text: {NuiTuning.Describe()}"); + } + else if (_item.Text == "Reset") + { + NuiTuning.Reset(); + + weightMode.ListIndex = 0; + textSize.CurrentItem = NuiTuning.TextSize.ToString("0.##"); + textBrightness.CurrentItem = NuiTuning.TextBrightness.ToString(); + } + }; + + MenuController.AddSubmenu(menu, menu12); + MenuItem nuiText = new MenuItem("NUI text", "Nudge the size, the brightness and the stem treatment of the NUI renderer's text, live.") + { + Label = "\u2192\u2192\u2192" + }; + menu.AddMenuItem(nuiText); + MenuController.BindMenuItem(menu, menu12, nuiText); + + Menu menu13 = new Menu("NUI Sprites", "Icons & tints") + { + HeaderTexture = new KeyValuePair("shopui_title_barber", "shopui_title_barber") + }; + + menu13.AddMenuItem(new SeparatorMenuItem("White with an alpha channel")); + menu13.AddMenuItem(new MenuItem("Lock", "Scroll onto me. Selected turns the tint black, so a lost alpha channel shows up as a filled square instead of a lock.") { LeftIcon = MenuItem.Icon.LOCK }); + menu13.AddMenuItem(new MenuItem("Tick", "The same again at a different size.") { LeftIcon = MenuItem.Icon.TICK }); + + menu13.AddMenuItem(new SeparatorMenuItem("Carries its own colours")); + menu13.AddMenuItem(new MenuItem("Star", "Gold. If it comes out flat white then the texture's own colours are being lost.") { LeftIcon = MenuItem.Icon.STAR }); + menu13.AddMenuItem(new MenuItem("Gold medal", "Another one with colour in the texture rather than in the tint.") { LeftIcon = MenuItem.Icon.MEDAL_GOLD }); + menu13.AddMenuItem(new MenuItem("Pegassi badge", "Fine detail, so it shows any loss of sharpness.") { LeftIcon = MenuItem.Icon.BRAND_PEGASSI }); + + menu13.AddMenuItem(new SeparatorMenuItem("Tinted by the menu")); + menu13.AddMenuItem(new MenuItem("Blue globe", "White texture, blue tint. Wrong alpha turns this into a blue block.") { LeftIcon = MenuItem.Icon.GLOBE_BLUE }); + menu13.AddMenuItem(new MenuItem("Green globe") { LeftIcon = MenuItem.Icon.GLOBE_GREEN }); + menu13.AddMenuItem(new MenuItem("Disabled, greyed out", "The tint drops to 109 grey.") { LeftIcon = MenuItem.Icon.STAR, Enabled = false }); + + menu13.AddMenuItem(new SeparatorMenuItem("Other dictionaries")); + menu13.AddMenuItem(new MenuItem("Male", "From mpleaderboard.") { LeftIcon = MenuItem.Icon.MALE }); + menu13.AddMenuItem(new MenuItem("Info", "From shared.") { LeftIcon = MenuItem.Icon.INFO }); + menu13.AddMenuItem(new MenuCheckboxItem("A ticked checkbox", "Its box has the tick drawn into the texture rather than the alpha, so it is the one most likely to break.", true)); + + MenuController.AddSubmenu(menu, menu13); + MenuItem nuiSprites = new MenuItem("NUI sprites", "Every icon type in one place, plus a banner from a dictionary MenuAPI does not otherwise touch.") + { + Label = "\u2192\u2192\u2192" + }; + menu.AddMenuItem(nuiSprites); + MenuController.BindMenuItem(menu, menu13, nuiSprites); + /*-------------- Event handlers --------------*/ @@ -755,4 +917,25 @@ Event handlers API.Log.Info($"OnDynamicListItemSelect: [{_menu}, {_dynamicListItem}, {_currentItem}]"); }; } -} \ No newline at end of file + + private static void RegisterRenderModeToggle() + { + const string command = "menuapi_testmenu_rendermode"; + + CitizenFX.FiveM.Shared.SharedAPI.Commands.RegisterCommand(command, false, new Action(() => + { + MenuController.RenderMode = MenuController.RenderMode == MenuRenderMode.Native + ? MenuRenderMode.Nui + : MenuRenderMode.Native; + + if (renderModeBox != null) + { + renderModeBox.Checked = MenuController.RenderMode == MenuRenderMode.Nui; + } + + API.Log.Info($"[TestMenu] render mode is now {MenuController.RenderMode}."); + })); + + RegisterKeyMapping(command, "Toggle NUI menu rendering", "keyboard", "F7"); + } +} diff --git a/TestMenu/TestMenu.csproj b/TestMenu/TestMenu.csproj index 9ae2021..f5b3d0c 100644 --- a/TestMenu/TestMenu.csproj +++ b/TestMenu/TestMenu.csproj @@ -27,6 +27,7 @@ + + + + + + + + + + + + diff --git a/docs/enhanced/astro.config.mjs b/docs/enhanced/astro.config.mjs index b23f44c..d9482fd 100644 --- a/docs/enhanced/astro.config.mjs +++ b/docs/enhanced/astro.config.mjs @@ -47,6 +47,7 @@ export default defineConfig({ sidebar: [ { label: 'Basic Info', link: '/' }, { label: 'Setup', link: '/setup/' }, + { label: 'Migration guide', link: '/migration/' }, { label: 'API Reference', items: [ diff --git a/docs/enhanced/src/content/docs/changelog.md b/docs/enhanced/src/content/docs/changelog.md index f9e864c..1f8a081 100644 --- a/docs/enhanced/src/content/docs/changelog.md +++ b/docs/enhanced/src/content/docs/changelog.md @@ -10,6 +10,34 @@ title: "Changelog" These are the changes in MenuAPI for FiveM Enhanced. If you are moving a resource over from the older (v3, non Enhanced) MenuAPI, this is the list of things you will have to deal with along the way. ::: +### The menu is drawn without describing it sixty times a second + +The NUI renderer used to build a full description of the open menu on every single frame, purely to +find out whether anything about it had changed. Nothing usually had, so almost all of that work, and +the few kilobytes of garbage that came with it, was thrown straight away again. + +Now every property that is on screen says so when you write to it, and the description is only built +when something actually said it changed. A menu nobody is touching costs nothing at all beyond the +draw itself. + +**Things you have to change in your own code:** + +- `MenuListItem.ListItems` is now a `MenuItemList` instead of a `List`. It is used the same + way, and a `List` can still be assigned straight to it, so most code needs no change. The + two things that do: a variable typed `List values = item.ListItems;` has to become + `MenuItemList` (or call `.ToList()`), and the list you pass to the constructor is now **copied**, + so change the values through `item.ListItems` rather than through your own copy of the list. See + the [Migration guide](migration/). + +**Things that just behave differently now:** + +- If you build part of a row's text from something MenuAPI does not own, such as a label you resolve + yourself, call `MenuController.RefreshNui()` when it changes. Everything MenuAPI owns already says + so on its own. +- The instructional buttons bar fills its slots a few times a second instead of every frame. Swapping + between keyboard and controller still changes the icons, it just no longer costs a dozen scaleform + calls per frame to notice. + ### Menus can be split into groups with a heading There is a new item type, [SeparatorMenuItem](reference/menuitems/separatormenuitem/). It is a heading that labels the rows underneath it, so a long menu can be broken into readable groups without pushing anything into a submenu. diff --git a/docs/enhanced/src/content/docs/migration.md b/docs/enhanced/src/content/docs/migration.md new file mode 100644 index 0000000..81f01d3 --- /dev/null +++ b/docs/enhanced/src/content/docs/migration.md @@ -0,0 +1,105 @@ +--- +title: "Migration guide" +--- + +## Migration guide + +Moving a resource from the older (v3, non Enhanced) MenuAPI to the Enhanced one. This page is just +the things that stop compiling or stop behaving the way they used to, with a before and after for +each. The [Changelog](changelog/) explains why each of them changed. + +Everything not listed here still works the way it always did. + +---- + +### The menu toggle key is the player's choice now + +`MenuController.MenuToggleKey` and `MenuController.MenuToggleKeyIsValid` are gone. You can still pick +the key players start with, but they can rebind it in **Settings, Key Bindings**. + +```cs +// Before +MenuController.MenuToggleKey = Control.SelectCharacterMichael; + +// After (a key name, and only for players who have never rebound it) +MenuController.MenuToggleKeyDefault = "M"; +``` + +---- + +### Select and back instructional buttons moved + +`Menu.InstructionalButtons` used to come with a select and a back entry already in it. It now starts +empty, and those two live on the menu itself so they can follow the player's own key binding. + +```cs +// Before +menu.InstructionalButtons[Control.FrontendAccept] = "Choose"; +menu.InstructionalButtons.Remove(Control.FrontendCancel); + +// After +menu.SelectButtonText = "Choose"; +menu.ShowBackInstructionalButton = false; +``` + +Your own extra buttons still go in `Menu.InstructionalButtons` exactly as before. + +---- + +### List item values are a MenuItemList + +`MenuListItem.ListItems` used to be a plain `List` that you handed over and kept a reference +to. It is now a [MenuItemList](reference/menuitems/menulistitem/#changing-the-values-later), which +works the same way but tells the menu when it changes, so the row redraws the moment you add or +remove a value. + +You still build one from a `List`, and the list you pass in is **copied**, so changing your +own copy afterwards no longer reaches the item. Change it through the item instead. + +```cs +// Before +List values = new List { "A", "B" }; +MenuListItem item = new MenuListItem("Item", values, 0); + +values.Add("C"); // the item picked this up +List current = item.ListItems; + +// After +MenuListItem item = new MenuListItem("Item", new List { "A", "B" }, 0); + +item.ListItems.Add("C"); // change it through the item +MenuItemList current = item.ListItems; +``` + +Everything you would call on a `List` is there and works the same: `Add`, `AddRange`, +`Insert`, `InsertRange`, `Remove`, `RemoveAt`, `RemoveAll`, `RemoveRange`, `Clear`, `Sort`, +`Reverse`, `Contains`, `IndexOf`, `Find`, `FindAll`, `FindIndex`, `Exists`, `TrueForAll`, +`ConvertAll`, `GetRange`, `ForEach`, `ToArray`, `Count`, indexing, `foreach` and all of LINQ. If you +genuinely need a `List` back, call `item.ListItems.ToList()`. + +---- + +### Manual garbage collection is gone + +`MenuController.EnableManualGCs` no longer exists. Delete the line, .NET handles this itself. + +```cs +// Before +MenuController.EnableManualGCs = false; + +// After +// (nothing) +``` + +---- + +### Nullable reference types + +MenuAPI is built with nullable reference types on, so things that can be null now say so: +`MenuController.GetCurrentMenu()`, `MenuController.MainMenu`, `Menu.ParentMenu`, +`Menu.GetCurrentMenuItem()`, `Menu.MenuTitle`, `Menu.MenuSubtitle`, `Menu.CounterPreText`, +`MenuItem.Label`, `MenuItem.Description` and `MenuDynamicListItem.CurrentItem`. + +Nothing breaks. If your resource has nullable switched on too you may get new warnings, and each one +is pointing at a crash that could already happen today, so they are worth fixing rather than +silencing. diff --git a/docs/enhanced/src/content/docs/reference/menu.md b/docs/enhanced/src/content/docs/reference/menu.md index 4005292..ae55297 100644 --- a/docs/enhanced/src/content/docs/reference/menu.md +++ b/docs/enhanced/src/content/docs/reference/menu.md @@ -691,6 +691,15 @@ None of this touches the subtitle bar, the counter or the menu items. It is the |MenuFont.ChaletComprimeCologne|`4`. Narrower than Chalet London, so long titles fit better.| |MenuFont.Pricedown|`7`. The Grand Theft Auto logo font.| +:::note +Those descriptions are the game's own fonts, which is what you get in the native render mode. The NUI +renderer is a web page, so it can only use fonts that are allowed to be shipped with MenuAPI. Chalet +London and Pricedown are the real thing, House Script is drawn in Dancing Script instead, Chalet +Comprime Cologne is drawn in Antonio, and Monospace is drawn in Bebas Neue, in capitals, which is how +the game draws that one too. A title picks the same font id either way, it just looks a little +different in NUI mode. +::: + ```cs Menu menu = new Menu("Los Santos Customs", "Vehicle mods") { diff --git a/docs/enhanced/src/content/docs/reference/menuitems/menulistitem.md b/docs/enhanced/src/content/docs/reference/menuitems/menulistitem.md index 029e61c..24a5012 100644 --- a/docs/enhanced/src/content/docs/reference/menuitems/menulistitem.md +++ b/docs/enhanced/src/content/docs/reference/menuitems/menulistitem.md @@ -63,13 +63,25 @@ The list wraps around: pressing right on the last value goes back to the first o #### Changing the values later -`ListItems` is a normal `List`, so you can change it at any time. Remember to keep `ListIndex` in range when you do. +`ListItems` is a `MenuItemList`, which is used exactly like a `List`: add to it, index into +it, loop over it, ask it for its `Count`. The difference is that it tells the menu when it changes, +so the row redraws as soon as you touch it. Remember to keep `ListIndex` in range when you do. ```cs +item.ListItems.Add("Option 4"); +item.ListItems.RemoveAt(0); + +// Or replace the lot. A List can be assigned straight to it. item.ListItems = newValues; item.ListIndex = 0; ``` +:::caution +The list you hand to the constructor is **copied**, so changing your own copy afterwards does not +reach the item. Change it through `item.ListItems` instead. See the [Migration guide](/migration/) +if you are moving a resource over from the older MenuAPI. +::: + :::caution Never leave a list item with an empty `ListItems` list. If the list is empty, MenuAPI inserts a single `"N/A"` value to keep the menu from freezing, and the item's value will read `N/A`. ::: @@ -119,7 +131,7 @@ The MenuItem properties **RightIcon** and **Label** are not available for MenuLi |Property|Type|Default value|Description|Optional| |---|---|---|---|---| |ListIndex|int|0|The currently selected list index.|**No**| -|ListItems|List<string>|-|A list holding a collection of strings which can be selected through this menu list item. At least one string must be present in this list.|**No**| +|ListItems|MenuItemList|-|A list holding a collection of strings which can be selected through this menu list item. At least one string must be present in this list. Used exactly like a `List`, and one can be assigned straight to it.|**No**| |HideArrowsWhenNotSelected|boolean|false|Hides the left & right arrows when the menu list item is not currently highlighted.|Yes| |ShowOpacityPanel|boolean|false|Shows the Opacity Panel.|Yes| |ShowColorPanel|boolean|false|Shows the Color Panel.|Yes| diff --git a/tools/dds-to-png.mjs b/tools/dds-to-png.mjs new file mode 100644 index 0000000..0557902 --- /dev/null +++ b/tools/dds-to-png.mjs @@ -0,0 +1,281 @@ +import fs from "node:fs"; +import path from "node:path"; +import zlib from "node:zlib"; + +const DDS_MAGIC = 0x20534444; +const HEADER_SIZE = 128; + +const PF_FLAGS = 80; +const PF_FOURCC = 84; +const PF_RGB_BIT_COUNT = 88; +const PF_FLAG_FOURCC = 0x4; + +function decode(buffer) { + if (buffer.readUInt32LE(0) !== DDS_MAGIC) { + throw new Error("not a DDS file"); + } + + const height = buffer.readUInt32LE(12); + const width = buffer.readUInt32LE(16); + const compressed = (buffer.readUInt32LE(PF_FLAGS) & PF_FLAG_FOURCC) !== 0; + const fourCC = buffer.toString("ascii", PF_FOURCC, PF_FOURCC + 4); + const pixels = Buffer.alloc(width * height * 4); + + if (!compressed) { + const bits = buffer.readUInt32LE(PF_RGB_BIT_COUNT); + + if (bits !== 32) { + throw new Error(`unsupported uncompressed depth ${bits}`); + } + + for (let i = 0; i < width * height; i++) { + const at = HEADER_SIZE + (i * 4); + + pixels[(i * 4) + 0] = buffer[at + 2]; + pixels[(i * 4) + 1] = buffer[at + 1]; + pixels[(i * 4) + 2] = buffer[at + 0]; + pixels[(i * 4) + 3] = buffer[at + 3]; + } + + return { width, height, pixels }; + } + + if (fourCC !== "DXT1" && fourCC !== "DXT3" && fourCC !== "DXT5") { + throw new Error(`unsupported format ${fourCC}`); + } + + const blockBytes = fourCC === "DXT1" ? 8 : 16; + const blocksWide = Math.max(1, Math.ceil(width / 4)); + const blocksHigh = Math.max(1, Math.ceil(height / 4)); + + let at = HEADER_SIZE; + + for (let by = 0; by < blocksHigh; by++) { + for (let bx = 0; bx < blocksWide; bx++) { + const block = buffer.subarray(at, at + blockBytes); + + at += blockBytes; + + const colour = fourCC === "DXT1" ? block : block.subarray(8); + const alpha = decodeAlpha(fourCC, block); + + writeColourBlock(pixels, width, height, bx, by, colour, alpha, fourCC === "DXT1"); + } + } + + return { width, height, pixels }; +} + +function decodeAlpha(fourCC, block) { + if (fourCC === "DXT1") { + return null; + } + + const out = new Uint8Array(16); + + if (fourCC === "DXT3") { + for (let i = 0; i < 16; i++) { + const nibble = (block[i >> 1] >> ((i & 1) * 4)) & 0xf; + + out[i] = (nibble * 255) / 15; + } + + return out; + } + + const a0 = block[0]; + const a1 = block[1]; + const ramp = new Uint8Array(8); + + ramp[0] = a0; + ramp[1] = a1; + + if (a0 > a1) { + for (let i = 2; i < 8; i++) { + ramp[i] = ((8 - i) * a0 + (i - 1) * a1) / 7; + } + } else { + for (let i = 2; i < 6; i++) { + ramp[i] = ((6 - i) * a0 + (i - 1) * a1) / 5; + } + + ramp[6] = 0; + ramp[7] = 255; + } + + for (let half = 0; half < 2; half++) { + const base = 2 + (half * 3); + const bits = block[base] | (block[base + 1] << 8) | (block[base + 2] << 16); + + for (let i = 0; i < 8; i++) { + out[(half * 8) + i] = ramp[(bits >> (i * 3)) & 0x7]; + } + } + + return out; +} + +function writeColourBlock(pixels, width, height, bx, by, colour, alpha, punchThrough) { + const c0 = colour.readUInt16LE(0); + const c1 = colour.readUInt16LE(2); + const bits = colour.readUInt32LE(4); + + const r = new Uint8Array(4); + const g = new Uint8Array(4); + const b = new Uint8Array(4); + const a = new Uint8Array(4).fill(255); + + unpack565(c0, r, g, b, 0); + unpack565(c1, r, g, b, 1); + + const opaque = !punchThrough || c0 > c1; + + if (opaque) { + for (const channel of [r, g, b]) { + channel[2] = ((2 * channel[0]) + channel[1]) / 3; + channel[3] = (channel[0] + (2 * channel[1])) / 3; + } + } else { + for (const channel of [r, g, b]) { + channel[2] = (channel[0] + channel[1]) / 2; + channel[3] = 0; + } + + a[3] = 0; + } + + for (let y = 0; y < 4; y++) { + for (let x = 0; x < 4; x++) { + const px = (bx * 4) + x; + const py = (by * 4) + y; + + if (px >= width || py >= height) { + continue; + } + + const i = (y * 4) + x; + const code = (bits >> (i * 2)) & 0x3; + const at = ((py * width) + px) * 4; + + pixels[at + 0] = r[code]; + pixels[at + 1] = g[code]; + pixels[at + 2] = b[code]; + pixels[at + 3] = alpha ? alpha[i] : a[code]; + } + } +} + +function unpack565(value, r, g, b, index) { + const r5 = (value >> 11) & 0x1f; + const g6 = (value >> 5) & 0x3f; + const b5 = value & 0x1f; + + r[index] = (r5 << 3) | (r5 >> 2); + g[index] = (g6 << 2) | (g6 >> 4); + b[index] = (b5 << 3) | (b5 >> 2); +} + +function encodePng({ width, height, pixels }) { + const raw = Buffer.alloc((width * 4 + 1) * height); + + for (let y = 0; y < height; y++) { + raw[y * (width * 4 + 1)] = 0; + pixels.copy(raw, (y * (width * 4 + 1)) + 1, y * width * 4, (y + 1) * width * 4); + } + + const ihdr = Buffer.alloc(13); + + ihdr.writeUInt32BE(width, 0); + ihdr.writeUInt32BE(height, 4); + ihdr[8] = 8; + ihdr[9] = 6; + ihdr[10] = 0; + ihdr[11] = 0; + ihdr[12] = 0; + + return Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + chunk("IHDR", ihdr), + chunk("IDAT", zlib.deflateSync(raw, { level: 9 })), + chunk("IEND", Buffer.alloc(0)), + ]); +} + +function chunk(type, data) { + const out = Buffer.alloc(data.length + 12); + + out.writeUInt32BE(data.length, 0); + out.write(type, 4, "ascii"); + data.copy(out, 8); + out.writeUInt32BE(crc32(out.subarray(4, out.length - 4)), out.length - 4); + + return out; +} + +const CRC_TABLE = (() => { + const table = new Uint32Array(256); + + for (let i = 0; i < 256; i++) { + let value = i; + + for (let bit = 0; bit < 8; bit++) { + value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1; + } + + table[i] = value >>> 0; + } + + return table; +})(); + +function crc32(buffer) { + let crc = 0xffffffff; + + for (const byte of buffer) { + crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8); + } + + return (crc ^ 0xffffffff) >>> 0; +} + +const source = process.argv[2]; +const target = process.argv[3] ?? "MenuAPI/ui/sprites"; + +if (!source) { + console.error('usage: node tools/dds-to-png.mjs "" [output folder]'); + process.exit(1); +} + +let converted = 0; +let failed = 0; + +for (const entry of fs.readdirSync(source, { withFileTypes: true })) { + if (!entry.isDirectory()) { + continue; + } + + const from = path.join(source, entry.name); + const to = path.join(target, entry.name); + + fs.mkdirSync(to, { recursive: true }); + + for (const file of fs.readdirSync(from)) { + if (!file.toLowerCase().endsWith(".dds")) { + continue; + } + + const out = path.join(to, `${path.basename(file, path.extname(file))}.png`); + + try { + fs.writeFileSync(out, encodePng(decode(fs.readFileSync(path.join(from, file))))); + converted++; + } catch (error) { + console.error(` ${entry.name}/${file}: ${error.message}`); + failed++; + } + } + + console.log(`${entry.name}`); +} + +console.log(`\n${converted} converted, ${failed} failed, into ${target}`); diff --git a/tools/preview/index.html b/tools/preview/index.html new file mode 100644 index 0000000..f846289 --- /dev/null +++ b/tools/preview/index.html @@ -0,0 +1,59 @@ + + + + + + MenuAPI NUI preview + + + + + + + + + + + + + + + + + + + + diff --git a/tools/preview/serve.mjs b/tools/preview/serve.mjs new file mode 100644 index 0000000..67f0a37 --- /dev/null +++ b/tools/preview/serve.mjs @@ -0,0 +1,56 @@ +import fs from "node:fs"; +import http from "node:http"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const ui = path.resolve(here, "../../MenuAPI/ui"); +const port = Number(process.argv[2] ?? 8730); + +const TYPES = { + ".css": "text/css", + ".html": "text/html; charset=utf-8", + ".js": "text/javascript", + ".png": "image/png", + ".woff": "font/woff", + ".woff2": "font/woff2", +}; + +if (!fs.existsSync(ui)) { + console.error(`cannot find ${ui}`); + process.exit(1); +} + +function resolve(url) { + const clean = decodeURIComponent(url.split("?")[0]).replace(/^\/+/, "") || "index.html"; + + const target = clean.startsWith("menuapi/") + ? path.join(ui, clean.slice("menuapi/".length)) + : path.join(here, clean); + + const resolved = path.resolve(target); + + return resolved.startsWith(path.resolve(ui)) || resolved.startsWith(path.resolve(here)) + ? resolved + : null; +} + +http.createServer((request, response) => { + const file = resolve(request.url ?? "/"); + + if (!file || !fs.existsSync(file) || !fs.statSync(file).isFile()) { + response.writeHead(404).end("not found"); + + return; + } + + response.writeHead(200, { + "content-type": TYPES[path.extname(file).toLowerCase()] ?? "application/octet-stream", + "cache-control": "no-store", + }); + + response.end(fs.readFileSync(file)); +}).listen(port, () => { + console.log(`MenuAPI NUI preview on http://localhost:${port}`); + console.log(`serving menuapi/ from ${ui}`); +}); diff --git a/tools/preview/snapshot.js b/tools/preview/snapshot.js new file mode 100644 index 0000000..196739f --- /dev/null +++ b/tools/preview/snapshot.js @@ -0,0 +1,96 @@ +function sprite(dict, name, size, r = 255, g = 255, b = 255) { + return { dict, name, size, r, g, b }; +} + +function row(data) { + return { kind: "item", label: null, enabled: true, selected: false, leftIcon: null, rightIcon: null, ...data }; +} + +const SNAPSHOT = { + type: "menuapi", + visible: true, + align: "left", + + origin: { x: 0.0125, y: 0.0185 }, + + header: { + title: "Main Menu", + font: 1, + titleAlign: "center", + glare: true, + texture: { dict: "commonmenu", name: "interaction_bgd" } + }, + + subtitle: { + text: "Subtitle", + counter: "5 / 32", + freemode: true, + colour: "rgb(64 148 214)" + }, + + rows: [ + row({ + text: "Normal Button", + enabled: false, + leftIcon: sprite("commonmenu", "shop_tick_icon", 38, 109, 109, 109) + }), + row({ + kind: "slider", + text: "Slider", + slider: { min: 0, max: 10, position: 5, divider: false, background: "#185d97", bar: "#35a5df", sliderLeftIcon: null } + }), + row({ + kind: "slider", + text: "Slider + Bar", + slider: { min: 0, max: 10, position: 5, divider: true, background: "#1e7a3c", bar: "#49e96f", sliderLeftIcon: null } + }), + row({ + kind: "slider", + text: "Slider + Bar + Icons", + rightIcon: sprite("mpleaderboard", "leaderboard_female_icon", 38), + slider: { + min: 0, max: 10, position: 5, divider: true, + background: "#6b1414", bar: "#e03232", + sliderLeftIcon: sprite("mpleaderboard", "leaderboard_male_icon", 38) + } + }), + row({ + kind: "checkbox", + text: "Checkbox - Style 1 (click me!)", + selected: true, + checkbox: { dict: "commonmenu", name: "shop_box_blankb", size: 45, shade: 255 } + }), + row({ + kind: "checkbox", + text: "Checkbox - Style 2", + checkbox: { dict: "commonmenu", name: "shop_box_tick", size: 45, shade: 255 } + }), + row({ + kind: "checkbox", + text: "Checkbox (unchecked + locked)", + enabled: false, + leftIcon: sprite("commonmenu", "shop_lock", 38, 109, 109, 109), + checkbox: { dict: "commonmenu", name: "shop_box_blank", size: 45, shade: 109 } + }), + row({ text: "Dynamic list item.", label: "~s~← -7 ~s~→" }), + row({ text: "Hair Color", label: "~s~← Color #55 ~s~→" }), + row({ text: "Makeup Color", label: "~s~← Color #59 ~s~→" }) + ], + + panelBackground: "rgb(0 0 0 / 73%)", + panelAccent: "240, 240, 240", + + text: { size: 21, brightness: 225, weight: "default" }, + + panel: { + colours: true, + opacity: 40, + index: 12, + title: "Opacity", + name: "Colour 13 of 64" + }, + + overflow: true, + description: "This checkbox can toggle the menu position! Try it out.", + stats: null +};