2018年10月14日日曜日

作ったゲームを解説する「Unity-chanバッティング VS.こはくちゃんズ」

utc-batting.png (985.4 kB)
「Unity-chanバッティング VS.こはくちゃんズ」というゲームを作りました。
こはくちゃんズが投げる球を打って飛距離を稼いでノルマクリアを目指すゲームです。
unityroomさんで公開しています。暇なときにでも遊んでみてください。

目次

開発のいきさつ

お仕事先で突発的にやっていた連休ゲームジャム(非公開)に参加しました。
お題は「打つ」で、ちょうどスポーツゲームって作るの難しいよねっていう話題があったのでじゃあ作ってみようとなって作りました。

作業のうちわけ

企画とか準備とか ... 1h
アセットの選定、インポート、使用感の確認 ... 18h
コーディング ... 8h
値の調整とか ... 12h
ビルド作業とか ... 1h
最終的にかかった時間は約40時間、AssetStoreで購入したアセットの料金が4000円ぐらいです。(以前購入したものを含めるともっとかかっています)
短期間のゲームジャムだと如何に既存のアセットをうまく使うかがとても重要だと思いますので、まあこんなもんでしょう。
作業時間を買うという感覚だと大抵のアセットは安く感じますね。調子に乗っていると請求がやばい。

ゲームデザイン

テーマは「打つ」なので、打撃にフォーカスした野球ゲームを作りました。
コンセプトは「プレイヤーの力量によって打てたり打てなかったりするけど打てれば気持ちいいゲーム」です。
力量によって打てたり打てなかったりするで思い浮かんだのがパワプロシリーズ1 の昇級試験2なので、それっぽいものを実装しようと思いました。ただし、ヒット性の当たりかどうかを判定するよりも、単に飛距離をスコアにした方が簡単なので飛距離がそのままスコアになります。
また、プレイヤーのモチベーションを確保するため、単に飛距離を競うスコアアタックにするのではなく、投手との対決という形態にしています。つまり、くまのプーさんのホームランダービー3
また、段階的にプレイヤースキルを上げられるようにデザインしています。前半は仕様を覚えてもらうためにノルマを低めに設定しておき、後半になるにつれノルマを上げます。最後はノルマを高めに設定していますが、そこまで到達したプレイヤーなら絶対無理ではなく、頑張ればできそうと思えるような調整にしたつもりです。

ゲームの仕組み

内部の仕組みについてざっくり解説します。
まずは野球のモーション集を探してきてUnityに突っ込み、SDこはくちゃんズのリグに適用します。この時点でゲームは8割完成です。
BASEBALL Motion Basic - Asset Store
上記のモーション数ですが投球などにバリエーションは無いので、本格的な野球ゲームを作るならやはりモーションは自前で用意する必要があるんでしょうね。

ボールの生成

アニメーションイベントを使って投球モーションの特定のタイミングでボールを生成しています。生成されるボールの位置はもちろん手の位置です。
image.png (262.7 kB)
後は単にAnimatorを使ってピッチングモーションを再生してやれば良い感じのタイミングでボールが生成されます。

投球モーションのスピード変更

現実の野球ではリリース時の手の振りなどで球種の判断をするそうなのですが、モーション自体にバリエーションを準備するのは大変だったので、代わりにモーションのスピードを球種ごとに変更しています。投球モーションに変化を付けているのはプレイヤースキルを重視する作りの一環でもあります。プレイヤーが球種によってモーションスピードが違うことに気付いてそれに対応できれば、いくらか攻略が楽になります。
モーションスピードの変更はAnimatorのパラメータを使って変更しています。
Picher.cs
public void StartPitchingMotion()
{
    ...
    GetComponent<Animator>().SetFloat("Speed",BallType.GetMotionSpeed());
    GetComponent<Animator>().SetTrigger("Pitch");
    ...
}

public void ReleaseBall()
{
    ...
    GetComponent<Animator>().SetFloat("Speed",2.0f);

    Ball ball = Instantiate(ballPrefab);
    ...
}
image.png (50.4 kB)

投球の軌道計算

ピッチャーの手元で生成されたボールはストライクゾーン上のターゲットに向かって飛んでいきます。
投球の軌道は物理演算を使用せず、スクリプトで位置を直接設定しています。
投球の軌道を説明する前に軌道の説明で使用する座標系を設定しておきます。
XYはストライクゾーンの真ん中を中心としています。
水平軸をX軸とし、アウト側を+方向、ストライクゾーンの大きさを1とします。
垂直軸をY軸とし、上を+方向、ストライクゾーンの大きさを1とします。
XY.png (208.4 kB)
Zはピッチャーがボールをリリースした点を0、ストライクゾーンの位置を1とします。
z.png (398.3 kB)
今回はボールのz位置に対するボールの位置を計算することで、投球の軌道としています。
つまりインターフェースはVector3 GetPosition(float z);
z位置は等速で移動させています。実際には空気抵抗による減速とかあると思いますが考えるのがめんどくさいので。

直線運動

現実世界では投げたボールが直線運動をするなんてことはありませんが、簡単ですからまずはこれを考えます。
ゲーム上では150km/h以上のエフェクトが無いストレートが実際に等速直線運動しています。
計算方法はリリース座標にzに対する変分を足せばいいですね。
Ball.cs
private Vector3 start;   //  リリース座標
private Vector3 target;  //  ストライクゾーン通過座標

private Vector3 GetPosLiner(float z)
{
    return start + (target - start) * z;
}
変な位置にピッチャーを配置してもちゃんと狙った位置にボールが届きます。
image.png (722.4 kB)
ちなみにリリース位置のX座標はだいたい0になるように投手の位置をずらしています。
リリース位置がずれているとカメラから見たときの情報が増え、無駄に少し難しくなるためです。

やまなり軌道

現実世界ではボールは重力の影響をうけてやまなりの軌道で移動します。
ボールがz方向について等速の場合、やまなり軌道は単純な2次関数なので、
まずはzが0と1のときに0になり、0.5のときに1になる関数を考えればいいです。
arch.png (291.9 kB)
Ball.cs
private float Arch(float z)
{
    float nz = 2 * z - 1;  //  (0, 1) -> (-1, 1)
    return - nz * nz + 1;
}
ストレートもやまなり軌道となっているのですが、このままではどんな球速でも鋭いやまなりとなってしまうので、
球速に応じた適当な係数を掛けてやまなりを緩やかにします。
arch2.png (336.8 kB)
Ball.cs
//  球種に応じたやまなり係数
private float xFactorArch;
private float yFactorArch;

// 球速
private float speed;

private Vector3 GetPosArch(float z){
    return new Vector3{
        x = Arch(z) * xFactorArch * (1 - (Mathf.Clamp(speed, 100f, 150f) - 100f) / 50f),
        y = Arch(z) * yFactorArch * (1 - (Mathf.Clamp(speed, 100f, 150f) - 100f) / 50f),
        z = z,
    };
}
X軸のやまなり軌道はカーブなどの変化球で使用しています。
変化球を考慮しないのであればY軸のやまなり係数は重力の表現なので、これが球種ごとに異なるというのはかなりおかしなことです。しかしそこはゲームなので、遅い球は大げさにやまなりになってもらった方が表現として都合が良いのです。

変化球

リアルな変化球を再現するのは無理ですし、仮に再現できたとしてもゲームとしては面白くないでしょう。
ので、単純化してこれも2次関数で表現することにします。ただし変化球の鋭さを表現するために途中までは変化しないようなものを考えます。
curve.png (281.8 kB)
Ball.cs
//  球種に応じた変化量
private float dx;
private float dy;

// 変化球の鋭さ
private float sharpness;

private Vector3 GetPosCurve(float z){
    if(z < sharpness)
    {
        return new Vector3(0f, 0f, z);
    }
    else
    {
        float s = sharpness;
        return new Vector3{
            x = dx * (z - s) * (z - s) / (1 - s) / (1 - s),
            y = dy * (z - s) * (z - s) / (1 - s) / (1 - s),
            z = z,
        };
    }
}
上記の「直線軌道」、「やまなり軌道」、「変化球」を全て足したものが最終的なzに対するボールの位置になります。
Ball.cs
public Vector3 GetPosition(float z)
{
    return GetPosLinear(z) + GetPosArch(z) + GetPosCurve(z);
}

ターゲット表示

パース付きカメラを使っているので、どこを狙えばいいかをガイドしないと非常に難しいゲームになってしまうため、ターゲットを表示しています。
これも投球と同じくzに対する位置座標をボールとは別に計算しています。変化球によるターゲット表示の移動は2次関数だと予測が難しくなるので1次関数で表現しています。
guide.png (343.8 kB)
円の大きさはストライクゾーンからボールまでのZ距離です。
円の描画はこういうコンポーネントを作っておくと便利ですね。
LineCircleRenderer.cs
using System.Linq;
using UnityEngine;

[RequireComponent(typeof(LineRenderer))]
public class LineCircleRenderer : MonoBehaviour{

    public int reslution = 32;
    public float radius = 1;

    private LineRenderer lineRenderer;

    private void Start()
    {
        lineRenderer = GetComponent<LineRenderer>();
    }

    public void Render()
    {
        lineRenderer.positionCount = reslution + 1;
        lineRenderer.SetPositions(
            Enumerable.Range(0, reslution + 1)
            .Select(x => 2 * Mathf.PI / reslution * x)
            .Select(x => new Vector3(
                Mathf.Sin(x) * radius + transform.position.x,
                Mathf.Cos(x) * radius + transform.position.y,
                transform.position.z))
            .ToArray());
    }

    private void LateUpdate()
    {
        Render();
    }
}

配球

配球はレベルデザインと強い関係があります。
配球を固定とすると覚えゲーになり、完全ランダムにすると難易度が上がるうえ、当たりはずれが大きくなってしまうので、間をとってシャッフルを利用しています。
実はどのキャラも最初の5球は球種が固定になっています。コースはランダムですが、配球のパラメータでばらつき加減を設定しています。
例えばもっとも簡単なこはくLv1では、最初の5球は必ず真ん中あたりにストレートを投げてきます。後半は2球だけフォークを投げる設定になっています。
balls.png (781.2 kB)
このような作りになっているため、ボール球は投げないようにしました。必ずストライクゾーンにボールが着弾します。
ちなみに球速は実際の球速とはだいぶ違います。(私が)反応して打てるぎりぎりを160km/hに設定しています。

バッティング

今回実装した簡易的な打撃の実装

気づく人は気づくと思いますが、バットとボールには当たり判定がありません。
スイングモーションにアニメーションイベントを付け、特定のタイミングで「ボールの着弾予定地点」「カーソル位置」「ボールのZ位置」を見てヒットしたかどうか、打球の方向、強さを決めています。
なので、バットはゲームには直接影響を及ぼさない飾りです。ただ、カーソル位置のあたりをバットが通らないと不自然になるため、バットの角度を決め打ちで無理やり変えることで近くを通しています。前から見るとバットが不自然にぐねってますし、本体がアニメーションしているため、毎回バットを通る位置は異なります。加えて、ボールのZ位置がバッターの正面のときにジャストとしているので、振り遅れてバットに当たる場合はバットよりも後ろでボールに当たることになります。これは明らかに違和感があります。
image.png (223.2 kB)

理想的な打撃の実装(うまくいくかは未検証)

まずはカーソル位置に正確にバットを通すようにIKを使ってモーションを制御します。
カーソル位置をバットが正確に通るようになったら板状の当たり判定をバットに着けてバットを振ってもらいます。
image.png (219.5 kB)
当たり判定がボールと衝突したときの衝突位置や板の角度を基に打球の飛距離や方向を決定します。正確にバットの位置を制御できたとしても
バットにボーン入れる必要がありますし、そのあたりのノウハウを持っていないため時間がかかると判断し、今回は実装しませんでした。ボーン入れるってどうやるの...

打球の軌道計算

ボールがバットにヒットしたときに角度と強さを計算で求めます。物理エンジンではいい感じの打球にすることはできないでしょう。
まずはヒットするかどうかとジャストミートの判定
タイミングの判定はボールのz位置による判定です。ただし、固定値だと球速が速くなるにつれ当たりにくくなるので、球速による補正をかけ、速い球ほど当たりやすいように設定しています。また、ジャストミートの判定はz位置が前に存在する分を長めにとっています。これは、引っ張り方向(タイミングが速め)の場合でもジャストミートが出るようにするためです。
タイミング以外の部分は単純に着弾予定地点とカーソル位置の距離を見ています。バットの形は考慮されていません。(ついでに言うとカーソルの大きさは適当で、当たり判定とは合っていません)
続いて角度の計算
カーソル位置と投球の着弾予定位置が完全に一致し、ボールのz位置がバッターの真正面のとき、打球は真正面に斜め45度で飛ぶように設定します。
y軸角度はスイングのタイミングにおおきく依存します。当たり判定をとったときに前方にボールがあるなら引っ張り方向、後方にボールがあるなら流し方向に回転します。最大の回転角度はだいたい+-110度ぐらいです。
x軸角度は着弾予定地点とカーソル位置のy差分に強く影響を受けます。ボールの下を叩いたときに上方向、上を叩いたときに下方向に飛ぶように設定します。最大の回転角度はだいたい45+-60度ぐらいです。ただし、ジャストミートのときは35度から55度の範囲に収まるように調整しています。
最後に打球の強さの計算
計算式は基本パワー + タイミング補正 + カーソル距離補正 + ジャストミートボーナス + 球種補正で計算しています。
基本パワーは固定値です。これが無いとバットに当ててもその場に落ちるだけという不思議な状況が発生してしまいます。
タイミング補正はタイミングが悪い時にあまり飛ばないようにします。ただし引っ張り方向は減衰しないようにしています。
カーソル距離補正はカーソル位置が着弾予測位置に近いほど大きいパワーを加えます。
ジャストミートボーナスはジャストミートの時に一律して加算するパワーです。
球種補正は変化球であればパワーを加えるようにします。
打球の強さの計算式は係数をあまり練っておらず、うまくいけば200m以上飛びます。蜂蜜キマってる。飛びすぎる分には別にいいかな?
初速と角度が決まったらボールのrigidbodyを有効にして初速を設定して飛ばします。
あとは地面と当たり判定をとって飛距離を計算し、スコアに加算します。

演出

ヒットストップ

特筆すべきはジャストミートのときのヒットストップだと思います。
打撃の気持ちよさを演出するために、少し派手目に演出しています。集中線のようなエフェクトを出してもよかったかもしれません。
Updateで動くものが無い時はヒットストップは簡単に実装できます。
HitStopHandler.cs
using System;
using UnityEngine;
using System.Collections;

public class HitStopHandler : MonoBehaviour {

    public void HitStop(float stopTime, Action onResume)
    {
        StartCoroutine(HitStopCoroutine(stopTime, onResume));
    }

    private IEnumerator HitStopCoroutine(float stopTime, Action onResume)
    {
        float timeScale = Time.timeScale;
        Time.timeScale = 0f;

        yield return new WaitForSecondsRealtime(stopTime);

        Time.timeScale = timeScale;
        if (onResume != null) onResume();
    }
}
前述の通り、バットはボールと接触しないことがあるため、ヒットストップの瞬間にはボールを消して一見バットにボールが当たっているように見せることが重要でした。

サウンド

打撃音とキャッチャーミットの音は、強さに応じてリバーブをかけて強さを演出しています。
Batter.cs
private float power; //  打球の強さ(0~1)
private AudioSource hitAudio;

private PlayHitAudio()
{
    float reverbLevel = -2000 + power * 2200;
    hitAudio.GetComponent<AudioReverbFilter>().reverbLevel = reverbLevel;
    hitAudio.Play();
}
WebGLではAudioReverbFilterなどのオーディオフィルターが動かないため、exe版のみの機能です。

よかった点

機能を絞れるところは絞り、こだわるところはこだわれた、バランス感覚が良かったです。最終的にはレベルデザインもいい感じに仕上がったように思えます(主観)
私にとっては新しいことにも取り組むことができましたし、満足できる結果になりました。

悪かった点

ほとんどビルドして確認していなかった

もともとWebGLで公開する気はなかったのもありますが、エディタ上だけでデバッグを済ませるのは良くなかったです。
オーディオ関係がうまく動かないのに気付いたのはほぼ完成した後だったので、そのあとからAudioFilterを使わなくてもよいサウンドのシステムを作るモチベーションは無かったです。もっと早く気づいていれば何かしらの対応をしていたかもしれません。

SDキャラにモーションを適用する際の不備

時短のために目をつぶった部分が多いので、一概に悪かったとは言えないのですが、やはり気になりますね。
image.png (140.8 kB)
他にもピッチングモーションの終わり際など、いろいろとめり込みが発生している箇所があります。
こういう不具合をさくっと解決できるグラフィッカースキルが欲しい!! 実際のところグラフィックの簡単な不具合はプログラマー側で直せた方が何かと便利ですね。

おわりに

リグを使った3Dゲームを作るのは実は初めてなので、めっちゃ楽しかったです。と同時に、3Dスキル不足を感じたので今後も精進していきたいです。
次はVOICEROID Game Jamにて何か作る予定です。
この記事および作成したゲームはユニティちゃんライセンス条項の元に提供されています。
© Unity Technologies Japan/UCL

  1. 選手を作成するストーリーモード「サクセス」におけるイベントの一種。NPCが投げる固定の数の球をヒット性のあたりにすれば得点が加算され、得点に応じて強い選手を作るための環境を手に入れたり失ったりする。

0 コメント:

コメントを投稿