Initial Hex Mines fork with hex tiles and mine-count slider.

Based on StefanOltmann/mines (AGPL-3.0): flat-top hex grid, configurable
board size, and a mine-count slider replacing fixed difficulty presets.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Dawnsorrow
2026-07-29 07:32:33 -05:00
co-authored by Cursor
commit 1ae7c24706
191 changed files with 14286 additions and 0 deletions
+210
View File
@@ -0,0 +1,210 @@
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpackConfig
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidApplication)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
alias(libs.plugins.androidGitVersion)
alias(libs.plugins.hydraulicConveyor)
}
androidGitVersion {
format = "%tag%"
}
version = androidGitVersion.name()
logger.lifecycle("App version $version (Code: ${androidGitVersion.code()})")
kotlin {
androidTarget {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
}
}
jvm()
jvmToolchain(jdkVersion = 21)
@OptIn(ExperimentalWasmDsl::class)
wasmJs {
outputModuleName = "app"
browser {
val rootDirPath = project.rootDir.path
val projectDirPath = project.projectDir.path
commonWebpackConfig {
outputFileName = "app.js"
devServer = (devServer ?: KotlinWebpackConfig.DevServer()).apply {
static = (static ?: mutableListOf()).apply {
// Serve sources to debug inside browser
add(rootDirPath)
add(projectDirPath)
}
}
}
}
binaries.executable()
}
sourceSets {
commonMain.dependencies {
/* Compose UI */
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material3)
implementation(compose.ui)
implementation(compose.components.resources)
/* Platform Tools */
implementation(libs.platformtools.core)
implementation(libs.platformtools.darkmodedetector)
/* Coroutines */
implementation(libs.kotlinx.coroutines.core)
/* Datetime */
implementation(libs.kotlinx.datetime)
/* Settings */
implementation(libs.multiplatformSettings)
/* Lottie Animations */
implementation(libs.compottie)
implementation(libs.compottie.dot)
}
commonTest.dependencies {
implementation(libs.kotlin.test)
implementation(libs.kotlinx.coroutines.test)
}
androidMain.dependencies {
implementation(libs.androidx.activity.compose)
}
jvmMain.dependencies {
implementation(compose.desktop.currentOs)
implementation(libs.kotlinx.coroutines.swing)
}
jvmTest.dependencies {
implementation(libs.kotlin.test.junit)
implementation(compose.desktop.currentOs)
implementation(compose.desktop.uiTestJUnit4)
}
}
}
android {
namespace = "de.stefan_oltmann.mines"
compileSdk = libs.versions.android.compileSdk.get().toInt()
defaultConfig {
applicationId = "de.stefan_oltmann.hexmines"
minSdk = libs.versions.android.minSdk.get().toInt()
targetSdk = libs.versions.android.targetSdk.get().toInt()
if (androidGitVersion.code() == 0) {
/* Values for the dev version. */
versionName = "1.0.0"
versionCode = 1
} else {
versionName = androidGitVersion.name()
versionCode = androidGitVersion.code()
}
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
buildTypes {
getByName("release") {
/*
* As an open source project we don't need ProGuard.
*/
isMinifyEnabled = false
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
compose.desktop {
application {
mainClass = "de.stefan_oltmann.mines.MainKt"
nativeDistributions {
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
packageName = "Mines"
if (androidGitVersion.code() == 0) {
/* Values for the dev version. */
packageVersion = "1.0.0"
} else {
packageVersion = version.toString()
}
macOS {
iconFile.set(project.file("../icon/icon.icns"))
}
windows {
iconFile.set(project.file("../icon/icon.ico"))
}
linux {
iconFile.set(project.file("../icon/icon.png"))
}
buildTypes.release.proguard {
isEnabled = true
obfuscate.set(false)
optimize.set(true)
configurationFiles.from(project.file("proguard-rules.pro"))
}
}
}
}
dependencies {
implementation(libs.androidx.runtime.android)
debugImplementation(compose.uiTooling)
linuxAmd64(compose.desktop.linux_x64)
macAmd64(compose.desktop.macos_x64)
macAarch64(compose.desktop.macos_arm64)
windowsAmd64(compose.desktop.windows_x64)
}
+87
View File
@@ -0,0 +1,87 @@
-keepclasseswithmembers public class de.stefan_oltmann.mines.MainKt { #
public static void main(java.lang.String[]);
}
-dontwarn kotlinx.coroutines.debug.*
-keep class kotlin.** { *; }
-keep class kotlinx.** { *; }
-keep class kotlinx.coroutines.** { *; }
-keep class org.jetbrains.skia.** { *; }
-keep class org.jetbrains.skiko.** { *; }
-keep class com.sun.jna.** { *; }
-keep class * implements com.sun.jna.** { *; }
-keepclassmembers class * extends com.sun.jna.* { public *; }
-keepclassmembers class * implements com.sun.jna.* { public *; }
-dontwarn com.sun.jna.**
# Keep specific JNA Platform classes used in the project
-keep class com.sun.jna.platform.** { *; }
-keep class com.sun.jna.win32.** { *; }
-dontwarn com.sun.jna.platform.**
-keep class com.kdroid.composetray.** { *; }
-assumenosideeffects public class androidx.compose.runtime.ComposerKt {
void sourceInformation(androidx.compose.runtime.Composer,java.lang.String);
void sourceInformationMarkerStart(androidx.compose.runtime.Composer,int,java.lang.String);
void sourceInformationMarkerEnd(androidx.compose.runtime.Composer);
}
# Keep `Companion` object fields of serializable classes.
# This avoids serializer lookup through `getDeclaredClasses` as done for named companion objects.
-if @kotlinx.serialization.Serializable class **
-keepclassmembers class <1> {
static <1>$Companion Companion;
}
# Keep `serializer()` on companion objects (both default and named) of serializable classes.
-if @kotlinx.serialization.Serializable class ** {
static **$* *;
}
-keepclassmembers class <2>$<3> {
kotlinx.serialization.KSerializer serializer(...);
}
# Keep `INSTANCE.serializer()` of serializable objects.
-if @kotlinx.serialization.Serializable class ** {
public static ** INSTANCE;
}
-keepclassmembers class <1> {
public static <1> INSTANCE;
kotlinx.serialization.KSerializer serializer(...);
}
# @Serializable and @Polymorphic are used at runtime for polymorphic serialization.
-keepattributes RuntimeVisibleAnnotations,AnnotationDefault
-keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.AnnotationsKt # core serialization annotations
-dontnote kotlinx.serialization.SerializationKt
# OkHttp platform used only on JVM and when Conscrypt and other security providers are available.
-dontwarn okhttp3.internal.platform.**
-dontwarn org.conscrypt.**
-dontwarn org.bouncycastle.**
-dontwarn org.openjsse.**
#################################### SLF4J #####################################
-dontwarn org.slf4j.**
# Prevent runtime crashes from use of class.java.getName()
-dontwarn javax.naming.**
# Keep enum classes
-keepclassmembers class * extends java.lang.Enum {
<fields>;
public static **[] values();
public static ** valueOf(java.lang.String);
}
# Specifically keep GameDifficulty enum
-keep enum de.stefan_oltmann.mines.model.GameDifficulty { *; }
# Ignore warnings and Don't obfuscate for now
-dontobfuscate
-ignorewarnings
+22
View File
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@android:style/Theme.Material.Light.NoActionBar">
<activity
android:exported="true"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|mnc|colorMode|density|fontScale|fontWeightAdjustment|keyboard|layoutDirection|locale|mcc|navigation|smallestScreenSize|touchscreen|uiMode"
android:name="MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
</application>
</manifest>
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

@@ -0,0 +1,57 @@
/*
* 💣 Mines 💣
* Copyright (C) 2025 Stefan Oltmann
* https://github.com/StefanOltmann/mines
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package de.stefan_oltmann.mines
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val settingsSharedPreferences = getSharedPreferences("settings", MODE_PRIVATE)
SettingsProvider.init(settingsSharedPreferences)
setContent {
/*
* On newer Android versions we need the proper paddings.
*/
Box(
modifier = Modifier
.background(Color.Black)
.statusBarsPadding()
.navigationBarsPadding()
) {
App()
}
}
}
}
@@ -0,0 +1,37 @@
package de.stefan_oltmann.mines
import android.content.SharedPreferences
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import com.russhwolf.settings.Settings
import com.russhwolf.settings.SharedPreferencesSettings
@Suppress("LateinitUsage", "MatchingDeclarationName")
object SettingsProvider {
lateinit var settings: SharedPreferencesSettings
private set
fun init(prefs: SharedPreferences) {
settings = SharedPreferencesSettings(prefs)
}
}
actual val settings: Settings = SettingsProvider.settings
actual val defaultMapWidth: Int = 7
actual val defaultMapHeight: Int = 7
actual val isDesktop: Boolean = false
/* Not effective as there is no right-click on Android */
actual fun Modifier.addRightClickListener(key: Any?, onClick: (Offset) -> Unit): Modifier = this
@Composable
actual fun BoxScope.HorizontalScrollbar(scrollState: ScrollState) = Unit
@Composable
actual fun BoxScope.VerticalScrollbar(scrollState: ScrollState) = Unit
@@ -0,0 +1,31 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0"/>
<item
android:color="#00000000"
android:offset="1.0"/>
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000"/>
</vector>
@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z"/>
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
</vector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 692 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 488 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 648 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 686 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 974 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#F2F2F2</color>
</resources>
@@ -0,0 +1,3 @@
<resources>
<string name="app_name">Hex Mines</string>
</resources>
@@ -0,0 +1,22 @@
Lottie Simple License (FL 9.13.21)
Copyright © 2021 Design Barn Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy of the public animation files available
for download at the LottieFiles site ("Files") to download, reproduce, modify, publish, distribute, publicly display,
and publicly digitally perform such Files, including for commercial purposes, provided that any display,
publication, performance, or distribution of Files must contain (and be subject to) the same terms and conditions
of this license. Modifications to Files are deemed derivative works and must also be expressly distributed under
the same terms and conditions of this license. You may not purport to impose any additional or different terms or
conditions on, or apply any technical measures that restrict exercise of, the rights granted under this license. This
license does not include the right to collect or compile Files from LottieFiles to replicate or develop a similar or
competing service.
Use of Files without attributing the creator(s) of the Files is permitted under this license, though attribution is
strongly encouraged. If attributions are included, such attributions should be visible to the end user.
FILES ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. EXCEPT
TO THE EXTENT REQUIRED BY APPLICABLE LAW, IN NO EVENT WILL THE CREATOR(S) OF FILES OR DESIGN BARN, INC.
BE LIABLE ON ANY LEGAL THEORY FOR ANY SPECIAL, INCIDENTAL, CONSEQUENTIAL, PUNITIVE, OR EXEMPLARY
DAMAGES ARISING OUT OF THIS LICENSE OR THE USE OF SUCH FILES.
@@ -0,0 +1,94 @@
Copyright (c) 2012, Vicente Lamonaca ([email protected] www.tipografia-montevideo.info www.tipotype.com),
with Reserved Font Name Economica.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,363 @@
/*
* 💣 Mines 💣
* Copyright (C) 2025 Stefan Oltmann
* https://github.com/StefanOltmann/mines
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package de.stefan_oltmann.mines
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.Game
import de.stefan_oltmann.mines.model.GameConfig
import de.stefan_oltmann.mines.model.GameDifficulty
import de.stefan_oltmann.mines.model.clampMineCount
import de.stefan_oltmann.mines.ui.AppFooter
import de.stefan_oltmann.mines.ui.GameOverOverlay
import de.stefan_oltmann.mines.ui.MinefieldCanvas
import de.stefan_oltmann.mines.ui.PlusVersionButton
import de.stefan_oltmann.mines.ui.SettingsDialog
import de.stefan_oltmann.mines.ui.SponsorButton
import de.stefan_oltmann.mines.ui.Toolbar
import de.stefan_oltmann.mines.ui.lottie.ConfettiLottieImage
import de.stefan_oltmann.mines.ui.theme.DefaultSpacer
import de.stefan_oltmann.mines.ui.theme.EconomicaFontFamily
import de.stefan_oltmann.mines.ui.theme.colorBackground
import de.stefan_oltmann.mines.ui.theme.colorCardBackground
import de.stefan_oltmann.mines.ui.theme.colorCardBorder
import de.stefan_oltmann.mines.ui.theme.colorCardBorderGameOver
import de.stefan_oltmann.mines.ui.theme.colorCardBorderGameWon
import de.stefan_oltmann.mines.ui.theme.defaultRoundedCornerShape
import de.stefan_oltmann.mines.ui.theme.defaultSpacing
import de.stefan_oltmann.mines.ui.theme.doublePadding
import de.stefan_oltmann.mines.ui.theme.doubleSpacing
import io.github.alexzhirkevich.compottie.DotLottie
import io.github.alexzhirkevich.compottie.LottieCompositionSpec
import io.github.alexzhirkevich.compottie.rememberLottieComposition
import mines.app.generated.resources.Res
import org.jetbrains.compose.resources.ExperimentalResourceApi
@OptIn(ExperimentalResourceApi::class)
@Composable
fun App() {
val fontFamily = EconomicaFontFamily()
val game = remember { Game() }
val gameConfig = remember {
mutableStateOf(
GameConfig(
cellSize = settings["mines_cell_size"] ?: DEFAULT_CELL_SIZE,
mapWidth = settings["mines_map_width"] ?: defaultMapWidth,
mapHeight = settings["mines_map_height"] ?: defaultMapHeight,
mineCount = resolveSavedMineCount(
mapWidth = settings["mines_map_width"] ?: defaultMapWidth,
mapHeight = settings["mines_map_height"] ?: defaultMapHeight
)
)
)
}
val redrawState = remember { mutableStateOf(0) }
/* State to trigger scrolling to the middle of the field */
val scrollToMiddleTrigger = remember { mutableStateOf(0) }
/* Remember scroll states so they can be accessed from the restartGame lambda */
val verticalScrollState = rememberScrollState()
val horizontalScrollState = rememberScrollState()
/*
* Pre-load lotties to avoid delays in playback.
*/
val confettiLottieComposition by rememberLottieComposition {
LottieCompositionSpec.DotLottie(
archive = Res.readBytes("files/confetti.lottie")
)
}
val explosionLottieComposition by rememberLottieComposition {
LottieCompositionSpec.DotLottie(
archive = Res.readBytes("files/explosion.lottie")
)
}
/*
* Initially start a new game when opened.
*/
LaunchedEffect(Unit) {
game.restart(gameConfig.value)
/* Trigger scrolling to the middle of the field */
scrollToMiddleTrigger.value += 1
}
LaunchedEffect(gameConfig.value) {
val newGameConfig = gameConfig.value
val oldMapWidth = settings["mines_map_width"] ?: defaultMapWidth
val oldMapHeight = settings["mines_map_height"] ?: defaultMapHeight
val oldMineCount = resolveSavedMineCount(oldMapWidth, oldMapHeight)
/* 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
val mapSettingsChanged =
oldMapWidth != newGameConfig.mapWidth ||
oldMapHeight != newGameConfig.mapHeight ||
oldMineCount != newGameConfig.mineCount
/* Launch a new game every time the settings change something that influences the map */
if (mapSettingsChanged) {
game.restart(gameConfig.value)
/* Trigger scrolling to the middle of the field */
scrollToMiddleTrigger.value += 1
}
/* HACK */
redrawState.value += 1
}
/* Effect to scroll to the middle of the field when the trigger changes */
LaunchedEffect(scrollToMiddleTrigger.value) {
horizontalScrollState.scrollTo(horizontalScrollState.maxValue / 2)
verticalScrollState.scrollTo(verticalScrollState.maxValue / 2)
}
val elapsedSeconds by game.elapsedSeconds.collectAsState()
val showSettings = remember { mutableStateOf(false) }
/*
* Force redraw if state changes.
*
* FIXME This is a hack
*/
redrawState.value
Column {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.weight(1F)
.fillMaxWidth()
.background(colorBackground)
) {
val borderColor = when {
game.gameOver -> colorCardBorderGameOver
game.gameWon -> colorCardBorderGameWon
else -> colorCardBorder
}
Card(
colors = CardDefaults.cardColors().copy(
containerColor = colorCardBackground
),
shape = defaultRoundedCornerShape,
border = BorderStroke(1.dp, borderColor),
modifier = Modifier.doublePadding()
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.padding(
start = defaultSpacing,
end = defaultSpacing,
bottom = doubleSpacing
)
) {
Toolbar(
highlightRestartButton = game.gameOver || game.gameWon,
elapsedSeconds = elapsedSeconds,
remainingFlagsCount = game.gameState?.getRemainingFlagsCount() ?: 0,
fontFamily = fontFamily,
showSettings = {
showSettings.value = true
},
restartGame = {
game.restart(gameConfig.value)
/* FIXME This is a hack */
redrawState.value += 1
/* Trigger scrolling to the middle of the field */
scrollToMiddleTrigger.value += 1
}
)
Box(
modifier = Modifier.weight(1f, fill = false)
) {
Box(
modifier = Modifier
.verticalScroll(verticalScrollState)
.horizontalScroll(horizontalScrollState)
) {
val gameState = game.gameState
if (gameState != null) {
MinefieldCanvas(
gameState,
gameConfig,
redrawState,
fontFamily,
hit = { x, y -> game.hit(x, y) },
flag = { x, y -> game.flag(x, y) }
)
}
}
if (verticalScrollState.canScrollForward || verticalScrollState.canScrollBackward)
VerticalScrollbar(verticalScrollState)
if (horizontalScrollState.canScrollForward || horizontalScrollState.canScrollBackward)
HorizontalScrollbar(horizontalScrollState)
}
if (!isDesktop) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(top = defaultSpacing)
) {
val uriHandler = LocalUriHandler.current
SponsorButton(
fontFamily = fontFamily,
onClick = {
uriHandler.openUri("https://github.com/sponsors/StefanOltmann")
}
)
DefaultSpacer()
PlusVersionButton(
fontFamily = fontFamily,
onClick = {
if (isDesktop)
uriHandler.openUri("https://apps.microsoft.com/detail/9nd96xcdzrgb")
else
uriHandler.openUri("https://play.google.com/store/apps/details?id=de.stefan_oltmann.mines_plus")
}
)
}
}
}
}
when {
game.gameWon ->
confettiLottieComposition?.let { lottieComposition ->
ConfettiLottieImage(lottieComposition)
}
game.gameOver ->
explosionLottieComposition?.let { lottieComposition ->
GameOverOverlay(
explosionLottieComposition = lottieComposition,
fontFamily = fontFamily
)
}
}
/*
* The settings must overlay the "game over" text.
*/
if (showSettings.value)
SettingsDialog(
gameConfig = gameConfig.value,
fontFamily = fontFamily,
onCancel = {
showSettings.value = false
},
onConfirm = { newGameSettings ->
showSettings.value = false
gameConfig.value = newGameSettings
}
)
}
AppFooter(fontFamily)
}
}
/**
* 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 {
val savedMineCount: Int? = settings["mines_mine_count"]
if (savedMineCount != null)
return clampMineCount(savedMineCount, mapWidth, mapHeight)
val difficulty = GameDifficulty.fromSettingsValue(settings["mines_difficulty"])
return clampMineCount(
mineCount = difficulty.calcMineCount(mapWidth, mapHeight),
mapWidth = mapWidth,
mapHeight = mapHeight
)
}
@@ -0,0 +1,35 @@
/*
* 💣 Mines 💣
* Copyright (C) 2025 Stefan Oltmann
* https://github.com/StefanOltmann/mines
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package de.stefan_oltmann.mines
const val APP_TITLE = "Hex Mines"
const val MIN_CELL_SIZE: Int = 30
const val MAX_CELL_SIZE: Int = 99
const val DEFAULT_CELL_SIZE: Int = 40
const val MIN_LONG_SIDE: Int = 5
const val MAX_LONG_SIDE: Int = 50
const val MIN_MINE_COUNT: Int = 1
const val FONT_SIZE: Int = 20
const val LONG_PRESS_TIMEOUT_MS: Long = 200
@@ -0,0 +1,46 @@
/*
* 💣 Mines 💣
* Copyright (C) 2025 Stefan Oltmann
* https://github.com/StefanOltmann/mines
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package de.stefan_oltmann.mines
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import com.russhwolf.settings.Settings
expect val settings: Settings
expect val defaultMapWidth: Int
expect val defaultMapHeight: Int
expect val isDesktop: Boolean
expect fun Modifier.addRightClickListener(
key: Any?,
onClick: (Offset) -> Unit
): Modifier
@Composable
expect fun BoxScope.HorizontalScrollbar(scrollState: ScrollState)
@Composable
expect fun BoxScope.VerticalScrollbar(scrollState: ScrollState)
@@ -0,0 +1,49 @@
/*
* 💣 Mines 💣
* Copyright (C) 2025 Stefan Oltmann
* https://github.com/StefanOltmann/mines
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package de.stefan_oltmann.mines.model
enum class CellType(
val adjacentMineCount: Int
) {
EMPTY(0),
MINE(-1),
ONE(1),
TWO(2),
THREE(3),
FOUR(4),
FIVE(5),
SIX(6);
companion object {
fun ofMineCount(mineCount: Int): CellType =
when (mineCount) {
0 -> EMPTY
1 -> ONE
2 -> TWO
3 -> THREE
4 -> FOUR
5 -> FIVE
6 -> SIX
else -> EMPTY
}
}
}
@@ -0,0 +1,187 @@
/*
* 💣 Mines 💣
* Copyright (C) 2025 Stefan Oltmann
* https://github.com/StefanOltmann/mines
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package de.stefan_oltmann.mines.model
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlin.time.Clock
import kotlin.time.ExperimentalTime
import kotlin.time.Instant
private val gameStateScope = CoroutineScope(Dispatchers.Default)
class Game {
private val _elapsedSeconds = MutableStateFlow(0L)
val elapsedSeconds = _elapsedSeconds.asStateFlow()
@OptIn(ExperimentalTime::class)
private var gameStartTime = Instant.DISTANT_PAST
private var isTimerRunning = false
var gameOver = false
var gameWon = false
var gameState: GameState? = null
val minefield: Minefield?
get() = gameState?.minefield
private fun generateSeed() =
(1..Int.MAX_VALUE).random()
@OptIn(ExperimentalTime::class)
private fun startTimer() {
if (isTimerRunning)
return
isTimerRunning = true
gameStateScope.launch {
gameStartTime = Clock.System.now()
while (isTimerRunning) {
/*
* We try to prevent shifts that occur from delay() not being super-accurate.
*/
val result = Clock.System.now() - gameStartTime
_elapsedSeconds.value = result.inWholeSeconds
delay(1000)
}
}
}
fun restart(
gameConfig: GameConfig
) {
isTimerRunning = false
_elapsedSeconds.value = 0
gameOver = false
gameWon = false
gameState =
GameState(
minefield = Minefield.create(
config = gameConfig,
seed = generateSeed()
)
)
}
fun hit(x: Int, y: Int) {
val gameState = gameState ?: return
/* Ignore further inputs if game ended. */
if (gameOver || gameWon)
return
/* Start timer on first interaction after reset. */
if (!isTimerRunning)
startTimer()
/* Ignore clicks on flagged cells as these are most likely accidents. */
if (gameState.isFlagged(x, y))
return
val revealed = gameState.isRevealed(x, y)
if (!revealed) {
/* Reveal the field in any case */
gameState.reveal(x, y)
/* On hitting a mine the game is over. */
if (gameState.minefield.isMine(x, y)) {
isTimerRunning = false
gameOver = true
return
}
}
/*
* Tapping on a revealed number should reveal all
* adjacent fields if a matching number of flags is set.
*/
if (revealed) {
val hitMineWhileRevealingAdjacentCells = gameState.revealAdjacentCells(x, y)
if (hitMineWhileRevealingAdjacentCells) {
isTimerRunning = false
gameOver = true
return
}
}
/* Check win condition */
if (gameState.isAllRevealed()) {
/*
* Many games flag all remaining mines
* for the finish screen.
*/
gameState.flagAllMines()
isTimerRunning = false
gameWon = true
}
}
fun flag(x: Int, y: Int) {
val gameState = gameState ?: return
/* Ignore further inputs if game ended. */
if (gameOver || gameWon)
return
/* Start timer on first interaction after reset. */
if (!isTimerRunning)
startTimer()
/* Only non-revealed fields can be flagged. */
if (gameState.isRevealed(x, y))
return
gameState.toggleFlag(x, y)
}
}
@@ -0,0 +1,56 @@
/*
* 💣 Mines 💣
* Copyright (C) 2025 Stefan Oltmann
* https://github.com/StefanOltmann/mines
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package de.stefan_oltmann.mines.model
import de.stefan_oltmann.mines.MIN_LONG_SIDE
import de.stefan_oltmann.mines.MIN_MINE_COUNT
data class GameConfig(
val cellSize: Int,
val mapWidth: Int,
val mapHeight: Int,
val mineCount: Int
) {
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(mineCount >= MIN_MINE_COUNT) { "Mine count must be at least $MIN_MINE_COUNT." }
require(mineCount <= maxPlaceableMines(mapWidth, mapHeight)) {
"Mine count must fit outside the protected starting area."
}
}
}
/**
* Maximum mines that can be placed without filling the protected center zone.
*/
fun maxPlaceableMines(mapWidth: Int, mapHeight: Int): Int {
val protectedCells =
Minefield.calcProtectedRange(mapWidth).count() *
Minefield.calcProtectedRange(mapHeight).count()
return (mapWidth * mapHeight - protectedCells).coerceAtLeast(MIN_MINE_COUNT)
}
fun clampMineCount(mineCount: Int, mapWidth: Int, mapHeight: Int): Int =
mineCount.coerceIn(MIN_MINE_COUNT, maxPlaceableMines(mapWidth, mapHeight))
@@ -0,0 +1,81 @@
/*
* 💣 Mines 💣
* Copyright (C) 2025 Stefan Oltmann
* https://github.com/StefanOltmann/mines
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package de.stefan_oltmann.mines.model
/**
* Difficulty tiers that map to a percentage of mines on the board.
*/
enum class GameDifficulty(
private val minePercentage: Int,
private val settingsKey: String
) {
/** Lower mine density for a relaxed game. */
EASY(10, "easy"),
/** Balanced mine density for typical play. */
MEDIUM(15, "medium"),
/** Higher mine density for advanced play. */
HARD(20, "hard");
/**
* Calculate mine count as a percentage of total cells.
*
* @param mapWidth Board width in cells.
* @param mapHeight Board height in cells.
*/
fun calcMineCount(mapWidth: Int, mapHeight: Int): Int {
val cellCount = mapWidth * mapHeight
return (cellCount * (minePercentage / 100f)).toInt().coerceAtLeast(1)
}
/**
* Return the stable key used for persisting this difficulty in settings.
*
* This value stays constant even when enum names are obfuscated.
*/
fun toSettingsValue(): String = settingsKey
companion object {
/**
* Resolve a persisted difficulty value without relying on enum names.
*
* This keeps settings compatible with older builds that stored enum names,
* while allowing ProGuard/R8 to freely obfuscate the enum.
*
* @param value Stored settings value, accepts legacy enum names as well.
*/
fun fromSettingsValue(value: String?): GameDifficulty {
val normalized = value?.trim()?.lowercase()
return when (normalized) {
EASY.settingsKey -> EASY
MEDIUM.settingsKey -> MEDIUM
HARD.settingsKey -> HARD
else -> EASY
}
}
}
}
@@ -0,0 +1,171 @@
/*
* 💣 Mines 💣
* Copyright (C) 2025 Stefan Oltmann
* https://github.com/StefanOltmann/mines
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package de.stefan_oltmann.mines.model
/**
* Represents the state of a game, including which cells are revealed and flagged.
*/
class GameState(
val minefield: Minefield
) {
private val revealedMatrix: Array<Array<Boolean>> =
Array(minefield.width) {
Array(minefield.height) {
false
}
}
private val flaggedMatrix: Array<Array<Boolean>> =
Array(minefield.width) {
Array(minefield.height) {
false
}
}
fun getRemainingFlagsCount(): Int =
minefield.config.mineCount - flaggedMatrix.flatten().count { it }
fun isRevealed(x: Int, y: Int): Boolean =
revealedMatrix[x][y]
/**
* Check if all non-mine fields are revealed now.
*/
fun isAllRevealed(): Boolean {
for (x in 0 until minefield.width)
for (y in 0 until minefield.height)
if (!minefield.isMine(x, y) && !isRevealed(x, y))
return false
return true
}
fun reveal(x: Int, y: Int) {
/* Ignore call if coordinates are already revealed. */
if (revealedMatrix[x][y])
return
/* Mark the current cell as revealed */
revealedMatrix[x][y] = true
/* Remove any flags that may have set on non-minefields. */
flaggedMatrix[x][y] = false
/* If the cell is empty, recursively reveal adjacent cells */
if (minefield.getCellType(x, y) == CellType.EMPTY) {
performOnAdjacentCells(x, y) { adjX, adjY ->
if (isRevealed(adjX, adjY))
return@performOnAdjacentCells
reveal(adjX, adjY)
}
}
}
/**
* Reveal adjacent cells around a number field.
*
* Returns if we hit a mine.
*/
fun revealAdjacentCells(x: Int, y: Int): Boolean {
val cellType = minefield.getCellType(x, y)
/*
* Ignore non-number cells.
*/
if (cellType == CellType.EMPTY || cellType == CellType.MINE)
return false
var hitMine = false
if (cellType.adjacentMineCount > 0) {
val adjacentFlags = countAdjacentFlags(x, y)
if (cellType.adjacentMineCount == adjacentFlags) {
performOnAdjacentCells(x, y) { adjX, adjY ->
if (isRevealed(adjX, adjY) || isFlagged(adjX, adjY))
return@performOnAdjacentCells
reveal(adjX, adjY)
/*
* We want to reveal all adjacent cells,
* so we don't immediately return here.
*/
if (minefield.isMine(adjX, adjY))
hitMine = true
}
}
}
return hitMine
}
fun isFlagged(x: Int, y: Int): Boolean =
flaggedMatrix[x][y]
fun toggleFlag(x: Int, y: Int) {
flaggedMatrix[x][y] = !flaggedMatrix[x][y]
}
fun flagAllMines() {
for (x in 0 until minefield.width)
for (y in 0 until minefield.height)
if (minefield.isMine(x, y))
flaggedMatrix[x][y] = true
}
private fun countAdjacentFlags(x: Int, y: Int): Int {
var count = 0
performOnAdjacentCells(x, y) { adjX, adjY ->
if (isFlagged(adjX, adjY))
count++
}
return count
}
private fun performOnAdjacentCells(
x: Int,
y: Int,
action: (Int, Int) -> Unit
) {
forEachAdjacentCell(
x = x,
y = y,
width = minefield.width,
height = minefield.height,
action = action
)
}
}
@@ -0,0 +1,147 @@
/*
* 💣 Mines 💣
* Copyright (C) 2025 Stefan Oltmann
* https://github.com/StefanOltmann/mines
*
* Hex geometry helpers for flat-top odd-r offset grids.
*/
package de.stefan_oltmann.mines.model
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.cos
import kotlin.math.roundToInt
import kotlin.math.sin
import kotlin.math.sqrt
/**
* Flat-top hex layout with odd-q offset coordinates
* (odd columns are shoved down).
*
* [hexSize] is the center-to-vertex radius in pixels.
*/
object HexGeometry {
private val sqrt3 = sqrt(3f)
fun horizontalSpacing(hexSize: Float): Float =
hexSize * 1.5f
fun verticalSpacing(hexSize: Float): Float =
hexSize * sqrt3
/**
* Pixel center of cell (col, row), with origin at the
* center of cell (0, 0).
*/
fun cellCenter(col: Int, row: Int, hexSize: Float): Pair<Float, Float> {
val x = hexSize * 1.5f * col
val y = hexSize * sqrt3 * (row + 0.5f * (col and 1))
return x to y
}
/**
* Canvas size needed to fit [cols] x [rows] hexes,
* including a full hex margin around centers.
*/
fun boardSize(cols: Int, rows: Int, hexSize: Float): Pair<Float, Float> {
if (cols <= 0 || rows <= 0)
return 0f to 0f
var minX = Float.POSITIVE_INFINITY
var maxX = Float.NEGATIVE_INFINITY
var minY = Float.POSITIVE_INFINITY
var maxY = Float.NEGATIVE_INFINITY
for (col in 0 until cols) {
for (row in 0 until rows) {
val (cx, cy) = cellCenter(col, row, hexSize)
minX = minOf(minX, cx)
maxX = maxOf(maxX, cx)
minY = minOf(minY, cy)
maxY = maxOf(maxY, cy)
}
}
/* Add radius so edges of outer hexes are included. */
val width = (maxX - minX) + hexSize * 2f
val height = (maxY - minY) + hexSize * 2f
return width to height
}
/**
* Origin offset so cell (0,0) center sits at (hexSize, hexSize)
* within the canvas (after accounting for odd-column stagger).
*/
fun originOffset(hexSize: Float): Pair<Float, Float> =
hexSize to hexSize
fun pixelToCell(px: Float, py: Float, hexSize: Float): Pair<Int, Int> {
val (ox, oy) = originOffset(hexSize)
val x = px - ox
val y = py - oy
/* Convert pixel -> axial (flat top). */
val q = (2f / 3f * x) / hexSize
val r = (-1f / 3f * x + sqrt3 / 3f * y) / hexSize
val (rq, rr) = axialRound(q, r)
/* Axial -> odd-q offset (matches cellCenter formula). */
val col = rq
val row = rr + (rq - (rq and 1)) / 2
return col to row
}
private fun axialRound(q: Float, r: Float): Pair<Int, Int> {
val s = -q - r
var rq = q.roundToInt()
var rr = r.roundToInt()
val rs = s.roundToInt()
val qDiff = abs(rq - q)
val rDiff = abs(rr - r)
val sDiff = abs(rs - s)
when {
qDiff > rDiff && qDiff > sDiff ->
rq = -rr - rs
rDiff > sDiff ->
rr = -rq - rs
}
return rq to rr
}
/**
* Six vertices of a flat-top hex centered at [cx], [cy].
*/
fun hexVertices(cx: Float, cy: Float, hexSize: Float): List<Pair<Float, Float>> {
val vertices = ArrayList<Pair<Float, Float>>(6)
for (i in 0 until 6) {
val angle = (PI / 180.0) * (60.0 * i)
val vx = cx + hexSize * cos(angle).toFloat()
val vy = cy + hexSize * sin(angle).toFloat()
vertices.add(vx to vy)
}
return vertices
}
}
@@ -0,0 +1,179 @@
/*
* 💣 Mines 💣
* Copyright (C) 2025 Stefan Oltmann
* https://github.com/StefanOltmann/mines
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package de.stefan_oltmann.mines.model
import kotlin.random.Random
class Minefield(
val config: GameConfig,
val seed: Int,
val matrix: Array<Array<CellType>>
) {
val width
get() = config.mapWidth
val height
get() = config.mapHeight
fun getCellType(x: Int, y: Int): CellType =
matrix[x][y]
fun isMine(x: Int, y: Int): Boolean =
matrix[x][y] == CellType.MINE
companion object {
fun create(
config: GameConfig,
seed: Int
): Minefield =
Minefield(
config = config,
seed = seed,
matrix = createMatrix(
width = config.mapWidth,
height = config.mapHeight,
mineCount = config.mineCount,
seed = seed
)
)
private fun createMatrix(
width: Int,
height: Int,
mineCount: Int,
seed: Int
): Array<Array<CellType>> {
val matrix = createEmptyMatrix(width, height)
placeMines(matrix, width, height, mineCount, seed)
placeCounts(matrix, width, height)
return matrix
}
private fun createEmptyMatrix(width: Int, height: Int): Array<Array<CellType>> =
Array(width) {
Array(height) {
CellType.EMPTY
}
}
/* 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,
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++
}
}
}
private fun placeCounts(
matrix: Array<Array<CellType>>,
width: Int,
height: Int
) {
for (x in 0 until width) {
for (y in 0 until height) {
/* Minefields stay as they are. */
if (matrix[x][y] == CellType.MINE)
continue
val mineCount = countMinesInAdjacentCells(matrix, x, y)
matrix[x][y] = CellType.ofMineCount(mineCount)
}
}
}
private fun countMinesInAdjacentCells(
matrix: Array<Array<CellType>>,
x: Int,
y: Int
): Int {
var count = 0
forEachAdjacentCell(x, y, matrix.size, matrix[x].size) { adjX, adjY ->
if (matrix[adjX][adjY] == CellType.MINE)
count++
}
return count
}
}
}
@@ -0,0 +1,67 @@
/*
* 💣 Mines 💣
* Copyright (C) 2025 Stefan Oltmann
* https://github.com/StefanOltmann/mines
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package de.stefan_oltmann.mines.model
/**
* Flat-top hex neighbors using odd-q offset coordinates
* (odd columns are shoved down).
*
* See https://www.redblobgames.com/grids/hexagons/#neighbors-offset
*/
fun directionsOfAdjacentCells(x: Int, y: Int): List<Pair<Int, Int>> =
if (x and 1 == 0) {
/* Even column */
listOf(
1 to 0,
1 to -1,
0 to -1,
-1 to -1,
-1 to 0,
0 to 1
)
} else {
/* Odd column */
listOf(
1 to 1,
1 to 0,
0 to -1,
-1 to 0,
-1 to 1,
0 to 1
)
}
fun forEachAdjacentCell(
x: Int,
y: Int,
width: Int,
height: Int,
action: (adjX: Int, adjY: Int) -> Unit
) {
for ((dx, dy) in directionsOfAdjacentCells(x, y)) {
val adjX = x + dx
val adjY = y + dy
if (adjX in 0 until width && adjY in 0 until height)
action(adjX, adjY)
}
}
@@ -0,0 +1,72 @@
/*
* 💣 Mines 💣
* Copyright (C) 2025 Stefan Oltmann
* https://github.com/StefanOltmann/mines
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package de.stefan_oltmann.mines.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredWidthIn
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import de.stefan_oltmann.mines.ui.theme.colorForeground
@Composable
fun AppFooter(
fontFamily: FontFamily
) {
val uriHandler = LocalUriHandler.current
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.height(32.dp)
.background(Color.Black)
.fillMaxWidth()
.padding(
horizontal = 2.dp
)
.noRippleClickable {
uriHandler.openUri("https://stefan-oltmann.de")
}
) {
Text(
text = "made by Stefan Oltmann",
color = colorForeground,
fontFamily = fontFamily,
fontSize = 20.sp,
textAlign = TextAlign.Center,
modifier = Modifier.requiredWidthIn(min = 180.dp)
)
}
}
@@ -0,0 +1,69 @@
package de.stefan_oltmann.mines.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import de.stefan_oltmann.mines.ui.theme.colorBackground
import de.stefan_oltmann.mines.ui.theme.defaultRoundedCornerShape
import de.stefan_oltmann.mines.ui.theme.doublePadding
import kotlinx.coroutines.delay
@Composable
fun DelayedGameOverText(
text: String,
color: Color,
fontFamily: FontFamily
) {
var showText by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
delay(500)
showText = true
}
AnimatedVisibility(
visible = showText,
enter = fadeIn(animationSpec = tween(durationMillis = 1500))
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier
.background(
color = colorBackground,
shape = defaultRoundedCornerShape
)
.doublePadding()
) {
Text(
text = text,
color = color,
fontSize = 32.sp,
fontWeight = FontWeight.Bold,
fontFamily = fontFamily
)
}
}
}
@@ -0,0 +1,31 @@
package de.stefan_oltmann.mines.ui
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.text.font.FontFamily
import de.stefan_oltmann.mines.ui.lottie.ExplosionLottieImage
import de.stefan_oltmann.mines.ui.theme.colorCardBorderGameOver
import io.github.alexzhirkevich.compottie.LottieComposition
@Composable
fun GameOverOverlay(
explosionLottieComposition: LottieComposition,
fontFamily: FontFamily
) {
Box(
contentAlignment = Alignment.Center
) {
ExplosionLottieImage(
explosionLottieComposition = explosionLottieComposition,
)
DelayedGameOverText(
text = "game over",
color = colorCardBorderGameOver,
fontFamily = fontFamily
)
}
}
@@ -0,0 +1,435 @@
/*
* 💣 Mines 💣
* Copyright (C) 2025 Stefan Oltmann
* https://github.com/StefanOltmann/mines
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package de.stefan_oltmann.mines.ui
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import de.stefan_oltmann.mines.LONG_PRESS_TIMEOUT_MS
import de.stefan_oltmann.mines.addRightClickListener
import de.stefan_oltmann.mines.model.CellType
import de.stefan_oltmann.mines.model.GameConfig
import de.stefan_oltmann.mines.model.GameState
import de.stefan_oltmann.mines.model.HexGeometry
import de.stefan_oltmann.mines.ui.theme.colorCardBackground
import de.stefan_oltmann.mines.ui.theme.colorCellBorder
import de.stefan_oltmann.mines.ui.theme.colorCellHidden
import de.stefan_oltmann.mines.ui.theme.colorCellHiddenPressed
import de.stefan_oltmann.mines.ui.theme.colorFiveAdjacentMines
import de.stefan_oltmann.mines.ui.theme.colorForeground
import de.stefan_oltmann.mines.ui.theme.colorFourAdjacentMines
import de.stefan_oltmann.mines.ui.theme.colorMine
import de.stefan_oltmann.mines.ui.theme.colorOneAdjacentMine
import de.stefan_oltmann.mines.ui.theme.colorSixAdjacentMines
import de.stefan_oltmann.mines.ui.theme.colorThreeAdjacentMines
import de.stefan_oltmann.mines.ui.theme.colorTwoAdjacentMines
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.sin
@Composable
fun MinefieldCanvas(
gameState: GameState,
gameConfig: MutableState<GameConfig>,
redrawState: MutableState<Int>,
fontFamily: FontFamily,
hit: (Int, Int) -> Unit,
flag: (Int, Int) -> Unit
) {
val textMeasurer = rememberTextMeasurer()
val density = LocalDensity.current.density
val cellSize = gameConfig.value.cellSize
/* cellSize setting maps to hex radius in density-independent pixels. */
val hexSize = remember(cellSize, density) {
cellSize * density * 0.55f
}
val (boardWidthPx, boardHeightPx) = remember(hexSize, gameState.minefield.width, gameState.minefield.height) {
HexGeometry.boardSize(
cols = gameState.minefield.width,
rows = gameState.minefield.height,
hexSize = hexSize
)
}
val (originX, originY) = remember(hexSize) {
HexGeometry.originOffset(hexSize)
}
val pressedPosition = remember { mutableStateOf<IntOffset?>(null) }
fun resolveCell(position: Offset): IntOffset? {
val (col, row) = HexGeometry.pixelToCell(position.x, position.y, hexSize)
if (col !in 0 until gameState.minefield.width)
return null
if (row !in 0 until gameState.minefield.height)
return null
return IntOffset(col, row)
}
Canvas(
modifier = Modifier
.size(
width = (boardWidthPx / density).dp,
height = (boardHeightPx / density).dp
)
.pointerInput(hexSize, gameState.minefield.width, gameState.minefield.height) {
detectTapGesturesMod(
onTap = { position ->
val cell = resolveCell(position) ?: return@detectTapGesturesMod
hit(cell.x, cell.y)
redrawState.value += 1
},
onLongPress = { position ->
val cell = resolveCell(position) ?: return@detectTapGesturesMod
flag(cell.x, cell.y)
pressedPosition.value = null
redrawState.value += 1
},
onPress = { position ->
pressedPosition.value = resolveCell(position)
tryAwaitRelease()
pressedPosition.value = null
},
longPressTimeoutMillis = LONG_PRESS_TIMEOUT_MS
)
}
.addRightClickListener(hexSize) { offset ->
val cell = resolveCell(offset) ?: return@addRightClickListener
flag(cell.x, cell.y)
redrawState.value += 1
}
) {
redrawState.value
val drawRadius = hexSize * 0.92f
for (x in 0 until gameState.minefield.width) {
for (y in 0 until gameState.minefield.height) {
val (cxRel, cyRel) = HexGeometry.cellCenter(x, y, hexSize)
val center = Offset(originX + cxRel, originY + cyRel)
if (gameState.isRevealed(x, y)) {
val cellType = gameState.minefield.getCellType(x, y)
drawHexCell(
center = center,
hexSize = drawRadius,
fill = colorCardBackground,
border = colorCellBorder
)
drawRevealedContent(
cellType = cellType,
textMeasurer = textMeasurer,
fontFamily = fontFamily,
center = center,
hexSize = drawRadius
)
} else {
val pressedPositionValue = pressedPosition.value
val pressed =
pressedPositionValue != null &&
pressedPositionValue.x == x &&
pressedPositionValue.y == y
drawHexCell(
center = center,
hexSize = drawRadius,
fill = when {
pressed -> colorCellHiddenPressed
else -> colorCellHidden
},
border = colorCellBorder
)
if (gameState.isFlagged(x, y))
drawFlag(
center = center,
hexSize = drawRadius
)
}
}
}
}
}
private fun DrawScope.drawHexCell(
center: Offset,
hexSize: Float,
fill: Color,
border: Color
) {
val path = hexPath(center, hexSize)
drawPath(path = path, color = fill, style = Fill)
drawPath(path = path, color = border, style = Stroke(width = 1.5f))
}
private fun hexPath(center: Offset, hexSize: Float): Path {
val vertices = HexGeometry.hexVertices(center.x, center.y, hexSize)
return Path().apply {
val first = vertices.first()
moveTo(first.first, first.second)
for (i in 1 until vertices.size)
lineTo(vertices[i].first, vertices[i].second)
close()
}
}
private fun DrawScope.drawRevealedContent(
cellType: CellType,
textMeasurer: TextMeasurer,
fontFamily: FontFamily,
center: Offset,
hexSize: Float,
) {
when (cellType) {
CellType.MINE ->
drawMine(center = center, hexSize = hexSize)
CellType.ONE ->
drawNumber(
textMeasurer = textMeasurer,
number = 1,
color = colorOneAdjacentMine,
fontFamily = fontFamily,
center = center
)
CellType.TWO ->
drawNumber(
textMeasurer = textMeasurer,
number = 2,
color = colorTwoAdjacentMines,
fontFamily = fontFamily,
center = center
)
CellType.THREE ->
drawNumber(
textMeasurer = textMeasurer,
number = 3,
color = colorThreeAdjacentMines,
fontFamily = fontFamily,
center = center
)
CellType.FOUR ->
drawNumber(
textMeasurer = textMeasurer,
number = 4,
color = colorFourAdjacentMines,
fontFamily = fontFamily,
center = center
)
CellType.FIVE ->
drawNumber(
textMeasurer = textMeasurer,
number = 5,
color = colorFiveAdjacentMines,
fontFamily = fontFamily,
center = center
)
CellType.SIX ->
drawNumber(
textMeasurer = textMeasurer,
number = 6,
color = colorSixAdjacentMines,
fontFamily = fontFamily,
center = center
)
CellType.EMPTY -> Unit
}
}
private fun DrawScope.drawNumber(
textMeasurer: TextMeasurer,
number: Number,
color: Color,
fontFamily: FontFamily,
center: Offset,
) {
val text = number.toString()
val style = TextStyle.Default.copy(
color = color,
fontFamily = fontFamily,
fontWeight = FontWeight.Bold,
fontSize = 20.sp
)
val textLayout = textMeasurer.measure(text, style)
val topLeft = Offset(
center.x - textLayout.size.width / 2f,
center.y - textLayout.size.height / 2f
)
drawText(
textMeasurer = textMeasurer,
text = text,
style = style,
topLeft = topLeft
)
}
private fun DrawScope.drawMine(
center: Offset,
hexSize: Float,
) {
val radius = hexSize / 4f
drawCircle(
color = colorMine,
radius = radius,
center = center
)
val lineLength = radius * 1.5f
val angles = listOf(0f, 45f, 90f, 135f, 180f, 225f, 270f, 315f)
val explosionRaysPath = Path().apply {
angles.forEach { angle ->
val radians = angle * (PI.toFloat() / 180f)
val endX = center.x + lineLength * cos(radians)
val endY = center.y + lineLength * sin(radians)
moveTo(center.x, center.y)
lineTo(endX, endY)
}
}
drawPath(
path = explosionRaysPath,
color = colorMine,
style = Stroke(width = 3f)
)
}
private fun DrawScope.drawFlag(
center: Offset,
hexSize: Float,
) {
val size = Size(hexSize * 1.2f, hexSize * 1.2f)
val topLeft = Offset(center.x - size.width / 2f, center.y - size.height / 2f)
val poleHeight = size.height * 0.5f
val poleWidth = size.width * 0.1f
val flagWidth = size.width * 0.25f
val flagHeight = size.height * 0.25f
val centerX = topLeft.x + size.width / 2
val centerY = topLeft.y + size.height / 2
val poleStartX = centerX - (poleWidth + flagWidth) / 2
val poleStartY = centerY - poleHeight / 2
val poleEndY = centerY + poleHeight / 2
val flagStartX = poleStartX + poleWidth
val flagPath = Path().apply {
moveTo(poleStartX, poleStartY)
lineTo(poleStartX, poleEndY)
lineTo(poleStartX + poleWidth, poleEndY)
lineTo(poleStartX + poleWidth, poleStartY)
moveTo(flagStartX, poleStartY)
lineTo(flagStartX + flagWidth, poleStartY)
lineTo(flagStartX + flagWidth * 0.6f, poleStartY + flagHeight * 0.5f)
lineTo(flagStartX + flagWidth, poleStartY + flagHeight)
lineTo(flagStartX, poleStartY + flagHeight)
close()
}
drawPath(
path = flagPath,
color = colorForeground
)
}
@@ -0,0 +1,66 @@
/*
* 💣 Mines 💣
* Copyright (C) 2025 Stefan Oltmann
* https://github.com/StefanOltmann/mines
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package de.stefan_oltmann.mines.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import de.stefan_oltmann.mines.FONT_SIZE
import de.stefan_oltmann.mines.ui.theme.colorForeground
import de.stefan_oltmann.mines.ui.theme.defaultSpacing
private val backgroundColor = Color(0xFF28292A)
@Composable
fun PlusVersionButton(
fontFamily: FontFamily,
onClick: () -> Unit
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier
.background(backgroundColor, RoundedCornerShape(4.dp))
.height(32.dp)
.padding(horizontal = defaultSpacing)
.noRippleClickable(onClick)
) {
Text(
text = "Mines+",
fontFamily = fontFamily,
fontSize = FONT_SIZE.sp,
color = colorForeground,
maxLines = 1,
modifier = Modifier.offset(y = -1.dp)
)
}
}
@@ -0,0 +1,325 @@
/*
* 💣 Mines 💣
* Copyright (C) 2025 Stefan Oltmann
* https://github.com/StefanOltmann/mines
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package de.stefan_oltmann.mines.ui
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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.widthIn
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
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_LONG_SIDE
import de.stefan_oltmann.mines.MIN_CELL_SIZE
import de.stefan_oltmann.mines.MIN_LONG_SIDE
import de.stefan_oltmann.mines.MIN_MINE_COUNT
import de.stefan_oltmann.mines.model.GameConfig
import de.stefan_oltmann.mines.model.clampMineCount
import de.stefan_oltmann.mines.model.maxPlaceableMines
import de.stefan_oltmann.mines.ui.icons.IconCancel
import de.stefan_oltmann.mines.ui.icons.IconCheck
import de.stefan_oltmann.mines.ui.icons.IconHeight
import de.stefan_oltmann.mines.ui.icons.IconMines
import de.stefan_oltmann.mines.ui.icons.IconWidth
import de.stefan_oltmann.mines.ui.icons.IconZoom
import de.stefan_oltmann.mines.ui.theme.HalfSpacer
import de.stefan_oltmann.mines.ui.theme.buttonSize
import de.stefan_oltmann.mines.ui.theme.colorCardBackground
import de.stefan_oltmann.mines.ui.theme.colorCardBorder
import de.stefan_oltmann.mines.ui.theme.colorCellHidden
import de.stefan_oltmann.mines.ui.theme.colorForeground
import de.stefan_oltmann.mines.ui.theme.defaultRoundedCornerShape
import de.stefan_oltmann.mines.ui.theme.defaultSpacing
import de.stefan_oltmann.mines.ui.theme.doublePadding
import de.stefan_oltmann.mines.ui.theme.sliderColors
@Composable
fun SettingsDialog(
gameConfig: GameConfig,
fontFamily: FontFamily,
onCancel: () -> Unit,
onConfirm: (GameConfig) -> Unit
) {
val cellSize = remember { mutableStateOf(gameConfig.cellSize.toFloat()) }
val mapWidth = remember { mutableStateOf(gameConfig.mapWidth.toFloat()) }
val mapHeight = remember { mutableStateOf(gameConfig.mapHeight.toFloat()) }
val mineCount = remember { mutableStateOf(gameConfig.mineCount.toFloat()) }
fun clampMinesToBoard() {
mineCount.value = clampMineCount(
mineCount = mineCount.value.toInt(),
mapWidth = mapWidth.value.toInt(),
mapHeight = mapHeight.value.toInt()
).toFloat()
}
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.2F))
.noRippleClickable(onClick = onCancel)
) {
Card(
colors = CardDefaults.cardColors().copy(
containerColor = colorCardBackground
),
shape = defaultRoundedCornerShape,
border = BorderStroke(1.dp, colorCardBorder),
modifier = Modifier
.widthIn(max = 400.dp)
.doublePadding()
.noRippleClickable {
/* Catch all clicks */
}
) {
Column(
verticalArrangement = Arrangement.spacedBy(defaultSpacing),
modifier = Modifier.doublePadding()
) {
Row(
horizontalArrangement = Arrangement.spacedBy(defaultSpacing),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = defaultSpacing)
) {
Icon(
imageVector = IconZoom,
contentDescription = null,
tint = colorForeground
)
Slider(
value = cellSize.value,
onValueChange = {
cellSize.value = it
},
valueRange = MIN_CELL_SIZE.toFloat()..MAX_CELL_SIZE.toFloat(),
colors = sliderColors,
modifier = Modifier.weight(1F)
)
Text(
text = cellSize.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,
modifier = Modifier.padding(horizontal = defaultSpacing)
) {
Icon(
imageVector = IconWidth,
contentDescription = null,
tint = colorForeground
)
Slider(
value = mapWidth.value,
onValueChange = {
mapWidth.value = it
clampMinesToBoard()
},
valueRange = MIN_LONG_SIDE.toFloat()..MAX_LONG_SIDE.toFloat(),
colors = sliderColors,
modifier = Modifier.weight(1F)
)
Text(
text = mapWidth.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,
modifier = Modifier.padding(horizontal = defaultSpacing)
) {
Icon(
imageVector = IconHeight,
contentDescription = null,
tint = colorForeground
)
Slider(
value = mapHeight.value,
onValueChange = {
mapHeight.value = it
clampMinesToBoard()
},
valueRange = MIN_LONG_SIDE.toFloat()..MAX_LONG_SIDE.toFloat(),
colors = sliderColors,
modifier = Modifier.weight(1F)
)
Text(
text = mapHeight.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,
modifier = Modifier.padding(horizontal = defaultSpacing)
) {
Icon(
imageVector = IconMines,
contentDescription = null,
tint = colorForeground
)
Slider(
value = mineCount.value.coerceIn(
MIN_MINE_COUNT.toFloat(),
maxPlaceableMines(
mapWidth.value.toInt(),
mapHeight.value.toInt()
).toFloat()
),
onValueChange = {
mineCount.value = it
},
valueRange = MIN_MINE_COUNT.toFloat()..maxPlaceableMines(
mapWidth.value.toInt(),
mapHeight.value.toInt()
).toFloat(),
colors = sliderColors,
modifier = Modifier.weight(1F)
)
Text(
text = mineCount.value.toInt().toString(),
fontFamily = fontFamily,
color = colorForeground,
fontSize = FONT_SIZE.sp,
textAlign = TextAlign.Right,
modifier = Modifier.widthIn(min = 20.dp)
)
}
HalfSpacer()
HorizontalDivider(
thickness = 1.dp
)
HalfSpacer()
Row(
horizontalArrangement = Arrangement.spacedBy(defaultSpacing)
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.height(buttonSize)
.weight(0.5f)
.background(colorCellHidden, defaultRoundedCornerShape)
.noRippleClickable(onCancel)
) {
Icon(
imageVector = IconCancel,
contentDescription = null,
tint = Color.Red
)
}
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.height(buttonSize)
.weight(0.5f)
.background(colorCellHidden, defaultRoundedCornerShape)
.noRippleClickable {
val width = mapWidth.value.toInt()
val height = mapHeight.value.toInt()
onConfirm(
GameConfig(
cellSize = cellSize.value.toInt(),
mapWidth = width,
mapHeight = height,
mineCount = clampMineCount(
mineCount = mineCount.value.toInt(),
mapWidth = width,
mapHeight = height
)
)
)
}
) {
Icon(
imageVector = IconCheck,
contentDescription = null,
tint = Color.Green
)
}
}
}
}
}
}
@@ -0,0 +1,74 @@
/*
* 💣 Mines 💣
* Copyright (C) 2025 Stefan Oltmann
* https://github.com/StefanOltmann/mines
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package de.stefan_oltmann.mines.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import de.stefan_oltmann.mines.FONT_SIZE
import de.stefan_oltmann.mines.ui.icons.IconGitHubSponsors
import de.stefan_oltmann.mines.ui.theme.colorForeground
import de.stefan_oltmann.mines.ui.theme.defaultSpacing
private val backgroundColor = Color(0xFF28292A)
private val heartColor = Color(0xFFEA4AAA)
@Composable
fun SponsorButton(
fontFamily: FontFamily,
onClick: () -> Unit
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier
.background(backgroundColor, RoundedCornerShape(4.dp))
.height(32.dp)
.padding(horizontal = defaultSpacing)
.noRippleClickable(onClick)
) {
Icon(
imageVector = IconGitHubSponsors,
contentDescription = null,
tint = heartColor
)
Text(
text = "Sponsor",
fontFamily = fontFamily,
fontSize = FONT_SIZE.sp,
color = colorForeground,
maxLines = 1,
modifier = Modifier.offset(y = -1.dp)
)
}
}
@@ -0,0 +1,250 @@
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package de.stefan_oltmann.mines.ui
import androidx.compose.foundation.gestures.GestureCancellationException
import androidx.compose.foundation.gestures.PressGestureScope
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.waitForUpOrCancellation
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.AwaitPointerEventScope
import androidx.compose.ui.input.pointer.PointerEventTimeoutCancellationException
import androidx.compose.ui.input.pointer.PointerInputChange
import androidx.compose.ui.input.pointer.PointerInputScope
import androidx.compose.ui.unit.Density
import androidx.compose.ui.util.fastAny
import androidx.compose.ui.util.fastForEach
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
/*
* Copied from androidx.compose.foundation.gestures.TapGestureDetector
* and modified for custom long press millis
*/
@Suppress("warnings", "all")
internal suspend fun PointerInputScope.detectTapGesturesMod(
onDoubleTap: ((Offset) -> Unit)? = null,
onLongPress: ((Offset) -> Unit)? = null,
onPress: suspend PressGestureScope.(Offset) -> Unit = NoPressGesture,
onTap: ((Offset) -> Unit)? = null,
longPressTimeoutMillis: Long
) = coroutineScope {
val pressScope = PressGestureScopeImpl(this@detectTapGesturesMod)
awaitEachGesture {
val down = awaitFirstDown()
down.consume()
launch {
pressScope.reset()
}
if (onPress !== NoPressGesture)
launch {
pressScope.onPress(down.position)
}
val longPressTimeout = onLongPress?.let {
longPressTimeoutMillis
} ?: (Long.MAX_VALUE / 2)
var upOrCancel: PointerInputChange? = null
try {
upOrCancel = withTimeout(longPressTimeout) {
waitForUpOrCancellation()
}
if (upOrCancel == null) {
launch {
pressScope.cancel()
}
} else {
upOrCancel.consume()
launch {
pressScope.release()
}
}
} catch (_: PointerEventTimeoutCancellationException) {
onLongPress?.invoke(down.position)
consumeUntilUp()
launch {
pressScope.release()
}
}
if (upOrCancel != null) {
if (onDoubleTap == null) {
onTap?.invoke(upOrCancel.position)
} else {
val secondDown = awaitSecondDown(upOrCancel)
if (secondDown == null) {
onTap?.invoke(upOrCancel.position)
} else {
launch {
pressScope.reset()
}
if (onPress !== NoPressGesture) {
launch { pressScope.onPress(secondDown.position) }
}
try {
withTimeout(longPressTimeout) {
val secondUp = waitForUpOrCancellation()
if (secondUp != null) {
secondUp.consume()
launch {
pressScope.release()
}
onDoubleTap(secondUp.position)
} else {
launch {
pressScope.cancel()
}
onTap?.invoke(upOrCancel.position)
}
}
} catch (e: PointerEventTimeoutCancellationException) {
onTap?.invoke(upOrCancel.position)
onLongPress?.invoke(secondDown.position)
consumeUntilUp()
launch {
pressScope.release()
}
}
}
}
}
}
}
@Suppress("warnings", "all")
private class PressGestureScopeImpl(
density: Density
) : PressGestureScope, Density by density {
private var isReleased = false
private var isCanceled = false
private val mutex = Mutex(locked = false)
/**
* Called when a gesture has been canceled.
*/
fun cancel() {
isCanceled = true
mutex.unlock()
}
/**
* Called when all pointers are up.
*/
fun release() {
isReleased = true
mutex.unlock()
}
/**
* Called when a new gesture has started.
*/
suspend fun reset() {
mutex.lock()
isReleased = false
isCanceled = false
}
override suspend fun awaitRelease() {
if (!tryAwaitRelease()) {
throw GestureCancellationException("The press gesture was canceled.")
}
}
override suspend fun tryAwaitRelease(): Boolean {
if (!isReleased && !isCanceled) {
mutex.lock()
mutex.unlock()
}
return isReleased
}
}
/**
* Consumes all pointer events until nothing is pressed and then returns. This method assumes
* that something is currently pressed.
*/
private suspend fun AwaitPointerEventScope.consumeUntilUp() {
do {
val event = awaitPointerEvent()
event.changes.fastForEach { it.consume() }
} while (event.changes.fastAny { it.pressed })
}
private suspend fun AwaitPointerEventScope.awaitSecondDown(
firstUp: PointerInputChange
): PointerInputChange? = withTimeoutOrNull(viewConfiguration.doubleTapTimeoutMillis) {
val minUptime = firstUp.uptimeMillis + viewConfiguration.doubleTapMinTimeMillis
var change: PointerInputChange
do {
change = awaitFirstDown()
} while (change.uptimeMillis < minUptime)
change
}
private val NoPressGesture: suspend PressGestureScope.(Offset) -> Unit = {}
@@ -0,0 +1,163 @@
/*
* 💣 Mines 💣
* Copyright (C) 2025 Stefan Oltmann
* https://github.com/StefanOltmann/mines
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package de.stefan_oltmann.mines.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.requiredWidthIn
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import de.stefan_oltmann.mines.FONT_SIZE
import de.stefan_oltmann.mines.isDesktop
import de.stefan_oltmann.mines.ui.icons.IconFlag
import de.stefan_oltmann.mines.ui.icons.IconRestart
import de.stefan_oltmann.mines.ui.icons.IconSettings
import de.stefan_oltmann.mines.ui.icons.IconTimer
import de.stefan_oltmann.mines.ui.theme.DefaultSpacer
import de.stefan_oltmann.mines.ui.theme.DoubleSpacer
import de.stefan_oltmann.mines.ui.theme.HalfSpacer
import de.stefan_oltmann.mines.ui.theme.buttonSize
import de.stefan_oltmann.mines.ui.theme.colorForeground
@Composable
fun Toolbar(
highlightRestartButton: Boolean,
elapsedSeconds: Long,
remainingFlagsCount: Int,
fontFamily: FontFamily,
showSettings: () -> Unit,
restartGame: () -> Unit
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier.requiredWidthIn(min = 288.dp)
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(buttonSize)
.noRippleClickable(onClick = restartGame)
) {
Icon(
imageVector = IconRestart,
contentDescription = null,
tint = if (highlightRestartButton)
Color.Yellow
else
colorForeground
)
}
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(buttonSize)
.noRippleClickable(onClick = showSettings)
) {
Icon(
imageVector = IconSettings,
contentDescription = null,
tint = colorForeground
)
}
DoubleSpacer()
Icon(
imageVector = IconTimer,
contentDescription = null,
tint = colorForeground
)
HalfSpacer()
Text(
text = elapsedSeconds.toString(),
fontFamily = fontFamily,
color = colorForeground,
fontSize = FONT_SIZE.sp,
textAlign = TextAlign.Right,
modifier = Modifier.widthIn(min = 20.dp)
)
DoubleSpacer()
Icon(
imageVector = IconFlag,
contentDescription = null,
tint = colorForeground
)
HalfSpacer()
Text(
text = remainingFlagsCount.toString(),
fontFamily = fontFamily,
color = colorForeground,
fontSize = FONT_SIZE.sp,
textAlign = TextAlign.Right,
modifier = Modifier.widthIn(min = 20.dp)
)
if (isDesktop) {
DoubleSpacer()
val uriHandler = LocalUriHandler.current
SponsorButton(
fontFamily = fontFamily,
onClick = {
uriHandler.openUri("https://github.com/sponsors/StefanOltmann")
}
)
DefaultSpacer()
PlusVersionButton(
fontFamily = fontFamily,
onClick = {
if (isDesktop)
uriHandler.openUri("https://apps.microsoft.com/detail/9nd96xcdzrgb")
else
uriHandler.openUri("https://play.google.com/store/apps/details?id=de.stefan_oltmann.mines_plus")
}
)
}
}
}
@@ -0,0 +1,34 @@
/*
* 💣 Mines 💣
* Copyright (C) 2025 Stefan Oltmann
* https://github.com/StefanOltmann/mines
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package de.stefan_oltmann.mines.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
@Composable
fun Modifier.noRippleClickable(onClick: (() -> Unit)): Modifier = this
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() },
onClick = onClick
)
@@ -0,0 +1,91 @@
package de.stefan_oltmann.mines.ui.icons
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
private val fillColor = SolidColor(Color(0xFF777777))
val AppIcon: ImageVector
get() {
if (_AppIcon != null) {
return _AppIcon!!
}
_AppIcon = ImageVector.Builder(
name = "AppIcon",
defaultWidth = 512.dp,
defaultHeight = 512.dp,
viewportWidth = 512f,
viewportHeight = 512f
).apply {
path(fill = fillColor) {
moveTo(256f, 256f)
moveToRelative(-120f, 0f)
arcToRelative(120f, 120f, 0f, isMoreThanHalf = true, isPositiveArc = true, 240f, 0f)
arcToRelative(120f, 120f, 0f, isMoreThanHalf = true, isPositiveArc = true, -240f, 0f)
}
path(
stroke = fillColor,
strokeLineWidth = 50f
) {
moveTo(256f, 227f)
lineTo(256f, 51f)
}
path(
stroke = fillColor,
strokeLineWidth = 50f
) {
moveTo(256f, 256f)
lineTo(400f, 112f)
}
path(
stroke = fillColor,
strokeLineWidth = 50f
) {
moveTo(253f, 256f)
lineTo(461f, 256f)
}
path(
stroke = fillColor,
strokeLineWidth = 50f
) {
moveTo(256f, 256f)
lineTo(400f, 400f)
}
path(
stroke = fillColor,
strokeLineWidth = 50f
) {
moveTo(256f, 253f)
lineTo(256f, 461f)
}
path(
stroke = fillColor,
strokeLineWidth = 50f
) {
moveTo(256f, 256f)
lineTo(112f, 400f)
}
path(
stroke = fillColor,
strokeLineWidth = 50f
) {
moveTo(259f, 256f)
lineTo(51f, 256f)
}
path(
stroke = fillColor,
strokeLineWidth = 50f
) {
moveTo(256f, 256f)
lineTo(112f, 112f)
}
}.build()
return _AppIcon!!
}
@Suppress("ObjectPropertyName")
private var _AppIcon: ImageVector? = null
@@ -0,0 +1,78 @@
/*
* Material Design Icon under Apache 2 License
* taken from https://fonts.google.com/icons
*/
package de.stefan_oltmann.mines.ui.icons
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
val IconCancel: ImageVector
get() {
if (_IconCancel != null) {
return _IconCancel!!
}
_IconCancel = ImageVector.Builder(
name = "IconCancel",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 960f,
viewportHeight = 960f
).apply {
path(fill = SolidColor(Color(0xFF5F6368))) {
moveToRelative(336f, 680f)
lineToRelative(144f, -144f)
lineToRelative(144f, 144f)
lineToRelative(56f, -56f)
lineToRelative(-144f, -144f)
lineToRelative(144f, -144f)
lineToRelative(-56f, -56f)
lineToRelative(-144f, 144f)
lineToRelative(-144f, -144f)
lineToRelative(-56f, 56f)
lineToRelative(144f, 144f)
lineToRelative(-144f, 144f)
lineToRelative(56f, 56f)
close()
moveTo(480f, 880f)
quadToRelative(-83f, 0f, -156f, -31.5f)
reflectiveQuadTo(197f, 763f)
quadToRelative(-54f, -54f, -85.5f, -127f)
reflectiveQuadTo(80f, 480f)
quadToRelative(0f, -83f, 31.5f, -156f)
reflectiveQuadTo(197f, 197f)
quadToRelative(54f, -54f, 127f, -85.5f)
reflectiveQuadTo(480f, 80f)
quadToRelative(83f, 0f, 156f, 31.5f)
reflectiveQuadTo(763f, 197f)
quadToRelative(54f, 54f, 85.5f, 127f)
reflectiveQuadTo(880f, 480f)
quadToRelative(0f, 83f, -31.5f, 156f)
reflectiveQuadTo(763f, 763f)
quadToRelative(-54f, 54f, -127f, 85.5f)
reflectiveQuadTo(480f, 880f)
close()
moveTo(480f, 800f)
quadToRelative(134f, 0f, 227f, -93f)
reflectiveQuadToRelative(93f, -227f)
quadToRelative(0f, -134f, -93f, -227f)
reflectiveQuadToRelative(-227f, -93f)
quadToRelative(-134f, 0f, -227f, 93f)
reflectiveQuadToRelative(-93f, 227f)
quadToRelative(0f, 134f, 93f, 227f)
reflectiveQuadToRelative(227f, 93f)
close()
moveTo(480f, 480f)
close()
}
}.build()
return _IconCancel!!
}
@Suppress("ObjectPropertyName")
private var _IconCancel: ImageVector? = null
@@ -0,0 +1,42 @@
/*
* Material Design Icon under Apache 2 License
* taken from https://fonts.google.com/icons
*/
package de.stefan_oltmann.mines.ui.icons
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
val IconCheck: ImageVector
get() {
if (_IconCheck != null) {
return _IconCheck!!
}
_IconCheck = ImageVector.Builder(
name = "IconCheck",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 960f,
viewportHeight = 960f
).apply {
path(fill = SolidColor(Color(0xFF5F6368))) {
moveTo(382f, 720f)
lineTo(154f, 492f)
lineToRelative(57f, -57f)
lineToRelative(171f, 171f)
lineToRelative(367f, -367f)
lineToRelative(57f, 57f)
lineToRelative(-424f, 424f)
close()
}
}.build()
return _IconCheck!!
}
@Suppress("ObjectPropertyName")
private var _IconCheck: ImageVector? = null
@@ -0,0 +1,76 @@
/*
* Material Design Icon under Apache 2 License
* taken from https://fonts.google.com/icons
*/
package de.stefan_oltmann.mines.ui.icons
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
@Suppress("MagicNumber")
val IconDonate: ImageVector
get() {
if (_IconDonate != null) {
return _IconDonate!!
}
_IconDonate = ImageVector.Builder(
name = "IconDonate",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 960f,
viewportHeight = 960f
).apply {
path(fill = SolidColor(Color(0xFF222222))) {
moveToRelative(480f, 840f)
lineToRelative(-58f, -52f)
quadToRelative(-101f, -91f, -167f, -157f)
reflectiveQuadTo(150f, 512.5f)
quadTo(111f, 460f, 95.5f, 416f)
reflectiveQuadTo(80f, 326f)
quadToRelative(0f, -94f, 63f, -157f)
reflectiveQuadToRelative(157f, -63f)
quadToRelative(52f, 0f, 99f, 22f)
reflectiveQuadToRelative(81f, 62f)
quadToRelative(34f, -40f, 81f, -62f)
reflectiveQuadToRelative(99f, -22f)
quadToRelative(94f, 0f, 157f, 63f)
reflectiveQuadToRelative(63f, 157f)
quadToRelative(0f, 46f, -15.5f, 90f)
reflectiveQuadTo(810f, 512.5f)
quadTo(771f, 565f, 705f, 631f)
reflectiveQuadTo(538f, 788f)
lineToRelative(-58f, 52f)
close()
moveTo(480f, 732f)
quadToRelative(96f, -86f, 158f, -147.5f)
reflectiveQuadToRelative(98f, -107f)
quadToRelative(36f, -45.5f, 50f, -81f)
reflectiveQuadToRelative(14f, -70.5f)
quadToRelative(0f, -60f, -40f, -100f)
reflectiveQuadToRelative(-100f, -40f)
quadToRelative(-47f, 0f, -87f, 26.5f)
reflectiveQuadTo(518f, 280f)
horizontalLineToRelative(-76f)
quadToRelative(-15f, -41f, -55f, -67.5f)
reflectiveQuadTo(300f, 186f)
quadToRelative(-60f, 0f, -100f, 40f)
reflectiveQuadToRelative(-40f, 100f)
quadToRelative(0f, 35f, 14f, 70.5f)
reflectiveQuadToRelative(50f, 81f)
quadToRelative(36f, 45.5f, 98f, 107f)
reflectiveQuadTo(480f, 732f)
close()
moveTo(480f, 459f)
close()
}
}.build()
return _IconDonate!!
}
@Suppress("ObjectPropertyName")
private var _IconDonate: ImageVector? = null
@@ -0,0 +1,54 @@
/*
* Material Design Icon under Apache 2 License
* taken from https://fonts.google.com/icons
*/
package de.stefan_oltmann.mines.ui.icons
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
val IconFlag: ImageVector
get() {
if (_IconFlag != null) {
return _IconFlag!!
}
_IconFlag = ImageVector.Builder(
name = "IconFlag",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 960f,
viewportHeight = 960f
).apply {
path(fill = SolidColor(Color(0xFF5F6368))) {
moveTo(200f, 880f)
verticalLineToRelative(-760f)
horizontalLineToRelative(640f)
lineToRelative(-80f, 200f)
lineToRelative(80f, 200f)
lineTo(280f, 520f)
verticalLineToRelative(360f)
horizontalLineToRelative(-80f)
close()
moveTo(280f, 440f)
horizontalLineToRelative(442f)
lineToRelative(-48f, -120f)
lineToRelative(48f, -120f)
lineTo(280f, 200f)
verticalLineToRelative(240f)
close()
moveTo(280f, 440f)
verticalLineToRelative(-240f)
verticalLineToRelative(240f)
close()
}
}.build()
return _IconFlag!!
}
@Suppress("ObjectPropertyName")
private var _IconFlag: ImageVector? = null
@@ -0,0 +1,65 @@
package de.stefan_oltmann.mines.ui.icons
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
/**
* Taken from https://github.com/simple-icons/simple-icons
* Licensed under MIT
*/
val IconGitHubSponsors: ImageVector
get() {
if (_IconGitHubSponsors != null) {
return _IconGitHubSponsors!!
}
_IconGitHubSponsors = ImageVector.Builder(
name = "IconGitHubSponsors",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f
).apply {
path(fill = SolidColor(Color.Black)) {
moveTo(17.625f, 1.499f)
curveToRelative(-2.32f, 0f, -4.354f, 1.203f, -5.625f, 3.03f)
curveToRelative(-1.271f, -1.827f, -3.305f, -3.03f, -5.625f, -3.03f)
curveTo(3.129f, 1.499f, 0f, 4.253f, 0f, 8.249f)
curveToRelative(0f, 4.275f, 3.068f, 7.847f, 5.828f, 10.227f)
arcToRelative(33.14f, 33.14f, 0f, isMoreThanHalf = false, isPositiveArc = false, 5.616f, 3.876f)
lineToRelative(0.028f, 0.017f)
lineToRelative(0.008f, 0.003f)
lineToRelative(-0.001f, 0.003f)
curveToRelative(0.163f, 0.085f, 0.342f, 0.126f, 0.521f, 0.125f)
curveToRelative(0.179f, 0.001f, 0.358f, -0.041f, 0.521f, -0.125f)
lineToRelative(-0.001f, -0.003f)
lineToRelative(0.008f, -0.003f)
lineToRelative(0.028f, -0.017f)
arcToRelative(33.14f, 33.14f, 0f, isMoreThanHalf = false, isPositiveArc = false, 5.616f, -3.876f)
curveTo(20.932f, 16.096f, 24f, 12.524f, 24f, 8.249f)
curveToRelative(0f, -3.996f, -3.129f, -6.75f, -6.375f, -6.75f)
close()
moveTo(16.706f, 16.774f)
arcToRelative(30.766f, 30.766f, 0f, isMoreThanHalf = false, isPositiveArc = true, -4.703f, 3.316f)
lineToRelative(-0.004f, -0.002f)
lineToRelative(-0.004f, 0.002f)
arcToRelative(30.955f, 30.955f, 0f, isMoreThanHalf = false, isPositiveArc = true, -4.703f, -3.316f)
curveToRelative(-2.677f, -2.307f, -5.047f, -5.298f, -5.047f, -8.523f)
curveToRelative(0f, -2.754f, 2.121f, -4.5f, 4.125f, -4.5f)
curveToRelative(2.06f, 0f, 3.914f, 1.479f, 4.544f, 3.684f)
curveToRelative(0.143f, 0.495f, 0.596f, 0.797f, 1.086f, 0.796f)
curveToRelative(0.49f, 0.001f, 0.943f, -0.302f, 1.085f, -0.796f)
curveToRelative(0.63f, -2.205f, 2.484f, -3.684f, 4.544f, -3.684f)
curveToRelative(2.004f, 0f, 4.125f, 1.746f, 4.125f, 4.5f)
curveToRelative(0f, 3.225f, -2.37f, 6.216f, -5.048f, 8.523f)
close()
}
}.build()
return _IconGitHubSponsors!!
}
@Suppress("ObjectPropertyName")
private var _IconGitHubSponsors: ImageVector? = null
@@ -0,0 +1,68 @@
/*
* Material Design Icon under Apache 2 License
* taken from https://fonts.google.com/icons
*/
package de.stefan_oltmann.mines.ui.icons
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
val IconHeight: ImageVector
get() {
if (_IconHeight != null) {
return _IconHeight!!
}
_IconHeight = ImageVector.Builder(
name = "IconHeight",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 960f,
viewportHeight = 960f
).apply {
path(fill = SolidColor(Color(0xFF5F6368))) {
moveTo(240f, 880f)
quadToRelative(-33f, 0f, -56.5f, -23.5f)
reflectiveQuadTo(160f, 800f)
verticalLineToRelative(-640f)
quadToRelative(0f, -33f, 23.5f, -56.5f)
reflectiveQuadTo(240f, 80f)
horizontalLineToRelative(480f)
quadToRelative(33f, 0f, 56.5f, 23.5f)
reflectiveQuadTo(800f, 160f)
verticalLineToRelative(640f)
quadToRelative(0f, 33f, -23.5f, 56.5f)
reflectiveQuadTo(720f, 880f)
lineTo(240f, 880f)
close()
moveTo(720f, 800f)
verticalLineToRelative(-640f)
lineTo(240f, 160f)
verticalLineToRelative(640f)
horizontalLineToRelative(480f)
close()
moveTo(720f, 160f)
lineTo(240f, 160f)
horizontalLineToRelative(480f)
close()
moveTo(360f, 360f)
horizontalLineToRelative(240f)
lineTo(480f, 240f)
lineTo(360f, 360f)
close()
moveTo(480f, 720f)
lineTo(600f, 600f)
lineTo(360f, 600f)
lineToRelative(120f, 120f)
close()
}
}.build()
return _IconHeight!!
}
@Suppress("ObjectPropertyName")
private var _IconHeight: ImageVector? = null
@@ -0,0 +1,89 @@
package de.stefan_oltmann.mines.ui.icons
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
val IconMines: ImageVector
get() {
if (_IconMines != null) {
return _IconMines!!
}
_IconMines = ImageVector.Builder(
name = "IconMines",
defaultWidth = 512.dp,
defaultHeight = 512.dp,
viewportWidth = 512f,
viewportHeight = 512f
).apply {
path(fill = SolidColor(Color(0xFF000000))) {
moveTo(256f, 256f)
moveToRelative(-120f, 0f)
arcToRelative(120f, 120f, 0f, isMoreThanHalf = true, isPositiveArc = true, 240f, 0f)
arcToRelative(120f, 120f, 0f, isMoreThanHalf = true, isPositiveArc = true, -240f, 0f)
}
path(
stroke = SolidColor(Color(0xFF000000)),
strokeLineWidth = 50f
) {
moveTo(256f, 227f)
lineTo(256f, 51f)
}
path(
stroke = SolidColor(Color(0xFF000000)),
strokeLineWidth = 50f
) {
moveTo(256f, 256f)
lineTo(400f, 112f)
}
path(
stroke = SolidColor(Color(0xFF000000)),
strokeLineWidth = 50f
) {
moveTo(253f, 256f)
lineTo(461f, 256f)
}
path(
stroke = SolidColor(Color(0xFF000000)),
strokeLineWidth = 50f
) {
moveTo(256f, 256f)
lineTo(400f, 400f)
}
path(
stroke = SolidColor(Color(0xFF000000)),
strokeLineWidth = 50f
) {
moveTo(256f, 253f)
lineTo(256f, 461f)
}
path(
stroke = SolidColor(Color(0xFF000000)),
strokeLineWidth = 50f
) {
moveTo(256f, 256f)
lineTo(112f, 400f)
}
path(
stroke = SolidColor(Color(0xFF000000)),
strokeLineWidth = 50f
) {
moveTo(259f, 256f)
lineTo(51f, 256f)
}
path(
stroke = SolidColor(Color(0xFF000000)),
strokeLineWidth = 50f
) {
moveTo(256f, 256f)
lineTo(112f, 112f)
}
}.build()
return _IconMines!!
}
@Suppress("ObjectPropertyName")
private var _IconMines: ImageVector? = null
@@ -0,0 +1,65 @@
/*
* Material Design Icon under Apache 2 License
* taken from https://fonts.google.com/icons
*/
package de.stefan_oltmann.mines.ui.icons
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
val IconRestart: ImageVector
get() {
if (_IconRestart != null) {
return _IconRestart!!
}
_IconRestart = ImageVector.Builder(
name = "IconRestart",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 960f,
viewportHeight = 960f
).apply {
path(fill = SolidColor(Color(0xFF5F6368))) {
moveTo(440f, 838f)
quadToRelative(-121f, -15f, -200.5f, -105.5f)
reflectiveQuadTo(160f, 520f)
quadToRelative(0f, -66f, 26f, -126.5f)
reflectiveQuadTo(260f, 288f)
lineToRelative(57f, 57f)
quadToRelative(-38f, 34f, -57.5f, 79f)
reflectiveQuadTo(240f, 520f)
quadToRelative(0f, 88f, 56f, 155.5f)
reflectiveQuadTo(440f, 758f)
verticalLineToRelative(80f)
close()
moveTo(520f, 838f)
verticalLineToRelative(-80f)
quadToRelative(87f, -16f, 143.5f, -83f)
reflectiveQuadTo(720f, 520f)
quadToRelative(0f, -100f, -70f, -170f)
reflectiveQuadToRelative(-170f, -70f)
horizontalLineToRelative(-3f)
lineToRelative(44f, 44f)
lineToRelative(-56f, 56f)
lineToRelative(-140f, -140f)
lineToRelative(140f, -140f)
lineToRelative(56f, 56f)
lineToRelative(-44f, 44f)
horizontalLineToRelative(3f)
quadToRelative(134f, 0f, 227f, 93f)
reflectiveQuadToRelative(93f, 227f)
quadToRelative(0f, 121f, -79.5f, 211.5f)
reflectiveQuadTo(520f, 838f)
close()
}
}.build()
return _IconRestart!!
}
@Suppress("ObjectPropertyName")
private var _IconRestart: ImageVector? = null
@@ -0,0 +1,78 @@
/*
* Material Design Icon under Apache 2 License
* taken from https://fonts.google.com/icons
*/
package de.stefan_oltmann.mines.ui.icons
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
val IconSettings: ImageVector
get() {
if (_IconSettings != null) {
return _IconSettings!!
}
_IconSettings = ImageVector.Builder(
name = "IconSettings",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 960f,
viewportHeight = 960f
).apply {
path(fill = SolidColor(Color(0xFF5F6368))) {
moveToRelative(370f, 880f)
lineToRelative(-16f, -128f)
quadToRelative(-13f, -5f, -24.5f, -12f)
reflectiveQuadTo(307f, 725f)
lineToRelative(-119f, 50f)
lineTo(78f, 585f)
lineToRelative(103f, -78f)
quadToRelative(-1f, -7f, -1f, -13.5f)
verticalLineToRelative(-27f)
quadToRelative(0f, -6.5f, 1f, -13.5f)
lineTo(78f, 375f)
lineToRelative(110f, -190f)
lineToRelative(119f, 50f)
quadToRelative(11f, -8f, 23f, -15f)
reflectiveQuadToRelative(24f, -12f)
lineToRelative(16f, -128f)
horizontalLineToRelative(220f)
lineToRelative(16f, 128f)
quadToRelative(13f, 5f, 24.5f, 12f)
reflectiveQuadToRelative(22.5f, 15f)
lineToRelative(119f, -50f)
lineToRelative(110f, 190f)
lineToRelative(-103f, 78f)
quadToRelative(1f, 7f, 1f, 13.5f)
verticalLineToRelative(27f)
quadToRelative(0f, 6.5f, -2f, 13.5f)
lineToRelative(103f, 78f)
lineToRelative(-110f, 190f)
lineToRelative(-118f, -50f)
quadToRelative(-11f, 8f, -23f, 15f)
reflectiveQuadToRelative(-24f, 12f)
lineTo(590f, 880f)
lineTo(370f, 880f)
close()
moveTo(482f, 620f)
quadToRelative(58f, 0f, 99f, -41f)
reflectiveQuadToRelative(41f, -99f)
quadToRelative(0f, -58f, -41f, -99f)
reflectiveQuadToRelative(-99f, -41f)
quadToRelative(-59f, 0f, -99.5f, 41f)
reflectiveQuadTo(342f, 480f)
quadToRelative(0f, 58f, 40.5f, 99f)
reflectiveQuadToRelative(99.5f, 41f)
close()
}
}.build()
return _IconSettings!!
}
@Suppress("ObjectPropertyName")
private var _IconSettings: ImageVector? = null
@@ -0,0 +1,79 @@
/*
* Material Design Icon under Apache 2 License
* taken from https://fonts.google.com/icons
*/
package de.stefan_oltmann.mines.ui.icons
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
val IconTimer: ImageVector
get() {
if (_IconTimer != null) {
return _IconTimer!!
}
_IconTimer = ImageVector.Builder(
name = "IconTimer",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 960f,
viewportHeight = 960f
).apply {
path(fill = SolidColor(Color(0xFF5F6368))) {
moveTo(360f, 120f)
verticalLineToRelative(-80f)
horizontalLineToRelative(240f)
verticalLineToRelative(80f)
lineTo(360f, 120f)
close()
moveTo(440f, 560f)
horizontalLineToRelative(80f)
verticalLineToRelative(-240f)
horizontalLineToRelative(-80f)
verticalLineToRelative(240f)
close()
moveTo(480f, 880f)
quadToRelative(-74f, 0f, -139.5f, -28.5f)
reflectiveQuadTo(226f, 774f)
quadToRelative(-49f, -49f, -77.5f, -114.5f)
reflectiveQuadTo(120f, 520f)
quadToRelative(0f, -74f, 28.5f, -139.5f)
reflectiveQuadTo(226f, 266f)
quadToRelative(49f, -49f, 114.5f, -77.5f)
reflectiveQuadTo(480f, 160f)
quadToRelative(62f, 0f, 119f, 20f)
reflectiveQuadToRelative(107f, 58f)
lineToRelative(56f, -56f)
lineToRelative(56f, 56f)
lineToRelative(-56f, 56f)
quadToRelative(38f, 50f, 58f, 107f)
reflectiveQuadToRelative(20f, 119f)
quadToRelative(0f, 74f, -28.5f, 139.5f)
reflectiveQuadTo(734f, 774f)
quadToRelative(-49f, 49f, -114.5f, 77.5f)
reflectiveQuadTo(480f, 880f)
close()
moveTo(480f, 800f)
quadToRelative(116f, 0f, 198f, -82f)
reflectiveQuadToRelative(82f, -198f)
quadToRelative(0f, -116f, -82f, -198f)
reflectiveQuadToRelative(-198f, -82f)
quadToRelative(-116f, 0f, -198f, 82f)
reflectiveQuadToRelative(-82f, 198f)
quadToRelative(0f, 116f, 82f, 198f)
reflectiveQuadToRelative(198f, 82f)
close()
moveTo(480f, 520f)
close()
}
}.build()
return _IconTimer!!
}
@Suppress("ObjectPropertyName")
private var _IconTimer: ImageVector? = null
@@ -0,0 +1,68 @@
/*
* Material Design Icon under Apache 2 License
* taken from https://fonts.google.com/icons
*/
package de.stefan_oltmann.mines.ui.icons
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
val IconWidth: ImageVector
get() {
if (_IconWidth != null) {
return _IconWidth!!
}
_IconWidth = ImageVector.Builder(
name = "IconWidth",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 960f,
viewportHeight = 960f
).apply {
path(fill = SolidColor(Color(0xFF5F6368))) {
moveTo(160f, 800f)
quadToRelative(-33f, 0f, -56.5f, -23.5f)
reflectiveQuadTo(80f, 720f)
verticalLineToRelative(-480f)
quadToRelative(0f, -33f, 23.5f, -56.5f)
reflectiveQuadTo(160f, 160f)
horizontalLineToRelative(640f)
quadToRelative(33f, 0f, 56.5f, 23.5f)
reflectiveQuadTo(880f, 240f)
verticalLineToRelative(480f)
quadToRelative(0f, 33f, -23.5f, 56.5f)
reflectiveQuadTo(800f, 800f)
lineTo(160f, 800f)
close()
moveTo(800f, 240f)
lineTo(160f, 240f)
verticalLineToRelative(480f)
horizontalLineToRelative(640f)
verticalLineToRelative(-480f)
close()
moveTo(160f, 240f)
verticalLineToRelative(480f)
verticalLineToRelative(-480f)
close()
moveTo(360f, 600f)
verticalLineToRelative(-240f)
lineTo(240f, 480f)
lineToRelative(120f, 120f)
close()
moveTo(720f, 480f)
lineTo(600f, 360f)
verticalLineToRelative(240f)
lineToRelative(120f, -120f)
close()
}
}.build()
return _IconWidth!!
}
@Suppress("ObjectPropertyName")
private var _IconWidth: ImageVector? = null
@@ -0,0 +1,56 @@
/*
* Material Design Icon under Apache 2 License
* taken from https://fonts.google.com/icons
*/
package de.stefan_oltmann.mines.ui.icons
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
val IconZoom: ImageVector
get() {
if (_IconZoom != null) {
return _IconZoom!!
}
_IconZoom = ImageVector.Builder(
name = "IconZoom",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 960f,
viewportHeight = 960f
).apply {
path(fill = SolidColor(Color(0xFF5F6368))) {
moveTo(120f, 840f)
verticalLineToRelative(-240f)
horizontalLineToRelative(80f)
verticalLineToRelative(104f)
lineToRelative(124f, -124f)
lineToRelative(56f, 56f)
lineToRelative(-124f, 124f)
horizontalLineToRelative(104f)
verticalLineToRelative(80f)
lineTo(120f, 840f)
close()
moveTo(636f, 380f)
lineTo(580f, 324f)
lineTo(704f, 200f)
lineTo(600f, 200f)
verticalLineToRelative(-80f)
horizontalLineToRelative(240f)
verticalLineToRelative(240f)
horizontalLineToRelative(-80f)
verticalLineToRelative(-104f)
lineTo(636f, 380f)
close()
}
}.build()
return _IconZoom!!
}
@Suppress("ObjectPropertyName")
private var _IconZoom: ImageVector? = null
@@ -0,0 +1,22 @@
package de.stefan_oltmann.mines.ui.lottie
import androidx.compose.foundation.Image
import androidx.compose.runtime.Composable
import io.github.alexzhirkevich.compottie.LottieComposition
import io.github.alexzhirkevich.compottie.rememberLottiePainter
@Composable
fun ConfettiLottieImage(
confettiLottieComposition: LottieComposition
) {
val painter = rememberLottiePainter(
composition = confettiLottieComposition,
speed = 1.3f
)
Image(
painter = painter,
contentDescription = null
)
}
@@ -0,0 +1,24 @@
package de.stefan_oltmann.mines.ui.lottie
import androidx.compose.foundation.Image
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.ColorFilter
import de.stefan_oltmann.mines.ui.theme.colorCardBorderGameOver
import io.github.alexzhirkevich.compottie.LottieComposition
import io.github.alexzhirkevich.compottie.rememberLottiePainter
@Composable
fun ExplosionLottieImage(
explosionLottieComposition: LottieComposition
) {
val painter = rememberLottiePainter(
composition = explosionLottieComposition
)
Image(
painter = painter,
contentDescription = null,
colorFilter = ColorFilter.tint(colorCardBorderGameOver)
)
}
@@ -0,0 +1,64 @@
/*
* 💣 Mines 💣
* Copyright (C) 2025 Stefan Oltmann
* https://github.com/StefanOltmann/mines
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package de.stefan_oltmann.mines.ui.theme
import androidx.compose.material3.SliderColors
import androidx.compose.ui.graphics.Color
val colorForeground = Color(0xFFF8F8F8)
val colorBackground = Color(0xFF111111)
val colorCardBackground = Color(0xFF1D1D1D)
val colorCardBorder = Color(0xFF3A3B3C)
val colorCellHidden = Color(0xFF3D3D3D)
val colorCellHiddenPressed = Color(0xFF777777)
val colorCellBorder = Color(0xFF3F3F3F)
val colorCardBorderGameOver = Color.Red
val colorCardBorderGameWon = Color.Green
val colorMine = Color.Red
val colorExplosion = Color.Red
val colorOneAdjacentMine = Color(0xFF64A8FF)
val colorTwoAdjacentMines = Color(0xFF00C000)
val colorThreeAdjacentMines = Color(0xFFFF6060)
val colorFourAdjacentMines = Color(0xFF3B6EFF)
val colorFiveAdjacentMines = Color(0xFFFF4444)
val colorSixAdjacentMines = Color(0xFF40E0D0)
val colorSevenAdjacentMines = Color(0xFF808080)
val colorEightAdjacentMines = Color(0xFFD0D0D0)
val sliderColors = SliderColors(
thumbColor = colorForeground,
activeTrackColor = colorForeground,
inactiveTrackColor = colorCellHidden,
activeTickColor = colorCellHidden,
inactiveTickColor = colorCellHidden,
/* Unused values */
disabledThumbColor = Color.Red,
disabledActiveTrackColor = Color.Red,
disabledActiveTickColor = Color.Red,
disabledInactiveTrackColor = Color.Red,
disabledInactiveTickColor = Color.Red
)
@@ -0,0 +1,61 @@
/*
* 💣 Mines 💣
* Copyright (C) 2025 Stefan Oltmann
* https://github.com/StefanOltmann/mines
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package de.stefan_oltmann.mines.ui.theme
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
val defaultRoundedCornerShape = RoundedCornerShape(8.dp)
val defaultSpacing = 8.dp
val doubleSpacing = defaultSpacing * 2
val halfSpacing = defaultSpacing / 2
/** Button size as recommended by Material Design */
val buttonSize = 48.dp
fun Modifier.halfPadding() = this.padding(halfSpacing)
fun Modifier.defaultPadding() = this.padding(defaultSpacing)
fun Modifier.doublePadding() = this.padding(doubleSpacing)
@Composable
fun HalfSpacer() = Spacer(Modifier.size(halfSpacing))
@Composable
fun DefaultSpacer() = Spacer(Modifier.size(defaultSpacing))
@Composable
fun DoubleSpacer() = Spacer(Modifier.size(doubleSpacing))
@Composable
fun ColumnScope.FillSpacer() = Spacer(Modifier.weight(1F))
@Composable
fun RowScope.FillSpacer() = Spacer(Modifier.weight(1F))
@@ -0,0 +1,56 @@
/*
* 💣 Mines 💣
* Copyright (C) 2025 Stefan Oltmann
* https://github.com/StefanOltmann/mines
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package de.stefan_oltmann.mines.ui.theme
import androidx.compose.runtime.Composable
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import mines.app.generated.resources.Res
import mines.app.generated.resources.economica_bold
import mines.app.generated.resources.economica_bold_italic
import mines.app.generated.resources.economica_italic
import mines.app.generated.resources.economica_regular
import org.jetbrains.compose.resources.Font
@Composable
fun EconomicaFontFamily(): FontFamily = FontFamily(
Font(
resource = Res.font.economica_regular,
weight = FontWeight.Normal,
style = FontStyle.Normal
),
Font(
resource = Res.font.economica_bold,
weight = FontWeight.Bold,
style = FontStyle.Normal
),
Font(
resource = Res.font.economica_italic,
weight = FontWeight.Normal,
style = FontStyle.Italic
),
Font(
resource = Res.font.economica_bold_italic,
weight = FontWeight.Bold,
style = FontStyle.Italic
)
)
@@ -0,0 +1,57 @@
/*
* 💣 Mines 💣
* Copyright (C) 2025 Stefan Oltmann
* https://github.com/StefanOltmann/mines
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package de.stefan_oltmann.mines.model
import kotlin.test.Test
import kotlin.test.assertEquals
class CellTypeTest {
@Test
fun testCellTypeValues() {
assertEquals(0, CellType.EMPTY.adjacentMineCount)
assertEquals(-1, CellType.MINE.adjacentMineCount)
assertEquals(1, CellType.ONE.adjacentMineCount)
assertEquals(2, CellType.TWO.adjacentMineCount)
assertEquals(3, CellType.THREE.adjacentMineCount)
assertEquals(4, CellType.FOUR.adjacentMineCount)
assertEquals(5, CellType.FIVE.adjacentMineCount)
assertEquals(6, CellType.SIX.adjacentMineCount)
}
@Test
fun testOfMineCount() {
assertEquals(CellType.EMPTY, CellType.ofMineCount(0))
assertEquals(CellType.ONE, CellType.ofMineCount(1))
assertEquals(CellType.TWO, CellType.ofMineCount(2))
assertEquals(CellType.THREE, CellType.ofMineCount(3))
assertEquals(CellType.FOUR, CellType.ofMineCount(4))
assertEquals(CellType.FIVE, CellType.ofMineCount(5))
assertEquals(CellType.SIX, CellType.ofMineCount(6))
/* Invalid / out-of-range counts map to EMPTY */
assertEquals(CellType.EMPTY, CellType.ofMineCount(-1))
assertEquals(CellType.EMPTY, CellType.ofMineCount(7))
assertEquals(CellType.EMPTY, CellType.ofMineCount(8))
assertEquals(CellType.EMPTY, CellType.ofMineCount(9))
}
}
@@ -0,0 +1,41 @@
/*
* 💣 Mines 💣
* Copyright (C) 2025 Stefan Oltmann
* https://github.com/StefanOltmann/mines
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package de.stefan_oltmann.mines.model
import kotlin.test.Test
import kotlin.test.assertEquals
class GameDifficultyTest {
@Test
fun testCalcMineCount() {
/* Test EASY difficulty (10%) */
assertEquals(1, GameDifficulty.EASY.calcMineCount(3, 3)) /* 9 cells * 10% = 0.9, rounded to 1 */
assertEquals(4, GameDifficulty.EASY.calcMineCount(10, 4)) /* 40 cells * 10% = 4 */
/* Test MEDIUM difficulty (15%) */
assertEquals(1, GameDifficulty.MEDIUM.calcMineCount(3, 3)) /* 9 cells * 15% = 1.35, rounded to 1 */
assertEquals(6, GameDifficulty.MEDIUM.calcMineCount(10, 4)) /* 40 cells * 15% = 6 */
/* Test HARD difficulty (20%) */
assertEquals(1, GameDifficulty.HARD.calcMineCount(3, 3)) /* 9 cells * 20% = 1.8, rounded to 1 */
assertEquals(8, GameDifficulty.HARD.calcMineCount(10, 4)) /* 40 cells * 20% = 8 */
}
}
@@ -0,0 +1,73 @@
package de.stefan_oltmann.mines.model
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class HexNeighborsTest {
@Test
fun evenColumnHasSixNeighborOffsets() {
val dirs = directionsOfAdjacentCells(0, 5)
assertEquals(6, dirs.size)
assertEquals(
listOf(
1 to 0,
1 to -1,
0 to -1,
-1 to -1,
-1 to 0,
0 to 1
),
dirs
)
}
@Test
fun oddColumnHasSixNeighborOffsets() {
val dirs = directionsOfAdjacentCells(1, 5)
assertEquals(6, dirs.size)
assertEquals(
listOf(
1 to 1,
1 to 0,
0 to -1,
-1 to 0,
-1 to 1,
0 to 1
),
dirs
)
}
@Test
fun cornerCellHasTwoNeighbors() {
val neighbors = mutableListOf<Pair<Int, Int>>()
forEachAdjacentCell(0, 0, width = 10, height = 10) { x, y ->
neighbors.add(x to y)
}
assertEquals(2, neighbors.size)
assertTrue(neighbors.contains(1 to 0))
assertTrue(neighbors.contains(0 to 1))
}
@Test
fun pixelRoundTripNearCenter() {
val hexSize = 40f
val (cx, cy) = HexGeometry.cellCenter(3, 4, hexSize)
val (ox, oy) = HexGeometry.originOffset(hexSize)
val (col, row) = HexGeometry.pixelToCell(ox + cx, oy + cy, hexSize)
assertEquals(3, col)
assertEquals(4, row)
}
}
@@ -0,0 +1,41 @@
package de.stefan_oltmann.mines.model
import de.stefan_oltmann.mines.MIN_MINE_COUNT
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class MineCountConfigTest {
@Test
fun maxPlaceableMinesLeavesProtectedCenterFree() {
val width = 10
val height = 10
val max = maxPlaceableMines(width, height)
assertTrue(max < width * height)
assertTrue(max >= MIN_MINE_COUNT)
/* Creating a board at the max must succeed without hanging. */
val minefield = Minefield.create(
config = GameConfig(
cellSize = 40,
mapWidth = width,
mapHeight = height,
mineCount = max
),
seed = 1
)
assertEquals(max, minefield.config.mineCount)
}
@Test
fun clampMineCountRespectsBounds() {
assertEquals(MIN_MINE_COUNT, clampMineCount(0, 10, 10))
assertEquals(maxPlaceableMines(10, 10), clampMineCount(9999, 10, 10))
assertEquals(12, clampMineCount(12, 10, 10))
}
}
@@ -0,0 +1,155 @@
/*
* 💣 Mines 💣
* Copyright (C) 2025 Stefan Oltmann
* https://github.com/StefanOltmann/mines
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package de.stefan_oltmann.mines.model
import de.stefan_oltmann.mines.DEFAULT_CELL_SIZE
/**
* Generates and parses ASCII representations of a minefield.
*
* Uses:
* - O for empty fields
* - * for mines
* - Numbers for number fields
*/
object MinefieldAscii {
/**
* Generates an ASCII representation of the given minefield.
*
* @param minefield The minefield to generate ASCII representation for
* @return A string containing the ASCII representation of the minefield
*/
fun toAscii(minefield: Minefield): String =
buildString {
/* Print info */
append(minefield.config.mineCount)
append("|")
append(minefield.config.mapWidth)
append("|")
append(minefield.config.mapHeight)
append("|")
append(minefield.seed)
appendLine()
/* Separator line */
repeat(minefield.config.mapWidth) {
append("-")
}
appendLine()
/* Print matrix */
for (y in 0 until minefield.height) {
for (x in 0 until minefield.width) {
val cellType = minefield.getCellType(x, y)
val asciiChar = getCellTypeChar(cellType)
append(asciiChar)
}
/* Add a new line after each row, except the last one */
if (y < minefield.height - 1)
append('\n')
}
}
/**
* Parses an ASCII representation of a minefield into a Minefield object.
*
* @param ascii The ASCII representation to parse
* @return The parsed Minefield object
*/
fun fromAscii(ascii: String): Minefield {
val lines = ascii.trim().lines()
/* Parse the first line to get metadata */
val metadataParts = lines[0].split("|")
val mineCount = metadataParts[0].toInt()
val width = metadataParts[1].toInt()
val height = metadataParts[2].toInt()
val seed = metadataParts[3].toInt()
/* Skip the separator line */
val matrixLines = lines.drop(2)
/* Create the matrix */
val matrix = Array(width) { x ->
Array(height) { y ->
val asciiChar = matrixLines[y][x]
getCellTypeFromChar(asciiChar)
}
}
/* Create the config */
val config = GameConfig(
cellSize = DEFAULT_CELL_SIZE, /* Default value, not stored in ASCII */
mapWidth = width,
mapHeight = height,
mineCount = mineCount
)
return Minefield(config, seed, matrix)
}
/**
* Returns the ASCII character representation of a cell type.
*
* @param cellType The cell type to convert
* @return The ASCII character representation
*/
private fun getCellTypeChar(cellType: CellType): Char =
when (cellType) {
CellType.EMPTY -> 'O'
CellType.MINE -> '*'
CellType.ONE -> '1'
CellType.TWO -> '2'
CellType.THREE -> '3'
CellType.FOUR -> '4'
CellType.FIVE -> '5'
CellType.SIX -> '6'
}
/**
* Returns the cell type for a given ASCII character.
*
* @param char The ASCII character to convert
* @return The corresponding cell type
*/
private fun getCellTypeFromChar(char: Char): CellType =
when (char) {
'O' -> CellType.EMPTY
'*' -> CellType.MINE
'1' -> CellType.ONE
'2' -> CellType.TWO
'3' -> CellType.THREE
'4' -> CellType.FOUR
'5' -> CellType.FIVE
'6' -> CellType.SIX
else -> throw IllegalArgumentException("Unknown cell type character: $char")
}
}
@@ -0,0 +1,78 @@
/*
* 💣 Mines 💣
* Copyright (C) 2025 Stefan Oltmann
* https://github.com/StefanOltmann/mines
*/
package de.stefan_oltmann.mines.model
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class MinefieldAsciiTest {
@Test
fun testRoundTripSmall() {
assertRoundTrip(smallTestMinefield)
}
@Test
fun testRoundTripMedium() {
assertRoundTrip(mediumTestMinefield)
}
@Test
fun testRoundTripLarge() {
assertRoundTrip(largeTestMinefield)
}
@Test
fun hexNeighborCountsNeverExceedSix() {
for (minefield in listOf(smallTestMinefield, mediumTestMinefield, largeTestMinefield)) {
for (x in 0 until minefield.width) {
for (y in 0 until minefield.height) {
val cell = minefield.getCellType(x, y)
if (cell != CellType.MINE)
assertTrue(
cell.adjacentMineCount in 0..6,
"Cell ($x,$y) has invalid count ${cell.adjacentMineCount}"
)
}
}
}
}
@Test
fun mineCountMatchesDifficulty() {
assertEquals(
GameDifficulty.HARD.calcMineCount(10, 10),
smallTestMinefield.config.mineCount
)
}
private fun assertRoundTrip(minefield: Minefield) {
val ascii = MinefieldAscii.toAscii(minefield)
val parsed = MinefieldAscii.fromAscii(ascii)
assertEquals(minefield.config.mineCount, parsed.config.mineCount)
assertEquals(minefield.config.mapWidth, parsed.config.mapWidth)
assertEquals(minefield.config.mapHeight, parsed.config.mapHeight)
assertEquals(minefield.seed, parsed.seed)
for (x in 0 until minefield.width) {
for (y in 0 until minefield.height) {
assertEquals(
minefield.getCellType(x, y),
parsed.getCellType(x, y),
"Cell at ($x, $y) should match after round trip"
)
}
}
}
}
@@ -0,0 +1,33 @@
package de.stefan_oltmann.mines.model
import de.stefan_oltmann.mines.DEFAULT_CELL_SIZE
val smallTestMinefield = Minefield.create(
config = GameConfig(
cellSize = DEFAULT_CELL_SIZE,
mapWidth = 10,
mapHeight = 10,
mineCount = GameDifficulty.HARD.calcMineCount(10, 10)
),
seed = 4711
)
val mediumTestMinefield = Minefield.create(
config = GameConfig(
cellSize = DEFAULT_CELL_SIZE,
mapWidth = 25,
mapHeight = 25,
mineCount = GameDifficulty.MEDIUM.calcMineCount(25, 25)
),
seed = 4242
)
val largeTestMinefield = Minefield.create(
config = GameConfig(
cellSize = DEFAULT_CELL_SIZE,
mapWidth = 50,
mapHeight = 50,
mineCount = GameDifficulty.EASY.calcMineCount(50, 50)
),
seed = 123456789
)
@@ -0,0 +1,61 @@
/*
* 💣 Mines 💣
* Copyright (C) 2025 Stefan Oltmann
* https://github.com/StefanOltmann/mines
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package de.stefan_oltmann.mines
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
import de.stefan_oltmann.mines.ui.icons.AppIcon
import io.github.kdroidfilter.platformtools.OperatingSystem
import io.github.kdroidfilter.platformtools.darkmodedetector.windows.setWindowsAdaptiveTitleBar
import io.github.kdroidfilter.platformtools.getOperatingSystem
import java.awt.Dimension
fun main() {
/*
* Title bar in dark mode on macOS.
*/
if (getOperatingSystem() == OperatingSystem.MACOS) System.setProperty(
"apple.awt.application.appearance",
"NSAppearanceNameDarkAqua"
)
application {
Window(
onCloseRequest = ::exitApplication,
title = APP_TITLE,
icon = rememberVectorPainter(AppIcon)
) {
/*
* Title bar in dark mode on Windows.
*/
window.setWindowsAdaptiveTitleBar(dark = true)
/*
* The layout breaks if we allow too small sizes.
*/
this.window.minimumSize = Dimension(600, 600)
App()
}
}
}
@@ -0,0 +1,93 @@
/*
* 💣 Mines 💣
* Copyright (C) 2025 Stefan Oltmann
* https://github.com/StefanOltmann/mines
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package de.stefan_oltmann.mines
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.defaultScrollbarStyle
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.rememberScrollbarAdapter
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.isSecondary
import androidx.compose.ui.input.pointer.pointerInput
import com.russhwolf.settings.PreferencesSettings
import com.russhwolf.settings.Settings
import de.stefan_oltmann.mines.ui.theme.colorForeground
import java.util.prefs.Preferences
private val preferences: Preferences = Preferences.userRoot().node("stefan-oltmann-mines")
actual val settings: Settings = PreferencesSettings(preferences)
actual val defaultMapWidth: Int = 10
actual val defaultMapHeight: Int = 10
actual val isDesktop: Boolean = true
@OptIn(ExperimentalComposeUiApi::class)
actual fun Modifier.addRightClickListener(key: Any?, onClick: (Offset) -> Unit): Modifier =
this.pointerInput(key) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent()
val change = event.changes.first()
if (change.pressed && event.button.isSecondary) {
val offset = change.position
onClick(offset)
}
}
}
}
@Composable
actual fun BoxScope.HorizontalScrollbar(scrollState: ScrollState) {
androidx.compose.foundation.HorizontalScrollbar(
adapter = rememberScrollbarAdapter(scrollState),
modifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter),
style = defaultScrollbarStyle().copy(
unhoverColor = colorForeground.copy(alpha = 0.4f),
hoverColor = colorForeground
)
)
}
@Composable
actual fun BoxScope.VerticalScrollbar(scrollState: ScrollState) {
androidx.compose.foundation.VerticalScrollbar(
adapter = rememberScrollbarAdapter(scrollState),
modifier = Modifier.fillMaxHeight().align(Alignment.CenterEnd),
style = defaultScrollbarStyle().copy(
unhoverColor = colorForeground.copy(alpha = 0.4f),
hoverColor = colorForeground
)
)
}
@@ -0,0 +1,48 @@
/*
* 💣 Mines 💣
* Copyright (C) 2025 Stefan Oltmann
* https://github.com/StefanOltmann/mines
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package de.stefan_oltmann.mines
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.window.ComposeViewport
import kotlinx.browser.document
import org.w3c.dom.HTMLElement
@OptIn(ExperimentalComposeUiApi::class)
fun main() {
ComposeViewport(document.body!!) {
hideLoader()
App()
}
}
/**
* Function to hide the loader and show the app
*/
fun hideLoader() {
val loader = document.getElementById("loader") as? HTMLElement
val app = document.getElementById("app") as? HTMLElement
/* Hide the loader */
loader?.style?.display = "none"
/* Show the app */
app?.style?.display = "block"
}
@@ -0,0 +1,90 @@
/*
* 💣 Mines 💣
* Copyright (C) 2025 Stefan Oltmann
* https://github.com/StefanOltmann/mines
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package de.stefan_oltmann.mines
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.defaultScrollbarStyle
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.rememberScrollbarAdapter
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.isSecondary
import androidx.compose.ui.input.pointer.pointerInput
import com.russhwolf.settings.Settings
import com.russhwolf.settings.StorageSettings
import de.stefan_oltmann.mines.ui.theme.colorForeground
actual val settings: Settings = StorageSettings()
actual val defaultMapWidth: Int = 10
actual val defaultMapHeight: Int = 10
actual val isDesktop: Boolean = true
@OptIn(ExperimentalComposeUiApi::class)
actual fun Modifier.addRightClickListener(key: Any?, onClick: (Offset) -> Unit): Modifier =
this.pointerInput(key) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent()
val change = event.changes.first()
if (change.pressed && event.button.isSecondary) {
val offset = change.position
onClick(offset)
}
}
}
}
@Composable
actual fun BoxScope.HorizontalScrollbar(scrollState: ScrollState) {
androidx.compose.foundation.HorizontalScrollbar(
adapter = rememberScrollbarAdapter(scrollState),
modifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter),
style = defaultScrollbarStyle().copy(
unhoverColor = colorForeground.copy(alpha = 0.4f),
hoverColor = colorForeground
)
)
}
@Composable
actual fun BoxScope.VerticalScrollbar(scrollState: ScrollState) {
androidx.compose.foundation.VerticalScrollbar(
adapter = rememberScrollbarAdapter(scrollState),
modifier = Modifier.fillMaxHeight().align(Alignment.CenterEnd),
style = defaultScrollbarStyle().copy(
unhoverColor = colorForeground.copy(alpha = 0.4f),
hoverColor = colorForeground
)
)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

+97
View File
@@ -0,0 +1,97 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mines</title>
<link rel="icon" href="icon.ico" type="image/x-icon">
<link rel="apple-touch-icon" href="icon_114.png">
<link rel="icon" type="image/png" sizes="256x256" href="icon_256.png">
<link rel="icon" type="image/png" sizes="512x512" href="icon_512.png">
<link type="text/css" rel="stylesheet" href="styles.css">
<link rel="manifest" href="manifest.json">
<meta name="theme-color" content="#000000">
<meta name="color-scheme" content="dark">
</head>
<body>
<div id="loader">
<div class="spinner"></div>
</div>
</body>
<script>
/*
* Register service worker for offline functionality
*/
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
/*
* Check if we're in development mode by looking for webpack dev server
*/
const isDevelopment = window.location.hostname === 'localhost' ||
window.location.hostname === '127.0.0.1' ||
window.location.port !== '';
if (isDevelopment) {
/*
* In development mode, unregister any existing service workers to prevent infinite loops
*/
console.log('Development mode detected, not registering service worker');
navigator.serviceWorker.getRegistrations().then(registrations => {
for (let registration of registrations) {
registration.unregister();
console.log('Unregistered service worker in development mode');
}
});
} else {
/*
* Only register service worker in production mode
*/
navigator.serviceWorker.register('./service-worker.js', {updateViaCache: 'none'})
.then(registration => {
console.log('Service Worker registered with scope:', registration.scope);
/*
* Check for updates on page load
*/
registration.update();
/*
* Don't let the service worker control the page on first load
* This prevents refresh loops in production mode
*/
if (registration.active) {
if (!navigator.serviceWorker.controller) {
console.log('Service worker is active but not controlling the page');
}
}
})
.catch(error => {
console.error('Service Worker registration failed:', error);
});
}
});
}
</script>
<footer>
<!-- The app.js script is placed in the footer to ensure the loader displays as quickly as possible. -->
<script type="application/javascript" src="app.js"></script>
</footer>
</html>
@@ -0,0 +1,46 @@
{
"name": "Mines",
"short_name": "Mines",
"description": "A simple mine puzzle game inspired by classic minesweeper, built using Kotlin Multiplatform.",
"start_url": "./",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#000000",
"icons": [
{
"src": "icon_114.png",
"sizes": "114x114",
"type": "image/png"
},
{
"src": "icon_256.png",
"sizes": "256x256",
"type": "image/png"
},
{
"src": "icon_512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"screenshots": [
{
"src": "screenshot01.webp",
"type": "image/webp",
"sizes": "1080x2340",
"form_factor": "narrow"
},
{
"src": "screenshot02.webp",
"type": "image/webp",
"sizes": "1080x2340",
"form_factor": "narrow"
},
{
"src": "screenshot03.webp",
"type": "image/webp",
"sizes": "1080x2340",
"form_factor": "narrow"
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 452 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 434 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 444 KiB

@@ -0,0 +1,131 @@
/*
* Service Worker for Mines app
*/
const CACHE_NAME = 'mines-cache-v3';
const ASSETS_TO_CACHE = [
'./',
'./index.html',
'./styles.css',
'./app.js',
'./manifest.json',
'./icon.ico',
'./icon_114.png',
'./icon_256.png',
'./icon_512.png'
];
/*
* Install event - cache all static assets
*/
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then((cache) => {
console.log('Opened cache');
return cache.addAll(ASSETS_TO_CACHE);
})
.then(() => self.skipWaiting())
);
});
/*
* Activate event - clean up old caches
*/
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => {
if (cacheName !== CACHE_NAME) {
console.log('Deleting old cache:', cacheName);
return caches.delete(cacheName);
}
})
);
})
.then(() => {
// Optional: Take control of uncontrolled clients
// This is commented out to prevent potential refresh loops in production
// return self.clients.claim();
// Instead, log that activation is complete
console.log('Service worker activated and ready');
return Promise.resolve();
})
);
});
/*
* Fetch event - serve from cache, fallback to network
*/
self.addEventListener('fetch', (event) => {
/*
* Skip caching for navigation requests to prevent refresh loops
*/
if (event.request.mode === 'navigate') {
event.respondWith(
fetch(event.request).catch(() => {
return caches.match(event.request);
})
);
return;
}
/*
* For non-navigation requests, use cache-first strategy
*/
event.respondWith(
caches.match(event.request)
.then((response) => {
/*
* Cache hit - return the response from the cached version
*/
if (response) {
return response;
}
/*
* Not in cache - fetch from network
*/
return fetch(event.request.clone())
.then((response) => {
/*
* Check if we received a valid response
*/
if (!response || response.status !== 200 || response.type !== 'basic') {
return response;
}
/*
* Clone the response as it's a stream and can only be consumed once
*/
const responseToCache = response.clone();
/*
* Add the new resource to the cache
*/
caches.open(CACHE_NAME)
.then((cache) => {
cache.put(event.request, responseToCache);
});
return response;
});
}).catch(() => {
/*
* If both cache and network fail, show a generic fallback
*/
console.log('Fetch failed, network and cache unavailable');
/*
* Note: You could return a custom offline page here
*/
})
);
});
+46
View File
@@ -0,0 +1,46 @@
html, body {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
}
/* Loader container: full screen, centered, and above all content */
#loader {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: #121212; /* Dark background */
display: flex;
justify-content: center;
align-items: center;
z-index: 9999; /* Ensure the loader is on top of everything */
}
/* Spinner styling */
.spinner {
width: 60px;
height: 60px;
border: 8px solid #333; /* Dark gray ring */
border-top: 8px solid #FFFFFF; /* Accent ring (white) */
border-radius: 50%;
animation: spin 1s linear infinite;
}
/* Spin animation */
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
/* Main application content, hidden by default */
#app {
display: none;
}