From c27708e168b755de484078020036f7dbf064856a Mon Sep 17 00:00:00 2001 From: qwint Date: Fri, 16 Aug 2024 17:09:54 -0500 Subject: [PATCH 1/4] makes it so that when sm varia calls stdout to print dots every time it retries it doesn't crash on frozen installs (where it doesn't exist) (#16) --- worlds/tracker/TrackerClient.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/worlds/tracker/TrackerClient.py b/worlds/tracker/TrackerClient.py index c718b14e61ca..81b41365a2d7 100644 --- a/worlds/tracker/TrackerClient.py +++ b/worlds/tracker/TrackerClient.py @@ -28,6 +28,9 @@ # webserver imports import urllib.parse +if not sys.stdout: # to make sure sm varia's "i'm working" dots don't break UT in frozen + sys.stdout = open(os.devnull, 'w') # from https://stackoverflow.com/a/6735958 + logger = logging.getLogger("Client") DEBUG = False From 66dfdbcd1778a7be515b1bd5bc7cdcd1bf532820 Mon Sep 17 00:00:00 2001 From: qwint Date: Fri, 16 Aug 2024 17:11:54 -0500 Subject: [PATCH 2/4] fix typing on 3.8 (#14) --- worlds/tracker/TrackerClient.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/worlds/tracker/TrackerClient.py b/worlds/tracker/TrackerClient.py index 81b41365a2d7..321a449f5d63 100644 --- a/worlds/tracker/TrackerClient.py +++ b/worlds/tracker/TrackerClient.py @@ -81,9 +81,9 @@ class TrackerGameContext(CommonContext): command_processor = TrackerCommandProcessor tracker_page = None watcher_task = None - update_callback: Callable[[list[str]], bool] = None - region_callback: Callable[[list[str]], bool] = None - events_callback: Callable[[list[str]], bool] = None + update_callback: "Callable[[List[str]], bool]" = None + region_callback: "Callable[[List[str]], bool]" = None + events_callback: "Callable[[List[str]], bool]" = None gen_error = None output_format = "Both" hide_excluded = False @@ -114,13 +114,13 @@ def log_to_tab(self, line: str, sort: bool = False): if self.tracker_page is not None: self.tracker_page.addLine(line, sort) - def set_callback(self, func: Optional[Callable[[list[str]], bool]] = None): + def set_callback(self, func: "Optional[Callable[[List[str]], bool]]" = None): self.update_callback = func - def set_region_callback(self, func: Optional[Callable[[list[str]], bool]] = None): + def set_region_callback(self, func: "Optional[Callable[[List[str]], bool]]" = None): self.region_callback = func - def set_events_callback(self, func: Optional[Callable[[list[str]], bool]] = None): + def set_events_callback(self, func: "Optional[Callable[[List[str]], bool]]" = None): self.events_callback = func def build_gui(self, manager: "GameManager"): @@ -471,7 +471,7 @@ def updateTracker(ctx: TrackerGameContext): regions = [] locations = [] for temp_loc in ctx.multiworld.get_reachable_locations(state, ctx.player_id): - if temp_loc.address == None or isinstance(temp_loc.address, list): + if temp_loc.address == None or isinstance(temp_loc.address, List): continue elif ctx.hide_excluded and temp_loc.progress_type == LocationProgressType.EXCLUDED: continue From 2e4c0197afeb8016f6874c3d28d3bc60bdf67e1a Mon Sep 17 00:00:00 2001 From: Faris <162540354+FarisTheAncient@users.noreply.github.com> Date: Fri, 16 Aug 2024 19:28:34 -0500 Subject: [PATCH 3/4] Map tracker poc (#17) --- worlds/tracker/Tracker.kv | 83 +++++++++----- worlds/tracker/TrackerClient.py | 188 +++++++++++++++++++++++++++++++- worlds/tracker/TrackerKivy.py | 37 +++++++ worlds/tracker/__init__.py | 31 ++++++ 4 files changed, 304 insertions(+), 35 deletions(-) create mode 100644 worlds/tracker/TrackerKivy.py diff --git a/worlds/tracker/Tracker.kv b/worlds/tracker/Tracker.kv index 53760b7e37a9..3e5e0bf9a467 100644 --- a/worlds/tracker/Tracker.kv +++ b/worlds/tracker/Tracker.kv @@ -1,31 +1,3 @@ -: - orientation: 'vertical' - padding: [10,5,10,5] - size_hint_y: 0.14 - -: - orientation: 'horizontal' - -: - text_size: self.size - size_hint: (None, 0.8) - width: 100 - markup: True - halign: 'center' - valign: 'middle' - padding_x: 5 - outline_width: 1 - disabled: True - on_release: setattr(self, 'state', 'down') - -: - orientation: 'horizontal' - padding_y: 5 - -: - size_hint_x: None - size: self.texture_size - pos_hint: {'left': 1} : messages: 1000 # amount of messages stored in client logs. cols: 1 @@ -40,4 +12,57 @@ size_hint_y: None height: self.minimum_height orientation: 'vertical' - spacing: dp(3) \ No newline at end of file + spacing: dp(3) + +: + BoxLayout: + orientation: "vertical" + # DropDown + # id: map_list + # Button: + # text: "First Item" + # Button: + # text: "Second Item" + ApAsyncImage: + id: tracker_map + source: app.source + Widget: + id: location_canvas + pos_hint: {'center_x': 0.5, 'center_y': 0.5} + size_hint: '0.01dp', '0.02dp' + canvas.before: + PushMatrix: + Rotate: + #make top left 0,0 like poptracker + origin: tracker_map.center + angle: 180 + axis: 1, 0, 0 + Translate: + #scale location_canvas with tracker_map to match resizing + xy: (tracker_map.x + (tracker_map.width - tracker_map.norm_image_size[0])/2, tracker_map.y + (tracker_map.height - tracker_map.norm_image_size[1])/2) + Scale: + #scale coords by original size so we can use coords for full image + origin: 0,0 + x: tracker_map.norm_image_size[0] / tracker_map.texture_size[0] if tracker_map.texture_size[0] > 0 else 1 + y: tracker_map.norm_image_size[1] / tracker_map.texture_size[1] if tracker_map.texture_size[1] > 0 else 1 + canvas.after: + #close transformations in after canvas so all objects get transformed + PopMatrix: + + + +: + +: + size: (app.loc_size, app.loc_size) + canvas: + Color: + rgb: 0,0,0 + Rectangle: + pos: self.x - app.loc_border-self.width/2, self.y - app.loc_border-self.height/2 + size: self.width + (app.loc_border * 2), self.height + (app.loc_border * 2) + Color: + rgb: self.color #set by ApLocation.update_color() + Rectangle: + pos: self.x-self.width/2, self.y-self.height/2 + size: self.size \ No newline at end of file diff --git a/worlds/tracker/TrackerClient.py b/worlds/tracker/TrackerClient.py index 321a449f5d63..d3d6899e32fb 100644 --- a/worlds/tracker/TrackerClient.py +++ b/worlds/tracker/TrackerClient.py @@ -7,8 +7,9 @@ import os import time import sys -from typing import Dict, Optional, List -from BaseClasses import Region, Location, ItemClassification +from typing import Dict, Optional, Union, List +from BaseClasses import ItemClassification + from BaseClasses import CollectionState, MultiWorld, LocationProgressType from worlds.generic.Rules import exclusion_rules, locality_rules @@ -16,8 +17,8 @@ from settings import get_settings from Utils import __version__, output_path from worlds import AutoWorld -from worlds.tracker import TrackerWorld -from collections import Counter +from worlds.tracker import TrackerWorld, UTMapTabData +from collections import Counter,defaultdict from MultiServer import mark_raw from Generate import main as GMain, mystery_argparse @@ -60,6 +61,22 @@ def _cmd_event_inventory(self): for event in sorted(events): logger.info(event) + def _cmd_load_map(self,map_id: str="0"): + """Force a poptracker map id to be loaded""" + if self.ctx.tracker_world is not None: + self.ctx.load_map(map_id) + updateTracker(self.ctx) + else: + logger.info("No world with internal map loaded") + + def _cmd_list_maps(self): + """List the available maps to load with /load_map""" + if self.ctx.tracker_world is not None: + for i,map in enumerate(self.ctx.maps): + logger.info("Map["+str(i)+"] = '"+map["name"]+"'") + else: + logger.info("No world with internal map loaded") + @mark_raw def _cmd_manually_collect(self, item_name: str = ""): """Manually adds an item name to the CollectionState to test""" @@ -80,6 +97,10 @@ class TrackerGameContext(CommonContext): tags = CommonContext.tags | {"Tracker"} command_processor = TrackerCommandProcessor tracker_page = None + map_page = None + tracker_world:UTMapTabData = None + coord_dict = {} + map_page_coords_func = None watcher_task = None update_callback: "Callable[[List[str]], bool]" = None region_callback: "Callable[[List[str]], bool]" = None @@ -106,6 +127,59 @@ def __init__(self, server_address, password, no_connection: bool = False): self.player_id = None self.manual_items = [] + def load_pack(self): + PACK_NAME = self.multiworld.worlds[self.player_id].__class__.__module__ + self.maps = [] + for map_page in self.tracker_world.map_page_maps: + self.maps += load_json(PACK_NAME, f"/{self.tracker_world.map_page_folder}/{map_page}") + self.locs = [] + for loc_page in self.tracker_world.map_page_locations: + self.locs += load_json(PACK_NAME, f"/{self.tracker_world.map_page_folder}/{loc_page}") + self.load_map(None) + + + def load_map(self,map_id:Union[int, str, None]): + """REMEMBER TO RUN UPDATE_TRACKER!""" + if not self.ui or self.tracker_world is None: + return + if map_id is None: + map_id = self.tracker_world.map_page_index(self.stored_data) + m=None + if isinstance(map_id,str) and not map_id.isdecimal(): + for map in self.maps: + if map["name"] == map_id: + m = map + break + else: + logger.error("Attempted to load a map that doesn't exist") + return + else: + if isinstance(map_id,str): + map_id = int(map_id) + m = self.maps[map_id] + location_name_to_id=AutoWorld.AutoWorldRegister.world_types[self.game].location_name_to_id + PACK_NAME = self.multiworld.worlds[self.player_id].__class__.__module__ + # m = [m for m in self.maps if m["name"] == map_name] + self.ui.source = f"ap:{PACK_NAME}/{self.tracker_world.map_page_folder}/{m['img']}" + self.ui.loc_size = m["location_size"] if "location_size" in m else 65 #default location size per poptracker/src/core/map.h + self.ui.loc_border = m["location_border_thickness"] if "location_border_thickness" in m else 8 #default location size per poptracker/src/core/map.h + temp_locs = [location for location in self.locs] + map_locs = [] + while temp_locs: + temp_loc = temp_locs.pop() + if "map_locations" in temp_loc: + map_locs.append(temp_loc) + elif "children" in temp_loc: + temp_locs.extend(temp_loc["children"]) + self.coords = { + (map_loc["x"], map_loc["y"]) : + [ section["name"] for section in location["sections"] if "name" in section and section["name"] in location_name_to_id and location_name_to_id[section["name"]] in self.server_locations ] + for location in map_locs + for map_loc in location["map_locations"] + if map_loc["map"] == m["name"] and any("name" in section and section["name"] in location_name_to_id and location_name_to_id[section["name"]] in self.server_locations for section in location["sections"]) + } + self.coord_dict = self.map_page_coords_func(self.coords) + def clear_page(self): if self.tracker_page is not None: self.tracker_page.resetData() @@ -127,6 +201,12 @@ def build_gui(self, manager: "GameManager"): from kivy.uix.boxlayout import BoxLayout from kivy.uix.tabbedpanel import TabbedPanelItem from kivy.uix.recycleview import RecycleView + from kivy.uix.widget import Widget + from kivy.properties import StringProperty, NumericProperty, BooleanProperty + try: + from kvui import ApAsyncImage #one of these needs to be loaded + except ImportError: + from .TrackerKivy import ApAsyncImage #use local until ap#3629 gets merged/released class TrackerLayout(BoxLayout): pass @@ -135,7 +215,7 @@ class TrackerView(RecycleView): def __init__(self, **kwargs): super().__init__(**kwargs) self.data = [] - self.data.append({"text": "Tracker v0.1.9 Initializing"}) + self.data.append({"text": "Tracker BETA v0.1.10 Initializing"}) def resetData(self): self.data.clear() @@ -145,7 +225,45 @@ def addLine(self, line: str, sort: bool = False): if sort: self.data.sort(key=lambda e: e["text"]) + class ApLocation(Widget): + from kivy.properties import DictProperty,ColorProperty + locationDict = DictProperty() + color = ColorProperty("#DD00FF") + def __init__(self, sections,**kwargs): + for location_name in sections: + self.locationDict[location_name]="none" + self.bind(locationDict=self.update_color) + super().__init__(**kwargs) + + def update_status(self, location, status): + if location in self.locationDict: + if self.locationDict[location] != status: + self.locationDict[location] = status + @staticmethod + def update_color(self,locationDict): + if any(status == "in_logic" for status in locationDict.values()) and any(status == "out_of_logic" for status in locationDict.values()): + self.color = "#FF9F20" + elif any(status == "in_logic" for status in locationDict.values()): + self.color = "#20FF20" + elif any(status == "out_of_logic" for status in locationDict.values()): + self.color = "#CF1010" + else: + self.color = "#3F3F3F" + + class VisualTracker(BoxLayout): + def load_coords(self,coords): + self.ids.location_canvas.clear_widgets() + returnDict = defaultdict(list) + for coord,sections in coords.items(): + #https://discord.com/channels/731205301247803413/1170094879142051912/1272327822630977727 + temp_loc = ApLocation(sections,pos=(coord)) + self.ids.location_canvas.add_widget(temp_loc) + for location_name in sections: + returnDict[location_name].append(temp_loc) + return returnDict + tracker_page = TabbedPanelItem(text="Tracker Page") + map_page = TabbedPanelItem(text="Map Page") try: tracker = TrackerLayout(orientation="horizontal") @@ -153,6 +271,10 @@ def addLine(self, line: str, sort: bool = False): tracker.add_widget(tracker_view) self.tracker_page = tracker_view tracker_page.content = tracker + map = VisualTracker() + self.map_page_coords_func = map.load_coords + self.map_page = map_page + map_page.content = map if self.gen_error is not None: for line in self.gen_error.split("\n"): self.log_to_tab(line, False) @@ -160,6 +282,19 @@ def addLine(self, line: str, sort: bool = False): tb = traceback.format_exc() print(tb) manager.tabs.add_widget(tracker_page) + @staticmethod + def set_map_tab(self,value,*args,map_page=map_page): + if value: + self.add_widget(map_page) + self.tab_width = self.tab_width * (len(self.tab_list)-1)/len(self.tab_list) + #for some forsaken reason, the tab panel doesn't auto adjust tab width by itself + #it is happy to let the header have a scroll bar until the window forces it to resize + else: + self.remove_widget(map_page) + self.tab_width = self.tab_width * (len(self.tab_list)+1)/len(self.tab_list) + + manager.tabs.apply_property(show_map=BooleanProperty(False)) + manager.tabs.fbind("show_map",set_map_tab) from kvui import HintLog # hook hint tab @@ -196,8 +331,18 @@ def update_available_hints(log: HintLog, hints: typing.Set[typing.Dict[str, typi def run_gui(self): from kvui import GameManager + from kivy.properties import StringProperty, NumericProperty, BooleanProperty + try: + from kvui import ImageLoader #one of these needs to be loaded + except ImportError: + from .TrackerKivy import ImageLoader #use local until ap#3629 gets merged/released + class TrackerManager(GameManager): + source = StringProperty("") + loc_size = NumericProperty(20) + loc_border = NumericProperty(5) + enable_map = BooleanProperty(False) logging_pairs = [ ("Client", "Archipelago") ] @@ -236,13 +381,20 @@ def on_package(self, cmd: str, args: dict): if self.multiworld is None: self.log_to_tab("Internal world was not able to be generated, check your yamls and relaunch", False) return - player_ids = [i for i, n in self.multiworld.player_name.items() if n == self.username] + player_ids = [i for i, n in self.multiworld.player_name.items() if n == self.player_names[self.slot]] if len(player_ids) < 1: self.log_to_tab("Player's Yaml not in tracker's list", False) return self.player_id = player_ids[0] # should only really ever be one match self.game = args["slot_info"][str(args["slot"])][1] + if self.ui is not None and getattr(self.multiworld.worlds[self.player_id], "tracker_world", None): + self.tracker_world = self.multiworld.worlds[self.player_id].tracker_world + self.load_pack() + self.ui.tabs.show_map = True + else: + self.tracker_world = None + if callable(getattr(self.multiworld.worlds[self.player_id], "interpret_slot_data", None)): temp = self.multiworld.worlds[self.player_id].interpret_slot_data(args["slot_data"]) if temp: @@ -258,6 +410,8 @@ async def disconnect(self, allow_autoreconnect: bool = False): if "Tracker" in self.tags: self.game = "" self.re_gen_passthrough = None + self.ui.tabs.show_map = False + self.tracker_world = None await super().disconnect(allow_autoreconnect) def _set_host_settings(self, host): @@ -436,6 +590,11 @@ def TMain(self, args, seed=None, baked_server_options: Optional[Dict[str, object return multiworld +def load_json(pack, path): + import pkgutil + import json + return json.loads(pkgutil.get_data(pack, path).decode('utf-8-sig')) + def updateTracker(ctx: TrackerGameContext): if ctx.tracker_failed: return #just return and don't bug the player @@ -511,6 +670,20 @@ def updateTracker(ctx: TrackerGameContext): ctx.events_callback(events) if len(callback_list) == 0: ctx.log_to_tab("All " + str(len(ctx.checked_locations)) + " accessible locations have been checked! Congrats!") + if ctx.tracker_world is not None and ctx.ui is not None: + #ctx.load_map() + location_id_to_name=AutoWorld.AutoWorldRegister.world_types[ctx.game].location_id_to_name + for location in ctx.server_locations: + loc_name = location_id_to_name[location] + relevent_coords = ctx.coord_dict[loc_name] + status = "out_of_logic" + if location in ctx.checked_locations: + status = "completed" + elif location in ctx.locations_available: + status = "in_logic" + for coord in relevent_coords: + coord.update_status(loc_name,status) + return (all_items, prog_items, events) @@ -557,3 +730,6 @@ def launch(): args.password = urllib.parse.unquote(url.password) asyncio.run(main(args)) + +if __name__ == "__main__": + launch() \ No newline at end of file diff --git a/worlds/tracker/TrackerKivy.py b/worlds/tracker/TrackerKivy.py new file mode 100644 index 000000000000..dea478742518 --- /dev/null +++ b/worlds/tracker/TrackerKivy.py @@ -0,0 +1,37 @@ +from kivy.core.image import ImageLoader, ImageLoaderBase, ImageData +from kivy.uix.image import AsyncImage +from typing import List, Union +import pkgutil +import io + + +class ImageLoaderPkgutil(ImageLoaderBase): + def load(self, filename: str) -> List[ImageData]: + # take off the "ap:" prefix + module, path = filename[3:].split("/", 1) + data = pkgutil.get_data(module, path) + print(filename) + return self._bytes_to_data(data) + + def _bytes_to_data(self, data: Union[bytes, bytearray]) -> List[ImageData]: + loader=next(loader for loader in ImageLoader.loaders if loader.can_load_memory()) + return loader.load(loader, io.BytesIO(data)) + +class ApAsyncImage(AsyncImage): + def is_uri(self, filename: str) -> bool: + if filename.startswith("ap:"): + return True + else: + return super().is_uri(filename) + +# grab the default loader method so we can override it but use it as a fallback +_original_image_loader_load = ImageLoader.load + + +def load_override(filename: str, default_load=_original_image_loader_load, **kwargs): + if filename.startswith("ap:"): + return ImageLoaderPkgutil(filename) + else: + return default_load(filename, **kwargs) + +ImageLoader.load = load_override diff --git a/worlds/tracker/__init__.py b/worlds/tracker/__init__.py index 9e61eb7cdc8a..7c8917e60456 100644 --- a/worlds/tracker/__init__.py +++ b/worlds/tracker/__init__.py @@ -1,5 +1,6 @@ from worlds.LauncherComponents import Component, components, Type, launch_subprocess +from typing import Dict, Optional, List, Any, Union def launch_client(): @@ -13,4 +14,34 @@ def launch_client(): class TrackerWorld: pass +class UTMapTabData: + """The holding class for all the poptracker integration values""" + + map_page_folder:str + """The name of the folder within the .apworld that contains the poptracker pack""" + + map_page_maps:List[str] + """The relative paths within the map_page_folder of the map.json""" + + map_page_locations:List[str] + """The relative paths within the map_page_folder of the location.json""" + + def __init__(self, map_page_folder:str, map_page_maps:Union[List[str],str], map_page_locations:Union[List[str],str]): + self.map_page_folder=map_page_folder + if isinstance(map_page_maps,str): + self.map_page_maps = [map_page_maps] + else: + self.map_page_maps = map_page_maps + if isinstance(map_page_locations,str): + self.map_page_locations = [map_page_locations] + else: + self.map_page_locations = map_page_locations + pass + + def map_page_index(self, data: Dict[str, Any]) -> int: + """Function used to fetch the map index that should be loaded, + it will be passed in the data storage (eventually) + Right now it should just return 0""" + return 0 + components.append(Component("Universal Tracker", None, func=launch_client, component_type=Type.CLIENT)) From 1f25da521a503d780f1bff7f8394649683856961 Mon Sep 17 00:00:00 2001 From: Faris Date: Fri, 16 Aug 2024 19:34:17 -0500 Subject: [PATCH 4/4] no longer a beta! --- worlds/tracker/TrackerClient.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/worlds/tracker/TrackerClient.py b/worlds/tracker/TrackerClient.py index d3d6899e32fb..f8b9b0be7ad2 100644 --- a/worlds/tracker/TrackerClient.py +++ b/worlds/tracker/TrackerClient.py @@ -215,7 +215,7 @@ class TrackerView(RecycleView): def __init__(self, **kwargs): super().__init__(**kwargs) self.data = [] - self.data.append({"text": "Tracker BETA v0.1.10 Initializing"}) + self.data.append({"text": "Tracker v0.1.10 Initializing"}) def resetData(self): self.data.clear()