2019-11-21 00:18:49 +00:00
|
|
|
---
|
2020-01-18 16:48:50 +00:00
|
|
|
title: "Terminal-based Tetris - Part 1: Procedural polyomino generation"
|
|
|
|
date: 2020-01-18T08:30:00-07:00
|
2019-11-21 00:18:49 +00:00
|
|
|
categories: [tutorial]
|
2019-12-12 03:28:29 +00:00
|
|
|
aliases:
|
|
|
|
- /post/textris-1/
|
2019-11-21 00:18:49 +00:00
|
|
|
---
|
|
|
|
|
2020-01-18 16:48:50 +00:00
|
|
|
This is the first part of 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).
|
2019-11-21 15:37:27 +00:00
|
|
|
|
2020-01-23 00:12:32 +00:00
|
|
|
For a complete implementation of a Tetris clone in Go, see [netris](https://gitlab.com/tslocum/netris).
|
2019-11-21 00:18:49 +00:00
|
|
|
|
2020-01-18 16:48:50 +00:00
|
|
|
# Disclaimer
|
2019-11-21 15:37:27 +00:00
|
|
|
|
2019-11-21 00:54:58 +00:00
|
|
|
Tetris is a registered trademark of the Tetris Holding, LLC.
|
|
|
|
|
|
|
|
Rocket Nine Labs is in no way affiliated with Tetris Holding, LLC.
|
|
|
|
|
2020-01-18 16:48:50 +00:00
|
|
|
## Minos
|
2019-11-21 00:18:49 +00:00
|
|
|
|
2020-01-18 16:48:50 +00:00
|
|
|
Game pieces are called "minos" because they are [polyominos](https://en.wikipedia.org/wiki/Polyomino).
|
|
|
|
This tutorial series will focus on the seven one-sided [terominos](https://en.wikipedia.org/wiki/Tetromino),
|
|
|
|
where each piece has four blocks.
|
2019-11-21 00:18:49 +00:00
|
|
|
|
2020-01-18 16:48:50 +00:00
|
|
|
```
|
|
|
|
XX X X X XX XX
|
|
|
|
XXXX XX XXX XXX XXX XX XX
|
2019-11-21 00:18:49 +00:00
|
|
|
|
2020-01-18 16:48:50 +00:00
|
|
|
I O T J L S Z
|
|
|
|
```
|
2019-11-21 15:37:27 +00:00
|
|
|
|
2019-11-21 14:36:58 +00:00
|
|
|
The number of blocks a mino has is also known as its rank.
|
|
|
|
|
2020-01-18 16:48:50 +00:00
|
|
|
### Mino data model
|
2019-11-21 00:18:49 +00:00
|
|
|
|
2020-01-18 16:48:50 +00:00
|
|
|
Tetris is played on an [X-Y grid](https://en.wikipedia.org/wiki/Cartesian_coordinate_system),
|
|
|
|
so we will store minos as slices of points.
|
2019-11-21 00:18:49 +00:00
|
|
|
|
2020-01-18 02:14:28 +00:00
|
|
|
{{< highlight go >}}
|
2019-11-21 00:18:49 +00:00
|
|
|
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
|
|
|
|
|
2020-01-18 16:48:50 +00:00
|
|
|
var exampleMino = Mino{{0, 0}, {1, 0}, {2, 0}, {1, 1}} // T piece
|
2020-01-18 02:14:28 +00:00
|
|
|
{{< / highlight >}}
|
2019-11-21 00:18:49 +00:00
|
|
|
|
2020-01-18 16:48:50 +00:00
|
|
|
### Generating minos
|
2019-11-21 00:18:49 +00:00
|
|
|
|
2019-11-21 14:36:58 +00:00
|
|
|
Instead of hard-coding each piece into our game, let's procedurally generate them.
|
|
|
|
This allows us to play with any size of mino.
|
|
|
|
|
2020-01-18 16:48:50 +00:00
|
|
|
### Sorting and comparing minos
|
2019-11-21 00:18:49 +00:00
|
|
|
|
2020-01-18 16:48:50 +00:00
|
|
|
To compare minos efficiently while generating, we will define a String method
|
|
|
|
which sorts a mino's coordinates before printing them.
|
|
|
|
|
|
|
|
This will allow us to identify duplicate minos by comparing their string values.
|
2019-11-21 00:18:49 +00:00
|
|
|
|
2020-01-18 02:14:28 +00:00
|
|
|
{{< highlight go >}}
|
2019-11-21 00:18:49 +00:00
|
|
|
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()
|
|
|
|
}
|
2020-01-18 02:14:28 +00:00
|
|
|
{{< / highlight >}}
|
2019-11-21 00:18:49 +00:00
|
|
|
|
2020-01-18 16:48:50 +00:00
|
|
|
Origin returns a translated mino located at `0,0` and with positive coordinates
|
|
|
|
only.
|
|
|
|
|
|
|
|
A mino with the coordinates `(-3, -1), (-2, -1), (-1, -1), (-2, 0)` would be
|
|
|
|
translated to `(0, 0), (1, 0), (2, 0), (1, 1)`.
|
2019-11-21 00:18:49 +00:00
|
|
|
|
2020-01-18 02:14:28 +00:00
|
|
|
{{< highlight go >}}
|
2019-11-21 00:18:49 +00:00
|
|
|
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
|
|
|
|
}
|
2020-01-18 02:14:28 +00:00
|
|
|
{{< / highlight >}}
|
2019-11-21 00:18:49 +00:00
|
|
|
|
2020-01-18 16:48:50 +00:00
|
|
|
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).
|
2019-11-21 14:36:58 +00:00
|
|
|
|
2020-01-18 16:48:50 +00:00
|
|
|
Flatten calculates the flattest side of a mino and returns a flattened mino.
|
2019-11-21 00:18:49 +00:00
|
|
|
|
2020-01-18 02:14:28 +00:00
|
|
|
{{< highlight go >}}
|
2019-11-21 00:18:49 +00:00
|
|
|
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))
|
|
|
|
for i := 0; i < len(m); i++ {
|
2020-01-18 16:48:50 +00:00
|
|
|
newMino[i] = rotateFunc(m[i])
|
2019-11-21 00:18:49 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
return newMino
|
|
|
|
}
|
2020-01-18 02:14:28 +00:00
|
|
|
{{< / highlight >}}
|
2019-11-21 00:18:49 +00:00
|
|
|
|
2019-11-21 14:36:58 +00:00
|
|
|
Variations returns the three other rotations of a mino.
|
2019-11-21 00:18:49 +00:00
|
|
|
|
2020-01-18 02:14:28 +00:00
|
|
|
{{< highlight go >}}
|
2019-11-21 00:18:49 +00:00
|
|
|
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
|
|
|
|
}
|
2020-01-18 02:14:28 +00:00
|
|
|
{{< / highlight >}}
|
2019-11-21 00:18:49 +00:00
|
|
|
|
2020-01-18 16:48:50 +00:00
|
|
|
Canonical returns a flattened mino translated to `0,0`.
|
2019-11-21 00:18:49 +00:00
|
|
|
|
2020-01-18 02:14:28 +00:00
|
|
|
{{< highlight go >}}
|
2019-11-21 00:18:49 +00:00
|
|
|
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()
|
|
|
|
}
|
2020-01-18 02:14:28 +00:00
|
|
|
{{< / highlight >}}
|
2019-11-21 00:18:49 +00:00
|
|
|
|
2020-01-18 16:48:50 +00:00
|
|
|
### Generating additional minos
|
|
|
|
|
|
|
|
Starting with a monomino (a mino with a single point: `0,0`), we will generate
|
|
|
|
additional minos by adding neighboring points.
|
2019-11-21 14:36:58 +00:00
|
|
|
|
2019-11-21 15:37:27 +00:00
|
|
|
Neighborhood returns the [Von Neumann neighborhood](https://en.wikipedia.org/wiki/Von_Neumann_neighborhood) of a point.
|
|
|
|
|
2020-01-18 02:14:28 +00:00
|
|
|
{{< highlight go >}}
|
2019-11-21 15:37:27 +00:00
|
|
|
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}}
|
|
|
|
}
|
2020-01-18 02:14:28 +00:00
|
|
|
{{< / highlight >}}
|
2019-11-21 15:37:27 +00:00
|
|
|
|
2019-12-12 03:28:29 +00:00
|
|
|
NewPoints calculates the neighborhood of each point of a mino and returns only the new points.
|
2019-11-21 00:18:49 +00:00
|
|
|
|
2020-01-18 02:14:28 +00:00
|
|
|
{{< highlight go >}}
|
2019-11-21 00:18:49 +00:00
|
|
|
func (m Mino) HasPoint(p Point) bool {
|
|
|
|
for _, mp := range m {
|
|
|
|
if mp == p {
|
|
|
|
return true
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
|
2019-11-21 15:37:27 +00:00
|
|
|
func (m Mino) NewPoints() []Point {
|
|
|
|
var newPoints []Point
|
2019-11-21 00:18:49 +00:00
|
|
|
|
|
|
|
for _, p := range m {
|
|
|
|
for _, np := range p.Neighborhood() {
|
2019-12-12 03:28:29 +00:00
|
|
|
if m.HasPoint(np) {
|
|
|
|
continue
|
2019-11-21 00:18:49 +00:00
|
|
|
}
|
2019-12-12 03:28:29 +00:00
|
|
|
|
|
|
|
newPoints = append(newPoints, np)
|
2019-11-21 00:18:49 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2019-11-21 15:37:27 +00:00
|
|
|
return newPoints
|
2019-11-21 00:18:49 +00:00
|
|
|
}
|
2020-01-18 02:14:28 +00:00
|
|
|
{{< / highlight >}}
|
2019-11-21 15:37:27 +00:00
|
|
|
|
|
|
|
NewMinos returns a new mino for every new neighborhood point of a supplied mino.
|
2019-11-21 00:18:49 +00:00
|
|
|
|
2020-01-18 02:14:28 +00:00
|
|
|
{{< highlight go >}}
|
2019-11-21 15:37:27 +00:00
|
|
|
func (m Mino) NewMinos() []Mino {
|
|
|
|
points := m.NewPoints()
|
2019-11-21 00:18:49 +00:00
|
|
|
|
|
|
|
minos := make([]Mino, len(points))
|
|
|
|
for i, p := range points {
|
|
|
|
minos[i] = append(m, p).Canonical()
|
|
|
|
}
|
|
|
|
|
|
|
|
return minos
|
|
|
|
}
|
2020-01-18 02:14:28 +00:00
|
|
|
{{< / highlight >}}
|
2019-11-21 00:18:49 +00:00
|
|
|
|
2020-01-18 16:48:50 +00:00
|
|
|
### Generating unique minos
|
|
|
|
|
2019-11-21 15:37:27 +00:00
|
|
|
Generate procedurally generates minos of a supplied rank.
|
|
|
|
|
2020-01-18 16:48:50 +00:00
|
|
|
We generate minos for the rank below the requested rank and iterate over the
|
|
|
|
variations of each mino, saving and returning all unique variations.
|
|
|
|
|
2020-01-18 02:14:28 +00:00
|
|
|
{{< highlight go >}}
|
2019-11-21 15:37:27 +00:00
|
|
|
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}}
|
|
|
|
}
|
2020-01-18 02:14:28 +00:00
|
|
|
{{< / highlight >}}
|
2019-11-21 15:37:27 +00:00
|
|
|
|
2020-01-18 16:48:50 +00:00
|
|
|
# Stay tuned...
|
2019-11-21 00:18:49 +00:00
|
|
|
|
2020-01-18 16:48:50 +00:00
|
|
|
In part two we will create a [matrix](https://tetris.wiki/Playfield) to hold our minos and implement [SRS rotation](https://tetris.wiki/SRS).
|