| # Copyright 2022 The Pigweed Authors |
| # |
| # Licensed under the Apache License, Version 2.0 (the "License"); you may not |
| # use this file except in compliance with the License. You may obtain a copy of |
| # the License at |
| # |
| # https://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, software |
| # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT |
| # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the |
| # License for the specific language governing permissions and limitations under |
| # the License. |
| """Example Plugin that displays some dynamic content: a game of 2048.""" |
| |
| from random import choice |
| from typing import Iterable, List, Tuple, TYPE_CHECKING |
| import time |
| |
| from prompt_toolkit.filters import has_focus |
| from prompt_toolkit.formatted_text import StyleAndTextTuples |
| from prompt_toolkit.key_binding import KeyBindings, KeyPressEvent |
| from prompt_toolkit.layout import ( |
| AnyContainer, |
| Dimension, |
| FormattedTextControl, |
| HSplit, |
| Window, |
| WindowAlign, |
| VSplit, |
| ) |
| from prompt_toolkit.mouse_events import MouseEvent, MouseEventType |
| from prompt_toolkit.widgets import MenuItem |
| |
| import pw_console.widgets.border |
| from pw_console.widgets import ( |
| FloatingWindowPane, |
| ToolbarButton, |
| WindowPaneToolbar, |
| ) |
| from pw_console.plugin_mixin import PluginMixin |
| from pw_console.get_pw_console_app import get_pw_console_app |
| |
| if TYPE_CHECKING: |
| from pw_console.console_app import ConsoleApp |
| |
| Twenty48Cell = Tuple[int, int, int] |
| |
| |
| class Twenty48Game: |
| """2048 Game.""" |
| def __init__(self) -> None: |
| self.colors = { |
| 2: 'bg:#dd6', |
| 4: 'bg:#da6', |
| 8: 'bg:#d86', |
| 16: 'bg:#d66', |
| 32: 'bg:#d6a', |
| 64: 'bg:#a6d', |
| 128: 'bg:#66d', |
| 256: 'bg:#68a', |
| 512: 'bg:#6a8', |
| 1024: 'bg:#6d6', |
| 2048: 'bg:#0f8', |
| 4096: 'bg:#0ff', |
| } |
| self.board: List[List[int]] |
| self.last_board: List[Twenty48Cell] |
| self.move_count: int |
| self.width: int = 4 |
| self.height: int = 4 |
| self.max_value: int = 0 |
| self.start_time: float |
| self.reset_game() |
| |
| def reset_game(self) -> None: |
| self.start_time = time.time() |
| self.max_value = 2 |
| self.move_count = 0 |
| self.board = [] |
| for _i in range(self.height): |
| self.board.append([0] * self.width) |
| self.last_board = list(self.all_cells()) |
| self.add_random_tiles(2) |
| |
| def stats(self) -> StyleAndTextTuples: |
| """Returns stats on the game in progress.""" |
| elapsed_time = int(time.time() - self.start_time) |
| minutes = int(elapsed_time / 60.0) |
| seconds = elapsed_time % 60 |
| fragments: StyleAndTextTuples = [] |
| fragments.append(('', '\n')) |
| fragments.append(('', f'Moves: {self.move_count}')) |
| fragments.append(('', '\n')) |
| fragments.append(('', 'Time: {:0>2}:{:0>2}'.format(minutes, seconds))) |
| fragments.append(('', '\n')) |
| fragments.append(('', f'Max: {self.max_value}')) |
| fragments.append(('', '\n\n')) |
| fragments.append(('', 'Press R to restart\n')) |
| fragments.append(('', '\n')) |
| fragments.append(('', 'Arrow keys to move')) |
| return fragments |
| |
| def __pt_formatted_text__(self) -> StyleAndTextTuples: |
| """Returns the game board formatted in a grid with colors.""" |
| fragments: StyleAndTextTuples = [] |
| |
| def print_row(row: List[int], include_number: bool = False) -> None: |
| fragments.append(('', ' ')) |
| for col in row: |
| style = 'class:theme-fg-default ' |
| if col > 0: |
| style = '#000 ' |
| style += self.colors.get(col, '') |
| text = ' ' * 6 |
| if include_number: |
| text = '{:^6}'.format(col) |
| fragments.append((style, text)) |
| fragments.append(('', '\n')) |
| |
| fragments.append(('', '\n')) |
| for row in self.board: |
| print_row(row) |
| print_row(row, include_number=True) |
| print_row(row) |
| |
| return fragments |
| |
| def __repr__(self) -> str: |
| board = '' |
| for row_cells in self.board: |
| for column in row_cells: |
| board += '{:^6}'.format(column) |
| board += '\n' |
| return board |
| |
| def all_cells(self) -> Iterable[Twenty48Cell]: |
| for row, row_cells in enumerate(self.board): |
| for col, cell_value in enumerate(row_cells): |
| yield (row, col, cell_value) |
| |
| def update_max_value(self) -> None: |
| for _row, _col, value in self.all_cells(): |
| if value > self.max_value: |
| self.max_value = value |
| |
| def empty_cells(self) -> Iterable[Twenty48Cell]: |
| for row, row_cells in enumerate(self.board): |
| for col, cell_value in enumerate(row_cells): |
| if cell_value != 0: |
| continue |
| yield (row, col, cell_value) |
| |
| def _board_changed(self) -> bool: |
| return self.last_board != list(self.all_cells()) |
| |
| def complete_move(self) -> None: |
| if not self._board_changed(): |
| # Move did nothing, ignore. |
| return |
| |
| self.update_max_value() |
| self.move_count += 1 |
| self.add_random_tiles() |
| self.last_board = list(self.all_cells()) |
| |
| def add_random_tiles(self, count: int = 1) -> None: |
| for _i in range(count): |
| empty_cells = list(self.empty_cells()) |
| if not empty_cells: |
| return |
| row, col, _value = choice(empty_cells) |
| self.board[row][col] = 2 |
| |
| def row(self, row_index: int) -> Iterable[Twenty48Cell]: |
| for col, cell_value in enumerate(self.board[row_index]): |
| yield (row_index, col, cell_value) |
| |
| def col(self, col_index: int) -> Iterable[Twenty48Cell]: |
| for row, row_cells in enumerate(self.board): |
| for col, cell_value in enumerate(row_cells): |
| if col == col_index: |
| yield (row, col, cell_value) |
| |
| def non_zero_row_values(self, index: int) -> Tuple[List, List]: |
| non_zero_values = [ |
| value for row, col, value in self.row(index) if value != 0 |
| ] |
| padding = [0] * (self.width - len(non_zero_values)) |
| return (non_zero_values, padding) |
| |
| def move_right(self) -> None: |
| for i in range(self.height): |
| non_zero_values, padding = self.non_zero_row_values(i) |
| self.board[i] = padding + non_zero_values |
| |
| def move_left(self) -> None: |
| for i in range(self.height): |
| non_zero_values, padding = self.non_zero_row_values(i) |
| self.board[i] = non_zero_values + padding |
| |
| def add_horizontal(self, reverse=False) -> None: |
| for i in range(self.width): |
| this_row = list(self.row(i)) |
| if reverse: |
| this_row = list(reversed(this_row)) |
| for row, col, this_cell in this_row: |
| if this_cell == 0 or col >= self.width - 1: |
| continue |
| next_cell = self.board[row][col + 1] |
| if this_cell == next_cell: |
| self.board[row][col] = 0 |
| self.board[row][col + 1] = this_cell * 2 |
| break |
| |
| def non_zero_col_values(self, index: int) -> Tuple[List, List]: |
| non_zero_values = [ |
| value for row, col, value in self.col(index) if value != 0 |
| ] |
| padding = [0] * (self.height - len(non_zero_values)) |
| return (non_zero_values, padding) |
| |
| def _set_column(self, col_index: int, values: List[int]) -> None: |
| for row, value in enumerate(values): |
| self.board[row][col_index] = value |
| |
| def add_vertical(self, reverse=False) -> None: |
| for i in range(self.height): |
| this_column = list(self.col(i)) |
| if reverse: |
| this_column = list(reversed(this_column)) |
| for row, col, this_cell in this_column: |
| if this_cell == 0 or row >= self.height - 1: |
| continue |
| next_cell = self.board[row + 1][col] |
| if this_cell == next_cell: |
| self.board[row][col] = 0 |
| self.board[row + 1][col] = this_cell * 2 |
| break |
| |
| def move_down(self) -> None: |
| for col_index in range(self.width): |
| non_zero_values, padding = self.non_zero_col_values(col_index) |
| self._set_column(col_index, padding + non_zero_values) |
| |
| def move_up(self) -> None: |
| for col_index in range(self.width): |
| non_zero_values, padding = self.non_zero_col_values(col_index) |
| self._set_column(col_index, non_zero_values + padding) |
| |
| def press_down(self) -> None: |
| self.move_down() |
| self.add_vertical(reverse=True) |
| self.move_down() |
| self.complete_move() |
| |
| def press_up(self) -> None: |
| self.move_up() |
| self.add_vertical() |
| self.move_up() |
| self.complete_move() |
| |
| def press_right(self) -> None: |
| self.move_right() |
| self.add_horizontal(reverse=True) |
| self.move_right() |
| self.complete_move() |
| |
| def press_left(self) -> None: |
| self.move_left() |
| self.add_horizontal() |
| self.move_left() |
| self.complete_move() |
| |
| |
| class Twenty48Control(FormattedTextControl): |
| """Example prompt_toolkit UIControl for displaying formatted text. |
| |
| This is the prompt_toolkit class that is responsible for drawing the 2048, |
| handling keybindings if in focus, and mouse input. |
| """ |
| def __init__(self, twenty48_pane: 'Twenty48Pane', *args, **kwargs) -> None: |
| self.twenty48_pane = twenty48_pane |
| self.game = self.twenty48_pane.game |
| |
| # Set some custom key bindings to toggle the view mode and wrap lines. |
| key_bindings = KeyBindings() |
| |
| @key_bindings.add('R') |
| def _restart(_event: KeyPressEvent) -> None: |
| """Restart the game.""" |
| self.game.reset_game() |
| |
| @key_bindings.add('q') |
| def _quit(_event: KeyPressEvent) -> None: |
| """Quit the game.""" |
| self.twenty48_pane.close_dialog() |
| |
| @key_bindings.add('j') |
| @key_bindings.add('down') |
| def _move_down(_event: KeyPressEvent) -> None: |
| """Move down""" |
| self.game.press_down() |
| |
| @key_bindings.add('k') |
| @key_bindings.add('up') |
| def _move_up(_event: KeyPressEvent) -> None: |
| """Move up.""" |
| self.game.press_up() |
| |
| @key_bindings.add('h') |
| @key_bindings.add('left') |
| def _move_left(_event: KeyPressEvent) -> None: |
| """Move left.""" |
| self.game.press_left() |
| |
| @key_bindings.add('l') |
| @key_bindings.add('right') |
| def _move_right(_event: KeyPressEvent) -> None: |
| """Move right.""" |
| self.game.press_right() |
| |
| # Include the key_bindings keyword arg when passing to the parent class |
| # __init__ function. |
| kwargs['key_bindings'] = key_bindings |
| # Call the parent FormattedTextControl.__init__ |
| super().__init__(*args, **kwargs) |
| |
| def mouse_handler(self, mouse_event: MouseEvent): |
| """Mouse handler for this control.""" |
| # If the user clicks anywhere this function is run. |
| |
| # Mouse positions relative to this control. x is the column starting |
| # from the left size as zero. y is the row starting with the top as |
| # zero. |
| _click_x = mouse_event.position.x |
| _click_y = mouse_event.position.y |
| |
| # Mouse click behavior usually depends on if this window pane is in |
| # focus. If not in focus, then focus on it when left clicking. If |
| # already in focus then perform the action specific to this window. |
| |
| # If not in focus, change focus to this 2048 pane and do nothing else. |
| if not has_focus(self.twenty48_pane)(): |
| if mouse_event.event_type == MouseEventType.MOUSE_UP: |
| get_pw_console_app().focus_on_container(self.twenty48_pane) |
| # Mouse event handled, return None. |
| return None |
| |
| # If code reaches this point, this window is already in focus. |
| # if mouse_event.event_type == MouseEventType.MOUSE_UP: |
| # # Toggle the view mode. |
| # self.twenty48_pane.toggle_view_mode() |
| # # Mouse event handled, return None. |
| # return None |
| |
| # Mouse event not handled, return NotImplemented. |
| return NotImplemented |
| |
| |
| class Twenty48Pane(FloatingWindowPane, PluginMixin): |
| """Example Pigweed Console plugin to play 2048. |
| |
| The Twenty48Pane is a WindowPane based plugin that displays an interactive |
| game of 2048. It inherits from both WindowPane and PluginMixin. It can be |
| added on console startup by calling: :: |
| |
| my_console.add_window_plugin(Twenty48Pane()) |
| |
| For an example see: |
| https://pigweed.dev/pw_console/embedding.html#adding-plugins |
| """ |
| def __init__(self, include_resize_handle: bool = True, **kwargs): |
| |
| super().__init__(pane_title='2048', |
| height=Dimension(preferred=17), |
| width=Dimension(preferred=50), |
| **kwargs) |
| self.game = Twenty48Game() |
| |
| # Hide by default. |
| self.show_pane = False |
| |
| # Create a toolbar for display at the bottom of the 2048 window. It |
| # will show the window title and buttons. |
| self.bottom_toolbar = WindowPaneToolbar( |
| self, include_resize_handle=include_resize_handle) |
| |
| # Add a button to restart the game. |
| self.bottom_toolbar.add_button( |
| ToolbarButton( |
| key='R', # Key binding help text for this function |
| description='Restart', # Button name |
| # Function to run when clicked. |
| mouse_handler=self.game.reset_game, |
| )) |
| # Add a button to restart the game. |
| self.bottom_toolbar.add_button( |
| ToolbarButton( |
| key='q', # Key binding help text for this function |
| description='Quit', # Button name |
| # Function to run when clicked. |
| mouse_handler=self.close_dialog, |
| )) |
| |
| # Every FormattedTextControl object (Twenty48Control) needs to live |
| # inside a prompt_toolkit Window() instance. Here is where you specify |
| # alignment, style, and dimensions. See the prompt_toolkit docs for all |
| # opitons: |
| # https://python-prompt-toolkit.readthedocs.io/en/latest/pages/reference.html#prompt_toolkit.layout.Window |
| self.twenty48_game_window = Window( |
| # Set the content to a Twenty48Control instance. |
| content=Twenty48Control( |
| self, # This Twenty48Pane class |
| self.game, # Content from Twenty48Game.__pt_formatted_text__() |
| show_cursor=False, |
| focusable=True, |
| ), |
| # Make content left aligned |
| align=WindowAlign.LEFT, |
| # These two set to false make this window fill all available space. |
| dont_extend_width=True, |
| dont_extend_height=False, |
| wrap_lines=False, |
| width=Dimension(preferred=28), |
| height=Dimension(preferred=15), |
| ) |
| |
| self.twenty48_stats_window = Window( |
| content=Twenty48Control( |
| self, # This Twenty48Pane class |
| self.game.stats, # Content from Twenty48Game.stats() |
| show_cursor=False, |
| focusable=True, |
| ), |
| # Make content left aligned |
| align=WindowAlign.LEFT, |
| # These two set to false make this window fill all available space. |
| width=Dimension(preferred=20), |
| dont_extend_width=False, |
| dont_extend_height=False, |
| wrap_lines=False, |
| ) |
| |
| # self.container is the root container that contains objects to be |
| # rendered in the UI, one on top of the other. |
| self.container = self._create_pane_container( |
| pw_console.widgets.border.create_border( |
| HSplit([ |
| # Vertical split content |
| VSplit([ |
| # Left side will show the game board. |
| self.twenty48_game_window, |
| # Stats will be shown on the right. |
| self.twenty48_stats_window, |
| ]), |
| # The bottom_toolbar is shown below the VSplit. |
| self.bottom_toolbar, |
| ]), |
| title='2048', |
| border_style='class:command-runner-border', |
| # left_margin_columns=1, |
| # right_margin_columns=1, |
| )) |
| |
| self.dialog_content: List[AnyContainer] = [ |
| # Vertical split content |
| VSplit([ |
| # Left side will show the game board. |
| self.twenty48_game_window, |
| # Stats will be shown on the right. |
| self.twenty48_stats_window, |
| ]), |
| # The bottom_toolbar is shown below the VSplit. |
| self.bottom_toolbar, |
| ] |
| # Wrap the dialog content in a border |
| self.bordered_dialog_content = pw_console.widgets.border.create_border( |
| HSplit(self.dialog_content), |
| title='2048', |
| border_style='class:command-runner-border', |
| ) |
| # self.container is the root container that contains objects to be |
| # rendered in the UI, one on top of the other. |
| if include_resize_handle: |
| self.container = self._create_pane_container(*self.dialog_content) |
| else: |
| self.container = self._create_pane_container( |
| self.bordered_dialog_content) |
| |
| # This plugin needs to run a task in the background periodically and |
| # uses self.plugin_init() to set which function to run, and how often. |
| # This is provided by PluginMixin. See the docs for more info: |
| # https://pigweed.dev/pw_console/plugins.html#background-tasks |
| self.plugin_init( |
| plugin_callback=self._background_task, |
| # Run self._background_task once per second. |
| plugin_callback_frequency=1.0, |
| plugin_logger_name='pw_console_example_2048_plugin', |
| ) |
| |
| def get_top_level_menus(self) -> List[MenuItem]: |
| def _toggle_dialog() -> None: |
| self.toggle_dialog() |
| |
| return [ |
| MenuItem( |
| '[2048]', |
| children=[ |
| MenuItem('Example Top Level Menu', |
| handler=None, |
| disabled=True), |
| # Menu separator |
| MenuItem('-', None), |
| MenuItem('Show/Hide 2048 Game', handler=_toggle_dialog), |
| MenuItem('Restart', handler=self.game.reset_game), |
| ], |
| ), |
| ] |
| |
| def pw_console_init(self, app: 'ConsoleApp') -> None: |
| """Set the Pigweed Console application instance. |
| |
| This function is called after the Pigweed Console starts up and allows |
| access to the user preferences. Prefs is required for creating new |
| user-remappable keybinds.""" |
| self.application = app |
| |
| def _background_task(self) -> bool: |
| """Function run in the background for the ClockPane plugin.""" |
| # Optional: make a log message for debugging purposes. For more info |
| # see: |
| # https://pigweed.dev/pw_console/plugins.html#debugging-plugin-behavior |
| # self.plugin_logger.debug('background_task_update_count: %s', |
| # self.background_task_update_count) |
| |
| # Returning True in the background task will force the user interface to |
| # re-draw. |
| # Returning False means no updates required. |
| |
| if self.show_pane: |
| # Return true so the game clock is updated. |
| return True |
| |
| # Game window is hidden, don't redraw. |
| return False |