# Released under the MIT License. See LICENSE for details.
#
"""The top level play window, as a doc-ui page.
A client-local doc-ui domain: one centered row of big buttons leading
to co-op (a legacy window) and the teams/free-for-all playlist browsers
(``bauiv1lib.playlist.browserdocui``).
Also the first step of gather's private-party playlist selection, in
select mode: just the teams and free-for-all buttons, leading to the
playlist browsers' own select mode.
"""
from __future__ import annotations # Docs-generation hack.
from enum import Enum
from dataclasses import dataclass, replace
from typing import TYPE_CHECKING, Annotated, override, assert_never
from efro.dataclassio import ioprepped, IOAttrs
import bacommon.docui.v2 as dui2
from bacommon.docui.routes import (
DocUIRoute,
DocUILocalActionBase,
PressSound,
family_members,
)
import bauiv1 as bui
from bauiv1 import _classicassets
from bauiv1lib.docui import TypedDocUIController, WindowOptions
if TYPE_CHECKING:
from typing import Literal
from bacommon.docui import DocUIResponse
from bacommon.langstr import LangStrSpec
from bauiv1lib.docui import DocUILocalAction
#: The buttons' unscaled size.
_BW = 400.0
_BH = 400.0
#: Decoration coords below are the legacy window's, which placed
#: everything from its (400x360) buttons' bottom-left corner; these
#: re-center them on our buttons (hence the ``- _BW/2`` /
#: ``- _LEGACY_BH/2`` offsets), then spread art and labels apart a bit
#: into our taller buttons.
_LEGACY_BH = 360.0
_ART_RAISE = 15.0
_LABEL_DROP = 24.0
#: How far each button's art (characters, computer) is moved down as a
#: whole, after its layout above: the art was placed for 432-tall
#: buttons, and this keeps its margin from the top edge in our shorter
#: ones. (Labels are placed via _LABEL_DROP to sit centered in the
#: space between the art and the button's bottom edge.)
_ART_TUCK = 16.0
#: The legacy labels sat 10 units left of center (calibrated for the
#: old, unevenly-fitting backing graphic); we center them exactly and
#: shift the art right by the same amount so it keeps its place
#: relative to them.
_ART_SHIFT = 10.0
#: Width of the label text boxes, as a fraction of the button's.
_LABEL_WIDTH = 0.85
#: Overall button scale and spacing, sized to just fill the wide
#: layout's page (width and height) without scrolling.
#: (The legacy window's were 0.75/3, but its buttons drew with more
#: transparent margin, so its visible gaps were wider than that spacing
#: suggests.)
_BUTTON_SCALE = 0.655
_BUTTON_SPACING = 25.0
#: How far below center the button row sits (see its spacing).
_ROW_DROP = 20.0
_TITLE_COLOR = (0.7, 0.9, 0.7, 1.0)
_SUBTITLE_COLOR = (0.6, 0.7, 0.6, 1.0)
[docs]
class Target(Enum):
"""Where the page's buttons lead."""
COOP = 'coop'
TEAMS = 'teams'
FREE_FOR_ALL = 'ffa'
[docs]
class PlayRoute(DocUIRoute):
"""Family class for the play routes."""
[docs]
@override
@classmethod
def get_route_types(cls) -> tuple[type[DocUIRoute], ...]:
return family_members(AnyPlayRoute)
[docs]
@override
@classmethod
def get_window_layout(cls) -> dui2.WindowLayout:
return dui2.WindowLayout.WIDE
[docs]
@ioprepped
@dataclass
class Root(PlayRoute, path='/'):
"""The play page."""
#: Picking a playlist for private-party hosting instead of playing.
select: bool = False
AnyPlayRoute = Root
[docs]
class PlayLocalAction(DocUILocalActionBase):
"""Family class for the play local-actions."""
[docs]
@override
@classmethod
def get_action_types(cls) -> tuple[type[DocUILocalActionBase], ...]:
return family_members(AnyPlayLocalAction)
[docs]
@ioprepped
@dataclass
class Open(PlayLocalAction, name='open'):
"""Go somewhere."""
[docs]
@override
@classmethod
def get_press_sound(cls) -> PressSound:
return PressSound.SWISH
target: Target
select: bool = False
AnyPlayLocalAction = Open
[docs]
class PlayController(TypedDocUIController[AnyPlayRoute, AnyPlayLocalAction]):
"""Doc-ui controller for the play page."""
[docs]
@override
@classmethod
def get_route_type(cls) -> type[PlayRoute]:
return PlayRoute
[docs]
@override
@classmethod
def get_local_action_type(cls) -> type[PlayLocalAction]:
return PlayLocalAction
[docs]
@override
def fulfill_route(self, route: AnyPlayRoute) -> DocUIResponse:
match route:
case Root():
_preload_modules()
return _page(route.select)
case _:
assert_never(route)
[docs]
@override
def run_local_action(
self, action: AnyPlayLocalAction, context: DocUILocalAction
) -> None:
match action:
case Open():
_open(action.target, action.select, context)
case _:
assert_never(action)
[docs]
class PlaySelectController(PlayController):
"""The play page as the first step of picking a private-party playlist.
Open it at a :class:`Root` route with ``select`` set. Its own class
keeps these windows distinct from the play flow's for navigation,
and it shows a minimal toolbar (as the legacy select flow did): we're
mid-errand for the gather window.
"""
def _preload_modules() -> None:
"""Preload modules our buttons lead to (avoids hitches)."""
# pylint: disable=cyclic-import
import bauiv1lib.account as _unused1
import bauiv1lib.coop.browser as _unused2
import bauiv1lib.playlist.browserdocui as _unused3
def _page(select: bool) -> dui2.Response:
"""Build the page (called in a background thread)."""
strs = _classicassets.strings
buttons: list[dui2.Button] = []
if not select:
buttons.append(
_button(
Target.COOP,
strs.play_modes.single_player_coop.spec,
strs.play.one_to_four_players.spec,
_coop_decos(),
select=select,
selected=True,
)
)
buttons += [
_button(
Target.TEAMS,
strs.play_modes.teams.spec,
strs.play.two_to_eight_players.spec,
_teams_decos(),
select=select,
selected=select,
),
_button(
Target.FREE_FOR_ALL,
strs.play_modes.free_for_all.spec,
strs.play.two_to_eight_players.spec,
_ffa_decos(),
select=select,
),
]
return dui2.Response(
page=dui2.Page(
title=(
strs.playlist.playlists.spec if select else strs.ui.play.spec
),
center_vertically=True,
rows=[
dui2.ButtonRow(
content_align=dui2.HAlign.CENTER,
button_spacing=_BUTTON_SPACING,
# Every bit of width goes to the buttons.
padding_left=0.0,
padding_right=0.0,
# Enough room for the buttons' art (glow above,
# shadow below) so our h-scroll doesn't clip it...
padding_top=6.0,
padding_bottom=21.0,
# ...paid for out of the page's bottom buffer, as
# the page height is otherwise spoken for. The row
# also sits a bit lower than centered (space added
# above and taken from below), which reads better
# under the title.
spacing_top=_ROW_DROP,
spacing_bottom=-7.0 - _ROW_DROP,
buttons=buttons,
)
],
)
)
def _button(
target: Target,
title: LangStrSpec,
subtitle: LangStrSpec,
art: list[dui2.Image],
*,
select: bool,
selected: bool = False,
) -> dui2.Button:
return dui2.Button(
size=(_BW, _BH),
scale=_BUTTON_SCALE,
action=Open(target=target, select=select).local(),
widget_id=target.value,
selected=selected,
decorations=[
*_scaled(art, 1.0, pivot=(0.0, 0.0), shift=(0.0, -_ART_TUCK)),
dui2.Text(
text=title,
position=(0.0, 120.0 - _LEGACY_BH * 0.5 - _LABEL_DROP),
size=(_BW * _LABEL_WIDTH, 80.0),
# Just fits the longest English title (co-op) in that
# width, so all three draw the same size.
scale=1.47,
color=_TITLE_COLOR,
),
dui2.Text(
text=subtitle,
# (A bit closer under the title than the legacy 69.)
position=(0.0, 75.0 - _LEGACY_BH * 0.5 - _LABEL_DROP),
size=(_BW * _LABEL_WIDTH, 40.0),
# Grown along with the title.
scale=1.06,
flatness=1.0,
color=_SUBTITLE_COLOR,
),
],
)
def _dude(
i: int, pos: tuple[float, float], color: tuple[float, float, float]
) -> list[dui2.Image]:
"""One little character (body plus eyes), as the legacy window drew.
``pos`` is in the legacy window's per-button coords.
"""
lineup_tex = _classicassets.textures.player_lineup
eye_color = (
0.7 + 0.3 * color[0],
0.7 + 0.3 * color[1],
0.7 + 0.3 * color[2],
1.0,
)
# Body mesh, body size, eye offset, eye size -- per character.
match i:
case 0:
mesh = _classicassets.meshes.player_lineup1_transparent
bsize, eoffs, esize = (60.0, 80.0), (12.0, 53.0), (36.0, 18.0)
case 1:
mesh = _classicassets.meshes.player_lineup2_transparent
bsize, eoffs, esize = (45.0, 90.0), (5.0, 67.0), (32.0, 16.0)
case 2:
mesh = _classicassets.meshes.player_lineup3_transparent
bsize, eoffs, esize = (45.0, 90.0), (5.0, 59.0), (34.0, 17.0)
case 3:
mesh = _classicassets.meshes.player_lineup4_transparent
bsize, eoffs, esize = (48.0, 96.0), (2.0, 62.0), (38.0, 19.0)
case _:
raise ValueError(f'Invalid dude index {i}.')
# The legacy window offset every character by (-100, 130).
x = pos[0] - 100.0 - _BW * 0.5 + _ART_SHIFT
y = pos[1] + 130.0 - _LEGACY_BH * 0.5 + _ART_RAISE
return [
dui2.Image(
texture=lineup_tex,
mesh_transparent=mesh,
position=(x, y),
size=bsize,
color=(*color, 1.0),
h_align=dui2.HAlign.LEFT,
v_align=dui2.VAlign.BOTTOM,
),
dui2.Image(
texture=lineup_tex,
mesh_transparent=_classicassets.meshes.plastic_eyes_transparent,
position=(x + eoffs[0], y + eoffs[1]),
size=esize,
color=eye_color,
h_align=dui2.HAlign.LEFT,
v_align=dui2.VAlign.BOTTOM,
),
]
def _scaled(
images: list[dui2.Image],
scale: float,
pivot: tuple[float, float],
shift: tuple[float, float],
) -> list[dui2.Image]:
"""Scale a group of images about a pivot, then shift it.
Positions scale along with sizes, so the group keeps its internal
proportions (spacing included). Assumes bottom-left-aligned images,
as ours all are.
"""
return [
replace(
img,
position=(
pivot[0] + (img.position[0] - pivot[0]) * scale + shift[0],
pivot[1] + (img.position[1] - pivot[1]) * scale + shift[1],
),
size=(img.size[0] * scale, img.size[1] * scale),
)
for img in images
]
def _coop_decos() -> list[dui2.Image]:
computer = dui2.Image(
texture=_classicassets.textures.player_lineup,
mesh_transparent=_classicassets.meshes.angry_computer_transparent,
position=(
230.0 - _BW * 0.5 + _ART_SHIFT,
153.0 - _LEGACY_BH * 0.5 + _ART_RAISE,
),
size=(115.0, 115.0),
h_align=dui2.HAlign.LEFT,
v_align=dui2.VAlign.BOTTOM,
)
# The computer is grown into the extra space our buttons have over
# the legacy ones (scaled about its center, then nudged up/right so
# it sits centered in the green above the label); its box then
# spans y 1..144, centered at 72.
#
# The players are grown the same way, and placed with their group
# vertically centered on the computer, staggered a bit more than
# the legacy window's so they fill their side of the button.
return [
*_dude_at(0, (-171, -9), (0.72, 0.4, 1.0), 1.2),
*_dude_at(1, (-117, 32), (0.71, 0.5, 1.0), 1.2),
*_dude_at(2, (-75, -12), (0.67, 0.44, 1.0), 1.2),
*_dude_at(3, (-33, 40), (0.7, 0.3, 1.0), 1.2),
*_scaled([computer], 1.25, pivot=(97.5, 45.5), shift=(14.0, 27.0)),
]
def _teams_decos() -> list[dui2.Image]:
# Two tight huddles side by side, each a zigzag of front/back
# rows close enough that heights overlap, with a gap between the
# teams; both centered in the space above the label (the legacy
# window stacked blue above red, which looked lopsided). Grown
# like co-op's players. Each team listed left to right, except
# that its rightmost (back-row) player comes first so it draws
# behind the one beside it.
return [
*_dude_at(0, (-73, 42), (0.3, 0.5, 1.0), 1.2),
*_dude_at(2, (-175, -4), (0.2, 0.4, 1.0), 1.2),
*_dude_at(1, (-141, 38), (0.3, 0.5, 1.0), 1.2),
*_dude_at(3, (-107, 0), (0.3, 0.4, 1.0), 1.2),
*_dude_at(2, (121, 40), (1.0, 0.5, 0.5), 1.2),
*_dude_at(0, (7, -6), (1.0, 0.5, 0.4), 1.2),
*_dude_at(1, (47, 36), (1.0, 0.58, 0.58), 1.2),
*_dude_at(3, (83, -10), (1.0, 0.5, 0.5), 1.2),
]
def _dude_at(
i: int,
pos: tuple[float, float],
color: tuple[float, float, float],
scale: float,
) -> list[dui2.Image]:
"""A character with its body's bottom-left at ``pos``, scaled.
Unlike :func:`_dude`, ``pos`` is in our button coords (origin at
the button's center).
"""
legacy_pos = (
pos[0] + 100.0 + _BW * 0.5 - _ART_SHIFT,
pos[1] - 130.0 + _LEGACY_BH * 0.5 - _ART_RAISE,
)
return _scaled(_dude(i, legacy_pos, color), scale, pivot=pos, shift=(0, 0))
def _ffa_decos() -> list[dui2.Image]:
# A scatter over the space above the label, grown like the other
# buttons' players: heights staggered so there's no sense of rows,
# and nobody overlapping. (Started from a randomized
# max-min-spacing search, then hand-tuned.) Listed left to right.
return [
*_dude_at(0, (-164, 66), (0.4, 0.5, 1.0), 1.2),
*_dude_at(2, (-121, -28), (0.5, 1.0, 0.4), 1.2),
*_dude_at(1, (-84, 59), (1.0, 0.9, 0.4), 1.2),
*_dude_at(3, (-35, 3), (0.4, 0.5, 0.8), 1.2),
*_dude_at(1, (12, -32), (0.7, 0.5, 0.9), 1.2),
*_dude_at(0, (15, 81), (0.4, 1.0, 0.4), 1.2),
*_dude_at(3, (71, -17), (1.0, 0.4, 0.5), 1.2),
*_dude_at(2, (120, 33), (0.7, 1.0, 0.5), 1.2),
]
def _open(target: Target, select: bool, context: DocUILocalAction) -> None:
# pylint: disable=cyclic-import
window = context.window
# No-op if we're not in control.
if not window.main_window_has_control():
return
match target:
case Target.COOP:
from bauiv1lib.account.signin import show_sign_in_prompt
from bauiv1lib.coop.browser import CoopBrowserWindow
plus = bui.app.plus
assert plus is not None
if plus.accounts.primary is None:
show_sign_in_prompt(origin_widget=context.widget)
return
window.main_window_replace(
lambda: CoopBrowserWindow(origin_widget=context.widget)
)
case Target.TEAMS | Target.FREE_FOR_ALL:
from bauiv1lib.playlist.browserdocui import (
PlaylistBrowserController,
PlaylistSelectController,
Playlists,
SessionKind,
window_layout_for,
)
kind = (
SessionKind.TEAMS
if target is Target.TEAMS
else SessionKind.FREE_FOR_ALL
)
# WIDE through LARGE, depending on how many playlists it'll
# show.
layout = window_layout_for(kind)
ctrl = (
PlaylistSelectController
if select
else PlaylistBrowserController
)
window.main_window_replace(
lambda: ctrl().create_window(
Playlists(kind=kind, select=select),
origin_widget=context.widget,
auxiliary_style=False,
options=WindowOptions(layout=layout),
),
extra_type_id=ctrl.get_window_extra_type_id(),
)
case _:
assert_never(target)
# 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, TypeIs)
import asyncio
from concurrent.futures import Future
from pathlib import Path
from enum import Enum