cli-arcade 2026.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. cli.py +468 -0
  2. cli_arcade-2026.0.0.dist-info/METADATA +136 -0
  3. cli_arcade-2026.0.0.dist-info/RECORD +35 -0
  4. cli_arcade-2026.0.0.dist-info/WHEEL +5 -0
  5. cli_arcade-2026.0.0.dist-info/entry_points.txt +4 -0
  6. cli_arcade-2026.0.0.dist-info/licenses/LICENSE +9 -0
  7. cli_arcade-2026.0.0.dist-info/top_level.txt +3 -0
  8. game_classes/__init__.py +1 -0
  9. game_classes/__pycache__/__init__.cpython-313.pyc +0 -0
  10. game_classes/__pycache__/game_base.cpython-313.pyc +0 -0
  11. game_classes/__pycache__/highscores.cpython-313.pyc +0 -0
  12. game_classes/__pycache__/menu.cpython-313.pyc +0 -0
  13. game_classes/__pycache__/tools.cpython-313.pyc +0 -0
  14. game_classes/game_base.py +121 -0
  15. game_classes/highscores.py +108 -0
  16. game_classes/menu.py +68 -0
  17. game_classes/tools.py +155 -0
  18. games/__init__.py +1 -0
  19. games/byte_bouncer/__init__.py +1 -0
  20. games/byte_bouncer/__pycache__/byte_bouncer.cpython-313.pyc +0 -0
  21. games/byte_bouncer/__pycache__/game.cpython-313.pyc +0 -0
  22. games/byte_bouncer/__pycache__/highscores.cpython-313.pyc +0 -0
  23. games/byte_bouncer/game.py +208 -0
  24. games/star_ship/__init__.py +1 -0
  25. games/star_ship/__pycache__/game.cpython-313.pyc +0 -0
  26. games/star_ship/__pycache__/highscores.cpython-313.pyc +0 -0
  27. games/star_ship/__pycache__/nibbles.cpython-313.pyc +0 -0
  28. games/star_ship/__pycache__/snek.cpython-313.pyc +0 -0
  29. games/star_ship/__pycache__/star_ship.cpython-313.pyc +0 -0
  30. games/star_ship/game.py +243 -0
  31. games/terminal_tumble/__init__.py +1 -0
  32. games/terminal_tumble/__pycache__/game.cpython-313.pyc +0 -0
  33. games/terminal_tumble/__pycache__/highscores.cpython-313.pyc +0 -0
  34. games/terminal_tumble/__pycache__/terminal_tumble.cpython-313.pyc +0 -0
  35. games/terminal_tumble/game.py +380 -0
@@ -0,0 +1,243 @@
1
+ import curses
2
+ import os
3
+ import time
4
+ import random
5
+ import sys
6
+
7
+ try:
8
+ this_dir = os.path.dirname(__file__)
9
+ project_root = os.path.abspath(os.path.join(this_dir, '..', '..'))
10
+ if project_root not in sys.path:
11
+ sys.path.insert(0, project_root)
12
+ except Exception:
13
+ project_root = None
14
+
15
+ from game_classes.highscores import HighScores
16
+ from game_classes.game_base import GameBase
17
+ from game_classes.menu import Menu
18
+ from game_classes.tools import verify_terminal_size, init_curses, is_enter_key, glyph
19
+
20
+ TITLE = [
21
+ " ______ ______ _ ",
22
+ " / __/ /____ _____ / __/ / (_)__ ",
23
+ r" _\ \/ __/ _ `/ __/ _\ \/ _ \/ / _ \ ",
24
+ r" /___/\__/\_,_/_/ /___/_//_/_/ .__/ ",
25
+ " /_/ "
26
+ ]
27
+
28
+ class Game(GameBase):
29
+ def __init__(self, stdscr, player_name='Player'):
30
+ self.title = TITLE
31
+ self.highscores = HighScores('star_ship', {
32
+ 'score': {'player': 'Player', 'value': 0},
33
+ 'stars': {'player': 'Player', 'value': 1},
34
+ 'length': {'player': 'Player', 'value': 3},
35
+ })
36
+ super().__init__(stdscr, player_name, 0.12, curses.COLOR_GREEN)
37
+ self.init_scores([['score', 0], ['stars', 0], ['length', 0]])
38
+
39
+ # game state
40
+ self.special = None
41
+ self.special_expire = None
42
+ self.next_special_at = time.time() + random.uniform(8, 18)
43
+ self.dir = (0, 1)
44
+ self.stars = []
45
+ cy = self.height // 2
46
+ cx = self.width // 2
47
+ self.ship = [(cy, cx - i) for i in range(3)]
48
+ self.dir = (0, 1)
49
+ self.place_star(count=1)
50
+
51
+ def place_star(self, count=1):
52
+ """Place `count` yellow stars in free locations."""
53
+ attempts = 0
54
+ placed = 0
55
+ while placed < count and attempts < 5000:
56
+ y = random.randint(0, max(0, self.height - 1))
57
+ x = random.randint(0, max(0, self.width - 1))
58
+ coord = (y, x)
59
+ # avoid ship, existing stars, and special
60
+ if coord in self.ship or coord in self.stars or (self.special is not None and coord == self.special):
61
+ attempts += 1
62
+ continue
63
+ self.stars.append(coord)
64
+ self.scores['stars'] = len(self.stars)
65
+ placed += 1
66
+ attempts += 1
67
+ return
68
+
69
+ def place_special(self):
70
+ """Place a single magenta special star and set its expiry."""
71
+ attempts = 0
72
+ while attempts < 2000:
73
+ y = random.randint(0, max(0, self.height - 1))
74
+ x = random.randint(0, max(0, self.width - 1))
75
+ coord = (y, x)
76
+ if coord in self.ship or coord in self.stars or (self.special is not None and coord == self.special):
77
+ attempts += 1
78
+ continue
79
+ self.special = coord
80
+ # Lifetime scales with terminal size (width + height).
81
+ # Use 0.035s per column/row, clamped to a sensible range.
82
+ size = getattr(self, 'width', 0) + getattr(self, 'height', 0)
83
+ lifetime = size * 0.035 # HIGHER = EASIER
84
+ self.special_expire = time.time() + lifetime
85
+ return
86
+ # failed to place
87
+ self.special = None
88
+ self.special_expire = None
89
+ return
90
+
91
+ def draw_info(self):
92
+ try:
93
+ info_x = 2
94
+ info_y = len(self.title)
95
+
96
+ # draw high scores below title
97
+ new_score = ' ***NEW High Score!' if self.new_highs.get('score', False) else ''
98
+ self.stdscr.addstr(info_y + 0, info_x, f'High Score: {int(self.high_scores["score"]["value"]):,} ({self.high_scores["score"]["player"]}){new_score}', curses.color_pair(curses.COLOR_GREEN))
99
+ new_ship_length = ' ***NEW Longest Ship!' if self.new_highs.get('length', False) else ''
100
+ self.stdscr.addstr(info_y + 1, info_x, f'Longest Ship: {int(self.high_scores["length"]["value"]):,} ({self.high_scores["length"]["player"]}){new_ship_length}', curses.color_pair(curses.COLOR_BLUE))
101
+ new_stars = ' ***NEW Most Stars!' if self.new_highs.get('stars', False) else ''
102
+ self.stdscr.addstr(info_y + 2, info_x, f'Most Stars: {int(self.high_scores["stars"]["value"]):,} ({self.high_scores["stars"]["player"]}){new_stars}', curses.color_pair(curses.COLOR_BLUE))
103
+
104
+ # draw game info below title
105
+ self.stdscr.addstr(info_y + 4, info_x, f'Player: {self.player_name}')
106
+ self.stdscr.addstr(info_y + 5, info_x, f'Score: {int(self.scores["score"]):,}', curses.color_pair(curses.COLOR_GREEN))
107
+ self.stdscr.addstr(info_y + 6, info_x, f'Ship Length: {int(self.scores["length"]):,}', curses.color_pair(curses.COLOR_BLUE))
108
+ self.stdscr.addstr(info_y + 7, info_x, f'Stars: {int(self.scores["stars"]):,}', curses.color_pair(curses.COLOR_BLUE))
109
+
110
+ self.stdscr.addstr(info_y + 9 , info_x, '↑ | w : Up')
111
+ self.stdscr.addstr(info_y + 10, info_x, '← | a : Left')
112
+ self.stdscr.addstr(info_y + 11, info_x, '↓ | s : Down')
113
+ self.stdscr.addstr(info_y + 12, info_x, '→ | d : Right')
114
+ self.stdscr.addstr(info_y + 13, info_x, 'Backspace : Pause')
115
+ self.stdscr.addstr(info_y + 14, info_x, 'ESC : Quit')
116
+ except Exception:
117
+ pass
118
+
119
+ def draw(self):
120
+ self.draw_info()
121
+ # draw game elements (ship + star) on top of title/info
122
+ try:
123
+ # draw yellow stars
124
+ for fy, fx in list(getattr(self, 'stars', [])):
125
+ self.stdscr.addch(fy, fx, '*', curses.color_pair(curses.COLOR_YELLOW) | curses.A_BOLD)
126
+ # draw special magenta (if present)
127
+ if getattr(self, 'special', None) is not None:
128
+ sy, sx = self.special
129
+ self.stdscr.addch(sy, sx, glyph('CIRCLE_FILLED'), curses.color_pair(curses.COLOR_MAGENTA) | curses.A_BOLD)
130
+ # ship: head and body
131
+ for idx, (sy, sx) in enumerate(self.ship):
132
+ try:
133
+ if idx == 0:
134
+ self.stdscr.addch(sy, sx, glyph('CIRCLE_FILLED'), curses.color_pair(curses.COLOR_GREEN) | curses.A_BOLD)
135
+ else:
136
+ self.stdscr.addch(sy, sx, glyph('CIRCLE_FILLED'), curses.color_pair(curses.COLOR_BLUE))
137
+ except Exception:
138
+ pass
139
+ except Exception:
140
+ pass
141
+
142
+ def step(self, now):
143
+ # auto-spawn special star when time reached
144
+ try:
145
+ if getattr(self, 'special', None) is None and getattr(self, 'next_special_at', 0) and now >= self.next_special_at:
146
+ self.place_special()
147
+ except Exception:
148
+ pass
149
+ # ensure special expires even if paused
150
+ try:
151
+ if getattr(self, 'special', None) is not None and getattr(self, 'special_expire', None) is not None and now >= self.special_expire:
152
+ self.special = None
153
+ self.special_expire = None
154
+ self.next_special_at = now + random.uniform(8, 18)
155
+ except Exception:
156
+ pass
157
+
158
+ # compute next head position
159
+ if not self.ship:
160
+ return
161
+ hy, hx = self.ship[0]
162
+ dy, dx = self.dir
163
+ nh, nx = hy + dy, hx + dx
164
+ # check bounds (terminal edges are boundaries)
165
+ if nh < 0 or nh >= self.height or nx < 0 or nx >= self.width:
166
+ self.over = True
167
+ return
168
+ # self-collision
169
+ if (nh, nx) in self.ship:
170
+ self.over = True
171
+ return
172
+ # move head
173
+ self.ship.insert(0, (nh, nx))
174
+ # eating: normal yellow stars
175
+ if (nh, nx) in self.stars:
176
+ try:
177
+ self.stars.remove((nh, nx))
178
+ except Exception:
179
+ pass
180
+ self.scores['score'] = int(self.scores['score']) + 10
181
+ # maintain star by placing one new yellow
182
+ self.place_star(count=1)
183
+ # do not pop tail (grow by 1)
184
+ return
185
+ # special magenta eaten
186
+ if self.special is not None and (nh, nx) == self.special:
187
+ # award larger bonus by yellow star qty
188
+ self.scores['score'] = int(self.scores['score']) + 50 * len(self.stars)
189
+ # spawn 2 yellow stars
190
+ self.place_star(count=2)
191
+ # immediately spawn another magenta special
192
+ self.place_special()
193
+ return
194
+ else:
195
+ # normal move: remove tail
196
+ try:
197
+ self.ship.pop()
198
+ except Exception:
199
+ pass
200
+ # if special expired, clear it and schedule next
201
+ now = time.time()
202
+ if self.special is not None and self.special_expire is not None and now >= self.special_expire:
203
+ self.special = None
204
+ self.special_expire = None
205
+ self.next_special_at = now + random.uniform(8, 18)
206
+ self.scores['length'] = len(self.ship)
207
+
208
+ def movement(self, ch):
209
+ new_dir = None
210
+ if ch in (curses.KEY_UP, ord('w')):
211
+ new_dir = (-1, 0)
212
+ elif ch in (curses.KEY_DOWN, ord('s')):
213
+ new_dir = (1, 0)
214
+ elif ch in (curses.KEY_LEFT, ord('a')):
215
+ new_dir = (0, -1)
216
+ elif ch in (curses.KEY_RIGHT, ord('d')):
217
+ new_dir = (0, 1)
218
+ if new_dir:
219
+ # prevent immediate 180-degree turns
220
+ cy, cx = self.dir
221
+ if (new_dir[0], new_dir[1]) != (-cy, -cx):
222
+ self.dir = new_dir
223
+
224
+ def main(stdscr):
225
+ verify_terminal_size('Star Ship')
226
+ init_curses(stdscr)
227
+ while True:
228
+ game = Game(stdscr)
229
+ menu = Menu(game)
230
+ start = menu.display()
231
+ if not start:
232
+ break
233
+ game.update_player_name(start)
234
+ game.run()
235
+
236
+ if __name__ == '__main__':
237
+ try:
238
+ curses.wrapper(main)
239
+ except KeyboardInterrupt:
240
+ try:
241
+ curses.endwin()
242
+ except Exception:
243
+ pass
@@ -0,0 +1 @@
1
+ """terminal_tumble game package"""
@@ -0,0 +1,380 @@
1
+ import curses
2
+ import random
3
+ import os
4
+ import math
5
+ from collections import deque
6
+ import sys
7
+
8
+ try:
9
+ this_dir = os.path.dirname(__file__)
10
+ project_root = os.path.abspath(os.path.join(this_dir, '..', '..'))
11
+ if project_root not in sys.path:
12
+ sys.path.insert(0, project_root)
13
+ except Exception:
14
+ project_root = None
15
+
16
+ from game_classes.highscores import HighScores
17
+ from game_classes.game_base import GameBase
18
+ from game_classes.menu import Menu
19
+ from game_classes.tools import verify_terminal_size, init_curses, is_enter_key
20
+
21
+ TITLE = [
22
+ '___________ .__ .__ ___________ ___. .__ ',
23
+ r'\__ ___/__________ _____ |__| ____ _____ | | \__ ___/_ __ _____\_ |__ | | ____ ',
24
+ r' | |_/ __ \_ __ \/ \| |/ \\__ \ | | | | | | \/ \| __ \| | _/ __ \ ',
25
+ r' | |\ ___/| | \/ Y Y \ | | \/ __ \| |__ | | | | / Y Y \ \_\ \ |_\ ___/ ',
26
+ r' |____| \___ >__| |__|_| /__|___| (____ /____/ |____| |____/|__|_| /___ /____/\___ >',
27
+ r' \/ \/ \/ \/ \/ \/ \/ '
28
+ ]
29
+
30
+ SHAPES = {
31
+ 'I': [[(0,1),(1,1),(2,1),(3,1)], [(2,0),(2,1),(2,2),(2,3)]],
32
+ 'O': [[(0,0),(1,0),(0,1),(1,1)]],
33
+ 'T': [[(1,0),(0,1),(1,1),(2,1)], [(1,0),(1,1),(2,1),(1,2)], [(0,1),(1,1),(2,1),(1,2)], [(1,0),(0,1),(1,1),(1,2)]],
34
+ 'S': [[(1,0),(2,0),(0,1),(1,1)], [(1,0),(1,1),(2,1),(2,2)]],
35
+ 'Z': [[(0,0),(1,0),(1,1),(2,1)], [(2,0),(1,1),(2,1),(1,2)]],
36
+ 'J': [[(0,0),(0,1),(1,1),(2,1)], [(1,0),(2,0),(1,1),(1,2)], [(0,1),(1,1),(2,1),(2,2)], [(1,0),(1,1),(0,2),(1,2)]],
37
+ 'L': [[(2,0),(0,1),(1,1),(2,1)], [(1,0),(1,1),(1,2),(2,2)], [(0,1),(1,1),(2,1),(0,2)], [(0,0),(1,0),(1,1),(1,2)]],
38
+ }
39
+
40
+ COLORS = {
41
+ 'I': curses.COLOR_WHITE,
42
+ 'O': curses.COLOR_BLUE,
43
+ 'T': curses.COLOR_CYAN,
44
+ 'S': curses.COLOR_GREEN,
45
+ 'Z': curses.COLOR_RED,
46
+ 'J': curses.COLOR_MAGENTA,
47
+ 'L': curses.COLOR_YELLOW,
48
+ }
49
+
50
+ class Piece:
51
+ def __init__(self, shape):
52
+ self.shape = shape
53
+ self.rots = SHAPES[shape]
54
+ self.rot = 0
55
+ self.blocks = self.rots[self.rot]
56
+ # shift spawn one column right to account for permanent left wall
57
+ self.x = 9
58
+ self.y = 0
59
+
60
+ def rotate(self, board):
61
+ old = self.rot
62
+ self.rot = (self.rot + 1) % len(self.rots)
63
+ self.blocks = self.rots[self.rot]
64
+ if self.collides(board):
65
+ self.rot = old
66
+ self.blocks = self.rots[self.rot]
67
+
68
+ def collides(self, board, dx=0, dy=0):
69
+ for bx, by in self.blocks:
70
+ x = self.x + bx + dx
71
+ y = self.y + by + dy
72
+ if x < 0 or x >= len(board[0]) or y < 0 or y >= len(board):
73
+ return True
74
+ if board[y][x] != ' ':
75
+ return True
76
+ return False
77
+
78
+ def move(self, dx, dy, board):
79
+ if not self.collides(board, dx, dy):
80
+ self.x += dx
81
+ self.y += dy
82
+ return True
83
+ return False
84
+
85
+ class Game(GameBase):
86
+ def __init__(self, stdscr, player_name='Player'):
87
+ self.title = TITLE
88
+ self.highscores = HighScores('terminal_tumble', {
89
+ 'score': {'player': 'Player', 'value': 0},
90
+ 'lines': {'player': 'Player', 'value': 0},
91
+ 'level': {'player': 'Player', 'value': 1},
92
+ })
93
+ super().__init__(stdscr, player_name, 0.5, curses.COLOR_RED)
94
+ self.msg_height = self.height - 22
95
+ self.init_scores([['score', 0], ['lines', 0], ['level', 1]])
96
+
97
+ # game state
98
+ # board with a permanent left wall in column 0
99
+ self.board = [[' ' for _ in range(20)] for _ in range(self.height - 6)]
100
+ for y in range(self.height - 6):
101
+ self.board[y][0] = 'W'
102
+ self.current = self.next_piece()
103
+ self.next = self.next_piece()
104
+ self.drop_timer = 0
105
+ self.msg_log = deque(maxlen=self.msg_height)
106
+
107
+ def push_message(self, text, color_const=curses.COLOR_WHITE):
108
+ try:
109
+ self.msg_log.append((text, color_const))
110
+ except Exception:
111
+ pass
112
+
113
+ def handle_new_highs(self, metric):
114
+ if not self.new_highs[metric]:
115
+ cap_metric = metric.capitalize()
116
+ self.push_message(f'New High {cap_metric}!', curses.COLOR_YELLOW)
117
+ super().handle_new_highs(metric)
118
+
119
+ def next_piece(self):
120
+ return Piece(random.choice(list(SHAPES.keys())))
121
+
122
+ def lock_piece(self):
123
+ for bx, by in self.current.blocks:
124
+ x = self.current.x + bx
125
+ y = self.current.y + by
126
+ if 0 <= y < self.height - 6 and 0 <= x < len(self.board[0]):
127
+ self.board[y][x] = self.current.shape
128
+ cleared = self.clear_lines()
129
+ self.current = self.next
130
+ self.next = self.next_piece()
131
+ if self.current.collides(self.board):
132
+ self.over = True
133
+ return cleared
134
+
135
+ def clear_lines(self):
136
+ # preserve left wall when clearing rows
137
+ new_board = [row for row in self.board if any(c == ' ' for c in row)]
138
+ cleared = self.height - 6 - len(new_board)
139
+ for _ in range(cleared):
140
+ new_row = [' ' for _ in range(len(self.board[0]))]
141
+ new_row[0] = 'W'
142
+ new_board.insert(0, new_row)
143
+ # ensure existing rows keep the wall in column 0
144
+ for row in new_board:
145
+ row[0] = 'W'
146
+ self.board = new_board
147
+ if cleared:
148
+ # update lines and level first
149
+ self.scores['lines'] += cleared
150
+ self.scores['level'] = 1 + self.scores['lines'] // 10
151
+ # tick based on level
152
+ self.tick = max(0.05, self.tick - (self.scores['level'] - 1)*0.04)
153
+ # exponential points based on level: slow start, grows faster later
154
+ multiplier = math.exp(self.scores['level'] / 10.0)
155
+ base_points = 100 * cleared
156
+ points = int(base_points * multiplier)
157
+ # push a rolling message for this clear
158
+ label = ''
159
+ color = curses.COLOR_WHITE
160
+ if cleared == 2:
161
+ label = 'Double!'
162
+ color = curses.COLOR_CYAN
163
+ points = int(points * 1.5)
164
+ elif cleared == 3:
165
+ label = 'Triple!'
166
+ color = curses.COLOR_BLUE
167
+ points = int(points * 2.0)
168
+ elif cleared == 4:
169
+ label = 'Full Stack!'
170
+ color = curses.COLOR_GREEN
171
+ points = int(points * 3.0)
172
+ self.scores['score'] += points
173
+ self.push_message(f'+{points} {label}', color)
174
+ return cleared
175
+
176
+ def hard_drop(self):
177
+ slam_mult = 0.1
178
+ # exponential accumulation: grow slam_mult each dropped row
179
+ # simple rule: slam_mult = slam_mult * 1.25 + 0.1
180
+ while self.current.move(0,1,self.board):
181
+ slam_mult = slam_mult * (1 + self.scores['level'] / 10.0)
182
+ # lock_piece now returns number of cleared lines
183
+ cleared = self.lock_piece()
184
+ if cleared and cleared > 0:
185
+ # compute slam bonus: scales with cleared lines and level
186
+ multiplier = math.exp(self.scores['level'] / 10.0)
187
+ base_points = 100 * cleared
188
+ # slam multiplier gives larger bonus for more lines (0.5,1.0,1.5,2.0)
189
+ slam_bonus = cleared * slam_mult
190
+ bonus = int(base_points * multiplier * slam_bonus)
191
+ # double the slam bonus if it's a 4-line slam
192
+ if cleared == 4:
193
+ bonus *= 2
194
+ self.scores['score'] += bonus
195
+ # push slam bonus message if any
196
+ if bonus > 0:
197
+ self.push_message(f'+{bonus} Slam Bonus!', curses.COLOR_MAGENTA)
198
+ try:
199
+ self.update_high_scores()
200
+ except Exception:
201
+ pass
202
+
203
+ def draw_info(self):
204
+ info_y = len(self.title)
205
+ # draw next-piece preview above the score
206
+ try:
207
+ preview_x = 43
208
+ preview_y = info_y
209
+ # draw the next-piece in the preview column (no label)
210
+ piece_x = preview_x
211
+ try:
212
+ # clear a 4x4 preview area to the right of the label
213
+ for ry in range(4):
214
+ for rx in range(4):
215
+ try:
216
+ self.stdscr.addstr(preview_y + ry, piece_x + rx*2, ' ')
217
+ except Exception:
218
+ pass
219
+ # draw the next piece
220
+ for bx, by in self.next.blocks:
221
+ px = piece_x + bx*2
222
+ py = preview_y + by
223
+ if py >= 0 and px >= 0:
224
+ color = COLORS.get(self.next.shape, 1)
225
+ try:
226
+ self.stdscr.addstr(py, px, '[]', curses.color_pair(color))
227
+ except Exception:
228
+ try:
229
+ self.stdscr.addstr(py, px, '[]')
230
+ except Exception:
231
+ pass
232
+ except Exception:
233
+ pass
234
+ except Exception:
235
+ pass
236
+ self.stdscr.addstr(info_y + 0 , 55, f'High Score: {int(self.high_scores["score"]["value"]):,} ({self.high_scores["score"]["player"]})', curses.color_pair(curses.COLOR_GREEN))
237
+ self.stdscr.addstr(info_y + 1 , 55, f'High Lines: {int(self.high_scores["lines"]["value"]):,} ({self.high_scores["lines"]["player"]})', curses.color_pair(curses.COLOR_BLUE))
238
+ self.stdscr.addstr(info_y + 2 , 55, f'High Level: {int(self.high_scores["level"]["value"]):,} ({self.high_scores["level"]["player"]})', curses.color_pair(curses.COLOR_MAGENTA))
239
+ # player name
240
+ info_y += 4
241
+ try:
242
+ self.stdscr.addstr(info_y, 43, f'Player: {self.player_name}', curses.A_BOLD)
243
+ except Exception:
244
+ pass
245
+ self.stdscr.addstr(info_y + 1, 43, '====================================================')
246
+ self.stdscr.addstr(info_y + 2, 43, f'Score: {int(self.scores["score"]):,}', curses.color_pair(curses.COLOR_GREEN))
247
+ self.stdscr.addstr(info_y + 3, 43, f'Lines: {int(self.scores["lines"]):,}', curses.color_pair(curses.COLOR_BLUE))
248
+ self.stdscr.addstr(info_y + 4, 43, f'Level: {int(self.scores["level"]):,}', curses.color_pair(curses.COLOR_MAGENTA))
249
+
250
+ self.stdscr.addstr(info_y + 5, 43, '====================================================')
251
+ # draw rolling message log (most recent at top)
252
+ info_y += 6
253
+ try:
254
+ preview_x = 43
255
+ start_y = info_y
256
+ msgs = list(self.msg_log)
257
+ # clear area first
258
+ for i in range(self.msg_height):
259
+ try:
260
+ self.stdscr.addstr(start_y + i, preview_x, ' ' * 40)
261
+ except Exception:
262
+ pass
263
+ # display newest first
264
+ for idx, (text, color_const) in enumerate(reversed(msgs)):
265
+ if idx >= self.msg_height:
266
+ break
267
+ y = start_y + idx
268
+ try:
269
+ self.stdscr.addstr(y, preview_x, text, curses.color_pair(color_const))
270
+ except Exception:
271
+ try:
272
+ self.stdscr.addstr(y, preview_x, text)
273
+ except Exception:
274
+ pass
275
+ except Exception:
276
+ pass
277
+ info_y += self.msg_height
278
+ self.stdscr.addstr(info_y - 1, 43, '====================================================')
279
+ self.stdscr.addstr(info_y + 0, 43, '← | a : Left')
280
+ self.stdscr.addstr(info_y + 1, 43, '→ | d : Right')
281
+ self.stdscr.addstr(info_y + 2, 43, '↑ | w : Rotate')
282
+ self.stdscr.addstr(info_y + 3, 43, '↓ | s : Down (soft drop)')
283
+ self.stdscr.addstr(info_y + 4, 43, 'SPACE | ENTER : Slam (hard drop)')
284
+ self.stdscr.addstr(info_y + 5, 43, 'BACKSPACE : Pause/Resume')
285
+ self.stdscr.addstr(info_y + 6, 43, 'ESC : Quit')
286
+
287
+ def draw(self):
288
+ # draw roof (one line) above the board with a centered opening
289
+ y_roof = len(self.title) - 1
290
+ if y_roof >= 0:
291
+ open_w = min(max(0, 6), len(self.board[0]))
292
+ open_start = (len(self.board[0]) - open_w) // 2
293
+ open_end = open_start + open_w
294
+ for x in range(len(self.board[0])):
295
+ try:
296
+ if open_start <= x < open_end:
297
+ # leave opening
298
+ self.stdscr.addstr(y_roof, x*2+1, ' ')
299
+ else:
300
+ self.stdscr.addstr(y_roof, x*2+1, '==')
301
+ except Exception:
302
+ pass
303
+ # draw board with top margin
304
+ for y in range(self.height - 6):
305
+ y_off = y + y_roof + 1
306
+ for x in range(len(self.board[0])):
307
+ ch = self.board[y][x]
308
+ if ch == 'W':
309
+ # permanent left wall
310
+ self.stdscr.addstr(y_off, x*2, ' |')
311
+ elif ch != ' ':
312
+ color = COLORS.get(ch, 1)
313
+ attr = curses.color_pair(color)
314
+ if ch == 'J':
315
+ attr |= curses.A_DIM
316
+ self.stdscr.addstr(y_off, x*2, '[]', attr)
317
+ else:
318
+ self.stdscr.addstr(y_off, x*2, ' ')
319
+ # draw current (apply top margin)
320
+ for bx, by in self.current.blocks:
321
+ x = self.current.x + bx
322
+ y = self.current.y + by
323
+ y_off = y + len(self.title)
324
+ if 0 <= y < self.height - 6 and 0 <= x < len(self.board[0]):
325
+ color = COLORS.get(self.current.shape, 1)
326
+ attr = curses.color_pair(color)
327
+ if self.current.shape == 'J':
328
+ attr |= curses.A_DIM
329
+ self.stdscr.addstr(y_off, x*2, '[]', attr)
330
+
331
+ # draw borders and (shifted by top margin)
332
+ for y in range(self.height - 6):
333
+ y_off = y + len(self.title)
334
+ self.stdscr.addstr(y_off, len(self.board[0])*2, '|')
335
+
336
+ # draw floor below the board using '='
337
+ try:
338
+ floor_y = len(self.title) + self.height - 6
339
+ for x in range(len(self.board[0])):
340
+ self.stdscr.addstr(floor_y, x*2+1, '==')
341
+ except Exception:
342
+ pass
343
+ self.draw_info()
344
+
345
+ def step(self, now):
346
+ if not self.current.move(0,1,self.board):
347
+ self.lock_piece()
348
+
349
+ def movement(self, ch):
350
+ if ch in (curses.KEY_LEFT, ord('a')):
351
+ self.current.move(-1,0,self.board)
352
+ elif ch in (curses.KEY_RIGHT, ord('d')):
353
+ self.current.move(1,0,self.board)
354
+ elif ch in (curses.KEY_DOWN, ord('s')):
355
+ self.current.move(0,1,self.board)
356
+ elif ch in (curses.KEY_UP, ord('w')):
357
+ self.current.rotate(self.board)
358
+ elif is_enter_key(ch) or ch == ord(' '):
359
+ self.hard_drop()
360
+
361
+ def main(stdscr):
362
+ verify_terminal_size('Terminal Tumble', 100, 30)
363
+ init_curses(stdscr)
364
+ while True:
365
+ game = Game(stdscr)
366
+ menu = Menu(game)
367
+ start = menu.display()
368
+ if not start:
369
+ break
370
+ game.update_player_name(start)
371
+ game.run()
372
+
373
+ if __name__ == '__main__':
374
+ try:
375
+ curses.wrapper(main)
376
+ except KeyboardInterrupt:
377
+ try:
378
+ curses.endwin()
379
+ except Exception:
380
+ pass