2
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?

ゲーム向けアクター指向スクリプト言語の設計メモ

2
Posted at

個人で制作しているスクリプト言語「Mana」を、ようやく 1.0.0 としてリリースできました。Web 上でも動作を確認できるので、まずはどんな言語なのか、気軽に触れていただけたらうれしいです。

ブラウザで実行する

Mana の特徴は、ゲームの振る舞いを「誰が」「何をする」に分けて書く、アクター指向の考え方です。キャラクターやイベントの進行役を Actor、その役が受け持つ行動を Action として定義し、互いに行動を依頼する形で処理を組み立てます。

なぜこの形にしたのか。1.0.0 のリリースを一つの区切りとして、設計で考えてきたことを振り返ってみようと思います。

なぜ Actor と Action なのか

Manaは、ゲームの振る舞いを書くために作っているスクリプト言語です。Manaのコードを見ると、最初に目に入るのは actor と action という二つの言葉だと思います。

この記事では、なぜ処理を「関数」や「クラス」ではなく、Actor と Action という形で分けることにしたのかを整理します。言語の使い方というより、設計の考え方のメモです。

ゲームの処理は「同時に進んでいるように見える」

ゲームの画面では、いろいろなことが同時に進んでいるように見えます。

  • NPCが会話している
  • 敵がプレイヤーを追いかけている
  • 扉がゆっくり開いている
  • イベントの進行役が「今どこまで進んだか」を覚えている

これらを一つの長い処理にまとめて書くこともできます。ただ、そうすると、ある処理の都合が別の処理へ入り込みやすくなります。例えば「扉の処理を直したら、会話のタイミングまでずれた」といったことが起きやすくなり、覚えておく状態も増えていきます。

そこでManaでは、最初から「誰が」「何をするか」を分けて書く形を選んでいます。

「誰が」を Actor、「何をする」を Action にする

Manaでは、状態を持つ実行の主体を Actor、その Actor ができる行動を Action と呼んでいます。

actor Guard
{
    bool mAlert;

    action watch()
    {
    }

    action move()
    {
    }
}

この例では、Guard(見張り)が Actor で、watch(見張る)と move(移動する)が Action です。mAlert は、見張りが警戒中かどうかを覚えておくための変数です。

設計するときは、まずゲーム上の役割を言葉にしてみます。

誰が?        何をする?
Guide         talk
Gate          open
Guard         move
Event         start

この表の左側を Actor、右側を Action にすると、コードの形がそのまま「役割の一覧」になります。後からコードを読み直した時に、「この行動は誰の担当か」を探しやすくしたい、というのが一番の理由です。

Actor はキャラクターに限らない

Actor という名前から、人や敵のようなキャラクターだけを表すように見えるかもしれません。ですが、Manaの Actor は「独立して行動を実行する単位」です。

  • 扉やスイッチなどの仕掛け
  • イベントの進行管理
  • 会話の管理
  • 演出の管理

のように、見た目を持たない役割も Actor にできます。大事なのは画面に映るかどうかではなく、「独立した役割と行動を持たせたいかどうか」だと考えています。

Actor は状態を持ち続ける

Actor は、Action が終わった後も残ります。そのため、Actor の変数に書いた値は、次に別の Action が動いた時にも使えます。

actor Door
{
    bool mOpened;

    action init()
    {
        mOpened = false;
    }

    action open()
    {
        if (mOpened)
            return;

        mOpened = true;
        print("Door opened\n");
    }
}

mOpened は「扉が開いているか」を覚える変数です。open が二回頼まれても、二回目は何もせずに終わります。

会話した回数、イベントの進み具合、NPCが警戒中かどうか。こうした「覚えておくこと」と「それを変える行動」を、同じまとまりの中に置けるのが Actor の良いところだと思っています。

Action と関数は何が違うのか

Manaには、Action とは別に普通の関数(Function)もあります。どちらも処理をまとめられますが、役割を分けています。

関数(Function) Action
計算や共通の処理 Actor の行動
普通に呼び出す 依頼(request)して動いてもらう
終わったら呼び出し元へ戻る 優先度によって割り込まれたり、後回しにされたりする
Actor の外でも使える Actor に属する

例えば「HPを0未満にしない計算」は関数、「敵がダメージを受ける」は Action、という分け方です。

int clampHp(int hp)
{
    if (hp < 0)
        return 0;
    return hp;
}

Action は、単なる「Actor のメンバー関数」ではありません。途中で別の行動に割り込まれ、後で続きから再開することがあります。この点は、なぜ関数呼び出しではなく request なのかで書いています。

分けすぎにも注意したい

何でも Actor に分ければよい、というわけでもないと考えています。Actor に向いているのは、例えば次のような処理です。

  • 自分の状態を持つ
  • 外から行動を頼まれる
  • 他の処理とは別のタイミングで動く
  • 急ぎの用事で割り込まれることがある
  • ゲーム上の役割として名前を付けやすい

一方で、単純な計算や、あちこちで使う共通処理まで Actor にすると、かえって流れが追いにくくなります。そうしたものは関数のままにしておく方が自然です。

反対に、一つの Action にイベント全体を詰め込むと、Actor に分けた意味が薄れてしまいます。

これをすべて直接書くより、次のように役割ごとに頼む形にした方が、Manaの考え方に合っています。

どこまで分けるかには、まだ決まった答えがありません。私自身も、短いイベントを組みながら「この粒度で読みやすいか」を確かめているところです。

なぜ関数呼び出しではなく request なのか

Manaでは、ある Actor が別の Actor に行動してもらう時、関数のように直接呼ぶのではなく request(リクエスト、依頼)を使います。

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

この一行は「見張り(Guard)に、優先度3で移動(move)を頼む」という意味です。この記事では、なぜわざわざ「依頼」という形にしたのかを整理します。

Actor と Action そのものについては、なぜ Actor と Action なのか にまとめています。

関数呼び出しは「終わるまで待つ」

普通の関数呼び出しでは、呼び出した処理が終わるまで、呼び出した側はその場で待ちます。計算のような短い処理なら、これで困ることはありません。

ところがゲームの行動は、何フレームもかけて進むことがよくあります。見張りが目的地まで歩く、NPCが会話を最後まで話す、扉がゆっくり開く。こうした行動を関数として呼ぶと、呼んだ側も一緒に止まってしまいます。

また、呼び出す側が相手の中身を細かく知っている必要も出てきます。「移動中にダメージを受けたらどうするか」「会話中に別の用事を頼まれたらどうするか」を、呼ぶ側が全部面倒を見ることになりがちです。

request は「相手の予定表に書き込む」

request は、相手の Action をその場で実行するものではありません。相手の Actor に「この行動をしてほしい」という予定を登録する操作です。

依頼した側は、相手の行動が終わるのを待たずに、自分の続きの処理へ進みます。

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

この例では、NPCに会話を頼んだ直後に「continue」が表示されます。会話が終わるのを待ってはいません。

依頼した側は「何をしてほしいか」だけを伝え、いつ・どう実行するかは頼まれた Actor の側で決まります。こうしておくと、頼む側が相手の内部事情を知らなくて済みます。

優先度で「割り込み」と「後回し」を決める

request の最初の数字は優先度(Priority)です。Manaでは、数字が大きいほど優先度が高くなります。

例えば、見張りが優先度1で巡回(patrol)している最中に、優先度5のダメージ(damage)が依頼されたとします。

優先度の高い damage が割り込み、patrol は途中の状態を保ったまま止まります。damage が終わると、patrol は止まった位置から続きを再開します。

逆に、今の行動より低い優先度で依頼された場合は、すぐには実行されません。今の行動が終わった後に実行できるよう、取っておかれます。

ゲームでは「歩いている → 敵を見つける → 戦う → ダメージを受ける → 戦いに戻る」のように、今の行動を中断してでも優先したいことがよくあります。これを大量の条件分岐だけで書くと、「元の行動のどこへ戻るか」の管理が大変になります。Manaでは、優先度ごとに実行の状態を持つことで、割り込まれる前の行動へ戻れるようにしています。

待ちたい時は、待つ命令を使う

とはいえ、イベントでは「この会話が終わってから扉を開けたい」のように、順番をはっきりさせたい場面もあります。そのために、待つための命令も用意しています。

命令 依頼した側の動き
request 依頼したら先へ進む
awaitStart 依頼した行動が実行できる段階になるまで待つ
awaitCompletion 依頼した行動が終わるまで待つ
join 相手の優先度が指定した値以下になるまで待つ

大事なのは、待っている間も止まるのは「待っている Actor だけ」だという点です。

ある Actor が待っていても、他の Actor はそのまま動けます。ゲーム全体が止まるわけではありません。

依頼は断られることもある

request は「依頼」なので、必ず引き受けてもらえるとは限りません。現在の実装では、例えば次のような場合に依頼は受け付けられません。

  • 相手の Actor が止まっている
  • 相手の Actor が依頼を断る状態にしている
  • 同じ優先度の行動がすでに登録されている
  • 指定した Action が存在しない

特に三つ目は、使い始めるとつまずきやすいところだと思います。今のManaでは、一つの Actor の同じ優先度には、一つの依頼しか持てません。同じ優先度へ次々に依頼を積んでおく「行列」のような仕組みではないのです。

そのため優先度は、「重要度」であると同時に、Actor の中の「予定を入れる枠」のような役割も持っています。数字を細かく使い分けるより、例えば次のように意味のある段階を決めて使う方が管理しやすいと考えています。

1 : 普段の行動
3 : 会話・イベント
5 : ダメージへの反応
8 : 強制的な演出

まだ気になっているところ

現在の文法では、request が受け付けられたかどうかを、戻り値として直接受け取ることはできません。依頼が断られたことに、スクリプトを書く側が気付きにくい場面もありそうです。

依頼の結果を返すようにするか、同じ優先度でも順番待ちできるようにするか。どちらも便利になる反面、Actor の動きが読みにくくなるおそれもあり、まだ決めきれていません。

ゲームAIと Actor Model

Mana の紹介では、「Actor Modelで書くスクリプト言語」と書いています。ただ、ManaはActor Modelという考え方を、そのまま忠実に実装した言語ではありません。

この記事では、Actor Modelのどこを借りて、どこをゲーム向けに変えたのかを、敵の行動(ゲームAI)を例に整理します。ここでいうゲームAIは、敵やNPCが「次に何をするか」を決める仕組みのことです。

Actor Modelとは

Actor Model(アクターモデル)は、プログラムを「アクター」という独立した単位に分けて考える方法です。アクターは自分の状態を持ち、他のアクターとはメッセージを送り合って協力します。相手の中身を直接書き換えるのではなく、「これをしてほしい」と伝えるのが特徴です。

この「独立した単位が、頼み合いながら動く」という形は、ゲームの登場人物によく合うと考えました。敵、NPC、扉、イベントの進行役が、それぞれ自分の都合で動きながら、必要な時だけ互いに声をかける。Manaの actor と request は、この考え方を借りています。

そのままではなく、ゲーム向けに変えたところ

一方で、Manaには一般的なActor Modelにはない、Mana独自の仕組みがいくつもあります。

  • Action(Actorが持つ名前付きの行動)
  • Request と優先度(Priority)
  • 優先度による割り込みと再開
  • グローバル変数
  • VM(実行エンジン)による、順番に少しずつ進める実行

そのため、Manaの actor は「学術的なActor Modelのアクターと完全に同じもの」ではなく、「ゲームの処理を独立した主体に分けるための、Mana独自のActor」と考える方が正確です。

特に大きな違いは、実行のしかたです。Actor Modelの説明では、アクターが本当に同時に動くことも多いのですが、ManaではActorごとにスレッド(CPU上で並行して動く処理の単位)を作りません。VMが各Actorを順番に少しずつ進めることで、ゲームから見ると同時に動いているように扱っています。Manaのドキュメントでは、これを「協調的な疑似並列実行」と呼んでいます。

ゲームは、毎フレーム決まった順番で更新する作りが多いと思います。本当の並列実行にすると、どの順番で動いたかによって結果が変わる、といった難しさも増えます。Manaでは、ゲームの更新ループと相性のよい、順番に進める形を選んでいます。

敵の行動を Action と優先度で書く

ゲームAIでよく出てくるのが、「普段は巡回しているが、攻撃されたら反応し、その後は巡回に戻る」という流れです。

Manaでは、これを優先度の違う Action として書けます。

const int kPatrolPriority = 1;
const int kDamagePriority = 5;

actor Enemy
{
    action main()
    {
        request(kPatrolPriority, self->patrol());
    }

    action patrol()
    {
        print("Enemy: Patrol A\n");
        yield();
        print("Enemy: Patrol B\n");
    }

    action damage()
    {
        print("Enemy: Ouch!\n");
    }
}

actor Player
{
    action main()
    {
        yield();
        request(kDamagePriority, Enemy->damage());
    }
}

self は「自分自身のActor」を表す言葉です。yield() は、そこで一度VMへ順番を譲り、続きは次の進行で行うための命令です。Playerの yield() は、Enemyが巡回を始めるまで少し待つために入れています。

Enemyが巡回(patrol)の途中にいる時、Playerから優先度5でダメージ(damage)が依頼されると、damage が割り込みます。damage が終わると、patrol は途中の位置から再開します。実行すると、次のように表示されます。

Enemy: Patrol A
Enemy: Ouch!
Enemy: Patrol B

巡回の「A」と「B」の間に、ダメージの反応が割り込んでいるのが分かります。

この例では画面上の敵を動かしているわけではなく、表示される文字で流れを確かめるだけのものです。

ステートマシンとの関係

ゲームAIでは、ステートマシン(状態ごとに処理を切り替える仕組み)もよく使われます。このサイトでも、ゲームを作る時におすすめしたいクラス - ステートマシン -で紹介しています。

ステートマシンは、「今どの状態にいるか」がはっきりしていて、とても扱いやすい仕組みです。ただ、「攻撃されたら一時的に反応して、元の状態の続きへ戻る」という割り込みが増えてくると、戻り先の管理が複雑になることがあります。

Manaでは、Actorが優先度ごとに実行の状態(どこまで進んだか)を持っています。

割り込みと再開をVMに任せられるので、一つの大きな状態機械だけで全部を表さなくても済むかもしれない、と考えています。

とはいえ、ステートマシンが不要になるわけではありません。「警戒中か」「戦闘中か」のような状態は、Actorの変数として持ち、Action の中で分岐する方が分かりやすい場面も多いはずです。私の考えでは、「割り込みと再開」は優先度に、「今の状態」は変数に、と役割を分けるのがよさそうだと思っています。

まだ難しいと感じているところ

Action と優先度で分けると、流れは追いやすくなる反面、「今、何が割り込んでいて、何が待たされているのか」が、コードを読むだけでは見えにくくなることがあります。

また、同じActorの同じ優先度には一つの依頼しか入らないので、優先度の段階をどう決めるかは、ゲームごとに考える必要があります。この点は なぜ関数呼び出しではなく request なのか でも触れています。

実行中のActorの状態を見える形にする道具があると、もっと調整しやすくなりそうです。

C++ゲームにVMを埋め込む

Mana のスクリプトは、単体のコマンドで動かすこともできますが、本来の目的はゲームの中で動かすことです。この記事では、C++で書いたゲームへManaの実行エンジン(VM)を組み込む時に、どんなことを考えて設計しているかを整理します。

VMは「Virtual Machine(仮想機械)」の略で、ここではManaのスクリプトを読み進めて実行する役のことです。

「言語」と「ゲームエンジン」の境界をどこに置くか

スクリプト言語をゲームに組み込む時、一番悩むのは「どこまでを言語側に持たせるか」だと思います。

絵を描く、音を鳴らす、物理計算をする、ファイルを読む。こうした機能を全部スクリプト言語に持たせると、言語がどんどん大きくなり、ゲームエンジン側の仕組みと二重になってしまいます。

Manaでは、描画・物理・サウンド・アセット管理のような機能は、ゲーム本体(C++側)に残すことにしています。Manaが受け持つのは、「いつ、誰が、何をするか」という行動の流れです。

部品を三つに分けている

Manaのリポジトリは、大きく三つの部品に分かれています。

部品 役割
compiler スクリプトを、実行用のデータ(Program Image)へ変換する
driver 端末から compiler を動かすための mana コマンド
runner 実行用データを読み込んで動かすVM。ヘッダーファイルだけで構成

ゲームに組み込むだけなら、runner ディレクトリを自分のプロジェクトへコピーすれば使えるようにしています。VMはヘッダーファイルだけで完結しているので、ライブラリを別にビルドしてリンクする手間がかかりません。

compiler もコマンドや画面表示から切り離してあるので、ゲームエディタのようなツールの中でスクリプトを変換したい場合は、compiler ごと組み込むこともできます。

VMはゲームループの「一部」として動かす

ゲームには、毎フレーム入力を読み、状態を更新し、画面を描く、というループがあります。ManaのVMは、このループの中で少しずつ進める部品として考えています。

auto vm = std::make_shared<mana::VM>();
vm->LoadProgram("game.mx");

// ゲームループの中で
while (running)
{
    UpdateInput();
    vm->Run(deltaSeconds);  // Manaを少し進める
    UpdateGame();
    Render();
}

UpdateInput などは説明のための仮の名前で、実際のゲーム側の処理に置き換わります。

Run() を一回呼ぶと、VMは登録されているActorを順番に少しずつ進めて戻ってきます。VMが勝手にループを回したり、別のスレッドで動き続けたりはしません。いつ進めるかはゲーム側が決められます。

このように「呼ばれた時だけ進む」形にしたのは、ゲーム側の都合(一時停止、スロー再生、ロード中など)に合わせやすくしたかったからです。

時間はゲーム側から渡せるようにした

Manaには、スクリプトの中で指定した秒数だけ待つ delay という関数があります。この「秒数」をどう数えるかも、組み込みでは大事な点です。

Run(deltaSeconds) のように、前回からの経過秒数をゲーム側から渡すと、VMはその時間を使って待ち時間を進めます。

  • ポーズ中は Run(0.0) にすれば、命令は進めても時間は進まない
  • 倍速で動かしたい時は、経過秒数を大きくして渡す

引数なしの Run() もあり、こちらはVMが自分で時計を見て経過時間を測ります。ただ、ポーズや倍速を扱うなら、ゲーム側から時間を渡す形に統一した方が分かりやすいと考えています。

以前の delay はフレーム数で待つ形でしたが、フレームの速さが環境によって変わると待ち時間もずれてしまいます。現在は秒で指定する形に変えています(以前の形とは互換性がありません)。

ゲームの機能は「登録した関数」で呼ぶ

Manaからゲームの機能を使うには、ネイティブ関数という仕組みを使います。Mana側では native を付けて宣言だけを書き、中身はC++側で用意します。

native int add(int a, int b);

actor Main
{
    action main()
    {
        int result = add(10, 20);
        print("%d\n", result);
    }
}

C++側では、同じ名前で関数を登録します。

void Add(const std::shared_ptr<mana::Actor>& actor, void*)
{
    const int32_t a = actor->GetParameterInteger(0);
    const int32_t b = actor->GetParameterInteger(1);
    actor->SetReturnInteger(a + b);
}

vm->RegisterFunction("add", &Add);

C++側の関数は、どのActorから呼ばれたかを受け取り、そのActorから引数を取り出して、戻り値をセットします。すべてのネイティブ関数がこの同じ形なので、登録する仕組みも一つで済みます。

その代わり、Mana側の宣言とC++側で読み取る引数の型や順番は、書く人が一致させる必要があります。ここがずれると正しく動かないので、組み込み時の約束ごととして気を付けたいところです。

C++のクラスのメンバー関数も、RegisterMemberFunction で直接登録できます。

auto bridge = std::make_shared<GameBridge>();
vm->RegisterMemberFunction("playSound", bridge, &GameBridge::PlaySound);

shared_ptr で渡した場合、VMは内部で弱い参照(相手が消えたことを検出できる参照)として持ちます。登録したオブジェクトが先に消えていても、呼び出し時にエラーを記録するだけで済むようにしています。

C++側からActorへ依頼することもできる

反対に、ゲーム側からManaのActorへ行動を依頼することもできます。

vm->Request(10, "Game::NPC::Guide", "talk", nullptr);

例えば、プレイヤーがNPCに話しかけた瞬間に、ゲーム側から Guide の talk を依頼する、といった使い方を想定しています。

組み込みで苦労したところ

組み込み用の部品では、「相手の環境を壊さない」ことが大事だと感じています。

最近の変更では、runner のヘッダーから Windows 用のヘッダー(windows.h)を読み込まないようにしました。このヘッダーは min や max といった名前のマクロを定義するため、組み込み先のゲームエンジン側のヘッダーとぶつかることがあったからです。必要な関数だけを自分で宣言する形に変え、組み込み先がどの順番でヘッダーを読んでも困らないようにしています。

ヘッダーだけで使える形は手軽な反面、組み込み先のコードと同じ場所でコンパイルされるので、こうした衝突の影響を受けやすくなります。手軽さと衝突の起きにくさの両方を、少しずつ整えているところです。

スクリプトのエラーでゲーム本体を落とさない設計

スクリプト言語をゲームに組み込むと、スクリプトの書き間違いでゲーム全体が止まってしまう、ということが起こり得ます。調整のために何度も書き換えるスクリプトだからこそ、失敗した時の影響はできるだけ小さくしたいところです。

Mana がエラーをどう分けて扱っているか、その考え方を整理します。組み込みの基本は C++ゲームにVMを埋め込む にまとめています。

エラーを一種類として扱わない

「エラー」とひとことで言っても、起きる場所も、直すべき人も違います。Manaでは、エラーを次の四つに分けて考えています。

いつ起きるか 例 誰が直すか
変換(コンパイル)の時 文法の書き間違い スクリプトを書く人
実行用データを読み込む時 ファイルが壊れている、バージョンが合わない 組み込む人
スクリプトの実行中 0で割った、配列の範囲外を読んだ スクリプトを書く人
Mana内部の食い違い 本来起きないはずの状態になった Manaや組み込み側の開発者

分けているのは、場所ごとに「どこまで巻き戻して立て直すか」と「誰に知らせるか」が違うからです。

コンパイルのエラーは「結果」として返す

スクリプトを変換する mana::Compile() は、失敗した時に例外を投げるのではなく、成功したかどうかと、エラーの一覧を結果として返します。

const mana::CompileResult result = mana::Compile(options);

if (!result.mSucceeded)
{
    for (const auto& diagnostic : result.mDiagnostics)
        ShowError(diagnostic);
}

ShowError は説明用の仮の名前で、実際にはエディタの画面などへ表示する処理になります。

コンパイラの内部で予想外の例外が起きた場合も、Compile() の中で受け止めて、エラーの一覧に入れて返します。ゲームエディタのようなツールに組み込んだ時、書きかけのスクリプトを変換するたびにツールが落ちてしまっては困るからです。

実行中のエラーは「そのActorだけ」止める

一番大事にしているのが、実行中のエラーの扱いです。

0で割る、配列の範囲外を読むなど、スクリプトの実行を続けられない問題が起きると、Manaはそれを「スクリプトのエラー」として扱います。そして、エラーを起こしたActorだけを止め、他のActorはそのまま動かし続けます。

ゲームの中では、多くのActorが同時に動いています。一つのNPCの会話スクリプトに書き間違いがあっただけで、敵もイベントも全部止まってしまうと、どこに問題があったのかも分かりにくくなります。

Actorごとに止めれば、「このActorだけ動かなくなった」という形で問題の場所が見えやすくなります。Actorに分けて書く設計は、エラーの影響範囲を区切るのにも役立つと考えています。

エラーの内容はゲーム側のログへ流す

Actorを止めた時、その理由はトレース(実行の記録)として出力されます。print() の出力や警告も同じ経路を通ります。

ゲームエンジンでは、普通の標準出力(端末への出力)が見えないこともあります。そこで、トレースの受け取り先をゲーム側で設定できるようにしています。

void OnTrace(
    void* userData,
    const mana::TraceLevel level,
    const char* message,
    const std::size_t length)
{
    // ゲームエンジン側のログへ送る
}

mana::SetTraceHandler(&OnTrace, hostContext);

level には、情報(Info)、警告(Warning)、エラー(Error)の三段階が入ります。ゲーム側のログ画面で色を分けるなど、見やすい形にできます。

Mana自身の不具合は、別の道で知らせる

スクリプトの書き間違いではなく、Mana自身や組み込み側の不具合で「本来起きないはずの状態」になることもあります。これは、スクリプトを書く人が直せる問題ではありません。

そこで、この種類の問題は別の扱いにしています。

種類 意味 開発者向けの通知 Actor
スクリプトのエラー スクリプト側の実行時の誤り 呼ばれない そのActorを停止
内部の食い違い Manaや組み込み側の問題 呼ばれる 実行中ならそのActorを停止

内部の食い違いが起きた時は、ゲーム側で設定した「開発者向けの通知先」が呼ばれます。デバッガーで止めたり、クラッシュレポートへ送ったりするための入口です。

mana::SetFaultHandler(&OnFault, hostContext);

この通知は、処理の巻き戻しが始まる前に呼ばれます。問題が起きたその場でデバッガーを止めて、状態を調べられるようにするためです。

スクリプトを書く人に見せるエラーと、Manaを開発する人が調べるべき問題を分けておくと、エラーを見た人が「自分が直すものかどうか」を判断しやすくなると考えています。

読み込みの失敗は、組み込む側が受け止める

実行用データ(Program Image)の読み込みでは、ファイルが開けない、形式が違う、バージョンが合わない、といった問題を調べています。これらは例外として知らせるので、組み込む側で受け止めてもらう形です。

try
{
    vm->LoadProgram("event.mx");
}
catch (const std::exception& e)
{
    LogError(e.what());
    return false;
}

ここだけ扱いが違うのは、正直なところ少し分かりにくいかもしれません。実行する前に確かめたい場合は、例外ではなく成功・失敗を返す mana::ProgramImage で先に検査する方法も用意しています。エディタやアセットの取り込み処理では、こちらを使うと扱いやすいと思います。

止めた後をどうするかは、まだ課題

今の仕組みは「止める範囲を小さくする」ところまでです。C++側にはActorをやり直すための関数もありますが、止まったActorをいつ、どう立て直すかは、ゲーム側に任せています。

例えば、止まったActorを自動で初期状態からやり直すのか、開発中だけ画面に警告を出すのか。ゲームによって望ましい形が違いそうなので、どこまでをMana側で用意するかは、使いながら考えていきたいと思っています。

Action に引数と戻り値を持たせるべきか

Mana の Action には、今のところ引数も戻り値もありません。普通の関数なら当たり前にある機能なので、「なぜないのか」「付ければいいのでは」と思われるかもしれません。

この記事は、答えが出た話ではなく、今まさに迷っていることのメモです。Action に引数と戻り値を持たせると何がうれしく、何が難しくなるのかを整理してみます。

Action と request の基本は、 なぜ Actor と Action なのか と なぜ関数呼び出しではなく request なのか で書いています。

今の Action の形

現在の Action は、次のような形です。

actor NPC
{
    action talk()
    {
        print("Hello\n");
    }
}

() を付けた形が正式な書き方で、中身が空なら省略もできます。action talk と action talk() は同じ Action です。ただ、この () の中に引数を書くことは、まだできません。

一方、普通の関数(Function)は引数も戻り値も持てます。

int clampHp(int hp)
{
    if (hp < 0)
        return 0;
    return hp;
}

引数がなくて困る場面

例えば「見張りに、指定した場所まで移動してほしい」と頼みたいとします。移動先を引数で渡せれば、次のように書けそうです。

request(3, Guard->move(10));   ← 今はこう書けない

今のManaでは、依頼する前に、相手が読める場所へ値を置いておく必要があります。例えばグローバル変数を使う形です。

int gGuardTarget = 0;

actor Guard
{
    action move()
    {
        print("Guard: move to %d\n", gGuardTarget);
    }
}

actor Event
{
    action main()
    {
        gGuardTarget = 10;
        request(3, Guard->move());
    }
}

これでも動きますが、「move は gGuardTarget を読む」という約束が、コードの外に出てしまいます。値を置き忘れたり、別の依頼が同じ変数を書き換えたりすると、思わぬ動きになりそうです。

Actor の変数、構造体(Struct)、ネイティブ関数を使って値を受け渡す方法もありますが、いずれも「依頼」と「その内容」が離れてしまう点は同じです。

引数を付けると、何が難しくなるか

では引数を付ければよいかというと、Manaの request ならではの難しさがあります。

依頼はすぐに実行されるとは限らない

request は、相手の予定に行動を登録するだけです。優先度が低ければ、実行されるのはずっと後かもしれません。引数の値は、依頼した瞬間の値を覚えておき、実際に動く時まで取っておく必要があります。

Manaの Actor は優先度ごとに実行の状態を持っているので、引数もその優先度の状態の中に置くのが自然そうです。ただ、割り込まれて止まっている間も引数を持ち続けることになるので、使うメモリや、途中で値がどう見えるかを考える必要があります。

依頼は断られることがある

同じ優先度がすでに使われている時など、request は受け付けられないことがあります。その場合、渡した引数は使われずに捨てられます。

引数がない今なら「何も起きなかった」で済みますが、引数付きの依頼が黙って捨てられると、「値を渡したのに反映されない」という分かりにくい不具合につながりそうです。依頼の成否をどう伝えるかも、あわせて考える必要があると思っています。

戻り値は「誰が、いつ受け取るのか」

戻り値はさらに難しい問題です。普通の request は、相手の行動が終わるのを待ちません。待たずに先へ進むなら、戻り値を受け取る相手がいなくなります。

行動が終わるまで待つ awaitCompletion なら、戻り値を受け取れる形にできそうです。

int result = awaitCompletion(3, Guard->search());   ← 案の一つ。今は書けない

ただ、そうすると「request では戻り値を使えず、awaitCompletion だけで使える」という使い分けが生まれます。Action が割り込まれて再開した場合や、途中で取り消された場合に何を返すのかも、決めておかなければなりません。

すでに「暗黙の引数」はある

実は、Action にはすでに引数のように使える値があります。

  • sender : この Action を依頼した Actor
  • priority : 今実行している優先度
  • self : 自分自身の Actor

特に sender は、「誰に頼まれたか」で振る舞いを変えるのに使えます。

actor Guard
{
    action talk()
    {
        if (sender == Guide)
        {
            print("Guide requested talk\n");
        }
    }
}

「誰が頼んだか」は分かるので、足りないのは「何を、どうしてほしいか」という中身の部分だと言えそうです。

考えられる方向

まだ何も決まっていませんが、ここまでの整理から、次のような方向が考えられます。

  • 引数は、依頼と内容を近くに置けるので、読みやすさの面では付ける価値がありそう
  • 引数の値は、依頼した瞬間にコピーして、その優先度の実行状態に持たせる形が分かりやすそう
  • 戻り値は、待つ命令とセットでないと意味を持たせにくいので、引数より慎重に考える必要がある
  • 依頼が断られた時に、それを知る手段も一緒に用意した方がよさそう

Action を関数に近づけすぎると、「頼んで、後は相手に任せる」という request の良さが薄れてしまうかもしれません。便利さと、Actor どうしがゆるくつながっている良さを、どう両立させるかが悩みどころです。

2
0
4

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
2
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?