Генерация Фракталов методом хаоса, UI на ScalaFX
Ремарка
В данной статье подробно разбирается, как автор создал оконное приложение с пользовательским интерфейсом для рисования фракталов методом хаоса. Однако, автор не утверждает, что выбранный стек технологий и методология являются наиболее подходящими или оптимальными для данной задачи или аналогичных проектов. Кроме того, в программе используется функционал, ранее описанный в предыдущей статье, поэтому аспекты обработки арифметических выражений будут упомянуты лишь вскользь с ссылкой на ту статью. Следует также отметить, что автор впервые сталкивается с использованием CSS в этом проекте и потому скорее всего весьма топорно и некрасиво оформил файл.
Метод Хауса
Метод хаоса, или «игра хаоса», является увлекательным и визуально впечатляющим подходом к созданию фрактальных структур. Этот метод основывается на случайном выборе точек внутри определенной фигуры и последовательном применении простых математических правил для генерации новых точек. В результате многократного применения этих правил могут формируются сложные и красивые узоры. Одним из наиболее известных примеров использования метода хаоса является создание фрактала Серпинского треугольника, что так же упомянуто в статье на википедии. Привлекательность метода хаоса заключается в его простоте и способности генерировать сложные структуры из простых алгоритмов, что делает его мощным инструментом для исследования фрактальной геометрии и визуализации хаоса.
что хочет создать автор
Целью проекта является создание оконного приложения на фреймворке ScalaFX, которое будет состоять из области для рисования фигур методом хаоса, панели настроек и кнопки, запускающей отрисовку фигуры. Приложение также должно иметь привлекательный внешний вид. Область для рисования представляет собой пространство, где после нажатия кнопки будет появляться фигура.
Панель настроек включает в себя две основные составляющие:
Настройка веса точки, который определяет, насколько близко к предыдущей точке будет ставиться следующая. Также можно будет задать цвет точек и итоговой фигуры, радиус точек и их количество.
Настройка аттракторов. Их координаты должны задаваться формульно отдельно для координат x и y. Также необходимо указать вероятностный вес аттрактора, от которого зависит выбор аттрактора на каждой итерации, и вес аттрактора, определяющий, насколько близко к нему будет располагаться точка. Дополнительно можно задать радиус, цвет и количество аттракторов (первые два параметра используются исключительно для визуализации).
Разметка и цветовая схема
недолго поразмыслив пришла к такой структуре:
Цвета здесь выбраны произвольно, и их нужно подобрать согласно цветовой схеме. Так как зона отрисовки фигур останется белой, будем отталкиваться от белого цвета и использовать следующим сайтом
подбираю 6 следующих оттенков:
#f5f9ea
#c1d0a4
#46727d
#003248
#001933
#000000
переделаю схему с использованием следующих оттенков
вроде уже симпотичнее, в общем пойдет
создание проекта и предворительная настройка sbt
добавим в корневую папуку файл build.sbt
и определим сначала самый базис:
name := "ScalaFX_Fractal" //имя сего проекта
version := "1.0.0" // версия проекта
scalaVersion := "2.13.12" // версия скалы
// подключаем необходимые опции компилятора
scalacOptions ++= Seq("-unchecked", "-deprecation", "-Xcheckinit", "-encoding", "utf8", "-Ymacro-annotations")
-unchecked
: Включает предупреждения о небезопасных или непроверенных операциях.-deprecation
: Включает предупреждения о том, что используются устаревшие элементы языка или библиотеки.-Xcheckinit
: Включает дополнительные проверки инициализации объектов.-encoding utf8
: Устанавливает кодировку исходных файлов наUTF-8
. Это гарантирует правильное чтение и запись файлов, особенно если они содержат символы, не входящие в стандартный наборASCII
.-Ymacro-annotations
: Включает поддержку аннотаций макросов, которые позволяют генерировать код во время компиляции с использованием макросов. в данном проекте это важно, поскольку без него не будетScalaFX
, а конкретно та часть что парситFXML
добавим зависемости для sclaaFX
lazy val scalaFxVersion = "16.0.0-R24"
lazy val scalaFxCoreVersion = "0.5"
lazy val scalaFxDependencies = Seq(
"org.scalafx" %% "scalafx" % scalaFxVersion, //добовляет scalaFX в проект
"org.scalafx" %% "scalafxml-core-sfx8" % scalaFxCoreVersion
/*
дополнительное расщирение ScalaFX для связки его с FXML,
собственно для него и необходима опция -Ymacro-annotations,
без которой не будет работать
*/
)
так как ScalaFx
— это обвесок для JavaFx
то и ScalaFx
без него работать не будет, а следовательно добавляем JavaFx
в проект добовляя строки в build.sbt
// Определяем зависимости для JavaFX в зависимости от операционной системы
lazy val javaFxDependencies = {
// Определяем название операционной системы, чтобы выбрать соответствующий классификатор
lazy val osName = System.getProperty("os.name") match {
case n if n.startsWith("Linux") => "linux" // Если ОС Linux, используем классификатор "linux"
case n if n.startsWith("Mac") => "mac" // Если ОС macOS, используем классификатор "mac"
case n if n.startsWith("Windows") => "win" // Если ОС Windows, используем классификатор "win"
case _ => throw new Exception("Unknown platform!") // Если платформа не распознана, выбрасываем исключение
}
// Создаем последовательность зависимостей для JavaFX, используя полученный классификатор
Seq("base", "controls", "fxml", "graphics", "media", "swing", "web") // Перечисляем модули JavaFX
.map(m => "org.openjfx" % s"javafx-$m" % "16" classifier osName) // Формируем зависимость для каждого модуля с нужным классификатором
}
данные строки были взяты с сайта и не то чтобы прям вникала, думаю для легковестности стоило бы что то из этого вырезать, например web
, но работаит и так сойдет.
ну и остаеться только добавить зависемости
libraryDependencies ++= scalaFxDependencies ++ javaFxDependencies
Делаем разметку FXML и подключаем отображение на окно
в ScalaFx для отображения первого окна нужно мейн классом от JFXApp3
и переписать метод start
// Главный объект приложения, наследуемся от JFXApp3
object Main extends JFXApp3 {
// Переопределяем метод start, который будет вызван при запуске приложения
override def start(): Unit = {
// Устанавливаем первичную сцену (stage) приложения
stage = new JFXApp3.PrimaryStage {
title = "ScalaFX" // Заголовок окна приложения
// Устанавливаем сцену с корневым элементом GridPane и размерами 600x450 пикселей
scene = new Scene(new GridPane(), 600, 450) //GridPane пока поставлен просто чтоб отобразилось хоть что то
}
}
}
и уже можно запускать приложие, я пока жму зеленый треугольник в Idea
первое окно запущено .fxml файл где будет описан интерфейс
созаю файл main_window.fxml
в папке resources
со следующим содержанием
данный FAXML
файл определяет такой же пустой GridPane
что до этого делали через new GridPane
с той лишь разницей что в первом был элемент из пространства scalafx
, а не javafx
, но дальше будет происходит автоматическая конвертация, об этом чуть позже
далее из файла нужно собрать обьект сцены, я решила это вынести из Mаin
обьекта и вынести в отдельный trait MainWindowConfigure
// Трейт (trait), который содержит конфигурацию основного окна приложения
trait MainWindowConfigure {
// Ленивая инициализация главного окна (PrimaryStage)
lazy val mainWindow: JFXApp3.PrimaryStage = new JFXApp3.PrimaryStage {
// Загружаем ресурс FXML-файла для главного окна
val resource: URL = getClass.getResource("/FXMLs/main_window.fxml")
// Проверяем, удалось ли загрузить ресурс
if (resource == null) {
// Если ресурс не найден, выбрасываем исключение
throw new IOException(f"Cannot load resource: ${resource.toURI}")
}
// Создаем корневой элемент сцены (root) из FXML-файла, используя FXMLView и NoDependencyResolver
val root: Parent = FXMLView(resource, NoDependencyResolver)
// Устанавливаем заголовок окна
title = "ScalaFX"
// Устанавливаем сцену для главного окна с корневым элементом и размерами 600x450 пикселей
scene = new Scene(root, 600, 450)
}
}
и теперь в main обьекте подключаем сцену из трейта и теперь мейн обьект выглядит так, более к нему не вернемся.
package com.scalafx.fractal
import scalafx.application.JFXApp3
object Main extends JFXApp3 with MainWindowConfigure {
override def start(): Unit = {
stage = mainWindow
}
}
и теперь при запуске открывается тоже самое окно что и раньше, только теперь оно собираеться из .fxml
файла
делаем разметку FAXML
на данном этапе задача воспроезвести следующий макет
угольных интерфейсов
для этого я выбираю GridPane, так как нохожу наиболее подходящим для тоталетарно прямоугольных интерфейсов. В GridPane нужно сделать разметку сетки и далее в ячейки этой сетки помещать различные элементы.
начнем с разметки сетки
все элементы которые должны размещаться в GridPane
и других layout-ов распологаються в children
GridPane.columnIndex: Устанавливает индекс столбца, в котором будет размещен элемент.
GridPane.rowIndex: Устанавливает индекс строки, в которой будет размещен элемент.
GridPane.columnSpan: Определяет, сколько столбцов будет занимать элемент.
GridPane.rowSpan: Определяет, сколько строк будет занимать элемент.
GridPane.vgrow: Определяет вертикальный рост элемента.
ALWAYS
позволяет элементу расти по вертикали, занимая доступное пространство.GridPane.hgrow: Определяет горизонтальный рост элемента.
ALWAYS
позволяет элементу расти по горизонтали, занимая доступное пространство.style: Позволяет установить CSS-стили для элемента, например,
-fx-background-color
для установки цвета фона.maxWidth и maxHeight: Устанавливают максимальные размеры элемента.
prefWidth и prefHeight: Устанавливают предпочтительные размеры элемента.
Эти настройки позволяют гибко управлять расположением и размером элементов в GridPane
, обеспечивая адаптивное и отзывчивое оформление интерфейса. и таким образом достигаю нужного эффекта. и вот что получаю следующие окно
таким образом разметкак готова
наполняем FAML функциональными элементами
на данном этапе задача заменить все Label
функциональными эментами.
первое что сделаю — это кнопка запуска
заменим соответствующий Label следующей структурой, которая определит кнопку
на данном этапе имеем следующие:
сделаем панель настройки точек фигуры, добавив следующию структуру
на данный момент имеем следующие:
сделаем настроичную панель заменив Label на следующие
и имею следующие:
и наконец осталась только отрисовочная панель
и того имеем:
Стилизуем через .css
для стилизации элементов в ScalaFx
можно использовать отдельный .css
файл
создадим в resources файл styles.css
и подключаем его к окну следующей строчко в трейте: root.stylesheets += getClass.getResource("/styles.css").toExternalForm
создадим стиль для кнопки следующим кодом
.start-button {
-fx-background-color: #003248; /* Цвет фона */
-fx-text-fill: #f5f9ea; /* Цвет текста */
-fx-border-color: #001933; /* Цвет бордюра */
-fx-border-width: 2px; /* Ширина бордюра */
-fx-font-size: 16px; /* Размер шрифта */
-fx-padding: 10px; /* Отступы внутри кнопки */
-fx-alignment: center; /* Выравнивает содержимое элемента по центру */
}
.start-button:hover {
-fx-background-color: #001933; /* Цвет фона при наведении */
}
и чтобы применить этот стиль к нопке надо добавить к styleClass="start-button"
к элементу Button
для остальных элементов делаем аналогичным образом и на .css более затрагиваться в статье не будет, но в нем есть стили для всех элементов и тгго имею следующий интерфейс:
Ну, вроде вышло симпотично
создаем контроллер для окна
что бы назначить контроллер нужно с начала создать класс и навесить на него анотацию @sfxml
@sfxml
class MainWindowController() {} //класс пока пустой
и чтобы на окно назначить этот контроллер нужно добавить строку в FAXML
чтобы в контроллере можно было использовать элементы из сцены, этим элементам надо добавить fx:id
вот на примере настроичной панели
для необходимых компонентов добовляю этот параметр, тут останавливаться не буду
и в конструктор по этому id добовляем элемент с соответсвующим типом
@sfxml
class MainWindowController(private val settingsVbox: VBox,
private val drawCNS: Pane,
private val drawSP: ScrollPane,
private val divisionCoefficientTF: TextField,
private val radiusTF: TextField,
private val hexTF: TextField,
private val iterTF: TextField) {
}
тут есть момент что в .fxml
мы описываем элементы из JavaFx
, однако в конструктуре получаем аналогичные обьекты из ScalaFx
, то есть анотация @sfxml производит неявную конвертацию что очень удобно.
делаем темплейт для панели настройки
добавим еще один .fxml
файл attractor_settings_panel.fxml
со следующим содержанием
это понадобиться далее, чтобы так же их .fxml
создавать элементы динамически и не хардкодить все это
Функционал на кнопки +/-
чтобы назначит функционал на кнопку в .fxml нужно повесить параметр onAction
вот на примере Button
//тут назначаем
далее нужно только создать в контролле метод с тем же именем
@sfxml
class MainWindowController(private val settingsVbox: VBox,
private val drawCNS: Pane,
private val drawSP: ScrollPane,
private val divisionCoefficientTF: TextField,
private val radiusTF: TextField,
private val hexTF: TextField,
private val iterTF: TextField) {
// Загружаем ресурс FXML файла для панели настроек аттрактора
lazy val resource: URL = getClass.getResource("/FXMLs/attractor_settings_panel.fxml")
if (resource == null) {
throw new IOException(f"Cannot load resource: ${resource.toURI}")
}
// Метод для обработки действия добавления новой панели настроек аттрактора
def handleAddAction(): Unit = {
// Создаем панель настроек из FXML ресурса
val settingPanel = FXMLView(resource, NoDependencyResolver)
// Добавляем новую панель перед последним элементом в settingsVbox (на последней распологаются кнопки +/-)
settingsVbox.children.add(settingsVbox.children.size - 1, settingPanel)
}
// Метод для обработки действия удаления панели настроек аттрактора
def handleremoveAction(): Unit = {
// Удаляем предпоследнюю панель, если их больше одной (на последней распологаются кнопки +/-)
if (settingsVbox.children.size > 1)
settingsVbox.children.remove(settingsVbox.children.size - 2)
}
}
и того мы можен добавлять новые панели настроичные для точек атрактора:
пример с двумя панелями
вроде как весьма симпотично.
рендеринг
последнее что осталось сделать — это функционал главной кнопки
но сначала определим 2 вспомогательных кейс класса, которые будут хранить информацию с панелей настроек аттракторов
package com.scalafx.fractal.model.DTO
//думаю тут особых пояснений не надо
case class AttractorSettingsPanelDTO(formulaX: String,
formulaY: String,
probabilityWeight: Int,
divisionCoefficient: Int,
radius: Int,
hex: String,
iterCount: Int)
package com.scalafx.fractal.model.DTO
//думаю тут пояснений не надо
case class PointSettingsDTO(divisionCoefficient: Int,
radius: Int,
hex: String,
iterCount: Int)
А так же понадобятся классы для работы с арефметическими вырожениями, которые я реализовывала в одной из своих статей (в связи с чем не буду останавливаться на данном функционале) я буквально просто копию/вставляю все классы в папку model.AST
добовляю только допом StandartASTComponents.scala
где соберу свои стандартные сущьности арефметические со следующим содержанием:
package com.scalafx.fractal.model.AST
import scala.util.Random
object StandartASTComponents {
lazy val plus: UDO = UDO("+", 1, Option(0d), (left, right) => left + right)
lazy val minus: UDO = UDO("-", 1, Option(0d), (left, right) => left - right)
lazy val multiply: UDO = UDO("*", 2, Option(1d), (left, right) => left * right)
lazy val division: UDO = UDO("/", 2, None, (left, right) => left / right)
lazy val pow: UDO = UDO("^", 3, None, (left, right) => Math.pow(left, right))
lazy val pi: UDC = UDC("pi", Math.PI)
lazy val e: UDC = UDC("e", Math.E)
val avg: UDF = UDF("avg", params => params.sum / params.size)
val abs: UDF = UDF("abs", params => params.head.abs)
val cos: UDF = UDF("cos", params => Math.cos(params.head))
val sin: UDF = UDF("sin", params => Math.sin(params.head))
val ln: UDF = UDF("ln", params => Math.log(params.head))
val max: UDF = UDF("max", params => params.max)
val min: UDF = UDF("min", params => params.min)
val ran: UDF = UDF("ran", params => Random.nextDouble())
lazy val standartUDOs: Set[UDO] = Set(plus, minus, multiply, division, pow)
lazy val standartUDCs: Set[UDC] = Set(pi, e)
lazy val standartUDFs: Set[UDF] = Set(avg, abs, cos, sin, ln, max, min, ran)
}
добавлю так же 2 вспомогательных метода в контроллер:
//небольшой метод, нужен на случай если пользователь ввел некоректно цветовую схему
//и если это так то возращает просто черный цвет и так же если поле было пустым
private def validHexColor(color: String): String= {
Try {
val hexColorPattern = "^#([A-Fa-f0-9]{6})$".r
if (hexColorPattern.matches(color))
color
else
"#000000"
}.getOrElse("#000000")
}
//метод который мне парсит настроичные панели в мой DTO caseClass
//подробнее тут останавливаться не хочу
private def validAttractorDTO(node: javafx.scene.Node): Try[AttractorSettingsPanelDTO] = {
Try {
AttractorSettingsPanelDTO(
formulaX = node.lookup("#formulaTFX").asInstanceOf[javafx.scene.control.TextField].getText,
formulaY = node.lookup("#formulaTFY").asInstanceOf[javafx.scene.control.TextField].getText,
probabilityWeight = Try(node.lookup("#probabilityWeightTF").asInstanceOf[javafx.scene.control.TextField].getText.toInt).getOrElse(1),
divisionCoefficient = Try(node.lookup("#divisionCoefficientTF").asInstanceOf[javafx.scene.control.TextField].getText.toInt).getOrElse(1),
radius = Try(node.lookup("#radiusTF").asInstanceOf[javafx.scene.control.TextField].getText.toInt).getOrElse(1),
hex = validHexColor(node.lookup("#hexTF").asInstanceOf[javafx.scene.control.TextField].getText),
iterCount = Try(node.lookup("#iterTF").asInstanceOf[javafx.scene.control.TextField].getText.toInt).getOrElse(1)
)
}
}
реализуем функционал кнопки главвной
к кнопке добовляю парметр onAction="#rendering"
и соответсвующий метод в контроллер: def rendering(): Unit = { }
и остается только его реализовать и функционал программы готов.
def rendering(): Unit = {
// первое что делаю отчищаю панель для отрисовки фигур
drawCNS.children.clear()
//вычисляю координаты центра
val centerX: Double = drawSP.getWidth / 2
val centerY: Double = drawSP.getHeight / 2
//ну и считываю настройки с панели настроект точек фигуры
//(понадобиться когда буду отрисовывать фигуру))
val pointSettingsDTO = PointSettingsDTO(
divisionCoefficient = Try( divisionCoefficientTF.getText.toInt ).getOrElse(1),
radius = Try( radiusTF.getText.toInt ).getOrElse(1),
hex = validHexColor(hexTF.getText),
iterCount = Try( iterTF.getText.toInt ).getOrElse(10000)
)
}
следующий шаг это расчитать все точки атрактры, ну то есмь их координаты и к ним сразу настройку добавить
val attractorPoints = settingsVbox //обращаемся к панели настроек
.children // берем ее "детей"
.init // убераю последнего "ребенка" (последний это +/- кнопочки)
.map(validAttractorDTO) // преобразую через вспомогательный метод все в DTO с параметрами с панелей
.filter(_.isSuccess) // оставляю только те где все корректно спарсилось
.map(_.get) // получаю сами DTO
.filter(_.formulaY.nonEmpty) // отсеиваю те где пустой параметр formulaY
.filter(_.formulaX.nonEmpty) // отсеиваю те где пустой параметр formulaX
.flatMap { attractor => //и таки работаю с информацией с панели
val coordinates = (0 until attractor.iterCount) //создаю с 0 по количеству интераций список
.map { i => //действие на итерацию
Try {
//тут к стандартным сущьностям добовляю только 2 константы, с итерацие i и c количеством итераций ic
implicit val udcs: Set[UDC] = StandartASTComponents.standartUDCs + UDC("i", i) + UDC("ic", attractor.iterCount)
implicit val udfs: Set[UDF] = StandartASTComponents.standartUDFs
implicit val udos: Set[UDO] = StandartASTComponents.standartUDOs
val x = attractor.formulaX.parseAST().calc() // вычисляю координату x
val y = attractor.formulaY.parseAST().calc() // вычисляю координату y
((x, y), attractor) // сама точка со своими настройками
}
}
//возращаю ничего если хоть на одной из итераций посчиталось некоректно
if (!coordinates.exists(_.isFailure)) {
coordinates.map(_.get)
} else {
List.empty
}
}
теперь нарисую эти точки атракторы
attractorPoints
.foreach { //беру каждую расчитаную точку и рисую по координатам их положение на фигуре которую рисую
case ((x, y), attractor) =>
//единственное что делаю это паралеьный перенос к центру
val circeCenterX = centerX + x
val circeCenterY = centerY + y
val circeColor = Color.web(attractor.hex)
val circle = new Circle {
centerX = circeCenterX
centerY = circeCenterY
radius = attractor.radius
fill = circeColor
}
drawCNS.children.add(circle)
}
расчитываю точки фигуры итоговой
// Рассчитываем суммарный вес аттракторов
val totalAttractorWeight = attractorPoints.map(_._2.probabilityWeight).sum
// Генерация точек фигуры
val fractalPoints: List[(Double, Double)] = Try {
// Итерируемся по количеству итераций, заданных в pointSettingsDTO
(0 until pointSettingsDTO.iterCount).foldLeft(List.empty[(Double, Double)]) {
// Инициализация списка точек случайной точкой в центре области рисования
case (Nil, _) =>
List((Random.nextDouble() * centerX * 2, Random.nextDouble() * centerY * 2))
// Добавляем новые точки на основе предыдущих
case (prePoints, _) =>
val lastPoint = prePoints.last
// Выбираем случайный аттрактор на основе его веса
val randomValue = Random.nextDouble() * totalAttractorWeight
val randomAttractPoint: ((Double, Double), AttractorSettingsPanelDTO) = attractorPoints
.foldLeft((0.0, Option.empty[((Double, Double), AttractorSettingsPanelDTO)])) {
case ((cumulativeWeight, selectedPoint), point) =>
val newCumulativeWeight = cumulativeWeight + point._2.probabilityWeight
if (randomValue <= newCumulativeWeight && selectedPoint.isEmpty) {
(newCumulativeWeight, Some(point))
} else {
(newCumulativeWeight, selectedPoint)
}
}
._2
.get
// Рассчитываем новую точку на основе предыдущей и выбранного аттрактора
val totalCoefficient = pointSettingsDTO.divisionCoefficient + randomAttractPoint._2.divisionCoefficient
val newPoint = (
(lastPoint._1 * pointSettingsDTO.divisionCoefficient + randomAttractPoint._1._1 * randomAttractPoint._2.divisionCoefficient) / totalCoefficient,
(lastPoint._2 * pointSettingsDTO.divisionCoefficient + randomAttractPoint._1._2 * randomAttractPoint._2.divisionCoefficient) / totalCoefficient
)
prePoints :+ newPoint
}
}.getOrElse(List.empty) //возращаю пустой список если хоть где то произошла ошибка
и наконец рисую сами точки фигуры
// Итерация по всем точкам фрактала и их отрисовка
fractalPoints.foreach {
case (x, y) =>
// Вычисление центра окружности для каждой точки фрактала
val circleCenterX = centerX + x
val circleCenterY = centerY + y
val circleColor = Color.web(pointSettingsDTO.hex)
// Создание новой окружности с заданными параметрами
val circle = new Circle {
centerX = circleCenterX
centerY = circleCenterY
radius = pointSettingsDTO.radius
fill = circleColor
}
// Добавление окружности на панель для рисования
drawCNS.children.add(circle)
}
собственно приложение готово
Рисуем фракталы
вот я рисую треугольник серпинского
другой фрактал у него вроде тоже есть название
и ещё
бубновый фрактал
последний из серии этой с ic = 6, тут прикольно что в центре еще фрактал снежинка образуется из пустоты
фрактал из превью к статье, обращу внимание что это не треугольник серпинского
фрактал «космический корабль» (авторское название)
ковер серпинского
снежинка
последний
снова лезем в sbt
так как я хочу чтобы условно любой мог запустить мое приложение на условный двойной щелчок мыши, то нужно настроить сборку .jar
файла
добовляем в plugins.sbt
следующию строку, для плагина assembly который поможет собирать джарник
addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "1.2.0")
В build.sbt добовляем следующие строки:
// Указание главного класса для сборки в JAR файл
mainClass in assembly := Some("com.scalafx.fractal.Main")
// Стратегия слияния для SBT Assembly при сборке JAR файла
assemblyMergeStrategy in assembly := {
// Игнорировать файлы с именем module-info.class
case x if x.endsWith("module-info.class") => MergeStrategy.discard
// Фильтрация строк в файлах META-INF/services/ для избежания конфликтов
case x if x.startsWith("META-INF/services/") => MergeStrategy.filterDistinctLines
// Игнорировать все файлы внутри директории META-INF/
case x if x.startsWith("META-INF/") => MergeStrategy.discard
// Игнорировать файлы с расширением .html
case x if x.endsWith(".html") => MergeStrategy.discard
// Для всех остальных файлов использовать первую версию файла
case x => MergeStrategy.first
}
// Задание имени для результирующего JAR файла
assemblyJarName in assembly := f"${name.value}.jar"
и теперь для сборки .jar файла достаточно вызвать команду sbt assembly
P.S. проект можно глянулянуть на github
А так, пишите/жмите что думаете.