You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

8.5 KiB

title categories draft aliases
Textris - Creating a terminal-based Tetris clone - Part 1: Pieces and playfield [tutorial] true [/post/textris-1/]

This is the first in a series of tutorials on creating a terminal-based Tetris clone with Go.

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

Game pieces are called "minos" because they are polyominos. This tutorial focuses on the seven one-sided terominos, 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. 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.

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 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 >}}