diff --git a/README.md b/README.md index 35e6bb5..a1ff326 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ go build tetris.go ## Roadmap -- [ ] Line clear animations +- [x] Line clear animations - [ ] Hard drop and ghost piece - [ ] Level/speed progression and pause - [ ] High score persistence diff --git a/game/grid.go b/game/grid.go index d951d2a..3cf9b6b 100644 --- a/game/grid.go +++ b/game/grid.go @@ -1,6 +1,8 @@ package game import ( + "slices" + "tetris/engine/graphics" ) @@ -44,6 +46,16 @@ func (g *Grid) ClearFullRows() byte { return completed } +func (g *Grid) FullRows() []byte { + rows := []byte{} + for y := byte(0); y < byte(g.Height()); y++ { + if g.IsRowFull(y) { + rows = append(rows, y) + } + } + return rows +} + func (g *Grid) IsRowFull(y byte) bool { for x := range byte(g.Width()) { if g.At(x, y) == BLOCK_EMPTY { @@ -53,6 +65,17 @@ func (g *Grid) IsRowFull(y byte) bool { return true } +func (g *Grid) MoveRowsDown(rows ...byte) { + completed := byte(0) + for y := int(g.Height() - 1); y >= 0; y-- { + if slices.Contains(rows, byte(y)) { + completed++ + } else if completed > 0 { + g.MoveRowDown(byte(y), completed) + } + } +} + func (g *Grid) MoveRowDown(y, num_rows byte) { w := uint16(g.Width()) src := uint16(y) * w diff --git a/game/line_clear_animation.go b/game/line_clear_animation.go new file mode 100644 index 0000000..b5885e9 --- /dev/null +++ b/game/line_clear_animation.go @@ -0,0 +1,57 @@ +package game + +// LineClearAnimation clears completed lines cell-by-cell to create +// a visual wipe effect from the center outward. +// +// Once the lines are cleared, the remaining lines are moved down. +type LineClearAnimation struct { + // lines to clear. + lines []byte + // next index to clear to the left + leftIndex int8 + // next index to clear to the right + rightIndex int8 +} + +func (lca *LineClearAnimation) Reset() { + lca.lines = []byte{} +} + +func (lca LineClearAnimation) NumLines() int { + return len(lca.lines) +} + +func (lca *LineClearAnimation) SetLines(rows []byte) { + lca.lines = rows + + lca.leftIndex = (GRID_WIDTH - 1) / 2 + if (GRID_WIDTH-1)%2 != 0 { + lca.rightIndex = lca.leftIndex + 1 + } else { + lca.rightIndex = lca.leftIndex + } +} + +func (lca *LineClearAnimation) Update(grid *Grid) { + if lca.Completed() { + return + } + + if lca.leftIndex < 0 || lca.rightIndex > GRID_WIDTH { + grid.MoveRowsDown(lca.lines...) + lca.Reset() + return + } + + for _, lineIndex := range lca.lines { + grid.Set(byte(lca.leftIndex), lineIndex, BLOCK_EMPTY) + grid.Set(byte(lca.rightIndex), lineIndex, BLOCK_EMPTY) + } + + lca.leftIndex-- + lca.rightIndex++ +} + +func (lca LineClearAnimation) Completed() bool { + return lca.NumLines() == 0 +} diff --git a/game/state/handlers/gameplay.go b/game/state/handlers/gameplay.go index 0027b0a..19ff3b3 100644 --- a/game/state/handlers/gameplay.go +++ b/game/state/handlers/gameplay.go @@ -16,21 +16,24 @@ import ( ) type GamePlay struct { - shape game.Shape - shape_pos core.Vec2i8 - score game.Score - dropTimer core.IntervalTimer - moveTimer core.IntervalTimer - grid game.Grid - nextShape game.Shape - shapeQueue *game.ShapeQueue - r draw.Renderer + shape game.Shape + shape_pos core.Vec2i8 + score game.Score + dropTimer core.IntervalTimer + moveTimer core.IntervalTimer + lineClearTimer core.IntervalTimer + grid game.Grid + nextShape game.Shape + shapeQueue *game.ShapeQueue + r draw.Renderer + lineClearAnimation game.LineClearAnimation } func NewGamePlay() *GamePlay { return &GamePlay{ - dropTimer: core.NewIntervalTimer(0.3), - moveTimer: core.NewIntervalTimer(0.1), + dropTimer: core.NewIntervalTimer(0.3), + moveTimer: core.NewIntervalTimer(0.1), + lineClearTimer: core.NewIntervalTimer(0.05), r: draw.Renderer{ Theme: &draw.Theme{ FrameBG: color.RGBA{R: 30, G: 30, B: 46, A: 255}, @@ -50,6 +53,7 @@ func (gp *GamePlay) Enter() { gp.shapeQueue = game.NewShapeQueue() gp.nextShape = gp.shapeQueue.Next() gp.SpawnShape() + gp.lineClearAnimation.Reset() } func (GamePlay) Exit() { @@ -81,6 +85,13 @@ func (gp *GamePlay) Update(fsm state.Transitioner, delta float32) { gp.dropTimer.SetInterval(0.3) } + if !gp.lineClearAnimation.Completed() { + if gp.lineClearTimer.UpdateReset(delta) { + gp.lineClearAnimation.Update(&gp.grid) + } + return + } + if rl.IsKeyPressed(rl.KeyUp) { rotated := gp.shape.RotateCW() if !game.CheckShapeCollision(gp.shape_pos, &rotated, &gp.grid) { @@ -107,15 +118,16 @@ func (gp *GamePlay) Update(fsm state.Transitioner, delta float32) { // Update position if it does not collide if game.CheckShapeCollision(new_pos, &gp.shape, &gp.grid) { gp.LockShape() - num_rows := gp.grid.ClearFullRows() - if num_rows > 0 { - audio.Play(assets.SFX_ROW_CLEARED) - gp.score.Lines(num_rows) - } gp.SpawnShape() - - if game.CheckShapeCollision(gp.shape_pos, &gp.shape, &gp.grid) { - fsm.Switch("gameover") + lines := gp.grid.FullRows() + if len(lines) > 0 { + gp.lineClearAnimation.SetLines(lines) + gp.score.Lines(byte(len(lines))) + audio.Play(assets.SFX_ROW_CLEARED) + } else { + if game.CheckShapeCollision(gp.shape_pos, &gp.shape, &gp.grid) { + fsm.Switch("gameover") + } } } else { gp.shape_pos = new_pos