はじめに
9年あまりPHPで動く統合版の非公式サーバーソフトウェアのPocketMine-MPを使ってサーバーの開発やプラグインの作成をしてきたのですが、今はもうビヘイビア―パックなるものがあるじゃないかと!(老人)
そんなわけで、最近は鯖で実装したコアプラグインをすべてビヘイビアーパックとして配布できればなぁという思いからScriptAPIの勉強を始めました。
その中でちょっとScriptAPIについて深く知りたいなと思ったので記事にしました。
あくまでも私の推論であることを念頭に置いてください。
根拠もある程度書いているので信憑性ゼロではないはずですが、すべてを鵜呑みにしたりしないでください。
そんなん当たり前やろってことも多分バカみたいに書いてるんで笑
ScriptAPIって何者?
まず誰?って話ですが、ScriptAPIは統合版Minecraft (以下MCBEと表記) の公式が用意した、公式が許可した範囲での安全な拡張を意図したものであり、C++製ゲームエンジン上に組み込まれたJavaScriptランタイムに対し、エンジン内部オブジェクトを安全に操作するためのバインディングAPIを提供する仕組みのようなものだそう。
MCBEがC++で動いているのは公式のMojang開発インタビューでも話されていたらしいので確実性はありますし、C++そのものに JSObject::create() とかいう関数があることも踏まえれば、確からしいと思えます。
そう裏付けられる根拠
Entity周りで私なりに根拠となりうるかわからないことも含めて書きます。
根拠
1. 不可解なプログラムの挙動
Entityオブジェクトにはそのエンティティがスクリプト上で操作可能かどうかを示すisValidというプロパティがあります。
要はisValidがfalseを返されたとき、そのエンティティが死んだということになるのですが、idは依然として取得できます。
通常オブジェクトがnullである場合、すべてアクセス不可になるのに!
だからJS側のEntityインスタンスが生きているけれど、内部の何かが死んでいるっていう二層構造であることがわかるのかも。
2. 開発者のコメント
isValidのメソッドに以下のようなコメントがあります。
Returns whether the entity can be manipulated by script.
(エンティティがスクリプトで操作可能かどうかを返します。)
凝り固まった頭を解いて一旦安直に考えましょう。もしこれで内部エンジンのC++のバインディングじゃないなら、こんな回りくどい書き方しなくてもいいかなと。
あとidのプロパティのコメントとか。
No meaning should be inferred from the value and structure
(値と構造から意味を推測してはならない)
うっふ~ん、知的な女よ~~ん。推測しちゃうわよ~~
こんなんで裏付けたと言えるのかわからんが
つまりはScriptAPIにおけるEntityは、JSオブジェクトとして表現されたエンジン側エンティティへの参照ハンドルであり、そのライフサイクルと実体はJavaScriptとは独立して管理されているのではないかということ。 (あくまでも推測)
だからこの項の冒頭で書いたように、ScriptAPIはエンジン内部オブジェクトを安全に操作するためのバインディングAPIを提供する仕組みなんじゃないかって話。
@minecraft/serverのsystemの正体
前提としてこのsystemオブジェクトがScriptAPIの中でも特にゲームエンジンの実行ループに近い層で位置しており、JavaScriptだけで完結した実装ではない可能性が極めて高いと仮定させてもらいます。
(公式からこのあたりの仕様を詳しく言及されていない気がするため)
こちらを説明するうえでまず、遅延処理と繰り返し処理を例にして考えます。
成り行きで検証したこと
ScriptAPIでは、system.runInterval() や system.runTimeout() などで簡単に繰り返し処理や遅延処理を行えるのですが、例えば大量にこの処理を行った時、独立した処理が大量に表れてしまうため、ひとつのクソデカIntervalで、集約したクロージャを呼び出す仕様にするのがいいのではないかと考え、タイマーAPIを作ろうとしました。
鯖がおっきな鞄だとしよう。
その中に1個のダウンロード版ソフトを入れたゲーム機が大量にあるよりも、1個のゲーム機と大量のカセットがある方が普通に考えて管理もしやすいし、処理も軽そうな気がするよな~っていう話。
そんでパフォーマンスとかの比較をしてみようかなって。
検証の方法
lagMonitor.ts
Gistの埋め込みみたいなのを貼ろうとしたのですが、クソ長になってしまったのでリンクにさせてもらいます。
プレイヤーの足元のブロックを取得するのを20tick置きに行う処理を生成しまくる感じ。
ChatGPT君に頑張ってもらいました。
動作の確認ができたのでコードの質がどうとかは一旦目を瞑ってもらえればなと思います。
貼ったコードの方はsystem.runInterval()を使ってますが、比較でタイマーをつかったのも書きなおして検証してます。
検証結果
-
runInterval*N- 公式の
runInterval()をN個生成する方
- 公式の
-
Closure*N- ひとつのクソデカIntervalでN個のClosureをforループさせる方
| N = 51500 | runInterval*N |
Closure*N |
|---|---|---|
TPS? [ms]
|
76 | 79 |
| ブロック破壊遅延 | たまに一瞬遅延 | たまに一瞬遅延 |
| N = 100000 | runInterval*N |
Closure*N |
|---|---|---|
TPS? [ms]
|
108 | 112 |
| ブロック破壊遅延 | たまに遅延 | たまに遅延 |
| N = 400000 | runInterval*N |
Closure*N |
|---|---|---|
TPS? [ms]
|
295 | 鯖が死亡 |
| ブロック破壊遅延 | かなりの遅延 | 鯖が死亡 |
... どう考えてもパフォーマンスが落ちている。
確かにforでクロージャを回しまくってる時点でパフォーマンスが落ちることは承知しているが鯖落ちした面を考えれば違いがありすぎ...?
これらからわかったこと
system.runIntervalがゲームTickに同期したネイティブ側のスケジューリング機構を利用し、コールバック実行のみをJSに委譲している可能性が極めて高いと推測できる。
仮に公式もJSでIntervalをforで処理しているなら、この実装を一個にまとめた私のタイマーAPIの方が速くなるに違いないから。
Intervalが乱立してもJSで処理するのはコールバックだけ。
一方でタイマーAPIではコールバックでforループを回してすべてのクロージャを実行していることから、JSで処理される個所が必然的に多くなってしまう。
これがパフォーマンスに大きな差が生まれた要因なのかなと思った。
つまり公式が提供しているScriptAPIのsystem.runIntervalは、JS側でinterval管理を再実装するよりも明確に低コストな経路で実行されているということが検証から結論付けられた。
ふむふむ!
この項の冒頭で、systemオブジェクトがScriptAPIの中でも特にゲームエンジンの実行ループに近い層で位置していると仮定したが、そうだからこそ以下の点に整合性が生まれる。
-
system.run()は「次のTick」で必ず呼ばれる - ワールドTickが止まるとsystemも止まる
- TPSが落ちるとsystemの実行頻度も落ちる
これらはすべて、systemがゲームループに直結していることを示している。
よってsystemオブジェクトとは、MCBEにおいてスクリプト実行をゲームエンジンのTickループへ接続するための、最下層に近い公式バインディング機構であるということがわかるのではなかろうか。(あくまでも推測)
BeforeEventの存在意義
何かと不便(?)なこいつ。
別にワールドをいじれるわけでもないし、操作ができるわけでもないイベント群。
誰もがハマる例と対処法について
ブロックが置かれたときにそのブロックをダイヤブロックに変換するコードを書きます。
import { PlayerPlaceBlockBeforeEvent, world } from '@minecraft/server';
world.beforeEvents.playerPlaceBlock.subscribe((event: PlayerPlaceBlockBeforeEvent) => {
const player = event.player;
const block = event.block;
player.dimension.setBlockType(block.location, 'minecraft:diamond_block');
});
すると処理が行われず、以下のエラーが出ます。
[Scripting] ReferenceError: Native function [Dimension::setBlockType] cannot be used in restricted execution.
しかしこの処理部分をsystem.run()で以下のように書くとうまく処理されます。
import { PlayerPlaceBlockBeforeEvent, system, world } from '@minecraft/server';
world.beforeEvents.playerPlaceBlock.subscribe((event: PlayerPlaceBlockBeforeEvent) => {
const player = event.player;
const block = event.block;
- player.dimension.setBlockType(block.location, 'minecraft:diamond_block');
+ system.run(() => {
+ player.dimension.setBlockType(block.location, 'minecraft:diamond_block');
+ });
});
system.run()のコメントにも書かれていますが、ここで起きているのは、一度BeforeEventを抜けて次のティック(安全な状態)に移動し、処理を実行しています。
要するに、このコードではsystem.run()の中身はAfterEventのような振る舞いとしてワールドに影響を与えられたということ。
BeforeEventとAfterEvent、MCBEエンジンの処理は以下のようなフローに沿っています。
仮に...
BeforeEventで自由に変更できるとしましょう。
- MCBEエンジンが草ブロックの設置を検知
- Scriptがその草ブロックをダイヤブロックに変更
- その後にMCBEエンジンが草ブロックを設置しようとする
- エンジンが処理するはずの草ブロックがなくなっているという“破壊”が起こる
マルチプレイでも仮にこのような状態になると、クライアントとサーバーで状態確定順にズレが生じてしまい、同期破綻の原因となる。
じゃあ結局BeforeEventって何を四天王?
BeforeEventはゲーム内でその動作が完了する直前に発火しており、ゲームエンジンが「まだ世界を確定させていない段階」であり、あくまでもその動作でおこる情報を取得したりキャンセルを行うためだけのものなのでしょう。
BDSはともかく、ScriptAPIを含めるビヘイビア―パックとして存在することができる以上、ワールドに矛盾が起きてはいけないことはご理解いただけるでしょう。
マルチプレイもできるということも考慮すれば尚更その重大性はわかるはずです。
非同期処理の注意
イベント処理を書く時にやる人はいないと思いますが、以下のように非同期処理としてイベントを実行させることもできます。
import { PlayerSpawnAfterEvent, world } from '@minecraft/server';
world.afterEvents.playerSpawn.subscribe(async (event: PlayerSpawnAfterEvent) => {
if (!event.initialSpawn) return;
event.player.sendMessage('おいでよ どうぶつの森');
});
たとえばこれに、よくあるsleepみたいな関数を実装して、遅延して「どうぶつの森」を送信したいとします。
import { PlayerSpawnAfterEvent, world, system } from '@minecraft/server';
/**
* 指定tick後にresolveされるPromiseを返す
*
* @param ticks
* @returns {Promise<void>}
*/
function sleep(ticks: number): Promise<void> {
return new Promise<void>(resolve => {
system.runTimeout(() => {
resolve();
}, ticks);
});
}
world.afterEvents.playerSpawn.subscribe(async (event: PlayerSpawnAfterEvent) => {
if (!event.initialSpawn) return;
const player = event.player;
player.sendMessage('おいでよ');
await sleep(1*20);
player.sendMessage('どうぶつの森');
});
これを実行すると遅延処理は愚か、何事もなかったかのようにチャット欄に
おいでよ
どうぶつの森
と表示されます。
何が起きているのか
本来そのイベントのトリガーとなったプレイヤーに対して同期的にイベントが処理されることを考慮していますが、非同期処理にしたことで、そのイベントが発火されたとたんにすべての処理を実行しているのかと思ってます。
そこにクライアント側の状態は考慮しないということ。
おそらくこの処理が正常に行われている間、クライアント側は読み込み画面にいるというわけですね。
最後に
クソ長い拙い記事になりました。
冒頭に書きましたが、あくまでも私の推論をもとに書いた記事です。
当然違うことや見当違いな意見もあるかもしれません。
とはいえすべてが間違っているはずないと思うので、よりScriptAPIをうまく使いこなせるようになったんじゃないかなと思います。
ここはこういう仕様なんだよ~とか物申したい方いらっしゃいましたら気兼ねなくコメントしてください。
メリークリスマス![]()