1
0
Fork 0

Initial commit

This commit is contained in:
Henrik Hautakoski 2025-09-14 08:38:30 +02:00
commit 0245a5cb43
22 changed files with 610 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/tetris

6
Makefile Normal file
View file

@ -0,0 +1,6 @@
GO = go
.PHONY: tetris
tetris :
$(GO) build -o $@

23
README.md Normal file
View file

@ -0,0 +1,23 @@
# Tetris in golang
## Roadmap
- [x] Initial Commit - Project Scaffolding (rendering engine)
- [ ] 0.1 - Grid
- [ ] 0.2 - Basic shapes (falling + collision with floor)
- [ ] 0.3 - Shapes: Lock + spawn
- [ ] 0.4 - Collision with with blocks in grid
- [ ] 0.5 - left/right movement
- [ ] 0.6 - Shapes rotation
- [ ] 0.7 - Soft Drop
- [ ] 0.8 - Clear lines
- [ ] 0.9 - Score
- [ ] 0.10 - Next shape
- [ ] 0.11 - RNG Shape generation
- [ ] 0.12 - Sound
- [ ] 0.13 - State machine: Menu, Gameplay and Game Over screen.
- [ ] 1.0 - Bugfixes
## Future things (aka Rad stuffs!)
- [ ] Animations (Clear lines)

55
assets/def.go Normal file
View file

@ -0,0 +1,55 @@
package assets
import (
_ "embed"
"tetris/engine/graphics/font"
)
//go:embed sprites.png
var Sprite []byte
var Font = font.TileFont{
Charmap: map[rune]byte{
'a': 0x20,
'b': 0x21,
'c': 0x22,
'd': 0x23,
'e': 0x24,
'f': 0x25,
'g': 0x26,
'h': 0x27,
'i': 0x28,
'j': 0x29,
'k': 0x2a,
'l': 0x2b,
'm': 0x2c,
'n': 0x30,
'o': 0x31,
'p': 0x32,
'q': 0x33,
'r': 0x34,
's': 0x35,
't': 0x36,
'u': 0x37,
'v': 0x38,
'w': 0x39,
'x': 0x3a,
'y': 0x3b,
'z': 0x3c,
'0': 0x40,
'1': 0x41,
'2': 0x42,
'3': 0x43,
'4': 0x44,
'5': 0x45,
'6': 0x46,
'7': 0x47,
'8': 0x48,
'9': 0x49,
'.': 0x4a,
'-': 0x4b,
'"': 0x4c,
},
CaseInsensitive: true,
}

BIN
assets/sprite.xcf Normal file

Binary file not shown.

BIN
assets/sprites.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -0,0 +1,41 @@
package font
import (
"tetris/engine/graphics"
)
// TileFont represents a tile-based font where each character maps
// to a tile location (X, Y) in a tileset or texture atlas.
//
// The character-to-tile mapping is defined in the Charmap.
// If CaseInsensitive is true, uppercase characters ('A''Z')
// are automatically converted to lowercase before lookup.
type TileFont struct {
Charmap map[rune]byte
// CaseInsensitive forces all characters to lowercase before lookup.
CaseInsensitive bool
}
// GetTile returns the Tile corresponding to the given character.
//
// If CaseInsensitive is enabled, uppercase ASCII characters are converted
// to lowercase before lookup. If the character is not found in the Charmap,
// GetTile returns nil.
//
// The resulting Tile assumes a fixed tile size of 8x8 pixels
func (f TileFont) GetTile(char rune) *graphics.Tile {
// convert to lowercase
if f.CaseInsensitive && char >= 'A' && char <= 'Z' {
char = char + 0x20
}
if offset, found := f.Charmap[char]; found {
return &graphics.Tile{
Size: 8,
X: offset & 0xF,
Y: offset >> 4,
}
}
return nil
}

View file

@ -0,0 +1,9 @@
package graphics
import rl "github.com/gen2brain/raylib-go/raylib"
func LoadTextureFromMemory(fileType string, data []byte) rl.Texture2D {
img := rl.LoadImageFromMemory(fileType, data, int32(len(data)))
defer rl.UnloadImage(img)
return rl.LoadTextureFromImage(img)
}

18
engine/graphics/tile.go Normal file
View file

@ -0,0 +1,18 @@
package graphics
import rl "github.com/gen2brain/raylib-go/raylib"
type Tile struct {
Size byte
X, Y byte
}
// GetTexRect returns the texture rectangle.
func (t Tile) GetTexRect() rl.Rectangle {
return rl.Rectangle{
X: float32(t.X * t.Size),
Y: float32(t.Y * t.Size),
Width: float32(t.Size),
Height: float32(t.Size),
}
}

24
engine/render/context.go Normal file
View file

@ -0,0 +1,24 @@
package render
import (
gfxfont "tetris/engine/graphics/font"
rl "github.com/gen2brain/raylib-go/raylib"
)
var (
texture rl.Texture2D
font *gfxfont.TileFont = nil
)
func SetTexture(tex rl.Texture2D) {
texture = tex
}
func GetTexture() rl.Texture2D {
return texture
}
func SetFont(fnt *gfxfont.TileFont) {
font = fnt
}

26
engine/render/rect.go Normal file
View file

@ -0,0 +1,26 @@
package render
import (
"image/color"
rl "github.com/gen2brain/raylib-go/raylib"
)
// DrawRectBorder draw a rectangle with a border around it.
// Note that the rectangle passed as the 'rect' parameter to the function represents the actual
// rectangle. The border will be drawn around it in all directions (outer border).
func DrawRectBorder(rect rl.RectangleInt32, col color.RGBA, border_size int32, border_col color.RGBA) {
rl.DrawRectangleRec(rl.Rectangle{
X: float32(rect.X),
Y: float32(rect.Y),
Width: float32(rect.Width),
Height: float32(rect.Height),
}, col)
rl.DrawRectangleLinesEx(rl.Rectangle{
X: float32(rect.X - border_size),
Y: float32(rect.Y - border_size),
Width: float32(rect.Width + (border_size * 2)),
Height: float32(rect.Height + (border_size * 2)),
}, float32(border_size), border_col)
}

107
engine/render/render.go Normal file
View file

@ -0,0 +1,107 @@
package render
import (
"image/color"
"tetris/engine/system"
rl "github.com/gen2brain/raylib-go/raylib"
)
type Config struct {
Title string
// Window
WindowWidth int32
WindowHeight int32
WindowFlags uint32
// Render target resolution
RenderHeight int32
RenderWidth int32
ScaleFlags ScaleFlags
}
// target is the off-screen render target where all drawing operations are performed.
// It has a fixed resolution and is later scaled to the window size.
var target rl.RenderTexture2D
var scaleFlags ScaleFlags
// Init initializes the rendering system.
//
// It creates an off-screen render texture with the fixed resolution defined by config.RenderWidth/Height.
// This texture is where all tiles and game graphics are drawn before being scaled and presented.
func Init(config Config) {
// hyprland has a bug where the height of the window is 37 pixels taller than requested.
// so we just subtract it here.
if system.IsHyprland() {
config.WindowHeight = config.WindowHeight - 37
}
scaleFlags = config.ScaleFlags
if config.WindowFlags > 0 {
rl.SetConfigFlags(config.WindowFlags)
}
// Setup window
rl.InitWindow(config.WindowWidth, config.WindowHeight, config.Title)
// Initialize render texture.
target = rl.LoadRenderTexture(config.RenderWidth, config.RenderHeight)
rl.SetTextureFilter(target.Texture, rl.TextureFilterNearest)
}
// Exit shuts down the rendering system.
//
// It unloads the render texture and frees any associated GPU resources.
// This should be called before the application exits.
func Exit() {
rl.UnloadRenderTexture(target)
rl.CloseWindow()
}
// Begin prepares the rendering system for a new frame.
//
// It binds the off-screen texture target and clears it to a black background.
// All tile drawing operations (e.g., DrawTile) should occur after Begin() and
// before End().
func Begin(col color.RGBA) {
// Bind texture so that all draw calls after are applied to target texture
rl.BeginTextureMode(target)
// Clear the texture
rl.ClearBackground(col)
}
// End finalizes the current frame and displays it on the screen.
//
// It ends the off-screen drawing session and stretches the texture target
// to fill the window. This function must be called after all drawing is done.
func End() {
// End drawing to the texture target.
rl.EndTextureMode()
// Begin drawing to screen buffer.
rl.BeginDrawing()
// Define source rectangle (flip vertically to match coordinate systems).
src := rl.Rectangle{
X: 0,
Y: 0,
Width: float32(target.Texture.Width),
Height: -float32(target.Texture.Height),
}
// Calculate the destination rectangle (window).
dest := scale(target.Texture.Width, target.Texture.Height,
int32(rl.GetRenderWidth()), int32(rl.GetRenderHeight()),
scaleFlags)
// Blit the off-screen texture to the screen's back buffer.
rl.DrawTexturePro(target.Texture, src, dest, rl.Vector2Zero(), 0.0, rl.White)
// Swap buffers
rl.EndDrawing()
}

56
engine/render/scale.go Normal file
View file

@ -0,0 +1,56 @@
package render
import (
"math"
rl "github.com/gen2brain/raylib-go/raylib"
)
type ScaleFlags byte
const (
// Use integer scaling (pixel-perfect). Implies SCALE_NO_DOWNSCALE because integer < 1 becomes 0.
SCALE_INTEGER ScaleFlags = 1 << iota
// Do not allow shrinking below 1 (clamp scale >= 1).
SCALE_NO_DOWNSCALE
// Use "cover" (crop) instead of "contain" (letterbox). Default is contain.
SCALE_COVER
)
func scale(srcW, srcH, destW, destH int32, flags ScaleFlags) rl.Rectangle {
scale := float64(1)
sx := float64(destW) / float64(srcW)
sy := float64(destH) / float64(srcH)
// Contain (letterbox) uses min; Cover (crop) uses max.
if flags&SCALE_COVER != 0 {
scale = math.Max(sx, sy)
} else {
scale = math.Min(sx, sy)
}
if flags&SCALE_INTEGER != 0 {
// For contain, floor; for cover, ceil.
if flags&SCALE_COVER != 0 {
scale = math.Ceil(scale)
} else {
scale = math.Floor(scale)
}
// IntegerScale implies no fractional; clamp at 1 to avoid zero.
scale = max(1.0, scale)
} else if flags&SCALE_NO_DOWNSCALE != 0 && scale < 1.0 {
scale = 1.0
}
w := float64(srcW) * scale
h := float64(srcH) * scale
return rl.Rectangle{
X: float32(math.Floor((float64(destW) - w) / 2.0)),
Y: float32(math.Floor((float64(destH) - h) / 2.0)),
Width: float32(w),
Height: float32(h),
}
}

31
engine/render/text.go Normal file
View file

@ -0,0 +1,31 @@
package render
import (
"image/color"
rl "github.com/gen2brain/raylib-go/raylib"
)
func DrawText(x, y, size int32, text string, col color.RGBA) {
destRect := rl.Rectangle{
X: float32(x),
Y: float32(y),
Width: float32(size),
Height: float32(size),
}
for _, ch := range text {
if ch == ' ' {
destRect.X += float32(size)
continue
}
tile := font.GetTile(ch)
if tile == nil {
destRect.X += float32(size)
continue
}
rl.DrawTexturePro(texture, tile.GetTexRect(), destRect, rl.Vector2Zero(), 0, col)
destRect.X += float32(size)
}
}

8
engine/render/texture.go Normal file
View file

@ -0,0 +1,8 @@
package render
import rl "github.com/gen2brain/raylib-go/raylib"
// DrawTextureRec - Draw a rectangle from the current set texture.
func DrawTextureRec(src rl.Rectangle, dest rl.Rectangle) {
rl.DrawTexturePro(texture, src, dest, rl.Vector2Zero(), 0.0, rl.White)
}

23
engine/system/hypr.go Normal file
View file

@ -0,0 +1,23 @@
package system
import (
"os"
"path/filepath"
"strings"
)
func IsHyprland() bool {
// Primary signals
xdg := os.Getenv("XDG_RUNTIME_DIR")
his := os.Getenv("HYPRLAND_INSTANCE_SIGNATURE")
if xdg != "" && his != "" {
sock := filepath.Join(xdg, "hypr", his, ".socket.sock")
if st, err := os.Stat(sock); err == nil && !st.IsDir() {
return true
}
}
// Fallback: desktop hint
hint := strings.ToLower(os.Getenv("XDG_CURRENT_DESKTOP"))
return strings.Contains(hint, "hypr")
}

47
game/draw/grid/grid.go Normal file
View file

@ -0,0 +1,47 @@
package grid
import (
"image/color"
"tetris/engine/render"
rl "github.com/gen2brain/raylib-go/raylib"
)
const (
// How many pixels wide and tall each cell is.
CELL_SIZE = 32
// Number of pixels between each cell
CELL_SPACING = 1
)
func DrawBackground(rect rl.RectangleInt32, col color.RGBA, border_size int32, border_col color.RGBA) {
render.DrawRectBorder(rl.RectangleInt32{
X: int32(rect.X),
Y: int32(rect.Y),
Width: int32((rect.Width * (CELL_SIZE + CELL_SPACING)) + CELL_SPACING),
Height: int32((rect.Height * (CELL_SIZE + CELL_SPACING)) + CELL_SPACING),
}, col, border_size, border_col)
}
func Draw(rect rl.RectangleInt32) {
// offset for background.
rect.X = rect.X + CELL_SPACING
rect.Y = rect.Y + CELL_SPACING
cell := rl.Rectangle{
X: 0,
Y: 0,
Width: float32(CELL_SIZE),
Height: float32(CELL_SIZE),
}
for y := range rect.Height {
for x := range rect.Width {
cell.X = float32(rect.X + (x * (CELL_SIZE + CELL_SPACING)))
cell.Y = float32(rect.Y + (y * (CELL_SIZE + CELL_SPACING)))
rl.DrawRectangleRec(cell, rl.Black)
}
}
}

40
game/draw/renderer.go Normal file
View file

@ -0,0 +1,40 @@
package draw
import (
"tetris/engine/render"
"tetris/game/draw/grid"
rl "github.com/gen2brain/raylib-go/raylib"
)
const (
// Border width when drawing frames
BORDER_WIDTH = 8
// Size for normal text
TEXT_SIZE = 32
// Text size for header texts
HEADER_TEXT_SIZE = 16
)
type Renderer struct {
Theme *Theme
}
func (r Renderer) DrawText(x int32, y int32, text string) {
render.DrawText(x, y, TEXT_SIZE, text, r.Theme.Text)
}
func (r Renderer) DrawHeaderText(x int32, y int32, text string) {
render.DrawText(x, y, HEADER_TEXT_SIZE, text, r.Theme.TextHeader)
}
func (r Renderer) DrawFrame(rect rl.RectangleInt32) {
render.DrawRectBorder(rect, r.Theme.FrameBG, BORDER_WIDTH, r.Theme.FrameBorder)
}
func (r Renderer) DrawGrid(rect rl.RectangleInt32) {
grid.DrawBackground(rect, r.Theme.GridBackground, BORDER_WIDTH, r.Theme.FrameBorder)
grid.Draw(rect)
}

20
game/draw/theme.go Normal file
View file

@ -0,0 +1,20 @@
package draw
import (
"image/color"
)
// Theme holds the different colors used when rendering.
type Theme struct {
// Frame colors
FrameBG color.RGBA
FrameBorder color.RGBA
// Text colors
TextHeader color.RGBA
Text color.RGBA
// Grid Colors
GridBackground color.RGBA
GridEmptyCell color.RGBA
}

11
go.mod Normal file
View file

@ -0,0 +1,11 @@
module tetris
go 1.24.6
require github.com/gen2brain/raylib-go/raylib v0.55.1
require (
github.com/ebitengine/purego v0.7.1 // indirect
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect
golang.org/x/sys v0.20.0 // indirect
)

8
go.sum Normal file
View file

@ -0,0 +1,8 @@
github.com/ebitengine/purego v0.7.1 h1:6/55d26lG3o9VCZX8lping+bZcmShseiqlh2bnUDiPA=
github.com/ebitengine/purego v0.7.1/go.mod h1:ah1In8AOtksoNK6yk5z1HTJeUkC1Ez4Wk2idgGslMwQ=
github.com/gen2brain/raylib-go/raylib v0.55.1 h1:1rdc10WvvYjtj7qijHnV9T38/WuvlT6IIL+PaZ6cNA8=
github.com/gen2brain/raylib-go/raylib v0.55.1/go.mod h1:BaY76bZk7nw1/kVOSQObPY1v1iwVE1KHAGMfvI6oK1Q=
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM=
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc=
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=

56
main.go Normal file
View file

@ -0,0 +1,56 @@
package main
import (
"image/color"
"tetris/assets"
"tetris/engine/graphics"
"tetris/engine/render"
"tetris/game/draw"
rl "github.com/gen2brain/raylib-go/raylib"
)
func main() {
render.Init(render.Config{
Title: "Tetris",
WindowWidth: 685,
WindowHeight: 600,
RenderWidth: 685,
RenderHeight: 600,
ScaleFlags: render.SCALE_INTEGER,
})
defer render.Exit()
// Load texture
texture := graphics.LoadTextureFromMemory(".png", assets.Sprite)
defer rl.UnloadTexture(texture)
render.SetTexture(texture)
render.SetFont(&assets.Font)
r := draw.Renderer{
Theme: &draw.Theme{
FrameBG: color.RGBA{R: 30, G: 30, B: 46, A: 255},
FrameBorder: color.RGBA{R: 242, G: 205, B: 205, A: 255},
TextHeader: color.RGBA{R: 242, G: 205, B: 205, A: 255},
Text: color.RGBA{R: 205, G: 214, B: 244, A: 255},
GridBackground: color.RGBA{R: 17, G: 17, B: 27, A: 255},
},
}
for !rl.WindowShouldClose() {
render.Begin(r.Theme.GridBackground)
r.DrawGrid(rl.RectangleInt32{
X: 25,
Y: 25,
Width: 10,
Height: 16,
})
r.DrawFrame(rl.RectangleInt32{X: 400, Y: 25, Width: 250, Height: 100})
r.DrawHeaderText(410, 30, "Score")
r.DrawText(410, 65, "999999")
r.DrawFrame(rl.RectangleInt32{X: 400, Y: 150, Width: 250, Height: 200})
render.End()
}
}