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)
This commit is contained in:
Fabian Dill 2022-05-24 00:20:02 +02:00 committed by GitHub
parent 7126b7bca0
commit 4165f58414
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 145 additions and 18 deletions

View File

@ -17,7 +17,7 @@ if __name__ == "__main__":
Utils.init_logging("TextClient", exception_logger="Client") Utils.init_logging("TextClient", exception_logger="Client")
from MultiServer import CommandProcessor 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 Utils import Version, stream_input
from worlds import network_data_package, AutoWorldRegister from worlds import network_data_package, AutoWorldRegister
import os import os
@ -125,6 +125,7 @@ class CommonContext():
input_task: typing.Optional[asyncio.Task] = None input_task: typing.Optional[asyncio.Task] = None
keep_alive_task: typing.Optional[asyncio.Task] = None keep_alive_task: typing.Optional[asyncio.Task] = None
items_handling: typing.Optional[int] = 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 current_energy_link_value: int = 0 # to display in UI, gets set by server
def __init__(self, server_address, password): def __init__(self, server_address, password):
@ -136,6 +137,7 @@ class CommonContext():
self.server_version = Version(0, 0, 0) self.server_version = Version(0, 0, 0)
self.hint_cost: typing.Optional[int] = None self.hint_cost: typing.Optional[int] = None
self.games: typing.Dict[int, str] = {} self.games: typing.Dict[int, str] = {}
self.slot_info = {}
self.permissions = { self.permissions = {
"forfeit": "disabled", "forfeit": "disabled",
"collect": "disabled", "collect": "disabled",
@ -187,6 +189,8 @@ class CommonContext():
def reset_server_state(self): def reset_server_state(self):
self.auth = None self.auth = None
self.slot = None
self.team = None
self.items_received = [] self.items_received = []
self.locations_info = {} self.locations_info = {}
self.server_version = Version(0, 0, 0) self.server_version = Version(0, 0, 0)
@ -511,6 +515,8 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
elif cmd == 'Connected': elif cmd == 'Connected':
ctx.team = args["team"] ctx.team = args["team"]
ctx.slot = args["slot"] 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"]) ctx.consume_players_package(args["players"])
msgs = [] msgs = []
if ctx.locations_checked: if ctx.locations_checked:
@ -643,6 +649,7 @@ if __name__ == '__main__':
async def main(args): async def main(args):
ctx = TextContext(args.connect, args.password) ctx = TextContext(args.connect, args.password)
ctx.auth = args.name
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop") ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
if gui_enabled: if gui_enabled:
@ -656,8 +663,16 @@ if __name__ == '__main__':
import colorama import colorama
parser = get_base_parser(description="Gameless Archipelago Client, for text interfacing.") 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() colorama.init()
asyncio.run(main(args)) asyncio.run(main(args))

View File

@ -263,7 +263,7 @@ if __name__ == '__main__':
import colorama import colorama
parser = get_base_parser() parser = get_base_parser()
args, rest = parser.parse_known_args() args = parser.parse_args()
colorama.init() colorama.init()
asyncio.run(main(args)) asyncio.run(main(args))

View File

@ -111,6 +111,7 @@ def get_any_version(data: dict) -> Version:
whitelist = { whitelist = {
"NetworkPlayer": NetworkPlayer, "NetworkPlayer": NetworkPlayer,
"NetworkItem": NetworkItem, "NetworkItem": NetworkItem,
"NetworkSlot": NetworkSlot
} }
custom_hooks = { custom_hooks = {

View File

@ -47,6 +47,9 @@ class StarcraftClientProcessor(ClientCommandProcessor):
if not self.ctx.sc2_run_task.done(): if not self.ctx.sc2_run_task.done():
sc2_logger.warning("Starcraft 2 Client is still running!") 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 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), self.ctx.sc2_run_task = asyncio.create_task(starcraft_launch(self.ctx, mission_number),
name="Starcraft 2 Launch") name="Starcraft 2 Launch")
else: else:
@ -131,10 +134,11 @@ class Context(CommonContext):
async def main(): async def main():
multiprocessing.freeze_support() multiprocessing.freeze_support()
parser = get_base_parser() 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() args = parser.parse_args()
ctx = Context(args.connect, args.password) ctx = Context(args.connect, args.password)
ctx.auth = args.name
if ctx.server_task is None: if ctx.server_task is None:
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop") ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")

View File

@ -20,7 +20,13 @@
later, later,
you can simply refresh this page and the server will be started again.<br> you can simply refresh this page and the server will be started again.<br>
{% if room.last_port %} {% 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 %} in the <a href="{{ url_for("tutorial_landing")}}">client</a>.<br>{% endif %}
{{ macros.list_patches_room(room) }} {{ macros.list_patches_room(room) }}
{% if room.owner == session["_id"] %} {% if room.owner == session["_id"] %}

View File

@ -22,7 +22,7 @@
{% for patch in room.seed.slots|list|sort(attribute="player_id") %} {% for patch in room.seed.slots|list|sort(attribute="player_id") %}
<tr> <tr>
<td>{{ patch.player_id }}</td> <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>{{ patch.game }}</td>
<td> <td>
{% if patch.game == "Minecraft" %} {% if patch.game == "Minecraft" %}

View File

@ -30,7 +30,7 @@
size_hint_x: 1 size_hint_x: 1
size_hint_y: 1 size_hint_y: 1
pos: (0, 0) pos: (0, 0)
<ServerToolTip>: <ToolTip>:
size: self.texture_size size: self.texture_size
size_hint: None, None size_hint: None, None
font_size: dp(18) font_size: dp(18)
@ -52,3 +52,5 @@
Line: Line:
width: 1 width: 1
rectangle: self.x-2, self.y-2, self.width+4, self.height+4 rectangle: self.x-2, self.y-2, self.width+4, self.height+4
<ServerToolTip>:
pos_hint: {'center_y': 0.5, 'center_x': 0.5}

View File

@ -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\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: "{#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] [Code]
const const

115
kvui.py
View File

@ -36,9 +36,12 @@ from kivy.uix.recycleview.views import RecycleDataViewBehavior
from kivy.uix.behaviors import FocusBehavior from kivy.uix.behaviors import FocusBehavior
from kivy.uix.recycleboxlayout import RecycleBoxLayout from kivy.uix.recycleboxlayout import RecycleBoxLayout
from kivy.uix.recycleview.layout import LayoutSelectionBehavior 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 import Utils
from NetUtils import JSONtoTextParser, JSONMessagePart from NetUtils import JSONtoTextParser, JSONMessagePart, SlotType
if typing.TYPE_CHECKING: if typing.TYPE_CHECKING:
import CommonClient import CommonClient
@ -50,7 +53,7 @@ else:
# I was surprised to find this didn't already exist in kivy :( # I was surprised to find this didn't already exist in kivy :(
class HoverBehavior(object): class HoverBehavior(object):
"""from https://stackoverflow.com/a/605348110""" """originally from https://stackoverflow.com/a/605348110"""
hovered = BooleanProperty(False) hovered = BooleanProperty(False)
border_point = ObjectProperty(None) border_point = ObjectProperty(None)
@ -61,11 +64,11 @@ class HoverBehavior(object):
Window.bind(on_cursor_leave=self.on_cursor_leave) Window.bind(on_cursor_leave=self.on_cursor_leave)
super(HoverBehavior, self).__init__(**kwargs) super(HoverBehavior, self).__init__(**kwargs)
def on_mouse_pos(self, *args): def on_mouse_pos(self, window, pos):
if not self.get_root_window(): if not self.get_root_window():
return # do proceed if I'm not displayed <=> If have no parent return # Abort if not displayed
pos = args[1]
# Next line to_widget allow to compensate for relative layout # to_widget translates window pos to within widget pos
inside = self.collide_point(*self.to_widget(*pos)) inside = self.collide_point(*self.to_widget(*pos))
if self.hovered == inside: if self.hovered == inside:
return # We have already done what was needed return # We have already done what was needed
@ -87,11 +90,19 @@ class HoverBehavior(object):
Factory.register('HoverBehavior', HoverBehavior) Factory.register('HoverBehavior', HoverBehavior)
class ServerToolTip(Label): class ToolTip(Label):
pass
class ServerToolTip(ToolTip):
pass pass
class HovererableLabel(HoverBehavior, Label): class HovererableLabel(HoverBehavior, Label):
pass
class ServerLabel(HovererableLabel):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super(HovererableLabel, self).__init__(*args, **kwargs) super(HovererableLabel, self).__init__(*args, **kwargs)
self.layout = FloatLayout() self.layout = FloatLayout()
@ -101,6 +112,7 @@ class HovererableLabel(HoverBehavior, Label):
def on_enter(self): def on_enter(self):
self.popuplabel.text = self.get_text() self.popuplabel.text = self.get_text()
App.get_running_app().root.add_widget(self.layout) App.get_running_app().root.add_widget(self.layout)
fade_in_animation.start(self.layout)
def on_leave(self): def on_leave(self):
App.get_running_app().root.remove_widget(self.layout) App.get_running_app().root.remove_widget(self.layout)
@ -109,8 +121,6 @@ class HovererableLabel(HoverBehavior, Label):
def ctx(self) -> context_type: def ctx(self) -> context_type:
return App.get_running_app().ctx return App.get_running_app().ctx
class ServerLabel(HovererableLabel):
def get_text(self): def get_text(self):
if self.ctx.server: if self.ctx.server:
ctx = self.ctx ctx = self.ctx
@ -158,10 +168,11 @@ class SelectableRecycleBoxLayout(FocusBehavior, LayoutSelectionBehavior,
""" Adds selection and focus behaviour to the view. """ """ Adds selection and focus behaviour to the view. """
class SelectableLabel(RecycleDataViewBehavior, Label): class SelectableLabel(RecycleDataViewBehavior, HovererableLabel):
""" Add selection support to the Label """ """ Add selection support to the Label """
index = None index = None
selected = BooleanProperty(False) selected = BooleanProperty(False)
tooltip = None
def refresh_view_attrs(self, rv, index, data): def refresh_view_attrs(self, rv, index, data):
""" Catch and handle the view changes """ """ Catch and handle the view changes """
@ -169,6 +180,56 @@ class SelectableLabel(RecycleDataViewBehavior, Label):
return super(SelectableLabel, self).refresh_view_attrs( return super(SelectableLabel, self).refresh_view_attrs(
rv, index, data) 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): def on_touch_down(self, touch):
""" Add selection on touch down """ """ Add selection on touch down """
if super(SelectableLabel, self).on_touch_down(touch): if super(SelectableLabel, self).on_touch_down(touch):
@ -416,6 +477,34 @@ class E(ExceptionHandler):
class KivyJSONtoTextParser(JSONtoTextParser): 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): def _handle_color(self, node: JSONMessagePart):
colors = node["color"].split(";") colors = node["color"].split(";")
@ -427,6 +516,12 @@ class KivyJSONtoTextParser(JSONtoTextParser):
return self._handle_text(node) return self._handle_text(node)
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()) ExceptionManager.add_handler(E())