1
0
Fork 0

feat: add ghost piece

This commit is contained in:
Henrik Hautakoski 2026-08-31 14:05:42 +02:00
parent b6ec52a3d9
commit cc8fa88afe
12 changed files with 162 additions and 11 deletions

View file

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

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Before After
Before After

View file

@ -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),

View file

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

View file

@ -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)

39
game/ghost_shape.go Normal file
View file

@ -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())
}

View file

@ -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 {

View file

@ -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 {

View file

@ -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))

View file

@ -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),
}
}

View file

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