Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b6cc0bedc | ||
|
|
a9846fd832 |
@@ -43,6 +43,7 @@ import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.russhwolf.settings.get
|
||||
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.GameConfig
|
||||
import de.stefan_oltmann.mines.model.GameDifficulty
|
||||
@@ -88,8 +89,12 @@ fun App() {
|
||||
mapHeight = settings["mines_map_height"] ?: defaultMapHeight,
|
||||
mineCount = resolveSavedMineCount(
|
||||
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 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 */
|
||||
settings["mines_cell_size"] = newGameConfig.cellSize
|
||||
settings["mines_map_width"] = newGameConfig.mapWidth
|
||||
settings["mines_map_height"] = newGameConfig.mapHeight
|
||||
settings["mines_mine_count"] = newGameConfig.mineCount
|
||||
settings["mines_board_layout"] = newGameConfig.boardLayout.toSettingsValue()
|
||||
settings["mines_hex_side"] = newGameConfig.hexSideLength
|
||||
|
||||
val mapSettingsChanged =
|
||||
oldMapWidth != newGameConfig.mapWidth ||
|
||||
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 */
|
||||
if (mapSettingsChanged) {
|
||||
@@ -346,18 +362,34 @@ fun App() {
|
||||
* Prefer an explicit saved mine count. Fall back to the old difficulty
|
||||
* 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"]
|
||||
|
||||
if (savedMineCount != null)
|
||||
return clampMineCount(savedMineCount, mapWidth, mapHeight)
|
||||
return clampMineCount(
|
||||
savedMineCount,
|
||||
mapWidth,
|
||||
mapHeight,
|
||||
boardLayout,
|
||||
hexSideLength
|
||||
)
|
||||
|
||||
val difficulty = GameDifficulty.fromSettingsValue(settings["mines_difficulty"])
|
||||
|
||||
return clampMineCount(
|
||||
mineCount = difficulty.calcMineCount(mapWidth, mapHeight),
|
||||
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 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 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
|
||||
|
||||
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_MINE_COUNT
|
||||
import kotlin.math.abs
|
||||
|
||||
data class GameConfig(
|
||||
val cellSize: Int,
|
||||
val mapWidth: Int,
|
||||
val mapHeight: Int,
|
||||
val mineCount: Int
|
||||
val mineCount: Int,
|
||||
val boardLayout: BoardLayout = BoardLayout.ROWS,
|
||||
val hexSideLength: Int = DEFAULT_HEX_SIDE
|
||||
) {
|
||||
|
||||
init {
|
||||
/* Ensure no illegal configs can be created. */
|
||||
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(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 <= maxPlaceableMines(mapWidth, mapHeight)) {
|
||||
require(
|
||||
mineCount <= maxPlaceableMines(
|
||||
mapWidth = mapWidth,
|
||||
mapHeight = mapHeight,
|
||||
boardLayout = boardLayout,
|
||||
hexSideLength = hexSideLength
|
||||
)
|
||||
) {
|
||||
"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.
|
||||
*/
|
||||
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 =
|
||||
Minefield.calcProtectedRange(mapWidth).count() *
|
||||
Minefield.calcProtectedRange(mapHeight).count()
|
||||
val activeCells = when (boardLayout) {
|
||||
BoardLayout.ROWS -> mapWidth * mapHeight
|
||||
BoardLayout.HEXAGON -> 3 * hexSideLength * (hexSideLength - 1) + 1
|
||||
}
|
||||
|
||||
return (mapWidth * mapHeight - protectedCells).coerceAtLeast(MIN_MINE_COUNT)
|
||||
val protectedCells = when (boardLayout) {
|
||||
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): Int =
|
||||
mineCount.coerceIn(MIN_MINE_COUNT, maxPlaceableMines(mapWidth, mapHeight))
|
||||
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 (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 true
|
||||
@@ -61,6 +65,9 @@ class GameState(
|
||||
|
||||
fun reveal(x: Int, y: Int) {
|
||||
|
||||
if (!minefield.isActive(x, y))
|
||||
return
|
||||
|
||||
/* Ignore call if coordinates are already revealed. */
|
||||
if (revealedMatrix[x][y])
|
||||
return
|
||||
@@ -131,6 +138,7 @@ class GameState(
|
||||
flaggedMatrix[x][y]
|
||||
|
||||
fun toggleFlag(x: Int, y: Int) {
|
||||
if (minefield.isActive(x, y))
|
||||
flaggedMatrix[x][y] = !flaggedMatrix[x][y]
|
||||
}
|
||||
|
||||
@@ -164,8 +172,10 @@ class GameState(
|
||||
x = x,
|
||||
y = y,
|
||||
width = minefield.width,
|
||||
height = minefield.height,
|
||||
action = action
|
||||
)
|
||||
height = minefield.height
|
||||
) { adjX, adjY ->
|
||||
if (minefield.isActive(adjX, adjY))
|
||||
action(adjX, adjY)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,16 +28,19 @@ class Minefield(
|
||||
) {
|
||||
|
||||
val width
|
||||
get() = config.mapWidth
|
||||
get() = config.boardWidth
|
||||
|
||||
val height
|
||||
get() = config.mapHeight
|
||||
get() = config.boardHeight
|
||||
|
||||
fun getCellType(x: Int, y: Int): CellType =
|
||||
matrix[x][y]
|
||||
|
||||
fun isActive(x: Int, y: Int): Boolean =
|
||||
config.isCellActive(x, y)
|
||||
|
||||
fun isMine(x: Int, y: Int): Boolean =
|
||||
matrix[x][y] == CellType.MINE
|
||||
isActive(x, y) && matrix[x][y] == CellType.MINE
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -49,25 +52,21 @@ class Minefield(
|
||||
config = config,
|
||||
seed = seed,
|
||||
matrix = createMatrix(
|
||||
width = config.mapWidth,
|
||||
height = config.mapHeight,
|
||||
mineCount = config.mineCount,
|
||||
config = config,
|
||||
seed = seed
|
||||
)
|
||||
)
|
||||
|
||||
private fun createMatrix(
|
||||
width: Int,
|
||||
height: Int,
|
||||
mineCount: Int,
|
||||
config: GameConfig,
|
||||
seed: Int
|
||||
): 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
|
||||
}
|
||||
@@ -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(
|
||||
matrix: Array<Array<CellType>>,
|
||||
width: Int,
|
||||
height: Int,
|
||||
mineCount: Int,
|
||||
config: GameConfig,
|
||||
seed: Int
|
||||
) {
|
||||
|
||||
/*
|
||||
* Mines are placed according to seed to reproduce results.
|
||||
*/
|
||||
val random = Random(seed)
|
||||
|
||||
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++
|
||||
val candidates = buildList {
|
||||
for (x in 0 until config.boardWidth) {
|
||||
for (y in 0 until config.boardHeight) {
|
||||
if (config.isCellActive(x, y) && !config.isCellProtected(x, y))
|
||||
add(x to y)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
candidates
|
||||
.shuffled(Random(seed))
|
||||
.take(config.mineCount)
|
||||
.forEach { (x, y) ->
|
||||
matrix[x][y] = CellType.MINE
|
||||
}
|
||||
}
|
||||
|
||||
private fun placeCounts(
|
||||
matrix: Array<Array<CellType>>,
|
||||
width: Int,
|
||||
height: Int
|
||||
config: GameConfig
|
||||
) {
|
||||
|
||||
for (x in 0 until width) {
|
||||
for (y in 0 until height) {
|
||||
for (x in 0 until config.boardWidth) {
|
||||
for (y in 0 until config.boardHeight) {
|
||||
|
||||
/* Minefields stay as they are. */
|
||||
if (matrix[x][y] == CellType.MINE)
|
||||
/* Inactive cells and minefields stay as they are. */
|
||||
if (!config.isCellActive(x, y) || matrix[x][y] == CellType.MINE)
|
||||
continue
|
||||
|
||||
val mineCount = countMinesInAdjacentCells(matrix, x, y)
|
||||
|
||||
@@ -111,6 +111,9 @@ fun MinefieldCanvas(
|
||||
if (row !in 0 until gameState.minefield.height)
|
||||
return null
|
||||
|
||||
if (!gameState.minefield.isActive(col, row))
|
||||
return null
|
||||
|
||||
return IntOffset(col, row)
|
||||
}
|
||||
|
||||
@@ -168,6 +171,9 @@ fun MinefieldCanvas(
|
||||
for (x in 0 until gameState.minefield.width) {
|
||||
for (y in 0 until gameState.minefield.height) {
|
||||
|
||||
if (!gameState.minefield.isActive(x, y))
|
||||
continue
|
||||
|
||||
val (cxRel, cyRel) = HexGeometry.cellCenter(x, y, hexSize)
|
||||
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.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
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.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
@@ -47,10 +49,13 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import de.stefan_oltmann.mines.FONT_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.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_MINE_COUNT
|
||||
import de.stefan_oltmann.mines.model.BoardLayout
|
||||
import de.stefan_oltmann.mines.model.GameConfig
|
||||
import de.stefan_oltmann.mines.model.clampMineCount
|
||||
import de.stefan_oltmann.mines.model.maxPlaceableMines
|
||||
@@ -83,16 +88,28 @@ fun SettingsDialog(
|
||||
val mapWidth = remember { mutableStateOf(gameConfig.mapWidth.toFloat()) }
|
||||
val mapHeight = remember { mutableStateOf(gameConfig.mapHeight.toFloat()) }
|
||||
val mineCount = remember { mutableStateOf(gameConfig.mineCount.toFloat()) }
|
||||
val boardLayout = remember { mutableStateOf(gameConfig.boardLayout) }
|
||||
val hexSideLength = remember { mutableStateOf(gameConfig.hexSideLength.toFloat()) }
|
||||
|
||||
fun clampMinesToBoard() {
|
||||
|
||||
mineCount.value = clampMineCount(
|
||||
mineCount = mineCount.value.toInt(),
|
||||
mapWidth = mapWidth.value.toInt(),
|
||||
mapHeight = mapHeight.value.toInt()
|
||||
mapHeight = mapHeight.value.toInt(),
|
||||
boardLayout = boardLayout.value,
|
||||
hexSideLength = hexSideLength.value.toInt()
|
||||
).toFloat()
|
||||
}
|
||||
|
||||
fun currentMaxMines(): Int =
|
||||
maxPlaceableMines(
|
||||
mapWidth = mapWidth.value.toInt(),
|
||||
mapHeight = mapHeight.value.toInt(),
|
||||
boardLayout = boardLayout.value,
|
||||
hexSideLength = hexSideLength.value.toInt()
|
||||
)
|
||||
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
@@ -129,7 +146,8 @@ fun SettingsDialog(
|
||||
Icon(
|
||||
imageVector = IconZoom,
|
||||
contentDescription = null,
|
||||
tint = colorForeground
|
||||
tint = colorForeground,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
|
||||
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(
|
||||
horizontalArrangement = Arrangement.spacedBy(defaultSpacing),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -161,7 +220,8 @@ fun SettingsDialog(
|
||||
Icon(
|
||||
imageVector = IconWidth,
|
||||
contentDescription = null,
|
||||
tint = colorForeground
|
||||
tint = colorForeground,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
|
||||
Slider(
|
||||
@@ -194,7 +254,8 @@ fun SettingsDialog(
|
||||
Icon(
|
||||
imageVector = IconHeight,
|
||||
contentDescription = null,
|
||||
tint = colorForeground
|
||||
tint = colorForeground,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
|
||||
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(
|
||||
horizontalArrangement = Arrangement.spacedBy(defaultSpacing),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -227,24 +325,19 @@ fun SettingsDialog(
|
||||
Icon(
|
||||
imageVector = IconMines,
|
||||
contentDescription = null,
|
||||
tint = colorForeground
|
||||
tint = colorForeground,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
|
||||
Slider(
|
||||
value = mineCount.value.coerceIn(
|
||||
MIN_MINE_COUNT.toFloat(),
|
||||
maxPlaceableMines(
|
||||
mapWidth.value.toInt(),
|
||||
mapHeight.value.toInt()
|
||||
).toFloat()
|
||||
currentMaxMines().toFloat()
|
||||
),
|
||||
onValueChange = {
|
||||
mineCount.value = it
|
||||
},
|
||||
valueRange = MIN_MINE_COUNT.toFloat()..maxPlaceableMines(
|
||||
mapWidth.value.toInt(),
|
||||
mapHeight.value.toInt()
|
||||
).toFloat(),
|
||||
valueRange = MIN_MINE_COUNT.toFloat()..currentMaxMines().toFloat(),
|
||||
colors = sliderColors,
|
||||
modifier = Modifier.weight(1F)
|
||||
)
|
||||
@@ -305,8 +398,12 @@ fun SettingsDialog(
|
||||
mineCount = clampMineCount(
|
||||
mineCount = mineCount.value.toInt(),
|
||||
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(
|
||||
name = "IconMines",
|
||||
defaultWidth = 512.dp,
|
||||
defaultHeight = 512.dp,
|
||||
defaultWidth = 24.dp,
|
||||
defaultHeight = 24.dp,
|
||||
viewportWidth = 512f,
|
||||
viewportHeight = 512f
|
||||
).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
|
||||
# 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.
|
||||
#
|
||||
# Requires: GITEA_TOKEN (repo write), curl, python3, git
|
||||
# Optional: HEX_MINES_VERSION (default 0.2.0)
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")" && pwd)"
|
||||
@@ -10,7 +11,7 @@ OWNER="${GITEA_OWNER:-Dawnsorrow}"
|
||||
REPO="${GITEA_REPO:-hex-mines}"
|
||||
BASE="${GITEA_BASE_URL:-https://git.hisora.dev}"
|
||||
BASE="${BASE%/}"
|
||||
VERSION="${HEX_MINES_VERSION:-0.1.0}"
|
||||
VERSION="${HEX_MINES_VERSION:-0.2.0}"
|
||||
TAG="v${VERSION}"
|
||||
TITLE="Hex Mines ${TAG}"
|
||||
APK="${1:-${ROOT}/dist/HexMines-${VERSION}-debug.apk}"
|
||||
@@ -74,7 +75,8 @@ else
|
||||
fi
|
||||
|
||||
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
|
||||
git tag -f "$TAG"
|
||||
@@ -83,13 +85,13 @@ git push -f "$PUSH_URL_AUTH" "refs/tags/${TAG}"
|
||||
NOTE=$(cat <<EOF
|
||||
## 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
|
||||
- Flat-top hex tiles (6 neighbors)
|
||||
- Configurable board width / height
|
||||
- Layout modes: **Rows** (width × height) and **Hexagon** (side length)
|
||||
- Configurable mine count slider
|
||||
- Based on [StefanOltmann/mines](https://github.com/StefanOltmann/mines) (AGPL-3.0)
|
||||
- Settings icon sizing fix for usable sliders
|
||||
|
||||
### Install
|
||||
Download \`HexMines-${VERSION}-debug.apk\` and install on Android (debug-signed).
|
||||
|
||||
Reference in New Issue
Block a user