17
Help us understand the problem. What are the problem?

More than 1 year has passed since last update.

posted at

updated at

Organization

C#とCoreRCONでMinecraft自動化

はじめに

C# その2 Advent Calendar 2019 の8日目枠です。初参加になります。

マイクラ自動化といっても経験値トラップやレッドストーン回路を組むわけではありません。コードをガリガリ書いていきます。C#を使ってマイクラをリモート操作できますよという内容です。

マイクラ自動化をC#でやっている方があまりおらず、いい機会なのでこのテーマにしました。ここに書いている内容は私のブログをまとめたものですが、ブログよりも丁寧?に書いていますので長文になっております。

実行環境

  • Windows 10
  • Minecraft Java Edition 1.7.10
  • Forge 10.13.4.1614 + LiteLoader 1.7.10
  • Visual Studio 2019
  • .NET Core 3.0
  • CoreRCON 3.0.0
  • NPOI Version 2.4.1

1. Minecraft サーバの設定

サーバは起動できる状態を想定しています。起動するまでの手順はここでは省きます。また、ゲーム設定でチートモードをONにし、プレイヤーの権限レベルを4に設定しておいてください。この設定がないと、そもそもコマンドを使えません。

サーバ起動ファイルのディレクトリ内に "server.properties" があるのでこれを次のように編集します。

enable-rcon=true
rcon.password=minecraft
rcon.port=25575

上から順番にRCON接続の許可、接続パスワード、接続ポートを指定しています。この記述がないと外部からコマンドを投げることができません。できたら保存を忘れずに。

2. CoreRCON の導入

Visual Studio でコンソールアプリケーション(.NET Core)のプロジェクトを作成します。作成したらパッケージマネージャコンソールにてCoreRCONを導入します。

Install-Package CoreRCON -Version 3.0.0

RCONは簡単に言えば「マイクラのコマンドをリモートで通信するためのプロトコル」です。これがないとコマンドを投げられないので忘れずに導入しましょう。

3. 簡単なコマンドの送信

まずは簡単なコマンドを投げてみましょう。簡単なコマンドと言えば "time set 0" や "weather clear" などの自然現象設定コマンドですかね...。
手順に沿ってコマンドを投げる方法を書いていきます。

3.1 非同期なメソッドの作成

RCON自体が非同期なので、まずは非同期メソッドを作ります。名前空間とクラスは省略して書くので、環境に合わせてご利用ください。

using System.Threading;
using System.Threading.Tasks;

static async Task Main(string[] args)
{
    await Command();
}

static async Task Command()
{
    //ここにコマンドを書いていく
}

これでメソッドは書けました。

3.2 RCON接続設定

次にRCONへ接続するための準備を行います。必要な情報は次の通りです。

  • サーバのIPアドレス
  • RCONのポート番号
  • RCON接続パスワード

これらは先ほど"server.properties" で設定した値をそのまま使います。RCONを上記の情報を使ってインスタンス化します。

using CoreRCON;
using System.Net;

static async Task Command()
{
    var serveraddress = IPAddress.Parse("127.0.0.1");
    ushort port = 25575;
    var serverpass = "minecraft";
    //RCONインスタンス生成
    var connection = new RCON(serveraddress, port, serverpass);
}

これでRCONを使う準備ができました。

3.3 コマンドを投げる

RCONインスタンスにはコマンドを投げるメソッドがあるので、これに投げたいコマンドを引数にいれて実行します。

var command = "weather rain";
await connection.SendCommandAsync(command);

このコマンドを実行すると天気を雨にすることができます。すごく簡単ですね。
コマンドの種類についてはこちらを参考にしてください。

3.4 結果をみたい

SendCommandAsync()メソッドの返り値はMinecraftのログなので、適当な変数を用意してそこに代入させればログを表示することができます。

では、ここまでの内容をまとめてコードに書いてみます。

using System;
using CoreRCON;
using System.Net;
using System.Threading;
using System.Threading.Tasks;

static async Task Main(string[] args)
{
    await Command();
}

static async Task Command()
{
    var serveraddress = IPAddress.Parse("127.0.0.1");
    ushort port = 25575;
    var serverpass = "minecraft";
    //RCONインスタンス生成
    var connection = new RCON(serveraddress, port, serverpass);
    //コマンドを投げる
    var command = "weather rain";
    var result = await connection.SendCommandAsync(command);
    //結果の表示
    Console.WriteLine(result);
}

これでコマンドの送信と結果(ログ)の表示までできるようになりました。
ただし、ゲーム自体が日本語でも返ってくる文字列は英語なので注意が必要です。
2019-12-04.png

4. プレイヤーの座標を取得する

残念ながらプレイヤー自身の座標を得るコマンドがありません。自身の座標がないと色々と不便だと思いますので、どのようにして取得したかをまとめます。

4.1 テレポートコマンドの送信

座標を得るためにはテレポートコマンドを投げるしかありません。テレポートするには対象となるユーザ名が必要なので追加します。

string PlayerName = "playername";
var command = $"/tp {PlayerName} ~ ~ ~";

コマンドを投げると、結果としてこのように返ってきます。

Teleported playername to 981.8198397840333,74.76969909395412,1985.788694989508

ご丁寧に、英文つきで値が返ってくるのです。
このままでは座標ごとに値で保存できなくて辛いので、ここから数値データだけを抜き出します。

4.2 正規表現による文字列のフィルタ

まずは数値だけが欲しいので、数値以外の文字列を除外します。

using System.Text.RegularExpressions;
string TmpChar = Regex.Replace(result, @"[^0-9-,.]", "");

除外した文字列は一度変数に保存しておきます。

Ragexは正規表現に関するクラスです。今回はその中の Replace() メソッドを用いました。Replace は正規表現に一致するものへ置換して返すメソッドです。このままだと一致するものになってしまうため、指定した文字以外[^ ]を使って除外します。
つまり、0 ~ 9とカンマ以外は除外されます。

これで余計な英文を除去できました。

981.8198397840333,74.76969909395412,1985.788694989508

4.3 座標データの格納

除外した文字列はカンマ区切りになっているので、カンマごとに各座標の値を保持していきます。

string[] StrArray = TmpChar.Split(',');
foreach (var item in StrArray)
{
    Console.WriteLine(item);
}

確認のために、みんな大好き foreach でぶん回して保持された座標を見てみます。

981.8198397840333
74.76969909395412
1985.788694989508

しかし、このままでは文字列型で不便なので double型にパースしていじれるようにします。foreach をfor文に書き換えます。

double[] PlayerPosition = new double[3];
for(int i = 0; i < StrArray.Length; i++)
{
    PlayerPosition[i] = double.Parse(StrArray[i]);
    Console.WriteLine(PlayerPosition[i]);
}

これでプレイヤー座標 (x, y, z) を得ることができました。

5. ブロックの配置

プレイヤー座標を得られたことで、for文をぶん回して座標を進め、そこにブロックを配置できるようになります。
配置コマンド: /setblock X Y Z ブロック名またはID

5.1 一次元方向でのブロック配置

これはfor文1つで実行可能です。オリジナルの座標をいじると再利用できないので、これをコピーした変数をいじくります。

double X = PlayerPosition[0];
double Y = PlayerPosition[1];
double Z = PlayerPosition[2];
string BlockName = "stone";

for (int i = 0; i < 5; i++)
{
    string Command = $"/setblock {X + i} {Y} {Z} {BlockName}";
    var result = await connection.SendCommandAsync(Command);
    await Task.Delay(10);
    Console.WriteLine(result);
}

これで実行すると、X方向へ5個の石ブロックが配置できます。
2019-12-04 (1).png
ブロック名やIDはこちらを参考にしてみてください。

5.2 二次元方向でのブロック配置

これは2重for文で実装すればできますが、ここで注意です。地面と平行な方向へ2次元に配置したい場合は Y に j を足すと高さの方向へブロックが積まれてしまうので、Z に加算するようにします。

for (int i = 0; i < 5; i++)
{
    for (int j = 0; j < 5; j++)
    {
        string Command = $"/setblock {X + i} {Y} {Z + j} {BlockName}";
        var result = await connection.SendCommandAsync(Command);
        await Task.Delay(10);
        Console.WriteLine(result);
    }
}

実行結果です。
2019-12-04 (2).png

5.3 三次元方向でのブロック配置

3重for文を使います。もし、水平方向から積み上げていくようにしたい場合は、一番外側にY座標に対するfor文を書いていきます。どのように積み上げていくかはfor文を入れ替えるか変数を入れ替えるかして工夫します。この例では変数を入れ替えています。

for (int i = 0; i < 5; i++)
{
    for (int j = 0; j < 5; j++)
    {
        for (int k = 0; k < 5; k++)
        {
            string Command = $"/setblock {X + j} {Y + i} {Z + k} {BlockName}";
            var result = await connection.SendCommandAsync(Command);
            await Task.Delay(10);
            Console.WriteLine(result);
        }
    }
}

実行結果です。
2019-12-04 (3).png

6. 建築の自動化

ここまでで座標位置のいじりかたとブロックの配置方法が分かったので、次は建築を自動化してみます。設計図はExcelを使います。行と列はX座標とZ座標、シートはY座標として扱えばわかりやすいと思います。まずはExcelファイルを読み込むところから始めていきます。

6.1 Excelファイルの読み込み

COM参照する方法があるそうなのですが、こちらの記事によると、この方法は良くないとのこと。ということで、Nugetのパッケージにお世話になります。
Excelの読み込みに使うパッケージはNPOIです。

Install-Package NPOI -Version 2.4.1

新しくクラスを作ります。クラス名はExcelReadにしました。その中にファイルオープン用のメソッドと、Excelシートの各セルの値を読み込むメソッドを作ります。
また、Excelを格納するディレクトリを作成し、その中にHouse.xlsxファイルを作りました。

static int SHEET = 5;
static int ROW = 10;
static int COL = 10;
public static string[,,] Value = new string[ROW, SHEET, COL];

public void ExcelOpen()
{
    string Path = "../../../Excel/House.xlsx";
    var Book = WorkbookFactory.Create(Path); //参照するブックのパス
    //マイクラの座標に合わせるために x, y, z を使う                                          
    for (int y = 0; y < SHEET; y++)
    {
        var Sheet = Book.GetSheetAt(y); //N枚目のシートを参照
        for (int x = 0; x < ROW; x++)
        {
            for (int z = 0; z < COL; z++)
            {
                Value[x, y, z] = GetValue(x, Sheet, z); //読み込んだ値を保持
            }
        }
    }
}

値を受けとるメソッドです。文字列にパース指定しています。

public string GetValue(int Row, ISheet Sheet, int Column)
{
    //例外対策(なければ空のシートを追加)
    var row = Sheet.GetRow(Row) ?? Sheet.CreateRow(Row); 
    var cell = row.GetCell(Column) ?? row.CreateCell(Column);
    string value = cell.NumericCellValue.ToString();
    return value;
}

6.2 ブロックとの対応付け

先ほど得られた値(文字列)をswitch文で分岐させて、ブロックIDを指定することにしました。この値をもとにsetblockコマンドを送れば建築できます。新しくConvertFromExcelクラスを作り、その中にConvertメソッドを書きました。
対応付けで代入している値(文字列)はブロックのIDです。例えば値が0だと空気ブロック、1だと石というようにブロックにはそれぞれIDが割り振られています。なので、setblockコマンドを投げるとき、ブロック名の部分をIDで指定することもできます。

public void Convert()
{
    for (int y = 0; y < Value.GetLength(1); y++)
    {
        for (int x = 0; x < Value.GetLength(0); x++)
        {
            for (int z = 0; z < Value.GetLength(2); z++)
            {
                string value = Value[x, y, z];
                switch (value)
                {
                    case "0":
                        Value[x, y, z] = "0"; //空気
                        break;

                    case "1":
                        Value[x, y, z] = "1"; //石
                        break;

                    case "2":
                        Value[x, y, z] = "20"; //ガラス
                        break;

                    case "3":
                        Value[x, y, z] = "5 0"; //木材(オーク)
                        break;

                    case "4":
                        Value[x, y, z] = "17"; //原木
                        break;

                    case "5":
                        Value[x, y, z] = "24"; //砂岩
                        break;

                    case "6":
                        Value[x, y, z] = "45"; //レンガ
                        break;
                }
            }
        }
    }
}

これはオプションですが、読み込めているかの確認で表示用メソッドも作りました。

public void ShowSheet()
{
    for (int y = 0; y < Value.GetLength(1); y++)
    {
        Console.WriteLine($"{y + 1}枚目のシート");
        for (int x = 0; x < Value.GetLength(0); x++)
        {
            for (int z = 0; z < Value.GetLength(2); z++)
            {
                Console.Write(string.Format("{0, 3}", ($"{Value[x, y, z]}")));
            }
            Console.WriteLine();
        }
    }
}

6.3 建築の実行

これらのメソッドを順番に読み込んで実行します。ファイルを開く、ブロックの関連付け、配置コマンド実行の順です。まずは配置コマンドを投げるための非同期メソッドを作ります。そのときに、関連付けメソッドのあるクラスのインスタンスも生成します。また、プレイヤーの座標を取得しておきたいので、GetPosition()メソッドを先に追記しておきます。

static async Task Building()
{
    await GetPosition(); //座標取得
    //建築用のインスタンス
    ConvertFromExcel convertFromExcel = new ConvertFromExcel();
    convertFromExcel.ExcelOpen(); //ファイル読み込み
    convertFromExcel.Convert(); //ブロック参照
    convertFromExcel.ShowSheet(); //読み込んだ値の確認

    double X = PlayerPosition[0];
    double Y = PlayerPosition[1];
    double Z = PlayerPosition[2];
    for (int y = 0; y < convertFromExcel.Value.GetLength(1); y++)
    {
        for (int x = 0; x < convertFromExcel.Value.GetLength(0); x++)
        {
            for (int z = 0; z < convertFromExcel.Value.GetLength(2); z++)
            {
                string SetBlock = $"/setblock { X + x } { Y + y } { Z + z } { convertFromExcel.Value[x, y, z] }";
                var result = await connection.SendCommandAsync(SetBlock);
                Console.WriteLine(result);
                await Task.Delay(5);
            }
        }
    }
}

そしてこのメソッドを呼び出すためにMainメソッドに追記します。

static async Task Main(string[] args)
{           
    await Building();
}

Excelの内容はswitch文で決めたような数値で、ブロックとの対応付けを考えながら適当に調整します。
2019-12-04 (4).png
そして動かしてみます。
m06ga-sdcvl.gif

これでC#を使って建築することができました。
コンソールに表示される Warning はプログラム的な意味ではなくて、ブロックIDを指定しているのが問題のようです。IDを使ったブロックの指定は今後サポートされないという内容です。

7. 湧き潰しの自動化

くらでべチャンネルでPython, Docker, Azureなどを用いてリモート操作し、湧き潰しの自動化をしていました。これを自分のローカル環境上で動かしてみます。

7.1 湧き潰しとは

Minecraftの世界では暗い場所でモンスターが湧きます。モンスターが湧くと、プレイヤーや村人を襲うようになるので非常に危険な状態となります。そこで、光源となるようなもの(主に松明)を置いて暗い場所をつぶしていくことで、モンスターの発生を防ぎます。これを湧き潰しと言います。

さて、問題はどのようにして湧き潰しを行うかです。こちらのサイトによると、6マス間隔で置くのが効率が良いとのことなので、これに則ってプログラムを書いていきます。

7.2 湧き潰しのロジック

6マス間隔に置くので、0ブロック目を置いたとすると次は6マス開けて7マス目に置きます。その次は6マス開けて14マス目...というように7の倍数で置きます。余剰演算子7で割り、あまりがない場合は置くことにします。
とりあえず、自分を基準にX座標とZ座標の方向へ等間隔に松明を設置してみます。

static async Task PutTorches()
{
    await GetPosition();

    double X = PlayerPosition[0];
    double Y = PlayerPosition[1];
    double Z = PlayerPosition[2];
    //プレイヤーを中心に50マス×50マスの範囲で湧き潰し            
    int RangeX = 50;
    int RangeZ = 50;
    string BlockName = "torch";

    for (int i = 0; i < RangeX; i++)
    {
        if (i % 7 == 0) //7の倍数ごとに設置
        {
            for (int j = 0; j < RangeZ; j++)
            {
                if (j % 7 == 0)
                {   //松明を置くコマンド
                    string PutTorch = $"/setblock {X + i} {Y} {Z + j} {BlockName}";
                    var result = await connection.SendCommandAsync(PutTorch);
                    Console.WriteLine(result);
                    await Task.Delay(5);
                }
            }
        }
    }
}

これで実行してみると、プレイヤーのいる高さの平面上に松明が配置されます。
2019-12-05 (1).png

ただし、その高さでブロックが存在すると置き換えてしまうので、穴が開いてしまうのが残念です。そこで、地表判定を行うようなロジックを考えます。

7.3 地表判定ロジック

上記の方法だけでは平面にどんなブロックがあっても強制的に松明に置き換えてしまいます。このままでは村のブロックを破壊しながら湧き潰ししてしまうことになるので、ちょっとした工夫が必要です。
松明がブロックを置き換えることの無いよう、地表にだけ設置するようにします。
また、松明を置くとき、そのブロックに草が生えていると設置できません。なので、空気ブロックかどうかを調べて空気でないなら置き換えるような処理を書いていきます。

プレイヤーの高さを基準に空へ向かって線形探索を行い、空気ブロックだったらそこに松明を置くようにします。
先に作成した二重for文の一番内側にこのロジックを実装すれば平面上7マスごとに探索が行われるので、計算量が少なくなります。

ブロックを調べるコマンドは /testforblock X Y Z ブロック名 です。また、このコマンドの返り値としてブロック名に条件が一致すれば "Successfully" と表示され、条件に合わない場合は見つかったブロック名でログを返してくれます。なので、これを基準に条件分岐を行って判定していきます。高さはプレイヤーの初期位置にして最高高度からその高さを引いた間で探索を行うようにします。

//初期値はプレイヤー座標
for(int k = (int)Y; k < (255 - (int)Y); k++)
{   //ブロックを調べるコマンド
    string Search = $"/testforblock {X + i} {k} {Z + j} {SearchBlock}";
    var result = await connection.SendCommandAsync(Search);
    //文字列検索 空気or草なら松明を置ける
    if (result.Contains("Successfully") || result.Contains("tallgrass"))
    {
        //草だったら刈る(空気ブロックに置換)
        if (result.Contains("tallgrass"))
        {   
            string Cut = $"/setblock {X + i} {k} {Z + j} air";
            result = await connection.SendCommandAsync(Cut);
        }
        //松明を置くコマンド
        string PutTorch = $"/setblock {X + i} {k} {Z + j} {BlockName}";
        result = await connection.SendCommandAsync(PutTorch);
        Console.WriteLine(result);
        await Task.Delay(5);
    }
}

このロジックを先ほどの二重for文に組み込みます。

static async Task PutTorches()
{
    await GetPosition();

    double X = PlayerPosition[0];
    double Y = PlayerPosition[1];
    double Z = PlayerPosition[2];
    //プレイヤーを中心に100マス×100マスの範囲で湧き潰し            
    int RangeX = 100;
    int RangeZ = 100;
    string BlockName = "torch";
    string SearchBlock = "air";

    for (int i = 0; i < RangeX; i++)
    {
        if (i % 7 == 0) //7の倍数ごとに設置
        {
            for (int j = 0; j < RangeZ; j++)
            {
                if (j % 7 == 0)
                {   //初期値はプレイヤー座標
                    for (int k = (int)Y; k < (255 - (int)Y); k++)
                    {   //ブロックを調べるコマンド
                        string Search = $"/testforblock {X + i} {k} {Z + j} {SearchBlock}";
                        var result = await connection.SendCommandAsync(Search);
                        Console.WriteLine(result);
                        await Task.Delay(5); //応答速度調整
                        //文字列検索 空気or草なら松明を置ける
                        if (result.Contains("Successfully") || result.Contains("tallgrass"))
                        {
                            //草だったら刈る
                            if (result.Contains("tallgrass"))
                            {
                                string Cut = $"/setblock {X + i} {k} {Z + j} air";
                                result = await connection.SendCommandAsync(Cut);
                            }
                            //松明を置くコマンド
                            string PutTorch = $"/setblock {X + i} {k} {Z + j} {BlockName}";
                            result = await connection.SendCommandAsync(PutTorch);
                            Console.WriteLine(result);
                            await Task.Delay(5);
                            break; //置いたら抜ける
                        }
                    }
                }
            }
        }
    }
}

実行してみるとこんな感じになりました。

le9v5-plop9.gif

ここではプレイヤーよりも高い位置での探索を行いましたが、プレイヤーよりも下の位置に松明を置くことも可能です。例えば初期値をプレイヤーよりも20マス下にすれば、20マス低い土地に対しても湧き潰しすることができます。この辺は個々のさじ加減ですね。

おわりに

上記以外にも、コマンドが使える範囲であればなんでもできると思います。
私のブログでは、指定範囲内で最も高い位置にあるブロックの座標の表示、整地の自動化、ブロックの種類別数え上げなど様々なことをやっています。今後も色々検証しながら、できることを探していきたいですね。

Minecraftはゲームではありますが、使い方次第ではとてもいい勉強ツールになります。
個人的には、このようなツールを使ってプログラミングを学べるような環境を作るのも一つの手かなと考えております。
特に、Minecraft Educationだけでは満足できない人にはオススメです。

今回作ったコードはこちらにあります。参考にどうぞ。

また、Minecraft自動化について解説した専用サイト Minecraft with Code を作りました。自動化の詳しい内容はこちらからご覧いただければと思います。

参考

  1. サーバ設定ファイルについて
  2. Minecraft に C# からコマンド叩き込んで操作してみた
  3. たくのろじぃのメモ部屋
  4. 【C#】NPOIならExcel操作が簡単にできる!使い方まとめ
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Sign upLogin
17
Help us understand the problem. What are the problem?