From 4165f58414893c241c3b619966bce05a5e94148b Mon Sep 17 00:00:00 2001 From: Fabian Dill <Berserker66@users.noreply.github.com> Date: Tue, 24 May 2022 00:20:02 +0200 Subject: [PATCH] Clients: now featuring tooltips and some general cleanup (#564) * Clients: now featuring tooltips and some general cleanup * Clients: fade in tooltip over 0.25 seconds * Clients: reset slot and team when disconnecting * Clients: allow joining multiworld via link (TextClient only for now) --- CommonClient.py | 19 ++++- FF1Client.py | 2 +- NetUtils.py | 1 + Starcraft2Client.py | 6 +- WebHostLib/templates/hostRoom.html | 8 +- WebHostLib/templates/macros.html | 2 +- data/client.kv | 6 +- inno_setup.iss | 4 + kvui.py | 115 ++++++++++++++++++++++++++--- 9 files changed, 145 insertions(+), 18 deletions(-) diff --git a/CommonClient.py b/CommonClient.py index 2c2236b3..e7d079ee 100644 --- a/CommonClient.py +++ b/CommonClient.py @@ -17,7 +17,7 @@ if __name__ == "__main__": Utils.init_logging("TextClient", exception_logger="Client") from MultiServer import CommandProcessor -from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission +from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission, NetworkSlot from Utils import Version, stream_input from worlds import network_data_package, AutoWorldRegister import os @@ -125,6 +125,7 @@ class CommonContext(): input_task: typing.Optional[asyncio.Task] = None keep_alive_task: typing.Optional[asyncio.Task] = None items_handling: typing.Optional[int] = None + slot_info: typing.Dict[int, NetworkSlot] current_energy_link_value: int = 0 # to display in UI, gets set by server def __init__(self, server_address, password): @@ -136,6 +137,7 @@ class CommonContext(): self.server_version = Version(0, 0, 0) self.hint_cost: typing.Optional[int] = None self.games: typing.Dict[int, str] = {} + self.slot_info = {} self.permissions = { "forfeit": "disabled", "collect": "disabled", @@ -187,6 +189,8 @@ class CommonContext(): def reset_server_state(self): self.auth = None + self.slot = None + self.team = None self.items_received = [] self.locations_info = {} self.server_version = Version(0, 0, 0) @@ -511,6 +515,8 @@ async def process_server_cmd(ctx: CommonContext, args: dict): elif cmd == 'Connected': ctx.team = args["team"] ctx.slot = args["slot"] + # int keys get lost in JSON transfer + ctx.slot_info = {int(pid): data for pid, data in args["slot_info"].items()} ctx.consume_players_package(args["players"]) msgs = [] if ctx.locations_checked: @@ -643,6 +649,7 @@ if __name__ == '__main__': async def main(args): ctx = TextContext(args.connect, args.password) + ctx.auth = args.name ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop") if gui_enabled: @@ -656,8 +663,16 @@ if __name__ == '__main__': import colorama parser = get_base_parser(description="Gameless Archipelago Client, for text interfacing.") + parser.add_argument('--name', default=None, help="Slot Name to connect as.") + parser.add_argument("url", nargs="?", help="Archipelago connection url") + args = parser.parse_args() + + if args.url: + url = urllib.parse.urlparse(args.url) + args.connect = url.netloc + args.name = urllib.parse.unquote(url.username) + args.password = urllib.parse.unquote(url.password) - args, rest = parser.parse_known_args() colorama.init() asyncio.run(main(args)) diff --git a/FF1Client.py b/FF1Client.py index 2b406ceb..f6bd0829 100644 --- a/FF1Client.py +++ b/FF1Client.py @@ -263,7 +263,7 @@ if __name__ == '__main__': import colorama parser = get_base_parser() - args, rest = parser.parse_known_args() + args = parser.parse_args() colorama.init() asyncio.run(main(args)) diff --git a/NetUtils.py b/NetUtils.py index c5811756..9b207715 100644 --- a/NetUtils.py +++ b/NetUtils.py @@ -111,6 +111,7 @@ def get_any_version(data: dict) -> Version: whitelist = { "NetworkPlayer": NetworkPlayer, "NetworkItem": NetworkItem, + "NetworkSlot": NetworkSlot } custom_hooks = { diff --git a/Starcraft2Client.py b/Starcraft2Client.py index 971d92b1..51a04ea4 100644 --- a/Starcraft2Client.py +++ b/Starcraft2Client.py @@ -47,6 +47,9 @@ class StarcraftClientProcessor(ClientCommandProcessor): if not self.ctx.sc2_run_task.done(): sc2_logger.warning("Starcraft 2 Client is still running!") self.ctx.sc2_run_task.cancel() # doesn't actually close the game, just stops the python task + if self.ctx.slot is None: + sc2_logger.warning("Launching Mission without Archipelago authentication, " + "checks will not be registered to server.") self.ctx.sc2_run_task = asyncio.create_task(starcraft_launch(self.ctx, mission_number), name="Starcraft 2 Launch") else: @@ -131,10 +134,11 @@ class Context(CommonContext): async def main(): multiprocessing.freeze_support() parser = get_base_parser() - parser.add_argument('--loglevel', default='info', choices=['debug', 'info', 'warning', 'error', 'critical']) + parser.add_argument('--name', default=None, help="Slot Name to connect as.") args = parser.parse_args() ctx = Context(args.connect, args.password) + ctx.auth = args.name if ctx.server_task is None: ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop") diff --git a/WebHostLib/templates/hostRoom.html b/WebHostLib/templates/hostRoom.html index 75307989..13770e85 100644 --- a/WebHostLib/templates/hostRoom.html +++ b/WebHostLib/templates/hostRoom.html @@ -20,7 +20,13 @@ later, you can simply refresh this page and the server will be started again.<br> {% if room.last_port %} - You can connect to this room by using '/connect {{ config['PATCH_TARGET'] }}:{{ room.last_port }}' + You can connect to this room by using <span class="interactive" + data-tooltip="This means address/ip is {{ config['PATCH_TARGET'] }} and port is {{ room.last_port }}. + Clicking here opens the Text Client, if installed properly."> + <a href="archipelago://{{ config['PATCH_TARGET'] }}:{{ room.last_port }}"> + '/connect {{ config['PATCH_TARGET'] }}:{{ room.last_port }}' + </a> + </span> in the <a href="{{ url_for("tutorial_landing")}}">client</a>.<br>{% endif %} {{ macros.list_patches_room(room) }} {% if room.owner == session["_id"] %} diff --git a/WebHostLib/templates/macros.html b/WebHostLib/templates/macros.html index 663f3581..70b41fad 100644 --- a/WebHostLib/templates/macros.html +++ b/WebHostLib/templates/macros.html @@ -22,7 +22,7 @@ {% for patch in room.seed.slots|list|sort(attribute="player_id") %} <tr> <td>{{ patch.player_id }}</td> - <td>{{ patch.player_name }}</td> + <td data-tooltip="Connect via TextClient"><a href="archipelago://{{ patch.player_name | e}}:@{{ config['PATCH_TARGET'] }}:{{ room.last_port }}">{{ patch.player_name }}<a/></td> <td>{{ patch.game }}</td> <td> {% if patch.game == "Minecraft" %} diff --git a/data/client.kv b/data/client.kv index 09be0188..4f924896 100644 --- a/data/client.kv +++ b/data/client.kv @@ -30,7 +30,7 @@ size_hint_x: 1 size_hint_y: 1 pos: (0, 0) -<ServerToolTip>: +<ToolTip>: size: self.texture_size size_hint: None, None font_size: dp(18) @@ -51,4 +51,6 @@ rgba: 0.235, 0.678, 0.843, 1 Line: width: 1 - rectangle: self.x-2, self.y-2, self.width+4, self.height+4 \ No newline at end of file + rectangle: self.x-2, self.y-2, self.width+4, self.height+4 +<ServerToolTip>: + pos_hint: {'center_y': 0.5, 'center_x': 0.5} diff --git a/inno_setup.iss b/inno_setup.iss index 7f5d11f0..47809c87 100644 --- a/inno_setup.iss +++ b/inno_setup.iss @@ -167,6 +167,10 @@ Root: HKCR; Subkey: "{#MyAppName}multidata"; ValueData: "Arc Root: HKCR; Subkey: "{#MyAppName}multidata\DefaultIcon"; ValueData: "{app}\ArchipelagoServer.exe,0"; ValueType: string; ValueName: ""; Components: server Root: HKCR; Subkey: "{#MyAppName}multidata\shell\open\command"; ValueData: """{app}\ArchipelagoServer.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: server +Root: HKCR; Subkey: "archipelago"; ValueType: "string"; ValueData: "Archipegalo Protocol"; Flags: uninsdeletekey; Components: client/text +Root: HKCR; Subkey: "archipelago"; ValueType: "string"; ValueName: "URL Protocol"; ValueData: ""; Components: client/text +Root: HKCR; Subkey: "archipelago\DefaultIcon"; ValueType: "string"; ValueData: "{app}\ArchipelagoTextClient.exe,0"; Components: client/text +Root: HKCR; Subkey: "archipelago\shell\open\command"; ValueType: "string"; ValueData: """{app}\ArchipelagoTextClient.exe"" ""%1"""; Components: client/text [Code] const diff --git a/kvui.py b/kvui.py index c1130103..8f681d69 100644 --- a/kvui.py +++ b/kvui.py @@ -36,9 +36,12 @@ from kivy.uix.recycleview.views import RecycleDataViewBehavior from kivy.uix.behaviors import FocusBehavior from kivy.uix.recycleboxlayout import RecycleBoxLayout from kivy.uix.recycleview.layout import LayoutSelectionBehavior +from kivy.animation import Animation + +fade_in_animation = Animation(opacity=0, duration=0) + Animation(opacity=1, duration=0.25) import Utils -from NetUtils import JSONtoTextParser, JSONMessagePart +from NetUtils import JSONtoTextParser, JSONMessagePart, SlotType if typing.TYPE_CHECKING: import CommonClient @@ -50,7 +53,7 @@ else: # I was surprised to find this didn't already exist in kivy :( class HoverBehavior(object): - """from https://stackoverflow.com/a/605348110""" + """originally from https://stackoverflow.com/a/605348110""" hovered = BooleanProperty(False) border_point = ObjectProperty(None) @@ -61,11 +64,11 @@ class HoverBehavior(object): Window.bind(on_cursor_leave=self.on_cursor_leave) super(HoverBehavior, self).__init__(**kwargs) - def on_mouse_pos(self, *args): + def on_mouse_pos(self, window, pos): if not self.get_root_window(): - return # do proceed if I'm not displayed <=> If have no parent - pos = args[1] - # Next line to_widget allow to compensate for relative layout + return # Abort if not displayed + + # to_widget translates window pos to within widget pos inside = self.collide_point(*self.to_widget(*pos)) if self.hovered == inside: return # We have already done what was needed @@ -87,11 +90,19 @@ class HoverBehavior(object): Factory.register('HoverBehavior', HoverBehavior) -class ServerToolTip(Label): +class ToolTip(Label): + pass + + +class ServerToolTip(ToolTip): pass class HovererableLabel(HoverBehavior, Label): + pass + + +class ServerLabel(HovererableLabel): def __init__(self, *args, **kwargs): super(HovererableLabel, self).__init__(*args, **kwargs) self.layout = FloatLayout() @@ -101,6 +112,7 @@ class HovererableLabel(HoverBehavior, Label): def on_enter(self): self.popuplabel.text = self.get_text() App.get_running_app().root.add_widget(self.layout) + fade_in_animation.start(self.layout) def on_leave(self): App.get_running_app().root.remove_widget(self.layout) @@ -109,8 +121,6 @@ class HovererableLabel(HoverBehavior, Label): def ctx(self) -> context_type: return App.get_running_app().ctx - -class ServerLabel(HovererableLabel): def get_text(self): if self.ctx.server: ctx = self.ctx @@ -158,10 +168,11 @@ class SelectableRecycleBoxLayout(FocusBehavior, LayoutSelectionBehavior, """ Adds selection and focus behaviour to the view. """ -class SelectableLabel(RecycleDataViewBehavior, Label): +class SelectableLabel(RecycleDataViewBehavior, HovererableLabel): """ Add selection support to the Label """ index = None selected = BooleanProperty(False) + tooltip = None def refresh_view_attrs(self, rv, index, data): """ Catch and handle the view changes """ @@ -169,6 +180,56 @@ class SelectableLabel(RecycleDataViewBehavior, Label): return super(SelectableLabel, self).refresh_view_attrs( rv, index, data) + def create_tooltip(self, text, x, y): + text = text.replace("<br>", "\n") + if self.tooltip: + # update + self.tooltip.children[0].text = text + else: + self.tooltip = FloatLayout() + tooltip_label = ToolTip(text=text) + self.tooltip.add_widget(tooltip_label) + fade_in_animation.start(self.tooltip) + App.get_running_app().root.add_widget(self.tooltip) + + # handle left-side boundary to not render off-screen + x = max(x, 3+self.tooltip.children[0].texture_size[0] / 2) + + # position float layout + self.tooltip.x = x - self.tooltip.width / 2 + self.tooltip.y = y - self.tooltip.height / 2 + 48 + + def remove_tooltip(self): + if self.tooltip: + App.get_running_app().root.remove_widget(self.tooltip) + self.tooltip = None + + def on_mouse_pos(self, window, pos): + if not self.get_root_window(): + return # Abort if not displayed + super().on_mouse_pos(window, pos) + if self.refs and self.hovered: + + tx, ty = self.to_widget(*pos, relative=True) + # Why TF is Y flipped *within* the texture? + ty = self.texture_size[1] - ty + hit = False + for uid, zones in self.refs.items(): + for zone in zones: + x, y, w, h = zone + if x <= tx <= w and y <= ty <= h: + self.create_tooltip(uid.split("|", 1)[1], *pos) + hit = True + break + if not hit: + self.remove_tooltip() + + def on_enter(self): + pass + + def on_leave(self): + self.remove_tooltip() + def on_touch_down(self, touch): """ Add selection on touch down """ if super(SelectableLabel, self).on_touch_down(touch): @@ -416,6 +477,34 @@ class E(ExceptionHandler): class KivyJSONtoTextParser(JSONtoTextParser): + def __call__(self, *args, **kwargs): + self.ref_count = 0 + return super(KivyJSONtoTextParser, self).__call__(*args, **kwargs) + + def _handle_item_name(self, node: JSONMessagePart): + flags = node.get("flags", 0) + if flags & 0b001: # advancement + itemtype = "progression" + elif flags & 0b010: # never_exclude + itemtype = "useful" + elif flags & 0b100: # trap + itemtype = "trap" + else: + itemtype = "normal" + node.setdefault("refs", []).append("Item Class: " + itemtype) + return super(KivyJSONtoTextParser, self)._handle_item_name(node) + + def _handle_player_id(self, node: JSONMessagePart): + player = int(node["text"]) + slot_info = self.ctx.slot_info.get(player, None) + if slot_info: + text = f"Game: {slot_info.game}<br>" \ + f"Type: {SlotType(slot_info.type).name}" + if slot_info.group_members: + text += f"<br>Members:<br> " + \ + '<br> '.join(self.ctx.player_names[player] for player in slot_info.group_members) + node.setdefault("refs", []).append(text) + return super(KivyJSONtoTextParser, self)._handle_player_id(node) def _handle_color(self, node: JSONMessagePart): colors = node["color"].split(";") @@ -427,6 +516,12 @@ class KivyJSONtoTextParser(JSONtoTextParser): return self._handle_text(node) return self._handle_text(node) + def _handle_text(self, node: JSONMessagePart): + for ref in node.get("refs", []): + node["text"] = f"[ref={self.ref_count}|{ref}]{node['text']}[/ref]" + self.ref_count += 1 + return super(KivyJSONtoTextParser, self)._handle_text(node) + ExceptionManager.add_handler(E())