見出し画像

【Unity】東方ゲームジャム2024に参加しました【リベンジ】

2024年10月15日~10月19日の5日間、東方ゲームジャム2024が開催され、当方も参加しました。そのレポートと最後は作品の作り方及び備忘録となります。


東方ゲームジャム2024の概要

急に発表されたので、もう少しボーっと過ごしていたら開催されたのも知らないまま終わってたかもしれません。
なお、1分たりとも遅刻はできない仕様です。
テーマは「霊夢」「可愛い」「ケモノ」「乗っ取り」のうち、どれかを選ぶというものでした。
(「乗っ取り」はZUN氏が直前にXのアカウントの乗っ取り被害にあったから採用されたと思われる。)

細かいことは動画にしています。

前回の結果と苦い思い出

東方ゲームジャム2024は、4回目の東方ゲームジャムです。
実は3回目の2022にも当方は参加しており、その際のnoteもあります。

前回も今回も同じく東方projectの原作者ZUN氏とビートまりお氏が東方ステーション(生放送)で数作品プレイすることになっていました。
そして前回は、残念ながらプレイされませんでした。
前回のnote内でも分析していますが、短い生放送の間プレイするゲームとして、100問出題されるクイズゲームは相性が最悪です。
これは他のゲームジャムにも言えることですが、大量のゲームが投稿されるゲームジャムで、とにかくプレイしてもらうには、以下の3つが必要です。

  1. ワンプレイの短さ

  2. ルールの明確さ

  3. 強烈なインパクト

また前のゲームジャムでは大変苦い経験をしました。
東方ゲームジャムの実況プレイされている方の動画を見ていたら、自分の「チルノのクイズ100本ノック!」の題名を見た所で「あー、クイズはいいわ」と一瞬でスルーされたことです。
当時はガクッと来ましたが、そう思われるのも当然ですし、この悔しさをバネにして、今回の東方ゲームジャム2024に臨むことができました。

東方ゲームジャム2024の結果

東方ステーション内で、ZUNさんとビートまりおさんにプレイしていただきました!

今回は前回の反省を活かし、ワンプレイを短く(約30秒)、ルールの明確に(使うのはADキーまたは左右キーのみ)、強烈なインパクトが残るゲーム(有名ゲームのパロディ)を心掛けて作りました。
それが『巫女レイムちゃん 妖怪退治は修行なのよの巻』です。

はい、ハットリくんのボーナスステージです!(鉄アレイもあるヨ)

元ネタの記事です。

ハットリくんの父親(ハットリ ジンゾウ)が、ちくわと鉄アレイをめちゃくちゃに投げてくるボーナスステージは度々ネタにされていますし、150万本売れたゲームなので、ZUNさんとビートまりおさんの世代的にどちらかは拾ってくれるだろうと思い、制作しました。
結果は両者に一瞬で拾っていただきました(笑)。

東方ゲームジャム2024投稿作品を遊ぼう!(DLは2024年10月31日まで!)

期限が決まっているので、とりあえずダウンロードだけでもしたほうがいいです!というかしてください!

『巫女レイムちゃん』の作り方

1.PlayerController

霊夢(プレイヤー)の移動を制御します。
得点計算と結果発表パネルの画像切り替えもやってしまっていますが、これは本来であればGameManager等で実装すべきでした。
霊夢に衝突するItemのタグによって、反応が変わる処理が入ってます。
GameEnd関数はタイムラインからのシグナルを受けて、実行します。

using System.Collections;
using UnityEngine;
using Animancer;
using TMPro;
using UnityEngine.UI;

public class PlayerController : MonoBehaviour
{
    [Header("Movement Settings")]
    public float moveSpeed = 5f;
    public float rightBoundary = 5f; // 右方向の移動範囲
    private float leftBoundary; // 左方向の移動範囲(右方向のマイナス)

    [Header("Animancer Settings")]
    public AnimancerComponent animancer;
    public AnimationClip walkRightAnimation;
    public AnimationClip walkLeftAnimation;
    public AnimationClip idleAnimation;
    public AnimationClip stunnedAnimation;

    [Header("Status")]
    public int score = 0;
    private bool isStunned = false; // 気絶状態フラグ
    private bool gameEnd = false; // ゲーム終了フラグ
    public float stunDuration = 1.5f; // 気絶時間

    private Rigidbody2D rb;
    private Vector2 movement;
    
    public AudioClip getSound;
    public AudioClip damageSound;
    
    public TextMeshProUGUI scoreText;
    public TextMeshProUGUI resultScoreText;
    public TextMeshProUGUI highScoreText;
    public TextMeshProUGUI serifuText;
    public Image kasenImage;
    
    public Sprite image0To99;     // 0-99用の画像
    public Sprite image100To399;  // 100-399用の画像
    public Sprite image400To799;  // 400-799用の画像
    public Sprite image800To1099; // 800-1099用の画像
    public Sprite image1100To1499; // 1100-1499用の画像
    public Sprite image1500AndAbove; // 1500以上用の画像
 
    void Start()
    {
        // ハイスコアを表示
        highScoreText.text = GameManager.i.GetHighScore().ToString("D6");
        //Time.timeScale = 1; // ゲームの時間を通常速度に戻す
        rb = GetComponent<Rigidbody2D>();
        leftBoundary = -rightBoundary; // 左方向は右方向のマイナス値
    }

    void Update() {
        if (!isStunned && !gameEnd)
        {
            HandleMovement();
        }
        else if(isStunned)
        {
            // 気絶中はアニメーションのみを再生し、移動はしない
            animancer.Play(stunnedAnimation);
        }else if (gameEnd) {
            rb.velocity = Vector2.zero; // 速度をゼロにして移動を止める
            animancer.Play(idleAnimation);
        }
    }

    void HandleMovement()
    {
        // 横方向の移動入力を取得
        float moveInput = Input.GetAxis("Horizontal");

        // 入力に応じてアニメーションを再生
        if (moveInput > 0) // 右方向移動
        {
            animancer.Play(walkRightAnimation);
        }
        else if (moveInput < 0) // 左方向移動
        {
            animancer.Play(walkLeftAnimation);
        }
        else // 無操作時は正面方向のアイドルアニメーション
        {
            animancer.Play(idleAnimation);
        }

        // 移動処理
        movement = new Vector2(moveInput * moveSpeed, rb.velocity.y);
        rb.velocity = movement;

        // プレイヤーのX位置を範囲内に制限
        float clampedX = Mathf.Clamp(transform.position.x, leftBoundary, rightBoundary);
        transform.position = new Vector2(clampedX, transform.position.y);
    }

    // 衝突判定
    void OnTriggerEnter2D(Collider2D other)
    {
        if (isStunned) return; // 気絶中は何も起こらない

        switch (other.gameObject.tag)
        {
            case "5yen":
                SoundManager.i.PlaySe(getSound);
                score += 5;
                Destroy(other.gameObject); // オブジェクトを消去
                scoreText.text = score.ToString("D6");
                break;
            case "10yen":
                SoundManager.i.PlaySe(getSound);
                score += 10;
                Destroy(other.gameObject);
                scoreText.text = score.ToString("D6");
                break;
            case "50yen":
                SoundManager.i.PlaySe(getSound);
                score += 50;
                Destroy(other.gameObject);
                scoreText.text = score.ToString("D6");
                break;
            case "100yen":
                SoundManager.i.PlaySe(getSound);
                score += 100;
                Destroy(other.gameObject);
                scoreText.text = score.ToString("D6");
                break;
            case "dumbbell":
                SoundManager.i.PlaySe(damageSound);
                StartCoroutine(StunPlayer()); // 気絶処理を開始
                Destroy(other.gameObject);
                break;
        }
    }

    // プレイヤーを一定時間操作不能にする気絶処理
    IEnumerator StunPlayer()
    {
        isStunned = true;
        
        // 気絶した瞬間に移動を停止する
        rb.velocity = Vector2.zero; // 速度をゼロにして移動を止める

        animancer.Play(stunnedAnimation); // 気絶アニメーション再生
        yield return new WaitForSeconds(stunDuration); // 一定時間待つ

        isStunned = false; // 操作を復帰
    }

    public void GameEnd()
    {
        Debug.Log("呼び出された");
        gameEnd = true;
        GameManager.i.SendScore(score);
        // スコアのカンマ区切り表示 + 円を付加
        resultScoreText.text = score.ToString("N0") + "円";
        
        // スコアに応じてserifuTextとkasenImageを変更
        if (score >= 0 && score <= 99)
        {
            serifuText.text = "霊夢、貴方ふざけているのかしら?\n 私の家で修行です!";
            kasenImage.sprite = image0To99;
        }
        else if (score >= 100 && score <= 399)
        {
            serifuText.text = "霊夢、調子悪いわね。\nどこか具合でも悪いのかしら?";
            kasenImage.sprite = image100To399;
        }
        else if (score >= 400 && score <= 799)
        {
            serifuText.text = "うーん、平凡ね。\nしゃきっとしなさい!";
            kasenImage.sprite = image400To799;
        }
        else if (score >= 800 && score <= 1099)
        {
            serifuText.text = "まずまずね。\n霊夢なら、もっとできるでしょう?";
            kasenImage.sprite = image800To1099;
        }
        else if (score >= 1100 && score <= 1499)
        {
            serifuText.text = "素晴らしいわ!\n流石、博麗の巫女ね!";
            kasenImage.sprite = image1100To1499;
        }
        else if (score >= 1500)
        {
            serifuText.text = "恐ろしい・・・。\n妖怪退治も程々にね・・・。";
            kasenImage.sprite = image1500AndAbove;
        }
    }
}

2.ItemSpawner

アイテムをランダムに生成します。

// アイテムの親フォルダを生成
itemFolder = new GameObject("ItemFolder");

//////////////中略//////////////////

// アイテムを生成し、指定された親オブジェクトの子として配置
GameObject item = Instantiate(selectedItem, spawnPosition, Quaternion.identity, itemFolder.transform);

上記のように記述することで、生成アイテムの親オブジェクトを指定することができます。これでヒエラルキーが生成されたアイテムでめちゃくちゃになることを防げますし、管理がしやすくなります。

using System.Collections;
using UnityEngine;
using System.Collections.Generic;
using Sirenix.OdinInspector;

public class ItemSpawner : MonoBehaviour
{
    [System.Serializable]
    public class SpawnableItem
    {
        public GameObject itemPrefab;
        [Range(0f, 1f)]
        public float spawnProbability;
    }

    [Title("Spawn Settings")]
    public List<SpawnableItem> spawnableItems;// スポーンするアイテムのリスト
    public float spawnInterval = 1f;// アイテムを生成する間隔
    public float forceUp = 5f;// アイテムに加える上向きの力
    public float horizontalForceRange = 2f; // 水平方向の力を制限
    public Vector2 screenBounds; // 画面の範囲を設定 (例えば、スクリーン幅や高さ)
    public GameObject itemFolder; // アイテムのスポーン先フォルダ(親オブジェクト)
    private float nextSpawnTime;// 次のアイテムを生成する時間

    void Start()
    {
        // アイテムの親フォルダが設定されていない場合、空のGameObjectを生成
        if (itemFolder == null)
        {
            // アイテムの親フォルダを生成
            itemFolder = new GameObject("ItemFolder");
        }
    }

    void Update()
    {
        // 次のアイテムを生成する時間になったら、アイテムを生成
        if (Time.time > nextSpawnTime)
        {
            // アイテムを生成
            SpawnItem();
            // 次のアイテム生成時間を設定
            nextSpawnTime = Time.time + spawnInterval;
        }
    }

    // アイテムを生成するメソッド
    void SpawnItem()
    {
        Vector2 spawnPosition = transform.position; // ItemSpawnerの位置に固定
        // ランダムにアイテムを選択
        GameObject selectedItem = SelectRandomItem();

        if (selectedItem != null)
        {
            // アイテムを生成し、指定された親オブジェクトの子として配置
            GameObject item = Instantiate(selectedItem, spawnPosition, Quaternion.identity, itemFolder.transform);
            Rigidbody2D rb = item.GetComponent<Rigidbody2D>();

            // X方向の力をランダムに制限し、Y方向に力を加える
            Vector2 force = new Vector2(Random.Range(-horizontalForceRange, horizontalForceRange), 1f) * forceUp;
            rb.AddForce(force, ForceMode2D.Impulse);

            // アイテムが画面の範囲内に収まるように制限
            StartCoroutine(KeepItemInBounds(rb));
        }
    }

    // アイテムをランダムに選択するメソッド
    GameObject SelectRandomItem()
    {
        float totalProbability = 0f;

        // 全体の確率の合計を計算
        foreach (var item in spawnableItems)
        {
            // 各アイテムの確率を合計
            totalProbability += item.spawnProbability;
        }

        // ランダムな値を取得して、確率に基づいてアイテムを選択
        float randomValue = Random.value * totalProbability;
        foreach (var item in spawnableItems)
        {
            if (randomValue < item.spawnProbability)
            {
                return item.itemPrefab;
            }
            randomValue -= item.spawnProbability;
        }

        return null; // 何も選ばれなかった場合はnull
    }

    // アイテムが画面外に出ないように位置を制限するコルーチン
    IEnumerator KeepItemInBounds(Rigidbody2D rb)
    {
        while (rb != null)
        {
            Vector2 position = rb.position;

            // 画面範囲内に位置を制限 (screenBoundsを基準に制限)
            position.x = Mathf.Clamp(position.x, -screenBounds.x, screenBounds.x);
            position.y = Mathf.Clamp(position.y, -screenBounds.y, screenBounds.y);

            rb.position = position;

            yield return null; // 毎フレーム処理
        }
    }

    // シーン遷移時にアイテムフォルダ内のすべてのアイテムを削除
    public void ClearItemsInFolder()
    {
        if (itemFolder != null)
        {
            foreach (Transform child in itemFolder.transform)
            {
                Destroy(child.gameObject);
                Time.timeScale = 1;
            }
        }
    }
}

TransformのY座標、Force UP、Horizontal Force Rangeを調節して、画面の左右にはみ出さないようにします。
一応、KeepItemInBoundsで画面端に当たると真下に落ちるように処理はされていますが、かなり不自然な動きになります。

Spawn Probabilityで生成確率を変えることができます。

3.Item

Item Spawnerで生成されるItemにアタッチされているコンポーネントです。
地面=Groundタグが付いているコライダーに衝突すると、Destroyされます。
またItem同士が空中でぶつからないように、レイヤーを「ItemLayer」にし、ItemLayer同志は衝突しないようにしています。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Item : MonoBehaviour
{
    void OnCollisionEnter2D(Collision2D collision)
    {
        if (collision.gameObject.CompareTag("Ground")||collision.gameObject.CompareTag("Player"))
        {
            Destroy(gameObject); // Groundに接触したらアイテムを消去
        }
    }
}
Z軸の回転を固定することで、鉄アレイがプロペラ状に回らなくなります。

4.GameManager

主にハイスコア保存のために使用しています。

using System;
using Sirenix.OdinInspector;
using UnityEngine;

public class GameManager : SerializedMonoBehaviour {
    public static GameManager i; //どこからでもアクセスできるようにする
    private SaveData saveData; //ゲームデータ

    void Awake() {
        CheckInstance();
    }

    void CheckInstance() {
        if (i == null) {
            i = this;
            DontDestroyOnLoad(gameObject); //シーン遷移しても破棄されない
        } else {
            Destroy(gameObject);
        }
    }

    private void Start() {
        LoadGameData();
    }

    // データを読み込む
    public void LoadGameData()
    {
        if (ES3.KeyExists("gameData"))
        {
            saveData = ES3.Load<SaveData>("gameData");
        }
        else
        {
            //セーブデータがない場合
            saveData = new SaveData();
            SaveGameData();
        }
    }

    // データを保存する
    public void SaveGameData(){
        ES3.Save("gameData", saveData);
    }
    
    public void SendScore(int amount) {
        ES3.Save("score", amount);
        if (GetHighScore()<amount){
            ES3.Save("highScore", amount);
        }
    }
    
    public int GetScore() {
        return ES3.Load<int>("score");
    }
    
    public int GetHighScore() {
        //ハイスコアを取得するメソッド
        //もしデータがなければ0を返す
        if (!ES3.KeyExists("highScore")) {
            return 0;
        }
        return ES3.Load<int>("highScore");
    }
}

5.TimeLine

今作においてはTimeLineがないと、実装がなかなか難しい場所が多かったです。TimeLineは以下のような流れです。

  1. ゲームスタート(Mainシーン再生)と同時に茨木華扇を上に移動させるアニメーションを再生し、ジングルを鳴らす。

  2. 茨木華扇のアニメーション再生が完了する。Item Spawnerをアクティブにする。BGMを再生する。霧雨魔理沙のアニメ―ションを再生する。

  3. ItemSpawnerを非アクティブにする。

  4. BGMをフェードアウトで終了し、霧雨魔理沙のアニメーション再生が完了する。

  5. PlayerControllerのGameEndを再生するシグナルを発生させる。

茨木華扇。階段の上に登場すると、ゲームが開始されます。
元ネタのハットリジンゾウと同じ動きをします。
霧雨魔理沙。画面右端から動き出し、左端に付くとゲーム終了です。
元ネタの時計と同じ動きをします。

これだけのことをタイミングよく、発生させるのをスクリプトで実行するのはなかなか難しいと思いますので、TimeLineを使ったことが無い人は是非導入してみましょう。

総評

とても良かったです。自分の中の東方熱も高まりました。
次の例大祭には是非参加したいですね。

この記事が気に入ったらサポートをしてみませんか?