目录

Gaming

Overview

This topic uses gaming apps as a typical example to detail how the feature of one-time development for multi-device deployment is applied in actual development. Gaming apps focus on immersive user experience and feature strong interactivity and user engagement. On large-screen devices, these apps can provide a broader field of view and a more efficient interaction experience.

Gaming apps should adapt to devices with different screen sizes, including smartphones, foldable phones, and tablets. The following sections provide reference examples from the perspectives of UX design and page development, helping you develop gaming apps.

UX Design

Breakpoint Design

Game view is an important factor affecting user experience. An appropriate game view on different devices and screen sizes can enhance user experience. The following lists the game view adaptation strategies for different devices and screen sizes:

  1. Smartphones (sm)

    Basic field of view.

  2. Bi-fold smartphones in unfolded state (md)

    Bi-fold smartphones offer unique opportunities. In unfolded state, you can use the larger screen to expand the game view, thereby enhancing immersion and information display. For example, game records can be displayed more prominently.

    Recommended strategies:

    • Based on the basic field of view of smartphones, slightly reduce the horizontal field of view and expand the vertical field of view to ensure a balance between the horizontal and vertical views. After the adjustment, the overall view is similar to that of smartphones.
    • Maintain consistency for the horizontal field of view between bi-fold smartphones in unfolded state and smartphones. Expand the vertical field of view or use the blacked-out areas to display auxiliary information, optimizing screen utilization and improving user experience.
  3. Tablets (lg)
    The layout strategies for tablets are similar to those for bi-fold smartphones in unfolded state.
    • Based on the basic field of view of smartphones, slightly reduce the horizontal field of view and expand the vertical field of view to ensure a balance between the horizontal and vertical views. After the adjustment, the overall view is similar to that of smartphones.
    • Maintain consistency for the horizontal field of view between tablets and smartphones. Expand the vertical field of view or use the blacked-out areas to display auxiliary information, making full use of the screen.
  4. Special considerations

    For casual games, especially those in vertical screen mode, you can properly enlarge the game vision on bi-fold smartphones in unfolded state and tablets to adapt to the larger screen.

With the preceding strategies, you can optimize the game view based on the characteristics of different devices, thereby improving the playability and user experience of the game. For details about the design suggestions, see the following.

Multi-Device Responsive Design for Gaming Applications

Click here to view the responsive design guideline.

Page Development

This section describes how to use layout capabilities to adapt a set of pages in the gaming app across different devices.

[h2]Page Partitioning

The home page of a gaming app mainly displays the full-screen game area, which is the game rendering area. Generally, the XComponent is used to render the graphics drawn by OpenGL.

[h2]Multi-Device Adaptation

On HarmonyOS, the XComponent is often used to display camera preview streams and game scenes. It can be used together with NativeWindow to create an OpenGL development environment and render the graphics drawn by OpenGL for display. To ensure the adaptation effect of the game on different devices and screen sizes, you can use either of the following approaches:

  1. Obtaining breakpoint information for different page adaptation: In HarmonyOS, you can obtain breakpoint information by listening for changes in the device screen size. This information can help you adjust the page layout and element size based on the screen size.
  2. Listening for surface size changes and adjusting the drawing area: When OpenGL is used for drawing, the surface size directly affects the drawing area. Therefore, this approach is the key to implementing multi-device adaptation.

Obtaining Breakpoint Information

Pass the breakpoint value obtained from ArkTS to the native side. The native side can then implement different page effects at different breakpoints based on game design requirements. This example only shows the process of passing breakpoint values. You can implement the adaptation as required on the native side.

  1. Use getWindowWidthBreakpoint() and getWindowHeightBreakpoint() provided by UIContext to directly obtain the breakpoint values of landscape and portrait modes in windowStage.loadContent() of the EntryAbility file, and listen for window size changes to obtain new breakpoint values. For more strategies for obtaining breakpoint values, see Design Principles of Breakpoints.
    export default class EntryAbility extends UIAbility {
      public uiContext?: UIContext;
      public onWindowSizeChange: (windowSize: window.Size) => void = (windowSize: window.Size) => {
        let widthBp: WidthBreakpoint = this.uiContext!.getWindowWidthBreakpoint();
        AppStorage.setOrCreate('currentWidthBreakpoint', widthBp);
        let heightBp: HeightBreakpoint = this.uiContext!.getWindowHeightBreakpoint();
        AppStorage.setOrCreate('currentHeightBreakpoint', heightBp);
        AppStorage.setOrCreate('windowHeight', windowSize.height);
        AppStorage.setOrCreate('windowWidth', windowSize.width);
      };
      // ...
    
      onWindowStageCreate(windowStage: window.WindowStage) {
        // Create the main window and set it for the UIAbility.
        // ...
        windowStage.loadContent('pages/Index', (err, data) => {
          // ...
          windowStage.getMainWindow((err: BusinessError, data) => {
            if (err.code) {
              Logger.error('Failed to obtain the main window. Cause: %{public}s', JSON.stringify(err) ?? '');
              return;
            }
            // Obtain the window size and listen for its changes.
            let properties = data.getWindowProperties();
            AppStorage.setOrCreate('windowHeight', properties.windowRect.height);
            AppStorage.setOrCreate('windowWidth', properties.windowRect.width);
            // Obtain the breakpoint and listen for its changes.
            this.uiContext = data.getUIContext();
            let widthBp: WidthBreakpoint = this.uiContext.getWindowWidthBreakpoint();
            let heightBp: HeightBreakpoint = this.uiContext.getWindowHeightBreakpoint();
            AppStorage.setOrCreate('currentWidthBreakpoint', widthBp);
            AppStorage.setOrCreate('currentHeightBreakpoint', heightBp);
            data.on('windowSizeChange', this.onWindowSizeChange);
            // ...
          })
        });
      }
    
      // ...
    };
  2. Obtain breakpoint values from the page, listen for breakpoint changes, and pass the breakpoint values to the native side.
    @Entry
    @Component
    struct Index {
      // ...
      // Define the variables passed to the native side.
      @State cutoutAreas: Areas = {
        top: 0,
        right: 0,
        bottom: 0,
        left: 0,
        heightBreakpoint: 0,
        widthBreakpoint: 0
      };
      // ...
      // Listen for the changes to breakpoint values in landscape and portrait modes.
      @StorageLink('currentHeightBreakpoint') @Watch('breakPointChange') heightBreakpoint: HeightBreakpoint =
        HeightBreakpoint.HEIGHT_SM;
      @StorageLink('currentWidthBreakpoint') @Watch('breakPointChange') widthBreakpoint: WidthBreakpoint =
        WidthBreakpoint.WIDTH_XS;
      // ...
    
      // Trigger value passing when breakpoint changes.
      breakPointChange() {
        this.cutoutAreas.heightBreakpoint = this.heightBreakpoint;
        this.cutoutAreas.widthBreakpoint = this.widthBreakpoint;
        // Encapsulate the native method and pass in a breakpoint.
        tetrahedron_napi.objectPassing(this.cutoutAreas);
      }
      // ...
    }

Listening for Surface Size Changes

The XComponent holds a surface, whose default position and size match the XComponent. Obtain a native XComponent instance at the native layer, which is used as the bridge between the ArkTS layer and the native layer. Register the lifecycle and event callbacks of the XComponent by using the APIs provided by the native XComponent, customize drawing content by calling APIs such as NativeWindow, and apply for and submit buffers to the graphics queue. In this way, the custom drawing content can be passed to the surface held by the XComponent. For details about the XComponent development process, see Custom Rendering.

In the event callback of the native XComponent, the OnSurfaceCreated() and OnSurfaceChanged() callbacks are invoked when the surface is created and the surface size changes, respectively. Therefore, you should pay attention to these two callbacks when adapting layouts to devices with different screen sizes.

In the OnSurfaceCreated() and OnSurfaceChanged() callbacks, use OH_NativeXComponent_GetXComponentSize() to obtain the size of the surface held by the XComponent. The width and height of the surface are obtained again each time the surface changes. Use the custom reSizeWindow() method to reset the size of the OpenGL drawing area, facilitating adaptation.

void AppNapi::OnSurfaceCreated(OH_NativeXComponent* component, void* window)
{
    LOGE("AppNapi::OnSurfaceCreated");
    OH_NativeXComponent_RegisterOnFrameCallback(component, OnFrameCB);
    // Obtain the surface size.
    int32_t ret = OH_NativeXComponent_GetXComponentSize(component, window, &width_, &height_);
    // Draw the image to be displayed on the window and set the size of the drawing area.
    LOGE("Offset : x = %{public}f, y = %{public}f ", x_, y_);
    if (ret == OH_NATIVEXCOMPONENT_RESULT_SUCCESS) {
        tetrahedron_->Init(window, width_, height_);
        tetrahedron_->reSizeWindow(width_, height_);
        tetrahedron_->Update(angleX_, angleY_);
        isCreated_++;
        xcHeight_ = height_;
        xcWidth_ = width_;

        LOGE("AppNapi::OnSurfaceCreated success ");
    } else {
        LOGE("AppNapi::OnSurfaceCreated failed");
    }
}

void AppNapi::OnSurfaceChanged(OH_NativeXComponent* component, void* window)
{
    LOGE("AppNapi::OnSurfaceChanged");
    // Obtain the surface size again.
    int32_t ret = OH_NativeXComponent_GetXComponentSize(component, window, &width_, &height_);
    int32_t ret1;

    // Set the size of the drawing area.
    if (ret == OH_NATIVEXCOMPONENT_RESULT_SUCCESS) {
        tetrahedron_->reSizeWindow(width_, height_);
        xcHeight_ = height_;
        xcWidth_ = width_;
        LOGE("after width = %{public}d, height = %{public}d", xcWidth_, xcHeight_);
        ret1= OH_NativeXComponent_GetXComponentOffset(component, window, &x_, &y_);
        off_x = x_;
        off_y = y_;
        // ...
    }
}

[h2]Avoid Area Adaptation

Gaming apps mainly use an immersive UI design. To create an immersive experience, the display effects of the status bar, app UI, and navigation bar are adjusted to minimize visual distractions and enhance user experience.

In addition, gaming apps need to extend the layout and hide the avoid area, that is, hide the status bar and navigation bar (the navigation bar shown in the following figure is hidden in the actual use scenario). UI elements are displayed as below.

In such scenarios, the cutout area (for example, camera area) may block part of the page information or user operation buttons. To optimize user experience, the operation buttons need to be moved to the other side of the cutout area. In addition, to avoid large white spaces on the side, you should obtain the position of the cutout area and apply corresponding avoidance design. The procedure is as follows:

Extending the Layout with Hidden Avoid Areas

Call setWindowLayoutFullScreen() to set the window layout to the immersive mode, and then call setWindowSystemBarEnable() to set the visibility state of the status bar and navigation bar. In gaming apps, you should hide the status bar and navigation bar.

onWindowStageCreate(windowStage: window.WindowStage) {
  // ...

  try {
    // Set the main window to immersive and hide the navigation bar.
    windowStage.getMainWindowSync().setWindowLayoutFullScreen(true);
    windowStage.getMainWindowSync().setWindowSystemBarEnable([]);
  } catch (error) {
    let err = error as BusinessError;
    hilog.error(0x0000, 'EntryAbility',
      `Failed to set the window state. Error code=${err.code}, message=${err.message}`);
  }

  // ...
}

Obtaining the Cutout Area

  1. Use getWindowAvoidArea() to obtain the notched screen area, which is generally the front-facing camera area, and use on('avoidAreaChange') to listen for position changes.
    public onAvoidAreaChange: (avoidArea: window.AvoidAreaOptions) => void = (avoidArea: window.AvoidAreaOptions) => {
      if (avoidArea.type === window.AvoidAreaType.TYPE_CUTOUT) {
        AppStorage.setOrCreate('cutout', avoidArea);
      }
    }
    
    // ...
    
    onWindowStageCreate(windowStage: window.WindowStage) {
      // ...
      windowStage.getMainWindow((err: BusinessError, data) => {
        // ...
      windowStage.loadContent('pages/Index', (err, data) => {
        // ...
          // Listen for changes to the position of the avoid area.
          let avoidArea: window.AvoidArea = data.getWindowAvoidArea(window.AvoidAreaType.TYPE_CUTOUT);
          this.onAvoidAreaChange({ type: window.AvoidAreaType.TYPE_CUTOUT, area: avoidArea });
          data.on('avoidAreaChange', this.onAvoidAreaChange);
        })
      });
    }
  2. After obtaining the new position of the camera area, calculate the top, bottom, left, and right of the avoid area.
    @StorageLink('cutout') @Watch('cutoutChange') avoidAreas: window.AvoidAreaOptions | undefined = undefined;
    @StorageLink('windowHeight') windowHeight: number = 0;
    @StorageLink('windowWidth') windowWidth: number = 0;
    // ...
    cutoutChange() {
      let topPX = getTop(this.avoidAreas);
      let rightPX = getRight(this.avoidAreas, this.windowWidth);
      let bottomPX = getBottom(this.avoidAreas, this.windowHeight);
      let leftPX = getLeft(this.avoidAreas);
    
      // ...
    }
    function getTop(avoidArea: window.AvoidAreaOptions | undefined): number {
      let result: number = 0;
      if (avoidArea !== undefined) {
        if (avoidArea.area.topRect.height) {
          result = avoidArea.area.topRect.top + avoidArea.area.topRect.height;
        }
      } else {
        hilog.error(0x0000, '3D', 'Can not get TopSafeAreaPixel, avoidArea visible false');
      }
      return result;
    }
    
    function getBottom(avoidArea: window.AvoidAreaOptions | undefined, windowHeight: number): number {
      let result: number = 0;
      if (avoidArea !== undefined) {
        if (avoidArea.area.bottomRect.height) {
          result = windowHeight - avoidArea.area.bottomRect.top;
        }
      } else {
        hilog.error(0x0000, '3D', 'Can not get BottomSafeAreaPixel, avoidArea visible false');
      }
      return result;
    }
    
    function getLeft(avoidArea: window.AvoidAreaOptions | undefined): number {
      let result: number = 0;
      if (avoidArea !== undefined) {
        if (avoidArea.area.leftRect.width) {
          result = avoidArea.area.leftRect.left + avoidArea.area.leftRect.width;
        }
      } else {
        hilog.error(0x0000, '3D', 'Can not get LeftSafeAreaPixel, avoidArea visible false');
      }
      return result;
    }
    
    function getRight(avoidArea: window.AvoidAreaOptions | undefined, windowWidth: number): number {
      let result: number = 0;
      if (avoidArea !== undefined) {
        if (avoidArea.area.rightRect.width) {
          result = windowWidth - avoidArea.area.rightRect.left;
        }
      } else {
        hilog.error(0x0000, '3D', 'Can not get RightSafeAreaPixel, avoidArea visible false');
      }
      return result;
    }

Setting the Avoid Area

After obtaining the position of the avoid area, you can set it on the native surface or ArkTS for avoidance. This topic sets the avoid area on ArkTS and passes the values to the native side. You can adapt the avoid area on the native side using the input value based on actual requirements.

[h2]Window Rotation

Games may have a specific window orientation preference, either in landscape or portrait. You can call setPreferredOrientation() to customize the window rotation direction based on app requirements. An appropriate window orientation is crucial for gaming apps. The following uses the portrait orientation as an example to describe how to rotate the window.

// Rotate the window to portrait or reverse portrait automatically following the sensor.
let orientation = window.Orientation.AUTO_ROTATION_PORTRAIT;
try {
  windowClass.setPreferredOrientation(orientation, (err: BusinessError) => {
    if (err.code) {
      Logger.error('Failed to set window orientation. Cause: %{public}s', JSON.stringify(err) ?? '');
      return;
    }
    Logger.info('Succeeded in setting window orientation. Data: %{public}s');
  })
} catch (exception) {
  Logger.error('Failed to set window orientation. Cause: %{public}s', JSON.stringify(exception) ?? '');
}

Sample Project