// Command 2048 is the sliding-tile puzzle: arrow keys or swipe to slide the
// board; equal tiles merge; reach 2048. It is one widget.Canvas driving smooth
// slide + spawn animations off a per-frame Ticker — a compact showcase of the
// paint + input + animation path.
//
// go run ./examples/2048
package main
import (
"fmt"
"log"
"math"
"math/rand"
"golang.org/x/image/font/gofont/goregular"
"github.com/doug/gophics/app"
"github.com/doug/gophics/geom"
"github.com/doug/gophics/paint"
"github.com/doug/gophics/shell"
"github.com/doug/gophics/theme"
"github.com/doug/gophics/widget"
)
const boardN = 4
// textDark/textLite are the tile ink colors — part of the classic 2048 identity,
// consumed only by tileText(). The board CHROME (window, frame, slots, header,
// buttons, chips) is themed and follows the platform light/dark scheme; see draw.
var (
textDark = paint.RGB(0.47, 0.43, 0.39)
textLite = paint.RGB(0.98, 0.96, 0.94)
)
// tileColor is the classic 2048 palette.
func tileColor(v int) paint.Color {
switch v {
case 2:
return paint.RGB(0.93, 0.89, 0.85)
case 4:
return paint.RGB(0.93, 0.88, 0.78)
case 8:
return paint.RGB(0.95, 0.69, 0.47)
case 16:
return paint.RGB(0.96, 0.58, 0.39)
case 32:
return paint.RGB(0.96, 0.49, 0.37)
case 64:
return paint.RGB(0.96, 0.37, 0.23)
case 128:
return paint.RGB(0.93, 0.81, 0.45)
case 256:
return paint.RGB(0.93, 0.80, 0.38)
case 512:
return paint.RGB(0.93, 0.78, 0.31)
case 1024:
return paint.RGB(0.93, 0.77, 0.25)
case 2048:
return paint.RGB(0.93, 0.76, 0.18)
default:
return paint.RGB(0.24, 0.23, 0.20) // beyond 2048
}
}
func tileText(v int) paint.Color {
if v <= 4 {
return textDark
}
return textLite
}
type Game struct{}
func (Game) CreateState() widget.State { return &game{} }
type game struct {
widget.StateBase[Game]
grid [boardN][boardN]int
score, best int
over bool
anims []mov // active slide animation
sliding bool
slideT float64
spR, spC int // spawned cell for its pop-in
spawning bool
spawnT float64
merges [][2]int // cells that merged this move — they pop after the slide
popping bool
popT float64
swDX, swDY float32 // swipe accumulator
swiped bool
pressPos geom.Pt
newBtn geom.Rect // set during draw, hit-tested on tap
th theme.Theme // chrome theme captured in Build (draw has no ctx)
ctx widget.Ctx
}
// mov is one tile sliding from (fr,fc) to (tr,tc).
type mov struct{ v, fr, fc, tr, tc int }
const (
slideDur = 0.13 // tile glide — long enough to read the motion
spawnDur = 0.16 // new-tile pop-in
popDur = 0.18 // merged-tile bounce
)
// stateHook, if set, receives the game state on mount — for tests to drive and
// inspect input end to end.
var stateHook func(*game)
func (s *game) Init(ctx widget.Ctx) {
s.ctx = ctx
ctx.AddTicker(s)
s.reset()
if stateHook != nil {
stateHook(s)
}
}
func (s *game) reset() {
s.grid = [boardN][boardN]int{}
s.score, s.over = 0, false
s.anims, s.sliding, s.spawning, s.popping = nil, false, false, false
s.merges = nil
s.addRandomTile()
s.addRandomTile()
}
func (s *game) addRandomTile() {
var empty [][2]int
for r := range boardN {
for c := range boardN {
if s.grid[r][c] == 0 {
empty = append(empty, [2]int{r, c})
}
}
}
if len(empty) == 0 {
return
}
cell := empty[rand.Intn(len(empty))]
v := 2
if rand.Intn(10) == 0 {
v = 4
}
s.grid[cell[0]][cell[1]] = v
s.spR, s.spC = cell[0], cell[1]
s.spawning, s.spawnT = true, 0
}
// Tick advances the slide/spawn animations; it idles (returns false) when
// nothing is animating, and a keypress/swipe re-arms it via SetState.
func (s *game) Tick(dt float64) bool {
if s.sliding {
if s.slideT += dt / slideDur; s.slideT >= 1 {
s.slideT, s.sliding = 1, false
s.afterSlide()
}
s.ctx.Invalidate()
}
if s.spawning {
if s.spawnT += dt / spawnDur; s.spawnT >= 1 {
s.spawnT, s.spawning = 1, false
}
s.ctx.Invalidate()
}
if s.popping {
if s.popT += dt / popDur; s.popT >= 1 {
s.popT, s.popping = 1, false
}
s.ctx.Invalidate()
}
return s.sliding || s.spawning || s.popping
}
func (s *game) afterSlide() {
s.anims = nil
if len(s.merges) > 0 { // the tiles that combined bounce once
s.popping, s.popT = true, 0
}
s.addRandomTile()
if !s.movesAvailable() {
s.over = true
}
}
func (s *game) movesAvailable() bool {
for r := range boardN {
for c := range boardN {
if s.grid[r][c] == 0 {
return true
}
if c+1 < boardN && s.grid[r][c] == s.grid[r][c+1] {
return true
}
if r+1 < boardN && s.grid[r][c] == s.grid[r+1][c] {
return true
}
}
}
return false
}
// lineCoords returns the four lines for a direction, each ordered front-first
// (the edge tiles pile against). dir: 0 left, 1 right, 2 up, 3 down.
func lineCoords(dir int) [boardN][boardN][2]int {
var out [boardN][boardN][2]int
for i := range boardN {
for j := range boardN {
var r, c int
switch dir {
case 0:
r, c = i, j
case 1:
r, c = i, boardN-1-j
case 2:
r, c = j, i
case 3:
r, c = boardN-1-j, i
}
out[i][j] = [2]int{r, c}
}
}
return out
}
// slide is the pure game step: given a grid and direction it returns the
// resulting grid, the per-tile movements (for the slide animation), the score
// gained, and whether anything moved. No state, so it's unit-testable.
func slide(grid [boardN][boardN]int, dir int) (out [boardN][boardN]int, movements []mov, gained int, moved bool) {
for _, coords := range lineCoords(dir) {
type sc struct{ v, r, c int }
var vals []sc
for _, cc := range coords {
if v := grid[cc[0]][cc[1]]; v != 0 {
vals = append(vals, sc{v, cc[0], cc[1]})
}
}
slot := 0
for i := 0; i < len(vals); {
dest := coords[slot]
if i+1 < len(vals) && vals[i].v == vals[i+1].v {
nv := vals[i].v * 2
out[dest[0]][dest[1]] = nv
gained += nv
movements = append(movements,
mov{vals[i].v, vals[i].r, vals[i].c, dest[0], dest[1]},
mov{vals[i+1].v, vals[i+1].r, vals[i+1].c, dest[0], dest[1]})
moved = true
i += 2
} else {
out[dest[0]][dest[1]] = vals[i].v
movements = append(movements, mov{vals[i].v, vals[i].r, vals[i].c, dest[0], dest[1]})
if vals[i].r != dest[0] || vals[i].c != dest[1] {
moved = true
}
i++
}
slot++
}
}
return out, movements, gained, moved
}
// move applies a slide and, if anything moved, commits it + starts the animation.
func (s *game) move(dir int) {
if s.sliding || s.over {
return
}
out, movements, gained, moved := slide(s.grid, dir)
if !moved {
return
}
s.grid = out
if s.score += gained; s.score > s.best {
s.best = s.score
}
// A destination reached by two movements is a merge — remember it so it can
// pop when the slide lands.
destCount := map[[2]int]int{}
for _, m := range movements {
destCount[[2]int{m.tr, m.tc}]++
}
s.merges = s.merges[:0]
for cell, n := range destCount {
if n >= 2 {
s.merges = append(s.merges, cell)
}
}
s.anims, s.sliding, s.slideT = movements, true, 0
s.popping = false
s.ctx.Invalidate()
}
func (s *game) Build(ctx widget.Ctx) widget.Widget {
// Derive the theme from the platform color scheme, provide it to the tree,
// and capture it for draw (which has no ctx) so the chrome follows light/dark.
th := theme.Auto(ctx)
s.th = th
content := widget.Interactive{
Gestures: widget.Gestures{
OnKey: func(k shell.Key) {
if k.Kind != shell.KeyPress {
return
}
switch k.Code {
case shell.KeyLeft:
s.move(0)
case shell.KeyRight:
s.move(1)
case shell.KeyUp:
s.move(2)
case shell.KeyDown:
s.move(3)
case shell.KeyEnter, shell.KeySpace:
s.reset()
s.ctx.Invalidate()
}
},
OnPress: func(p geom.Pt) {
s.pressPos, s.swDX, s.swDY, s.swiped = p, 0, 0, false
},
OnDrag: func(_, d geom.Pt) {
if s.swiped {
return
}
s.swDX, s.swDY = s.swDX+d.X, s.swDY+d.Y
const th = 24
switch {
case abs(s.swDX) < th && abs(s.swDY) < th:
return
case abs(s.swDX) > abs(s.swDY):
s.swiped = true
if s.swDX < 0 {
s.move(0) // left
} else {
s.move(1) // right
}
default:
s.swiped = true
if s.swDY < 0 {
s.move(2) // up
} else {
s.move(3) // down
}
}
},
OnTap: func() {
if s.over || s.newBtn.Contains(s.pressPos) {
s.reset()
s.ctx.Invalidate()
}
},
},
Child: widget.Canvas{Clip: true, Draw: s.draw},
}
return widget.Provide[theme.Theme]{
Value: th,
Child: widget.Fill{Color: th.Bg, Child: content},
}
}
func (s *game) draw(c paint.Canvas, sz geom.Size) {
th := s.th
c.Clear(th.Bg)
// headerH must clear the header's tallest element — the New Game button,
// whose bottom is at y=110 (top 78 + height 32) — plus a gap before the board.
const pad, headerH = 18, 124
board := min(sz.W-2*pad, sz.H-headerH-pad)
if board < 40 {
return
}
bx, by := (sz.W-board)/2, float32(headerH)
// Header: title, score/best chips, New Game.
p := s.ctx.Painter()
c.TextIn("", "2048", geom.Pt{X: bx, Y: 56}, 42, th.Text)
drawChip(c, geom.RectXYWH(bx+board-158, 24, 74, 48), "SCORE", s.score, th, p)
drawChip(c, geom.RectXYWH(bx+board-78, 24, 78, 48), "BEST", s.best, th, p)
// Size the button to its label (+ padding) so the text never clips.
const btnLabel, btnSize, btnPadX = "New Game", float32(14), float32(16)
lblW := p.MeasureWidthIn("", btnLabel, btnSize)
s.newBtn = geom.RectXYWH(bx, 78, lblW+2*btnPadX, 32)
c.FillRRect(s.newBtn, 6, th.Primary)
c.TextIn("", btnLabel, geom.Pt{X: s.newBtn.Min.X + btnPadX, Y: 78 + 16 + btnSize*0.35}, btnSize, th.OnPrimary)
// Board + slots — neutral chrome that frames the tiles in both schemes:
// the frame (Border) sits darker than Bg on light and lighter on dark, and
// the empty slots (Surface) recede so the classic tile ramp stays the focus.
c.FillRRect(geom.RectXYWH(bx, by, board, board), 8, th.Border)
gap := board * 0.028
cell := (board - gap*float32(boardN+1)) / boardN
xy := func(r, cc int) (float32, float32) {
return bx + gap + float32(cc)*(cell+gap), by + gap + float32(r)*(cell+gap)
}
for r := range boardN {
for cc := range boardN {
x, y := xy(r, cc)
c.FillRRect(geom.RectXYWH(x, y, cell, cell), 5, th.Surface)
}
}
// drawTile paints one tile at full size; scale (≠1) applies a transform about
// the tile centre for spawn/merge pops, so the number glyph is rasterized once
// at a whole-pixel size and scaled by the GPU — no per-frame atlas churn.
drawTile := func(v int, x, y, scale float32) {
if v == 0 {
return
}
cx, cy := x+cell/2, y+cell/2
if scale != 1 {
c.PushTransform(paint.Transform{SX: scale, SY: scale, PivotX: cx, PivotY: cy})
}
c.FillRRect(geom.RectXYWH(x, y, cell, cell), 5, tileColor(v))
txt := fmt.Sprintf("%d", v)
fs := tileFont(v, cell)
tw := p.MeasureWidthIn("", txt, fs)
c.TextIn("", txt, geom.Pt{X: cx - tw/2, Y: cy + fs*0.35}, fs, tileText(v))
if scale != 1 {
c.PopTransform()
}
}
if s.sliding {
t := easeOut(float32(s.slideT))
for _, m := range s.anims {
fx, fy := xy(m.fr, m.fc)
tx, ty := xy(m.tr, m.tc)
drawTile(m.v, lerp(fx, tx, t), lerp(fy, ty, t), 1)
}
} else {
for r := range boardN {
for cc := range boardN {
scale := float32(1)
switch {
case s.spawning && r == s.spR && cc == s.spC:
scale = spawnScale(float32(s.spawnT))
case s.popping && s.isMerge(r, cc):
scale = mergePop(float32(s.popT))
}
x, y := xy(r, cc)
drawTile(s.grid[r][cc], x, y, scale)
}
}
}
if s.over {
c.FillRRect(geom.RectXYWH(bx, by, board, board), 8, th.Bg.WithAlpha(0.62))
mid := bx + board/2
center(c, p, "Game Over", mid, by+board/2, 30, th.Text)
center(c, p, "tap to try again", mid, by+board/2+30, 15, th.Muted)
}
}
// center draws s horizontally centred on x at baseline y (measured, not guessed).
func center(c paint.Canvas, p *paint.Painter, s string, x, y, size float32, col paint.Color) {
c.TextIn("", s, geom.Pt{X: x - p.MeasureWidthIn("", s, size)/2, Y: y}, size, col)
}
// isMerge reports whether cell (r,c) is a destination that merged this move.
func (s *game) isMerge(r, c int) bool {
for _, m := range s.merges {
if m[0] == r && m[1] == c {
return true
}
}
return false
}
func drawChip(c paint.Canvas, r geom.Rect, label string, val int, th theme.Theme, p *paint.Painter) {
c.FillRRect(r, 6, th.Border)
center(c, p, label, r.Min.X+r.Dx()/2, r.Min.Y+17, 10, th.Muted)
center(c, p, fmt.Sprintf("%d", val), r.Min.X+r.Dx()/2, r.Min.Y+40, 18, th.Text)
}
// tileFont shrinks the number as it grows more digits. The result is rounded to
// a whole pixel: pop/spawn animations scale the glyph with a transform (not by
// re-sizing the font), and the board size snaps per resize, so the text
// rasterizer only ever sees a small set of integer sizes — no per-frame churn.
func tileFont(v int, cell float32) float32 {
var f float32
switch {
case v >= 1000:
f = cell * 0.30
case v >= 100:
f = cell * 0.38
default:
f = cell * 0.46
}
return float32(math.Round(float64(f)))
}
func lerp(a, b, t float32) float32 { return a + (b-a)*t }
func easeOut(t float32) float32 { u := 1 - t; return 1 - u*u*u }
// spawnScale grows a new tile 0→1 with a slight overshoot (easeOutBack) so it
// pops in rather than fading up.
func spawnScale(t float32) float32 {
const s = 1.70158
u := t - 1
return 1 + (s+1)*u*u*u + s*u*u
}
// mergePop is a one-shot bounce (1 → ~1.2 → 1) for a tile that just merged.
func mergePop(t float32) float32 {
return 1 + 0.22*float32(math.Sin(math.Pi*float64(t)))
}
func abs(x float32) float32 {
if x < 0 {
return -x
}
return x
}
func main() {
if err := app.Run(Game{}, app.Config{
Title: "2048",
Size: geom.Size{W: 420, H: 560},
Background: theme.Light().Bg, // pre-context window fill; the tree themes itself
Font: goregular.TTF,
}); err != nil {
log.Fatal(err)
}
}