В этом руководстве рассказывается, как адаптировать игру для разных типов устройств Android.
Прежде чем начать, прочитайте статью Поддержка изменения размера окон на больших экранах. Современные игры для Android должны адаптироваться к экранам любого размера без использования системных уровней совместимости. Так вы сможете контролировать, как отображается игра, и обеспечивать пользователям наилучшие впечатления даже на устройствах расширенной реальности или устройствах, где экран нельзя повернуть.
Для этого выполните следующие действия:
- Отключите системные черные полосы и реализуйте собственные в движке.
- Сделайте область леттербоксинга полезной и тематически подходящей.
- Учитывайте особенности дизайна, необходимые для масштабирования игры от 21:9 до 1:1 и 9:21.
Команды для тестирования
При создании и тестировании игры убедитесь, что она сама отрисовывает каше. Вы также должны принудительно перевести игру в портретную и альбомную ориентацию, поскольку такие устройства, как телевизоры и автомобильные дисплеи, не могут поворачиваться из альбомной или портретной ориентации по умолчанию.
Чтобы проверить, использует ли игровой движок системное кашетирование, выполните команду adb shell cmd window set-letterbox-style. Например, следующая команда делает системные черные полосы ярко-зелеными и добавляет радиус угла в игре, чтобы было очевидно, что игра возвращается в режим совместимости операционной системы:
adb shell "cmd window set-letterbox-style --backgroundType solid_color --backgroundColor '#00FF00' --cornerRadius 30 --isEducationEnabled false"
Чтобы принудительно задать ориентацию для тестирования, используйте adb shell cmd window user-rotation lock. Это поможет вам понять, как игра ведет себя на устройстве, которое не учитывает переопределения ориентации, или что видят пользователи на устройстве XR или ПК, где окна могут появляться в портретной или альбомной ориентации независимо от ориентации устройства.
Сначала переведите устройство в состояние, в котором можно переопределить ориентацию приложения:
adb shell cmd window set-ignore-orientation-request true
adb shell cmd window fixed-to-user-rotation enabled
Теперь проверьте, как игра работает в горизонтальном режиме.
adb shell cmd window user-rotation lock 1
Затем проверьте, как игра работает в вертикальном режиме:
adb shell cmd window user-rotation lock 0
Чтобы восстановить конфигурацию OEM, перезагрузите устройство или выполните следующие команды:
adb shell cmd window reset-letterbox-style
adb shell cmd window fixed-to-user-rotation default
adb shell cmd window user-rotation free
adb shell cmd window set-ignore-orientation-request reset
Зачем отключать системный кашетированный формат?
Система всегда управляет системными черными полосами. Определяя размер экрана и управляя областью отображения, вы можете устранить проблемы, например пропущенные входные данные, которые возникают, когда входные данные покидают область игры. Вы также можете расширить элементы, не относящиеся к геймплею, до краев экрана, даже если контент игры остается фиксированным посередине. Режимы совместимости позволяют запустить игру, но только вы можете сделать ее по-настоящему удобной для пользователей.
Пример реализации леттербокса
Большинство игровых движков позволяют управлять тем, где на экране отображается камера. Чтобы реализовать собственные черные полосы, измерьте соотношение сторон области рендеринга и настройте прямоугольник отсечения для неиспользуемой области, а не позволяйте системе добавлять черные полосы в игру. В следующем примере одна камера ограничена рендерингом в ограниченном соотношении сторон, в то время как камеры пользовательского интерфейса или проходы могут расширяться до полного логического размера экрана:
Unity
[SerializeField] private float minAspectRatio = 4f / 3f; [SerializeField] private float maxAspectRatio = 21f / 9f; private Camera _camera; private void Awake() { _camera = GetComponent<Camera>(); } public void MaintainAspectRatio() { int viewWidth = Screen.width; int viewHeight = Screen.height; // 1. Calculate screen aspect ratio float scaleScreen = viewWidth / (float)viewHeight; Rect rect = _camera.pixelRect; // 2. Scale to the fullest of the lower between width or height if (scaleScreen > maxAspectRatio) { // use height, lock width to max at aspect scale rect.width = viewHeight * maxAspectRatio; rect.height = viewHeight; } else if (scaleScreen < minAspectRatio) { // use width, lock height to min at aspect scale rect.width = viewWidth; rect.height = viewWidth / minAspectRatio; } else { // within allowed aspect ratio range, use full screen rect.width = viewWidth; rect.height = viewHeight; } rect.x = Mathf.Floor((viewWidth - rect.width) * 0.5f); rect.y = Mathf.Floor((viewHeight - rect.height) * 0.5f); Debug.Log("camera view width = " + rect.width + " height = " + rect.height + " x = " + rect.x + " y = " + rect.y); // 3. Commit the new size to the camera so that it takes effect _camera.pixelRect = rect; }
C++ (Vulkan)
/** * @brief Calculates a bounded-aspect-ratio gameplay viewport and scissor * rectangle and records them directly into a Vulkan command buffer. * * @note Requires `VK_DYNAMIC_STATE_VIEWPORT` and `VK_DYNAMIC_STATE_SCISSOR` to * be enabled on the bound graphics `VkPipeline`. * * @param command_buffer Active `VkCommandBuffer` in the recording state. * @param window_width Full physical window or swapchain width in pixels. * @param window_height Full physical window or swapchain height in pixels. * @param min_aspect_ratio Minimum allowed aspect ratio (`width / height`). * @param max_aspect_ratio Maximum allowed aspect ratio (`width / height`). */ void MaintainAspectRatio(VkCommandBuffer command_buffer, uint32_t window_width, uint32_t window_height, float min_aspect_ratio, float max_aspect_ratio) { if (command_buffer == VK_NULL_HANDLE || window_width == 0 || window_height == 0 || min_aspect_ratio <= 0.0f || max_aspect_ratio < min_aspect_ratio) { return; } // 1. Calculate screen aspect ratio. const float view_width = static_cast<float>(window_width); const float view_height = static_cast<float>(window_height); const float scale_screen = view_width / view_height; // 2. Scale to the fullest of the lower between width or height. float rect_width = view_width; float rect_height = view_height; if (scale_screen > max_aspect_ratio) { // Use height, lock width to max at aspect scale (pillarbox). rect_width = std::floor(view_height * max_aspect_ratio); rect_height = view_height; } else if (scale_screen < min_aspect_ratio) { // Use width, lock height to min at aspect scale (letterbox). rect_width = view_width; rect_height = std::floor(view_width / min_aspect_ratio); } else { // Within allowed aspect ratio range, use full screen. rect_width = view_width; rect_height = view_height; } const float rect_x = std::floor((view_width - rect_width) * 0.5f); const float rect_y = std::floor((view_height - rect_height) * 0.5f); // 3. Commit the new viewport and scissor to the command buffer so that // subsequent gameplay draw calls are transformed and clipped to this region. const VkViewport viewport = { .x = rect_x, .y = rect_y, .width = rect_width, .height = rect_height, .minDepth = 0.0f, .maxDepth = 1.0f, }; const VkRect2D scissor = { .offset = { .x = static_cast<int32_t>(rect_x), .y = static_cast<int32_t>(rect_y), }, .extent = { .width = static_cast<uint32_t>(rect_width), .height = static_cast<uint32_t>(rect_height), }, }; vkCmdSetViewport(command_buffer, 0, 1, &viewport); vkCmdSetScissor(command_buffer, 0, 1, &scissor); }
Пошаговые инструкции для Unity вы найдете в практической работе Оптимизация игр Unity для больших экранов и складных устройств.
Поддержка больших и маленьких экранов
На небольших экранах, например на телефонах и внешних дисплеях складных устройств, лучше всего использовать игру с заблокированной портретной или альбомной ориентацией. На устройствах с большими экранами, таких как планшеты, Chromebook, автомобильные дисплеи или внутренние экраны складных устройств, поворачивать устройство нежелательно или даже невозможно, а большой физический дисплей позволяет играть в игру с соотношением сторон вне оси.
Чтобы обеспечить игрокам максимальное удобство, определяйте размер экрана во время выполнения и при необходимости ограничивайте поворот, предоставляя игрокам больше гибкости по мере увеличения размера экрана. Идеальным пороговым значением для Android считается sw600dp – минимальная ширина 600 dp (пикселей, не зависящих от плотности экрана).
Это соответствует WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND. Используйте computeMaximumWindowMetrics, чтобы проверить доступный размер экрана в конфигурациях с несколькими дисплеями.
Чтобы реализовать это в игре, укажите в манифесте поддержку всех ориентаций, а затем в методах onCreate() и onConfigurationChanged() оцените наименьшую ширину и задайте соответствующую блокировку ориентации:
Удалите из манифеста статические блокировки ориентации и объявите обработку изменений конфигурации:
<activity android:name=".MainActivity" android:resizeableActivity="true" android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|density|keyboard|keyboardHidden" />Добавьте необходимые импорты:
import android.content.pm.ActivityInfo import android.content.res.Configuration import android.os.Bundle import androidx.activity.enableEdgeToEdge import androidx.window.core.layout.WindowSizeClass import androidx.window.layout.WindowMetricsCalculator import kotlin.math.minОбработка
onCreateиonConfigurationChanged:override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() super.onCreate(savedInstanceState) applyAdaptiveOrientationPolicy() // ... } override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) applyAdaptiveOrientationPolicy() // ... }Обновите запрашиваемую ориентацию на основе наименьшей ширины экрана, заблокировав компактные экраны в предпочтительной ориентации дизайна вашей игры:
private fun applyAdaptiveOrientationPolicy() { // compute the smallest screen bound in dp val maxMetrics = WindowMetricsCalculator.getOrCreate().computeMaximumWindowMetrics(this) val minDimensionPx = min(maxMetrics.bounds.width(), maxMetrics.bounds.height()) val displaySmallestWidthDp = minDimensionPx / maxMetrics.density val targetOrientation = if (displaySmallestWidthDp >= WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND) { // Large display (>= 600dp): allow free rotation in any orientation ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED } else { // Compact display (< 600dp): lock to your game's preferred orientation // (use SCREEN_ORIENTATION_SENSOR_LANDSCAPE for landscape-first games) ActivityInfo.SCREEN_ORIENTATION_PORTRAIT } // Guard against redundant setter calls to avoid configuration change loops if (requestedOrientation != targetOrientation) { requestedOrientation = targetOrientation } }
Учитывайте распространенные проблемы
Поддержка разных соотношений сторон и ориентаций экрана позволяет сделать игру максимально удобной для пользователей. В отличие от телефонов, большие дисплеи часто фиксируются в альбомной или портретной ориентации, а в некоторых случаях экран почти квадратный. На таких устройствах пользователи часто играют в многозадачном режиме. Максимально эффективно используя пространство экрана, вы сможете привлекать больше игроков, которые будут играть чаще и дольше.
В следующих разделах приведены решения, которые помогут сделать вашу игру премиальной на всех типах устройств, а не просто мобильной игрой, встроенной в экран другого устройства.
Камеры с поддержкой соотношения сторон
В 3D-играх на слишком высоком экране у игроков может возникнуть ощущение, что они смотрят на игру через узкую щель, а на слишком широком экране они могут пропустить важные взаимодействия. Как и в 2D-играх, вы можете сделать так, чтобы безопасная зона была видна всегда. В следующем примере кода камера переключается между горизонтальной и вертикальной зоной обзора в зависимости от того, как активное соотношение сторон соотносится с эталонным (1920 x 1080). Таким образом, дизайнер может создать игру для дисплея с разрешением 1080p и быть уверенным, что все, что разработано в рамках этого эталонного кадра, останется видимым:
Unity
[SerializeField] private Camera _camera; [SerializeField] private float _referenceWidth = 1920f; [SerializeField] private float _referenceHeight = 1080f; [SerializeField] private float _referenceVerticalFov = 60f; public float ReferenceAspectRatio => _referenceWidth / Mathf.Max(0.0001f, _referenceHeight); private void Awake() { if (_camera == null) { _camera = GetComponent<Camera>(); } } // Built-in Render Pipeline callback. In URP, subscribe to // RenderPipelineManager.beginCameraRendering or call UpdateFov() on resize. private void OnPreCull() { UpdateFov(); } public void UpdateFov() { if (_camera == null || _camera.orthographic) { return; } float referenceAspect = ReferenceAspectRatio; float currentAspect = _camera.aspect; if (currentAspect <= 0.0001f) { return; } float targetVerticalFov; if (currentAspect >= referenceAspect) { // Viewport is wider than (or equal to) the reference safe area targetVerticalFov = _referenceVerticalFov; } else { // Viewport is skinnier than the reference safe area float referenceHorizontalFov = Camera.VerticalToHorizontalFieldOfView(_referenceVerticalFov, referenceAspect); targetVerticalFov = Camera.HorizontalToVerticalFieldOfView(referenceHorizontalFov, currentAspect); targetVerticalFov = Mathf.Clamp(targetVerticalFov, 1f, 179f); } if (!Mathf.Approximately(_camera.fieldOfView, targetVerticalFov)) { _camera.fieldOfView = targetVerticalFov; } }
C++ (Vulkan)
/** * @brief Calculates an aspect-aware Vulkan perspective projection matrix using * GLM that keeps a reference safe area (e.g., 1920x1080) visible across * aspect ratios. * * @note Uses `glm::perspectiveZO` for Vulkan's `[0, 1]` depth range and * inverts `projection[1][1]` for Vulkan's top-left (`+Y` down) clip space. * * @param viewport_width Active gameplay viewport width in pixels. * @param viewport_height Active gameplay viewport height in pixels. * @param reference_width Authored safe-area reference width in pixels. * @param reference_height Authored safe-area reference height in pixels. * @param reference_vertical_fov Authored vertical FOV in degrees (e.g., 60.0f). * @param near_plane Near clipping plane distance (`zNear > 0.0f`). * @param far_plane Far clipping plane distance (`zFar > zNear`). * @return Aspect-aware `glm::mat4` perspective projection matrix for Vulkan. */ glm::mat4 CalculateAspectAwareProjectionMatrix( float viewport_width, float viewport_height, float reference_width = 1920.0f, float reference_height = 1080.0f, float reference_vertical_fov = 60.0f, float near_plane = 0.1f, float far_plane = 100.0f) { if (viewport_width <= 0.0001f || viewport_height <= 0.0001f) { return glm::mat4(1.0f); } // 1. Compare the active viewport aspect ratio against the reference safe // area. const float reference_aspect = reference_width / std::max(0.0001f, reference_height); const float current_aspect = viewport_width / viewport_height; float target_vertical_fov_rad = glm::radians(reference_vertical_fov); if (current_aspect >= reference_aspect) { // Viewport is wider than (or equal to) the reference safe area (Hor+). target_vertical_fov_rad = glm::radians(reference_vertical_fov); } else { // Viewport is skinnier than the reference safe area (Vert+): // Convert reference vertical FOV -> reference horizontal FOV -> new // vertical FOV at current_aspect so the safe area is never cropped. const float tan_half_horiz = std::tan(target_vertical_fov_rad * 0.5f) * reference_aspect; target_vertical_fov_rad = 2.0f * std::atan(tan_half_horiz / current_aspect); target_vertical_fov_rad = std::clamp( target_vertical_fov_rad, glm::radians(1.0f), glm::radians(179.0f)); } // 2. Build the Vulkan [0, 1] depth perspective matrix and flip +Y clip space. glm::mat4 projection = glm::perspectiveZO( target_vertical_fov_rad, current_aspect, near_plane, far_plane); projection[1][1] *= -1.0f; return projection; }
Полностью интерактивные почтовые ящики
В некоторых играх фиксированная игровая область обусловлена жанром, например в вертикальных шутерах или играх в ретростиле с прокруткой экрана.
В таких играх масштабируйте игровую область так, чтобы одна из осей всегда была максимально заполнена. Какая ось будет увеличена, зависит от соотношения сторон игры и границ экрана.
Закрепите HUD и элементы интерфейса у безопасного края экрана. Избегайте вырезов, но размещайте интерактивные элементы, такие как виртуальные элементы управления, индикаторы здоровья и мини-карты, как можно дальше от игровой зоны. В этом случае интерактивная часть игры будет занимать весь экран, а пользователям планшетов и складных устройств будет удобно играть, не растягивая пальцы.
Наконец, сделайте так, чтобы черные полосы соответствовали теме или сюжету. Например, можно использовать рамки, которые соответствуют палитре или набору плиток на экране, виртуальный стол или экран для настольных или ретроигр, а также расширенные миникарты и декоративные визуальные элементы.
В примере ниже игра отображается в полном размере для большинства соотношений сторон. Если соотношение сторон меньше 4:3, то отрисовывается тематический скайбокс, а элементы интерфейса, например элементы управления, размещаются по краям экрана.
|
|
Туман войны
Многие разработчики игр обеспокоены тем, что игроки могут получить несправедливое преимущество за счет экстремальных вертикальных или горизонтальных соотношений сторон. Например, в соревновательной игре, такой как многопользовательская онлайн-боевая арена (MOBA), игрок может использовать очень вертикальный экран, чтобы получить глубокий обзор линии противника.
В стратегиях и симуляторах с видом сверху можно использовать туман войны.
- Не отрисовывайте геометрию, пока игрок не сможет ее увидеть.
- Сделайте так, чтобы геометрия, которую персонаж игрока не видит, отображалась затемненной и без динамических элементов.
- Отрисовывайте только противников, предметы для сбора и другие динамические объекты, которые персонаж игрока может видеть в игровом мире.
Если вы объедините этот метод с полностью интерактивными черными полосами и отрисуете интерфейс до края экрана, то игроки получат качественный и продуманный игровой процесс. При этом вам не придется менять структуру уровней или баланс команд.
Если расширить эту игру на большой экран без тумана войны, то откроются части карты, которые игрок не должен видеть:
|
|
Отображая туман войны и скрывая динамические элементы, такие как враги и предметы, вы поддерживаете справедливость в игре, используя при этом весь доступный кадр:
|
|
Затемнение геометрии
Во многих играх, особенно в пошаговых стратегиях, используется техника, похожая на туман войны, описанный в предыдущем разделе. В них не отрисовывается геометрия, которую игрок ещё не обнаружил или с которой не может взаимодействовать в определенной сцене.
Если расширенная зона обзора показывает части сцены или ролика, которые игрок не должен видеть, можно замаскировать их. Это позволит добиться того же эффекта, что и каше, но без ощущения фиксированной статической границы.
В примере ниже расширение соотношения сторон игры позволяет увидеть орду зомби за стенами замка.
|
|
Вместо этого вы можете не отрисовывать геометрию уровня за пределами текущей игровой зоны:
|
|
Диегетические границы
В иммерсивных 3D-играх, где расширенная зона обзора может дать преимущество в многопользовательских играх от первого или третьего лица или показать неавторизованные области игр с фиксированным набором, подумайте о том, чтобы встроить в игру диегетические элементы, чтобы ограничить зону обзора, а не полагаться на прямоугольный почтовый ящик. Примеры:
- Кабина транспортного средства, например автомобиля или самолета.
- Внутренняя часть шлема
- Виньетка, обозначающая край поля зрения игрока
Элементы интерфейса и HUD должны доходить до края экрана или быть встроены в диегетические границы. Идеально, если они будут динамическими. Такие диегетические элементы также могут помочь игрокам, которые испытывают укачивание, особенно на устройствах XR, обеспечивая доступность в дополнение к снижению сложности новых соотношений сторон.
Не показывайте игру на поверхности внутри неинтерактивной статической сцены. Хотя этот подход лучше, чем рендеринг черных полос для почтовых ящиков и столбчатых ящиков, игроки быстро замечают статические границы и могут быть разочарованы, если их любимые игры отображаются на участке экрана размером с телефон в нетелефонной форме. факторы.
|
В этом примере игра, разработанная для стандартного экрана телефона, отображается внутри симулированного телевизора со скевоморфным геймпадом, чтобы соответствовать более квадратному соотношению сторон, обычному для внутренних дисплеев складных устройств:
|
В некоторых 3D-играх может потребоваться ограничить зону обзора, чтобы обеспечить равные условия для всех игроков или по соображениям дизайна. Для ограничения зоны обзора лучше использовать кабину, шлем или другие диегетические метафоры, а не черные полосы.
|
Дополнительные ресурсы
- Как оптимизировать игру Unity для всех типов устройств
- Как обеспечить совместимость игры со всеми типами устройств
- Поддержка естественного ввода на всех типах устройств
- Как управлять ориентацией устройства с помощью предварительного поворота Vulkan