├── misc
├── 2dVersion.gif
├── menuOfRules.png
├── startScreen.png
└── composeVersion.gif
├── gradle
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── app
├── src
│ ├── main
│ │ ├── res
│ │ │ ├── drawable
│ │ │ │ ├── ic_biohazard.webp
│ │ │ │ ├── ic_biohazard2d.webp
│ │ │ │ ├── ic_launcher_foreground.xml
│ │ │ │ └── ic_launcher_background.xml
│ │ │ ├── mipmap-hdpi
│ │ │ │ ├── ic_launcher.webp
│ │ │ │ └── ic_launcher_round.webp
│ │ │ ├── mipmap-mdpi
│ │ │ │ ├── ic_launcher.webp
│ │ │ │ └── ic_launcher_round.webp
│ │ │ ├── mipmap-xhdpi
│ │ │ │ ├── ic_launcher.webp
│ │ │ │ └── ic_launcher_round.webp
│ │ │ ├── mipmap-xxhdpi
│ │ │ │ ├── ic_launcher.webp
│ │ │ │ └── ic_launcher_round.webp
│ │ │ ├── mipmap-xxxhdpi
│ │ │ │ ├── ic_launcher.webp
│ │ │ │ └── ic_launcher_round.webp
│ │ │ ├── raw
│ │ │ │ ├── fragment_shader.glsl
│ │ │ │ └── vertex_shader.glsl
│ │ │ ├── values
│ │ │ │ ├── themes.xml
│ │ │ │ ├── colors.xml
│ │ │ │ └── strings.xml
│ │ │ └── xml
│ │ │ │ ├── backup_rules.xml
│ │ │ │ └── data_extraction_rules.xml
│ │ ├── kotlin
│ │ │ └── org
│ │ │ │ └── redbyte
│ │ │ │ ├── GameApp.kt
│ │ │ │ └── life
│ │ │ │ ├── common
│ │ │ │ ├── data
│ │ │ │ │ └── GameSettings.kt
│ │ │ │ ├── domain
│ │ │ │ │ └── Rule.kt
│ │ │ │ └── GameBoard.kt
│ │ │ │ ├── MainActivity.kt
│ │ │ │ ├── ui
│ │ │ │ ├── theme
│ │ │ │ │ ├── Type.kt
│ │ │ │ │ ├── Color.kt
│ │ │ │ │ └── Theme.kt
│ │ │ │ ├── settings
│ │ │ │ │ ├── img
│ │ │ │ │ │ └── IcArrowDown.kt
│ │ │ │ │ ├── SharedGameSettingsViewModel.kt
│ │ │ │ │ └── SettingsScreen.kt
│ │ │ │ └── render
│ │ │ │ │ ├── opengl
│ │ │ │ │ ├── LifeGame2D.kt
│ │ │ │ │ └── GameRenderer.kt
│ │ │ │ │ └── compose
│ │ │ │ │ └── LifeGame.kt
│ │ │ │ ├── AppNavigation.kt
│ │ │ │ └── monitoring
│ │ │ │ └── FPSMonitor.kt
│ │ └── AndroidManifest.xml
│ ├── androidTest
│ │ └── java
│ │ │ └── org
│ │ │ └── redbyte
│ │ │ └── life
│ │ │ └── ExampleInstrumentedTest.kt
│ └── test
│ │ └── java
│ │ └── org
│ │ └── redbyte
│ │ └── life
│ │ └── common
│ │ └── GameBoardTest.kt
├── proguard-rules.pro
├── .gitignore
└── build.gradle.kts
├── .gitignore
├── settings.gradle.kts
├── LICENSE
├── gradle.properties
├── .github
└── workflows
│ └── release.yml
├── gradlew.bat
├── README.md
└── gradlew
/misc/2dVersion.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/i-redbyte/life-game/HEAD/misc/2dVersion.gif
--------------------------------------------------------------------------------
/misc/menuOfRules.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/i-redbyte/life-game/HEAD/misc/menuOfRules.png
--------------------------------------------------------------------------------
/misc/startScreen.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/i-redbyte/life-game/HEAD/misc/startScreen.png
--------------------------------------------------------------------------------
/misc/composeVersion.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/i-redbyte/life-game/HEAD/misc/composeVersion.gif
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/i-redbyte/life-game/HEAD/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_biohazard.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/i-redbyte/life-game/HEAD/app/src/main/res/drawable/ic_biohazard.webp
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_biohazard2d.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/i-redbyte/life-game/HEAD/app/src/main/res/drawable/ic_biohazard2d.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/i-redbyte/life-game/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/i-redbyte/life-game/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/i-redbyte/life-game/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/i-redbyte/life-game/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/i-redbyte/life-game/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/raw/fragment_shader.glsl:
--------------------------------------------------------------------------------
1 | precision mediump float;
2 | uniform vec4 vColor;
3 | void main() {
4 | gl_FragColor = vColor;
5 | }
6 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/i-redbyte/life-game/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/i-redbyte/life-game/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/i-redbyte/life-game/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/i-redbyte/life-game/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/i-redbyte/life-game/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/raw/vertex_shader.glsl:
--------------------------------------------------------------------------------
1 | uniform mat4 uMVPMatrix;
2 | attribute vec4 vPosition;
3 | void main() {
4 | gl_Position = uMVPMatrix * vPosition;
5 | }
6 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.iml
2 | .gradle
3 | /local.properties
4 | /.idea
5 | .DS_Store
6 | /build
7 | /captures
8 | .externalNativeBuild
9 | .cxx
10 | local.properties
11 |
--------------------------------------------------------------------------------
/app/src/main/res/values/themes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/org/redbyte/GameApp.kt:
--------------------------------------------------------------------------------
1 | package org.redbyte
2 |
3 | import android.app.Application
4 |
5 | class GameApp : Application() {
6 | override fun onCreate() {
7 | super.onCreate()
8 | }
9 | }
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | #Sun Jan 28 02:20:15 MSK 2024
2 | distributionBase=GRADLE_USER_HOME
3 | distributionPath=wrapper/dists
4 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
5 | zipStoreBase=GRADLE_USER_HOME
6 | zipStorePath=wrapper/dists
7 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/org/redbyte/life/common/data/GameSettings.kt:
--------------------------------------------------------------------------------
1 | package org.redbyte.life.common.data
2 |
3 | import org.redbyte.life.common.domain.Rule
4 |
5 | data class GameSettings(
6 | val width: Int,
7 | val height: Int,
8 | val initialPopulation: Int,
9 | val rule: Rule
10 | )
11 |
--------------------------------------------------------------------------------
/settings.gradle.kts:
--------------------------------------------------------------------------------
1 | pluginManagement {
2 | repositories {
3 | google()
4 | mavenCentral()
5 | gradlePluginPortal()
6 | }
7 | }
8 | dependencyResolutionManagement {
9 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
10 | repositories {
11 | google()
12 | mavenCentral()
13 | }
14 | }
15 |
16 | rootProject.name = "life"
17 | include(":app")
18 |
--------------------------------------------------------------------------------
/app/src/main/res/values/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #FFBB86FC
4 | #FF6200EE
5 | #FF3700B3
6 | #FF03DAC5
7 | #FF018786
8 | #FF000000
9 | #FFFFFFFF
10 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/org/redbyte/life/MainActivity.kt:
--------------------------------------------------------------------------------
1 | package org.redbyte.life
2 |
3 | import android.os.Bundle
4 | import androidx.activity.ComponentActivity
5 | import androidx.activity.compose.setContent
6 | import org.redbyte.life.ui.theme.LifeTheme
7 |
8 | class MainActivity : ComponentActivity() {
9 | override fun onCreate(savedInstanceState: Bundle?) {
10 | super.onCreate(savedInstanceState)
11 | setContent {
12 | LifeTheme {
13 | AppNavigation()
14 | }
15 | }
16 | }
17 | }
--------------------------------------------------------------------------------
/app/src/main/res/xml/backup_rules.xml:
--------------------------------------------------------------------------------
1 |
8 |
9 |
13 |
--------------------------------------------------------------------------------
/app/src/androidTest/java/org/redbyte/life/ExampleInstrumentedTest.kt:
--------------------------------------------------------------------------------
1 | package org.redbyte.life
2 |
3 | import androidx.test.platform.app.InstrumentationRegistry
4 | import androidx.test.ext.junit.runners.AndroidJUnit4
5 |
6 | import org.junit.Test
7 | import org.junit.runner.RunWith
8 |
9 | import org.junit.Assert.*
10 |
11 | /**
12 | * Instrumented test, which will execute on an Android device.
13 | *
14 | * See [testing documentation](http://d.android.com/tools/testing).
15 | */
16 | @RunWith(AndroidJUnit4::class)
17 | class ExampleInstrumentedTest {
18 |
19 | }
--------------------------------------------------------------------------------
/app/src/main/kotlin/org/redbyte/life/ui/theme/Type.kt:
--------------------------------------------------------------------------------
1 | package org.redbyte.life.ui.theme
2 |
3 | import androidx.compose.material3.Typography
4 | import androidx.compose.ui.text.TextStyle
5 | import androidx.compose.ui.text.font.FontFamily
6 | import androidx.compose.ui.text.font.FontWeight
7 | import androidx.compose.ui.unit.sp
8 |
9 | val Typography = Typography(
10 | bodyLarge = TextStyle(
11 | fontFamily = FontFamily.Default,
12 | fontWeight = FontWeight.Normal,
13 | fontSize = 16.sp,
14 | lineHeight = 24.sp,
15 | letterSpacing = 0.5.sp
16 | )
17 | )
--------------------------------------------------------------------------------
/app/src/main/res/xml/data_extraction_rules.xml:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
12 |
13 |
19 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/org/redbyte/life/ui/theme/Color.kt:
--------------------------------------------------------------------------------
1 | package org.redbyte.life.ui.theme
2 |
3 | import androidx.compose.ui.graphics.Color
4 |
5 | val Purple80 = Color(0xFFD0BCFF)
6 | val PurpleGrey80 = Color(0xFFCCC2DC)
7 | val Pink80 = Color(0xFFEFB8C8)
8 |
9 | val Purple40 = Color(0xFF6650a4)
10 | val PurpleGrey40 = Color(0xFF625b71)
11 | val Pink40 = Color(0xFF7D5260)
12 |
13 | val baseYellow = Color(0xFFFFC107)
14 | val baseGreen = Color(0xFF228B22)
15 | val greenSeaWave = Color(0xFF2E8B57)
16 | val blueSapphire = Color(0xFF0F52BA)
17 | val baseRed = Color(0xFFFF2400)
18 | val baseBlack = Color(0xFF1B1B1B)
19 | val baseWhite = Color.White
20 | val baseLightGray = Color.LightGray
21 | val baseDarkGray = Color.DarkGray
22 | val baseTeal = Color(0xFF009688)
--------------------------------------------------------------------------------
/app/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # You can control the set of applied configuration files using the
3 | # proguardFiles setting in build.gradle.
4 | #
5 | # For more details, see
6 | # http://developer.android.com/guide/developing/tools/proguard.html
7 |
8 | # If your project uses WebView with JS, uncomment the following
9 | # and specify the fully qualified class name to the JavaScript interface
10 | # class:
11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12 | # public *;
13 | #}
14 |
15 | # Uncomment this to preserve the line number information for
16 | # debugging stack traces.
17 | #-keepattributes SourceFile,LineNumberTable
18 |
19 | # If you keep the line number information, uncomment this to
20 | # hide the original source file name.
21 | #-renamesourcefileattribute SourceFile
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Ilya Sokolov
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/org/redbyte/life/AppNavigation.kt:
--------------------------------------------------------------------------------
1 | package org.redbyte.life
2 |
3 | import androidx.compose.runtime.Composable
4 | import androidx.lifecycle.viewmodel.compose.viewModel
5 | import androidx.navigation.compose.NavHost
6 | import androidx.navigation.compose.composable
7 | import androidx.navigation.compose.rememberNavController
8 | import org.redbyte.life.ui.render.compose.LifeGame
9 | import org.redbyte.life.ui.settings.SettingsScreen
10 | import org.redbyte.life.ui.render.opengl.LifeGame2D
11 | import org.redbyte.life.ui.settings.SharedGameSettingsViewModel
12 |
13 | @Composable
14 | fun AppNavigation() {
15 | val navController = rememberNavController()
16 | val sharedViewModel: SharedGameSettingsViewModel = viewModel()
17 | NavHost(navController = navController, startDestination = "settingsGame") {
18 | composable("settingsGame") {
19 | SettingsScreen(navController, sharedViewModel)
20 | }
21 | composable("lifeGame") {
22 | LifeGame(sharedViewModel)
23 | }
24 | composable("openGLGame") {
25 | LifeGame2D(sharedViewModel)
26 | }
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/app/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
16 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/org/redbyte/life/ui/settings/img/IcArrowDown.kt:
--------------------------------------------------------------------------------
1 | import androidx.compose.ui.graphics.Color
2 | import androidx.compose.ui.graphics.PathFillType
3 | import androidx.compose.ui.graphics.SolidColor
4 | import androidx.compose.ui.graphics.StrokeCap
5 | import androidx.compose.ui.graphics.StrokeJoin
6 | import androidx.compose.ui.graphics.vector.ImageVector
7 | import androidx.compose.ui.graphics.vector.path
8 | import androidx.compose.ui.unit.dp
9 |
10 | val IcArrowDown: ImageVector
11 | get() = ImageVector.Builder(
12 | name = "ic_arrow_down",
13 | defaultWidth = 24.dp,
14 | defaultHeight = 24.dp,
15 | viewportWidth = 24f,
16 | viewportHeight = 24f
17 | ).apply {
18 | path(
19 | fill = SolidColor(Color.White),
20 | stroke = null,
21 | strokeLineWidth = 0.0f,
22 | strokeLineCap = StrokeCap.Butt,
23 | strokeLineJoin = StrokeJoin.Miter,
24 | pathFillType = PathFillType.NonZero
25 | ) {
26 | moveTo(7f, 10f)
27 | lineTo(12f, 15f)
28 | lineTo(17f, 10f)
29 | lineTo(16.6f, 9.6f)
30 | lineTo(12f, 14.2f)
31 | lineTo(7.4f, 9.6f)
32 | close()
33 | }
34 | }.build()
35 |
--------------------------------------------------------------------------------
/app/.gitignore:
--------------------------------------------------------------------------------
1 | /build### Example user template template
2 | ### Example user template
3 |
4 | # IntelliJ project files
5 | .idea
6 | *.iml
7 | out
8 | gen
9 | ### macOS template
10 | # General
11 | .DS_Store
12 | .AppleDouble
13 | .LSOverride
14 |
15 | # Icon must end with two \r
16 | Icon
17 |
18 | # Thumbnails
19 | ._*
20 |
21 | # Files that might appear in the root of a volume
22 | .DocumentRevisions-V100
23 | .fseventsd
24 | .Spotlight-V100
25 | .TemporaryItems
26 | .Trashes
27 | .VolumeIcon.icns
28 | .com.apple.timemachine.donotpresent
29 |
30 | # Directories potentially created on remote AFP share
31 | .AppleDB
32 | .AppleDesktop
33 | Network Trash Folder
34 | Temporary Items
35 | .apdisk
36 |
37 | ### Android template
38 | # Gradle files
39 | .gradle/
40 | build/
41 |
42 | # Local configuration file (sdk path, etc)
43 | local.properties
44 |
45 | # Log/OS Files
46 | *.log
47 |
48 | # Android Studio generated files and folders
49 | captures/
50 | .externalNativeBuild/
51 | .cxx/
52 | *.apk
53 | output.json
54 |
55 | # IntelliJ
56 | *.iml
57 | .idea/
58 | misc.xml
59 | deploymentTargetDropDown.xml
60 | render.experimental.xml
61 |
62 | # Keystore files
63 | *.jks
64 | *.keystore
65 |
66 | # Google Services (e.g. APIs or Firebase)
67 | google-services.json
68 |
69 | # Android Profiling
70 | *.hprof
71 |
72 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/org/redbyte/life/monitoring/FPSMonitor.kt:
--------------------------------------------------------------------------------
1 | package org.redbyte.life.monitoring
2 |
3 | import android.view.Choreographer
4 |
5 | class FPSMonitor(private val listener: (Double) -> Unit) : Choreographer.FrameCallback {
6 |
7 | private var lastFrameTimeNanos: Long = 0L
8 | private var frameCount = 0
9 | private val oneSecondInNanos = 1_000_000_000.0
10 | private val choreographer = Choreographer.getInstance()
11 |
12 | override fun doFrame(frameTimeNanos: Long) {
13 | if (lastFrameTimeNanos != 0L) {
14 | frameCount++
15 | val timeElapsedInSeconds = (frameTimeNanos - lastFrameTimeNanos) / oneSecondInNanos
16 | if (timeElapsedInSeconds >= 1.0) {
17 | val fps = frameCount / timeElapsedInSeconds
18 | listener(fps)
19 | frameCount = 0
20 | lastFrameTimeNanos = frameTimeNanos
21 | }
22 | } else {
23 | lastFrameTimeNanos = frameTimeNanos
24 | }
25 |
26 | choreographer.postFrameCallback(this)
27 | }
28 |
29 | fun start() {
30 | if (lastFrameTimeNanos != 0L) return
31 | choreographer.postFrameCallback(this)
32 | }
33 |
34 | fun stop() {
35 | choreographer.removeFrameCallback(this)
36 | lastFrameTimeNanos = 0L
37 | frameCount = 0
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/app/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 | Life Game
3 | Количество живых клеток: %1$d
4 | Ход: %1$d
5 | Ход: %1$d
6 | Продолжить
7 | Пауза
8 | Настройки игры
9 | Ширина поля
10 | Высота поля
11 | Начальное население
12 | ОК
13 | Compose Game
14 | OpenGL Game
15 | \"Классическое\" правило
16 | \"Высокий уровень жизни\"
17 | \"День и Ночь\"
18 | \"Морли\"
19 | \"2x2\"
20 | \"Диамеба\"
21 | \"Жизнь без смерти\"
22 | \"Репликатор\"
23 | \"Семена\"
24 | FPS:
25 |
26 |
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | # Project-wide Gradle settings.
2 | # IDE (e.g. Android Studio) users:
3 | # Gradle settings configured through the IDE *will override*
4 | # any settings specified in this file.
5 | # For more details on how to configure your build environment visit
6 | # http://www.gradle.org/docs/current/userguide/build_environment.html
7 | # Specifies the JVM arguments used for the daemon process.
8 | # The setting is particularly useful for tweaking memory settings.
9 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
10 | # When configured, Gradle will run in incubating parallel mode.
11 | # This option should only be used with decoupled projects. More details, visit
12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
13 | # org.gradle.parallel=true
14 | # AndroidX package structure to make it clearer which packages are bundled with the
15 | # Android operating system, and which are packaged with your app's APK
16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn
17 | android.useAndroidX=true
18 | # Kotlin code style for this project: "official" or "obsolete":
19 | kotlin.code.style=official
20 | # Enables namespacing of each library's R class so that its R class includes only the
21 | # resources declared in the library itself and none from the library's dependencies,
22 | # thereby reducing the size of the R class for that library
23 | android.nonTransitiveRClass=true
--------------------------------------------------------------------------------
/app/src/main/kotlin/org/redbyte/life/ui/settings/SharedGameSettingsViewModel.kt:
--------------------------------------------------------------------------------
1 | package org.redbyte.life.ui.settings
2 |
3 | import androidx.lifecycle.MutableLiveData
4 | import androidx.lifecycle.ViewModel
5 | import org.redbyte.life.common.GameBoard
6 | import org.redbyte.life.common.data.GameSettings
7 | import org.redbyte.life.common.domain.ClassicRule
8 |
9 | class SharedGameSettingsViewModel : ViewModel() {
10 | private var _settings: GameSettings? = null
11 | private val _gameBoard: MutableLiveData = MutableLiveData()
12 |
13 | fun setupSettings(newSettings: GameSettings) {
14 | _settings = newSettings
15 | resetGameBoard()
16 | }
17 |
18 | fun resetGameBoard() {
19 | val settings = _settings ?: GameSettings(
20 | width = 32,
21 | height = 64,
22 | initialPopulation = 256,
23 | rule = ClassicRule
24 | )
25 | _gameBoard.value = GameBoard(settings)
26 | }
27 |
28 | fun getGameBoard(): GameBoard {
29 | return _gameBoard.value ?: resetGameBoardAndGet()
30 | }
31 |
32 | private fun resetGameBoardAndGet(): GameBoard {
33 | resetGameBoard()
34 | return _gameBoard.value ?: throw RuntimeException("Game board could not be reset")
35 | }
36 |
37 | fun getGameSettings(): GameSettings {
38 | return _settings ?: GameSettings(
39 | width = 32,
40 | height = 64,
41 | initialPopulation = 256,
42 | rule = ClassicRule
43 | )
44 | }
45 | }
46 |
47 |
48 |
49 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_launcher_foreground.xml:
--------------------------------------------------------------------------------
1 |
7 |
8 |
9 |
15 |
18 |
21 |
22 |
23 |
24 |
30 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/org/redbyte/life/common/domain/Rule.kt:
--------------------------------------------------------------------------------
1 | package org.redbyte.life.common.domain
2 |
3 | sealed interface Rule {
4 | fun apply(isAlive: Boolean, neighbors: Int): Boolean
5 | }
6 |
7 | data object ClassicRule : Rule {
8 | override fun apply(isAlive: Boolean, neighbors: Int): Boolean =
9 | if (isAlive) neighbors in 2..3 else neighbors == 3
10 | }
11 |
12 | data object DayAndNightRule : Rule {
13 | override fun apply(isAlive: Boolean, neighbors: Int): Boolean =
14 | if (isAlive) neighbors in listOf(3, 6, 7, 8) else neighbors in listOf(3, 6, 7, 8)
15 | }
16 |
17 | data object HighLifeRule : Rule {
18 | override fun apply(isAlive: Boolean, neighbors: Int): Boolean =
19 | if (isAlive) neighbors in 2..3 else neighbors == 3 || neighbors == 6
20 | }
21 |
22 | data object MorleyRule : Rule {
23 | override fun apply(isAlive: Boolean, neighbors: Int): Boolean =
24 | if (isAlive) neighbors in listOf(2, 4, 5) else neighbors in listOf(3, 6)
25 | }
26 |
27 | data object TwoByTwoRule : Rule {
28 | override fun apply(isAlive: Boolean, neighbors: Int): Boolean =
29 | if (isAlive) neighbors in listOf(1, 2, 5) else neighbors in listOf(3, 6)
30 | }
31 |
32 | data object DiamoebaRule : Rule {
33 | override fun apply(isAlive: Boolean, neighbors: Int): Boolean =
34 | if (isAlive) neighbors in 5..8 else neighbors in listOf(3, 5, 6, 7)
35 | }
36 |
37 | data object LifeWithoutDeathRule : Rule {
38 | override fun apply(isAlive: Boolean, neighbors: Int): Boolean =
39 | isAlive || neighbors == 3
40 | }
41 |
42 | data object ReplicatorRule : Rule {
43 | override fun apply(isAlive: Boolean, neighbors: Int): Boolean =
44 | neighbors % 2 == 1
45 | }
46 |
47 | data object SeedsRule : Rule {
48 | override fun apply(isAlive: Boolean, neighbors: Int): Boolean =
49 | !isAlive && neighbors == 2
50 | }
51 |
52 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/org/redbyte/life/ui/theme/Theme.kt:
--------------------------------------------------------------------------------
1 | package org.redbyte.life.ui.theme
2 |
3 | import android.app.Activity
4 | import android.os.Build
5 | import androidx.compose.foundation.isSystemInDarkTheme
6 | import androidx.compose.material3.MaterialTheme
7 | import androidx.compose.material3.darkColorScheme
8 | import androidx.compose.material3.dynamicDarkColorScheme
9 | import androidx.compose.material3.dynamicLightColorScheme
10 | import androidx.compose.material3.lightColorScheme
11 | import androidx.compose.runtime.Composable
12 | import androidx.compose.runtime.SideEffect
13 | import androidx.compose.ui.graphics.toArgb
14 | import androidx.compose.ui.platform.LocalContext
15 | import androidx.compose.ui.platform.LocalView
16 | import androidx.core.view.WindowCompat
17 |
18 | private val DarkColorScheme = darkColorScheme(
19 | primary = Purple80,
20 | secondary = PurpleGrey80,
21 | tertiary = Pink80
22 | )
23 |
24 | private val LightColorScheme = lightColorScheme(
25 | primary = Purple40,
26 | secondary = PurpleGrey40,
27 | tertiary = Pink40
28 | )
29 |
30 | @Composable
31 | fun LifeTheme(
32 | darkTheme: Boolean = isSystemInDarkTheme(),
33 | dynamicColor: Boolean = true,
34 | content: @Composable () -> Unit
35 | ) {
36 | val colorScheme = when {
37 | dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
38 | val context = LocalContext.current
39 | if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
40 | }
41 |
42 | darkTheme -> DarkColorScheme
43 | else -> LightColorScheme
44 | }
45 | val view = LocalView.current
46 | if (!view.isInEditMode) {
47 | SideEffect {
48 | val window = (view.context as Activity).window
49 | window.statusBarColor = colorScheme.primary.toArgb()
50 | WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme
51 | }
52 | }
53 |
54 | MaterialTheme(
55 | colorScheme = colorScheme,
56 | typography = Typography,
57 | content = content
58 | )
59 | }
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Publish Release
2 |
3 | on:
4 | push:
5 | tags:
6 | - 'v*.*.*'
7 |
8 | jobs:
9 | build:
10 | runs-on: ubuntu-latest
11 |
12 | steps:
13 | - name: Checkout repository
14 | uses: actions/checkout@v4
15 |
16 | - name: Set up JDK 17
17 | uses: actions/setup-java@v4
18 | with:
19 | distribution: 'temurin'
20 | java-version: '17'
21 |
22 | - name: Run unit tests
23 | run: ./gradlew test
24 |
25 | - name: Build the project
26 | run: ./gradlew assembleDebug --info
27 |
28 | - name: Find APK files
29 | run: find app/build -name "*.apk"
30 |
31 | - name: List APK files
32 | run: |
33 | if [ -d "app/build/intermediates/apk/debug" ]; then
34 | ls -R app/build/intermediates/apk/debug
35 | else
36 | echo "Directory does not exist"
37 | fi
38 |
39 | - name: Upload APK as an artifact
40 | uses: actions/upload-artifact@v4
41 | with:
42 | name: life-game-debug-apk
43 | path: app/build/intermediates/apk/debug/life-1.0.0-debug.apk
44 |
45 | - name: Verify artifact upload
46 | run: |
47 | if [ -f "app/build/intermediates/apk/debug/life-1.0.0-debug.apk" ]; then
48 | echo "APK exists and was uploaded"
49 | else
50 | echo "APK does not exist, something went wrong with the build."
51 | exit 1
52 | fi
53 |
54 | release:
55 | needs: build
56 | runs-on: ubuntu-latest
57 | permissions:
58 | contents: write
59 |
60 | steps:
61 | - name: Checkout repository
62 | uses: actions/checkout@v4
63 |
64 | - name: Download APK artifact
65 | uses: actions/download-artifact@v4
66 | with:
67 | name: life-game-debug-apk
68 |
69 | - name: Create Release
70 | uses: softprops/action-gh-release@v1
71 | with:
72 | tag_name: ${{ github.ref }}
73 | name: Release ${{ github.ref }}
74 | draft: false
75 | prerelease: false
76 | env:
77 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
78 |
79 | - name: Upload APK to Release
80 | uses: softprops/action-gh-release@v1
81 | with:
82 | files: app/build/intermediates/apk/debug/life-1.0.0-debug.apk
83 | env:
84 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
85 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/org/redbyte/life/common/GameBoard.kt:
--------------------------------------------------------------------------------
1 | package org.redbyte.life.common
2 |
3 | import org.redbyte.life.common.data.GameSettings
4 |
5 | typealias CellMatrix = List
6 |
7 | class GameBoard(val settings: GameSettings) {
8 |
9 | var matrix: CellMatrix = List(settings.height) { 0L }
10 | private val rule = settings.rule
11 |
12 | init {
13 | populateInitialCells()
14 | }
15 |
16 | private fun populateInitialCells() {
17 | val initialCells = (0 until settings.height).flatMap { y ->
18 | (0 until settings.width).map { x -> x to y }
19 | }
20 | .shuffled()
21 | .take(settings.initialPopulation)
22 | .toSet()
23 | matrix = List(settings.height) { y ->
24 | (0 until settings.width).fold(0L) { row, x ->
25 | if (x to y in initialCells) row or (1L shl x) else row
26 | }
27 | }
28 | }
29 |
30 | fun update(): GameBoard {
31 | matrix = matrix.mapIndexed { y, row ->
32 | row.mapBitsIndexed(settings.width) { x, isAlive ->
33 | val neighbors = countNeighbors(x, y)
34 | rule.apply(isAlive, neighbors)
35 | }
36 | }
37 | return this
38 | }
39 |
40 | fun countLivingCells(): Int =
41 | matrix.sumOf { row -> row.countBits() }
42 |
43 | internal fun countNeighbors(x: Int, y: Int): Int {
44 | val directions = listOf(
45 | Pair(-1, -1), Pair(-1, 0), Pair(-1, 1),
46 | Pair(0, -1), Pair(0, 1),
47 | Pair(1, -1), Pair(1, 0), Pair(1, 1)
48 | )
49 |
50 | return directions.mapNotNull { (dx, dy) ->
51 | val nx = x + dx
52 | val ny = y + dy
53 | if (nx in 0 until settings.width && ny in 0 until settings.height)
54 | getCellAlive(nx, ny)
55 | else
56 | null
57 | }.count { it }
58 | }
59 |
60 | private fun getCellAlive(x: Int, y: Int): Boolean =
61 | (matrix[y] shr x) and 1L == 1L
62 |
63 | private fun Long.setBitAlive(x: Int, isAlive: Boolean): Long =
64 | if (isAlive) this or (1L shl x)
65 | else this and (1L shl x).inv()
66 |
67 | private fun Long.mapBitsIndexed(width: Int, action: (Int, Boolean) -> Boolean): Long {
68 | var result = 0L
69 | for (i in 0 until width) {
70 | val isAlive = (this shr i) and 1L == 1L
71 | result = result.setBitAlive(i, action(i, isAlive))
72 | }
73 | return result
74 | }
75 |
76 | private fun Long.countBits(): Int =
77 | this.toString(2).count { it == '1' }
78 |
79 | }
80 |
--------------------------------------------------------------------------------
/app/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | kotlin("kapt")
3 | id("com.android.application")
4 | id("org.jetbrains.kotlin.android")
5 | id("org.jetbrains.kotlin.plugin.compose") version "2.0.0"
6 | }
7 |
8 | android {
9 | namespace = "org.redbyte.life"
10 | compileSdk = 34
11 |
12 | defaultConfig {
13 | applicationId = "org.redbyte.life"
14 | minSdk = 24
15 | targetSdk = 35
16 |
17 | versionCode = 1
18 | versionName = "1.0.0"
19 |
20 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
21 | vectorDrawables {
22 | useSupportLibrary = true
23 | }
24 | }
25 |
26 | buildTypes {
27 | release {
28 | isMinifyEnabled = false
29 | proguardFiles(
30 | getDefaultProguardFile("proguard-android-optimize.txt"),
31 | "proguard-rules.pro"
32 | )
33 | }
34 | }
35 | compileOptions {
36 | sourceCompatibility = JavaVersion.VERSION_1_8
37 | targetCompatibility = JavaVersion.VERSION_1_8
38 | }
39 | kotlinOptions {
40 | jvmTarget = "1.8"
41 | }
42 | buildFeatures {
43 | compose = true
44 | }
45 | composeOptions {
46 | kotlinCompilerExtensionVersion = "2.0.0"
47 | }
48 | packaging {
49 | resources {
50 | excludes += "/META-INF/{AL2.0,LGPL2.1}"
51 | }
52 | }
53 |
54 | applicationVariants.all {
55 | outputs.all {
56 | val outputImpl = this as com.android.build.gradle.internal.api.BaseVariantOutputImpl
57 | outputImpl.outputFileName = "life-${versionName}-${buildType.name}.apk"
58 | }
59 | }
60 |
61 | }
62 |
63 | dependencies {
64 | implementation("androidx.core:core-ktx:1.13.1")
65 | implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.5")
66 | implementation("androidx.compose.runtime:runtime-livedata")
67 | implementation("androidx.activity:activity-compose:1.9.2")
68 | implementation(platform("androidx.compose:compose-bom:2024.09.01"))
69 | implementation("com.google.android.material:material:1.12.0")
70 | implementation("androidx.compose.ui:ui")
71 |
72 | // Material 3
73 | implementation("androidx.compose.material3:material3")
74 |
75 | // Navigation for Compose
76 | implementation("androidx.navigation:navigation-compose:2.8.0")
77 |
78 | // Testing dependencies
79 | testImplementation("junit:junit:4.13.2")
80 | androidTestImplementation("androidx.test.ext:junit:1.2.1")
81 | androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
82 | androidTestImplementation(platform("androidx.compose:compose-bom:2024.09.01"))
83 | androidTestImplementation("androidx.compose.ui:ui-test-junit4")
84 |
85 | // Debugging UI and Testing
86 | debugImplementation("androidx.compose.ui:ui-tooling")
87 | debugImplementation("androidx.compose.ui:ui-test-manifest")
88 | }
89 |
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @rem
2 | @rem Copyright 2015 the original author or authors.
3 | @rem
4 | @rem Licensed under the Apache License, Version 2.0 (the "License");
5 | @rem you may not use this file except in compliance with the License.
6 | @rem You may obtain a copy of the License at
7 | @rem
8 | @rem https://www.apache.org/licenses/LICENSE-2.0
9 | @rem
10 | @rem Unless required by applicable law or agreed to in writing, software
11 | @rem distributed under the License is distributed on an "AS IS" BASIS,
12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | @rem See the License for the specific language governing permissions and
14 | @rem limitations under the License.
15 | @rem
16 |
17 | @if "%DEBUG%" == "" @echo off
18 | @rem ##########################################################################
19 | @rem
20 | @rem Gradle startup script for Windows
21 | @rem
22 | @rem ##########################################################################
23 |
24 | @rem Set local scope for the variables with windows NT shell
25 | if "%OS%"=="Windows_NT" setlocal
26 |
27 | set DIRNAME=%~dp0
28 | if "%DIRNAME%" == "" set DIRNAME=.
29 | set APP_BASE_NAME=%~n0
30 | set APP_HOME=%DIRNAME%
31 |
32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
34 |
35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
37 |
38 | @rem Find java.exe
39 | if defined JAVA_HOME goto findJavaFromJavaHome
40 |
41 | set JAVA_EXE=java.exe
42 | %JAVA_EXE% -version >NUL 2>&1
43 | if "%ERRORLEVEL%" == "0" goto execute
44 |
45 | echo.
46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
47 | echo.
48 | echo Please set the JAVA_HOME variable in your environment to match the
49 | echo location of your Java installation.
50 |
51 | goto fail
52 |
53 | :findJavaFromJavaHome
54 | set JAVA_HOME=%JAVA_HOME:"=%
55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
56 |
57 | if exist "%JAVA_EXE%" goto execute
58 |
59 | echo.
60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
61 | echo.
62 | echo Please set the JAVA_HOME variable in your environment to match the
63 | echo location of your Java installation.
64 |
65 | goto fail
66 |
67 | :execute
68 | @rem Setup the command line
69 |
70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
71 |
72 |
73 | @rem Execute Gradle
74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
75 |
76 | :end
77 | @rem End local scope for the variables with windows NT shell
78 | if "%ERRORLEVEL%"=="0" goto mainEnd
79 |
80 | :fail
81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
82 | rem the _cmd.exe /c_ return code!
83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
84 | exit /b 1
85 |
86 | :mainEnd
87 | if "%OS%"=="Windows_NT" endlocal
88 |
89 | :omega
90 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/org/redbyte/life/ui/render/opengl/LifeGame2D.kt:
--------------------------------------------------------------------------------
1 | package org.redbyte.life.ui.render.opengl
2 |
3 | import android.opengl.GLSurfaceView
4 | import androidx.compose.foundation.layout.Box
5 | import androidx.compose.foundation.layout.fillMaxSize
6 | import androidx.compose.foundation.layout.padding
7 | import androidx.compose.material3.Text
8 | import androidx.compose.runtime.Composable
9 | import androidx.compose.runtime.DisposableEffect
10 | import androidx.compose.runtime.mutableDoubleStateOf
11 | import androidx.compose.runtime.mutableIntStateOf
12 | import androidx.compose.runtime.remember
13 | import androidx.compose.ui.Alignment
14 | import androidx.compose.ui.Modifier
15 | import androidx.compose.ui.platform.LocalContext
16 | import androidx.compose.ui.res.stringResource
17 | import androidx.compose.ui.unit.dp
18 | import androidx.compose.ui.unit.sp
19 | import androidx.compose.ui.viewinterop.AndroidView
20 | import org.redbyte.life.R
21 | import org.redbyte.life.common.GameBoard
22 | import org.redbyte.life.monitoring.FPSMonitor
23 | import org.redbyte.life.ui.settings.SharedGameSettingsViewModel
24 | import org.redbyte.life.ui.theme.baseTeal
25 |
26 | @Composable
27 | fun LifeGame2D(viewModel: SharedGameSettingsViewModel) {
28 | val gameBoard = viewModel.getGameBoard()
29 | val livingCellsCount = remember { mutableIntStateOf(gameBoard.settings.initialPopulation) }
30 | val turnGame = remember { mutableIntStateOf(0) }
31 | val fps = remember { mutableDoubleStateOf(0.0) }
32 |
33 | Box(modifier = Modifier.fillMaxSize()) {
34 | GameBoardView(
35 | gameBoard = gameBoard,
36 | onGameUpdated = { count, turn ->
37 | livingCellsCount.intValue = count
38 | turnGame.intValue = turn
39 | },
40 | onFPSUpdate = { fps.doubleValue = it }
41 | )
42 | GameInfoView(
43 | livingCellsCount = livingCellsCount.intValue,
44 | turnGame = turnGame.intValue,
45 | fps = fps.doubleValue
46 | )
47 | }
48 | }
49 |
50 | @Composable
51 | fun GameBoardView(
52 | gameBoard: GameBoard,
53 | onGameUpdated: (Int, Int) -> Unit,
54 | onFPSUpdate: (Double) -> Unit
55 | ) {
56 | val context = LocalContext.current
57 | val fpsMonitor = remember { FPSMonitor(onFPSUpdate) }
58 |
59 | DisposableEffect(Unit) {
60 | fpsMonitor.start()
61 | onDispose {
62 | fpsMonitor.stop()
63 | }
64 | }
65 |
66 | AndroidView(
67 | modifier = Modifier.fillMaxSize(),
68 | factory = {
69 | GLSurfaceView(context).apply {
70 | setEGLContextClientVersion(2)
71 | setRenderer(
72 | GameRenderer(
73 | context,
74 | gameBoard,
75 | onGameUpdated
76 | )
77 | )
78 | renderMode = GLSurfaceView.RENDERMODE_CONTINUOUSLY
79 | }
80 | }
81 | )
82 | }
83 |
84 | @Composable
85 | fun GameInfoView(livingCellsCount: Int, turnGame: Int, fps: Double) {
86 | val livingCellsText = stringResource(id = R.string.living_cells_count, livingCellsCount)
87 | val gameTurnText = stringResource(id = R.string.game_turn, turnGame)
88 |
89 | Box(modifier = Modifier.fillMaxSize()) {
90 | Text(
91 | text = "$livingCellsText\n$gameTurnText\nFPS: ${fps.format(2)}",
92 | color = baseTeal,
93 | fontSize = 18.sp,
94 | modifier = Modifier
95 | .align(Alignment.TopStart)
96 | .padding(top = 32.dp, start = 16.dp)
97 | )
98 | }
99 | }
100 |
101 | fun Double.format(digits: Int) = "%.${digits}f".format(this)
102 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 | # Игра Жизнь
3 | |  |  |
4 | |:---------------------------------------:|:-----------------------------------:|
5 | | Стартовый экран | Меню правил |
6 |
7 | **Игра Жизнь** — это реализация [знаменитого](https://ru.wikipedia.org/wiki/%D0%98%D0%B3%D1%80%D0%B0_%C2%AB%D0%96%D0%B8%D0%B7%D0%BD%D1%8C%C2%BB) клеточного автомата Джона Конвея на языке Kotlin. Проект включает две версии игры: одну с использованием **OpenGL** для работы с графикой низкого уровня, и другую с использованием **Jetpack Compose**. В этой игре поддерживаются различные модификации правил, что позволяет варьировать поведение клеток и экспериментировать с их эволюцией.
8 |
9 | ## Особенности
10 | - **Две версии игры**:
11 | - **OpenGL**
12 | - **Jetpack Compose**:
13 |
14 | |
|
|
15 | |:-----------------------------------------------------------------------------------:|:-----------------------------------------------------------------------------:|
16 | | Compose version | OpenGL version |
17 |
18 |
19 | - **Поддержка различных правил** для изменения логики поведения клеток.
20 | - **Высокая производительность** благодаря оптимальной работе с игровым полем через представление его в виде битовых последовательностей.
21 |
22 | ## Игровое поле
23 |
24 | Игровое поле представлено классом `GameBoard`. В основе его работы лежит `CellMatrix` — список целых чисел (`Long`), где каждый бит представляет состояние клетки: живая или мёртвая.
25 |
26 | ### Основные функции `GameBoard`
27 |
28 | - **Инициализация поля**: клетки случайным образом заполняются исходя из начальных настроек игры.
29 | - **Обновление состояния клеток**: для каждой клетки проверяется количество соседей, и на основе заданного правила определяется, останется ли клетка живой или умрет.
30 | - **Подсчет соседей**: для каждой клетки можно подсчитать количество живых соседей, учитывая восемь возможных направлений.
31 | - **Оптимизация**: управление состояниями клеток на основе битовых операций, что позволяет работать с большим количеством клеток эффективно.
32 |
33 |
34 | ### Пример работы с битами
35 |
36 | Для управления состоянием клеток используются битовые операции.
37 |
38 | ## Правила игры
39 |
40 | Правила игры определяют, будет ли клетка живой или мертвой в следующем поколении, в зависимости от её текущего состояния и числа живых соседей.]
41 |
42 | Некоторые из доступных правил:
43 |
44 | - **ClassicRule** — классические правила Конвея.
45 | - **DayAndNightRule** — клетки остаются живыми или возрождаются при 3, 6, 7 или 8 соседях.
46 | - **HighLifeRule** — добавляет возможность возрождения клетки при 6 соседях.
47 | - **ReplicatorRule** — клетки остаются живыми, если у них нечётное количество соседей.
48 | - **MorleyRule** — клетки остаются живыми при 2, 4 или 5 соседях, а возрождаются при 3 или 6 соседях.
49 | - **TwoByTwoRule** — клетки остаются живыми при 1, 2 или 5 соседях, а возрождаются при 3 или 6 соседях.
50 | - **DiamoebaRule** — клетки остаются живыми при 5-8 соседях, а возрождаются при 3, 5, 6 или 7 соседях.
51 | - **LifeWithoutDeathRule** — клетки остаются живыми навсегда или возрождаются при 3 соседях.
52 | - **SeedsRule** — клетки могут возродиться только при 2 соседях, но никогда не остаются живыми на следующем ходу.
53 |
54 | ## Установка
55 | 1. Вы можете скачать последнюю релизную версию по этой [ссылке](https://github.com/i-redbyte/life-game/releases).
56 |
57 | 2.1. Склонируйте репозиторий:
58 |
59 | ```bash
60 | git clone https://github.com/i-redbyte/life-game.git
61 | ```
62 |
63 | 2.2. Откройте проект в Android Studio и синхронизируйте зависимости.
64 |
65 | 2.3. Запустите приложение на эмуляторе или физическом устройстве.
66 |
67 | ## Вклад в проект
68 |
69 | Вы можете предлагать улучшения, исправления или добавлять новые правила, создавая pull request или открывая issue.
70 |
71 | ## Лицензия
72 |
73 | Этот проект распространяется по лицензии MIT. Подробности смотрите в файле [LICENSE](LICENSE).
74 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/org/redbyte/life/ui/render/compose/LifeGame.kt:
--------------------------------------------------------------------------------
1 | package org.redbyte.life.ui.render.compose
2 |
3 | import androidx.compose.animation.AnimatedVisibility
4 | import androidx.compose.animation.slideInVertically
5 | import androidx.compose.animation.slideOutVertically
6 | import androidx.compose.foundation.Canvas
7 | import androidx.compose.foundation.gestures.detectVerticalDragGestures
8 | import androidx.compose.foundation.layout.BoxWithConstraints
9 | import androidx.compose.foundation.layout.Column
10 | import androidx.compose.foundation.layout.fillMaxSize
11 | import androidx.compose.foundation.layout.fillMaxWidth
12 | import androidx.compose.foundation.layout.padding
13 | import androidx.compose.material3.Button
14 | import androidx.compose.material3.Text
15 | import androidx.compose.runtime.Composable
16 | import androidx.compose.runtime.DisposableEffect
17 | import androidx.compose.runtime.LaunchedEffect
18 | import androidx.compose.runtime.getValue
19 | import androidx.compose.runtime.mutableDoubleStateOf
20 | import androidx.compose.runtime.mutableIntStateOf
21 | import androidx.compose.runtime.mutableStateOf
22 | import androidx.compose.runtime.remember
23 | import androidx.compose.runtime.rememberCoroutineScope
24 | import androidx.compose.runtime.setValue
25 | import androidx.compose.ui.Modifier
26 | import androidx.compose.ui.geometry.Offset
27 | import androidx.compose.ui.graphics.Color
28 | import androidx.compose.ui.input.pointer.pointerInput
29 | import androidx.compose.ui.res.stringResource
30 | import androidx.compose.ui.unit.dp
31 | import kotlinx.coroutines.delay
32 | import kotlinx.coroutines.launch
33 | import org.redbyte.life.R
34 | import org.redbyte.life.monitoring.FPSMonitor
35 | import org.redbyte.life.ui.settings.SharedGameSettingsViewModel
36 | import org.redbyte.life.ui.theme.baseGreen
37 |
38 | private const val DELAY_UPDATE_WORLD = 150L
39 |
40 | @Composable
41 | fun LifeGame(viewModel: SharedGameSettingsViewModel) {
42 | fun Long.countBits(): Int = this.toString(2).count { it == '1' }
43 |
44 | val initialBoard = remember { viewModel.getGameBoard() }
45 | viewModel.resetGameBoard()
46 |
47 | val coroutineScope = rememberCoroutineScope()
48 |
49 | var showTopSheet by remember { mutableStateOf(false) }
50 | var isPaused by remember { mutableStateOf(false) }
51 | var turnNumber by remember { mutableIntStateOf(0) }
52 | var cellCount by remember { mutableIntStateOf(0) }
53 | var matrix by remember { mutableStateOf(initialBoard.matrix) }
54 | var fps by remember { mutableDoubleStateOf(0.0) }
55 |
56 | val fpsMonitor = remember {
57 | FPSMonitor { reportedFps ->
58 | fps = reportedFps
59 | }
60 | }
61 |
62 | DisposableEffect(Unit) {
63 | fpsMonitor.start()
64 | onDispose {
65 | fpsMonitor.stop()
66 | }
67 | }
68 |
69 | BoxWithConstraints(Modifier.fillMaxSize()) {
70 | val screenWidth = constraints.maxWidth
71 | val cellSize = screenWidth / initialBoard.settings.width
72 |
73 | LaunchedEffect(isPaused) {
74 | if (!isPaused) {
75 | while (true) {
76 | delay(DELAY_UPDATE_WORLD)
77 | val updatedBoard = initialBoard.update()
78 | matrix = updatedBoard.matrix
79 | cellCount = updatedBoard.matrix.sumOf { row -> row.countBits() }
80 | turnNumber++
81 | }
82 | }
83 | }
84 |
85 | Column(
86 | modifier = Modifier.pointerInput(Unit) {
87 | detectVerticalDragGestures { _, dragAmount ->
88 | coroutineScope.launch {
89 | if (dragAmount > 0 && !showTopSheet) showTopSheet = true
90 | else if (dragAmount < 0 && showTopSheet) showTopSheet = false
91 | }
92 | }
93 | }
94 | ) {
95 | AnimatedVisibility(
96 | visible = showTopSheet,
97 | enter = slideInVertically(),
98 | exit = slideOutVertically()
99 | ) {
100 | Column(
101 | modifier = Modifier
102 | .fillMaxWidth()
103 | .padding(16.dp)
104 | ) {
105 | Text(stringResource(R.string.turn, turnNumber))
106 | Text("FPS: $fps")
107 | Button(onClick = { isPaused = !isPaused }) {
108 | Text(
109 | if (isPaused) stringResource(R.string.continue_game)
110 | else stringResource(R.string.pause)
111 | )
112 | }
113 | }
114 | }
115 |
116 | Canvas(modifier = Modifier.fillMaxSize()) {
117 | matrix.forEachIndexed { rowIndex, row ->
118 | repeat(initialBoard.settings.width) { colIndex ->
119 | val isCellAlive = (row shr colIndex) and 1L == 1L
120 | val color = if (isCellAlive) baseGreen else Color.White
121 | drawCircle(
122 | color = color,
123 | radius = cellSize / 2f,
124 | center = Offset(
125 | colIndex * cellSize + cellSize / 2f,
126 | rowIndex * cellSize + cellSize / 2f
127 | )
128 | )
129 | }
130 | }
131 | }
132 | }
133 | }
134 | }
135 |
136 |
137 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_launcher_background.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
10 |
15 |
20 |
25 |
30 |
35 |
40 |
45 |
50 |
55 |
60 |
65 |
70 |
75 |
80 |
85 |
90 |
95 |
100 |
105 |
110 |
115 |
120 |
125 |
130 |
135 |
140 |
145 |
150 |
155 |
160 |
165 |
170 |
171 |
--------------------------------------------------------------------------------
/gradlew:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 |
3 | #
4 | # Copyright 2015 the original author or authors.
5 | #
6 | # Licensed under the Apache License, Version 2.0 (the "License");
7 | # you may not use this file except in compliance with the License.
8 | # You may obtain a copy of the License at
9 | #
10 | # https://www.apache.org/licenses/LICENSE-2.0
11 | #
12 | # Unless required by applicable law or agreed to in writing, software
13 | # distributed under the License is distributed on an "AS IS" BASIS,
14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15 | # See the License for the specific language governing permissions and
16 | # limitations under the License.
17 | #
18 |
19 | ##############################################################################
20 | ##
21 | ## Gradle start up script for UN*X
22 | ##
23 | ##############################################################################
24 |
25 | # Attempt to set APP_HOME
26 | # Resolve links: $0 may be a link
27 | PRG="$0"
28 | # Need this for relative symlinks.
29 | while [ -h "$PRG" ] ; do
30 | ls=`ls -ld "$PRG"`
31 | link=`expr "$ls" : '.*-> \(.*\)$'`
32 | if expr "$link" : '/.*' > /dev/null; then
33 | PRG="$link"
34 | else
35 | PRG=`dirname "$PRG"`"/$link"
36 | fi
37 | done
38 | SAVED="`pwd`"
39 | cd "`dirname \"$PRG\"`/" >/dev/null
40 | APP_HOME="`pwd -P`"
41 | cd "$SAVED" >/dev/null
42 |
43 | APP_NAME="Gradle"
44 | APP_BASE_NAME=`basename "$0"`
45 |
46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
48 |
49 | # Use the maximum available, or set MAX_FD != -1 to use that value.
50 | MAX_FD="maximum"
51 |
52 | warn () {
53 | echo "$*"
54 | }
55 |
56 | die () {
57 | echo
58 | echo "$*"
59 | echo
60 | exit 1
61 | }
62 |
63 | # OS specific support (must be 'true' or 'false').
64 | cygwin=false
65 | msys=false
66 | darwin=false
67 | nonstop=false
68 | case "`uname`" in
69 | CYGWIN* )
70 | cygwin=true
71 | ;;
72 | Darwin* )
73 | darwin=true
74 | ;;
75 | MINGW* )
76 | msys=true
77 | ;;
78 | NONSTOP* )
79 | nonstop=true
80 | ;;
81 | esac
82 |
83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
84 |
85 |
86 | # Determine the Java command to use to start the JVM.
87 | if [ -n "$JAVA_HOME" ] ; then
88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
89 | # IBM's JDK on AIX uses strange locations for the executables
90 | JAVACMD="$JAVA_HOME/jre/sh/java"
91 | else
92 | JAVACMD="$JAVA_HOME/bin/java"
93 | fi
94 | if [ ! -x "$JAVACMD" ] ; then
95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
96 |
97 | Please set the JAVA_HOME variable in your environment to match the
98 | location of your Java installation."
99 | fi
100 | else
101 | JAVACMD="java"
102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
103 |
104 | Please set the JAVA_HOME variable in your environment to match the
105 | location of your Java installation."
106 | fi
107 |
108 | # Increase the maximum file descriptors if we can.
109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
110 | MAX_FD_LIMIT=`ulimit -H -n`
111 | if [ $? -eq 0 ] ; then
112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
113 | MAX_FD="$MAX_FD_LIMIT"
114 | fi
115 | ulimit -n $MAX_FD
116 | if [ $? -ne 0 ] ; then
117 | warn "Could not set maximum file descriptor limit: $MAX_FD"
118 | fi
119 | else
120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
121 | fi
122 | fi
123 |
124 | # For Darwin, add options to specify how the application appears in the dock
125 | if $darwin; then
126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
127 | fi
128 |
129 | # For Cygwin or MSYS, switch paths to Windows format before running java
130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"`
132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
133 |
134 | JAVACMD=`cygpath --unix "$JAVACMD"`
135 |
136 | # We build the pattern for arguments to be converted via cygpath
137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
138 | SEP=""
139 | for dir in $ROOTDIRSRAW ; do
140 | ROOTDIRS="$ROOTDIRS$SEP$dir"
141 | SEP="|"
142 | done
143 | OURCYGPATTERN="(^($ROOTDIRS))"
144 | # Add a user-defined pattern to the cygpath arguments
145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then
146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
147 | fi
148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
149 | i=0
150 | for arg in "$@" ; do
151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
153 |
154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
156 | else
157 | eval `echo args$i`="\"$arg\""
158 | fi
159 | i=`expr $i + 1`
160 | done
161 | case $i in
162 | 0) set -- ;;
163 | 1) set -- "$args0" ;;
164 | 2) set -- "$args0" "$args1" ;;
165 | 3) set -- "$args0" "$args1" "$args2" ;;
166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;;
167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
172 | esac
173 | fi
174 |
175 | # Escape application args
176 | save () {
177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
178 | echo " "
179 | }
180 | APP_ARGS=`save "$@"`
181 |
182 | # Collect all arguments for the java command, following the shell quoting and substitution rules
183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
184 |
185 | exec "$JAVACMD" "$@"
186 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/org/redbyte/life/ui/render/opengl/GameRenderer.kt:
--------------------------------------------------------------------------------
1 | package org.redbyte.life.ui.render.opengl
2 |
3 | import android.content.Context
4 | import android.opengl.GLES20
5 | import android.opengl.GLSurfaceView.Renderer
6 | import android.opengl.Matrix
7 | import org.redbyte.life.R
8 | import org.redbyte.life.common.GameBoard
9 | import java.nio.ByteBuffer
10 | import java.nio.ByteOrder
11 | import javax.microedition.khronos.egl.EGLConfig
12 | import javax.microedition.khronos.opengles.GL10
13 |
14 | class GameRenderer(
15 | private val context: Context,
16 | private val gameBoard: GameBoard,
17 | private val onCellCountUpdate: (Int, Int) -> Unit
18 | ) : Renderer {
19 |
20 | private var shaderProgramId: Int = 0
21 | private val projectionMatrix = FloatArray(16)
22 | private var lastUpdateTime = System.nanoTime()
23 | private val updateInterval = 128_000_000L
24 | private var turnCounter = 0
25 | private var aspectRatio: Float = 1f
26 |
27 | override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) {
28 | initializeOpenGL()
29 | shaderProgramId = createShaderProgram(R.raw.vertex_shader, R.raw.fragment_shader)
30 | }
31 |
32 | override fun onDrawFrame(gl: GL10?) {
33 | GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)
34 | GLES20.glUseProgram(shaderProgramId)
35 |
36 | val deltaTime = System.nanoTime() - lastUpdateTime
37 | renderGameBoard()
38 |
39 | if (deltaTime >= updateInterval) {
40 | updateGameState()
41 | lastUpdateTime = System.nanoTime()
42 | }
43 | }
44 |
45 | override fun onSurfaceChanged(gl: GL10?, width: Int, height: Int) {
46 | GLES20.glViewport(0, 0, width, height)
47 | aspectRatio = if (width >= height) {
48 | width.toFloat() / height
49 | } else {
50 | height.toFloat() / width
51 | }
52 | setProjectionMatrix(width, height)
53 | }
54 |
55 | private fun initializeOpenGL() {
56 | GLES20.glClearColor(0f, 0f, 0.2f, 1.0f)
57 | }
58 |
59 | private fun createShaderProgram(vertexShaderResId: Int, fragmentShaderResId: Int): Int {
60 | val vertexShaderCode = readShaderCode(vertexShaderResId)
61 | val fragmentShaderCode = readShaderCode(fragmentShaderResId)
62 |
63 | return listOf(
64 | loadShader(GLES20.GL_VERTEX_SHADER, vertexShaderCode),
65 | loadShader(GLES20.GL_FRAGMENT_SHADER, fragmentShaderCode)
66 | ).fold(GLES20.glCreateProgram()) { program, shader ->
67 | GLES20.glAttachShader(program, shader)
68 | program
69 | }.also {
70 | GLES20.glLinkProgram(it)
71 | }
72 | }
73 |
74 | private fun readShaderCode(resId: Int): String =
75 | context.resources.openRawResource(resId).bufferedReader().use { it.readText() }
76 |
77 | private fun loadShader(type: Int, shaderCode: String): Int =
78 | GLES20.glCreateShader(type).apply {
79 | GLES20.glShaderSource(this, shaderCode)
80 | GLES20.glCompileShader(this)
81 | }
82 |
83 | private fun setProjectionMatrix(width: Int, height: Int) {
84 | val aspectRatio = (width.toFloat() / height).let { if (width >= height) it else 1 / it }
85 | Matrix.orthoM(
86 | projectionMatrix, 0,
87 | if (width >= height) -aspectRatio else -1f,
88 | if (width >= height) aspectRatio else 1f,
89 | -1f, 1f, -1f, 1f
90 | )
91 | }
92 |
93 | private fun renderGameBoard() {
94 | val handles = getHandles()
95 |
96 | GLES20.glUniformMatrix4fv(handles.mvpMatrixHandle, 1, false, projectionMatrix, 0)
97 |
98 | gameBoard.matrix.forEachIndexed { y, row ->
99 | drawAliveCellsInRow(row, y, handles)
100 | }
101 | }
102 |
103 | private fun getHandles(): Handles = Handles(
104 | positionHandle = GLES20.glGetAttribLocation(shaderProgramId, "vPosition"),
105 | colorHandle = GLES20.glGetUniformLocation(shaderProgramId, "vColor"),
106 | mvpMatrixHandle = GLES20.glGetUniformLocation(shaderProgramId, "uMVPMatrix")
107 | )
108 |
109 | private fun drawAliveCellsInRow(row: Long, y: Int, handles: Handles) {
110 | val aliveCellColor = floatArrayOf(0.0f, 1.0f, 0.0f, 1.0f)
111 | val vertexStride = COORDS_PER_VERTEX * 4
112 |
113 | (0 until gameBoard.settings.width)
114 | .filter { (row shr it) and 1L == 1L }
115 | .map { x -> calculateSquareCoords(x, y) }
116 | .forEach { squareCoords ->
117 | drawCell(squareCoords, handles, aliveCellColor, vertexStride)
118 | }
119 | }
120 |
121 | private fun drawCell(squareCoords: FloatArray, handles: Handles, color: FloatArray, vertexStride: Int) {
122 | val vertexBuffer = createVertexBuffer(squareCoords)
123 | GLES20.glEnableVertexAttribArray(handles.positionHandle)
124 | GLES20.glVertexAttribPointer(
125 | handles.positionHandle,
126 | COORDS_PER_VERTEX,
127 | GLES20.GL_FLOAT,
128 | false,
129 | vertexStride,
130 | vertexBuffer
131 | )
132 | GLES20.glUniform4fv(handles.colorHandle, 1, color, 0)
133 | GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, squareCoords.size / COORDS_PER_VERTEX)
134 | GLES20.glDisableVertexAttribArray(handles.positionHandle)
135 | }
136 |
137 | private fun updateGameState() {
138 | gameBoard.update()
139 | onCellCountUpdate(gameBoard.countLivingCells(), ++turnCounter)
140 | }
141 |
142 | private fun createVertexBuffer(squareCoords: FloatArray) = ByteBuffer.allocateDirect(squareCoords.size * 4).run {
143 | order(ByteOrder.nativeOrder())
144 | asFloatBuffer().apply {
145 | put(squareCoords)
146 | position(0)
147 | }
148 | }
149 |
150 | private fun calculateSquareCoords(x: Int, y: Int): FloatArray {
151 | val normalizedCellWidth = 2.0f / gameBoard.settings.width
152 | val normalizedCellHeight = 2.0f / gameBoard.settings.height
153 | val normalizedX = -1f + x * normalizedCellWidth
154 | val normalizedY = 1f - y * normalizedCellHeight
155 |
156 | return floatArrayOf(
157 | normalizedX, normalizedY,
158 | normalizedX, normalizedY - normalizedCellHeight,
159 | normalizedX + normalizedCellWidth, normalizedY,
160 | normalizedX + normalizedCellWidth, normalizedY - normalizedCellHeight
161 | )
162 | }
163 |
164 |
165 | companion object {
166 | const val COORDS_PER_VERTEX = 2
167 | }
168 |
169 | data class Handles(
170 | val positionHandle: Int,
171 | val colorHandle: Int,
172 | val mvpMatrixHandle: Int
173 | )
174 | }
175 |
--------------------------------------------------------------------------------
/app/src/test/java/org/redbyte/life/common/GameBoardTest.kt:
--------------------------------------------------------------------------------
1 | package org.redbyte.life.common
2 |
3 | import org.junit.Assert.*
4 | import org.junit.Before
5 | import org.junit.Test
6 | import org.redbyte.life.common.data.GameSettings
7 | import org.redbyte.life.common.domain.ClassicRule
8 | import org.redbyte.life.common.domain.DayAndNightRule
9 | import org.redbyte.life.common.domain.DiamoebaRule
10 | import org.redbyte.life.common.domain.HighLifeRule
11 | import org.redbyte.life.common.domain.LifeWithoutDeathRule
12 | import org.redbyte.life.common.domain.MorleyRule
13 | import org.redbyte.life.common.domain.ReplicatorRule
14 | import org.redbyte.life.common.domain.SeedsRule
15 | import org.redbyte.life.common.domain.TwoByTwoRule
16 |
17 | class GameBoardTest {
18 |
19 | private lateinit var gameSettings: GameSettings
20 | private lateinit var gameBoard: GameBoard
21 |
22 | @Before
23 | fun setup() {
24 | gameSettings =
25 | GameSettings(width = 5, height = 5, initialPopulation = 5, rule = ClassicRule)
26 | gameBoard = GameBoard(gameSettings)
27 | }
28 |
29 | @Test
30 | fun `initial population should be set correctly`() {
31 | val livingCellsCount = gameBoard.countLivingCells()
32 | assertEquals(gameSettings.initialPopulation, livingCellsCount)
33 | }
34 |
35 | @Test
36 | fun `board should update state after applying rules with predefined configuration`() {
37 | val customSettings =
38 | GameSettings(width = 3, height = 3, initialPopulation = 3, rule = ClassicRule)
39 | val customGameBoard = GameBoard(customSettings)
40 |
41 | customGameBoard.matrix = listOf(
42 | 0b010L, // Вторая клетка живая
43 | 0b111L, // Все три клетки живые
44 | 0b010L // Вторая клетка живая
45 | )
46 |
47 | val initialLivingCells = customGameBoard.countLivingCells()
48 | customGameBoard.update()
49 | val updatedLivingCells = customGameBoard.countLivingCells()
50 | assertNotEquals(initialLivingCells, updatedLivingCells)
51 | }
52 |
53 | @Test
54 | fun `board should maintain correct living cell count after multiple updates`() {
55 | repeat(5) {
56 | gameBoard.update()
57 | }
58 | val livingCellsCount = gameBoard.countLivingCells()
59 | assertTrue(livingCellsCount >= 0)
60 | }
61 |
62 | @Test
63 | fun `should correctly calculate neighbors for middle cell`() {
64 | val customSettings =
65 | GameSettings(width = 3, height = 3, initialPopulation = 3, rule = ClassicRule)
66 | val customGameBoard = GameBoard(customSettings)
67 |
68 | customGameBoard.matrix = listOf(
69 | 0b010L, // Вторая клетка живая
70 | 0b111L, // Все три клетки живые
71 | 0b010L // Вторая клетка живая
72 | )
73 |
74 | // Проверяем количество соседей для центральной клетки (1,1)
75 | val neighbors = customGameBoard.countNeighbors(1, 1)
76 | assertEquals(4, neighbors) // Ожидаем 4 соседа
77 | }
78 |
79 | @Test
80 | fun `should handle empty board correctly`() {
81 | val customSettings =
82 | GameSettings(width = 3, height = 3, initialPopulation = 0, rule = ClassicRule)
83 | val customGameBoard = GameBoard(customSettings)
84 |
85 | customGameBoard.matrix = listOf(
86 | 0b000L, // Пустая строка
87 | 0b000L, // Пустая строка
88 | 0b000L // Пустая строка
89 | )
90 |
91 | val initialLivingCells = customGameBoard.countLivingCells()
92 | assertEquals(0, initialLivingCells)
93 |
94 | customGameBoard.update()
95 | val updatedLivingCells = customGameBoard.countLivingCells()
96 | assertEquals(0, updatedLivingCells)
97 | }
98 |
99 | @Test
100 | fun `board should oscillate correctly with predefined blinker configuration`() {
101 | val customSettings =
102 | GameSettings(width = 5, height = 5, initialPopulation = 3, rule = ClassicRule)
103 | val customGameBoard = GameBoard(customSettings)
104 |
105 | // Инициализируем конфигурацию "мигалки" (Blinker)
106 | customGameBoard.matrix = listOf(
107 | 0b00000L,
108 | 0b00000L,
109 | 0b01110L, // Три живые клетки по горизонтали
110 | 0b00000L,
111 | 0b00000L
112 | )
113 |
114 | // Проверим количество живых клеток
115 | val initialLivingCells = customGameBoard.countLivingCells()
116 | assertEquals(3, initialLivingCells)
117 |
118 | // Обновляем состояние (ожидаем, что мигалка сменит горизонталь на вертикаль)
119 | customGameBoard.update()
120 |
121 | // Проверяем, что после обновления мигалка стала вертикальной
122 | val expectedVerticalBlinker = listOf(
123 | 0b00000L,
124 | 0b00100L, // Одна клетка
125 | 0b00100L, // Одна клетка
126 | 0b00100L, // Одна клетка
127 | 0b00000L
128 | )
129 | assertEquals(expectedVerticalBlinker, customGameBoard.matrix)
130 |
131 | // Еще раз обновляем (ожидаем, что мигалка вернется к горизонтальной конфигурации)
132 | customGameBoard.update()
133 |
134 | val expectedHorizontalBlinker = listOf(
135 | 0b00000L,
136 | 0b00000L,
137 | 0b01110L, // Три клетки по горизонтали
138 | 0b00000L,
139 | 0b00000L
140 | )
141 | assertEquals(expectedHorizontalBlinker, customGameBoard.matrix)
142 | }
143 |
144 | @Test
145 | fun `should correctly count living cells after update`() {
146 | val customSettings =
147 | GameSettings(width = 5, height = 5, initialPopulation = 3, rule = ClassicRule)
148 | val customGameBoard = GameBoard(customSettings)
149 |
150 | // Устанавливаем конфигурацию "мигалки" (Blinker), которая изменится после обновления
151 | customGameBoard.matrix = listOf(
152 | 0b00000L, // Пустая строка
153 | 0b00000L, // Пустая строка
154 | 0b01110L, // Три живые клетки по горизонтали (мигалка)
155 | 0b00000L, // Пустая строка
156 | 0b00000L // Пустая строка
157 | )
158 |
159 | // Проверяем количество живых клеток до обновления
160 | val initialLivingCells = customGameBoard.countLivingCells()
161 | assertEquals(3, initialLivingCells) // У нас 3 живые клетки в начальной конфигурации
162 |
163 | // Обновляем состояние
164 | customGameBoard.update()
165 |
166 | // Проверяем количество живых клеток после обновления
167 | val updatedLivingCells = customGameBoard.countLivingCells()
168 |
169 | // Ожидаем, что количество живых клеток останется тем же
170 | assertEquals(3, updatedLivingCells)
171 |
172 | // Проверяем, что расположение клеток изменилось (с горизонтального на вертикальное)
173 | val expectedNewMatrix = listOf(
174 | 0b00000L,
175 | 0b00100L, // После обновления мигалка должна стать вертикальной
176 | 0b00100L, // После обновления мигалка должна стать вертикальной
177 | 0b00100L, // После обновления мигалка должна стать вертикальной
178 | 0b00000L
179 | )
180 | assertEquals(expectedNewMatrix, customGameBoard.matrix)
181 | }
182 |
183 | @Test
184 | fun `should not change living cells for stable block pattern`() {
185 | val customSettings =
186 | GameSettings(width = 5, height = 5, initialPopulation = 4, rule = ClassicRule)
187 | val customGameBoard = GameBoard(customSettings)
188 |
189 | // Устанавливаем конфигурацию "Блока" (Block) — стабильная структура
190 | customGameBoard.matrix = listOf(
191 | 0b00000L, // Пустая строка
192 | 0b01100L, // Две клетки рядом
193 | 0b01100L, // Две клетки рядом
194 | 0b00000L, // Пустая строка
195 | 0b00000L // Пустая строка
196 | )
197 |
198 | // Проверяем количество живых клеток до обновления
199 | val initialLivingCells = customGameBoard.countLivingCells()
200 | assertEquals(4, initialLivingCells) // У нас 4 живые клетки в конфигурации блока
201 |
202 | // Обновляем состояние
203 | customGameBoard.update()
204 |
205 | // Проверяем, что количество живых клеток остается таким же после обновления
206 | val updatedLivingCells = customGameBoard.countLivingCells()
207 | assertEquals(4, updatedLivingCells)
208 |
209 | // Проверяем, что конфигурация осталась такой же (блок не изменяется)
210 | val expectedBlockMatrix = listOf(
211 | 0b00000L,
212 | 0b01100L, // Структура блока остается такой же
213 | 0b01100L, // Структура блока остается такой же
214 | 0b00000L,
215 | 0b00000L
216 | )
217 | assertEquals(expectedBlockMatrix, customGameBoard.matrix)
218 | }
219 |
220 | @Test
221 | fun `board should update state correctly with ReplicatorRule`() {
222 | val customSettings =
223 | GameSettings(width = 3, height = 3, initialPopulation = 3, rule = ReplicatorRule)
224 | val customGameBoard = GameBoard(customSettings)
225 |
226 | customGameBoard.matrix = listOf(
227 | 0b010L, // Вторая клетка живая
228 | 0b111L, // Все три клетки живые
229 | 0b010L // Вторая клетка живая
230 | )
231 |
232 | val initialLivingCells = customGameBoard.countLivingCells()
233 | customGameBoard.update()
234 | val updatedLivingCells = customGameBoard.countLivingCells()
235 | assertNotEquals(initialLivingCells, updatedLivingCells)
236 | }
237 |
238 | @Test
239 | fun `should handle large values in matrix without overflow`() {
240 | val customSettings =
241 | GameSettings(width = 64, height = 1, initialPopulation = 1, rule = ClassicRule)
242 | val customGameBoard = GameBoard(customSettings)
243 |
244 | // Устанавливаем самое большое возможное значение в 64-битную строку
245 | customGameBoard.matrix = listOf(0b1L.shl(63))
246 |
247 | // Проверяем, что не произойдет переполнения при обновлении
248 | customGameBoard.update()
249 |
250 | // Ожидаем, что после обновления состояние не изменится, так как у клетки нет соседей
251 | assertEquals(0L, customGameBoard.matrix[0])
252 | }
253 |
254 | @Test
255 | fun `empty board should remain empty for all rules`() {
256 | val rules = listOf(
257 | ClassicRule,
258 | DayAndNightRule,
259 | HighLifeRule,
260 | MorleyRule,
261 | TwoByTwoRule,
262 | DiamoebaRule,
263 | LifeWithoutDeathRule,
264 | ReplicatorRule,
265 | SeedsRule
266 | )
267 |
268 | rules.forEach { rule ->
269 | val customSettings =
270 | GameSettings(width = 3, height = 3, initialPopulation = 0, rule = rule)
271 | val customGameBoard = GameBoard(customSettings)
272 |
273 | // Инициализируем пустое поле
274 | customGameBoard.matrix = listOf(
275 | 0b000L,
276 | 0b000L,
277 | 0b000L
278 | )
279 |
280 | // Проверяем количество живых клеток до обновления
281 | val initialLivingCells = customGameBoard.countLivingCells()
282 | assertEquals(0, initialLivingCells)
283 |
284 | // Обновляем состояние
285 | customGameBoard.update()
286 |
287 | // Проверяем количество живых клеток после обновления
288 | val updatedLivingCells = customGameBoard.countLivingCells()
289 | assertEquals(0, updatedLivingCells)
290 | }
291 | }
292 |
293 | @Test
294 | fun `should handle very small grid correctly`() {
295 | val customSettings =
296 | GameSettings(width = 2, height = 2, initialPopulation = 2, rule = ClassicRule)
297 | val customGameBoard = GameBoard(customSettings)
298 |
299 | customGameBoard.matrix = listOf(
300 | 0b11L, // Две клетки живые
301 | 0b00L // Пустая строка
302 | )
303 |
304 | val initialLivingCells = customGameBoard.countLivingCells()
305 | assertEquals(2, initialLivingCells)
306 |
307 | customGameBoard.update()
308 |
309 | val updatedLivingCells = customGameBoard.countLivingCells()
310 | assertTrue(updatedLivingCells >= 0) // Должны убедиться, что клеток не станет меньше нуля
311 | }
312 |
313 | @Test
314 | fun `should correctly handle random initial configurations`() {
315 | repeat(100) {
316 | val customSettings =
317 | GameSettings(width = 5, height = 5, initialPopulation = 5, rule = ClassicRule)
318 | val customGameBoard = GameBoard(customSettings)
319 |
320 | val initialLivingCells = customGameBoard.countLivingCells()
321 |
322 | // Обновляем состояние несколько раз
323 | repeat(10) {
324 | customGameBoard.update()
325 | }
326 |
327 | // Проверяем, что количество живых клеток остается в допустимых границах
328 | val updatedLivingCells = customGameBoard.countLivingCells()
329 | assertTrue(updatedLivingCells in 0..25)
330 | }
331 | }
332 |
333 | }
334 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/org/redbyte/life/ui/settings/SettingsScreen.kt:
--------------------------------------------------------------------------------
1 | package org.redbyte.life.ui.settings
2 |
3 | import IcArrowDown
4 | import androidx.compose.animation.AnimatedVisibility
5 | import androidx.compose.animation.animateColorAsState
6 | import androidx.compose.animation.core.animateFloatAsState
7 | import androidx.compose.animation.core.tween
8 | import androidx.compose.animation.expandVertically
9 | import androidx.compose.animation.fadeIn
10 | import androidx.compose.animation.fadeOut
11 | import androidx.compose.animation.shrinkVertically
12 | import androidx.compose.foundation.Image
13 | import androidx.compose.foundation.background
14 | import androidx.compose.foundation.clickable
15 | import androidx.compose.foundation.layout.*
16 | import androidx.compose.foundation.rememberScrollState
17 | import androidx.compose.foundation.text.KeyboardOptions
18 | import androidx.compose.foundation.text.selection.TextSelectionColors
19 | import androidx.compose.foundation.verticalScroll
20 | import androidx.compose.material3.Divider
21 | import androidx.compose.material3.DropdownMenuItem
22 | import androidx.compose.material3.Surface
23 | import androidx.compose.material3.Text
24 | import androidx.compose.material3.TextField
25 | import androidx.compose.material3.TextFieldDefaults
26 | import androidx.compose.runtime.*
27 | import androidx.compose.ui.Alignment
28 | import androidx.compose.ui.Modifier
29 | import androidx.compose.ui.draw.rotate
30 | import androidx.compose.ui.graphics.ImageBitmap
31 | import androidx.compose.ui.res.imageResource
32 | import androidx.compose.ui.res.stringResource
33 | import androidx.compose.ui.text.font.FontWeight
34 | import androidx.compose.ui.text.input.KeyboardType
35 | import androidx.compose.ui.unit.dp
36 | import androidx.navigation.NavHostController
37 | import org.redbyte.life.R
38 | import org.redbyte.life.common.data.GameSettings
39 | import org.redbyte.life.common.domain.*
40 |
41 | import org.redbyte.life.ui.theme.*
42 |
43 | @Composable
44 | fun SettingsScreen(navController: NavHostController, viewModel: SharedGameSettingsViewModel) {
45 | val gameSettings = viewModel.getGameSettings()
46 | var width by remember { mutableStateOf(gameSettings.width.toString()) }
47 | var height by remember { mutableStateOf(gameSettings.height.toString()) }
48 | var initialPopulation by remember { mutableStateOf(gameSettings.initialPopulation.toString()) }
49 | var selectedRule by remember { mutableStateOf(gameSettings.rule) }
50 |
51 | Surface(color = baseBlack) {
52 | Column(
53 | modifier = Modifier
54 | .fillMaxSize()
55 | .padding(16.dp),
56 | horizontalAlignment = Alignment.CenterHorizontally
57 | ) {
58 | HeaderTitle(text = stringResource(R.string.game_settings))
59 | Spacer(modifier = Modifier.height(16.dp))
60 |
61 | Column(
62 | modifier = Modifier
63 | .weight(1f)
64 | .verticalScroll(rememberScrollState())
65 | ) {
66 | GameSettingsInputFields(
67 | width = width,
68 | height = height,
69 | initialPopulation = initialPopulation,
70 | onWidthChange = {
71 | width = it
72 | viewModel.setupSettings(
73 | GameSettings(
74 | width = if (it.isNotEmpty()) it.toInt() else 0,
75 | height = height.toInt(),
76 | initialPopulation = initialPopulation.toInt(),
77 | rule = selectedRule
78 | )
79 | )
80 | },
81 | onHeightChange = {
82 | height = it
83 | viewModel.setupSettings(
84 | GameSettings(
85 | width = width.toInt(),
86 | height = if (it.isNotEmpty()) it.toInt() else 0,
87 | initialPopulation = initialPopulation.toInt(),
88 | rule = selectedRule
89 | )
90 | )
91 | },
92 | onInitialPopulationChange = {
93 | initialPopulation = it
94 | viewModel.setupSettings(
95 | GameSettings(
96 | width = width.toInt(),
97 | height = height.toInt(),
98 | initialPopulation = if (it.isNotEmpty()) it.toInt() else 0,
99 | rule = selectedRule
100 | )
101 | )
102 | }
103 | )
104 |
105 | Spacer(modifier = Modifier.height(16.dp))
106 |
107 | RuleSelectionDropdown(
108 | selectedRule = selectedRule,
109 | onRuleSelected = {
110 | selectedRule = it
111 | viewModel.setupSettings(
112 | GameSettings(
113 | width = width.toInt(),
114 | height = height.toInt(),
115 | initialPopulation = initialPopulation.toInt(),
116 | rule = selectedRule
117 | )
118 | )
119 | }
120 | )
121 |
122 | Spacer(modifier = Modifier.height(16.dp))
123 | }
124 |
125 | GameSelectionButtons(
126 | width = width,
127 | height = height,
128 | initialPopulation = initialPopulation,
129 | selectedRule = selectedRule,
130 | navController = navController,
131 | viewModel = viewModel
132 | )
133 | }
134 | }
135 | }
136 |
137 | @Composable
138 | fun HeaderTitle(text: String) {
139 | Text(
140 | text = text,
141 | color = greenSeaWave,
142 | fontWeight = FontWeight.Bold
143 | )
144 | }
145 |
146 | @Composable
147 | fun GameSettingsInputFields(
148 | width: String,
149 | height: String,
150 | initialPopulation: String,
151 | onWidthChange: (String) -> Unit,
152 | onHeightChange: (String) -> Unit,
153 | onInitialPopulationChange: (String) -> Unit
154 | ) {
155 | NumberInputField(
156 | value = width,
157 | onValueChange = onWidthChange,
158 | label = stringResource(R.string.field_width)
159 | )
160 | NumberInputField(
161 | value = height,
162 | onValueChange = onHeightChange,
163 | label = stringResource(R.string.field_height)
164 | )
165 | NumberInputField(
166 | value = initialPopulation,
167 | onValueChange = onInitialPopulationChange,
168 | label = stringResource(R.string.initial_population)
169 | )
170 | }
171 |
172 | @Composable
173 | fun RuleSelectionDropdown(selectedRule: Rule, onRuleSelected: (Rule) -> Unit) {
174 | var expanded by remember { mutableStateOf(false) }
175 | val rules = listOf(
176 | ClassicRule,
177 | HighLifeRule,
178 | DayAndNightRule,
179 | MorleyRule,
180 | TwoByTwoRule,
181 | DiamoebaRule,
182 | LifeWithoutDeathRule,
183 | ReplicatorRule,
184 | SeedsRule
185 | )
186 | val ruleNames = listOf(
187 | stringResource(R.string.classic_rule),
188 | stringResource(R.string.highlife_rule),
189 | stringResource(R.string.day_and_night_rule),
190 | stringResource(R.string.morley_rule),
191 | stringResource(R.string.two_by_two_rule),
192 | stringResource(R.string.diamoeba_rule),
193 | stringResource(R.string.life_without_death_rule),
194 | stringResource(R.string.replicator_rule),
195 | stringResource(R.string.seeds_rule)
196 | )
197 |
198 | val arrowRotationDegree by animateFloatAsState(if (expanded) 180f else 0f, label = "")
199 |
200 | Column(
201 | modifier = Modifier
202 | .fillMaxWidth()
203 | .padding(16.dp)
204 | ) {
205 | Row(
206 | modifier = Modifier
207 | .fillMaxWidth()
208 | .clickable { expanded = !expanded }
209 | .padding(16.dp),
210 | verticalAlignment = Alignment.CenterVertically
211 | ) {
212 | Text(
213 | text = ruleNames[rules.indexOf(selectedRule)],
214 | fontWeight = FontWeight.Bold,
215 | color = baseWhite,
216 | modifier = Modifier.weight(1f)
217 | )
218 | Image(
219 | imageVector = IcArrowDown,
220 | contentDescription = null,
221 | modifier = Modifier
222 | .size(24.dp)
223 | .rotate(arrowRotationDegree)
224 | )
225 | }
226 |
227 | AnimatedVisibility(
228 | visible = expanded,
229 | enter = expandVertically(animationSpec = tween(durationMillis = 300)) + fadeIn(),
230 | exit = shrinkVertically(animationSpec = tween(durationMillis = 300)) + fadeOut()
231 | ) {
232 | Column(
233 | modifier = Modifier
234 | .fillMaxWidth()
235 | .height(200.dp)
236 | .background(baseBlack)
237 | .verticalScroll(rememberScrollState())
238 | ) {
239 | rules.forEachIndexed { index, rule ->
240 | val backgroundColor by animateColorAsState(
241 | targetValue = if (rule == selectedRule) greenSeaWave else baseBlack,
242 | animationSpec = tween(durationMillis = 300),
243 | label = ""
244 | )
245 |
246 | DropdownMenuItem(
247 | text = {
248 | Text(
249 | text = ruleNames[index],
250 | color = if (rule == selectedRule) baseBlack else baseWhite,
251 | )
252 | },
253 | modifier = Modifier
254 | .background(backgroundColor)
255 | .fillMaxWidth()
256 | .clickable {
257 | onRuleSelected(rule)
258 | expanded = false
259 | },
260 | onClick = {
261 | onRuleSelected(rule)
262 | expanded = false
263 | }
264 | )
265 |
266 | if (index != rules.lastIndex) {
267 | Divider(color = baseLightGray, thickness = 1.dp)
268 | }
269 | }
270 | }
271 | }
272 | }
273 | }
274 |
275 | @Composable
276 | fun GameSelectionButtons(
277 | width: String,
278 | height: String,
279 | initialPopulation: String,
280 | selectedRule: Rule,
281 | navController: NavHostController,
282 | viewModel: SharedGameSettingsViewModel
283 | ) {
284 | Row(
285 | horizontalArrangement = Arrangement.SpaceEvenly,
286 | modifier = Modifier.fillMaxWidth()
287 | ) {
288 | GameButton(
289 | imageId = R.drawable.ic_biohazard,
290 | contentDescription = stringResource(R.string.compose_game),
291 | onClick = {
292 | viewModel.setupSettings(
293 | GameSettings(
294 | width = width.toInt(),
295 | height = height.toInt(),
296 | initialPopulation = initialPopulation.toInt(),
297 | rule = selectedRule
298 | )
299 | )
300 | viewModel.resetGameBoard()
301 | navController.navigate("lifeGame")
302 | },
303 | modifier = Modifier.weight(1f)
304 | )
305 | GameButton(
306 | imageId = R.drawable.ic_biohazard2d,
307 | contentDescription = stringResource(R.string.opengl_game),
308 | onClick = {
309 | viewModel.setupSettings(
310 | GameSettings(
311 | width = width.toInt(),
312 | height = height.toInt(),
313 | initialPopulation = initialPopulation.toInt(),
314 | rule = selectedRule
315 | )
316 | )
317 | viewModel.resetGameBoard()
318 | navController.navigate("openGLGame")
319 | },
320 | modifier = Modifier.weight(1f)
321 | )
322 | }
323 | }
324 |
325 | @Composable
326 | fun GameButton(
327 | imageId: Int,
328 | contentDescription: String,
329 | onClick: () -> Unit,
330 | modifier: Modifier = Modifier
331 | ) {
332 | Image(
333 | bitmap = ImageBitmap.imageResource(id = imageId),
334 | contentDescription = contentDescription,
335 | modifier = modifier
336 | .aspectRatio(1f)
337 | .clickable { onClick() }
338 | )
339 | }
340 |
341 | @Composable
342 | fun NumberInputField(value: String, onValueChange: (String) -> Unit, label: String) {
343 | TextField(
344 | value = value,
345 | onValueChange = onValueChange,
346 | label = { Text(label) },
347 | singleLine = true,
348 | keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
349 | colors = numberInputFieldColors(),
350 | modifier = Modifier.fillMaxWidth()
351 | )
352 | }
353 |
354 | @Composable
355 | fun numberInputFieldColors() = TextFieldDefaults.colors(
356 | focusedTextColor = baseWhite,
357 | unfocusedTextColor = baseWhite,
358 | cursorColor = greenSeaWave,
359 | focusedContainerColor = baseBlack,
360 | unfocusedContainerColor = baseBlack,
361 | focusedIndicatorColor = greenSeaWave,
362 | unfocusedIndicatorColor = baseLightGray,
363 | selectionColors = TextSelectionColors(
364 | handleColor = greenSeaWave,
365 | backgroundColor = baseGreen.copy(alpha = 0.3f)
366 | ),
367 | focusedLabelColor = greenSeaWave,
368 | unfocusedLabelColor = baseLightGray
369 | )
370 |
--------------------------------------------------------------------------------