From 41f26363c839561d462c3d21375933241f95b9bc Mon Sep 17 00:00:00 2001 From: Gregory Tertyshny Date: Tue, 25 Jul 2023 11:20:51 +0300 Subject: [PATCH] base cli game --- cli/main.go | 9 ++ go.mod | 15 ++++ go.sum | 58 +++++++++++++ lib/board.go | 210 ++++++++++++++++++++++++++++++++++++++++++++++ lib/board_test.go | 122 +++++++++++++++++++++++++++ lib/game.go | 58 +++++++++++++ 6 files changed, 472 insertions(+) create mode 100644 cli/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 lib/board.go create mode 100644 lib/board_test.go create mode 100644 lib/game.go diff --git a/cli/main.go b/cli/main.go new file mode 100644 index 0000000..19730e1 --- /dev/null +++ b/cli/main.go @@ -0,0 +1,9 @@ +package main + +import ( + "15/lib" +) + +func main() { + lib.Start() +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..bafcf9c --- /dev/null +++ b/go.mod @@ -0,0 +1,15 @@ +module 15 + +go 1.20 + +require ( + atomicgo.dev/keyboard v0.2.9 + golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 +) + +require github.com/vcaesar/keycode v0.10.0 // indirect + +require ( + github.com/containerd/console v1.0.3 // indirect + golang.org/x/sys v0.10.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d0f772d --- /dev/null +++ b/go.sum @@ -0,0 +1,58 @@ +atomicgo.dev/keyboard v0.2.9 h1:tOsIid3nlPLZ3lwgG8KZMp/SFmr7P0ssEN5JUsm78K8= +atomicgo.dev/keyboard v0.2.9/go.mod h1:BC4w9g00XkxH/f1HXhW2sXmJFOCWbKn9xrOunSFtExQ= +github.com/MarvinJWendt/testza v0.1.0/go.mod h1:7AxNvlfeHP7Z/hDQ5JtE3OKYT3XFUeLCDE2DQninSqs= +github.com/MarvinJWendt/testza v0.2.1/go.mod h1:God7bhG8n6uQxwdScay+gjm9/LnO4D3kkcZX4hv9Rp8= +github.com/MarvinJWendt/testza v0.2.8/go.mod h1:nwIcjmr0Zz+Rcwfh3/4UhBp7ePKVhuBExvZqnKYWlII= +github.com/MarvinJWendt/testza v0.2.10/go.mod h1:pd+VWsoGUiFtq+hRKSU1Bktnn+DMCSrDrXDpX2bG66k= +github.com/MarvinJWendt/testza v0.2.12/go.mod h1:JOIegYyV7rX+7VZ9r77L/eH6CfJHHzXjB69adAhzZkI= +github.com/MarvinJWendt/testza v0.3.0/go.mod h1:eFcL4I0idjtIx8P9C6KkAuLgATNKpX4/2oUqKc6bF2c= +github.com/MarvinJWendt/testza v0.4.2/go.mod h1:mSdhXiKH8sg/gQehJ63bINcCKp7RtYewEjXsvsVUPbE= +github.com/atomicgo/cursor v0.0.1/go.mod h1:cBON2QmmrysudxNBFthvMtN32r3jxVRIvzkUiF/RuIk= +github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw= +github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gookit/color v1.4.2/go.mod h1:fqRyamkC1W8uxl+lxCQxOT09l/vYfZ+QeiX3rKQHCoQ= +github.com/gookit/color v1.5.0/go.mod h1:43aQb+Zerm/BWh2GnrgOQm7ffz7tvQXEKV6BFMl7wAo= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.0.10/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= +github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pterm/pterm v0.12.27/go.mod h1:PhQ89w4i95rhgE+xedAoqous6K9X+r6aSOI2eFF7DZI= +github.com/pterm/pterm v0.12.29/go.mod h1:WI3qxgvoQFFGKGjGnJR849gU0TsEOvKn5Q8LlY1U7lg= +github.com/pterm/pterm v0.12.30/go.mod h1:MOqLIyMOgmTDz9yorcYbcw+HsgoZo3BQfg2wtl3HEFE= +github.com/pterm/pterm v0.12.31/go.mod h1:32ZAWZVXD7ZfG0s8qqHXePte42kdz8ECtRyEejaWgXU= +github.com/pterm/pterm v0.12.33/go.mod h1:x+h2uL+n7CP/rel9+bImHD5lF3nM9vJj80k9ybiiTTE= +github.com/pterm/pterm v0.12.36/go.mod h1:NjiL09hFhT/vWjQHSj1athJpx6H8cjpHXNAK5bUw8T8= +github.com/pterm/pterm v0.12.40/go.mod h1:ffwPLwlbXxP+rxT0GsgDTzS3y3rmpAO1NMjUkGTYf8s= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/vcaesar/keycode v0.10.0/go.mod h1:JNlY7xbKsh+LAGfY2j4M3znVrGEm5W1R8s/Uv6BJcfQ= +github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs= +golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 h1:MGwJjxBy0HJshjDNfLsYO8xppfqWlA5ZT9OhtUUhTNw= +golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211013075003-97ac67df715c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA= +golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/lib/board.go b/lib/board.go new file mode 100644 index 0000000..84e39f3 --- /dev/null +++ b/lib/board.go @@ -0,0 +1,210 @@ +package lib + +import ( + "fmt" + "math" + "math/rand" + + "golang.org/x/exp/slices" +) + +type Board struct { + grid [16]int + empty [2]int +} + +const ROW_COUNT = 4 + +var SOLVED_GRID = [16]int{ + 1, 2, 3, 4, + 5, 6, 7, 8, + 9, 10, 11, 12, + 13, 14, 15, 0, +} + +type Direction int + +const ( + LEFT Direction = iota + RIGHT + UP + DOWN +) + +func NewBoard() *Board { + return &Board{ + grid: [16]int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 0}, + empty: [2]int{3, 3}, + } +} + +// If all pieces on their desired places +// no more moves are needed and we can say that +// board is solved. +func (board *Board) Solved() bool { + return board.neededMoves() == 0 +} + +// Faster way to check if board is solved. +// Arrays are comparable in Go so we can simply +// compare desired state with current +func (board *Board) SolvedFast() bool { + return board.grid == SOLVED_GRID +} + +func (b *Board) Print() { + for i, cell := range b.grid { + if cell == 0 { + fmt.Printf(" ") + } else { + fmt.Printf("%3d", cell) + } + if i == 3 || i == 7 || i == 11 { + fmt.Println() + } + } + fmt.Println() +} + +func (b *Board) PossibleDirections() []Direction { + directions := []Direction{} + + if b.empty[0] != 0 { + directions = append(directions, UP) + } + + if b.empty[0] != 3 { + directions = append(directions, DOWN) + } + + if b.empty[1] != 0 { + directions = append(directions, LEFT) + } + + if b.empty[1] != 3 { + directions = append(directions, RIGHT) + } + + return directions +} + +// "Moves" empty cell to new position. +// It's easier to reason if we will move +// empty cell as another piece rather than +// moving pieces that are surrounds it. +func (b *Board) Move(d Direction) { + possibleDirections := b.PossibleDirections() + + if slices.Index(possibleDirections, d) == -1 { + return + } + + toRow, toCol := directionToStep(d) + + newRow := b.empty[0] + toRow + newCol := b.empty[1] + toCol + + piceToSwap := b.get(newRow, newCol) + + b.set(b.empty[0], b.empty[1], piceToSwap) + b.set(newRow, newCol, 0) + + b.empty[0] = newRow + b.empty[1] = newCol +} + +func (b *Board) Shuffle(steps int) []Direction { + moves := []Direction{} + + for i := 0; i < steps; i++ { + possibleDirections := b.PossibleDirections() + + // Remove opposite moves to prevent + // moving around one cell + if len(moves) != 0 { + last := moves[len(moves)-1] + possibleDirections = slices.DeleteFunc( + possibleDirections, + func(direction Direction) bool { + return oppositeDirections(direction, last) + }) + } + + rand.Shuffle(len(possibleDirections), func(i, j int) { + possibleDirections[i], possibleDirections[j] = possibleDirections[j], possibleDirections[i] + }) + + nextMove := possibleDirections[0] + + b.Move(nextMove) + + moves = append(moves, nextMove) + } + + return moves +} + +// Optimistic number. Indicates +// sum of number of moves each board piece should do +// to get to desired position. It ignores real "circular" +// moves and calculates moves as if only one piece exists on the board. +func (board *Board) neededMoves() int { + neededMoves := 0 + + for row := 0; row < 4; row++ { + for col := 0; col < 4; col++ { + + number := board.get(row, col) + + if number == 0 { + continue + } + + neededMoves += rectilinearDistance(number, row, col) + } + } + + return neededMoves +} + +func (b *Board) get(row, col int) int { + return b.grid[row*ROW_COUNT+col] +} + +func (b *Board) set(row, col, val int) { + b.grid[row*ROW_COUNT+col] = val +} + +func originalPosition(number int) (int, int) { + return (number - 1) / 4, (number - 1) % 4 +} + +// Or "Manhattan distance". We use it to calculate "shortest" path +// to desired piece position. +// https://en.wikipedia.org/wiki/Taxicab_geometry +func rectilinearDistance(number, i, j int) int { + origRow, origCol := originalPosition(number) + return int(math.Abs(float64(origRow-i)) + math.Abs(float64(origCol-j))) +} + +func directionToStep(d Direction) (int, int) { + switch d { + case UP: + return -1, 0 + case DOWN: + return 1, 0 + case LEFT: + return 0, -1 + case RIGHT: + return 0, 1 + default: + return 0, 0 + } +} + +func oppositeDirections(a Direction, b Direction) bool { + ar, al := directionToStep(a) + br, bl := directionToStep(b) + + return ar+br == 0 && al+bl == 0 +} diff --git a/lib/board_test.go b/lib/board_test.go new file mode 100644 index 0000000..16d0f42 --- /dev/null +++ b/lib/board_test.go @@ -0,0 +1,122 @@ +package lib + +import ( + "testing" + + "golang.org/x/exp/slices" +) + +func TestSolvedState(t *testing.T) { + board := NewBoard() + if !board.Solved() { + t.Error("Initial state should be solved") + } + + boardWithShuffledPieces := Board{grid: [16]int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 0, 15}, empty: [2]int{3, 3}} + + if boardWithShuffledPieces.Solved() { + t.Error("Shuffled board should not be solved") + } +} + +func TestSolvedFast(t *testing.T) { + board := NewBoard() + if !board.SolvedFast() { + t.Error("Initial state should be solved") + } + + boardWithShuffledPieces := Board{ + grid: [16]int{ + 1, 2, 3, 4, + 5, 6, 7, 8, + 9, 10, 11, 12, + 13, 14, 0, 15, + }, + empty: [2]int{3, 3}} + + if boardWithShuffledPieces.SolvedFast() { + t.Error("Shuffled board should not be solved") + } +} + +func TestPossibleDirections(t *testing.T) { + board := NewBoard() + directions := board.PossibleDirections() + + if len(directions) != 2 { + t.Error("For initial state only UP and LEFT directions should be available") + } + + isUPresent := slices.Index(directions, UP) + isLeftPresent := slices.Index(directions, LEFT) + + if isLeftPresent == -1 || isUPresent == -1 { + t.Error("UP and LEFT directions should be present") + } +} + +func TestMove(t *testing.T) { + board := NewBoard() + board.Move(LEFT) + + toTheRight := board.grid == [16]int{ + 1, 2, 3, 4, + 5, 6, 7, 8, + 9, 10, 11, 12, + 13, 14, 0, 15, + } + + if !toTheRight { + t.Error("Move should move pieces") + } + + if board.empty != [2]int{3, 2} { + t.Error("after Move new empty position should be set") + } + + board.Move(UP) + board.Move(UP) + board.Move(UP) + + tripleUp := board.grid == [16]int{ + 1, 2, 0, 4, + 5, 6, 3, 8, + 9, 10, 7, 12, + 13, 14, 11, 15, + } + + if !tripleUp { + t.Error("Corrupt state after moving") + } + + board.Move(UP) + + asBefore := board.grid == [16]int{ + 1, 2, 0, 4, + 5, 6, 3, 8, + 9, 10, 7, 12, + 13, 14, 11, 15, + } + + if !asBefore { + t.Error("If we cannot move further state should stay the same") + } +} + +func TestOppositeDirections(t *testing.T) { + vertical := oppositeDirections(UP, DOWN) + horizontal := oppositeDirections(LEFT, RIGHT) + + if !vertical || !horizontal { + t.Error("Opposite direction should return true") + } +} + +func TestShuffle(t *testing.T) { + board := NewBoard() + board.Shuffle(2) + + if board.SolvedFast() { + t.Error("Board should be in unsolved state after shuffle") + } +} diff --git a/lib/game.go b/lib/game.go new file mode 100644 index 0000000..5d84d5d --- /dev/null +++ b/lib/game.go @@ -0,0 +1,58 @@ +package lib + +import ( + "fmt" + + "atomicgo.dev/keyboard" + "atomicgo.dev/keyboard/keys" +) + +type Game struct { + board *Board +} + +func (g *Game) PrintState() { + // Works only on Linux + fmt.Print("\033[H\033[2J") + fmt.Printf("To quit game press ESC.\n") + g.board.Print() + fmt.Printf("Solved: %t\n", g.board.SolvedFast()) +} + +func (g *Game) Loop() { + g.PrintState() + + keyboard.Listen(func(key keys.Key) (stop bool, err error) { + switch key.Code { + case keys.CtrlC, keys.Escape: + return true, nil + case keys.Up: + g.board.Move(DOWN) + case keys.Down: + g.board.Move(UP) + case keys.Left: + g.board.Move(RIGHT) + case keys.Right: + g.board.Move(LEFT) + default: + fmt.Printf("\rYou pressed: %s\n", key) + } + + g.PrintState() + + if g.board.SolvedFast() { + fmt.Printf("\rYou Won!\n") + return true, nil + } + + return false, nil + }) +} + +func Start() { + game := Game{board: NewBoard()} + + game.board.Shuffle(10) + + game.Loop() +}