Z tego przewodnika dowiesz się, jak dostosować grę do różnych formatów Androida.
Zanim zaczniesz, przeczytaj artykuł Obsługa zmiany rozmiaru na dużym ekranie. Nowoczesne gry na Androida powinny dostosowywać się do wszystkich rozmiarów ekranu bez konieczności korzystania z warstw zgodności systemu. Dzięki temu zawsze masz kontrolę nad tym, jak wyświetla się Twoja gra, i zapewniasz graczom najwyższą jakość rozgrywki, nawet na urządzeniach XR lub urządzeniach, na których nie można obrócić ekranu.
Aby to zrobić, wykonaj te czynności:
- Wyłącz systemowy letterboxing i wdroż własny letterboxing w silniku.
- Zadbaj o to, aby obszar letterbox był użyteczny i odpowiedni tematycznie.
- Uwzględnij kwestie projektowe, które pozwolą skalować grę od 21:9 do 1:1 i 9:21.
Polecenia do testowania
Podczas tworzenia i testowania gry sprawdź, czy sama renderuje pasy. Należy też wymusić w grze orientację pionową i poziomą, ponieważ urządzenia takie jak telewizory i wyświetlacze samochodowe nie mogą zmienić domyślnej orientacji poziomej lub pionowej.
Aby sprawdzić, czy silnik gry używa systemowego formatu letterbox, użyj polecenia adb shell cmd window set-letterbox-style. Na przykład to polecenie sprawia, że systemowe pasy są jasnozielone i dodaje promień narożnika do gry, dzięki czemu łatwo zauważyć, czy gra przechodzi w tryb zgodności systemu operacyjnego:
adb shell "cmd window set-letterbox-style --backgroundType solid_color --backgroundColor '#00FF00' --cornerRadius 30 --isEducationEnabled false"
Aby wymusić orientację na potrzeby testów, użyj adb shell cmd window user-rotation lock. Dzięki temu możesz zasymulować, jak gra zachowuje się na urządzeniu, które nie uwzględnia zastąpień aplikacji dotyczących orientacji, lub dowiedzieć się, co widzą użytkownicy na urządzeniu XR lub PC, na którym okna mogą wyświetlać się w orientacji pionowej lub poziomej niezależnie od orientacji urządzenia.
Najpierw przełącz urządzenie w stan, w którym możesz zastąpić orientację aplikacji:
adb shell cmd window set-ignore-orientation-request true
adb shell cmd window fixed-to-user-rotation enabled
Następnie przetestuj zmianę orientacji gry na poziomą:
adb shell cmd window user-rotation lock 1
Następnie przetestuj zmianę orientacji gry na pionową:
adb shell cmd window user-rotation lock 0
Zresetuj urządzenie do konfiguracji OEM, uruchamiając je ponownie lub wykonując te polecenia:
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
Dlaczego warto wyłączyć systemowy letterboxing?
System zawsze kontroluje systemowy letterboxing. Wykrywając rozmiar ekranu i samodzielnie zarządzając obszarem wyświetlania, możesz ograniczyć problemy, takie jak pomijanie danych wejściowych, które występują, gdy dane wejściowe opuszczają obszar gry. Możesz też rozszerzyć elementy niezwiązane z rozgrywką do krawędzi ekranu, nawet jeśli zawartość gry pozostaje na środku. Tryby zgodności systemu mogą sprawić, że gra będzie dostępna, ale to od Ciebie jako dewelopera zależy, czy będzie ona atrakcyjna dla graczy.
Przykładowa implementacja efektu letterbox
Większość silników gier umożliwia kontrolowanie miejsca renderowania kamery na ekranie. Aby wdrożyć własny letterboxing, zmierz format obrazu renderowanego obszaru i dostosuj prostokąt przycinania do nieużywanego obszaru, zamiast pozwalać systemowi na letterboxing gry. Poniższy przykład ogranicza renderowanie jednej kamery do ograniczonego formatu obrazu, jednocześnie umożliwiając kamerom interfejsu lub przejściom rozszerzenie do pełnego logicznego rozmiaru ekranu:
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); }
Szczegółowe instrukcje dotyczące Unity znajdziesz w ćwiczeniu z programowania Optymalizowanie gier Unity pod kątem dużych i składanych ekranów.
Obsługa dużych i małych ekranów
Na mniejszych ekranach, np. telefonów i zewnętrznych wyświetlaczy urządzeń składanych, najlepsze wrażenia może zapewnić gra zablokowana w orientacji pionowej lub poziomej. W przypadku większych ekranów – takich jak tablety, Chromebooki, wyświetlacze samochodowe czy wewnętrzne wyświetlacze urządzeń składanych – obracanie urządzenia jest mniej pożądane lub wręcz niemożliwe, a większy fizyczny wyświetlacz pozwala na rozgrywkę w formatach obrazu poza osią.
Aby zapewnić graczom jak najlepsze wrażenia, wykrywaj rozmiar ekranu w czasie działania i w razie potrzeby ograniczaj obracanie, dając graczom większą elastyczność w miarę zwiększania się rozmiaru ekranu. Android uważa, że idealna wartość graniczna to sw600dp, czyli najmniejsza szerokość wynosząca 600 pikseli niezależnych od gęstości (600 dp).
Odpowiada to WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND. Użyj computeMaximumWindowMetrics, aby sprawdzić dostępny rozmiar ekranu w konfiguracjach z wieloma wyświetlaczami.
Aby zapewnić obsługę tej funkcji w grze, uwzględnij wszystkie orientacje w pliku manifestu, a następnie w metodach onCreate() i onConfigurationChanged() oszacuj najmniejszą szerokość i odpowiednio ustaw blokadę orientacji:
Usuń statyczne blokady orientacji w pliku manifestu i zadeklaruj obsługę zmian konfiguracji:
<activity android:name=".MainActivity" android:resizeableActivity="true" android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|density|keyboard|keyboardHidden" />Dodaj wymagane instrukcje importu:
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.minObsługuj zarówno
onCreate, jak ionConfigurationChanged:override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() super.onCreate(savedInstanceState) applyAdaptiveOrientationPolicy() // ... } override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) applyAdaptiveOrientationPolicy() // ... }Zaktualizuj żądaną orientację na podstawie najmniejszej szerokości ekranu, blokując ekrany kompaktowe w preferowanej orientacji gry:
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 } }
Projektowanie z uwzględnieniem typowych problemów
Obsługa różnych formatów obrazu i orientacji zapewnia graczom najlepsze wrażenia. W przeciwieństwie do telefonów większe wyświetlacze są często ustawione w orientacji poziomej lub pionowej, a w niektórych przypadkach ekran jest prawie kwadratowy. Na tych większych urządzeniach gracze często wykonują wiele zadań jednocześnie podczas grania. Maksymalnie wykorzystując przestrzeń na ekranie, możesz angażować więcej graczy, częściej i na dłuższe sesje.
W poniższych sekcjach znajdziesz rozwiązania, dzięki którym Twoja gra będzie wyglądać jak produkt premium na różnych formatach, a nie jak gra na telefon osadzona na ekranie innego urządzenia.
Kamery uwzględniające współczynnik proporcji
W grach 3D, jeśli ekran jest zbyt wysoki, gracze mogą mieć wrażenie, że patrzą na grę przez wąską szczelinę, a jeśli jest zbyt szeroki, mogą przegapić zaprojektowane interakcje. Podobnie jak w grach 2D, możesz zachować „bezpieczny obszar”, który będzie zawsze widoczny. Poniższy kod przełącza kamerę między poziomym a pionowym polem widzenia na podstawie porównania aktywnego formatu obrazu z referencyjnym formatem obrazu 1920 x 1080. Dzięki temu projektant może stworzyć grę na wyświetlacz 1080p i mieć pewność, że wszystko, co zostało zaprojektowane w ramach tej klatki referencyjnej, pozostanie widoczne:
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; }
W pełni interaktywne skrzynki na listy
Niektóre gry mają stały obszar rozgrywki, co często wynika z założeń projektowych związanych z gatunkiem, np. stałe pole w strzelance z widokiem z góry lub przewijanie ekranu w stylu retro.
W przypadku tych gier przeskaluj obszar rozgrywki, aby go dopasować, tak aby jedna oś gry była zawsze zmaksymalizowana i wypełniała obszar rozgrywki. To, która oś jest zmaksymalizowana, może się zmieniać w zależności od względnych formatów obrazu gry i granic wyświetlania.
Zakotwicz elementy HUD i elementy interfejsu aż do bezpiecznej krawędzi ekranu: unikaj wycięć, ale umieszczaj elementy interaktywne, takie jak wirtualne elementy sterujące, paski zdrowia i minimapy, jak najdalej od obszaru rozgrywki. Dzięki temu interaktywna część gry wykorzystuje cały ekran, a gracze na urządzeniach o formatach takich jak tablety i urządzenia składane mogą wygodnie grać bez nadmiernego rozciągania palców.
Na koniec zadbaj o to, aby obszar letterbox był odpowiednio dopasowany do tematu lub świata gry. Rozważ obramowania pasujące do palety kolorów lub zestawu kafelków na ekranie, wirtualny stół lub ekran w przypadku gier planszowych lub retro, rozszerzone minimapy i elementy dekoracyjne.
W przykładzie poniżej gra jest renderowana w pełnym rozmiarze w przypadku większości formatów obrazu. Gdy format obrazu jest węższy niż 4:3, renderuje tematycznie kolorowy skybox, jednocześnie umieszczając elementy interfejsu, takie jak elementy sterujące, na krawędzi ekranu:
|
|
mgła wojny,
Wielu deweloperów gier obawia się, że gracze mogą uzyskać nieuczciwą przewagę dzięki ekstremalnym pionowym lub poziomym formatom obrazu. Na przykład gracz w grze opartej na rywalizacji, takiej jak multiplayer online battle arena (MOBA), może używać bardzo pionowego ekranu, aby widzieć, co dzieje się na linii przeciwnika.
W przypadku gier strategicznych i symulacyjnych z widokiem z góry rozważ renderowanie mgły wojny:
- Nie renderuj geometrii, dopóki gracz nie będzie mógł jej zobaczyć.
- Renderuj geometrię, której postać gracza nie może aktywnie zobaczyć, jako przyciemnioną, bez żadnych elementów dynamicznych.
- Renderuj tylko przeciwników, przedmioty kolekcjonerskie i inne obiekty dynamiczne, które postać gracza może aktywnie zobaczyć w świecie gry.
Jeśli połączysz tę technikę z w pełni interaktywnymi pasami i wyrenderujesz interfejs do krawędzi ekranu, gracz będzie miał wrażenie, że gra jest dopracowana i przemyślana, a Ty nie musisz zmieniać struktury poziomów ani balansu drużyn.
Rozszerzenie tej gry na większy ekran bez mgły wojny ujawnia części mapy, których gracz nie powinien widzieć:
|
|
Renderując mgłę wojny i ukrywając elementy dynamiczne, takie jak wrogowie i przedmioty, zachowujesz uczciwość w grze, korzystając jednocześnie z pełnej klatki dostępnej w grze:
|
|
Zanikanie geometrii
Wiele gier, zwłaszcza turowych gier strategicznych, używa techniki podobnej do mgły wojny opisanej w poprzedniej sekcji i nie renderuje geometrii, której gracz nie odkrył lub z którą nie może wejść w interakcję podczas określonej sceny.
Jeśli rozszerzone pole widzenia ujawniłoby części planu lub przerywnika filmowego, których gracz nie powinien widzieć, zamaskowanie niektórych części sceny w przemyślany sposób pozwoli uzyskać taki sam efekt jak format letterbox, ale bez wrażenia stałej, statycznej ramki.
W tym przykładzie rozszerzenie gry do większego formatu obrazu ujawnia hordę zombie poza murami zamku:
|
|
Zamiast tego możesz pominąć renderowanie geometrii poziomu poza granicami bieżącego obszaru gry:
|
|
Granice diegetyczne
W grach 3D, w których rozszerzone pole widzenia może zapewnić przewagę nad innymi graczami w grach wieloosobowych z perspektywy pierwszej lub trzeciej osoby albo ujawnić nieautoryzowane obszary gier o stałym zestawie, rozważ wbudowanie w grę elementów diegetycznych, aby ograniczyć pole widzenia, zamiast polegać na prostokątnym formacie letterbox. Przykłady:
- wnętrze pojazdu, np. samochodu lub samolotu;
- Wnętrze kasku
- winieta wskazująca krawędź pola widzenia gracza;
Elementy interfejsu i HUD powinny nadal rozciągać się do krawędzi ekranu lub być włączone w granice diegetyczne. Najlepiej, aby były dynamiczne. Te elementy diegetyczne mogą też pomóc graczom, którzy cierpią na chorobę lokomocyjną – zwłaszcza w przypadku formatów XR – ponieważ ułatwiają dostęp do gry i pomagają zmniejszyć złożoność nowych formatów obrazu.
Unikaj renderowania gry na powierzchni w nieinteraktywnej scenie statycznej. Chociaż to podejście jest lepsze niż renderowanie czarnych pasów w przypadku formatów letterbox i pillarbox, gracze szybko zauważają statyczne obramowania i mogą być sfrustrowani, gdy ich ulubione gry są renderowane na ekranie o rozmiarze telefonu na urządzeniach innych niż telefon.
|
W tym przykładzie gra zaprojektowana na standardowy ekran telefonu jest renderowana w symulowanym telewizorze ze skeumorficznym padem do gier, aby dostosować ją do bardziej kwadratowego formatu obrazu, który jest typowy dla wewnętrznych wyświetlaczy urządzeń składanych:
|
W niektórych grach 3D może być konieczne ograniczenie pola widzenia ze względu na uczciwą rywalizację lub kwestie projektowe. Do ograniczenia pola widzenia lepiej jest użyć kokpitu, kasku lub innej metafory diegetycznej niż czarnych pasów:
|
Dodatkowe materiały
- Zadbaj o to, aby Twoja gra Unity działała świetnie na wszystkich formatach
- Zapewnianie zgodności gry ze wszystkimi formatami urządzeń
- Włączanie naturalnego wprowadzania danych na wszystkich formatach urządzeń
- Obsługa orientacji urządzenia za pomocą wstępnego obrotu Vulkan