Делаем звонок из браузера в игре на движке Godot 4
Представим ситуацию, вам очень хочется необычно поздравить человека, ну или сделать маркетинговую акцию. В голове уже созрел сценарий – пользователь открывает ссылку, выполняет простые действия и затем прямо из браузера звонит на определенный номер. А мы на том конце сообщаем ему какую-нибудь приятную новость. Чудеса да и только!
Но вот незадача, оплаченные курсы «Как стать фронтенд-специалистом с нуля до техлида за два дня» начнутся только через неделю, поэтому разрабатывать супер крутые сайты – пока за гранью ваших возможностей.
К счастью в активе есть жгучее желание делать игры на бесплатном движке Godot, пара свободных часов и аккаунт в МТС Exolve.
Не будем терять ни минуты и начнем эту задачу решать.
Оглавление:
Пара слов про Godot и МТС Exolve WebSDK
Godot – кроссплатформенный движок для разработки 2D и 3D игр с открытым исходным кодом. Запустить среду разработки и собрать проект Godot можно не только на ПК под управлением Windows/Linux/Mac, но также на Android / IOS смартфонах и даже в браузере (с ограничениями). Выбор целевых платформ для экспорта проекта, тоже велик, но нас сегодня интересует возможность экспортировать игру в формат HTML 5.
WebSDK – это набор инструментов от МТС Exolve с помощью, которого можно реализовать работу с интернет-телефонией прямо из браузера.
Web SDK распространяется в качестве npm пакета. Для импорта инструментов по созданию соединения и совершения звонка через браузер, в статье «Как сделать виджет звонков из браузера на примере XWiki» мы собрали свою клиентскую библиотеку bundle.js на базе WebSDK. Она отлично подойдет для решения текущей задачи.
Что хотим сделать
Исключительно в демонстрационных целях, мы сделаем простую игру.
Возьмем номер телефона без кода страны (в нашем случае он всегда «7») и разобьем его на 5 пар по две цифры. Перемешаем пары в случайном порядке и предложим игроку собрать верный номер телефона.
После того как пользователь разместит все числа в правильные позиции, станет активной кнопка совершения вызова. Пользователь прямо из браузера совершает вызов, а мы снимаем трубку и чествуем величайшего победителя нашей игры!

Чтобы это воплотить в жизнь, нам понадобится не только забрать к себе библиотеку bundle.js, о которой я упоминал выше, но и настроить SIP-соединение в личном кабинете MTС Evolve. Подробно этот процесс разобран в разделе «Подготовительные работы» статьи про интеграцию WebSDK и XWiki.
Пара слов перед началом работы
Формальности позади, теперь можем приступить к созданию проекта в Godot. Я использовал версию движка 4.3.
Изучить готовый проект можно на GitHub.
Традиционный дисклеймер: я не разработчик, поэтому решения принятые в этой статье могут быть не самыми лучшими. Это лишь демонстрация концепта, поэтому я не рекомендую использовать её в неизменном виде для продуктовой разработки.
Я решил не «раздувать» статью, поэтому не буду подробно останавливаться на параметрах узлов и настройках проекта, не связанных с логикой. Этот проект не про красоту. Если вы хотите повторить его в точности. то будет быстрее и проще импортировать и изучить готовый проект.
Игра будет состоять из трех сцен с ресурсами и одного корневого скрипта.
-
Сцена game.tscn – основная сцена игра, на которой реализована ключевая техника
-
Сцена number.tscn – реализация перетаскиваемых фрагментов номера телефона
-
Сцена number_pannel.tscn – реализация приемника для фрагментов номера.
-
events.gd – корневой скрипт, который содержит единственный сигнал, по сути прототип шины событий.
Автоподключение events.gd
Events.gd скрипт в котором будет лежать общий для проекта сигнал pannel_change_number
.
Создайте не привязанный к сцене скрипт events.gd
Посмотреть код
extends Node
signal pannel_change_number
func _ready() -> void:
pass # Replace with function body.
func _process(delta: float) -> void:
pass
В настройках проекта (Project Settings -> Globals) добавьте скрипт в автозагрузку.
Сцена с фрагментами номера
Сцена number.tscn Сцена реализует логику создания фрагмента номера и его захвата для переноса в соответствующий контейнер.

Структура сцены (список отражает структуру вложенности):
-
Number (Control) – контейнер для элементов
-
Panel – панель с заливкой и рамкой.
-
NumberLabel (Label) – текст для фрагмента номера
-
-
Примечание: здесь и далее если тип и название узла не совпадают, тип указывается в скобках.
К сцене привяжем скрипт number.gd.
Посмотреть код.
extends Control
@export var current_number := "00"
var drag_preview
# Called when the node enters the scene tree for the first time.
func _ready() -> void:
$Panel/NumberLabel.text = current_number
func change_color(hexcolor:String):
$Panel/NumberLabel.set("theme_override_colors/font_color",hexcolor)
func change_current_number(number:String):
current_number = number
$Panel/NumberLabel.text = current_number
# Called every frame. 'delta' is the elapsed time since the previous frame.
func _process(delta: float) -> void:
pass
func _get_drag_data(at_position: Vector2):
var preview = load('res://scenes/number/number.tscn').instantiate()
set_drag_preview(preview)
preview.change_current_number(current_number)
drag_preview = preview
return self
Разберем код по частям
@export var currentNumber := "00"
Переменная для задания текущего значения фрагменту номера.
Отмечена как @export для удобства разработки, чтобы на начальном этапе можно было быстро назначить значение через редактор.
Функции change_color
и change_current_number
просты для понимания, их мы пропустим.
А вот на функции захвата панели остановимся подробнее.
func _get_drag_data(at_position: Vector2):
var preview = load('res://scenes/number/number.tscn').instantiate()
set_drag_preview(preview)
preview.change_current_number(current_number)
drag_preview = preview
return self
_get_drag_data
–встроенная функция движка, в данном случае мы переопределить её своей логикой.
В нашем случае важны две вещи: превью, которое будет отображаться в момент перетаскивания, и объект, который мы передадим приемнику.
В самом начале скрипта мы объявили глобальную переменную drag_preview
, для того, чтобы впоследствии передать её в приемник вместе с остальными элементами сцены.
В данной функции мы создаем по сути копию сцены number.tscn, говорим движку что её надо обрабатывать как превью при перетаскивании объекта.
Поскольку нужный фрагмент номера мы зададим в основной игровой сцене, то в превью надо передать актуальный current_number
сцены, в противном случае мы при перетаскивании вместо нужных цифр будем видеть «00».
В итоге мы возвращаем всю сцену вместе с её превью, это пригодится нам в при обработке перетаскивания в контейнер.
Сцена с панелями для вставки номеров
Сцена number_pannel.tscn Сцена реализует логику создания фрагмента номера и его захвата для переноса в соответствующий контейнер.

Структура сцены (список отражает структуру вложенности):
-
NumberPannel (Control) – контейнер для элементов
-
Panel – панель с заливкой и рамкой.
-
NumberLabel (Label) – текст для фрагмента номера в контейнере
-
-
К сцене привяжем скрипт number_pannel.gd
Посмотреть код
extends Control
@export var correct_number := "00"
@export var current_number = "--"
func _ready() -> void:
pass # Replace with function body.
# change container number
func change_current_number(number:String):
current_number = number
$Panel/NumberLabel.text = current_number
func change_correct_number(number:String):
correct_number = number
func _process(delta: float) -> void:
pass
func _can_drop_data(at_position: Vector2, data: Variant):
# color hint for drag and drop
if data.current_number == self.correct_number:
data.drag_preview.change_color("#00FF00")
return true
else:
data.drag_preview.change_color("#FF0000")
return false
func _drop_data(at_position: Vector2, data: Variant):
# change number in receiver container and delete dropped panel
change_current_number (data.current_number)
data.queue_free()
Events.pannel_change_number.emit()
Сцена очень похожа на number.tscn поэтому остановимся только на функцйиях can_drop_data
и can_drop_data
. Как и в случае с _get_drag_data это
функции движка, логику которых мы переопределим.
func _can_drop_data(at_position: Vector2, data: Variant):
# color hint for drag and drop
if data.current_number == self.correct_number:
data.drag_preview.change_color("#00FF00")
return true
else:
data.drag_preview.change_color("#FF0000")
return false
Отвечает за то, можно ли нам «сбросить» перетаскиваемый объект на экземпляр сцены number_pannel.tscn.
Мы проверяем совпадает ли фрагмент номера с правильным. Если совпадает, то мы красим превью зеленым и разрешаем сброс, иначе – красим в красный и не даем сбросить перетаскиваемый объект.
Поскольку у нас в сцене будет всего один тип перетаскиваемых объектов, какие-либо другие проверки мне показались излишними.
func _drop_data(at_position: Vector2, data: Variant):
# change number in receiver container and delete dropped panel
change_current_number (data.current_number)
data.queue_free()
Events.pannel_change_number.emit()
В этой функции мы:
-
меняем значение номера для контейнера;
-
удаляем полностью перетаскиваемый фрагмент номера;
-
посылаем сигнал, который раньше определили в events.gd, на этот сигнал мы подпишемся в сцене game.tscn и будем использовать его для проверки на то, что номер корректно собран.
Главная сцена игры
Вот и пришло время главной сцены игры.
Сцена game.tscn Сцена реализует главную. механику игры, в том числе «звонок другу».

Структура сцены (список отражает структуру вложенности):
-
Background (TextureRect) – backround игры
-
VBoxContainer – нужен для вертикального выравнивания
-
HeaderTable (GridContainer) – размещает три вложенных элемента, как три колонки одной строки таблицы.
-
HeaderMargin1 (MarginContainer) – нужен для отступа Label
-
Label – текст с правилами игры
-
-
Container (MarginContainer) – нужен для отступа по середине
-
HeaderMargin2 (MarginContainer) – нужен для отступа Button
-
NewGameButton (Button) – кнопка для запуска новой игры
-
отправляет сигнал pressed() – обработчик _on_new_game_pressed())
-
-
-
-
HBoxContainer2 – нужен для горизонтального выравнивания
-
MarginContainer – нужен для оступа
-
Number1 – экземпляр сцены number.tscn
-
-
-
… далее идентично вплоть до Number5
-
HBoxContainer3 – нужен для горизонтального выравнивания
-
Label – текст «+7»
-
%NP1 – экземпляр сцены number_pannel.tscn
-
символ «%» – говорит нам о том, что мы можем обращаться к узлу прямо по его имени.
-
-
… далее идентично вплоть до NP5
-
-
HBoxContainer3 – нужен для горизонтального выравнивания
-
CallButton (Button) – кнопка для совершения вызова.
-
отправляет сигнал pressed() – обработчик _on_button_pressed()
-
-
-
К сцене привяжем скрипт game.gd.
Посмотреть код
extends Control
const COUNTRY_CODE: = "7"
@onready var game_scene = preload("res://scenes/game/game.tscn")
var sip_login:= "<your_sip_login>"
var sip_password:= "<your_sip_password>"
var window
var correct_phone
func fload():
var file = FileAccess.open("res://game-config.json", FileAccess.READ)
var content = file.get_as_text()
file.close()
var result_json = JSON.parse_string(content)
return result_json
func string_to_array_pairs(text: String) -> Array:
var result = []
for i in range(0, text.length() - (text.length() % 2), 2): # Adjust range to avoid odd last char
result.append(text.substr(i, 2))
return result
# Called when the node enters the scene tree for the first time.
func _ready() -> void:
Events.connect("pannel_change_number",on_pannel_change_number)
var user_config = fload()
correct_phone = user_config["phone"]
var phone_array = string_to_array_pairs(correct_phone)
# fill number containers
var number_containers = [%NP1,%NP2,%NP3,%NP4,%NP5]
for i in number_containers.size():
number_containers[i].change_correct_number(phone_array[i])
# shuffle for random fill numbers
phone_array.shuffle()
var nodes :=get_tree().get_nodes_in_group("Numbers")
for i in nodes.size():
nodes[i].change_current_number(phone_array[i])
# Called every frame. 'delta' is the elapsed time since the previous frame.
func _process(delta: float) -> void:
pass
func get_numbers(numbers:Array) -> String:
var text:= ""
for n in numbers:
text += n.current_number
return text
func _on_button_pressed() -> void:
# get phone number to call
var np_array := [%NP1,%NP2,%NP3,%NP4,%NP5]
var phone := get_numbers(np_array)
# make phone call via SIP connection
window.sip_instance.call(COUNTRY_CODE+phone)
func on_pannel_change_number():
var np_array := [%NP1,%NP2,%NP3,%NP4,%NP5]
var phone := get_numbers(np_array)
if phone == correct_phone:
# create SIP connection in JS window object
%CallButton.text = "Позвонить на номер +{0}{1}".format([COUNTRY_CODE,phone])
JavaScriptBridge.eval("window.sip_instance=WebVoiceSdk.createSipInstance({
sipLogin: '{0}',
sipPassword: '{1}',
})".format([sip_login,sip_password]),true)
# register SIP connection
JavaScriptBridge.eval("window.sip_instance.register()",true)
# get JS window object as godot object
window = JavaScriptBridge.get_interface("window")
if (window):
%CallButton.disabled = false
else:
push_error("Works only in browser")
%CallButton.text = "Чтобы сделать вызов откройте игру в браузере"
# instance new game scene for reset
func _on_new_game_pressed() -> void:
get_tree().change_scene_to_packed(game_scene)
Разберем некоторые глобальные переменные
-
@onready var game_scene = preload("res://scenes/game/game.tscn")
– предварительно загруженная сцена для рестарта игры. -
var sip_login:= "<your_sip_login>"
– логин для SIP-соединения из вашего ЛК МТС Exolve -
var sip_password:= "<your_sip_password>"
– пароль для SIP-соединения из вашего ЛК МТС Exolve -
var window
– переменная для JS объекта window -
var correct_phone
– верный телефон (берется из конфига)
Не забудьте указать SIP логин и пароль.
Разберем некоторые функции (не в порядке появления в листинге):
func string_to_array_pairs – разбивает строку на массив по 2 цифры, чтобы заполнить плитки.
func get_numbers – наоборот собирает сроку из массива.
func fload() – читает и парсит json файл, используем для получения номера телефона из res://game-config.json.
Будет уместно рассказать пару слов о файле конфигурации game-config.json
В файле всего один атрибут – номер телефона, который мы угадываем без кода страны
{
"phone":"0001234567"
}
В принципе его можно было бы вынести и в константу, но при экспорте проекта мы включим game-config.json в состав файла index.pck.
Во-первых, в качестве демонстрации опций экспорта (об этом позже), ну а во-вторых потому что гипотетически .pck файл можно поправить без экспорта всего проекта в редакторе, например, с помощью GodotPCKExplorer (см. GitHub).
Остальные функции рассмотрим с листингом кода.
func _on_new_game_pressed() -> void:
get_tree().change_scene_to_packed(game_scene)
Это обработчик нажатия на кнопку «Начать заново». По сути мы просто заменяем текущую сцену game.scn новым экземпляром.
func on_pannel_change_number():
var np_array := [%NP1,%NP2,%NP3,%NP4,%NP5]
var phone := get_numbers(np_array)
if phone == correct_phone:
# create SIP connection in JS window object
%CallButton.text = "Позвонить на номер +{0}{1}".format([COUNTRY_CODE,phone])
JavaScriptBridge.eval("window.sip_instance=WebVoiceSdk.createSipInstance({
sipLogin: '{0}',
sipPassword: '{1}',
})".format([sip_login,sip_password]),true)
# register SIP connection
JavaScriptBridge.eval("window.sip_instance.register()",true)
# get JS window object as godot object
window = JavaScriptBridge.get_interface("window")
if (window):
%CallButton.disabled = false
else:
push_error("Works only in browser")
%CallButton.text = "Чтобы сделать вызов, откройте игру в браузере"
В первой части функции мы проверяем собранный из фрагментов номер с верным, если все успешно, то меняем текст на кнопке.
Далее наступает время интеграции Godot и JavaScript.
Есть несколько способов взаимодействия, и мы для демонстрации рассмотрим оба.
Функции для звонка мы будем брать из bundle.js. Как подключить библиотеку к игре мы рассмотрим позже на этапе экспорта проекта.
JavaScriptBridge.eval
– Фактически выполняет JS код. Это почти тоже самое как если бы вы во время выполнения открыли консоль браузера и выполнили код.
В первом вызове eval мы создаем SIP-соединение с помощью WebSDK.
Во втором вызове, мы регистрируем это соединение.
В принципе все – можно совершать вызов, но у нас предусмотрена чуть более сложная логика.
window = JavaScriptBridge.get_interface("window")
В данном фрагменте мы фактически конвертируем JS объект window в понятный для Godot объект.
Далее проверяем: если объект window существует, значит игра открыта в браузере и можно совершить вызов.
Иначе, WebSDK не сработает и разблокировать кнопку вызова нельзя.
Мы плавно подошли к обработчику нажатия на кнопку вызова.
func _on_button_pressed() -> void:
# get phone number to call
var np_array := [%NP1,%NP2,%NP3,%NP4,%NP5]
var phone := get_numbers(np_array)
# make phone call via SIP connection
window.sip_instance.call(COUNTRY_CODE+phone)
Эта функция собирает все фрагменты номера телефона вместе и делает запрос к функции вызова абонента в WebSDK.
Осталась последняя в списке, но не по значимости функция _ready
она срабатывает когда сцена готова. По сути в ней мы задаем настройки всем объектам сцены, особо разбирать смысла код нет.
Теперь, когда всё готово, запустим проект в редакторе и убедимся, что он работает.

Всё как ожидалось, номер собран корректно, но сделать вызов не из браузера мы не можем. Пора переходить к экспорту.
Экспорт и результат
Перейдите в раздел Project->Export… и в появившемся окне добавьте новый шаблон «Web».
Если это ваш первый экспорт для Web, Godot попросит установить шаблон для экспорта.
На вкладке «Options» в графе «Headers» добавьте ссылку на нашу библиотеку собранную на базе WebSDK <script src="bundle.js"></script>
.
Обратите внимание! Вам надо скопировать её самостоятельно в папку с остальными файлами экспорта, или просто разместите её на каком-нибудь cdn.
Также в этом разделе можно выбрать папку, в которую будем экспортировать проект.

Перейдем на вкладку «Resources» и добавим в экспорт ресурсов маску *.json, чтобы файл game-config.json был включен в файл ресурсов index.pck.
Нажмите кнопку «Export All…», и в появившемся окне выберите любой из режимов «Debug» или «Release»
Осталось проверить, что всё работает.
Проект необходимо разместить на каком-нибудь web-сервере.
Я выбрал самый простой способ и установил расширение Live Server в VS Code.
Открываем экспортированный проект в браузере и пробуем сыграть.




Конечно же, номер выдуманный (любые совпадения случайны), поэтому система сделает вызов, о чем свидетельствует запрос прав на использование микрофона, но трубку на том конце никто не возьмет.
Но если вы введете свой существующий номер, то вам придет вызов.
Если захочется протестировать на

Поздравляю! Вы сделали простую web игру с функцией вызова номера телефона.
В идеале хотелось бы полной кроссплатформенности. У меня есть мысли по использованию других SDK от МТС Evolve, например ReactNative или Flutter. Если я пойму, что тема вам интересна, то обязательно напишу о своём опыте.
А пока буду рад почитать конструктивные комментарии.
Подписывайтесь на наш Хаб, следите за новыми гайдами и получайте приз
Каждый понедельник мы случайным образом выбираем победителей среди новых подписчиков нашего Хабр-канала и дарим крутые призы от МТС Exolve: стильные рюкзаки, лонгсливы и мощные беспроводные зарядки. Победители прошлых розыгрышей и правила.
Автор: BosonBeard