ankigammon 1.0.6__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.
- ankigammon/__init__.py +7 -0
- ankigammon/__main__.py +6 -0
- ankigammon/analysis/__init__.py +13 -0
- ankigammon/analysis/score_matrix.py +391 -0
- ankigammon/anki/__init__.py +6 -0
- ankigammon/anki/ankiconnect.py +216 -0
- ankigammon/anki/apkg_exporter.py +111 -0
- ankigammon/anki/card_generator.py +1325 -0
- ankigammon/anki/card_styles.py +1054 -0
- ankigammon/gui/__init__.py +8 -0
- ankigammon/gui/app.py +192 -0
- ankigammon/gui/dialogs/__init__.py +10 -0
- ankigammon/gui/dialogs/export_dialog.py +594 -0
- ankigammon/gui/dialogs/import_options_dialog.py +201 -0
- ankigammon/gui/dialogs/input_dialog.py +762 -0
- ankigammon/gui/dialogs/note_dialog.py +93 -0
- ankigammon/gui/dialogs/settings_dialog.py +420 -0
- ankigammon/gui/dialogs/update_dialog.py +373 -0
- ankigammon/gui/format_detector.py +377 -0
- ankigammon/gui/main_window.py +1611 -0
- ankigammon/gui/resources/down-arrow.svg +3 -0
- ankigammon/gui/resources/icon.icns +0 -0
- ankigammon/gui/resources/icon.ico +0 -0
- ankigammon/gui/resources/icon.png +0 -0
- ankigammon/gui/resources/style.qss +402 -0
- ankigammon/gui/resources.py +26 -0
- ankigammon/gui/update_checker.py +259 -0
- ankigammon/gui/widgets/__init__.py +8 -0
- ankigammon/gui/widgets/position_list.py +166 -0
- ankigammon/gui/widgets/smart_input.py +268 -0
- ankigammon/models.py +356 -0
- ankigammon/parsers/__init__.py +7 -0
- ankigammon/parsers/gnubg_match_parser.py +1094 -0
- ankigammon/parsers/gnubg_parser.py +468 -0
- ankigammon/parsers/sgf_parser.py +290 -0
- ankigammon/parsers/xg_binary_parser.py +1097 -0
- ankigammon/parsers/xg_text_parser.py +688 -0
- ankigammon/renderer/__init__.py +5 -0
- ankigammon/renderer/animation_controller.py +391 -0
- ankigammon/renderer/animation_helper.py +191 -0
- ankigammon/renderer/color_schemes.py +145 -0
- ankigammon/renderer/svg_board_renderer.py +791 -0
- ankigammon/settings.py +315 -0
- ankigammon/thirdparty/__init__.py +7 -0
- ankigammon/thirdparty/xgdatatools/__init__.py +17 -0
- ankigammon/thirdparty/xgdatatools/xgimport.py +160 -0
- ankigammon/thirdparty/xgdatatools/xgstruct.py +1032 -0
- ankigammon/thirdparty/xgdatatools/xgutils.py +118 -0
- ankigammon/thirdparty/xgdatatools/xgzarc.py +260 -0
- ankigammon/utils/__init__.py +13 -0
- ankigammon/utils/gnubg_analyzer.py +590 -0
- ankigammon/utils/gnuid.py +577 -0
- ankigammon/utils/move_parser.py +204 -0
- ankigammon/utils/ogid.py +326 -0
- ankigammon/utils/xgid.py +387 -0
- ankigammon-1.0.6.dist-info/METADATA +352 -0
- ankigammon-1.0.6.dist-info/RECORD +61 -0
- ankigammon-1.0.6.dist-info/WHEEL +5 -0
- ankigammon-1.0.6.dist-info/entry_points.txt +2 -0
- ankigammon-1.0.6.dist-info/licenses/LICENSE +21 -0
- ankigammon-1.0.6.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,791 @@
|
|
|
1
|
+
"""SVG-based backgammon board renderer for animated cards."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional, Tuple, List, Dict
|
|
4
|
+
import json
|
|
5
|
+
|
|
6
|
+
from ankigammon.models import Position, Player, CubeState, Move
|
|
7
|
+
from ankigammon.renderer.color_schemes import ColorScheme, CLASSIC
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SVGBoardRenderer:
|
|
11
|
+
"""Renders backgammon positions as SVG markup."""
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
width: int = 880,
|
|
16
|
+
height: int = 600,
|
|
17
|
+
point_height_ratio: float = 0.45,
|
|
18
|
+
color_scheme: ColorScheme = CLASSIC,
|
|
19
|
+
orientation: str = "counter-clockwise",
|
|
20
|
+
):
|
|
21
|
+
"""
|
|
22
|
+
Initialize the SVG board renderer.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
width: SVG viewBox width
|
|
26
|
+
height: SVG viewBox height
|
|
27
|
+
point_height_ratio: Height of points as ratio of board height
|
|
28
|
+
color_scheme: ColorScheme object defining board colors
|
|
29
|
+
orientation: Board orientation ("clockwise" or "counter-clockwise")
|
|
30
|
+
"""
|
|
31
|
+
self.width = width
|
|
32
|
+
self.height = height
|
|
33
|
+
self.point_height_ratio = point_height_ratio
|
|
34
|
+
self.color_scheme = color_scheme
|
|
35
|
+
self.orientation = orientation
|
|
36
|
+
|
|
37
|
+
# Calculate board dimensions
|
|
38
|
+
self.internal_width = 900
|
|
39
|
+
self.margin = 20
|
|
40
|
+
self.cube_area_width = 70
|
|
41
|
+
self.bearoff_area_width = 100
|
|
42
|
+
|
|
43
|
+
self.playing_width = self.internal_width - 2 * self.margin - self.cube_area_width - self.bearoff_area_width
|
|
44
|
+
self.board_height = self.height - 2 * self.margin
|
|
45
|
+
|
|
46
|
+
self.bar_width = self.playing_width * 0.08
|
|
47
|
+
self.half_width = (self.playing_width - self.bar_width) / 2
|
|
48
|
+
self.point_width = self.half_width / 6
|
|
49
|
+
self.point_height = self.board_height * point_height_ratio
|
|
50
|
+
|
|
51
|
+
self.checker_radius = min(self.point_width * 0.45, 25)
|
|
52
|
+
|
|
53
|
+
def render_svg(
|
|
54
|
+
self,
|
|
55
|
+
position: Position,
|
|
56
|
+
on_roll: Player = Player.O,
|
|
57
|
+
dice: Optional[Tuple[int, int]] = None,
|
|
58
|
+
dice_opacity: float = 1.0,
|
|
59
|
+
cube_value: int = 1,
|
|
60
|
+
cube_owner: CubeState = CubeState.CENTERED,
|
|
61
|
+
move_data: Optional[Dict] = None,
|
|
62
|
+
score_x: int = 0,
|
|
63
|
+
score_o: int = 0,
|
|
64
|
+
match_length: int = 0,
|
|
65
|
+
) -> str:
|
|
66
|
+
"""
|
|
67
|
+
Render a backgammon position as SVG.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
position: The position to render
|
|
71
|
+
on_roll: Which player is on roll
|
|
72
|
+
dice: Dice values (if any)
|
|
73
|
+
dice_opacity: Opacity for dice (0.0-1.0)
|
|
74
|
+
cube_value: Doubling cube value
|
|
75
|
+
cube_owner: Who owns the cube
|
|
76
|
+
move_data: Optional move animation data (for animated cards)
|
|
77
|
+
score_x: X player's current score
|
|
78
|
+
score_o: O player's current score
|
|
79
|
+
match_length: Match length (0 = money game, > 0 = match play)
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
SVG markup string
|
|
83
|
+
"""
|
|
84
|
+
svg_parts = []
|
|
85
|
+
|
|
86
|
+
# Start SVG with viewBox
|
|
87
|
+
svg_parts.append(f'<svg viewBox="0 0 {self.width} {self.height}" '
|
|
88
|
+
f'xmlns="http://www.w3.org/2000/svg" '
|
|
89
|
+
f'class="backgammon-board">')
|
|
90
|
+
|
|
91
|
+
# Add styles
|
|
92
|
+
svg_parts.append(self._generate_styles())
|
|
93
|
+
|
|
94
|
+
# Board coordinates
|
|
95
|
+
board_x = self.margin + self.cube_area_width
|
|
96
|
+
board_y = self.margin
|
|
97
|
+
|
|
98
|
+
# Draw full background (covers entire SVG viewBox)
|
|
99
|
+
svg_parts.append(self._draw_full_background())
|
|
100
|
+
|
|
101
|
+
# Draw board background
|
|
102
|
+
svg_parts.append(self._draw_board_background(board_x, board_y))
|
|
103
|
+
|
|
104
|
+
# Draw bar
|
|
105
|
+
svg_parts.append(self._draw_bar(board_x, board_y))
|
|
106
|
+
|
|
107
|
+
# Draw points
|
|
108
|
+
svg_parts.append(self._draw_points(board_x, board_y))
|
|
109
|
+
|
|
110
|
+
# Draw checkers
|
|
111
|
+
flipped = False
|
|
112
|
+
svg_parts.append(self._draw_checkers(position, board_x, board_y, flipped, move_data))
|
|
113
|
+
|
|
114
|
+
# Draw bear-off trays
|
|
115
|
+
svg_parts.append(self._draw_bearoff(position, board_x, board_y, flipped))
|
|
116
|
+
|
|
117
|
+
# Draw dice
|
|
118
|
+
if dice:
|
|
119
|
+
svg_parts.append(self._draw_dice(dice, on_roll, board_x, board_y, dice_opacity))
|
|
120
|
+
|
|
121
|
+
# Draw cube
|
|
122
|
+
svg_parts.append(self._draw_cube(cube_value, cube_owner, board_x, board_y, flipped))
|
|
123
|
+
|
|
124
|
+
# Draw pip counts
|
|
125
|
+
svg_parts.append(self._draw_pip_counts(position, board_x, board_y, flipped))
|
|
126
|
+
|
|
127
|
+
# Draw scores (for match play only)
|
|
128
|
+
if match_length > 0:
|
|
129
|
+
svg_parts.append(self._draw_scores(score_x, score_o, match_length, board_x, board_y, flipped))
|
|
130
|
+
|
|
131
|
+
# Close SVG
|
|
132
|
+
svg_parts.append('</svg>')
|
|
133
|
+
|
|
134
|
+
return ''.join(svg_parts)
|
|
135
|
+
|
|
136
|
+
def _generate_styles(self) -> str:
|
|
137
|
+
"""Generate CSS styles for the SVG."""
|
|
138
|
+
return f"""
|
|
139
|
+
<defs>
|
|
140
|
+
<style>
|
|
141
|
+
.backgammon-board {{
|
|
142
|
+
max-width: 100%;
|
|
143
|
+
height: auto;
|
|
144
|
+
}}
|
|
145
|
+
.point {{
|
|
146
|
+
stroke: {self.color_scheme.board_dark};
|
|
147
|
+
stroke-width: 1;
|
|
148
|
+
}}
|
|
149
|
+
.checker {{
|
|
150
|
+
stroke: {self.color_scheme.checker_border};
|
|
151
|
+
stroke-width: 2;
|
|
152
|
+
}}
|
|
153
|
+
.checker-x {{
|
|
154
|
+
fill: {self.color_scheme.checker_x};
|
|
155
|
+
}}
|
|
156
|
+
.checker-o {{
|
|
157
|
+
fill: {self.color_scheme.checker_o};
|
|
158
|
+
}}
|
|
159
|
+
.checker-text {{
|
|
160
|
+
font-family: Arial, sans-serif;
|
|
161
|
+
font-weight: bold;
|
|
162
|
+
text-anchor: middle;
|
|
163
|
+
dominant-baseline: middle;
|
|
164
|
+
pointer-events: none;
|
|
165
|
+
}}
|
|
166
|
+
.point-label {{
|
|
167
|
+
font-family: Arial, sans-serif;
|
|
168
|
+
font-size: 10px;
|
|
169
|
+
fill: {self.color_scheme.text};
|
|
170
|
+
text-anchor: middle;
|
|
171
|
+
}}
|
|
172
|
+
.pip-count {{
|
|
173
|
+
font-family: Arial, sans-serif;
|
|
174
|
+
font-size: 12px;
|
|
175
|
+
fill: {self.color_scheme.text};
|
|
176
|
+
}}
|
|
177
|
+
.die {{
|
|
178
|
+
fill: {self.color_scheme.dice_color};
|
|
179
|
+
stroke: #000000;
|
|
180
|
+
stroke-width: 2;
|
|
181
|
+
}}
|
|
182
|
+
.die-pip {{
|
|
183
|
+
fill: #000000;
|
|
184
|
+
}}
|
|
185
|
+
.cube {{
|
|
186
|
+
fill: #FFD700;
|
|
187
|
+
stroke: #000000;
|
|
188
|
+
stroke-width: 2;
|
|
189
|
+
}}
|
|
190
|
+
.cube-text {{
|
|
191
|
+
font-family: Arial, sans-serif;
|
|
192
|
+
font-size: 32px;
|
|
193
|
+
font-weight: bold;
|
|
194
|
+
fill: #000000;
|
|
195
|
+
text-anchor: middle;
|
|
196
|
+
dominant-baseline: middle;
|
|
197
|
+
}}
|
|
198
|
+
/* Animation support */
|
|
199
|
+
.checker-animated {{
|
|
200
|
+
transition: transform 0.8s ease-in-out;
|
|
201
|
+
}}
|
|
202
|
+
</style>
|
|
203
|
+
</defs>
|
|
204
|
+
"""
|
|
205
|
+
|
|
206
|
+
def _draw_full_background(self) -> str:
|
|
207
|
+
"""Draw the full SVG background."""
|
|
208
|
+
return f'''
|
|
209
|
+
<rect x="0" y="0" width="{self.width}" height="{self.height}"
|
|
210
|
+
fill="{self.color_scheme.board_light}"/>
|
|
211
|
+
'''
|
|
212
|
+
|
|
213
|
+
def _draw_board_background(self, board_x: float, board_y: float) -> str:
|
|
214
|
+
"""Draw the board background rectangle."""
|
|
215
|
+
return f'''
|
|
216
|
+
<rect x="{board_x}" y="{board_y}"
|
|
217
|
+
width="{self.playing_width}" height="{self.board_height}"
|
|
218
|
+
fill="{self.color_scheme.board_light}"
|
|
219
|
+
stroke="{self.color_scheme.board_dark}"
|
|
220
|
+
stroke-width="3"/>
|
|
221
|
+
'''
|
|
222
|
+
|
|
223
|
+
def _draw_bar(self, board_x: float, board_y: float) -> str:
|
|
224
|
+
"""Draw the center bar."""
|
|
225
|
+
bar_x = board_x + self.half_width
|
|
226
|
+
return f'''
|
|
227
|
+
<rect x="{bar_x}" y="{board_y}"
|
|
228
|
+
width="{self.bar_width}" height="{self.board_height}"
|
|
229
|
+
fill="{self.color_scheme.bar}"
|
|
230
|
+
stroke="{self.color_scheme.board_dark}"
|
|
231
|
+
stroke-width="2"/>
|
|
232
|
+
'''
|
|
233
|
+
|
|
234
|
+
def _get_visual_point_index(self, point_num: int) -> int:
|
|
235
|
+
"""
|
|
236
|
+
Map point number to visual position index based on orientation.
|
|
237
|
+
|
|
238
|
+
Counter-clockwise:
|
|
239
|
+
Top: 13-18 (left), 19-24 (right)
|
|
240
|
+
Bottom: 12-7 (left), 6-1 (right)
|
|
241
|
+
|
|
242
|
+
Clockwise (horizontally mirrored):
|
|
243
|
+
Top: 24-19 (left), 18-13 (right)
|
|
244
|
+
Bottom: 1-6 (left), 7-12 (right)
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
point_num: Point number (1-24)
|
|
248
|
+
|
|
249
|
+
Returns:
|
|
250
|
+
Visual index for rendering (0-23)
|
|
251
|
+
"""
|
|
252
|
+
if self.orientation == "clockwise":
|
|
253
|
+
# Horizontal mirror transformation
|
|
254
|
+
if point_num <= 12:
|
|
255
|
+
return 12 - point_num
|
|
256
|
+
else:
|
|
257
|
+
return 36 - point_num
|
|
258
|
+
else:
|
|
259
|
+
# Standard counter-clockwise layout
|
|
260
|
+
return point_num - 1
|
|
261
|
+
|
|
262
|
+
def _draw_points(self, board_x: float, board_y: float) -> str:
|
|
263
|
+
"""Draw the triangular points with numbers."""
|
|
264
|
+
svg_parts = ['<g class="points">']
|
|
265
|
+
|
|
266
|
+
for point_num in range(1, 25):
|
|
267
|
+
visual_idx = self._get_visual_point_index(point_num)
|
|
268
|
+
|
|
269
|
+
# Calculate point position based on visual index
|
|
270
|
+
if visual_idx < 6:
|
|
271
|
+
# Bottom right quadrant (visual positions 0-5)
|
|
272
|
+
x = board_x + self.half_width + self.bar_width + (5 - visual_idx) * self.point_width
|
|
273
|
+
y_base = board_y + self.board_height
|
|
274
|
+
y_tip = y_base - self.point_height
|
|
275
|
+
color = self.color_scheme.point_dark if point_num % 2 == 1 else self.color_scheme.point_light
|
|
276
|
+
label_y = y_base + 13
|
|
277
|
+
elif visual_idx < 12:
|
|
278
|
+
# Bottom left quadrant (visual positions 6-11)
|
|
279
|
+
x = board_x + (11 - visual_idx) * self.point_width
|
|
280
|
+
y_base = board_y + self.board_height
|
|
281
|
+
y_tip = y_base - self.point_height
|
|
282
|
+
color = self.color_scheme.point_dark if point_num % 2 == 1 else self.color_scheme.point_light
|
|
283
|
+
label_y = y_base + 13
|
|
284
|
+
elif visual_idx < 18:
|
|
285
|
+
# Top left quadrant (visual positions 12-17)
|
|
286
|
+
x = board_x + (visual_idx - 12) * self.point_width
|
|
287
|
+
y_base = board_y
|
|
288
|
+
y_tip = y_base + self.point_height
|
|
289
|
+
color = self.color_scheme.point_dark if point_num % 2 == 1 else self.color_scheme.point_light
|
|
290
|
+
label_y = y_base - 5
|
|
291
|
+
else:
|
|
292
|
+
# Top right quadrant (visual positions 18-23)
|
|
293
|
+
x = board_x + self.half_width + self.bar_width + (visual_idx - 18) * self.point_width
|
|
294
|
+
y_base = board_y
|
|
295
|
+
y_tip = y_base + self.point_height
|
|
296
|
+
color = self.color_scheme.point_dark if point_num % 2 == 1 else self.color_scheme.point_light
|
|
297
|
+
label_y = y_base - 5
|
|
298
|
+
|
|
299
|
+
# Draw triangle
|
|
300
|
+
x_mid = x + self.point_width / 2
|
|
301
|
+
svg_parts.append(f'''
|
|
302
|
+
<polygon class="point" points="{x},{y_base} {x + self.point_width},{y_base} {x_mid},{y_tip}"
|
|
303
|
+
fill="{color}"/>
|
|
304
|
+
''')
|
|
305
|
+
|
|
306
|
+
# Draw point number
|
|
307
|
+
svg_parts.append(f'''
|
|
308
|
+
<text class="point-label" x="{x_mid}" y="{label_y}">{point_num}</text>
|
|
309
|
+
''')
|
|
310
|
+
|
|
311
|
+
svg_parts.append('</g>')
|
|
312
|
+
return ''.join(svg_parts)
|
|
313
|
+
|
|
314
|
+
def _draw_checkers(
|
|
315
|
+
self,
|
|
316
|
+
position: Position,
|
|
317
|
+
board_x: float,
|
|
318
|
+
board_y: float,
|
|
319
|
+
flipped: bool,
|
|
320
|
+
move_data: Optional[Dict] = None
|
|
321
|
+
) -> str:
|
|
322
|
+
"""Draw checkers on the board with optional animation data."""
|
|
323
|
+
svg_parts = ['<g class="checkers">']
|
|
324
|
+
|
|
325
|
+
for point_idx in range(1, 25):
|
|
326
|
+
count = position.points[point_idx]
|
|
327
|
+
if count == 0:
|
|
328
|
+
continue
|
|
329
|
+
|
|
330
|
+
player = Player.X if count > 0 else Player.O
|
|
331
|
+
num_checkers = abs(count)
|
|
332
|
+
|
|
333
|
+
x, y_base, is_top = self._get_point_position(point_idx, board_x, board_y)
|
|
334
|
+
|
|
335
|
+
for checker_num in range(min(num_checkers, 5)):
|
|
336
|
+
if is_top:
|
|
337
|
+
y = y_base + self.checker_radius + checker_num * (self.checker_radius * 2 + 2)
|
|
338
|
+
else:
|
|
339
|
+
y = y_base - self.checker_radius - checker_num * (self.checker_radius * 2 + 2)
|
|
340
|
+
|
|
341
|
+
cx = x + self.point_width / 2
|
|
342
|
+
|
|
343
|
+
checker_attrs = f'data-point="{point_idx}" data-checker-index="{checker_num}"'
|
|
344
|
+
if move_data:
|
|
345
|
+
checker_attrs += f' data-move-info=\'{json.dumps(move_data)}\''
|
|
346
|
+
|
|
347
|
+
svg_parts.append(
|
|
348
|
+
self._draw_checker(cx, y, player, checker_attrs)
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
# If more than 5 checkers, draw a number on the last one
|
|
352
|
+
if num_checkers > 5:
|
|
353
|
+
if is_top:
|
|
354
|
+
y = y_base + self.checker_radius + 4 * (self.checker_radius * 2 + 2)
|
|
355
|
+
else:
|
|
356
|
+
y = y_base - self.checker_radius - 4 * (self.checker_radius * 2 + 2)
|
|
357
|
+
|
|
358
|
+
cx = x + self.point_width / 2
|
|
359
|
+
checker_attrs = f'data-point="{point_idx}" data-checker-index="4"'
|
|
360
|
+
svg_parts.append(
|
|
361
|
+
self._draw_checker_with_number(cx, y, player, num_checkers, checker_attrs)
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
# Draw bar checkers
|
|
365
|
+
svg_parts.append(self._draw_bar_checkers(position, board_x, board_y, flipped))
|
|
366
|
+
|
|
367
|
+
svg_parts.append('</g>')
|
|
368
|
+
return ''.join(svg_parts)
|
|
369
|
+
|
|
370
|
+
def _draw_checker(self, cx: float, cy: float, player: Player, extra_attrs: str = "") -> str:
|
|
371
|
+
"""Draw a single checker."""
|
|
372
|
+
player_class = "checker-x" if player == Player.X else "checker-o"
|
|
373
|
+
return f'''
|
|
374
|
+
<circle class="checker {player_class}" cx="{cx}" cy="{cy}" r="{self.checker_radius}" {extra_attrs}/>
|
|
375
|
+
'''
|
|
376
|
+
|
|
377
|
+
def _draw_checker_with_number(self, cx: float, cy: float, player: Player, number: int, extra_attrs: str = "") -> str:
|
|
378
|
+
"""Draw a checker with a number on it."""
|
|
379
|
+
player_class = "checker-x" if player == Player.X else "checker-o"
|
|
380
|
+
text_color = (self.color_scheme.checker_o if player == Player.X
|
|
381
|
+
else self.color_scheme.checker_x)
|
|
382
|
+
|
|
383
|
+
return f'''
|
|
384
|
+
<circle class="checker {player_class}" cx="{cx}" cy="{cy}" r="{self.checker_radius}" {extra_attrs}/>
|
|
385
|
+
<text class="checker-text" x="{cx}" y="{cy}"
|
|
386
|
+
font-size="{self.checker_radius * 1.2}" fill="{text_color}">{number}</text>
|
|
387
|
+
'''
|
|
388
|
+
|
|
389
|
+
def _draw_bar_checkers(
|
|
390
|
+
self,
|
|
391
|
+
position: Position,
|
|
392
|
+
board_x: float,
|
|
393
|
+
board_y: float,
|
|
394
|
+
flipped: bool
|
|
395
|
+
) -> str:
|
|
396
|
+
"""Draw checkers on the bar."""
|
|
397
|
+
svg_parts = []
|
|
398
|
+
bar_x = board_x + self.half_width
|
|
399
|
+
bar_center_x = bar_x + self.bar_width / 2
|
|
400
|
+
|
|
401
|
+
x_bar_count = max(position.points[0], 0)
|
|
402
|
+
o_bar_count = max(-position.points[25], 0)
|
|
403
|
+
|
|
404
|
+
if not flipped:
|
|
405
|
+
svg_parts.append(
|
|
406
|
+
self._draw_bar_stack(bar_center_x, x_bar_count, Player.X, True, board_y)
|
|
407
|
+
)
|
|
408
|
+
svg_parts.append(
|
|
409
|
+
self._draw_bar_stack(bar_center_x, o_bar_count, Player.O, False, board_y)
|
|
410
|
+
)
|
|
411
|
+
else:
|
|
412
|
+
svg_parts.append(
|
|
413
|
+
self._draw_bar_stack(bar_center_x, x_bar_count, Player.X, False, board_y)
|
|
414
|
+
)
|
|
415
|
+
svg_parts.append(
|
|
416
|
+
self._draw_bar_stack(bar_center_x, o_bar_count, Player.O, True, board_y)
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
return ''.join(svg_parts)
|
|
420
|
+
|
|
421
|
+
def _draw_bar_stack(
|
|
422
|
+
self,
|
|
423
|
+
center_x: float,
|
|
424
|
+
count: int,
|
|
425
|
+
player: Player,
|
|
426
|
+
top: bool,
|
|
427
|
+
board_y: float
|
|
428
|
+
) -> str:
|
|
429
|
+
"""Draw stacked checkers on the bar for a single player."""
|
|
430
|
+
if count <= 0:
|
|
431
|
+
return ""
|
|
432
|
+
|
|
433
|
+
svg_parts = []
|
|
434
|
+
max_visible = min(count, 3)
|
|
435
|
+
|
|
436
|
+
bar_point = 0 if player == Player.X else 25
|
|
437
|
+
|
|
438
|
+
# Calculate starting position from center with spacing between players
|
|
439
|
+
board_center_y = board_y + self.board_height / 2
|
|
440
|
+
separation_offset = self.checker_radius * 2 + 10
|
|
441
|
+
|
|
442
|
+
for i in range(max_visible):
|
|
443
|
+
if top:
|
|
444
|
+
y = board_center_y + separation_offset + i * (self.checker_radius * 2 + 2)
|
|
445
|
+
else:
|
|
446
|
+
y = board_center_y - separation_offset - i * (self.checker_radius * 2 + 2)
|
|
447
|
+
|
|
448
|
+
checker_attrs = f'data-point="{bar_point}" data-checker-index="{i}"'
|
|
449
|
+
|
|
450
|
+
if i == max_visible - 1 and count > max_visible:
|
|
451
|
+
svg_parts.append(
|
|
452
|
+
self._draw_checker_with_number(center_x, y, player, count, checker_attrs)
|
|
453
|
+
)
|
|
454
|
+
else:
|
|
455
|
+
svg_parts.append(
|
|
456
|
+
self._draw_checker(center_x, y, player, checker_attrs)
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
return ''.join(svg_parts)
|
|
460
|
+
|
|
461
|
+
def _draw_bearoff(
|
|
462
|
+
self,
|
|
463
|
+
position: Position,
|
|
464
|
+
board_x: float,
|
|
465
|
+
board_y: float,
|
|
466
|
+
flipped: bool
|
|
467
|
+
) -> str:
|
|
468
|
+
"""Draw bear-off trays with stacked checker representations."""
|
|
469
|
+
svg_parts = ['<g class="bearoff">']
|
|
470
|
+
|
|
471
|
+
bearoff_x = board_x + self.playing_width + 10
|
|
472
|
+
bearoff_width = self.bearoff_area_width - 20
|
|
473
|
+
|
|
474
|
+
checker_width = 10
|
|
475
|
+
checker_height = 50
|
|
476
|
+
checker_spacing_x = 3
|
|
477
|
+
checker_spacing_y = 4
|
|
478
|
+
checkers_per_row = 5
|
|
479
|
+
|
|
480
|
+
top_player = Player.X if not flipped else Player.O
|
|
481
|
+
bottom_player = Player.O if not flipped else Player.X
|
|
482
|
+
|
|
483
|
+
def get_off_count(player: Player) -> int:
|
|
484
|
+
if player == Player.X:
|
|
485
|
+
return max(position.x_off, 0)
|
|
486
|
+
return max(position.o_off, 0)
|
|
487
|
+
|
|
488
|
+
def get_color(player: Player) -> str:
|
|
489
|
+
return self.color_scheme.checker_x if player == Player.X else self.color_scheme.checker_o
|
|
490
|
+
|
|
491
|
+
# Top tray
|
|
492
|
+
tray_top = board_y + 10
|
|
493
|
+
tray_bottom = board_y + self.board_height / 2 - 70
|
|
494
|
+
|
|
495
|
+
svg_parts.append(f'''
|
|
496
|
+
<rect x="{bearoff_x}" y="{tray_top}"
|
|
497
|
+
width="{bearoff_width}" height="{tray_bottom - tray_top}"
|
|
498
|
+
fill="{self.color_scheme.bearoff}"
|
|
499
|
+
stroke="{self.color_scheme.board_dark}"
|
|
500
|
+
stroke-width="2"/>
|
|
501
|
+
''')
|
|
502
|
+
|
|
503
|
+
top_count = get_off_count(top_player)
|
|
504
|
+
if top_count > 0:
|
|
505
|
+
row_width = checkers_per_row * checker_width + (checkers_per_row - 1) * checker_spacing_x
|
|
506
|
+
start_x = bearoff_x + (bearoff_width - row_width) / 2
|
|
507
|
+
start_y = tray_bottom - 10 - checker_height
|
|
508
|
+
|
|
509
|
+
for i in range(top_count):
|
|
510
|
+
row = i // checkers_per_row
|
|
511
|
+
col = i % checkers_per_row
|
|
512
|
+
x = start_x + col * (checker_width + checker_spacing_x)
|
|
513
|
+
y = start_y - row * (checker_height + checker_spacing_y)
|
|
514
|
+
|
|
515
|
+
svg_parts.append(f'''
|
|
516
|
+
<rect x="{x}" y="{y}"
|
|
517
|
+
width="{checker_width}" height="{checker_height}"
|
|
518
|
+
fill="{get_color(top_player)}"
|
|
519
|
+
stroke="{self.color_scheme.checker_border}"
|
|
520
|
+
stroke-width="1"/>
|
|
521
|
+
''')
|
|
522
|
+
|
|
523
|
+
# Bottom tray
|
|
524
|
+
tray_top = board_y + self.board_height / 2 + 70
|
|
525
|
+
tray_bottom = board_y + self.board_height - 10
|
|
526
|
+
|
|
527
|
+
svg_parts.append(f'''
|
|
528
|
+
<rect x="{bearoff_x}" y="{tray_top}"
|
|
529
|
+
width="{bearoff_width}" height="{tray_bottom - tray_top}"
|
|
530
|
+
fill="{self.color_scheme.bearoff}"
|
|
531
|
+
stroke="{self.color_scheme.board_dark}"
|
|
532
|
+
stroke-width="2"/>
|
|
533
|
+
''')
|
|
534
|
+
|
|
535
|
+
bottom_count = get_off_count(bottom_player)
|
|
536
|
+
if bottom_count > 0:
|
|
537
|
+
row_width = checkers_per_row * checker_width + (checkers_per_row - 1) * checker_spacing_x
|
|
538
|
+
start_x = bearoff_x + (bearoff_width - row_width) / 2
|
|
539
|
+
start_y = tray_bottom - 10 - checker_height
|
|
540
|
+
|
|
541
|
+
for i in range(bottom_count):
|
|
542
|
+
row = i // checkers_per_row
|
|
543
|
+
col = i % checkers_per_row
|
|
544
|
+
x = start_x + col * (checker_width + checker_spacing_x)
|
|
545
|
+
y = start_y - row * (checker_height + checker_spacing_y)
|
|
546
|
+
|
|
547
|
+
svg_parts.append(f'''
|
|
548
|
+
<rect x="{x}" y="{y}"
|
|
549
|
+
width="{checker_width}" height="{checker_height}"
|
|
550
|
+
fill="{get_color(bottom_player)}"
|
|
551
|
+
stroke="{self.color_scheme.checker_border}"
|
|
552
|
+
stroke-width="1"/>
|
|
553
|
+
''')
|
|
554
|
+
|
|
555
|
+
svg_parts.append('</g>')
|
|
556
|
+
return ''.join(svg_parts)
|
|
557
|
+
|
|
558
|
+
def _draw_dice(
|
|
559
|
+
self,
|
|
560
|
+
dice: Tuple[int, int],
|
|
561
|
+
on_roll: Player,
|
|
562
|
+
board_x: float,
|
|
563
|
+
board_y: float,
|
|
564
|
+
opacity: float = 1.0
|
|
565
|
+
) -> str:
|
|
566
|
+
"""Draw dice with optional transparency."""
|
|
567
|
+
svg_parts = ['<g class="dice"']
|
|
568
|
+
if opacity < 1.0:
|
|
569
|
+
svg_parts.append(f' opacity="{opacity}"')
|
|
570
|
+
svg_parts.append('>')
|
|
571
|
+
|
|
572
|
+
die_size = 50
|
|
573
|
+
die_spacing = 15
|
|
574
|
+
|
|
575
|
+
total_dice_width = 2 * die_size + die_spacing
|
|
576
|
+
right_half_start = board_x + self.half_width + self.bar_width
|
|
577
|
+
die_x = right_half_start + (self.half_width - total_dice_width) / 2
|
|
578
|
+
die_y = board_y + (self.board_height - die_size) / 2
|
|
579
|
+
|
|
580
|
+
svg_parts.append(self._draw_die(die_x, die_y, die_size, dice[0]))
|
|
581
|
+
svg_parts.append(self._draw_die(die_x + die_size + die_spacing, die_y, die_size, dice[1]))
|
|
582
|
+
|
|
583
|
+
svg_parts.append('</g>')
|
|
584
|
+
return ''.join(svg_parts)
|
|
585
|
+
|
|
586
|
+
def _draw_die(self, x: float, y: float, size: float, value: int) -> str:
|
|
587
|
+
"""Draw a single die."""
|
|
588
|
+
svg_parts = [f'''
|
|
589
|
+
<rect class="die" x="{x}" y="{y}" width="{size}" height="{size}" rx="5"/>
|
|
590
|
+
''']
|
|
591
|
+
|
|
592
|
+
pip_radius = size / 10
|
|
593
|
+
center = size / 2
|
|
594
|
+
|
|
595
|
+
pip_positions = {
|
|
596
|
+
1: [(center, center)],
|
|
597
|
+
2: [(size / 4, size / 4), (3 * size / 4, 3 * size / 4)],
|
|
598
|
+
3: [(size / 4, size / 4), (center, center), (3 * size / 4, 3 * size / 4)],
|
|
599
|
+
4: [(size / 4, size / 4), (3 * size / 4, size / 4),
|
|
600
|
+
(size / 4, 3 * size / 4), (3 * size / 4, 3 * size / 4)],
|
|
601
|
+
5: [(size / 4, size / 4), (3 * size / 4, size / 4),
|
|
602
|
+
(center, center),
|
|
603
|
+
(size / 4, 3 * size / 4), (3 * size / 4, 3 * size / 4)],
|
|
604
|
+
6: [(size / 4, size / 4), (3 * size / 4, size / 4),
|
|
605
|
+
(size / 4, center), (3 * size / 4, center),
|
|
606
|
+
(size / 4, 3 * size / 4), (3 * size / 4, 3 * size / 4)],
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
for px, py in pip_positions.get(value, []):
|
|
610
|
+
svg_parts.append(f'''
|
|
611
|
+
<circle class="die-pip" cx="{x + px}" cy="{y + py}" r="{pip_radius}"/>
|
|
612
|
+
''')
|
|
613
|
+
|
|
614
|
+
return ''.join(svg_parts)
|
|
615
|
+
|
|
616
|
+
def _draw_cube(
|
|
617
|
+
self,
|
|
618
|
+
cube_value: int,
|
|
619
|
+
cube_owner: CubeState,
|
|
620
|
+
board_x: float,
|
|
621
|
+
board_y: float,
|
|
622
|
+
flipped: bool
|
|
623
|
+
) -> str:
|
|
624
|
+
"""Draw the doubling cube."""
|
|
625
|
+
cube_size = 50
|
|
626
|
+
|
|
627
|
+
cube_area_center = (self.margin + self.cube_area_width) / 2
|
|
628
|
+
|
|
629
|
+
# Position based on cube owner
|
|
630
|
+
if cube_owner == CubeState.CENTERED:
|
|
631
|
+
cube_x = cube_area_center - cube_size / 2
|
|
632
|
+
cube_y = board_y + (self.board_height - cube_size) / 2
|
|
633
|
+
elif cube_owner == CubeState.O_OWNS:
|
|
634
|
+
cube_x = cube_area_center - cube_size / 2
|
|
635
|
+
cube_y = board_y + self.board_height - cube_size - 10 if not flipped else board_y + 10
|
|
636
|
+
else: # X_OWNS
|
|
637
|
+
cube_x = cube_area_center - cube_size / 2
|
|
638
|
+
cube_y = board_y + 10 if not flipped else board_y + self.board_height - cube_size - 10
|
|
639
|
+
|
|
640
|
+
text = "64" if cube_owner == CubeState.CENTERED else str(cube_value)
|
|
641
|
+
|
|
642
|
+
return f'''
|
|
643
|
+
<g class="cube">
|
|
644
|
+
<rect class="cube" x="{cube_x}" y="{cube_y}"
|
|
645
|
+
width="{cube_size}" height="{cube_size}" rx="3"/>
|
|
646
|
+
<text class="cube-text" x="{cube_x + cube_size / 2}" y="{cube_y + cube_size / 2 + 2}">{text}</text>
|
|
647
|
+
</g>
|
|
648
|
+
'''
|
|
649
|
+
|
|
650
|
+
def _draw_pip_counts(
|
|
651
|
+
self,
|
|
652
|
+
position: Position,
|
|
653
|
+
board_x: float,
|
|
654
|
+
board_y: float,
|
|
655
|
+
flipped: bool
|
|
656
|
+
) -> str:
|
|
657
|
+
"""Draw pip counts for both players."""
|
|
658
|
+
x_pips = self._calculate_pip_count(position, Player.X)
|
|
659
|
+
o_pips = self._calculate_pip_count(position, Player.O)
|
|
660
|
+
|
|
661
|
+
bearoff_text_x = board_x + self.playing_width + 15
|
|
662
|
+
x_bearoff_top = board_y + 10 + 15
|
|
663
|
+
o_bearoff_top = board_y + self.board_height / 2 + 70 + 15
|
|
664
|
+
|
|
665
|
+
if flipped:
|
|
666
|
+
return f'''
|
|
667
|
+
<g class="pip-counts">
|
|
668
|
+
<text class="pip-count" x="{bearoff_text_x}" y="{o_bearoff_top}">Pip: {x_pips}</text>
|
|
669
|
+
<text class="pip-count" x="{bearoff_text_x}" y="{x_bearoff_top}">Pip: {o_pips}</text>
|
|
670
|
+
</g>
|
|
671
|
+
'''
|
|
672
|
+
else:
|
|
673
|
+
return f'''
|
|
674
|
+
<g class="pip-counts">
|
|
675
|
+
<text class="pip-count" x="{bearoff_text_x}" y="{x_bearoff_top}">Pip: {x_pips}</text>
|
|
676
|
+
<text class="pip-count" x="{bearoff_text_x}" y="{o_bearoff_top}">Pip: {o_pips}</text>
|
|
677
|
+
</g>
|
|
678
|
+
'''
|
|
679
|
+
|
|
680
|
+
def _draw_scores(
|
|
681
|
+
self,
|
|
682
|
+
score_x: int,
|
|
683
|
+
score_o: int,
|
|
684
|
+
match_length: int,
|
|
685
|
+
board_x: float,
|
|
686
|
+
board_y: float,
|
|
687
|
+
flipped: bool
|
|
688
|
+
) -> str:
|
|
689
|
+
"""Draw match scores on the board (only for match play)."""
|
|
690
|
+
if match_length == 0:
|
|
691
|
+
return ""
|
|
692
|
+
|
|
693
|
+
bearoff_x = board_x + self.playing_width + 10
|
|
694
|
+
bearoff_width = self.bearoff_area_width - 20
|
|
695
|
+
center_x = bearoff_x + bearoff_width / 2
|
|
696
|
+
center_y = board_y + self.board_height / 2
|
|
697
|
+
|
|
698
|
+
box_width = 60
|
|
699
|
+
box_height = 35
|
|
700
|
+
box_spacing = 5
|
|
701
|
+
total_height = 3 * box_height + 2 * box_spacing
|
|
702
|
+
start_y = center_y - total_height / 2
|
|
703
|
+
|
|
704
|
+
return f'''
|
|
705
|
+
<g class="match-scores">
|
|
706
|
+
<!-- Top box: O player score -->
|
|
707
|
+
<rect x="{center_x - box_width/2}" y="{start_y}"
|
|
708
|
+
width="{box_width}" height="{box_height}"
|
|
709
|
+
fill="{self.color_scheme.point_dark}"
|
|
710
|
+
stroke="{self.color_scheme.bearoff}"
|
|
711
|
+
stroke-width="2"/>
|
|
712
|
+
<text x="{center_x}" y="{start_y + box_height/2 + 8}"
|
|
713
|
+
text-anchor="middle" font-family="Arial, sans-serif"
|
|
714
|
+
font-size="22px" font-weight="bold" fill="{self.color_scheme.text}">{score_o}</text>
|
|
715
|
+
|
|
716
|
+
<!-- Middle box: Match length -->
|
|
717
|
+
<rect x="{center_x - box_width/2}" y="{start_y + box_height + box_spacing}"
|
|
718
|
+
width="{box_width}" height="{box_height}"
|
|
719
|
+
fill="{self.color_scheme.point_dark}"
|
|
720
|
+
stroke="{self.color_scheme.bearoff}"
|
|
721
|
+
stroke-width="2"/>
|
|
722
|
+
<text x="{center_x}" y="{start_y + box_height + box_spacing + box_height/2 + 7}"
|
|
723
|
+
text-anchor="middle" font-family="Arial, sans-serif"
|
|
724
|
+
font-size="16px" font-weight="bold" fill="{self.color_scheme.text}">{match_length}pt</text>
|
|
725
|
+
|
|
726
|
+
<!-- Bottom box: X player score -->
|
|
727
|
+
<rect x="{center_x - box_width/2}" y="{start_y + 2*box_height + 2*box_spacing}"
|
|
728
|
+
width="{box_width}" height="{box_height}"
|
|
729
|
+
fill="{self.color_scheme.point_dark}"
|
|
730
|
+
stroke="{self.color_scheme.bearoff}"
|
|
731
|
+
stroke-width="2"/>
|
|
732
|
+
<text x="{center_x}" y="{start_y + 2*box_height + 2*box_spacing + box_height/2 + 8}"
|
|
733
|
+
text-anchor="middle" font-family="Arial, sans-serif"
|
|
734
|
+
font-size="22px" font-weight="bold" fill="{self.color_scheme.text}">{score_x}</text>
|
|
735
|
+
</g>
|
|
736
|
+
'''
|
|
737
|
+
|
|
738
|
+
def _get_point_position(self, point_idx: int, board_x: float, board_y: float) -> Tuple[float, float, bool]:
|
|
739
|
+
"""
|
|
740
|
+
Get the x, y position and orientation of a point.
|
|
741
|
+
|
|
742
|
+
Returns:
|
|
743
|
+
(x, y_base, is_top) where is_top indicates if point extends from top
|
|
744
|
+
"""
|
|
745
|
+
if point_idx < 1 or point_idx > 24:
|
|
746
|
+
raise ValueError(f"Invalid point index: {point_idx}")
|
|
747
|
+
|
|
748
|
+
visual_idx = self._get_visual_point_index(point_idx)
|
|
749
|
+
if visual_idx < 6:
|
|
750
|
+
# Bottom right quadrant (visual positions 0-5)
|
|
751
|
+
x = board_x + self.half_width + self.bar_width + (5 - visual_idx) * self.point_width
|
|
752
|
+
y_base = board_y + self.board_height
|
|
753
|
+
is_top = False
|
|
754
|
+
elif visual_idx < 12:
|
|
755
|
+
# Bottom left quadrant (visual positions 6-11)
|
|
756
|
+
x = board_x + (11 - visual_idx) * self.point_width
|
|
757
|
+
y_base = board_y + self.board_height
|
|
758
|
+
is_top = False
|
|
759
|
+
elif visual_idx < 18:
|
|
760
|
+
# Top left quadrant (visual positions 12-17)
|
|
761
|
+
x = board_x + (visual_idx - 12) * self.point_width
|
|
762
|
+
y_base = board_y
|
|
763
|
+
is_top = True
|
|
764
|
+
else:
|
|
765
|
+
# Top right quadrant (visual positions 18-23)
|
|
766
|
+
x = board_x + self.half_width + self.bar_width + (visual_idx - 18) * self.point_width
|
|
767
|
+
y_base = board_y
|
|
768
|
+
is_top = True
|
|
769
|
+
|
|
770
|
+
return x, y_base, is_top
|
|
771
|
+
|
|
772
|
+
def _calculate_pip_count(self, position: Position, player: Player) -> int:
|
|
773
|
+
"""Calculate pip count for a player."""
|
|
774
|
+
pip_count = 0
|
|
775
|
+
|
|
776
|
+
if player == Player.X:
|
|
777
|
+
for point_idx in range(1, 25):
|
|
778
|
+
if position.points[point_idx] > 0:
|
|
779
|
+
x_pip_distance = 25 - point_idx
|
|
780
|
+
pip_count += x_pip_distance * position.points[point_idx]
|
|
781
|
+
if position.points[0] > 0:
|
|
782
|
+
pip_count += 25 * position.points[0]
|
|
783
|
+
else:
|
|
784
|
+
for point_idx in range(1, 25):
|
|
785
|
+
if position.points[point_idx] < 0:
|
|
786
|
+
o_pip_distance = point_idx
|
|
787
|
+
pip_count += o_pip_distance * abs(position.points[point_idx])
|
|
788
|
+
if position.points[25] < 0:
|
|
789
|
+
pip_count += 25 * abs(position.points[25])
|
|
790
|
+
|
|
791
|
+
return pip_count
|