ユーザ用ツール

サイト用ツール


ハイハイスクールアドベンチャー_avaloniaui版

文書の過去の版を表示しています。


ハイハイスクールアドベンチャー AvaloniaUI版

あらすじ

2019年神奈山県立ハイ高等学校は 地盤が弱く校舎の老朽化も進んだため、 とうとう廃校にする以外方法がなく なってしまった。

ところで大変な情報を手に入れた。 それは、

「ハイ高校にATOMIC BOMBが仕掛けられている。」

と、いうものだ。 どうやらハイ高が廃校になった時、 気が狂った理科の先生がATOMIC BOMBを 学校のどこかに仕掛けてしまったらしい。

お願いだ。我が母校のコナゴナになった 姿を見たくはない。 早くATOMIC BOMBを取り除いてくれ……!!

行動は英語で、“<動詞>” 或いは、“<動詞>”+“<目的語>“のように入れていただきたい。 例えば、”look room”と入れれば部屋の様子を見ることが出来るという訳だ。

それでは Good Luck!!!…………

概要

.NET はいつの間にかかなり本格的なマルチプラットフォームのフレームワークに進化していました。 実際、多少の手間こそあるものの、SDL2版は Windowsでも Linuxでも動きました。 ただ、SDL2なので、UIがかなり手抜きになっています。

せっかくのマルチプラットフォームなのだから、マルチプラットフォームなツールキットがあるんじゃないかと、AIに聞いたら、WPFをそのまま移植するのは無理だが、いくつかマルチプラットフォームのツールキットは存在しているよ、というので、じゃあせっかくだし、それで動くのを作ったら、それはそれで楽しいんじゃなかろうか、と思って、AvaloniaUIを使ったバージョンを実装しようと思い立ったのでした。

しかし、これがそんなに甘くはないんだということに気づくのに、それほど時間は必要ではありませんでした。

なお、2025年10月14日現在、まだ完成に至っていません。 ゲームはできるんですが、設定画面が全く動かなくて……。

AvaloniaUI版への困難な道

ビジュアルなUIデザイナがない?

ないのです。 Visual Studio Code 用に AvaloniaUI の拡張があって、.NET9ならなにやらビジュアルなツールが使えるようなことが書いてあったのですが、一応開発はLTS版である .NET8 をターゲットとしているので、それは使えない。

と、なると、XMLをじかに書き換えるしかないのです。

まあそんなにUIの点数の多いゲームではないので、大きな難点ではないのですが、いろいろ問題が起きたときに、修正がそれなりにめんどくさかったりするので。

また、ほぼXAMLなのに、UI要素があったりなかったり、また同じ名前でも持ってる属性が違ったりと、書き換えているうちに自分がなにをやっているのか見失いがちなのも難点でした。

UIスレッド

WPFでもそうだし、まあAndroidなんかも、UIの操作はUIスレッドが受け持つのです。

とはいえ、WPFにしてもSDL2にしても、ハイハイスクールアドベンチャーはUIからのイベントで駆動されるので、大体の処理はUIスレッドがそのまま受け持っていたので、あまり気にする必要はなかったのですが、AvaloniaUIの実装はかなり積極的にスレッドを起こして、処理ごとにスレッドが張り付くような格好になっているので、イベントの処理もUIスレッドじゃないスレッドが持ってくることが多いのです。

すると何が起こるか……画面の書き換えなどを行っても、それが反映されるのが、次にUIスレッドに処理が回ったときになるので、ゲームの進行と無関係に画面が書き換わるという困った状態になってしまうのです。

なので、画面を書き換えたらUIスレッドを起こして、処理が終わるのを待ってから進むようにしないといけないのです。 似たような話は Androidでもあったので、まあ、それはそれなんですが、処理の受け渡しなどの作法が、AIに聞きながらやっていると、それじゃないというコードが出てきたりして、ちょっと手間がかかりました。

        public async Task UpdateScreenAndAwaitRenderAsync()
        {
            // 1. TaskCompletionSource を作成し、イベントの完了を待てるようにする
            var tcs = new TaskCompletionSource<bool>();
 
            // 2. イベントハンドラを定義し、発火したら Task を完了させる
            //    C#のイベントハンドラは、イベントが発生した後に自身を解除します。
            EventHandler handler = null!;
 
            handler = (s, e) =>
            {
                // UIスレッドで実行されているため、直接操作して問題ありません。
                // イベントハンドラを解除
                Screen.LayoutUpdated -= handler; 
                // 待機中の Task を完了させる
                tcs.TrySetResult(true); 
            };
 
            // 3. イベントハンドラを登録し、ソースを更新
            Screen.LayoutUpdated += handler;
            Screen.InvalidateVisual(); // 画面更新を要求
 
            // 4. Taskが完了するまで待機 (描画完了まで待機)
            await tcs.Task;
        }

画面の書き換えが終わったら上をawait指定で呼び出す。

await UpdateScreenAndAwaitRenderAsync();

モーダルダイアログ

WPFでもAvaloniaUIでもダイアログは ShowDialog()で表示をかけるのですが、その挙動が違っています。 WPFは同期してダイアログが閉じるまで ShowDialog()から戻らないのですが、AvaloniaUIのそれは非同期で即座に戻ってきます。 これを、ダイアログが閉じられるまで待つなら await をつけて呼び出す必要があります。

await をつけると、その処理を含む関数は async 属性にしないといけなくて、さらにその処理を呼び出しているところで、その関数を awaitしないといけないのです。 当然、その関数自体も async にして……となります。

これが、後で別の問題を起こすのですが、それはまた別途。

イベントハンドラと非同期

イベントハンドラは非同期にすること自体は特に問題ないとAIはいうのですが、実は問題があるケースがあります。 メニューから呼び出されるイベントハンドラは非同期だとダメなのです。 でも、メニューから呼び出す機能って、設定だったりヘルプだったり、ダイアログを呼び出したりするものが少なからずあって、非同期にせざるを得なかったりするのです。

じゃあどうするのか?

非同期のイベントハンドラを呼び出すためだけの非同期でないイベントハンドラを作って、そこから非同期のイベントハンドラを呼ぶようにして、非同期じゃないハンドラの方をメニューに登録します。

モデルビューとリアクティブUI

設定値を変更したときに、それをメインの画面に反映させるために、様々な方法があるのですが、AIに聞くと、AvaloniaUIではモデルビューとリアクティブUIを使うことで美しく実装できるし簡単だよ、というので、じゃあそれで、っていうことでコードをがさがさと書き換えたのですが、これが恐ろしいどっぱまりの始まりでした。

Qtでも WPFでも、設定ダイアログのコードビハインドでこのあたりの処理をしていたんですが、設定値は ModelView をつかって、設定画面の DataContext としてマッピングして、サービスを介在させて、親画面に変更が伝わるようにすればいい、ということらしいのですが、そのようにやったとたんにビルドすら通らなくなりました。

結論から言うと、Avalonia.ReactiveUI のパッケージと、その依存しているパッケージの間に不整合があって、結果、ビルドできなかったり例外はいたりと様々な問題を引き起こしていました。 AIは、あれなおせ、これなおせ、といっていろいろ示唆をくれるのですが、こういう状況ではしばしばループに陥って、正解にたどり着けなくなるので、こっちからも「こうじゃないの?ああじゃないの?」というのを入れていかないといけません。 結局のところ、Avalonia.ReactiveUIは捨てて、ReactiveUIの機能だけを使って、残りは手で実装しよう、とかいうことになりまして、実はここの部分はまだ進行中なのです。

Copilotのいうままにパッケージをあれこれ追加していたら、Geminiが、ReactiveUIとか直接関係ないやつはパッケージマネージャに任せて指定すんな!っていうのでそうしたらあっさりビルド問題は解決しました。

あとは、構造を整理して、きちんと動くようになりました。

スクロールバーが出ない?

ハイハイスクールアドベンチャーでは、画面の下部にメッセージビューを置いて、長くなったらスクロールバーが出るようにしているのですが、これがメッセージがいくら長くなっても出てこないのです。

おかしいでしょう?

結論から言うと StackPanelを使っていると、ログビュワーの縦方向が無限に長い扱いになるらしくて、スクロール必要にならない。 なので、StackPanelじゃなくて Gridを使いましょうという結論なわけです。 わからんよ、そんなの!

色味違わない?

色コードの扱いの違いなのか、同じロジックで実装しているのに、他の環境と微妙に色味が違っている部分があるような気がします。

上がAvaloniaUI版で下がWPF版です。 ほかのプラットフォームでも下と似た色味になるので、AvaloniaUIの一部の色の処理が少し違っているのかなあと思っています。

カスタムスタイルにテーマは適用できない?

設定ダイアログで、チェックボックスはスライドスイッチに、ラジオボタンは押しボタン風にしたいわけです。 環境によってはそもそもサポートされていたり、カスタムコードを書いたり、あるいはスタイルを書き換えるだけで実現できたりと様々ですが、AvaloniaUIではカスタムスタイルを作って適用するだけで外観を変更することができます。

色はテーマに追随してほしいので、AIにそのように頼むと「簡単です」とかいいながら、FluentTheme と同じになります、とかいってカスタムスタイルに色設定を埋め込んでいくのですがこれが全くと言っていいほど効かない。

即値を指定すれば反映されるから、テーマカラーをうまく拾えないのが原因なのは明らかなので、自前でブラシを定義して、テーマによって同じ名前に違う色を割り当ててくれればそれでいいよっていっているのに、AIが頑固にテーマカラーを適用する方法を模索し続けるものだからもう大変。

最終的にAIが折れて、自前のブラシをテーマごとに用意する方向で実装となりました。

        <Style Selector=":is(Control):light">
            <Style.Resources>
                <SolidColorBrush x:Key="PushButton.Background.Normal" Color="#EBEBEB"/>
                <SolidColorBrush x:Key="PushButton.Border.Normal" Color="#DADADA"/>
                <SolidColorBrush x:Key="PushButton.Foreground.Normal" Color="#7F7F7F"/>
                <SolidColorBrush x:Key="PushButton.Background.Checked" Color="#0078D4"/>
                <SolidColorBrush x:Key="PushButton.Foreground.Checked" Color="White"/>
                <SolidColorBrush x:Key="PushButton.Background.PointerOver" Color="#F4F4F4"/>
            </Style.Resources>
        </Style>
 
        <Style Selector=":is(Control):dark">
            <Style.Resources>
                <SolidColorBrush x:Key="PushButton.Background.Normal" Color="#2D2D2D"/>
                <SolidColorBrush x:Key="PushButton.Border.Normal" Color="#4D4D4D"/>
                <SolidColorBrush x:Key="PushButton.Foreground.Normal" Color="White"/>
                <SolidColorBrush x:Key="PushButton.Background.Checked" Color="#0078D4"/>
                <SolidColorBrush x:Key="PushButton.Foreground.Checked" Color="White"/>
                <SolidColorBrush x:Key="PushButton.Background.PointerOver" Color="#383838"/>
            </Style.Resources>
        </Style>

これでlight用、dark用の色を分けられるので、このブラシを使ってカスタムスタイルを作ればうまくいきます。

CustomStyles.axaml
<Styles xmlns="https://github.com/avaloniaui"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
 
    <!-- スイッチスタイル -->
    <Style Selector="ToggleButton.ToggleSwitch">
      <Setter Property="Template">
        <ControlTemplate>
          <Border Width="50" Height="25" CornerRadius="12.5"
                  Background="{TemplateBinding Background}">
            <Grid>
              <!-- サム -->
              <Border x:Name="PART_Knob"
                      Width="21" Height="21"
                      CornerRadius="10.5"
                      Background="White"
                      HorizontalAlignment="Left"
                      Margin="2"/>
            </Grid>
          </Border>
        </ControlTemplate>
      </Setter>
      <Setter Property="Background" Value="LightGray"/>
    </Style>
 
    <!-- チェック時の見た目 -->
    <Style Selector="ToggleButton.ToggleSwitch:checked">
      <Setter Property="Background" Value="MediumSeaGreen"/>
    </Style>
    <Style Selector="ToggleButton.ToggleSwitch:checked /template/ Border#PART_Knob">
      <Setter Property="HorizontalAlignment" Value="Right"/>
    </Style>
 
 
    <!-- 押しボタン風 RadioButton -->
    <Style Selector="RadioButton.PushButtonStyle">
        <Setter Property="Template">
            <ControlTemplate>
                <Border x:Name="PART_Border"
                        Padding="8"
                        Background="{TemplateBinding Background}"
                        BorderBrush="{TemplateBinding BorderBrush}"
                        BorderThickness="1"
                        CornerRadius="4">
                    <ContentPresenter Content="{TemplateBinding Content}"
                                      ContentTemplate="{TemplateBinding ContentTemplate}"
                                      Foreground="{TemplateBinding Foreground}" 
                                      HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
                                      VerticalAlignment="{TemplateBinding VerticalContentAlignment}"
                                      RecognizesAccessKey="True"/>
                </Border>
            </ControlTemplate>
    </Setter>
 
    <Setter Property="Foreground" Value="{StaticResource PushButton.Foreground.Normal}"/>
        <Setter Property="Background" Value="{StaticResource PushButton.Background.Normal}"/>
        <Setter Property="BorderBrush" Value="{StaticResource PushButton.Border.Normal}"/>
    </Style>
 
    <Style Selector="RadioButton.PushButtonStyle:pointerover">
        <Setter Property="Background" Value="{StaticResource PushButton.Background.PointerOver}"/>
        <Setter Property="BorderBrush" Value="{StaticResource PushButton.Border.Normal}"/>
    </Style>
 
    <Style Selector="RadioButton.PushButtonStyle:pressed">
        <Setter Property="Background" Value="{StaticResource PushButton.Background.PointerOver}"/>
    </Style>
 
    <Style Selector="RadioButton.PushButtonStyle:checked">
        <Setter Property="Background" Value="{StaticResource PushButton.Background.Checked}"/>
        <Setter Property="BorderBrush" Value="{StaticResource PushButton.Background.Checked}"/>
        <Setter Property="Foreground" Value="{StaticResource PushButton.Foreground.Checked}"/>
    </Style>
 
    <Style Selector="RadioButton.PushButtonStyle:checked:pointerover">
        <Setter Property="Background" Value="{StaticResource PushButton.Background.Checked}"/>
        <Setter Property="BorderBrush" Value="{StaticResource PushButton.Background.Checked}"/>
    </Style>
 
    <Style Selector="RadioButton.PushButtonStyle:disabled">
        <Setter Property="Background" Value="{DynamicResource ThemeControlLowBrush}"/>
        <Setter Property="BorderBrush" Value="{DynamicResource ThemeBorderLowBrush}"/>
        <Setter Property="Foreground" Value="{DynamicResource ThemeForegroundDisabledBrush}"/>
    </Style>
</Styles>

AvaloniaUIの良いところ

テーマの統一的検出機能

WPFやQtの6.8以前はレジストリをじか読みしたり、GNOMEの設定ファイルを読んだりしないとシステムのテーマを取り出せないというめんどくささがあったのですが、AvaloniaUIは、機種に依存しない取得方法と設定方法の両方を提供しています。

また、独自テーマを作る際にも、XAMLのような辞書方式や、Qtのスタイルシート方式のどちらでもできるらしく、柔軟性に優れています。

ハイハイスクールアドベンチャー_avaloniaui版.1760655680.txt.gz · 最終更新: by araki