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

Подготовка
Как всегда, все материалы доступны на Github
Для начала скачаем движок. Я использовал Godot Engine 4.5.1,
Затем нам понадобится фоновое изображение для красоты (но можно и без фона).
Я обратился за помощью к генеративному ИИ. Правда результат пришлось немного подправить с помощью Krita.
Барабан я не мог доверить нейросети, поэтому набросал его в 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, чтобы ловить зону касания мышью (пальцем).

Сцена с 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
Вот как это выглядит в редакторе:

Главная сцена (main.tscn)
Структура главной сцены простая в родительский узел Barrel (node2D) входят:
Background – TextureRect c текстурой background.jpg
Barrel.tscn
UI.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
В основном скрипт выполняет следующие функции:
Скрывает / показывает элементы интерфейса связанные с событием
Вспомогательная логика для работы со списком городов
Полный код под спойлером.
Развернуть код
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 году!
