シミュレーションLESSON 34形質と天候の資源計画原作060

小さな進化島の作り方

今日の収穫と、明日の維持費を考える。
六日間の架空の天気を読み、2つの枠に入れる形質(体の特徴)と、エネルギーの蓄えを計画します。その場で払う費用と、毎日かかる費用を分けて学びます。

完成版の遊び方六日間の天気は、最初から公開されています。それに合わせて2つの枠に入れる形質を選び、収穫と維持費を考えます。架空のルールで遊ぶ計画ゲームです。

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

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

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

完成版の主な操作:上下キーで餌をとる場所を選ぶ、左右キーで形質の枠を選ぶ、Zで形質を変える、Spaceで一日を確定、Xで戻す、Rでやり直し、Pで説明

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

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

うまく表示されないとき

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

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

もう払った費用を、2回引かずに予告する。

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

1. 予告と確定を分ける
2. データの表と、条件の組み合わせ
3. 得るものと費用を分けて合計する
それだけで動く小さなプログラムを、5つのステップで少しずつ大きくしていきます。
必要な環境
Python 3.11以上 / Pyxel 2.9.9
知っておくとよいこと
変数・if・for・関数 / タプルと辞書 / 書き換えない dataclass と replace は本文で説明します
教材に入っているもの
ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
完成した教材の操作
上・下:池か木を選ぶ / 左・右:枠を選ぶ(02以降) / 0・1・2・3:空・鰭(ひれ)・毛皮・鉤爪(かぎづめ) / Space:一日の採集を確定 / X:1手戻す(05だけ) / R:最初に戻す / Esc:終了
難易度の目安
初級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_energy.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

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

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

STEP 01 / 05

六日間のエネルギー収支を作る

予告と確定を分ける。

最初は、形質のない小さな生き物と、湿・湿・湿・寒・寒・寒の六日間を作ります。上・下キーで池(POND)か木(TREE)を選び、Space で一日を確定します。考えている間は、日付は進みません。画面の DAY 0 は、初日の行動をする前という意味です。

State は、日付・蓄え・結果をまとめた、書き換えない箱です。forecast は今の状態を読み、収穫(harvest)から基本消費(base)を引いて、次の日の蓄えを計算します。湿の日に木へ行くと6+3-2=7、池なら6+2-2=6です。draw は、その予告を表示するだけです。確定する move が、replace で次の State を作ります。

蓄えの上限は18です。0以下になると FAILED(失敗)です。六日目まで生き残れば SURVIVED(生き残り)です。min は上限をこえないようにするだけなので、失敗したときのマイナスの値を0に直したりはしません。上限の処理と失敗の条件を、同じ処理に混ぜないようにしましょう。

この段階は、収支(増える分と減る分)だけの練習です。形質がないと、この天気予報では生き残れません。木で三日間蓄えを増やしても、寒い三日間で使い切ってしまいます。次の段階で、収穫や消費を変える選択を足します。

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

01_energy.py / 注目する部分
            "after": min(ENERGY_CAP, state.energy+harvest-base-upkeep)}

実行して確かめる

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

Windows
.venv\Scripts\python.exe 01_energy.py
macOS / Linux
.venv/bin/python 01_energy.py
画面で見ること下→Space と押すと、DAY 1・ENERGY 7 になります。画面を見て待つだけでは、数値は変わりません。R を押すと、日付0・蓄え6に戻ります。
  • 待っているだけでは進まない
  • 予告と確定を分ける
  • 上限18と、0以下で失敗する条件は別
つまずきやすいところdraw で日付や蓄えを変えると、画面を描くたびに進んでしまいます。予告は読むだけにして、Space だけが確定するように分けます。
少し変えてみよう

ENERGY_CAP を7に変えて、起動し直してみましょう。下→Space を2回押すと DAY 2・ENERGY 7 になり、上限をこえた1は持ち越せません。確かめたら18に戻します。

01_energy.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(165行)
01_energy.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。現実の進化を再現しない資源計画ゲーム。"""
from dataclasses import dataclass, replace

WEATHER = (0, 0, 0, 2, 2, 2)
WEATHER_NAMES = ("WET", "DRY", "COLD")
BASE_HARVEST = ((2, 0, 0), (3, 2, 1))
BASE_USE = (2, 3, 4)
ENERGY_CAP = 18


@dataclass(frozen=True)
class State:
    day: int = 0
    energy: int = 6
    outcome: str = "running"

def forecast(state, field):
    if state.day >= 6:
        return {"harvest": 0, "base": 0, "upkeep": 0, "change": 0, "after": state.energy}
    weather = WEATHER[state.day]
    harvest = BASE_HARVEST[field][weather]
    base = BASE_USE[weather]
    upkeep = 0
    paid_change = 0
    # paid_changeは表示する支払済み費用。ここでは二重に引かない。
    return {"harvest": harvest, "base": base, "upkeep": upkeep,
            "change": paid_change,
            "after": min(ENERGY_CAP, state.energy+harvest-base-upkeep)}


def move(state, action):
    if state.outcome != "running":
        return state, "ENDED"
    if action not in ("forage:0", "forage:1"):
        return state, "INVALID"
    field = int(action[-1])
    detail = forecast(state, field)
    energy = detail["after"]
    day = state.day+1
    outcome = "failed" if energy <= 0 else "won" if day == 6 else "running"
    return replace(state, day=day, energy=energy, outcome=outcome), "DAY CONFIRMED"

class App:
    def __init__(self):
        self.reset()

    def reset(self):
        self.state = State()
        self.field = 0
        self.gated = False
        self.message = "UP/DOWN FIELD, SPACE CONFIRM"

    def control(self, pressed, held):
        # 新しいRはいつでも優先。ただし、その後は全解放を待つ。
        if "R" in pressed:
            self.reset()
            self.gated = True
            return
        if self.gated:
            if not pressed and not held:
                self.gated = False
            return
        if len(pressed | held) > 1:
            self.gated = True
            self.message = "ONE KEY AT A TIME / RELEASE ALL"
            return
        if len(pressed) != 1:
            return
        key = next(iter(pressed))
        if self.state.outcome != "running":
            return
        if key in ("UP", "DOWN"):
            self.field = 0 if key == "UP" else 1
            self.gated = True
            return
        action = "forage:"+str(self.field) if key == "SPACE" else None
        if action is None:
            return
        self.state, self.message = move(self.state, action)
        self.gated = True

    def draw(self, p):
        p.cls(1)
        p.text(8, 8, "TRAIT ISLAND / STAGE 1", 10)
        p.text(8, 22, "DAY "+str(self.state.day)+" / 6  ENERGY "+str(self.state.energy), 7)
        for i in range(ENERGY_CAP):
            p.rect(8+i*10, 34, 8, 10, 11 if i<self.state.energy else 5)
        for i, weather in enumerate(WEATHER):
            p.text(8+i*60, 55, str(i+1)+":"+WEATHER_NAMES[weather], 10 if i==self.state.day else 6)
        p.text(8, 80, "BASE ENERGY PRACTICE / NO TRAITS YET", 6)
        detail = forecast(self.state, self.field)
        p.text(8, 107, "FIELD: "+("POND" if self.field==0 else "TREE"), 10)
        p.text(8, 121, "HARVEST +"+str(detail["harvest"])+"  BASE -"+str(detail["base"]), 7)
        p.text(8, 164, "NEXT ENERGY: "+str(detail["after"])+" / CAP "+str(ENERGY_CAP), 11)
        if self.state.outcome != "running":
            result = "FAILED" if self.state.outcome=="failed" else "SURVIVED / "+("EGG" if self.state.energy>=5 else "NO EGG")
            p.text(8, 182, result, 10)
        p.text(8, 200, self.message, 7)
        p.text(8, 220, "UP/DOWN FIELD  SPACE FORAGE  R RESET", 6)
        p.text(8, 244, "ESC QUIT", 6)

DEMO_KEYS = ('DOWN', 'SPACE')

CONTROL_KEYS = ('UP', 'DOWN', 'SPACE', 'R')

def main(headless=False, screenshot=None, demo=False):
    from pathlib import Path
    import pyxel as p
    screenshot = str(Path(screenshot).resolve()) if screenshot else None
    if screenshot:
        Path(screenshot).parent.mkdir(parents=True, exist_ok=True)
    app = App()
    p.init(384, 260, title="Trait Island", fps=30, display_scale=3, headless=headless)
    keys = {key: getattr(p, "KEY_"+key) for key in CONTROL_KEYS}
    limit = max(12, 2*len(DEMO_KEYS)+3) if demo else 12

    def update():
        if headless and p.frame_count >= limit:
            if screenshot:
                p.screenshot(screenshot, scale=3)
            p.quit()
            return
        if headless and demo:
            index = p.frame_count//2
            pressed = {DEMO_KEYS[index]} if p.frame_count%2==0 and index<len(DEMO_KEYS) else set()
            app.control(pressed, pressed)
        else:
            app.control({k for k,v in keys.items() if p.btnp(v)}, {k for k,v in keys.items() if p.btn(v)})
    p.run(update, lambda: app.draw(p))
    return app


if __name__ == "__main__":
    import argparse
    parser = argparse.ArgumentParser()
    parser.add_argument("--headless-smoke", action="store_true")
    parser.add_argument("--demo-checkpoint", action="store_true")
    parser.add_argument("--screenshot")
    args = parser.parse_args()
    main(args.headless_smoke, args.screenshot, args.demo_checkpoint)
STEP 02 / 05

二枠の形質で収支を変える

データ表と条件の組合せ。

左右キーで2つの枠のどちらかを選び、1で鰭(FIN)、2で毛皮(FUR)、3で鉤爪(CLAW)、0で空の枠(EMPTY)にします。同じ形質を2つの枠に入れることはできません。形質を変えても、まだ日付は進みません。Space で、池か木の収支を確定します。

鰭(F)は、池の収穫を、湿の日に+3、乾の日に+1、寒の日に+0します。鉤爪(T)は、木の収穫を+2します。毛皮(C)は、寒い日の基本消費を2減らします。WEATHER の0・1・2は表の列の番号で、湿・乾・寒を表します。文字 F・C・T は、形質の識別子(見分けるための名前)です。英語の表示名は、別の TRAITS の表に置いています。

trait(形質)が枠のタプルに入っているかを、in で調べます。そして、場所や天気の条件が合ったときだけ、数値を増減します。鰭を付けても、木の収穫は増えません。画面に出す名前と、計算に使う識別子を分けておくと、あとから表示の言語だけを変えやすくなります。

ここでは、維持費も変更費もありません。毎朝、何度でも無料で形質を変えられる、練習用のルールです。完成版でどちらが有利かを、この段階だけで判断しないでください。

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

02_benefits.py / 注目する部分
    if "F" in state.traits and field == 0:
        harvest += (3, 1, 0)[weather]

実行して確かめる

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

Windows
.venv\Scripts\python.exe 02_benefits.py
macOS / Linux
.venv/bin/python 02_benefits.py
画面で見ることR→1→Space と押して、鰭を付けて池を選ぶと、DAY 1・ENERGY 9 になります。鰭なしの池では6だったので、その差を確かめます。
  • 同じ形質を2つの枠に入れられない
  • 場所と天気によって変わる効果
  • 形質の変更は、まだ無料の練習
つまずきやすいところ鰭の+3を場所に関係なく加えると、木の収穫まで増えてしまいます。形質・採集場所・天気の3つの条件を照らし合わせます。
少し変えてみよう

R→3→下→Space と押してみましょう。鉤爪を付けて木へ行くと、6+5-2=9になります。次の段階では、同じ選び方に維持費がかかります。

02_benefits.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(198行)
02_benefits.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。現実の進化を再現しない資源計画ゲーム。"""
from dataclasses import dataclass, replace

WEATHER = (0, 0, 0, 2, 2, 2)
WEATHER_NAMES = ("WET", "DRY", "COLD")
BASE_HARVEST = ((2, 0, 0), (3, 2, 1))
BASE_USE = (2, 3, 4)
ENERGY_CAP = 18
TRAITS = {"": "EMPTY", "F": "FIN", "C": "FUR", "T": "CLAW"}


@dataclass(frozen=True)
class State:
    day: int = 0
    energy: int = 6
    traits: tuple = ("", "")
    outcome: str = "running"

def forecast(state, field):
    if state.day >= 6:
        return {"harvest": 0, "base": 0, "upkeep": 0, "change": 0, "after": state.energy}
    weather = WEATHER[state.day]
    harvest = BASE_HARVEST[field][weather]
    base = BASE_USE[weather]
    if "F" in state.traits and field == 0:
        harvest += (3, 1, 0)[weather]
    if "T" in state.traits and field == 1:
        harvest += 2
    if "C" in state.traits and weather == 2:
        base -= 2
    upkeep = 0
    paid_change = 0
    # paid_changeは表示する支払済み費用。ここでは二重に引かない。
    return {"harvest": harvest, "base": base, "upkeep": upkeep,
            "change": paid_change,
            "after": min(ENERGY_CAP, state.energy+harvest-base-upkeep)}


def move(state, action):
    if state.outcome != "running":
        return state, "ENDED"
    if isinstance(action, str) and action.startswith("set:"):
        try:
            _, raw, trait = action.split(":")
            slot = int(raw)
        except (ValueError, TypeError):
            return state, "INVALID"
        if slot not in (0, 1) or trait not in TRAITS:
            return state, "INVALID"
        if trait == state.traits[slot]:
            return state, "SAME TRAIT"
        if trait and trait in state.traits:
            return state, "NO DUPLICATES"
        traits = list(state.traits)
        traits[slot] = trait
        return replace(state, traits=tuple(traits)), "FREE CHANGE / PRACTICE RULE"
    if action not in ("forage:0", "forage:1"):
        return state, "INVALID"
    field = int(action[-1])
    detail = forecast(state, field)
    energy = detail["after"]
    day = state.day+1
    outcome = "failed" if energy <= 0 else "won" if day == 6 else "running"
    return replace(state, day=day, energy=energy, outcome=outcome), "DAY CONFIRMED"

class App:
    def __init__(self):
        self.reset()

    def reset(self):
        self.state = State()
        self.field = 0
        self.slot = 0
        self.gated = False
        self.message = "UP/DOWN FIELD, SPACE CONFIRM"

    def control(self, pressed, held):
        # 新しいRはいつでも優先。ただし、その後は全解放を待つ。
        if "R" in pressed:
            self.reset()
            self.gated = True
            return
        if self.gated:
            if not pressed and not held:
                self.gated = False
            return
        if len(pressed | held) > 1:
            self.gated = True
            self.message = "ONE KEY AT A TIME / RELEASE ALL"
            return
        if len(pressed) != 1:
            return
        key = next(iter(pressed))
        if self.state.outcome != "running":
            return
        if key in ("UP", "DOWN"):
            self.field = 0 if key == "UP" else 1
            self.gated = True
            return
        if key in ("LEFT", "RIGHT"):
            self.slot = 0 if key == "LEFT" else 1
            self.gated = True
            return
        action = "forage:"+str(self.field) if key == "SPACE" else None
        if key in ("0", "1", "2", "3"):
            action = "set:"+str(self.slot)+":"+{"0": "", "1": "F", "2": "C", "3": "T"}[key]
        if action is None:
            return
        self.state, self.message = move(self.state, action)
        self.gated = True

    def draw(self, p):
        p.cls(1)
        p.text(8, 8, "TRAIT ISLAND / STAGE 2", 10)
        p.text(8, 22, "DAY "+str(self.state.day)+" / 6  ENERGY "+str(self.state.energy), 7)
        for i in range(ENERGY_CAP):
            p.rect(8+i*10, 34, 8, 10, 11 if i<self.state.energy else 5)
        for i, weather in enumerate(WEATHER):
            p.text(8+i*60, 55, str(i+1)+":"+WEATHER_NAMES[weather], 10 if i==self.state.day else 6)
        for slot, trait in enumerate(self.state.traits):
            p.text(8+slot*165, 74, ("> " if slot==self.slot else "  ")+"SLOT "+str(slot+1)+": "+TRAITS[trait], 10 if slot==self.slot else 7)
        p.text(8, 87, "1 FIN  2 FUR  3 CLAW  0 EMPTY", 7)
        detail = forecast(self.state, self.field)
        p.text(8, 107, "FIELD: "+("POND" if self.field==0 else "TREE"), 10)
        p.text(8, 121, "HARVEST +"+str(detail["harvest"])+"  BASE -"+str(detail["base"]), 7)
        p.text(8, 164, "NEXT ENERGY: "+str(detail["after"])+" / CAP "+str(ENERGY_CAP), 11)
        if self.state.outcome != "running":
            result = "FAILED" if self.state.outcome=="failed" else "SURVIVED / "+("EGG" if self.state.energy>=5 else "NO EGG")
            p.text(8, 182, result, 10)
        p.text(8, 200, self.message, 7)
        p.text(8, 220, "UP/DOWN FIELD  SPACE FORAGE  R RESET", 6)
        p.text(8, 232, "LEFT/RIGHT SLOT  0/1/2/3 SET TRAIT", 6)
        p.text(8, 244, "ESC QUIT", 6)

DEMO_KEYS = ('1', 'SPACE')

CONTROL_KEYS = ('UP', 'DOWN', 'SPACE', 'R', 'LEFT', 'RIGHT', '0', '1', '2', '3')

def main(headless=False, screenshot=None, demo=False):
    from pathlib import Path
    import pyxel as p
    screenshot = str(Path(screenshot).resolve()) if screenshot else None
    if screenshot:
        Path(screenshot).parent.mkdir(parents=True, exist_ok=True)
    app = App()
    p.init(384, 260, title="Trait Island", fps=30, display_scale=3, headless=headless)
    keys = {key: getattr(p, "KEY_"+key) for key in CONTROL_KEYS}
    limit = max(12, 2*len(DEMO_KEYS)+3) if demo else 12

    def update():
        if headless and p.frame_count >= limit:
            if screenshot:
                p.screenshot(screenshot, scale=3)
            p.quit()
            return
        if headless and demo:
            index = p.frame_count//2
            pressed = {DEMO_KEYS[index]} if p.frame_count%2==0 and index<len(DEMO_KEYS) else set()
            app.control(pressed, pressed)
        else:
            app.control({k for k,v in keys.items() if p.btnp(v)}, {k for k,v in keys.items() if p.btn(v)})
    p.run(update, lambda: app.draw(p))
    return app


if __name__ == "__main__":
    import argparse
    parser = argparse.ArgumentParser()
    parser.add_argument("--headless-smoke", action="store_true")
    parser.add_argument("--demo-checkpoint", action="store_true")
    parser.add_argument("--screenshot")
    args = parser.parse_args()
    main(args.headless_smoke, args.screenshot, args.demo_checkpoint)
STEP 03 / 05

強みと維持費を同じ予告に入れる

得るものと費用を分けて合計する。

形質を持っているだけでかかる維持費(UPKEEP)を追加します。表は湿・乾・寒の順で、鰭は0・1・1、毛皮は1・2・0、鉤爪は1・1・1です。空の枠の維持費は0です。基本消費と維持費は、別々に表示します。

sum で、付けている形質それぞれの、その日の維持費を足します。空文字の枠を if t で除いてから表を引くので、空の枠のキーが表になくても困りません。「収穫-基本消費-維持費」を、同じ forecast の中で計算します。

たとえば、湿の日に毛皮と鉤爪を付けて木へ行くと、収穫5、基本消費2、維持費2なので+1です。寒い日なら、収穫3、毛皮のおかげで基本消費2、維持費1なので、増減は0です。強い効果だけを見るのではなく、次の三日間も続けられる組み合わせを考えるゲームになります。

形質の変更は、まだ毎朝何度でも無料です。元のゲームの変更費は、次の段階で加えます。これは、現実の生き物の進化を再現する教材ではありません。架空のルールを使って、資源の使い方を計画するパズルです。

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

03_upkeep.py / 注目する部分
    upkeep = sum(UPKEEP[t][weather] for t in state.traits if t)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 03_upkeep.py
macOS / Linux
.venv/bin/python 03_upkeep.py
画面で見ることR→2→右→3→下と押してから、Space を3回押します。毛皮と鉤爪を付けて木へ行き、DAY 3・ENERGY 9 になります。次の寒い日には、収支が0になります。
  • 空の枠は費用0
  • 基本消費と維持費を分ける
  • 無料で変えられるのは、まだ練習用のルール
つまずきやすいところ基本消費の表を維持費で上書きすると、体を保つための基本の費用が消えてしまいます。base と upkeep を別々に計算して、最後に両方を引きます。
少し変えてみよう

R→3→下→Space と押してみましょう。02と同じ「鉤爪を付けて木」でも、維持費1の分だけ少ない ENERGY 8 になることを確かめます。

03_upkeep.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(200行)
03_upkeep.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。現実の進化を再現しない資源計画ゲーム。"""
from dataclasses import dataclass, replace

WEATHER = (0, 0, 0, 2, 2, 2)
WEATHER_NAMES = ("WET", "DRY", "COLD")
BASE_HARVEST = ((2, 0, 0), (3, 2, 1))
BASE_USE = (2, 3, 4)
ENERGY_CAP = 18
TRAITS = {"": "EMPTY", "F": "FIN", "C": "FUR", "T": "CLAW"}
UPKEEP = {"F": (0, 1, 1), "C": (1, 2, 0), "T": (1, 1, 1)}


@dataclass(frozen=True)
class State:
    day: int = 0
    energy: int = 6
    traits: tuple = ("", "")
    outcome: str = "running"

def forecast(state, field):
    if state.day >= 6:
        return {"harvest": 0, "base": 0, "upkeep": 0, "change": 0, "after": state.energy}
    weather = WEATHER[state.day]
    harvest = BASE_HARVEST[field][weather]
    base = BASE_USE[weather]
    if "F" in state.traits and field == 0:
        harvest += (3, 1, 0)[weather]
    if "T" in state.traits and field == 1:
        harvest += 2
    if "C" in state.traits and weather == 2:
        base -= 2
    upkeep = sum(UPKEEP[t][weather] for t in state.traits if t)
    paid_change = 0
    # paid_changeは表示する支払済み費用。ここでは二重に引かない。
    return {"harvest": harvest, "base": base, "upkeep": upkeep,
            "change": paid_change,
            "after": min(ENERGY_CAP, state.energy+harvest-base-upkeep)}


def move(state, action):
    if state.outcome != "running":
        return state, "ENDED"
    if isinstance(action, str) and action.startswith("set:"):
        try:
            _, raw, trait = action.split(":")
            slot = int(raw)
        except (ValueError, TypeError):
            return state, "INVALID"
        if slot not in (0, 1) or trait not in TRAITS:
            return state, "INVALID"
        if trait == state.traits[slot]:
            return state, "SAME TRAIT"
        if trait and trait in state.traits:
            return state, "NO DUPLICATES"
        traits = list(state.traits)
        traits[slot] = trait
        return replace(state, traits=tuple(traits)), "FREE CHANGE / PRACTICE RULE"
    if action not in ("forage:0", "forage:1"):
        return state, "INVALID"
    field = int(action[-1])
    detail = forecast(state, field)
    energy = detail["after"]
    day = state.day+1
    outcome = "failed" if energy <= 0 else "won" if day == 6 else "running"
    return replace(state, day=day, energy=energy, outcome=outcome), "DAY CONFIRMED"

class App:
    def __init__(self):
        self.reset()

    def reset(self):
        self.state = State()
        self.field = 0
        self.slot = 0
        self.gated = False
        self.message = "UP/DOWN FIELD, SPACE CONFIRM"

    def control(self, pressed, held):
        # 新しいRはいつでも優先。ただし、その後は全解放を待つ。
        if "R" in pressed:
            self.reset()
            self.gated = True
            return
        if self.gated:
            if not pressed and not held:
                self.gated = False
            return
        if len(pressed | held) > 1:
            self.gated = True
            self.message = "ONE KEY AT A TIME / RELEASE ALL"
            return
        if len(pressed) != 1:
            return
        key = next(iter(pressed))
        if self.state.outcome != "running":
            return
        if key in ("UP", "DOWN"):
            self.field = 0 if key == "UP" else 1
            self.gated = True
            return
        if key in ("LEFT", "RIGHT"):
            self.slot = 0 if key == "LEFT" else 1
            self.gated = True
            return
        action = "forage:"+str(self.field) if key == "SPACE" else None
        if key in ("0", "1", "2", "3"):
            action = "set:"+str(self.slot)+":"+{"0": "", "1": "F", "2": "C", "3": "T"}[key]
        if action is None:
            return
        self.state, self.message = move(self.state, action)
        self.gated = True

    def draw(self, p):
        p.cls(1)
        p.text(8, 8, "TRAIT ISLAND / STAGE 3", 10)
        p.text(8, 22, "DAY "+str(self.state.day)+" / 6  ENERGY "+str(self.state.energy), 7)
        for i in range(ENERGY_CAP):
            p.rect(8+i*10, 34, 8, 10, 11 if i<self.state.energy else 5)
        for i, weather in enumerate(WEATHER):
            p.text(8+i*60, 55, str(i+1)+":"+WEATHER_NAMES[weather], 10 if i==self.state.day else 6)
        for slot, trait in enumerate(self.state.traits):
            p.text(8+slot*165, 74, ("> " if slot==self.slot else "  ")+"SLOT "+str(slot+1)+": "+TRAITS[trait], 10 if slot==self.slot else 7)
        p.text(8, 87, "1 FIN  2 FUR  3 CLAW  0 EMPTY", 7)
        detail = forecast(self.state, self.field)
        p.text(8, 107, "FIELD: "+("POND" if self.field==0 else "TREE"), 10)
        p.text(8, 121, "HARVEST +"+str(detail["harvest"])+"  BASE -"+str(detail["base"]), 7)
        p.text(8, 134, "UPKEEP -"+str(detail["upkeep"]), 7)
        p.text(8, 164, "NEXT ENERGY: "+str(detail["after"])+" / CAP "+str(ENERGY_CAP), 11)
        if self.state.outcome != "running":
            result = "FAILED" if self.state.outcome=="failed" else "SURVIVED / "+("EGG" if self.state.energy>=5 else "NO EGG")
            p.text(8, 182, result, 10)
        p.text(8, 200, self.message, 7)
        p.text(8, 220, "UP/DOWN FIELD  SPACE FORAGE  R RESET", 6)
        p.text(8, 232, "LEFT/RIGHT SLOT  0/1/2/3 SET TRAIT", 6)
        p.text(8, 244, "ESC QUIT", 6)

DEMO_KEYS = ('2', 'RIGHT', '3', 'DOWN', 'SPACE', 'SPACE', 'SPACE')

CONTROL_KEYS = ('UP', 'DOWN', 'SPACE', 'R', 'LEFT', 'RIGHT', '0', '1', '2', '3')

def main(headless=False, screenshot=None, demo=False):
    from pathlib import Path
    import pyxel as p
    screenshot = str(Path(screenshot).resolve()) if screenshot else None
    if screenshot:
        Path(screenshot).parent.mkdir(parents=True, exist_ok=True)
    app = App()
    p.init(384, 260, title="Trait Island", fps=30, display_scale=3, headless=headless)
    keys = {key: getattr(p, "KEY_"+key) for key in CONTROL_KEYS}
    limit = max(12, 2*len(DEMO_KEYS)+3) if demo else 12

    def update():
        if headless and p.frame_count >= limit:
            if screenshot:
                p.screenshot(screenshot, scale=3)
            p.quit()
            return
        if headless and demo:
            index = p.frame_count//2
            pressed = {DEMO_KEYS[index]} if p.frame_count%2==0 and index<len(DEMO_KEYS) else set()
            app.control(pressed, pressed)
        else:
            app.control({k for k,v in keys.items() if p.btnp(v)}, {k for k,v in keys.items() if p.btn(v)})
    p.run(update, lambda: app.draw(p))
    return app


if __name__ == "__main__":
    import argparse
    parser = argparse.ArgumentParser()
    parser.add_argument("--headless-smoke", action="store_true")
    parser.add_argument("--demo-checkpoint", action="store_true")
    parser.add_argument("--screenshot")
    args = parser.parse_args()
    main(args.headless_smoke, args.screenshot, args.demo_checkpoint)
STEP 04 / 05

朝の一回だけ、蓄えを払って変更

その場での支払いと、一日の確定を分ける。

ここからは、元のゲームのAのふつうのルールです。初日(DAY 0)の形質選びは、何度でも無料です。DAY 1以降は、1回の朝に1つの枠だけ変えられます。変えると決めた瞬間に、蓄えから2を払います。付け替えも、追加も、取り外しも、どれも1回と数えます。今と同じ形質を選んだだけのとき、同じ形質が2つになるとき、正しくない操作のときは、費用も回数も使いません。

changed は、今朝もう変更したかどうかです。changes は、全期間で有料の変更をした回数です。払ったあとの蓄えが0以下になるなら、変更を受け付けません。つまり、蓄えが2では変更できず、3なら払って1を残せます。採集を確定して次の朝になったら、changed だけを False に戻します。

画面の CHANGE ALREADY PAID は、もう払った費用の記録です。forecast の after から、もう一度2を引くと、2回払わせることになってしまいます。たとえば、三日後の蓄え15から毛皮に替えたら、その場で13になります。木の収穫1と基本消費2で、その日の結果は12です。

State.history には、一日ごとの内訳を記録します。これは、次の段階で作る、操作の Undo(元に戻す)のための履歴とは別のものです。六日間生き残るだけで SURVIVED です。終わったときの蓄えが5以上なら、さらに EGG(卵)も表示されます。蓄えが1〜4でも、失敗ではありません。

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

04_changes.py / 注目する部分
        cost = 0 if state.day == 0 else 2
        if state.energy-cost <= 0:
            return state, "NEED ENERGY AFTER PAYMENT"

実行して確かめる

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

Windows
.venv\Scripts\python.exe 04_changes.py
macOS / Linux
.venv/bin/python 04_changes.py
画面で見ることR→1→Space を3回→2→下→Space を3回と押します。DAY 6・ENERGY 10・SURVIVED / EGG になります。変更回数は1です。
  • 初日だけは、何度変えても無料
  • 変更費は、変えたその場で1回だけ払う
  • 生き残ることと、卵の追加の達成は別
つまずきやすいところ変更したときに払った2を、forecast でもう一度引かないようにします。もう払ったことを表す change と、これから差し引く upkeep の意味を分けます。
少し変えてみよう

R→1→Space→0 と押してみましょう。DAY 1 に取り外すだけでも2を払い、ENERGY 7 になります。続けて2を押しても、同じ朝の2回目の変更なので、何も変わりません。

04_changes.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(215行)
04_changes.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。現実の進化を再現しない資源計画ゲーム。"""
from dataclasses import dataclass, replace

WEATHER = (0, 0, 0, 2, 2, 2)
WEATHER_NAMES = ("WET", "DRY", "COLD")
BASE_HARVEST = ((2, 0, 0), (3, 2, 1))
BASE_USE = (2, 3, 4)
ENERGY_CAP = 18
TRAITS = {"": "EMPTY", "F": "FIN", "C": "FUR", "T": "CLAW"}
UPKEEP = {"F": (0, 1, 1), "C": (1, 2, 0), "T": (1, 1, 1)}


@dataclass(frozen=True)
class State:
    day: int = 0
    energy: int = 6
    traits: tuple = ("", "")
    changed: bool = False
    changes: int = 0
    tick: int = 0
    outcome: str = "running"
    history: tuple = ()

def forecast(state, field):
    if state.day >= 6:
        return {"harvest": 0, "base": 0, "upkeep": 0, "change": 0, "after": state.energy}
    weather = WEATHER[state.day]
    harvest = BASE_HARVEST[field][weather]
    base = BASE_USE[weather]
    if "F" in state.traits and field == 0:
        harvest += (3, 1, 0)[weather]
    if "T" in state.traits and field == 1:
        harvest += 2
    if "C" in state.traits and weather == 2:
        base -= 2
    upkeep = sum(UPKEEP[t][weather] for t in state.traits if t)
    paid_change = 2 if state.changed else 0
    # paid_changeは表示する支払済み費用。ここでは二重に引かない。
    return {"harvest": harvest, "base": base, "upkeep": upkeep,
            "change": paid_change,
            "after": min(ENERGY_CAP, state.energy+harvest-base-upkeep)}


def move(state, action):
    if state.outcome != "running":
        return state, "ENDED"
    if isinstance(action, str) and action.startswith("set:"):
        try:
            _, raw, trait = action.split(":")
            slot = int(raw)
        except (ValueError, TypeError):
            return state, "INVALID"
        if slot not in (0, 1) or trait not in TRAITS:
            return state, "INVALID"
        if trait == state.traits[slot]:
            return state, "SAME TRAIT"
        if trait and trait in state.traits:
            return state, "NO DUPLICATES"
        if state.day > 0 and state.changed:
            return state, "ONE CHANGE PER MORNING"
        cost = 0 if state.day == 0 else 2
        if state.energy-cost <= 0:
            return state, "NEED ENERGY AFTER PAYMENT"
        traits = list(state.traits)
        traits[slot] = trait
        return replace(state, traits=tuple(traits), energy=state.energy-cost,
                       changed=state.day>0, changes=state.changes+(state.day>0),
                       tick=state.tick+1), "CHANGED / PAID "+str(cost)
    if action not in ("forage:0", "forage:1"):
        return state, "INVALID"
    field = int(action[-1])
    detail = forecast(state, field)
    energy = detail["after"]
    day = state.day+1
    outcome = "failed" if energy <= 0 else "won" if day == 6 else "running"
    entry = (day, WEATHER[state.day], state.traits, field, state.energy,
             detail["harvest"], detail["base"], detail["upkeep"], detail["change"], energy)
    return replace(state, day=day, energy=energy, changed=False, tick=state.tick+1,
                   outcome=outcome, history=state.history+(entry,)), "DAY CONFIRMED"

class App:
    def __init__(self):
        self.reset()

    def reset(self):
        self.state = State()
        self.field = 0
        self.slot = 0
        self.gated = False
        self.message = "UP/DOWN FIELD, SPACE CONFIRM"

    def control(self, pressed, held):
        # 新しいRはいつでも優先。ただし、その後は全解放を待つ。
        if "R" in pressed:
            self.reset()
            self.gated = True
            return
        if self.gated:
            if not pressed and not held:
                self.gated = False
            return
        if len(pressed | held) > 1:
            self.gated = True
            self.message = "ONE KEY AT A TIME / RELEASE ALL"
            return
        if len(pressed) != 1:
            return
        key = next(iter(pressed))
        if self.state.outcome != "running":
            return
        if key in ("UP", "DOWN"):
            self.field = 0 if key == "UP" else 1
            self.gated = True
            return
        if key in ("LEFT", "RIGHT"):
            self.slot = 0 if key == "LEFT" else 1
            self.gated = True
            return
        action = "forage:"+str(self.field) if key == "SPACE" else None
        if key in ("0", "1", "2", "3"):
            action = "set:"+str(self.slot)+":"+{"0": "", "1": "F", "2": "C", "3": "T"}[key]
        if action is None:
            return
        self.state, self.message = move(self.state, action)
        self.gated = True

    def draw(self, p):
        p.cls(1)
        p.text(8, 8, "TRAIT ISLAND / STAGE 4", 10)
        p.text(8, 22, "DAY "+str(self.state.day)+" / 6  ENERGY "+str(self.state.energy), 7)
        for i in range(ENERGY_CAP):
            p.rect(8+i*10, 34, 8, 10, 11 if i<self.state.energy else 5)
        for i, weather in enumerate(WEATHER):
            p.text(8+i*60, 55, str(i+1)+":"+WEATHER_NAMES[weather], 10 if i==self.state.day else 6)
        for slot, trait in enumerate(self.state.traits):
            p.text(8+slot*165, 74, ("> " if slot==self.slot else "  ")+"SLOT "+str(slot+1)+": "+TRAITS[trait], 10 if slot==self.slot else 7)
        p.text(8, 87, "1 FIN  2 FUR  3 CLAW  0 EMPTY", 7)
        detail = forecast(self.state, self.field)
        p.text(8, 107, "FIELD: "+("POND" if self.field==0 else "TREE"), 10)
        p.text(8, 121, "HARVEST +"+str(detail["harvest"])+"  BASE -"+str(detail["base"]), 7)
        p.text(8, 134, "UPKEEP -"+str(detail["upkeep"]), 7)
        p.text(8, 148, "CHANGE ALREADY PAID: "+str(detail["change"]), 6)
        p.text(8, 164, "NEXT ENERGY: "+str(detail["after"])+" / CAP "+str(ENERGY_CAP), 11)
        if self.state.outcome != "running":
            result = "FAILED" if self.state.outcome=="failed" else "SURVIVED / "+("EGG" if self.state.energy>=5 else "NO EGG")
            p.text(8, 182, result, 10)
        p.text(8, 200, self.message, 7)
        p.text(8, 220, "UP/DOWN FIELD  SPACE FORAGE  R RESET", 6)
        p.text(8, 232, "LEFT/RIGHT SLOT  0/1/2/3 SET TRAIT", 6)
        p.text(8, 244, "ESC QUIT", 6)

DEMO_KEYS = ('1', 'SPACE', 'SPACE', 'SPACE', '2', 'DOWN', 'SPACE', 'SPACE', 'SPACE')

CONTROL_KEYS = ('UP', 'DOWN', 'SPACE', 'R', 'LEFT', 'RIGHT', '0', '1', '2', '3')

def main(headless=False, screenshot=None, demo=False):
    from pathlib import Path
    import pyxel as p
    screenshot = str(Path(screenshot).resolve()) if screenshot else None
    if screenshot:
        Path(screenshot).parent.mkdir(parents=True, exist_ok=True)
    app = App()
    p.init(384, 260, title="Trait Island", fps=30, display_scale=3, headless=headless)
    keys = {key: getattr(p, "KEY_"+key) for key in CONTROL_KEYS}
    limit = max(12, 2*len(DEMO_KEYS)+3) if demo else 12

    def update():
        if headless and p.frame_count >= limit:
            if screenshot:
                p.screenshot(screenshot, scale=3)
            p.quit()
            return
        if headless and demo:
            index = p.frame_count//2
            pressed = {DEMO_KEYS[index]} if p.frame_count%2==0 and index<len(DEMO_KEYS) else set()
            app.control(pressed, pressed)
        else:
            app.control({k for k,v in keys.items() if p.btnp(v)}, {k for k,v in keys.items() if p.btn(v)})
    p.run(update, lambda: app.draw(p))
    return app


if __name__ == "__main__":
    import argparse
    parser = argparse.ArgumentParser()
    parser.add_argument("--headless-smoke", action="store_true")
    parser.add_argument("--demo-checkpoint", action="store_true")
    parser.add_argument("--screenshot")
    args = parser.parse_args()
    main(args.headless_smoke, args.screenshot, args.demo_checkpoint)
STEP 05 / 05

採集も形質変更も一手ずつ戻す

状態全体を保存する Undo(元に戻す)。

Timeline に、今の State と、確定する前の State のタプルを持たせます。X で、直前の形質の変更か採集を1つ戻せる完成版です。State は書き換えられず、形質も日ごとの記録もタプルなので、過去の状態があとから書き換わることはありません。

履歴に積むかどうかは、日付が進んだかではなく、State が変わったかで決めます。初日の無料の変更も、朝の有料の変更も、1つの判断として戻せます。同じ形質が2つになるなどの理由で受け付けなかった操作は、履歴に積みません。1回の X が、受け付けた1つの判断に対応します。

結果が出たあとでも X を先に受け付けるので、最後の一日に戻れます。R は、日付・形質・支払い・日ごとの記録・Undo をすべて最初に戻します。場所と枠のカーソルは画面の選択なので、Undo の対象にはなりません。操作が混ざらないように、1度に1つのキーを押し、離してから次のキーを押します。新しく押した R は、ほかのキーを同時に押していても優先されます。

元のゲームのAと、状態の移り変わりと予告を比べてあります。元のゲームのBとC、維持費なしの比較、日本語の文字、記録ファイルの正確な再生は省きました。次は、元の model.py を、Level データ、forecast、move、step の順に読みましょう。同じルールに、ちがう天気を渡す作り方を調べます。

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

05_undo.py / 注目する部分
    return (Timeline(state, timeline.history+(timeline.state,)), message) if state != timeline.state else (timeline, message)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 05_undo.py
macOS / Linux
.venv/bin/python 05_undo.py
画面で見ること04の手順で成功したあと X を押すと、DAY 5・ENERGY 11・running(遊んでいる途中)に戻ります。R のあとに X を押しても、何も起こりません。
  • 無料の変更も戻せる
  • 受け付けなかった操作では、履歴を増やさない
  • 終わった状態からも戻せる
つまずきやすいところenergy だけを戻すと、changed や日ごとの記録が残ってしまいます。State 全体を保存し、形質の変更も履歴に積みます。
少し変えてみよう

R→1→Space→2→X と押してみましょう。DAY 1・ENERGY 9・鰭に戻ります。有料の変更をする前に戻るので、その朝の変更の1回分も、まだ使っていない状態になります。

05_undo.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(235行)
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。現実の進化を再現しない資源計画ゲーム。"""
from dataclasses import dataclass, replace

WEATHER = (0, 0, 0, 2, 2, 2)
WEATHER_NAMES = ("WET", "DRY", "COLD")
BASE_HARVEST = ((2, 0, 0), (3, 2, 1))
BASE_USE = (2, 3, 4)
ENERGY_CAP = 18
TRAITS = {"": "EMPTY", "F": "FIN", "C": "FUR", "T": "CLAW"}
UPKEEP = {"F": (0, 1, 1), "C": (1, 2, 0), "T": (1, 1, 1)}


@dataclass(frozen=True)
class State:
    day: int = 0
    energy: int = 6
    traits: tuple = ("", "")
    changed: bool = False
    changes: int = 0
    tick: int = 0
    outcome: str = "running"
    history: tuple = ()

def forecast(state, field):
    if state.day >= 6:
        return {"harvest": 0, "base": 0, "upkeep": 0, "change": 0, "after": state.energy}
    weather = WEATHER[state.day]
    harvest = BASE_HARVEST[field][weather]
    base = BASE_USE[weather]
    if "F" in state.traits and field == 0:
        harvest += (3, 1, 0)[weather]
    if "T" in state.traits and field == 1:
        harvest += 2
    if "C" in state.traits and weather == 2:
        base -= 2
    upkeep = sum(UPKEEP[t][weather] for t in state.traits if t)
    paid_change = 2 if state.changed else 0
    # paid_changeは表示する支払済み費用。ここでは二重に引かない。
    return {"harvest": harvest, "base": base, "upkeep": upkeep,
            "change": paid_change,
            "after": min(ENERGY_CAP, state.energy+harvest-base-upkeep)}


def move(state, action):
    if state.outcome != "running":
        return state, "ENDED"
    if isinstance(action, str) and action.startswith("set:"):
        try:
            _, raw, trait = action.split(":")
            slot = int(raw)
        except (ValueError, TypeError):
            return state, "INVALID"
        if slot not in (0, 1) or trait not in TRAITS:
            return state, "INVALID"
        if trait == state.traits[slot]:
            return state, "SAME TRAIT"
        if trait and trait in state.traits:
            return state, "NO DUPLICATES"
        if state.day > 0 and state.changed:
            return state, "ONE CHANGE PER MORNING"
        cost = 0 if state.day == 0 else 2
        if state.energy-cost <= 0:
            return state, "NEED ENERGY AFTER PAYMENT"
        traits = list(state.traits)
        traits[slot] = trait
        return replace(state, traits=tuple(traits), energy=state.energy-cost,
                       changed=state.day>0, changes=state.changes+(state.day>0),
                       tick=state.tick+1), "CHANGED / PAID "+str(cost)
    if action not in ("forage:0", "forage:1"):
        return state, "INVALID"
    field = int(action[-1])
    detail = forecast(state, field)
    energy = detail["after"]
    day = state.day+1
    outcome = "failed" if energy <= 0 else "won" if day == 6 else "running"
    entry = (day, WEATHER[state.day], state.traits, field, state.energy,
             detail["harvest"], detail["base"], detail["upkeep"], detail["change"], energy)
    return replace(state, day=day, energy=energy, changed=False, tick=state.tick+1,
                   outcome=outcome, history=state.history+(entry,)), "DAY CONFIRMED"

@dataclass(frozen=True)
class Timeline:
    state: State = State()
    history: tuple = ()


def step(timeline, action):
    if action == "undo":
        return (Timeline(timeline.history[-1], timeline.history[:-1]), "UNDO") if timeline.history else (timeline, "NOTHING TO UNDO")
    state, message = move(timeline.state, action)
    return (Timeline(state, timeline.history+(timeline.state,)), message) if state != timeline.state else (timeline, message)

class App:
    def __init__(self):
        self.reset()

    def reset(self):
        self.timeline = Timeline()
        self.field = 0
        self.slot = 0
        self.gated = False
        self.message = "UP/DOWN FIELD, SPACE CONFIRM"

    @property
    def state(self):
        return self.timeline.state

    def control(self, pressed, held):
        # 新しいRはいつでも優先。ただし、その後は全解放を待つ。
        if "R" in pressed:
            self.reset()
            self.gated = True
            return
        if self.gated:
            if not pressed and not held:
                self.gated = False
            return
        if len(pressed | held) > 1:
            self.gated = True
            self.message = "ONE KEY AT A TIME / RELEASE ALL"
            return
        if len(pressed) != 1:
            return
        key = next(iter(pressed))
        if key == "X":
            self.timeline, self.message = step(self.timeline, "undo")
            self.gated = True
            return
        if self.state.outcome != "running":
            return
        if key in ("UP", "DOWN"):
            self.field = 0 if key == "UP" else 1
            self.gated = True
            return
        if key in ("LEFT", "RIGHT"):
            self.slot = 0 if key == "LEFT" else 1
            self.gated = True
            return
        action = "forage:"+str(self.field) if key == "SPACE" else None
        if key in ("0", "1", "2", "3"):
            action = "set:"+str(self.slot)+":"+{"0": "", "1": "F", "2": "C", "3": "T"}[key]
        if action is None:
            return
        self.timeline, self.message = step(self.timeline, action)
        self.gated = True

    def draw(self, p):
        p.cls(1)
        p.text(8, 8, "TRAIT ISLAND / STAGE 5", 10)
        p.text(8, 22, "DAY "+str(self.state.day)+" / 6  ENERGY "+str(self.state.energy), 7)
        for i in range(ENERGY_CAP):
            p.rect(8+i*10, 34, 8, 10, 11 if i<self.state.energy else 5)
        for i, weather in enumerate(WEATHER):
            p.text(8+i*60, 55, str(i+1)+":"+WEATHER_NAMES[weather], 10 if i==self.state.day else 6)
        for slot, trait in enumerate(self.state.traits):
            p.text(8+slot*165, 74, ("> " if slot==self.slot else "  ")+"SLOT "+str(slot+1)+": "+TRAITS[trait], 10 if slot==self.slot else 7)
        p.text(8, 87, "1 FIN  2 FUR  3 CLAW  0 EMPTY", 7)
        detail = forecast(self.state, self.field)
        p.text(8, 107, "FIELD: "+("POND" if self.field==0 else "TREE"), 10)
        p.text(8, 121, "HARVEST +"+str(detail["harvest"])+"  BASE -"+str(detail["base"]), 7)
        p.text(8, 134, "UPKEEP -"+str(detail["upkeep"]), 7)
        p.text(8, 148, "CHANGE ALREADY PAID: "+str(detail["change"]), 6)
        p.text(8, 164, "NEXT ENERGY: "+str(detail["after"])+" / CAP "+str(ENERGY_CAP), 11)
        if self.state.outcome != "running":
            result = "FAILED" if self.state.outcome=="failed" else "SURVIVED / "+("EGG" if self.state.energy>=5 else "NO EGG")
            p.text(8, 182, result, 10)
        p.text(8, 200, self.message, 7)
        p.text(8, 220, "UP/DOWN FIELD  SPACE FORAGE  R RESET", 6)
        p.text(8, 232, "LEFT/RIGHT SLOT  0/1/2/3 SET TRAIT", 6)
        p.text(8, 244, "X UNDO  ESC QUIT", 6)

DEMO_KEYS = ('1', 'SPACE', 'SPACE', 'SPACE', '2', 'DOWN', 'SPACE', 'SPACE', 'SPACE')

CONTROL_KEYS = ('UP', 'DOWN', 'SPACE', 'R', 'LEFT', 'RIGHT', '0', '1', '2', '3', 'X')

def main(headless=False, screenshot=None, demo=False):
    from pathlib import Path
    import pyxel as p
    screenshot = str(Path(screenshot).resolve()) if screenshot else None
    if screenshot:
        Path(screenshot).parent.mkdir(parents=True, exist_ok=True)
    app = App()
    p.init(384, 260, title="Trait Island", fps=30, display_scale=3, headless=headless)
    keys = {key: getattr(p, "KEY_"+key) for key in CONTROL_KEYS}
    limit = max(12, 2*len(DEMO_KEYS)+3) if demo else 12

    def update():
        if headless and p.frame_count >= limit:
            if screenshot:
                p.screenshot(screenshot, scale=3)
            p.quit()
            return
        if headless and demo:
            index = p.frame_count//2
            pressed = {DEMO_KEYS[index]} if p.frame_count%2==0 and index<len(DEMO_KEYS) else set()
            app.control(pressed, pressed)
        else:
            app.control({k for k,v in keys.items() if p.btnp(v)}, {k for k,v in keys.items() if p.btn(v)})
    p.run(update, lambda: app.draw(p))
    return app


if __name__ == "__main__":
    import argparse
    parser = argparse.ArgumentParser()
    parser.add_argument("--headless-smoke", action="store_true")
    parser.add_argument("--demo-checkpoint", action="store_true")
    parser.add_argument("--screenshot")
    args = parser.parse_args()
    main(args.headless_smoke, args.screenshot, args.demo_checkpoint)
AFTER STEP 05

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

元のゲームのAです。湿の3日→寒の3日を、最初の蓄え6、上限18で生き残ります。形質の枠は2つです。初日の変更は無料で、それ以降は1回の朝に1つの枠だけ、変更費2で変えられます。形質の維持費と、採集場所ごとの収支を計算し、終わったときに5以上なら卵です。架空の資源計画で、現実の進化を再現したものではありません。

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

比べる点5ステップの教材元の完成版
段階と天気01は形質なし、02は得るものだけ、03は維持費までの練習。04と05で元のゲームのAのふつうのルール。A・B・Cの3つの天気の並びを選べる。
画面と操作英字(ASCII)で表示し、枠と形質を直接キーで選ぶ。元のゲームとちがい、1つずつキーを押す入力。05だけ Undo がある。日本語の文字、表紙と状況の選択、形質と場所のメニュー。
比較と記録維持費ありに固定。操作の Undo と日ごとの内訳はあるが、外部への記録の保存はない。維持費なしの比較、動かした環境の情報まで正確に付けた記録・再生と、補助ツール。

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

  1. model.py の Level・LEVELS にある天気のデータ
  2. model.py の forecast にある、もう払った change と、まだ払っていない upkeep
  3. model.py の move にある、朝に1回だけの変更と、生き残りの条件
  4. model.py の step の履歴と、record・replay で再現できるかを調べる仕組み
自分のゲームに変えるなら

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

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