戦略・対戦LESSON 86集団分岐と有限資材の防衛計画原作045

穴だらけの防衛線の作り方

今やって来た一団を、どちらの道へ通す?
同じ分かれ道にいる全員に道を指示し、建設・移動・砲撃・荷物の落下を、決まった順番で処理します。限られた資材をもとに、1手先の予告と Undo を作ります。

完成版の遊び方害のない輸送車が裏道に落としていく資材と、同じ列で進んでくる敵を見ながら、通す道と砲台の置き場所を決めます。

穴だらけの防衛線の元の完成版。自動操作によるPyxelの実描画画面。
元の完成版Bの自動描画です。下の教材も初期Aから始め、全段階で三つの出現計画を選べます。03で砲撃の規則が揃います。
PLAY / 試験公開

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

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

完成版の主な操作:左右経路/上下建設、Z/Space一刻、X戻す、R再開、P停止、1〜3状況

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

画面に「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 と辞書 / dataclass・replace は本文で説明します
教材に入っているもの
ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
完成した教材の操作
左右:F/Sの道を候補選択 / 上下:02以降、建設・解体を候補選択 / Space/Z:計画を確定し一刻進める / 1/2/3:固定出現予定A/B/Cへ切替・初期化 / B:02以降、落荷価値2/0を切替・初期化 / X:05のみ一計画Undo / R:同じコードで状態を初期化 / Esc:終了
難易度の目安
中級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_cohort_routes.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

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

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

STEP 01 / 05

分岐に来た全員へ、同じ道を指定する

書き換えないデータ・マスからマスへの対応表・区切られた時刻。

このゲームは、キーを押した瞬間だけ1刻(時間の区切り1つ)進む、小さな防衛の計画ゲームです。E が入口、J が分かれ道、F1/F2 が正面の道、S1/S2 が裏道、EXIT が出口です。まずは砲台を作らずに、1刻ごとに集団がどう動くかを見えるようにします。最初の状況 A では、E に R(兵士)が1人います。Space で J へ、もう一度 Space で、選んだ道の1マス目へ進みます。左右キーで道を選ぶだけでは、時間は進みません。分かれ道にまだ来ていない人に、先の道を予約しておく仕組みでもありません。

Unit は、id、kind(種類)、cell(いるマス)、spawn(出てきた刻)、dropped(荷物を落としたか)を持つ、書き換えない dataclass です。cell だけを変えるには、replace(u,cell=...) で新しい Unit を作ります。path の辞書は、「今のマス → 次のマス」の対応表です。選んだ道 route が使われるのは J の行だけなので、もう F1 に進んだ人を、あとから S2 に移すことはありません。J に2人いれば、その2人に同じ path を使うので、全員が同じ道へ進みます。

scheduled は、出てくる予定を最初から順に数えて、重ならない id をつけ、決められた刻に出てくる人だけを返します。最初の状態には、時刻0の人を置きます。1刻の最後に t を増やし、その新しい時刻の人を E に加えます。出てきたその刻のうちに、J まで動いてしまわないことに注意しましょう。R と A は EXIT に着くと、守りの耐久を1減らします。T は荷車で、EXIT に着いても耐久を減らしません。

01 でも、限られた数の出てくる予定と、終わりの判定までは動きます。全員を処理し終え、最後に出てくる時刻を過ぎたときに、耐久が1以上なら WON、0なら LOST です。耐久が0になった瞬間に、残りの列を消すことはしません。まだ攻撃できないので、状況 A は何もしないと LOST になります。これは完成版の難しさではなく、動きを観察する段階です。あとで画面を比べやすいように、State には資材や砲台の欄も先に置いていますが、01 では資材は2のままで、建設・荷物の落下・予告・Undo はまだありません。

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

01_cohort_routes.py / 注目する部分
 path={'E':'J','J':route+'1','F1':'F2','S1':'S2','F2':'EXIT','S2':'EXIT'}
 moved=[replace(u,cell=path[u.cell]) for u in s.units]

実行して確かめる

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

Windows
.venv\Scripts\python.exe 01_cohort_routes.py
macOS / Linux
.venv/bin/python 01_cohort_routes.py
画面で見ること状況 A で Space を1回押すと TICK 1・R が J に、右 → Space で TICK 2・R が S1 です。R → 2 → Space → 右 → Space なら、T と R の両方が S1 に並びます。
  • E と J で、指示の効き方を区別する
  • 同じ J にいる集団を、同じ対応表で動かす
  • 新しく出てくる人は、移動のあとで加える
つまずきやすいところJ にいる1人だけを選ばないでください。route は1人ずつの id への指示ではなく、その刻に J を出る集団全体への指示です。出てきたばかりで E にいる人には、まだ使いません。
少し変えてみよう

R → 2 → Space → 右 → Space で、T と R が一緒に S1 へ進むことを確かめましょう。次に、App.reset の self.route = 'F' を 'S' に1か所だけ変え、保存 → Esc で終了 → 実行し直します。状況 A で Space を2回押すと、最初から S1 へ進みます。確認したら F に戻します。R は状態を最初に戻すだけで、コードを読み直しません。

01_cohort_routes.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(229行)
01_cohort_routes.py / 全文
# MIT License
# 
# Copyright (c) 2026 OpenAI-assisted game contributors
# 
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# 
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# 
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# 
# This MIT license covers original source, documentation and primitive artwork.
# Bitmap glyph data is derived from Noto Sans CJK and remains under SIL Open
# Font License 1.1. See assets/NOTO-LICENSE.txt for the separate font license.

"""045 穴だらけの防衛線 / 第1段階。LESSON.md を参照。"""
STAGE=1
STAGE_NOTE='MOVING ONE CELL DOES NOT MEAN CHOOSING ONE UNIT'
"""Immutable, deterministic cohort-routing defense. No Pyxel dependency."""
from dataclasses import dataclass,replace,asdict
import hashlib,json
@dataclass(frozen=True)
class Level:
 title:str
 waves:tuple
LEVELS=(Level('別々に来る',((0,('R',)),(2,('T',)),(5,('A',)),(7,('R',)))),Level('一緒に来た荷車',((0,('T','R')),(3,('A',)),(4,('R',)),(5,('R',)))),Level('資材より今の列',((0,('T',)),(3,('R','R')),(5,('T','A')),(7,('A',)),(8,('R',)))))
@dataclass(frozen=True)
class Unit:
 id:int
 kind:str
 cell:str
 spawn:int
 dropped:bool=False
@dataclass(frozen=True)
class State:
 t:int=0
 units:tuple=()
 towers:tuple=('', '')
 cooldown:tuple=(0,0)
 material:int=2
 hp:int=3
 salvage:int=0
 reclaimed:int=0
 spent:int=0
 killed:int=0
 escaped:int=0
 cars:int=0
 route:str='F'
 outcome:str='playing'
REASONS={'ready':'同じ分岐にいる全員が、同じ道を通ります。','ok':'計画を確定しました。','funds':'資材不足。今刻の落荷は、次の刻から使えます。','occupied':'砲座があります。先に解体してください。','empty':'その場所には砲座がありません。','invalid':'この計画は選べません。','terminal':'結果が出ました。Xで戻すかRで再開。','undo':'直前の計画に戻しました。','no_undo':'まだ戻せる計画がありません。'}
BUILDS=('none',)
def scheduled(l,t):
 result=[];uid=0
 for at,kinds in l.waves:
  for k in kinds:
   if at==t:result.append(Unit(uid,k,'E',at))
   uid+=1
 return tuple(result)
def initial(l):return State(units=scheduled(l,0))
def apply(s,l,a,salvage_value=2):
 if s.outcome!='playing':return s,'terminal'
 if len(a)!=2 or a[0] not in ('F','S') or a[1]!='none':return s,'invalid'
 route,build=a
 path={'E':'J','J':route+'1','F1':'F2','S1':'S2','F2':'EXIT','S2':'EXIT'}
 moved=[replace(u,cell=path[u.cell]) for u in s.units]
 units=[];hp=s.hp;escaped=s.escaped;cars=s.cars
 for u in moved:
  if u.cell=='EXIT':
   if u.kind=='T':cars+=1
   else:hp=max(0,hp-1);escaped+=1
  else:units.append(u)
 t=s.t+1;units+=scheduled(l,t)
 done=not units and t>=max(at for at,_ in l.waves)
 return replace(s,t=t,units=tuple(units),hp=hp,escaped=escaped,cars=cars,route=route,outcome=('won' if hp>=1 else 'lost') if done else 'playing'),'ok'
def invariant(s,l):
 assert s.material==2+s.salvage+s.reclaimed-s.spent and s.material>=0
 assert s.hp==max(0,3-s.escaped)
 assert len({u.id for u in s.units})==len(s.units)
 spawned=sum(len(k) for t,k in l.waves if t<=s.t)
 assert spawned==len(s.units)+s.killed+s.escaped+s.cars
 assert all(u.cell in ('E','J','F1','F2','S1','S2') for u in s.units)
 assert all(v in (0,1,2) for v in s.cooldown)
 assert s.salvage>=0
def state_hash(s):return hashlib.sha256(json.dumps(asdict(s),sort_keys=True).encode('utf-8')).hexdigest()

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

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

    def reset(self):
        self.state = initial(self.board)
        self.route = 'F'
        self.selection = 0
        self.reason = 'ready'
        self.gate = False
        pass

    @property
    def choices(self):
        return BUILDS

    def commit(self, action):
        self.state, self.reason = apply(self.state, self.board, action, self.salvage_value)
        if self.reason == 'ok':
            self.selection = 0

    def control(self, pressed, held):
        if self.gate:
            if not held:
                self.gate = False
            return
        if 'R' in pressed:
            self.reset()
            self.gate = True
            return
        for i, key in enumerate(('1', '2', '3')):
            if key in pressed:
                self.level = i
                self.reset()
                self.gate = True
                return
        pass
        if 'LEFT' in pressed:
            self.route = 'F'
        if 'RIGHT' in pressed:
            self.route = 'S'
        if 'UP' in pressed:
            self.selection = (self.selection - 1) % len(self.choices)
        if 'DOWN' in pressed:
            self.selection = (self.selection + 1) % len(self.choices)
        if 'SPACE' in pressed or 'Z' in pressed:
            self.commit((self.route, self.choices[self.selection]))
        if pressed:
            self.gate = True

    def draw(self, p):
        s = self.state
        p.cls(1)
        p.text(12,10,'045 OPEN DEFENSE / LESSON '+str(STAGE),10)
        p.text(12,28,'LEVEL '+'ABC'[self.level]+' / TICK '+str(s.t)+' / HP '+str(s.hp)+' / '+s.outcome.upper(),7)
        p.text(370,28,'CASH '+str(s.material)+'  SALVAGE '+str(s.salvage),11)
        p.text(12,48,'R RUNNER / A ARMOR / T TRANSPORT (HARMLESS)',6)
        pos={'E':(40,140),'J':(145,140),'F1':(285,95),'F2':(425,95),'S1':(285,210),'S2':(425,210),'EXIT':(570,140)}
        for a,b in (('E','J'),('J','F1'),('F1','F2'),('F2','EXIT'),('J','S1'),('S1','S2'),('S2','EXIT')):
            x,y=pos[a];xx,yy=pos[b]
            p.line(x+20,y+12,xx+20,yy+12,5)
        for cell,(x,y) in pos.items():
            p.rect(x,y,48,24,4 if cell.startswith('F') else 3 if cell.startswith('S') else 5)
            p.rectb(x,y,48,24,6)
            p.text(x+7,y+9,cell,7)
            for j,u in enumerate(v for v in s.units if v.cell==cell):
                p.circ(x+7+j*16,y+35,6,10 if u.kind=='T' else 8)
                p.text(x+5+j*16,y+33,u.kind,1)
        for i,x in enumerate((285,425)):
            p.text(x-6,76,'TOWER '+(s.towers[i] or '-')+' CD '+str(s.cooldown[i]),10)
        p.text(240,270,'ONLY J COHORT OBEYS THIS TICK: '+self.route,10)
        p.text(12,293,'PLAN '+self.route+' / '+self.choices[self.selection]+' / RESULT '+self.reason.upper(),7)
        p.text(12,311,'KILLED '+str(s.killed)+' / ESCAPED '+str(s.escaped)+' / CARS '+str(s.cars),6)
        p.text(12,337,'MOVEMENT ONLY: NO TOWER, SALVAGE, FORECAST OR UNDO',11)
        p.text(12,389,'WAVES '+str(self.board.waves),13)
        p.text(12,407,'ARROWS ROUTE / BUILD     Z OR SPACE COMMIT ONE TICK',7)
        p.text(12,425,'1-3 LEVEL / R RESET / ESC QUIT / RELEASE EACH KEY',6)
        p.text(12,449,STAGE_NOTE,10)

def demo_keys():
    return ('2','SPACE','RIGHT','SPACE','SPACE','SPACE')

def main(headless=False, screenshot=None, report=None, demo=False):
    import pyxel as p
    p.init(700,470,title='Open defense lesson '+str(STAGE),fps=30,display_scale=1,headless=headless)
    app=App()
    sequence=demo_keys() if demo else ()
    limit=max(12,2*len(sequence)+4)
    keys={k:getattr(p,'KEY_'+k) for k in ('LEFT','RIGHT','UP','DOWN','SPACE','Z','X','R','1','2','3','B')}
    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)
            if report:
                from pathlib import Path
                Path(report).write_text(json.dumps({'stage':STAGE,'state':asdict(app.state),'reason':app.reason,'scope':'actual Pyxel; synthetic key input; not native keyboard or human playtest'},ensure_ascii=False,indent=2),encoding='utf-8')
            p.quit()
            return
        if headless:
            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')
    parser.add_argument('--report')
    args=parser.parse_args()
    if (args.demo_checkpoint or args.screenshot or args.report) and not args.headless_smoke:
        parser.error('demo and output require --headless-smoke')
    for field,suffix in (('screenshot','.png'),('report','.json')):
        value=getattr(args,field)
        if value:
            # Keep the launch cwd even if Pyxel changes process cwd during init.
            target=Path(value).resolve()
            if target.suffix.lower()!=suffix or not target.parent.is_dir() or target.exists():
                parser.error('use a new '+suffix+' in an existing directory')
            setattr(args,field,str(target))
    main(args.headless_smoke,args.screenshot,args.report,args.demo_checkpoint)
STEP 02 / 05

今ある資材で建て、落荷は次の計画へ回す

処理の順番・全部やるか何もしないか・資材の収支の式。

弓の砲台 B、建設の費用、取り壊しで戻る資材、荷車が落とす荷物を加えます。上下キーで none/F1B/F2B/D1/D2 を選び、Z または Space で、道の指示と一緒に確定します。選ぶだけでは、資材も状態も変わりません。F1B は F1 に弓の砲台を作る、D1 は F1 の砲台を取り壊す、という意味です。建設は資材2、取り壊しで1戻ります。確定に成功すると候補は none に戻るので、次の刻に同じ場所へ二重に建ててしまう事故を防げます。

apply の処理の順番は、①入力の確認 → ②建設か取り壊し → ③1マス移動 → ④砲撃 → ⑤荷物の落下と出口 → ⑥次に出てくる人、です。建設が先なので、今 J にいる R を F へ通す計画と、F1B の建設を一緒に確定すると、その新しい砲台が同じ刻に R を止めます。一方、S1 にいる T がこの刻に S2 へ進めば資材が増えますが、建設する時点ではまだ受け取っていません。手持ち0で建設を選ぶと FUNDS(資材不足)になり、1刻も進まず、荷物も落ちません。

弓の砲台がねらえるのは、自分と同じ F のマスに移動してきた R だけです。A は装甲兵、T は荷車で、弓では止まりません。候補が何人もいれば、spawn、id の順で最初の1人だけを選びます。弓の砲台は毎刻使えます。S1/S2 は射程の外で、F1 の砲台がとなりの裏道をねらうことはありません。02 には、まだ槌の砲台も、撃ったあとの待ち時間もありません。

T が初めて S2 に来たときだけ、salvage(落とした荷物の合計)と cash(資材)に同じ値を足し、dropped=True にした新しい Unit を残します。この値はふつう2で、B キーで0と2を切り替えて、同じ出てくる予定で最初からやり直せます。比べるときに変えるのは、落とした荷物の価値だけです。資材はいつも「最初の2+落とした荷物の合計+取り壊しで戻った合計−建設の費用の合計」に一致します。これが invariant(いつも成り立つはずの条件)の検査の式です。無効な計画では元の State をそのまま返すので、資材だけ失って時間が止まる、といった中途半端な取引は起きません。

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

02_salvage_bows.py / 注目する部分
  if u.kind=='T' and u.cell=='S2' and not u.dropped:
   salvage+=salvage_value;cash+=salvage_value;u=replace(u,dropped=True)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 02_salvage_bows.py
macOS / Linux
.venv/bin/python 02_salvage_bows.py
画面で見ること2 → Space → 右 → Space → Space で T が S2 に着き、CASH 4・SALVAGE 2 です。R で最初に戻し → 下で F1B を選んで Space を2回押すと、F1 に来た R だけが消え、T は残ります。
  • 建設には、今持っている資材だけを使う
  • 同じ F のマスにいる R だけをねらう
  • 資材の収支の式で検査する
つまずきやすいところ落とした荷物を建設より先に足すと、まだ持っていない資材を前借りできてしまいます。FUNDS なら State 全体をそのまま返し、移動も砲撃もしません。
少し変えてみよう

App.__init__ の self.salvage_value = 2 を4に変えてみましょう。保存 → 終了 → 実行し直して、2 → Space → 右 → Space → Space → Space と押すと、CASH 6・SALVAGE 4 になります。最後に2に戻します。qa/exercise_checks.py は、同じ書き換えを一時的なコピーに対して行って確かめています。

02_salvage_bows.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(252行)
02_salvage_bows.py / 全文
# MIT License
# 
# Copyright (c) 2026 OpenAI-assisted game contributors
# 
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# 
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# 
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# 
# This MIT license covers original source, documentation and primitive artwork.
# Bitmap glyph data is derived from Noto Sans CJK and remains under SIL Open
# Font License 1.1. See assets/NOTO-LICENSE.txt for the separate font license.

"""045 穴だらけの防衛線 / 第2段階。LESSON.md を参照。"""
STAGE=2
STAGE_NOTE='BOW HITS RUNNERS / S2 CAR DROP HAPPENS AFTER PURCHASE'
"""Immutable, deterministic cohort-routing defense. No Pyxel dependency."""
from dataclasses import dataclass,replace,asdict
import hashlib,json
@dataclass(frozen=True)
class Level:
 title:str
 waves:tuple
LEVELS=(Level('別々に来る',((0,('R',)),(2,('T',)),(5,('A',)),(7,('R',)))),Level('一緒に来た荷車',((0,('T','R')),(3,('A',)),(4,('R',)),(5,('R',)))),Level('資材より今の列',((0,('T',)),(3,('R','R')),(5,('T','A')),(7,('A',)),(8,('R',)))))
@dataclass(frozen=True)
class Unit:
 id:int
 kind:str
 cell:str
 spawn:int
 dropped:bool=False
@dataclass(frozen=True)
class State:
 t:int=0
 units:tuple=()
 towers:tuple=('', '')
 cooldown:tuple=(0,0)
 material:int=2
 hp:int=3
 salvage:int=0
 reclaimed:int=0
 spent:int=0
 killed:int=0
 escaped:int=0
 cars:int=0
 route:str='F'
 outcome:str='playing'
REASONS={'ready':'同じ分岐にいる全員が、同じ道を通ります。','ok':'計画を確定しました。','funds':'資材不足。今刻の落荷は、次の刻から使えます。','occupied':'砲座があります。先に解体してください。','empty':'その場所には砲座がありません。','invalid':'この計画は選べません。','terminal':'結果が出ました。Xで戻すかRで再開。','undo':'直前の計画に戻しました。','no_undo':'まだ戻せる計画がありません。'}
BUILDS=('none','F1B','F2B','D1','D2')
def scheduled(l,t):
 result=[];uid=0
 for at,kinds in l.waves:
  for k in kinds:
   if at==t:result.append(Unit(uid,k,'E',at))
   uid+=1
 return tuple(result)
def initial(l):return State(units=scheduled(l,0))
def apply(s,l,a,salvage_value=2):
 if s.outcome!='playing':return s,'terminal'
 if len(a)!=2 or a[0] not in ('F','S') or a[1] not in BUILDS:return s,'invalid'
 route,build=a;towers=list(s.towers);cd=list(s.cooldown);cash=s.material;spent=s.spent;reclaim=s.reclaimed
 if build.startswith('F'):
  i=int(build[1])-1
  if towers[i]:return s,'occupied'
  if cash<2:return s,'funds'
  towers[i]=build[2];cash-=2;spent+=2;cd[i]=0
 elif build.startswith('D'):
  i=int(build[1])-1
  if not towers[i]:return s,'empty'
  towers[i]='';cd[i]=0;cash+=1;reclaim+=1
 cd=[max(0,v-1) for v in cd]
 path={'E':'J','J':route+'1','F1':'F2','S1':'S2','F2':'EXIT','S2':'EXIT'}
 moved=[replace(u,cell=path[u.cell]) for u in s.units]
 dead=set();killed=s.killed
 for i,kind in enumerate(towers):
  if not kind or cd[i]:continue
  targets=sorted((u for u in moved if u.cell=='F'+str(i+1) and u.kind!='T' and u.kind=='R'),key=lambda u:(u.spawn,u.id))
  if targets:dead.add(targets[0].id);killed+=1;cd[i]=0
 units=[];hp=s.hp;salvage=s.salvage;escaped=s.escaped;cars=s.cars
 for u in moved:
  if u.id in dead:continue
  if u.kind=='T' and u.cell=='S2' and not u.dropped:
   salvage+=salvage_value;cash+=salvage_value;u=replace(u,dropped=True)
  if u.cell=='EXIT':
   if u.kind=='T':cars+=1
   else:hp=max(0,hp-1);escaped+=1
  else:units.append(u)
 t=s.t+1;units+=scheduled(l,t)
 done=not units and t>=max(at for at,_ in l.waves)
 ns=State(t,tuple(units),tuple(towers),tuple(cd),cash,hp,salvage,reclaim,spent,killed,escaped,cars,route,('won' if hp>=1 else 'lost') if done else 'playing')
 return ns,'ok'
def invariant(s,l):
 assert s.material==2+s.salvage+s.reclaimed-s.spent and s.material>=0
 assert s.hp==max(0,3-s.escaped)
 assert len({u.id for u in s.units})==len(s.units)
 spawned=sum(len(k) for t,k in l.waves if t<=s.t)
 assert spawned==len(s.units)+s.killed+s.escaped+s.cars
 assert all(u.cell in ('E','J','F1','F2','S1','S2') for u in s.units)
 assert all(v in (0,1,2) for v in s.cooldown)
 assert s.salvage>=0
def state_hash(s):return hashlib.sha256(json.dumps(asdict(s),sort_keys=True).encode('utf-8')).hexdigest()

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

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

    def reset(self):
        self.state = initial(self.board)
        self.route = 'F'
        self.selection = 0
        self.reason = 'ready'
        self.gate = False
        pass

    @property
    def choices(self):
        return BUILDS

    def commit(self, action):
        self.state, self.reason = apply(self.state, self.board, action, self.salvage_value)
        if self.reason == 'ok':
            self.selection = 0

    def control(self, pressed, held):
        if self.gate:
            if not held:
                self.gate = False
            return
        if 'R' in pressed:
            self.reset()
            self.gate = True
            return
        for i, key in enumerate(('1', '2', '3')):
            if key in pressed:
                self.level = i
                self.reset()
                self.gate = True
                return
        if 'B' in pressed:
            self.salvage_value = 0 if self.salvage_value else 2
            self.reset()
            self.gate = True
            return
        if 'LEFT' in pressed:
            self.route = 'F'
        if 'RIGHT' in pressed:
            self.route = 'S'
        if 'UP' in pressed:
            self.selection = (self.selection - 1) % len(self.choices)
        if 'DOWN' in pressed:
            self.selection = (self.selection + 1) % len(self.choices)
        if 'SPACE' in pressed or 'Z' in pressed:
            self.commit((self.route, self.choices[self.selection]))
        if pressed:
            self.gate = True

    def draw(self, p):
        s = self.state
        p.cls(1)
        p.text(12,10,'045 OPEN DEFENSE / LESSON '+str(STAGE),10)
        p.text(12,28,'LEVEL '+'ABC'[self.level]+' / TICK '+str(s.t)+' / HP '+str(s.hp)+' / '+s.outcome.upper(),7)
        p.text(370,28,'CASH '+str(s.material)+'  SALVAGE '+str(s.salvage),11)
        p.text(12,48,'R RUNNER / A ARMOR / T TRANSPORT (HARMLESS)',6)
        pos={'E':(40,140),'J':(145,140),'F1':(285,95),'F2':(425,95),'S1':(285,210),'S2':(425,210),'EXIT':(570,140)}
        for a,b in (('E','J'),('J','F1'),('F1','F2'),('F2','EXIT'),('J','S1'),('S1','S2'),('S2','EXIT')):
            x,y=pos[a];xx,yy=pos[b]
            p.line(x+20,y+12,xx+20,yy+12,5)
        for cell,(x,y) in pos.items():
            p.rect(x,y,48,24,4 if cell.startswith('F') else 3 if cell.startswith('S') else 5)
            p.rectb(x,y,48,24,6)
            p.text(x+7,y+9,cell,7)
            for j,u in enumerate(v for v in s.units if v.cell==cell):
                p.circ(x+7+j*16,y+35,6,10 if u.kind=='T' else 8)
                p.text(x+5+j*16,y+33,u.kind,1)
        for i,x in enumerate((285,425)):
            p.text(x-6,76,'TOWER '+(s.towers[i] or '-')+' CD '+str(s.cooldown[i]),10)
        p.text(240,270,'ONLY J COHORT OBEYS THIS TICK: '+self.route,10)
        p.text(12,293,'PLAN '+self.route+' / '+self.choices[self.selection]+' / RESULT '+self.reason.upper(),7)
        p.text(12,311,'KILLED '+str(s.killed)+' / ESCAPED '+str(s.escaped)+' / CARS '+str(s.cars),6)
        p.text(12,337,'SALVAGE VALUE '+str(self.salvage_value)+' / B TOGGLE + RESET',11)
        p.text(12,389,'WAVES '+str(self.board.waves),13)
        p.text(12,407,'ARROWS ROUTE / BUILD     Z OR SPACE COMMIT ONE TICK',7)
        p.text(12,425,'1-3 LEVEL / R RESET / ESC QUIT / RELEASE EACH KEY',6)
        p.text(12,449,STAGE_NOTE,10)

def demo_keys():
    return ('2','SPACE','RIGHT','SPACE','SPACE','SPACE')

def main(headless=False, screenshot=None, report=None, demo=False):
    import pyxel as p
    p.init(700,470,title='Open defense lesson '+str(STAGE),fps=30,display_scale=1,headless=headless)
    app=App()
    sequence=demo_keys() if demo else ()
    limit=max(12,2*len(sequence)+4)
    keys={k:getattr(p,'KEY_'+k) for k in ('LEFT','RIGHT','UP','DOWN','SPACE','Z','X','R','1','2','3','B')}
    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)
            if report:
                from pathlib import Path
                Path(report).write_text(json.dumps({'stage':STAGE,'state':asdict(app.state),'reason':app.reason,'scope':'actual Pyxel; synthetic key input; not native keyboard or human playtest'},ensure_ascii=False,indent=2),encoding='utf-8')
            p.quit()
            return
        if headless:
            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')
    parser.add_argument('--report')
    args=parser.parse_args()
    if (args.demo_checkpoint or args.screenshot or args.report) and not args.headless_smoke:
        parser.error('demo and output require --headless-smoke')
    for field,suffix in (('screenshot','.png'),('report','.json')):
        value=getattr(args,field)
        if value:
            # Keep the launch cwd even if Pyxel changes process cwd during init.
            target=Path(value).resolve()
            if target.suffix.lower()!=suffix or not target.parent.is_dir() or target.exists():
                parser.error('use a new '+suffix+' in an existing directory')
            setattr(args,field,str(target))
    main(args.headless_smoke,args.screenshot,args.report,args.demo_checkpoint)
STEP 03 / 05

槌砲の待ち時間を、刻の先頭で減らす

決まった状態を順に移る仕組み・ねらう相手の選び方・境目の値。

F1H/F2H を加えて、ここから元のゲームの apply と同じルールがすべてそろいます。H は槌の砲台です。R に加えて A も止められますが、T はどちらの砲台でもねらいません。H が撃つと、待ち時間 cooldown を2にします。次の刻の最初で2→1になり、まだ撃てません。さらに次の刻で1→0になり、その刻の移動のあとには撃てます。「2刻に1発」は、描画の2フレームではなく、計画を2回確定する間隔です。

cooldown を減らしてから、撃てるかを判定する順番が大切です。元の値が1なら今の刻は0になるので撃てます。元の値が2なら今の刻は1なので待ちます。砲台を作った刻の cooldown は0なので、その刻から撃てます。取り壊すと砲台と cooldown を一緒に消し、戻る資材は1です。撃った回数に応じて、追加の資材を払う仕組みはありません。

同じ F のマスに、T、A、R、R が並ぶ例を考えます。弓は T と A を飛ばして、R の1人だけを止めます。槌は T を飛ばして、spawn が一番早い相手、同じ spawn なら小さい id の相手を止めます。id の順番は、画面でたまたま左に見える順番とは別です。候補 targets を並べかえてから最初の1つを選ぶので、同じ State と計画からは、いつも同じ結果になります。

3つの出てくる予定は決まっています。状況 C では、T と兵士を一緒に裏道へ通すと、資材は増やせても、兵士はそのまま通り抜けてしまいます。「荷車がいれば必ず裏道」が、ただ1つの正解ではありません。出てくる予定、今 J にいる集団、資材、撃ったあとの待ち時間を合わせて考えます。全員を処理し終えて、耐久が1以上なら WON です。耐久を多く残す計画と、資材を多く残す計画は、別々に比べられます。まだ先の結果を画面に出さず、Undo もないので、R から試し直します。

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

03_hammer_ticks.py / 注目する部分
 cd=[max(0,v-1) for v in cd]

実行して確かめる

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

Windows
.venv\Scripts\python.exe 03_hammer_ticks.py
macOS / Linux
.venv/bin/python 03_hammer_ticks.py
画面で見ること2 → 下を2回で F1H → Space で建設します。もう一度 Space で R を倒し、T は F1 に残って CD 2 です。さらに Space で CD 1、次は CD 0 です。
  • CD 1 なら、今の刻に撃てる
  • T は、必ずねらう相手から外す
  • spawn と id で、1人を選ぶ
つまずきやすいところ撃てるかを判定したあとで待ち時間を減らすと、元の CD 1 で撃てず、1刻遅れてしまいます。また、H が T までねらうと、「裏道の資材」と「害のない通過」という、元のゲームの関係が崩れます。
少し変えてみよう

apply の cd[i]=2 if kind=='H' else 0 を3に変え、invariant で許される値 (0,1,2) も (0,1,2,3) に変えてみましょう。保存 → 終了 → 実行し直します。上の操作で、撃ったあと CD 3、次の2刻は CD 2・CD 1 で待ちます。元の2と、許される値に戻します。

03_hammer_ticks.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(252行)
03_hammer_ticks.py / 全文
# MIT License
# 
# Copyright (c) 2026 OpenAI-assisted game contributors
# 
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# 
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# 
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# 
# This MIT license covers original source, documentation and primitive artwork.
# Bitmap glyph data is derived from Noto Sans CJK and remains under SIL Open
# Font License 1.1. See assets/NOTO-LICENSE.txt for the separate font license.

"""045 穴だらけの防衛線 / 第3段階。LESSON.md を参照。"""
STAGE=3
STAGE_NOTE='HAMMER HITS ARMOR / COOLDOWN DECREASES BEFORE FIRING'
"""Immutable, deterministic cohort-routing defense. No Pyxel dependency."""
from dataclasses import dataclass,replace,asdict
import hashlib,json
@dataclass(frozen=True)
class Level:
 title:str
 waves:tuple
LEVELS=(Level('別々に来る',((0,('R',)),(2,('T',)),(5,('A',)),(7,('R',)))),Level('一緒に来た荷車',((0,('T','R')),(3,('A',)),(4,('R',)),(5,('R',)))),Level('資材より今の列',((0,('T',)),(3,('R','R')),(5,('T','A')),(7,('A',)),(8,('R',)))))
@dataclass(frozen=True)
class Unit:
 id:int
 kind:str
 cell:str
 spawn:int
 dropped:bool=False
@dataclass(frozen=True)
class State:
 t:int=0
 units:tuple=()
 towers:tuple=('', '')
 cooldown:tuple=(0,0)
 material:int=2
 hp:int=3
 salvage:int=0
 reclaimed:int=0
 spent:int=0
 killed:int=0
 escaped:int=0
 cars:int=0
 route:str='F'
 outcome:str='playing'
REASONS={'ready':'同じ分岐にいる全員が、同じ道を通ります。','ok':'計画を確定しました。','funds':'資材不足。今刻の落荷は、次の刻から使えます。','occupied':'砲座があります。先に解体してください。','empty':'その場所には砲座がありません。','invalid':'この計画は選べません。','terminal':'結果が出ました。Xで戻すかRで再開。','undo':'直前の計画に戻しました。','no_undo':'まだ戻せる計画がありません。'}
BUILDS=('none','F1B','F1H','F2B','F2H','D1','D2')
def scheduled(l,t):
 result=[];uid=0
 for at,kinds in l.waves:
  for k in kinds:
   if at==t:result.append(Unit(uid,k,'E',at))
   uid+=1
 return tuple(result)
def initial(l):return State(units=scheduled(l,0))
def apply(s,l,a,salvage_value=2):
 if s.outcome!='playing':return s,'terminal'
 if len(a)!=2 or a[0] not in ('F','S') or a[1] not in BUILDS:return s,'invalid'
 route,build=a;towers=list(s.towers);cd=list(s.cooldown);cash=s.material;spent=s.spent;reclaim=s.reclaimed
 if build.startswith('F'):
  i=int(build[1])-1
  if towers[i]:return s,'occupied'
  if cash<2:return s,'funds'
  towers[i]=build[2];cash-=2;spent+=2;cd[i]=0
 elif build.startswith('D'):
  i=int(build[1])-1
  if not towers[i]:return s,'empty'
  towers[i]='';cd[i]=0;cash+=1;reclaim+=1
 cd=[max(0,v-1) for v in cd]
 path={'E':'J','J':route+'1','F1':'F2','S1':'S2','F2':'EXIT','S2':'EXIT'}
 moved=[replace(u,cell=path[u.cell]) for u in s.units]
 dead=set();killed=s.killed
 for i,kind in enumerate(towers):
  if not kind or cd[i]:continue
  targets=sorted((u for u in moved if u.cell=='F'+str(i+1) and u.kind!='T' and (kind=='H' or u.kind=='R')),key=lambda u:(u.spawn,u.id))
  if targets:dead.add(targets[0].id);killed+=1;cd[i]=2 if kind=='H' else 0
 units=[];hp=s.hp;salvage=s.salvage;escaped=s.escaped;cars=s.cars
 for u in moved:
  if u.id in dead:continue
  if u.kind=='T' and u.cell=='S2' and not u.dropped:
   salvage+=salvage_value;cash+=salvage_value;u=replace(u,dropped=True)
  if u.cell=='EXIT':
   if u.kind=='T':cars+=1
   else:hp=max(0,hp-1);escaped+=1
  else:units.append(u)
 t=s.t+1;units+=scheduled(l,t)
 done=not units and t>=max(at for at,_ in l.waves)
 ns=State(t,tuple(units),tuple(towers),tuple(cd),cash,hp,salvage,reclaim,spent,killed,escaped,cars,route,('won' if hp>=1 else 'lost') if done else 'playing')
 return ns,'ok'
def invariant(s,l):
 assert s.material==2+s.salvage+s.reclaimed-s.spent and s.material>=0
 assert s.hp==max(0,3-s.escaped)
 assert len({u.id for u in s.units})==len(s.units)
 spawned=sum(len(k) for t,k in l.waves if t<=s.t)
 assert spawned==len(s.units)+s.killed+s.escaped+s.cars
 assert all(u.cell in ('E','J','F1','F2','S1','S2') for u in s.units)
 assert all(v in (0,1,2) for v in s.cooldown)
 assert s.salvage>=0
def state_hash(s):return hashlib.sha256(json.dumps(asdict(s),sort_keys=True).encode('utf-8')).hexdigest()

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

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

    def reset(self):
        self.state = initial(self.board)
        self.route = 'F'
        self.selection = 0
        self.reason = 'ready'
        self.gate = False
        pass

    @property
    def choices(self):
        return BUILDS

    def commit(self, action):
        self.state, self.reason = apply(self.state, self.board, action, self.salvage_value)
        if self.reason == 'ok':
            self.selection = 0

    def control(self, pressed, held):
        if self.gate:
            if not held:
                self.gate = False
            return
        if 'R' in pressed:
            self.reset()
            self.gate = True
            return
        for i, key in enumerate(('1', '2', '3')):
            if key in pressed:
                self.level = i
                self.reset()
                self.gate = True
                return
        if 'B' in pressed:
            self.salvage_value = 0 if self.salvage_value else 2
            self.reset()
            self.gate = True
            return
        if 'LEFT' in pressed:
            self.route = 'F'
        if 'RIGHT' in pressed:
            self.route = 'S'
        if 'UP' in pressed:
            self.selection = (self.selection - 1) % len(self.choices)
        if 'DOWN' in pressed:
            self.selection = (self.selection + 1) % len(self.choices)
        if 'SPACE' in pressed or 'Z' in pressed:
            self.commit((self.route, self.choices[self.selection]))
        if pressed:
            self.gate = True

    def draw(self, p):
        s = self.state
        p.cls(1)
        p.text(12,10,'045 OPEN DEFENSE / LESSON '+str(STAGE),10)
        p.text(12,28,'LEVEL '+'ABC'[self.level]+' / TICK '+str(s.t)+' / HP '+str(s.hp)+' / '+s.outcome.upper(),7)
        p.text(370,28,'CASH '+str(s.material)+'  SALVAGE '+str(s.salvage),11)
        p.text(12,48,'R RUNNER / A ARMOR / T TRANSPORT (HARMLESS)',6)
        pos={'E':(40,140),'J':(145,140),'F1':(285,95),'F2':(425,95),'S1':(285,210),'S2':(425,210),'EXIT':(570,140)}
        for a,b in (('E','J'),('J','F1'),('F1','F2'),('F2','EXIT'),('J','S1'),('S1','S2'),('S2','EXIT')):
            x,y=pos[a];xx,yy=pos[b]
            p.line(x+20,y+12,xx+20,yy+12,5)
        for cell,(x,y) in pos.items():
            p.rect(x,y,48,24,4 if cell.startswith('F') else 3 if cell.startswith('S') else 5)
            p.rectb(x,y,48,24,6)
            p.text(x+7,y+9,cell,7)
            for j,u in enumerate(v for v in s.units if v.cell==cell):
                p.circ(x+7+j*16,y+35,6,10 if u.kind=='T' else 8)
                p.text(x+5+j*16,y+33,u.kind,1)
        for i,x in enumerate((285,425)):
            p.text(x-6,76,'TOWER '+(s.towers[i] or '-')+' CD '+str(s.cooldown[i]),10)
        p.text(240,270,'ONLY J COHORT OBEYS THIS TICK: '+self.route,10)
        p.text(12,293,'PLAN '+self.route+' / '+self.choices[self.selection]+' / RESULT '+self.reason.upper(),7)
        p.text(12,311,'KILLED '+str(s.killed)+' / ESCAPED '+str(s.escaped)+' / CARS '+str(s.cars),6)
        p.text(12,337,'SALVAGE VALUE '+str(self.salvage_value)+' / B TOGGLE + RESET',11)
        p.text(12,389,'WAVES '+str(self.board.waves),13)
        p.text(12,407,'ARROWS ROUTE / BUILD     Z OR SPACE COMMIT ONE TICK',7)
        p.text(12,425,'1-3 LEVEL / R RESET / ESC QUIT / RELEASE EACH KEY',6)
        p.text(12,449,STAGE_NOTE,10)

def demo_keys():
    return ('2','DOWN','DOWN','SPACE','SPACE','SPACE','SPACE')

def main(headless=False, screenshot=None, report=None, demo=False):
    import pyxel as p
    p.init(700,470,title='Open defense lesson '+str(STAGE),fps=30,display_scale=1,headless=headless)
    app=App()
    sequence=demo_keys() if demo else ()
    limit=max(12,2*len(sequence)+4)
    keys={k:getattr(p,'KEY_'+k) for k in ('LEFT','RIGHT','UP','DOWN','SPACE','Z','X','R','1','2','3','B')}
    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)
            if report:
                from pathlib import Path
                Path(report).write_text(json.dumps({'stage':STAGE,'state':asdict(app.state),'reason':app.reason,'scope':'actual Pyxel; synthetic key input; not native keyboard or human playtest'},ensure_ascii=False,indent=2),encoding='utf-8')
            p.quit()
            return
        if headless:
            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')
    parser.add_argument('--report')
    args=parser.parse_args()
    if (args.demo_checkpoint or args.screenshot or args.report) and not args.headless_smoke:
        parser.error('demo and output require --headless-smoke')
    for field,suffix in (('screenshot','.png'),('report','.json')):
        value=getattr(args,field)
        if value:
            # Keep the launch cwd even if Pyxel changes process cwd during init.
            target=Path(value).resolve()
            if target.suffix.lower()!=suffix or not target.parent.is_dir() or target.exists():
                parser.error('use a new '+suffix+' in an existing directory')
            setattr(args,field,str(target))
    main(args.headless_smoke,args.screenshot,args.report,args.demo_checkpoint)
STEP 04 / 05

確定しないまま、次の一刻を同じ関数で読む

状態を変えない関数・予測と本番の一致・入力の確認。

今選んでいる道と建設の候補について、NEXT 欄に、次の耐久、資材、倒した数の増え方を表示します。予告のために、新しい戦いの計算を作る必要はありません。forecast は apply をそのまま呼び、返ってきた State を表示用の n に入れます。self.state には代入しないので、画面を何回描いても、本体の時刻、資材、兵士は変わりません。

予告は「今選んでいる1つの計画を、1刻だけ使ったら」です。この先に出てくる人すべてを解く AI でも、一番良い配置を保証する機能でもありません。J の集団を裏道へ通すと、今の刻には資材が増えなくても、そのあと S2 に届けば増えます。今 S1 に T がいる場合は、次の1刻で荷物が落ちるのが見えます。同じ1刻でも、そのときのマスによって変化が違います。

無効な候補では、NEXT に FUNDS、OCCUPIED(もう建っている)、EMPTY(何も建っていない)などと表示され、数値は今のままです。これも、実際に確定したときと同じ apply の結果です。予告だけが資材不足を無視して砲台を作ることはありません。上下キーで候補を変えて、NEXT だけが変わり、TICK や今ある砲台は変わらないことを確かめましょう。

書き換えない State を使うだけで、プログラム全体が「状態を変えない作り」になるわけではありません。関数の中で、外のリストやファイルを書き換えないことも必要です。このルールは、Level と State と行動 action を受け取り、新しい State と理由を返します。state_hash と asdict を使って、予告の前とあとで入力が同じであること、予告の結果と確定の結果が一致することをテストしています。次の段階では、この同じ「置きかえ」の仕組みを、過去に戻る操作に使います。

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

04_forecast.py / 注目する部分
def forecast(s,l,a,salvage_value=2):return apply(s,l,a,salvage_value)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 04_forecast.py
macOS / Linux
.venv/bin/python 04_forecast.py
画面で見ること最初の状況 A で、下を2回押して F1H を候補にすると、NEXT OK・CASH 0 ですが、今の CASH 2・TICK 0 のままです。Space を押して初めて TICK 1・CASH 0 になります。もう一度 F1H を選ぶと NEXT OCCUPIED で、時刻は止まります。
  • 予告と確定で、同じ apply を使う
  • 候補を変えても、本当の状態は変わらない
  • 1刻だけの予告であることを、はっきりさせる
つまずきやすいところdraw の中で self.state=forecast(...)[0] とすると、画面を更新するだけでゲームが進んでしまいます。n という別の名前で受け取り、読むだけにします。予告は1刻先までで、WON の保証ではありません。
少し変えてみよう

exercises/04_preview_plan.py を実行してみましょう。PLAN=('F','F1H') を ('F','none') に書き換えて、保存 → 終了 → 実行し直すと、予告の資材は0から2に、砲台は H から空に変わります。どちらも、before の時刻0・資材2は同じです。元の PLAN に戻します。画面でも、同じ候補を上下キーで選んで、NEXT を見比べましょう。

04_forecast.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(255行)
04_forecast.py / 全文
# MIT License
# 
# Copyright (c) 2026 OpenAI-assisted game contributors
# 
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# 
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# 
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# 
# This MIT license covers original source, documentation and primitive artwork.
# Bitmap glyph data is derived from Noto Sans CJK and remains under SIL Open
# Font License 1.1. See assets/NOTO-LICENSE.txt for the separate font license.

"""045 穴だらけの防衛線 / 第4段階。LESSON.md を参照。"""
STAGE=4
STAGE_NOTE='FORECAST CALLS THE SAME APPLY WITHOUT CHANGING STATE'
"""Immutable, deterministic cohort-routing defense. No Pyxel dependency."""
from dataclasses import dataclass,replace,asdict
import hashlib,json
@dataclass(frozen=True)
class Level:
 title:str
 waves:tuple
LEVELS=(Level('別々に来る',((0,('R',)),(2,('T',)),(5,('A',)),(7,('R',)))),Level('一緒に来た荷車',((0,('T','R')),(3,('A',)),(4,('R',)),(5,('R',)))),Level('資材より今の列',((0,('T',)),(3,('R','R')),(5,('T','A')),(7,('A',)),(8,('R',)))))
@dataclass(frozen=True)
class Unit:
 id:int
 kind:str
 cell:str
 spawn:int
 dropped:bool=False
@dataclass(frozen=True)
class State:
 t:int=0
 units:tuple=()
 towers:tuple=('', '')
 cooldown:tuple=(0,0)
 material:int=2
 hp:int=3
 salvage:int=0
 reclaimed:int=0
 spent:int=0
 killed:int=0
 escaped:int=0
 cars:int=0
 route:str='F'
 outcome:str='playing'
REASONS={'ready':'同じ分岐にいる全員が、同じ道を通ります。','ok':'計画を確定しました。','funds':'資材不足。今刻の落荷は、次の刻から使えます。','occupied':'砲座があります。先に解体してください。','empty':'その場所には砲座がありません。','invalid':'この計画は選べません。','terminal':'結果が出ました。Xで戻すかRで再開。','undo':'直前の計画に戻しました。','no_undo':'まだ戻せる計画がありません。'}
BUILDS=('none','F1B','F1H','F2B','F2H','D1','D2')
def scheduled(l,t):
 result=[];uid=0
 for at,kinds in l.waves:
  for k in kinds:
   if at==t:result.append(Unit(uid,k,'E',at))
   uid+=1
 return tuple(result)
def initial(l):return State(units=scheduled(l,0))
def apply(s,l,a,salvage_value=2):
 if s.outcome!='playing':return s,'terminal'
 if len(a)!=2 or a[0] not in ('F','S') or a[1] not in BUILDS:return s,'invalid'
 route,build=a;towers=list(s.towers);cd=list(s.cooldown);cash=s.material;spent=s.spent;reclaim=s.reclaimed
 if build.startswith('F'):
  i=int(build[1])-1
  if towers[i]:return s,'occupied'
  if cash<2:return s,'funds'
  towers[i]=build[2];cash-=2;spent+=2;cd[i]=0
 elif build.startswith('D'):
  i=int(build[1])-1
  if not towers[i]:return s,'empty'
  towers[i]='';cd[i]=0;cash+=1;reclaim+=1
 cd=[max(0,v-1) for v in cd]
 path={'E':'J','J':route+'1','F1':'F2','S1':'S2','F2':'EXIT','S2':'EXIT'}
 moved=[replace(u,cell=path[u.cell]) for u in s.units]
 dead=set();killed=s.killed
 for i,kind in enumerate(towers):
  if not kind or cd[i]:continue
  targets=sorted((u for u in moved if u.cell=='F'+str(i+1) and u.kind!='T' and (kind=='H' or u.kind=='R')),key=lambda u:(u.spawn,u.id))
  if targets:dead.add(targets[0].id);killed+=1;cd[i]=2 if kind=='H' else 0
 units=[];hp=s.hp;salvage=s.salvage;escaped=s.escaped;cars=s.cars
 for u in moved:
  if u.id in dead:continue
  if u.kind=='T' and u.cell=='S2' and not u.dropped:
   salvage+=salvage_value;cash+=salvage_value;u=replace(u,dropped=True)
  if u.cell=='EXIT':
   if u.kind=='T':cars+=1
   else:hp=max(0,hp-1);escaped+=1
  else:units.append(u)
 t=s.t+1;units+=scheduled(l,t)
 done=not units and t>=max(at for at,_ in l.waves)
 ns=State(t,tuple(units),tuple(towers),tuple(cd),cash,hp,salvage,reclaim,spent,killed,escaped,cars,route,('won' if hp>=1 else 'lost') if done else 'playing')
 return ns,'ok'
def forecast(s,l,a,salvage_value=2):return apply(s,l,a,salvage_value)
def invariant(s,l):
 assert s.material==2+s.salvage+s.reclaimed-s.spent and s.material>=0
 assert s.hp==max(0,3-s.escaped)
 assert len({u.id for u in s.units})==len(s.units)
 spawned=sum(len(k) for t,k in l.waves if t<=s.t)
 assert spawned==len(s.units)+s.killed+s.escaped+s.cars
 assert all(u.cell in ('E','J','F1','F2','S1','S2') for u in s.units)
 assert all(v in (0,1,2) for v in s.cooldown)
 assert s.salvage>=0
def state_hash(s):return hashlib.sha256(json.dumps(asdict(s),sort_keys=True).encode('utf-8')).hexdigest()

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

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

    def reset(self):
        self.state = initial(self.board)
        self.route = 'F'
        self.selection = 0
        self.reason = 'ready'
        self.gate = False
        pass

    @property
    def choices(self):
        return BUILDS

    def commit(self, action):
        self.state, self.reason = apply(self.state, self.board, action, self.salvage_value)
        if self.reason == 'ok':
            self.selection = 0

    def control(self, pressed, held):
        if self.gate:
            if not held:
                self.gate = False
            return
        if 'R' in pressed:
            self.reset()
            self.gate = True
            return
        for i, key in enumerate(('1', '2', '3')):
            if key in pressed:
                self.level = i
                self.reset()
                self.gate = True
                return
        if 'B' in pressed:
            self.salvage_value = 0 if self.salvage_value else 2
            self.reset()
            self.gate = True
            return
        if 'LEFT' in pressed:
            self.route = 'F'
        if 'RIGHT' in pressed:
            self.route = 'S'
        if 'UP' in pressed:
            self.selection = (self.selection - 1) % len(self.choices)
        if 'DOWN' in pressed:
            self.selection = (self.selection + 1) % len(self.choices)
        if 'SPACE' in pressed or 'Z' in pressed:
            self.commit((self.route, self.choices[self.selection]))
        if pressed:
            self.gate = True

    def draw(self, p):
        s = self.state
        p.cls(1)
        p.text(12,10,'045 OPEN DEFENSE / LESSON '+str(STAGE),10)
        p.text(12,28,'LEVEL '+'ABC'[self.level]+' / TICK '+str(s.t)+' / HP '+str(s.hp)+' / '+s.outcome.upper(),7)
        p.text(370,28,'CASH '+str(s.material)+'  SALVAGE '+str(s.salvage),11)
        p.text(12,48,'R RUNNER / A ARMOR / T TRANSPORT (HARMLESS)',6)
        pos={'E':(40,140),'J':(145,140),'F1':(285,95),'F2':(425,95),'S1':(285,210),'S2':(425,210),'EXIT':(570,140)}
        for a,b in (('E','J'),('J','F1'),('F1','F2'),('F2','EXIT'),('J','S1'),('S1','S2'),('S2','EXIT')):
            x,y=pos[a];xx,yy=pos[b]
            p.line(x+20,y+12,xx+20,yy+12,5)
        for cell,(x,y) in pos.items():
            p.rect(x,y,48,24,4 if cell.startswith('F') else 3 if cell.startswith('S') else 5)
            p.rectb(x,y,48,24,6)
            p.text(x+7,y+9,cell,7)
            for j,u in enumerate(v for v in s.units if v.cell==cell):
                p.circ(x+7+j*16,y+35,6,10 if u.kind=='T' else 8)
                p.text(x+5+j*16,y+33,u.kind,1)
        for i,x in enumerate((285,425)):
            p.text(x-6,76,'TOWER '+(s.towers[i] or '-')+' CD '+str(s.cooldown[i]),10)
        p.text(240,270,'ONLY J COHORT OBEYS THIS TICK: '+self.route,10)
        p.text(12,293,'PLAN '+self.route+' / '+self.choices[self.selection]+' / RESULT '+self.reason.upper(),7)
        p.text(12,311,'KILLED '+str(s.killed)+' / ESCAPED '+str(s.escaped)+' / CARS '+str(s.cars),6)
        p.text(12,337,'SALVAGE VALUE '+str(self.salvage_value)+' / B TOGGLE + RESET',11)
        n,r=forecast(s,self.board,(self.route,self.choices[self.selection]),self.salvage_value)
        p.text(12,355,'NEXT '+r.upper()+' / HP '+str(n.hp)+' / CASH '+str(n.material)+' / KILLS +'+str(n.killed-s.killed),10)
        p.text(12,389,'WAVES '+str(self.board.waves),13)
        p.text(12,407,'ARROWS ROUTE / BUILD     Z OR SPACE COMMIT ONE TICK',7)
        p.text(12,425,'1-3 LEVEL / R RESET / ESC QUIT / RELEASE EACH KEY',6)
        p.text(12,449,STAGE_NOTE,10)

def demo_keys():
    return ('2','DOWN','DOWN','SPACE','SPACE','SPACE','SPACE')

def main(headless=False, screenshot=None, report=None, demo=False):
    import pyxel as p
    p.init(700,470,title='Open defense lesson '+str(STAGE),fps=30,display_scale=1,headless=headless)
    app=App()
    sequence=demo_keys() if demo else ()
    limit=max(12,2*len(sequence)+4)
    keys={k:getattr(p,'KEY_'+k) for k in ('LEFT','RIGHT','UP','DOWN','SPACE','Z','X','R','1','2','3','B')}
    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)
            if report:
                from pathlib import Path
                Path(report).write_text(json.dumps({'stage':STAGE,'state':asdict(app.state),'reason':app.reason,'scope':'actual Pyxel; synthetic key input; not native keyboard or human playtest'},ensure_ascii=False,indent=2),encoding='utf-8')
            p.quit()
            return
        if headless:
            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')
    parser.add_argument('--report')
    args=parser.parse_args()
    if (args.demo_checkpoint or args.screenshot or args.report) and not args.headless_smoke:
        parser.error('demo and output require --headless-smoke')
    for field,suffix in (('screenshot','.png'),('report','.json')):
        value=getattr(args,field)
        if value:
            # Keep the launch cwd even if Pyxel changes process cwd during init.
            target=Path(value).resolve()
            if target.suffix.lower()!=suffix or not target.parent.is_dir() or target.exists():
                parser.error('use a new '+suffix+' in an existing directory')
            setattr(args,field,str(target))
    main(args.headless_smoke,args.screenshot,args.report,args.demo_checkpoint)
STEP 05 / 05

一つ前の計画へ、状態一式を戻す

書き換えない状態の写し・履歴・同じ結果を再現できるリプレイ。

X で、直前に確定した1刻を取り消せるようにします。Timeline は、state と history を持つ、書き換えない dataclass です。確定したあとの State が元と違うときだけ、変わる前の State を history の最後に追加します。履歴に保存するのは、時刻だけでも兵士の位置だけでもなく、資材、砲台、待ち時間、荷物を落とした印、耐久、終わったかどうかなどを含む、State 全体です。

Undo は、history[-1] を今の状態にし、history[:-1] を新しい履歴にした Timeline を返します。履歴が空なら NO_UNDO で、そのままです。無効な建設では apply が同じ State を返すので、履歴は増えません。候補を左右・上下で動かしても、まだルールに確定していないので、履歴には入りません。X で戻すのは、画面で動かした候補ではなく、採用された1つの計画です。

たとえば、S1 の荷車を S2 へ進めて資材を受け取った直後に X を押すと、荷車は S1 に戻り、資材と salvage と dropped も一緒に戻ります。そこから同じ計画をもう一度確定すれば、同じ荷物を同じ1回だけ受け取ります。資材だけを戻し忘れると、いくらでも稼げてしまいます。書き換えないデータなので、昔の State があとの変更で書き換わることも防げます。

WON や LOST のあとも、X で結果が出る直前に戻れます。結果が出た状態でふつうの計画を確定しても、TERMINAL(終了)で何も変わりません。R や 1/2/3、B での比較の切り替えは、新しい挑戦として、状態と履歴を最初に戻します。R は、書き換えた Python を読み直しません。書き換えを確かめるときは、保存 → Esc で終了 → 同じファイルを実行し直します。

05 には、元のゲームの model.py のルールの関数が、同じ働きのまま全部入っています。画面は学習用の英数字(ASCII)で、元のゲームの日本語の文字、表紙、P での一時停止、M でのメニュー、F12 での画像、出来事の記録と GIF は作っていません。画像と JSON の保存は、画面を出さない(headless)検査用のコマンドだけです。自分で計画を立てるゲームなので、待っている間は進みません。描き直しの30fpsと、ルール上の時刻を混同しないようにしましょう。元のゲームを読むときは、step → forecast → app.py の action/record/update の順に読み、ルールと画面の役割を対応させます。

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

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

実行して確かめる

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

Windows
.venv\Scripts\python.exe 05_undo.py
macOS / Linux
.venv/bin/python 05_undo.py
画面で見ること2 → Space → 右 → Space → Space で CASH 4 です。X を押すと TICK 2・CASH 2 になり、T が S1 に戻ります。Space で、また CASH 4 です。履歴がないときの X は NO_UNDO です。
  • 変わる前の State を保存する
  • 荷物を落とした印も資材も、一緒に戻す
  • 受け付けなかった計画は、履歴に入れない
つまずきやすいところhistory に変わったあとの State を入れると、X を押しても同じ画面に戻ってしまいます。必ず、変わる前の tl.state を追加します。候補の選択の移動は Undo しない仕様であることも区別します。
少し変えてみよう

exercises/05_replay_undo.py を実行し、UNDO_COUNT=1 を2に変えて、保存 → 終了 → 実行し直してみましょう。4刻進めたあとから、3刻目ではなく2刻目に戻り、CASH 2 になります。その時点から、保存してある計画を実行し直すと、元の4刻目と完全に一致します。元の1に戻します。

05_undo.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(270行)
05_undo.py / 全文
# MIT License
# 
# Copyright (c) 2026 OpenAI-assisted game contributors
# 
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# 
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# 
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# 
# This MIT license covers original source, documentation and primitive artwork.
# Bitmap glyph data is derived from Noto Sans CJK and remains under SIL Open
# Font License 1.1. See assets/NOTO-LICENSE.txt for the separate font license.

"""045 穴だらけの防衛線 / 第5段階。LESSON.md を参照。"""
STAGE=5
STAGE_NOTE='UNDO RESTORES EVERY FIELD / INVALID PLANS DO NOT ADD HISTORY'
"""Immutable, deterministic cohort-routing defense. No Pyxel dependency."""
from dataclasses import dataclass,replace,asdict
import hashlib,json
@dataclass(frozen=True)
class Level:
 title:str
 waves:tuple
LEVELS=(Level('別々に来る',((0,('R',)),(2,('T',)),(5,('A',)),(7,('R',)))),Level('一緒に来た荷車',((0,('T','R')),(3,('A',)),(4,('R',)),(5,('R',)))),Level('資材より今の列',((0,('T',)),(3,('R','R')),(5,('T','A')),(7,('A',)),(8,('R',)))))
@dataclass(frozen=True)
class Unit:
 id:int
 kind:str
 cell:str
 spawn:int
 dropped:bool=False
@dataclass(frozen=True)
class State:
 t:int=0
 units:tuple=()
 towers:tuple=('', '')
 cooldown:tuple=(0,0)
 material:int=2
 hp:int=3
 salvage:int=0
 reclaimed:int=0
 spent:int=0
 killed:int=0
 escaped:int=0
 cars:int=0
 route:str='F'
 outcome:str='playing'
@dataclass(frozen=True)
class Timeline:
 state:State
 history:tuple=()
REASONS={'ready':'同じ分岐にいる全員が、同じ道を通ります。','ok':'計画を確定しました。','funds':'資材不足。今刻の落荷は、次の刻から使えます。','occupied':'砲座があります。先に解体してください。','empty':'その場所には砲座がありません。','invalid':'この計画は選べません。','terminal':'結果が出ました。Xで戻すかRで再開。','undo':'直前の計画に戻しました。','no_undo':'まだ戻せる計画がありません。'}
BUILDS=('none','F1B','F1H','F2B','F2H','D1','D2')
def scheduled(l,t):
 result=[];uid=0
 for at,kinds in l.waves:
  for k in kinds:
   if at==t:result.append(Unit(uid,k,'E',at))
   uid+=1
 return tuple(result)
def initial(l):return State(units=scheduled(l,0))
def apply(s,l,a,salvage_value=2):
 if s.outcome!='playing':return s,'terminal'
 if len(a)!=2 or a[0] not in ('F','S') or a[1] not in BUILDS:return s,'invalid'
 route,build=a;towers=list(s.towers);cd=list(s.cooldown);cash=s.material;spent=s.spent;reclaim=s.reclaimed
 if build.startswith('F'):
  i=int(build[1])-1
  if towers[i]:return s,'occupied'
  if cash<2:return s,'funds'
  towers[i]=build[2];cash-=2;spent+=2;cd[i]=0
 elif build.startswith('D'):
  i=int(build[1])-1
  if not towers[i]:return s,'empty'
  towers[i]='';cd[i]=0;cash+=1;reclaim+=1
 cd=[max(0,v-1) for v in cd]
 path={'E':'J','J':route+'1','F1':'F2','S1':'S2','F2':'EXIT','S2':'EXIT'}
 moved=[replace(u,cell=path[u.cell]) for u in s.units]
 dead=set();killed=s.killed
 for i,kind in enumerate(towers):
  if not kind or cd[i]:continue
  targets=sorted((u for u in moved if u.cell=='F'+str(i+1) and u.kind!='T' and (kind=='H' or u.kind=='R')),key=lambda u:(u.spawn,u.id))
  if targets:dead.add(targets[0].id);killed+=1;cd[i]=2 if kind=='H' else 0
 units=[];hp=s.hp;salvage=s.salvage;escaped=s.escaped;cars=s.cars
 for u in moved:
  if u.id in dead:continue
  if u.kind=='T' and u.cell=='S2' and not u.dropped:
   salvage+=salvage_value;cash+=salvage_value;u=replace(u,dropped=True)
  if u.cell=='EXIT':
   if u.kind=='T':cars+=1
   else:hp=max(0,hp-1);escaped+=1
  else:units.append(u)
 t=s.t+1;units+=scheduled(l,t)
 done=not units and t>=max(at for at,_ in l.waves)
 ns=State(t,tuple(units),tuple(towers),tuple(cd),cash,hp,salvage,reclaim,spent,killed,escaped,cars,route,('won' if hp>=1 else 'lost') if done else 'playing')
 return ns,'ok'
def step(tl,l,a,salvage_value=2):
 if a==('undo',):
  return (Timeline(tl.history[-1],tl.history[:-1]),'undo') if tl.history else (tl,'no_undo')
 ns,r=apply(tl.state,l,a,salvage_value)
 return (Timeline(ns,tl.history+(tl.state,)),r) if ns!=tl.state else (tl,r)
def forecast(s,l,a,salvage_value=2):return apply(s,l,a,salvage_value)
def invariant(s,l):
 assert s.material==2+s.salvage+s.reclaimed-s.spent and s.material>=0
 assert s.hp==max(0,3-s.escaped)
 assert len({u.id for u in s.units})==len(s.units)
 spawned=sum(len(k) for t,k in l.waves if t<=s.t)
 assert spawned==len(s.units)+s.killed+s.escaped+s.cars
 assert all(u.cell in ('E','J','F1','F2','S1','S2') for u in s.units)
 assert all(v in (0,1,2) for v in s.cooldown)
 assert s.salvage>=0
def state_hash(s):return hashlib.sha256(json.dumps(asdict(s),sort_keys=True).encode('utf-8')).hexdigest()

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

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

    def reset(self):
        self.state = initial(self.board)
        self.route = 'F'
        self.selection = 0
        self.reason = 'ready'
        self.gate = False
        self.timeline = Timeline(self.state)

    @property
    def choices(self):
        return BUILDS

    def commit(self, action):
        self.timeline, self.reason = step(self.timeline, self.board, action, self.salvage_value)
        self.state = self.timeline.state
        if self.reason == 'ok':
            self.selection = 0

    def control(self, pressed, held):
        if self.gate:
            if not held:
                self.gate = False
            return
        if 'R' in pressed:
            self.reset()
            self.gate = True
            return
        for i, key in enumerate(('1', '2', '3')):
            if key in pressed:
                self.level = i
                self.reset()
                self.gate = True
                return
        if 'B' in pressed:
            self.salvage_value = 0 if self.salvage_value else 2
            self.reset()
            self.gate = True
            return
        if 'X' in pressed:
            self.commit(('undo',))
            self.gate = True
            return
        if 'LEFT' in pressed:
            self.route = 'F'
        if 'RIGHT' in pressed:
            self.route = 'S'
        if 'UP' in pressed:
            self.selection = (self.selection - 1) % len(self.choices)
        if 'DOWN' in pressed:
            self.selection = (self.selection + 1) % len(self.choices)
        if 'SPACE' in pressed or 'Z' in pressed:
            self.commit((self.route, self.choices[self.selection]))
        if pressed:
            self.gate = True

    def draw(self, p):
        s = self.state
        p.cls(1)
        p.text(12,10,'045 OPEN DEFENSE / LESSON '+str(STAGE),10)
        p.text(12,28,'LEVEL '+'ABC'[self.level]+' / TICK '+str(s.t)+' / HP '+str(s.hp)+' / '+s.outcome.upper(),7)
        p.text(370,28,'CASH '+str(s.material)+'  SALVAGE '+str(s.salvage),11)
        p.text(12,48,'R RUNNER / A ARMOR / T TRANSPORT (HARMLESS)',6)
        pos={'E':(40,140),'J':(145,140),'F1':(285,95),'F2':(425,95),'S1':(285,210),'S2':(425,210),'EXIT':(570,140)}
        for a,b in (('E','J'),('J','F1'),('F1','F2'),('F2','EXIT'),('J','S1'),('S1','S2'),('S2','EXIT')):
            x,y=pos[a];xx,yy=pos[b]
            p.line(x+20,y+12,xx+20,yy+12,5)
        for cell,(x,y) in pos.items():
            p.rect(x,y,48,24,4 if cell.startswith('F') else 3 if cell.startswith('S') else 5)
            p.rectb(x,y,48,24,6)
            p.text(x+7,y+9,cell,7)
            for j,u in enumerate(v for v in s.units if v.cell==cell):
                p.circ(x+7+j*16,y+35,6,10 if u.kind=='T' else 8)
                p.text(x+5+j*16,y+33,u.kind,1)
        for i,x in enumerate((285,425)):
            p.text(x-6,76,'TOWER '+(s.towers[i] or '-')+' CD '+str(s.cooldown[i]),10)
        p.text(240,270,'ONLY J COHORT OBEYS THIS TICK: '+self.route,10)
        p.text(12,293,'PLAN '+self.route+' / '+self.choices[self.selection]+' / RESULT '+self.reason.upper(),7)
        p.text(12,311,'KILLED '+str(s.killed)+' / ESCAPED '+str(s.escaped)+' / CARS '+str(s.cars),6)
        p.text(12,337,'SALVAGE VALUE '+str(self.salvage_value)+' / B TOGGLE + RESET',11)
        n,r=forecast(s,self.board,(self.route,self.choices[self.selection]),self.salvage_value)
        p.text(12,355,'NEXT '+r.upper()+' / HP '+str(n.hp)+' / CASH '+str(n.material)+' / KILLS +'+str(n.killed-s.killed),10)
        p.text(450,337,'X UNDO / HISTORY '+str(len(self.timeline.history)),6)
        p.text(12,389,'WAVES '+str(self.board.waves),13)
        p.text(12,407,'ARROWS ROUTE / BUILD     Z OR SPACE COMMIT ONE TICK',7)
        p.text(12,425,'1-3 LEVEL / R RESET / ESC QUIT / RELEASE EACH KEY',6)
        p.text(12,449,STAGE_NOTE,10)

def demo_keys():
    return ('2','DOWN','DOWN','SPACE','SPACE','SPACE','SPACE','X')

def main(headless=False, screenshot=None, report=None, demo=False):
    import pyxel as p
    p.init(700,470,title='Open defense lesson '+str(STAGE),fps=30,display_scale=1,headless=headless)
    app=App()
    sequence=demo_keys() if demo else ()
    limit=max(12,2*len(sequence)+4)
    keys={k:getattr(p,'KEY_'+k) for k in ('LEFT','RIGHT','UP','DOWN','SPACE','Z','X','R','1','2','3','B')}
    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)
            if report:
                from pathlib import Path
                Path(report).write_text(json.dumps({'stage':STAGE,'state':asdict(app.state),'reason':app.reason,'scope':'actual Pyxel; synthetic key input; not native keyboard or human playtest'},ensure_ascii=False,indent=2),encoding='utf-8')
            p.quit()
            return
        if headless:
            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')
    parser.add_argument('--report')
    args=parser.parse_args()
    if (args.demo_checkpoint or args.screenshot or args.report) and not args.headless_smoke:
        parser.error('demo and output require --headless-smoke')
    for field,suffix in (('screenshot','.png'),('report','.json')):
        value=getattr(args,field)
        if value:
            # Keep the launch cwd even if Pyxel changes process cwd during init.
            target=Path(value).resolve()
            if target.suffix.lower()!=suffix or not target.parent.is_dir() or target.exists():
                parser.error('use a new '+suffix+' in an existing directory')
            setattr(args,field,str(target))
    main(args.headless_smoke,args.screenshot,args.report,args.demo_checkpoint)
AFTER STEP 05

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

ルールのまとめ:J にいる全員を同じ道へ。建設2・取り壊し1を先に計算し、待ち時間を減らして、移動のあとに同じ F のマスにいる T 以外だけを攻撃。B は R を、H は R/A をねらい、撃ったら待ち時間2。S2 での T の荷物の落下は、建設のあと。全員を処理し終えて耐久が1以上なら成功。

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

比べる点5ステップの教材元の完成版
段階01 移動、02 弓と資材、03 槌で元のゲームの apply がすべてそろう、04 予告、05 元のゲームのルール全部と Undo。State のあとで使う欄は、01 から置いています。最初から、ルール全部・予告・Undo があります。
画面1つのファイル、英数字(ASCII)、表紙なし。組み立てたキー入力による検査用のコマンド(CLI)を追加。日本語の文字、表紙・説明・P での一時停止・M でのメニュー、F12、記録と GIF。
未実装元のゲームの日本語の文字・音・出来事の保存・ふつうの画面での画像の保存はなし。元のゲームの app.py の表示と記録の機能へ続きます。

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

  1. model.py の scheduled / apply:処理の順番
  2. model.py の forecast / step / invariant / state_hash
  3. experiments.py の run_policy / search:建設だけを調べる、限られた数の探索
  4. app.py の action / record / update / draw
自分のゲームに変えるなら

最初は、数字かルールを1つだけ変えてみましょう。遊ぶ前に「どう変わるか」を予想しておくと、仕組みがよくわかります。慣れてきたら、見た目や音も足してみてください。元のファイルは残しておき、コピーした別のフォルダーで改造すると、いつでも元に戻せます。

教材をダウンロードする

無料で、登録なしでダウンロードできます。ZIPファイルは必ず展開してから、中のファイルを実行してください。元の完成版は、同じZIPに入っている assets フォルダー(画像などの素材)も一緒に使います。

コードと図形はMITライセンス、元の完成版に含まれる日本語の文字データ(Noto由来)はSIL OFL 1.1です。配ったり改造したりするときは、ZIPに入っているLICENSEなどの権利表示を残してください。出典・権利について

検証について

どこまで動作を確認したかを見る

2026年10月5日以降、Linux(Python 3.12・Pyxel 2.9.9)で、教材のコードとゲームのルールを自動テストしています。全500ステップの教材が、実際のPyxelで起動して画面を描けることも確かめました。元の完成版も、自動で遊ぶデモが起動して画面を描き、正常に終わることを確認しています。

この確認は、画面を表示しない自動テストで行ったものです。Windows・Macの実際のパソコンでの操作や、音が出るかどうかはまだ確認していません。人がすべての操作を試したわけでもありません。ブラウザー版の確認範囲は確認状況をご覧ください。

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