# Released under the MIT License. See LICENSE for details.
#
"""Provides UI for editing a game config."""
from __future__ import annotations
import copy
import random
import logging
from typing import TYPE_CHECKING, cast, override
import bascenev1 as bs
import bauiv1 as bui
if TYPE_CHECKING:
from typing import Any, Callable
[docs]
class PlaylistEditGameWindow(bui.MainWindow):
"""Window for editing a game config."""
def __init__(
self,
gametype: type[bs.GameActivity],
sessiontype: type[bs.Session],
config: dict[str, Any] | None,
completion_call: Callable[[dict[str, Any] | None, bui.MainWindow], Any],
default_selection: str | None = None,
transition: str | None = 'in_right',
origin_widget: bui.Widget | None = None,
edit_info: dict[str, Any] | None = None,
):
# pylint: disable=too-many-branches
# pylint: disable=too-many-positional-arguments
# pylint: disable=too-many-statements
# pylint: disable=too-many-locals
from bascenev1 import (
get_filtered_map_name,
get_map_class,
get_map_display_string,
)
assert bui.app.classic is not None
store = bui.app.classic.store
self._gametype = gametype
self._sessiontype = sessiontype
# If we're within an editing session we get passed edit_info
# (returning from map selection window, etc).
if edit_info is not None:
self._edit_info = edit_info
# ..otherwise determine whether we're adding or editing a game based
# on whether an existing config was passed to us.
else:
if config is None:
self._edit_info = {'editType': 'add'}
else:
self._edit_info = {'editType': 'edit'}
self._r = 'gameSettingsWindow'
valid_maps = gametype.get_supported_maps(sessiontype)
if not valid_maps:
bui.screenmessage(bui.Lstr(resource='noValidMapsErrorText'))
raise RuntimeError('No valid maps found.')
self._config = config
self._settings_defs = gametype.get_available_settings(sessiontype)
self._completion_call = completion_call
# To start with, pick a random map out of the ones we own.
unowned_maps = store.get_unowned_maps()
valid_maps_owned = [m for m in valid_maps if m not in unowned_maps]
if valid_maps_owned:
self._map = valid_maps[random.randrange(len(valid_maps_owned))]
# Hmmm.. we own none of these maps.. just pick a random un-owned one
# I guess.. should this ever happen?
else:
self._map = valid_maps[random.randrange(len(valid_maps))]
is_add = self._edit_info['editType'] == 'add'
# If there's a valid map name in the existing config, use that.
try:
if (
config is not None
and 'settings' in config
and 'map' in config['settings']
):
filtered_map_name = get_filtered_map_name(
config['settings']['map']
)
if filtered_map_name in valid_maps:
self._map = filtered_map_name
except Exception:
logging.exception('Error getting map for editor.')
if config is not None and 'settings' in config:
self._settings = config['settings']
else:
self._settings = {}
self._choice_selections: dict[str, int] = {}
uiscale = bui.app.ui_v1.uiscale
width = 820 if uiscale is bui.UIScale.SMALL else 620
x_inset = 100 if uiscale is bui.UIScale.SMALL else 0
height = (
400
if uiscale is bui.UIScale.SMALL
else 460 if uiscale is bui.UIScale.MEDIUM else 550
)
spacing = 52
y_extra = 15
y_extra2 = 21
yoffs = -30 if uiscale is bui.UIScale.SMALL else 0
map_tex_name = get_map_class(self._map).get_preview_texture_name()
if map_tex_name is None:
raise RuntimeError(f'No map preview tex found for {self._map}.')
map_tex = bui.gettexture(map_tex_name)
top_extra = 20 if uiscale is bui.UIScale.SMALL else 0
super().__init__(
root_widget=bui.containerwidget(
size=(width, height + top_extra),
scale=(
2.3
if uiscale is bui.UIScale.SMALL
else 1.35 if uiscale is bui.UIScale.MEDIUM else 1.0
),
stack_offset=(
(0, 0) if uiscale is bui.UIScale.SMALL else (0, 0)
),
),
transition=transition,
origin_widget=origin_widget,
)
btn = bui.buttonwidget(
parent=self._root_widget,
position=(45 + x_inset, height - 82 + y_extra2 + yoffs),
size=(60, 48) if is_add else (180, 65),
label=(
bui.charstr(bui.SpecialChar.BACK)
if is_add
else bui.Lstr(resource='cancelText')
),
button_type='backSmall' if is_add else None,
autoselect=True,
scale=1.0 if is_add else 0.75,
text_scale=1.3,
on_activate_call=bui.Call(self._cancel),
)
bui.containerwidget(edit=self._root_widget, cancel_button=btn)
add_button = bui.buttonwidget(
parent=self._root_widget,
position=(width - (193 + x_inset), height - 82 + y_extra2 + yoffs),
size=(200, 65),
scale=0.75,
text_scale=1.3,
label=(
bui.Lstr(resource=f'{self._r}.addGameText')
if is_add
else bui.Lstr(resource='applyText')
),
)
pbtn = bui.get_special_widget('squad_button')
bui.widget(edit=add_button, right_widget=pbtn, up_widget=pbtn)
bui.textwidget(
parent=self._root_widget,
position=(-8, height - 70 + y_extra2 + yoffs),
size=(width, 25),
text=gametype.get_display_string(),
color=bui.app.ui_v1.title_color,
maxwidth=235,
scale=1.1,
h_align='center',
v_align='center',
)
map_height = 100
scroll_height = map_height + 10 # map select and margin
# Calc our total height we'll need
scroll_height += spacing * len(self._settings_defs)
scroll_width = width - (86 + 2 * x_inset)
self._scrollwidget = bui.scrollwidget(
parent=self._root_widget,
position=(
44 + x_inset,
(80 if uiscale is bui.UIScale.SMALL else 35) + y_extra + yoffs,
),
size=(
scroll_width,
height - (166 if uiscale is bui.UIScale.SMALL else 116),
),
highlight=False,
claims_left_right=True,
selection_loops_to_parent=True,
border_opacity=0.4,
)
self._subcontainer = bui.containerwidget(
parent=self._scrollwidget,
size=(scroll_width, scroll_height),
background=False,
claims_left_right=True,
selection_loops_to_parent=True,
)
v = scroll_height - 5
h = -40
# Keep track of all the selectable widgets we make so we can wire
# them up conveniently.
widget_column: list[list[bui.Widget]] = []
# Map select button.
bui.textwidget(
parent=self._subcontainer,
position=(h + 49, v - 63),
size=(100, 30),
maxwidth=110,
text=bui.Lstr(resource='mapText'),
h_align='left',
color=(0.8, 0.8, 0.8, 1.0),
v_align='center',
)
bui.imagewidget(
parent=self._subcontainer,
size=(256 * 0.7, 125 * 0.7),
position=(h + 261 - 128 + 128.0 * 0.56, v - 90),
texture=map_tex,
mesh_opaque=bui.getmesh('level_select_button_opaque'),
mesh_transparent=bui.getmesh('level_select_button_transparent'),
mask_texture=bui.gettexture('mapPreviewMask'),
)
map_button = btn = bui.buttonwidget(
parent=self._subcontainer,
size=(140, 60),
position=(h + 448, v - 72),
on_activate_call=bui.Call(self._select_map),
scale=0.7,
label=bui.Lstr(resource='mapSelectText'),
)
widget_column.append([btn])
bui.textwidget(
parent=self._subcontainer,
position=(h + 363 - 123, v - 114),
size=(100, 30),
flatness=1.0,
shadow=1.0,
scale=0.55,
maxwidth=256 * 0.7 * 0.8,
text=get_map_display_string(self._map),
h_align='center',
color=(0.6, 1.0, 0.6, 1.0),
v_align='center',
)
v -= map_height
for setting in self._settings_defs:
value = setting.default
value_type = type(value)
# Now, if there's an existing value for it in the config,
# override with that.
try:
if (
config is not None
and 'settings' in config
and setting.name in config['settings']
):
value = value_type(config['settings'][setting.name])
except Exception:
logging.exception('Error getting game setting.')
# Shove the starting value in there to start.
self._settings[setting.name] = value
name_translated = self._get_localized_setting_name(setting.name)
mw1 = 280
mw2 = 70
# Handle types with choices specially:
if isinstance(setting, bs.ChoiceSetting):
for choice in setting.choices:
if len(choice) != 2:
raise ValueError(
"Expected 2-member tuples for 'choices'; got: "
+ repr(choice)
)
if not isinstance(choice[0], str):
raise TypeError(
'First value for choice tuple must be a str; got: '
+ repr(choice)
)
if not isinstance(choice[1], value_type):
raise TypeError(
'Choice type does not match default value; choice:'
+ repr(choice)
+ '; setting:'
+ repr(setting)
)
if value_type not in (int, float):
raise TypeError(
'Choice type setting must have int or float default; '
'got: ' + repr(setting)
)
# Start at the choice corresponding to the default if possible.
self._choice_selections[setting.name] = 0
for index, choice in enumerate(setting.choices):
if choice[1] == value:
self._choice_selections[setting.name] = index
break
v -= spacing
bui.textwidget(
parent=self._subcontainer,
position=(h + 50, v),
size=(100, 30),
maxwidth=mw1,
text=name_translated,
h_align='left',
color=(0.8, 0.8, 0.8, 1.0),
v_align='center',
)
txt = bui.textwidget(
parent=self._subcontainer,
position=(h + 509 - 95, v),
size=(0, 28),
text=self._get_localized_setting_name(
setting.choices[self._choice_selections[setting.name]][
0
]
),
editable=False,
color=(0.6, 1.0, 0.6, 1.0),
maxwidth=mw2,
h_align='right',
v_align='center',
padding=2,
)
btn1 = bui.buttonwidget(
parent=self._subcontainer,
position=(h + 509 - 50 - 1, v),
size=(28, 28),
label='<',
autoselect=True,
on_activate_call=bui.Call(
self._choice_inc, setting.name, txt, setting, -1
),
repeat=True,
)
btn2 = bui.buttonwidget(
parent=self._subcontainer,
position=(h + 509 + 5, v),
size=(28, 28),
label='>',
autoselect=True,
on_activate_call=bui.Call(
self._choice_inc, setting.name, txt, setting, 1
),
repeat=True,
)
widget_column.append([btn1, btn2])
elif isinstance(setting, (bs.IntSetting, bs.FloatSetting)):
v -= spacing
min_value = setting.min_value
max_value = setting.max_value
increment = setting.increment
bui.textwidget(
parent=self._subcontainer,
position=(h + 50, v),
size=(100, 30),
text=name_translated,
h_align='left',
color=(0.8, 0.8, 0.8, 1.0),
v_align='center',
maxwidth=mw1,
)
txt = bui.textwidget(
parent=self._subcontainer,
position=(h + 509 - 95, v),
size=(0, 28),
text=str(value),
editable=False,
color=(0.6, 1.0, 0.6, 1.0),
maxwidth=mw2,
h_align='right',
v_align='center',
padding=2,
)
btn1 = bui.buttonwidget(
parent=self._subcontainer,
position=(h + 509 - 50 - 1, v),
size=(28, 28),
label='-',
autoselect=True,
on_activate_call=bui.Call(
self._inc,
txt,
min_value,
max_value,
-increment,
value_type,
setting.name,
),
repeat=True,
)
btn2 = bui.buttonwidget(
parent=self._subcontainer,
position=(h + 509 + 5, v),
size=(28, 28),
label='+',
autoselect=True,
on_activate_call=bui.Call(
self._inc,
txt,
min_value,
max_value,
increment,
value_type,
setting.name,
),
repeat=True,
)
widget_column.append([btn1, btn2])
elif value_type == bool:
v -= spacing
bui.textwidget(
parent=self._subcontainer,
position=(h + 50, v),
size=(100, 30),
text=name_translated,
h_align='left',
color=(0.8, 0.8, 0.8, 1.0),
v_align='center',
maxwidth=mw1,
)
txt = bui.textwidget(
parent=self._subcontainer,
position=(h + 509 - 95, v),
size=(0, 28),
text=(
bui.Lstr(resource='onText')
if value
else bui.Lstr(resource='offText')
),
editable=False,
color=(0.6, 1.0, 0.6, 1.0),
maxwidth=mw2,
h_align='right',
v_align='center',
padding=2,
)
cbw = bui.checkboxwidget(
parent=self._subcontainer,
text='',
position=(h + 505 - 50 - 5, v - 2),
size=(200, 30),
autoselect=True,
textcolor=(0.8, 0.8, 0.8),
value=value,
on_value_change_call=bui.Call(
self._check_value_change, setting.name, txt
),
)
widget_column.append([cbw])
else:
raise TypeError(f'Invalid value type: {value_type}.')
# Ok now wire up the column.
try:
prev_widgets: list[bui.Widget] | None = None
for cwdg in widget_column:
if prev_widgets is not None:
# Wire our rightmost to their rightmost.
bui.widget(edit=prev_widgets[-1], down_widget=cwdg[-1])
bui.widget(edit=cwdg[-1], up_widget=prev_widgets[-1])
# Wire our leftmost to their leftmost.
bui.widget(edit=prev_widgets[0], down_widget=cwdg[0])
bui.widget(edit=cwdg[0], up_widget=prev_widgets[0])
prev_widgets = cwdg
except Exception:
logging.exception(
'Error wiring up game-settings-select widget column.'
)
bui.buttonwidget(edit=add_button, on_activate_call=bui.Call(self._add))
bui.containerwidget(
edit=self._root_widget,
selected_child=add_button,
start_button=add_button,
)
if default_selection == 'map':
bui.containerwidget(
edit=self._root_widget, selected_child=self._scrollwidget
)
bui.containerwidget(
edit=self._subcontainer, selected_child=map_button
)
[docs]
@override
def get_main_window_state(self) -> bui.MainWindowState:
# Support recreating our window for back/refresh purposes.
cls = type(self)
# Pull things out of self here so we don't refer to self in the
# lambda below which would keep us alive.
gametype = self._gametype
sessiontype = self._sessiontype
config = self._config
completion_call = self._completion_call
return bui.BasicMainWindowState(
create_call=lambda transition, origin_widget: cls(
transition=transition,
origin_widget=origin_widget,
gametype=gametype,
sessiontype=sessiontype,
config=config,
completion_call=completion_call,
)
)
def _get_localized_setting_name(self, name: str) -> bui.Lstr:
return bui.Lstr(translate=('settingNames', name))
def _select_map(self) -> None:
# pylint: disable=cyclic-import
from bauiv1lib.playlist.mapselect import PlaylistMapSelectWindow
# No-op if we're not in control.
if not self.main_window_has_control():
return
self._config = self._getconfig()
# Replace ourself with the map-select UI.
self.main_window_replace(
PlaylistMapSelectWindow(
self._gametype,
self._sessiontype,
self._config,
self._edit_info,
self._completion_call,
)
)
def _choice_inc(
self,
setting_name: str,
widget: bui.Widget,
setting: bs.ChoiceSetting,
increment: int,
) -> None:
choices = setting.choices
if increment > 0:
self._choice_selections[setting_name] = min(
len(choices) - 1, self._choice_selections[setting_name] + 1
)
else:
self._choice_selections[setting_name] = max(
0, self._choice_selections[setting_name] - 1
)
bui.textwidget(
edit=widget,
text=self._get_localized_setting_name(
choices[self._choice_selections[setting_name]][0]
),
)
self._settings[setting_name] = choices[
self._choice_selections[setting_name]
][1]
def _cancel(self) -> None:
self._completion_call(None, self)
def _check_value_change(
self, setting_name: str, widget: bui.Widget, value: int
) -> None:
bui.textwidget(
edit=widget,
text=(
bui.Lstr(resource='onText')
if value
else bui.Lstr(resource='offText')
),
)
self._settings[setting_name] = value
def _getconfig(self) -> dict[str, Any]:
settings = copy.deepcopy(self._settings)
settings['map'] = self._map
return {'settings': settings}
def _add(self) -> None:
self._completion_call(self._getconfig(), self)
def _inc(
self,
ctrl: bui.Widget,
min_val: int | float,
max_val: int | float,
increment: int | float,
setting_type: type,
setting_name: str,
) -> None:
# pylint: disable=too-many-positional-arguments
if setting_type == float:
val = float(cast(str, bui.textwidget(query=ctrl)))
else:
val = int(cast(str, bui.textwidget(query=ctrl)))
val += increment
val = max(min_val, min(val, max_val))
if setting_type == float:
bui.textwidget(edit=ctrl, text=str(round(val, 2)))
elif setting_type == int:
bui.textwidget(edit=ctrl, text=str(int(val)))
else:
raise TypeError('invalid vartype: ' + str(setting_type))
self._settings[setting_name] = val
# Docs-generation hack; import some stuff that we likely only forward-declared
# in our actual source code so that docs tools can find it.
from typing import (Coroutine, Any, Literal, Callable,
Generator, Awaitable, Sequence, Self)
import asyncio
from concurrent.futures import Future
from pathlib import Path
from enum import Enum