• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1# Copyright 2022 The Pigweed Authors
2#
3# Licensed under the Apache License, Version 2.0 (the "License"); you may not
4# use this file except in compliance with the License. You may obtain a copy of
5# the License at
6#
7#     https://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
11# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
12# License for the specific language governing permissions and limitations under
13# the License.
14"""Example Plugin that displays some dynamic content: a game of 2048."""
15
16from random import choice
17from typing import Iterable, List, Tuple, TYPE_CHECKING
18import time
19
20from prompt_toolkit.filters import has_focus
21from prompt_toolkit.formatted_text import StyleAndTextTuples
22from prompt_toolkit.key_binding import KeyBindings, KeyPressEvent
23from prompt_toolkit.layout import (
24    AnyContainer,
25    Dimension,
26    FormattedTextControl,
27    HSplit,
28    Window,
29    WindowAlign,
30    VSplit,
31)
32from prompt_toolkit.mouse_events import MouseEvent, MouseEventType
33from prompt_toolkit.widgets import MenuItem
34
35from pw_console.widgets import (
36    create_border,
37    FloatingWindowPane,
38    ToolbarButton,
39    WindowPaneToolbar,
40)
41from pw_console.plugin_mixin import PluginMixin
42from pw_console.get_pw_console_app import get_pw_console_app
43
44if TYPE_CHECKING:
45    from pw_console.console_app import ConsoleApp
46
47Twenty48Cell = Tuple[int, int, int]
48
49
50class Twenty48Game:
51    """2048 Game."""
52
53    def __init__(self) -> None:
54        self.colors = {
55            2: 'bg:#dd6',
56            4: 'bg:#da6',
57            8: 'bg:#d86',
58            16: 'bg:#d66',
59            32: 'bg:#d6a',
60            64: 'bg:#a6d',
61            128: 'bg:#66d',
62            256: 'bg:#68a',
63            512: 'bg:#6a8',
64            1024: 'bg:#6d6',
65            2048: 'bg:#0f8',
66            4096: 'bg:#0ff',
67        }
68        self.board: List[List[int]]
69        self.last_board: List[Twenty48Cell]
70        self.move_count: int
71        self.width: int = 4
72        self.height: int = 4
73        self.max_value: int = 0
74        self.start_time: float
75        self.reset_game()
76
77    def reset_game(self) -> None:
78        self.start_time = time.time()
79        self.max_value = 2
80        self.move_count = 0
81        self.board = []
82        for _i in range(self.height):
83            self.board.append([0] * self.width)
84        self.last_board = list(self.all_cells())
85        self.add_random_tiles(2)
86
87    def stats(self) -> StyleAndTextTuples:
88        """Returns stats on the game in progress."""
89        elapsed_time = int(time.time() - self.start_time)
90        minutes = int(elapsed_time / 60.0)
91        seconds = elapsed_time % 60
92        fragments: StyleAndTextTuples = []
93        fragments.append(('', '\n'))
94        fragments.append(('', f'Moves: {self.move_count}'))
95        fragments.append(('', '\n'))
96        fragments.append(('', 'Time:  {:0>2}:{:0>2}'.format(minutes, seconds)))
97        fragments.append(('', '\n'))
98        fragments.append(('', f'Max: {self.max_value}'))
99        fragments.append(('', '\n\n'))
100        fragments.append(('', 'Press R to restart\n'))
101        fragments.append(('', '\n'))
102        fragments.append(('', 'Arrow keys to move'))
103        return fragments
104
105    def __pt_formatted_text__(self) -> StyleAndTextTuples:
106        """Returns the game board formatted in a grid with colors."""
107        fragments: StyleAndTextTuples = []
108
109        def print_row(row: List[int], include_number: bool = False) -> None:
110            fragments.append(('', '  '))
111            for col in row:
112                style = 'class:theme-fg-default '
113                if col > 0:
114                    style = '#000 '
115                style += self.colors.get(col, '')
116                text = ' ' * 6
117                if include_number:
118                    text = '{:^6}'.format(col)
119                fragments.append((style, text))
120            fragments.append(('', '\n'))
121
122        fragments.append(('', '\n'))
123        for row in self.board:
124            print_row(row)
125            print_row(row, include_number=True)
126            print_row(row)
127
128        return fragments
129
130    def __repr__(self) -> str:
131        board = ''
132        for row_cells in self.board:
133            for column in row_cells:
134                board += '{:^6}'.format(column)
135            board += '\n'
136        return board
137
138    def all_cells(self) -> Iterable[Twenty48Cell]:
139        for row, row_cells in enumerate(self.board):
140            for col, cell_value in enumerate(row_cells):
141                yield (row, col, cell_value)
142
143    def update_max_value(self) -> None:
144        for _row, _col, value in self.all_cells():
145            if value > self.max_value:
146                self.max_value = value
147
148    def empty_cells(self) -> Iterable[Twenty48Cell]:
149        for row, row_cells in enumerate(self.board):
150            for col, cell_value in enumerate(row_cells):
151                if cell_value != 0:
152                    continue
153                yield (row, col, cell_value)
154
155    def _board_changed(self) -> bool:
156        return self.last_board != list(self.all_cells())
157
158    def complete_move(self) -> None:
159        if not self._board_changed():
160            # Move did nothing, ignore.
161            return
162
163        self.update_max_value()
164        self.move_count += 1
165        self.add_random_tiles()
166        self.last_board = list(self.all_cells())
167
168    def add_random_tiles(self, count: int = 1) -> None:
169        for _i in range(count):
170            empty_cells = list(self.empty_cells())
171            if not empty_cells:
172                return
173            row, col, _value = choice(empty_cells)
174            self.board[row][col] = 2
175
176    def row(self, row_index: int) -> Iterable[Twenty48Cell]:
177        for col, cell_value in enumerate(self.board[row_index]):
178            yield (row_index, col, cell_value)
179
180    def col(self, col_index: int) -> Iterable[Twenty48Cell]:
181        for row, row_cells in enumerate(self.board):
182            for col, cell_value in enumerate(row_cells):
183                if col == col_index:
184                    yield (row, col, cell_value)
185
186    def non_zero_row_values(self, index: int) -> Tuple[List, List]:
187        non_zero_values = [
188            value for row, col, value in self.row(index) if value != 0
189        ]
190        padding = [0] * (self.width - len(non_zero_values))
191        return (non_zero_values, padding)
192
193    def move_right(self) -> None:
194        for i in range(self.height):
195            non_zero_values, padding = self.non_zero_row_values(i)
196            self.board[i] = padding + non_zero_values
197
198    def move_left(self) -> None:
199        for i in range(self.height):
200            non_zero_values, padding = self.non_zero_row_values(i)
201            self.board[i] = non_zero_values + padding
202
203    def add_horizontal(self, reverse=False) -> None:
204        for i in range(self.width):
205            this_row = list(self.row(i))
206            if reverse:
207                this_row = list(reversed(this_row))
208            for row, col, this_cell in this_row:
209                if this_cell == 0 or col >= self.width - 1:
210                    continue
211                next_cell = self.board[row][col + 1]
212                if this_cell == next_cell:
213                    self.board[row][col] = 0
214                    self.board[row][col + 1] = this_cell * 2
215                    break
216
217    def non_zero_col_values(self, index: int) -> Tuple[List, List]:
218        non_zero_values = [
219            value for row, col, value in self.col(index) if value != 0
220        ]
221        padding = [0] * (self.height - len(non_zero_values))
222        return (non_zero_values, padding)
223
224    def _set_column(self, col_index: int, values: List[int]) -> None:
225        for row, value in enumerate(values):
226            self.board[row][col_index] = value
227
228    def add_vertical(self, reverse=False) -> None:
229        for i in range(self.height):
230            this_column = list(self.col(i))
231            if reverse:
232                this_column = list(reversed(this_column))
233            for row, col, this_cell in this_column:
234                if this_cell == 0 or row >= self.height - 1:
235                    continue
236                next_cell = self.board[row + 1][col]
237                if this_cell == next_cell:
238                    self.board[row][col] = 0
239                    self.board[row + 1][col] = this_cell * 2
240                    break
241
242    def move_down(self) -> None:
243        for col_index in range(self.width):
244            non_zero_values, padding = self.non_zero_col_values(col_index)
245            self._set_column(col_index, padding + non_zero_values)
246
247    def move_up(self) -> None:
248        for col_index in range(self.width):
249            non_zero_values, padding = self.non_zero_col_values(col_index)
250            self._set_column(col_index, non_zero_values + padding)
251
252    def press_down(self) -> None:
253        self.move_down()
254        self.add_vertical(reverse=True)
255        self.move_down()
256        self.complete_move()
257
258    def press_up(self) -> None:
259        self.move_up()
260        self.add_vertical()
261        self.move_up()
262        self.complete_move()
263
264    def press_right(self) -> None:
265        self.move_right()
266        self.add_horizontal(reverse=True)
267        self.move_right()
268        self.complete_move()
269
270    def press_left(self) -> None:
271        self.move_left()
272        self.add_horizontal()
273        self.move_left()
274        self.complete_move()
275
276
277class Twenty48Control(FormattedTextControl):
278    """Example prompt_toolkit UIControl for displaying formatted text.
279
280    This is the prompt_toolkit class that is responsible for drawing the 2048,
281    handling keybindings if in focus, and mouse input.
282    """
283
284    def __init__(self, twenty48_pane: 'Twenty48Pane', *args, **kwargs) -> None:
285        self.twenty48_pane = twenty48_pane
286        self.game = self.twenty48_pane.game
287
288        # Set some custom key bindings to toggle the view mode and wrap lines.
289        key_bindings = KeyBindings()
290
291        @key_bindings.add('R')
292        def _restart(_event: KeyPressEvent) -> None:
293            """Restart the game."""
294            self.game.reset_game()
295
296        @key_bindings.add('q')
297        def _quit(_event: KeyPressEvent) -> None:
298            """Quit the game."""
299            self.twenty48_pane.close_dialog()
300
301        @key_bindings.add('j')
302        @key_bindings.add('down')
303        def _move_down(_event: KeyPressEvent) -> None:
304            """Move down"""
305            self.game.press_down()
306
307        @key_bindings.add('k')
308        @key_bindings.add('up')
309        def _move_up(_event: KeyPressEvent) -> None:
310            """Move up."""
311            self.game.press_up()
312
313        @key_bindings.add('h')
314        @key_bindings.add('left')
315        def _move_left(_event: KeyPressEvent) -> None:
316            """Move left."""
317            self.game.press_left()
318
319        @key_bindings.add('l')
320        @key_bindings.add('right')
321        def _move_right(_event: KeyPressEvent) -> None:
322            """Move right."""
323            self.game.press_right()
324
325        # Include the key_bindings keyword arg when passing to the parent class
326        # __init__ function.
327        kwargs['key_bindings'] = key_bindings
328        # Call the parent FormattedTextControl.__init__
329        super().__init__(*args, **kwargs)
330
331    def mouse_handler(self, mouse_event: MouseEvent):
332        """Mouse handler for this control."""
333        # If the user clicks anywhere this function is run.
334
335        # Mouse positions relative to this control. x is the column starting
336        # from the left size as zero. y is the row starting with the top as
337        # zero.
338        _click_x = mouse_event.position.x
339        _click_y = mouse_event.position.y
340
341        # Mouse click behavior usually depends on if this window pane is in
342        # focus. If not in focus, then focus on it when left clicking. If
343        # already in focus then perform the action specific to this window.
344
345        # If not in focus, change focus to this 2048 pane and do nothing else.
346        if not has_focus(self.twenty48_pane)():
347            if mouse_event.event_type == MouseEventType.MOUSE_UP:
348                get_pw_console_app().focus_on_container(self.twenty48_pane)
349                # Mouse event handled, return None.
350                return None
351
352        # If code reaches this point, this window is already in focus.
353        # if mouse_event.event_type == MouseEventType.MOUSE_UP:
354        #     # Toggle the view mode.
355        #     self.twenty48_pane.toggle_view_mode()
356        #     # Mouse event handled, return None.
357        #     return None
358
359        # Mouse event not handled, return NotImplemented.
360        return NotImplemented
361
362
363class Twenty48Pane(FloatingWindowPane, PluginMixin):
364    """Example Pigweed Console plugin to play 2048.
365
366    The Twenty48Pane is a WindowPane based plugin that displays an interactive
367    game of 2048. It inherits from both WindowPane and PluginMixin. It can be
368    added on console startup by calling: ::
369
370        my_console.add_window_plugin(Twenty48Pane())
371
372    For an example see:
373    https://pigweed.dev/pw_console/embedding.html#adding-plugins
374    """
375
376    def __init__(self, include_resize_handle: bool = True, **kwargs):
377        super().__init__(
378            pane_title='2048',
379            height=Dimension(preferred=17),
380            width=Dimension(preferred=50),
381            **kwargs,
382        )
383        self.game = Twenty48Game()
384
385        # Hide by default.
386        self.show_pane = False
387
388        # Create a toolbar for display at the bottom of the 2048 window. It
389        # will show the window title and buttons.
390        self.bottom_toolbar = WindowPaneToolbar(
391            self, include_resize_handle=include_resize_handle
392        )
393
394        # Add a button to restart the game.
395        self.bottom_toolbar.add_button(
396            ToolbarButton(
397                key='R',  # Key binding help text for this function
398                description='Restart',  # Button name
399                # Function to run when clicked.
400                mouse_handler=self.game.reset_game,
401            )
402        )
403        # Add a button to restart the game.
404        self.bottom_toolbar.add_button(
405            ToolbarButton(
406                key='q',  # Key binding help text for this function
407                description='Quit',  # Button name
408                # Function to run when clicked.
409                mouse_handler=self.close_dialog,
410            )
411        )
412
413        # Every FormattedTextControl object (Twenty48Control) needs to live
414        # inside a prompt_toolkit Window() instance. Here is where you specify
415        # alignment, style, and dimensions. See the prompt_toolkit docs for all
416        # opitons:
417        # https://python-prompt-toolkit.readthedocs.io/en/latest/pages/reference.html#prompt_toolkit.layout.Window
418        self.twenty48_game_window = Window(
419            # Set the content to a Twenty48Control instance.
420            content=Twenty48Control(
421                self,  # This Twenty48Pane class
422                self.game,  # Content from Twenty48Game.__pt_formatted_text__()
423                show_cursor=False,
424                focusable=True,
425            ),
426            # Make content left aligned
427            align=WindowAlign.LEFT,
428            # These two set to false make this window fill all available space.
429            dont_extend_width=True,
430            dont_extend_height=False,
431            wrap_lines=False,
432            width=Dimension(preferred=28),
433            height=Dimension(preferred=15),
434        )
435
436        self.twenty48_stats_window = Window(
437            content=Twenty48Control(
438                self,  # This Twenty48Pane class
439                self.game.stats,  # Content from Twenty48Game.stats()
440                show_cursor=False,
441                focusable=True,
442            ),
443            # Make content left aligned
444            align=WindowAlign.LEFT,
445            # These two set to false make this window fill all available space.
446            width=Dimension(preferred=20),
447            dont_extend_width=False,
448            dont_extend_height=False,
449            wrap_lines=False,
450        )
451
452        # self.container is the root container that contains objects to be
453        # rendered in the UI, one on top of the other.
454        self.container = self._create_pane_container(
455            create_border(
456                HSplit(
457                    [
458                        # Vertical split content
459                        VSplit(
460                            [
461                                # Left side will show the game board.
462                                self.twenty48_game_window,
463                                # Stats will be shown on the right.
464                                self.twenty48_stats_window,
465                            ]
466                        ),
467                        # The bottom_toolbar is shown below the VSplit.
468                        self.bottom_toolbar,
469                    ]
470                ),
471                title='2048',
472                border_style='class:command-runner-border',
473                # left_margin_columns=1,
474                # right_margin_columns=1,
475            )
476        )
477
478        self.dialog_content: List[AnyContainer] = [
479            # Vertical split content
480            VSplit(
481                [
482                    # Left side will show the game board.
483                    self.twenty48_game_window,
484                    # Stats will be shown on the right.
485                    self.twenty48_stats_window,
486                ]
487            ),
488            # The bottom_toolbar is shown below the VSplit.
489            self.bottom_toolbar,
490        ]
491        # Wrap the dialog content in a border
492        self.bordered_dialog_content = create_border(
493            HSplit(self.dialog_content),
494            title='2048',
495            border_style='class:command-runner-border',
496        )
497        # self.container is the root container that contains objects to be
498        # rendered in the UI, one on top of the other.
499        if include_resize_handle:
500            self.container = self._create_pane_container(*self.dialog_content)
501        else:
502            self.container = self._create_pane_container(
503                self.bordered_dialog_content
504            )
505
506        # This plugin needs to run a task in the background periodically and
507        # uses self.plugin_init() to set which function to run, and how often.
508        # This is provided by PluginMixin. See the docs for more info:
509        # https://pigweed.dev/pw_console/plugins.html#background-tasks
510        self.plugin_init(
511            plugin_callback=self._background_task,
512            # Run self._background_task once per second.
513            plugin_callback_frequency=1.0,
514            plugin_logger_name='pw_console_example_2048_plugin',
515        )
516
517    def get_top_level_menus(self) -> List[MenuItem]:
518        def _toggle_dialog() -> None:
519            self.toggle_dialog()
520
521        return [
522            MenuItem(
523                '[2048]',
524                children=[
525                    MenuItem(
526                        'Example Top Level Menu', handler=None, disabled=True
527                    ),
528                    # Menu separator
529                    MenuItem('-', None),
530                    MenuItem('Show/Hide 2048 Game', handler=_toggle_dialog),
531                    MenuItem('Restart', handler=self.game.reset_game),
532                ],
533            ),
534        ]
535
536    def pw_console_init(self, app: 'ConsoleApp') -> None:
537        """Set the Pigweed Console application instance.
538
539        This function is called after the Pigweed Console starts up and allows
540        access to the user preferences. Prefs is required for creating new
541        user-remappable keybinds."""
542        self.application = app
543
544    def _background_task(self) -> bool:
545        """Function run in the background for the ClockPane plugin."""
546        # Optional: make a log message for debugging purposes. For more info
547        # see:
548        # https://pigweed.dev/pw_console/plugins.html#debugging-plugin-behavior
549        # self.plugin_logger.debug('background_task_update_count: %s',
550        #                          self.background_task_update_count)
551
552        # Returning True in the background task will force the user interface to
553        # re-draw.
554        # Returning False means no updates required.
555
556        if self.show_pane:
557            # Return true so the game clock is updated.
558            return True
559
560        # Game window is hidden, don't redraw.
561        return False
562