Если на минуту задуматься, великая это вещь - колесо. Человечество значит разок его попробовало и все. Подсело. Теперь почти ни дня без колеса. Завертелось всё, закрутилось, как хоровод вокруг новогодней ёлки. 

Так и я намедни сделал для семейных развлечений свою вариацию на тему игры «Поле чудес», а в поле чудес кто главный герой? Нет, нет, отнюдь не «импозантный мужчина в усах». Я имел в виду – красавец барабан. А барабан это что? Правильно, барабан – колесо. Поэтому, один раз научившись вращать двухмерное колесо в игре на движке Godot я уже не смог остановиться. Захотелось мне это колесо еще где-нибудь использовать на благо прогрессивного человечества.

А тут как раз каникулы длинные нарисовались. И чтобы не было соблазна все выходные сидеть в теплых светодиодных лучах монитора, я сделал простенькое колесо фортуны, которое поможет мне с выбором занятия на день.

И как всегда готов поделится результатом с вами.

Задача

Данная статья, рассчитана на таких же, как я начинающих любителей делать игры. Я предполагаю, что вы немного знакомы с Godot, поэтому совсем очевидные вещи буду пропускать.

Сегодня мы сделаем простое приложение на Godot 4.

В приложении будет колесо с видами досуга. Его можно будет вращать мышью.

После выбора сегмента, делаем запрос к сервису афиши. В результате получаем случайное тематическое событие актуальное на ближайшие сутки.

Получим примерно следующий сценарий:

Диаграмма последовательности
Диаграмма последовательности

Подготовка

Как всегда, все материалы доступны на Github

Для начала скачаем движок. Я использовал Godot Engine 4.5.1,

Затем нам понадобится фоновое изображение для красоты (но можно и без фона).

Я обратился за помощью к генеративному ИИ. Правда результат пришлось немного подправить с помощью Krita.

Барабан я не мог доверить нейросети, поэтому набросал его в Inkscape.

Небольшое отступление. Я наверное лет десять, не открывал Inkscape и надо признать был поражен проделанной работой сообщества.

Барабан в Inkscape
Барабан в Inkscape

Еще нам понадобится стрелка – указатель сегмента. Её тоже несложно нарисовать самостоятельно.

Осталось определиться с API для афиши.

Я выбрал KudaGo. Сервис не требует регистрации, там есть несколько городов, а в городах есть события. Для наших целей вполне достаточно.

Но поскольку это не реклама, то вы вольны выбрать, любой другой сервис афиши.

Нам понадобится два метода для получения:

Теперь мы готовы создавать проект.

Я выбрал:

  • Рендер: mobile

  • Размер окна: 1152 x 648

  • Масштабирование: Mode – Viewport, Aspect – keep

  • Ориентацию: Sensor Landscape

Настройки проекта
Настройки проекта

Структура

Проект достаточно простой, поэтому я не буду детально останавливаться на описании компонентов. Разберем только самое главное. Тем более весь проект доступен на GitHub.

Пришло время традиционного дисклеймера:
Я не разработчик,поэтому скорее всего принял решения далекие от оптимальных. Рекомендую не воспринимать проект как истину в последней инстанции.

Структура проекта

Приведу примерное древо проекта с ключевыми файлами и папками

├── addons
│   └── date_time_label – файлы расширения DataTimeLabel 
├── Assets – картинки
│   ├── arrow.svg –  стрелка барабана
│   ├── background.jpg –  фон
│   ├── baraban.svg – барабан
├── Core
│   ├── global.gd – скрипт с общими сигналами и перменными
├── project.godot – файл проекта
├── Scenes
│   ├── barrel – сцена барабана и её скрипт
│   │   ├── barrel.gd
│   │   └── barrel.tscn
│   ├── main –  главная сцена и её скрипт
│   │   ├── main.gd
│   │   └── main.tscn
│   └── ui – сцена пользовательского интерфейса для Афиши событий и её скрипт
│       ├── ui.gd
│       └── ui.tscn
├── wheel_of_new_year.apk – экспортированная сборка под Android 

Сцена с барабаном (barrel.tscn)

Структура сцены простая в родительский узел Barrel (Node2D) входят:

  • Wheel – sprite2D c текстурой baraban.swg

  • Arrow – sprite2D c текстурой arrow.swg

  • Label – с пояснением как вращать барабан

  • ClickArea – Control. По размеру чуть больше wheel + label, чтобы ловить зону касания мышью (пальцем).

barrel.tscn
barrel.tscn

Сцена с UI событий афиши (ui.tscn)

Эта сцена немного посложнее. Давайте разберем её более детально.

Описание достаточно объ��мное вышло, для удобства оставляю ссылку на скриншот со структурой сцены в конце раздела.   

Родительский узел UI (Control)

Из важных настроек:

  • размер (x,y): 400 x 476

  • anchors preset: Full Rect

В него вложен VBoxContainer, мы его используем для удобного выравнивания всех вложенных в него элементов интерфейса.

Из важных настроек:

  • размер (x,y): 400 x 476

  • anchors preset: Top Wide

  • separation: 15 px

Первый блок вложенный в VBoxContainer – DateTimePanel (Panel)

Из важных настроек:

  • Мин. размер (x,y): 0 x 50

В панель вложен DateTimeLabel

Это расширение позволяет быстро и просто отображать текущую дату и время.

Я выбрал его потому, что мне не понравился ни один из имеющихся в  магазине ассетов компонентов выбора даты и времени. Поэтому я решил упростить приложение.

DateTimeLabel – необходимо установить из магазина расширений, если вы не знаете как, то см. под спойлер

Установка плагина
Выбор плагина
Выбор плагина
Установка
Установка
Включение
Включение

Из важных настроек:

  • format_str:"Афиша событий за 24 часа

    начиная с {day}.{month}.{year} {hour}:{minute%02d}"

  • anchors preset: Top Wide

  • horizontal_alignment: Center

Следующий блок вложенный в VBoxContainer – Cities (OptionButton)

Из важных настроек:

  • мин. размер (x,y): 250 x 0

Следующим блок вложенный в VBoxContainer – ScrollContainer

Из важных настроек:

  • размер (x,y): 400 x 300

  • horizontal_scroll_mode: выкл

В него вложен DescPanel (Panel). Он нужен чисто для красоты.

В него вложен MarginContainer

Из важных настроек:

  • anchors preset: Full Rect

  • отступы со всех сторон: 5 px

В него вложен Description (RichTextLabel)

Из важных настроек:

  • bbcode_enabled: true

Остался последний блок вложенный в VBox Container – UrlPanel (Panel)

Из важных настроек:

  • мин. размер (x,y): 0 x 50

В него вложен URL (LinkButton)

Из важных настроек:

  • мин. размер (x,y): 0 x 30

  • anchors_preset: Center

Вот как это выглядит в редакторе:

ui.tscn
ui.tscn

Главная сцена (main.tscn)

Структура главной сцены простая в родительский узел Barrel (node2D) входят:

  • Background – TextureRect c текстурой background.jpg

  • Barrel.tscn

  • UI.tscn

main.tscn
main.tscn

Теперь можно переходить к коду.

Код

Все скрипты кроме global.gd привязаны к одноименным сценам.

Global.gd

Без этого скрипта можно было бы обойтись, но я решил, что мне пригодится централизованное хранилище, для общих сигналов и переменных.

Не забудьте добавить скрипт в автозагрузку.

Код:

extends Node

const SECTOR_ROTATION_SIGNS = - 1 #сли сектора заданы по часовой стрелке

# Сектора для барабана, по приязке к категориям API, идут по часовой
var sectors = [
	"cinema",
	"theater",
	"concert",
	"quest",
	"exhibition",
	"party,social-activity",
	"education",
	"entertainment,festival,other"
]

# Массив с городами
var locations = []

# Глобальный флаг,  разрешающий вращать барабан
var can_spin = true

# Общий сигнал очистки UI
signal init_ui()

# Общий сигнал начала вращения барабана
signal spin_started()

# Общий сигнал завершения вращения барабана
signal spin_finished(sector:int)
Автозагрузка скрипта
Автозагрузка скрипта

Barrel.gd

Код для вращения барабана.

Полный код под спойлером:

Развернуть код
extends Node2D

@export var max_rotation_speed = 25.5 # Максимальная скорость вращения
@export var min_rotation_speed = 0.5 # Минимальная скорость вращения
@export var friction_coefficient = 0.990  # Коэффициент замедления вращения
@export var touch_slow_down_coefficient = 5 # Уменьшает скорость вращения при раскрутке барабана

var touch_start_position = Vector2.ZERO  # Начало касания
var touch_end_position = Vector2.ZERO  # Конечная позиция касания
var current_rotation_speed = 0  # Текущая скорость вращения
var last_touch_time = 0  # Время начала касания
var is_rotating = false  # Флаг вращения
var spin_sign # в какую сторону вращается барабан
var sectors_count = global.sectors.size() # количество секторов на барабане

func _ready():
	set_process_input(true)  # Включаем обработку событий ввода

## Функция чтобы понять попали ли мы в зону около барабана при раскрутке
## Нужна чтобы не мешать нажатия на кнопку ссылку афиши
func is_point_over_barrel(point):
	var rect = $ClickArea.get_global_rect()
	return rect.has_point(point)

func _input(event):
	# нажали мышкой в зоне прикосновения
	if event is InputEventMouseButton and is_point_over_barrel(event.position):
		if event.pressed: # ЛКМ нажата
			handle_touch_start(event.position)
		elif event.button_index == MOUSE_BUTTON_LEFT and not event.pressed:  # Левая кнопка мыши отпущена
			handle_touch_release()
	elif event is InputEventScreenTouch and is_point_over_barrel(event.position):
		if event.pressed:
			handle_touch_start(event.position)
		elif not event.pressed:  # Сенсорное событие отпущено
			handle_touch_release()
	elif event is InputEventMouseMotion or event is InputEventScreenDrag:
		touch_end_position = event.position

## обработка начала  движения указателя (пальца)
func handle_touch_start(touch_position):
	if global.can_spin==true:
		touch_start_position = touch_position
		last_touch_time = Time.get_ticks_msec()
		global.spin_started.emit() 

## Обработка начала  движения уазателя (пальца)
func handle_touch_release():
	if global.can_spin==true:
		var time_diff_ms = Time.get_ticks_msec() - last_touch_time
		var distance = touch_start_position.distance_to(touch_end_position)
		var speed = clamp(max_rotation_speed * (distance / (time_diff_ms * touch_slow_down_coefficient)), min_rotation_speed, max_rotation_speed)
		if is_rotating == false:
			spin_sign =  sign(touch_start_position.normalized().angle_to(touch_end_position.normalized()) )
			current_rotation_speed = speed * spin_sign
			is_rotating = true
			global.spin_started.emit()
	
## Замелояем движене барабана
func slow_down():
	if is_rotating == true:
		current_rotation_speed *= friction_coefficient 
		if abs(current_rotation_speed) < 0.25:
			is_rotating = false
			determine_sector()

## определяем выпавший сектор и запускаем дальнейшую обработку
func determine_sector():
	
	var sector_size = 360.0 / sectors_count # Размер сектора
	
	var rotation_deg = $Wheel.rotation_degrees # сохраняем угол поворота
	var wheel_angle =  rotation_deg * spin_sign # Применение поправки на знак вращения
	
	if spin_sign > 0:
		wheel_angle = 360 - wheel_angle  # Инвертируем угол при вращении против часовой стрелки
	

	var centered_angle = wheel_angle + (sector_size / 2) 	# Центрирование границы секторов	# Центрирование границы секторов
	
	var result_sector = floori(centered_angle / sector_size) % sectors_count # Определение сектора
	global.spin_finished.emit(result_sector) # отправляем сигнал
	rocking_wheel(rotation_deg,spin_sign) # делаем покачивание барабана у стрелки

## Покачивание барабана у стрелки
## Нужно чтобы задержка от запроса к API не бросалась в глаза
## final_rotation -- настоящий угол поворота который определен у барабана
## spin_sig -- направление вращения барабана
func rocking_wheel(final_rotation,spin_sign):
	var transition_duration = 1.0 # Длительность перехода в секундах
	var rotation_span = 1.5  # Макс разброс покачивываания у стрелки
	var wheel = $Wheel
	var tween = create_tween() # создаем анимацию
	var first_step= wheel.rotation_degrees - rotation_span * spin_sign  # угол для первой анимации
	var second_step = wheel.rotation_degrees  + (rotation_span / 4.0 * spin_sign ) #угол для второй анимации
	global.can_spin=false # чтобы не пытались запустить вращение во время покачивания
	tween.finished.connect(_on_wheel_tween_finished) # разрешаем вщать барабан после завершения качения
	tween.tween_property(wheel, "rotation_degrees", first_step , transition_duration) # Качаем против движения
	tween.tween_property(wheel, "rotation_degrees", second_step , transition_duration) # качаем по движению
	tween.tween_property(wheel, "rotation_degrees", final_rotation  , transition_duration) # приводим в конечное состояние
	
## разрешаем вращать барабан после завершения качения
func _on_wheel_tween_finished():
	global.can_spin=true

func _process(delta):
	if is_rotating:
		$Wheel.rotate(current_rotation_speed * delta)
		slow_down()

В этот раз я снабдил код кучей комментариев. Поэтому ограничусь кратким пересказом логики.

Вначале устанавливаем переменные и включаем обработку ввода.

Если пользователь нажал левую кнопку мыши (ЛКМ) или коснулся тачскрина, то мы проверяем, что касание было в зоне ClickArea.

Если все ОК, то смотрим можно ли вращать барабан.

Если можно, запускаем вращение. Направление и скорость вращения барабана определяется в момент отпускания ЛКМ (тачскрина).

Пока барабан вращается, повторные запуски вращения заблокированы.

Вращаясь барабан замедляется.

Когда скорость вращения барабана упадет ниже 0.25 мы считаем, что он остановился.

Определяем сектор на котором остановился барабан. И отправляем сигнал об остановке.

Сигнал остановки барабана обработает скрипт main.gd и направит запрос к API на получение события афиши.

Но поскольку, ответ не будет мгновенным, чтобы пользователь не ждал в пустую, мы показываем ему анимацию колебания барабана у стрелки (func rocking_wheel). Пока стрелка не остановится окончательно, барабан нельзя будет вращать повторно.

UI.gd

В основном скрипт выполняет следующие функции:

  1. Скрывает / показывает элементы интерфейса связанные с событием

  2. Вспомогательная логика для работы со списком городов

Полный код под спойлером.

Развернуть код
extends Control

var transition_duration = 1.5 # Длительность перехода в секундах
var start_alpha = 0.0  # Начальная прозрачность
var end_alpha = 1  # Конечная прозрачность

## Скрываем и обнулям лишние элементы
func init_ui():
	$VBoxContainer/ScrollContainer.visible = false
	$VBoxContainer/UrlPanel.visible = false
	$VBoxContainer/ScrollContainer/DescPanel/MarginContainer/Description.text=""
	$VBoxContainer/UrlPanel/URL.uri=""
	$VBoxContainer/UrlPanel/URL.disabled = true
	$VBoxContainer/DateTimePanel/DateTimeLabel.update_text() 

## Анимация медленного появления
func show_smoothly(node):
	var tween = create_tween()
	node = get_node(node)
	if node:
		node.modulate.a = 0
		tween.tween_property(node, "modulate:a",  end_alpha, transition_duration)

## Показать событие и ссыоку
func show_event(event:Dictionary):
	var text = ""
	if event.has("title"): #Если титл заполнен формируем жирную строку
		text = "[b]{title}[/b]\n".format({"title":event.title})
	
	if event.has("desc"): # Если описание заполнено добавляем его после титула.
		text += "{desc}".format({"desc":event.desc})
		
	if event.has("title") or event.has("desc"): # Если есть контент для окна с описанеим события отображаем его
		show_smoothly("VBoxContainer/ScrollContainer/DescPanel") # включаем плавное появление описания события
		$VBoxContainer/ScrollContainer.visible = true
		$VBoxContainer/ScrollContainer/DescPanel/MarginContainer/Description.text =  text
	
	# Аналогично заголовку и описанию, но для ссылки		
	if event.has("url"): 
		$VBoxContainer/UrlPanel/URL.disabled = false
		var url = event.url
		$VBoxContainer/UrlPanel/URL.uri= url
		show_smoothly("VBoxContainer/UrlPanel/")
		$VBoxContainer/UrlPanel.visible = true

## Выбираем ранее сохраненный город
func select_city_by_name(target_name):
	# Получить количество пунктов в OptionButton
	var option_button = $VBoxContainer/Cities
	var count = option_button.get_item_count()
	# Цикл по всем пунктам
	for i in range(count):
		# Получаем текст текущего пункта
		var text = option_button.get_item_text(i)
		# Проверяем совпадение с искомым именем
		if text == target_name:
			option_button.select(i)
	return -1  # Если элемент не найден, возвращаем -1
	
## Получить выбранный город в формате словаря для записи в файл (используется в main.gd)
func get_selected_city_name():
	var option_button = $VBoxContainer/Cities
	var selected = option_button.selected
	var city = {"city": option_button.get_item_text(selected)}
	return city

## Обработчик выбора сигнала после выбора города
func _on_cities_item_selected(index: int):
	init_ui()

Main.gd

По сути содержит основную логику связанную с обработкой запросов к сервису афиши.

Полный код под спойлером.

Развернуть код
extends Node2D

var rng = RandomNumberGenerator.new() # ГСЧ для выбора события

@onready var UI = $UI # ссылка на сцену UI
var event # информация о событии афиши
var http_request_location = HTTPRequest.new() # запрос к API с городами
var http_request_event = HTTPRequest.new() # запрос к API с афишей событий
const USER_CONFIG = "user://config.json" # файл в котором храним город
const API_LOCATIONS_URL = "https://kudago.com/public-api/v1.2/locations/?lang=ru&fields=slug,name" # Шаблон запроса к API для  городов
const API_EVENTS_TEMPLATE = "https://kudago.com/public-api/v1.4/events/?categories={category}&actual_since={current_dt}&actual_until={next_day}&fields=title,description,site_url&text_format=text&location={city}" # Шаблон запроса к API для событий

## Called when the node enters the scene tree for the first time.
func _ready() -> void:
	rng.randomize()  # Автоматически установит случайное зерно

	global.spin_started.connect(_on_spin_started) # связываем обработчик с сигналом начала вращения барабана
	global.spin_finished.connect(_on_spin_finished) # связываем обработчик с сигналом завершения вращения барабана
	$UI.init_ui() # Скрываем лишние элементы интерфейса
	
	add_child(http_request_location) # добавляем запросы в древо
	add_child(http_request_event)
	
	# связываем сигналы запросов к API с обработчиками
	http_request_location.request_completed.connect(_on_location_request_completed)
	http_request_event.request_completed.connect(_on_event_request_completed)

	http_request_location.request(API_LOCATIONS_URL) # отправляем запрос на получение списка городов

## Обработка начала вращения барабана
func _on_spin_started ():
	$UI.init_ui() # скрываем элементы UI

## Обработка завершения вращения барабана (выбран сектор)
func _on_spin_finished (sector:int):
	var current_dt = int(Time.get_unix_time_from_system()) # текущая дата и время
	var next_day= current_dt+(24*60*60)  # + 1 сутки
	var category = global.sectors[sector] # получаем сектор барабана
	var city_idx = $UI/VBoxContainer/Cities.selected # получаем выбранный город
	var city = global.locations[city_idx].slug # сопоставляем выбранный город со словарем городов Афиши
	var requset=API_EVENTS_TEMPLATE.format({ "category":category, "city":city, "current_dt":current_dt, "next_day":next_day}) #подставляем значения в шаблон запроса

	http_request_event.request(requset) # получаем афишу событий

## Обработка ответа на запрос к API по городам
func _on_location_request_completed(result, response_code, headers, body):
	if response_code == 200:
		var json = JSON.new()
		json.parse(body.get_string_from_utf8())
		var response = json.get_data()
		if  response.size()>0: # проверяем что есть города в ответе
			var cities = response.filter(	(func(element): return element.slug != "interesting")) # убираем странный пукнкт "интересные события"
			update_locations(cities) # 
			global.locations = cities # обновляем список городов
		else:
			error_locations_handler() # выве5де ошибку
	else:
			error_locations_handler()
			
## Вывод сообщения об ошибке в списке городов 
func error_locations_handler():
	var cities=UI.get_node("VBoxContainer/Cities")
	cities.clear()
	cities.add_item("Город не загрузился")
	global.can_spin = false
	var barrel_text=get_node("Barrel/Label")
	barrel_text.text = "Ошибка. Перезапустите приложение"

## обработка ответа от API Афиши
func _on_event_request_completed(result, response_code, headers, body):
	# Преобразуем ответ из JSON
	var json = JSON.new()
	json.parse(body.get_string_from_utf8())
	var response = json.get_data()
	if  response_code == 200:
		if response.count>0: # Проверка, что есть хоть одно событие
			# Выбираем случайное событие из ответа
			var rnd_event =  response.results[ rng.randi_range(0, response.results.size()-1)]
			# Формируем наполнение для UI
			event = {
				"title": rnd_event.title[0].to_upper() + rnd_event.title.substr(1,-1),
				"url": rnd_event.site_url,
				"desc": rnd_event.description
			}
		
		else: 
			event = {
				"desc": "Событий нет. Попробуйте еще!" 
			}
	else:
		event = {
			"desc": "Ошибка при получении событий"
		}
	$UI.show_event(event) # Показываем описание событие и ссылку
	var selected_city = $UI.get_selected_city_name() # Сохраняем  город на котором успешно отработал запрос, как город по умолчанию.
	write_json_data(USER_CONFIG, selected_city)
	
## Обновляем список городов
func update_locations(locations:Array):
	var cities = UI.get_node("VBoxContainer/Cities")
	cities.clear()
	for i in range(locations.size()):
		if locations[i].slug != "interesting":
			cities.add_item(locations[i].name,i)
	var config=read_json_data(USER_CONFIG)
	if config.has("city"):
		UI.select_city_by_name(config.city)

## Записываем город в json
func write_json_data(filename: String, data: Dictionary) -> bool:
	var file = FileAccess.open(filename, FileAccess.WRITE)
	if file.is_open():
		var json_str = JSON.stringify(data)
		file.store_string(json_str)
		file.close()
		return true
	else:
		print("Ошибка открытия файла для записи:", filename)
		return false
		
## Считываем город из json
func read_json_data(filename: String) -> Dictionary:
	if FileAccess.file_exists(filename):
		var file = FileAccess.open(filename, FileAccess.READ)
		if file.is_open():
			var content = file.get_as_text()
			file.close()
			var json = JSON.new()
			var result = json.parse(content)
			
			if result == Error.OK:
				return  json.get_data()
			else:
				print("Ошибка парсинга JSON файла:", filename)
				return {}
		else:
			print("Файл не найден:", filename)
			return {}
	else:
		return {}

Данный скрипт при запуске приложения, отправляет запрос на получение списка городов к API афиши.

Получив список городов, мы проверяем есть ли у нас в пользовательской папке файл config.json с ранее сохраненным городом.

Это нужно, чтобы пользователь при каждом новом запуске не должен был выбрать свой любимый город из списка.

Также при старте скрипт скрывает элементы интерфейса, связанные с описанием события афиши.

Далее мы ждем, пока барабан закончит вращение и отправит соответствующий сигнал.

Получив сигнал мы отправляем запрос на получение афиши для категорий событий назначенных сектору.

Если все прошло успешно, мы вызываем в сцене UI метод для отображения события. И попутно сохраняем город в конфиг.

Вот собственно и всё, давайте посмотрим, как это выглядит.

Запуск

Запуск через Godot Editor

Старт приложения
Старт приложения
Барабан вращается
Барабан вращается
Не повезло, событий нет.
Не повезло, событий нет.
Пробуем другой город
Пробуем другой город

Запуск на Android

Я для запуска приложения на Android, не стал заморачиваться с настройкой отладки на смартфоне, а просто экспортировал приложения в .apk файл.

Я не буду подробно расписывать процедуру экспорта, просто оставлю ссылку на туториал.

Не забудьте разрешить приложению доступ в интернет. 

Чтобы не мучиться с ключом, можно экспортировать debug сборку приложения.

Настройки экспорта
Настройки экспорта

Далее устанавливаем приложение, смартфон скорее всего будет ругаться, на то что это не безопасно. Однако, все равно даст его установить.

Если хочется сразу посмотреть результат, можно скачать .apk файл с GitHub.

Вот результат на смартфоне.

Результат на смартфоне
Результат на смартфоне

Заключение

Вот мы и разобрали идею для приложения на Godot. Понятно, что в нём нет практической пользы, но сама механика вращения колеса наверняка может найти применение. Например, как колесо фортуны для выбора активностей за праздничным столом. Или как составляющая другой игры.

Пусть я сам только начинаю путь в изучении Godot, но надеюсь, что эта статья хоть немного будет полезна и поможет кому-нибудь придумать свои замечательные игры.

Спасибо большое, что дочитали до конца.

Всем счастья и успехов в новом 2026 году!