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
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import curses
|
|
2
|
+
import time
|
|
3
|
+
from game_classes.tools import get_terminal_size
|
|
4
|
+
|
|
5
|
+
class GameBase:
|
|
6
|
+
def __init__(self, stdscr, player_name, tick, color=curses.COLOR_GREEN):
|
|
7
|
+
self.stdscr = stdscr
|
|
8
|
+
self.player_name = player_name
|
|
9
|
+
self.tick = tick
|
|
10
|
+
self.color = color
|
|
11
|
+
self.high_scores = self.highscores.load()
|
|
12
|
+
self.width, self.height = get_terminal_size(stdscr)
|
|
13
|
+
self.over = False
|
|
14
|
+
self.paused = False
|
|
15
|
+
|
|
16
|
+
def init_scores(self, list=[['score', 0]]):
|
|
17
|
+
self.new_highs = {}
|
|
18
|
+
self.scores = {}
|
|
19
|
+
for metric, initial in list:
|
|
20
|
+
self.new_highs[metric] = False
|
|
21
|
+
self.scores[metric] = initial
|
|
22
|
+
|
|
23
|
+
def update_player_name(self, name):
|
|
24
|
+
self.player_name = name
|
|
25
|
+
|
|
26
|
+
def handle_new_highs(self, metric):
|
|
27
|
+
self.new_highs[metric] = True
|
|
28
|
+
|
|
29
|
+
def check_and_set_scores(self, metric='score'):
|
|
30
|
+
updated = False
|
|
31
|
+
try:
|
|
32
|
+
if self.scores[metric] > self.high_scores[metric].get('value', 0):
|
|
33
|
+
self.high_scores[metric]['value'] = int(self.scores[metric])
|
|
34
|
+
self.high_scores[metric]['player'] = getattr(self, 'player_name', 'Player')
|
|
35
|
+
updated = True
|
|
36
|
+
self.handle_new_highs(metric)
|
|
37
|
+
except Exception as e:
|
|
38
|
+
updated = False
|
|
39
|
+
return updated
|
|
40
|
+
|
|
41
|
+
def update_high_scores(self):
|
|
42
|
+
"""Update the `high_scores` dict if current player exceeds any metric and save."""
|
|
43
|
+
updated = False
|
|
44
|
+
try:
|
|
45
|
+
for metric in self.scores:
|
|
46
|
+
updated |= self.check_and_set_scores(metric)
|
|
47
|
+
except Exception:
|
|
48
|
+
updated = False
|
|
49
|
+
if updated:
|
|
50
|
+
try:
|
|
51
|
+
self.highscores.save(self.high_scores)
|
|
52
|
+
except Exception:
|
|
53
|
+
pass
|
|
54
|
+
return updated
|
|
55
|
+
|
|
56
|
+
def step(self, now):
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
def movement(self, ch):
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
def events(self, ch):
|
|
63
|
+
if ch != -1:
|
|
64
|
+
if ch == 27:
|
|
65
|
+
return True
|
|
66
|
+
if not getattr(self, 'over', False):
|
|
67
|
+
# toggle pause on Backspace
|
|
68
|
+
if ch in (curses.KEY_BACKSPACE, 127, 8):
|
|
69
|
+
self.paused = not getattr(self, 'paused', False)
|
|
70
|
+
# movement only when not paused
|
|
71
|
+
elif not getattr(self, 'paused', False):
|
|
72
|
+
self.movement(ch)
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
def draw_game_status(self, msg):
|
|
76
|
+
try:
|
|
77
|
+
py = max(0, min(self.height, self.height // 2))
|
|
78
|
+
px = max(0, (self.width - len(msg)) // 2)
|
|
79
|
+
self.stdscr.addstr(py, px, msg, curses.color_pair(curses.COLOR_RED) | curses.A_BOLD)
|
|
80
|
+
except Exception:
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
def pre_draw(self):
|
|
84
|
+
self.stdscr.clear()
|
|
85
|
+
try:
|
|
86
|
+
for i, line in enumerate(self.title):
|
|
87
|
+
try:
|
|
88
|
+
self.stdscr.addstr(i, 0, line, curses.color_pair(self.color) | curses.A_BOLD)
|
|
89
|
+
except Exception:
|
|
90
|
+
pass
|
|
91
|
+
except Exception:
|
|
92
|
+
pass
|
|
93
|
+
|
|
94
|
+
def draw(self):
|
|
95
|
+
pass
|
|
96
|
+
|
|
97
|
+
def post_draw(self):
|
|
98
|
+
# overlay: game-over takes precedence over paused
|
|
99
|
+
if getattr(self, 'over', False):
|
|
100
|
+
self.draw_game_status('GAME OVER')
|
|
101
|
+
elif getattr(self, 'paused', False):
|
|
102
|
+
self.draw_game_status('PAUSED')
|
|
103
|
+
self.stdscr.refresh()
|
|
104
|
+
|
|
105
|
+
def run(self):
|
|
106
|
+
last = time.time()
|
|
107
|
+
while True:
|
|
108
|
+
now = time.time()
|
|
109
|
+
if self.events(self.stdscr.getch()):
|
|
110
|
+
break
|
|
111
|
+
if now - last > self.tick and not getattr(self, 'over', False) and not getattr(self, 'paused', False):
|
|
112
|
+
self.step(now)
|
|
113
|
+
last = now
|
|
114
|
+
self.pre_draw()
|
|
115
|
+
self.draw()
|
|
116
|
+
self.post_draw()
|
|
117
|
+
try:
|
|
118
|
+
self.update_high_scores()
|
|
119
|
+
except Exception:
|
|
120
|
+
pass
|
|
121
|
+
time.sleep(0.01)
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import shutil
|
|
4
|
+
import warnings
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
import appdirs
|
|
8
|
+
except Exception:
|
|
9
|
+
appdirs = None
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class HighScores:
|
|
13
|
+
"""Per-game highscores stored in a user-writable location.
|
|
14
|
+
|
|
15
|
+
Constructor is backward-compatible: `HighScores(game, default)` works.
|
|
16
|
+
If legacy highscores exist under the project `games/<game>/highscores.json`,
|
|
17
|
+
they will be migrated to the user data directory on first use.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, game, default=None, appname='cli-games', appauthor=None):
|
|
21
|
+
if default is None:
|
|
22
|
+
default = {'score': {'player': 'Player', 'value': 0}}
|
|
23
|
+
# copy default to avoid shared mutable state
|
|
24
|
+
try:
|
|
25
|
+
self.default = {k: v.copy() for k, v in default.items()}
|
|
26
|
+
except Exception:
|
|
27
|
+
self.default = dict(default)
|
|
28
|
+
|
|
29
|
+
self.game = game
|
|
30
|
+
self.appname = appname
|
|
31
|
+
self.appauthor = appauthor
|
|
32
|
+
|
|
33
|
+
# determine user-writable base
|
|
34
|
+
base = None
|
|
35
|
+
if appdirs is not None:
|
|
36
|
+
try:
|
|
37
|
+
base = appdirs.user_data_dir(self.appname, self.appauthor)
|
|
38
|
+
except Exception:
|
|
39
|
+
base = None
|
|
40
|
+
|
|
41
|
+
if not base:
|
|
42
|
+
home = os.path.expanduser('~') or os.getcwd()
|
|
43
|
+
base = os.path.join(home, f'.{self.appname}')
|
|
44
|
+
|
|
45
|
+
self.dir = os.path.abspath(os.path.join(base, 'games', game))
|
|
46
|
+
self.path = os.path.join(self.dir, 'highscores.json')
|
|
47
|
+
|
|
48
|
+
# attempt migration from project-root location if present
|
|
49
|
+
try:
|
|
50
|
+
proj_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
|
|
51
|
+
legacy = os.path.join(proj_root, 'games', game, 'highscores.json')
|
|
52
|
+
if os.path.exists(legacy) and not os.path.exists(self.path):
|
|
53
|
+
try:
|
|
54
|
+
os.makedirs(os.path.dirname(self.path), exist_ok=True)
|
|
55
|
+
shutil.copy2(legacy, self.path)
|
|
56
|
+
except Exception as e:
|
|
57
|
+
warnings.warn(f"Failed to migrate legacy highscores for {game}: {e}")
|
|
58
|
+
except Exception:
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
def _path(self):
|
|
62
|
+
return self.path
|
|
63
|
+
|
|
64
|
+
def load(self):
|
|
65
|
+
path = self._path()
|
|
66
|
+
try:
|
|
67
|
+
if os.path.exists(path):
|
|
68
|
+
with open(path, 'r', encoding='utf-8') as f:
|
|
69
|
+
data = json.load(f)
|
|
70
|
+
if isinstance(data, dict):
|
|
71
|
+
for k, v in self.default.items():
|
|
72
|
+
if k not in data or not isinstance(data[k], dict):
|
|
73
|
+
data[k] = v.copy()
|
|
74
|
+
else:
|
|
75
|
+
data[k]['player'] = data[k].get('player', v.get('player'))
|
|
76
|
+
data[k]['value'] = data[k].get('value', v.get('value'))
|
|
77
|
+
return data
|
|
78
|
+
except Exception as e:
|
|
79
|
+
warnings.warn(f"HighScores.load() failed for {path}: {e}")
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
return {k: v.copy() for k, v in self.default.items()}
|
|
83
|
+
except Exception:
|
|
84
|
+
return dict(self.default)
|
|
85
|
+
|
|
86
|
+
def save(self, data):
|
|
87
|
+
path = self._path()
|
|
88
|
+
try:
|
|
89
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
90
|
+
except Exception as e:
|
|
91
|
+
warnings.warn(f"HighScores.save() failed to create dir: {e}")
|
|
92
|
+
return False
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
tmp = path + '.tmp'
|
|
96
|
+
with open(tmp, 'w', encoding='utf-8') as f:
|
|
97
|
+
json.dump(data, f, ensure_ascii=False, indent=2)
|
|
98
|
+
os.replace(tmp, path)
|
|
99
|
+
return True
|
|
100
|
+
except Exception as e:
|
|
101
|
+
warnings.warn(f"HighScores.save() write failed for {path}: {e}")
|
|
102
|
+
try:
|
|
103
|
+
with open(path, 'w', encoding='utf-8') as f:
|
|
104
|
+
json.dump(data, f, ensure_ascii=False, indent=2)
|
|
105
|
+
return True
|
|
106
|
+
except Exception as e2:
|
|
107
|
+
warnings.warn(f"HighScores.save() fallback write failed for {path}: {e2}")
|
|
108
|
+
return False
|
game_classes/menu.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import curses
|
|
2
|
+
from game_classes.tools import is_enter_key
|
|
3
|
+
|
|
4
|
+
class Menu:
|
|
5
|
+
def __init__(self, game):
|
|
6
|
+
self.game = game
|
|
7
|
+
|
|
8
|
+
def prompt_name(self, prompt_y, prompt_x, max_len=12):
|
|
9
|
+
name = ''
|
|
10
|
+
while True:
|
|
11
|
+
try:
|
|
12
|
+
self.game.stdscr.addstr(prompt_y, prompt_x, ' ' * (max_len + 20))
|
|
13
|
+
self.game.stdscr.addstr(prompt_y, prompt_x, f'Enter name (max {max_len}): {name}')
|
|
14
|
+
self.game.stdscr.refresh()
|
|
15
|
+
except Exception:
|
|
16
|
+
pass
|
|
17
|
+
ch = self.game.stdscr.getch()
|
|
18
|
+
if is_enter_key(ch): # Enter
|
|
19
|
+
return name.strip() or 'Player'
|
|
20
|
+
elif ch in (27,):
|
|
21
|
+
return False
|
|
22
|
+
elif ch in (curses.KEY_BACKSPACE, 127, 8):
|
|
23
|
+
name = name[:-1]
|
|
24
|
+
elif 32 <= ch <= 126 and len(name) < max_len:
|
|
25
|
+
name += chr(ch)
|
|
26
|
+
else:
|
|
27
|
+
# ignore other keys
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
def display(self):
|
|
31
|
+
self.game.stdscr.clear()
|
|
32
|
+
h, w = self.game.stdscr.getmaxyx()
|
|
33
|
+
# draw title art left-aligned where it appears in-game (above the board)
|
|
34
|
+
title_height = len(self.game.title)
|
|
35
|
+
for i, line in enumerate(self.game.title):
|
|
36
|
+
try:
|
|
37
|
+
self.game.stdscr.addstr(i, 0, line, curses.color_pair(self.game.color) | curses.A_BOLD)
|
|
38
|
+
except Exception:
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
# instructions centered under the title block
|
|
42
|
+
instr_y = title_height + 1
|
|
43
|
+
try:
|
|
44
|
+
title_width = max((len(l) for l in self.game.title), default=0)
|
|
45
|
+
instrs = ['Press ENTER to Start Game', 'Press ESC to Quit']
|
|
46
|
+
for idx, instr in enumerate(instrs):
|
|
47
|
+
x = max(0, (title_width - len(instr)) // 2)
|
|
48
|
+
self.game.stdscr.addstr(instr_y + idx, x, instr)
|
|
49
|
+
except Exception:
|
|
50
|
+
pass
|
|
51
|
+
self.game.stdscr.refresh()
|
|
52
|
+
while True:
|
|
53
|
+
ch = self.game.stdscr.getch()
|
|
54
|
+
# if ch in (ord('s'), ord('S')) or ch == curses.KEY_DOWN:
|
|
55
|
+
if is_enter_key(ch):
|
|
56
|
+
# prompt for name before starting, centered over the title block
|
|
57
|
+
prompt_y = title_height + 3
|
|
58
|
+
title_width = max((len(l) for l in self.game.title), default=0)
|
|
59
|
+
max_len = 50
|
|
60
|
+
prompt_prefix = f'Enter name (max {max_len}): '
|
|
61
|
+
prompt_len = len(prompt_prefix) + max_len
|
|
62
|
+
prompt_x = max(0, (title_width - prompt_len) // 2)
|
|
63
|
+
name = self.prompt_name(prompt_y, prompt_x, max_len=max_len)
|
|
64
|
+
if name is False:
|
|
65
|
+
return False
|
|
66
|
+
return name
|
|
67
|
+
elif ch == 27:
|
|
68
|
+
return False
|
game_classes/tools.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import curses
|
|
2
|
+
import os
|
|
3
|
+
import shutil
|
|
4
|
+
|
|
5
|
+
def verify_terminal_size(game_name, min_cols=70, min_rows=20):
|
|
6
|
+
try:
|
|
7
|
+
size = shutil.get_terminal_size()
|
|
8
|
+
cols, rows = size.columns, size.lines
|
|
9
|
+
except Exception:
|
|
10
|
+
try:
|
|
11
|
+
cols, rows = os.get_terminal_size().columns, os.get_terminal_size().lines
|
|
12
|
+
except Exception:
|
|
13
|
+
cols, rows = 0, 0
|
|
14
|
+
if cols < min_cols or rows < min_rows:
|
|
15
|
+
print(f" [ACTION] Terminal size is too small to run {game_name}.")
|
|
16
|
+
if cols < min_cols:
|
|
17
|
+
print(f" [ACTION] Actual Colums: {cols} Required Colums: {min_cols}")
|
|
18
|
+
if rows < min_rows:
|
|
19
|
+
print(f" [ACTION] Actual Rows: {rows} Required Rows: {min_rows}")
|
|
20
|
+
raise SystemExit(1)
|
|
21
|
+
|
|
22
|
+
def get_terminal_size(stdscr):
|
|
23
|
+
try:
|
|
24
|
+
rows, cols = stdscr.getmaxyx()
|
|
25
|
+
except Exception:
|
|
26
|
+
try:
|
|
27
|
+
cols, rows = shutil.get_terminal_size()
|
|
28
|
+
except Exception:
|
|
29
|
+
cols, rows = 24, 80
|
|
30
|
+
return max(20, cols - 2), max(6, rows - 1)
|
|
31
|
+
|
|
32
|
+
def init_curses(stdscr):
|
|
33
|
+
curses.curs_set(0)
|
|
34
|
+
stdscr.nodelay(True)
|
|
35
|
+
stdscr.timeout(50)
|
|
36
|
+
# enable keypad mode so special keys (e.g. numpad Enter) map to curses.KEY_ENTER
|
|
37
|
+
try:
|
|
38
|
+
stdscr.keypad(True)
|
|
39
|
+
except Exception:
|
|
40
|
+
pass
|
|
41
|
+
# init colors
|
|
42
|
+
curses.start_color()
|
|
43
|
+
curses.use_default_colors()
|
|
44
|
+
# try to normalize key colors (0..1000 scale). Must run before init_pair.
|
|
45
|
+
if curses.can_change_color() and curses.COLORS >= 8:
|
|
46
|
+
try:
|
|
47
|
+
curses.init_color(curses.COLOR_MAGENTA, 1000, 0, 1000)
|
|
48
|
+
curses.init_color(curses.COLOR_YELLOW, 1000, 1000, 0)
|
|
49
|
+
curses.init_color(curses.COLOR_WHITE, 1000, 1000, 1000)
|
|
50
|
+
curses.init_color(curses.COLOR_CYAN, 0, 1000, 1000)
|
|
51
|
+
curses.init_color(curses.COLOR_BLUE, 0, 0, 1000)
|
|
52
|
+
curses.init_color(curses.COLOR_GREEN, 0, 800, 0)
|
|
53
|
+
curses.init_color(curses.COLOR_RED, 1000, 0, 0)
|
|
54
|
+
curses.init_color(curses.COLOR_BLACK, 0, 0, 0)
|
|
55
|
+
except Exception:
|
|
56
|
+
pass
|
|
57
|
+
for i in range(1,8):
|
|
58
|
+
curses.init_pair(i, i, -1)
|
|
59
|
+
# set an explicit default attribute so unstyled text uses white
|
|
60
|
+
try:
|
|
61
|
+
stdscr.bkgd(' ', curses.color_pair(curses.COLOR_WHITE))
|
|
62
|
+
except Exception:
|
|
63
|
+
try:
|
|
64
|
+
stdscr.bkgd(' ', curses.color_pair(7))
|
|
65
|
+
except Exception:
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
def clamp(v, a, b):
|
|
69
|
+
return max(a, min(b, v))
|
|
70
|
+
|
|
71
|
+
def is_enter_key(ch):
|
|
72
|
+
try:
|
|
73
|
+
enter_vals = {10, 13, getattr(curses, 'KEY_ENTER', -1), 343, 459}
|
|
74
|
+
except Exception:
|
|
75
|
+
enter_vals = {10, 13}
|
|
76
|
+
return ch in enter_vals
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _supports_unicode():
|
|
80
|
+
"""Decide whether to use Unicode glyphs.
|
|
81
|
+
|
|
82
|
+
Rules:
|
|
83
|
+
- Honor `CLI_GAMES_FORCE_ASCII` (1/true/yes) -> False
|
|
84
|
+
- On classic Windows PowerShell/conhost (no modern terminal env vars) prefer ASCII
|
|
85
|
+
- If encodings are explicitly ASCII -> False
|
|
86
|
+
- Otherwise prefer Unicode (True)
|
|
87
|
+
"""
|
|
88
|
+
try:
|
|
89
|
+
if os.environ.get('CLI_GAMES_FORCE_ASCII', '').lower() in ('1', 'true', 'yes'):
|
|
90
|
+
return False
|
|
91
|
+
|
|
92
|
+
# On Windows, many older/conhost-based shells lack glyph fonts.
|
|
93
|
+
# If we're on Windows and don't detect a modern terminal emulator,
|
|
94
|
+
# prefer ASCII to avoid boxed question-mark glyphs.
|
|
95
|
+
if os.name == 'nt':
|
|
96
|
+
env = os.environ
|
|
97
|
+
modern_term_markers = (
|
|
98
|
+
'WT_SESSION', # Windows Terminal
|
|
99
|
+
'WT_PROFILE_ID',
|
|
100
|
+
'TERM_PROGRAM',
|
|
101
|
+
'ANSICON',
|
|
102
|
+
'ConEmuPID',
|
|
103
|
+
'TERM',
|
|
104
|
+
)
|
|
105
|
+
modern = any(k in env for k in modern_term_markers)
|
|
106
|
+
term = env.get('TERM', '').lower()
|
|
107
|
+
if 'xterm' in term or 'vt' in term:
|
|
108
|
+
modern = True
|
|
109
|
+
if not modern:
|
|
110
|
+
return False
|
|
111
|
+
|
|
112
|
+
import sys
|
|
113
|
+
import locale
|
|
114
|
+
encs = [getattr(sys.stdout, 'encoding', None), locale.getpreferredencoding(False)]
|
|
115
|
+
for enc in encs:
|
|
116
|
+
if not enc:
|
|
117
|
+
continue
|
|
118
|
+
low = enc.lower()
|
|
119
|
+
if 'ascii' in low or low in ('us-ascii', '646'):
|
|
120
|
+
return False
|
|
121
|
+
|
|
122
|
+
return True
|
|
123
|
+
except Exception:
|
|
124
|
+
return True
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
_UNICODE = _supports_unicode()
|
|
128
|
+
|
|
129
|
+
GLYPHS_UNICODE = {
|
|
130
|
+
'VBAR': '│',
|
|
131
|
+
'BLOCK': '█',
|
|
132
|
+
'CIRCLE_FILLED': '◉',
|
|
133
|
+
'CIRCLE': '◌',
|
|
134
|
+
'THUMB': '█',
|
|
135
|
+
}
|
|
136
|
+
GLYPHS_ASCII = {
|
|
137
|
+
'VBAR': '|',
|
|
138
|
+
'BLOCK': '#',
|
|
139
|
+
'CIRCLE_FILLED': 'O',
|
|
140
|
+
'CIRCLE': 'O',
|
|
141
|
+
'THUMB': '#',
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def glyph(name, fallback='*'):
|
|
146
|
+
"""Return a glyph string for `name`, using unicode when available.
|
|
147
|
+
|
|
148
|
+
Known names: 'VBAR', 'BLOCK', 'CIRCLE_FILLED', 'CIRCLE', 'THUMB'
|
|
149
|
+
"""
|
|
150
|
+
try:
|
|
151
|
+
if _UNICODE:
|
|
152
|
+
return GLYPHS_UNICODE.get(name, GLYPHS_UNICODE.get('CIRCLE_FILLED', fallback))
|
|
153
|
+
return GLYPHS_ASCII.get(name, GLYPHS_ASCII.get('CIRCLE_FILLED', fallback))
|
|
154
|
+
except Exception:
|
|
155
|
+
return fallback
|
games/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""games package"""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""byte_bouncer game package"""
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import curses
|
|
2
|
+
import os
|
|
3
|
+
import random
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
this_dir = os.path.dirname(__file__)
|
|
8
|
+
project_root = os.path.abspath(os.path.join(this_dir, '..', '..'))
|
|
9
|
+
if project_root not in sys.path:
|
|
10
|
+
sys.path.insert(0, project_root)
|
|
11
|
+
except Exception:
|
|
12
|
+
project_root = None
|
|
13
|
+
|
|
14
|
+
from game_classes.highscores import HighScores
|
|
15
|
+
from game_classes.game_base import GameBase
|
|
16
|
+
from game_classes.menu import Menu
|
|
17
|
+
from game_classes.tools import glyph, verify_terminal_size, init_curses, clamp
|
|
18
|
+
|
|
19
|
+
TITLE = [
|
|
20
|
+
' ____ _ _ ____ ____ ____ __ _ _ __ _ ___ ____ ____ ',
|
|
21
|
+
r' ( _ \( \/ )(_ _)( __) ( _ \ / \ / )( \( ( \ / __)( __)( _ \ ',
|
|
22
|
+
r' ) _ ( ) / )( ) _) ) _ (( O )) \/ (/ /( (__ ) _) ) / ',
|
|
23
|
+
r' (____/(__/ (__) (____) (____/ \__/ \____/\_)__) \___)(____)(__\_) '
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
class Game(GameBase):
|
|
27
|
+
def __init__(self, stdscr, player_name='Player'):
|
|
28
|
+
self.title = TITLE
|
|
29
|
+
self.highscores = HighScores('byte_bouncer', {
|
|
30
|
+
'score': {'player': 'Player', 'value': 0},
|
|
31
|
+
'level': {'player': 'Player', 'value': 1},
|
|
32
|
+
})
|
|
33
|
+
super().__init__(stdscr, player_name, 0.12, curses.COLOR_GREEN)
|
|
34
|
+
self.init_scores([['score', 0], ['level', 1]])
|
|
35
|
+
|
|
36
|
+
# game state
|
|
37
|
+
self.count = 0
|
|
38
|
+
# multiple balls support: list of dicts with x,y,vx,vy
|
|
39
|
+
self.balls = [
|
|
40
|
+
{ 'x': self.width // 2, 'y': self.height // 2, 'vx': random.choice([-1,1]), 'vy': -1 }
|
|
41
|
+
]
|
|
42
|
+
self.paddle_w = 30
|
|
43
|
+
self.paddle_x = self.width // 2 - self.paddle_w // 2
|
|
44
|
+
|
|
45
|
+
def draw_info(self):
|
|
46
|
+
info_x = 2
|
|
47
|
+
info_y = len(self.title)
|
|
48
|
+
try:
|
|
49
|
+
# draw high scores below title
|
|
50
|
+
new_score = ' ***NEW High Score!' if self.new_highs.get('score', False) else ''
|
|
51
|
+
new_level = ' ***NEW High Level!' if self.new_highs.get('level', False) else ''
|
|
52
|
+
self.stdscr.addstr(info_y + 1 , info_x, f'High Score: {int(self.high_scores["score"]["value"]):,} ({self.high_scores["score"]["player"]}){new_score}', curses.color_pair(curses.COLOR_GREEN))
|
|
53
|
+
self.stdscr.addstr(info_y + 2 , info_x, f'High Level: {int(self.high_scores["level"]["value"]):,} ({self.high_scores["level"]["player"]}){new_level}', curses.color_pair(curses.COLOR_BLUE))
|
|
54
|
+
|
|
55
|
+
# draw game info below title
|
|
56
|
+
self.stdscr.addstr(info_y + 4, info_x, f'Player: {self.player_name}')
|
|
57
|
+
self.stdscr.addstr(info_y + 5, info_x, f'Score: {int(self.scores["score"]):,}', curses.color_pair(curses.COLOR_GREEN))
|
|
58
|
+
self.stdscr.addstr(info_y + 6, info_x, f'Level: {int(self.scores["level"]):,}', curses.color_pair(curses.COLOR_BLUE))
|
|
59
|
+
|
|
60
|
+
self.stdscr.addstr(info_y + 8 , info_x, '← | a : Left')
|
|
61
|
+
self.stdscr.addstr(info_y + 9 , info_x, '→ | d : Right')
|
|
62
|
+
self.stdscr.addstr(info_y + 10, info_x, 'Backspace : Pause')
|
|
63
|
+
self.stdscr.addstr(info_y + 11, info_x, 'ESC : Quit')
|
|
64
|
+
except Exception:
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
def draw(self):
|
|
68
|
+
self.draw_info()
|
|
69
|
+
# draw balls
|
|
70
|
+
try:
|
|
71
|
+
for idx, b in enumerate(self.balls):
|
|
72
|
+
try:
|
|
73
|
+
if idx == 0:
|
|
74
|
+
attr = curses.color_pair(curses.COLOR_MAGENTA) | curses.A_BOLD
|
|
75
|
+
else:
|
|
76
|
+
attr = curses.color_pair(curses.COLOR_YELLOW) | curses.A_BOLD
|
|
77
|
+
self.stdscr.addch(int(b['y']), 1 + int(b['x']), glyph('CIRCLE_FILLED', 'O'), attr)
|
|
78
|
+
except Exception:
|
|
79
|
+
pass
|
|
80
|
+
except Exception:
|
|
81
|
+
pass
|
|
82
|
+
# draw paddle
|
|
83
|
+
for i in range(self.paddle_w):
|
|
84
|
+
x = clamp(self.paddle_x + i, 0, self.width - 1)
|
|
85
|
+
try:
|
|
86
|
+
self.stdscr.addch(self.height, x + 1, '=', curses.color_pair(curses.COLOR_GREEN) | curses.A_BOLD)
|
|
87
|
+
except Exception:
|
|
88
|
+
pass
|
|
89
|
+
|
|
90
|
+
def step(self, now):
|
|
91
|
+
# move each ball and handle collisions
|
|
92
|
+
for i, b in enumerate(self.balls[:]):
|
|
93
|
+
b['x'] += b['vx']
|
|
94
|
+
b['y'] += b['vy']
|
|
95
|
+
# collisions with walls
|
|
96
|
+
if b['x'] < 0:
|
|
97
|
+
b['x'] = 0
|
|
98
|
+
b['vx'] *= -1
|
|
99
|
+
elif b['x'] >= self.width:
|
|
100
|
+
b['x'] = self.width - 1
|
|
101
|
+
b['vx'] *= -1
|
|
102
|
+
if b['y'] < 0:
|
|
103
|
+
b['y'] = 0
|
|
104
|
+
b['vy'] *= -1
|
|
105
|
+
# bottom: check paddle
|
|
106
|
+
if b['y'] >= self.height:
|
|
107
|
+
if self.paddle_x <= b['x'] < self.paddle_x + self.paddle_w:
|
|
108
|
+
# bounce
|
|
109
|
+
b['y'] = self.height - 1
|
|
110
|
+
b['vy'] *= -1
|
|
111
|
+
# normalize horizontal velocity to magnitude 1
|
|
112
|
+
if b['vx'] < 0:
|
|
113
|
+
b['vx'] = -1
|
|
114
|
+
elif b['vx'] > 0:
|
|
115
|
+
b['vx'] = 1
|
|
116
|
+
else:
|
|
117
|
+
b['vx'] = random.choice([-1, 1])
|
|
118
|
+
self.scores['score'] += 10 * self.scores['level'] * len(self.balls)
|
|
119
|
+
self.count += 1
|
|
120
|
+
# increase level every 5 successful bounces
|
|
121
|
+
if self.count % 5 == 0:
|
|
122
|
+
self.scores['level'] += 1
|
|
123
|
+
# spawn new ball near center top area
|
|
124
|
+
nb = {
|
|
125
|
+
'x': random.randint(2, max(2, self.width-3)),
|
|
126
|
+
'y': random.randint(2, max(2, self.height - self.height//3)),
|
|
127
|
+
'vx': random.choice([-1,1]),
|
|
128
|
+
'vy': -1
|
|
129
|
+
}
|
|
130
|
+
self.balls.append(nb)
|
|
131
|
+
elif b['x'] == self.paddle_x - 1 and b['vx'] > 0:
|
|
132
|
+
# edge bounce left
|
|
133
|
+
b['y'] = self.height - 1
|
|
134
|
+
b['vy'] *= -1
|
|
135
|
+
b['vx'] *= -1
|
|
136
|
+
self.scores['score'] += 10 * self.scores['level'] * len(self.balls) * 2
|
|
137
|
+
self.count += 1
|
|
138
|
+
# increase level every 5 successful bounces
|
|
139
|
+
if self.count % 5 == 0:
|
|
140
|
+
self.scores['level'] += 1
|
|
141
|
+
# spawn new ball near center top area
|
|
142
|
+
nb = {
|
|
143
|
+
'x': random.randint(2, max(2, self.width-3)),
|
|
144
|
+
'y': random.randint(2, max(2, self.height - self.height//3)),
|
|
145
|
+
'vx': random.choice([-1,1]),
|
|
146
|
+
'vy': -1
|
|
147
|
+
}
|
|
148
|
+
self.balls.append(nb)
|
|
149
|
+
elif b['x'] == self.paddle_x + self.paddle_w and b['vx'] < 0:
|
|
150
|
+
# edge bounce right
|
|
151
|
+
b['y'] = self.height - 1
|
|
152
|
+
b['vy'] *= -1
|
|
153
|
+
b['vx'] *= -1
|
|
154
|
+
self.scores['score'] += 10 * self.scores['level'] * len(self.balls) * 2
|
|
155
|
+
self.count += 1
|
|
156
|
+
# increase level every 5 successful bounces
|
|
157
|
+
if self.count % 5 == 0:
|
|
158
|
+
self.scores['level'] += 1
|
|
159
|
+
# spawn new ball near center top aread
|
|
160
|
+
nb = {
|
|
161
|
+
'x': random.randint(2, max(2, self.width-3)),
|
|
162
|
+
'y': random.randint(2, max(2, self.height - self.height//3)),
|
|
163
|
+
'vx': random.choice([-1,1]),
|
|
164
|
+
'vy': -1
|
|
165
|
+
}
|
|
166
|
+
self.balls.append(nb)
|
|
167
|
+
else:
|
|
168
|
+
b['y'] = self.height + 1
|
|
169
|
+
# if primary ball misses -> game over
|
|
170
|
+
if i == 0:
|
|
171
|
+
self.over = True
|
|
172
|
+
return
|
|
173
|
+
# otherwise remove the extra ball (it contributes to score normally)
|
|
174
|
+
try:
|
|
175
|
+
# find and remove by identity (safer if list has changed)
|
|
176
|
+
self.balls.pop(i)
|
|
177
|
+
except Exception:
|
|
178
|
+
try:
|
|
179
|
+
self.balls.remove(b)
|
|
180
|
+
except Exception:
|
|
181
|
+
pass
|
|
182
|
+
|
|
183
|
+
def movement(self, ch):
|
|
184
|
+
if ch in (curses.KEY_LEFT, ord('a')):
|
|
185
|
+
self.paddle_x = int(clamp(self.paddle_x - 2, 0, self.width - self.paddle_w))
|
|
186
|
+
elif ch in (curses.KEY_RIGHT, ord('d')):
|
|
187
|
+
self.paddle_x = int(clamp(self.paddle_x + 2, 0, self.width - self.paddle_w))
|
|
188
|
+
|
|
189
|
+
def main(stdscr):
|
|
190
|
+
verify_terminal_size('Byte Bouncer')
|
|
191
|
+
init_curses(stdscr)
|
|
192
|
+
while True:
|
|
193
|
+
game = Game(stdscr)
|
|
194
|
+
menu = Menu(game)
|
|
195
|
+
start = menu.display()
|
|
196
|
+
if not start:
|
|
197
|
+
break
|
|
198
|
+
game.update_player_name(start)
|
|
199
|
+
game.run()
|
|
200
|
+
|
|
201
|
+
if __name__ == '__main__':
|
|
202
|
+
try:
|
|
203
|
+
curses.wrapper(main)
|
|
204
|
+
except KeyboardInterrupt:
|
|
205
|
+
try:
|
|
206
|
+
curses.endwin()
|
|
207
|
+
except Exception:
|
|
208
|
+
pass
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""star_ship game package"""
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|