Pyxel/Pythonで寿司が回るシューティングゲーム作ってみた

# 概要
Pyxelで寿司が回るシューティングゲームを作ったので
こんなの作ったよ、こんな風に書いてみたよという紹介記事です。

作ったゲームは小学生の娘たちが遊んでくれました。やったぜ。
![スクリーンショット 2023-03-31 22.07.40.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/151818/a22f143b-e76c-e969-d4a7-bd87418cc0cc.jpeg)



# 作ったもの（リンク先から遊べます）
作例として投稿を勧められ、ゲームエンジンの開発者様に紹介いただきました。
嬉しい！

https://twitter.com/kitao/status/1631546727961612288

**◆ゲームルール**
画面右から現れる寿司ネタにシャリをぶつけると得点し
対応する寿司がプレイヤーを周回するようになる。
全種類揃えると周回する寿司が消え、追加得点。
敵から被弾すると体力が減り、０になるとゲームオーバー。
回復パネル（ハート）に触れると体力が１回復。
加速パネル（お茶）に触れると移動速度が一定時間UP。


**◆操作方法**
**＞メニュー画面**
左右キー：モード選択
（INVINCIBLE：無敵状態、NORMAL：通常モード、HARD：ちょっと速度が上がります）
Enter：決定

**＞ゲーム画面**
上下左右キー：移動
スペースキー：シャリ投げます
Vキー：ホーミング撃ちます

# ソース

こちらに公開しています。

https://github.com/sejiseji/pyxel-sushi

本体である　sushishooter.py　と、
プレイヤーの周囲を３Dで周回する寿司の回転のための　pyquaternion.py
によって構成されています。後者はこちらを使用しました。

https://github.com/KieranWynn/pyquaternion

# コード内容について
sushishooter.pyですが、中身一切整理してないですね。すみません。
基本的にはシューティングのサンプルソースを横型にしてアレコレ付け足した感じです。

## サンプルソースへの＋α
付け足し項目は以下のような感じです。
概ね上から順番に行ったと思います。

* 縦型を横型にしてみる。
* 回転する寿司のピクセル画を描いてみる。
* DRAWの記述方法を真似て、
自分で描いたピクセルを描画してみる。
（回転する寿司オブジェクトを描画してみる）
* 寿司オブジェクトを画面内で横移動させてみる。
* 寿司オブジェクトをSin関数で跳ねさせてみる。
* 複数種類の寿司オブジェクトを用意してみる。
* 寿司オブジェクト達をある座標を中心に円状に並べてみる。
（<INS>総数と並び順序番号を元に複素平面上の回転計算を行う</INS>）
* 寿司オブジェクトをある座標を中心に周回させてみる。
（<INS>複素回転の初期位置計算・更新を行うクラスを継承させる</INS>）
* 周回の中心点をキャラクターの座標に差し替えてみる。
（プレイヤーの画像も寿司同様に回転数分描いてみる）
* 上下左右キーでの移動を中心オブジェクトに適用してみる。
* 画面外に飛び出さないよう判定をつけてみる。
* 画面右奥から流れてくる敵性オブジェクトを実装してみる。
* 敵性オブジェクトの定期的生成を実装してみる。
* ２D回転する周回寿司を引き連れたプレイヤーオブジェクトが
任意キー打鍵に応じてシャリ弾を撃つようにしてみる。
* 弾射出時の効果音を作成し、打鍵時に再生してみる。
* シャリ弾と敵性オブジェクトの当たり判定をつけてみる。
* 着弾時の効果音を作成し、当たり判定時に再生してみる。
* プレイヤーに体力を設定し、プレイヤーと敵性オブジェクトの
当たり判定をつけてみる。
* 被弾直後数フレーム間の点滅および当たり判定無効時間を
　設定してみる。
* 被弾時の効果音を作成し、当たり判定時に再生してみる。
* 敵性オブジェクトに醤油さしを追加し、醤油差しから
醤油弾を撃たせるようにしてみる。
* プレイヤーと醤油弾の当たり判定をつけてみる。
* 被弾時の効果音再生を醤油弾当たり判定時にも追加してみる。
* 着弾時に衝撃波オブジェクトの生成自壊を加えてみる。
* 周回する寿司に存在フラグを設け、対応する全敵性ネタの
シャリとの当たり判定が有効になったときにONにしてみる。
（対応ネタを撃つと寿司が出現するようになる）
* 全ての周回寿司の存在フラグがONになったときに
全寿司の存在フラグをOFFにし、衝撃波オブジェクトに
　光の種別を設けて寿司位置に出現させるようにしてみる。
* 寿司を全て揃えるとフラグをたて、プレイヤーの描画を変化させてみる。
（羽根フラグfeather_flgを有効にし、翼を描いたキャラクタに切り替える）
* ダメージを受けると羽根フラグが外れるようにしてみる。
* 光の生存時間をアニメーション枚数と合わせ、自壊させてみる。
* スコアを実装し、画面上に表示させてみる。
* 当たり判定箇所に、敵性オブジェクトのネタ種別に応じて
加点数を分けるようにしてみる。
* 羽根フラグが有効な場合、加点が倍になるようにしてみる。
（フラグを０／１で管理していれば、係数１にフラグを加えればよい）
* 流れる背景を設定してみる。
（タイルマップの作成）
* シャリ射出時、５回に一度、星弾が出るようにしてみる。
* 星弾と敵性オブジェクトの当たり判定をつけてみる。
（星の自壊を実装しないことで、貫通弾のような挙動にする）
* <INS>画面上の最も近い敵性オブジェクトに向かうホーミング弾を実装してみる。</INS>
* ホーミング弾用の着弾時衝撃波アニメーションを追加してみる。
* ホーミング弾に軌跡を描画させてみる。
（自身の位置情報を一定数保持し、各点を指定色の直線で繋いでみる）
* 敵性オブジェクトもホーミング弾を撃てるようにしてみる。
* 回復アイテムが流れてくるようにしてみる。
* 回復アイテムとプレイヤーの当たり判定をつけてみる。
（当たり判定時に体力が増加するようにしてみる）
* 加速アイテムが流れてくるようにしてみる。
* 加速アイテムとプレイヤーの当たり判定をつけてみる。
（当たり判定時にプレイヤーの移動増分を加算してみる）
* <INS>周回する寿司が３次元回転するようにしてみる</INS>
（四元数を実装したMITLicenseなpyquaternion.pyを利用する）
* スタート画面、ゲーム画面、ゲームオーバー画面に分けてみる。
* ゲームモード選択をつけてみる。
* 当たり判定にフラグを設け、無敵プレイモード選択時の切替を試してみる。
* ゲームモードによって敵が射出する弾数を増減させてみる。
* ゲームオーバー画面で、撃破数・取得数などを表示してみる。
* スタート画面で、キーストロークとゲーム内容の説明を表示してみる。
* スタート画面内で、寿司が左から右へ流れるようにしてみる。
* ゲームオーバー時、ハイスコアを記録するよう追加してみる。
* ハイスコア更新時のテキスト表示を変更してみる。
* ボスキャラとして寿司桶を追加してみる。
* 寿司桶がイクラ弾、ホーミング弾を発射できるようにしてみる。
* 寿司桶とプレイヤー、各種弾との当たり判定を追加してみる。
* 寿司桶のホーミング弾の弾頭をバランにしてみる。
* バランの軌跡（複数）を描くようにしてみる。
* 背景に、一定時間ごとにSinカーブでせり上がってくる大波を追加してみる。


記事にするにあたって何を掘って書くか悩んだのですが、
移動の方法や描画のアレコレは先人のPyxel記事がよくまとまっていますし、
当たり判定などはサンプルソースを読み解くのが一番良いと思っています。

このゲームではとにかく寿司を回したかったので、回転に絡む
下線をつけた項目についてだけ、次項で解説してみたいと思います。



# 解説してみます
◆回転◆
* 寿司オブジェクト達をある座標を中心に円状に並べてみる。
（総数と並び順序番号を元に複素平面上の回転計算を行う）
* 寿司オブジェクトをある座標を中心に周回させてみる。
（複素回転の初期位置計算・更新を行うクラスを継承させる）
* 周回する寿司が３次元回転するようにしてみる
（四元数を実装したMITLicenseなpyquaternion.pyを利用する）

◆ホーミング◆
* 画面上の最も近い敵性オブジェクトに向かうホーミング弾を実装してみる。
（回転・・・というか進行角度をくるくると更新し続ける弾の機構ですね）

### 複素平面上の回転計算を用い円状に並ぶ座標を求める
まず回転から。
寿司を円状に並べる際、複素平面上での回転計算を行っています。

プレイヤーを周回する寿司はAppクラス内で周回の中心点となるプレイヤー（Miku）を生成後、７種類予め用意しています。

```python:sushishooter.py
class App:
    def __init__(self): # 初期化
        ....
        (省略)

    def init(self):
        pyxel.load("sushishooter.pyxres")
        ....
        (省略)

        self.miku = MIKU(100, 100, False, 16, 1, 1, 0, ACCELERATED_SPEED)
        if(self.game_mode == HARD_MODE):
            self.miku.addspeed = 2
        
        # Mikuを周回するSushi衛星を準備。回転の中心点は正方形Miku画像の1辺サイズの半分を基準点とする。
        # 引数：寿司ネタ種別, 回転の基準点X, 回転の基準点Y, 衛星振舞い（回転）フラグ、基準点からの距離, 編隊数, 編隊内の順序, 3d回転フラグ
        # 寿司ネタ種別 0:まぐろ 1:はまち 2:たまご 3:とろ軍艦 4:サーモン 5:えび 6:さんま
        self.sushiset_r = []
        for i in range(7):
            self.sushiset_r.append(SUSHI(i, self.miku.x, self.miku.y, True, SATELLITE_RADIUS, 7, (i + 1), self.miku.size/2, FLG_SATELITE_3D_ROTATE))

....
```
SUSHIは衛星の挙動を定義したSATELLITEを継承しており、
SATELLITEで定義した初期位置セットを行うことになります。
これが、指定の座標を中心に円状に並ぶ衛星座標を定めています。

```python:sushishooter.py
class SUSHI(SATELLITE):
    # 初期化
    def __init__(self, neta, base_x, base_y, flg_rot, radius, sat_num, order, center_adjust, flg_3d):
        self.FLG_ROT = flg_rot
        # 回転フラグがTrueの場合衛星としての振舞いを有効にする。
        # そうでない場合は引数座標を初期位置座標として用いる。
        if (self.FLG_ROT):
            super().__init__(base_x, base_y, radius, sat_num, order, center_adjust, flg_3d)
        else:
            self.x = base_x
            self.y = base_y
        self.NETA = 16 * neta # 0:まぐろ 1:はまち 2:たまご 3:とろ軍艦 4:サーモン 5:えび 6:さんま
....
```


衛星クラスが以下。
（長いので一旦すっ飛ばして下さい）

```python:sushishooter.py
class SATELLITE(GameObject):
    def __init__(self, base_x, base_y, radius, sat_num, order, center_adjust, flg_3d):
        super().__init__()
        self.radius = radius # 周回半径
        self.ddeg = 16 # 周回時偏角固定
        self.drad = math.radians(self.ddeg)
        # 3次元回転での回転計算をする際に用いるz軸の仮値
        self.z = 0
        # 描画時の順序。3d回転を行わない平常時は0
        self.draw_index = 0
        # 3次元回転フラグ
        self.flg_3d = flg_3d
        if(self.flg_3d):
            # 3D回転でどんな軸周りに何ラジアン回転するか規定したQuaternionを生成。
            self.quat = Quaternion(
                axis  = [AXIS_3D_X, AXIS_3D_Y, AXIS_3D_Z], # 回転に使用するxyz3軸を設定。
                angle = math.pi * 0.1 # 回転角度を設定しラジアン値を用いる。
            )
            # 初期配置用
            self.quat_init = Quaternion(
                axis  = [AXIS_3D_X_INIT, AXIS_3D_Y_INIT, AXIS_3D_Z_INIT], # 回転に使用するxyz3軸を設定。
                angle = math.pi * pyxel.rndf(0.04, 0.08) # 回転角度を設定しラジアン値を用いる。
            )
        # 回転基準位置
        self.BASE_X = base_x
        self.BASE_Y = base_y
        # 初期位置は必ず2次元座標上での回転計算を用いてXY平面上に展開する
        self.initposition(base_x, base_y, radius, sat_num, order, center_adjust)
        # 回転後位置の初期化（一旦、初期位置）
        self.rotated_X = self.x
        self.rotated_Y = self.y
        self.rotated_Z = self.z
    def initposition(self, base_x, base_y, radius, sat_num, order, center_adjust):
        # ≫回転の基準となるx,y座標パラメタとそこからの距離radius,予定編隊衛星数sat_num,
        #   そのうちの何番目かorderを受け取って衛星1個自身の初期位置を決める。
        # 編隊位置計算のため偏角算出
        self.initdeg = math.ceil(360/sat_num) * (order - 1) # order数1のときX軸方向に延伸した位置からの角度ゼロ位置。
        self.initrad = math.radians(self.initdeg)
        # 複素平面上でinitradの回転実施
        self.rot_origin_polar = base_x + base_y * 1j
        self.rot_target_polar = (base_x + radius + center_adjust) + (base_y + center_adjust) * 1j
        self.rotated = (self.rot_target_polar - self.rot_origin_polar) * math.e ** (1j * self.initrad) \
                        + self.rot_origin_polar
        # 実部虚部を取り出して衛星のXY座標とする
        self.x = self.rotated.real
        self.y = self.rotated.imag

        if(self.flg_3d):
            self.init_quaternion_rotate()

    def update(self): # フレームの更新処理
        # 回転先の座標を計算して位置情報を更新
        self.rotate()
        self.x = self.rotated_X
        self.y = self.rotated_Y
        self.z = self.rotated_Z
    def baseupdate(self, base_x, base_y): 
        # 回転の基準点を引数の値に更新
        self.BASE_X = base_x
        self.BASE_Y = base_y   
    def rotate(self):
        # 周回後の位置を決定する.
        # 3次元回転をするか否かのフラグ状態で挙動を切り替え。
        if(self.flg_3d):
            self.quaternion_rotate()
        else:
            self.complex_rotate()
    def complex_rotate(self):
        # # 複素平面上の実部・虚部の関係性に置き換えて回転角dradで回転後の座標を計算する
        # # 回転前の座標に対応する極座標
        #   （衛星が周回する中心点から半径radiusだけX軸方向に進んだ座標）
        self.rot_target = (self.x + self.y * 1j)
        # # ★回転の基準となる直交座標で作る極座標
        self.rot_origin = (self.BASE_X + self.BASE_Y * 1j) 
        # 基準点を中心に回転した後の極座標
        self.rotated =  (self.rot_target - self.rot_origin) * math.e ** (1j * self.drad) + self.rot_origin
        # 複素平面の実部・虚部が回転後の直交座標に対応
        self.rotated_X = self.rotated.real # 実部係数
        self.rotated_Y = self.rotated.imag # 虚部係数
    def quaternion_rotate(self):
        # 3次元回転の計算と描画順序層の更新。
        # 回転後のx,yを取得
        [self.rotated_X, self.rotated_Y, self.rotated_Z] = self.quat.rotate([self.x - self.BASE_X, self.y - self.BASE_Y, self.z])
        self.rotated_X += self.BASE_X
        self.rotated_Y += self.BASE_Y
        # zは圧縮して描画順序の更新に利用する.
        self.draw_index = 1 if self.z > 0 else -1
    def init_quaternion_rotate(self):
        # 3次元回転の計算と描画順序層の更新。
        # 回転後のx,yを取得
        [self.rotated_X, self.rotated_Y, self.rotated_Z] = self.quat_init.rotate([self.x, self.y - self.BASE_Y, self.z])
        self.rotated_X += self.BASE_X
        self.rotated_Y += self.BASE_Y
        # zは圧縮して描画順序の更新に利用する.
        self.draw_index = 1 if self.rotated_Z > 0 else -1
```

衛星クラスでは後述する３次元回転も定義しているのですが、
ここでは関係ないので一旦読み飛ばします。
円状に配置しているのはinitpositionです。


```python:sushishooter.py
class SATELLITE(GameObject):
    ....
    (省略)

    def initposition(self, base_x, base_y, radius, sat_num, order, center_adjust):
        # ≫回転の基準となるx,y座標パラメタとそこからの距離radius,予定編隊衛星数sat_num,
        #   そのうちの何番目かorderを受け取って衛星1個自身の初期位置を決める。
        # 編隊位置計算のため偏角算出
        self.initdeg = math.ceil(360/sat_num) * (order - 1) # order数1のときX軸方向に延伸した位置からの角度ゼロ位置。
        self.initrad = math.radians(self.initdeg)
        # 複素平面上でinitradの回転実施
        self.rot_origin_polar = base_x + base_y * 1j
        self.rot_target_polar = (base_x + radius + center_adjust) + (base_y + center_adjust) * 1j
        self.rotated = (self.rot_target_polar - self.rot_origin_polar) * math.e ** (1j * self.initrad) \
                        + self.rot_origin_polar
        # 実部虚部を取り出して衛星のXY座標とする
        self.x = self.rotated.real
        self.y = self.rotated.imag

    ....
    (省略)
```


３６０度を衛星総数sat_numで割り、隣り合う衛星が中心点と成す角度を求めます。
ここから、各衛星が０角度位置から始める先頭衛星からどの程度角度を成していればいいのかは、衛星に与えた並び順orderから明らかです。

まず確定した偏角を弧度initradへ変換。
次に中心点として指定した座標（base_x, base_y）から起こした複素数rot_origin_polarを用意。
さらに、<INS>偏角を加味する前の</INS>当該衛星自身を表すrot_target_polarを中心点から実軸方向に周回半径radius分だけ延伸した複素数として一旦定義します。
あとは求めた角度で複素平面上での基準となる１点を中心とした回転計算、すなわち
α：回転対象複素数rot_target_polar, β：回転の中心複素数rot_origin_polar, 
虚数単位：i, 回転角：θ　について

$（α - β） * e^{iθ}　+ β$ 

を計算し、<INS>偏角を加味した</INS>、真に当該衛星を示す複素数を取得します。
最後に計算後の実部と虚部をそれぞれ画面上でのX座標Y座標として取り出して完了です。

計算の仕組みは複素数の指数関数や複素関数論の基礎なんかでググったりPDF読んだり本買ったりして勉強してみて下さい。

これを順序パラメタを持つ各衛星で実行することで、指定の座標を中心として円状にN個配置される際の各々の座標を求めていけます。


### 複素回転の更新を行う

初期位置が定められれば回転は簡単です。
Appのupdate内で、常に移動し得る衛星回転の中心点を更新しつつ（update_base）、前項と同様の複素平面上の回転計算→実部虚部取得により現在地を更新します(update_torot)。


```python:sushishooter.py
class SUSHI(SATELLITE):
    ....
    (省略)

    def update_base(self, base_x, base_y):
            # 回転の基準点を引数の内容に更新
        if (self.FLG_ROT):
            super().baseupdate(base_x, base_y) 
    def update_torot(self):
            # 周回後座標を計算して現在地を更新する
        if (self.FLG_ROT):
            super().update()
```
定義はSATELLITEにて。
座標にｚがありますが、２D表現では本来不要です。
後述の３次元回転で描画順序に用いています。

```python:sushishooter.py
class SATELLITE(GameObject):
    ....
    (省略)

    def update(self): # フレームの更新処理
        # 回転先の座標を計算して位置情報を更新
        self.rotate()
        self.x = self.rotated_X
        self.y = self.rotated_Y
        self.z = self.rotated_Z
    def baseupdate(self, base_x, base_y): 
        # 回転の基準点を引数の値に更新
        self.BASE_X = base_x
        self.BASE_Y = base_y   
    def rotate(self):
        # 周回後の位置を決定する.
        # 3次元回転をするか否かのフラグ状態で挙動を切り替え。
        if(self.flg_3d):
            self.quaternion_rotate()
        else:
            self.complex_rotate()
    def complex_rotate(self):
        # # 複素平面上の実部・虚部の関係性に置き換えて回転角dradで回転後の座標を計算する
        # # 回転前の座標に対応する極座標
        #   （衛星が周回する中心点から半径radiusだけX軸方向に進んだ座標）
        self.rot_target = (self.x + self.y * 1j)
        # # ★回転の基準となる直交座標で作る極座標
        self.rot_origin = (self.BASE_X + self.BASE_Y * 1j) 
        # 基準点を中心に回転した後の極座標
        self.rotated =  (self.rot_target - self.rot_origin) * math.e ** (1j * self.drad) + self.rot_origin
        # 複素平面の実部・虚部が回転後の直交座標に対応
        self.rotated_X = self.rotated.real # 実部係数
        self.rotated_Y = self.rotated.imag # 虚部係数
```

ちなみに、先日GPT4にUnityみたいなQuaternion回転が基本のゲームエンジンで複素数計算で回転を書くのはどうなの？って聞いてみたらあんまり効率的じゃないですねって言われました。ですよね。

### 周回する寿司が３次元回転するようにしてみる
SATELLITEでは３次元回転も定義しており、実際ゲーム内ではこちらが動いてます。
これには四元数Quaternionを実装したMITLicenseなpyquaternion.pyを利用しています。

__init__内でまず、Quaternionを２つ生成。
update時に回し続ける軸と回転角を定めたquatと、
円状に配置した寿司をちょっとだけ３次元回転で傾けるためのquat_initです。こちらは回転角を微量なランダム値にしています。

```python:sushishooter.py
## 回転更新に用いる回転軸
AXIS_3D_X = -1
AXIS_3D_Y = 1
AXIS_3D_Z = -1
## 初期位置の平面→3次元平面への座標セットに用いる回転軸
AXIS_3D_X_INIT = -1
AXIS_3D_Y_INIT = 1
AXIS_3D_Z_INIT = -1

class SATELLITE(GameObject):
    def __init__(self, base_x, base_y, radius, sat_num, order, center_adjust, flg_3d):
        super().__init__()
        self.radius = radius # 周回半径
        self.ddeg = 16 # 周回時偏角固定
        self.drad = math.radians(self.ddeg)
        # 3次元回転での回転計算をする際に用いるz軸の仮値
        self.z = 0
        # 描画時の順序。3d回転を行わない平常時は0
        self.draw_index = 0
        # 3次元回転フラグ
        self.flg_3d = flg_3d
        if(self.flg_3d):
            # 3D回転でどんな軸周りに何ラジアン回転するか規定したQuaternionを生成。
            self.quat = Quaternion(
                axis  = [AXIS_3D_X, AXIS_3D_Y, AXIS_3D_Z], # 回転に使用するxyz3軸を設定。
                angle = math.pi * 0.1 # 回転角度を設定しラジアン値を用いる。
            )
            # 初期配置用
            self.quat_init = Quaternion(
                axis  = [AXIS_3D_X_INIT, AXIS_3D_Y_INIT, AXIS_3D_Z_INIT], # 回転に使用するxyz3軸を設定。
                angle = math.pi * pyxel.rndf(0.04, 0.08) # 回転角度を設定しラジアン値を用いる。
            )
```

quat_initを用いた３次元回転は、寿司を円形に配置するinitpositionの最後で
3D回転フラグを有効にしていた場合に実行されます。

Quaternion回転の後に、描画時順序draw_indexをZが正なら1,それ以外は-1で指定しています。
これはプレイヤーが常にｚ＝０なxy平面に位置しているわけですが、寿司衛星は３次元回転によりその平面から重なりが常にズレます。描画時の自然な重なりのために、オブジェクトのdraw順序を３層に分割（プレイヤーより奥(z<0)の衛星→プレイヤーのいるxy平面(z=0)→プレイヤーより手前(z>0)の衛星）する際の指標として設定している処置になります。



```python:sushishooter.py

class SATELLITE(GameObject):
    ....
    def initposition(self, base_x, base_y, radius, sat_num, order, center_adjust):
        # ≫回転の基準となるx,y座標パラメタとそこからの距離radius,予定編隊衛星数sat_num,
        #   そのうちの何番目かorderを受け取って衛星1個自身の初期位置を決める。
        # 編隊位置計算のため偏角算出
        self.initdeg = math.ceil(360/sat_num) * (order - 1) # order数1のときX軸方向に延伸した位置からの角度ゼロ位置。
        self.initrad = math.radians(self.initdeg)
        # 複素平面上でinitradの回転実施
        self.rot_origin_polar = base_x + base_y * 1j
        self.rot_target_polar = (base_x + radius + center_adjust) + (base_y + center_adjust) * 1j
        self.rotated = (self.rot_target_polar - self.rot_origin_polar) * math.e ** (1j * self.initrad) \
                        + self.rot_origin_polar
        # 実部虚部を取り出して衛星のXY座標とする
        self.x = self.rotated.real
        self.y = self.rotated.imag

        if(self.flg_3d):
            self.init_quaternion_rotate()

    ....

    def init_quaternion_rotate(self):
        # 3次元回転の計算と描画順序層の更新。
        # 回転後のx,yを取得
        [self.rotated_X, self.rotated_Y, self.rotated_Z] = self.quat_init.rotate([self.x, self.y - self.BASE_Y, self.z])
        self.rotated_X += self.BASE_X
        self.rotated_Y += self.BASE_Y
        # zは圧縮して描画順序の更新に利用する.
        self.draw_index = 1 if self.rotated_Z > 0 else -1

....

class App:
    def draw_play_scene(self):

        # mikuを周回する寿司衛星を描画する（描画階層index = -1）
        for i in range(len(self.sushiset_r)):
            if (self.sushiset_r[i].exists and self.sushiset_r[i].draw_index == -1):
                self.sushiset_r[i].draw_circle()

        # mikuを描画する（描画階層index = 0）
        self.miku.draw_circle()

        # mikuを周回する寿司衛星を描画する（描画階層index = 1）
        for i in range(len(self.sushiset_r)):
            if (self.sushiset_r[i].exists and self.sushiset_r[i].draw_index == 1):
                self.sushiset_r[i].draw_circle()
```

updateも同様です

```python:sushishooter.py
class SATELLITE(GameObject):
    ....
    def update(self): # フレームの更新処理
        # 回転先の座標を計算して位置情報を更新
        self.rotate()
        self.x = self.rotated_X
        self.y = self.rotated_Y
        self.z = self.rotated_Z
    def baseupdate(self, base_x, base_y): 
        # 回転の基準点を引数の値に更新
        self.BASE_X = base_x
        self.BASE_Y = base_y   
    def rotate(self):
        # 周回後の位置を決定する.
        # 3次元回転をするか否かのフラグ状態で挙動を切り替え。
        if(self.flg_3d):
            self.quaternion_rotate()
        else:
            self.complex_rotate()
    ....
    def quaternion_rotate(self):
        # 3次元回転の計算と描画順序層の更新。
        # 回転後のx,yを取得
        [self.rotated_X, self.rotated_Y, self.rotated_Z] = self.quat.rotate([self.x - self.BASE_X, self.y - self.BASE_Y, self.z])
        self.rotated_X += self.BASE_X
        self.rotated_Y += self.BASE_Y
        # zは圧縮して描画順序の更新に利用する.
        self.draw_index = 1 if self.z > 0 else -1

```


### 画面上の最も近い敵性オブジェクトに向かうホーミング弾を実装してみる。
LASERクラスです。
これ、実は思うような挙動になってなかったりする。
でも遊べるし動いてるからこのまま行こうぜ！って心に飼ってるおじさんが言うので放流しました。

laserなんだかHomingなんだかはっきりしろという感じですが、
ソースではlaserです。
敵だけじゃなく敵が放ってくるLASER弾も距離を測って追撃しています。

```python:sushishooter.py
class LASER():
    # 初期化
    def __init__(self, x, y, target_x, target_y, addspeed, bullet_kind):
        self.x = x
        self.y = y
        self.target_x = target_x
        self.target_y = target_y
        self.addspeed = addspeed
        # 自機弾か敵弾か
        self.bullet_kind = bullet_kind
        # 弾頭画像に基づくサイズ
        if(bullet_kind == 2):
            self.w, self.h = BALAN_WIDTH, BALAN_HEIGHT
        else:
            self.w, self.h = LASER_WIDTH, LASER_HEIGHT
        self.pre_shotangle = 0
        self.shot_angle = 0
        self.angle_uv = 0
        self.dx, self.dy = (0, 0)
        self.ux, self.uy = (0, 0)
        self.vx, self.vy = (0, 0)
        self.du, self.dv = (0, 0)
        self.nearest_x, self.nearest_y = pyxel.width, self.y
        # 速度の指定
        self.speed = LASER_SPEED + self.addspeed
        # 軌跡描画用の座標記録配列
        self.trajectory_point = []
        self.trajectory_point.append([self.x, self.y])

        ### -------draw用の射出方向決定処理
        # 自機弾か敵弾かでターゲットを決める
        if (self.bullet_kind == 0):
            # 最も近い敵性オブジェクトを判定しターゲット座標に指定する
            self.update_nearest_obj()
        if (self.bullet_kind == 1 or self.bullet_kind == 2):
            # mikuの座標を指定する
            self.target_x, self.target_y =  miku_xy[0][0], miku_xy[0][1]
        self.update_targetlock()
        self.is_alive = True
        if (self.bullet_kind == 0):
            lasers.append(self)
        if (self.bullet_kind == 1 or self.bullet_kind == 2):
            lasers_enemy.append(self)

    def update(self):
        # 従前の角度を退避
        pre_angle = self.angle_uv
        # 自機より後方の敵弾は従前の角度を保って飛ぶ
        if (self.bullet_kind == 1 or self.bullet_kind == 2) and (self.x <= self.target_x):
            # if (not(pre_angle == 0)):
            self.angle_uv = pre_angle
        else:        
            # 自機弾か敵弾かで何をターゲットにして進行方向を決定するか決める
            # >>自機弾
            if (self.bullet_kind == 0):
                # 最も近い敵性オブジェクトを判定しターゲット座標に指定する
                self.update_nearest_obj()            
            # >>敵弾
            if (self.bullet_kind == 1 or self.bullet_kind == 2):
                # mikuの座標を指定する
                self.target_x, self.target_y = miku_xy[0][0], miku_xy[0][1]
            # ターゲット座標とレーザーの現在座標から進行方向角度を更新する
            if((self.bullet_kind == 0) or (self.bullet_kind == 1 and self.target_x < self.x) or (self.bullet_kind == 2 and self.target_x < self.x)):
                self.update_targetlock() 
        
        # Speedと進行方向角度に基づいて進行、レーザー座標を更新する
        self.update_xy()

    def check_nearest_forward_obj_axis(self, list):
        # 指定のリストオブジェクトのうち最も近いものの座標と距離を返す
        # 返却用座標変数・返却用距離変数の初期値として画面端と画面幅をセット
        rtn_x, rtn_y = pyxel.width, self.y
        rtn_distance = pyxel.width
        for elem in list:
            # 存在フラグが有効なもの　かつ　攻撃対象がX軸方向について前方にあるもの  について距離を測る
            if (elem.is_alive and elem.x >= self.x):
                distance = math.sqrt((elem.x - self.x)**2 + (elem.y - self.y)**2)
                # 計算結果が保持中の評価用距離変数以下の場合、返却用座標と返却用距離を更新する
                if (distance <= rtn_distance):
                    (rtn_x, rtn_y) = (elem.x, elem.y)
                    rtn_distance = distance
        return (rtn_x, rtn_y, rtn_distance)

    def update_nearest_obj(self):
            # 攻撃可能オブジェクトリストからそれぞれ最も近い座標を取得し、それらのターゲットとする
            tmp_x1, tmp_y1, distance1 = self.check_nearest_forward_obj_axis(sushineta)
            tmp_x2, tmp_y2, distance2 = self.check_nearest_forward_obj_axis(shoyu)
            tmp_x3, tmp_y3, distance3 = self.check_nearest_forward_obj_axis(shoyu_bullets)
            tmp_x4, tmp_y4, distance4 = self.check_nearest_forward_obj_axis(lasers_enemy)
            tmp_x5, tmp_y5, distance5 = self.check_nearest_forward_obj_axis(sushioke)
            # distance1 = math.sqrt((tmp_x1 - self.x)**2 + (tmp_y1 - self.y)**2)
            # distance2 = math.sqrt((tmp_x2 - self.x)**2 + (tmp_y2 - self.y)**2)
            # distance3 = math.sqrt((tmp_x3 - self.x)**2 + (tmp_y3 - self.y)**2)
            # distance4 = math.sqrt((tmp_x4 - self.x)**2 + (tmp_y4 - self.y)**2)
            # distance5 = math.sqrt((tmp_x5 - self.x)**2 + (tmp_y5 - self.y)**2)
            distance = min(distance1, distance2, distance3, distance4, distance5)
            # 最も近い敵性オブジェクト迄の距離が一定値以下になると、自身を加速させる
            if (distance <= 15):
                self.speed += 1
            # 最も近い敵性オブジェクトの座標をnearestな座標として保持する
            if  (distance == distance1):
                self.nearest_x, self.nearest_y = tmp_x1, tmp_y1
            elif(distance == distance2):
                self.nearest_x, self.nearest_y = tmp_x2, tmp_y2
            elif(distance == distance3):
                self.nearest_x, self.nearest_y = tmp_x3, tmp_y3
            elif(distance == distance4):
                self.nearest_x, self.nearest_y = tmp_x4, tmp_y4
            else:
                self.nearest_x, self.nearest_y = tmp_x5, tmp_y5
            self.target_x, self.target_y = self.nearest_x, self.nearest_y

    def update_targetlock(self):
        # shotangleuvを退避
        self.pre_shotangle = self.angle_uv
        # 弾→標的ベクトルd(dx, dy)
        self.update_dxdy(self.x, self.y, self.target_x, self.target_y)
        # 射出角度
        self.update_shotangle(self.dx, self.dy)
        # uv座標系での角度を求める
        self.update_uvaxis_shotangle()
        # 進行方向に標的が位置している場合、値は0となる

    def update_uvaxis_shotangle(self):
        ### -------射出方向判定用のuv直交系の計算 
        # 弾の進行方向u(ux,uy)
        self.update_vector_u(self.speed, self.shot_angle)
        # uに直交するベクトルv
        self.update_vector_v(self.ux, self.uy)
        # uv座標系で弾→標的ベクトルduv（du, dv）
        self.update_dudv(self.dx, self.dy, self.ux, self.uy, self.vx, self.vy)
        # uv座標系での進行方向angle_uv
        self.update_angleuv(self.du, self.dv)

    def update_vector_u(self, speed, angle):
        self.ux = speed * pyxel.cos(angle)
        self.uy = speed * pyxel.sin(angle)
    def update_vector_v(self, ux, uy):
            self.vx = uy
            self.vy = -ux
    def update_dudv(self, dx, dy, ux, uy, vx, vy):
        if self.bullet_kind == 0:
            self.du = dx * ux + dy * uy
            self.dv = dx * vx + dy * vy
        if (self.bullet_kind == 1 or self.bullet_kind == 2):
            self.du = -(dx * ux + dy * uy)
            self.dv = -(dx * vx + dy * vy)
    def update_angleuv(self, du, dv):
        self.angle_uv = pyxel.atan2(du, dv)
    def update_dxdy(self,  x, y, target_x, target_y):
        self.dx = target_x - x
        self.dy = target_y - y
    def update_shotangle(self, dx, dy):
        self.shot_angle = pyxel.atan2(dx, dy)
    def update_check_upperangle(self, pre_angle):
        if((self.angle_uv > math.pi * 1/5)):
            self.angle_uv = pre_angle

    def update_xy(self):
        # 極座標計算によるXY座標系の位置更新
        self.x += pyxel.ceil(self.speed * pyxel.cos(self.angle_uv))
        self.y += pyxel.ceil(self.speed * pyxel.sin(self.angle_uv))
        # 軌跡を描画するために座標履歴を保持する
        if (len(self.trajectory_point) == 5):
            self.trajectory_point.pop(0)
        self.trajectory_point.append([self.x, self.y])
        # 画面左右端を超えたとき、存在フラグを折る
        if self.x > pyxel.width:
            self.is_alive = False
        if self.x < 0:
            self.is_alive = False
        # 画面上下を10pixel超えると存在フラグを折る
        if self.y < - 10:
            self.is_alive = False
        if self.y > pyxel.height + 10:
            self.is_alive = False

    def draw(self):
        # 自機弾
        if (self.bullet_kind == 0):
            # レーザー弾の軌跡を描く
            if (len(self.trajectory_point)>=5):
                pyxel.line(self.trajectory_point[3][0], self.trajectory_point[3][1], self.trajectory_point[4][0], self.trajectory_point[4][1], 7)
            if (len(self.trajectory_point)>=4):
                pyxel.line(self.trajectory_point[2][0], self.trajectory_point[2][1], self.trajectory_point[3][0], self.trajectory_point[3][1], 6)
            if (len(self.trajectory_point)>=3):
                pyxel.line(self.trajectory_point[1][0], self.trajectory_point[1][1], self.trajectory_point[2][0], self.trajectory_point[2][1], 12)
            if (len(self.trajectory_point)>=2):
                pyxel.line(self.trajectory_point[0][0], self.trajectory_point[0][1], self.trajectory_point[1][0], self.trajectory_point[1][1], 5)
            # レーザー弾の弾頭を描く
            pyxel.blt(self.x - 2, self.y - 2, 1, 0, 160 + 5*(pyxel.frame_count % 4), 5, 5, 0)
        # 敵弾
        if (self.bullet_kind == 1):
            if (len(self.trajectory_point)>=5):
                pyxel.line(self.trajectory_point[3][0], self.trajectory_point[3][1], self.trajectory_point[4][0], self.trajectory_point[4][1], 7)
            if (len(self.trajectory_point)>=4):
                pyxel.line(self.trajectory_point[2][0], self.trajectory_point[2][1], self.trajectory_point[3][0], self.trajectory_point[3][1], 14)
            if (len(self.trajectory_point)>=3):
                pyxel.line(self.trajectory_point[1][0], self.trajectory_point[1][1], self.trajectory_point[2][0], self.trajectory_point[2][1], 8)
            if (len(self.trajectory_point)>=2):
                pyxel.line(self.trajectory_point[0][0], self.trajectory_point[0][1], self.trajectory_point[1][0], self.trajectory_point[1][1], 4)
            pyxel.blt(self.x - 2, self.y - 2, 1, 5, 160 + 5*(pyxel.frame_count % 4), 5, 5, 0)
        # 敵弾（バラン弾頭）
        if (self.bullet_kind == 2):
            if (len(self.trajectory_point)>=5):
                pyxel.line(self.trajectory_point[3][0], self.trajectory_point[3][1],   self.trajectory_point[4][0], self.trajectory_point[4][1],   7)
                pyxel.line(self.trajectory_point[3][0], self.trajectory_point[3][1]+1, self.trajectory_point[4][0], self.trajectory_point[4][1]+1, 3)
                pyxel.line(self.trajectory_point[3][0], self.trajectory_point[3][1]+2, self.trajectory_point[4][0], self.trajectory_point[4][1]+2, 7)
                pyxel.line(self.trajectory_point[3][0], self.trajectory_point[3][1]+3, self.trajectory_point[4][0], self.trajectory_point[4][1]+3, 3)
                pyxel.line(self.trajectory_point[3][0], self.trajectory_point[3][1]+4, self.trajectory_point[4][0], self.trajectory_point[4][1]+4, 7)
            if (len(self.trajectory_point)>=4):
                pyxel.line(self.trajectory_point[2][0], self.trajectory_point[2][1],   self.trajectory_point[3][0], self.trajectory_point[3][1],   11)
                pyxel.line(self.trajectory_point[2][0], self.trajectory_point[2][1]+1, self.trajectory_point[3][0], self.trajectory_point[3][1]+1,  5)
                pyxel.line(self.trajectory_point[2][0], self.trajectory_point[2][1]+2, self.trajectory_point[3][0], self.trajectory_point[3][1]+2, 11)
                pyxel.line(self.trajectory_point[2][0], self.trajectory_point[2][1]+3, self.trajectory_point[3][0], self.trajectory_point[3][1]+3,  5)
                pyxel.line(self.trajectory_point[2][0], self.trajectory_point[2][1]+4, self.trajectory_point[3][0], self.trajectory_point[3][1]+4, 11)
            if (len(self.trajectory_point)>=3):
                pyxel.line(self.trajectory_point[1][0], self.trajectory_point[1][1],   self.trajectory_point[2][0], self.trajectory_point[2][1],   3)
                pyxel.line(self.trajectory_point[1][0], self.trajectory_point[1][1]+2, self.trajectory_point[2][0], self.trajectory_point[2][1]+2, 3)
                pyxel.line(self.trajectory_point[1][0], self.trajectory_point[1][1]+4, self.trajectory_point[2][0], self.trajectory_point[2][1]+4, 3)
            if (len(self.trajectory_point)>=2):
                pyxel.line(self.trajectory_point[0][0], self.trajectory_point[0][1],   self.trajectory_point[1][0], self.trajectory_point[1][1],   5)
                pyxel.line(self.trajectory_point[0][0], self.trajectory_point[0][1]+2, self.trajectory_point[1][0], self.trajectory_point[1][1]+2, 5)
                pyxel.line(self.trajectory_point[0][0], self.trajectory_point[0][1]+4, self.trajectory_point[1][0], self.trajectory_point[1][1]+4, 5)
            pyxel.blt(self.x - 8, self.y - 5, 1, 48, 128 + 16*(pyxel.frame_count % 8), 16, 16, 0)

```
自弾か敵弾かの弾種別でターゲットの指定先を変えています。

以上です。

# 最後に
誰かに届いて参考になれば嬉しいです。

Pyxel面白いのでこの記事を読んでるあなたも是非色々作ってみてください！