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
cli.py
ADDED
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
import curses
|
|
2
|
+
import os
|
|
3
|
+
import importlib.util
|
|
4
|
+
import argparse
|
|
5
|
+
import glob
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
# helper: recognize Enter from multiple terminals/keypads
|
|
9
|
+
def is_enter_key(ch):
|
|
10
|
+
try:
|
|
11
|
+
enter_vals = {10, 13, getattr(curses, 'KEY_ENTER', -1), 343, 459}
|
|
12
|
+
except Exception:
|
|
13
|
+
enter_vals = {10, 13}
|
|
14
|
+
return ch in enter_vals
|
|
15
|
+
|
|
16
|
+
TITLE = [
|
|
17
|
+
' ________ ____ _________ __ ______________ ',
|
|
18
|
+
r' / ____/ / / _/ / ____/ | / |/ / ____/ ___/ ',
|
|
19
|
+
r' / / / / / / / / __/ /| | / /|_/ / __/ \__ \ ',
|
|
20
|
+
r' / /___/ /____/ / / /_/ / ___ |/ / / / /___ ___/ / ',
|
|
21
|
+
r' \____/_____/___/ \____/_/ |_/_/ /_/_____//____/ '
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
def _discover_games():
|
|
25
|
+
base = os.path.dirname(__file__)
|
|
26
|
+
games = []
|
|
27
|
+
# only scan the 'games' subdirectory
|
|
28
|
+
games_dir = os.path.join(base, 'games')
|
|
29
|
+
if not os.path.isdir(games_dir):
|
|
30
|
+
return []
|
|
31
|
+
scan_roots = [games_dir]
|
|
32
|
+
|
|
33
|
+
for root in scan_roots:
|
|
34
|
+
for entry in sorted(os.listdir(root)):
|
|
35
|
+
dirpath = os.path.join(root, entry)
|
|
36
|
+
if not os.path.isdir(dirpath):
|
|
37
|
+
continue
|
|
38
|
+
if entry.startswith('__'):
|
|
39
|
+
continue
|
|
40
|
+
# prefer standardized <dir>/game.py
|
|
41
|
+
candidate = os.path.join(dirpath, "game.py")
|
|
42
|
+
file_to_check = None
|
|
43
|
+
if os.path.exists(candidate):
|
|
44
|
+
file_to_check = candidate
|
|
45
|
+
else:
|
|
46
|
+
pyfiles = [p for p in os.listdir(dirpath) if p.endswith('.py') and not p.startswith('_')]
|
|
47
|
+
if pyfiles:
|
|
48
|
+
file_to_check = os.path.join(dirpath, pyfiles[0])
|
|
49
|
+
if not file_to_check:
|
|
50
|
+
continue
|
|
51
|
+
# use directory name as the display name
|
|
52
|
+
name = entry.replace('_', ' ').title()
|
|
53
|
+
rel = os.path.relpath(file_to_check, base).replace('\\', '/')
|
|
54
|
+
games.append((name, rel))
|
|
55
|
+
return games
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
GAMES = _discover_games()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _read_console_aliases():
|
|
62
|
+
"""Read console_scripts names from installed package metadata."""
|
|
63
|
+
try:
|
|
64
|
+
from importlib import metadata as importlib_metadata
|
|
65
|
+
except Exception:
|
|
66
|
+
return []
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
dist = importlib_metadata.distribution('cli-games')
|
|
70
|
+
aliases = [ep.name for ep in dist.entry_points if ep.group == 'console_scripts']
|
|
71
|
+
return sorted(set(aliases))
|
|
72
|
+
except Exception:
|
|
73
|
+
return []
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _menu(stdscr):
|
|
77
|
+
curses.curs_set(0)
|
|
78
|
+
stdscr.nodelay(False)
|
|
79
|
+
# ensure a cyan color pair is available for the title
|
|
80
|
+
if curses.has_colors():
|
|
81
|
+
try:
|
|
82
|
+
# init colors
|
|
83
|
+
curses.start_color()
|
|
84
|
+
curses.use_default_colors()
|
|
85
|
+
# try to normalize key colors (0..1000 scale). Must run before init_pair.
|
|
86
|
+
if curses.can_change_color() and curses.COLORS >= 8:
|
|
87
|
+
try:
|
|
88
|
+
curses.init_color(curses.COLOR_MAGENTA, 1000, 0, 1000)
|
|
89
|
+
curses.init_color(curses.COLOR_YELLOW, 1000, 1000, 0)
|
|
90
|
+
curses.init_color(curses.COLOR_WHITE, 1000, 1000, 1000)
|
|
91
|
+
curses.init_color(curses.COLOR_CYAN, 0, 1000, 1000)
|
|
92
|
+
curses.init_color(curses.COLOR_BLUE, 0, 0, 1000)
|
|
93
|
+
curses.init_color(curses.COLOR_GREEN, 0, 800, 0)
|
|
94
|
+
curses.init_color(curses.COLOR_RED, 1000, 0, 0)
|
|
95
|
+
curses.init_color(curses.COLOR_BLACK, 0, 0, 0)
|
|
96
|
+
except Exception:
|
|
97
|
+
pass
|
|
98
|
+
for i in range(1,8):
|
|
99
|
+
curses.init_pair(i, i, -1)
|
|
100
|
+
except Exception:
|
|
101
|
+
pass
|
|
102
|
+
sel = 0
|
|
103
|
+
top = 0
|
|
104
|
+
while True:
|
|
105
|
+
stdscr.clear()
|
|
106
|
+
h, w = stdscr.getmaxyx()
|
|
107
|
+
title_h = len(TITLE)
|
|
108
|
+
title_start = 0
|
|
109
|
+
colors = [curses.COLOR_MAGENTA, curses.COLOR_MAGENTA, curses.COLOR_CYAN, curses.COLOR_CYAN, curses.COLOR_GREEN, curses.COLOR_GREEN]
|
|
110
|
+
for i, line in enumerate(TITLE):
|
|
111
|
+
try:
|
|
112
|
+
stdscr.addstr(title_start + i, 0, line, curses.color_pair(colors[i]))
|
|
113
|
+
except Exception:
|
|
114
|
+
pass
|
|
115
|
+
stdscr.addstr(title_h + 1, 2, "Use Up/Down, PageUp/PageDown, Enter to start, ESC to quit", curses.color_pair(curses.COLOR_WHITE))
|
|
116
|
+
start_y = title_h + 3
|
|
117
|
+
# number of lines available for the game list
|
|
118
|
+
avail = max(1, h - start_y - 2)
|
|
119
|
+
total = len(GAMES)
|
|
120
|
+
# clamp top so it stays within valid range
|
|
121
|
+
if top < 0:
|
|
122
|
+
top = 0
|
|
123
|
+
if top > max(0, total - avail):
|
|
124
|
+
top = max(0, total - avail)
|
|
125
|
+
|
|
126
|
+
for vis_i in range(min(avail, total)):
|
|
127
|
+
i = top + vis_i
|
|
128
|
+
name = GAMES[i][0]
|
|
129
|
+
attr = curses.A_REVERSE if i == sel else curses.A_NORMAL
|
|
130
|
+
try:
|
|
131
|
+
stdscr.addstr(start_y + vis_i, 2, name[:w-4], curses.color_pair(curses.COLOR_CYAN) | attr)
|
|
132
|
+
except Exception:
|
|
133
|
+
pass
|
|
134
|
+
# optional scrollbar indicator when list is long
|
|
135
|
+
if total > avail:
|
|
136
|
+
try:
|
|
137
|
+
# try to use glyphs from game_classes.tools when available
|
|
138
|
+
try:
|
|
139
|
+
from game_classes.tools import glyph
|
|
140
|
+
vbar = glyph('VBAR')
|
|
141
|
+
block = glyph('THUMB')
|
|
142
|
+
except Exception:
|
|
143
|
+
vbar = '|'
|
|
144
|
+
block = '#'
|
|
145
|
+
bar_y = start_y
|
|
146
|
+
bar_h = avail
|
|
147
|
+
thumb_h = max(1, int(bar_h * (avail / total)))
|
|
148
|
+
thumb_pos = int((bar_h - thumb_h) * (top / max(1, total - avail)))
|
|
149
|
+
for by in range(bar_h):
|
|
150
|
+
stdscr.addstr(bar_y + by, w - 2, vbar)
|
|
151
|
+
for by in range(thumb_h):
|
|
152
|
+
stdscr.addstr(bar_y + thumb_pos + by, w - 2, block)
|
|
153
|
+
except Exception:
|
|
154
|
+
pass
|
|
155
|
+
stdscr.refresh()
|
|
156
|
+
|
|
157
|
+
ch = stdscr.getch()
|
|
158
|
+
if ch == curses.KEY_UP:
|
|
159
|
+
sel = max(0, sel - 1)
|
|
160
|
+
elif ch == curses.KEY_DOWN:
|
|
161
|
+
sel = min(len(GAMES) - 1, sel + 1)
|
|
162
|
+
elif ch == curses.KEY_PPAGE: # Page Up
|
|
163
|
+
sel = max(0, sel - avail)
|
|
164
|
+
elif ch == curses.KEY_NPAGE: # Page Down
|
|
165
|
+
sel = min(len(GAMES) - 1, sel + avail)
|
|
166
|
+
elif is_enter_key(ch):
|
|
167
|
+
return sel
|
|
168
|
+
elif ch == 27:
|
|
169
|
+
return None
|
|
170
|
+
# adjust top to keep the selected item visible
|
|
171
|
+
if sel < top:
|
|
172
|
+
top = sel
|
|
173
|
+
elif sel >= top + avail:
|
|
174
|
+
top = sel - avail + 1
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _run_game_by_index(choice):
|
|
178
|
+
"""Load and run the game given by numeric index in GAMES."""
|
|
179
|
+
name, relpath = GAMES[choice]
|
|
180
|
+
base = os.path.dirname(__file__)
|
|
181
|
+
path = os.path.join(base, relpath)
|
|
182
|
+
if not os.path.exists(path):
|
|
183
|
+
print(f" [INFO] Game file not found: {path}")
|
|
184
|
+
return
|
|
185
|
+
game_dir = os.path.dirname(path)
|
|
186
|
+
spec = importlib.util.spec_from_file_location(f"cli_game_{choice}", path)
|
|
187
|
+
mod = importlib.util.module_from_spec(spec)
|
|
188
|
+
inserted = []
|
|
189
|
+
try:
|
|
190
|
+
# ensure both the game's directory and project root are on sys.path
|
|
191
|
+
proj_root = os.path.dirname(__file__)
|
|
192
|
+
if game_dir and game_dir not in sys.path:
|
|
193
|
+
sys.path.insert(0, game_dir)
|
|
194
|
+
inserted.append(game_dir)
|
|
195
|
+
if proj_root and proj_root not in sys.path:
|
|
196
|
+
sys.path.insert(0, proj_root)
|
|
197
|
+
inserted.append(proj_root)
|
|
198
|
+
spec.loader.exec_module(mod)
|
|
199
|
+
except Exception as e:
|
|
200
|
+
print(f" [ERROR] Failed to load game {name}: {e}")
|
|
201
|
+
return
|
|
202
|
+
finally:
|
|
203
|
+
for p in inserted:
|
|
204
|
+
try:
|
|
205
|
+
sys.path.remove(p)
|
|
206
|
+
except Exception:
|
|
207
|
+
pass
|
|
208
|
+
if hasattr(mod, 'main'):
|
|
209
|
+
try:
|
|
210
|
+
curses.wrapper(mod.main)
|
|
211
|
+
except Exception as e:
|
|
212
|
+
print(f" [ERROR] Error running game {name}: {e}")
|
|
213
|
+
else:
|
|
214
|
+
print(f" [INFO] Game {name} has no main(stdscr) entry point.")
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _reset_game_by_index(choice, yes=False):
|
|
218
|
+
name, relpath = GAMES[choice]
|
|
219
|
+
base = os.path.dirname(__file__)
|
|
220
|
+
game_dir = os.path.dirname(os.path.join(base, relpath))
|
|
221
|
+
# find highscores files (common pattern in project) and user-data highscores
|
|
222
|
+
files = glob.glob(os.path.join(game_dir, 'highscores*.json'))
|
|
223
|
+
# try user-data location via HighScores
|
|
224
|
+
try:
|
|
225
|
+
from game_classes.highscores import HighScores
|
|
226
|
+
slug = os.path.basename(game_dir)
|
|
227
|
+
hs = HighScores(slug)
|
|
228
|
+
user_path = hs._path()
|
|
229
|
+
if user_path and os.path.exists(user_path):
|
|
230
|
+
files.append(user_path)
|
|
231
|
+
except Exception:
|
|
232
|
+
# if import fails or path not available, ignore
|
|
233
|
+
pass
|
|
234
|
+
if not files:
|
|
235
|
+
print(f" [INFO] No highscore files found for '{name}' ({game_dir}).")
|
|
236
|
+
return
|
|
237
|
+
# dedupe and present
|
|
238
|
+
files = sorted(set(files))
|
|
239
|
+
print(f" [INFO] Found {len(files)} highscore file(s) for '{name}':")
|
|
240
|
+
for f in files:
|
|
241
|
+
print(f' [{choice}] {f}')
|
|
242
|
+
if not yes:
|
|
243
|
+
ans = input(f" [ACTION] Delete these files for '{name}'? [y/N]: ")
|
|
244
|
+
if not ans.lower().startswith('y'):
|
|
245
|
+
print(' [CANCELED]')
|
|
246
|
+
return
|
|
247
|
+
for f in files:
|
|
248
|
+
try:
|
|
249
|
+
os.remove(f)
|
|
250
|
+
print(f" [DELETED] {f}")
|
|
251
|
+
except Exception as e:
|
|
252
|
+
print(f" [ERROR] Failed to delete {f}: {e}")
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _reset_all_games(yes=False):
|
|
256
|
+
base = os.path.dirname(__file__)
|
|
257
|
+
all_files = []
|
|
258
|
+
for name, rel in GAMES:
|
|
259
|
+
game_dir = os.path.dirname(os.path.join(base, rel))
|
|
260
|
+
all_files.extend(glob.glob(os.path.join(game_dir, 'highscores*.json')))
|
|
261
|
+
# include user-data highscores when present
|
|
262
|
+
try:
|
|
263
|
+
from game_classes.highscores import HighScores
|
|
264
|
+
slug = os.path.basename(game_dir)
|
|
265
|
+
hs = HighScores(slug)
|
|
266
|
+
user_path = hs._path()
|
|
267
|
+
if user_path and os.path.exists(user_path):
|
|
268
|
+
all_files.append(user_path)
|
|
269
|
+
except Exception:
|
|
270
|
+
pass
|
|
271
|
+
if not all_files:
|
|
272
|
+
print(' [INFO] No highscore files found for any game.')
|
|
273
|
+
return
|
|
274
|
+
# dedupe list before showing
|
|
275
|
+
all_files = sorted(set(all_files))
|
|
276
|
+
print(f' [INFO] Found {len(all_files)} highscore file(s):')
|
|
277
|
+
for i, f in enumerate(all_files):
|
|
278
|
+
print(f' [{i}] {f}')
|
|
279
|
+
if not yes:
|
|
280
|
+
ans = input(' [ACTION] Delete all these highscore files? [y/N]: ')
|
|
281
|
+
if not ans.lower().startswith('y'):
|
|
282
|
+
print(' [CANCELED]')
|
|
283
|
+
return
|
|
284
|
+
for f in all_files:
|
|
285
|
+
try:
|
|
286
|
+
os.remove(f)
|
|
287
|
+
print(f" [DELETED] {f}")
|
|
288
|
+
except Exception as e:
|
|
289
|
+
print(f" [ERROR] Failed to delete {f}: {e}")
|
|
290
|
+
|
|
291
|
+
# CLI version: read from setup.cfg to keep a single source of truth
|
|
292
|
+
def _read_version_from_setupcfg():
|
|
293
|
+
try:
|
|
294
|
+
from importlib import metadata as importlib_metadata
|
|
295
|
+
except Exception:
|
|
296
|
+
return '0.0.0'
|
|
297
|
+
|
|
298
|
+
try:
|
|
299
|
+
return importlib_metadata.version('cli-games')
|
|
300
|
+
except Exception:
|
|
301
|
+
return '0.0.0'
|
|
302
|
+
|
|
303
|
+
def main():
|
|
304
|
+
# support simple CLI subcommands (e.g. `clig list`)
|
|
305
|
+
# build epilog with examples and any console script aliases from setup.cfg
|
|
306
|
+
epilog_lines = [
|
|
307
|
+
'Examples:',
|
|
308
|
+
f' %(prog)s',
|
|
309
|
+
f' %(prog)s list [-h]',
|
|
310
|
+
f' %(prog)s run [-h] [0, "Byte Bouncer"]',
|
|
311
|
+
f' %(prog)s reset [-h] [0, "Byte Bouncer"] [-y]',
|
|
312
|
+
]
|
|
313
|
+
aliases = _read_console_aliases()
|
|
314
|
+
if aliases:
|
|
315
|
+
epilog_lines.append('\nAliases: ' + ', '.join(aliases))
|
|
316
|
+
epilog = '\n'.join(epilog_lines) + '\n'
|
|
317
|
+
|
|
318
|
+
parser = argparse.ArgumentParser(
|
|
319
|
+
prog=os.path.basename(sys.argv[0]) or 'games',
|
|
320
|
+
description='Run the CLI Games menu or subcommands.',
|
|
321
|
+
epilog=epilog,
|
|
322
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
323
|
+
)
|
|
324
|
+
# standard --version support
|
|
325
|
+
parser.add_argument('-v', '--version', action='version', version=f"%(prog)s {_read_version_from_setupcfg()}")
|
|
326
|
+
sub = parser.add_subparsers(dest='cmd')
|
|
327
|
+
sub.add_parser(
|
|
328
|
+
'list',
|
|
329
|
+
help='List available games',
|
|
330
|
+
description='List all available games with their zero-based indices.',
|
|
331
|
+
epilog='Example:\n %(prog)s\n',
|
|
332
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
333
|
+
)
|
|
334
|
+
runp = sub.add_parser(
|
|
335
|
+
'run',
|
|
336
|
+
help='Run a game by name or zero-based index',
|
|
337
|
+
description='Run a game directly without the menu.',
|
|
338
|
+
epilog='Examples:\n %(prog)s 0\n %(prog)s "Byte Bouncer"\n',
|
|
339
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
340
|
+
)
|
|
341
|
+
runp.add_argument('game', help='Game name or zero-based index')
|
|
342
|
+
resetp = sub.add_parser(
|
|
343
|
+
'reset',
|
|
344
|
+
help='Reset highscores (delete highscore files)',
|
|
345
|
+
description='Delete highscores for one game or all games. Use with care.',
|
|
346
|
+
epilog='Examples:\n %(prog)s\n %(prog)s -y\n %(prog)s 0\n %(prog)s "Byte Bouncer" -y\n',
|
|
347
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
348
|
+
)
|
|
349
|
+
resetp.add_argument('game', nargs='?', help='Optional game name or zero-based index (omit to reset all)')
|
|
350
|
+
resetp.add_argument('-y', '--yes', action='store_true', help='Do not prompt; proceed with deletion')
|
|
351
|
+
args, _rest = parser.parse_known_args()
|
|
352
|
+
|
|
353
|
+
if args.cmd == 'list':
|
|
354
|
+
base = os.path.dirname(__file__)
|
|
355
|
+
for i, (name, rel) in enumerate(GAMES):
|
|
356
|
+
print(f" [{i}] {name}")
|
|
357
|
+
return
|
|
358
|
+
|
|
359
|
+
if args.cmd == 'run':
|
|
360
|
+
token = args.game
|
|
361
|
+
choice = None
|
|
362
|
+
# try integer (zero-based index)
|
|
363
|
+
try:
|
|
364
|
+
idx = int(token)
|
|
365
|
+
if 0 <= idx < len(GAMES):
|
|
366
|
+
choice = idx
|
|
367
|
+
else:
|
|
368
|
+
print(f" [INFO] Index out of range: {idx}")
|
|
369
|
+
for i, (name, rel) in enumerate(GAMES):
|
|
370
|
+
print(f" [{i}] {name}")
|
|
371
|
+
return
|
|
372
|
+
except Exception:
|
|
373
|
+
# match by exact name (case-insensitive)
|
|
374
|
+
lowered = token.lower()
|
|
375
|
+
for i, (name, _) in enumerate(GAMES):
|
|
376
|
+
if name.lower() == lowered:
|
|
377
|
+
choice = i
|
|
378
|
+
break
|
|
379
|
+
# fallback: substring match
|
|
380
|
+
if choice is None:
|
|
381
|
+
for i, (name, _) in enumerate(GAMES):
|
|
382
|
+
if lowered in name.lower():
|
|
383
|
+
choice = i
|
|
384
|
+
break
|
|
385
|
+
if choice is None:
|
|
386
|
+
print(f" [INFO] Game not found: {token}")
|
|
387
|
+
for i, (name, rel) in enumerate(GAMES):
|
|
388
|
+
print(f" [{i}] {name}")
|
|
389
|
+
return
|
|
390
|
+
# run the selected game (skip menu)
|
|
391
|
+
try:
|
|
392
|
+
_run_game_by_index(choice)
|
|
393
|
+
except Exception as e:
|
|
394
|
+
print(f" [ERROR] Error running game: {e}")
|
|
395
|
+
return
|
|
396
|
+
|
|
397
|
+
if args.cmd == 'reset':
|
|
398
|
+
token = args.game
|
|
399
|
+
yes = getattr(args, 'yes', False)
|
|
400
|
+
if token is None:
|
|
401
|
+
_reset_all_games(yes=yes)
|
|
402
|
+
return
|
|
403
|
+
# resolve token to index similar to 'run'
|
|
404
|
+
choice = None
|
|
405
|
+
try:
|
|
406
|
+
idx = int(token)
|
|
407
|
+
if 0 <= idx < len(GAMES):
|
|
408
|
+
choice = idx
|
|
409
|
+
else:
|
|
410
|
+
print(f" [INFO] Index out of range: {idx}")
|
|
411
|
+
return
|
|
412
|
+
except Exception:
|
|
413
|
+
lowered = token.lower()
|
|
414
|
+
for i, (name, _) in enumerate(GAMES):
|
|
415
|
+
if name.lower() == lowered:
|
|
416
|
+
choice = i
|
|
417
|
+
break
|
|
418
|
+
if choice is None:
|
|
419
|
+
print(f" [INFO] Game not found: {token}")
|
|
420
|
+
return
|
|
421
|
+
_reset_game_by_index(choice, yes=yes)
|
|
422
|
+
return
|
|
423
|
+
|
|
424
|
+
# run the menu under curses, then launch the chosen game's main()
|
|
425
|
+
choice = curses.wrapper(_menu)
|
|
426
|
+
if choice is None:
|
|
427
|
+
return
|
|
428
|
+
name, relpath = GAMES[choice]
|
|
429
|
+
base = os.path.dirname(__file__)
|
|
430
|
+
path = os.path.join(base, relpath)
|
|
431
|
+
if not os.path.exists(path):
|
|
432
|
+
print(f" [INFO] Game file not found: {path}")
|
|
433
|
+
return
|
|
434
|
+
# Ensure the game's directory is on sys.path so local imports (like `highscores`) resolve
|
|
435
|
+
game_dir = os.path.dirname(path)
|
|
436
|
+
spec = importlib.util.spec_from_file_location(f"cli_game_{choice}", path)
|
|
437
|
+
mod = importlib.util.module_from_spec(spec)
|
|
438
|
+
inserted = []
|
|
439
|
+
try:
|
|
440
|
+
proj_root = os.path.dirname(__file__)
|
|
441
|
+
if game_dir and game_dir not in sys.path:
|
|
442
|
+
sys.path.insert(0, game_dir)
|
|
443
|
+
inserted.append(game_dir)
|
|
444
|
+
if proj_root and proj_root not in sys.path:
|
|
445
|
+
sys.path.insert(0, proj_root)
|
|
446
|
+
inserted.append(proj_root)
|
|
447
|
+
spec.loader.exec_module(mod)
|
|
448
|
+
except Exception as e:
|
|
449
|
+
print(f" [INFO] Failed to load game {name}: {e}")
|
|
450
|
+
return
|
|
451
|
+
finally:
|
|
452
|
+
for p in inserted:
|
|
453
|
+
try:
|
|
454
|
+
sys.path.remove(p)
|
|
455
|
+
except Exception:
|
|
456
|
+
pass
|
|
457
|
+
# call the game's main function if present
|
|
458
|
+
if hasattr(mod, 'main'):
|
|
459
|
+
try:
|
|
460
|
+
curses.wrapper(mod.main)
|
|
461
|
+
except Exception as e:
|
|
462
|
+
print(f" [ERROR] Error running game {name}: {e}")
|
|
463
|
+
else:
|
|
464
|
+
print(f" [INFO] Game {name} has no main(stdscr) entry point.")
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
if __name__ == '__main__':
|
|
468
|
+
main()
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cli-arcade
|
|
3
|
+
Version: 2026.0.0
|
|
4
|
+
Summary: Collection of terminal CLI games
|
|
5
|
+
Home-page: https://github.com/Bro-Code-Technologies/cli_games/tree/main/windows
|
|
6
|
+
Author: Bro Code Technologies LLC
|
|
7
|
+
Author-email: info@brocodetechnologies.com
|
|
8
|
+
License: MIT
|
|
9
|
+
Project-URL: Source, https://github.com/Bro-Code-Technologies/cli_games/tree/main/windows
|
|
10
|
+
Project-URL: Issues, https://github.com/Bro-Code-Technologies/cli_games/tree/main/windows
|
|
11
|
+
Project-URL: Documentation, https://github.com/Bro-Code-Technologies/cli_games/tree/main/windows
|
|
12
|
+
Project-URL: Package, https://pypi.org/project/cli-games/
|
|
13
|
+
Keywords: cli,terminal,games,curses
|
|
14
|
+
Classifier: Development Status :: 4 - Beta
|
|
15
|
+
Classifier: Environment :: Console
|
|
16
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
17
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
18
|
+
Classifier: Operating System :: OS Independent
|
|
19
|
+
Classifier: Programming Language :: Python :: 3
|
|
20
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
25
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
26
|
+
Classifier: Topic :: Games/Entertainment
|
|
27
|
+
Requires-Python: >=3.8
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
License-File: LICENSE
|
|
30
|
+
Requires-Dist: appdirs
|
|
31
|
+
Requires-Dist: windows-curses; sys_platform == "win32"
|
|
32
|
+
Dynamic: license-file
|
|
33
|
+
|
|
34
|
+
# CLI Games
|
|
35
|
+
|
|
36
|
+
Collection of small terminal games bundled with a single CLI launcher.
|
|
37
|
+
|
|
38
|
+
Requirements
|
|
39
|
+
- Python 3.8+
|
|
40
|
+
- On Windows: install `windows-curses` for curses support:
|
|
41
|
+
|
|
42
|
+
```powershell
|
|
43
|
+
pip install windows-curses
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Quick start (developer)
|
|
47
|
+
|
|
48
|
+
```powershell
|
|
49
|
+
# install editable (recommended during development)
|
|
50
|
+
pip install -e .
|
|
51
|
+
|
|
52
|
+
# list installed console aliases and available games
|
|
53
|
+
clig list
|
|
54
|
+
|
|
55
|
+
# run interactive menu
|
|
56
|
+
clig
|
|
57
|
+
|
|
58
|
+
# run a game by zero-based index or name
|
|
59
|
+
clig run 0
|
|
60
|
+
clig run "Byte Bouncer"
|
|
61
|
+
|
|
62
|
+
# reset highscores for one game or all
|
|
63
|
+
clig reset 0
|
|
64
|
+
clig reset "Byte Bouncer"
|
|
65
|
+
clig reset -y # skip confirmation
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
If the `clig` command is not found after installation, the installer likely wrote scripts to your user Scripts directory. On Windows add this to your PATH (PowerShell):
|
|
69
|
+
|
|
70
|
+
```powershell
|
|
71
|
+
$env:Path += ";$env:APPDATA\Python\Python<version>\Scripts"
|
|
72
|
+
# or permanently via System settings: add %APPDATA%\Python\Python<version>\Scripts to your user PATH
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
You can always run the CLI directly without installing:
|
|
76
|
+
|
|
77
|
+
```powershell
|
|
78
|
+
python -m cli
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Commands
|
|
82
|
+
- `clig` — interactive curses menu
|
|
83
|
+
- `clig list` — print available games and zero-based indices
|
|
84
|
+
- `clig run <index|name>` — run a game directly (index is zero-based)
|
|
85
|
+
- `clig reset [<index|name>] [-y]` — delete highscores for a game or all games
|
|
86
|
+
- Aliases available: `cli-games`, `cli-game`
|
|
87
|
+
|
|
88
|
+
Highscores storage and migration
|
|
89
|
+
- Highscores are now stored in a user-writable application data directory. Typical locations:
|
|
90
|
+
- Windows (appdirs): `%LOCALAPPDATA%\cli-games\games\<game>\highscores.json`
|
|
91
|
+
- Fallback (no appdirs): `%USERPROFILE%\.cli-games\games\<game>\highscores.json`
|
|
92
|
+
- On first run the CLI attempts to migrate any legacy `games/<game>/highscores.json` found in the project into the user data directory.
|
|
93
|
+
|
|
94
|
+
Packaging & publishing (brief)
|
|
95
|
+
|
|
96
|
+
- `setup.cfg` now declares `packages = find:` and `include_package_data = true` so `game_classes/` and `games/` are included in sdist/wheels. Remember to add a `MANIFEST.in` if you need additional files in source distributions.
|
|
97
|
+
- Update `setup.cfg` version.
|
|
98
|
+
- Build: `python -m build` (requires `build` package).
|
|
99
|
+
- Upload: `twine upload dist/*` (requires `twine`).
|
|
100
|
+
- The package exposes several console script aliases (see `setup.cfg` -> `options.entry_points.console_scripts`).
|
|
101
|
+
|
|
102
|
+
Notes & Troubleshooting
|
|
103
|
+
- On Windows, `curses` requires `windows-curses`.
|
|
104
|
+
- The CLI requires a minimum terminal size; if the menu exits with an error, try enlarging your terminal or run `python -m cli` in a larger window.
|
|
105
|
+
- Games should live in their own subdirectory (`games/<slug>/game.py`) and export a `main(stdscr)` entry point. The CLI uses the directory name (slug) as the display title.
|
|
106
|
+
|
|
107
|
+
Terminal recommendations (Windows)
|
|
108
|
+
- Recommended: use Windows Terminal or the VS Code integrated terminal for the best UTF-8 + glyph support.
|
|
109
|
+
- Install Windows Terminal via Microsoft Store or `winget`:
|
|
110
|
+
|
|
111
|
+
```powershell
|
|
112
|
+
winget install --id Microsoft.WindowsTerminal -e
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
- PowerShell (external) often uses OEM code page 437 and may not render some Unicode glyphs. If you prefer the external shell to render glyphs, either use Windows Terminal or make these changes:
|
|
116
|
+
- Change the console font to a glyph-capable font (Cascadia Code PL, Fira Code, DejaVu Sans Mono, or modern Consolas) via the console Properties → Font.
|
|
117
|
+
- Ensure UTF-8 is enabled for the session (temporary):
|
|
118
|
+
|
|
119
|
+
```powershell
|
|
120
|
+
chcp 65001
|
|
121
|
+
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
|
122
|
+
$env:PYTHONIOENCODING = 'utf-8'
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
- To make the change persistent, add the above lines to your PowerShell profile (see `code $profile`), or prefer PowerShell Core (`pwsh`) which typically handles UTF-8 better.
|
|
126
|
+
|
|
127
|
+
- If you cannot enable UTF-8 in your external terminal, the CLI will automatically fall back to safe ASCII glyphs for problematic terminals. To force ASCII output regardless of terminal detection, set the environment variable `CLI_GAMES_FORCE_ASCII=1` before running the CLI.
|
|
128
|
+
|
|
129
|
+
- Advanced: enable system-wide UTF-8 (Region → Administrative → Change system locale → check “Beta: Use Unicode UTF-8 for worldwide language support”) and restart. This affects other apps and requires caution.
|
|
130
|
+
|
|
131
|
+
Contributing
|
|
132
|
+
- Add a new game by creating a subdirectory under `games/` with a `game.py` file that exports `main(stdscr)`.
|
|
133
|
+
- Keep changes minimal and run `clig` locally to verify.
|
|
134
|
+
|
|
135
|
+
License
|
|
136
|
+
- MIT
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
cli.py,sha256=Kc8jgyneJYYjLedgvniskd2_0mXyBg1R6-TLslJgcgE,17458
|
|
2
|
+
cli_arcade-2026.0.0.dist-info/licenses/LICENSE,sha256=1PLSNFyGPi9COkVEeNYcUTuRLqGxzMLLTah05tByD4s,1099
|
|
3
|
+
game_classes/__init__.py,sha256=A0o4vBUktOiONToIJb0hACalszBnFS9ls9WXPn3-KH0,28
|
|
4
|
+
game_classes/game_base.py,sha256=q779LzYsK8rO0rWrj2iB5407wuLiZvuFiRBx6qQEfPQ,3505
|
|
5
|
+
game_classes/highscores.py,sha256=uzK2NJU-hnmMcDT7TEWhkikKn4rnqiJMln8szOqqPX4,4060
|
|
6
|
+
game_classes/menu.py,sha256=6frg15Ibx_H89g-KmHv53d6uWSUsxluhXMGvn4Nj7xY,2795
|
|
7
|
+
game_classes/tools.py,sha256=TKkJ0bv2sbs_smjrXLCJZd9IIcZe_f96ms0uSBYA6gc,4972
|
|
8
|
+
game_classes/__pycache__/__init__.cpython-313.pyc,sha256=zSl9ESMUQn5Vo_XXAA8DD7lpFHOtOwBruGvCOJ5qeuo,192
|
|
9
|
+
game_classes/__pycache__/game_base.cpython-313.pyc,sha256=p6NUAgjNMXwPoNkQq01tUZm_ZYX18jaIMRZOm35vKsk,6962
|
|
10
|
+
game_classes/__pycache__/highscores.cpython-313.pyc,sha256=diYyL8QT5LpS_68hz5s_kfHbgaqihyOygTABlkzg2MI,7067
|
|
11
|
+
game_classes/__pycache__/menu.cpython-313.pyc,sha256=wseCBPJ5XKtVI8scyJrBZxAW2avD2JF3J0IpRUYzsR8,4459
|
|
12
|
+
game_classes/__pycache__/tools.cpython-313.pyc,sha256=OrKBCCTPdimXHQZ7hXRCkB_0IcCQiG9POLdnnKgKjl8,7331
|
|
13
|
+
games/__init__.py,sha256=Rg46lnLj_sezpnXaQTS1NXX5OaRhR_EWpNTyYUxycj4,21
|
|
14
|
+
games/byte_bouncer/__init__.py,sha256=8hxIHRyUPB-X6vJXh1432ytKjOcfIzCbdpO5mmWA7iQ,33
|
|
15
|
+
games/byte_bouncer/game.py,sha256=9SFBre-QvjTa5maKP-XjI-Fh-1dNgxeYxxtglrccnA4,8105
|
|
16
|
+
games/byte_bouncer/__pycache__/byte_bouncer.cpython-313.pyc,sha256=JSTB6-_KCukAAVaZz9TpJ3V4gif_M3n_L5X1KOviSJ8,24605
|
|
17
|
+
games/byte_bouncer/__pycache__/game.cpython-313.pyc,sha256=Z5xiPsrisDqe8Mx6FQjW3eHNiXMXuIY5mJlrVmNUfec,13017
|
|
18
|
+
games/byte_bouncer/__pycache__/highscores.cpython-313.pyc,sha256=g3FozKpWGDVsTVETNpb6lRHcKA3ZIsQio8Kh7KCGVCI,3125
|
|
19
|
+
games/star_ship/__init__.py,sha256=X8AdhoccmNVu6yl7MalsxIsMxuEsddRdlvEQmhZMD0A,30
|
|
20
|
+
games/star_ship/game.py,sha256=Mph_USmrroynsli43vD3luadYAOiPXmbs0JMOCDGxMk,9545
|
|
21
|
+
games/star_ship/__pycache__/game.cpython-313.pyc,sha256=TnsJQS7zrwB0zG7CZ7VlTPE_qq063cZdZcL0Z66fu08,14623
|
|
22
|
+
games/star_ship/__pycache__/highscores.cpython-313.pyc,sha256=nHbEOTWeX0U9D47K2EP5S5uDPI0WW52Eg7h_NesG1OU,3148
|
|
23
|
+
games/star_ship/__pycache__/nibbles.cpython-313.pyc,sha256=Yv2P-GwEc30zA24aO7k0hkk1AOja6K85AFvmjLIffqQ,20510
|
|
24
|
+
games/star_ship/__pycache__/snek.cpython-313.pyc,sha256=3Mwyy6vjGFRmNyAWSqV5JRilLOxiCSndBmwua_Y7VTs,13313
|
|
25
|
+
games/star_ship/__pycache__/star_ship.cpython-313.pyc,sha256=9gJt10MTzVTSwsc4I16TDZTaAXclfhfGTzgfYgZNOKo,28021
|
|
26
|
+
games/terminal_tumble/__init__.py,sha256=axTd7hCgnLRNU8QS73NnQY3q5xjIb3ZYQORJ9Lz8l_E,36
|
|
27
|
+
games/terminal_tumble/game.py,sha256=p4lrFHBoHZJLpsUr0jXmR9FI8OTZmwfGIS1Z29MoYzU,15699
|
|
28
|
+
games/terminal_tumble/__pycache__/game.cpython-313.pyc,sha256=-xFF1wgupqiSoHz3QZwMNHxhAIV_UyIp0YifRdg8ijA,24104
|
|
29
|
+
games/terminal_tumble/__pycache__/highscores.cpython-313.pyc,sha256=UPnU9Zq88q2TZq1qXyo8e2Y_PdOgwvu5mncIkunFah8,3154
|
|
30
|
+
games/terminal_tumble/__pycache__/terminal_tumble.cpython-313.pyc,sha256=7oLpmmkYjpLYRC0bjRoEkHbIUNq7FZVJToGoja94m0o,38016
|
|
31
|
+
cli_arcade-2026.0.0.dist-info/METADATA,sha256=YdvaQUC9u0xS_teAd3dVSJTn0-hrxVNFE3gzh_erD2Y,5952
|
|
32
|
+
cli_arcade-2026.0.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
33
|
+
cli_arcade-2026.0.0.dist-info/entry_points.txt,sha256=dEd6b2AwMr2o-sSOzi2aAfIy0ViqcnAkuHShiOMVr4o,75
|
|
34
|
+
cli_arcade-2026.0.0.dist-info/top_level.txt,sha256=gTfYlz7gHYgPY0ugfJivukCOkNzmOisNR6VuJjAXPF4,23
|
|
35
|
+
cli_arcade-2026.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Bro Code Technologies LLC
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
game_classes/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""game_classes package"""
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|