完成サンプルをブラウザーで遊ぶ
この講座のもとになった完成版のゲームを、ブラウザーでそのまま遊べます。キーボードで操作するので、PCで開いてください(スマホのタッチ操作には対応していません)。初めて開くときは、読み込みに少し時間がかかります。
完成版の主な操作:Enterで開始(Pで練習)、←→で移動、左右の端でSpaceを押してかごに預ける、Rで同じ配置からやり直し、Nで別の配置、Escで表紙に戻る
ボタンを押すと、ゲームの読み込みが始まります。
画面に「CLICK TO START」と出たらクリックします。続けてゲーム画面を一度クリックすると、キー操作が効くようになります。ゲームを終えるときは、枠の外にある「閉じる」ボタンを使ってください(Escキーの働きはゲームごとに違います)。ゲーム内のセーブは、閉じる・ページを移る・再読み込みすると消えます。
うまく表示されないとき
ゲームを動かすためのプログラム(Pyxel 2.9.9)は、外部の配信サービス(jsDelivr)から読み込みます。表示されないときは「最初から再読込」を押すか、完成版のソースをダウンロードしてPCで実行してください。ブラウザー版の録画機能は止めています。ブラウザー版で確認できている範囲は検証状況にまとめています。
持ち物と得点を分ける。
いきなり完成版のコードを全部読む必要はありません。小さなゲームにルールを1つずつ足しながら、5つのステップで作っていきます。どのステップのファイルもそれだけで動くので、動きを確かめてから次へ進みましょう。
持ち物 3枚
確定点 0点
3 × 10 + 3 × 2
= 36点を加算
持ち物 0枚
確定点 36点
- 必要な環境
- Python 3.11以上 / Pyxel 2.9.9
- 知っておくとよいこと
- 変数・if・関数の基本
- 教材に入っているもの
- ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
- 完成した教材の操作
- ← →:移動 / Space:かごに預ける / R:やり直し
- 難易度の目安
- 入門このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
- 学習順に迷ったら
- おすすめの学ぶ順番を見る
コードを書き換えたら、ファイルを保存し、動いているゲームを閉じてから、もう一度同じ.pyを実行します。ゲーム中にRキーなどで「やり直し」をしても、書き換えたコードは読み込まれません。
教材のゲーム画面は、日本語フォントを用意しなくても動くように英語で表示しています。ファイルには動作確認用のコードも入っていますが、最初は Game / State・update・draw の3つに注目すれば十分です。わからない言葉は用語集で調べられます。class・self・dataclass はPythonの状態、キー入力と時間の扱いは入力・更新・描画、マス目とピクセルの違いは画面とマップの座標のページで詳しく説明しています。
座標を持つおばけ
「数値を変える処理」と「絵を描く処理」を分ける。
おばけの左右の位置は、Game.x という数値で表します。ゲームは1秒間に30回、同じ処理をくり返しています。update はキー入力を読み、step で位置の数値を変えます。draw は、その数値の場所におばけを描くだけです。左右のキー入力は「左なら -1、押していなければ 0、右なら 1」の3つの値にまとめ、max と min で画面の端から出ないように止めます。R キーを押すと、最初の状態を作り直します。
下のコードは 01_move.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
def step(self, direction=0):
# 左右を同時に押すと direction は0。画面の外に出さない。
self.x = max(10, min(WIDTH - 10, self.x + direction * 2))実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 01_move.py.venv/bin/python 01_move.py- 左右に動く
- 端から出ない
- Rで中央へ戻る
draw の中で x を増やしてはいけません。draw は画面を描くたびに呼ばれるので、描く回数によってゲームの動きが変わってしまいます。位置を変える処理は step に書きます。移動量の 2 を 1 に変えて、同じ距離を進むのにかかる時間を比べてみましょう。元に戻したら、右端の WIDTH - 10 の 10 を 20 に変えると何が変わるか、先に予想してから試してみましょう。
このステップのコード全文(86行)
# MIT License
#
# Copyright (c) 2026 Tidepost contributors
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
#
# Exception: assets/glyphs.json contains rasterized glyph data derived from
# Noto Sans CJK JP (SIL Open Font License 1.1). See assets/NOTO-LICENSE.txt.
# Pyxel is an external dependency under its own MIT license.
"""おばけ洗濯日和・教材 1/5。原作を簡略化した独立実行ファイル。
実行: python 01_move.py
コード・図形: MIT。原作者と条件は隣の LICENSE を参照。
"""
WIDTH, HEIGHT = 240, 180
GROUND = 136
FPS = 30
class Game:
def __init__(self):
self.x = WIDTH / 2
def step(self, direction=0):
# 左右を同時に押すと direction は0。画面の外に出さない。
self.x = max(10, min(WIDTH - 10, self.x + direction * 2))
def main(headless=False, screenshot=None):
# 純粋な Game のテストではPyxelを読み込まない。
import pyxel
game = Game()
def update():
nonlocal game
if headless and pyxel.frame_count >= 12:
if screenshot:
pyxel.screenshot(screenshot, scale=2)
pyxel.quit()
return
if pyxel.btnp(pyxel.KEY_R):
game = Game()
return
direction = int(pyxel.btn(pyxel.KEY_RIGHT)) - int(pyxel.btn(pyxel.KEY_LEFT))
game.step(direction)
def draw():
pyxel.cls(1)
pyxel.text(8, 8, "GHOST LAUNDRY / STEP 1", 10)
pyxel.line(0, GROUND + 8, WIDTH - 1, GROUND + 8, 3)
x = game.x
pyxel.circ(x, GROUND - 6, 7, 7)
pyxel.rect(x - 7, GROUND - 6, 15, 11, 7)
pyxel.pset(x - 3, GROUND - 7, 1)
pyxel.pset(x + 3, GROUND - 7, 1)
pyxel.text(8, 164, "LEFT/RIGHT: MOVE R: RESET ESC: QUIT", 6)
pyxel.init(WIDTH, HEIGHT, title="Ghost Laundry - Stage 1", fps=FPS, headless=headless)
pyxel.run(update, draw)
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--headless-smoke", action="store_true", help="12 frames then quit; not a playtest")
parser.add_argument("--screenshot", help="PNG path used with --headless-smoke")
args = parser.parse_args()
main(headless=args.headless_smoke, screenshot=args.screenshot)落下物を集める
リストを使って、たくさんの落下物をまとめて動かす。
Drop は、落ちてくる布1枚分の位置をまとめたものです。30フレーム(1秒)ごとに新しい布を追加し、毎フレーム y(縦の位置)を増やして落とします。布が地面(GROUND)に届いた瞬間だけ、おばけとの横の距離を比べ、12未満なら拾えたことにします。持てる布(cargo)は最大8枚です。頭の上に積んだ布の絵が高くなっても、拾えるかどうかを判定する高さは変わりません。
下のコードは 02_catch.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
for drop in self.drops[:]:
drop.y += 1.5
if drop.y >= GROUND:
if abs(self.x - drop.x) < 12:
self.cargo = min(CAPACITY, self.cargo + 1)
self.drops.remove(drop)実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 02_catch.py.venv/bin/python 02_catch.py- 最初の布を中央で拾う
- 落ちる場所にいないと増えない
- 8枚を超えない
for drop in self.drops でリストを順番に見ている途中で remove(削除)すると、となりの落下物が飛ばされてしまうことがあります。そこで、リストのコピー self.drops[:] を順番に見るようにしています。SHEET_POSITIONS の数字の順番を入れ替えて試してみましょう。次に 1.5 を 1.0 に変えて、落ちるまでの時間だけが変わることを確かめましょう。
このステップのコード全文(117行)
# MIT License
#
# Copyright (c) 2026 Tidepost contributors
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
#
# Exception: assets/glyphs.json contains rasterized glyph data derived from
# Noto Sans CJK JP (SIL Open Font License 1.1). See assets/NOTO-LICENSE.txt.
# Pyxel is an external dependency under its own MIT license.
"""おばけ洗濯日和・教材 2/5。原作を簡略化した独立実行ファイル。
実行: python 02_catch.py
コード・図形: MIT。原作者と条件は隣の LICENSE を参照。
"""
from dataclasses import dataclass
WIDTH, HEIGHT = 240, 180
GROUND = 136
FPS = 30
CAPACITY = 8
SHEET_POSITIONS = (120, 150, 90, 60, 180)
@dataclass
class Drop:
x: float
y: float = 24
class Game:
def __init__(self):
self.x = WIDTH / 2
self.frames = 0
self.cargo = 0
self.drops = []
def step(self, direction=0):
self.frames += 1
# 左右を同時に押すと direction は0。画面の外に出さない。
self.x = max(10, min(WIDTH - 10, self.x + direction * 2))
if self.frames % 30 == 1:
index = ((self.frames - 1) // 30) % len(SHEET_POSITIONS)
self.drops.append(Drop(SHEET_POSITIONS[index]))
# 元リストから削除するため、コピーを使って全ての落下物を調べる。
for drop in self.drops[:]:
drop.y += 1.5
if drop.y >= GROUND:
if abs(self.x - drop.x) < 12:
self.cargo = min(CAPACITY, self.cargo + 1)
self.drops.remove(drop)
def main(headless=False, screenshot=None):
# 純粋な Game のテストではPyxelを読み込まない。
import pyxel
game = Game()
def update():
nonlocal game
if headless and pyxel.frame_count >= 12:
if screenshot:
pyxel.screenshot(screenshot, scale=2)
pyxel.quit()
return
if pyxel.btnp(pyxel.KEY_R):
game = Game()
return
direction = int(pyxel.btn(pyxel.KEY_RIGHT)) - int(pyxel.btn(pyxel.KEY_LEFT))
game.step(direction)
def draw():
pyxel.cls(1)
pyxel.text(8, 8, "GHOST LAUNDRY / STEP 2", 10)
pyxel.line(0, GROUND + 8, WIDTH - 1, GROUND + 8, 3)
pyxel.text(8, 20, f"CARRY {game.cargo}/{CAPACITY}", 7)
for drop in game.drops:
pyxel.rect(drop.x - 5, drop.y - 3, 10, 6, 7)
x = game.x
pyxel.circ(x, GROUND - 6, 7, 7)
pyxel.rect(x - 7, GROUND - 6, 15, 11, 7)
pyxel.pset(x - 3, GROUND - 7, 1)
pyxel.pset(x + 3, GROUND - 7, 1)
for i in range(game.cargo):
pyxel.rect(x - 5, GROUND - 18 - i * 4, 10, 3, 6)
pyxel.text(8, 164, "LEFT/RIGHT: MOVE R: RESET ESC: QUIT", 6)
pyxel.init(WIDTH, HEIGHT, title="Ghost Laundry - Stage 2", fps=FPS, headless=headless)
pyxel.run(update, draw)
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--headless-smoke", action="store_true", help="12 frames then quit; not a playtest")
parser.add_argument("--screenshot", help="PNG path used with --headless-smoke")
args = parser.parse_args()
main(headless=args.headless_smoke, screenshot=args.screenshot)持ち物を得点に変える
拾った持ち物と、確定した得点を分ける。
布は持っているだけでは得点になりません。左右のかごの近くで SPACE キーを押すと、持っている布の点数を score(得点)に足して、cargo(持ち物)を0に戻します。布は1枚10点で、まとめて預けると追加点が最大12点つきます。たくさん持つほど speed(移動の速さ)が少し下がるので、「早めに預ける」か「たくさん集める」かを選ぶことになります。
下のコードは 03_bank.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
if bank and self.near_basket and self.cargo > 0:
self.score += bundle_value(self.cargo)
self.cargo = 0実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 03_bank.py.venv/bin/python 03_bank.py- 中央では預けられない
- かごでSPACEを押すと加算される
- 持ち物が減ると速さが戻る
score = bundle_value(...) と書くと、それまでの得点が上書きされて消えてしまいます。得点を足すときは += を使います。また、先に cargo を0にしてしまうと、預ける布の点数も0になります。3枚なら36点、5枚なら62点、8枚なら92点になることを、計算式で確かめてみましょう。次に、追加点の係数 3 を 1 に変えて、「ためてから預ける」うまみがどう変わったか説明してみましょう。
このステップのコード全文(138行)
# MIT License
#
# Copyright (c) 2026 Tidepost contributors
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
#
# Exception: assets/glyphs.json contains rasterized glyph data derived from
# Noto Sans CJK JP (SIL Open Font License 1.1). See assets/NOTO-LICENSE.txt.
# Pyxel is an external dependency under its own MIT license.
"""おばけ洗濯日和・教材 3/5。原作を簡略化した独立実行ファイル。
実行: python 03_bank.py
コード・図形: MIT。原作者と条件は隣の LICENSE を参照。
"""
from dataclasses import dataclass
WIDTH, HEIGHT = 240, 180
GROUND = 136
FPS = 30
CAPACITY = 8
SHEET_POSITIONS = (120, 150, 90, 60, 180)
def bundle_value(count):
"""原作と同じ束の計算。追加点は最大12点。"""
return 10 * count + 3 * min(max(count - 1, 0), 4)
@dataclass
class Drop:
x: float
y: float = 24
class Game:
def __init__(self):
self.x = WIDTH / 2
self.frames = 0
self.cargo = 0
self.drops = []
self.score = 0
@property
def near_basket(self):
return self.x <= 28 or self.x >= WIDTH - 28
@property
def speed(self):
return max(1.2, 2.0 - self.cargo * 0.1)
def step(self, direction=0, bank=False):
self.frames += 1
# 左右を同時に押すと direction は0。画面の外に出さない。
self.x = max(10, min(WIDTH - 10, self.x + direction * self.speed))
if bank and self.near_basket and self.cargo > 0:
self.score += bundle_value(self.cargo)
self.cargo = 0
if self.frames % 30 == 1:
index = ((self.frames - 1) // 30) % len(SHEET_POSITIONS)
self.drops.append(Drop(SHEET_POSITIONS[index]))
# 元リストから削除するため、コピーを使って全ての落下物を調べる。
for drop in self.drops[:]:
drop.y += 1.5
if drop.y >= GROUND:
if abs(self.x - drop.x) < 12:
self.cargo = min(CAPACITY, self.cargo + 1)
self.drops.remove(drop)
def main(headless=False, screenshot=None):
# 純粋な Game のテストではPyxelを読み込まない。
import pyxel
game = Game()
def update():
nonlocal game
if headless and pyxel.frame_count >= 12:
if screenshot:
pyxel.screenshot(screenshot, scale=2)
pyxel.quit()
return
if pyxel.btnp(pyxel.KEY_R):
game = Game()
return
direction = int(pyxel.btn(pyxel.KEY_RIGHT)) - int(pyxel.btn(pyxel.KEY_LEFT))
game.step(direction, pyxel.btnp(pyxel.KEY_SPACE))
def draw():
pyxel.cls(1)
pyxel.text(8, 8, "GHOST LAUNDRY / STEP 3", 10)
pyxel.line(0, GROUND + 8, WIDTH - 1, GROUND + 8, 3)
for bx in (15, WIDTH - 15):
pyxel.rect(bx - 12, GROUND - 2, 24, 12, 9)
pyxel.rectb(bx - 12, GROUND - 2, 24, 12, 4)
pyxel.text(8, 20, f"SCORE {game.score} CARRY {game.cargo}/{CAPACITY}", 7)
pyxel.text(8, 30, f"BANK +{bundle_value(game.cargo)}", 6)
for drop in game.drops:
pyxel.rect(drop.x - 5, drop.y - 3, 10, 6, 7)
x = game.x
pyxel.circ(x, GROUND - 6, 7, 7)
pyxel.rect(x - 7, GROUND - 6, 15, 11, 7)
pyxel.pset(x - 3, GROUND - 7, 1)
pyxel.pset(x + 3, GROUND - 7, 1)
for i in range(game.cargo):
pyxel.rect(x - 5, GROUND - 18 - i * 4, 10, 3, 6)
pyxel.text(8, 164, "ARROWS: MOVE SPACE: BANK R: RESET", 6)
pyxel.init(WIDTH, HEIGHT, title="Ghost Laundry - Stage 3", fps=FPS, headless=headless)
pyxel.run(update, draw)
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--headless-smoke", action="store_true", help="12 frames then quit; not a playtest")
parser.add_argument("--screenshot", help="PNG path used with --headless-smoke")
args = parser.parse_args()
main(headless=args.headless_smoke, screenshot=args.screenshot)危険を予告して半分落とす
落ちてくる物の種類によって、当たったときの結果を変える。
Drop.kind で、落ちてくる物が布(sheet)か汚れ玉(dirt)かを区別します。汚れ玉は、出てきたときのおばけの x 座標を落下地点として記録し、地面に赤い × を出して知らせます。あとからおばけを追いかけることはありません。汚れ玉に当たると、持ち物の半分(端数は切り上げ)を失います。当たったあと36フレームの間は、続けて当たりません。すでに預けた score(得点)は減りません。
下のコードは 04_hazard.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
if drop.kind == "sheet":
self.cargo = min(CAPACITY, self.cargo + 1)
elif self.cooldown == 0:
lost = (self.cargo + 1) // 2
self.cargo -= lost
self.cooldown = 36 # 30 fpsで1.2秒の連続被弾防止実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 04_hazard.py.venv/bin/python 04_hazard.py- 予告の×が地面に見える
- 5枚から3枚を失って2枚残る
- 得点は減らない
drop.x = self.x とすると、× がおばけを追いかけてしまい、よけられなくなります。落下地点は、汚れ玉を作ったときに1回だけ決めます。5枚持っているときに当たったら、lost(失う枚数)と残りの枚数がいくつになるか計算してみましょう。次に cooldown の 36 を 60 にして、当たったあとに守られる時間を長くしてみましょう。同時に落ちてきたときのテストも確かめてください。
このステップのコード全文(163行)
# MIT License
#
# Copyright (c) 2026 Tidepost contributors
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
#
# Exception: assets/glyphs.json contains rasterized glyph data derived from
# Noto Sans CJK JP (SIL Open Font License 1.1). See assets/NOTO-LICENSE.txt.
# Pyxel is an external dependency under its own MIT license.
"""おばけ洗濯日和・教材 4/5。原作を簡略化した独立実行ファイル。
実行: python 04_hazard.py
コード・図形: MIT。原作者と条件は隣の LICENSE を参照。
"""
from dataclasses import dataclass
WIDTH, HEIGHT = 240, 180
GROUND = 136
FPS = 30
CAPACITY = 8
SHEET_POSITIONS = (120, 150, 90, 60, 180)
def bundle_value(count):
"""原作と同じ束の計算。追加点は最大12点。"""
return 10 * count + 3 * min(max(count - 1, 0), 4)
@dataclass
class Drop:
x: float
y: float = 24
kind: str = "sheet"
class Game:
def __init__(self):
self.x = WIDTH / 2
self.frames = 0
self.cargo = 0
self.drops = []
self.score = 0
self.cooldown = 0
@property
def near_basket(self):
return self.x <= 28 or self.x >= WIDTH - 28
@property
def speed(self):
return max(1.2, 2.0 - self.cargo * 0.1)
def step(self, direction=0, bank=False):
self.frames += 1
self.cooldown = max(0, self.cooldown - 1)
# 左右を同時に押すと direction は0。画面の外に出さない。
self.x = max(10, min(WIDTH - 10, self.x + direction * self.speed))
if bank and self.near_basket and self.cargo > 0:
self.score += bundle_value(self.cargo)
self.cargo = 0
if self.frames % 30 == 1:
index = ((self.frames - 1) // 30) % len(SHEET_POSITIONS)
self.drops.append(Drop(SHEET_POSITIONS[index]))
if self.frames % 110 == 55:
# 発生時の着地点を保存する。プレイヤーを後から追尾しない。
self.drops.append(Drop(self.x, kind="dirt"))
# 元リストから削除するため、コピーを使って全ての落下物を調べる。
for drop in self.drops[:]:
drop.y += 2.0 if drop.kind == "dirt" else 1.5
if drop.y >= GROUND:
if abs(self.x - drop.x) < 12:
if drop.kind == "sheet":
self.cargo = min(CAPACITY, self.cargo + 1)
elif self.cooldown == 0:
lost = (self.cargo + 1) // 2
self.cargo -= lost
self.cooldown = 36 # 30 fpsで1.2秒の連続被弾防止
self.drops.remove(drop)
def main(headless=False, screenshot=None):
# 純粋な Game のテストではPyxelを読み込まない。
import pyxel
game = Game()
def update():
nonlocal game
if headless and pyxel.frame_count >= 12:
if screenshot:
pyxel.screenshot(screenshot, scale=2)
pyxel.quit()
return
if pyxel.btnp(pyxel.KEY_R):
game = Game()
return
direction = int(pyxel.btn(pyxel.KEY_RIGHT)) - int(pyxel.btn(pyxel.KEY_LEFT))
game.step(direction, pyxel.btnp(pyxel.KEY_SPACE))
def draw():
pyxel.cls(1)
pyxel.text(8, 8, "GHOST LAUNDRY / STEP 4", 10)
pyxel.line(0, GROUND + 8, WIDTH - 1, GROUND + 8, 3)
for bx in (15, WIDTH - 15):
pyxel.rect(bx - 12, GROUND - 2, 24, 12, 9)
pyxel.rectb(bx - 12, GROUND - 2, 24, 12, 4)
pyxel.text(8, 20, f"SCORE {game.score} CARRY {game.cargo}/{CAPACITY}", 7)
pyxel.text(8, 30, f"BANK +{bundle_value(game.cargo)}", 6)
for drop in game.drops:
if drop.kind == "dirt":
pyxel.circ(drop.x, drop.y, 4, 8)
pyxel.line(drop.x - 4, drop.y - 4, drop.x + 4, drop.y + 4, 0)
pyxel.line(drop.x + 4, drop.y - 4, drop.x - 4, drop.y + 4, 0)
else:
pyxel.rect(drop.x - 5, drop.y - 3, 10, 6, 7)
x = game.x
pyxel.circ(x, GROUND - 6, 7, 7)
pyxel.rect(x - 7, GROUND - 6, 15, 11, 7)
pyxel.pset(x - 3, GROUND - 7, 1)
pyxel.pset(x + 3, GROUND - 7, 1)
for i in range(game.cargo):
pyxel.rect(x - 5, GROUND - 18 - i * 4, 10, 3, 6)
# 予告は最後に描く。持ち物に隠れないよう地面の下へ置く。
for drop in game.drops:
if drop.kind == "dirt":
pyxel.line(drop.x - 4, GROUND + 10, drop.x + 4, GROUND + 16, 8)
pyxel.line(drop.x + 4, GROUND + 10, drop.x - 4, GROUND + 16, 8)
else:
pyxel.line(drop.x - 5, GROUND + 10, drop.x + 5, GROUND + 10, 6)
if game.cooldown > 0:
pyxel.rectb(x - 9, GROUND - 16, 19, 23, 8)
pyxel.text(8, 164, "ARROWS: MOVE SPACE: BANK R: RESET", 6)
pyxel.init(WIDTH, HEIGHT, title="Ghost Laundry - Stage 4", fps=FPS, headless=headless)
pyxel.run(update, draw)
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--headless-smoke", action="store_true", help="12 frames then quit; not a playtest")
parser.add_argument("--screenshot", help="PNG path used with --headless-smoke")
args = parser.parse_args()
main(headless=args.headless_smoke, screenshot=args.screenshot)時間切れで結果を固定する
遊んでいる途中の状態と、ゲームが終わった状態を分ける。
制限時間の45秒は、30fps(1秒に30フレーム)のフレーム数で数えます。数えた値が ROUND_FRAMES に届いたら done を True にして、それ以降の step では何も変えないようにします。80点以上なら GOAL、届かなければ TIME UP と表示します。預けずに残った cargo は得点に入りません。R キーですべての状態を作り直すので、同じ布の順番でもう一度挑戦できます。
下のコードは 05_round.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
def step(self, direction=0, bank=False):
if self.done:
return実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 05_round.py.venv/bin/python 05_round.py- TIME 0で操作が止まる
- 未確定の布は得点にならない
- Rですべて初期化される
step の最初にある if self.done: return(終わっていたら何もしない)が、ゲームを止める部分です。ROUND_FRAMES を 30 * FPS(30秒)に、GOAL を 60 に変えてみましょう。難しさを比べるときは、2つを同時に変えないのがコツです。先に時間だけ、次に目標点だけを変えて、それぞれの効果を確かめます。
このステップのコード全文(179行)
# MIT License
#
# Copyright (c) 2026 Tidepost contributors
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
#
# Exception: assets/glyphs.json contains rasterized glyph data derived from
# Noto Sans CJK JP (SIL Open Font License 1.1). See assets/NOTO-LICENSE.txt.
# Pyxel is an external dependency under its own MIT license.
"""おばけ洗濯日和・教材 5/5。原作を簡略化した独立実行ファイル。
実行: python 05_round.py
コード・図形: MIT。原作者と条件は隣の LICENSE を参照。
"""
from dataclasses import dataclass
WIDTH, HEIGHT = 240, 180
GROUND = 136
FPS = 30
CAPACITY = 8
SHEET_POSITIONS = (120, 150, 90, 60, 180)
ROUND_FRAMES = 45 * FPS
GOAL = 80
def bundle_value(count):
"""原作と同じ束の計算。追加点は最大12点。"""
return 10 * count + 3 * min(max(count - 1, 0), 4)
@dataclass
class Drop:
x: float
y: float = 24
kind: str = "sheet"
class Game:
def __init__(self):
self.x = WIDTH / 2
self.frames = 0
self.cargo = 0
self.drops = []
self.score = 0
self.cooldown = 0
self.done = False
@property
def near_basket(self):
return self.x <= 28 or self.x >= WIDTH - 28
@property
def speed(self):
return max(1.2, 2.0 - self.cargo * 0.1)
def step(self, direction=0, bank=False):
if self.done:
return
self.frames += 1
self.cooldown = max(0, self.cooldown - 1)
# 左右を同時に押すと direction は0。画面の外に出さない。
self.x = max(10, min(WIDTH - 10, self.x + direction * self.speed))
if bank and self.near_basket and self.cargo > 0:
self.score += bundle_value(self.cargo)
self.cargo = 0
if self.frames % 30 == 1:
index = ((self.frames - 1) // 30) % len(SHEET_POSITIONS)
self.drops.append(Drop(SHEET_POSITIONS[index]))
if self.frames % 110 == 55:
# 発生時の着地点を保存する。プレイヤーを後から追尾しない。
self.drops.append(Drop(self.x, kind="dirt"))
# 元リストから削除するため、コピーを使って全ての落下物を調べる。
for drop in self.drops[:]:
drop.y += 2.0 if drop.kind == "dirt" else 1.5
if drop.y >= GROUND:
if abs(self.x - drop.x) < 12:
if drop.kind == "sheet":
self.cargo = min(CAPACITY, self.cargo + 1)
elif self.cooldown == 0:
lost = (self.cargo + 1) // 2
self.cargo -= lost
self.cooldown = 36 # 30 fpsで1.2秒の連続被弾防止
self.drops.remove(drop)
if self.frames >= ROUND_FRAMES:
self.done = True # 持ち物は自動で得点にならない。
def main(headless=False, screenshot=None):
# 純粋な Game のテストではPyxelを読み込まない。
import pyxel
game = Game()
def update():
nonlocal game
if headless and pyxel.frame_count >= 12:
if screenshot:
pyxel.screenshot(screenshot, scale=2)
pyxel.quit()
return
if pyxel.btnp(pyxel.KEY_R):
game = Game()
return
direction = int(pyxel.btn(pyxel.KEY_RIGHT)) - int(pyxel.btn(pyxel.KEY_LEFT))
game.step(direction, pyxel.btnp(pyxel.KEY_SPACE))
def draw():
pyxel.cls(1)
pyxel.text(8, 8, "GHOST LAUNDRY / STEP 5", 10)
pyxel.line(0, GROUND + 8, WIDTH - 1, GROUND + 8, 3)
for bx in (15, WIDTH - 15):
pyxel.rect(bx - 12, GROUND - 2, 24, 12, 9)
pyxel.rectb(bx - 12, GROUND - 2, 24, 12, 4)
pyxel.text(8, 20, f"SCORE {game.score} CARRY {game.cargo}/{CAPACITY}", 7)
pyxel.text(8, 30, f"BANK +{bundle_value(game.cargo)}", 6)
for drop in game.drops:
if drop.kind == "dirt":
pyxel.circ(drop.x, drop.y, 4, 8)
pyxel.line(drop.x - 4, drop.y - 4, drop.x + 4, drop.y + 4, 0)
pyxel.line(drop.x + 4, drop.y - 4, drop.x - 4, drop.y + 4, 0)
else:
pyxel.rect(drop.x - 5, drop.y - 3, 10, 6, 7)
x = game.x
pyxel.circ(x, GROUND - 6, 7, 7)
pyxel.rect(x - 7, GROUND - 6, 15, 11, 7)
pyxel.pset(x - 3, GROUND - 7, 1)
pyxel.pset(x + 3, GROUND - 7, 1)
for i in range(game.cargo):
pyxel.rect(x - 5, GROUND - 18 - i * 4, 10, 3, 6)
# 予告は最後に描く。持ち物に隠れないよう地面の下へ置く。
for drop in game.drops:
if drop.kind == "dirt":
pyxel.line(drop.x - 4, GROUND + 10, drop.x + 4, GROUND + 16, 8)
pyxel.line(drop.x + 4, GROUND + 10, drop.x - 4, GROUND + 16, 8)
else:
pyxel.line(drop.x - 5, GROUND + 10, drop.x + 5, GROUND + 10, 6)
if game.cooldown > 0:
pyxel.rectb(x - 9, GROUND - 16, 19, 23, 8)
left = max(0, (ROUND_FRAMES - game.frames + FPS - 1) // FPS)
pyxel.text(168, 30, f"TIME {left:02}", 10)
if game.done:
pyxel.rect(29, 58, 182, 52, 0)
label = "GOAL!" if game.score >= GOAL else "TIME UP"
pyxel.text(90, 65, label, 10)
pyxel.text(40, 79, f"SCORE {game.score} / GOAL {GOAL}", 7)
pyxel.text(40, 90, "CARRY IS NOT BANKED. R: RETRY", 6)
pyxel.text(8, 164, "ARROWS: MOVE SPACE: BANK R: RESET", 6)
pyxel.init(WIDTH, HEIGHT, title="Ghost Laundry - Stage 5", fps=FPS, headless=headless)
pyxel.run(update, draw)
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--headless-smoke", action="store_true", help="12 frames then quit; not a playtest")
parser.add_argument("--screenshot", help="PNG path used with --headless-smoke")
args = parser.parse_args()
main(headless=args.headless_smoke, screenshot=args.screenshot)仕組みがわかったら、元の完成版を読んでみよう。
完成版は Enter キーで始まります。矢印キーか A・D キーで移動し、左右のかごで Space を押して預けます。持てる布は最大8枚で、75秒で220点を目指します。R キーは同じ配置で、N キーは別の配置でやり直します。M キーで音のオン・オフを切り替えます。
ここまでに作ったのは、元のゲームの仕組みだけを取り出して作り直した「簡略版」です。元のゲームのソースは手を加えずに、別のZIP(元の完成版ソース)に入れてあります。
| 比べる点 | 5ステップの教材 | 元の完成版 |
|---|---|---|
| ラウンド | 45秒・80点 | 75秒・220点 |
| 落下物 | 決まった順番・垂直落下 | 乱数(seed)で配置を決める・風で斜めに落ちる |
| 演出 | 英語表示・図形だけ・音なし | 日本語表示・音・粒の演出・練習モード |
| 共通する仕組み | 集める / 預ける / 最大8枚 / 束の追加点 / 被弾で半分を失う | |
完成版のコードで、次に読むところ
model.pyのbundle_valueを開いて、教材と同じ得点の計算式を探してみましょう。Game.stepのDT = 1/30とelapsedを読んでみましょう。教材ではフレーム数で時間を数えましたが、完成版は秒で管理しています。その違いを比べてみましょう。spawnで、落下地点を最初に1回だけ決めている処理を読みます。予告の × がずれないようにしたまま、風の動きを足している方法がわかります。app.pyのupdateとdrawを読み、当たり判定などのルールと、見た目の演出が別々に書かれていることを確かめましょう。
最初は、数字かルールを1つだけ変えてみましょう。遊ぶ前に「どう変わるか」を予想しておくと、仕組みがよくわかります。慣れてきたら、見た目や音も足してみてください。元のファイルは残しておき、コピーした別のフォルダーで改造すると、いつでも元に戻せます。
教材をダウンロードする
無料で、登録なしでダウンロードできます。ZIPファイルは必ず展開してから、中のファイルを実行してください。元の完成版は、同じZIPに入っている assets フォルダー(画像などの素材)も一緒に使います。
コードと図形はMITライセンス、元の完成版に含まれる日本語の文字データ(Noto由来)はSIL OFL 1.1です。配ったり改造したりするときは、ZIPに入っているLICENSEなどの権利表示を残してください。出典・権利について
検証について
どこまで動作を確認したかを見る
2026年10月5日以降、Linux(Python 3.12・Pyxel 2.9.9)で、教材のコードとゲームのルールを自動テストしています。全500ステップの教材が、実際のPyxelで起動して画面を描けることも確かめました。元の完成版についても、自動で遊ぶデモが起動して画面を描き、正常に終わることを確認しています。
この確認は、画面を表示しない自動テストで行ったものです。Windows・Macの実際のパソコンでの操作や、音が出るかどうかはまだ確認していません。人がすべての操作を試したわけでもありません。ブラウザー版の確認範囲は確認状況をご覧ください。
うまく動かないときは:起動やエラーの対処法 / 質問用のメモを作る
