アプリ置き場

アプリ置き場

http://www.moreread.net/

FlutterでSteam対応ゲーム(windows)を作った


ゲームエンジンぽいもの

Flutterでピクセルパーフェクトぽいものを実現する簡易的なゲームエンジンを作りました。
数年前からやりたいなと思いつつ放置してたやつ。飽きなければ整理して公開もしたい。

  • Flutterのその他Widgetと共存可
    Widgetツリーの中にゲーム画面を埋め込み可能
  • PSやSwitch系コントローラー対応
    自前でWin32APIを叩いた
  • 指定したフレームレートを維持する制御
    Flutterのデフォ機能では60hzディスプレイなのになぜか秒間90回ほど描画されるクソ動作だったので、高精度タイマーなど使いつつ自前制御。でもいくら時間制御を正確にして描画イベント発火させても、描画処理がFlutter持ちなので滑らかにならなくてびみょい。
  • ピクセルパーフェクトに描画を変換
    昨今のゲームエンジンを使うと移動やら回転やらでドット単位に沿わない偽レトロゲームになりがち
  • クライアント領域サイズ、解像度、フルスクリーンなど制御(外部ライブラリ依存)
  • TiledMap、Spriteクラス、画像のパレット変換ぽいAPIなど
  • AudioplayersをラップしたAudio系クラス
    AudioplayersをWindowsでバイナリからロードするとメモリリークしまくってたのでよい感じの運用でラップ
  • リソースの簡易暗号化と復号
    assetsフォルダがそのまま出ちゃうのでパスワード付きzipでまとめようとしたが、Winでも使えるFlutterのarchiveというライブラリはパスワードがついていると解凍できなかった。IFはあるのに。 なので自前で簡易暗号化と復号。

作ったゲーム

とりあえずエンジンぽいもの作ったので、それを使ったゲームもということで、20年近く前に作ったゲームをベースにミニゲームをつくってSteamに置いておきました。

CrapShoot クソシュー
store.steampowered.com
最初は無料の予定でしたが、登録料に15000円もとられたので一部でも回収したく200円に。よかったら購入してやってください。登録は終わったので近日中に公開される予定です。 ちなみに作業時間は下記くらい。おのれSteam。

  • エンジン部分作成1週間
  • ゲーム部分作成1週間
  • Steam対応2週間

Steam対応の方法

ちなみにFlutterでのSteam対応についてはごく最近に先駆者様がおられました。ありがたや。
steamworksライブラリを使った実績解除の方法など紹介されてます。
midland.hatenadiary.jp

参考にさせていただきつつ、スコアランキングのスコア送信のコードを書いたので置いておきます。
steamworksライブラリはC言語用のライブラリの極薄ラッパーなのでなんとも使い辛い。

import 'dart:convert';
import 'dart:ffi';
import 'dart:io';
import 'dart:typed_data';
import 'package:ffi/ffi.dart';
import 'package:steamworks/steamworks.dart';

~~~~~

Pointer<Utf8> _convertStringToUtf8Pointer(String st) {
  final uint8List = Uint8List.fromList(utf8.encode(st));
  final utf8Pointer = calloc.allocate<Utf8>(uint8List.length + 1);
  final nativeString = utf8Pointer.cast<Uint8>().asTypedList(uint8List.length + 1);
  nativeString.setAll(0, uint8List);
  nativeString[uint8List.length] = 0; // null終端
  return utf8Pointer;
}

void rankingSendScore(String boardName, int score) {
  final boardNamePointer = _convertStringToUtf8Pointer(boardName);
  final callId = _steamClient.steamUserStats.findLeaderboard(boardNamePointer);
  _steamClient.registerCallResult<LeaderboardFindResult>(
    asyncCallId: callId,
    cb: (result, hasFailed) {
      calloc.free(boardNamePointer);
      if (hasFailed) {
        print('findLeaderboard failed.');
      } else {
        if (result.leaderboardFound == 0) {
          print('$boardName not found.');
          return;
        }
        print('$boardName found. boardId = ${result.steamLeaderboard}');
        final callIdU = _steamClient.steamUserStats.uploadLeaderboardScore(
          result.steamLeaderboard,
          ELeaderboardUploadScoreMethod.keepBest,
          score,
          Pointer.fromAddress(0),
          0,
        );
        _steamClient.registerCallResult<LeaderboardScoreUploaded>(
          asyncCallId: callIdU,
          cb: (result, hasFailed) {
            print('newRank = ${result.globalRankNew}');
          },
        );
      }
    },
  );
}

// 毎フレーム呼ばないとSteamAPIのイベントキューに積まれた各種処理が進まないぽい
void runFrame() {
  _steamClient.runFrame();
}