Source code for bauiv1lib.credits

# Released under the MIT License. See LICENSE for details.
#
"""Provides a window to display game credits."""

from __future__ import annotations  # Docs-generation hack.

from functools import partial
from typing import TYPE_CHECKING, override

from efro.util import strip_exception_tracebacks

import bauiv1 as bui
from bauiv1 import _classicassets

from bauiv1lib.utils import get_screen_margins

if TYPE_CHECKING:
    from typing import Sequence, TypeAlias

    #: One display row: either a plain text line, or a row of names as
    #: (inset, [(column-slot, name), ...]) drawn as individual widgets
    #: at absolute column positions.
    CreditsRow: TypeAlias = str | tuple[float, list[tuple[int, str]]]

#: Horizontal distance between name columns (text units).
_NAME_COLUMN_SPACING = 330.0

#: Name columns per row.
_NAME_COLUMN_COUNT = 3

#: Font scale for the credits body text.
_TEXT_SCALE = 0.55

#: Vertical distance between rows, in container units. This is
#: text-mesh row height (32) at our text scale — rows within a
#: multi-line widget land exactly this far apart, which is what lets
#: us batch many rows into one widget with per-row positions intact.
_ROW_HEIGHT = 32.0 * _TEXT_SCALE

#: Widget-creates performed per frame when populating content.
#: Each create costs ~1ms all-in on a midrange phone (creation plus
#: its first-draw mesh build), so this keeps per-frame logic-thread
#: cost in the single-digit-ms range while the full page still lands
#: within a fraction of a second. Content fills top-down, so the
#: initially-visible portion appears in the first batch or two.
_CREATES_PER_FRAME = 6

#: Max rows batched into a single textwidget. Batching is what keeps
#: logic-thread build cost low (widget-creation overhead dominates on
#: mobile; a phone spent ~165ms creating one widget per row/name).
#: Capped well below text-mesh vertex limits (a 50-row chunk of even
#: very long lines stays under ~20k of the 65535 max verts).
_CHUNK_ROWS = 50


def _layout_names(
    names2: Sequence[str], inset: float
) -> list[tuple[float, list[tuple[int, str]]]]:
    """Assign names to (row, column-slot) positions.

    Each name later becomes its own widget at an absolute column x, so
    columns line up exactly regardless of font metrics; measurement
    (via the OS text backend for non-Latin names, hence
    background-thread-only) is needed only to skip slots for names too
    wide for their column.
    """
    rows: list[tuple[float, list[tuple[int, str]]]] = []
    row: list[tuple[int, str]] = []
    slot = 0
    for name in names2:
        if slot >= _NAME_COLUMN_COUNT:
            rows.append((inset, row))
            row = []
            slot = 0
        row.append((slot, name))
        width = bui.get_string_width(name, suppress_warning=True)
        # Advance past however many slots this name's width covers.
        end = slot * _NAME_COLUMN_SPACING + width
        slot = max(slot + 1, int(end // _NAME_COLUMN_SPACING) + 1)
    if row:
        rows.append((inset, row))
    return rows


def _bake_widget_specs(
    rows: Sequence[CreditsRow],
) -> tuple[int, list[tuple[float, float, str]]]:
    """Bake display rows into a minimal set of widget-create specs.

    Returns ``(row_count, [(x, y_down, text), ...])`` where each spec
    is one multi-line textwidget: x in container units, y_down the
    distance of its first row below the content top, text with rows
    joined by newlines (drawn ``_ROW_HEIGHT`` apart). Consecutive
    plain rows batch into chunks; name rows batch into one spec per
    column (same y, fixed column x) so column alignment survives
    batching exactly. All layout math happens here (on the background
    compose thread) so the logic thread's job is reduced to a few
    dozen bare widget-create calls.
    """
    specs: list[tuple[float, float, str]] = []
    row_idx = 0
    total = len(rows)
    while row_idx < total:
        row = rows[row_idx]
        chunk_top = row_idx
        if isinstance(row, str):
            # A run of plain rows.
            lines: list[str] = []
            while (
                row_idx < total
                and isinstance(rows[row_idx], str)
                and len(lines) < _CHUNK_ROWS
            ):
                nrow = rows[row_idx]
                assert isinstance(nrow, str)
                lines.append(nrow)
                row_idx += 1
            specs.append((0.0, chunk_top * _ROW_HEIGHT, '\n'.join(lines)))
        else:
            # A run of name rows (all sharing one inset; the sections
            # are separated by plain rows so runs never mix insets).
            inset = row[0]
            cols: list[list[str]] = [[] for _ in range(_NAME_COLUMN_COUNT)]
            count = 0
            while (
                row_idx < total
                and not isinstance(rows[row_idx], str)
                and count < _CHUNK_ROWS
            ):
                nrow = rows[row_idx]
                assert not isinstance(nrow, str)
                present: dict[int, str] = dict(nrow[1])
                for slot in range(_NAME_COLUMN_COUNT):
                    cols[slot].append(present.get(slot, ''))
                count += 1
                row_idx += 1
            for slot in range(_NAME_COLUMN_COUNT):
                text = '\n'.join(cols[slot])
                if not text.strip():
                    continue
                specs.append(
                    (
                        (inset + slot * _NAME_COLUMN_SPACING) * _TEXT_SCALE,
                        chunk_top * _ROW_HEIGHT,
                        text,
                    )
                )
    return (total, specs)


def _compose_credits_rows(
    translation_contributors: Sequence[str],
) -> list[CreditsRow]:
    """Build the full credits body as a list of display rows.

    Involves lots of string measuring (see ``_layout_names``), so this
    must run on a background thread, not the logic thread.
    """
    # Flat text on purpose: the credits body is assembled as
    # pre-laid-out plain text (name rows get positioned-widget
    # treatment instead so their columns line up exactly), so Lstr
    # values get evaluated at the boundary.
    sound_and_music = _classicassets.strings.credits.song_credit(
        title="'William Tell (Trumpet Entry)'",
        performer='The Apollo Symphony Orchestra',
        composer='Gioacchino Rossini',
        arranger='Chris Worth',
        publisher='BMI',
        source='www.AudioSparx.com',
    ).evaluate()
    spc = '     '
    sound_and_music = spc + sound_and_music.replace('\n', '\n' + spc)
    names = [
        'HubOfTheUniverseProd',
        'Jovica',
        'LG',
        'Leady',
        'Percy Duke',
        'PhreaKsAccount',
        'Pogotron',
        'Rock Savage',
        'anamorphosis',
        'benboncan',
        'cdrk',
        'chipfork',
        'guitarguy1985',
        'jascha',
        'joedeshon',
        'loofa',
        'm_O_m',
        'mich3d',
        'sandyrb',
        'shakaharu',
        'sirplus',
        'stickman',
        'thanvannispen',
        'virotic',
        'zimbot',
    ]
    names.sort(key=lambda x: x.lower())

    part_before_freesound = (
        '  '
        + _classicassets.strings.credits.coding_graphics_audio(
            name='Eric Froemling'
        ).evaluate()
        + '\n'
        '\n'
        '  '
        + _classicassets.strings.credits.additional_audio_art_ideas(
            name='Raphael Suter'
        ).evaluate()
        + '\n'
        '\n'
        '  ' + _classicassets.strings.credits.sound_and_music.evaluate() + '\n'
        '\n' + sound_and_music + '\n'
        '\n'
        '     '
        + _classicassets.strings.credits.public_domain_music_via(
            name='Musopen.com'
        ).evaluate()
        + '\n'
        '        '
        + _classicassets.strings.credits.thanks_especially_to(
            name='the US Army, Navy, and Marine Bands'
        ).evaluate()
        + '\n'
        '\n'
        '     '
        + _classicassets.strings.credits.additional_music_from(
            name='The YouTube Audio Library'
        ).evaluate()
        + '\n'
        '\n'
        '     '
        + _classicassets.strings.credits.sounds_source(
            source='Freesound.org'
        ).evaluate()
        + '\n\n'
    )

    part_before_translators = (
        '\n\n  '
        + _classicassets.strings.credits.language_translations.evaluate()
        + '\n\n'
    )

    part_after_translators = (
        '\n'
        '  Shout Out to Awesome Mods / Modders / Contributors:\n\n'
        '     BombDash ModPack\n'
        '     TheMikirog & SoK - BombSquad Joyride Modpack\n'
        '     Mrmaxmeier - BombSquad-Community-Mod-Manager\n'
        '     Ritiek Malhotra \n'
        '     Dliwk\n'
        '     vishal332008\n'
        '     itsre3\n'
        '     Drooopyyy\n'
        '     Loup\n'
        '\n'
        '  Holiday theme vector art designed by Freepik\n'
        '\n'
        '  ' + _classicassets.strings.credits.special_thanks.evaluate() + '\n'
        '\n'
        '     Todd, Laura, and Robert Froemling\n'
        '     '
        + _classicassets.strings.credits.all_my_family.evaluate().replace(
            '\n', '\n     '
        )
        + '\n'
        '     '
        + _classicassets.strings.credits.whoever_invented_coffee.evaluate()
        + '\n'
        '\n'
        '  ' + _classicassets.strings.credits.legal.evaluate() + '\n'
        '\n'
        '     '
        + _classicassets.strings.credits.software_based_on(
            name='the Khronos Group'
        ).evaluate()
        + '\n'
        '\n'
        '                                       '
        '                      www.ballistica.net\n'
    )

    rows: list[CreditsRow] = []
    rows += part_before_freesound.splitlines()
    rows += _layout_names(names, 90)
    rows += part_before_translators.splitlines()
    rows += _layout_names(translation_contributors, 60)
    rows += part_after_translators.splitlines()
    return rows


[docs] class CreditsWindow(bui.MainWindow): """Window for displaying game credits.""" def __init__( self, transition: str | None = 'in_right', origin_widget: bui.Widget | None = None, ): bui.set_analytics_screen('Credits Window') assert bui.app.classic is not None uiscale = bui.app.ui_v1.uiscale width = 990 if uiscale is bui.UIScale.SMALL else 670 height = 750 if uiscale is bui.UIScale.SMALL else 500 # Do some fancy math to fill all available screen area up to the # size of our backing container. This lets us fit to the exact # screen shape at small ui scale. screensize = bui.get_virtual_screen_size() scale = ( 2.0 if uiscale is bui.UIScale.SMALL else 1.2 if uiscale is bui.UIScale.MEDIUM else 1.0 ) # Scale down if necessary so the full width of our UI is # visible. min_width = 800 if screensize[0] / scale < min_width: scale *= (screensize[0] / scale) / min_width # Calc screen size in our local container space and clamp to a # bit smaller than our container size. target_width = min(width - 80, screensize[0] / scale) target_height = min(height - 80, screensize[1] / scale) # To get top/left coords, go to the center of our window and # offset by half the width/height of our target area. yoffs = 0.5 * height + 0.5 * target_height scroll_width = target_width # Use the full screen area in small mode (we'll include our # title in the scrollable content). if uiscale is bui.UIScale.SMALL: scroll_height = target_height scroll_y = yoffs - scroll_height else: yoffs += 30 scroll_height = target_height - 29 scroll_y = yoffs - 58 - scroll_height # In small ui (where we cover the screen), extend our scroll # area out to cover any margins between the virtual rect and # the visible screen edges (cutout insets and whatnot), # insetting content by those same amounts so it stays put and # only the scroll surface itself reaches further out. ( self._margin_left, self._margin_right, self._margin_bottom, self._margin_top, ) = ( get_screen_margins(scale) if uiscale is bui.UIScale.SMALL else (0.0, 0.0, 0.0, 0.0) ) self._r = 'creditsWindow' super().__init__( root_widget=bui.containerwidget( size=(width, height), toolbar_visibility=( 'menu_minimal' if uiscale is bui.UIScale.SMALL else 'menu_full' ), scale=scale, ), transition=transition, origin_widget=origin_widget, # We're affected by screen size only at small ui-scale. refresh_on_screen_size_changes=uiscale is bui.UIScale.SMALL, ) if uiscale is bui.UIScale.SMALL: bui.containerwidget( edit=self._root_widget, on_cancel_call=self.main_window_back ) else: btn = bui.buttonwidget( parent=self._root_widget, id=f'{self.main_window_id_prefix}|back', position=(40, yoffs - 46), size=(60, 55), scale=0.8, label=bui.charstr(bui.SpecialChar.BACK), button_type='backSmall', on_activate_call=self.main_window_back, autoselect=True, ) bui.containerwidget(edit=self._root_widget, cancel_button=btn) scroll = self._scroll = bui.scrollwidget( parent=self._root_widget, size=( scroll_width + self._margin_left + self._margin_right, scroll_height + self._margin_bottom + self._margin_top, ), position=( width * 0.5 - scroll_width * 0.5 - self._margin_left, scroll_y - self._margin_bottom, ), capture_arrows=True, border_opacity=0.4, center_small_content_horizontally=True, ) if uiscale is bui.UIScale.SMALL: bui.widget( edit=scroll, left_widget=bui.get_special_widget('back_button'), ) bui.widget( edit=scroll, right_widget=bui.get_special_widget('squad_button'), ) bui.containerwidget(edit=self._root_widget, selected_child=scroll) # Layout values our deferred content build will need. self._uiscale = uiscale self._width = width self._yoffs = yoffs self._scroll_width = scroll_width self._sub_width = min(700, width - 80) self._sub_height: float | None = None self._subcontainer: bui.Widget | None = None self._specs: list[tuple[float, float, str]] | None = None self._spec_index = 0 self._content_top = 0.0 self._build_timer: bui.DisplayTimer | None = None # Composing the credits body involves measuring lots of # multi-script text, which can stall on lazy OS font loads — so # it happens on a background thread and the resulting content # gets built here once ready (this is also why we grab the # contributor list here rather than there; its underlying # asset-registry lookups keep a simple logic-thread-only # contract). Measurement itself is thread-safe. translation_contributors: Sequence[str] = bui.get_legacy_langdata().get( 'translation_contributors', [] ) bui.app.threadpool.submit_no_wait( partial(self._compose_in_bg, translation_contributors) ) def _compose_in_bg(self, translation_contributors: Sequence[str]) -> None: """Compose the credits body (on a background thread).""" try: assert not bui.in_logic_thread() rows = _compose_credits_rows(translation_contributors) # Bake rows into a minimal set of fully-positioned widget # specs (all layout math done here, off the logic thread). row_count, specs = _bake_widget_specs(rows) # Pre-measure the exact spec strings: this populates the # engine's span-measure cache with the spans the logic # thread will need when building these widgets' text # meshes, keeping that step off the OS text backend. for _x, _y, spec_text in specs: bui.get_string_width(spec_text, suppress_warning=True) bui.pushcall( bui.WeakCallStrict(self._build_content, row_count, specs), from_other_thread=True, ) except Exception as exc: bui.uilog.exception('Error composing credits content.') strip_exception_tracebacks(exc) def _build_content( self, row_count: int, specs: list[tuple[float, float, str]] ) -> None: """Populate our scroll content (back on the logic thread). Everything here was baked on the background compose thread; this just runs the (few dozen) widget creates. """ # No-op if our ui is already gone. if not self._root_widget: return sub_height = float(_ROW_HEIGHT * row_count + 40) inline_title_height = 50 # Make space for our title when we're stuffing it inline. if self._uiscale is bui.UIScale.SMALL: sub_height += inline_title_height # Grow to cover any screen margins, insetting content to match # (via the margin_left/margin_top shifts below) so it stays put. sub_height += self._margin_bottom + self._margin_top self._sub_height = sub_height self._subcontainer = bui.containerwidget( parent=self._scroll, id=f'{self.main_window_id_prefix}|sub', size=( self._sub_width + self._margin_left + self._margin_right, sub_height, ), background=False, claims_left_right=False, ) # Stick our title on the scrollable content in small ui mode so # we can use the full screen area for said content. bui.textwidget( parent=( self._subcontainer if self._uiscale is bui.UIScale.SMALL else self._root_widget ), position=( ( self._margin_left + self._sub_width * 0.5, sub_height - self._margin_top - 20, ) if self._uiscale is bui.UIScale.SMALL else (self._width * 0.5, self._yoffs - 28) ), size=(0, 0), scale=0.8 if self._uiscale is bui.UIScale.SMALL else 1.0, text=_classicassets.strings.credits.title( app_name=_classicassets.strings.ui.app_name ), h_align='center', v_align='center', color=bui.app.ui_v1.title_color, maxwidth=self._scroll_width * 0.7, ) voffs = ( -inline_title_height if self._uiscale is bui.UIScale.SMALL else 0 ) # Create content widgets a few per frame: each costs ~1ms # all-in on a midrange phone, so doing all of them in one # frame is a visible hitch while this way stays well within # frame budget. Specs are ordered top-down so visible content # lands first; the rest fills in below over the next fraction # of a second. self._content_top = sub_height - self._margin_top - 20 + voffs self._specs = specs self._spec_index = 0 self._build_timer = bui.DisplayTimer( 0.0001, bui.WeakCallStrict(self._build_batch), repeat=True ) self._build_batch() def _build_batch(self) -> None: """Create the next batch of content widgets (logic thread).""" container = self._subcontainer if not container: # Our ui died; stop. self._build_timer = None return assert self._specs is not None batch_end = min(len(self._specs), self._spec_index + _CREATES_PER_FRAME) textwidget = bui.textwidget for i in range(self._spec_index, batch_end): xoffs, y_down, text = self._specs[i] textwidget( parent=container, padding=4, color=(0.7, 0.9, 0.7, 1.0), scale=_TEXT_SCALE, flatness=1.0, size=(0, 0), position=( self._margin_left + xoffs, self._content_top - y_down, ), h_align='left', v_align='top', text=text, ) self._spec_index = batch_end if batch_end >= len(self._specs): self._build_timer = None self._specs = None
[docs] @override def get_main_window_state(self) -> bui.MainWindowState: # Support recreating our window for back/refresh purposes. cls = type(self) return bui.BasicMainWindowState( create_call=lambda transition, origin_widget: cls( transition=transition, origin_widget=origin_widget ) )
[docs] @override def main_window_should_preserve_selection(self) -> bool: return True
# 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