スキン中のランタイムLuaスクリプトの記述方法 - exch-bms2/beatoraja GitHub Wiki

Luaスキンの記述方法 で説明しているようにLuaスキンのスクリプト全体はロード時に実行されますが、画面に表示する数値を指定するところやオブジェクトの表示条件を指定するところにLuaの関数を書くことができ、スキン動作時に評価させることができます。これにより、プレイ状況に応じて柔軟に表示を変えることができます。

できることとできないこと

バージョン0.7.9現在、以下の要素をLuaスクリプトに置き換えることができます。各要素がスキンのどの部分に出現するかはソースコードの JsonSkinクラス を参照してください。

従来の指定内容 書けるLuaスクリプト 主な使い道
BooleanProperty オプションID(OPTION_* 真偽値を返す関数 表示条件の制御
IntegerProperty 数値ID(NUMBER_* 整数値を返す関数 表示する数値の制御
FloatProperty スライダーID(SLIDER_*)または棒グラフID(BARGRAPH_* 数値を返す関数 スライダーや棒グラフの制御
StringProperty 文字列ID(STRING_* 文字列を返す関数 表示する文字列の制御
TimerProperty タイマーID(TIMER_*) ※1 タイマー時刻を返す関数 アニメーションの進み方や表示条件の制御
FloatWriter スライダーID(SLIDER_* 数値を受け取る関数 スライダーを操作したときの挙動の制御
Event ボタンID(BUTTON_*)またはカスタムイベントID ※2 関数 画像パーツをクリックしたときの処理の制御

※1: このページで説明するカスタムタイマーを指定可能。

※2: カスタムイベントはカスタムタイマーと同様ランタイムLuaの利用が前提となります。

ランタイムLuaスクリプトでできることとできないことは大まかに以下のようになります。

できること

  • 各オブジェクトの表示条件をスクリプトで柔軟に制御する
  • 表示する数値、グラフの値、文字列などをスクリプトで柔軟に制御する
  • オブジェクトをクリックしたときにスクリプトを実行し、上記の表示条件制御や表示内容制御と連動させる

できないこと

  • オブジェクトの座標や色をスクリプトで柔軟に制御する
    • 直接はできませんが、今後対応予定です。
    • 大量の Destination を用意しておき、それらの表示条件やタイマーを制御することで間接的に行うことは不可能ではありません。
  • スクリプトから動的に Destination を生成する
    • 実装コスト、処理コストともに高いため。
    • 固定数用意しておき使い回すことで疑似的には対応可能です。
  • GUIを簡単に作成する
    • ボタンクリック時の動作など自由度は広がりますが、レイアウトの仕組みを支援する機能などは特にありません。
  • 新種のスキンオブジェクトを作成したり、画面以外のバッファに描画したりする
    • スキン描画の仕組み自体はカスタマイズできません。
    • バッファへの描画など面白い需要があればLuaスキンとは関係なく対応しても良いかもしれません。
  • 画面遷移の流れをカスタマイズしたり、譜面内容を変更したりする
    • ゲームシステムへの干渉はできません。

基本の記述方法

最も基本的な、オプションIDの代わりに関数を書いて表示条件をカスタマイズする例を紹介します。0.7.9現在の Destination クラスの定義は以下のようになっているため、 draw フィールドに「オプションIDを表す整数値」と「真偽値を返すLuaの関数」のどちらも書くことができます。

    public static class Destination {
        public String id;
        public int blend;
        public int filter;
        public TimerProperty timer;
        public int loop;
        public int center;
        public int offset;
        public int[] offsets = new int[0];
        public int stretch = -1;
        public int[] op = new int[0];
        public BooleanProperty draw;
        public Animation[] dst = new Animation[0];
        public Rect mouseRect;
    }

オプションIDの少し複雑な事情として、従来オプションIDを指定していたのは draw ではなく op でした。 op には複数のオプションIDを入れることができますが、諸々の事情からLuaの関数を入れることはできません。 drawop で役割が被っているわけですが、両方指定した場合は draw が優先されます。

例えば draw にオプションIDを入れる書き方と、関数を入れる書き方はそれぞれ以下のようになります。

    -- オプションID 80(ロード中のみ表示される)
    {id = "load-progress", loop = 0, draw = 80, dst = {
        {time = 0, x = 40, y = 440, w = 200, h = 4},
        {time = 500, a = 192, r = 0},
        {time = 1000, a = 128, r = 255, g = 0},
        {time = 1500, a = 192, g = 255, b = 0},
        {time = 2000, a = 255, b = 255}
    }},

    -- 常に true を返す関数(常に表示されるので無意味)
    {id = "load-progress", loop = 0,
    draw = function()
        return true
    end,
    dst = {
        {time = 0, x = 40, y = 440, w = 200, h = 4},
        {time = 500, a = 192, r = 0},
        {time = 1000, a = 128, r = 255, g = 0},
        {time = 1500, a = 192, g = 255, b = 0},
        {time = 2000, a = 255, b = 255}
    }},

BooleanProperty である draw に関数を入れた場合、その関数は毎フレーム評価されて返り値によって表示状態が決まります。したがって、この関数の中でゲーム状態を参照して判定することで、例えば「ゲージが80%以上のときのみ表示される」のような挙動を実現できます。

ゲーム状態の参照

動作時にLuaスクリプトからゲーム状態を参照するには、beatoraja 本体から提供される main_state というモジュールを使います。まず、スキン定義の最初のほうに

local main_state = require("main_state")

と書きます。モジュールのロード結果は様々な関数や値が入ったテーブルとなっており、これが main_state 変数に代入されます。あとは、動作時に実行される関数からこのテーブル内の関数を呼び出すことでゲーム状態を取得できます。例えば、ゲージが80%以上のときのみ表示されるオブジェクトは次のように書くことができます。

    {
        id = "background",
        draw = function()
            return main_state.gauge() >= 80
        end,
        dst = {{x=0, y=0, w=1280, h=720}}
    },

当然ですが、main_state 内の関数は動作時に呼び出される部分から使用してください。スキン読み込み時に main_state 内の関数を呼び出してもあまり役に立つ結果は得られないと思います。また、スキンヘッダ読み込み時は main_state モジュールの読み込み結果そのものが空っぽになります。(スキンヘッダ読み込み時は skin_config が使えないのと同様です。詳しくは Luaスキンの記述方法#スキン設定へのアクセス を参照してください。)

main_state モジュールで提供される関数などの一覧はソースコードの MainStateAccessor で確認することができます。以下で代表的なものを説明します。

ID指定でプロパティの値を取得する関数

  • main_state.option(id): オプションID id の値(オンかどうか)
  • main_state.number(id): 数値ID id の値
  • main_state.float_number(id): スライダーまたはグラフID id の値
  • main_state.text(id): 文字列ID id の値
  • main_state.offset(id): オフセットID id の値
    • 結果は { x = x, y = y, w = w, h = h, r = r, a = a } 形式のテーブルです
  • main_state.timer(id): タイマーID id の値(マイクロ秒単位)
    • タイマーがオフの場合に返ってくる値はJavaの Long.MIN_VALUE となりますが、これは main_state.timer_off_value (関数ではありません)で与えられています。つまり、タイマーがオンかどうかは返り値を main_state.timer_off_value と比較すればわかります。
  • main_state.event_index(id): イベントID id の値(イベントの現在の状態を表すインデックス番号)(0.8.4以降)

特定の値を直接取得する関数

類似のプロパティは適宜省略するので MainStateAccessor で確認してください。

  • main_state.time(): 現在時刻(ゲーム状態が遷移してからの経過時間)をマイクロ秒単位で取得します。
  • main_state.rate(): 現在のスコアレートを取得します。
  • main_state.exscore(): 現在のEXスコアを取得します。
  • main_state.volume_sys(): 現在のシステム音量を取得します。
  • main_state.judge(judge): 判定 judge の現在のカウントを取得します。
    • judge の値: 0:PG, 1:GR, 2:GD, 3:BD, 4:PR, 5:MS
  • main_state.gauge(): 現在のゲージの値を取得します。
  • main_state.gauge_type(): ゲージの種類を取得します。
    • 0:ASSIST EASY, 1:EASY, 2:NORMAL, 3:HARD, 4:EX-HARD, 5:HAZARD, 6:GRADE, 7:EX GRADE, 8:EXHARD GRADE

各プロパティの仕様

BooleanProperty

引数なしで真偽値を返す関数を書くことができます。例:

    function()
        -- ゲージが80%以上かどうか
        return main_state.gauge() >= 80
    end
  • Destinationdraw フィールドに記述すると毎フレーム評価されて結果が true のときのみ表示されます。同じ役割を持つ op フィールドはオプションID専用です。
  • CustomEventcondition フィールドに記述するとイベントを実行しようとしているときに評価されて結果が true のときのみイベントが実行されます。

IntegerProperty

引数なしで整数値(Luaには実数型との区別がないので実数値でもOK)を返す関数を書くことができます。例:

    function()
        -- ゲージの10の位(//は切り捨て除算)
        return main_state.gauge() // 10
    end
  • ImageSetvalue フィールドに記述すると毎フレーム評価されて結果が何番目の画像が表示されるかに使われます。同じ役割を持つ ref フィールドは数値ID専用です。
  • Valuevalue フィールドに記述すると毎フレーム評価されて結果の数値が表示されます。同じ役割を持つ ref フィールドは数値ID専用です。

FloatProperty

引数なしで実数値を返す関数を書くことができます。例:

    function()
        -- ゲージの割合(最大100の場合)
        return main_state.gauge() / 100
    end
  • Slidervalue フィールドに記述すると毎フレーム評価されて結果がスライダーの位置に反映されます。
    • スライダーを操作したときの動作 FloatWriter event も同時に設定することになると思います。
    • type フィールドにスライダーIDを指定すると valueevent の両方指定したのと同じ結果になります。
  • Graphvalue フィールドに記述すると毎フレーム評価されて結果が棒グラフの長さ(割合)に反映されます。同じ役割を持つ type フィールドはグラフID専用です。

StringProperty

引数なしで文字列を返す関数を書くことができます。例:

    function()
        return "Hello"
    end
  • Textvalue フィールドに記述すると毎フレーム評価されて結果の文字列が表示されます。同じ役割を持つ ref フィールドは文字列ID専用です。

TimerProperty

引数なしで整数値を返す関数を書くことができます。結果が Long.MIN_VALUE すなわち main_state.timer_off_value の場合はタイマーがオフであることを、正の数のときはタイマーがオンになった時刻を表します。例:

    function()
        -- KEY1 と KEY2 の両方がオンのときオンになるタイマー
        local key1 = main_state.timer(101)
        local key2 = main_state.timer(102)
        if key1 == main_state.timer_off_value or key2 == main_state.timer_off_value then
            return main_state.timer_off_value
        elseif key1 < key2 then
            return key2
        else
            return key1
        end
    end
  • Image, Value, Slider, Graph, HiddenCover, LiftCovertimer フィールドに記述すると毎フレーム評価されて結果が分割アニメーションに用いられます。
  • Destinationtimer フィールドに記述すると毎フレーム評価されて結果が dst のアニメーションに用いられます。
  • CustomTimertimer フィールドに記述するとカスタムタイマーの挙動として用いられ、毎フレーム評価されて結果がタイマーの値となります。

発展的な例として、オプション値を常に観測してオンになったらタイマーもオン、オフになったらタイマーもオフにするというのが次のように実装できます。(これは補助関数として timer_util モジュールで提供されているので、自分で作る必要はありません。)

    (function()
        -- クロージャに保持する内部状態
        local state = main_state.timer_off_value
        return function()
            -- ここからが実際に毎フレーム実行される
            local on = main_state.option(241)
            if on and state == main_state.timer_off_value then
                -- オフからオンに変化
                state = main_state.time()
            elseif not on and state ~= main_state.timer_off_value then
                -- オンからオフに変化
                state = main_state.timer_off_value
            end
            return state
        end
    end)()

FloatWriter

実数値を受け取る関数を書くことができます。

  • Sliderevent フィールドに記述するとスライダーを動かしたときにスライダーの位置を引数として呼び出されます。普通のスライダーとして動作させるには value と同時に設定することになります。

Event

引数なしの関数を書くことができます。

  • Image, ImageSetact フィールドに記述するとオブジェクトをクリックしたときに呼び出されます。
  • CustomEventaction フィールドに記述するとカスタムイベント実行時に呼び出されます。

ここまでの応用例

クリックした回数を表示する

各プロパティにLuaスクリプトを記述できるということは、単に表示条件や値の制御を柔軟に行えるだけでなく、変数を介してそれぞれの挙動を連動させることができることを意味します。わかりやすい例として、ボタンクリック時の挙動として変数の値を増加させ、一方でその変数の値を表示するというものを考えてみます。

実装例は以下のようになります。Destination 側は省略していますが buttonclick_count に対応したものを作ってください。

local click_count = 0

skin.image = {
    ...
    {
        id = "button", src = 1, x = 0, y = 0, w = 100, h = 100, act = function()
			click_count = click_count + 1
		end
    },
    ...
}

skin.value = {
    ...
    {
        id = "click_count", src = 2, x = 0, y = 0, w = 240, h = 24, divx = 10, digit = 4, value = function()
			return click_count
		end
    },
    ...
}

click_count 変数が2つの関数から見える位置に宣言されるように注意してください。Luaでは変数がどれを指しているのかが構文的に決定されます(参考: 可視性ルール )。内側から外側のスコープに向かって順番にローカル変数宣言を探していき、見つからなければグローバル変数として扱われてしまいます。ボタン側ではローカル変数だけど数値表示側ではグローバル変数なんてことになると最悪にわかりにくいバグとなってしまうので気を付けましょう。次のような書き方にするとミスが起こりにくいかもしれません。(何をやっているのかわからなければ無視してください。)

local function make_counter()
    local click_count = 0
    return {
        increment = function()
            click_count = click_count + 1
        end,
        get = function()
            return click_count
        end
    }
end

local counter_1 = make_counter()

skin.image = {
    ...
    {
        id = "button", src = 1, x = 0, y = 0, w = 100, h = 100, act = counter_1.increment
    },
    ...
}

skin.value = {
    ...
    {
        id = "click_count", src = 2, x = 0, y = 0, w = 240, h = 24, divx = 10, digit = 4, value = counter_1.get
    },
    ...
}

ハーフレーンのプレイスキンで重なったキービームが2重に描画されないようにする

マニアックなネタですが、5鍵や24鍵のハーフレーンのスキンで隣接した鍵盤のキービームの重なった部分が2重に描画されないようにするにはどうすればいいだろうと考えたことがある方もいるのではないでしょうか。

beam1 beam2

キービームオブジェクトを「1鍵が占有する部分」「1鍵と2鍵が重なる部分」「2鍵が占有する部分」「2鍵と3鍵が重なる部分」…と分割していき、重なる部分のタイマーをスクリプトで定義すればこれを実現できます。

1鍵と2鍵が重なる部分のキーONタイマー関数は次のようになります。2つのタイマーのうち早い方のタイマー値が採用され、両方OFFのときのみ結果がOFFになります。(キービームをアニメーションさせない場合はON/OFFだけ区別できれば大丈夫ですが)

    function()
        local key1 = main_state.timer(101)
        local key2 = main_state.timer(102)
        if key1 == main_state.timer_off_value then
            if key2 == main_state.timer_off_value then
                return main_state.timer_off_value
            else
                return key2
            end
        else
            if key2 == main_state.timer_off_value then
                return key1
            elseif key1 < key2 then
                return key1
            else
                return key2
            end
        end
    end

キーOFFタイマーはキーが離されたときにセットされるタイマーです。1鍵と2鍵が重なる部分は次のように、両方離されたとき初めて重なった部分のタイマーがONになるようにします。

    function()
        local key1 = main_state.timer(121)
        local key2 = main_state.timer(122)
        if key1 == main_state.timer_off_value or key2 == main_state.timer_off_value then
            return main_state.timer_off_value
        elseif key1 < key2 then
            return key2
        else
            return key1
        end
    end

ボムを多重に再生する

ボムタイマーは鍵盤ごとに1個割り当てられているだけなので、普通に作ったのでは同一鍵盤で同時に1個しか再生することができません。豪華なボムを作ろうとしても頻繁に時刻がリセットされてしまうのでいまいちな見た目になってしまい、諦めた方も多いのではないでしょうか。

やはりタイマーのスクリプト制御によってボムを多重に再生することができるようになります。やり方は色々考えられますが、ここでは鍵盤ごとに最大N個のボムを再生する仕組みを作ります。

次の関数 create_cascade_timers では、タイマーID timer を多重化した max_count 個のタイマー関数を作成します。current_value 変数には元のタイマーの前回の値が、current_index には何番目のタイマーが次に更新されるかが格納されています。タイマーの値が更新されたとき、自身が更新対象(current_index == i)なら自身の値を更新し current_index を進めるというわけです。

local function create_cascade_timers(timer, max_count)
    local current_value = main_state.timer_off_value
    local current_index = 0;
    local timers = {}
    for i = 0, max_count - 1 do
        local self_value = main_state.timer_off_value
        table.insert(timers, function()
            local value = main_state.timer(timer)
            if value ~= current_value and current_index == i then
                current_index = (current_index + 1) % max_count
                current_value = value
                self_value = value
            end
            return self_value
        end)
    end
    return timers
end

使用例は次のようになります。Destination側だけでなく、Image側も多重化したタイマーを参照するように注意しましょう。

local bomb_position_x = {...} -- ボムのX座標
for i = 1, key_count do
    local timers = create_cascade_timers(49 + i, 4)
    for j = 1, 4 do
        local id = "bomb_" .. i .. "_" .. j
        table.insert(skin.image, {
            id = id, timer = timers[j], cycle = 800,
            src = 10, x = 0, y = 0, w = 1810, h = 192, divx = 10
        })
        table.insert(skin.destination, {
            id = id, timer = timers[j], loop = -1,
            blend = 2, dst = {
                { time = 0, x = bomb_position_x[i], y = 28, w = 180, h = 192 },
                { time = 800 }
            }
        })
    end
end

カスタムタイマーとカスタムイベントの定義

自分でLuaスクリプトによって定義したタイマー(TimerProperty)やイベント(Event)は、もちろん timeract フィールドに直接入れれば使えるのですが、既存のタイマーやイベントのようなIDを振ることができます。これをカスタムタイマーやカスタムイベントと呼びます。複数人でスキンを制作するときはタイマーの実装者がカスタムタイマーとしてIDを割り当て、他の作者にタイマーID参照で使ってもらうのがわかりやすいかもしれません。

カスタムタイマーやカスタムイベントを定義するには、スキンの customTimerscustomEvents フィールドにデータを入れます。詳しくは JsonSkinクラス を参照してください。

カスタムタイマーでは、本来スクリプトを入れるべき timer フィールドを空にすることで、「受動的なタイマー」を作成することができます。受動的なタイマーとは一体何かというと、自身の毎フレームの更新処理は行わず、外部からの操作(main_state.set_timer(id, value))によって値をセットすることだけができるというものです。ぶっちゃけただの変数といえますが、IDの振られたカスタムタイマーとしては役に立つ場面もあると思います。

カスタムIDとして割り当て可能な値の範囲については SkinPropertyクラス を参照してください。現在タイマーIDは 10000~19999 が、イベントIDは 1000~1999 が利用可能です。

スキンから利用可能な補助関数

本体からスキンに対して main_state 以外にも timer_utilevent_util の2つのモジュールを公開しています。それぞれの内容については TimerUtilityクラスEventUtilityクラス を参照してください。