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:
parent
7126b7bca0
commit
4165f58414
|
@ -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))
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -111,6 +111,7 @@ def get_any_version(data: dict) -> Version:
|
|||
whitelist = {
|
||||
"NetworkPlayer": NetworkPlayer,
|
||||
"NetworkItem": NetworkItem,
|
||||
"NetworkSlot": NetworkSlot
|
||||
}
|
||||
|
||||
custom_hooks = {
|
||||
|
|
|
@ -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")
|
||||
|
||||
|
|
|
@ -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"] %}
|
||||
|
|
|
@ -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" %}
|
||||
|
|
|
@ -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
|
||||
rectangle: self.x-2, self.y-2, self.width+4, self.height+4
|
||||
<ServerToolTip>:
|
||||
pos_hint: {'center_y': 0.5, 'center_x': 0.5}
|
||||
|
|
|
@ -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
|
||||
|
|
115
kvui.py
115
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())
|
||||
|
||||
|
|
Loading…
Reference in New Issue