diff --git a/Starcraft2Client.py b/Starcraft2Client.py index e7e66cbc..f8994d26 100644 --- a/Starcraft2Client.py +++ b/Starcraft2Client.py @@ -36,7 +36,7 @@ nest_asyncio.apply() class StarcraftClientProcessor(ClientCommandProcessor): - ctx: Context + ctx: SC2Context def _cmd_disable_mission_check(self) -> bool: """Disables the check to see if a mission is available to play. Meant for co-op runs where one player can play @@ -74,7 +74,7 @@ class StarcraftClientProcessor(ClientCommandProcessor): return True -class Context(CommonContext): +class SC2Context(CommonContext): command_processor = StarcraftClientProcessor game = "Starcraft 2 Wings of Liberty" items_handling = 0b111 @@ -89,10 +89,12 @@ class Context(CommonContext): announcement_pos = 0 sc2_run_task: typing.Optional[asyncio.Task] = None missions_unlocked = False + current_tooltip = None + last_loc_list = None async def server_auth(self, password_requested: bool = False): if password_requested and not self.password: - await super(Context, self).server_auth(password_requested) + await super(SC2Context, self).server_auth(password_requested) if not self.auth: logger.info('Enter slot name:') self.auth = await self.console_input() @@ -105,6 +107,10 @@ class Context(CommonContext): self.all_in_choice = args["slot_data"]["all_in_map"] slot_req_table = args["slot_data"]["mission_req"] self.mission_req_table = {} + # Compatibility for 0.3.2 server data. + if "category" not in next(iter(slot_req_table)): + for i, mission_data in enumerate(slot_req_table.values()): + mission_data["category"] = wol_default_categories[i] for mission in slot_req_table: self.mission_req_table[mission] = MissionInfo(**slot_req_table[mission]) @@ -119,19 +125,53 @@ class Context(CommonContext): self.announcements.append(args["data"]) def run_gui(self): - from kvui import GameManager + from kvui import GameManager, HoverBehavior, ServerToolTip, fade_in_animation + 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.properties import StringProperty import Utils - class MissionButton(Button): + 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) + 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): + if self.ctx.current_tooltip: + App.get_running_app().root.remove_widget(self.ctx.current_tooltip) + + self.ctx.current_tooltip = None + + @property + def ctx(self) -> CommonContext: + return App.get_running_app().ctx + class MissionLayout(GridLayout): pass @@ -148,6 +188,9 @@ class Context(CommonContext): mission_panel = None last_checked_locations = {} mission_id_to_button = {} + launching = False + refresh_from_launching = True + first_check = True def __init__(self, ctx): super().__init__(ctx) @@ -165,49 +208,87 @@ class Context(CommonContext): return container def build_mission_table(self, dt): - self.mission_panel.clear_widgets() + if (not self.launching and (not self.last_checked_locations == self.ctx.checked_locations or + not self.refresh_from_launching)) or self.first_check: + self.refresh_from_launching = True - if self.ctx.mission_req_table: - self.mission_id_to_button = {} - categories = {} - available_missions = [] - unfinished_missions = calc_unfinished_missions(self.ctx.checked_locations, self.ctx.mission_req_table, - self.ctx, available_missions=available_missions) + self.mission_panel.clear_widgets() - self.last_checked_locations = self.ctx.checked_locations + if self.ctx.mission_req_table: + self.last_checked_locations = self.ctx.checked_locations.copy() + self.first_check = False - # separate missions into categories - for mission in self.ctx.mission_req_table: - if not self.ctx.mission_req_table[mission].category in categories: - categories[self.ctx.mission_req_table[mission].category] = [] + self.mission_id_to_button = {} + categories = {} + available_missions = [] + unfinished_locations = initialize_blank_mission_dict(self.ctx.mission_req_table) + unfinished_missions = calc_unfinished_missions(self.ctx.checked_locations, + self.ctx.mission_req_table, + self.ctx, available_missions=available_missions, + unfinished_locations=unfinished_locations) - categories[self.ctx.mission_req_table[mission].category].append(mission) + # separate missions into categories + for mission in self.ctx.mission_req_table: + if not self.ctx.mission_req_table[mission].category in categories: + categories[self.ctx.mission_req_table[mission].category] = [] - for category in categories: - category_panel = MissionCategory() - category_panel.add_widget(Label(text=category, size_hint_y=None, height=50, outline_width=1)) + categories[self.ctx.mission_req_table[mission].category].append(mission) - for mission in categories[category]: - text = mission + for category in categories: + category_panel = MissionCategory() + category_panel.add_widget(Label(text=category, size_hint_y=None, height=50, outline_width=1)) - if mission in unfinished_missions: - text = f"[color=6495ED]{text}[/color]" - elif mission in available_missions: - text = f"[color=FFFFFF]{text}[/color]" - else: - text = f"[color=a9a9a9]{text}[/color]" + # Map is completed + for mission in categories[category]: + text = mission + tooltip = "" - mission_button = MissionButton(text=text, size_hint_y=None, height=50) - mission_button.bind(on_press=self.mission_callback) - self.mission_id_to_button[self.ctx.mission_req_table[mission].id] = mission_button - category_panel.add_widget(mission_button) + # Map has uncollected locations + if mission in unfinished_missions: + text = f"[color=6495ED]{text}[/color]" - category_panel.add_widget(Label(text="")) - self.mission_panel.add_widget(category_panel) + tooltip = f"Uncollected locations:\n" + tooltip += "\n".join(location for location in unfinished_locations[mission]) + 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 len(self.ctx.mission_req_table[mission].required_world) > 0: + tooltip += ", ".join(list(self.ctx.mission_req_table)[req_mission-1] for + req_mission in + self.ctx.mission_req_table[mission].required_world) + + if self.ctx.mission_req_table[mission].number > 0: + tooltip += " and " + if self.ctx.mission_req_table[mission].number > 0: + tooltip += f"{self.ctx.mission_req_table[mission].number} missions completed" + + mission_button = MissionButton(text=text, size_hint_y=None, height=50) + mission_button.tooltip_text = tooltip + mission_button.bind(on_press=self.mission_callback) + self.mission_id_to_button[self.ctx.mission_req_table[mission].id] = mission_button + category_panel.add_widget(mission_button) + + category_panel.add_widget(Label(text="")) + self.mission_panel.add_widget(category_panel) + + elif self.launching: + self.refresh_from_launching = False + + self.mission_panel.clear_widgets() + self.mission_panel.add_widget(Label(text="Launching Mission")) def mission_callback(self, button): - self.ctx.play_mission(list(self.mission_id_to_button.keys()) - [list(self.mission_id_to_button.values()).index(button)]) + if not self.launching: + self.ctx.play_mission(list(self.mission_id_to_button.keys()) + [list(self.mission_id_to_button.values()).index(button)]) + self.launching = True + Clock.schedule_once(self.finish_launching, 10) + + def finish_launching(self, dt): + self.launching = False self.ui = SC2Manager(self) self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI") @@ -215,7 +296,7 @@ class Context(CommonContext): Builder.load_file(Utils.local_path(os.path.dirname(SC2WoLWorld.__file__), "Starcraft2.kv")) async def shutdown(self): - await super(Context, self).shutdown() + await super(SC2Context, self).shutdown() if self.sc2_run_task: self.sc2_run_task.cancel() @@ -243,7 +324,7 @@ async def main(): parser.add_argument('--name', default=None, help="Slot Name to connect as.") args = parser.parse_args() - ctx = Context(args.connect, args.password) + ctx = SC2Context(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") @@ -267,6 +348,13 @@ maps_table = [ "ap_tvalerian01", "ap_tvalerian02a", "ap_tvalerian02b", "ap_tvalerian03" ] +wol_default_categories = [ + "Mar Sara", "Mar Sara", "Mar Sara", "Colonist", "Colonist", "Colonist", "Colonist", + "Artifact", "Artifact", "Artifact", "Artifact", "Artifact", "Covert", "Covert", "Covert", "Covert", + "Rebellion", "Rebellion", "Rebellion", "Rebellion", "Rebellion", "Prophecy", "Prophecy", "Prophecy", "Prophecy", + "Char", "Char", "Char", "Char" +] + def calculate_items(items): unit_unlocks = 0 @@ -279,6 +367,7 @@ def calculate_items(items): protoss_unlock = 0 minerals = 0 vespene = 0 + supply = 0 for item in items: data = lookup_id_to_name[item.item] @@ -303,9 +392,11 @@ def calculate_items(items): minerals += item_table[data].number elif item_table[data].type == "Vespene": vespene += item_table[data].number + elif item_table[data].type == "Supply": + supply += item_table[data].number return [unit_unlocks, upgrade_unlocks, armory1_unlocks, armory2_unlocks, building_unlocks, merc_unlocks, - lab_unlocks, protoss_unlock, minerals, vespene] + lab_unlocks, protoss_unlock, minerals, vespene, supply] def calc_difficulty(difficulty): @@ -321,7 +412,7 @@ def calc_difficulty(difficulty): return 'X' -async def starcraft_launch(ctx: Context, mission_id): +async def starcraft_launch(ctx: SC2Context, mission_id): ctx.rec_announce_pos = len(ctx.items_rec_to_announce) ctx.sent_announce_pos = len(ctx.items_sent_to_announce) ctx.announcements_pos = len(ctx.announcements) @@ -343,14 +434,14 @@ class ArchipelagoBot(sc2.bot_ai.BotAI): sixth_bonus = False seventh_bonus = False eight_bonus = False - ctx: Context = None + ctx: SC2Context = None mission_id = 0 can_read_game = False last_received_update = 0 - def __init__(self, ctx: Context, mission_id): + def __init__(self, ctx: SC2Context, mission_id): self.ctx = ctx self.mission_id = mission_id @@ -361,11 +452,11 @@ class ArchipelagoBot(sc2.bot_ai.BotAI): if iteration == 0: start_items = calculate_items(self.ctx.items_received) difficulty = calc_difficulty(self.ctx.difficulty) - await self.chat_send("ArchipelagoLoad {} {} {} {} {} {} {} {} {} {} {} {}".format( + await self.chat_send("ArchipelagoLoad {} {} {} {} {} {} {} {} {} {} {} {} {}".format( difficulty, start_items[0], start_items[1], start_items[2], start_items[3], start_items[4], start_items[5], start_items[6], start_items[7], start_items[8], start_items[9], - self.ctx.all_in_choice)) + self.ctx.all_in_choice, start_items[10])) self.last_received_update = len(self.ctx.items_received) else: diff --git a/worlds/sc2wol/Items.py b/worlds/sc2wol/Items.py index daa3d98c..e618e747 100644 --- a/worlds/sc2wol/Items.py +++ b/worlds/sc2wol/Items.py @@ -141,8 +141,9 @@ item_table = { "Void Ray": ItemData(707 + SC2WOL_ITEM_ID_OFFSET, "Protoss", 7, progression=True), "Carrier": ItemData(708 + SC2WOL_ITEM_ID_OFFSET, "Protoss", 8, progression=True), - "+5 Starting Minerals": ItemData(800+SC2WOL_ITEM_ID_OFFSET, "Minerals", 5, quantity=0, never_exclude=False), - "+5 Starting Vespene": ItemData(801+SC2WOL_ITEM_ID_OFFSET, "Vespene", 5, quantity=0, never_exclude=False) + "+15 Starting Minerals": ItemData(800+SC2WOL_ITEM_ID_OFFSET, "Minerals", 15, quantity=0, never_exclude=False), + "+15 Starting Vespene": ItemData(801+SC2WOL_ITEM_ID_OFFSET, "Vespene", 15, quantity=0, never_exclude=False), + "+2 Starting Supply": ItemData(802+SC2WOL_ITEM_ID_OFFSET, "Supply", 2, quantity=0, never_exclude=False), } basic_unit: typing.Tuple[str, ...] = ( @@ -165,8 +166,8 @@ item_name_groups["Missions"] = ["Beat Liberation Day", "Beat The Outlaws", "Beat "Beat Media Blitz", "Beat Piercing the Shroud"] filler_items: typing.Tuple[str, ...] = ( - '+5 Starting Minerals', - '+5 Starting Vespene' + '+15 Starting Minerals', + '+15 Starting Vespene' ) lookup_id_to_name: typing.Dict[int, str] = {data.code: item_name for item_name, data in get_full_item_list().items() if data.code} \ No newline at end of file diff --git a/worlds/sc2wol/MissionTables.py b/worlds/sc2wol/MissionTables.py index 92a7189d..ecd1da49 100644 --- a/worlds/sc2wol/MissionTables.py +++ b/worlds/sc2wol/MissionTables.py @@ -24,10 +24,10 @@ class FillMission(NamedTuple): type: str connect_to: List[int] # -1 connects to Menu category: str - number: int = 0 # number of worlds need beaten + number: int = 0 # number of worlds need beaten completion_critical: bool = False # missions needed to beat game or_requirements: bool = False # true if the requirements should be or-ed instead of and-ed - + relegate: bool = False # true if this is a slot no build missions should be relegated to. vanilla_shuffle_order = [ @@ -37,7 +37,7 @@ vanilla_shuffle_order = [ FillMission("easy", [2], "Colonist"), FillMission("medium", [3], "Colonist"), FillMission("hard", [4], "Colonist", number=7), - FillMission("hard", [4], "Colonist", number=7), + FillMission("hard", [4], "Colonist", number=7, relegate=True), FillMission("easy", [2], "Artifact", completion_critical=True), FillMission("medium", [7], "Artifact", number=8, completion_critical=True), FillMission("hard", [8], "Artifact", number=11, completion_critical=True), @@ -45,17 +45,17 @@ vanilla_shuffle_order = [ FillMission("hard", [10], "Artifact", completion_critical=True), FillMission("medium", [2], "Covert", number=4), FillMission("medium", [12], "Covert"), - FillMission("hard", [13], "Covert", number=8), - FillMission("hard", [13], "Covert", number=8), + FillMission("hard", [13], "Covert", number=8, relegate=True), + FillMission("hard", [13], "Covert", number=8, relegate=True), FillMission("medium", [2], "Rebellion", number=6), FillMission("hard", [16], "Rebellion"), FillMission("hard", [17], "Rebellion"), FillMission("hard", [18], "Rebellion"), - FillMission("hard", [19], "Rebellion"), + FillMission("hard", [19], "Rebellion", relegate=True), FillMission("medium", [8], "Prophecy"), FillMission("hard", [21], "Prophecy"), FillMission("hard", [22], "Prophecy"), - FillMission("hard", [23], "Prophecy"), + FillMission("hard", [23], "Prophecy", relegate=True), FillMission("hard", [11], "Char", completion_critical=True), FillMission("hard", [25], "Char", completion_critical=True), FillMission("hard", [25], "Char", completion_critical=True), diff --git a/worlds/sc2wol/Options.py b/worlds/sc2wol/Options.py index fe05af28..efd08725 100644 --- a/worlds/sc2wol/Options.py +++ b/worlds/sc2wol/Options.py @@ -38,17 +38,25 @@ class AllInMap(Choice): class MissionOrder(Choice): """Determines the order the missions are played in. Vanilla: Keeps the standard mission order and branching from the WoL Campaign. - Vanilla Shuffled: Keeps same branching paths from the WoL Campaign but randomizes the order of missions within""" + Vanilla Shuffled: Keeps same branching paths from the WoL Campaign but randomizes the order of missions within.""" display_name = "Mission Order" option_vanilla = 0 option_vanilla_shuffled = 1 + class ShuffleProtoss(DefaultOnToggle): """Determines if the 3 protoss missions are included in the shuffle if Vanilla Shuffled is enabled. If this is not the 3 protoss missions will stay in their vanilla order in the mission order making them optional to complete the game.""" display_name = "Shuffle Protoss Missions" + +class RelegateNoBuildMissions(DefaultOnToggle): + """If enabled, all no build missions besides the needed first one will be placed at the end of optional routes so + that none of them become required to complete the game. Only takes effect if mission order is not set to vanilla.""" + display_name = "Relegate No-Build Missions" + + # noinspection PyTypeChecker sc2wol_options: Dict[str, Option] = { "game_difficulty": GameDifficulty, @@ -56,7 +64,8 @@ sc2wol_options: Dict[str, Option] = { "bunker_upgrade": BunkerUpgrade, "all_in_map": AllInMap, "mission_order": MissionOrder, - "shuffle_protoss": ShuffleProtoss + "shuffle_protoss": ShuffleProtoss, + "relegate_no_build": RelegateNoBuildMissions } diff --git a/worlds/sc2wol/Regions.py b/worlds/sc2wol/Regions.py index 003037dc..3a00b604 100644 --- a/worlds/sc2wol/Regions.py +++ b/worlds/sc2wol/Regions.py @@ -132,6 +132,8 @@ def create_regions(world: MultiWorld, player: int, locations: Tuple[LocationData for mission in vanilla_shuffle_order: if mission.type == "all_in": missions.append("All-In") + elif get_option_value(world, player, "relegate_no_build") and mission.relegate: + missions.append("no_build") else: missions.append(mission.type) diff --git a/worlds/sc2wol/__init__.py b/worlds/sc2wol/__init__.py index 31b759d8..3b34fd06 100644 --- a/worlds/sc2wol/__init__.py +++ b/worlds/sc2wol/__init__.py @@ -33,6 +33,7 @@ class SC2WoLWorld(World): game = "Starcraft 2 Wings of Liberty" web = Starcraft2WoLWebWorld() + data_version = 2 item_name_to_id = {name: data.code for name, data in item_table.items()} location_name_to_id = {location.name: location.code for location in get_locations(None, None)}