From a9846fd832a6c7e252f55cd707f1e5ff4d24d4e0 Mon Sep 17 00:00:00 2001 From: Dawnsorrow Date: Wed, 29 Jul 2026 08:14:58 -0500 Subject: [PATCH] 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 --- .../kotlin/de/stefan_oltmann/mines/App.kt | 46 ++++++- .../de/stefan_oltmann/mines/AppConfig.kt | 4 + .../stefan_oltmann/mines/model/BoardLayout.kt | 26 ++++ .../stefan_oltmann/mines/model/GameConfig.kt | 122 +++++++++++++++-- .../stefan_oltmann/mines/model/GameState.kt | 20 ++- .../stefan_oltmann/mines/model/Minefield.kt | 99 +++++--------- .../mines/ui/MinefieldCanvas.kt | 6 + .../stefan_oltmann/mines/ui/SettingsDialog.kt | 127 +++++++++++++++--- .../mines/ui/icons/IconMines.kt | 4 +- .../mines/model/BoardLayoutTest.kt | 65 +++++++++ 10 files changed, 412 insertions(+), 107 deletions(-) create mode 100644 app/src/commonMain/kotlin/de/stefan_oltmann/mines/model/BoardLayout.kt create mode 100644 app/src/commonTest/kotlin/de/stefan_oltmann/mines/model/BoardLayoutTest.kt diff --git a/app/src/commonMain/kotlin/de/stefan_oltmann/mines/App.kt b/app/src/commonMain/kotlin/de/stefan_oltmann/mines/App.kt index e7708bd..46d65a6 100644 --- a/app/src/commonMain/kotlin/de/stefan_oltmann/mines/App.kt +++ b/app/src/commonMain/kotlin/de/stefan_oltmann/mines/App.kt @@ -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"]) diff --git a/app/src/commonMain/kotlin/de/stefan_oltmann/mines/AppConfig.kt b/app/src/commonMain/kotlin/de/stefan_oltmann/mines/AppConfig.kt index 691d3a5..c30e3eb 100644 --- a/app/src/commonMain/kotlin/de/stefan_oltmann/mines/AppConfig.kt +++ b/app/src/commonMain/kotlin/de/stefan_oltmann/mines/AppConfig.kt @@ -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 diff --git a/app/src/commonMain/kotlin/de/stefan_oltmann/mines/model/BoardLayout.kt b/app/src/commonMain/kotlin/de/stefan_oltmann/mines/model/BoardLayout.kt new file mode 100644 index 0000000..4332e38 --- /dev/null +++ b/app/src/commonMain/kotlin/de/stefan_oltmann/mines/model/BoardLayout.kt @@ -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 + } + } +} diff --git a/app/src/commonMain/kotlin/de/stefan_oltmann/mines/model/GameConfig.kt b/app/src/commonMain/kotlin/de/stefan_oltmann/mines/model/GameConfig.kt index ab50f93..0d7c08a 100644 --- a/app/src/commonMain/kotlin/de/stefan_oltmann/mines/model/GameConfig.kt +++ b/app/src/commonMain/kotlin/de/stefan_oltmann/mines/model/GameConfig.kt @@ -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)) +} diff --git a/app/src/commonMain/kotlin/de/stefan_oltmann/mines/model/GameState.kt b/app/src/commonMain/kotlin/de/stefan_oltmann/mines/model/GameState.kt index 962432c..c80a51b 100644 --- a/app/src/commonMain/kotlin/de/stefan_oltmann/mines/model/GameState.kt +++ b/app/src/commonMain/kotlin/de/stefan_oltmann/mines/model/GameState.kt @@ -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,7 +138,8 @@ class GameState( flaggedMatrix[x][y] fun toggleFlag(x: Int, y: Int) { - flaggedMatrix[x][y] = !flaggedMatrix[x][y] + if (minefield.isActive(x, y)) + flaggedMatrix[x][y] = !flaggedMatrix[x][y] } fun flagAllMines() { @@ -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) + } } } diff --git a/app/src/commonMain/kotlin/de/stefan_oltmann/mines/model/Minefield.kt b/app/src/commonMain/kotlin/de/stefan_oltmann/mines/model/Minefield.kt index ce9d908..4757a75 100644 --- a/app/src/commonMain/kotlin/de/stefan_oltmann/mines/model/Minefield.kt +++ b/app/src/commonMain/kotlin/de/stefan_oltmann/mines/model/Minefield.kt @@ -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> { - 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>, - 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>, - 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) diff --git a/app/src/commonMain/kotlin/de/stefan_oltmann/mines/ui/MinefieldCanvas.kt b/app/src/commonMain/kotlin/de/stefan_oltmann/mines/ui/MinefieldCanvas.kt index 2a2078a..56476f0 100644 --- a/app/src/commonMain/kotlin/de/stefan_oltmann/mines/ui/MinefieldCanvas.kt +++ b/app/src/commonMain/kotlin/de/stefan_oltmann/mines/ui/MinefieldCanvas.kt @@ -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) diff --git a/app/src/commonMain/kotlin/de/stefan_oltmann/mines/ui/SettingsDialog.kt b/app/src/commonMain/kotlin/de/stefan_oltmann/mines/ui/SettingsDialog.kt index 181e89a..5b40838 100644 --- a/app/src/commonMain/kotlin/de/stefan_oltmann/mines/ui/SettingsDialog.kt +++ b/app/src/commonMain/kotlin/de/stefan_oltmann/mines/ui/SettingsDialog.kt @@ -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() ) ) } diff --git a/app/src/commonMain/kotlin/de/stefan_oltmann/mines/ui/icons/IconMines.kt b/app/src/commonMain/kotlin/de/stefan_oltmann/mines/ui/icons/IconMines.kt index 53a5f7f..2b5cd70 100644 --- a/app/src/commonMain/kotlin/de/stefan_oltmann/mines/ui/icons/IconMines.kt +++ b/app/src/commonMain/kotlin/de/stefan_oltmann/mines/ui/icons/IconMines.kt @@ -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 { diff --git a/app/src/commonTest/kotlin/de/stefan_oltmann/mines/model/BoardLayoutTest.kt b/app/src/commonTest/kotlin/de/stefan_oltmann/mines/model/BoardLayoutTest.kt new file mode 100644 index 0000000..031a65b --- /dev/null +++ b/app/src/commonTest/kotlin/de/stefan_oltmann/mines/model/BoardLayoutTest.kt @@ -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) + } +}