minesweeper/main.go

427 lines
11 KiB
Go

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