Archipelago/worlds/sc2/ClientGui.py

316 lines
14 KiB
Python

from typing import *
import asyncio
from NetUtils import JSONMessagePart
from kvui import GameManager, HoverBehavior, ServerToolTip, KivyJSONtoTextParser
from kivy.app import App
from kivy.clock import Clock
from kivy.uix.tabbedpanel import TabbedPanelItem
from kivy.uix.gridlayout import GridLayout
from kivy.lang import Builder
from kivy.uix.label import Label
from kivy.uix.button import Button
from kivy.uix.floatlayout import FloatLayout
from kivy.uix.scrollview import ScrollView
from kivy.properties import StringProperty
from .Client import SC2Context, calc_unfinished_missions, parse_unlock
from .MissionTables import (lookup_id_to_mission, lookup_name_to_mission, campaign_race_exceptions, SC2Mission, SC2Race,
SC2Campaign)
from .Locations import LocationType, lookup_location_id_to_type
from .Options import LocationInclusion
from . import SC2World, get_first_mission
class HoverableButton(HoverBehavior, Button):
pass
class MissionButton(HoverableButton):
tooltip_text = StringProperty("Test")
def __init__(self, *args, **kwargs):
super(HoverableButton, self).__init__(*args, **kwargs)
self.layout = FloatLayout()
self.popuplabel = ServerToolTip(text=self.text, markup=True)
self.popuplabel.padding = [5, 2, 5, 2]
self.layout.add_widget(self.popuplabel)
def on_enter(self):
self.popuplabel.text = self.tooltip_text
if self.ctx.current_tooltip:
App.get_running_app().root.remove_widget(self.ctx.current_tooltip)
if self.tooltip_text == "":
self.ctx.current_tooltip = None
else:
App.get_running_app().root.add_widget(self.layout)
self.ctx.current_tooltip = self.layout
def on_leave(self):
self.ctx.ui.clear_tooltip()
@property
def ctx(self) -> SC2Context:
return App.get_running_app().ctx
class CampaignScroll(ScrollView):
pass
class MultiCampaignLayout(GridLayout):
pass
class CampaignLayout(GridLayout):
pass
class MissionLayout(GridLayout):
pass
class MissionCategory(GridLayout):
pass
class SC2JSONtoKivyParser(KivyJSONtoTextParser):
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):
logging_pairs = [
("Client", "Archipelago"),
("Starcraft2", "Starcraft2"),
]
base_title = "Archipelago Starcraft 2 Client"
campaign_panel: Optional[CampaignLayout] = None
last_checked_locations: Set[int] = set()
mission_id_to_button: Dict[int, MissionButton] = {}
launching: Union[bool, int] = False # if int -> mission ID
refresh_from_launching = True
first_check = True
first_mission = ""
ctx: SC2Context
def __init__(self, ctx) -> None:
super().__init__(ctx)
self.json_to_kivy_parser = SC2JSONtoKivyParser(ctx)
def clear_tooltip(self) -> None:
if self.ctx.current_tooltip:
App.get_running_app().root.remove_widget(self.ctx.current_tooltip)
self.ctx.current_tooltip = None
def build(self):
container = super().build()
panel = self.add_client_tab("Starcraft 2 Launcher", CampaignScroll())
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 (not self.launching and (not self.last_checked_locations == self.ctx.checked_locations or
not self.refresh_from_launching)) or self.first_check:
assert self.campaign_panel is not None
self.refresh_from_launching = True
self.campaign_panel.clear_widgets()
if self.ctx.mission_req_table:
self.last_checked_locations = self.ctx.checked_locations.copy()
self.first_check = False
self.first_mission = get_first_mission(self.ctx.mission_req_table)
self.mission_id_to_button = {}
available_missions, unfinished_missions = calc_unfinished_missions(self.ctx)
multi_campaign_layout_height = 0
for campaign, missions in sorted(self.ctx.mission_req_table.items(), key=lambda item: item[0].id):
categories: Dict[str, List[str]] = {}
# separate missions into categories
for mission_index in missions:
mission_info = self.ctx.mission_req_table[campaign][mission_index]
if mission_info.category not in categories:
categories[mission_info.category] = []
categories[mission_info.category].append(mission_index)
max_mission_count = max(len(categories[category]) for category in categories)
if max_mission_count == 1:
campaign_layout_height = 115
else:
campaign_layout_height = (max_mission_count + 2) * 50
multi_campaign_layout_height += campaign_layout_height
campaign_layout = CampaignLayout(size_hint_y=None, height=campaign_layout_height)
if campaign != SC2Campaign.GLOBAL:
campaign_layout.add_widget(
Label(text=campaign.campaign_name, size_hint_y=None, height=25, outline_width=1)
)
mission_layout = MissionLayout()
for category in categories:
category_name_height = 0
category_spacing = 3
if category.startswith('_'):
category_display_name = ''
else:
category_display_name = category
category_name_height += 25
category_spacing = 10
category_panel = MissionCategory(padding=[category_spacing,6,category_spacing,6])
category_panel.add_widget(
Label(text=category_display_name, size_hint_y=None, height=category_name_height, outline_width=1))
for mission in categories[category]:
text: str = mission
tooltip: str = ""
mission_obj: SC2Mission = lookup_name_to_mission[mission]
mission_id: int = mission_obj.id
mission_data = self.ctx.mission_req_table[campaign][mission]
remaining_locations, plando_locations, remaining_count = self.sort_unfinished_locations(mission)
# Map has uncollected locations
if mission in unfinished_missions:
if self.any_valuable_locations(remaining_locations):
text = f"[color=6495ED]{text}[/color]"
else:
text = f"[color=A0BEF4]{text}[/color]"
elif mission in available_missions:
text = f"[color=FFFFFF]{text}[/color]"
# Map requirements not met
else:
text = f"[color=a9a9a9]{text}[/color]"
tooltip = f"Requires: "
if mission_data.required_world:
tooltip += ", ".join(list(self.ctx.mission_req_table[parse_unlock(req_mission).campaign])[parse_unlock(req_mission).connect_to - 1] for
req_mission in
mission_data.required_world)
if mission_data.number:
tooltip += " and "
if mission_data.number:
tooltip += f"{self.ctx.mission_req_table[campaign][mission].number} missions completed"
if mission_id == self.ctx.final_mission:
if mission in available_missions:
text = f"[color=FFBC95]{mission}[/color]"
else:
text = f"[color=D0C0BE]{mission}[/color]"
if tooltip:
tooltip += "\n"
tooltip += "Final Mission"
if remaining_count > 0:
if tooltip:
tooltip += "\n\n"
tooltip += f"-- Uncollected locations --"
for loctype in LocationType:
if len(remaining_locations[loctype]) > 0:
if loctype == LocationType.VICTORY:
tooltip += f"\n- {remaining_locations[loctype][0]}"
else:
tooltip += f"\n{self.get_location_type_title(loctype)}:\n- "
tooltip += "\n- ".join(remaining_locations[loctype])
if len(plando_locations) > 0:
tooltip += f"\nPlando:\n- "
tooltip += "\n- ".join(plando_locations)
MISSION_BUTTON_HEIGHT = 50
for pad in range(mission_data.ui_vertical_padding):
column_spacer = Label(text='', size_hint_y=None, height=MISSION_BUTTON_HEIGHT)
category_panel.add_widget(column_spacer)
mission_button = MissionButton(text=text, size_hint_y=None, height=MISSION_BUTTON_HEIGHT)
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)
racial_colors = {
SC2Race.TERRAN: (0.24, 0.84, 0.68),
SC2Race.ZERG: (1, 0.65, 0.37),
SC2Race.PROTOSS: (0.55, 0.7, 1)
}
if race in racial_colors:
mission_button.background_color = racial_colors[race]
mission_button.tooltip_text = tooltip
mission_button.bind(on_press=self.mission_callback)
self.mission_id_to_button[mission_id] = mission_button
category_panel.add_widget(mission_button)
category_panel.add_widget(Label(text=""))
mission_layout.add_widget(category_panel)
campaign_layout.add_widget(mission_layout)
self.campaign_panel.add_widget(campaign_layout)
self.campaign_panel.height = multi_campaign_layout_height
elif 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()
def mission_callback(self, button: MissionButton) -> None:
if not self.launching:
mission_id: int = next(k for k, v in self.mission_id_to_button.items() if v == button)
if self.ctx.play_mission(mission_id):
self.launching = mission_id
Clock.schedule_once(self.finish_launching, 10)
def finish_launching(self, dt):
self.launching = False
def sort_unfinished_locations(self, mission_name: str) -> Tuple[Dict[LocationType, List[str]], List[str], int]:
locations: Dict[LocationType, List[str]] = {loctype: [] for loctype in LocationType}
count = 0
for loc in self.ctx.locations_for_mission(mission_name):
if loc in self.ctx.missing_locations:
count += 1
locations[lookup_location_id_to_type[loc]].append(self.ctx.location_names.lookup_in_game(loc))
plando_locations = []
for plando_loc in self.ctx.plando_locations:
for loctype in LocationType:
if plando_loc in locations[loctype]:
locations[loctype].remove(plando_loc)
plando_locations.append(plando_loc)
return locations, plando_locations, count
def any_valuable_locations(self, locations: Dict[LocationType, List[str]]) -> bool:
for loctype in LocationType:
if len(locations[loctype]) > 0 and self.ctx.location_inclusions[loctype] == LocationInclusion.option_enabled:
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_resources:
title += " (Resources)"
else:
title += ""
return title
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)