C++OpenSiv3D入門講座 Vol. 10 基底・派生クラス・仮想関数・ポリモーフィズム

C++OpenSiv3D入門講座

基底クラスと派生クラス

クラスを継承することで、既存のクラスの要素を引き継いだ新しいクラスを作成することができる。

継承元のクラスを基底クラス、継承先のクラスを派生クラスという。

派生クラスは基底クラスのメンバを持つ。

下の例の場合DerivedクラスがBaseクラスを継承している。

#include <iostream>

class Base {
public:
    Base(int _x) :
        x{ _x }
    {
    }
    int x;
    void show() {
        std::cout << "x:" << x << std::endl;
    }
};

class Derived : public Base {
public:
    Derived(int _x) :
        Base{ _x }
    {
    }
};


int main() {

    Base obj_b{ 12 };
    obj_b.show();

    Derived obj_d{ 23 };
    obj_d.show();

    return 0;
}


書き方

class 派生クラス名 : アクセス 基底クラス名{
}

アクセスがpublicだと基本クラスのpublicメンバが派生クラスのpublicメンバに、privateだと基本クラスのpublicメンバが派生クラスのprivateメンバになる。 派生クラスから基底クラスのコンストラクタを呼ぶときは、コンポジションの時と同様にメンバイニシャライザで呼べばよい。

class 派生クラス名 : アクセス 基底クラス名{
public:
    派生クラス名(引数1,引数2, ...):
        基底クラス名(引数1,引数2, ...)
    {
    }
};

基底クラスへのポインタと参照

基底クラスへのポインタ/参照には、派生クラスのポインタ/参照を入れることが出来る。

#include <iostream>

class Base {
public:
    int x;

    Base(int _x) :
        x{ _x }
    {
    }

    void show() {
        std::cout << "show関数が呼ばれました。 xは:" << x << std::endl;
    }
};

class Derived : public Base {
public:
    Derived(int _x) : 
        Base{ _x }
    {
    }

};

int main() {

    Base obj_b{ 100 };
    Derived obj_d{ 200 };

    obj_b.show();
    obj_d.show();

    std::cout << std::endl;

    Base* ptr1 = &obj_b;
    Base* ptr2 = &obj_d; //基底クラスへのポインタに派生クラスのポインタを入れられる

    ptr1->show();
    ptr2->show();

    std::cout << std::endl;

    Base& ref1 = obj_b;
    Base& ref2 = obj_d; //基底クラスの参照に派生クラスの参照を入れられる

    ref1.show();
    ref2.show();

    std::cout << std::endl;

    return 0;
}

仮想関数

基底クラスでvirtualをつけ仮想関数を作り、派生クラスでその関数をoverrideすることで、派生クラスを指している基底クラスへのポインタから、派生クラスでoverrideされた関数を呼び出すことが出来るようになる。 以下は、show関数をオーバーライドしている例。メンバ関数の前にvirtualをつけることで仮想関数を定義できる。また、派生クラスでは、override指定子をつけてオーバーライドを明示しながらshow関数をオーバーライドしている。

#include <iostream>

class Base {
public:
    int x;

    Base(int _x) :
        x{ _x }
    {

    }

    virtual void show() {
        std::cout << "基底クラスのshow関数が呼ばれました。 xは:" << x << std::endl;
    }
};

class Derived : public Base {
public:
    Derived(int _x) :
        Base{ _x }
    {
    }

    void show() override {
        std::cout << "派生クラスのshow関数が呼ばれました。 xは:" << x << std::endl;
    }
};

int main() {

    Base obj_b{ 100 };
    Derived obj_d{ 200 };

    obj_b.show();
    obj_d.show();

    std::cout << std::endl;

    Base* ptr1 = &obj_b;
    Base* ptr2 = &obj_d; // ptr2は、派生クラスを指している基底クラスへのポインタ

    ptr1->show();
    ptr2->show();

    std::cout << std::endl;

    return 0;
}

継承によるポリモーフィズム

例えばシューティングで敵A,敵B,敵Cを作りたいとする。もし、敵A,敵B,敵Cの配列をそれぞれ作って管理しようとすると大変だし、敵が一種類増えるたびに新しい配列を作らなければならない。 下のようにEnemyの基底クラスIEnemyを定義し、それを継承すれば、派生クラスを一つの配列で扱うことが可能になる。

このように、異なる型の実体を同一の型として扱えるようなインターフェースを提供することをポリモーフィズムという。

#include <iostream>
#include <vector>

class IEnemy {
public:
    int x;

    //コンストラクタ
    IEnemy(int _x) :
        x{ _x }
    {
    }

    virtual void show() {
        std::cout << "基底クラスのshow関数が呼ばれました。 xは:" << x << std::endl;
    }
};

class EnemyA : public IEnemy {
public:

    EnemyA(int _x) :
        IEnemy{ _x }
    {
    }

    void show() override {
        std::cout << "EnemyAクラスのshow関数が呼ばれました。 xは:" << x << std::endl;
    }
};


class EnemyB : public IEnemy {
public:

    EnemyB(int _x) :
        IEnemy{ _x }
    {
    }

    void show() override {
        std::cout << "EnemyBクラスのshow関数が呼ばれました。 xは:" << x << std::endl;
    }
};


class EnemyC : public IEnemy {
public:

    EnemyC(int _x) :
        IEnemy{ _x }
    {
    }

    void show() override {
        std::cout << "EnemyCクラスのshow関数が呼ばれました。 xは:" << x << std::endl;
    }
};



int main() {
    std::vector<IEnemy*> vec;

    vec.emplace_back(new EnemyA{ 100 });
    vec.emplace_back(new EnemyB{ 200 });
    vec.emplace_back(new EnemyC{ 300 });

    //違うクラスなのに、同じ配列で扱えた!!!!!
    for (const auto& enemy : vec) {
        enemy->show();
    }

    return 0;
}

純粋仮想関数

純粋仮想関数を作ると、そのクラスはインスタンス化出来なくなる。上の例では敵A,B,Cの基底クラスにIEnemyが定義されているが、このようなクラスをインターフェイスを定義するクラスという。純粋仮想関数はインターフェイスを定義するクラスを作るときに使い、誤ってインターフェイスを定義するクラスを生成してしまうのを防げる。

#include <iostream>

class Base {
public:
    double x;

    Base(int _x) :
        x{ _x }
    {
    }

    virtual void show() = 0; //純粋仮想関数
};

class Derived : public Base {
public:
    Derived(int _x) :
        Base{ _x }
    {
    }

    void show() {
        std::cout << "派生クラスのshowが呼ばれました。 xは:" << x << std::endl;
    }
};

int main() {
    Base* ptr1 = new Derived{ 100 };
    ptr1->show();

    //コメントを外すと純粋仮想関数をもつクラスをインスタンス化してしまうのでコンパイルエラー
    //Base b{ 100 };
    //Base *ptr2 = new Base{ 100 };

    return 0;
}

純粋仮想関数の書き方

virtual 返り値の型 関数名() = 0;
例:virtual void show() = 0;

基底クラスの仮想デストラク

以下の例では、Baseを継承したDerivedクラスを作り、Derivedをnewした後、そのポインタをBaseへのポインタに入れ、その後deleteしている。また、基底クラスBaseで仮想デストラクタを定義、派生クラスDerivedでデストラクタをオーバーライドし定義している。 DerivedクラスをさすBaseクラスのポインタを解放した際、Baseクラスに仮想デストラクタがないと、Baseクラスとしての領域のみ解放されてしまい、Derivedクラスの領域が解放されない。よって、ポリモーフィズムのための基底クラスには仮想デストラクタの宣言が必要である。
基底クラスで仮想デストラクタを宣言する例

#include <iostream>

class Base {
public:
    int x;

    Base(int _x) :
        x{ _x }
    {
    }

    // 仮想デストラクタを定義
    virtual ~Base() {
        std::cout << "仮想デストラクタ~Baseが呼ばれました。" << std::endl;
    };
};

class Derived : public Base {
public:
    Derived(int _x) :
        Base{ _x }
    {
    }

    // デストラクタをオーバーライドし定義
    ~Derived() {
        std::cout << "デストラクタ~Derivedが呼ばれました。" << std::endl;
    };
};


int main() {

    Base* ptr = new Derived{ 10 };
    delete ptr;

    return 0;
}

デストラクタの中身が特にない場合は、以下のようにdefaultをつけてデストラクタの中身の生成をコンパイラに任せることができる。また、派生クラスのデストラクタは自動生成されるもので問題ないので、書かなくてもよい。

#include <iostream>

class Base {
public:
    int x;

    Base(int _x) :
        x{ _x }
    {
    }

    // 仮想デストラクタを定義
    virtual ~Base() = default;
};

class Derived : public Base {
public:
    Derived(int _x) :
        Base{ _x }
    {
    }

    // デストラクタをオーバーライドし定義
    ~Derived() = default;
};


int main() {

    Base* ptr = new Derived{ 10 };
    delete ptr;

    return 0;
}

Tips:override指定子

実は、override指定子がなくてもオーバーライドすることはできる。しかし、override指定子を用いてオーバーライドを明示することで、コンパイラにoverrideしていることを明示し、ミスを防ぐことができる。 以下は、show関数を派生クラスでオーバーライドするつもりだったが、showaとミスタイプしてしまった例。override指定子を用いてoverrideを明示しているが、showaという名前の仮想関数はないので、コンパイルしようとするとコンパイルエラーになり、オーバーライドしそびれていることに早い段階で気づけるようになっている。

// このソースコードはコンパイル不可
#include <iostream>

class Base {
public:
    virtual void show() {
        std::cout << "基底クラスのshow関数が呼ばれました。" << std::endl;
    }
};

class Derived : public Base {
public:
    // 本当はshowとタイプするつもりだったが、showaとミスタイプしてしまった例。コンパイルできない。
    void showa() override {
        std::cout << "派生クラスのshow関数が呼ばれました。" << std::endl;
    }
};

int main() {
    Base base;
    Derived derived;

    base.show();
    derived.show();

    return 0;
}

Tips:宣言と定義を分ける場合のoverrideの書き方

以下の例はDerivedクラス内でshow関数をオーバーライドした例。override指定子はクラス内の関数宣言(.hのほう)にはつけるが、関数定義(cppのほう)にはつけない。

class Derived : public IBase {
public:
    void show() override;
};
void Derived::show() {
    std::cout << "派生クラスのshow関数が呼ばれました。" << std::endl;
}

まとめ

  • クラスを継承することで、既存のクラスの要素を引き継いだ新しいクラスを作成することができる
  • 継承元のクラスを基底クラス、継承先のクラスを派生クラスという
  • 基底クラスへのポインタ/参照には、派生クラスのポインタ/参照を入れることが出来る
  • 基底クラスでvirtualをつけ仮想関数を作り、派生クラスでその関数をoverrideすることで、基底クラスへのポインタから、派生クラスでoverrideされた関数を呼び出すことが出来るようになる
  • 異なる型の実体を同一の型と同じように扱えるようなインターフェースを提供することをポリモーフィズムという
  • 純粋仮想関数を作ると、誤ってインターフェイスを定義するクラスを生成してしまうのを防げる
  • 基底クラスには仮想デストラクタを作成しよう
  • override指定子は省略できるが、ミスを防ぐために書こう
  • override指定子はクラス内の関数宣言(.hのほう)にのみつければ良い。

演習問題(コンソール)

  1. 以下の様なクラスを作った。Dog,Catを参考に、IAnimalクラスを継承したクラスをもう1つ作れ。
    # include<iostream>
    class IAnimal {
    public:
        double weight;//重さ
        IAnimal(double w) :
            weight{ w }
        {
        }
        virtual void talk() {
            std::cout << "基底クラスのtalk関数が呼ばれました。 重さは:" << weight << std::endl;
        }
        virtual ~IAnimal() = default;
    };
    class Dog : public IAnimal {
    public:
        Dog(double w) :
            IAnimal{ w }
        {
        }
        void talk() override {
            std::cout << "わんわん 重さは:" << weight << std::endl;
        }
    };
    class Cat : public IAnimal {
    public:
        Cat(double w) :
            IAnimal{ w }
        {
        }
        void talk() override {
            std::cout << "ネコチャン 重さは:" << weight << std::endl;
        }
    };
    int main() {
    }
    
  2. IAnimal型へのポインタを要素に持つvectorにnewで動的確保したIAnimalの派生クラスへのポインタを適当に複数入れた後、すべてのインスタンスtalk関数を呼び出せ。
  3. IAnimalのtalk関数を純粋仮想関数にせよ。

演習問題(OpenSiv3D)

  1. vol.8の演習問題(OpenSiv3D)のプログラムを書き換え、継承によるポリモーフィズムを用いて、数種類の敵を作りなおせ。

演習問題の拡張

これまでやったことを用いて、演習問題(OpenSiv3D)のプログラムを拡張しよう。


拡張例

  • プレイヤーが弾を撃てるようにする
  • 敵が弾を撃ってくるようにする
  • プレイヤーの弾で敵が倒れ、敵の弾でプレイヤーが倒れるようにする
  • 倒れた時にエフェクトを出す
  • スコアを表示する


また、拡張例のようなものを作りたい場合のクラス構成の例を、以下に示す。

クラス構成の例
GameManagerクラス(他のクラスを所持し、管理する)
|- Playerクラス
|- PlayerBulletManagerクラス(PlayerBulletクラスのvectorを所持)
|- EnemyManagerクラス(Enemyクラスのvectorを所持)
|- EnemyBulletManagerクラス(EnemyBulletクラスのvectorを所持)
|- EffectManagerクラス(Effectクラスのvectorを所持)
|- ScoreManagerクラス(スコアを管理するクラス)

当たり判定やエフェクト等については、OpenSiv3Dで便利な機能が用意されている。OpenSiv3DのDocumentを見てみよう。

zenn.dev

Documentの例はスマートな書き方をしていてC/C++初級者には難しいものもあるかもしれない。分からなくなったら是非周りの人に聞いてみたり、勉強と思って頑張って調べながら読んでみたりしよう。

C++OpenSiv3D入門講座

C++OpenSiv3D入門講座 Vol. 09 new・delete・スマートポインタ

C++OpenSiv3D入門講座

newとdelete

newを使うと、プログラムの実行中にメモリを確保し、オブジェクトを生成できる。 newすると生成したオブジェクトへのポインタが返ってくる。生成したオブジェクトを見失わないように、ポインタを格納する変数を用意する必要がある。

#include <iostream>

class MyClass{
public:
    int x;

    MyClass(int _x):
    x{ _x }
    {
    }

};

int main(){

    MyClass* ptr = new MyClass{ 32 };

    std::cout << "MyClassのxの値 : " << ptr->x << std::endl;

    return 0;
}

deleteを使うと、プログラム実行中にnewで確保された領域を開放することが出来る。 deleteには開放する領域のポインタを渡す。 下の例では、xの値を表示した後deleteするので、メイン関数を抜ける前にデストラクタが呼ばれる。 deleteされた後にptrのxの値を表示するとカオスな値が入っている(動作未定義)。見てみよう。 deleteしなかったらデストラクタがいつ呼ばれるか確認してみよう。

#include <iostream>

class MyClass{
public:
    int x;

    MyClass(int _x):
    x{ _x }
    {
        std::cout << "コンストラクタが呼ばれました" << std::endl;
    }

    ~MyClass(){
        std::cout << "デストラクタが呼ばれました" << std::endl;
    }
};

int main(){
    std::cout << "メイン関数に入りました" << std::endl;

    MyClass* ptr = new MyClass{ 12 };

    std::cout << "MyClassのxの値 : " << ptr->x << std::endl;

    delete ptr;

    std::cout << "メイン関数を抜けました" << std::endl;

    return 0;
}

スマートポインタstd::shared_ptr

newしたポインタは必ずdeleteしなくてはいけない。これは非常に面倒だし、忘れるとメモリリークの原因になる。そんな時に便利なのがスマートポインタだ。 スマートポインタはほぼ通常のポインタと同じ使い方でしかも適切なタイミングで自動的にdeleteされる。 スマートポインタを使用するにはmemoryヘッダーをインクルードする。 スコープを抜ける時自動的にsptrが破棄され、newで確保したMyClassを指すスマートポインタがなくなるので、自動的にnewで確保した領域が開放される。

#include <iostream>
#include <memory>

class MyClass{
public:
    int x;
    MyClass(int _x):
        x{ _x }
    {
        std::cout << "コンストラクタが呼ばれました" << std::endl;
    }

    ~MyClass(){
        std::cout << "デストラクタが呼ばれました" << std::endl;
    }
};


int main(){

    std::cout << "メイン関数に入りました" << std::endl;

    {
        std::cout << "スコープに入りました" << std::endl;
        std::shared_ptr<MyClass> sptr( new MyClass{ 100 } );
        std::cout << "objのxの値:" << sptr->x << std::endl;
        std::cout << "スコープを抜けます" << std::endl;
    }

    std::cout << "メイン関数を抜けます" << std::endl;

    return 0;
}

上の例ではshared_ptrに直接newのポインタを渡して、

std::shared_ptr<MyClass> sptr( new MyClass(100) );

のように書いているが、make_sharedを使って

std::shared_ptr<MyClass> sptr = std::make_shared<MyClass>(100);

と書いた方が処理効率が良い。


書き方

std::shared_ptr<クラス名> 変数名 = std::make_shared<クラス名>(引数1, 引数2, ...);

Tips:std::shared_ptr以外のスマートポインタ

スマートポインタは、他にもコピー出来ないがstd::shared_ptrに比べ高速に動作するstd::unique_ptrや、std::shared_ptrが指すメモリを参照し、循環参照問題を防ぐことができるstd::weak_ptrなどがある。std::shared_ptrに慣れたらこちらも使っていくとよい。

Tips:メンバにポインタを持つクラスの注意点

メンバにポインタを持つクラスの例を以下に示す。コンストラクタでnewし、解放忘れがないようにデストラクタでdeleteしている。実行してみると、問題なく処理が終了していることがわかる。

#include <iostream>

class MyClass {
public:
    int* pVal;
    MyClass(int val) {
        std::cout << "コンストラクタが呼ばれました" << std::endl;
        pVal = new int(val);
    }
    ~MyClass() {
        std::cout << "デストラクタが呼ばれました" << std::endl;
        delete pVal;
    }
    void showVal() {
        //pValが指す値を表示
        std::cout << *pVal << std::endl;
    }
};


int main() {
    MyClass obj{ 10 };
    //値を表示
    obj.showVal();
    
    return 0;
}

ここで、MyClassオブジェクトの値渡し(コピー)を行ってみる。すると、関数を抜けた後の動作がおかしくなってしまう。

#include <iostream>

class MyClass {
public:
    int* pVal;
    MyClass(int val) {
        std::cout << "コンストラクタが呼ばれました" << std::endl;
        pVal = new int(val);
    }
    ~MyClass() {
        std::cout << "デストラクタが呼ばれました" << std::endl;
        delete pVal;
    }
    void showVal() {
        //pValが指す値を表示
        std::cout << *pVal << std::endl;
    }
};

void hoge(MyClass obj) {
    std::cout << "hoge関数に入りました" << std::endl;
    //値を表示
    obj.showVal();
    std::cout << "hoge関数を抜けます" << std::endl;
}

int main() {
    MyClass obj{ 10 };
    //値を表示
    obj.showVal();
    //funcを呼ぶ
    hoge(obj);
    //値を再度表示
    obj.showVal();
    return 0;
}

実行結果

コンストラクタが呼ばれました
10
hoge関数に入りました
10
hoge関数を抜けます
デストラクタが呼ばれました
-572662307
デストラクタが呼ばれました

動作の流れを追ってみる。hoge関数が呼ばれたところでは、以下の図のような状態になっている。値渡しによりobjのコピーが作成され、それぞれのpValが同じ実体を指している。 f:id:chinimuruhi:20211127175759p:plain hoge関数を抜ける際、以下の図のようにobj(hoge関数内)のデストラクタが呼ばれ、pValの指す先がdeleteされてしまう。 f:id:chinimuruhi:20211127175803p:plain これにより、hoge関数を抜けた後、pValの値を使おうとすると未定義のメモリを使うことになり、おかしなことになってしまう。 また、pValを2度delete(二重解放)することになり、エラーが発生する。

二重解放問題の解決策

  1. 今回の場合、objのコピーを行ったことが原因であるため、オブジェクトのコピーが発生しないよう、参照渡し又はポインタ渡しにすることで回避できる。 オブジェクトのコピーは時間がかかり、このような不具合を生む原因にもなるので、理由が無い限り発生しないようにしよう。
    #include <iostream>
    
    class MyClass {
    public:
        int* pVal;
        MyClass(int val) {
            std::cout << "コンストラクタが呼ばれました" << std::endl;
            pVal = new int(val);
        }
        ~MyClass() {
            std::cout << "デストラクタが呼ばれました" << std::endl;
            delete pVal;
        }
        void showVal() {
            //pValが指す値を表示
            std::cout << *pVal << std::endl;
        }
    };
    
    void hoge(MyClass& obj) {
        std::cout << "hoge関数に入りました" << std::endl;
        //値を表示
        obj.showVal();
        std::cout << "hoge関数を抜けます" << std::endl;
    }
    
    int main() {
        MyClass obj{ 10 };
        //値を表示
        obj.showVal();
        //funcを呼ぶ
        hoge(obj);
        //値を再度表示
        obj.showVal();
        return 0;
    }
    
  2. クラスのコピーを禁止したり、コピーコンストラクタ、代入演算子オーバーロードを正しく設定することで回避することも出来る。興味があれば調べてみよう。

まとめ

  • newを使うと、プログラムの実行中にメモリを確保し、オブジェクトを生成できる。
  • newしたポインタは必ずdeleteしなくてはいけない。
  • スマートポインタは適切なタイミングで自動にdeleteをしてくれる
  • クラス内でnew,deleteする場合は気を付けよう

演習問題(コンソール)

  1. 以下のようなクラスを定義した。MyClassへのポインタのvectorを用意し、適当な値のa(0~10ぐらい)を持ったデータ10個をnewで生成・格納し、表示した後にaが5以下の要素を削除せよ。
    #include <iostream>
    
    class MyClass{
    public:
        int a;
    
        MyClass(int _a):
            a{ _a }
        {
        }
    };
    

    ヒント
    vectorの要素のMyClassへのポインタを削除しただけでは動的に確保した領域は解放されていない。 現時点ではremove_ifを使うとdeleteを呼べないので、whileとeraseを使って削除をすること。
    auto it = vec.begin();
    while(it != vec.end()){
        if(条件){
            it = vec.erase(it);
        }else{
            it++;
        }
    }

演習問題(OpenSiv3D)

今回はEnemyを複数出すだけなので、前半課題とは別に、サンプルプロジェクトを元にを作った方が楽かもしれない。 以下のようなEnenyクラスを用意した。

#pragma once
#include <Siv3D.hpp>

class Enemy {
public:
    static const double Radius;

    Vec2 pos;
    Vec2 velocity;
    Enemy(const Vec2& _pos);
    void update();
    void draw();
};
# include "Enemy.h"

const double Enemy::Radius = 30.0;

Enemy::Enemy(const Vec2& _pos):
    pos{ _pos },
    velocity{ RandomVec2(50.0) }
{
}

void Enemy::update() {
    pos += Scene::DeltaTime() * velocity;
}

void Enemy::draw() {
    Circle{ pos, Radius }.draw(Color{ 255, 0, 0 });
}
  1. Enemy型へのポインタを要素に持つvectorを用意し、newを使って敵を複数生成せよ。
  2. シーン(画面)外に行った敵を削除せよ。newで動的確保した領域もdeleteで解放すること。
  3. Enemy型へのスマートポインタを要素に持つvectorを使って書き直せ。

C++OpenSiv3D入門講座

【ゲーム制作向け】イージングのすゝめ

こちらはCCS Advent Calendar 2021 12日目の記事となります。 adventar.org

昨日の記事はこちら! note.com 最優秀賞めでたい!みんなもバグハンターにならないか?

真面目な話、一つのスキルになるし、教養展開の単位もとれるし、入賞すれば就活等のアピールで使えるかも?(あと景品のアマギフもらえるし...)

おすすめです。

はじめに

自分の作るゲームをちょっぴり良く見せたい!そんなアナタにイージングがおすすめです。

この記事ではイージングとは何か?から始まり実用例・実装を簡単に説明します。

イージングは気持ちの良い動きを追求するための技術です。気持ちの良い動きはゲームを遊んでいる時の面白さに繋がるものであり、重要な要素です。

知っているとゲーム制作以外にも役立てられるかも...?

イージングとは

まずはこの画像をご覧ください。

f:id:chinimuruhi:20211130161917g:plain

上のCubeと下のCubeは、動く時間も距離も同じです。では、何が違うでしょう? 上のCubeはずっと同じ速度で動いているのに対し、下のCubeは徐々に速くなるような動きをしています。このように、速度に緩急をつけることをイージング(Easing)と言います。


続いてこちらの画像をご覧ください。

f:id:chinimuruhi:20211130162640g:plain

上のように徐々に速くなる動きをイーズイン(Ease in)、中央のように徐々に遅くなる動きをイーズアウト(Ease out)、下のように最初と最後が遅くなる動きをイーズインアウト(Ease in-out)と言います。

イージングには様々な動きがあります。以下のサイトに動きとその数式が載っています。

easings.net

実用例

ゲーム内のオブジェクト

ゲーム内のオブジェクトにイージングを適用してみましょう。イージングをつける時は、物理的な挙動を意識すると不自然になりにくく、気持ちの良い動きになりやすいです。

例:ゴール時の衝突演出

f:id:chinimuruhi:20211130181910g:plain

球がカップに当たるとカップが反応を起こす、という動作での例です。ストローの動きとカップのスケールの動きにEase outを使用しています。

衝突によってカップに運動が伝わり、段々減衰していくことを考えてEase outを選択しています。

等速でストローを動かすよりも気持ちが良い動きになっており、ゴールした達成感をより強めることが出来ているかと思います。

また、正しい物理挙動が気持ちの良い動きとは限りません。速度の減衰のモデルを考えて...とするのではなく、様々な種類のイージングを試してみましょう。

不自然でない限りで、大袈裟に表現出来ていると嬉しい。

エフェクト

同様に物理的な挙動を意識しながら使っていきます。

例:打撃のエフェクトを出す例

f:id:chinimuruhi:20211130210050g:plain

ハンマーが当たるとエフェクトが出る例です。先ほど同様、衝突の運動が伝わり減衰していく様子を考え、Ease outを使用しています。

(ハンマーの動き自体にもイージングを使っています。隙あらば使っていきましょう。)

UI

プレイヤーの操作に依らない動きの場合、プレイヤーは要素がいつ動くのか分かりません。

そのため、Ease outのようにいきなり最高速度で動き出すとびっくりさせてしまう可能性があり、Ease in又はEase in-outを使うようにしています。

一方で、プレイヤーの操作によって起こる動作はEase outを使います。ボタンを押した衝撃がゲーム内に伝わるような体験をさせて、「自分が操作した感」を強めます。

アイテム欄っぽいものの例

f:id:chinimuruhi:20211130213500g:plain

出てきた時はEase in、アイテム欄の詳細を開くときはEase outを使っています。

実装

動きのイージングを楽に実装するために必要なものは以下の2つです。

  • 線形補完関数
  • イージング関数

線形補完関数

線形補完関数はこのような形をしていることが多いです。

//(double型などの型をTとする)
T lerp(T 開始の値, T 終了の値, T t)

tは基本的に0.0~1.0の間の値をとり、0.0の時は開始の値、1.0の時は終了の値を返します。

例
val = lerp(2.0, 5.0, 0.0)  //valは2.0
val = lerp(2.0, 5.0, 0.1)  //valは2.3
val = lerp(2.0, 5.0, 0.5)  //valは3.5
val = lerp(2.0, 5.0, 1.0)  //valは5.0

線形補完関数の開始の値と終了の値を移動の開始点と終了点にし、tを0.0~1.0で動かすことで移動の動作を実装することができます。 この時、tを一定の速さで動かせばオブジェクトも一定の速さで動きます。

イージング関数

実装したいイージングの数式をそのまま関数にしたものです。

InCubicの例

f:id:chinimuruhi:20211129182223p:plain

//(double型などの型をTとする)
T easeInCubic(T x){
    return x * x * x;
}

動きのイージングの実装

線形補完関数でオブジェクトの座標等を決めながら、線形補完関数のtの動きをイージングさせるようにします。

//InCubicでの例
pos = lerp(開始の値, 終了の値, easeInCubic(t))

tを0.0~1.0で一定の速さで動かすことによって、イージングされた動きでの座標を得ることができます。

フレームワークゲームエンジンによる実装

線形補完関数についてはC++であればstdにありますが、イージング関数については自作する or どこかから持ってくる必要があります。

cpprefjp.github.io

フレームワークゲームエンジンによっては、線形補完及びイージングを行える機能が備えられています。そちらを軽く紹介します。


OpenSiv3D

OpenSiv3Dでは座標等を保持するVec2型での線形補完や、約30種類のイージング関数が備えられています。便利!

zenn.dev


Unity

Unityには線形補完関数はありますが、イージング関数はデフォルトではありません。自作するか、どこかから持ってきましょう。 また、これらの線形補完関数のtは[0, 1]で制限されており、この範囲を超えることのあるBack等のイージングには使えないので注意しましょう。

Mathf-Lerp - Unity スクリプトリファレンス

Vector3-Lerp - Unity スクリプトリファレンス

このように自作しようとすると少々面倒ですが、イージングの動作を簡単に書けるAssetがあります。

assetstore.unity.com

DOTweenを使えば、イージングの動作も一行で書けちゃいます。簡単!

//(5,0,0)へ1秒でOutExpo移動する
transform.DOMove(new Vector3(5f, 0f, 0f), 1f).SetEase(Ease.OutExpo);

SetEaseの引数にはAnimationCurveも対応しており、SerializeFieldにしたAnimationCurveでGUI上から書いたグラフの通りに動かすこともできます。

おわりに

ここまでお読みいただきありがとうございました。 皆さんのゲーム制作(や日常生活?)に少しでも役立てたら幸いです。

イージングの使い方についても軽く紹介しましたが、あくまで目安として受け取ってください。目的は「気持ちの良い動きを作ること」であり、例の通りイージングを使うことではありません。人の直感を信じてトライアンドエラーしていきましょう。

C++OpenSiv3D入門講座 Vol. 08 GameManagerクラス

C++OpenSiv3D入門講座

実際ゲームを作る時、PlayerやEnemyの配列、Bulletの配列、Effectの配列等をメンバに持ち、それらを管理するManagerクラスを作ると便利である。

今回は、PlayerとEnemyを持つManagerクラスの実体をグローバル変数にすることで、EnemyとPlayerが相互にアクセスする例を示す。 (前回は、EnemyクラスがPlayerへのポインタを持つことで、Playerクラスにアクセスすることができたが、今回はEnemyクラスがPlayerクラスへのポインタを持つ必要はない)

PlayerがEnemyの情報に、EnemyがPlayerの情報に相互にアクセス出来るようにするため、上手くファイルをインクルードしなければならない。

以下の例では、PlayerはshowEnemyX関数でEnemyのxにアクセスし、EnemyはshowPlayerX関数でPlayerのxにアクセスしている。

#pragma once
#include <iostream>
#include "Manager.h"

class Player {
public:
    int x;
    Player(int _x);
    void update();
    void showEnemyX();
};
#include "Manager.h"

Player::Player(int _x) :
    x{ _x }
{
}

void Player::update() {
    std::cout << "Player内のupdateが呼ばれました。xは:" << x << std::endl;
}

void Player::showEnemyX() {
    std::cout << "Player内のshowEnemyXが呼ばれました。" << std::endl;
    std::cout << "Enemyのxは:" << manager.enemy.x << std::endl;
}
#pragma once
#include <iostream>
#include "Manager.h"

class Enemy {
public:
    int x;
    Enemy(int _x);
    void update();
    void showPlayerX();
};
#include "Manager.h"

Enemy::Enemy(int _x) :
    x{ _x }
{
}

void Enemy::update() {
    std::cout << "Enemy内のupdateが呼ばれました。xは:" << x << std::endl;
}

void Enemy::showPlayerX() {
    std::cout << "Enemy内のshowPlayerXが呼ばれました。" << std::endl;
    std::cout << "Playerのxは:" << manager.player.x << std::endl;
}
#pragma once
#include "Player.h"
#include "Enemy.h"

class Manager {
public:
    Player player;
    Enemy enemy;

    Manager();
};

extern Manager manager;
#include "Manager.h"

Manager::Manager() :
    player{ 100 },
    enemy{ 200 }
{
};

Manager manager;
#include <iostream>
#include "Manager.h"

int main() {

    manager.player.update();
    manager.enemy.update();

    manager.player.showEnemyX();
    manager.enemy.showPlayerX();

    return 0;
}

上記の例では、Manager.cppでManager型のグローバル変数を作成している。

Manager manager;

また、Manager.hで

extern Manager manager;

とすることで、Manager.h内及びManager.hをインクルードした場所で他のファイルにあるグローバル変数managerを使うことが可能になる。

演習問題(OpenSiv3D)

  1. GameManagerクラスを作れ。
    • GameManagerクラスは、PlayerやEnemyManager(Enemyのvectorを管理するクラス)をメンバに持ち、それらを管理するクラスである。
    • GameManagerのインスタンスグローバル変数にすることで、GameManagerを介してPlayerやEnemyの情報にアクセスできるようにせよ。
    • GameManagerクラスのupdateを呼べば、PlayerとすべてのEnemyのupdateが呼ばれるようにせよ。
    • EnemyクラスがPlayerクラスへのポインタを持っていた場合、それを消して、Playerクラスへのアクセスはグローバル空間に存在するGameManagerクラスのインスタンスを介して行うようにせよ。
  2. Enemyが自機を追うようにせよ。
  3. Enemyクラス内にint型のkind変数を作り、kind変数によって敵が多様な動きをするようにせよ。
    (例:kind==1なら自機に直進、kind==2ならsin軌道、kind==3なら出た位置で円運動)
  4. kindによって敵の色が変わるようにせよ。
  5. kindをenum型にせよ。クラス内で定義されたenumは、クラス外部からは(クラス名)::(要素の名前)で enumを指定できる。

ヒント

クラス内でenumを定義しコンストラクタで指定する例。

#include <iostream>

class Hoge {
public:
    enum Kind {
        A,
        B,
        C,
    };

    Kind kind;

    //コンストラクタ kindを指定
    Hoge(Kind _kind) :
    kind{ _kind }
    {
    }
};

int main() {
    // コンストラクタでkindを指定
    Hoge ins(Hoge::B);

    // 出力。整数が出力される(0から始まる)
    std::cout << ins.kind << std::endl;

    return 0;
}

C++OpenSiv3D入門講座

C++OpenSiv3D入門講座 Vol. 07 remove_if・ラムダ式

C++OpenSiv3D入門講座

今回は前回とは別の、vectorの要素を削除する方法を学ぶ。演習問題では前回と同様敵を削除する。 vectorはコンテナの一種である。algorithmヘッダーはコンテナに対して削除・ソートなどを行う事ができる。 今回はコンテナに対し条件に一致する要素を削除する関数、remove_ifを使い、要素を削除する。また条件の指定にラムダ式を用いる。

要素を削除 std::remove_if

remove_ifは条件にあった要素を削除する。 正確には、条件にあった要素を、vectorの後ろに配置しなおし、削除したように見せかけた後のvectorの最後のイテレータを返す。 つまり、remove_if関数を呼んだだけでは要素は削除されておらず、その後にerase関数を呼ぶ必要がある。 remove_ifを使うには、algorithmヘッダーをインクルードする。

remove_ifの使い方

//remove_ifを実行し、条件にあった要素を後ろに詰める。
auto rmvIter = std::remove_if(vec.begin(),vec.end(), 関数オブジェクト);

//後ろの方に詰められた要素を削除
vec.erase( rmvIter, vec.end() );

remove_ifの第一引数には削除するかを判定する範囲の先頭、第二引数には終端を書く。 remove_ifの第三引数には削除の条件を返す関数を書く。

3の倍数を削除する例

#include <iostream>
#include <vector>
#include <algorithm>

//3の倍数かどうかをたしかめて返す叙述関数
bool isMultipleThree(int x){
    return (x%3) == 0;
}

int main(){

    std::vector<int> vec;//int型の動的配列

    //0から数を入れる
    for(int i = 0; i < 10; i++){
        vec.emplace_back(i);
    }

    //表示
    for(auto i = vec.begin(); i < vec.end(); ++i){
        std::cout << *i << " ";
    }
    std::cout << std::endl;


    //vecの中から3の倍数を後ろに詰める
    //3の倍数かどうかの判定は3番目のパラメータで渡された関数で判定
    auto rmvIter = std::remove_if(vec.begin(), vec.end(), isMultipleThree);

    //実際に削除
    vec.erase( rmvIter, vec.end() );

    //表示
    for(auto i = vec.begin(); i < vec.end(); ++i){
        std::cout << *i << " ";
    }
    std::cout << std::endl;

    return 0;
}

remove_ifの第三引数には、remove_ifが対象にするvectorの中身を引数に取り、bool型(trueかfalse)を返す関数を入れる。以下が例。

bool Func(配列の要素の型 x){
    return (x%3) == 0;//条件に一致してるか確認してbool型を返す
}

動作の様子を、以下の図に示す。 f:id:chinimuruhi:20211126162618p:plain

ラムダ式

本項目ではC++の中でも特に書き方が変態的なラムダ式を扱う。 remove_ifを使う時、大きなプログラムになると削除するかを判定する関数の定義と使う部分が離れてしまう。そこでC++11からはラムダ式というものが導入された。 例えば2で割り切れるかを返す関数オブジェクトを生成する場合

#include <iostream>

int main() {

    //2乗した数を返す関数オブジェクトを生成
    auto mySquare = [](int num) {return num * num; };

    std::cout << mySquare(3) << std::endl;
    std::cout << mySquare(4) << std::endl;

    return 0;

}

上のコードの

 [](int num) {return num * num; };

の部分がラムダ式だ。ラムダ式の型はautoで推論させている。(明示的に型指定をした std :: function オブジェクトや、一部関数ポインタに代入することもできるが、autoで受け取った方が良い)
ラムダ式の書き方(簡易版)

[](引数){処理};

ところで返り値の型を指定していないことに気づいただろうか? これは多くの場合指定しなくてよいものだ。 ラムダ式の返り値を明示的に指定する場合は

[](引数)->返り値の型{
    処理
};

のように()のあとに「->(返り値の型)」という形式にする。その他にも様々なオプションが存在するため、興味があれば調べてみよう。

ラムダ式の活用法

ラムダ式の節での処理は以下のように今まで通り関数を作って使っているのと同じようなものである。

#include <iostream>
//2乗した数を返す関数
auto mySquare(int num) {
    return num * num;
}

int main() {
    std::cout << mySquare(3) << std::endl;
    std::cout << mySquare(4) << std::endl;

    return 0;

}

ラムダ式は関数の一時オブジェクトを作成することができるのが強みの一つである。どのような場面でラムダ式を使うのが良いか、実際に例を挙げる。


関数の引数に関数オブジェクトを渡すときの例

以下は要素を削除 std::remove_ifの節での例の中で、remove_ifを呼んでいる箇所である。

auto rmvIter = std::remove_if(vec.begin(), vec.end(), isMultipleThree);

remove_ifの第三引数には、削除する対象の要素であるかどうかを返す関数を入れていた。

bool isMultipleThree(int x){
    return (x%3) == 0;
}

第三引数に渡す関数オブジェクトを、ラムダ式を用いて直接書くことができる。こうすることにより、remove_if関数の近くに条件を判定する関数を置くことができる。

#include <iostream>
#include <vector>
#include <algorithm>

int main(){

    std::vector<int> vec;//int型の動的配列

    for(int i = 0; i < 10; i++){
        vec.emplace_back(i);
    }

    //表示
    for(auto i = vec.begin(); i < vec.end(); ++i){
        std::cout << *i << " ";
    }
    std::cout << std::endl;




    //vecの中から3の倍数を後ろに詰める
    auto rmvIter = std::remove_if(vec.begin(),vec.end(),
        [](int x){
            return (x % 3) == 0;
        }
    );

    //実際に削除
    vec.erase( rmvIter, vec.end() );


    //表示
    for(auto i = vec.begin(); i < vec.end(); ++i){
        std::cout << *i << " ";
    }
    std::cout << std::endl;

    return 0;
}

std等に関数オブジェクトを引数にとる関数はいくつか存在する。( std:sort()std::foreachなど) 引数に渡すためなど一度きりしか使われなく、内容が複雑でない関数には積極的にラムダ式を使っていこう。

まとめ

  • vectorの条件に合った要素を削除するのにremove_ifが使える
  • remove_ifでは不要な要素を後ろに並び替えるだけであり、削除は行われていない
  • 関数の一時オブジェクトを作成するのにラムダ式が使える
  • 関数の引数に関数オブジェクトを渡す場合で、内容が複雑でない関数には積極的にラムダ式を使っていこう

演習問題(コンソール)

  1. int型の変数を引数にとり、2倍にして返す関数をラムダ式を用いて作成せよ。
  2. int型のvectorに0から9を入れ、remove_ifとラムダ式を用いて2の倍数を削除せよ。

演習問題(OpenSiv3D)

  1. 前回同様にEnemyクラスを用意した。Enemyのvectorを作り、敵を複数生成せよ。
    #pragma once
    #include <Siv3D.hpp>
    
    class Enemy {
    public:
        Vec2 pos;
        Vec2 velocity;
        Enemy(const Vec2& _pos);
        void update();
        void draw();
    };
    
    # include "Enemy.h"
    
    Enemy::Enemy(const Vec2& _pos) :
        pos{ _pos },
        velocity{ RandomVec2(50.0) }
    {
        // RandomVec2(double length)
        // 半径length(今回は50.0)の2次元ベクトルを返す
    }
    
    void Enemy::update() {
        pos += Scene::DeltaTime() * velocity;
    }
    
    void Enemy::draw() {
        Circle{ pos, 30.0 }.draw(Color{ 255, 0, 0 });
    }
    
  2. 画面外に出た敵を削除せよ。要素の削除にはalgorithmのremove_ifを使い、その条件の指定にはラムダ式を使用せよ。

C++OpenSiv3D入門講座

C++OpenSiv3D入門講座 Vol. 06 イテレータ・vector要素の削除

C++OpenSiv3D入門講座

今回はvectorの要素を削除する方法を学ぶ。Siv3Dを用いた演習では、画面外に出た敵を削除する。 C++にはSTLという便利なライブラリが標準でついている。STLにはコンテナがあり、コンテナには便利な配列やリスト構造などがはじめから用意されている。vectorはコンテナの一種である。

イテレータ

ポインタを抽象化してstd::vectorや他のコンテナでも似た形で使えるようにしたイテレータというものが存在する。 ポインタが変数をさすのに対して、イテレータvectorの要素をさす。 begin()関数は、配列の先頭へのイテレータを返す関数、end()関数は、配列の最後の要素の次へのイテレータを返す関数。 下の例では、これらを用いてfor文を回している。

#include <iostream>
#include <vector>

int main(){

    std::vector<int> vec;

    // 10個の要素を追加していく
    for(int i = 0; i < 10; i++ ){
        vec.emplace_back(rand()%100);
    }

    //イテレータを用いて出力
    for(std::vector<int>::iterator iter =vec.begin(); iter !=vec.end(); ++iter){
        std::cout << *iter << " ";
    }
    std::cout << std::endl;


    //begin()関数は、配列の先頭へのイテレータを返す関数
    std::vector<int>::iterator iter2 = vec.begin();
    std::cout << *iter2 << std::endl;

    //イテレータはポインタと同じく+することで指す要素を進められる
    //はじめの要素を指すイテレータを4つ進めたので、5番目の要素が出力される
    iter2 += 4;
    std::cout << *iter2 << std::endl;

    return 0;
}

std::vector<int>::iteratorは長いので、C++の機能である型推論を使い表記を省略できる。 型名の代わりに、autoと書くことで、コンパイラが型を推測してくれる。

#include <iostream>
#include <vector>

int main(){

    std::vector<int> vec;

    // 10個の要素を追加していく
    for(int i = 0; i < 10; i++ ){
        vec.emplace_back(i);
    }

    //イテレータを用いて出力
    for(auto iter =vec.begin(); iter !=vec.end(); ++iter){
        std::cout << *iter << " ";
    }
    std::cout << std::endl;

    return 0;
}

クラスのvectorイテレータ

MyClassにxという要素があったとする。「MyClassのvectorイテレータ」からそれが指すMyClassのxにアクセスするには、イテレータiterとすると、下のように書けばよい。

iter->x

例:Vector2クラスのvectorへのイテレータを使ってVector2クラスのx, yを出力

#include <iostream>
#include <vector>

class Vector2{
public:
    int x;
    int y;

    Vector2(int _x, int _y) :
        x{ _x },
        y{ _y }
    {
    }
};

int main(){

    std::vector<Vector2> vec;

    //適当な値のVector2を5個入れる
    for (int i = 0; i < 5; i++){
        vec.emplace_back(Vector2{ rand() % 100, rand() % 100 });
    }

    //出力
    for (auto iter = vec.begin(); iter != vec.end(); ++iter){
        std::cout << iter->x << " " << iter->y << std::endl;
    }

    return 0;
}

vectorの要素の削除

erase関数で要素を削除できる。引数には削除する要素のイテレータを入れる。

#include <iostream>
#include <vector>

int main(){

    std::vector<int> vec;//int型の動的配列

    // 10個の要素を追加していく
    for(int i = 0; i < 10; i++ ){
        vec.emplace_back(i);
    }

    //イテレータを用いて出力
    for(auto iter = vec.begin(); iter != vec.end(); ++iter){
        std::cout << *iter << " ";
    }
    std::cout << std::endl;


    std::cout << "現在のサイズ : " << vec.size() << std::endl;

    //4つめの要素の消去
    vec.erase(vec.begin() + 3);

    //イテレータを用いて出力
    for(auto iter =vec.begin(); iter !=vec.end(); ++iter){
        std::cout << *iter << " ";
    }
    std::cout << std::endl;

    std::cout << "現在のサイズ : " << vec.size() << std::endl;

    return 0;
}

条件にあった要素の削除

条件にあった複数の要素を削除するには以下のようにする。わりとトリッキー。 vectorの要素を削除すると削除した要素を指していたイテレータが迷子になってしまう。erase関数を使って削除をすると、返り値として削除した要素の次の要素へのイテレータが返ってくるので、それをループ用のイテレータに入れる。

#include <iostream>
#include <vector>

int main(){

    std::vector<int> vec;//int型の動的配列

    // 10個の要素を追加していく
    for(int i = 0; i < 10; i++ ){
        vec.emplace_back(i);
    }

    //出力
    for(auto iter =vec.begin(); iter !=vec.end(); ++iter){
        std::cout << *iter << " ";
    }
    std::cout << std::endl;

    std::cout << "現在のサイズ : " << vec.size() << std::endl << std::endl;

    //要素の消去(偶数を消去)
    auto it = vec.begin();
    while(it != vec.end()){
        if(*it % 2 == 0){//偶数だったら
            std::cout << *it << "を消去" << std::endl;
            //eraseの返り値をitで受ける(返り値は削除したものの次につながっていた要素へのイテレータ)
            it = vec.erase(it);
        }else{
            it++;
        }
    }
    std::cout << std::endl;

    //出力
    for(auto iter =vec.begin(); iter !=vec.end(); ++iter){
        std::cout << *iter << " ";
    }
    std::cout << std::endl;

    std::cout << "現在のサイズ : " << vec.size() << std::endl << std::endl;

    return 0;
}

まとめ

  • イテレータは配列等で扱いやすくし、抽象化されたポインタ
  • begin()関数は、配列の先頭へのイテレータを返す
  • end()関数は、配列の最後の要素の次へのイテレータを返す
  • ループ内での要素の削除はトリッキーになるので気を付けよう
  • erase関数を使って削除をすると、返り値として削除した要素の次の要素へのイテレータが返ってくる

演習問題(コンソール)

  1. 以下のようなクラスを用意した。MyClassのvectorを作り、適当なa,bの値(0~100ぐらい)をもつデータを10個ほど格納し、イテレータを用いたfor文によって表示せよ。
    #include <iostream>
    
    class MyClass{
    public:
        int a;
        int b;
    
        MyClass(int _a, int _b):
            a{ _a },
            b{ _b }
        {
        }
    };
    
  2. aがbより小さいデータを削除し、再度表示せよ。

演習問題(OpenSiv3D)

今回はvol_3のSOpenSiv3D演習問題の続きから作るか、新しくサンプルプロジェクトからプログラムを書くことをオススメする。いずれにせよ今回はプレイヤーはいなくていいし、敵がポインタ経由でプレイヤーの情報を受け取る必要はない。

  1. 以下のようなEnemyクラスを用意した。Enemyのvectorを作り、敵を複数出せ。forループは、イテレータを用いて書くこと。
    #pragma once
    #include <Siv3D.hpp>
    
    class Enemy {
    public:
        Vec2 pos;
        Vec2 velocity;
        Enemy(const Vec2& _pos);
        void update();
        void draw();
    };
    
    # include "Enemy.h"
    
    Enemy::Enemy(const Vec2& _pos) :
        pos{ _pos },
        velocity{ RandomVec2(50.0) }
    {
        // RandomVec2(double length)
        // 半径length(今回は50.0)の2次元ベクトルを返す
    }
    
    void Enemy::update() {
        pos += Scene::DeltaTime() * velocity;
    }
    
    void Enemy::draw() {
        Circle{ pos, 30.0 }.draw(Color{ 255, 0, 0 });
    }
    
  2. y座標が480を超えた敵(下の方の画面外に出た敵)を削除するようにせよ。
  3. シーン外に出た敵を削除するようにせよ。
  4. ヒント: Scene::Size() 関数でシーンのサイズをVec2型で取得することができる。デフォルトではシーンのサイズはウィンドウサイズ(拡大縮小倍率がのっていないもの)と同じサイズになる。 zenn.dev

C++OpenSiv3D入門講座

C++OpenSiv3D入門講座 Vol. 05 関数オーバーロード・range-based-for・constメンバ関数

C++OpenSiv3D入門講座

今回は関数オーバーロードと、range-based-forについて説明する。

関数オーバーロード

C++では、引数が異なる同じ名前の関数を定義できる。そのように関数を多重定義することを関数オーバーロードという。 実行中に、引数によって呼ばれる関数が選択される。

#include <iostream>

//絶対値を返す関数、myAbsを3通りにオーバーロードする
int myAbs(int n){
    std::cout << "int型のmyAbsが呼ばれました" << std::endl;
    if(n > 0){
        return n;
    }else{
        return -n;
    }
}

float myAbs(float n){
    std::cout << "float型のmyAbsが呼ばれました" << std::endl;
    if(n > 0){
        return n;
    }else{
        return -n;
    }
}

double myAbs(double n){
    std::cout << "double型のmyAbsが呼ばれました" << std::endl;
    if(n > 0){
        return n;
    }else{
        return -n;
    }
}


int main(){

    int a = -10;
    float b = -2.71f;
    double c = -3.14;

    std::cout << myAbs(a) << std::endl << std::endl;
    std::cout << myAbs(b) << std::endl << std::endl;
    std::cout << myAbs(c) << std::endl << std::endl;


    return 0;

}


以下は、引数の数によって呼ばれる関数が変わるパターンである。

#include <iostream>

void myFunc(int a){
    std::cout << "引数1つのmyFuncが呼ばれました。引数は " << a << std::endl;
}

void myFunc(int a, int b){
    std::cout << "引数2つのmyFuncが呼ばれました。引数は " << a << " " << b << std::endl;
}

int main(){
    myFunc(1);
    myFunc(2, 3);

    return 0;
}

関数オーバーロードと同じ要領で、コンストラクタも複数定義できる。

#include <iostream>

class MyClass {
public:
    int value;

    MyClass() :
        value{ 0 }
    {
        std::cout << "引数なしのコンストラクタが呼ばれました" << std::endl;
    }

    MyClass(int _value) :
        value{ _value }
    {
        std::cout << "引数ありのコンストラクタが呼ばれました" << std::endl;
    }
};

int main() {

    MyClass obj1{ 10 };
    std::cout << obj1.value << std::endl;

    MyClass obj2;
    std::cout << obj2.value << std::endl;

    return 0;

}

Tips:range-based-for

これまでvectorに対して、for文で用いる時、毎回始めのイテレータと終わりのイテレータを書いていたが、これは面倒である。C++にはrange-based-forがあり、これを用いると、配列のそれぞれの要素に対して行う処理が簡単に書ける。以下に書き方を示す。

for(配列の要素の型 受け取る変数 : 配列名){

}

以下にサンプルコードを用意した。今回は、自分で用意したMyClass型のvectorのそれぞれの要素のaを表示している。配列の要素の型はautoで推論している。constを使うことで値を間違って変更することを防いでおり、参照を用いて、無駄なコピーをが発生しないようにしている。

#include <iostream>
#include <vector>

class MyClass {
public:
    int a;
    MyClass(int _a) :
        a{ _a } 
    {
    }
};

int main() {

    std::vector<MyClass> vec;
    for (int i = 0; i < 5; i++) {
        vec.emplace_back(MyClass{ i });
    }

    for (const auto& i : vec) {
        std::cout << i.a << std::endl;

        // コメントを外すと値を書き換えることになるのでコンパイルエラー
        // i.a = 99;
    }

    return 0;
}

以下はMyClassのaを全て2倍にする例。参照を用いて、vectorの中身を書き換えている。auto&&と、&が1つではなく2つあるがタイプミスではない。今回の場合auto&でも問題ないが、auto&&を用いると右辺値にも対応できるようになり、より汎用的な書き方になるので、range-based-forを用いる際は基本的にこちらを推奨する。(ちなみに、VisualStudio上でマウスカーソルをiの上に持っていくとわかるが、iはちゃんとMyClass&と推論されていることがわかる)詳しくは、range-based for loopsの要素の型について - Qiita参照。

#include <iostream>
#include <vector>

class MyClass {
public:
    int a;
    MyClass(int _a) :
        a{ _a } 
    {
    }
};

int main() {

    std::vector<MyClass> vec;
    for (int i = 0; i < 5; i++) {
        vec.emplace_back(MyClass{ i });
    }

    // 参照を用いてそれぞれのMyClassのaを2倍に
    for (auto&& i : vec) {
        i.a *= 2;
    }

    // 中身を表示
    for (const auto& i : vec) {
        std::cout << i.a << std::endl;
    }

    return 0;
}

Tips:constメンバ関数

constメンバ関数を使うと、メンバ変数を変更できないメンバ関数を作ることができる。これを用いることで、誤ってメンバを変更することを防ぐことができ、また多人数開発の際にメンバ変数を変更していないことを明示できる。

#include <iostream>

class MyClass {
public:
    int a;
    MyClass(int _a);
    void func() const;
};


MyClass::MyClass(int _a) :
    a{ _a }
{
}

void MyClass::func() const {
    // コメントを外すとメンバ変数を変更してしまうのでコンパイルエラー
    // a = 100;

    std::cout << a << std::endl;
}


int main() {
    MyClass obj{ 10 };
    obj.func();
    return 0;
}

まとめ

  • 引数が異なる同じ名前の関数を定義でき、実行中に引数によって呼ばれる関数が選択される(関数オーバーロード)
  • C++にはrange-based-forがあり、配列のそれぞれの要素に対して行う処理が簡単に書ける。
  • constメンバ関数は、メンバ変数を変更できないメンバ関数である。

演習問題(コンソール)

  1. 引数で受け取った値の2乗を返す関数mySquare()を作成せよ。mySquare()をint,float,double型の3通りに対応できるようオーバーロードしなさい。
  2. 以下の様なクラスVector2を作った。コンストラクタとデストラクタを作れ。
    コンストラクタ・デストラクタがよばれたら、"~が呼ばれました"と出力するようにせよ。コンストラクタは引数有りのものと引数なし(引数なしの時はx=0,y=0)のものを作ること。
    class Vector2{
        public:
            int x, y;
    
            //以下にコンストラクタとデストラクタ書く
    }
    

C++OpenSiv3D入門講座