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]>
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
|
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>
|
||||
|
After Width: | Height: | Size: 692 B |
|
After Width: | Height: | Size: 488 B |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 648 B |
|
After Width: | Height: | Size: 312 B |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 686 B |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 974 B |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
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
|
||||
)
|
||||
)
|
||||
}
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 452 KiB |
|
After Width: | Height: | Size: 434 KiB |
|
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
|
||||
*/
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||