WEB3DEV

Cover image for O Guia Definitivo Para a Construção de um Aplicativo de Terminal com Rust e Go
Isabela Curado Nehme
Isabela Curado Nehme

Posted on

O Guia Definitivo Para a Construção de um Aplicativo de Terminal com Rust e Go

https://miro.medium.com/v2/resize:fit:720/0*4mAwd9EVLqGnCY37

Foto de M. Zakiyuddin Munziri no Unsplash

Introdução

As TUIs (Touchless User Interface ou interface de usuário sem toque) já existem há muito tempo. Elas têm muitos casos de uso, especialmente em sistemas como servidores remotos ou sistemas embarcados onde não há GUI (Graphical User Interface). Isso pode ser um divisor de águas quando se quer ter uma interface simples para interagir com seu aplicativo sem precisar instalar uma GUI.

Portanto, neste tutorial, vamos recriar o popular jogo Tic Tac Toe (Jogo da Velha), mas com uma TUI e um tamanho de grade ajustável e usaremos notações de xadrez como “A1” e “D4” para identificar células.

Os principais frameworks deste artigo são Ratatui, a super biblioteca Rust para a construção de aplicativos TUI ricos, e Fiber, o incrível framework da Web para o Go, que serve para a construção de APIs e muito mais.

Este artigo será dividido em 3 seções:

  • Configurando o projeto;
  • Usando Go para explorar o Game Server (servidor de jogo) com Fiber V2;
  • Configurando a TUI em Rust com o Ratatui.

Aqui está uma prévia da maravilhosa interface do usuário do Terminal que iremos construir e este é o link para o repositório Github se você quiser cloná-lo, já que este artigo é extenso.

Sem perder mais tempo, vamos começar.

Configurando o projeto

Este é um tutorial de nível intermediário, então você deve ter pelo menos uma boa experiência em programação, mas tentarei explicar o máximo que puder. Você também precisa ter instalado o Go e o Rust em sua máquina.

Se você tiver dúvidas, verifique o repositório Github do projeto para obter o código amplamente documentado. Este será um longo tutorial, então pegue sua xícara de café e vamos começar!

Estrutura do Projeto

Você pode simplesmente copiar e colar todo o bloco de código abaixo em seu terminal para criar a estrutura do projeto e os arquivos que precisaremos para este tutorial:

mkdir tic-tac-to && cd tic-tac-to; \
cargo init --bin client && mkdir client/src/tui; \
cd client/src/tui && touch entry.rs game.rs input.rs mod.rs ui.rs && \
cd - && mkdir server && cd server && go mod init server && mkdir pkg cmd; \
touch cmd/main.go pkg/bot.go pkg/logic.go;
Enter fullscreen mode Exit fullscreen mode

Desde que você tenha o Rust e o Go instalados em sua máquina, o código acima criará uma nova pasta chamada tic-tac-to e inicializará um projeto Rust na pasta do cliente e um módulo Go na pasta do servidor. Ele também criará os arquivos que precisaremos para este tutorial. A estrutura do seu projeto deve ficar assim:

https://miro.medium.com/v2/resize:fit:640/format:webp/1*aUxkiY--uPRp-B12NqVrBQ.png

Estrutura do Projeto

Dependências

Agora que configuramos a estrutura do nosso projeto, vamos instalar rapidamente as dependências necessárias para este projeto. Para o lado Go das coisas, tudo o que precisamos fazer é instalar o framework Fiber, um framework Web para o Go construído a partir do FastHttp. É rápido, simples e fácil de usar. Você pode conferir a documentação para saber mais sobre o Fiber.

Execute este comando no diretório server (servidor) para instalar o Fiber:

go get -u github.com/gofiber/fiber/v2
Enter fullscreen mode Exit fullscreen mode

Agora, para as dependências do Rust, no diretório client (cliente), atualize a seção de dependências do arquivo Cargo.toml para ficar assim:

[dependencies]
crossterm = "0.26"
ratatui = "0.20"
clearscreen = "2.0.1"
figlet-rs = "0.1.5"
tui-input = "0.7.1"
reqwest = { version = "0.11", features = ["blocking", "json"] }
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
Enter fullscreen mode Exit fullscreen mode

Aqui está um rápido resumo das dependências que usaremos:

  • Ratatui: uma biblioteca para construir interfaces de usuário de terminal.
  • Crossterm: uma biblioteca multiplataforma para manipulação de terminal.
  • Clearscreen: uma biblioteca para limpar a tela do terminal.
  • Figlet-rs: uma biblioteca para criar arte ASCII a partir de texto.
  • Tui-input: uma biblioteca para ler a entrada do usuário no terminal.
  • Reqwest: uma biblioteca para fazer solicitações HTTP.
  • Serde_json: uma biblioteca para serializar e desserializar JSON.
  • Serde: uma estrutura para serializar e desserializar estruturas de dados Rust de forma eficiente e genérica.

Agora vamos entrar na parte divertida deste tutorial. Vamos indo!

Construindo o Game Server

Não estamos construindo nada muito complicado, apenas queremos que o servidor seja capaz de olhar o tabuleiro e determinar se há vencedor ou não. Se não houver um vencedor ou se o jogo ainda não tiver terminado, o servidor deverá ser capaz de determinar a próxima jogada simplesmente por aleatoriedade.

Lógica do jogo

Então, vamos começar implementando a lógica do jogo. Criaremos duas funções, uma para verificar se há vencedor e outra para determinar a próxima jogada. Copie e cole o código abaixo no arquivo pkg/logic.go para a função checkWin:

package pkg

import (
 "crypto/rand"
 "math/big"
)

func checkWin(board [][]int) bool {
 n := len(board)

 // Verifica as linhas
 for _, row := range board {
  if row[0] == 0 {
   continue
  }
  won := true

  for _, cell := range row {
   if row[0] != cell {
    won = false
    break
   }
  }

  if won {
   return true
  }

 } 

 // Verifica as colunas
 for i := 0; i < n; i++ {
  if board[0][i] == 0 {
   continue
  }
  won := true

  for j := 0; j < n; j++ {
   if board[0][i] != board[j][i] {
    won = false
    break
   }
  }

  if won {
   return true
  }
 }

 // Verifica as diagonais
 if board[0][0] == 0 && board[0][n-1] == 0 {
  return false
 }

 right_won := true
 left_won := true

 for i := 0; i < n; i++ {
  if board[0][0] != board[i][i] && board[0][0] != 0 {
   right_won = false
  }

  if board[0][n-1] != board[i][n-1-i] && board[0][n-1] != 0 {
   left_won = false
  }
 }

 if right_won && board[0][0] != 0 || left_won && board[0][n-1] != 0 {
  return true
 } else {
  return false
 }
}
Enter fullscreen mode Exit fullscreen mode

Minhas desculpas aos engenheiros de software de todo o mundo pelo código acima. Sei que não é o melhor código que já viram, mas funciona. Eu não queria sacrificar a legibilidade pelo desempenho. Tenho 400% de certeza de que existem maneiras melhores de escrever o código acima, então fique à vontade para melhorá-lo. Atenção! Vocês terão que me perdoar novamente pela função Play na próxima seção, que MUITAS VEZES chama essa função cara por solicitação. 😉

O que a função checkWin faz é relativamente simples, simplesmente verificamos se há um vencedor, verificando se alguma linha, coluna ou diagonal do tabuleiro tem o mesmo valor. Agora, vamos criar a função makeMove. Copie e adicione o código abaixo ao arquivo pkg/logic.go:

func makeMove(board *[][]int, player string) ([]int, error) {
 var NoOpenSpotsError error
 var openSpots [][]int

 max := big.NewInt(100000000)
 randInt, err := rand.Int(rand.Reader, max)
 if err != nil {
  return nil, NoOpenSpotsError
 }

 for i, row := range *board {
  for j, cell := range row {
   if cell == 0 {
    openSpots = append(openSpots, []int{i, j})
   }
  }
 }

 if len(openSpots) == 0 {
  return nil, NoOpenSpotsError
 }

 move := openSpots[randInt.Int64()%int64(len(openSpots))]

 if player == "X" {
  (*board)[move[0]][move[1]] = 2
 } else {
  (*board)[move[0]][move[1]] = 1
 }

 return move, nil
}
Enter fullscreen mode Exit fullscreen mode

Armazenamos todos os espaços abertos no tabuleiro em uma parte do array e então escolhemos um local aleatório desse array. Em seguida, atualizamos o tabuleiro com a jogada do jogador e retornamos a jogada. Se não houver lugares livres no tabuleiro, retornaremos um erro.

Agora que temos a lógica do jogo, vamos criar a API.

Criando a API

Vamos criar um handler (manipulador) simples para o ponto de extremidade /play. Copie e cole o código abaixo no arquivo pkg/bot.go:

package pkg

import (
 "github.com/gofiber/fiber/v2"
)

type Game struct {
 Board [][]int `json:"board"`
 Player string `json:"player"`
 GameOver bool `json:"game_over"`
}

func Play(c *fiber.Ctx) error {
 var game Game

 if err := c.BodyParser(&game); err != nil {
  return err
 }

 if checkWin(game.Board) {
  game.GameOver = true
  return c.JSON(
   fiber.Map{
    "game_over": game.GameOver,
    "move": nil,
   },
  )
 }

 move, err := makeMove(&game.Board, game.Player)
 if err != nil {
  game.GameOver = true
  return c.JSON(
   fiber.Map{
    "game_over": game.GameOver,
    "move": nil,
   },
  )
 }

 if checkWin(game.Board) {
  game.GameOver = true
  return c.JSON(
   fiber.Map{
    "game_over": game.GameOver,
    "move": nil,
   },
  )
 }

 return c.JSON(
  fiber.Map{
   "game_over": game.GameOver,
   "move": move,
  },
 )
}
Enter fullscreen mode Exit fullscreen mode

Começamos criando uma struct Game que contém o tabuleiro, o jogador e um booleano que indica se o jogo acabou ou não. Em seguida, analisamos o corpo da solicitação na struct Game.

Em seguida, verificamos se o jogo acabou chamando a função checkWin que criamos anteriormente. Se o jogo tiver terminado, retornamos uma resposta JSON com o campo game_over definido como true (verdadeiro) e o campo move (jogada) definido como nil (nada). Se o jogo ainda não tiver terminado, chamamos a função makeMove, que criamos anteriormente, para fazer um movimento para o bot. Em seguida, verificamos se o jogo terminou novamente e tratamos disso de acordo.

Finalmente, vamos conectar o manipulador à API. Copie e cole o código abaixo no arquivo cmd/main.go:

package main

import (
 "server/pkg"

 "github.com/gofiber/fiber/v2"
 "github.com/gofiber/fiber/v2/middleware/cors"
 "github.com/gofiber/fiber/v2/middleware/logger"
)

func main() {
 // Crie um aplicativo Go Fiber app
 app := fiber.New()

 app.Use(cors.New(cors.Config{
  // Não use AllowOrigins: "*" na produção. Não é seguro.
  AllowOrigins: "*",
  AllowHeaders: "Origin, Content-Type, Accept",
 }))

 app.Use(logger.New())

 app.Get("/", func(c *fiber.Ctx) error {
  return c.JSON(
   fiber.Map{
    "health": "ok",
   },
  )
 })

 app.Post("/play", pkg.Play)

 // Inicie o servidor em http://localhost:3000
 app.Listen(":3000")
}
Enter fullscreen mode Exit fullscreen mode

Primeiro, criamos uma nova instância do aplicativo Fiber e, em seguida, configuramos os middlewares cors e logger no aplicativo. Depois, criamos um manipulador simples para o ponto de extremidade /, que retorna uma resposta JSON com o campo health (saúde) definido como ok. Por fim, adicionamos o manipulador Play que criamos anteriormente ao ponto de extremidade /play.

Agora você pode executar o servidor executando o comando abaixo:

 go run cmd/main.go
Enter fullscreen mode Exit fullscreen mode

Se tudo correr bem, você deverá ver o seguinte em seu terminal:

https://miro.medium.com/v2/resize:fit:720/format:webp/1*WAzy1arGCisT1srC9R_sfQ.png

Executando o servidor Go

A API agora está pronta, então vamos prosseguir para a construção da TUI. É hora de aplicar o Rust!

Construindo a TUI

Usaremos muito a biblioteca Ratatui nesta seção. O Ratatui é o fork (bifurcação) mantido pela comunidade da biblioteca TUI original para Rust. É muito fácil de usar, com muitos widgets úteis para você começar a trabalhar rapidamente. Vamos começar!

Para tornar o código mais modular e legível, vamos escrever o código relacionado à TUI em arquivos separados na pasta tui e, em seguida, expor as funções necessárias ao arquivo main.rs por meio do arquivo src/tui/ mod.rs.

Adicione este código ao arquivo src/tui/mod.rs:

pub mod entry;
pub mod game;
mod input;
mod ui;
Enter fullscreen mode Exit fullscreen mode

Mais uma coisa antes de começarmos a escrever o código TUI propriamente dito. Precisamos criar a struct pública Game, que será usada para armazenar o estado do jogo. Adicione o código abaixo ao arquivo src/tui/game.rs:

use serde::{Deserialize, Serialize};

#[derive(PartialEq, Eq, Serialize, Deserialize, Debug)]
pub enum Player {
    X,
    O,
}

#[derive(Serialize, Deserialize, Debug)]
pub struct Game {
    pub board: Vec<Vec<u8>>,
    pub player: Player,
    pub game_over: bool,
}

impl Game {
    pub fn new(player: Option<Player>, dim: usize) -> Game {
        let rows = vec![0; dim];
        let board = vec![rows; dim];

        let player = player.unwrap_or(Player::X);
        let game_over = false;

        Game {
            board,
            player,
            game_over,
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Tudo o que fizemos aqui foi definir uma enum de Player (jogador) serializável e uma struct Game com um método de inicialização que retorna uma nova struct Game com o jogador especificado (cujo padrão é “X”) e a dimensão do tabuleiro.

Vamos para a parte complicada! Construir o layout da TUI e lidar com a entrada do usuário.

Criando o layout

O Ratatui fornece muitos widgets que podemos usar para construir facilmente nossas interfaces TUI. Ele usa o padrão builder (construtor) para facilitar o encadeamento de métodos e a construção de layouts complexos. Começaremos trabalhando no arquivo src/tui/ui.rs.

Para facilitar as coisas para você, meu caro leitor, vou colar o código que vai no arquivo src/tui/ui.rs abaixo e depois explicar o que cada seção faz. Lembre-se de verificar o repositório GitHub para obter uma documentação extensa se tiver dúvidas.

use std::io::{self, Stdout};

use ratatui::{
    backend::CrosstermBackend,
    layout::{Alignment, Constraint, Direction, Layout},
    style::{Color, Style},
    text::Text,
    widgets::{Block, Borders, Cell, Paragraph, Row, Table},
    Terminal,
};
use tui_input::Input;

use super::game::Game;

pub fn draw(
    terminal: &mut Terminal<CrosstermBackend<Stdout>>,
    input: &Input,
    game: &Game,
) -> Result<(), io::Error> {
    terminal.draw(|f| {
            let chunks = Layout::default()
                .direction(Direction::Vertical)
                .margin(1)
                .constraints(
                    [
                        Constraint::Percentage(5),
                        Constraint::Percentage(10),
                        Constraint::Percentage(70),
                        Constraint::Percentage(15),
                    ]
                    .as_ref(),
                )
                .split(f.size());

            let header = Block::default()
                .title(String::from("Tic Rac Go!"))
                .borders(Borders::NONE)
                .title_alignment(Alignment::Center)
                .style(Style::default().fg(Color::Yellow));

            let input = Paragraph::new(input.value())
                .style(Style::default())
                .block(Block::default().borders(Borders::ALL).title("Input"));

            let table_width = vec![Constraint::Length(5); game.board.len()];

            let body = Table::new(game.board.iter().map(|row| {
                Row::new(row.iter().map(|cell| {
                    match cell {
                        0 => Cell::from(Text::from("-"))
.style(Style::default().fg(Color::Green)),

                        1 => Cell::from(Text::from("X"))
.style(Style::default().fg(Color::Red)),

                        2 => Cell::from(Text::from("O"))
.style(Style::default().fg(Color::Blue)),

                        _ => panic!("Invalid cell value"),
                    }
                }))
                .height(2)
            }))
            .style(Style::default().fg(Color::White))
            .header(
                Row::new(vec![Cell::from(Text::from("Board"))])
                    .style(Style::default().fg(Color::Yellow))
                    .bottom_margin(1),
            )
            .widths(&table_width)
            .column_spacing(1);

            let footer = Paragraph::new(Text::from(
                r"Press 'q' to quit, 'esc' to clear input, and 'enter' to submit your input eg 'A2'.",
            ))
            .style(Style::default().fg(Color::Green))
            .block(
                Block::default()
                    .borders(Borders::ALL)
                    .title("Powered by Ratatui and Crossterm").border_style(
                        Style::default()
                            .fg(Color::Yellow)
                    )
            );

            f.render_widget(header, chunks[0]);
            f.render_widget(input, chunks[1]);
            f.render_widget(body, chunks[2]);
            f.render_widget(footer, chunks[3]);
        })?;

    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

Após nossas importações, definimos uma função draw (desenho) que usa uma referência mutável para uma instância Terminal, uma referência para uma instância Input (entrada) e uma referência para uma instância Game (jogo).

pub fn draw(
    terminal: &mut Terminal<CrosstermBackend<Stdout>>,
    input: &Input,
    game: &Game,
) -> Result<(), io::Error> {
    // ...
}
Enter fullscreen mode Exit fullscreen mode

Imediatamente depois, chamamos o método draw na instância Terminal que passamos para a função draw. O Ratatui suporta vários back-ends, mas estamos usando o back-end CrosstermBackend neste tutorial. O método draw recebe um fechamento que leva uma referência mutável a uma instância Frame. A instância Frame é usada para renderizar os widgets que passamos para o método render_widget no final da função.

pub fn draw(/* argumentos */) /* retorna */ {
    terminal.draw(|f| {
        // ...

        f.render_widget(/* widget */, /* restrição de layout */);
    })?;
}
Enter fullscreen mode Exit fullscreen mode

Para que o Ratatui renderize os widgets com precisão, precisamos especificar as restrições para cada widget. Fazemos isso usando a struct Layout. A struct Layout usa uma enum Direction que especifica a direção do layout. Estamos usando a variante Vertical. Também especificamos uma margem de 1 para o layout.

E, por último, especificamos como o Layout será dividido usando a enum Constraint. Estamos usando a variante Percentage para especificar a porcentagem do terminal que cada widget ocupará. O método split retorna um vetor de instâncias Rect com base no tamanho do Frame que podemos usar para renderizar os widgets.

let chunks = Layout::default()
    .direction(Direction::Vertical)
    .margin(1)
    .constraints(
        [
            Constraint::Percentage(5),
            Constraint::Percentage(10),
            Constraint::Percentage(70),
            Constraint::Percentage(15),
        ]
        .as_ref(),
    )
    .split(f.size());
Enter fullscreen mode Exit fullscreen mode

Os widgets header (cabeçalho), input (entrada), body (corpo) e footer (rodapé) definidos posteriormente seguem todos o mesmo padrão, por isso não irei discuti-los muito. Aqui está um rápido resumo de cada widget:

  • O widget header é um widget Block (de bloco) com o título “Tic Rac Go!” e uma cor de primeiro plano amarela.
  • O widget input é um widget Paragraph (de parágrafo) com uma borda e um título “Input”.
  • O widget body é um widget Table (de tabela) com um cabeçalho de “Board” (tabuleiro) e uma cor de primeiro plano branca que apresenta o tabuleiro de jogo com base no tabuleiro da instância Game passada para a função draw.
  • O widget footer é um widget Paragraph com uma borda e um título “Powered by Ratatui and Crossterm” .

No final, renderizamos esses widgets passando-os em partes para a função render_widget.

Isso conclui a parte da interface do usuário do tutorial. Na próxima seção, adicionaremos a lógica para lidar com a entrada do usuário.

Manipulação de eventos e entrada do usuário

Semelhante à seção anterior, vou colar o código da função handle_input e explicá-lo posteriormente. Adicione isso ao arquivo tui/input.rs.

use std::io::{self, Stdout};

use crossterm::{
    event::{self, DisableMouseCapture, Event, KeyCode},
    execute,
    terminal::{disable_raw_mode, LeaveAlternateScreen},
};
use ratatui::{backend::CrosstermBackend, Terminal};
use serde_json::Value;
use tui_input::{backend::crossterm::EventHandler, Input};

use super::game::{Game, Player};

pub fn handle_input(
    terminal: &mut Terminal<CrosstermBackend<Stdout>>,
    input: &mut Input,
    game: &mut Game,
) -> Result<u8, io::Error> {
    if let Event::Key(key) = event::read()? {
        match key.code {
            KeyCode::Char('q') => {
                 // restaura o terminal
                 disable_raw_mode()?;
                 execute!(
                    terminal.backend_mut(),
                    LeaveAlternateScreen,
                    DisableMouseCapture
                )?;
                terminal.show_cursor()?;

                return Ok(1);
            }

            KeyCode::Enter => {
                // Valida a entrada
                if input.value().len() != 2 {
                    input.reset();
                    return Ok(2);
                }

                let l = game.board.len() - 1;

                let file_char = input.value().chars().next().unwrap();
                let rank_char = input.value().chars().last().unwrap();

                if !file_char.is_alphabetic() || !rank_char.is_digit(10) {
                    input.reset();
                    return Ok(2);
                }

                let r = (file_char as u8 - 'A' as u8) as usize;
                let c = (rank_char as u8 - '1' as u8) as usize;

                if r > l || c > l {
                    input.reset();
                    return Ok(2);
                }

                if game.board[r][c] != 0 {
                    input.reset();
                    return Ok(2);
                }

                if game.player == Player::X {
                    game.board[r][c] = 1;
                } else {
                    game.board[r][c] = 2;
                }

                let serialized = serde_json::to_string(&game).unwrap();

                let url = "http://localhost:3000/play";
                let client = reqwest::blocking::Client::new();

                let res = client
                    .post(url)
                    .body(serialized)
                    .header("Content-Type", "application/json")
                    .send()
                    .unwrap();

                let res_str = res.text().unwrap();

                let val: Value =
                    serde_json::from_str(&res_str.as_str()).unwrap();

                match val["move"].clone() {
                    Value::Array(moves) => {
                        let r = moves[0].as_u64().unwrap() as usize;
                        let c = moves[1].as_u64().unwrap() as usize;

                        if game.player == Player::X {
                            game.board[r][c] = 2;
                        } else {
                            game.board[r][c] = 1;
                        }
                    }
                    _ => {}
                }

                game.game_over = val["game_over"].as_bool().unwrap();

                input.reset();
            }

            KeyCode::Esc => {
                input.reset();
            }

            _ => {
                input.handle_event(&Event::Key(key));
            }
        }
    }

    Ok(0)
}
Enter fullscreen mode Exit fullscreen mode

A função handle_input tem parâmetros semelhantes aos da função draw, então vamos apenas passar para o corpo da função e como a entrada é tratada.

O pacote do Cargo que torna isso possível é o crate tui-input. Ele fornece uma struct Input que pode ser usada para obter a entrada do usuário. Ele também fornece uma struct EventHandler que pode ser usada para manipular eventos como eventos de mouse, eventos principais, eventos de foco de terminal, etc.

if let Event::Key(key) = event::read()? {
    match key.code {
        // ...

    }
}

Ok(0)
Enter fullscreen mode Exit fullscreen mode

Não há nada de especial aqui. Estamos apenas verificando se o usuário pressionou uma tecla e, em caso afirmativo, estamos combinando o código da tecla. Estamos usando a struct EventHandler para lidar apenas com eventos de teclas. Vamos examinar o código da função handle_input bloco por bloco.

match key.code {
    KeyCode::Char('q') => {
        // restaura o terminal
        disable_raw_mode()?;
        execute!(
            terminal.backend_mut(),
            LeaveAlternateScreen,
            DisableMouseCapture
        )?;
        terminal.show_cursor()?;

        return Ok(1);
    }

    KeyCode::Enter => {
        // ...

    }

    KeyCode::Esc => {
        input.reset();
    }

    _ => {
        input.handle_event(&Event::Key(key));
    }

}
Enter fullscreen mode Exit fullscreen mode

Olhando para o bloco correspondente acima, podemos ver que, se o usuário pressionar a tecla q, restauramos o estado do terminal e retornamos 1. Isso é usado para indicar que o usuário pressionou a tecla q e que devemos sair do programa. Se o usuário pressionar a tecla Esc, reinicializamos a instância Input. Isso é usado para limpar a entrada do usuário. Se o usuário pressionar qualquer outra tecla, passamos a tecla para a instância Input para lidar com isso.

Não falarei sobre as verificações de integridade que validam se a entrada do usuário é uma célula válida, por exemplo, “A1” ou “C4” , mas começarei de onde enviamos o estado do jogo para o back-end. Agora vamos detalhar o que acontece quando a tecla Enter é pressionada.

KeyCode::Enter => {
    // -- Pulando a verificação de integridade --

    let serialized = serde_json::to_string(&game).unwrap();
    let url = "http://localhost:3000/play";
    let client = reqwest::blocking::Client::new();

    let res = client
        .post(url)
        .body(serialized)
        .header("Content-Type", "application/json")
        .send()
        .unwrap();

    let res_str = res.text().unwrap();

    let val: Value =
        serde_json::from_str(&res_str.as_str()).unwrap();

    match val["move"].clone() {
        Value::Array(moves) => {
            let r = moves[0].as_u64().unwrap() as usize;
            let c = moves[1].as_u64().unwrap() as usize;

            if game.player == Player::X {
                game.board[r][c] = 2;
            } else {
                game.board[r][c] = 1;
            }
        }
        _ => {}
    }
}
Enter fullscreen mode Exit fullscreen mode

Tudo o que fazemos é enviar uma solicitação de bloqueio com a struct Game serializada para o ponto de extremidade /play do servidor e recebemos uma resposta. A resposta é um objeto JSON que contém o próximo movimento e se o jogo acabou ou não. Desserializamos a resposta e atualizamos o estado do jogo de acordo.

Conectando-se à API

Nesta seção final, atualizaremos os dois arquivos restantes intocados no diretório src. Começaremos com o arquivo tui/entry.rs. Cole este código abaixo:

use std::io;

use crossterm::{
    event::{DisableMouseCapture, EnableMouseCapture},
    execute,
    terminal::{
        disable_raw_mode, enable_raw_mode, EnterAlternateScreen,
        LeaveAlternateScreen,
    },
};
use ratatui::{backend::CrosstermBackend, Terminal};
use tui_input::Input;

use crate::Game;

use super::{input::handle_input, ui::draw};

pub fn terminal(game: &mut Game) -> Result<(), io::Error> {
    let mut input = Input::default();

    // configura o terminal
    enable_raw_mode()?;
    let mut stdout = io::stdout();
    execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
    let backend = CrosstermBackend::new(stdout);
    let mut terminal = Terminal::new(backend)?;
    terminal.clear()?;

    loop {
        if game.game_over {
            // restaura o terminal
            disable_raw_mode()?;
            execute!(
                terminal.backend_mut(),
                LeaveAlternateScreen,
                DisableMouseCapture
            )?;
            terminal.show_cursor()?;
           println!("Game Over!");
           break;
        }

        draw(&mut terminal, &input, &game).unwrap();

        match handle_input(&mut terminal, &mut input, game).unwrap() {
            1 => break,
            2 => continue,
            _ => (),
        }
    }

Ok(())
}
Enter fullscreen mode Exit fullscreen mode

Este é o ponto de entrada para todo o código TUI. Ele possui uma única função chamada terminal que usa uma referência mutável para uma struct Game. Essa função configura nosso terminal e inicia o loop principal para lidar com a entrada do usuário e desenhar a interface do usuário.

Ela sai do loop somente quando o jogo termina ou o usuário pressiona a tecla q . Vejamos o arquivo main.rs, que é o ponto de entrada para todo o programa.

use crate::tui::entry::terminal;
use crate::tui::game::{Game, Player};

use std::io::{self, Error, ErrorKind};

pub mod tui;

use figlet_rs::FIGfont;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let stdin = io::stdin();
    let standard_font = FIGfont::standard().unwrap();
    let figure = standard_font.convert("Tic Rac Go!");

    let mut game: Game;
    let mut dimension = String::new();
    let mut player = String::new();

    clearscreen::clear().expect("failed to clear screen");
    println!("{}", figure.unwrap());

    println!("Enter Dimension (n x n), defaults to 3. n? ");
    match stdin.read_line(&mut dimension) {
        Ok(_) => {
            match dimension.trim().parse::<u32>() {
                Ok(i) => {
                    if i > 9 {
                        println!("Grid too large, defaulting to 3");
                        game = Game::new(None, 3);
                    } else {
                        game = Game::new(None, i as usize);
                    }
                }
                Err(..) => {
                    println!("Invalid input, defaulting to 3");
                    game = Game::new(None, 3);
                }
            };
        }
        Err(error) => {
            print!("Error: {}, defaulting to 3", error);
            game = Game::new(None, 3);
        }
    };

    println!("Play as X or O? ");
    match stdin.read_line(&mut player) {
        Ok(_) => match (&player as &str).trim() {
            "X" => game.player = Player::X,
            "O" => game.player = Player::O,
            _ => {
                println!("Invalid input, defaulting to X");
                game.player = Player::X;
            }
        },
        Err(error) => {
            print!("Error: {}, defaulting to X", error);
            game.player = Player::X;
        }
    };

    // Verificação da integridade do servidor
    let url = "http://localhost:3000/";
    let resp = reqwest::blocking::get(url);

    match resp {
        Ok(_) => (),
        Err(_) => {
            return Err(Box::new(Error::new(
                ErrorKind::ConnectionRefused,
                format!("Server is not running at {}", url),
            )));
        }
    }

    terminal(&mut game)?;
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

A função principal é bastante simples. Primeiro, limpamos a tela e, em seguida, pegamos a entrada do usuário relativa à dimensão do tabuleiro e à escolha do símbolo do jogador e, se a entrada for inválida, o padrão é 3 e “X”, respectivamente.

Depois disso, verificamos a integridade do servidor e encerramos o programa se o servidor não estiver funcionando. Se o servidor estiver em execução, chamamos a função terminal no arquivo tui/entry.rs e passamos a ela uma referência mutável para a struct Game.

Agora você pode executar o programa com o seguinte comando.

cargo run
Enter fullscreen mode Exit fullscreen mode

E você deverá ver a seguinte saída.

https://miro.medium.com/v2/resize:fit:720/format:webp/1*SOBTFiny6a4gt4s8EkrhtA.png

Projeto concluído

Conclusão

Obrigado por ler e chegar até aqui. Espero que você tenha gostado deste tutorial e aprendido algo novo. Tenho certeza de que há muitas maneiras de melhorar este projeto e encorajo você a experimentá-lo.

Também estou aberto a comentários, dúvidas e sugestões. Sinta-se à vontade para entrar em contato comigo no Twitter ou GitHub. Tenha um ótimo dia!

Recursos e Referências

Você pode verificar alguns dos recursos listados abaixo para saber mais sobre as tecnologias usadas neste tutorial.

Esse artigo foi escrito por Simon Samuel e traduzido por Isabela Curado Nehme. Seu original pode ser lido aqui.

Top comments (0)