備忘

Python や Pyxel について出た疑問と、その答えの記録

← トップページへもどる

このページの使い方

学習を進める中で出てきた疑問・つまずき・調べたことを、その都度ここに積み上げていきます。 あとから「あれ何だったっけ」となったときに戻ってくる場所です。 新しい項目は下に追記していきます。

Python について

Q. インデントはタブとスペースのどちらで書くべき?

A. 半角スペース 4 つを推奨します。タブだけで統一してあれば Python は正しく動くので、 現状のコードを慌てて直す必要はありません。ただし次の 2 つの理由から、早いうちに スペースへ寄せておくほうが安全です。

VS Code なら自動で直せます ファイルを開いた状態で Ctrl + Shift + PConvert Indentation to Spaces を実行すると、タブが一括でスペースに変換されます。
画面右下のステータスバーに タブのサイズ: 4 / スペース: 4 と現在の設定が出ており、 そこをクリックして切り替えることもできます。
見た目では区別がつきません タブもスペースも画面上は同じ空白に見えます。混在が疑わしいときは、 VS Code のメニュー 表示 → レンダリングされた空白の切り替えToggle Render Whitespace)をオンにすると、スペースは点、タブは矢印で表示されます。

Q. self という名前は me などに変えてもいい?

A. 変えられます。self は Python の予約語ではなく、 「メソッドの 1 つめの引数」に慣習でつけているただの変数名です。 次のコードは実際に動きます(15 と表示されます)。

class Counter:
    def __init__(me, start):      # self ではなく me
        me.value = start

    def add(watashi, n):          # メソッドごとに名前が違ってもよい
        watashi.value += n

    def show(self):               # ここだけ self
        print("いまの値:", self.value)

c = Counter(10)
c.add(5)
c.show()                          # → いまの値: 15

ポイントは「名前が何か」ではなく「1 つめの引数である」ことです。 c.add(5) と書くと、Python が裏で Counter.add(c, 5) のように 呼び出し元のインスタンスを 1 つめの引数として自動で渡します。 受け取る側の名前は self でも me でも watashi でも、渡ってくる中身は同じです。

フェーズ4 で出たエラーの正体 def make_stars(): と 1 つめの引数を消すと TypeError: make_stars() takes 0 positional arguments but 1 was given が出ました。 これは「Python が勝手に 1 個渡したのに、受け取る場所がない」という意味です。 名前を消したのが問題ではなく、受け皿を消したのが問題でした。
それでも self と書いてください 動くことと、そう書くべきことは別です。self は Python の世界で ほぼ全員が守っている約束で、次の実害があります。 「変えられるが、変えない」——理由を知ったうえで慣習に乗るのが一番ラクです。
ついでに:cls という名前も出てきます クラスメソッド(@classmethod)では 1 つめの引数に インスタンスではなくクラスそのものが渡され、慣習で cls と名づけます。 これも予約語ではなく、self と同じ「ただの慣習」です。 step0 では使いませんが、名前を見かけたら「あの仲間だ」と思ってください。

Q. __init__ の前後のアンダーバー 2 つには意味がある?

A. あります。前後をアンダースコア 2 つで挟んだ名前は、 「Python 本体が特別扱いする名前」という目印です。 これらは自分で呼ぶメソッドではなく、決まったタイミングで Python が勝手に呼ぶメソッドで、 特殊メソッド(マジックメソッド、ダンダーメソッド)と呼ばれます。

名前呼ばれるタイミング
__init__App() のようにインスタンスを作ったとき
__str__print(obj) したとき
__len__len(obj) と書いたとき
__eq__obj1 == obj2 と比べたとき
__name__(メソッドではなく変数)実行中のモジュール名が入る。 if __name__ == "__main__": でおなじみ

実際に動かすと、呼んでいないのに呼ばれることが確認できます。

class Team:
    def __init__(self, name, members):   # Team(...) で呼ばれる
        self.name = name
        self.members = members

    def __str__(self):                   # print(t) で呼ばれる
        return "チーム" + self.name

    def __len__(self):                   # len(t) で呼ばれる
        return len(self.members)


t = Team("A", ["山田", "鈴木", "佐藤"])
print(t)        # → チームA
print(len(t))   # → 3

__str____len__ も、コードのどこにも「呼び出し」を書いていません。 printlen裏で探しにきているのです。 App()__init__ が呼ばれるのも、まったく同じ仕組みです。

なぜこんな変な見た目なのか

普通の名前とぶつからないようにするためです。 もし init という名前だったら、自分で「初期化用の init メソッド」を 作りたくなったときに衝突します。前後をアンダースコア 2 つで挟むことで、 「ここは Python 側が予約している領域」と見ただけで区別できるようにしています。

アンダースコアは本数と位置で意味が変わります
書き方意味
__init__
(前後に 2 つ)
Python が特別扱いする特殊メソッド。 決められた名前を使うもので、自分で新しく発明するものではありません
_count
(前に 1 つ)
「内部用なので外から触らないでね」というお願い。 強制力はなく、単なる慣習です
__count
(前に 2 つだけ)
Python が名前を裏で書き換えて、外から呼びにくくします (name mangling)。step0 では使いません
自分で __init__ を呼ぶ必要はありません app = App() と書けば Python が呼んでくれます。 app.__init__() と自分で書くことは、通常ありません。 「特殊メソッドは定義するけれど、呼ばない」と覚えてください。

Q. 処理をどこで関数に分ければいい? 分ける基準は?

A. 行数ではなく 「名前がつけられるか」 で決めます。 「この 5 行は何をしているか」を短い名前で言い切れるなら、それは関数にしてよいまとまりです。 逆に move_and_grow のように「〜と〜」でしか言えないなら、 まだ 2 つの仕事が混ざっているサインです。

分けどきの 4 つのサイン

サイン具体例
名前が「〜と〜をする」になる 「花を動かしヒヨコを大きくする」としか言えない
主語が違うものが混ざっている move_flower() の中にヒヨコの大きさのルールが書いてある
同じコードが 2 か所に出た 足元のY座標を求める式を初期化と衝突時の両方で書いている
update() を読んでも流れが分からない 呼び出しが並んでいるのに、何をしているゲームか読み取れない
update() は「目次」になっているのが理想 中身を読まなくても、呼び出しの並びだけでゲームの流れが分かる状態を目指します。
    def update(self):
        self.make_flower()      # 花を作る
        self.move_flowers()     # 花を動かす
        self.move_hiyoko()      # ヒヨコを動かす
これが読めれば、関数分割はうまくいっています。

「主語が違う」の見つけ方

いちばん見落としやすいのがこれです。関数の名前は の話をしているのに、 中身に ヒヨコ のルールが紛れ込んでいる、という状態です。

混ざっている update() make_flower() move_flower() x += vx / y += vy 当たり判定 hiyoko_scale += 1 min(..., 16) / put_on_ground() move_hiyoko() ヒヨコ 花の関数の中にヒヨコのルールがある。 大きくなる条件が増えたとき、 上限チェックを書き忘れる。 切り出したあと update() make_flower() move_flowers() x += vx / y += vy 当たり判定 grow_hiyoko() ←呼ぶだけ grow_hiyoko() ヒヨコ move_hiyoko() ヒヨコ 花の関数は花の話だけ。 大きさのルールは grow_hiyoko の 1 か所にまとまる。
関数の「名前が指している主語」と、中身の主語を合わせる

切り出すと、呼び出し側は 1 行で意図が読めるようになります。

HIYOKO_SCALE_MAX = 16   # ヒヨコの大きさの上限

    def grow_hiyoko(self):
        """ヒヨコを1段階大きくする(上限まで)"""
        self.hiyoko_scale = min(self.hiyoko_scale + 1, HIYOKO_SCALE_MAX)
        self.put_on_ground()

    # 呼び出し側(花の移動処理の中)
            if pyxel.pget(x + 1, y + 2) == pyxel.COLOR_YELLOW:
                self.grow_hiyoko()      # ヒヨコが大きくなる
                continue

名前のつけ方

逆に、分けてはいけないとき

「長いから」だけの理由で分けない 無理に切ると、update() から呼ばれる関数が 20 個並んで、かえって流れが追えなくなります。 分ける理由はつねに「まとまりに名前がついたから」であって、行数ではありません。

たとえば pyxel.btn() による入力の読み取りが move_hiyokomake_flower の両方に散らばっていても、この規模なら問題ありません (Pyxel 付属のサンプルもこの書き方です)。 「タイトル画面では入力を無視したい」のように実際に困りごとが起きてから、 入力係の関数へまとめれば十分です。

先回りして分けない 「いつか必要になりそう」で関数を増やすと、使われないまま残って読む量だけが増えます。 同じコードが 2 回目に出てきたとき、あるいは名前が「〜と〜」になったときが、 実際に手を動かすタイミングです。

Q. /// はどう使い分ける?

A. /ふつうの割り算で、答えはつねに小数(float)になります。 //切り捨て除算で、小数点以下を捨てます。 「1 きざみの整数でないと意味がないもの」には //、 「途中の値にも意味があるもの」には / を使う、というのが基本の分かれ道です。

結果ひとこと
7 / 23.5float小数のまま残る
7 // 23int小数点以下を捨てる
4 / 22.0float割り切れても float
4 // 22int整数どうしなら int
7.4 // 23.0float片方が小数なら型は float。値は切り捨て済み
// は「整数にする命令」ではありません 7.4 // 2 の答えは 3.0 で、型は float のままです。 // が保証するのは「値が整数ちょうどになる」ことだけで、 型が int になるとはかぎりません

使い分けの目安

// を使う/ を使う
対象 個数・番号・マス目
(1 きざみでないと意味がない)
位置・速さ・大きさ
(途中の値にも意味がある)
(pyxel.frame_count // 5) % 2
5 フレームごとに切り替わるコマ番号
GROUND_Y - HIYOKO_HEIGHT * (scale + 1) / 2
ヒヨコのY 座標
理由 「1.4 コマ目」は存在しない Pyxel が小数の座標を受け取り、描くときに四捨五入してくれる
→ 小数の座標

実例:ヒヨコの足元が地面に沈んだ話

put_on_ground() を最初こう書いていました。 hiyoko_scale は花を当てるたびに 0.2 ずつ増えるので、途中から小数になります

self.hiyoko_y = GROUND_Y - HIYOKO_HEIGHT * (self.hiyoko_scale + 1) // 2

すると足元の位置が地面(GROUND_Y = 111)から最大 0.8 ドットずれます。

hiyoko_scale1.01.21.41.61.82.0
// のときの足元111.0111.6111.2111.8111.4111.0
/ のときの足元111.0111.0111.0111.0111.0111.0

// で切り捨てると 引く量が減る ので、Y 座標が下へずれて足元が地面へ埋まります。 111.6111.8 は描画時に四捨五入されて 112、 つまり地面のタイルに 1 ドット食い込んだ状態です。 大きくなるたびに沈んだり戻ったりするので、見た目がわずかに揺れます。

/ を使う(正しい) 111 - 16 * 2.2 / 2 = 93.4 110 111 112 113 ヒヨコの最下行 地面のタイル 足元 111.0 → 地面にぴたりとのる // を使う(沈む) 111 - 16 * 2.2 // 2 = 94.0 110 111 112 113 地面のタイル ヒヨコの最下行 足元 111.6 → 四捨五入されて 112 地面に 1 ドット食い込む
ドットを拡大した図。// で切り捨てると引く量が減り、ヒヨコ全体が 1 ドット下へずれる。

つまずきやすい 2 点

1. *// は同じ強さ。左から順に計算される 16 * (scale + 1) // 2(16 * (scale + 1)) // 2 であって、16 * ((scale + 1) // 2) ではありません。 かけ算のあとに切り捨てるので、答えが変わります。
16 * (1.2 + 1) // 2      # → 17.0   最後に切り捨て
16 * ((1.2 + 1) // 2)    # → 16.0   先に切り捨てるとこうなる
*, /, //, % はすべて同じ優先順位です。 迷ったらかっこを付けるのがいちばん安全です。
2. 負の数では「0 に近づける」丸めではない //小さいほう(数直線の左)へ丸めます。0 方向へ丸める int() とは結果が違います。
-7 // 2      # → -4   小さいほうへ
int(-7 / 2)  # → -3   0 に近いほうへ
画面の左端より外(マイナスの座標)を扱うときに、1 ドットずれる原因になります。

Q. 関数の定義は、呼び出す行より下にあってもいい?(C では定義が先)

A. Python は関数定義の位置に制限がありません。 update() の中で self.move_boy() を呼び、move_boy() の定義は その下にある——という candy_hunt.py の書き方で問題ありません。

理由は「名前をいつ探すか」が C と Python で違うからです。

CPython
名前を探すタイミング コンパイルするとき(実行する前) その行が実際に動くとき
だから必要なこと 呼び出しより前に、宣言か定義が書いてある 呼び出しが起きる時点で定義が済んでいる
書く順番 上から順に縛られる 自由(ただし下の 3 つは例外)

Python の def「この名前で、この中身を覚えておいて」という命令文です。 関数の中身はそのとき読まれません。中の move_boy という名前を探しにいくのは、 update()実際に呼ばれた瞬間です。そのときには ファイルはすでに最後まで読み終わっていて、move_boy も登録済みなので見つかります。

① ファイルを読み込むとき 上から下へ 1 回だけ通る。中身は実行しない def update(self): self.move_boy() → 名前だけ登録 中身は読まない def move_boy(self): ... → 名前だけ登録 App() ← ここで初めて中身が動く 読み終えた時点で覚えている名前 update move_boy App 順番は関係なく、両方そろっている ② update() が呼ばれたとき ここで初めて中身の行が実行される self.move_boy() 「move_boy」という名前を いまから探しにいく ① で登録済みなので見つかる 定義が下にあっても関係ない C はここが違う コンパイルする時点で名前を確定させるので、 呼び出しより前に宣言/定義が要る。 Python は「動くとき」まで探すのを先送りする
Python は「読み込み」と「実行」が別。名前を探すのは実行するとき

ただし、位置が効く場面が 3 つある

「制限がない」のは関数の中身の話です。次の 3 つは ファイルを読み込んだ瞬間に実行されるので、順番どおりに書く必要があります。

1. トップレベルの「呼び出し」 candy_hunt.py の最後にある App() がこれです。 これは def ではなく呼び出しなので、その場で実行されます。 ファイルの先頭に移すと止まります。
App()          # ← 先頭に置くと…

class App:
    ...

# NameError: name 'App' is not defined
App() がいちばん下にあるのは、こういう理由です。
2. 引数の初期値 def f(size=BOY_SIZE):BOY_SIZE は、 def の行を読んだ瞬間に評価されます。中身とは扱いが違います。
def f(size=BOY_SIZE):    # ← この行を読んだ時点で BOY_SIZE を探す
    print(size)

BOY_SIZE = 8             # 遅すぎる

# NameError: name 'BOY_SIZE' is not defined
3. class の本体(メソッドの外)に直接書いた行 class の中でも def の外に書いた行は、読み込み時に実行されます。
class App:
    size = BOY_SIZE      # ← メソッドの外なので、すぐ実行される

BOY_SIZE = 8

# NameError: name 'BOY_SIZE' is not defined

自由なぶん、注意すること

打ち間違いは「その関数が呼ばれるまで」見つからない C はコンパイルの時点で「そんな名前はない」と教えてくれますが、 Python は実行して初めて気づきます。
def rarely_used():
    print(mistyped_name)     # 存在しない名前

print("読み込みは通る")       # ← ここまでは何ごともなく動く
rarely_used()                # ← 呼ばれて初めて NameError
ゲームだと「ゲームオーバーになって初めて落ちる」といった形で出てきます。 新しく書いた関数は、一度は必ず通してみるのが大事です。

では、どう並べるのがよいか

順番は自由なので、読みやすさで決めます。よくあるのは次の並べ方です。

確認した環境 Python 3.12.10 で、上の 3 つの NameError と、 「下にある関数を上から呼べる」ことを実際に動かして確かめました。

Pyxel について

Q. .pyxres ファイルの中身は読める? 何が入っている?

A. 読めます。.pyxres は独自の暗号化バイナリではなく、 中身が 1 個の TOML テキストファイルだけの ZIP です。 拡張子を .zip に変えれば、普通の解凍ソフトでも開けます。

import zipfile

z = zipfile.ZipFile("step0/assets.pyxres")
print(z.namelist())
# → ['pyxel_resource.toml']

print(z.read("pyxel_resource.toml").decode("utf-8")[:200])

取り出した pyxel_resource.toml は、こんな中身です。

format_version = 1

[[images]]
width = 256
height = 256
data = [[0, 0, 0, 0, 0, 0, 6, 7, 7, 6, 0], [0, 0, 0, 6, 6, 6, 6, 7, 7, 6, ...

[[tilemaps]]
...

[[sounds]]
...

[[musics]]
...

[[images]]data は、1 行が画像の 1 ライン数字 1 つがドット 1 個のパレット番号です。 上の 6, 7, 7, 6 は、宇宙船のてっぺんの 「6 = 淡い青、7 = 白」 の並びそのものです。右端の連続する 0(黒=未使用)は省略されるので、 行ごとに長さが違って見えます。

何がうれしいのか
直接書き換えるのはやめましょう 読むだけにしてください。絵の編集は pyxel edit のリソースエディタで行うのが安全です。 手で TOML を書き換えると、format_version や配列の形が崩れて 読み込めなくなることがあります。

Q. 座標や速さに小数を使ってもいい? SHIP_SPEED = 0.2 でもちゃんと動いたのはなぜ?

A. 使えます。Pyxel の描画命令は座標を小数(float)で受け取り、 画面に出すときにいちばん近いドットへ四捨五入します。 付属の型定義ファイル(pyxel/__init__.pyi)を見ると、はっきり float と書いてあります。

def blt(
    x: float,
    y: float,
    ...

どう丸められるかを実際に試した結果が次です (pyxel.Imagepset して、どのドットが点いたかを調べたもの)。

渡した値0.00.20.40.50.61.01.52.5
点いたドット00011123

四捨五入で、.5 はつねに切り上げです(切り捨てではありません)。

なぜ 1 より遅くなるのか

SHIP_SPEED = 0.2 にすると、self.ship_x0.2 ずつ、小数のまま変わっていきます。画面に出るのは丸めたあとの整数なので、 値が 1 ドットぶん動くまでに 5 フレームかかります。 1 秒 = 30 フレームなので、1 秒あたり 6 ドット。SHIP_SPEED = 1(1 秒に 30 ドット) と比べて、ちょうど 5 分の 1 の速さです。

フレーム 1 2 3 4 5 6 7 8 9 10 11 12 ship_x 71.8 71.6 71.4 71.2 71.0 70.8 70.6 70.4 70.2 70.0 69.8 69.6 描かれる x 72 72 71 71 71 71 70 70 70 70 71 70 1 ドット動いた 1 ドット動いた 0.2 × 5 フレーム = 1 ドット
小数の値は毎フレーム動いているが、画面に出るのは丸めたあとの整数だけ
自分で丸めてはいけない 「どうせ整数で描かれるなら」と、毎フレーム丸めて代入し直すと壊れます。 位置は小数のまま持ち、丸めるのは Pyxel に任せる。これが正解です。
じつは星でも同じことをしています フェーズ 3 の star[1] += star[2] は、まさにこの仕組みです。 星の速さは random.uniform(0.3, 1.8) の小数なので、 遅い星は数フレームに 1 ドットしか動きません。 だから奥行きのある、なめらかな流れに見えます。 もし整数しか使えなかったら、星の速さは 1、2、3… の 3 段階しか作れません。
使い分けの目安
おまけ:端に着くと型が int に戻る max(0, min(self.ship_x, 144)) は、はみ出したときに 0144(整数)をそのまま返します。 つまり左端に張り付いた瞬間、self.ship_x は小数から整数に変わります。 Python は型が混ざっても計算できるので、動作に問題はありません。

Q. blt()scale で絵を大きくしたら位置がズレた。XY はどう計算する?

A. scale絵の中心を固定したまま拡大します。 blt() に渡す x, y は「scale が 1 のときの左上」を指す座標のままで、 scale を上げるとそこから上下左右へ均等に絵が広がります。

4×4 の絵を blt(10, 10, img, 0, 0, 4, 4, None, 0, scale) で描き、 実際に色が付いたドットを pget で数えた結果です。

scale描かれた x描かれた y中心
110 〜 1310 〜 13(12, 12)
28 〜 158 〜 15(12, 12)
36 〜 176 〜 17(12, 12)

どの scale でも中心は (12, 12) のまま動いていません。 幅をマイナスにして左右反転させても(w = -4)、結果はまったく同じでした。

scale = 3 scale = 2 scale = 1 中心 — 動かない blt に渡す (x, y) scale=1 のときの左上 上下左右へ均等に広がる 上端 = 中心Y − 高さ × scale ÷ 2 下端 = 中心Y + 高さ × scale ÷ 2 ←「足元」はここ
scale を上げても中心は動かない。x, y は「scale=1 のときの左上」を指したまま

足元を地面に固定したいとき

キャラクターが大きくなっても地面に立っているように見せたい、というのはよくある要求です。 「下端=地面のY」になるよう、上の式を y について解きます。

中心Y + 高さ × scale ÷ 2 = 地面Y
       ↓  中心Y = y + 高さ ÷ 2 を代入して整理

y = 地面Y − 高さ × (scale + 1) ÷ 2

x は補正が要りません。横方向の中心は scale によらず保たれるので、 x を触ると逆にズレます。直すのは y だけです。

高さ 16 ドット・地面 Y = 111 で計算するとこうなります。

scaley の値実際に描かれる範囲足元
1111 − 16 = 9595 〜 110111
2111 − 24 = 8779 〜 110111
3111 − 32 = 7963 〜 110111
地面Y = 111 — この線に足元をそろえる scale = 1 y = 95 scale = 2 y = 87 scale = 3 y = 79 ● = blt に渡す (x, y) 絵の左上ではなくなる
足元(下端)をそろえると、yscale が 1 増えるごとに高さの半分だけ小さくなる

コードの形

「ぶつかった瞬間に差分を引く」のではなく、scale から毎回 Y を計算し直す形にしておくと、 あとで縮ませたりリセットしたりしても崩れません。

GROUND_Y = 111          # ヒヨコの足元を置くY座標

    def put_on_ground(self):
        """ヒヨコの足元が地面につくようにY座標を決める"""
        self.hiyoko_y = GROUND_Y - HIYOKO_HEIGHT * (self.hiyoko_scale + 1) / 2

あとは大きさを変えたところで呼ぶだけです。

        self.hiyoko_scale = 1           # 初期化のとき
        self.put_on_ground()

        self.hiyoko_scale = min(self.hiyoko_scale + 0.2, HIYOKO_SCALE_MAX)  # 花にぶつかって大きくなったとき
        self.put_on_ground()
ここは // ではなく / で割る scale0.2 きざみで増やすと、HIYOKO_HEIGHT * (scale + 1)割り切れない小数になります。ここで //(切り捨て)を使うと引く量が減り、 足元が地面へ最大 0.8 ドット沈みます。座標に小数を渡しても Pyxel は四捨五入して 描いてくれる(→ 小数の座標)ので、/ のままで問題ありません。 /// の使い分け
画面端の判定も scale の影響を受ける max() / min() で左右の動ける範囲を決めている場合、 幅を HIYOKO_WIDTH 固定で書いていると、大きくなったぶんだけ画面外へはみ出します。 見えている絵の幅は HIYOKO_WIDTH * scale なので、はみ出しを厳密に止めたいときはこちらを使います。

Q. 1 つのフォルダに複数の作品がある。pyxel package で別々にパッケージ化できる?

A. できますが、そのままだと出力が上書きされます。 pyxel package は「フォルダの中身を丸ごと」ZIP に詰め、 どれを起動スクリプトにするかを記録するだけのコマンドです。 出力ファイル名は フォルダ名で決まるため、同じフォルダを 2 回パッケージ化すると 2 回目が 1 回目を警告なしに上書きします。

pyxel package step0-learning step0-learning/hiyoko_anime.py
  → step0-learning.pyxapp    起動 = hiyoko_anime.py

pyxel package step0-learning step0-learning/fish_clock.py
  → step0-learning.pyxapp    起動 = fish_clock.py ← 同じ名前で上書き

中身も毎回フォルダ内の全ファイルが入ります。作品が 2 つあれば、 どちらの .pyxapp にも相手のスクリプトとリソースが同梱されます。

解決:作品ごとにサブフォルダを切る

APP_DIRいちばん下のフォルダ名が出力名になります。 作品ごとにフォルダを分ければ、名前は自動で分かれ、中身も必要なファイルだけになります。

1 つのフォルダにまとめた場合 step0-learning/ hiyoko_anime.py learning_bgm.pyxres fish_clock.py fish_clock.pyxres step0-learning.pyxapp step0-learning.pyxapp 同じ名前 — 2 回目が 1 回目を上書きする 中身はどちらも全 4 ファイル(4610 B) 作品ごとにフォルダを分けた場合 hiyoko_anime/ hiyoko_anime.py learning_bgm.pyxres fish_clock/ fish_clock.py fish_clock.pyxres hiyoko_anime.pyxapp 2883 B fish_clock.pyxapp 1878 B
出力名は APP_DIR のいちばん下のフォルダ名で決まる
step0-learning/
├── hiyoko_anime/
│   ├── hiyoko_anime.py
│   └── learning_bgm.pyxres
└── fish_clock/
    ├── fish_clock.py
    └── fish_clock.pyxres
pyxel package step0-learning/hiyoko_anime step0-learning/hiyoko_anime/hiyoko_anime.py
pyxel app2html hiyoko_anime.pyxapp

pyxel package step0-learning/fish_clock step0-learning/fish_clock/fish_clock.py
pyxel app2html fish_clock.pyxapp

実測したサイズの差です(作品 2 つ・pyxel 2.9.9)。

ファイル1 フォルダにまとめた場合フォルダを分けた場合
hiyoko_anime.pyxapp4610 B2883 B
fish_clock.pyxapp4608 B1878 B
hiyoko_anime.html6367 B4063 B
fish_clock.html6361 B2721 B
コードの変更は要りません pyxel.load("learning_bgm.pyxres") の相対パスは 実行するスクリプトがあるディレクトリを基準に解決されます。 スクリプトとリソースを同じフォルダに入れたまま移動する限り、書き換えは不要です。 実行も pyxel run step0-learning/hiyoko_anime/hiyoko_anime.py で これまでどおり動きます。
フォルダを分けずに済ませたいとき .pyxapp ができた直後に別名へリネームすれば、上書きは避けられます (pyxel app2html はリネーム後のファイルでも正しく動き、 起動スクリプトの記録も保たれることを確認済みです)。 ただしどちらの .pyxapp にも全作品のファイルが入ったままなので、 作品が増えるほど無駄が膨らみます。フォルダを分けるほうが素直です。
.pyxapp の中身 実体は ZIP です。フォルダ名/.pyxapp_startup_script に 起動するスクリプト名が 1 行だけ書かれていて、あとは元のファイルがそのまま入っています。 zipfile モジュールで中を確認できます(→ .pyxres の中身と同じ要領)。

Q. 「分が 00 の間ずっと」のような条件で、1 回だけ処理したい

A. 「前回の値」を覚えておき、変わった瞬間だけ動かします。 条件そのものを厳しくしても解決しません。update() は 1 秒に fps 回呼ばれるので、 「分が 00」のような条件はその 1 分間ずっと真だからです。

時報で起きたのがこれでした。fps=15 なので play_chime() が 900 回呼ばれ、 pyxel.play()呼ぶたびに先頭から鳴らし直すため、 チャイムの出だしだけが延々と繰り返される音になります。

書き方

__init__() で「前回の値」を用意します。

        self.last_minute = datetime.now().minute    # 前回のフレームで見た「分」

update() では、値が変わったときだけ中を通します。

        # 分が変わった瞬間だけ判定する
        if self.minute != self.last_minute:
            if self.minute == 0:
                self.play_chime()
            self.last_minute = self.minute      # これが無いと 1 分間鳴り続ける
フレーム 1 2 3 4 5 6 7 8 minute(今の分) 59 59 59 0 0 0 0 0 last_minute(前回) 59 59 59 59 0 0 0 0 分が変わった 前回と比べないと 1 分間ずっと鳴り直す 前回と比べると ← ここだけ鳴る
minute59 から 0 に変わったフレームだけ last_minute と食い違う。 そこを捕まえて 1 回だけ鳴らす。
btn()btnp() の関係と同じです pyxel.btn() は「押されている間ずっと真」、pyxel.btnp() は「押した瞬間だけ真」。 Pyxel がキーに対してやってくれていることを、自分で last_minute を持って 「分」に対してやっているのがこの書き方です。
初期値を -1 にしない self.last_minute = -1 にすると、たまたま 10:00:30 に起動したとき いきなりチャイムが鳴ります0 != -1 なので条件が成立してしまう)。 「今の分」を入れておけば、起動後に分が変わったときから判定が始まります。

この形は時報だけのものではありません。 「HP が 0 になった瞬間にゲームオーバーの音を鳴らす」「スコアが 1000 を超えた瞬間に演出を出す」など、 状態がずっと続くのに処理は 1 回でいい場面すべてで同じ書き方をします。

Q. 音をもっと長く伸ばしたい。SPEED が 99 までしか入らない

A. SPEED を上げるのではなく、同じ音を複数ステップ並べます。 そのうえで EFX は最後のステップだけに付けるのがコツです。

SPEED は「1 音の長さ」で、120 で 1 音 1 秒。エディタの入力欄は 2 桁なので 99(=0.825 秒)が上限になります。長さが足りないときは音の数を増やすのが素直な解決です。

いまの設定(1 音+休符・F) 1.00 秒 4 つ並べて 全部 F 2.50 秒 4 つ並べて 最後だけ F 2.50 秒 棒 1 本 = 100 ミリ秒。高さは音量
Sound.save() で WAV に書き出し、100 ミリ秒ごとの音量を測ったもの。 音を並べても EFX を全部に付けると 1 音ごとに鳴り直す。

エディタでの操作は「同じ音を 4 回入力し、EFX 欄は 4 つ目だけ F」です。 同じ高さの音が続くので途切れず、最後だけきれいに減衰します。 もっと伸ばしたいときは音の数を増やします(8 つ並べれば約 4.5 秒)。

EFX で減衰の始まりを変える

EFX意味音量カーブ(1 音 0.5 秒)
N(空欄)なし999999 最後まで鳴って急に止まる
Fフェードアウト986410 最初から減衰する
H半分フェードアウト999730 後半だけ減衰する
Q1/4 フェードアウト999970 最後の 1/4 だけ減衰する
S / Vスライド / ビブラート音程を変える効果。長さには影響しない

並べる方法と組み合わせて「4 つ並べて最後だけ H」にすると 9999999999999999997300000 となり、いちばん長く伸びます。

どうしても SPEED を大きくしたいとき

99 の制限はエディタの入力欄が 2 桁だからで、値そのものに上限はありません。 pyxel.load() の直後にコードから書き換えられます。

        pyxel.sounds[0].speed = 200     # エディタの上限 99 を超えて設定できる
エディタの試聴と実際の音が食い違う コードで speed を書き換えても .pyxres には保存されません。 あとでエディタを開いて音を確かめると、ゲーム中に鳴る音と違って聞こえます。 エディタの中だけで完結する「音を並べる」方法のほうが、あとから自分が混乱しません。

Q. 2 つの効果音を同時に鳴らしたのに、片方しか聞こえない/「出た」感じがしない

原因は2 段階あります。まず ①チャンネルの上書きを疑い、 それが違えば ②マスキング(音が似すぎて混ざる)を疑います。

① 同じチャンネルに 2 回鳴らすと、あとの 1 回が前を消す

Pyxel の play(ch, snd) は、そのチャンネルで鳴っているものを止めて、 新しい音に差し替える命令です。停止と再生が 1 つになっている、と考えてください。

            pyxel.play(CH_SE, SOUND_CANDY)      # ch2 = お菓子の音
            ...
                pyxel.play(CH_SE, SOUND_GHOST)   # ← 同じ ch2! 上書きされる

やっかいなのは、この 2 行が同じフレームの中で走ることです。 音が出るのはフレームの終わりなので、お菓子の音は 1 サンプルも鳴らないまま消えます。 「片方が途中で切れる」のではなく、最初からなかったことになります。

0.0 0.1 0.2 0.3 0.4 秒 × 2 つとも ch2 に鳴らした お菓子 0.267 秒 1 度も鳴らない ch2 増えた 0.167 秒 これだけ鳴る ch2 ○ ch2 と ch3 に分けた お菓子 0.267 秒 ch2 増えた 0.167 秒 重なって、2 つとも鳴る ch3
上:あとに書いた play が前を消してしまう。下:チャンネルを分ければ同時に鳴る
見分け方 「音が消えた」ではなく「別の音に化けた」ように聞こえたら、これです。 お菓子を取ったのに、いつもと違う短い音が鳴る——という聞こえ方をします。 片方だけを鳴らす場面(=ふつうにお菓子を取っただけ)では正常なので、 「音は鳴っているのだから合っているはず」と思い込みやすいのが厄介なところです。

② 似すぎている音は、2 つでも 1 つに聞こえる(マスキング)

チャンネルを分けても弱いままなら、こちらです。 耳は同時に鳴る似た音を 1 つにまとめて聞きます。とくに あとから鳴った小さい音・短い音は、先に鳴っている音に隠されます。 これをマスキングと呼びます。

step1 の 2 つの音は、埋もれる条件がそろっていました。

お菓子(SOUND 0)お化けが増えた(SOUND 2)
音色PP同じ
音量66同じ
音符C3 E3 G3 C4C3 G3片方に完全に含まれる
長さ0.267 秒0.167 秒短いほうが負ける

手当ては 4 つ。1 つでも効きますが、2 つ以上ずらすと確実です。

ずらすところやり方効き目
音色PSN 別の楽器になるので、いちばん確実
音域1 〜 2 オクターブ下げる 高い音どうしの取り合いから抜けられる
長さ相手より長くする 相手が終わったあとひとりで残るので必ず気づく
音量67 単純だが効く。ただし上限があるので他と併用
意味の向きもそろえる 上がる音=よいこと、下がる音=わるいことという感覚は、ほぼ世界共通です。 「お化けが増えた」はわるいことなので、低い音・下がる音にすると、 聞き分けやすさと意味の両方が一度にそろいます。
それでも足りないときは「ずらして鳴らす」 同じ瞬間に 2 つ鳴らすのをやめ、数フレーム遅らせて鳴らす手もあります。 「お菓子を取った → → お化けが出た!」となって、演出としても強くなります。 ただしコードが増えます(鳴らす音と鳴らす時刻を覚えておき、 update でその時刻が来たら鳴らす)。 まずは音そのものを変えるほうを試してください。

Q. Web に公開した Pyxel 作品を全画面にしたい。F11 が効かない

A. ページに「全画面」ボタンを自分で足します。 pyxel app2html が出力する HTML には、全画面にする仕組みがまったく入っていません。 読み込まれる pyxel.js(2.9.9)を確認しましたが、 requestFullscreen に類する処理は 1 行もありませんでした。

なぜ F11 が効かなくなるのか

アプリが起動した瞬間から、Pyxel の wasm ランタイムが document に キー入力のハンドラを付け、自分が使うキーの既定の動作を打ち消します。 F11 もその対象に入っているため、ブラウザの全画面切り替えまで一緒に止まってしまいます。

実際に keydown を投げて、既定動作が打ち消されるか(defaultPrevented)を測りました。

押したキーCLICK TO START の画面ゲーム起動後
F11打ち消されない打ち消される
ArrowLeft打ち消されない打ち消される
Space打ち消されない打ち消されない

起動前は素通りしていたものが、起動後だけ打ち消されています。 ページ側の JavaScript が原因で、ブラウザの設定や Pyxel の不具合ではありません。

対策 1:ブラウザのメニューから全画面にする(コード変更なし)

Chrome なら右上の メニューの中に全画面のアイコンがあります。 メニュー操作はページのキー処理を経由しないので、こちらからなら全画面にできます。 今すぐ確かめたいときはこれが手軽です。

対策 2:公開する HTML に「全画面」ボタンを足す

app2html が出力する HTML は、実質 4 行しかない小さなファイルです。 その後ろにボタンとスクリプトを足すだけで、全画面ボタンを付けられます。

<meta charset="utf-8">

<style>
  #fs-btn {
    position: fixed; right: 12px; bottom: 12px; z-index: 9999;
    padding: 8px 14px; color: #fff; background: rgba(50,50,50,.8);
    border: 1px solid #888; border-radius: 6px; cursor: pointer;
  }
  /* 全画面のあいだはボタンを隠す(抜けるときは ESC) */
  :root:fullscreen #fs-btn { display: none; }
  :root:-webkit-full-screen #fs-btn { display: none; }
</style>
<button id="fs-btn">全画面</button>
<script>
const fsBtn = document.getElementById("fs-btn");

fsBtn.addEventListener("click", async () => {
  try {
    await document.documentElement.requestFullscreen();
  } catch (e) {
    console.log("[fs] 全画面にできませんでした:", e.name, e.message);
  }
  document.getElementById("canvas").focus();   // キー入力を戻す
});

document.addEventListener("fullscreenchange", () => {
  fsBtn.hidden = !!document.fullscreenElement;   // 全画面のあいだは隠す
  document.getElementById("canvas").focus();
});
</script>
全画面のあいだはボタンを隠し、ESC で抜ける ボタンが出たままだと見栄えが悪いので、全画面中は隠しています。 ESC で全画面を抜けられることは、ブラウザが保証しています。 ページ側の JavaScript がどれだけキーを横取りしても、この動作だけは止められません (navigator.keyboard.lock() という専用の API を使わないかぎり)。 pyxel.js がこの API を使っていないことは確認済みです。
通常の表示 ゲーム画面 上の 38px 分が使えない 全画面 requestFullscreen() のあと ゲーム画面 ウィンドウ全部がゲーム画面になる
ゲーム画面はもともとウィンドウいっぱいに広がる。全画面にすると、タブバーとアドレスバーの分まで使えるようになる

足すときに引っかかった 3 点

1. <meta charset="utf-8"> を忘れると日本語が化ける app2html の出力には 文字コードの指定が入っていません。 絵と音しか入っていないうちは問題になりませんが、 ボタンに日本語を書いたとたん化けます(実際に化けました)。 ファイルの先頭、<!doctype html> の次の行に足しておきます。
2. 全画面にしたあと canvas.focus() を呼ぶ ボタンをクリックすると、キーボードの入力先がボタンに移ります。 そのままだと矢印キーがゲームに届かなくなるので、 入力先を canvas に戻す必要があります。
3. requestFullscreen() はクリックの中からしか呼べない ブラウザの安全策で、全画面はユーザーが操作したときにしか許可されません。 ページを開いた直後に自動で全画面にする、といった書き方はできません (試すと TypeError not granted で拒否されます)。
ページの構造 pyxel.js は次の形で画面を作ります。ボタンを足す位置を考えるときの参考に。
<body>
  <div id="pyxel-screen">
    <canvas id="canvas">   ← ゲーム画面。ウィンドウの大きさに合わせて拡大される
  </div>
  <button id="fs-btn">    ← ここに足す
</body>
canvas の表示サイズを測ったところ、ウィンドウの内側とぴったり同じでした。 つまり全画面にすれば、そのぶんそのままゲーム画面が大きくなります。
pyxel.fullscreen() は使えないのか Python 側には pyxel.fullscreen(enabled) という関数があります。 ただしこれはパソコンで直接実行するとき用のもので、 Web 版で頼りにするものではありません。Web では上のボタン方式が確実です。

Q. pyxel.init() より先に pyxel.load() を書いたらエラーになった。init() は何をしている?

A. pyxel.init() は「ウィンドウを開く関数」だと思われがちですが、 それ以上に大事な仕事をしています。画像・音・色をしまっておくための「空の入れ物」を用意することです。 pyxel.load() はその入れ物に .pyxres の中身を流し込む関数なので、 入れ物が無いうちに呼ぶと、中身の行き場がなくてエラーになります。

import pyxel

pyxel.load("candy_hunt.pyxres")   # ← init より前だと、ここで落ちる
pyxel.init(160, 120)
thread '<unnamed>' panicked at pyxel-core\src\pyxel.rs:48:31:
Pyxel not initialized
pyo3_runtime.PanicException: Pyxel not initialized

Pyxel not initialized は「Pyxel はまだ初期化されていない」という意味です。 load だけの話ではなく、clsblttextbtnplay など エンジンの中身に触る関数はすべて同じエラーになります。

import pyxel しただけの状態 画面もイメージバンクも まだ存在しない pyxel.load("candy_hunt.pyxres") Pyxel not initialized pyxel.init(160, 120) を実行したあと 画面       160 × 120 パレット     16 色 イメージバンク  3 枚(各 256 × 256) サウンド     64 枠 ミュージック   8 枠 pyxel.load("candy_hunt.pyxres") 空き枠に絵と音が入る
init() は入れ物を作る係、load() は中身を入れる係。順番は逆にできない

init() が用意する枠は、実際に数えると次のとおりでした(pyxel 2.9.9 で実測)。

用意されるもの数・大きさ
画面init() に渡した幅 × 高さ
パレット(pyxel.colors16 色
イメージバンク(pyxel.images3 枚、各 256 × 256 ドット
サウンド(pyxel.sounds64 枠
ミュージック(pyxel.musics8 枠

load() がしているのは、この空っぽの枠に .pyxres の中身を上書きすることです。 リソースエディタで描いた絵が「イメージバンク 0」に入るのも、 init() が先に 0 番の枠を作っているからです。

init() の前でも読めるものがある pyxel.COLOR_BLACK(= 0)や pyxel.KEY_LEFT(= 1073741904)、 pyxel.VERSION のような定数は、init() の前でも読めます。 ただの数字なので、エンジンに聞きに行く必要がないからです。
むしろ注意したいのは pyxel.width で、init() の前に読むと エラーにならず 0 が返ってきます。 「画面幅の半分に置く」つもりの計算が黙って 0 になるので、こちらのほうが気づきにくい失敗です。

順番の覚え方:init → load → 自分の変数 → run

class App:
    def __init__(self):
        pyxel.init(SCREEN_WIDTH, SCREEN_HEIGHT, title="Candy Hunt")   # 1. 入れ物を作る
        pyxel.load("candy_hunt.pyxres")                               # 2. 絵と音を入れる
        self.boy_x = 76                                               # 3. 自分の変数を決める
        pyxel.run(self.update, self.draw)                             # 4. ループを始める

3 番と 2 番は入れ替えても動きますが、1 番がいちばん先、4 番がいちばん最後という点は動かせません。

pyxel.run() は必ず最後に書く run() は「ここから毎フレームのループに入る」という関数で、 ゲームが終わるまで戻ってきませんrun() の下に書いた行は Esc でウィンドウを閉じるまで実行されないので、 「load のし忘れに気づいて run の下に足す」といった直し方はできません。

Q. お菓子を「取った」感じが薄い。かすっただけで次のお菓子が出てしまう

A. 当たり判定の四角が、絵の見た目より大きいからです。 判定は 8×8 の四角どうしで行っていますが、 (1)絵は 8×8 のマス全部に色が付いているわけではなく、 (2)四角が1 ドットでも重なれば当たりという条件なので、 角がかすっただけで成立してしまいます

step1 で描いた絵を実際に数えると、こうなっていました。

8×8 の中で色が付いているドット数色が付く範囲
男の子34 / 64 ドット横 1〜6(幅 6)、縦 0〜7(高さ 8)
お菓子32 / 64 ドット横 0〜7(幅 8)、縦 1〜6(高さ 6)

男の子は左右に 1 ドットずつ、お菓子は上下に 1 ドットずつ余白があります。 つまり四角が触れていても、絵にはまだすき間がある状態が起こります。

どれくらい「かすっている」のか

男の子を中央に固定して、お菓子を 1 ドットずつずらしながら 「当たりと判定されるか」「絵が実際に重なっているか」を全通り調べました。

お菓子の位置をずらしたとき、いまの判定で「当たり」になる範囲 中央が「ぴったり重なった位置」。1 マス = 1 ドットのずれ 判定を 4×4 に縮めたときの範囲 絵が 1 ドットも重なっていない 225 通り中 81 通り(36%) 少しだけ重なっている しっかり重なっている 色が濃いほど、絵として重なっている ドット数が多い(最大 22 ドット) 黄色の枠まで縮めると 当たる範囲は 49 通りになり、 ピンクのマスは 0 になる 外周のピンク=「当たったのに、絵は離れている」位置
いまの判定では、外側の 1〜2 周ぶんが「触れていないのに取れる」範囲になっている

外周のピンクのマスが、まさに「追いかけているだけで取れてしまう」感覚の正体です。

対策:判定の四角を、絵より小さくする

ゲームでは「絵の大きさ」と「当たり判定の大きさ」を別々に決めるのが普通です。 絵の中央だけを判定に使うようにすると、「ちゃんと重なったときだけ当たる」ようになります。

定数を 2 つ足します。

BOY_SIZE = 8            # 男の子の絵の大きさ(縦横とも 8 ドット)
BOY_SPEED = 2           # 1 フレームで進むドット数

CANDY_SIZE = 8          # お菓子の絵の大きさ

HIT_SIZE = 4                                # 当たり判定に使う四角の大きさ(絵の中央だけを見る)
HIT_OFFSET = (BOY_SIZE - HIT_SIZE) // 2     # 絵の左上から、判定の四角までの距離(= 2)

そして check_candy() の呼び出しを差しかえます。

    def check_candy(self):
        """お菓子にしっかり重なったら、スコアを増やして次の場所へ置きなおす"""
        if is_hit(self.boy_x + HIT_OFFSET, self.boy_y + HIT_OFFSET, HIT_SIZE,
                  self.candy_x + HIT_OFFSET, self.candy_y + HIT_OFFSET, HIT_SIZE):
            self.score += 1
            self.place_candy()
place_candy() のほうは 8×8 のままにしておきます こちらは「置きなおした先が男の子と重ならないように」という別の目的なので、 広めに見ておくほうが安全です。判定を縮めてしまうと、 男の子のすぐ横にお菓子が出てしまいます。 同じ is_hit() でも、目的によって渡す四角の大きさを変えてよい ——これがこの書き方の便利なところです。

どのサイズにするか

全通り調べた結果です。HIT_SIZE を変えたときの手ごたえの目安にしてください。

HIT_SIZEHIT_OFFSET当たる位置絵が重ならないのに当たる重なりドット数(最小/平均)
8(現状)0225 通り81 通り(36%)0 / 4.8
61121 通り2 通り(2%)0 / 8.6
4(おすすめ)249 通り0 通り6 / 14.7
239 通り0 通り18 / 20.4

4 にすると、当たった瞬間には必ず 6 ドット以上が重なっているので、 見た目に「乗った」と分かります。2 はほぼ完全に重ねる必要があり、 お化けから逃げながら取るには厳しすぎるでしょう。

このプロジェクトでは HIT_SIZE = 4 に決めました 2026-09-04、実際に遊んで比べたうえで「4 がいちばん違和感がない」という判断です。 step1 の以降のフェーズ(フェーズ 6 の「お化けに捕まる」判定を含む)は、 この値を前提にして進めます。
小さくしすぎると「絶対に取れない位置」が生まれます BOY_SPEED = 2 なので、男の子の座標はずっと偶数のままです (初期値 76 も、端で止める 0 と 152 も偶数)。 一方お菓子は randint なので奇数の位置にも出ます。 つまりずれが 0 になることは決してありません
HIT_SIZE = 1 にすると「ずれ 0 のときだけ当たり」になるため、 お菓子が奇数の位置に出た瞬間に絶対に取れなくなります。 判定を縮めるときは、移動の刻み幅より広く残すのが鉄則です。

「取った実感」はもう一つの方向からも作れる

当たり判定を直すのは「取れるタイミングを正しくする」対策です。 これとは別に、「取れたことを分かりやすく伝える」という対策もあります。

どれも「判定が正しいこと」の上に乗せるものなので、まず判定を直すのが先です。

Q. お化けの速さはどう決める? GHOST_SPEED = 1 でも速く感じる

A. その感覚は正しいです。男の子の半分の速さでも、全力で逃げ続けて 5 秒で捕まります。 「半分の速さなら余裕で逃げられる」という直感が当たらないのは、 画面が狭くて、必ず壁に突き当たるからです。

実際に数えてみました(画面 160×120、BOY_SPEED = 2、 お化けは左上 (0, 0) から出発、判定は HIT_SIZE = 4)。 男の子はお菓子を無視して、お化けから遠ざかる向きへ全力で逃げ続けるという条件です。

GHOST_SPEED捕まるまで体感
1(現在)149 フレーム=5.0 秒逃げるだけで精一杯
0.5297 フレーム=9.9 秒お菓子を取りに寄り道できる
0.25593 フレーム=19.8 秒ほぼ追われている感じがしない
逃げ方を工夫しても変わりません 「斜めに逃げる」「横だけに逃げる」のどちらでも 149 フレームで同じでした。 お化けは横と縦を別々に、同時に詰めてくるので、斜めに逃げても縦の差は縮まり続けます。 壁に着いてしまえば、あとは 1 フレームに 1 ドットずつ詰められるだけです。 速さを変える以外に、逃げ切る方法はありません。

速さに小数を使うときの落とし穴

Pyxel は座標に小数を渡せる(#float-coord)ので GHOST_SPEED = 0.5 はそのまま動きます。ただし どんな小数でもよいわけではありません。

GHOST_SPEED = 0.5  男の子は x = 10 で止まっている 8.5 9.0 9.5 10.0 10.5 10.0 にぴったり乗って止まる GHOST_SPEED = 0.3  同じ条件 9.6 9.9 10.0 10.2 9.9 → 10.2 → 9.9 → … 目標をまたいで往復し続ける 10.0 をふまない(点線)ので、いつまでも止まらない
速さが目標までの距離を割り切れないと、目標の周りで永久に往復する

move_ghost() は「小さければ増やす/大きければ減らす」なので、 == になったところで初めて止まります。 ぴったり == にならない速さだと、目標をまたいで永久に往復します。 実際に 400 フレーム動かして確かめた結果です。

GHOST_SPEED結果理由
1 / 0.5 / 0.25 ぴったり止まる 2 で割り切れる数なので、2 進数でも誤差が出ない
0.3 9.9 ↔ 10.2 を往復 目標との差 0.1 を 0.3 でまたいでしまう
0.1 10.0 ↔ 10.1 を往復 0.1 は 2 進数で表せず、足すたびに誤差がたまる
小数を使うなら 0.50.250.125 から選ぶ いずれも 1 を 2 で割っていった数なので、コンピュータの中でも誤差なく表せます。 0.30.1 は、見た目はきりのよい数でも 2 進数では割り切れず、足すたびに少しずつずれます
BOY_SPEED = 2 なので男の子の座標は必ず偶数、お化けの座標は 0.5 刻みになり、両者は必ずどこかで一致します。だから 0.5 は安全です。
0.5 のときの見た目 描画は四捨五入(.5 は切り上げ)なので、実座標 0.5, 1.0, 1.5, 2.0 …1, 1, 2, 2 … と描かれます。 2 フレームで 1 ドットずつ進むことになり、動きはなめらかに見えます。

速さ以外で難しさを調整する手

「捕まるまでの秒数」は速さだけで決まるものではありません。次の手も使えます。

「2 進数で正確」なだけでは足りない ── 0.75 の落とし穴

上で「1 を 2 で割っていった数なら安全」と書きましたが、これだけでは足りません0.750.5 + 0.25 なので 2 進数でぴったり表せますが、 それでも往復します

男の子が x = 76 で止まっているとき(実測)

  速さ 1     → 76.0, 76.0, 76.0 …          ぴったり止まる
  速さ 0.75  → 75.75, 76.5, 75.75, 76.5 …  ★ 往復(振れ幅 0.75)
  速さ 0.5   → 76.0, 76.0, 76.0 …          ぴったり止まる
  速さ 0.25  → 76.0, 76.0, 76.0 …          ぴったり止まる

理由は「お化けが立てる座標」に男の子の座標が含まれていないからです。 お化けは 0 から速さの倍数の位置しか通れません。

速さ通れる座標そのうち整数になるのは
0.750, 0.75, 1.5, 2.25, 3.0, 3.75 … 3 の倍数だけ(0, 3, 6, 9 …)
0.50, 0.5, 1.0, 1.5, 2.0 すべての整数
0.250, 0.25, 0.5, 0.75, 1.0 すべての整数

男の子の座標は BOY_SPEED = 2 なので必ず偶数です。 0.75 の倍数で偶数になるのは 6 の倍数だけなので、 76 のような座標にはどうやっても乗れません

本当の条件は「速さの倍数が、相手の座標を含むこと」 1 / 2 のべき乗0.50.250.125)が安全なのは、 その倍数がすべての整数を含むからです。 0.751.5 のように分子が 1 でない分数は、 2 進数で正確に表せても飛び石になって相手をまたぎます

いちばん安全なのは、小数を使わないこと

そもそも「1 回に進む量」を減らすかわりに、「動く回数」を減らすと、 座標はずっと整数のままになります。小数の話が丸ごと消えます。

class Ghost:
    def __init__(self, x, y, move_every):
        self.x = x
        self.y = y
        self.move_every = move_every        # 何フレームに 1 回動くか

    def move(self, target_x, target_y):
        if pyxel.frame_count % self.move_every != 0:
            return                          # 今回は自分の番ではない

        if self.x < target_x:
            self.x += 1                     # 進むのはいつも 1 ドット
        ...
move_every実質の速さ座標
11 ドット/フレームつねに整数
往復しない
20.5
30.333…(小数では危険な値)
40.25

見た目も変わりません。描画はどうせ整数のドットに丸められるので、 「0.5 ずつ進む」も「2 フレームに 1 ドット進む」も画面上は同じです。 それでいて 1/3 のような、小数では扱えない速さが使えます。

この学習プロジェクトでの扱い step1 フェーズ 8 では GHOST_MOVE_EVERY = [1, 2, 3, 4] を採用しました。 4 体とも実質の速さが違うので重なって見えず、座標も整数のままです。 同じ速さにすると、600 フレーム後には 4 体が完全に 1 か所へ重なります(実測)。

A. ポイントは「消す命令はない。描かなければ、それが消えたということ」です。

        for ghost in self.ghosts:
            if pyxel.frame_count - ghost.born_frame < 30 and not is_blink_on():
                continue                # 出てきて 1 秒は、点滅の消える側で描かない

            pyxel.blt(ghost.x, ghost.y, 0, 8, 0,
                      GHOST_SIZE, GHOST_SIZE, pyxel.COLOR_BLACK)

① 画面は毎フレーム、まっさらから描き直している

draw() のいちばん上を思い出してください。

    def draw(self):
        pyxel.cls(pyxel.COLOR_BLACK)        # ← 毎フレーム、画面を黒で塗りつぶす
        ...

1 フレームごとに、画面はいったん全部消えています。 そのあと blt() で描いたものだけが見えます。 つまり画面は「前のフレームの続き」ではなく、毎回ゼロから作り直したものです。

だから「隠す命令」は存在しません お化けを消したければ、その回だけ blt() を呼ばなければよいのです。 cls() がすでに消してくれているので、あとは描かないだけで消えます。
逆に言うと、毎フレーム描き続けているから、止まって見えているだけです。

continue は「この 1 個だけ飛ばして、次へ」

for の中で continue に来ると、その下は実行せずに、次の 1 個へ進みます

書き方意味
return関数ごと抜ける。残りのお化けも処理されない
continueいま扱っている 1 個だけとばす。次のお化けへ進む
breakくり返しをやめる。残りのお化けは処理されない

ここでやりたいのは「この 1 体だけ、今回は描かない」なので continue です。 return にすると、そのあとの男の子やスコアまで描かれなくなります

③ 条件は 2 つに分かれている

and でつないであるので、両方そろったときだけ continue します。

部分意味
pyxel.frame_count - ghost.born_frame < 30 出てきてから 30 フレーム(1 秒)未満か。 つまり「出たばかり」かどうか
not is_blink_on() 点滅の「消えている側」か。 is_blink_on() は「見えている側なら True」なので、 not を付けて裏返している

表にすると、描かないのは 4 通りのうち 1 つだけです。

出たばかり?点滅は見える側?結果
はい見える側描く
はい消える側描かない ← ここだけ
いいえ(1 秒たった)見える側描く
いいえ(1 秒たった)消える側描く

1 秒たつと 1 つめの条件がずっと False になるので、 and 全体も必ず False。以後は毎フレーム必ず描かれ、点滅が止まります。 「点滅をやめる処理」を別に書く必要はありません。

フレーム(お化けが出てきた瞬間を 0 とする) 181920 2129 303132 出たばかり? 点滅は見える側? continue する? 画面 はいはいはい はいはい いいえいいえいいえ 見える 見える 消える 消える 消える 見える 見える 見える しない しない する する する しない しない しない ここから先は必ず描かれる(点滅が止まる) cls() が毎フレーム画面を消すので、blt() を呼ばなかったフレームは「何もない」= 消えて見える。 これがくり返されると、点滅しているように見える。
描くフレームと描かないフレームが交互になると、点滅して見える

読みにくいと感じたら、名前を付けて分ける

and not が並ぶと、頭の中で 2 回ひっくり返すことになります。 途中に名前を付けると、そのまま読めるようになります

        for ghost in self.ghosts:
            is_new = pyxel.frame_count - ghost.born_frame < 30      # 出てきたばかり?

            if is_new and not is_blink_on():
                continue

            pyxel.blt(...)

あるいは「描く条件」のほうを書くと、continue がいらなくなります。

        for ghost in self.ghosts:
            is_new = pyxel.frame_count - ghost.born_frame < 30

            if not is_new or is_blink_on():      # 出て 1 秒たった、または 見える側なら描く
                pyxel.blt(...)
どちらでも動きます。読みやすいほうで continue を使う形は「例外を先に片づけて、あとは素直に描く」書き方です。 条件が増えたときに、if の入れ子が深くならないのが利点です。 一方、下の形は「描くのはこういうとき」と正面から書いてあるので、 条件が 1〜2 個なら読みやすくなります。
数字の 30 は定数にしてもよい場所です 「出てきてから何フレーム点滅させるか」なので、 GHOST_BLINK_FRAMES = 30 のような名前を付けておくと、 あとから長さを変えたくなったときに探さずに済みます。 いまは 1 か所しか出てこないので、そのままでも問題ありません (フェーズ 4 で決めた「2 か所以上に出たら格上げ」の基準)。

Q. walk_timer = 1 としているのは、walk_timer = 0 ではだめ?

A. だめです。0 という値そのものに「止まっている」という意味を持たせているので、 歩き出すところで 0 を入れるのは「歩き出さなかったことにする」のと同じになります。 (step2 フェーズ 4try_move() の最終行の話です)

        self.player_x = next_x
        self.player_y = next_y
        self.walk_timer = 1                     # ← ここを 0 にすると壊れる

① 実際に 0 にすると、こうなります

上キーを押しっぱなしにしたときの、最初の 6 フレームです。

=== walk_timer = 1(正しいほう)===
  f0 timer=1  盤面 y=3  from_y=4  画面 py=73
  f1 timer=2  盤面 y=3  from_y=4  画面 py=72     ← 1 フレームに 1 ドット
  f2 timer=3  盤面 y=3  from_y=4  画面 py=71
  f3 timer=4  盤面 y=3  from_y=4  画面 py=70

=== walk_timer = 0(まちがい)===
  f0 timer=0  盤面 y=3  from_y=4  画面 py=74
  f1 timer=0  盤面 y=2  from_y=4  画面 py=74     ← 1 フレームに 1 マス!
  f2 timer=0  盤面 y=2  from_y=4  画面 py=74     ← 絵はまったく動かない
  f3 timer=0  盤面 y=2  from_y=4  画面 py=74

絵は出発地点に張りついたまま、盤面の上だけ猛スピードで進みます。 壁にぶつかるまで一瞬です。原因は 2 つあります。

② タイマーが一度も進まないので、到着処理も走らない

    def update(self):
        if self.walk_timer > 0:                 # ← 0 のままなので、ここに一度も入れない
            self.walk_timer += 1
            if self.walk_timer > MOVE_FRAMES:
                self.walk_timer = 0
                self.from_x = self.player_x     # ← だから、これも実行されない
                self.from_y = self.player_y

self.from_yずっと最初の値のままです。 slide_pos()from_y を出発点にして描くので、 player_y がいくら進んでも絵は最初のマスから動きません。 上の実測で py=74 が並んでいるのが、それです。

③ 毎フレーム、キーを見に行ってしまう

        if self.walk_timer == 0:                # ← ずっと成り立ってしまう
            self.handle_key()

pyxel.btn()押しているあいだ、ずっと True です。 「歩いているあいだはキーを見ない」というブレーキが効かなくなるので、 1 フレームに 1 マス=毎秒 30 マス進みます。

④ では、なぜ 1 なのか(0 始まりの 0〜7 ではだめ?)

walk_timer は「歩き始めてから何フレーム経ったか」を表す数です。 キーを押したそのフレームは、もう1 フレーム目なので 1 から始めます。

そして 1〜8 の 8 段階が、1〜8 ドットにそのまま対応します。 0 始まりで 0〜7 にすると「0 ドットの回」ができてしまい、 1 マス歩くのに 9 フレームぶんの位置が必要になります。 0 ドットの絵(=出発マスにいる姿)は、キーを押す前のフレームですでに描かれているので、 動き出す最初の 1 枚は 1 ドットが正解です。

フレームwalk_timer出発マスからのドット
キーを押す前00(止まっている姿)
押したフレーム11
その次22
8 フレーム目88 = 到着
「特別な値」に意味を持たせる書き方 0 のようなふつうは出てこない値に、特別な意味を持たせるやり方は、 プログラムでよく使われます。変数が 1 つで済むのが利点です。
もし 0 始まりのカウンタにしたいなら、 self.walking = True / False別に持つことになります。 こちらは意味がはっきりする代わりに、2 つの変数を常にそろえて動かす手間が増え、 片方だけ更新し忘れる不具合が起きやすくなります。
どちらが正解というものではありません。 今回は「止まっている」と「何フレーム目か」が同時に成り立たないので、 1 つにまとめても混乱しない、と判断しています。
同じ形は step1 にもありました step1 の self.scene(タイトル/ゲーム/ゲームオーバー)も、 数字 1 つで状態を表すという同じ考え方です。 walk_timer は、そこに「経過フレーム数」という別の役目も兼ねさせているぶん、 少し欲ばりな作りになっています。

Q. 主人公の目を黒く描いたのに、画面では目が出てこない(透過色はどう選ぶ?)

A. 透過色(colkey)に黒を指定していたからです。 blt() の最後の引数は「この 1 色だけは描かない」という意味なので、 背景を黒で塗った絵に pyxel.COLOR_BLACK を渡すと、 黒で描いたものはすべて抜けます——目もいっしょに。 (step2 フェーズ 5 で実際に直した話です)

① 実際のドットで見ると

sokoban.pyxres の主人公(下向き・コマ A)を、色番号のまま並べたものです。

  以前(背景も目も黒)        いま(背景だけグレー)
    0 0 a a a a 0 0            d d a a a a d d
    0 a a a a a a 0            d a a a a a a d
    9 a a a a a a 9            9 a a a a a a 9
    9 a 0 a a 0 a 9            9 a 0 a a 0 a 9   ← 0 が目
    9 a a 7 7 a a 9            9 a a 7 7 a a 9
    0 0 a a a a 0 0            d d a a a a d d
    0 a 0 0 0 0 a 0            d a d d d d a d
    0 a 0 0 0 0 a 0            d a d d d d a d

  0 = 黒   9 = オレンジ   a = 黄   7 = 白   d = グレー(13)

左は 0 が「背景」と「目」の両方に使われています。 プログラムには、どちらがどちらか区別できません。 右は背景だけ d に塗り替えたので、0 は目だけになりました。

② 直し方は 2 か所

どこやること
リソースエディタ 絵の背景だけを、別の色(今回はグレー=色番号 13)で塗り替える
コード その絵を描く blt() の最後を pyxel.COLOR_GRAY に変える
pyxel.blt(px, py, 0, u, v, 8, 8, pyxel.COLOR_GRAY)

③ 透過色に選んでよい色の条件

その絵の中で、背景以外に 1 ドットも使っていない色であることです。 もし犬の体にグレーを使っていたら、そこに穴があきます(背景と同じ扱いで抜かれる)。

迷ったら「背景専用の色」を 1 つ決めてしまう Pyxel の 16 色のうち、絵に使いどころの少ない色——紫(2)や緑(3)あたり——を 「この作品では背景にしか使わない」と決めてしまうやり方が確実です。
逆に黒は避けたほうが無難です。輪郭線・目・影といったいちばん使いたい色なので、 背景と役目がぶつかります。

④ 透過色は blt() ごとに指定する

1 枚の絵につき 1 色ではなく、描くたびに指定するものです。 だから絵によって背景の色を変えてかまいません。 倉庫番では 3 通りを使い分けています。

描くもの透過色なぜ
床・壁・ゴール指定しない 8×8 をまるごと敷き詰めるので、抜くところがない
荷物(リンゴ)COLOR_BLACK 絵に黒を使っていないので、黒を背景にして困らない
主人公(ワンコ)COLOR_GRAY 目に黒を使うので、背景は別の色にした
透過色を省くとどうなるか colkey を渡さないとすべての色をそのまま描きます。 背景が黒い絵なら、8×8 の黒い四角がそのまま乗るので、 下に描いた床やゴールが四角く消されます。 「キャラのまわりが四角く欠ける」症状はこれです。

環境・ツールについて

Q. Pyxel のウィンドウが閉じられない

A. Esc キーで終了します。ウィンドウ右上の × でも閉じられます。 ターミナルから起動したまま固まってしまった場合は、ターミナルで Ctrl + C を押すと強制終了できます。

Q. 付属のサンプルはどこにある?

A. ターミナルで pyxel copy_examples を実行すると、 カレントディレクトリに pyxel_examples/ フォルダが作られ、その中に展開されます。 実行は pyxel run 01_hello_pyxel.py のように行います。

リポジトリには含めていません pyxel_examples/ は Pyxel に付属するサンプルなので、 .gitignore で GitHub への push 対象から外しています。手元に置いておく分には問題ありません。

Q. 閉じ """ の手前にあるカーソルを、次の行へ楽に送りたい

A. Ctrl + Enter を押します。カーソルが行のどこにあっても、 その行の下に新しい行を作って移動するコマンド(editor.action.insertLineAfter)で、 VS Code の標準キーバインドなので設定は要りません。インデントも自動で揃います。

関数の説明文(docstring)を書くとき、""" と打つと閉じ側の """ が自動で補完され、 説明文を入力し終えたカーソルは閉じ """ の手前で止まります。 ここからわざわざ キーで閉じ引用符を通り過ぎる必要はありません。

入力直後 — カーソルは閉じ引用符の手前で止まっている 27 28 def star_color(speed): """星の色を返す""" ← ここ Ctrl + Enter 押した後 — 閉じ引用符を飛び越えて次の行へ(インデント付き) 27 28 29 def star_color(speed): """星の色を返す""" ← ここ
閉じ """ の手前で Ctrl + Enter を押すと、そのまま次の行の先頭へ移動する

上下セットで覚えておくと便利です。

効かないときの代替:EndEnter End キーで行末(閉じ """ の右側)へ飛び、そこで Enter。 2 打鍵になりますが、どんな環境でも確実に動きます。
Ctrl + Enter が別の機能に取られている場合 Jupyter 拡張を入れていて、そのファイルに # %% のセル区切りがあると 「セルを実行」に割り当てが移ることがあります。反応がおかしいときは Ctrl + KCtrl + S でキーボードショートカット画面を開き、 insertLineAfter で検索して現在の割り当てを確認してください。