# Released under the MIT License. See LICENSE for details.
#
"""Provides a score screen for coop games."""
# pylint: disable=too-many-lines
from __future__ import annotations
import random
import logging
from typing import TYPE_CHECKING, override
from efro.util import strict_partial
import bacommon.bs
from bacommon.login import LoginType
import bascenev1 as bs
import bauiv1 as bui
from bascenev1lib.actor.text import Text
from bascenev1lib.actor.zoomtext import ZoomText
if TYPE_CHECKING:
from typing import Any, Sequence
[docs]
class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]):
"""Score screen showing the results of a cooperative game."""
def __init__(self, settings: dict):
# pylint: disable=too-many-statements
super().__init__(settings)
plus = bs.app.plus
assert plus is not None
# Keep prev activity alive while we fade in
self.transition_time = 0.5
self.inherits_tint = True
self.inherits_vr_camera_offset = True
self.inherits_music = True
self.use_fixed_vr_overlay = True
self._do_new_rating: bool = self.session.tournament_id is not None
self._score_display_sound = bs.getsound('scoreHit01')
self._score_display_sound_small = bs.getsound('scoreHit02')
self.drum_roll_sound = bs.getsound('drumRoll')
self.cymbal_sound = bs.getsound('cymbal')
self._replay_icon_texture = bui.gettexture('replayIcon')
self._menu_icon_texture = bui.gettexture('menuIcon')
self._next_level_icon_texture = bui.gettexture('nextLevelIcon')
self._campaign: bs.Campaign = settings['campaign']
self._have_achievements = (
bs.app.classic is not None
and bs.app.classic.ach.achievements_for_coop_level(
self._campaign.name + ':' + settings['level']
)
)
self._game_service_icon_color: Sequence[float] | None
self._game_service_achievements_texture: bui.Texture | None
self._game_service_leaderboards_texture: bui.Texture | None
# Tie in to specific game services if they are active.
adapter = plus.accounts.login_adapters.get(LoginType.GPGS)
gpgs_active = adapter is not None and adapter.is_back_end_active()
adapter = plus.accounts.login_adapters.get(LoginType.GAME_CENTER)
game_center_active = (
adapter is not None and adapter.is_back_end_active()
)
if game_center_active:
self._game_service_icon_color = (1.0, 1.0, 1.0)
icon = bui.gettexture('gameCenterIcon')
self._game_service_achievements_texture = icon
self._game_service_leaderboards_texture = icon
self._account_has_achievements = True
elif gpgs_active:
self._game_service_icon_color = (0.8, 1.0, 0.6)
self._game_service_achievements_texture = bui.gettexture(
'googlePlayAchievementsIcon'
)
self._game_service_leaderboards_texture = bui.gettexture(
'googlePlayLeaderboardsIcon'
)
self._account_has_achievements = True
else:
self._game_service_icon_color = None
self._game_service_achievements_texture = None
self._game_service_leaderboards_texture = None
self._account_has_achievements = False
self._cashregistersound = bs.getsound('cashRegister')
self._gun_cocking_sound = bs.getsound('gunCocking')
self._dingsound = bs.getsound('ding')
self._score_link: str | None = None
self._root_ui: bui.Widget | None = None
self._background: bs.Actor | None = None
self._old_best_rank = 0.0
self._game_name_str: str | None = None
self._game_config_str: str | None = None
# Ui bits.
self._corner_button_offs: tuple[float, float] | None = None
self._restart_button: bui.Widget | None = None
self._next_level_error: bs.Actor | None = None
# Score/gameplay bits.
self._was_complete: bool | None = None
self._is_complete: bool | None = None
self._newly_complete: bool | None = None
self._is_more_levels: bool | None = None
self._next_level_name: str | None = None
self._show_info: dict[str, Any] | None = None
self._name_str: str | None = None
self._friends_loading_status: bs.Actor | None = None
self._score_loading_status: bs.Actor | None = None
self._tournament_time_remaining: float | None = None
self._tournament_time_remaining_text: Text | None = None
self._tournament_time_remaining_text_timer: bs.BaseTimer | None = None
self._submit_score = self.session.submit_score
# Stuff for activity skip by pressing button
self._birth_time = bs.time()
self._min_view_time = 5.0
self._allow_server_transition = False
self._server_transitioning: bool | None = None
self._playerinfos: list[bs.PlayerInfo] = settings['playerinfos']
assert isinstance(self._playerinfos, list)
assert all(isinstance(i, bs.PlayerInfo) for i in self._playerinfos)
self._score: int | None = settings['score']
assert isinstance(self._score, (int, type(None)))
self._fail_message: bs.Lstr | None = settings['fail_message']
assert isinstance(self._fail_message, (bs.Lstr, type(None)))
self._begin_time: float | None = None
self._score_order: str
if 'score_order' in settings:
if not settings['score_order'] in ['increasing', 'decreasing']:
raise ValueError(
'Invalid score order: ' + settings['score_order']
)
self._score_order = settings['score_order']
else:
self._score_order = 'increasing'
assert isinstance(self._score_order, str)
self._score_type: str
if 'score_type' in settings:
if not settings['score_type'] in ['points', 'time']:
raise ValueError(
'Invalid score type: ' + settings['score_type']
)
self._score_type = settings['score_type']
else:
self._score_type = 'points'
assert isinstance(self._score_type, str)
self._level_name: str = settings['level']
assert isinstance(self._level_name, str)
self._game_name_str = self._campaign.name + ':' + self._level_name
self._game_config_str = (
str(len(self._playerinfos))
+ 'p'
+ self._campaign.getlevel(self._level_name)
.get_score_version_string()
.replace(' ', '_')
)
try:
self._old_best_rank = self._campaign.getlevel(
self._level_name
).rating
except Exception:
self._old_best_rank = 0.0
self._victory: bool = settings['outcome'] == 'victory'
@override
def __del__(self) -> None:
super().__del__()
# If our UI is still up, kill it.
if self._root_ui and not self._root_ui.transitioning_out:
with bui.ContextRef.empty():
bui.containerwidget(edit=self._root_ui, transition='out_left')
[docs]
@override
def on_transition_in(self) -> None:
from bascenev1lib.actor import background # FIXME NO BSSTD
bs.set_analytics_screen('Coop Score Screen')
super().on_transition_in()
self._background = background.Background(
fade_time=0.45, start_faded=False, show_logo=True
)
def _ui_menu(self) -> None:
bui.containerwidget(edit=self._root_ui, transition='out_left')
with self.context:
bs.timer(0.1, bs.Call(bs.WeakCall(self.session.end)))
def _ui_restart(self) -> None:
from bauiv1lib.tournamententry import TournamentEntryWindow
# If we're in a tournament and it looks like there's no time left,
# disallow.
if self.session.tournament_id is not None:
if self._tournament_time_remaining is None:
bui.screenmessage(
bui.Lstr(resource='tournamentCheckingStateText'),
color=(1, 0, 0),
)
bui.getsound('error').play()
return
if self._tournament_time_remaining <= 0:
bui.screenmessage(
bui.Lstr(resource='tournamentEndedText'), color=(1, 0, 0)
)
bui.getsound('error').play()
return
# If there are currently fewer players than our session min,
# don't allow.
if len(self.players) < self.session.min_players:
bui.screenmessage(
bui.Lstr(resource='notEnoughPlayersRemainingText'),
color=(1, 0, 0),
)
bui.getsound('error').play()
return
self._campaign.set_selected_level(self._level_name)
# If this is a tournament, go back to the tournament-entry UI
# otherwise just hop back in.
tournament_id = self.session.tournament_id
if tournament_id is not None:
assert self._restart_button is not None
TournamentEntryWindow(
tournament_id=tournament_id,
tournament_activity=self,
position=self._restart_button.get_screen_space_center(),
)
else:
bui.containerwidget(edit=self._root_ui, transition='out_left')
self.can_show_ad_on_death = True
with self.context:
self.end({'outcome': 'restart'})
def _ui_next(self) -> None:
# If we didn't just complete this level but are choosing to play the
# next one, set it as current (this won't happen otherwise).
if (
self._is_complete
and self._is_more_levels
and not self._newly_complete
):
assert self._next_level_name is not None
self._campaign.set_selected_level(self._next_level_name)
bui.containerwidget(edit=self._root_ui, transition='out_left')
with self.context:
self.end({'outcome': 'next_level'})
def _ui_gc(self) -> None:
if bs.app.plus is not None:
bs.app.plus.show_game_service_ui(
'leaderboard',
game=self._game_name_str,
game_version=self._game_config_str,
)
else:
logging.warning('show_game_service_ui requires plus feature-set')
def _ui_show_achievements(self) -> None:
if bs.app.plus is not None:
bs.app.plus.show_game_service_ui('achievements')
else:
logging.warning('show_game_service_ui requires plus feature-set')
def _ui_worlds_best(self) -> None:
if self._score_link is None:
bui.getsound('error').play()
bui.screenmessage(
bui.Lstr(resource='scoreListUnavailableText'), color=(1, 0.5, 0)
)
else:
bui.open_url(self._score_link)
def _ui_error(self) -> None:
with self.context:
self._next_level_error = Text(
bs.Lstr(resource='completeThisLevelToProceedText'),
flash=True,
maxwidth=360,
scale=0.54,
h_align=Text.HAlign.CENTER,
color=(0.5, 0.7, 0.5, 1),
position=(300, -235),
)
bui.getsound('error').play()
bs.timer(
2.0,
bs.WeakCall(
self._next_level_error.handlemessage, bs.DieMessage()
),
)
def _should_show_worlds_best_button(self) -> bool:
# Old high score lists webpage for tourneys seems broken
# (looking at meteor shower at least).
if self.session.tournament_id is not None:
return False
# Link is too complicated to display with no browser.
return bui.is_browser_likely_available()
[docs]
def request_ui(self) -> None:
"""Set up a callback to show our UI at the next opportune time."""
classic = bui.app.classic
assert classic is not None
# We don't want to just show our UI in case the user already has the
# main menu up, so instead we add a callback for when the menu
# closes; if we're still alive, we'll come up then.
# If there's no main menu this gets called immediately.
classic.add_main_menu_close_callback(bui.WeakCall(self.show_ui))
[docs]
def show_ui(self) -> None:
"""Show the UI for restarting, playing the next Level, etc."""
# pylint: disable=too-many-locals
# pylint: disable=too-many-statements
# pylint: disable=too-many-branches
assert bui.app.classic is not None
env = bui.app.env
delay = 0.7 if (self._score is not None) else 0.0
# If there's no players left in the game, lets not show the UI
# (that would allow restarting the game with zero players, etc).
if not self.players:
return
rootc = self._root_ui = bui.containerwidget(
size=(0, 0),
transition='in_right',
toolbar_visibility='no_menu_minimal',
)
h_offs = 7.0
v_offs = -280.0
v_offs2 = -236.0
# We wanna prevent controllers users from popping up browsers
# or game-center widgets in cases where they can't easily get back
# to the game (like on mac).
can_select_extra_buttons = bui.app.classic.platform == 'android'
bui.set_ui_input_device(None) # Menu is up for grabs.
if self._have_achievements and self._account_has_achievements:
bui.buttonwidget(
parent=rootc,
color=(0.45, 0.4, 0.5),
position=(h_offs - 520, v_offs + 450 - 235 + 40),
size=(300, 60),
label=bui.Lstr(resource='achievementsText'),
on_activate_call=bui.WeakCall(self._ui_show_achievements),
transition_delay=delay + 1.5,
icon=self._game_service_achievements_texture,
icon_color=self._game_service_icon_color,
autoselect=True,
selectable=can_select_extra_buttons,
)
if self._should_show_worlds_best_button():
bui.buttonwidget(
parent=rootc,
color=(0.45, 0.4, 0.5),
position=(240, v_offs2 + 439),
size=(350, 62),
label=(
bui.Lstr(resource='tournamentStandingsText')
if self.session.tournament_id is not None
else (
bui.Lstr(resource='worldsBestScoresText')
if self._score_type == 'points'
else bui.Lstr(resource='worldsBestTimesText')
)
),
autoselect=True,
on_activate_call=bui.WeakCall(self._ui_worlds_best),
transition_delay=delay + 1.9,
selectable=can_select_extra_buttons,
)
else:
pass
show_next_button = self._is_more_levels and not (env.demo or env.arcade)
if not show_next_button:
h_offs += 60
# Due to virtual-bounds changes, have to squish buttons a bit to
# avoid overlapping with tips at bottom. Could look nicer to
# rework things in the middle to get more space, but would
# rather not touch this old code more than necessary.
small_buttons = False
if small_buttons:
menu_button = bui.buttonwidget(
parent=rootc,
autoselect=True,
position=(h_offs - 130 - 45, v_offs + 40),
size=(100, 50),
label='',
button_type='square',
on_activate_call=bui.WeakCall(self._ui_menu),
)
bui.imagewidget(
parent=rootc,
draw_controller=menu_button,
position=(h_offs - 130 - 60 + 43, v_offs + 43),
size=(45, 45),
texture=self._menu_icon_texture,
opacity=0.8,
)
else:
menu_button = bui.buttonwidget(
parent=rootc,
autoselect=True,
position=(h_offs - 130 - 60, v_offs),
size=(110, 85),
label='',
on_activate_call=bui.WeakCall(self._ui_menu),
)
bui.imagewidget(
parent=rootc,
draw_controller=menu_button,
position=(h_offs - 130 - 60 + 22, v_offs + 14),
size=(60, 60),
texture=self._menu_icon_texture,
opacity=0.8,
)
if small_buttons:
self._restart_button = restart_button = bui.buttonwidget(
parent=rootc,
autoselect=True,
position=(h_offs - 60, v_offs + 40),
size=(100, 50),
label='',
button_type='square',
on_activate_call=bui.WeakCall(self._ui_restart),
)
bui.imagewidget(
parent=rootc,
draw_controller=restart_button,
position=(h_offs - 60 + 25, v_offs + 42),
size=(47, 47),
texture=self._replay_icon_texture,
opacity=0.8,
)
else:
self._restart_button = restart_button = bui.buttonwidget(
parent=rootc,
autoselect=True,
position=(h_offs - 60, v_offs),
size=(110, 85),
label='',
on_activate_call=bui.WeakCall(self._ui_restart),
)
bui.imagewidget(
parent=rootc,
draw_controller=restart_button,
position=(h_offs - 60 + 19, v_offs + 7),
size=(70, 70),
texture=self._replay_icon_texture,
opacity=0.8,
)
next_button: bui.Widget | None = None
# Our 'next' button is disabled if we haven't unlocked the next
# level yet and invisible if there is none.
if show_next_button:
if self._is_complete:
call = bui.WeakCall(self._ui_next)
button_sound = True
image_opacity = 0.8
color = None
else:
call = bui.WeakCall(self._ui_error)
button_sound = False
image_opacity = 0.2
color = (0.3, 0.3, 0.3)
if small_buttons:
next_button = bui.buttonwidget(
parent=rootc,
autoselect=True,
position=(h_offs + 130 - 75, v_offs + 40),
size=(100, 50),
label='',
button_type='square',
on_activate_call=call,
color=color,
enable_sound=button_sound,
)
bui.imagewidget(
parent=rootc,
draw_controller=next_button,
position=(h_offs + 130 - 60 + 12, v_offs + 40),
size=(50, 50),
texture=self._next_level_icon_texture,
opacity=image_opacity,
)
else:
next_button = bui.buttonwidget(
parent=rootc,
autoselect=True,
position=(h_offs + 130 - 60, v_offs),
size=(110, 85),
label='',
on_activate_call=call,
color=color,
enable_sound=button_sound,
)
bui.imagewidget(
parent=rootc,
draw_controller=next_button,
position=(h_offs + 130 - 60 + 12, v_offs + 5),
size=(80, 80),
texture=self._next_level_icon_texture,
opacity=image_opacity,
)
x_offs_extra = 0 if show_next_button else -100
self._corner_button_offs = (
h_offs + 300.0 + x_offs_extra,
v_offs + 519.0,
)
bui.containerwidget(
edit=rootc,
selected_child=(
next_button
if (self._newly_complete and self._victory and show_next_button)
else restart_button
),
on_cancel_call=menu_button.activate,
)
def _player_press(self) -> None:
# (Only for headless builds).
# If this activity is a good 'end point', ask server-mode just
# once if it wants to do anything special like switch sessions
# or kill the app.
if (
self._allow_server_transition
and bs.app.classic is not None
and bs.app.classic.server is not None
and self._server_transitioning is None
):
self._server_transitioning = (
bs.app.classic.server.handle_transition()
)
assert isinstance(self._server_transitioning, bool)
# If server-mode is handling this, don't do anything ourself.
if self._server_transitioning is True:
return
# Otherwise restart current level.
self._campaign.set_selected_level(self._level_name)
with self.context:
self.end({'outcome': 'restart'})
def _safe_assign(self, player: bs.Player) -> None:
# (Only for headless builds).
# Just to be extra careful, don't assign if we're transitioning out.
# (though theoretically that should be ok).
if not self.is_transitioning_out() and player:
player.assigninput(
(
bs.InputType.JUMP_PRESS,
bs.InputType.PUNCH_PRESS,
bs.InputType.BOMB_PRESS,
bs.InputType.PICK_UP_PRESS,
),
self._player_press,
)
[docs]
@override
def on_player_join(self, player: bs.Player) -> None:
super().on_player_join(player)
if bs.app.classic is not None and bs.app.classic.server is not None:
# Host can't press retry button, so anyone can do it instead.
time_till_assign = max(
0, self._birth_time + self._min_view_time - bs.time()
)
bs.timer(time_till_assign, bs.WeakCall(self._safe_assign, player))
[docs]
@override
def on_begin(self) -> None:
# pylint: disable=too-many-statements
# pylint: disable=too-many-branches
# pylint: disable=too-many-locals
super().on_begin()
app = bs.app
env = app.env
plus = app.plus
assert plus is not None
self._begin_time = bs.time()
# Calc whether the level is complete and other stuff.
levels = self._campaign.levels
level = self._campaign.getlevel(self._level_name)
self._was_complete = level.complete
self._is_complete = self._was_complete or self._victory
self._newly_complete = self._is_complete and not self._was_complete
self._is_more_levels = (
level.index < len(levels) - 1
) and self._campaign.sequential
# Any time we complete a level, set the next one as unlocked.
if self._is_complete and self._is_more_levels:
plus.add_v1_account_transaction(
{
'type': 'COMPLETE_LEVEL',
'campaign': self._campaign.name,
'level': self._level_name,
}
)
self._next_level_name = levels[level.index + 1].name
# If this is the first time we completed it, set the next one
# as current.
if self._newly_complete:
cfg = app.config
cfg['Selected Coop Game'] = (
self._campaign.name + ':' + self._next_level_name
)
cfg.commit()
self._campaign.set_selected_level(self._next_level_name)
bs.timer(1.0, bs.WeakCall(self.request_ui))
if (
self._is_complete
and self._victory
and self._is_more_levels
and not (env.demo or env.arcade)
):
Text(
(
bs.Lstr(
value='${A}:\n',
subs=[('${A}', bs.Lstr(resource='levelUnlockedText'))],
)
if self._newly_complete
else bs.Lstr(
value='${A}:\n',
subs=[('${A}', bs.Lstr(resource='nextLevelText'))],
)
),
transition=Text.Transition.IN_RIGHT,
transition_delay=5.2,
flash=self._newly_complete,
scale=0.54,
h_align=Text.HAlign.CENTER,
maxwidth=270,
color=(0.5, 0.7, 0.5, 1),
position=(270, -235),
).autoretain()
assert self._next_level_name is not None
Text(
bs.Lstr(translate=('coopLevelNames', self._next_level_name)),
transition=Text.Transition.IN_RIGHT,
transition_delay=5.2,
flash=self._newly_complete,
scale=0.7,
h_align=Text.HAlign.CENTER,
maxwidth=205,
color=(0.5, 0.7, 0.5, 1),
position=(270, -255),
).autoretain()
if self._newly_complete:
bs.timer(5.2, self._cashregistersound.play)
bs.timer(5.2, self._dingsound.play)
offs_x = -195
if len(self._playerinfos) > 1:
pstr = bs.Lstr(
value='- ${A} -',
subs=[
(
'${A}',
bs.Lstr(
resource='multiPlayerCountText',
subs=[('${COUNT}', str(len(self._playerinfos)))],
),
)
],
)
else:
pstr = bs.Lstr(
value='- ${A} -',
subs=[('${A}', bs.Lstr(resource='singlePlayerCountText'))],
)
ZoomText(
self._campaign.getlevel(self._level_name).displayname,
maxwidth=800,
flash=False,
trail=False,
color=(0.5, 1, 0.5, 1),
h_align='center',
scale=0.4,
position=(0, 292),
jitter=1.0,
).autoretain()
Text(
pstr,
maxwidth=300,
transition=Text.Transition.FADE_IN,
scale=0.7,
h_align=Text.HAlign.CENTER,
v_align=Text.VAlign.CENTER,
color=(0.5, 0.7, 0.5, 1),
position=(0, 230),
).autoretain()
if app.classic is not None and app.classic.server is None:
# If we're running in normal non-headless build, show this text
# because only host can continue the game.
adisp = plus.get_v1_account_display_string()
txt = Text(
bs.Lstr(
resource='waitingForHostText', subs=[('${HOST}', adisp)]
),
maxwidth=300,
transition=Text.Transition.FADE_IN,
transition_delay=8.0,
scale=0.85,
h_align=Text.HAlign.CENTER,
v_align=Text.VAlign.CENTER,
color=(1, 1, 0, 1),
position=(0, -230),
).autoretain()
assert txt.node
txt.node.client_only = True
else:
# In headless build, anyone can continue the game.
sval = bs.Lstr(resource='pressAnyButtonPlayAgainText')
Text(
sval,
v_attach=Text.VAttach.BOTTOM,
h_align=Text.HAlign.CENTER,
flash=True,
vr_depth=50,
position=(0, 60),
scale=0.8,
color=(0.5, 0.7, 0.5, 0.5),
transition=Text.Transition.IN_BOTTOM_SLOW,
transition_delay=self._min_view_time,
).autoretain()
if self._score is not None:
bs.timer(0.35, self._score_display_sound_small.play)
# Vestigial remain; this stuff should just be instance vars.
self._show_info = {}
if self._score is not None:
bs.timer(0.8, bs.WeakCall(self._show_score_val, offs_x))
else:
bs.pushcall(bs.WeakCall(self._show_fail))
self._name_str = name_str = ', '.join(
[p.name for p in self._playerinfos]
)
self._score_loading_status = Text(
bs.Lstr(
value='${A}...',
subs=[('${A}', bs.Lstr(resource='loadingText'))],
),
position=(280, 150 + 30),
color=(1, 1, 1, 0.4),
transition=Text.Transition.FADE_IN,
scale=0.7,
transition_delay=2.0,
)
if self._score is not None and self._submit_score:
bs.timer(0.4, bs.WeakCall(self._play_drumroll))
# Add us to high scores, filter, and store.
our_high_scores_all = self._campaign.getlevel(
self._level_name
).get_high_scores()
our_high_scores = our_high_scores_all.setdefault(
str(len(self._playerinfos)) + ' Player', []
)
if self._score is not None:
our_score: list | None = [
self._score,
{
'players': [
{'name': p.name, 'character': p.character}
for p in self._playerinfos
]
},
]
our_high_scores.append(our_score)
else:
our_score = None
try:
our_high_scores.sort(
reverse=self._score_order == 'increasing', key=lambda x: x[0]
)
except Exception:
logging.exception('Error sorting scores.')
print(f'our_high_scores: {our_high_scores}')
del our_high_scores[10:]
if self._score is not None:
sver = self._campaign.getlevel(
self._level_name
).get_score_version_string()
plus.add_v1_account_transaction(
{
'type': 'SET_LEVEL_LOCAL_HIGH_SCORES',
'campaign': self._campaign.name,
'level': self._level_name,
'scoreVersion': sver,
'scores': our_high_scores_all,
}
)
if plus.get_v1_account_state() != 'signed_in':
# We expect this only in kiosk mode; complain otherwise.
if not (env.demo or env.arcade):
logging.error('got not-signed-in at score-submit; unexpected')
bs.pushcall(bs.WeakCall(self._got_score_results, None))
else:
assert self._game_name_str is not None
assert self._game_config_str is not None
plus.submit_score(
self._game_name_str,
self._game_config_str,
name_str,
self._score,
bs.WeakCall(self._got_score_results),
order=self._score_order,
tournament_id=self.session.tournament_id,
score_type=self._score_type,
campaign=self._campaign.name,
level=self._level_name,
)
# Apply the transactions we've been adding locally.
plus.run_v1_account_transactions()
# If we're not doing the world's-best button, just show a title
# instead.
ts_height = 300
ts_h_offs = 290
v_offs = 40
txt = Text(
(
bs.Lstr(resource='tournamentStandingsText')
if self.session.tournament_id is not None
else (
bs.Lstr(resource='worldsBestScoresText')
if self._score_type == 'points'
else bs.Lstr(resource='worldsBestTimesText')
)
),
maxwidth=210,
position=(ts_h_offs - 10, ts_height / 2 + 25 + v_offs + 20),
transition=Text.Transition.IN_LEFT,
v_align=Text.VAlign.CENTER,
scale=1.2,
transition_delay=2.2,
).autoretain()
# If we've got a button on the server, only show this on clients.
if self._should_show_worlds_best_button():
assert txt.node
txt.node.client_only = True
ts_height = 300
ts_h_offs = -480
v_offs = 40
Text(
(
bs.Lstr(resource='yourBestScoresText')
if self._score_type == 'points'
else bs.Lstr(resource='yourBestTimesText')
),
maxwidth=210,
position=(ts_h_offs - 10, ts_height / 2 + 25 + v_offs + 20),
transition=Text.Transition.IN_RIGHT,
v_align=Text.VAlign.CENTER,
scale=1.2,
transition_delay=1.8,
).autoretain()
display_scores = list(our_high_scores)
display_count = 5
while len(display_scores) < display_count:
display_scores.append((0, None))
showed_ours = False
h_offs_extra = 85 if self._score_type == 'points' else 130
v_offs_extra = 20
v_offs_names = 0
scale = 1.0
p_count = len(self._playerinfos)
h_offs_extra -= 75
if p_count > 1:
h_offs_extra -= 20
if p_count == 2:
scale = 0.9
elif p_count == 3:
scale = 0.65
elif p_count == 4:
scale = 0.5
times: list[tuple[float, float]] = []
for i in range(display_count):
times.insert(
random.randrange(0, len(times) + 1),
(1.9 + i * 0.05, 2.3 + i * 0.05),
)
for i in range(display_count):
try:
if display_scores[i][1] is None:
name_str = '-'
else:
name_str = ', '.join(
[p['name'] for p in display_scores[i][1]['players']]
)
except Exception:
logging.exception(
'Error calcing name_str for %s.', display_scores
)
name_str = '-'
if display_scores[i] == our_score and not showed_ours:
flash = True
color0 = (0.6, 0.4, 0.1, 1.0)
color1 = (0.6, 0.6, 0.6, 1.0)
tdelay1 = 3.7
tdelay2 = 3.7
showed_ours = True
else:
flash = False
color0 = (0.6, 0.4, 0.1, 1.0)
color1 = (0.6, 0.6, 0.6, 1.0)
tdelay1 = times[i][0]
tdelay2 = times[i][1]
Text(
(
str(display_scores[i][0])
if self._score_type == 'points'
else bs.timestring((display_scores[i][0] * 10) / 1000.0)
),
position=(
ts_h_offs + 20 + h_offs_extra,
v_offs_extra
+ ts_height / 2
+ -ts_height * (i + 1) / 10
+ v_offs
+ 11.0,
),
h_align=Text.HAlign.RIGHT,
v_align=Text.VAlign.CENTER,
color=color0,
flash=flash,
transition=Text.Transition.IN_RIGHT,
transition_delay=tdelay1,
).autoretain()
Text(
bs.Lstr(value=name_str),
position=(
ts_h_offs + 35 + h_offs_extra,
v_offs_extra
+ ts_height / 2
+ -ts_height * (i + 1) / 10
+ v_offs_names
+ v_offs
+ 11.0,
),
maxwidth=80.0 + 100.0 * len(self._playerinfos),
v_align=Text.VAlign.CENTER,
color=color1,
flash=flash,
scale=scale,
transition=Text.Transition.IN_RIGHT,
transition_delay=tdelay2,
).autoretain()
# Show achievements for this level.
ts_height = -150
ts_h_offs = -480
v_offs = 40
# Only make this if we don't have the button (never want clients
# to see it so no need for client-only version, etc).
if self._have_achievements:
if not self._account_has_achievements:
Text(
bs.Lstr(resource='achievementsText'),
position=(ts_h_offs - 10, ts_height / 2 + 25 + v_offs + 3),
maxwidth=210,
host_only=True,
transition=Text.Transition.IN_RIGHT,
v_align=Text.VAlign.CENTER,
scale=1.2,
transition_delay=2.8,
).autoretain()
assert self._game_name_str is not None
assert bs.app.classic is not None
achievements = bs.app.classic.ach.achievements_for_coop_level(
self._game_name_str
)
hval = -455
vval = -100
tdelay = 0.0
for ach in achievements:
ach.create_display(hval, vval + v_offs, 3.0 + tdelay)
vval -= 55
tdelay += 0.250
bs.timer(5.0, bs.WeakCall(self._show_tips))
def _play_drumroll(self) -> None:
bs.NodeActor(
bs.newnode(
'sound',
attrs={
'sound': self.drum_roll_sound,
'positional': False,
'loop': False,
},
)
).autoretain()
def _got_friend_score_results(self, results: list[Any] | None) -> None:
# pylint: disable=too-many-locals
# pylint: disable=too-many-branches
# pylint: disable=too-many-statements
from efro.util import asserttype
# delay a bit if results come in too fast
assert self._begin_time is not None
base_delay = max(0, 1.9 - (bs.time() - self._begin_time))
ts_height = 300
ts_h_offs = -550
v_offs = 30
# Report in case of error.
if results is None:
self._friends_loading_status = Text(
bs.Lstr(resource='friendScoresUnavailableText'),
maxwidth=330,
position=(-475, 150 + v_offs),
color=(1, 1, 1, 0.4),
transition=Text.Transition.FADE_IN,
transition_delay=base_delay + 0.8,
scale=0.7,
)
return
self._friends_loading_status = None
# Ok, it looks like we aren't able to reliably get a just-submitted
# result returned in the score list, so we need to look for our score
# in this list and replace it if ours is better or add ours otherwise.
if self._score is not None:
our_score_entry = [self._score, 'Me', True]
for score in results:
if score[2]:
if self._score_order == 'increasing':
our_score_entry[0] = max(score[0], self._score)
else:
our_score_entry[0] = min(score[0], self._score)
results.remove(score)
break
results.append(our_score_entry)
results.sort(
reverse=self._score_order == 'increasing',
key=lambda x: asserttype(x[0], int),
)
# If we're not submitting our own score, we still want to change the
# name of our own score to 'Me'.
else:
for score in results:
if score[2]:
score[1] = 'Me'
break
h_offs_extra = 80 if self._score_type == 'points' else 130
v_offs_extra = 20
v_offs_names = 0
scale = 1.0
# Make sure there's at least 5.
while len(results) < 5:
results.append([0, '-', False])
results = results[:5]
times: list[tuple[float, float]] = []
for i in range(len(results)):
times.insert(
random.randrange(0, len(times) + 1),
(base_delay + i * 0.05, base_delay + 0.3 + i * 0.05),
)
for i, tval in enumerate(results):
score = int(tval[0])
name_str = tval[1]
is_me = tval[2]
if is_me and score == self._score:
flash = True
color0 = (0.6, 0.4, 0.1, 1.0)
color1 = (0.6, 0.6, 0.6, 1.0)
tdelay1 = base_delay + 1.0
tdelay2 = base_delay + 1.0
else:
flash = False
if is_me:
color0 = (0.6, 0.4, 0.1, 1.0)
color1 = (0.9, 1.0, 0.9, 1.0)
else:
color0 = (0.6, 0.4, 0.1, 1.0)
color1 = (0.6, 0.6, 0.6, 1.0)
tdelay1 = times[i][0]
tdelay2 = times[i][1]
if name_str != '-':
Text(
(
str(score)
if self._score_type == 'points'
else bs.timestring((score * 10) / 1000.0)
),
position=(
ts_h_offs + 20 + h_offs_extra,
v_offs_extra
+ ts_height / 2
+ -ts_height * (i + 1) / 10
+ v_offs
+ 11.0,
),
h_align=Text.HAlign.RIGHT,
v_align=Text.VAlign.CENTER,
color=color0,
flash=flash,
transition=Text.Transition.IN_RIGHT,
transition_delay=tdelay1,
).autoretain()
else:
if is_me:
print('Error: got empty name_str on score result:', tval)
Text(
bs.Lstr(value=name_str),
position=(
ts_h_offs + 35 + h_offs_extra,
v_offs_extra
+ ts_height / 2
+ -ts_height * (i + 1) / 10
+ v_offs_names
+ v_offs
+ 11.0,
),
color=color1,
maxwidth=160.0,
v_align=Text.VAlign.CENTER,
flash=flash,
scale=scale,
transition=Text.Transition.IN_RIGHT,
transition_delay=tdelay2,
).autoretain()
def _on_v2_score_results(
self, response: bacommon.bs.ScoreSubmitResponse | Exception
) -> None:
if isinstance(response, Exception):
logging.debug('Got error score-submit response: %s', response)
return
assert isinstance(response, bacommon.bs.ScoreSubmitResponse)
# Aim to have these effects run shortly after the final rating
# hit happens.
with self.context:
assert self._begin_time is not None
delay = max(0, 5.5 - (bs.time() - self._begin_time))
assert bui.app.classic is not None
bs.timer(
delay,
strict_partial(
bui.app.classic.run_bs_client_effects, response.effects
),
)
def _got_score_results(self, results: dict[str, Any] | None) -> None:
# pylint: disable=too-many-locals
# pylint: disable=too-many-branches
# pylint: disable=too-many-statements
plus = bs.app.plus
assert plus is not None
classic = bs.app.classic
assert classic is not None
# We need to manually run this in the context of our activity
# and only if we aren't shutting down.
# (really should make the submit_score call handle that stuff itself)
if self.expired:
return
with self.context:
# Delay a bit if results come in too fast.
assert self._begin_time is not None
base_delay = max(0, 2.7 - (bs.time() - self._begin_time))
# v_offs = 20
v_offs = 64
if results is None:
self._score_loading_status = Text(
bs.Lstr(resource='worldScoresUnavailableText'),
position=(280, 130 + v_offs),
color=(1, 1, 1, 0.4),
transition=Text.Transition.FADE_IN,
transition_delay=base_delay + 0.3,
scale=0.7,
)
else:
# If there's a score-uuid bundled, ship it along to the
# v2 master server to ask about any rewards from that
# end.
score_token = results.get('token')
if (
isinstance(score_token, str)
and plus.accounts.primary is not None
):
with plus.accounts.primary:
plus.cloud.send_message_cb(
bacommon.bs.ScoreSubmitMessage(score_token),
on_response=bui.WeakCall(self._on_v2_score_results),
)
self._score_link = results['link']
assert self._score_link is not None
# Prepend our master-server addr if its a relative addr.
if not self._score_link.startswith(
'http://'
) and not self._score_link.startswith('https://'):
self._score_link = (
plus.get_master_server_address()
+ '/'
+ self._score_link
)
self._score_loading_status = None
if 'tournamentSecondsRemaining' in results:
secs_remaining = results['tournamentSecondsRemaining']
assert isinstance(secs_remaining, int)
self._tournament_time_remaining = secs_remaining
self._tournament_time_remaining_text_timer = bs.BaseTimer(
1.0,
bs.WeakCall(
self._update_tournament_time_remaining_text
),
repeat=True,
)
assert self._show_info is not None
self._show_info['results'] = results
if results is not None:
if results['tops'] != '':
self._show_info['tops'] = results['tops']
else:
self._show_info['tops'] = []
offs_x = -195
available = self._show_info['results'] is not None
if self._score is not None:
bs.basetimer(
(1.5 + base_delay),
bs.WeakCall(self._show_world_rank, offs_x),
)
ts_h_offs = 280
ts_height = 300
# Show world tops.
if available:
# Show the number of games represented by this
# list (except for in tournaments).
if self.session.tournament_id is None:
Text(
bs.Lstr(
resource='lastGamesText',
subs=[
(
'${COUNT}',
str(self._show_info['results']['total']),
)
],
),
position=(
ts_h_offs - 35 + 95,
ts_height / 2 + 6 + v_offs - 41,
),
color=(0.4, 0.4, 0.4, 1.0),
scale=0.7,
transition=Text.Transition.IN_RIGHT,
transition_delay=base_delay + 0.3,
).autoretain()
else:
v_offs += 40
h_offs_extra = 0
v_offs_names = 0
scale = 1.0
p_count = len(self._playerinfos)
if p_count > 1:
h_offs_extra -= 40
if self._score_type != 'points':
h_offs_extra += 60
if p_count == 2:
scale = 0.9
elif p_count == 3:
scale = 0.65
elif p_count == 4:
scale = 0.5
# Make sure there's at least 10.
while len(self._show_info['tops']) < 10:
self._show_info['tops'].append([0, '-'])
times: list[tuple[float, float]] = []
for i in range(len(self._show_info['tops'])):
times.insert(
random.randrange(0, len(times) + 1),
(base_delay + i * 0.05, base_delay + 0.4 + i * 0.05),
)
# Conundrum: We want to place line numbers to the
# left of our score column based on the largest
# score width. However scores may use Lstrs and thus
# may have different widths in different languages.
# We don't want to bake down the Lstrs we display
# because then clients can't view scores in their
# own language. So as a compromise lets measure
# max-width based on baked down Lstrs but then
# display regular Lstrs with max-width set based on
# that. Hopefully that'll look reasonable for most
# languages.
max_score_width = 10.0
for tval in self._show_info['tops']:
score = int(tval[0])
name_str = tval[1]
if name_str != '-':
max_score_width = max(
max_score_width,
bui.get_string_width(
(
str(score)
if self._score_type == 'points'
else bs.timestring(
(score * 10) / 1000.0
).evaluate()
),
suppress_warning=True,
),
)
for i, tval in enumerate(self._show_info['tops']):
score = int(tval[0])
name_str = tval[1]
if self._name_str == name_str and self._score == score:
flash = True
color0 = (0.6, 0.4, 0.1, 1.0)
color1 = (0.6, 0.6, 0.6, 1.0)
tdelay1 = base_delay + 1.0
tdelay2 = base_delay + 1.0
else:
flash = False
if self._name_str == name_str:
color0 = (0.6, 0.4, 0.1, 1.0)
color1 = (0.9, 1.0, 0.9, 1.0)
else:
color0 = (0.6, 0.4, 0.1, 1.0)
color1 = (0.6, 0.6, 0.6, 1.0)
tdelay1 = times[i][0]
tdelay2 = times[i][1]
if name_str != '-':
sstr = (
str(score)
if self._score_type == 'points'
else bs.timestring((score * 10) / 1000.0)
)
# Line number.
Text(
str(i + 1),
position=(
ts_h_offs
+ 20
+ h_offs_extra
- max_score_width
- 8.0,
ts_height / 2
+ -ts_height * (i + 1) / 10
+ v_offs
- 30.0,
),
scale=0.5,
h_align=Text.HAlign.RIGHT,
v_align=Text.VAlign.CENTER,
color=(0.3, 0.3, 0.3),
transition=Text.Transition.IN_LEFT,
transition_delay=tdelay1,
).autoretain()
# Score.
Text(
sstr,
position=(
ts_h_offs + 20 + h_offs_extra,
ts_height / 2
+ -ts_height * (i + 1) / 10
+ v_offs
- 30.0,
),
maxwidth=max_score_width,
h_align=Text.HAlign.RIGHT,
v_align=Text.VAlign.CENTER,
color=color0,
flash=flash,
transition=Text.Transition.IN_LEFT,
transition_delay=tdelay1,
).autoretain()
# Player name.
Text(
bs.Lstr(value=name_str),
position=(
ts_h_offs + 35 + h_offs_extra,
ts_height / 2
+ -ts_height * (i + 1) / 10
+ v_offs_names
+ v_offs
- 30.0,
),
maxwidth=80.0 + 100.0 * len(self._playerinfos),
v_align=Text.VAlign.CENTER,
color=color1,
flash=flash,
scale=scale,
transition=Text.Transition.IN_LEFT,
transition_delay=tdelay2,
).autoretain()
def _show_tips(self) -> None:
from bascenev1lib.actor.tipstext import TipsText
TipsText(offs_y=30).autoretain()
def _update_tournament_time_remaining_text(self) -> None:
if self._tournament_time_remaining is None:
return
self._tournament_time_remaining = max(
0, self._tournament_time_remaining - 1
)
if self._tournament_time_remaining_text is not None:
val = bs.timestring(
self._tournament_time_remaining,
centi=False,
)
self._tournament_time_remaining_text.node.text = val
def _show_world_rank(self, offs_x: float) -> None:
# FIXME: Tidy this up.
# pylint: disable=too-many-locals
# pylint: disable=too-many-branches
# pylint: disable=too-many-statements
assert bs.app.classic is not None
assert self._show_info is not None
available = self._show_info['results'] is not None
if available and self._submit_score:
error = (
self._show_info['results']['error']
if 'error' in self._show_info['results']
else None
)
rank = self._show_info['results']['rank']
total = self._show_info['results']['total']
rating = (
10.0
if total == 1
else 10.0 * (1.0 - (float(rank - 1) / (total - 1)))
)
player_rank = self._show_info['results']['playerRank']
best_player_rank = self._show_info['results']['bestPlayerRank']
else:
error = False
rating = None
player_rank = None
best_player_rank = None
# If we've got tournament-seconds-remaining, show it.
if self._tournament_time_remaining is not None:
Text(
bs.Lstr(resource='coopSelectWindow.timeRemainingText'),
position=(-360, -70 - 100),
color=(1, 1, 1, 0.7),
h_align=Text.HAlign.CENTER,
v_align=Text.VAlign.CENTER,
transition=Text.Transition.FADE_IN,
scale=0.8,
maxwidth=300,
transition_delay=2.0,
).autoretain()
self._tournament_time_remaining_text = Text(
'',
position=(-360, -110 - 100),
color=(1, 1, 1, 0.7),
h_align=Text.HAlign.CENTER,
v_align=Text.VAlign.CENTER,
transition=Text.Transition.FADE_IN,
scale=1.6,
maxwidth=150,
transition_delay=2.0,
)
# If we're a tournament, show prizes.
try:
assert bs.app.classic is not None
tournament_id = self.session.tournament_id
if tournament_id is not None:
if tournament_id in bs.app.classic.accounts.tournament_info:
tourney_info = bs.app.classic.accounts.tournament_info[
tournament_id
]
# pylint: disable=useless-suppression
# pylint: disable=unbalanced-tuple-unpacking
(pr1, pv1, pr2, pv2, pr3, pv3) = (
bs.app.classic.get_tournament_prize_strings(
tourney_info, include_tickets=False
)
)
# pylint: enable=unbalanced-tuple-unpacking
# pylint: enable=useless-suppression
Text(
bs.Lstr(resource='coopSelectWindow.prizesText'),
position=(-360, -70 + 77),
color=(1, 1, 1, 0.7),
h_align=Text.HAlign.CENTER,
v_align=Text.VAlign.CENTER,
transition=Text.Transition.FADE_IN,
scale=1.0,
maxwidth=300,
transition_delay=2.0,
).autoretain()
vval = -107 + 70
for i, rng, val in (
(0, pr1, pv1),
(1, pr2, pv2),
(2, pr3, pv3),
):
Text(
rng,
position=(-430 + 10, vval),
color=(1, 1, 1, 0.7),
h_align=Text.HAlign.RIGHT,
v_align=Text.VAlign.CENTER,
transition=Text.Transition.FADE_IN,
scale=0.6,
maxwidth=300,
transition_delay=2.0,
).autoretain()
Text(
val,
position=(-410 + 10, vval),
color=(0.7, 0.7, 0.7, 1.0),
h_align=Text.HAlign.LEFT,
v_align=Text.VAlign.CENTER,
transition=Text.Transition.FADE_IN,
scale=0.8,
maxwidth=300,
transition_delay=2.0,
).autoretain()
bs.app.classic.create_in_game_tournament_prize_image(
tourney_info, i, (-410 + 70, vval)
)
vval -= 35
except Exception:
logging.exception('Error showing prize ranges.')
if self._do_new_rating:
if error:
ZoomText(
bs.Lstr(resource='failText'),
flash=True,
trail=True,
scale=1.0 if available else 0.333,
tilt_translate=0.11,
h_align='center',
position=(190 + offs_x, -60),
maxwidth=200,
jitter=1.0,
).autoretain()
Text(
bs.Lstr(translate=('serverResponses', error)),
position=(0, -140),
color=(1, 1, 1, 0.7),
h_align=Text.HAlign.CENTER,
v_align=Text.VAlign.CENTER,
transition=Text.Transition.FADE_IN,
scale=0.9,
maxwidth=400,
transition_delay=1.0,
).autoretain()
elif self._submit_score:
ZoomText(
(
('#' + str(player_rank))
if player_rank is not None
else bs.Lstr(resource='unavailableText')
),
flash=True,
trail=True,
scale=1.0 if available else 0.333,
tilt_translate=0.11,
h_align='center',
position=(190 + offs_x, -60),
maxwidth=200,
jitter=1.0,
).autoretain()
Text(
bs.Lstr(
value='${A}:',
subs=[('${A}', bs.Lstr(resource='rankText'))],
),
position=(0, 36),
maxwidth=300,
transition=Text.Transition.FADE_IN,
h_align=Text.HAlign.CENTER,
v_align=Text.VAlign.CENTER,
transition_delay=0,
).autoretain()
if best_player_rank is not None:
Text(
bs.Lstr(
resource='currentStandingText',
fallback_resource='bestRankText',
subs=[('${RANK}', str(best_player_rank))],
),
position=(0, -155),
color=(1, 1, 1, 0.7),
h_align=Text.HAlign.CENTER,
transition=Text.Transition.FADE_IN,
scale=0.7,
transition_delay=1.0,
).autoretain()
else:
assert rating is not None
ZoomText(
(
f'{rating:.1f}'
if available
else bs.Lstr(resource='unavailableText')
),
flash=True,
trail=True,
scale=0.6 if available else 0.333,
tilt_translate=0.11,
h_align='center',
position=(190 + offs_x, -94),
maxwidth=200,
jitter=1.0,
).autoretain()
if available:
if rating >= 9.5:
stars = 3
elif rating >= 7.5:
stars = 2
elif rating > 0.0:
stars = 1
else:
stars = 0
star_tex = bs.gettexture('star')
star_x = 135 + offs_x
for _i in range(stars):
img = bs.NodeActor(
bs.newnode(
'image',
attrs={
'texture': star_tex,
'position': (star_x, -16),
'scale': (62, 62),
'opacity': 1.0,
'color': (2.2, 1.2, 0.3),
'absolute_scale': True,
},
)
).autoretain()
assert img.node
bs.animate(img.node, 'opacity', {0.15: 0, 0.4: 1})
star_x += 60
for _i in range(3 - stars):
img = bs.NodeActor(
bs.newnode(
'image',
attrs={
'texture': star_tex,
'position': (star_x, -16),
'scale': (62, 62),
'opacity': 1.0,
'color': (0.3, 0.3, 0.3),
'absolute_scale': True,
},
)
).autoretain()
assert img.node
bs.animate(img.node, 'opacity', {0.15: 0, 0.4: 1})
star_x += 60
def dostar(
count: int, xval: float, offs_y: float, score: str
) -> None:
Text(
score + ' =',
position=(xval, -64 + offs_y),
color=(0.6, 0.6, 0.6, 0.6),
h_align=Text.HAlign.CENTER,
v_align=Text.VAlign.CENTER,
transition=Text.Transition.FADE_IN,
scale=0.4,
transition_delay=1.0,
).autoretain()
stx = xval + 20
for _i2 in range(count):
img2 = bs.NodeActor(
bs.newnode(
'image',
attrs={
'texture': star_tex,
'position': (stx, -64 + offs_y),
'scale': (12, 12),
'opacity': 0.7,
'color': (2.2, 1.2, 0.3),
'absolute_scale': True,
},
)
).autoretain()
assert img2.node
bs.animate(img2.node, 'opacity', {1.0: 0.0, 1.5: 0.5})
stx += 13.0
dostar(1, -44 - 30, -112, '0.0')
dostar(2, 10 - 30, -112, '7.5')
dostar(3, 77 - 30, -112, '9.5')
try:
best_rank = self._campaign.getlevel(self._level_name).rating
except Exception:
best_rank = 0.0
if available:
Text(
bs.Lstr(
resource='outOfText',
subs=[
(
'${RANK}',
str(int(self._show_info['results']['rank'])),
),
(
'${ALL}',
str(self._show_info['results']['total']),
),
],
),
position=(0, -155 if self._newly_complete else -145),
color=(1, 1, 1, 0.7),
h_align=Text.HAlign.CENTER,
transition=Text.Transition.FADE_IN,
scale=0.55,
transition_delay=1.0,
).autoretain()
new_best = best_rank > self._old_best_rank and best_rank > 0.0
was_string = bs.Lstr(
value=' ${A}',
subs=[
('${A}', bs.Lstr(resource='scoreWasText')),
('${COUNT}', str(self._old_best_rank)),
],
)
if not self._newly_complete:
Text(
(
bs.Lstr(
value='${A}${B}',
subs=[
(
'${A}',
bs.Lstr(resource='newPersonalBestText'),
),
('${B}', was_string),
],
)
if new_best
else bs.Lstr(
resource='bestRatingText',
subs=[('${RATING}', str(best_rank))],
)
),
position=(0, -165),
color=(1, 1, 1, 0.7),
flash=new_best,
h_align=Text.HAlign.CENTER,
transition=(
Text.Transition.IN_RIGHT
if new_best
else Text.Transition.FADE_IN
),
scale=0.5,
transition_delay=1.0,
).autoretain()
Text(
bs.Lstr(
value='${A}:',
subs=[('${A}', bs.Lstr(resource='ratingText'))],
),
position=(0, 36),
maxwidth=300,
transition=Text.Transition.FADE_IN,
h_align=Text.HAlign.CENTER,
v_align=Text.VAlign.CENTER,
transition_delay=0,
).autoretain()
if self._submit_score:
bs.timer(0.35, self._score_display_sound.play)
if not error:
bs.timer(0.35, self.cymbal_sound.play)
def _show_fail(self) -> None:
ZoomText(
bs.Lstr(resource='failText'),
maxwidth=300,
flash=False,
trail=True,
h_align='center',
tilt_translate=0.11,
position=(0, 40),
jitter=1.0,
).autoretain()
if self._fail_message is not None:
Text(
self._fail_message,
h_align=Text.HAlign.CENTER,
position=(0, -130),
maxwidth=300,
color=(1, 1, 1, 0.5),
transition=Text.Transition.FADE_IN,
transition_delay=1.0,
).autoretain()
bs.timer(0.35, self._score_display_sound.play)
def _show_score_val(self, offs_x: float) -> None:
assert self._score_type is not None
assert self._score is not None
ZoomText(
(
str(self._score)
if self._score_type == 'points'
else bs.timestring((self._score * 10) / 1000.0)
),
maxwidth=300,
flash=True,
trail=True,
scale=1.0 if self._score_type == 'points' else 0.6,
h_align='center',
tilt_translate=0.11,
position=(190 + offs_x, 115),
jitter=1.0,
).autoretain()
Text(
(
bs.Lstr(
value='${A}:',
subs=[('${A}', bs.Lstr(resource='finalScoreText'))],
)
if self._score_type == 'points'
else bs.Lstr(
value='${A}:',
subs=[('${A}', bs.Lstr(resource='finalTimeText'))],
)
),
maxwidth=300,
position=(0, 200),
transition=Text.Transition.FADE_IN,
h_align=Text.HAlign.CENTER,
v_align=Text.VAlign.CENTER,
transition_delay=0,
).autoretain()
bs.timer(0.35, self._score_display_sound.play)
# 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