Help us understand the problem. What is going on with this article?

弊学が台風で水没してアトランティス大学になったのでゲームを作った話

この記事はTokyo City University Advent Calendar 2019の5日目の記事です.

4日目はmikuta0407さんでした。

 

C言語の環境構築ってタイヘンナンダナー(棒)。自分もこの記事見ながら環境構築します()










台風襲来による弊学の被害

はい、幣学が台風で沈みました。東京アトランティス大学になっちゃいました。

この台風の影響で大学は2週間休校になりましたし、私たちの部活のメインイベントである学祭も中止(延期じゃないです、中止です)になりました。学祭の為に頑張って作ってたゲームもある意味無駄になっちゃいました。








東京アトランティス大学の台風時のネタについて

上記の通り学校が水没したため、2週間の秋休みが降ってきました。

以前から時事ネタをゲームにするのが趣味だったのもあり(というか2週間余りにも暇だったため)、ゲームの題材として使おうとなりました。ただこのご時世、下手に炎上させると面倒臭いのでみんなが不快にならないようなゲームを考えなければいけません。








ゲーム案提供者の登場

1週間何を作るか考え続けてましたが、特にゲームの案も浮かんでこないしこのままこの計画も無しにするしかないか...と嘆いてたところに救世主が現れました。荷札君です。彼が「土嚢をネタにすればいいんじゃないの?」と提案してくれたお陰でゲーム案が浮かんできました。


そういえばまだ東京アトランティス大学が台風の日にどんな状態だったか書いてませんでしたね。何とこの大学、あれだけ生徒に台風前日と当日にメールで「危険なので学校に立ち入り禁止です」と送ってたのにも関わらず、水没対策全然してなかったんですよね。流石Fラ

具体的に言うとこの大学、台風当日に 土嚢置いてませんでした。

しかもそれだけじゃなく、台風の次の日の朝に大学を確認しに行ったら何故か 土嚢が置いてありました。 完全に隠蔽です本当にありがとうございました



彼はこれをネタにすればいいのでは?と提案してきました。本当に良いネタを思い付いてくれたよありがと。








いざ制作

時事ネタをゲームにする時に一番重要な事は、「時期を逃さない事」です。 という事で制作開始から3時間で制作しました。クオリティはお察しになりました(言い訳)。開発環境はOpenSiv3D v0.4.2、言語はC++です。大事な事を言い忘れてました、タイトルは『追い土嚢ゲーム』です。

ゲームシーンのソースコード

ゲームシーンで登場する通行人のクラスのコードを下に示します。

/// <summary>
/// 通行人クラス
/// </summary>
class Passerby : public Triangle {
private:
    /// <summary>
    /// 精神状態
    /// </summary>
    enum class State {
        None,
        Angry
    };

    /// <summary>
    /// 現在の精神状態
    /// </summary>
    State m_state = State::None;

    /// <summary>
    /// y軸の方向
    /// </summary>
    const double moveYDir = Random (-1.0, 1.0);

public:
    explicit Passerby (const Vec2& initPos) noexcept
        : Triangle (initPos, initPos.movedBy (-450.0, 50.0), initPos.movedBy (-450.0, -50.0)) {}

    /// <summary>
    /// 怒ってるか判定
    /// </summary>
    /// <returns>現在の精神状態がAngryか</returns>
    [[nodiscard]] bool isAngry() const noexcept {
        return m_state == State::Angry;
    }

    constexpr double getYDir() const noexcept {
        return moveYDir;
    }

    /// <summary>
    /// 精神状態を怒りにする
    /// </summary>
    void changeStateAngry() noexcept {
        m_state = State::Angry;
    }
};

継承してるTriangleクラスはOpenSiv3Dに標準搭載されてるクラスで、名前の通り三角形を描画したり当たり判定ができます。通行人に目線が必要だったので目線を三角形で実装しています。

次に、ゲームシーンで呼び出されるクラスを下に示しますが、長いので畳んでおきます。

ゲームシーンクラス
/// <summary>
/// ゲームシーンで呼び出されるクラス
/// </summary>
class Game : public MyApp::Scene {
private:
    /// <summary>
    /// スタートのカウントを数えるタイマー
    /// </summary>
    Stopwatch m_countDown;
    /// <summary>
    /// ゲームがスタートしてからのタイマー
    /// </summary>
    Stopwatch m_gameTimer;
    /// <summary>
    /// 男を作り出すタイミング
    /// </summary>
    Stopwatch m_createMan;
    /// <summary>
    /// 女を作り出すタイミング
    /// </summary>
    Stopwatch m_createWoman;

    /// <summary>
    /// 作り出した男を格納しておく
    /// </summary>
    Array<Human> m_men;
    /// <summary>
    /// 作り出した女を格納しておく
    /// </summary>
    Array<Human> m_women;

    /// <summary>
    /// 土嚢の当たり判定や、
    /// そもそも土嚢を持ってるかの情報を持ってる
    /// </summary>
    Optional<RectF> m_donou;

    /// <summary>
    /// 土嚢を作り出す場所
    /// </summary>
    RectF m_chargeDonou;
    /// <summary>
    /// 持ってる土嚢を置く場所
    /// </summary>
    RectF m_putDonou;
    /// <summary>
    /// 置いた土嚢が学校の入り口に近いと点数が上がるシステムなため、
    /// 学校の玄関の位置の情報が必要
    /// </summary>
    Vec2 m_scoreSphere = Vec2 (1140.0, 530.0);

    /// <summary>
    /// 置いた土嚢の位置を格納しておく
    /// </summary>
    Array<Vec2> m_putDonous;

    /// <summary>
    /// ゲームの終了時間
    /// </summary>
    static constexpr int32 gameTimeMillisec = 20 * 1000;

    /// <summary>
    /// 現在のスコア
    /// </summary>
    int32 m_score = 0;

    [[nodiscard]] bool onCountDown() const noexcept {
        return m_countDown.isRunning() && m_countDown < 4000ms;
    }
    [[nodiscard]] bool onGame() const noexcept {
        return m_gameTimer.isRunning();
    }
public:

    Game (const InitData& init)
        : IScene (init)
        , m_chargeDonou (Arg::center (50.0, Scene::CenterF().movedBy (0.0, 120.0).y), Vec2 (200.0, 500.0))
        , m_putDonou (Arg::center (1230.0, Scene::CenterF().movedBy (0.0, 120.0).y), Vec2 (300.0, 500.0)) {
            m_countDown.start();
    }

    void update() override {
        /// <summary>
        /// ゲームスタートの処理
        /// </summary>
        if (!onGame() && m_countDown >= 3000ms) {
            m_gameTimer.start();
            m_createMan.start();
            m_createWoman.start();
            m_countDown.reset();
            Vec2 initPos (1400.0, Scene::CenterF().y + Random (-100.0, 200.0));
            m_men.emplace_back (initPos);
            initPos = Vec2 (1400.0, Scene::CenterF().y + Random (-100.0, 200.0));
            m_women.emplace_back (initPos);
        }
        /// <summary>
        /// ゲームを開始してない場合、早期リターン
        /// </summary>
        if (!onGame()) {
            return;
        }
        /// <summary>
        /// ゲームが終了した場合、スコアをGameDataに渡し、
        /// シーンをリザルトシーンに移動
        /// </summary>
        if (m_gameTimer.ms() >= gameTimeMillisec) {
            getData().score = m_score;
            changeScene (State::Result, 2000);
        }
        /// <summary>
        /// 人間の生成
        /// </summary>
        if (m_createMan >= 1600ms) {
            m_createMan.restart();
            Vec2 initPos (1400.0, Scene::CenterF().y + Random (-100.0, 200.0));
            m_men.emplace_back (initPos);
        }
        if (m_createWoman >= 2000ms) {
            m_createWoman.restart();
            Vec2 initPos (1400.0, Scene::CenterF().y + Random (-100.0, 200.0));
            m_women.emplace_back (initPos);
        }
        /// <summary>
        /// 男の処理
        /// </summary>
        for (auto& man : m_men) {
            man.moveBy (-5.0, man.getYDir());  
            /// <summary>
            /// プレイヤーが土嚢を持ってない場合は早期リターン
            /// </summary>
            if (!m_donou.has_value()) {
                continue;
            }
            /// <summary>
            /// プレイヤーの土嚢と接触した場合、
            /// スコアが減り、怒り状態になる。
            /// また、土嚢も消える。
            /// </summary>
            if (man.intersects (m_donou.value())) {
                man.changeStateAngry();
                m_score -= Random (2500, 5000);
                m_donou.reset();
            }
        }
        /// <summary>
        /// 女の処理
        /// </summary>
        for (auto& woman : m_women) {
            woman.moveBy (-5.0, woman.getYDir());  
            /// <summary>
            /// プレイヤーが土嚢を持ってない場合は早期リターン
            /// </summary>
            if (!m_donou.has_value()) {
                continue;
            }
            /// <summary>
            /// プレイヤーの土嚢と接触した場合、
            /// スコアが減り、怒り状態になる。
            /// また、土嚢も消える。
            if (woman.intersects (m_donou.value())) {
                woman.changeStateAngry();
                m_score -= Random (2500, 5000);;
                m_donou.reset();
            }
        }
        /// <summary>
        /// 土嚢を持ってない場合の処理
        /// </summary>
        if (!m_donou.has_value()) {
            if (m_chargeDonou.leftClicked()) {
                m_donou.emplace (Arg::center (Cursor::PosF()), 100, 100);
            }
            return;
        }
        /// <summary>
        /// 土嚢をカーソルの位置に設定する。
        /// ただしズルは許さないため、y座標の最小値最大値を設定している。
        /// </summary>
        m_donou.value().setCenter (Cursor::PosF().x, Clamp (Cursor::PosF().y, 250.0, 650.0));
        /// <summary>
        /// 学校前の四角形の範囲のところをクリックした場合、
        /// 土嚢をクリックした場所に置きスコアを更新する。
        /// </summary>
        if (m_putDonou.leftClicked()) {
            m_putDonous.emplace_back (Cursor::PosF());
            int32 score = static_cast<int32>(20000 - m_scoreSphere.distanceFrom (Cursor::PosF()) * 50);
            m_score += Max (score, 0);
            m_donou.reset();
        }
    }

    void draw() const override {
        /// <summary>
        /// カウントダウン中の処理
        /// </summary>
        if (onCountDown()) {
            const int32 timeMillisec = Max ((3999 - m_countDown.ms()), 0);
            const int32 countDown = timeMillisec / 1000;
            const double e = EaseIn (Easing::Expo, (timeMillisec % 1000) / 1000.0);
            TextureAsset (U"River").resized (1400, 830).drawAt (Scene::CenterF());
            TextureAsset (U"Schoolkun").resized (400.0, 300.0).drawAt (m_putDonou.center().movedBy (-100.0, -100.0));
            if (countDown > 0) {
                Transformer2D t (Mat3x2::Scale (1.0 + e * 2, Scene::Center()));
                FontAsset (U"CountDown")(countDown).drawAt (Scene::Center(), Palette::Black);
            }
            else {
                Transformer2D t (Mat3x2::Scale (1.0 + (1.0 - e) * 2, Scene::Center()));
                FontAsset (U"CountDown")(U"START").drawAt (Scene::Center(), Palette::Black);
            }
        }
        /// <summary>
        /// ゲーム中じゃない場合、早期リターン
        /// </summary>
        if (!onGame()) {
            return;
        }
        /// <summary>
        /// 背景の描画
        /// </summary>
        TextureAsset (U"River").resized (1400, 830).drawAt (Scene::CenterF());
        m_putDonou.draw (ColorF (Palette::Yellow, 0.5f));
        TextureAsset (U"Schoolkun").resized (400.0, 300.0).drawAt (m_putDonou.center().movedBy (-100.0, -100.0));
        m_chargeDonou.draw (ColorF (Palette::Red, 0.5f));
        /// <summary>
        /// 置いた土嚢の描画処理
        /// </summary>
        for (auto& donouPos : m_putDonous) {
            TextureAsset (U"Donou").resized (50, 100).drawAt (donouPos);
        }
        /// <summary>
        /// 男の描画処理
        /// </summary>
        for (auto& man : m_men) {
            /// <summary>
            /// 精神状態によって描画するテクスチャを変更する
            /// </summary>
            if (!man.isAngry()) {
                TextureAsset (U"ManSchool").resized (180, 300).drawAt (man.p0.movedBy (0, 85));
            }
            else {
                TextureAsset (U"ManIkari").resized (180, 300).drawAt (man.p0.movedBy (0, 85));
            }
            /// <summary>
            /// 男の目線の描画
            /// 半透明にしてる
            /// </summary>
            man.draw (ColorF (Palette::Red, 0.2f));
        }
        /// <summary>
        /// 女の描画処理
        /// </summary>
        for (auto& woman : m_women) {
            /// <summary>
            /// 精神状態によって描画するテクスチャを変更する
            /// </summary>
            if (!woman.isAngry()) {
                TextureAsset (U"WomanSchool").resized (180, 300).drawAt (woman.p0.movedBy (0, 85));
            }
            else {
                TextureAsset (U"WomanIkari").resized (180, 300).drawAt (woman.p0.movedBy (0, 85));
            }
            /// <summary>
            /// 女の目線の描画
            /// 半透明にしてる
            /// </summary>
            woman.draw (ColorF (Palette::Red, 0.2f));
        }
        /// <summary>
        /// 土嚢をプレイヤーが持ってる場合の描画処理
        /// </summary>
        if (m_donou.has_value()) {
            TextureAsset (U"Donou").resized (100, 100).drawAt (m_donou.value().center());
        }
        /// <summary>
        /// 残り時間の描画
        /// </summary>
        const int32 timeLeftMillisec = Max (gameTimeMillisec - m_gameTimer.ms(), 0);
        FontAsset (U"GameTime")(U"生徒が来るまで: {:0>2}'{:0>2} 時間"_fmt (timeLeftMillisec / 1000, timeLeftMillisec % 1000 / 10)).draw (60, 60, Palette::Black);
        /// <summary>
        /// スコアの描画
        /// </summary>
        FontAsset (U"GameTime")(U"スコア: {0}"_fmt (m_score)).draw (800, 60, Palette::Black);
    }
};

このGameクラスのupdateメンバ関数は、マイフレーム呼び出されます。UnityのUpdate()みたいな感じです。drawメンバ関数はupdateメンバ関数の後に呼び出されます。ちなみにconstメンバ関数なのでdraw()でメンバ変数等を変更できません。

リザルトシーンのソースコード

次にリザルトシーンで使う風船のクラスを下に示します。

/// <summary>
/// 風船クラス
/// Tw○tterの誕生日の時みたいに飛ばす
/// </summary>
class Balloon {
private:
    /// <summary>
    /// 風船のテクスチャ
    /// 色は別の関数で変更する
    /// </summary>
    Texture m_texture;
    /// <summary>
    /// 左右に動くが、周期的に動くのでその周期
    /// </summary>
    Duration m_cycleMove;
    /// <summary>
    /// 風船が上に行く速さ
    /// </summary>
    double m_speed;
    /// <summary>
    /// 風船の位置
    /// </summary>
    Vec2 m_position;
public:
    /// <summary>
    /// デフォルトコンストラクタを禁止する
    /// </summary>
    Balloon() = delete;
    /// <summary>
    /// コンストラクタ
    /// </summary>
    /// <param name="texture_">風船のテクスチャ</param>
    /// <param name="cycle_">左右に動く際の周期</param>
    /// <param name="pos_">初期位置</param>
    /// <param name="speed_">上昇の速さ</param>
    /// <returns></returns>
    Balloon (const Texture& texture_, Duration cycle_, const Vec2& pos_, const double speed_) noexcept
        : m_texture (texture_)
        , m_cycleMove (cycle_)
        , m_position (pos_)
        , m_speed (speed_) {}
    /// <summary>
    /// 風船を動かす
    /// </summary>
    /// <returns></returns>
    void move() noexcept {
        m_position.y -= m_speed;
    }
    /// <summary>
    /// 風船の描画
    /// 風船の持つところに土嚢が付いてる
    /// 左右を周期的に動かしてる
    /// </summary>
    void drawBalloon() const {
        m_texture.draw (m_position.x + 20 * Periodic::Triangle0_1 (m_cycleMove), m_position.y);
        TextureAsset (U"Donou").resized (100, 100).draw (
            m_position.x + 20 * Periodic::Triangle0_1 (m_cycleMove) - 50
            , m_position.y + 100
        );
    }
};

上記の通り、Twitterの誕生日に飛ぶ風船を実装しています。作りたかったから作りました。

次にリザルトシーンで呼び出されるクラスを下に示しますが、長いので畳んでおきます。

リザルトシーンクラス
/// <summary>
/// リザルトクラス
/// </summary>
class Result : public MyApp::Scene {
private:
    /// <summary>
    /// 風船を格納する
    /// </summary>
    Array<Balloon> m_balloons;
public:

    Result (const InitData& init)
        : IScene (init) {
            /// <summary>
            /// 風船を30個作る
            /// x座標をランダムにする
            /// </summary>
            for (auto i : step (30)) {
                m_balloons.emplace_back (Texture (CreateBalloon (12 * i)), Duration (Random (0.8, 1.5))
                    , Vec2 (Random (0, Scene::Size().x), 750), Random (1.0, 4.0));
            }
    }

    void update() override {
        if ((MouseL | KeyEscape).down()) {
            changeScene (State::Title);
            return;
        }

        for (auto& balloon : m_balloons) {
            balloon.Move();
        }
    }

    void draw() const override {
        /// <summary>
        /// 背景の描画
        /// </summary>
        TextureAsset (U"River").resized (1400, 830).drawAt (Scene::CenterF());
        TextureAsset (U"Schoolkun").resized (400.f, 300.f).drawAt (Vec2 (1230.f, Scene::CenterF().movedBy (0, 120).y).movedBy (-100.f, -100.f));
        /// <summary>
        /// 飛ばしてる風船の描画
        /// </summary>
        for (auto& balloon : m_balloons) {
            balloon.drawBalloon();
        }
        const double resultHeight = FontAsset (U"Result")(U"スコア:", getData().score).region().h;
        FontAsset (U"Result")(U"スコア").draw (Scene::CenterF().x - 100, Scene::Height() * 0.4 - resultHeight / 2, Palette::Black);
        FontAsset (U"Result")(U":", getData().score).draw (Scene::CenterF().x + 200, Scene::Height() * 0.4 - resultHeight / 2, Palette::Black);
        /// <summary>
        /// スコアに応じて文字を変更
        /// </summary>
        if (getData().score <= 0) {
            FontAsset (U"ResultMonku")(U"え!?何で土のう").draw (Scene::CenterF().x - 450, Scene::Height() * 0.3 + resultHeight * 1.5, Palette::Red);
            FontAsset (U"ResultMonku")(U"置いてないの???").draw (Scene::CenterF().x - 450, Scene::Height() * 0.3 + resultHeight * 3.5, Palette::Red);
            return;
        }
        FontAsset (U"ResultMonku")(U"夜は土のう無かったのに").draw (Scene::CenterF().x - 550, Scene::Height() * 0.3 + resultHeight * 1.5, Palette::Red);
        FontAsset (U"ResultMonku")(U"何であるんですか!?!?").draw (Scene::CenterF().x - 450, Scene::Height() * 0.3 + resultHeight * 3.5, Palette::Red);
    }
};

自作関数のソースコード

上記で風船を作る際にCreateBalloon関数を使っています。こちらは自作の関数です。下にCreateBalloon関数を示します。

/// <summary>
/// 風船をHSVを指定したもので返す
/// </summary>
/// <param name="hue">色相</param>
/// <returns>風船のImage</returns>
Image CreateBalloon (double hue) {
Image image (Emoji::CreateImage (U"🎈"));
    for (auto& pixel : image) {
        HSV hsv = pixel;

        hsv.h = hue;

        pixel = hsv;
    }

    return image;
}

この関数の説明として、元々OpenSiv3Dに風船の絵文字をテクスチャに変換する関数が提供されていますが、色を変更する事ができませんでした。そこで、生成したテクスチャの色相を変更する事で色を変更しています。関数の引数は色相の値です。色相は値の範囲が0.0〜360.0です(370.0は10.0と同じ)。これにより、全ての風船が違う色になります。やったね

追い土嚢ゲームの公開について

全てのコードは私のGitHubに公開していますので気になった方はどうぞ。
ちなみにCommon.hppにその他の自作関数等を載せてあります。

また、コットン(このアドベントカレンダーの17日に参加してます)に今まで作ったゲームを公開してるページを作成してもらってます。このページに追い土嚢ゲームも公開してます。macとWindows両方対応していますので気になる方は是非。マジでありがとうコットン。

Twitterでの反応

作ったゲームをTwitterに公開した結果が以下になります。

 

投稿直後にバイトをしてたのですが、終わった後にTwitterを確認したら想像の10倍いいねやRTが来てました。ありがとうございます。








終わりに

今後も炎上しないように注意しつつ時事ネタを題材としたゲームを作成していきたいと思ってます。

次回はいちたくさんです。vimについての記事らしいのでとても楽しみです。私はVScode派です対戦よろしくお願いします。

makia
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした