アクションLESSON 01キャッチ × リスク判断原作002

おばけ洗濯日和の作り方

もう1枚、拾う? それとも帰る?
落ちてくるシーツを集めて、かごに預けると得点になります。まだ預けていない「持ち物」と、預けて確定した「得点」を分けるだけで、プレイヤーが迷うおもしろさが生まれます。

完成版の遊び方落ちてくる布を集めて、75秒以内にかごへ預けて得点にします。たくさん持つほど足が遅くなり、危険な物に当たると持ち物を落とします。

おばけ洗濯日和の元の完成版。自動操作によるPyxelの実描画画面。
元の完成版の画面です。この講座では、仕組みがわかりやすいように小さくした「教材版」を、5つのステップで作ります。
PLAY / 試験公開

完成サンプルをブラウザーで遊ぶ

この講座のもとになった完成版のゲームを、ブラウザーでそのまま遊べます。キーボードで操作するので、PCで開いてください(スマホのタッチ操作には対応していません)。初めて開くときは、読み込みに少し時間がかかります。

完成版の主な操作:Enterで開始(Pで練習)、←→で移動、左右の端でSpaceを押してかごに預ける、Rで同じ配置からやり直し、Nで別の配置、Escで表紙に戻る

ボタンを押すと、ゲームの読み込みが始まります。

画面に「CLICK TO START」と出たらクリックします。続けてゲーム画面を一度クリックすると、キー操作が効くようになります。ゲームを終えるときは、枠の外にある「閉じる」ボタンを使ってください(Escキーの働きはゲームごとに違います)。ゲーム内のセーブは、閉じる・ページを移る・再読み込みすると消えます。

うまく表示されないとき

ゲームを動かすためのプログラム(Pyxel 2.9.9)は、外部の配信サービス(jsDelivr)から読み込みます。表示されないときは「最初から再読込」を押すか、完成版のソースをダウンロードしてPCで実行してください。ブラウザー版の録画機能は止めています。ブラウザー版で確認できている範囲は検証状況にまとめています。

同じPyxelアプリをダウンロード(.pyxapp) / 別のタブで開く

持ち物と得点を分ける。

いきなり完成版のコードを全部読む必要はありません。小さなゲームにルールを1つずつ足しながら、5つのステップで作っていきます。どのステップのファイルもそれだけで動くので、動きを確かめてから次へ進みましょう。

拾う

持ち物 3枚
確定点 0点

かごで預ける

3 × 10 + 3 × 2
= 36点を加算

次を選ぶ

持ち物 0枚
確定点 36点

拾った布は、すぐには得点になりません。かごに預けて初めて得点になります。汚れ玉に当たって失うのは、まだ預けていない持ち物だけです。
必要な環境
Python 3.11以上 / Pyxel 2.9.9
知っておくとよいこと
変数・if・関数の基本
教材に入っているもの
ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
完成した教材の操作
← →:移動 / Space:かごに預ける / R:やり直し
難易度の目安
入門このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_move.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

コードを書き換えたら、ファイルを保存し、動いているゲームを閉じてから、もう一度同じ.pyを実行します。ゲーム中にRキーなどで「やり直し」をしても、書き換えたコードは読み込まれません。

教材のゲーム画面は、日本語フォントを用意しなくても動くように英語で表示しています。ファイルには動作確認用のコードも入っていますが、最初は Game / State・update・draw の3つに注目すれば十分です。わからない言葉は用語集で調べられます。class・self・dataclass はPythonの状態、キー入力と時間の扱いは入力・更新・描画、マス目とピクセルの違いは画面とマップの座標のページで詳しく説明しています。

STEP 01 / 05

座標を持つおばけ

「数値を変える処理」と「絵を描く処理」を分ける。

おばけの左右の位置は、Game.x という数値で表します。ゲームは1秒間に30回、同じ処理をくり返しています。update はキー入力を読み、step で位置の数値を変えます。draw は、その数値の場所におばけを描くだけです。左右のキー入力は「左なら -1、押していなければ 0、右なら 1」の3つの値にまとめ、max と min で画面の端から出ないように止めます。R キーを押すと、最初の状態を作り直します。

下のコードは 01_move.py の一部です。前のステップから追加・変更したところだけを抜き出しています。

01_move.py / 注目する部分
def step(self, direction=0):
    # 左右を同時に押すと direction は0。画面の外に出さない。
    self.x = max(10, min(WIDTH - 10, self.x + direction * 2))

実行して確かめる

教材のZIPを展開したフォルダーで、次のコマンドを実行します。

Windows
.venv\Scripts\python.exe 01_move.py
macOS / Linux
.venv/bin/python 01_move.py
画面で見ること白いおばけが左右キーで動き、画面の両端で止まります。左右のキーを同時に押すと動きません。R キーで中央に戻ります。
  • 左右に動く
  • 端から出ない
  • Rで中央へ戻る
つまずきやすいところdraw の中で x を増やしてはいけません。draw は画面を描くたびに呼ばれるので、描く回数によってゲームの動きが変わってしまいます。位置を変える処理は step に書きます。
少し変えてみよう

移動量の 2 を 1 に変えて、同じ距離を進むのにかかる時間を比べてみましょう。元に戻したら、右端の WIDTH - 10 の 10 を 20 に変えると何が変わるか、先に予想してから試してみましょう。

01_move.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(86行)
01_move.py / 全文
# 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)
STEP 02 / 05

落下物を集める

リストを使って、たくさんの落下物をまとめて動かす。

Drop は、落ちてくる布1枚分の位置をまとめたものです。30フレーム(1秒)ごとに新しい布を追加し、毎フレーム y(縦の位置)を増やして落とします。布が地面(GROUND)に届いた瞬間だけ、おばけとの横の距離を比べ、12未満なら拾えたことにします。持てる布(cargo)は最大8枚です。頭の上に積んだ布の絵が高くなっても、拾えるかどうかを判定する高さは変わりません。

下のコードは 02_catch.py の一部です。前のステップから追加・変更したところだけを抜き出しています。

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を展開したフォルダーで、次のコマンドを実行します。

Windows
.venv\Scripts\python.exe 02_catch.py
macOS / Linux
.venv/bin/python 02_catch.py
画面で見ること白い布が、横の位置120→150→90→60→180の順に降ってきます。中央で2.5秒ほど待つと、最初の1枚を拾えます。拾った枚数が CARRY に表示され、おばけの頭の上に布が積まれます。
  • 最初の布を中央で拾う
  • 落ちる場所にいないと増えない
  • 8枚を超えない
つまずきやすいところfor drop in self.drops でリストを順番に見ている途中で remove(削除)すると、となりの落下物が飛ばされてしまうことがあります。そこで、リストのコピー self.drops[:] を順番に見るようにしています。
少し変えてみよう

SHEET_POSITIONS の数字の順番を入れ替えて試してみましょう。次に 1.5 を 1.0 に変えて、落ちるまでの時間だけが変わることを確かめましょう。

02_catch.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(117行)
02_catch.py / 全文
# 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)
STEP 03 / 05

持ち物を得点に変える

拾った持ち物と、確定した得点を分ける。

布は持っているだけでは得点になりません。左右のかごの近くで SPACE キーを押すと、持っている布の点数を score(得点)に足して、cargo(持ち物)を0に戻します。布は1枚10点で、まとめて預けると追加点が最大12点つきます。たくさん持つほど speed(移動の速さ)が少し下がるので、「早めに預ける」か「たくさん集める」かを選ぶことになります。

下のコードは 03_bank.py の一部です。前のステップから追加・変更したところだけを抜き出しています。

03_bank.py / 注目する部分
if bank and self.near_basket and self.cargo > 0:
    self.score += bundle_value(self.cargo)
    self.cargo = 0

実行して確かめる

教材のZIPを展開したフォルダーで、次のコマンドを実行します。

Windows
.venv\Scripts\python.exe 03_bank.py
macOS / Linux
.venv/bin/python 03_bank.py
画面で見ることCARRY(持ち物)と SCORE(得点)が別々に表示されます。布を持って左端か右端に行き SPACE を押すと、SCORE が増えて頭の上の布が消えます。中央で SPACE を押しても預けられません。
  • 中央では預けられない
  • かごでSPACEを押すと加算される
  • 持ち物が減ると速さが戻る
つまずきやすいところscore = bundle_value(...) と書くと、それまでの得点が上書きされて消えてしまいます。得点を足すときは += を使います。また、先に cargo を0にしてしまうと、預ける布の点数も0になります。
少し変えてみよう

3枚なら36点、5枚なら62点、8枚なら92点になることを、計算式で確かめてみましょう。次に、追加点の係数 3 を 1 に変えて、「ためてから預ける」うまみがどう変わったか説明してみましょう。

03_bank.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(138行)
03_bank.py / 全文
# 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)
STEP 04 / 05

危険を予告して半分落とす

落ちてくる物の種類によって、当たったときの結果を変える。

Drop.kind で、落ちてくる物が布(sheet)か汚れ玉(dirt)かを区別します。汚れ玉は、出てきたときのおばけの x 座標を落下地点として記録し、地面に赤い × を出して知らせます。あとからおばけを追いかけることはありません。汚れ玉に当たると、持ち物の半分(端数は切り上げ)を失います。当たったあと36フレームの間は、続けて当たりません。すでに預けた score(得点)は減りません。

下のコードは 04_hazard.py の一部です。前のステップから追加・変更したところだけを抜き出しています。

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を展開したフォルダーで、次のコマンドを実行します。

Windows
.venv\Scripts\python.exe 04_hazard.py
macOS / Linux
.venv/bin/python 04_hazard.py
画面で見ること赤い汚れ玉と、地面の × 印が出ます。× の場所にいると持ち物が減り、少しの間赤い枠が表示されます。汚れ玉が出たあとに移動しても、× は動きません。
  • 予告の×が地面に見える
  • 5枚から3枚を失って2枚残る
  • 得点は減らない
つまずきやすいところ毎フレーム drop.x = self.x とすると、× がおばけを追いかけてしまい、よけられなくなります。落下地点は、汚れ玉を作ったときに1回だけ決めます。
少し変えてみよう

5枚持っているときに当たったら、lost(失う枚数)と残りの枚数がいくつになるか計算してみましょう。次に cooldown の 36 を 60 にして、当たったあとに守られる時間を長くしてみましょう。同時に落ちてきたときのテストも確かめてください。

04_hazard.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(163行)
04_hazard.py / 全文
# 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)
STEP 05 / 05

時間切れで結果を固定する

遊んでいる途中の状態と、ゲームが終わった状態を分ける。

制限時間の45秒は、30fps(1秒に30フレーム)のフレーム数で数えます。数えた値が ROUND_FRAMES に届いたら done を True にして、それ以降の step では何も変えないようにします。80点以上なら GOAL、届かなければ TIME UP と表示します。預けずに残った cargo は得点に入りません。R キーですべての状態を作り直すので、同じ布の順番でもう一度挑戦できます。

下のコードは 05_round.py の一部です。前のステップから追加・変更したところだけを抜き出しています。

05_round.py / 注目する部分
def step(self, direction=0, bank=False):
    if self.done:
        return

実行して確かめる

教材のZIPを展開したフォルダーで、次のコマンドを実行します。

Windows
.venv\Scripts\python.exe 05_round.py
macOS / Linux
.venv/bin/python 05_round.py
画面で見ることTIME が減っていき、0 になると結果が表示されます。終わったあとは、矢印キーや SPACE を押しても何も変わりません。R を押すと、45秒・0点・持ち物0の状態から始め直せます。
  • TIME 0で操作が止まる
  • 未確定の布は得点にならない
  • Rですべて初期化される
つまずきやすいところ結果を画面に描くだけでは、裏で落下物や得点が動き続けてしまいます。step の最初にある if self.done: return(終わっていたら何もしない)が、ゲームを止める部分です。
少し変えてみよう

ROUND_FRAMES を 30 * FPS(30秒)に、GOAL を 60 に変えてみましょう。難しさを比べるときは、2つを同時に変えないのがコツです。先に時間だけ、次に目標点だけを変えて、それぞれの効果を確かめます。

05_round.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(179行)
05_round.py / 全文
# 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)
AFTER STEP 05

仕組みがわかったら、元の完成版を読んでみよう。

完成版は Enter キーで始まります。矢印キーか A・D キーで移動し、左右のかごで Space を押して預けます。持てる布は最大8枚で、75秒で220点を目指します。R キーは同じ配置で、N キーは別の配置でやり直します。M キーで音のオン・オフを切り替えます。

ここまでに作ったのは、元のゲームの仕組みだけを取り出して作り直した「簡略版」です。元のゲームのソースは手を加えずに、別のZIP(元の完成版ソース)に入れてあります。

比べる点5ステップの教材元の完成版
ラウンド45秒・80点75秒・220点
落下物決まった順番・垂直落下乱数(seed)で配置を決める・風で斜めに落ちる
演出英語表示・図形だけ・音なし日本語表示・音・粒の演出・練習モード
共通する仕組み集める / 預ける / 最大8枚 / 束の追加点 / 被弾で半分を失う

完成版のコードで、次に読むところ

  1. model.py の bundle_value を開いて、教材と同じ得点の計算式を探してみましょう。
  2. Game.step の DT = 1/30 と elapsed を読んでみましょう。教材ではフレーム数で時間を数えましたが、完成版は秒で管理しています。その違いを比べてみましょう。
  3. spawn で、落下地点を最初に1回だけ決めている処理を読みます。予告の × がずれないようにしたまま、風の動きを足している方法がわかります。
  4. 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の実際のパソコンでの操作や、音が出るかどうかはまだ確認していません。人がすべての操作を試したわけでもありません。ブラウザー版の確認範囲は確認状況をご覧ください。

うまく動かないときは:起動やエラーの対処法 / 質問用のメモを作る