石橋を叩いて壊すページ

プレイヤーのデータを退避・復元する

前々から、作りたいと思っているプラグインがある。
通常の生活・冒険用ワールドとは別に競技用のワールドを作り、その中でグループ戦で競技をするというもの。
それにあたり、競技を公正にするため競技用ワールドへのテレポート時にアイテムを預かり、競技後に戻すという処理を作りたい。
テレポート直前の位置情報やヒットポイント・経験値の貯め具合なども保管して、同様に競技後に戻したい。

アイテムの保管・操作方法は、以前にも記事にしたインベントリデータを取得する方法があるので簡単なのだが、
ひとつ問題なのは、メモリ上にインベントリデータを保持すると、競技中にサーバがクラッシュしたらデータがぶっとぶということだ。
せっかく書いた本とかダイヤモンドとかが消えたら競技どころの騒ぎではない。

ということで、いろいろと試行錯誤してみた。

シリアライズしてみた

Javaにはシリアライズという概念がある。(Java以外にもある。)
これは、オブジェクト内のデータを全部ファイルに書き出して、必要になったらファイルを読み込んで元通りに復元する機能だ。
Bukkitの場合は、ConfigurationSerializableというインターフェースが定義されており、
このインターフェースを実装したクラスのserialize()メソッドを叩けば、データが出力されるらしい。

さらに、BukkitのPlayerクラスはこのインターフェースを実装している。
ということで、さっそくシリアライズして、どんな内容が出力されるのか見てみた。

/**
 * プレイヤーがクリックしたときに呼ばれる
 * 
 * @param e
 */
@EventHandler
public void onPlayerInteractEvent(PlayerInteractEvent e) {

    // 紙を手に持って右クリックしたのか
    if ((e.getAction().equals(Action.RIGHT_CLICK_AIR) || e.getAction()
            .equals(Action.RIGHT_CLICK_BLOCK))
            && e.getPlayer().getItemInHand().getType()
                    .equals(Material.PAPER)) {

        // プレイヤーデータをシリアライズする
        Map<String, Object> map = e.getPlayer().serialize();

        // 中身を見てみる
        for (Map.Entry<String, Object> entry : map.entrySet()) {
            // クライアントに中身を表示
            e.getPlayer().sendMessage(
                    "§e[" + entry.getKey() + "]§f"
                            + entry.getValue().toString());
        }

    }
}


上記のプラグインを実行し、紙を手に持って右クリックしたところ。

('A` )…?
名前だけ…?もっとほら、位置情報とかインベントリの内容とか保存するべきものあるんじゃないの?ないの?
うーん、これどうやって使えばいいんだろ。

ちなみに、ItemStackクラスもConfigurationSerializableを実装しているので、同じ方法でアイテムデータをシリアライズできる。
ちょっとやってみたけど、こちらはアイテム種別、ダメージ値、スタック数、本の記述内容などのメタデータが出力されたので
こちらは使いどころはあるかもしれない。


プログラムを少し変えて、手に持っているアイテムの情報をシリアライズするようにしてみた。
アイテム種別・スタック数・ダメージ値が出力されることがわかる。


記入済みの本では、本の内容も出力される。

データファイルを操作してみた

なにかいい方法はないものかとPlayerクラスを眺めていると、saveData()・loadData()というメソッドがあった。
どうやら、プレイヤーのデータをファイルに保存してくれるようだ。さっそく使ってみる。

/**
 * プレイヤーがクリックしたときに呼ばれる
 * 
 * @param e
 */
@SuppressWarnings("deprecation")
@EventHandler
public void onPlayerInteractEvent(PlayerInteractEvent e) {

    // 手に持って右クリックしたのか
    if ((e.getAction().equals(Action.RIGHT_CLICK_AIR) || e.getAction()
            .equals(Action.RIGHT_CLICK_BLOCK))) {

        try {

            // プラグイン用のデータ保管フォルダがないか
            if(!this.getDataFolder().exists())
            {
                // フプラグイン用のデータ保管フォルダを作る
                if(!this.getDataFolder().mkdirs())
                {
                    throw new Exception("データを退避するフォルダが作れなかった。");
                }
            }

            // プレイヤーデータを退避するフォルダのパス
            String destDir = this.getDataFolder().getAbsolutePath() + "/";

            // プレイヤーデータがあるフォルダのパス
            String srcDir = Bukkit.getWorlds().get(0).getWorldFolder()
                    .getAbsolutePath()
                    + "/players/";

            // プレイヤーデータのファイル名
            String filename = e.getPlayer().getName() + ".dat";

            //手にもっているアイテムはなにか
            if (e.getPlayer().getItemInHand().getType()
                    .equals(Material.WATER_BUCKET)) {
                // 水バケツを持っている場合

                // 現在のデータをデータファイルに保存
                e.getPlayer().saveData();

                // データファイルを退避フォルダにコピー
                fileCopy(srcDir + filename, destDir + filename);

                e.getPlayer().sendMessage("§3データを退避しました。");

            } else if (e.getPlayer().getItemInHand().getType()
                    .equals(Material.LAVA_BUCKET)) {
                // 溶岩バケツを持っている場合
                
                // 退避したファイルはあるか
                File destFile=new File(destDir + filename);
                if(!destFile.exists())
                {
                    throw new Exception("退避したデータがないので復元できない。");
                }

                // 退避したファイルを退避元のファイルに上書き
                fileCopy(destFile.getAbsolutePath(), srcDir + filename);

                // 復元されたデータを読み込む
                e.getPlayer().loadData();

                // インベントリと位置データの変更をクライアントに通知
                e.getPlayer().updateInventory();
                e.getPlayer().teleport(e.getPlayer().getLocation());
                
                // 退避したファイルを削除
                if(!destFile.delete())
                {
                    throw new Exception("退避したデータの削除に失敗した。");
                }

                e.getPlayer().sendMessage("§3データを復元しました。");
            }
        } catch (Exception ex) {
            //エラー内容を表示する
            Bukkit.broadcastMessage("§6"+ex.getMessage());
        }
    }
}

/**
 * ファイルをコピーする
 * 
 * @param src
 *            コピー元のファイルパス
 * @param dest
 *            コピー先のファイルパス
 * @throws IOException
 */
private void fileCopy(String src, String dest) throws IOException {

    FileChannel srcChannel = null, destChannel = null;
    try {
        // ファイルを開く
        srcChannel = new FileInputStream(src).getChannel();
        destChannel = new FileOutputStream(dest).getChannel();

        // 内容を移動
        srcChannel.transferTo(0, srcChannel.size(), destChannel);
    } finally {

        // ファイルを閉じる
        srcChannel.close();
        destChannel.close();
    }
}

水バケツを右クリックでデータを保存、溶岩バケツを右クリックでデータを復元。
上のコードでテストしてみると、プレイヤーの位置・インベントリデータ・
ヒットポイント・空腹度いずれもファイルに保存・復元することができた。

注意点として、loadData()メソッドを叩いたとき、それがクライアントに伝わらない。
たとえば、A地点に立ってデータをセーブし、B地点に移動してデータをロードする。
すると、サーバ側では情報が更新されキャラクターはA地点にいることになっているが、
クライアント側では変更が伝わらずB地点にいることになっている。
これを避けるには、loadData()後にPlayer#teleport(…)で位置を更新してやる必要がある。
同様の理由でインベントリデータもupdateInventory()で情報を更新しなければならない。

しかし、この方法にはもうひとつ問題が残る。
どうやら、saveData()で保存されるデータには、プレイヤーのホットバーの選択スロット番号も入っているようだ。

たとえば、ホットバーのスロット1に溶岩バケツが、スロット2に水バケツが入っていて、
いまプレイヤーはスロット1(溶岩バケツ)を選択した状態だとする。この状態でsaveData()でデータを保存する。
次に、プレイヤーがスロット2(水バケツ)を選択したとする。この状態でloadData()でデータを復元する。
すると、サーバ側ではデータが復元されて、プレイヤーはスロット1(溶岩バケツ)が選択されていることになっているが、
クライアント側ではデータの変更が通知されず、スロット2(水バケツ)が選択されていることになっている。
このためプレイヤーが地面などを右クリックをすると、水バケツを使ったら溶岩が出てきたという不思議な状態が起こる。


水バケツを使ったら溶岩が出てくる悪夢。(画像はクリエイティブモード)

ホットバーの選択スロットについては、クライアントに変更を通知する方法がない。
このためこの問題は基本的に解決できないし、かといって水バケツから溶岩が出たり
鋏で火が起こせたりする状態はプレイヤーにとって安全とは言いがたい。

メモリ上へのデータ保存+データファイル使用の組み合わせにしてみた

上記したふたつの方法から、どちらも万能とは言いがたいことがわかった。
そこで、以下の作戦を立ててみる。

まず、サーバがクラッシュしない場合。
この場合は、メモリ上にアイテムやプレイヤーのデータを保存しておけばいい。
競技が終わったら、メモリ上からプレイヤーにアイテムを返還すればいい。

次に、サーバがクラッシュした場合。
この場合は、メモリ上のデータがふっとぶので、代わりにデータファイルを使う。
競技開始時にデータファイルを退避し、競技終了時にデータファイルを消す仕組みを作っておく。
そして、Bukkit起動時にデータファイルがまだ残っていたら、競技中にクラッシュが発生したと判断、
退避したファイルを戻すことでプレイヤーデータを復旧する。
この場合、サーバがクラッシュした以上、クライアントは切断状態になっているので、
再接続時にホットバーがリセットされるため、ホットバー選択スロット食い違い問題は起こらない。

この作戦をクラス化してみた。

package jp.jias.bukkit;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.channels.FileChannel;
import java.util.Collection;
import java.util.logging.Logger;

import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.bukkit.plugin.java.JavaPlugin;
import org.bukkit.potion.PotionEffect;

/**
 * プレイヤーの状態を記憶する
 * 
 */
public class PlayerState {

    /**
     * プレイヤー
     */
    private Player player;

    /**
     * 装備品とインベントリの中身
     */
    private ItemStack[] equipment, inventory;

    /**
     * 現在位置、ベッドスポーン位置
     */
    private Location loc, bed;

    /**
     * ポーション効果
     */
    private Collection<PotionEffect> potionEffects;

    /**
     * HP、最大HP
     */
    private double maxhp, hp;

    /**
     * 空腹度、空気残量、炎症時間、経験値
     */
    private int food, air, fire, exp;

    /**
     * ユーザーデータファイルの元のフォルダ、退避先フォルダ
     */
    static private File srcDir = null, reserveDir = null;

    /**
     * 退避しておくユーザーデータのファイルの名前
     */
    private String fileName;

    /**
     * ログ出力用
     */
    static private Logger log;

    /**
     * 準備完了したらtrue
     */
    static private boolean ready = false;

    /**
     * 現在の状態を保存する
     * 
     * @param _player
     *            データを退避したいプレイヤー
     * @throws IOException
     *             プレイヤーデータを退避フォルダに退避できなかった場合に発生
     */
    public PlayerState(Player _player) throws IOException {

        // 準備は済んでいるか
        if (!ready) {
            throw new IOException("[PlayerState] 準備が完了していないので情報を保存できません。");
        }

        // データを取り出して保存しておく
        player = _player;

        loc = player.getLocation();
        bed = player.getBedSpawnLocation();
        equipment = player.getInventory().getArmorContents();
        inventory = player.getInventory().getContents();
        potionEffects = player.getActivePotionEffects();
        maxhp = player.getMaxHealth();
        hp = player.getHealth();
        food = player.getFoodLevel();
        air = player.getRemainingAir();
        fire = player.getFireTicks();
        exp = player.getTotalExperience();

        // 現在のプレイヤーデータをファイルに保存
        player.saveData();

        // データファイルを退避フォルダにコピー
        fileName = "/" + player.getName() + ".dat";
        fileCopy(srcDir.getAbsolutePath() + fileName, reserveDir + fileName);

        player.sendMessage("§3セーブしました。");
    }

    /**
     * 保存した情報を書き戻す
     */
    public void update() {
        // 現在のポーションエフェクトをすべて削除する
        Collection<PotionEffect> pes = player.getActivePotionEffects();
        for (PotionEffect pe : pes) {
            player.removePotionEffect(pe.getType());
        }

        // 取り出しておいたデータを適用する
        player.addPotionEffects(potionEffects);
        player.teleport(loc);
        player.setBedSpawnLocation(bed, true);
        player.getInventory().setArmorContents(equipment);
        player.getInventory().setContents(inventory);
        player.setMaxHealth(maxhp);
        player.setHealth(hp);
        player.setFoodLevel(food);
        player.setRemainingAir(air);
        player.setFireTicks(fire);
        player.setTotalExperience(exp);

        // データファイルを削除する
        if (!new File(reserveDir + fileName).delete()) {
            Bukkit.broadcastMessage("§Eプレイヤー[§6" + player.getName()
                    + "§E]のデータを復元しましたが、退避したデータファイルを削除できませんでした。");
        }

        player.sendMessage("§3ロードしました。");
    }

    /**
     * データを退避する準備をする
     */
    static public void init(JavaPlugin plugin) {

        // 既に準備されていたら何もしない
        if (ready) {
            return;
        }

        // ログ出力の準備
        log = plugin.getLogger();

        // 退避元・退避先のパスを作る
        srcDir = new File(Bukkit.getWorlds().get(0).getWorldFolder()
                .getAbsolutePath()
                + "/players");
        reserveDir = new File(plugin.getDataFolder() + "/reserve");

        // フォルダはあるか
        if (reserveDir.exists() && reserveDir.isDirectory()) {
            // フォルダの中にファイルがあったら警告を出す
            File[] files = reserveDir.listFiles();

            if (files.length != 0) {
                log.warning("[PlayerState] 退避したデータファイルのうち、復元できていないファイルが"
                        + files.length + "つあります。");

                return;
            }
        } else {
            // フォルダがなければ作る
            if (!reserveDir.mkdirs()) {

                log.warning("[PlayerState] 退避データ用のフォルダが作れません。");

                return;
            }
        }

        // 準備完了
        ready = true;
    }

    /**
     * ファイルをコピーする
     * 
     * @param src
     *            コピー元のファイルパス
     * @param dest
     *            コピー先のファイルパス
     * @throws IOException
     */
    static public void fileCopy(String src, String dest) throws IOException {

        FileChannel srcChannel = null, destChannel = null;
        try {
            // ファイルを開く
            srcChannel = new FileInputStream(src).getChannel();
            destChannel = new FileOutputStream(dest).getChannel();

            // 内容を移動
            srcChannel.transferTo(0, srcChannel.size(), destChannel);
        } catch (IOException e) {
            log.warning("[PlayerState] データを退避することに失敗しました。");
            throw e;
        } finally {

            // ファイルを閉じる
            srcChannel.close();
            destChannel.close();
        }
    }
}

使い方はこうなる。

//プレイヤーデータを保存する準備(プラグインのonEnable()から呼ぶ)
PlayerState.init(this);

//プレイヤーデータを保存する
PlayerState ps;
try {
    ps = new PlayerState(e.getPlayer());
} catch (IOException ex) {
    e.getPlayer().sendMessage("§7" + ex.toString());
}

//プレイヤーデータを復元する
ps.update();

サーバがクラッシュしたときに、退避したデータを書き戻す処理は上記に入っていない。
というのも、Bukkitは起動時にすべてのプレイヤーデータを読み込んでしまう。
このため、Bukkit起動後にプラグインから退避データファイルを戻しても、データは既に読み込まれた後だから意味がない。
Player#loadData()メソッドで任意にファイルを読み込めればいいが、プレイヤーがオンラインにならないとPlayerオブジェクトが得られないし
オンラインになるのをいつまでも待つわけにもいかない。

ということで、退避したデータを復元する処理は、プラグインではなくBukkitを起動するバッチファイルに入れておく。
Bukkitを起動する前に、ファイルを直接移動してしまうのだ。

::退避したユーザーデータがあれば元の位置に戻す(Windowsの場合)

move /y plugins\(プラグイン名)\reserve\*.dat world\players

これで、Bukkitがクラッシュした場合はプレイヤーは強制的に元の情報に復帰させられる。
位置データも含めて復元されるので、競技ワールドに取り残されてしまう心配もない。

2014/07/26追記:
プレイヤーデータの保存パスが変わったので、プラグインを修正した。修正したコードは新しい記事に書いた。

この記事を評価

この記事にコメント

  1. ...

【この記事にコメント】
お名前:
コメント:

Menu