main.go
// Command solitaire is a Klondike solitaire built on gophics — one codebase
// for desktop, web, and (via shell/mobile) iOS/Android. The rules engine is the
// pure, exhaustively-tested examples/solitaire/klondike package; this command is
// the board: a single widget.Canvas that draws the cards (no image assets) and
// does its own drag/drop and hit-testing.
package main
import (
"log"
"time"
"golang.org/x/image/font/gofont/gobold"
"golang.org/x/image/font/gofont/goregular"
"github.com/doug/gophics/app"
"github.com/doug/gophics/geom"
)
func main() {
err := app.Run(Solitaire{Seed: time.Now().UnixNano()}, app.Config{
Title: "Solitaire",
Size: geom.Size{W: 920, H: 720},
Background: colFelt,
Font: goregular.TTF,
FontFamilies: map[string][]byte{"bold": gobold.TTF},
})
if err != nil {
log.Fatal(err)
}
}
board.go
package main
import (
"slices"
"github.com/doug/gophics/examples/solitaire/klondike"
"github.com/doug/gophics/geom"
)
// Board is the pure geometry of a Klondike layout for a given surface size and
// game — every card's rectangle, shared by rendering and hit-testing so they
// can never disagree. It holds no game state; recompute it each frame (cheap).
type Board struct {
CardW, CardH float32
Stock, Waste geom.Rect
Foundations [4]geom.Rect
Slot [7]geom.Rect // base rect of each tableau column (empty slot / first card)
Tableaus [7][]geom.Rect // one rect per card, fanned down
}
// Layout computes the board for size and the current game.
func Layout(size geom.Size, g *klondike.Game) Board {
const margin, gapFrac, fanUpFrac, fanDownFrac = 14, 0.16, 0.30, 0.11
usable := size.W - 2*margin
cardW := usable / (7 + 6*gapFrac)
gap := cardW * gapFrac
cardH := cardW * 1.4
colX := func(i int) float32 { return margin + float32(i)*(cardW+gap) }
rect := func(x, y float32) geom.Rect { return geom.RectXYWH(x, y, cardW, cardH) }
var b Board
b.CardW, b.CardH = cardW, cardH
topY := float32(margin)
b.Stock = rect(colX(0), topY)
b.Waste = rect(colX(1), topY)
for i := range 4 {
b.Foundations[i] = rect(colX(3+i), topY)
}
tableTop := topY + cardH + gap*1.4
fanUp, fanDown := cardH*fanUpFrac, cardH*fanDownFrac
for j := range 7 {
x := colX(j)
b.Slot[j] = rect(x, tableTop)
col := g.Tableau(j)
y := tableTop
for k := range col {
b.Tableaus[j] = append(b.Tableaus[j], rect(x, y))
if col[k].Up {
y += fanUp
} else {
y += fanDown
}
}
}
return b
}
// Hit returns the pile and — for a tableau — the index of the topmost card at p.
// For waste/foundation/stock the returned index is not meaningful (use the top).
// idx == -1 means an empty tableau slot.
func (b Board) Hit(p geom.Pt) (pile klondike.Pile, idx int, ok bool) {
switch {
case b.Stock.Contains(p):
return klondike.Pile{Kind: klondike.Stock}, 0, true
case b.Waste.Contains(p):
return klondike.Pile{Kind: klondike.Waste}, 0, true
}
for i := range 4 {
if b.Foundations[i].Contains(p) {
return klondike.Pile{Kind: klondike.Foundation, Index: i}, 0, true
}
}
for j := range 7 {
rects := b.Tableaus[j]
for k, rect := range slices.Backward(rects) {
if rect.Contains(p) {
return klondike.Pile{Kind: klondike.Tableau, Index: j}, k, true
}
}
if len(rects) == 0 && b.Slot[j].Contains(p) {
return klondike.Pile{Kind: klondike.Tableau, Index: j}, -1, true
}
}
return klondike.Pile{}, 0, false
}
// DropTarget is a candidate landing spot for a dragged run.
type DropTarget struct {
Pile klondike.Pile
Rect geom.Rect
}
// DropTargets returns the foundations and the landing rect of each tableau
// column (its top card, or the empty slot), for overlap-based drop resolution.
func (b Board) DropTargets(g *klondike.Game) []DropTarget {
out := make([]DropTarget, 0, 11)
for i := range 4 {
out = append(out, DropTarget{klondike.Pile{Kind: klondike.Foundation, Index: i}, b.Foundations[i]})
}
for j := range 7 {
r := b.Slot[j]
if n := len(b.Tableaus[j]); n > 0 {
r = b.Tableaus[j][n-1]
}
out = append(out, DropTarget{klondike.Pile{Kind: klondike.Tableau, Index: j}, r})
}
return out
}
// overlapArea is the area of the intersection of a and b (0 if disjoint).
func overlapArea(a, c geom.Rect) float32 {
x := min(a.Max.X, c.Max.X) - max(a.Min.X, c.Min.X)
y := min(a.Max.Y, c.Max.Y) - max(a.Min.Y, c.Min.Y)
if x <= 0 || y <= 0 {
return 0
}
return x * y
}
cards_draw.go
package main
import (
"fmt"
"github.com/doug/gophics/examples/solitaire/klondike"
"github.com/doug/gophics/geom"
"github.com/doug/gophics/paint"
)
var (
colFelt = paint.RGB(0.10, 0.44, 0.30)
colFeltHi = paint.RGB(0.13, 0.50, 0.34)
colFeltLo = paint.RGB(0.06, 0.33, 0.22)
colFace = paint.RGB(0.99, 0.99, 0.98)
colEdge = paint.Color{R: 0, G: 0, B: 0, A: 0.10} // subtle card outline
colShadow = paint.Color{R: 0, G: 0, B: 0, A: 0.28}
colRed = paint.RGB(0.79, 0.13, 0.17)
colBlack = paint.RGB(0.11, 0.12, 0.15)
colBack1 = paint.RGB(0.28, 0.42, 0.72)
colBack2 = paint.RGB(0.12, 0.22, 0.46)
colBack3 = paint.Color{R: 0.60, G: 0.72, B: 0.98, A: 0.55} // light argyle diamond (over gradient)
colBack4 = paint.Color{R: 0.08, G: 0.16, B: 0.40, A: 0.40} // dark argyle diamond
colBackFrame = paint.Color{R: 0.85, G: 0.90, B: 1.0, A: 0.35} // hairline back frame
colSlot = paint.Color{R: 1, G: 1, B: 1, A: 0.14}
)
func suitColor(s klondike.Suit) paint.Color {
if s.Red() {
return colRed
}
return colBlack
}
// suitGlyph returns the Unicode pip; goregular includes all four.
func suitGlyph(s klondike.Suit) string {
switch s {
case klondike.Club:
return "♣"
case klondike.Diamond:
return "♦"
case klondike.Heart:
return "♥"
default:
return "♠"
}
}
func rankLabel(r uint8) string {
switch r {
case 1:
return "A"
case 11:
return "J"
case 12:
return "Q"
case 13:
return "K"
default:
return fmt.Sprintf("%d", r)
}
}
// drawCard paints one card in r (face up, or a gradient back), with a soft
// drop shadow for depth.
func drawCard(c paint.Canvas, r geom.Rect, card klondike.Card) {
sz := r.Dx()
paint.DropShadow(c, r, sz*0.08, geom.Pt{Y: sz * 0.02}, sz*0.05, colShadow)
drawCardBody(c, r, card)
}
// drawCardFanned paints a card that is mostly hidden under the next one in a
// fan, showing only a strip at the top.
//
// It exists because the back's inset frame is a decoration for a card you can
// see all of. On a strip a few pixels tall only its top edge and two severed
// legs survive, and a column of those reads as loose outlines lying over the
// cards rather than as a deck. Real stacked cards show their pattern and their
// edge, so that is what this draws.
func drawCardFanned(c paint.Canvas, r geom.Rect, card klondike.Card) {
sz := r.Dx()
paint.DropShadow(c, r, sz*0.08, geom.Pt{Y: sz * 0.02}, sz*0.05, colShadow)
if !card.Up {
drawCardBackNoFrame(c, r, sz*0.08)
return
}
drawCardBody(c, r, card)
}
// drawCardBody paints the card without a shadow (used for the many win-cascade
// trail stamps, where per-card shadows would be too costly).
func drawCardBody(c paint.Canvas, r geom.Rect, card klondike.Card) {
sz := r.Dx()
rad := sz * 0.08
if !card.Up {
drawCardBack(c, r, rad)
return
}
c.FillRRect(r, rad, colFace)
c.StrokeRRect(r, rad, 1, colEdge)
col := suitColor(card.Suit)
glyph := suitGlyph(card.Suit)
rl := rankLabel(card.Rank)
// Two opposing corner indices (top-left, and bottom-right rotated 180°),
// like a real deck — the second one shows on face-up tops (waste/foundation).
drawCorner(c, r, rl, glyph, col)
cx, cy := r.Min.X+sz/2, r.Min.Y+r.Dy()/2
c.PushTransform(paint.Transform{Rotation: pi, PivotX: cx, PivotY: cy})
drawCorner(c, r, rl, glyph, col)
c.PopTransform()
switch {
case card.Rank >= 2 && card.Rank <= 10:
// The traditional pip arrangement: N symbols laid out in the standard
// grid, with the lower-half pips rotated 180° as on a printed card.
xs := [3]float32{r.Min.X + sz*0.30, cx, r.Max.X - sz*0.30}
ps := sz * 0.19
for _, p := range pipLayout[card.Rank] {
y := r.Min.Y + r.Dy()*p.y
pip(c, glyph, xs[p.col], y, ps, col, p.y > 0.5)
}
case card.Rank == 1:
// Ace: one large central pip.
pip(c, glyph, cx, cy, sz*0.5, col, false)
default:
// Court cards: a large rank letter over its suit.
centerGlyph(c, rl, cx, r.Min.Y+r.Dy()*0.46, sz*0.5, col)
centerGlyph(c, glyph, cx, r.Min.Y+r.Dy()*0.72, sz*0.26, col)
}
}
// drawCardBack paints a face-down card: a blue gradient overlaid with an argyle
// diamond lattice (two alternating translucent tones so the gradient still shows
// through for depth), inside a hairline frame — a classic playing-card back. The
// lattice is a rotated square grid clipped to the card's rounded rect, so the
// squares read as diamonds and the pattern runs edge to edge like a real deck.
func drawCardBack(c paint.Canvas, r geom.Rect, rad float32) {
drawCardBackNoFrame(c, r, rad)
sz := r.Dx()
m := sz * 0.06
c.StrokeRRect(geom.RectXYWH(r.Min.X+m, r.Min.Y+m, r.Dx()-2*m, r.Dy()-2*m), rad*0.7, 1, colBackFrame)
}
// drawCardBackNoFrame paints the back's gradient and lattice without the inset
// frame; see drawCardFanned for why a fanned card omits it.
func drawCardBackNoFrame(c paint.Canvas, r geom.Rect, rad float32) {
sz := r.Dx()
c.FillRRectGradient(r, rad, colBack1, colBack2, false)
cx, cy := r.Min.X+r.Dx()/2, r.Min.Y+r.Dy()/2
c.PushClipRRect(r, rad)
c.PushTransform(paint.Transform{Rotation: 0.7853982, PivotX: cx, PivotY: cy})
cell := sz * 0.26
reach := r.Dx() + r.Dy() // covers the rotated card with margin; the clip trims the overflow
n := int(reach/cell) + 2
x0 := cx - float32(n)*cell/2
y0 := cy - float32(n)*cell/2
for iy := range n {
for ix := range n {
col := colBack3
if (ix+iy)%2 == 1 {
col = colBack4
}
c.FillRect(geom.RectXYWH(x0+float32(ix)*cell, y0+float32(iy)*cell, cell, cell), col)
}
}
c.PopTransform()
c.PopClip()
}
// drawCorner paints the top-left rank index over a small pip. Kept compact so a
// fanned card still reveals it (the fan offset is ~0.42·sz — see Layout).
func drawCorner(c paint.Canvas, r geom.Rect, rl, glyph string, col paint.Color) {
sz := r.Dx()
centerGlyph(c, rl, r.Min.X+sz*0.15, r.Min.Y+sz*0.19, sz*0.20, col)
centerGlyph(c, glyph, r.Min.X+sz*0.15, r.Min.Y+sz*0.36, sz*0.15, col)
}
// pip draws a suit symbol centered at (ax, ay), optionally rotated 180° (as the
// lower-half pips are printed on a real card).
func pip(c paint.Canvas, glyph string, ax, ay, size float32, col paint.Color, flip bool) {
if flip {
c.PushTransform(paint.Transform{Rotation: pi, PivotX: ax, PivotY: ay})
centerGlyph(c, glyph, ax, ay, size, col)
c.PopTransform()
return
}
centerGlyph(c, glyph, ax, ay, size, col)
}
// centerGlyph draws s centered on (ax, ay). The Canvas has no measure API, so it
// uses fixed fractions calibrated for goregular's near-square suit glyphs and
// digits (baseline-left positioning; pos.Y is the baseline).
func centerGlyph(c paint.Canvas, s string, ax, ay, size float32, col paint.Color) {
c.TextIn("", s, geom.Pt{X: ax - size*0.30, Y: ay + size*0.36}, size, col)
}
const pi = 3.14159265
// pipPos is a suit-symbol slot: column (0=left, 1=center, 2=right) and a
// vertical fraction of the card height. Lower-half slots (y>0.5) render rotated.
type pipPos struct {
col int
y float32
}
// pipLayout is the standard printed arrangement of N suit symbols for ranks
// 2–10.
var pipLayout = map[uint8][]pipPos{
2: {{1, 0.20}, {1, 0.80}},
3: {{1, 0.20}, {1, 0.50}, {1, 0.80}},
4: {{0, 0.20}, {2, 0.20}, {0, 0.80}, {2, 0.80}},
5: {{0, 0.20}, {2, 0.20}, {1, 0.50}, {0, 0.80}, {2, 0.80}},
6: {{0, 0.20}, {2, 0.20}, {0, 0.50}, {2, 0.50}, {0, 0.80}, {2, 0.80}},
7: {{0, 0.20}, {2, 0.20}, {1, 0.35}, {0, 0.50}, {2, 0.50}, {0, 0.80}, {2, 0.80}},
8: {{0, 0.20}, {2, 0.20}, {1, 0.35}, {0, 0.50}, {2, 0.50}, {1, 0.65}, {0, 0.80}, {2, 0.80}},
9: {{0, 0.20}, {2, 0.20}, {0, 0.40}, {2, 0.40}, {1, 0.50}, {0, 0.60}, {2, 0.60}, {0, 0.80}, {2, 0.80}},
10: {{0, 0.20}, {2, 0.20}, {1, 0.30}, {0, 0.40}, {2, 0.40}, {0, 0.60}, {2, 0.60}, {1, 0.70}, {0, 0.80}, {2, 0.80}},
}
// drawStamp paints a cheap card for the win-cascade trail: just the face, a
// hairline edge, and the top-left index — enough to read as a streaking card
// without the cost of a full pip layout across hundreds of stamps per frame.
func drawStamp(c paint.Canvas, r geom.Rect, card klondike.Card) {
sz := r.Dx()
c.FillRRect(r, sz*0.08, colFace)
c.StrokeRRect(r, sz*0.08, 1, colEdge)
col := suitColor(card.Suit)
drawCorner(c, r, rankLabel(card.Rank), suitGlyph(card.Suit), col)
}
// drawEmpty paints a ghost slot where a pile can be placed.
func drawEmpty(c paint.Canvas, r geom.Rect) {
c.StrokeRRect(r, r.Dx()*0.08, 1.5, colSlot)
}
klondike/autocomplete.go
package klondike
// CanAutoComplete reports whether the game can be finished automatically: the
// stock and waste are empty and every tableau card is face up, so nothing is
// hidden and greedy foundation play is guaranteed to win. This is the point a
// "Finish" affordance should appear.
func (g *Game) CanAutoComplete() bool {
if len(g.stock) != 0 || len(g.waste) != 0 || g.Won() {
return false
}
for i := range g.tab {
for _, c := range g.tab[i] {
if !c.Up {
return false
}
}
}
return true
}
// AutoComplete plays every remaining card to the foundations by repeatedly
// sending each tableau's top card up until the game is won or wedged. When
// CanAutoComplete was true this always reaches a win.
func (g *Game) AutoComplete() {
for !g.Won() {
moved := false
for i := range g.tab {
if g.AutoToFoundation(Pile{Kind: Tableau, Index: i}) {
moved = true
}
}
if g.AutoToFoundation(Pile{Kind: Waste}) {
moved = true
}
if !moved {
break
}
}
}
klondike/card.go
// Package klondike is the pure rules engine for Klondike solitaire: deck,
// piles, legal moves, an O(1) undo journal, and win detection — with no
// rendering, no framework imports, and no global state, so it is exhaustively
// unit-testable with plain `go test`. The example's UI (a widget.Canvas board)
// is built on top of this.
package klondike
import "fmt"
// Suit of a card. The foundation and tableau rules care only about a suit's
// color (red = Diamond/Heart), never its identity.
type Suit uint8
const (
Club Suit = iota
Diamond
Heart
Spade
)
// Red reports whether the suit is red (Diamond or Heart).
func (s Suit) Red() bool { return s == Diamond || s == Heart }
func (s Suit) String() string {
switch s {
case Club:
return "C"
case Diamond:
return "D"
case Heart:
return "H"
default:
return "S"
}
}
// Card is one playing card. Rank is 1 (Ace) through 13 (King). Up reports
// whether the card is face up (visible and playable).
type Card struct {
Suit Suit
Rank uint8
Up bool
}
func (c Card) String() string {
r := map[uint8]string{1: "A", 11: "J", 12: "Q", 13: "K"}[c.Rank]
if r == "" {
r = fmt.Sprintf("%d", c.Rank)
}
s := r + c.Suit.String()
if !c.Up {
return "(" + s + ")"
}
return s
}
// standardDeck returns the 52 distinct cards, face down, in suit-then-rank order.
func standardDeck() []Card {
d := make([]Card, 0, 52)
for s := Club; s <= Spade; s++ {
for r := uint8(1); r <= 13; r++ {
d = append(d, Card{Suit: s, Rank: r})
}
}
return d
}
klondike/game.go
package klondike
import (
"math/rand"
"slices"
)
// PileKind identifies a family of stacks in the Klondike layout.
type PileKind uint8
const (
Stock PileKind = iota // the face-down draw pile
Waste // cards drawn from the stock, face up
Foundation // four suit stacks, built up Ace→King
Tableau // seven columns, built down by alternating color
)
// Pile addresses a specific stack. Index selects the foundation (0..3) or
// tableau (0..6); it is unused for Stock/Waste.
type Pile struct {
Kind PileKind
Index int
}
// Move is one applied action, recorded so it can be undone exactly.
type Move struct {
From, To Pile
Count int // cards moved (a tableau run may be >1)
Flipped bool // the move exposed and flipped up a face-down source card
Draw int // this move drew Draw cards stock→waste (0 if not a draw)
Recycle bool // this move recycled the waste back into the stock
}
// Game is a Klondike deal in progress. All state is plain data; copy the piles
// out through the accessors to render them.
type Game struct {
stock []Card
waste []Card
found [4][]Card
tab [7][]Card
history []Move
drawN int
}
// New deals a game from seed (deterministic) drawing drawN cards at a time
// (1 or 3; anything else means 1).
func New(seed int64, drawN int) *Game {
if drawN != 3 {
drawN = 1
}
deck := standardDeck()
rand.New(rand.NewSource(seed)).Shuffle(len(deck), func(i, j int) {
deck[i], deck[j] = deck[j], deck[i]
})
g := &Game{drawN: drawN}
k := 0
for col := range 7 {
for row := 0; row <= col; row++ {
c := deck[k]
k++
c.Up = row == col // only the last card in each column is face up
g.tab[col] = append(g.tab[col], c)
}
}
for ; k < len(deck); k++ {
g.stock = append(g.stock, deck[k]) // face down
}
return g
}
// Accessors (read-only views for rendering and tests).
func (g *Game) DrawCount() int { return g.drawN }
func (g *Game) Stock() []Card { return g.stock }
func (g *Game) Waste() []Card { return g.waste }
func (g *Game) Foundation(i int) []Card { return g.found[i] }
func (g *Game) Tableau(i int) []Card { return g.tab[i] }
func (g *Game) MoveCount() int { return len(g.history) }
func (g *Game) pile(p Pile) *[]Card {
switch p.Kind {
case Stock:
return &g.stock
case Waste:
return &g.waste
case Foundation:
return &g.found[p.Index]
case Tableau:
return &g.tab[p.Index]
}
return nil
}
// Draw turns the next drawN cards from the stock onto the waste, or — when the
// stock is empty — recycles the waste back into a fresh stock. Reports whether
// anything happened (false only when stock and waste are both empty).
func (g *Game) Draw() bool {
if len(g.stock) == 0 {
if len(g.waste) == 0 {
return false
}
n := len(g.waste)
for i := n - 1; i >= 0; i-- {
c := g.waste[i]
c.Up = false
g.stock = append(g.stock, c)
}
g.waste = g.waste[:0]
g.history = append(g.history, Move{Recycle: true, Count: n})
return true
}
n := min(g.drawN, len(g.stock))
for i := 0; i < n; i++ {
c := g.stock[len(g.stock)-1]
g.stock = g.stock[:len(g.stock)-1]
c.Up = true
g.waste = append(g.waste, c)
}
g.history = append(g.history, Move{Draw: n})
return true
}
// canPlace reports whether the run `moving` (its first element is the card that
// will touch the destination) may legally land on `to`.
func (g *Game) canPlace(moving []Card, to Pile) bool {
if len(moving) == 0 {
return false
}
bottom := moving[0]
switch to.Kind {
case Foundation:
if len(moving) != 1 {
return false
}
f := g.found[to.Index]
if len(f) == 0 {
return bottom.Rank == 1 // an Ace starts a foundation
}
t := f[len(f)-1]
return bottom.Suit == t.Suit && bottom.Rank == t.Rank+1
case Tableau:
d := g.tab[to.Index]
if len(d) == 0 {
return bottom.Rank == 13 // only a King may start an empty column
}
t := d[len(d)-1]
return t.Up && t.Suit.Red() != bottom.Suit.Red() && bottom.Rank == t.Rank-1
default:
return false // nothing may be placed onto the stock or waste
}
}
// CanMove reports whether the cards from fromIdx to the top of `from` may move
// onto `to`.
func (g *Game) CanMove(from Pile, fromIdx int, to Pile) bool {
src := g.pile(from)
if src == nil || from.Kind == Stock || from == to {
return false
}
if fromIdx < 0 || fromIdx >= len(*src) {
return false
}
// Only a tableau exposes a multi-card run; elsewhere only the top card moves.
if from.Kind != Tableau && fromIdx != len(*src)-1 {
return false
}
moving := (*src)[fromIdx:]
for _, c := range moving {
if !c.Up {
return false
}
}
return g.canPlace(moving, to)
}
// Move applies the move if legal (flipping a newly exposed source card face up)
// and returns whether it did.
func (g *Game) Move(from Pile, fromIdx int, to Pile) bool {
if !g.CanMove(from, fromIdx, to) {
return false
}
src := g.pile(from)
dst := g.pile(to)
moving := (*src)[fromIdx:]
n := len(moving)
run := make([]Card, n)
copy(run, moving)
*dst = append(*dst, run...)
*src = (*src)[:fromIdx]
flipped := false
if from.Kind == Tableau && len(*src) > 0 && !(*src)[len(*src)-1].Up {
(*src)[len(*src)-1].Up = true
flipped = true
}
g.history = append(g.history, Move{From: from, To: to, Count: n, Flipped: flipped})
return true
}
// AutoToFoundation moves the top card of p onto a legal foundation if one
// accepts it. This is the single-tap / double-click convenience.
func (g *Game) AutoToFoundation(p Pile) bool {
src := g.pile(p)
if src == nil || p.Kind == Stock || len(*src) == 0 {
return false
}
idx := len(*src) - 1
if !(*src)[idx].Up {
return false
}
card := (*src)[idx]
for i := range 4 {
if g.canPlace([]Card{card}, Pile{Foundation, i}) {
return g.Move(p, idx, Pile{Foundation, i})
}
}
return false
}
// Undo reverses the most recent move exactly, including re-hiding a card that
// the move had flipped up — the classic Klondike undo bug, made explicit by the
// Move.Flipped bit. Reports whether there was anything to undo.
func (g *Game) Undo() bool {
if len(g.history) == 0 {
return false
}
m := g.history[len(g.history)-1]
g.history = g.history[:len(g.history)-1]
switch {
case m.Recycle:
// The recycle emptied the waste into the stock; put it all back.
for _, c := range slices.Backward(g.stock) {
c.Up = true
g.waste = append(g.waste, c)
}
g.stock = g.stock[:0]
case m.Draw > 0:
for i := 0; i < m.Draw; i++ {
c := g.waste[len(g.waste)-1]
g.waste = g.waste[:len(g.waste)-1]
c.Up = false
g.stock = append(g.stock, c)
}
default:
src := g.pile(m.From)
dst := g.pile(m.To)
if m.Flipped && len(*src) > 0 {
(*src)[len(*src)-1].Up = false // re-hide before the run lands back on it
}
moving := (*dst)[len(*dst)-m.Count:]
run := make([]Card, m.Count)
copy(run, moving)
*dst = (*dst)[:len(*dst)-m.Count]
*src = append(*src, run...)
}
return true
}
// Won reports whether all 52 cards have reached the foundations.
func (g *Game) Won() bool {
n := 0
for i := range 4 {
n += len(g.found[i])
}
return n == 52
}
// Action is a concrete legal move (source run → destination).
type Action struct {
From Pile
FromIdx int
To Pile
}
// LegalActions lists every card move available right now (not including drawing
// from the stock). Used by hints, the auto-player, and the fuzz test.
func (g *Game) LegalActions() []Action {
var out []Action
dests := make([]Pile, 0, 11)
for i := range 4 {
dests = append(dests, Pile{Foundation, i})
}
for i := range 7 {
dests = append(dests, Pile{Tableau, i})
}
consider := func(from Pile, idx int) {
for _, to := range dests {
if g.CanMove(from, idx, to) {
out = append(out, Action{from, idx, to})
}
}
}
if len(g.waste) > 0 {
consider(Pile{Waste, 0}, len(g.waste)-1)
}
for i := range 4 {
if len(g.found[i]) > 0 {
consider(Pile{Foundation, i}, len(g.found[i])-1)
}
}
for i := range 7 {
t := g.tab[i]
for j := range t {
if t[j].Up {
consider(Pile{Tableau, i}, j)
}
}
}
return out
}
klondike/snapshot.go
package klondike
// Snapshot is a serializable capture of a game — plain exported data (all
// fields JSON-safe), the basis for save/resume. Card, Pile and Move are already
// exported, so a Snapshot round-trips through encoding/json unchanged.
type Snapshot struct {
Stock []Card
Waste []Card
Foundations [4][]Card
Tableaus [7][]Card
History []Move
DrawN int
}
// Save captures the current game as a Snapshot (deep-copied, so later play does
// not mutate it).
func (g *Game) Save() Snapshot {
s := Snapshot{DrawN: g.drawN,
Stock: append([]Card(nil), g.stock...),
Waste: append([]Card(nil), g.waste...)}
for i := range g.found {
s.Foundations[i] = append([]Card(nil), g.found[i]...)
}
for i := range g.tab {
s.Tableaus[i] = append([]Card(nil), g.tab[i]...)
}
s.History = append([]Move(nil), g.history...)
return s
}
// Restore rebuilds a game from a Snapshot.
func Restore(s Snapshot) *Game {
g := &Game{drawN: s.DrawN}
if g.drawN != 3 {
g.drawN = 1
}
g.stock = append([]Card(nil), s.Stock...)
g.waste = append([]Card(nil), s.Waste...)
for i := range s.Foundations {
g.found[i] = append([]Card(nil), s.Foundations[i]...)
}
for i := range s.Tableaus {
g.tab[i] = append([]Card(nil), s.Tableaus[i]...)
}
g.history = append([]Move(nil), s.History...)
return g
}
// CardTotal is the number of cards currently in play across every pile — 52 for
// any valid game, so a loader can reject a corrupt or truncated save.
func (g *Game) CardTotal() int {
n := len(g.stock) + len(g.waste)
for i := range g.found {
n += len(g.found[i])
}
for i := range g.tab {
n += len(g.tab[i])
}
return n
}
solitaire.go
package main
import (
"encoding/json"
"math/rand"
"time"
"github.com/doug/gophics/anim"
"github.com/doug/gophics/examples/solitaire/klondike"
"github.com/doug/gophics/geom"
"github.com/doug/gophics/layout"
"github.com/doug/gophics/paint"
"github.com/doug/gophics/widget"
)
// Solitaire is the root widget: a full-screen Klondike board. Seed makes the
// deal deterministic (tests pass a fixed value; the command uses the clock).
type Solitaire struct{ Seed int64 }
func (Solitaire) CreateState() widget.State { return &gameState{} }
// stateHook lets tests observe the mounted game state.
var stateHook func(*gameState)
type gameState struct {
widget.StateBase[Solitaire]
ctx widget.Ctx
g *klondike.Game
store store
board Board
deal int64 // current deal's seed; bumped by New game
won bool
// Press/drag transient state.
pressHit klondike.Pile
pressIdx int
pressOK bool
pressStart geom.Pt
dragging bool
dragPile klondike.Pile
dragIdx int
dragCards []klondike.Card
grabOff geom.Pt // pointer offset within the grabbed top card
pointer geom.Pt // live pointer during a drag
// Snap-back: an illegal drop glides the run home instead of vanishing.
snapping bool
snapCtrl *anim.Controller
snapFrom, snapTo geom.Pt
// Deal: a fresh game flies its tableau cards in from the stock, staggered.
dealing bool
dealCtrl *anim.Controller
// Win cascade: on a win, the foundation cards fountain off and bounce down
// the felt, leaving streaks — the classic finale.
size geom.Size // last drawn surface size (physics bounds)
cascading bool
cascadeTick *cascadeAnim
cascade []fallCard // cards currently in flight
stamps []stamp // trail left behind (bounded)
launch []launchItem // cards waiting to fountain, top-first
launchT float32 // countdown to the next launch
rng *rand.Rand
}
// fallCard is a bouncing card during the win cascade.
type fallCard struct {
card klondike.Card
pos, vel geom.Pt
}
// stamp is one frame of a card's trail, drawn cheaply so streaks are affordable.
type stamp struct {
card klondike.Card
pos geom.Pt
}
// launchItem is a foundation card queued to fountain, with its source pile.
type launchItem struct {
card klondike.Card
found int
}
func (s *gameState) Init(ctx widget.Ctx) {
s.ctx = ctx
s.deal = s.W().Seed
s.store = makeStore(ctx.Preferences())
g, resumed := s.loadOrNew()
s.g = g
s.won = s.g.Won()
s.snapCtrl = &anim.Controller{Duration: 170 * time.Millisecond, Curve: anim.EaseOut, OnChange: func() {
s.SetState(nil)
// Finalize on completion (Value hits 1) — not on the initial Jump(0),
// which also leaves the controller not-Running but at Value 0.
if s.snapping && s.snapCtrl.Value() >= 1 {
s.snapping, s.dragCards = false, nil
}
}}
ctx.AddTicker(s.snapCtrl)
s.dealCtrl = &anim.Controller{Duration: 650 * time.Millisecond, Curve: anim.Linear, OnChange: func() {
s.SetState(nil)
if s.dealCtrl.Value() >= 1 {
s.dealing = false
}
}}
ctx.AddTicker(s.dealCtrl)
s.rng = rand.New(rand.NewSource(s.deal + 1))
s.cascadeTick = &cascadeAnim{s}
ctx.AddTicker(s.cascadeTick)
if !resumed {
s.startDeal() // animate a fresh deal, but not a resumed game
}
if stateHook != nil {
stateHook(s)
}
}
func (s *gameState) Dispose() {
s.ctx.RemoveTicker(s.snapCtrl)
s.ctx.RemoveTicker(s.dealCtrl)
s.ctx.RemoveTicker(s.cascadeTick)
}
func (s *gameState) startDeal() {
s.dealing = true
s.dealCtrl.Jump(0)
s.dealCtrl.Forward()
s.ctx.Invalidate()
}
// maybeWin refreshes the win flag and kicks the cascade off exactly on the
// losing→won transition (and cancels it if an undo takes the win back).
func (s *gameState) maybeWin() {
won := s.g.Won()
switch {
case won && !s.won:
s.startCascade()
case !won:
s.stopCascade()
}
s.won = won
}
// startCascade queues every foundation card (top of each pile first, dealt
// round-robin across suits) to fountain off and bounce down the felt.
func (s *gameState) startCascade() {
s.cascading = true
s.cascade, s.stamps, s.launch, s.launchT = nil, nil, nil, 0
maxLen := 0
for i := range 4 {
if l := len(s.g.Foundation(i)); l > maxLen {
maxLen = l
}
}
for row := 0; row < maxLen; row++ {
for i := range 4 {
f := s.g.Foundation(i)
if idx := len(f) - 1 - row; idx >= 0 {
s.launch = append(s.launch, launchItem{f[idx], i})
}
}
}
s.ctx.Invalidate()
}
func (s *gameState) stopCascade() {
s.cascading = false
s.cascade, s.stamps, s.launch = nil, nil, nil
}
// stepCascade advances the cascade physics by dt seconds: launch the next card
// on a fixed cadence, integrate gravity + floor bounce, and record a trail.
func (s *gameState) stepCascade(dt float32) {
if s.size.W == 0 {
return // no frame drawn yet — no bounds to bounce within
}
if dt > 0.05 {
dt = 0.05 // clamp long stalls so the integration stays stable
}
for s.launchT -= dt; s.launchT <= 0 && len(s.launch) > 0; s.launchT += 0.11 {
it := s.launch[0]
s.launch = s.launch[1:]
vx := (s.rng.Float32()*2 - 1) // [-1,1]
if vx > -0.4 && vx < 0.4 { // ensure a decent sideways throw
if vx < 0 {
vx -= 0.4
} else {
vx += 0.4
}
}
s.cascade = append(s.cascade, fallCard{
card: it.card,
pos: s.board.Foundations[it.found].Min,
vel: geom.Pt{X: vx * 340, Y: -(220 + s.rng.Float32()*180)},
})
}
const gravity = 2100
floor := s.size.H - s.board.CardH
alive := s.cascade[:0]
for _, fc := range s.cascade {
fc.vel.Y += gravity * dt
fc.pos.X += fc.vel.X * dt
fc.pos.Y += fc.vel.Y * dt
if fc.pos.Y >= floor {
fc.pos.Y = floor
if fc.vel.Y = -fc.vel.Y * 0.78; fc.vel.Y > -90 {
fc.vel.Y = 0 // too slow to rebound — slide off along the floor
}
}
s.stamps = append(s.stamps, stamp{fc.card, fc.pos})
if fc.pos.X > -s.board.CardW && fc.pos.X < s.size.W {
alive = append(alive, fc)
}
}
s.cascade = alive
if n := len(s.stamps); n > 900 { // bound the trail (perf)
s.stamps = append(s.stamps[:0], s.stamps[n-900:]...)
}
if len(s.cascade) == 0 && len(s.launch) == 0 {
s.cascading = false
}
}
// cascadeAnim drives the win cascade's per-frame physics.
type cascadeAnim struct{ s *gameState }
func (a *cascadeAnim) Tick(dt float64) bool {
if !a.s.cascading {
return false
}
a.s.stepCascade(float32(dt))
a.s.SetState(nil)
a.s.ctx.Invalidate()
return a.s.cascading
}
// loadOrNew resumes the saved game (resumed=true), or deals a fresh one if
// there's no valid save.
func (s *gameState) loadOrNew() (g *klondike.Game, resumed bool) {
if s.store != nil {
if data, ok := s.store.load(); ok {
var snap klondike.Snapshot
if json.Unmarshal(data, &snap) == nil {
if g := klondike.Restore(snap); fullDeck(g) {
return g, true
}
}
}
}
return klondike.New(s.deal, 1), false
}
// persist autosaves the current game (called after every state change).
func (s *gameState) persist() {
if s.store == nil {
return
}
if data, err := json.Marshal(s.g.Save()); err == nil {
s.store.save(data)
}
}
func (s *gameState) Build(ctx widget.Ctx) widget.Widget {
board := widget.Interactive{
Gestures: widget.Gestures{
OnPress: func(p geom.Pt) {
if s.dealing { // ignore board input while the deal animates in
s.pressOK = false
return
}
s.pressHit, s.pressIdx, s.pressOK = s.board.Hit(p)
s.pressStart, s.dragging = p, false
},
OnDrag: func(pos, _ geom.Pt) {
if !s.pressOK {
return
}
if !s.dragging {
if s.snapping || !s.grab(s.pressHit, s.pressIdx, s.pressStart) {
s.pressOK = false
return
}
s.dragging = true
}
s.pointer = pos
s.SetState(nil)
},
OnRelease: func() {
if !s.dragging {
return
}
if s.tryDrop() {
s.dragging, s.dragCards = false, nil
s.maybeWin()
s.persist()
} else {
s.startSnapBack()
}
s.SetState(nil)
},
OnTap: func() {
if !s.pressOK || s.dragging {
return
}
switch s.pressHit.Kind {
case klondike.Stock:
s.g.Draw()
case klondike.Waste, klondike.Tableau, klondike.Foundation:
s.g.AutoToFoundation(s.pressHit)
}
s.maybeWin()
s.persist()
s.SetState(nil)
},
},
Child: widget.Canvas{Clip: true, Draw: s.draw},
}
// The board fills the window (so board coordinates are window coordinates);
// the controls float over the felt at the bottom-right, on top of it.
var items []widget.Widget
if s.g.CanAutoComplete() {
items = append(items, chip("Finish", s.finish), widget.Sized{W: 8})
}
items = append(items, chip("Undo", s.undo), widget.Sized{W: 8}, chip("New", s.newGame))
controls := widget.Row(items...)
controls.CrossAlign = layout.CrossCenter
return widget.Stack{Children: []widget.Widget{
board,
widget.Align{X: 1, Y: 1, Child: widget.Padding{All: 14, Child: controls}},
}}
}
func chip(label string, onTap func()) widget.Widget {
return widget.Interactive{
Gestures: widget.Gestures{OnTap: onTap},
Child: widget.Decorated{Color: colBack2, Radius: 8, Child: widget.Padding{
Insets: geom.InsetsSymmetric(14, 7),
Child: widget.Text{Value: label, Size: 14, Color: colFace},
}},
}
}
func (s *gameState) undo() {
s.cancelInteraction()
s.g.Undo()
s.maybeWin()
s.persist()
s.SetState(nil)
}
func (s *gameState) newGame() {
s.cancelInteraction()
s.deal++
s.g = klondike.New(s.deal, 1)
s.stopCascade()
s.won = false
s.persist()
s.startDeal()
s.SetState(nil)
}
// finish auto-plays the rest of the game to the foundations (shown only when
// s.g.CanAutoComplete()).
func (s *gameState) finish() {
s.cancelInteraction()
s.g.AutoComplete()
s.maybeWin()
s.persist()
s.SetState(nil)
}
func (s *gameState) cancelInteraction() {
s.dragging, s.snapping, s.dragCards, s.pressOK = false, false, nil, false
s.snapCtrl.Jump(0)
}
// grab sets up the run being dragged from pile at idx, or returns false.
func (s *gameState) grab(pile klondike.Pile, idx int, p geom.Pt) bool {
switch pile.Kind {
case klondike.Waste:
w := s.g.Waste()
if len(w) == 0 {
return false
}
s.dragPile, s.dragIdx = pile, len(w)-1
s.dragCards = []klondike.Card{w[len(w)-1]}
s.grabOff = p.Sub(s.board.Waste.Min)
return true
case klondike.Foundation:
f := s.g.Foundation(pile.Index)
if len(f) == 0 {
return false
}
s.dragPile, s.dragIdx = pile, len(f)-1
s.dragCards = []klondike.Card{f[len(f)-1]}
s.grabOff = p.Sub(s.board.Foundations[pile.Index].Min)
return true
case klondike.Tableau:
col := s.g.Tableau(pile.Index)
if idx < 0 || idx >= len(col) || !col[idx].Up {
return false
}
s.dragPile, s.dragIdx = pile, idx
s.dragCards = append([]klondike.Card(nil), col[idx:]...)
s.grabOff = p.Sub(s.board.Tableaus[pile.Index][idx].Min)
return true
}
return false
}
// tryDrop lands the dragged run on the legal target it overlaps most and reports
// whether it moved (false → the caller snaps it back).
func (s *gameState) tryDrop() bool {
topRect := geom.RectXYWH(s.pointer.X-s.grabOff.X, s.pointer.Y-s.grabOff.Y, s.board.CardW, s.board.CardH)
best := -1
var bestArea float32
targets := s.board.DropTargets(s.g)
for i, t := range targets {
if !s.g.CanMove(s.dragPile, s.dragIdx, t.Pile) {
continue
}
if a := overlapArea(topRect, t.Rect); a > bestArea {
bestArea, best = a, i
}
}
if best >= 0 && bestArea > 0 {
return s.g.Move(s.dragPile, s.dragIdx, targets[best].Pile)
}
return false
}
// startSnapBack animates the dragged run from the release point back to where it
// was grabbed, then clears it (the game was never mutated).
func (s *gameState) startSnapBack() {
s.snapFrom = geom.Pt{X: s.pointer.X - s.grabOff.X, Y: s.pointer.Y - s.grabOff.Y}
s.snapTo = s.sourceTop()
s.dragging, s.snapping = false, true
s.snapCtrl.Jump(0)
s.snapCtrl.Forward()
s.ctx.Invalidate()
}
func (s *gameState) sourceTop() geom.Pt {
switch s.dragPile.Kind {
case klondike.Waste:
return s.board.Waste.Min
case klondike.Foundation:
return s.board.Foundations[s.dragPile.Index].Min
case klondike.Tableau:
return s.board.Tableaus[s.dragPile.Index][s.dragIdx].Min
}
return geom.Pt{}
}
// hidingRun reports whether the source cards of the active run should be hidden
// (they are being dragged or snapped back and drawn as an overlay).
func (s *gameState) hidingRun() bool { return s.dragging || s.snapping }
func (s *gameState) draw(c paint.Canvas, size geom.Size) {
s.board = Layout(size, s.g)
s.size = size
b := s.board
// A subtle felt gradient (lighter top → darker bottom) for depth.
c.FillRRectGradient(geom.RectXYWH(0, 0, size.W, size.H), 0, colFeltHi, colFeltLo, false)
if len(s.g.Stock()) > 0 {
drawCard(c, b.Stock, klondike.Card{})
} else {
drawEmpty(c, b.Stock)
}
w := s.g.Waste()
if len(w) > 0 && !(s.hidingRun() && s.dragPile.Kind == klondike.Waste) {
drawCard(c, b.Waste, w[len(w)-1])
} else {
drawEmpty(c, b.Waste)
}
for i := range 4 {
f := s.g.Foundation(i)
hiding := s.hidingRun() && s.dragPile.Kind == klondike.Foundation && s.dragPile.Index == i
if len(f) > 0 && !hiding {
drawCard(c, b.Foundations[i], f[len(f)-1])
} else {
drawEmpty(c, b.Foundations[i])
}
}
total := 0
for j := range 7 {
total += len(s.g.Tableau(j))
}
di := 0
for j := range 7 {
col := s.g.Tableau(j)
if len(col) == 0 {
drawEmpty(c, b.Slot[j])
}
for k := range col {
idx := di
di++
if s.hidingRun() && s.dragPile.Kind == klondike.Tableau && s.dragPile.Index == j && k >= s.dragIdx {
break // being dragged / snapped
}
if s.dealing {
if lt, flying := dealProgress(s.dealCtrl.Value(), idx, total); lt < 0 {
continue // still in the deck (drawn as the stock back)
} else if flying {
x := b.Stock.Min.X + (b.Tableaus[j][k].Min.X-b.Stock.Min.X)*lt
y := b.Stock.Min.Y + (b.Tableaus[j][k].Min.Y-b.Stock.Min.Y)*lt
drawCard(c, geom.RectXYWH(x, y, b.CardW, b.CardH), col[k])
continue
}
}
// A card with another on top of it shows only a strip, so it is
// drawn without the back's inset frame -- see drawCardFanned.
if k < len(col)-1 {
drawCardFanned(c, b.Tableaus[j][k], col[k])
} else {
drawCard(c, b.Tableaus[j][k], col[k])
}
}
}
// The active run (dragged, or gliding home), on top.
if rx, ry, ok := s.runOrigin(); ok {
fan := b.CardH * 0.30
for i, card := range s.dragCards {
drawCard(c, geom.RectXYWH(rx, ry+float32(i)*fan, b.CardW, b.CardH), card)
}
}
// Win cascade: the trail streaks under the live bouncing cards.
for _, st := range s.stamps {
drawStamp(c, geom.RectXYWH(st.pos.X, st.pos.Y, b.CardW, b.CardH), st.card)
}
for _, fc := range s.cascade {
drawCard(c, geom.RectXYWH(fc.pos.X, fc.pos.Y, b.CardW, b.CardH), fc.card)
}
// The banner lands once the cascade has played out.
if s.won && !s.cascading {
c.FillRRect(geom.RectXYWH(size.W*0.5-size.W*0.22, size.H*0.42, size.W*0.44, size.H*0.14), 16, colFeltHi)
c.TextIn("bold", "You win!", geom.Pt{X: size.W*0.5 - size.W*0.16, Y: size.H * 0.52}, size.W*0.08, colFace)
}
}
// dealProgress maps the global deal timeline t (0..1) to card idx's flight:
// lt < 0 means still in the deck, 0..1 means in flight, and flying is true only
// during that window (lt >= 1 means arrived — draw it at its final spot).
func dealProgress(t float32, idx, total int) (lt float32, flying bool) {
if total < 1 {
total = 1
}
const fly = 0.5
lt = (t - float32(idx)*(0.5/float32(total))) / fly
switch {
case lt < 0:
return lt, false
case lt >= 1:
return 1, false
default:
return lt, true
}
}
// runOrigin returns the top-left of the active run and whether one is showing.
func (s *gameState) runOrigin() (float32, float32, bool) {
switch {
case s.dragging:
return s.pointer.X - s.grabOff.X, s.pointer.Y - s.grabOff.Y, true
case s.snapping:
t := s.snapCtrl.Value()
return s.snapFrom.X + (s.snapTo.X-s.snapFrom.X)*t, s.snapFrom.Y + (s.snapTo.Y-s.snapFrom.Y)*t, true
}
return 0, 0, false
}
store.go
package main
import (
"github.com/doug/gophics/examples/solitaire/klondike"
"github.com/doug/gophics/shell"
)
// store persists the current game between runs.
//
// There is no platform split here, and that is the point. This used to be two
// build-tagged files — localStorage on web, a JSON file under the user's config
// directory on desktop — which is precisely what shell.Preferences already is
// on every platform gophics runs on, mobile included. An app writing that split
// itself is reimplementing a capability the framework ships.
//
// makeStore is a var so tests can substitute an in-memory slot.
type store interface {
save(data []byte)
load() ([]byte, bool)
}
var makeStore = newPrefsStore
// prefKey names this game's save. The app name is part of the key because the
// shell's own prefix ("gophics.pref.") namespaces the framework, not the app,
// and every demo on gophics.com shares one origin and therefore one
// localStorage.
const prefKey = "solitaire.game"
// prefsStore autosaves the game through the Preferences capability.
type prefsStore struct{ p shell.Preferences }
// newPrefsStore returns nil where the platform has no preference store — a
// sandboxed browser context, say. Callers already treat a nil store as "do not
// persist", so a game that cannot be saved still deals and plays.
func newPrefsStore(p shell.Preferences) store {
if p == nil {
return nil
}
return prefsStore{p}
}
func (s prefsStore) save(data []byte) { _ = s.p.Set(prefKey, string(data)) }
func (s prefsStore) load() ([]byte, bool) {
v, ok := s.p.Get(prefKey)
if !ok {
return nil, false
}
return []byte(v), true
}
// fullDeck reports whether g is a complete, non-corrupt game (52 cards) — used
// to reject a bad save and start fresh instead.
func fullDeck(g *klondike.Game) bool { return g.CardTotal() == 52 }