Refactor rulesets into smaller composable operations In order to mix up the functionality from different rulesets like Solo, Royale, etc. the code in these classes needs to be broken up into small functions that can be composed in a pipeline to make a custom game mode.
580 lines
18 KiB
Go
580 lines
18 KiB
Go
package commands
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io/ioutil"
|
|
"log"
|
|
"math/rand"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path"
|
|
"strconv"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/BattlesnakeOfficial/rules"
|
|
"github.com/BattlesnakeOfficial/rules/client"
|
|
"github.com/google/uuid"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
// Used to store state for each SnakeState while running a local game
|
|
type SnakeState struct {
|
|
URL string
|
|
Name string
|
|
ID string
|
|
LastMove string
|
|
Squad string
|
|
Character rune
|
|
Color string
|
|
Head string
|
|
Tail string
|
|
}
|
|
|
|
var GameId string
|
|
var Turn int32
|
|
var HttpClient http.Client
|
|
var Width int32
|
|
var Height int32
|
|
var Names []string
|
|
var URLs []string
|
|
var Squads []string
|
|
var Timeout int32
|
|
var Sequential bool
|
|
var GameType string
|
|
var ViewMap bool
|
|
var UseColor bool
|
|
var Seed int64
|
|
var TurnDelay int32
|
|
var DebugRequests bool
|
|
var Output string
|
|
|
|
var FoodSpawnChance int32
|
|
var MinimumFood int32
|
|
var HazardDamagePerTurn int32
|
|
var ShrinkEveryNTurns int32
|
|
|
|
var defaultConfig = map[string]string{
|
|
// default to standard ruleset
|
|
rules.ParamGameType: "standard",
|
|
// squad settings default to true (not zero value)
|
|
rules.ParamSharedElimination: "true",
|
|
rules.ParamSharedHealth: "true",
|
|
rules.ParamSharedLength: "true",
|
|
rules.ParamAllowBodyCollisions: "true",
|
|
}
|
|
|
|
var playCmd = &cobra.Command{
|
|
Use: "play",
|
|
Short: "Play a game of Battlesnake locally.",
|
|
Long: "Play a game of Battlesnake locally.",
|
|
Run: run,
|
|
PreRun: playPreRun,
|
|
}
|
|
|
|
func init() {
|
|
rootCmd.AddCommand(playCmd)
|
|
|
|
playCmd.Flags().Int32VarP(&Width, "width", "W", 11, "Width of Board")
|
|
playCmd.Flags().Int32VarP(&Height, "height", "H", 11, "Height of Board")
|
|
playCmd.Flags().StringArrayVarP(&Names, "name", "n", nil, "Name of Snake")
|
|
playCmd.Flags().StringArrayVarP(&URLs, "url", "u", nil, "URL of Snake")
|
|
playCmd.Flags().StringArrayVarP(&Names, "squad", "S", nil, "Squad of Snake")
|
|
playCmd.Flags().Int32VarP(&Timeout, "timeout", "t", 500, "Request Timeout")
|
|
playCmd.Flags().BoolVarP(&Sequential, "sequential", "s", false, "Use Sequential Processing")
|
|
playCmd.Flags().StringVarP(&GameType, "gametype", "g", "standard", "Type of Game Rules")
|
|
playCmd.Flags().BoolVarP(&ViewMap, "viewmap", "v", false, "View the Map Each Turn")
|
|
playCmd.Flags().BoolVarP(&UseColor, "color", "c", false, "Use color to draw the map")
|
|
playCmd.Flags().Int64VarP(&Seed, "seed", "r", time.Now().UTC().UnixNano(), "Random Seed")
|
|
playCmd.Flags().Int32VarP(&TurnDelay, "delay", "d", 0, "Turn Delay in Milliseconds")
|
|
playCmd.Flags().BoolVar(&DebugRequests, "debug-requests", false, "Log body of all requests sent")
|
|
playCmd.Flags().StringVarP(&Output, "output", "o", "", "File path to output game state to. Existing files will be overwritten")
|
|
|
|
playCmd.Flags().Int32Var(&FoodSpawnChance, "foodSpawnChance", 15, "Percentage chance of spawning a new food every round")
|
|
playCmd.Flags().Int32Var(&MinimumFood, "minimumFood", 1, "Minimum food to keep on the board every turn")
|
|
playCmd.Flags().Int32Var(&HazardDamagePerTurn, "hazardDamagePerTurn", 14, "Health damage a snake will take when ending its turn in a hazard")
|
|
playCmd.Flags().Int32Var(&ShrinkEveryNTurns, "shrinkEveryNTurns", 25, "In Royale mode, the number of turns between generating new hazards")
|
|
|
|
playCmd.Flags().SortFlags = false
|
|
}
|
|
|
|
func playPreRun(cmd *cobra.Command, args []string) {
|
|
initialiseGameConfig()
|
|
}
|
|
|
|
var run = func(cmd *cobra.Command, args []string) {
|
|
rand.Seed(Seed)
|
|
|
|
GameId = uuid.New().String()
|
|
Turn = 0
|
|
|
|
snakeStates := buildSnakesFromOptions()
|
|
|
|
ruleset := getRuleset(Seed, snakeStates)
|
|
state := initializeBoardFromArgs(ruleset, snakeStates)
|
|
exportGame := Output != ""
|
|
|
|
gameExporter := GameExporter{
|
|
game: createClientGame(ruleset),
|
|
snakeRequests: make([]client.SnakeRequest, 0),
|
|
winner: SnakeState{},
|
|
isDraw: false,
|
|
}
|
|
|
|
for v := false; !v; v, _ = ruleset.IsGameOver(state) {
|
|
Turn++
|
|
state = createNextBoardState(ruleset, state, snakeStates, Turn)
|
|
|
|
if ViewMap {
|
|
printMap(state, snakeStates, Turn)
|
|
} else {
|
|
log.Printf("[%v]: State: %v\n", Turn, state)
|
|
}
|
|
|
|
if TurnDelay > 0 {
|
|
time.Sleep(time.Duration(TurnDelay) * time.Millisecond)
|
|
}
|
|
|
|
if exportGame {
|
|
// The output file was designed in a way so that (nearly) every entry is equivalent to a valid API request.
|
|
// This is meant to help unlock further development of tools such as replaying a saved game by simply copying each line and sending it as a POST request.
|
|
// There was a design choice to be made here: the difference between SnakeRequest and BoardState is the `you` key.
|
|
// We could choose to either store the SnakeRequest of each snake OR to omit the `you` key OR fill the `you` key with one of the snakes
|
|
// In all cases the API request is technically non-compliant with how the actual API request should be.
|
|
// The third option (filling the `you` key with an arbitrary snake) is the closest to the actual API request that would need the least manipulation to
|
|
// be adjusted to look like an API call for a specific snake in the game.
|
|
snakeState := snakeStates[state.Snakes[0].ID]
|
|
snakeRequest := getIndividualBoardStateForSnake(state, snakeState, snakeStates, ruleset)
|
|
gameExporter.AddSnakeRequest(snakeRequest)
|
|
}
|
|
}
|
|
|
|
isDraw := true
|
|
if GameType == "solo" {
|
|
log.Printf("[DONE]: Game completed after %v turns.", Turn)
|
|
if exportGame {
|
|
// These checks for exportGame are present to avoid vacuuming up RAM when an export is not requred.
|
|
gameExporter.winner = snakeStates[state.Snakes[0].ID]
|
|
}
|
|
} else {
|
|
var winner SnakeState
|
|
for _, snake := range state.Snakes {
|
|
snakeState := snakeStates[snake.ID]
|
|
if snake.EliminatedCause == rules.NotEliminated {
|
|
isDraw = false
|
|
winner = snakeState
|
|
}
|
|
sendEndRequest(ruleset, state, snakeState, snakeStates)
|
|
}
|
|
|
|
if isDraw {
|
|
log.Printf("[DONE]: Game completed after %v turns. It was a draw.", Turn)
|
|
} else {
|
|
log.Printf("[DONE]: Game completed after %v turns. %v is the winner.", Turn, winner.Name)
|
|
}
|
|
if exportGame {
|
|
gameExporter.winner = winner
|
|
}
|
|
}
|
|
|
|
if exportGame {
|
|
err := gameExporter.FlushToFile(Output, "JSONL")
|
|
if err != nil {
|
|
log.Printf("[WARN]: Unable to export game. Reason: %v\n", err.Error())
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
}
|
|
|
|
func initialiseGameConfig() {
|
|
defaultConfig[rules.ParamGameType] = GameType
|
|
defaultConfig[rules.ParamFoodSpawnChance] = fmt.Sprint(FoodSpawnChance)
|
|
defaultConfig[rules.ParamMinimumFood] = fmt.Sprint(MinimumFood)
|
|
defaultConfig[rules.ParamHazardDamagePerTurn] = fmt.Sprint(HazardDamagePerTurn)
|
|
defaultConfig[rules.ParamShrinkEveryNTurns] = fmt.Sprint(ShrinkEveryNTurns)
|
|
}
|
|
|
|
func getRuleset(seed int64, snakeStates map[string]SnakeState) rules.Ruleset {
|
|
rb := rules.NewRulesetBuilder().WithSeed(seed).WithParams(defaultConfig)
|
|
|
|
for _, s := range snakeStates {
|
|
rb.AddSnakeToSquad(s.ID, s.Squad)
|
|
}
|
|
|
|
return rb.Ruleset()
|
|
|
|
}
|
|
|
|
func initializeBoardFromArgs(ruleset rules.Ruleset, snakeStates map[string]SnakeState) *rules.BoardState {
|
|
if Timeout == 0 {
|
|
Timeout = 500
|
|
}
|
|
HttpClient = http.Client{
|
|
Timeout: time.Duration(Timeout) * time.Millisecond,
|
|
}
|
|
|
|
snakeIds := []string{}
|
|
for _, snakeState := range snakeStates {
|
|
snakeIds = append(snakeIds, snakeState.ID)
|
|
}
|
|
state, err := rules.CreateDefaultBoardState(Width, Height, snakeIds)
|
|
if err != nil {
|
|
log.Panic("[PANIC]: Error Initializing Board State")
|
|
}
|
|
state, err = ruleset.ModifyInitialBoardState(state)
|
|
if err != nil {
|
|
log.Panic("[PANIC]: Error Initializing Board State")
|
|
}
|
|
|
|
for _, snakeState := range snakeStates {
|
|
snakeRequest := getIndividualBoardStateForSnake(state, snakeState, snakeStates, ruleset)
|
|
requestBody := serialiseSnakeRequest(snakeRequest)
|
|
u, _ := url.ParseRequestURI(snakeState.URL)
|
|
u.Path = path.Join(u.Path, "start")
|
|
if DebugRequests {
|
|
log.Printf("POST %s: %v", u, string(requestBody))
|
|
}
|
|
_, err = HttpClient.Post(u.String(), "application/json", bytes.NewBuffer(requestBody))
|
|
if err != nil {
|
|
log.Printf("[WARN]: Request to %v failed", u.String())
|
|
}
|
|
}
|
|
return state
|
|
}
|
|
|
|
func createNextBoardState(ruleset rules.Ruleset, state *rules.BoardState, snakeStates map[string]SnakeState, turn int32) *rules.BoardState {
|
|
var moves []rules.SnakeMove
|
|
if Sequential {
|
|
for _, snakeState := range snakeStates {
|
|
for _, snake := range state.Snakes {
|
|
if snakeState.ID == snake.ID && snake.EliminatedCause == rules.NotEliminated {
|
|
moves = append(moves, getMoveForSnake(ruleset, state, snakeState, snakeStates))
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
var wg sync.WaitGroup
|
|
c := make(chan rules.SnakeMove, len(snakeStates))
|
|
|
|
for _, snakeState := range snakeStates {
|
|
for _, snake := range state.Snakes {
|
|
if snakeState.ID == snake.ID && snake.EliminatedCause == rules.NotEliminated {
|
|
wg.Add(1)
|
|
go func(snakeState SnakeState) {
|
|
defer wg.Done()
|
|
c <- getMoveForSnake(ruleset, state, snakeState, snakeStates)
|
|
}(snakeState)
|
|
}
|
|
}
|
|
}
|
|
|
|
wg.Wait()
|
|
close(c)
|
|
|
|
for move := range c {
|
|
moves = append(moves, move)
|
|
}
|
|
}
|
|
for _, move := range moves {
|
|
snakeState := snakeStates[move.ID]
|
|
snakeState.LastMove = move.Move
|
|
snakeStates[move.ID] = snakeState
|
|
}
|
|
state, err := ruleset.CreateNextBoardState(state, moves)
|
|
if err != nil {
|
|
log.Panicf("[PANIC]: Error Producing Next Board State: %v", err)
|
|
}
|
|
|
|
state.Turn = turn
|
|
|
|
return state
|
|
}
|
|
|
|
func getMoveForSnake(ruleset rules.Ruleset, state *rules.BoardState, snakeState SnakeState, snakeStates map[string]SnakeState) rules.SnakeMove {
|
|
snakeRequest := getIndividualBoardStateForSnake(state, snakeState, snakeStates, ruleset)
|
|
requestBody := serialiseSnakeRequest(snakeRequest)
|
|
u, _ := url.ParseRequestURI(snakeState.URL)
|
|
u.Path = path.Join(u.Path, "move")
|
|
if DebugRequests {
|
|
log.Printf("POST %s: %v", u, string(requestBody))
|
|
}
|
|
res, err := HttpClient.Post(u.String(), "application/json", bytes.NewBuffer(requestBody))
|
|
move := snakeState.LastMove
|
|
if err != nil {
|
|
log.Printf("[WARN]: Request to %v failed\n", u.String())
|
|
log.Printf("Body --> %v\n", string(requestBody))
|
|
} else if res.Body != nil {
|
|
defer res.Body.Close()
|
|
body, readErr := ioutil.ReadAll(res.Body)
|
|
if readErr != nil {
|
|
log.Fatal(readErr)
|
|
} else {
|
|
playerResponse := client.MoveResponse{}
|
|
jsonErr := json.Unmarshal(body, &playerResponse)
|
|
if jsonErr != nil {
|
|
log.Fatal(jsonErr)
|
|
} else {
|
|
move = playerResponse.Move
|
|
}
|
|
}
|
|
}
|
|
return rules.SnakeMove{ID: snakeState.ID, Move: move}
|
|
}
|
|
|
|
func sendEndRequest(ruleset rules.Ruleset, state *rules.BoardState, snakeState SnakeState, snakeStates map[string]SnakeState) {
|
|
snakeRequest := getIndividualBoardStateForSnake(state, snakeState, snakeStates, ruleset)
|
|
requestBody := serialiseSnakeRequest(snakeRequest)
|
|
u, _ := url.ParseRequestURI(snakeState.URL)
|
|
u.Path = path.Join(u.Path, "end")
|
|
if DebugRequests {
|
|
log.Printf("POST %s: %v", u, string(requestBody))
|
|
}
|
|
_, err := HttpClient.Post(u.String(), "application/json", bytes.NewBuffer(requestBody))
|
|
if err != nil {
|
|
log.Printf("[WARN]: Request to %v failed", u.String())
|
|
}
|
|
}
|
|
|
|
func getIndividualBoardStateForSnake(state *rules.BoardState, snakeState SnakeState, snakeStates map[string]SnakeState, ruleset rules.Ruleset) client.SnakeRequest {
|
|
var youSnake rules.Snake
|
|
for _, snk := range state.Snakes {
|
|
if snakeState.ID == snk.ID {
|
|
youSnake = snk
|
|
break
|
|
}
|
|
}
|
|
request := client.SnakeRequest{
|
|
Game: createClientGame(ruleset),
|
|
Turn: Turn,
|
|
Board: convertStateToBoard(state, snakeStates),
|
|
You: convertRulesSnake(youSnake, snakeStates[youSnake.ID]),
|
|
}
|
|
return request
|
|
}
|
|
|
|
func serialiseSnakeRequest(snakeRequest client.SnakeRequest) []byte {
|
|
requestJSON, err := json.Marshal(snakeRequest)
|
|
if err != nil {
|
|
log.Panic("[PANIC]: Error Marshalling JSON from State")
|
|
panic(err)
|
|
}
|
|
return requestJSON
|
|
}
|
|
|
|
func createClientGame(ruleset rules.Ruleset) client.Game {
|
|
return client.Game{ID: GameId, Timeout: Timeout, Ruleset: client.Ruleset{
|
|
Name: ruleset.Name(),
|
|
Version: "cli", // TODO: Use GitHub Release Version
|
|
Settings: ruleset.Settings(),
|
|
}}
|
|
}
|
|
|
|
func convertRulesSnake(snake rules.Snake, snakeState SnakeState) client.Snake {
|
|
return client.Snake{
|
|
ID: snake.ID,
|
|
Name: snakeState.Name,
|
|
Health: snake.Health,
|
|
Body: client.CoordFromPointArray(snake.Body),
|
|
Latency: "0",
|
|
Head: client.CoordFromPoint(snake.Body[0]),
|
|
Length: int32(len(snake.Body)),
|
|
Shout: "",
|
|
Squad: snakeState.Squad,
|
|
Customizations: client.Customizations{
|
|
Head: snakeState.Head,
|
|
Tail: snakeState.Tail,
|
|
Color: snakeState.Color,
|
|
},
|
|
}
|
|
}
|
|
|
|
func convertRulesSnakes(snakes []rules.Snake, snakeStates map[string]SnakeState) []client.Snake {
|
|
var a []client.Snake
|
|
for _, snake := range snakes {
|
|
if snake.EliminatedCause == rules.NotEliminated {
|
|
a = append(a, convertRulesSnake(snake, snakeStates[snake.ID]))
|
|
}
|
|
}
|
|
return a
|
|
}
|
|
|
|
func convertStateToBoard(state *rules.BoardState, snakeStates map[string]SnakeState) client.Board {
|
|
return client.Board{
|
|
Height: state.Height,
|
|
Width: state.Width,
|
|
Food: client.CoordFromPointArray(state.Food),
|
|
Hazards: client.CoordFromPointArray(state.Hazards),
|
|
Snakes: convertRulesSnakes(state.Snakes, snakeStates),
|
|
}
|
|
}
|
|
|
|
func buildSnakesFromOptions() map[string]SnakeState {
|
|
bodyChars := []rune{'■', '⌀', '●', '☻', '◘', '☺', '□', '⍟'}
|
|
var numSnakes int
|
|
snakes := map[string]SnakeState{}
|
|
numNames := len(Names)
|
|
numURLs := len(URLs)
|
|
numSquads := len(Squads)
|
|
if numNames > numURLs {
|
|
numSnakes = numNames
|
|
} else {
|
|
numSnakes = numURLs
|
|
}
|
|
if numNames != numURLs {
|
|
log.Println("[WARN]: Number of Names and URLs do not match: defaults will be applied to missing values")
|
|
}
|
|
for i := int(0); i < numSnakes; i++ {
|
|
var snakeName string
|
|
var snakeURL string
|
|
var snakeSquad string
|
|
|
|
id := uuid.New().String()
|
|
|
|
if i < numNames {
|
|
snakeName = Names[i]
|
|
} else {
|
|
log.Printf("[WARN]: Name for URL %v is missing: a default name will be applied\n", URLs[i])
|
|
snakeName = id
|
|
}
|
|
|
|
if i < numURLs {
|
|
u, err := url.ParseRequestURI(URLs[i])
|
|
if err != nil {
|
|
log.Printf("[WARN]: URL %v is not valid: a default will be applied\n", URLs[i])
|
|
snakeURL = "https://example.com"
|
|
} else {
|
|
snakeURL = u.String()
|
|
}
|
|
} else {
|
|
log.Printf("[WARN]: URL for Name %v is missing: a default URL will be applied\n", Names[i])
|
|
snakeURL = "https://example.com"
|
|
}
|
|
|
|
if GameType == "squad" {
|
|
if i < numSquads {
|
|
snakeSquad = Squads[i]
|
|
} else {
|
|
log.Printf("[WARN]: Squad for URL %v is missing: a default squad will be applied\n", URLs[i])
|
|
snakeSquad = strconv.Itoa(i / 2)
|
|
}
|
|
}
|
|
snakeState := SnakeState{
|
|
Name: snakeName, URL: snakeURL, ID: id, LastMove: "up", Character: bodyChars[i%8],
|
|
}
|
|
res, err := HttpClient.Get(snakeURL)
|
|
if err != nil {
|
|
log.Printf("[WARN]: Request to %v failed: %v", snakeURL, err)
|
|
} else if res.Body != nil {
|
|
defer res.Body.Close()
|
|
body, readErr := ioutil.ReadAll(res.Body)
|
|
if readErr != nil {
|
|
log.Fatal(readErr)
|
|
}
|
|
|
|
pingResponse := client.SnakeMetadataResponse{}
|
|
jsonErr := json.Unmarshal(body, &pingResponse)
|
|
if jsonErr != nil {
|
|
log.Printf("Error reading response from %v: %v", snakeURL, jsonErr)
|
|
} else {
|
|
snakeState.Head = pingResponse.Head
|
|
snakeState.Tail = pingResponse.Tail
|
|
snakeState.Color = pingResponse.Color
|
|
}
|
|
}
|
|
if GameType == "squad" {
|
|
snakeState.Squad = snakeSquad
|
|
}
|
|
snakes[snakeState.ID] = snakeState
|
|
}
|
|
return snakes
|
|
}
|
|
|
|
// Parses a color string like "#ef03d3" to rgb values from 0 to 255 or returns
|
|
// the default gray if any errors occure
|
|
func parseSnakeColor(color string) (int64, int64, int64) {
|
|
if len(color) == 7 {
|
|
red, err_r := strconv.ParseInt(color[1:3], 16, 64)
|
|
green, err_g := strconv.ParseInt(color[3:5], 16, 64)
|
|
blue, err_b := strconv.ParseInt(color[5:], 16, 64)
|
|
if err_r == nil && err_g == nil && err_b == nil {
|
|
return red, green, blue
|
|
}
|
|
}
|
|
// Default gray color from Battlesnake board
|
|
return 136, 136, 136
|
|
}
|
|
|
|
func printMap(state *rules.BoardState, snakeStates map[string]SnakeState, gameTurn int32) {
|
|
var o bytes.Buffer
|
|
o.WriteString(fmt.Sprintf("Ruleset: %s, Seed: %d, Turn: %v\n", GameType, Seed, gameTurn))
|
|
board := make([][]string, state.Width)
|
|
for i := range board {
|
|
board[i] = make([]string, state.Height)
|
|
}
|
|
for y := int32(0); y < state.Height; y++ {
|
|
for x := int32(0); x < state.Width; x++ {
|
|
if UseColor {
|
|
board[x][y] = TERM_FG_LIGHTGRAY + "□"
|
|
} else {
|
|
board[x][y] = "◦"
|
|
}
|
|
}
|
|
}
|
|
for _, oob := range state.Hazards {
|
|
if UseColor {
|
|
board[oob.X][oob.Y] = TERM_BG_GRAY + " " + TERM_BG_WHITE
|
|
} else {
|
|
board[oob.X][oob.Y] = "░"
|
|
}
|
|
}
|
|
if UseColor {
|
|
o.WriteString(fmt.Sprintf("Hazards "+TERM_BG_GRAY+" "+TERM_RESET+": %v\n", state.Hazards))
|
|
} else {
|
|
o.WriteString(fmt.Sprintf("Hazards ░: %v\n", state.Hazards))
|
|
}
|
|
for _, f := range state.Food {
|
|
if UseColor {
|
|
board[f.X][f.Y] = TERM_FG_FOOD + "●"
|
|
} else {
|
|
board[f.X][f.Y] = "⚕"
|
|
}
|
|
}
|
|
if UseColor {
|
|
o.WriteString(fmt.Sprintf("Food "+TERM_FG_FOOD+TERM_BG_WHITE+"●"+TERM_RESET+": %v\n", state.Food))
|
|
} else {
|
|
o.WriteString(fmt.Sprintf("Food ⚕: %v\n", state.Food))
|
|
}
|
|
for _, s := range state.Snakes {
|
|
red, green, blue := parseSnakeColor(snakeStates[s.ID].Color)
|
|
for _, b := range s.Body {
|
|
if b.X >= 0 && b.X < state.Width && b.Y >= 0 && b.Y < state.Height {
|
|
if UseColor {
|
|
board[b.X][b.Y] = fmt.Sprintf(TERM_FG_RGB+"■", red, green, blue)
|
|
} else {
|
|
board[b.X][b.Y] = string(snakeStates[s.ID].Character)
|
|
}
|
|
}
|
|
}
|
|
if UseColor {
|
|
o.WriteString(fmt.Sprintf("%v "+TERM_FG_RGB+TERM_BG_WHITE+"■■■"+TERM_RESET+": %v\n", snakeStates[s.ID].Name, red, green, blue, s))
|
|
} else {
|
|
o.WriteString(fmt.Sprintf("%v %c: %v\n", snakeStates[s.ID].Name, snakeStates[s.ID].Character, s))
|
|
}
|
|
}
|
|
for y := state.Height - 1; y >= 0; y-- {
|
|
if UseColor {
|
|
o.WriteString(TERM_BG_WHITE)
|
|
}
|
|
for x := int32(0); x < state.Width; x++ {
|
|
o.WriteString(board[x][y])
|
|
}
|
|
if UseColor {
|
|
o.WriteString(TERM_RESET)
|
|
}
|
|
o.WriteString("\n")
|
|
}
|
|
log.Print(o.String())
|
|
}
|