LoginSignup
1
2

More than 3 years have passed since last update.

サイバーエージェント様のインターンで三位受賞した話!

Last updated at Posted at 2020-09-30

はじめに

はじめまして。Unity、C#、C++を勉強している「でこ」と申します。Qiita初投稿になるので、C#やUnityについてだけでなく、Qiitaの書き方についても改善点があればお教えいただくと幸いです。

初投稿にはサイバーエージェント様の3日間のゲーム開発インターン「プロトスプリントリーグ」に参加させていただきましたのでそのお話を書かせていただきたいと思います。
これからサイバーエージェント様のゲームエンジニアインターンに参加する方がいましたらこの記事が少しでもお役に立てれば幸いです。

対象読者

これからサイバーエージェントさんのインターンに参加予定がある人
サイバーエージェントさんのインターンに興味がある人
ゲームエンジニアとして企業のインターンがどんなものか知りたい人

目次

1.インターン概要
2.企画
3.キャラクター制御
4.レベルデザイン
5.リザルトシーン。タイトルシーン
6.完成
7.講評
8.インターン全体を振り返って
9.最後に

1.インターン概要

9/19 から9/21の三日間のインターンのでした。メンバーは4人だったのですが3人がクライアントエンジニア。1人がサーバーエンジニアの方でした。
僕は去年もこのCA様のインターンに参加させていただいたのですが、このサーバーへのつなぎ込みという部分が去年にないところで、難しいところでした。

まず、インターンが始まる前にZOOMでメンバーの方とお話をして作るゲームをどんなものにするのかを話し合いました。お題は「シューティングゲーム」or「パズルゲーム」
ZOOMで話してみると、チームのみんなとすごく仲良くなれて話し始めて数時間しかたっていないのにいろんな話題まで話せるぐらいの仲になることができました。
終わった今となってはこの仲のよさが僕たちのチームの最大の武器となり、そのおかげで3日間という短い時間で納得のいく一個のゲームが作れ、賛意を受賞することができたんだと思います。

2.企画

企画についてはたった三日間で一つのゲームを作るので事前からしっかり決めておきたいものです。これから参加する方にはこの企画の段階をインターンが始まる前までにしっかりと詰めれたらいいと思います。仕様書なども書いとくのもいいと思います。

まず、私たちは紆余曲折あって「シューティングゲーム」を作成することにしました。(その紆余曲折を描くと話が2倍になってしまうので止めます(笑))

そのあと考えたのは3Dなのか2Dなのかという点です。私は去年AppStoreに自作のゲームをリリースしたのですがそのゲームというのは3Dシューティングゲームです。ここでかなり苦労したのが3Dシューティングという操作のむずかしさなのです。
3Dというのは当たり前ですが次元が三つあることになります。ということはこれらをすべて操作するということはx軸、y軸、z軸の操作が少なくとも三つ必要になります。私のゲームはiPhoneなので左手で飛行機を操作。右手で弾をうつ。という仕様にしたかったのでどうしても操作が二次元的になってしまうのです。そこで私はz軸、、つまり前方方向には機体が自動で進んでもらうようにしたのです。

では今回どうするのか。私がよくプレイしているPUBGは世界は三次元で操作は二次元です。もちろんジャンプはありますが重力で落ちてしまいます。
今回作るゲームはPC上で動くWEBGLの予定でした。そのため三次元操作ならば最低でもキーボードの三つボタンを押したりしなくちゃならない、、、それってユーザーは楽しいより煩雑さを気にしてしまうんじゃないかな?と感じました。

私の考えですが「楽しい」と思ってもらうためには当たり前ですがまずはプレイしてもらわなくてはなりません。そしてその一回目のプレイで操作が難しすぎると、「このゲームいろんなことできそうだけど複雑でわからないからまたあとでプレイしよう」と思われるのではないかと考えました。特に今回のハッカソンでは私たちのチーム(Aチーム)を含めて8チームあります。メンターの方々が少ない時間の間で8つのゲームをプレイしてもらうことを考えると、初回のプレイで引き込まれるようなものを作らなくてはなりません。

以上の考えから私たちは先にスローガンを考えました。「手軽で、簡単で、面白く」です。
「手軽」であることでプレイの敷居を徹底的に下げます。「操作」が簡単であることでゲームに慣れていない人でも楽しんでもらえる。そしてエフェクトやSE、レベルデザインにこだわることで「面白い」とプレイヤーに思わせます。それによってもう一回プレイしたい!と感じさせます。そこで「手軽」であることでもう一回プレイしてもらうことにつながっていきます。1プレイの時間もある程度短くすることで少ない時間で何回も遊んでもらえるようにする。この循環サイクルを作るために様々なことをやっていこうと考えました。そしてこのスローガンは何が何でも崩さない。つまりこのスローガンに反するゲーム仕様が追加するのはやめようという風に考えました。

ではこのスローガンを達成するためにどうしたらいいでしょうか。まず、操作が「簡単」であることに注目しました。ゲームに慣れていないような小学生の女の子でも簡単に操作できる。これを考えたとき、もはやWASD操作ですらも煩雑であるかもしれない。という風に考えました。
そして両手を操作するのは難しいのではないか。という考えに至りました。しかし、片手で操作できるとしたらマウスを持つか、キーボードのボタン一個(ボタン二つを押すことすらも煩雑だと判断しました。)です。これでシューティングゲームを実装できるのか。そう悩みました。

シューティングゲームはプレイヤーの操作と弾を撃つ。最低でもこの二つが必要です。これらが片手一つで操作できるのでしょうか。
このことを考えても答えは出ず、21:30に始まっていたチーム会議は日付を超えようとしていました。
ちょっと休もうと思って私はトイレ休憩しました。その時にふと何かいい案がないか一人で考えていました。

今まで僕たちが考えていたアイデアというのは共通点があります。それは
売れているゲームのアイデアを借りてそこにオリジナリティのある要素を追加して一見新しいゲームを作ろう
というようなものでした。
この考え方は正しいと思います。今売れているゲームはなぜおもしろいのか。これを考えることは面白いゲームを作る第一歩目になるからです。
しかし、あまりにも長考していたのでこの考えから一回離れてみようと思いました。
しかし、0から新しいゲームを作ることはむずかしい、、、ではどうするか。

そこで別の角度から考え直して
テーマパークとかのアトラクションから着想をえれないだろうか
という考えに至りました。
テーマパークも同じエンターテイメント。人を楽しませるというところには共通点があります。そこでアトラクションでシューティングのものあったかなあ~と考えるとふと思いついたのがディズニーランドにある「バズライトイヤー」でした。

バズライトイヤーのアトラクションは機体に乗って的を見つけたら機体を回転させて、的に向かって撃つ!!!それだけの操作です。でもあんなに面白い。私もディズニーに遊びに行ったときは必ず乗ります。操作が「回転」と「撃つ」の二個なのにあんなに面白いのってすごいですよね。それに、点数がランキングになったりしていてその部分もサーバーとのつなぎ込みという部分で使えると思いました。さらにバズライトイヤーに乗っているとき、的がたくさんあるところで思いっきり振り返ると一番高い点数が高い的があったり、隠し要素的な面でもレベルデザインがものすごい考えられていると私は思っていたのです。

これらを考えたとき、バズライトイヤーのように移動はすべて自動で行えれば、プレイヤーの操作は回転と撃つという二つだけ。そしてそれをマウス操作にすることで
マウスを動かすと視点が動く
クリックすると弾が出る
という操作にすれば当初目標にしていた片手操作も達成できます。
あとはバズライトイヤーのように的の種類によって点数を変え、的の位置などを工夫してレベルデザインにこだわれば、十分賞を狙えることができるものを作れる!と思いました。

この話をほかのメンバーにするとみんな納得してくれてそれでいこう!というふうに固まったので大体のゲーム仕様が決まってきました。

また、私はチュートリアルは飛ばされるぐらいが理想。という風に考えています(それぐらい簡単であるということ)。すると、チュートリアルシーンを作らず、ゲームスタートシーンに的を置いてそこをクリックするとゲームシーンに遷移するのだけれど、そこで的に弾を発射すれば、プレイヤーは「クリックで弾が打てるんだ」「マウスを使うんだ」という風に認識できるのではないかと考えました。
タイトルシーンがチュートリアルシーンを兼ねるということですね。

3.キャラクター制御

やっとプログラミングのお話になります(笑)長々とお話しして申し訳ありません。
僕が担当したのはプレイヤー制御、敵(的)の作成。ステージ作成。タイトルシーンやリザルトシーンやUI。シーン遷移などゲームの根幹となるものです。ではまず最初にゲームの要とも言えるプレイヤー制御についてお話します。

マウスによる視点回転について


public float sensitivity = 30f;
public GameObject cam;
float rotX, rotY;


void Update(){

rotX = Input.GetAxis("Mouse X") * sensitivity;
rotY = Input.GetAxis("Mouse Y") * sensitivity;

CameraRotate(cam, rotX, rotY);
}

これでマウスの移動で視点回転を簡単に行うことができます。そして肝心のCamRotateメソッドは



private void CameraRotate(GameObject cam, float rotX, float rotY)
{
    transform.Rotate(0, rotX * Time.deltaTime, 0);
    cam.transform.Rotate(-rotY * Time.deltaTime, 0, 0);
}

この二つをかいて視点回転は終わりです。sensitivityはお好みの値で変更してください。結局私は30のままでしたが、Editor上では1.5倍するようにStartメソッドに書いて調節していました。

クリックによる弾の射出

では次はシューティングゲームとしてのメイン部分の弾の発射です。
企画のところでお話しした通り、クリックで弾を撃つ仕様です。また、バズライトイヤーのイメージで最初、ステージは室内というイメージだったので(結局は屋外になりました(笑)その話はレベルデザインのパート)周りに壁がある前提だったのでクリックした方向にRayを飛ばして当たったポジションに弾の発射ベクトルを向ければいいと考え、以下のコードになりました。


void Update(){

            if (Input.GetMouseButtonDown(0))
            {
                Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
                RaycastHit hit;
                if (Physics.Raycast(ray, out hit, 1000))
                {
                    Vector3 dir = hit.point - bulletEnter.position;
                    dir = dir.normalized;
                    LaserLaunch(dir);

                    if (hit.transform.tag == Point1)
                    {
                        //弾が当たったときの処理を以下に書く
                        Debug.Log("Point!");

                    }

                }
            }
}

private void LaserLaunch(Vector3 direction)
    {
        GameObject laser = Instantiate(laserBullet,bulletEnter.position,Quaternion.identity);
        Rigidbody laserRigid = laser.GetComponent<Rigidbody>();
        laserRigid.AddForce(direction * laserSpeed);
    }

これで弾の射出を行いました。Physics.Raycastを使ったら屋外でクリックしたところにColliderがなかったら弾が撃てないじゃないか、と思う方もいらっしゃると思います。僕も思います(笑)
本当にコードを書いてるときに屋外になったら撃てないなあ。と思いましたが、もし屋外に変更されたとしてもプレイヤーが自動で移動するという仕様は絶対に変わらないものなのでその航路の周りに見えない壁を置いて対応しようと考え、スピード重視で書きました。
結果屋外になったので見えない壁で対応しました。

あと、シューティングゲームなのに弾で当たり判定持たせていないんだと思ったかもしれません。このゲームはプレイヤーが自動で進むものですし、後で出てきますが的である果物たちも動いています。そんな中、弾はできるだけ当たってほしいためにクリックのときに当たり判定なるものをとり、弾はその判定を覆さないぐらい早い弾速にしようという風に考えました。
あたってないじゃないか!といわれるよりもあたりやすい仕様にしようと考えたためです。

Point1やlaserSpeedはインスペクターで操作してもらえるようにSerializeField形式でフィールドに書いています。
やはり当たり判定のtag処理も直にtag==""で書くのではなく、変数として書くと後で楽ですね。この時は的がリンゴなのか、ニンジンなのかわかっていなかったので後でリンゴ、ニンジン、ブドウ、すいか、、、っていう風に増えていっても変数を変えればいいだけですもんね。

このコードでユーザーが行う操作がすべて終わり、後はプレイヤーの自動移動だけですが、ステージができないことには進みません。ここでステージづくりに作業を移行しました。
ここまでで1時間半で作れました。そのスピード感がよかったかなあと今になって思います。プレイヤー操作をある程度終わらせてほかのメンバーに操作の雰囲気をイメージしてもらえるという点で。

的(果物たち)の実装

次に的である果物たちの実装です。
CA Proto A - GameScene_DAIKI - WebGL - Unity 2019.4.4f1 Personal _DX11_ 2020_09_29 20_50_10.png
上のようにAnimationで敵たちの挙動を作りました。のちに難しすぎる!というご意見をいただいたので少し動きを遅くしたり、的の大きさを大きくしました。
これをりんご、にんじん、ぶどう、ばなな、すいかでAnimationを変えて移動させました。

ゲームマネージャーの実装

そろそろ点数やゲーム状態を保持するスクリプトが必要になってきます。ゲームマネージャーを作り管理させていくことにしました。


public class GAME_MANAGER : MonoBehaviour
{

    //0番目がリンゴ、1番目がニンジン、2番目がバナナ、3番目がすいか
    public int[] fruitCount = { 0, 0, 0, 0,0 };

    [SerializeField] int appleTimes = 1;
    [SerializeField] int carrotTimes = 5;
    [SerializeField] int bananaTimes = 10;
    [SerializeField] int grapeTimes = 30;
    [SerializeField] int watermelonTimes = 50;

    public void GameInitialize()
    {
        PointTimes = 0;

        for(int i = 0; i < fruitCount.Length; i++)
        {
            fruitCount[i] = 0;
        }
    }
    public void PointTimesUp()
    {
        PointTimes++;
        Debug.Log(PointTimes);
    }

    public void PointTimesDecrease()
    {
        PointTimes--;
    }

    public void AppleUp()
    {
        int number = fruitCount[0];
        number++;
        fruitCount[0] = number;
        Debug.Log("Apple" + fruitCount[0]);
    }

    public void CarrotUp()
    {
        int number = fruitCount[1];
        number++;
        fruitCount[1] = number;
        Debug.Log("Carrot" + fruitCount[1]);
    }

    public void BananaCount()
    {
        int number = fruitCount[2];
        number++;
        fruitCount[2] = number;
        Debug.Log("Banana" + fruitCount[2]);
    }

    public void WaterMelonUp()
    {
        int number = fruitCount[3];
        number++;
        fruitCount[3] = number;
        Debug.Log("WaterMelon" + fruitCount[3]);
    }

    public void GrapeUp()
    {
        int number = fruitCount[4];
        number++;
        fruitCount[4] = number;
        Debug.Log("Grape" + fruitCount[4]);

    }

    public int CaliculateScore()
    {
        int score = fruitCount[0] * appleTimes + fruitCount[1] * carrotTimes + fruitCount[2] * bananaTimes + fruitCount[3] * watermelonTimes+fruitCount[4]*grapeTimes;

        return score;
    }


}

ここは一気に完成形を上記に表示させますね。
fruitContという配列をもっておき、その配列の中の数を当たり判定で一個づつカウントアップしておくことにしました。この辺も持続性あるコードでかければよかったなあ、、、
リンゴやにんじんやバナナで点数が変わってくるのでそこは[SerializeField]でインスペクターからいじれるように。
appleTimesやcarrotTimesがその倍率になります。
当たり判定の時はリンゴに当たったらAppleUp()を、ニンジンに当たったらCarrotUp()を呼び出してゲームクリアになるときにCaliculateScore()を呼び出せばスコアを換算してくれます。

プレイヤースクリプト修正

ここまででGameManagerが作れたのでPlayer側のスクリプトも変更し、ゲームマネージャーとの連携を入れました。
まず、以下がUpdate関数の全文になります。


 void Update()
    {

        if (gameState == GameState.CountDown)
        {
            countDeltaTime += Time.deltaTime;

            if (countDeltaTime <= 1)
            {
                if (!CountDownText.gameObject.activeSelf)
                {
                    CountDownText.gameObject.SetActive(true);
                }
                CountDownText.text = "3";
            }
            else if(countDeltaTime<=2)
            {
                CountDownText.text = "2";
            }else if (countDeltaTime <= 3)
            {
                CountDownText.text = "1";
            }else

            if (countDeltaTime >= countInterval)
            {
                countDeltaTime = 0;
                CountDownText.text = "Go!";
                animator.enabled = true;
                gameState = GameState.InGame;
                StartCoroutine(DisappearObject(CountDownText.gameObject, 1));
            }

        }else

        if (gameState == GameState.InGame)
        {
            if (!progressSlider.gameObject.activeSelf)
            {
                progressSlider.gameObject.SetActive(true);
            }

            AnimatorStateInfo animInfo = animator.GetCurrentAnimatorStateInfo(0);


            if (animInfo.normalizedTime < 1)
            {
                progressSlider.value = (animInfo.normalizedTime);
            }

            if (animInfo.normalizedTime >= 1)
            {
                //終了処理
                ResultMethod();
            }

            rotX = Input.GetAxis("Mouse X") * sensitivity;
            rotY = Input.GetAxis("Mouse Y") * sensitivity;

            CameraRotate(cam, rotX, rotY);



            if (Input.GetMouseButtonDown(0))
            {
                //Vector3 clickPos = Camera.main.ScreenToWorldPoint(Input.mousePosition);
                //clickPos.z = transform.position.z + transform.forward.z * 1000;
                //Vector3 dir = clickPos - bulletEnter.position;

                Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
                RaycastHit hit;
                if (Physics.Raycast(ray, out hit, 1000))
                {
                    Vector3 dir = hit.point - bulletEnter.position;
                    dir = dir.normalized;
                    LaserLaunch(dir);

                    shotSE.Play();

                    if (hit.transform.tag == Point1)
                    {
                        //弾が当たったときの処理
                        Debug.Log("Point!");
                        //manager.PointTimesUp();
                        manager.AppleUp();
                        HitParticle(hit, 0);

                        ScoreDisplay.text = "Score:" + manager.CaliculateScore();

                        hit.transform.GetComponent<AudioSource>().Play();

                        StartCoroutine(DisappearObject(hit.transform.root.gameObject, 0.1f));
                    }
                    else if (hit.transform.tag == Point2)
                    {
                        manager.CarrotUp();
                        HitParticle(hit, 1);

                        ScoreDisplay.text = "Score:" + manager.CaliculateScore();

                        hit.transform.GetComponent<AudioSource>().Play();

                        StartCoroutine(DisappearObject(hit.transform.root.gameObject, 0.1f));
                    }
                    else if (hit.transform.tag == Point3)
                    {
                        manager.BananaCount();
                        HitParticle(hit, 2);

                        ScoreDisplay.text = "Score:" + manager.CaliculateScore();

                        hit.transform.GetComponent<AudioSource>().Play();

                        StartCoroutine(DisappearObject(hit.transform.root.gameObject, 0.1f));

                    }
                    else if (hit.transform.tag == Point4)
                    {
                        manager.WaterMelonUp();
                        HitParticle(hit, 3);

                        ScoreDisplay.text = "Score:" + manager.CaliculateScore();

                        hit.transform.GetComponent<AudioSource>().Play();

                        StartCoroutine(DisappearObject(hit.transform.root.gameObject, 0.1f));
                    }else if (hit.transform.tag == Point5)
                    {
                        manager.GrapeUp();
                        HitParticle(hit, 4);

                        ScoreDisplay.text = "Score:" + manager.CaliculateScore();

                        hit.transform.GetComponent<AudioSource>().Play();

                        StartCoroutine(DisappearObject(hit.transform.root.gameObject, 0.1f));                           

                    }
                }


            }


        }else

        if (gameState == GameState.GameClear)
        {

            if (Input.GetMouseButtonDown(0))
            {
                Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
                RaycastHit hit;
                if (Physics.Raycast(ray, out hit, 1000))
                {
                    Vector3 dir = hit.point - bulletEnter.position;
                    dir = dir.normalized;
                    LaserLaunch(dir);

                    if (hit.transform.tag == "ResultPoint")
                    {
                        StartCoroutine(GoToResult());
                    }
                }


            }

        }
    }

ではこれについて部分部分に見ていきます。


if (gameState == GameState.CountDown)
        {
            countDeltaTime += Time.deltaTime;

            if (countDeltaTime <= 1)
            {
                if (!CountDownText.gameObject.activeSelf)
                {
                    CountDownText.gameObject.SetActive(true);
                }
                CountDownText.text = "3";
            }
            else if(countDeltaTime<=2)
            {
                CountDownText.text = "2";
            }else if (countDeltaTime <= 3)
            {
                CountDownText.text = "1";
            }else

            if (countDeltaTime >= countInterval)
            {
                countDeltaTime = 0;
                CountDownText.text = "Go!";
                animator.enabled = true;
                gameState = GameState.InGame;
                StartCoroutine(DisappearObject(CountDownText.gameObject, 1));
            }

        }

これはゲームが始まる前にカウントダウンを表示するUIを操作する部分です。
enumとしてGameStateを定義しています。GameStateがCountdownの時はタイマーを起動させ、テキストに表示させます。
そしてcountInterval(ここでは3秒)を超えるとGameStateを切り替えています。
実行するとこんな感じです映画 & テレビ 2020_09_29 23_33_49.png
映画 & テレビ 2020_09_29 23_36_07.png

写真なのでわかりづらいですがUIには時間とともに薄くなるようなAnimationをつけています。
ちなみにここに使われている


DisappearObject(引数1,引数2)

は僕がよく作るメソッドでめっちゃ簡単なんですけど、Unityに慣れていないメンバーもいるかもしれないので仕込んでおきました。
あるオブジェクトを数秒後に消す処理です。もともとUIは僕が触る予定ではなかったのでスクリプトを自分とは別の人に渡すときによく使うけど書くのがめんどくさいメソッドをあらかじめに書いておこうと思っていました。


private IEnumerator DisappearObject(GameObject disObject, float time)
    {

        yield return new WaitForSeconds(time);

        disObject.SetActive(false);

    }

まあ最後は自分が使うことになり助かったのは自分でした(笑)

次にカウントダウンが終わってからの処理です。


if (gameState == GameState.InGame)
        {
            if (!progressSlider.gameObject.activeSelf)
            {
                progressSlider.gameObject.SetActive(true);
            }


            AnimatorStateInfo animInfo = animator.GetCurrentAnimatorStateInfo(0);


            if (animInfo.normalizedTime < 1)
            {
                progressSlider.value = (animInfo.normalizedTime);
            }

            if (animInfo.normalizedTime >= 1)
            {
                //終了処理
                ResultMethod();
            }

            //ここからは弾の発射部分

まず前半です。この部分はゲームの進行度合いを何かしらのUIで見せたほうがいいというメンバーの意見を取り入れ実装したところですね。
このGameStateの分岐によってゲームが始まってすぐ始まって焦ることはなくなりましたし、ゲームの前に弾を撃って点数を稼ぐことも防げたので良かったです。

進行度合いはSliderで見せています。以下の写真の右上ですね。スコアの下側に出しています。
映画 & テレビ 2020_09_29 23_53_59.png

プレイヤー移動はAnimationで行っているので、まず、AnimatorStateInfoを毎フレーム取得します。


 AnimatorStateInfo animInfo = animator.GetCurrentAnimatorStateInfo(0);

そしてその後にAnimatorStateInfo全体を一とした時の現在の進行度合いをあらわすnormalizedTimeでゲームが終わっていないかを確認します。normalizedTimeが1になるのはゲームが終わったこと。1未満はゲーム中ということで進捗度合いをSliderに反映しています。


if (animInfo.normalizedTime < 1)
   {
        progressSlider.value = (animInfo.normalizedTime);
   }

次に後半となる部分。Rayを飛ばしてSEを鳴らす処理や当たった的によって点数が変わるところはtagで判定しているのでそこの条件分岐でmanager(GameManagerスクリプトインスタンス)のそれぞれの点数処理にアクセスしています。


             if (Input.GetMouseButtonDown(0))
            {
                Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
                RaycastHit hit;
                if (Physics.Raycast(ray, out hit, 1000))
                {
                    Vector3 dir = hit.point - bulletEnter.position;
                    dir = dir.normalized;
                    LaserLaunch(dir);

                    shotSE.Play();

                    if (hit.transform.tag == Point1)
                    {
                        //弾が当たったときの処理
                        Debug.Log("Point!");
                        manager.AppleUp();
                        HitParticle(hit, 0);

                        ScoreDisplay.text = "Score:" + manager.CaliculateScore();

                        hit.transform.GetComponent<AudioSource>().Play();

                        StartCoroutine(DisappearObject(hit.transform.root.gameObject, 0.1f));
                    }
                    else if (hit.transform.tag == Point2)
                    {
                        manager.CarrotUp();
                        HitParticle(hit, 1);

                        ScoreDisplay.text = "Score:" + manager.CaliculateScore();

                        hit.transform.GetComponent<AudioSource>().Play();

                        StartCoroutine(DisappearObject(hit.transform.root.gameObject, 0.1f));
                    }
                    else if (hit.transform.tag == Point3)
                    {
                        manager.BananaCount();
                        HitParticle(hit, 2);

                        ScoreDisplay.text = "Score:" + manager.CaliculateScore();

                        hit.transform.GetComponent<AudioSource>().Play();

                        StartCoroutine(DisappearObject(hit.transform.root.gameObject, 0.1f));

                    }
                    else if (hit.transform.tag == Point4)
                    {
                        manager.WaterMelonUp();
                        HitParticle(hit, 3);

                        ScoreDisplay.text = "Score:" + manager.CaliculateScore();

                        hit.transform.GetComponent<AudioSource>().Play();

                        StartCoroutine(DisappearObject(hit.transform.root.gameObject, 0.1f));
                    }else if (hit.transform.tag == Point5)
                    {
                        manager.GrapeUp();
                        HitParticle(hit, 4);

                        ScoreDisplay.text = "Score:" + manager.CaliculateScore();

                        hit.transform.GetComponent<AudioSource>().Play();

                        StartCoroutine(DisappearObject(hit.transform.root.gameObject, 0.1f));                           

                    }
                }


            }

最後にゲームクリアーとなる部分


if (gameState == GameState.GameClear)
        {

            if (Input.GetMouseButtonDown(0))
            {
                Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
                RaycastHit hit;
                if (Physics.Raycast(ray, out hit, 1000))
                {
                    Vector3 dir = hit.point - bulletEnter.position;
                    dir = dir.normalized;
                    LaserLaunch(dir);

                    if (hit.transform.tag == "ResultPoint")
                    {
                        StartCoroutine(GoToResult());
                    }
                }
            }
        }

ここは弾を撃つ処理と同じです。ゲームが終わると以下の写真のような的が出てくるのでそれに当たるとリザルトシーンに飛びます。
映画 & テレビ 2020_09_30 0_02_43.png

企画のところで説明したようにボタンを可能な限り排除して的に代用させることで世界観を維持しています。
ここのGAME CLEARのAnimationも動いてかわいくなっていました。

ここまででキャラクター処理になります。

4.レベルデザイン

そして企画の段階でもお話ししたようにゲームにレベルデザインは欠かせません。ステージ作りは肝となる部分でした。

CA Proto A - GameScene_DAIKI - WebGL - Unity 2019.4.4f1 Personal _DX11_ 2020_09_30 0_16_22.png
全体となるステージです。自動生成は負荷の面で行いたいところだったのですが時間がなく断念しました。時間があればまたチャレンジしたいところです。
ステージはぐるっと一周して終わりのイメージ。大体一回のプレイで1分半もないぐらいです。それこそ目標にしている「手軽さ」をあらわしました。世界観はポップでかわいいものに。果物が的を持つというのは世界観を明るくするためにはもってこいでした。
この世界観を統一していくというチームの雰囲気は2日目には固まり、それによってステージが作りこみやすくなりました。

CA Proto A - GameScene_DAIKI - WebGL - Unity 2019.4.4f1 Personal _DX11_ 2020_09_30 0_17_04.png
上の写真からわかるようにプレイヤーが移動する道の周りにいっぱい的を置いています。今写真の中ですいかを見つけれたでしょうか。すいかが一番点数が高く、リンゴとニンジンは低いです。それに伴い、点数が高いスイカは少なく配置し、低い果物は多く配置しています。

CA Proto A - GameScene_DAIKI - WebGL - Unity 2019.4.4f1 Personal _DX11_ 2020_09_30 0_17_23.png

プレイ時間が1分半未満のゲームといえど同じことを同じペースでプレイすれば飽きてしまうかもしれません。プレイが1分もいかないぐらいでプレイヤーが山を登るのですが、そこでフィーバータイムを設けました。こうすることでユーザーが飽きることなく、いっぱい的に当てれる爽快感を感じることができます。

しかし、点数がインフレを起こさないようにフィーバータイムで多く配置しているのは1点しか点数が入らないリンゴです。
ここで点数を稼ぎたい数回プレイしてもらっているユーザーはフィーバータイムで目の前のリンゴを撃つのではなく、奥のブドウを撃つと高得点を出すことができます。ブドウは一個で30点入ります。

このように「まだ数回しかプレイしたことがないユーザーが楽しめる場所」「何回もプレイしていただいているユーザーが楽しめる場所」を二つ実装したことが何回やっても飽きずに楽しめる要因でした。

レベルデザインとして先に述べましたが、コードフリーズしなくてはならない1時間半前ぐらいにWEBGLとして書き出し、それをメンターさんやチームのみんなに遊んでもらって感想を言ってもらいました。
作っている自分はそのときにはもうめちゃくちゃうまくなっていたので判断材料にならないんですよね(笑)

そこで寄せられた意見は第一に当たり判定を緩くしてほしいという意見です。これには早急に対応しました。ある程度当ててもらわなくてはゲームになりませんもんね(笑)
的をおおきくし、敵の動きをおそくすることで解決。皆さんにはのちにやりやすくなった!爽快感が上がった!などとおっしゃっていただき、このプレイしていただいた方から意見を聞くというものがどれだけ重要かが身に沁みました。

あとはBGMの音量やバグの報告など。本当に事前に自分たちが作ったゲームを遊んでもらって意見をとりえれるということは参加する学生の方はおすすめです!!

サウンド

レベルデザインとは少し離れてしまいますがサウンドについてもお話ししたいと思います。今回のゲームのサウンドは自分が担当しました。時間がなく最終日に取り掛かったので簡単なことしかできませんでしたが、それでもこだわりをもってSEやBGMを選びました。

まず、BGMとしてはフリー素材の中から選ぶのですが、もちろん世界観にあったポップなもの。そして一回のプレイが1分半未満であることと、リザルト画面の遷移までに10秒ぐらいあることを考えてBGMは1:45ぐらいの長さのものを用意。

また、特に考えたのはSE(弾を撃った時の効果音と、的に当たって的が消えるときの音)なのですが、フリー素材で「シューティング」のSEを探すとどうしても、銃を撃った時のいかつい音や、爆発音だったり、ポップなものとはかけ離れていて世界観を壊しかねません。

私自身の考えなのですが、音というのは耳からの情報だけでなく、視界からの情報が合わさって何の音かを判断していると思っています。つまりいいたいのは、シューティングの銃を撃つときの音が実際の発砲音じゃなくても、音が出たタイミングで弾が前に発射されていたら、聞こえてきた音が弾を撃った時のSEだという風に考える、ということです。

この考えから私は、実際の発砲音や爆発音ではなく、ボタンを押したときの「ぴゅっ」「ぱっ」というボタン音のSEをダウンロードし、それをそれぞれ「弾を撃った時の音」と「的に当たって的が消えるときの音」に割り振りました。

結果、違和感なく、むしろ世界観にあったかわいいSEをつけることができました。

5.リザルトシーン、タイトルシーン

リザルトシーンやタイトルシーンも担当しました。

リザルトシーン

まず初めにリザルトシーンから。
映画 & テレビ 2020_09_30 0_49_41 (1).png
映画 & テレビ 2020_09_30 0_49_43.png
このように、リザルトシーンに遷移したとたん上から果物が降ってくる仕様にしました。思いついて実装した時は思わず「かわいーーー!!!」と叫んじゃいました(笑)

ここのコードは以下のようにしています。


 public void DropFruit()
    {
        for (int i = 0; i < DropCount; i++)
        {
            Vector3 randomPos = Random.insideUnitSphere * 3;
            Instantiate(Apple, DropPoint.position + randomPos, Quaternion.identity);
        }
        for (int i = 0; i < DropCount; i++)
        {
            Vector3 randomPos = Random.insideUnitSphere * 4;
            Instantiate(Banana, DropPoint.position + randomPos, Quaternion.identity);
        }

        for (int i = 0; i < DropCount; i++)
        {
            Vector3 randomPos = Random.insideUnitSphere * 5;
            Instantiate(Carrot, DropPoint.position + randomPos, Quaternion.identity);
        }

        for (int i = 0; i < DropCount; i++)
        {
            Vector3 randomPos = Random.insideUnitSphere * 6;
            Instantiate(WaterMelon, DropPoint.position + randomPos, Quaternion.identity);
        }

        for (int i = 0; i < DropCount; i++)
        {
            Vector3 randomPos = Random.insideUnitSphere * 8;
            Instantiate(Grape, DropPoint.position + randomPos, Quaternion.identity);
        }

    }

メソッドとしてDrop()を呼べば自動で果物たちを生成してその果物たちがRigidBodyをもってuseGravityにOnしているために勝手に落ちてくるようになっています。

DropCountというint型整数がスクリプト内にあり、それをいじくれば落ちてくる果物の数が変わります。
本当は個人的に点数によって落ちてくる果物の数を増やしたりすれば面白いかなあと思っていたのでそれに対応できるようにコーディングしていました。

果物の種類で数を数え、配列としてわたすことで落ちてくる果物の種類ごとに数を変えることも可能ですね。夢ある感じだったのですがスコアのつなぎ込みはコードフリーズ15分前まで粘っていたので時間がなく断念!

タイトルシーン

タイトルシーンは以下のような感じです。
映画 & テレビ 2020_09_30 0_49_12.png
映画 & テレビ 2020_09_30 0_49_12 (1).png

的である果物たちが左から右。右から左に動くかわいいシーンにしました。果物たちの点数は果物の下に書いてもらうよう、エフェクト担当の方に頼みました。(すごいいい感じ)

メンターの方に動く点数だけでなく、早見表として点数を教えてほしいという声を聞き、右にUIとして出しています。
プレイヤーが乗るという設定のカボチャは山の頂点で回ったり飛び跳ねたりするかわいいAnimationをつけました。

タイトルシーンもリザルトシーンもボタンは排除して的をボタン代わりに。企画のパートで話したようにタイトル画面がチュートリアルの代わりをするように意識しました。

タイトルシーンでフルーツを動かすことで、ゲームが始まってフルーツが動いてても違和感なくプレイにはいってもらえますしね。

6.完成

全体としてコードフリーズの直前にやりたいことをほとんどすべて実装完了することができました!
本当にメンバーに感謝です!!!

サーバーとのつなぎ込みだったり、もはやサーバー自体を立てたり、、、本当にメンバーの頑張りのおかげでこのゲームは成り立ちました。
エフェクトが出来上がったときは声が上がったなあ。かわいいポップなエフェクト。求めてたやつだ!!となりました。

最後の最後まであきらめずにいろんな実装。修正。頑張ってくれたメンバーに心から感謝。そして支えてくださったメンターの方に感謝です。
ありがとうございました。

ゲームシナリオは以下のように考えました。

果物の王国では9月の満月の日。大規模なパーティーが開かれます。あなたはそのパーティーに紹介され、カボチャに乗っての射的大会。果物たちが的をもって踊っています。ハイスコア目指してフルーツたちと遊びつくそう!

タイトルは「FRUITS PARTY」。本当に面白い、企画していたものができたと思います。
WEBGL版が以下のURLから遊べます。ぜひ遊んでみてください。(サーバーをもうしめてしまっているのでランキング機能やユーザー登録に不具合が出ますがご了承ください)
http://18.181.158.83/

7.講評

最終日に最終発表があり、私たちAチームは見事3位を受賞することができました!!!うれしいー!!!

寄せられた感想としては「レベルデザインが本当にしっかりしてておもしろかった」や、「世界観がポップでかわいい」「何度もプレイして楽しめた」などと本当にうれしいお言葉をたくさんいただきました。ありがとうございます。
企画の時に考えていたお言葉をいただけて本当にうれしかったです。

修正点としては弾の弾道エフェクトをもう少しはやく消したほうがいい。Rankingを表示するUIをもっと工夫する。というようなことでした。
この修正点はこのゲームだけでなくこれから作っていくゲームにも取り入れれる考えでとてもためになりました。

何がよかったか

今回3位を受賞する要因となったことの一つにはスピーディーな実装を行えたことだと思います。それにより士気も上がりますし、何よりプレイすることが速くなると様々なイメージが出来上がるからです。

あとは何といっても仲の良さですかね。チームの雰囲気が本当に良かったことでだれでも発言できる空気だったりとか、率直に議論しあえる関係というのがよりいゲームを作るという面でよかったことでした。

8.インターン全体を振り返って

このインターンを振り返って本当に多くのものを学び、吸収できました。エンジニアとしてはエフェクトの見せ方だったり、サーバーとのつなぎ込みの部分。

ほかにも、サイバーエージェントさんがどういう会社であるのか。ということを知れるいい機会でした。
メンターの方は親身に対応してくださりますし、質問すると本当にすぐいろんなことを教えてくれます。
懇親会でもいろんなお話ができて、とても楽しかったし、ためになりました。

もしこの記事を読んでいてサイバーエージェントさんのインターンに参加するか悩んでいる方は一度応募してみたらいいと思います。就活生じゃなくても大学二年生や一年生でも。

9.最後に

ここまで本当に長い記事を読んでくださりありがとうございました。この記事が誰かのお役に立てれば幸いです。
このインターンに参加できてよかったです。本当にかかわってくれたすべての皆さん、ありがとうございました。

最後に宣伝になりますが私が昨年にAppStoreでリリースしたゲームです。URLは以下の通りです。
https://apps.apple.com/jp/app/spacecombat/id1458949808

今は次の作品をリリースするために鋭意制作中です。今回のインターンで得たものを生かしてこれからも頑張っていきたいです。

1
2
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
1
2