# Released under the MIT License. See LICENSE for details.
#
"""Provides help related ui."""
from __future__ import annotations # Docs-generation hack.
import random
from typing import override, TYPE_CHECKING
from efro.util import asserttype
import bacommon.docui.v2 as dui2
from bacommon.assetspec import TextureSpec
from bacommon.langstr import LangStrSpecValue
import bauiv1 as bui
from bascenev1lib.actor import spazappearance
from bauiv1 import builtinassets
from bauiv1 import classicassets
from bauiv1lib.docui import DocUIController
if TYPE_CHECKING:
from typing import Any
from bacommon.docui import DocUIRequest, DocUIResponse
from bauiv1lib.docui import DocUILocalAction, DocUIWindow
def _tex_from_qualified(qualified: str) -> TextureSpec:
"""Typed ref for a qualified ``<apverid>:<name>`` texture string.
(Appearance texture fields carry qualified strings; docui v2 wants
typed refs.)
"""
apverid, _, name = qualified.partition(':')
return TextureSpec(apverid, name)
[docs]
class InventoryUIController(DocUIController):
"""DocUI setup for inventory."""
def __init__(self, player_profiles_only: bool = False) -> None:
self._next_selected_profile: str | None = None
self._player_profiles_only = player_profiles_only
[docs]
@override
def fulfill_request(self, request: DocUIRequest) -> DocUIResponse:
# All local authoring here uses strings from BUNDLED packages
# (baclassicassets/builtin) so these pages keep working offline.
invstrs = classicassets.strings.inventory
profstrs = classicassets.strings.profiles
response: DocUIResponse
# If we only want player profiles, we can skip the whole cloud
# request bit.
if self._player_profiles_only:
response = dui2.Response(
page=dui2.Page(
title=invstrs.title.spec,
rows=[],
)
)
else:
# *Most* of our inventory comes from the cloud - we just supply
# profiles ourself so it works offline.
cloudresponse = self.fulfill_request_cloud(
request, 'classicinventory'
)
assert isinstance(request, dui2.Request)
if not isinstance(cloudresponse, dui2.Response):
# A server that doesn't speak v2 for us; show its
# response as-is (no local additions).
return cloudresponse
response = cloudresponse
if request.path != '/':
return response
signed_in = (
bui.app.plus is not None
and bui.app.plus.accounts.primary is not None
)
# If anything went wrong, replace the error page they sent us with
# a minimal 'most stuff is only available online' page.
if response.status is not dui2.ResponseStatus.SUCCESS:
response = dui2.Response(
page=dui2.Page(
title=invstrs.title.spec,
rows=[
dui2.ButtonRow(
center_content=True,
buttons=[
dui2.Button(
(
invstrs.only_available_signed_in
if not signed_in
else invstrs.only_available_online
).spec,
texture=builtinassets.textures.white,
size=(600, 100),
color=(1, 1, 1, 0.0),
label_scale=0.7,
label_color=(1, 0.4, 0.4, 0.8),
)
],
),
],
),
)
# Wire spawn-bot actions onto any buttons the server marked
# (structured ids; no display-text sniffing).
for row in response.page.rows:
if isinstance(row, dui2.ButtonRow):
for button in row.buttons:
wid = button.widget_id
if wid is not None and wid.startswith('spawn_char:'):
button.action = dui2.Local(
immediate_local_action='spawn_bot',
immediate_local_action_args={
'name': wid.removeprefix('spawn_char:')
},
)
# Now add in our profiles, which we handle locally so it is
# available offline.
response.page.rows = [
dui2.ButtonRow(
title=profstrs.title.spec,
subtitle=profstrs.explanation.spec,
button_spacing=15,
buttons=self._get_profile_buttons(),
),
dui2.ButtonRow(
spacing_top=-15,
spacing_bottom=15,
padding_left=13,
buttons=[
dui2.Button(
profstrs.new_profile.spec,
action=dui2.Local(
default_sound=False,
immediate_local_action='new_profile',
),
icon=classicassets.textures.plus_button,
icon_scale=1.3,
icon_color=(0.7, 0.6, 0.9, 1),
style=dui2.ButtonStyle.MEDIUM,
size=(210, 60),
scale=0.8,
color=(0.6, 0.5, 0.8, 1.0),
label_color=(1, 1, 1, 1),
),
],
),
] + response.page.rows
return response
[docs]
@override
def local_action(self, action: DocUILocalAction) -> None:
if action.name == 'new_profile':
self._new_profile(action)
elif action.name == 'edit_profile':
self._edit_profile(action)
elif action.name == 'spawn_bot':
self._spawn_bot(action)
else:
bui.screenmessage(
f'Invalid local-action "{action.name}".', color=(1, 0, 0)
)
builtinassets.audio.error.get().play()
[docs]
@override
def restore_window_shared_state(
self, window: DocUIWindow, state: dict
) -> None:
"""Called when a window shared state is being restored."""
if not isinstance(window.request, dui2.Request):
return
# If desired, set the profile button that will be selected in
# the new window. We do this when coming back from creating a
# new profile/etc.
if (
window.request.path == '/'
and self._next_selected_profile is not None
):
state['selection'] = f'$(WIN)|profile.{self._next_selected_profile}'
# Only do this once (return to normal selection save/restore
# after).
self._next_selected_profile = None
def _on_profile_save(self, name: str) -> None:
# An editor we launched tells us it saved a profile.
# Have this one selected when we go back to the listing.
self._next_selected_profile = name
bui.pushcall(self._notify_profiles_changed)
def _on_profile_delete(self, name: str) -> None:
# An editor we launched tells us it deleted a profile.
# Ask the inventory list to select/show the profile right before
# the one we're deleting.
profiles = bui.app.config.get('Player Profiles', {})
items = list(profiles.items())
items.sort(key=lambda x: asserttype(x[0], str).lower())
namelower = name.lower()
prevname = items[0][0] if items else None
for item in items:
if item[0].lower() < namelower:
prevname = item[0]
else:
break
if prevname is not None:
self._next_selected_profile = prevname
self._notify_profiles_changed()
def _notify_profiles_changed(self) -> None:
import bascenev1 as bs
# If there's a team-chooser in existence, tell it the profile-list
# has probably changed.
session = bs.get_foreground_host_session()
if session is not None:
session.handlemessage(bs.PlayerProfilesChangedMessage())
def _get_profile_buttons(self) -> list[dui2.Button]:
plus = bui.app.plus
assert plus is not None
classic = bui.app.classic
assert classic is not None
buttons: list[dui2.Button] = []
profiles = bui.app.config.get('Player Profiles', {})
items = list(profiles.items())
items.sort(key=lambda x: asserttype(x[0], str).lower())
account_name: str | None
if plus.get_v1_account_state() == 'signed_in':
account_name = plus.get_v1_account_display_string()
else:
account_name = None
spaz_appearances = classic.spaz_appearances
spaz_appearance_default = spaz_appearances['Spaz']
for p_name, p_info in items:
if p_name == '__account__' and account_name is None:
continue
color, highlight = classic.get_player_profile_colors(p_name)
tval = (
account_name
if p_name == '__account__'
else classic.get_player_profile_icon(p_name) + p_name
)
assert tval is not None
tcolor: Any = bui.safecolor(color, 0.4) + (1.0,)
assert len(tcolor) == 4
appearance = spaz_appearances.get(p_info['character'])
if appearance is None:
appearance = spaz_appearance_default
buttons.append(
dui2.Button(
texture=builtinassets.textures.white,
size=(145, 175),
action=dui2.Local(
default_sound=False,
immediate_local_action='edit_profile',
immediate_local_action_args={'profile': p_name},
),
# color=(0.6, 0.5, 0.7, 1.0),
color=(1, 1, 1, 0.0),
widget_id=f'profile.{p_name}',
decorations=[
dui2.Image(
spazappearance.texture_spec(
appearance.icon_texture
),
position=(0, 15),
size=(140, 140),
mask_texture=(
builtinassets.textures.character_icon_mask
),
tint_texture=spazappearance.texture_spec(
appearance.icon_mask_texture
),
tint_color=color,
tint2_color=highlight,
),
dui2.Text(
# Raw profile name (+icon glyph); verbatim.
LangStrSpecValue(tval),
position=(0, -75),
size=(130, 40),
flatness=1.0,
shadow=1.0,
color=tcolor,
),
],
)
)
return buttons
def _new_profile(self, action: DocUILocalAction) -> None:
# pylint: disable=cyclic-import
from bauiv1lib.profile.edit import EditProfileWindow
builtinassets.audio.swish.get().play()
plus = bui.app.plus
assert plus is not None
# Clamp at 100 profiles (otherwise the server will and that's less
# elegant looking).
profiles = bui.app.config.get('Player Profiles', {})
if len(profiles) > 100:
bui.screenmessage(
bui.Lstr(
translate=(
'serverResponses',
'Max number of profiles reached.',
)
),
color=(1, 0, 0),
)
builtinassets.audio.error.get().play()
return
action.window.main_window_replace(
lambda: EditProfileWindow(
existing_profile=None,
on_profile_save=bui.WeakCallPartial(self._on_profile_save),
on_profile_delete=bui.WeakCallPartial(self._on_profile_delete),
)
)
def _edit_profile(self, action: DocUILocalAction) -> None:
# pylint: disable=cyclic-import
from bauiv1lib.profile.edit import EditProfileWindow
builtinassets.audio.swish.get().play()
profile = action.args.get('profile')
assert isinstance(profile, str)
# Play a random sound from the character.
classic = bui.app.classic
if classic is not None:
profiles = bui.app.config.get('Player Profiles', {})
p_info = profiles.get(profile)
if p_info:
char = p_info.get('character', 'Spaz')
appearance = classic.spaz_appearances.get(char)
if appearance:
sounds = [
*appearance.jump_sounds,
*appearance.attack_sounds,
*appearance.pickup_sounds,
]
if sounds:
spazappearance.ui_sound(random.choice(sounds)).play()
action.window.main_window_replace(
lambda: EditProfileWindow(
profile,
origin_widget=action.widget,
on_profile_save=bui.WeakCallPartial(self._on_profile_save),
on_profile_delete=bui.WeakCallPartial(self._on_profile_delete),
)
)
def _spawn_bot(self, action: DocUILocalAction) -> None:
import bascenev1 as bs
from bascenev1lib.mainmenu import MainMenuActivity
from bascenev1lib.actor.spazbot import DemoSpazBotSet, DemoBot
from bascenev1lib.actor.spazappearance import get_appearances
name = action.args.get('name')
assert isinstance(name, str)
# Modern flow passes the exact internal appearance name (from
# the server's spawn_char widget-id markers); the legacy scan
# below also tolerates old Lstr-JSON display strings.
activity = bs.get_foreground_host_activity()
if not isinstance(activity, MainMenuActivity) or activity.map is None:
return
bounds = activity.map.get_def_bound_box('map_bounds')
if bounds is None:
return
i = 0
while i < len(activity.bot_sets):
if activity.bot_sets[i].have_living_bots():
i += 1
else:
activity.bot_sets.pop(i)
for appearance in get_appearances(True):
if appearance == name or f'"{appearance}"' in name:
with activity.context:
bot_set = DemoSpazBotSet()
DemoBot.randomize_traits(appearance)
bot_set.spawn_bot(
DemoBot,
(
(bounds[0] + bounds[3]) / 2 + random.uniform(-7, 7),
bounds[4] - 2,
(bounds[2] + bounds[5]) / 2 + random.uniform(-7, 7),
),
0,
)
activity.bot_sets.append(bot_set)
break
# 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