# Released under the MIT License. See LICENSE for details.
#
"""Provides a window for browsing and launching game playlists."""
from __future__ import annotations
import copy
import math
import logging
from typing import override, TYPE_CHECKING
import bascenev1 as bs
import bauiv1 as bui
if TYPE_CHECKING:
from bauiv1lib.play import PlaylistSelectContext
[docs]
class PlaylistBrowserWindow(bui.MainWindow):
"""Window for starting teams games."""
def __init__(
self,
sessiontype: type[bs.Session],
transition: str | None = 'in_right',
origin_widget: bui.Widget | None = None,
playlist_select_context: PlaylistSelectContext | None = None,
):
# pylint: disable=cyclic-import
from bauiv1lib.playlist import PlaylistTypeVars
# Store state for when we exit the next game.
if issubclass(sessiontype, bs.DualTeamSession):
bui.set_analytics_screen('Teams Window')
elif issubclass(sessiontype, bs.FreeForAllSession):
bui.set_analytics_screen('FreeForAll Window')
else:
raise TypeError(f'Invalid sessiontype: {sessiontype}.')
self._pvars = PlaylistTypeVars(sessiontype)
self._sessiontype = sessiontype
self._customize_button: bui.Widget | None = None
self._sub_width: float | None = None
self._sub_height: float | None = None
self._playlist_select_context = playlist_select_context
self._ensure_standard_playlists_exist()
# Get the current selection (if any).
self._selected_playlist = bui.app.config.get(
self._pvars.config_name + ' Playlist Selection'
)
uiscale = bui.app.ui_v1.uiscale
self._width = 1100.0 if uiscale is bui.UIScale.SMALL else 800.0
x_inset = 150 if uiscale is bui.UIScale.SMALL else 0
self._height = (
440
if uiscale is bui.UIScale.SMALL
else 510 if uiscale is bui.UIScale.MEDIUM else 580
)
top_extra = 30 if uiscale is bui.UIScale.SMALL else 0
super().__init__(
root_widget=bui.containerwidget(
size=(self._width, self._height + top_extra),
toolbar_visibility=(
'menu_minimal'
if (
uiscale is bui.UIScale.SMALL
or playlist_select_context is not None
)
else 'menu_full'
),
scale=(
1.83
if uiscale is bui.UIScale.SMALL
else 1.05 if uiscale is bui.UIScale.MEDIUM else 0.9
),
stack_offset=(
(0, -46) if uiscale is bui.UIScale.SMALL else (0, 0)
),
),
transition=transition,
origin_widget=origin_widget,
)
self._back_button: bui.Widget | None = bui.buttonwidget(
parent=self._root_widget,
position=(59 + x_inset, self._height - 70),
size=(120, 60),
scale=1.0,
on_activate_call=self._on_back_press,
autoselect=True,
label=bui.Lstr(resource='backText'),
button_type='back',
)
bui.containerwidget(
edit=self._root_widget, cancel_button=self._back_button
)
self._title_text = bui.textwidget(
parent=self._root_widget,
position=(
self._width * 0.5,
self._height - (32 if uiscale is bui.UIScale.SMALL else 41),
),
size=(0, 0),
text=self._pvars.window_title_name,
scale=(0.8 if uiscale is bui.UIScale.SMALL else 1.3),
res_scale=1.5,
color=bui.app.ui_v1.heading_color,
h_align='center',
v_align='center',
)
bui.buttonwidget(
edit=self._back_button,
button_type='backSmall',
size=(60, 54),
position=(59 + x_inset, self._height - 67),
label=bui.charstr(bui.SpecialChar.BACK),
)
if uiscale is bui.UIScale.SMALL:
self._back_button.delete()
self._back_button = None
bui.containerwidget(
edit=self._root_widget, on_cancel_call=self._on_back_press
)
scroll_offs = 33
else:
scroll_offs = 0
self._scroll_width = self._width - (100 + 2 * x_inset)
self._scroll_height = self._height - (
146 if uiscale is bui.UIScale.SMALL else 136
)
self._scrollwidget = bui.scrollwidget(
parent=self._root_widget,
highlight=False,
size=(self._scroll_width, self._scroll_height),
position=(
(self._width - self._scroll_width) * 0.5,
65 + scroll_offs,
),
)
bui.containerwidget(edit=self._scrollwidget, claims_left_right=True)
self._subcontainer: bui.Widget | None = None
self._config_name_full = self._pvars.config_name + ' Playlists'
self._last_config = None
# Update now and once per second (this should do our initial
# refresh).
self._update()
self._update_timer = bui.AppTimer(
1.0, bui.WeakCall(self._update), repeat=True
)
[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; if we do it below in the lambda
# then we keep self alive.
sessiontype = self._sessiontype
# Pull anything out of self here; if we do it in the lambda
# we'll inadvertanly keep self alive.
playlist_select_context = self._playlist_select_context
return bui.BasicMainWindowState(
create_call=lambda transition, origin_widget: cls(
transition=transition,
origin_widget=origin_widget,
sessiontype=sessiontype,
playlist_select_context=playlist_select_context,
)
)
[docs]
@override
def on_main_window_close(self) -> None:
self._save_state()
def _ensure_standard_playlists_exist(self) -> None:
plus = bui.app.plus
assert plus is not None
# On new installations, go ahead and create a few playlists
# besides the hard-coded default one:
if not plus.get_v1_account_misc_val('madeStandardPlaylists', False):
plus.add_v1_account_transaction(
{
'type': 'ADD_PLAYLIST',
'playlistType': 'Free-for-All',
'playlistName': bui.Lstr(
resource='singleGamePlaylistNameText'
)
.evaluate()
.replace(
'${GAME}',
bui.Lstr(
translate=('gameNames', 'Death Match')
).evaluate(),
),
'playlist': [
{
'type': 'bs_death_match.DeathMatchGame',
'settings': {
'Epic Mode': False,
'Kills to Win Per Player': 10,
'Respawn Times': 1.0,
'Time Limit': 300,
'map': 'Doom Shroom',
},
},
{
'type': 'bs_death_match.DeathMatchGame',
'settings': {
'Epic Mode': False,
'Kills to Win Per Player': 10,
'Respawn Times': 1.0,
'Time Limit': 300,
'map': 'Crag Castle',
},
},
],
}
)
plus.add_v1_account_transaction(
{
'type': 'ADD_PLAYLIST',
'playlistType': 'Team Tournament',
'playlistName': bui.Lstr(
resource='singleGamePlaylistNameText'
)
.evaluate()
.replace(
'${GAME}',
bui.Lstr(
translate=('gameNames', 'Capture the Flag')
).evaluate(),
),
'playlist': [
{
'type': 'bs_capture_the_flag.CTFGame',
'settings': {
'map': 'Bridgit',
'Score to Win': 3,
'Flag Idle Return Time': 30,
'Flag Touch Return Time': 0,
'Respawn Times': 1.0,
'Time Limit': 600,
'Epic Mode': False,
},
},
{
'type': 'bs_capture_the_flag.CTFGame',
'settings': {
'map': 'Roundabout',
'Score to Win': 2,
'Flag Idle Return Time': 30,
'Flag Touch Return Time': 0,
'Respawn Times': 1.0,
'Time Limit': 600,
'Epic Mode': False,
},
},
{
'type': 'bs_capture_the_flag.CTFGame',
'settings': {
'map': 'Tip Top',
'Score to Win': 2,
'Flag Idle Return Time': 30,
'Flag Touch Return Time': 3,
'Respawn Times': 1.0,
'Time Limit': 300,
'Epic Mode': False,
},
},
],
}
)
plus.add_v1_account_transaction(
{
'type': 'ADD_PLAYLIST',
'playlistType': 'Team Tournament',
'playlistName': bui.Lstr(
translate=('playlistNames', 'Just Sports')
).evaluate(),
'playlist': [
{
'type': 'bs_hockey.HockeyGame',
'settings': {
'Time Limit': 0,
'map': 'Hockey Stadium',
'Score to Win': 1,
'Respawn Times': 1.0,
},
},
{
'type': 'bs_football.FootballTeamGame',
'settings': {
'Time Limit': 0,
'map': 'Football Stadium',
'Score to Win': 21,
'Respawn Times': 1.0,
},
},
],
}
)
plus.add_v1_account_transaction(
{
'type': 'ADD_PLAYLIST',
'playlistType': 'Free-for-All',
'playlistName': bui.Lstr(
translate=('playlistNames', 'Just Epic')
).evaluate(),
'playlist': [
{
'type': 'bs_elimination.EliminationGame',
'settings': {
'Time Limit': 120,
'map': 'Tip Top',
'Respawn Times': 1.0,
'Lives Per Player': 1,
'Epic Mode': 1,
},
}
],
}
)
plus.add_v1_account_transaction(
{
'type': 'SET_MISC_VAL',
'name': 'madeStandardPlaylists',
'value': True,
}
)
plus.run_v1_account_transactions()
def _refresh(self) -> None:
# FIXME: Should tidy this up.
# pylint: disable=too-many-statements
# pylint: disable=too-many-branches
# pylint: disable=too-many-locals
# pylint: disable=too-many-nested-blocks
from efro.util import asserttype
from bascenev1 import get_map_class, filter_playlist
if not self._root_widget:
return
if self._subcontainer is not None:
self._save_state()
self._subcontainer.delete()
# Make sure config exists.
if self._config_name_full not in bui.app.config:
bui.app.config[self._config_name_full] = {}
items = list(bui.app.config[self._config_name_full].items())
# Make sure everything is unicode.
items = [
(i[0].decode(), i[1]) if not isinstance(i[0], str) else i
for i in items
]
items.sort(key=lambda x2: asserttype(x2[0], str).lower())
items = [['__default__', None]] + items # default is always first
count = len(items)
columns = 3
rows = int(math.ceil(float(count) / columns))
button_width = 230
button_height = 230
button_buffer_h = -3
button_buffer_v = 0
self._sub_width = self._scroll_width
self._sub_height = (
40.0 + rows * (button_height + 2 * button_buffer_v) + 90
)
assert self._sub_width is not None
assert self._sub_height is not None
self._subcontainer = bui.containerwidget(
parent=self._scrollwidget,
size=(self._sub_width, self._sub_height),
background=False,
)
children = self._subcontainer.get_children()
for child in children:
child.delete()
assert bui.app.classic is not None
bui.textwidget(
parent=self._subcontainer,
text=bui.Lstr(resource='playlistsText'),
position=(40, self._sub_height - 26),
size=(0, 0),
scale=1.0,
maxwidth=400,
color=bui.app.ui_v1.title_color,
h_align='left',
v_align='center',
)
index = 0
appconfig = bui.app.config
mesh_opaque = bui.getmesh('level_select_button_opaque')
mesh_transparent = bui.getmesh('level_select_button_transparent')
mask_tex = bui.gettexture('mapPreviewMask')
h_offs = 225 if count == 1 else 115 if count == 2 else 0
h_offs_bottom = 0
uiscale = bui.app.ui_v1.uiscale
for y in range(rows):
for x in range(columns):
name = items[index][0]
assert name is not None
pos = (
x * (button_width + 2 * button_buffer_h)
+ button_buffer_h
+ 8
+ h_offs,
self._sub_height
- 47
- (y + 1) * (button_height + 2 * button_buffer_v),
)
btn = bui.buttonwidget(
parent=self._subcontainer,
button_type='square',
size=(button_width, button_height),
autoselect=True,
label='',
position=pos,
)
if x == 0 and uiscale is bui.UIScale.SMALL:
bui.widget(
edit=btn,
left_widget=bui.get_special_widget('back_button'),
)
if x == columns - 1 and uiscale is bui.UIScale.SMALL:
bui.widget(
edit=btn,
right_widget=bui.get_special_widget('squad_button'),
)
bui.buttonwidget(
edit=btn,
on_activate_call=bui.Call(
self._on_playlist_press, btn, name
),
on_select_call=bui.Call(self._on_playlist_select, name),
)
bui.widget(edit=btn, show_buffer_top=50, show_buffer_bottom=50)
if self._selected_playlist == name:
bui.containerwidget(
edit=self._subcontainer,
selected_child=btn,
visible_child=btn,
)
if self._back_button is not None:
if y == 0:
bui.widget(edit=btn, up_widget=self._back_button)
if x == 0:
bui.widget(edit=btn, left_widget=self._back_button)
print_name: str | bui.Lstr | None
if name == '__default__':
print_name = self._pvars.default_list_name
else:
print_name = name
bui.textwidget(
parent=self._subcontainer,
text=print_name,
position=(
pos[0] + button_width * 0.5,
pos[1] + button_height * 0.79,
),
size=(0, 0),
scale=button_width * 0.003,
maxwidth=button_width * 0.7,
draw_controller=btn,
h_align='center',
v_align='center',
)
# Poke into this playlist and see if we can display some of
# its maps.
map_images = []
try:
map_textures = []
map_texture_entries = []
if name == '__default__':
playlist = self._pvars.get_default_list_call()
else:
if (
name
not in appconfig[
self._pvars.config_name + ' Playlists'
]
):
print(
'NOT FOUND ERR',
appconfig[
self._pvars.config_name + ' Playlists'
],
)
playlist = appconfig[
self._pvars.config_name + ' Playlists'
][name]
playlist = filter_playlist(
playlist,
self._sessiontype,
remove_unowned=False,
mark_unowned=True,
name=name,
)
for entry in playlist:
mapname = entry['settings']['map']
maptype: type[bs.Map] | None
try:
maptype = get_map_class(mapname)
except bui.NotFoundError:
maptype = None
if maptype is not None:
tex_name = maptype.get_preview_texture_name()
if tex_name is not None:
map_textures.append(tex_name)
map_texture_entries.append(entry)
if len(map_textures) >= 6:
break
if len(map_textures) > 4:
img_rows = 3
img_columns = 2
scl = 0.33
h_offs_img = 30
v_offs_img = 126
elif len(map_textures) > 2:
img_rows = 2
img_columns = 2
scl = 0.35
h_offs_img = 24
v_offs_img = 110
elif len(map_textures) > 1:
img_rows = 2
img_columns = 1
scl = 0.5
h_offs_img = 47
v_offs_img = 105
else:
img_rows = 1
img_columns = 1
scl = 0.75
h_offs_img = 20
v_offs_img = 65
v = None
for row in range(img_rows):
for col in range(img_columns):
tex_index = row * img_columns + col
if tex_index < len(map_textures):
entry = map_texture_entries[tex_index]
owned = not (
(
'is_unowned_map' in entry
and entry['is_unowned_map']
)
or (
'is_unowned_game' in entry
and entry['is_unowned_game']
)
)
tex_name = map_textures[tex_index]
h = pos[0] + h_offs_img + scl * 250 * col
v = pos[1] + v_offs_img - scl * 130 * row
map_images.append(
bui.imagewidget(
parent=self._subcontainer,
size=(scl * 250.0, scl * 125.0),
position=(h, v),
texture=bui.gettexture(tex_name),
opacity=1.0 if owned else 0.25,
draw_controller=btn,
mesh_opaque=mesh_opaque,
mesh_transparent=mesh_transparent,
mask_texture=mask_tex,
)
)
if not owned:
bui.imagewidget(
parent=self._subcontainer,
size=(scl * 100.0, scl * 100.0),
position=(h + scl * 75, v + scl * 10),
texture=bui.gettexture('lock'),
draw_controller=btn,
)
if v is not None:
v -= scl * 130.0
except Exception:
logging.exception('Error listing playlist maps.')
if not map_images:
bui.textwidget(
parent=self._subcontainer,
text='???',
scale=1.5,
size=(0, 0),
color=(1, 1, 1, 0.5),
h_align='center',
v_align='center',
draw_controller=btn,
position=(
pos[0] + button_width * 0.5,
pos[1] + button_height * 0.5,
),
)
index += 1
if index >= count:
break
if index >= count:
break
self._customize_button = btn = bui.buttonwidget(
parent=self._subcontainer,
size=(100, 30),
position=(34 + h_offs_bottom, 50),
text_scale=0.6,
label=bui.Lstr(resource='customizeText'),
on_activate_call=self._on_customize_press,
color=(0.54, 0.52, 0.67),
textcolor=(0.7, 0.65, 0.7),
autoselect=True,
)
bui.widget(edit=btn, show_buffer_top=22, show_buffer_bottom=28)
self._restore_state()
[docs]
def on_play_options_window_run_game(self) -> None:
"""(internal)"""
# No-op if we're not in control.
if not self.main_window_has_control():
# if not self._root_widget:
return
if self._playlist_select_context is not None:
# Done doing a playlist selection; now back all the way out
# of our selection windows to our stored starting point.
if self._playlist_select_context.back_state is None:
logging.error(
'No back state found'
' after playlist select context completion.'
)
else:
self.main_window_back_state = (
self._playlist_select_context.back_state
)
self.main_window_back()
else:
# Launching a regular game session; simply get our window
# transitioning out.
self.main_window_close(transition='out_left')
def _on_playlist_select(self, playlist_name: str) -> None:
self._selected_playlist = playlist_name
def _update(self) -> None:
# Make sure config exists.
if self._config_name_full not in bui.app.config:
bui.app.config[self._config_name_full] = {}
cfg = bui.app.config[self._config_name_full]
if cfg != self._last_config:
self._last_config = copy.deepcopy(cfg)
self._refresh()
def _on_playlist_press(
self, button: bui.Widget, playlist_name: str
) -> None:
# pylint: disable=cyclic-import
from bauiv1lib.playoptions import PlayOptionsWindow
# Make sure the target playlist still exists.
exists = (
playlist_name == '__default__'
or playlist_name in bui.app.config.get(self._config_name_full, {})
)
if not exists:
return
self._save_state()
PlayOptionsWindow(
sessiontype=self._sessiontype,
scale_origin=button.get_screen_space_center(),
playlist=playlist_name,
delegate=self,
playlist_select_context=self._playlist_select_context,
)
def _on_customize_press(self) -> None:
# pylint: disable=cyclic-import
from bauiv1lib.playlist.customizebrowser import (
PlaylistCustomizeBrowserWindow,
)
# no-op if we're not in control.
if not self.main_window_has_control():
return
self.main_window_replace(
PlaylistCustomizeBrowserWindow(
origin_widget=self._customize_button,
sessiontype=self._sessiontype,
)
)
def _on_back_press(self) -> None:
# pylint: disable=cyclic-import
# from bauiv1lib.play import PlayWindow
# no-op if we're not in control.
if not self.main_window_has_control():
return
# Store our selected playlist if that's changed.
if self._selected_playlist is not None:
prev_sel = bui.app.config.get(
self._pvars.config_name + ' Playlist Selection'
)
if self._selected_playlist != prev_sel:
cfg = bui.app.config
cfg[self._pvars.config_name + ' Playlist Selection'] = (
self._selected_playlist
)
cfg.commit()
self.main_window_back()
def _save_state(self) -> None:
try:
sel = self._root_widget.get_selected_child()
if sel == self._back_button:
sel_name = 'Back'
elif sel == self._scrollwidget:
assert self._subcontainer is not None
subsel = self._subcontainer.get_selected_child()
if subsel == self._customize_button:
sel_name = 'Customize'
else:
sel_name = 'Scroll'
else:
raise RuntimeError('Unrecognized selected widget.')
assert bui.app.classic is not None
bui.app.ui_v1.window_states[type(self)] = sel_name
except Exception:
logging.exception('Error saving state for %s.', self)
def _restore_state(self) -> None:
try:
assert bui.app.classic is not None
sel_name = bui.app.ui_v1.window_states.get(type(self))
if sel_name == 'Back':
sel = self._back_button
elif sel_name == 'Scroll':
sel = self._scrollwidget
elif sel_name == 'Customize':
sel = self._scrollwidget
bui.containerwidget(
edit=self._subcontainer,
selected_child=self._customize_button,
visible_child=self._customize_button,
)
else:
sel = self._scrollwidget
bui.containerwidget(edit=self._root_widget, selected_child=sel)
except Exception:
logging.exception('Error restoring state for %s.', self)