bastd.ui.specialoffer

UI for presenting sales/etc.

  1# Released under the MIT License. See LICENSE for details.
  2#
  3"""UI for presenting sales/etc."""
  4
  5from __future__ import annotations
  6
  7import copy
  8from typing import TYPE_CHECKING
  9
 10import _ba
 11import ba
 12
 13if TYPE_CHECKING:
 14    from typing import Any
 15
 16
 17class SpecialOfferWindow(ba.Window):
 18    """Window for presenting sales/etc."""
 19
 20    def __init__(self, offer: dict[str, Any], transition: str = 'in_right'):
 21        # pylint: disable=too-many-statements
 22        # pylint: disable=too-many-branches
 23        # pylint: disable=too-many-locals
 24        from ba.internal import (get_store_item_display_size, get_clean_price)
 25        from ba import SpecialChar
 26        from bastd.ui.store import item as storeitemui
 27        self._cancel_delay = offer.get('cancelDelay', 0)
 28
 29        # First thing: if we're offering pro or an IAP, see if we have a
 30        # price for it.
 31        # If not, abort and go into zombie mode (the user should never see
 32        # us that way).
 33
 34        real_price: str | None
 35
 36        # Misnomer: 'pro' actually means offer 'pro_sale'.
 37        if offer['item'] in ['pro', 'pro_fullprice']:
 38            real_price = _ba.get_price('pro' if offer['item'] ==
 39                                       'pro_fullprice' else 'pro_sale')
 40            if real_price is None and ba.app.debug_build:
 41                print('NOTE: Faking prices for debug build.')
 42                real_price = '$1.23'
 43            zombie = real_price is None
 44        elif isinstance(offer['price'], str):
 45            # (a string price implies IAP id)
 46            real_price = _ba.get_price(offer['price'])
 47            if real_price is None and ba.app.debug_build:
 48                print('NOTE: Faking price for debug build.')
 49                real_price = '$1.23'
 50            zombie = real_price is None
 51        else:
 52            real_price = None
 53            zombie = False
 54        if real_price is None:
 55            real_price = '?'
 56
 57        if offer['item'] in ['pro', 'pro_fullprice']:
 58            self._offer_item = 'pro'
 59        else:
 60            self._offer_item = offer['item']
 61
 62        # If we wanted a real price but didn't find one, go zombie.
 63        if zombie:
 64            return
 65
 66        # This can pop up suddenly, so lets block input for 1 second.
 67        _ba.lock_all_input()
 68        ba.timer(1.0, _ba.unlock_all_input, timetype=ba.TimeType.REAL)
 69        ba.playsound(ba.getsound('ding'))
 70        ba.timer(0.3,
 71                 lambda: ba.playsound(ba.getsound('ooh')),
 72                 timetype=ba.TimeType.REAL)
 73        self._offer = copy.deepcopy(offer)
 74        self._width = 580
 75        self._height = 590
 76        uiscale = ba.app.ui.uiscale
 77        super().__init__(root_widget=ba.containerwidget(
 78            size=(self._width, self._height),
 79            transition=transition,
 80            scale=(1.2 if uiscale is ba.UIScale.SMALL else
 81                   1.15 if uiscale is ba.UIScale.MEDIUM else 1.0),
 82            stack_offset=(0, -15) if uiscale is ba.UIScale.SMALL else (0, 0)))
 83        self._is_bundle_sale = False
 84        try:
 85            if offer['item'] in ['pro', 'pro_fullprice']:
 86                original_price_str = _ba.get_price('pro')
 87                if original_price_str is None:
 88                    original_price_str = '?'
 89                new_price_str = _ba.get_price('pro_sale')
 90                if new_price_str is None:
 91                    new_price_str = '?'
 92                percent_off_text = ''
 93            else:
 94                # If the offer includes bonus tickets, it's a bundle-sale.
 95                if ('bonusTickets' in offer
 96                        and offer['bonusTickets'] is not None):
 97                    self._is_bundle_sale = True
 98                original_price = _ba.get_v1_account_misc_read_val(
 99                    'price.' + self._offer_item, 9999)
100
101                # For pure ticket prices we can show a percent-off.
102                if isinstance(offer['price'], int):
103                    new_price = offer['price']
104                    tchar = ba.charstr(SpecialChar.TICKET)
105                    original_price_str = tchar + str(original_price)
106                    new_price_str = tchar + str(new_price)
107                    percent_off = int(
108                        round(100.0 -
109                              (float(new_price) / original_price) * 100.0))
110                    percent_off_text = ' ' + ba.Lstr(
111                        resource='store.salePercentText').evaluate().replace(
112                            '${PERCENT}', str(percent_off))
113                else:
114                    original_price_str = new_price_str = '?'
115                    percent_off_text = ''
116
117        except Exception:
118            print(f'Offer: {offer}')
119            ba.print_exception('Error setting up special-offer')
120            original_price_str = new_price_str = '?'
121            percent_off_text = ''
122
123        # If its a bundle sale, change the title.
124        if self._is_bundle_sale:
125            sale_text = ba.Lstr(resource='store.saleBundleText',
126                                fallback_resource='store.saleText').evaluate()
127        else:
128            # For full pro we say 'Upgrade?' since its not really a sale.
129            if offer['item'] == 'pro_fullprice':
130                sale_text = ba.Lstr(
131                    resource='store.upgradeQuestionText',
132                    fallback_resource='store.saleExclaimText').evaluate()
133            else:
134                sale_text = ba.Lstr(
135                    resource='store.saleExclaimText',
136                    fallback_resource='store.saleText').evaluate()
137
138        self._title_text = ba.textwidget(
139            parent=self._root_widget,
140            position=(self._width * 0.5, self._height - 40),
141            size=(0, 0),
142            text=sale_text +
143            ((' ' + ba.Lstr(resource='store.oneTimeOnlyText').evaluate())
144             if self._offer['oneTimeOnly'] else '') + percent_off_text,
145            h_align='center',
146            v_align='center',
147            maxwidth=self._width * 0.9 - 220,
148            scale=1.4,
149            color=(0.3, 1, 0.3))
150
151        self._flash_on = False
152        self._flashing_timer: ba.Timer | None = ba.Timer(
153            0.05,
154            ba.WeakCall(self._flash_cycle),
155            repeat=True,
156            timetype=ba.TimeType.REAL)
157        ba.timer(0.6,
158                 ba.WeakCall(self._stop_flashing),
159                 timetype=ba.TimeType.REAL)
160
161        size = get_store_item_display_size(self._offer_item)
162        display: dict[str, Any] = {}
163        storeitemui.instantiate_store_item_display(
164            self._offer_item,
165            display,
166            parent_widget=self._root_widget,
167            b_pos=(self._width * 0.5 - size[0] * 0.5 + 10 -
168                   ((size[0] * 0.5 + 30) if self._is_bundle_sale else 0),
169                   self._height * 0.5 - size[1] * 0.5 + 20 +
170                   (20 if self._is_bundle_sale else 0)),
171            b_width=size[0],
172            b_height=size[1],
173            button=not self._is_bundle_sale)
174
175        # Wire up the parts we need.
176        if self._is_bundle_sale:
177            self._plus_text = ba.textwidget(parent=self._root_widget,
178                                            position=(self._width * 0.5,
179                                                      self._height * 0.5 + 50),
180                                            size=(0, 0),
181                                            text='+',
182                                            h_align='center',
183                                            v_align='center',
184                                            maxwidth=self._width * 0.9,
185                                            scale=1.4,
186                                            color=(0.5, 0.5, 0.5))
187            self._plus_tickets = ba.textwidget(
188                parent=self._root_widget,
189                position=(self._width * 0.5 + 120, self._height * 0.5 + 50),
190                size=(0, 0),
191                text=ba.charstr(SpecialChar.TICKET_BACKING) +
192                str(offer['bonusTickets']),
193                h_align='center',
194                v_align='center',
195                maxwidth=self._width * 0.9,
196                scale=2.5,
197                color=(0.2, 1, 0.2))
198            self._price_text = ba.textwidget(parent=self._root_widget,
199                                             position=(self._width * 0.5, 150),
200                                             size=(0, 0),
201                                             text=real_price,
202                                             h_align='center',
203                                             v_align='center',
204                                             maxwidth=self._width * 0.9,
205                                             scale=1.4,
206                                             color=(0.2, 1, 0.2))
207            # Total-value if they supplied it.
208            total_worth_item = offer.get('valueItem', None)
209            if total_worth_item is not None:
210                price = _ba.get_price(total_worth_item)
211                total_worth_price = (get_clean_price(price)
212                                     if price is not None else None)
213                if total_worth_price is not None:
214                    total_worth_text = ba.Lstr(resource='store.totalWorthText',
215                                               subs=[('${TOTAL_WORTH}',
216                                                      total_worth_price)])
217                    self._total_worth_text = ba.textwidget(
218                        parent=self._root_widget,
219                        text=total_worth_text,
220                        position=(self._width * 0.5, 210),
221                        scale=0.9,
222                        maxwidth=self._width * 0.7,
223                        size=(0, 0),
224                        h_align='center',
225                        v_align='center',
226                        shadow=1.0,
227                        flatness=1.0,
228                        color=(0.3, 1, 1))
229
230        elif offer['item'] == 'pro_fullprice':
231            # for full-price pro we simply show full price
232            ba.textwidget(edit=display['price_widget'], text=real_price)
233            ba.buttonwidget(edit=display['button'],
234                            on_activate_call=self._purchase)
235        else:
236            # Show old/new prices otherwise (for pro sale).
237            ba.buttonwidget(edit=display['button'],
238                            on_activate_call=self._purchase)
239            ba.imagewidget(edit=display['price_slash_widget'], opacity=1.0)
240            ba.textwidget(edit=display['price_widget_left'],
241                          text=original_price_str)
242            ba.textwidget(edit=display['price_widget_right'],
243                          text=new_price_str)
244
245        # Add ticket button only if this is ticket-purchasable.
246        if isinstance(offer.get('price'), int):
247            self._get_tickets_button = ba.buttonwidget(
248                parent=self._root_widget,
249                position=(self._width - 125, self._height - 68),
250                size=(90, 55),
251                scale=1.0,
252                button_type='square',
253                color=(0.7, 0.5, 0.85),
254                textcolor=(0.2, 1, 0.2),
255                autoselect=True,
256                label=ba.Lstr(resource='getTicketsWindow.titleText'),
257                on_activate_call=self._on_get_more_tickets_press)
258
259            self._ticket_text_update_timer = ba.Timer(
260                1.0,
261                ba.WeakCall(self._update_tickets_text),
262                timetype=ba.TimeType.REAL,
263                repeat=True)
264            self._update_tickets_text()
265
266        self._update_timer = ba.Timer(1.0,
267                                      ba.WeakCall(self._update),
268                                      timetype=ba.TimeType.REAL,
269                                      repeat=True)
270
271        self._cancel_button = ba.buttonwidget(
272            parent=self._root_widget,
273            position=(50, 40) if self._is_bundle_sale else
274            (self._width * 0.5 - 75, 40),
275            size=(150, 60),
276            scale=1.0,
277            on_activate_call=self._cancel,
278            autoselect=True,
279            label=ba.Lstr(resource='noThanksText'))
280        self._cancel_countdown_text = ba.textwidget(
281            parent=self._root_widget,
282            text='',
283            position=(50 + 150 + 20, 40 + 27) if self._is_bundle_sale else
284            (self._width * 0.5 - 75 + 150 + 20, 40 + 27),
285            scale=1.1,
286            size=(0, 0),
287            h_align='left',
288            v_align='center',
289            shadow=1.0,
290            flatness=1.0,
291            color=(0.6, 0.5, 0.5))
292        self._update_cancel_button_graphics()
293
294        if self._is_bundle_sale:
295            self._purchase_button = ba.buttonwidget(
296                parent=self._root_widget,
297                position=(self._width - 200, 40),
298                size=(150, 60),
299                scale=1.0,
300                on_activate_call=self._purchase,
301                autoselect=True,
302                label=ba.Lstr(resource='store.purchaseText'))
303
304        ba.containerwidget(edit=self._root_widget,
305                           cancel_button=self._cancel_button,
306                           start_button=self._purchase_button
307                           if self._is_bundle_sale else None,
308                           selected_child=self._purchase_button
309                           if self._is_bundle_sale else display['button'])
310
311    def _stop_flashing(self) -> None:
312        self._flashing_timer = None
313        ba.textwidget(edit=self._title_text, color=(0.3, 1, 0.3))
314
315    def _flash_cycle(self) -> None:
316        if not self._root_widget:
317            return
318        self._flash_on = not self._flash_on
319        ba.textwidget(edit=self._title_text,
320                      color=(0.3, 1, 0.3) if self._flash_on else (1, 0.5, 0))
321
322    def _update_cancel_button_graphics(self) -> None:
323        ba.buttonwidget(edit=self._cancel_button,
324                        color=(0.5, 0.5, 0.5) if self._cancel_delay > 0 else
325                        (0.7, 0.4, 0.34),
326                        textcolor=(0.5, 0.5,
327                                   0.5) if self._cancel_delay > 0 else
328                        (0.9, 0.9, 1.0))
329        ba.textwidget(
330            edit=self._cancel_countdown_text,
331            text=str(self._cancel_delay) if self._cancel_delay > 0 else '')
332
333    def _update(self) -> None:
334
335        # If we've got seconds left on our countdown, update it.
336        if self._cancel_delay > 0:
337            self._cancel_delay = max(0, self._cancel_delay - 1)
338            self._update_cancel_button_graphics()
339
340        can_die = False
341
342        # We go away if we see that our target item is owned.
343        if self._offer_item == 'pro':
344            if _ba.app.accounts_v1.have_pro():
345                can_die = True
346        else:
347            if _ba.get_purchased(self._offer_item):
348                can_die = True
349
350        if can_die:
351            self._transition_out('out_left')
352
353    def _transition_out(self, transition: str = 'out_left') -> None:
354        # Also clear any pending-special-offer we've stored at this point.
355        cfg = ba.app.config
356        if 'pendingSpecialOffer' in cfg:
357            del cfg['pendingSpecialOffer']
358            cfg.commit()
359
360        ba.containerwidget(edit=self._root_widget, transition=transition)
361
362    def _update_tickets_text(self) -> None:
363        from ba import SpecialChar
364        if not self._root_widget:
365            return
366        sval: str | ba.Lstr
367        if _ba.get_v1_account_state() == 'signed_in':
368            sval = (ba.charstr(SpecialChar.TICKET) +
369                    str(_ba.get_v1_account_ticket_count()))
370        else:
371            sval = ba.Lstr(resource='getTicketsWindow.titleText')
372        ba.buttonwidget(edit=self._get_tickets_button, label=sval)
373
374    def _on_get_more_tickets_press(self) -> None:
375        from bastd.ui import account
376        from bastd.ui import getcurrency
377        if _ba.get_v1_account_state() != 'signed_in':
378            account.show_sign_in_prompt()
379            return
380        getcurrency.GetCurrencyWindow(modal=True).get_root_widget()
381
382    def _purchase(self) -> None:
383        from ba.internal import get_store_item_name_translated
384        from bastd.ui import getcurrency
385        from bastd.ui import confirm
386        if self._offer['item'] == 'pro':
387            _ba.purchase('pro_sale')
388        elif self._offer['item'] == 'pro_fullprice':
389            _ba.purchase('pro')
390        elif self._is_bundle_sale:
391            # With bundle sales, the price is the name of the IAP.
392            _ba.purchase(self._offer['price'])
393        else:
394            ticket_count: int | None
395            try:
396                ticket_count = _ba.get_v1_account_ticket_count()
397            except Exception:
398                ticket_count = None
399            if (ticket_count is not None
400                    and ticket_count < self._offer['price']):
401                getcurrency.show_get_tickets_prompt()
402                ba.playsound(ba.getsound('error'))
403                return
404
405            def do_it() -> None:
406                _ba.in_game_purchase('offer:' + str(self._offer['id']),
407                                     self._offer['price'])
408
409            ba.playsound(ba.getsound('swish'))
410            confirm.ConfirmWindow(ba.Lstr(
411                resource='store.purchaseConfirmText',
412                subs=[('${ITEM}',
413                       get_store_item_name_translated(self._offer['item']))]),
414                                  width=400,
415                                  height=120,
416                                  action=do_it,
417                                  ok_text=ba.Lstr(
418                                      resource='store.purchaseText',
419                                      fallback_resource='okText'))
420
421    def _cancel(self) -> None:
422        if self._cancel_delay > 0:
423            ba.playsound(ba.getsound('error'))
424            return
425        self._transition_out('out_right')
426
427
428def show_offer() -> bool:
429    """(internal)"""
430    try:
431        from bastd.ui import feedback
432        app = ba.app
433
434        # Space things out a bit so we don't hit the poor user with an ad and
435        # then an in-game offer.
436        has_been_long_enough_since_ad = True
437        if (app.ads.last_ad_completion_time is not None and
438            (ba.time(ba.TimeType.REAL) - app.ads.last_ad_completion_time <
439             30.0)):
440            has_been_long_enough_since_ad = False
441
442        if app.special_offer is not None and has_been_long_enough_since_ad:
443
444            # Special case: for pro offers, store this in our prefs so we
445            # can re-show it if the user kills us (set phasers to 'NAG'!!!).
446            if app.special_offer.get('item') == 'pro_fullprice':
447                cfg = app.config
448                cfg['pendingSpecialOffer'] = {
449                    'a': _ba.get_public_login_id(),
450                    'o': app.special_offer
451                }
452                cfg.commit()
453
454            with ba.Context('ui'):
455                if app.special_offer['item'] == 'rating':
456                    feedback.ask_for_rating()
457                else:
458                    SpecialOfferWindow(app.special_offer)
459
460            app.special_offer = None
461            return True
462    except Exception:
463        ba.print_exception('Error showing offer.')
464
465    return False
class SpecialOfferWindow(ba.ui.Window):
 18class SpecialOfferWindow(ba.Window):
 19    """Window for presenting sales/etc."""
 20
 21    def __init__(self, offer: dict[str, Any], transition: str = 'in_right'):
 22        # pylint: disable=too-many-statements
 23        # pylint: disable=too-many-branches
 24        # pylint: disable=too-many-locals
 25        from ba.internal import (get_store_item_display_size, get_clean_price)
 26        from ba import SpecialChar
 27        from bastd.ui.store import item as storeitemui
 28        self._cancel_delay = offer.get('cancelDelay', 0)
 29
 30        # First thing: if we're offering pro or an IAP, see if we have a
 31        # price for it.
 32        # If not, abort and go into zombie mode (the user should never see
 33        # us that way).
 34
 35        real_price: str | None
 36
 37        # Misnomer: 'pro' actually means offer 'pro_sale'.
 38        if offer['item'] in ['pro', 'pro_fullprice']:
 39            real_price = _ba.get_price('pro' if offer['item'] ==
 40                                       'pro_fullprice' else 'pro_sale')
 41            if real_price is None and ba.app.debug_build:
 42                print('NOTE: Faking prices for debug build.')
 43                real_price = '$1.23'
 44            zombie = real_price is None
 45        elif isinstance(offer['price'], str):
 46            # (a string price implies IAP id)
 47            real_price = _ba.get_price(offer['price'])
 48            if real_price is None and ba.app.debug_build:
 49                print('NOTE: Faking price for debug build.')
 50                real_price = '$1.23'
 51            zombie = real_price is None
 52        else:
 53            real_price = None
 54            zombie = False
 55        if real_price is None:
 56            real_price = '?'
 57
 58        if offer['item'] in ['pro', 'pro_fullprice']:
 59            self._offer_item = 'pro'
 60        else:
 61            self._offer_item = offer['item']
 62
 63        # If we wanted a real price but didn't find one, go zombie.
 64        if zombie:
 65            return
 66
 67        # This can pop up suddenly, so lets block input for 1 second.
 68        _ba.lock_all_input()
 69        ba.timer(1.0, _ba.unlock_all_input, timetype=ba.TimeType.REAL)
 70        ba.playsound(ba.getsound('ding'))
 71        ba.timer(0.3,
 72                 lambda: ba.playsound(ba.getsound('ooh')),
 73                 timetype=ba.TimeType.REAL)
 74        self._offer = copy.deepcopy(offer)
 75        self._width = 580
 76        self._height = 590
 77        uiscale = ba.app.ui.uiscale
 78        super().__init__(root_widget=ba.containerwidget(
 79            size=(self._width, self._height),
 80            transition=transition,
 81            scale=(1.2 if uiscale is ba.UIScale.SMALL else
 82                   1.15 if uiscale is ba.UIScale.MEDIUM else 1.0),
 83            stack_offset=(0, -15) if uiscale is ba.UIScale.SMALL else (0, 0)))
 84        self._is_bundle_sale = False
 85        try:
 86            if offer['item'] in ['pro', 'pro_fullprice']:
 87                original_price_str = _ba.get_price('pro')
 88                if original_price_str is None:
 89                    original_price_str = '?'
 90                new_price_str = _ba.get_price('pro_sale')
 91                if new_price_str is None:
 92                    new_price_str = '?'
 93                percent_off_text = ''
 94            else:
 95                # If the offer includes bonus tickets, it's a bundle-sale.
 96                if ('bonusTickets' in offer
 97                        and offer['bonusTickets'] is not None):
 98                    self._is_bundle_sale = True
 99                original_price = _ba.get_v1_account_misc_read_val(
100                    'price.' + self._offer_item, 9999)
101
102                # For pure ticket prices we can show a percent-off.
103                if isinstance(offer['price'], int):
104                    new_price = offer['price']
105                    tchar = ba.charstr(SpecialChar.TICKET)
106                    original_price_str = tchar + str(original_price)
107                    new_price_str = tchar + str(new_price)
108                    percent_off = int(
109                        round(100.0 -
110                              (float(new_price) / original_price) * 100.0))
111                    percent_off_text = ' ' + ba.Lstr(
112                        resource='store.salePercentText').evaluate().replace(
113                            '${PERCENT}', str(percent_off))
114                else:
115                    original_price_str = new_price_str = '?'
116                    percent_off_text = ''
117
118        except Exception:
119            print(f'Offer: {offer}')
120            ba.print_exception('Error setting up special-offer')
121            original_price_str = new_price_str = '?'
122            percent_off_text = ''
123
124        # If its a bundle sale, change the title.
125        if self._is_bundle_sale:
126            sale_text = ba.Lstr(resource='store.saleBundleText',
127                                fallback_resource='store.saleText').evaluate()
128        else:
129            # For full pro we say 'Upgrade?' since its not really a sale.
130            if offer['item'] == 'pro_fullprice':
131                sale_text = ba.Lstr(
132                    resource='store.upgradeQuestionText',
133                    fallback_resource='store.saleExclaimText').evaluate()
134            else:
135                sale_text = ba.Lstr(
136                    resource='store.saleExclaimText',
137                    fallback_resource='store.saleText').evaluate()
138
139        self._title_text = ba.textwidget(
140            parent=self._root_widget,
141            position=(self._width * 0.5, self._height - 40),
142            size=(0, 0),
143            text=sale_text +
144            ((' ' + ba.Lstr(resource='store.oneTimeOnlyText').evaluate())
145             if self._offer['oneTimeOnly'] else '') + percent_off_text,
146            h_align='center',
147            v_align='center',
148            maxwidth=self._width * 0.9 - 220,
149            scale=1.4,
150            color=(0.3, 1, 0.3))
151
152        self._flash_on = False
153        self._flashing_timer: ba.Timer | None = ba.Timer(
154            0.05,
155            ba.WeakCall(self._flash_cycle),
156            repeat=True,
157            timetype=ba.TimeType.REAL)
158        ba.timer(0.6,
159                 ba.WeakCall(self._stop_flashing),
160                 timetype=ba.TimeType.REAL)
161
162        size = get_store_item_display_size(self._offer_item)
163        display: dict[str, Any] = {}
164        storeitemui.instantiate_store_item_display(
165            self._offer_item,
166            display,
167            parent_widget=self._root_widget,
168            b_pos=(self._width * 0.5 - size[0] * 0.5 + 10 -
169                   ((size[0] * 0.5 + 30) if self._is_bundle_sale else 0),
170                   self._height * 0.5 - size[1] * 0.5 + 20 +
171                   (20 if self._is_bundle_sale else 0)),
172            b_width=size[0],
173            b_height=size[1],
174            button=not self._is_bundle_sale)
175
176        # Wire up the parts we need.
177        if self._is_bundle_sale:
178            self._plus_text = ba.textwidget(parent=self._root_widget,
179                                            position=(self._width * 0.5,
180                                                      self._height * 0.5 + 50),
181                                            size=(0, 0),
182                                            text='+',
183                                            h_align='center',
184                                            v_align='center',
185                                            maxwidth=self._width * 0.9,
186                                            scale=1.4,
187                                            color=(0.5, 0.5, 0.5))
188            self._plus_tickets = ba.textwidget(
189                parent=self._root_widget,
190                position=(self._width * 0.5 + 120, self._height * 0.5 + 50),
191                size=(0, 0),
192                text=ba.charstr(SpecialChar.TICKET_BACKING) +
193                str(offer['bonusTickets']),
194                h_align='center',
195                v_align='center',
196                maxwidth=self._width * 0.9,
197                scale=2.5,
198                color=(0.2, 1, 0.2))
199            self._price_text = ba.textwidget(parent=self._root_widget,
200                                             position=(self._width * 0.5, 150),
201                                             size=(0, 0),
202                                             text=real_price,
203                                             h_align='center',
204                                             v_align='center',
205                                             maxwidth=self._width * 0.9,
206                                             scale=1.4,
207                                             color=(0.2, 1, 0.2))
208            # Total-value if they supplied it.
209            total_worth_item = offer.get('valueItem', None)
210            if total_worth_item is not None:
211                price = _ba.get_price(total_worth_item)
212                total_worth_price = (get_clean_price(price)
213                                     if price is not None else None)
214                if total_worth_price is not None:
215                    total_worth_text = ba.Lstr(resource='store.totalWorthText',
216                                               subs=[('${TOTAL_WORTH}',
217                                                      total_worth_price)])
218                    self._total_worth_text = ba.textwidget(
219                        parent=self._root_widget,
220                        text=total_worth_text,
221                        position=(self._width * 0.5, 210),
222                        scale=0.9,
223                        maxwidth=self._width * 0.7,
224                        size=(0, 0),
225                        h_align='center',
226                        v_align='center',
227                        shadow=1.0,
228                        flatness=1.0,
229                        color=(0.3, 1, 1))
230
231        elif offer['item'] == 'pro_fullprice':
232            # for full-price pro we simply show full price
233            ba.textwidget(edit=display['price_widget'], text=real_price)
234            ba.buttonwidget(edit=display['button'],
235                            on_activate_call=self._purchase)
236        else:
237            # Show old/new prices otherwise (for pro sale).
238            ba.buttonwidget(edit=display['button'],
239                            on_activate_call=self._purchase)
240            ba.imagewidget(edit=display['price_slash_widget'], opacity=1.0)
241            ba.textwidget(edit=display['price_widget_left'],
242                          text=original_price_str)
243            ba.textwidget(edit=display['price_widget_right'],
244                          text=new_price_str)
245
246        # Add ticket button only if this is ticket-purchasable.
247        if isinstance(offer.get('price'), int):
248            self._get_tickets_button = ba.buttonwidget(
249                parent=self._root_widget,
250                position=(self._width - 125, self._height - 68),
251                size=(90, 55),
252                scale=1.0,
253                button_type='square',
254                color=(0.7, 0.5, 0.85),
255                textcolor=(0.2, 1, 0.2),
256                autoselect=True,
257                label=ba.Lstr(resource='getTicketsWindow.titleText'),
258                on_activate_call=self._on_get_more_tickets_press)
259
260            self._ticket_text_update_timer = ba.Timer(
261                1.0,
262                ba.WeakCall(self._update_tickets_text),
263                timetype=ba.TimeType.REAL,
264                repeat=True)
265            self._update_tickets_text()
266
267        self._update_timer = ba.Timer(1.0,
268                                      ba.WeakCall(self._update),
269                                      timetype=ba.TimeType.REAL,
270                                      repeat=True)
271
272        self._cancel_button = ba.buttonwidget(
273            parent=self._root_widget,
274            position=(50, 40) if self._is_bundle_sale else
275            (self._width * 0.5 - 75, 40),
276            size=(150, 60),
277            scale=1.0,
278            on_activate_call=self._cancel,
279            autoselect=True,
280            label=ba.Lstr(resource='noThanksText'))
281        self._cancel_countdown_text = ba.textwidget(
282            parent=self._root_widget,
283            text='',
284            position=(50 + 150 + 20, 40 + 27) if self._is_bundle_sale else
285            (self._width * 0.5 - 75 + 150 + 20, 40 + 27),
286            scale=1.1,
287            size=(0, 0),
288            h_align='left',
289            v_align='center',
290            shadow=1.0,
291            flatness=1.0,
292            color=(0.6, 0.5, 0.5))
293        self._update_cancel_button_graphics()
294
295        if self._is_bundle_sale:
296            self._purchase_button = ba.buttonwidget(
297                parent=self._root_widget,
298                position=(self._width - 200, 40),
299                size=(150, 60),
300                scale=1.0,
301                on_activate_call=self._purchase,
302                autoselect=True,
303                label=ba.Lstr(resource='store.purchaseText'))
304
305        ba.containerwidget(edit=self._root_widget,
306                           cancel_button=self._cancel_button,
307                           start_button=self._purchase_button
308                           if self._is_bundle_sale else None,
309                           selected_child=self._purchase_button
310                           if self._is_bundle_sale else display['button'])
311
312    def _stop_flashing(self) -> None:
313        self._flashing_timer = None
314        ba.textwidget(edit=self._title_text, color=(0.3, 1, 0.3))
315
316    def _flash_cycle(self) -> None:
317        if not self._root_widget:
318            return
319        self._flash_on = not self._flash_on
320        ba.textwidget(edit=self._title_text,
321                      color=(0.3, 1, 0.3) if self._flash_on else (1, 0.5, 0))
322
323    def _update_cancel_button_graphics(self) -> None:
324        ba.buttonwidget(edit=self._cancel_button,
325                        color=(0.5, 0.5, 0.5) if self._cancel_delay > 0 else
326                        (0.7, 0.4, 0.34),
327                        textcolor=(0.5, 0.5,
328                                   0.5) if self._cancel_delay > 0 else
329                        (0.9, 0.9, 1.0))
330        ba.textwidget(
331            edit=self._cancel_countdown_text,
332            text=str(self._cancel_delay) if self._cancel_delay > 0 else '')
333
334    def _update(self) -> None:
335
336        # If we've got seconds left on our countdown, update it.
337        if self._cancel_delay > 0:
338            self._cancel_delay = max(0, self._cancel_delay - 1)
339            self._update_cancel_button_graphics()
340
341        can_die = False
342
343        # We go away if we see that our target item is owned.
344        if self._offer_item == 'pro':
345            if _ba.app.accounts_v1.have_pro():
346                can_die = True
347        else:
348            if _ba.get_purchased(self._offer_item):
349                can_die = True
350
351        if can_die:
352            self._transition_out('out_left')
353
354    def _transition_out(self, transition: str = 'out_left') -> None:
355        # Also clear any pending-special-offer we've stored at this point.
356        cfg = ba.app.config
357        if 'pendingSpecialOffer' in cfg:
358            del cfg['pendingSpecialOffer']
359            cfg.commit()
360
361        ba.containerwidget(edit=self._root_widget, transition=transition)
362
363    def _update_tickets_text(self) -> None:
364        from ba import SpecialChar
365        if not self._root_widget:
366            return
367        sval: str | ba.Lstr
368        if _ba.get_v1_account_state() == 'signed_in':
369            sval = (ba.charstr(SpecialChar.TICKET) +
370                    str(_ba.get_v1_account_ticket_count()))
371        else:
372            sval = ba.Lstr(resource='getTicketsWindow.titleText')
373        ba.buttonwidget(edit=self._get_tickets_button, label=sval)
374
375    def _on_get_more_tickets_press(self) -> None:
376        from bastd.ui import account
377        from bastd.ui import getcurrency
378        if _ba.get_v1_account_state() != 'signed_in':
379            account.show_sign_in_prompt()
380            return
381        getcurrency.GetCurrencyWindow(modal=True).get_root_widget()
382
383    def _purchase(self) -> None:
384        from ba.internal import get_store_item_name_translated
385        from bastd.ui import getcurrency
386        from bastd.ui import confirm
387        if self._offer['item'] == 'pro':
388            _ba.purchase('pro_sale')
389        elif self._offer['item'] == 'pro_fullprice':
390            _ba.purchase('pro')
391        elif self._is_bundle_sale:
392            # With bundle sales, the price is the name of the IAP.
393            _ba.purchase(self._offer['price'])
394        else:
395            ticket_count: int | None
396            try:
397                ticket_count = _ba.get_v1_account_ticket_count()
398            except Exception:
399                ticket_count = None
400            if (ticket_count is not None
401                    and ticket_count < self._offer['price']):
402                getcurrency.show_get_tickets_prompt()
403                ba.playsound(ba.getsound('error'))
404                return
405
406            def do_it() -> None:
407                _ba.in_game_purchase('offer:' + str(self._offer['id']),
408                                     self._offer['price'])
409
410            ba.playsound(ba.getsound('swish'))
411            confirm.ConfirmWindow(ba.Lstr(
412                resource='store.purchaseConfirmText',
413                subs=[('${ITEM}',
414                       get_store_item_name_translated(self._offer['item']))]),
415                                  width=400,
416                                  height=120,
417                                  action=do_it,
418                                  ok_text=ba.Lstr(
419                                      resource='store.purchaseText',
420                                      fallback_resource='okText'))
421
422    def _cancel(self) -> None:
423        if self._cancel_delay > 0:
424            ba.playsound(ba.getsound('error'))
425            return
426        self._transition_out('out_right')

Window for presenting sales/etc.

SpecialOfferWindow(offer: dict[str, typing.Any], transition: str = 'in_right')
 21    def __init__(self, offer: dict[str, Any], transition: str = 'in_right'):
 22        # pylint: disable=too-many-statements
 23        # pylint: disable=too-many-branches
 24        # pylint: disable=too-many-locals
 25        from ba.internal import (get_store_item_display_size, get_clean_price)
 26        from ba import SpecialChar
 27        from bastd.ui.store import item as storeitemui
 28        self._cancel_delay = offer.get('cancelDelay', 0)
 29
 30        # First thing: if we're offering pro or an IAP, see if we have a
 31        # price for it.
 32        # If not, abort and go into zombie mode (the user should never see
 33        # us that way).
 34
 35        real_price: str | None
 36
 37        # Misnomer: 'pro' actually means offer 'pro_sale'.
 38        if offer['item'] in ['pro', 'pro_fullprice']:
 39            real_price = _ba.get_price('pro' if offer['item'] ==
 40                                       'pro_fullprice' else 'pro_sale')
 41            if real_price is None and ba.app.debug_build:
 42                print('NOTE: Faking prices for debug build.')
 43                real_price = '$1.23'
 44            zombie = real_price is None
 45        elif isinstance(offer['price'], str):
 46            # (a string price implies IAP id)
 47            real_price = _ba.get_price(offer['price'])
 48            if real_price is None and ba.app.debug_build:
 49                print('NOTE: Faking price for debug build.')
 50                real_price = '$1.23'
 51            zombie = real_price is None
 52        else:
 53            real_price = None
 54            zombie = False
 55        if real_price is None:
 56            real_price = '?'
 57
 58        if offer['item'] in ['pro', 'pro_fullprice']:
 59            self._offer_item = 'pro'
 60        else:
 61            self._offer_item = offer['item']
 62
 63        # If we wanted a real price but didn't find one, go zombie.
 64        if zombie:
 65            return
 66
 67        # This can pop up suddenly, so lets block input for 1 second.
 68        _ba.lock_all_input()
 69        ba.timer(1.0, _ba.unlock_all_input, timetype=ba.TimeType.REAL)
 70        ba.playsound(ba.getsound('ding'))
 71        ba.timer(0.3,
 72                 lambda: ba.playsound(ba.getsound('ooh')),
 73                 timetype=ba.TimeType.REAL)
 74        self._offer = copy.deepcopy(offer)
 75        self._width = 580
 76        self._height = 590
 77        uiscale = ba.app.ui.uiscale
 78        super().__init__(root_widget=ba.containerwidget(
 79            size=(self._width, self._height),
 80            transition=transition,
 81            scale=(1.2 if uiscale is ba.UIScale.SMALL else
 82                   1.15 if uiscale is ba.UIScale.MEDIUM else 1.0),
 83            stack_offset=(0, -15) if uiscale is ba.UIScale.SMALL else (0, 0)))
 84        self._is_bundle_sale = False
 85        try:
 86            if offer['item'] in ['pro', 'pro_fullprice']:
 87                original_price_str = _ba.get_price('pro')
 88                if original_price_str is None:
 89                    original_price_str = '?'
 90                new_price_str = _ba.get_price('pro_sale')
 91                if new_price_str is None:
 92                    new_price_str = '?'
 93                percent_off_text = ''
 94            else:
 95                # If the offer includes bonus tickets, it's a bundle-sale.
 96                if ('bonusTickets' in offer
 97                        and offer['bonusTickets'] is not None):
 98                    self._is_bundle_sale = True
 99                original_price = _ba.get_v1_account_misc_read_val(
100                    'price.' + self._offer_item, 9999)
101
102                # For pure ticket prices we can show a percent-off.
103                if isinstance(offer['price'], int):
104                    new_price = offer['price']
105                    tchar = ba.charstr(SpecialChar.TICKET)
106                    original_price_str = tchar + str(original_price)
107                    new_price_str = tchar + str(new_price)
108                    percent_off = int(
109                        round(100.0 -
110                              (float(new_price) / original_price) * 100.0))
111                    percent_off_text = ' ' + ba.Lstr(
112                        resource='store.salePercentText').evaluate().replace(
113                            '${PERCENT}', str(percent_off))
114                else:
115                    original_price_str = new_price_str = '?'
116                    percent_off_text = ''
117
118        except Exception:
119            print(f'Offer: {offer}')
120            ba.print_exception('Error setting up special-offer')
121            original_price_str = new_price_str = '?'
122            percent_off_text = ''
123
124        # If its a bundle sale, change the title.
125        if self._is_bundle_sale:
126            sale_text = ba.Lstr(resource='store.saleBundleText',
127                                fallback_resource='store.saleText').evaluate()
128        else:
129            # For full pro we say 'Upgrade?' since its not really a sale.
130            if offer['item'] == 'pro_fullprice':
131                sale_text = ba.Lstr(
132                    resource='store.upgradeQuestionText',
133                    fallback_resource='store.saleExclaimText').evaluate()
134            else:
135                sale_text = ba.Lstr(
136                    resource='store.saleExclaimText',
137                    fallback_resource='store.saleText').evaluate()
138
139        self._title_text = ba.textwidget(
140            parent=self._root_widget,
141            position=(self._width * 0.5, self._height - 40),
142            size=(0, 0),
143            text=sale_text +
144            ((' ' + ba.Lstr(resource='store.oneTimeOnlyText').evaluate())
145             if self._offer['oneTimeOnly'] else '') + percent_off_text,
146            h_align='center',
147            v_align='center',
148            maxwidth=self._width * 0.9 - 220,
149            scale=1.4,
150            color=(0.3, 1, 0.3))
151
152        self._flash_on = False
153        self._flashing_timer: ba.Timer | None = ba.Timer(
154            0.05,
155            ba.WeakCall(self._flash_cycle),
156            repeat=True,
157            timetype=ba.TimeType.REAL)
158        ba.timer(0.6,
159                 ba.WeakCall(self._stop_flashing),
160                 timetype=ba.TimeType.REAL)
161
162        size = get_store_item_display_size(self._offer_item)
163        display: dict[str, Any] = {}
164        storeitemui.instantiate_store_item_display(
165            self._offer_item,
166            display,
167            parent_widget=self._root_widget,
168            b_pos=(self._width * 0.5 - size[0] * 0.5 + 10 -
169                   ((size[0] * 0.5 + 30) if self._is_bundle_sale else 0),
170                   self._height * 0.5 - size[1] * 0.5 + 20 +
171                   (20 if self._is_bundle_sale else 0)),
172            b_width=size[0],
173            b_height=size[1],
174            button=not self._is_bundle_sale)
175
176        # Wire up the parts we need.
177        if self._is_bundle_sale:
178            self._plus_text = ba.textwidget(parent=self._root_widget,
179                                            position=(self._width * 0.5,
180                                                      self._height * 0.5 + 50),
181                                            size=(0, 0),
182                                            text='+',
183                                            h_align='center',
184                                            v_align='center',
185                                            maxwidth=self._width * 0.9,
186                                            scale=1.4,
187                                            color=(0.5, 0.5, 0.5))
188            self._plus_tickets = ba.textwidget(
189                parent=self._root_widget,
190                position=(self._width * 0.5 + 120, self._height * 0.5 + 50),
191                size=(0, 0),
192                text=ba.charstr(SpecialChar.TICKET_BACKING) +
193                str(offer['bonusTickets']),
194                h_align='center',
195                v_align='center',
196                maxwidth=self._width * 0.9,
197                scale=2.5,
198                color=(0.2, 1, 0.2))
199            self._price_text = ba.textwidget(parent=self._root_widget,
200                                             position=(self._width * 0.5, 150),
201                                             size=(0, 0),
202                                             text=real_price,
203                                             h_align='center',
204                                             v_align='center',
205                                             maxwidth=self._width * 0.9,
206                                             scale=1.4,
207                                             color=(0.2, 1, 0.2))
208            # Total-value if they supplied it.
209            total_worth_item = offer.get('valueItem', None)
210            if total_worth_item is not None:
211                price = _ba.get_price(total_worth_item)
212                total_worth_price = (get_clean_price(price)
213                                     if price is not None else None)
214                if total_worth_price is not None:
215                    total_worth_text = ba.Lstr(resource='store.totalWorthText',
216                                               subs=[('${TOTAL_WORTH}',
217                                                      total_worth_price)])
218                    self._total_worth_text = ba.textwidget(
219                        parent=self._root_widget,
220                        text=total_worth_text,
221                        position=(self._width * 0.5, 210),
222                        scale=0.9,
223                        maxwidth=self._width * 0.7,
224                        size=(0, 0),
225                        h_align='center',
226                        v_align='center',
227                        shadow=1.0,
228                        flatness=1.0,
229                        color=(0.3, 1, 1))
230
231        elif offer['item'] == 'pro_fullprice':
232            # for full-price pro we simply show full price
233            ba.textwidget(edit=display['price_widget'], text=real_price)
234            ba.buttonwidget(edit=display['button'],
235                            on_activate_call=self._purchase)
236        else:
237            # Show old/new prices otherwise (for pro sale).
238            ba.buttonwidget(edit=display['button'],
239                            on_activate_call=self._purchase)
240            ba.imagewidget(edit=display['price_slash_widget'], opacity=1.0)
241            ba.textwidget(edit=display['price_widget_left'],
242                          text=original_price_str)
243            ba.textwidget(edit=display['price_widget_right'],
244                          text=new_price_str)
245
246        # Add ticket button only if this is ticket-purchasable.
247        if isinstance(offer.get('price'), int):
248            self._get_tickets_button = ba.buttonwidget(
249                parent=self._root_widget,
250                position=(self._width - 125, self._height - 68),
251                size=(90, 55),
252                scale=1.0,
253                button_type='square',
254                color=(0.7, 0.5, 0.85),
255                textcolor=(0.2, 1, 0.2),
256                autoselect=True,
257                label=ba.Lstr(resource='getTicketsWindow.titleText'),
258                on_activate_call=self._on_get_more_tickets_press)
259
260            self._ticket_text_update_timer = ba.Timer(
261                1.0,
262                ba.WeakCall(self._update_tickets_text),
263                timetype=ba.TimeType.REAL,
264                repeat=True)
265            self._update_tickets_text()
266
267        self._update_timer = ba.Timer(1.0,
268                                      ba.WeakCall(self._update),
269                                      timetype=ba.TimeType.REAL,
270                                      repeat=True)
271
272        self._cancel_button = ba.buttonwidget(
273            parent=self._root_widget,
274            position=(50, 40) if self._is_bundle_sale else
275            (self._width * 0.5 - 75, 40),
276            size=(150, 60),
277            scale=1.0,
278            on_activate_call=self._cancel,
279            autoselect=True,
280            label=ba.Lstr(resource='noThanksText'))
281        self._cancel_countdown_text = ba.textwidget(
282            parent=self._root_widget,
283            text='',
284            position=(50 + 150 + 20, 40 + 27) if self._is_bundle_sale else
285            (self._width * 0.5 - 75 + 150 + 20, 40 + 27),
286            scale=1.1,
287            size=(0, 0),
288            h_align='left',
289            v_align='center',
290            shadow=1.0,
291            flatness=1.0,
292            color=(0.6, 0.5, 0.5))
293        self._update_cancel_button_graphics()
294
295        if self._is_bundle_sale:
296            self._purchase_button = ba.buttonwidget(
297                parent=self._root_widget,
298                position=(self._width - 200, 40),
299                size=(150, 60),
300                scale=1.0,
301                on_activate_call=self._purchase,
302                autoselect=True,
303                label=ba.Lstr(resource='store.purchaseText'))
304
305        ba.containerwidget(edit=self._root_widget,
306                           cancel_button=self._cancel_button,
307                           start_button=self._purchase_button
308                           if self._is_bundle_sale else None,
309                           selected_child=self._purchase_button
310                           if self._is_bundle_sale else display['button'])
Inherited Members
ba.ui.Window
get_root_widget