diff --git a/Mods/ModMenu/NetworkManager.py b/Mods/ModMenu/NetworkManager.py index 1caad6fa..492f4b2c 100644 --- a/Mods/ModMenu/NetworkManager.py +++ b/Mods/ModMenu/NetworkManager.py @@ -74,11 +74,45 @@ def send(self) -> None: def _PlayerTick(caller: unrealsdk.UObject, function: unrealsdk.UFunction, params: unrealsdk.FStruct) -> bool: timeout = _message_queue[0].timeout - if timeout is not None and timeout < time(): + if timeout is None: + _message_queue[0].send() + elif timeout < time(): _dequeue_message() return True +def _Logout(caller: unrealsdk.UObject, function: unrealsdk.UFunction, params: unrealsdk.FStruct) -> bool: + global _message_queue + # If there are no queued messages, we have nothing to do. + if len(_message_queue) == 0: + return True + + # Filter the messages destined to the logged out player out of our message queue. + purged_queue = deque(message for message in _message_queue if message.PC is not params.Exiting) + + # If there are no more messages left in the queue, we may cease observing message timeouts. + if len(purged_queue) == 0: + unrealsdk.RemoveHook("Engine.PlayerController.PlayerTick", "ModMenu.NetworkManager") + + # If the first message in the filtered queue is different from the previous one, send it. + elif purged_queue[0] is not _message_queue[0]: + purged_queue[0].send() + + _message_queue = purged_queue + return True + + +def _GameSessionEnded(caller: unrealsdk.UObject, function: unrealsdk.UFunction, params: unrealsdk.FStruct) -> bool: + global _message_queue + # If there are no queued messages, we have nothing to do. + if len(_message_queue) == 0: + return True + # Cease observing message timeouts and empty the message queue. + unrealsdk.RemoveHook("Engine.PlayerController.PlayerTick", "ModMenu.NetworkManager") + _message_queue = deque() + return True + + def _enqueue_message(message: _Message) -> None: """ Add a message to the message queue, sending it if message queue is empty. """ _message_queue.append(message) @@ -328,7 +362,7 @@ def _server_speech(caller: unrealsdk.UObject, function: unrealsdk.UFunction, par # Check if the message type indicates an acknowledgement from a client for the previous message # we had sent. If so, and its ID matches that of our last message, dequeue it and we are done. if message_type == "unrealsdk.__clientack__": - if message == _message_queue[0].ID: + if len(_message_queue) > 0 and _message_queue[0].ID == message: _dequeue_message() return False @@ -390,7 +424,7 @@ def _client_message(caller: unrealsdk.UObject, function: unrealsdk.UFunction, pa return True if message_type == "unrealsdk.__serverack__": - if message == _message_queue[0].ID: + if len(_message_queue) > 0 and _message_queue[0].ID == message: _dequeue_message() return False @@ -431,3 +465,5 @@ def _client_message(caller: unrealsdk.UObject, function: unrealsdk.UFunction, pa unrealsdk.RunHook("Engine.PlayerController.ServerSpeech", "ModMenu.NetworkManager", _server_speech) unrealsdk.RunHook("WillowGame.WillowPlayerController.ClientMessage", "ModMenu.NetworkManager", _client_message) +unrealsdk.RunHook("Engine.GameInfo.Logout", "ModMenu.NetworkManager", _Logout) +unrealsdk.RunHook("Engine.GameViewportClient.GameSessionEnded", "ModMenu.NetworkManager", _GameSessionEnded) diff --git a/Mods/ModMenu/OptionManager.py b/Mods/ModMenu/OptionManager.py index 50eea8eb..7997396c 100644 --- a/Mods/ModMenu/OptionManager.py +++ b/Mods/ModMenu/OptionManager.py @@ -14,6 +14,15 @@ _MOD_OPTIONS_EVENT_ID: int = 1417 _MOD_OPTIONS_MENU_NAME: str = "MODS" +_INDENT: int = 2 + + +class _ModHeader(Options.Field): + def __init__(self, Caption: str) -> None: + self.Caption = Caption + self.Description = "" + self.IsHidden = False + def _create_data_provider(name: str) -> unrealsdk.UObject: """ @@ -149,15 +158,18 @@ def _DataProviderOptionsBasePopulate(caller: unrealsdk.UObject, function: unreal continue if not one_shown: one_shown = True - all_options.append(Options.Field(mod.Name)) + all_options.append(_ModHeader(mod.Name)) all_options.append(option) _nested_options_stack.append(Options.Nested(_MOD_OPTIONS_MENU_NAME, "", all_options)) + first_level = len(_nested_options_stack) == 1 for idx, option in enumerate(_nested_options_stack[-1].Children): if option.IsHidden: continue + indent = " " * _INDENT if first_level and not isinstance(option, _ModHeader) else "" + if isinstance(option, Options.Spinner): spinner_idx: int if isinstance(option, Options.Boolean): @@ -166,12 +178,12 @@ def _DataProviderOptionsBasePopulate(caller: unrealsdk.UObject, function: unreal spinner_idx = option.Choices.index(option.CurrentValue) params.TheList.AddSpinnerListItem( - idx, option.Caption, False, spinner_idx, option.Choices + idx, indent + option.Caption, False, spinner_idx, option.Choices ) elif isinstance(option, Options.Slider): params.TheList.AddSliderListItem( idx, - option.Caption, + indent + option.Caption, False, option.CurrentValue, option.MinValue, @@ -180,11 +192,9 @@ def _DataProviderOptionsBasePopulate(caller: unrealsdk.UObject, function: unreal ) elif isinstance(option, Options.Field): disabled = False - new = False if isinstance(option, Options.Nested): disabled = not _is_anything_shown(option.Children) - new = True - params.TheList.AddListItem(idx, option.Caption, disabled, new) + params.TheList.AddListItem(idx, indent + option.Caption, disabled, False) caller.AddDescription(idx, option.Description) diff --git a/Mods/ModMenu/Options.py b/Mods/ModMenu/Options.py index 85482d05..9b4a4f31 100644 --- a/Mods/ModMenu/Options.py +++ b/Mods/ModMenu/Options.py @@ -1,5 +1,6 @@ from abc import ABC, abstractmethod -from typing import Any, Optional, Sequence, Tuple +from reprlib import recursive_repr +from typing import Any, Generic, Optional, Sequence, Tuple, TypeVar from . import DeprecationHelper as dh @@ -14,6 +15,8 @@ "Value", ) +T = TypeVar("T") + class Base(ABC): """ @@ -39,7 +42,7 @@ def __init__( raise NotImplementedError -class Value(Base): +class Value(Base, Generic[T]): """ The abstract base class for all options that store a value. @@ -52,22 +55,22 @@ class Value(Base): IsHidden: If the option is hidden from the options menu. """ - CurrentValue: Any - StartingValue: Any + CurrentValue: T + StartingValue: T @abstractmethod def __init__( self, Caption: str, Description: str, - StartingValue: Any, + StartingValue: T, *, IsHidden: bool = True ) -> None: raise NotImplementedError -class Hidden(Value): +class Hidden(Value[T]): """ A hidden option that never displays in the menu but stores an arbitrary (json serializable) value to the settings file. @@ -81,11 +84,12 @@ class Hidden(Value): StartingValue: The default value of the option. IsHidden: If the option is hidden from the options menu. This is forced to True. """ + def __init__( self, Caption: str, Description: str = "", - StartingValue: Any = None, + StartingValue: T = None, # type: ignore *, IsHidden: bool = True ) -> None: @@ -116,8 +120,18 @@ def IsHidden(self) -> bool: # type: ignore def IsHidden(self, val: bool) -> None: pass + @recursive_repr() + def __repr__(self) -> str: + return ( + f"Hidden(" + f"Caption={repr(self.Caption)}," + f"Description={repr(self.Description)}," + f"*,IsHidden={repr(self.IsHidden)}" + f")" + ) -class Slider(Value): + +class Slider(Value[int]): """ An option which allows users to select a value along a slider. @@ -176,8 +190,23 @@ def __init__( self.Increment = Increment self.IsHidden = IsHidden - -class Spinner(Value): + @recursive_repr() + def __repr__(self) -> str: + return ( + f"Slider(" + f"Caption={repr(self.Caption)}," + f"Description={repr(self.Description)}," + f"CurrentValue={repr(self.CurrentValue)}," + f"StartingValue={repr(self.StartingValue)}," + f"MinValue={repr(self.MinValue)}," + f"MaxValue={repr(self.MaxValue)}," + f"Increment={repr(self.Increment)}," + f"*,IsHidden={repr(self.IsHidden)}" + f")" + ) + + +class Spinner(Value[str]): """ An option which allows users to select one value from a sequence of strings. @@ -242,8 +271,21 @@ def __init__( f"Provided starting value '{self.StartingValue}' is not in the list of choices." ) - -class Boolean(Spinner): + @recursive_repr() + def __repr__(self) -> str: + return ( + f"Spinner(" + f"Caption={repr(self.Caption)}," + f"Description={repr(self.Description)}," + f"CurrentValue={repr(self.CurrentValue)}," + f"StartingValue={repr(self.StartingValue)}," + f"Choices={repr(self.Choices)}," + f"*,IsHidden={repr(self.IsHidden)}" + f")" + ) + + +class Boolean(Spinner, Value[bool]): """ A special form of a spinner, with two options representing boolean values. @@ -309,6 +351,19 @@ def CurrentValue(self, val: Any) -> None: else: self._current_value = bool(val) + @recursive_repr() + def __repr__(self) -> str: + return ( + f"Boolean(" + f"Caption={repr(self.Caption)}," + f"Description={repr(self.Description)}," + f"CurrentValue={repr(self.CurrentValue)}," + f"StartingValue={repr(self.StartingValue)}," + f"Choices={repr(self.Choices)}," + f"*,IsHidden={repr(self.IsHidden)}" + f")" + ) + class Field(Base): """ @@ -320,33 +375,11 @@ class Field(Base): IsHidden: If the field is hidden from the options menu. """ - def __init__( - self, - Caption: str, - Description: str = "", - *, - IsHidden: bool = False - ) -> None: - """ - Creates the option. - - Args: - Caption: The name of the option. - Description: A short description of the option to show when hovering over it in the menu. - IsHidden (keyword only): If the value is hidden from the options menu. - """ - self.Caption = Caption - self.Description = Description - self.IsHidden = IsHidden - class Nested(Field): """ A field which when clicked opens up a nested menu with more options. - These are distinguished from normal fields by having the "new" exclaimation mark to the side of - it, but you should probably still give it a meaningful description. - Note that these fields will be disabled if all child options are either hidden or other disabled nested fields. @@ -383,3 +416,14 @@ def __init__( self.Description = Description self.Children = Children self.IsHidden = IsHidden + + @recursive_repr() + def __repr__(self) -> str: + return ( + f"Nested(" + f"Caption={repr(self.Caption)}," + f"Description={repr(self.Description)}," + f"Children={repr(self.Children)}," + f"*,IsHidden={repr(self.IsHidden)}" + f")" + ) diff --git a/Mods/SkillRandomizer/__init__.py b/Mods/SkillRandomizer/__init__.py index ec3484e0..7f307773 100644 --- a/Mods/SkillRandomizer/__init__.py +++ b/Mods/SkillRandomizer/__init__.py @@ -6,7 +6,7 @@ import os from ..ModManager import BL2MOD, RegisterMod -from Mods.ModMenu import Game +from Mods.ModMenu import Game, Hook class CrossSkillRandomizer(BL2MOD): diff --git a/README.md b/README.md index c345c35f..bd395e11 100644 --- a/README.md +++ b/README.md @@ -7,19 +7,29 @@ An UnrealEngine Plugin enabling using Python to write plugins that interact dire ## Installation -Begin by [downloading the latest version of `PythonSDK.zip` here](https://github.com/Matt-Hurd/BL2-SDK/releases). +1. [Download the latest version of `PythonSDK.zip`.](https://github.com/Matt-Hurd/BL2-SDK/releases) Ensure you download `PythonSDK.zip` from that page: +![PythonSDK Download Page](https://i.imgur.com/tBlidGi.png) -For PythonSDK to be able to interact with the game, you must add a few things to the game's Win32 folder. If you already have PluginLoader installed, you'll need to overwrite some files. +2. Open `PythonSDK.zip`. It should contain 4 items: -1. Quit the game if it is running. +![PythonSDK.zip Contents](https://i.imgur.com/jd77dnB.png) -2. Extract all of the contents of the folder in the PythonSDK.zip into your `Borderlands 2\Binaries\Win32` directory, overwriting files if necessary. - You want there to be a file `Win32\ddraw.dll`, *not* `Win32\PythonSDK\ddraw.dll`. +3. Locate your game's files. In Steam, this can be done by right-clicking on the game in your library, selecting "Properties," then in the "Local Files" section, clicking "Browse": -3. If you have installed an older version of the SDK, delete any extra old files that weren't overwritten. +![Steam Contextual Menu](https://i.imgur.com/eyfn3ht.png) ![Steam Local Files Properties](https://i.imgur.com/wok2ZUA.png) -4. Download and install [this](https://aka.ms/vs/16/release/vc_redist.x86.exe) Mircosoft Visual C++ Redistributable. Most of the time this will already be installed. +4. In the game's files, navigate to the `Binaries`, then the `Win32` folder. This folder should contain the `.exe` for your game (i.e. `Borderlands2.exe` or `BorderlandsPreSequel.exe`). + +5. Copy the 4 items from `PythonSDK.zip` **exactly as they** are to the `Win32` folder. Note that `pythonXX.zip` should *not* be un-zipped: + +![Win32 Folder Contents](https://i.imgur.com/hIvNi7w.png) + +6. If you had previously installed an older version of the SDK, delete any old files that weren't overwritten by the ones in the latest `PythonSDK.zip`. + +7. You are done, and may launch the game (if it is running, relaunch it now). You should see a "Mods" menu in the main menu! + +8. If the SDK fails to run with the files correctly in place as described above, you may need to [download and install Mircosoft Visual C++ Redistributable](https://aka.ms/vs/16/release/vc_redist.x86.exe). ### Linux (SteamPlay/Proton and Wine)