これは「ドワンゴ Advent Calendar 2021」の14日目の記事です。
Akashic Engine とは
Akashic Engine はニコニコ生放送上で動くインタラクティブなコンテンツを作るための JavaScript/TypeScript ライブラリです。ニコニコ生放送ではアンケートやゲームなど、放送者と視聴者がリアルタイムに画面をタップしてコミュニケーションする双方向の機能があり、その技術的な基盤として利用されています。
Akashic Engine はインタフェースとツールが GitHub 上で公開されていて、誰でもコンテンツを開発することができます。Akashic Engine で作ったコンテンツは、ニコニコのサービスの一つであるゲームアツマールに投稿したり、生放送でプレイできるように申請したりできます。ニコニコ生放送には多数の自作ゲームが公開されていて、ニコニコ生放送の魅力のひとつとなっています。
g.Trigger とは
g.Trigger は Akashic Engine に含まれているイベント通知のためのクラスで、イベントハンドラを登録することでイベント発生時に通知されます。
例えば Akashic Engine では画面をタップしたときに何か処理を実行するコードは以下のように書きます。
scene.onPointDownCapture.add((ev) => {
// イベントが発生したときの処理
});
ここで onPointDownCapture
が Trigger<PointDownEvent>
となっています。g.Trigger は Akashic Engine の他の機能と依存がないため以下のように単独でインスタンスを生成することができます。
const trigger = new g.Trigger<number>();
trigger.add(n => console.log(n));
trigger.addOnce(n => console.log(n * 10));
trigger.fire(1); // 1と10が表示される
trigger.fire(2); // 2が表示される
g.Trigger プリミティブ
Akashic Engine の g.Trigger は単にイベントを通知するクラス以上の機能を持っていないのですが、この g.Trigger をイベントストリームだと考えるといろいろと便利に利用できるのではないかというのが今回やってみたことのひとつとなります。例えば g.Trigger の値を map する関数は次のように定義できます。
function mapTrigger<T, U>(src: g.Trigger<T>, f: (x: T) => U): g.Trigger<U> {
const trigger = new g.Trigger<U>();
src.add((value) => {
trigger.fire(f(value));
});
return trigger;
}
このような関数があらかじめ定義されているとすると、マウスイベントからマウスのX座標だけを取り出す処理が、以下のように簡潔に記述できます。
const mouseX = mapTrigger(scene.onPointDownCapture, ev => ev.point.x)
他にもトリガー同士を連結する chainOnce
/chain
条件で分岐する ifTrigger
、複数の g.Trigger を合成する mergeTrigger
、をそれぞれ以下のように定義できます。
function chain<T>(src: g.Trigger<T>, dst: g.Trigger<T>): void {
src.add((arg) => dst.fire(arg));
}
function chainOnce<T>(src: g.Trigger<T>, dst: g.Trigger<T>): void {
src.addOnce((arg) => dst.fire(arg));
}
function ifTrigger<T>(src: g.Trigger<T>, cond: () => boolean): [g.Trigger<T>, g.Trigger<T>] {
const trueTrigger = new g.Trigger<T>();
const falseTrigger = new g.Trigger<T>();
src.add((value) => {
if (cond()) {
trueTrigger.fire(value);
} else {
falseTrigger.fire(value);
}
});
return [trueTrigger, falseTrigger];
}
function mergeTrigger<T>(src1: g.Trigger<T>, src2: g.Trigger<T>): g.Trigger<T> {
const trigger = new g.Trigger<T>();
chain(src1, trigger);
chain(src2, trigger);
return trigger;
}
このような g.Trigger プリミティブが実際のコンテンツ制作に役に立つのかですが、具体例としてモグラたたきゲームのヒット判定の処理を考えます。マウスをクリックしたときに、ヒットテストをして、ヒットしていたらスコアを加算、ミスの場合はスコアを減算するような状況を考えます。この処理は以下の図のように g.Trigger プリミティブの組み合わせで表現できます。
コンテンツの性質によりますが、ゲームのロジックを宣言的に書きたいという場合は選択肢の一つとなる可能性があります。他にも Reactive Extensions のようなライブラリを参考にすることで役に立ちそうな g.Trigger は多数考えることができます。
時間幅をもった処理
g.Trigger プリミティブという道具を手に入れると、次にゲーム全体を g.Trigger の組み合わせだけで表現できないだろうかという興味が湧いてきます。理屈の上では実現可能ですが、現実にはゲームのロジックをイベントの流れだと考えるのは、よっぽどシンプルなゲームでない限り無理があるので、ぎこちないプログラムとなってしまいます。
ゲームのロジックをよく観察すると、ゲームは時間幅をもった処理が集まったものととらえることができます。ここでは時間幅をもった処理を Action
というインターフェースで抽象化します。
interface Action {
finished: g.Trigger<void>;
start(): void;
}
start()
を呼び出して処理が終わると g.Trigger を発火するという想定のインターフェースです。もっともシンプルな Action は関数を実行するだけの挙動をするものです。これを simpleAction
と名付けて以下のように定義します。
function simpleAction(action: () => void): Action {
const finished = new g.Trigger<void>();
return {
finished,
start() {
action();
finished.fire();
},
};
}
他にも一定時間経過したら終了する durationAction
、
function durationAction(
milliseconds: number,
action: () => void,
cancel?: g.Trigger<void>
): Action {
const finished = new g.Trigger<void>();
return {
finished,
start() {
const timeout = timeoutTrigger(milliseconds);
chainOnce(timeout, finished);
if (cancel) {
cancel.addOnce(() => {
timeout.destroy();
finished.fire();
});
}
action();
},
};
}
別の g.Trigger の発火を待機する awaitTriggerAction
、
function awaitTriggerAction(trigger: g.Trigger<void>): Action {
const finished = new g.Trigger<void>();
return {
finished,
start() {
trigger.addOnce(() => {
finished.fire();
});
},
};
}
などを考えることができます。Action
はJavaScript の Promise
と同じようなものと考えることもできます。Action
は単体ではほとんど役に立たないので Action
同士を連結する仕組みが必要となります。sequence
はアクションを順番に実行します。
function sequence(actions: Action[]): Action {
if (actions.length === 1) {
return actions[0];
}
const finished = new g.Trigger<void>();
return {
finished,
start() {
if (actions.length === 0) {
finished.fire();
return;
}
if (actions.length > 1) {
for (let i = 0; i < actions.length - 1; i++) {
actions[i].finished.addOnce(() => actions[i + 1].start());
}
}
chainOnce(actions[actions.length - 1].finished, finished);
actions[0].start();
},
};
}
これを利用すると例えば、画面にメッセージを表示して、3秒経過したらボタンを表示して、ボタンがタップされたら別のメッセージを表示するといった逐次処理を以下のように記述できます。
sequence([
durationAction(3000, () => {
// 画面にメッセージを表示
}),
simpleAction(() => {
// ボタンを表示
}),
awaitTriggerAction(button.onPointDown), // ボタンがクリックするのを待つ
simpleAction(() => {
// 別のメッセージを表示する
})
]).start();
やりたいことを直接コードに落とし込むことができます。さらに以下の関数を利用するとループ構造も作成できます。
function repeat(
actionCreator: (exit: g.Trigger<void>) => Action[]
): Action {
const finished = new g.Trigger<void>();
return {
finished,
start() {
const action = sequence(actionCreator(finished));
action.finished.add(() => action.start());
finished.addOnce(() => action.finished.removeAll());
action.finished.fire();
},
};
}
例えば、画面にメッセージを3秒間表示して、別のメッセージを3秒間表示して、という流れを永遠に繰り返すというコードは、以下のようになります。
repeat(() => [
durationAction(3000, () => {
// メッセージを表示する
}),
durationAction(3000, () => {
// 別のメッセージを表示する
})
]);
実現したいロジックをそのまま表現できます。
さいごに
この記事では g.Trigger を組み合わせて複雑な処理を宣言的に記述できる可能性があるという話と、時間のかかる処理を Action
という名前でまとめることで、表現したいロジックをそのまま表現できる可能性があるという話をしました。この記事で紹介した g.Trigger プリミティブや Action
はそれぞれを連携してさらに活用することができます。
この記事が Akashic Engine を利用した誰かのコンテンツのお役に立てることを願いつつ、今年の記事は終わりたいと思います。