完成サンプルをブラウザーで遊ぶ
この講座のもとになった完成版のゲームを、ブラウザーでそのまま遊べます。キーボードで操作するので、PCで開いてください(スマホのタッチ操作には対応していません)。初めて開くときは、読み込みに少し時間がかかります。
完成版の主な操作:矢印キーで台を選んでZでカードを変える、Tabで台と箱の札を切り替える、Enterで試運転、Spaceで1刻進める、Pで自動運転・停止、Xで1手戻す
ボタンを押すと、ゲームの読み込みが始まります。
画面に「CLICK TO START」と出たらクリックします。続けてゲーム画面を一度クリックすると、キー操作が効くようになります。ゲームを終えるときは、枠の外にある「閉じる」ボタンを使ってください(Escキーの働きはゲームごとに違います)。ゲーム内のセーブは、閉じる・ページを移る・再読み込みすると消えます。
うまく表示されないとき
ゲームを動かすためのプログラム(Pyxel 2.9.9)は、外部の配信サービス(jsDelivr)から読み込みます。表示されないときは「最初から再読込」を押すか、完成版のソースをダウンロードしてPCで実行してください。ブラウザー版の録画機能は止めています。ブラウザー版で確認できている範囲は検証状況にまとめています。
条件分岐と、まとめて更新する処理を組み合わせる。
いきなり完成版のコードを全部読む必要はありません。小さなゲームにルールを1つずつ足しながら、5つのステップで作っていきます。どのステップのファイルもそれだけで動くので、動きを確かめてから次へ進みましょう。
- 必要な環境
- Python 3.11以上 / Pyxel 2.9.9
- 知っておくとよいこと
- Pythonの変数・if・関数・for / リストとタプルの基本 / dataclass と replace、書き換えられない状態は本文で説明します
- 教材に入っているもの
- ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
- 完成した教材の操作
- Z:01はカード、02は選んだ台のカード、04と05は選んだカード・箱の札を順番に変える / P:01と02は見本の荷物を変える / 矢印:02は左右で台を選ぶ、04と05は台・箱を選ぶ / Tab:04と05で台と箱の札を切り替える / Enter:03〜05の試運転を始める / Space:01は分かれ道、02は全部の道すじ、03〜05は1刻進める / X:05だけ、すべての状態をUndo / R:最初に戻す / Esc:終了
- 難易度の目安
- 初級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
- 学習順に迷ったら
- おすすめの学ぶ順番を見る
コードを書き換えたら、ファイルを保存し、動いているゲームを閉じてから、もう一度同じ.pyを実行します。ゲーム中にRキーなどで「やり直し」をしても、書き換えたコードは読み込まれません。
教材のゲーム画面は、日本語フォントを用意しなくても動くように英語で表示しています。ファイルには動作確認用のコードも入っていますが、最初は Game / State・update・draw の3つに注目すれば十分です。わからない言葉は用語集で調べられます。class・self・dataclass はPythonの状態、キー入力と時間の扱いは入力・更新・描画、マス目とピクセルの違いは画面とマップの座標のページで詳しく説明しています。
1台のカードで左右を決める
状態を変えない関数と、条件分岐。
最初は、荷物1個と台 A だけです。Z でカードを COLOR(色)→WEIGHT(重さ)→LEFT(左)→RIGHT(右)の順に選び、P で見本の荷物を切り替え、Space で出口を計算します。画面の R は赤、B は青、重さ1は軽い、2は重いです。左右は画面上の位置であり、関数の中では左が0・右が1という数です。まだ時計・順番待ちの列・箱への配達・クリアの判定はありません。
branch(card, parcel) は、受け取った2つの値だけから答えを返す関数です。COLOR なら赤は左、青は右。WEIGHT なら重さ2は左、1は右です。LEFT と RIGHT は、荷物の色や重さを見ずに、そのまま決まった向きへ進める命令です。カードの番号0と1は条件で判定し、2と3は素通りなので、card-2 で出口の0か1に直せます。
Parcel は、番号・色・重さ・宛先を持つ dataclass です。frozen=True は「作った値をあとから書き換えない」という約束です。表示に使う State にカード・今の荷物の番号・結果をまとめ、編集するときは replace で新しい State を作ります。前の結果を-1に戻すのは、カードを変えたのに古い答えが見え続けて、勘違いするのを防ぐためです。
App.control はキーを意味のある操作に置きかえ、App.draw は State を描くだけです。branch の中では Pyxel を使わないので、画面を開かずにテストできます。押しっぱなしを連続した操作にせず、すべてのキーが離されてから次を受け付けます。
下のコードは 01_branch.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
if card == 0:
return 0 if parcel.color == "R" else 1
if card == 1:
return 0 if parcel.weight == 2 else 1実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 01_branch.py.venv/bin/python 01_branch.py- 色と重さを別々に判定できる
- LEFT と RIGHT は、色や重さに関係なく決まる
- カードを変えると、前の結果が消える
R で戻して Z を1回押します。荷物1(赤・重さ1)のまま Space を押すと RIGHT です。次に LEFT のカードに変えて、6種類の荷物がすべて左になることを確かめてください。
このステップのコード全文(171行)
# MIT License
#
# Copyright (c) 2026
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
#
# Third-party exception: bitmap glyph data derived from Noto Sans CJK is licensed
# under the SIL Open Font License 1.1. See assets/NOTO-LICENSE.txt.
"""からくり郵便室・教材1/5。単独で起動できます。"""
from dataclasses import dataclass, replace
CARD_NAMES = ("COLOR", "WEIGHT", "LEFT", "RIGHT")
@dataclass(frozen=True)
class Parcel:
id: int
color: str
weight: int
destination: str
# 原作Bの6便。番号の内部値は0始まり、画面だけ1始まり。
PARCELS = tuple(Parcel(i, color, weight, color+("H" if weight == 2 else "L"))
for i, (color, weight) in enumerate(
(("R", 1), ("B", 2), ("R", 2), ("B", 1), ("B", 2), ("R", 1))))
def branch(card, parcel):
# 出口は左=0、右=1。台番号や箱番号とは区別する。
if card == 0:
return 0 if parcel.color == "R" else 1
if card == 1:
return 0 if parcel.weight == 2 else 1
return card-2
@dataclass(frozen=True)
class State:
card: int = 0
parcel: int = 0
result: int = -1
def apply(state, action):
if action == "card":
return replace(state, card=(state.card+1)%4, result=-1)
if action == "parcel":
return replace(state, parcel=(state.parcel+1)%len(PARCELS), result=-1)
if action == "route":
return replace(state, result=branch(state.card, PARCELS[state.parcel]))
return state
class App:
def __init__(self):
self.reset()
def reset(self):
self.state = State()
self.locked = False
self.message = "CHOOSE A CARD AND PARCEL, THEN SPACE"
def control(self, down):
down = set(down)
if self.locked:
if not down:
self.locked = False
return
if not down:
return
self.locked = True
if len(down) != 1:
self.message = "ONE KEY AT A TIME / RELEASE ALL"
return
key = next(iter(down))
if key == "R":
self.reset()
self.locked = True
return
action = {"Z": "card", "P": "parcel", "SPACE": "route"}.get(key)
if action:
self.state = apply(self.state, action)
self.message = "RESULT CLEARED AFTER EDIT" if action != "route" else "ONE BRANCH EVALUATED"
def draw(self, p):
s = self.state
parcel = PARCELS[s.parcel]
p.cls(1)
p.text(8, 8, "CLOCKWORK POST / 1: ONE BRANCH", 10)
p.text(8, 26, "PRACTICE / NO CLOCK, DELIVERY OR WIN YET", 6)
p.text(8, 48, "PARCEL "+str(parcel.id+1)+" COLOR "+parcel.color+" WEIGHT "+str(parcel.weight), 7)
p.text(8, 60, "Z CARD / P NEXT PARCEL / SPACE EVALUATE", 7)
p.rect(140, 106, 120, 32, 2)
p.text(160, 118, "A: "+CARD_NAMES[s.card], 10)
p.line(200, 138, 88, 192, 5)
p.line(200, 138, 312, 192, 5)
for i, x in enumerate((44, 268)):
p.rectb(x, 192, 88, 32, 11 if s.result == i else 5)
p.text(x+20, 205, "LEFT = 0" if i == 0 else "RIGHT = 1", 7)
p.text(8, 250, "RESULT: "+("NOT EVALUATED" if s.result < 0 else ("LEFT" if s.result == 0 else "RIGHT")), 11)
p.text(8, 278, self.message, 10)
p.text(8, 304, "COLOR R:LEFT B:RIGHT / WEIGHT 2:LEFT 1:RIGHT", 6)
p.text(8, 315, "LEFT/RIGHT: UNCONDITIONAL / R:RED B:BLUE", 6)
p.text(8, 326, "DEST RH:RED HEAVY RL:RED LIGHT BH/BL:BLUE", 6)
p.text(8, 344, "R RESET / RELEASE ALL KEYS BETWEEN ACTIONS / ESC QUIT", 5)
DEMO_KEYS = ("Z", "P", "SPACE")
def main(headless=False, screenshot=None, demo=False):
from pathlib import Path
import pyxel as p
# Pyxelの初期化より先に、指定した保存先を絶対化する。
screenshot = str(Path(screenshot).resolve()) if screenshot else None
if screenshot:
Path(screenshot).parent.mkdir(parents=True, exist_ok=True)
app = App()
p.init(400, 360, title="Clockwork Post", fps=30, display_scale=2,
headless=headless)
keys = {name: getattr(p, "KEY_"+name) for name in
("UP", "DOWN", "LEFT", "RIGHT", "TAB", "Z", "P", "RETURN", "SPACE", "X", "R")}
limit = max(12, 2*len(DEMO_KEYS)+3) if demo else 12
def update():
if headless and p.frame_count >= limit:
if screenshot:
p.screenshot(screenshot, scale=2)
from dataclasses import asdict
import json
print(json.dumps({"state": asdict(app.state), "message": app.message},
ensure_ascii=True, sort_keys=True), flush=True)
p.quit()
return
if headless and demo:
index = p.frame_count//2
down = {DEMO_KEYS[index]} if p.frame_count%2 == 0 and index < len(DEMO_KEYS) else set()
else:
down = {key for key, value in keys.items() if p.btn(value)}
app.control(down)
p.run(update, lambda: app.draw(p))
return app
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("--headless-smoke", action="store_true")
parser.add_argument("--demo-checkpoint", action="store_true")
parser.add_argument("--screenshot")
args = parser.parse_args()
main(args.headless_smoke, args.screenshot, args.demo_checkpoint)分岐をつないで4つの箱へ運ぶ
決まった形の二分木と、番号の変換。
台を A・B・C の3つに増やし、A から左の B か右の C へ、さらに4つの箱へ進む、決まった形の二分木を作ります。台の中での番号は A=0、B=1、C=2 です。箱の中での番号は0〜3で、画面では1〜4と表示します。矢印キーで台を選び、Z でカードを変え、P で荷物を変えます。Space は、選んだ荷物が通る道すじを一度に全部調べる操作です。
route は、まず A の出口(0か1)に1を足して、次の台(1か2)を選びます。次にその台の出口を読み、(table-1)*2 を足して箱の番号を作ります。B の2つの箱は0と1、C の箱は2と3なので、この小さな式で左右の箱を区別できます。戻り値 (0, table, box) のうち、最後の値だけが箱の番号で、3つ全部が台の番号というわけではありません。
最初のカードは、A が COLOR、B と C が WEIGHT です。赤は B へ、青は C へ分けてから、重い便を左、軽い便を右へ送ります。図の箱の札 RH・RL・BH・BL は、赤重・赤軽・青重・青軽という意味です。ただし、この段階では札との照合や失敗はまだ作っていません。カードを変えて宛先と違う箱になっても、道すじを表示するだけです。
この Space は、元のゲームの「1刻」ではありません。時間を省いて、道すじを全部計算するための模型です。たくさんの荷物をここにそのままループで流すと、台がふさがっていることや、待っていることを表せません。次の段階で「前の状態」と「新しい状態」を分け、同じ荷物が1刻のうちに2つの台を通らない仕組みに置きかえます。
下のコードは 02_tree.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
table = 1+branch(cards[0], parcel)
box = (table-1)*2+branch(cards[table], parcel)
return (0, table, box)実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 02_tree.py.venv/bin/python 02_tree.py- 台の番号と箱の番号を区別する
- A の左右で B か C を選べる
- 道すじを全部計算するのは、1刻の動きではない
+1 と、箱の組をずらす *2 を、別々に確かめてください。R のあと P を2回押して荷物3(赤・重さ2)を選び、Space を押す前に道すじを紙に書いてみましょう。A > B > BOX 1 と同じになるか確かめます。そのあと C のカードだけを変えても、赤い荷物の道すじが変わらない理由を説明してください。
このステップのコード全文(193行)
# MIT License
#
# Copyright (c) 2026
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
#
# Third-party exception: bitmap glyph data derived from Noto Sans CJK is licensed
# under the SIL Open Font License 1.1. See assets/NOTO-LICENSE.txt.
"""からくり郵便室・教材2/5。単独で起動できます。"""
from dataclasses import dataclass, replace
CARD_NAMES = ("COLOR", "WEIGHT", "LEFT", "RIGHT")
@dataclass(frozen=True)
class Parcel:
id: int
color: str
weight: int
destination: str
# 原作Bの6便。番号の内部値は0始まり、画面だけ1始まり。
PARCELS = tuple(Parcel(i, color, weight, color+("H" if weight == 2 else "L"))
for i, (color, weight) in enumerate(
(("R", 1), ("B", 2), ("R", 2), ("B", 1), ("B", 2), ("R", 1))))
def branch(card, parcel):
# 出口は左=0、右=1。台番号や箱番号とは区別する。
if card == 0:
return 0 if parcel.color == "R" else 1
if card == 1:
return 0 if parcel.weight == 2 else 1
return card-2
@dataclass(frozen=True)
class State:
cards: tuple = (0, 1, 1)
parcel: int = 0
route: tuple = ()
def route(cards, parcel):
# Aの0/1出口を、子台の1/2番へ写す。
table = 1+branch(cards[0], parcel)
box = (table-1)*2+branch(cards[table], parcel)
return (0, table, box)
def apply(state, action):
if action == "parcel":
return replace(state, parcel=(state.parcel+1)%len(PARCELS), route=())
if action == "route":
return replace(state, route=route(state.cards, PARCELS[state.parcel]))
if isinstance(action, tuple) and len(action) == 2 and action[0] == "card":
index = action[1]
if type(index) is int and 0 <= index < 3:
cards = list(state.cards)
cards[index] = (cards[index]+1)%4
return replace(state, cards=tuple(cards), route=())
return state
class App:
def __init__(self):
self.reset()
def reset(self):
self.state = State()
self.cursor = 0
self.locked = False
self.message = "FOLLOW TWO BRANCHES / THIS IS NOT A TICK"
def control(self, down):
down = set(down)
if self.locked:
if not down:
self.locked = False
return
if not down:
return
self.locked = True
if len(down) != 1:
self.message = "ONE KEY AT A TIME / RELEASE ALL"
return
key = next(iter(down))
if key == "R":
self.reset()
self.locked = True
return
if key in ("LEFT", "RIGHT"):
self.cursor = (self.cursor+(-1 if key == "LEFT" else 1))%3
elif key == "Z":
self.state = apply(self.state, ("card", self.cursor))
elif key in ("P", "SPACE"):
self.state = apply(self.state, "parcel" if key == "P" else "route")
def draw(self, p):
s = self.state
parcel = PARCELS[s.parcel]
p.cls(1)
p.text(8, 8, "CLOCKWORK POST / 2: FIXED BINARY TREE", 10)
p.text(8, 25, "NO OCCUPANCY OR CLOCK / SPACE TRACES WHOLE ROUTE", 6)
p.text(8, 42, "PARCEL "+str(parcel.id+1)+" COLOR "+parcel.color+" WEIGHT "+str(parcel.weight), 7)
points = ((200, 86), (100, 144), (300, 144))
for x in (100, 300):
p.line(200, 101, x, 135, 5)
for i, (x, y) in enumerate(points):
p.rect(x-45, y-9, 90, 27, 2)
p.rectb(x-45, y-9, 90, 27, 10 if self.cursor == i else 5)
p.text(x-35, y+2, chr(65+i)+": "+CARD_NAMES[s.cards[i]], 7)
labels = ("RH", "RL", "BH", "BL")
for i, x in enumerate((50, 150, 250, 350)):
p.line(100 if i < 2 else 300, 162, x, 192, 5)
p.rectb(x-35, 192, 70, 26, 11 if s.route and s.route[2] == i else 5)
p.text(x-28, 201, "BOX "+str(i+1)+" "+labels[i], 7)
result = "NOT EVALUATED"
if s.route:
result = "A > "+chr(65+s.route[1])+" > BOX "+str(s.route[2]+1)
p.text(8, 239, "ROUTE: "+result, 11)
p.text(8, 255, "ARROWS SELECT TABLE / Z CARD / P PARCEL / SPACE TRACE", 7)
p.text(8, 278, self.message, 10)
p.text(8, 304, "COLOR R:LEFT B:RIGHT / WEIGHT 2:LEFT 1:RIGHT", 6)
p.text(8, 315, "LEFT/RIGHT: UNCONDITIONAL / R:RED B:BLUE", 6)
p.text(8, 326, "DEST RH:RED HEAVY RL:RED LIGHT BH/BL:BLUE", 6)
p.text(8, 344, "R RESET / RELEASE ALL KEYS BETWEEN ACTIONS / ESC QUIT", 5)
DEMO_KEYS = ("P", "SPACE")
def main(headless=False, screenshot=None, demo=False):
from pathlib import Path
import pyxel as p
# Pyxelの初期化より先に、指定した保存先を絶対化する。
screenshot = str(Path(screenshot).resolve()) if screenshot else None
if screenshot:
Path(screenshot).parent.mkdir(parents=True, exist_ok=True)
app = App()
p.init(400, 360, title="Clockwork Post", fps=30, display_scale=2,
headless=headless)
keys = {name: getattr(p, "KEY_"+name) for name in
("UP", "DOWN", "LEFT", "RIGHT", "TAB", "Z", "P", "RETURN", "SPACE", "X", "R")}
limit = max(12, 2*len(DEMO_KEYS)+3) if demo else 12
def update():
if headless and p.frame_count >= limit:
if screenshot:
p.screenshot(screenshot, scale=2)
from dataclasses import asdict
import json
print(json.dumps({"state": asdict(app.state), "message": app.message},
ensure_ascii=True, sort_keys=True), flush=True)
p.quit()
return
if headless and demo:
index = p.frame_count//2
down = {DEMO_KEYS[index]} if p.frame_count%2 == 0 and index < len(DEMO_KEYS) else set()
else:
down = {key for key, value in keys.items() if p.btn(value)}
app.control(down)
p.run(update, lambda: app.draw(p))
return app
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("--headless-smoke", action="store_true")
parser.add_argument("--demo-checkpoint", action="store_true")
parser.add_argument("--screenshot")
args = parser.parse_args()
main(args.headless_smoke, args.screenshot, args.demo_checkpoint)古い占有状態で一刻を進める
まとめて更新する処理と、6便がなくならないことの確認。
この段階から、元のゲームの B の6便と、ゲームの中の時刻を使います。カードと札は正解の配置に固定しています。Enter で始め、Space を1回押すごとに1刻進みます。何も押さなかったり、画面を描いたりするだけでは進みません。便1は時刻0に入口にいて、便2〜6は時刻1〜5に1個ずつ届きます。next_id はまだ届いていない便の先頭の番号、queue は届いて待っている列、tables は3つの台にどの便があるかです。-1は空いている台を表します。
いちばん難しいところは、old = state.tables と tables = list(old) を分けるところです。「B・C から箱へ」「A から B・C へ」「入口から A へ」の順に動きの候補を計算しますが、動いてよいかどうかは、いつも old(前の状態)で決めます。B がこの刻に荷物を箱へ送って空いても、old の B に荷物があれば、A は待ちます。同じように、A の荷物が動いた刻に、入口の荷物がその空きを使うことはできません。順番に書いた Python でも、許可の判断を古いリストにそろえれば、「全部が同時に動く」ルールになります。
1刻目は、便1が A に入るだけです。2刻目に A から B へ進みますが、便2は空いた A にまだ入れません。3刻目に便1が箱2に届き、便2が A に入ります。その刻に新しく台に着いた便を、同じ刻にもう一度処理しないので、1つの便が1刻に動くのは最大1回です。比較版の「その刻に空いた台も使える」ルールは入れていません。
なくなっていないかの確認では、入口・台・配達済み・まだ届いていない便の番号を集めて、0〜5がちょうど1回ずつあるかを調べます。数が6であることだけを調べると、同じ便が2つに増えて別の便が消える、というバグを見逃してしまいます。箱に着いたら宛先と照合し、まちがっていたらその刻の分をすべて確定してから止まります。ここでは正解の配置に固定しているので、ふつうは13刻で全部の便が届きます。
下のコードは 03_sync.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
if old[target] < 0:
tables[0] = -1
tables[target] = pid実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 03_sync.py.venv/bin/python 03_sync.py- 1刻目に A から先へ飛ばない
- 2刻目に、入口の便は A に入らない
- すべての便の番号が、いつも1回ずつある
old[target] ではなく、書き換え途中の tables[target] を調べると、下流から処理する順番に結果が左右され、「その刻に空いた台も使える」という別のルールになってしまいます。新しく着いた便をもう一度処理するループもだめです。時刻0〜3の queue・tables・deliveries を紙に書き、1刻ずつ画面と見比べてみましょう。2刻目に便2が A に入れない理由を、old[0] を使って説明します。テストの test_downstream_vacancy_uses_old_state にある、2つの台がうまった状態も読んでみましょう。
このステップのコード全文(311行)
# MIT License
#
# Copyright (c) 2026
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
#
# Third-party exception: bitmap glyph data derived from Noto Sans CJK is licensed
# under the SIL Open Font License 1.1. See assets/NOTO-LICENSE.txt.
"""からくり郵便室・教材3/5。単独で起動できます。"""
from dataclasses import dataclass, replace
CARD_NAMES = ("COLOR", "WEIGHT", "LEFT", "RIGHT")
@dataclass(frozen=True)
class Parcel:
id: int
color: str
weight: int
destination: str
# 原作Bの6便。番号の内部値は0始まり、画面だけ1始まり。
PARCELS = tuple(Parcel(i, color, weight, color+("H" if weight == 2 else "L"))
for i, (color, weight) in enumerate(
(("R", 1), ("B", 2), ("R", 2), ("B", 1), ("B", 2), ("R", 1))))
def branch(card, parcel):
# 出口は左=0、右=1。台番号や箱番号とは区別する。
if card == 0:
return 0 if parcel.color == "R" else 1
if card == 1:
return 0 if parcel.weight == 2 else 1
return card-2
CAP = 3
LABELS = ("RL", "RH", "BL", "BH")
LABEL_NAMES = {"RL": "赤・軽", "RH": "赤・重", "BL": "青・軽", "BH": "青・重"}
@dataclass(frozen=True)
class Delivery:
parcel: int
box: int
tick: int
correct: bool
path: tuple
@dataclass(frozen=True)
class State:
cards: tuple = (0, 1, 1)
labels: tuple = ("RH", "RL", "BH", "BL")
t: int = 0
next_id: int = 1
queue: tuple = (0,)
tables: tuple = (-1, -1, -1)
deliveries: tuple = ()
paths: tuple = ((),)*6
status: str = "edit"
reason: str = "カードと箱札を編集して試運転。"
moves: tuple = ()
waits: tuple = ()
def condition(card, parcel):
if card == 0:
return "赤" if parcel.color == "R" else "青"
if card == 1:
return "重さ"+str(parcel.weight)
return "そのまま"
def count_tests(state):
return sum(card < 2 for card in state.cards)
def start(state):
if state.status != "edit" or count_tests(state) > CAP:
return state
return replace(state, status="run", reason="試運転中。Spaceで一刻。",
moves=(), waits=())
def tick(state):
if state.status != "run":
return state
t = state.t+1
queue = list(state.queue)
next_id = state.next_id
if next_id < len(PARCELS) and next_id <= t:
queue.append(next_id)
next_id += 1
# oldは読み取り専用。tablesだけを書き換えて最後に同時確定する。
old = state.tables
tables = list(old)
paths = list(state.paths)
deliveries = list(state.deliveries)
moves, waits, faults = [], [], []
for table in (1, 2):
pid = old[table]
if pid < 0:
continue
parcel = PARCELS[pid]
exit_side = branch(state.cards[table], parcel)
box = (table-1)*2+exit_side
path = paths[pid]+(chr(65+table)+":"+condition(state.cards[table], parcel)+"→"+
("左" if exit_side == 0 else "右"),
"箱"+str(box+1)+":"+LABEL_NAMES[state.labels[box]])
paths[pid] = path
correct = state.labels[box] == parcel.destination
deliveries.append(Delivery(pid, box, t, correct, path))
tables[table] = -1
moves.append((pid, chr(65+table), "箱"+str(box+1)))
if not correct:
faults.append(pid)
if old[0] >= 0:
pid = old[0]
parcel = PARCELS[pid]
exit_side = branch(state.cards[0], parcel)
target = 1+exit_side
if old[target] < 0:
tables[0] = -1
tables[target] = pid
paths[pid] += ("A:"+condition(state.cards[0], parcel)+"→"+
("左" if exit_side == 0 else "右"),)
moves.append((pid, "A", chr(65+target)))
else:
waits.append((pid, "この刻の初めは "+chr(65+target)+" が使用中"))
if queue:
if old[0] < 0:
pid = queue.pop(0)
tables[0] = pid
moves.append((pid, "入口", "A"))
else:
waits.append((queue[0], "この刻の初めは A が使用中"))
status = "fault" if faults else ("done" if len(deliveries) == 6 else
("timeout" if t >= 32 else "run"))
reason = ("誤配: 荷物 "+",".join(str(pid+1) for pid in faults)+" の命令列を確認。" if faults else
("全6便の仕分け達成。空箱・重複札も正当な配置です。" if status == "done" else
("32刻で停止。" if status == "timeout" else "一刻の移動を同時に反映しました。")))
return replace(state, t=t, next_id=next_id, queue=tuple(queue), tables=tuple(tables),
paths=tuple(paths), deliveries=tuple(deliveries), status=status,
reason=reason, moves=tuple(moves), waits=tuple(waits))
def parcel_conservation(state):
ids = list(state.queue)+[pid for pid in state.tables if pid >= 0]
ids += [delivery.parcel for delivery in state.deliveries]
ids += list(range(state.next_id, len(PARCELS)))
return sorted(ids) == list(range(len(PARCELS)))
def apply(state, action):
if action == "start":
return start(state)
if action == "tick":
return tick(state)
return state
class App:
def __init__(self):
self.reset()
def reset(self):
self.state = State()
self.cursor = 0
self.section = 0
self.locked = False
self.message = "ENTER START / SPACE ONE TICK"
def commit(self, action):
before = self.state
self.state = apply(self.state, action)
if self.state == before:
self.message = "NO CHANGE / CHECK MODE, CAP OR HISTORY LIMIT"
elif action == "undo":
self.message = "UNDO WHOLE STATE / START CAN ALSO BE UNDONE"
else:
self.message = "OLD OCCUPANCY ONLY / ONE TABLE PER PARCEL PER TICK"
def control(self, down):
down = set(down)
if self.locked:
if not down:
self.locked = False
return
if not down:
return
self.locked = True
if len(down) != 1:
self.message = "ONE KEY AT A TIME / RELEASE ALL"
return
key = next(iter(down))
if key == "R":
self.reset()
self.locked = True
return
if key == "RETURN":
self.commit("start")
elif key == "SPACE":
self.commit("tick")
def draw(self, p):
s = self.state
p.cls(1)
p.text(8, 8, "CLOCKWORK POST / STAGE 3 / ORIGINAL B", 10)
p.text(8, 22, "TICK "+str(s.t)+"/32 STATUS "+s.status.upper()+" DELIVERED "+str(len(s.deliveries))+"/6", 7)
p.text(8, 34, "TEST CARDS "+str(count_tests(s))+"/"+str(CAP)+" OLD-STATE SYNCHRONOUS TICKS", 6)
for i, parcel in enumerate(PARCELS):
p.text(8+i*65, 49, str(i+1)+":"+parcel.color+str(parcel.weight)+">"+parcel.destination, 8 if parcel.color == "R" else 12)
queue = " ".join(str(pid+1) for pid in s.queue) or "EMPTY"
p.text(8, 63, "QUEUE: "+queue+" NOT ARRIVED: "+str(6-s.next_id), 7)
points = ((200, 96), (100, 149), (300, 149))
for x in (100, 300):
p.line(200, 116, x, 137, 5)
for i, (x, y) in enumerate(points):
p.rect(x-49, y-12, 98, 39, 2)
p.rectb(x-49, y-12, 98, 39, 10 if self.section == 0 and self.cursor == i else 5)
p.text(x-40, y-5, chr(65+i)+": "+CARD_NAMES[s.cards[i]], 7)
pid = s.tables[i]
p.text(x-40, y+10, "EMPTY" if pid < 0 else "PARCEL "+str(pid+1), 6 if pid < 0 else 11)
for i, x in enumerate((50, 150, 250, 350)):
p.line(100 if i < 2 else 300, 176, x, 193, 5)
p.rect(x-37, 193, 74, 35, 4)
p.rectb(x-37, 193, 74, 35, 10 if self.section == 1 and self.cursor == i else 9)
p.text(x-31, 199, "BOX "+str(i+1)+" "+s.labels[i], 7)
received = [str(d.parcel+1)+("!" if not d.correct else "") for d in s.deliveries if d.box == i]
p.text(x-31, 213, " ".join(received) or "EMPTY", 11)
waits = ",".join(str(pid+1) for pid, _ in s.waits) or "NONE"
p.text(8, 237, "WAITED THIS TICK: "+waits+" / ALL 6 CONSERVED: "+str(parcel_conservation(s)), 6)
if s.deliveries:
d = s.deliveries[-1]
p.text(8, 249, "LAST: "+str(d.parcel+1)+" > A > "+("B" if d.box < 2 else "C")+" > BOX "+str(d.box+1)+" "+("OK" if d.correct else "WRONG / STOP"), 11 if d.correct else 8)
else:
p.text(8, 249, "ARRIVED NOW? READ ITS CARD NEXT TICK.", 6)
p.text(8, 264, self.message, 10)
p.text(8, 278, "ENTER START / SPACE ONE TICK / R RESET", 7)
p.text(8, 289, "FIXED SOLUTION / NO CONFIGURATION EDITING YET", 7)
p.text(8, 304, "COLOR R:LEFT B:RIGHT / WEIGHT 2:LEFT 1:RIGHT", 6)
p.text(8, 315, "LEFT/RIGHT: UNCONDITIONAL / R:RED B:BLUE", 6)
p.text(8, 326, "DEST RH:RED HEAVY RL:RED LIGHT BH/BL:BLUE", 6)
p.text(8, 344, "R RESET / RELEASE ALL KEYS BETWEEN ACTIONS / ESC QUIT", 5)
DEMO_KEYS = ('RETURN', 'SPACE', 'SPACE')
def main(headless=False, screenshot=None, demo=False):
from pathlib import Path
import pyxel as p
# Pyxelの初期化より先に、指定した保存先を絶対化する。
screenshot = str(Path(screenshot).resolve()) if screenshot else None
if screenshot:
Path(screenshot).parent.mkdir(parents=True, exist_ok=True)
app = App()
p.init(400, 360, title="Clockwork Post", fps=30, display_scale=2,
headless=headless)
keys = {name: getattr(p, "KEY_"+name) for name in
("UP", "DOWN", "LEFT", "RIGHT", "TAB", "Z", "P", "RETURN", "SPACE", "X", "R")}
limit = max(12, 2*len(DEMO_KEYS)+3) if demo else 12
def update():
if headless and p.frame_count >= limit:
if screenshot:
p.screenshot(screenshot, scale=2)
from dataclasses import asdict
import json
print(json.dumps({"state": asdict(app.state), "message": app.message},
ensure_ascii=True, sort_keys=True), flush=True)
p.quit()
return
if headless and demo:
index = p.frame_count//2
down = {DEMO_KEYS[index]} if p.frame_count%2 == 0 and index < len(DEMO_KEYS) else set()
else:
down = {key for key, value in keys.items() if p.btn(value)}
app.control(down)
p.run(update, lambda: app.draw(p))
return app
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("--headless-smoke", action="store_true")
parser.add_argument("--demo-checkpoint", action="store_true")
parser.add_argument("--screenshot")
args = parser.parse_args()
main(args.headless_smoke, args.screenshot, args.demo_checkpoint)カードと箱札を編集して試運転する
「編集中」と「実行中」の状態を分ける。
元のゲームの B「四つの小包」を、自分で解く段階です。最初のカードは3つの台ともすべて LEFT、4つの箱の札はすべて RL です。矢印キーで台を選び、Z でカードを順番に変えます。Tab で箱の札に切り替え、矢印キーで箱を選び、Z で RL→RH→BL→BH の順に変えます。編集しているときは、時刻も荷物も動きません。Enter で試運転に移ると、カードと札は固定され、Space で1刻ずつ進めます。
apply は、操作を受け取る窓口です。("card", 台の番号, 値) や ("label", 箱の番号, 値) を確かめ、編集中のときだけ新しい State を返します。実行中に札だけを書き換えると、同じ便が同じ命令を通っても、途中で結果が変わってしまいます。失敗や成功から直したいときは、この段階では R で最初からやり直します。
判定カード(COLOR・WEIGHT)は、元のゲームの B と同じく3枚までです。台も3つなので、B ではこの上限だけで受け付けられない組み合わせはありません。上限のチェックは start に残しておき、あとで A(1枚)や C(2枚)という別の状況を学ぶ準備にします。LEFT と RIGHT は枚数に数えません。同じ札を重ねたり、空の箱を作ったりすることも禁止せず、箱に着いたときに宛先と合っているかだけを、正しいかどうかの基準にします。
正解の例は、A=COLOR、B と C=WEIGHT、箱1〜4=RH・RL・BH・BL です。最初の状態のまま始めると、最初の赤軽は届きますが、青重の便2が5刻目にまちがった箱に届き、FAULT(失敗)になります。その刻に決まったほかの動きも反映してから、そのあとの Space では動かなくなります。失敗した便の番号・箱・道すじは State に残るので、どの条件を読みまちがえたのかを追えます。
下のコードは 04_mission.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
if state.status == "edit" and isinstance(action, tuple) and len(action) == 3:
kind, index, value = action実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 04_mission.py.venv/bin/python 04_mission.py- 編集中は時刻を進めない
- 始めたあとは、カードと札を変えられない
- まちがって届いた刻を確定してから、全体を止める
別の答えを試してみましょう。A=WEIGHT、B と C=COLOR、札=RH・BH・RL・BL でも、13刻で DONE になります。次に、コードの CAP を2に変えると、判定カードが3枚のまま Enter を押しても EDIT(編集中)のままです。試したら CAP=3 に戻してください。
このステップのコード全文(334行)
# MIT License
#
# Copyright (c) 2026
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
#
# Third-party exception: bitmap glyph data derived from Noto Sans CJK is licensed
# under the SIL Open Font License 1.1. See assets/NOTO-LICENSE.txt.
"""からくり郵便室・教材4/5。単独で起動できます。"""
from dataclasses import dataclass, replace
CARD_NAMES = ("COLOR", "WEIGHT", "LEFT", "RIGHT")
@dataclass(frozen=True)
class Parcel:
id: int
color: str
weight: int
destination: str
# 原作Bの6便。番号の内部値は0始まり、画面だけ1始まり。
PARCELS = tuple(Parcel(i, color, weight, color+("H" if weight == 2 else "L"))
for i, (color, weight) in enumerate(
(("R", 1), ("B", 2), ("R", 2), ("B", 1), ("B", 2), ("R", 1))))
def branch(card, parcel):
# 出口は左=0、右=1。台番号や箱番号とは区別する。
if card == 0:
return 0 if parcel.color == "R" else 1
if card == 1:
return 0 if parcel.weight == 2 else 1
return card-2
CAP = 3
LABELS = ("RL", "RH", "BL", "BH")
LABEL_NAMES = {"RL": "赤・軽", "RH": "赤・重", "BL": "青・軽", "BH": "青・重"}
@dataclass(frozen=True)
class Delivery:
parcel: int
box: int
tick: int
correct: bool
path: tuple
@dataclass(frozen=True)
class State:
cards: tuple = (2, 2, 2)
labels: tuple = ("RL",)*4
t: int = 0
next_id: int = 1
queue: tuple = (0,)
tables: tuple = (-1, -1, -1)
deliveries: tuple = ()
paths: tuple = ((),)*6
status: str = "edit"
reason: str = "カードと箱札を編集して試運転。"
moves: tuple = ()
waits: tuple = ()
def condition(card, parcel):
if card == 0:
return "赤" if parcel.color == "R" else "青"
if card == 1:
return "重さ"+str(parcel.weight)
return "そのまま"
def count_tests(state):
return sum(card < 2 for card in state.cards)
def start(state):
if state.status != "edit" or count_tests(state) > CAP:
return state
return replace(state, status="run", reason="試運転中。Spaceで一刻。",
moves=(), waits=())
def tick(state):
if state.status != "run":
return state
t = state.t+1
queue = list(state.queue)
next_id = state.next_id
if next_id < len(PARCELS) and next_id <= t:
queue.append(next_id)
next_id += 1
# oldは読み取り専用。tablesだけを書き換えて最後に同時確定する。
old = state.tables
tables = list(old)
paths = list(state.paths)
deliveries = list(state.deliveries)
moves, waits, faults = [], [], []
for table in (1, 2):
pid = old[table]
if pid < 0:
continue
parcel = PARCELS[pid]
exit_side = branch(state.cards[table], parcel)
box = (table-1)*2+exit_side
path = paths[pid]+(chr(65+table)+":"+condition(state.cards[table], parcel)+"→"+
("左" if exit_side == 0 else "右"),
"箱"+str(box+1)+":"+LABEL_NAMES[state.labels[box]])
paths[pid] = path
correct = state.labels[box] == parcel.destination
deliveries.append(Delivery(pid, box, t, correct, path))
tables[table] = -1
moves.append((pid, chr(65+table), "箱"+str(box+1)))
if not correct:
faults.append(pid)
if old[0] >= 0:
pid = old[0]
parcel = PARCELS[pid]
exit_side = branch(state.cards[0], parcel)
target = 1+exit_side
if old[target] < 0:
tables[0] = -1
tables[target] = pid
paths[pid] += ("A:"+condition(state.cards[0], parcel)+"→"+
("左" if exit_side == 0 else "右"),)
moves.append((pid, "A", chr(65+target)))
else:
waits.append((pid, "この刻の初めは "+chr(65+target)+" が使用中"))
if queue:
if old[0] < 0:
pid = queue.pop(0)
tables[0] = pid
moves.append((pid, "入口", "A"))
else:
waits.append((queue[0], "この刻の初めは A が使用中"))
status = "fault" if faults else ("done" if len(deliveries) == 6 else
("timeout" if t >= 32 else "run"))
reason = ("誤配: 荷物 "+",".join(str(pid+1) for pid in faults)+" の命令列を確認。" if faults else
("全6便の仕分け達成。空箱・重複札も正当な配置です。" if status == "done" else
("32刻で停止。" if status == "timeout" else "一刻の移動を同時に反映しました。")))
return replace(state, t=t, next_id=next_id, queue=tuple(queue), tables=tuple(tables),
paths=tuple(paths), deliveries=tuple(deliveries), status=status,
reason=reason, moves=tuple(moves), waits=tuple(waits))
def parcel_conservation(state):
ids = list(state.queue)+[pid for pid in state.tables if pid >= 0]
ids += [delivery.parcel for delivery in state.deliveries]
ids += list(range(state.next_id, len(PARCELS)))
return sorted(ids) == list(range(len(PARCELS)))
def apply(state, action):
if action == "start":
return start(state)
if action == "tick":
return tick(state)
if state.status == "edit" and isinstance(action, tuple) and len(action) == 3:
kind, index, value = action
if type(index) is not int:
return state
if kind == "card" and 0 <= index < 3 and type(value) is int and 0 <= value < 4:
return replace(state, cards=tuple(value if i == index else card
for i, card in enumerate(state.cards)))
if kind == "label" and 0 <= index < 4 and value in LABELS:
return replace(state, labels=tuple(value if i == index else label
for i, label in enumerate(state.labels)))
return state
class App:
def __init__(self):
self.reset()
def reset(self):
self.state = State()
self.cursor = 0
self.section = 0
self.locked = False
self.message = "EDIT CARDS AND LABELS, THEN ENTER"
def commit(self, action):
before = self.state
self.state = apply(self.state, action)
if self.state == before:
self.message = "NO CHANGE / CHECK MODE, CAP OR HISTORY LIMIT"
elif action == "undo":
self.message = "UNDO WHOLE STATE / START CAN ALSO BE UNDONE"
else:
self.message = "OLD OCCUPANCY ONLY / ONE TABLE PER PARCEL PER TICK"
def control(self, down):
down = set(down)
if self.locked:
if not down:
self.locked = False
return
if not down:
return
self.locked = True
if len(down) != 1:
self.message = "ONE KEY AT A TIME / RELEASE ALL"
return
key = next(iter(down))
if key == "R":
self.reset()
self.locked = True
return
if key == "RETURN":
self.commit("start")
elif key == "SPACE":
self.commit("tick")
elif self.state.status == "edit":
if key == "TAB":
self.section = 1-self.section
self.cursor = 0
elif key in ("LEFT", "RIGHT", "UP", "DOWN"):
step = -1 if key in ("LEFT", "UP") else 1
self.cursor = (self.cursor+step)%(3 if self.section == 0 else 4)
elif key == "Z":
if self.section == 0:
self.commit(("card", self.cursor, (self.state.cards[self.cursor]+1)%4))
else:
value = LABELS[(LABELS.index(self.state.labels[self.cursor])+1)%4]
self.commit(("label", self.cursor, value))
def draw(self, p):
s = self.state
p.cls(1)
p.text(8, 8, "CLOCKWORK POST / STAGE 4 / ORIGINAL B", 10)
p.text(8, 22, "TICK "+str(s.t)+"/32 STATUS "+s.status.upper()+" DELIVERED "+str(len(s.deliveries))+"/6", 7)
p.text(8, 34, "TEST CARDS "+str(count_tests(s))+"/"+str(CAP)+" OLD-STATE SYNCHRONOUS TICKS", 6)
for i, parcel in enumerate(PARCELS):
p.text(8+i*65, 49, str(i+1)+":"+parcel.color+str(parcel.weight)+">"+parcel.destination, 8 if parcel.color == "R" else 12)
queue = " ".join(str(pid+1) for pid in s.queue) or "EMPTY"
p.text(8, 63, "QUEUE: "+queue+" NOT ARRIVED: "+str(6-s.next_id), 7)
points = ((200, 96), (100, 149), (300, 149))
for x in (100, 300):
p.line(200, 116, x, 137, 5)
for i, (x, y) in enumerate(points):
p.rect(x-49, y-12, 98, 39, 2)
p.rectb(x-49, y-12, 98, 39, 10 if self.section == 0 and self.cursor == i else 5)
p.text(x-40, y-5, chr(65+i)+": "+CARD_NAMES[s.cards[i]], 7)
pid = s.tables[i]
p.text(x-40, y+10, "EMPTY" if pid < 0 else "PARCEL "+str(pid+1), 6 if pid < 0 else 11)
for i, x in enumerate((50, 150, 250, 350)):
p.line(100 if i < 2 else 300, 176, x, 193, 5)
p.rect(x-37, 193, 74, 35, 4)
p.rectb(x-37, 193, 74, 35, 10 if self.section == 1 and self.cursor == i else 9)
p.text(x-31, 199, "BOX "+str(i+1)+" "+s.labels[i], 7)
received = [str(d.parcel+1)+("!" if not d.correct else "") for d in s.deliveries if d.box == i]
p.text(x-31, 213, " ".join(received) or "EMPTY", 11)
waits = ",".join(str(pid+1) for pid, _ in s.waits) or "NONE"
p.text(8, 237, "WAITED THIS TICK: "+waits+" / ALL 6 CONSERVED: "+str(parcel_conservation(s)), 6)
if s.deliveries:
d = s.deliveries[-1]
p.text(8, 249, "LAST: "+str(d.parcel+1)+" > A > "+("B" if d.box < 2 else "C")+" > BOX "+str(d.box+1)+" "+("OK" if d.correct else "WRONG / STOP"), 11 if d.correct else 8)
else:
p.text(8, 249, "ARRIVED NOW? READ ITS CARD NEXT TICK.", 6)
p.text(8, 264, self.message, 10)
p.text(8, 278, "ARROWS SELECT / TAB TABLE-BOX / Z CYCLE / ENTER START", 7)
p.text(8, 289, "SPACE TICK / R RESET / EDIT ONLY IN EDIT MODE", 7)
p.text(8, 304, "COLOR R:LEFT B:RIGHT / WEIGHT 2:LEFT 1:RIGHT", 6)
p.text(8, 315, "LEFT/RIGHT: UNCONDITIONAL / R:RED B:BLUE", 6)
p.text(8, 326, "DEST RH:RED HEAVY RL:RED LIGHT BH/BL:BLUE", 6)
p.text(8, 344, "R RESET / RELEASE ALL KEYS BETWEEN ACTIONS / ESC QUIT", 5)
DEMO_KEYS = ('Z', 'Z', 'RIGHT', 'Z', 'Z', 'Z', 'RIGHT', 'Z', 'Z', 'Z', 'TAB', 'Z', 'RIGHT', 'RIGHT', 'Z', 'Z', 'Z', 'RIGHT', 'Z', 'Z', 'RETURN', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE')
def main(headless=False, screenshot=None, demo=False):
from pathlib import Path
import pyxel as p
# Pyxelの初期化より先に、指定した保存先を絶対化する。
screenshot = str(Path(screenshot).resolve()) if screenshot else None
if screenshot:
Path(screenshot).parent.mkdir(parents=True, exist_ok=True)
app = App()
p.init(400, 360, title="Clockwork Post", fps=30, display_scale=2,
headless=headless)
keys = {name: getattr(p, "KEY_"+name) for name in
("UP", "DOWN", "LEFT", "RIGHT", "TAB", "Z", "P", "RETURN", "SPACE", "X", "R")}
limit = max(12, 2*len(DEMO_KEYS)+3) if demo else 12
def update():
if headless and p.frame_count >= limit:
if screenshot:
p.screenshot(screenshot, scale=2)
from dataclasses import asdict
import json
print(json.dumps({"state": asdict(app.state), "message": app.message},
ensure_ascii=True, sort_keys=True), flush=True)
p.quit()
return
if headless and demo:
index = p.frame_count//2
down = {DEMO_KEYS[index]} if p.frame_count%2 == 0 and index < len(DEMO_KEYS) else set()
else:
down = {key for key, value in keys.items() if p.btn(value)}
app.control(down)
p.run(update, lambda: app.draw(p))
return app
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("--headless-smoke", action="store_true")
parser.add_argument("--demo-checkpoint", action="store_true")
parser.add_argument("--screenshot")
args = parser.parse_args()
main(args.headless_smoke, args.screenshot, args.demo_checkpoint)失敗も成功も丸ごと戻せる完成版
書き換えられない状態の履歴と、Undo。
X キーで1手戻す機能を加えます。Timeline は、今の State、過去の State のタプル past、受け付けた操作の記録 actions を持ちます。編集・開始・1刻進める、で状態が本当に変わったときだけ、古い State を past に積みます。同じ札を指定し直す操作や、始める前の Space などでは、履歴を増やしません。
Undo は、past の最後を今の state に戻し、その最後を取り除きます。時計だけでなく、入れた便の数・順番待ちの列・3つの台・カード・札・配達の結果・すべての便の道すじ・状態・前の刻の動きや待ちも、一緒に元に戻します。タプルと書き換えられない dataclass を使っているので、保存した過去があとから書き換わることはありません。選んでいる枠や操作の案内は画面の状態なので、ゲームの履歴には入れません。
DONE から X を1回押すと、時刻12・配達5件・便6が B にいる状態に戻ります。FAULT からも戻せますが、まだ RUN(実行中)なので、カードは編集できません。試運転の開始まで取り消して EDIT に戻ると、直せます。たとえば最初の配置でまちがって届いた場合は、5刻分と開始の合計6回 X を押すと EDIT に戻り、カードや札を直して再開できます。R は、編集した内容と履歴も含めて最初に戻します。
元のゲームと同じく、記録した操作の数が512に達すると新しい操作を止めますが、Undo は使い続けられます。Undo も actions に記録するので、戻しただけでは操作の枠は回復せず、R でやり直します。最後の教材は、元のゲームの B のふつうの「同時に動く」ルールを使っています。A と C の選択、同じ刻に空いた台を使う比較モード、連続運転、予告、日本語フォント、再現記録の保存は省きました。学び終わったら、元のゲームの forecast・act・Controller へ進みましょう。
下のコードは 05_undo.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
state = apply(timeline.state, action)
if state == timeline.state:
return timeline
return Timeline(state, timeline.past+(timeline.state,), timeline.actions+(action,))実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 05_undo.py.venv/bin/python 05_undo.py- DONE や FAULT から戻せる
- 受け付けなかった操作では、履歴を増やさない
- 開始を取り消すと、編集に戻れる
- R キーで履歴と操作の数も最初に戻る
State 全体を保存し、戻した直後も「どの便も1回ずつある」ことを確かめてください。最初の配置で5刻目のまちがいを起こし、X で4刻に戻ります。まだ Z で編集できないことを確かめましょう。さらに X を5回押して EDIT に戻り、正解の配置に変えて13刻まで進めます。
このステップのコード全文(361行)
# MIT License
#
# Copyright (c) 2026
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
#
# Third-party exception: bitmap glyph data derived from Noto Sans CJK is licensed
# under the SIL Open Font License 1.1. See assets/NOTO-LICENSE.txt.
"""からくり郵便室・教材5/5。単独で起動できます。"""
from dataclasses import dataclass, replace
CARD_NAMES = ("COLOR", "WEIGHT", "LEFT", "RIGHT")
@dataclass(frozen=True)
class Parcel:
id: int
color: str
weight: int
destination: str
# 原作Bの6便。番号の内部値は0始まり、画面だけ1始まり。
PARCELS = tuple(Parcel(i, color, weight, color+("H" if weight == 2 else "L"))
for i, (color, weight) in enumerate(
(("R", 1), ("B", 2), ("R", 2), ("B", 1), ("B", 2), ("R", 1))))
def branch(card, parcel):
# 出口は左=0、右=1。台番号や箱番号とは区別する。
if card == 0:
return 0 if parcel.color == "R" else 1
if card == 1:
return 0 if parcel.weight == 2 else 1
return card-2
CAP = 3
LABELS = ("RL", "RH", "BL", "BH")
LABEL_NAMES = {"RL": "赤・軽", "RH": "赤・重", "BL": "青・軽", "BH": "青・重"}
@dataclass(frozen=True)
class Delivery:
parcel: int
box: int
tick: int
correct: bool
path: tuple
@dataclass(frozen=True)
class State:
cards: tuple = (2, 2, 2)
labels: tuple = ("RL",)*4
t: int = 0
next_id: int = 1
queue: tuple = (0,)
tables: tuple = (-1, -1, -1)
deliveries: tuple = ()
paths: tuple = ((),)*6
status: str = "edit"
reason: str = "カードと箱札を編集して試運転。"
moves: tuple = ()
waits: tuple = ()
def condition(card, parcel):
if card == 0:
return "赤" if parcel.color == "R" else "青"
if card == 1:
return "重さ"+str(parcel.weight)
return "そのまま"
def count_tests(state):
return sum(card < 2 for card in state.cards)
def start(state):
if state.status != "edit" or count_tests(state) > CAP:
return state
return replace(state, status="run", reason="試運転中。Spaceで一刻。",
moves=(), waits=())
def tick(state):
if state.status != "run":
return state
t = state.t+1
queue = list(state.queue)
next_id = state.next_id
if next_id < len(PARCELS) and next_id <= t:
queue.append(next_id)
next_id += 1
# oldは読み取り専用。tablesだけを書き換えて最後に同時確定する。
old = state.tables
tables = list(old)
paths = list(state.paths)
deliveries = list(state.deliveries)
moves, waits, faults = [], [], []
for table in (1, 2):
pid = old[table]
if pid < 0:
continue
parcel = PARCELS[pid]
exit_side = branch(state.cards[table], parcel)
box = (table-1)*2+exit_side
path = paths[pid]+(chr(65+table)+":"+condition(state.cards[table], parcel)+"→"+
("左" if exit_side == 0 else "右"),
"箱"+str(box+1)+":"+LABEL_NAMES[state.labels[box]])
paths[pid] = path
correct = state.labels[box] == parcel.destination
deliveries.append(Delivery(pid, box, t, correct, path))
tables[table] = -1
moves.append((pid, chr(65+table), "箱"+str(box+1)))
if not correct:
faults.append(pid)
if old[0] >= 0:
pid = old[0]
parcel = PARCELS[pid]
exit_side = branch(state.cards[0], parcel)
target = 1+exit_side
if old[target] < 0:
tables[0] = -1
tables[target] = pid
paths[pid] += ("A:"+condition(state.cards[0], parcel)+"→"+
("左" if exit_side == 0 else "右"),)
moves.append((pid, "A", chr(65+target)))
else:
waits.append((pid, "この刻の初めは "+chr(65+target)+" が使用中"))
if queue:
if old[0] < 0:
pid = queue.pop(0)
tables[0] = pid
moves.append((pid, "入口", "A"))
else:
waits.append((queue[0], "この刻の初めは A が使用中"))
status = "fault" if faults else ("done" if len(deliveries) == 6 else
("timeout" if t >= 32 else "run"))
reason = ("誤配: 荷物 "+",".join(str(pid+1) for pid in faults)+" の命令列を確認。" if faults else
("全6便の仕分け達成。空箱・重複札も正当な配置です。" if status == "done" else
("32刻で停止。" if status == "timeout" else "一刻の移動を同時に反映しました。")))
return replace(state, t=t, next_id=next_id, queue=tuple(queue), tables=tuple(tables),
paths=tuple(paths), deliveries=tuple(deliveries), status=status,
reason=reason, moves=tuple(moves), waits=tuple(waits))
def parcel_conservation(state):
ids = list(state.queue)+[pid for pid in state.tables if pid >= 0]
ids += [delivery.parcel for delivery in state.deliveries]
ids += list(range(state.next_id, len(PARCELS)))
return sorted(ids) == list(range(len(PARCELS)))
def apply(state, action):
if action == "start":
return start(state)
if action == "tick":
return tick(state)
if state.status == "edit" and isinstance(action, tuple) and len(action) == 3:
kind, index, value = action
if type(index) is not int:
return state
if kind == "card" and 0 <= index < 3 and type(value) is int and 0 <= value < 4:
return replace(state, cards=tuple(value if i == index else card
for i, card in enumerate(state.cards)))
if kind == "label" and 0 <= index < 4 and value in LABELS:
return replace(state, labels=tuple(value if i == index else label
for i, label in enumerate(state.labels)))
return state
@dataclass(frozen=True)
class Timeline:
state: State = State()
past: tuple = ()
actions: tuple = ()
def act(timeline, action):
if action == "undo":
if not timeline.past:
return timeline
return Timeline(timeline.past[-1], timeline.past[:-1], timeline.actions+(action,))
if len(timeline.actions) >= 512:
return timeline
state = apply(timeline.state, action)
if state == timeline.state:
return timeline
return Timeline(state, timeline.past+(timeline.state,), timeline.actions+(action,))
class App:
def __init__(self):
self.reset()
@property
def state(self):
return self.timeline.state
def reset(self):
self.timeline = Timeline()
self.cursor = 0
self.section = 0
self.locked = False
self.message = "EDIT CARDS AND LABELS, THEN ENTER"
def commit(self, action):
before = self.state
self.timeline = act(self.timeline, action)
if self.state == before:
self.message = "NO CHANGE / CHECK MODE, CAP OR HISTORY LIMIT"
elif action == "undo":
self.message = "UNDO WHOLE STATE / START CAN ALSO BE UNDONE"
else:
self.message = "OLD OCCUPANCY ONLY / ONE TABLE PER PARCEL PER TICK"
def control(self, down):
down = set(down)
if self.locked:
if not down:
self.locked = False
return
if not down:
return
self.locked = True
if len(down) != 1:
self.message = "ONE KEY AT A TIME / RELEASE ALL"
return
key = next(iter(down))
if key == "R":
self.reset()
self.locked = True
return
if key == "X":
self.commit("undo")
return
if key == "RETURN":
self.commit("start")
elif key == "SPACE":
self.commit("tick")
elif self.state.status == "edit":
if key == "TAB":
self.section = 1-self.section
self.cursor = 0
elif key in ("LEFT", "RIGHT", "UP", "DOWN"):
step = -1 if key in ("LEFT", "UP") else 1
self.cursor = (self.cursor+step)%(3 if self.section == 0 else 4)
elif key == "Z":
if self.section == 0:
self.commit(("card", self.cursor, (self.state.cards[self.cursor]+1)%4))
else:
value = LABELS[(LABELS.index(self.state.labels[self.cursor])+1)%4]
self.commit(("label", self.cursor, value))
def draw(self, p):
s = self.state
p.cls(1)
p.text(8, 8, "CLOCKWORK POST / STAGE 5 / ORIGINAL B", 10)
p.text(8, 22, "TICK "+str(s.t)+"/32 STATUS "+s.status.upper()+" DELIVERED "+str(len(s.deliveries))+"/6", 7)
p.text(8, 34, "TEST CARDS "+str(count_tests(s))+"/"+str(CAP)+" OLD-STATE SYNCHRONOUS TICKS", 6)
for i, parcel in enumerate(PARCELS):
p.text(8+i*65, 49, str(i+1)+":"+parcel.color+str(parcel.weight)+">"+parcel.destination, 8 if parcel.color == "R" else 12)
queue = " ".join(str(pid+1) for pid in s.queue) or "EMPTY"
p.text(8, 63, "QUEUE: "+queue+" NOT ARRIVED: "+str(6-s.next_id), 7)
points = ((200, 96), (100, 149), (300, 149))
for x in (100, 300):
p.line(200, 116, x, 137, 5)
for i, (x, y) in enumerate(points):
p.rect(x-49, y-12, 98, 39, 2)
p.rectb(x-49, y-12, 98, 39, 10 if self.section == 0 and self.cursor == i else 5)
p.text(x-40, y-5, chr(65+i)+": "+CARD_NAMES[s.cards[i]], 7)
pid = s.tables[i]
p.text(x-40, y+10, "EMPTY" if pid < 0 else "PARCEL "+str(pid+1), 6 if pid < 0 else 11)
for i, x in enumerate((50, 150, 250, 350)):
p.line(100 if i < 2 else 300, 176, x, 193, 5)
p.rect(x-37, 193, 74, 35, 4)
p.rectb(x-37, 193, 74, 35, 10 if self.section == 1 and self.cursor == i else 9)
p.text(x-31, 199, "BOX "+str(i+1)+" "+s.labels[i], 7)
received = [str(d.parcel+1)+("!" if not d.correct else "") for d in s.deliveries if d.box == i]
p.text(x-31, 213, " ".join(received) or "EMPTY", 11)
waits = ",".join(str(pid+1) for pid, _ in s.waits) or "NONE"
p.text(8, 237, "WAITED THIS TICK: "+waits+" / ALL 6 CONSERVED: "+str(parcel_conservation(s)), 6)
if s.deliveries:
d = s.deliveries[-1]
p.text(8, 249, "LAST: "+str(d.parcel+1)+" > A > "+("B" if d.box < 2 else "C")+" > BOX "+str(d.box+1)+" "+("OK" if d.correct else "WRONG / STOP"), 11 if d.correct else 8)
else:
p.text(8, 249, "ARRIVED NOW? READ ITS CARD NEXT TICK.", 6)
p.text(8, 264, self.message, 10)
p.text(8, 278, "ARROWS SELECT / TAB TABLE-BOX / Z CYCLE / ENTER START", 7)
p.text(8, 289, "SPACE TICK / X UNDO / EDIT ONLY IN EDIT MODE", 7)
p.text(8, 304, "COLOR R:LEFT B:RIGHT / WEIGHT 2:LEFT 1:RIGHT", 6)
p.text(8, 315, "LEFT/RIGHT: UNCONDITIONAL / R:RED B:BLUE", 6)
p.text(8, 326, "DEST RH:RED HEAVY RL:RED LIGHT BH/BL:BLUE", 6)
p.text(8, 344, "R RESET / RELEASE ALL KEYS BETWEEN ACTIONS / ESC QUIT", 5)
DEMO_KEYS = ('Z', 'Z', 'RIGHT', 'Z', 'Z', 'Z', 'RIGHT', 'Z', 'Z', 'Z', 'TAB', 'Z', 'RIGHT', 'RIGHT', 'Z', 'Z', 'Z', 'RIGHT', 'Z', 'Z', 'RETURN', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'X')
def main(headless=False, screenshot=None, demo=False):
from pathlib import Path
import pyxel as p
# Pyxelの初期化より先に、指定した保存先を絶対化する。
screenshot = str(Path(screenshot).resolve()) if screenshot else None
if screenshot:
Path(screenshot).parent.mkdir(parents=True, exist_ok=True)
app = App()
p.init(400, 360, title="Clockwork Post", fps=30, display_scale=2,
headless=headless)
keys = {name: getattr(p, "KEY_"+name) for name in
("UP", "DOWN", "LEFT", "RIGHT", "TAB", "Z", "P", "RETURN", "SPACE", "X", "R")}
limit = max(12, 2*len(DEMO_KEYS)+3) if demo else 12
def update():
if headless and p.frame_count >= limit:
if screenshot:
p.screenshot(screenshot, scale=2)
from dataclasses import asdict
import json
print(json.dumps({"state": asdict(app.state), "message": app.message},
ensure_ascii=True, sort_keys=True), flush=True)
p.quit()
return
if headless and demo:
index = p.frame_count//2
down = {DEMO_KEYS[index]} if p.frame_count%2 == 0 and index < len(DEMO_KEYS) else set()
else:
down = {key for key, value in keys.items() if p.btn(value)}
app.control(down)
p.run(update, lambda: app.draw(p))
return app
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("--headless-smoke", action="store_true")
parser.add_argument("--demo-checkpoint", action="store_true")
parser.add_argument("--screenshot")
args = parser.parse_args()
main(args.headless_smoke, args.screenshot, args.demo_checkpoint)仕組みがわかったら、元の完成版を読んでみよう。
最後の教材の範囲は、元のゲームの B のふつうのモードです。決まった形の二分木で「入口→A→B か C→4つの箱」と進み、6便は時刻0〜5に入ります。前の刻の状態だけを見て、全部が同時に動きます。台に着いた便の判定は次の刻です。COLOR と WEIGHT は合わせて3枚まで、素通りと同じ札の重ねは使えます。まちがって届いた刻を確定して止まり、6便すべてが正しく届けば成功、32刻で止まります。
ここまでに作ったのは、元のゲームの仕組みだけを取り出して作り直した「簡略版」です。元のゲームのソースは手を加えずに、別のZIP(元の完成版ソース)に入れてあります。
| 比べる点 | 5ステップの教材 | 元の完成版 |
|---|---|---|
| 状況 | 03はBの正解に固定。04と05は元のゲームのBの6便で、すべてのカードと札を編集できる。 | A・B・Cの3つの状況。判定カードの上限は1・3・2。 |
| 段階ごとの簡略化 | 01は台1つだけ、02は時間のない道すじの模型。03からがふつうの「同時に動く」ルール。 | 最初から順番待ちの列、3つの台、6便、まちがいの判定、便がなくならない確認がある。 |
| 台のふさがり方と比較 | ふつうのモードだけ。比較版の「空いているか」の判定には変えない。 | Bキーで、同じ刻に空いた台も使う比較版を選べる。 |
| 表示と操作 | 英語(ASCII)の画面。1段目から段階ごとのキー。XのUndoは05だけ。自動の連続運転・予告・表紙は省略。 | 日本語の文字、連続運転、予告、説明と表紙、状況の切り替えなど。 |
| 確認した範囲 | 04と05で、元のゲームのBの16384通りのすべての配置について、始める前・始めたあと・すべての刻の状態を比べた。表示用の reason だけは比べていない。 | 比べたのは、純粋なモデルのBのふつうのモード。元の Controller や描画、AとC、比較モード全体が同じだとは言っていない。 |
| 外部の素材と記録 | それぞれの .py だけで動く。Noto由来の文字データなし。ふつうは保存も音もなし。指定したときだけ確認用のPNGを出す。 | Noto由来の文字データ、再現記録・画像・映像の補助機能がある。 |
完成版のコードで、次に読むところ
model.pyのbranch・condition・make_levelで、A と C へ広げる方法model.pyのtickのold(前の状態)の使い方と、flow(比較版)との違いmodel.pyのact・Timeline・parcel_conservation(操作・履歴・便がなくならない確認)model.pyのforecastとsimulate(予告と、最後まで動かしてみる処理)controller.pyのfeedとautomatic_tick、app.pyのdrawの予告と通り道の表示
最初は、数字かルールを1つだけ変えてみましょう。遊ぶ前に「どう変わるか」を予想しておくと、仕組みがよくわかります。慣れてきたら、見た目や音も足してみてください。元のファイルは残しておき、コピーした別のフォルダーで改造すると、いつでも元に戻せます。
教材をダウンロードする
無料で、登録なしでダウンロードできます。ZIPファイルは必ず展開してから、中のファイルを実行してください。元の完成版は、同じZIPに入っている assets フォルダー(画像などの素材)も一緒に使います。
コードと図形はMITライセンス、元の完成版に含まれる日本語の文字データ(Noto由来)はSIL OFL 1.1です。配ったり改造したりするときは、ZIPに入っているLICENSEなどの権利表示を残してください。出典・権利について
検証について
どこまで動作を確認したかを見る
2026年10月5日以降、Linux(Python 3.12・Pyxel 2.9.9)で、教材のコードとゲームのルールを自動テストしています。全500ステップの教材が、実際のPyxelで起動して画面を描けることも確かめました。元の完成版も、自動で遊ぶデモが起動して画面を描き、正常に終わることを確認しています。
この確認は、画面を表示しない自動テストで行ったものです。Windows・Macの実際のパソコンでの操作や、音が出るかどうかはまだ確認していません。人がすべての操作を試したわけでもありません。ブラウザー版の確認範囲は確認状況をご覧ください。
うまく動かないときは:起動やエラーの対処法 / 質問用のメモを作る
