第11回 快刀乱麻を断つ

今回から第1?回までの?回で、マイクロスレッドというものについて話していきたいと思います。

要旨

それではいってみましょう。

弾幕処理の概要

さて、ここまでの知識である程度簡単な弾幕であれば作れるようになっています。しかし、具体的に色々作ってみる前に、ここで一旦弾幕処理全体を大まかに眺めてみましょう。

敵の処理を抽象的に書くと、次の様な形になっています。

[敵] {
    [データ]
    [処理] {
        [初期化]
        [ループ] {
            [動作]
            [描画]
        }
        [後処理]
    }
}
図 1. 東方弾幕風における敵の処理

[敵] は [データ] を持っています。これは、script_enemy_main に直接宣言された変数に相当します。

[敵] の [処理] では、先ず [初期化] が行われます。次に、敵を倒すまで [動作] と [描画] を繰り返します。[動作] は @MainLoop に、[描画] は @DrawLoop に相当します。そして、敵を倒すと [後処理] が行われます。これが @Finalize に相当します。東方弾幕風では、これらの各項目を自分でプログラムすることにより、弾幕を生成します。

しかし、複雑な弾幕を作っていくと、全体の流れはこうなっているのが理想であることが分かります。

[敵] {
    [キャラ] {
        [データ]
        [処理] {
            [初期化]
            [ループ] {
                [動作]
                [描画]
            }
            [後処理]
        }
    }
    [弾1] {
        [データ]
        [処理] {
            [初期化]
            [ループ] {
                [動作]
                [描画]
            }
            [後処理]
        }
    }
    [弾2] {
        ...
    }
    [弾3] {
        ...
    }
    ...
    [情報1] {
        ...
    }
    ...
}
図 2. 理想的な敵の処理

つまり、キャラの処理を行うだけではなく、いくつもの弾の制御や、何らかの情報の操作も同時に行うわけです。しかし、前回までのプログラムを見ると、実際にはここまで綺麗に分かれてはおらず、次のようになっていることが分かります。

[敵] {
    [データ] { [キャラ], [弾1], [弾2], [弾3], ..., [情報1], ... }
    [処理] {
        [初期化] { [キャラ], [弾1], [弾2], [弾3], ..., [情報1], ... }
        [ループ] {
            [動作] { [キャラ], [弾1], [弾2], [弾3], ..., [情報1], ... }
            [描画] { [キャラ], [弾1], [弾2], [弾3], ..., [情報1], ... }
        }
        [後処理] { [キャラ], [弾1], [弾2], [弾3], ..., [情報1], ... }
    }
}
図 3. 実際の敵の処理

この処理の流れは、単に図 1 の処理を細かく書いただけであることが分かると思います。つまり、理想的には図 2 なのに、図 1 の縛りがあるために、図 3 にならざるをえないという状況にあるわけです。

このことを実感してもらうため、前回のプログラムのどこがどれに対応するかを見てみましょう。

#東方弾幕風
#Title[テストスクリプト]
#Text[テストスクリプト]
#ScriptVersion[2]

[敵] script_enemy_main {
    [データ] {
        [キャラ] {
            let imgBoss   = "script\img\ExRumia.png";
        }
        [弾] {
            let frame     =  0;
            let angleBase = 90;
        }
    }

    [処理] {
        [初期化] @Initialize {
            [キャラ] {
                SetX(GetCenterX);
                SetY(GetClipMinY + 120);
                SetLife(2000);

                LoadGraphic(imgBoss);
                SetTexture(imgBoss);
                SetGraphicRect(0, 0, 63, 63);
            }
        }
 
        [ループ] {
            [動作] @MainLoop {
                [キャラ] {
                    SetCollisionA(GetX, GetY, 24);
                    SetCollisionB(GetX, GetY, 24);
                }

                [弾] {
                    frame++;
                    if(frame == 30) {
                        nway(angleBase, 3, 10, YELLOW01);
                        angleBase += 8;
                    } else if(frame == 60) {
                        nway(angleBase, 5,  5, WHITE01);
                        angleBase += 8;
                        frame = 0;
                    }
                }
            }

            [描画] @DrawLoop {
                [キャラ] {
                    DrawGraphic(GetX, GetY);
                }
            }
        }

        [後処理] @Finalize {
            [キャラ] {
                DeleteGraphic(imgBoss);
            }
        }
    }

    function nway(dir, way, span, color) {
        let radius = 32;
        let angle  = dir - (way - 1) / 2 * span;

        loop(way) {
            let x = GetX + offsetX(radius, angle);
            let y = GetY + offsetY(radius, angle);

            CreateShot01(x, y, 1, angle, color, 0);
            angle += span;
        }
    }

    function offsetX(radius, angle) {
        return radius * cos(angle);
    }

    function offsetY(radius, angle) {
        return radius * sin(angle);
    }
}

こうしてみると、キャラに関する処理と弾に関する処理が入り乱れているのがよく分かると思います。

では、東方弾幕風では理想的な図 2 の形で処理できないのでしょうか? 実は、答えはほぼ No! です。とりあえず、図 2 の形に即したプログラムを見てみましょう。

#東方弾幕風
#Title[テストスクリプト]
#Text[テストスクリプト]
#ScriptVersion[2]

script_enemy_main {
    let imgBoss = "script\img\ExRumia.png";

    @Initialize {
        SetX(GetCenterX);
        SetY(GetClipMinY + 120);
        SetLife(2000);

        LoadGraphic(imgBoss);
        SetTexture(imgBoss);
        SetGraphicRect(0, 0, 63, 63);

        TNway; 
    }

    @MainLoop {
        SetCollisionA(GetX, GetY, 24);
        SetCollisionB(GetX, GetY, 24);

        yield; 
    }

    @DrawLoop {
        DrawGraphic(GetX, GetY);
    }

    @Finalize {
        DeleteGraphic(imgBoss);
    }

    task TNway { 
        let angleBase = 90;
        yield; 

        loop {
            loop(30) { yield; }
            nway(angleBase, 3, 10, YELLOW01);
            angleBase += 8;

            loop(30) { yield; }
            nway(angleBase, 5,  5, WHITE01);
            angleBase += 8;
        }
    } 

    function nway(dir, way, span, color) {
        let radius = 32;
        let angle  = dir - (way - 1) / 2 * span;

        loop(way) {
            let x = GetX + offsetX(radius, angle);
            let y = GetY + offsetY(radius, angle);

            CreateShot01(x, y, 1, angle, color, 0);
            angle += span;
        }
    }

    function offsetX(radius, angle) {
        return radius * cos(angle);
    }

    function offsetY(radius, angle) {
        return radius * sin(angle);
    }
}

完全に図 2 の形に即しているというわけではありませんが、こんな形になります。それぞれの意味に関しては後で話しますが、見た感じ、弾の動作に関する部分が task TNway という部分に分離されていることだけは何となく分かるのではないでしょうか?

マイクロスレッド

では、何がどうなっているのか見ていきましょう。

先ず注目すべき点は、 @Initialize, @MainLoop の中や script_enemy_main 下の直接のデータに、弾に関する部分がほとんど見当たらない点です。数少ない関係ありそうな部分は、TNway;yield; です。

そして、全体を眺めてみると、弾に関する処理は task TNway に集約されていそうなことが分かります。しかし、その処理の流れは前のものとは大きく違っていると思います。

では、task TNway とは何なのでしょうか? 一見すると関数やサブルーチンのようです。しかし、中には無限ループがあります。breakreturn もないので、もし普通の関数やサブルーチンであれば、@Initialize で呼ばれた時点で無限ループに入ってしまい、永遠に先に進まない事になります。ただ、breakreturn はないものの、何やら yield というものがあります。これがキーを握っていそうですね。

実は、task TNway が関数やサブルーチンに近いものであるという予想は間違っていません。ただ、処理が yield に達すると、そこで処理を中断します。そして、task の外で yield に達すると、中断していた位置に復帰します

つまり、こういうことです。先ず、@InitializeTNway を呼びます。

TNway;

すると、変数 angleBase の宣言と初期化があります。そして、次に yield があるので、そこで処理を中断して戻ります

let angleBase = 90;
yield;

@Initialize に処理が戻ってきたので、初期化を無事終了する事ができます。

そして、次に @MainLoop に達します。この中に yield があるので、ここで TNway で処理を中断していた地点へ復帰します

yield;

そして、処理が進みます。すると、無限ループに入ってすぐ yield が実行されます。

loop {
    loop(30) { yield; }

ここで直ちに @MainLoop 戻ります。そして、loop(30) になっているので、次のフレームでもまた直ちに処理を中断します。これが 30 フレーム続きます。

30 フレーム中断と復帰を繰り返した後、31 フレーム目に漸く先に進み、黄色い 3-way 弾が撃たれます。

nway(angleBase, 3, 10, YELLOW01);
angleBase += 8;

ここで angleBase を使っています。関数の場合、関数から出ると関数の内部で宣言している変数の寿命が切れます。しかし、ここではあくまで処理を中断しただけであって、task TNway が終了したわけではありません。なので、ここではまだ angleBase の寿命はつきていないのです。これが関数やサブルーチンとは大きく違う点です。

このあとはまた

loop(30) { yield; }

で 30 フレームだけ中断と復帰を繰り返し、61 フレーム目に白い 5-way 弾を撃つ事になるわけです。

nway(angleBase, 5,  5, WHITE01);
angleBase += 8;

以上にようにして、弾の発射タイミングが制御されるわけです。この task で作られるものをマイクロスレッドと言います。ただ、マイクロスレッドという名前が長いので、簡単にタスクと呼ぶこともあります。一般にはタスクとマイクロスレッドは意味が違うのですが、東方弾幕風では気にしないことが多いです。

意義

今までは frame という変数を使ってフレーム数をカウントすることにより、弾の発生をコントロールしていました。しかし、この方法で複雑な弾幕を作るには、大きな問題が3つあります。

1つ目は、変数に関する問題です。フレーム数を保存する変数など、フレームを超えて寿命を持つ変数をいくつか作る必要がありますが、これらは必ず script_enemy_main に直接置かれることになります。これは、「変数のスコープはできるだけ狭くすべし」という理念に照らし合わせると、あまりよろしくない状況であることが分かります。ある1つの処理に必要なモノはできる限り1カ所にまとめられるべきであり、何カ所にも分散していると把握が困難になります。何種類か弾を用意すると、似た様な、名前の微妙に違う変数がいくつも置かれることになります。微妙に違う名前を考えるのも面倒ですし、どれが何だったか忘れると把握に時間がかかります。違うけど似ている変数を作る可能性が増えるため、もしかしたらタイプミスで偶然別の変数になってしまい、どこが変なのかなかなか気づけないこともあるかもしれません。

2つ目は、処理速度の問題です。弾幕が多くのタイミングで制御される場合、if ... else if ... else if ... else if ... という長い判定文が出てきます。最初の方で判定が済めばいいのですが、後の方まで何度も判定が行われる場合は処理が重くなります(時間がかかります)。

3つ目は、処理を変更しにくいという問題です。例えば、前回のプログラムを例に考えてみましょう。30 フレーム毎に弾を発射しているわけですが、奇数回目の弾から偶数回目の弾までの間隔を 30 フレームに維持しつつ、偶数回目の弾から奇数回目の弾までの間隔を 15 フレームに変更してみたいと思います。この場合、比較するフレーム数を 30, 60 から 15, 45 に変えればいいわけです。変化させる量は1つなはずなのに、プログラムの変更箇所2カ所になります。変数でおいてやることによりとりあえず変更箇所を減らすことはできますが、弾の種類を増やしたい時や順番を変更したい時にはやはり変更箇所が多く、間違えないよう気をつける必要があります。弾幕を作ってすぐ満足するものができることは少なく、調整が必要な事は多いです。調整の面倒さは弾幕の作成意欲の低減に繋がります。

このように、フレーム数を数えながら弾幕を生成するのは、大きく複雑な弾幕を生成するのには向いていません。

マイクロスレッドを使うと、これらの点が解決されます。

1つ目の変数に関する問題は、明らかに改善されています。angleBase はマイクロスレッドの中に入っています。これにより、変数のスコープは狭くなり、ある1つの処理に必要なものが1カ所にまとめられ、似た様な名前の微妙に違う変数を作る必要もありません。

2つ目の処理速度の問題も、長い判定が不必要になるため、処理が不必要に重くなる事もありません。

3つ目の処理を変更しにくいという問題も、マイクロスレッドを使うと解決されています。発射間隔を変更したければ、各ループのループ回数を各自変更するだけですし、処理の移動も簡単です。色んな種類の弾を作るという拡張性もあります(これに関しては、次回話します)。

このように、マイクロスレッドを使うと複雑な弾幕を作るのが非常に楽になるのです。

文法

マイクロスレッドの定義する部分の文法は、詳しくはこうなります。

task <マイクロスレッド名>[([<仮引数リスト>])] {
    <処理>
}

起動部分はこうなります。

<マイクロスレッド名>[([<実引数リスト>])];

文法的には関数とほとんど同じなので、これ以上の解説は不要かと思います。

要旨

次回もマイクロスレッドについて話していきましょう。

第10回 | 第12回 | 目次へ戻る

この講座はロベールが作成しました。
inserted by FC2 system