Pyxel 「継承」を使って敵キャラの種類を増やす

Pyxel シューティングゲーム 敵機の種類を増やす

 Python向けレトロゲームエンジン「Pyxel(ピクセル)」を使ってのゲーム作成を通してプログラミングを学習します。

 オブジェクト指向プログラミングにおいて「継承」と呼ばれる仕組みがあります。クラス定義の共通部分をまとめたクラスから,固有の変数やメソッドを持ったクラスを作ることで,コードの重複を減らすことができる仕組みです。本記事では「継承」を使ってシューティングゲームに登場させる敵キャラクター(敵機)の種類を増やす例を紹介します。
 
 ※元になるプログラムは下記記事(前編・後編)で作成しています。kinutani.hateblo.jp

ソースコード

 ※ソースファイルの内容(list11_01 ~ list11_05)
 シューティングゲーム02 - Google ドライブ
 
 

01 Enemyクラスの修正

 Enemyクラスを「共通部分をまとめたクラス」にしようと思います。後の手順で敵機の移動を変更しやすいようにメソッドを修正します。(下の図の左側は3段の箱になっていて 上段:クラス名,中段:変数,下段:メソッド が書いてあります)
 
 

【ソースコード】はこちらのリンクから確認してください(Googleドキュメント)
list11_01.py 抜粋

class Enemy:
・・・
    def update(self):
        self.tmr += 1
        self.move()
        if (self.x < -20 or pyxel.width + 20 < self.x
            or self.y < -20 or pyxel.height + 20 < self.y):
            self.is_alive = False

    def move(self):
        self.x += self.dx
        self.y += self.dy

・・・

・更新メソッド内のxy座標変更を新規メソッド move() で行うように変更
・更新メソッドからmove()呼び出し
・更新メソッドでの表示を消す条件を変更

 

02 Enemyクラスを継承したEnemyAクラス

 前回記事の敵は一直線に移動しましたが,上下に移動する敵を追加しましょう。また,表示するドット絵も別のものにしたいと思います。新しい敵用に新しいクラスを定義するのですが,ゼロから作るのではなく「継承」を使います。継承することで,Enemyクラスの変数とメソッドを全て持ったクラスを定義することができます。
 
 
 EnemyAクラスでは,座標変更メソッドと描画メソッドを固有のものにしようと思います(再定義します)。
 先にあるクラスを「スーパークラス」と呼び,スーパークラスを継承して利用するクラスを「サブクラス」と呼びます。

 【補足】図の白抜き矢印の向きに違和感があるかもしれませんが,クラス図という図で表すときはサブクラスからスーパークラスへの向きで表記します。これは汎化(はんか generalization 一般化のこと)という,複数の具体的なサブクラスから,共通的なところをまとめ抽象的なスーパークラスを定義することを表す矢印と思ってください。(今回の記事では逆向きの作り方をしていて特化(specialization)とよばれる考え方に近いです)


【ソースコード】はこちらのリンクから確認してください(Googleドキュメント)
list11_02.py 抜粋

ENEMY_A_ANIMA =[ (40,40),(48,40),(56,40),(64,40) ]
・・・
class EnemyA(Enemy):
    def move(self):
        self.x += self.dx
        self.y += self.dy
        if( (self.y < 10 and self.dy < 0)
          or (150 < self.y and 0 < self.dy)):
            self.dy = -self.dy

    def draw(self):
        u,v = ENEMY_A_ANIMA[(self.tmr//10)%4]
        pyxel.blt(self.x, self.y, 0, u, v, self.w, self.h, 0)
・・・
class App:
    def update_play_scene(self):
        if pyxel.frame_count % 60 == 10:
            Enemy(pyxel.width,pyxel.rndi(20,pyxel.height - 20),-2,0)
        if pyxel.frame_count % 60 == 20:
            EnemyA(pyxel.width,pyxel.rndi(40,pyxel.height - 40),-2.5,pyxel.rndi(-2,2))

・class EnemyA(Enemy):

class クラス名(継承リスト):

 継承の仕方は,クラス定義でかっこをつけて継承リストにクラス名を指定します。かっこの中のクラスがスーパークラスになります。

スーパークラスのメソッド
 EnemyAクラスには,__init__()とupdate()が記述されていませんが,スーパークラスのメソッドが呼び出されて実行されます。

・再定義したメソッド
 サブクラスには固有の変数やメソッドを定義できます。また,スーパークラスと同じ名前でメソッドを定義しておくと,インスタンスから呼び出されたときには,この再定義したメソッドが実行されます。

・EnemyA()
 サブクラスのインスタンスを作成する処理を追加しています。サブクラスを使う側(Appクラス)の変更はここだけです。

 【補足】EnemyAのインスタンスはenemiesリストに格納されていて,update()とdraw()メソッドの呼び出し部分のコードを変更することなく新しい敵が表示されます。これはオブジェクト指向プログラミングの「ポリモーフィズム」とよばれる,呼び出し側の処理を共通化しておける仕組みにつながります(敵の種類が増えても変更不要)。

実行結果
 
 上下ジグザグに移動する敵を追加できました。

 ※ここではy軸方向の変化量dyの正負を切り替えることで上下に移動させましたが,移動方法はいろいろなやり方が考えられます。公式サンプル 09_shooter.py ではx軸方向に揺れながら,そのときの向きで絵も変化するようになっています。また,sinθの値の範囲が-1から1の間であることを利用して作る例を記事の最後で紹介します。

 

03 スーパークラスのメソッド呼び出し

 次は弾を撃つ敵機を追加します。敵の弾についてもEnemyクラスを継承したクラスを定義してみましょう。

【ソースコード】はこちらのリンクから確認してください(Googleドキュメント)
list11_03.py抜粋

class EnemyB(Enemy):
    def update(self):
        super().update()
        if pyxel.rndi(0,100) < 3 and 10 < self.tmr:
            EnemyBullet(self.x,self.y+2,-3,pyxel.rndi(-1,1))
            self.tmr = 0

    def draw(self):
        pyxel.blt(self.x, self.y, 0, 88,40, self.w, self.h, 0)


class EnemyBullet(Enemy):
    def __init__(self, x, y, dx, dy):
        super().__init__(x,y,dx,dy)
        self.type = 0
        self.w = 4
        self.h = 4
        
    def draw(self):
        pyxel.blt(self.x, self.y, 0, 50,34, self.w, self.h, 0)
・・・
class App:
    def update_play_scene(self):

        if pyxel.frame_count % 60 == 30:
            EnemyB(pyxel.width,pyxel.rndi(30,pyxel.height - 30),-2.5,0)

・super().update()
 EnemyBクラスにupdate()メソッドを再定義すると,インスタンスから呼び出されるのはEnemyBのupdate()メソッドになります。その中でEnemyのupdate()メソッドと同じ処理をしたいときは,「super().メソッド名」でスーパークラスのメソッドを呼び出すことができます。
 xy座標の更新や範囲判定はEnemyクラスと同じ処理で,最後に一定の確率で弾を発射する処理を追加しています。

・class EnemyBullet(Enemy):
 敵機の弾のクラスでは,スーパークラスの__init__()を呼び出した後に変数を上書きしています。

敵機と敵の弾の判別

class Enemy:
    def __init__(self, x, y, dx, dy):
        self.type = 1
・・・
class App:
    def update_play_scene(self):

        for enemy in enemies:
            if 1 == enemy.type:
                for bullet in bullets:
・・・

スーパークラスの改造
 プレーヤーの弾との衝突判定を敵機だけにする目的で,Enemyクラスに type という変数を追加しています。(0:敵の弾,1:敵機)
 (変数を追加しなくても,大きさ(enemy.h)で判別や,type()関数でクラスを調べて分岐させることもできると思います)

実行結果
 

 

04 敵の出現間隔を変更する

 ゲームの経過時間で出現する敵が変わるようにしてみましょう。Appクラスに変数tmrを追加して,更新時に加算,画面の切り替えごとにリセットすることで何秒経過したか判定できる処理を追加します。
 (経過時間判定とは無関係ですが,背景の星が流れる速度も遅くなるようにBackgroundクラスのコードを修正しています。敵機の動きを速く見せようという意図です)(2023-01-22 逆効果だったためBackgroundクラスの変更取り消し)
【ソースコード】はこちらのリンクから確認してください(Googleドキュメント)
list11_04.py

class App:
    def __init__(self):
        self.tmr = 0

    def update(self):
        self.tmr += 1

    def update_title_scene(self):
        if pyxel.btnp(pyxel.KEY_RETURN):
            self.scene = SCENE_PLAY
            self.tmr = 0

    def update_play_scene(self):
        sec = self.tmr // 30     # フレームレートで割って経過秒数にする
        if sec < 20:
            (020秒の間の敵出現パターン)
        else:
            (20秒経過後の敵出現パターン)

        ・・・
        for enemy in enemies:
            (自機と敵との衝突判定)
                self.scene = SCENE_GAMEOVER
                self.tmr = 0

実行結果
 

 継承を使った敵キャラクターの種類を増やす方法の紹介は以上です。

 

05 【参考】sinの値を利用した上下移動

 浮遊感があるような,カーブ付近の移動量が小さい動きにできます。
 


<sin(サイン)って何?>
 高校の科目「数学Ⅰ」で図形の性質や計量について学ぶときに「三角比」が登場します。これは直角三角形の辺の長さの比と1つの鋭角(0°より大きく90°より小さい角)の関係を見てみると,辺の長さの比は鋭角の大きさによって定まるといった内容です。その比の値の一つがsin(サイン)とか正弦とか呼ばれる値です(斜辺の長さで高さを割った値)。この三角比を鋭角だけでなく鈍角(90°より大きく180°より小さい角)にも広げて考えるときに,(鈍角が直角三角形の内側に書けないので)半径1の半円で考えます。
 
 xy座標の原点Oを中心とする半径1の円(単位円)の円周上を動く点Pという図を描くと,点Pのy座標をsinを使って表すことができます。(θはシータとよむ)
 「数学Ⅱ」では角の範囲をもっと広げて「三角関数」という内容を学習します。角度は1周ぐるっと回って360°ですが,もう1周回って720°というように一般角というぐるぐる回るような角度でも三角比を考えるという感じです。



  (画像はWikipediaより引用 Lucas Vieira , Circle_cos_sin.gif パブリックドメイン
 上の図のようにsinθの値は,どれだけ角度がぐるぐる回っても最大値と最小値が決まっていてその範囲を超えないという特徴があります。(同じ値を周期的に繰り返すので周期関数ともよばれます)

 
 プログラミングに話を戻すと,Pyxelには「pyxel.sin(deg) deg度(Degree)の正弦を返します」という命令が用意されています。引数の値によらず,sin()命令で得られる値は-1から1の範囲になります。これを利用して「徐々に変化しながら,任意の範囲を往復する値」を得ることができます。
 

 
【ソースコード】はこちらのリンクから確認してください(Googleドキュメント)
list11_05.py抜粋

class EnemyA(Enemy):
    def __init__(self,x,y,dx,step,rng):
        self.lvl = y
        self.step = step
        self.rng = rng
        super().__init__(x,y,dx,0)

    def move(self):
        self.x += self.dx
        self.y = self.lvl + pyxel.sin(self.tmr*self.step) * self.rng

・・・
class App:
    def update_play_scene(self):
        sec = self.tmr // 30
        if pyxel.frame_count % 30 == 0: # 動作確認用
            EnemyA(pyxel.width,pyxel.rndi(40,pyxel.height - 40),-2.5, 3, 40)

・__init__()
 コンストラクタを変更して,スーパークラスとは別のEnemyA固有の引数を持たせています。

・move()
 出現したy座標を基準値にして,上下に動きます。範囲もインスタンス作成時に指定します。

 
<円周上を移動するコード例>
 sin()命令とcos()命令を利用すれば,キャラクターが円周上を動く処理が作れます。

import pyxel
pyxel.init(128,128)

x = 0
y = 0
deg = 0
r = 60
def update():
    global x,y,deg
    x = pyxel.cos(deg)*r
    y = pyxel.sin(deg)*r
    deg += 2
    return

def draw():
    pyxel.cls(0)
    pyxel.camera(-64,-64)
    pyxel.circ( x, y, 3, 7)
    return

pyxel.run(update,draw)

実行結果
 

 京都大学が公開しているテキストには,アナログ時計を表示するアプリや波のグラフ描画などで三角関数を利用する例が記載されています。
 ゲーム開発に役立つなら数学の勉強も頑張れる,という人もいるかもしれないので,やや難しい範囲の内容ですが紹介しました。sin()命令を利用するだけならすぐにできると思うので,チャレンジしてみてください。
 
 

Webブラウザでのデモ

 Pyxel demo Pyxel Shooter r(通常版)
 Pyxel demo Pyxel Shooter r sin()(sin()命令使用版)

 
<次の記事>
ソースファイルの行数が増えてきたので,複数のファイルに分割する例を紹介します。
 Pyxel ソースファイルを分ける - 勉強ボックス管理者ブログ

 

関連記事

 kinutani.hateblo.jp