アプリ置き場

アプリ置き場

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();
}

C# お手軽にそこそこ高精度なSleep

.NET Framework4.8、2023年10月時点で最新状態のWindows11で確認

通常

Task.Delay(1) 15msくらいの精度
Thread.Sleep(1) 15ms くらいの精度

timeBeginPeriod(1)を実行してから

[DllImport("Winmm.dll")]
public static extern uint timeBeginPeriod(uint uuPeriod);

Task.Delay(1) 15msくらいの精度
Thread.Sleep(1) 1~2msくらいの精度

合わせ技

Thread.Sleepでメインループを回すとスレッドをブロックしてイベント受信が滞るので合わせ技

static hoge{
    timeBeginPeriod(1);
}

async Task Delay(int delay) 
{
    await Task.Run(() => System.Threading.Thread.Sleep(delay)); 
}

タスクスイッチを促しつつ最小2ms程度で返ってくる

補足

ちなみに以前のtimeBeginPeriodは他のアプリへの影響があったが、
いつからかプロセス内に閉じることになったらしいので気にせず使用できる

バージョン 2004 Windows 10以降、この関数はグローバル タイマー解決に影響しなくなりました。

Windowsでライン入力による音声を再生する

ゼルダティアキンのプレイ中にPCで攻略情報を動画で見たい時、Switchの音とPCの音がそれぞれ別の再生デバイス(ワイヤレスイヤホンとか)から出るのめんどくさい。ミキシングしたい。

 

一応PCのライン入力端子(青いやつ)に、Switchのイヤホンジャックから接続することでSwitchの音をPCで聞くことができる。(要両端がオスのケーブル)

 

贅沢を言えば、Switchのイヤホンジャックじゃなくて、1000円くらいのでいいのでUSBオーディオアダプタ買ってきてSwitchに繋げて、そこからとったほうがノイズ少なくてよし。

そのあと、

コントロールパネル>サウンド>録音>(対象の入力デバイス)>プロパティ>聴く>このデバイスを聴く

をONにすることで、入力された音がPCで聞こえるようになる。

 

がしかし、プレイ中はいいけど、プレイが終わったあとそのままにしていると、ライン入力からはいってくるノイズがうるさい。入力音のボリュームは設定しても事あるごとにリセットされるので、必然的に使い始めと使い終わりに「このデバイスを聴く」のON/OFFを切り替えることになるのだけど、奥まった場所にあって不便。ということで、このデバイスを聴くをOFFにしたままでもよいように、入力された音声を再生するだけのソフトを作りました。置いておきます。

 

ダウンロードはこちらから

https://www.moreread.net/

 

PCで処理している以上どうしても遅延は発生しますが、安定して再生できるギリギリを攻められるように一応レイテンシの設定はつけておきました。

 

LightGBMでモデルの作成と保存と読込方法 (python)

モデルの作成

model = lgb.train(
        params,
        trains,
        valid_sets=valids,
        callbacks=[
            lgb.early_stopping(stopping_rounds=100, verbose=True),
            lgb.log_evaluation(10),
        ],
    )

モデルの保存

model.save_model("model.txt", num_iteration=model.best_iteration)

モデルの読込

model = lgb.Booster(model_file="model.txt")

Flutter いろいろ備忘メモ

いろいろメモ。

Flutter Webも含めたPlatform判別

Webブラウザでモバイル用のコンテンツをテストしようとすると、プラットフォーム判別で落ちるのでこれを使う。

pub.dev

bool isIos = UniversalPlatform.isIOS;
bool isWeb = UniversalPlatform.isWeb;



デバッグモードかリリースモードを判断する

以下を使えばよいらしい

kReleaseMode
kProfileMode
kDebugMode

api.flutter.dev



ウィジェットを囲む

f:id:nazenaninadesico:20210914024613p:plain

(VS Code)
ウィジェットにカーソルが合った状態で Ctrl + . を押すと新しいウィジェットで対象ウィジェットをラップしてくれる。 閉じ括弧がどこまでなのかイライラすることがなくなる。



不定サイズのウィジェットを上下(または左右)半分に分割する

f:id:nazenaninadesico:20210914024227p:plain

Expanded2個並列に並べたらいけるぽい

  Widget build(BuildContext context) {
    return Column(
      children: [
        Expanded(
          child: ColoredBox(
            color: Colors.red,
            child: Text(" 上 "),
          ),
        ),
        Expanded(
          child: ColoredBox(
            color: Colors.green,
            child: Text(" 下 "),
          ),
        ),
      ],
    );
  }



リタイアシミュレータ

Flutterの習作ということでリタイアシミュレータのモバイル版を製作。少し簡略化してモバイルで操作しやすい感じに。詳細にシミュレーションしたい方はWeb版のほうをご使用ください。

 

iOS

AndroidWeb版(PC向け)

 

Flutterでグラフ charts_flutter

charts_flutter

dependencies:
  flutter:
    sdk: flutter
  charts_flutter: "^0.11.0"


Flutterで各種グラフを描画できるパッケージ。
便利なんだけど情報が少なくて目的の処理をするのに調べるのが面倒だったので備忘録。

f:id:nazenaninadesico:20210911202745p:plain

コードにコメントでメモ

//double fontSize;

//class GraphData {
//  int hoge = 0;
//  int fuga = 0;
//  GraphData(this.hoge, this.fuga);
//}

List<charts.Series<GraphData, int>> _serieseData = [];

_serieseData.add(
  charts.Series<GraphData, int>(
    id: "凡例",
    data: _graphData,
    domainFn: (GraphData data, _) => data.hoge,
    measureFn: (GraphData data, _) => data.fuga,
    colorFn: (GraphData data, _) => charts.ColorUtil.fromDartColor(Colors.blue),//線の色
  ),
);

Container(
  padding: EdgeInsets.all(10),
  width: 600,
  height: 400,
  child: charts.LineChart(
    _serieseData,
    animate: true,
    animationDuration: Duration(milliseconds: 300), //アニメーションの時間
    behaviors: [
      charts.SeriesLegend(
        position: charts.BehaviorPosition.top,//凡例の表示位置
        desiredMaxColumns: 1,//凡例の並べ方の行数
        entryTextStyle: charts.TextStyleSpec(//凡例のテキストスタイル
          color: charts.ColorUtil.fromDartColor(Colors.white),
          fontSize: (fontSize).toInt(),
        ),
      ),
      charts.ChartTitle(
        '横軸タイトル',
        behaviorPosition: charts.BehaviorPosition.bottom,//横軸なので下に表示
        titleOutsideJustification: charts.OutsideJustification.middleDrawArea,
        titleStyleSpec: charts.TextStyleSpec(//テキストスタイル
          color: charts.ColorUtil.fromDartColor(Colors.white),//色は変換して使う
          fontSize: (fontSize).toInt(),//int型を要求される
        ),
      ),
      charts.ChartTitle(
        '縦軸タイトル',
        behaviorPosition: charts.BehaviorPosition.start,//縦軸なので左に表示
        titleOutsideJustification: charts.OutsideJustification.middleDrawArea,
        titleStyleSpec: charts.TextStyleSpec(//テキストスタイル
          color: charts.ColorUtil.fromDartColor(Colors.white),
          fontSize: (fontSize).toInt(),
        ),
      ),
    ],
    domainAxis: charts.NumericAxisSpec(//横軸
      viewport: charts.NumericExtents(10, 100.0), //横軸の表示範囲を明示する場合
      tickProviderSpec: charts.BasicNumericTickProviderSpec(
        zeroBound: false, //falseにすると軸を0から開始せずに、存在するデータの値の範囲を表示する
        desiredTickCount: 10,//目盛りを何分割するか desiredなので必ずしもこの値にはならないが考慮してくれる
        // desiredMinTickCount: 5,//分割の範囲を指定する場合
        // desiredMaxTickCount: 10,
      ),
      renderSpec: charts.GridlineRendererSpec(
        labelStyle: charts.TextStyleSpec(//目盛りのテキストスタイル
          fontSize: (fontSize).round(),
          color: charts.MaterialPalette.white,
        ),
        lineStyle: charts.LineStyleSpec(//目盛り線の設定
          color: charts.ColorUtil.fromDartColor(Colors.grey),
          dashPattern: [4, 4],//点線(実線と隙間のサイズを指定する)
        ),
      ),
    ),
    primaryMeasureAxis: charts.NumericAxisSpec(//縦軸
      tickProviderSpec: charts.BasicNumericTickProviderSpec(
        zeroBound: true,
        desiredTickCount: 10,
        // desiredMinTickCount: 5,
        // desiredMaxTickCount: 10,
      ),
      renderSpec: charts.GridlineRendererSpec(
        labelStyle: charts.TextStyleSpec(
          fontSize: (fontSize).round(),
          color: charts.MaterialPalette.white,
        ),
        lineStyle: charts.LineStyleSpec(
            color: charts.ColorUtil.fromDartColor(Colors.grey)
            //dashPattern: [4, 4],
            ),
      ),
    ),
  ),
),