aboutsummaryrefslogtreecommitdiff
path: root/pw_console/py/pw_console/plugins/twenty48_pane.py
diff options
context:
space:
mode:
Diffstat (limited to 'pw_console/py/pw_console/plugins/twenty48_pane.py')
-rw-r--r--pw_console/py/pw_console/plugins/twenty48_pane.py561
1 files changed, 561 insertions, 0 deletions
diff --git a/pw_console/py/pw_console/plugins/twenty48_pane.py b/pw_console/py/pw_console/plugins/twenty48_pane.py
new file mode 100644
index 000000000..891b2481d
--- /dev/null
+++ b/pw_console/py/pw_console/plugins/twenty48_pane.py
@@ -0,0 +1,561 @@
+# 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
+
+from pw_console.widgets import (
+ create_border,
+ 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(
+ 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 = 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