【Pyxel】Pyxelで学ぶゲームプログラム 〜フラグと状態遷移

今回はレトロゲームエンジン「Pyxel」を使ってゲームプログラム の基礎である「フラグ」制御と「状態遷移」について学びます。

なお Pyxel のセットアップについては、以下のページにまとめていますので、まだインストールできていない場合は、こちらを参考にしてください。
【関連記事】:【Pyxel】セットアップ手順と基本の関数について

■今回作るもの

アクションゲームの基礎となる、ジャンプと空中攻撃を実装します。


■素材のダウンロード

お馴染みのニャンコ画像を使用するので以下からダウンロードします。

ダウンロード用:http://syun777.sakura.ne.jp/tmp/pyxel/cat.png

■Pyxelを動かす最小限のコード

まずはPyxelを動かすための最小限のコードを記述します。

import pyxel

class App:
    def __init__(self):
        pyxel.init(160, 120, fps=60)
        pyxel.run(self.update, self.draw)
    def update(self):
        pass
    def draw(self):
        pass

App()

実行すると何もない画面が表示されます。

■ニャンコ画像を表示する

続けてニャンコ画像を表示します。

import pyxel

class App:
    def __init__(self):
        pyxel.init(160, 120, fps=60)
        self.init()
        pyxel.run(self.update, self.draw)
    def init(self):
        pyxel.image(0).load(0, 0, "cat.png")
    def update(self):
        pass
    def draw(self):
        pyxel.cls(0)
        pyxel.blt(0, 0, 0, 0, 0, 16, 16, 5)

App()

実行するとニャンコの画像が画面の左上に表示されます。

■ニャンコを地面に立たせる

コードを修正してニャンコを地面の上に立たせます。

▼コード修正箇所
* Appクラスの下に定数 GROUND_Y を追加
* init() にニャンコ変数 (self.x / self.y) の初期化を追加
* draw() に地面の描画を追加

import pyxel

class App:
    GROUND_Y = 100 # 地面の座標
    def __init__(self):
        pyxel.init(160, 120, fps=60)
        self.init()
        pyxel.run(self.update, self.draw)
    def init(self):
        pyxel.image(0).load(0, 0, "cat.png")
        # ニャンコ変数の初期化
        self.x = 72
        self.y = self.GROUND_Y - 16
        
    def update(self):
        pass
    def draw(self):
        pyxel.cls(0)

        # 地面を描画
        pyxel.rect(0, self.GROUND_Y, pyxel.width, pyxel.height, 4)

        pyxel.blt(self.x, self.y, 0, 0, 0, 16, 16, 5)

実行すると地面の上にニャンコが描画されます。

ニャンコ地面に降り立つ

■ニャンコを重力落下させる

重力落下を実装します。まずは init() に速度(self.vy)と重力(self.gravity)を追加します。

    def init(self):
        pyxel.image(0).load(0, 0, "cat.png")
        # ニャンコ変数の初期化
        self.x = 72
        self.y = self.GROUND_Y - 16
        self.vy = 0 # Y方向の速度
        self.gravity = 0.05 # 重力

重力加速度は 0.5 としました。
次に、update() で速度と座標を更新します。

    def update(self):
        # 加速度更新
        self.vy += self.gravity
        # 速度を更新
        self.y += self.vy

実行するとニャンコが落下して画面外に消えてしまいます……

これは地面との当たり判定を実装していないためです。
地面に着地するように update() を修正します。

    def update(self):
        # 加速度更新
        self.vy += self.gravity
        # 速度を更新
        self.y += self.vy

        # ニャンコを地面に着地させる
        if self.y > self.GROUND_Y - 16:
            self.y = self.GROUND_Y - 16

実行するとニャンコが画面外に移動しないようになります。

■ニャンコをジャンプさせる

SPACEキーでニャンコがジャンプするようにします。
input_key() を新たに追加します。

    def input_key(self):
        if pyxel.btnp(pyxel.KEY_SPACE):
            # ジャンプする
            self.vy = -2

SPACEキーを押したら Y方向の速度を "-2" (上向きの2) に設定します。
そして、この関数を update() で呼び出すようにしました。

    def update(self):
        # キー入力判定
        self.input_key()

        # 加速度更新
        self.vy += self.gravity
        # 速度を更新
        self.y += self.vy

        # ニャンコを地面に着地させる
        if self.y > self.GROUND_Y - 16:
            self.y = self.GROUND_Y - 16

では実行して動作を確認します。

確かにジャンプできるようになったのですが、空中ジャンプできてしまいます。

■空中ジャンプできないようにする

ジャンプ中にはジャンプできないようにします。方法としては、ジャンプしたらフラグ (self.is_jumping) を True にすることで防ぐという考え方です。
では、まずは init()self.is_jumping を追加します。

    def init(self):
        pyxel.image(0).load(0, 0, "cat.png")
        # ニャンコ変数の初期化
        self.x = 72
        self.y = self.GROUND_Y - 16
        self.vy = 0 # Y方向の速度
        self.gravity = 0.05 # 重力
        self.is_jumping = False

次に input_key() で、self.is_jumping が False である時のみ、ジャンプできるようにします。ジャンプしたら self.is_jumping を True にします。

    def input_key(self):
        if pyxel.btnp(pyxel.KEY_SPACE):
            if self.is_jumping == False:
                # ジャンプする
                self.vy = -2
                self.is_jumping = True # ジャンプ中

最後に、update() で地面に着地したら self.is_jumping を False にすることで再びジャンプできるようにします。

実行すると、ジャンプ後は着地するまでジャンプできないようになっているはずです。

■空中攻撃をする

空中でSPACEキーを押したら、空中攻撃をするようにしてみます。ただしニャンコなので、体当たりしかできません。
空中攻撃中は空中攻撃を重ねて発動できないように、フラグ self.is_aerial_attack を追加します。

    def init(self):
        pyxel.image(0).load(0, 0, "cat.png")
        # ニャンコ変数の初期化
        self.x = 72
        self.y = self.GROUND_Y - 16
        self.vy = 0 # Y方向の速度
        self.gravity = 0.05 # 重力
        self.is_jumping = False # ジャンプ中かどうか
        self.is_aerial_attack = False # 空中攻撃

init() にフラグ self.is_aerial_attack に初期化を追加しました。
次に input_key() で空中攻撃判定を行います。

    # キー入力
    def input_key(self):
        if pyxel.btnp(pyxel.KEY_SPACE):
            if self.is_jumping == False:
                # ジャンプする
                self.vy = -2
                self.is_jumping = True # ジャンプ中
            else:
                # ジャンプ中
                if self.is_aerial_attack == False:
                    # 空中攻撃開始
                    self.is_aerial_attack = True
                    self.vy = 3

ジャンプ判定の下に else文 を追加し、空中攻撃中でなければ空中攻撃開始、という処理を入れました。空中攻撃はニャンコなので、下方向に "3" を設定するだけです。あと、self.is_aerial_attack を True にして連続発動させないようにします。
最後に、update() に着地したら空中攻撃フラグを False にする処理を入れます。

    def update(self):
        # キー入力判定
        self.input_key()

        # 加速度更新
        self.vy += self.gravity
        # 速度を更新
        self.y += self.vy

        # ニャンコを地面に着地させる
        if self.y > self.GROUND_Y - 16:
            self.y = self.GROUND_Y - 16
            self.is_jumping = False # 着地した
            self.is_aerial_attack = False

では、実行して空中でSPACEキーを押して、ニャンコが下に加速する動きを堪能します。

地面に怒りをぶつけるニャンコ様

エフェクトがないので何をしているのかよく分からないですが、そこは脳内補完でなんとかします。

■空中攻撃による着地時に硬直フレームを入れる

アクションゲームでよくあるのが、着地時に硬直フレーム(動けない時間)を設定して、技のリスクと技の重みを出す手法があります。それを実装しようと思う……のですが、またフラグを追加する……と管理が大変になります。

というのもフラグで管理する場合、フラグ数に応じて 2^(フラグ数) という組み合わせが存在するためです。実際に必要なのは「1〜4」だけですが、組み合わせとしては、2^3=8パターン存在します。
このように複雑な状態が存在する場合、「状態遷移」を使用するとスッキリした実装になります。
「状態遷移」とはフラグで管理するのではなく、決まったパターンを状態変数として、取りうる状態内のみで値を変化させていきます。

例えばこのように、各状態が存在し、何らかの条件により別の状態へ遷移を行うものとなります。

では、この状態遷移の定数を作成します。
Enum (列挙体)を使用して定数を定義します。

import pyxel
from enum import Enum

# 状態の列挙体クラス
class State(Enum):
    STANDING      = 1 # 地面に立っている
    JUMPING       = 2 # ジャンプ中
    AERIAL_ATTACK = 3 # 空中攻撃中

Pythonでは、Enumクラスを使うと数値とは異なる定数として扱われます。
これを使って init() で初期化を行います。

    def init(self):
        pyxel.image(0).load(0, 0, "cat.png")
        # ニャンコ変数の初期化
        self.x = 72
        self.y = self.GROUND_Y - 16
        self.vy = 0 # Y方向の速度
        self.gravity = 0.05 # 重力
        self.state = State.STANDING
        #self.is_jumping = False # ジャンプ中かどうか
        #self.is_aerial_attack = False # 空中攻撃

is_jumping / is_aerial_attack フラグを消して、状態変数 self.state を追加しました。
次に、input_key() を以下のように書き換えます。

    # キー入力
    def input_key(self):
        if pyxel.btnp(pyxel.KEY_SPACE):
            if self.state == State.STANDING:
                # ジャンプする
                self.vy = -2
                self.state = State.JUMPING
            elif self.state == State.JUMPING:
                # 空中攻撃を開始する
                self.vy = 3
                self.state = State.AERIAL_ATTACK
            elif self.state == State.AERIAL_ATTACK:
                # 特に何もしない
                pass

SPACEキーを押した後の処理を、if〜elif でそれぞれの状態ごとの処理を行うようにしました。以前の処理はフラグ判定が階層化していたのですが、この修正により self.state の分岐のみとなったので、シンプルになったと思います。なお、C言語などでは、switch〜case文という記述があり、よりシンプルに書くことができます。
最後に、update() を修正します。

    def update(self):
        # キー入力判定
        self.input_key()

        # 加速度更新
        self.vy += self.gravity
        # 速度を更新
        self.y += self.vy

        # ニャンコを地面に着地させる
        if self.y > self.GROUND_Y - 16:
            self.y = self.GROUND_Y - 16
            self.state = State.STANDING
            #self.is_jumping = False # 着地した
            #self.is_aerial_attack = False

ニャンコが着地した時は、複数のフラグを初期化する必要がなくなり、self.state に State.STANDING を代入するだけとなりました。
では実行して、ひとまず以前と同じ動きになっていることを確認します。

そうしたら、硬直処理を入れていきます。

▼修正箇所
* import random を追加 (硬直時のガクブル対応のため)
* 状態定数に "RECOVERY" を追加
* init() に硬直フレーム数を格納する変数 self.frame_count を追加
* update() の着地処理に、通常の着地と空中攻撃後の着地の判定の分岐を入れる
* input_key()SPACEキーの判定を状態判定後に移動
* input_key()硬直時 (self.state == State.RECOVERY) の判定を追加
* draw() に硬直時のブレを追加
import pyxel
from enum import Enum
import random

# 状態の列挙体クラス
class State(Enum):
    STANDING      = 1 # 地面に立っている
    JUMPING       = 2 # ジャンプ中
    AERIAL_ATTACK = 3 # 空中攻撃中
    RECOVERY      = 4 # 硬直中

class App:
    GROUND_Y = 100 # 地面の座標
    def __init__(self):
        pyxel.init(160, 120, fps=60)
        self.init()
        pyxel.run(self.update, self.draw)
    def init(self):
        #pyxel.image(0).load(0, 0, "cat.png")
        pyxel.image(0).load(0, 0, "/Users/syun/Desktop/State/cat.png")
        # ニャンコ変数の初期化
        self.x = 72
        self.y = self.GROUND_Y - 16
        self.vy = 0 # Y方向の速度
        self.gravity = 0.05 # 重力
        self.state = State.STANDING
        #self.is_jumping = False # ジャンプ中かどうか
        #self.is_aerial_attack = False # 空中攻撃
        self.recovery_frame = 0
    def update(self):
        # キー入力判定
        self.input_key()

        # 加速度更新
        self.vy += self.gravity
        # 速度を更新
        self.y += self.vy

        # ニャンコを地面に着地させる
        if self.y > self.GROUND_Y - 16:
            self.y = self.GROUND_Y - 16
            if self.state == State.AERIAL_ATTACK:
                self.state = State.RECOVERY
                self.recovery_frame = 20 # 20フレーム間硬直する
            elif self.state == State.JUMPING:
                # 通常の着地
                self.state = State.STANDING
            #self.is_jumping = False # 着地した
            #self.is_aerial_attack = False
    # キー入力
    def input_key(self):
        if self.state == State.STANDING:
            if pyxel.btnp(pyxel.KEY_SPACE):
                # ジャンプする
                self.vy = -2
                self.state = State.JUMPING
        elif self.state == State.JUMPING:
            if pyxel.btnp(pyxel.KEY_SPACE):
                # 空中攻撃を開始する
                self.vy = 3
                self.state = State.AERIAL_ATTACK
        elif self.state == State.AERIAL_ATTACK:
            # 特に何もしない
            pass
        elif self.state == State.RECOVERY:
            if self.recovery_frame > 0:
                # 硬直中
                self.recovery_frame -= 1
            else:
                # 硬直終了
                self.state = State.STANDING


    def draw(self):
        pyxel.cls(0)

        # 地面を描画
        pyxel.rect(0, self.GROUND_Y, pyxel.width, pyxel.height, 4)

        px = self.x
        py = self.y
        if self.state == State.RECOVERY:
            if self.recovery_frame%2 == 0:
                py += self.recovery_frame/4

        pyxel.blt(px, py, 0, 0, 0, 16, 16, 5)

App()

self.state の判定が update() / input_key() の2つに分かれてしまっため、少しわかりにくいかもしれません。
では、実行して空中攻撃後、硬直フレームが発生することを確認します。

■最後に

状態遷移は、キャラクターの制御の他にも、例えばゲーム全体の制御にも使えます。

1. ゲーム開始演出
2. メインゲーム
  a. ゲーム勝利 →「3. 勝利演出」へ遷移
  b. ゲーム敗北 →「4. 敗北演出」へ遷移
3. 勝利演出 →「5. リザルト」へ遷移
4. 敗北演出
  a. 「やり直し」を選ぶと 「1. ゲーム開始演出」に戻る
  b. 「あきらめる」を選ぶとゲームオーバー
5. リザルト →次のステージに進む

以下、Pythonでの実装例です。

from enum import Enum

class State(Enem):
  START = 1 # 開始演出
  MAIN = 2 # メイン
  WIN = 3 # 勝利
  LOSE = 4 # 敗北
  RESULT = 5 # リザルト

class App:
  def init(self):
    self.state = State.START
    
  def update(self):
    if self.state == State.START: # 開始演出
      if (開始演出終了):
        self.state = State.MAIN
    elif self.state == State.MAIN: # メイン
      if (勝利条件を満たした):
        self.state = State.WIN
      elif (敗北した):
        self.state = State.LOSE
    elif self.state == State.WIN: # 勝利
      if (勝利演出終了):
        self.state = State.RESULT
    elif self.state == State.LOSE: # 敗北
      if (敗北演出終了):
        if (やり直す):
          self.state == State.START
        elif (あきらめる):
          # ゲームオーバー処理
    elif self.state == State.RESULT: # リザルト
      if (リザルト演出終了):
        # 次のステージに進む

■参考書籍

ゲームプログラムのよくあるパターンを集めた本です。今回の記事もこちらを参考にして書きました。


この記事が気に入ったら、サポートをしてみませんか?気軽にクリエイターを支援できます。

3

しゅん

Pyxel記事まとめ

Pyxelに関する記事をまとめたものです