フェーズ 4 素材を描き、クラスにまとめる

step0:星空を飛ぶ宇宙船

トップページstep0 概要 / フェーズ 4

ゴール

このフェーズでやることは 2 つです。

このフェーズは「仕込み」です。画面の見た目は変わりません 描いた宇宙船を実際に画面へ出すのは次のフェーズ 5、動かすのはフェーズ 6 です。 ステップ B を終えても、星が流れる画面はフェーズ 3 とまったく同じに見えます。 それが正解なので、変化がなくても不安にならないでください。

1. 考え方

1-1. 絵はコードで書かない。「絵として」作る

星は pyxel.pset 1 行で描けました。1 ドットだからです。 では宇宙船はどうでしょう。これから描くものは 100 ドットほどあります。 pset を 100 行書くのは現実的ではありませんし、 1 ドット直すたびにコードを書き換えることになります。

そこで発想を変えます。絵は絵として別に作っておき、コードからは 「あの絵を、あの場所に貼れ」とだけ指示する。 Pyxel はこのために リソースエディタという専用のお絵かきツールを内蔵しています。

用語:イメージバンク Pyxel が絵を置いておくための、256 × 256 ドットの台紙です。 0123 枚あり、 ゲームで使う絵をここに並べておきます。コードからは 「何番の台紙の、どの座標から、何ドットぶん」という指定で切り出して使います。
イメージバンク(絵の置き場・256×256 ドット × 3 枚) イメージバンク 0(全 3 枚) 緑の枠=(0, 0) から 16×16 ドット pyxel.blt 切り出して貼る ゲーム画面(160×120 ドット) 好きな座標へ
絵は台紙に置きっぱなしにしておき、毎フレーム「切り出して貼る」だけ。1 枚の絵を何度でも使い回せます
このフェーズは「台紙に絵を置く」ところまで 上の図の pyxel.blt(切り出して貼る)はフェーズ 5 で扱います。

1-2. 変数が増えると、関数だけでは苦しくなる

いまのコードでは、星のリスト stars関数の外に置いています。 これはフェーズ 3 までは何の問題もありませんでした。しかし、この先を考えてみてください。

関数の外に置く変数(グローバル変数)が増えていくと、 「この変数は、どの関数が書き換えているのか」が追えなくなります。 そして、もっと困る問題が待っています。

関数の中では、外の変数に代入できない

フェーズ 6 で宇宙船を右へ動かすとき、素直に書くとこうなります。ですがこれは動きません。

ship_x = 72                  # 関数の外に置いた変数

def update():
    ship_x = ship_x + 2      # ← UnboundLocalError で落ちる

Python は「関数の中で代入されている名前は、その関数だけのローカル変数だ」と判断します。 そのため ship_x + 2 を計算しようとした時点で、 まだ何も入っていないローカルの ship_x を読もうとしてエラーになります。

では、なぜ stars.append(...) は動いたのか append は「stars という名前に別のものを代入する」のではなく、 「stars が指しているリストの中身を変える」操作だからです。 名前への代入がないので、Python はローカル変数だと判断しません。 詳しくはフェーズ 3 の解説で扱いました。
数値ではこの手が使えません。ship_x を増やすには必ず代入が必要になります。

回避策として global ship_x と宣言する方法はありますが、 変数が増えるたびに書き足すことになり、結局は散らかります。そこで クラスの出番です。

クラス=データと関数を 1 つの箱にまとめる仕組み

App という箱を作り、その中にデータ(星のリスト)それを扱う関数も入れてしまいます。箱の中からは self. を付けるだけで データに手が届き、代入も自由にできます。

フェーズ 3 まで(関数だけ) star_voyager.py(モジュール全体) stars make_stars update draw 誰でも触れるが、代入はできない まとめ直す フェーズ 4 から(クラス) class App: self.stars make_stars update draw 同じ箱の中なので self. で自由に読み書き
やっていることは同じですが、データと関数が同じ箱に入るだけで見通しが変わります

2. このフェーズで使う API・機能

API・機能意味
pyxel edit ファイル名リソースエディタを開く。ファイルが無ければ空の状態で開き、保存した時点で作られる
.pyxres絵・タイルマップ・音・曲をまとめて 1 つに入れる、Pyxel のリソースファイル
イメージバンク 02256×256 ドットの絵の置き場。3 枚ある
class App:クラス(データと関数をまとめた設計図)を定義する
def __init__(self):箱が作られるときに 1 回だけ呼ばれる、準備専用の関数
self「作られた箱そのもの」を指す。クラスの中の関数では第 1 引数に必ず書く
self.名前 = 値その箱が覚えておく変数(属性)を作る/書き換える
App()設計図から箱を 1 つ作る。このとき __init__ が呼ばれる
用語:メソッド/属性/インスタンス クラスの中に書いた関数をメソッド、クラスが持つ変数(self.stars など)を属性、 設計図から実際に作られた箱 1 個をインスタンスと呼びます。 用語は後からで構いません。まずは「箱にまとめる」感覚をつかんでください。

3. ステップ A:リソースエディタで宇宙船を描く

3-1. エディタを開く

ターミナルで次を実行します。

pyxel edit step0/assets.pyxres

assets.pyxres はまだ存在しませんが、エラーにはなりません。 空のリソースがエディタで開き、保存した時点でファイルが作られます。

エディタは Esc では閉じません リソースエディタは Esc キーを無効にしています(描いている途中に誤って閉じないためです)。 ウィンドウ右上の × ボタンで閉じてください。 そして閉じる前に必ず Ctrl + S で保存すること。 保存していない変更は警告なしに消えます。

3-2. 画面の見方

IMAGE TILEMAP / SOUND / MUSIC UNDO REDO SAVE キャンバス(16×16 ドット) イメージバンク全体 桃色の枠=いま編集中の場所 IMAGE 番号(0〜2) 0 ツール(ペン・四角・円・バケツ…) パレット(16 色)
図は要素の関係を示す概念図です。細部の配置は実物と異なります

覚えておく点は 3 つだけです。

3-3. 操作一覧

操作できること
キャンバスを左ドラッグ選んでいる色で描く
キャンバスを右クリックその場所の色を吸い取る(スポイト)
ドラッグ中に Shiftまっすぐ/正方形に補正される
1807 を選ぶ
Shift + 18815 を選ぶ
P / Sペン/選択ツール
R / Shift + R四角(枠だけ/塗りつぶし)
C / Shift + C円(枠だけ/塗りつぶし)
Bバケツ(つながった同じ色を塗りつぶす)
Ctrl + Z / Ctrl + Y元に戻す/やり直す
Ctrl + S保存
Alt + / IMAGE / TILEMAP / SOUND / MUSIC を切り替える
画面の下端にヒントが出ます マウスを乗せている場所に応じて、そこで使えるショートカットと いまカーソルがあるドットの座標が下端に表示されます。迷ったらそこを見てください。

3-4. 宇宙船を描く

下が設計図です。左上の (0, 0) から 16×16 ドットの範囲に描きます。 方眼の太い線が 8 ドットごとの区切りです。

0 0 1 1 2 2 3 3 4 4 5 5 6 6 7 7 8 8 9 9 10 10 11 11 12 12 13 13 14 14 15 15 u v
宇宙船スプライトの設計図(16×16 ドット)。上と左の数字が座標です。横方向を u、縦方向を v と呼びます(フェーズ 5 で使います)

使う色は、背景の黒を入れて 7 色です。キーボードの数字キーで切り替えられます。

見本色番号名前選ぶキー使う場所
0BLACK1背景(そのまま/あとで透明にする)
5DARK_BLUE6
6LIGHT_BLUE7機体
7WHITE8輪郭・機首
8REDShift + 1噴射の外側
10YELLOWShift + 3噴射の芯
12CYANShift + 5コックピット
設計図どおりでなくて構いません 絵は自由に描いてください。ただし次の 3 つだけは守ってください。 フェーズ 5 のコードがこの前提で動きます。
  1. 16×16 ドットに収める
  2. 左上の (0, 0) から描き始める
  3. 背景は色 0(黒)のまま残す … フェーズ 5 で色 0 を「透明」として抜くためです。 背景を黒以外で塗ってしまうと、宇宙船のまわりに四角い枠が出ます

3-5. 保存して確認

  1. Ctrl + S を押す
  2. ウィンドウ右上の × でエディタを閉じる
  3. ファイルができているか確認する
ls step0

assets.pyxres が並んでいれば成功です。もう一度 pyxel edit step0/assets.pyxres を実行して、 描いた絵がちゃんと残っていることも確かめておきましょう。

ここまで確認できたらステップ B へ .pyxres は中身がバイナリなので、VS Code で開いても読めません。それで正常です。

4. ステップ B:コードをクラスにまとめ直す

ここからはコードの作業です。動きは 1 ミリも変えません。 同じ動作のまま、置き場所だけを整えます。 このように「外から見た動きを変えずに、内側を整理する」書き換えを リファクタリングと呼びます。

star_voyager.py下の内容にまるごと書き換えてください。 定数の部分(上から 15 行目まで)は今までと同じなので、そのまま残して構いません。

# star_voyager.py
# step0 フェーズ4:素材を描き、クラスにまとめる

import random

import pyxel

SCREEN_WIDTH = 160
SCREEN_HEIGHT = 120

TITLE = "STAR VOYAGER"
GUIDE = "PRESS ESC TO QUIT"

STAR_COUNT = 40         # 星の数
STAR_SPEED_MIN = 0.3    # 星の速さの下限(ドット/フレーム)
STAR_SPEED_MAX = 1.8    # 星の速さの上限


class App:
    """このゲーム全体を受け持つ箱"""

    def __init__(self):
        """箱が作られるときに 1 回だけ呼ばれる。ここで準備をすませる"""
        pyxel.init(SCREEN_WIDTH, SCREEN_HEIGHT, title="Star Voyager")
        self.stars = []          # 星をぜんぶ入れるリスト
        self.make_stars()
        pyxel.run(self.update, self.draw)

    def make_stars(self):
        """星を STAR_COUNT 個ぶん作って self.stars に入れる"""
        for i in range(STAR_COUNT):
            x = random.randint(0, SCREEN_WIDTH - 1)
            y = random.randint(0, SCREEN_HEIGHT - 1)
            speed = random.uniform(STAR_SPEED_MIN, STAR_SPEED_MAX)
            self.stars.append([x, y, speed])

    def star_color(self, speed):
        """速い星ほど明るい色にして、奥行きを表す"""
        if speed > 1.2:
            return pyxel.COLOR_WHITE
        if speed > 0.7:
            return pyxel.COLOR_GRAY
        return pyxel.COLOR_DARK_BLUE

    def text_center(self, y, s, col):
        """文字列 s を、画面の横中央に来るように描く"""
        x = (SCREEN_WIDTH - len(s) * pyxel.FONT_WIDTH) // 2
        pyxel.text(x, y, s, col)

    def update(self):
        """毎フレームの更新処理。星を下へ動かす"""
        for star in self.stars:
            star[1] += star[2]                                  # y に速さを足す

            if star[1] >= SCREEN_HEIGHT:                        # 画面の下に出たら
                star[0] = random.randint(0, SCREEN_WIDTH - 1)   # x を引き直して
                star[1] = 0                                     # いちばん上に戻す

    def draw(self):
        """毎フレームの描画処理"""
        pyxel.cls(0)

        for star in self.stars:
            pyxel.pset(star[0], star[1], self.star_color(star[2]))

        self.text_center(20, TITLE, pyxel.COLOR_YELLOW)
        self.text_center(34, GUIDE, pyxel.COLOR_GRAY)


App()
書き換えのルールは 4 つだけ
  1. 関数の定義を すべて class App: の中へ入れる(インデントを 4 つ深くする)
  2. すべての関数の引数の先頭に self を足す
  3. starsself.stars に、 star_color(...)self.star_color(...) に書き換える
  4. 最後にあった pyxel.initmake_stars()pyxel.run の 3 行を __init__ の中へ移し、いちばん下は App() だけにする

実行して確認

python step0/star_voyager.py

「何も変わらない」ことが確認できれば成功です。

5. コードの解説

class は設計図、App() は実物

class App: と書いた時点では、まだ何も動きません。これは設計図だからです。 いちばん下の App() で初めて、設計図から実物が 1 個作られます。

「たった 1 個しか作らないのに、なぜ設計図が要るのか」と思うかもしれません。 理由は変数と関数を 1 つの名前の下にまとめられるからで、 数を作るためではありません。step1 以降でお化けやお菓子を何十個も作るときには、 「設計図から実物をたくさん作る」という本来の使い方も出てきます。

__init__ は準備係

App() と書くと、Python は箱を作ったあと自動的に __init__ を呼びます。 自分で app.__init__() と書く必要はありません。 前後のアンダースコア 2 本は「Python が決めた特別な名前」という目印です。

今回の __init__ がやっているのは、フェーズ 3 でファイルの最後に書いていた 3 行そのままです。

フェーズ 3(ファイルの末尾)フェーズ 4(__init__ の中)
pyxel.init(...)pyxel.init(...)
stars = [] はファイル上部)self.stars = []
make_stars()self.make_stars()
pyxel.run(update, draw)pyxel.run(self.update, self.draw)

self とは何者か

self「いま処理している、その箱そのもの」です。 不思議に見えるのは、呼ぶときには書かないのに、定義には書く点でしょう。 これは Python が自動で渡しているからです。

呼ぶとき(__init__ の中) self.make_stars() ← 空っぽ Python が自動で箱そのものを渡す 定義するとき def make_stars(self): ← 受け取る App の箱(実物) stars = [ ... ] self はこの箱を指す だから self.stars で中身に届く
self という名前は決まりではなく慣習ですが、必ず self と書いてください。Python の世界の共通語です
最頻出のエラー:self の書き忘れ 定義に self を書き忘れると TypeError: make_stars() takes 0 positional arguments but 1 was given というエラーになります。「0 個しか受け取らない関数に 1 個渡された」= Python が渡した self の受け取り先がない、という意味です。

self.stars と、ただの stars の違い

self. を付け忘れて stars と書くと、それは そのメソッドの中だけの、まったく別の変数になります。 書き換えても箱の中身は変わらないので、 「エラーは出ないのに星が動かない」という一番やっかいな症状になります。 クラスの中でデータを触るときは、必ず self. を付けると覚えてください。

pyxel.run(self.update, self.draw) にカッコが無い理由

self.update() と書くと「いま実行しろ」という意味になり、実行結果が渡ってしまいます。 カッコを付けない self.update関数そのものを指します。 Pyxel に渡したいのは結果ではなく「毎フレーム呼ぶべき関数」なので、カッコは付けません。

これはフェーズ 1 の pyxel.run(update, draw) と同じ考え方で、 self. が付いただけです。

定数はなぜクラスの外に残したのか

SCREEN_WIDTHSTAR_COUNT一度決めたら変わらない値で、 箱ごとに違う値を持つ必要がありません。こういうものは外に置いたままで十分です。 「箱ごとに変わるもの」だけを self. に入れるのが目安です。

なぜ App() の 1 行だけで動くのか

App()__init__ が呼ばれる → その中の pyxel.run(...) に到達する、 という流れです。pyxel.runそこで止まってゲームを回し続けるので、 App() の 1 行が実質的にゲーム全体の実行になります。

6. 試してみよう

やってみること確かめたいこと
make_stars の定義から self を消す TypeError: ... takes 0 positional arguments but 1 was given が出る
update の中の self.starsstars にする NameError: name 'stars' is not defined が出る(self. は省略できない)
いちばん下の App()App に変える 何も起きずに終了する。カッコが「作れ」の合図だと分かる
pyxel.run(self.update, self.draw)pyxel.run(self.update(), self.draw()) にする エラーになる。カッコの有無で意味が変わることを確認できる
__init__ の中の self.make_stars() を消す 星が 1 つも出ない。準備の呼び出しが要ることが分かる

確認できたら元に戻しておいてください。

7. うまくいかないときは

症状原因と対処
Directory for 'step0/assets.pyxres' does not exist step0 フォルダのある場所(リポジトリのルート)から実行していない。 pwd で現在地を確認する
エディタが Esc で閉じない 仕様です。ウィンドウ右上の × で閉じてください
閉じたら描いた絵が消えていた Ctrl + S での保存を忘れている。保存前は閉じても警告が出ません
キャンバスに描いても反応しない 選んでいる色が背景と同じ 0(黒)になっている。数字キーで別の色を選ぶ
TypeError: ... takes 0 positional arguments but 1 was given メソッドの定義に self を書き忘れている
NameError: name 'stars' is not defined self.stars と書くべきところが stars のままになっている
NameError: name 'star_color' is not defined draw の中が self.star_color(...) になっていない。 同じクラスの中でも self. は必要です
IndentationError が出る クラスの中に入れた関数のインデントが揃っていない。 def はすべてスペース 4 つの位置に揃える
実行しても何も起きずに終わる いちばん下の App() が抜けているか、App とカッコ無しで書いている
星が出ない(エラーは無し) __init__ の中の self.make_stars() が抜けている。 または pyxel.run(...) より後ろに書いている
次のフェーズ フェーズ 5 では、いま描いた宇宙船を pyxel.load で読み込み、 pyxel.blt で画面に表示します。ようやく見た目が変わります。