Yandex Ads SDK для игр на Godot

16 minute read

Давно не было статей про плагины под Godot. В движке уже даже успели поменять подход к написанию плагинов, поэтому будем разбираться походу дела. По сути, плагин Godot для Android v2 — это библиотека для Android, которая зависит от библиотеки Godot для Android и пользовательского манифеста библиотеки Android.

В этой статье расскажу про самый главный плагин для игр - Яндекс реклама. Разберемся как интегрировать рекламу в свою игру. Правда, пока только под андроид. Но уже пишу под iOS.

Интегрировать будем SDK от Yandex - самая популярная рекламная SDK для российского рынка.

Плюсом реализации будет возможность подключать несколько слотов для разных форматов. Во всех существующих SDK можно подключать только по одному слоту на формат, а в моей реализации - сколько угодно слотов. Кроме того, в той реализации будет использоваться библиотека со встроенной медиацией, что позволит вам еще лучше монетизировать свое приложение.

Плагин v2

Будем делать плагин для godot 4.3 а там ощутимо переделали подход к написанию плагинов. Подробности про написание плагинов можно почитать в официальной документации. Тут я разберу только важные моменты

Плагины Android версии 1 требовали специального gdap файла конфигурации, который использовался редактором Godot для их обнаружения и загрузки. Однако у этого подхода было несколько недостатков, главным из которых было отсутствие гибкости и отход от существующего формата, процесса доставки и установки плагинов Godot EditorExport.

Эта проблема была решена для плагинов Android версии 2 путём отказа от механизма gdap упаковки и настройки в пользу существующего формата упаковки Godot EditorExportPlugin. API EditorExportPlugin в свою очередь был расширен для корректной поддержки плагинов Android.

Шаблон плагина берем с гитхаба - это самый простой подход начать писать качественный плагин.

Структура шаблонного плагина, которую будем менять

 1.
 2+--plugin
 3|   +--demo
 4|   |  +--addons
 5|   |     \...
 6|   +--export_scripts_template
 7|   |  +--export_plugin.gd
 8|   |  \--plugin.cfg
 9|   \--src
10|   |  \--main
11|   |      +--java
12|   |      |  \--org
13|   |      |     \--godotengine
14|   |      |        \--plugin
15|   |      |           \--android
16|   |      |              \--template
17|   |      |                 \--GodotAndroidPlugin.kt
18|   |      \--AndroidManifest.xml
19|   \--build.gradle.kts
20+--build.gradle.kts
21\--settings.gradle.kts

Начнем с переименовывания в шаблоне всех нужных названий. Начнем с файла plugin/build.gradle.kts, нужно указать правильное название плагина и имя пакета, в котором плагин будет лежать

1val pluginName = "GodotYandexAds"
2
3val pluginPackageName = "ru.kovardin.godotyandexads"

Обратите внимание, что в этом файле указывается зависимость org.godotengine:godot, которая нужна для работы плагина.

1dependencies {
2    implementation("org.godotengine:godot:4.3.0.stable")
3}

Сразу можно указать еще одну зависимость, которая нам понадобится для разработки:

1dependencies {
2    implementation("org.godotengine:godot:4.3.0.stable")
3    // yandex ads
4    implementation("com.yandex.android:mobileads-mediation:7.8.0.0")
5}

Далее, название плагина GodotYandexAds вписываем в AndroidManifest.xml

1<meta-data
2    android:name="org.godotengine.plugin.v2.${godotPluginName}"
3    android:value="${godotPluginPackageName}.GodotYandexAds"/>

Теперь меняем название проекта, указываем в файле settings.gradle.kts новое название GodotYandexAds. В моем случае название проекта совпадает с названием плагина

1rootProject.name = "GodotYandexAds"
2include(":plugin")

Теперь переименовываем файл GodotAndroidPlugin.kt в GodotYandexAds.kt и в файле меняем название класса на GodotYandexAds. Сам файл нужно перенести в пакет ru.kovardin.godotyandexads, и для этого нужно поменять структуру директорий, перенести его в src/ru/kovardin/godotyandexads. В файле GodotYandexAds.kt я буду описывать всю основную логику плагина

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

Папку export_scripts_template переименовываем в export. Файл export_plugin.gd переименовываем в export_yandex_ads.gd. В файле plugin.cfg нужно указать новое название скрипта в поле script="export_yandex_ads.gd", заодно указываем всю остальную информацию плагина

Теперь нужно отредактировать скрипт экспорта, который по сути является расширением класса EditorPlugin. Этот скрипт необходим для корректной работы плагина в движке. Нужно определить три метода:

  • _get_android_dependencies(platform, debug) - в этом методе определяем зависимости для работы нашего плагина;
  • _get_android_dependencies_maven_repos(platform, debug) - тут указываем репозитории, которые будут нужны для правильной работы Яндекс рекламы;
  • _get_android_manifest_application_element_contents(platform, debug) - этот метод позволяет дополнить AndroidManifest.xml и дописать туда необходимую информацию.

Итоговый файл export_yandex_ads.gd будет выглядеть так:

 1@tool
 2extends EditorPlugin
 3
 4# A class member to hold the editor export plugin during its lifecycle.
 5var export_plugin : AndroidExportPlugin
 6
 7func _enter_tree():
 8	# Initialization of the plugin goes here.
 9	export_plugin = AndroidExportPlugin.new()
10	add_export_plugin(export_plugin)
11
12
13func _exit_tree():
14	# Clean-up of the plugin goes here.
15	remove_export_plugin(export_plugin)
16	export_plugin = null
17
18
19class AndroidExportPlugin extends EditorExportPlugin:
20	# TODO: Update to your plugin's name.
21	var _plugin_name = "GodotYandexAds"
22
23	func _supports_platform(platform):
24		if platform is EditorExportPlatformAndroid:
25			return true
26		return false
27
28	func _get_android_libraries(platform, debug):
29		if debug:
30			return PackedStringArray([_plugin_name + "/bin/debug/" + _plugin_name + "-debug.aar"])
31		else:
32			return PackedStringArray([_plugin_name + "/bin/release/" + _plugin_name + "-release.aar"])
33
34	func _get_android_dependencies(platform, debug):
35		return PackedStringArray([
36			"com.yandex.android:mobileads-mediation:7.8.0.0"
37		])
38
39
40	func _get_android_dependencies_maven_repos(platform, debug):
41		return PackedStringArray([
42			"https://android-sdk.is.com/",
43			"https://artifact.bytedance.com/repository/pangle",
44			"https://sdk.tapjoy.com/",
45			"https://dl-maven-android.mintegral.com/repository/mbridge_android_sdk_oversea",
46			"https://cboost.jfrog.io/artifactory/chartboost-ads/",
47			"https://dl.appnext.com/"
48		])
49
50	func _get_android_manifest_application_element_contents(platform, debug) -> String:
51		return '<meta-data android:name="com.google.android.gms.ads.APPLICATION_ID" android:value="ca-app-pub-3940256099942544~3347511713"/>'
52
53
54	func _get_name():
55		return _plugin_name

В скрипте нужно правильно указать название плагина var _plugin_name = "GodotYandexAds"

Итоговая структура плагина будет выглядеть так:

 1.
 2+--plugin
 3|   +--demo
 4|   |  +--addons
 5|   |     \...
 6|   +--export
 7|   |  +--export_yandex_ads.gd
 8|   |  \--plugin.cfg
 9|   \--src
10|   |  \--main
11|   |      +--java
12|   |      |  \--ru
13|   |      |     \--kovardin
14|   |      |        \--godotyandexads
15|   |      |           \--GodotYandexAds.kt
16|   |      \--AndroidManifest.xml
17|   \--build.gradle.kts
18+--build.gradle.kts
19\--settings.gradle.kts

В файле GodotYandexAds.kt я буду описывать всю основную логику плагина. А пока разберемся с папками demo и export.

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

Папка export содержит скрипт EditorPlugin который нужен для корректной работы плагина в движке

Сборка

В шаблоне уже все готово, чтобы собрать проект и положить его в нужную папку в игре. Достаточно запустить

1./gradlew assemble 

В файле plugin/build.gradle.kts есть несколько подготовленных команд

 1// BUILD TASKS DEFINITION
 2val copyDebugAARToDemoAddons by tasks.registering(Copy::class) {
 3    description = "Copies the generated debug AAR binary to the plugin's addons directory"
 4    from("build/outputs/aar")
 5    include("$pluginName-debug.aar")
 6    into("demo/addons/$pluginName/bin/debug")
 7}
 8
 9val copyReleaseAARToDemoAddons by tasks.registering(Copy::class) {
10    description = "Copies the generated release AAR binary to the plugin's addons directory"
11    from("build/outputs/aar")
12    include("$pluginName-release.aar")
13    into("demo/addons/$pluginName/bin/release")
14}
15
16val cleanDemoAddons by tasks.registering(Delete::class) {
17    delete("demo/addons/$pluginName")
18}
19
20val copyAddonsToDemo by tasks.registering(Copy::class) {
21    description = "Copies the export scripts templates to the plugin's addons directory"
22
23    dependsOn(cleanDemoAddons)
24    finalizedBy(copyDebugAARToDemoAddons)
25    finalizedBy(copyReleaseAARToDemoAddons)
26
27    from("export")
28    into("demo/addons/$pluginName")
29}
30
31tasks.named("assemble").configure {
32    finalizedBy(copyAddonsToDemo)
33}

Каждый раз при запуске сборки будет обновляться плагин в игре примере. Новая версия собранной библиотеки будет копироваться в demo/addons/GodotYandexAds. Чтобы включить плагин, нужно зайти в меню Project > Project Settings, выбрать вкладку Plugins и отметить как Enable плагин с названием “GodotYandexAds”

Интеграция Яндекс рекламы

Реализуем только часть форматов: баннер, интерстишел, ревардед, аппопен. В 99% случаях этого будет достаточно.

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

В коде мапа для баннеров выглядит так:

1private var banners: MutableMap<String, BannerAdView> = mutableMapOf()

Менеджмент юнитов остается на стороне разработчика.

Баннер

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

Тут есть некоторые сложности. Баннер просто так не отрендерить, его нужно прицепить его к лайауту. Для этого переопределяем метод onMainCreate

1private var layout: FrameLayout? = null
2private val layoutParams: FrameLayout.LayoutParams? = null
3
4override fun onMainCreate(activity: Activity): View? {
5    layout = FrameLayout(activity)
6    return layout
7}

В методе onMainCreate создается новый FrameLayout и сохраняется в параметре layout, который можно использовать для рендера баннера. Но до рендера нужно создать баннер и настроить все параметры баннера. Для создания нам понадобится доступ к актуальной активити:

1private fun createBanner(id: String, params: Dictionary) {
2    val activity = activity ?: return
3
4    val banner = BannerAdView(activity)
5
6    // остальной код реализации
7}

Дальше задаем нужные параметры. Нужно определить позицию баннера и задать ее через настройку лайаута

 1layoutParams = FrameLayout.LayoutParams(
 2    FrameLayout.LayoutParams.MATCH_PARENT,
 3    FrameLayout.LayoutParams.WRAP_CONTENT
 4)
 5
 6val position = params.getOrDefault(BANNER_POSITION, POSITION_BOTTOM) as Int
 7val safearea = params.getOrDefault(BANNER_SAFE_AREA, true) as Boolean
 8
 9if (position == POSITION_TOP) {
10    layoutParams?.gravity = Gravity.TOP
11    if (safearea) banner.y = getSafeArea().top.toFloat()
12} else { // default
13    layoutParams?.gravity = Gravity.BOTTOM
14    if (safearea) banner.y = (-getSafeArea().bottom).toFloat()
15}

params - это переменная типа Dictionary в которой передаются настройки для баннера из самого движка Godot.

getSafeArea() - украл у пакета для работы с AdMob. Этот метод нужен для правильного определения безопасных границ для размещения баннера

 1private fun getSafeArea(): Rect {
 2    val safeInsetRect = Rect()
 3    if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
 4        return safeInsetRect
 5    }
 6    val windowInsets: WindowInsets = activity?.getWindow()?.getDecorView()?.getRootWindowInsets()
 7        ?: return safeInsetRect
 8    val displayCutout = windowInsets.displayCutout
 9    if (displayCutout != null) {
10        safeInsetRect[displayCutout.safeInsetLeft, displayCutout.safeInsetTop, displayCutout.safeInsetRight] =
11            displayCutout.safeInsetBottom
12    }
13    return safeInsetRect
14}

Нужно задать еще один параметр - указать размер баннера. Баннер может быть inline или sticky.

inline тип рекламы позволяет разработчикам указывать максимально допустимые ширину и высоту объявления, при этом наиболее оптимальный размер рекламы определяется автоматически. Чтобы выбрать лучший размер объявления, встроенные адаптивные баннеры используют максимальную, а не фиксированную высоту. Как правило, такой формат используется в приложениях-лентах или там, где допустимо обеспечить основной фокус на рекламе.

sticky — небольшое, автоматически обновляемое рекламное объявление, которое располагается внизу или вверху экрана приложения. Баннер не перекрывает основной контент приложения и часто используется в приложениях-играх.

Код в котором указываем размеры и тип баннера:

 1var sizeType = params.getOrDefault(BANNER_SIZE_TYPE, BANNER_STICKY_SIZE)
 2var width = params.getOrDefault(BANNER_WIDTH, 0) as Int
 3var height = params.getOrDefault(BANNER_HEIGHT, 0) as Int
 4
 5when (sizeType) {
 6    BANNER_INLINE_SIZE ->
 7        banner.setAdSize(BannerAdSize.inlineSize(activity, width, height))
 8
 9    BANNER_STICKY_SIZE ->
10        banner.setAdSize(BannerAdSize.stickySize(activity, width))
11}

Все параметры, которые можно передавать для настройки баннера можно посмотреть в companion object

 1companion object {
 2    const val POSITION_TOP = 1
 3    const val POSITION_BOTTOM = 0
 4
 5    const val BANNER_STICKY_SIZE = "sticky"
 6    const val BANNER_INLINE_SIZE = "inline"
 7
 8    const val BANNER_POSITION = "position"
 9    const val BANNER_SAFE_AREA = "safe_area"
10    const val BANNER_WIDTH = "width"
11    const val BANNER_HEIGHT = "height"
12    const val BANNER_SIZE_TYPE = "size_type"
13}

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

 1val signals: MutableSet<SignalInfo> = ArraySet()
 2signals.add(SignalInfo("ads_initialized"))
 3
 4// banner
 5signals.add(SignalInfo("banner_loaded", String::class.java))
 6signals.add(SignalInfo("banner_failed_to_load", String::class.java, Integer::class.java))
 7signals.add(SignalInfo("banner_ad_clicked", String::class.java))
 8signals.add(SignalInfo("banner_left_application", String::class.java))
 9signals.add(SignalInfo("banner_returned_to_application", String::class.java))
10signals.add(SignalInfo("banner_on_impression", String::class.java, String::class.java))

Для баннера создаем 6 сигналов и сохраняем их в сет signals. Как я указал выше, в каждом обработчике будет емититься свой сигнал с нужными параметрами. Определяем все обработчики:

 1banner.setBannerAdEventListener(object : BannerAdEventListener {
 2    override fun onAdLoaded() {
 3        Log.w(tag, "YandexAds: onBannerAdLoaded")
 4        emitSignal("banner_loaded", id)
 5    }
 6
 7    override fun onAdFailedToLoad(error: AdRequestError) {
 8        Log.w(tag, "YandexAds: onBannerAdFailedToLoad. Error: " + error.code)
 9        emitSignal("banner_failed_to_load", id, error.code)
10    }
11
12    override fun onAdClicked() {
13        Log.w(tag, "YandexAds: onBannerAdClicked")
14        emitSignal("banner_ad_clicked", id)
15    }
16
17    override fun onLeftApplication() {
18        Log.w(tag, "YandexAds: onBannerLeftApplication")
19        emitSignal("banner_left_application", id)
20    }
21
22    override fun onReturnedToApplication() {
23        Log.w(tag, "YandexAds: onBannerReturnedToApplication")
24        emitSignal("banner_returned_to_application", id)
25    }
26
27    override fun onImpression(impression: ImpressionData?) {
28        Log.w(tag, "YandexAds: onBannerAdImpression");
29        emitSignal("banner_on_impression", id, impression?.rawData.orEmpty());
30    }
31})

Осталось определить несколько параметров, сохранить баннер в мапе banners и, наконец, цепляем баннер к лейауту и загружаем его

1banner.setAdUnitId(id);
2banner.setBackgroundColor(Color.TRANSPARENT);
3
4banners[id] = banner
5
6layout.addView(banner, layoutParams);
7banner.loadAd(request());

Больше в метод createBanner ничего добавлять не нужно. Но каким-то образом нужно загрузить и показать баннер из кода Godot игры. Для этого определяем методы с указанием модификатора @UsedByGodot.

Баннер нужно сначала загрузить:

 1@UsedByGodot
 2fun loadBanner(id: String, params: Dictionary) {
 3    godot.getActivity()?.runOnUiThread {
 4        if (!banners.containsKey(id) || banners[id] == null) {
 5            createBanner(id, params)
 6        } else {
 7            banners[id]?.loadAd(request())
 8        }
 9    }
10}

Как раз тут используется метод createBanner, который я определил выше. Если баннер уже создан, то он будет переиспользован.

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

 1@UsedByGodot
 2fun showBanner(id: String) {
 3    godot.getActivity()?.runOnUiThread {
 4        if (banners.containsKey(id) && banners[id] != null) {
 5            banners[id]?.visibility = View.VISIBLE
 6            Log.d(tag, "showBanner: banner ok")
 7        } else {
 8            Log.w(tag, "showBanner: banner not found")
 9        }
10    }
11}

Пытаемся достать баннер из мапы banners и делаем его видимым. Аналогичный метод hideBanner

1@UsedByGodot
2fun hideBanner(id: String) {
3    if (banners.containsKey(id) && banners[id] != null) {
4        banners[id]?.visibility = View.GONE
5        Log.d(tag, "hideBanner: banner ok")
6    } else {
7        Log.w(tag, "hideBanner: banner not found")
8    }
9}

И нужно не забыть метод, который удалить баннер с лайаута и из мапы при необходимости

 1@UsedByGodot
 2fun removeBanner(id: String) {
 3    godot.getActivity()?.runOnUiThread {
 4        if (banners.containsKey(id) && banners[id] != null) {
 5            layout.removeView(banners[id]) // удаление самого баннера
 6            banners.remove(id)
 7            Log.d(tag, "removeBanner: banner ok")
 8        } else {
 9            Log.w(tag, "removeBanner: banner not found")
10        }
11    }
12}

Теперь есть все методы для показа баннера в игре. Покажу код, который используется для показа баннера:

 1var _plugin_name = "GodotYandexAds"
 2
 3@onready var banner_button = $CanvasLayer/VBoxContainer/Banner
 4
 5
 6func _ready():
 7	banner_button.pressed.connect(_on_banner_button_pressed)
 8
 9	if Engine.has_singleton(_plugin_name):
10		var ads = Engine.get_singleton(_plugin_name)
11
12		ads.banner_loaded.connect(_ad_loaded)
13		ads.banner_on_impression.connect(_on_impression)
14
15		ads.loadBanner("demo-banner-yandex", {"size_type": "sticky", "width": 300, "position":0})
16
17
18func _on_banner_button_pressed():
19	if Engine.has_singleton(_plugin_name):
20		var ads = Engine.get_singleton(_plugin_name)
21		ads.showBanner("demo-banner-yandex")
22
23
24func _ad_loaded(id: String):
25	print("_ad_loaded: " + id)
26
27
28func _ad_shown(id: String):
29	print("_ad_shown: " + id)
30
31
32func _on_impression(id: String, data: String):
33	print("_on_impression: " + id)
34	print(data)

В этом коде получаем доступ к плагину через вызов Engine.get_singleton(_plugin_name). Напомню, что плагин называется GodotYandexAds. Для загрузки баннера вызываем метод

1ads.loadBanner("demo-banner-yandex", {"size_type": "sticky", "width": 300, "position":0})

И передаем в словаре все необходимые параметры.

Метод _on_banner_button_pressed вызывается при клике по кнопке в игре.

Интерстишиал

Интерстишел - это межстраничное объявление. Полноэкранный формат рекламы, встраиваемый в контент приложения во время естественных пауз, таких как переход между уровнями игры или окончание выполнения целевого действия.

Начинаем как и с баннером - добавляем новый параметр, в котором будет храниться мапа со всеми интерстишелами

1private var interstitials: MutableMap<String, InterstitialAd> = mutableMapOf()

Вторым шагом добавляем нужные сигналы

1signals.add(SignalInfo("interstitial_loaded", String::class.java))
2signals.add(SignalInfo("interstitial_failed_to_load", String::class.java, Integer::class.java))
3signals.add(SignalInfo("interstitial_failed_to_show", String::class.java, Integer::class.java))
4signals.add(SignalInfo("interstitial_ad_shown", String::class.java))
5signals.add(SignalInfo("interstitial_ad_dismissed", String::class.java))
6signals.add(SignalInfo("interstitial_ad_clicked", String::class.java))
7signals.add(SignalInfo("interstitial_on_impression", String::class.java, String::class.java))

В каждый сигнал передается как минимум один параметр - идентификатор юнита. По этому идентификатору в коде игры всегда сможем понять для какого юнита сработал сигнал

По аналогии с баннерами, описываем метод createInterstitial(id: String). Этот метод проще чем метод для работы с баннерами.

 1private fun createInterstitial(id: String) {
 2    val activity = activity ?: return
 3    
 4    val loader = InterstitialAdLoader(activity)
 5    loader.setAdLoadListener(object : InterstitialAdLoadListener {
 6        override fun onAdLoaded(interstitial: InterstitialAd) {
 7            Log.w(tag, "onInterstitialAdLoaded")
 8
 9            emitSignal("interstitial_loaded", id)
10
11            interstitial.setAdEventListener(object : InterstitialAdEventListener {
12                override fun onAdShown() {
13                    Log.w(tag, "onInterstitialAdShown")
14                    emitSignal("interstitial_ad_shown", id)
15                }
16
17                override fun onAdFailedToShow(error: AdError) {
18                    Log.w(tag, "onInterstitialAdFailedToShow: ${error.description}")
19                    emitSignal("interstitial_failed_to_show", id, error.description)
20                }
21
22                override fun onAdDismissed() {
23                    Log.w(tag, "onInterstitialAdDismissed")
24                    emitSignal("interstitial_ad_dismissed", id)
25                }
26
27                override fun onAdClicked() {
28                    Log.w(tag, "onInterstitialAdClicked")
29                    emitSignal("interstitial_ad_clicked", id)
30                }
31
32                override fun onAdImpression(data: ImpressionData?) {
33                    Log.w(tag, "onInterstitialAdImpression: ${data?.rawData.orEmpty()}")
34                    emitSignal("interstitial_on_impression", id, data?.rawData.orEmpty())
35                }
36            })
37
38            interstitials[id] = interstitial
39        }
40
41        override fun onAdFailedToLoad(error: AdRequestError) {
42            Log.w(tag, "onAdFailedToLoad. error: " + error.code)
43            emitSignal("interstitial_failed_to_load", id, error.description)
44        }
45    })
46    loader.loadAd(AdRequestConfiguration.Builder(id).build())
47}

У стишела(как и у ревардеда) есть важное отличие от логики баннеров. Для загрузки баннеров необходимо создать экземпляр InterstitialAdLoader.

1val loader = InterstitialAdLoader(activity)

Загрузка стишела выполняется вызовом метода loadAd, в который передается AdRequestConfiguration с указанием идентификатора юнита

1loader.loadAd(AdRequestConfiguration.Builder(id).build())

При загрузке стишела есть два колбека:

1public interface InterstitialAdLoadListener {
2    public abstract fun onAdFailedToLoad(error: com.yandex.mobile.ads.common.AdRequestError): kotlin.Unit
3
4    public abstract fun onAdLoaded(interstitialAd: com.yandex.mobile.ads.interstitial.InterstitialAd): kotlin.Unit
5}

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

 1loader.setAdLoadListener(object : InterstitialAdLoadListener {
 2        override fun onAdLoaded(interstitial: InterstitialAd) {
 3            Log.w(tag, "onAdLoaded")
 4
 5            emitSignal("interstitial_loaded", id)
 6
 7            interstitial.setAdEventListener(object : InterstitialAdEventListener {
 8                // остальной код коллбеков
 9            })
10
11            interstitials[id] = interstitial
12        }
13
14        override fun onAdFailedToLoad(error: AdRequestError) {
15            Log.w(tag, "onInterstitialAdFailedToLoad. error: " + error.code)
16            emitSignal("interstitial_failed_to_load", id, error.description)
17        }
18    })

В мапу interstitials сохраняется экземпляр InterstitialAd. Именно у этого класса есть метод show(), который понадобится для показа интерстишела

Теперь определяем метод, который будем вызывать в коде игры на Godot для создания и загрузки стишела

1@UsedByGodot
2fun loadInterstitial(id: String) {
3    godot.getActivity()?.runOnUiThread {
4        createInterstitial(id)
5    }
6}

Осталось написать метод для показа интерстишела в игре:

 1@UsedByGodot
 2fun showInterstitial(id: String) {
 3    val activity = activity ?: return
 4
 5    godot.getActivity()?.runOnUiThread {
 6        if (interstitials.containsKey(id) && interstitials[id] != null) {
 7            interstitials[id]?.show(activity)
 8            Log.d(tag, "showInterstitial: interstitial ok");
 9        } else {
10            Log.w(tag, "showInterstitial: interstitial not found");
11        }
12    }
13}

Можно использовать стишелы в скрипте самой игры. Пример того, как загрузить и показать стишел:

 1var _plugin_name = "GodotYandexAds"
 2
 3@onready var interstitial_button = $CanvasLayer/VBoxContainer/Interstitial
 4
 5
 6func _ready():
 7	interstitial_button.pressed.connect(_on_interstitial_button_pressed)
 8
 9	if Engine.has_singleton(_plugin_name):
10		var ads = Engine.get_singleton(_plugin_name)
11
12		ads.interstitial_loaded.connect(_ad_loaded)
13		ads.interstitial_ad_shown.connect(_ad_shown)
14		ads.interstitial_on_impression.connect(_on_impression)
15
16		ads.loadInterstitial("demo-interstitial-yandex")
17
18
19func _process(delta):
20	pass
21
22
23func _on_interstitial_button_pressed():
24	if Engine.has_singleton(_plugin_name):
25		var ads = Engine.get_singleton(_plugin_name)
26		ads.showInterstitial("demo-interstitial-yandex")
27
28
29func _ad_loaded(id: String):
30	print("_ad_loaded: " + id)
31
32
33func _ad_shown(id: String):
34	print("_ad_shown: " + id)
35
36
37func _on_impression(id: String, data: String):
38	print("_on_impression: " + id)
39	print(data)

Со стишелом закончили, можно переходить к следующему формату

Ревардед

Ревардед(rewarded, реклама с вознаграждением) — популярный полноэкранный формат объявления, за просмотр которого пользователь получает поощрение.

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

1override fun onRewarded(reward: Reward) {
2    Log.w(tag, "onRewarded")
3    val data = Dictionary()
4    data.set("amount", reward.amount)
5    data.set("type", reward.type)
6    emitSignal("rewarded_rewarded", id, data)
7}

В остальном реализация полностью повторяет повторяет реализацию стишела. Итоговый список сигналов, которые можно подключать для ревардед формата:

1signals.add(SignalInfo("rewarded_loaded", String::class.java))
2signals.add(SignalInfo("rewarded_failed_to_load", String::class.java, Integer::class.java))
3signals.add(SignalInfo("rewarded_failed_to_show", String::class.java, Integer::class.java))
4signals.add(SignalInfo("rewarded_ad_shown", String::class.java))
5signals.add(SignalInfo("rewarded_ad_dismissed", String::class.java))
6signals.add(SignalInfo("rewarded_rewarded", String::class.java, Dictionary::class.java))
7signals.add(SignalInfo("rewarded_ad_clicked", String::class.java))
8signals.add(SignalInfo("rewarded_on_impression", String::class.java, String::class.java))

В сигнале rewarded_rewarded передаются данные о вознаграждении в параметре типа Dictionary

Аппопен

Аппопен(appopen, реклама при открытии приложения) — специальный формат рекламы для монетизации экранов загрузки своих приложений. Такие объявления могут быть закрыты в любое время и предназначены для показа, когда пользователи выводят ваше приложение на передний план (foreground), либо при запуске, либо при возврате в него из фонового режима (background)

Для appopen нам не нужно создавать мапу - такой юнит в приложении может быть только один.

Показываться реклама будет только при вызове приложения из бекграунда. Для этого нужно подвязаться на жизненный цикл приложения. В первую очередь, нужно добавить дополнительные зависимости в plugin/build.gradle.kts

1dependencies {
2    // ...
3    implementation("androidx.lifecycle:lifecycle-process:2.8.7")
4}

То же самое нужно указать в файле plugin/export/export_yandex_ads.gd в методе _get_android_dependencies. После добавления зависимости, в методе onMainCreate привязываемся к жизненному циклу приложения:

1override fun onMainCreate(activity: Activity): View {
2    val processLifecycleObserver = DefaultProcessLifecycleObserver(
3        onProcessCameForeground = ::showAppopen
4    )
5
6    ProcessLifecycleOwner.get().lifecycle.addObserver(processLifecycleObserver)
7
8    // остальной код
9}

DefaultProcessLifecycleObserver - класс, описанный в файле DefaultProcessLifecycleObserver.kt

 1package ru.kovardin.godotyandexads
 2
 3import androidx.lifecycle.DefaultLifecycleObserver
 4import androidx.lifecycle.LifecycleOwner
 5
 6class DefaultProcessLifecycleObserver(
 7    private val onProcessCameForeground: () -> Unit
 8) : DefaultLifecycleObserver {
 9
10    override fun onStart(owner: LifecycleOwner) {
11        onProcessCameForeground()
12    }
13}

В этом классе определен всего один метод onStart. Как раз этот метод срабатывать при старте приложения и будет вызываться метод showAppopen.

До того как показывать рекламу, нужно ее загрузить. Для этого реализуем метод fun loadAppopen(id: String), который будет доступен для вызова в коде самой игры

1@UsedByGodot
2fun loadAppopen(id: String) {
3    godot.getActivity()?.runOnUiThread {
4        createAppopen(id)
5    }
6}

Метод createAppopen(id: String) очень похож на аналогичный метод для стишела и ревардеда. Коллбеки один в один как у стишела

 1private fun createAppopen(id: String) {
 2    val activity = activity ?: return
 3
 4    val loader = AppOpenAdLoader(activity)
 5    loader.setAdLoadListener(object : AppOpenAdLoadListener {
 6        override fun onAdLoaded(ad: AppOpenAd) {
 7            Log.w(tag, "onAppopenAdLoaded")
 8
 9            emitSignal("appopen_loaded", id)
10
11            ad.setAdEventListener(object : AppOpenAdEventListener {
12                override fun onAdShown() {
13                    Log.w(tag, "onAppopenAdShown")
14                    emitSignal("appopen_ad_shown", id)
15                }
16
17                override fun onAdFailedToShow(error: AdError) {
18                    Log.w(tag, "onAppopenAdFailedToShow: ${error.description}")
19                    emitSignal("appopen_failed_to_show", id, error.description)
20                }
21
22                override fun onAdDismissed() {
23                    Log.w(tag, "onAppopenAdDismissed")
24                    emitSignal("appopen_ad_dismissed", id)
25                }
26
27                override fun onAdClicked() {
28                    Log.w(tag, "onAppopenAdClicked")
29                    emitSignal("appopen_ad_clicked", id)
30                }
31
32                override fun onAdImpression(data: ImpressionData?) {
33                    Log.w(tag, "onAppopenAdImpression: ${data?.rawData.orEmpty()}")
34                    emitSignal("appopen_on_impression", id, data?.rawData.orEmpty())
35                }
36            })
37
38            appopen = ad
39        }
40
41        override fun onAdFailedToLoad(error: AdRequestError) {
42            Log.w(tag, "onAppopenAdFailedToLoad. error: " + error.code)
43            emitSignal("appopen_failed_to_load", id, error.description)
44        }
45    })
46    loader.loadAd(AdRequestConfiguration.Builder(id).build())
47}

И, как и для стишела. делаем набор сигналов для предачи информации в код игры

1signals.add(SignalInfo("appopen_loaded", String::class.java))
2signals.add(SignalInfo("appopen_failed_to_load", String::class.java, Integer::class.java))
3signals.add(SignalInfo("appopen_failed_to_show", String::class.java, Integer::class.java))
4signals.add(SignalInfo("appopen_ad_shown", String::class.java))
5signals.add(SignalInfo("appopen_ad_dismissed", String::class.java))
6signals.add(SignalInfo("appopen_ad_clicked", String::class.java))
7signals.add(SignalInfo("appopen_on_impression", String::class.java, String::class.java))

Осталось загрузить appopen и наслаждаться бесконечными видами рекламы

 1extends Node2D
 2
 3var _plugin_name = "GodotYandexAds"
 4
 5
 6func _ready():
 7	if Engine.has_singleton(_plugin_name):
 8		var ads = Engine.get_singleton(_plugin_name)
 9
10		ads.appopen_loaded.connect(_on_ad_loaded)
11		ads.appopen_ad_shown.connect(_on_ad_shown)
12		ads.appopen_on_impression.connect(_on_impression)
13
14		ads.loadAppopen("demo-appopenad-yandex")
15
16func _on_ad_loaded(id: String):
17	print("_ad_loaded: " + id)
18
19
20func _on_ad_shown(id: String):
21	print("_ad_shown: " + id)
22
23
24func _on_impression(id: String, data: String):
25	print("_on_impression: " + id)
26	print(data)

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

Заключение

Плагин уже можно использовать в играх и проложениях, но есть простор для развития. Можно реализовать больше доступных форматов. Кроме того, Яндекс реклама предоставляет дополнительный функционал для подбора рекламы. Такое таргетирование неплохо было бы вынести на уровень кода игры

Кроме методов для показа рекламы, в плагине реализованы еще несколько методов для отладки. Например метод для включения логирования:

1@UsedByGodot
2fun enableLogging(value: Boolean) {
3    MobileAds.enableLogging(value)
4}

Код плагина доступен на гитфлик

Ссылки