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.
- cli.py +468 -0
- cli_arcade-2026.0.0.dist-info/METADATA +136 -0
- cli_arcade-2026.0.0.dist-info/RECORD +35 -0
- cli_arcade-2026.0.0.dist-info/WHEEL +5 -0
- cli_arcade-2026.0.0.dist-info/entry_points.txt +4 -0
- cli_arcade-2026.0.0.dist-info/licenses/LICENSE +9 -0
- cli_arcade-2026.0.0.dist-info/top_level.txt +3 -0
- game_classes/__init__.py +1 -0
- game_classes/__pycache__/__init__.cpython-313.pyc +0 -0
- game_classes/__pycache__/game_base.cpython-313.pyc +0 -0
- game_classes/__pycache__/highscores.cpython-313.pyc +0 -0
- game_classes/__pycache__/menu.cpython-313.pyc +0 -0
- game_classes/__pycache__/tools.cpython-313.pyc +0 -0
- game_classes/game_base.py +121 -0
- game_classes/highscores.py +108 -0
- game_classes/menu.py +68 -0
- game_classes/tools.py +155 -0
- games/__init__.py +1 -0
- games/byte_bouncer/__init__.py +1 -0
- games/byte_bouncer/__pycache__/byte_bouncer.cpython-313.pyc +0 -0
- games/byte_bouncer/__pycache__/game.cpython-313.pyc +0 -0
- games/byte_bouncer/__pycache__/highscores.cpython-313.pyc +0 -0
- games/byte_bouncer/game.py +208 -0
- games/star_ship/__init__.py +1 -0
- games/star_ship/__pycache__/game.cpython-313.pyc +0 -0
- games/star_ship/__pycache__/highscores.cpython-313.pyc +0 -0
- games/star_ship/__pycache__/nibbles.cpython-313.pyc +0 -0
- games/star_ship/__pycache__/snek.cpython-313.pyc +0 -0
- games/star_ship/__pycache__/star_ship.cpython-313.pyc +0 -0
- games/star_ship/game.py +243 -0
- games/terminal_tumble/__init__.py +1 -0
- games/terminal_tumble/__pycache__/game.cpython-313.pyc +0 -0
- games/terminal_tumble/__pycache__/highscores.cpython-313.pyc +0 -0
- games/terminal_tumble/__pycache__/terminal_tumble.cpython-313.pyc +0 -0
- games/terminal_tumble/game.py +380 -0
games/star_ship/game.py
ADDED
|
@@ -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"""
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|