🎁

C#でマイクラ統合版にコマンドを送信してみよう

2022/09/26に公開約15,400字

はじめに

皆様こんにちは~
C#とマイクラで色々と邪悪なことをしている「たくのろじぃ」です~⤴

マイクラ統合版は様々なプラットフォームで展開されているマイクラシリーズの1つで, Bedrock Edition (BE版) とも呼ばれます. Java 版と異なるのは PC 以外のプラットフォームでも動く点です. 今回はそんな統合版のマイクラのコマンドを, C#から実行してみようという企画です. すでに Java 版でのやり方は記事にしていますので, 興味があれば下記からどうぞ. (セクション3からです.)

https://zenn.dev/takunology/articles/mstechcamp17

実行環境

  • Windows 10 21H2(Build 19044)
  • Minecraft Bedrock Edition (最新バージョン / Win版を使用)
  • Visual Studio 2022

準備

まずはマイクラ統合版が必要ですので, 持っていない方は購入してください.

https://www.minecraft.net/ja-jp/store/minecraft-java-bedrock-edition-pc

マイクラのコマンドの実行には, localhost でサーバをたてて, マイクラから接続する必要があります. しかし, デフォルトのままでは localhost へ接続できません. 統合版は UWP アプリであり, localhost へ接続(ループバック)できないような仕様になっているため PowerShell を開いて次のコマンドを実行し, 除外リストへ追加します.

CheckNetIsolation LoopbackExempt -a -n="Microsoft.MinecraftUWP_8wekyb3d8bbwe"

ちなみに, アプリのIDを検索するには下記のようなコマンドが使えます. <AppName> のところにアプリのキーワードを入れます.

Get-StartApps |  Where-Object {($_ -like "*<AppName>*") } | Select-Object AppID

次に, マイクラで適当なワールドを作成します. ワールドの作成方法は省きますが, 設定はクリエイティブモードにしておいてください. プログラム作成中にモンスターに襲われたりすると面倒ですし, チートをオンにしないとコマンドが使用できません.

ここまでで準備は完了です.

WebSocket 通信のプログラム作成

まずは C# のコンソールアプリ上でサーバを立てて, マイクラから接続できるようにします.(ちなみに, Java 版では TCP プロトコルに対し, UWP では UDP プロトコルを使用します.)

Visual Studio を使用する場合は新しいプロジェクトの作成から適当な名前をつけて, コンソールアプリケーション を作成します. このとき, トップレベルステートメントをオフにしておくことをオススメします.

作成できたら NuGet パッケージマネージャーコンソールにて下記のコマンドを実行し, WebSocketSharp を導入します。(もしかしたら Unity 開発者の方には有名なパッケージかも?)

NuGet\Install-Package WebSocketSharp -Version 1.0.3-rc11

次に, WebSocket インスタンスを作成して, サーバを起動後にキーが入力されるまで待機しておきます.

Program.cs
using WebSocketSharp.Server;

internal class Program
{
    private static WebSocketServer _server;
    
    static void Main(string[] args)
    {
        _server = new WebSocketServer(8080); // コンストラクタに開放するポート番号を渡す
        _server.AddWebSocketService<MinecraftService>("/");
        _server.Start();
        Console.WriteLine("Server has started. Execute the below command at Minecraft.");
        Console.WriteLine("/connect localhost:8080");
        Console.ReadKey();
        ServerStop();
    }

    private static void ServerStop()
    {
        _server.Stop();
        _server = null;
    }
}

次に, サービスを追加します. ここで言うサービスは下記の4つです.

  • サーバとの接続が確立したとき
  • マイクラから何かしらのレスポンスを受け取ったとき(チャットコマンドなど)
  • 接続にエラーが生じたとき
  • サーバと切断したとき

これらは WebSocketBehavior にて定義されており, 継承することでオーバーライド可能です. このサービスを定義するために新しくクラス MinecraftService.cs を追加します.

MinecraftService.cs
using WebSocketSharp;
using WebSocketSharp.Server;

public class MinecraftService : WebSocketBehavior
{
    protected override void OnOpen()
    {
        Console.WriteLine("Connected.");
    }

    protected override void OnError(WebSocketSharp.ErrorEventArgs e)
    {
        Console.WriteLine(e);
    }

    protected override void OnMessage(MessageEventArgs e)
    {
        // ここでコマンドを送信したり、受け取った情報を見ることができる
    }

    protected override void OnClose(CloseEventArgs e)
    {
        Console.WriteLine("Disconnected.");
    }
}

書けたら実行してみてください. 初回実行時に, ファイアウォールの画面が表示されますので, アクセスを許可してください.

マイクラにてチャットを開き, connect コマンドを実行します. 今回は localhost:8080 にて listen していますので, /connect localhost:8080 を実行します.

サーバとの接続が完了すると, マイクラでは "サーバとの接続を確立しました" と表示され, コンソール側では "Connected." が表示されます. しばらく何もしないでいると, 自動的にサーバが閉じます.

ここまでで, WebSocket 側の雛形は完成しましたので, 色々と追記していきます.

マイクラからのレスポンスを受け取る

では次に, マイクラで実行されたコマンドを受け取って, その情報を見ていきます. マイクラからのレスポンスは OnMessage メソッドから受け取ることができます. MessageEventArgsData プロパティをコンソール出力させると, その中身を表示できます.

protected override void OnMessage(MessageEventArgs e)
{
    Console.WriteLine(e.Data)
}

追記したらまたサーバを立ち上げて, マイクラのチャットから適当に発言してみてください. すると, マイクラからのレスポンスを JSON 形式で受け取ることができます.

これを整形ツールで見てみると, このようになります.

{
	"body": {
		"message": "hello",
		"receiver": "",
		"sender": "takunology",
		"type": "chat"
	},
	"header": {
		"eventName": "PlayerMessage",
		"messagePurpose": "event",
		"version": 16973824
	}
}

これをデシリアライズすれば, 例えば "build" と発言したら建築するといった条件分岐に応用することができそうですね.

JSONのデシリアライズ

では, チャットで発言した内容を取り出すためのデシリアライズ処理を作っていきます. JSON の構造を見ると, bodyheader の構造体の中に各種キーと値が入っていますので, このペアをプロパティで定義してあげれば良さそうです. 新しく, ResponseJson.cs を作成します.

public class ResponseJson
{
    public Body body { get; set; }
    public Header header { get; set; }

    public class Body
    {
        public string message { get; set; }
        public string receiver { get; set; }
        public string sender { get; set; }
        public string type { get; set; }
    }

    public class Header
    {
        public string eventName { get; set; }
        public string messagePurpose { get; set; }
        public int version { get; set; }
    }
}

あとは OnMessage メソッドにデシリアライズ処理を記述していきます.

protected override void OnMessage(MessageEventArgs e)
{
    var json = JsonSerializer.Deserialize<ResponseJson>(e.Data);
    Console.WriteLine(json.body.message);
}

それでは実行して, マイクラから何か発言してみてください. コンソールに, そのメッセージがそのまま表示されます.

コマンドを送信する

いよいよお待ちかねのコマンド送信です. マイクラにはたくさんのコマンドがありますが, 一番シンプルな title コマンドを実行してみましょう. コマンドを送信するにはリクエスト用のJSONを作成して, それをマイクラへ送信する必要がありますので, まずはJSONの構造体を定義するプロパティから作成します.

コマンド送信用の JSON の基本的な雛形は下記を参考にしてください.

{
    "body": {
        "origin": {
            "type": "player"
        },
        "commandLine": "<Command>", // ここにコマンドを記述する
        "version": 1,
    },
    "header": {
        "requestId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", // UUID v4
        "messagePurpose": "commandRequest",
        "version": 1,
        "messageType": "commandRequest"
    }
}

これを元にプロパティを定義します.

CommandJson.cs
public class CommandJson
{
    public Body body { get; set; }
    public Header header { get; set; }

    public class Body
    {
        public Origin origin { get; set; }

        public class Origin
        { 
            public string type { get; set; }
        }

        public string commandLine { get; set; }
        public int version { get; set; } = 1;
    }

    public class Header
    {
        public string requestId { get; set; }
        public string messagePurpose { get; set; }
        public int version { get; set; } = 1;
        public string messageType { get; set; }
    }

}

次に, 任意のコマンドを引数にして, そのコマンドの JSON を生成するメソッド CreateCommand() を作成します. MinecraftService.cs の中に追記します. さらに, そのメソッドを呼び出してブロードキャストすることにより, JSON形式のコマンドをマイクラに送信します.

MinecraftService.cs
protected override void OnMessage(MessageEventArgs e)
{
    // ここの2行は確認のために残しておくと良いです
    var json = JsonSerializer.Deserialize<ResponseJson>(e.Data);
    Console.WriteLine(json.body.message);
    
    var command = "/title @p title Hello from C#!";
    Sessions.Broadcast(CreateCommand(command));
}

private string CreateCommand(string comamnd)
{
    var json = new CommandJson
    {
        body = new CommandJson.Body
        {
            origin = new CommandJson.Body.Origin
            {
                type = "player"
            },
            commandLine = comamnd,
        },
        header = new CommandJson.Header
        {
            requestId = Guid.NewGuid().ToString(),
            messagePurpose = "commandRequest",
            messageType = "commandRequest"
        }
    };

    return JsonSerializer.Serialize(json);
}

さて, このまま実行すると面白いことが起こります.

なんと, 無限にタイトルコマンドを送信し続けます(ピャー⤴笑)

こうなってしまうのは, サーバが随時レスポンスを受け取っているためです. マイクラへタイトルを表示させると, そのタイトルが表示されたというイベントが発生し, それをトリガーにタイトルのJSONがサーバへ送られます. レスポンスを受け取ると, 再びコマンドを実行してしまうため, 無限コマンド編が出来上がるわけですね. なので, ここで必要になってくるのが

  • 誰がコマンドを送信したか (sender の評価)
  • どのような発言をしたか (message の評価)

の2つです. したがって, コマンドを送信する前にこれらを比較できるように条件分岐を用いてあげれば良さそうですね.

MinecraftService.cs
protected override void OnMessage(MessageEventArgs e)
{
    var json = JsonSerializer.Deserialize<ResponseJson>(e.Data);
    Console.WriteLine(json.body.message);

    if(json.body.sender == "takunology" && json.body.message == "hello")
    {
        // ここらへんでマイクラのコマンドを定義しておく!
        var command = "/title @p title Hello from C#!";
        Sessions.Broadcast(CreateCommand(command));
    }
}

これで上記の2点の評価がなされて, 指定したプレイヤーIDかつメッセージが発言されたときのみにコマンドが実行されます. マイクラからサーバへ接続し "hello" と発言したときと, そうでないととでタイトルが表示されるかどうか確認してみてください. チャットで発言した瞬間に ESC を押してゲーム画面に戻るとタイトルが見えます.

"hello" のとき

"hello" でないとき

これで, コマンドの送信方法もバッチリですね!

応用編

ここからは Subscribe イベントを使用して, 特定の条件を満たす場合にレスポンスを送ってもらうような処理を作成します. これまで, プレイヤーがチャットで発言することによって, サーバはそのJSONを受け取ることができていました. しかし, マイクラでは例えば

  • プレイヤーが動いたとき
  • ブロックが設置されたとき
  • モンスターを倒したとき

といったような各種イベントが発生します. これらはサーバへ接続した際に Subscribe イベントをあらかじめ登録しておかないと, このようなイベント発生時にレスポンスを受け取れません.

まずは, Subscribe イベントの JSON の雛形です.

{
    "body": {
        "eventName": "<EventName>" // イベントの種類
    },
    "header": {
        "requestId": "xxxxxxxx-xxxx-xxxx-xxxxxxxxxxxxxxxxx", // UUID v4
        "messagePurpose": "subscribe",
        "version": 1, // ここはプロトコルのバージョン(固定)
        "messageType": "commandRequest"
    }
}

これを参考にプロパティを作っていきます.

EventSubscribeJson.cs
public class EventSubscribeJson
{
    public Body body { get; set; }
    public Header header { get; set; }

    public class Body
    {
        public string eventName { get; set; }
    }

    public class Header
    {
        public string requestId { get; set; }
        public string messagePurpose { get; set; }
        public int version { get; set; }
        public string messageType { get; set; }
    }
}

次にシリアライザです. まずは何かしらのブロックが設置されたときに, どんなデータが返ってくるか確かめてみます. ブロックが設置されたかどうかは BlockPlaced イベントを登録すれば良さそうです. レスポンス部分もちょっと書き換えます.

MinecraftService.cs
protected override void OnOpen()
{
    Console.WriteLine("Connected.");
    // サーバとの接続ができたらイベントを登録しておく
    Sessions.Broadcast(CreateEventSubscribe("BlockPlaced"));
}

protected override void OnMessage(MessageEventArgs e)
{
    // とりあえずコマンド出力させてJSON構造体を把握したい
    Console.WriteLine(e.Data);
}

private string CreateEventSubscribe(string eventName)
{
    var json = new EventSubscribeJson
    {
        body = new EventSubscribeJson.Body
        {
            eventName = eventName
        },
        header = new EventSubscribeJson.Header
        {
            requestId = Guid.NewGuid().ToString(),
            messagePurpose = "subscribe",
            messageType = "commandRequest"
        }
    };

    return JsonSerializer.Serialize(json);
}

書けたら実行して, 適当なブロックを置いてみてください. JSON形式のデータが受け取れます.

これを整形すると, こんな構造になっています.

長いので畳んでおきます
{
	"body": {
		"block": {
			"aux": 0,
			"id": "planks",
			"namespace": "minecraft"
		},
		"count": 1,
		"placedUnderWater": false,
		"placementMethod": 0,
		"player": {
			"color": "ff5454ff",
			"dimension": 0,
			"id": -4294967295,
			"name": "takunology",
			"position": {
				"x": -90.0643539428711,
				"y": 78.62001037597656,
				"z": 32.29999923706055
			},
			"type": "minecraft:player",
			"variant": 0,
			"yRot": 84.33499145507812
		},
		"tool": {
			"aux": 0,
			"enchantments": [],
			"freeStackSize": 0,
			"id": "planks",
			"maxStackSize": 64,
			"namespace": "minecraft",
			"stackSize": 64
		}
	},
	"header": {
		"eventName": "BlockPlaced",
		"messagePurpose": "event",
		"version": 16973824
	}
}

これ, 結構エグい構造体になってますね...
こんなときは変換ツールを使ってラクをしましょう.

https://www.site24x7.com/ja/tools/json-to-csharp.html

ただし, このサイトで変換されたコードをそのまま貼り付けても正しい記述ではないので, 適度に修正しておきます. ちなみに, namespace は名前が衝突するので, Json のプロパティ名を自分で指定できる属性 [JsonPropertyName("<Name>")] をつけてあげます.

長いので畳んでおきます
BlockPlacedEventJson
using System.Text.Json.Serialization;

public class BlockPlacedEventJson
{
    public Body body { get; set; }
    public Header header { get; set; }
}
public class Block
{
    public int aux { get; set; }
    public string id { get; set; }
    [JsonPropertyName("namespace")]
    public string nameSpace { get; set; }
}
public class Position
{
    public double x { get; set; }
    public double y { get; set; }
    public double z { get; set; }
}
public class Player
{
    public string color { get; set; }
    public int dimension { get; set; }
    public int id { get; set; }
    public string name { get; set; }
    public Position position { get; set; }
    public string type { get; set; }
    public int variant { get; set; }
    public double yRot { get; set; }

}
public class Tool
{
    public int aux { get; set; }
    public IList<string> enchantments { get; set; }
    public int freeStackSize { get; set; }
    public string id { get; set; }
    public int maxStackSize { get; set; }
    [JsonPropertyName("namespace")]
    public string nameSpace { get; set; }
    public int stackSize { get; set; }
}
public class Body
{
    public Block block { get; set; }
    public int count { get; set; }
    public bool placedUnderWater { get; set; }
    public int placementMethod { get; set; }
    public Player player { get; set; }
    public Tool tool { get; set; }

}
public class Header
{
    public string eventName { get; set; }
    public string messagePurpose { get; set; }
    public int version { get; set; }

}

あとは, プロパティを参照して値が取れるかどうかを確認しておきます. 例えば, ブロックを置いたときのプレイヤーの位置を知るには, こんな感じに書きます.

MinecraftService.cs
protected override void OnMessage(MessageEventArgs e)
{
    var json = JsonSerializer.Deserialize<BlockPlacedEventJson>(e.Data);
    var x = json.body.player.position.x;
    var y = json.body.player.position.y;
    var z = json.body.player.position.z;
    Console.WriteLine($"{x}\t{y}\t{z}");
}

実行すると, コンソール上にプレイヤーの座標が表示されます.

-89.03551483154297      78.62001037597656       32.29999923706055

ここまでで, ブロックを置いたときにプレイヤーの座標を取得する処理ができました. あとは何か面白そうなことをやればいいのですが...

そうだ! TNT の壁を作ろう!(脳死)
子供ってとりあえず TNT 置いて爆発させたがりますよね~~(しらんけど)

もちろん, 今回も条件分岐をつかって誰がブロックを設置したかを評価します. でないと, ブロックを設置したことをトリガーにして, ブロックを設置して...を繰り返すことになり, 無限ブロック設置編が始まります. しかも, ブロックを設置したのがプレイヤーかサーバかでレスポンスも異なるので, このままでは JSON 形式に変換できず, 一部 null になってしまいます. なので, レスポンスの中にプレイヤー名が含まれるかどうかで, 上記の問題を解決します. (もし他にいい方法があれば教えてください.)

protected override void OnMessage(MessageEventArgs e)
{
    var json = JsonSerializer.Deserialize<BlockPlacedEventJson>(e.Data);
    
    if (e.Data.Contains("takunology"))
    {
        var x = json.body.player.position.x;
        var y = json.body.player.position.y;
        var z = json.body.player.position.z;
        var command = $"/fill {x - 5} {y} {z + 5} {x + 5} {y + 5} {z + 5} tnt";
        Sessions.Broadcast(CreateCommand(command));
    }
}

これで実行してみます.

おお~これでレッドストーンとか添えたらもうヤバい.
他にもいろんなイベントがあるのですが, キリがないのでこの辺で.

おわりに

統合版でもC#からコマンド送信できました!
応用編とかは特に子供ウケがよさそうですね~. これができればコマンドの範囲内であれば何でもできそうです!

ただし, TNT の置きすぎには注意です!

参考

コマンド送信用JSONの書き方

https://gist.github.com/jocopa3/54b42fb6361952997c4a6e38945e306f

Subscribe イベントの書き方

https://gist.github.com/jocopa3/5f718f4198f1ea91a37e3a9da468675c

Discussion

ログインするとコメントできます