ankigammon 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of ankigammon might be problematic. Click here for more details.

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