シミュレーションLESSON 82有限在庫と移転を扱うターン制の資源パズル原作043

蜜蜂の引っ越しの作り方

採った蜜の一滴を、巣まで届けよう。
花に残る蜜、蜂が運んでいる荷物、巣にたまった蜜を、別々に数えます。2匹の蜂の予約、遅れて咲く花、巣を引っ越すときの費用を扱います。最後に、操作の結果の予告と、状態をまるごと戻す Undo(元に戻す)を作ります。

完成版の遊び方2匹の蜂で、限られた花の蜜を予約します。花を採り切ると別の花が咲くことや、巣を引っ越す費用を考えながら、巣に蜜を集めます。

蜜蜂の引っ越しの元の完成版。自動操作によるPyxelの実描画画面。
元の完成版の庭Bを、自動で動かして描いた画面です。下の教材は、蜂1匹・花1輪の練習から始めます。03からは、庭A・B・Cの3つの庭を扱います。
PLAY / 試験公開

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

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

完成版の主な操作:上下キーで蜂か引っ越しを選ぶ、左右キーで花か巣台(巣を置ける場所)を選ぶ、Zで決める、Cで予約を取り消す、Spaceで時間を1刻進める、Fで自動で進める、Xで戻す

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

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

うまく表示されないとき

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

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

蜜のある場所と予約を分けて、巣の引っ越しにかかる費用を確定する。

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

1. 位置、蜂の荷物、巣の蜜を別々に持つ
2. 花の蜜の一部を予約し、出発する前だけ取り消せるようにする
3. もう動いているタイマーの更新と、新しく起きたできごとの順番
それだけで動く小さなプログラムを、5つのステップで少しずつ大きくしていきます。
必要な環境
Python 3.11以上 / Pyxel 2.9.9
知っておくとよいこと
変数・if・for・関数 / tuple、list、dataclass、replace(本文で説明します) / 足し算、比較、状態の移り変わり(状態遷移)
教材に入っているもの
ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
完成した教材の操作
Z:01は出発、02以降は予約、04以降は HIVE を選んでいるときに巣の引っ越し / Space:時間を1刻進める / Tab:02以降は蜂1と蜂2を選ぶ。04以降はさらに巣も選べる / 左右:02以降、花か巣台(巣を置ける場所)を選ぶ / C:02以降、選んだ蜂の予約を、出発前のときだけ取り消す / 1・2・3:03以降、庭A・B・Cを選んで最初からにする / X:05だけ。直前の状態に戻す(Undo) / R:読み込んであるコードの最初の状態に戻す / Esc:終了
難易度の目安
中級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_delivery.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

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

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

STEP 01 / 05

採った蜜は、まだ巣には届かない

位置、蜂の荷物、巣の蜜を別々に持つ。

最初は、1匹の蜂と、位置2にある1輪の花だけです。Z で出発を指示し、Space を1回押すごとに、蜂が1区画進みます。画面の30fps(1秒に30回)は、描画の速さです。ゲームの中の時刻 t は、Space を押したときだけ増えます。そのため、考えている間に時間切れで損をすることはありません。

State は、あとから書き換えられない dataclass です。x は蜂の位置、mode は蜂の様子(idle:巣で待つ、out:行き、back:帰り)です。load は蜂が運んでいる蜜、stock は花に残っている蜜、nectar は巣に届いた蜜です。apply は元の State を直接書き換えず、replace で新しい状態を作って返します。出発を指示しただけでは、時計も蜜も変わりません。

Z を押してから Space を2回押すと、蜂が花に着きます。その刻に、stock は3から2に、load は0から1に変わります。でも、nectar は最初の1のままです。次の刻から蜂は帰り始め、さらに Space を2回押すと巣に着きます。ここで初めて、nectar が2に、load が0になります。途中のどの時点でも、nectar+stock+load は4です。同じ一滴を、花と蜂の両方に残さないこと。これが、あとで作る予約の仕組みにつながります。

この段階は、教材のためだけの小さな練習です。元のゲームは、蜂が2匹、花がいくつもあり、目標は6以上です。ここでは、花の位置は2、蜜は3、目標は3です。予約、取り消し、次々に花が咲く仕組み(連鎖開花)、巣の引っ越し、Undo はありません。2往復で達成でき、達成したあとは R でやり直せます。

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

01_delivery.py / 注目する部分
if x==0:q=replace(q,x=0,mode='idle',load=0,nectar=s.nectar+s.load)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 01_delivery.py
macOS / Linux
.venv/bin/python 01_delivery.py
画面で見ることZ を押して Space を2回押すと、TICK 2・STOCK 2・LOAD 1・HIVE NECTAR 1 になります。Space をさらに2回押すと、TICK 4・LOAD 0・HIVE NECTAR 2 になります。もう1往復すると WON(成功)です。
  • 出発の指示と、時間を1刻進めることを区別する
  • 花で採った蜜と、巣に届けた蜜を、別の数に反映する
  • 保存して終了し、もう一度実行して、コードの変更を確かめる
つまずきやすいところ花に着いた瞬間に nectar も増やすと、まだ巣に帰っていない荷物を使えてしまいます。花では stock から load へ、巣では load から nectar へ、決まった一方向にだけ移します。
少し変えてみよう

TARGET = 3 だけを TARGET = 4 に変えます。保存して、Esc で終了し、もう一度実行します。2往復(8刻)では PLAYING(遊んでいる途中)、3往復(12刻)で WON・STOCK 0・HIVE NECTAR 4 になります。確かめたら3に戻します。R は読み込んである状態を最初に戻すだけで、ファイルの変更は読み直しません。

01_delivery.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(136行)
01_delivery.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.
# 
# This applies to original game source and primitive artwork. Bitmap glyph data
# is derived from Noto Sans CJK under SIL Open Font License 1.1, and is covered
# by assets/NOTO-LICENSE.txt instead of MIT.

"""蜜蜂の引っ越し・教材1/5。解説はLESSON.mdを参照。"""

from dataclasses import dataclass,replace,asdict

FLOWER_X = 2
TARGET = 3

@dataclass(frozen=True)
class State:
    t:int=0
    x:int=0
    mode:str='idle'
    load:int=0
    stock:int=3
    nectar:int=1
    outcome:str='playing'

def apply(s,a):
    if s.outcome!='playing':return s,'terminal'
    if a==('send',):
        if s.mode!='idle':return s,'busy'
        if s.stock==0:return s,'empty'
        return replace(s,mode='out'),'send'
    if a!=('tick',):return s,'invalid'
    q=replace(s,t=s.t+1)
    if s.mode=='out':
        x=s.x+1
        if x==FLOWER_X:q=replace(q,x=x,mode='back',load=1,stock=s.stock-1)
        else:q=replace(q,x=x)
    elif s.mode=='back':
        x=s.x-1
        if x==0:q=replace(q,x=0,mode='idle',load=0,nectar=s.nectar+s.load)
        else:q=replace(q,x=x)
    return replace(q,outcome='won' if q.nectar>=TARGET else 'playing'),'tick'

STAGE=1

class App:
    def __init__(self):self.reset()
    def reset(self):self.state=State();self.message='READY';self.gated=False
    def press(self,key):
        if key=='R':self.reset()
        elif key=='Z':self.state,self.message=apply(self.state,('send',))
        elif key=='SPACE':self.state,self.message=apply(self.state,('tick',))
    def control(self,pressed,held):
        if self.gated:
            if not pressed and not held:self.gated=False
            return
        if len(pressed|held)>1:self.gated=True;self.message='RELEASE ALL KEYS';return
        if len(pressed)==1:self.press(next(iter(pressed)));self.gated=True
    def draw(self,p):
        s=self.state;p.cls(1)
        p.text(12,12,'MOVING BEES / STAGE 1 / ONE BEE DELIVERY',10)
        p.text(12,32,'TICK '+str(min(s.t,9999))+('+' if s.t>9999 else '')+' / '+s.mode.upper(),7)
        p.rect(12,70,616,160,3)
        p.rect(48,182,24,25,9);p.rect(56,191,8,16,4)
        x=60+FLOWER_X*100;p.line(x,115,x,190,11);p.circ(x,115,15,10);p.circ(x,115,5,4)
        p.text(x-20,145,'STOCK '+str(s.stock),7)
        bx=60+s.x*100;p.circ(bx-5,91,5,6);p.circ(bx+5,91,5,6);p.rect(bx-7,95,15,7,10);p.line(bx,95,bx,101,0)
        for i in range(FLOWER_X+1):p.text(60+i*100,214,str(i),7)
        p.text(12,251,'HIVE NECTAR '+str(s.nectar)+' / TARGET '+str(TARGET)+' / LOAD '+str(s.load),10)
        p.text(12,278,'TOTAL '+str(s.nectar+s.stock+s.load)+' = HIVE + STOCK + LOAD',6)
        p.text(12,306,'PICKUP IS NOT DELIVERY. NECTAR ENTERS THE HIVE ON RETURN.',7)
        p.text(12,326,'ONE BEE / ONE OPEN FLOWER / NO RESERVATIONS OR MOVING HIVE.',13)
        p.text(12,386,'RESULT '+s.outcome.upper()+' / '+self.message.upper(),11 if s.outcome=='won' else 7)
        p.text(12,426,'Z: SEND / SPACE: ONE TICK / R: RESET / ESC: QUIT',7)
        p.text(12,444,'RELEASE EACH KEY BEFORE THE NEXT. TIME DOES NOT RUN BY ITSELF.',13)

def demo_keys():return ('Z','SPACE','SPACE')

def main(headless=False,screenshot=None,demo=False):
    # Pyxel may change cwd. Resolve and validate all destinations before importing it.
    from pathlib import Path
    if screenshot:
        target=Path(screenshot).resolve()
        if target.suffix.lower()!='.png' or not target.parent.is_dir() or target.exists():
            raise ValueError('use a new .png in an existing directory')
        screenshot=str(target)
    import pyxel as p
    p.init(640,464,title='Moving bees lesson '+str(STAGE),fps=30,display_scale=1,headless=headless)
    app=App();sequence=demo_keys() if demo else ()
    keys={name:getattr(p,'KEY_'+name) for name in ('LEFT','RIGHT','TAB','SPACE','Z','C','X','R','1','2','3')}
    limit=max(20,2*len(sequence)+8)
    def update():
        if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count>=limit:
            if screenshot:app.draw(p);p.screen.save(screenshot,1)
            p.quit();return
        if headless:
            if demo:
                index=p.frame_count//2
                pressed={sequence[index]} if p.frame_count%2==0 and index<len(sequence) 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))

if __name__=='__main__':
    import argparse
    from pathlib import Path
    parser=argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless-smoke',action='store_true')
    parser.add_argument('--demo-checkpoint',action='store_true')
    parser.add_argument('--screenshot')
    args=parser.parse_args()
    if args.demo_checkpoint and not args.headless_smoke:parser.error('demo requires headless mode')
    if args.screenshot and not args.headless_smoke:parser.error('screenshot requires headless mode')
    if args.screenshot:
        output=Path(args.screenshot).resolve()
        if output.suffix.lower()!='.png' or not output.parent.is_dir() or output.exists():parser.error('use a new .png in an existing directory')
        args.screenshot=str(output)
    main(args.headless_smoke,args.screenshot,args.demo_checkpoint)
STEP 02 / 05

二匹で最後の蜜を取り合わない

花の蜜の一部を予約し、出発する前だけ取り消せるようにする。

蜂を2匹、花を2輪に増やします。Tab で蜂1と蜂2を、左右キーで花aと花bを選び、Z で蜜を一滴予約します。Space を押すまでは、蜂は巣にいる reserved(予約した状態)です。予約しただけでは、時刻も stock も変わりません。花の reserved だけが増えます。画面の BOOK は、追加の蜜ではありません。STOCK のうち、もう行き先が決まっている数です。

予約できるのは、咲いていて、stock>reserved の花です。蜜が2ある花に2匹が予約すると、STOCK 2・BOOK 2 になります。まだ予約されていない分は0なので、それ以上の予約は受け付けません。蜂が1匹着くごとに、stock と reserved を1つずつ減らします。だから、2匹が同じ刻に着いても、蜜がマイナスになりません。for の各回で、直前の蜂を処理したあとの花を読むことが大切です。

C で取り消せるのは、reserved の蜂だけです。花の BOOK を1減らし、蜂を巣で待つ idle に戻します。out や back になったあとは、C も行き先の変更も busy(移動中)として受け付けません。もう reserved の蜂で別の花に Z を押しても、reserved として受け付けません。先に C を押す必要があります。受け付けなかった操作では、時刻も蜜の数も、まったく変わりません。

この段階も、教材のためだけの庭です。位置1と3に、蜜が2ずつある花があります。どちらも最初から咲いていて、目標は4です。根のリンク(花どうしのつながり)はなく、巣の引っ越しもできません。State には、次の段階で使う hive=0、spent=0、花の phase と timer があります。モデルの potential(集められる蜜の合計)は、巣の蜜、すべての花の stock、蜂の load だけを足します。reserved は足しません。

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

02_reservations.py / 注目する部分
if f.phase != 'open' or f.stock <= f.reserved:
            return (s, 'unavailable')

実行して確かめる

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

Windows
.venv\Scripts\python.exe 02_reservations.py
macOS / Linux
.venv/bin/python 02_reservations.py
画面で見ることZ→Tab→Z と押すと、花aは STOCK 2・BOOK 2、時刻は0です。Space を1回押すと、STOCK 0・BOOK 0 になり、2匹とも LOAD 1 です。巣の蜜は、まだ1です。もう1刻進めると、2匹が巣に帰り、巣の蜜は3になります。
  • stock と reserved を足し合わせない
  • 出発する前だけ、C を受け付ける
  • 同時に着いた蜂は、更新したあとの蜜の数に順番に当てはめる
つまずきやすいところstock から reserved を引いた値を、stock に保存してはいけません。予約したときと採ったときで、二重に減ってしまいます。まだ予約されていない分は、stock-reserved で、読むときに計算するだけです。
少し変えてみよう

R→Z→Tab→Z→C と押します。花aは STOCK 2・BOOK 1、蜂1は RESERVED、蜂2は IDLE、TICK 0 です。もう一度 Z→Space→C と押すと、蜂2は BACK(帰り道)なので、取り消しは BUSY になります。Space で2匹が巣に帰り、巣の蜜は3になります。

02_reservations.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(297行)
02_reservations.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.
# 
# This applies to original game source and primitive artwork. Bitmap glyph data
# is derived from Noto Sans CJK under SIL Open Font License 1.1, and is covered
# by assets/NOTO-LICENSE.txt instead of MIT.

"""蜜蜂の引っ越し・教材2/5。解説はLESSON.mdを参照。"""
"""Immutable bee ecology; reservations are commitments, not duplicated nectar."""
from dataclasses import dataclass, replace, asdict
import hashlib, json

@dataclass(frozen=True)
class FlowerDef:
    x: int
    stock: int
    awake: bool

@dataclass(frozen=True)
class Level:
    title: str
    flowers: tuple
    links: tuple
    target: int

@dataclass(frozen=True)
class Flower:
    stock: int
    reserved: int = 0
    phase: str = 'sleep'
    timer: int = 0

@dataclass(frozen=True)
class Bee:
    x: int = 0
    target: int = -1
    mode: str = 'idle'
    load: int = 0

@dataclass(frozen=True)
class State:
    t: int
    hive: int
    nectar: int
    bees: tuple
    flowers: tuple
    spent: int = 0
    outcome: str = 'playing'
LEVELS = (Level('予約の練習', (FlowerDef(1, 2, True), FlowerDef(3, 2, True)), (), 4),)
REASONS = {'ready': '花を予約して、SPACEで一刻進めよう。', 'assign': '蜜1を予約。出発前ならCで取り消せます。', 'cancel': '出発前の予約を解きました。', 'tick': '蜂が一歩進み、芽の待ち時間が減りました。', 'move': '巣の蜜1を使い、巣台へ引っ越しました。', 'busy': '飛行中の行先は変更できません。', 'unavailable': '開花中の未予約蜜が必要です。', 'reserved': '先にCで予約を取り消してください。', 'need_idle': '2匹とも未予約で巣にいるときだけ移転できます。', 'cost': '巣に運搬用の蜜1が必要です。', 'same': '別の巣台を選んでください。', 'terminal': 'Xで直前へ戻すか、Rで再開できます。', 'invalid': 'その操作はできません。', 'undo': '直前の予約または一刻へ戻しました。', 'empty': '戻せる操作がまだありません。'}

def initial(l, open_c=False):
    return State(0, 0, 1, (Bee(), Bee()), tuple((Flower(f.stock, phase='open' if f.awake or (open_c and i == 2) else 'sleep') for i, f in enumerate(l.flowers))))

def potential(s):
    return s.nectar + sum((f.stock for f in s.flowers)) + sum((b.load for b in s.bees))

def finish(s, l):
    return replace(s, outcome='won' if s.nectar >= l.target else 'lost' if potential(s) < l.target else 'playing')

def apply(s, l, a):
    if s.outcome != 'playing':
        return (s, 'terminal')
    if not isinstance(a, tuple) or not a:
        return (s, 'invalid')
    k = a[0]
    bees = list(s.bees)
    fs = list(s.flowers)
    if k == 'assign' and len(a) == 3 and (type(a[1]) == int) and (type(a[2]) == int) and (0 <= a[1] < 2) and (0 <= a[2] < len(fs)):
        bi, fi = a[1:]
        b = bees[bi]
        f = fs[fi]
        if b.mode == 'reserved':
            return (s, 'reserved')
        if b.mode != 'idle':
            return (s, 'busy')
        if f.phase != 'open' or f.stock <= f.reserved:
            return (s, 'unavailable')
        bees[bi] = replace(b, target=fi, mode='reserved')
        fs[fi] = replace(f, reserved=f.reserved + 1)
        return (replace(s, bees=tuple(bees), flowers=tuple(fs)), k)
    if k == 'cancel' and len(a) == 2 and (type(a[1]) == int) and (0 <= a[1] < 2):
        b = bees[a[1]]
        if b.mode != 'reserved':
            return (s, 'busy')
        fs[b.target] = replace(fs[b.target], reserved=fs[b.target].reserved - 1)
        bees[a[1]] = Bee(s.hive)
        return (replace(s, bees=tuple(bees), flowers=tuple(fs)), k)
    if a != ('tick',):
        return (s, 'invalid')
    fs = [replace(f, timer=f.timer - 1, phase='open' if f.timer == 1 else 'wait') if f.phase == 'wait' else f for f in fs]
    depleted = []
    nectar = s.nectar
    for i, b in enumerate(bees):
        if b.mode in ('reserved', 'out'):
            dest = l.flowers[b.target].x
            x = b.x + (dest > b.x) - (dest < b.x)
            if x == dest:
                f = fs[b.target]
                fs[b.target] = replace(f, stock=f.stock - 1, reserved=f.reserved - 1, phase='spent' if f.stock == 1 else 'open')
                bees[i] = Bee(x, b.target, 'back', 1)
                if f.stock == 1:
                    depleted.append(b.target)
            else:
                bees[i] = replace(b, x=x, mode='out')
        elif b.mode == 'back':
            x = b.x + (s.hive > b.x) - (s.hive < b.x)
            if x == s.hive:
                nectar += b.load
                bees[i] = Bee(s.hive)
            else:
                bees[i] = replace(b, x=x)
    return (finish(replace(s, t=s.t + 1, nectar=nectar, bees=tuple(bees), flowers=tuple(fs)), l), 'tick')

def invariant(s, l):
    if not (s.t >= 0 and s.hive in (0, 3, 6) and (s.nectar >= 0) and (s.spent >= 0) and (potential(s) + s.spent == 1 + sum((f.stock for f in l.flowers)))):
        return False
    for i, f in enumerate(s.flowers):
        if not 0 <= f.reserved <= f.stock <= l.flowers[i].stock:
            return False
        if f.reserved != sum((b.target == i and b.mode in ('out', 'reserved') for b in s.bees)):
            return False
        if f.phase not in ('open', 'sleep', 'wait', 'spent') or (f.phase == 'wait') != (f.timer > 0) or (f.phase == 'spent') != (f.stock == 0):
            return False
    for b in s.bees:
        if not 0 <= b.x <= 6 or b.mode not in ('idle', 'reserved', 'out', 'back') or b.load != (1 if b.mode == 'back' else 0):
            return False
        if b.mode in ('idle', 'reserved') and b.x != s.hive:
            return False
        if b.mode == 'idle' and b.target != -1:
            return False
    return True

def state_hash(s):
    return hashlib.sha256(json.dumps(asdict(s), sort_keys=True).encode('utf-8')).hexdigest()

STAGE = 2
STAGE_NAMES = ('ONE BEE DELIVERY', 'TWO BEES / RESERVATIONS', 'LINKED BLOOMING', 'MOVING HIVE / CONSERVATION', 'UNDO / FORECAST')

class App:
    def __init__(self):
        self.level=0
        self.reset()

    @property
    def board(self):
        return LEVELS[self.level]

    @property
    def state(self):
        return self.timeline.state if STAGE==5 else self.current

    def reset(self):
        if STAGE==5:self.timeline=Timeline(initial(self.board))
        else:self.current=initial(self.board)
        self.selection=0
        self.target=0
        self.platform=1
        self.message='READY'
        self.gated=False

    def selected(self):
        if self.selection==2:return ('move',(0,3,6)[self.platform])
        return ('assign',self.selection,self.target)

    def action(self,a):
        if STAGE==5:self.timeline,self.message=step(self.timeline,self.board,a)
        else:self.current,self.message=apply(self.current,self.board,a)

    def press(self,key):
        if key=='R':self.reset();return
        if STAGE>=3 and key in ('1','2','3'):
            self.level=int(key)-1;self.reset();return
        if STAGE==5 and key=='X':self.action(('undo',));return
        if self.state.outcome!='playing':return
        if key=='TAB':self.selection=(self.selection+1)%(3 if STAGE>=4 else 2)
        if key in ('LEFT','RIGHT'):
            delta=1 if key=='RIGHT' else -1
            if self.selection<2:self.target=(self.target+delta)%len(self.board.flowers)
            else:self.platform=(self.platform+delta)%3
        if key=='Z':self.action(self.selected())
        if key=='C' and self.selection<2:self.action(('cancel',self.selection))
        if key=='SPACE':self.action(('tick',))

    def control(self,pressed,held):
        # 一度押したら全キーの解放を待つ。長押しで世界を進めない。
        if self.gated:
            if not pressed and not held:self.gated=False
            return
        if len(pressed|held)>1:
            self.message='RELEASE ALL KEYS';self.gated=True;return
        if len(pressed)==1:
            self.press(next(iter(pressed)));self.gated=True

    def draw(self,p):
        s=self.state;l=self.board;p.cls(1)
        p.text(12,12,'MOVING BEES / STAGE '+str(STAGE)+' / '+STAGE_NAMES[STAGE-1],10)
        p.text(12,30,'GARDEN '+('ABC'[self.level] if STAGE>=3 else 'PRACTICE')+'  TICK '+str(min(s.t,9999))+('+' if s.t>9999 else ''),7)
        p.rect(12,52,616,169,3)
        for a,b in l.links:
            p.line(52+l.flowers[a].x*88,177,52+l.flowers[b].x*88,177,4)
            for j in (a,b):p.line(52+l.flowers[j].x*88,159,52+l.flowers[j].x*88,177,4)
        for x in range(7):p.text(50+x*88,205,str(x),7)
        for x in (0,3,6):p.rectb(41+x*88,187,23,10,9)
        hx=52+s.hive*88;p.rect(hx-10,180,21,18,9);p.rect(hx-3,188,7,10,4)
        for i,(f,d) in enumerate(zip(s.flowers,l.flowers)):
            x=52+d.x*88;c=10 if f.phase=='open' else (11 if f.phase=='wait' else 5)
            p.line(x,123,x,166,11);p.circ(x,120,12,c);p.circ(x,120,4,4)
            p.text(x-3,95,chr(97+i),7)
            p.text(x-22,141,'STOCK '+str(f.stock),7)
            p.text(x-22,152,'BOOK '+str(f.reserved),7)
            p.text(x-22,164,f.phase.upper()+(' '+str(f.timer) if f.timer else ''),c if f.phase in ('open','wait') else 7)
            if self.selection<2 and i==self.target:p.rectb(x-28,88,58,92,10)
        for i,b in enumerate(s.bees):
            x=52+b.x*88;y=64+i*14
            p.circ(x-4,y-2,3,6);p.circ(x+4,y-2,3,6);p.rect(x-5,y,11,5,10);p.line(x,y,x,y+4,0)
            p.text(x+8,y,str(i+1)+('+' if b.load else ''),7)
        p.text(12,235,'HIVE NECTAR '+str(s.nectar)+' / TARGET '+str(l.target)+' / SPENT '+str(s.spent),10)
        p.text(12,251,'POTENTIAL '+str(potential(s))+' = HIVE + FLOWER STOCK (SLEEP INCLUDED) + LOAD',6)
        p.text(12,266,'BOOK IS PART OF STOCK. POTENTIAL IS NOT A REACHABILITY PROMISE.',13)
        for i,b in enumerate(s.bees):
            p.text(12,287+i*17,'BEE '+str(i+1)+' '+b.mode.upper()+' / X '+str(b.x)+' / LOAD '+str(b.load),11 if self.selection==i else 7)
        p.text(12,328,'SELECT '+('BEE '+str(self.selection+1)+' / FLOWER '+chr(97+self.target) if self.selection<2 else 'HIVE / PLATFORM '+str((0,3,6)[self.platform])),10)
        if STAGE==5:
            q,reason=forecast(s,l,self.selected())
            p.text(12,348,'NEXT Z: '+reason.upper()+' / TICK '+str(min(q.t,9999))+' / NECTAR '+str(q.nectar)+' / '+q.outcome.upper(),6)
            p.text(12,365,'HISTORY '+str(len(self.timeline.history))+' / FORECAST NEVER COMMITS',6)
        p.text(12,386,'RESULT '+s.outcome.upper()+' / '+self.message.upper(),11 if s.outcome=='won' else 7)
        p.text(12,410,'TAB: SELECT / LEFT-RIGHT: FLOWER OR PLATFORM / Z: BOOK OR MOVE' if STAGE>=4 else 'TAB: SELECT BEE / LEFT-RIGHT: FLOWER / Z: BOOK',7)
        p.text(12,427,'SPACE: ONE TICK / C: CANCEL BEFORE DEPARTURE / R: RESET / ESC: QUIT',7)
        p.text(12,444,('1-3: GARDEN / ' if STAGE>=3 else 'PRACTICE: ALL FLOWERS OPEN / NO LINKS / ')+('X: UNDO' if STAGE==5 else 'NO UNDO YET'),13)


def demo_keys():
    if STAGE==2:return ('Z','TAB','Z','SPACE','SPACE')
    if STAGE==3:return ('Z','TAB','Z','SPACE','SPACE','SPACE')
    return ('Z','TAB','Z','SPACE','SPACE','TAB','Z')

def main(headless=False,screenshot=None,demo=False):
    # Pyxel may change cwd. Resolve and validate all destinations before importing it.
    from pathlib import Path
    if screenshot:
        target=Path(screenshot).resolve()
        if target.suffix.lower()!='.png' or not target.parent.is_dir() or target.exists():
            raise ValueError('use a new .png in an existing directory')
        screenshot=str(target)
    import pyxel as p
    p.init(640,464,title='Moving bees lesson '+str(STAGE),fps=30,display_scale=1,headless=headless)
    app=App();sequence=demo_keys() if demo else ()
    keys={name:getattr(p,'KEY_'+name) for name in ('LEFT','RIGHT','TAB','SPACE','Z','C','X','R','1','2','3')}
    limit=max(20,2*len(sequence)+8)
    def update():
        if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count>=limit:
            if screenshot:app.draw(p);p.screen.save(screenshot,1)
            p.quit();return
        if headless:
            if demo:
                index=p.frame_count//2
                pressed={sequence[index]} if p.frame_count%2==0 and index<len(sequence) 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))

if __name__=='__main__':
    import argparse
    from pathlib import Path
    parser=argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless-smoke',action='store_true')
    parser.add_argument('--demo-checkpoint',action='store_true')
    parser.add_argument('--screenshot')
    args=parser.parse_args()
    if args.demo_checkpoint and not args.headless_smoke:parser.error('demo requires headless mode')
    if args.screenshot and not args.headless_smoke:parser.error('screenshot requires headless mode')
    if args.screenshot:
        output=Path(args.screenshot).resolve()
        if output.suffix.lower()!='.png' or not output.parent.is_dir() or output.exists():parser.error('use a new .png in an existing directory')
        args.screenshot=str(output)
    main(args.headless_smoke,args.screenshot,args.demo_checkpoint)
STEP 03 / 05

採り切った、その二刻後に咲く

もう動いているタイマーの更新と、新しく起きたできごとの順番。

元のゲームの3つの庭と、花どうしのリンクを加えます。1・2・3で庭A・B・Cを切り替えると、その庭は最初の状態になります。最初に予約できるのは、はじめから咲いている花だけです。sleep は眠っている花、wait 2・1は咲くのを待っている花、open は蜜を採れる花、spent は蜜が0になった花です。花の蜜は、いくらでも元に戻るわけではありません。

花の最後の一滴を採ったら、その花の番号を depleted(採り切った花)に入れます。蜂の移動を処理したあとで、その花のリンク先を探します。そして、sleep の相手だけを、wait・timer 2に変えます。1刻の始めに、もともと wait だった花のタイマーを先に減らしておきます。なので、新しく始まった timer 2を、同じ刻に1へ減らすことはありません。採り切った刻の次の刻で1、その次の刻で0になり、open になります。採り切ったあと、ちょうど2回の tick で咲きます。

リンクは両方向に調べますが、起こすのは sleep の相手だけです。2つの花から同時に起こされる芽も、先に処理したほうで wait になったら、もう対象になりません。すでに待っている花の時計を、2に戻すことはありません。open や spent の花も、起こし直しません。1度だけ切り替わるこの仕組みのおかげで、連鎖して咲く動きが安定します。

ふつうの庭Aでは、2匹を花aへ送り、1刻で蜜2を採り切ります。その瞬間、花bは WAIT 2 で、まだ予約できません。次の刻で蜂が帰り、花bは WAIT 1 になります。さらに次の刻で OPEN です。この段階では、まだ巣の引っ越しは受け付けません。3つの庭の、花の咲き方・予約・採集のデータは、元のゲームと同じです。引っ越しをしないで目標に届く遊び方も残っています。

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

03_blooming.py / 注目する部分
if target >= 0 and fs[target].phase == 'sleep':
                fs[target] = replace(fs[target], phase='wait', timer=2)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 03_blooming.py
macOS / Linux
.venv/bin/python 03_blooming.py
画面で見ること庭Aで Z→Tab→Z→Space と押します。花aは SPENT、花bは WAIT 2、巣の蜜は1です。Space で WAIT 1・巣の蜜3、さらに Space で OPEN・TICK 3 になります。
  • 待ち時間2を作った刻には、それを減らさない
  • sleep の花だけを、1度だけ起こす
  • 巣に帰ることと花が咲くことは、同じ tick でも別の更新
つまずきやすいところ蜂を処理したあとで全部のタイマーを減らすと、新しく作った2まで同じ刻に減って、1刻早く咲いてしまいます。正しい順番は、もとからある wait のタイマーを減らす→蜂の移動と採り切り→sleep の花への知らせ、です。
少し変えてみよう

R→Z→Tab→Z→Space→右→Z と押して、花bを予約しようとします。蜂2は BACK(帰り道)なので、BUSY になります。Space で巣に帰ったあと、もう一度 Z を押しても、花bは WAIT 1 なので UNAVAILABLE(予約できない)です。さらに Space→Z と押すと、ここで初めて花bを予約できます。予約しても、時刻は3のままです。

03_blooming.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(302行)
03_blooming.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.
# 
# This applies to original game source and primitive artwork. Bitmap glyph data
# is derived from Noto Sans CJK under SIL Open Font License 1.1, and is covered
# by assets/NOTO-LICENSE.txt instead of MIT.

"""蜜蜂の引っ越し・教材3/5。解説はLESSON.mdを参照。"""
"""Immutable bee ecology; reservations are commitments, not duplicated nectar."""
from dataclasses import dataclass, replace, asdict
import hashlib, json

@dataclass(frozen=True)
class FlowerDef:
    x: int
    stock: int
    awake: bool

@dataclass(frozen=True)
class Level:
    title: str
    flowers: tuple
    links: tuple
    target: int

@dataclass(frozen=True)
class Flower:
    stock: int
    reserved: int = 0
    phase: str = 'sleep'
    timer: int = 0

@dataclass(frozen=True)
class Bee:
    x: int = 0
    target: int = -1
    mode: str = 'idle'
    load: int = 0

@dataclass(frozen=True)
class State:
    t: int
    hive: int
    nectar: int
    bees: tuple
    flowers: tuple
    spent: int = 0
    outcome: str = 'playing'
LEVELS = (Level('隣の芽', (FlowerDef(1, 2, True), FlowerDef(3, 3, False), FlowerDef(5, 3, False)), ((0, 1), (1, 2)), 6), Level('少ない花を先に', (FlowerDef(1, 3, True), FlowerDef(2, 1, True), FlowerDef(4, 4, False)), ((1, 2),), 6), Level('両端の庭', (FlowerDef(1, 2, True), FlowerDef(2, 3, False), FlowerDef(5, 2, True), FlowerDef(4, 3, False)), ((0, 1), (2, 3)), 8))
REASONS = {'ready': '花を予約して、SPACEで一刻進めよう。', 'assign': '蜜1を予約。出発前ならCで取り消せます。', 'cancel': '出発前の予約を解きました。', 'tick': '蜂が一歩進み、芽の待ち時間が減りました。', 'move': '巣の蜜1を使い、巣台へ引っ越しました。', 'busy': '飛行中の行先は変更できません。', 'unavailable': '開花中の未予約蜜が必要です。', 'reserved': '先にCで予約を取り消してください。', 'need_idle': '2匹とも未予約で巣にいるときだけ移転できます。', 'cost': '巣に運搬用の蜜1が必要です。', 'same': '別の巣台を選んでください。', 'terminal': 'Xで直前へ戻すか、Rで再開できます。', 'invalid': 'その操作はできません。', 'undo': '直前の予約または一刻へ戻しました。', 'empty': '戻せる操作がまだありません。'}

def initial(l, open_c=False):
    return State(0, 0, 1, (Bee(), Bee()), tuple((Flower(f.stock, phase='open' if f.awake or (open_c and i == 2) else 'sleep') for i, f in enumerate(l.flowers))))

def potential(s):
    return s.nectar + sum((f.stock for f in s.flowers)) + sum((b.load for b in s.bees))

def finish(s, l):
    return replace(s, outcome='won' if s.nectar >= l.target else 'lost' if potential(s) < l.target else 'playing')

def apply(s, l, a):
    if s.outcome != 'playing':
        return (s, 'terminal')
    if not isinstance(a, tuple) or not a:
        return (s, 'invalid')
    k = a[0]
    bees = list(s.bees)
    fs = list(s.flowers)
    if k == 'assign' and len(a) == 3 and (type(a[1]) == int) and (type(a[2]) == int) and (0 <= a[1] < 2) and (0 <= a[2] < len(fs)):
        bi, fi = a[1:]
        b = bees[bi]
        f = fs[fi]
        if b.mode == 'reserved':
            return (s, 'reserved')
        if b.mode != 'idle':
            return (s, 'busy')
        if f.phase != 'open' or f.stock <= f.reserved:
            return (s, 'unavailable')
        bees[bi] = replace(b, target=fi, mode='reserved')
        fs[fi] = replace(f, reserved=f.reserved + 1)
        return (replace(s, bees=tuple(bees), flowers=tuple(fs)), k)
    if k == 'cancel' and len(a) == 2 and (type(a[1]) == int) and (0 <= a[1] < 2):
        b = bees[a[1]]
        if b.mode != 'reserved':
            return (s, 'busy')
        fs[b.target] = replace(fs[b.target], reserved=fs[b.target].reserved - 1)
        bees[a[1]] = Bee(s.hive)
        return (replace(s, bees=tuple(bees), flowers=tuple(fs)), k)
    if a != ('tick',):
        return (s, 'invalid')
    fs = [replace(f, timer=f.timer - 1, phase='open' if f.timer == 1 else 'wait') if f.phase == 'wait' else f for f in fs]
    depleted = []
    nectar = s.nectar
    for i, b in enumerate(bees):
        if b.mode in ('reserved', 'out'):
            dest = l.flowers[b.target].x
            x = b.x + (dest > b.x) - (dest < b.x)
            if x == dest:
                f = fs[b.target]
                fs[b.target] = replace(f, stock=f.stock - 1, reserved=f.reserved - 1, phase='spent' if f.stock == 1 else 'open')
                bees[i] = Bee(x, b.target, 'back', 1)
                if f.stock == 1:
                    depleted.append(b.target)
            else:
                bees[i] = replace(b, x=x, mode='out')
        elif b.mode == 'back':
            x = b.x + (s.hive > b.x) - (s.hive < b.x)
            if x == s.hive:
                nectar += b.load
                bees[i] = Bee(s.hive)
            else:
                bees[i] = replace(b, x=x)
    for source in depleted:
        for a, b in l.links:
            target = b if a == source else a if b == source else -1
            if target >= 0 and fs[target].phase == 'sleep':
                fs[target] = replace(fs[target], phase='wait', timer=2)
    return (finish(replace(s, t=s.t + 1, nectar=nectar, bees=tuple(bees), flowers=tuple(fs)), l), 'tick')

def invariant(s, l):
    if not (s.t >= 0 and s.hive in (0, 3, 6) and (s.nectar >= 0) and (s.spent >= 0) and (potential(s) + s.spent == 1 + sum((f.stock for f in l.flowers)))):
        return False
    for i, f in enumerate(s.flowers):
        if not 0 <= f.reserved <= f.stock <= l.flowers[i].stock:
            return False
        if f.reserved != sum((b.target == i and b.mode in ('out', 'reserved') for b in s.bees)):
            return False
        if f.phase not in ('open', 'sleep', 'wait', 'spent') or (f.phase == 'wait') != (f.timer > 0) or (f.phase == 'spent') != (f.stock == 0):
            return False
    for b in s.bees:
        if not 0 <= b.x <= 6 or b.mode not in ('idle', 'reserved', 'out', 'back') or b.load != (1 if b.mode == 'back' else 0):
            return False
        if b.mode in ('idle', 'reserved') and b.x != s.hive:
            return False
        if b.mode == 'idle' and b.target != -1:
            return False
    return True

def state_hash(s):
    return hashlib.sha256(json.dumps(asdict(s), sort_keys=True).encode('utf-8')).hexdigest()

STAGE = 3
STAGE_NAMES = ('ONE BEE DELIVERY', 'TWO BEES / RESERVATIONS', 'LINKED BLOOMING', 'MOVING HIVE / CONSERVATION', 'UNDO / FORECAST')

class App:
    def __init__(self):
        self.level=0
        self.reset()

    @property
    def board(self):
        return LEVELS[self.level]

    @property
    def state(self):
        return self.timeline.state if STAGE==5 else self.current

    def reset(self):
        if STAGE==5:self.timeline=Timeline(initial(self.board))
        else:self.current=initial(self.board)
        self.selection=0
        self.target=0
        self.platform=1
        self.message='READY'
        self.gated=False

    def selected(self):
        if self.selection==2:return ('move',(0,3,6)[self.platform])
        return ('assign',self.selection,self.target)

    def action(self,a):
        if STAGE==5:self.timeline,self.message=step(self.timeline,self.board,a)
        else:self.current,self.message=apply(self.current,self.board,a)

    def press(self,key):
        if key=='R':self.reset();return
        if STAGE>=3 and key in ('1','2','3'):
            self.level=int(key)-1;self.reset();return
        if STAGE==5 and key=='X':self.action(('undo',));return
        if self.state.outcome!='playing':return
        if key=='TAB':self.selection=(self.selection+1)%(3 if STAGE>=4 else 2)
        if key in ('LEFT','RIGHT'):
            delta=1 if key=='RIGHT' else -1
            if self.selection<2:self.target=(self.target+delta)%len(self.board.flowers)
            else:self.platform=(self.platform+delta)%3
        if key=='Z':self.action(self.selected())
        if key=='C' and self.selection<2:self.action(('cancel',self.selection))
        if key=='SPACE':self.action(('tick',))

    def control(self,pressed,held):
        # 一度押したら全キーの解放を待つ。長押しで世界を進めない。
        if self.gated:
            if not pressed and not held:self.gated=False
            return
        if len(pressed|held)>1:
            self.message='RELEASE ALL KEYS';self.gated=True;return
        if len(pressed)==1:
            self.press(next(iter(pressed)));self.gated=True

    def draw(self,p):
        s=self.state;l=self.board;p.cls(1)
        p.text(12,12,'MOVING BEES / STAGE '+str(STAGE)+' / '+STAGE_NAMES[STAGE-1],10)
        p.text(12,30,'GARDEN '+('ABC'[self.level] if STAGE>=3 else 'PRACTICE')+'  TICK '+str(min(s.t,9999))+('+' if s.t>9999 else ''),7)
        p.rect(12,52,616,169,3)
        for a,b in l.links:
            p.line(52+l.flowers[a].x*88,177,52+l.flowers[b].x*88,177,4)
            for j in (a,b):p.line(52+l.flowers[j].x*88,159,52+l.flowers[j].x*88,177,4)
        for x in range(7):p.text(50+x*88,205,str(x),7)
        for x in (0,3,6):p.rectb(41+x*88,187,23,10,9)
        hx=52+s.hive*88;p.rect(hx-10,180,21,18,9);p.rect(hx-3,188,7,10,4)
        for i,(f,d) in enumerate(zip(s.flowers,l.flowers)):
            x=52+d.x*88;c=10 if f.phase=='open' else (11 if f.phase=='wait' else 5)
            p.line(x,123,x,166,11);p.circ(x,120,12,c);p.circ(x,120,4,4)
            p.text(x-3,95,chr(97+i),7)
            p.text(x-22,141,'STOCK '+str(f.stock),7)
            p.text(x-22,152,'BOOK '+str(f.reserved),7)
            p.text(x-22,164,f.phase.upper()+(' '+str(f.timer) if f.timer else ''),c if f.phase in ('open','wait') else 7)
            if self.selection<2 and i==self.target:p.rectb(x-28,88,58,92,10)
        for i,b in enumerate(s.bees):
            x=52+b.x*88;y=64+i*14
            p.circ(x-4,y-2,3,6);p.circ(x+4,y-2,3,6);p.rect(x-5,y,11,5,10);p.line(x,y,x,y+4,0)
            p.text(x+8,y,str(i+1)+('+' if b.load else ''),7)
        p.text(12,235,'HIVE NECTAR '+str(s.nectar)+' / TARGET '+str(l.target)+' / SPENT '+str(s.spent),10)
        p.text(12,251,'POTENTIAL '+str(potential(s))+' = HIVE + FLOWER STOCK (SLEEP INCLUDED) + LOAD',6)
        p.text(12,266,'BOOK IS PART OF STOCK. POTENTIAL IS NOT A REACHABILITY PROMISE.',13)
        for i,b in enumerate(s.bees):
            p.text(12,287+i*17,'BEE '+str(i+1)+' '+b.mode.upper()+' / X '+str(b.x)+' / LOAD '+str(b.load),11 if self.selection==i else 7)
        p.text(12,328,'SELECT '+('BEE '+str(self.selection+1)+' / FLOWER '+chr(97+self.target) if self.selection<2 else 'HIVE / PLATFORM '+str((0,3,6)[self.platform])),10)
        if STAGE==5:
            q,reason=forecast(s,l,self.selected())
            p.text(12,348,'NEXT Z: '+reason.upper()+' / TICK '+str(min(q.t,9999))+' / NECTAR '+str(q.nectar)+' / '+q.outcome.upper(),6)
            p.text(12,365,'HISTORY '+str(len(self.timeline.history))+' / FORECAST NEVER COMMITS',6)
        p.text(12,386,'RESULT '+s.outcome.upper()+' / '+self.message.upper(),11 if s.outcome=='won' else 7)
        p.text(12,410,'TAB: SELECT / LEFT-RIGHT: FLOWER OR PLATFORM / Z: BOOK OR MOVE' if STAGE>=4 else 'TAB: SELECT BEE / LEFT-RIGHT: FLOWER / Z: BOOK',7)
        p.text(12,427,'SPACE: ONE TICK / C: CANCEL BEFORE DEPARTURE / R: RESET / ESC: QUIT',7)
        p.text(12,444,('1-3: GARDEN / ' if STAGE>=3 else 'PRACTICE: ALL FLOWERS OPEN / NO LINKS / ')+('X: UNDO' if STAGE==5 else 'NO UNDO YET'),13)


def demo_keys():
    if STAGE==2:return ('Z','TAB','Z','SPACE','SPACE')
    if STAGE==3:return ('Z','TAB','Z','SPACE','SPACE','SPACE')
    return ('Z','TAB','Z','SPACE','SPACE','TAB','Z')

def main(headless=False,screenshot=None,demo=False):
    # Pyxel may change cwd. Resolve and validate all destinations before importing it.
    from pathlib import Path
    if screenshot:
        target=Path(screenshot).resolve()
        if target.suffix.lower()!='.png' or not target.parent.is_dir() or target.exists():
            raise ValueError('use a new .png in an existing directory')
        screenshot=str(target)
    import pyxel as p
    p.init(640,464,title='Moving bees lesson '+str(STAGE),fps=30,display_scale=1,headless=headless)
    app=App();sequence=demo_keys() if demo else ()
    keys={name:getattr(p,'KEY_'+name) for name in ('LEFT','RIGHT','TAB','SPACE','Z','C','X','R','1','2','3')}
    limit=max(20,2*len(sequence)+8)
    def update():
        if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count>=limit:
            if screenshot:app.draw(p);p.screen.save(screenshot,1)
            p.quit();return
        if headless:
            if demo:
                index=p.frame_count//2
                pressed={sequence[index]} if p.frame_count%2==0 and index<len(sequence) 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))

if __name__=='__main__':
    import argparse
    from pathlib import Path
    parser=argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless-smoke',action='store_true')
    parser.add_argument('--demo-checkpoint',action='store_true')
    parser.add_argument('--screenshot')
    args=parser.parse_args()
    if args.demo_checkpoint and not args.headless_smoke:parser.error('demo requires headless mode')
    if args.screenshot and not args.headless_smoke:parser.error('screenshot requires headless mode')
    if args.screenshot:
        output=Path(args.screenshot).resolve()
        if output.suffix.lower()!='.png' or not output.parent.is_dir() or output.exists():parser.error('use a new .png in an existing directory')
        args.screenshot=str(output)
    main(args.headless_smoke,args.screenshot,args.demo_checkpoint)
STEP 04 / 05

一滴を使って、巣を近づける

巣の引っ越しの条件、時間と蜜の費用、蜜の保存則(合計が変わらない決まり)。

Tab で選べるものの3番目に、HIVE(巣)が加わります。左右キーで巣台0・3・6を選び、Z で引っ越します。引っ越せる条件は3つです。2匹とも予約をしていない idle で巣にいること、今とちがう巣台であること、巣に払うための蜜が1あることです。飛んでいる途中や予約中なら need_idle、同じ場所なら same、巣の蜜が0なら cost として断ります。運んでいる途中の load は、支払いに使いません。

1回の引っ越しで、nectar を1減らし、spent を1増やし、t を1進めます。そして、2匹と巣の位置を一緒に新しい巣台へ移します。この1刻でも、wait のタイマーは減ります。庭Aで採り切って巣に帰った直後は、花bは WAIT 1 です。そのため、次に Space ではなく引っ越しをすると、花bは OPEN になります。引っ越しの間だけ花の時計が止まる、という特別な扱いはしません。

保存則は、「potential(s)+spent = 最初の巣の蜜1 + すべての花の最初の蜜」です。potential は、nectar、すべての花の stock、すべての蜂の load を足したものです。予約は足しません。眠っている花の蜜は含めます。これが目標より少なければ、残りを全部運べても足りないので、lost(失敗)です。反対に、目標以上だからといって、必ず目標に届くとは限りません。リンクのない眠った花などを置いた自由なデータでは、いつまでも採れない蜜も含まれるからです。

成功になるのは、巣の nectar が target 以上になったときだけです。蜂が持っている蜜や、眠っている花の蜜で、先に成功にすることはしません。元のゲームのふつうのモデルと3つの庭のデータを使いますが、Undo と予告の画面はまだありません。引っ越しをくり返すと、蜜が足りなくなります。R でやり直して、引っ越しをしない場合の結果と比べましょう。速さと使う蜜の量を比べるもので、引っ越しだけが正解というわけではありません。

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

04_moving_hive.py / 注目する部分
def potential(s):
    return s.nectar + sum((f.stock for f in s.flowers)) + sum((b.load for b in s.bees))

実行して確かめる

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

Windows
.venv\Scripts\python.exe 04_moving_hive.py
macOS / Linux
.venv/bin/python 04_moving_hive.py
画面で見ること庭Aで Z→Tab→Z→Space→Space→Tab→Z と押します。TICK 3・巣台3・巣の蜜2・SPENT 1 で、花bは OPEN です。POTENTIAL 8 に SPENT 1 を足すと、最初の合計の9になります。
  • 2匹とも idle であることを確かめる
  • 引っ越しの1刻でも、花の時計を進める
  • 保存則と、「必ず目標に届く」という保証を混同しない
つまずきやすいところ巣の場所だけを変えて、idle の蜂を古い巣に残してはいけません。元のゲームでは、2匹とも、新しい巣の位置にいる Bee に置き換えます。
少し変えてみよう

exercises/04_cost.py を実行します。元のゲームの庭Aで、実際にたどり着く状態から、4回引っ越す例です。最後の行は lost 1 4 5(結果、巣の蜜、使った蜜、potential)です。COUNT = 4 を3に変えて、保存して終了し、もう一度実行すると、playing 2 3 6 になります。potential が目標の6と同じときは、すぐには失敗にしません。確かめたら4に戻します。

04_moving_hive.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(349行)
04_moving_hive.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.
# 
# This applies to original game source and primitive artwork. Bitmap glyph data
# is derived from Noto Sans CJK under SIL Open Font License 1.1, and is covered
# by assets/NOTO-LICENSE.txt instead of MIT.

"""蜜蜂の引っ越し・教材4/5。解説はLESSON.mdを参照。"""
"""Immutable bee ecology; reservations are commitments, not duplicated nectar."""
from dataclasses import dataclass, replace, asdict
import hashlib, json

@dataclass(frozen=True)
class FlowerDef:
    x: int
    stock: int
    awake: bool

@dataclass(frozen=True)
class Level:
    title: str
    flowers: tuple
    links: tuple
    target: int

@dataclass(frozen=True)
class Flower:
    stock: int
    reserved: int = 0
    phase: str = 'sleep'
    timer: int = 0

@dataclass(frozen=True)
class Bee:
    x: int = 0
    target: int = -1
    mode: str = 'idle'
    load: int = 0

@dataclass(frozen=True)
class State:
    t: int
    hive: int
    nectar: int
    bees: tuple
    flowers: tuple
    spent: int = 0
    outcome: str = 'playing'
LEVELS = (Level('隣の芽', (FlowerDef(1, 2, True), FlowerDef(3, 3, False), FlowerDef(5, 3, False)), ((0, 1), (1, 2)), 6), Level('少ない花を先に', (FlowerDef(1, 3, True), FlowerDef(2, 1, True), FlowerDef(4, 4, False)), ((1, 2),), 6), Level('両端の庭', (FlowerDef(1, 2, True), FlowerDef(2, 3, False), FlowerDef(5, 2, True), FlowerDef(4, 3, False)), ((0, 1), (2, 3)), 8))
REASONS = {'ready': '花を予約して、SPACEで一刻進めよう。', 'assign': '蜜1を予約。出発前ならCで取り消せます。', 'cancel': '出発前の予約を解きました。', 'tick': '蜂が一歩進み、芽の待ち時間が減りました。', 'move': '巣の蜜1を使い、巣台へ引っ越しました。', 'busy': '飛行中の行先は変更できません。', 'unavailable': '開花中の未予約蜜が必要です。', 'reserved': '先にCで予約を取り消してください。', 'need_idle': '2匹とも未予約で巣にいるときだけ移転できます。', 'cost': '巣に運搬用の蜜1が必要です。', 'same': '別の巣台を選んでください。', 'terminal': 'Xで直前へ戻すか、Rで再開できます。', 'invalid': 'その操作はできません。', 'undo': '直前の予約または一刻へ戻しました。', 'empty': '戻せる操作がまだありません。'}

def initial(l, open_c=False):
    return State(0, 0, 1, (Bee(), Bee()), tuple((Flower(f.stock, phase='open' if f.awake or (open_c and i == 2) else 'sleep') for i, f in enumerate(l.flowers))))

def potential(s):
    return s.nectar + sum((f.stock for f in s.flowers)) + sum((b.load for b in s.bees))

def finish(s, l):
    return replace(s, outcome='won' if s.nectar >= l.target else 'lost' if potential(s) < l.target else 'playing')

def apply(s, l, a):
    if s.outcome != 'playing':
        return (s, 'terminal')
    if not isinstance(a, tuple) or not a:
        return (s, 'invalid')
    k = a[0]
    bees = list(s.bees)
    fs = list(s.flowers)
    if k == 'assign' and len(a) == 3 and (type(a[1]) == int) and (type(a[2]) == int) and (0 <= a[1] < 2) and (0 <= a[2] < len(fs)):
        bi, fi = a[1:]
        b = bees[bi]
        f = fs[fi]
        if b.mode == 'reserved':
            return (s, 'reserved')
        if b.mode != 'idle':
            return (s, 'busy')
        if f.phase != 'open' or f.stock <= f.reserved:
            return (s, 'unavailable')
        bees[bi] = replace(b, target=fi, mode='reserved')
        fs[fi] = replace(f, reserved=f.reserved + 1)
        return (replace(s, bees=tuple(bees), flowers=tuple(fs)), k)
    if k == 'cancel' and len(a) == 2 and (type(a[1]) == int) and (0 <= a[1] < 2):
        b = bees[a[1]]
        if b.mode != 'reserved':
            return (s, 'busy')
        fs[b.target] = replace(fs[b.target], reserved=fs[b.target].reserved - 1)
        bees[a[1]] = Bee(s.hive)
        return (replace(s, bees=tuple(bees), flowers=tuple(fs)), k)
    if k == 'move' and len(a) == 2 and (type(a[1]) == int) and (a[1] in (0, 3, 6)):
        if any((b.mode != 'idle' for b in bees)):
            return (s, 'need_idle')
        if a[1] == s.hive:
            return (s, 'same')
        if s.nectar < 1:
            return (s, 'cost')
        fs = tuple((replace(f, timer=f.timer - 1, phase='open' if f.timer == 1 else 'wait') if f.phase == 'wait' else f for f in fs))
        return (finish(replace(s, t=s.t + 1, hive=a[1], nectar=s.nectar - 1, spent=s.spent + 1, bees=(Bee(a[1]), Bee(a[1])), flowers=fs), l), k)
    if a != ('tick',):
        return (s, 'invalid')
    fs = [replace(f, timer=f.timer - 1, phase='open' if f.timer == 1 else 'wait') if f.phase == 'wait' else f for f in fs]
    depleted = []
    nectar = s.nectar
    for i, b in enumerate(bees):
        if b.mode in ('reserved', 'out'):
            dest = l.flowers[b.target].x
            x = b.x + (dest > b.x) - (dest < b.x)
            if x == dest:
                f = fs[b.target]
                fs[b.target] = replace(f, stock=f.stock - 1, reserved=f.reserved - 1, phase='spent' if f.stock == 1 else 'open')
                bees[i] = Bee(x, b.target, 'back', 1)
                if f.stock == 1:
                    depleted.append(b.target)
            else:
                bees[i] = replace(b, x=x, mode='out')
        elif b.mode == 'back':
            x = b.x + (s.hive > b.x) - (s.hive < b.x)
            if x == s.hive:
                nectar += b.load
                bees[i] = Bee(s.hive)
            else:
                bees[i] = replace(b, x=x)
    for source in depleted:
        for a, b in l.links:
            target = b if a == source else a if b == source else -1
            if target >= 0 and fs[target].phase == 'sleep':
                fs[target] = replace(fs[target], phase='wait', timer=2)
    return (finish(replace(s, t=s.t + 1, nectar=nectar, bees=tuple(bees), flowers=tuple(fs)), l), 'tick')

def invariant(s, l):
    if not (s.t >= 0 and s.hive in (0, 3, 6) and (s.nectar >= 0) and (s.spent >= 0) and (potential(s) + s.spent == 1 + sum((f.stock for f in l.flowers)))):
        return False
    for i, f in enumerate(s.flowers):
        if not 0 <= f.reserved <= f.stock <= l.flowers[i].stock:
            return False
        if f.reserved != sum((b.target == i and b.mode in ('out', 'reserved') for b in s.bees)):
            return False
        if f.phase not in ('open', 'sleep', 'wait', 'spent') or (f.phase == 'wait') != (f.timer > 0) or (f.phase == 'spent') != (f.stock == 0):
            return False
    for b in s.bees:
        if not 0 <= b.x <= 6 or b.mode not in ('idle', 'reserved', 'out', 'back') or b.load != (1 if b.mode == 'back' else 0):
            return False
        if b.mode in ('idle', 'reserved') and b.x != s.hive:
            return False
        if b.mode == 'idle' and b.target != -1:
            return False
    return True

def state_hash(s):
    return hashlib.sha256(json.dumps(asdict(s), sort_keys=True).encode('utf-8')).hexdigest()

def policy(l, kind='near', open_c=False, max_steps=300):
    """Bounded, transparent priorities. Does not certify human enjoyment."""
    s = initial(l, open_c)
    path = []
    seen = set()
    moved = set()
    for _ in range(max_steps):
        if s.outcome != 'playing':
            break
        a = None
        if kind in ('move', 'twice') and all((b.mode == 'idle' for b in s.bees)):
            dest = 3 if 3 not in moved and s.flowers[0].stock == 0 else 6 if kind == 'twice' and 3 in moved and (6 not in moved) and (s.flowers[1].stock == 0) else None
            if dest is not None and s.nectar >= 1 and (potential(s) > l.target):
                a = ('move', dest)
                moved.add(dest)
        if a is None:
            for bi, b in enumerate(s.bees):
                if b.mode != 'idle':
                    continue
                opts = [i for i, f in enumerate(s.flowers) if f.phase == 'open' and f.stock > f.reserved]
                if opts:
                    if kind == 'small':
                        opts.sort(key=lambda i: (s.flowers[i].stock, abs(l.flowers[i].x - s.hive), i))
                    elif kind == 'right':
                        opts.sort(reverse=True)
                    else:
                        opts.sort(key=lambda i: (abs(l.flowers[i].x - s.hive), i))
                    a = ('assign', bi, opts[0])
                    break
        if a is None:
            a = ('tick',)
        q, r = apply(s, l, a)
        if q == s:
            raise AssertionError((kind, a, r))
        s = q
        path.append(a)
    return (s, tuple(path))

STAGE = 4
STAGE_NAMES = ('ONE BEE DELIVERY', 'TWO BEES / RESERVATIONS', 'LINKED BLOOMING', 'MOVING HIVE / CONSERVATION', 'UNDO / FORECAST')

class App:
    def __init__(self):
        self.level=0
        self.reset()

    @property
    def board(self):
        return LEVELS[self.level]

    @property
    def state(self):
        return self.timeline.state if STAGE==5 else self.current

    def reset(self):
        if STAGE==5:self.timeline=Timeline(initial(self.board))
        else:self.current=initial(self.board)
        self.selection=0
        self.target=0
        self.platform=1
        self.message='READY'
        self.gated=False

    def selected(self):
        if self.selection==2:return ('move',(0,3,6)[self.platform])
        return ('assign',self.selection,self.target)

    def action(self,a):
        if STAGE==5:self.timeline,self.message=step(self.timeline,self.board,a)
        else:self.current,self.message=apply(self.current,self.board,a)

    def press(self,key):
        if key=='R':self.reset();return
        if STAGE>=3 and key in ('1','2','3'):
            self.level=int(key)-1;self.reset();return
        if STAGE==5 and key=='X':self.action(('undo',));return
        if self.state.outcome!='playing':return
        if key=='TAB':self.selection=(self.selection+1)%(3 if STAGE>=4 else 2)
        if key in ('LEFT','RIGHT'):
            delta=1 if key=='RIGHT' else -1
            if self.selection<2:self.target=(self.target+delta)%len(self.board.flowers)
            else:self.platform=(self.platform+delta)%3
        if key=='Z':self.action(self.selected())
        if key=='C' and self.selection<2:self.action(('cancel',self.selection))
        if key=='SPACE':self.action(('tick',))

    def control(self,pressed,held):
        # 一度押したら全キーの解放を待つ。長押しで世界を進めない。
        if self.gated:
            if not pressed and not held:self.gated=False
            return
        if len(pressed|held)>1:
            self.message='RELEASE ALL KEYS';self.gated=True;return
        if len(pressed)==1:
            self.press(next(iter(pressed)));self.gated=True

    def draw(self,p):
        s=self.state;l=self.board;p.cls(1)
        p.text(12,12,'MOVING BEES / STAGE '+str(STAGE)+' / '+STAGE_NAMES[STAGE-1],10)
        p.text(12,30,'GARDEN '+('ABC'[self.level] if STAGE>=3 else 'PRACTICE')+'  TICK '+str(min(s.t,9999))+('+' if s.t>9999 else ''),7)
        p.rect(12,52,616,169,3)
        for a,b in l.links:
            p.line(52+l.flowers[a].x*88,177,52+l.flowers[b].x*88,177,4)
            for j in (a,b):p.line(52+l.flowers[j].x*88,159,52+l.flowers[j].x*88,177,4)
        for x in range(7):p.text(50+x*88,205,str(x),7)
        for x in (0,3,6):p.rectb(41+x*88,187,23,10,9)
        hx=52+s.hive*88;p.rect(hx-10,180,21,18,9);p.rect(hx-3,188,7,10,4)
        for i,(f,d) in enumerate(zip(s.flowers,l.flowers)):
            x=52+d.x*88;c=10 if f.phase=='open' else (11 if f.phase=='wait' else 5)
            p.line(x,123,x,166,11);p.circ(x,120,12,c);p.circ(x,120,4,4)
            p.text(x-3,95,chr(97+i),7)
            p.text(x-22,141,'STOCK '+str(f.stock),7)
            p.text(x-22,152,'BOOK '+str(f.reserved),7)
            p.text(x-22,164,f.phase.upper()+(' '+str(f.timer) if f.timer else ''),c if f.phase in ('open','wait') else 7)
            if self.selection<2 and i==self.target:p.rectb(x-28,88,58,92,10)
        for i,b in enumerate(s.bees):
            x=52+b.x*88;y=64+i*14
            p.circ(x-4,y-2,3,6);p.circ(x+4,y-2,3,6);p.rect(x-5,y,11,5,10);p.line(x,y,x,y+4,0)
            p.text(x+8,y,str(i+1)+('+' if b.load else ''),7)
        p.text(12,235,'HIVE NECTAR '+str(s.nectar)+' / TARGET '+str(l.target)+' / SPENT '+str(s.spent),10)
        p.text(12,251,'POTENTIAL '+str(potential(s))+' = HIVE + FLOWER STOCK (SLEEP INCLUDED) + LOAD',6)
        p.text(12,266,'BOOK IS PART OF STOCK. POTENTIAL IS NOT A REACHABILITY PROMISE.',13)
        for i,b in enumerate(s.bees):
            p.text(12,287+i*17,'BEE '+str(i+1)+' '+b.mode.upper()+' / X '+str(b.x)+' / LOAD '+str(b.load),11 if self.selection==i else 7)
        p.text(12,328,'SELECT '+('BEE '+str(self.selection+1)+' / FLOWER '+chr(97+self.target) if self.selection<2 else 'HIVE / PLATFORM '+str((0,3,6)[self.platform])),10)
        if STAGE==5:
            q,reason=forecast(s,l,self.selected())
            p.text(12,348,'NEXT Z: '+reason.upper()+' / TICK '+str(min(q.t,9999))+' / NECTAR '+str(q.nectar)+' / '+q.outcome.upper(),6)
            p.text(12,365,'HISTORY '+str(len(self.timeline.history))+' / FORECAST NEVER COMMITS',6)
        p.text(12,386,'RESULT '+s.outcome.upper()+' / '+self.message.upper(),11 if s.outcome=='won' else 7)
        p.text(12,410,'TAB: SELECT / LEFT-RIGHT: FLOWER OR PLATFORM / Z: BOOK OR MOVE' if STAGE>=4 else 'TAB: SELECT BEE / LEFT-RIGHT: FLOWER / Z: BOOK',7)
        p.text(12,427,'SPACE: ONE TICK / C: CANCEL BEFORE DEPARTURE / R: RESET / ESC: QUIT',7)
        p.text(12,444,('1-3: GARDEN / ' if STAGE>=3 else 'PRACTICE: ALL FLOWERS OPEN / NO LINKS / ')+('X: UNDO' if STAGE==5 else 'NO UNDO YET'),13)


def demo_keys():
    if STAGE==2:return ('Z','TAB','Z','SPACE','SPACE')
    if STAGE==3:return ('Z','TAB','Z','SPACE','SPACE','SPACE')
    return ('Z','TAB','Z','SPACE','SPACE','TAB','Z')

def main(headless=False,screenshot=None,demo=False):
    # Pyxel may change cwd. Resolve and validate all destinations before importing it.
    from pathlib import Path
    if screenshot:
        target=Path(screenshot).resolve()
        if target.suffix.lower()!='.png' or not target.parent.is_dir() or target.exists():
            raise ValueError('use a new .png in an existing directory')
        screenshot=str(target)
    import pyxel as p
    p.init(640,464,title='Moving bees lesson '+str(STAGE),fps=30,display_scale=1,headless=headless)
    app=App();sequence=demo_keys() if demo else ()
    keys={name:getattr(p,'KEY_'+name) for name in ('LEFT','RIGHT','TAB','SPACE','Z','C','X','R','1','2','3')}
    limit=max(20,2*len(sequence)+8)
    def update():
        if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count>=limit:
            if screenshot:app.draw(p);p.screen.save(screenshot,1)
            p.quit();return
        if headless:
            if demo:
                index=p.frame_count//2
                pressed={sequence[index]} if p.frame_count%2==0 and index<len(sequence) 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))

if __name__=='__main__':
    import argparse
    from pathlib import Path
    parser=argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless-smoke',action='store_true')
    parser.add_argument('--demo-checkpoint',action='store_true')
    parser.add_argument('--screenshot')
    args=parser.parse_args()
    if args.demo_checkpoint and not args.headless_smoke:parser.error('demo requires headless mode')
    if args.screenshot and not args.headless_smoke:parser.error('screenshot requires headless mode')
    if args.screenshot:
        output=Path(args.screenshot).resolve()
        if output.suffix.lower()!='.png' or not output.parent.is_dir() or output.exists():parser.error('use a new .png in an existing directory')
        args.screenshot=str(output)
    main(args.headless_smoke,args.screenshot,args.demo_checkpoint)
STEP 05 / 05

同じ計算で予告し、丸ごと戻す

書き換えない状態をためる履歴と、副作用(ほかを書き換えること)のない予告。

最後に、NEXT Z と X を加えます。NEXT Z は、今の選択を apply に渡した結果です。表示のために計算しても、self.timeline には代入しません。なので、毎フレーム描画しても、予約・費用・時間は進みません。Z を押したときに、同じ apply が返す結果を確定します。予告するのは、今選んでいる Z の操作の結果です。Space で次の刻に進んだときを予言する欄ではありません。

Timeline は、今の State と、それより前の State のタプルを持ちます。step は、apply の前とあとで状態がちがうときだけ、古い State を history に積みます。予約や取り消しも戻せます。受け付けなかった引っ越しや、蜜が足りない予約などでは、履歴は増えません。蜂が idle だけのときの Space でも t は増えるので、これも戻せる1刻です。

X は history[-1] に戻し、履歴を history[:-1] に縮めます。巣の蜜だけを足して戻すような逆算ではありません。時刻、巣の位置、花の蜜・予約・phase・timer、2匹の位置・行き先・mode・load、spent、outcome を、まとめて元に戻します。成功や失敗のあとでも X を受け付けるので、蜜が足りなくなる直前に戻って選び直せます。画面の選択カーソルはゲームの状態ではないので、X では戻りません。

完成したファイルには、元のゲームのモデルにある15個のクラスと関数の定義すべてと、3つの庭、理由の表がそのまま入っています。モデルには open_c の比較と policy も、読むために入れてあります。ただし、画面はふつうの版だけです。元のゲームの日本語の文字、表紙、P の一時停止の説明、F の自動送り、M・B・F12、動画の記録は省きました。英字(ASCII)の画面で、1刻ずつ手で進め、すべてのキーを離すまで待つ形にした教材です。モデルが同じことと、画面の機能が同じことを、混同しないでください。

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

05_undo_forecast.py / 注目する部分
def forecast(s, l, a):
    return apply(s, l, a)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 05_undo_forecast.py
macOS / Linux
.venv/bin/python 05_undo_forecast.py
画面で見ること04と同じ引っ越しまで進めてから X を押すと、TICK 2・巣台0・巣の蜜3・SPENT 0・花bは WAIT 1 に戻ります。もう一度 Z を押すと、同じ引っ越しの結果になります。予告を見ているだけでは、HISTORY も変わりません。
  • 予告で、今の状態を置き換えない
  • 受け付けなかった操作を、履歴に積まない
  • カーソルとゲームの状態で、Undo で戻す範囲を分ける
つまずきやすいところ描画の中で self.timeline=step(...) とすると、画面を描くたびに操作が確定してしまいます。forecast は読むだけ、step はキーを入力したときだけ使います。
少し変えてみよう

R→Z→Tab→Z→Space→Space→Tab→Z→X と押します。引っ越す前の状態すべてに戻ります。もう一度 X を押すと、巣に帰る前(TICK 1・巣の蜜1・2匹とも LOAD 1・花bは WAIT 2)に戻ります。Space で巣に帰るところをやり直し、Z で引っ越すと、04と同じ TICK 3・巣の蜜2 になります。

05_undo_forecast.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(363行)
05_undo_forecast.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.
# 
# This applies to original game source and primitive artwork. Bitmap glyph data
# is derived from Noto Sans CJK under SIL Open Font License 1.1, and is covered
# by assets/NOTO-LICENSE.txt instead of MIT.

"""蜜蜂の引っ越し・教材5/5。解説はLESSON.mdを参照。"""
"""Immutable bee ecology; reservations are commitments, not duplicated nectar."""
from dataclasses import dataclass, replace, asdict
import hashlib, json

@dataclass(frozen=True)
class FlowerDef:
    x: int
    stock: int
    awake: bool

@dataclass(frozen=True)
class Level:
    title: str
    flowers: tuple
    links: tuple
    target: int

@dataclass(frozen=True)
class Flower:
    stock: int
    reserved: int = 0
    phase: str = 'sleep'
    timer: int = 0

@dataclass(frozen=True)
class Bee:
    x: int = 0
    target: int = -1
    mode: str = 'idle'
    load: int = 0

@dataclass(frozen=True)
class State:
    t: int
    hive: int
    nectar: int
    bees: tuple
    flowers: tuple
    spent: int = 0
    outcome: str = 'playing'

@dataclass(frozen=True)
class Timeline:
    state: State
    history: tuple = ()
LEVELS = (Level('隣の芽', (FlowerDef(1, 2, True), FlowerDef(3, 3, False), FlowerDef(5, 3, False)), ((0, 1), (1, 2)), 6), Level('少ない花を先に', (FlowerDef(1, 3, True), FlowerDef(2, 1, True), FlowerDef(4, 4, False)), ((1, 2),), 6), Level('両端の庭', (FlowerDef(1, 2, True), FlowerDef(2, 3, False), FlowerDef(5, 2, True), FlowerDef(4, 3, False)), ((0, 1), (2, 3)), 8))
REASONS = {'ready': '花を予約して、SPACEで一刻進めよう。', 'assign': '蜜1を予約。出発前ならCで取り消せます。', 'cancel': '出発前の予約を解きました。', 'tick': '蜂が一歩進み、芽の待ち時間が減りました。', 'move': '巣の蜜1を使い、巣台へ引っ越しました。', 'busy': '飛行中の行先は変更できません。', 'unavailable': '開花中の未予約蜜が必要です。', 'reserved': '先にCで予約を取り消してください。', 'need_idle': '2匹とも未予約で巣にいるときだけ移転できます。', 'cost': '巣に運搬用の蜜1が必要です。', 'same': '別の巣台を選んでください。', 'terminal': 'Xで直前へ戻すか、Rで再開できます。', 'invalid': 'その操作はできません。', 'undo': '直前の予約または一刻へ戻しました。', 'empty': '戻せる操作がまだありません。'}

def initial(l, open_c=False):
    return State(0, 0, 1, (Bee(), Bee()), tuple((Flower(f.stock, phase='open' if f.awake or (open_c and i == 2) else 'sleep') for i, f in enumerate(l.flowers))))

def potential(s):
    return s.nectar + sum((f.stock for f in s.flowers)) + sum((b.load for b in s.bees))

def finish(s, l):
    return replace(s, outcome='won' if s.nectar >= l.target else 'lost' if potential(s) < l.target else 'playing')

def apply(s, l, a):
    if s.outcome != 'playing':
        return (s, 'terminal')
    if not isinstance(a, tuple) or not a:
        return (s, 'invalid')
    k = a[0]
    bees = list(s.bees)
    fs = list(s.flowers)
    if k == 'assign' and len(a) == 3 and (type(a[1]) == int) and (type(a[2]) == int) and (0 <= a[1] < 2) and (0 <= a[2] < len(fs)):
        bi, fi = a[1:]
        b = bees[bi]
        f = fs[fi]
        if b.mode == 'reserved':
            return (s, 'reserved')
        if b.mode != 'idle':
            return (s, 'busy')
        if f.phase != 'open' or f.stock <= f.reserved:
            return (s, 'unavailable')
        bees[bi] = replace(b, target=fi, mode='reserved')
        fs[fi] = replace(f, reserved=f.reserved + 1)
        return (replace(s, bees=tuple(bees), flowers=tuple(fs)), k)
    if k == 'cancel' and len(a) == 2 and (type(a[1]) == int) and (0 <= a[1] < 2):
        b = bees[a[1]]
        if b.mode != 'reserved':
            return (s, 'busy')
        fs[b.target] = replace(fs[b.target], reserved=fs[b.target].reserved - 1)
        bees[a[1]] = Bee(s.hive)
        return (replace(s, bees=tuple(bees), flowers=tuple(fs)), k)
    if k == 'move' and len(a) == 2 and (type(a[1]) == int) and (a[1] in (0, 3, 6)):
        if any((b.mode != 'idle' for b in bees)):
            return (s, 'need_idle')
        if a[1] == s.hive:
            return (s, 'same')
        if s.nectar < 1:
            return (s, 'cost')
        fs = tuple((replace(f, timer=f.timer - 1, phase='open' if f.timer == 1 else 'wait') if f.phase == 'wait' else f for f in fs))
        return (finish(replace(s, t=s.t + 1, hive=a[1], nectar=s.nectar - 1, spent=s.spent + 1, bees=(Bee(a[1]), Bee(a[1])), flowers=fs), l), k)
    if a != ('tick',):
        return (s, 'invalid')
    fs = [replace(f, timer=f.timer - 1, phase='open' if f.timer == 1 else 'wait') if f.phase == 'wait' else f for f in fs]
    depleted = []
    nectar = s.nectar
    for i, b in enumerate(bees):
        if b.mode in ('reserved', 'out'):
            dest = l.flowers[b.target].x
            x = b.x + (dest > b.x) - (dest < b.x)
            if x == dest:
                f = fs[b.target]
                fs[b.target] = replace(f, stock=f.stock - 1, reserved=f.reserved - 1, phase='spent' if f.stock == 1 else 'open')
                bees[i] = Bee(x, b.target, 'back', 1)
                if f.stock == 1:
                    depleted.append(b.target)
            else:
                bees[i] = replace(b, x=x, mode='out')
        elif b.mode == 'back':
            x = b.x + (s.hive > b.x) - (s.hive < b.x)
            if x == s.hive:
                nectar += b.load
                bees[i] = Bee(s.hive)
            else:
                bees[i] = replace(b, x=x)
    for source in depleted:
        for a, b in l.links:
            target = b if a == source else a if b == source else -1
            if target >= 0 and fs[target].phase == 'sleep':
                fs[target] = replace(fs[target], phase='wait', timer=2)
    return (finish(replace(s, t=s.t + 1, nectar=nectar, bees=tuple(bees), flowers=tuple(fs)), l), 'tick')

def step(tl, l, a):
    if a == ('undo',):
        return (Timeline(tl.history[-1], tl.history[:-1]), 'undo') if tl.history else (tl, 'empty')
    q, r = apply(tl.state, l, a)
    return (Timeline(q, tl.history + (tl.state,)), r) if q != tl.state else (tl, r)

def forecast(s, l, a):
    return apply(s, l, a)

def invariant(s, l):
    if not (s.t >= 0 and s.hive in (0, 3, 6) and (s.nectar >= 0) and (s.spent >= 0) and (potential(s) + s.spent == 1 + sum((f.stock for f in l.flowers)))):
        return False
    for i, f in enumerate(s.flowers):
        if not 0 <= f.reserved <= f.stock <= l.flowers[i].stock:
            return False
        if f.reserved != sum((b.target == i and b.mode in ('out', 'reserved') for b in s.bees)):
            return False
        if f.phase not in ('open', 'sleep', 'wait', 'spent') or (f.phase == 'wait') != (f.timer > 0) or (f.phase == 'spent') != (f.stock == 0):
            return False
    for b in s.bees:
        if not 0 <= b.x <= 6 or b.mode not in ('idle', 'reserved', 'out', 'back') or b.load != (1 if b.mode == 'back' else 0):
            return False
        if b.mode in ('idle', 'reserved') and b.x != s.hive:
            return False
        if b.mode == 'idle' and b.target != -1:
            return False
    return True

def state_hash(s):
    return hashlib.sha256(json.dumps(asdict(s), sort_keys=True).encode('utf-8')).hexdigest()

def policy(l, kind='near', open_c=False, max_steps=300):
    """Bounded, transparent priorities. Does not certify human enjoyment."""
    s = initial(l, open_c)
    path = []
    seen = set()
    moved = set()
    for _ in range(max_steps):
        if s.outcome != 'playing':
            break
        a = None
        if kind in ('move', 'twice') and all((b.mode == 'idle' for b in s.bees)):
            dest = 3 if 3 not in moved and s.flowers[0].stock == 0 else 6 if kind == 'twice' and 3 in moved and (6 not in moved) and (s.flowers[1].stock == 0) else None
            if dest is not None and s.nectar >= 1 and (potential(s) > l.target):
                a = ('move', dest)
                moved.add(dest)
        if a is None:
            for bi, b in enumerate(s.bees):
                if b.mode != 'idle':
                    continue
                opts = [i for i, f in enumerate(s.flowers) if f.phase == 'open' and f.stock > f.reserved]
                if opts:
                    if kind == 'small':
                        opts.sort(key=lambda i: (s.flowers[i].stock, abs(l.flowers[i].x - s.hive), i))
                    elif kind == 'right':
                        opts.sort(reverse=True)
                    else:
                        opts.sort(key=lambda i: (abs(l.flowers[i].x - s.hive), i))
                    a = ('assign', bi, opts[0])
                    break
        if a is None:
            a = ('tick',)
        q, r = apply(s, l, a)
        if q == s:
            raise AssertionError((kind, a, r))
        s = q
        path.append(a)
    return (s, tuple(path))

STAGE = 5
STAGE_NAMES = ('ONE BEE DELIVERY', 'TWO BEES / RESERVATIONS', 'LINKED BLOOMING', 'MOVING HIVE / CONSERVATION', 'UNDO / FORECAST')

class App:
    def __init__(self):
        self.level=0
        self.reset()

    @property
    def board(self):
        return LEVELS[self.level]

    @property
    def state(self):
        return self.timeline.state if STAGE==5 else self.current

    def reset(self):
        if STAGE==5:self.timeline=Timeline(initial(self.board))
        else:self.current=initial(self.board)
        self.selection=0
        self.target=0
        self.platform=1
        self.message='READY'
        self.gated=False

    def selected(self):
        if self.selection==2:return ('move',(0,3,6)[self.platform])
        return ('assign',self.selection,self.target)

    def action(self,a):
        if STAGE==5:self.timeline,self.message=step(self.timeline,self.board,a)
        else:self.current,self.message=apply(self.current,self.board,a)

    def press(self,key):
        if key=='R':self.reset();return
        if STAGE>=3 and key in ('1','2','3'):
            self.level=int(key)-1;self.reset();return
        if STAGE==5 and key=='X':self.action(('undo',));return
        if self.state.outcome!='playing':return
        if key=='TAB':self.selection=(self.selection+1)%(3 if STAGE>=4 else 2)
        if key in ('LEFT','RIGHT'):
            delta=1 if key=='RIGHT' else -1
            if self.selection<2:self.target=(self.target+delta)%len(self.board.flowers)
            else:self.platform=(self.platform+delta)%3
        if key=='Z':self.action(self.selected())
        if key=='C' and self.selection<2:self.action(('cancel',self.selection))
        if key=='SPACE':self.action(('tick',))

    def control(self,pressed,held):
        # 一度押したら全キーの解放を待つ。長押しで世界を進めない。
        if self.gated:
            if not pressed and not held:self.gated=False
            return
        if len(pressed|held)>1:
            self.message='RELEASE ALL KEYS';self.gated=True;return
        if len(pressed)==1:
            self.press(next(iter(pressed)));self.gated=True

    def draw(self,p):
        s=self.state;l=self.board;p.cls(1)
        p.text(12,12,'MOVING BEES / STAGE '+str(STAGE)+' / '+STAGE_NAMES[STAGE-1],10)
        p.text(12,30,'GARDEN '+('ABC'[self.level] if STAGE>=3 else 'PRACTICE')+'  TICK '+str(min(s.t,9999))+('+' if s.t>9999 else ''),7)
        p.rect(12,52,616,169,3)
        for a,b in l.links:
            p.line(52+l.flowers[a].x*88,177,52+l.flowers[b].x*88,177,4)
            for j in (a,b):p.line(52+l.flowers[j].x*88,159,52+l.flowers[j].x*88,177,4)
        for x in range(7):p.text(50+x*88,205,str(x),7)
        for x in (0,3,6):p.rectb(41+x*88,187,23,10,9)
        hx=52+s.hive*88;p.rect(hx-10,180,21,18,9);p.rect(hx-3,188,7,10,4)
        for i,(f,d) in enumerate(zip(s.flowers,l.flowers)):
            x=52+d.x*88;c=10 if f.phase=='open' else (11 if f.phase=='wait' else 5)
            p.line(x,123,x,166,11);p.circ(x,120,12,c);p.circ(x,120,4,4)
            p.text(x-3,95,chr(97+i),7)
            p.text(x-22,141,'STOCK '+str(f.stock),7)
            p.text(x-22,152,'BOOK '+str(f.reserved),7)
            p.text(x-22,164,f.phase.upper()+(' '+str(f.timer) if f.timer else ''),c if f.phase in ('open','wait') else 7)
            if self.selection<2 and i==self.target:p.rectb(x-28,88,58,92,10)
        for i,b in enumerate(s.bees):
            x=52+b.x*88;y=64+i*14
            p.circ(x-4,y-2,3,6);p.circ(x+4,y-2,3,6);p.rect(x-5,y,11,5,10);p.line(x,y,x,y+4,0)
            p.text(x+8,y,str(i+1)+('+' if b.load else ''),7)
        p.text(12,235,'HIVE NECTAR '+str(s.nectar)+' / TARGET '+str(l.target)+' / SPENT '+str(s.spent),10)
        p.text(12,251,'POTENTIAL '+str(potential(s))+' = HIVE + FLOWER STOCK (SLEEP INCLUDED) + LOAD',6)
        p.text(12,266,'BOOK IS PART OF STOCK. POTENTIAL IS NOT A REACHABILITY PROMISE.',13)
        for i,b in enumerate(s.bees):
            p.text(12,287+i*17,'BEE '+str(i+1)+' '+b.mode.upper()+' / X '+str(b.x)+' / LOAD '+str(b.load),11 if self.selection==i else 7)
        p.text(12,328,'SELECT '+('BEE '+str(self.selection+1)+' / FLOWER '+chr(97+self.target) if self.selection<2 else 'HIVE / PLATFORM '+str((0,3,6)[self.platform])),10)
        if STAGE==5:
            q,reason=forecast(s,l,self.selected())
            p.text(12,348,'NEXT Z: '+reason.upper()+' / TICK '+str(min(q.t,9999))+' / NECTAR '+str(q.nectar)+' / '+q.outcome.upper(),6)
            p.text(12,365,'HISTORY '+str(len(self.timeline.history))+' / FORECAST NEVER COMMITS',6)
        p.text(12,386,'RESULT '+s.outcome.upper()+' / '+self.message.upper(),11 if s.outcome=='won' else 7)
        p.text(12,410,'TAB: SELECT / LEFT-RIGHT: FLOWER OR PLATFORM / Z: BOOK OR MOVE' if STAGE>=4 else 'TAB: SELECT BEE / LEFT-RIGHT: FLOWER / Z: BOOK',7)
        p.text(12,427,'SPACE: ONE TICK / C: CANCEL BEFORE DEPARTURE / R: RESET / ESC: QUIT',7)
        p.text(12,444,('1-3: GARDEN / ' if STAGE>=3 else 'PRACTICE: ALL FLOWERS OPEN / NO LINKS / ')+('X: UNDO' if STAGE==5 else 'NO UNDO YET'),13)


def demo_keys():
    if STAGE==2:return ('Z','TAB','Z','SPACE','SPACE')
    if STAGE==3:return ('Z','TAB','Z','SPACE','SPACE','SPACE')
    return ('Z','TAB','Z','SPACE','SPACE','TAB','Z')

def main(headless=False,screenshot=None,demo=False):
    # Pyxel may change cwd. Resolve and validate all destinations before importing it.
    from pathlib import Path
    if screenshot:
        target=Path(screenshot).resolve()
        if target.suffix.lower()!='.png' or not target.parent.is_dir() or target.exists():
            raise ValueError('use a new .png in an existing directory')
        screenshot=str(target)
    import pyxel as p
    p.init(640,464,title='Moving bees lesson '+str(STAGE),fps=30,display_scale=1,headless=headless)
    app=App();sequence=demo_keys() if demo else ()
    keys={name:getattr(p,'KEY_'+name) for name in ('LEFT','RIGHT','TAB','SPACE','Z','C','X','R','1','2','3')}
    limit=max(20,2*len(sequence)+8)
    def update():
        if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count>=limit:
            if screenshot:app.draw(p);p.screen.save(screenshot,1)
            p.quit();return
        if headless:
            if demo:
                index=p.frame_count//2
                pressed={sequence[index]} if p.frame_count%2==0 and index<len(sequence) 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))

if __name__=='__main__':
    import argparse
    from pathlib import Path
    parser=argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless-smoke',action='store_true')
    parser.add_argument('--demo-checkpoint',action='store_true')
    parser.add_argument('--screenshot')
    args=parser.parse_args()
    if args.demo_checkpoint and not args.headless_smoke:parser.error('demo requires headless mode')
    if args.screenshot and not args.headless_smoke:parser.error('screenshot requires headless mode')
    if args.screenshot:
        output=Path(args.screenshot).resolve()
        if output.suffix.lower()!='.png' or not output.parent.is_dir() or output.exists():parser.error('use a new .png in an existing directory')
        args.screenshot=str(output)
    main(args.headless_smoke,args.screenshot,args.demo_checkpoint)
AFTER STEP 05

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

2匹の蜂が蜜を一滴ずつ予約し、1刻に1区画ずつ移動します。花に着いたときに蜜を採り、巣に帰ったときに巣の蜜に加えます。採り切った花のリンク先にある眠った花は、2刻後に咲きます。2匹とも予約をしないで巣にいるときだけ、蜜1と1刻を使って巣を引っ越せます。蜜の保存則、巣の蜜による成功、potential 不足による失敗、状態をまるごと戻す Undo があります。

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

比べる点5ステップの教材元の完成版
前半の段階で、教材のために簡単にしたところ01は蜂1匹・花1輪・目標3。02は蜂2匹・花2輪(どちらも咲いている)・目標4で、リンクと引っ越しはなし。03はふつうの3つの庭で、引っ越しはなし。蜂2匹、3つの庭、連鎖して咲く花、引っ越し、Undo が最初から使える。
モデルとデータ04は、元のゲームのモデルから Timeline・step・forecast だけを省いている。05は、すべてのクラスと関数、LEVELS と REASONS が元のゲームと同じ。集められる蜜の合計(potential)は、眠っている花の stock も含めた上限。ふつうの版と initial-c の比較、5つの決まった動かし方(有限の方策)。potential は、目標に届くかを探して調べたものではない。
画面・入力・記録英字(ASCII)の画面、開始画面なし、Tab で選ぶ、手で tick を進める、キーを押したあとは全部のキーを離すまで待つ。自動検査の PNG だけを、必要なら保存できる。日本語の文字、上下キーで選ぶ、表紙と説明つきの一時停止、F で自動送り、M・B・F12、記録用のデモ。

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

  1. model.py の apply(予約・取り消し・引っ越しと、tick の順番)
  2. model.py の potential・finish・invariant(保存則と上限)
  3. model.py の step・forecast(状態まるごとの履歴と、共通の計算)
  4. app.py の update(キーを離すまで待つゲート、一時停止、自動送り)
  5. experiments.py(近い花・小さい花・引っ越しを、決まった手順で比べる)
自分のゲームに変えるなら

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

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