package main import ( "errors" "fmt" "log" "math/rand/v2" "strings" "time" "github.com/awesome-gocui/gocui" ) type gameState struct { isGameOver bool rows int cols int numMines int field [][]point gameStartedAt time.Time firstMove bool cursorX int cursorY int } type point struct { isMine bool isOpen bool isMarked bool numNeighbouringMines int } func (gs *gameState) applyToNeighbours(x, y int, do func(x, y int)) { dx := []int{-1, -1, -1, 0, 0, 1, 1, 1} dy := []int{-1, 0, 1, -1, 1, -1, 0, 1} for i := range dx { neighbourX := x + dx[i] neighbourY := y + dy[i] if neighbourX >= 0 && neighbourY >= 0 && neighbourX < gs.rows && neighbourY < gs.cols { do(neighbourX, neighbourY) } } } // Colors using gocui's attributes var numberColors = []gocui.Attribute{ gocui.ColorBlue, // 1 gocui.ColorGreen, // 2 gocui.ColorRed, // 3 gocui.ColorMagenta, // 4 gocui.ColorYellow, // 5 gocui.ColorCyan, // 6 gocui.ColorWhite, // 7 gocui.ColorDefault, // 8 } // Restore emoji cell representations var ( emojiUnopened = "🟦 " // Note the trailing space for consistent width emojiMarked = "🚩 " // Note the trailing space emojiMine = "💣 " // Note the trailing space emojiEmpty = " " // Three spaces ) func (gs *gameState) printField(v *gocui.View) { v.Clear() // Build the entire board string first to avoid partial rendering issues var board strings.Builder for i := range gs.field { for j := range gs.field[i] { cell := gs.field[i][j] if !cell.isOpen { if cell.isMarked { board.WriteString(emojiMarked) // Fixed-width emoji + space } else { board.WriteString(emojiUnopened) // Fixed-width emoji + space } } else { if cell.isMine { board.WriteString(emojiMine) // Fixed-width emoji + space } else if cell.numNeighbouringMines > 0 { num := cell.numNeighbouringMines // Format numbers to take exactly the same width as emojis (3 chars) board.WriteString(fmt.Sprintf(" %d ", num)) } else { board.WriteString(emojiEmpty) // Three spaces } } } board.WriteString("\n") // End of row } // Write the entire board at once fmt.Fprint(v, board.String()) } func (gs *gameState) initField(safeX, safeY int) { gs.field = make([][]point, gs.rows) for i := range gs.field { gs.field[i] = make([]point, gs.cols) } // Create a list of all possible mine positions except the first clicked cell and its neighbors possiblePositions := make([]struct{ x, y int }, 0, gs.rows*gs.cols) for i := 0; i < gs.rows; i++ { for j := 0; j < gs.cols; j++ { // Skip the safe cell and its neighbors if (i == safeX && j == safeY) || isCellNeighbor(i, j, safeX, safeY) { continue } possiblePositions = append(possiblePositions, struct{ x, y int }{i, j}) } } // Shuffle the positions for i := range possiblePositions { j := rand.IntN(len(possiblePositions)) possiblePositions[i], possiblePositions[j] = possiblePositions[j], possiblePositions[i] } // Place mines using the first numMines positions minesPlaced := 0 for i := 0; i < len(possiblePositions) && minesPlaced < gs.numMines; i++ { pos := possiblePositions[i] gs.field[pos.x][pos.y].isMine = true gs.applyToNeighbours(pos.x, pos.y, func(x, y int) { gs.field[x][y].numNeighbouringMines++ }) minesPlaced++ } } func (gs *gameState) layout(g *gocui.Gui) error { maxX, maxY := g.Size() // Calculate game view dimensions based on the grid size // Each cell is exactly 3 characters wide (emoji + space) gameWidth := gs.cols*3 + 2 // +2 for the frame gameHeight := gs.rows + 2 // +2 for the frame (reduced padding) // Center the game view precisely gameX := (maxX - gameWidth) / 2 gameY := (maxY - gameHeight - 3) / 2 // Adjust position to center vertically, leaving room for restart button // Game view - centered with proper spacing if v, err := g.SetView("game", gameX, gameY, gameX+gameWidth, gameY+gameHeight, 0); err != nil { if !errors.Is(err, gocui.ErrUnknownView) { return err } v.Title = "Minesweeper" v.Clear() // Remove the blank line padding that was causing extra space gs.printField(v) if _, err := g.SetCurrentView("game"); err != nil { return err } v.Editor = gocui.EditorFunc(gs.gameEditor) v.Editable = true } // Restart button - positioned below the game view buttonWidth := 14 buttonY := gameY + gameHeight + 1 // Position right below the game view if v, err := g.SetView("restart", maxX/2-buttonWidth/2, buttonY, maxX/2+buttonWidth/2, buttonY+2, 0); err != nil { if !errors.Is(err, gocui.ErrUnknownView) { return err } v.Title = "Restart" fmt.Fprintln(v, " [R] ") } return nil } func (gs *gameState) keybindings(g *gocui.Gui) error { if err := g.SetKeybinding("", gocui.KeyCtrlC, gocui.ModNone, gs.quit); err != nil { return err } // Left click to open cells if err := g.SetKeybinding("game", gocui.MouseLeft, gocui.ModNone, gs.handleMouseClick); err != nil { return err } // Right click to mark cells if err := g.SetKeybinding("game", gocui.MouseRight, gocui.ModNone, gs.handleRightClick); err != nil { return err } // 'R' key to restart the game - making it lowercase and uppercase if err := g.SetKeybinding("", 'r', gocui.ModNone, gs.restart); err != nil { return err } if err := g.SetKeybinding("", 'R', gocui.ModNone, gs.restart); err != nil { return err } // Mouse binding for restart button if err := g.SetKeybinding("restart", gocui.MouseLeft, gocui.ModNone, gs.restart); err != nil { return err } return nil } func (gs *gameState) quit(g *gocui.Gui, v *gocui.View) error { return gocui.ErrQuit } // Use the MousePosition method from gocui instead of view cursor func (gs *gameState) handleMouseClick(g *gocui.Gui, v *gocui.View) error { // Get global mouse position mx, my := g.MousePosition() // Get view's position vx0, vy0, _, _, err := g.ViewPosition("game") if err != nil { return err } // Calculate local position within the view (accounting for frame) localX := mx - vx0 - 1 // -1 for left frame localY := my - vy0 - 1 // -1 for top frame // Calculate cell coordinates cellX := localY cellY := localX / 3 // Proceed with the game logic if cellX >= 0 && cellX < gs.rows && cellY >= 0 && cellY < gs.cols { // If it's the first move, initialize the field after the click if gs.firstMove { gs.initField(cellX, cellY) // Pass click coordinates to initField gs.firstMove = false gs.gameStartedAt = time.Now() // Reset the start time to when the game actually begins } if !gs.field[cellX][cellY].isOpen && !gs.field[cellX][cellY].isMarked { if gs.field[cellX][cellY].isMine { gs.revealAllMines() gs.isGameOver = true } else { gs.openCell(cellX, cellY) } gs.printField(v) gs.updateGameState(g) } } return nil } // Handle keyboard input for the game func (gs *gameState) gameEditor(v *gocui.View, key gocui.Key, ch rune, mod gocui.Modifier) { // Handle keyboard input for the game if key == gocui.KeyArrowUp { // Move cursor up } else if key == gocui.KeyArrowDown { // Move cursor down } // etc. } func (gs *gameState) openCell(x, y int) { if x < 0 || y < 0 || x >= gs.rows || y >= gs.cols || gs.field[x][y].isOpen { return // Bounds check and already opened check } gs.field[x][y].isOpen = true if gs.field[x][y].numNeighbouringMines == 0 { gs.applyToNeighbours(x, y, gs.openCell) // Recursively open neighbours if no adjacent mines } } func (gs *gameState) revealAllMines() { for i := 0; i < gs.rows; i++ { for j := 0; j < gs.cols; j++ { if gs.field[i][j].isMine { gs.field[i][j].isOpen = true // Reveal all mines } } } } func (gs *gameState) isGameWon() bool { openedCells := 0 for i := 0; i < gs.rows; i++ { for j := 0; j < gs.cols; j++ { if gs.field[i][j].isOpen && !gs.field[i][j].isMine { openedCells++ } } } return openedCells == (gs.rows*gs.cols - gs.numMines) // Check if all non-mine cells are opened } // Helper function to check if a cell is a neighbor of another cell func isCellNeighbor(x1, y1, x2, y2 int) bool { return abs(x1-x2) <= 1 && abs(y1-y2) <= 1 } // Helper function for absolute value func abs(x int) int { if x < 0 { return -x } return x } func (gs *gameState) restart(g *gocui.Gui, v *gocui.View) error { // Reset game state gs.isGameOver = false gs.firstMove = true gs.gameStartedAt = time.Now() gs.field = make([][]point, gs.rows) // Clear field for i := range gs.field { gs.field[i] = make([]point, gs.cols) } // Delete any message view g.DeleteView("message") // Find the game view and refresh it if gameView, err := g.View("game"); err == nil { gs.printField(gameView) } return nil } // Add a new function to handle right-clicks for marking cells func (gs *gameState) handleRightClick(g *gocui.Gui, v *gocui.View) error { // Get global mouse position mx, my := g.MousePosition() // Get view's position vx0, vy0, _, _, err := g.ViewPosition("game") if err != nil { return err } // Calculate local position within the view (accounting for frame) localX := mx - vx0 - 1 // -1 for left frame localY := my - vy0 - 1 // -1 for top frame // Calculate cell coordinates cellX := localY cellY := localX / 3 // Proceed with the game logic if cellX >= 0 && cellX < gs.rows && cellY >= 0 && cellY < gs.cols { // Can only mark cells that aren't already open if !gs.field[cellX][cellY].isOpen { // Toggle the marked state gs.field[cellX][cellY].isMarked = !gs.field[cellX][cellY].isMarked gs.printField(v) } } return nil } // Add a dedicated function for game state changes func (gs *gameState) updateGameState(g *gocui.Gui) { if gs.isGameWon() { gs.revealAllMines() gs.isGameOver = true gs.showMessage(g, "YEET DAB!", "Congratulation! You're winRAR!\nR to restart or Ctrl+C to ragequit") } else if gs.isGameOver { gs.showMessage(g, "OH SHIET!", "You dun goofed!\nR to restart or Ctrl+C to ragequit") } } // Helper for message display func (gs *gameState) showMessage(g *gocui.Gui, title, message string) error { maxX, maxY := g.Size() width, height := 40, 5 // Slightly larger for better appearance msgView, err := g.SetView("message", maxX/2-width/2, maxY/2-height/2, maxX/2+width/2, maxY/2+height/2, 0) if err != nil && !errors.Is(err, gocui.ErrUnknownView) { return err } msgView.Title = title msgView.Clear() // Add padding for better appearance fmt.Fprintln(msgView, "") fmt.Fprintln(msgView, " "+message) return nil } func main() { g, err := gocui.NewGui(gocui.OutputNormal, true) if err != nil { log.Panicln(err) } defer g.Close() g.Cursor = false g.Mouse = true gs := &gameState{ rows: 10, cols: 10, numMines: 10, gameStartedAt: time.Now(), firstMove: true, } gs.initField(0, 0) g.SetManagerFunc(gs.layout) if err := gs.keybindings(g); err != nil { log.Panicln(err) } if err := g.MainLoop(); err != nil && !errors.Is(err, gocui.ErrQuit) { log.Panicln(err) } }