427 lines
11 KiB
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)
|
|
}
|
|
}
|