これは前編の続きです。前編を読んだ前提で、続きから解説していきます。
Modを書く
ファイル構成
./src/main/java/
以下に、maven_group
とMod IDに従って、フォルダーを作っていきます。今回は、io/github/apple502j/kanaify/
以下にコードを設置します。同フォルダーには、Mixin用のコードを置くmixin
フォルダーも作りましょう。
LunaChatのソースコードから、src/main/java/com/github/ucchyocean/lc3/japanize/
以下のファイルを、フォルダごとこのModのsrc/main/java
以下にコピーします。
イニシャライザー
Modには、「イニシャライザー」という、初期化のために実行されるコードが必要です。これは、クライアントやサーバーが起動するときに実行されますが、注意しておきたいのは、クライアント内でサーバーが実行されることがあるということです。つまり、シングルプレイヤーモードでワールドを開いたときには、サーバーが起動され、ワールドを終了してメインメニューに戻ると、サーバーが終了するというわけです。そして、サーバーはワールドを1回開くごとに再作成されるため、イニシャライザーもその回分(+クライアント起動時の1回)実行することになります。
イニシャライザーでは、まずログを記録することにします。System.out.println
でもいいのですが、せっかくMinecraftはLog4Jを使用しているので、これを使ってみます。まずイニシャライザー用のファイル、KanaifyMod.java
を作成して、以下のように記載します。
package io.github.apple502j.kanaify;
import net.fabricmc.api.ModInitializer;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
public class KanaifyMod implements ModInitializer {
public static final Logger LOGGER = LogManager.getLogger("kanaify");
@Override
public void onInitialize() {
LOGGER.info("Kanaifier initialized.");
}
}
ModInitializer
は、イニシャライザーが実装すべきインターフェースで、FabricのModローダーから提供されています。static
なフィールドでLOGGER
を定義し、インターフェースで定義されたonInitialize
にてログを書き込むよう実装します。
最後に、イニシャライザーの場所をfabric.mod.json
内で指定します。entrypoints
オブジェクトのmain
欄の配列を、["io.github.apple502j.kanaify.KanaifyMod"]
のようにイニシャライザーのクラスの完全修飾名に変えておきます。
スレッド化
LunaChatは、パケットを受信したスレッドでかな漢字変換APIを叩きます。これを別スレッドで行うよう変更してみましょう。ここはめんどくさいので、私の実装を参考にしてみてください。このKanaifier
クラスが、ローマ字漢字変換用の処理を行う形になります。これはシングルトンKanaifier.INSTANCE
からアクセスできるようにしました。
イニシャライザーでは、Kanaifier.INSTANCE
にKanaifier
の新しいインスタンスを設定しますが、ここで気を付けないといけないのは、イニシャライザーの多重実行のため、すでにKanaifier.INSTANCE
が設定されている場合があるということです。この場合は、既存のものをclose
し、新しいものを作成するようにしました。もちろん、既存のものを使いまわしても問題はないはずです。
ExecutorService
は、サーバー終了後にもきちんと終了する必要があります。これも後述します。
Mixinに突入
Mixinは、前述のとおりモンキーパッチです。既存のクラスの中に挿入(Inject
)したり、乗っ取り(Redirect
)や上書き(Overwrite
)を行って、ゲームの動作を変更します。
まず、ExecutorService
の終了のために、MinecraftClient
とMinecraftDedicatedServer
のclose
メソッドの上部に、Kanaifier.INSTANCE.close();
を呼び出す処理を追加します。これはInject
にあたります。
mixin
フォルダー内にMinecraftClientMixin.java
ファイルを作成し、以下のように記載します。
package io.github.apple502j.kanaify.mixin;
import net.fabricmc.api.EnvType;
import net.fabricmc.api.Environment;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
import net.minecraft.client.MinecraftClient;
import io.github.apple502j.kanaify.Kanaifier;
import io.github.apple502j.kanaify.KanaifyMod;
@Mixin(MinecraftClient.class)
@Environment(EnvType.CLIENT)
public class MinecraftClientMixin {
@Inject(at = @At("HEAD"), method = "close()V")
private void close(CallbackInfo info) {
if (Kanaifier.INSTANCE == null) return;
try {
Kanaifier.INSTANCE.close();
} catch (Exception e) {
KanaifyMod.LOGGER.warn("Error during Kanaifier shutdown: ", e);
}
Kanaifier.INSTANCE = null;
}
}
@Mixin(MinecraftClient.class)
とは、MinecraftClient
クラスにMixinを行うことを、@Environment(EnvType.CLIENT)
は、クライアント側のみでMixinをすべきことを示します。(ただし、これは便宜上のもので、付けなくても問題はありません。) Mixinといっても、アノテーションがあることを除けば通常のクラスです。private void
なメソッドclose
を定義します。このメソッドは、Mixin先のメソッドの引数すべてに加え、CallbackInfo
という返り値などの設定に使うインスタンスを受け取ります。MinecraftClient#close
は引数を受け取らないため、MixinのメソッドはCallbackInfo
のみを受け取ります。
この上にある@Inject
アノテーションが、挿入場所を決めています。at = @At("HEAD")
は、メソッドの最上部という意味で、method = "close()V"
は、メソッド名を示しています。Javaでは、異なる引数を持つ複数のメソッドが同じ名前を持つことが許されているため(オーバーロード)、ここでは引数の型を指定したclose()V
(closeメソッド、引数なし、返り値はvoid(V
))を指定します。IntelliJ IDEAを使用していて、Minecraft Developmentプラグインをインストールした場合は、メソッドを右クリックしてCopy
からMixin Target Reference
を選ぶとコピーできます。
中のコードは簡単です。
- 何らかの理由で二重終了した場合は何もしない
-
Kanaifier.INSTANCE.close();
を呼び出す(念のため例外キャッチも) -
Kanaifier.INSTANCE
をnull
にする
同じようなコードを、MinecraftDedicatedServer
に対しても書きます。このときは、@Environment(EnvType.SERVER)
アノテーションを使用します。
PlayerManager#broadcastを乗っ取る
前回、チャットメッセージを送信している部分が、ServerPlayNetworkHandler#handleMessage
内のPlayerManager#broadcast
ということがわかりました。これを乗っ取るには、まずServerPlayNetworkHandler
内にMixinします。ここでは既存のロジックを乗っ取るため、Redirect
を使用します。この場合は、
@Redirect(method = "乗っ取るメソッドが呼び出されているメソッドの名前", at = @At(value = "INVOKE", target = "乗っ取るメソッド"))
のように記述します。返り値は、乗っ取るメソッドと同じです。また、PlayerManager
への参照を、第一引数として受け取る以外は、引数も乗っ取るメソッドと同じです。
メソッド名の指定は、IntelliJ IDEAでコピーしてもいいのですが、せっかくの機会ですから覚えてみましょう。クラス名を指定するときは、L
の後に完全修飾名のドットをスラッシュで置き換えたものを書き、セミコロンで終えます。その後メソッド名、そしてカッコ内に引数を指定し、最後に返り値を指定します。引数は、int
の場合はI
、boolean
の場合はZ
、クラスのインスタンスの場合は前述の方法でクラスを指定したものとなります。返り値も同様です。また、返り値がvoid
の場合はV
を指定します。そのため、以下のようになります:
@Mixin(ServerPlayNetworkHandler.class)
public class ServerPlayNetworkHandlerMixin {
@Redirect(method = "handleMessage(Lnet/minecraft/server/filter/TextStream$Message;)V",
at = @At(value = "INVOKE", target = "Lnet/minecraft/server/PlayerManager;broadcast(Lnet/minecraft/text/Text;Ljava/util/function/Function;Lnet/minecraft/network/MessageType;Ljava/util/UUID;)V"))
private void broadcastKanaified(PlayerManager playerManager, Text serverMessage, Function<ServerPlayerEntity, Text> playerMessageFactory, MessageType playerMessageType, UUID sender) {
/* 処理 */
}
}
実際に送信されるメッセージは、serverMessage
です。しかし、これは型がString
ではなく、Text
となっています。これは、Minecraft上で表示される文字列を示すクラスで、サブクラスに文字列をそのまま表示するLiteralText
や、全部や一部が翻訳できるTranslatableText
があります。チャットメッセージは、実は後者なのです。チャットメッセージを送信すると、画面には<ユーザー名> メッセージ
と表示されますが、これはリソースパックを用いて括弧の種類などを変更できるため、TranslatableText
になります。また、ユーザー名が第一引数、送信されたメッセージが第二引数です。ユーザー名を翻訳する意味はないので、この第二引数を取り出す必要があります。
引数の取り出し
KanaifyMod.java
と同階層にKanaifyUtil.java
を作り、引数の取り出しに使うユーティリティーメソッドを置きましょう。まず、引数Text text
がTranslatableText
かどうかを確認し、次にその翻訳キーがチャットメッセージのもの("chat.type.text"
)であるかを確認する必要があります。Minecraft 1.17.1以降はJava 16が必須なので、Java 14で導入されたisinstance
のパターンマッチングが使えます。
public static String getChatMessage(Text text) {
if (text == null) return "";
if (text instanceof TranslatableText chatText) {
if (CHAT_TEXT_KEY.equals(chatText.getKey())) {
/* 第二引数を返す */
}
}
/* 他のModが干渉してきた場合など */
KanaifyMod.LOGGER.warn("getChatMessage received unexpected input " + text);
return text.toString();
}
ここで問題が生じました。引数を取り出すメソッド、TranslatableText#getArg
はprivate
アクセスが掛かっています。これを呼び出したいのですが、現状はできません。
Access Widener
といっても、先人たちの知恵によりこうした問題は手早く解決できます。Fabric Modローダーには、アクセス制限レベルを強制的にpublic
に上書きしたり、クラスのfinal
を取り除いたりする機能「Access Widener」が存在します。これを設定してみましょう。
src/main/resources/
以下に、kanaify.accesswidener
という名前のファイルを作ります。書式は以下の通りです:
accessWidener v1 named
# #で始まる行はコメント
accessible method クラス メソッド名 引数と返り値
クラスや引数と返り値の指定は、Mixinと同様です。そのため、クラスnet.minecraft.text.TranslatableText
内に定義された、int
を受け取ってnet.minecraft.text.StringVisitable
を返すgetArg
メソッドは、以下のように指定します:
accessible method net/minecraft/text/TranslatableText getArg (I)Lnet/minecraft/text/StringVisitable;
最後に、このファイルへのパスを、fabric.mod.json
に書く必要があります。このファイルに"accessWidener" : "kanaify.accesswidener",
を追加すれば完了です。
Mixinの登録
同じフォルダーには、kanaify.mixins.json
というファイルがあります。ここに、作成したMixinを登録します。配列"mixins"
には、サーバーとクライアントの両方で実行すべきMixinを、"client"
では、クライアント側のみで実行すべきMixinを、両方とも単純なクラス名で指定します。また、配列"server"
で、サーバー側のみで実行すべきMixinを指定できます。MinecraftClientMixin
はクライアント側のみで、MinecraftDedicatedServerMixin
はサーバー側のみで実行したいので、そのように指定します。
ServerPlayNetworkHandler
はどうしましょうか。ここで、Mixinの指定時は、クライアント側に内蔵されたサーバーは、クライアント側とみなされることに注意が必要です。つまり、このMixinはサーバーとクライアントの両方で実行することになります。最後に、"package"
の値をMixinが含まれるパッケージに修正すると、結果は次のようになります:
{
"required": true,
"minVersion": "0.8",
"package": "io.github.apple502j.kanaify.mixin",
"compatibilityLevel": "JAVA_16",
"mixins": [
"ServerPlayNetworkHandlerMixin"
],
"client": [
"MinecraftClientMixin"
],
"server": [
"MinecraftDedicatedServerMixin"
],
"injectors": {
"defaultRequire": 1
}
}
チャットメッセージの作成
表示用には、プレイヤー名と変換前、変換後のメッセージを組み合わせて、新しいTranslatedText
を作らないといけません。まずユーザー名を元のText、source
から取り出し、変換前と変換後のメッセージを組み合わせたメッセージを、新しいTranslatableText
の引数とします。null
チェックなども加えた結果は、以下のようになります。
public static Text createChatMessage(Text source, String original, String message) {
if (source == null) return new LiteralText("");
if (source instanceof TranslatableText chatText) {
if ("chat.type.text".equals(chatText.getKey())) {
try {
StringVisitable username = chatText.getArg(0);
return new TranslatableText("chat.type.text", username.getString(), String.format("%s (%s)", message, original));
} catch (TranslationException exc) {
}
}
}
KanaifyMod.LOGGER.warn("createChatMessage received unexpected input " + source);
return source;
}
かな漢字変換コードを一般化する
Google翻訳API以外にも、かな漢字変換APIは存在します。これらを使いやすいよう、コードを抽象化させましょう。といっても、これはメインの話題ではないため、実装を確認してコピーで大丈夫です。
組み合わせる
これまで、MixinといったFabric系のMod開発に必要な部分は詳しく、それ以外は実装をコピーという形でコードを準備してきました。これらを組み合わせる時が来ました。
ServerPlayNetworkHandlerMixin
に戻ります。このMixin内では、次の処理を行う必要があります:
- メッセージ内容を、
KanaifyUtil#getChatMessage
で取得する。 - コピーした
Kanaifier.INSTANCE.convert(String)
で、ローマ字から漢字に変換する。これはCompletableFuture<String>
を返す。 - 変換後の文字列を用いて、送信するチャットメッセージを作成する。
-
PlayerManager#broadcast
を実行しなおす。
結果は、以下のようになります。
private void broadcastKanaified(PlayerManager playerManager, Text serverMessage, Function<ServerPlayerEntity, Text> playerMessageFactory, MessageType playerMessageType, UUID sender) {
String m = KanaifiyUtil.getChatMessage(serverMessage);
CompletableFuture<String> future = Kanaifier.INSTANCE == null ? CompletableFuture.completedFuture(m) : Kanaifier.INSTANCE.convert(m);
future.thenAcceptAsync((kanaified) -> {
Text kanaText = KanaifiyUtil.createChatMessage(serverMessage, m, kanaified);
playerManager.broadcast(kanaText, (_player) -> kanaText, MessageType.CHAT, sender);
});
}
ビルド
RAMが8GiB以下の場合は、ビルド前にIDE、ブラウザーなどのRAMを消費するプログラムをすべて閉じることをお勧めします。ビルドを実行するには、build.gradle
などがあるフォルダー内にて、gradlew build
を実行します。
ビルドが成功した場合は、./build/libs/
内にkanaify-1.0.0.jar
といったファイルができたはずです。ただし、ビルドが成功したといっても、読み込んだらクラッシュ、ということはあり得ます。なので、実際に読み込まないといけません。
MultiMCでテスト
前回設定したMultiMCを開き、インスタンスのアイコンを右クリックし「インスタンスの編集」を押します。「Loader mods」タブに、ドラッグ&ドロップでkanaify-1.0.0.jar
を追加します。
Fabric Modローダーはパフォーマンス改善等のパッチを一切しないため、プレイ中のRAMやCPU使用率を改善したい場合は、Modを導入する必要があります。これは、互換性テストにもなります。LithiumとSodiumは著名なFabric系のパフォーマンス改善Modです。jarファイルをダウンロードし、同様にして追加します。チャットModのテストにシェーダーは不要なので、OptifineといったModは追加しなくて大丈夫です。
起動して、まずクラッシュしないか、ワールドを作成してチャットに書き込めるか、ローマ字で書き込んだ内容は変換されるかをテストしましょう。起動時直後にクラッシュした場合は、多くの場合はMixinの指定に問題があります。MultiMCでクラッシュ時に自動表示されるクラッシュログの「Mixin transformation failed」などの行を読むと、大体理解できるはずです。
Modの公開
GitHub上でModを公開することもできますし、Mod公開用のウェブサイトを使うこともできます。fabric-example-modテンプレートを使用した場合は、GitHubにpushすると同時に、GitHub側でも(Actionsを用いて)ビルドされます。
Mod公開用のウェブサイトには、多数のModが掲載されているCurseforgeや、比較的新しく、Fabricのmodが多くそろっているModrinthがあります。私はModrinthで公開しました。
最後に
Fabric系のmod開発は、初心者にとっては難しいものですが、慣れれば「Mixinを使ってコードを直接触る」といったほうがAPIの文書を読むより簡単になってきます。そして、Minecraftのソースコードの読み方をある程度理解すれば、複雑な仕組みがどのように実装されているかがすぐ理解できるようになります。
このModは機能は単純ですが、作る過程でFabric系の開発の基礎であるMixin、Access Widener、そしてテスト方法についてすべて触れることができました。これを理解すれば、他のModの開発にも非常に役立つはずです。
質疑応答
1.18対応はどうすればいいですか?
まず、(すでにしていない場合は)Java 17をインストールします。fabric-example-modからbuild.gradle
を上書きコピーし、次のコマンドを実行します:
$ gradlew migrateMappings --mappings "1.18+build.1"
その後、remappedSrc
内のコードをsrc/main/java
内に移動し、最後にfabric.mod.json
とgradle.properties
を編集します。gradle.properties
の内容は、公式ウェブサイトに例が記載されています。
MultiMC側では、新しいインスタンスを作成することをおすすめします。既存のものを使用する場合は、インスタンス編集画面で、MinecraftとIntermediary Mappingsのバージョンを1.18に変更し、またFabric Loaderのバージョンを0.12.8以降にします。
ただし、バージョンの変更により、Mixin先のコードが変更されている可能性があるので、再確認は必須です。
PlayerManager#broadcastは別スレッドで実行してもいいのですか?
いい質問ですね。たしかに、このメソッドは本来メインスレッドで実行すべきです。ただ、実際ModされていないMinecraftでもメインスレッド外で実行されているため、問題はないでしょう。
チャットメッセージを送信中、ちょうどいいタイミングでプレイヤーが参加退出したときなどに、ConcurrentModificationException
が送出される可能性があります。ただ、これはいずれ捕捉されるし、またメインスレッド外での例外はサーバークラッシュには至らないため、大した問題にはなりません。
Invokerを使ったほうが早いのではないですか?
Fabric Modder現る。たしかにこの場合は@Invoker
MixinでTranslatableText.getArg
を呼び出せますが、Access Widenerのほうが万能なのでこっちを紹介しました。
なぜそこまで詳しいのですか? 専門家ですか?
開発者のひとりです(?)。よくスナップショット版の解読速度を競っています。