2 Commits
Author SHA1 Message Date
DawnsorrowandCursor 8b6cc0bedc Update Gitea publish script for v0.2.0 releases.
Deploy / Build Kotlin/Wasm (push) Canceled after 0s
Build Android APK / Android (push) Canceled after 0s
Build Desktop app / Build Desktop app (push) Canceled after 0s
Co-authored-by: Cursor <[email protected]>
2026-07-29 08:15:46 -05:00
DawnsorrowandCursor a9846fd832 Add hexagon board layout and fix settings icon sizing.
Rows vs Hexagon selector, side-length sizing for hex boards, and
constrain settings icons so sliders stay usable.

Co-authored-by: Cursor <[email protected]>
2026-07-29 08:14:58 -05:00
11 changed files with 420 additions and 113 deletions
@@ -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)
}
}
+8 -6
View File
@@ -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).