Python や Pyxel について出た疑問と、その答えの記録
学習を進める中で出てきた疑問・つまずき・調べたことを、その都度ここに積み上げていきます。 あとから「あれ何だったっけ」となったときに戻ってくる場所です。 新しい項目は下に追記していきます。
A. 半角スペース 4 つを推奨します。タブだけで統一してあれば Python は正しく動くので、 現状のコードを慌てて直す必要はありません。ただし次の 2 つの理由から、早いうちに スペースへ寄せておくほうが安全です。
TabError: inconsistent use of tabs and spaces で止まります。
解説ページからコードを一部コピーしたときなど、気づかないうちに混ざるのが厄介ですCtrl + Shift + P →
Convert Indentation to Spaces を実行すると、タブが一括でスペースに変換されます。タブのサイズ: 4 / スペース: 4 と現在の設定が出ており、
そこをクリックして切り替えることもできます。
Toggle Render Whitespace)をオンにすると、スペースは点、タブは矢印で表示されます。
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 でも、渡ってくる中身は同じです。
def make_stars(): と 1 つめの引数を消すと
TypeError: make_stars() takes 0 positional arguments but 1 was given が出ました。
これは「Python が勝手に 1 個渡したのに、受け取る場所がない」という意味です。
名前を消したのが問題ではなく、受け皿を消したのが問題でした。
self と書いてください
動くことと、そう書くべきことは別です。self は Python の世界で
ほぼ全員が守っている約束で、次の実害があります。
self を前提にしているため、
別名だと補完が弱くなったり、N805 のような警告が出たりするcls という名前も出てきます
クラスメソッド(@classmethod)では 1 つめの引数に
インスタンスではなくクラスそのものが渡され、慣習で cls と名づけます。
これも予約語ではなく、self と同じ「ただの慣習」です。
step0 では使いませんが、名前を見かけたら「あの仲間だ」と思ってください。
__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__ も、コードのどこにも「呼び出し」を書いていません。
print や len が裏で探しにきているのです。
App() で __init__ が呼ばれるのも、まったく同じ仕組みです。
普通の名前とぶつからないようにするためです。
もし init という名前だったら、自分で「初期化用の init メソッド」を
作りたくなったときに衝突します。前後をアンダースコア 2 つで挟むことで、
「ここは Python 側が予約している領域」と見ただけで区別できるようにしています。
| 書き方 | 意味 |
|---|---|
__init__(前後に 2 つ) |
Python が特別扱いする特殊メソッド。 決められた名前を使うもので、自分で新しく発明するものではありません |
_count(前に 1 つ) |
「内部用なので外から触らないでね」というお願い。 強制力はなく、単なる慣習です |
__count(前に 2 つだけ) |
Python が名前を裏で書き換えて、外から呼びにくくします (name mangling)。step0 では使いません |
__init__ を呼ぶ必要はありません
app = App() と書けば Python が呼んでくれます。
app.__init__() と自分で書くことは、通常ありません。
「特殊メソッドは定義するけれど、呼ばない」と覚えてください。
A. 行数ではなく 「名前がつけられるか」 で決めます。
「この 5 行は何をしているか」を短い名前で言い切れるなら、それは関数にしてよいまとまりです。
逆に move_and_grow のように「〜と〜」でしか言えないなら、
まだ 2 つの仕事が混ざっているサインです。
| サイン | 具体例 |
|---|---|
| 名前が「〜と〜をする」になる | 「花を動かしてヒヨコを大きくする」としか言えない |
| 主語が違うものが混ざっている | move_flower() の中にヒヨコの大きさのルールが書いてある |
| 同じコードが 2 か所に出た | 足元のY座標を求める式を初期化と衝突時の両方で書いている |
update() を読んでも流れが分からない |
呼び出しが並んでいるのに、何をしているゲームか読み取れない |
update() は「目次」になっているのが理想
中身を読まなくても、呼び出しの並びだけでゲームの流れが分かる状態を目指します。
def update(self):
self.make_flower() # 花を作る
self.move_flowers() # 花を動かす
self.move_hiyoko() # ヒヨコを動かす
これが読めれば、関数分割はうまくいっています。
いちばん見落としやすいのがこれです。関数の名前は 花 の話をしているのに、 中身に ヒヨコ のルールが紛れ込んでいる、という状態です。
切り出すと、呼び出し側は 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
make_flower / move_flowers /
move_hiyoko / grow_hiyoko。規則が一貫していると、
中身を読まずに役割が推測できますmake_flower は単数、
全部を動かす move_flowers は複数。あとで「1 個だけ動かす関数」が
欲しくなったときに名前が空きます16 のような数字は定数にする — 名前がつくと、
その数字が何のための値なのかがコードから読み取れますupdate() から呼ばれる関数が 20 個並んで、かえって流れが追えなくなります。
分ける理由はつねに「まとまりに名前がついたから」であって、行数ではありません。
たとえば pyxel.btn() による入力の読み取りが move_hiyoko と
make_flower の両方に散らばっていても、この規模なら問題ありません
(Pyxel 付属のサンプルもこの書き方です)。
「タイトル画面では入力を無視したい」のように実際に困りごとが起きてから、
入力係の関数へまとめれば十分です。
/ と // はどう使い分ける?
A. / はふつうの割り算で、答えはつねに小数(float)になります。
// は切り捨て除算で、小数点以下を捨てます。
「1 きざみの整数でないと意味がないもの」には //、
「途中の値にも意味があるもの」には / を使う、というのが基本の分かれ道です。
| 式 | 結果 | 型 | ひとこと |
|---|---|---|---|
7 / 2 | 3.5 | float | 小数のまま残る |
7 // 2 | 3 | int | 小数点以下を捨てる |
4 / 2 | 2.0 | float | 割り切れても float |
4 // 2 | 2 | int | 整数どうしなら int |
7.4 // 2 | 3.0 | float | 片方が小数なら型は float。値は切り捨て済み |
// は「整数にする命令」ではありません
7.4 // 2 の答えは 3.0 で、型は float のままです。
// が保証するのは「値が整数ちょうどになる」ことだけで、
型が int になるとはかぎりません。
// を使う | / を使う | |
|---|---|---|
| 対象 | 個数・番号・マス目 (1 きざみでないと意味がない) |
位置・速さ・大きさ (途中の値にも意味がある) |
| 例 | (pyxel.frame_count // 5) % 25 フレームごとに切り替わるコマ番号 |
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_scale | 1.0 | 1.2 | 1.4 | 1.6 | 1.8 | 2.0 |
|---|---|---|---|---|---|---|
// のときの足元 | 111.0 | 111.6 | 111.2 | 111.8 | 111.4 | 111.0 |
/ のときの足元 | 111.0 | 111.0 | 111.0 | 111.0 | 111.0 | 111.0 |
// で切り捨てると 引く量が減る ので、Y 座標が下へずれて足元が地面へ埋まります。
111.6 や 111.8 は描画時に四捨五入されて 112、
つまり地面のタイルに 1 ドット食い込んだ状態です。
大きくなるたびに沈んだり戻ったりするので、見た目がわずかに揺れます。
// で切り捨てると引く量が減り、ヒヨコ全体が 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 先に切り捨てるとこうなる
*, /, //, % はすべて同じ優先順位です。
迷ったらかっこを付けるのがいちばん安全です。
// は小さいほう(数直線の左)へ丸めます。0 方向へ丸める int() とは結果が違います。
-7 // 2 # → -4 小さいほうへ
int(-7 / 2) # → -3 0 に近いほうへ
画面の左端より外(マイナスの座標)を扱うときに、1 ドットずれる原因になります。
A. Python は関数定義の位置に制限がありません。
update() の中で self.move_boy() を呼び、move_boy() の定義は
その下にある——という candy_hunt.py の書き方で問題ありません。
理由は「名前をいつ探すか」が C と Python で違うからです。
| C | Python | |
|---|---|---|
| 名前を探すタイミング | コンパイルするとき(実行する前) | その行が実際に動くとき |
| だから必要なこと | 呼び出しより前に、宣言か定義が書いてある | 呼び出しが起きる時点で定義が済んでいる |
| 書く順番 | 上から順に縛られる | 自由(ただし下の 3 つは例外) |
Python の def は「この名前で、この中身を覚えておいて」という命令文です。
関数の中身はそのとき読まれません。中の move_boy という名前を探しにいくのは、
update() が実際に呼ばれた瞬間です。そのときには
ファイルはすでに最後まで読み終わっていて、move_boy も登録済みなので見つかります。
「制限がない」のは関数の中身の話です。次の 3 つは ファイルを読み込んだ瞬間に実行されるので、順番どおりに書く必要があります。
candy_hunt.py の最後にある App() がこれです。
これは def ではなく呼び出しなので、その場で実行されます。
ファイルの先頭に移すと止まります。
App() # ← 先頭に置くと…
class App:
...
# NameError: name 'App' is not defined
App() がいちばん下にあるのは、こういう理由です。
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
class の本体(メソッドの外)に直接書いた行
class の中でも def の外に書いた行は、読み込み時に実行されます。
class App:
size = BOY_SIZE # ← メソッドの外なので、すぐ実行される
BOY_SIZE = 8
# NameError: name 'BOY_SIZE' is not defined
def rarely_used():
print(mistyped_name) # 存在しない名前
print("読み込みは通る") # ← ここまでは何ごともなく動く
rarely_used() # ← 呼ばれて初めて NameError
ゲームだと「ゲームオーバーになって初めて落ちる」といった形で出てきます。
新しく書いた関数は、一度は必ず通してみるのが大事です。
順番は自由なので、読みやすさで決めます。よくあるのは次の並べ方です。
update() を読めば全体の流れが分かり、
細かい中身は下に読み進めればよい。candy_hunt.py はこの形ですmove_boy() と check_candy() のように、
update() から呼ばれるものをそのすぐ下にまとめるBOY_SIZE などは、上の「例外 2・3」に引っかからないよう
ファイルの先頭にまとめておくと安全ですNameError と、
「下にある関数を上から呼べる」ことを実際に動かして確かめました。
.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(黒=未使用)は省略されるので、
行ごとに長さが違って見えます。
blt の u・v・w・h
を決めるときに確実ですpyxel edit のリソースエディタで行うのが安全です。
手で TOML を書き換えると、format_version や配列の形が崩れて
読み込めなくなることがあります。
SHIP_SPEED = 0.2 でもちゃんと動いたのはなぜ?
A. 使えます。Pyxel の描画命令は座標を小数(float)で受け取り、
画面に出すときにいちばん近いドットへ四捨五入します。
付属の型定義ファイル(pyxel/__init__.pyi)を見ると、はっきり float と書いてあります。
def blt(
x: float,
y: float,
...
どう丸められるかを実際に試した結果が次です
(pyxel.Image に pset して、どのドットが点いたかを調べたもの)。
| 渡した値 | 0.0 | 0.2 | 0.4 | 0.5 | 0.6 | 1.0 | 1.5 | 2.5 |
|---|---|---|---|---|---|---|---|---|
| 点いたドット | 0 | 0 | 0 | 1 | 1 | 1 | 2 | 3 |
四捨五入で、.5 はつねに切り上げです(切り捨てではありません)。
SHIP_SPEED = 0.2 にすると、self.ship_x は
0.2 ずつ、小数のまま変わっていきます。画面に出るのは丸めたあとの整数なので、
値が 1 ドットぶん動くまでに 5 フレームかかります。
1 秒 = 30 フレームなので、1 秒あたり 6 ドット。SHIP_SPEED = 1(1 秒に 30 ドット)
と比べて、ちょうど 5 分の 1 の速さです。
self.ship_x = round(self.ship_x - 0.2) … 72 → 71.8 → 丸めて 72 …
と元に戻り続けて、永久に動きませんself.ship_x = int(self.ship_x - 0.2) … 切り捨てなので毎フレーム 1 ドット進み、
0.2 のはずが 1 の速さになりますstar[1] += star[2] は、まさにこの仕組みです。
星の速さは random.uniform(0.3, 1.8) の小数なので、
遅い星は数フレームに 1 ドットしか動きません。
だから奥行きのある、なめらかな流れに見えます。
もし整数しか使えなかったら、星の速さは 1、2、3… の 3 段階しか作れません。
max(0, min(self.ship_x, 144)) は、はみ出したときに
0 や 144(整数)をそのまま返します。
つまり左端に張り付いた瞬間、self.ship_x は小数から整数に変わります。
Python は型が混ざっても計算できるので、動作に問題はありません。
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 | 中心 |
|---|---|---|---|
| 1 | 10 〜 13 | 10 〜 13 | (12, 12) |
| 2 | 8 〜 15 | 8 〜 15 | (12, 12) |
| 3 | 6 〜 17 | 6 〜 17 | (12, 12) |
どの scale でも中心は (12, 12) のまま動いていません。
幅をマイナスにして左右反転させても(w = -4)、結果はまったく同じでした。
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 で計算するとこうなります。
scale | y の値 | 実際に描かれる範囲 | 足元 |
|---|---|---|---|
| 1 | 111 − 16 = 95 | 95 〜 110 | 111 |
| 2 | 111 − 24 = 87 | 79 〜 110 | 111 |
| 3 | 111 − 32 = 79 | 63 〜 110 | 111 |
y は scale が 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()
// ではなく / で割る
scale を 0.2 きざみで増やすと、HIYOKO_HEIGHT * (scale + 1) は
割り切れない小数になります。ここで //(切り捨て)を使うと引く量が減り、
足元が地面へ最大 0.8 ドット沈みます。座標に小数を渡しても Pyxel は四捨五入して
描いてくれる(→ 小数の座標)ので、/ のままで問題ありません。
→ / と // の使い分け
scale の影響を受ける
max() / min() で左右の動ける範囲を決めている場合、
幅を HIYOKO_WIDTH 固定で書いていると、大きくなったぶんだけ画面外へはみ出します。
見えている絵の幅は HIYOKO_WIDTH * scale なので、はみ出しを厳密に止めたいときはこちらを使います。
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 のいちばん下のフォルダ名が出力名になります。
作品ごとにフォルダを分ければ、名前は自動で分かれ、中身も必要なファイルだけになります。
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.pyxapp | 4610 B | 2883 B |
fish_clock.pyxapp | 4608 B | 1878 B |
hiyoko_anime.html | 6367 B | 4063 B |
fish_clock.html | 6361 B | 2721 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 の中身と同じ要領)。
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 分間鳴り続ける
minute が 59 から 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 回でいい場面すべてで同じ書き方をします。
A. SPEED を上げるのではなく、同じ音を複数ステップ並べます。 そのうえで EFX は最後のステップだけに付けるのがコツです。
SPEED は「1 音の長さ」で、120 で 1 音 1 秒。エディタの入力欄は 2 桁なので
99(=0.825 秒)が上限になります。長さが足りないときは音の数を増やすのが素直な解決です。
Sound.save() で WAV に書き出し、100 ミリ秒ごとの音量を測ったもの。
音を並べても EFX を全部に付けると 1 音ごとに鳴り直す。
エディタでの操作は「同じ音を 4 回入力し、EFX 欄は 4 つ目だけ F」です。
同じ高さの音が続くので途切れず、最後だけきれいに減衰します。
もっと伸ばしたいときは音の数を増やします(8 つ並べれば約 4.5 秒)。
| EFX | 意味 | 音量カーブ(1 音 0.5 秒) |
|---|---|---|
N(空欄) | なし | 999999 最後まで鳴って急に止まる |
F | フェードアウト | 986410 最初から減衰する |
H | 半分フェードアウト | 999730 後半だけ減衰する |
Q | 1/4 フェードアウト | 999970 最後の 1/4 だけ減衰する |
S / V | スライド / ビブラート | 音程を変える効果。長さには影響しない |
並べる方法と組み合わせて「4 つ並べて最後だけ H」にすると
9999999999999999997300000 となり、いちばん長く伸びます。
99 の制限はエディタの入力欄が 2 桁だからで、値そのものに上限はありません。
pyxel.load() の直後にコードから書き換えられます。
pyxel.sounds[0].speed = 200 # エディタの上限 99 を超えて設定できる
speed を書き換えても .pyxres には保存されません。
あとでエディタを開いて音を確かめると、ゲーム中に鳴る音と違って聞こえます。
エディタの中だけで完結する「音を並べる」方法のほうが、あとから自分が混乱しません。
原因は2 段階あります。まず ①チャンネルの上書きを疑い、 それが違えば ②マスキング(音が似すぎて混ざる)を疑います。
Pyxel の play(ch, snd) は、そのチャンネルで鳴っているものを止めて、
新しい音に差し替える命令です。停止と再生が 1 つになっている、と考えてください。
pyxel.play(CH_SE, SOUND_CANDY) # ch2 = お菓子の音
...
pyxel.play(CH_SE, SOUND_GHOST) # ← 同じ ch2! 上書きされる
やっかいなのは、この 2 行が同じフレームの中で走ることです。 音が出るのはフレームの終わりなので、お菓子の音は 1 サンプルも鳴らないまま消えます。 「片方が途中で切れる」のではなく、最初からなかったことになります。
play が前を消してしまう。下:チャンネルを分ければ同時に鳴るチャンネルを分けても弱いままなら、こちらです。 耳は同時に鳴る似た音を 1 つにまとめて聞きます。とくに あとから鳴った小さい音・短い音は、先に鳴っている音に隠されます。 これをマスキングと呼びます。
step1 の 2 つの音は、埋もれる条件がそろっていました。
| お菓子(SOUND 0) | お化けが増えた(SOUND 2) | 差 | |
|---|---|---|---|
| 音色 | P | P | 同じ |
| 音量 | 6 | 6 | 同じ |
| 音符 | C3 E3 G3 C4 | C3 G3 | 片方に完全に含まれる |
| 長さ | 0.267 秒 | 0.167 秒 | 短いほうが負ける |
手当ては 4 つ。1 つでも効きますが、2 つ以上ずらすと確実です。
| ずらすところ | やり方 | 効き目 |
|---|---|---|
| 音色 | P → S や N |
別の楽器になるので、いちばん確実 |
| 音域 | 1 〜 2 オクターブ下げる | 高い音どうしの取り合いから抜けられる |
| 長さ | 相手より長くする | 相手が終わったあとひとりで残るので必ず気づく |
| 音量 | 6 → 7 |
単純だが効く。ただし上限があるので他と併用 |
update でその時刻が来たら鳴らす)。
まずは音そのものを変えるほうを試してください。
A. ページに「全画面」ボタンを自分で足します。
pyxel app2html が出力する HTML には、全画面にする仕組みがまったく入っていません。
読み込まれる pyxel.js(2.9.9)を確認しましたが、
requestFullscreen に類する処理は 1 行もありませんでした。
アプリが起動した瞬間から、Pyxel の wasm ランタイムが document に
キー入力のハンドラを付け、自分が使うキーの既定の動作を打ち消します。
F11 もその対象に入っているため、ブラウザの全画面切り替えまで一緒に止まってしまいます。
実際に keydown を投げて、既定動作が打ち消されるか(defaultPrevented)を測りました。
| 押したキー | CLICK TO START の画面 | ゲーム起動後 |
|---|---|---|
F11 | 打ち消されない | 打ち消される |
ArrowLeft | 打ち消されない | 打ち消される |
Space | 打ち消されない | 打ち消されない |
起動前は素通りしていたものが、起動後だけ打ち消されています。 ページ側の JavaScript が原因で、ブラウザの設定や Pyxel の不具合ではありません。
Chrome なら右上の ⋮ メニューの中に全画面のアイコンがあります。 メニュー操作はページのキー処理を経由しないので、こちらからなら全画面にできます。 今すぐ確かめたいときはこれが手軽です。
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>
navigator.keyboard.lock() という専用の API を使わないかぎり)。
pyxel.js がこの API を使っていないことは確認済みです。
<meta charset="utf-8"> を忘れると日本語が化ける
app2html の出力には 文字コードの指定が入っていません。
絵と音しか入っていないうちは問題になりませんが、
ボタンに日本語を書いたとたん化けます(実際に化けました)。
ファイルの先頭、<!doctype html> の次の行に足しておきます。
canvas.focus() を呼ぶ
ボタンをクリックすると、キーボードの入力先がボタンに移ります。
そのままだと矢印キーがゲームに届かなくなるので、
入力先を canvas に戻す必要があります。
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 では上のボタン方式が確実です。
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 だけの話ではなく、cls・blt・text・btn・play など
エンジンの中身に触る関数はすべて同じエラーになります。
init() は入れ物を作る係、load() は中身を入れる係。順番は逆にできない
init() が用意する枠は、実際に数えると次のとおりでした(pyxel 2.9.9 で実測)。
| 用意されるもの | 数・大きさ |
|---|---|
| 画面 | init() に渡した幅 × 高さ |
パレット(pyxel.colors) | 16 色 |
イメージバンク(pyxel.images) | 3 枚、各 256 × 256 ドット |
サウンド(pyxel.sounds) | 64 枠 |
ミュージック(pyxel.musics) | 8 枠 |
load() がしているのは、この空っぽの枠に .pyxres の中身を上書きすることです。
リソースエディタで描いた絵が「イメージバンク 0」に入るのも、
init() が先に 0 番の枠を作っているからです。
init() の前でも読めるものがある
pyxel.COLOR_BLACK(= 0)や pyxel.KEY_LEFT(= 1073741904)、
pyxel.VERSION のような定数は、init() の前でも読めます。
ただの数字なので、エンジンに聞きに行く必要がないからです。pyxel.width で、init() の前に読むと
エラーにならず 0 が返ってきます。
「画面幅の半分に置く」つもりの計算が黙って 0 になるので、こちらのほうが気づきにくい失敗です。
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 の下に足す」といった直し方はできません。
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 ドットずつずらしながら 「当たりと判定されるか」「絵が実際に重なっているか」を全通り調べました。
外周のピンクのマスが、まさに「追いかけているだけで取れてしまう」感覚の正体です。
ゲームでは「絵の大きさ」と「当たり判定の大きさ」を別々に決めるのが普通です。 絵の中央だけを判定に使うようにすると、「ちゃんと重なったときだけ当たる」ようになります。
定数を 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_SIZE | HIT_OFFSET | 当たる位置 | 絵が重ならないのに当たる | 重なりドット数(最小/平均) |
|---|---|---|---|---|
| 8(現状) | 0 | 225 通り | 81 通り(36%) | 0 / 4.8 |
| 6 | 1 | 121 通り | 2 通り(2%) | 0 / 8.6 |
| 4(おすすめ) | 2 | 49 通り | 0 通り | 6 / 14.7 |
| 2 | 3 | 9 通り | 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 のときだけ当たり」になるため、
お菓子が奇数の位置に出た瞬間に絶対に取れなくなります。
判定を縮めるときは、移動の刻み幅より広く残すのが鉄則です。
当たり判定を直すのは「取れるタイミングを正しくする」対策です。 これとは別に、「取れたことを分かりやすく伝える」という対策もあります。
どれも「判定が正しいこと」の上に乗せるものなので、まず判定を直すのが先です。
GHOST_SPEED = 1 でも速く感じるA. その感覚は正しいです。男の子の半分の速さでも、全力で逃げ続けて 5 秒で捕まります。 「半分の速さなら余裕で逃げられる」という直感が当たらないのは、 画面が狭くて、必ず壁に突き当たるからです。
実際に数えてみました(画面 160×120、BOY_SPEED = 2、
お化けは左上 (0, 0) から出発、判定は HIT_SIZE = 4)。
男の子はお菓子を無視して、お化けから遠ざかる向きへ全力で逃げ続けるという条件です。
GHOST_SPEED | 捕まるまで | 体感 |
|---|---|---|
| 1(現在) | 149 フレーム=5.0 秒 | 逃げるだけで精一杯 |
| 0.5 | 297 フレーム=9.9 秒 | お菓子を取りに寄り道できる |
| 0.25 | 593 フレーム=19.8 秒 | ほぼ追われている感じがしない |
Pyxel は座標に小数を渡せる(#float-coord)ので
GHOST_SPEED = 0.5 はそのまま動きます。ただし
どんな小数でもよいわけではありません。
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.5、0.25、0.125 から選ぶ
いずれも 1 を 2 で割っていった数なので、コンピュータの中でも誤差なく表せます。
0.3 や 0.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 ドットずつ進むことになり、動きはなめらかに見えます。
「捕まるまでの秒数」は速さだけで決まるものではありません。次の手も使えます。
1 と 0.5 を混ぜると散らばって見た目にも分かりやすくなります0.75 の落とし穴
上で「1 を 2 で割っていった数なら安全」と書きましたが、これだけでは足りません。
0.75 は 0.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.75 | 0, 0.75, 1.5, 2.25, 3.0, 3.75 … | 3 の倍数だけ(0, 3, 6, 9 …) |
0.5 | 0, 0.5, 1.0, 1.5, 2.0 … | すべての整数 |
0.25 | 0, 0.25, 0.5, 0.75, 1.0 … | すべての整数 |
男の子の座標は BOY_SPEED = 2 なので必ず偶数です。
0.75 の倍数で偶数になるのは 6 の倍数だけなので、
76 のような座標にはどうやっても乗れません。
1 / 2 のべき乗(0.5、0.25、0.125)が安全なのは、
その倍数がすべての整数を含むからです。
0.75 や 1.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 | 実質の速さ | 座標 |
|---|---|---|
1 | 1 ドット/フレーム | つねに整数 往復しない |
2 | 0.5 | |
3 | 0.333…(小数では危険な値) | |
4 | 0.25 |
見た目も変わりません。描画はどうせ整数のドットに丸められるので、
「0.5 ずつ進む」も「2 フレームに 1 ドット進む」も画面上は同じです。
それでいて 1/3 のような、小数では扱えない速さが使えます。
GHOST_MOVE_EVERY = [1, 2, 3, 4] を採用しました。
4 体とも実質の速さが違うので重なって見えず、座標も整数のままです。
同じ速さにすると、600 フレーム後には 4 体が完全に 1 か所へ重なります(実測)。
continue の行の意味が分からない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 にすると、そのあとの男の子やスコアまで描かれなくなります。
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。以後は毎フレーム必ず描かれ、点滅が止まります。
「点滅をやめる処理」を別に書く必要はありません。
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 か所以上に出たら格上げ」の基準)。
walk_timer = 1 としているのは、walk_timer = 0 ではだめ?
A. だめです。0 という値そのものに「止まっている」という意味を持たせているので、
歩き出すところで 0 を入れるのは「歩き出さなかったことにする」のと同じになります。
(step2 フェーズ 4 の try_move() の最終行の話です)
self.player_x = next_x
self.player_y = next_y
self.walk_timer = 1 # ← ここを 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 | 出発マスからのドット |
|---|---|---|
| キーを押す前 | 0 | 0(止まっている姿) |
| 押したフレーム | 1 | 1 |
| その次 | 2 | 2 |
| … | … | … |
| 8 フレーム目 | 8 | 8 = 到着 |
0 のようなふつうは出てこない値に、特別な意味を持たせるやり方は、
プログラムでよく使われます。変数が 1 つで済むのが利点です。0 始まりのカウンタにしたいなら、
self.walking = True / False を別に持つことになります。
こちらは意味がはっきりする代わりに、2 つの変数を常にそろえて動かす手間が増え、
片方だけ更新し忘れる不具合が起きやすくなります。self.scene(タイトル/ゲーム/ゲームオーバー)も、
数字 1 つで状態を表すという同じ考え方です。
walk_timer は、そこに「経過フレーム数」という別の役目も兼ねさせているぶん、
少し欲ばりな作りになっています。
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 は目だけになりました。
| どこ | やること |
|---|---|
| リソースエディタ | 絵の背景だけを、別の色(今回はグレー=色番号 13)で塗り替える |
| コード | その絵を描く blt() の最後を pyxel.COLOR_GRAY に変える |
pyxel.blt(px, py, 0, u, v, 8, 8, pyxel.COLOR_GRAY)
その絵の中で、背景以外に 1 ドットも使っていない色であることです。 もし犬の体にグレーを使っていたら、そこに穴があきます(背景と同じ扱いで抜かれる)。
blt() ごとに指定する1 枚の絵につき 1 色ではなく、描くたびに指定するものです。 だから絵によって背景の色を変えてかまいません。 倉庫番では 3 通りを使い分けています。
| 描くもの | 透過色 | なぜ |
|---|---|---|
| 床・壁・ゴール | 指定しない | 8×8 をまるごと敷き詰めるので、抜くところがない |
| 荷物(リンゴ) | COLOR_BLACK |
絵に黒を使っていないので、黒を背景にして困らない |
| 主人公(ワンコ) | COLOR_GRAY |
目に黒を使うので、背景は別の色にした |
colkey を渡さないとすべての色をそのまま描きます。
背景が黒い絵なら、8×8 の黒い四角がそのまま乗るので、
下に描いた床やゴールが四角く消されます。
「キャラのまわりが四角く欠ける」症状はこれです。
A. Esc キーで終了します。ウィンドウ右上の × でも閉じられます。
ターミナルから起動したまま固まってしまった場合は、ターミナルで Ctrl + C
を押すと強制終了できます。
A. ターミナルで pyxel copy_examples を実行すると、
カレントディレクトリに pyxel_examples/ フォルダが作られ、その中に展開されます。
実行は pyxel run 01_hello_pyxel.py のように行います。
pyxel_examples/ は Pyxel に付属するサンプルなので、
.gitignore で GitHub への push 対象から外しています。手元に置いておく分には問題ありません。
""" の手前にあるカーソルを、次の行へ楽に送りたい
A. Ctrl + Enter を押します。カーソルが行のどこにあっても、
その行の下に新しい行を作って移動するコマンド(editor.action.insertLineAfter)で、
VS Code の標準キーバインドなので設定は要りません。インデントも自動で揃います。
関数の説明文(docstring)を書くとき、""" と打つと閉じ側の """ が自動で補完され、
説明文を入力し終えたカーソルは閉じ """ の手前で止まります。
ここからわざわざ → キーで閉じ引用符を通り過ぎる必要はありません。
""" の手前で Ctrl + Enter を押すと、そのまま次の行の先頭へ移動する上下セットで覚えておくと便利です。
Ctrl + Enter … 現在行の下に行を挿入して移動Ctrl + Shift + Enter … 現在行の上に行を挿入して移動End → Enter
End キーで行末(閉じ """ の右側)へ飛び、そこで Enter。
2 打鍵になりますが、どんな環境でも確実に動きます。
Ctrl + Enter が別の機能に取られている場合
Jupyter 拡張を入れていて、そのファイルに # %% のセル区切りがあると
「セルを実行」に割り当てが移ることがあります。反応がおかしいときは
Ctrl + K → Ctrl + S でキーボードショートカット画面を開き、
insertLineAfter で検索して現在の割り当てを確認してください。