bauiv1lib.chest

Provides chest related ui.

   1# Released under the MIT License. See LICENSE for details.
   2#
   3# pylint: disable=too-many-lines
   4"""Provides chest related ui."""
   5
   6from __future__ import annotations
   7
   8import math
   9import random
  10from typing import override, TYPE_CHECKING
  11
  12from efro.util import strict_partial
  13import bacommon.bs
  14import bauiv1 as bui
  15
  16if TYPE_CHECKING:
  17    import datetime
  18
  19    import baclassic
  20
  21_g_open_voices: list[tuple[float, str, float]] = []
  22
  23
  24class ChestWindow(bui.MainWindow):
  25    """Allows viewing and performing operations on a chest."""
  26
  27    def __init__(
  28        self,
  29        index: int,
  30        transition: str | None = 'in_right',
  31        origin_widget: bui.Widget | None = None,
  32    ):
  33        self._index = index
  34
  35        assert bui.app.classic is not None
  36        uiscale = bui.app.ui_v1.uiscale
  37        self._width = 1200 if uiscale is bui.UIScale.SMALL else 650
  38        self._height = 800 if uiscale is bui.UIScale.SMALL else 450
  39        self._action_in_flight = False
  40        self._open_now_button: bui.Widget | None = None
  41        self._open_now_spinner: bui.Widget | None = None
  42        self._open_now_texts: list[bui.Widget] = []
  43        self._open_now_images: list[bui.Widget] = []
  44        self._watch_ad_button: bui.Widget | None = None
  45        self._time_string_timer: bui.AppTimer | None = None
  46        self._time_string_text: bui.Widget | None = None
  47        self._prizesets: list[bacommon.bs.ChestInfoResponse.Chest.PrizeSet] = []
  48        self._prizeindex = -1
  49        self._prizesettxts: dict[int, list[bui.Widget]] = {}
  50        self._prizesetimgs: dict[int, list[bui.Widget]] = {}
  51        self._chestdisplayinfo: baclassic.ChestAppearanceDisplayInfo | None = (
  52            None
  53        )
  54
  55        # The set of widgets we keep when doing a clear.
  56        self._core_widgets: list[bui.Widget] = []
  57
  58        # Do some fancy math to fill all available screen area up to the
  59        # size of our backing container. This lets us fit to the exact
  60        # screen shape at small ui scale.
  61        screensize = bui.get_virtual_screen_size()
  62        scale = (
  63            1.8
  64            if uiscale is bui.UIScale.SMALL
  65            else 1.1 if uiscale is bui.UIScale.MEDIUM else 0.9
  66        )
  67        # Calc screen size in our local container space and clamp to a
  68        # bit smaller than our container size.
  69        target_height = min(self._height - 120, screensize[1] / scale)
  70
  71        # To get top/left coords, go to the center of our window and
  72        # offset by half the width/height of our target area.
  73        self._yoffstop = 0.5 * self._height + 0.5 * target_height + 18
  74
  75        # Offset for stuff we want centered.
  76        self._yoffs = 0.5 * self._height + (
  77            220 if uiscale is bui.UIScale.SMALL else 190
  78        )
  79
  80        self._chest_yoffs = self._yoffs - 223
  81
  82        super().__init__(
  83            root_widget=bui.containerwidget(
  84                size=(self._width, self._height),
  85                toolbar_visibility='menu_full',
  86                scale=scale,
  87            ),
  88            transition=transition,
  89            origin_widget=origin_widget,
  90            # We're affected by screen size only at small ui-scale.
  91            refresh_on_screen_size_changes=uiscale is bui.UIScale.SMALL,
  92        )
  93
  94        # Tell the root-ui to stop updating toolbar values immediately;
  95        # this allows it to run animations based on the results of our
  96        # chest opening.
  97        bui.root_ui_pause_updates()
  98        self._root_ui_updates_paused = True
  99
 100        self._title_text = bui.textwidget(
 101            parent=self._root_widget,
 102            position=(
 103                self._width * 0.5,
 104                self._yoffstop - (36 if uiscale is bui.UIScale.SMALL else 10),
 105            ),
 106            size=(0, 0),
 107            text=bui.Lstr(
 108                resource='chests.slotText',
 109                subs=[('${NUM}', str(index + 1))],
 110            ),
 111            color=bui.app.ui_v1.title_color,
 112            maxwidth=110.0 if uiscale is bui.UIScale.SMALL else 200,
 113            scale=0.9 if uiscale is bui.UIScale.SMALL else 1.1,
 114            h_align='center',
 115            v_align='center',
 116        )
 117        self._core_widgets.append(self._title_text)
 118
 119        if uiscale is bui.UIScale.SMALL:
 120            bui.containerwidget(
 121                edit=self._root_widget, on_cancel_call=self.main_window_back
 122            )
 123        else:
 124            btn = bui.buttonwidget(
 125                parent=self._root_widget,
 126                position=(50, self._yoffs - 44),
 127                size=(60, 55),
 128                scale=0.8,
 129                label=bui.charstr(bui.SpecialChar.BACK),
 130                button_type='backSmall',
 131                extra_touch_border_scale=2.0,
 132                autoselect=True,
 133                on_activate_call=self.main_window_back,
 134            )
 135            bui.containerwidget(edit=self._root_widget, cancel_button=btn)
 136            self._core_widgets.append(btn)
 137
 138        # Note: Don't need to explicitly clean this up. Just not adding
 139        # it to core_widgets so it will go away on next reset.
 140        self._loadingspinner = bui.spinnerwidget(
 141            parent=self._root_widget,
 142            position=(self._width * 0.5, self._height * 0.5),
 143            size=48,
 144            style='bomb',
 145        )
 146
 147        self._infotext = bui.textwidget(
 148            parent=self._root_widget,
 149            position=(self._width * 0.5, self._yoffs - 200),
 150            size=(0, 0),
 151            text='',
 152            maxwidth=700,
 153            scale=0.8,
 154            color=(0.6, 0.5, 0.6),
 155            h_align='center',
 156            v_align='center',
 157        )
 158        self._core_widgets.append(self._infotext)
 159
 160        plus = bui.app.plus
 161        if plus is None:
 162            self._error('Plus feature-set is not present.')
 163            return
 164
 165        if plus.accounts.primary is None:
 166            self._error(bui.Lstr(resource='notSignedInText'))
 167            return
 168
 169        # Start by showing info/options for our target chest. Note that
 170        # we always ask the server for these values even though we may
 171        # have them through our appmode subscription which updates the
 172        # chest UI. This is because the wait_for_connectivity()
 173        # mechanism will often bring our window up a split second before
 174        # the chest subscription receives its first values which would
 175        # lead us to incorrectly think there is no chest there. If we
 176        # want to optimize this in the future we could perhaps use local
 177        # values only if there is a chest present in them.
 178        assert not self._action_in_flight
 179        self._action_in_flight = True
 180        with plus.accounts.primary:
 181            plus.cloud.send_message_cb(
 182                bacommon.bs.ChestInfoMessage(chest_id=str(self._index)),
 183                on_response=bui.WeakCall(self._on_chest_info_response),
 184            )
 185
 186    def __del__(self) -> None:
 187
 188        # Make sure UI updates are resumed if we haven't done so.
 189        if self._root_ui_updates_paused:
 190            bui.root_ui_resume_updates()
 191
 192    @override
 193    def get_main_window_state(self) -> bui.MainWindowState:
 194        # Support recreating our window for back/refresh purposes.
 195        cls = type(self)
 196
 197        # Pull anything we need from self out here; if we do it in the
 198        # lambda we keep self alive which is bad.
 199        index = self._index
 200
 201        return bui.BasicMainWindowState(
 202            create_call=lambda transition, origin_widget: cls(
 203                index=index, transition=transition, origin_widget=origin_widget
 204            )
 205        )
 206
 207    def _update_time_display(self, unlock_time: datetime.datetime) -> None:
 208        # Once text disappears, kill our timer.
 209        if not self._time_string_text:
 210            self._time_string_timer = None
 211            return
 212        now = bui.utc_now_cloud()
 213        secs_till_open = max(0.0, (unlock_time - now).total_seconds())
 214        tstr = (
 215            bui.timestring(secs_till_open, centi=False)
 216            if secs_till_open > 0
 217            else ''
 218        )
 219        bui.textwidget(edit=self._time_string_text, text=tstr)
 220
 221    def _on_chest_info_response(
 222        self, response: bacommon.bs.ChestInfoResponse | Exception
 223    ) -> None:
 224        assert self._action_in_flight  # Should be us.
 225        self._action_in_flight = False
 226
 227        if isinstance(response, Exception):
 228            self._error(
 229                bui.Lstr(resource='internal.unableToCompleteTryAgainText'),
 230                minor=True,
 231            )
 232            return
 233
 234        if response.chest is None:
 235            self._show_about_chest_slots()
 236            return
 237
 238        assert response.user_tokens is not None
 239        self._show_chest_actions(response.user_tokens, response.chest)
 240
 241    def _on_chest_action_response(
 242        self, response: bacommon.bs.ChestActionResponse | Exception
 243    ) -> None:
 244        assert self._action_in_flight  # Should be us.
 245        self._action_in_flight = False
 246
 247        # Communication/local error:
 248        if isinstance(response, Exception):
 249            self._error(
 250                bui.Lstr(resource='internal.unableToCompleteTryAgainText'),
 251                minor=True,
 252            )
 253            return
 254
 255        # Server-side error:
 256        if response.error is not None:
 257            self._error(bui.Lstr(translate=('serverResponses', response.error)))
 258            return
 259
 260        # Show any bundled success message.
 261        if response.success_msg is not None:
 262            bui.screenmessage(
 263                bui.Lstr(translate=('serverResponses', response.success_msg)),
 264                color=(0, 1.0, 0),
 265            )
 266            bui.getsound('cashRegister').play()
 267
 268        # Show any bundled warning.
 269        if response.warning is not None:
 270            bui.screenmessage(
 271                bui.Lstr(translate=('serverResponses', response.warning)),
 272                color=(1, 0.5, 0),
 273            )
 274            bui.getsound('error').play()
 275
 276        # If we just paid for something, make a sound accordingly.
 277        if bool(False):  # Hmm maybe this feels odd.
 278            if response.tokens_charged > 0:
 279                bui.getsound('cashRegister').play()
 280
 281        # If there's contents listed in the response, show them.
 282        if response.contents is not None:
 283            self._show_chest_contents(response)
 284        else:
 285            # Otherwise we're done here; just close out our UI.
 286            self.main_window_back()
 287
 288    def _show_chest_actions(
 289        self, user_tokens: int, chest: bacommon.bs.ChestInfoResponse.Chest
 290    ) -> None:
 291        """Show state for our chest."""
 292        # pylint: disable=too-many-locals
 293        # pylint: disable=cyclic-import
 294        from baclassic import (
 295            ClassicAppMode,
 296            CHEST_APPEARANCE_DISPLAY_INFOS,
 297            CHEST_APPEARANCE_DISPLAY_INFO_DEFAULT,
 298        )
 299
 300        plus = bui.app.plus
 301        assert plus is not None
 302
 303        # We expect to be run under classic app mode.
 304        mode = bui.app.mode
 305        if not isinstance(mode, ClassicAppMode):
 306            self._error('Classic app mode not active.')
 307            return
 308
 309        self._reset()
 310
 311        self._chestdisplayinfo = CHEST_APPEARANCE_DISPLAY_INFOS.get(
 312            chest.appearance, CHEST_APPEARANCE_DISPLAY_INFO_DEFAULT
 313        )
 314
 315        bui.textwidget(
 316            edit=self._title_text,
 317            text=bui.Lstr(
 318                translate=('displayItemNames', chest.appearance.pretty_name)
 319            ),
 320        )
 321
 322        imgsize = 145
 323        bui.imagewidget(
 324            parent=self._root_widget,
 325            position=(
 326                self._width * 0.5 - imgsize * 0.5,
 327                # self._height - 223 + self._yoffs,
 328                self._chest_yoffs,
 329            ),
 330            color=self._chestdisplayinfo.color,
 331            size=(imgsize, imgsize),
 332            texture=bui.gettexture(self._chestdisplayinfo.texclosed),
 333            tint_texture=bui.gettexture(self._chestdisplayinfo.texclosedtint),
 334            tint_color=self._chestdisplayinfo.tint,
 335            tint2_color=self._chestdisplayinfo.tint2,
 336        )
 337
 338        # Store the prize-sets so we can display odds/etc. Sort them
 339        # with largest weights first.
 340        self._prizesets = sorted(
 341            chest.prizesets, key=lambda s: s.weight, reverse=True
 342        )
 343
 344        if chest.unlock_tokens > 0:
 345            lsize = 30
 346            bui.imagewidget(
 347                parent=self._root_widget,
 348                position=(
 349                    self._width * 0.5 - imgsize * 0.4 - lsize * 0.5,
 350                    # self._height - 223 + 27.0 + self._yoffs,
 351                    self._chest_yoffs + 27.0,
 352                ),
 353                size=(lsize, lsize),
 354                texture=bui.gettexture('lock'),
 355            )
 356
 357        # Time string.
 358        if chest.unlock_tokens != 0:
 359            self._time_string_text = bui.textwidget(
 360                parent=self._root_widget,
 361                position=(
 362                    self._width * 0.5,
 363                    # self._height - 85 + self._yoffs
 364                    self._yoffs - 85,
 365                ),
 366                size=(0, 0),
 367                text='',
 368                maxwidth=700,
 369                scale=0.6,
 370                color=(0.6, 1.0, 0.6),
 371                h_align='center',
 372                v_align='center',
 373            )
 374            self._update_time_display(chest.unlock_time)
 375            self._time_string_timer = bui.AppTimer(
 376                1.0,
 377                repeat=True,
 378                call=bui.WeakCall(self._update_time_display, chest.unlock_time),
 379            )
 380
 381        # Allow watching an ad IF the server tells us we can AND we have
 382        # an ad ready to show.
 383        show_ad_button = (
 384            chest.unlock_tokens > 0
 385            and chest.ad_allow
 386            and plus.have_incentivized_ad()
 387        )
 388
 389        bwidth = 130
 390        bheight = 90
 391        bposy = -330 if chest.unlock_tokens == 0 else -340
 392        hspace = 20
 393        boffsx = (hspace * -0.5 - bwidth * 0.5) if show_ad_button else 0.0
 394
 395        self._open_now_button = bui.buttonwidget(
 396            parent=self._root_widget,
 397            position=(
 398                self._width * 0.5 - bwidth * 0.5 + boffsx,
 399                self._yoffs + bposy,
 400            ),
 401            size=(bwidth, bheight),
 402            label='',
 403            button_type='square',
 404            autoselect=True,
 405            on_activate_call=bui.WeakCall(
 406                self._open_press, user_tokens, chest.unlock_tokens
 407            ),
 408            enable_sound=False,
 409        )
 410        self._open_now_images = []
 411        self._open_now_texts = []
 412
 413        iconsize = 50
 414        if chest.unlock_tokens == 0:
 415            self._open_now_texts.append(
 416                bui.textwidget(
 417                    parent=self._root_widget,
 418                    text=bui.Lstr(resource='openText'),
 419                    position=(
 420                        self._width * 0.5 + boffsx,
 421                        self._yoffs + bposy + bheight * 0.5,
 422                    ),
 423                    color=(0, 1, 0),
 424                    draw_controller=self._open_now_button,
 425                    scale=0.7,
 426                    maxwidth=bwidth * 0.8,
 427                    size=(0, 0),
 428                    h_align='center',
 429                    v_align='center',
 430                )
 431            )
 432        else:
 433            self._open_now_texts.append(
 434                bui.textwidget(
 435                    parent=self._root_widget,
 436                    text=bui.Lstr(resource='openNowText'),
 437                    position=(
 438                        self._width * 0.5 + boffsx,
 439                        self._yoffs + bposy + bheight * 1.15,
 440                    ),
 441                    maxwidth=bwidth * 0.8,
 442                    scale=0.7,
 443                    color=(0.7, 1, 0.7),
 444                    size=(0, 0),
 445                    h_align='center',
 446                    v_align='center',
 447                )
 448            )
 449            self._open_now_images.append(
 450                bui.imagewidget(
 451                    parent=self._root_widget,
 452                    size=(iconsize, iconsize),
 453                    position=(
 454                        self._width * 0.5 - iconsize * 0.5 + boffsx,
 455                        self._yoffs + bposy + bheight * 0.35,
 456                    ),
 457                    draw_controller=self._open_now_button,
 458                    texture=bui.gettexture('coin'),
 459                )
 460            )
 461            self._open_now_texts.append(
 462                bui.textwidget(
 463                    parent=self._root_widget,
 464                    text=bui.Lstr(
 465                        resource='tokens.numTokensText',
 466                        subs=[('${COUNT}', str(chest.unlock_tokens))],
 467                    ),
 468                    position=(
 469                        self._width * 0.5 + boffsx,
 470                        self._yoffs + bposy + bheight * 0.25,
 471                    ),
 472                    scale=0.65,
 473                    color=(0, 1, 0),
 474                    draw_controller=self._open_now_button,
 475                    maxwidth=bwidth * 0.8,
 476                    size=(0, 0),
 477                    h_align='center',
 478                    v_align='center',
 479                )
 480            )
 481        self._open_now_spinner = bui.spinnerwidget(
 482            parent=self._root_widget,
 483            position=(
 484                self._width * 0.5 + boffsx,
 485                self._yoffs + bposy + 0.5 * bheight,
 486            ),
 487            visible=False,
 488        )
 489
 490        if show_ad_button:
 491            bui.textwidget(
 492                parent=self._root_widget,
 493                text=bui.Lstr(resource='chests.reduceWaitText'),
 494                position=(
 495                    self._width * 0.5 + hspace * 0.5 + bwidth * 0.5,
 496                    self._yoffs + bposy + bheight * 1.15,
 497                ),
 498                maxwidth=bwidth * 0.8,
 499                scale=0.7,
 500                color=(0.7, 1, 0.7),
 501                size=(0, 0),
 502                h_align='center',
 503                v_align='center',
 504            )
 505            self._watch_ad_button = bui.buttonwidget(
 506                parent=self._root_widget,
 507                position=(
 508                    self._width * 0.5 + hspace * 0.5,
 509                    self._yoffs + bposy,
 510                ),
 511                size=(bwidth, bheight),
 512                label='',
 513                button_type='square',
 514                autoselect=True,
 515                on_activate_call=bui.WeakCall(self._watch_ad_press),
 516                enable_sound=False,
 517            )
 518            bui.imagewidget(
 519                parent=self._root_widget,
 520                size=(iconsize, iconsize),
 521                position=(
 522                    self._width * 0.5
 523                    + hspace * 0.5
 524                    + bwidth * 0.5
 525                    - iconsize * 0.5,
 526                    self._yoffs + bposy + bheight * 0.35,
 527                ),
 528                draw_controller=self._watch_ad_button,
 529                color=(1.5, 1.0, 2.0),
 530                texture=bui.gettexture('tv'),
 531            )
 532            # Note to self: AdMob requires rewarded ad usage
 533            # specifically says 'Ad' in it.
 534            bui.textwidget(
 535                parent=self._root_widget,
 536                text=bui.Lstr(resource='watchAnAdText'),
 537                position=(
 538                    self._width * 0.5 + hspace * 0.5 + bwidth * 0.5,
 539                    self._yoffs + bposy + bheight * 0.25,
 540                ),
 541                scale=0.65,
 542                color=(0, 1, 0),
 543                draw_controller=self._watch_ad_button,
 544                maxwidth=bwidth * 0.8,
 545                size=(0, 0),
 546                h_align='center',
 547                v_align='center',
 548            )
 549
 550        self._show_odds(initial_highlighted_row=-1)
 551
 552    def _highlight_odds_row(self, row: int, extra: bool = False) -> None:
 553
 554        for rindex, imgs in self._prizesetimgs.items():
 555            opacity = (
 556                (0.9 if extra else 0.75)
 557                if rindex == row
 558                else (0.4 if extra else 0.5)
 559            )
 560            for img in imgs:
 561                if img:
 562                    bui.imagewidget(edit=img, opacity=opacity)
 563
 564        for rindex, txts in self._prizesettxts.items():
 565            opacity = (
 566                (0.9 if extra else 0.75)
 567                if rindex == row
 568                else (0.4 if extra else 0.5)
 569            )
 570            for txt in txts:
 571                if txt:
 572                    bui.textwidget(edit=txt, color=(0.7, 0.65, 1, opacity))
 573
 574    def _show_odds(
 575        self,
 576        *,
 577        initial_highlighted_row: int,
 578        initial_highlighted_extra: bool = False,
 579    ) -> None:
 580        # pylint: disable=too-many-locals
 581        xoffs = 110
 582
 583        totalweight = max(0.001, sum(t.weight for t in self._prizesets))
 584
 585        rowheight = 25
 586        totalheight = (len(self._prizesets) + 1) * rowheight
 587        x = self._width * 0.5 + xoffs
 588        y = self._yoffs - 150.0 + totalheight * 0.5
 589
 590        # Title.
 591        bui.textwidget(
 592            parent=self._root_widget,
 593            text=bui.Lstr(resource='chests.prizeOddsText'),
 594            color=(0.7, 0.65, 1, 0.5),
 595            flatness=1.0,
 596            shadow=1.0,
 597            position=(x, y),
 598            scale=0.55,
 599            size=(0, 0),
 600            h_align='left',
 601            v_align='center',
 602        )
 603        y -= 5.0
 604
 605        prizesettxts: list[bui.Widget]
 606        prizesetimgs: list[bui.Widget]
 607
 608        def _mkicon(img: str) -> None:
 609            iconsize = 20.0
 610            nonlocal x
 611            nonlocal prizesetimgs
 612            prizesetimgs.append(
 613                bui.imagewidget(
 614                    parent=self._root_widget,
 615                    size=(iconsize, iconsize),
 616                    position=(x, y - iconsize * 0.5),
 617                    texture=bui.gettexture(img),
 618                    opacity=0.4,
 619                )
 620            )
 621            x += iconsize
 622
 623        def _mktxt(txt: str, advance: bool = True) -> None:
 624            tscale = 0.45
 625            nonlocal x
 626            nonlocal prizesettxts
 627            prizesettxts.append(
 628                bui.textwidget(
 629                    parent=self._root_widget,
 630                    text=txt,
 631                    flatness=1.0,
 632                    shadow=1.0,
 633                    position=(x, y),
 634                    scale=tscale,
 635                    size=(0, 0),
 636                    h_align='left',
 637                    v_align='center',
 638                )
 639            )
 640            if advance:
 641                x += (bui.get_string_width(txt, suppress_warning=True)) * tscale
 642
 643        self._prizesettxts = {}
 644        self._prizesetimgs = {}
 645
 646        for i, p in enumerate(self._prizesets):
 647            prizesettxts = self._prizesettxts.setdefault(i, [])
 648            prizesetimgs = self._prizesetimgs.setdefault(i, [])
 649            x = self._width * 0.5 + xoffs
 650            y -= rowheight
 651            percent = 100.0 * p.weight / totalweight
 652
 653            # Show decimals only if we get very small percentages (looks
 654            # better than rounding as '0%').
 655            percenttxt = (
 656                f'{percent:.2f}%:'
 657                if percent < 0.095
 658                else (
 659                    f'{percent:.1f}%:'
 660                    if percent < 0.95
 661                    else f'{round(percent)}%:'
 662                )
 663            )
 664
 665            # We advance manually here to keep values lined up
 666            # (otherwise single digit percent rows don't line up with
 667            # double digit ones).
 668            _mktxt(percenttxt, advance=False)
 669            x += 35.0
 670
 671            for item in p.contents:
 672                x += 5.0
 673                if isinstance(item.item, bacommon.bs.TicketsDisplayItem):
 674                    _mktxt(str(item.item.count))
 675                    _mkicon('tickets')
 676                elif isinstance(item.item, bacommon.bs.TokensDisplayItem):
 677                    _mktxt(str(item.item.count))
 678                    _mkicon('coin')
 679                else:
 680                    # For other cases just fall back on text desc.
 681                    #
 682                    # Translate the wrapper description and apply any subs.
 683                    descfin = bui.Lstr(
 684                        translate=('serverResponses', item.description)
 685                    ).evaluate()
 686                    subs = (
 687                        []
 688                        if item.description_subs is None
 689                        else item.description_subs
 690                    )
 691                    assert len(subs) % 2 == 0  # Should always be even.
 692                    for j in range(0, len(subs) - 1, 2):
 693                        descfin = descfin.replace(subs[j], subs[j + 1])
 694                    _mktxt(descfin)
 695        self._highlight_odds_row(
 696            initial_highlighted_row, extra=initial_highlighted_extra
 697        )
 698
 699    def _open_press(self, user_tokens: int, token_payment: int) -> None:
 700        from bauiv1lib.gettokens import show_get_tokens_prompt
 701
 702        bui.getsound('click01').play()
 703
 704        # Allow only one in-flight action at once.
 705        if self._action_in_flight:
 706            bui.screenmessage(
 707                bui.Lstr(resource='pleaseWaitText'), color=(1, 0, 0)
 708            )
 709            bui.getsound('error').play()
 710            return
 711
 712        plus = bui.app.plus
 713        assert plus is not None
 714
 715        if plus.accounts.primary is None:
 716            self._error(bui.Lstr(resource='notSignedInText'))
 717            return
 718
 719        # Offer to purchase tokens if they don't have enough.
 720        if user_tokens < token_payment:
 721            # Hack: We disable normal swish for the open button and it
 722            # seems weird without a swish here, so explicitly do one.
 723            bui.getsound('swish').play()
 724            show_get_tokens_prompt()
 725            return
 726
 727        self._action_in_flight = True
 728        with plus.accounts.primary:
 729            plus.cloud.send_message_cb(
 730                bacommon.bs.ChestActionMessage(
 731                    chest_id=str(self._index),
 732                    action=bacommon.bs.ChestActionMessage.Action.UNLOCK,
 733                    token_payment=token_payment,
 734                ),
 735                on_response=bui.WeakCall(self._on_chest_action_response),
 736            )
 737
 738        # Convey that something is in progress.
 739        if self._open_now_button:
 740            bui.spinnerwidget(edit=self._open_now_spinner, visible=True)
 741            for twidget in self._open_now_texts:
 742                bui.textwidget(edit=twidget, color=(1, 1, 1, 0.2))
 743            for iwidget in self._open_now_images:
 744                bui.imagewidget(edit=iwidget, opacity=0.2)
 745
 746    def _watch_ad_press(self) -> None:
 747
 748        bui.getsound('click01').play()
 749
 750        # Allow only one in-flight action at once.
 751        if self._action_in_flight:
 752            bui.screenmessage(
 753                bui.Lstr(resource='pleaseWaitText'), color=(1, 0, 0)
 754            )
 755            bui.getsound('error').play()
 756            return
 757
 758        assert bui.app.classic is not None
 759
 760        self._action_in_flight = True
 761        bui.app.classic.ads.show_ad_2(
 762            'reduce_chest_wait',
 763            on_completion_call=bui.WeakCall(self._watch_ad_complete),
 764        )
 765
 766        # Convey that something is in progress.
 767        if self._watch_ad_button:
 768            bui.buttonwidget(edit=self._watch_ad_button, color=(0.4, 0.4, 0.4))
 769
 770    def _watch_ad_complete(self, actually_showed: bool) -> None:
 771
 772        assert self._action_in_flight  # Should be ad view.
 773        self._action_in_flight = False
 774
 775        if not actually_showed:
 776            return
 777
 778        # Allow only one in-flight action at once.
 779        if self._action_in_flight:
 780            bui.screenmessage(
 781                bui.Lstr(resource='pleaseWaitText'), color=(1, 0, 0)
 782            )
 783            bui.getsound('error').play()
 784            return
 785
 786        plus = bui.app.plus
 787        assert plus is not None
 788
 789        if plus.accounts.primary is None:
 790            self._error(bui.Lstr(resource='notSignedInText'))
 791            return
 792
 793        self._action_in_flight = True
 794        with plus.accounts.primary:
 795            plus.cloud.send_message_cb(
 796                bacommon.bs.ChestActionMessage(
 797                    chest_id=str(self._index),
 798                    action=bacommon.bs.ChestActionMessage.Action.AD,
 799                    token_payment=0,
 800                ),
 801                on_response=bui.WeakCall(self._on_chest_action_response),
 802            )
 803
 804    def _reset(self) -> None:
 805        """Clear all non-permanent widgets and clear infotext."""
 806        for widget in self._root_widget.get_children():
 807            if widget not in self._core_widgets:
 808                widget.delete()
 809        bui.textwidget(edit=self._infotext, text='', color=(1, 1, 1))
 810
 811    def _error(self, msg: str | bui.Lstr, minor: bool = False) -> None:
 812        """Put ourself in an error state with a visible error message."""
 813        self._reset()
 814        bui.textwidget(
 815            edit=self._infotext,
 816            text=msg,
 817            color=(1, 0.5, 0.5) if minor else (1, 0, 0),
 818        )
 819
 820    def _show_about_chest_slots(self) -> None:
 821        # No-op if our ui is dead.
 822        if not self._root_widget:
 823            return
 824
 825        self._reset()
 826        bui.textwidget(
 827            edit=self._infotext,
 828            text=bui.Lstr(resource='chests.slotDescriptionText'),
 829            color=(1, 1, 1),
 830        )
 831
 832    def _show_chest_contents(
 833        self, response: bacommon.bs.ChestActionResponse
 834    ) -> None:
 835        # pylint: disable=too-many-locals
 836
 837        from baclassic import show_display_item
 838
 839        # No-op if our ui is dead.
 840        if not self._root_widget:
 841            return
 842
 843        assert response.contents is not None
 844
 845        # Insert test items for testing.
 846        if bool(False):
 847            response.contents += [
 848                bacommon.bs.DisplayItemWrapper.for_display_item(
 849                    bacommon.bs.TestDisplayItem()
 850                )
 851            ]
 852
 853        tincr = 0.4
 854        tendoffs = tincr * 4.0
 855        toffs = 0.0
 856
 857        bui.getsound('revUp').play(volume=2.0)
 858
 859        # Show nothing but the chest icon and animate it shaking.
 860        self._reset()
 861        imgsize = 145
 862        assert self._chestdisplayinfo is not None
 863        img = bui.imagewidget(
 864            parent=self._root_widget,
 865            color=self._chestdisplayinfo.color,
 866            texture=bui.gettexture(self._chestdisplayinfo.texclosed),
 867            tint_texture=bui.gettexture(self._chestdisplayinfo.texclosedtint),
 868            tint_color=self._chestdisplayinfo.tint,
 869            tint2_color=self._chestdisplayinfo.tint2,
 870        )
 871
 872        def _set_img(x: float, scale: float) -> None:
 873            if not img:
 874                return
 875            bui.imagewidget(
 876                edit=img,
 877                position=(
 878                    self._width * 0.5 - imgsize * scale * 0.5 + x,
 879                    self._yoffs - 223 + imgsize * 0.5 - imgsize * scale * 0.5,
 880                ),
 881                size=(imgsize * scale, imgsize * scale),
 882            )
 883
 884        # Set initial place.
 885        _set_img(0.0, 1.0)
 886
 887        sign = 1.0
 888        while toffs < tendoffs:
 889            toffs += 0.03 * random.uniform(0.5, 1.5)
 890            sign = -sign
 891            bui.apptimer(
 892                toffs,
 893                bui.Call(
 894                    _set_img,
 895                    x=(
 896                        20.0
 897                        * random.uniform(0.3, 1.0)
 898                        * math.pow(toffs / tendoffs, 2.0)
 899                        * sign
 900                    ),
 901                    scale=1.0 - 0.2 * math.pow(toffs / tendoffs, 2.0),
 902                ),
 903            )
 904
 905        xspacing = 100
 906        xoffs = -0.5 * (len(response.contents) - 1) * xspacing
 907        bui.apptimer(
 908            toffs - 0.2, lambda: bui.getsound('corkPop2').play(volume=4.0)
 909        )
 910        # Play a variety of voice sounds.
 911
 912        # We keep a global list of voice options which we randomly pull
 913        # from and refill when empty. This ensures everything gets
 914        # played somewhat frequently and minimizes annoying repeats.
 915        global _g_open_voices  # pylint: disable=global-statement
 916        if not _g_open_voices:
 917            _g_open_voices = [
 918                (0.3, 'woo3', 2.5),
 919                (0.1, 'gasp', 1.3),
 920                (0.2, 'woo2', 2.0),
 921                (0.2, 'wow', 2.0),
 922                (0.2, 'kronk2', 2.0),
 923                (0.2, 'mel03', 2.0),
 924                (0.2, 'aww', 2.0),
 925                (0.4, 'nice', 2.0),
 926                (0.3, 'yeah', 1.5),
 927                (0.2, 'woo', 1.0),
 928                (0.5, 'ooh', 0.8),
 929            ]
 930
 931        voicetimeoffs, voicename, volume = _g_open_voices.pop(
 932            random.randrange(len(_g_open_voices))
 933        )
 934        bui.apptimer(
 935            toffs + voicetimeoffs,
 936            lambda: bui.getsound(voicename).play(volume=volume),
 937        )
 938
 939        toffsopen = toffs
 940        bui.apptimer(toffs, bui.WeakCall(self._show_chest_opening))
 941        toffs += tincr * 1.0
 942        width = xspacing * 0.95
 943
 944        for item in response.contents:
 945            toffs += tincr
 946            bui.apptimer(
 947                toffs - 0.1, lambda: bui.getsound('cashRegister').play()
 948            )
 949            bui.apptimer(
 950                toffs,
 951                strict_partial(
 952                    show_display_item,
 953                    item,
 954                    self._root_widget,
 955                    pos=(
 956                        self._width * 0.5 + xoffs,
 957                        self._yoffs - 250.0,
 958                    ),
 959                    width=width,
 960                ),
 961            )
 962            xoffs += xspacing
 963        toffs += tincr
 964        bui.apptimer(toffs, bui.WeakCall(self._show_done_button))
 965
 966        self._show_odds(initial_highlighted_row=-1)
 967
 968        # Store this for later
 969        self._prizeindex = response.prizeindex
 970
 971        # The final result was already randomly selected on the server,
 972        # but we want to give the illusion of randomness here, so cycle
 973        # through highlighting our options and stop on the winner when
 974        # the chest opens. To do this, we start at the end at the prize
 975        # and work backwards setting timers.
 976        if self._prizesets:
 977            toffs2 = toffsopen - 0.01
 978            amt = 0.02
 979            i = self._prizeindex
 980            while toffs2 > 0.0:
 981                bui.apptimer(
 982                    toffs2,
 983                    bui.WeakCall(self._highlight_odds_row, i),
 984                )
 985                toffs2 -= amt
 986                amt *= 1.05 * random.uniform(0.9, 1.1)
 987                i = (i - 1) % len(self._prizesets)
 988
 989    def _show_chest_opening(self) -> None:
 990
 991        # No-op if our ui is dead.
 992        if not self._root_widget:
 993            return
 994
 995        self._reset()
 996        imgsize = 145
 997        bui.getsound('hiss').play()
 998        assert self._chestdisplayinfo is not None
 999        img = bui.imagewidget(
1000            parent=self._root_widget,
1001            color=self._chestdisplayinfo.color,
1002            texture=bui.gettexture(self._chestdisplayinfo.texopen),
1003            tint_texture=bui.gettexture(self._chestdisplayinfo.texopentint),
1004            tint_color=self._chestdisplayinfo.tint,
1005            tint2_color=self._chestdisplayinfo.tint2,
1006        )
1007        tincr = 0.8
1008        tendoffs = tincr * 2.0
1009        toffs = 0.0
1010
1011        def _set_img(x: float, scale: float) -> None:
1012            if not img:
1013                return
1014            bui.imagewidget(
1015                edit=img,
1016                position=(
1017                    self._width * 0.5 - imgsize * scale * 0.5 + x,
1018                    self._yoffs - 223 + imgsize * 0.5 - imgsize * scale * 0.5,
1019                ),
1020                size=(imgsize * scale, imgsize * scale),
1021            )
1022
1023        # Set initial place.
1024        _set_img(0.0, 1.0)
1025
1026        sign = 1.0
1027        while toffs < tendoffs:
1028            toffs += 0.03 * random.uniform(0.5, 1.5)
1029            sign = -sign
1030            # Note: we speed x along here (multing toffs) so position
1031            # comes to rest before scale.
1032            bui.apptimer(
1033                toffs,
1034                bui.Call(
1035                    _set_img,
1036                    x=(
1037                        1.0
1038                        * random.uniform(0.3, 1.0)
1039                        * (
1040                            1.0
1041                            - math.pow(min(1.0, 3.0 * toffs / tendoffs), 2.0)
1042                        )
1043                        * sign
1044                    ),
1045                    scale=1.0 - 0.1 * math.pow(toffs / tendoffs, 0.5),
1046                ),
1047            )
1048
1049        self._show_odds(
1050            initial_highlighted_row=self._prizeindex,
1051            initial_highlighted_extra=True,
1052        )
1053
1054    def _show_done_button(self) -> None:
1055        # No-op if our ui is dead.
1056        if not self._root_widget:
1057            return
1058
1059        bwidth = 200
1060        bheight = 60
1061
1062        btn = bui.buttonwidget(
1063            parent=self._root_widget,
1064            position=(
1065                self._width * 0.5 - bwidth * 0.5,
1066                self._yoffs - 350,
1067            ),
1068            size=(bwidth, bheight),
1069            label=bui.Lstr(resource='doneText'),
1070            autoselect=True,
1071            on_activate_call=self.main_window_back,
1072        )
1073        bui.containerwidget(edit=self._root_widget, start_button=btn)
1074
1075
1076# Slight hack: we define window different classes for our different
1077# chest slots so that the default UI behavior is to replace each other
1078# when different ones are pressed. If they are all the same window class
1079# then the default behavior for such presses is to toggle the existing
1080# one back off.
1081
1082
1083class ChestWindow0(ChestWindow):
1084    """Child class of ChestWindow for slighty hackish reasons."""
1085
1086
1087class ChestWindow1(ChestWindow):
1088    """Child class of ChestWindow for slighty hackish reasons."""
1089
1090
1091class ChestWindow2(ChestWindow):
1092    """Child class of ChestWindow for slighty hackish reasons."""
1093
1094
1095class ChestWindow3(ChestWindow):
1096    """Child class of ChestWindow for slighty hackish reasons."""
class ChestWindow(bauiv1._uitypes.MainWindow):
  25class ChestWindow(bui.MainWindow):
  26    """Allows viewing and performing operations on a chest."""
  27
  28    def __init__(
  29        self,
  30        index: int,
  31        transition: str | None = 'in_right',
  32        origin_widget: bui.Widget | None = None,
  33    ):
  34        self._index = index
  35
  36        assert bui.app.classic is not None
  37        uiscale = bui.app.ui_v1.uiscale
  38        self._width = 1200 if uiscale is bui.UIScale.SMALL else 650
  39        self._height = 800 if uiscale is bui.UIScale.SMALL else 450
  40        self._action_in_flight = False
  41        self._open_now_button: bui.Widget | None = None
  42        self._open_now_spinner: bui.Widget | None = None
  43        self._open_now_texts: list[bui.Widget] = []
  44        self._open_now_images: list[bui.Widget] = []
  45        self._watch_ad_button: bui.Widget | None = None
  46        self._time_string_timer: bui.AppTimer | None = None
  47        self._time_string_text: bui.Widget | None = None
  48        self._prizesets: list[bacommon.bs.ChestInfoResponse.Chest.PrizeSet] = []
  49        self._prizeindex = -1
  50        self._prizesettxts: dict[int, list[bui.Widget]] = {}
  51        self._prizesetimgs: dict[int, list[bui.Widget]] = {}
  52        self._chestdisplayinfo: baclassic.ChestAppearanceDisplayInfo | None = (
  53            None
  54        )
  55
  56        # The set of widgets we keep when doing a clear.
  57        self._core_widgets: list[bui.Widget] = []
  58
  59        # Do some fancy math to fill all available screen area up to the
  60        # size of our backing container. This lets us fit to the exact
  61        # screen shape at small ui scale.
  62        screensize = bui.get_virtual_screen_size()
  63        scale = (
  64            1.8
  65            if uiscale is bui.UIScale.SMALL
  66            else 1.1 if uiscale is bui.UIScale.MEDIUM else 0.9
  67        )
  68        # Calc screen size in our local container space and clamp to a
  69        # bit smaller than our container size.
  70        target_height = min(self._height - 120, screensize[1] / scale)
  71
  72        # To get top/left coords, go to the center of our window and
  73        # offset by half the width/height of our target area.
  74        self._yoffstop = 0.5 * self._height + 0.5 * target_height + 18
  75
  76        # Offset for stuff we want centered.
  77        self._yoffs = 0.5 * self._height + (
  78            220 if uiscale is bui.UIScale.SMALL else 190
  79        )
  80
  81        self._chest_yoffs = self._yoffs - 223
  82
  83        super().__init__(
  84            root_widget=bui.containerwidget(
  85                size=(self._width, self._height),
  86                toolbar_visibility='menu_full',
  87                scale=scale,
  88            ),
  89            transition=transition,
  90            origin_widget=origin_widget,
  91            # We're affected by screen size only at small ui-scale.
  92            refresh_on_screen_size_changes=uiscale is bui.UIScale.SMALL,
  93        )
  94
  95        # Tell the root-ui to stop updating toolbar values immediately;
  96        # this allows it to run animations based on the results of our
  97        # chest opening.
  98        bui.root_ui_pause_updates()
  99        self._root_ui_updates_paused = True
 100
 101        self._title_text = bui.textwidget(
 102            parent=self._root_widget,
 103            position=(
 104                self._width * 0.5,
 105                self._yoffstop - (36 if uiscale is bui.UIScale.SMALL else 10),
 106            ),
 107            size=(0, 0),
 108            text=bui.Lstr(
 109                resource='chests.slotText',
 110                subs=[('${NUM}', str(index + 1))],
 111            ),
 112            color=bui.app.ui_v1.title_color,
 113            maxwidth=110.0 if uiscale is bui.UIScale.SMALL else 200,
 114            scale=0.9 if uiscale is bui.UIScale.SMALL else 1.1,
 115            h_align='center',
 116            v_align='center',
 117        )
 118        self._core_widgets.append(self._title_text)
 119
 120        if uiscale is bui.UIScale.SMALL:
 121            bui.containerwidget(
 122                edit=self._root_widget, on_cancel_call=self.main_window_back
 123            )
 124        else:
 125            btn = bui.buttonwidget(
 126                parent=self._root_widget,
 127                position=(50, self._yoffs - 44),
 128                size=(60, 55),
 129                scale=0.8,
 130                label=bui.charstr(bui.SpecialChar.BACK),
 131                button_type='backSmall',
 132                extra_touch_border_scale=2.0,
 133                autoselect=True,
 134                on_activate_call=self.main_window_back,
 135            )
 136            bui.containerwidget(edit=self._root_widget, cancel_button=btn)
 137            self._core_widgets.append(btn)
 138
 139        # Note: Don't need to explicitly clean this up. Just not adding
 140        # it to core_widgets so it will go away on next reset.
 141        self._loadingspinner = bui.spinnerwidget(
 142            parent=self._root_widget,
 143            position=(self._width * 0.5, self._height * 0.5),
 144            size=48,
 145            style='bomb',
 146        )
 147
 148        self._infotext = bui.textwidget(
 149            parent=self._root_widget,
 150            position=(self._width * 0.5, self._yoffs - 200),
 151            size=(0, 0),
 152            text='',
 153            maxwidth=700,
 154            scale=0.8,
 155            color=(0.6, 0.5, 0.6),
 156            h_align='center',
 157            v_align='center',
 158        )
 159        self._core_widgets.append(self._infotext)
 160
 161        plus = bui.app.plus
 162        if plus is None:
 163            self._error('Plus feature-set is not present.')
 164            return
 165
 166        if plus.accounts.primary is None:
 167            self._error(bui.Lstr(resource='notSignedInText'))
 168            return
 169
 170        # Start by showing info/options for our target chest. Note that
 171        # we always ask the server for these values even though we may
 172        # have them through our appmode subscription which updates the
 173        # chest UI. This is because the wait_for_connectivity()
 174        # mechanism will often bring our window up a split second before
 175        # the chest subscription receives its first values which would
 176        # lead us to incorrectly think there is no chest there. If we
 177        # want to optimize this in the future we could perhaps use local
 178        # values only if there is a chest present in them.
 179        assert not self._action_in_flight
 180        self._action_in_flight = True
 181        with plus.accounts.primary:
 182            plus.cloud.send_message_cb(
 183                bacommon.bs.ChestInfoMessage(chest_id=str(self._index)),
 184                on_response=bui.WeakCall(self._on_chest_info_response),
 185            )
 186
 187    def __del__(self) -> None:
 188
 189        # Make sure UI updates are resumed if we haven't done so.
 190        if self._root_ui_updates_paused:
 191            bui.root_ui_resume_updates()
 192
 193    @override
 194    def get_main_window_state(self) -> bui.MainWindowState:
 195        # Support recreating our window for back/refresh purposes.
 196        cls = type(self)
 197
 198        # Pull anything we need from self out here; if we do it in the
 199        # lambda we keep self alive which is bad.
 200        index = self._index
 201
 202        return bui.BasicMainWindowState(
 203            create_call=lambda transition, origin_widget: cls(
 204                index=index, transition=transition, origin_widget=origin_widget
 205            )
 206        )
 207
 208    def _update_time_display(self, unlock_time: datetime.datetime) -> None:
 209        # Once text disappears, kill our timer.
 210        if not self._time_string_text:
 211            self._time_string_timer = None
 212            return
 213        now = bui.utc_now_cloud()
 214        secs_till_open = max(0.0, (unlock_time - now).total_seconds())
 215        tstr = (
 216            bui.timestring(secs_till_open, centi=False)
 217            if secs_till_open > 0
 218            else ''
 219        )
 220        bui.textwidget(edit=self._time_string_text, text=tstr)
 221
 222    def _on_chest_info_response(
 223        self, response: bacommon.bs.ChestInfoResponse | Exception
 224    ) -> None:
 225        assert self._action_in_flight  # Should be us.
 226        self._action_in_flight = False
 227
 228        if isinstance(response, Exception):
 229            self._error(
 230                bui.Lstr(resource='internal.unableToCompleteTryAgainText'),
 231                minor=True,
 232            )
 233            return
 234
 235        if response.chest is None:
 236            self._show_about_chest_slots()
 237            return
 238
 239        assert response.user_tokens is not None
 240        self._show_chest_actions(response.user_tokens, response.chest)
 241
 242    def _on_chest_action_response(
 243        self, response: bacommon.bs.ChestActionResponse | Exception
 244    ) -> None:
 245        assert self._action_in_flight  # Should be us.
 246        self._action_in_flight = False
 247
 248        # Communication/local error:
 249        if isinstance(response, Exception):
 250            self._error(
 251                bui.Lstr(resource='internal.unableToCompleteTryAgainText'),
 252                minor=True,
 253            )
 254            return
 255
 256        # Server-side error:
 257        if response.error is not None:
 258            self._error(bui.Lstr(translate=('serverResponses', response.error)))
 259            return
 260
 261        # Show any bundled success message.
 262        if response.success_msg is not None:
 263            bui.screenmessage(
 264                bui.Lstr(translate=('serverResponses', response.success_msg)),
 265                color=(0, 1.0, 0),
 266            )
 267            bui.getsound('cashRegister').play()
 268
 269        # Show any bundled warning.
 270        if response.warning is not None:
 271            bui.screenmessage(
 272                bui.Lstr(translate=('serverResponses', response.warning)),
 273                color=(1, 0.5, 0),
 274            )
 275            bui.getsound('error').play()
 276
 277        # If we just paid for something, make a sound accordingly.
 278        if bool(False):  # Hmm maybe this feels odd.
 279            if response.tokens_charged > 0:
 280                bui.getsound('cashRegister').play()
 281
 282        # If there's contents listed in the response, show them.
 283        if response.contents is not None:
 284            self._show_chest_contents(response)
 285        else:
 286            # Otherwise we're done here; just close out our UI.
 287            self.main_window_back()
 288
 289    def _show_chest_actions(
 290        self, user_tokens: int, chest: bacommon.bs.ChestInfoResponse.Chest
 291    ) -> None:
 292        """Show state for our chest."""
 293        # pylint: disable=too-many-locals
 294        # pylint: disable=cyclic-import
 295        from baclassic import (
 296            ClassicAppMode,
 297            CHEST_APPEARANCE_DISPLAY_INFOS,
 298            CHEST_APPEARANCE_DISPLAY_INFO_DEFAULT,
 299        )
 300
 301        plus = bui.app.plus
 302        assert plus is not None
 303
 304        # We expect to be run under classic app mode.
 305        mode = bui.app.mode
 306        if not isinstance(mode, ClassicAppMode):
 307            self._error('Classic app mode not active.')
 308            return
 309
 310        self._reset()
 311
 312        self._chestdisplayinfo = CHEST_APPEARANCE_DISPLAY_INFOS.get(
 313            chest.appearance, CHEST_APPEARANCE_DISPLAY_INFO_DEFAULT
 314        )
 315
 316        bui.textwidget(
 317            edit=self._title_text,
 318            text=bui.Lstr(
 319                translate=('displayItemNames', chest.appearance.pretty_name)
 320            ),
 321        )
 322
 323        imgsize = 145
 324        bui.imagewidget(
 325            parent=self._root_widget,
 326            position=(
 327                self._width * 0.5 - imgsize * 0.5,
 328                # self._height - 223 + self._yoffs,
 329                self._chest_yoffs,
 330            ),
 331            color=self._chestdisplayinfo.color,
 332            size=(imgsize, imgsize),
 333            texture=bui.gettexture(self._chestdisplayinfo.texclosed),
 334            tint_texture=bui.gettexture(self._chestdisplayinfo.texclosedtint),
 335            tint_color=self._chestdisplayinfo.tint,
 336            tint2_color=self._chestdisplayinfo.tint2,
 337        )
 338
 339        # Store the prize-sets so we can display odds/etc. Sort them
 340        # with largest weights first.
 341        self._prizesets = sorted(
 342            chest.prizesets, key=lambda s: s.weight, reverse=True
 343        )
 344
 345        if chest.unlock_tokens > 0:
 346            lsize = 30
 347            bui.imagewidget(
 348                parent=self._root_widget,
 349                position=(
 350                    self._width * 0.5 - imgsize * 0.4 - lsize * 0.5,
 351                    # self._height - 223 + 27.0 + self._yoffs,
 352                    self._chest_yoffs + 27.0,
 353                ),
 354                size=(lsize, lsize),
 355                texture=bui.gettexture('lock'),
 356            )
 357
 358        # Time string.
 359        if chest.unlock_tokens != 0:
 360            self._time_string_text = bui.textwidget(
 361                parent=self._root_widget,
 362                position=(
 363                    self._width * 0.5,
 364                    # self._height - 85 + self._yoffs
 365                    self._yoffs - 85,
 366                ),
 367                size=(0, 0),
 368                text='',
 369                maxwidth=700,
 370                scale=0.6,
 371                color=(0.6, 1.0, 0.6),
 372                h_align='center',
 373                v_align='center',
 374            )
 375            self._update_time_display(chest.unlock_time)
 376            self._time_string_timer = bui.AppTimer(
 377                1.0,
 378                repeat=True,
 379                call=bui.WeakCall(self._update_time_display, chest.unlock_time),
 380            )
 381
 382        # Allow watching an ad IF the server tells us we can AND we have
 383        # an ad ready to show.
 384        show_ad_button = (
 385            chest.unlock_tokens > 0
 386            and chest.ad_allow
 387            and plus.have_incentivized_ad()
 388        )
 389
 390        bwidth = 130
 391        bheight = 90
 392        bposy = -330 if chest.unlock_tokens == 0 else -340
 393        hspace = 20
 394        boffsx = (hspace * -0.5 - bwidth * 0.5) if show_ad_button else 0.0
 395
 396        self._open_now_button = bui.buttonwidget(
 397            parent=self._root_widget,
 398            position=(
 399                self._width * 0.5 - bwidth * 0.5 + boffsx,
 400                self._yoffs + bposy,
 401            ),
 402            size=(bwidth, bheight),
 403            label='',
 404            button_type='square',
 405            autoselect=True,
 406            on_activate_call=bui.WeakCall(
 407                self._open_press, user_tokens, chest.unlock_tokens
 408            ),
 409            enable_sound=False,
 410        )
 411        self._open_now_images = []
 412        self._open_now_texts = []
 413
 414        iconsize = 50
 415        if chest.unlock_tokens == 0:
 416            self._open_now_texts.append(
 417                bui.textwidget(
 418                    parent=self._root_widget,
 419                    text=bui.Lstr(resource='openText'),
 420                    position=(
 421                        self._width * 0.5 + boffsx,
 422                        self._yoffs + bposy + bheight * 0.5,
 423                    ),
 424                    color=(0, 1, 0),
 425                    draw_controller=self._open_now_button,
 426                    scale=0.7,
 427                    maxwidth=bwidth * 0.8,
 428                    size=(0, 0),
 429                    h_align='center',
 430                    v_align='center',
 431                )
 432            )
 433        else:
 434            self._open_now_texts.append(
 435                bui.textwidget(
 436                    parent=self._root_widget,
 437                    text=bui.Lstr(resource='openNowText'),
 438                    position=(
 439                        self._width * 0.5 + boffsx,
 440                        self._yoffs + bposy + bheight * 1.15,
 441                    ),
 442                    maxwidth=bwidth * 0.8,
 443                    scale=0.7,
 444                    color=(0.7, 1, 0.7),
 445                    size=(0, 0),
 446                    h_align='center',
 447                    v_align='center',
 448                )
 449            )
 450            self._open_now_images.append(
 451                bui.imagewidget(
 452                    parent=self._root_widget,
 453                    size=(iconsize, iconsize),
 454                    position=(
 455                        self._width * 0.5 - iconsize * 0.5 + boffsx,
 456                        self._yoffs + bposy + bheight * 0.35,
 457                    ),
 458                    draw_controller=self._open_now_button,
 459                    texture=bui.gettexture('coin'),
 460                )
 461            )
 462            self._open_now_texts.append(
 463                bui.textwidget(
 464                    parent=self._root_widget,
 465                    text=bui.Lstr(
 466                        resource='tokens.numTokensText',
 467                        subs=[('${COUNT}', str(chest.unlock_tokens))],
 468                    ),
 469                    position=(
 470                        self._width * 0.5 + boffsx,
 471                        self._yoffs + bposy + bheight * 0.25,
 472                    ),
 473                    scale=0.65,
 474                    color=(0, 1, 0),
 475                    draw_controller=self._open_now_button,
 476                    maxwidth=bwidth * 0.8,
 477                    size=(0, 0),
 478                    h_align='center',
 479                    v_align='center',
 480                )
 481            )
 482        self._open_now_spinner = bui.spinnerwidget(
 483            parent=self._root_widget,
 484            position=(
 485                self._width * 0.5 + boffsx,
 486                self._yoffs + bposy + 0.5 * bheight,
 487            ),
 488            visible=False,
 489        )
 490
 491        if show_ad_button:
 492            bui.textwidget(
 493                parent=self._root_widget,
 494                text=bui.Lstr(resource='chests.reduceWaitText'),
 495                position=(
 496                    self._width * 0.5 + hspace * 0.5 + bwidth * 0.5,
 497                    self._yoffs + bposy + bheight * 1.15,
 498                ),
 499                maxwidth=bwidth * 0.8,
 500                scale=0.7,
 501                color=(0.7, 1, 0.7),
 502                size=(0, 0),
 503                h_align='center',
 504                v_align='center',
 505            )
 506            self._watch_ad_button = bui.buttonwidget(
 507                parent=self._root_widget,
 508                position=(
 509                    self._width * 0.5 + hspace * 0.5,
 510                    self._yoffs + bposy,
 511                ),
 512                size=(bwidth, bheight),
 513                label='',
 514                button_type='square',
 515                autoselect=True,
 516                on_activate_call=bui.WeakCall(self._watch_ad_press),
 517                enable_sound=False,
 518            )
 519            bui.imagewidget(
 520                parent=self._root_widget,
 521                size=(iconsize, iconsize),
 522                position=(
 523                    self._width * 0.5
 524                    + hspace * 0.5
 525                    + bwidth * 0.5
 526                    - iconsize * 0.5,
 527                    self._yoffs + bposy + bheight * 0.35,
 528                ),
 529                draw_controller=self._watch_ad_button,
 530                color=(1.5, 1.0, 2.0),
 531                texture=bui.gettexture('tv'),
 532            )
 533            # Note to self: AdMob requires rewarded ad usage
 534            # specifically says 'Ad' in it.
 535            bui.textwidget(
 536                parent=self._root_widget,
 537                text=bui.Lstr(resource='watchAnAdText'),
 538                position=(
 539                    self._width * 0.5 + hspace * 0.5 + bwidth * 0.5,
 540                    self._yoffs + bposy + bheight * 0.25,
 541                ),
 542                scale=0.65,
 543                color=(0, 1, 0),
 544                draw_controller=self._watch_ad_button,
 545                maxwidth=bwidth * 0.8,
 546                size=(0, 0),
 547                h_align='center',
 548                v_align='center',
 549            )
 550
 551        self._show_odds(initial_highlighted_row=-1)
 552
 553    def _highlight_odds_row(self, row: int, extra: bool = False) -> None:
 554
 555        for rindex, imgs in self._prizesetimgs.items():
 556            opacity = (
 557                (0.9 if extra else 0.75)
 558                if rindex == row
 559                else (0.4 if extra else 0.5)
 560            )
 561            for img in imgs:
 562                if img:
 563                    bui.imagewidget(edit=img, opacity=opacity)
 564
 565        for rindex, txts in self._prizesettxts.items():
 566            opacity = (
 567                (0.9 if extra else 0.75)
 568                if rindex == row
 569                else (0.4 if extra else 0.5)
 570            )
 571            for txt in txts:
 572                if txt:
 573                    bui.textwidget(edit=txt, color=(0.7, 0.65, 1, opacity))
 574
 575    def _show_odds(
 576        self,
 577        *,
 578        initial_highlighted_row: int,
 579        initial_highlighted_extra: bool = False,
 580    ) -> None:
 581        # pylint: disable=too-many-locals
 582        xoffs = 110
 583
 584        totalweight = max(0.001, sum(t.weight for t in self._prizesets))
 585
 586        rowheight = 25
 587        totalheight = (len(self._prizesets) + 1) * rowheight
 588        x = self._width * 0.5 + xoffs
 589        y = self._yoffs - 150.0 + totalheight * 0.5
 590
 591        # Title.
 592        bui.textwidget(
 593            parent=self._root_widget,
 594            text=bui.Lstr(resource='chests.prizeOddsText'),
 595            color=(0.7, 0.65, 1, 0.5),
 596            flatness=1.0,
 597            shadow=1.0,
 598            position=(x, y),
 599            scale=0.55,
 600            size=(0, 0),
 601            h_align='left',
 602            v_align='center',
 603        )
 604        y -= 5.0
 605
 606        prizesettxts: list[bui.Widget]
 607        prizesetimgs: list[bui.Widget]
 608
 609        def _mkicon(img: str) -> None:
 610            iconsize = 20.0
 611            nonlocal x
 612            nonlocal prizesetimgs
 613            prizesetimgs.append(
 614                bui.imagewidget(
 615                    parent=self._root_widget,
 616                    size=(iconsize, iconsize),
 617                    position=(x, y - iconsize * 0.5),
 618                    texture=bui.gettexture(img),
 619                    opacity=0.4,
 620                )
 621            )
 622            x += iconsize
 623
 624        def _mktxt(txt: str, advance: bool = True) -> None:
 625            tscale = 0.45
 626            nonlocal x
 627            nonlocal prizesettxts
 628            prizesettxts.append(
 629                bui.textwidget(
 630                    parent=self._root_widget,
 631                    text=txt,
 632                    flatness=1.0,
 633                    shadow=1.0,
 634                    position=(x, y),
 635                    scale=tscale,
 636                    size=(0, 0),
 637                    h_align='left',
 638                    v_align='center',
 639                )
 640            )
 641            if advance:
 642                x += (bui.get_string_width(txt, suppress_warning=True)) * tscale
 643
 644        self._prizesettxts = {}
 645        self._prizesetimgs = {}
 646
 647        for i, p in enumerate(self._prizesets):
 648            prizesettxts = self._prizesettxts.setdefault(i, [])
 649            prizesetimgs = self._prizesetimgs.setdefault(i, [])
 650            x = self._width * 0.5 + xoffs
 651            y -= rowheight
 652            percent = 100.0 * p.weight / totalweight
 653
 654            # Show decimals only if we get very small percentages (looks
 655            # better than rounding as '0%').
 656            percenttxt = (
 657                f'{percent:.2f}%:'
 658                if percent < 0.095
 659                else (
 660                    f'{percent:.1f}%:'
 661                    if percent < 0.95
 662                    else f'{round(percent)}%:'
 663                )
 664            )
 665
 666            # We advance manually here to keep values lined up
 667            # (otherwise single digit percent rows don't line up with
 668            # double digit ones).
 669            _mktxt(percenttxt, advance=False)
 670            x += 35.0
 671
 672            for item in p.contents:
 673                x += 5.0
 674                if isinstance(item.item, bacommon.bs.TicketsDisplayItem):
 675                    _mktxt(str(item.item.count))
 676                    _mkicon('tickets')
 677                elif isinstance(item.item, bacommon.bs.TokensDisplayItem):
 678                    _mktxt(str(item.item.count))
 679                    _mkicon('coin')
 680                else:
 681                    # For other cases just fall back on text desc.
 682                    #
 683                    # Translate the wrapper description and apply any subs.
 684                    descfin = bui.Lstr(
 685                        translate=('serverResponses', item.description)
 686                    ).evaluate()
 687                    subs = (
 688                        []
 689                        if item.description_subs is None
 690                        else item.description_subs
 691                    )
 692                    assert len(subs) % 2 == 0  # Should always be even.
 693                    for j in range(0, len(subs) - 1, 2):
 694                        descfin = descfin.replace(subs[j], subs[j + 1])
 695                    _mktxt(descfin)
 696        self._highlight_odds_row(
 697            initial_highlighted_row, extra=initial_highlighted_extra
 698        )
 699
 700    def _open_press(self, user_tokens: int, token_payment: int) -> None:
 701        from bauiv1lib.gettokens import show_get_tokens_prompt
 702
 703        bui.getsound('click01').play()
 704
 705        # Allow only one in-flight action at once.
 706        if self._action_in_flight:
 707            bui.screenmessage(
 708                bui.Lstr(resource='pleaseWaitText'), color=(1, 0, 0)
 709            )
 710            bui.getsound('error').play()
 711            return
 712
 713        plus = bui.app.plus
 714        assert plus is not None
 715
 716        if plus.accounts.primary is None:
 717            self._error(bui.Lstr(resource='notSignedInText'))
 718            return
 719
 720        # Offer to purchase tokens if they don't have enough.
 721        if user_tokens < token_payment:
 722            # Hack: We disable normal swish for the open button and it
 723            # seems weird without a swish here, so explicitly do one.
 724            bui.getsound('swish').play()
 725            show_get_tokens_prompt()
 726            return
 727
 728        self._action_in_flight = True
 729        with plus.accounts.primary:
 730            plus.cloud.send_message_cb(
 731                bacommon.bs.ChestActionMessage(
 732                    chest_id=str(self._index),
 733                    action=bacommon.bs.ChestActionMessage.Action.UNLOCK,
 734                    token_payment=token_payment,
 735                ),
 736                on_response=bui.WeakCall(self._on_chest_action_response),
 737            )
 738
 739        # Convey that something is in progress.
 740        if self._open_now_button:
 741            bui.spinnerwidget(edit=self._open_now_spinner, visible=True)
 742            for twidget in self._open_now_texts:
 743                bui.textwidget(edit=twidget, color=(1, 1, 1, 0.2))
 744            for iwidget in self._open_now_images:
 745                bui.imagewidget(edit=iwidget, opacity=0.2)
 746
 747    def _watch_ad_press(self) -> None:
 748
 749        bui.getsound('click01').play()
 750
 751        # Allow only one in-flight action at once.
 752        if self._action_in_flight:
 753            bui.screenmessage(
 754                bui.Lstr(resource='pleaseWaitText'), color=(1, 0, 0)
 755            )
 756            bui.getsound('error').play()
 757            return
 758
 759        assert bui.app.classic is not None
 760
 761        self._action_in_flight = True
 762        bui.app.classic.ads.show_ad_2(
 763            'reduce_chest_wait',
 764            on_completion_call=bui.WeakCall(self._watch_ad_complete),
 765        )
 766
 767        # Convey that something is in progress.
 768        if self._watch_ad_button:
 769            bui.buttonwidget(edit=self._watch_ad_button, color=(0.4, 0.4, 0.4))
 770
 771    def _watch_ad_complete(self, actually_showed: bool) -> None:
 772
 773        assert self._action_in_flight  # Should be ad view.
 774        self._action_in_flight = False
 775
 776        if not actually_showed:
 777            return
 778
 779        # Allow only one in-flight action at once.
 780        if self._action_in_flight:
 781            bui.screenmessage(
 782                bui.Lstr(resource='pleaseWaitText'), color=(1, 0, 0)
 783            )
 784            bui.getsound('error').play()
 785            return
 786
 787        plus = bui.app.plus
 788        assert plus is not None
 789
 790        if plus.accounts.primary is None:
 791            self._error(bui.Lstr(resource='notSignedInText'))
 792            return
 793
 794        self._action_in_flight = True
 795        with plus.accounts.primary:
 796            plus.cloud.send_message_cb(
 797                bacommon.bs.ChestActionMessage(
 798                    chest_id=str(self._index),
 799                    action=bacommon.bs.ChestActionMessage.Action.AD,
 800                    token_payment=0,
 801                ),
 802                on_response=bui.WeakCall(self._on_chest_action_response),
 803            )
 804
 805    def _reset(self) -> None:
 806        """Clear all non-permanent widgets and clear infotext."""
 807        for widget in self._root_widget.get_children():
 808            if widget not in self._core_widgets:
 809                widget.delete()
 810        bui.textwidget(edit=self._infotext, text='', color=(1, 1, 1))
 811
 812    def _error(self, msg: str | bui.Lstr, minor: bool = False) -> None:
 813        """Put ourself in an error state with a visible error message."""
 814        self._reset()
 815        bui.textwidget(
 816            edit=self._infotext,
 817            text=msg,
 818            color=(1, 0.5, 0.5) if minor else (1, 0, 0),
 819        )
 820
 821    def _show_about_chest_slots(self) -> None:
 822        # No-op if our ui is dead.
 823        if not self._root_widget:
 824            return
 825
 826        self._reset()
 827        bui.textwidget(
 828            edit=self._infotext,
 829            text=bui.Lstr(resource='chests.slotDescriptionText'),
 830            color=(1, 1, 1),
 831        )
 832
 833    def _show_chest_contents(
 834        self, response: bacommon.bs.ChestActionResponse
 835    ) -> None:
 836        # pylint: disable=too-many-locals
 837
 838        from baclassic import show_display_item
 839
 840        # No-op if our ui is dead.
 841        if not self._root_widget:
 842            return
 843
 844        assert response.contents is not None
 845
 846        # Insert test items for testing.
 847        if bool(False):
 848            response.contents += [
 849                bacommon.bs.DisplayItemWrapper.for_display_item(
 850                    bacommon.bs.TestDisplayItem()
 851                )
 852            ]
 853
 854        tincr = 0.4
 855        tendoffs = tincr * 4.0
 856        toffs = 0.0
 857
 858        bui.getsound('revUp').play(volume=2.0)
 859
 860        # Show nothing but the chest icon and animate it shaking.
 861        self._reset()
 862        imgsize = 145
 863        assert self._chestdisplayinfo is not None
 864        img = bui.imagewidget(
 865            parent=self._root_widget,
 866            color=self._chestdisplayinfo.color,
 867            texture=bui.gettexture(self._chestdisplayinfo.texclosed),
 868            tint_texture=bui.gettexture(self._chestdisplayinfo.texclosedtint),
 869            tint_color=self._chestdisplayinfo.tint,
 870            tint2_color=self._chestdisplayinfo.tint2,
 871        )
 872
 873        def _set_img(x: float, scale: float) -> None:
 874            if not img:
 875                return
 876            bui.imagewidget(
 877                edit=img,
 878                position=(
 879                    self._width * 0.5 - imgsize * scale * 0.5 + x,
 880                    self._yoffs - 223 + imgsize * 0.5 - imgsize * scale * 0.5,
 881                ),
 882                size=(imgsize * scale, imgsize * scale),
 883            )
 884
 885        # Set initial place.
 886        _set_img(0.0, 1.0)
 887
 888        sign = 1.0
 889        while toffs < tendoffs:
 890            toffs += 0.03 * random.uniform(0.5, 1.5)
 891            sign = -sign
 892            bui.apptimer(
 893                toffs,
 894                bui.Call(
 895                    _set_img,
 896                    x=(
 897                        20.0
 898                        * random.uniform(0.3, 1.0)
 899                        * math.pow(toffs / tendoffs, 2.0)
 900                        * sign
 901                    ),
 902                    scale=1.0 - 0.2 * math.pow(toffs / tendoffs, 2.0),
 903                ),
 904            )
 905
 906        xspacing = 100
 907        xoffs = -0.5 * (len(response.contents) - 1) * xspacing
 908        bui.apptimer(
 909            toffs - 0.2, lambda: bui.getsound('corkPop2').play(volume=4.0)
 910        )
 911        # Play a variety of voice sounds.
 912
 913        # We keep a global list of voice options which we randomly pull
 914        # from and refill when empty. This ensures everything gets
 915        # played somewhat frequently and minimizes annoying repeats.
 916        global _g_open_voices  # pylint: disable=global-statement
 917        if not _g_open_voices:
 918            _g_open_voices = [
 919                (0.3, 'woo3', 2.5),
 920                (0.1, 'gasp', 1.3),
 921                (0.2, 'woo2', 2.0),
 922                (0.2, 'wow', 2.0),
 923                (0.2, 'kronk2', 2.0),
 924                (0.2, 'mel03', 2.0),
 925                (0.2, 'aww', 2.0),
 926                (0.4, 'nice', 2.0),
 927                (0.3, 'yeah', 1.5),
 928                (0.2, 'woo', 1.0),
 929                (0.5, 'ooh', 0.8),
 930            ]
 931
 932        voicetimeoffs, voicename, volume = _g_open_voices.pop(
 933            random.randrange(len(_g_open_voices))
 934        )
 935        bui.apptimer(
 936            toffs + voicetimeoffs,
 937            lambda: bui.getsound(voicename).play(volume=volume),
 938        )
 939
 940        toffsopen = toffs
 941        bui.apptimer(toffs, bui.WeakCall(self._show_chest_opening))
 942        toffs += tincr * 1.0
 943        width = xspacing * 0.95
 944
 945        for item in response.contents:
 946            toffs += tincr
 947            bui.apptimer(
 948                toffs - 0.1, lambda: bui.getsound('cashRegister').play()
 949            )
 950            bui.apptimer(
 951                toffs,
 952                strict_partial(
 953                    show_display_item,
 954                    item,
 955                    self._root_widget,
 956                    pos=(
 957                        self._width * 0.5 + xoffs,
 958                        self._yoffs - 250.0,
 959                    ),
 960                    width=width,
 961                ),
 962            )
 963            xoffs += xspacing
 964        toffs += tincr
 965        bui.apptimer(toffs, bui.WeakCall(self._show_done_button))
 966
 967        self._show_odds(initial_highlighted_row=-1)
 968
 969        # Store this for later
 970        self._prizeindex = response.prizeindex
 971
 972        # The final result was already randomly selected on the server,
 973        # but we want to give the illusion of randomness here, so cycle
 974        # through highlighting our options and stop on the winner when
 975        # the chest opens. To do this, we start at the end at the prize
 976        # and work backwards setting timers.
 977        if self._prizesets:
 978            toffs2 = toffsopen - 0.01
 979            amt = 0.02
 980            i = self._prizeindex
 981            while toffs2 > 0.0:
 982                bui.apptimer(
 983                    toffs2,
 984                    bui.WeakCall(self._highlight_odds_row, i),
 985                )
 986                toffs2 -= amt
 987                amt *= 1.05 * random.uniform(0.9, 1.1)
 988                i = (i - 1) % len(self._prizesets)
 989
 990    def _show_chest_opening(self) -> None:
 991
 992        # No-op if our ui is dead.
 993        if not self._root_widget:
 994            return
 995
 996        self._reset()
 997        imgsize = 145
 998        bui.getsound('hiss').play()
 999        assert self._chestdisplayinfo is not None
1000        img = bui.imagewidget(
1001            parent=self._root_widget,
1002            color=self._chestdisplayinfo.color,
1003            texture=bui.gettexture(self._chestdisplayinfo.texopen),
1004            tint_texture=bui.gettexture(self._chestdisplayinfo.texopentint),
1005            tint_color=self._chestdisplayinfo.tint,
1006            tint2_color=self._chestdisplayinfo.tint2,
1007        )
1008        tincr = 0.8
1009        tendoffs = tincr * 2.0
1010        toffs = 0.0
1011
1012        def _set_img(x: float, scale: float) -> None:
1013            if not img:
1014                return
1015            bui.imagewidget(
1016                edit=img,
1017                position=(
1018                    self._width * 0.5 - imgsize * scale * 0.5 + x,
1019                    self._yoffs - 223 + imgsize * 0.5 - imgsize * scale * 0.5,
1020                ),
1021                size=(imgsize * scale, imgsize * scale),
1022            )
1023
1024        # Set initial place.
1025        _set_img(0.0, 1.0)
1026
1027        sign = 1.0
1028        while toffs < tendoffs:
1029            toffs += 0.03 * random.uniform(0.5, 1.5)
1030            sign = -sign
1031            # Note: we speed x along here (multing toffs) so position
1032            # comes to rest before scale.
1033            bui.apptimer(
1034                toffs,
1035                bui.Call(
1036                    _set_img,
1037                    x=(
1038                        1.0
1039                        * random.uniform(0.3, 1.0)
1040                        * (
1041                            1.0
1042                            - math.pow(min(1.0, 3.0 * toffs / tendoffs), 2.0)
1043                        )
1044                        * sign
1045                    ),
1046                    scale=1.0 - 0.1 * math.pow(toffs / tendoffs, 0.5),
1047                ),
1048            )
1049
1050        self._show_odds(
1051            initial_highlighted_row=self._prizeindex,
1052            initial_highlighted_extra=True,
1053        )
1054
1055    def _show_done_button(self) -> None:
1056        # No-op if our ui is dead.
1057        if not self._root_widget:
1058            return
1059
1060        bwidth = 200
1061        bheight = 60
1062
1063        btn = bui.buttonwidget(
1064            parent=self._root_widget,
1065            position=(
1066                self._width * 0.5 - bwidth * 0.5,
1067                self._yoffs - 350,
1068            ),
1069            size=(bwidth, bheight),
1070            label=bui.Lstr(resource='doneText'),
1071            autoselect=True,
1072            on_activate_call=self.main_window_back,
1073        )
1074        bui.containerwidget(edit=self._root_widget, start_button=btn)

Allows viewing and performing operations on a chest.

ChestWindow( index: int, transition: str | None = 'in_right', origin_widget: _bauiv1.Widget | None = None)
 28    def __init__(
 29        self,
 30        index: int,
 31        transition: str | None = 'in_right',
 32        origin_widget: bui.Widget | None = None,
 33    ):
 34        self._index = index
 35
 36        assert bui.app.classic is not None
 37        uiscale = bui.app.ui_v1.uiscale
 38        self._width = 1200 if uiscale is bui.UIScale.SMALL else 650
 39        self._height = 800 if uiscale is bui.UIScale.SMALL else 450
 40        self._action_in_flight = False
 41        self._open_now_button: bui.Widget | None = None
 42        self._open_now_spinner: bui.Widget | None = None
 43        self._open_now_texts: list[bui.Widget] = []
 44        self._open_now_images: list[bui.Widget] = []
 45        self._watch_ad_button: bui.Widget | None = None
 46        self._time_string_timer: bui.AppTimer | None = None
 47        self._time_string_text: bui.Widget | None = None
 48        self._prizesets: list[bacommon.bs.ChestInfoResponse.Chest.PrizeSet] = []
 49        self._prizeindex = -1
 50        self._prizesettxts: dict[int, list[bui.Widget]] = {}
 51        self._prizesetimgs: dict[int, list[bui.Widget]] = {}
 52        self._chestdisplayinfo: baclassic.ChestAppearanceDisplayInfo | None = (
 53            None
 54        )
 55
 56        # The set of widgets we keep when doing a clear.
 57        self._core_widgets: list[bui.Widget] = []
 58
 59        # Do some fancy math to fill all available screen area up to the
 60        # size of our backing container. This lets us fit to the exact
 61        # screen shape at small ui scale.
 62        screensize = bui.get_virtual_screen_size()
 63        scale = (
 64            1.8
 65            if uiscale is bui.UIScale.SMALL
 66            else 1.1 if uiscale is bui.UIScale.MEDIUM else 0.9
 67        )
 68        # Calc screen size in our local container space and clamp to a
 69        # bit smaller than our container size.
 70        target_height = min(self._height - 120, screensize[1] / scale)
 71
 72        # To get top/left coords, go to the center of our window and
 73        # offset by half the width/height of our target area.
 74        self._yoffstop = 0.5 * self._height + 0.5 * target_height + 18
 75
 76        # Offset for stuff we want centered.
 77        self._yoffs = 0.5 * self._height + (
 78            220 if uiscale is bui.UIScale.SMALL else 190
 79        )
 80
 81        self._chest_yoffs = self._yoffs - 223
 82
 83        super().__init__(
 84            root_widget=bui.containerwidget(
 85                size=(self._width, self._height),
 86                toolbar_visibility='menu_full',
 87                scale=scale,
 88            ),
 89            transition=transition,
 90            origin_widget=origin_widget,
 91            # We're affected by screen size only at small ui-scale.
 92            refresh_on_screen_size_changes=uiscale is bui.UIScale.SMALL,
 93        )
 94
 95        # Tell the root-ui to stop updating toolbar values immediately;
 96        # this allows it to run animations based on the results of our
 97        # chest opening.
 98        bui.root_ui_pause_updates()
 99        self._root_ui_updates_paused = True
100
101        self._title_text = bui.textwidget(
102            parent=self._root_widget,
103            position=(
104                self._width * 0.5,
105                self._yoffstop - (36 if uiscale is bui.UIScale.SMALL else 10),
106            ),
107            size=(0, 0),
108            text=bui.Lstr(
109                resource='chests.slotText',
110                subs=[('${NUM}', str(index + 1))],
111            ),
112            color=bui.app.ui_v1.title_color,
113            maxwidth=110.0 if uiscale is bui.UIScale.SMALL else 200,
114            scale=0.9 if uiscale is bui.UIScale.SMALL else 1.1,
115            h_align='center',
116            v_align='center',
117        )
118        self._core_widgets.append(self._title_text)
119
120        if uiscale is bui.UIScale.SMALL:
121            bui.containerwidget(
122                edit=self._root_widget, on_cancel_call=self.main_window_back
123            )
124        else:
125            btn = bui.buttonwidget(
126                parent=self._root_widget,
127                position=(50, self._yoffs - 44),
128                size=(60, 55),
129                scale=0.8,
130                label=bui.charstr(bui.SpecialChar.BACK),
131                button_type='backSmall',
132                extra_touch_border_scale=2.0,
133                autoselect=True,
134                on_activate_call=self.main_window_back,
135            )
136            bui.containerwidget(edit=self._root_widget, cancel_button=btn)
137            self._core_widgets.append(btn)
138
139        # Note: Don't need to explicitly clean this up. Just not adding
140        # it to core_widgets so it will go away on next reset.
141        self._loadingspinner = bui.spinnerwidget(
142            parent=self._root_widget,
143            position=(self._width * 0.5, self._height * 0.5),
144            size=48,
145            style='bomb',
146        )
147
148        self._infotext = bui.textwidget(
149            parent=self._root_widget,
150            position=(self._width * 0.5, self._yoffs - 200),
151            size=(0, 0),
152            text='',
153            maxwidth=700,
154            scale=0.8,
155            color=(0.6, 0.5, 0.6),
156            h_align='center',
157            v_align='center',
158        )
159        self._core_widgets.append(self._infotext)
160
161        plus = bui.app.plus
162        if plus is None:
163            self._error('Plus feature-set is not present.')
164            return
165
166        if plus.accounts.primary is None:
167            self._error(bui.Lstr(resource='notSignedInText'))
168            return
169
170        # Start by showing info/options for our target chest. Note that
171        # we always ask the server for these values even though we may
172        # have them through our appmode subscription which updates the
173        # chest UI. This is because the wait_for_connectivity()
174        # mechanism will often bring our window up a split second before
175        # the chest subscription receives its first values which would
176        # lead us to incorrectly think there is no chest there. If we
177        # want to optimize this in the future we could perhaps use local
178        # values only if there is a chest present in them.
179        assert not self._action_in_flight
180        self._action_in_flight = True
181        with plus.accounts.primary:
182            plus.cloud.send_message_cb(
183                bacommon.bs.ChestInfoMessage(chest_id=str(self._index)),
184                on_response=bui.WeakCall(self._on_chest_info_response),
185            )

Create a MainWindow given a root widget and transition info.

Automatically handles in and out transitions on the provided widget, so there is no need to set transitions when creating it.

@override
def get_main_window_state(self) -> bauiv1.MainWindowState:
193    @override
194    def get_main_window_state(self) -> bui.MainWindowState:
195        # Support recreating our window for back/refresh purposes.
196        cls = type(self)
197
198        # Pull anything we need from self out here; if we do it in the
199        # lambda we keep self alive which is bad.
200        index = self._index
201
202        return bui.BasicMainWindowState(
203            create_call=lambda transition, origin_widget: cls(
204                index=index, transition=transition, origin_widget=origin_widget
205            )
206        )

Return a WindowState to recreate this window, if supported.

class ChestWindow0(ChestWindow):
1084class ChestWindow0(ChestWindow):
1085    """Child class of ChestWindow for slighty hackish reasons."""

Child class of ChestWindow for slighty hackish reasons.

class ChestWindow1(ChestWindow):
1088class ChestWindow1(ChestWindow):
1089    """Child class of ChestWindow for slighty hackish reasons."""

Child class of ChestWindow for slighty hackish reasons.

class ChestWindow2(ChestWindow):
1092class ChestWindow2(ChestWindow):
1093    """Child class of ChestWindow for slighty hackish reasons."""

Child class of ChestWindow for slighty hackish reasons.

class ChestWindow3(ChestWindow):
1096class ChestWindow3(ChestWindow):
1097    """Child class of ChestWindow for slighty hackish reasons."""

Child class of ChestWindow for slighty hackish reasons.