From 22864ffcab42077b2165e60db9c9405de886c438 Mon Sep 17 00:00:00 2001 From: huyinsong Date: Sun, 8 Jun 2025 12:58:00 +0800 Subject: [PATCH] =?UTF-8?q?=E7=94=9F=E6=88=90=E5=8F=AF=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E7=9A=84=E7=89=88=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build.yml | 173 ++++++++ .gitignore | 87 ++++ Makefile | 223 ++++++++++ README.md | 77 +++- game.go | 468 -------------------- go.mod | 0 go.sum | 0 internal/game/game.go | 440 ++++++++++++++++++ block.go => internal/tetromino/tetromino.go | 88 ++-- internal/types/types.go | 34 ++ main.go | 9 +- patch/0001-fix-IBlock-disappear-issue.patch | 151 +++++++ pkg/config/config.go | 17 + 13 files changed, 1213 insertions(+), 554 deletions(-) create mode 100644 .github/workflows/build.yml create mode 100644 .gitignore create mode 100644 Makefile mode change 100644 => 100755 README.md delete mode 100644 game.go mode change 100644 => 100755 go.mod mode change 100644 => 100755 go.sum create mode 100644 internal/game/game.go rename block.go => internal/tetromino/tetromino.go (65%) create mode 100644 internal/types/types.go mode change 100644 => 100755 main.go create mode 100755 patch/0001-fix-IBlock-disappear-issue.patch create mode 100644 pkg/config/config.go diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..0f747f8 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,173 @@ +name: Build and Release + +on: + push: + branches: [ main ] + tags: [ 'v*' ] + pull_request: + branches: [ main ] + +jobs: + build: + name: Build for ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + matrix: + include: + - os: ubuntu-latest + goos: linux + goarch: amd64 + binary_suffix: "" + archive_type: tar.gz + - os: windows-latest + goos: windows + goarch: amd64 + binary_suffix: ".exe" + archive_type: zip + - os: macos-latest + goos: darwin + goarch: amd64 + binary_suffix: "" + archive_type: tar.gz + - os: macos-latest + goos: darwin + goarch: arm64 + binary_suffix: "" + archive_type: tar.gz + + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: '1.21' + + - name: Install dependencies (Linux) + if: matrix.os == 'ubuntu-latest' + run: | + sudo apt-get update + sudo apt-get install -y libc6-dev libglu1-mesa-dev libgl1-mesa-dev libxcursor-dev libxi-dev libxinerama-dev libxrandr-dev libxxf86vm-dev libasound2-dev pkg-config + + - name: Download dependencies + run: go mod download + + - name: Build + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + run: | + mkdir -p build + go build -ldflags "-s -w" -o build/tetris${{ matrix.binary_suffix }} . + + - name: Create archive (tar.gz) + if: matrix.archive_type == 'tar.gz' + run: | + cd build + tar -czf tetris-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz tetris${{ matrix.binary_suffix }} + + - name: Create archive (zip) + if: matrix.archive_type == 'zip' + run: | + cd build + powershell Compress-Archive -Path tetris${{ matrix.binary_suffix }} -DestinationPath tetris-${{ matrix.goos }}-${{ matrix.goarch }}.zip + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: tetris-${{ matrix.goos }}-${{ matrix.goarch }} + path: build/tetris-${{ matrix.goos }}-${{ matrix.goarch }}.* + + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: '1.21' + + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y libc6-dev libglu1-mesa-dev libgl1-mesa-dev libxcursor-dev libxi-dev libxinerama-dev libxrandr-dev libxxf86vm-dev libasound2-dev pkg-config + + - name: Download dependencies + run: go mod download + + - name: Run tests + run: go test -v ./... + + - name: Run go vet + run: go vet ./... + + - name: Check formatting + run: | + if [ "$(gofmt -s -l . | wc -l)" -gt 0 ]; then + echo "The following files are not formatted:" + gofmt -s -l . + exit 1 + fi + + release: + name: Create Release + needs: [build, test] + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/v') + + steps: + - name: Download all artifacts + uses: actions/download-artifact@v4 + + - name: Create Release + id: create_release + uses: actions/create-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ github.ref }} + release_name: Tetris ${{ github.ref }} + draft: false + prerelease: false + + - name: Upload Linux Release Asset + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: ./tetris-linux-amd64/tetris-linux-amd64.tar.gz + asset_name: tetris-linux-amd64.tar.gz + asset_content_type: application/gzip + + - name: Upload Windows Release Asset + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: ./tetris-windows-amd64/tetris-windows-amd64.zip + asset_name: tetris-windows-amd64.zip + asset_content_type: application/zip + + - name: Upload macOS Intel Release Asset + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: ./tetris-darwin-amd64/tetris-darwin-amd64.tar.gz + asset_name: tetris-darwin-amd64.tar.gz + asset_content_type: application/gzip + + - name: Upload macOS ARM Release Asset + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: ./tetris-darwin-arm64/tetris-darwin-arm64.tar.gz + asset_name: tetris-darwin-arm64.tar.gz + asset_content_type: application/gzip \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2b40957 --- /dev/null +++ b/.gitignore @@ -0,0 +1,87 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib +tetris +tetris.exe + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work + +# IDE and Editor files +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# macOS +.DS_Store +.AppleDouble +.LSOverride + +# Windows +Thumbs.db +ehthumbs.db +Desktop.ini + +# Linux +*~ + +# Temporary files +*.tmp +*.temp +*.log + +# Build artifacts +build/ +dist/ +bin/ + +# Environment variables +.env +.env.local +.env.production + +# Debug files +debug +*.pprof + +# Air live reload +tmp/ + +# Go mod cache +go.sum.bak + +# Crash logs +crash.log + +# Runtime data +*.pid +*.seed +*.pid.lock + +# Coverage directory used by tools like istanbul +coverage/ +*.lcov + +# Logs +logs/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Game specific +saves/ +screenshots/ \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..938c8d0 --- /dev/null +++ b/Makefile @@ -0,0 +1,223 @@ +# Tetris Game Makefile +# Supports cross-compilation for Windows, macOS, and Linux + +# Variables +BINARY_NAME=tetris +MAIN_PATH=. +BUILD_DIR=build +VERSION?=dev +LDFLAGS=-ldflags "-s -w" + +# Platform specific settings +WINDOWS_BINARY=$(BINARY_NAME).exe +MACOS_BINARY=$(BINARY_NAME)_macos +LINUX_BINARY=$(BINARY_NAME)_linux + +# Default target +.PHONY: help +help: ## Show this help message + @echo "Available targets:" + @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " %-20s %s\n", $$1, $$2}' $(MAKEFILE_LIST) + +# Build targets +.PHONY: build +build: ## Build for current platform + @echo "Building $(BINARY_NAME) for current platform..." + go build $(LDFLAGS) -o $(BINARY_NAME) $(MAIN_PATH) + @echo "Build complete: $(BINARY_NAME)" + +.PHONY: build-windows +build-windows: ## Build for Windows (64-bit) - requires Windows or cross-compilation setup + @echo "Building $(BINARY_NAME) for Windows..." + @echo "Note: Cross-compilation for Ebitengine requires proper CGO setup" + @mkdir -p $(BUILD_DIR) + CGO_ENABLED=1 GOOS=windows GOARCH=amd64 go build $(LDFLAGS) -o $(BUILD_DIR)/$(WINDOWS_BINARY) $(MAIN_PATH) + @echo "Build complete: $(BUILD_DIR)/$(WINDOWS_BINARY)" + +.PHONY: build-windows-native +build-windows-native: ## Build for Windows using native Go (may have limitations) + @echo "Building $(BINARY_NAME) for Windows (native Go)..." + @mkdir -p $(BUILD_DIR) + CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build $(LDFLAGS) -tags ebitengine -o $(BUILD_DIR)/$(WINDOWS_BINARY) $(MAIN_PATH) + @echo "Build complete: $(BUILD_DIR)/$(WINDOWS_BINARY)" + +.PHONY: build-macos +build-macos: ## Build for macOS (Intel) - requires macOS or cross-compilation setup + @echo "Building $(BINARY_NAME) for macOS (Intel)..." + @echo "Note: Cross-compilation for Ebitengine requires proper CGO setup" + @mkdir -p $(BUILD_DIR) + CGO_ENABLED=1 GOOS=darwin GOARCH=amd64 go build $(LDFLAGS) -o $(BUILD_DIR)/$(MACOS_BINARY)_amd64 $(MAIN_PATH) + @echo "Build complete: $(BUILD_DIR)/$(MACOS_BINARY)_amd64" + +.PHONY: build-macos-arm +build-macos-arm: ## Build for macOS (Apple Silicon) - requires macOS or cross-compilation setup + @echo "Building $(BINARY_NAME) for macOS (Apple Silicon)..." + @echo "Note: Cross-compilation for Ebitengine requires proper CGO setup" + @mkdir -p $(BUILD_DIR) + CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 go build $(LDFLAGS) -o $(BUILD_DIR)/$(MACOS_BINARY)_arm64 $(MAIN_PATH) + @echo "Build complete: $(BUILD_DIR)/$(MACOS_BINARY)_arm64" + +.PHONY: build-macos-current +build-macos-current: ## Build for current macOS architecture + @echo "Building $(BINARY_NAME) for current macOS architecture..." + @mkdir -p $(BUILD_DIR) + go build $(LDFLAGS) -o $(BUILD_DIR)/$(MACOS_BINARY)_$$(go env GOARCH) $(MAIN_PATH) + @echo "Build complete: $(BUILD_DIR)/$(MACOS_BINARY)_$$(go env GOARCH)" + +.PHONY: build-linux +build-linux: ## Build for Linux (64-bit) - requires Linux or cross-compilation setup + @echo "Building $(BINARY_NAME) for Linux..." + @echo "Note: Cross-compilation for Ebitengine requires proper CGO setup" + @mkdir -p $(BUILD_DIR) + CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build $(LDFLAGS) -o $(BUILD_DIR)/$(LINUX_BINARY) $(MAIN_PATH) + @echo "Build complete: $(BUILD_DIR)/$(LINUX_BINARY)" + +.PHONY: build-current-platform +build-current-platform: ## Build for current platform (recommended) + @echo "Building $(BINARY_NAME) for current platform: $$(go env GOOS)/$$(go env GOARCH)" + @mkdir -p $(BUILD_DIR) + go build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)_$$(go env GOOS)_$$(go env GOARCH) $(MAIN_PATH) + @echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)_$$(go env GOOS)_$$(go env GOARCH)" + +.PHONY: build-all +build-all: ## Build for all platforms (requires proper cross-compilation setup) + @echo "Attempting to build for all platforms..." + @echo "Note: This may fail without proper cross-compilation environment" + $(MAKE) build-current-platform + -$(MAKE) build-windows-native + @echo "Build summary:" + @ls -la $(BUILD_DIR)/ 2>/dev/null || echo "No builds completed successfully" + +# Development targets +.PHONY: run +run: ## Run the game + @echo "Starting Tetris game..." + go run $(MAIN_PATH) + +.PHONY: dev +dev: clean build run ## Clean, build and run for development + +.PHONY: install +install: ## Install the game to GOPATH/bin + @echo "Installing $(BINARY_NAME)..." + go install $(LDFLAGS) $(MAIN_PATH) + @echo "Installation complete!" + +# Quality assurance targets +.PHONY: test +test: ## Run tests + @echo "Running tests..." + go test -v ./... + +.PHONY: test-coverage +test-coverage: ## Run tests with coverage + @echo "Running tests with coverage..." + go test -v -coverprofile=coverage.out ./... + go tool cover -html=coverage.out -o coverage.html + @echo "Coverage report generated: coverage.html" + +.PHONY: fmt +fmt: ## Format code + @echo "Formatting code..." + go fmt ./... + +.PHONY: vet +vet: ## Run go vet + @echo "Running go vet..." + go vet ./... + +.PHONY: lint +lint: ## Run golangci-lint (requires golangci-lint to be installed) + @echo "Running golangci-lint..." + golangci-lint run + +.PHONY: check +check: fmt vet test ## Run format, vet, and tests + +# Dependency management +.PHONY: deps +deps: ## Download dependencies + @echo "Downloading dependencies..." + go mod download + +.PHONY: deps-update +deps-update: ## Update dependencies + @echo "Updating dependencies..." + go mod tidy + go get -u ./... + go mod tidy + +.PHONY: deps-vendor +deps-vendor: ## Vendor dependencies + @echo "Vendoring dependencies..." + go mod vendor + +# Cleanup targets +.PHONY: clean +clean: ## Clean build artifacts + @echo "Cleaning build artifacts..." + @rm -f $(BINARY_NAME) + @rm -rf $(BUILD_DIR) + @rm -f coverage.out coverage.html + @echo "Clean complete!" + +.PHONY: clean-all +clean-all: clean ## Clean everything including vendor + @echo "Cleaning vendor directory..." + @rm -rf vendor/ + +# Release targets +.PHONY: release +release: clean build-current-platform ## Create release build for current platform + @echo "Creating release package for current platform..." + @mkdir -p $(BUILD_DIR)/releases + @CURRENT_OS=$$(go env GOOS); \ + CURRENT_ARCH=$$(go env GOARCH); \ + if [ "$$CURRENT_OS" = "windows" ]; then \ + cd $(BUILD_DIR) && zip releases/$(BINARY_NAME)-$$CURRENT_OS-$$CURRENT_ARCH.zip $(BINARY_NAME)_$$CURRENT_OS_$$CURRENT_ARCH; \ + else \ + cd $(BUILD_DIR) && tar -czf releases/$(BINARY_NAME)-$$CURRENT_OS-$$CURRENT_ARCH.tar.gz $(BINARY_NAME)_$$CURRENT_OS_$$CURRENT_ARCH; \ + fi + @echo "Release package created in $(BUILD_DIR)/releases/" + @ls -la $(BUILD_DIR)/releases/ + +# Docker targets (optional) +.PHONY: docker-build +docker-build: ## Build Docker image + @echo "Building Docker image..." + docker build -t $(BINARY_NAME):$(VERSION) . + +.PHONY: docker-run +docker-run: ## Run in Docker container + @echo "Running in Docker container..." + docker run --rm -it $(BINARY_NAME):$(VERSION) + +# Info targets +.PHONY: info +info: ## Show build information + @echo "=== Build Information ===" + @echo "Binary name: $(BINARY_NAME)" + @echo "Version: $(VERSION)" + @echo "Go version: $$(go version)" + @echo "Build directory: $(BUILD_DIR)" + @echo "Current platform: $$(go env GOOS)/$$(go env GOARCH)" + @echo "CGO enabled: $$(go env CGO_ENABLED)" + +.PHONY: env +env: ## Show Go environment + @echo "=== Go Environment ===" + @go env + +.PHONY: cross-compile-info +cross-compile-info: ## Show cross-compilation information + @echo "=== Cross-Compilation Information ===" + @echo "Ebitengine requires CGO for cross-compilation." + @echo "To build for other platforms, you need:" + @echo "1. C compiler for target platform" + @echo "2. Platform-specific libraries" + @echo "3. Proper CGO_ENABLED=1 setting" + @echo "" + @echo "Recommended approach:" + @echo "- Build on the target platform directly" + @echo "- Use GitHub Actions or CI/CD for multi-platform builds" + @echo "- Use Docker with multi-stage builds" \ No newline at end of file diff --git a/README.md b/README.md old mode 100644 new mode 100755 index 14dc618..7d80ece --- a/README.md +++ b/README.md @@ -1,38 +1,69 @@ -# Tetris Game in Go +# Tetris Game -A simple Tetris game implementation using Go and the Ebiten game engine. +A classic Tetris game implemented in Go using the Ebitengine game engine. -## Requirements +## Features -- Go 1.16 or later -- Ebiten v2 +- Classic Tetris gameplay +- Ghost piece preview +- Next piece preview +- Line clearing with scoring +- Progressive difficulty levels +- Smooth controls with wall kicks -## Installation +## Project Structure -1. Clone the repository -2. Install dependencies: -```bash -go mod tidy ``` +tetris/ +├── main.go # Application entry point +├── go.mod # Go module definition +├── go.sum # Go module checksums +├── assets/ # Game assets (future use) +├── internal/ # Internal packages +│ ├── game/ # Core game logic +│ │ └── game.go # Game state and rendering +│ ├── tetromino/ # Tetromino (falling pieces) logic +│ │ └── tetromino.go # Piece shapes, rotation, movement +│ └── types/ # Shared type definitions +│ └── types.go # Block types and colors +└── pkg/ # Public packages + └── config/ # Configuration constants + └── config.go # Game configuration and constants +``` + +## Architecture + +The project follows Go best practices with a clean package structure: + +- **`main.go`**: Entry point that initializes the game +- **`pkg/config`**: Contains all game configuration constants +- **`internal/types`**: Defines core data types and enums +- **`internal/tetromino`**: Handles tetromino piece logic (shapes, rotation, movement) +- **`internal/game`**: Core game mechanics (board state, input handling, rendering) -## Running the Game +## Controls + +- **Left/Right Arrow**: Move piece horizontally +- **Up Arrow**: Rotate piece clockwise +- **Down Arrow**: Soft drop (faster falling) +- **Space**: Hard drop (instant drop) +- **Space** (when game over): Restart game + +## Building and Running ```bash +# Run the game go run . + +# Build executable +go build -o tetris +./tetris ``` -## Controls +## Dependencies -- Left Arrow: Move piece left -- Right Arrow: Move piece right -- Down Arrow: Move piece down faster -- Up Arrow: Rotate piece -- Space: Drop piece instantly -- ESC: Quit game +- [Ebitengine](https://ebitengine.org/) v2.8.8 - 2D game engine for Go -## Features +## License -- Classic Tetris gameplay -- Score tracking -- Next piece preview -- Level system \ No newline at end of file +This project is open source and available under the MIT License. \ No newline at end of file diff --git a/game.go b/game.go deleted file mode 100644 index aa9896f..0000000 --- a/game.go +++ /dev/null @@ -1,468 +0,0 @@ -package main - -import ( - "fmt" - "image/color" - "math/rand" - "time" - - "github.com/hajimehoshi/ebiten/v2" - "github.com/hajimehoshi/ebiten/v2/ebitenutil" - "github.com/hajimehoshi/ebiten/v2/inpututil" -) - -const ( - // Size constants - BlockSize = 30 - BoardWidth = 10 - BoardHeight = 20 - ScreenWidth = BlockSize * (BoardWidth + 6) // Extra space for next piece preview - ScreenHeight = BlockSize * BoardHeight - PreviewX = BlockSize * (BoardWidth + 1) - PreviewY = BlockSize * 2 - - // Game constants - InitialDropInterval = 60 - MinDropInterval = 5 -) - -// Game represents the main game state -type Game struct { - board [][]BlockType - currentPiece *Tetromino - nextPiece *Tetromino - dropCounter int - dropInterval int - score int - level int - gameOver bool - lastMoveDown time.Time - lastRotate time.Time -} - -// NewGame creates a new game instance -func NewGame() *Game { - g := &Game{ - board: make([][]BlockType, BoardHeight), - dropInterval: InitialDropInterval, - lastMoveDown: time.Now(), - lastRotate: time.Now(), - } - - for i := range g.board { - g.board[i] = make([]BlockType, BoardWidth) - } - - g.spawnNewPiece() - return g -} - -// Update handles game logic updates -func (g *Game) Update() error { - if g.gameOver { - if inpututil.IsKeyJustPressed(ebiten.KeySpace) { - *g = *NewGame() - return nil - } - return nil - } - - g.handleInput() - g.updateDropCounter() - return nil -} - -// Draw renders the game state -func (g *Game) Draw(screen *ebiten.Image) { - // Draw board background - ebitenutil.DrawRect(screen, 0, 0, float64(BoardWidth*BlockSize), float64(BoardHeight*BlockSize), color.RGBA{40, 40, 40, 255}) - - // Draw preview area background - ebitenutil.DrawRect(screen, float64(PreviewX-BlockSize), 0, float64(6*BlockSize), float64(ScreenHeight), color.RGBA{30, 30, 30, 255}) - - // Draw grid lines - gridColor := color.RGBA{60, 60, 60, 255} - for x := 0; x <= BoardWidth; x++ { - ebitenutil.DrawLine(screen, float64(x*BlockSize), 0, float64(x*BlockSize), float64(BoardHeight*BlockSize), gridColor) - } - for y := 0; y <= BoardHeight; y++ { - ebitenutil.DrawLine(screen, 0, float64(y*BlockSize), float64(BoardWidth*BlockSize), float64(y*BlockSize), gridColor) - } - - // Draw placed blocks - for y := 0; y < BoardHeight; y++ { - for x := 0; x < BoardWidth; x++ { - if g.board[y][x] != 0 { - g.drawBlock(screen, x, y, g.board[y][x]) - } - } - } - - // Draw current piece and its ghost - if g.currentPiece != nil { - // Draw ghost piece - ghostPiece := *g.currentPiece - for { - ghostPiece.Move(0, 1) - if g.wouldCollide(&ghostPiece) { - ghostPiece.Move(0, -1) - break - } - } - for _, pos := range ghostPiece.GetAbsolutePositions() { - if pos[1] >= 0 { - ghostColor := color.RGBA{128, 128, 128, 128} - ebitenutil.DrawRect(screen, - float64(pos[0]*BlockSize), - float64(pos[1]*BlockSize), - float64(BlockSize-1), - float64(BlockSize-1), - ghostColor) - } - } - - // Draw actual piece - for _, pos := range g.currentPiece.GetAbsolutePositions() { - if pos[1] >= 0 { - g.drawBlock(screen, pos[0], pos[1], g.currentPiece.BlockType) - } - } - } - - // Draw next piece preview - if g.nextPiece != nil { - previewLabel := "NEXT:" - ebitenutil.DebugPrintAt(screen, previewLabel, PreviewX-BlockSize, BlockSize) - - for _, block := range g.nextPiece.Blocks { - x := PreviewX + block.X*BlockSize - y := PreviewY + block.Y*BlockSize - ebitenutil.DrawRect(screen, - float64(x), - float64(y), - float64(BlockSize-1), - float64(BlockSize-1), - BlockColors[g.nextPiece.BlockType]) - } - } - - // Draw score and level with larger font - scoreStr := fmt.Sprintf("Score: %d", g.score) - levelStr := fmt.Sprintf("Level: %d", g.level) - ebitenutil.DebugPrintAt(screen, scoreStr, PreviewX-BlockSize, ScreenHeight-4*BlockSize) - ebitenutil.DebugPrintAt(screen, levelStr, PreviewX-BlockSize, ScreenHeight-3*BlockSize) - - if g.gameOver { - gameOverStr := "Game Over!" - restartStr := "Press SPACE to restart" - ebitenutil.DebugPrintAt(screen, gameOverStr, PreviewX-BlockSize, ScreenHeight/2) - ebitenutil.DebugPrintAt(screen, restartStr, PreviewX-BlockSize, ScreenHeight/2+20) - } -} - -// Layout implements ebiten.Game interface -func (g *Game) Layout(outsideWidth, outsideHeight int) (screenWidth, screenHeight int) { - return ScreenWidth, ScreenHeight -} - -// handleInput processes player input -func (g *Game) handleInput() { - // Move left/right - if inpututil.IsKeyJustPressed(ebiten.KeyLeft) { - g.tryMove(-1, 0) - } - if inpututil.IsKeyJustPressed(ebiten.KeyRight) { - g.tryMove(1, 0) - } - - // Rotate - if inpututil.IsKeyJustPressed(ebiten.KeyUp) { - if time.Since(g.lastRotate) > time.Millisecond*100 { - g.tryRotate() - g.lastRotate = time.Now() - } - } - - // Soft drop - if ebiten.IsKeyPressed(ebiten.KeyDown) { - if time.Since(g.lastMoveDown) > time.Millisecond*50 { - g.tryMove(0, 1) - g.lastMoveDown = time.Now() - } - } - - // Hard drop - if inpututil.IsKeyJustPressed(ebiten.KeySpace) { - g.hardDrop() - } -} - -// updateDropCounter handles automatic piece dropping -func (g *Game) updateDropCounter() { - if g.currentPiece == nil { - return - } - - g.dropCounter++ - if g.dropCounter >= g.dropInterval { - g.dropCounter = 0 - - // Try to move down - g.currentPiece.Move(0, 1) - if g.isColliding() { - // Move back up and lock the piece - g.currentPiece.Move(0, -1) - g.lockPiece() - if !g.gameOver { - g.clearLines() - g.spawnNewPiece() - } - } - } -} - -// tryMove attempts to move the current piece -func (g *Game) tryMove(dx, dy int) bool { - if g.currentPiece == nil { - return false - } - - g.currentPiece.Move(dx, dy) - if g.isColliding() { - g.currentPiece.Move(-dx, -dy) - return false - } - return true -} - -// tryRotate attempts to rotate the current piece -func (g *Game) tryRotate() bool { - if g.currentPiece == nil { - return false - } - - g.currentPiece.Rotate() - if g.isColliding() { - // Try wall kicks - kicks := NormalKicks - if g.currentPiece.BlockType == IBlock { - kicks = IKicks - } - - success := false - for _, kick := range kicks { - g.currentPiece.Move(kick[0], kick[1]) - if !g.isColliding() { - success = true - break - } - g.currentPiece.Move(-kick[0], -kick[1]) - } - - if !success { - // Rotate back to original position - g.currentPiece.Rotate() - g.currentPiece.Rotate() - g.currentPiece.Rotate() - return false - } - } - return true -} - -// hardDrop drops the piece to the bottom instantly -func (g *Game) hardDrop() { - if g.currentPiece == nil { - return - } - - // Move down until collision - dropDistance := 0 - for { - g.currentPiece.Move(0, 1) - if g.isColliding() { - g.currentPiece.Move(0, -1) - break - } - dropDistance++ - } - - // Add bonus points for hard drop - g.score += dropDistance * 2 - - g.lockPiece() - if !g.gameOver { - g.clearLines() - g.spawnNewPiece() - } -} - -// isColliding checks if the current piece collides with the board or boundaries -func (g *Game) isColliding() bool { - if g.currentPiece == nil { - return false - } - - for _, pos := range g.currentPiece.GetAbsolutePositions() { - x, y := pos[0], pos[1] - // Check boundaries - if x < 0 || x >= BoardWidth || y >= BoardHeight { - return true - } - // Check collision with other pieces - if y >= 0 && x >= 0 && x < BoardWidth && y < BoardHeight { - if g.board[y][x] != 0 { - return true - } - } - } - return false -} - -// lockPiece fixes the current piece to the board -func (g *Game) lockPiece() { - if g.currentPiece == nil { - return - } - - positions := g.currentPiece.GetAbsolutePositions() - for _, pos := range positions { - x, y := pos[0], pos[1] - if y < 0 { - g.gameOver = true - return - } - if x >= 0 && x < BoardWidth && y >= 0 && y < BoardHeight { - g.board[y][x] = g.currentPiece.BlockType - } - } -} - -// clearLines removes completed lines and updates the score -func (g *Game) clearLines() { - linesCleared := 0 - for y := BoardHeight - 1; y >= 0; y-- { - if g.isLineFull(y) { - g.removeLine(y) - linesCleared++ - y++ // Check the same line again after shifting - } - } - - // Update score and level - if linesCleared > 0 { - // 计分规则:一次消除的行数越多,得分越高 - baseScore := 0 - switch linesCleared { - case 1: - baseScore = 100 - case 2: - baseScore = 300 - case 3: - baseScore = 500 - case 4: - baseScore = 800 - } - - // 根据当前等级增加得分 - levelMultiplier := g.level + 1 - g.score += baseScore * levelMultiplier - - // 更新等级 - g.level = g.score / 1000 - - // 随等级提高,方块下落速度加快 - g.dropInterval = max(MinDropInterval, InitialDropInterval-g.level*5) - } -} - -// isLineFull checks if a line is completely filled -func (g *Game) isLineFull(y int) bool { - for x := 0; x < BoardWidth; x++ { - if g.board[y][x] == 0 { - return false - } - } - return true -} - -// removeLine removes a line and shifts everything above down -func (g *Game) removeLine(y int) { - for i := y; i > 0; i-- { - copy(g.board[i], g.board[i-1]) - } - for x := 0; x < BoardWidth; x++ { - g.board[0][x] = 0 - } -} - -// spawnNewPiece creates a new piece at the top of the board -func (g *Game) spawnNewPiece() { - if g.nextPiece == nil { - g.nextPiece = NewTetromino(BlockType(rand.Intn(7)), 0, 0) - } - - g.currentPiece = g.nextPiece - g.currentPiece.X = BoardWidth/2 - 2 - if g.currentPiece.BlockType != IBlock { - g.currentPiece.X++ // Adjust for non-I pieces - } - g.currentPiece.Y = 0 - - g.nextPiece = NewTetromino(BlockType(rand.Intn(7)), 0, 0) - - if g.isColliding() { - g.gameOver = true - } -} - -// wouldCollide checks if a piece would collide in its current position -func (g *Game) wouldCollide(piece *Tetromino) bool { - for _, pos := range piece.GetAbsolutePositions() { - x, y := pos[0], pos[1] - if x < 0 || x >= BoardWidth || y >= BoardHeight { - return true - } - if y >= 0 && y < BoardHeight && x >= 0 && x < BoardWidth && g.board[y][x] != 0 { - return true - } - } - return false -} - -// drawBlock draws a single block at the specified position -func (g *Game) drawBlock(screen *ebiten.Image, x, y int, blockType BlockType) { - if y < 0 { - return - } - - // Draw block background - ebitenutil.DrawRect(screen, - float64(x*BlockSize), - float64(y*BlockSize), - float64(BlockSize-1), - float64(BlockSize-1), - BlockColors[blockType]) - - // Draw highlight (3D effect) - highlightColor := color.RGBA{255, 255, 255, 64} - ebitenutil.DrawLine(screen, - float64(x*BlockSize), - float64(y*BlockSize), - float64(x*BlockSize+BlockSize-1), - float64(y*BlockSize), - highlightColor) - ebitenutil.DrawLine(screen, - float64(x*BlockSize), - float64(y*BlockSize), - float64(x*BlockSize), - float64(y*BlockSize+BlockSize-1), - highlightColor) -} - -func max(a, b int) int { - if a > b { - return a - } - return b -} diff --git a/go.mod b/go.mod old mode 100644 new mode 100755 diff --git a/go.sum b/go.sum old mode 100644 new mode 100755 diff --git a/internal/game/game.go b/internal/game/game.go new file mode 100644 index 0000000..17ba050 --- /dev/null +++ b/internal/game/game.go @@ -0,0 +1,440 @@ +package game + +import ( + "fmt" + "image/color" + "math/rand" + "time" + + "tetris/internal/tetromino" + "tetris/internal/types" + "tetris/pkg/config" + + "github.com/hajimehoshi/ebiten/v2" + "github.com/hajimehoshi/ebiten/v2/ebitenutil" + "github.com/hajimehoshi/ebiten/v2/inpututil" +) + +// Game represents the main game state +type Game struct { + board [][]types.BlockType + currentPiece *tetromino.Tetromino + nextPiece *tetromino.Tetromino + dropCounter int + dropInterval int + score int + level int + gameOver bool + lastMoveDown time.Time + lastRotate time.Time +} + +// New creates a new game instance +func New() *Game { + g := &Game{ + board: make([][]types.BlockType, config.BoardHeight), + dropInterval: config.InitialDropInterval, + lastMoveDown: time.Now(), + lastRotate: time.Now(), + } + + for i := range g.board { + g.board[i] = make([]types.BlockType, config.BoardWidth) + } + + g.spawnNewPiece() + return g +} + +// Update handles game logic updates +func (g *Game) Update() error { + if g.gameOver { + if inpututil.IsKeyJustPressed(ebiten.KeySpace) { + *g = *New() + return nil + } + return nil + } + + g.handleInput() + g.updateDropCounter() + return nil +} + +// Draw renders the game state +func (g *Game) Draw(screen *ebiten.Image) { + // Draw board background + ebitenutil.DrawRect(screen, 0, 0, float64(config.BoardWidth*config.BlockSize), float64(config.BoardHeight*config.BlockSize), color.RGBA{40, 40, 40, 255}) + + // Draw preview area background + ebitenutil.DrawRect(screen, float64(config.PreviewX-config.BlockSize), 0, float64(6*config.BlockSize), float64(config.ScreenHeight), color.RGBA{30, 30, 30, 255}) + + // Draw grid lines + gridColor := color.RGBA{60, 60, 60, 255} + for x := 0; x <= config.BoardWidth; x++ { + ebitenutil.DrawLine(screen, float64(x*config.BlockSize), 0, float64(x*config.BlockSize), float64(config.BoardHeight*config.BlockSize), gridColor) + } + for y := 0; y <= config.BoardHeight; y++ { + ebitenutil.DrawLine(screen, 0, float64(y*config.BlockSize), float64(config.BoardWidth*config.BlockSize), float64(y*config.BlockSize), gridColor) + } + + // Draw placed blocks + for y := range config.BoardHeight { + for x := range config.BoardWidth { + if g.board[y][x] != config.EmptyBlock { + g.drawBlock(screen, x, y, g.board[y][x]) + } + } + } + + // Draw current piece and its ghost + if g.currentPiece != nil { + // Draw ghost piece + ghostPiece := *g.currentPiece + for { + ghostPiece.Move(0, 1) + if g.wouldCollide(&ghostPiece) { + ghostPiece.Move(0, -1) + break + } + } + for _, pos := range ghostPiece.GetAbsolutePositions() { + if pos[1] >= 0 { + ghostColor := color.RGBA{128, 128, 128, 128} + ebitenutil.DrawRect(screen, + float64(pos[0]*config.BlockSize), + float64(pos[1]*config.BlockSize), + float64(config.BlockSize-1), + float64(config.BlockSize-1), + ghostColor) + } + } + + // Draw actual piece + for _, pos := range g.currentPiece.GetAbsolutePositions() { + if pos[1] >= 0 { + g.drawBlock(screen, pos[0], pos[1], g.currentPiece.BlockType) + } + } + } + + // Draw next piece preview + if g.nextPiece != nil { + previewLabel := "NEXT:" + ebitenutil.DebugPrintAt(screen, previewLabel, config.PreviewX-config.BlockSize, config.BlockSize) + + for _, block := range g.nextPiece.Blocks { + x := config.PreviewX + block.X*config.BlockSize + y := config.PreviewY + block.Y*config.BlockSize + ebitenutil.DrawRect(screen, + float64(x), + float64(y), + float64(config.BlockSize-1), + float64(config.BlockSize-1), + types.BlockColors[g.nextPiece.BlockType]) + } + } + + // Draw score and level with larger font + scoreStr := fmt.Sprintf("Score: %d", g.score) + levelStr := fmt.Sprintf("Level: %d", g.level) + ebitenutil.DebugPrintAt(screen, scoreStr, config.PreviewX-config.BlockSize, config.ScreenHeight-4*config.BlockSize) + ebitenutil.DebugPrintAt(screen, levelStr, config.PreviewX-config.BlockSize, config.ScreenHeight-3*config.BlockSize) + + if g.gameOver { + gameOverStr := "Game Over!" + restartStr := "Press SPACE to restart" + ebitenutil.DebugPrintAt(screen, gameOverStr, config.PreviewX-config.BlockSize, config.ScreenHeight/2) + ebitenutil.DebugPrintAt(screen, restartStr, config.PreviewX-config.BlockSize, config.ScreenHeight/2+20) + } +} + +// Layout implements ebiten.Game interface +func (g *Game) Layout(outsideWidth, outsideHeight int) (screenWidth, screenHeight int) { + return config.ScreenWidth, config.ScreenHeight +} + +// handleInput processes player input +func (g *Game) handleInput() { + // Move left/right + if inpututil.IsKeyJustPressed(ebiten.KeyLeft) { + g.tryMove(-1, 0) + } + if inpututil.IsKeyJustPressed(ebiten.KeyRight) { + g.tryMove(1, 0) + } + + // Rotate + if inpututil.IsKeyJustPressed(ebiten.KeyUp) { + if time.Since(g.lastRotate) > time.Millisecond*100 { + g.tryRotate() + g.lastRotate = time.Now() + } + } + + // Soft drop + if ebiten.IsKeyPressed(ebiten.KeyDown) { + if time.Since(g.lastMoveDown) > time.Millisecond*50 { + g.tryMove(0, 1) + g.lastMoveDown = time.Now() + } + } + + // Hard drop + if inpututil.IsKeyJustPressed(ebiten.KeySpace) { + g.hardDrop() + } +} + +// updateDropCounter handles automatic piece dropping +func (g *Game) updateDropCounter() { + g.dropCounter++ + if g.dropCounter >= g.dropInterval { + g.dropCounter = 0 + if !g.tryMove(0, 1) { + g.lockPiece() + } + } +} + +// tryMove attempts to move the current piece and returns success +func (g *Game) tryMove(dx, dy int) bool { + if g.currentPiece == nil { + return false + } + + originalX, originalY := g.currentPiece.X, g.currentPiece.Y + g.currentPiece.Move(dx, dy) + + if g.isColliding() { + g.currentPiece.X, g.currentPiece.Y = originalX, originalY + return false + } + + return true +} + +// tryRotate attempts to rotate the current piece with wall kicks +func (g *Game) tryRotate() bool { + if g.currentPiece == nil { + return false + } + + // Store original state + originalBlocks := make([]types.Block, len(g.currentPiece.Blocks)) + copy(originalBlocks, g.currentPiece.Blocks) + + // Try rotation + g.currentPiece.Rotate() + + // Try wall kicks + var kicks [][2]int + if g.currentPiece.BlockType == types.IBlock { + kicks = tetromino.IKicks + } else { + kicks = tetromino.NormalKicks + } + + for _, kick := range kicks { + originalX, originalY := g.currentPiece.X, g.currentPiece.Y + g.currentPiece.Move(kick[0], kick[1]) + + if !g.isColliding() { + return true + } + + // Reset position for next kick attempt + g.currentPiece.X, g.currentPiece.Y = originalX, originalY + } + + // All kicks failed, revert rotation + g.currentPiece.Blocks = originalBlocks + return false +} + +// hardDrop drops the piece instantly to the bottom +func (g *Game) hardDrop() { + if g.currentPiece == nil { + return + } + + dropDistance := 0 + for { + if !g.tryMove(0, 1) { + break + } + dropDistance++ + } + + // Award extra points for hard drop + g.score += dropDistance * 2 + + g.lockPiece() +} + +// isColliding checks if the current piece is colliding with the board or boundaries +func (g *Game) isColliding() bool { + if g.currentPiece == nil { + return false + } + + for _, pos := range g.currentPiece.GetAbsolutePositions() { + x, y := pos[0], pos[1] + + // Check boundaries + if x < 0 || x >= config.BoardWidth || y >= config.BoardHeight { + return true + } + + // Check collision with placed blocks (but allow pieces above the visible board) + if y >= 0 && g.board[y][x] != config.EmptyBlock { + return true + } + } + + return false +} + +// lockPiece places the current piece on the board and spawns a new one +func (g *Game) lockPiece() { + if g.currentPiece == nil { + return + } + + // Place the piece on the board + for _, pos := range g.currentPiece.GetAbsolutePositions() { + x, y := pos[0], pos[1] + if y >= 0 && y < config.BoardHeight && x >= 0 && x < config.BoardWidth { + g.board[y][x] = g.currentPiece.BlockType + } + } + + // Clear completed lines + linesCleared := g.clearLines() + + // Update score and level + g.updateScore(linesCleared) + + // Spawn new piece + g.spawnNewPiece() + + // Check for game over + if g.isColliding() { + g.gameOver = true + } +} + +// clearLines removes completed lines and returns the number cleared +func (g *Game) clearLines() int { + linesCleared := 0 + + for y := config.BoardHeight - 1; y >= 0; y-- { + if g.isLineFull(y) { + g.removeLine(y) + linesCleared++ + y++ // Check the same line again since everything moved down + } + } + + return linesCleared +} + +// updateScore updates the score and level based on lines cleared +func (g *Game) updateScore(linesCleared int) { + points := []int{0, 40, 100, 300, 1200} // Points for 0, 1, 2, 3, 4 lines + if linesCleared <= 4 { + g.score += points[linesCleared] * (g.level + 1) + } + + // Increase level every 10 lines + newLevel := g.score / 1000 + if newLevel > g.level { + g.level = newLevel + g.dropInterval = max(config.MinDropInterval, config.InitialDropInterval-g.level*3) + } +} + +// isLineFull checks if a line is completely filled +func (g *Game) isLineFull(y int) bool { + for x := 0; x < config.BoardWidth; x++ { + if g.board[y][x] == config.EmptyBlock { + return false + } + } + return true +} + +// removeLine removes a line and shifts everything above down +func (g *Game) removeLine(lineY int) { + for y := lineY; y > 0; y-- { + copy(g.board[y], g.board[y-1]) + } + // Clear the top line + for x := 0; x < config.BoardWidth; x++ { + g.board[0][x] = config.EmptyBlock + } +} + +// spawnNewPiece creates a new falling piece +func (g *Game) spawnNewPiece() { + if g.nextPiece == nil { + g.nextPiece = tetromino.New(types.BlockType(rand.Intn(7)+1), config.BoardWidth/2-1, 0) + } + + g.currentPiece = g.nextPiece + g.nextPiece = tetromino.New(types.BlockType(rand.Intn(7)+1), config.BoardWidth/2-1, 0) + + g.dropCounter = 0 +} + +// wouldCollide checks if a piece would collide at its current position +func (g *Game) wouldCollide(piece *tetromino.Tetromino) bool { + for _, pos := range piece.GetAbsolutePositions() { + x, y := pos[0], pos[1] + + // Check boundaries + if x < 0 || x >= config.BoardWidth || y >= config.BoardHeight { + return true + } + + // Check collision with placed blocks + if y >= 0 && g.board[y][x] != config.EmptyBlock { + return true + } + } + + return false +} + +// drawBlock draws a single block at the specified position +func (g *Game) drawBlock(screen *ebiten.Image, x, y int, blockType types.BlockType) { + ebitenutil.DrawRect(screen, + float64(x*config.BlockSize), + float64(y*config.BlockSize), + float64(config.BlockSize-1), + float64(config.BlockSize-1), + types.BlockColors[blockType]) + + // Add a subtle border for 3D effect + borderColor := color.RGBA{255, 255, 255, 50} + ebitenutil.DrawRect(screen, + float64(x*config.BlockSize), + float64(y*config.BlockSize), + float64(config.BlockSize-1), + 2, + borderColor) + ebitenutil.DrawRect(screen, + float64(x*config.BlockSize), + float64(y*config.BlockSize), + 2, + float64(config.BlockSize-1), + borderColor) +} + +// max returns the maximum of two integers +func max(a, b int) int { + if a > b { + return a + } + return b +} diff --git a/block.go b/internal/tetromino/tetromino.go similarity index 65% rename from block.go rename to internal/tetromino/tetromino.go index ad276ea..405ae61 100644 --- a/block.go +++ b/internal/tetromino/tetromino.go @@ -1,79 +1,40 @@ -package main +package tetromino -import ( - "image/color" -) - -// Block represents a single block in the game -type Block struct { - X, Y int - Type BlockType -} - -// BlockType represents different types of tetrominos -type BlockType int - -const ( - IBlock BlockType = iota - JBlock - LBlock - OBlock - SBlock - TBlock - ZBlock -) - -// Tetromino represents a complete tetris piece -type Tetromino struct { - Blocks []Block - BlockType BlockType - X, Y int -} - -// Colors for different block types -var BlockColors = map[BlockType]color.Color{ - IBlock: color.RGBA{0, 255, 255, 255}, // Cyan - JBlock: color.RGBA{0, 0, 255, 255}, // Blue - LBlock: color.RGBA{255, 165, 0, 255}, // Orange - OBlock: color.RGBA{255, 255, 0, 255}, // Yellow - SBlock: color.RGBA{0, 255, 0, 255}, // Green - TBlock: color.RGBA{128, 0, 128, 255}, // Purple - ZBlock: color.RGBA{255, 0, 0, 255}, // Red -} +import "tetris/internal/types" // TetrominoShapes defines the shape of each tetromino type -var TetrominoShapes = map[BlockType][][]bool{ - IBlock: { +var TetrominoShapes = map[types.BlockType][][]bool{ + types.IBlock: { {false, false, false, false}, {false, false, false, false}, {true, true, true, true}, {false, false, false, false}, }, - JBlock: { + types.JBlock: { {true, false, false}, {true, true, true}, {false, false, false}, }, - LBlock: { + types.LBlock: { {false, false, true}, {true, true, true}, {false, false, false}, }, - OBlock: { + types.OBlock: { {true, true}, {true, true}, }, - SBlock: { + types.SBlock: { {false, true, true}, {true, true, false}, {false, false, false}, }, - TBlock: { + types.TBlock: { {false, true, false}, {true, true, true}, {false, false, false}, }, - ZBlock: { + types.ZBlock: { {true, true, false}, {false, true, true}, {false, false, false}, @@ -97,15 +58,22 @@ var NormalKicks = [][2]int{ {0, -1}, } -// NewTetromino creates a new tetromino of the specified type -func NewTetromino(blockType BlockType, x, y int) *Tetromino { +// Tetromino represents a complete tetris piece +type Tetromino struct { + Blocks []types.Block + BlockType types.BlockType + X, Y int +} + +// New creates a new tetromino of the specified type +func New(blockType types.BlockType, x, y int) *Tetromino { shape := TetrominoShapes[blockType] - blocks := make([]Block, 0) + blocks := make([]types.Block, 0) - for i := 0; i < len(shape); i++ { - for j := 0; j < len(shape[i]); j++ { + for i := range shape { + for j := range shape[i] { if shape[i][j] { - blocks = append(blocks, Block{ + blocks = append(blocks, types.Block{ X: j, Y: i, Type: blockType, @@ -115,7 +83,7 @@ func NewTetromino(blockType BlockType, x, y int) *Tetromino { } // Adjust initial position for I piece - if blockType == IBlock { + if blockType == types.IBlock { y-- // Start one row higher } @@ -135,13 +103,13 @@ func (t *Tetromino) Move(dx, dy int) { // Rotate rotates the tetromino clockwise func (t *Tetromino) Rotate() { - if t.BlockType == OBlock { + if t.BlockType == types.OBlock { return // O block doesn't need rotation } // Get the size of the shape matrix size := 3 - if t.BlockType == IBlock { + if t.BlockType == types.IBlock { size = 4 } @@ -159,7 +127,7 @@ func (t *Tetromino) Rotate() { } // Create a new rotated shape - newBlocks := make([]Block, 0) + newBlocks := make([]types.Block, 0) // Perform rotation for y := 0; y < size; y++ { @@ -168,7 +136,7 @@ func (t *Tetromino) Rotate() { // Rotate coordinates 90 degrees clockwise newX := size - 1 - y newY := x - newBlocks = append(newBlocks, Block{ + newBlocks = append(newBlocks, types.Block{ X: newX, Y: newY, Type: t.BlockType, diff --git a/internal/types/types.go b/internal/types/types.go new file mode 100644 index 0000000..a38e9ff --- /dev/null +++ b/internal/types/types.go @@ -0,0 +1,34 @@ +package types + +import "image/color" + +// Block represents a single block in the game +type Block struct { + X, Y int + Type BlockType +} + +// BlockType represents different types of tetrominos +type BlockType int + +const ( + IBlock BlockType = iota + 1 + JBlock + LBlock + OBlock + SBlock + TBlock + ZBlock +) + +// Colors for different block types +var BlockColors = map[BlockType]color.Color{ + 0: color.RGBA{0, 0, 0, 0}, // EmptyBlock + IBlock: color.RGBA{0, 255, 255, 255}, // Cyan + JBlock: color.RGBA{0, 0, 255, 255}, // Blue + LBlock: color.RGBA{255, 165, 0, 255}, // Orange + OBlock: color.RGBA{255, 255, 0, 255}, // Yellow + SBlock: color.RGBA{0, 255, 0, 255}, // Green + TBlock: color.RGBA{128, 0, 128, 255}, // Purple + ZBlock: color.RGBA{255, 0, 0, 255}, // Red +} diff --git a/main.go b/main.go old mode 100644 new mode 100755 index b2f5fa8..0bda141 --- a/main.go +++ b/main.go @@ -5,17 +5,20 @@ import ( "math/rand" "time" + "tetris/internal/game" + "tetris/pkg/config" + "github.com/hajimehoshi/ebiten/v2" ) func main() { rand.Seed(time.Now().UnixNano()) - ebiten.SetWindowSize(ScreenWidth, ScreenHeight) + ebiten.SetWindowSize(config.ScreenWidth, config.ScreenHeight) ebiten.SetWindowTitle("Tetris") - game := NewGame() - if err := ebiten.RunGame(game); err != nil { + g := game.New() + if err := ebiten.RunGame(g); err != nil { log.Fatal(err) } } diff --git a/patch/0001-fix-IBlock-disappear-issue.patch b/patch/0001-fix-IBlock-disappear-issue.patch new file mode 100755 index 0000000..6f8a33c --- /dev/null +++ b/patch/0001-fix-IBlock-disappear-issue.patch @@ -0,0 +1,151 @@ +From ffe9242a73abfb3e8ee9b6effd57e64155717bfb Mon Sep 17 00:00:00 2001 +From: yinqiang +Date: Sat, 7 Jun 2025 22:50:07 +0800 +Subject: [PATCH] fix IBlock disappear issue + +--- + block.go | 21 +++++++++++---------- + game.go | 26 +++++++++++++++----------- + 2 files changed, 26 insertions(+), 21 deletions(-) + +diff --git a/block.go b/block.go +index ad276ea..0b3aaee 100644 +--- a/block.go ++++ b/block.go +@@ -14,7 +14,7 @@ type Block struct { + type BlockType int + + const ( +- IBlock BlockType = iota ++ IBlock BlockType = iota + 1 + JBlock + LBlock + OBlock +@@ -32,13 +32,14 @@ type Tetromino struct { + + // Colors for different block types + var BlockColors = map[BlockType]color.Color{ +- IBlock: color.RGBA{0, 255, 255, 255}, // Cyan +- JBlock: color.RGBA{0, 0, 255, 255}, // Blue +- LBlock: color.RGBA{255, 165, 0, 255}, // Orange +- OBlock: color.RGBA{255, 255, 0, 255}, // Yellow +- SBlock: color.RGBA{0, 255, 0, 255}, // Green +- TBlock: color.RGBA{128, 0, 128, 255}, // Purple +- ZBlock: color.RGBA{255, 0, 0, 255}, // Red ++ EmptyBlock: color.RGBA{0, 0, 0, 0}, // Black ++ IBlock: color.RGBA{0, 255, 255, 255}, // Cyan ++ JBlock: color.RGBA{0, 0, 255, 255}, // Blue ++ LBlock: color.RGBA{255, 165, 0, 255}, // Orange ++ OBlock: color.RGBA{255, 255, 0, 255}, // Yellow ++ SBlock: color.RGBA{0, 255, 0, 255}, // Green ++ TBlock: color.RGBA{128, 0, 128, 255}, // Purple ++ ZBlock: color.RGBA{255, 0, 0, 255}, // Red + } + + // TetrominoShapes defines the shape of each tetromino type +@@ -102,8 +103,8 @@ func NewTetromino(blockType BlockType, x, y int) *Tetromino { + shape := TetrominoShapes[blockType] + blocks := make([]Block, 0) + +- for i := 0; i < len(shape); i++ { +- for j := 0; j < len(shape[i]); j++ { ++ for i := range shape { ++ for j := range shape[i] { + if shape[i][j] { + blocks = append(blocks, Block{ + X: j, +diff --git a/game.go b/game.go +index aa9896f..2aa98e9 100644 +--- a/game.go ++++ b/game.go +@@ -24,6 +24,7 @@ const ( + // Game constants + InitialDropInterval = 60 + MinDropInterval = 5 ++ EmptyBlock = 0 + ) + + // Game represents the main game state +@@ -90,9 +91,9 @@ func (g *Game) Draw(screen *ebiten.Image) { + } + + // Draw placed blocks +- for y := 0; y < BoardHeight; y++ { +- for x := 0; x < BoardWidth; x++ { +- if g.board[y][x] != 0 { ++ for y := range BoardHeight { ++ for x := range BoardWidth { ++ if g.board[y][x] != EmptyBlock { + g.drawBlock(screen, x, y, g.board[y][x]) + } + } +@@ -311,7 +312,7 @@ func (g *Game) isColliding() bool { + } + // Check collision with other pieces + if y >= 0 && x >= 0 && x < BoardWidth && y < BoardHeight { +- if g.board[y][x] != 0 { ++ if g.board[y][x] != EmptyBlock { + return true + } + } +@@ -324,6 +325,9 @@ func (g *Game) lockPiece() { + if g.currentPiece == nil { + return + } ++ // if g.currentPiece.BlockType == IBlock { ++ // g.currentPiece.BlockType = IBlock ++ // } + + positions := g.currentPiece.GetAbsolutePositions() + for _, pos := range positions { +@@ -378,8 +382,8 @@ func (g *Game) clearLines() { + + // isLineFull checks if a line is completely filled + func (g *Game) isLineFull(y int) bool { +- for x := 0; x < BoardWidth; x++ { +- if g.board[y][x] == 0 { ++ for x := range BoardWidth { ++ if g.board[y][x] == EmptyBlock { + return false + } + } +@@ -391,15 +395,15 @@ func (g *Game) removeLine(y int) { + for i := y; i > 0; i-- { + copy(g.board[i], g.board[i-1]) + } +- for x := 0; x < BoardWidth; x++ { +- g.board[0][x] = 0 ++ for x := range BoardWidth { ++ g.board[0][x] = EmptyBlock + } + } + + // spawnNewPiece creates a new piece at the top of the board + func (g *Game) spawnNewPiece() { + if g.nextPiece == nil { +- g.nextPiece = NewTetromino(BlockType(rand.Intn(7)), 0, 0) ++ g.nextPiece = NewTetromino(BlockType(rand.Intn(7)+1), 0, 0) + } + + g.currentPiece = g.nextPiece +@@ -409,7 +413,7 @@ func (g *Game) spawnNewPiece() { + } + g.currentPiece.Y = 0 + +- g.nextPiece = NewTetromino(BlockType(rand.Intn(7)), 0, 0) ++ g.nextPiece = NewTetromino(BlockType(rand.Intn(7)+1), 0, 0) + + if g.isColliding() { + g.gameOver = true +@@ -423,7 +427,7 @@ func (g *Game) wouldCollide(piece *Tetromino) bool { + if x < 0 || x >= BoardWidth || y >= BoardHeight { + return true + } +- if y >= 0 && y < BoardHeight && x >= 0 && x < BoardWidth && g.board[y][x] != 0 { ++ if y >= 0 && y < BoardHeight && x >= 0 && x < BoardWidth && g.board[y][x] != EmptyBlock { + return true + } + } +-- +2.39.5 (Apple Git-154) + diff --git a/pkg/config/config.go b/pkg/config/config.go new file mode 100644 index 0000000..6a3f9bf --- /dev/null +++ b/pkg/config/config.go @@ -0,0 +1,17 @@ +package config + +const ( + // Size constants + BlockSize = 30 + BoardWidth = 10 + BoardHeight = 20 + ScreenWidth = BlockSize * (BoardWidth + 6) // Extra space for next piece preview + ScreenHeight = BlockSize * BoardHeight + PreviewX = BlockSize * (BoardWidth + 1) + PreviewY = BlockSize * 2 + + // Game constants + InitialDropInterval = 60 + MinDropInterval = 5 + EmptyBlock = 0 +)