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