LoginSignup
7
0

More than 5 years have passed since last update.

Minecraft上で野球を表現するために使った似非物理学とSpigotにおける実装(捕球・Tips編)

Posted at

他の記事

バウンド編
投球・打撃編

捕球

捕球部分の実装に関しては難しいところはあまりない。とりあえずの基本機能としては、ProjectileHitEventを拾い、ボールがプレイヤーに当たった場合に判定に移るようにすればいいだけだ。
SnowballGameの場合、オフハンドに「グラブ」というアイテムを持ってさえいれば無条件で捕球が成功するようになっている。捕球時にはエンティティのボールがremoveされ捕球したプレイヤーのインベントリにボールが加えられる。低いゴロであったりちょうど地面にバウンドするタイミングであったりした場合にボールとプレイヤーとの間でProjectileHitEventが呼ばれない場合があるなど、意外にこれだけでも捕球にコツが必要になる場面があったりする。(外野手が地面を這うようなゴロに向かってダッシュでチャージするとそこそこの確率で後逸する。リアルといえばリアルで、人によってはトラウマを呼び起こす仕様であった。)

課題

問題点と呼ぶほど困ったものではないが、この仕様ではやはり野球を再現するうえで物足りない部分が残る。
第一に、打球を弾いたりといった捕球ミス(エラー)が存在しないことである。
これに関してはイベントが呼ばれたときにランダムな確率でエラーになるように作ってもいいが、それではエラーの発生確率をプレイヤー側で操作できないため理不尽に感じられてしまう可能性がある。スキルがある程度プレイの質に影響するようなデザインにしておきたい。

もう一つは捕球の難易度自体が高すぎるという点である。
Minecraftにおいてプレイヤーのヒットボックスは基本的に手を下に下げた状態になっているので、この仕様のままでは「手を伸ばしての捕球」という概念が存在せず、いわばすべての打球に対して「正面に回り込んで」捕球することが求められることになる。そのため、速いゴロが内野を抜けていくことが非常に多く、またある程度プレイ経験を積んで打撃の瞬間に打球の飛び方をある程度推測できるようになるまで、捕球できる打球の種類が非常に限られてしまっていた。また、「腕を伸ばせない」ことの影響は外野守備におけるフライ捕球においてより顕著で、ほぼ落下点に入っている状態でもフライの捕球に失敗するケースがかなり多くなってしまっていた。

解決策

これは非常に簡単な話で、ボールの近く一定範囲での右クリックによって捕球が可能になるようにした。
右クリックが行われたことの検知はPlayerInteractEventevent.getAction()Action.RIGHT_CLICK_AIRまたはRIGHT_CLICK_BLOCKであることを判定すれば行えるので、その際にプレイヤーのオフハンドにグラブがあり、メインハンドに何もない(誤作動を避けるため)ことを確認すればよい。
また、この一定範囲内でボールとプレイヤーにどの程度の距離があるかを取得することで、捕球時の「体勢の悪さ」を示す指標として用いることができる。つまり、この値が大きいほど「エラー」が発生しやすくすればよい。
自分とボールとの距離が最も近くなるタイミングで捕球を試みる(あるいは打者をアウトにするためにミスの可能性を織り込みつつより早いタイミングでボールを抑えようとする)という部分にプレイスキルが出る余地が生まれ、また単純に当たり判定が多少拡大するようなものなので、、多少動き出しが遅くてもアウトにできる範囲が広がった。
SnowballGameにおいては、右クリックが行われたとき、プレイヤーの目から3*4*3の直方体内にボールが存在した場合に捕球の判定を行い、Math.Random * 8よりもボールとの距離が小さければ捕球成功、それより大きければミスという設定にしている。

tryCatch
//tryCatch(player, player.getEyeLocation(), new Vector(3,4,3), 8)の形で呼んでいる
//捕球の成否がわかった方が都合がよい使用方法もあるかと考えて返り値がboolean
public static boolean tryCatch(Player player, Location from, Vector range, double rate){
        Collection <Entity> nearByEntities = player.getWorld().getNearbyEntities(from, range.getX(), range.getY(), range.getZ());
        Inventory inventory = player.getInventory();
        for(Entity ball : nearByEntities){
            if(ball.getType() == EntityType.SNOWBALL && Util.isBall(ball)){
                if(Math.random() * rate > from.distance(ball.getLocation())){
                    ball.remove();
                    ItemStack itemBall = Util.getItemBall();
                    //インベントリに入る余地があったらインベントリへ、そうでなければアイテムとしてドロップ
                    if(inventory.containsAtLeast(itemBall,1) || inventory.firstEmpty() != -1){
                        player.getInventory().addItem(itemBall);
                        return true;
                    }else{
                        ball.getWorld().dropItem(ball.getLocation(), itemBall);
                    }
                }else{
                    ball.setGravity(true);
                    //弾いたボールは速度をランダム倍しつつランダムなベクトルを加える
                    ball.setVelocity(ball.getVelocity().multiply(Math.random()).add(Vector.getRandom().multiply(0.3)));
                }
            }
        }
        return false;
}

Tips

ここからは、SnowballGameと関連プラグインの開発の最中に得た、他のスポーツ(あるいはそれ以外も)プラグインの作成に役立ちそうな知見をいくつか紹介していきたい。

単なるランダムでない無回転の実装

野球においてはナックルボール、ほかのスポーツではフローターあるいはそのまま無回転などの呼称で呼ばれる無回転系のボールは、大気などの周囲の環境からきわめて影響を受けやすく、軌道が不規則になるのが強みの球種である。
他のスポーツでどういった扱いをされているかはわからないが、野球においてはナックルボールといえば、今なお決定的な攻略法が存在しない「現代の魔球」であり、これさえ高い精度で投げられれば元野手が一流の投手になれたり、130キロ程度のナックルが投げられれば全盛期には世界最高の投手でいられるなど、まさにロマンの塊と言って全く過言でない変化球だ。
そんな夢の詰まった変化球だからこそ、ぜひとも野球プラグインには実装したいものなのだが、残念ながら、投球編で紹介した原理ではナックルの変化は記述不能である。

ナックルの変化はここで用いたマグヌス効果による揚力では説明できないものであり、変化の大部分は「野球のボールが球でない」ことによって起こるものである。(本当のことを言うと恐らく真球であっても不規則な変化は発生するが、ここでは説明を省く。)そのため、変化の向きや量を単純な式で記述することができない。軌道の計算にはかなり精巧なシミュレーションが必要になるものであり、素人が手を出せるような筋合いのものではないようだ。

つまり、無回転のボールの軌道をしっかりと再現することは最初からあきらめてしまわねばならない。その上で、どれだけ実物のナックルに近いイメージの軌道を作れるか、という部分が課題になる。

不規則な変化を実装する場合、一番簡単に思いつくのは「毎Tickランダムなベクトルをボールに加える」といったものだろう。確かにこれは文字通り不規則な変化を発生させることができ、実装も簡単でナックルの表現方法としては優秀に見える。
が、実際にそうして実装した「ナックルボール」を投げてみると、すぐに違和感に気づくはずだ。
現実でのナックルボールは、投手から捕手のミットに収まるまで1~3回くらいの「ゆれ」を見せ、最終的にどちらに曲がっていくのか全く予想がつかないというボールである。ゆっくり打者のほうに曲がっていったかと思えば急激に落ちたりといった軌道の変化がナックルボールの表現のための必要要件といえる。

対して、この「ランダムベクトル式」ナックルボールは、確かに予測不能な変化はするのだが、あまりにも「ゆれ」が発生する回数が多い。基本的にはカクカクと様々な方向に変化しながら進んでくるのだが、最終的にそれぞれ逆方向の変化と打ち消しあって予測がつきやすい位置に来ることが多い。ひどいときには、ぷるぷる空中で震えるだけのストレートと大差ないボールになってしまうこともあり、ナックル本来の打ちづらさがこれでは表現できない。
javaw_20180604_233815F.gif
randomを「ランダムな変化の大きさの目安」として0.02とし、Vector.getRandom().subtract(Vector.getRandom()).multiply(2 * random)で得られるベクトルをボールに加えたものが上のgif。
左右へのぶれは確かに確認できるが、打ち消し合って結果的にほとんど変化しないのと同じボールになってしまっている。

ナックルらしさを表現するためには、ある程度いずれかの方向に偏りながら変化しつつ、何度か方向を変えるようなゆれを見せる形でなくてならない。かといって変化パターンをいくつか設定しておいてランダムに分岐して…などという方法は少し冗長に感じる。

SnowballGameでは、「sin曲線上のランダムな地点からスタートし、ランダムな大きさ分だけ進める」ということをx,y,zそれぞれに対して毎tick繰り返し、得られたベクトルをボールに加えるという方法を用いてこれを表現した。

軌道の変化というのは要するに、x,y,zがそれぞれ+○○されるか-△△されるかということでしかない。そしていま求められている変化の内容は、「不規則な期間連続して+あるいは-されていき、不規則なタイミングで逆に-あるいは+されはじめる」というものである。
では、これを表現するためには、引数の値が増えていくにつれ、ある程度の周期をもってある程度の値の範囲で+と-を行き来する関数があればよい。その周期の何分の一かの(ランダムな)値を毎tick引数に加えていけば、期待通りの挙動をしてくれるはずだ。
こうした性質を持つ関数の中で自分が知る限り一番扱いやすい関数がsin(あるいはcos)だった。1から-1の範囲を一定周期で連続して行き来する。急に大きな値になったり無限になったりしない。(自分の知る限りでは)
もちろん単に「都合がいい」から使っているだけであって、一切このsinの使用法を正当化する根拠はない。(しいて言えばボールの縫い目はサインカーブっぽいかもしれない)もしかしたら理系の人の中には蕁麻疹が出る人もいるかもしれないくらい似非物理的であり汚いやり方ではあるが、こうして得られたナックルは、そこそこちゃんとナックルになってくれている。
javaw_20180605_000615F.gif
上の「ランダムベクトル式」と比べれば差は一目瞭然だろう。今度はストライクゾーンに投げ込むのに苦労するようになったが、打者との勝負が成立する変化量になった。
このナックルを実装するためのコードとしては以下のようになる。

Knuckle
import org.bukkit.Particle;
import org.bukkit.entity.Projectile;
import org.bukkit.metadata.FixedMetadataValue;
import org.bukkit.scheduler.BukkitRunnable;
import org.bukkit.util.Vector;
//実際の実装時は変化球と同じRunnableを用いるためこのクラス
public class BallMovingTask extends BukkitRunnable {
    private Projectile ball;
    private double random = 0;
    private double x,y,z;

    public BallMovingTask(Projectile ball, double random) {
        this.ball = ball;
        this.random = random;
        if(random != 0){
            this.x = Math.random() * 2 * Math.PI;
            this.y = Math.random() * 2 * Math.PI;
            this.z = Math.random() * 2 * Math.PI;
        }
    }
    @Override
    public void run() {
        if(ball.isDead()){
            this.cancel();
        }
        Vector velocity = ball.getVelocity();
        if(random != 0){
            //だいたいマウンドから打者まで15-20tickと考えられるので、毎tick0~0.3進めれば1~3回「ゆれ」るはず
            this.x += Math.random() * 0.3;
            this.y += Math.random() * 0.3;
            this.z += Math.random() * 0.3;
            Vector toAdd = new Vector(Math.sin(x), Math.sin(y), Math.sin(z));
            toAdd.multiply(random);
            velocity.add(toAdd);
        }
        ball.setVelocity(velocity);
    }

}

この変化量の目安randomをコンフィグで設定できるようにしてやると、それぞれのプレイヤーごとに扱いやすいように調整してくれるだろう。

BossBarを使った独自ゲージ

野球の打撃に関しては、「足を止めて行う」「角度や方向などの精度がそこまで問われない」といった特徴があったため、元々ゲージを持っている弓を使うことで表現が可能だったが、他のスポーツを表現する場合、弓のそれでは都合が悪いということも考えられる。
例えばサッカーを表現するような場合、シュートの部分に関しては有名ゲームでもゲージ式の強弱設定が用いられているが、これを弓で表現するのはあまりいい方法とは言えない。
弓は引いている間著しく移動速度が落ちるという問題があり、またゲージが溜まる速度もシュートの表現のためには少し遅すぎる。
そのためこういう場合、より自由度の高いゲージ的なUIが求められることになる。

そこで一つの有効な手段として用いることができるのがBossbarである。
本来はエンダードラゴンやウィザーといったボスの残り体力を表示するためにのみ使われているものだが、ドキュメントを見てもわかるように、実はコンストラクターを呼び出す際に特にエンティティを必要としない。
むしろエンティティの体力を反映するためには自前で増減の処理を書く必要があるくらいで、逆に言えば体力の表示以外にも自由に用いることが可能なのだ。表示する値は0-1の間でBossbar.setProgress(double)を用いれば直接書き換えることができるため、Runnableを書いて毎tick値を書き換えてやれば、自由な速度で増減するゲージを作成することができる。もちろん弓を用いた場合のような邪魔な副作用も発生しない。

自作のプラグインにSnowballGameを前提プラグインとして動くOutOfBoundsというゴルフゲームプラグインがあるのだが、これの「イージーモード」の実装にBossBarを用いた強弱ゲージを採用した。Spigotページにデモムービー(gif)を貼っているので、よければ確認してほしい。

OutofBoundsにおいては、

  • 「ハンディキャップ」というアイテムを頭に装備した状態で左クリックをすると20tickで満タンになるBossBarが表示される
  • 表示された状態でもう一度左クリックするとその時点でのゲージのたまり具合に対応した強さで「スイング」が行われる

という仕様になっており、スイングがボールに当たったかどうか、どういった当たり方をしたのかという部分の判定はSnowballGameの打撃部分をそのまま用いている。

これを表現するため、イベントリスナーは

PlayerInteract
if(e.getAction() == Action.LEFT_CLICK_AIR || e.getAction() == Action.LEFT_CLICK_BLOCK){
    e.setCancelled(true);
    if(player.hasMetadata("taskID") && player.hasMetadata("bar")){
        int isRight = 1;
        if(player.getMainHand() == MainHand.LEFT){
            isRight = -1;
        }
        Location loc = player.getLocation();
        loc.setYaw(loc.getYaw() - 90 * isRight);
        // 簡略化のため視線から左手方向(右打ちの場合)に90度向けた方向をスイングとして仮定
        Vector swing = loc.getDirection().setY(0).normalize();
        BossBar powerBar = (BossBar)player.getMetadata("bar").get(0).value();
        // 視線の延長線上1.8mの位置をクラブヘッドが通る位置と仮定
        Location start = player.getEyeLocation().add(player.getEyeLocation().getDirection().normalize().multiply(1.8));
        // SnowballGameの打撃判定関数
        // tryHit(打撃者,バット位置,当たり判定のxyz,弓の引き具合,バットの運動量,ボールの反発係数)
        SnowballGameAPI.tryHit(player, start, new Vector(1.2,1.2,1.2), (float)powerBar.getProgress(), 1.3, swing, 1);
            Bukkit.getScheduler().cancelTask(player.getMetadata("taskID").get(0).asInt());
            powerBar.removeAll();
            powerBar.setProgress(0.001);
            player.removeMetadata("taskID", plugin);
            return;
        }
        BossBar powerBar = null;
        // スイング時に毎回BossBarを作成するのが望ましくないと思われたため、各プレイヤーの初スイング時に
        //一度だけ作成したものをメタデータをとして付与しておき再利用している
        if(player.hasMetadata("bar")){
            powerBar = (BossBar)player.getMetadata("bar").get(0).value();
            powerBar.setColor(BarColor.GREEN);
        }else{
            powerBar = Bukkit.getServer().createBossBar("Power", BarColor.GREEN, BarStyle.SOLID);
        }
        powerBar.setProgress(0);
        powerBar.addPlayer(player);
        BukkitTask task = new PowerBarTask(powerBar, player).runTaskTimer(plugin, 0, 1);
        player.setMetadata("taskID", new FixedMetadataValue(plugin, task.getTaskId()));
        player.setMetadata("bar", new FixedMetadataValue(plugin, powerBar));
}

という形になっており、対応するRunnableは

PowerBarTask
import org.bukkit.boss.BarColor;
import org.bukkit.boss.BossBar;
import org.bukkit.entity.Player;
import org.bukkit.scheduler.BukkitRunnable;

public class PowerBarTask extends BukkitRunnable {
    BossBar bar;
    Player player;
    public PowerBarTask(BossBar bar, Player player){
        this.bar = bar;
        this.player = player;
    }
    @Override
    public void run() {
        if(bar.getProgress() == 1){
            bar.setColor(BarColor.GREEN);
            bar.removeAll();
            this.cancel();
            player.removeMetadata("taskID", Swing.getPlugin(Swing.class));
        // 後述するプレイ感のための調整を手っ取り早くやっている
        }else if(bar.getProgress() > 0.999){
            bar.setProgress(1);
        }else if(bar.getProgress() > 0.95){
            bar.setProgress(0.9999);
            bar.setColor(BarColor.RED);
        }else{
            bar.setProgress(bar.getProgress() + 0.05);
        }
    }

}

となっている。
注意点としては、タスクのキャンセルをタスク外から行う必要があるためメタデータやその他の方法でタスクIDを保持しておかねばならないということ、Runnable内においてprogressを増やす際、そのまま増やすと計算誤差で1にはならないため少し癖のある判定をする必要があることなどがある。
また、本来progressが1になるはずのタイミングですぐにBossbarを隠してしまうと、「満タンを狙ってゲージを止める」ということが難しくなり、プレイヤー視点では満タンになる前にゲージが消えてしまうような感覚を受けやすい。1~数tick程度余裕を持たせてやると遊びやすくなるだろう。

このBossBarを用いた独自ゲージに関しては、サッカーなどのスポーツ再現の範疇に限らず、「溜め攻撃」など、他のジャンルのプラグインにも様々に応用が可能だと考えられる。よければ利用してみてはどうだろうか。

このほかにもVector.isInAABBを用いた(ProjectileHitEventを伴わない)通過判定や「HR確定演出」プラグインを作成するために用いた(アバウトな)落下地点予測など、覚え書き的に書いておいてもいいかもしれない小技はあるのだが、野球以外への応用方法に乏しいのでここまでにする。

おわりに

もしかすると単なる自分の認識不足かもしれないが、このSnowballGameを作る前に軽く調べた際、スポーツMOD、プラグインの数が極めて少ないことに驚いた。(MODに関してはいくつかのスポーツがセットになったものを一つ確認できたが、プラグインに関してはほぼ皆無だった。)
現代都市を建築する際に球場や競技場が建てられていたり、現実に存在する球場をMinecraft内で再現するプロジェクトは目にしたことがあるが、その建築を実際に使用するという部分はこれまであまり進んでいなかったらしい。(元々SnowballGameも、ワールド内に数十の球場が建築されている自分の常駐サーバー用に作ったものを公開したのが経緯。)

Minecraft内でスポーツを再現するということを考える場合、たいていのスポーツは一人でやるよりも複数人でやる方が楽しみが増えると考えられるため、MODではなくプラグインの形で提供されることに大きな意味があるのではないかと個人的には思っている。
今後自分以外にもスポーツ系プラグインの作成を志す開発者が現れた際に、ここに書いた知識を役立てていただければ光栄だ。

7
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
0