В этой серии вы найдете краткий практический подход к изучению языка Go.
В Части 1 мы рассмотрели основы установки компилятора Go, запуска программ Go и системы модулей Go.
В Части 2 мы разработали программу управления паролями и узнали о нескольких языковых функциях и пакетах из стандартной библиотеки.
В части 3 мы разработаем игру, изучая больше возможностей языка Go.
Вы, наверное, знакомы с игрой TicTacToe. Давайте создадим версию онлайн-плеера.
В многопользовательских играх используется веб-сервер, и мы разработаем веб-приложение на основе Go, которое будет обслуживать игру. Игроки подключаются к серверу для игры.
Когда игрок подключается, а игры нет, мы начинаем новую игру и ждем другого игрока. Когда игрок подключается и к игре можно присоединиться, мы добавляем этого игрока в игру.
Поскольку это веб-сервер, естественно думать, что игроки используют браузерные клиенты. Но поскольку мы изучаем Go, давайте также создадим клиент на Go. Так интереснее :-)
Общая модель
Нам нужна абстракция игры и определение некоторых общих вещей, общих для сервера и клиента.
В рабочей области создайте каталог ticatactoe и дочерний каталог common.
mkdir -p tictactoe/common
В tictactoe создайте модуль
go mod init antosara.com/tictactoe
Добавьте локальный преобразователь для нашего модуля в go.mod
module antosara.com/tictactoe replace antosara.com/tictactoe => ../tictactoe 1.14
Здесь создайте файл common.go с содержимым:
package common const GAME_NEW = 0 const GAME_ACTIVE = 1 const GAME_DONE = 2 const PLAYER_X = "X" const PLAYER_O = "O" type Game struct { Id int State int Player string Board [9]string Winner string }
- GAME_NEW, GAME_ACTIVE, GAME_DONE — это состояния игры. GAME_NEW — это новая игра, созданная одним игроком и ожидающая, пока к ней присоединится другой. GAME_ACTIVE — когда второй игрок присоединяется и игра начинается. GAME_DONE — ничья или победа одного из игроков.
- PLAYER_X и PLAYER_O — два игрока в каждой игре.
- Игра — это структура
- Id — это случайный идентификатор, присвоенный игре.
- Состояние относится к одному из состояний игры, указанных выше.
- Игрок относится к текущему игроку между PLAYER_X и PLAYER_O.
- Доска — это массив доски TicTacToe с 9 квадратами. Каждый элемент начинается с пробела («») и принимает значение «X» или «O», когда соответствующий игрок играет.
- Победитель устанавливается на «X» или «O» или «-» в случае ничьей
Игровой сервер
Создайте каталог «сервер» в каталоге tictactoe.
mkdir server cd server
Создайте здесь файл «server.go».
package main import ( "log" "net/http" "io/ioutil" "encoding/json" "math/rand" "time" "strconv" "os" "strings" "sync" "tictactoe/common" ) type Games struct { G map[int]*common.Game sync.Mutex } var games *Games
- Мы намерены создать исполняемый файл, поэтому пакет является основным
- Импортируйте пакеты на данный момент. Вы увидите использование по мере того, как вы идете в длинную позицию. Обратите внимание, что мы импортировали tictactoe/common, чтобы получить доступ к игровой модели.
- Игры — это структура данных, содержащая карту созданных игр. Каждая игра по мере ее создания будет добавляться к этому.
- Обратите внимание на определение карты как элемент «G». Ключ — целое число, а значение — указатель на игру.
- «games» — это указатель на экземпляр Games, который создает сервер.
- Обратите внимание на мьютекс в структуре Games, который управляет одновременным доступом к играм. Он называется встроенным мьютексом, потому что мы не назвали это поле. Как мы увидим в будущем, у нас будут функции на сервере, которые читают/записывают «игры», и Go выполняет эти функции одновременно. Чтобы предотвратить состояние гонки, мы хотели бы сериализовать любые обновления/чтения в «играх». В этом помогает мьютекс. Он охраняет членов Games, поэтому он определен внутри структуры.
Напишите основную функцию:
func main() { log.SetOutput(os.Stdout) rand.Seed(time.Now().UnixNano()) games = &Games{G : make(map[int]*common.Game)} http.HandleFunc("/tictactoe/", gameHandler) http.HandleFunc("/tictactoe/start", startHandler) log.Fatal(http.ListenAndServe(":8080", nil)) }
- Инициализируйте журнал и установите его для вывода на стандартный вывод
- Инициализировать генератор случайных чисел с текущим временем в качестве начального числа
- Инициализируйте «игры». Обратите внимание, как мы инициализируем карту с помощью функции make(). Присваиваем ссылку указателю «games»
- http.HandleFunc добавляет, что функция имеет обработчик для заданного пути URL. Здесь мы определили два таких обработчика, которые реализуем позже.
- startHandler — это функция (подробнее ниже), которая обрабатывает запросы к /tictactoe/start. Реализует механику запуска игры
- gamehandler — это функция (подробнее ниже), которая обрабатывает запросы во время игры.
- http.ListenAndAServe() запускает http-сервер с заданным адресом и обработчиком. В этом случае, указав «nil», мы просто используем предоставленный Go обработчик по умолчанию.
Функция startHandler:
func startHandler(w http.ResponseWriter, r *http.Request) { log.Println("START game") games.Lock() // Iterate the games and check for an available game var g *common.Game; for _, game := range games.G { if game.State == common.GAME_NEW { // This game is waiting for another player. // Start the game. game.State = common.GAME_ACTIVE g = game break; } } if g == nil { // Create a new game with a random id and the player as X gameId := rand.Intn(20000-1000) + 1000 game := common.Game{Id: gameId, Player: common.PLAYER_X, State:common.GAME_NEW, Board:[9]string{" "," "," "," "," "," "," "," "," "}} games.G[gameId] = &game g = &game } games.Unlock() js, _ := json.Marshal(*g) log.Println("Game started: ", g.Id) w.Header().Set("Content-Type", "application/json") w.Write(js) }
- Любой обработчик принимает эти два параметра: модуль записи ответа HTTP и запрос HTTP.
- Каждый обработчик выполняется одновременно для нескольких запросов, происходящих одновременно. Поскольку мы делимся картой игр, лучше сериализовать эти запросы с целью создания игр. Если нет, есть вероятность, что второй игрок при подключении может создать новую игру вместо того, чтобы присоединиться к существующей игре из-за состояния гонки.
- Чтобы предотвратить проблемы с параллелизмом, мы блокируем карту игр с помощью мьютекса. games.lock() — способ сделать это. Пока он не будет разблокирован, другой поток должен ждать, прежде чем продолжить работу с этим блоком кода.
- Мы перебираем игры на карте, чтобы проверить, не создана ли игра уже и не ожидает ли к ней присоединения другого игрока. На это состояние указывает состояние GAME_NEW. Если найдено, мы получаем игру и присваиваем указатель «g» и устанавливаем игру как запущенную с состоянием GAME_ACTIVE.
- Если мы не нашли открытой игры, мы создаем ее и добавляем на карту игр. Идентификатор игры генерируется случайным образом в диапазоне от 1000 до 20000. Мы назначаем игрока как «X» с состоянием игры GAME_NEW. Мы также инициализируем массив board значениями « » (пробел). Получаем отсылку к игре с «г»
- Разблокируйте мьютекс, потому что мы закончили с модификациями игр.
- Верните игру в виде сериализованного ответа JSON. Библиотека json помогает в преобразовании. Затем мы записываем содержимое в ответ HTTP вместе с заголовком для типа содержимого.
Теперь обработчик игры:
func gameHandler(w http.ResponseWriter, r *http.Request) { gameIdStr := strings.Split(r.URL.Path, "/")[2] gameId, _ := strconv.Atoi(gameIdStr) switch r.Method { case "GET": log.Println("GET game " + r.URL.Path + ":" + gameIdStr) js, _ := json.Marshal(games.G[gameId]) log.Println("Return Game: ", string(js)) w.Header().Set("Content-Type", "application/json") w.Write(js) case "POST": log.Println("POST game") body, err := ioutil.ReadAll(r.Body) if err != nil { log.Printf("Error reading body: %v", err) return } var playerGame common.Game; json.Unmarshal(body, &playerGame) game := games.G[gameId] log.Println("Player is " + playerGame.Player) if game.Player == playerGame.Player { game.Board = playerGame.Board if isDraw(game) { game.State = common.GAME_DONE game.Winner = "-" } else if isWon(game, playerGame.Player) { game.State = common.GAME_DONE game.Winner = playerGame.Player } else { if game.Player == common.PLAYER_X { game.Player = common.PLAYER_O } else { game.Player = common.PLAYER_X } } } js, _ := json.Marshal(game) w.Header().Set("Content-Type", "application/json") w.Write(js) default: w.Write([]byte("Unsupported")) } }
- Запросы к /tictactoe/*, отличные от /tictactoe/start, обслуживаются этим.
- Для игры, которую мы пишем, мы ожидаем только запросы GET и POST tictactoe/‹gameid›. Идентификатор игры создается startHandler и отправляется обратно клиентам игроков. Пока игра не будет завершена, игроки отправляют запросы на эту конечную точку.
- Мы получаем gameId из пути запроса. Например, /tictactoe/6825
- Для запросов GET мы извлекаем игру из карты игр, сериализуем ее в JSON и отправляем обратно в виде тела ответа.
- POST используется клиентом игрока для обновления игрового поля после заполнения квадрата его отметкой (X или O). Весь игровой объект будет сериализован на клиенте и отправлен как тело запроса POST.
- В POST мы десериализуем (json.Unmarshal) тело запроса JSON в «playerGame». Мы получаем серверную версию игры, используя gameId из карты игр. Теперь нам нужно проверить, какой игрок отправил запрос. Текущий игрок отслеживается в игровых данных как «Игрок». Если игрок в запросе не соответствует текущему игроку в игре, мы игнорируем и просто отправляем текущую игру обратно. Если он совпадает, мы обновляем игровое поле сервера.
- Теперь, когда мы обновляем доску, мы проверяем, является ли игра ничьей или победителем. Методы isDraw() и isWon() показаны ниже. В случае ничьей мы устанавливаем Победителя как «-». В случае победы игрока мы устанавливаем Победителя вместе с игроком (X или O). Если ничего не происходит, это означает, что мы даем другому игроку следующий ход. Если текущий игрок «X», мы устанавливаем игрового игрока «O» и наоборот.
- Затем мы отправляем обратно сериализованную игру в формате JSON.
func isWon(game *common.Game, player string) bool { log.Println("Checking win for " + player) log.Println(game.Board) won := ((game.Board[0] == player && game.Board[1] == player && game.Board[2] == player) || (game.Board[3] == player && game.Board[4] == player && game.Board[5] == player) || (game.Board[6] == player && game.Board[7] == player && game.Board[8] == player) || (game.Board[0] == player && game.Board[3] == player && game.Board[6] == player) || (game.Board[1] == player && game.Board[4] == player && game.Board[7] == player) || (game.Board[2] == player && game.Board[5] == player && game.Board[8] == player) || (game.Board[0] == player && game.Board[4] == player && game.Board[8] == player) || (game.Board[2] == player && game.Board[4] == player && game.Board[6] == player)) log.Println("Checking win for " + player + " : " + strconv.FormatBool(won)) return won } func isDraw(game *common.Game) bool { log.Println("Checking draw") draw := (game.Board[0] != " " && game.Board[1] != " " && game.Board[2] != " " && game.Board[3] != " " && game.Board[4] != " " && game.Board[5] != " " && game.Board[6] != " " && game.Board[7] != " " && game.Board[8] != " ") log.Println("Checking draw: " + strconv.FormatBool(draw)) return draw }
- IsWon() и isDraw() — довольно простые реализации для определения состояния доски TicTacToe. Вы можете улучшить их, чтобы быть более эффективными. Здесь мы просто проверяем, выиграл ли игрок, заполняя столбец/строку/диагональ. Если все квадраты заполнены без выигрыша, это ничья.
Мы закончили с сервером.
Запустите сервер. Перейдите в каталог сервера и
go build ./server
В настоящее время вы можете протестировать конечные точки с помощью curl или клиентского инструмента REST, такого как Postman. Попробуйте следующее:
curl http://localhost:8080/tictactoe/start
Ответ:
{"Id":7111,"State":0,"Player":"X","Board":[" "," "," "," "," "," "," "," "," "],"Winner":""}
Подключен первый игрок. Состояние GAME_NEW.
Второй запрос на скручивание запустит игру между двумя игроками:
{"Id":7111,"State":1,"Player":"X","Board":[" "," "," "," "," "," "," "," "," "],"Winner":""}
Подключен второй игрок. Состояние GAME_ACTIVE. Игрок X делает первый ход. Далее вы увидите, как вы будете использовать это для управления ходами клиента игрока.
Теперь отправьте сообщение на http://localhost:8080/tictactoe/7111, где игрок X выбирает первый квадрат:
curl --location --request POST 'http://localhost:8080/tictactoe/7111' \ --header 'Content-Type: application/json' \ --data-raw '{"Id":7111,"State":1,"Player":"X","Board":["X"," "," "," "," "," "," "," "," "],"Winner":""}'
Ответ:
{"Id":7111,"State":1,"Player":"O","Board":["X"," "," "," "," "," "," "," "," "],"Winner":""}
Вы видите, что состояние доски фиксируется сервером, а игрок переключается на «O».
Далее мы собираем игровой клиент.
Игровой клиент
Напомним, что мы хотели создать клиент плеера и на GoLang. Это будет консольный плеер. Итак, все, что у нас есть, это ASCII для рисования доски. Доску TicTacToe легко рисовать с помощью символов ASCII. Это будет выглядеть так:
------------- | | | | ------------- | X | | O | ------------- | | X | | -------------
Просто, верно? Давайте начнем.
Создайте каталог «client» в каталоге tictactoe. Здесь создайте файл с именем player.go
mkdir client cd client
В player.go начните добавлять код:
package main import ( "log" "os" "fmt" "bufio" "strings" "net/http" "encoding/json" "time" "io/ioutil" "strconv" "bytes" "tictactoe/common" ) const BASE_URL = "http://localhost:8080/tictactoe" const START_URL = BASE_URL + "/start" var game common.Game
- Обычный пакет и зависимости
- Некоторые константы для URL-адресов, с которыми мы общаемся.
- Переменная «game» для хранения игровых данных.
func readTrimmed(reader *bufio.Reader) (string, error) { str, err := reader.ReadString('\n') return strings.Replace(strings.TrimSpace(str)," ", "_", -1), err }
- Чтобы прочитать пользовательский ввод. Мы использовали то же самое в менеджере паролей. Это обрезает пробелы вокруг входной строки.
func printBoard() { fmt.Println("-------------") fmt.Println("| " + game.Board[0] + " | " + game.Board[1] + " | " + game.Board[2] + " |" ) fmt.Println("-------------") fmt.Println("| " + game.Board[3] + " | " + game.Board[4] + " | " + game.Board[5] + " |" ) fmt.Println("-------------") fmt.Println("| " + game.Board[6] + " | " + game.Board[7] + " | " + game.Board[8] + " |" ) fmt.Println("-------------") }
- Это будет печатать игровое поле с текущим состоянием всякий раз, когда нам нужно
Далее мы напишем основную функцию игрока. Это немного долго. Таким образом, комментарии добавляются в строку. Убедитесь, что вы понимаете каждую строку кода.
func main() { // Setup a reader from standard input reader := bufio.NewReader(os.Stdin) // Intro to excite the player :-) fmt.Println("+++++++++++++++++++++++++++++++++++++") fmt.Println("Welcome to TicTacToe!! ") fmt.Println("+++++++++++++++++++++++++++++++++++++") for { // Initialize to keep track of the player to blank // Once the start request is sent, the player is set // and remains the same for the rest of the game player := "" // Info for the user fmt.Println("Starting a new game. Please wait...") // Call the START_URL endpoint to start/join a game resp, err := http.Get(START_URL) if err != nil { log.Fatalln(err) } // Read the response from game server body, err := ioutil.ReadAll(resp.Body) if err != nil { log.Fatalln(err) } // Deserialize response to the "game" data structure json.Unmarshal(body, &game) // Make sure we close the response body resp.Body.Close() // If the server responded with game state of GAME_NEW // this is the player that started a game. // The starting player will be assigned "X" or "O" on server // which we get here too. // If server responded with GAME_ACTIVE, this player // joined a game. This player should be assigned the other mark if game.State == common.GAME_NEW { player = game.Player } else { if game.Player == common.PLAYER_X { player = common.PLAYER_O } else { player = common.PLAYER_X } } // If we have received GAME_NEW, we just // wait for another player to join and start the game // Keep fetching the game every second, until that happens for game.State == common.GAME_NEW { // Get the game using the game endpoint. // We know the game Id by now resp, err := http.Get(BASE_URL + "/" + strconv.Itoa(game.Id)) if err != nil { log.Fatalln(err) } body, err := ioutil.ReadAll(resp.Body) if err != nil { log.Fatalln(err) } json.Unmarshal(body, &game) resp.Body.Close() time.Sleep(1 * time.Second) } // If we are here, the server has two players and the game is started. fmt.Println("***********************") fmt.Println("Game " + strconv.Itoa(game.Id) + " started. You are: " + player) fmt.Println("***********************") // We start a forever loop until the game is done. // There are certain things like showing a message once // until some change occurs. We use the marked flag for that. var marked = true for game.State != common.GAME_DONE { // Pause for a second before we send the next game update request time.Sleep(1 * time.Second) resp, _ := http.Get(BASE_URL + "/" + strconv.Itoa(game.Id)) body, _ := ioutil.ReadAll(resp.Body) // Read the game json.Unmarshal(body, &game) // If the game is done, exit the loop if game.State == common.GAME_DONE { break } // If this player is NOT the current game player, // we print a wait message and go back to the start // of the game loop if game.Player != player { // We need to show this message only once until // some state changes if marked { printBoard() fmt.Println("Waiting for other player...") marked = false } // Give extra time time.Sleep(1 * time.Second) continue } else { // It is this player's turn. Print any board update printBoard() // Track the player choice. The squares are numbered // 0 to 8 sequentially right and down. choice := -1 // Ask for a valid choice. Any square between 0 and 8 // inclusive and containing " " as value is acceptable // Keep asking until we get it for choice == -1 { fmt.Print("Fill square (0-8): ") c, _ := readTrimmed(reader) choice, _ = strconv.Atoi(c) if choice < 0 || choice > 8 || game.Board[choice] != " " { fmt.Println("Invalid choice.") choice = -1 } } // Update the player's board game.Board[choice] = player // Reset the state for any messages marked = true // Post the game data as JSON to the game endpoint postBody, _ := json.Marshal(game) postResp, _ := http.Post(BASE_URL + "/" + strconv.Itoa(game.Id), "application/json", bytes.NewBuffer(postBody)) postResp.Body.Close() } } // We will be here when one of these happen // -> Game is a tie // -> Game has winner // We can find it from the game.Winner field // Give appropriate message to the player printBoard() fmt.Println("***********************") if game.Winner == "-" { fmt.Println("It's a tie, try harder next time!!") } else if player == game.Winner { fmt.Println("You won, keep it up!!") } else { fmt.Println("You lost, but good luck next time!!") } fmt.Println("***********************") // Let the player choose to play another game or exit fmt.Println("Enter 'y' if you want to play again: ") d, _ := readTrimmed(reader) if d != "y" { fmt.Println("Thanks for playing!!") break; } } }
Потрясающий! Если вы последовали за ним, вы закончили с клиентом.
Соберите клиент:
go build
Предполагая, что вы уже используете сервер, запустите клиент
./client
Теперь вы можете играть в игру. Пригласите друга поиграть в другом окне консоли.
Если вы играете в клиент в двух окнах, вы должны увидеть что-то вроде этого:
Клиент 1
+++++++++++++++++++++++++++++++++++++ Welcome to TicTacToe!! +++++++++++++++++++++++++++++++++++++ Starting a new game. Please wait... *********************** Game 3448 started. You are: X *********************** ------------- | | | | ------------- | | | | ------------- | | | | ------------- Fill square (0-8): 0 ------------- | X | | | ------------- | | | | ------------- | | | | ------------- Waiting for other player... ------------- | X | | | ------------- | | O | | ------------- | | | | ------------- Fill square (0-8):
Клиент 2:
+++++++++++++++++++++++++++++++++++++ Welcome to TicTacToe!! +++++++++++++++++++++++++++++++++++++ Starting a new game. Please wait... *********************** Game 3448 started. You are: O *********************** ------------- | | | | ------------- | | | | ------------- | | | | ------------- Waiting for other player... ------------- | X | | | ------------- | | | | ------------- | | | | ------------- Fill square (0-8): 4 ------------- | X | | | ------------- | | O | | ------------- | | | | ------------- Waiting for other player...
Поздравляю с успешным путешествием! Вы можете улучшить эту элементарную игру или создать совершенно новую игру, например боевой корабль, которая может быть более сложной.
Мы изучим продвинутую тему Go в Части 4.