完成サンプルをブラウザーで遊ぶ
この講座のもとになった完成版のゲームを、ブラウザーでそのまま遊べます。キーボードで操作するので、PCで開いてください(スマホのタッチ操作には対応していません)。初めて開くときは、読み込みに少し時間がかかります。
完成版の主な操作:矢印方向/Space待機/Z品・帰還を選ぶ、Enter一刻、X戻す、R同夜/N三夜再開
ボタンを押すと、ゲームの読み込みが始まります。
画面に「CLICK TO START」と出たらクリックします。続けてゲーム画面を一度クリックすると、キー操作が効くようになります。ゲームを終えるときは、枠の外にある「閉じる」ボタンを使ってください(Escキーの働きはゲームごとに違います)。ゲーム内のセーブは、閉じる・ページを移る・再読み込みすると消えます。
うまく表示されないとき
ゲームを動かすためのプログラム(Pyxel 2.9.9)は、外部の配信サービス(jsDelivr)から読み込みます。表示されないときは「最初から再読込」を押すか、完成版のソースをダウンロードしてPCで実行してください。ブラウザー版の録画機能は止めています。ブラウザー版で確認できている範囲は検証状況にまとめています。
前の夜の行動を、次の夜のルールに渡す。
いきなり完成版のコードを全部読む必要はありません。小さなゲームにルールを1つずつ足しながら、5つのステップで作っていきます。どのステップのファイルもそれだけで動くので、動きを確かめてから次へ進みましょう。
- 必要な環境
- Python 3.11以上 / Pyxel 2.9.9
- 知っておくとよいこと
- 変数と、整数の割り算・余り / 関数・if・辞書・
dataclass/replace - 教材に入っているもの
- ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
- 完成した教材の操作
- 矢印/Space/Z:行動を選択しEnterで確定。各キー間は全離し / X:03以降でUndo。確認中は取消 / R:確認して今夜再試行。コード再読込ではない / N/M/H/P:04以降で三夜リセット・表紙・説明 / S/L:05の幕間/結果で保存読込 / Esc:確認して終了
- 難易度の目安
- 中級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
- 学習順に迷ったら
- おすすめの学ぶ順番を見る
コードを書き換えたら、ファイルを保存し、動いているゲームを閉じてから、もう一度同じ.pyを実行します。ゲーム中にRキーなどで「やり直し」をしても、書き換えたコードは読み込まれません。
教材のゲーム画面は、日本語フォントを用意しなくても動くように英語で表示しています。ファイルには動作確認用のコードも入っていますが、最初は Game / State・update・draw の3つに注目すれば十分です。わからない言葉は用語集で調べられます。class・self・dataclass はPythonの状態、キー入力と時間の扱いは入力・更新・描画、マス目とピクセルの違いは画面とマップの座標のページで詳しく説明しています。
壁のある館で、選択と移動を分ける
1列に並べた位置の番号・書き換えない状態・キーを離すまで待つ仕組み。
最初は、7列×5行の館だけを作ります。位置 p の列は p%7(7で割った余り)、行は p//7(7で割った商)です。上・真ん中・下の横の道と、両はしの縦の道が FLOOR(歩ける床)です。S=14 から右へ動くと15ですが、そこから上の8は壁です。p に -7 を足すだけでは、壁も画面のはしも通り抜けてしまいます。動く先の x,y が範囲の中か、FLOOR かを両方調べ、無効なら同じ s を返します。
State は pos と t の2つの項目だけです。今は、警備も品物も夜もありません。矢印キーは selected(選んでいる行動)を変えるだけで、Enter のときに step に渡して、新しい State を代入します。draw は状態を読むだけです。枠の中の PREVIEW は、同じ step を仮に呼んで見せるだけで、本当の状態は置きかえません。t は有効な移動の回数で、現実の秒数やフレームの数ではありません。
Controller の previous と gate は、押しっぱなしと同時押しを区別するための、入力の足場です。history という配列は、あとの 03 で使うために共通の入力の部分に残していますが、この段階では Undo のキーは働きません。画面の描画、コマンド(CLI)、確認の画面も、それぞれの段階が1つのファイルで動くための足場です。ゲームの完成したルールは、まだありません。
キーは1回押すごとに、全部離してください。Enter を押し続けても、次の1刻は進みません。コードを書き換えたら保存し、Esc → キーを全部離す → Enter で終了して、同じ python のコマンドで実行し直します。R はゲームの状態をやり直すだけで、コードを読み直すわけではありません。
下のコードは 01_walk.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
x, y = s.pos % 7 + delta[0], s.pos // 7 + delta[1]
q = y * 7 + x
if not (0 <= x < 7 and 0 <= y < 5 and q in FLOOR):
return s
return replace(s, pos=q, t=s.t + 1)実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 01_walk.py.venv/bin/python 01_walk.py- キーで選ぶことと、確定することを分ける
- 壁や画面のはしでは、時刻も変わらない
pos/tだけで、ひとりで動く
self.s を更新しないでください。START = 14 を START = 0 に1か所変えてみましょう。保存 → 終了 → 実行し直します。左 → 離す → Enter → 離す は 0・0 のままです。右 → 離す → Enter → 離す で 1・1 になります。python exercises/01_start.py も、書き換えたコピーで同じ操作を実行します。確認したら14に戻します。
このステップのコード全文(326行)
# -*- coding: utf-8 -*-
# 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.
STAGE = 1
from dataclasses import dataclass, replace, asdict
START = 14
FLOOR = frozenset(p for p in range(35) if p % 7 in (0, 6) or p // 7 in (0, 2, 4))
ACTIONS = ('up', 'down', 'left', 'right')
@dataclass(frozen=True)
class State:
pos: int = START
t: int = 0
def new():
return State()
def step(s, action):
delta = {'up': (0, -1), 'down': (0, 1), 'left': (-1, 0), 'right': (1, 0)}.get(action)
if delta is None:
return s
x, y = s.pos % 7 + delta[0], s.pos // 7 + delta[1]
q = y * 7 + x
if not (0 <= x < 7 and 0 <= y < 5 and q in FLOOR):
return s
return replace(s, pos=q, t=s.t + 1)
KEYS = {'UP','DOWN','LEFT','RIGHT','RETURN','SPACE','Z','X','R','ESCAPE'}
class Controller:
def __init__(self, path=None):
self.s = new()
self.selected = 'right'
self.previous = set()
self.gate = False
self.dialog = ''
self.history = []
self.log = []
self.notice = ''
self.title = False
self.help = False
def apply(self, action):
if action == 'undo':
if not self.history:
return 'ignored:undo'
self.s = self.history.pop()
return 'undo'
old = self.s
self.s = step(old, action)
if self.s == old:
return 'ignored:' + action
self.history.append(old)
return action
def feed(self, down):
down = set(down) & KEYS
fresh = down - self.previous
self.previous = down
if self.gate:
if not down:
self.gate = False
return None
if not fresh:
return None
self.gate = True
if len(down) > 1:
return 'reject:chord'
key = next(iter(fresh))
if self.dialog:
if key in ('X', 'ESCAPE'):
self.dialog = ''
return 'cancel'
if key == 'RETURN':
dialog, self.dialog = self.dialog, ''
if dialog == 'quit':
return 'quit'
if STAGE == 1:
self.s = new()
return 'reset'
return self.apply('retry')
return 'ignored:dialog'
if key == 'ESCAPE':
self.dialog = 'quit'
return 'confirm:quit'
if key == 'R' and getattr(self.s, 'phase', 'play') == 'play':
self.dialog = 'retry'
return 'confirm:retry'
if key == 'X' and STAGE >= 3:
return self.apply('undo')
action = {'UP':'up','DOWN':'down','LEFT':'left','RIGHT':'right','SPACE':'wait','Z':'use'}.get(key)
if action in ACTIONS:
self.selected = action
return 'select:' + action
if key == 'RETURN':
return self.apply(self.selected)
return 'ignored:key'
# UI and diagnostic CLI: independent of the pure model.
import argparse
import json
from pathlib import Path
def tap(ui, key):
event = ui.feed({key})
ui.feed(set()) # Every distinct key press has a full release.
return event
def commit(ui, action):
if action == 'next':
return tap(ui, 'RETURN')
key = {'up':'UP','down':'DOWN','left':'LEFT','right':'RIGHT','wait':'SPACE','use':'Z'}[action]
tap(ui, key)
return tap(ui, 'RETURN')
def demo(ui, scenario):
if scenario == 'initial':
return
if ui.title:
tap(ui, 'RETURN')
if scenario == 'checkpoint':
commit(ui, 'right')
return
if scenario == 'wall':
commit(ui, 'right'); commit(ui, 'up')
elif scenario in ('caught', 'preview') and STAGE >= 2:
if STAGE >= 4:
ui.s = new('A')
ui.s = replace(ui.s, guards=(0, 1, 0))
commit(ui, 'right'); commit(ui, 'right'); commit(ui, 'wait')
tap(ui, 'RIGHT')
if scenario == 'caught':
tap(ui, 'RETURN')
elif scenario == 'timeout' and STAGE >= 2:
for _ in range(40):
commit(ui, 'wait')
elif scenario == 'confirm':
commit(ui, 'right'); tap(ui, 'R')
elif scenario in ('returned', 'next', 'undo_next', 'success', 'saved', 'loaded', 'return40') and STAGE >= 3:
nights = 3 if scenario == 'success' and STAGE >= 4 else 1
for night in range(nights):
if night:
commit(ui, 'next')
# A deterministic public model route, waiting before occupied gates.
for direction in ('right', 'left'):
for _ in range(6):
q = ui.s.pos + (1 if direction == 'right' else -1)
while q in GATES and occupied(ui.s.guards[GATES.index(q)], ui.s.t + 1):
commit(ui, 'wait')
commit(ui, direction)
if direction == 'left' and scenario == 'return40':
while ui.s.t < 39:
commit(ui, 'wait')
commit(ui, 'use')
if scenario in ('next', 'undo_next') and STAGE >= 4:
commit(ui, 'next')
if scenario == 'undo_next':
tap(ui, 'X')
if scenario in ('saved','loaded') and STAGE == 5:
tap(ui, 'S' if scenario == 'saved' else 'L')
else:
raise ValueError('scenario not available at this stage')
def parse_args(argv=None):
parser = argparse.ArgumentParser(description='Single-file Pyxel lesson 093')
parser.add_argument('--headless-smoke', action='store_true')
parser.add_argument('--demo-checkpoint', action='store_true')
parser.add_argument('--scenario', choices=['initial','checkpoint','wall','confirm'] +
(['caught','preview','timeout'] if STAGE >= 2 else []) +
(['returned','return40'] if STAGE >= 3 else []) +
(['next','undo_next','success'] if STAGE >= 4 else []) +
(['saved','loaded'] if STAGE == 5 else []))
parser.add_argument('--screenshot', type=Path)
parser.add_argument('--state-json', type=Path)
parser.add_argument('--save-file', type=Path, default=Path('save_093.json'))
args = parser.parse_args(argv)
if not args.headless_smoke and (args.demo_checkpoint or args.scenario or args.screenshot or args.state_json):
parser.error('diagnostic flags require --headless-smoke')
if args.demo_checkpoint and args.scenario:
parser.error('choose --demo-checkpoint or --scenario')
# Resolve before pyxel.init, which can alter working-directory behavior.
for name, suffix in (('screenshot','.png'),('state_json','.json')):
path = getattr(args, name)
if path is None:
continue
path = path.resolve()
if path.suffix.lower() != suffix or not path.parent.is_dir() or path.exists():
parser.error(name + ': require a new file with correct suffix in an existing directory')
setattr(args, name, path)
args.save_file = args.save_file.resolve()
if args.save_file.suffix.lower() != '.json' or not args.save_file.parent.is_dir() or args.save_file.is_dir():
parser.error('save-file: require a .json file in an existing directory')
if args.save_file in (args.screenshot, args.state_json):
parser.error('save-file and diagnostic outputs must differ')
if args.scenario == 'saved' and args.save_file.exists():
parser.error('saved scenario must use a new save file')
return args
class App:
def __init__(self, args):
self.args = args
self.ui = Controller(args.save_file)
self.frame = 0
if args.headless_smoke:
demo(self.ui, args.scenario or ('checkpoint' if args.demo_checkpoint else 'initial'))
pyxel.init(560, 400, title='093 THREE NIGHTS / STAGE ' + str(STAGE),
fps=30, quit_key=pyxel.KEY_NONE, headless=args.headless_smoke, display_scale=1)
self.keys = {key: getattr(pyxel, 'KEY_' + key) for key in KEYS}
pyxel.run(self.update, self.draw)
def update(self):
self.frame += 1
if self.args.headless_smoke:
if self.frame >= 3:
pyxel.quit()
return
event = self.ui.feed({key for key, code in self.keys.items() if pyxel.btn(code)})
if event == 'quit':
pyxel.quit()
def draw(self):
u, s = self.ui, self.ui.s
pyxel.cls(1)
pyxel.text(12, 10, '093 THREE NIGHTS | STAGE ' + str(STAGE), 10)
pyxel.text(12, 24, 'SELECT: ' + u.selected.upper() + ' TICK: ' + str(s.t) + ' POS: ' + str(s.pos), 7)
pyxel.text(300, 24, 'NIGHT: ' + str(getattr(s,'night',0)+1) + ' PHASE: ' + getattr(s,'phase','play').upper(), 11)
for p in range(35):
x, y = 12 + p % 7 * 42, 48 + p // 7 * 42
pyxel.rect(x, y, 39, 39, 5 if p in FLOOR else 2)
if p in FLOOR:
pyxel.rectb(x, y, 39, 39, 13)
if STAGE >= 2 and p in GATES:
g = s.guards[GATES.index(p)]
pyxel.rectb(x+3,y+3,33,23,8 if occupied(g,s.t) else 11)
pyxel.text(x+5,y+28,'G'+str(g),7)
if p == START:
pyxel.text(x+3,y+29,'S',11)
if STAGE >= 3 and p == TREASURE:
pyxel.text(x+3,y+29,'T',10)
if not s.holding and s.phase == 'play':
pyxel.circ(x+25,y+16,5,10)
x,y = 12+s.pos%7*42+19,48+s.pos//7*42+14
pyxel.circ(x,y,6,12)
if getattr(s,'holding',False):
pyxel.circ(x+9,y,3,10)
pyxel.rect(315,48,233,210,2)
if STAGE >= 2:
pyxel.text(325,60,'PUBLIC GUARDS / NEXT 3 TICKS' if getattr(s,'phase','play') == 'play' else 'RECORDED GUARDS / FINISHED NIGHT',10)
for i,g in enumerate(s.guards):
future = ' '.join('BUSY' if occupied(g,s.t+k) else 'OPEN' for k in (1,2,3))
pyxel.text(325,80+i*18,str(i)+': '+(future if getattr(s,'phase','play') == 'play' else 'LEVEL '+str(g)),7)
pyxel.text(325,143,'FAILS: '+str(s.fails)+' TOTAL: '+str(s.total),7)
if STAGE >= 3:
pyxel.text(325,163,'HOLDING: '+str(int(s.holding)),10)
pyxel.text(325,179,'HOME: '+str(len(s.items))+' USED BITS: '+str(s.used),11)
phase = getattr(s,'phase','play')
if phase == 'play':
preview = step(s,u.selected)
msg = 'NO CHANGE' if preview == s else 'CAUGHT / RESET' if getattr(preview,'fails',0)>getattr(s,'fails',0) else 'VALID ONE TICK'
pyxel.text(325,208,'PREVIEW: '+msg,8 if 'RESET' in msg else 6)
pyxel.text(325,225,'ENTER commits; preview is read-only.',7)
else:
pyxel.text(325,208,'RESULT: '+phase.upper(),10)
if phase == 'intermission':
next_guards = adapted(s.guards,s.used) if s.variant == 'adaptive' else s.guards
pyxel.text(325,224,'NEXT GUARDS: '+str(next_guards),11)
else:
pyxel.text(325,224,'NO FUTURE GUARD UPDATE',11)
pyxel.text(12,272,'Arrows: select direction ENTER: commit Release ALL keys between presses',7)
if STAGE >= 2: pyxel.text(12,288,'SPACE: select wait R: confirm retry (current night only)',6)
if STAGE >= 3: pyxel.text(12,304,'Z: select pickup/return X: Undo one committed decision',6)
if STAGE >= 4: pyxel.text(12,320,'N: new M: menu H/P: help Title: 1/2/3 case, B variant',6)
if STAGE == 5: pyxel.text(12,336,'S/L: save/load at intermission or result only',6)
pyxel.text(12,358,'ESC: confirm quit. R never reloads edited Python code.',7)
if u.notice:
notice = 'SAVE OK' if '保存しました' in u.notice else 'LOAD OK' if '読み込みました' in u.notice else 'SAVE/LOAD ERROR: file preserved' if '読めません' in u.notice else 'SAVE/LOAD: intermission or result only'
pyxel.text(12,375,notice,10)
if u.title or u.help:
pyxel.rect(35,70,490,175,1); pyxel.rectb(35,70,490,175,10)
pyxel.text(50,85,'THREE NIGHTS: BRING HOME THREE FICTIONAL KEEPSAKES',10)
pyxel.text(50,110,'Directions / SPACE / Z select. ENTER commits one action.',7)
pyxel.text(50,130,'No real-world intrusion instructions. This is a grid puzzle.',6)
pyxel.text(50,155,'Case: '+s.case+' Variant: '+s.variant,11)
pyxel.text(50,175,'1/2/3 choose case. B changes adaptation before starting.',7)
pyxel.text(50,205,'ENTER: start H/P: close help. Release keys first.',10)
if u.dialog:
pyxel.rect(35,125,490,105,1); pyxel.rectb(35,125,490,105,8)
pyxel.text(50,145,'CONFIRM: '+u.dialog.upper(),10)
pyxel.text(50,170,'Release all keys, then ENTER confirms. X cancels.',7)
pyxel.text(50,193,'Retry keeps previous nights. New resets all three.',6)
if self.args.headless_smoke and self.frame == 2:
if self.args.screenshot:
pyxel.screenshot(str(self.args.screenshot),scale=1)
if self.args.state_json:
data={'stage':STAGE,'state':asdict(s),'selected':u.selected,'title':u.title,
'dialog':u.dialog,'history':[asdict(v) for v in u.history],
'scope':'Actual Pyxel Linux headless rendering; synthetic controller inputs'}
with self.args.state_json.open('x',encoding='utf-8') as handle:
json.dump(data,handle,ensure_ascii=False,indent=2)
if __name__ == '__main__':
args = parse_args()
import pyxel
App(args)新しい刻の門番を読む
くり返しの周期・見せている予告・40刻とやり直し。
02 で、GATES=(3,17,31)(門の位置)と guards(警備)を加えます。教材の最初の警備は、真ん中だけが1です。これは時間の流れを観察しやすい練習用の配置で、元のゲームの状況 A の最初の値ではありません。警備0はいつもいない、1は偶数の刻、2は3で割った余りが0か1の刻に、門にいます。行動したあとの t=s.t+1 で、動いた先の p と重なるかを調べます。となりのマスや、視線の判定はありません。
Space は「待つ」を選びます。Enter を押すまで、時間は進みません。右の PUBLIC GUARDS は、次の3刻の、見せている警備の周期、PREVIEW は、今選んでいる行動の1刻ぶんだけの予告です。予告の CAUGHT / RESET(見つかる・やり直し)は、まだ失敗した結果ではありません。実際に Enter で確定したときだけ、fails(失敗の回数)が増え、入口・t0 に戻り、その挑戦で使った刻が total に入ります。門から出たなら、古い場所との重なりは調べません。
有効な行動が40刻になっても、入口に戻ります。失敗しても guards は変わりません。R → 離す で確認の画面が開き、Enter → 離す で今の練習を入口に戻します。X → 離す なら取り消しです。R でのやり直しでは、fails/total は増えません。まだ品物と夜がないので、成功で終わることはありません。
キーは1回押すごとに、全部離してください。Enter を押し続けても、次の1刻は進みません。コードを書き換えたら保存し、Esc → キーを全部離す → Enter で終了して、同じ python のコマンドで実行し直します。R はゲームの状態をやり直すだけで、コードを読み直すわけではありません。
下のコードは 02_guard_clock.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
t = s.t + 1
if p in GATES and occupied(s.guards[GATES.index(p)], t):
return retry(s, True, t)
if t >= 40:
return retry(s, True, t)実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 02_guard_clock.py.venv/bin/python 02_guard_clock.py- 1刻先の予告と、実際の結果を区別する
- 無効な移動では、刻は進まない
- やり直しても、警備は変わらない
s.t の時点の警備を調べると、1刻ずれてしまいます。待つ場合も新しい刻で判定するので、門の上で待つのが安全とは限りません。State の guards: tuple = (0, 1, 0) を (0, 2, 0) に変え、保存 → 終了 → 実行し直してみましょう。右 → 離す → Enter → 離す を3回押します。3刻目に真ん中の門で見つかり、FAILS=1、TOTAL=3、TICK=0 です。元の警備1なら、3刻目は通れます。python exercises/02_cycle.py で両方の版を実行できます。最後に (0, 1, 0) に戻します。
このステップのコード全文(349行)
# -*- coding: utf-8 -*-
# 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.
STAGE = 2
from dataclasses import dataclass, replace, asdict
START = 14
GATES = (3, 17, 31)
FLOOR = frozenset(p for p in range(35) if p % 7 in (0, 6) or p // 7 in (0, 2, 4))
ACTIONS = ('up', 'down', 'left', 'right', 'wait')
@dataclass(frozen=True)
class State:
pos: int = START
t: int = 0
guards: tuple = (0, 1, 0)
fails: int = 0
total: int = 0
def new():
return State()
def occupied(level, t):
return level == 1 and t % 2 == 0 or level == 2 and t % 3 in (0, 1)
def forecast(s):
return tuple(tuple(occupied(g, s.t + k) for g in s.guards) for k in range(1, 4))
def retry(s, failure=False, spent=0):
return replace(s, pos=START, t=0, fails=s.fails + int(failure), total=s.total + spent)
def step(s, action):
if action == 'retry':
return retry(s)
if action not in ACTIONS:
return s
p = s.pos
delta = {'up': (0, -1), 'down': (0, 1), 'left': (-1, 0), 'right': (1, 0)}.get(action)
if delta:
x, y = p % 7 + delta[0], p // 7 + delta[1]
q = y * 7 + x
if not (0 <= x < 7 and 0 <= y < 5 and q in FLOOR):
return s
p = q
t = s.t + 1
if p in GATES and occupied(s.guards[GATES.index(p)], t):
return retry(s, True, t)
if t >= 40:
return retry(s, True, t)
return replace(s, pos=p, t=t)
KEYS = {'UP','DOWN','LEFT','RIGHT','RETURN','SPACE','Z','X','R','ESCAPE'}
class Controller:
def __init__(self, path=None):
self.s = new()
self.selected = 'right'
self.previous = set()
self.gate = False
self.dialog = ''
self.history = []
self.log = []
self.notice = ''
self.title = False
self.help = False
def apply(self, action):
if action == 'undo':
if not self.history:
return 'ignored:undo'
self.s = self.history.pop()
return 'undo'
old = self.s
self.s = step(old, action)
if self.s == old:
return 'ignored:' + action
self.history.append(old)
return action
def feed(self, down):
down = set(down) & KEYS
fresh = down - self.previous
self.previous = down
if self.gate:
if not down:
self.gate = False
return None
if not fresh:
return None
self.gate = True
if len(down) > 1:
return 'reject:chord'
key = next(iter(fresh))
if self.dialog:
if key in ('X', 'ESCAPE'):
self.dialog = ''
return 'cancel'
if key == 'RETURN':
dialog, self.dialog = self.dialog, ''
if dialog == 'quit':
return 'quit'
if STAGE == 1:
self.s = new()
return 'reset'
return self.apply('retry')
return 'ignored:dialog'
if key == 'ESCAPE':
self.dialog = 'quit'
return 'confirm:quit'
if key == 'R' and getattr(self.s, 'phase', 'play') == 'play':
self.dialog = 'retry'
return 'confirm:retry'
if key == 'X' and STAGE >= 3:
return self.apply('undo')
action = {'UP':'up','DOWN':'down','LEFT':'left','RIGHT':'right','SPACE':'wait','Z':'use'}.get(key)
if action in ACTIONS:
self.selected = action
return 'select:' + action
if key == 'RETURN':
return self.apply(self.selected)
return 'ignored:key'
# UI and diagnostic CLI: independent of the pure model.
import argparse
import json
from pathlib import Path
def tap(ui, key):
event = ui.feed({key})
ui.feed(set()) # Every distinct key press has a full release.
return event
def commit(ui, action):
if action == 'next':
return tap(ui, 'RETURN')
key = {'up':'UP','down':'DOWN','left':'LEFT','right':'RIGHT','wait':'SPACE','use':'Z'}[action]
tap(ui, key)
return tap(ui, 'RETURN')
def demo(ui, scenario):
if scenario == 'initial':
return
if ui.title:
tap(ui, 'RETURN')
if scenario == 'checkpoint':
commit(ui, 'right')
return
if scenario == 'wall':
commit(ui, 'right'); commit(ui, 'up')
elif scenario in ('caught', 'preview') and STAGE >= 2:
if STAGE >= 4:
ui.s = new('A')
ui.s = replace(ui.s, guards=(0, 1, 0))
commit(ui, 'right'); commit(ui, 'right'); commit(ui, 'wait')
tap(ui, 'RIGHT')
if scenario == 'caught':
tap(ui, 'RETURN')
elif scenario == 'timeout' and STAGE >= 2:
for _ in range(40):
commit(ui, 'wait')
elif scenario == 'confirm':
commit(ui, 'right'); tap(ui, 'R')
elif scenario in ('returned', 'next', 'undo_next', 'success', 'saved', 'loaded', 'return40') and STAGE >= 3:
nights = 3 if scenario == 'success' and STAGE >= 4 else 1
for night in range(nights):
if night:
commit(ui, 'next')
# A deterministic public model route, waiting before occupied gates.
for direction in ('right', 'left'):
for _ in range(6):
q = ui.s.pos + (1 if direction == 'right' else -1)
while q in GATES and occupied(ui.s.guards[GATES.index(q)], ui.s.t + 1):
commit(ui, 'wait')
commit(ui, direction)
if direction == 'left' and scenario == 'return40':
while ui.s.t < 39:
commit(ui, 'wait')
commit(ui, 'use')
if scenario in ('next', 'undo_next') and STAGE >= 4:
commit(ui, 'next')
if scenario == 'undo_next':
tap(ui, 'X')
if scenario in ('saved','loaded') and STAGE == 5:
tap(ui, 'S' if scenario == 'saved' else 'L')
else:
raise ValueError('scenario not available at this stage')
def parse_args(argv=None):
parser = argparse.ArgumentParser(description='Single-file Pyxel lesson 093')
parser.add_argument('--headless-smoke', action='store_true')
parser.add_argument('--demo-checkpoint', action='store_true')
parser.add_argument('--scenario', choices=['initial','checkpoint','wall','confirm'] +
(['caught','preview','timeout'] if STAGE >= 2 else []) +
(['returned','return40'] if STAGE >= 3 else []) +
(['next','undo_next','success'] if STAGE >= 4 else []) +
(['saved','loaded'] if STAGE == 5 else []))
parser.add_argument('--screenshot', type=Path)
parser.add_argument('--state-json', type=Path)
parser.add_argument('--save-file', type=Path, default=Path('save_093.json'))
args = parser.parse_args(argv)
if not args.headless_smoke and (args.demo_checkpoint or args.scenario or args.screenshot or args.state_json):
parser.error('diagnostic flags require --headless-smoke')
if args.demo_checkpoint and args.scenario:
parser.error('choose --demo-checkpoint or --scenario')
# Resolve before pyxel.init, which can alter working-directory behavior.
for name, suffix in (('screenshot','.png'),('state_json','.json')):
path = getattr(args, name)
if path is None:
continue
path = path.resolve()
if path.suffix.lower() != suffix or not path.parent.is_dir() or path.exists():
parser.error(name + ': require a new file with correct suffix in an existing directory')
setattr(args, name, path)
args.save_file = args.save_file.resolve()
if args.save_file.suffix.lower() != '.json' or not args.save_file.parent.is_dir() or args.save_file.is_dir():
parser.error('save-file: require a .json file in an existing directory')
if args.save_file in (args.screenshot, args.state_json):
parser.error('save-file and diagnostic outputs must differ')
if args.scenario == 'saved' and args.save_file.exists():
parser.error('saved scenario must use a new save file')
return args
class App:
def __init__(self, args):
self.args = args
self.ui = Controller(args.save_file)
self.frame = 0
if args.headless_smoke:
demo(self.ui, args.scenario or ('checkpoint' if args.demo_checkpoint else 'initial'))
pyxel.init(560, 400, title='093 THREE NIGHTS / STAGE ' + str(STAGE),
fps=30, quit_key=pyxel.KEY_NONE, headless=args.headless_smoke, display_scale=1)
self.keys = {key: getattr(pyxel, 'KEY_' + key) for key in KEYS}
pyxel.run(self.update, self.draw)
def update(self):
self.frame += 1
if self.args.headless_smoke:
if self.frame >= 3:
pyxel.quit()
return
event = self.ui.feed({key for key, code in self.keys.items() if pyxel.btn(code)})
if event == 'quit':
pyxel.quit()
def draw(self):
u, s = self.ui, self.ui.s
pyxel.cls(1)
pyxel.text(12, 10, '093 THREE NIGHTS | STAGE ' + str(STAGE), 10)
pyxel.text(12, 24, 'SELECT: ' + u.selected.upper() + ' TICK: ' + str(s.t) + ' POS: ' + str(s.pos), 7)
pyxel.text(300, 24, 'NIGHT: ' + str(getattr(s,'night',0)+1) + ' PHASE: ' + getattr(s,'phase','play').upper(), 11)
for p in range(35):
x, y = 12 + p % 7 * 42, 48 + p // 7 * 42
pyxel.rect(x, y, 39, 39, 5 if p in FLOOR else 2)
if p in FLOOR:
pyxel.rectb(x, y, 39, 39, 13)
if STAGE >= 2 and p in GATES:
g = s.guards[GATES.index(p)]
pyxel.rectb(x+3,y+3,33,23,8 if occupied(g,s.t) else 11)
pyxel.text(x+5,y+28,'G'+str(g),7)
if p == START:
pyxel.text(x+3,y+29,'S',11)
if STAGE >= 3 and p == TREASURE:
pyxel.text(x+3,y+29,'T',10)
if not s.holding and s.phase == 'play':
pyxel.circ(x+25,y+16,5,10)
x,y = 12+s.pos%7*42+19,48+s.pos//7*42+14
pyxel.circ(x,y,6,12)
if getattr(s,'holding',False):
pyxel.circ(x+9,y,3,10)
pyxel.rect(315,48,233,210,2)
if STAGE >= 2:
pyxel.text(325,60,'PUBLIC GUARDS / NEXT 3 TICKS' if getattr(s,'phase','play') == 'play' else 'RECORDED GUARDS / FINISHED NIGHT',10)
for i,g in enumerate(s.guards):
future = ' '.join('BUSY' if occupied(g,s.t+k) else 'OPEN' for k in (1,2,3))
pyxel.text(325,80+i*18,str(i)+': '+(future if getattr(s,'phase','play') == 'play' else 'LEVEL '+str(g)),7)
pyxel.text(325,143,'FAILS: '+str(s.fails)+' TOTAL: '+str(s.total),7)
if STAGE >= 3:
pyxel.text(325,163,'HOLDING: '+str(int(s.holding)),10)
pyxel.text(325,179,'HOME: '+str(len(s.items))+' USED BITS: '+str(s.used),11)
phase = getattr(s,'phase','play')
if phase == 'play':
preview = step(s,u.selected)
msg = 'NO CHANGE' if preview == s else 'CAUGHT / RESET' if getattr(preview,'fails',0)>getattr(s,'fails',0) else 'VALID ONE TICK'
pyxel.text(325,208,'PREVIEW: '+msg,8 if 'RESET' in msg else 6)
pyxel.text(325,225,'ENTER commits; preview is read-only.',7)
else:
pyxel.text(325,208,'RESULT: '+phase.upper(),10)
if phase == 'intermission':
next_guards = adapted(s.guards,s.used) if s.variant == 'adaptive' else s.guards
pyxel.text(325,224,'NEXT GUARDS: '+str(next_guards),11)
else:
pyxel.text(325,224,'NO FUTURE GUARD UPDATE',11)
pyxel.text(12,272,'Arrows: select direction ENTER: commit Release ALL keys between presses',7)
if STAGE >= 2: pyxel.text(12,288,'SPACE: select wait R: confirm retry (current night only)',6)
if STAGE >= 3: pyxel.text(12,304,'Z: select pickup/return X: Undo one committed decision',6)
if STAGE >= 4: pyxel.text(12,320,'N: new M: menu H/P: help Title: 1/2/3 case, B variant',6)
if STAGE == 5: pyxel.text(12,336,'S/L: save/load at intermission or result only',6)
pyxel.text(12,358,'ESC: confirm quit. R never reloads edited Python code.',7)
if u.notice:
notice = 'SAVE OK' if '保存しました' in u.notice else 'LOAD OK' if '読み込みました' in u.notice else 'SAVE/LOAD ERROR: file preserved' if '読めません' in u.notice else 'SAVE/LOAD: intermission or result only'
pyxel.text(12,375,notice,10)
if u.title or u.help:
pyxel.rect(35,70,490,175,1); pyxel.rectb(35,70,490,175,10)
pyxel.text(50,85,'THREE NIGHTS: BRING HOME THREE FICTIONAL KEEPSAKES',10)
pyxel.text(50,110,'Directions / SPACE / Z select. ENTER commits one action.',7)
pyxel.text(50,130,'No real-world intrusion instructions. This is a grid puzzle.',6)
pyxel.text(50,155,'Case: '+s.case+' Variant: '+s.variant,11)
pyxel.text(50,175,'1/2/3 choose case. B changes adaptation before starting.',7)
pyxel.text(50,205,'ENTER: start H/P: close help. Release keys first.',10)
if u.dialog:
pyxel.rect(35,125,490,105,1); pyxel.rectb(35,125,490,105,8)
pyxel.text(50,145,'CONFIRM: '+u.dialog.upper(),10)
pyxel.text(50,170,'Release all keys, then ENTER confirms. X cancels.',7)
pyxel.text(50,193,'Retry keeps previous nights. New resets all three.',6)
if self.args.headless_smoke and self.frame == 2:
if self.args.screenshot:
pyxel.screenshot(str(self.args.screenshot),scale=1)
if self.args.state_json:
data={'stage':STAGE,'state':asdict(s),'selected':u.selected,'title':u.title,
'dialog':u.dialog,'history':[asdict(v) for v in u.history],
'scope':'Actual Pyxel Linux headless rendering; synthetic controller inputs'}
with self.args.state_json.open('x',encoding='utf-8') as handle:
json.dump(data,handle,ensure_ascii=False,indent=2)
if __name__ == '__main__':
args = parse_args()
import pyxel
App(args)包んで帰る一夜を完成させる
持っている品物・帰ってくる条件・履歴の写し。
03 は、1つの夜だけの完成したゲームです。警備を元のゲームの状況 A と同じ0に戻し、右はしの T=20 で Z を選んで Enter で品物を包みます。T に着いただけでは持てません。holding=True(持っている)で入口 S に帰り、もう一度 Z → Enter で帰ると、HOME=1・DONE になります。品物がない入口や、移動の途中での use(使う)は無効で、刻も履歴も増えません。
used は、通った門の集まりを、3つのビットで記録します。真ん中を往復しても2のままです。trace には、有効な1刻ごとの位置を足していき、最初の位置も含むので、長さは t+1 です。この2つの項目は、04 で足あとを次の夜に渡すための準備ですが、03 でも画面の USED BITS と状態の JSON で確かめられます。
40刻目でも入口に帰れれば、DONE を先に決めるので成功です。失敗や R でのやり直しでは、持ち物・足あと・trace を、今の夜の最初の値に戻します。03 には、前の夜はまだありません。X は、変わる前の State を history から1つ戻します。位置だけを逆算せず、時刻・持ち物・結果・失敗・total も一緒に戻ります。DONE からも、帰りを確定する前に戻せます。
キーは1回押すごとに、全部離してください。Enter を押し続けても、次の1刻は進みません。コードを書き換えたら保存し、Esc → キーを全部離す → Enter で終了して、同じ python のコマンドで実行し直します。R はゲームの状態をやり直すだけで、コードを読み直すわけではありません。
下のコードは 03_one_night.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
if action == 'use':
if p == TREASURE:
holding = True
else:
holding, items, phase, total = False, (0,), 'done', total + t
if t >= 40 and phase == 'play':
return retry(s, True, t)実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 03_one_night.py.venv/bin/python 03_one_night.py- 品物を取ることと、持ち帰ることは、別の1刻
- 40刻目の成功は、成功のまま
- 結果が出たあとの Undo も、状態全部を戻す
t>=40 の判定を帰りの処理より前に置くと、40刻目の正しい帰りを失敗にしてしまいます。X で t だけを戻すと、持ち物とつじつまが合わなくなります。State の guards: tuple = (0, 0, 0) を (0, 1, 0) に変えてみましょう。保存 → 終了 → 実行し直します。右 → Enter を6回、Z → Enter、左 → Enter を2回押します。p18、t9 で Space → Enter を1回、左 → Enter を4回、Z → Enter と押すと、t15 で DONE です。X で t14 の帰る直前に戻ります。キーは毎回全部離します。python exercises/03_return.py で実行できます。元に戻します。
このステップのコード全文(368行)
# -*- coding: utf-8 -*-
# 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.
STAGE = 3
from dataclasses import dataclass, replace, asdict
START = 14
TREASURE = 20
GATES = (3, 17, 31)
FLOOR = frozenset(p for p in range(35) if p % 7 in (0, 6) or p // 7 in (0, 2, 4))
ACTIONS = ('up', 'down', 'left', 'right', 'wait', 'use')
@dataclass(frozen=True)
class State:
pos: int = START
t: int = 0
guards: tuple = (0, 0, 0)
fails: int = 0
total: int = 0
holding: bool = False
items: tuple = ()
phase: str = 'play'
used: int = 0
trace: tuple = (START,)
def new():
return State()
def occupied(level, t):
return level == 1 and t % 2 == 0 or level == 2 and t % 3 in (0, 1)
def forecast(s):
return tuple(tuple(occupied(g, s.t + k) for g in s.guards) for k in range(1, 4))
def retry(s, failure=False, spent=0):
if s.phase != 'play':
return s
return replace(s, pos=START, t=0, holding=False, used=0, trace=(START,),
fails=s.fails + int(failure), total=s.total + spent)
def step(s, action):
if action == 'retry':
return retry(s)
if s.phase != 'play' or action not in ACTIONS:
return s
if action == 'use' and not (s.pos == TREASURE and not s.holding or s.pos == START and s.holding):
return s
p = s.pos
delta = {'up': (0, -1), 'down': (0, 1), 'left': (-1, 0), 'right': (1, 0)}.get(action)
if delta:
x, y = p % 7 + delta[0], p // 7 + delta[1]
q = y * 7 + x
if not (0 <= x < 7 and 0 <= y < 5 and q in FLOOR):
return s
p = q
t = s.t + 1
if p in GATES and occupied(s.guards[GATES.index(p)], t):
return retry(s, True, t)
used = s.used | (1 << GATES.index(p) if p in GATES else 0)
holding, items, phase, total = s.holding, s.items, s.phase, s.total
if action == 'use':
if p == TREASURE:
holding = True
else:
holding, items, phase, total = False, (0,), 'done', total + t
if t >= 40 and phase == 'play':
return retry(s, True, t)
return replace(s, pos=p, t=t, holding=holding, used=used, items=items,
phase=phase, total=total, trace=s.trace + (p,))
KEYS = {'UP','DOWN','LEFT','RIGHT','RETURN','SPACE','Z','X','R','ESCAPE'}
class Controller:
def __init__(self, path=None):
self.s = new()
self.selected = 'right'
self.previous = set()
self.gate = False
self.dialog = ''
self.history = []
self.log = []
self.notice = ''
self.title = False
self.help = False
def apply(self, action):
if action == 'undo':
if not self.history:
return 'ignored:undo'
self.s = self.history.pop()
return 'undo'
old = self.s
self.s = step(old, action)
if self.s == old:
return 'ignored:' + action
self.history.append(old)
return action
def feed(self, down):
down = set(down) & KEYS
fresh = down - self.previous
self.previous = down
if self.gate:
if not down:
self.gate = False
return None
if not fresh:
return None
self.gate = True
if len(down) > 1:
return 'reject:chord'
key = next(iter(fresh))
if self.dialog:
if key in ('X', 'ESCAPE'):
self.dialog = ''
return 'cancel'
if key == 'RETURN':
dialog, self.dialog = self.dialog, ''
if dialog == 'quit':
return 'quit'
if STAGE == 1:
self.s = new()
return 'reset'
return self.apply('retry')
return 'ignored:dialog'
if key == 'ESCAPE':
self.dialog = 'quit'
return 'confirm:quit'
if key == 'R' and getattr(self.s, 'phase', 'play') == 'play':
self.dialog = 'retry'
return 'confirm:retry'
if key == 'X' and STAGE >= 3:
return self.apply('undo')
action = {'UP':'up','DOWN':'down','LEFT':'left','RIGHT':'right','SPACE':'wait','Z':'use'}.get(key)
if action in ACTIONS:
self.selected = action
return 'select:' + action
if key == 'RETURN':
return self.apply(self.selected)
return 'ignored:key'
# UI and diagnostic CLI: independent of the pure model.
import argparse
import json
from pathlib import Path
def tap(ui, key):
event = ui.feed({key})
ui.feed(set()) # Every distinct key press has a full release.
return event
def commit(ui, action):
if action == 'next':
return tap(ui, 'RETURN')
key = {'up':'UP','down':'DOWN','left':'LEFT','right':'RIGHT','wait':'SPACE','use':'Z'}[action]
tap(ui, key)
return tap(ui, 'RETURN')
def demo(ui, scenario):
if scenario == 'initial':
return
if ui.title:
tap(ui, 'RETURN')
if scenario == 'checkpoint':
commit(ui, 'right')
return
if scenario == 'wall':
commit(ui, 'right'); commit(ui, 'up')
elif scenario in ('caught', 'preview') and STAGE >= 2:
if STAGE >= 4:
ui.s = new('A')
ui.s = replace(ui.s, guards=(0, 1, 0))
commit(ui, 'right'); commit(ui, 'right'); commit(ui, 'wait')
tap(ui, 'RIGHT')
if scenario == 'caught':
tap(ui, 'RETURN')
elif scenario == 'timeout' and STAGE >= 2:
for _ in range(40):
commit(ui, 'wait')
elif scenario == 'confirm':
commit(ui, 'right'); tap(ui, 'R')
elif scenario in ('returned', 'next', 'undo_next', 'success', 'saved', 'loaded', 'return40') and STAGE >= 3:
nights = 3 if scenario == 'success' and STAGE >= 4 else 1
for night in range(nights):
if night:
commit(ui, 'next')
# A deterministic public model route, waiting before occupied gates.
for direction in ('right', 'left'):
for _ in range(6):
q = ui.s.pos + (1 if direction == 'right' else -1)
while q in GATES and occupied(ui.s.guards[GATES.index(q)], ui.s.t + 1):
commit(ui, 'wait')
commit(ui, direction)
if direction == 'left' and scenario == 'return40':
while ui.s.t < 39:
commit(ui, 'wait')
commit(ui, 'use')
if scenario in ('next', 'undo_next') and STAGE >= 4:
commit(ui, 'next')
if scenario == 'undo_next':
tap(ui, 'X')
if scenario in ('saved','loaded') and STAGE == 5:
tap(ui, 'S' if scenario == 'saved' else 'L')
else:
raise ValueError('scenario not available at this stage')
def parse_args(argv=None):
parser = argparse.ArgumentParser(description='Single-file Pyxel lesson 093')
parser.add_argument('--headless-smoke', action='store_true')
parser.add_argument('--demo-checkpoint', action='store_true')
parser.add_argument('--scenario', choices=['initial','checkpoint','wall','confirm'] +
(['caught','preview','timeout'] if STAGE >= 2 else []) +
(['returned','return40'] if STAGE >= 3 else []) +
(['next','undo_next','success'] if STAGE >= 4 else []) +
(['saved','loaded'] if STAGE == 5 else []))
parser.add_argument('--screenshot', type=Path)
parser.add_argument('--state-json', type=Path)
parser.add_argument('--save-file', type=Path, default=Path('save_093.json'))
args = parser.parse_args(argv)
if not args.headless_smoke and (args.demo_checkpoint or args.scenario or args.screenshot or args.state_json):
parser.error('diagnostic flags require --headless-smoke')
if args.demo_checkpoint and args.scenario:
parser.error('choose --demo-checkpoint or --scenario')
# Resolve before pyxel.init, which can alter working-directory behavior.
for name, suffix in (('screenshot','.png'),('state_json','.json')):
path = getattr(args, name)
if path is None:
continue
path = path.resolve()
if path.suffix.lower() != suffix or not path.parent.is_dir() or path.exists():
parser.error(name + ': require a new file with correct suffix in an existing directory')
setattr(args, name, path)
args.save_file = args.save_file.resolve()
if args.save_file.suffix.lower() != '.json' or not args.save_file.parent.is_dir() or args.save_file.is_dir():
parser.error('save-file: require a .json file in an existing directory')
if args.save_file in (args.screenshot, args.state_json):
parser.error('save-file and diagnostic outputs must differ')
if args.scenario == 'saved' and args.save_file.exists():
parser.error('saved scenario must use a new save file')
return args
class App:
def __init__(self, args):
self.args = args
self.ui = Controller(args.save_file)
self.frame = 0
if args.headless_smoke:
demo(self.ui, args.scenario or ('checkpoint' if args.demo_checkpoint else 'initial'))
pyxel.init(560, 400, title='093 THREE NIGHTS / STAGE ' + str(STAGE),
fps=30, quit_key=pyxel.KEY_NONE, headless=args.headless_smoke, display_scale=1)
self.keys = {key: getattr(pyxel, 'KEY_' + key) for key in KEYS}
pyxel.run(self.update, self.draw)
def update(self):
self.frame += 1
if self.args.headless_smoke:
if self.frame >= 3:
pyxel.quit()
return
event = self.ui.feed({key for key, code in self.keys.items() if pyxel.btn(code)})
if event == 'quit':
pyxel.quit()
def draw(self):
u, s = self.ui, self.ui.s
pyxel.cls(1)
pyxel.text(12, 10, '093 THREE NIGHTS | STAGE ' + str(STAGE), 10)
pyxel.text(12, 24, 'SELECT: ' + u.selected.upper() + ' TICK: ' + str(s.t) + ' POS: ' + str(s.pos), 7)
pyxel.text(300, 24, 'NIGHT: ' + str(getattr(s,'night',0)+1) + ' PHASE: ' + getattr(s,'phase','play').upper(), 11)
for p in range(35):
x, y = 12 + p % 7 * 42, 48 + p // 7 * 42
pyxel.rect(x, y, 39, 39, 5 if p in FLOOR else 2)
if p in FLOOR:
pyxel.rectb(x, y, 39, 39, 13)
if STAGE >= 2 and p in GATES:
g = s.guards[GATES.index(p)]
pyxel.rectb(x+3,y+3,33,23,8 if occupied(g,s.t) else 11)
pyxel.text(x+5,y+28,'G'+str(g),7)
if p == START:
pyxel.text(x+3,y+29,'S',11)
if STAGE >= 3 and p == TREASURE:
pyxel.text(x+3,y+29,'T',10)
if not s.holding and s.phase == 'play':
pyxel.circ(x+25,y+16,5,10)
x,y = 12+s.pos%7*42+19,48+s.pos//7*42+14
pyxel.circ(x,y,6,12)
if getattr(s,'holding',False):
pyxel.circ(x+9,y,3,10)
pyxel.rect(315,48,233,210,2)
if STAGE >= 2:
pyxel.text(325,60,'PUBLIC GUARDS / NEXT 3 TICKS' if getattr(s,'phase','play') == 'play' else 'RECORDED GUARDS / FINISHED NIGHT',10)
for i,g in enumerate(s.guards):
future = ' '.join('BUSY' if occupied(g,s.t+k) else 'OPEN' for k in (1,2,3))
pyxel.text(325,80+i*18,str(i)+': '+(future if getattr(s,'phase','play') == 'play' else 'LEVEL '+str(g)),7)
pyxel.text(325,143,'FAILS: '+str(s.fails)+' TOTAL: '+str(s.total),7)
if STAGE >= 3:
pyxel.text(325,163,'HOLDING: '+str(int(s.holding)),10)
pyxel.text(325,179,'HOME: '+str(len(s.items))+' USED BITS: '+str(s.used),11)
phase = getattr(s,'phase','play')
if phase == 'play':
preview = step(s,u.selected)
msg = 'NO CHANGE' if preview == s else 'CAUGHT / RESET' if getattr(preview,'fails',0)>getattr(s,'fails',0) else 'VALID ONE TICK'
pyxel.text(325,208,'PREVIEW: '+msg,8 if 'RESET' in msg else 6)
pyxel.text(325,225,'ENTER commits; preview is read-only.',7)
else:
pyxel.text(325,208,'RESULT: '+phase.upper(),10)
if phase == 'intermission':
next_guards = adapted(s.guards,s.used) if s.variant == 'adaptive' else s.guards
pyxel.text(325,224,'NEXT GUARDS: '+str(next_guards),11)
else:
pyxel.text(325,224,'NO FUTURE GUARD UPDATE',11)
pyxel.text(12,272,'Arrows: select direction ENTER: commit Release ALL keys between presses',7)
if STAGE >= 2: pyxel.text(12,288,'SPACE: select wait R: confirm retry (current night only)',6)
if STAGE >= 3: pyxel.text(12,304,'Z: select pickup/return X: Undo one committed decision',6)
if STAGE >= 4: pyxel.text(12,320,'N: new M: menu H/P: help Title: 1/2/3 case, B variant',6)
if STAGE == 5: pyxel.text(12,336,'S/L: save/load at intermission or result only',6)
pyxel.text(12,358,'ESC: confirm quit. R never reloads edited Python code.',7)
if u.notice:
notice = 'SAVE OK' if '保存しました' in u.notice else 'LOAD OK' if '読み込みました' in u.notice else 'SAVE/LOAD ERROR: file preserved' if '読めません' in u.notice else 'SAVE/LOAD: intermission or result only'
pyxel.text(12,375,notice,10)
if u.title or u.help:
pyxel.rect(35,70,490,175,1); pyxel.rectb(35,70,490,175,10)
pyxel.text(50,85,'THREE NIGHTS: BRING HOME THREE FICTIONAL KEEPSAKES',10)
pyxel.text(50,110,'Directions / SPACE / Z select. ENTER commits one action.',7)
pyxel.text(50,130,'No real-world intrusion instructions. This is a grid puzzle.',6)
pyxel.text(50,155,'Case: '+s.case+' Variant: '+s.variant,11)
pyxel.text(50,175,'1/2/3 choose case. B changes adaptation before starting.',7)
pyxel.text(50,205,'ENTER: start H/P: close help. Release keys first.',10)
if u.dialog:
pyxel.rect(35,125,490,105,1); pyxel.rectb(35,125,490,105,8)
pyxel.text(50,145,'CONFIRM: '+u.dialog.upper(),10)
pyxel.text(50,170,'Release all keys, then ENTER confirms. X cancels.',7)
pyxel.text(50,193,'Retry keeps previous nights. New resets all three.',6)
if self.args.headless_smoke and self.frame == 2:
if self.args.screenshot:
pyxel.screenshot(str(self.args.screenshot),scale=1)
if self.args.state_json:
data={'stage':STAGE,'state':asdict(s),'selected':u.selected,'title':u.title,
'dialog':u.dialog,'history':[asdict(v) for v in u.history],
'scope':'Actual Pyxel Linux headless rendering; synthetic controller inputs'}
with self.args.state_json.open('x',encoding='utf-8') as handle:
json.dump(data,handle,ensure_ascii=False,indent=2)
if __name__ == '__main__':
args = parse_args()
import pyxel
App(args)前夜の足あとを次の夜へ渡す
3つの夜の状態の移り変わり・警備の変化と比較の実験。
04 で、元のゲームの model.py の State、新しく始めるときの設定、移動、警備の変化(適応)を、そのまま取り入れます。表紙の 1/2/3 で状況 A/B/C を選び、B で adaptive(警備が変わる)と fixed(変わらない)を切り替えます。Enter → 離す で始まります。元のゲームの日本語のデータはコードの中に残し、画面は英数字(ASCII)です。03 の1夜での DONE は、1夜目と2夜目では intermission(夜と夜の間の幕間)に変わり、3夜目で done になります。
持ち帰った items は、次の夜にも残ります。幕間で Enter を確定した瞬間に night を増やし、guards を adapted に通します。使った門は +1、使わなかった門は -1 で、範囲は0〜2です。used は今夜の集まりなので、往復しても2回は増えません。previous_used に前の夜の集まりを記録してから、今夜の used を0にします。失敗・やり直し・最後の結果では、警備は変わりません。fixed では、移動、周期、40刻、品物の条件は同じで、警備の更新だけを止めます。
夜をまたぐことも1つの判断なので、X で幕間の状態全部に戻せます。R は今夜だけ、N は3つの夜全部の新しい開始で、どちらも確認と、キーを全部離すことが必要です。M は表紙に戻る確認、H/P は説明を開く・閉じるです。表紙や説明、確認の画面では、刻は進みません。S/L での保存はまだ入れず、05 で保存用の確認の関数と一緒に加えます。
キーは1回押すごとに、全部離してください。Enter を押し続けても、次の1刻は進みません。コードを書き換えたら保存し、Esc → キーを全部離す → Enter で終了して、同じ python のコマンドで実行し直します。R はゲームの状態をやり直すだけで、コードを読み直すわけではありません。
下のコードは 04_three_nights.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
def adapted(guards,used):return tuple(min(2,g+1) if used>>i&1 else max(0,g-1) for i,g in enumerate(guards))実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 04_three_nights.py.venv/bin/python 04_three_nights.py- 品物は残し、今夜の足あとは消す
- 警備が変わるのは、次の夜に進むとき(next)だけ
- X で、夜の境目も戻せる
adapted の中の min(2,g+1) を min(2,g+2) に変え、保存 → 終了 → 実行し直してみましょう。表紙で Enter → 離す。状況 A の真ん中の往復14刻(右 → Enter ×6、Z → Enter、左 → Enter ×6、Z → Enter)のあと、幕間で NEXT GUARDS=(0,2,0) になります。Enter → 離す で、同じ警備の2夜目です。X → 離す で、警備 (0,0,0) の1夜目の幕間に戻ります。python exercises/04_adaptation.py で実行できます。最後に g+1 に戻します。
このステップのコード全文(328行)
# -*- coding: utf-8 -*-
# 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.
STAGE = 4
"""Three-night infiltration. Pure, immutable and deterministic; no private AI input."""
from dataclasses import dataclass,replace,asdict,fields
import hashlib,json
VERSION='093-1.0';GAME_ID='093';START=14;TREASURE=20;GATES=(3,17,31)
TITLES={'A':'静かな一夜目','B':'窓番のいる館','C':'足あとが多い館'}
INITIAL={'A':(0,0,0),'B':(1,0,1),'C':(2,1,0)}
ITEMS=('月のティーカップ','星のオルゴール','朝の小さな鏡')
ROUTES=('上の窓','中央の扉','下の窓');ACTIONS=('up','down','left','right','wait','use')
FLOOR=frozenset(p for p in range(35) if p%7 in (0,6) or p//7 in (0,2,4))
@dataclass(frozen=True)
class State:
case:str='A';variant:str='adaptive';night:int=0;pos:int=14;holding:bool=False;t:int=0;guards:tuple=(0,0,0);used:int=0;items:tuple=();phase:str='play';fails:int=0;total:int=0;previous_used:int=0;trace:tuple=(14,);note:str='今夜はどの道から?'
def new(case='A',variant='adaptive'):
if case not in INITIAL or variant not in ('adaptive','fixed'):raise ValueError('invalid setup')
return State(case=case,variant=variant,guards=INITIAL[case])
def occupied(level,t):return level==1 and t%2==0 or level==2 and t%3 in (0,1)
def forecast(s):return tuple(tuple(occupied(g,s.t+k) for g in s.guards) for k in range(1,4))
def adapted(guards,used):return tuple(min(2,g+1) if used>>i&1 else max(0,g-1) for i,g in enumerate(guards))
def retry(s,note='この夜だけ最初から。前夜までの品はそのまま。',failure=False,spent=0):
if s.phase!='play':return s
return replace(s,pos=START,holding=False,t=0,used=0,trace=(START,),fails=s.fails+int(failure),total=s.total+spent,note=note)
def step(s,a):
if a=='next' and s.phase=='intermission':
return replace(s,night=s.night+1,pos=START,holding=False,t=0,guards=adapted(s.guards,s.used) if s.variant=='adaptive' else s.guards,previous_used=s.used,used=0,phase='play',trace=(START,),note='昨日の足あとだけを覚えています。' if s.variant=='adaptive' else '比較版: 警備は初期値のまま。')
if a=='retry':return retry(s)
if s.phase!='play' or a not in ACTIONS:return s
if a=='use' and not (s.pos==TREASURE and not s.holding or s.pos==START and s.holding):return s
p=s.pos;d={'up':(0,-1),'down':(0,1),'left':(-1,0),'right':(1,0)}.get(a)
if d:
x,y=p%7+d[0],p//7+d[1];q=y*7+x
if not(0<=x<7 and 0<=y<5 and q in FLOOR):return s
p=q
t=s.t+1
if p in GATES and occupied(s.guards[GATES.index(p)],t):
return retry(s,'見つかった! この夜の入口から、もう一度。',True,t)
used=s.used|(1<<GATES.index(p) if p in GATES else 0);holding=s.holding;items=s.items;phase=s.phase;total=s.total
note='待って、次の番人の位置を読む。' if a=='wait' else '足音をひとつ。'
if a=='use':
if p==TREASURE:holding=True;note=ITEMS[s.night]+'を包んだ。入口へ戻ろう。'
else:
holding=False;items=items+(s.night,);phase='done' if s.night==2 else 'intermission';total+=t;note='三品が部屋にそろいました。' if phase=='done' else ('帰還! 次の夜は、この足あとへ反応。' if s.variant=='adaptive' else '帰還! 次の夜も初期警備のまま。')
if t>=40 and phase=='play':return retry(s,'40刻。今夜の入口から再試行できます。',True,t)
return replace(s,pos=p,t=t,holding=holding,used=used,items=items,phase=phase,total=total,trace=s.trace+(p,),note=note)
KEYS={'SPACE','RETURN','Z','X','P','H','R','N','M','UP','DOWN','LEFT','RIGHT','1','2','3','B','S','L','F12','ESCAPE'}
class Controller:
def __init__(self,path='save_093.json'):
self.s=new();self.title=True;self.help=False;self.dialog='';self.previous=set();self.gate=False;self.history=[];self.log=[];self.notice='';self.path=path;self.selected='right'
def reset(self,case=None):self.s=new(case or self.s.case,self.s.variant);self.history=[];self.log=[];self.notice='';self.selected='right'
def apply(self,a):
if a=='undo':
if not self.history:return 'ignored:undo'
self.s=self.history.pop();self.log.append(a);return a
old=self.s;self.s=step(old,a)
if self.s==old:return 'ignored:'+a
self.history.append(old);self.log.append(a);return a
def feed(self,down):
down=set(down)&KEYS;fresh=down-self.previous;self.previous=down
if self.gate:
if not down:self.gate=False
return None
if not fresh:return None
self.gate=True
if len(down)>1:return 'reject:chord'
k=next(iter(fresh));self.notice=''
if k=='F12':return 'screenshot'
if self.dialog:
if k in ('X','ESCAPE'):self.dialog='';return 'cancel'
if k=='RETURN':
d=self.dialog;self.dialog=''
if d=='quit':return 'quit'
if d=='new':self.reset();return 'reset:campaign'
if d=='retry':return self.apply('retry')
if d=='menu':self.title=True;return 'menu'
return 'ignored:dialog'
if k=='ESCAPE':self.dialog='quit';return 'confirm:quit'
if k in ('P','H'):self.help=not self.help;return 'help'
if self.help:return 'ignored:help'
if self.title:
if k in ('1','2','3'):self.reset('ABC'[int(k)-1]);return 'case'
if k=='B':self.s=new(self.s.case,'fixed' if self.s.variant=='adaptive' else 'adaptive');self.history=[];self.log=[];return 'variant'
if k in ('SPACE','RETURN'):self.title=False;return 'start'
return 'ignored:title'
if k=='M':self.dialog='menu';return 'confirm:menu'
if k=='N':self.dialog='new';return 'confirm:new'
if k=='R' and self.s.phase=='play':self.dialog='retry';return 'confirm:retry'
if k=='X':return self.apply('undo')
if self.s.phase=='intermission' and k=='RETURN':return self.apply('next')
if self.s.phase!='play':return 'ignored:result'
a={'UP':'up','DOWN':'down','LEFT':'left','RIGHT':'right','SPACE':'wait','Z':'use'}.get(k)
if a:self.selected=a;return 'select:'+a
if k=='RETURN':return self.apply(self.selected)
return 'ignored:key'
# UI and diagnostic CLI: independent of the pure model.
import argparse
import json
from pathlib import Path
def tap(ui, key):
event = ui.feed({key})
ui.feed(set()) # Every distinct key press has a full release.
return event
def commit(ui, action):
if action == 'next':
return tap(ui, 'RETURN')
key = {'up':'UP','down':'DOWN','left':'LEFT','right':'RIGHT','wait':'SPACE','use':'Z'}[action]
tap(ui, key)
return tap(ui, 'RETURN')
def demo(ui, scenario):
if scenario == 'initial':
return
if ui.title:
tap(ui, 'RETURN')
if scenario == 'checkpoint':
commit(ui, 'right')
return
if scenario == 'wall':
commit(ui, 'right'); commit(ui, 'up')
elif scenario in ('caught', 'preview') and STAGE >= 2:
if STAGE >= 4:
ui.s = new('A')
ui.s = replace(ui.s, guards=(0, 1, 0))
commit(ui, 'right'); commit(ui, 'right'); commit(ui, 'wait')
tap(ui, 'RIGHT')
if scenario == 'caught':
tap(ui, 'RETURN')
elif scenario == 'timeout' and STAGE >= 2:
for _ in range(40):
commit(ui, 'wait')
elif scenario == 'confirm':
commit(ui, 'right'); tap(ui, 'R')
elif scenario in ('returned', 'next', 'undo_next', 'success', 'saved', 'loaded', 'return40') and STAGE >= 3:
nights = 3 if scenario == 'success' and STAGE >= 4 else 1
for night in range(nights):
if night:
commit(ui, 'next')
# A deterministic public model route, waiting before occupied gates.
for direction in ('right', 'left'):
for _ in range(6):
q = ui.s.pos + (1 if direction == 'right' else -1)
while q in GATES and occupied(ui.s.guards[GATES.index(q)], ui.s.t + 1):
commit(ui, 'wait')
commit(ui, direction)
if direction == 'left' and scenario == 'return40':
while ui.s.t < 39:
commit(ui, 'wait')
commit(ui, 'use')
if scenario in ('next', 'undo_next') and STAGE >= 4:
commit(ui, 'next')
if scenario == 'undo_next':
tap(ui, 'X')
if scenario in ('saved','loaded') and STAGE == 5:
tap(ui, 'S' if scenario == 'saved' else 'L')
else:
raise ValueError('scenario not available at this stage')
def parse_args(argv=None):
parser = argparse.ArgumentParser(description='Single-file Pyxel lesson 093')
parser.add_argument('--headless-smoke', action='store_true')
parser.add_argument('--demo-checkpoint', action='store_true')
parser.add_argument('--scenario', choices=['initial','checkpoint','wall','confirm'] +
(['caught','preview','timeout'] if STAGE >= 2 else []) +
(['returned','return40'] if STAGE >= 3 else []) +
(['next','undo_next','success'] if STAGE >= 4 else []) +
(['saved','loaded'] if STAGE == 5 else []))
parser.add_argument('--screenshot', type=Path)
parser.add_argument('--state-json', type=Path)
parser.add_argument('--save-file', type=Path, default=Path('save_093.json'))
args = parser.parse_args(argv)
if not args.headless_smoke and (args.demo_checkpoint or args.scenario or args.screenshot or args.state_json):
parser.error('diagnostic flags require --headless-smoke')
if args.demo_checkpoint and args.scenario:
parser.error('choose --demo-checkpoint or --scenario')
# Resolve before pyxel.init, which can alter working-directory behavior.
for name, suffix in (('screenshot','.png'),('state_json','.json')):
path = getattr(args, name)
if path is None:
continue
path = path.resolve()
if path.suffix.lower() != suffix or not path.parent.is_dir() or path.exists():
parser.error(name + ': require a new file with correct suffix in an existing directory')
setattr(args, name, path)
args.save_file = args.save_file.resolve()
if args.save_file.suffix.lower() != '.json' or not args.save_file.parent.is_dir() or args.save_file.is_dir():
parser.error('save-file: require a .json file in an existing directory')
if args.save_file in (args.screenshot, args.state_json):
parser.error('save-file and diagnostic outputs must differ')
if args.scenario == 'saved' and args.save_file.exists():
parser.error('saved scenario must use a new save file')
return args
class App:
def __init__(self, args):
self.args = args
self.ui = Controller(args.save_file)
self.frame = 0
if args.headless_smoke:
demo(self.ui, args.scenario or ('checkpoint' if args.demo_checkpoint else 'initial'))
pyxel.init(560, 400, title='093 THREE NIGHTS / STAGE ' + str(STAGE),
fps=30, quit_key=pyxel.KEY_NONE, headless=args.headless_smoke, display_scale=1)
self.keys = {key: getattr(pyxel, 'KEY_' + key) for key in KEYS}
pyxel.run(self.update, self.draw)
def update(self):
self.frame += 1
if self.args.headless_smoke:
if self.frame >= 3:
pyxel.quit()
return
event = self.ui.feed({key for key, code in self.keys.items() if pyxel.btn(code)})
if event == 'quit':
pyxel.quit()
def draw(self):
u, s = self.ui, self.ui.s
pyxel.cls(1)
pyxel.text(12, 10, '093 THREE NIGHTS | STAGE ' + str(STAGE), 10)
pyxel.text(12, 24, 'SELECT: ' + u.selected.upper() + ' TICK: ' + str(s.t) + ' POS: ' + str(s.pos), 7)
pyxel.text(300, 24, 'NIGHT: ' + str(getattr(s,'night',0)+1) + ' PHASE: ' + getattr(s,'phase','play').upper(), 11)
for p in range(35):
x, y = 12 + p % 7 * 42, 48 + p // 7 * 42
pyxel.rect(x, y, 39, 39, 5 if p in FLOOR else 2)
if p in FLOOR:
pyxel.rectb(x, y, 39, 39, 13)
if STAGE >= 2 and p in GATES:
g = s.guards[GATES.index(p)]
pyxel.rectb(x+3,y+3,33,23,8 if occupied(g,s.t) else 11)
pyxel.text(x+5,y+28,'G'+str(g),7)
if p == START:
pyxel.text(x+3,y+29,'S',11)
if STAGE >= 3 and p == TREASURE:
pyxel.text(x+3,y+29,'T',10)
if not s.holding and s.phase == 'play':
pyxel.circ(x+25,y+16,5,10)
x,y = 12+s.pos%7*42+19,48+s.pos//7*42+14
pyxel.circ(x,y,6,12)
if getattr(s,'holding',False):
pyxel.circ(x+9,y,3,10)
pyxel.rect(315,48,233,210,2)
if STAGE >= 2:
pyxel.text(325,60,'PUBLIC GUARDS / NEXT 3 TICKS' if getattr(s,'phase','play') == 'play' else 'RECORDED GUARDS / FINISHED NIGHT',10)
for i,g in enumerate(s.guards):
future = ' '.join('BUSY' if occupied(g,s.t+k) else 'OPEN' for k in (1,2,3))
pyxel.text(325,80+i*18,str(i)+': '+(future if getattr(s,'phase','play') == 'play' else 'LEVEL '+str(g)),7)
pyxel.text(325,143,'FAILS: '+str(s.fails)+' TOTAL: '+str(s.total),7)
if STAGE >= 3:
pyxel.text(325,163,'HOLDING: '+str(int(s.holding)),10)
pyxel.text(325,179,'HOME: '+str(len(s.items))+' USED BITS: '+str(s.used),11)
phase = getattr(s,'phase','play')
if phase == 'play':
preview = step(s,u.selected)
msg = 'NO CHANGE' if preview == s else 'CAUGHT / RESET' if getattr(preview,'fails',0)>getattr(s,'fails',0) else 'VALID ONE TICK'
pyxel.text(325,208,'PREVIEW: '+msg,8 if 'RESET' in msg else 6)
pyxel.text(325,225,'ENTER commits; preview is read-only.',7)
else:
pyxel.text(325,208,'RESULT: '+phase.upper(),10)
if phase == 'intermission':
next_guards = adapted(s.guards,s.used) if s.variant == 'adaptive' else s.guards
pyxel.text(325,224,'NEXT GUARDS: '+str(next_guards),11)
else:
pyxel.text(325,224,'NO FUTURE GUARD UPDATE',11)
pyxel.text(12,272,'Arrows: select direction ENTER: commit Release ALL keys between presses',7)
if STAGE >= 2: pyxel.text(12,288,'SPACE: select wait R: confirm retry (current night only)',6)
if STAGE >= 3: pyxel.text(12,304,'Z: select pickup/return X: Undo one committed decision',6)
if STAGE >= 4: pyxel.text(12,320,'N: new M: menu H/P: help Title: 1/2/3 case, B variant',6)
if STAGE == 5: pyxel.text(12,336,'S/L: save/load at intermission or result only',6)
pyxel.text(12,358,'ESC: confirm quit. R never reloads edited Python code.',7)
if u.notice:
notice = 'SAVE OK' if '保存しました' in u.notice else 'LOAD OK' if '読み込みました' in u.notice else 'SAVE/LOAD ERROR: file preserved' if '読めません' in u.notice else 'SAVE/LOAD: intermission or result only'
pyxel.text(12,375,notice,10)
if u.title or u.help:
pyxel.rect(35,70,490,175,1); pyxel.rectb(35,70,490,175,10)
pyxel.text(50,85,'THREE NIGHTS: BRING HOME THREE FICTIONAL KEEPSAKES',10)
pyxel.text(50,110,'Directions / SPACE / Z select. ENTER commits one action.',7)
pyxel.text(50,130,'No real-world intrusion instructions. This is a grid puzzle.',6)
pyxel.text(50,155,'Case: '+s.case+' Variant: '+s.variant,11)
pyxel.text(50,175,'1/2/3 choose case. B changes adaptation before starting.',7)
pyxel.text(50,205,'ENTER: start H/P: close help. Release keys first.',10)
if u.dialog:
pyxel.rect(35,125,490,105,1); pyxel.rectb(35,125,490,105,8)
pyxel.text(50,145,'CONFIRM: '+u.dialog.upper(),10)
pyxel.text(50,170,'Release all keys, then ENTER confirms. X cancels.',7)
pyxel.text(50,193,'Retry keeps previous nights. New resets all three.',6)
if self.args.headless_smoke and self.frame == 2:
if self.args.screenshot:
pyxel.screenshot(str(self.args.screenshot),scale=1)
if self.args.state_json:
data={'stage':STAGE,'state':asdict(s),'selected':u.selected,'title':u.title,
'dialog':u.dialog,'history':[asdict(v) for v in u.history],
'scope':'Actual Pyxel Linux headless rendering; synthetic controller inputs'}
with self.args.state_json.open('x',encoding='utf-8') as handle:
json.dump(data,handle,ensure_ascii=False,indent=2)
if __name__ == '__main__':
args = parse_args()
import pyxel
App(args)保存とUndoを三夜の記録につなぐ
UTF-8 での保存・確かめてから読みこむ・確定した入力の境目。
05 には、元のゲームの model.py の定義とデータ全部、Controller の定義、storage の save/load の定義が入っています。いくつものファイルの import だけを、1つのファイル用につなぎ直し、storage という小さな名前のまとまりが、同じ関数を見せています。辞書から State を作り直すときは、項目の名前、型、決まった値の種類、範囲、items の順番、trace の長さと最後を調べます。これは元のゲームの形式の確認で、すべての保存が正しい入力の列から作られたことを、暗号のように保証するものではありません。
S/L が使えるのは、幕間か、3つの夜の結果のときだけです。プレイ中は blocked:save-phase(保存できない場面)です。保存先は、起動した作業フォルダーの save_093.json で、--save-file ではっきり指定することもできます。UTF-8 の一時的なファイルに書き、flush/fsync のあとで置きかえます。保存は同じファイルを上書きしますが、確認用の画像や JSON は、すでにある出力への上書きを断ります。作品の ID・版、case/variant、状態全部、Undo の履歴、意味のある入力を保存します。L は、確認が成功してから、今の3つの値を同時に置きかえ、失敗したときはファイルの内容を使いません。
読みこんだ履歴でも、X は直前の判断に戻ります。次の夜へ進む(next)も、やり直し(retry)も、Undo の対象です。N で新しく始めても保存ファイルは残りますが、L を使うには、もう一度幕間か結果まで進めてください。表紙の L では読みこみません。S は Python のコードの保存ではありません。エディターでの保存と区別しましょう。
キーは1回押すごとに、全部離してください。Enter を押し続けても、次の1刻は進みません。コードを書き換えたら保存し、Esc → キーを全部離す → Enter で終了して、同じ python のコマンドで実行し直します。R はゲームの状態をやり直すだけで、コードを読み直すわけではありません。
元のゲームの日本語の文字、飾り、F12 での記録、起動時のコードの複製、出来事の記録は省いています。キーそのものは Controller に残っていますが、F12 の画面の保存の出来事を、教材の画面は保存しません。音はありません。元のゲームに進むときは、app.py の record/flush_events と text、storage.py の load を読みましょう。
下のコードは 05_save_undo.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
if k in ('S','L'):
if self.s.phase not in ('intermission','done'):self.notice='保存と読込は夜と夜の間、または三夜の結果で。';return 'blocked:save-phase'
try:
if k=='S':storage.save(self.path,self.s,self.history,self.log);self.notice='この三夜を保存しました。';return 'save'
self.s,self.history,self.log=storage.load(self.path);self.notice='保存した三夜を読み込みました。';return 'load'実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 05_save_undo.py.venv/bin/python 05_save_undo.py- 保存は、幕間か結果のときだけ
- UTF-8 と、確かめてから元に戻す仕組み
- 読みこんだあとの Undo も、状態全部を戻す
json.load が成功しただけで、状態が正しいと判断しないでください。encoding を省くと、パソコンの環境によって結果が変わります。L の途中で self.s だけを先に変えると、失敗したときに履歴と状態が食い違います。Controller.__init__ の最後の self.path=path;self.selected='right' だけを、self.path=path;self.selected='wait' に変えてみましょう。保存 → 終了 → 実行し直します。表紙で Enter → 離す では t0。さらに Enter → 離す で、入口のまま t1 です。X → 離す で t0。右 → 離す → Enter → 離す で p15、t1 です。python exercises/05_initial_selection.py が、書き換えと、同じ開始・確定・Undo を実行します。最後に right に戻します。
このステップのコード全文(377行)
# -*- coding: utf-8 -*-
# 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.
STAGE = 5
"""Three-night infiltration. Pure, immutable and deterministic; no private AI input."""
from dataclasses import dataclass,replace,asdict,fields
import hashlib,json
VERSION='093-1.0';GAME_ID='093';START=14;TREASURE=20;GATES=(3,17,31)
TITLES={'A':'静かな一夜目','B':'窓番のいる館','C':'足あとが多い館'}
INITIAL={'A':(0,0,0),'B':(1,0,1),'C':(2,1,0)}
ITEMS=('月のティーカップ','星のオルゴール','朝の小さな鏡')
ROUTES=('上の窓','中央の扉','下の窓');ACTIONS=('up','down','left','right','wait','use')
FLOOR=frozenset(p for p in range(35) if p%7 in (0,6) or p//7 in (0,2,4))
@dataclass(frozen=True)
class State:
case:str='A';variant:str='adaptive';night:int=0;pos:int=14;holding:bool=False;t:int=0;guards:tuple=(0,0,0);used:int=0;items:tuple=();phase:str='play';fails:int=0;total:int=0;previous_used:int=0;trace:tuple=(14,);note:str='今夜はどの道から?'
def new(case='A',variant='adaptive'):
if case not in INITIAL or variant not in ('adaptive','fixed'):raise ValueError('invalid setup')
return State(case=case,variant=variant,guards=INITIAL[case])
def occupied(level,t):return level==1 and t%2==0 or level==2 and t%3 in (0,1)
def forecast(s):return tuple(tuple(occupied(g,s.t+k) for g in s.guards) for k in range(1,4))
def adapted(guards,used):return tuple(min(2,g+1) if used>>i&1 else max(0,g-1) for i,g in enumerate(guards))
def retry(s,note='この夜だけ最初から。前夜までの品はそのまま。',failure=False,spent=0):
if s.phase!='play':return s
return replace(s,pos=START,holding=False,t=0,used=0,trace=(START,),fails=s.fails+int(failure),total=s.total+spent,note=note)
def step(s,a):
if a=='next' and s.phase=='intermission':
return replace(s,night=s.night+1,pos=START,holding=False,t=0,guards=adapted(s.guards,s.used) if s.variant=='adaptive' else s.guards,previous_used=s.used,used=0,phase='play',trace=(START,),note='昨日の足あとだけを覚えています。' if s.variant=='adaptive' else '比較版: 警備は初期値のまま。')
if a=='retry':return retry(s)
if s.phase!='play' or a not in ACTIONS:return s
if a=='use' and not (s.pos==TREASURE and not s.holding or s.pos==START and s.holding):return s
p=s.pos;d={'up':(0,-1),'down':(0,1),'left':(-1,0),'right':(1,0)}.get(a)
if d:
x,y=p%7+d[0],p//7+d[1];q=y*7+x
if not(0<=x<7 and 0<=y<5 and q in FLOOR):return s
p=q
t=s.t+1
if p in GATES and occupied(s.guards[GATES.index(p)],t):
return retry(s,'見つかった! この夜の入口から、もう一度。',True,t)
used=s.used|(1<<GATES.index(p) if p in GATES else 0);holding=s.holding;items=s.items;phase=s.phase;total=s.total
note='待って、次の番人の位置を読む。' if a=='wait' else '足音をひとつ。'
if a=='use':
if p==TREASURE:holding=True;note=ITEMS[s.night]+'を包んだ。入口へ戻ろう。'
else:
holding=False;items=items+(s.night,);phase='done' if s.night==2 else 'intermission';total+=t;note='三品が部屋にそろいました。' if phase=='done' else ('帰還! 次の夜は、この足あとへ反応。' if s.variant=='adaptive' else '帰還! 次の夜も初期警備のまま。')
if t>=40 and phase=='play':return retry(s,'40刻。今夜の入口から再試行できます。',True,t)
return replace(s,pos=p,t=t,holding=holding,used=used,items=items,phase=phase,total=total,trace=s.trace+(p,),note=note)
def digest(s):return hashlib.sha256(json.dumps(asdict(s),ensure_ascii=False,sort_keys=True).encode()).hexdigest()
def from_dict(d):
if not isinstance(d,dict) or set(d)!={f.name for f in fields(State)}:raise ValueError('state fields')
d=dict(d)
for k in ('guards','items','trace'):
if not isinstance(d[k],list):raise ValueError('tuple field')
d[k]=tuple(d[k])
s=State(**d)
if s.case not in INITIAL or s.variant not in ('adaptive','fixed') or s.phase not in ('play','intermission','done'):raise ValueError('enum')
for k,lo,hi in (('night',0,2),('pos',0,34),('t',0,40),('used',0,7),('previous_used',0,7),('fails',0,100000),('total',0,10000000)):
if type(getattr(s,k)) is not int or not lo<=getattr(s,k)<=hi:raise ValueError(k)
if type(s.holding) is not bool or not isinstance(s.note,str) or len(s.note)>200 or s.pos not in FLOOR:raise ValueError('state value')
if len(s.guards)!=3 or any(type(x) is not int or x not in range(3) for x in s.guards):raise ValueError('guards')
if s.items!=tuple(range(s.night+(s.phase!='play'))):raise ValueError('items')
if s.phase=='intermission' and s.night==2 or s.phase=='done' and s.night!=2:raise ValueError('phase')
if not 1<=len(s.trace)<=41 or any(type(x) is not int or x not in FLOOR for x in s.trace):raise ValueError('trace')
if s.trace[-1]!=s.pos or len(s.trace)!=s.t+1:raise ValueError('trace clock')
return s
"""UTF-8 atomic local save. Validate fully before replacing the live controller."""
import json,os
from pathlib import Path
from dataclasses import asdict
def save(path,state,history,commands):
p=Path(path);data={'game_id':GAME_ID,'version':VERSION,'case':state.case,'variant':state.variant,'state':asdict(state),'history':[asdict(s) for s in history],'semantic_inputs':commands,'settings':{'silent':True}}
temp=p.with_suffix(p.suffix+'.tmp')
with temp.open('w',encoding='utf-8',newline='\n') as f:json.dump(data,f,ensure_ascii=False,indent=2);f.flush();os.fsync(f.fileno())
os.replace(temp,p)
def load(path):
p=Path(path)
if p.stat().st_size>8000000:raise ValueError('保存が大きすぎます')
d=json.loads(p.read_text(encoding='utf-8'))
if not isinstance(d,dict) or d.get('game_id')!=GAME_ID or d.get('version')!=VERSION:raise ValueError('未対応の作品または版です')
s=from_dict(d['state']);h=d['history'];c=d['semantic_inputs']
if d['case']!=s.case or d['variant']!=s.variant or not isinstance(h,list) or len(h)>5000 or not isinstance(c,list) or len(c)>10000 or any(not isinstance(x,str) or len(x)>40 for x in c):raise ValueError('記録の形式が違います')
history=[from_dict(x) for x in h]
if any(x.case!=s.case or x.variant!=s.variant for x in history):raise ValueError('履歴の状況が違います')
return s,history,c
class storage:
save = staticmethod(save)
load = staticmethod(load)
KEYS={'SPACE','RETURN','Z','X','P','H','R','N','M','UP','DOWN','LEFT','RIGHT','1','2','3','B','S','L','F12','ESCAPE'}
class Controller:
def __init__(self,path='save_093.json'):
self.s=new();self.title=True;self.help=False;self.dialog='';self.previous=set();self.gate=False;self.history=[];self.log=[];self.notice='';self.path=path;self.selected='right'
def reset(self,case=None):self.s=new(case or self.s.case,self.s.variant);self.history=[];self.log=[];self.notice='';self.selected='right'
def apply(self,a):
if a=='undo':
if not self.history:return 'ignored:undo'
self.s=self.history.pop();self.log.append(a);return a
old=self.s;self.s=step(old,a)
if self.s==old:return 'ignored:'+a
self.history.append(old);self.log.append(a);return a
def feed(self,down):
down=set(down)&KEYS;fresh=down-self.previous;self.previous=down
if self.gate:
if not down:self.gate=False
return None
if not fresh:return None
self.gate=True
if len(down)>1:return 'reject:chord'
k=next(iter(fresh));self.notice=''
if k=='F12':return 'screenshot'
if self.dialog:
if k in ('X','ESCAPE'):self.dialog='';return 'cancel'
if k=='RETURN':
d=self.dialog;self.dialog=''
if d=='quit':return 'quit'
if d=='new':self.reset();return 'reset:campaign'
if d=='retry':return self.apply('retry')
if d=='menu':self.title=True;return 'menu'
return 'ignored:dialog'
if k=='ESCAPE':self.dialog='quit';return 'confirm:quit'
if k in ('P','H'):self.help=not self.help;return 'help'
if self.help:return 'ignored:help'
if self.title:
if k in ('1','2','3'):self.reset('ABC'[int(k)-1]);return 'case'
if k=='B':self.s=new(self.s.case,'fixed' if self.s.variant=='adaptive' else 'adaptive');self.history=[];self.log=[];return 'variant'
if k in ('SPACE','RETURN'):self.title=False;return 'start'
return 'ignored:title'
if k=='M':self.dialog='menu';return 'confirm:menu'
if k=='N':self.dialog='new';return 'confirm:new'
if k=='R' and self.s.phase=='play':self.dialog='retry';return 'confirm:retry'
if k=='X':return self.apply('undo')
if k in ('S','L'):
if self.s.phase not in ('intermission','done'):self.notice='保存と読込は夜と夜の間、または三夜の結果で。';return 'blocked:save-phase'
try:
if k=='S':storage.save(self.path,self.s,self.history,self.log);self.notice='この三夜を保存しました。';return 'save'
self.s,self.history,self.log=storage.load(self.path);self.notice='保存した三夜を読み込みました。';return 'load'
except (ValueError,KeyError,TypeError,OSError):self.notice='保存を読めません。元のファイルは残しています。Nで新規開始。';return 'save-error'
if self.s.phase=='intermission' and k=='RETURN':return self.apply('next')
if self.s.phase!='play':return 'ignored:result'
a={'UP':'up','DOWN':'down','LEFT':'left','RIGHT':'right','SPACE':'wait','Z':'use'}.get(k)
if a:self.selected=a;return 'select:'+a
if k=='RETURN':return self.apply(self.selected)
return 'ignored:key'
# UI and diagnostic CLI: independent of the pure model.
import argparse
import json
from pathlib import Path
def tap(ui, key):
event = ui.feed({key})
ui.feed(set()) # Every distinct key press has a full release.
return event
def commit(ui, action):
if action == 'next':
return tap(ui, 'RETURN')
key = {'up':'UP','down':'DOWN','left':'LEFT','right':'RIGHT','wait':'SPACE','use':'Z'}[action]
tap(ui, key)
return tap(ui, 'RETURN')
def demo(ui, scenario):
if scenario == 'initial':
return
if ui.title:
tap(ui, 'RETURN')
if scenario == 'checkpoint':
commit(ui, 'right')
return
if scenario == 'wall':
commit(ui, 'right'); commit(ui, 'up')
elif scenario in ('caught', 'preview') and STAGE >= 2:
if STAGE >= 4:
ui.s = new('A')
ui.s = replace(ui.s, guards=(0, 1, 0))
commit(ui, 'right'); commit(ui, 'right'); commit(ui, 'wait')
tap(ui, 'RIGHT')
if scenario == 'caught':
tap(ui, 'RETURN')
elif scenario == 'timeout' and STAGE >= 2:
for _ in range(40):
commit(ui, 'wait')
elif scenario == 'confirm':
commit(ui, 'right'); tap(ui, 'R')
elif scenario in ('returned', 'next', 'undo_next', 'success', 'saved', 'loaded', 'return40') and STAGE >= 3:
nights = 3 if scenario == 'success' and STAGE >= 4 else 1
for night in range(nights):
if night:
commit(ui, 'next')
# A deterministic public model route, waiting before occupied gates.
for direction in ('right', 'left'):
for _ in range(6):
q = ui.s.pos + (1 if direction == 'right' else -1)
while q in GATES and occupied(ui.s.guards[GATES.index(q)], ui.s.t + 1):
commit(ui, 'wait')
commit(ui, direction)
if direction == 'left' and scenario == 'return40':
while ui.s.t < 39:
commit(ui, 'wait')
commit(ui, 'use')
if scenario in ('next', 'undo_next') and STAGE >= 4:
commit(ui, 'next')
if scenario == 'undo_next':
tap(ui, 'X')
if scenario in ('saved','loaded') and STAGE == 5:
tap(ui, 'S' if scenario == 'saved' else 'L')
else:
raise ValueError('scenario not available at this stage')
def parse_args(argv=None):
parser = argparse.ArgumentParser(description='Single-file Pyxel lesson 093')
parser.add_argument('--headless-smoke', action='store_true')
parser.add_argument('--demo-checkpoint', action='store_true')
parser.add_argument('--scenario', choices=['initial','checkpoint','wall','confirm'] +
(['caught','preview','timeout'] if STAGE >= 2 else []) +
(['returned','return40'] if STAGE >= 3 else []) +
(['next','undo_next','success'] if STAGE >= 4 else []) +
(['saved','loaded'] if STAGE == 5 else []))
parser.add_argument('--screenshot', type=Path)
parser.add_argument('--state-json', type=Path)
parser.add_argument('--save-file', type=Path, default=Path('save_093.json'))
args = parser.parse_args(argv)
if not args.headless_smoke and (args.demo_checkpoint or args.scenario or args.screenshot or args.state_json):
parser.error('diagnostic flags require --headless-smoke')
if args.demo_checkpoint and args.scenario:
parser.error('choose --demo-checkpoint or --scenario')
# Resolve before pyxel.init, which can alter working-directory behavior.
for name, suffix in (('screenshot','.png'),('state_json','.json')):
path = getattr(args, name)
if path is None:
continue
path = path.resolve()
if path.suffix.lower() != suffix or not path.parent.is_dir() or path.exists():
parser.error(name + ': require a new file with correct suffix in an existing directory')
setattr(args, name, path)
args.save_file = args.save_file.resolve()
if args.save_file.suffix.lower() != '.json' or not args.save_file.parent.is_dir() or args.save_file.is_dir():
parser.error('save-file: require a .json file in an existing directory')
if args.save_file in (args.screenshot, args.state_json):
parser.error('save-file and diagnostic outputs must differ')
if args.scenario == 'saved' and args.save_file.exists():
parser.error('saved scenario must use a new save file')
return args
class App:
def __init__(self, args):
self.args = args
self.ui = Controller(args.save_file)
self.frame = 0
if args.headless_smoke:
demo(self.ui, args.scenario or ('checkpoint' if args.demo_checkpoint else 'initial'))
pyxel.init(560, 400, title='093 THREE NIGHTS / STAGE ' + str(STAGE),
fps=30, quit_key=pyxel.KEY_NONE, headless=args.headless_smoke, display_scale=1)
self.keys = {key: getattr(pyxel, 'KEY_' + key) for key in KEYS}
pyxel.run(self.update, self.draw)
def update(self):
self.frame += 1
if self.args.headless_smoke:
if self.frame >= 3:
pyxel.quit()
return
event = self.ui.feed({key for key, code in self.keys.items() if pyxel.btn(code)})
if event == 'quit':
pyxel.quit()
def draw(self):
u, s = self.ui, self.ui.s
pyxel.cls(1)
pyxel.text(12, 10, '093 THREE NIGHTS | STAGE ' + str(STAGE), 10)
pyxel.text(12, 24, 'SELECT: ' + u.selected.upper() + ' TICK: ' + str(s.t) + ' POS: ' + str(s.pos), 7)
pyxel.text(300, 24, 'NIGHT: ' + str(getattr(s,'night',0)+1) + ' PHASE: ' + getattr(s,'phase','play').upper(), 11)
for p in range(35):
x, y = 12 + p % 7 * 42, 48 + p // 7 * 42
pyxel.rect(x, y, 39, 39, 5 if p in FLOOR else 2)
if p in FLOOR:
pyxel.rectb(x, y, 39, 39, 13)
if STAGE >= 2 and p in GATES:
g = s.guards[GATES.index(p)]
pyxel.rectb(x+3,y+3,33,23,8 if occupied(g,s.t) else 11)
pyxel.text(x+5,y+28,'G'+str(g),7)
if p == START:
pyxel.text(x+3,y+29,'S',11)
if STAGE >= 3 and p == TREASURE:
pyxel.text(x+3,y+29,'T',10)
if not s.holding and s.phase == 'play':
pyxel.circ(x+25,y+16,5,10)
x,y = 12+s.pos%7*42+19,48+s.pos//7*42+14
pyxel.circ(x,y,6,12)
if getattr(s,'holding',False):
pyxel.circ(x+9,y,3,10)
pyxel.rect(315,48,233,210,2)
if STAGE >= 2:
pyxel.text(325,60,'PUBLIC GUARDS / NEXT 3 TICKS' if getattr(s,'phase','play') == 'play' else 'RECORDED GUARDS / FINISHED NIGHT',10)
for i,g in enumerate(s.guards):
future = ' '.join('BUSY' if occupied(g,s.t+k) else 'OPEN' for k in (1,2,3))
pyxel.text(325,80+i*18,str(i)+': '+(future if getattr(s,'phase','play') == 'play' else 'LEVEL '+str(g)),7)
pyxel.text(325,143,'FAILS: '+str(s.fails)+' TOTAL: '+str(s.total),7)
if STAGE >= 3:
pyxel.text(325,163,'HOLDING: '+str(int(s.holding)),10)
pyxel.text(325,179,'HOME: '+str(len(s.items))+' USED BITS: '+str(s.used),11)
phase = getattr(s,'phase','play')
if phase == 'play':
preview = step(s,u.selected)
msg = 'NO CHANGE' if preview == s else 'CAUGHT / RESET' if getattr(preview,'fails',0)>getattr(s,'fails',0) else 'VALID ONE TICK'
pyxel.text(325,208,'PREVIEW: '+msg,8 if 'RESET' in msg else 6)
pyxel.text(325,225,'ENTER commits; preview is read-only.',7)
else:
pyxel.text(325,208,'RESULT: '+phase.upper(),10)
if phase == 'intermission':
next_guards = adapted(s.guards,s.used) if s.variant == 'adaptive' else s.guards
pyxel.text(325,224,'NEXT GUARDS: '+str(next_guards),11)
else:
pyxel.text(325,224,'NO FUTURE GUARD UPDATE',11)
pyxel.text(12,272,'Arrows: select direction ENTER: commit Release ALL keys between presses',7)
if STAGE >= 2: pyxel.text(12,288,'SPACE: select wait R: confirm retry (current night only)',6)
if STAGE >= 3: pyxel.text(12,304,'Z: select pickup/return X: Undo one committed decision',6)
if STAGE >= 4: pyxel.text(12,320,'N: new M: menu H/P: help Title: 1/2/3 case, B variant',6)
if STAGE == 5: pyxel.text(12,336,'S/L: save/load at intermission or result only',6)
pyxel.text(12,358,'ESC: confirm quit. R never reloads edited Python code.',7)
if u.notice:
notice = 'SAVE OK' if '保存しました' in u.notice else 'LOAD OK' if '読み込みました' in u.notice else 'SAVE/LOAD ERROR: file preserved' if '読めません' in u.notice else 'SAVE/LOAD: intermission or result only'
pyxel.text(12,375,notice,10)
if u.title or u.help:
pyxel.rect(35,70,490,175,1); pyxel.rectb(35,70,490,175,10)
pyxel.text(50,85,'THREE NIGHTS: BRING HOME THREE FICTIONAL KEEPSAKES',10)
pyxel.text(50,110,'Directions / SPACE / Z select. ENTER commits one action.',7)
pyxel.text(50,130,'No real-world intrusion instructions. This is a grid puzzle.',6)
pyxel.text(50,155,'Case: '+s.case+' Variant: '+s.variant,11)
pyxel.text(50,175,'1/2/3 choose case. B changes adaptation before starting.',7)
pyxel.text(50,205,'ENTER: start H/P: close help. Release keys first.',10)
if u.dialog:
pyxel.rect(35,125,490,105,1); pyxel.rectb(35,125,490,105,8)
pyxel.text(50,145,'CONFIRM: '+u.dialog.upper(),10)
pyxel.text(50,170,'Release all keys, then ENTER confirms. X cancels.',7)
pyxel.text(50,193,'Retry keeps previous nights. New resets all three.',6)
if self.args.headless_smoke and self.frame == 2:
if self.args.screenshot:
pyxel.screenshot(str(self.args.screenshot),scale=1)
if self.args.state_json:
data={'stage':STAGE,'state':asdict(s),'selected':u.selected,'title':u.title,
'dialog':u.dialog,'history':[asdict(v) for v in u.history],
'scope':'Actual Pyxel Linux headless rendering; synthetic controller inputs'}
with self.args.state_json.open('x',encoding='utf-8') as handle:
json.dump(data,handle,ensure_ascii=False,indent=2)
if __name__ == '__main__':
args = parse_args()
import pyxel
App(args)仕組みがわかったら、元の完成版を読んでみよう。
ルールのまとめ:新しい刻の、見せている警備の位置と、動いた先が重なれば、今夜をやり直し。品物を取ることと、帰ることは別の行動。前の夜に使った門だけ次の夜に +1、使わなかった門は -1、範囲は0〜2。40刻目に帰れれば成功。
ここまでに作ったのは、元のゲームの仕組みだけを取り出して作り直した「簡略版」です。元のゲームのソースは手を加えずに、別のZIP(元の完成版ソース)に入れてあります。
| 比べる点 | 5ステップの教材 | 元の完成版 |
|---|---|---|
| 段階 | 01 移動だけ、02 は真ん中の警備1、03 は元のゲームの状況 A の1夜、04 3つの夜、05 元のゲームのルール全部と保存。 | 最初から、3つの夜とすべての機能があります。 |
| 表示・記録 | 560×400の英数字(ASCII)。F12・元のゲームの出来事の記録・日本語の描画・飾りは省略。 | 1024×720の日本語の画面、F12・実際の入力やコードの記録。 |
| 単独実行 | それぞれの Python ファイルに、ルール・必要な入力の処理・MIT ライセンスの全文。外部に必要なのは Pyxel だけで、assets は不要。 | いくつもの Python ファイルと glyphs.json。 |
完成版のコードで、次に読むところ
- 元のゲームの model.py:
stepの移動の候補とFLOOR - 元のゲームの controller.py:
feedのfreshとgate - 元のゲームの model.py:
occupied/forecast/retry - 元のゲームの app.py:
preview.failsとwarning - 元のゲームの model.py:
stepのholding/items/phase - 元のゲームの controller.py:
applyのhistory - 元のゲームの model.py:
adaptedと、stepのnextの分かれ道 - 元のゲームの experiments.py:
campaign/all_guards - 元のゲームの storage.py:
save/load - 元のゲームの model.py:
from_dict/digest - 元のゲームの app.py:
record/flush_events/text
最初は、数字かルールを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の実際のパソコンでの操作や、音が出るかどうかはまだ確認していません。人がすべての操作を試したわけでもありません。ブラウザー版の確認範囲は確認状況をご覧ください。
うまく動かないときは:起動やエラーの対処法 / 質問用のメモを作る
