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