Crazy Eights, Spite & Malice, Old Maid: SVG boards" \

discard, suit in play, stock, and the hand.
* cg-spite.el (cg-spite--board-svg, cg-spite--render-text): centre piles,
goal, discards, and hand.
* cg-match.el (cg-om--svg, cg-om--render-text, cg-render-apply): Old Maid
board with clickable target cards; wire mouse-1, zoom, and cg-current-game.
* test/card-games-tests.el: add cgt-eights-svg-smoke, cgt-spite-svg-smoke,
cgt-om-svg-smoke.
* NEWS: note the new boards.
This commit is contained in:
Corwin Brust 2026-07-01 05:06:29 -05:00
parent 2ee802f52d
commit 32fb9f0c6d
5 changed files with 222 additions and 3 deletions

4
NEWS
View file

@ -46,6 +46,10 @@ the mouse as well as the keyboard.
- The same board now covers Hand & Foot (each team's books, and your - The same board now covers Hand & Foot (each team's books, and your
hand or foot) and the fishing games Scopa and Casino (the loose hand or foot) and the fishing games Scopa and Casino (the loose
table cards and the deck). table cards and the deck).
- SVG boards for Crazy Eights (the discard, the suit in play, and the
stock), Spite & Malice (the four centre piles, your goal and discard
piles), and Old Maid (the opponents and your hand; click one of the
next player's face-down cards to draw it).
** Rummy-family rules completed ** Rummy-family rules completed
- Rummy 500: take a card from anywhere in the discard pile (key ~T~) -- - Rummy 500: take a card from anywhere in the discard pile (key ~T~) --

View file

@ -213,8 +213,65 @@ Return the drawn card, or nil when none is available."
"Return the cg-svg display spec (RANK-STRING . SUIT) for CARD." "Return the cg-svg display spec (RANK-STRING . SUIT) for CARD."
(cons (aref cg-eights-ranks (cdr card)) (car card))) (cons (aref cg-eights-ranks (cdr card)) (car card)))
(defun cg-eights--board-svg (game)
"Return an SVG board for the Crazy Eights GAME."
(let* ((w cg-svg-card-width) (h cg-svg-card-height) (gap cg-svg-card-gap) (pad 16)
(hand (cg-eights--hand game 0)) (n (length hand))
(cursor (cg-get game :cursor))
(top (cg-eights--top game)) (suit (cg-get game :suit))
(overlap (cond ((> n 11) (- w 24)) ((> n 8) 20) (t 0)))
(step (max 14 (- (+ w gap) overlap)))
(fanw (if (> n 0) (+ (* (1- n) step) w) w))
(np (cg-get game :nplayers)) (nstock (length (cg-get game :stock)))
(y-title 6) (y-info 26)
(y-mid (+ y-info (* (1- np) 16) 14))
(y-hand (+ y-mid h 42))
(height (+ y-hand h 30))
(width (max (+ fanw (* 2 pad)) 560))
(svg (svg-create width height))
(lc (cg-color 'shadow :foreground "gray50"))
(regions '()))
(cl-labels ((txt (str x y &optional sz bold)
(apply #'svg-text svg str :x x :y y :font-size (or sz 12) :fill lc
:font-family cg-svg-font-family (and bold '(:font-weight "bold")))))
(txt "Crazy Eights" pad (+ y-title 12) 13 t)
(let ((yy (+ y-info 4)))
(dotimes (s np)
(unless (= s 0)
(txt (format "Player %d: %d cards (score %d)" s
(length (cg-eights--hand game s)) (aref (cg-get game :scores) s))
pad yy 12)
(setq yy (+ yy 16)))))
(cg-svg-card svg pad y-mid :down (> nstock 0) :gap (= nstock 0))
(txt (format "Stock %d" nstock) pad (+ y-mid h 13) 11)
(let ((dx (+ pad w gap 28)) (sp (cg-eights--spec top)))
(cg-svg-card svg dx y-mid :rank (car sp) :suit (cdr sp))
(txt "Discard" dx (+ y-mid h 13) 11)
(let ((sx (+ dx w gap 34))
(col (if (cg-red-suit-p suit) "#c0392b" "#2c3e50")))
(txt "Suit in play" sx (- y-mid 4) 11)
(svg-text svg (cg-suit-glyph suit) :x (+ sx 12) :y (+ y-mid 46)
:font-size 44 :fill col :font-family cg-svg-font-family)))
(txt "Your hand" pad (- y-hand 6) 11)
(let ((x (max pad (- (/ width 2) (/ fanw 2)))) (i 0))
(dolist (c hand)
(let ((sp (cg-eights--spec c)) (curp (= i cursor))
(hintp (cg-eights--legal-p game c)))
(cg-svg-card svg x y-hand :rank (car sp) :suit (cdr sp)
:highlight curp :hint hintp)
(push (cons (list x y-hand (if (= i (1- n)) w step) h) (cons 'hand i)) regions))
(setq x (+ x step) i (1+ i))))
(txt (or (cg-get game :message) "") pad (- height 8) 12))
(propertize "*" 'display (cg-svg-image svg (cg-scale)) 'cg-regions (nreverse regions))))
(cl-defmethod cg-render ((game cg-eights-game)) (cl-defmethod cg-render ((game cg-eights-game))
"Return a propertized string depicting GAME for a text display." "Return a depiction of GAME: an SVG board if graphical, else text."
(if (and cg-eights-svg-cards (display-graphic-p))
(cg-eights--board-svg game)
(cg-eights--render-text game)))
(defun cg-eights--render-text (game)
"Return a plain-text depiction of GAME."
(let* ((out (list)) (top (cg-eights--top game)) (let* ((out (list)) (top (cg-eights--top game))
(hand (cg-eights--hand game 0)) (cursor (cg-get game :cursor))) (hand (cg-eights--hand game 0)) (cursor (cg-get game :cursor)))
(push (format " Crazy Eights\n\n") out) (push (format " Crazy Eights\n\n") out)

View file

@ -452,8 +452,69 @@ instead of hunting for one overlapped card in a big hand."
(defvar-local cg-om--game nil "The Old Maid game in the current buffer.") (defvar-local cg-om--game nil "The Old Maid game in the current buffer.")
(defun cg-om--svg (game)
"Return an SVG board for the Old Maid GAME."
(let* ((w cg-svg-card-width) (h cg-svg-card-height) (gap cg-svg-card-gap) (pad 16)
(hand (cg-om--hand game 0)) (n (length hand))
(target (cg-om--target game 0)) (pick (or (cg-get game :pick) 0))
(yourp (and target (eq (cg-get game :phase) 'play) (= (cg-get game :turn) 0)))
(np (cg-get game :nplayers))
(overlap (cond ((> n 11) (- w 24)) ((> n 8) 20) (t 0)))
(step (max 14 (- (+ w gap) overlap)))
(fanw (if (> n 0) (+ (* (1- n) step) w) w))
(bstep 20)
(tn (and target (length (cg-om--hand game target))))
(y-title 6) (y-info 26)
(y-target (+ y-info (* (1- np) 16) 18))
(y-hand (+ y-target h 42))
(targetw (if (and yourp tn (> tn 0)) (+ (* (1- tn) bstep) w) 0))
(height (+ y-hand h 30))
(width (max (+ fanw (* 2 pad)) (+ targetw (* 2 pad)) 560))
(svg (svg-create width height))
(lc (cg-color 'shadow :foreground "gray50"))
(regions '()))
(cl-labels ((txt (str x y &optional sz bold)
(apply #'svg-text svg str :x x :y y :font-size (or sz 12) :fill lc
:font-family cg-svg-font-family (and bold '(:font-weight "bold")))))
(txt "Old Maid" pad (+ y-title 12) 13 t)
(let ((yy (+ y-info 4)))
(dotimes (s np)
(unless (= s 0)
(txt (format "Player %d: %d cards%s" s (length (cg-om--hand game s))
(if (eql s target) " <- draw from here" ""))
pad yy 12)
(setq yy (+ yy 16)))))
(when (and yourp tn (> tn 0))
(txt (format "Pick a card from Player %d:" target) pad (- y-target 6) 11)
(let ((x pad))
(dotimes (i tn)
(cg-svg-card svg x y-target :down t :highlight (= i pick))
(push (cons (list x y-target (if (= i (1- tn)) w bstep) h) (cons 'pick i))
regions)
(setq x (+ x bstep)))))
(txt "Your hand" pad (- y-hand 6) 11)
(let ((x (max pad (- (/ width 2) (/ fanw 2)))))
(dolist (c hand)
(let ((sp (cg-rummy--card-spec c)))
(cg-svg-card svg x y-hand :rank (car sp) :suit (cdr sp)))
(setq x (+ x step))))
(txt (or (cg-get game :message) "") pad (- height 8) 12))
(propertize "*" 'display (cg-svg-image svg (cg-scale)) 'cg-regions (nreverse regions))))
(cl-defmethod cg-render-apply ((g cg-old-maid-game) action)
"Apply a click ACTION: pick that card from the target and draw it."
(pcase action
(`(pick . ,i) (cg-put g :pick i) (cg-om-draw))
(_ (cl-call-next-method))))
(cl-defmethod cg-render ((game cg-old-maid-game)) (cl-defmethod cg-render ((game cg-old-maid-game))
"Return a propertized depiction of the Old Maid GAME." "Return a depiction of the Old Maid GAME: SVG board if graphical, else text."
(if (and cg-rummy-svg-cards (display-graphic-p))
(cg-om--svg game)
(cg-om--render-text game)))
(defun cg-om--render-text (game)
"Return a plain-text depiction of the Old Maid GAME."
(let* ((out '()) (target (cg-om--target game 0))) (let* ((out '()) (target (cg-om--target game 0)))
(push " Old Maid\n\n" out) (push " Old Maid\n\n" out)
(dotimes (s (cg-get game :nplayers)) (dotimes (s (cg-get game :nplayers))
@ -472,6 +533,7 @@ instead of hunting for one overlapped card in a big hand."
(defun cg-om--redisplay () (defun cg-om--redisplay ()
(let ((game cg-om--game) (inhibit-read-only t)) (let ((game cg-om--game) (inhibit-read-only t))
(setq cg-current-game game cg-redisplay-function #'cg-om--redisplay)
(setq-local mode-line-process (format " [%s]" (cg-get game :phase))) (setq-local mode-line-process (format " [%s]" (cg-get game :phase)))
(erase-buffer) (insert (cg-render game)) (goto-char (point-min)))) (erase-buffer) (insert (cg-render game)) (goto-char (point-min))))
@ -510,6 +572,11 @@ instead of hunting for one overlapped card in a big hand."
(defvar cg-old-maid-mode-map (defvar cg-old-maid-mode-map
(let ((map (make-sparse-keymap))) (let ((map (make-sparse-keymap)))
(define-key map [mouse-1] #'cg-card-click)
(define-key map "+" #'cg-card-zoom-in)
(define-key map "=" #'cg-card-zoom-in)
(define-key map "-" #'cg-card-zoom-out)
(define-key map "0" #'cg-card-zoom-reset)
(define-key map (kbd "<left>") #'cg-om-left) (define-key map (kbd "<left>") #'cg-om-left)
(define-key map (kbd "<right>") #'cg-om-right) (define-key map (kbd "<right>") #'cg-om-right)
(define-key map (kbd "RET") #'cg-om-draw) (define-key map (kbd "RET") #'cg-om-draw)

View file

@ -273,8 +273,74 @@
(push (format "%d:%s" (1+ d) (if top (cg-rummy-card-string top) "--")) parts))) (push (format "%d:%s" (1+ d) (if top (cg-rummy-card-string top) "--")) parts)))
(mapconcat #'identity (nreverse parts) " "))) (mapconcat #'identity (nreverse parts) " ")))
(defun cg-spite--board-svg (game)
"Return an SVG board for the Spite & Malice GAME."
(let* ((w cg-svg-card-width) (h cg-svg-card-height) (gap cg-svg-card-gap) (pad 16)
(hand (cg-spite--hand game 0)) (n (length hand))
(cursor (cg-get game :cursor)) (center (cg-get game :center))
(overlap (cond ((> n 11) (- w 24)) ((> n 8) 20) (t 0)))
(step (max 14 (- (+ w gap) overlap)))
(fanw (if (> n 0) (+ (* (1- n) step) w) w))
(colstep (+ w 14))
(y-title 6) (y-opp 26)
(y-center (+ y-opp 16))
(y-sm (+ y-center h 16))
(y-ylabel (+ y-sm 12))
(y-yours (+ y-ylabel 6))
(y-hand (+ y-yours h 42))
(height (+ y-hand h 30))
(width (max (+ fanw (* 2 pad)) (+ (* 5 colstep) (* 2 pad)) 620))
(svg (svg-create width height))
(lc (cg-color 'shadow :foreground "gray50"))
(regions '()))
(cl-labels ((txt (str x y &optional sz bold)
(apply #'svg-text svg str :x x :y y :font-size (or sz 12) :fill lc
:font-family cg-svg-font-family (and bold '(:font-weight "bold"))))
(pilecard (spec x y)
(if spec (cg-svg-card svg x y :rank (car spec) :suit (cdr spec))
(cg-svg-card svg x y :gap t))))
(txt (format "Spite & Malice (goal %d)" cg-spite-goal-size) pad (+ y-title 12) 13 t)
(txt (format "Computer: goal %d left hand %d discards %s"
(length (cg-spite--goal game 1)) (length (cg-spite--hand game 1))
(cg-spite--disc-string game 1))
pad (+ y-opp 4) 12)
(txt "Centre (build A..Q; King is wild)" pad (- y-center 4) 11)
(dotimes (i 4)
(let* ((x (+ pad (* i colstep))) (pp (aref center i))
(spec (and pp (cons (aref cg-rummy-ranks (car pp)) (car (cadr pp))))))
(pilecard spec x y-center)))
(txt (format "Stock %d Muck %d"
(length (cg-get game :stock)) (length (cg-get game :muck)))
pad y-sm 11)
(let* ((gtop (car (cg-spite--goal game 0)))
(gspec (and gtop (cg-rummy--card-spec gtop))))
(txt (format "Your goal (%d left)" (length (cg-spite--goal game 0)))
pad y-ylabel 11)
(txt "Discards" (+ pad colstep) y-ylabel 11)
(pilecard gspec pad y-yours)
(dotimes (d 4)
(let* ((x (+ pad colstep (* d colstep)))
(dtop (car (aref (cg-spite--disc game 0) d)))
(dspec (and dtop (cg-rummy--card-spec dtop))))
(pilecard dspec x y-yours))))
(txt "Your hand" pad (- y-hand 6) 11)
(let ((x (max pad (- (/ width 2) (/ fanw 2)))) (i 0))
(dolist (c hand)
(let ((sp (cg-rummy--card-spec c)) (curp (= i cursor)))
(cg-svg-card svg x y-hand :rank (car sp) :suit (cdr sp) :highlight curp)
(push (cons (list x y-hand (if (= i (1- n)) w step) h) (cons 'hand i)) regions))
(setq x (+ x step) i (1+ i))))
(txt (or (cg-get game :message) "") pad (- height 8) 12))
(propertize "*" 'display (cg-svg-image svg (cg-scale)) 'cg-regions (nreverse regions))))
(cl-defmethod cg-render ((game cg-spite-game)) (cl-defmethod cg-render ((game cg-spite-game))
"Return a propertized depiction of the Spite & Malice GAME." "Return a depiction of the GAME: an SVG board if graphical, else text."
(if (and cg-rummy-svg-cards (display-graphic-p))
(cg-spite--board-svg game)
(cg-spite--render-text game)))
(defun cg-spite--render-text (game)
"Return a plain-text depiction of the Spite & Malice GAME."
(let* ((out '()) (cursor (cg-get game :cursor))) (let* ((out '()) (cursor (cg-get game :cursor)))
(push " Spite & Malice\n\n" out) (push " Spite & Malice\n\n" out)
(push (format " Computer goal: %d left hand: %d discards: %s\n\n" (push (format " Computer goal: %d left hand: %d discards: %s\n\n"

View file

@ -894,6 +894,31 @@
(should (rassoc '(hand . 0) (should (rassoc '(hand . 0)
(get-text-property 0 'cg-regions (cg-fish--svg g)))))) (get-text-property 0 'cg-regions (cg-fish--svg g))))))
(ert-deftest cgt-eights-svg-smoke ()
(let ((g (cg-eights-game)))
(cg-put g :nplayers 3) (cg-put g :scores (make-vector 3 0))
(cg-eights--deal g) (cg-put g :cursor 0) (cg-put g :message "x")
(should (stringp (cg-eights--render-text g)))
(let ((regs (get-text-property 0 'cg-regions (cg-eights--board-svg g))))
(should (rassoc '(hand . 0) regs))
(should (cl-every (lambda (r) (= 4 (length (car r)))) regs)))))
(ert-deftest cgt-spite-svg-smoke ()
(let ((g (cg-spite-game)))
(cg-put g :nplayers 2) (cg-put g :scores (make-vector 2 0))
(cg-spite--deal g) (cg-put g :cursor 0) (cg-put g :message "x")
(aset (cg-get g :center) 0 (cons 3 (list '(1 . 3))))
(should (stringp (cg-spite--render-text g)))
(should (rassoc '(hand . 0)
(get-text-property 0 'cg-regions (cg-spite--board-svg g))))))
(ert-deftest cgt-om-svg-smoke ()
(let ((g (cg-old-maid-game)))
(cg-om--deal g)
(cg-put g :phase 'play) (cg-put g :turn 0) (cg-put g :message "x")
(should (stringp (cg-om--render-text g)))
(should (stringp (cg-om--svg g)))))
(ert-deftest cgt-pat-golf-deal () (ert-deftest cgt-pat-golf-deal ()
(let ((g (cg-pat--deal (cg-golf-game)))) (let ((g (cg-pat--deal (cg-golf-game))))
(should (= 35 (length (cg-get g :cards)))) (should (= 35 (length (cg-get g :cards))))