ゲームの状態、場面、画面 #
多くのゲームに共通した状態、画面がある。
- 起動画面(ロゴ)
- タイトル画面
- ゲーム画面(戦闘中、対戦中など)
- 設定画面
- ローディング画面(リソースのロードを誤魔化す画面)
まあ、あったりなかったりする場合もあるが。
自作ゲームでもこういったゲームの状態を実装する必要が生じたので考えをまとめておくことにした。
Rust言語実装のゲームプログラムで状態をどう表現、管理するか #
winitのキーボード&マウス入力、Redraw Request等をゲームがハンドルしなければいけない状況と仮定する。
考察だけで済まそうと思ったが、今回実際に書いて何度か書き直したので一度デモを書いて比較してみることにした。
enumで状態を表現する #
struct InGameStateContext {
a: u32,
}
impl InGameStateContext {
pub fn handle_update_request(&mut self) {
self.a += 1;
}
pub fn handle_redraw_request(&mut self, dummy_external_context: &mut DummyExternalContext) {
// render stuffs
}
}
// ゲームの状態
enum GameState {
Initial,
Title,
InGame(InGameStateContext),
}
// winitのイベントを変換したものと仮定
enum Event {
KeyboardInput(u32),
WindowResize((u16, u16)), // new size
RedrawRequest,
UpdateRequest,
}
// グラフィックデバイスなどの外側のデータを状態のハンドラ内で利用できるようにするため
#[derive(Default)]
struct DummyExternalContext {
buffer: Vec<u8>,
string: String,
flag: bool,
}
fn main() {
let mut dummy_external_context = DummyExternalContext::default();
let dummy_events = vec![
Event::UpdateRequest,
Event::RedrawRequest,
Event::KeyboardInput(0x1),
Event::WindowResize((0, 0)),
Event::WindowResize((400, 800)),
Event::RedrawRequest,
];
let mut current_game_state = GameState::Initial;
// メインループ
loop {
// winit のイベントが次々生成されるイメージ
for dummy_event in &dummy_events {
// 現在のゲームの状態に応じてイベントを処理する
match &mut current_game_state {
GameState::Initial => {
println!("Initial handler");
current_game_state = GameState::InGame(InGameStateContext { a: 22 });
// InGame状態を初期化して`遷移`する
}
GameState::Title => {
println!("Title handler");
match dummy_event {
_ => {}
}
}
GameState::InGame(in_game_state_context) => {
println!("InGame handler");
match dummy_event {
Event::UpdateRequest => {
in_game_state_context.handle_update_request();
}
Event::RedrawRequest => {
in_game_state_context
.handle_redraw_request(&mut dummy_external_context); // '外側'のデータを扱う
}
_ => {}
}
}
}
}
panic!("Demo end");
}
}
実行結果
Initial handler
InGame handler
InGame handler
InGame handler
InGame handler
InGame handler
thread 'main' panicked at src/main.rs:84:9:
Demo end
これでも動作するがいくつかの課題が残る。
- 状態の初期化の実装が必要であり、さらに複雑になる
- 状態を抜けるときの処理も同様
リソースのロードや生成、解放だ。
もうここらへんでステートマシンを使うしかないと思うだろう。 ここまでは実装前から読めたので自分はstatigというライブラリを利用した。
ステートマシンを利用する #
Rust製のステートマシンライブラリは検索すると4つぐらい出てくるが、その中で2つぐらい使ったり、exampleを漁ったりしたところ、statigが一番良い(目的に合っている)と考えたのでそちらを利用することにした。
statig Hierarchical state machines for designing event-driven systems : 🔗https://github.com/mdeloof/statig
提供されているマクロなしでも利用できる。
最初はマクロ無しでやったがやっぱりマクロでやったほうが楽だと思った。
statigを利用すると先程書いたコードと同じパターンをもっと可読性、保守性が高いコードで書くことができる。
もちろん外部データを状態のハンドラに渡すことも可能だ。
後から必要になって設計崩壊したと思い心臓が止まりかけたが救われた。
Rustを勉強していてここが一番難しいと感じる。
作ろうとしているものに対しての理解が深くないといけない。
先程のenumのコードでの課題であった、状態に入る、出るときの関数を定義することもできる。(マクロなしだとここの定義の仕方が分からなかった。)
自分のコードでは状態に入るときの関数で状態を初期化している。
別の場所で初期化しておいて外側から&mutをハンドラに渡すのでも良い。
statigのContextという機能で実現できる。
状態遷移時のコールバック、イベントをdispatchした時のコールバックもあるのでデバッグも楽。
Box<dyn GameState>
はどうなのか?
#
とまあステートマシンでいいやと思ったが、Box<dyn GameState>
みたいにゲームの状態のTraitを定義してdynamic dispatchで共通したコードで状態のハンドラを呼び出すというのはどうだろうか?
コードがより単純になって、後々のデータの取り回しなどの拡張性も上がるようであれば検討に入るだろう。
デモを実装したらこんな感じになった。
use std::cell::OnceCell;
trait GameState {
// 実際にはResultを返すようにしたほうが良い
fn init(&mut self);
fn handle_redraw_request(&mut self, draw_request: &Event);
}
struct InitialGameState {}
impl InitialGameState {
fn new() -> Self {
Self {}
}
}
impl GameState for InitialGameState {
fn init(&mut self) {}
fn handle_redraw_request(&mut self, draw_request: &Event) {}
}
struct TitleState {
logo_string: String,
}
impl GameState for TitleState {
fn init(&mut self) {}
fn handle_redraw_request(&mut self, draw_request: &Event) {}
}
#[derive(Debug)]
struct InGameStateImpl {
data: Vec<u8>,
}
struct InGameState {
data: OnceCell<InGameStateImpl>,
opt: Option<u8>,
}
impl InGameState {
fn new() -> Self {
// この時点ではデータ、リソースを生成しない
Self {
data: OnceCell::new(),
opt: None,
}
}
}
impl GameState for InGameState {
fn init(&mut self) {
// ここで初めてデータを生成する
self.opt = Some(22);
self.data
.set(InGameStateImpl {
data: vec![1, 2, 3, 4, 5],
})
.unwrap(); // 実際にはResultを返したほうが良い
}
fn handle_redraw_request(&mut self, draw_request: &Event) {
// caller側でEventの値を抜き取ればこのmatchは回避できる
match draw_request {
Event::RedrawRequest => {} // Eventに値があれば、ここで取得できる
_ => {
return;
}
}
// 描画
}
}
enum Event {
KeyboardInput(u32),
WindowResize((u16, u16)), // new size
RedrawRequest,
UpdateRequest,
}
#[derive(Default)]
struct DummyExternalContext {
buffer: Vec<u8>,
string: String,
flag: bool,
}
struct Game {
current_game_state: Box<dyn GameState>,
}
impl Game {}
fn main() {
// winitのイベントを変換したものと仮定
let dummy_events = vec![
Event::UpdateRequest,
Event::RedrawRequest,
Event::KeyboardInput(0x1),
Event::WindowResize((0, 0)),
Event::WindowResize((400, 800)),
Event::RedrawRequest,
];
let current_game_state: Box<dyn GameState> = Box::new(InitialGameState::new());
let mut game = Game { current_game_state };
// メインループ
loop {
// winit のイベントが次々生成されるイメージ
for dummy_event in &dummy_events {
// 現在のゲームの状態に応じてイベントを処理する
match dummy_event {
Event::RedrawRequest => {
// ここでenumのvariantに値があれば取得して関数に渡せる(callee側でeventをmatchしなくて良い)
// デモなのでそのまま渡す
game.current_game_state.handle_redraw_request(&dummy_event);
}
_ => {}
}
}
panic!("Demo end");
}
}
Trait内でpub fn new() -> Self
を定義することはできない。(🔗The Rust Reference: Object Safety)
とりあえずこちらでも動作しそうだ。
拡張性はステートマシンより高く感じる。
ハンドラに引数で外部のデータを回すのも難しくない。
状態の遷移は実装していないが、実装するとしたらGameState Traitにfn transition()-> Option<Box<dyn GameState>>
のような関数を追加して、handle_update()
の次に呼んで、Someであれば現在の状態をそれと置き換えるのがひとつのやり方だと思う。
バックグラウンドで別の状態のリソースを読み込んだりとか複雑なことをするのであればこの方法の拡張性の高さが生きると思う。
enum_dispatch #
dynamic dispatchを回避できるenum_dispatch(🔗https://docs.rs/enum_dispatch/latest/enum_dispatch/)というライブラリがある。
導入を検討してみても良いかもしれない。
dynamic dispatchの代償については
🔗What are the actual runtime performance costs of dynamic dispatch?
🔗How much slower is a Dynamic Dispatch really?
を参照。
比較的Hotなゲームのループ内でどれくらいdynamic dispatchが影響を与えるのだろうか。
実際の開発現場ではどうしているのか気になる。
1frame内にに1000回ぐらい呼ぶなら実装を考え直したほうが良いらしい。
PCよりパワーのない環境の話かもしれないが。
まあ自分が作るぐらいのゲームであれば大した影響はないと思う。
そもそもC++でもRustでもVecとか普通に使っている時点でメモリの確保をまとめて行えなくなるはずなのでそっちのほうが影響でかそうだし。
そこらへんもそのうち勉強したい。
結論&感想 #
ステートマシンかBox<dyn State>
が良いと考える。
比較的シンプルなプログラムであれば、ステートマシン、後々の拡張性を重視するのであればBox<dyn State>
を選ぶことにした。
ステートマシンは正しいが、コード量や拡張性がBox<dyn State>
に劣ると感じた。
追記
しばらく実装をすすめたがやはりステートマシンはきつい。
Boxに乗り換えるか迷うレベル。
結局その後Boxに乗り換えた。
ゲームエンジンを利用したことがないのでどんな感じなのか気になっている。
GodotかUnityを触ってみたい。
GodotはC++, C#で利用できるらしい。
C#はほとんど知らないのでそちらを習得するのもアリ。
Godot/C++でC++20, C++23を学習するのもアリだ。
Unityは手数料システムの改定騒動以外にも、UnityStore(?)での対応の悪さ、LGPL関連でも面倒そうだ。
どうしようか。
まあとりあえず今作っているものを完成させようと思う。
するか分からないが勉強にはなっている。