--- title: "Textris - Creating a terminal-based Tetris clone - Part 1: Pieces and playfield" #date: 2019-11-26T01:42:18-07:00 categories: [tutorial] draft: true aliases: - /post/textris-1/ --- This is the first in a series of tutorials on creating a [terminal-based](https://en.wikipedia.org/wiki/Text-based_user_interface) [Tetris](https://en.wikipedia.org/wiki/Tetris) clone with [Go](https://golang.org). Game pieces (minos) are generated and added to a playfield (matrix). ## Disclaimer Tetris is a registered trademark of the Tetris Holding, LLC. Rocket Nine Labs is in no way affiliated with Tetris Holding, LLC. ## Contents * [Minos](#minos) * [Data model](#data-model) * [Generation](#generation) * [Comparing and sorting](#comparing-and-sorting) * [Generating new minos](#generating-new-minos) * [Matrix](#matrix) * [Data model](#data-model) # Minos Game pieces are called "minos" because they are [polyominos](https://en.wikipedia.org/wiki/Polyomino). This tutorial focuses on the seven one-sided [terominos](https://en.wikipedia.org/wiki/Tetromino), where each piece has four blocks. The number of blocks a mino has is also known as its rank. | I | O | T | J | L | S | Z | | --- | --- | --- | --- | --- | --- | --- | |
|
████
██|
██
█|
███
█|
███
█|
███
██|
██
██| ## Data model Tetris is played on an [X-Y grid](https://en.wikipedia.org/wiki/Cartesian_coordinate_system). We will store Minos as slices of points. {{< highlight go >}} type Point struct { X, Y int } func (p Point) Rotate90() Point { return Point{p.Y, -p.X} } func (p Point) Rotate180() Point { return Point{-p.X, -p.Y} } func (p Point) Rotate270() Point { return Point{-p.Y, p.X} } func (p Point) Reflect() Point { return Point{-p.X, p.Y} } type Mino []Point var minoT = Mino{{0, 0}, {1, 0}, {2, 0}, {1, 1}} {{< / highlight >}} ## Generation Instead of hard-coding each piece into our game, let's procedurally generate them. This allows us to play with any size of mino. ### Comparing and sorting To compare minos efficiently while generating, we will compare their string representation. We will define a String method which sorts the coordinates before printing. This allows us to compare duplicate minos just by checking their string values. {{< highlight go >}} func (m Mino) Len() int { return len(m) } func (m Mino) Swap(i, j int) { m[i], m[j] = m[j], m[i] } func (m Mino) Less(i, j int) bool { return m[i].Y < m[j].Y || (m[i].Y == m[j].Y && m[i].X < m[j].X) } func (m Mino) String() string { sort.Sort(m) var b strings.Builder b.Grow(5*len(m) + (len(m) - 1)) for i := range m { if i > 0 { b.WriteRune(',') } b.WriteRune('(') b.WriteString(strconv.Itoa(m[i].X)) b.WriteRune(',') b.WriteString(strconv.Itoa(m[i].Y)) b.WriteRune(')') } return b.String() } {{< / highlight >}} Origin returns a translated mino located at 0,0 and with positive coordinates only. {{< highlight go >}} func (m Mino) minCoords() (int, int) { minx := m[0].X miny := m[0].Y for _, p := range m[1:] { if p.X < minx { minx = p.X } if p.Y < miny { miny = p.Y } } return minx, miny } func (m Mino) Origin() Mino { minx, miny := m.minCoords() newMino := make(Mino, len(m)) for i, p := range m { newMino[i].X = p.X - minx newMino[i].Y = p.Y - miny } return newMino } {{< / highlight >}} Another transformation is applied not only to help identify duplicate minos, but also to retrieve their initial rotation, as [pieces should spawn flat-side down](https://tetris.wiki/Super_Rotation_System). The flattest side is calculated and a flattened mino is returned. {{< highlight go >}} func (m Mino) Flatten() Mino { var ( w, h = m.Size() sides [4]int // Left Top Right Bottom ) for i := 0; i < len(m); i++ { if m[i].Y == 0 { sides[3]++ } else if m[i].Y == (h - 1) { sides[1]++ } if m[i].X == 0 { sides[0]++ } else if m[i].X == (w - 1) { sides[2]++ } } var ( largestSide = 3 largestLength = sides[3] ) for i, s := range sides[:2] { if s > largestLength { largestSide = i largestLength = s } } var rotateFunc func(Point) Point switch largestSide { case 0: // Left rotateFunc = Point.Rotate270 case 1: // Top rotateFunc = Point.Rotate180 case 2: // Right rotateFunc = Point.Rotate90 default: // Bottom return m } newMino := make(Mino, len(m)) copy(newMino, m) for i := 0; i < len(m); i++ { newMino[i] = rotateFunc(newMino[i]) } return newMino } {{< / highlight >}} Variations returns the three other rotations of a mino. {{< highlight go >}} func (m Mino) Variations() []Mino { v := make([]Mino, 3) for i := 0; i < 3; i++ { v[i] = make(Mino, len(m)) } for j := 0; j < len(m); j++ { v[0][j] = m[j].Rotate90() v[1][j] = m[j].Rotate180() v[2][j] = m[j].Rotate270() } return v } {{< / highlight >}} Canonical returns a flattened mino translated to 0,0. {{< highlight go >}} func (m Mino) Canonical() Mino { var ( ms = m.Origin().String() c = -1 v = m.Origin().Variations() vs string ) for i := 0; i < 3; i++ { vs = v[i].Origin().String() if vs < ms { c = i ms = vs } } if c == -1 { return m.Origin().Flatten().Origin() } return v[c].Origin().Flatten().Origin() } {{< / highlight >}} ### Generating new minos Neighborhood returns the [Von Neumann neighborhood](https://en.wikipedia.org/wiki/Von_Neumann_neighborhood) of a point. {{< highlight go >}} func (p Point) Neighborhood() []Point { return []Point{ {p.X - 1, p.Y}, {p.X, p.Y - 1}, {p.X + 1, p.Y}, {p.X, p.Y + 1}} } {{< / highlight >}} NewPoints calculates the neighborhood of each point of a mino and returns only the new points. {{< highlight go >}} func (m Mino) HasPoint(p Point) bool { for _, mp := range m { if mp == p { return true } } return false } func (m Mino) NewPoints() []Point { var newPoints []Point for _, p := range m { for _, np := range p.Neighborhood() { if m.HasPoint(np) { continue } newPoints = append(newPoints, np) } } return newPoints } {{< / highlight >}} NewMinos returns a new mino for every new neighborhood point of a supplied mino. {{< highlight go >}} func (m Mino) NewMinos() []Mino { points := m.NewPoints() minos := make([]Mino, len(points)) for i, p := range points { minos[i] = append(m, p).Canonical() } return minos } {{< / highlight >}} Generate procedurally generates minos of a supplied rank. {{< highlight go >}} func Generate(rank int) ([]Mino, error) { switch { case rank < 0: return nil, errors.New("invalid rank") case rank == 0: return []Mino{}, nil case rank == 1: return []Mino{monomino()}, nil default: r, err := Generate(rank - 1) if err != nil { return nil, err } var ( minos []Mino s string found = make(map[string]bool) ) for _, mino := range r { for _, newMino := range mino.NewMinos() { s = newMino.Canonical().String() if found[s] { continue } minos = append(minos, newMino.Canonical()) found[s] = true } } return minos, nil } } func monomino() Mino { return Mino{{0, 0}} } {{< / highlight >}} # Matrix The matrix is typically 10 blocks wide and 20 blocks high. ## Data model A block is an integer representing the contents of a single X-Y coordinate on the matrix. {{< highlight go >}} type Block int const ( BlockNone Block = iota BlockSolidBlue BlockSolidCyan BlockSolidRed BlockSolidYellow BlockSolidMagenta BlockSolidGreen BlockSolidOrange ) {{< / highlight >}} The matrix will be stored as a slice of blocks. The zero-value of Block is a blank space. The matrix has a width, height and buffer height. The buffer is additional space above the visible playfield. {{< highlight go >}} type Matrix struct { W int // Width H in // Height B int // Buffer height M []Block // Contents } func NewMatrix(w int, h int, b int) *Matrix { m := Matrix{ W: w, H: h, B: b, M: make([]Block, w*(h+b)), } return &m } {{< / highlight >}} To retrieve the contents of a point, we calculate its index by multiplying the Y coordinate with the matrix width and adding the X coordinate. {{< highlight go >}} func I(x int, y int, w int) int { if x < 0 || x >= w || y < 0 { log.Panicf("failed to retrieve index for %d,%d width %d: invalid coordinates", x, y, w) } return (y * w) + x } func (m *Matrix) Block(x int, y int) Block { if y >= m.H+m.B { log.Panicf("failed to retrieve block at %d,%d: invalid y coordinate", x, y) } index := I(x, y, m.W) return m.M[index] } {{< / highlight >}}
██