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.
Files changed (61) hide show
  1. ankigammon/__init__.py +7 -0
  2. ankigammon/__main__.py +6 -0
  3. ankigammon/analysis/__init__.py +13 -0
  4. ankigammon/analysis/score_matrix.py +391 -0
  5. ankigammon/anki/__init__.py +6 -0
  6. ankigammon/anki/ankiconnect.py +216 -0
  7. ankigammon/anki/apkg_exporter.py +111 -0
  8. ankigammon/anki/card_generator.py +1325 -0
  9. ankigammon/anki/card_styles.py +1054 -0
  10. ankigammon/gui/__init__.py +8 -0
  11. ankigammon/gui/app.py +192 -0
  12. ankigammon/gui/dialogs/__init__.py +10 -0
  13. ankigammon/gui/dialogs/export_dialog.py +594 -0
  14. ankigammon/gui/dialogs/import_options_dialog.py +201 -0
  15. ankigammon/gui/dialogs/input_dialog.py +762 -0
  16. ankigammon/gui/dialogs/note_dialog.py +93 -0
  17. ankigammon/gui/dialogs/settings_dialog.py +420 -0
  18. ankigammon/gui/dialogs/update_dialog.py +373 -0
  19. ankigammon/gui/format_detector.py +377 -0
  20. ankigammon/gui/main_window.py +1611 -0
  21. ankigammon/gui/resources/down-arrow.svg +3 -0
  22. ankigammon/gui/resources/icon.icns +0 -0
  23. ankigammon/gui/resources/icon.ico +0 -0
  24. ankigammon/gui/resources/icon.png +0 -0
  25. ankigammon/gui/resources/style.qss +402 -0
  26. ankigammon/gui/resources.py +26 -0
  27. ankigammon/gui/update_checker.py +259 -0
  28. ankigammon/gui/widgets/__init__.py +8 -0
  29. ankigammon/gui/widgets/position_list.py +166 -0
  30. ankigammon/gui/widgets/smart_input.py +268 -0
  31. ankigammon/models.py +356 -0
  32. ankigammon/parsers/__init__.py +7 -0
  33. ankigammon/parsers/gnubg_match_parser.py +1094 -0
  34. ankigammon/parsers/gnubg_parser.py +468 -0
  35. ankigammon/parsers/sgf_parser.py +290 -0
  36. ankigammon/parsers/xg_binary_parser.py +1097 -0
  37. ankigammon/parsers/xg_text_parser.py +688 -0
  38. ankigammon/renderer/__init__.py +5 -0
  39. ankigammon/renderer/animation_controller.py +391 -0
  40. ankigammon/renderer/animation_helper.py +191 -0
  41. ankigammon/renderer/color_schemes.py +145 -0
  42. ankigammon/renderer/svg_board_renderer.py +791 -0
  43. ankigammon/settings.py +315 -0
  44. ankigammon/thirdparty/__init__.py +7 -0
  45. ankigammon/thirdparty/xgdatatools/__init__.py +17 -0
  46. ankigammon/thirdparty/xgdatatools/xgimport.py +160 -0
  47. ankigammon/thirdparty/xgdatatools/xgstruct.py +1032 -0
  48. ankigammon/thirdparty/xgdatatools/xgutils.py +118 -0
  49. ankigammon/thirdparty/xgdatatools/xgzarc.py +260 -0
  50. ankigammon/utils/__init__.py +13 -0
  51. ankigammon/utils/gnubg_analyzer.py +590 -0
  52. ankigammon/utils/gnuid.py +577 -0
  53. ankigammon/utils/move_parser.py +204 -0
  54. ankigammon/utils/ogid.py +326 -0
  55. ankigammon/utils/xgid.py +387 -0
  56. ankigammon-1.0.6.dist-info/METADATA +352 -0
  57. ankigammon-1.0.6.dist-info/RECORD +61 -0
  58. ankigammon-1.0.6.dist-info/WHEEL +5 -0
  59. ankigammon-1.0.6.dist-info/entry_points.txt +2 -0
  60. ankigammon-1.0.6.dist-info/licenses/LICENSE +21 -0
  61. 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