Criando um Sliding Puzzle em Go

mfbmina

Matheus Mina

Posted on May 13, 2024

Criando um Sliding Puzzle em Go

Escrever um jogo é uma ótima maneira de se começar a programar, principalmente pois diversas pessoas começaram a programar por que queriam criar jogos para computadores ou até mesmo video game. Inclusive, um dos meus primeiros "projetos" foi um joguinho, ainda quando cursava o curso técnico em Informática Industrial, em 2010.

No curso, utilizávamos Python e implementamos um slide puzzle. Foi bem desafiador na época, pois tive que entender da mecânica do jogo, de como criar uma GUI, mas entreguei o projeto. Quando comecei a trabalhar com Ruby, também fiz uma implementação para comparar com o que já tinha feito em Python.

Sliding puzzle

Decidi então escrever um sliding puzzle usando Go. O primeiro passo foi escrever o que chamei de core, ou seja, a parte principal do jogo. Para isso, defini uma estrutura chamada Play que contém o tabuleiro e as posições x e y do valor nulo. O tabuleiro pode ser representado com um array, contudo acho mais simples uma representação usando uma matriz quadrada, e pro nosso caso, escolhi uma 3x3. Para representar o valor nulo, o valor escolhido foi o 0.

var DEFAULT_TABLE = [3][3]int{{1, 2, 3}, {4, 5, 6}, {7, 8, 0}}

type Play struct {
    Table    [3][3]int
    EmptyRow int
    EmptyCol int
}
Enter fullscreen mode Exit fullscreen mode

Depois precisamos gerar uma nova partida com um tabuleiro aleatório. Para embalharar, percorremos cada célula do tabuleiro e trocamos por outra de aleátoria. Isso nos garante que vamos ter o mínimo de embaralhamento em nosso tabuleiro.

func NewPlay() *Play {
    t, x, y := generateRandomTable()
    return &Play{
        Table:    t,
        EmptyRow: x,
        EmptyCol: y,
    }
}

func generateRandomTable() ([3][3]int, int, int) {
    t := DEFAULT_TABLE
    s := 3
    xEmpty := 0
    yEmpty := 0

    for i, r := range t {
        for j := range r {
            x := rand.Intn(s)
            y := rand.Intn(s)
            t[i][j], t[x][y] = t[x][y], t[i][j]

            if t[i][j] == 0 {
                xEmpty = i
                yEmpty = j
            }

            if t[x][y] == 0 {
                xEmpty = x
                yEmpty = y
            }
        }
    }

    return t, xEmpty, yEmpty
}
Enter fullscreen mode Exit fullscreen mode

O próximo passo foi implementar os movimentos de subida, descida, direita e esquerda. Aqui vamos sempre olhar para o valor nulo e é ele que vamos movimentar. Caso não seja possível andar com o 0, nós retornamos um erro.

func (p *Play) Up() error {
    if p.EmptyRow == 0 {
        return fmt.Errorf("can't move up")
    }

    p.Table[p.EmptyRow][p.EmptyCol], p.Table[p.EmptyRow-1][p.EmptyCol] = p.Table[p.EmptyRow-1][p.EmptyCol], p.Table[p.EmptyRow][p.EmptyCol]
    p.EmptyRow = p.EmptyRow - 1

    return nil
}

func (p *Play) Down() error {
    if p.EmptyRow == 2 {
        return fmt.Errorf("can't move down")
    }

    p.Table[p.EmptyRow][p.EmptyCol], p.Table[p.EmptyRow+1][p.EmptyCol] = p.Table[p.EmptyRow+1][p.EmptyCol], p.Table[p.EmptyRow][p.EmptyCol]
    p.EmptyRow = p.EmptyRow + 1

    return nil
}

func (p *Play) Left() error {
    if p.EmptyCol == 0 {
        return fmt.Errorf("can't move left")
    }

    p.Table[p.EmptyRow][p.EmptyCol], p.Table[p.EmptyRow][p.EmptyCol-1] = p.Table[p.EmptyRow][p.EmptyCol-1], p.Table[p.EmptyRow][p.EmptyCol]
    p.EmptyCol = p.EmptyCol - 1

    return nil
}

func (p *Play) Right() error {
    if p.EmptyCol == 2 {
        return fmt.Errorf("can't move right")
    }

    p.Table[p.EmptyRow][p.EmptyCol], p.Table[p.EmptyRow][p.EmptyCol+1] = p.Table[p.EmptyRow][p.EmptyCol+1], p.Table[p.EmptyRow][p.EmptyCol]
    p.EmptyCol = p.EmptyCol + 1

    return nil
}
Enter fullscreen mode Exit fullscreen mode

Por fim, nos resta só verificar se o tabuleiro está num estado de vitória ou não.

func (p *Play) IsWin() bool {
    return p.Table == DEFAULT_TABLE
}
Enter fullscreen mode Exit fullscreen mode

A primeira interface gráfica que implementei foi para o STDOUT do terminal. Para seguir a interface de View, precisamos apenas definir uma função de Render. Definimos também as teclas que são utilizáveis e ficamos num loop que se quebra em duas situações:

  • ou o usuário apertou a tecla de saída
  • ou o usuário venceu a partida
var KEYS = map[string]string{
    "up":    "w",
    "left":  "a",
    "down":  "s",
    "right": "d",
    "quit":  "q",
}

type Stdout struct {
    Play *core.Play
}

func NewStdout() *Stdout {
    return &Stdout{Play: core.NewPlay()}
}

func (s *Stdout) Render() {
    k := ""
    w := false

    for !w && !isQuit(k) {
        s.printTable()

        k = getMove()
        err := s.move(k)
        if err != nil {
            fmt.Println(err)
        }

        w = s.Play.IsWin()
    }

    if w {
        fmt.Println("You win!")
    }
}

func (s *Stdout) move(k string) error {
    switch k {
    case KEYS["up"]:
        return s.Play.Up()
    case KEYS["left"]:
        return s.Play.Left()
    case KEYS["down"]:
        return s.Play.Down()
    case KEYS["right"]:
        return s.Play.Right()
    case KEYS["quit"]:
        return nil
    default:
        return fmt.Errorf("Invalid key. Play again.")
    }
}

func (s *Stdout) printTable() {
    for _, row := range s.Play.Table {
        for _, col := range row {
            fmt.Printf("%d ", col)
        }
        fmt.Printf("\n")
    }
}

func isQuit(k string) bool {
    return KEYS["quit"] == k
}

func getMove() string {
    reader := bufio.NewReader(os.Stdin)
    t, _ := reader.ReadString('\n')
    return strings.TrimSuffix(t, "\n")
}
Enter fullscreen mode Exit fullscreen mode

Contudo, ao jogar algumas vezes percebi que algumas vezes o jogo era impossível de ser resolvido. Pesquisando, descobri que o problema vem do meu algoritmo de embaralhamento. Ao trocar uma célula por outra, fazemos algumas trocas que nunca seriam realizadas em um tabuleiro físico. Mas, para nossa sorte, isso é possível de identificar e resolver ao contar o número de inversões necessárias para resolver o jogo. Se o número for par ele é solucionável e se for impar, não.

func solvablePuzzle(t [3][3]int) bool {
    inversions := 0
    for i, r := range t {
        for j, c := range r {
            if c == 0 {
                continue
            }
            for x := i; x < 3; x++ {
                for y := 0; y < 3; y++ {
                    if x == i && y <= j {
                        continue
                    }
                    if t[x][y] == 0 {
                        continue
                    }
                    if t[x][y] < c {
                        inversions += 1
                    }
                }
            }
        }
    }

    if inversions%2 == 0 {
        return true
    }

    return false
}
Enter fullscreen mode Exit fullscreen mode

Caso o jogo seja insolucionável, realizamos uma última troca e garantimos que o jogo tenha uma solução.

func generateRandomTable() ([3][3]int, int, int) {
    // ...

    if !solvablePuzzle(t) {
        t[0][0], t[0][1] = t[0][1], t[0][0]
    }

    return t, xEmpty, yEmpty
}
Enter fullscreen mode Exit fullscreen mode

Finalmente temos nosso jogo funcional! Podemos agora implementar uma GUI para o nosso sliding puzzle! Escolhi a biblioteca Ebiten, uma engine open source que nos permite criar jogos 2D. Ela nos obriga a implementar uma interface que define as funções Update, Draw e Layout.

A função Layout é a mais simples delas: define o tamanho da janela.

func (u *UI) Layout(outsideWidth, outsideHeight int) (screenWidth, screenHeight int) {
    return 900, 900
}
Enter fullscreen mode Exit fullscreen mode

Já a função Draw é responsável por desenhar a sua tela e é executada a cada frame. Para representar nosso tabuleiro, decidi utilizar imagens de 300x300, para cada um dos números válidos. Assim, a cada iteração, a função pega o valor do tabuleiro e representa visualmente. Se o jogo estiver num estado de vitória, ele exibe uma imagem de parabéns!

//go:embed assets/*
var assets embed.FS

func (u *UI) Draw(screen *ebiten.Image) {
    for x, row := range u.Play.Table {
        for y, value := range row {
            if value == 0 {
                continue
            }

            img := loadImage(fmt.Sprint(value))
            op := &ebiten.DrawImageOptions{}
            fX := float64(x)
            fY := float64(y)
            op.GeoM.Translate(300*fY, 300*fX)

            screen.DrawImage(img, op)
        }
    }

    if u.Play.IsWin() {
        screen.Clear()
        img := loadImage("win")
        screen.DrawImage(img, nil)
    }
}

func loadImage(name string) *ebiten.Image {
    fName := fmt.Sprintf("assets/%s.png", name)
    f, err := assets.Open(fName)
    if err != nil {
        panic(err)
    }
    defer f.Close()

    img, _, err := image.Decode(f)
    if err != nil {
        panic(err)
    }

    return ebiten.NewImageFromImage(img)
}
Enter fullscreen mode Exit fullscreen mode

A função Update é responsável por atualizar o estado do jogo, ou seja, de fato realizar alguma ação no tabuleiro. Definimos aqui qual o comportamento esperado ao se apertar alguma tecla. No core, nós mapeamos qualquer ação ao valor zero, contudo, ao traduzir isso para uma GUI, faz mais sentido inverter a lógica. O usuário aperta para baixo, pois quer mover o número para baixo e não o zero.

func (u *UI) Update() error {
    if inpututil.IsKeyJustPressed(ebiten.KeyQ) {
        return fmt.Errorf("Quit")
    }
    if u.Play.IsWin() {
        return nil
    }
    if inpututil.IsKeyJustPressed(ebiten.KeyDown) {
        u.Play.Up()
    }
    if inpututil.IsKeyJustPressed(ebiten.KeyUp) {
        u.Play.Down()
    }
    if inpututil.IsKeyJustPressed(ebiten.KeyLeft) {
        u.Play.Right()
    }
    if inpututil.IsKeyJustPressed(ebiten.KeyRight) {
        u.Play.Left()
    }

    return nil
}
Enter fullscreen mode Exit fullscreen mode

E por fim, definimos também nossa função Render, para seguir nosso contrato de UI.

type UI struct {
    Play *core.Play
}

func NewUI() *UI {
    return &UI{Play: core.NewPlay()}
}

func (u *UI) Render() {
    ebiten.SetWindowSize(900, 900)
    ebiten.SetWindowTitle("Puzzle Game")

    if err := ebiten.RunGame(u); err != nil {
        log.Fatal(err)
    }
}
Enter fullscreen mode Exit fullscreen mode

Temos nosso jogo pronto! Foi um projeto bem interessante de se criar e me mostrou que implementar joguinhos é sempre uma ótima maneira de se aprender conceitos novos e reforçar conhecimentos em alguma linguagem de programação. Além disso, o Ebitten nos permite criar jogos 2D usando a nossa linguagem favorita e distribuir para diversas plataformas, até mesmo para Web usando WebAssembly ou XBOX. Se quiser executar o código fonte, ele se encontra aqui. Me diga o que você achou dessa postagem nos comentários e fica uma pergunta: qual jogo você quer criar usando Go?

Você também pode ler essas e outras postagens em meu blog pessoal!

💖 💪 🙅 🚩
mfbmina
Matheus Mina

Posted on May 13, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related