實驗性 YouTube 遊戲角落 Flutter Web SDK

本指南說明如何在 Flutter Web 應用程式中使用 YouTube Playables SDK。

Flutter 設定

預設的 Flutter 網路應用程式需要進行一些變更,才能做為可播放的內容。自 Flutter 3.24.5 版起,您必須進行下列變更。

根據預設,Flutter 會設定為從根目錄載入資源,而 Playables 則要求資源必須相對於進入點載入。這會顯示為 404 錯誤,或可能會顯示為 javascript 主控台中的錯誤,內容如下:Refused to execute script from... because its MIME type ('text/html') is not executable. 如要重新設定這項設定,您可以從 index.html 檔案中移除下列標記:

<base href="$FLUTTER_BASE_HREF">

根據預設,Flutter 會動態載入部分程式庫,而不是將程式庫嵌入應用程式。CanvasKit 就是這類程式庫的例子。這會在 JavaScript 控制台中顯示為錯誤,內容如下:Refused to connect to '...' because it violates the following Content Security Policy directive: "connect-src 'self' blob: data:". 在載入失敗時,Flutter 也可能會輸出其他記錄,例如 Failed to download any of the following CanvasKit URLs。如要修正這個問題,您可以使用下列額外標記建構網頁應用程式:

$ flutter build web --no-web-resources-cdn

根據預設,Flutter 網頁應用程式會動態載入字型,而不是將字型嵌入應用程式。預設的 Flutter 應用程式會使用 Roboto 字型,這會在 JavaScript 主控台中顯示為錯誤,內容如下:Refused to connect to '...' because it violates the following Content Security Policy directive: "connect-src 'self' blob: data". 如要修正這個問題,您可以採取下列步驟:

  • 在 Flutter 網頁應用程式的根目錄中建立名為「fonts」的資料夾
  • 前往 fonts.google.com 下載 Roboto 字型系列。
  • 解壓縮字型,並將 Roboto-Regular.ttf 複製到字型資料夾
  • 在您的 flutter 網頁應用程式的 pubspec.yaml 中加入以下行:
  fonts:
    - family: Roboto
      fonts:
       - asset: fonts/Roboto-Regular.ttf

如要進一步瞭解字型設定,請參閱 Flutter 說明文件

SDK 整合

您可以透過類似以下的 JS 互通包裝函式,從 Flutter Web 遊戲使用 YouTube Playables SDK

ytgame.dart

// Copyright 2023 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

@JS()
library ytgame_api;

import 'package:js/js.dart';
import 'package:js/js_util.dart' as js_util;

enum PlayablesErrorType {
  unknown('UNKNOWN'),
  apiUnavailable('API_UNAVAILABLE'),
  invalidParams('INVALID_PARAMS'),
  sizeLimitExceeded('SIZE_LIMIT_EXCEEDED');

  const PlayablesErrorType(String type) : _type = type;
  final String _type;

  @override
  String toString() => _type;

  static PlayablesErrorType fromString(String errorType) {
    return values.firstWhere(
      (element) => element._type == errorType,
    );
  }
}

@JS()
@staticInterop
class PlayablesError {}

extension PlayablesErrorExtension on PlayablesError {
  @JS('errorType')
  external String get _errorType;
  PlayablesErrorType get errorType {
    return PlayablesErrorType.fromString(_errorType);
  }
}

@JS()
@anonymous
@staticInterop
class PlayablesScore {
  external factory PlayablesScore({
    required num value,
  });
}

extension PlayablesScoreExtension on PlayablesScore {
  external num get value;
}

// engagement
@JS()
@staticInterop
class PlayablesEngagement {}

extension PlayablesEngagementExtension on PlayablesEngagement {
  @JS('sendScore')
  external Object _sendScore(PlayablesScore score);
  Future<void> sendScore(PlayablesScore score) {
    return js_util.promiseToFuture(_sendScore(score));
  }
}

// game
@JS()
@staticInterop
class PlayablesGame {}

extension PlayablesGameExtension on PlayablesGame {
  external void firstFrameReady();
  external void gameReady();

  @JS('loadData')
  external Object _loadData();
  Future<String?> loadData() {
    return js_util.promiseToFuture<String?>(_loadData());
  }

  @JS('saveData')
  external Object _saveData(String? data);
  Future<void> saveData(String? data) {
    return js_util.promiseToFuture<void>(_saveData(data));
  }
}

// health
@JS()
@staticInterop
class PlayablesHealth {}

extension PlayablesHealthExtension on PlayablesHealth {
  external void logError();
  external void logWarning();
}

// system
typedef PlayablesUnsetCallback = void Function();
typedef PlayablesOnAudioEnabledChange = void Function(bool isAudioEnabled);
typedef PlayablesOnPause = void Function();
typedef PlayablesOnResume = void Function();

@JS()
@staticInterop
class PlayablesSystem {}

extension PlayablesSystemExtension on PlayablesSystem {
  external bool isAudioEnabled();

  @JS('onAudioEnabledChange')
  external PlayablesUnsetCallback _onAudioEnabledChange(
      PlayablesOnAudioEnabledChange callback);
  PlayablesUnsetCallback onAudioEnabledChange(
      PlayablesOnAudioEnabledChange callback) {
    return _onAudioEnabledChange(allowInterop(callback));
  }

  @JS('onPause')
  external PlayablesUnsetCallback _onPause(PlayablesOnPause callback);
  PlayablesUnsetCallback onPause(PlayablesOnPause callback) {
    return _onPause(allowInterop(callback));
  }

  @JS('onResume')
  external PlayablesUnsetCallback _onResume(PlayablesOnResume callback);
  PlayablesUnsetCallback onResume(PlayablesOnResume callback) {
    return _onResume(allowInterop(callback));
  }
}

@JS()
@staticInterop
class Playables {}

extension PlayablesExtension on Playables {
  @JS('SDK_VERSION')
  external String get sdkVersion;
  external PlayablesEngagement get engagement;
  external PlayablesGame get game;
  external PlayablesHealth get health;
  external PlayablesSystem get system;
}

@JS('ytgame')
external Playables get ytgame;

用量

  1. 請按照這篇文章的操作說明設定及初始化 Web SDK。
    • 修改 Flutter Web 應用程式的 web/index.html,納入必要的指令碼標記。
  2. 在 Flutter 網路應用程式的 src 中新增 ytgame.dart 的副本。
  3. 請確認已將 js 套件新增至 Flutter,例如執行 flutter pub add js 指令。
  4. 視需要新增 import 'src/location/of/ytgame.dart';
  5. 使用 ytgame 物件存取 SDK。

範例

以下列舉幾個 Dart API 的實際運作範例。

firstFrameReadygameReady

為了讓遊戲正確啟動,您需要在第一個影格準備好顯示時呼叫 firstFrameReady,並在遊戲準備好進行互動時呼叫 gameReady。在預設的 Flutter 應用程式中執行此操作的方法之一,是將呼叫新增至 main.dart 檔案:

...
import 'src/ytgame.dart';
...

void main() {
  ytgame.game.firstFrameReady();
  ytgame.game.gameReady();
  runApp(const MyApp());
}

請注意,這只是讓遊戲運作的範例,您需要正確放置這些呼叫的位置,才能提供正確的實作方式。

sendScore

以下範例說明如何在 Dart 中實作 sendScore(int points)

...
import 'src/ytgame.dart';
...

/// Sends [points] as score to YT Playables
Future<void> sendScore(int points) async {
  // Create a score object...
  final PlayablesScore score = PlayablesScore(
    value: points,
  );
  // Submit the score to ytgame
  await ytgame.engagement.sendScore(score);
}

onPause

以下範例說明遊戲如何監聽來自 YT Playables 的 Pause 事件,以便在需要時暫停引擎:

...
import 'src/ytgame.dart';
...

/// The instance of your game.
late Game myGame;

/// Callback to stop listening for Pause events (cleanup).
PlayablesUnsetCallback? _unsetOnPause;
...

/// Sets a [callback] to respond to Pause events.
void registerOnPauseListener(PlayablesOnPause callback) {
  _unsetOnPause = ytgame.system.onPause(callback);
}

/// Stops listening to Pause events, and clears [_unsetOnPause].
void unsetOnPauseListener() {
  if (_unsetOnPause != null) {
    _unsetOnPause!();
    _unsetOnPause = null;
  }
}

...

/// Initialization for your game
void initGame() {
  ...
  myGame = ...
  registerOnPauseListener(_onPause);
}

void _onPause() {
  // This is called when a Pause event is received from YT Playables.
  myGame.pauseEngine();
}

/// Teardown for your game
void disposeGame() {
  ...
  unsetOnPauseListener();
}

請參閱完整的 YT Playables API 參考資料