diff --git a/README.md b/README.md index a1ff326..f7efed0 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,8 @@ go build tetris.go ## Roadmap - [x] Line clear animations -- [ ] Hard drop and ghost piece +- [x] ghost piece +- [ ] Hard drop - [ ] Level/speed progression and pause - [ ] High score persistence - [ ] Custom key bindings diff --git a/assets/sprite.xcf b/assets/sprite.xcf index 982b272..e376963 100644 Binary files a/assets/sprite.xcf and b/assets/sprite.xcf differ diff --git a/assets/sprites.png b/assets/sprites.png index 4b350bd..b26a3b6 100644 Binary files a/assets/sprites.png and b/assets/sprites.png differ diff --git a/game/block.go b/game/block.go index 0221d49..5b7445a 100644 --- a/game/block.go +++ b/game/block.go @@ -16,12 +16,22 @@ const ( BLOCK_ORANGE BLOCK_MAGENTA BLOCK_TEAL + BLOCK_GHOST ) func (c Block) Tile() graphics.Tile { - if c >= 8 { + if c >= 9 { c = BLOCK_EMPTY } + + if c == BLOCK_GHOST { + return graphics.Tile{ + Size: assets.BLOCK_TILE_SIZE, + X: byte(7), + Y: byte(1), + } + } + return graphics.Tile{ Size: assets.BLOCK_TILE_SIZE, X: byte(c), diff --git a/game/config/config.go b/game/config/config.go index 82ad3d8..7437eae 100644 --- a/game/config/config.go +++ b/game/config/config.go @@ -9,11 +9,13 @@ import ( type Config struct { fd *os.File SoundVolume byte `json:"sound_volume"` + GhostPiece bool `json:"ghost_piece"` } func DefaultConfig(fd *os.File) *Config { cfg := Config{ SoundVolume: 255, + GhostPiece: false, } cfg.fd = fd return &cfg diff --git a/game/draw/shape.go b/game/draw/shape.go index 8913b41..08847df 100644 --- a/game/draw/shape.go +++ b/game/draw/shape.go @@ -14,8 +14,10 @@ type Shape interface { } func DrawShape(offset rl.Vector2, pos core.Vec2i8, shape Shape) { - tile := shape.Tile() + DrawShapeTile(offset, pos, shape, shape.Tile()) +} +func DrawShapeTile(offset rl.Vector2, pos core.Vec2i8, shape Shape, tile graphics.Tile) { for _, coord := range shape.Coordinates() { block := pos.Add(coord) grid.DrawBlock(offset, byte(block.X), byte(block.Y), tile) diff --git a/game/ghost_shape.go b/game/ghost_shape.go new file mode 100644 index 0000000..1a2a96e --- /dev/null +++ b/game/ghost_shape.go @@ -0,0 +1,39 @@ +package game + +import ( + "tetris/engine/core" + "tetris/game/draw" + + rl "github.com/gen2brain/raylib-go/raylib" +) + +type GhostShape struct { + posY int8 // only need Y coordinate +} + +func (gs *GhostShape) UpdatePos(shape *Shape, pos core.Vec2i8, grid *Grid) { + for gs.posY = pos.Y; pos.Y <= int8(grid.Height()); pos.Y++ { + if CheckShapeCollision(pos, shape, grid) { + gs.posY = pos.Y - 1 + break + } + } +} + +func (gs *GhostShape) Draw(offset rl.Vector2, shape *Shape, pos core.Vec2i8) { + draw.DrawShapeTile(offset, core.NewVec2(pos.X, gs.posY), shape, BLOCK_GHOST.Tile()) +} + +func UpdateGhostShapeY(shape *Shape, pos core.Vec2i8, grid *Grid) int8 { + for ; pos.Y <= int8(grid.Height()); pos.Y++ { + if CheckShapeCollision(pos, shape, grid) { + pos.Y = pos.Y - 1 + break + } + } + return pos.Y +} + +func DrawGhost(offset rl.Vector2, shape *Shape, pos core.Vec2i8, ghost_y int8) { + draw.DrawShapeTile(offset, core.NewVec2(pos.X, ghost_y), shape, BLOCK_GHOST.Tile()) +} diff --git a/game/line_clear_animation.go b/game/line_clear_animation.go index 5c650d5..d79ff8a 100644 --- a/game/line_clear_animation.go +++ b/game/line_clear_animation.go @@ -32,15 +32,15 @@ func (lca *LineClearAnimation) SetLines(rows []byte) { } } -func (lca *LineClearAnimation) Update(grid *Grid) { +func (lca *LineClearAnimation) Update(grid *Grid) bool { if lca.Completed() { - return + return false } if lca.leftIndex < 0 || lca.rightIndex > GRID_WIDTH { grid.MoveLinesDown(lca.lines...) lca.Reset() - return + return true } for _, lineIndex := range lca.lines { @@ -50,6 +50,8 @@ func (lca *LineClearAnimation) Update(grid *Grid) { lca.leftIndex-- lca.rightIndex++ + + return false } func (lca LineClearAnimation) Completed() bool { diff --git a/game/shape.go b/game/shape.go index 7543e39..c6cccfd 100644 --- a/game/shape.go +++ b/game/shape.go @@ -58,6 +58,10 @@ func (s Shape) Coordinates() [4]core.Vec2i8 { } func (s Shape) RotateCW() Shape { + if s.typ == SHAPE_O { + return s + } + rotated := [3]core.Vec2i8{} for i, block := range s.coordinates { @@ -71,6 +75,10 @@ func (s Shape) RotateCW() Shape { } func (s Shape) RotateCCW() Shape { + if s.typ == SHAPE_O { + return s + } + rotated := [3]core.Vec2i8{} for i, block := range s.coordinates { diff --git a/game/state/handlers/gameplay.go b/game/state/handlers/gameplay.go index b65da11..7730635 100644 --- a/game/state/handlers/gameplay.go +++ b/game/state/handlers/gameplay.go @@ -18,6 +18,8 @@ import ( type GamePlay struct { shape game.Shape shape_pos core.Vec2i8 + ghost game.GhostShape + ghost_needs_update bool score game.Score dropTimer core.IntervalTimer moveTimer core.IntervalTimer @@ -66,6 +68,7 @@ func (gp *GamePlay) SpawnShape() { gp.shape = gp.nextShape gp.nextShape = gp.shapeQueue.Next() gp.shape_pos = core.Vec2i8{X: 4, Y: 0} + gp.ghost_needs_update = true } func (gp *GamePlay) LockShape() { @@ -90,7 +93,9 @@ func (gp *GamePlay) Update(fsm state.Transitioner, delta float32) { if !gp.lineClearAnimation.Completed() { if gp.lineClearTimer.UpdateReset(delta) { - gp.lineClearAnimation.Update(&gp.grid) + if gp.lineClearAnimation.Update(&gp.grid) { + gp.ghost_needs_update = true + } } return } @@ -99,6 +104,7 @@ func (gp *GamePlay) Update(fsm state.Transitioner, delta float32) { rotated := gp.shape.RotateCW() if !game.CheckShapeCollision(gp.shape_pos, &rotated, &gp.grid) { gp.shape = rotated + gp.ghost_needs_update = true } } @@ -111,6 +117,7 @@ func (gp *GamePlay) Update(fsm state.Transitioner, delta float32) { } if !game.CheckShapeCollision(new_pos, &gp.shape, &gp.grid) { gp.shape_pos.X = new_pos.X + gp.ghost_needs_update = true } } @@ -136,12 +143,24 @@ func (gp *GamePlay) Update(fsm state.Transitioner, delta float32) { gp.shape_pos = new_pos } } + + if gp.ghost_needs_update { + if game.Config.GhostPiece { + gp.ghost.UpdatePos(&gp.shape, gp.shape_pos, &gp.grid) + } + gp.ghost_needs_update = false + } } func (gp GamePlay) Render() { + offset := rl.NewVector2(25, 25) + render.Begin(gp.r.Theme.GridBackground) - gp.r.DrawGrid(rl.NewVector2(25, 25), gp.grid) - draw.DrawShape(rl.NewVector2(25, 25), gp.shape_pos, gp.shape) + gp.r.DrawGrid(offset, gp.grid) + draw.DrawShape(offset, gp.shape_pos, gp.shape) + if game.Config.GhostPiece && gp.lineClearAnimation.Completed() { + gp.ghost.Draw(offset, &gp.shape, gp.shape_pos) + } gp.r.DrawFrame(rl.RectangleInt32{X: 400, Y: 25, Width: 250, Height: 100}) gp.r.DrawHeaderText(410, 30, "Score") gp.r.DrawText(410, 65, fmt.Sprintf("%.7d", gp.score)) diff --git a/game/state/handlers/options.go b/game/state/handlers/options.go index 8fabd6d..9456389 100644 --- a/game/state/handlers/options.go +++ b/game/state/handlers/options.go @@ -16,8 +16,9 @@ import ( ) type OptionsMenu struct { - sndVolume *widgets.Slider - list *layouts.ListBox + sndVolume *widgets.Slider + ghostBlock *widgets.Checkbox + list *layouts.ListBox } func soundVolumeChanged(widget *widgets.Slider) { @@ -33,10 +34,15 @@ func soundVolumeChanged(widget *widgets.Slider) { func NewOptionsMenu() *OptionsMenu { sndVolume := widgets.NewSlider(0, 255, 10).WithOnChange(soundVolumeChanged) + ghostPiece := widgets.NewCheckbox(game.Config.GhostPiece, func(value bool) { + game.Config.GhostPiece = value + game.Config.Save() + }) return &OptionsMenu{ sndVolume: sndVolume, list: layouts.NewListBox([]ui.InputWidget{ layouts.NewInputControl(widgets.NewLabel("Sound Volume", 16), sndVolume), + layouts.NewInputControl(widgets.NewLabel("Show ghost piece", 16), ghostPiece), }).Spacing(10).OnSelect(uievents.MenuSelect), } } diff --git a/game/ui/widgets/checkbox.go b/game/ui/widgets/checkbox.go new file mode 100644 index 0000000..5954c25 --- /dev/null +++ b/game/ui/widgets/checkbox.go @@ -0,0 +1,62 @@ +package widgets + +import ( + "tetris/engine/core" + "tetris/engine/input" + "tetris/engine/render" + "tetris/game/ui/base" + + rl "github.com/gen2brain/raylib-go/raylib" +) + +type CheckboxAction func(value bool) + +type Checkbox struct { + *base.WithFocus + action CheckboxAction + value bool +} + +func NewCheckbox(value bool, action CheckboxAction) *Checkbox { + return &Checkbox{ + WithFocus: &base.WithFocus{}, + value: value, + action: action, + } +} + +func (cb Checkbox) Size() core.Vec2i { + return core.NewVec2(24, 24) +} + +func (cb *Checkbox) HandleInput() { + if input.KeyPressedWithRepeat(rl.KeyEnter) { + cb.value = !cb.value + cb.action(cb.value) + } +} + +func (cb Checkbox) Draw(x, y int32) { + size := cb.Size() + space := int32(2) + color := rl.White + if cb.HasFocus() { + color = rl.Red + } + + render.DrawRectOutlineBorder(rl.RectangleInt32{ + X: x, + Y: y, + Width: int32(size.X), + Height: int32(size.Y), + }, 4, color) + + if cb.value { + render.DrawRectBorder(rl.RectangleInt32{ + X: x + space, + Y: y + space, + Width: int32(size.X) - (space * 2), + Height: int32(size.Y) - (space * 2), + }, color, 0, rl.Blank) + } +}