1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ゲームの行動を「依頼」で書くと何が変わるのか ― Mana開発メモ

1
Posted at

※本記事は、以前の記事を削除のうえ再投稿したものです。旧記事にコメントをくださった皆さま、ありがとうございました。コメントが消えてしまい申し訳ありません。いただいたご意見は今回の内容に活かしています。

ゲームのキャラクターが、何かをしている途中で別の行動に割り込まれる。そして、割り込んだ行動が終わると、元の行動の続きを始める。

ゲームではよく見かける振る舞いですが、プログラムにしようとすると意外と面倒です。

そんな処理をもう少し自然に書けないかと考え、個人で「Mana」というスクリプト言語を作ってきました。

2026年にようやくバージョン1.0という区切りを付けたので、これまでに考えたことや、設計で悩んだことを何回かに分けて書いてみようと思います。

言語の使い方を説明するというより、ゲーム向けのスクリプト言語を設計する過程で、何を選び、何を諦め、どこで迷ったのかという開発メモです。

まずは少し動かしてみる

文章だけで仕組みを説明するより、実際に動かした方が早いかもしれません。

Mana Playground ― ブラウザで試す

開発環境を用意せず、ブラウザ上でコードを書き換えて実行できます。

サンプルは3種類あります。

  • Hello:最初のプログラム。文字列を書き換えるだけでも動作を確認できます。
  • Actor:ランプが自分の明るさを覚えていて、プレイヤーから頼まれるたびに明るくなります。
  • Request:門番が会話している途中で警報が割り込み、警報が終わると会話の続きに戻ります。

特に「Request」は、Manaで実現したかった振る舞いが分かりやすいサンプルです。

会話と警報には、それぞれ優先度が設定されています。

const int kTalk = 10;
const int kAlarm = 100;

警報の優先度を 100 から 5 に変えると、実行順序が変わります。

また、request と await を入れ替えると、相手の行動が終わるのを待つかどうかが変わります。

難しい文法を覚える必要はありません。少し書き換えて、結果がどう変わるかを眺めるだけでも、言語の考え方が見えてくると思います。

なぜ行動を「依頼」という形にしたのか

ここからは、こうした仕組みを作った理由について書いてみます。

Manaでは、ゲームの登場人物や進行役を Actor、その行動を Action として扱います。

そして、他のActorに何かをしてもらいたいときには、直接Actionを呼び出すのではなく、request で依頼します。

request(3, Guard->move());

これは「Guardに、優先度3でmoveを実行してほしい」と頼んでいます。

最初に考えたかったのは、「処理を呼び出すこと」と「相手に行動してもらうこと」は、少し違うのではないかという点でした。

通常の同期的な関数呼び出しでは、呼び出した処理が終わるまで、その関数を呼び出した側へ制御は戻りません。

しかしゲームの行動は、何フレームもかけて進むことがあります。

NPCが目的地まで歩く。扉がゆっくり開く。プレイヤーの入力を待ちながら会話を進める。

さらに厄介なのは、その途中で別の行動が割り込んでくることです。

例えば、巡回中のNPCが敵を発見して戦闘へ移り、攻撃を受けたらダメージリアクションを再生する。そして戦闘が終わったら、巡回へ戻る。

こうした処理は、もちろんステートマシンやコルーチンでも実現できます。

ただ、行動が増えるほど「何をしているか」だけでなく、「どこから割り込まれたか」「何が終わったら、どこへ戻るか」という情報の管理が必要になります。

これを毎回ゲームロジックに書くのではなく、スクリプトの実行環境が管理してくれたら、行動そのものの記述に集中できるのではないかと考えました。

Actionの実行状態をActorに持たせる

Manaの request は、相手のActionをその場で直接実行する命令ではありません。

相手のActorへ、実行してほしい行動を登録します。

request(10, NPC->talk());
print("continue\n");

この例では、NPCに会話を依頼した後、依頼元はそのまま次の処理へ進みます。

NPCが実際にいつ会話を始めるかは、NPC自身の実行状態や優先度によって決まります。

依頼する側は、NPCが今何をしているか、どの処理から再開するかを細かく知る必要がありません。

このあたりはActor Modelの考え方から着想を得ていますが、Manaは一般的なActor Modelをそのまま実装したものではありません。

ゲームの行動に必要な割り込みや再開を扱うため、優先度ごとにActionの実行状態を管理する仕組みを持たせています。

優先度による割り込みと再開

例えば、見張りが優先度1で巡回しているところへ、優先度5のダメージリアクションが依頼されたとします。

優先度1 : patrol 実行中
              │
              ▼
優先度5 : damage が割り込む
              │
              ▼
          damage 終了
              │
              ▼
優先度1 : patrol を途中から再開

割り込まれたpatrolの実行状態は保持され、damageが終わると再開されます。

そのため、スクリプト側に「巡回に戻る」という処理を毎回書かなくても済みます。

門番のサンプルも、この仕組みで動いています。

action talk()
{
    print("Guard: Halt! Who goes there?\n");
    request(kAlarm, self->alarm());
    print("Guard: False alarm. You may pass.\n");
}

talk の実行中に、自分自身へ alarm を依頼しています。

警報の優先度が高ければ、会話に割り込んで警報が実行されます。優先度が低ければ、現在の会話を先に進めます。

この仕組みによって、ゲームで頻繁に必要になる割り込みと再開を共通化できました。

ただし、優先度を導入したことで、別の問題も生まれました。

優先度を「依頼の枠」にしてよかったのか

現在のManaでは、一つのActorの同じ優先度には、一つの依頼しか登録できません。

これは、優先度ごとにActionの実行状態を管理しているためです。

例えば、優先度3にすでにActionが登録されている場合、同じ優先度3への新しい依頼は受け付けられません。

つまり、同じ優先度へ複数の依頼を積んで順番に実行する、一般的なキューとは異なります。

実行状態の管理は整理しやすくなりましたが、依頼を順番待ちさせたい用途には向いていません。

ゲームで複数のイベントが同じNPCに話しかけようとする状況を考えると、先着順に会話を処理したい場合もあるはずです。

では、同じ優先度に複数の依頼を登録できるようにするべきなのでしょうか。

そうすると今度は、依頼の順序、キャンセル、古くなった依頼の扱いなど、新しい問題が出てきます。

単純な優先度の仕組みだったはずが、用途を広げようとすると次々に仕様が増えてしまいます。

今の設計が最適なのか、まだ結論は出ていません。

依頼した後に、待ちたいこともある

もう一つ考えなければならなかったのは、依頼した後の待機です。

request は相手の終了を待たずに先へ進みます。

これはキャラクター同士が独立して動くには便利ですが、イベントの進行では都合が悪いこともあります。

例えば、

「門番と会話する → 門を開く → 旅人が入る」

という順番は守りたいところです。

そこでManaには、依頼したActionの終了まで待つ await も用意しました。

await(10, Guard->talk());
await(10, Gate->open());
request(10, Traveler->enter());

これなら会話が終わってから門を開け、その後で旅人に入場を依頼できます。

重要なのは、待っている間もゲーム全体が停止するわけではないことです。

待つのは、そのActionを実行しているActorだけです。他のActorは引き続き動けます。

依頼と待機を組み合わせることで、複数のキャラクターが独立して動く処理と、順番を守る必要があるイベント進行を、同じ実行モデルの中で扱えるようにしました。

ただ、ここでも悩みが残っています。

依頼が断られたことを、どう知らせるべきか

現在のManaでは、request が受け付けられたかどうかを、戻り値として直接取得することはできません。

例えば、相手のActorが停止している場合や、同じ優先度にすでに依頼が登録されている場合など、依頼が受け付けられないことがあります。

ところが依頼元のスクリプトからは、その結果を十分に扱えません。

依頼が失敗したときに別の行動を選びたい場合、これは不便です。

では、request に結果を返すようにすればよいのでしょうか。

ここで少し面倒なことになります。

依頼が受け付けられたことと、Actionが実際に開始されたこと、そしてActionが正常に終了したことは、すべて違う意味だからです。

どの段階の結果を返すべきなのか。

また、Actionに引数や戻り値を持たせる場合、後から実行されたり、途中で割り込まれたりするActionと、値の受け渡しをどう組み合わせるべきなのか。

普通の関数なら比較的自然に扱えることも、実行のタイミングを相手に任せる仕組みにすると、途端に難しくなることがあります。

機能を増やせば便利になりますが、それだけ言語の実行モデルは複雑になります。

どこまでを言語の仕事にするべきか

振り返ってみると、request を作ったのは、関数呼び出しそのものが悪いと考えたからではありません。

ゲームの行動には、時間をかけて進み、途中で割り込まれ、後から再開するという性質があります。

それを個々のゲームロジックで管理するのではなく、言語の実行環境に持たせたかったのです。

その結果、ActorがActionを持ち、他のActorからの依頼を受けて実行する形になりました。

ただし、実行環境で面倒を見る範囲を増やすほど、言語そのものの仕様は複雑になります。

優先度による割り込みを用意すると、依頼のキューをどうするかが問題になる。

非同期で行動を進められるようにすると、依頼結果をどう受け取るかが問題になる。

Actionに値を渡せるようにすると、その値の寿命や、いつ結果を返すかを考えなければならない。

使う側のプログラムを簡単にするための仕組みが、言語の実装や仕様を複雑にしていく。

自作言語を開発していて、難しいと感じる部分の一つです。

Manaではまだ答えの出ていないことも多くあります。

Actionと普通の関数をどこで分けるか。ゲームAIとの関係をどう整理するか。C++側とスクリプト側にどこまで責任を持たせるか。

今後も、実装した機能だけでなく、採用しなかった案や、設計を決めきれずにいる部分も含めて書いていこうと思います。

1
0
6

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?