commit 0245a5cb436e5a7d79054c157d704900cd0913e7 Author: Henrik Hautakoski Date: Sun Sep 14 08:38:30 2025 +0200 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1dce0e8 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/tetris diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..7017b46 --- /dev/null +++ b/Makefile @@ -0,0 +1,6 @@ +GO = go + +.PHONY: tetris + +tetris : + $(GO) build -o $@ diff --git a/README.md b/README.md new file mode 100644 index 0000000..b56fa7b --- /dev/null +++ b/README.md @@ -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) diff --git a/assets/def.go b/assets/def.go new file mode 100644 index 0000000..4524c96 --- /dev/null +++ b/assets/def.go @@ -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, +} diff --git a/assets/sprite.xcf b/assets/sprite.xcf new file mode 100644 index 0000000..982b272 Binary files /dev/null and b/assets/sprite.xcf differ diff --git a/assets/sprites.png b/assets/sprites.png new file mode 100644 index 0000000..4b350bd Binary files /dev/null and b/assets/sprites.png differ diff --git a/engine/graphics/font/tilefont.go b/engine/graphics/font/tilefont.go new file mode 100644 index 0000000..495476b --- /dev/null +++ b/engine/graphics/font/tilefont.go @@ -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 +} diff --git a/engine/graphics/texture.go b/engine/graphics/texture.go new file mode 100644 index 0000000..6497fc7 --- /dev/null +++ b/engine/graphics/texture.go @@ -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) +} diff --git a/engine/graphics/tile.go b/engine/graphics/tile.go new file mode 100644 index 0000000..de37812 --- /dev/null +++ b/engine/graphics/tile.go @@ -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), + } +} diff --git a/engine/render/context.go b/engine/render/context.go new file mode 100644 index 0000000..540afad --- /dev/null +++ b/engine/render/context.go @@ -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 +} diff --git a/engine/render/rect.go b/engine/render/rect.go new file mode 100644 index 0000000..3ad993b --- /dev/null +++ b/engine/render/rect.go @@ -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) +} diff --git a/engine/render/render.go b/engine/render/render.go new file mode 100644 index 0000000..f72e9c5 --- /dev/null +++ b/engine/render/render.go @@ -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() +} diff --git a/engine/render/scale.go b/engine/render/scale.go new file mode 100644 index 0000000..5b179b0 --- /dev/null +++ b/engine/render/scale.go @@ -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), + } +} diff --git a/engine/render/text.go b/engine/render/text.go new file mode 100644 index 0000000..471f85e --- /dev/null +++ b/engine/render/text.go @@ -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) + } +} diff --git a/engine/render/texture.go b/engine/render/texture.go new file mode 100644 index 0000000..9fee159 --- /dev/null +++ b/engine/render/texture.go @@ -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) +} diff --git a/engine/system/hypr.go b/engine/system/hypr.go new file mode 100644 index 0000000..8483e06 --- /dev/null +++ b/engine/system/hypr.go @@ -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") +} diff --git a/game/draw/grid/grid.go b/game/draw/grid/grid.go new file mode 100644 index 0000000..5bb25a6 --- /dev/null +++ b/game/draw/grid/grid.go @@ -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) + } + } +} diff --git a/game/draw/renderer.go b/game/draw/renderer.go new file mode 100644 index 0000000..b94a427 --- /dev/null +++ b/game/draw/renderer.go @@ -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) +} diff --git a/game/draw/theme.go b/game/draw/theme.go new file mode 100644 index 0000000..bc37307 --- /dev/null +++ b/game/draw/theme.go @@ -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 +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..00b95c8 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..aa78a1e --- /dev/null +++ b/go.sum @@ -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= diff --git a/main.go b/main.go new file mode 100644 index 0000000..243d8fd --- /dev/null +++ b/main.go @@ -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() + } +}