# Released under the MIT License. See LICENSE for details.
#
"""Session and Activity for displaying the main menu bg."""
from __future__ import annotations # Docs-generation hack.
import random
from typing import TYPE_CHECKING, override
from bacommon.locale import LocaleResolved
import bascenev1 as bs
from bascenev1 import _classicassets, _classiccatalogassets
from bascenev1 import _uiv1assets
import bauiv1 as bui
if TYPE_CHECKING:
from typing import Any
import bacommon.classic
from bascenev1lib.actor.spazbot import DemoSpazBotSet
[docs]
class MainMenuActivity(bs.Activity[bs.Player, bs.Team]):
"""Activity showing the rotating main menu bg stuff."""
_did_initial_transition = False
def __init__(self, settings: dict):
super().__init__(settings)
self._logo_node: bs.Node | None = None
self._custom_logo_tex: bs.TextureHandle | None = None
self._word_actors: list[bs.Actor] = []
self.my_name: bs.NodeActor | None = None
self._host_is_navigating_text: bs.NodeActor | None = None
self.version: bs.NodeActor | None = None
self.beta_info: bs.NodeActor | None = None
self.beta_info_2: bs.NodeActor | None = None
self.map: bs.Map | None = None
self.bot_sets: list[DemoSpazBotSet] = []
self.trees: bs.NodeActor | None = None
self._ts = 0.86
self._language: str | None = None
self._update_timer: bs.Timer | None = None
self._attract_mode_timer: bs.Timer | None = None
self._logo_rotate_timer: bs.Timer | None = None
[docs]
@override
def on_transition_in(self) -> None:
super().on_transition_in()
from bascenev1lib.maps import ThePad
ThePad.preload()
self.map = ThePad(main_menu_style=True)
random.seed(123)
app = bs.app
env = app.env
assert app.classic is not None
plus = bs.app.plus
assert plus is not None
# Throw up some text that only clients can see so they know that
# the host is navigating menus while they're just staring at an
# empty-ish screen.
tval = _classicassets.strings.main_menu.host_navigating_menus(
host=plus.get_v1_account_display_string()
)
self._host_is_navigating_text = bs.NodeActor(
bs.newnode(
'text',
attrs={
'text': tval,
'client_only': True,
'position': (0, -200),
'flatness': 1.0,
'h_align': 'center',
},
)
)
if not self._did_initial_transition and self.my_name is not None:
assert self.my_name.node
bs.animate(self.my_name.node, 'opacity', {2.3: 0, 3.0: 1.0})
# Throw in test build info.
self.beta_info = self.beta_info_2 = None
badge_text: bs.LangStr | str | None = None
if env.variant is type(env.variant).TEST_BUILD:
badge_text = _classicassets.strings.main_menu.test_build
if badge_text is not None:
pos = (230, 35)
self.beta_info = bs.NodeActor(
bs.newnode(
'text',
attrs={
'v_attach': 'center',
'h_align': 'center',
'color': (1, 1, 1, 1),
'shadow': 0.5,
'flatness': 0.5,
'scale': 1,
'vr_depth': -60,
'position': pos,
'text': badge_text,
},
)
)
if not self._did_initial_transition:
assert self.beta_info.node
bs.animate(self.beta_info.node, 'opacity', {1.3: 0, 1.8: 1.0})
trees_mesh = _classicassets.meshes.trees.get()
trees_texture = _classicassets.textures.trees_color.get()
gnode = self.globalsnode
gnode.camera_mode = 'rotate'
tint = (1.14, 1.1, 1.0)
gnode.tint = tint
gnode.ambient_color = (1.06, 1.04, 1.03)
gnode.vignette_outer = (0.45, 0.55, 0.54)
gnode.vignette_inner = (0.99, 0.98, 0.98)
self.trees = bs.NodeActor(
bs.newnode(
'terrain',
attrs={
'mesh': trees_mesh,
'lighting': False,
'reflection': 'char',
'reflection_scale': [0.1],
'color_texture': trees_texture,
},
)
)
self._update_timer = bs.Timer(0.1, self._update, repeat=True)
self._update()
# Hopefully this won't hitch but lets space these out anyway.
bs.add_clean_frame_callback(bs.WeakCallStrict(self._start_preloads))
random.seed()
self._attract_mode_timer = bs.Timer(
3.12, self._update_attract_mode, repeat=True
)
app.classic.invoke_main_menu_ui()
def _update(self) -> None:
# pylint: disable=too-many-statements
app = bs.app
assert app.classic is not None
# Update logo in case it changes.
if self._logo_node:
custom_texture = self._get_custom_logo_tex()
if custom_texture != self._custom_logo_tex:
self._custom_logo_tex = custom_texture
self._logo_node.texture = (
custom_texture
if custom_texture is not None
else _classicassets.textures.logo
).get()
self._logo_node.mesh_opaque = (
None
if custom_texture is not None
else _classicassets.meshes.logo.get()
)
self._logo_node.mesh_transparent = (
None
if custom_texture is not None
else _classicassets.meshes.logo_transparent.get()
)
# If language has changed, recreate our logo text/graphics.
lang = app.lang.language
if lang != self._language:
self._language = lang
y = 20
base_scale = 1.1
self._word_actors = []
base_delay = 0.8
delay = base_delay
delay_inc = 0.02
# Come on faster after the first time.
if self._did_initial_transition:
base_delay = 0.0
delay = base_delay
delay_inc = 0.02
# We draw higher in kiosk mode (make sure to test this
# when making adjustments) for now we're hard-coded for
# a few languages.. should maybe look into generalizing this?..
if (
app.locale.current_locale.resolved
is LocaleResolved.CHINESE_SIMPLIFIED
):
base_x = -270.0
x = base_x - 20.0
spacing = 85.0 * base_scale
y_extra = 0.0
self._make_logo(
x - 110 + 50,
113 + y + 1.2 * y_extra,
0.34 * base_scale,
delay=base_delay + 0.1,
custom_texture=_classicassets.textures.ch_title_char1,
jitter_scale=2.0,
vr_depth_offset=-30,
)
x += spacing
delay += delay_inc
self._make_logo(
x - 10 + 50,
110 + y + 1.2 * y_extra,
0.31 * base_scale,
delay=base_delay + 0.15,
custom_texture=_classicassets.textures.ch_title_char2,
jitter_scale=2.0,
vr_depth_offset=-30,
)
x += 2.0 * spacing
delay += delay_inc
self._make_logo(
x + 180 - 140,
110 + y + 1.2 * y_extra,
0.3 * base_scale,
delay=base_delay + 0.25,
custom_texture=_classicassets.textures.ch_title_char3,
jitter_scale=2.0,
vr_depth_offset=-30,
)
x += spacing
delay += delay_inc
self._make_logo(
x + 241 - 120,
110 + y + 1.2 * y_extra,
0.31 * base_scale,
delay=base_delay + 0.3,
custom_texture=_classicassets.textures.ch_title_char4,
jitter_scale=2.0,
vr_depth_offset=-30,
)
x += spacing
delay += delay_inc
self._make_logo(
x + 300 - 90,
105 + y + 1.2 * y_extra,
0.34 * base_scale,
delay=base_delay + 0.35,
custom_texture=_classicassets.textures.ch_title_char5,
jitter_scale=2.0,
vr_depth_offset=-30,
)
self._make_logo(
base_x + 155,
146 + y + 1.2 * y_extra,
0.28 * base_scale,
delay=base_delay + 0.2,
rotate=-7,
)
else:
base_x = -170
x = base_x - 20
spacing = 55 * base_scale
y_extra = 0
xv1 = x
delay1 = delay
for shadow in (True, False):
x = xv1
delay = delay1
self._make_word(
'B',
x - 50,
y - 23 + 0.8 * y_extra,
scale=1.3 * base_scale,
delay=delay,
vr_depth_offset=3,
shadow=shadow,
)
x += spacing
delay += delay_inc
self._make_word(
'm',
x,
y + y_extra,
delay=delay,
scale=base_scale,
shadow=shadow,
)
x += spacing * 1.25
delay += delay_inc
self._make_word(
'b',
x,
y + y_extra - 10,
delay=delay,
scale=1.1 * base_scale,
vr_depth_offset=5,
shadow=shadow,
)
x += spacing * 0.85
delay += delay_inc
self._make_word(
'S',
x,
y - 25 + 0.8 * y_extra,
scale=1.35 * base_scale,
delay=delay,
vr_depth_offset=14,
shadow=shadow,
)
x += spacing
delay += delay_inc
self._make_word(
'q',
x,
y + y_extra,
delay=delay,
scale=base_scale,
shadow=shadow,
)
x += spacing * 0.9
delay += delay_inc
self._make_word(
'u',
x,
y + y_extra,
delay=delay,
scale=base_scale,
vr_depth_offset=7,
shadow=shadow,
)
x += spacing * 0.9
delay += delay_inc
self._make_word(
'a',
x,
y + y_extra,
delay=delay,
scale=base_scale,
shadow=shadow,
)
x += spacing * 0.64
delay += delay_inc
self._make_word(
'd',
x,
y + y_extra - 10,
delay=delay,
scale=1.1 * base_scale,
vr_depth_offset=6,
shadow=shadow,
)
self._make_logo(
base_x - 28,
125 + y + 1.2 * y_extra,
0.32 * base_scale,
delay=base_delay,
)
def _make_word(
self,
word: str,
x: float,
y: float,
*,
scale: float = 1.0,
delay: float = 0.0,
vr_depth_offset: float = 0.0,
shadow: bool = False,
) -> None:
# pylint: disable=too-many-branches
if shadow:
word_obj = bs.NodeActor(
bs.newnode(
'text',
attrs={
'position': (x, y),
'big': True,
'color': (0.0, 0.0, 0.2, 0.08),
'tilt_translate': 0.09,
'opacity_scales_shadow': False,
'shadow': 0.2,
'vr_depth': -130,
'v_align': 'center',
'project_scale': 0.97 * scale,
'scale': 1.0,
'text': word,
},
)
)
self._word_actors.append(word_obj)
else:
word_obj = bs.NodeActor(
bs.newnode(
'text',
attrs={
'position': (x, y),
'big': True,
'color': (1.2, 1.15, 1.15, 1.0),
'tilt_translate': 0.11,
'shadow': 0.2,
'vr_depth': -40 + vr_depth_offset,
'v_align': 'center',
'project_scale': scale,
'scale': 1.0,
'text': word,
},
)
)
self._word_actors.append(word_obj)
# Add a bit of stop-motion-y jitter to the logo (unless we're in
# VR mode in which case its best to leave things still).
if not bs.app.env.vr:
cmb: bs.Node | None
cmb2: bs.Node | None
if not shadow:
cmb = bs.newnode(
'combine', owner=word_obj.node, attrs={'size': 2}
)
else:
cmb = None
if shadow:
cmb2 = bs.newnode(
'combine', owner=word_obj.node, attrs={'size': 2}
)
else:
cmb2 = None
if not shadow:
assert cmb and word_obj.node
cmb.connectattr('output', word_obj.node, 'position')
if shadow:
assert cmb2 and word_obj.node
cmb2.connectattr('output', word_obj.node, 'position')
keys = {}
keys2 = {}
time_v = 0.0
for _i in range(10):
val = x + (random.random() - 0.5) * 0.8
val2 = x + (random.random() - 0.5) * 0.8
keys[time_v * self._ts] = val
keys2[time_v * self._ts] = val2 + 5
time_v += random.random() * 0.1
if cmb is not None:
bs.animate(cmb, 'input0', keys, loop=True)
if cmb2 is not None:
bs.animate(cmb2, 'input0', keys2, loop=True)
keys = {}
keys2 = {}
time_v = 0
for _i in range(10):
val = y + (random.random() - 0.5) * 0.8
val2 = y + (random.random() - 0.5) * 0.8
keys[time_v * self._ts] = val
keys2[time_v * self._ts] = val2 - 9
time_v += random.random() * 0.1
if cmb is not None:
bs.animate(cmb, 'input1', keys, loop=True)
if cmb2 is not None:
bs.animate(cmb2, 'input1', keys2, loop=True)
if not shadow:
assert word_obj.node
bs.animate(
word_obj.node,
'project_scale',
{delay: 0.0, delay + 0.1: scale * 1.1, delay + 0.2: scale},
)
else:
assert word_obj.node
bs.animate(
word_obj.node,
'project_scale',
{delay: 0.0, delay + 0.1: scale * 1.1, delay + 0.2: scale},
)
def _get_custom_logo_tex(self) -> bs.TextureHandle | None:
plus = bui.app.plus
assert plus is not None
if plus.get_v1_account_misc_read_val('easter', False):
return _classicassets.textures.logo_easter
return None
# Pop the logo and menu in.
def _make_logo(
self,
x: float,
y: float,
scale: float,
delay: float,
*,
custom_texture: bs.TextureHandle | None = None,
jitter_scale: float = 1.0,
rotate: float = 0.0,
vr_depth_offset: float = 0.0,
) -> None:
if custom_texture is None:
custom_texture = self._get_custom_logo_tex()
self._custom_logo_tex = custom_texture
ltex = (
custom_texture
if custom_texture is not None
else _classicassets.textures.logo
).get()
mopaque = (
None
if custom_texture is not None
else _classicassets.meshes.logo.get()
)
mtrans = (
None
if custom_texture is not None
else _classicassets.meshes.logo_transparent.get()
)
logo_attrs = {
'position': (x, y),
'texture': ltex,
'mesh_opaque': mopaque,
'mesh_transparent': mtrans,
'vr_depth': -10 + vr_depth_offset,
'rotate': rotate,
'attach': 'center',
'tilt_translate': 0.21,
'absolute_scale': True,
}
if custom_texture is None:
logo_attrs['scale'] = (2000.0, 2000.0)
logo = bs.NodeActor(bs.newnode('image', attrs=logo_attrs))
self._logo_node = logo.node
self._word_actors.append(logo)
# Add a bit of stop-motion-y jitter to the logo (unless we're in
# VR mode in which case its best to leave things still).
assert logo.node
def jitter() -> None:
if not bs.app.env.vr:
cmb = bs.newnode('combine', owner=logo.node, attrs={'size': 2})
cmb.connectattr('output', logo.node, 'position')
keys = {}
time_v = 0.0
# Gen some random keys for that stop-motion-y look
for _i in range(10):
keys[time_v] = (
x + (random.random() - 0.5) * 0.7 * jitter_scale
)
time_v += random.random() * 0.1
bs.animate(cmb, 'input0', keys, loop=True)
keys = {}
time_v = 0.0
for _i in range(10):
keys[time_v * self._ts] = (
y + (random.random() - 0.5) * 0.7 * jitter_scale
)
time_v += random.random() * 0.1
bs.animate(cmb, 'input1', keys, loop=True)
# Do a fun spinny animation on the logo the first time in.
if (
custom_texture is None
and bs.app.classic is not None
and not self._did_initial_transition
):
jitter()
cmb = bs.newnode('combine', owner=logo.node, attrs={'size': 2})
delay = 0.0
keys = {
delay: 5000.0 * scale,
delay + 0.4: 530.0 * scale,
delay + 0.45: 620.0 * scale,
delay + 0.5: 590.0 * scale,
delay + 0.55: 605.0 * scale,
delay + 0.6: 600.0 * scale,
}
bs.animate(cmb, 'input0', keys)
bs.animate(cmb, 'input1', keys)
cmb.connectattr('output', logo.node, 'scale')
keys = {
delay: 100.0,
delay + 0.4: 370.0,
delay + 0.45: 357.0,
delay + 0.5: 360.0,
}
bs.animate(logo.node, 'rotate', keys)
type(self)._did_initial_transition = True
else:
# For all other cases do a simple scale up animation.
jitter()
cmb = bs.newnode('combine', owner=logo.node, attrs={'size': 2})
keys = {
delay: 0.0,
delay + 0.1: 700.0 * scale,
delay + 0.2: 600.0 * scale,
}
bs.animate(cmb, 'input0', keys)
bs.animate(cmb, 'input1', keys)
cmb.connectattr('output', logo.node, 'scale')
def _start_preloads(self) -> None:
# FIXME: The func that calls us back doesn't save/restore state
# or check for a dead activity so we have to do that ourself.
if self.expired:
return
with self.context:
_preload1()
def _start_menu_music() -> None:
assert bs.app.classic is not None
bs.setmusic(bs.MusicType.MENU)
bui.apptimer(0.5, _start_menu_music)
def _update_attract_mode(self) -> None:
if bui.app.classic is None:
return
if not bui.app.config.resolve('Show Demos When Idle'):
return
threshold = 20.0
# If we're idle *and* have been in this activity for that long,
# flip over to our cpu demo.
if bui.get_input_idle_time() > threshold and bs.time() > threshold:
bui.app.classic.run_stress_test(
playlist_type='Random',
playlist_name='__default__',
player_count=8,
round_duration=20,
attract_mode=True,
)
def _preload1() -> None:
"""Pre-load some assets a second or two into the main menu.
Helps avoid hitches later on. Only meshes and textures belong here:
one that isn't loaded yet when first drawn gets loaded inline on the
graphics thread (a visible hitch). Sounds are deliberately left out;
one that isn't loaded when first played gets loaded inline on the
audio thread, which costs a few milliseconds of latency on that one
sound and nothing visible, while preloading all of them here was
~250ms of decode work at every launch on a phone (the factories
load theirs when a game builds them).
"""
meshes = _classicassets.meshes
for mesh in [
meshes.plastic_eyes_transparent,
meshes.player_lineup1_transparent,
meshes.player_lineup2_transparent,
meshes.player_lineup3_transparent,
meshes.player_lineup4_transparent,
meshes.angry_computer_transparent,
meshes.scroll_widget_short,
]:
_ = mesh.get()
_ = _classiccatalogassets.textures.character_icon_mask.get()
_ = _classicassets.textures.player_lineup.get()
_ = _uiv1assets.textures.lock.get()
_ = _classicassets.textures.icon_runaround.get()
_ = _classicassets.textures.icon_onslaught.get()
_ = _classicassets.textures.bg.get()
bui.apptimer(0.1, _preload2)
def _preload2() -> None:
# These mirror the meshes and textures the classic factories
# (PowerupBoxFactory, BombFactory, FlagFactory) load. We don't just
# instantiate the factories here because they load their sounds too.
_ = _classicassets.meshes.powerup.get()
_ = _classicassets.meshes.powerup_simple.get()
_ = _classicassets.textures.powerup_bomb.get()
_ = _classicassets.textures.powerup_speed.get()
_ = _classicassets.textures.powerup_punch.get()
_ = _classicassets.textures.powerup_ice_bombs.get()
_ = _classicassets.textures.powerup_sticky_bombs.get()
_ = _classicassets.textures.powerup_shield.get()
_ = _classicassets.textures.powerup_impact_bombs.get()
_ = _classicassets.textures.powerup_health.get()
_ = _classicassets.textures.powerup_land_mines.get()
_ = _classicassets.textures.powerup_curse.get()
bui.apptimer(0.1, _preload3)
def _preload3() -> None:
_ = _classicassets.meshes.bomb.get()
_ = _classicassets.meshes.bomb_sticky.get()
_ = _classicassets.meshes.impact_bomb.get()
_ = _classicassets.meshes.land_mine.get()
_ = _classicassets.meshes.tnt.get()
_ = _classicassets.textures.bomb_color.get()
_ = _classicassets.textures.bomb_color_ice.get()
_ = _classicassets.textures.bomb_sticky_color.get()
_ = _classicassets.textures.impact_bomb_color.get()
_ = _classicassets.textures.impact_bomb_color_lit.get()
_ = _classicassets.textures.land_mine.get()
_ = _classicassets.textures.land_mine_lit.get()
_ = _classicassets.textures.tnt.get()
bui.apptimer(0.2, _preload4)
def _preload4() -> None:
_ = _classicassets.textures.bar.get()
_ = _classicassets.textures.null.get()
_ = _classicassets.textures.flag_color.get()
_ = _classicassets.textures.achievement_outline.get()
_ = _classicassets.meshes.frame_inset.get()
_ = _classicassets.meshes.meter_transparent.get()
_ = _classicassets.meshes.achievement_outline.get()
[docs]
class MainMenuSession(bs.Session):
"""Session that runs the main menu environment."""
def __init__(self) -> None:
super().__init__()
self._locked = False
self.setactivity(bs.newactivity(MainMenuActivity))
[docs]
@override
def on_activity_end(self, activity: bs.Activity, results: Any) -> None:
if self._locked:
bui.unlock_all_input()
# Any ending activity leads us into the main menu one.
self.setactivity(bs.newactivity(MainMenuActivity))
[docs]
@override
def on_player_request(self, player: bs.SessionPlayer) -> bool:
# Reject all player requests.
return False
# 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