Global Game Jam 2014 レポート

unity20140127-001

なんとか無事ゲームを完成させれました

世界最大規模のハッカソンである、Global Game Jam に先週初参加し、無事なんとかゲーム1本開発できました。

http://globalgamejam.org/2014/games/gravity-rabbity

いつもはそれなりの期間でゲームを作るのですが初めて組む人と 48 時間以内にゲームを完成させるということで今までと違うスキルも得られるだろうし、なにより 「凄まじく楽しい」 と聞いていたのでワクワクしながら参加してきたわけです。

とりあえず、前置きはおいといてまずは公開してみます。

Gravity Rabbity

【チームメンバー】

Team Leader & Character Design : kurisaka_konabe
Main Programmer : 目玉P
Level Design & GUI Design : syyama
Sound Design : Mandy Wong
Sub Programmer & Level Design : hima_zinn

【ルール】

・4色のウサギを操作し、それぞれ対応したニンジンをゲットします。4匹全部がニンジンゲットでステージクリアです。
・全7ステージです。
・4つの色はそれぞれ重力の向きが違います
・重りを押して下に落としましょう、他のウサギの足場になります
・風船に触れて上に飛ばしましょう、天井の穴を埋める事で他のウサギの足場になります
・落ちてくる重りにつぶされるとミスです
・クリアできなくなったら ESC キーでギブアップしましょう

【操作方法】

・360パッドでも操作できますが、調整不足なのでキーボード推奨です(ぉぃ
・ADキーで左右移動
・JILキーでそれぞれ画面回転(兼操作ウサギの切り替え)

2D TOOLKIT 的な話を

今回パズルということで、2D TOOLKIT のタイルマップ機能と相性がものすごくいいんじゃないか! と思ってましたがステージデータ作成ではものすごく威力を発揮できたのかなと思います。

4色の「Player1」「Player2」「Player3」「プレイヤー4」はタイルマップですが、シーン起動時には他のプレハブに置き換える機能があり、この機能を活用することで敵やスポーンポイントの配置が簡単になります。

2D Toolkit のタイルマップインスペクタ Data タブを選択し、プレハブに置き換えたいタイルに対してプレハブを指定してやります。

プレハブ PlayerDown が指定されたので、Player1 のタイルはスタート時にウサギに置き換わります。

こんな感じです。

プログラム的な話を

開発時は物理演算を使ってやってましたがグリッドにキッチリ収まるパズル系ゲームだと使わない方がよかったと気がつくのが遅すぎましたw

ブロックを押したり邪魔になったり、落ちていくブロックがキレイに1ブロック分だけ空いた穴にハマったり。

落ちても微妙にズレたりいきなり予想外の挙動を起こしたり。

最終的にとった方法は、他の物体との衝突判定は Physics.Linecast で行い、移動制御は完全に 1m 単位で行うようにしました。

後は基本動作を行う MonoBehaviour クラスを作成し、各オブジェクト(ウサギ、重り、風船、ニンジン)にそれを継承させてパズルの世界の挙動ルールをスーパークラスにまかせるようにしました。

【基本動作クラス】

using UnityEngine;
using System.Collections;

public class GameObjectBase : MonoBehaviour {

    public enum Move {
        Wait,
        Trigger,
        Move,
        Lock,
        Miss
    }

    public GameSystem.Gravity eGravity = GameSystem.Gravity.Down;
    public float fBaseLength = 1f;
    public float fFallSpeed = 3f;
    public Move eMove = Move.Wait;

    Vector3 v3GainVector, v3FallVector, v3RightVector, v3LeftVector;
    Quaternion quaGravity;
    Vector3 v3TargetVector;
    Vector3 v3TargetPosition;
    bool boolLastFall = false;

    public void Initialize() {
        quaGravity = Quaternion.Euler(0, 0, GameSystem.Instance.GetGravityAngle(eGravity));
        v3GainVector = quaGravity * Vector3.up;
        v3FallVector = quaGravity * Vector3.down;
        v3RightVector = quaGravity * Vector3.right;
        v3LeftVector = quaGravity * Vector3.left;
    }

    public bool IsMiss() {
        return (eMove == Move.Miss);
    }

    public void Miss() {
        if (eMove == Move.Wait) {
            eMove = Move.Miss;
            gameObject.layer = LayerMask.NameToLayer("Miss");
        }
    }

    public bool IsLock() {
        return (eMove == Move.Lock);
    }

    public void Lock() {
        if (eMove == Move.Wait) {
            eMove = Move.Lock;
        }
    }

    public bool IsWait() {
        return (eMove == Move.Wait);
    }

    public RaycastHit GainCheck() {
        RaycastHit oHit = MoveCheck(v3GainVector);
        if (oHit.transform == null) {
            eMove = Move.Move;
        }
        boolLastFall = false;
        return oHit;
    }

    public RaycastHit FallCheck() {
        RaycastHit oHit = MoveCheck(v3FallVector);
        if (oHit.transform == null) {
            eMove = Move.Move;
            if (!boolLastFall) {
                GameGravityBlock oBlock = GetComponent<GameGravityBlock>();
                if (oBlock != null) {
                    SoundScript.Instance.SoundPlay("block_fall");
                }
            }
            boolLastFall = true;
        } else {
            boolLastFall = false;
        }
        return oHit;
    }

    public RaycastHit RightCheck() {
        RaycastHit oHit = MoveCheck(v3RightVector);
        if (oHit.transform == null) {
            eMove = Move.Move;
        }
        boolLastFall = false;
        return oHit;
    }

    public RaycastHit LeftCheck() {
        RaycastHit oHit = MoveCheck(v3LeftVector);
        if (oHit.transform == null) {
            eMove = Move.Move;
        }
        boolLastFall = false;
        return oHit;
    }

    // Update is called once per frame
    public void BaseUpdate() {
        Vector3 v3Position;
        switch (eMove) {
            case Move.Trigger:
                break;
            case Move.Move:
                v3Position = transform.localPosition;
                v3Position += v3TargetVector * fFallSpeed * Time.deltaTime;
                Vector3 v3From2Target = v3TargetPosition - transform.localPosition;
                Vector3 v3To2Target = v3TargetPosition - v3Position;
                if (Vector3.Dot(v3From2Target, v3To2Target) < 0f) {
                    v3Position = v3TargetPosition;
                    eMove = Move.Wait;
                    //Debug.Log(iSereal.ToString() + ": ChangeWait");
                }
                transform.localPosition = v3Position;
                break;
        }
    }

    RaycastHit MoveCheck(Vector3 v3Move) {
        Vector3 v3Position;
        Vector3 v3Start = transform.localPosition + v3Move * fBaseLength * 0.4f;
        Vector3 v3End = v3Start + v3Move * 0.9f;
        RaycastHit oHit;
        int iHitLayerMask = 1 << LayerMask.NameToLayer("Player") |
                            1 << LayerMask.NameToLayer("BackGround") |
                            1 << LayerMask.NameToLayer("CapturedCarrot");
        bool boolHit = Physics.Linecast(v3Start, v3End, out oHit, iHitLayerMask);
        if (!boolHit || oHit.transform.gameObject.layer != LayerMask.NameToLayer("BackGround")) {
            //Debug.Log("Fall " + v3Start.ToString() + " -> " + v3End.ToString() + " / " + LayerMask.LayerToName(oHit.transform.gameObject.layer));
            v3Position = transform.localPosition;
            v3TargetPosition = v3Position + v3Move * fBaseLength;
            v3TargetVector = v3Move;
            if (boolHit && LayerMask.LayerToName(oHit.transform.gameObject.layer) != "Player") {
                oHit = new RaycastHit();
            }
        } else {
            //Debug.Log(iSereal.ToString() + ": Hit " + v3Start.ToString() + " -> " + v3End.ToString());
        }
        return oHit;
    }
}

このクラスを全てのゲーム内オブジェクトが継承しています。移動はこのクラスを利用しています。

重要なメソッドは移動先のベクトルに何もないか判定する MoveCheck と、決められたベクトルに決められた量だけ移動する BaseUpdate です。

移動完了判定は、移動元からターゲットへのベクトルと、移動後からターゲットとのベクトルの内積で判定しています。

v3Position = transform.localPosition; // 移動前ポジション
v3Position += v3TargetVector * fFallSpeed * Time.deltaTime; // 移動後ポジション
Vector3 v3From2Target = v3TargetPosition - transform.localPosition; // 移動元からターゲットへのベクトル...(1)
Vector3 v3To2Target = v3TargetPosition - v3Position; // 移動後からターゲットへのポジション...(2)

// (1) と (2) の内積
if (Vector3.Dot(v3From2Target, v3To2Target) < 0f) {
    // (1) と (2) のなす角が 90 以上なため、移動後ポジションを無理やりターゲットに設定して移動完了フェイズへ
    v3Position = v3TargetPosition;
    eMove = Move.Wait;
}
transform.localPosition = v3Position;

【ブロッククラス】

using UnityEngine;
using System.Collections;

public class GameGravityBlock : GameObjectBase {

    // Use this for initialization
	void Start () {
        base.Initialize();
	}

	// Update is called once per frame
	void Update () {
        if (IsWait()) {
            RaycastHit oHit = FallCheck();
            if (oHit.transform != null && oHit.transform.gameObject.layer == LayerMask.NameToLayer("Player")) {
                GameMainPlayer oPlayer = oHit.transform.gameObject.GetComponent<GameMainPlayer>();
                if (oPlayer != null) {
                    oPlayer.Miss();
                    SoundScript.Instance.SoundPlay("game_over_sound,game_over_voice");
                    Application.LoadLevel("gameover");
                    return;
                }
            }
        }
        base.BaseUpdate();
    }
}

ブロック(ゲーム内では重り)の処理内容は単純です。足元に何もなければ落ちる。移動先にウサギがいたらゲームオーバー処理に遷移。

ブロック(重り)を押す処理は、ウサギルーチン内からブロッククラス経由で横方向ベクトルで MoveCheck を呼ばせています。

v2AxisMove = new Vector2(Input.GetAxis("Horizontal"), Input.GetAxis("Vertical"));
RaycastHit oHit = new RaycastHit();
// ベースクラスの RightCheck や LeftCheck は MoveCheck をコールし、ヒットした RaycastHit 情報を返す
if (v2AxisMove.x > 0f) {
    oHit = RightCheck();
} else if (v2AxisMove.x < 0f) {
    oHit = LeftCheck();
}
// 何かにヒットした
if (oHit.transform != null) {
    // ヒットした情報から Block コンポーネントを取得してみる
    GameGravityBlock oBlock = oHit.transform.GetComponent<GameGravityBlock>();
    // 取得できたので Block だと判定する
    if (oBlock != null) {
        // これは向きによって押せる押せないの判定なので説明は省略
        if (((eGravity == GameSystem.Gravity.Down || eGravity == GameSystem.Gravity.Up) &&
             (oBlock.eGravity == GameSystem.Gravity.Down || oBlock.eGravity == GameSystem.Gravity.Up)) ||
            ((eGravity == GameSystem.Gravity.Right || eGravity == GameSystem.Gravity.Left) &&
             (oBlock.eGravity == GameSystem.Gravity.Right || oBlock.eGravity == GameSystem.Gravity.Left))) {
            RaycastHit oHitBlock = new RaycastHit();
            // ブロックも基本移動クラスを継承しているので、
            // ブロックの RightCheck や LeftCheck をコールすることで
            // 左右に押すという処理が行える
            if (eGravity == oBlock.eGravity) {
                if (v2AxisMove.x > 0f && oBlock.eMove == Move.Wait) {
                    oHitBlock = oBlock.RightCheck();
                } else if (v2AxisMove.x < 0f && oBlock.eMove == Move.Wait) {
                    oHitBlock = oBlock.LeftCheck();
                }
            } else {
                if (v2AxisMove.x > 0f && oBlock.eMove == Move.Wait) {
                    oHitBlock = oBlock.LeftCheck();
                } else if (v2AxisMove.x < 0f && oBlock.eMove == Move.Wait) {
                    oHitBlock = oBlock.RightCheck();
                }
            }
            if (oHitBlock.transform == null) {
                SoundScript.Instance.SoundPlay("move_block");
            }
        }
    }
}

ソースコードや素材のやり取り的な話を

データやソースのやり取りには Dropbox や USB メモリ等をつかってるところもあったみたいですが、うちは VMware で仮想サーバを立てて Subversion を使用しました。無線 LAN ルータを使ってうちのマシンと他のマシンを隔離された LAN 環境に配置することでソースコード合成の手間を省きました。

ただ、samba は設定ミスってうまく動かなかったので一部ファイルのやり取りが USB メモリだったのは残念でした。

そして今回最大の失敗といえば

初日の朝3時頃まで仮想サーバの設定に手間取ったのが今回最大のミスでした。

[iPhone] – (テザリング) – [開発マシン – (NAT) – [VMマシン]]

現場でサクっと yum できるしええか。と思ってたのが罠だった。

NAT 環境で VMware マシン動かすのが予想外にめんどくさかった。

次回からは用意できる環境は全部完全にしてから挑まないといかんよね。

「シェアする」

ツイートツイート