go_games_collection/minesweeper_game.go

300 lines
9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"errors"
"math/rand"
"time"
)
// ПРИМЕЧАНИЕ О ПРАВИЛАХ: классический Сапёр. Мины расставляются
// только ПОСЛЕ первого клика — так, чтобы кликнутая клетка и все её
// соседи гарантированно не содержали мину (это даёт более приятное,
// обычно достаточно большое первое открытие, а не голую цифру) —
// распространённое улучшение поверх самой базовой гарантии "хотя бы
// кликнутая клетка не мина". "Аккорд" (быстрое открытие соседей у
// уже открытой цифры, если рядом расставлено ровно нужное число
// флагов) реализован, как и в привычных версиях игры.
var (
ErrMinesweeperGameOver = errors.New("партия уже завершена")
ErrMinesweeperOutOfBounds = errors.New("клетка вне пределов поля")
ErrMinesweeperFlagged = errors.New("сначала снимите флаг с этой клетки")
ErrMinesweeperAlreadyOpen = errors.New("клетка уже открыта")
ErrMinesweeperNotRevealed = errors.New("эта клетка ещё не открыта")
)
// MinesweeperPos — координаты клетки (0-based, row/col).
type MinesweeperPos struct {
Row, Col int
}
// minesweeperCell — состояние одной клетки поля.
type minesweeperCell struct {
Revealed bool
Flagged bool
IsMine bool
AdjacentMines int
}
// MinesweeperResult — итог завершившейся партии.
type MinesweeperResult struct {
Won bool
}
// MinesweeperGameState — полное состояние партии в Сапёра.
type MinesweeperGameState struct {
Width, Height int
MineCount int
Board [][]minesweeperCell
minesPlaced bool // мины расставляются только после первого клика
RevealedCount int
FlagCount int
Result *MinesweeperResult
rng *rand.Rand
}
// NewMinesweeperGame создаёт новое поле width x height с mineCount
// мин (не больше width*height-1 — хотя бы одна клетка гарантированно
// останется небезопасной для мины, чтобы было куда сходить первым
// ходом). Сами мины появятся только после первого хода — см. Reveal.
func NewMinesweeperGame(width, height, mineCount int) *MinesweeperGameState {
maxMines := width*height - 1
if mineCount > maxMines {
mineCount = maxMines
}
g := &MinesweeperGameState{
Width: width, Height: height, MineCount: mineCount,
rng: rand.New(rand.NewSource(time.Now().UnixNano())),
}
g.Board = make([][]minesweeperCell, height)
for r := range g.Board {
g.Board[r] = make([]minesweeperCell, width)
}
return g
}
func (g *MinesweeperGameState) inBounds(p MinesweeperPos) bool {
return p.Row >= 0 && p.Row < g.Height && p.Col >= 0 && p.Col < g.Width
}
func msNeighbors(p MinesweeperPos) []MinesweeperPos {
out := make([]MinesweeperPos, 0, 8)
for dr := -1; dr <= 1; dr++ {
for dc := -1; dc <= 1; dc++ {
if dr == 0 && dc == 0 {
continue
}
out = append(out, MinesweeperPos{Row: p.Row + dr, Col: p.Col + dc})
}
}
return out
}
// placeMines расставляет мины случайно (используя переданный
// генератор rnd — так тесты могут вызывать её напрямую
// детерминированно), избегая точки first и всех её соседей
// (безопасная зона первого клика), затем считает число мин-соседей
// для каждой клетки.
func (g *MinesweeperGameState) placeMines(first MinesweeperPos, rnd *rand.Rand) {
safe := map[MinesweeperPos]bool{first: true}
for _, n := range msNeighbors(first) {
if g.inBounds(n) {
safe[n] = true
}
}
var candidates []MinesweeperPos
for r := 0; r < g.Height; r++ {
for c := 0; c < g.Width; c++ {
p := MinesweeperPos{Row: r, Col: c}
if !safe[p] {
candidates = append(candidates, p)
}
}
}
count := g.MineCount
if count > len(candidates) {
count = len(candidates) // защитный случай — слишком мало свободных клеток
}
rnd.Shuffle(len(candidates), func(i, j int) { candidates[i], candidates[j] = candidates[j], candidates[i] })
for i := 0; i < count; i++ {
p := candidates[i]
g.Board[p.Row][p.Col].IsMine = true
}
for r := 0; r < g.Height; r++ {
for c := 0; c < g.Width; c++ {
p := MinesweeperPos{Row: r, Col: c}
n := 0
for _, adj := range msNeighbors(p) {
if g.inBounds(adj) && g.Board[adj.Row][adj.Col].IsMine {
n++
}
}
g.Board[r][c].AdjacentMines = n
}
}
g.minesPlaced = true
}
// Reveal открывает клетку pos. Если это первый ход партии, сначала
// расставляет мины (см. placeMines).
func (g *MinesweeperGameState) Reveal(pos MinesweeperPos) error {
if g.Result != nil {
return ErrMinesweeperGameOver
}
if !g.inBounds(pos) {
return ErrMinesweeperOutOfBounds
}
if !g.minesPlaced {
g.placeMines(pos, g.rng)
}
cell := &g.Board[pos.Row][pos.Col]
if cell.Flagged {
return ErrMinesweeperFlagged
}
if cell.Revealed {
return ErrMinesweeperAlreadyOpen
}
if cell.IsMine {
g.revealAllMines()
g.Result = &MinesweeperResult{Won: false}
return nil
}
g.floodReveal(pos)
g.checkWin()
return nil
}
// floodReveal открывает pos и, если у неё 0 мин-соседей, рекурсивно
// (через очередь) открывает всех соседей — классическое каскадное
// открытие пустых областей.
func (g *MinesweeperGameState) floodReveal(start MinesweeperPos) {
if g.Board[start.Row][start.Col].Revealed || g.Board[start.Row][start.Col].Flagged {
return
}
queue := []MinesweeperPos{start}
g.Board[start.Row][start.Col].Revealed = true
g.RevealedCount++
for len(queue) > 0 {
cur := queue[0]
queue = queue[1:]
if g.Board[cur.Row][cur.Col].AdjacentMines != 0 {
continue
}
for _, n := range msNeighbors(cur) {
if !g.inBounds(n) {
continue
}
nc := &g.Board[n.Row][n.Col]
if nc.Revealed || nc.Flagged || nc.IsMine {
continue
}
nc.Revealed = true
g.RevealedCount++
queue = append(queue, n)
}
}
}
// Chord — быстрое открытие соседей уже открытой цифры, если число
// флагов вокруг неё совпадает с самой цифрой (вызывается на уже
// открытой клетке).
func (g *MinesweeperGameState) Chord(pos MinesweeperPos) error {
if g.Result != nil {
return ErrMinesweeperGameOver
}
if !g.inBounds(pos) {
return ErrMinesweeperOutOfBounds
}
cell := g.Board[pos.Row][pos.Col]
if !cell.Revealed {
return ErrMinesweeperNotRevealed
}
flagCount := 0
for _, n := range msNeighbors(pos) {
if g.inBounds(n) && g.Board[n.Row][n.Col].Flagged {
flagCount++
}
}
if flagCount != cell.AdjacentMines {
return nil // недостаточно/слишком много флагов — аккорд не срабатывает, это не ошибка
}
for _, n := range msNeighbors(pos) {
if !g.inBounds(n) {
continue
}
nc := g.Board[n.Row][n.Col]
if nc.Revealed || nc.Flagged {
continue
}
if nc.IsMine {
g.revealAllMines()
g.Result = &MinesweeperResult{Won: false}
return nil
}
g.floodReveal(n)
}
if g.Result == nil {
g.checkWin()
}
return nil
}
func (g *MinesweeperGameState) revealAllMines() {
for r := 0; r < g.Height; r++ {
for c := 0; c < g.Width; c++ {
if g.Board[r][c].IsMine {
g.Board[r][c].Revealed = true
}
}
}
}
func (g *MinesweeperGameState) checkWin() {
total := g.Width * g.Height
if g.RevealedCount == total-g.MineCount {
g.Result = &MinesweeperResult{Won: true}
}
}
// ToggleFlag ставит/снимает флаг на скрытой клетке.
func (g *MinesweeperGameState) ToggleFlag(pos MinesweeperPos) error {
if g.Result != nil {
return ErrMinesweeperGameOver
}
if !g.inBounds(pos) {
return ErrMinesweeperOutOfBounds
}
cell := &g.Board[pos.Row][pos.Col]
if cell.Revealed {
return ErrMinesweeperAlreadyOpen
}
if cell.Flagged {
cell.Flagged = false
g.FlagCount--
} else {
cell.Flagged = true
g.FlagCount++
}
return nil
}
// MinesRemaining — сколько мин, по мнению счётчика, ещё не
// помечено флагом (может уйти в минус при перефлаживании — это
// нормально и так же ведёт себя в привычных версиях игры).
func (g *MinesweeperGameState) MinesRemaining() int {
return g.MineCount - g.FlagCount
}