完成サンプルをブラウザーで遊ぶ
この講座のもとになった完成版のゲームを、ブラウザーでそのまま遊べます。キーボードで操作するので、PCで開いてください(スマホのタッチ操作には対応していません)。初めて開くときは、読み込みに少し時間がかかります。
完成版の主な操作:上下で札を選んでZで下書きに置く、左右で位置を選んでSpaceで外す、Enterで送る・確認、Cで全文を確認、Xで1手戻す
ボタンを押すと、ゲームの読み込みが始まります。
画面に「CLICK TO START」と出たらクリックします。続けてゲーム画面を一度クリックすると、キー操作が効くようになります。ゲームを終えるときは、枠の外にある「閉じる」ボタンを使ってください(Escキーの働きはゲームごとに違います)。ゲーム内のセーブは、閉じる・ページを移る・再読み込みすると消えます。
うまく表示されないとき
ゲームを動かすためのプログラム(Pyxel 2.9.9)は、外部の配信サービス(jsDelivr)から読み込みます。表示されないときは「最初から再読込」を押すか、完成版のソースをダウンロードしてPCで実行してください。ブラウザー版の録画機能は止めています。ブラウザー版で確認できている範囲は検証状況にまとめています。
意味の集合を重ねて、候補を絞る。
いきなり完成版のコードを全部読む必要はありません。小さなゲームにルールを1つずつ足しながら、5つのステップで作っていきます。どのステップのファイルもそれだけで動くので、動きを確かめてから次へ進みましょう。
- 必要な環境
- Python 3.11以上 / Pyxel 2.9.9
- 知っておくとよいこと
- Python 3.11以上、ターミナルと仮想環境の基本 / if・for・関数・tuple・set の基本 / dataclass と replace は本文で説明します
- 教材に入っているもの
- ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
- 完成した教材の操作
- 1〜8:札 / 左右:外す位置 / Space:外す / Enter:送る・確認 / C:全文を確認(04から) / X:Undo(05) / R:最初に戻す / Esc:終了
- 難易度の目安
- 初級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
- 学習順に迷ったら
- おすすめの学ぶ順番を見る
コードを書き換えたら、ファイルを保存し、動いているゲームを閉じてから、もう一度同じ.pyを実行します。ゲーム中にRキーなどで「やり直し」をしても、書き換えたコードは読み込まれません。
教材のゲーム画面は、日本語フォントを用意しなくても動くように英語で表示しています。ファイルには動作確認用のコードも入っていますが、最初は Game / State・update・draw の3つに注目すれば十分です。わからない言葉は用語集で調べられます。class・self・dataclass はPythonの状態、キー入力と時間の扱いは入力・更新・描画、マス目とピクセルの違いは画面とマップの座標のページで詳しく説明しています。
同じ候補だけを残す
集合の「共通部分」と、相手ごとの意味の表。
8枚の札は、言葉を読み取って理解する命令ではなく、相手が公開している表で決められた「用事の番号(ID)の集合」です。たとえば、工房の「灯」は {1,2}、「磨」は {1,3} なので、両方に入っている1だけが残ります。interpret はすべての候補から始め、intersection_update で共通部分だけを残していきます。何も選んでいなければ候補は全部、意味が空の札をまぜると候補は0になります。札の順番に意味はありません。この段階だけは、札を何枚でも選べて、その場で候補が変わる練習用の実験台です。
下のコードは 01_sets.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
candidates = set(range(len(person.errands)))
for letter in letters:
candidates.intersection_update(person.meanings[letter])
return tuple(sorted(candidates))実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 01_sets.py.venv/bin/python 01_sets.py- 候補の表と用事の番号を対応させられる
- 共通部分と、合わせた集合(和集合)の違いがわかる
- 「何も選んでいない」と「候補がない」を区別できる
union(和集合)を使うと、候補が増えてしまいます。また、札の順番を文の文法として読むのも、元のゲームとは別のルールです。R のあと 3→1 と 1→3 を比べて、並び順が違っても候補が同じ1になることを確かめましょう。何も選んでいないときに、4つの候補が全部見える理由も説明してみてください。
このステップのコード全文(302行)
# 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.
# 教材にはNoto由来字形は含めません。描画はPyxel標準ASCIIと図形です。
"""伝言は三文字・教材 1/5。原作Bを段階的に学ぶ独立プログラム。"""
from dataclasses import dataclass, replace, asdict
@dataclass(frozen=True)
class Recipient:
name: str
errands: tuple
meanings: tuple
glosses: tuple
@dataclass(frozen=True)
class Level:
id: str
title: str
letters: tuple
people: tuple
targets: tuple
hints: tuple
WORKSHOP = Recipient(
'工房の係',
('灯を磨く / 東机', '灯を運ぶ / 西机', '鈴を磨く / 東机', '鈴を運ぶ / 西机'),
((0, 1), (0, 2), (0, 2), (), (0, 1, 2, 3), (), (1, 3), (2, 3)),
('灯の用事', '東机の印', '磨く用事', '該当なし', '全て机の用事', '該当なし', '運ぶ用事', '鈴の用事'),
)
RECEPTION = Recipient(
'受付の係',
('青い封を窓へ', '赤い封を机へ', '青い箱を机へ', '赤い箱を窓へ'),
((), (0, 2), (), (2, 3), (1, 2), (0, 3), (0, 1, 2, 3), ()),
('該当なし', '荷物が青色', '該当なし', '箱の用事', '机へ届ける', '窓へ届ける', '全て運ぶ用事', '該当なし'),
)
LEVEL = Level('067-B-g1', '青の意味が違う', tuple('灯青磨箱机窓運鈴'),
(WORKSHOP, RECEPTION), (0, 2),
('青は工房では東机、受付では色。', '送った文は、前の相手の意味で保存。'))
LETTER_NAMES = ('LAMP', 'BLUE', 'POLISH', 'BOX', 'DESK', 'WINDOW', 'CARRY', 'BELL')
PERSON_NAMES = ('WORKSHOP', 'RECEPTION')
ERRAND_NAMES = (
('Polish lamp / EAST desk', 'Carry lamp / WEST desk',
'Polish bell / EAST desk', 'Carry bell / WEST desk'),
('Blue envelope / WINDOW', 'Red envelope / DESK',
'Blue box / DESK', 'Red box / WINDOW'),
)
GLOSS_NAMES = (
('Lamp errands', 'EAST desk mark', 'Polish errands', 'No matching errand',
'All desk errands', 'No matching errand', 'Carry errands', 'Bell errands'),
('No matching errand', 'BLUE color', 'No matching errand', 'Box errands',
'To DESK', 'To WINDOW', 'All delivery errands', 'No matching errand'),
)
def interpret(person, letters):
"""各札が指す集合の積集合。順番は問わない。空なら全候補。"""
candidates = set(range(len(person.errands)))
for letter in letters:
candidates.intersection_update(person.meanings[letter])
return tuple(sorted(candidates))
STAGE = 1
STAGE_TITLE = 'SET INTERSECTION'
REQUEST_COUNT = 1
USE_UNDO = False
ALLOW_CONFIRM = False
CONTROLS = ('1', '2', '3', '4', '5', '6', '7', '8', 'R')
HELP_ONE = '1..8: toggle cards'
HELP_TWO = 'R: reset ESC: quit No timer. Empty selection = all errands.'
@dataclass(frozen=True)
class State:
draft: tuple = ()
candidates: tuple = (0, 1, 2, 3)
def initial():
return State()
def advance(state, action):
"""この段階だけは札の選択を即プレビューする教材用の実験台。"""
if action.startswith('place:') and action[6:].isdigit():
letter = int(action[6:])
if letter not in range(8):
return state, 'unavailable'
draft = tuple(i for i in state.draft if i != letter) if letter in state.draft else state.draft + (letter,)
return State(draft, interpret(WORKSHOP, draft)), 'preview'
return state, 'unknown'
class Game:
def __init__(self):
self.reset()
@property
def state(self):
return self.current
def reset(self):
self.current = initial()
self.reason = 'ready'
def act(self, action):
self.current, self.reason = advance(self.current, action)
class Controller:
"""実画面もテストも同じ入口。1回押して離すと1判断。"""
def __init__(self):
self.game = Game()
self.slot = 0
self.gate = False
def tick(self, pressed=(), held=()):
pressed, held = set(pressed), set(held)
if self.gate:
if not (held & set(CONTROLS)):
self.gate = False
return
if len(pressed & set(CONTROLS)) != 1 or len(held & set(CONTROLS)) != 1:
return # 同時押しを1つの判断に勝手に変換しない。
key = next(iter(pressed & set(CONTROLS)))
self.gate = True
if key == 'R':
self.game.reset()
self.slot = 0
elif key in '12345678':
self.game.act('place:' + str(int(key) - 1))
elif key == 'LEFT':
self.slot = (self.slot - 1) % 3
elif key == 'RIGHT':
self.slot = (self.slot + 1) % 3
elif key == 'SPACE':
self.game.act('erase:' + str(self.slot))
elif key == 'RETURN':
state = self.game.state
confirm = ALLOW_CONFIRM and state.phase == 'reply' and len(state.candidates) == 1 and not state.draft
self.game.act('confirm' if confirm else 'send')
elif key == 'C':
self.game.act('safe')
elif key == 'X':
self.game.act('undo')
def numbers(values):
return ','.join(str(v + 1) for v in values) or '-'
def names(values):
return ' + '.join(LETTER_NAMES[v] for v in values) or '(none)'
def main(headless=False, screenshot=None, actions='', report=None):
import json
from pathlib import Path
# Pyxel初期化前に、出力先を呼び出した場所基準の絶対パスにする。
screenshot = str(Path(screenshot).resolve()) if screenshot else None
report = str(Path(report).resolve()) if report else None
import pyxel
controller = Controller()
key_codes = {k: getattr(pyxel, 'KEY_' + k) for k in CONTROLS}
replay_keys = {'E': 'RETURN', 'D': 'SPACE', 'L': 'LEFT', 'T': 'RIGHT', 'C': 'C', 'X': 'X', 'R': 'R'}
replay_keys.update({k: k for k in '12345678'})
frame = 0
events = []
frames = max(12, len(actions) * 2 + 2)
def update():
nonlocal frame
frame += 1
if headless:
index = (frame - 1) // 2
if frame % 2 and index < len(actions):
key = replay_keys[actions[index]]
controller.tick((key,), (key,))
events.append({'input': actions[index], 'reason': controller.game.reason,
'state': asdict(controller.game.state)})
else:
controller.tick((), ())
else:
controller.tick([k for k, v in key_codes.items() if pyxel.btnp(v)],
[k for k, v in key_codes.items() if pyxel.btn(v)])
def text(x, y, message, color=7):
pyxel.text(x, y, message, color)
def draw():
state = controller.game.state
request = min(getattr(state, 'request', 0), REQUEST_COUNT - 1)
person = LEVEL.people[request]
available = getattr(state, 'available', tuple(range(8)))
sent = getattr(state, 'sent', ())
phase = getattr(state, 'phase', 'preview')
pyxel.cls(1)
text(12, 10, 'THREE LETTERS / STEP ' + str(STAGE) + ' / ' + STAGE_TITLE, 10)
text(12, 25, 'RULES: B - recipient-specific sets, no word order', 6)
pyxel.rect(8, 39, 544, 49, 2)
text(16, 46, 'CURRENT: ' + PERSON_NAMES[request] + ' REQUEST ' + str(request + 1) + '/' + str(REQUEST_COUNT), 11)
text(16, 59, 'TARGET #' + str(LEVEL.targets[request] + 1) + ': ' + ERRAND_NAMES[request][LEVEL.targets[request]], 10)
next_label = 'NEXT: RECEPTION #3 Blue box / DESK' if REQUEST_COUNT == 2 and request == 0 else 'NEXT: none in this lesson'
text(16, 74, next_label, 6)
text(12, 101, 'KEY', 7)
text(32, 101, 'CARD', 7)
text(83, 101, 'CURRENT MEANING', 7)
text(232, 101, 'ERRAND IDS', 7)
text(340, 101, 'ERRANDS / * = CANDIDATE', 7)
for i in range(8):
y = 117 + i * 19
used, drafted = i not in available, i in state.draft
pyxel.rect(8, y - 3, 318, 17, 2 if not drafted else 3)
color = 13 if used else 10 if drafted else 7
text(13, y, str(i + 1), color)
text(32, y, LETTER_NAMES[i], color)
text(83, y, GLOSS_NAMES[request][i], color)
text(232, y, numbers(person.meanings[i]), color)
if used:
text(286, y, 'USED', 8)
shown = state.candidates
for i, label in enumerate(ERRAND_NAMES[request]):
y = 121 + i * 28
if i in shown:
pyxel.rect(336, y - 5, 216, 23, 3)
text(341, y, ('*' if i in shown else ' ') + str(i + 1) + ' ' + label, 10 if i in shown else 6)
text(340, 241, 'CANDIDATES: ' + numbers(shown), 11)
text(340, 256, 'PHASE: ' + phase.upper(), 6)
pyxel.rect(8, 276, 544, 60, 2)
text(16, 284, 'DRAFT: ' + names(state.draft), 10)
text(16, 299, 'SENT: ' + names(sent), 7)
text(16, 316, 'AVAILABLE: ' + numbers(available) + ' ERASE SLOT: ' + str(controller.slot + 1), 6)
rounds = getattr(state, 'rounds', 0)
posts = getattr(state, 'posts', 0)
status = 'SELECTED: ' + str(len(state.draft)) if STAGE <= 2 else 'ROUNDS: ' + str(rounds)
if STAGE >= 4:
status += ' POSTS: ' + str(posts) + '/2'
text(12, 346, status + ' RESULT: ' + controller.game.reason.upper(), 11)
text(12, 362, HELP_ONE, 7)
text(12, 377, HELP_TWO, 6)
if phase == 'won':
text(12, 392, 'SUCCESS: correct request(s)! Safe full reading counts too.', 10)
elif STAGE <= 2:
text(12, 392, 'PREVIEW ONLY: no sending or consumption in this lesson.', 13)
elif STAGE == 3:
text(12, 392, 'Read reply, then R to retry. Confirm arrives in step 4.', 13)
else:
text(12, 392, 'C: read full request safely (+2 rounds, no extra cards)', 13)
for index, result in enumerate(getattr(state, 'results', ())):
recipient_index = LEVEL.people.index(next(p for p in LEVEL.people if p.name == result.person))
mode = 'SAFE' if result.safe else 'UNIQUE'
text(12, 409 + index * 14, f'DONE {index + 1}: {PERSON_NAMES[recipient_index]} #{result.target + 1} {mode} / {result.rounds} rounds / ' + names(result.letters), 6)
if headless and frame >= frames:
if screenshot:
pyxel.screenshot(screenshot, scale=1)
if report:
Path(report).write_text(json.dumps({
'label': 'Actual Pyxel headless draw with synthetic controller keys; silent',
'stage': STAGE, 'frames': frame, 'actions': actions,
'events': events, 'state': asdict(state),
'history_length': len(controller.game.timeline.history) if USE_UNDO else 0,
}, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
pyxel.quit()
pyxel.init(560, 444, title='Three Letters - Step ' + str(STAGE), fps=30, headless=headless)
pyxel.run(update, draw)
if __name__ == '__main__':
import argparse
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--headless-smoke', action='store_true')
parser.add_argument('--headless-replay', help='Synthetic keys: 1..8 E=Enter D=erase L/T=slot C=safe X=undo R=reset')
parser.add_argument('--screenshot', help='PNG output, headless only')
parser.add_argument('--report', help='UTF-8 JSON output, headless only')
args = parser.parse_args()
if args.headless_smoke and args.headless_replay is not None:
parser.error('Choose smoke or replay')
headless = args.headless_smoke or args.headless_replay is not None
if not headless and (args.screenshot or args.report):
parser.error('Output options require headless mode')
actions = args.headless_replay or ''
replay_allowed = set('12345678R') | ({'D', 'L', 'T'} if STAGE >= 2 else set()) | ({'E'} if STAGE >= 3 else set()) | ({'C'} if STAGE >= 4 else set()) | ({'X'} if STAGE == 5 else set())
if any(key not in replay_allowed for key in actions):
parser.error('Unsupported key in this stage')
main(headless, args.screenshot, actions, args.report)送る前に三文字へ絞る
書き換えられない State、上限、同じ札の重複を防ぐ、位置を選んで外す。
State は frozen=True の dataclass です。下書きは tuple で持ち、追加や削除のたびに新しい State を返します。ここから、1通に使える札は3枚までで、同じ札を2回入れることはできません。左右キーで外す位置(1〜3)を選び、Space でその位置の札を外します。まだ下書きの実験なので、札は使ったことにならず、Enter も何もしません。先に、上限と編集の仕組みだけを完成させます。
下のコードは 02_draft.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
if len(state.draft) >= 3:
return state, 'draft_full'
draft = state.draft + (letter,)
return State(draft, interpret(WORKSHOP, draft)), 'placed'実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 02_draft.py.venv/bin/python 02_draft.py- 4枚目と重複は受け付けない
- 外す位置を選べる
- 送る前の札は、まだ使っていない
tuple の番号(添字)は0から数えます。erase:1 は2番目の札を外します。選んだ札の番号と、下書きの中の位置を混同しないでください。1、2、3 の状態で 右→Space と押し、そのあともう一度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.
# 教材にはNoto由来字形は含めません。描画はPyxel標準ASCIIと図形です。
"""伝言は三文字・教材 2/5。原作Bを段階的に学ぶ独立プログラム。"""
from dataclasses import dataclass, replace, asdict
@dataclass(frozen=True)
class Recipient:
name: str
errands: tuple
meanings: tuple
glosses: tuple
@dataclass(frozen=True)
class Level:
id: str
title: str
letters: tuple
people: tuple
targets: tuple
hints: tuple
WORKSHOP = Recipient(
'工房の係',
('灯を磨く / 東机', '灯を運ぶ / 西机', '鈴を磨く / 東机', '鈴を運ぶ / 西机'),
((0, 1), (0, 2), (0, 2), (), (0, 1, 2, 3), (), (1, 3), (2, 3)),
('灯の用事', '東机の印', '磨く用事', '該当なし', '全て机の用事', '該当なし', '運ぶ用事', '鈴の用事'),
)
RECEPTION = Recipient(
'受付の係',
('青い封を窓へ', '赤い封を机へ', '青い箱を机へ', '赤い箱を窓へ'),
((), (0, 2), (), (2, 3), (1, 2), (0, 3), (0, 1, 2, 3), ()),
('該当なし', '荷物が青色', '該当なし', '箱の用事', '机へ届ける', '窓へ届ける', '全て運ぶ用事', '該当なし'),
)
LEVEL = Level('067-B-g1', '青の意味が違う', tuple('灯青磨箱机窓運鈴'),
(WORKSHOP, RECEPTION), (0, 2),
('青は工房では東机、受付では色。', '送った文は、前の相手の意味で保存。'))
LETTER_NAMES = ('LAMP', 'BLUE', 'POLISH', 'BOX', 'DESK', 'WINDOW', 'CARRY', 'BELL')
PERSON_NAMES = ('WORKSHOP', 'RECEPTION')
ERRAND_NAMES = (
('Polish lamp / EAST desk', 'Carry lamp / WEST desk',
'Polish bell / EAST desk', 'Carry bell / WEST desk'),
('Blue envelope / WINDOW', 'Red envelope / DESK',
'Blue box / DESK', 'Red box / WINDOW'),
)
GLOSS_NAMES = (
('Lamp errands', 'EAST desk mark', 'Polish errands', 'No matching errand',
'All desk errands', 'No matching errand', 'Carry errands', 'Bell errands'),
('No matching errand', 'BLUE color', 'No matching errand', 'Box errands',
'To DESK', 'To WINDOW', 'All delivery errands', 'No matching errand'),
)
def interpret(person, letters):
"""各札が指す集合の積集合。順番は問わない。空なら全候補。"""
candidates = set(range(len(person.errands)))
for letter in letters:
candidates.intersection_update(person.meanings[letter])
return tuple(sorted(candidates))
STAGE = 2
STAGE_TITLE = 'BUILD A DRAFT'
REQUEST_COUNT = 1
USE_UNDO = False
ALLOW_CONFIRM = False
CONTROLS = ('1', '2', '3', '4', '5', '6', '7', '8', 'R', 'LEFT', 'RIGHT', 'SPACE')
HELP_ONE = '1..8: add card LEFT/RIGHT: slot SPACE: erase'
HELP_TWO = 'R: reset ESC: quit No timer. Empty selection = all errands.'
@dataclass(frozen=True)
class State:
draft: tuple = ()
candidates: tuple = (0, 1, 2, 3)
def initial():
return State()
def advance(state, action):
"""3枚までの下書き。まだ送信も消費もしない。"""
op, separator, arg = action.partition(':')
if op == 'place' and separator and arg.isdigit():
letter = int(arg)
if letter not in range(8) or letter in state.draft:
return state, 'unavailable'
if len(state.draft) >= 3:
return state, 'draft_full'
draft = state.draft + (letter,)
return State(draft, interpret(WORKSHOP, draft)), 'placed'
if op == 'erase' and separator and arg.isdigit():
slot = int(arg)
if slot >= len(state.draft):
return state, 'bad_slot'
draft = state.draft[:slot] + state.draft[slot + 1:]
return State(draft, interpret(WORKSHOP, draft)), 'erased'
return state, 'unknown'
class Game:
def __init__(self):
self.reset()
@property
def state(self):
return self.current
def reset(self):
self.current = initial()
self.reason = 'ready'
def act(self, action):
self.current, self.reason = advance(self.current, action)
class Controller:
"""実画面もテストも同じ入口。1回押して離すと1判断。"""
def __init__(self):
self.game = Game()
self.slot = 0
self.gate = False
def tick(self, pressed=(), held=()):
pressed, held = set(pressed), set(held)
if self.gate:
if not (held & set(CONTROLS)):
self.gate = False
return
if len(pressed & set(CONTROLS)) != 1 or len(held & set(CONTROLS)) != 1:
return # 同時押しを1つの判断に勝手に変換しない。
key = next(iter(pressed & set(CONTROLS)))
self.gate = True
if key == 'R':
self.game.reset()
self.slot = 0
elif key in '12345678':
self.game.act('place:' + str(int(key) - 1))
elif key == 'LEFT':
self.slot = (self.slot - 1) % 3
elif key == 'RIGHT':
self.slot = (self.slot + 1) % 3
elif key == 'SPACE':
self.game.act('erase:' + str(self.slot))
elif key == 'RETURN':
state = self.game.state
confirm = ALLOW_CONFIRM and state.phase == 'reply' and len(state.candidates) == 1 and not state.draft
self.game.act('confirm' if confirm else 'send')
elif key == 'C':
self.game.act('safe')
elif key == 'X':
self.game.act('undo')
def numbers(values):
return ','.join(str(v + 1) for v in values) or '-'
def names(values):
return ' + '.join(LETTER_NAMES[v] for v in values) or '(none)'
def main(headless=False, screenshot=None, actions='', report=None):
import json
from pathlib import Path
# Pyxel初期化前に、出力先を呼び出した場所基準の絶対パスにする。
screenshot = str(Path(screenshot).resolve()) if screenshot else None
report = str(Path(report).resolve()) if report else None
import pyxel
controller = Controller()
key_codes = {k: getattr(pyxel, 'KEY_' + k) for k in CONTROLS}
replay_keys = {'E': 'RETURN', 'D': 'SPACE', 'L': 'LEFT', 'T': 'RIGHT', 'C': 'C', 'X': 'X', 'R': 'R'}
replay_keys.update({k: k for k in '12345678'})
frame = 0
events = []
frames = max(12, len(actions) * 2 + 2)
def update():
nonlocal frame
frame += 1
if headless:
index = (frame - 1) // 2
if frame % 2 and index < len(actions):
key = replay_keys[actions[index]]
controller.tick((key,), (key,))
events.append({'input': actions[index], 'reason': controller.game.reason,
'state': asdict(controller.game.state)})
else:
controller.tick((), ())
else:
controller.tick([k for k, v in key_codes.items() if pyxel.btnp(v)],
[k for k, v in key_codes.items() if pyxel.btn(v)])
def text(x, y, message, color=7):
pyxel.text(x, y, message, color)
def draw():
state = controller.game.state
request = min(getattr(state, 'request', 0), REQUEST_COUNT - 1)
person = LEVEL.people[request]
available = getattr(state, 'available', tuple(range(8)))
sent = getattr(state, 'sent', ())
phase = getattr(state, 'phase', 'preview')
pyxel.cls(1)
text(12, 10, 'THREE LETTERS / STEP ' + str(STAGE) + ' / ' + STAGE_TITLE, 10)
text(12, 25, 'RULES: B - recipient-specific sets, no word order', 6)
pyxel.rect(8, 39, 544, 49, 2)
text(16, 46, 'CURRENT: ' + PERSON_NAMES[request] + ' REQUEST ' + str(request + 1) + '/' + str(REQUEST_COUNT), 11)
text(16, 59, 'TARGET #' + str(LEVEL.targets[request] + 1) + ': ' + ERRAND_NAMES[request][LEVEL.targets[request]], 10)
next_label = 'NEXT: RECEPTION #3 Blue box / DESK' if REQUEST_COUNT == 2 and request == 0 else 'NEXT: none in this lesson'
text(16, 74, next_label, 6)
text(12, 101, 'KEY', 7)
text(32, 101, 'CARD', 7)
text(83, 101, 'CURRENT MEANING', 7)
text(232, 101, 'ERRAND IDS', 7)
text(340, 101, 'ERRANDS / * = CANDIDATE', 7)
for i in range(8):
y = 117 + i * 19
used, drafted = i not in available, i in state.draft
pyxel.rect(8, y - 3, 318, 17, 2 if not drafted else 3)
color = 13 if used else 10 if drafted else 7
text(13, y, str(i + 1), color)
text(32, y, LETTER_NAMES[i], color)
text(83, y, GLOSS_NAMES[request][i], color)
text(232, y, numbers(person.meanings[i]), color)
if used:
text(286, y, 'USED', 8)
shown = state.candidates
for i, label in enumerate(ERRAND_NAMES[request]):
y = 121 + i * 28
if i in shown:
pyxel.rect(336, y - 5, 216, 23, 3)
text(341, y, ('*' if i in shown else ' ') + str(i + 1) + ' ' + label, 10 if i in shown else 6)
text(340, 241, 'CANDIDATES: ' + numbers(shown), 11)
text(340, 256, 'PHASE: ' + phase.upper(), 6)
pyxel.rect(8, 276, 544, 60, 2)
text(16, 284, 'DRAFT: ' + names(state.draft), 10)
text(16, 299, 'SENT: ' + names(sent), 7)
text(16, 316, 'AVAILABLE: ' + numbers(available) + ' ERASE SLOT: ' + str(controller.slot + 1), 6)
rounds = getattr(state, 'rounds', 0)
posts = getattr(state, 'posts', 0)
status = 'SELECTED: ' + str(len(state.draft)) if STAGE <= 2 else 'ROUNDS: ' + str(rounds)
if STAGE >= 4:
status += ' POSTS: ' + str(posts) + '/2'
text(12, 346, status + ' RESULT: ' + controller.game.reason.upper(), 11)
text(12, 362, HELP_ONE, 7)
text(12, 377, HELP_TWO, 6)
if phase == 'won':
text(12, 392, 'SUCCESS: correct request(s)! Safe full reading counts too.', 10)
elif STAGE <= 2:
text(12, 392, 'PREVIEW ONLY: no sending or consumption in this lesson.', 13)
elif STAGE == 3:
text(12, 392, 'Read reply, then R to retry. Confirm arrives in step 4.', 13)
else:
text(12, 392, 'C: read full request safely (+2 rounds, no extra cards)', 13)
for index, result in enumerate(getattr(state, 'results', ())):
recipient_index = LEVEL.people.index(next(p for p in LEVEL.people if p.name == result.person))
mode = 'SAFE' if result.safe else 'UNIQUE'
text(12, 409 + index * 14, f'DONE {index + 1}: {PERSON_NAMES[recipient_index]} #{result.target + 1} {mode} / {result.rounds} rounds / ' + names(result.letters), 6)
if headless and frame >= frames:
if screenshot:
pyxel.screenshot(screenshot, scale=1)
if report:
Path(report).write_text(json.dumps({
'label': 'Actual Pyxel headless draw with synthetic controller keys; silent',
'stage': STAGE, 'frames': frame, 'actions': actions,
'events': events, 'state': asdict(state),
'history_length': len(controller.game.timeline.history) if USE_UNDO else 0,
}, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
pyxel.quit()
pyxel.init(560, 444, title='Three Letters - Step ' + str(STAGE), fps=30, headless=headless)
pyxel.run(update, draw)
if __name__ == '__main__':
import argparse
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--headless-smoke', action='store_true')
parser.add_argument('--headless-replay', help='Synthetic keys: 1..8 E=Enter D=erase L/T=slot C=safe X=undo R=reset')
parser.add_argument('--screenshot', help='PNG output, headless only')
parser.add_argument('--report', help='UTF-8 JSON output, headless only')
args = parser.parse_args()
if args.headless_smoke and args.headless_replay is not None:
parser.error('Choose smoke or replay')
headless = args.headless_smoke or args.headless_replay is not None
if not headless and (args.screenshot or args.report):
parser.error('Output options require headless mode')
actions = args.headless_replay or ''
replay_allowed = set('12345678R') | ({'D', 'L', 'T'} if STAGE >= 2 else set()) | ({'E'} if STAGE >= 3 else set()) | ({'C'} if STAGE >= 4 else set()) | ({'X'} if STAGE == 5 else set())
if any(key not in replay_allowed for key in actions):
parser.error('Unsupported key in this stage')
main(headless, args.screenshot, actions, args.report)送信した瞬間に札を使う
下書きと送ったものを分ける、在庫、やり取りの回数、ゲームの段階(phase)。
available(使える札)・draft(下書き)・sent(送った札)を分けます。下書きに置いただけでは、札は使ったことになりません。Enter で1〜3枚を送った瞬間に、使える札から取り除き、下書きを空にして、送った札を相手の表で読み取ります。送ることが、1回のやり取り(往復)です。ここからは、候補の強調表示は「送ったあとの返事」で、編集中の予想ではありません。この段階では最初の返事までで止まるので、R で何度でも比べられます。
下のコードは 03_send.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
return replace(state, available=tuple(i for i in state.available if i not in state.draft),
draft=(), sent=state.draft, candidates=interpret(WORKSHOP, state.draft),
rounds=state.rounds + 1, phase='reply', tick=state.tick + 1), 'reply'実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 03_send.py.venv/bin/python 03_send.py- 下書きのうちは、使える札を減らさない
- 送ると、やり取りが1回増える
- 候補の表示が、予想から返事に変わったとわかる
sent を使い、下書きの draft とは分けておきます。R→1→Enter と R→1→3→Enter を比べてみましょう。どちらもやり取りは1回ですが、使った札の数と候補の数が違います。下書きが空のまま送っても、やり取りは増えません。
このステップのコード全文(333行)
# 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.
# 教材にはNoto由来字形は含めません。描画はPyxel標準ASCIIと図形です。
"""伝言は三文字・教材 3/5。原作Bを段階的に学ぶ独立プログラム。"""
from dataclasses import dataclass, replace, asdict
@dataclass(frozen=True)
class Recipient:
name: str
errands: tuple
meanings: tuple
glosses: tuple
@dataclass(frozen=True)
class Level:
id: str
title: str
letters: tuple
people: tuple
targets: tuple
hints: tuple
WORKSHOP = Recipient(
'工房の係',
('灯を磨く / 東机', '灯を運ぶ / 西机', '鈴を磨く / 東机', '鈴を運ぶ / 西机'),
((0, 1), (0, 2), (0, 2), (), (0, 1, 2, 3), (), (1, 3), (2, 3)),
('灯の用事', '東机の印', '磨く用事', '該当なし', '全て机の用事', '該当なし', '運ぶ用事', '鈴の用事'),
)
RECEPTION = Recipient(
'受付の係',
('青い封を窓へ', '赤い封を机へ', '青い箱を机へ', '赤い箱を窓へ'),
((), (0, 2), (), (2, 3), (1, 2), (0, 3), (0, 1, 2, 3), ()),
('該当なし', '荷物が青色', '該当なし', '箱の用事', '机へ届ける', '窓へ届ける', '全て運ぶ用事', '該当なし'),
)
LEVEL = Level('067-B-g1', '青の意味が違う', tuple('灯青磨箱机窓運鈴'),
(WORKSHOP, RECEPTION), (0, 2),
('青は工房では東机、受付では色。', '送った文は、前の相手の意味で保存。'))
LETTER_NAMES = ('LAMP', 'BLUE', 'POLISH', 'BOX', 'DESK', 'WINDOW', 'CARRY', 'BELL')
PERSON_NAMES = ('WORKSHOP', 'RECEPTION')
ERRAND_NAMES = (
('Polish lamp / EAST desk', 'Carry lamp / WEST desk',
'Polish bell / EAST desk', 'Carry bell / WEST desk'),
('Blue envelope / WINDOW', 'Red envelope / DESK',
'Blue box / DESK', 'Red box / WINDOW'),
)
GLOSS_NAMES = (
('Lamp errands', 'EAST desk mark', 'Polish errands', 'No matching errand',
'All desk errands', 'No matching errand', 'Carry errands', 'Bell errands'),
('No matching errand', 'BLUE color', 'No matching errand', 'Box errands',
'To DESK', 'To WINDOW', 'All delivery errands', 'No matching errand'),
)
def interpret(person, letters):
"""各札が指す集合の積集合。順番は問わない。空なら全候補。"""
candidates = set(range(len(person.errands)))
for letter in letters:
candidates.intersection_update(person.meanings[letter])
return tuple(sorted(candidates))
STAGE = 3
STAGE_TITLE = 'SEND AND CONSUME'
REQUEST_COUNT = 1
USE_UNDO = False
ALLOW_CONFIRM = False
CONTROLS = ('1', '2', '3', '4', '5', '6', '7', '8', 'R', 'LEFT', 'RIGHT', 'SPACE', 'RETURN')
HELP_ONE = '1..8: add card LEFT/RIGHT: slot SPACE: erase ENTER: send'
HELP_TWO = 'R: reset ESC: quit Used cards do not return.'
@dataclass(frozen=True)
class Result:
person: str
target: int
letters: tuple
safe: bool
rounds: int
@dataclass(frozen=True)
class State:
request: int = 0
available: tuple = tuple(range(8))
draft: tuple = ()
sent: tuple = ()
candidates: tuple = (0, 1, 2, 3)
posts: int = 0
rounds: int = 0
results: tuple = ()
phase: str = 'draft'
tick: int = 0
def initial():
return State()
def advance(state, action):
"""送信で初めて在庫を消費する。追伸と確認は次の段階。"""
if state.phase == 'reply':
return state, 'read_reply'
op, separator, arg = action.partition(':')
if op == 'place' and separator and arg.isdigit():
letter = int(arg)
if letter not in state.available or letter in state.draft:
return state, 'unavailable'
if len(state.draft) >= 3:
return state, 'draft_full'
return replace(state, draft=state.draft + (letter,), tick=state.tick + 1), 'placed'
if op == 'erase' and separator and arg.isdigit():
slot = int(arg)
if slot >= len(state.draft):
return state, 'bad_slot'
return replace(state, draft=state.draft[:slot] + state.draft[slot + 1:], tick=state.tick + 1), 'erased'
if action == 'send':
if not state.draft:
return state, 'empty'
return replace(state, available=tuple(i for i in state.available if i not in state.draft),
draft=(), sent=state.draft, candidates=interpret(WORKSHOP, state.draft),
rounds=state.rounds + 1, phase='reply', tick=state.tick + 1), 'reply'
return state, 'unknown'
class Game:
def __init__(self):
self.reset()
@property
def state(self):
return self.current
def reset(self):
self.current = initial()
self.reason = 'ready'
def act(self, action):
self.current, self.reason = advance(self.current, action)
class Controller:
"""実画面もテストも同じ入口。1回押して離すと1判断。"""
def __init__(self):
self.game = Game()
self.slot = 0
self.gate = False
def tick(self, pressed=(), held=()):
pressed, held = set(pressed), set(held)
if self.gate:
if not (held & set(CONTROLS)):
self.gate = False
return
if len(pressed & set(CONTROLS)) != 1 or len(held & set(CONTROLS)) != 1:
return # 同時押しを1つの判断に勝手に変換しない。
key = next(iter(pressed & set(CONTROLS)))
self.gate = True
if key == 'R':
self.game.reset()
self.slot = 0
elif key in '12345678':
self.game.act('place:' + str(int(key) - 1))
elif key == 'LEFT':
self.slot = (self.slot - 1) % 3
elif key == 'RIGHT':
self.slot = (self.slot + 1) % 3
elif key == 'SPACE':
self.game.act('erase:' + str(self.slot))
elif key == 'RETURN':
state = self.game.state
confirm = ALLOW_CONFIRM and state.phase == 'reply' and len(state.candidates) == 1 and not state.draft
self.game.act('confirm' if confirm else 'send')
elif key == 'C':
self.game.act('safe')
elif key == 'X':
self.game.act('undo')
def numbers(values):
return ','.join(str(v + 1) for v in values) or '-'
def names(values):
return ' + '.join(LETTER_NAMES[v] for v in values) or '(none)'
def main(headless=False, screenshot=None, actions='', report=None):
import json
from pathlib import Path
# Pyxel初期化前に、出力先を呼び出した場所基準の絶対パスにする。
screenshot = str(Path(screenshot).resolve()) if screenshot else None
report = str(Path(report).resolve()) if report else None
import pyxel
controller = Controller()
key_codes = {k: getattr(pyxel, 'KEY_' + k) for k in CONTROLS}
replay_keys = {'E': 'RETURN', 'D': 'SPACE', 'L': 'LEFT', 'T': 'RIGHT', 'C': 'C', 'X': 'X', 'R': 'R'}
replay_keys.update({k: k for k in '12345678'})
frame = 0
events = []
frames = max(12, len(actions) * 2 + 2)
def update():
nonlocal frame
frame += 1
if headless:
index = (frame - 1) // 2
if frame % 2 and index < len(actions):
key = replay_keys[actions[index]]
controller.tick((key,), (key,))
events.append({'input': actions[index], 'reason': controller.game.reason,
'state': asdict(controller.game.state)})
else:
controller.tick((), ())
else:
controller.tick([k for k, v in key_codes.items() if pyxel.btnp(v)],
[k for k, v in key_codes.items() if pyxel.btn(v)])
def text(x, y, message, color=7):
pyxel.text(x, y, message, color)
def draw():
state = controller.game.state
request = min(getattr(state, 'request', 0), REQUEST_COUNT - 1)
person = LEVEL.people[request]
available = getattr(state, 'available', tuple(range(8)))
sent = getattr(state, 'sent', ())
phase = getattr(state, 'phase', 'preview')
pyxel.cls(1)
text(12, 10, 'THREE LETTERS / STEP ' + str(STAGE) + ' / ' + STAGE_TITLE, 10)
text(12, 25, 'RULES: B - recipient-specific sets, no word order', 6)
pyxel.rect(8, 39, 544, 49, 2)
text(16, 46, 'CURRENT: ' + PERSON_NAMES[request] + ' REQUEST ' + str(request + 1) + '/' + str(REQUEST_COUNT), 11)
text(16, 59, 'TARGET #' + str(LEVEL.targets[request] + 1) + ': ' + ERRAND_NAMES[request][LEVEL.targets[request]], 10)
next_label = 'NEXT: RECEPTION #3 Blue box / DESK' if REQUEST_COUNT == 2 and request == 0 else 'NEXT: none in this lesson'
text(16, 74, next_label, 6)
text(12, 101, 'KEY', 7)
text(32, 101, 'CARD', 7)
text(83, 101, 'CURRENT MEANING', 7)
text(232, 101, 'ERRAND IDS', 7)
text(340, 101, 'ERRANDS / * = CANDIDATE', 7)
for i in range(8):
y = 117 + i * 19
used, drafted = i not in available, i in state.draft
pyxel.rect(8, y - 3, 318, 17, 2 if not drafted else 3)
color = 13 if used else 10 if drafted else 7
text(13, y, str(i + 1), color)
text(32, y, LETTER_NAMES[i], color)
text(83, y, GLOSS_NAMES[request][i], color)
text(232, y, numbers(person.meanings[i]), color)
if used:
text(286, y, 'USED', 8)
shown = state.candidates
for i, label in enumerate(ERRAND_NAMES[request]):
y = 121 + i * 28
if i in shown:
pyxel.rect(336, y - 5, 216, 23, 3)
text(341, y, ('*' if i in shown else ' ') + str(i + 1) + ' ' + label, 10 if i in shown else 6)
text(340, 241, 'CANDIDATES: ' + numbers(shown), 11)
text(340, 256, 'PHASE: ' + phase.upper(), 6)
pyxel.rect(8, 276, 544, 60, 2)
text(16, 284, 'DRAFT: ' + names(state.draft), 10)
text(16, 299, 'SENT: ' + names(sent), 7)
text(16, 316, 'AVAILABLE: ' + numbers(available) + ' ERASE SLOT: ' + str(controller.slot + 1), 6)
rounds = getattr(state, 'rounds', 0)
posts = getattr(state, 'posts', 0)
status = 'SELECTED: ' + str(len(state.draft)) if STAGE <= 2 else 'ROUNDS: ' + str(rounds)
if STAGE >= 4:
status += ' POSTS: ' + str(posts) + '/2'
text(12, 346, status + ' RESULT: ' + controller.game.reason.upper(), 11)
text(12, 362, HELP_ONE, 7)
text(12, 377, HELP_TWO, 6)
if phase == 'won':
text(12, 392, 'SUCCESS: correct request(s)! Safe full reading counts too.', 10)
elif STAGE <= 2:
text(12, 392, 'PREVIEW ONLY: no sending or consumption in this lesson.', 13)
elif STAGE == 3:
text(12, 392, 'Read reply, then R to retry. Confirm arrives in step 4.', 13)
else:
text(12, 392, 'C: read full request safely (+2 rounds, no extra cards)', 13)
for index, result in enumerate(getattr(state, 'results', ())):
recipient_index = LEVEL.people.index(next(p for p in LEVEL.people if p.name == result.person))
mode = 'SAFE' if result.safe else 'UNIQUE'
text(12, 409 + index * 14, f'DONE {index + 1}: {PERSON_NAMES[recipient_index]} #{result.target + 1} {mode} / {result.rounds} rounds / ' + names(result.letters), 6)
if headless and frame >= frames:
if screenshot:
pyxel.screenshot(screenshot, scale=1)
if report:
Path(report).write_text(json.dumps({
'label': 'Actual Pyxel headless draw with synthetic controller keys; silent',
'stage': STAGE, 'frames': frame, 'actions': actions,
'events': events, 'state': asdict(state),
'history_length': len(controller.game.timeline.history) if USE_UNDO else 0,
}, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
pyxel.quit()
pyxel.init(560, 444, title='Three Letters - Step ' + str(STAGE), fps=30, headless=headless)
pyxel.run(update, draw)
if __name__ == '__main__':
import argparse
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--headless-smoke', action='store_true')
parser.add_argument('--headless-replay', help='Synthetic keys: 1..8 E=Enter D=erase L/T=slot C=safe X=undo R=reset')
parser.add_argument('--screenshot', help='PNG output, headless only')
parser.add_argument('--report', help='UTF-8 JSON output, headless only')
args = parser.parse_args()
if args.headless_smoke and args.headless_replay is not None:
parser.error('Choose smoke or replay')
headless = args.headless_smoke or args.headless_replay is not None
if not headless and (args.screenshot or args.report):
parser.error('Output options require headless mode')
actions = args.headless_replay or ''
replay_allowed = set('12345678R') | ({'D', 'L', 'T'} if STAGE >= 2 else set()) | ({'E'} if STAGE >= 3 else set()) | ({'C'} if STAGE >= 4 else set()) | ({'X'} if STAGE == 5 else set())
if any(key not in replay_allowed for key in actions):
parser.error('Unsupported key in this stage')
main(headless, args.screenshot, actions, args.report)一文字の追伸と安全な確認
状態が切り替わる条件、「1つに絞れた」と「正しい」の違い、失敗しても成功までたどり着ける道。
候補が2つ以上残っていたら、1文字の追伸を2回まで送れます。最初の送信も追伸も1回のやり取りですが、posts は追伸だけを数えます。候補が1つに絞れても、依頼票の target(本当の用事)と違えば完了しません。候補が0になったとき、まちがった1つに絞れたとき、追伸の上限に達したときでも、C の全文確認を使えば正しい依頼を完了できます。全文確認はいつでも使え、やり取りが2回増えますが、札は使いません。この段階では、工房の1件を終えたら成功です。
下のコードは 04_confirm.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
if action == 'confirm':
if state.phase != 'reply' or len(state.candidates) != 1:
return state, 'need_unique'
if state.candidates[0] != LEVEL.targets[state.request]:
return state, 'wrong_errand'
return finish(state, False), 'completed'実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 04_confirm.py.venv/bin/python 04_confirm.py- 追伸は1枚ずつ、最大2回
- 「1つに絞れた」と「正しい依頼」の両方を確かめる
- 全文確認も正式な成功だとわかる
len(candidates)==1 だけで成功にすると、まちがった用事も実行してしまいます。target と一致していることが必要です。また、候補が0のときは追伸できないので、C か R で考え直します。R→5→Enter→2→Enter→3→Enter と押すと、候補1,3が残り、POSTS は2になります。さらに1を足そうとすると NO_POST(もう追伸できない)になることを確かめ、C で ROUNDS 5 の成功にしてください。
このステップのコード全文(362行)
# 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.
# 教材にはNoto由来字形は含めません。描画はPyxel標準ASCIIと図形です。
"""伝言は三文字・教材 4/5。原作Bを段階的に学ぶ独立プログラム。"""
from dataclasses import dataclass, replace, asdict
@dataclass(frozen=True)
class Recipient:
name: str
errands: tuple
meanings: tuple
glosses: tuple
@dataclass(frozen=True)
class Level:
id: str
title: str
letters: tuple
people: tuple
targets: tuple
hints: tuple
WORKSHOP = Recipient(
'工房の係',
('灯を磨く / 東机', '灯を運ぶ / 西机', '鈴を磨く / 東机', '鈴を運ぶ / 西机'),
((0, 1), (0, 2), (0, 2), (), (0, 1, 2, 3), (), (1, 3), (2, 3)),
('灯の用事', '東机の印', '磨く用事', '該当なし', '全て机の用事', '該当なし', '運ぶ用事', '鈴の用事'),
)
RECEPTION = Recipient(
'受付の係',
('青い封を窓へ', '赤い封を机へ', '青い箱を机へ', '赤い箱を窓へ'),
((), (0, 2), (), (2, 3), (1, 2), (0, 3), (0, 1, 2, 3), ()),
('該当なし', '荷物が青色', '該当なし', '箱の用事', '机へ届ける', '窓へ届ける', '全て運ぶ用事', '該当なし'),
)
LEVEL = Level('067-B-g1', '青の意味が違う', tuple('灯青磨箱机窓運鈴'),
(WORKSHOP, RECEPTION), (0, 2),
('青は工房では東机、受付では色。', '送った文は、前の相手の意味で保存。'))
LETTER_NAMES = ('LAMP', 'BLUE', 'POLISH', 'BOX', 'DESK', 'WINDOW', 'CARRY', 'BELL')
PERSON_NAMES = ('WORKSHOP', 'RECEPTION')
ERRAND_NAMES = (
('Polish lamp / EAST desk', 'Carry lamp / WEST desk',
'Polish bell / EAST desk', 'Carry bell / WEST desk'),
('Blue envelope / WINDOW', 'Red envelope / DESK',
'Blue box / DESK', 'Red box / WINDOW'),
)
GLOSS_NAMES = (
('Lamp errands', 'EAST desk mark', 'Polish errands', 'No matching errand',
'All desk errands', 'No matching errand', 'Carry errands', 'Bell errands'),
('No matching errand', 'BLUE color', 'No matching errand', 'Box errands',
'To DESK', 'To WINDOW', 'All delivery errands', 'No matching errand'),
)
def interpret(person, letters):
"""各札が指す集合の積集合。順番は問わない。空なら全候補。"""
candidates = set(range(len(person.errands)))
for letter in letters:
candidates.intersection_update(person.meanings[letter])
return tuple(sorted(candidates))
STAGE = 4
STAGE_TITLE = 'POST AND CONFIRM'
REQUEST_COUNT = 1
USE_UNDO = False
ALLOW_CONFIRM = True
CONTROLS = ('1', '2', '3', '4', '5', '6', '7', '8', 'R', 'LEFT', 'RIGHT', 'SPACE', 'RETURN', 'C')
HELP_ONE = '1..8: add card LEFT/RIGHT: slot SPACE: erase ENTER: send/confirm'
HELP_TWO = 'C: safe full reading (+2 rounds) R: reset ESC: quit'
@dataclass(frozen=True)
class Result:
person: str
target: int
letters: tuple
safe: bool
rounds: int
@dataclass(frozen=True)
class State:
request: int = 0
available: tuple = tuple(range(8))
draft: tuple = ()
sent: tuple = ()
candidates: tuple = (0, 1, 2, 3)
posts: int = 0
rounds: int = 0
results: tuple = ()
phase: str = 'draft'
tick: int = 0
def initial():
return State()
def finish(state, safe):
"""全文確認は必ず正しい依頼を完了し、追加2往復。未送信の札は使わない。"""
used_rounds = state.rounds + 2 * safe - sum(r.rounds for r in state.results)
result = Result(LEVEL.people[state.request].name, LEVEL.targets[state.request],
state.sent, safe, used_rounds)
next_request = state.request + 1
return replace(state, request=next_request, draft=(), sent=(), posts=0,
candidates=tuple(range(4)) if next_request < REQUEST_COUNT else (),
results=state.results + (result,), rounds=state.rounds + 2 * safe,
phase='draft' if next_request < REQUEST_COUNT else 'won',
tick=state.tick + 1)
def advance(state, action):
"""状態を直接書き換えず、新しいStateと理由を返す。"""
if state.phase == 'won':
return state, 'ended'
op, separator, arg = action.partition(':')
if op == 'place' and separator and arg.isdigit():
letter = int(arg)
limit = 1 if state.phase == 'reply' else 3
if letter not in state.available or letter in state.draft:
return state, 'unavailable'
if len(state.draft) >= limit:
return state, 'draft_full'
if state.phase == 'reply' and (len(state.candidates) <= 1 or state.posts >= 2):
return state, 'no_post'
return replace(state, draft=state.draft + (letter,), tick=state.tick + 1), 'placed'
if op == 'erase' and separator and arg.isdigit():
slot = int(arg)
if slot >= len(state.draft):
return state, 'bad_slot'
draft = state.draft[:slot] + state.draft[slot + 1:]
return replace(state, draft=draft, tick=state.tick + 1), 'erased'
if action == 'send':
if not state.draft:
return state, 'empty'
if state.phase == 'reply' and (len(state.candidates) <= 1 or state.posts >= 2 or len(state.draft) != 1):
return state, 'no_post'
sent = state.sent + state.draft
candidates = interpret(LEVEL.people[state.request], sent)
return replace(state, available=tuple(i for i in state.available if i not in state.draft),
draft=(), sent=sent, candidates=candidates,
posts=state.posts + (state.phase == 'reply'), rounds=state.rounds + 1,
phase='reply', tick=state.tick + 1), 'reply'
if action == 'confirm':
if state.phase != 'reply' or len(state.candidates) != 1:
return state, 'need_unique'
if state.candidates[0] != LEVEL.targets[state.request]:
return state, 'wrong_errand'
return finish(state, False), 'completed'
if action == 'safe':
return finish(state, True), 'safe_completed'
return state, 'unknown'
class Game:
def __init__(self):
self.reset()
@property
def state(self):
return self.current
def reset(self):
self.current = initial()
self.reason = 'ready'
def act(self, action):
self.current, self.reason = advance(self.current, action)
class Controller:
"""実画面もテストも同じ入口。1回押して離すと1判断。"""
def __init__(self):
self.game = Game()
self.slot = 0
self.gate = False
def tick(self, pressed=(), held=()):
pressed, held = set(pressed), set(held)
if self.gate:
if not (held & set(CONTROLS)):
self.gate = False
return
if len(pressed & set(CONTROLS)) != 1 or len(held & set(CONTROLS)) != 1:
return # 同時押しを1つの判断に勝手に変換しない。
key = next(iter(pressed & set(CONTROLS)))
self.gate = True
if key == 'R':
self.game.reset()
self.slot = 0
elif key in '12345678':
self.game.act('place:' + str(int(key) - 1))
elif key == 'LEFT':
self.slot = (self.slot - 1) % 3
elif key == 'RIGHT':
self.slot = (self.slot + 1) % 3
elif key == 'SPACE':
self.game.act('erase:' + str(self.slot))
elif key == 'RETURN':
state = self.game.state
confirm = ALLOW_CONFIRM and state.phase == 'reply' and len(state.candidates) == 1 and not state.draft
self.game.act('confirm' if confirm else 'send')
elif key == 'C':
self.game.act('safe')
elif key == 'X':
self.game.act('undo')
def numbers(values):
return ','.join(str(v + 1) for v in values) or '-'
def names(values):
return ' + '.join(LETTER_NAMES[v] for v in values) or '(none)'
def main(headless=False, screenshot=None, actions='', report=None):
import json
from pathlib import Path
# Pyxel初期化前に、出力先を呼び出した場所基準の絶対パスにする。
screenshot = str(Path(screenshot).resolve()) if screenshot else None
report = str(Path(report).resolve()) if report else None
import pyxel
controller = Controller()
key_codes = {k: getattr(pyxel, 'KEY_' + k) for k in CONTROLS}
replay_keys = {'E': 'RETURN', 'D': 'SPACE', 'L': 'LEFT', 'T': 'RIGHT', 'C': 'C', 'X': 'X', 'R': 'R'}
replay_keys.update({k: k for k in '12345678'})
frame = 0
events = []
frames = max(12, len(actions) * 2 + 2)
def update():
nonlocal frame
frame += 1
if headless:
index = (frame - 1) // 2
if frame % 2 and index < len(actions):
key = replay_keys[actions[index]]
controller.tick((key,), (key,))
events.append({'input': actions[index], 'reason': controller.game.reason,
'state': asdict(controller.game.state)})
else:
controller.tick((), ())
else:
controller.tick([k for k, v in key_codes.items() if pyxel.btnp(v)],
[k for k, v in key_codes.items() if pyxel.btn(v)])
def text(x, y, message, color=7):
pyxel.text(x, y, message, color)
def draw():
state = controller.game.state
request = min(getattr(state, 'request', 0), REQUEST_COUNT - 1)
person = LEVEL.people[request]
available = getattr(state, 'available', tuple(range(8)))
sent = getattr(state, 'sent', ())
phase = getattr(state, 'phase', 'preview')
pyxel.cls(1)
text(12, 10, 'THREE LETTERS / STEP ' + str(STAGE) + ' / ' + STAGE_TITLE, 10)
text(12, 25, 'RULES: B - recipient-specific sets, no word order', 6)
pyxel.rect(8, 39, 544, 49, 2)
text(16, 46, 'CURRENT: ' + PERSON_NAMES[request] + ' REQUEST ' + str(request + 1) + '/' + str(REQUEST_COUNT), 11)
text(16, 59, 'TARGET #' + str(LEVEL.targets[request] + 1) + ': ' + ERRAND_NAMES[request][LEVEL.targets[request]], 10)
next_label = 'NEXT: RECEPTION #3 Blue box / DESK' if REQUEST_COUNT == 2 and request == 0 else 'NEXT: none in this lesson'
text(16, 74, next_label, 6)
text(12, 101, 'KEY', 7)
text(32, 101, 'CARD', 7)
text(83, 101, 'CURRENT MEANING', 7)
text(232, 101, 'ERRAND IDS', 7)
text(340, 101, 'ERRANDS / * = CANDIDATE', 7)
for i in range(8):
y = 117 + i * 19
used, drafted = i not in available, i in state.draft
pyxel.rect(8, y - 3, 318, 17, 2 if not drafted else 3)
color = 13 if used else 10 if drafted else 7
text(13, y, str(i + 1), color)
text(32, y, LETTER_NAMES[i], color)
text(83, y, GLOSS_NAMES[request][i], color)
text(232, y, numbers(person.meanings[i]), color)
if used:
text(286, y, 'USED', 8)
shown = state.candidates
for i, label in enumerate(ERRAND_NAMES[request]):
y = 121 + i * 28
if i in shown:
pyxel.rect(336, y - 5, 216, 23, 3)
text(341, y, ('*' if i in shown else ' ') + str(i + 1) + ' ' + label, 10 if i in shown else 6)
text(340, 241, 'CANDIDATES: ' + numbers(shown), 11)
text(340, 256, 'PHASE: ' + phase.upper(), 6)
pyxel.rect(8, 276, 544, 60, 2)
text(16, 284, 'DRAFT: ' + names(state.draft), 10)
text(16, 299, 'SENT: ' + names(sent), 7)
text(16, 316, 'AVAILABLE: ' + numbers(available) + ' ERASE SLOT: ' + str(controller.slot + 1), 6)
rounds = getattr(state, 'rounds', 0)
posts = getattr(state, 'posts', 0)
status = 'SELECTED: ' + str(len(state.draft)) if STAGE <= 2 else 'ROUNDS: ' + str(rounds)
if STAGE >= 4:
status += ' POSTS: ' + str(posts) + '/2'
text(12, 346, status + ' RESULT: ' + controller.game.reason.upper(), 11)
text(12, 362, HELP_ONE, 7)
text(12, 377, HELP_TWO, 6)
if phase == 'won':
text(12, 392, 'SUCCESS: correct request(s)! Safe full reading counts too.', 10)
elif STAGE <= 2:
text(12, 392, 'PREVIEW ONLY: no sending or consumption in this lesson.', 13)
elif STAGE == 3:
text(12, 392, 'Read reply, then R to retry. Confirm arrives in step 4.', 13)
else:
text(12, 392, 'C: read full request safely (+2 rounds, no extra cards)', 13)
for index, result in enumerate(getattr(state, 'results', ())):
recipient_index = LEVEL.people.index(next(p for p in LEVEL.people if p.name == result.person))
mode = 'SAFE' if result.safe else 'UNIQUE'
text(12, 409 + index * 14, f'DONE {index + 1}: {PERSON_NAMES[recipient_index]} #{result.target + 1} {mode} / {result.rounds} rounds / ' + names(result.letters), 6)
if headless and frame >= frames:
if screenshot:
pyxel.screenshot(screenshot, scale=1)
if report:
Path(report).write_text(json.dumps({
'label': 'Actual Pyxel headless draw with synthetic controller keys; silent',
'stage': STAGE, 'frames': frame, 'actions': actions,
'events': events, 'state': asdict(state),
'history_length': len(controller.game.timeline.history) if USE_UNDO else 0,
}, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
pyxel.quit()
pyxel.init(560, 444, title='Three Letters - Step ' + str(STAGE), fps=30, headless=headless)
pyxel.run(update, draw)
if __name__ == '__main__':
import argparse
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--headless-smoke', action='store_true')
parser.add_argument('--headless-replay', help='Synthetic keys: 1..8 E=Enter D=erase L/T=slot C=safe X=undo R=reset')
parser.add_argument('--screenshot', help='PNG output, headless only')
parser.add_argument('--report', help='UTF-8 JSON output, headless only')
args = parser.parse_args()
if args.headless_smoke and args.headless_replay is not None:
parser.error('Choose smoke or replay')
headless = args.headless_smoke or args.headless_replay is not None
if not headless and (args.screenshot or args.report):
parser.error('Output options require headless mode')
actions = args.headless_replay or ''
replay_allowed = set('12345678R') | ({'D', 'L', 'T'} if STAGE >= 2 else set()) | ({'E'} if STAGE >= 3 else set()) | ({'C'} if STAGE >= 4 else set()) | ({'X'} if STAGE == 5 else set())
if any(key not in replay_allowed for key in actions):
parser.error('Unsupported key in this stage')
main(headless, args.screenshot, actions, args.report)相手が変わる二件と丸ごとUndo
みんなで使う資源、送った相手と結びついた結果、すべての状態の履歴。
REQUEST_COUNT を2に増やして、元のゲームの B のふつうのルールにそろえます。1件目は工房への「灯を磨く/東の机」、2件目は受付への「青い箱を机へ」です。一度使った札は戻りません。同じ「青」でも、工房では東の机の印、受付では色の意味になります。結果には、そのときの相手・target・送った札・全文確認を使ったか・その依頼のやり取りの回数を保存します。X は判断する前の State 全体を元に戻すので、使える札だけでなく、相手・候補・追伸・やり取りの回数・結果・成功したかも戻ります。カーソルの位置は、この状態には含みません。
下のコードは 05_two_requests.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
if action == 'undo':
if not timeline.history:
return timeline, 'no_undo'
return Timeline(timeline.history[-1], timeline.history[:-1]), 'undo'実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 05_two_requests.py.venv/bin/python 05_two_requests.py- 相手が変わると、意味の表も変わる
- 2件で、同じ限られた札を分け合う
- Undo ですべての状態が元に戻る
- R キーで履歴も空にする
available を8枚全部に戻すと、元のゲームの標準ルールではなく、比較版のルールになってしまいます。また、書き換えられる辞書を履歴で共有すると、過去まで書き換わってしまいます。1件目で1と2を使ったら、2件目は4と5で解けるか予想してから確かめましょう。次に、送った直後に X を押して、使った札・候補・やり取りの回数が一緒に戻ることを確かめます。C→C で、2件とも全文確認しても、ROUNDS 4 で成功になります。
このステップのコード全文(383行)
# 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.
# 教材にはNoto由来字形は含めません。描画はPyxel標準ASCIIと図形です。
"""伝言は三文字・教材 5/5。原作Bを段階的に学ぶ独立プログラム。"""
from dataclasses import dataclass, replace, asdict
@dataclass(frozen=True)
class Recipient:
name: str
errands: tuple
meanings: tuple
glosses: tuple
@dataclass(frozen=True)
class Level:
id: str
title: str
letters: tuple
people: tuple
targets: tuple
hints: tuple
WORKSHOP = Recipient(
'工房の係',
('灯を磨く / 東机', '灯を運ぶ / 西机', '鈴を磨く / 東机', '鈴を運ぶ / 西机'),
((0, 1), (0, 2), (0, 2), (), (0, 1, 2, 3), (), (1, 3), (2, 3)),
('灯の用事', '東机の印', '磨く用事', '該当なし', '全て机の用事', '該当なし', '運ぶ用事', '鈴の用事'),
)
RECEPTION = Recipient(
'受付の係',
('青い封を窓へ', '赤い封を机へ', '青い箱を机へ', '赤い箱を窓へ'),
((), (0, 2), (), (2, 3), (1, 2), (0, 3), (0, 1, 2, 3), ()),
('該当なし', '荷物が青色', '該当なし', '箱の用事', '机へ届ける', '窓へ届ける', '全て運ぶ用事', '該当なし'),
)
LEVEL = Level('067-B-g1', '青の意味が違う', tuple('灯青磨箱机窓運鈴'),
(WORKSHOP, RECEPTION), (0, 2),
('青は工房では東机、受付では色。', '送った文は、前の相手の意味で保存。'))
LETTER_NAMES = ('LAMP', 'BLUE', 'POLISH', 'BOX', 'DESK', 'WINDOW', 'CARRY', 'BELL')
PERSON_NAMES = ('WORKSHOP', 'RECEPTION')
ERRAND_NAMES = (
('Polish lamp / EAST desk', 'Carry lamp / WEST desk',
'Polish bell / EAST desk', 'Carry bell / WEST desk'),
('Blue envelope / WINDOW', 'Red envelope / DESK',
'Blue box / DESK', 'Red box / WINDOW'),
)
GLOSS_NAMES = (
('Lamp errands', 'EAST desk mark', 'Polish errands', 'No matching errand',
'All desk errands', 'No matching errand', 'Carry errands', 'Bell errands'),
('No matching errand', 'BLUE color', 'No matching errand', 'Box errands',
'To DESK', 'To WINDOW', 'All delivery errands', 'No matching errand'),
)
def interpret(person, letters):
"""各札が指す集合の積集合。順番は問わない。空なら全候補。"""
candidates = set(range(len(person.errands)))
for letter in letters:
candidates.intersection_update(person.meanings[letter])
return tuple(sorted(candidates))
STAGE = 5
STAGE_TITLE = 'TWO REQUESTS + UNDO'
REQUEST_COUNT = 2
USE_UNDO = True
ALLOW_CONFIRM = True
CONTROLS = ('1', '2', '3', '4', '5', '6', '7', '8', 'R', 'LEFT', 'RIGHT', 'SPACE', 'RETURN', 'C', 'X')
HELP_ONE = '1..8: add card LEFT/RIGHT: slot SPACE: erase ENTER: send/confirm'
HELP_TWO = 'C: safe full reading (+2 rounds) X: undo R: reset ESC: quit'
@dataclass(frozen=True)
class Result:
person: str
target: int
letters: tuple
safe: bool
rounds: int
@dataclass(frozen=True)
class State:
request: int = 0
available: tuple = tuple(range(8))
draft: tuple = ()
sent: tuple = ()
candidates: tuple = (0, 1, 2, 3)
posts: int = 0
rounds: int = 0
results: tuple = ()
phase: str = 'draft'
tick: int = 0
def initial():
return State()
def finish(state, safe):
"""全文確認は必ず正しい依頼を完了し、追加2往復。未送信の札は使わない。"""
used_rounds = state.rounds + 2 * safe - sum(r.rounds for r in state.results)
result = Result(LEVEL.people[state.request].name, LEVEL.targets[state.request],
state.sent, safe, used_rounds)
next_request = state.request + 1
return replace(state, request=next_request, draft=(), sent=(), posts=0,
candidates=tuple(range(4)) if next_request < REQUEST_COUNT else (),
results=state.results + (result,), rounds=state.rounds + 2 * safe,
phase='draft' if next_request < REQUEST_COUNT else 'won',
tick=state.tick + 1)
def advance(state, action):
"""状態を直接書き換えず、新しいStateと理由を返す。"""
if state.phase == 'won':
return state, 'ended'
op, separator, arg = action.partition(':')
if op == 'place' and separator and arg.isdigit():
letter = int(arg)
limit = 1 if state.phase == 'reply' else 3
if letter not in state.available or letter in state.draft:
return state, 'unavailable'
if len(state.draft) >= limit:
return state, 'draft_full'
if state.phase == 'reply' and (len(state.candidates) <= 1 or state.posts >= 2):
return state, 'no_post'
return replace(state, draft=state.draft + (letter,), tick=state.tick + 1), 'placed'
if op == 'erase' and separator and arg.isdigit():
slot = int(arg)
if slot >= len(state.draft):
return state, 'bad_slot'
draft = state.draft[:slot] + state.draft[slot + 1:]
return replace(state, draft=draft, tick=state.tick + 1), 'erased'
if action == 'send':
if not state.draft:
return state, 'empty'
if state.phase == 'reply' and (len(state.candidates) <= 1 or state.posts >= 2 or len(state.draft) != 1):
return state, 'no_post'
sent = state.sent + state.draft
candidates = interpret(LEVEL.people[state.request], sent)
return replace(state, available=tuple(i for i in state.available if i not in state.draft),
draft=(), sent=sent, candidates=candidates,
posts=state.posts + (state.phase == 'reply'), rounds=state.rounds + 1,
phase='reply', tick=state.tick + 1), 'reply'
if action == 'confirm':
if state.phase != 'reply' or len(state.candidates) != 1:
return state, 'need_unique'
if state.candidates[0] != LEVEL.targets[state.request]:
return state, 'wrong_errand'
return finish(state, False), 'completed'
if action == 'safe':
return finish(state, True), 'safe_completed'
return state, 'unknown'
@dataclass(frozen=True)
class Timeline:
state: State
history: tuple = ()
def step(timeline, action):
if action == 'undo':
if not timeline.history:
return timeline, 'no_undo'
return Timeline(timeline.history[-1], timeline.history[:-1]), 'undo'
state, reason = advance(timeline.state, action)
if state == timeline.state:
return timeline, reason # 拒否した操作は履歴を増やさない。
return Timeline(state, timeline.history + (timeline.state,)), reason
class Game:
def __init__(self):
self.reset()
@property
def state(self):
return self.timeline.state if USE_UNDO else self.current
def reset(self):
if USE_UNDO:
self.timeline = Timeline(initial())
else:
self.current = initial()
self.reason = 'ready'
def act(self, action):
if USE_UNDO:
self.timeline, self.reason = step(self.timeline, action)
else:
self.current, self.reason = advance(self.current, action)
class Controller:
"""実画面もテストも同じ入口。1回押して離すと1判断。"""
def __init__(self):
self.game = Game()
self.slot = 0
self.gate = False
def tick(self, pressed=(), held=()):
pressed, held = set(pressed), set(held)
if self.gate:
if not (held & set(CONTROLS)):
self.gate = False
return
if len(pressed & set(CONTROLS)) != 1 or len(held & set(CONTROLS)) != 1:
return # 同時押しを1つの判断に勝手に変換しない。
key = next(iter(pressed & set(CONTROLS)))
self.gate = True
if key == 'R':
self.game.reset()
self.slot = 0
elif key in '12345678':
self.game.act('place:' + str(int(key) - 1))
elif key == 'LEFT':
self.slot = (self.slot - 1) % 3
elif key == 'RIGHT':
self.slot = (self.slot + 1) % 3
elif key == 'SPACE':
self.game.act('erase:' + str(self.slot))
elif key == 'RETURN':
state = self.game.state
confirm = ALLOW_CONFIRM and state.phase == 'reply' and len(state.candidates) == 1 and not state.draft
self.game.act('confirm' if confirm else 'send')
elif key == 'C':
self.game.act('safe')
elif key == 'X':
self.game.act('undo')
def numbers(values):
return ','.join(str(v + 1) for v in values) or '-'
def names(values):
return ' + '.join(LETTER_NAMES[v] for v in values) or '(none)'
def main(headless=False, screenshot=None, actions='', report=None):
import json
from pathlib import Path
# Pyxel初期化前に、出力先を呼び出した場所基準の絶対パスにする。
screenshot = str(Path(screenshot).resolve()) if screenshot else None
report = str(Path(report).resolve()) if report else None
import pyxel
controller = Controller()
key_codes = {k: getattr(pyxel, 'KEY_' + k) for k in CONTROLS}
replay_keys = {'E': 'RETURN', 'D': 'SPACE', 'L': 'LEFT', 'T': 'RIGHT', 'C': 'C', 'X': 'X', 'R': 'R'}
replay_keys.update({k: k for k in '12345678'})
frame = 0
events = []
frames = max(12, len(actions) * 2 + 2)
def update():
nonlocal frame
frame += 1
if headless:
index = (frame - 1) // 2
if frame % 2 and index < len(actions):
key = replay_keys[actions[index]]
controller.tick((key,), (key,))
events.append({'input': actions[index], 'reason': controller.game.reason,
'state': asdict(controller.game.state)})
else:
controller.tick((), ())
else:
controller.tick([k for k, v in key_codes.items() if pyxel.btnp(v)],
[k for k, v in key_codes.items() if pyxel.btn(v)])
def text(x, y, message, color=7):
pyxel.text(x, y, message, color)
def draw():
state = controller.game.state
request = min(getattr(state, 'request', 0), REQUEST_COUNT - 1)
person = LEVEL.people[request]
available = getattr(state, 'available', tuple(range(8)))
sent = getattr(state, 'sent', ())
phase = getattr(state, 'phase', 'preview')
pyxel.cls(1)
text(12, 10, 'THREE LETTERS / STEP ' + str(STAGE) + ' / ' + STAGE_TITLE, 10)
text(12, 25, 'RULES: B - recipient-specific sets, no word order', 6)
pyxel.rect(8, 39, 544, 49, 2)
text(16, 46, 'CURRENT: ' + PERSON_NAMES[request] + ' REQUEST ' + str(request + 1) + '/' + str(REQUEST_COUNT), 11)
text(16, 59, 'TARGET #' + str(LEVEL.targets[request] + 1) + ': ' + ERRAND_NAMES[request][LEVEL.targets[request]], 10)
next_label = 'NEXT: RECEPTION #3 Blue box / DESK' if REQUEST_COUNT == 2 and request == 0 else 'NEXT: none in this lesson'
text(16, 74, next_label, 6)
text(12, 101, 'KEY', 7)
text(32, 101, 'CARD', 7)
text(83, 101, 'CURRENT MEANING', 7)
text(232, 101, 'ERRAND IDS', 7)
text(340, 101, 'ERRANDS / * = CANDIDATE', 7)
for i in range(8):
y = 117 + i * 19
used, drafted = i not in available, i in state.draft
pyxel.rect(8, y - 3, 318, 17, 2 if not drafted else 3)
color = 13 if used else 10 if drafted else 7
text(13, y, str(i + 1), color)
text(32, y, LETTER_NAMES[i], color)
text(83, y, GLOSS_NAMES[request][i], color)
text(232, y, numbers(person.meanings[i]), color)
if used:
text(286, y, 'USED', 8)
shown = state.candidates
for i, label in enumerate(ERRAND_NAMES[request]):
y = 121 + i * 28
if i in shown:
pyxel.rect(336, y - 5, 216, 23, 3)
text(341, y, ('*' if i in shown else ' ') + str(i + 1) + ' ' + label, 10 if i in shown else 6)
text(340, 241, 'CANDIDATES: ' + numbers(shown), 11)
text(340, 256, 'PHASE: ' + phase.upper(), 6)
pyxel.rect(8, 276, 544, 60, 2)
text(16, 284, 'DRAFT: ' + names(state.draft), 10)
text(16, 299, 'SENT: ' + names(sent), 7)
text(16, 316, 'AVAILABLE: ' + numbers(available) + ' ERASE SLOT: ' + str(controller.slot + 1), 6)
rounds = getattr(state, 'rounds', 0)
posts = getattr(state, 'posts', 0)
status = 'SELECTED: ' + str(len(state.draft)) if STAGE <= 2 else 'ROUNDS: ' + str(rounds)
if STAGE >= 4:
status += ' POSTS: ' + str(posts) + '/2'
text(12, 346, status + ' RESULT: ' + controller.game.reason.upper(), 11)
text(12, 362, HELP_ONE, 7)
text(12, 377, HELP_TWO, 6)
if phase == 'won':
text(12, 392, 'SUCCESS: correct request(s)! Safe full reading counts too.', 10)
elif STAGE <= 2:
text(12, 392, 'PREVIEW ONLY: no sending or consumption in this lesson.', 13)
elif STAGE == 3:
text(12, 392, 'Read reply, then R to retry. Confirm arrives in step 4.', 13)
else:
text(12, 392, 'C: read full request safely (+2 rounds, no extra cards)', 13)
for index, result in enumerate(getattr(state, 'results', ())):
recipient_index = LEVEL.people.index(next(p for p in LEVEL.people if p.name == result.person))
mode = 'SAFE' if result.safe else 'UNIQUE'
text(12, 409 + index * 14, f'DONE {index + 1}: {PERSON_NAMES[recipient_index]} #{result.target + 1} {mode} / {result.rounds} rounds / ' + names(result.letters), 6)
if headless and frame >= frames:
if screenshot:
pyxel.screenshot(screenshot, scale=1)
if report:
Path(report).write_text(json.dumps({
'label': 'Actual Pyxel headless draw with synthetic controller keys; silent',
'stage': STAGE, 'frames': frame, 'actions': actions,
'events': events, 'state': asdict(state),
'history_length': len(controller.game.timeline.history) if USE_UNDO else 0,
}, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
pyxel.quit()
pyxel.init(560, 444, title='Three Letters - Step ' + str(STAGE), fps=30, headless=headless)
pyxel.run(update, draw)
if __name__ == '__main__':
import argparse
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--headless-smoke', action='store_true')
parser.add_argument('--headless-replay', help='Synthetic keys: 1..8 E=Enter D=erase L/T=slot C=safe X=undo R=reset')
parser.add_argument('--screenshot', help='PNG output, headless only')
parser.add_argument('--report', help='UTF-8 JSON output, headless only')
args = parser.parse_args()
if args.headless_smoke and args.headless_replay is not None:
parser.error('Choose smoke or replay')
headless = args.headless_smoke or args.headless_replay is not None
if not headless and (args.screenshot or args.report):
parser.error('Output options require headless mode')
actions = args.headless_replay or ''
replay_allowed = set('12345678R') | ({'D', 'L', 'T'} if STAGE >= 2 else set()) | ({'E'} if STAGE >= 3 else set()) | ({'C'} if STAGE >= 4 else set()) | ({'X'} if STAGE == 5 else set())
if any(key not in replay_allowed for key in actions):
parser.error('Unsupported key in this stage')
main(headless, args.screenshot, actions, args.report)仕組みがわかったら、元の完成版を読んでみよう。
元のゲームには、A・B・C の3つの状況があります。相手ごとに決まった、限られた候補の集合の共通部分で意味を決め、文字の順番は関係ありません。最初は1〜3文字、候補が複数残れば1文字の追伸を最大2回送れます。正しい用事に1つに絞れたときだけ確認できます。C の全文確認は、やり取りが2回増えますが、正しい用事を確かめられ、札は使いません。ふつうは2件の間で札は戻らず、X はすべての状態を元に戻します。日本語の文章を自動で理解する仕組みではありません。
ここまでに作ったのは、元のゲームの仕組みだけを取り出して作り直した「簡略版」です。元のゲームのソースは手を加えずに、別のZIP(元の完成版ソース)に入れてあります。
| 比べる点 | 5ステップの教材 | 元の完成版 |
|---|---|---|
| 状況 | 元のゲームのB「青の意味が違う」だけ。最後の05のデータとふつうのモデルはBと同じ。 | A「北を残す」、B「青の意味が違う」、C「条件を消さない」の3つの状況。 |
| 段階 | 01は枚数の制限なしで、すぐ結果が出る集合の実験。02は3枚までの編集。03は最初の送信まで。04は工房の1件。05は元のゲームのBの2件とUndo。 | 最初から、2件・送ったあとの返事・追伸・確認・全文確認・Undoが使える。 |
| 画面と入力 | 英字(ASCII)の札の名前・用事の番号・意味の表・依頼票をいつも表示。数字キーで直接札を選ぶ。タイトル・Pの一時停止・Mのメニュー・Sの記録・F12・面の選択は省いている。 | 日本語の文字、上下で選んでZ、タイトルと説明、3つの状況の選択、操作の記録や画面の保存。 |
| 比較と補助機能 | 札が戻る比較モード、答えを探すプログラム、元のゲームのデモ動画は省いている。自動の Controller テスト・実際のPyxelで画面を表示せずに描いた画像・元のゲームとの比較データがついている。 | Bキーで依頼ごとに札が戻る比較、答えを探すプログラム、記録と再生・動画など。 |
| 素材と音 | 標準の英字と四角だけ。Noto由来の文字データや音の素材は入っておらず、音を出す処理もない。 | Noto由来の日本語の文字データが入っている。元のゲームも音なし。 |
完成版のコードで、次に読むところ
- 元のゲームの
model.pyのLEVELS[0]とLEVELS[2]を読み、A と C の意味の表と依頼票を追加してみましょう。 interpretとforecastを読み、相手の意味の表と、次の依頼の予告を整理しましょう。advanceのreturns引数とfinishを読み、依頼ごとに札が戻る比較版を、別のルールとして追加してみましょう。record・replay・digestを読み、入力の並びとルールの版を含む再生記録を作ってみましょう。- 元のゲームの
app.pyのactionとupdateを読み、画面の入力の仕組みと、モデルの履歴を分ける方法を学びましょう。
最初は、数字かルールを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の実際のパソコンでの操作や、音が出るかどうかはまだ確認していません。人がすべての操作を試したわけでもありません。ブラウザー版の確認範囲は確認状況をご覧ください。
うまく動かないときは:起動やエラーの対処法 / 質問用のメモを作る
