Skip to content

Commit e004c53

Browse files
authored
feat: Implement Tetris (#19)
* Add a basic board, model and tetris game * Add shape logic and drawing * Add ticker state updates and collision detection * Add rotations and piece movements * Add line removal * Add all shapes and create a random one * Remove gameboard pckg * Add a sidebar * Switch to using tea's Tick method for the loop * Add an animation when lines are completed * Add scoring * Add pause * Quit when game is lost * Add a shape randomizer * Add docs and some code cleanup * Add difficulty * Fix tests * Remove Tetris from the backlog * Break down gamestate to smaller files * Code and doc cleanup * Switch to using no color for the background, add play area border * Make down to fully drop the piece * Change game order
1 parent 0a7e677 commit e004c53

File tree

14 files changed

+1141
-1
lines changed

14 files changed

+1141
-1
lines changed

README.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,6 @@ To contribute:
8787
invaders)
8888
* [ ] Simon
8989
* [ ] Solitaire
90-
* [ ] Tetris
9190
* [ ] Tron
9291
* [ ] Typespeed
9392

cmd/gg/main.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"github.com/Kaamkiya/gg/internal/app/pong"
1111
"github.com/Kaamkiya/gg/internal/app/snake"
1212
"github.com/Kaamkiya/gg/internal/app/sudoku"
13+
"github.com/Kaamkiya/gg/internal/app/tetris"
1314
"github.com/Kaamkiya/gg/internal/app/tictactoe"
1415
"github.com/Kaamkiya/gg/internal/app/twenty48"
1516

@@ -30,6 +31,7 @@ func main() {
3031
huh.NewOption("maze", "maze"),
3132
huh.NewOption("hangman", "hangman"),
3233
huh.NewOption("snake", "snake"),
34+
huh.NewOption("tetris", "tetris"),
3335
huh.NewOption("connect 4 (2 player)", "connect4"),
3436
huh.NewOption("pong (2 player)", "pong"),
3537
huh.NewOption("tictactoe (2 player)", "tictactoe"),
@@ -63,6 +65,8 @@ func main() {
6365
snake.Run()
6466
case "sudoku":
6567
sudoku.Run()
68+
case "tetris":
69+
tetris.Run()
6670
default:
6771
panic("This game either doesn't exist or hasn't been implemented.")
6872
}

internal/app/tetris/color/color.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package color
2+
3+
import "github.com/charmbracelet/lipgloss"
4+
5+
type Color int
6+
7+
const (
8+
None Color = iota
9+
Blue
10+
Green
11+
Orange
12+
Pink
13+
Teal
14+
Purple
15+
Magenta
16+
Beige
17+
)
18+
19+
var defaultStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#f9f6f2"))
20+
21+
var Colors = map[Color]lipgloss.Style{
22+
None: defaultStyle.Background(lipgloss.NoColor{}),
23+
Blue: defaultStyle.Background(lipgloss.Color("#063970")),
24+
Green: defaultStyle.Background(lipgloss.Color("#4CA74F")),
25+
Orange: defaultStyle.Background(lipgloss.Color("#CF6209")),
26+
Pink: defaultStyle.Background(lipgloss.Color("#D85B85")),
27+
Teal: defaultStyle.Background(lipgloss.Color("#2692E8")),
28+
Purple: defaultStyle.Background(lipgloss.Color("#9047A3")),
29+
Magenta: defaultStyle.Background(lipgloss.Color("#CA1F7B")),
30+
Beige: defaultStyle.Background(lipgloss.Color("#FFFDD0")),
31+
}

internal/app/tetris/difficulty.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package tetris
2+
3+
import "time"
4+
5+
const (
6+
// initialDifficulyCountDown is the number of pieces that trigger a difficulty increase
7+
initialDifficulyCountDown = 10
8+
// initialDifficulyLevel is the factor that increases scoring and decreases the game tick. Increased by 0.1 on difficulty increase.
9+
initialDifficulyLevel = 1.0
10+
)
11+
12+
type difficulty struct {
13+
countdown int
14+
level float32
15+
gameProgressTickDelay time.Duration
16+
}
17+
18+
func (gs *gameState) adjustDifficulty() {
19+
if gs.currentDifficulty.countdown <= 1 {
20+
gs.currentDifficulty.countdown = initialDifficulyCountDown
21+
gs.currentDifficulty.level += 0.1
22+
gs.currentDifficulty.gameProgressTickDelay = time.Duration(float32(initialGameProgressTickDelay) / gs.currentDifficulty.level)
23+
24+
return
25+
}
26+
27+
gs.currentDifficulty.countdown--
28+
}

internal/app/tetris/gameloop.go

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
package tetris
2+
3+
import (
4+
"strconv"
5+
"strings"
6+
"time"
7+
8+
"github.com/Kaamkiya/gg/internal/app/tetris/color"
9+
"github.com/Kaamkiya/gg/internal/app/tetris/shape"
10+
tea "github.com/charmbracelet/bubbletea"
11+
"github.com/charmbracelet/lipgloss"
12+
)
13+
14+
type gameProgressTick struct{}
15+
16+
func initialModel() gameState {
17+
return gameState{
18+
nil,
19+
nil,
20+
newGameboard(color.Colors),
21+
shape.NewRandomizer(),
22+
0,
23+
&difficulty{
24+
initialDifficulyCountDown,
25+
initialDifficulyLevel,
26+
initialGameProgressTickDelay,
27+
},
28+
false,
29+
dropFinished,
30+
}
31+
}
32+
33+
func (gs *gameState) Init() tea.Cmd {
34+
return func() tea.Msg {
35+
return gameProgressTick{}
36+
}
37+
}
38+
39+
// Update implements the game loop by handling the tea.Msg structs. There are the following flows:
40+
// - Base loop: gameProgressTick -> handleGameProgress -> gameProgressTick
41+
// - Line complete: gameProgressTick -> handleGameProgress -> lineAnimationTick
42+
// - Line animation ongoing: lineAnimationTick -> handleLineAnimation -> lineAnimationTick
43+
// - Line animation finished: lineAnimationTick -> handleLineAnimation -> gameProgressTick
44+
func (gs *gameState) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
45+
switch msg := msg.(type) {
46+
case tea.KeyMsg:
47+
if msg.String() == "ctrl+c" || msg.String() == "q" || msg.String() == "Q" {
48+
return gs, tea.Quit
49+
} else if !gs.isPaused {
50+
switch msg.String() {
51+
case "h", "H", "left":
52+
gs.handleLeft()
53+
case "l", "L", "right":
54+
gs.handleRight()
55+
case "j", "J", "down":
56+
gs.handleDrop()
57+
case "z", "Z":
58+
gs.handleLeftRotate()
59+
case "x", "X":
60+
gs.handleRightRotate()
61+
case "p", "P":
62+
gs.isPaused = true
63+
return gs, nil
64+
}
65+
} else {
66+
if msg.String() == "p" || msg.String() == "P" {
67+
gs.isPaused = false
68+
return gs, tea.Tick(gs.currentDifficulty.gameProgressTickDelay, func(time.Time) tea.Msg { return gameProgressTick{} })
69+
}
70+
}
71+
case gameProgressTick:
72+
if gs.isPaused {
73+
return gs, nil
74+
}
75+
return gs, gs.handleGameProgressTick()
76+
case lineAnimationTick:
77+
return gs, gs.handleLineAnimationTick(msg)
78+
}
79+
80+
return gs, nil
81+
}
82+
83+
// View method creates the view by generating the play area and the sidebar. Although the Tetris board size is
84+
// defined by Height and Width, the play area is larger. Each Tetris box is 4 characters wide and 2 characters tall
85+
// so the total play area size is 2 * Height * 4 * Width characters. On each line of the play area, a sidebar
86+
// line is appended.
87+
func (gs *gameState) View() string {
88+
boardBuilder := strings.Builder{}
89+
boardBuilder.Grow((height+2)*(width+2)*8 + 22*14)
90+
91+
borderStyle := lipgloss.NewStyle().
92+
BorderStyle(lipgloss.NormalBorder()).
93+
BorderForeground(lipgloss.AdaptiveColor{Light: "#200C0C", Dark: "#BEC1C6"})
94+
95+
gameGridLines := buildGameGrid(gs)
96+
sideBarLines := buildSidebar(gs)
97+
98+
for i := range height * 2 {
99+
var playAreaStr string
100+
101+
if i == 0 {
102+
playAreaStr = borderStyle.
103+
BorderTop(true).
104+
BorderRight(true).
105+
BorderLeft(true).
106+
Render(gameGridLines[i])
107+
} else if i == height*2-1 {
108+
playAreaStr = borderStyle.
109+
BorderRight(true).
110+
BorderBottom(true).
111+
BorderLeft(true).
112+
Render(gameGridLines[i])
113+
} else {
114+
playAreaStr = borderStyle.BorderLeft(true).BorderRight(true).Render(gameGridLines[i])
115+
}
116+
117+
boardBuilder.WriteString(playAreaStr)
118+
119+
if i < len(sideBarLines) {
120+
boardBuilder.WriteString(sideBarLines[i])
121+
}
122+
123+
boardBuilder.WriteString("\n")
124+
}
125+
126+
return boardBuilder.String()
127+
}
128+
129+
func buildGameGrid(gs *gameState) [height * 2]string {
130+
gridLines := [height * 2]string{}
131+
132+
for i := range height {
133+
lineBuilder := strings.Builder{}
134+
lineBuilder.Grow(width * 4)
135+
136+
for j := range width {
137+
nextChar := gs.gameBoard.Colors[gs.gameBoard.Grid[i][j]].Render(" ")
138+
lineBuilder.WriteString(nextChar)
139+
}
140+
141+
line := lineBuilder.String()
142+
gridLines[2*i] = line
143+
gridLines[2*i+1] = line
144+
}
145+
146+
return gridLines
147+
}
148+
149+
func buildSidebar(gs *gameState) [14]string {
150+
sidebarLines := [14]string{}
151+
sidebarLines[0] = " Next Shape "
152+
sidebarLines[1] = " "
153+
154+
if gs.nextShape != nil {
155+
grid := gs.nextShape.GetGrid()
156+
157+
for i := range 4 {
158+
if i >= len(grid) {
159+
sidebarLines[i+2] = " "
160+
} else {
161+
lineBuilder := strings.Builder{}
162+
spaceLength := (22 - len(grid[i])) / 2
163+
lineBuilder.WriteString(strings.Repeat(" ", spaceLength))
164+
165+
for j := range grid[i] {
166+
if grid[i][j] {
167+
lineBuilder.WriteString(gs.gameBoard.Colors[gs.nextShape.GetColor()].Render(" "))
168+
} else {
169+
lineBuilder.WriteString(" ")
170+
}
171+
}
172+
lineBuilder.WriteString(strings.Repeat(" ", spaceLength))
173+
174+
sidebarLines[i+2] = lineBuilder.String()
175+
}
176+
}
177+
}
178+
179+
scoreStr := strconv.FormatUint(uint64(gs.score), 10)
180+
sidebarLines[6] = " "
181+
sidebarLines[7] = " Your score is "
182+
sidebarLines[8] = strings.Repeat(" ", 22-len(scoreStr)) + scoreStr
183+
sidebarLines[9] = " "
184+
sidebarLines[10] = " hjl/←↓→ to move "
185+
sidebarLines[11] = " z,x to rotate "
186+
sidebarLines[12] = " q/ctl+c to quit "
187+
sidebarLines[13] = " p to pause "
188+
189+
return sidebarLines
190+
}

0 commit comments

Comments
 (0)