belote-cli 0.9.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.
- belote/__init__.py +0 -0
- belote/ai.py +412 -0
- belote/ansi.py +141 -0
- belote/bidding.py +68 -0
- belote/deck.py +156 -0
- belote/game.py +537 -0
- belote/input.py +151 -0
- belote/main.py +352 -0
- belote/rules.py +104 -0
- belote/scoring.py +488 -0
- belote/ui.py +825 -0
- belote_cli-0.9.0.dist-info/METADATA +177 -0
- belote_cli-0.9.0.dist-info/RECORD +16 -0
- belote_cli-0.9.0.dist-info/WHEEL +4 -0
- belote_cli-0.9.0.dist-info/entry_points.txt +2 -0
- belote_cli-0.9.0.dist-info/licenses/LICENSE +21 -0
belote/ui.py
ADDED
|
@@ -0,0 +1,825 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
import sys
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
from .deck import Card, Rank, Suit
|
|
8
|
+
from .game import (
|
|
9
|
+
GameState,
|
|
10
|
+
Phase,
|
|
11
|
+
Seat,
|
|
12
|
+
TrickCard,
|
|
13
|
+
legal_cards,
|
|
14
|
+
team_of,
|
|
15
|
+
partner,
|
|
16
|
+
)
|
|
17
|
+
from .ansi import (
|
|
18
|
+
RESET, BOLD, DIM, REVERSE, UNDERLINE,
|
|
19
|
+
fg, clear_screen, hide_cursor, show_cursor,
|
|
20
|
+
felt_bg, red_fg, black_fg, card_face_bg, card_back_bg,
|
|
21
|
+
highlight_bg, gold_fg, white_fg, light_gray_fg, green_fg,
|
|
22
|
+
banner_bg, banner_fg, visible_len, ansi_center, ansi_ljust,
|
|
23
|
+
face_card_bg,
|
|
24
|
+
)
|
|
25
|
+
from .input import KeyReader, KeyEvent, Key
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# Card display dimensions — fixed constants only, no terminal queries at module level.
|
|
29
|
+
# Terminal width is queried fresh inside render() on every frame.
|
|
30
|
+
CARD_W = 6
|
|
31
|
+
CARD_H = 5
|
|
32
|
+
CARD_GAP = 1
|
|
33
|
+
|
|
34
|
+
# Fixed visible width for the WEST and EAST side columns in the middle section.
|
|
35
|
+
SIDE_COL_W = 22
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _card_symbol(card: Card) -> str:
|
|
39
|
+
return f"{card.rank.value}{card.suit.symbol}"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _card_face(card: Card, selected: bool = False, legal: bool = True) -> list[str]:
|
|
43
|
+
"""Render a card as CARD_H lines of width CARD_W."""
|
|
44
|
+
sym = card.rank.value + " " if len(card.rank.value) == 1 else card.rank.value
|
|
45
|
+
suit_sym = card.suit.symbol
|
|
46
|
+
face_text = f"{sym}{suit_sym}"
|
|
47
|
+
|
|
48
|
+
is_face = card.rank in (Rank.JACK, Rank.QUEEN, Rank.KING)
|
|
49
|
+
|
|
50
|
+
color = red_fg() if card.suit.is_red else black_fg()
|
|
51
|
+
|
|
52
|
+
if selected:
|
|
53
|
+
bg_code = highlight_bg()
|
|
54
|
+
elif is_face:
|
|
55
|
+
bg_code = face_card_bg()
|
|
56
|
+
else:
|
|
57
|
+
bg_code = card_face_bg()
|
|
58
|
+
|
|
59
|
+
prefix = "" if legal else DIM
|
|
60
|
+
|
|
61
|
+
inner_w = CARD_W - 2
|
|
62
|
+
|
|
63
|
+
# Optional miniature art for face cards
|
|
64
|
+
art = " "
|
|
65
|
+
if card.rank == Rank.JACK:
|
|
66
|
+
art = "⚔ "
|
|
67
|
+
elif card.rank == Rank.QUEEN:
|
|
68
|
+
art = "♕ "
|
|
69
|
+
elif card.rank == Rank.KING:
|
|
70
|
+
art = "♔ "
|
|
71
|
+
elif card.rank == Rank.ACE:
|
|
72
|
+
art = "★ "
|
|
73
|
+
|
|
74
|
+
return [
|
|
75
|
+
f"{prefix}{bg_code}{color}┌{'─' * inner_w}┐{RESET}",
|
|
76
|
+
f"{prefix}{bg_code}{color}│{face_text.ljust(inner_w)}│{RESET}",
|
|
77
|
+
f"{prefix}{bg_code}{color}│{art.center(inner_w)}│{RESET}",
|
|
78
|
+
f"{prefix}{bg_code}{color}│{face_text.rjust(inner_w)}│{RESET}",
|
|
79
|
+
f"{prefix}{bg_code}{color}└{'─' * inner_w}┘{RESET}",
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _card_back() -> list[str]:
|
|
84
|
+
"""Render a face-down card."""
|
|
85
|
+
inner_w = CARD_W - 2
|
|
86
|
+
hatch = "╳" * (inner_w // 2)
|
|
87
|
+
return [
|
|
88
|
+
f"{card_back_bg()}┌{'─' * inner_w}┐{RESET}",
|
|
89
|
+
f"{card_back_bg()}│{' ' * inner_w}│{RESET}",
|
|
90
|
+
f"{card_back_bg()}│{hatch.center(inner_w)}│{RESET}",
|
|
91
|
+
f"{card_back_bg()}│{' ' * inner_w}│{RESET}",
|
|
92
|
+
f"{card_back_bg()}└{'─' * inner_w}┘{RESET}",
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _card_back_small() -> str:
|
|
97
|
+
"""Single-line face-down card for opponent hand display."""
|
|
98
|
+
return f"{card_back_bg()}▓▓{RESET}"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _felt_blank(width: int) -> str:
|
|
102
|
+
return felt_bg() + " " * width + RESET
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _felt_pad(content: str, width: int) -> str:
|
|
106
|
+
"""Center content on a green felt background of `width` visible chars."""
|
|
107
|
+
vlen = visible_len(content)
|
|
108
|
+
total_pad = max(0, width - vlen)
|
|
109
|
+
lpad = total_pad // 2
|
|
110
|
+
rpad = total_pad - lpad
|
|
111
|
+
return felt_bg() + " " * lpad + RESET + content + felt_bg() + " " * rpad + RESET
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _felt_placeholder() -> list[str]:
|
|
115
|
+
"""Card-sized dashed outline on the felt — shown for an empty trick slot."""
|
|
116
|
+
inner_w = CARD_W - 2
|
|
117
|
+
dim = fg(35, 130, 70)
|
|
118
|
+
top = felt_bg() + dim + "┌" + "─" * inner_w + "┐" + RESET
|
|
119
|
+
mid = felt_bg() + dim + "│" + " " * inner_w + "│" + RESET
|
|
120
|
+
bottom = felt_bg() + dim + "└" + "─" * inner_w + "┘" + RESET
|
|
121
|
+
return [top, mid, mid, mid, bottom]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _render_trick_mat(seat_map: dict[Seat, Card], center_w: int) -> list[str]:
|
|
125
|
+
"""21-row green felt mat with full card graphics at compass positions."""
|
|
126
|
+
def slot(seat: Seat) -> list[str]:
|
|
127
|
+
return _card_face(seat_map[seat]) if seat in seat_map else _felt_placeholder()
|
|
128
|
+
|
|
129
|
+
n_card = slot(Seat.NORTH)
|
|
130
|
+
w_card = slot(Seat.WEST)
|
|
131
|
+
e_card = slot(Seat.EAST)
|
|
132
|
+
s_card = slot(Seat.SOUTH)
|
|
133
|
+
|
|
134
|
+
# Horizontal anchors: West centred at ¼, East centred at ¾ of center_w
|
|
135
|
+
w_start = max(0, center_w // 4 - CARD_W // 2)
|
|
136
|
+
e_start = max(0, 3 * center_w // 4 - CARD_W // 2)
|
|
137
|
+
mid_gap = max(0, e_start - w_start - CARD_W)
|
|
138
|
+
r_pad = max(0, center_w - e_start - CARD_W)
|
|
139
|
+
|
|
140
|
+
n_label = _felt_pad(f"{light_gray_fg()}N{RESET}", center_w)
|
|
141
|
+
s_label = _felt_pad(f"{light_gray_fg()}S{RESET}", center_w)
|
|
142
|
+
|
|
143
|
+
rows: list[str] = []
|
|
144
|
+
|
|
145
|
+
rows.append(_felt_blank(center_w)) # top padding
|
|
146
|
+
rows.append(n_label) # N label
|
|
147
|
+
for line in n_card: # North card (5 rows)
|
|
148
|
+
rows.append(_felt_pad(line, center_w))
|
|
149
|
+
rows.append(_felt_blank(center_w)) # gap
|
|
150
|
+
for i in range(CARD_H): # West + East (5 rows)
|
|
151
|
+
rows.append(
|
|
152
|
+
felt_bg() + " " * w_start + RESET +
|
|
153
|
+
w_card[i] +
|
|
154
|
+
felt_bg() + " " * mid_gap + RESET +
|
|
155
|
+
e_card[i] +
|
|
156
|
+
felt_bg() + " " * r_pad + RESET
|
|
157
|
+
)
|
|
158
|
+
rows.append(_felt_blank(center_w)) # gap
|
|
159
|
+
for line in s_card: # South card (5 rows)
|
|
160
|
+
rows.append(_felt_pad(line, center_w))
|
|
161
|
+
rows.append(s_label) # S label
|
|
162
|
+
rows.append(_felt_blank(center_w)) # bottom padding
|
|
163
|
+
|
|
164
|
+
return rows # 21 rows total
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _render_hand_horizontal(
|
|
168
|
+
cards: tuple[Card, ...],
|
|
169
|
+
selection: int | None,
|
|
170
|
+
legal: tuple[Card, ...],
|
|
171
|
+
term_w: int,
|
|
172
|
+
) -> list[str]:
|
|
173
|
+
"""Render South's hand horizontally, already centered to term_w.
|
|
174
|
+
|
|
175
|
+
The cursor ▲ row is offset to match the visual center of the selected card,
|
|
176
|
+
accounting for the left-padding that ansi_center adds.
|
|
177
|
+
"""
|
|
178
|
+
if not cards:
|
|
179
|
+
return [""]
|
|
180
|
+
|
|
181
|
+
# Build each card's CARD_H lines
|
|
182
|
+
card_line_groups: list[list[str]] = [
|
|
183
|
+
_card_face(c, selected=(i == selection), legal=(c in legal))
|
|
184
|
+
for i, c in enumerate(cards)
|
|
185
|
+
]
|
|
186
|
+
|
|
187
|
+
# Join cards horizontally with a 1-space gap
|
|
188
|
+
gap = " "
|
|
189
|
+
slot_w = CARD_W + len(gap) # visible width of one card slot
|
|
190
|
+
total_hand_w = len(cards) * slot_w - len(gap) # visible width of full hand
|
|
191
|
+
|
|
192
|
+
# Compute left padding that ansi_center will add — we need this for the cursor
|
|
193
|
+
left_pad = max(0, (term_w - total_hand_w) // 2)
|
|
194
|
+
|
|
195
|
+
rows: list[str] = []
|
|
196
|
+
for row_idx in range(CARD_H):
|
|
197
|
+
raw = gap.join(group[row_idx] for group in card_line_groups)
|
|
198
|
+
rows.append(ansi_center(raw, term_w)) # ← ANSI-aware centering
|
|
199
|
+
|
|
200
|
+
# Cursor row — must account for the centering offset so ▲ lands under the card
|
|
201
|
+
if selection is not None:
|
|
202
|
+
cursor_col = left_pad + selection * slot_w + CARD_W // 2
|
|
203
|
+
rows.append(" " * cursor_col + "▲")
|
|
204
|
+
|
|
205
|
+
return rows
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _seat_label(seat: Seat, state: GameState) -> str:
|
|
209
|
+
"""Colored seat label, highlighted when it's that seat's turn."""
|
|
210
|
+
name = seat.name
|
|
211
|
+
if state.turn == seat:
|
|
212
|
+
return f"{BOLD}{gold_fg()}{name} >>{RESET}"
|
|
213
|
+
return f"{BOLD}{white_fg()}{name}{RESET}"
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _render_middle_section(state: GameState, term_w: int) -> list[str]:
|
|
217
|
+
"""Build the 3-column middle section: WEST | green felt mat | EAST."""
|
|
218
|
+
west_hand = state.hand_of(Seat.WEST)
|
|
219
|
+
east_hand = state.hand_of(Seat.EAST)
|
|
220
|
+
|
|
221
|
+
center_w = max(0, term_w - SIDE_COL_W * 2)
|
|
222
|
+
|
|
223
|
+
# ── Center column (trick area / bidding up-card) ──────────────────────────
|
|
224
|
+
center_rows = []
|
|
225
|
+
if state.phase == Phase.BIDDING and state.up_card:
|
|
226
|
+
# Show the up-card in the center
|
|
227
|
+
up_face = _card_face(state.up_card)
|
|
228
|
+
center_rows = [
|
|
229
|
+
ansi_center(f"{BOLD}{white_fg()}ROUND {state.bidding_round}{RESET}", 20),
|
|
230
|
+
"",
|
|
231
|
+
ansi_center(up_face[0], 20),
|
|
232
|
+
ansi_center(up_face[1], 20),
|
|
233
|
+
ansi_center(up_face[2], 20),
|
|
234
|
+
ansi_center(up_face[3], 20),
|
|
235
|
+
ansi_center(up_face[4], 20),
|
|
236
|
+
"",
|
|
237
|
+
]
|
|
238
|
+
# Pad to match trick mat height if needed
|
|
239
|
+
while len(center_rows) < 21: # Trick mat is 21 rows
|
|
240
|
+
center_rows.insert(0, "")
|
|
241
|
+
if len(center_rows) < 21:
|
|
242
|
+
center_rows.append("")
|
|
243
|
+
else:
|
|
244
|
+
trick = state.current_trick
|
|
245
|
+
seat_map: dict[Seat, Card] = {tc.seat: tc.card for tc in trick}
|
|
246
|
+
center_rows = _render_trick_mat(seat_map, center_w)
|
|
247
|
+
|
|
248
|
+
n_rows = len(center_rows)
|
|
249
|
+
|
|
250
|
+
# ── Left column (WEST) — vertically centred in the mat ───────────────────
|
|
251
|
+
w_label = _seat_label(Seat.WEST, state)
|
|
252
|
+
w_cards = f"{_card_back_small()} " * min(len(west_hand), 4)
|
|
253
|
+
w_count = f"{light_gray_fg()}({len(west_hand)} left){RESET}"
|
|
254
|
+
|
|
255
|
+
left_rows: list[str] = [""] * n_rows
|
|
256
|
+
mid = n_rows // 2
|
|
257
|
+
left_rows[mid - 1] = w_label
|
|
258
|
+
left_rows[mid] = w_cards
|
|
259
|
+
left_rows[mid + 1] = w_count
|
|
260
|
+
|
|
261
|
+
# ── Right column (EAST) — vertically centred in the mat ──────────────────
|
|
262
|
+
e_label = _seat_label(Seat.EAST, state)
|
|
263
|
+
e_cards = f"{_card_back_small()} " * min(len(east_hand), 4)
|
|
264
|
+
e_count = f"{light_gray_fg()}({len(east_hand)} left){RESET}"
|
|
265
|
+
|
|
266
|
+
right_rows: list[str] = [""] * n_rows
|
|
267
|
+
right_rows[mid - 3] = e_label
|
|
268
|
+
right_rows[mid - 2] = e_cards
|
|
269
|
+
right_rows[mid - 1] = e_count
|
|
270
|
+
|
|
271
|
+
# Last Trick Panel
|
|
272
|
+
if state.completed_tricks:
|
|
273
|
+
last = state.completed_tricks[-1]
|
|
274
|
+
right_rows[mid + 1] = f"{UNDERLINE}Last Trick:{RESET}"
|
|
275
|
+
# Format as "S:7♠ E:J♦"
|
|
276
|
+
line1 = " ".join(f"{tc.seat.name[0]}:{_card_symbol(tc.card)}" for tc in last[:2])
|
|
277
|
+
line2 = " ".join(f"{tc.seat.name[0]}:{_card_symbol(tc.card)}" for tc in last[2:])
|
|
278
|
+
right_rows[mid + 2] = line1
|
|
279
|
+
right_rows[mid + 3] = line2
|
|
280
|
+
|
|
281
|
+
# ── Combine columns ───────────────────────────────────────────────────────
|
|
282
|
+
result: list[str] = []
|
|
283
|
+
for l, c, r in zip(left_rows, center_rows, right_rows):
|
|
284
|
+
result.append(ansi_ljust(l, SIDE_COL_W) + c + ansi_ljust(r, SIDE_COL_W))
|
|
285
|
+
|
|
286
|
+
return result
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _build_hud(state: GameState, term_w: int) -> str:
|
|
290
|
+
"""Build the top HUD bar, padded to term_w visible chars."""
|
|
291
|
+
trump_sym = state.trump.symbol if state.trump else "?"
|
|
292
|
+
ns, ew = state.team_scores
|
|
293
|
+
trick_num = len(state.completed_tricks) + (1 if state.current_trick else 0)
|
|
294
|
+
taker_name = state.taker.name if state.taker else "-"
|
|
295
|
+
|
|
296
|
+
# Live round points
|
|
297
|
+
ns_round = state.tricks_won_by_team(0) # We'll need to count points instead of tricks
|
|
298
|
+
ew_round = state.tricks_won_by_team(1)
|
|
299
|
+
|
|
300
|
+
# Actually calculate card points for better 'live' feel
|
|
301
|
+
from .deck import card_points
|
|
302
|
+
ns_pts = 0
|
|
303
|
+
ew_pts = 0
|
|
304
|
+
for trick in state.completed_tricks:
|
|
305
|
+
from .game import trick_winner_seat, team_of
|
|
306
|
+
winner = trick_winner_seat(trick, state.trump)
|
|
307
|
+
p = sum(card_points(tc.card, state.trump) for tc in trick)
|
|
308
|
+
if winner and team_of(winner) == 0:
|
|
309
|
+
ns_pts += p
|
|
310
|
+
elif winner:
|
|
311
|
+
ew_pts += p
|
|
312
|
+
|
|
313
|
+
# Include Dix de Der in live score if 8 tricks are done
|
|
314
|
+
if len(state.completed_tricks) == 8:
|
|
315
|
+
from .game import trick_winner_seat, team_of
|
|
316
|
+
winner = trick_winner_seat(state.completed_tricks[-1], state.trump)
|
|
317
|
+
if winner and team_of(winner) == 0:
|
|
318
|
+
ns_pts += 10
|
|
319
|
+
elif winner:
|
|
320
|
+
ew_pts += 10
|
|
321
|
+
|
|
322
|
+
left = f"{BOLD}{gold_fg()}BELOTE{RESET}"
|
|
323
|
+
mid = (f"{white_fg()}Trump: {trump_sym} "
|
|
324
|
+
f"NS: {BOLD}{ns}{RESET}{white_fg()} (+{ns_pts}) "
|
|
325
|
+
f"EW: {BOLD}{ew}{RESET}{white_fg()} (+{ew_pts}) "
|
|
326
|
+
f"Trick {trick_num}/8 Taker: {taker_name}{RESET}")
|
|
327
|
+
bar = left + " " + mid
|
|
328
|
+
return ansi_ljust(bar, term_w)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def animate_score_update(state: GameState, target_ns: int, target_ew: int, duration: float = 1.0) -> None:
|
|
332
|
+
"""Animate the team scores rolling up to their new values."""
|
|
333
|
+
import time
|
|
334
|
+
from .game import replace
|
|
335
|
+
|
|
336
|
+
start_ns, start_ew = state.team_scores
|
|
337
|
+
steps = 20
|
|
338
|
+
delay = duration / steps
|
|
339
|
+
|
|
340
|
+
for i in range(1, steps + 1):
|
|
341
|
+
curr_ns = start_ns + (target_ns - start_ns) * i // steps
|
|
342
|
+
curr_ew = start_ew + (target_ew - start_ew) * i // steps
|
|
343
|
+
|
|
344
|
+
temp_state = replace(state, team_scores=(curr_ns, curr_ew))
|
|
345
|
+
display(temp_state, None)
|
|
346
|
+
time.sleep(delay)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def render(state: GameState, selection: int | None = None) -> str:
|
|
350
|
+
"""Pure: returns a full-screen ANSI-formatted string. No I/O.
|
|
351
|
+
|
|
352
|
+
Terminal width is queried fresh on every call so resizing works correctly.
|
|
353
|
+
"""
|
|
354
|
+
# Query terminal size HERE, not at module level.
|
|
355
|
+
term_w, _ = shutil.get_terminal_size(fallback=(120, 40))
|
|
356
|
+
|
|
357
|
+
out = clear_screen() + hide_cursor()
|
|
358
|
+
legal : tuple[Card, ...] = ()
|
|
359
|
+
if state.phase == Phase.PLAYING and state.turn == Seat.SOUTH:
|
|
360
|
+
legal = legal_cards(state, Seat.SOUTH)
|
|
361
|
+
|
|
362
|
+
lines: list[str] = []
|
|
363
|
+
|
|
364
|
+
# ── HUD ──────────────────────────────────────────────────────────────────
|
|
365
|
+
lines.append(_build_hud(state, term_w))
|
|
366
|
+
lines.append("─" * term_w)
|
|
367
|
+
|
|
368
|
+
# ── NORTH ────────────────────────────────────────────────────────────────
|
|
369
|
+
north_hand = state.hand_of(Seat.NORTH)
|
|
370
|
+
north_cards = f"{_card_back_small()} " * min(len(north_hand), 4)
|
|
371
|
+
north_label = _seat_label(Seat.NORTH, state)
|
|
372
|
+
north_count = f"{light_gray_fg()}({len(north_hand)} cards){RESET}"
|
|
373
|
+
lines.append("")
|
|
374
|
+
lines.append(ansi_center(
|
|
375
|
+
f"{north_label} {north_cards} {north_count}", term_w
|
|
376
|
+
))
|
|
377
|
+
|
|
378
|
+
# ── WEST | trick area | EAST (3-column, same rows) ──────────────────────
|
|
379
|
+
lines.append("")
|
|
380
|
+
for row in _render_middle_section(state, term_w):
|
|
381
|
+
lines.append(row)
|
|
382
|
+
|
|
383
|
+
# ── DIVIDER ───────────────────────────────────────────────────────────────
|
|
384
|
+
lines.append("")
|
|
385
|
+
lines.append("─" * term_w)
|
|
386
|
+
|
|
387
|
+
# ── PHASE INFO ────────────────────────────────────────────────────────────
|
|
388
|
+
phase_info = ""
|
|
389
|
+
if state.phase == Phase.BIDDING:
|
|
390
|
+
if state.turn == Seat.SOUTH:
|
|
391
|
+
phase_info = f"{BOLD}{gold_fg()}>> YOUR TURN <<{RESET}"
|
|
392
|
+
else:
|
|
393
|
+
phase_info = f"{light_gray_fg()}Waiting for {state.turn.name} to bid...{RESET}"
|
|
394
|
+
elif state.phase == Phase.PLAYING:
|
|
395
|
+
if state.turn == Seat.SOUTH:
|
|
396
|
+
phase_info = f"{BOLD}{gold_fg()}>> YOUR TURN <<{RESET}"
|
|
397
|
+
else:
|
|
398
|
+
phase_info = f"{light_gray_fg()}Waiting for {state.turn.name}...{RESET}"
|
|
399
|
+
elif state.phase == Phase.SCORING:
|
|
400
|
+
phase_info = f"{BOLD}{gold_fg()}Round complete!{RESET}"
|
|
401
|
+
|
|
402
|
+
if state.announced:
|
|
403
|
+
phase_info += f" {BOLD}{banner_bg()}{banner_fg()} {state.announced} {RESET}"
|
|
404
|
+
|
|
405
|
+
lines.append(ansi_center(phase_info, term_w))
|
|
406
|
+
lines.append("")
|
|
407
|
+
|
|
408
|
+
# ── SOUTH hand ────────────────────────────────────────────────────────────
|
|
409
|
+
south_hand = state.hand_of(Seat.SOUTH)
|
|
410
|
+
south_legal = legal if state.turn == Seat.SOUTH else ()
|
|
411
|
+
|
|
412
|
+
if south_hand:
|
|
413
|
+
# Keyboard shortcut hints
|
|
414
|
+
hints = " ".join(f"[{i+1}]" for i in range(len(south_hand)))
|
|
415
|
+
lines.append(ansi_center(hints, term_w))
|
|
416
|
+
|
|
417
|
+
# Cards + cursor (already centered inside _render_hand_horizontal)
|
|
418
|
+
for sl in _render_hand_horizontal(south_hand, selection, south_legal, term_w):
|
|
419
|
+
lines.append(sl)
|
|
420
|
+
|
|
421
|
+
south_label = _seat_label(Seat.SOUTH, state)
|
|
422
|
+
lines.append(ansi_center(f"{south_label} (you)", term_w))
|
|
423
|
+
|
|
424
|
+
# ── BIDDING PROMPT ────────────────────────────────────────────────────────
|
|
425
|
+
if state.phase == Phase.BIDDING and state.turn == Seat.SOUTH:
|
|
426
|
+
lines.append("")
|
|
427
|
+
prompt = f"{BOLD}{gold_fg()}Bid: [P]ass [1]♠ [2]♥ [3]♦ [4]♣{RESET}"
|
|
428
|
+
lines.append(ansi_center(prompt, term_w))
|
|
429
|
+
|
|
430
|
+
# Pad to minimum height
|
|
431
|
+
while len(lines) < 26:
|
|
432
|
+
lines.append("")
|
|
433
|
+
|
|
434
|
+
# CRITICAL: use \r\n not \n.
|
|
435
|
+
# tty.setraw() clears OPOST which disables ONLCR (automatic \n→\r\n).
|
|
436
|
+
# Without \r, the cursor moves DOWN but not back to column 1, so every
|
|
437
|
+
# subsequent line starts where the previous one ended — producing the
|
|
438
|
+
# diagonal stagger visible in the screenshot. \r\n fixes this.
|
|
439
|
+
return out + "\r\n".join(lines) + show_cursor()
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def display(state: GameState, selection: int | None = None) -> None:
|
|
443
|
+
sys.stdout.write(render(state, selection))
|
|
444
|
+
sys.stdout.flush()
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def prompt_card(state: GameState, reader: KeyReader) -> Card | None:
|
|
448
|
+
"""Interactive card selection with arrow keys. Returns None if QUIT is pressed."""
|
|
449
|
+
hand = state.hand_of(Seat.SOUTH)
|
|
450
|
+
legal = legal_cards(state, Seat.SOUTH)
|
|
451
|
+
|
|
452
|
+
if not hand:
|
|
453
|
+
raise ValueError("No cards in hand")
|
|
454
|
+
if not legal:
|
|
455
|
+
return hand[0]
|
|
456
|
+
|
|
457
|
+
# Start selection on the first legal card
|
|
458
|
+
sel = next((i for i, c in enumerate(hand) if c in legal), 0)
|
|
459
|
+
|
|
460
|
+
while True:
|
|
461
|
+
display(state, sel)
|
|
462
|
+
event = reader.read()
|
|
463
|
+
|
|
464
|
+
match event.key:
|
|
465
|
+
case Key.QUIT:
|
|
466
|
+
return None
|
|
467
|
+
case Key.LEFT | Key.UP:
|
|
468
|
+
new = sel - 1
|
|
469
|
+
while new >= 0 and hand[new] not in legal:
|
|
470
|
+
new -= 1
|
|
471
|
+
if new >= 0:
|
|
472
|
+
sel = new
|
|
473
|
+
case Key.RIGHT | Key.DOWN:
|
|
474
|
+
new = sel + 1
|
|
475
|
+
while new < len(hand) and hand[new] not in legal:
|
|
476
|
+
new += 1
|
|
477
|
+
if new < len(hand):
|
|
478
|
+
sel = new
|
|
479
|
+
case Key.ENTER:
|
|
480
|
+
if hand[sel] in legal:
|
|
481
|
+
return hand[sel]
|
|
482
|
+
# Fallback: return nearest legal card
|
|
483
|
+
for delta in range(1, len(hand)):
|
|
484
|
+
for d in (delta, -delta):
|
|
485
|
+
idx = sel + d
|
|
486
|
+
if 0 <= idx < len(hand) and hand[idx] in legal:
|
|
487
|
+
return hand[idx]
|
|
488
|
+
case Key.CHAR:
|
|
489
|
+
if event.char and event.char.isdigit():
|
|
490
|
+
idx = int(event.char) - 1
|
|
491
|
+
if 0 <= idx < len(hand) and hand[idx] in legal:
|
|
492
|
+
return hand[idx]
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def prompt_bid(state: GameState, reader: KeyReader) -> Suit | str | None:
|
|
496
|
+
"""Interactive bid selection. Returns 'QUIT' if QUIT is pressed."""
|
|
497
|
+
if state.bidding_round == 1:
|
|
498
|
+
# Round 1: Take (up_card suit) or Pass
|
|
499
|
+
options = [state.up_card.suit, None] # type: ignore[union-attr]
|
|
500
|
+
labels = [f"Take {state.up_card.suit.symbol}", "Pass"] # type: ignore[union-attr]
|
|
501
|
+
else:
|
|
502
|
+
# Round 2: Any suit except up_card suit, or Pass
|
|
503
|
+
all_suits = [Suit.SPADES, Suit.HEARTS, Suit.DIAMONDS, Suit.CLUBS]
|
|
504
|
+
other_suits = [s for s in all_suits if s != state.up_card.suit] # type: ignore[union-attr]
|
|
505
|
+
options = other_suits + [None]
|
|
506
|
+
labels = [s.symbol for s in other_suits] + ["Pass"]
|
|
507
|
+
|
|
508
|
+
sel = 0
|
|
509
|
+
|
|
510
|
+
while True:
|
|
511
|
+
display(state, None)
|
|
512
|
+
term_w, _ = shutil.get_terminal_size(fallback=(120, 40))
|
|
513
|
+
|
|
514
|
+
if state.bidding_round == 2:
|
|
515
|
+
# Nice boxed UI for round 2
|
|
516
|
+
inner_w = 40
|
|
517
|
+
box_lines = [
|
|
518
|
+
f"┌{'─' * inner_w}┐",
|
|
519
|
+
f"│{f'ROUND {state.bidding_round} BID'.center(inner_w)}│",
|
|
520
|
+
f"├{'─' * inner_w}┤",
|
|
521
|
+
f"│{' ' * inner_w}│",
|
|
522
|
+
]
|
|
523
|
+
|
|
524
|
+
# Options row
|
|
525
|
+
opt_str = ""
|
|
526
|
+
for i, opt in enumerate(options):
|
|
527
|
+
lbl = labels[i]
|
|
528
|
+
prefix = f"{BOLD}{gold_fg()}" if i == sel else ""
|
|
529
|
+
suffix = RESET
|
|
530
|
+
|
|
531
|
+
# Add color to suit symbols
|
|
532
|
+
color = ""
|
|
533
|
+
if isinstance(opt, Suit):
|
|
534
|
+
color = red_fg() if opt.is_red else black_fg()
|
|
535
|
+
|
|
536
|
+
entry = f"{prefix}({i+1}) {color}{lbl}{RESET}{prefix}"
|
|
537
|
+
if i == sel:
|
|
538
|
+
entry = f"{REVERSE} {entry} {RESET}"
|
|
539
|
+
else:
|
|
540
|
+
entry = f" {entry} "
|
|
541
|
+
opt_str += entry + " "
|
|
542
|
+
|
|
543
|
+
box_lines.append(f"│{ansi_center(opt_str.strip(), inner_w)}│")
|
|
544
|
+
box_lines.append(f"│{' ' * inner_w}│")
|
|
545
|
+
box_lines.append(f"└{'─' * inner_w}┘")
|
|
546
|
+
|
|
547
|
+
for bl in box_lines:
|
|
548
|
+
sys.stdout.write("\r\n" + ansi_center(bl, term_w))
|
|
549
|
+
sys.stdout.write("\r\n")
|
|
550
|
+
else:
|
|
551
|
+
# Round 1 simple prompt
|
|
552
|
+
parts = [
|
|
553
|
+
f"{BOLD}{gold_fg()}({i+1}){lbl}{RESET}" if i == sel else f"({i+1}){lbl}"
|
|
554
|
+
for i, lbl in enumerate(labels)
|
|
555
|
+
]
|
|
556
|
+
prompt = f"{BOLD}{white_fg()}Round {state.bidding_round} Bid: {' '.join(parts)}{RESET}"
|
|
557
|
+
sys.stdout.write("\r\n" + ansi_center(prompt, term_w) + "\r\n")
|
|
558
|
+
|
|
559
|
+
sys.stdout.flush()
|
|
560
|
+
|
|
561
|
+
event = reader.read()
|
|
562
|
+
match event.key:
|
|
563
|
+
case Key.QUIT:
|
|
564
|
+
return "QUIT"
|
|
565
|
+
case Key.LEFT | Key.UP:
|
|
566
|
+
sel = (sel - 1) % len(options)
|
|
567
|
+
case Key.RIGHT | Key.DOWN:
|
|
568
|
+
sel = (sel + 1) % len(options)
|
|
569
|
+
case Key.ENTER:
|
|
570
|
+
return options[sel]
|
|
571
|
+
case Key.CHAR:
|
|
572
|
+
if event.char:
|
|
573
|
+
if event.char.lower() == 'p':
|
|
574
|
+
return None
|
|
575
|
+
try:
|
|
576
|
+
idx = int(event.char) - 1
|
|
577
|
+
if 0 <= idx < len(options):
|
|
578
|
+
return options[idx]
|
|
579
|
+
except ValueError:
|
|
580
|
+
pass
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def show_rules(reader: KeyReader) -> None:
|
|
584
|
+
"""Display scrollable rules and history in EN/FR."""
|
|
585
|
+
from .rules import RULES_CONTENT
|
|
586
|
+
lang = "en"
|
|
587
|
+
scroll = 0
|
|
588
|
+
|
|
589
|
+
while True:
|
|
590
|
+
term_w, term_h = shutil.get_terminal_size(fallback=(120, 40))
|
|
591
|
+
content = RULES_CONTENT[lang]
|
|
592
|
+
|
|
593
|
+
# Build all lines first
|
|
594
|
+
all_lines = []
|
|
595
|
+
all_lines.append(f"{BOLD}{gold_fg()}{content['title']}{RESET}")
|
|
596
|
+
all_lines.append("=" * visible_len(content['title']))
|
|
597
|
+
all_lines.append("")
|
|
598
|
+
|
|
599
|
+
for section in content['sections']:
|
|
600
|
+
all_lines.append(f"{BOLD}{white_fg()}{section['header']}{RESET}")
|
|
601
|
+
all_lines.append("-" * len(section['header']))
|
|
602
|
+
# Wrap text manually
|
|
603
|
+
words = section['text'].split()
|
|
604
|
+
line = " "
|
|
605
|
+
for w in words:
|
|
606
|
+
if len(line) + len(w) > 70:
|
|
607
|
+
all_lines.append(line)
|
|
608
|
+
line = " " + w + " "
|
|
609
|
+
else:
|
|
610
|
+
line += w + " "
|
|
611
|
+
all_lines.append(line)
|
|
612
|
+
all_lines.append("")
|
|
613
|
+
|
|
614
|
+
all_lines.append(f"{DIM}Press [T] to Toggle Language ({lang.upper()}) | [Q/Enter] Back{RESET}")
|
|
615
|
+
|
|
616
|
+
# Window the lines
|
|
617
|
+
view_h = term_h - 4
|
|
618
|
+
scroll = max(0, min(scroll, len(all_lines) - view_h))
|
|
619
|
+
visible_lines = all_lines[scroll : scroll + view_h]
|
|
620
|
+
|
|
621
|
+
out = clear_screen() + hide_cursor()
|
|
622
|
+
rendered = "\r\n".join(ansi_center(line, term_w) for line in visible_lines)
|
|
623
|
+
sys.stdout.write(out + rendered)
|
|
624
|
+
sys.stdout.flush()
|
|
625
|
+
|
|
626
|
+
event = reader.read()
|
|
627
|
+
match event.key:
|
|
628
|
+
case Key.QUIT | Key.ENTER | Key.ESC:
|
|
629
|
+
return
|
|
630
|
+
case Key.UP:
|
|
631
|
+
scroll = max(0, scroll - 1)
|
|
632
|
+
case Key.DOWN:
|
|
633
|
+
scroll = min(len(all_lines) - view_h, scroll + 1)
|
|
634
|
+
case Key.CHAR:
|
|
635
|
+
if event.char and event.char.lower() == 't':
|
|
636
|
+
lang = "fr" if lang == "en" else "en"
|
|
637
|
+
scroll = 0
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def _render_main_menu_art(sel: int, options: list[str], frame: int) -> list[str]:
|
|
641
|
+
"""Render the full main menu art with cards logo and chalice container."""
|
|
642
|
+
f = frame % 4
|
|
643
|
+
# Steam frames
|
|
644
|
+
steams = [
|
|
645
|
+
(" ( ", " ) (", " ( "),
|
|
646
|
+
(" ) ", " ( )", " ) "),
|
|
647
|
+
(" ( ", " ) (", " ( "),
|
|
648
|
+
(" ( ", " ) (", " ( ")
|
|
649
|
+
]
|
|
650
|
+
st = steams[f]
|
|
651
|
+
|
|
652
|
+
g = gold_fg()
|
|
653
|
+
w = white_fg()
|
|
654
|
+
|
|
655
|
+
cards_art = [
|
|
656
|
+
f" {w}⢠⣴⣶⣶⣶⣄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀{RESET}",
|
|
657
|
+
f" {w}⣿⣿⣿⣿⣿⣿⣦⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀{RESET}",
|
|
658
|
+
f" {w}⢰⣿⣿⣿⣿⡿⠟⠁⣠⣴⣶⣦⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀{RESET}",
|
|
659
|
+
f" {w}⢸⣿⣿⠟⠉⣠⣴⣿⣿⣿⠟⠁⣠⣾⣿⣦⡀⠀⠀⠀⠀⠀⠀⠀{RESET}",
|
|
660
|
+
f" {w}⠉⣀⣴⣾⣿⣿⣿⠟⢁⣤⣾⣿⣿⣿⣿⣿⡆⠀⠀⠀⠀⠀⠀{RESET}",
|
|
661
|
+
f" {w}⢀⣤⣾⣿⣿⣿⡿⠛⢁⣴⣿⣿⣿⣿⣿⣿⣿⠟⠁⡀⠀⠀⠀⠀⠀{RESET}",
|
|
662
|
+
f" {w}⢼⣿⣿⣿⡿⠋⣀⣴⣿⣿⣿⣿⣿⣿⣿⡿⠉⣠⣾⣿⡆⠀⠀⠀⠀{RESET}",
|
|
663
|
+
f" {w}⠘⢿⡿⠋⣠⣾⣿⣿⣿⠟⠁⣿⣿⣿⣿⣿⠟⢁⣀⠀⠀⠀{RESET}",
|
|
664
|
+
f" {w}⣠⣾⣿⣿⣿⣿⣿⣿⣿⣿⠏⢀⣴⣿⣿⣿⠋⢠⣾⣿⣷⣦⡀{RESET}",
|
|
665
|
+
f" {w}⢻⣿⣿⣿⣿⣿⣿⣿⠟⢁⣴⣿⣿⣿⡿⠁⣰⣿⣿⣿⣿⣿⣿{RESET}",
|
|
666
|
+
f" {w}⠹⢿⣿⣿⣿⡿⠋⣠⣾⣿⣿⣿⠟⢀⣼⣿⣿⣿⣿⣿⣿⡟{RESET}",
|
|
667
|
+
f" {w}⠉⠉⠉⠀⢾⣿⣿⣿⣿⠋⠀⠚⠛⠛⠛⠛⠛⠛⠁⠀{RESET}",
|
|
668
|
+
]
|
|
669
|
+
|
|
670
|
+
# The Vessel / Chalice frame template
|
|
671
|
+
cup = [
|
|
672
|
+
f" {w}{st[0]}{RESET}",
|
|
673
|
+
f" {w}{st[1]}{RESET}",
|
|
674
|
+
f" {g}___...(-------)-....___{RESET}",
|
|
675
|
+
f" {g}.-'' ) ( ''-.{RESET}",
|
|
676
|
+
f" {g}.-'``'|-._ ) _.-|{RESET}",
|
|
677
|
+
f" {g}/ .--.| `''---...........---''` |{RESET}",
|
|
678
|
+
f" {g}/ / | [[ OPT 0 ]] |{RESET}",
|
|
679
|
+
f" {g}| | | [[ OPT 1 ]] |{RESET}",
|
|
680
|
+
f" {g}\\ \\ | [[ OPT 2 ]] |{RESET}",
|
|
681
|
+
f" {g}`\\ `\\ | [[ OPT 3 ]] |{RESET}",
|
|
682
|
+
f" {g}`\\ `| [[ OPT 4 ]] |{RESET}",
|
|
683
|
+
f" {g}_/ /\\ [[ OPT 5 ]] /{RESET}",
|
|
684
|
+
f" {g}(__/ \\ /{RESET}",
|
|
685
|
+
f" {g}_..---''` \\ /`''---.._{RESET}",
|
|
686
|
+
f" {g}.-' \\ / '-.{RESET}",
|
|
687
|
+
f" {g}: `-.__ __.-' :{RESET}",
|
|
688
|
+
f" {g}: ) ''---...---'' ( :{RESET}",
|
|
689
|
+
f" {g}'._ `''...___...--''` _.'{RESET}",
|
|
690
|
+
f" {g}jgs \\''--..__ __..--''/{RESET}",
|
|
691
|
+
f" {g}'._ '''----.....______.....----''' _.'{RESET}",
|
|
692
|
+
f" {g}`''--..,,_____ _____,,..--''`{RESET}",
|
|
693
|
+
f" {g}`'''----'''`{RESET}",
|
|
694
|
+
]
|
|
695
|
+
|
|
696
|
+
# Process placeholders
|
|
697
|
+
final_cup = []
|
|
698
|
+
for line in cup:
|
|
699
|
+
new_line = line
|
|
700
|
+
for i in range(6):
|
|
701
|
+
tag = f"[[ OPT {i} ]]"
|
|
702
|
+
if tag in line:
|
|
703
|
+
label = options[i] if i < len(options) else ""
|
|
704
|
+
if i == sel:
|
|
705
|
+
text = f"{REVERSE} > {label} < {RESET}"
|
|
706
|
+
else:
|
|
707
|
+
text = f" {label} "
|
|
708
|
+
# Center text in a fixed 29-character wide field
|
|
709
|
+
centered = ansi_center(text, 29)
|
|
710
|
+
new_line = line.replace(tag, centered)
|
|
711
|
+
final_cup.append(new_line)
|
|
712
|
+
|
|
713
|
+
return cards_art + [""] + final_cup
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def show_main_menu(reader: KeyReader, difficulty: str, target: int, speed: str) -> tuple[str, str, int, str]:
|
|
717
|
+
"""Display the main menu and return (choice, difficulty, target, speed)."""
|
|
718
|
+
curr_diff = difficulty
|
|
719
|
+
curr_target = target
|
|
720
|
+
curr_speed = speed
|
|
721
|
+
|
|
722
|
+
sel = 0
|
|
723
|
+
diffs = ["easy", "medium", "hard"]
|
|
724
|
+
targs = [500, 1000, 1500, 2000]
|
|
725
|
+
spds = ["slow", "normal", "fast", "instant"]
|
|
726
|
+
frame = 0
|
|
727
|
+
|
|
728
|
+
while True:
|
|
729
|
+
options_labels = [
|
|
730
|
+
"Start Game",
|
|
731
|
+
f"Difficulty: < {curr_diff.capitalize()} >",
|
|
732
|
+
f"Target Score: < {curr_target} >",
|
|
733
|
+
f"Speed: < {curr_speed.capitalize()} >",
|
|
734
|
+
"Rules & History",
|
|
735
|
+
"Quit"
|
|
736
|
+
]
|
|
737
|
+
|
|
738
|
+
term_w, term_h = shutil.get_terminal_size(fallback=(120, 40))
|
|
739
|
+
out = clear_screen() + hide_cursor()
|
|
740
|
+
|
|
741
|
+
# Build the art containing the menu
|
|
742
|
+
all_lines = _render_main_menu_art(sel, options_labels, frame)
|
|
743
|
+
|
|
744
|
+
# Center the entire block vertically and horizontally
|
|
745
|
+
v_pad = max(0, (term_h - len(all_lines) - 2) // 2)
|
|
746
|
+
h_pad = "" # Horizontal centering handled by ansi_center within _render
|
|
747
|
+
|
|
748
|
+
lines = [""] * v_pad
|
|
749
|
+
for line in all_lines:
|
|
750
|
+
lines.append(ansi_center(line, term_w))
|
|
751
|
+
|
|
752
|
+
lines.append("")
|
|
753
|
+
lines.append(ansi_center(f"{light_gray_fg()}↑/↓: Navigate ←/→: Change Settings Enter: Confirm Q: Quit{RESET}", term_w))
|
|
754
|
+
|
|
755
|
+
sys.stdout.write(out + "\r\n".join(lines))
|
|
756
|
+
sys.stdout.flush()
|
|
757
|
+
|
|
758
|
+
event = reader.read_timeout(0.3)
|
|
759
|
+
if event is None:
|
|
760
|
+
frame += 1
|
|
761
|
+
continue
|
|
762
|
+
|
|
763
|
+
match event.key:
|
|
764
|
+
case Key.QUIT:
|
|
765
|
+
return "Quit", curr_diff, curr_target, curr_speed
|
|
766
|
+
case Key.UP:
|
|
767
|
+
sel = (sel - 1) % len(options_labels)
|
|
768
|
+
case Key.DOWN:
|
|
769
|
+
sel = (sel + 1) % len(options_labels)
|
|
770
|
+
case Key.LEFT | Key.RIGHT:
|
|
771
|
+
delta = 1 if event.key == Key.RIGHT else -1
|
|
772
|
+
if sel == 1:
|
|
773
|
+
curr_diff = diffs[(diffs.index(curr_diff) + delta) % len(diffs)]
|
|
774
|
+
elif sel == 2:
|
|
775
|
+
curr_target = targs[(targs.index(curr_target) + delta) % len(targs)]
|
|
776
|
+
elif sel == 3:
|
|
777
|
+
curr_speed = spds[(spds.index(curr_speed) + delta) % len(spds)]
|
|
778
|
+
case Key.ENTER:
|
|
779
|
+
choice = ["Start Game", "Difficulty", "Target Score", "Speed", "Rules & History", "Quit"][sel]
|
|
780
|
+
if choice in ("Start Game", "Quit", "Rules & History"):
|
|
781
|
+
return choice, curr_diff, curr_target, curr_speed
|
|
782
|
+
# For settings, Enter can also toggle forward
|
|
783
|
+
if sel == 1:
|
|
784
|
+
curr_diff = diffs[(diffs.index(curr_diff) + 1) % len(diffs)]
|
|
785
|
+
elif sel == 2:
|
|
786
|
+
curr_target = targs[(targs.index(curr_target) + 1) % len(targs)]
|
|
787
|
+
elif sel == 3:
|
|
788
|
+
curr_speed = spds[(spds.index(curr_speed) + 1) % len(spds)]
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
def announce(message: str, duration: float = 2.0) -> None:
|
|
792
|
+
"""Display a transient announcement banner."""
|
|
793
|
+
import time
|
|
794
|
+
sys.stdout.write(f"\n{banner_bg()}{banner_fg()} {BOLD} {message} {RESET}\n")
|
|
795
|
+
sys.stdout.flush()
|
|
796
|
+
time.sleep(duration)
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
def show_final_screen(state: GameState) -> None:
|
|
800
|
+
"""Display the game-over screen."""
|
|
801
|
+
ns, ew = state.team_scores
|
|
802
|
+
if ns >= state.target and ew >= state.target:
|
|
803
|
+
winner = "NS" if ns > ew else "EW"
|
|
804
|
+
elif ns >= state.target:
|
|
805
|
+
winner = "NS"
|
|
806
|
+
else:
|
|
807
|
+
winner = "EW"
|
|
808
|
+
|
|
809
|
+
lines = [
|
|
810
|
+
"",
|
|
811
|
+
f"{BOLD}{gold_fg()}{'=' * 50}{RESET}",
|
|
812
|
+
f"{BOLD}{gold_fg()} GAME OVER{RESET}",
|
|
813
|
+
f"{BOLD}{gold_fg()}{'=' * 50}{RESET}",
|
|
814
|
+
"",
|
|
815
|
+
f" {white_fg()}Team NS: {ns} points{RESET}",
|
|
816
|
+
f" {white_fg()}Team EW: {ew} points{RESET}",
|
|
817
|
+
"",
|
|
818
|
+
f" {BOLD}{gold_fg()}Winner: Team {winner}!{RESET}",
|
|
819
|
+
"",
|
|
820
|
+
f" {light_gray_fg()}Press Enter to exit{RESET}",
|
|
821
|
+
]
|
|
822
|
+
|
|
823
|
+
sys.stdout.write(clear_screen())
|
|
824
|
+
sys.stdout.write("\n".join(lines))
|
|
825
|
+
sys.stdout.flush()
|