A game of minesweeper, using Raylib and Nim
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

346 lines
11 KiB

import algorithm, heapqueue, random, options, sequtils, sugar, tables
import raylib, raygui
from times import getTime, toUnix, nanosecond
# Seed rng
let now = getTime()
randomize(now.toUnix * 1_000_000_000 + now.nanosecond)
# constants
const rowSize = 20 # How many tiles per row of the board
const boxStride: int = 40 # How much space does a tile take up
const borderWidth: float32 = 6.float32 # How wide are the borders between tiles
const sideSize = (boxStride-borderWidth.int).float32 # How long is a side of a tile
const infinity = (1.0/0.0).float32
const averageMineCount = ((rowSize*rowSize) * 0.10).int # How many mines should be placed on average?
const boardOffset: int = 90 # How far is the board from the top of the window
type GameState = enum
unfinished,
lost,
won
type TileState = object
mine: bool # is it a mine?
marked: bool # have we marked it as a mine?
revealed: bool # has it been revealed?, implies the search algorithm ran
mineNeighbours: int
type Tile = ref object of RootObj
state: TileState
pos: Rectangle
x: int
y: int
proc `$` (t: Tile): string =
return "state = " & $t.state & ", " & "x = " & $t.x & ", " & "y = " & $t.y
type Board = ref object of RootObj
tiles: seq[Tile]
xIntervals: seq[tuple[l: float32, h: float32]]
yIntervals: seq[tuple[l: float32, h: float32]]
proc isWinning(board: Board): bool =
# If All tiles are either revealed or mines (but not both), you won
board.tiles.all((tile) => (tile.state.mine and (not tile.state.revealed)) or tile.state.revealed)
proc getTile(board: Board, x: int, y: int): Option[Tile] =
# Given x, y coords, get tile if it exists
if x > (rowSize - 1) or y > (rowSize - 1) or x < 0 or y < 0:
return none(Tile)
let l = board.tiles.len
let pos = (rowSize * y) + x
if pos >= 0 and pos < l:
return some(board.tiles[pos])
none(Tile)
proc getNeighbours(board: Board, tile: Tile): seq[Tile] =
let x = tile.x
let y = tile.y
let positions = @[
@[x+1, y],
@[x+1, y+1],
@[x+1, y-1],
@[x, y+1],
@[x, y-1],
@[x-1, y+1],
@[x-1, y],
@[x-1, y-1]
] # neighboring coordinates to check
let filteredPositions = filterIt(positions, getTile(board, it[0], it[1]).isSome)
assert(filteredPositions.len >= 3 and filteredPositions.len <= 8)
return map(filteredPositions, (pos) => getTile(board, pos[0], pos[1]).get)
proc revealBoard(board: Board, tile: Tile) =
# Recursively reveal neighbours unless one of them is a mine
var q = initHeapQueue[Tile]()
q.push(tile)
var processed = initTable[string, bool]()
while q.len > 0:
let t = q.pop()
let k = $t.x & "," & $t.y
if processed.contains(k):
continue
processed[k] = true
# If it's not a mine, reveal it
t.state.revealed = true
# If any of the neighbours are mines, stop
let neighbours = getNeighbours(board, t)
if anyIt(neighbours, it.state.mine):
t.state.mineNeighbours = filterIt(neighbours, it.state.mine).len
continue
# Add all the neighbours to the queue to be processed
for neighbour in neighbours:
if processed.contains(($neighbour.x & "," & $neighbour.y)):
continue
# Must not add mines to the queue
assert(not neighbour.state.mine)
q.push(neighbour)
proc setMine(numMines: int): bool =
let maxR: float = rowSize*rowSize
return rand(max_r) < (numMines.float)
proc comparator(a: tuple[l: float32, h: float32], k: int): int =
if k.float32 < a.l:
return 1
if k.float32 > a.h:
return -1
else:
return 0
proc getTilePos(mouseX: int, mouseY: int, board: Board): int =
# Take x, y coordinates of cursor
# Do search for the tile x and y coordinates using intervals
# Return position in the set of tiles
const boardLength = (boxStride+borderWidth.int) * (rowSize - 1) # How long is a side of the board
if mouseX.float32 > (boardLength + borderWidth) or mouseY.float32 > (boardLength + borderWidth):
return -1
if mouseX < boardOffset or mouseY < boardOffset:
return -1
let x: int = board.xIntervals.binarySearch(mouseX, comparator) - 1
let y: int = board.yIntervals.binarySearch(mouseY, comparator) - 1
return (y*rowSize) + x
proc drawTile(heightPos: int, widthPos: int, state: TileState, reveal: bool = false): Tile =
let edge = 1.float32 # How far from the edge is the X
let y = boardOffset+(boxStride*heightPos)
let x = boardOffset+(boxStride*widthPos)
let boxRect = Rectangle(x: x.float32, y: y.float32, width: sideSize, height: sideSize)
let c1 = Color(r: 2, g: 91, b: 36, a: 255)
let c2 = Color(r: 0, g: 0, b: 0, a: 255)
let c3 = Color(r: 0, g: 91, b: 109, a: 255)
let c4 = Color(r: 0, g: 212, b: 255, a: 255)
if state.mine and reveal:
DrawRectangleRec(boxRect, RED)
elif state.revealed:
DrawRectangleRec(boxRect, GREEN)
if state.mineNeighbours > 0:
DrawText($state.mineNeighbours, x+(sideSize/4).int, y, sideSize.int, BLACK)
# show everything if `reveal` is true
elif reveal:
DrawRectangleRec(boxRect, GREEN)
else:
DrawRectangleGradientEx(boxRect, c1, c2, c3, c4)
let start1 = Vector2(x: x.float32+edge, y: y.float32+edge)
let end1 = Vector2(x: (x+boxStride).float32-borderWidth-edge, y: (y+boxStride).float32-borderWidth-edge)
let start2 = Vector2(x: (x).float32+edge,
y: (y+boxStride).float32-borderWidth-edge)
let end2 = Vector2(x: (x+boxStride).float32-borderWidth-edge,
y: y.float32+edge)
if state.marked:
DrawLineEx(start1, end1, 3, WHITE)
DrawLineEx(start2, end2, 3, WHITE)
return Tile(state: state, x: widthPos, y: heightPos, pos: boxRect)
proc drawBoardWindow() =
DrawRectangle(boardOffset, boardOffset, boxStride*rowSize, boxStride*rowSize, BLACK)
proc generateBoard(screenWidth: int, screenHeight: int, rowSize: int): Board =
# Draw the initial board
drawBoardWindow()
var tiles: seq[Tile]
var xIntervals: seq[tuple[l: float32, h: float32]] = @[]
var yIntervals: seq[tuple[l: float32, h: float32]] = @[]
for heightPos in countup(0, rowSize-1, 1):
xIntervals = @[]
for widthPos in countup(0, rowSize-1, 1):
var state: TileState
state.marked = false
state.revealed = false
state.mine = setMine(averageMineCount) # average number of mines
state.mineNeighbours = 0
let tile = drawTile(heightPos, widthPos, state)
tiles &= @[tile]
# FIXME ugly shit, encapsulate it into a function or make it more terse
if xIntervals.len > 0:
xIntervals &= @[(l: xIntervals[xIntervals.high].h, h: tile.pos.x.float32)]
else:
xIntervals &= @[(l: 0.float32, h: tile.pos.x.float32)]
if yIntervals.len > 0:
yIntervals &= @[(l: yIntervals[yIntervals.high].h, h: tiles[tiles.high].pos.y.float32)]
else:
yIntervals &= @[(l: 0.float32, h: tiles[tiles.high].pos.y.float32)]
# Required for fencepost cases
xIntervals &= (l: xIntervals[xIntervals.high].h, h: infinity)
yIntervals &= (l: yIntervals[yIntervals.high].h, h: infinity)
result = Board(tiles: tiles, xIntervals: xIntervals, yIntervals: yIntervals)
proc drawBoard(screenWidth: int, screenHeight: int, board: Board) =
drawBoardWindow()
for tile in board.tiles:
discard drawTile(tile.y, tile.x, tile.state)
proc showBoard(screenWidth: int, screenHeight: int, board: Board) =
# Show the entire board
drawBoardWindow()
for tile in board.tiles:
discard drawTile(tile.y, tile.x, tile.state, reveal=true)
proc guiLoop*() =
echo "gui loop starting"
# TODO get from xlib
var screenWidth: int = 100
var screenHeight: int = 100
SetConfigFlags(ord(ConfigFlags.FLAG_WINDOW_UNDECORATED))
InitWindow(screenWidth, screenHeight, "Minesweeper")
let monitor = GetCurrentMonitor()
screenWidth = (monitor.GetMonitorWidth() / 2).int
screenHeight = (monitor.GetMonitorHeight() / 2).int
SetWindowSize(screenWidth, screenHeight)
SetWindowTitle("Minesweeper")
MaximizeWindow()
GuiLoadStyle("styles/terminal/terminal.rgs")
#echo GetScreenWidth()
#echo GetScreenHeight()
var mousePos = Vector2(x: 0, y: 0)
var windowPos = Vector2(x: screenWidth.float32, y: screenHeight.float32)
var panOffset = mousePos
var dragWindow = false
var exitWindow = false
var board: Option[Board] = none(Board)
var gameState: GameState = unfinished
SetTargetFPS(60)
while not exitWindow and not WindowShouldClose():
screenWidth = (monitor.GetMonitorWidth()).int
screenHeight = (monitor.GetMonitorHeight()).int
mousePos = GetMousePosition()
if IsMouseButtonPressed(MOUSE_LEFT_BUTTON):
if CheckCollisionPointRec(mousePos, Rectangle(x: 0.float32, y: 0.float32, width: screenWidth.float32, height: 20.float32)):
dragWindow = true
panOffset = mousePos
else:
# Check if we're clicking a minefield tile
if board.isSome and gameState == unfinished:
echo isWinning(board.get)
let tilePos = getTilePos(mousePos.x.int, mousePos.y.int, board.get) # TODO make this take vector2 instead ?
if tilePos >= 0:
let tile = board.get.tiles[tilePos]
if tile.state.mine and not tile.state.marked:
echo "boom!"
gameState = lost
if tile.state.marked:
echo "do nothing"
else:
echo "reveal it"
revealBoard(board.get, tile)
if isWinning(board.get):
echo "You won!"
gameState = won
if IsMouseButtonPressed(MOUSE_RIGHT_BUTTON):
if board.isSome:
let tilePos = getTilePos(mousePos.x.int, mousePos.y.int, board.get)
if tilePos >= 0:
let tile = board.get.tiles[tilePos]
if not tile.state.revealed:
board.get.tiles[tilePos].state.marked = not tile.state.marked
if dragWindow:
windowPos.x += (mousePos.x - panOffset.x)
windowPos.y += (mousePos.y - panOffset.y)
if IsMouseButtonReleased(MOUSE_LEFT_BUTTON):
dragWindow = false
SetWindowPosition(windowPos.x.int, windowPos.y.int)
BeginDrawing()
# This must come before anything else!
ClearBackground(RAYWHITE)
exitWindow = GuiWindowBox(Rectangle(x: 0.float32, y: 0.float32, width: screenWidth.float32, height: screenHeight.float32),
"#198# Minesweeper".cstring)
if board.isNone:
# Generate the initial board if there isn't one
board = some(generateBoard(screenWidth, screenHeight, rowSize))
else:
if gameState == won and board.isSome:
showBoard(screenWidth, screenHeight, board.get)
DrawText("You won!", (screenWidth/2).int, (screenHeight/2).int, 30, GREEN)
elif gameState == lost and board.isSome:
showBoard(screenWidth, screenHeight, board.get)
DrawText("You lost! :(", (screenWidth/2).int, (screenHeight/2).int, 30, RED)
# Otherwise update the state of the abstract board with the window
else:
drawBoard(screenWidth, screenHeight, board.get)
EndDrawing()
CloseWindow()