main.go
// Command match3 is a match-3 puzzle on gophics: swap adjacent gems to line up
// three or more; matches clear, gems fall in to fill the gaps, and fresh gems
// drop from the top — chaining into cascades. It's the animation driver
// example: every swap, clear, fall, and cascade runs through anim.Controller
// and the paint path, all procedural (no image assets). Swipe or tap a gem then
// an adjacent one to swap.
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"
"github.com/doug/gophics/sound"
"github.com/doug/gophics/sound/device"
)
func main() {
// Audio is best-effort: if the device won't open, the game runs silent.
mixer := sound.NewMixer()
if closer, err := device.Open(mixer); err != nil {
log.Printf("audio disabled: %v", err)
} else {
defer closer.Close()
}
err := app.Run(Match3{Seed: time.Now().UnixNano(), Sound: mixer}, app.Config{
Title: "Match 3",
Size: geom.Size{W: 480, H: 760},
Background: colBG,
Font: goregular.TTF,
FontFamilies: map[string][]byte{"bold": gobold.TTF},
})
if err != nil {
log.Fatal(err)
}
}
match3.go
package main
import (
"fmt"
"math"
"math/rand"
"time"
"github.com/doug/gophics/anim"
"github.com/doug/gophics/geom"
"github.com/doug/gophics/paint"
"github.com/doug/gophics/sound"
"github.com/doug/gophics/sound/procedural"
"github.com/doug/gophics/theme"
"github.com/doug/gophics/widget"
)
const (
cols = 8
rows = 8
numTypes = 6
)
// Phases of the resolve loop. Input is only accepted in phaseIdle.
const (
phaseIdle = iota
phaseSwap
phaseSwapBack
phaseClear
phaseFall
)
var (
// colBG is the window background at Start, before a widget context exists
// (main passes it as Config.Background). Inside the tree every chrome color
// comes from the active theme (theme.Auto in Build, captured onto the state
// for the ctx-less Canvas draw), so the game follows the platform light/dark
// scheme. This matches the light identity's background.
colBG = theme.Light().Bg
// white is the gem gloss + colorblind glyph ink — part of the signature gem
// look, not chrome, so it stays fixed across themes.
white = paint.RGB(1, 1, 1)
)
// gem is a type's fill (dark→light gradient) plus a symbol id for colorblind
// distinctness (the inner white glyph shape).
type gem struct {
base, light paint.Color
sym int
}
var gems = [numTypes]gem{
{paint.RGB(0.86, 0.22, 0.30), paint.RGB(0.98, 0.42, 0.48), 0}, // red — circle
{paint.RGB(0.95, 0.55, 0.18), paint.RGB(1.00, 0.74, 0.38), 1}, // orange — square
{paint.RGB(0.95, 0.82, 0.25), paint.RGB(1.00, 0.93, 0.50), 2}, // yellow — diamond
{paint.RGB(0.30, 0.78, 0.45), paint.RGB(0.48, 0.92, 0.62), 3}, // green — triangle
{paint.RGB(0.28, 0.62, 0.95), paint.RGB(0.50, 0.80, 1.00), 4}, // blue — ring
{paint.RGB(0.66, 0.44, 0.95), paint.RGB(0.82, 0.64, 1.00), 5}, // purple — plus
}
type cell struct{ r, c int }
var noCell = cell{-1, -1}
func (c cell) ok() bool { return c.r >= 0 && c.r < rows && c.c >= 0 && c.c < cols }
func adjacent(a, b cell) bool {
dr, dc := abs(a.r-b.r), abs(a.c-b.c)
return dr+dc == 1
}
// Match3 is the game widget; Seed makes a run reproducible, Sound is optional.
type Match3 struct {
Seed int64
Sound *sound.Mixer
}
func (Match3) CreateState() widget.State { return &game{} }
type game struct {
widget.StateBase[Match3]
ctx widget.Ctx
rng *rand.Rand
snd *sound.Mixer
pool map[string]*sound.Sample
grid [rows][cols]int8 // gem type, -1 = empty
phase int
ctrl *anim.Controller
swapA, swapB cell // the pair in flight (phaseSwap/phaseSwapBack)
swapValid bool
clearing [rows][cols]bool // gems shrinking out (phaseClear)
fallFrom [rows][cols]int8 // a gem's start row for the fall (phaseFall); <0 = spawned above
sel cell // tap-selected gem, or noCell
press cell // gem under the current pointer-down
swiped bool // a swipe already fired this gesture
pressAt geom.Pt
score int
chain int
moves int
size geom.Size
// th is the active theme, refreshed each Build. The Canvas draw closure has
// no ctx, so chrome colors are read from here (th.Bg/Surface/Text/Muted/
// Primary); the gem palette stays the game's own signature colors.
th theme.Theme
}
func (s *game) Init(ctx widget.Ctx) {
s.ctx = ctx
s.rng = rand.New(rand.NewSource(s.W().Seed))
s.snd = s.W().Sound
s.sel = noCell
s.pool = map[string]*sound.Sample{
"swap": procedural.Blip(520, 0.05),
"bad": procedural.Thud(),
"clear": procedural.Coin(),
"cascade": procedural.Blip(880, 0.06),
}
s.ctrl = &anim.Controller{Curve: anim.EaseInOut, OnChange: func() {
s.SetState(nil)
if !s.ctrl.Running() && s.ctrl.Value() >= 1 {
s.advance()
}
}}
ctx.AddTicker(s.ctrl)
s.newBoard()
}
func (s *game) Dispose() { s.ctx.RemoveTicker(s.ctrl) }
func (s *game) Build(ctx widget.Ctx) widget.Widget {
// Resolve the platform theme and provide it to the tree; also capture it on
// the state so the ctx-less Canvas draw closure can read chrome colors.
s.th = theme.Auto(ctx)
board := widget.Interactive{
Gestures: widget.Gestures{
OnPress: s.onPress,
OnDrag: s.onDrag,
OnRelease: s.onRelease,
},
Child: widget.Canvas{Clip: true, Draw: s.draw},
}
return widget.Provide[theme.Theme]{
Value: s.th,
Child: widget.Fill{Color: s.th.Bg, Child: board},
}
}
// --- board setup ---
// newBoard fills the grid with no pre-existing matches (a fair start).
func (s *game) newBoard() {
for r := range rows {
for c := range cols {
for {
t := int8(s.rng.Intn(numTypes))
// Reject a type that would already make a run of three.
if c >= 2 && s.grid[r][c-1] == t && s.grid[r][c-2] == t {
continue
}
if r >= 2 && s.grid[r-1][c] == t && s.grid[r-2][c] == t {
continue
}
s.grid[r][c] = t
break
}
}
}
s.phase = phaseIdle
s.ctx.Invalidate()
}
// --- input ---
func (s *game) onPress(p geom.Pt) {
if s.phase != phaseIdle {
return
}
s.press = s.cellAt(p)
s.pressAt = p
s.swiped = false
if !s.press.ok() {
return
}
if s.sel.ok() && adjacent(s.sel, s.press) {
s.trySwap(s.sel, s.press)
s.sel = noCell
} else if s.sel == s.press {
s.sel = noCell // tap again to deselect
} else {
s.sel = s.press
}
s.ctx.Invalidate()
}
func (s *game) onDrag(pos, _ geom.Pt) {
if s.phase != phaseIdle || s.swiped || !s.press.ok() {
return
}
d := geom.Pt{X: pos.X - s.pressAt.X, Y: pos.Y - s.pressAt.Y}
cellPx := s.layout().cell
if math.Hypot(float64(d.X), float64(d.Y)) < float64(cellPx)*0.4 {
return // below the swipe threshold
}
// Swap with the neighbor in the dominant swipe direction.
n := s.press
if abs2(d.X) > abs2(d.Y) {
if d.X > 0 {
n.c++
} else {
n.c--
}
} else {
if d.Y > 0 {
n.r++
} else {
n.r--
}
}
if n.ok() {
s.swiped = true
s.sel = noCell
s.trySwap(s.press, n)
}
}
func (s *game) onRelease() { s.press = noCell }
// --- resolve loop ---
func (s *game) trySwap(a, b cell) {
s.swapA, s.swapB = a, b
// Peek: is the swap a match? (grid is committed only on completion.)
s.grid[a.r][a.c], s.grid[b.r][b.c] = s.grid[b.r][b.c], s.grid[a.r][a.c]
s.swapValid = s.hasMatch()
s.grid[a.r][a.c], s.grid[b.r][b.c] = s.grid[b.r][b.c], s.grid[a.r][a.c]
s.moves++
s.play("swap", a.c)
s.start(phaseSwap, 140*time.Millisecond)
}
// advance runs at the end of each animated phase.
func (s *game) advance() {
switch s.phase {
case phaseSwap:
if s.swapValid {
a, b := s.swapA, s.swapB
s.grid[a.r][a.c], s.grid[b.r][b.c] = s.grid[b.r][b.c], s.grid[a.r][a.c]
s.chain = 0
s.beginClear()
} else {
s.play("bad", s.swapA.c)
s.start(phaseSwapBack, 140*time.Millisecond)
}
case phaseSwapBack:
s.phase = phaseIdle
s.ctx.Invalidate()
case phaseClear:
for r := range rows {
for c := range cols {
if s.clearing[r][c] {
s.grid[r][c] = -1
}
}
}
s.beginFall()
case phaseFall:
if s.hasMatch() {
s.chain++
s.beginClear()
} else {
s.chain = 0
s.phase = phaseIdle
s.ctx.Invalidate()
}
}
}
// beginClear marks the current matches and animates them shrinking out.
func (s *game) beginClear() {
s.clearing = s.matches()
n := 0
for r := range rows {
for c := range cols {
if s.clearing[r][c] {
n++
}
}
}
mult := s.chain + 1
s.score += n * 10 * mult
if s.chain > 0 {
s.play("cascade", cols/2)
} else {
s.play("clear", cols/2)
}
s.start(phaseClear, 200*time.Millisecond)
}
// beginFall compacts each column and spawns fresh gems above, recording where
// every gem falls from so the animation can slide it into place.
func (s *game) beginFall() {
var next [rows][cols]int8
for c := range cols {
w := rows - 1
for r := rows - 1; r >= 0; r-- {
if s.grid[r][c] != -1 {
next[w][c] = s.grid[r][c]
s.fallFrom[w][c] = int8(r)
w--
}
}
spawn := int8(-1)
for ; w >= 0; w-- {
next[w][c] = int8(s.rng.Intn(numTypes))
s.fallFrom[w][c] = spawn // above the top edge
spawn--
}
}
s.grid = next
// Distance-scaled duration so tall drops don't feel instant.
s.start(phaseFall, 260*time.Millisecond)
}
func (s *game) start(phase int, d time.Duration) {
s.phase = phase
s.ctrl.Duration = d
s.ctrl.Jump(0)
s.ctrl.Forward()
s.ctx.Invalidate()
}
// --- match detection ---
func (s *game) hasMatch() bool {
m := s.matches()
for r := range rows {
for c := range cols {
if m[r][c] {
return true
}
}
}
return false
}
// matches returns the mask of gems that are part of a run of 3+ (rows or cols).
func (s *game) matches() [rows][cols]bool {
var m [rows][cols]bool
for r := range rows {
run := 1
for c := 1; c <= cols; c++ {
if c < cols && s.grid[r][c] != -1 && s.grid[r][c] == s.grid[r][c-1] {
run++
} else {
if run >= 3 {
for k := c - run; k < c; k++ {
m[r][k] = true
}
}
run = 1
}
}
}
for c := range cols {
run := 1
for r := 1; r <= rows; r++ {
if r < rows && s.grid[r][c] != -1 && s.grid[r][c] == s.grid[r-1][c] {
run++
} else {
if run >= 3 {
for k := r - run; k < r; k++ {
m[k][c] = true
}
}
run = 1
}
}
}
return m
}
// --- sound ---
func (s *game) play(name string, col int) {
if s.snd == nil {
return
}
smp := s.pool[name]
if smp == nil {
return
}
pan := (float64(col)/float64(cols-1))*2 - 1 // -1 left … +1 right
pitch := 1.0
if name == "cascade" {
pitch = 1 + float64(s.chain)*0.12 // each cascade rings higher
}
s.snd.Play(smp, sound.PlayOptions{Volume: 0.5, Pan: pan, Pitch: pitch})
}
// --- layout + drawing ---
type box struct {
x, y, cell float32
}
func (s *game) layout() box {
const pad = 16
top := float32(96) // score panel
availW := s.size.W - 2*pad
availH := s.size.H - top - pad
cellPx := float32(math.Min(float64(availW)/cols, float64(availH)/rows))
boardW := cellPx * cols
return box{
x: (s.size.W - boardW) / 2,
y: top,
cell: cellPx,
}
}
func (s *game) cellAt(p geom.Pt) cell {
b := s.layout()
if b.cell == 0 {
return noCell
}
// Floor (not int truncation): a tap in the band just left of / above the
// board gives a negative offset, which must map to -1 (off-board), not 0.
c := int(math.Floor(float64((p.X - b.x) / b.cell)))
r := int(math.Floor(float64((p.Y - b.y) / b.cell)))
cl := cell{r, c}
if !cl.ok() {
return noCell
}
return cl
}
func (s *game) draw(c paint.Canvas, size geom.Size) {
s.size = size
b := s.layout()
th := s.th
c.Clear(th.Bg)
// Score panel.
c.TextIn("bold", "MATCH 3", geom.Pt{X: b.x, Y: 40}, 30, th.Text)
c.TextIn("", fmt.Sprintf("Score %d", s.score), geom.Pt{X: b.x, Y: 68}, 16, th.Muted)
moves := fmt.Sprintf("Moves %d", s.moves)
c.TextIn("", moves, geom.Pt{X: b.x + b.cell*cols - s.textW(moves, 16), Y: 68}, 16, th.Muted)
if s.chain > 1 && s.phase == phaseClear {
tag := fmt.Sprintf("x%d CHAIN!", s.chain+1)
c.TextIn("bold", tag, geom.Pt{X: b.x + b.cell*cols - s.textW(tag, 18), Y: 42}, 18, th.Primary)
}
// Board backing — a neutral surface with a hairline border so the board
// reads against the app background in both light and dark schemes.
boardW, boardH := b.cell*cols, b.cell*rows
backing := geom.RectXYWH(b.x-6, b.y-6, boardW+12, boardH+12)
c.FillRRect(backing, 16, th.Surface)
c.StrokeRRect(backing, 16, 1, th.Border)
// Clip gems to the board so those falling in from above stay hidden until
// they cross the top edge (otherwise they'd draw over the score panel).
c.PushClip(geom.RectXYWH(b.x, b.y, boardW, boardH))
p := s.ctrl.Value()
for r := range rows {
for cc := range cols {
t := s.grid[r][cc]
if t < 0 {
continue
}
cx := b.x + float32(cc)*b.cell + b.cell/2
cy := b.y + float32(r)*b.cell + b.cell/2
scale := float32(1)
switch s.phase {
case phaseSwap, phaseSwapBack:
// The two gems glide between their cells (grid is uncommitted,
// so the gem logically at swapA travels to swapB and back).
q := p
if s.phase == phaseSwapBack {
q = 1 - p // return trip
}
if (cell{r, cc}) == s.swapA {
cx, cy = s.lerpCenter(b, s.swapA, s.swapB, q)
} else if (cell{r, cc}) == s.swapB {
cx, cy = s.lerpCenter(b, s.swapB, s.swapA, q)
}
case phaseClear:
if s.clearing[r][cc] {
scale = 1 - p // shrink out
}
case phaseFall:
from := float32(s.fallFrom[r][cc])
vr := geom.LerpFloat(from, float32(r), p)
cy = b.y + vr*b.cell + b.cell/2
}
drawGem(c, cx, cy, b.cell*0.9*scale, gems[t])
}
}
c.PopClip()
// Selection ring.
if s.sel.ok() && s.phase == phaseIdle {
x := b.x + float32(s.sel.c)*b.cell
y := b.y + float32(s.sel.r)*b.cell
c.StrokeRRect(geom.RectXYWH(x+3, y+3, b.cell-6, b.cell-6), b.cell*0.28, 3, th.Primary)
}
c.TextIn("", "swipe a gem, or tap two neighbors, to swap",
geom.Pt{X: b.x, Y: b.y + b.cell*rows + 26}, 13, th.Muted)
}
func (s *game) lerpCenter(b box, from, to cell, p float32) (x, y float32) {
fx := b.x + float32(from.c)*b.cell + b.cell/2
fy := b.y + float32(from.r)*b.cell + b.cell/2
tx := b.x + float32(to.c)*b.cell + b.cell/2
ty := b.y + float32(to.r)*b.cell + b.cell/2
return geom.LerpFloat(fx, tx, p), geom.LerpFloat(fy, ty, p)
}
// drawGem renders one gem centered at (cx,cy) with side sz: a rounded, top-lit
// body, a gloss highlight, and a distinct white symbol.
func drawGem(c paint.Canvas, cx, cy, sz float32, g gem) {
if sz < 1 {
return
}
h := sz / 2
rad := sz * 0.28
r := geom.RectXYWH(cx-h, cy-h, sz, sz)
c.FillRRectGradient(r, rad, g.light, g.base, false)
// Gloss: a pre-blended highlight streak across the top — a lighter tint of
// the gem rather than a PushOpacity layer. With 64 gems/frame, pre-blending
// avoids allocating a full-surface layer pixmap per gem (a perf win; the
// HiDPI layer-clipping bug this once tripped is fixed in gg).
c.FillRRect(geom.RectXYWH(cx-h*0.66, cy-h*0.7, sz*0.66, sz*0.28), sz*0.14, mix(g.base, white, 0.55))
// Symbol: a distinct white glyph for colorblind-safe identification.
drawSymbol(c, cx, cy, sz*0.34, g.sym)
}
func drawSymbol(c paint.Canvas, cx, cy, s float32, sym int) {
switch sym {
case 0: // circle
c.FillRRect(geom.RectXYWH(cx-s, cy-s, 2*s, 2*s), s, white)
case 1: // square
c.FillRRect(geom.RectXYWH(cx-s*0.85, cy-s*0.85, s*1.7, s*1.7), s*0.2, white)
case 2: // diamond
p := paint.NewPath()
p.MoveTo(geom.Pt{X: cx, Y: cy - s}).LineTo(geom.Pt{X: cx + s, Y: cy}).
LineTo(geom.Pt{X: cx, Y: cy + s}).LineTo(geom.Pt{X: cx - s, Y: cy}).Close()
c.FillPath(p, white)
case 3: // triangle
p := paint.NewPath()
p.MoveTo(geom.Pt{X: cx, Y: cy - s}).LineTo(geom.Pt{X: cx + s, Y: cy + s*0.8}).
LineTo(geom.Pt{X: cx - s, Y: cy + s*0.8}).Close()
c.FillPath(p, white)
case 4: // ring
c.StrokeRRect(geom.RectXYWH(cx-s*0.85, cy-s*0.85, s*1.7, s*1.7), s*0.85, s*0.5, white)
case 5: // plus
t := s * 0.42
c.FillRRect(geom.RectXYWH(cx-t, cy-s, 2*t, 2*s), t*0.6, white)
c.FillRRect(geom.RectXYWH(cx-s, cy-t, 2*s, 2*t), t*0.6, white)
}
}
// --- small helpers ---
// mix blends a→b by t (0..1), opaque.
func mix(a, b paint.Color, t float32) paint.Color {
return paint.Color{
R: a.R + (b.R-a.R)*t,
G: a.G + (b.G-a.G)*t,
B: a.B + (b.B-a.B)*t,
A: 1,
}
}
func abs(x int) int {
if x < 0 {
return -x
}
return x
}
func abs2(x float32) float32 {
if x < 0 {
return -x
}
return x
}
// textW estimates a string's width for right-alignment (approx, monospace-ish).
func (s *game) textW(str string, size float32) float32 {
return s.ctx.Painter().MeasureWidthIn("", str, size)
}