戦略・対戦LESSON 55三人の役割分担と後衛支援で進むターン制探検原作090

一夜の探検隊の作り方

先頭の得意なことを、うしろの仲間の道具で支える。
3人の体力と道具を別々に持ち、「先頭を歩く役」「うしろから道具で支える役」「途中の休憩」を使って、6つの区間を進む探検隊を作ります。

完成版の遊び方1人のプレイヤーが3人の隊列と道具を組みかえ、うしろの仲間の支えを生かしながら、6区間先の丘をめざします。

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

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

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

完成版の主な操作:左右隊員/Z先頭、上下分岐、Enter一区間/休憩、Tab道具、X一区間戻す

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

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

うまく表示されないとき

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

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

役割と持ち物を分けて、1歩ぶんの負担を計算する。

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

1. ゲームの進み方・書きかえないデータ・終わりの判定の順番
2. 隊列の順番・得意な地形・だれが負担を払うか
3. 道具ごとの持ち主・持てる数の制限・役割で変わる効果
それだけで動く小さなプログラムを、5つのステップで少しずつ大きくしていきます。
必要な環境
Python 3.11以上 / Pyxel 2.9.9
知っておくとよいこと
変数・if・for・関数 / タプル・リスト・辞書と番号 / dataclass と replace は本文で説明します。クラスの属性が読めると進めやすくなります
教材に入っているもの
ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
完成した教材の操作
Enter:歩く。04以降は休憩待ちなら選択した一人を回復 / 1/2/3:02以降で隊員を選択 / L:02以降で選択した人を先頭に / T:03以降で次の道具を選択 / G:03以降で選択した人へ道具を渡す / Up:04以降で次の分岐を選択 / C:04以降でルートを替えて初期化 / X:05で一歩戻す / V:05で先頭道具の比較規則を替えて初期化 / R:初期化 / Esc:終了
難易度の目安
初級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_walk.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

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

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

STEP 01 / 05

一歩を一つの状態変化として作る

ゲームの進み方・書きかえないデータ・終わりの判定の順番。

最初は1人で、平地を6区間歩きます。元のゲームの隊列や道具はまだありません。体力を6にして、1歩の負担を1に決めています。Enter を押して離すたびに、SEGMENT(区間)が1つ増え、STAMINA(体力)が1つ減ります。右上の区間の数は、画面を描いた回数や秒ではありません。画面を読んでいる間は変わらないので、先に次の値を予想できます。

State は、segment(歩き終えた区間)、stamina(残りの体力)、ended(終わった理由)をまとめたものです。dataclass(frozen=True) で、代入による書きかえを防ぎます。walk は、古い State をこわさずに、新しい State を返す関数です。入力の control、ルールの walk、表示の draw を分けておくと、Pyxel がなくてもルールをテストできます。draw で体力を減らしてはいけません。

walk は、もう終わっているときや体力が足りないときを、先に受け付けないようにします。受け付けたら、体力を引き、区間を増やし、「着いたか」「救助が必要か」をこの順番で判断します。6歩目で体力は0になりますが、目的地に着いたので ARRIVED(到着)です。先に「体力0」を調べると、正しく着いたのに RESCUE(救助)にしてしまいます。途中で体力が0になったときだけ救助です。命を落とすような表現はありません。

画面の6つの箱が区間、下の緑の6本の棒が体力です。Enter を押したままでは、2歩目は受け付けません。control の gated は、全部のキーを離してから次の1回を受け付けるためのものです。R で最初から、Esc で終了です。これは1人用のゲームで、このあと3人の隊員が出てきても、操作するプレイヤーは1人です。

この段階の練習は、元のゲームそのままではありません。元のゲームでは全員の体力が5で、地形によって負担が変わります。まず「入力1回 → 状態の変化1回 → 描く」という骨組みを確かめてから、同じ場所に隊員のルールを足していきます。

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

01_walk.py / 注目する部分
    ended = 'arrived' if segment == 6 else 'rescue' if stamina == 0 else ''

実行して確かめる

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

Windows
.venv\Scripts\python.exe 01_walk.py
macOS / Linux
.venv/bin/python 01_walk.py
画面で見ることEnter を2回押すと SEGMENT 2/6・STAMINA 4/6、6回押すと 6/6・0/6・ARRIVED です。7回目からは値は変わりません。
  • 描いている間には進まない
  • 受け付けないときは同じ State を返す
  • 最後の体力での到着を成功にする
つまずきやすいところdraw や、毎フレームの update から、条件なしに walk を呼ばないようにしましょう。到着の判定を、体力0の判定よりうしろに移すのもまちがいです。
少し変えてみよう

R → Enter を6回押して、到着を確かめます。コードの State.stamina の最初の値だけを6から3に変えたらどうなるか、予想してみてください。答えは3歩目で、SEGMENT 3・体力0・RESCUE です。棒の表示は6本のままなので、これはルールだけを変える実験です。元に戻してから次へ進みます。

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

"""一夜の探検隊・教材 1/5。Python 3.11+ / Pyxel 2.9.9。"""

from dataclasses import dataclass, replace, asdict

@dataclass(frozen=True)
class State:
    segment: int = 0
    stamina: int = 6
    ended: str = ''


def new():
    return State()


def walk(state):
    if state.ended or state.stamina < 1:
        return state
    stamina = state.stamina - 1
    segment = state.segment + 1
    # The last point of stamina can pay for arrival.
    ended = 'arrived' if segment == 6 else 'rescue' if stamina == 0 else ''
    return State(segment, stamina, ended)


def step(state, action):
    return walk(state) if action == 'walk:0' else state

STAGE = 1
KEYS = ('RETURN', 'R')
DEMO_KEYS = ('RETURN', 'RETURN')


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

    def reset(self, case=None, variant=None):
        self.state = new()
        self.person = 0
        self.tool = 0
        self.branch = 0
        self.gated = False
        self.message = 'READY'

    def apply(self, action):
        old = self.state
        self.state = step(self.state, action)
        self.message = action if self.state != old else 'BLOCKED / NO CHANGE'
        if self.state.segment != old.segment:
            self.branch = 0

    def control(self, pressed, held):
        pressed, held = set(pressed), set(held)
        if 'R' in pressed:
            self.reset()
            self.gated = True
            return
        if self.gated:
            if not pressed and not held:
                self.gated = False
            return
        if len(pressed | held) > 1:
            self.gated = True
            self.message = 'RELEASE ALL KEYS'
            return
        if len(pressed) != 1:
            return
        key = next(iter(pressed))
        if key not in KEYS:
            return
        self.gated = True
        if self.state.ended:
            self.message = 'ENDED - R TO RESET'
            return
        if key == 'RETURN':
            self.apply('walk:0')

    def draw(self, p):
        state = self.state
        p.cls(1)
        p.text(12, 10, '090 NIGHT EXPEDITION / STAGE '+str(STAGE), 10)
        p.text(12, 25, 'SEGMENT '+str(state.segment)+'/6'+'  '+(state.ended.upper() or 'WALKING'), 11)
        for i in range(6):
            p.rect(12+i*77, 43, 70, 21, 3 if i < state.segment else 2)
            p.text(18+i*77, 50, str(i+1)+':'+ '/'.join(('P',)), 7)
        p.rectb(12, 80, 456, 125, 5)
        p.text(24, 96, 'ONE WALKER / FLAT PATH / COST 1', 7)
        p.text(24, 119, 'STAMINA '+str(state.stamina)+'/6', 10)
        for i in range(6):
            p.rect(24+i*30, 142, 24, 22, 11 if i < state.stamina else 5)
        p.text(24, 184, 'ONLY AN ACCEPTED ENTER ADVANCES A SEGMENT.', 6)
        p.text(12, 229, 'ENTER WALK / R RESET / ESC QUIT', 7)
        p.text(12, 249, 'RELEASE THE KEY BEFORE THE NEXT WALK.', 6)
        p.text(12, 277, 'ARRIVAL IS CHECKED BEFORE ZERO-STAMINA RESCUE.', 13)
        p.text(12, 301, 'R RESET / ESC QUIT', 6)
        p.text(12, 337, self.message.upper(), 11)


def parse_args(argv=None):
    """すべてのCLI検証はPyxelをimportする前に終える。"""
    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('--frames', type=int)
    parser.add_argument('--screenshot')
    parser.add_argument('--state-output')
    args = parser.parse_args(argv)
    if not args.headless_smoke and (args.demo_checkpoint or args.frames is not None
                                   or args.screenshot or args.state_output):
        parser.error('QA options require --headless-smoke')
    minimum = 2 * len(DEMO_KEYS) + 4 if args.demo_checkpoint else 4
    if args.frames is None:
        args.frames = minimum
    if not minimum <= args.frames <= 600:
        parser.error('--frames must be between '+str(minimum)+' and 600')
    for name, suffix in (('screenshot', '.png'), ('state_output', '.json')):
        value = getattr(args, name)
        if value is not None and (not value.strip() or Path(value).suffix.lower() != suffix):
            parser.error('--'+name.replace('_', '-')+' requires a '+suffix+' file path')
    return args


def main(argv=None):
    args = parse_args(argv)
    from pathlib import Path
    import json
    import pyxel as p
    app = App()
    p.init(480, 352, title='090 Night Expedition', fps=30,
           display_scale=2, headless=args.headless_smoke)
    keys = {key: getattr(p, 'KEY_'+key) for key in KEYS}
    finished = False
    def update():
        if args.headless_smoke:
            index = p.frame_count // 2
            pressed = ({DEMO_KEYS[index]} if args.demo_checkpoint
                       and p.frame_count % 2 == 0 and index < len(DEMO_KEYS) else set())
            app.control(pressed, pressed)
        else:
            app.control({k for k, v in keys.items() if p.btnp(v)},
                        {k for k, v in keys.items() if p.btn(v)})
    def draw():
        nonlocal finished
        app.draw(p)
        if args.headless_smoke and p.frame_count >= args.frames-1 and not finished:
            finished = True
            if args.screenshot:
                path = Path(args.screenshot).resolve()
                path.parent.mkdir(parents=True, exist_ok=True)
                p.screenshot(str(path), scale=2)
            if args.state_output:
                path = Path(args.state_output).resolve()
                path.parent.mkdir(parents=True, exist_ok=True)
                path.write_text(json.dumps(asdict(app.state), ensure_ascii=False, indent=2)+'\n', encoding='utf-8')
            p.quit()
    p.run(update, draw)


if __name__ == '__main__':
    main()
STEP 02 / 05

先頭と、選んでいる隊員を分ける

隊列の順番・得意な地形・だれが負担を払うか。

3人とも体力5で出発します。地形は、元のゲームの状況 A の D → C → P → W → D → P と変わります。D は暗い道、C はがけ、W は寒い道、P は平地で、基本の負担は順に2・3・2・1です。アキ(AKI)はがけ C、フミ(FUMI)は暗い道 D、ルウ(RUU)は寒い道 W が得意で、その人が先頭のときだけ負担が1軽くなります。道具や休憩はまだありません。

1・2・3 で黄色い枠を動かして隊員を選び、L でその人を先頭にします。選んだだけでは、LEADER(先頭)の表示は移りません。選んでいる人は App.person、確定した隊列は State.order と、保存する場所を分けました。order のいちばん前の人だけが先頭の役です。lead は選んだ人を先頭に置き、残りの人は元の順番のまま続けます。たとえば (2,0,1) でフミを選ぶと (1,2,0) になります。隊員を増やしたり消したりはしません。

burden は、次の地形・先頭が得意かどうか・実際の負担を返します。expert は、得意なら1、ちがえば0です。画面の NEXT/COST(予告)と walk で減らす体力は、同じ burden を使うので、予告と実際がずれません。hp をいったん list にして、hp[leader](先頭の体力)だけを減らし、tuple に戻して保存します。うしろの2人は、歩いても体力を使いません。

必要な体力が足りなければ、区間も体力も記録もそのままです。失敗しても時間は進まないので、別の人を先頭に選び直せます。体力0の人は先頭にできず、歩くときにも0の人は受け付けません。全員が0になったら救助ですが、最後の区間なら先に到着になります。残りの体力の合計から引くやり方だと、だれが疲れたのかが分からなくなってしまいます。

元のゲームには、3人全員を必ず先頭にする条件や、点数で優劣を付けるルールはありません。交代は、必要な負担をだれが引き受けるかを比べるための手段です。ここは状況 A の得意な地形までの練習なので、元のゲームにある道具での軽減や、2・4区間のあとの休憩は、次の段階に分けています。

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

02_leaders.py / 注目する部分
    order = (person,) + tuple(p for p in state.order if p != person)
    return replace(state, order=order)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 02_leaders.py
macOS / Linux
.venv/bin/python 02_leaders.py
画面で見ることR → 2 → L → Enter で、フミの体力だけが4になり、暗い道を1つ通ります。1 → L → Enter で、アキ3・フミ4・ルウ5、区間2です。
  • 選ぶことと、先頭を確定することを分ける
  • 負担を払うのは先頭だけ
  • 得意な地形のときだけ1軽くなる
つまずきやすいところ1・2・3 を押しただけで先頭が変わったと思わないようにしましょう。黄色い選択の枠と、LEADER の表示は別々に見ます。hp の全員から cost を引くのもまちがいです。
少し変えてみよう

R から、先頭をフミ、アキ、フミ、ルウ、フミ、アキの順にして、それぞれ Enter で1歩進みます。L で確定してから歩くと、着いたときの体力は (2,2,4) です。同じ人を続けて選んでも区間は増えず、うしろの人の体力は減らないことも確かめます。

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

"""一夜の探検隊・教材 2/5。Python 3.11+ / Pyxel 2.9.9。"""

from dataclasses import dataclass, replace, asdict

NAMES = ('AKI', 'FUMI', 'RUU')
SKILLS = ('C', 'D', 'W')
LAND = {'D': 'DARK', 'C': 'CLIFF', 'W': 'COLD', 'P': 'PLAIN'}
SCENERY = {'D': '夜の花', 'C': '町の灯り', 'W': '星と霜', 'P': '草原'}
BASE = {'D': 2, 'C': 3, 'W': 2, 'P': 1}
ROUTE = ('D', 'C', 'P', 'W', 'D', 'P')

@dataclass(frozen=True)
class State:
    segment: int = 0
    hp: tuple = (5, 5, 5)
    order: tuple = (0, 1, 2)
    path: tuple = ()
    log: tuple = ()
    ended: str = ''


def new():
    return State()


def terrain(state, branch=0):
    return ROUTE[state.segment]


def burden(state, branch=0):
    land = terrain(state, branch)
    leader = state.order[0]
    expert = int(SKILLS[leader] == land)
    cost = max(0, BASE[land] - expert)
    return land, cost, expert

def walk(state, branch=0):
    if state.ended or branch != 0 or state.segment >= 6:
        return state
    land, cost, expert, *detail = burden(state)
    leader = state.order[0]
    if state.hp[leader] == 0 or state.hp[leader] < cost:
        return state
    hp = list(state.hp)
    hp[leader] -= cost
    segment = state.segment + 1
    ended = 'arrived' if segment == 6 else 'rescue' if not any(hp) else ''
    entry = (segment, land, leader, cost, expert, *detail, tuple(hp))
    return replace(state, segment=segment, hp=tuple(hp),
                   path=state.path + (land,), log=state.log + (entry,), ended=ended)

def lead(state, person):
    if state.ended or person not in range(3) or state.hp[person] == 0:
        return state
    order = (person,) + tuple(p for p in state.order if p != person)
    return replace(state, order=order)

def step(state, action):
    # Invalid UI/API actions are harmless; parse before indexing.
    if not isinstance(action, str):
        return state
    bits = action.split(':')
    try:
        if len(bits) == 2 and bits[0] == 'walk':
            return walk(state, int(bits[1]))
        if len(bits) == 2 and bits[0] == 'lead':
            return lead(state, int(bits[1]))


    except (ValueError, TypeError):
        pass
    return state

STAGE = 2
KEYS = ('RETURN', 'R', '1', '2', '3', 'L')
DEMO_KEYS = ('2', 'L', 'RETURN', '1', 'L', 'RETURN')


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

    def reset(self, case=None, variant=None):
        self.state = new()
        self.person = 0
        self.tool = 0
        self.branch = 0
        self.gated = False
        self.message = 'READY'

    def apply(self, action):
        old = self.state
        self.state = step(self.state, action)
        self.message = action if self.state != old else 'BLOCKED / NO CHANGE'
        if self.state.segment != old.segment:
            self.branch = 0

    def control(self, pressed, held):
        pressed, held = set(pressed), set(held)
        if 'R' in pressed:
            self.reset()
            self.gated = True
            return
        if self.gated:
            if not pressed and not held:
                self.gated = False
            return
        if len(pressed | held) > 1:
            self.gated = True
            self.message = 'RELEASE ALL KEYS'
            return
        if len(pressed) != 1:
            return
        key = next(iter(pressed))
        if key not in KEYS:
            return
        self.gated = True
        if self.state.ended:
            self.message = 'ENDED - R TO RESET'
            return
        if key in ('1', '2', '3'):
            self.person = int(key)-1
            self.message = 'SELECTED '+NAMES[self.person]
        elif key == 'L':
            self.apply('lead:'+str(self.person))
        elif key == 'RETURN':
            self.apply('walk:0')

    def draw(self, p):
        state = self.state
        p.cls(1)
        p.text(12, 10, '090 NIGHT EXPEDITION / STAGE '+str(STAGE), 10)
        p.text(12, 25, 'SEGMENT '+str(state.segment)+'/6'+'  '+(state.ended.upper() or 'WALKING'), 11)
        for i in range(6):
            p.rect(12+i*77, 43, 70, 21, 3 if i < state.segment else 2)
            p.text(18+i*77, 50, str(i+1)+':'+ '/'.join((ROUTE[i],)), 7)
        for person in range(3):
            x = 12+person*156
            p.rectb(x, 78, 144, 106, 10 if person == self.person else 5)
            p.text(x+8, 88, str(person+1)+' '+NAMES[person], 7)
            p.text(x+8, 103, 'LEADER' if person == state.order[0] else 'REAR', 11)
            p.text(x+8, 118, 'HP '+str(state.hp[person])+'/5  SKILL '+SKILLS[person], 6)
            for n in range(5):
                p.rect(x+8+n*24, 133, 18, 9, 11 if n < state.hp[person] else 5)
            p.text(x+8, 160, 'NO TOOLS YET', 13)
        if not state.ended:
            land, cost, expert, *details = burden(state, self.branch)
            p.text(12, 199, 'NEXT '+LAND[land]+'  COST '+str(cost)+'  EXPERT -'+str(expert), 7)
            p.text(12, 213, 'ONLY THE LEADER PAYS / NO CAMP YET', 6)
        p.text(12, 229, 'ENTER WALKS / 1 2 3 SELECT / L MAKE LEADER', 6)
        p.text(12, 250, 'AKI: CLIFF / FUMI: DARK / RUU: COLD', 10)
        p.text(12, 265, 'SELECTING A PERSON DOES NOT CHANGE THE LEADER.', 6)
        p.text(12, 281, 'READING AND SELECTION DO NOT ADVANCE THE PATH.', 7)
        p.text(12, 301, 'R RESET / ESC QUIT', 6)
        p.text(12, 337, self.message.upper(), 11)


def parse_args(argv=None):
    """すべてのCLI検証はPyxelをimportする前に終える。"""
    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('--frames', type=int)
    parser.add_argument('--screenshot')
    parser.add_argument('--state-output')
    args = parser.parse_args(argv)
    if not args.headless_smoke and (args.demo_checkpoint or args.frames is not None
                                   or args.screenshot or args.state_output):
        parser.error('QA options require --headless-smoke')
    minimum = 2 * len(DEMO_KEYS) + 4 if args.demo_checkpoint else 4
    if args.frames is None:
        args.frames = minimum
    if not minimum <= args.frames <= 600:
        parser.error('--frames must be between '+str(minimum)+' and 600')
    for name, suffix in (('screenshot', '.png'), ('state_output', '.json')):
        value = getattr(args, name)
        if value is not None and (not value.strip() or Path(value).suffix.lower() != suffix):
            parser.error('--'+name.replace('_', '-')+' requires a '+suffix+' file path')
    return args


def main(argv=None):
    args = parse_args(argv)
    from pathlib import Path
    import json
    import pyxel as p
    app = App()
    p.init(480, 352, title='090 Night Expedition', fps=30,
           display_scale=2, headless=args.headless_smoke)
    keys = {key: getattr(p, 'KEY_'+key) for key in KEYS}
    finished = False
    def update():
        if args.headless_smoke:
            index = p.frame_count // 2
            pressed = ({DEMO_KEYS[index]} if args.demo_checkpoint
                       and p.frame_count % 2 == 0 and index < len(DEMO_KEYS) else set())
            app.control(pressed, pressed)
        else:
            app.control({k for k, v in keys.items() if p.btnp(v)},
                        {k for k, v in keys.items() if p.btn(v)})
    def draw():
        nonlocal finished
        app.draw(p)
        if args.headless_smoke and p.frame_count >= args.frames-1 and not finished:
            finished = True
            if args.screenshot:
                path = Path(args.screenshot).resolve()
                path.parent.mkdir(parents=True, exist_ok=True)
                p.screenshot(str(path), scale=2)
            if args.state_output:
                path = Path(args.state_output).resolve()
                path.parent.mkdir(parents=True, exist_ok=True)
                path.write_text(json.dumps(asdict(app.state), ensure_ascii=False, indent=2)+'\n', encoding='utf-8')
            p.quit()
    p.run(update, draw)


if __name__ == '__main__':
    main()
STEP 03 / 05

道具は、後ろから支える人に持ってもらう

道具ごとの持ち主・持てる数の制限・役割で変わる効果。

ランタンは暗い道、ロープはがけ、毛布は寒い道の負担を1減らします。ただし、効くのは持ち主がうしろにいるときだけです。先頭の人が自分で持っていても、ふつうのルールでは使えません。先頭の得意とうしろの道具の効果は足し算になり、負担は max(0, 基本−得意−支え) です。フミが先頭で暗い道を歩き、アキがうしろでランタンを持っていれば、2−1−1=0 になります。

owners は「隊員ごとの道具のリスト」ではなく、「道具ごとの持ち主」を並べた長さ3のタプルです。順番はランタン・ロープ・毛布、値の0/1/2はアキ・フミ・ルウです。最初の (0,1,2) なら、1人1個ずつ持っています。道具を渡すときはその1か所だけを書きかえるので、道具が増えたり消えたりしません。渡したあとに持ち主が3個持つことになるなら、その変更自体を受け付けません。1人が持てるのは2個までです。

1・2・3 で受け取る人を選び、T で渡す道具を LANTERN → ROPE → BLANKET と切りかえ、G で渡します。画面の TOOLS L/R/B が、実際の持ち物です。選んでいる道具と持ち物は別のもので、T を押すだけでは渡しません。この段階にはまだ休憩がないので、配り直せるのは出発前の区間0だけです。1歩進むと GIVE LOCKED(渡せない)になります。L による先頭の交代は、途中でもできます。

体力0のうしろの人も、道具で支えることはできます。支えの条件は owner != leader(持ち主が先頭ではない)で、持ち主の体力は関係ありません。一方、先頭は負担が0でも、体力0なら歩けません。「負担を払えるか」と「先頭に立てるか」を、別々に調べる必要があります。道具は使ってもなくならず、数も持ち主も変わりません。

予告の「SUPPORT -1」は、今選んでいる人ではなく、確定した先頭と、実際の持ち主で計算します。選んだだけで軽くなる作りはまちがいです。ここでは状況 A の道具と隊列を作り、休憩所・分かれ道・元のゲームのひとことの表示は、まだ入れません。

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

03_tools.py / 注目する部分
    support = int(tool is not None and owner != leader)
    cost = max(0, BASE[land] - expert - support)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 03_tools.py
macOS / Linux
.venv/bin/python 03_tools.py
画面で見ることR → 2 → G で、ランタンをフミに渡します。owners=(1,1,2) になり、フミは L と R の2個を持ちます。先頭がアキのまま Enter を押すと、暗い道の負担は1で、体力は (4,5,5) になります。
  • 道具1個につき、持ち主は1人
  • 渡す前に「2個まで」を確かめる
  • 体力0のうしろの人は支えられるが、先頭にはなれない
つまずきやすいところ道具の持ち主本人だけに軽減を付けると、この作品の「うしろから支える」仕組みが逆になってしまいます。また、give で先に owners を書きかえてから「持ちすぎ」の注意を出すと、正しくない3個持ちの状態が残ってしまいます。
少し変えてみよう

R → 2 → G → T → T → G と押して、フミに3個目の毛布を渡そうとすると受け付けられず、(1,1,2) のままです。R から、各歩の先頭を フミ/アキ/アキ/ルウ/フミ/フミ にすると、道具を渡さずに6区間に到着し、体力は (3,4,4) になります。コードの owners の最初の値を (1,0,2) に変える実験では、アキの最初の暗い道の負担は1、次のがけでは自分のロープが使えず負担2です。

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

"""一夜の探検隊・教材 3/5。Python 3.11+ / Pyxel 2.9.9。"""

from dataclasses import dataclass, replace, asdict

NAMES = ('AKI', 'FUMI', 'RUU')
SKILLS = ('C', 'D', 'W')
LAND = {'D': 'DARK', 'C': 'CLIFF', 'W': 'COLD', 'P': 'PLAIN'}
SCENERY = {'D': '夜の花', 'C': '町の灯り', 'W': '星と霜', 'P': '草原'}
BASE = {'D': 2, 'C': 3, 'W': 2, 'P': 1}
ROUTE = ('D', 'C', 'P', 'W', 'D', 'P')
TOOLS = ('LANTERN', 'ROPE', 'BLANKET')
TOOL = {'D': 0, 'C': 1, 'W': 2}

@dataclass(frozen=True)
class State:
    segment: int = 0
    hp: tuple = (5, 5, 5)
    order: tuple = (0, 1, 2)
    owners: tuple = (0, 1, 2)
    path: tuple = ()
    log: tuple = ()
    ended: str = ''


def new():
    return State()


def terrain(state, branch=0):
    return ROUTE[state.segment]


def burden(state, branch=0):
    land = terrain(state, branch)
    leader = state.order[0]
    expert = int(SKILLS[leader] == land)
    tool = TOOL.get(land)
    owner = state.owners[tool] if tool is not None else -1
    support = int(tool is not None and owner != leader)
    cost = max(0, BASE[land] - expert - support)
    return land, cost, expert, support, owner, tool

def walk(state, branch=0):
    if state.ended or branch != 0 or state.segment >= 6:
        return state
    land, cost, expert, *detail = burden(state)
    leader = state.order[0]
    if state.hp[leader] == 0 or state.hp[leader] < cost:
        return state
    hp = list(state.hp)
    hp[leader] -= cost
    segment = state.segment + 1
    ended = 'arrived' if segment == 6 else 'rescue' if not any(hp) else ''
    entry = (segment, land, leader, cost, expert, *detail, tuple(hp))
    return replace(state, segment=segment, hp=tuple(hp),
                   path=state.path + (land,), log=state.log + (entry,), ended=ended)

def lead(state, person):
    if state.ended or person not in range(3) or state.hp[person] == 0:
        return state
    order = (person,) + tuple(p for p in state.order if p != person)
    return replace(state, order=order)

def editable(state):
    return not state.ended and state.segment == 0


def give(state, tool, person):
    if not editable(state) or tool not in range(3) or person not in range(3):
        return state
    owners = list(state.owners)
    owners[tool] = person
    if owners.count(person) > 2:
        return state
    return replace(state, owners=tuple(owners))

def step(state, action):
    # Invalid UI/API actions are harmless; parse before indexing.
    if not isinstance(action, str):
        return state
    bits = action.split(':')
    try:
        if len(bits) == 2 and bits[0] == 'walk':
            return walk(state, int(bits[1]))
        if len(bits) == 2 and bits[0] == 'lead':
            return lead(state, int(bits[1]))
        if len(bits) == 3 and bits[0] == 'give':
            return give(state, int(bits[1]), int(bits[2]))

    except (ValueError, TypeError):
        pass
    return state

STAGE = 3
KEYS = ('RETURN', 'R', '1', '2', '3', 'L', 'T', 'G')
DEMO_KEYS = ('2', 'G', 'RETURN')


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

    def reset(self, case=None, variant=None):
        self.state = new()
        self.person = 0
        self.tool = 0
        self.branch = 0
        self.gated = False
        self.message = 'READY'

    def apply(self, action):
        old = self.state
        self.state = step(self.state, action)
        self.message = action if self.state != old else 'BLOCKED / NO CHANGE'
        if self.state.segment != old.segment:
            self.branch = 0

    def control(self, pressed, held):
        pressed, held = set(pressed), set(held)
        if 'R' in pressed:
            self.reset()
            self.gated = True
            return
        if self.gated:
            if not pressed and not held:
                self.gated = False
            return
        if len(pressed | held) > 1:
            self.gated = True
            self.message = 'RELEASE ALL KEYS'
            return
        if len(pressed) != 1:
            return
        key = next(iter(pressed))
        if key not in KEYS:
            return
        self.gated = True
        if self.state.ended:
            self.message = 'ENDED - R TO RESET'
            return
        if key in ('1', '2', '3'):
            self.person = int(key)-1
            self.message = 'SELECTED '+NAMES[self.person]
        elif key == 'L':
            self.apply('lead:'+str(self.person))
        elif key == 'T':
            self.tool = (self.tool+1)%3
            self.message = 'SELECTED '+TOOLS[self.tool]
        elif key == 'G':
            self.apply('give:'+str(self.tool)+':'+str(self.person))
        elif key == 'RETURN':
            self.apply('walk:0')

    def draw(self, p):
        state = self.state
        p.cls(1)
        p.text(12, 10, '090 NIGHT EXPEDITION / STAGE '+str(STAGE), 10)
        p.text(12, 25, 'SEGMENT '+str(state.segment)+'/6'+'  '+(state.ended.upper() or 'WALKING'), 11)
        for i in range(6):
            p.rect(12+i*77, 43, 70, 21, 3 if i < state.segment else 2)
            p.text(18+i*77, 50, str(i+1)+':'+ '/'.join((ROUTE[i],)), 7)
        for person in range(3):
            x = 12+person*156
            p.rectb(x, 78, 144, 106, 10 if person == self.person else 5)
            p.text(x+8, 88, str(person+1)+' '+NAMES[person], 7)
            p.text(x+8, 103, 'LEADER' if person == state.order[0] else 'REAR', 11)
            p.text(x+8, 118, 'HP '+str(state.hp[person])+'/5  SKILL '+SKILLS[person], 6)
            for n in range(5):
                p.rect(x+8+n*24, 133, 18, 9, 11 if n < state.hp[person] else 5)
            owned = ''.join('LRB'[j] for j in range(3) if state.owners[j] == person)
            p.text(x+8, 160, 'TOOLS '+(owned or '-'), 10)
        if not state.ended:
            land, cost, expert, *details = burden(state, self.branch)
            p.text(12, 199, 'NEXT '+LAND[land]+'  COST '+str(cost)+'  EXPERT -'+str(expert), 7)
            p.text(12, 213, 'SUPPORT -'+str(details[0])+' / ONLY THE LEADER PAYS', 6)
        p.text(12, 229, 'ENTER WALKS / 1 2 3 SELECT / L MAKE LEADER', 6)
        p.text(12, 247, 'TOOL '+TOOLS[self.tool]+' / '+('GIVE OPEN' if editable(state) else 'GIVE LOCKED'), 10)
        p.text(12, 263, 'T NEXT TOOL / G GIVE TO SELECTED / LIMIT 2', 6)
        p.text(12, 281, 'READING AND SELECTION DO NOT ADVANCE THE PATH.', 7)
        p.text(12, 301, 'R RESET / ESC QUIT', 6)
        p.text(12, 337, self.message.upper(), 11)


def parse_args(argv=None):
    """すべてのCLI検証はPyxelをimportする前に終える。"""
    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('--frames', type=int)
    parser.add_argument('--screenshot')
    parser.add_argument('--state-output')
    args = parser.parse_args(argv)
    if not args.headless_smoke and (args.demo_checkpoint or args.frames is not None
                                   or args.screenshot or args.state_output):
        parser.error('QA options require --headless-smoke')
    minimum = 2 * len(DEMO_KEYS) + 4 if args.demo_checkpoint else 4
    if args.frames is None:
        args.frames = minimum
    if not minimum <= args.frames <= 600:
        parser.error('--frames must be between '+str(minimum)+' and 600')
    for name, suffix in (('screenshot', '.png'), ('state_output', '.json')):
        value = getattr(args, name)
        if value is not None and (not value.strip() or Path(value).suffix.lower() != suffix):
            parser.error('--'+name.replace('_', '-')+' requires a '+suffix+' file path')
    return args


def main(argv=None):
    args = parse_args(argv)
    from pathlib import Path
    import json
    import pyxel as p
    app = App()
    p.init(480, 352, title='090 Night Expedition', fps=30,
           display_scale=2, headless=args.headless_smoke)
    keys = {key: getattr(p, 'KEY_'+key) for key in KEYS}
    finished = False
    def update():
        if args.headless_smoke:
            index = p.frame_count // 2
            pressed = ({DEMO_KEYS[index]} if args.demo_checkpoint
                       and p.frame_count % 2 == 0 and index < len(DEMO_KEYS) else set())
            app.control(pressed, pressed)
        else:
            app.control({k for k, v in keys.items() if p.btnp(v)},
                        {k for k, v in keys.items() if p.btn(v)})
    def draw():
        nonlocal finished
        app.draw(p)
        if args.headless_smoke and p.frame_count >= args.frames-1 and not finished:
            finished = True
            if args.screenshot:
                path = Path(args.screenshot).resolve()
                path.parent.mkdir(parents=True, exist_ok=True)
                p.screenshot(str(path), scale=2)
            if args.state_output:
                path = Path(args.state_output).resolve()
                path.parent.mkdir(parents=True, exist_ok=True)
                path.write_text(json.dumps(asdict(app.state), ensure_ascii=False, indent=2)+'\n', encoding='utf-8')
            p.quit()
    p.run(update, draw)


if __name__ == '__main__':
    main()
STEP 04 / 05

休憩待ちと分岐を、全員の状態に加える

段階を追った変化・1回だけの回復・理由のある記録。

元のゲームの状況 A・B・C のすべてのルートを入れ、最初の画面は分かれ道のある B にします。C キーで B → C → A → B の順にルートを替え、体力や記録も最初に戻します。B の道は、がけ・がけ・暗い道/寒い道・がけ・平地/暗い道・寒い道です。上の6つの箱には、これから先の地形も表示されます。上キーで次の分かれ道を選び、NEXT と COST を見てから Enter を押します。分かれ道に、かくれた点数の差はありません。景色を選ぶための道です。

2区間目と4区間目を歩き終えると、rest_pending=True(休憩待ち)になります。この間の walk は必ず受け付けません。画面に CAMP と出たら 1・2・3 で1人を選び、Enter でその人だけ体力を+2(最大5まで)回復します。この Enter では歩きません。rest_pending を False にしたあとの Enter が、次の1歩です。体力が満タンの人を選んでも休憩は1回使い、もう一度は回復できません。体力0の人も2まで回復できます。

道具の配り直しは、区間0・2・4でできます。休憩待ちかどうかとは別に区間で判定するので、回復が終わってから道具を渡すこともできます。次の区間へ出発すると、渡せなくなります。先頭の交代と回復する人の選択は別々で、休んだ人が自動で先頭になるわけではありません。

walk の順番は、「終わっているか・休憩待ちか・分かれ道を選んだかを調べる → 同じ burden で負担を予告 → 先頭と体力を調べる → 先頭だけ体力を使う → 区間・道・記録を追加 → 終わりか、次の休憩待ちかを決める」です。全員が0なら救助ですが、6区間目は到着を優先します。成功しなかった操作で、休憩や記録を増やさないことが、あとの Undo にも役立ちます。

log には、歩いたときの景色・先頭・負担・得意・支え・持ち主・道具・全員の体力を残します。voices は、この記録から最後のひとことを作ります。うしろで支えた人は、いちばん多く使った道具の名前と、その道具で支えた回数を話します。同じ回数なら、道具の順番が先のものです。今の持ち物で、過去の働きを言い直すことはしません。画面は英数字の簡単な版なので日本語のひとことは描かず、voices(state) の返り値とテストで確かめます。04/05 の State と、歩く・渡す・休む関数は、元のゲームの model.py と同じふつうのルールです。

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

04_camps.py / 注目する部分
    hp[person] = min(5, hp[person] + 2)
    return replace(state, hp=tuple(hp), rest_pending=False,
                   rested=state.rested + ((state.segment, person),))

実行して確かめる

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

Windows
.venv\Scripts\python.exe 04_camps.py
macOS / Linux
.venv/bin/python 04_camps.py
画面で見ることB で Enter を2回押すと、区間2・体力 (1,5,5)・CAMP になります。1 → Enter で、アキが3に回復し、区間は2のままです。次に 2 → T → G でロープをフミに渡すと、回復したあとでも配り直せます。
  • 休憩は、歩くのとは別の入力
  • 回復したあとでも、休憩所なら道具を渡せる
  • 過去の支えを、歩いた記録から数える
つまずきやすいところ回復した直後に、同じ入力で歩かせないようにしましょう。体力の上限を決めるだけでは、続けて休憩するのは防げません。rest_pending を使い切って、1回だけにします。分かれ道を進んだら、branch を0に戻します。
少し変えてみよう

B で 2 → T → G(ロープをフミへ)、Enter を2回、1 → Enter(休憩)、2 → L → Enter(暗い道)、1 → L → Enter(がけ)、Enter(アキが休憩)、2 → L → Enter(平地)、3 → L → Enter(寒い道)と進めます。6区間に体力 (5,4,5) で到着します。別に、ルート C の区間2で上キーを押すと、がけから平地に変わり、歩いた記録の景色は「草原」になります。

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

"""一夜の探検隊・教材 4/5。Python 3.11+ / Pyxel 2.9.9。"""

from dataclasses import dataclass, replace, asdict

NAMES = ('AKI', 'FUMI', 'RUU')
JAPANESE_NAMES = ('アキ', 'フミ', 'ルウ')
TOOLS = ('LANTERN', 'ROPE', 'BLANKET')
JAPANESE_TOOLS = ('ランタン', 'ロープ', '毛布')
SKILLS = ('C', 'D', 'W')
LAND = {'D': 'DARK', 'C': 'CLIFF', 'W': 'COLD', 'P': 'PLAIN'}
JAPANESE_LAND = {'D': '暗道', 'C': '崖', 'W': '寒道', 'P': '平地'}
SCENERY = {'D': '夜の花', 'C': '町の灯り', 'W': '星と霜', 'P': '草原'}
BASE = {'D': 2, 'C': 3, 'W': 2, 'P': 1}
TOOL = {'D': 0, 'C': 1, 'W': 2}
ROUTES = {
    'A': (('D',), ('C',), ('P',), ('W',), ('D',), ('P',)),
    'B': (('C',), ('C',), ('D', 'W'), ('C',), ('P', 'D'), ('W',)),
    'C': (('W',), ('D',), ('C', 'P'), ('W',), ('D', 'C'), ('P',)),
}
INITIAL = {'A': (0, 1, 2), 'B': (2, 0, 1), 'C': (1, 2, 0)}

@dataclass(frozen=True)
class State:
    case: str = 'A'
    variant: str = 'rear'
    segment: int = 0
    hp: tuple = (5, 5, 5)
    order: tuple = (0, 1, 2)
    owners: tuple = (0, 1, 2)
    rest_pending: bool = False
    rested: tuple = ()
    path: tuple = ()
    log: tuple = ()
    ended: str = ''


def new(case='A', variant='rear'):
    if case not in ROUTES or variant not in ('rear', 'self'):
        raise ValueError('invalid configuration')
    return State(case=case, variant=variant, owners=INITIAL[case])


def editable(state):
    return not state.ended and state.segment in (0, 2, 4)


def terrain(state, branch=0):
    return ROUTES[state.case][state.segment][branch]


def burden(state, branch=0):
    land = terrain(state, branch)
    leader = state.order[0]
    expert = int(SKILLS[leader] == land)
    tool = TOOL.get(land)
    owner = state.owners[tool] if tool is not None else -1
    support = int(tool is not None and (owner != leader or state.variant == 'self'))
    cost = max(0, BASE[land] - expert - support)
    return land, cost, expert, support, owner, tool


def walk(state, branch=0):
    if (state.ended or state.rest_pending or state.segment >= 6
            or branch not in range(len(ROUTES[state.case][state.segment]))):
        return state
    land, cost, expert, support, owner, tool = burden(state, branch)
    leader = state.order[0]
    if state.hp[leader] == 0 or state.hp[leader] < cost:
        return state
    hp = list(state.hp)
    hp[leader] -= cost
    segment = state.segment + 1
    entry = (segment, land, SCENERY[land], leader, cost, expert,
             support, owner, tool, tuple(hp))
    ended = ('arrived' if segment == 6 else
             'rescue' if not any(hp) else '')
    return replace(state, segment=segment, hp=tuple(hp),
                   rest_pending=segment in (2, 4) and not ended,
                   path=state.path + (land,), log=state.log + (entry,), ended=ended)

def lead(state, person):
    if state.ended or person not in range(3) or state.hp[person] == 0:
        return state
    order = (person,) + tuple(p for p in state.order if p != person)
    return replace(state, order=order)


def give(state, tool, person):
    if not editable(state) or tool not in range(3) or person not in range(3):
        return state
    owners = list(state.owners)
    owners[tool] = person
    if owners.count(person) > 2:
        return state
    return replace(state, owners=tuple(owners))


def rest(state, person):
    if state.ended or not state.rest_pending or person not in range(3):
        return state
    hp = list(state.hp)
    hp[person] = min(5, hp[person] + 2)
    return replace(state, hp=tuple(hp), rest_pending=False,
                   rested=state.rested + ((state.segment, person),))

def step(state, action):
    # Invalid UI/API actions are harmless; parse before indexing.
    if not isinstance(action, str):
        return state
    bits = action.split(':')
    try:
        if len(bits) == 2 and bits[0] == 'walk':
            return walk(state, int(bits[1]))
        if len(bits) == 2 and bits[0] == 'lead':
            return lead(state, int(bits[1]))
        if len(bits) == 3 and bits[0] == 'give':
            return give(state, int(bits[1]), int(bits[2]))
        if len(bits) == 2 and bits[0] == 'rest':
            return rest(state, int(bits[1]))
    except (ValueError, TypeError):
        pass
    return state


def voices(state):
    """A line must be backed by the walk log, not the current inventory."""
    result = []
    for person in range(3):
        ahead = [row for row in state.log if row[3] == person]
        behind = [row for row in state.log if row[6] and row[7] == person
                  and row[3] != person]
        if behind:
            tool = max(range(3), key=lambda j: sum(row[8] == j for row in behind))
            count = sum(row[8] == tool for row in behind)
            line = JAPANESE_TOOLS[tool] + 'で後ろから' + str(count) + '回支えたよ'
        elif ahead:
            line = JAPANESE_LAND[ahead[-1][1]] + 'で先頭を歩いたよ'
        else:
            line = '一緒に夜明けを見に来たよ'
        result.append(JAPANESE_NAMES[person] + '「' + line + '」')
    return tuple(result)

STAGE = 4
KEYS = ('RETURN', 'R', '1', '2', '3', 'L', 'T', 'G', 'UP', 'C')
DEMO_KEYS = ('2', 'T', 'G', 'RETURN', 'RETURN', '1', 'RETURN', '2', 'L', 'RETURN', '1', 'L', 'RETURN', 'RETURN', '2', 'L', 'RETURN', '3', 'L', 'RETURN')


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

    def reset(self, case=None, variant=None):
        old = getattr(self, 'state', None)
        self.state = new(case or (old.case if old else 'B'),
                         variant or (old.variant if old else 'rear'))
        self.person = 0
        self.tool = 0
        self.branch = 0
        self.gated = False
        self.message = 'READY'

    def apply(self, action):
        old = self.state
        self.state = step(self.state, action)
        self.message = action if self.state != old else 'BLOCKED / NO CHANGE'
        if self.state.segment != old.segment:
            self.branch = 0

    def control(self, pressed, held):
        pressed, held = set(pressed), set(held)
        if 'R' in pressed:
            self.reset()
            self.gated = True
            return
        if self.gated:
            if not pressed and not held:
                self.gated = False
            return
        if len(pressed | held) > 1:
            self.gated = True
            self.message = 'RELEASE ALL KEYS'
            return
        if len(pressed) != 1:
            return
        key = next(iter(pressed))
        if key not in KEYS:
            return
        self.gated = True
        if key == 'C':
            self.reset('ABC'[('ABC'.index(self.state.case)+1)%3])
            self.gated = True
            return
        if self.state.ended:
            self.message = 'ENDED - R TO RESET'
            return
        if key in ('1', '2', '3'):
            self.person = int(key)-1
            self.message = 'SELECTED '+NAMES[self.person]
        elif key == 'L':
            self.apply('lead:'+str(self.person))
        elif key == 'T':
            self.tool = (self.tool+1)%3
            self.message = 'SELECTED '+TOOLS[self.tool]
        elif key == 'G':
            self.apply('give:'+str(self.tool)+':'+str(self.person))
        elif key == 'UP':
            self.branch = (self.branch+1)%len(ROUTES[self.state.case][self.state.segment])
            self.message = 'BRANCH '+str(self.branch+1)
        elif key == 'RETURN':
            action = ('rest:'+str(self.person) if self.state.rest_pending
                      else 'walk:'+str(self.branch))
            self.apply(action)

    def draw(self, p):
        state = self.state
        p.cls(1)
        p.text(12, 10, '090 NIGHT EXPEDITION / STAGE '+str(STAGE), 10)
        p.text(12, 25, 'SEGMENT '+str(state.segment)+'/6'+'  '+state.case+' / '+state.variant.upper()+'  '+(state.ended.upper() or 'WALKING'), 11)
        for i in range(6):
            p.rect(12+i*77, 43, 70, 21, 3 if i < state.segment else 2)
            p.text(18+i*77, 50, str(i+1)+':'+ '/'.join(ROUTES[state.case][i]), 7)
        for person in range(3):
            x = 12+person*156
            p.rectb(x, 78, 144, 106, 10 if person == self.person else 5)
            p.text(x+8, 88, str(person+1)+' '+NAMES[person], 7)
            p.text(x+8, 103, 'LEADER' if person == state.order[0] else 'REAR', 11)
            p.text(x+8, 118, 'HP '+str(state.hp[person])+'/5  SKILL '+SKILLS[person], 6)
            for n in range(5):
                p.rect(x+8+n*24, 133, 18, 9, 11 if n < state.hp[person] else 5)
            owned = ''.join('LRB'[j] for j in range(3) if state.owners[j] == person)
            p.text(x+8, 160, 'TOOLS '+(owned or '-'), 10)
        if not state.ended:
            land, cost, expert, *details = burden(state, self.branch)
            p.text(12, 199, 'NEXT '+LAND[land]+'  COST '+str(cost)+'  EXPERT -'+str(expert), 7)
            p.text(12, 213, 'SUPPORT -'+str(details[0])+' / ONLY THE LEADER PAYS', 6)
        if state.rest_pending:
            p.text(12, 229, 'CAMP: ENTER HEALS SELECTED PERSON +2 (MAX 5)', 10)
        elif state.ended:
            p.text(12, 229, 'LOGGED WALKS: '+str(len(state.log))+' / '+state.ended.upper(), 10)
        else:
            p.text(12, 229, 'ENTER WALKS / UP SELECTS PUBLIC BRANCH', 6)
        p.text(12, 247, 'TOOL '+TOOLS[self.tool]+' / '+('GIVE OPEN' if editable(state) else 'GIVE LOCKED'), 10)
        p.text(12, 263, 'T NEXT TOOL / G GIVE TO SELECTED / LIMIT 2', 6)
        p.text(12, 281, '1 2 3 SELECT / L LEADER / C NEXT ROUTE (RESET)', 7)
        p.text(12, 301, 'R RESET / ESC QUIT', 6)
        p.text(12, 337, self.message.upper(), 11)


def parse_args(argv=None):
    """すべてのCLI検証はPyxelをimportする前に終える。"""
    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('--frames', type=int)
    parser.add_argument('--screenshot')
    parser.add_argument('--state-output')
    args = parser.parse_args(argv)
    if not args.headless_smoke and (args.demo_checkpoint or args.frames is not None
                                   or args.screenshot or args.state_output):
        parser.error('QA options require --headless-smoke')
    minimum = 2 * len(DEMO_KEYS) + 4 if args.demo_checkpoint else 4
    if args.frames is None:
        args.frames = minimum
    if not minimum <= args.frames <= 600:
        parser.error('--frames must be between '+str(minimum)+' and 600')
    for name, suffix in (('screenshot', '.png'), ('state_output', '.json')):
        value = getattr(args, name)
        if value is not None and (not value.strip() or Path(value).suffix.lower() != suffix):
            parser.error('--'+name.replace('_', '-')+' requires a '+suffix+' file path')
    return args


def main(argv=None):
    args = parse_args(argv)
    from pathlib import Path
    import json
    import pyxel as p
    app = App()
    p.init(480, 352, title='090 Night Expedition', fps=30,
           display_scale=2, headless=args.headless_smoke)
    keys = {key: getattr(p, 'KEY_'+key) for key in KEYS}
    finished = False
    def update():
        if args.headless_smoke:
            index = p.frame_count // 2
            pressed = ({DEMO_KEYS[index]} if args.demo_checkpoint
                       and p.frame_count % 2 == 0 and index < len(DEMO_KEYS) else set())
            app.control(pressed, pressed)
        else:
            app.control({k for k, v in keys.items() if p.btnp(v)},
                        {k for k, v in keys.items() if p.btn(v)})
    def draw():
        nonlocal finished
        app.draw(p)
        if args.headless_smoke and p.frame_count >= args.frames-1 and not finished:
            finished = True
            if args.screenshot:
                path = Path(args.screenshot).resolve()
                path.parent.mkdir(parents=True, exist_ok=True)
                p.screenshot(str(path), scale=2)
            if args.state_output:
                path = Path(args.state_output).resolve()
                path.parent.mkdir(parents=True, exist_ok=True)
                path.write_text(json.dumps(asdict(app.state), ensure_ascii=False, indent=2)+'\n', encoding='utf-8')
            p.quit()
    p.run(update, draw)


if __name__ == '__main__':
    main()
STEP 05 / 05

一つ前の歩行直前へ、隊全体を戻す

歩く直前の保存・Undo で戻る範囲・1つの条件だけを変えて比べる。

X で1歩戻せるようにします。履歴に入れるのは、受け付けた walk の直前の State だけです。先頭の交代・道具を渡す・休憩を受け付けても、それだけでは履歴は増やしません。これは「前のキー入力」に戻るのではなく、元のゲームと同じ「1つ前の歩く直前」に戻る Undo です。隊員の選択や道具のカーソルは App 側にあり、状態の履歴には入れません。

Timeline は、今の state と、古い State を並べたタプル history です。act は、まず Undo かどうかを調べます。履歴があれば、最後の State を今の状態にして、履歴の最後を消します。ふつうの操作なら step で次の状態を作り、変わらなければ同じ Timeline を返します。受け付けた歩きのときだけ、その直前の状態を history に足します。書きかえない dataclass と、中身もタプルなので、あとの回復で、保存済みの体力まで書きかわることはありません。

B で「2歩 → 休憩 → 道具を渡す → X」と進めると、戻る先は2歩目の直前、区間1です。2歩目の疲れ、着いてからの回復、道具を渡したこと、休憩の記録、道、歩いた記録を、まとめて戻します。一方、休憩と道具を渡したあとに3歩目を歩いてから X を押すと、3歩目の直前の区間2に戻るので、その休憩と道具を渡したことは残ります。「どこを戻る地点にしたか」がちがうのです。

救助や到着のあとでも X は使えます。終わったときの「受け付けない」処理より先に、Undo を扱うからです。受け付けない歩き、同じ先頭や同じ持ち主への確定、履歴が空のときの X では、状態は増えません。R やルート C への切りかえでは、履歴も全部捨てます。救助になっても、戻る地点に戻って、隊列を考え直せます。

V で rear(うしろの人だけ)と self(自分も)を切りかえる比較も加えます。変えるルールは「先頭の本人が持っている道具も、軽減に使えるか」だけです。同じルートを最初からやり直して比べ、最初の体力・道具・休憩・到着の目標は同じです。B の最初は、アキがロープを持っているので、がけの負担は rear なら2、self なら1です。比べている途中で疲れを持ちこすと条件がそろわないので、V は履歴もふくめてリセットします。

元のゲームとの照らし合わせでは、道具の渡し方24通りすべて・隊列の6通りの順番・すべての地形の負担、全員の体力0〜5の組み合わせでの歩き、決まった乱数での操作の並びとひとこと、元のゲームの Controller の Undo の履歴を、別々に比べています。考えられるすべての長さの入力を証明したものではありません。元のゲームを指定しないと、4つの照らし合わせのテストだけがとばされ(SKIP)、教材のテストは単独で動きます。

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

05_undo.py / 注目する部分
    if action.startswith('walk:'):
        history += (timeline.state,)
    return Timeline(state, history)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 05_undo.py
macOS / Linux
.venv/bin/python 05_undo.py
画面で見ることB で Enter → Enter → Enter(アキが休憩)→ 2 → T → G(ロープをフミへ)→ X と押します。区間1、体力 (3,5,5)、道具 (2,0,1)、休憩の記録なしに戻ります。履歴は1件です。
  • 歩く直前だけを保存する
  • 休憩と道具を渡したことも含めて、隊全体を元に戻す
  • 比べるときは、1つの条件だけを変えて最初からやり直す
つまずきやすいところ回復の操作だけを Undo して「休憩できる」の印を戻さなかったり、体力だけを戻したりすると、2回回復できてしまったり、記録が食いちがったりします。「前の入力を1件消す」Undo では、元のゲームの戻る範囲とちがってしまいます。
少し変えてみよう

B で2歩進んでアキが休憩した状態は、区間2・体力 (3,5,5) です。X → Enter → Enter を5回くり返しても、同じ体力のままです。回復を増やし続けることはできません。次に R → V で self にして、最初のがけの負担が2から1に変わることを確かめましょう。04 の到着の手順を終えたあとに X を押すと区間5に戻り、Enter で同じ到着がもう一度できることも確かめます。

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

"""一夜の探検隊・教材 5/5。Python 3.11+ / Pyxel 2.9.9。"""

from dataclasses import dataclass, replace, asdict

NAMES = ('AKI', 'FUMI', 'RUU')
JAPANESE_NAMES = ('アキ', 'フミ', 'ルウ')
TOOLS = ('LANTERN', 'ROPE', 'BLANKET')
JAPANESE_TOOLS = ('ランタン', 'ロープ', '毛布')
SKILLS = ('C', 'D', 'W')
LAND = {'D': 'DARK', 'C': 'CLIFF', 'W': 'COLD', 'P': 'PLAIN'}
JAPANESE_LAND = {'D': '暗道', 'C': '崖', 'W': '寒道', 'P': '平地'}
SCENERY = {'D': '夜の花', 'C': '町の灯り', 'W': '星と霜', 'P': '草原'}
BASE = {'D': 2, 'C': 3, 'W': 2, 'P': 1}
TOOL = {'D': 0, 'C': 1, 'W': 2}
ROUTES = {
    'A': (('D',), ('C',), ('P',), ('W',), ('D',), ('P',)),
    'B': (('C',), ('C',), ('D', 'W'), ('C',), ('P', 'D'), ('W',)),
    'C': (('W',), ('D',), ('C', 'P'), ('W',), ('D', 'C'), ('P',)),
}
INITIAL = {'A': (0, 1, 2), 'B': (2, 0, 1), 'C': (1, 2, 0)}

@dataclass(frozen=True)
class State:
    case: str = 'A'
    variant: str = 'rear'
    segment: int = 0
    hp: tuple = (5, 5, 5)
    order: tuple = (0, 1, 2)
    owners: tuple = (0, 1, 2)
    rest_pending: bool = False
    rested: tuple = ()
    path: tuple = ()
    log: tuple = ()
    ended: str = ''


def new(case='A', variant='rear'):
    if case not in ROUTES or variant not in ('rear', 'self'):
        raise ValueError('invalid configuration')
    return State(case=case, variant=variant, owners=INITIAL[case])


def editable(state):
    return not state.ended and state.segment in (0, 2, 4)


def terrain(state, branch=0):
    return ROUTES[state.case][state.segment][branch]


def burden(state, branch=0):
    land = terrain(state, branch)
    leader = state.order[0]
    expert = int(SKILLS[leader] == land)
    tool = TOOL.get(land)
    owner = state.owners[tool] if tool is not None else -1
    support = int(tool is not None and (owner != leader or state.variant == 'self'))
    cost = max(0, BASE[land] - expert - support)
    return land, cost, expert, support, owner, tool


def walk(state, branch=0):
    if (state.ended or state.rest_pending or state.segment >= 6
            or branch not in range(len(ROUTES[state.case][state.segment]))):
        return state
    land, cost, expert, support, owner, tool = burden(state, branch)
    leader = state.order[0]
    if state.hp[leader] == 0 or state.hp[leader] < cost:
        return state
    hp = list(state.hp)
    hp[leader] -= cost
    segment = state.segment + 1
    entry = (segment, land, SCENERY[land], leader, cost, expert,
             support, owner, tool, tuple(hp))
    ended = ('arrived' if segment == 6 else
             'rescue' if not any(hp) else '')
    return replace(state, segment=segment, hp=tuple(hp),
                   rest_pending=segment in (2, 4) and not ended,
                   path=state.path + (land,), log=state.log + (entry,), ended=ended)

def lead(state, person):
    if state.ended or person not in range(3) or state.hp[person] == 0:
        return state
    order = (person,) + tuple(p for p in state.order if p != person)
    return replace(state, order=order)


def give(state, tool, person):
    if not editable(state) or tool not in range(3) or person not in range(3):
        return state
    owners = list(state.owners)
    owners[tool] = person
    if owners.count(person) > 2:
        return state
    return replace(state, owners=tuple(owners))


def rest(state, person):
    if state.ended or not state.rest_pending or person not in range(3):
        return state
    hp = list(state.hp)
    hp[person] = min(5, hp[person] + 2)
    return replace(state, hp=tuple(hp), rest_pending=False,
                   rested=state.rested + ((state.segment, person),))

def step(state, action):
    # Invalid UI/API actions are harmless; parse before indexing.
    if not isinstance(action, str):
        return state
    bits = action.split(':')
    try:
        if len(bits) == 2 and bits[0] == 'walk':
            return walk(state, int(bits[1]))
        if len(bits) == 2 and bits[0] == 'lead':
            return lead(state, int(bits[1]))
        if len(bits) == 3 and bits[0] == 'give':
            return give(state, int(bits[1]), int(bits[2]))
        if len(bits) == 2 and bits[0] == 'rest':
            return rest(state, int(bits[1]))
    except (ValueError, TypeError):
        pass
    return state


def voices(state):
    """A line must be backed by the walk log, not the current inventory."""
    result = []
    for person in range(3):
        ahead = [row for row in state.log if row[3] == person]
        behind = [row for row in state.log if row[6] and row[7] == person
                  and row[3] != person]
        if behind:
            tool = max(range(3), key=lambda j: sum(row[8] == j for row in behind))
            count = sum(row[8] == tool for row in behind)
            line = JAPANESE_TOOLS[tool] + 'で後ろから' + str(count) + '回支えたよ'
        elif ahead:
            line = JAPANESE_LAND[ahead[-1][1]] + 'で先頭を歩いたよ'
        else:
            line = '一緒に夜明けを見に来たよ'
        result.append(JAPANESE_NAMES[person] + '「' + line + '」')
    return tuple(result)


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


def act(timeline, action):
    # Undo is checked before the terminal-state guard in step/walk.
    if action == 'undo':
        if not timeline.history:
            return timeline
        return Timeline(timeline.history[-1], timeline.history[:-1])
    state = step(timeline.state, action)
    if state == timeline.state:
        return timeline
    history = timeline.history
    if action.startswith('walk:'):
        history += (timeline.state,)
    return Timeline(state, history)

STAGE = 5
KEYS = ('RETURN', 'R', '1', '2', '3', 'L', 'T', 'G', 'UP', 'C', 'X', 'V')
DEMO_KEYS = ('RETURN', 'RETURN', 'RETURN', '2', 'T', 'G', 'X')


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

    def reset(self, case=None, variant=None):
        old = getattr(self, 'state', None)
        self.state = new(case or (old.case if old else 'B'),
                         variant or (old.variant if old else 'rear'))
        self.timeline = Timeline(self.state)
        self.person = 0
        self.tool = 0
        self.branch = 0
        self.gated = False
        self.message = 'READY'

    def apply(self, action):
        old = self.state
        self.timeline = act(self.timeline, action)
        self.state = self.timeline.state
        self.message = action if self.state != old else 'BLOCKED / NO CHANGE'
        if self.state.segment != old.segment:
            self.branch = 0

    def control(self, pressed, held):
        pressed, held = set(pressed), set(held)
        if 'R' in pressed:
            self.reset()
            self.gated = True
            return
        if self.gated:
            if not pressed and not held:
                self.gated = False
            return
        if len(pressed | held) > 1:
            self.gated = True
            self.message = 'RELEASE ALL KEYS'
            return
        if len(pressed) != 1:
            return
        key = next(iter(pressed))
        if key not in KEYS:
            return
        self.gated = True
        if key == 'C':
            self.reset('ABC'[('ABC'.index(self.state.case)+1)%3])
            self.gated = True
            return
        if key == 'V':
            self.reset(variant='self' if self.state.variant == 'rear' else 'rear')
            self.gated = True
            return
        if key == 'X':
            self.apply('undo')
            return
        if self.state.ended:
            self.message = 'ENDED - R TO RESET'
            return
        if key in ('1', '2', '3'):
            self.person = int(key)-1
            self.message = 'SELECTED '+NAMES[self.person]
        elif key == 'L':
            self.apply('lead:'+str(self.person))
        elif key == 'T':
            self.tool = (self.tool+1)%3
            self.message = 'SELECTED '+TOOLS[self.tool]
        elif key == 'G':
            self.apply('give:'+str(self.tool)+':'+str(self.person))
        elif key == 'UP':
            self.branch = (self.branch+1)%len(ROUTES[self.state.case][self.state.segment])
            self.message = 'BRANCH '+str(self.branch+1)
        elif key == 'RETURN':
            action = ('rest:'+str(self.person) if self.state.rest_pending
                      else 'walk:'+str(self.branch))
            self.apply(action)

    def draw(self, p):
        state = self.state
        p.cls(1)
        p.text(12, 10, '090 NIGHT EXPEDITION / STAGE '+str(STAGE), 10)
        p.text(12, 25, 'SEGMENT '+str(state.segment)+'/6'+'  '+state.case+' / '+state.variant.upper()+'  '+(state.ended.upper() or 'WALKING'), 11)
        for i in range(6):
            p.rect(12+i*77, 43, 70, 21, 3 if i < state.segment else 2)
            p.text(18+i*77, 50, str(i+1)+':'+ '/'.join(ROUTES[state.case][i]), 7)
        for person in range(3):
            x = 12+person*156
            p.rectb(x, 78, 144, 106, 10 if person == self.person else 5)
            p.text(x+8, 88, str(person+1)+' '+NAMES[person], 7)
            p.text(x+8, 103, 'LEADER' if person == state.order[0] else 'REAR', 11)
            p.text(x+8, 118, 'HP '+str(state.hp[person])+'/5  SKILL '+SKILLS[person], 6)
            for n in range(5):
                p.rect(x+8+n*24, 133, 18, 9, 11 if n < state.hp[person] else 5)
            owned = ''.join('LRB'[j] for j in range(3) if state.owners[j] == person)
            p.text(x+8, 160, 'TOOLS '+(owned or '-'), 10)
        if not state.ended:
            land, cost, expert, *details = burden(state, self.branch)
            p.text(12, 199, 'NEXT '+LAND[land]+'  COST '+str(cost)+'  EXPERT -'+str(expert), 7)
            p.text(12, 213, 'SUPPORT -'+str(details[0])+' / ONLY THE LEADER PAYS', 6)
        if state.rest_pending:
            p.text(12, 229, 'CAMP: ENTER HEALS SELECTED PERSON +2 (MAX 5)', 10)
        elif state.ended:
            p.text(12, 229, 'LOGGED WALKS: '+str(len(state.log))+' / '+state.ended.upper(), 10)
        else:
            p.text(12, 229, 'ENTER WALKS / UP SELECTS PUBLIC BRANCH', 6)
        p.text(12, 247, 'TOOL '+TOOLS[self.tool]+' / '+('GIVE OPEN' if editable(state) else 'GIVE LOCKED'), 10)
        p.text(12, 263, 'T NEXT TOOL / G GIVE TO SELECTED / LIMIT 2', 6)
        p.text(12, 281, '1 2 3 SELECT / L LEADER / C NEXT ROUTE (RESET)', 7)
        p.text(12, 301, 'X UNDO WALK / V RULE+RESET / R RESET / ESC QUIT', 6)
        p.text(12, 316, 'WALK CHECKPOINTS '+str(len(self.timeline.history)), 13)
        p.text(12, 337, self.message.upper(), 11)


def parse_args(argv=None):
    """すべてのCLI検証はPyxelをimportする前に終える。"""
    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('--frames', type=int)
    parser.add_argument('--screenshot')
    parser.add_argument('--state-output')
    args = parser.parse_args(argv)
    if not args.headless_smoke and (args.demo_checkpoint or args.frames is not None
                                   or args.screenshot or args.state_output):
        parser.error('QA options require --headless-smoke')
    minimum = 2 * len(DEMO_KEYS) + 4 if args.demo_checkpoint else 4
    if args.frames is None:
        args.frames = minimum
    if not minimum <= args.frames <= 600:
        parser.error('--frames must be between '+str(minimum)+' and 600')
    for name, suffix in (('screenshot', '.png'), ('state_output', '.json')):
        value = getattr(args, name)
        if value is not None and (not value.strip() or Path(value).suffix.lower() != suffix):
            parser.error('--'+name.replace('_', '-')+' requires a '+suffix+' file path')
    return args


def main(argv=None):
    args = parse_args(argv)
    from pathlib import Path
    import json
    import pyxel as p
    app = App()
    p.init(480, 352, title='090 Night Expedition', fps=30,
           display_scale=2, headless=args.headless_smoke)
    keys = {key: getattr(p, 'KEY_'+key) for key in KEYS}
    finished = False
    def update():
        if args.headless_smoke:
            index = p.frame_count // 2
            pressed = ({DEMO_KEYS[index]} if args.demo_checkpoint
                       and p.frame_count % 2 == 0 and index < len(DEMO_KEYS) else set())
            app.control(pressed, pressed)
        else:
            app.control({k for k, v in keys.items() if p.btnp(v)},
                        {k for k, v in keys.items() if p.btn(v)})
    def draw():
        nonlocal finished
        app.draw(p)
        if args.headless_smoke and p.frame_count >= args.frames-1 and not finished:
            finished = True
            if args.screenshot:
                path = Path(args.screenshot).resolve()
                path.parent.mkdir(parents=True, exist_ok=True)
                p.screenshot(str(path), scale=2)
            if args.state_output:
                path = Path(args.state_output).resolve()
                path.parent.mkdir(parents=True, exist_ok=True)
                path.write_text(json.dumps(asdict(app.state), ensure_ascii=False, indent=2)+'\n', encoding='utf-8')
            p.quit()
    p.run(update, draw)


if __name__ == '__main__':
    main()
AFTER STEP 05

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

3人とも体力5で始めます。地形の負担を払うのは先頭だけで、得意な地形なら1、対応する道具をうしろの人が持っていればさらに1軽くなります。体力0のうしろの人も支えられますが、先頭にはなれません。道具は1人2個まで、区間0・2・4で渡せます。2・4区間のあとは、1人を+2(最大5)回復する休憩が1回あります。全員0の救助より、6区間目の到着を優先します。Undo は歩く直前の全状態に戻ります。期限・音・ランダムはありません。

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

比べる点5ステップの教材元の完成版
段階01 は1人・平地6区間・体力6、02 は状況 A の3人と得意な地形だけ、03 は状況 A のうしろの人の道具を追加(渡せるのは出発前だけ)、04 は状況 A・B・C と休憩・分かれ道(最初は B)、05 で歩きの Undo と比較 V です。最初から全員体力5で、道具・休憩・分かれ道・Undo があり、A から始まります。
操作数字で人を選び、L で確定、T で道具、G で渡す、Enter で歩く・休む、です。C と V は確認なしでリセットします。知らない入力や、こわれた入力は、何も変えずに受け付けません。左右・上下・Tab・Z で場面を切りかえます。表紙でルートと比較を選びます。R・M・Esc は確認の画面が出ます。step は、正しい形の入力が来る前提です。
表示と保存480×352 の英数字の画面です。日本語のひとこと(voices)はプログラムで作ってテストしますが、画面には出しません。外部の素材の読みこみ・音・入力の保存は省いています。テストでは状態と画像だけを出力します。640×480 の日本語の文字、ひとこと、表紙と説明、イベントの記録、比較の実験の保存があります。

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

  1. model.py: burden のexpert/support/ownerの関係
  2. model.py: walk の拒否→消費→到着優先→休憩待ち
  3. model.py: editable / give / rest の独立条件
  4. model.py: voices の記録に根ざした支援回数
  5. controller.py: apply のwalk直前履歴とfeedの選択/入力解放
  6. experiments.py: finite の全24配布・回復・先頭・分岐の有限探索
自分のゲームに変えるなら

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

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