パズルLESSON 10因果比較 × 小さなシミュレーション原作089

未来からの修理票の作り方

ひとつだけ変えて、同じ未来をもう一度試す。
小さな工場を「6刻」(6回分の時間)動かして、うまくいかない原因を観察します。記録(ログ)を残し、変える場所を1つにしぼって比べることで、ルールを確かめるゲームを作ります。

完成版の遊び方4台の機械がある小さな工場を6刻のあいだ観察し、1か所だけを変えて、同じ未来をもう一度動かします。

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

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

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

完成版の主な操作:Spaceで1刻進める(Pで自動)、矢印キーで変える場所を選ぶ、Zで詳しく見る、Enterで変更して再実行、Xで1手戻す、Rで何も変えずに確認

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

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

うまく表示されないとき

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

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

変えるところを1つにしぼって比べる。

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

1. 決まった順番で進む「1刻」の処理
2. 「終わる条件」と「成功の条件」を分ける
3. 状態の移り変わりを、時間順の記録(ログ)に残す
それだけで動く小さなプログラムを、5つのステップで少しずつ大きくしていきます。
必要な環境
Python 3.11以上 / Pyxel 2.9.9
知っておくとよいこと
変数・if・関数の基本
教材に入っているもの
ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
完成した教材の操作
Space:1刻進める / 左右:変える場所を選ぶ / Enter:変えて再実行 / X:1手戻す / R:最初に戻す
難易度の目安
初級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_water.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

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

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

STEP 01 / 05

水を足してから、品を作る

決まった順番で進む「1刻」の処理。

State は、水の量・完成した品の数・刻(経過した回数)を持つ、書き換えられないデータです。Space を押すたびに、まずポンプで水を1足し、上限の4を超えないようにします。そのあと、水が2あればそれを使って品を1つ作ります。水が足りなければ、水を減らさずに待ちます。時間は実際の時計ではなく、操作した回数で進みます。

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

01_water.py / 注目する部分
supplied = min(capacity, state.water + pump)
produced = int(supplied >= cost)
water = supplied - cost if produced else supplied

実行して確かめる

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

Windows
.venv\Scripts\python.exe 01_water.py
macOS / Linux
.venv/bin/python 01_water.py
画面で見ること最初の水は2です。Space で1刻進めると、水1・完成1になります。もう1回で水0・完成2、3刻目は水1・完成2のままです。
  • 水を足してから、品を作る
  • 水が足りなければ使わない
  • R キーで同じ最初の状態に戻る
つまずきやすいところ品を作る処理を先に書くと、同じ刻に足した水を使えず、元のゲームとは違う工場になってしまいます。処理の順番と結果を、コードと見比べて確かめましょう。
少し変えてみよう

水0から始めたとき、最初の1刻で品を作れるか予想してみましょう。次に、最初の水の量だけを1に変えて比べてみましょう。

01_water.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(89行)
01_water.py / 全文
# MIT License
# 
# Copyright (c) 2026
# 
# 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.
# 
# Third-party exception: bitmap glyph data derived from Noto Sans CJK is licensed
# under the SIL Open Font License 1.1. See assets/NOTO-LICENSE.txt.

"""未来からの修理票・教材1/5。架空の工場Aの仕組みを段階化。
実行: python 01_water.py
"""
from dataclasses import dataclass

@dataclass(frozen=True)
class State:
    water: int = 2
    output: int = 0
    tick: int = 0


def step(state):
    pump, capacity, cost = 1, 4, 2
    # 補給を先に行う。同じ刻の補給を、その刻の生産で使える。
    supplied = min(capacity, state.water + pump)
    produced = int(supplied >= cost)
    water = supplied - cost if produced else supplied
    return State(water=water, output=state.output + produced, tick=state.tick + 1)


def main(headless=False, screenshot=None):
    import pyxel
    state = State()

    def update():
        nonlocal state
        if headless and pyxel.frame_count >= 12:
            if screenshot:
                pyxel.screenshot(screenshot, scale=2)
            pyxel.quit()
            return
        if pyxel.btnp(pyxel.KEY_R):
            state = State()
            return
        current = state
        if pyxel.btnp(pyxel.KEY_SPACE):
            state = step(state)

    def draw():
        current = state
        pyxel.cls(1)
        pyxel.text(10, 10, "FUTURE REPAIR / STEP 1", 10)
        pyxel.text(10, 25, f"TICK {current.tick}   WATER {current.water}   OUTPUT {current.output}", 7)
        pyxel.rect(10, 44, 130, 14, 5)
        pyxel.rect(10, 44, min(current.water, 6) * 20, 14, 12)
        pyxel.text(155, 48, "WATER", 6)
        pyxel.text(10, 66, "PUMP +1  CAP 4  COST 2", 11)
        pyxel.text(10, 93, "FILL FIRST, THEN TRY TO MAKE ONE.", 6)
        pyxel.text(10, 107, "NOT ENOUGH WATER = WAIT", 6)
        pyxel.text(10, 121, "WAIT DOES NOT USE WATER", 6)
        pyxel.text(10, 248, "SPACE: ONE TICK  R: RESET  ESC: QUIT", 6)

    pyxel.init(310, 280, title="Future Repair - Stage 1", fps=30, 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

六刻で結果を止める

「終わる条件」と「成功の条件」を分ける。

1回の試運転は6刻です。done は6刻まで進んだか、success は終わったときに目標の5個以上できたかを表します。何も変えないと、4個で終わります。この段階ではまだ変更はできず、動かして問題を観察する練習です。

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

02_six_ticks.py / 注目する部分
def done(self):
    return self.tick >= TICKS

実行して確かめる

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

Windows
.venv\Scripts\python.exe 02_six_ticks.py
macOS / Linux
.venv/bin/python 02_six_ticks.py
画面で見ることSpace を6回押すと、OUTPUT BELOW TARGET(目標に届かない)と RESULT 4 / TARGET 5 が表示されます。7回目を押しても値は変わりません。
  • 6刻で止まる
  • 何も変えないと4個
  • 失敗してもやり直せる
つまずきやすいところ目標に届くまで動かし続けると、「同じ6刻で比べる」という条件がくずれてしまいます。成功していなくても、決めた時刻で止めます。
少し変えてみよう

TARGET だけを4に変えて、機械が作る数は変わらず、結果の評価だけが変わることを確かめましょう。確かめたら元に戻して、次へ進んでください。

02_six_ticks.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(106行)
02_six_ticks.py / 全文
# MIT License
# 
# Copyright (c) 2026
# 
# 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.
# 
# Third-party exception: bitmap glyph data derived from Noto Sans CJK is licensed
# under the SIL Open Font License 1.1. See assets/NOTO-LICENSE.txt.

"""未来からの修理票・教材2/5。架空の工場Aの仕組みを段階化。
実行: python 02_six_ticks.py
"""
from dataclasses import dataclass

TICKS = 6
TARGET = 5

@dataclass(frozen=True)
class State:
    water: int = 2
    output: int = 0
    tick: int = 0

    @property
    def done(self):
        return self.tick >= TICKS

    @property
    def success(self):
        return self.done and self.output >= TARGET


def step(state):
    if state.done:
        return state
    pump, capacity, cost = 1, 4, 2
    # 補給を先に行う。同じ刻の補給を、その刻の生産で使える。
    supplied = min(capacity, state.water + pump)
    produced = int(supplied >= cost)
    water = supplied - cost if produced else supplied
    return State(water=water, output=state.output + produced, tick=state.tick + 1)


def main(headless=False, screenshot=None):
    import pyxel
    state = State()

    def update():
        nonlocal state
        if headless and pyxel.frame_count >= 12:
            if screenshot:
                pyxel.screenshot(screenshot, scale=2)
            pyxel.quit()
            return
        if pyxel.btnp(pyxel.KEY_R):
            state = State()
            return
        current = state
        if pyxel.btnp(pyxel.KEY_SPACE):
            state = step(state)

    def draw():
        current = state
        pyxel.cls(1)
        pyxel.text(10, 10, "FUTURE REPAIR / STEP 2", 10)
        pyxel.text(10, 25, f"TICK {current.tick}   WATER {current.water}   OUTPUT {current.output}", 7)
        pyxel.rect(10, 44, 130, 14, 5)
        pyxel.rect(10, 44, min(current.water, 6) * 20, 14, 12)
        pyxel.text(155, 48, "WATER", 6)
        pyxel.text(10, 66, "PUMP +1  CAP 4  COST 2", 11)
        pyxel.text(10, 93, "FILL FIRST, THEN TRY TO MAKE ONE.", 6)
        pyxel.text(10, 107, "NOT ENOUGH WATER = WAIT", 6)
        pyxel.text(10, 121, "WAIT DOES NOT USE WATER", 6)
        if current.done:
            pyxel.text(10, 181, "GOAL!" if current.success else "OUTPUT BELOW TARGET", 10)
            pyxel.text(10, 193, f"RESULT {current.output} / TARGET {TARGET}", 7)
            pyxel.text(10, 209, "R: OBSERVE THE SAME RUN AGAIN", 6)
        pyxel.text(10, 248, "SPACE: ONE TICK  R: RESET  ESC: QUIT", 6)

    pyxel.init(310, 280, title="Future Repair - Stage 2", fps=30, 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

いつ止まったかを残す

状態の移り変わりを、時間順の記録(ログ)に残す。

それぞれの刻について、「始めの水」「足したあとの水」「使ったあとの水」「品を作ったか」を1行(entry)にまとめます。timeline は、タプルを1つ追加した新しい値として保存します。完成した数だけでは見えない、「3刻目と5刻目は待っていた」ということが、表で確かめられます。

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

03_timeline.py / 注目する部分
entry = (state.tick + 1, state.water, supplied, water, produced)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 03_timeline.py
macOS / Linux
.venv/bin/python 03_timeline.py
画面で見ること6行のログが並びます。3行目は BEFORE 0、FILLED 1、LEFT 1、MADE 0 です。5行目も品を作っていませんが、水は減っていません。
  • 6刻分の行を順番に読める
  • 待っていた刻がわかる
  • 記録してもルールは変わらない
つまずきやすいところログに、中身を書き換えられる同じリストを何度も入れると、あとから過去の行まで変わってしまうことがあります。値の組を毎回新しく作って保存します。
少し変えてみよう

3刻目に品を作れない原因を、「足す水の量」「タンクの大きさ」「使う水の量」のどれかと結びつけて説明してみましょう。まだ2つを同時には変えません。

03_timeline.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(110行)
03_timeline.py / 全文
# MIT License
# 
# Copyright (c) 2026
# 
# 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.
# 
# Third-party exception: bitmap glyph data derived from Noto Sans CJK is licensed
# under the SIL Open Font License 1.1. See assets/NOTO-LICENSE.txt.

"""未来からの修理票・教材3/5。架空の工場Aの仕組みを段階化。
実行: python 03_timeline.py
"""
from dataclasses import dataclass

TICKS = 6
TARGET = 5

@dataclass(frozen=True)
class State:
    water: int = 2
    output: int = 0
    tick: int = 0
    timeline: tuple = ()

    @property
    def done(self):
        return self.tick >= TICKS

    @property
    def success(self):
        return self.done and self.output >= TARGET


def step(state):
    if state.done:
        return state
    pump, capacity, cost = 1, 4, 2
    # 補給を先に行う。同じ刻の補給を、その刻の生産で使える。
    supplied = min(capacity, state.water + pump)
    produced = int(supplied >= cost)
    water = supplied - cost if produced else supplied
    entry = (state.tick + 1, state.water, supplied, water, produced)
    return State(water=water, output=state.output + produced, tick=state.tick + 1,
                 timeline=state.timeline + (entry,))


def main(headless=False, screenshot=None):
    import pyxel
    state = State()

    def update():
        nonlocal state
        if headless and pyxel.frame_count >= 12:
            if screenshot:
                pyxel.screenshot(screenshot, scale=2)
            pyxel.quit()
            return
        if pyxel.btnp(pyxel.KEY_R):
            state = State()
            return
        current = state
        if pyxel.btnp(pyxel.KEY_SPACE):
            state = step(state)

    def draw():
        current = state
        pyxel.cls(1)
        pyxel.text(10, 10, "FUTURE REPAIR / STEP 3", 10)
        pyxel.text(10, 25, f"TICK {current.tick}   WATER {current.water}   OUTPUT {current.output}", 7)
        pyxel.rect(10, 44, 130, 14, 5)
        pyxel.rect(10, 44, min(current.water, 6) * 20, 14, 12)
        pyxel.text(155, 48, "WATER", 6)
        pyxel.text(10, 66, "PUMP +1  CAP 4  COST 2", 11)
        pyxel.text(10, 86, "TICK  BEFORE  FILLED  LEFT  MADE", 6)
        for row, (tick, before, filled, left, made) in enumerate(current.timeline):
            pyxel.text(10, 100 + row * 12,
                       f"{tick:4}  {before:6}  {filled:6}  {left:4}  {made:4}", 7 if made else 8)
        if current.done:
            pyxel.text(10, 181, "GOAL!" if current.success else "OUTPUT BELOW TARGET", 10)
            pyxel.text(10, 193, f"RESULT {current.output} / TARGET {TARGET}", 7)
            pyxel.text(10, 209, "R: OBSERVE THE SAME RUN AGAIN", 6)
        pyxel.text(10, 248, "SPACE: ONE TICK  R: RESET  ESC: QUIT", 6)

    pyxel.init(310, 280, title="Future Repair - Stage 3", fps=30, 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

一か所だけ変えて再実行

設定を足し合わせず、1つ選んで置きかえる。

まず、何も変えずに6刻観察します。結果が出たら左右キーで変える場所の候補を選び、Enter で刻0からやり直します。settings は、足す水の量・タンクの大きさ・使う水の量・最初の水の4つの値を返し、どの候補もそのうち1つだけを変えます。前に変えたところを残したまま、さらに変更を積み重ねることはしません。

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

04_one_change.py / 注目する部分
return (2 if choice == 1 else 1, 6 if choice == 2 else 4,
        1 if choice == 3 else 2, 4 if choice == 4 else 2)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 04_one_change.py
macOS / Linux
.venv/bin/python 04_one_change.py
画面で見ること何も変えない結果(4個)のあと、PUMP 2(足す水を2に)を選ぶと、次は6個になります。CAPACITY 6(タンクを6に)だけでは4個のままです。使う水を1にすると6個、最初の水を4にすると4個です。
  • 結果が出たあとだけ候補を選べる
  • 一度に変わるのは1つの値だけ
  • 同じ6刻で比べられる
つまずきやすいところ前回のポンプの変更を残したままタンクも大きくすると、どの変更が効いたのかわからなくなります。new(choice) で、毎回元の条件から作り直します。
少し変えてみよう

「タンクを大きくすれば、たくさん作れる」という予想を、PUMP 2 と CAPACITY 6 のログで比べてみましょう。2つを同時に選べないようにしている理由も説明してみましょう。

04_one_change.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(137行)
04_one_change.py / 全文
# MIT License
# 
# Copyright (c) 2026
# 
# 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.
# 
# Third-party exception: bitmap glyph data derived from Noto Sans CJK is licensed
# under the SIL Open Font License 1.1. See assets/NOTO-LICENSE.txt.

"""未来からの修理票・教材4/5。架空の工場Aの仕組みを段階化。
実行: python 04_one_change.py
"""
from dataclasses import dataclass

TICKS = 6
TARGET = 5

CHOICES = ("NO CHANGE", "PUMP 2", "CAPACITY 6", "COST 1", "INITIAL WATER 4")


def settings(choice):
    if choice not in range(5):
        raise ValueError("choice must be 0..4")
    return (2 if choice == 1 else 1, 6 if choice == 2 else 4,
            1 if choice == 3 else 2, 4 if choice == 4 else 2)


@dataclass(frozen=True)
class State:
    water: int = 2
    output: int = 0
    tick: int = 0
    timeline: tuple = ()
    choice: int = 0

    @property
    def done(self):
        return self.tick >= TICKS

    @property
    def success(self):
        return self.done and self.output >= TARGET


def new(choice=0):
    pump, capacity, cost, initial_water = settings(choice)
    return State(water=initial_water, choice=choice)


def step(state):
    if state.done:
        return state
    pump, capacity, cost, initial_water = settings(state.choice)
    # 補給を先に行う。同じ刻の補給を、その刻の生産で使える。
    supplied = min(capacity, state.water + pump)
    produced = int(supplied >= cost)
    water = supplied - cost if produced else supplied
    entry = (state.tick + 1, state.water, supplied, water, produced)
    return State(water=water, output=state.output + produced, tick=state.tick + 1,
                 timeline=state.timeline + (entry,), choice=state.choice)


def main(headless=False, screenshot=None):
    import pyxel
    state = new()
    selected = 0

    def update():
        nonlocal state, selected
        if headless and pyxel.frame_count >= 12:
            if screenshot:
                pyxel.screenshot(screenshot, scale=2)
            pyxel.quit()
            return
        if pyxel.btnp(pyxel.KEY_R):
            state = new()
            selected = 0
            return
        current = state
        if current.done:
            if pyxel.btnp(pyxel.KEY_LEFT):
                selected = (selected - 1) % len(CHOICES)
            elif pyxel.btnp(pyxel.KEY_RIGHT):
                selected = (selected + 1) % len(CHOICES)
            elif pyxel.btnp(pyxel.KEY_RETURN):
                state = new(selected)
            return
        if pyxel.btnp(pyxel.KEY_SPACE):
            state = step(state)

    def draw():
        current = state
        pyxel.cls(1)
        pyxel.text(10, 10, "FUTURE REPAIR / STEP 4", 10)
        pyxel.text(10, 25, f"TICK {current.tick}   WATER {current.water}   OUTPUT {current.output}", 7)
        pyxel.rect(10, 44, 130, 14, 5)
        pyxel.rect(10, 44, min(current.water, 6) * 20, 14, 12)
        pyxel.text(155, 48, "WATER", 6)
        pyxel.text(10, 66, "ACTIVE: " + CHOICES[current.choice], 11)
        pyxel.text(10, 86, "TICK  BEFORE  FILLED  LEFT  MADE", 6)
        for row, (tick, before, filled, left, made) in enumerate(current.timeline):
            pyxel.text(10, 100 + row * 12,
                       f"{tick:4}  {before:6}  {filled:6}  {left:4}  {made:4}", 7 if made else 8)
        if current.done:
            pyxel.text(10, 181, "GOAL!" if current.success else "OUTPUT BELOW TARGET", 10)
            pyxel.text(10, 193, f"RESULT {current.output} / TARGET {TARGET}", 7)
            pyxel.text(10, 209, "TRY: " + CHOICES[selected], 11)
            pyxel.text(10, 222, "LEFT/RIGHT: CHOOSE  ENTER: RESTART", 6)
        pyxel.text(10, 248, "SPACE: ONE TICK  R: RESET  ESC: QUIT", 6)

    pyxel.init(310, 280, title="Future Repair - Stage 4", fps=30, 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

一刻と一回の変更を戻す

移り変わってきた状態を、履歴に保存する。

Session は、進める前の State を history に追加します。X キーで、刻・水の量・完成した数・設定・ログがまとめて戻ります。変更してやり直した直後の刻0で X を押すと、直前の試運転の結果に戻れるので、別の候補を選び直せます。R キーは何も変えない最初の状態に戻し、履歴も消します。

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

05_undo.py / 注目する部分
def undo(self):
    if self.history:
        self.state = self.history.pop()

実行して確かめる

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

Windows
.venv\Scripts\python.exe 05_undo.py
macOS / Linux
.venv/bin/python 05_undo.py
画面で見ること6刻動かしたあとに PUMP 2 を選んだ直後なら、X で元の結果(4個)に戻れます。試運転の途中で X を押すと、ログも1行消えます。R のあとに X を押しても、前の運転には戻りません。
  • 1刻前に戻る
  • 変更した直後なら、前の結果に戻る
  • R キーで履歴も消える
つまずきやすいところ水だけを戻してログや設定を残すと、その画面がどの条件の結果なのかわからなくなります。1つの State をまるごと戻します。
少し変えてみよう

PUMP 2 で成功したあと、X でその運転の途中に戻り、同じ操作をくり返して、結果が同じになるか確かめましょう。

05_undo.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(167行)
05_undo.py / 全文
# MIT License
# 
# Copyright (c) 2026
# 
# 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.
# 
# Third-party exception: bitmap glyph data derived from Noto Sans CJK is licensed
# under the SIL Open Font License 1.1. See assets/NOTO-LICENSE.txt.

"""未来からの修理票・教材5/5。架空の工場Aの仕組みを段階化。
実行: python 05_undo.py
"""
from dataclasses import dataclass

TICKS = 6
TARGET = 5

CHOICES = ("NO CHANGE", "PUMP 2", "CAPACITY 6", "COST 1", "INITIAL WATER 4")


def settings(choice):
    if choice not in range(5):
        raise ValueError("choice must be 0..4")
    return (2 if choice == 1 else 1, 6 if choice == 2 else 4,
            1 if choice == 3 else 2, 4 if choice == 4 else 2)


@dataclass(frozen=True)
class State:
    water: int = 2
    output: int = 0
    tick: int = 0
    timeline: tuple = ()
    choice: int = 0

    @property
    def done(self):
        return self.tick >= TICKS

    @property
    def success(self):
        return self.done and self.output >= TARGET


def new(choice=0):
    pump, capacity, cost, initial_water = settings(choice)
    return State(water=initial_water, choice=choice)


def step(state):
    if state.done:
        return state
    pump, capacity, cost, initial_water = settings(state.choice)
    # 補給を先に行う。同じ刻の補給を、その刻の生産で使える。
    supplied = min(capacity, state.water + pump)
    produced = int(supplied >= cost)
    water = supplied - cost if produced else supplied
    entry = (state.tick + 1, state.water, supplied, water, produced)
    return State(water=water, output=state.output + produced, tick=state.tick + 1,
                 timeline=state.timeline + (entry,), choice=state.choice)


class Session:
    def __init__(self):
        self.state = new()
        self.history = []

    def advance(self):
        result = step(self.state)
        if result != self.state:
            self.history.append(self.state)
            self.state = result

    def change(self, choice):
        if self.state.done and choice in range(5):
            self.history.append(self.state)
            self.state = new(choice)  # 介入を積み増さず、毎回ひとつへ置き換える。

    def undo(self):
        if self.history:
            self.state = self.history.pop()

    def restart(self):
        self.state = new()
        self.history.clear()


def main(headless=False, screenshot=None):
    import pyxel
    session = Session()
    selected = 0

    def update():
        nonlocal selected
        if headless and pyxel.frame_count >= 12:
            if screenshot:
                pyxel.screenshot(screenshot, scale=2)
            pyxel.quit()
            return
        if pyxel.btnp(pyxel.KEY_R):
            session.restart()
            selected = 0
            return
        if pyxel.btnp(pyxel.KEY_X):
            session.undo()
            selected = session.state.choice
            return
        current = session.state
        if current.done:
            if pyxel.btnp(pyxel.KEY_LEFT):
                selected = (selected - 1) % len(CHOICES)
            elif pyxel.btnp(pyxel.KEY_RIGHT):
                selected = (selected + 1) % len(CHOICES)
            elif pyxel.btnp(pyxel.KEY_RETURN):
                session.change(selected)
            return
        if pyxel.btnp(pyxel.KEY_SPACE):
            session.advance()

    def draw():
        current = session.state
        pyxel.cls(1)
        pyxel.text(10, 10, "FUTURE REPAIR / STEP 5", 10)
        pyxel.text(10, 25, f"TICK {current.tick}   WATER {current.water}   OUTPUT {current.output}", 7)
        pyxel.rect(10, 44, 130, 14, 5)
        pyxel.rect(10, 44, min(current.water, 6) * 20, 14, 12)
        pyxel.text(155, 48, "WATER", 6)
        pyxel.text(10, 66, "ACTIVE: " + CHOICES[current.choice], 11)
        pyxel.text(10, 86, "TICK  BEFORE  FILLED  LEFT  MADE", 6)
        for row, (tick, before, filled, left, made) in enumerate(current.timeline):
            pyxel.text(10, 100 + row * 12,
                       f"{tick:4}  {before:6}  {filled:6}  {left:4}  {made:4}", 7 if made else 8)
        if current.done:
            pyxel.text(10, 181, "GOAL!" if current.success else "OUTPUT BELOW TARGET", 10)
            pyxel.text(10, 193, f"RESULT {current.output} / TARGET {TARGET}", 7)
            pyxel.text(10, 209, "TRY: " + CHOICES[selected], 11)
            pyxel.text(10, 222, "LEFT/RIGHT: CHOOSE  ENTER: RESTART", 6)
        pyxel.text(10, 248, "SPACE: ONE TICK  R: RESET  ESC: QUIT", 6)
        pyxel.text(10, 260, "X: UNDO A TICK / RETURN TO PRIOR RUN", 6)

    pyxel.init(310, 280, title="Future Repair - Stage 5", fps=30, 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

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

元の作品では、A・B・C の3つの事例を6刻ずつ観察し、1か所だけを変えて同じ未来をもう一度動かします。A は水と生産、B は電力の取り合い、C は運ぶ速さと置ける量がテーマです。条件を満たして終われば、どの結末も同じ「基本の成功」として扱います。

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

比べる点5ステップの教材元の完成版
事例水のタンクと品作りのAだけA・B・C、それぞれ4台の機械と5つの候補
比べ方1か所の変更・6刻・ログ・undo同じルール+修理票・比較の表示・記録
操作と表示手動で1刻ずつ・英語・図形日本語・自動運転・説明と確認の画面

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

  1. model.py の new と step で、A の5つの設定を教材と見比べましょう。
  2. step の B で、「充電→部品→運ぶ」の順番と、取り合いになる資源を読みましょう。
  3. step の C で、戻ってきた品を同じ刻にもう一度流さないようにする処理を読みましょう。
  4. controller.py で、1刻分の undo と、変更を選び直すための履歴を読みましょう。
自分のゲームに変えるなら

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

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