スポーツ・レースLESSON 04ワンボタン釣り/タイミング原作004

最後の一匹の作り方

巻くか、緩めるか。1つのボタンで駆け引きする釣りゲーム。
魚の動きの前ぶれを読んで、糸を「巻く」と「緩める」を切り替えます。毎フレーム数値を更新する方法と、ボタンを押している時間・離している時間の使い方を学びます。

完成版の遊び方魚の尾がひるがえる前ぶれを見て、糸を巻くか緩めるかを選び、魚を釣り上げます。続けて釣るとボーナスがあります。

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

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

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

完成版の主な操作:Spaceを押している間は巻く・離すと緩める、魚と魚の間に左右で選んでSpaceで決定、PかEscで一時停止、Rでやり直し、Tabで魚の群れを選ぶ、ウィンドウの×で終了

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

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

うまく表示されないとき

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

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

ボタンを押している時間と、離している時間をルールにする。

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

1. 押した瞬間と、押し続けている間を区別する
2. 1つの入力で、得をする数値と危険な数値を同時に変える
3. フレームを一定の周期で区切り、最後の1回だけ出来事を起こす
それだけで動く小さなプログラムを、5つのステップで少しずつ大きくしていきます。
必要な環境
Python 3.11以上 / Pyxel 2.9.9
知っておくとよいこと
変数・if・関数の基本
教材に入っているもの
ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
完成した教材の操作
SPACE:押している間は巻く、離すと緩める / R:同じ最初の状態からやり直す / P:一時停止・再開(05のみ) / ESC:終了(元のゲームの Esc は一時停止です)
難易度の目安
初級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_button.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

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

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

STEP 01 / 05

長押しと離した時間

押した瞬間と、押し続けている間を区別する。

Space を押しているフレームの間はずっと held が True、離している間は False になります。btn は押し続けているかどうか、btnp は押した瞬間かどうかを調べる関数です。reeled は続けて押したフレーム数、released は続けて離したフレーム数です。片方が増えるときにもう片方を0に戻すことで、「合計」ではなく「続けて何フレームか」を数えられます。魚までの距離 DIST は、押し続けると減り、離すと増えます。まだ勝ち負けはありません。

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

01_button.py / 注目する部分
self.held = held
# btnでは長押しを読む。離したフレームで反対のカウンターを0にする。
self.reeled = self.reeled + 1 if held else 0
self.released = 0 if held else self.released + 1

実行して確かめる

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

Windows
.venv\Scripts\python.exe 01_button.py
macOS / Linux
.venv/bin/python 01_button.py
画面で見ることSpace を押している間は HELD とその数が増え、離すと RELEASED が増えます。押すと魚が岸に近づき、離すと遠ざかります。R キーで、距離70・両方のカウンター0に戻ります。
  • Spaceの長押しでHELDが増える
  • 離すとHELDは0・RELEASEDが増える
  • Rで同じ初期状態へ戻る
つまずきやすいところ巻く操作を btnp(KEY_SPACE) にすると、押し続けても最初の1フレームしか動きません。また、反対側のカウンターを0に戻し忘れると、続けて押した時間を測れません。
少し変えてみよう

step(True) を8回、step(False) を4回、step(True) を1回呼んだとき、それぞれの reeled と released を紙に書いてみましょう。答えは順に (8,0)、(0,4)、(1,0) です。1フレームは1/30秒なので、30回で1秒になります。

01_button.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(127行)
01_button.py / 全文
# MIT License
#
# Copyright (c) 2026 Pyxel 100 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_button.py
01〜04はメーター観察の練習、05は小魚1匹を釣るゲームです。
日本語フォント素材は使わず、標準フォントと図形だけで描きます。
"""

WIDTH, HEIGHT = 280, 210
FPS = 30


class Game:
    def __init__(self):
        self.distance = 70.0
        self.held = False
        self.reeled = 0
        self.released = 0

    def step(self, held=False):
        self.held = held
        # btnでは長押しを読む。離したフレームで反対のカウンターを0にする。
        self.reeled = self.reeled + 1 if held else 0
        self.released = 0 if held else self.released + 1
        if held:
            self.distance -= 0.42
        else:
            self.distance += 0.45

        # 01〜04は練習用。端に来ても終了せずメーターを範囲内に止める。
        self.distance = max(0.0, min(100.0, self.distance))


def main(headless=False, screenshot=None):
    # importしてGameを試すだけならPyxelは不要。描画のときだけ読み込む。
    import pyxel
    from pathlib import Path

    # Pyxel初期化より前に、呼び出した場所からの保存先を確定する。
    if screenshot:
        screenshot = str(Path(screenshot).expanduser().resolve())
    game = Game()
    frames = 0
    screenshot_saved = False

    def update():
        nonlocal game, frames
        # 12回の更新と12回の描画の後、次の更新で終了する。
        if headless and frames >= 12:
            pyxel.quit()
            return
        frames += 1
        if pyxel.btnp(pyxel.KEY_R):
            game = Game()
            return
        game.step(pyxel.btn(pyxel.KEY_SPACE))

    def bar(y, label, value, color):
        shown = max(0.0, min(100.0, value))
        pyxel.text(10, y, label, 7)
        pyxel.rect(64, y, 164, 7, 0)
        pyxel.rect(65, y + 1, int(shown * 1.62), 5, color)
        pyxel.text(237, y, f"{shown:5.1f}", color)

    def draw():
        nonlocal screenshot_saved
        pyxel.cls(1)
        pyxel.text(10, 8, "THE LAST FISH / STEP 1", 10)
        pyxel.text(10, 19, "PRACTICE: METERS DO NOT END THE GAME", 6)
        pyxel.text(10, 35, "SPACE: " + ("HELD / REEL" if game.held else "RELEASED / SLACK"), 7)
        pyxel.text(10, 46, f"HELD {game.reeled:3}f   RELEASED {game.released:3}f", 6)
        bar(60, "DIST", game.distance, 12)
        pyxel.text(10, 75, "HOLD: CLOSER / RELEASE: FARTHER", 6)
        # 距離が0なら岸側、100なら画面右。状態の計算はここでしない。
        x = 54 + max(0.0, min(100.0, game.distance)) * 1.90
        pyxel.rect(0, 134, WIDTH, 38, 5)
        pyxel.line(0, 140, WIDTH - 1, 140, 6)
        pyxel.rect(0, 131, 38, 9, 4)
        pyxel.line(24, 133, 42, 122, 9)
        pyxel.line(42, 122, x, 151, 7)
        pyxel.elli(x - 9, 146, 18, 10, 10)
        pyxel.tri(x + 8, 151, x + 16, 145, x + 16, 157, 10)
        pyxel.pset(x - 5, 149, 0)
        pyxel.text(10, 104, "ONE BUTTON, TWO DIFFERENT ACTIONS", 10)
        pyxel.text(10, 178, "R RESTARTS THE SAME PRACTICE", 6)
        pyxel.text(10, 196, "SPACE: REEL   R: RESET   ESC: QUIT", 6)
        # 12回目に描いた本物のPyxel画面を保存。自動プレイ画像ではない。
        if screenshot and frames >= 12 and not screenshot_saved:
            pyxel.screenshot(screenshot, scale=2)
            screenshot_saved = True

    pyxel.init(WIDTH, HEIGHT, title="The Last Fish - 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="Render 12 real Pyxel frames then quit; not a full playtest")
    parser.add_argument("--screenshot", metavar="PATH", help="Save the 12th rendered frame as a PNG")
    args = parser.parse_args()
    main(headless=args.headless_smoke, screenshot=args.screenshot)
STEP 02 / 05

距離と張力を分ける

1つの入力で、得をする数値と危険な数値を同時に変える。

DIST は魚までの距離、TENSION は糸の張り具合(張力)です。巻くと1フレームごとに距離が0.42減り、張力が0.60増えます。離すと距離が0.45増え、張力が1.30減ります。近づけようと巻き続けると張力が上がりすぎ、緩めてばかりだと魚が遠ざかります。ここは練習なので、値を0〜100の範囲に収めるだけです。勝ち負けは05で追加します。

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

02_meters.py / 注目する部分
if held:
    self.distance -= 0.42
    self.tension += 0.60
else:
    self.distance += 0.45
    self.tension = max(0.0, self.tension - 1.30)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 02_meters.py
macOS / Linux
.venv/bin/python 02_meters.py
画面で見ること押し続けると青い距離の棒が短くなり、張力の棒が伸びます。離すと逆になります。張力が75以上になると赤色になります。この段階では、張力が100になってもゲームは続きます。
  • 長押しは近づくが張力が上がる
  • 離すと張力が下がるが遠ざかる
  • 0未満・100超のメーターにならない
つまずきやすいところ張力と距離を1つの値で管理すると、「近いけれど糸が切れそう」という状態を表せません。また、max(0.0, ...) を外すと張力がマイナスになり、次に押し続けたときに、不自然に安全な時間ができてしまいます。
少し変えてみよう

最初の値から1フレーム巻いたあとの距離69.58・張力20.60と、続けて1フレーム離したあとの距離70.03・張力19.30を、計算で確かめてみましょう。次に、張力の増え方 0.60 だけを 0.30 に変えて、距離の変わり方が同じままか観察してみましょう。

02_meters.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(131行)
02_meters.py / 全文
# MIT License
#
# Copyright (c) 2026 Pyxel 100 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_meters.py
01〜04はメーター観察の練習、05は小魚1匹を釣るゲームです。
日本語フォント素材は使わず、標準フォントと図形だけで描きます。
"""

WIDTH, HEIGHT = 280, 210
FPS = 30


class Game:
    def __init__(self):
        self.distance = 70.0
        self.held = False
        self.reeled = 0
        self.released = 0
        self.tension = 20.0

    def step(self, held=False):
        self.held = held
        # btnでは長押しを読む。離したフレームで反対のカウンターを0にする。
        self.reeled = self.reeled + 1 if held else 0
        self.released = 0 if held else self.released + 1
        if held:
            self.distance -= 0.42
            self.tension += 0.60
        else:
            self.distance += 0.45
            self.tension = max(0.0, self.tension - 1.30)

        # 01〜04は練習用。端に来ても終了せずメーターを範囲内に止める。
        self.distance = max(0.0, min(100.0, self.distance))
        self.tension = min(100.0, self.tension)


def main(headless=False, screenshot=None):
    # importしてGameを試すだけならPyxelは不要。描画のときだけ読み込む。
    import pyxel
    from pathlib import Path

    # Pyxel初期化より前に、呼び出した場所からの保存先を確定する。
    if screenshot:
        screenshot = str(Path(screenshot).expanduser().resolve())
    game = Game()
    frames = 0
    screenshot_saved = False

    def update():
        nonlocal game, frames
        # 12回の更新と12回の描画の後、次の更新で終了する。
        if headless and frames >= 12:
            pyxel.quit()
            return
        frames += 1
        if pyxel.btnp(pyxel.KEY_R):
            game = Game()
            return
        game.step(pyxel.btn(pyxel.KEY_SPACE))

    def bar(y, label, value, color):
        shown = max(0.0, min(100.0, value))
        pyxel.text(10, y, label, 7)
        pyxel.rect(64, y, 164, 7, 0)
        pyxel.rect(65, y + 1, int(shown * 1.62), 5, color)
        pyxel.text(237, y, f"{shown:5.1f}", color)

    def draw():
        nonlocal screenshot_saved
        pyxel.cls(1)
        pyxel.text(10, 8, "THE LAST FISH / STEP 2", 10)
        pyxel.text(10, 19, "PRACTICE: METERS DO NOT END THE GAME", 6)
        pyxel.text(10, 35, "SPACE: " + ("HELD / REEL" if game.held else "RELEASED / SLACK"), 7)
        pyxel.text(10, 46, f"HELD {game.reeled:3}f   RELEASED {game.released:3}f", 6)
        bar(60, "DIST", game.distance, 12)
        bar(74, "TENSION", game.tension, 8 if game.tension >= 75 else 10)
        # 距離が0なら岸側、100なら画面右。状態の計算はここでしない。
        x = 54 + max(0.0, min(100.0, game.distance)) * 1.90
        pyxel.rect(0, 134, WIDTH, 38, 5)
        pyxel.line(0, 140, WIDTH - 1, 140, 6)
        pyxel.rect(0, 131, 38, 9, 4)
        pyxel.line(24, 133, 42, 122, 9)
        pyxel.line(42, 122, x, 151, 7)
        pyxel.elli(x - 9, 146, 18, 10, 10)
        pyxel.tri(x + 8, 151, x + 16, 145, x + 16, 157, 10)
        pyxel.pset(x - 5, 149, 0)
        pyxel.text(10, 104, "ONE BUTTON, TWO DIFFERENT ACTIONS", 10)
        pyxel.text(10, 178, "R RESTARTS THE SAME PRACTICE", 6)
        pyxel.text(10, 196, "SPACE: REEL   R: RESET   ESC: QUIT", 6)
        # 12回目に描いた本物のPyxel画面を保存。自動プレイ画像ではない。
        if screenshot and frames >= 12 and not screenshot_saved:
            pyxel.screenshot(screenshot, scale=2)
            screenshot_saved = True

    pyxel.init(WIDTH, HEIGHT, title="The Last Fish - 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="Render 12 real Pyxel frames then quit; not a full playtest")
    parser.add_argument("--screenshot", metavar="PATH", help="Save the 12th rendered frame as a PNG")
    args = parser.parse_args()
    main(headless=args.headless_smoke, screenshot=args.screenshot)
STEP 03 / 05

本物の反転を予告する

フレームを一定の周期で区切り、最後の1回だけ出来事を起こす。

tick(経過フレーム数)を84で割った余り local を使い、「泳ぐ」36フレーム・「前ぶれ」24フレーム・「休み」24フレームをくり返します。この段階の前ぶれは、すべて本物の反転(魚が向きを変える動き)で、右向きの矢印と REAL TURN IN 24〜1 が表示されます。local=59 で更新するときに1回だけ反転します。そのとき巻いていれば張力が+65、離していれば+16になり、どちらも距離が+8されます。04で作る成功のごほうびは、まだありません。休み中は、巻いたときの張力の増え方が+0.20に下がります。

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

03_cue.py / 注目する部分
# 予兆の最後だけ判定する。24フレーム全てに加算しない。
if self.local == TURN_FRAME:
    self.tension += 65 if held else 16
    self.distance += 8
    self.feedback = "TURN! HELD +65" if held else "TURN! RELEASED +16"
    self.feedback_ticks = 35

実行して確かめる

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

Windows
.venv\Scripts\python.exe 03_cue.py
macOS / Linux
.venv/bin/python 03_cue.py
画面で見ることCRUISE(泳ぐ)→ WARNING(前ぶれ)→ REST(休み)が決まった順で切り替わります。前ぶれのカウントが1になった次の更新で、張力と距離が大きく変わり、TURN! と表示されます。離しっぱなしでも、反転そのものは起きます。
  • 右向き矢印で本物の反転が予告される
  • 24〜1の予兆の最後だけ大きく変わる
  • 休みでは張力の上がり方が小さい
つまずきやすいところif phase == "warning" の中だけで+65すると、前ぶれの24フレーム分、24回も足されてしまいます。最後の local=59 のときに1回だけ判定します。また、tick を先に増やすと、見えている前ぶれと判定が1フレームずれます。
少し変えてみよう

tick が35、36、58、59、60、83、84のときの phase と turn_in を予想してみましょう。36は前ぶれ24、59は前ぶれ1、60は休みです。CRUISE_FRAMES だけを60に変えると、前ぶれの長さはそのままで、泳ぐ時間だけを長くできます。

03_cue.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(177行)
03_cue.py / 全文
# MIT License
#
# Copyright (c) 2026 Pyxel 100 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_cue.py
01〜04はメーター観察の練習、05は小魚1匹を釣るゲームです。
日本語フォント素材は使わず、標準フォントと図形だけで描きます。
"""

WIDTH, HEIGHT = 280, 210
FPS = 30
CRUISE_FRAMES = 36
WARNING_FRAMES = 24
REST_FRAMES = 24
CYCLE_FRAMES = CRUISE_FRAMES + WARNING_FRAMES + REST_FRAMES
TURN_FRAME = CRUISE_FRAMES + WARNING_FRAMES - 1  # 0始まりなので59


class Game:
    def __init__(self):
        self.distance = 70.0
        self.held = False
        self.reeled = 0
        self.released = 0
        self.tension = 20.0
        self.tick = 0
        self.feedback = ""
        self.feedback_ticks = 0

    @property
    def local(self):
        return self.tick % CYCLE_FRAMES

    @property
    def phase(self):
        if self.local < CRUISE_FRAMES:
            return "cruise"
        if self.local < CRUISE_FRAMES + WARNING_FRAMES:
            return "warning"
        return "rest"

    @property
    def cue(self):
        # この教材にはフェイントがない。予兆は全て本物の外向き矢印。
        return "out" if self.phase == "warning" else ""

    @property
    def turn_in(self):
        # 次のstepを含む残り回数。予兆開始で24、判定直前で1。
        return TURN_FRAME - self.local + 1 if self.phase == "warning" else 0

    def step(self, held=False):
        self.held = held
        # btnでは長押しを読む。離したフレームで反対のカウンターを0にする。
        self.reeled = self.reeled + 1 if held else 0
        self.released = 0 if held else self.released + 1
        self.feedback_ticks = max(0, self.feedback_ticks - 1)
        if held:
            self.distance -= 0.42
            self.tension += 0.20 if self.phase == "rest" else 0.60
        else:
            self.distance += 0.45
            self.tension = max(0.0, self.tension - 1.30)

        # 予兆の最後だけ判定する。24フレーム全てに加算しない。
        if self.local == TURN_FRAME:
            self.tension += 65 if held else 16
            self.distance += 8
            self.feedback = "TURN! HELD +65" if held else "TURN! RELEASED +16"
            self.feedback_ticks = 35

        # 01〜04は練習用。端に来ても終了せずメーターを範囲内に止める。
        self.distance = max(0.0, min(100.0, self.distance))
        self.tension = min(100.0, self.tension)
        self.tick += 1


def main(headless=False, screenshot=None):
    # importしてGameを試すだけならPyxelは不要。描画のときだけ読み込む。
    import pyxel
    from pathlib import Path

    # Pyxel初期化より前に、呼び出した場所からの保存先を確定する。
    if screenshot:
        screenshot = str(Path(screenshot).expanduser().resolve())
    game = Game()
    frames = 0
    screenshot_saved = False

    def update():
        nonlocal game, frames
        # 12回の更新と12回の描画の後、次の更新で終了する。
        if headless and frames >= 12:
            pyxel.quit()
            return
        frames += 1
        if pyxel.btnp(pyxel.KEY_R):
            game = Game()
            return
        game.step(pyxel.btn(pyxel.KEY_SPACE))

    def bar(y, label, value, color):
        shown = max(0.0, min(100.0, value))
        pyxel.text(10, y, label, 7)
        pyxel.rect(64, y, 164, 7, 0)
        pyxel.rect(65, y + 1, int(shown * 1.62), 5, color)
        pyxel.text(237, y, f"{shown:5.1f}", color)

    def draw():
        nonlocal screenshot_saved
        pyxel.cls(1)
        pyxel.text(10, 8, "THE LAST FISH / STEP 3", 10)
        pyxel.text(10, 19, "PRACTICE: METERS DO NOT END THE GAME", 6)
        pyxel.text(10, 35, "SPACE: " + ("HELD / REEL" if game.held else "RELEASED / SLACK"), 7)
        pyxel.text(10, 46, f"HELD {game.reeled:3}f   RELEASED {game.released:3}f", 6)
        bar(60, "DIST", game.distance, 12)
        bar(74, "TENSION", game.tension, 8 if game.tension >= 75 else 10)
        # 距離が0なら岸側、100なら画面右。状態の計算はここでしない。
        x = 54 + max(0.0, min(100.0, game.distance)) * 1.90
        pyxel.rect(0, 134, WIDTH, 38, 5)
        pyxel.line(0, 140, WIDTH - 1, 140, 6)
        pyxel.rect(0, 131, 38, 9, 4)
        pyxel.line(24, 133, 42, 122, 9)
        pyxel.line(42, 122, x, 151, 7)
        pyxel.elli(x - 9, 146, 18, 10, 10)
        pyxel.tri(x + 8, 151, x + 16, 145, x + 16, 157, 10)
        pyxel.pset(x - 5, 149, 0)
        pyxel.text(10, 92, f"PHASE {game.phase.upper():7}  CYCLE {game.local:02}/{CYCLE_FRAMES}", 6)
        if game.cue:
            pyxel.line(15, 111, 37, 111, 10)
            pyxel.tri(43, 111, 34, 106, 34, 116, 10)
            pyxel.text(51, 103, f"REAL TURN IN {game.turn_in:02} FRAMES", 10)
            pyxel.text(51, 115, "HELD: +65  RELEASED: +16 TENSION", 7)
        elif game.feedback_ticks:
            pyxel.text(10, 109, game.feedback, 10)
        pyxel.text(10, 178, "R RESTARTS THE SAME PRACTICE", 6)
        pyxel.text(10, 196, "SPACE: REEL   R: RESET   ESC: QUIT", 6)
        # 12回目に描いた本物のPyxel画面を保存。自動プレイ画像ではない。
        if screenshot and frames >= 12 and not screenshot_saved:
            pyxel.screenshot(screenshot, scale=2)
            screenshot_saved = True

    pyxel.init(WIDTH, HEIGHT, title="The Last Fish - 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="Render 12 real Pyxel frames then quit; not a full playtest")
    parser.add_argument("--screenshot", metavar="PATH", help="Save the 12th rendered frame as a PNG")
    args = parser.parse_args()
    main(headless=args.headless_smoke, screenshot=args.screenshot)
STEP 04 / 05

短く緩めて魚を疲れさせる

ボタンを続けて離していた時間を、成功の条件に使う。

反転を判定するときに、そのフレームも含めて3〜18フレーム続けて離していれば成功です。成功すると距離が24縮まり、魚の疲れ STAMINA が1増えます。疲れは最大3です。巻く速さは「0.42 + 0.12 × 疲れ」なので、成功するほど魚を寄せやすくなります。離していた時間が2フレーム以下なら遅すぎ、19フレーム以上なら早く離しすぎで、どちらも失敗です。失敗すると、03と同じ反転になります。まだ練習なので、距離が0になっても終わりません。

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

04_release.py / 注目する部分
if MIN_RELEASE <= self.released <= MAX_RELEASE:
    self.distance -= 24
    self.stamina = min(3, self.stamina + 1)
    self.good_turns += 1
    self.feedback = "GOOD TURN! DISTANCE -24"

実行して確かめる

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

Windows
.venv\Scripts\python.exe 04_release.py
macOS / Linux
.venv/bin/python 04_release.py
画面で見ること前ぶれの残りが10くらいのときに離し、反転したあとで巻き直すと GOOD TURN! と表示されます。STAMINA と GOOD TURNS が1ずつ増え、距離が大きく縮みます。最初から離したままでは成功しません。
  • 予兆の最後を3〜18フレーム離すと成功する
  • 最初から離しっぱなしでは成功しない
  • 疲れは3まで、成功回数はその先も増える
つまずきやすいところ判定したあとで released を増やすと、3フレーム目が2として判定されてしまいます。このコードは、先に離している時間を数えてから判定します。また、疲れと成功回数は別の数値で、3で止まるのは疲れだけです。
少し変えてみよう

test_stages.py の duration を1、2、3、18、19、24にしたときの結果を読んでみましょう。成功するのは3と18だけです。MAX_RELEASE だけを12に変えると、3は成功のまま、18は失敗になります。先に予想してから、テストを書き換えて試してみましょう。

04_release.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(187行)
04_release.py / 全文
# MIT License
#
# Copyright (c) 2026 Pyxel 100 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_release.py
01〜04はメーター観察の練習、05は小魚1匹を釣るゲームです。
日本語フォント素材は使わず、標準フォントと図形だけで描きます。
"""

WIDTH, HEIGHT = 280, 210
FPS = 30
CRUISE_FRAMES = 36
WARNING_FRAMES = 24
REST_FRAMES = 24
CYCLE_FRAMES = CRUISE_FRAMES + WARNING_FRAMES + REST_FRAMES
TURN_FRAME = CRUISE_FRAMES + WARNING_FRAMES - 1  # 0始まりなので59
MIN_RELEASE = 3
MAX_RELEASE = 18


class Game:
    def __init__(self):
        self.distance = 70.0
        self.held = False
        self.reeled = 0
        self.released = 0
        self.tension = 20.0
        self.tick = 0
        self.feedback = ""
        self.feedback_ticks = 0
        self.stamina = 0
        self.good_turns = 0

    @property
    def local(self):
        return self.tick % CYCLE_FRAMES

    @property
    def phase(self):
        if self.local < CRUISE_FRAMES:
            return "cruise"
        if self.local < CRUISE_FRAMES + WARNING_FRAMES:
            return "warning"
        return "rest"

    @property
    def cue(self):
        # この教材にはフェイントがない。予兆は全て本物の外向き矢印。
        return "out" if self.phase == "warning" else ""

    @property
    def turn_in(self):
        # 次のstepを含む残り回数。予兆開始で24、判定直前で1。
        return TURN_FRAME - self.local + 1 if self.phase == "warning" else 0

    def step(self, held=False):
        self.held = held
        # btnでは長押しを読む。離したフレームで反対のカウンターを0にする。
        self.reeled = self.reeled + 1 if held else 0
        self.released = 0 if held else self.released + 1
        self.feedback_ticks = max(0, self.feedback_ticks - 1)
        if held:
            self.distance -= 0.42 + 0.12 * self.stamina
            self.tension += 0.20 if self.phase == "rest" else 0.60
        else:
            self.distance += 0.45
            self.tension = max(0.0, self.tension - 1.30)

        if self.local == TURN_FRAME:
            # 先に今回の入力を数えたので、releasedには今回も含まれる。
            if MIN_RELEASE <= self.released <= MAX_RELEASE:
                self.distance -= 24
                self.stamina = min(3, self.stamina + 1)
                self.good_turns += 1
                self.feedback = "GOOD TURN! DISTANCE -24"
            else:
                self.tension += 65 if held else 16
                self.distance += 8
                self.feedback = "MISSED TURN!"
            self.feedback_ticks = 35

        # 01〜04は練習用。端に来ても終了せずメーターを範囲内に止める。
        self.distance = max(0.0, min(100.0, self.distance))
        self.tension = min(100.0, self.tension)
        self.tick += 1


def main(headless=False, screenshot=None):
    # importしてGameを試すだけならPyxelは不要。描画のときだけ読み込む。
    import pyxel
    from pathlib import Path

    # Pyxel初期化より前に、呼び出した場所からの保存先を確定する。
    if screenshot:
        screenshot = str(Path(screenshot).expanduser().resolve())
    game = Game()
    frames = 0
    screenshot_saved = False

    def update():
        nonlocal game, frames
        # 12回の更新と12回の描画の後、次の更新で終了する。
        if headless and frames >= 12:
            pyxel.quit()
            return
        frames += 1
        if pyxel.btnp(pyxel.KEY_R):
            game = Game()
            return
        game.step(pyxel.btn(pyxel.KEY_SPACE))

    def bar(y, label, value, color):
        shown = max(0.0, min(100.0, value))
        pyxel.text(10, y, label, 7)
        pyxel.rect(64, y, 164, 7, 0)
        pyxel.rect(65, y + 1, int(shown * 1.62), 5, color)
        pyxel.text(237, y, f"{shown:5.1f}", color)

    def draw():
        nonlocal screenshot_saved
        pyxel.cls(1)
        pyxel.text(10, 8, "THE LAST FISH / STEP 4", 10)
        pyxel.text(10, 19, "PRACTICE: METERS DO NOT END THE GAME", 6)
        pyxel.text(10, 35, "SPACE: " + ("HELD / REEL" if game.held else "RELEASED / SLACK"), 7)
        pyxel.text(10, 46, f"HELD {game.reeled:3}f   RELEASED {game.released:3}f", 6)
        bar(60, "DIST", game.distance, 12)
        bar(74, "TENSION", game.tension, 8 if game.tension >= 75 else 10)
        # 距離が0なら岸側、100なら画面右。状態の計算はここでしない。
        x = 54 + max(0.0, min(100.0, game.distance)) * 1.90
        pyxel.rect(0, 134, WIDTH, 38, 5)
        pyxel.line(0, 140, WIDTH - 1, 140, 6)
        pyxel.rect(0, 131, 38, 9, 4)
        pyxel.line(24, 133, 42, 122, 9)
        pyxel.line(42, 122, x, 151, 7)
        pyxel.elli(x - 9, 146, 18, 10, 10)
        pyxel.tri(x + 8, 151, x + 16, 145, x + 16, 157, 10)
        pyxel.pset(x - 5, 149, 0)
        pyxel.text(10, 92, f"PHASE {game.phase.upper():7}  CYCLE {game.local:02}/{CYCLE_FRAMES}", 6)
        if game.cue:
            pyxel.line(15, 111, 37, 111, 10)
            pyxel.tri(43, 111, 34, 106, 34, 116, 10)
            pyxel.text(51, 103, f"REAL TURN IN {game.turn_in:02} FRAMES", 10)
            pyxel.text(51, 115, "RELEASE FOR 3-18f AT THE TURN", 7)
        elif game.feedback_ticks:
            pyxel.text(10, 109, game.feedback, 10)
        pyxel.text(10, 178, f"STAMINA {game.stamina}/3  GOOD TURNS {game.good_turns}", 14)
        pyxel.text(10, 196, "SPACE: REEL   R: RESET   ESC: QUIT", 6)
        # 12回目に描いた本物のPyxel画面を保存。自動プレイ画像ではない。
        if screenshot and frames >= 12 and not screenshot_saved:
            pyxel.screenshot(screenshot, scale=2)
            screenshot_saved = True

    pyxel.init(WIDTH, HEIGHT, title="The Last Fish - 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="Render 12 real Pyxel frames then quit; not a full playtest")
    parser.add_argument("--screenshot", metavar="PATH", help="Save the 12th rendered frame as a PNG")
    args = parser.parse_args()
    main(headless=args.headless_smoke, screenshot=args.screenshot)
STEP 05 / 05

巻き始めと結果・再挑戦

動き出すまでの待ち時間と、止まる状態をはっきり書く。

巻き始めの1〜5フレームは、糸のたるみを取るため距離が減らず、6フレーム目から巻けます。張力は、この待ち時間の間も増えます。距離が0以下で釣り上げ、100以上で逃げられ、張力が100以上で糸が切れます。同時に起きたときは、糸が切れるほうを優先します。結果が出たあとと、P で一時停止している間は、step の最初で処理を戻すので、時間・続けて押した数・ごほうびも動きません。R キーで Game を作り直し、同じ魚にもう一度挑戦できます。

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

05_result.py / 注目する部分
# 1〜5フレーム目はたるみ取り。6フレーム目から距離が減る。
if self.reeled > REEL_DELAY:
    self.distance -= 0.42 + 0.12 * self.stamina

実行して確かめる

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

Windows
.venv\Scripts\python.exe 05_result.py
macOS / Linux
.venv/bin/python 05_result.py
画面で見ること最初の5フレームは TAKING UP SLACK(たるみを取っている)と表示されます。押し続けるだけだと最初の反転で LINE CUT(糸が切れた)、離したままだと FISH ESCAPED(逃げられた)になります。前ぶれの残り10から反転まで離し、あとは巻くと CAUGHT!(釣れた)です。結果が出たあとは Space を押しても変わらず、R で最初に戻ります。P で止めている間は、すべてのカウンターが止まります。
  • 巻き始めは5フレーム距離が変わらない
  • 短い解放で釣れ、押しっぱなし・離しっぱなしは負ける
  • 結果・停止中は動かず、Rで全状態が戻る
つまずきやすいところreeled >= 5 にすると、5フレーム目から巻けてしまい、1フレーム早くなります。また、結果の絵を出すだけではゲームは止まりません。step の最初の return と、勝ち負けを決める if / elif の順番も必要です。
少し変えてみよう

step(True) を5回呼んでも距離は70のままで、6回目で69.58になることを確かめましょう。1回離してから巻き直すと、また5回待つことも確かめます。勝ちと負けが同時に起きるテストを読んで、なぜ糸が切れるほうが先なのか説明してみましょう。

05_result.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(217行)
05_result.py / 全文
# MIT License
#
# Copyright (c) 2026 Pyxel 100 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_result.py
01〜04はメーター観察の練習、05は小魚1匹を釣るゲームです。
日本語フォント素材は使わず、標準フォントと図形だけで描きます。
"""

WIDTH, HEIGHT = 280, 210
FPS = 30
CRUISE_FRAMES = 36
WARNING_FRAMES = 24
REST_FRAMES = 24
CYCLE_FRAMES = CRUISE_FRAMES + WARNING_FRAMES + REST_FRAMES
TURN_FRAME = CRUISE_FRAMES + WARNING_FRAMES - 1  # 0始まりなので59
MIN_RELEASE = 3
MAX_RELEASE = 18
REEL_DELAY = 5


class Game:
    def __init__(self):
        self.distance = 70.0
        self.held = False
        self.reeled = 0
        self.released = 0
        self.tension = 20.0
        self.tick = 0
        self.feedback = ""
        self.feedback_ticks = 0
        self.stamina = 0
        self.good_turns = 0
        self.status = "play"
        self.paused = False

    def toggle_pause(self):
        self.paused = not self.paused

    @property
    def local(self):
        return self.tick % CYCLE_FRAMES

    @property
    def phase(self):
        if self.local < CRUISE_FRAMES:
            return "cruise"
        if self.local < CRUISE_FRAMES + WARNING_FRAMES:
            return "warning"
        return "rest"

    @property
    def cue(self):
        # この教材にはフェイントがない。予兆は全て本物の外向き矢印。
        return "out" if self.phase == "warning" else ""

    @property
    def turn_in(self):
        # 次のstepを含む残り回数。予兆開始で24、判定直前で1。
        return TURN_FRAME - self.local + 1 if self.phase == "warning" else 0

    def step(self, held=False):
        if self.paused or self.status != "play":
            return
        self.held = held
        # btnでは長押しを読む。離したフレームで反対のカウンターを0にする。
        self.reeled = self.reeled + 1 if held else 0
        self.released = 0 if held else self.released + 1
        self.feedback_ticks = max(0, self.feedback_ticks - 1)
        if held:
            # 1〜5フレーム目はたるみ取り。6フレーム目から距離が減る。
            if self.reeled > REEL_DELAY:
                self.distance -= 0.42 + 0.12 * self.stamina
            self.tension += 0.20 if self.phase == "rest" else 0.60
        else:
            self.distance += 0.45
            self.tension = max(0.0, self.tension - 1.30)

        if self.local == TURN_FRAME:
            # 先に今回の入力を数えたので、releasedには今回も含まれる。
            if MIN_RELEASE <= self.released <= MAX_RELEASE:
                self.distance -= 24
                self.stamina = min(3, self.stamina + 1)
                self.good_turns += 1
                self.feedback = "GOOD TURN! DISTANCE -24"
            else:
                self.tension += 65 if held else 16
                self.distance += 8
                self.feedback = "MISSED TURN!"
            self.feedback_ticks = 35

        # 同時に到達したら、糸切れ > 逃走 > 釣り上げの順で決める。
        if self.tension >= 100:
            self.status = "cut"
        elif self.distance >= 100:
            self.status = "escape"
        elif self.distance <= 0:
            self.status = "caught"
        self.tick += 1


def main(headless=False, screenshot=None):
    # importしてGameを試すだけならPyxelは不要。描画のときだけ読み込む。
    import pyxel
    from pathlib import Path

    # Pyxel初期化より前に、呼び出した場所からの保存先を確定する。
    if screenshot:
        screenshot = str(Path(screenshot).expanduser().resolve())
    game = Game()
    frames = 0
    screenshot_saved = False

    def update():
        nonlocal game, frames
        # 12回の更新と12回の描画の後、次の更新で終了する。
        if headless and frames >= 12:
            pyxel.quit()
            return
        frames += 1
        if pyxel.btnp(pyxel.KEY_R):
            game = Game()
            return
        if pyxel.btnp(pyxel.KEY_P):
            game.toggle_pause()
            return
        game.step(pyxel.btn(pyxel.KEY_SPACE))

    def bar(y, label, value, color):
        shown = max(0.0, min(100.0, value))
        pyxel.text(10, y, label, 7)
        pyxel.rect(64, y, 164, 7, 0)
        pyxel.rect(65, y + 1, int(shown * 1.62), 5, color)
        pyxel.text(237, y, f"{shown:5.1f}", color)

    def draw():
        nonlocal screenshot_saved
        pyxel.cls(1)
        pyxel.text(10, 8, "THE LAST FISH / STEP 5", 10)
        pyxel.text(10, 19, "CATCH ONE FISH. DISTANCE 0 = WIN", 6)
        pyxel.text(10, 35, "SPACE: " + ("HELD / REEL" if game.held else "RELEASED / SLACK"), 7)
        pyxel.text(10, 46, f"HELD {game.reeled:3}f   RELEASED {game.released:3}f", 6)
        bar(60, "DIST", game.distance, 12)
        bar(74, "TENSION", game.tension, 8 if game.tension >= 75 else 10)
        # 距離が0なら岸側、100なら画面右。状態の計算はここでしない。
        x = 54 + max(0.0, min(100.0, game.distance)) * 1.90
        pyxel.rect(0, 134, WIDTH, 38, 5)
        pyxel.line(0, 140, WIDTH - 1, 140, 6)
        pyxel.rect(0, 131, 38, 9, 4)
        pyxel.line(24, 133, 42, 122, 9)
        pyxel.line(42, 122, x, 151, 7)
        pyxel.elli(x - 9, 146, 18, 10, 10)
        pyxel.tri(x + 8, 151, x + 16, 145, x + 16, 157, 10)
        pyxel.pset(x - 5, 149, 0)
        pyxel.text(10, 92, f"PHASE {game.phase.upper():7}  CYCLE {game.local:02}/{CYCLE_FRAMES}", 6)
        if game.cue:
            pyxel.line(15, 111, 37, 111, 10)
            pyxel.tri(43, 111, 34, 106, 34, 116, 10)
            pyxel.text(51, 103, f"REAL TURN IN {game.turn_in:02} FRAMES", 10)
            pyxel.text(51, 115, "RELEASE FOR 3-18f AT THE TURN", 7)
        elif game.feedback_ticks:
            pyxel.text(10, 109, game.feedback, 10)
        pyxel.text(10, 178, f"STAMINA {game.stamina}/3  GOOD TURNS {game.good_turns}", 14)
        if 0 < game.reeled <= REEL_DELAY:
            pyxel.text(147, 35, "TAKING UP SLACK", 10)
        if game.status != "play":
            pyxel.rect(8, 89, 264, 54, 0)
            pyxel.rectb(8, 89, 264, 54, 10)
            label = {"caught": "CAUGHT!", "cut": "LINE CUT!", "escape": "FISH ESCAPED!"}[game.status]
            pyxel.text(55, 105, label, 10 if game.status == "caught" else 8)
            pyxel.text(55, 120, "R: TRY THE SAME FISH AGAIN", 7)
        if game.paused:
            pyxel.rect(64, 97, 152, 42, 0)
            pyxel.rectb(64, 97, 152, 42, 6)
            pyxel.text(78, 108, "PAUSED / P: RESUME", 10)
            pyxel.text(78, 121, "INPUT COUNTERS STOP TOO", 7)
        pyxel.text(10, 196, "SPACE: REEL  R: RETRY  P: PAUSE  ESC: QUIT", 6)
        # 12回目に描いた本物のPyxel画面を保存。自動プレイ画像ではない。
        if screenshot and frames >= 12 and not screenshot_saved:
            pyxel.screenshot(screenshot, scale=2)
            screenshot_saved = True

    pyxel.init(WIDTH, HEIGHT, title="The Last Fish - 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="Render 12 real Pyxel frames then quit; not a full playtest")
    parser.add_argument("--screenshot", metavar="PATH", help="Save the 12th rendered frame as a PNG")
    args = parser.parse_args()
    main(headless=args.headless_smoke, screenshot=args.screenshot)
AFTER STEP 05

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

元のゲームでは、全部で5匹の魚を釣ります。張力が100で糸が切れ、距離が100で逃げられ、0で釣り上げです。本物の反転のときに3〜18フレーム続けて緩めると、距離が-24、疲れが+1になります。巻き始めの5フレームは距離が減りません。フェイント(見せかけの前ぶれ)のときは巻きます。連続ボーナスを確定するには、次の1匹分の枠を使います。

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

比べる点5ステップの教材元の完成版
変更点 1最後の段階は small・variant=0 の小さな魚1匹だけ。5匹分の魚の群れ・魚の種類・seed は省いています
変更点 2前ぶれはすべて本物。フェイントの見分け方は、元のゲームを読む発展課題です
変更点 3連続ボーナス・確定・魚を見送る選択は省いています
変更点 401〜04は0〜100のメーターの練習で、勝ち負けと巻き始めの待ち時間はありません。05で勝ち負けと5フレームの待ちを足します
変更点 5元のゲームの日本語フォント・タイトル画面・記録・録画は省いています。教材は英字と図形だけです
変更点 6Escで終了。Pの一時停止とRのやり直しは05で作ります

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

  1. model.py の Fish.phase・Fish.event・Fish.cue(魚の動きの段階と前ぶれ)
  2. model.py の Fish.step(魚の1フレーム分の更新)
  3. model.py の Voyage.start・Voyage.step・Voyage.bank・Voyage.advance(釣り全体の流れ)
  4. app.py の App.update・App.commit(入力と画面の更新)
  5. tests/test_model.py の Rules(ルールのテスト)
自分のゲームに変えるなら

最初は、数字かルールを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の実際のパソコンでの操作や、音が出るかどうかはまだ確認していません。人がすべての操作を試したわけでもありません。ブラウザー版の確認範囲は確認状況をご覧ください。

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