Compare commits
2
Commits
v0.1.0
..
8b6cc0bedc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b6cc0bedc | ||
|
|
a9846fd832 |
@@ -43,6 +43,7 @@ import androidx.compose.ui.platform.LocalUriHandler
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.russhwolf.settings.get
|
import com.russhwolf.settings.get
|
||||||
import com.russhwolf.settings.set
|
import com.russhwolf.settings.set
|
||||||
|
import de.stefan_oltmann.mines.model.BoardLayout
|
||||||
import de.stefan_oltmann.mines.model.Game
|
import de.stefan_oltmann.mines.model.Game
|
||||||
import de.stefan_oltmann.mines.model.GameConfig
|
import de.stefan_oltmann.mines.model.GameConfig
|
||||||
import de.stefan_oltmann.mines.model.GameDifficulty
|
import de.stefan_oltmann.mines.model.GameDifficulty
|
||||||
@@ -88,8 +89,12 @@ fun App() {
|
|||||||
mapHeight = settings["mines_map_height"] ?: defaultMapHeight,
|
mapHeight = settings["mines_map_height"] ?: defaultMapHeight,
|
||||||
mineCount = resolveSavedMineCount(
|
mineCount = resolveSavedMineCount(
|
||||||
mapWidth = settings["mines_map_width"] ?: defaultMapWidth,
|
mapWidth = settings["mines_map_width"] ?: defaultMapWidth,
|
||||||
mapHeight = settings["mines_map_height"] ?: defaultMapHeight
|
mapHeight = settings["mines_map_height"] ?: defaultMapHeight,
|
||||||
)
|
boardLayout = savedBoardLayout(),
|
||||||
|
hexSideLength = settings["mines_hex_side"] ?: DEFAULT_HEX_SIDE
|
||||||
|
),
|
||||||
|
boardLayout = savedBoardLayout(),
|
||||||
|
hexSideLength = settings["mines_hex_side"] ?: DEFAULT_HEX_SIDE
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -138,18 +143,29 @@ fun App() {
|
|||||||
|
|
||||||
val oldMapWidth = settings["mines_map_width"] ?: defaultMapWidth
|
val oldMapWidth = settings["mines_map_width"] ?: defaultMapWidth
|
||||||
val oldMapHeight = settings["mines_map_height"] ?: defaultMapHeight
|
val oldMapHeight = settings["mines_map_height"] ?: defaultMapHeight
|
||||||
val oldMineCount = resolveSavedMineCount(oldMapWidth, oldMapHeight)
|
val oldBoardLayout = savedBoardLayout()
|
||||||
|
val oldHexSideLength = settings["mines_hex_side"] ?: DEFAULT_HEX_SIDE
|
||||||
|
val oldMineCount = resolveSavedMineCount(
|
||||||
|
oldMapWidth,
|
||||||
|
oldMapHeight,
|
||||||
|
oldBoardLayout,
|
||||||
|
oldHexSideLength
|
||||||
|
)
|
||||||
|
|
||||||
/* Save new settings to config */
|
/* Save new settings to config */
|
||||||
settings["mines_cell_size"] = newGameConfig.cellSize
|
settings["mines_cell_size"] = newGameConfig.cellSize
|
||||||
settings["mines_map_width"] = newGameConfig.mapWidth
|
settings["mines_map_width"] = newGameConfig.mapWidth
|
||||||
settings["mines_map_height"] = newGameConfig.mapHeight
|
settings["mines_map_height"] = newGameConfig.mapHeight
|
||||||
settings["mines_mine_count"] = newGameConfig.mineCount
|
settings["mines_mine_count"] = newGameConfig.mineCount
|
||||||
|
settings["mines_board_layout"] = newGameConfig.boardLayout.toSettingsValue()
|
||||||
|
settings["mines_hex_side"] = newGameConfig.hexSideLength
|
||||||
|
|
||||||
val mapSettingsChanged =
|
val mapSettingsChanged =
|
||||||
oldMapWidth != newGameConfig.mapWidth ||
|
oldMapWidth != newGameConfig.mapWidth ||
|
||||||
oldMapHeight != newGameConfig.mapHeight ||
|
oldMapHeight != newGameConfig.mapHeight ||
|
||||||
oldMineCount != newGameConfig.mineCount
|
oldMineCount != newGameConfig.mineCount ||
|
||||||
|
oldBoardLayout != newGameConfig.boardLayout ||
|
||||||
|
oldHexSideLength != newGameConfig.hexSideLength
|
||||||
|
|
||||||
/* Launch a new game every time the settings change something that influences the map */
|
/* Launch a new game every time the settings change something that influences the map */
|
||||||
if (mapSettingsChanged) {
|
if (mapSettingsChanged) {
|
||||||
@@ -346,18 +362,34 @@ fun App() {
|
|||||||
* Prefer an explicit saved mine count. Fall back to the old difficulty
|
* Prefer an explicit saved mine count. Fall back to the old difficulty
|
||||||
* preset so existing installs keep a sensible default.
|
* preset so existing installs keep a sensible default.
|
||||||
*/
|
*/
|
||||||
private fun resolveSavedMineCount(mapWidth: Int, mapHeight: Int): Int {
|
private fun resolveSavedMineCount(
|
||||||
|
mapWidth: Int,
|
||||||
|
mapHeight: Int,
|
||||||
|
boardLayout: BoardLayout,
|
||||||
|
hexSideLength: Int
|
||||||
|
): Int {
|
||||||
|
|
||||||
val savedMineCount: Int? = settings["mines_mine_count"]
|
val savedMineCount: Int? = settings["mines_mine_count"]
|
||||||
|
|
||||||
if (savedMineCount != null)
|
if (savedMineCount != null)
|
||||||
return clampMineCount(savedMineCount, mapWidth, mapHeight)
|
return clampMineCount(
|
||||||
|
savedMineCount,
|
||||||
|
mapWidth,
|
||||||
|
mapHeight,
|
||||||
|
boardLayout,
|
||||||
|
hexSideLength
|
||||||
|
)
|
||||||
|
|
||||||
val difficulty = GameDifficulty.fromSettingsValue(settings["mines_difficulty"])
|
val difficulty = GameDifficulty.fromSettingsValue(settings["mines_difficulty"])
|
||||||
|
|
||||||
return clampMineCount(
|
return clampMineCount(
|
||||||
mineCount = difficulty.calcMineCount(mapWidth, mapHeight),
|
mineCount = difficulty.calcMineCount(mapWidth, mapHeight),
|
||||||
mapWidth = mapWidth,
|
mapWidth = mapWidth,
|
||||||
mapHeight = mapHeight
|
mapHeight = mapHeight,
|
||||||
|
boardLayout = boardLayout,
|
||||||
|
hexSideLength = hexSideLength
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun savedBoardLayout(): BoardLayout =
|
||||||
|
BoardLayout.fromSettingsValue(settings["mines_board_layout"])
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ const val DEFAULT_CELL_SIZE: Int = 40
|
|||||||
const val MIN_LONG_SIDE: Int = 5
|
const val MIN_LONG_SIDE: Int = 5
|
||||||
const val MAX_LONG_SIDE: Int = 50
|
const val MAX_LONG_SIDE: Int = 50
|
||||||
|
|
||||||
|
const val MIN_HEX_SIDE: Int = 3
|
||||||
|
const val MAX_HEX_SIDE: Int = 29
|
||||||
|
const val DEFAULT_HEX_SIDE: Int = 8
|
||||||
|
|
||||||
const val MIN_MINE_COUNT: Int = 1
|
const val MIN_MINE_COUNT: Int = 1
|
||||||
|
|
||||||
const val FONT_SIZE: Int = 20
|
const val FONT_SIZE: Int = 20
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
/*
|
||||||
|
* 💣 Mines 💣
|
||||||
|
* Copyright (C) 2025 Stefan Oltmann
|
||||||
|
* https://github.com/StefanOltmann/mines
|
||||||
|
*/
|
||||||
|
|
||||||
|
package de.stefan_oltmann.mines.model
|
||||||
|
|
||||||
|
enum class BoardLayout(
|
||||||
|
private val settingsValue: String
|
||||||
|
) {
|
||||||
|
|
||||||
|
ROWS("rows"),
|
||||||
|
HEXAGON("hexagon");
|
||||||
|
|
||||||
|
fun toSettingsValue(): String = settingsValue
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
fun fromSettingsValue(value: String?): BoardLayout =
|
||||||
|
when (value?.trim()?.lowercase()) {
|
||||||
|
HEXAGON.settingsValue -> HEXAGON
|
||||||
|
else -> ROWS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,38 +19,142 @@
|
|||||||
|
|
||||||
package de.stefan_oltmann.mines.model
|
package de.stefan_oltmann.mines.model
|
||||||
|
|
||||||
|
import de.stefan_oltmann.mines.DEFAULT_HEX_SIDE
|
||||||
|
import de.stefan_oltmann.mines.MIN_HEX_SIDE
|
||||||
import de.stefan_oltmann.mines.MIN_LONG_SIDE
|
import de.stefan_oltmann.mines.MIN_LONG_SIDE
|
||||||
import de.stefan_oltmann.mines.MIN_MINE_COUNT
|
import de.stefan_oltmann.mines.MIN_MINE_COUNT
|
||||||
|
import kotlin.math.abs
|
||||||
|
|
||||||
data class GameConfig(
|
data class GameConfig(
|
||||||
val cellSize: Int,
|
val cellSize: Int,
|
||||||
val mapWidth: Int,
|
val mapWidth: Int,
|
||||||
val mapHeight: Int,
|
val mapHeight: Int,
|
||||||
val mineCount: Int
|
val mineCount: Int,
|
||||||
|
val boardLayout: BoardLayout = BoardLayout.ROWS,
|
||||||
|
val hexSideLength: Int = DEFAULT_HEX_SIDE
|
||||||
) {
|
) {
|
||||||
|
|
||||||
init {
|
init {
|
||||||
/* Ensure no illegal configs can be created. */
|
/* Ensure no illegal configs can be created. */
|
||||||
require(mapWidth >= MIN_LONG_SIDE) { "Map width must be greater than $MIN_LONG_SIDE." }
|
require(mapWidth >= MIN_LONG_SIDE) { "Map width must be greater than $MIN_LONG_SIDE." }
|
||||||
require(mapHeight >= MIN_LONG_SIDE) { "Map height must be greater than $MIN_LONG_SIDE." }
|
require(mapHeight >= MIN_LONG_SIDE) { "Map height must be greater than $MIN_LONG_SIDE." }
|
||||||
|
require(hexSideLength >= MIN_HEX_SIDE) { "Hex side must be at least $MIN_HEX_SIDE." }
|
||||||
require(mineCount >= MIN_MINE_COUNT) { "Mine count must be at least $MIN_MINE_COUNT." }
|
require(mineCount >= MIN_MINE_COUNT) { "Mine count must be at least $MIN_MINE_COUNT." }
|
||||||
require(mineCount <= maxPlaceableMines(mapWidth, mapHeight)) {
|
require(
|
||||||
|
mineCount <= maxPlaceableMines(
|
||||||
|
mapWidth = mapWidth,
|
||||||
|
mapHeight = mapHeight,
|
||||||
|
boardLayout = boardLayout,
|
||||||
|
hexSideLength = hexSideLength
|
||||||
|
)
|
||||||
|
) {
|
||||||
"Mine count must fit outside the protected starting area."
|
"Mine count must fit outside the protected starting area."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val boardWidth: Int
|
||||||
|
get() = when (boardLayout) {
|
||||||
|
BoardLayout.ROWS -> mapWidth
|
||||||
|
BoardLayout.HEXAGON -> hexSideLength * 2 - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
val boardHeight: Int
|
||||||
|
get() = when (boardLayout) {
|
||||||
|
BoardLayout.ROWS -> mapHeight
|
||||||
|
BoardLayout.HEXAGON -> hexSideLength * 2 - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isCellActive(x: Int, y: Int): Boolean =
|
||||||
|
when (boardLayout) {
|
||||||
|
BoardLayout.ROWS ->
|
||||||
|
x in 0 until mapWidth && y in 0 until mapHeight
|
||||||
|
|
||||||
|
BoardLayout.HEXAGON ->
|
||||||
|
hexDistanceFromCenter(x, y, hexSideLength) < hexSideLength
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isCellProtected(x: Int, y: Int): Boolean =
|
||||||
|
when (boardLayout) {
|
||||||
|
BoardLayout.ROWS ->
|
||||||
|
x in calcProtectedRange(mapWidth) &&
|
||||||
|
y in calcProtectedRange(mapHeight)
|
||||||
|
|
||||||
|
BoardLayout.HEXAGON -> {
|
||||||
|
val protectedSide = (hexSideLength * 0.3f).toInt().coerceAtLeast(2)
|
||||||
|
hexDistanceFromCenter(x, y, hexSideLength) < protectedSide
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Maximum mines that can be placed without filling the protected center zone.
|
* Maximum mines that can be placed without filling the protected center zone.
|
||||||
*/
|
*/
|
||||||
fun maxPlaceableMines(mapWidth: Int, mapHeight: Int): Int {
|
fun maxPlaceableMines(
|
||||||
|
mapWidth: Int,
|
||||||
|
mapHeight: Int,
|
||||||
|
boardLayout: BoardLayout = BoardLayout.ROWS,
|
||||||
|
hexSideLength: Int = DEFAULT_HEX_SIDE
|
||||||
|
): Int {
|
||||||
|
|
||||||
val protectedCells =
|
val activeCells = when (boardLayout) {
|
||||||
Minefield.calcProtectedRange(mapWidth).count() *
|
BoardLayout.ROWS -> mapWidth * mapHeight
|
||||||
Minefield.calcProtectedRange(mapHeight).count()
|
BoardLayout.HEXAGON -> 3 * hexSideLength * (hexSideLength - 1) + 1
|
||||||
|
|
||||||
return (mapWidth * mapHeight - protectedCells).coerceAtLeast(MIN_MINE_COUNT)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clampMineCount(mineCount: Int, mapWidth: Int, mapHeight: Int): Int =
|
val protectedCells = when (boardLayout) {
|
||||||
mineCount.coerceIn(MIN_MINE_COUNT, maxPlaceableMines(mapWidth, mapHeight))
|
BoardLayout.ROWS ->
|
||||||
|
calcProtectedRange(mapWidth).count() *
|
||||||
|
calcProtectedRange(mapHeight).count()
|
||||||
|
|
||||||
|
BoardLayout.HEXAGON -> {
|
||||||
|
val protectedSide = (hexSideLength * 0.3f).toInt().coerceAtLeast(2)
|
||||||
|
3 * protectedSide * (protectedSide - 1) + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (activeCells - protectedCells).coerceAtLeast(MIN_MINE_COUNT)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clampMineCount(
|
||||||
|
mineCount: Int,
|
||||||
|
mapWidth: Int,
|
||||||
|
mapHeight: Int,
|
||||||
|
boardLayout: BoardLayout = BoardLayout.ROWS,
|
||||||
|
hexSideLength: Int = DEFAULT_HEX_SIDE
|
||||||
|
): Int =
|
||||||
|
mineCount.coerceIn(
|
||||||
|
MIN_MINE_COUNT,
|
||||||
|
maxPlaceableMines(mapWidth, mapHeight, boardLayout, hexSideLength)
|
||||||
|
)
|
||||||
|
|
||||||
|
/* Calculates a centered protected range that scales with board size. */
|
||||||
|
fun calcProtectedRange(length: Int): IntRange {
|
||||||
|
|
||||||
|
val targetSize = (length * 0.3).toInt().coerceAtLeast(2)
|
||||||
|
val protectedSize =
|
||||||
|
if (targetSize % 2 == length % 2)
|
||||||
|
targetSize
|
||||||
|
else
|
||||||
|
targetSize + 1
|
||||||
|
val start = (length - protectedSize) / 2
|
||||||
|
|
||||||
|
return start until (start + protectedSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cube distance from a cell to the center of an odd-q offset hex board.
|
||||||
|
*/
|
||||||
|
private fun hexDistanceFromCenter(x: Int, y: Int, sideLength: Int): Int {
|
||||||
|
|
||||||
|
val centerX = sideLength - 1
|
||||||
|
val centerY = sideLength - 1
|
||||||
|
|
||||||
|
val axialR = y - (x - (x and 1)) / 2
|
||||||
|
val centerAxialR = centerY - (centerX - (centerX and 1)) / 2
|
||||||
|
|
||||||
|
val dq = x - centerX
|
||||||
|
val dr = axialR - centerAxialR
|
||||||
|
val ds = -dq - dr
|
||||||
|
|
||||||
|
return maxOf(abs(dq), abs(dr), abs(ds))
|
||||||
|
}
|
||||||
|
|||||||
@@ -53,7 +53,11 @@ class GameState(
|
|||||||
|
|
||||||
for (x in 0 until minefield.width)
|
for (x in 0 until minefield.width)
|
||||||
for (y in 0 until minefield.height)
|
for (y in 0 until minefield.height)
|
||||||
if (!minefield.isMine(x, y) && !isRevealed(x, y))
|
if (
|
||||||
|
minefield.isActive(x, y) &&
|
||||||
|
!minefield.isMine(x, y) &&
|
||||||
|
!isRevealed(x, y)
|
||||||
|
)
|
||||||
return false
|
return false
|
||||||
|
|
||||||
return true
|
return true
|
||||||
@@ -61,6 +65,9 @@ class GameState(
|
|||||||
|
|
||||||
fun reveal(x: Int, y: Int) {
|
fun reveal(x: Int, y: Int) {
|
||||||
|
|
||||||
|
if (!minefield.isActive(x, y))
|
||||||
|
return
|
||||||
|
|
||||||
/* Ignore call if coordinates are already revealed. */
|
/* Ignore call if coordinates are already revealed. */
|
||||||
if (revealedMatrix[x][y])
|
if (revealedMatrix[x][y])
|
||||||
return
|
return
|
||||||
@@ -131,6 +138,7 @@ class GameState(
|
|||||||
flaggedMatrix[x][y]
|
flaggedMatrix[x][y]
|
||||||
|
|
||||||
fun toggleFlag(x: Int, y: Int) {
|
fun toggleFlag(x: Int, y: Int) {
|
||||||
|
if (minefield.isActive(x, y))
|
||||||
flaggedMatrix[x][y] = !flaggedMatrix[x][y]
|
flaggedMatrix[x][y] = !flaggedMatrix[x][y]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,8 +172,10 @@ class GameState(
|
|||||||
x = x,
|
x = x,
|
||||||
y = y,
|
y = y,
|
||||||
width = minefield.width,
|
width = minefield.width,
|
||||||
height = minefield.height,
|
height = minefield.height
|
||||||
action = action
|
) { adjX, adjY ->
|
||||||
)
|
if (minefield.isActive(adjX, adjY))
|
||||||
|
action(adjX, adjY)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,16 +28,19 @@ class Minefield(
|
|||||||
) {
|
) {
|
||||||
|
|
||||||
val width
|
val width
|
||||||
get() = config.mapWidth
|
get() = config.boardWidth
|
||||||
|
|
||||||
val height
|
val height
|
||||||
get() = config.mapHeight
|
get() = config.boardHeight
|
||||||
|
|
||||||
fun getCellType(x: Int, y: Int): CellType =
|
fun getCellType(x: Int, y: Int): CellType =
|
||||||
matrix[x][y]
|
matrix[x][y]
|
||||||
|
|
||||||
|
fun isActive(x: Int, y: Int): Boolean =
|
||||||
|
config.isCellActive(x, y)
|
||||||
|
|
||||||
fun isMine(x: Int, y: Int): Boolean =
|
fun isMine(x: Int, y: Int): Boolean =
|
||||||
matrix[x][y] == CellType.MINE
|
isActive(x, y) && matrix[x][y] == CellType.MINE
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
@@ -49,25 +52,21 @@ class Minefield(
|
|||||||
config = config,
|
config = config,
|
||||||
seed = seed,
|
seed = seed,
|
||||||
matrix = createMatrix(
|
matrix = createMatrix(
|
||||||
width = config.mapWidth,
|
config = config,
|
||||||
height = config.mapHeight,
|
|
||||||
mineCount = config.mineCount,
|
|
||||||
seed = seed
|
seed = seed
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun createMatrix(
|
private fun createMatrix(
|
||||||
width: Int,
|
config: GameConfig,
|
||||||
height: Int,
|
|
||||||
mineCount: Int,
|
|
||||||
seed: Int
|
seed: Int
|
||||||
): Array<Array<CellType>> {
|
): Array<Array<CellType>> {
|
||||||
|
|
||||||
val matrix = createEmptyMatrix(width, height)
|
val matrix = createEmptyMatrix(config.boardWidth, config.boardHeight)
|
||||||
|
|
||||||
placeMines(matrix, width, height, mineCount, seed)
|
placeMines(matrix, config, seed)
|
||||||
|
|
||||||
placeCounts(matrix, width, height)
|
placeCounts(matrix, config)
|
||||||
|
|
||||||
return matrix
|
return matrix
|
||||||
}
|
}
|
||||||
@@ -79,77 +78,39 @@ class Minefield(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Calculates a centered protected range that scales with board size */
|
|
||||||
fun calcProtectedRange(length: Int): IntRange {
|
|
||||||
|
|
||||||
val targetSize = (length * 0.3).toInt().coerceAtLeast(2)
|
|
||||||
|
|
||||||
val protectedSize =
|
|
||||||
if (targetSize % 2 == length % 2)
|
|
||||||
targetSize
|
|
||||||
else
|
|
||||||
targetSize + 1
|
|
||||||
|
|
||||||
val start = (length - protectedSize) / 2
|
|
||||||
|
|
||||||
return start until (start + protectedSize)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun placeMines(
|
private fun placeMines(
|
||||||
matrix: Array<Array<CellType>>,
|
matrix: Array<Array<CellType>>,
|
||||||
width: Int,
|
config: GameConfig,
|
||||||
height: Int,
|
|
||||||
mineCount: Int,
|
|
||||||
seed: Int
|
seed: Int
|
||||||
) {
|
) {
|
||||||
|
|
||||||
/*
|
val candidates = buildList {
|
||||||
* Mines are placed according to seed to reproduce results.
|
for (x in 0 until config.boardWidth) {
|
||||||
*/
|
for (y in 0 until config.boardHeight) {
|
||||||
val random = Random(seed)
|
if (config.isCellActive(x, y) && !config.isCellProtected(x, y))
|
||||||
|
add(x to y)
|
||||||
val protectedXRange = calcProtectedRange(width)
|
|
||||||
val protectedYRange = calcProtectedRange(height)
|
|
||||||
|
|
||||||
var placedMinesCount = 0
|
|
||||||
|
|
||||||
while (placedMinesCount < mineCount) {
|
|
||||||
|
|
||||||
val x = random.nextInt(width)
|
|
||||||
val y = random.nextInt(height)
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Keep the middle free of mines to give players a starting point.
|
|
||||||
*/
|
|
||||||
if (x in protectedXRange && y in protectedYRange)
|
|
||||||
continue
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Only place mines into empty cells.
|
|
||||||
*
|
|
||||||
* This guarantees that we have enough mines,
|
|
||||||
* even if the randomizer selects the same cell twice.
|
|
||||||
*/
|
|
||||||
if (matrix[x][y] == CellType.EMPTY) {
|
|
||||||
|
|
||||||
matrix[x][y] = CellType.MINE
|
|
||||||
|
|
||||||
placedMinesCount++
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
candidates
|
||||||
|
.shuffled(Random(seed))
|
||||||
|
.take(config.mineCount)
|
||||||
|
.forEach { (x, y) ->
|
||||||
|
matrix[x][y] = CellType.MINE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun placeCounts(
|
private fun placeCounts(
|
||||||
matrix: Array<Array<CellType>>,
|
matrix: Array<Array<CellType>>,
|
||||||
width: Int,
|
config: GameConfig
|
||||||
height: Int
|
|
||||||
) {
|
) {
|
||||||
|
|
||||||
for (x in 0 until width) {
|
for (x in 0 until config.boardWidth) {
|
||||||
for (y in 0 until height) {
|
for (y in 0 until config.boardHeight) {
|
||||||
|
|
||||||
/* Minefields stay as they are. */
|
/* Inactive cells and minefields stay as they are. */
|
||||||
if (matrix[x][y] == CellType.MINE)
|
if (!config.isCellActive(x, y) || matrix[x][y] == CellType.MINE)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
val mineCount = countMinesInAdjacentCells(matrix, x, y)
|
val mineCount = countMinesInAdjacentCells(matrix, x, y)
|
||||||
|
|||||||
@@ -111,6 +111,9 @@ fun MinefieldCanvas(
|
|||||||
if (row !in 0 until gameState.minefield.height)
|
if (row !in 0 until gameState.minefield.height)
|
||||||
return null
|
return null
|
||||||
|
|
||||||
|
if (!gameState.minefield.isActive(col, row))
|
||||||
|
return null
|
||||||
|
|
||||||
return IntOffset(col, row)
|
return IntOffset(col, row)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,6 +171,9 @@ fun MinefieldCanvas(
|
|||||||
for (x in 0 until gameState.minefield.width) {
|
for (x in 0 until gameState.minefield.width) {
|
||||||
for (y in 0 until gameState.minefield.height) {
|
for (y in 0 until gameState.minefield.height) {
|
||||||
|
|
||||||
|
if (!gameState.minefield.isActive(x, y))
|
||||||
|
continue
|
||||||
|
|
||||||
val (cxRel, cyRel) = HexGeometry.cellCenter(x, y, hexSize)
|
val (cxRel, cyRel) = HexGeometry.cellCenter(x, y, hexSize)
|
||||||
val center = Offset(originX + cxRel, originY + cyRel)
|
val center = Offset(originX + cxRel, originY + cyRel)
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ package de.stefan_oltmann.mines.ui
|
|||||||
|
|
||||||
import androidx.compose.foundation.BorderStroke
|
import androidx.compose.foundation.BorderStroke
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -28,6 +29,7 @@ import androidx.compose.foundation.layout.Row
|
|||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.widthIn
|
import androidx.compose.foundation.layout.widthIn
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
@@ -47,10 +49,13 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import de.stefan_oltmann.mines.FONT_SIZE
|
import de.stefan_oltmann.mines.FONT_SIZE
|
||||||
import de.stefan_oltmann.mines.MAX_CELL_SIZE
|
import de.stefan_oltmann.mines.MAX_CELL_SIZE
|
||||||
|
import de.stefan_oltmann.mines.MAX_HEX_SIDE
|
||||||
import de.stefan_oltmann.mines.MAX_LONG_SIDE
|
import de.stefan_oltmann.mines.MAX_LONG_SIDE
|
||||||
import de.stefan_oltmann.mines.MIN_CELL_SIZE
|
import de.stefan_oltmann.mines.MIN_CELL_SIZE
|
||||||
|
import de.stefan_oltmann.mines.MIN_HEX_SIDE
|
||||||
import de.stefan_oltmann.mines.MIN_LONG_SIDE
|
import de.stefan_oltmann.mines.MIN_LONG_SIDE
|
||||||
import de.stefan_oltmann.mines.MIN_MINE_COUNT
|
import de.stefan_oltmann.mines.MIN_MINE_COUNT
|
||||||
|
import de.stefan_oltmann.mines.model.BoardLayout
|
||||||
import de.stefan_oltmann.mines.model.GameConfig
|
import de.stefan_oltmann.mines.model.GameConfig
|
||||||
import de.stefan_oltmann.mines.model.clampMineCount
|
import de.stefan_oltmann.mines.model.clampMineCount
|
||||||
import de.stefan_oltmann.mines.model.maxPlaceableMines
|
import de.stefan_oltmann.mines.model.maxPlaceableMines
|
||||||
@@ -83,16 +88,28 @@ fun SettingsDialog(
|
|||||||
val mapWidth = remember { mutableStateOf(gameConfig.mapWidth.toFloat()) }
|
val mapWidth = remember { mutableStateOf(gameConfig.mapWidth.toFloat()) }
|
||||||
val mapHeight = remember { mutableStateOf(gameConfig.mapHeight.toFloat()) }
|
val mapHeight = remember { mutableStateOf(gameConfig.mapHeight.toFloat()) }
|
||||||
val mineCount = remember { mutableStateOf(gameConfig.mineCount.toFloat()) }
|
val mineCount = remember { mutableStateOf(gameConfig.mineCount.toFloat()) }
|
||||||
|
val boardLayout = remember { mutableStateOf(gameConfig.boardLayout) }
|
||||||
|
val hexSideLength = remember { mutableStateOf(gameConfig.hexSideLength.toFloat()) }
|
||||||
|
|
||||||
fun clampMinesToBoard() {
|
fun clampMinesToBoard() {
|
||||||
|
|
||||||
mineCount.value = clampMineCount(
|
mineCount.value = clampMineCount(
|
||||||
mineCount = mineCount.value.toInt(),
|
mineCount = mineCount.value.toInt(),
|
||||||
mapWidth = mapWidth.value.toInt(),
|
mapWidth = mapWidth.value.toInt(),
|
||||||
mapHeight = mapHeight.value.toInt()
|
mapHeight = mapHeight.value.toInt(),
|
||||||
|
boardLayout = boardLayout.value,
|
||||||
|
hexSideLength = hexSideLength.value.toInt()
|
||||||
).toFloat()
|
).toFloat()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun currentMaxMines(): Int =
|
||||||
|
maxPlaceableMines(
|
||||||
|
mapWidth = mapWidth.value.toInt(),
|
||||||
|
mapHeight = mapHeight.value.toInt(),
|
||||||
|
boardLayout = boardLayout.value,
|
||||||
|
hexSideLength = hexSideLength.value.toInt()
|
||||||
|
)
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -129,7 +146,8 @@ fun SettingsDialog(
|
|||||||
Icon(
|
Icon(
|
||||||
imageVector = IconZoom,
|
imageVector = IconZoom,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = colorForeground
|
tint = colorForeground,
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
)
|
)
|
||||||
|
|
||||||
Slider(
|
Slider(
|
||||||
@@ -152,6 +170,47 @@ fun SettingsDialog(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(defaultSpacing)
|
||||||
|
) {
|
||||||
|
|
||||||
|
for (layout in BoardLayout.entries) {
|
||||||
|
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = Modifier
|
||||||
|
.height(buttonSize)
|
||||||
|
.weight(0.5f)
|
||||||
|
.background(colorCellHidden, defaultRoundedCornerShape)
|
||||||
|
.border(
|
||||||
|
width = 1.dp,
|
||||||
|
color = if (boardLayout.value == layout)
|
||||||
|
colorForeground
|
||||||
|
else
|
||||||
|
Color.Transparent,
|
||||||
|
shape = defaultRoundedCornerShape
|
||||||
|
)
|
||||||
|
.noRippleClickable {
|
||||||
|
boardLayout.value = layout
|
||||||
|
clampMinesToBoard()
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = when (layout) {
|
||||||
|
BoardLayout.ROWS -> "Rows"
|
||||||
|
BoardLayout.HEXAGON -> "Hexagon"
|
||||||
|
},
|
||||||
|
fontFamily = fontFamily,
|
||||||
|
color = colorForeground,
|
||||||
|
fontSize = FONT_SIZE.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (boardLayout.value == BoardLayout.ROWS) {
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(defaultSpacing),
|
horizontalArrangement = Arrangement.spacedBy(defaultSpacing),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
@@ -161,7 +220,8 @@ fun SettingsDialog(
|
|||||||
Icon(
|
Icon(
|
||||||
imageVector = IconWidth,
|
imageVector = IconWidth,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = colorForeground
|
tint = colorForeground,
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
)
|
)
|
||||||
|
|
||||||
Slider(
|
Slider(
|
||||||
@@ -194,7 +254,8 @@ fun SettingsDialog(
|
|||||||
Icon(
|
Icon(
|
||||||
imageVector = IconHeight,
|
imageVector = IconHeight,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = colorForeground
|
tint = colorForeground,
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
)
|
)
|
||||||
|
|
||||||
Slider(
|
Slider(
|
||||||
@@ -218,6 +279,43 @@ fun SettingsDialog(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(defaultSpacing),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.padding(horizontal = defaultSpacing)
|
||||||
|
) {
|
||||||
|
|
||||||
|
Icon(
|
||||||
|
imageVector = IconWidth,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = colorForeground,
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Slider(
|
||||||
|
value = hexSideLength.value,
|
||||||
|
onValueChange = {
|
||||||
|
hexSideLength.value = it
|
||||||
|
clampMinesToBoard()
|
||||||
|
},
|
||||||
|
valueRange = MIN_HEX_SIDE.toFloat()..MAX_HEX_SIDE.toFloat(),
|
||||||
|
colors = sliderColors,
|
||||||
|
modifier = Modifier.weight(1F)
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = hexSideLength.value.toInt().toString(),
|
||||||
|
fontFamily = fontFamily,
|
||||||
|
color = colorForeground,
|
||||||
|
fontSize = FONT_SIZE.sp,
|
||||||
|
textAlign = TextAlign.Right,
|
||||||
|
modifier = Modifier.widthIn(min = 20.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(defaultSpacing),
|
horizontalArrangement = Arrangement.spacedBy(defaultSpacing),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
@@ -227,24 +325,19 @@ fun SettingsDialog(
|
|||||||
Icon(
|
Icon(
|
||||||
imageVector = IconMines,
|
imageVector = IconMines,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = colorForeground
|
tint = colorForeground,
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
)
|
)
|
||||||
|
|
||||||
Slider(
|
Slider(
|
||||||
value = mineCount.value.coerceIn(
|
value = mineCount.value.coerceIn(
|
||||||
MIN_MINE_COUNT.toFloat(),
|
MIN_MINE_COUNT.toFloat(),
|
||||||
maxPlaceableMines(
|
currentMaxMines().toFloat()
|
||||||
mapWidth.value.toInt(),
|
|
||||||
mapHeight.value.toInt()
|
|
||||||
).toFloat()
|
|
||||||
),
|
),
|
||||||
onValueChange = {
|
onValueChange = {
|
||||||
mineCount.value = it
|
mineCount.value = it
|
||||||
},
|
},
|
||||||
valueRange = MIN_MINE_COUNT.toFloat()..maxPlaceableMines(
|
valueRange = MIN_MINE_COUNT.toFloat()..currentMaxMines().toFloat(),
|
||||||
mapWidth.value.toInt(),
|
|
||||||
mapHeight.value.toInt()
|
|
||||||
).toFloat(),
|
|
||||||
colors = sliderColors,
|
colors = sliderColors,
|
||||||
modifier = Modifier.weight(1F)
|
modifier = Modifier.weight(1F)
|
||||||
)
|
)
|
||||||
@@ -305,8 +398,12 @@ fun SettingsDialog(
|
|||||||
mineCount = clampMineCount(
|
mineCount = clampMineCount(
|
||||||
mineCount = mineCount.value.toInt(),
|
mineCount = mineCount.value.toInt(),
|
||||||
mapWidth = width,
|
mapWidth = width,
|
||||||
mapHeight = height
|
mapHeight = height,
|
||||||
)
|
boardLayout = boardLayout.value,
|
||||||
|
hexSideLength = hexSideLength.value.toInt()
|
||||||
|
),
|
||||||
|
boardLayout = boardLayout.value,
|
||||||
|
hexSideLength = hexSideLength.value.toInt()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ val IconMines: ImageVector
|
|||||||
}
|
}
|
||||||
_IconMines = ImageVector.Builder(
|
_IconMines = ImageVector.Builder(
|
||||||
name = "IconMines",
|
name = "IconMines",
|
||||||
defaultWidth = 512.dp,
|
defaultWidth = 24.dp,
|
||||||
defaultHeight = 512.dp,
|
defaultHeight = 24.dp,
|
||||||
viewportWidth = 512f,
|
viewportWidth = 512f,
|
||||||
viewportHeight = 512f
|
viewportHeight = 512f
|
||||||
).apply {
|
).apply {
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package de.stefan_oltmann.mines.model
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class BoardLayoutTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun hexagonSideThreeHasNineteenActiveCells() {
|
||||||
|
|
||||||
|
val config = GameConfig(
|
||||||
|
cellSize = 40,
|
||||||
|
mapWidth = 10,
|
||||||
|
mapHeight = 10,
|
||||||
|
mineCount = 5,
|
||||||
|
boardLayout = BoardLayout.HEXAGON,
|
||||||
|
hexSideLength = 3
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(5, config.boardWidth)
|
||||||
|
assertEquals(5, config.boardHeight)
|
||||||
|
|
||||||
|
val activeCells = buildList {
|
||||||
|
for (x in 0 until config.boardWidth)
|
||||||
|
for (y in 0 until config.boardHeight)
|
||||||
|
if (config.isCellActive(x, y))
|
||||||
|
add(x to y)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(19, activeCells.size)
|
||||||
|
assertTrue(config.isCellActive(2, 2))
|
||||||
|
assertFalse(config.isCellActive(0, 0))
|
||||||
|
assertFalse(config.isCellActive(4, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun minesOnlyAppearOnActiveHexagonCells() {
|
||||||
|
|
||||||
|
val config = GameConfig(
|
||||||
|
cellSize = 40,
|
||||||
|
mapWidth = 10,
|
||||||
|
mapHeight = 10,
|
||||||
|
mineCount = 10,
|
||||||
|
boardLayout = BoardLayout.HEXAGON,
|
||||||
|
hexSideLength = 5
|
||||||
|
)
|
||||||
|
val minefield = Minefield.create(config, seed = 1234)
|
||||||
|
|
||||||
|
var mines = 0
|
||||||
|
|
||||||
|
for (x in 0 until minefield.width) {
|
||||||
|
for (y in 0 until minefield.height) {
|
||||||
|
if (minefield.isMine(x, y)) {
|
||||||
|
mines++
|
||||||
|
assertTrue(minefield.isActive(x, y))
|
||||||
|
assertFalse(config.isCellProtected(x, y))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(config.mineCount, mines)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Create Dawnsorrow/hex-mines on Gitea (if needed), push main, create v0.1.0
|
# Create Dawnsorrow/hex-mines on Gitea (if needed), push main, create a
|
||||||
# release, and upload the debug APK.
|
# release, and upload the debug APK.
|
||||||
#
|
#
|
||||||
# Requires: GITEA_TOKEN (repo write), curl, python3, git
|
# Requires: GITEA_TOKEN (repo write), curl, python3, git
|
||||||
|
# Optional: HEX_MINES_VERSION (default 0.2.0)
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")" && pwd)"
|
ROOT="$(cd "$(dirname "$0")" && pwd)"
|
||||||
@@ -10,7 +11,7 @@ OWNER="${GITEA_OWNER:-Dawnsorrow}"
|
|||||||
REPO="${GITEA_REPO:-hex-mines}"
|
REPO="${GITEA_REPO:-hex-mines}"
|
||||||
BASE="${GITEA_BASE_URL:-https://git.hisora.dev}"
|
BASE="${GITEA_BASE_URL:-https://git.hisora.dev}"
|
||||||
BASE="${BASE%/}"
|
BASE="${BASE%/}"
|
||||||
VERSION="${HEX_MINES_VERSION:-0.1.0}"
|
VERSION="${HEX_MINES_VERSION:-0.2.0}"
|
||||||
TAG="v${VERSION}"
|
TAG="v${VERSION}"
|
||||||
TITLE="Hex Mines ${TAG}"
|
TITLE="Hex Mines ${TAG}"
|
||||||
APK="${1:-${ROOT}/dist/HexMines-${VERSION}-debug.apk}"
|
APK="${1:-${ROOT}/dist/HexMines-${VERSION}-debug.apk}"
|
||||||
@@ -74,7 +75,8 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Pushing main and tag ${TAG} ..."
|
echo "Pushing main and tag ${TAG} ..."
|
||||||
git push -u "$PUSH_URL_AUTH" HEAD:main
|
git push "$PUSH_URL_AUTH" HEAD:main
|
||||||
|
git branch --set-upstream-to=origin/main main 2>/dev/null || true
|
||||||
|
|
||||||
# Create / move tag locally
|
# Create / move tag locally
|
||||||
git tag -f "$TAG"
|
git tag -f "$TAG"
|
||||||
@@ -83,13 +85,13 @@ git push -f "$PUSH_URL_AUTH" "refs/tags/${TAG}"
|
|||||||
NOTE=$(cat <<EOF
|
NOTE=$(cat <<EOF
|
||||||
## Hex Mines ${TAG}
|
## Hex Mines ${TAG}
|
||||||
|
|
||||||
First public build of the hexagonal minesweeper fork.
|
Hexagonal minesweeper fork of [StefanOltmann/mines](https://github.com/StefanOltmann/mines) (AGPL-3.0).
|
||||||
|
|
||||||
### Highlights
|
### Highlights
|
||||||
- Flat-top hex tiles (6 neighbors)
|
- Flat-top hex tiles (6 neighbors)
|
||||||
- Configurable board width / height
|
- Layout modes: **Rows** (width × height) and **Hexagon** (side length)
|
||||||
- Configurable mine count slider
|
- Configurable mine count slider
|
||||||
- Based on [StefanOltmann/mines](https://github.com/StefanOltmann/mines) (AGPL-3.0)
|
- Settings icon sizing fix for usable sliders
|
||||||
|
|
||||||
### Install
|
### Install
|
||||||
Download \`HexMines-${VERSION}-debug.apk\` and install on Android (debug-signed).
|
Download \`HexMines-${VERSION}-debug.apk\` and install on Android (debug-signed).
|
||||||
|
|||||||
Reference in New Issue
Block a user