From f8fc7f64cc5ac64b8e4e6526f88338db129de4ac Mon Sep 17 00:00:00 2001 From: SlavaVlad Date: Fri, 15 May 2026 01:30:16 +0300 Subject: [PATCH] Implementation - GUI step --- src/main/kotlin/com/nano/lab2/Main.kt | 35 ++ .../com/nano/lab2/data/ConfigRepository.kt | 32 ++ .../kotlin/com/nano/lab2/model/AppState.kt | 15 + src/main/kotlin/com/nano/lab2/model/Config.kt | 75 ++++ .../kotlin/com/nano/lab2/model/GameOfLife.kt | 125 ++++++ .../com/nano/lab2/model/OneDimensionalCA.kt | 51 +++ .../kotlin/com/nano/lab2/ui/MainScreen.kt | 70 +++ src/main/kotlin/com/nano/lab2/ui/Sidebar.kt | 405 ++++++++++++++++++ src/main/kotlin/com/nano/lab2/ui/Theme.kt | 42 ++ .../nano/lab2/ui/gameoflife/GameOfLifeView.kt | 242 +++++++++++ .../lab2/ui/gameoflife/PatternClassifier.kt | 135 ++++++ .../ui/onedimensional/OneDimensionalView.kt | 252 +++++++++++ .../com/nano/lab2/util/ExperimentRunner.kt | 133 ++++++ 13 files changed, 1612 insertions(+) create mode 100644 src/main/kotlin/com/nano/lab2/Main.kt create mode 100644 src/main/kotlin/com/nano/lab2/data/ConfigRepository.kt create mode 100644 src/main/kotlin/com/nano/lab2/model/AppState.kt create mode 100644 src/main/kotlin/com/nano/lab2/model/Config.kt create mode 100644 src/main/kotlin/com/nano/lab2/model/GameOfLife.kt create mode 100644 src/main/kotlin/com/nano/lab2/model/OneDimensionalCA.kt create mode 100644 src/main/kotlin/com/nano/lab2/ui/MainScreen.kt create mode 100644 src/main/kotlin/com/nano/lab2/ui/Sidebar.kt create mode 100644 src/main/kotlin/com/nano/lab2/ui/Theme.kt create mode 100644 src/main/kotlin/com/nano/lab2/ui/gameoflife/GameOfLifeView.kt create mode 100644 src/main/kotlin/com/nano/lab2/ui/gameoflife/PatternClassifier.kt create mode 100644 src/main/kotlin/com/nano/lab2/ui/onedimensional/OneDimensionalView.kt create mode 100644 src/main/kotlin/com/nano/lab2/util/ExperimentRunner.kt diff --git a/src/main/kotlin/com/nano/lab2/Main.kt b/src/main/kotlin/com/nano/lab2/Main.kt new file mode 100644 index 0000000..f8045ba --- /dev/null +++ b/src/main/kotlin/com/nano/lab2/Main.kt @@ -0,0 +1,35 @@ +package com.nano.lab2 + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.WindowState +import androidx.compose.ui.window.application +import model.* +import ui.* +import ui.onedimensional.OneDimensionalView +import data.ConfigRepository + +fun main() = application { + val configRepository = remember { ConfigRepository() } + var config by remember { mutableStateOf(configRepository.load()) } + + Window( + onCloseRequest = ::exitApplication, + title = "Теория Систем - Лаб2 игра в жизнь/клеточный автомат", + state = WindowState(width = 1200.dp, height = 800.dp) + ) { + CellularAutomataTheme { + MainScreen( + config = config, + onConfigChange = { newConfig -> + config = newConfig + configRepository.save(newConfig) + } + ) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/nano/lab2/data/ConfigRepository.kt b/src/main/kotlin/com/nano/lab2/data/ConfigRepository.kt new file mode 100644 index 0000000..baa7b23 --- /dev/null +++ b/src/main/kotlin/com/nano/lab2/data/ConfigRepository.kt @@ -0,0 +1,32 @@ +package data + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import model.AppConfig +import java.io.File + +class ConfigRepository { + private val configFile = File("config.json") + + fun load(): AppConfig { + return try { + if (configFile.exists()) { + val json = configFile.readText() + Json.decodeFromString(json) + } else { + AppConfig() + } + } catch (e: Exception) { + AppConfig() + } + } + + fun save(config: AppConfig) { + try { + val json = Json.encodeToString(config) + configFile.writeText(json) + } catch (e: Exception) { + e.printStackTrace() + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/nano/lab2/model/AppState.kt b/src/main/kotlin/com/nano/lab2/model/AppState.kt new file mode 100644 index 0000000..3d1ca1f --- /dev/null +++ b/src/main/kotlin/com/nano/lab2/model/AppState.kt @@ -0,0 +1,15 @@ +package model + +sealed class UiAction { + data object Randomize : UiAction() + data object Clear : UiAction() +} + +data class AppState( + val config: AppConfig = AppConfig(), + val action: UiAction? = null +) { + fun withAction(action: UiAction?) = copy(action = action) + fun clearAction() = copy(action = null) + fun configOnly() = copy(action = null) +} \ No newline at end of file diff --git a/src/main/kotlin/com/nano/lab2/model/Config.kt b/src/main/kotlin/com/nano/lab2/model/Config.kt new file mode 100644 index 0000000..3dba899 --- /dev/null +++ b/src/main/kotlin/com/nano/lab2/model/Config.kt @@ -0,0 +1,75 @@ +package model + +import kotlinx.serialization.Serializable + +@Serializable +data class AppConfig( + val mode: AutomatonMode = AutomatonMode.GAME_OF_LIFE, + val gridWidth: Int = 50, + val gridHeight: Int = 50, + val simulationSpeed: Int = 10, + val probability: Double = 0.5, + val oneDimensionalRule: OneDimensionalRule = OneDimensionalRule.RULE_30 +) + +@Serializable +enum class AutomatonMode { + GAME_OF_LIFE, + ONE_DIMENSIONAL +} + +@Serializable +enum class OneDimensionalRule(val value: Int) { + RULE_30(30), + RULE_90(90), + RULE_110(110), + RULE_184(184) +} + +fun OneDimensionalRule.applyRule(left: Boolean, center: Boolean, right: Boolean): Boolean { + val pattern = when { + left && center && right -> 7 + left && center && !right -> 6 + left && !center && right -> 5 + left && !center && !right -> 4 + !left && center && right -> 3 + !left && center && !right -> 2 + !left && !center && right -> 1 + else -> 0 + } + return (value shr pattern) and 1 == 1 +} + +fun gridToBitmap(grid: Array>): ByteArray { + val height = grid.size + val width = grid[0].size + val bytes = (width * height + 7) / 8 + val result = ByteArray(bytes) + + for (y in 0 until height) { + for (x in 0 until width) { + if (grid[y][x]) { + val idx = y * width + x + val byteIdx = idx / 8 + val bitIdx = idx % 8 + result[byteIdx] = (result[byteIdx].toInt() or (1 shl bitIdx)).toByte() + } + } + } + return result +} + +fun bitmapToGrid(bitmap: ByteArray, width: Int, height: Int): Array> { + val grid = Array(height) { Array(width) { false } } + for (y in 0 until height) { + for (x in 0 until width) { + val idx = y * width + x + val byteIdx = idx / 8 + val bitIdx = idx % 8 + if (byteIdx < bitmap.size) { + grid[y][x] = (bitmap[byteIdx].toInt() and (1 shl bitIdx)) != 0 + } + } + } + return grid +} \ No newline at end of file diff --git a/src/main/kotlin/com/nano/lab2/model/GameOfLife.kt b/src/main/kotlin/com/nano/lab2/model/GameOfLife.kt new file mode 100644 index 0000000..4584640 --- /dev/null +++ b/src/main/kotlin/com/nano/lab2/model/GameOfLife.kt @@ -0,0 +1,125 @@ +package model + +class GameOfLife( + val width: Int, + val height: Int +) { + private var grid: Array> = Array(height) { Array(width) { false } } + private var generation: Int = 0 + + fun initialize(probability: Double = 0.5) { + grid = Array(height) { row -> + Array(width) { col -> + Math.random() < probability + } + } + generation = 0 + } + + fun initializeFromPattern(pattern: List>) { + grid = Array(height) { row -> + Array(width) { col -> + pattern.getOrNull(row)?.getOrNull(col) ?: false + } + } + generation = 0 + } + + fun clear() { + grid = Array(height) { Array(width) { false } } + generation = 0 + } + + fun step(): Int { + val newGrid = Array(height) { Array(width) { false } } + + for (y in 0 until height) { + for (x in 0 until width) { + val neighbors = countNeighbors(x, y) + val isAlive = grid[y][x] + + newGrid[y][x] = when { + isAlive && (neighbors == 2 || neighbors == 3) -> true + !isAlive && neighbors == 3 -> true + else -> false + } + } + } + + grid = newGrid + generation++ + return generation + } + + private fun countNeighbors(x: Int, y: Int): Int { + var count = 0 + for (dy in -1..1) { + for (dx in -1..1) { + if (dx == 0 && dy == 0) continue + val nx = (x + dx + width) % width + val ny = (y + dy + height) % height + if (grid[ny][nx]) count++ + } + } + return count + } + + fun getGrid(): Array> = grid.map { it.copyOf() }.toTypedArray() + + fun getGeneration(): Int = generation + + fun getLiveCellCount(): Int = grid.sumOf { row -> row.count { it } } + + fun setCell(x: Int, y: Int, alive: Boolean) { + if (x in 0 until width && y in 0 until height) { + grid[y][x] = alive + } + } + + fun toggleCell(x: Int, y: Int) { + if (x in 0 until width && y in 0 until height) { + grid[y][x] = !grid[y][x] + } + } + + fun getConnectedComponents(): List>> { + val visited = Array(height) { BooleanArray(width) } + val components = mutableListOf>>() + + for (y in 0 until height) { + for (x in 0 until width) { + if (grid[y][x] && !visited[y][x]) { + val component = bfs(x, y, visited) + components.add(component) + } + } + } + return components + } + + private fun bfs(startX: Int, startY: Int, visited: Array): List> { + val component = mutableListOf>() + val queue = ArrayDeque>() + queue.add(Pair(startX, startY)) + visited[startY][startX] = true + + while (queue.isNotEmpty()) { + val (x, y) = queue.removeFirst() + component.add(Pair(x, y)) + + for (dy in -1..1) { + for (dx in -1..1) { + if (dx == 0 && dy == 0) continue + val nx = x + dx + val ny = y + dy + if (nx in 0 until width && ny in 0 until height && + grid[ny][nx] && !visited[ny][nx]) { + visited[ny][nx] = true + queue.add(Pair(nx, ny)) + } + } + } + } + return component + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/nano/lab2/model/OneDimensionalCA.kt b/src/main/kotlin/com/nano/lab2/model/OneDimensionalCA.kt new file mode 100644 index 0000000..5d7688d --- /dev/null +++ b/src/main/kotlin/com/nano/lab2/model/OneDimensionalCA.kt @@ -0,0 +1,51 @@ +package model + +class OneDimensionalCA( + val width: Int, + val rule: OneDimensionalRule +) { + private var currentState: BooleanArray = BooleanArray(width) + private var history: MutableList = mutableListOf() + + fun initialize(centerPosition: Int? = null) { + currentState = BooleanArray(width) { false } + if (centerPosition != null && centerPosition in 0 until width) { + currentState[centerPosition] = true + } else { + currentState[width / 2] = true + } + history.clear() + history.add(currentState.copyOf()) + } + + fun initializeRandom(probability: Double) { + currentState = BooleanArray(width) { Math.random() < probability } + history.clear() + history.add(currentState.copyOf()) + } + + fun step(): BooleanArray { + val newState = BooleanArray(width) + for (i in 0 until width) { + val left = currentState[(i - 1 + width) % width] + val center = currentState[i] + val right = currentState[(i + 1) % width] + newState[i] = rule.applyRule(left, center, right) + } + currentState = newState + history.add(currentState.copyOf()) + return currentState.copyOf() + } + + fun getCurrentState(): BooleanArray = currentState.copyOf() + + fun getHistory(): List = history.toList() + + fun getGenerations(count: Int): List { + if (count <= history.size) return history.take(count) + while (history.size < count) { + step() + } + return history.take(count) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/nano/lab2/ui/MainScreen.kt b/src/main/kotlin/com/nano/lab2/ui/MainScreen.kt new file mode 100644 index 0000000..23f359c --- /dev/null +++ b/src/main/kotlin/com/nano/lab2/ui/MainScreen.kt @@ -0,0 +1,70 @@ +package ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import model.* +import ui.gameoflife.GameOfLifeView +import ui.onedimensional.OneDimensionalView + +@Composable +fun MainScreen( + config: AppConfig, + onConfigChange: (AppConfig) -> Unit +) { + var appState by remember { mutableStateOf(AppState(config)) } + + LaunchedEffect(config) { + appState = appState.copy(config = config) + } + + Row(modifier = Modifier.fillMaxSize().background(Color.Black)) { + Sidebar( + config = appState.config, + onConfigChange = { newConfig -> + appState = appState.copy(config = newConfig) + onConfigChange(newConfig) + }, + onRandomize = { appState = appState.withAction(UiAction.Randomize) }, + onClear = { appState = appState.withAction(UiAction.Clear) } + ) + + VerticalDivider( + modifier = Modifier.fillMaxHeight(), + thickness = 2.dp, + color = MaterialTheme.colorScheme.primary + ) + + Box( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .padding(8.dp) + ) { + when (appState.config.mode) { + AutomatonMode.GAME_OF_LIFE -> GameOfLifeView( + config = appState.config, + action = appState.action, + onActionConsumed = { appState = appState.clearAction() }, + onConfigChange = { newConfig -> + appState = appState.copy(config = newConfig) + onConfigChange(newConfig) + } + ) + AutomatonMode.ONE_DIMENSIONAL -> OneDimensionalView( + config = appState.config, + action = appState.action, + onActionConsumed = { appState = appState.clearAction() }, + onConfigChange = { newConfig -> + appState = appState.copy(config = newConfig) + onConfigChange(newConfig) + } + ) + } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/nano/lab2/ui/Sidebar.kt b/src/main/kotlin/com/nano/lab2/ui/Sidebar.kt new file mode 100644 index 0000000..a75c7e9 --- /dev/null +++ b/src/main/kotlin/com/nano/lab2/ui/Sidebar.kt @@ -0,0 +1,405 @@ +package ui + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import model.* +import util.ExperimentRunner +import java.io.File +import javax.swing.JFileChooser + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun Sidebar( + config: AppConfig, + onConfigChange: (AppConfig) -> Unit, + onRandomize: () -> Unit = {}, + onClear: () -> Unit = {} +) { + var isRunning by remember { mutableStateOf(false) } + var showExperimentResults by remember { mutableStateOf(false) } + var experimentProgress by remember { mutableStateOf("") } + + val primaryColor = MaterialTheme.colorScheme.primary + + Column( + modifier = Modifier + .width(280.dp) + .fillMaxHeight() + .padding(16.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + "Настройки", + style = MaterialTheme.typography.titleLarge, + color = primaryColor + ) + + HorizontalDivider(color = primaryColor.copy(alpha = 0.3f)) + + Text( + "Режим автомата", + style = MaterialTheme.typography.labelMedium, + color = primaryColor + ) + SelectAutomatonMode( + mode = config.mode, + onModeChange = { onConfigChange(config.copy(mode = it)) } + ) + + if (config.mode == AutomatonMode.ONE_DIMENSIONAL) { + Text( + "Правило", + style = MaterialTheme.typography.labelMedium, + color = primaryColor + ) + SelectRule( + rule = config.oneDimensionalRule, + onRuleChange = { onConfigChange(config.copy(oneDimensionalRule = it)) } + ) + } + + HorizontalDivider(color = primaryColor.copy(alpha = 0.3f)) + + Text( + "Размер поля", + style = MaterialTheme.typography.labelMedium, + color = primaryColor + ) + + if (config.mode == AutomatonMode.GAME_OF_LIFE) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedTextField( + value = config.gridWidth.toString(), + onValueChange = { value -> + val width = value.toIntOrNull() ?: config.gridWidth + onConfigChange(config.copy(gridWidth = width, gridHeight = width)) + }, + label = { Text("Ширина", color = primaryColor) }, + modifier = Modifier.weight(1f), + singleLine = true, + colors = OutlinedTextFieldDefaults.colors( + focusedTextColor = primaryColor, + unfocusedTextColor = primaryColor, + focusedBorderColor = primaryColor, + unfocusedBorderColor = primaryColor.copy(alpha = 0.5f), + cursorColor = primaryColor + ) + ) + OutlinedTextField( + value = config.gridHeight.toString(), + onValueChange = { value -> + val height = value.toIntOrNull() ?: config.gridHeight + onConfigChange(config.copy(gridHeight = height)) + }, + label = { Text("Высота", color = primaryColor) }, + modifier = Modifier.weight(1f), + singleLine = true, + colors = OutlinedTextFieldDefaults.colors( + focusedTextColor = primaryColor, + unfocusedTextColor = primaryColor, + focusedBorderColor = primaryColor, + unfocusedBorderColor = primaryColor.copy(alpha = 0.5f), + cursorColor = primaryColor + ) + ) + } + } else { + OutlinedTextField( + value = config.gridWidth.toString(), + onValueChange = { value -> + val width = value.toIntOrNull() ?: config.gridWidth + onConfigChange(config.copy(gridWidth = width)) + }, + label = { Text("Ширина (ячеек)", color = primaryColor) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + colors = OutlinedTextFieldDefaults.colors( + focusedTextColor = primaryColor, + unfocusedTextColor = primaryColor, + focusedBorderColor = primaryColor, + unfocusedBorderColor = primaryColor.copy(alpha = 0.5f), + cursorColor = primaryColor + ) + ) + } + + HorizontalDivider(color = primaryColor.copy(alpha = 0.3f)) + + Text( + "Скорость симуляции", + style = MaterialTheme.typography.labelMedium, + color = primaryColor + ) + Slider( + value = config.simulationSpeed.toFloat(), + onValueChange = { onConfigChange(config.copy(simulationSpeed = it.toInt())) }, + valueRange = 1f..60f, + steps = 58, + modifier = Modifier.fillMaxWidth(), + colors = SliderDefaults.colors( + thumbColor = primaryColor, + activeTrackColor = primaryColor, + inactiveTrackColor = primaryColor.copy(alpha = 0.3f) + ) + ) + Text( + "${config.simulationSpeed} FPS", + style = MaterialTheme.typography.bodySmall, + color = primaryColor + ) + + HorizontalDivider(color = primaryColor.copy(alpha = 0.3f)) + + if (config.mode == AutomatonMode.GAME_OF_LIFE) { + Text( + "Вероятность заполнения", + style = MaterialTheme.typography.labelMedium, + color = primaryColor + ) + Slider( + value = config.probability.toFloat(), + onValueChange = { onConfigChange(config.copy(probability = it.toDouble())) }, + valueRange = 0.0f..1.0f, + modifier = Modifier.fillMaxWidth(), + colors = SliderDefaults.colors( + thumbColor = primaryColor, + activeTrackColor = primaryColor, + inactiveTrackColor = primaryColor.copy(alpha = 0.3f) + ) + ) + Text( + "${(config.probability * 100).toInt()}%", + style = MaterialTheme.typography.bodySmall, + color = primaryColor + ) + } + + HorizontalDivider(color = primaryColor.copy(alpha = 0.3f)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Button( + onClick = { isRunning = !isRunning }, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary + ) + ) { + Text(if (isRunning) "Стоп" else "Старт") + } + + OutlinedButton( + onClick = onClear, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = primaryColor + ) + ) { + Text("Сброс") + } + } + + if (config.mode == AutomatonMode.GAME_OF_LIFE) { + Button( + onClick = onRandomize, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary + ) + ) { + Text("Случайное заполнение") + } + } + + if (config.mode == AutomatonMode.GAME_OF_LIFE) { + HorizontalDivider(color = primaryColor.copy(alpha = 0.3f)) + + Text( + "Эксперименты", + style = MaterialTheme.typography.titleMedium, + color = primaryColor + ) + + Button( + onClick = { + isRunning = false + experimentProgress = "Запуск экспериментов..." + showExperimentResults = true + CoroutineScope(Dispatchers.Default).launch { + val runner = ExperimentRunner() + val results = runner.runExperiments(config.gridWidth, config.gridHeight) + val graphFile = File("stabilization_graph.png") + runner.generateGraph(results, graphFile.absolutePath) + experimentProgress = "Эксперименты завершены!\nГрафик сохранён в stabilization_graph.png" + } + }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary + ) + ) { + Text("Запустить эксперименты") + } + + if (experimentProgress.isNotEmpty()) { + Text( + experimentProgress, + style = MaterialTheme.typography.bodySmall, + color = primaryColor + ) + } + + OutlinedButton( + onClick = { + // Загрузка паттерна - пока отключено + }, + modifier = Modifier.fillMaxWidth(), + enabled = false, + colors = ButtonDefaults.outlinedButtonColors( + contentColor = primaryColor.copy(alpha = 0.5f) + ) + ) { + Text("Загрузить паттерн") + } + + OutlinedButton( + onClick = { + // Сохранение паттерна - пока отключено + }, + modifier = Modifier.fillMaxWidth(), + enabled = false, + colors = ButtonDefaults.outlinedButtonColors( + contentColor = primaryColor.copy(alpha = 0.5f) + ) + ) { + Text("Сохранить паттерн") + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SelectAutomatonMode( + mode: AutomatonMode, + onModeChange: (AutomatonMode) -> Unit +) { + var expanded by remember { mutableStateOf(false) } + val primaryColor = MaterialTheme.colorScheme.primary + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it } + ) { + OutlinedTextField( + value = when (mode) { + AutomatonMode.GAME_OF_LIFE -> "Game of Life (2D)" + AutomatonMode.ONE_DIMENSIONAL -> "1D Cellular Automaton" + }, + onValueChange = {}, + readOnly = true, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) + }, + modifier = Modifier + .menuAnchor(MenuAnchorType.PrimaryNotEditable) + .fillMaxWidth(), + colors = OutlinedTextFieldDefaults.colors( + focusedTextColor = primaryColor, + unfocusedTextColor = primaryColor, + focusedBorderColor = primaryColor, + unfocusedBorderColor = primaryColor.copy(alpha = 0.5f), + focusedTrailingIconColor = primaryColor, + unfocusedTrailingIconColor = primaryColor + ) + ) + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + containerColor = MaterialTheme.colorScheme.surface + ) { + DropdownMenuItem( + text = { Text("Game of Life (2D)", color = primaryColor) }, + onClick = { + onModeChange(AutomatonMode.GAME_OF_LIFE) + expanded = false + } + ) + DropdownMenuItem( + text = { Text("1D Cellular Automaton", color = primaryColor) }, + onClick = { + onModeChange(AutomatonMode.ONE_DIMENSIONAL) + expanded = false + } + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SelectRule( + rule: OneDimensionalRule, + onRuleChange: (OneDimensionalRule) -> Unit +) { + var expanded by remember { mutableStateOf(false) } + val primaryColor = MaterialTheme.colorScheme.primary + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it } + ) { + OutlinedTextField( + value = "Rule ${rule.value}", + onValueChange = {}, + readOnly = true, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) + }, + modifier = Modifier + .menuAnchor(MenuAnchorType.PrimaryNotEditable) + .fillMaxWidth(), + colors = OutlinedTextFieldDefaults.colors( + focusedTextColor = primaryColor, + unfocusedTextColor = primaryColor, + focusedBorderColor = primaryColor, + unfocusedBorderColor = primaryColor.copy(alpha = 0.5f), + focusedTrailingIconColor = primaryColor, + unfocusedTrailingIconColor = primaryColor + ) + ) + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + containerColor = MaterialTheme.colorScheme.surface + ) { + OneDimensionalRule.entries.forEach { r -> + DropdownMenuItem( + text = { Text("Rule ${r.value}", color = primaryColor) }, + onClick = { + onRuleChange(r) + expanded = false + } + ) + } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/nano/lab2/ui/Theme.kt b/src/main/kotlin/com/nano/lab2/ui/Theme.kt new file mode 100644 index 0000000..8de6267 --- /dev/null +++ b/src/main/kotlin/com/nano/lab2/ui/Theme.kt @@ -0,0 +1,42 @@ +package ui + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +private val GreenTerminal = Color(0xFF00FF00) +private val DarkGreen = Color(0xFF003300) +private val MediumGreen = Color(0xFF008800) +private val Black = Color(0xFF000000) + +private val GreenColorScheme = darkColorScheme( + primary = GreenTerminal, + onPrimary = Black, + primaryContainer = DarkGreen, + onPrimaryContainer = GreenTerminal, + secondary = MediumGreen, + onSecondary = Black, + secondaryContainer = DarkGreen, + onSecondaryContainer = GreenTerminal, + tertiary = GreenTerminal, + onTertiary = Black, + background = Black, + onBackground = GreenTerminal, + surface = Color(0xFF001100), + onSurface = GreenTerminal, + surfaceVariant = DarkGreen, + onSurfaceVariant = GreenTerminal, + error = Color(0xFFFF3333), + onError = Black +) + +@Composable +fun CellularAutomataTheme( + content: @Composable () -> Unit +) { + MaterialTheme( + colorScheme = GreenColorScheme, + content = content + ) +} \ No newline at end of file diff --git a/src/main/kotlin/com/nano/lab2/ui/gameoflife/GameOfLifeView.kt b/src/main/kotlin/com/nano/lab2/ui/gameoflife/GameOfLifeView.kt new file mode 100644 index 0000000..ded9bf0 --- /dev/null +++ b/src/main/kotlin/com/nano/lab2/ui/gameoflife/GameOfLifeView.kt @@ -0,0 +1,242 @@ +package ui.gameoflife + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +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.input.pointer.pointerInput +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay +import model.AppConfig +import model.GameOfLife +import model.UiAction +import ui.gameoflife.PatternClassifier.PatternInfo + +@Composable +fun GameOfLifeView( + config: AppConfig, + action: UiAction?, + onActionConsumed: () -> Unit, + onConfigChange: (AppConfig) -> Unit +) { + var game by remember(config.gridWidth, config.gridHeight) { + mutableStateOf(GameOfLife(config.gridWidth, config.gridHeight)) + } + var isRunning by remember { mutableStateOf(false) } + var generation by remember { mutableIntStateOf(0) } + var liveCells by remember { mutableIntStateOf(0) } + var detectedPatterns by remember { mutableStateOf>(emptyList()) } + var validationError by remember { mutableStateOf(null) } + + val cellColor = MaterialTheme.colorScheme.primary + val gridColor = Color(0xFF003300) + + LaunchedEffect(action) { + when (action) { + UiAction.Randomize -> { + game.initialize(config.probability) + generation = 0 + liveCells = game.getLiveCellCount() + detectedPatterns = PatternClassifier.classifyPatterns(game.getGrid()) + } + UiAction.Clear -> { + game.clear() + generation = 0 + liveCells = 0 + detectedPatterns = emptyList() + isRunning = false + } + null -> {} + } + if (action != null) onActionConsumed() + } + + LaunchedEffect(isRunning, config.simulationSpeed) { + while (isRunning) { + delay(1000L / config.simulationSpeed) + game.step() + generation = game.getGeneration() + liveCells = game.getLiveCellCount() + detectedPatterns = PatternClassifier.classifyPatterns(game.getGrid()) + } + } + + Column(modifier = Modifier.fillMaxSize().background(Color.Black)) { + Row( + modifier = Modifier.fillMaxWidth().padding(8.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text("Поколение: $generation", color = cellColor) + Text("Живых клеток: $liveCells", color = cellColor) + Text("Объектов: ${detectedPatterns.size}", color = cellColor) + } + + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .background(Color(0xFF001100)) + ) { + if (validationError != null) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = validationError!!, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.headlineSmall + ) + } + } else { + Canvas( + modifier = Modifier + .fillMaxSize() + .pointerInput(config.gridWidth, config.gridHeight) { + detectTapGestures { offset -> + val cellSize = minOf( + size.width.toFloat() / config.gridWidth, + size.height.toFloat() / config.gridHeight + ) + val gridWidthPx = cellSize * config.gridWidth + val gridHeightPx = cellSize * config.gridHeight + val offsetX = (size.width - gridWidthPx) / 2 + val offsetY = (size.height - gridHeightPx) / 2 + + val x = ((offset.x - offsetX) / cellSize).toInt() + .coerceIn(0, config.gridWidth - 1) + val y = ((offset.y - offsetY) / cellSize).toInt() + .coerceIn(0, config.gridHeight - 1) + + if (x in 0 until config.gridWidth && y in 0 until config.gridHeight) { + game.toggleCell(x, y) + liveCells = game.getLiveCellCount() + detectedPatterns = PatternClassifier.classifyPatterns(game.getGrid()) + } + } + } + ) { + val cellSize = minOf( + size.width / config.gridWidth, + size.height / config.gridHeight + ) + val gridWidthPx = cellSize * config.gridWidth + val gridHeightPx = cellSize * config.gridHeight + val offsetX = (size.width - gridWidthPx) / 2 + val offsetY = (size.height - gridHeightPx) / 2 + + for (y in 0 until config.gridHeight) { + for (x in 0 until config.gridWidth) { + drawRect( + color = gridColor, + topLeft = Offset(offsetX + x * cellSize, offsetY + y * cellSize), + size = Size(cellSize, cellSize) + ) + } + } + + val grid = game.getGrid() + for (y in 0 until config.gridHeight) { + for (x in 0 until config.gridWidth) { + if (grid[y][x]) { + drawRect( + color = cellColor, + topLeft = Offset(offsetX + x * cellSize, offsetY + y * cellSize), + size = Size(cellSize - 1, cellSize - 1) + ) + } + } + } + } + } + } + + if (detectedPatterns.isNotEmpty()) { + Row( + modifier = Modifier.fillMaxWidth().padding(8.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + detectedPatterns.take(5).forEach { pattern -> + Text( + "${pattern.type}: ${pattern.count}", + style = MaterialTheme.typography.bodySmall, + color = cellColor + ) + } + } + } + + Row( + modifier = Modifier.fillMaxWidth().padding(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Button( + onClick = { + validationError = null + if (config.gridWidth < 5 || config.gridHeight < 5) { + validationError = "Размер поля слишком маленький (минимум 5x5)" + } else if (config.gridWidth > 500 || config.gridHeight > 500) { + validationError = "Размер поля слишком большой (максимум 500x500)" + } else if (config.probability < 0.01 && liveCells == 0) { + validationError = "Поле пустое! Нажмите 'Случайно' или кликните по полю" + } else { + isRunning = !isRunning + } + }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary + ) + ) { + Text(if (isRunning) "Стоп" else "Старт") + } + OutlinedButton( + onClick = { + game.step() + generation = game.getGeneration() + liveCells = game.getLiveCellCount() + detectedPatterns = PatternClassifier.classifyPatterns(game.getGrid()) + }, + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.primary + ) + ) { + Text("Шаг") + } + OutlinedButton( + onClick = { + game.clear() + generation = 0 + liveCells = 0 + detectedPatterns = emptyList() + isRunning = false + }, + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.primary + ) + ) { + Text("Очистить") + } + OutlinedButton( + onClick = { + game.initialize(config.probability) + generation = 0 + liveCells = game.getLiveCellCount() + detectedPatterns = PatternClassifier.classifyPatterns(game.getGrid()) + }, + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.primary + ) + ) { + Text("Случайно") + } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/nano/lab2/ui/gameoflife/PatternClassifier.kt b/src/main/kotlin/com/nano/lab2/ui/gameoflife/PatternClassifier.kt new file mode 100644 index 0000000..80fd433 --- /dev/null +++ b/src/main/kotlin/com/nano/lab2/ui/gameoflife/PatternClassifier.kt @@ -0,0 +1,135 @@ +package ui.gameoflife + +object PatternClassifier { + data class PatternInfo( + val type: String, + val count: Int, + val examples: List> + ) + + fun classifyPatterns(grid: Array>): List { + val components = findConnectedComponents(grid) + val patterns = mutableListOf() + val typeCounts = mutableMapOf() + + for (component in components) { + val patternType = classifySinglePattern(component, grid) + typeCounts[patternType] = (typeCounts[patternType] ?: 0) + 1 + } + + for ((type, count) in typeCounts) { + patterns.add(PatternInfo(type, count, emptyList())) + } + + return patterns.sortedByDescending { it.count } + } + + private fun findConnectedComponents(grid: Array>): List>> { + val height = grid.size + val width = grid[0].size + val visited = Array(height) { BooleanArray(width) } + val components = mutableListOf>>() + + for (y in 0 until height) { + for (x in 0 until width) { + if (grid[y][x] && !visited[y][x]) { + val component = bfs(x, y, grid, visited) + components.add(component) + } + } + } + return components + } + + private fun bfs(startX: Int, startY: Int, grid: Array>, visited: Array): List> { + val component = mutableListOf>() + val queue = ArrayDeque>() + queue.add(Pair(startX, startY)) + visited[startY][startX] = true + val height = grid.size + val width = grid[0].size + + while (queue.isNotEmpty()) { + val (x, y) = queue.removeFirst() + component.add(Pair(x, y)) + + for (dy in -1..1) { + for (dx in -1..1) { + if (dx == 0 && dy == 0) continue + val nx = x + dx + val ny = y + dy + if (nx in 0 until width && ny in 0 until height && + grid[ny][nx] && !visited[ny][nx]) { + visited[ny][nx] = true + queue.add(Pair(nx, ny)) + } + } + } + } + return component + } + + private fun classifySinglePattern(component: List>, grid: Array>): String { + val size = component.size + + if (size == 1) return "Single" + if (size == 2) return "Pair" + if (size == 3) return "Triad" + + val boundingBox = getBoundingBox(component) + val width = boundingBox.second.first - boundingBox.first.first + 1 + val height = boundingBox.second.second - boundingBox.first.second + 1 + + if (width == 2 && height == 2 && size == 4) return "Block" + if (width == 3 && height == 3 && size == 6) return "Beehive" + if (width == 3 && height == 3 && size == 5) return "Loaf" + if (width == 3 && height == 2 && size == 5) return "Boat" + if (width == 3 && height == 3 && size == 4) return "Tub" + + if (isGlider(component, grid)) return "Glider" + + return when { + size <= 5 -> "Small" + size <= 10 -> "Medium" + else -> "Large" + } + } + + private fun getBoundingBox(component: List>): Pair, Pair> { + val minX = component.minOf { it.first } + val maxX = component.maxOf { it.first } + val minY = component.minOf { it.second } + val maxY = component.maxOf { it.second } + return Pair(Pair(minX, minY), Pair(maxX, maxY)) + } + + private fun isGlider(component: List>, grid: Array>): Boolean { + if (component.size != 5) return false + val cells = component.toSet() + val patterns = listOf( + setOf(Pair(0, 1), Pair(1, 2), Pair(2, 0), Pair(2, 1), Pair(2, 2)), + setOf(Pair(0, 0), Pair(0, 2), Pair(1, 1), Pair(2, 1), Pair(2, 2)), + setOf(Pair(0, 0), Pair(0, 1), Pair(1, 1), Pair(2, 1), Pair(2, 2)), + setOf(Pair(0, 1), Pair(0, 2), Pair(1, 1), Pair(2, 0), Pair(2, 1)) + ) + return patterns.any { it == cells } + } + + fun getPatternName(type: String): String { + return when (type) { + "Block" -> "Устойчивый (блок)" + "Beehive" -> "Устойчивый (улей)" + "Loaf" -> "Устойчивый (буханка)" + "Boat" -> "Устойчивый (лодка)" + "Tub" -> "Устойчивый (труба)" + "Glider" -> "Движущийся (глайдер)" + "Single" -> "Одиночная клетка" + "Pair" -> "Пара" + "Triad" -> "Триада" + "Small" -> "Малая фигура" + "Medium" -> "Средняя фигура" + "Large" -> "Большая фигура" + else -> type + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/nano/lab2/ui/onedimensional/OneDimensionalView.kt b/src/main/kotlin/com/nano/lab2/ui/onedimensional/OneDimensionalView.kt new file mode 100644 index 0000000..35393fb --- /dev/null +++ b/src/main/kotlin/com/nano/lab2/ui/onedimensional/OneDimensionalView.kt @@ -0,0 +1,252 @@ +package ui.onedimensional + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.* +import androidx.compose.runtime.* +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.unit.dp +import kotlinx.coroutines.delay +import model.AppConfig +import model.OneDimensionalCA +import model.OneDimensionalRule +import model.UiAction + +@Composable +fun OneDimensionalView( + config: AppConfig, + action: UiAction?, + onActionConsumed: () -> Unit, + onConfigChange: (AppConfig) -> Unit +) { + var automaton by remember(config.gridWidth, config.oneDimensionalRule) { + mutableStateOf(OneDimensionalCA(config.gridWidth, config.oneDimensionalRule)) + } + var isRunning by remember { mutableStateOf(false) } + var generations by remember { mutableStateOf>(emptyList()) } + var currentGeneration by remember { mutableIntStateOf(0) } + var displayMode by remember { mutableStateOf(DisplayMode.TRAIL) } + var validationError by remember { mutableStateOf(null) } + + val primaryColor = MaterialTheme.colorScheme.primary + + LaunchedEffect(config.oneDimensionalRule, config.gridWidth) { + automaton = OneDimensionalCA(config.gridWidth, config.oneDimensionalRule) + automaton.initialize(config.gridWidth / 2) + generations = listOf(automaton.getCurrentState()) + currentGeneration = 0 + } + + LaunchedEffect(action) { + when (action) { + UiAction.Clear -> { + generations = emptyList() + currentGeneration = 0 + isRunning = false + validationError = null + } + UiAction.Randomize -> { + automaton.initializeRandom(0.5) + generations = listOf(automaton.getCurrentState()) + currentGeneration = 0 + isRunning = false + validationError = null + } + null -> {} + } + if (action != null) onActionConsumed() + } + + LaunchedEffect(isRunning, config.simulationSpeed) { + while (isRunning) { + delay(1000L / config.simulationSpeed) + automaton.step() + generations = automaton.getGenerations(generations.size + 1) + currentGeneration = generations.size - 1 + } + } + + Column(modifier = Modifier.fillMaxSize().background(Color.Black)) { + Row( + modifier = Modifier.fillMaxWidth().padding(8.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text("Правило: ${config.oneDimensionalRule.value}", color = primaryColor) + Text("Поколение: $currentGeneration", color = primaryColor) + } + + Row( + modifier = Modifier.fillMaxWidth().padding(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text("Режим отображения:", color = primaryColor) + FilterChip( + selected = displayMode == DisplayMode.TRAIL, + onClick = { displayMode = DisplayMode.TRAIL }, + label = { Text("Трейс") }, + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = primaryColor, + selectedLabelColor = Color.Black, + containerColor = Color.Black, + labelColor = primaryColor + ) + ) + FilterChip( + selected = displayMode == DisplayMode.CURRENT, + onClick = { displayMode = DisplayMode.CURRENT }, + label = { Text("Текущее") }, + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = primaryColor, + selectedLabelColor = Color.Black, + containerColor = Color.Black, + labelColor = primaryColor + ) + ) + } + + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .background(Color(0xFF001100)) + ) { + if (validationError != null) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = androidx.compose.ui.Alignment.Center + ) { + Text( + text = validationError!!, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.headlineSmall + ) + } + } else { + Canvas( + modifier = Modifier + .width((config.gridWidth * 8).dp) + .height((generations.size * 8).dp.coerceAtMost(2000.dp)) + .horizontalScroll(rememberScrollState()) + ) { + when (displayMode) { + DisplayMode.TRAIL -> { + generations.forEachIndexed { genIndex, generation -> + generation.forEachIndexed { cellIndex, isAlive -> + if (isAlive) { + drawRect( + color = primaryColor, + topLeft = Offset(cellIndex * 8f, genIndex * 8f), + size = Size(8f, 8f) + ) + } + } + } + } + DisplayMode.CURRENT -> { + if (generations.isNotEmpty()) { + val current = generations.last() + current.forEachIndexed { cellIndex, isAlive -> + if (isAlive) { + drawRect( + color = primaryColor, + topLeft = Offset(cellIndex * 8f, 0f), + size = Size(8f, 8f) + ) + } + } + } + } + } + } + } + } + + Row( + modifier = Modifier.fillMaxWidth().padding(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Button( + onClick = { + validationError = null + if (config.gridWidth < 5) { + validationError = "Слишком маленькое поле (минимум 5)" + } else if (config.gridWidth > 200) { + validationError = "Слишком большое поле (максимум 200)" + } else { + isRunning = !isRunning + } + }, + colors = ButtonDefaults.buttonColors( + containerColor = primaryColor, + contentColor = Color.Black + ) + ) { + Text(if (isRunning) "Стоп" else "Старт") + } + OutlinedButton( + onClick = { + automaton.step() + generations = automaton.getGenerations(generations.size + 1) + currentGeneration = generations.size - 1 + }, + colors = ButtonDefaults.outlinedButtonColors( + contentColor = primaryColor + ) + ) { + Text("Шаг") + } + OutlinedButton( + onClick = { + automaton.initialize(config.gridWidth / 2) + generations = listOf(automaton.getCurrentState()) + currentGeneration = 0 + isRunning = false + validationError = null + }, + colors = ButtonDefaults.outlinedButtonColors( + contentColor = primaryColor + ) + ) { + Text("Сброс (центр)") + } + OutlinedButton( + onClick = { + automaton.initializeRandom(0.5) + generations = listOf(automaton.getCurrentState()) + currentGeneration = 0 + isRunning = false + validationError = null + }, + colors = ButtonDefaults.outlinedButtonColors( + contentColor = primaryColor + ) + ) { + Text("Случайно") + } + OutlinedButton( + onClick = { + generations = emptyList() + currentGeneration = 0 + isRunning = false + validationError = null + }, + colors = ButtonDefaults.outlinedButtonColors( + contentColor = primaryColor + ) + ) { + Text("Очистить") + } + } + } +} + +private enum class DisplayMode { + TRAIL, + CURRENT +} \ No newline at end of file diff --git a/src/main/kotlin/com/nano/lab2/util/ExperimentRunner.kt b/src/main/kotlin/com/nano/lab2/util/ExperimentRunner.kt new file mode 100644 index 0000000..ab7fe68 --- /dev/null +++ b/src/main/kotlin/com/nano/lab2/util/ExperimentRunner.kt @@ -0,0 +1,133 @@ +package util + +import model.GameOfLife +import org.knowm.xchart.BitmapEncoder +import org.knowm.xchart.XYChartBuilder +import org.knowm.xchart.style.markers.SeriesMarkers +import java.io.File +import kotlin.random.Random + +data class ExperimentResult( + val density: Double, + val stabilizationTime: Int, + val survived: Boolean, +) + +class ExperimentRunner { + private val stabilityWindow = 10 + + fun runExperiments( + width: Int, + height: Int, + ): List { + val densities = listOf(0.1, 0.15, 0.2, 0.25, 0.3, 0.35, 0.4, 0.45, 0.5) + val results = mutableListOf() + + for (density in densities) { + repeat(10) { run -> + val result = runSingleExperiment(width, height, density) + results.add(result) + println("Density: $density, Run: ${run + 1}, Stabilization: ${result.stabilizationTime}") + } + } + + return results + } + + private fun runSingleExperiment( + width: Int, + height: Int, + density: Double, + ): ExperimentResult { + val game = GameOfLife(width, height) + game.initialize(density) + + val populationHistory = mutableListOf() + var stabilized = false + var stabilizationTime = 0 + var stableCount = 0 + + while (!stabilized && populationHistory.size < 1000) { + val currentPopulation = game.getLiveCellCount() + populationHistory.add(currentPopulation) + + if (populationHistory.size >= stabilityWindow) { + val recentWindow = populationHistory.takeLast(stabilityWindow).toList() + val allSame = recentWindow.distinct().size <= 2 + val twoCyclic = + recentWindow.windowed(2).all { it[0] == it[1] } || + (recentWindow.zipWithNext { a, b -> a == b }.count { !it } <= 2) + + if (allSame || twoCyclic) { + stabilized = true + stabilizationTime = populationHistory.size - stabilityWindow + stableCount = currentPopulation + } + } + + if (currentPopulation == 0) { + stabilized = true + stabilizationTime = populationHistory.size + stableCount = 0 + } + + if (!stabilized) { + game.step() + } + } + + if (!stabilized) { + stabilizationTime = populationHistory.size + } + + return ExperimentResult( + density = density, + stabilizationTime = stabilizationTime, + survived = stableCount > 0, + ) + } + + fun generateGraph( + results: List, + outputPath: String, + ) { + val averageResults = + results + .groupBy { it.density } + .mapValues { (_, results) -> + results.map { it.stabilizationTime }.average() + }.toSortedMap() + + val chart = + XYChartBuilder() + .title("Зависимость времени стабилизации от плотности") + .xAxisTitle("Начальная плотность (p)") + .yAxisTitle("Среднее время стабилизации (поколений)") + .build() + + val xValues = averageResults.keys.toList() + val yValues = averageResults.values.toList() + + chart + .addSeries("Стабилизация", xValues, yValues) + .setMarker(SeriesMarkers.CIRCLE) + + BitmapEncoder.saveBitmap(chart, outputPath, BitmapEncoder.BitmapFormat.PNG) + println("Graph saved to $outputPath") + } + + fun printStatistics(results: List) { + val byDensity = results.groupBy { it.density } + + println("\n=== Экспериментальные результаты ===") + for (density in byDensity.keys.sorted()) { + val experiments = byDensity[density]!! + val avgStabilization = experiments.map { it.stabilizationTime }.average() + val survivalRate = experiments.count { it.survived }.toDouble() / experiments.size * 100 + + println("Плотность p=$density:") + println(" Среднее время стабилизации: ${"%.1f".format(avgStabilization)} поколений") + println(" Выживаемость: ${"%.0f".format(survivalRate)}%") + } + } +}