SC2: GUI Mission Launcher (#586)

* SC2: Functioning Starcraft 2 Mission Launcher UI

* AutoWorld: add .__file__ attribute to AutoWorlds
This tries to help with a recurring easy to make mistake, where ./worlds/myworld does not exist in frozen form and is instead ./lib/worlds/myworld

* SC2: get .kv file path correctly when frozen too

Co-authored-by: TheCondor07 <TheCondorian07@gmail.com>
Co-authored-by: Fabian Dill <fabian.dill@web.de>
This commit is contained in:
TheCondor07 2022-05-30 01:11:01 -04:00 committed by GitHub
parent 3dd3f045e6
commit 20be691f36
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 206 additions and 114 deletions

View File

@ -3,18 +3,21 @@ from __future__ import annotations
import multiprocessing
import logging
import asyncio
import nest_asyncio
import os.path
import nest_asyncio
import sc2
from sc2.main import run_game
from sc2.data import Race
from sc2.bot_ai import BotAI
from sc2.player import Bot
from worlds.sc2wol.Regions import MissionInfo
from worlds.sc2wol.MissionTables import lookup_id_to_mission
from worlds.sc2wol.Items import lookup_id_to_name, item_table
from worlds.sc2wol.Locations import SC2WOL_LOC_ID_OFFSET
from worlds.sc2wol import SC2WoLWorld
from Utils import init_logging
@ -34,12 +37,11 @@ nest_asyncio.apply()
class StarcraftClientProcessor(ClientCommandProcessor):
ctx: Context
missions_unlocked = False
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
the next mission in a chain the other player is doing."""
self.missions_unlocked = True
self.ctx.missions_unlocked = True
sc2_logger.info("Mission check has been disabled")
def _cmd_play(self, mission_id: str = "") -> bool:
@ -51,20 +53,7 @@ class StarcraftClientProcessor(ClientCommandProcessor):
if num_options > 0:
mission_number = int(options[0])
if self.missions_unlocked or \
is_mission_available(mission_number, self.ctx.checked_locations, self.ctx.mission_req_table):
if self.ctx.sc2_run_task:
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:
sc2_logger.info(
"This mission is not currently unlocked. Use /unfinished or /available to see what is available.")
self.ctx.play_mission(mission_number)
else:
sc2_logger.info(
@ -99,6 +88,7 @@ class Context(CommonContext):
announcements = []
announcement_pos = 0
sc2_run_task: typing.Optional[asyncio.Task] = None
missions_unlocked = False
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
@ -130,6 +120,23 @@ class Context(CommonContext):
def run_gui(self):
from kvui import GameManager
from kivy.base 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
import Utils
class MissionButton(Button):
pass
class MissionLayout(GridLayout):
pass
class MissionCategory(GridLayout):
pass
class SC2Manager(GameManager):
logging_pairs = [
@ -138,14 +145,97 @@ class Context(CommonContext):
]
base_title = "Archipelago Starcraft 2 Client"
mission_panel = None
last_checked_locations = {}
mission_id_to_button = {}
def __init__(self, ctx):
super().__init__(ctx)
def build(self):
container = super().build()
panel = TabbedPanelItem(text="Starcraft 2 Launcher")
self.mission_panel = panel.content = MissionLayout()
self.tabs.add_widget(panel)
Clock.schedule_interval(self.build_mission_table, 0.5)
return container
def build_mission_table(self, dt):
self.mission_panel.clear_widgets()
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.last_checked_locations = self.ctx.checked_locations
# 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] = []
categories[self.ctx.mission_req_table[mission].category].append(mission)
for category in categories:
category_panel = MissionCategory()
category_panel.add_widget(Label(text=category, size_hint_y=None, height=50, outline_width=1))
for mission in categories[category]:
text = mission
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]"
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)
category_panel.add_widget(Label(text=""))
self.mission_panel.add_widget(category_panel)
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)])
self.ui = SC2Manager(self)
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
Builder.load_file(Utils.local_path(os.path.dirname(SC2WoLWorld.__file__), "Starcraft2.kv"))
async def shutdown(self):
await super(Context, self).shutdown()
if self.sc2_run_task:
self.sc2_run_task.cancel()
def play_mission(self, mission_id):
if self.missions_unlocked or \
is_mission_available(mission_id, self.checked_locations, self.mission_req_table):
if self.sc2_run_task:
if not self.sc2_run_task.done():
sc2_logger.warning("Starcraft 2 Client is still running!")
self.sc2_run_task.cancel() # doesn't actually close the game, just stops the python task
if self.slot is None:
sc2_logger.warning("Launching Mission without Archipelago authentication, "
"checks will not be registered to server.")
self.sc2_run_task = asyncio.create_task(starcraft_launch(self, mission_id),
name="Starcraft 2 Launch")
else:
sc2_logger.info(
f"{lookup_id_to_mission[mission_id]} is not currently unlocked. "
f"Use /unfinished or /available to see what is available.")
async def main():
multiprocessing.freeze_support()
@ -404,39 +494,6 @@ class ArchipelagoBot(sc2.bot_ai.BotAI):
await self.chat_send("LostConnection - Lost connection to game.")
mission_req_table = {
"Liberation Day": MissionInfo(1, 7, [], completion_critical=True),
"The Outlaws": MissionInfo(2, 2, [1], completion_critical=True),
"Zero Hour": MissionInfo(3, 4, [2], completion_critical=True),
"Evacuation": MissionInfo(4, 4, [3]),
"Outbreak": MissionInfo(5, 3, [4]),
"Safe Haven": MissionInfo(6, 1, [5], number=7),
"Haven's Fall": MissionInfo(7, 1, [5], number=7),
"Smash and Grab": MissionInfo(8, 5, [3], completion_critical=True),
"The Dig": MissionInfo(9, 4, [8], number=8, completion_critical=True),
"The Moebius Factor": MissionInfo(10, 9, [9], number=11, completion_critical=True),
"Supernova": MissionInfo(11, 5, [10], number=14, completion_critical=True),
"Maw of the Void": MissionInfo(12, 6, [11], completion_critical=True),
"Devil's Playground": MissionInfo(13, 3, [3], number=4),
"Welcome to the Jungle": MissionInfo(14, 4, [13]),
"Breakout": MissionInfo(15, 3, [14], number=8),
"Ghost of a Chance": MissionInfo(16, 6, [14], number=8),
"The Great Train Robbery": MissionInfo(17, 4, [3], number=6),
"Cutthroat": MissionInfo(18, 5, [17]),
"Engine of Destruction": MissionInfo(19, 6, [18]),
"Media Blitz": MissionInfo(20, 5, [19]),
"Piercing the Shroud": MissionInfo(21, 6, [20]),
"Whispers of Doom": MissionInfo(22, 4, [9]),
"A Sinister Turn": MissionInfo(23, 4, [22]),
"Echoes of the Future": MissionInfo(24, 3, [23]),
"In Utter Darkness": MissionInfo(25, 3, [24]),
"Gates of Hell": MissionInfo(26, 2, [12], completion_critical=True),
"Belly of the Beast": MissionInfo(27, 4, [26], completion_critical=True),
"Shatter the Sky": MissionInfo(28, 5, [26], completion_critical=True),
"All-In": MissionInfo(29, -1, [27, 28], completion_critical=True, or_requirements=True)
}
def calc_objectives_completed(mission, missions_info, locations_done, unfinished_locations, ctx):
objectives_complete = 0
@ -460,7 +517,8 @@ def request_unfinished_missions(locations_done, location_table, ui, ctx):
unlocks = initialize_blank_mission_dict(location_table)
unfinished_locations = initialize_blank_mission_dict(location_table)
unfinished_missions = calc_unfinished_missions(locations_done, location_table, unlocks, unfinished_locations, ctx)
unfinished_missions = calc_unfinished_missions(locations_done, location_table, ctx, unlocks=unlocks,
unfinished_locations=unfinished_locations)
message += ", ".join(f"{mark_up_mission_name(mission, location_table, ui,unlocks)}[{location_table[mission].id}] " +
mark_up_objectives(
@ -477,10 +535,21 @@ def request_unfinished_missions(locations_done, location_table, ui, ctx):
sc2_logger.warning("No mission table found, you are likely not connected to a server.")
def calc_unfinished_missions(locations_done, locations, unlocks, unfinished_locations, ctx):
def calc_unfinished_missions(locations_done, locations, ctx, unlocks=None, unfinished_locations=None,
available_missions=[]):
unfinished_missions = []
locations_completed = []
available_missions = calc_available_missions(locations_done, locations, unlocks)
if not unlocks:
unlocks = initialize_blank_mission_dict(locations)
if not unfinished_locations:
unfinished_locations = initialize_blank_mission_dict(locations)
if len(available_missions) > 0:
available_missions = []
available_missions.extend(calc_available_missions(locations_done, locations, unlocks))
for name in available_missions:
if not locations[name].extra_locations == -1:

View File

@ -363,7 +363,8 @@ class GameManager(App):
return self.container
def update_texts(self, dt):
self.tabs.content.children[0].fix_heights() # TODO: remove this when Kivy fixes this upstream
if hasattr(self.tabs.content.children[0], 'fix_heights'):
self.tabs.content.children[0].fix_heights() # TODO: remove this when Kivy fixes this upstream
if self.ctx.server:
self.title = self.base_title + " " + Utils.__version__ + \
f" | Connected to: {self.ctx.server_address} " \

View File

@ -1,6 +1,7 @@
from __future__ import annotations
import logging
import sys
from typing import Dict, FrozenSet, Set, Tuple, List, Optional, TextIO, Any, Callable, Union, NamedTuple
from BaseClasses import MultiWorld, Item, CollectionState, Location, Tutorial
@ -41,6 +42,7 @@ class AutoWorldRegister(type):
new_class = super().__new__(mcs, name, bases, dct)
if "game" in dct:
AutoWorldRegister.world_types[dct["game"]] = new_class
new_class.__file__ = sys.modules[new_class.__module__].__file__
return new_class

View File

@ -14,6 +14,7 @@ class MissionInfo(NamedTuple):
id: int
extra_locations: int
required_world: List[int]
category: str
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
@ -22,74 +23,76 @@ class MissionInfo(NamedTuple):
class FillMission(NamedTuple):
type: str
connect_to: List[int] # -1 connects to Menu
category: str
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
vanilla_shuffle_order = [
FillMission("no_build", [-1], completion_critical=True),
FillMission("easy", [0], completion_critical=True),
FillMission("easy", [1], completion_critical=True),
FillMission("easy", [2]),
FillMission("medium", [3]),
FillMission("hard", [4], number=7),
FillMission("hard", [4], number=7),
FillMission("easy", [2], completion_critical=True),
FillMission("medium", [7], number=8, completion_critical=True),
FillMission("hard", [8], number=11, completion_critical=True),
FillMission("hard", [9], number=14, completion_critical=True),
FillMission("hard", [10], completion_critical=True),
FillMission("medium", [2], number=4),
FillMission("medium", [12]),
FillMission("hard", [13], number=8),
FillMission("hard", [13], number=8),
FillMission("medium", [2], number=6),
FillMission("hard", [16]),
FillMission("hard", [17]),
FillMission("hard", [18]),
FillMission("hard", [19]),
FillMission("medium", [8]),
FillMission("hard", [21]),
FillMission("hard", [22]),
FillMission("hard", [23]),
FillMission("hard", [11], completion_critical=True),
FillMission("hard", [25], completion_critical=True),
FillMission("hard", [25], completion_critical=True),
FillMission("all_in", [26, 27], completion_critical=True, or_requirements=True)
FillMission("no_build", [-1], "Mar Sara", completion_critical=True),
FillMission("easy", [0], "Mar Sara", completion_critical=True),
FillMission("easy", [1], "Mar Sara", completion_critical=True),
FillMission("easy", [2], "Colonist"),
FillMission("medium", [3], "Colonist"),
FillMission("hard", [4], "Colonist", number=7),
FillMission("hard", [4], "Colonist", number=7),
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),
FillMission("hard", [9], "Artifact", number=14, completion_critical=True),
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("medium", [2], "Rebellion", number=6),
FillMission("hard", [16], "Rebellion"),
FillMission("hard", [17], "Rebellion"),
FillMission("hard", [18], "Rebellion"),
FillMission("hard", [19], "Rebellion"),
FillMission("medium", [8], "Prophecy"),
FillMission("hard", [21], "Prophecy"),
FillMission("hard", [22], "Prophecy"),
FillMission("hard", [23], "Prophecy"),
FillMission("hard", [11], "Char", completion_critical=True),
FillMission("hard", [25], "Char", completion_critical=True),
FillMission("hard", [25], "Char", completion_critical=True),
FillMission("all_in", [26, 27], "Char", completion_critical=True, or_requirements=True)
]
vanilla_mission_req_table = {
"Liberation Day": MissionInfo(1, 7, [], completion_critical=True),
"The Outlaws": MissionInfo(2, 2, [1], completion_critical=True),
"Zero Hour": MissionInfo(3, 4, [2], completion_critical=True),
"Evacuation": MissionInfo(4, 4, [3]),
"Outbreak": MissionInfo(5, 3, [4]),
"Safe Haven": MissionInfo(6, 1, [5], number=7),
"Haven's Fall": MissionInfo(7, 1, [5], number=7),
"Smash and Grab": MissionInfo(8, 5, [3], completion_critical=True),
"The Dig": MissionInfo(9, 4, [8], number=8, completion_critical=True),
"The Moebius Factor": MissionInfo(10, 9, [9], number=11, completion_critical=True),
"Supernova": MissionInfo(11, 5, [10], number=14, completion_critical=True),
"Maw of the Void": MissionInfo(12, 6, [11], completion_critical=True),
"Devil's Playground": MissionInfo(13, 3, [3], number=4),
"Welcome to the Jungle": MissionInfo(14, 4, [13]),
"Breakout": MissionInfo(15, 3, [14], number=8),
"Ghost of a Chance": MissionInfo(16, 6, [14], number=8),
"The Great Train Robbery": MissionInfo(17, 4, [3], number=6),
"Cutthroat": MissionInfo(18, 5, [17]),
"Engine of Destruction": MissionInfo(19, 6, [18]),
"Media Blitz": MissionInfo(20, 5, [19]),
"Piercing the Shroud": MissionInfo(21, 6, [20]),
"Whispers of Doom": MissionInfo(22, 4, [9]),
"A Sinister Turn": MissionInfo(23, 4, [22]),
"Echoes of the Future": MissionInfo(24, 3, [23]),
"In Utter Darkness": MissionInfo(25, 3, [24]),
"Gates of Hell": MissionInfo(26, 2, [12], completion_critical=True),
"Belly of the Beast": MissionInfo(27, 4, [26], completion_critical=True),
"Shatter the Sky": MissionInfo(28, 5, [26], completion_critical=True),
"All-In": MissionInfo(29, -1, [27, 28], completion_critical=True, or_requirements=True)
"Liberation Day": MissionInfo(1, 7, [], "Mar Sara", completion_critical=True),
"The Outlaws": MissionInfo(2, 2, [1], "Mar Sara", completion_critical=True),
"Zero Hour": MissionInfo(3, 4, [2], "Mar Sara", completion_critical=True),
"Evacuation": MissionInfo(4, 4, [3], "Colonist"),
"Outbreak": MissionInfo(5, 3, [4], "Colonist"),
"Safe Haven": MissionInfo(6, 1, [5], "Colonist", number=7),
"Haven's Fall": MissionInfo(7, 1, [5], "Colonist", number=7),
"Smash and Grab": MissionInfo(8, 5, [3], "Artifact", completion_critical=True),
"The Dig": MissionInfo(9, 4, [8], "Artifact", number=8, completion_critical=True),
"The Moebius Factor": MissionInfo(10, 9, [9], "Artifact", number=11, completion_critical=True),
"Supernova": MissionInfo(11, 5, [10], "Artifact", number=14, completion_critical=True),
"Maw of the Void": MissionInfo(12, 6, [11], "Artifact", completion_critical=True),
"Devil's Playground": MissionInfo(13, 3, [3], "Covert", number=4),
"Welcome to the Jungle": MissionInfo(14, 4, [13], "Covert"),
"Breakout": MissionInfo(15, 3, [14], "Covert", number=8),
"Ghost of a Chance": MissionInfo(16, 6, [14], "Covert", number=8),
"The Great Train Robbery": MissionInfo(17, 4, [3], "Rebellion", number=6),
"Cutthroat": MissionInfo(18, 5, [17], "Rebellion"),
"Engine of Destruction": MissionInfo(19, 6, [18], "Rebellion"),
"Media Blitz": MissionInfo(20, 5, [19], "Rebellion"),
"Piercing the Shroud": MissionInfo(21, 6, [20], "Rebellion"),
"Whispers of Doom": MissionInfo(22, 4, [9], "Prophecy"),
"A Sinister Turn": MissionInfo(23, 4, [22], "Prophecy"),
"Echoes of the Future": MissionInfo(24, 3, [23], "Prophecy"),
"In Utter Darkness": MissionInfo(25, 3, [24], "Prophecy"),
"Gates of Hell": MissionInfo(26, 2, [12], "Char", completion_critical=True),
"Belly of the Beast": MissionInfo(27, 4, [26], "Char", completion_critical=True),
"Shatter the Sky": MissionInfo(28, 5, [26], "Char", completion_critical=True),
"All-In": MissionInfo(29, -1, [27, 28], "Char", completion_critical=True, or_requirements=True)
}
lookup_id_to_mission: Dict[int, str] = {

View File

@ -203,8 +203,9 @@ def create_regions(world: MultiWorld, player: int, locations: Tuple[LocationData
mission_req_table.update({missions[i]: MissionInfo(
vanilla_mission_req_table[missions[i]].id, vanilla_mission_req_table[missions[i]].extra_locations,
connections, completion_critical=vanilla_shuffle_order[i].completion_critical,
number=vanilla_shuffle_order[i].number, or_requirements=vanilla_shuffle_order[i].or_requirements)})
connections, vanilla_shuffle_order[i].category, number=vanilla_shuffle_order[i].number,
completion_critical=vanilla_shuffle_order[i].completion_critical,
or_requirements=vanilla_shuffle_order[i].or_requirements)})
return mission_req_table

View File

@ -0,0 +1,16 @@
<MissionLayout>:
rows: 1
<MissionCategory>:
cols: 1
padding: [10,5,10,5]
spacing: [0,5]
<MissionButton>:
text_size: self.size
markup: True
halign: 'center'
valign: 'middle'
padding_x: 5
markup: True
outline_width: 1