Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 15 additions & 12 deletions .github/workflows/danger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,27 @@ jobs:
build:
name: PR Checks
runs-on: ubuntu-latest
permissions:
pull-requests: write
contents: read
steps:
- uses: actions/checkout@v1
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
channel: stable
flutter-version: 3.44.1
- name: Run Danger
run: |
cd $HOME
wget -O flutter.tar.xz https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.7.3-stable.tar.xz
tar xf flutter.tar.xz
export PATH="${PATH}:$HOME/flutter/bin"
cd $GITHUB_WORKSPACE
cd games_services
cd $GITHUB_WORKSPACE/games_services_platform_interface
flutter pub get
cd $GITHUB_WORKSPACE/games_services
flutter pub get
cd ..
cd games_services_platform_interface
cd $GITHUB_WORKSPACE/games_services/example
flutter pub get
cd ..
cd $GITHUB_WORKSPACE
npm install --global yarn
yarn add danger --dev
yarn danger ci
env:
GITHUB_TOKEN: ${{ secrets.DANGER_GITHUB_API_TOKEN }}
DANGER_GITHUB_API_TOKEN: ${{ secrets.DANGER_GITHUB_API_TOKEN }}
# Use the run's built-in token; no custom repo secret required.
DANGER_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }}
13 changes: 8 additions & 5 deletions dangerfile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,15 +36,18 @@ function checkPRSize() {
const maxLinesOfCode = 1000
const linesOfCode = danger.github.pr.additions + danger.github.pr.deletions
if (linesOfCode > maxLinesOfCode) {
fail(`This pull request adds too many lines of code. It adds ${linesOfCode} lines, but the maximum allowed is ${maxLinesOfCode} lines.`)
warn(`This pull request is large: it changes ${linesOfCode} lines (soft limit ${maxLinesOfCode}). Consider splitting it into smaller pull requests when possible.`)
}
}

function runFlutterAnalyzer() {
try {
child_process.execSync('flutter analyze')
} catch (error) {
fail(`Flutter analyzer failed. Please fix the issues reported by the analyzer. ${error}`)
// The analyzer must run inside a package (the repo root has no pubspec.yaml).
for (const package of ['games_services', 'games_services_platform_interface']) {
try {
child_process.execSync('flutter analyze', { cwd: package })
} catch (error) {
fail(`Flutter analyzer failed in \`${package}\`. Please fix the issues reported by the analyzer. ${error}`)
}
}
}

Expand Down
24 changes: 24 additions & 0 deletions docs/save_load_game.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,30 @@ final result = await SaveGame.saveGame(data: data, name: "slot1");

*The `name` must be between 1 and 100 non-URL-reserved characters (a-z, A-Z, 0-9, or the symbols "-", ".", "_", or "~").*

### Saving a cover image (and other metadata)

On Android (Google Play Games Services) you can attach a cover image, a
description, and the total played time to a saved game. Providing a cover image
is required to pass Google's [Play Games Services quality checklist](https://developer.android.com/games/pgs/quality#saved-games) (item 6.1).

```dart
// `coverImage` is the raw bytes of an image, e.g. a PNG or JPEG.
final Uint8List coverImage =
(await rootBundle.load("assets/cover.png")).buffer.asUint8List();

final result = await SaveGame.saveGame(
data: data,
name: "slot1",
coverImage: coverImage,
description: "Level 96, sword equipped",
playedTime: const Duration(minutes: 42),
);
```

All three parameters are optional, so existing calls keep working unchanged.
They are ignored on iOS/macOS (Game Center), which does not support snapshot
metadata.

## Load game

Load a game save by `name`.
Expand Down
88 changes: 88 additions & 0 deletions docs/windows_playfab.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# Windows (PlayFab)

Windows has no native equivalent of Game Center or Google Play Games. On Windows the
`games_services` plugin is implemented in pure Dart on top of the
[Microsoft PlayFab](https://learn.microsoft.com/gaming/playfab/) REST API:

| games_services concept | PlayFab feature |
| --- | --- |
| Sign in | `LoginWithCustomID` with an anonymous device id (a GUID persisted via `shared_preferences`) |
| Leaderboards | Player **statistics** (`UpdatePlayerStatistics`, `GetLeaderboard`, `GetLeaderboardAroundPlayer`, `GetFriendLeaderboard`) |
| Achievements | Definitions in **Title Data**, per-player progress in **User Data** |
| Saved games | **Entity Files** for the payload, an **Entity Object** for the index |

## 1. Create a PlayFab title

1. Create a title in the [PlayFab Game Manager](https://developer.playfab.com/) and copy its
**Title ID** (Settings → Game Properties), e.g. `ABCDE`.
2. In **Settings → API Features**, enable **Allow client to post Player Statistics** (required
for `submitScore`).
3. In **Settings → Client Profile Options**, allow **Display Name** and **Avatar URL** so the
player profile and leaderboard entries can return them.

## 2. Initialize before sign-in

`initialize` is a no-op on Android/iOS/macOS, so it is safe to guard with a platform check or
just call it everywhere:

```dart
import 'dart:io' show Platform;

void main() async {
WidgetsFlutterBinding.ensureInitialized();
if (Platform.isWindows) {
await GamesServices.initialize(playFabTitleId: "ABCDE");
}
runApp(const App());
}

// later
await GameAuth.signIn();
```

`initialize` optionally accepts:
- `customId` — use your own stable player id instead of the auto-generated device GUID.
- `displayName` — set the player's display name when the account has none yet.

## 3. Leaderboards

Each leaderboard maps to a PlayFab **statistic** whose name is the **iOS** leaderboard id you
pass (Windows reuses the iOS identifiers). Create the statistic in **Leaderboards** (or let the
first `submitScore` create it). Time scopes (`today`/`week`/`allTime`) rely on the statistic's
configured reset frequency; `loadPreviousOccurrence` reads the prior statistic version.

> Leaderboard entry avatars are returned as **URLs** in `scoreHolder.iconImage` on Windows
> (the signed-in player's own `iconImage` is base64, fetched once at sign-in).

## 4. Achievements

PlayFab has no built-in achievement system, so define your achievements in **Content → Title
Data** under the key `achievements` as a JSON array:

```json
[
{ "id": "first_win", "name": "First Win", "description": "Win a game", "steps": 0 },
{ "id": "100_kills", "name": "Centurion", "description": "100 kills", "steps": 100,
"lockedImage": "<base64>", "unlockedImage": "<base64>" }
]
```

- `steps` is the total step count for incremental achievements, `0` otherwise.
- `unlock`/`increment` write progress into the player's User Data key `achievements_progress`.
- `loadAchievements` merges the definitions with the stored progress.
- `resetAchievements` clears the progress key.

## 5. Saved games

`saveGame` stores each save as a PlayFab Entity File (a JSON envelope containing the data, an
optional base64 cover image, description, and played time) and records lightweight metadata
(name, modification date, device name) in an Entity Object index so `getSavedGames` is cheap.
`loadGame` downloads and unwraps the file; `deleteGame` removes both.

## Not available on Windows

These return `null` because they have no PlayFab equivalent:

- `showAchievements`, `showLeaderboards`, `showAccessPoint`, `hideAccessPoint` (no native UI)
- `getAuthCode` (Google Play Games only)
- `fetchIdentityVerificationSignature` (Game Center only)
9 changes: 9 additions & 0 deletions games_services/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
## 5.2.0

- Add **Windows** support, backed by [PlayFab](https://learn.microsoft.com/gaming/playfab/). Call `GamesServices.initialize(playFabTitleId: ...)` before `signIn()` on Windows (no-op on other platforms). Supports auth, leaderboards (statistics), achievements (Title Data definitions + player-data progress), and saved games (entity files). See [docs/windows_playfab.md](https://github.com/Abedalkareem/games_services/blob/master/docs/windows_playfab.md).

## 5.1.0

- Add optional `coverImage`, `description`, and `playedTime` parameters to `SaveGame.saveGame`. On Android these are set as the snapshot metadata (cover image required to pass Google's Play Games Services quality checklist). Ignored on iOS/macOS. (#228)
- Fix Android `saveGame` using the unique name as the snapshot description; the description is now only set when provided. (#228)

## 5.0.1

- Return `null` for `playerID` and `teamPlayerID` when GameCenter configuration errors lead to temporary IDs. by @theLee3
Expand Down
23 changes: 22 additions & 1 deletion games_services/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

<img src="https://github.com/Abedalkareem/games_services/raw/master/logo.png" width="200"/>

A Flutter plugin to support Game Center and Google Play Games services.
A Flutter plugin to support Game Center, Google Play Games, and (on Windows) PlayFab services.

## Screenshot

Expand Down Expand Up @@ -41,6 +41,27 @@ TTG - Through The Galaxies 🚀 casual game . [Android](https://play.google.com/

Check the docs folder for documentation and how to use the plugin.

## Windows (PlayFab)

Windows has no native equivalent of Game Center or Google Play Games, so on Windows the
plugin is backed by [Microsoft PlayFab](https://learn.microsoft.com/gaming/playfab/) over
its REST API. See [docs/windows_playfab.md](docs/windows_playfab.md) for the full setup, but
in short:

```dart
// Call once, before signIn(), on Windows. No-op on Android/iOS/macOS.
await GamesServices.initialize(playFabTitleId: "ABCDE");
await GameAuth.signIn();
```

Notes:
- Windows reuses the **iOS** identifiers you pass for leaderboards and achievements.
- Leaderboards map to PlayFab statistics; achievements are defined in PlayFab Title Data with
progress stored in player data; saved games use PlayFab Entity Files.
- Native-UI methods (`showAchievements`, `showLeaderboards`, the access point) and
Apple/Google-only methods (`getAuthCode`, `fetchIdentityVerificationSignature`) return
`null` on Windows.

## Installing

Simply add `games_services` as a dependency in your `pubspec.yaml` by running the following command:
Expand Down
6 changes: 6 additions & 0 deletions games_services/analysis_options.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml

analyzer:
errors:
# The platform interface is consumed via a path dependency during local
# development in this monorepo, which is expected; don't fail analysis on it.
invalid_dependency: ignore

linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,10 @@ class GamesServicesPlugin : FlutterPlugin,
Method.SaveGame -> {
val data = call.argument<String>("data") ?: ""
val name = call.argument<String>("name") ?: ""
saveGame?.saveGame(data, name, name, result)
val description = call.argument<String>("description")
val coverImage = call.argument<ByteArray>("coverImage")
val playedTime = call.argument<Number>("playedTime")?.toLong()
saveGame?.saveGame(data, description, name, coverImage, playedTime, result)
}

Method.LoadGame -> {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.abedalkareem.games_services

import android.graphics.BitmapFactory
import android.util.Log
import com.abedalkareem.games_services.models.SavedGame
import com.abedalkareem.games_services.util.Messages
Expand Down Expand Up @@ -61,12 +62,30 @@ class SaveGame(private var activityPluginBinding: ActivityPluginBinding) {
}

fun saveGame(
data: String, desc: String, name: String, result: MethodChannel.Result
data: String,
desc: String?,
name: String,
coverImage: ByteArray?,
playedTimeMillis: Long?,
result: MethodChannel.Result
) {
Log.d(tag, "[SaveGame] Start saving game")
val metadataChange = SnapshotMetadataChange.Builder()
.setDescription(desc)
.build()
val metadataChangeBuilder = SnapshotMetadataChange.Builder()
if (desc != null) {
metadataChangeBuilder.setDescription(desc)
}
if (playedTimeMillis != null) {
metadataChangeBuilder.setPlayedTimeMillis(playedTimeMillis)
}
if (coverImage != null) {
val bitmap = BitmapFactory.decodeByteArray(coverImage, 0, coverImage.size)
if (bitmap != null) {
metadataChangeBuilder.setCoverImage(bitmap)
} else {
Log.d(tag, "[SaveGame] Failed to decode the cover image bytes, skipping it")
}
}
val metadataChange = metadataChangeBuilder.build()
snapshotsClient.open(name, true, SnapshotsClient.RESOLUTION_POLICY_MOST_RECENTLY_MODIFIED)
.addOnSuccessListener { annotatedData ->
val snapshot = annotatedData.data
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ class SaveGame: BaseGamesServices {

func saveGame(name: String, data: String, result: @escaping FlutterResult) {
log("[SaveGame] Please add the iCloud capability to your project and enable iCloud Documents. If you already have done that please ignore this message.")
log("[SaveGame] Snapshot metadata (cover image, description, played time) is not supported by Game Center and will be ignored on iOS/macOS.")
log("[SaveGame] Start saving game")
guard let data = data.data(using: .utf8) else {
log("[SaveGame] failed to get data from a string \(data)")
Expand Down
29 changes: 27 additions & 2 deletions games_services/example/lib/main.dart
Original file line number Diff line number Diff line change
@@ -1,9 +1,24 @@
import 'dart:convert';
import 'dart:io' show Platform;

import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show rootBundle;
import 'package:games_services/games_services.dart';

void main() => runApp(const App());
void main() async {
WidgetsFlutterBinding.ensureInitialized();

// Windows is backed by PlayFab, which needs the title id before sign-in.
// Provide it via `--dart-define=PLAYFAB_TITLE_ID=XXXXX` or hardcode it here.
if (Platform.isWindows) {
const titleId = String.fromEnvironment("PLAYFAB_TITLE_ID");
if (titleId.isNotEmpty) {
await GamesServices.initialize(playFabTitleId: titleId);
}
}

runApp(const App());
}

class App extends StatefulWidget {
const App({Key? key}) : super(key: key);
Expand Down Expand Up @@ -310,7 +325,17 @@ class AppState extends State<App> {

void _saveGame() async {
final data = jsonEncode(GameData(96, "sword").toJson());
final result = await SaveGame.saveGame(data: data, name: "slot1");
// A cover image is required to pass Google's Play Games Services quality
// checklist. Here we reuse the bundled logo as a sample cover image.
final coverImage =
(await rootBundle.load("assets/logo.png")).buffer.asUint8List();
final result = await SaveGame.saveGame(
data: data,
name: "slot1",
coverImage: coverImage,
description: "Level 96, sword equipped",
playedTime: const Duration(minutes: 42),
);
print(result);
}

Expand Down
17 changes: 17 additions & 0 deletions games_services/example/windows/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
flutter/ephemeral/

# Visual Studio user-specific files.
*.suo
*.user
*.userosscache
*.sln.docstates

# Visual Studio build-related files.
x64/
x86/

# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!*.[Cc]ache/
Loading
Loading