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,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"""
@@ -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"""