from typing import * import asyncio import logging from BaseClasses import ItemClassification from NetUtils import JSONMessagePart from kvui import GameManager, HoverBehavior, ServerToolTip, KivyJSONtoTextParser, LogtoUI from kivy.app import App from kivy.clock import Clock from kivy.core.clipboard import Clipboard from kivy.uix.gridlayout import GridLayout from kivy.lang import Builder from kivy.metrics import dp from kivy.uix.label import Label from kivy.uix.button import Button from kivymd.uix.menu import MDDropdownMenu from kivymd.uix.tooltip import MDTooltip from kivy.uix.scrollview import ScrollView from kivy.properties import StringProperty, BooleanProperty, NumericProperty from .client import SC2Context, calc_unfinished_nodes, is_mission_available, compute_received_items, STARCRAFT2 from .item.item_descriptions import item_descriptions from .item.item_annotations import ITEM_NAME_ANNOTATIONS from .mission_order.entry_rules import RuleData, SubRuleRuleData, ItemRuleData from .mission_tables import lookup_id_to_mission, campaign_race_exceptions, \ SC2Mission, SC2Race from .locations import LocationType, lookup_location_id_to_type, lookup_location_id_to_flags from .options import LocationInclusion, MissionOrderScouting from . import SC2World class HoverableButton(HoverBehavior, Button): pass class MissionButton(HoverableButton, MDTooltip): tooltip_text = StringProperty("Test") mission_id = NumericProperty(-1) is_exit = BooleanProperty(False) is_goal = BooleanProperty(False) showing_tooltip = BooleanProperty(False) def __init__(self, *args, **kwargs): super(HoverableButton, self).__init__(**kwargs) self._tooltip = ServerToolTip(text=self.text, markup=True) self._tooltip.padding = [5, 2, 5, 2] def on_enter(self): self._tooltip.text = self.tooltip_text if self.tooltip_text != "": self.display_tooltip() def on_leave(self): self.remove_tooltip() def display_tooltip(self, *args): self.showing_tooltip = True return super().display_tooltip(*args) def remove_tooltip(self, *args): self.showing_tooltip = False return super().remove_tooltip(*args) @property def ctx(self) -> SC2Context: return App.get_running_app().ctx class CampaignScroll(ScrollView): border_on = BooleanProperty(False) class MultiCampaignLayout(GridLayout): pass class DownloadDataWarningMessage(Label): pass class CampaignLayout(GridLayout): pass class RegionLayout(GridLayout): pass class ColumnLayout(GridLayout): pass class MissionLayout(GridLayout): pass class MissionCategory(GridLayout): pass class SC2JSONtoKivyParser(KivyJSONtoTextParser): def _handle_item_name(self, node: JSONMessagePart): item_name = node["text"] if self.ctx.slot_info[node["player"]].game != STARCRAFT2 or item_name not in item_descriptions: return super()._handle_item_name(node) flags = node.get("flags", 0) item_types = [] if flags & ItemClassification.progression: item_types.append("progression") if flags & ItemClassification.useful: item_types.append("useful") if flags & ItemClassification.trap: item_types.append("trap") if not item_types: item_types.append("normal") # TODO: Some descriptions are too long and get cut off. Is there a general solution or does someone need to manually check every description? desc = item_descriptions[item_name].replace(". \n", ".
").replace(". ", ".
").replace("\n", "
") annotation = ITEM_NAME_ANNOTATIONS.get(item_name) if annotation is not None: desc = f"{annotation}
{desc}" ref = "Item Class: " + ", ".join(item_types) + "

" + desc node.setdefault("refs", []).append(ref) return super(KivyJSONtoTextParser, self)._handle_item_name(node) def _handle_text(self, node: JSONMessagePart): if node.get("keep_markup", False): 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) else: return super()._handle_text(node) class SC2Manager(GameManager): base_title = "Archipelago Starcraft 2 Client" campaign_panel: Optional[MultiCampaignLayout] = None campaign_scroll_panel: Optional[CampaignScroll] = None last_checked_locations: Set[int] = set() last_items_received: List[int] = [] last_shown_tooltip: int = -1 last_data_out_of_date = False mission_buttons: List[MissionButton] = [] launching: Union[bool, int] = False # if int -> mission ID refresh_from_launching = True first_check = True first_mission = "" button_colors: Dict[SC2Race, Tuple[float, float, float]] = {} ctx: SC2Context def __init__(self, ctx: SC2Context) -> None: super().__init__(ctx) self.json_to_kivy_parser = SC2JSONtoKivyParser(ctx) self.minimized = False def on_start(self) -> None: from . import gui_config warnings, window_width, window_height = gui_config.get_window_defaults() from kivy.core.window import Window original_size_x, original_size_y = Window.size Window.size = window_width, window_height Window.left -= max((window_width - original_size_x) // 2, 0) Window.top -= max((window_height - original_size_y) // 2, 0) # Add the logging handler manually here instead of using `logging_pairs` to avoid adding 2 unnecessary tabs logging.getLogger("Starcraft2").addHandler(LogtoUI(self.log_panels["All"].on_log)) for startup_warning in warnings: logging.getLogger("Starcraft2").warning(f"Startup WARNING: {startup_warning}") for race in (SC2Race.TERRAN, SC2Race.PROTOSS, SC2Race.ZERG): errors, color = gui_config.get_button_color(race.name) self.button_colors[race] = color for error in errors: logging.getLogger("Starcraft2").warning(f"{race.name.title()} button color setting: {error}") def clear_tooltip(self) -> None: for button in self.mission_buttons: button.remove_tooltip() def shown_tooltip(self) -> int: for button in self.mission_buttons: if button.showing_tooltip: return button.mission_id return -1 def build(self): container = super().build() panel = self.add_client_tab("Starcraft 2 Launcher", CampaignScroll()) self.campaign_scroll_panel = panel.content self.campaign_panel = MultiCampaignLayout() panel.content.add_widget(self.campaign_panel) Clock.schedule_interval(self.build_mission_table, 0.5) return container def build_mission_table(self, dt) -> None: if self.launching: assert self.campaign_panel is not None self.refresh_from_launching = False self.campaign_panel.clear_widgets() self.campaign_panel.add_widget(Label( text="Launching Mission: " + lookup_id_to_mission[self.launching].mission_name )) if self.ctx.ui: self.ctx.ui.clear_tooltip() return sorted_items_received = sorted([item.item for item in self.ctx.items_received]) shown_tooltip = self.shown_tooltip() hovering_tooltip = ( self.last_shown_tooltip != -1 and self.last_shown_tooltip == shown_tooltip ) data_changed = ( self.last_checked_locations != self.ctx.checked_locations or self.last_items_received != sorted_items_received ) needs_redraw = ( data_changed and not hovering_tooltip or not self.refresh_from_launching or self.last_data_out_of_date != self.ctx.data_out_of_date or self.first_check ) self.last_shown_tooltip = shown_tooltip if not needs_redraw: return assert self.campaign_panel is not None self.refresh_from_launching = True self.clear_tooltip() self.campaign_panel.clear_widgets() if self.ctx.data_out_of_date: self.campaign_panel.add_widget(Label(text="", padding=[0, 5, 0, 5])) warning_label = DownloadDataWarningMessage( text="Map/Mod data is out of date. Run /download_data in the client", padding=[0, 25, 0, 25], ) self.campaign_scroll_panel.border_on = True self.campaign_panel.add_widget(warning_label) else: self.campaign_scroll_panel.border_on = False self.last_data_out_of_date = self.ctx.data_out_of_date if len(self.ctx.custom_mission_order) == 0: self.campaign_panel.add_widget(Label(text="Connect to a world to see a mission layout here.")) return self.last_checked_locations = self.ctx.checked_locations.copy() self.last_items_received = sorted_items_received self.first_check = False self.mission_buttons = [] available_missions, available_layouts, available_campaigns, unfinished_missions = calc_unfinished_nodes(self.ctx) # The MultiCampaignLayout widget needs a default height of 15 (set in the .kv) to display the above Labels correctly multi_campaign_layout_height = 15 # Fetching IDs of all the locations with hints self.hints_to_highlight = [] hints = self.ctx.stored_data.get(f"_read_hints_{self.ctx.team}_{self.ctx.slot}") if hints: for hint in hints: if hint['finding_player'] == self.ctx.slot and not hint['found']: self.hints_to_highlight.append(hint['location']) MISSION_BUTTON_HEIGHT = 50 MISSION_BUTTON_PADDING = 6 for campaign_idx, campaign in enumerate(self.ctx.custom_mission_order): longest_column = max(len(col) for layout in campaign.layouts for col in layout.missions) if longest_column == 1: campaign_layout_height = 115 else: campaign_layout_height = (longest_column + 2) * (MISSION_BUTTON_HEIGHT + MISSION_BUTTON_PADDING) multi_campaign_layout_height += campaign_layout_height campaign_layout = CampaignLayout(size_hint_y=None, height=campaign_layout_height) campaign_layout.add_widget( Label(text=campaign.name, size_hint_y=None, height=25, outline_width=1) ) mission_layout = MissionLayout(padding=[10,0,10,0]) for layout_idx, layout in enumerate(campaign.layouts): layout_panel = RegionLayout() layout_panel.add_widget( Label(text=layout.name, size_hint_y=None, height=25, outline_width=1)) column_panel = ColumnLayout() for column in layout.missions: category_panel = MissionCategory(padding=[3,MISSION_BUTTON_PADDING,3,MISSION_BUTTON_PADDING]) for mission in column: mission_id = mission.mission_id # Empty mission slots if mission_id == -1: column_spacer = Label(text='', size_hint_y=None, height=MISSION_BUTTON_HEIGHT) category_panel.add_widget(column_spacer) continue mission_obj = lookup_id_to_mission[mission_id] mission_finished = self.ctx.is_mission_completed(mission_id) is_layout_exit = mission_id in layout.exits and not mission_finished is_campaign_exit = mission_id in campaign.exits and not mission_finished text, tooltip = self.mission_text( self.ctx, mission_id, mission_obj, layout_idx, is_layout_exit, layout.name, campaign_idx, is_campaign_exit, campaign.name, available_missions, available_layouts, available_campaigns, unfinished_missions ) mission_button = MissionButton(text=text, size_hint_y=None, height=MISSION_BUTTON_HEIGHT) mission_button.mission_id = mission_id if mission_id in self.ctx.final_mission_ids: mission_button.is_goal = True if is_layout_exit or is_campaign_exit: mission_button.is_exit = True mission_race = mission_obj.race if mission_race == SC2Race.ANY: mission_race = mission_obj.campaign.race race = campaign_race_exceptions.get(mission_obj, mission_race) if race in self.button_colors: mission_button.background_color = self.button_colors[race] mission_button.tooltip_text = tooltip mission_button.bind(on_press=self.mission_callback) self.mission_buttons.append(mission_button) category_panel.add_widget(mission_button) # layout_panel.add_widget(Label(text="")) column_panel.add_widget(category_panel) layout_panel.add_widget(column_panel) mission_layout.add_widget(layout_panel) campaign_layout.add_widget(mission_layout) self.campaign_panel.add_widget(campaign_layout) self.campaign_panel.height = multi_campaign_layout_height # For some reason the AP HoverBehavior won't send an enter event if a button spawns under the cursor, # so manually send an enter event if a button is hovered immediately for button in self.mission_buttons: if button.hovered: button.dispatch("on_enter") break def mission_text( self, ctx: SC2Context, mission_id: int, mission_obj: SC2Mission, layout_id: int, is_layout_exit: bool, layout_name: str, campaign_id: int, is_campaign_exit: bool, campaign_name: str, available_missions: List[int], available_layouts: Dict[int, List[int]], available_campaigns: List[int], unfinished_missions: List[int] ) -> Tuple[str, str]: COLOR_MISSION_IMPORTANT = "6495ED" # blue COLOR_MISSION_UNIMPORTANT = "A0BEF4" # lighter blue COLOR_MISSION_CLEARED = "FFFFFF" # white COLOR_MISSION_LOCKED = "A9A9A9" # gray COLOR_PARENT_LOCKED = "848484" # darker gray COLOR_MISSION_FINAL = "FFBC95" # orange COLOR_MISSION_FINAL_LOCKED = "D0C0BE" # gray + orange COLOR_FINAL_PARENT_LOCKED = "D0C0BE" # gray + orange COLOR_FINAL_MISSION_REMINDER = "FF5151" # light red COLOR_VICTORY_LOCATION = "FFC156" # gold COLOR_TOOLTIP_DONE = "51FF51" # light green COLOR_TOOLTIP_NOT_DONE = "FF5151" # light red text = mission_obj.mission_name tooltip: str = "" remaining_locations, plando_locations, remaining_count = self.sort_unfinished_locations(mission_id) campaign_locked = campaign_id not in available_campaigns layout_locked = layout_id not in available_layouts[campaign_id] # Map has uncollected locations if mission_id in unfinished_missions: if self.any_valuable_locations(remaining_locations): text = f"[color={COLOR_MISSION_IMPORTANT}]{text}[/color]" else: text = f"[color={COLOR_MISSION_UNIMPORTANT}]{text}[/color]" elif mission_id in available_missions: text = f"[color={COLOR_MISSION_CLEARED}]{text}[/color]" # Map requirements not met else: mission_rule, layout_rule, campaign_rule = ctx.mission_id_to_entry_rules[mission_id] mission_has_rule = mission_rule.amount > 0 layout_has_rule = layout_rule.amount > 0 extra_reqs = False if campaign_locked: text = f"[color={COLOR_PARENT_LOCKED}]{text}[/color]" tooltip += "To unlock this campaign, " shown_rule = campaign_rule extra_reqs = layout_has_rule or mission_has_rule elif layout_locked: text = f"[color={COLOR_PARENT_LOCKED}]{text}[/color]" tooltip += "To unlock this questline, " shown_rule = layout_rule extra_reqs = mission_has_rule else: text = f"[color={COLOR_MISSION_LOCKED}]{text}[/color]" tooltip += "To unlock this mission, " shown_rule = mission_rule rule_tooltip = shown_rule.tooltip(0, lookup_id_to_mission, COLOR_TOOLTIP_DONE, COLOR_TOOLTIP_NOT_DONE) tooltip += rule_tooltip.replace(rule_tooltip[0], rule_tooltip[0].lower(), 1) extra_word = "are" if shown_rule.shows_single_rule(): extra_word = "is" tooltip += "." if extra_reqs: tooltip += f"\nThis mission has additional requirements\nthat will be shown once the above {extra_word} met." # Mark exit missions exit_for: str = "" if is_layout_exit: exit_for += layout_name if layout_name else "this questline" if is_campaign_exit: if exit_for: exit_for += " and " exit_for += campaign_name if campaign_name else "this campaign" if exit_for: if tooltip: tooltip += "\n\n" tooltip += f"Required to beat {exit_for}" # Mark goal missions if mission_id in self.ctx.final_mission_ids: if mission_id in available_missions: text = f"[color={COLOR_MISSION_FINAL}]{mission_obj.mission_name}[/color]" elif campaign_locked or layout_locked: text = f"[color={COLOR_FINAL_PARENT_LOCKED}]{mission_obj.mission_name}[/color]" else: text = f"[color={COLOR_MISSION_FINAL_LOCKED}]{mission_obj.mission_name}[/color]" if tooltip and not exit_for: tooltip += "\n\n" elif exit_for: tooltip += "\n" if any(location_type == LocationType.VICTORY for (location_type, _, _) in remaining_locations): tooltip += f"[color={COLOR_FINAL_MISSION_REMINDER}]Required to beat the world[/color]" else: tooltip += "This goal mission is already beaten.\nBeat the remaining goal missions to beat the world." # Populate remaining location list if remaining_count > 0: if tooltip: tooltip += "\n\n" tooltip += f"[b][color={COLOR_MISSION_IMPORTANT}]Uncollected locations[/color][/b]" last_location_type = LocationType.VICTORY victory_printed = False if self.ctx.mission_order_scouting != MissionOrderScouting.option_none: mission_available = mission_id in available_missions scoutable = self.is_scoutable(remaining_locations, mission_available, layout_locked, campaign_locked) else: scoutable = False for location_type, location_name, _ in remaining_locations: if location_type in (LocationType.VICTORY, LocationType.VICTORY_CACHE) and victory_printed: continue if location_type != last_location_type: tooltip += f"\n[color={COLOR_MISSION_IMPORTANT}]{self.get_location_type_title(location_type)}:[/color]" last_location_type = location_type if location_type == LocationType.VICTORY: victory_count = len([loc for loc in remaining_locations if loc[0] in (LocationType.VICTORY, LocationType.VICTORY_CACHE)]) victory_loc = location_name.replace(":", f":[color={COLOR_VICTORY_LOCATION}]") if victory_count > 1: victory_loc += f' ({victory_count})' tooltip += f"\n- {victory_loc}[/color]" victory_printed = True else: tooltip += f"\n- {location_name}" if scoutable: tooltip += self.handle_scout_display(location_name) if len(plando_locations) > 0: tooltip += "\n[b]Plando:[/b]\n- " tooltip += "\n- ".join(plando_locations) tooltip = f"[b]{text}[/b]\n" + tooltip #If the mission has any hints pointing to a check, add asterisks around the mission name if any(tuple(x in self.hints_to_highlight for x in self.ctx.locations_for_mission_id(mission_id))): text = "* " + text + " *" return text, tooltip def mission_callback(self, button: MissionButton) -> None: if button.last_touch.button == 'right': self.open_mission_menu(button) return if not self.launching: mission_id: int = button.mission_id if self.ctx.play_mission(mission_id): self.launching = mission_id Clock.schedule_once(self.finish_launching, 10) def open_mission_menu(self, button: MissionButton) -> None: # Will be assigned later, used to close menu in callbacks menu = None mission_id = button.mission_id def copy_mission_name(): Clipboard.copy(lookup_id_to_mission[mission_id].mission_name) menu.dismiss() menu_items = [ { "text": "Copy Mission Name", "on_release": copy_mission_name, } ] width_override = None hinted_item_ids = Counter() hints = self.ctx.stored_data.get(f"_read_hints_{self.ctx.team}_{self.ctx.slot}") if hints: for hint in hints: if hint['receiving_player'] == self.ctx.slot and not hint['found']: hinted_item_ids[hint['item']] += 1 if not self.ctx.is_mission_completed(mission_id) and not is_mission_available(self.ctx, mission_id): # Uncompleted and inaccessible missions can have items hinted if they're needed # The inaccessible restriction is to ensure users don't waste hints on missions that they can already access items_needed = self.resolve_items_needed(mission_id) received_items = compute_received_items(self.ctx) for item_id, amount in items_needed.items(): # If we have already received or hinted enough of this item, skip it if received_items[item_id] + hinted_item_ids[item_id] >= amount: continue if width_override is None: width_override = dp(500) item_name = self.ctx.item_names.lookup_in_game(item_id) label_text = f"Hint Required Item: {item_name}" def hint_and_close(): self.ctx.command_processor(self.ctx)(f"!hint {item_name}") menu.dismiss() menu_items.append({ "text": label_text, "on_release": hint_and_close, }) menu = MDDropdownMenu( caller=button, items=menu_items, **({"width": width_override} if width_override else {}), ) menu.open() def resolve_items_needed(self, mission_id: int) -> Counter[int]: def resolve_rule_to_items(rule: RuleData) -> Counter[int]: if isinstance(rule, SubRuleRuleData): all_items = Counter() for sub_rule in rule.sub_rules: # Take max of each item across all sub-rules all_items |= resolve_rule_to_items(sub_rule) return all_items elif isinstance(rule, ItemRuleData): return Counter(rule.item_ids) else: return Counter() rules = self.ctx.mission_id_to_entry_rules[mission_id] # Take max value of each item across all rules using '|' return (resolve_rule_to_items(rules.mission_rule) | resolve_rule_to_items(rules.layout_rule) | resolve_rule_to_items(rules.campaign_rule)) def finish_launching(self, dt): self.launching = False def sort_unfinished_locations(self, mission_id: int) -> Tuple[List[Tuple[LocationType, str, int]], List[str], int]: locations: List[Tuple[LocationType, str, int]] = [] location_name_to_index: Dict[str, int] = {} for loc in self.ctx.locations_for_mission_id(mission_id): if loc in self.ctx.missing_locations: location_name = self.ctx.location_names.lookup_in_game(loc) location_name_to_index[location_name] = len(locations) locations.append(( lookup_location_id_to_type[loc], location_name, loc, )) count = len(locations) plando_locations = [] elements_to_remove: Set[Tuple[LocationType, str, int]] = set() for plando_loc_name in self.ctx.plando_locations: if plando_loc_name in location_name_to_index: elements_to_remove.add(locations[location_name_to_index[plando_loc_name]]) plando_locations.append(plando_loc_name) for element in elements_to_remove: locations.remove(element) return sorted(locations), plando_locations, count def any_valuable_locations(self, locations: List[Tuple[LocationType, str, int]]) -> bool: for location_type, _, location_id in locations: if (self.ctx.location_inclusions[location_type] == LocationInclusion.option_enabled and all( self.ctx.location_inclusions_by_flag[flag] == LocationInclusion.option_enabled for flag in lookup_location_id_to_flags[location_id].values() ) ): return True return False def get_location_type_title(self, location_type: LocationType) -> str: title = location_type.name.title().replace("_", " ") if self.ctx.location_inclusions[location_type] == LocationInclusion.option_disabled: title += " (Nothing)" elif self.ctx.location_inclusions[location_type] == LocationInclusion.option_filler: title += " (Filler)" else: title += "" return title def is_scoutable(self, remaining_locations, mission_available: bool, layout_locked: bool, campaign_locked: bool) -> bool: if self.ctx.mission_order_scouting == MissionOrderScouting.option_all: return True elif self.ctx.mission_order_scouting == MissionOrderScouting.option_campaign and not campaign_locked: return True elif self.ctx.mission_order_scouting == MissionOrderScouting.option_layout and not layout_locked: return True elif self.ctx.mission_order_scouting == MissionOrderScouting.option_available and mission_available: return True elif self.ctx.mission_order_scouting == MissionOrderScouting.option_completed and len([loc for loc in remaining_locations if loc[0] in (LocationType.VICTORY, LocationType.VICTORY_CACHE)]) == 0: # Assuming that when a mission is completed, all victory location are removed return True else: return False def handle_scout_display(self, location_name: str) -> str: if self.ctx.mission_item_classification is None: return "" # Only one information is provided for the victory locations of a mission if " Cache (" in location_name: location_name = location_name.split(" Cache")[0] item_classification_key = self.ctx.mission_item_classification[location_name] if ((ItemClassification.progression & item_classification_key) and (ItemClassification.useful & item_classification_key) ): # Uncommon, but some games do this to show off that an item is super-important # This can also happen on a victory display if the cache holds both progression and useful return " [color=AF99EF](Useful+Progression)[/color]" if ItemClassification.progression & item_classification_key: return " [color=AF99EF](Progression)[/color]" if ItemClassification.useful & item_classification_key: return " [color=6D8BE8](Useful)[/color]" if SC2World.settings.show_traps and ItemClassification.trap & item_classification_key: return " [color=FA8072](Trap)[/color]" return " [color=00EEEE](Filler)[/color]" def start_gui(context: SC2Context): context.ui = SC2Manager(context) context.ui_task = asyncio.create_task(context.ui.async_run(), name="UI") import pkgutil data = pkgutil.get_data(SC2World.__module__, "starcraft2.kv").decode() Builder.load_string(data)