完成サンプルをブラウザーで遊ぶ
この講座のもとになった完成版のゲームを、ブラウザーでそのまま遊べます。キーボードで操作するので、PCで開いてください(スマホのタッチ操作には対応していません)。初めて開くときは、読み込みに少し時間がかかります。
完成版の主な操作:上下経路/左右整数、Space/Z一期確定、C計画消去、X前期へ、P停止、R再開
ボタンを押すと、ゲームの読み込みが始まります。
画面に「CLICK TO START」と出たらクリックします。続けてゲーム画面を一度クリックすると、キー操作が効くようになります。ゲームを終えるときは、枠の外にある「閉じる」ボタンを使ってください(Escキーの働きはゲームごとに違います)。ゲーム内のセーブは、閉じる・ページを移る・再読み込みすると消えます。
うまく表示されないとき
ゲームを動かすためのプログラム(Pyxel 2.9.9)は、外部の配信サービス(jsDelivr)から読み込みます。表示されないときは「最初から再読込」を押すか、完成版のソースをダウンロードしてPCで実行してください。ブラウザー版の録画機能は止めています。ブラウザー版で確認できている範囲は検証状況にまとめています。
配る電気と充電の計画を、確定前の状態からまとめて検査する。
いきなり完成版のコードを全部読む必要はありません。小さなゲームにルールを1つずつ足しながら、5つのステップで作っていきます。どのステップのファイルもそれだけで動くので、動きを確かめてから次へ進みましょう。
- 必要な環境
- Python 3.11以上 / Pyxel 2.9.9
- 知っておくとよいこと
- Python 3.11以上・Pyxel 2.9.9 / 変数、整数、if、for、関数、タプル / 書き換えない
dataclass・replace・再帰(関数が自分自身を呼ぶこと)・メモ化(計算結果を覚えて使い回すこと)は本文で説明します - 教材に入っているもの
- ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
- 完成した教材の操作
- 上/下:編集する経路を選択 / 左/右:量を0〜2で調整 / Space/Z:一期間を確定 / C:案だけゼロにする / R:現在の状況を初期化 / 1/2/3:04/05でA/B/C選択・初期化 / X:05のUndo / Esc:終了
- 難易度の目安
- 中級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
- 学習順に迷ったら
- おすすめの学ぶ順番を見る
コードを書き換えたら、ファイルを保存し、動いているゲームを閉じてから、もう一度同じ.pyを実行します。ゲーム中にRキーなどで「やり直し」をしても、書き換えたコードは読み込まれません。
教材のゲーム画面は、日本語フォントを用意しなくても動くように英語で表示しています。ファイルには動作確認用のコードも入っていますが、最初は Game / State・update・draw の3つに注目すれば十分です。わからない言葉は用語集で調べられます。class・self・dataclass はPythonの状態、キー入力と時間の扱いは入力・更新・描画、マス目とピクセルの違いは画面とマップの座標のページで詳しく説明しています。
必要な灯りと任意の飾りを分ける
計画を立てる・検査する・確定する流れと、電気の帳簿。
まずは1期間だけの練習を作ります。発電所 G の発電量は4です。西の地区 A と東の地区 B には、それぞれ「必須(必ず届けたい電気)」が1、「任意(余裕があれば点ける飾りの電気)」が1ずつあります。上下キーで送り先 A/B を選び、左右キーで送る量を0〜2に設定して、Space か Z で確定します。計画を変えている間は、ゲームの期間も記録も進みません。この段階の線はつながりを示す図だけで、電線の容量の制限も電池もまだありません。
Period は今の期間の発電量と需要、Plan は入力中の計画、State は確定した結果です。@dataclass(frozen=True) は、これらの値をあとから直接書き換えない、という約束です。左右キーを押すと、replace で新しい Plan を作ります。apply も、元の State はそのまま残して、次の State を新しく作って返します。画面の PREVIEW(予告)も同じ apply を呼んで結果を見せるだけなので、何度表示しても確定はされません。
足りなかった量(不足)は max(0, 必須−供給)、点いた飾り(任意)の数は max(0, 供給−必須) で計算します。ただし、送れるのは必須+任意の合計までです。A へ2を送ると、必須1と飾り1の両方を満たします。必要以上に送ろうとする計画は、DEMAND(需要オーバー)として受け付けません。届けられなかった必須は「不足」として記録したうえで、その期間を進めます。つまり、ルール上は正しい(合法な)計画でも、成功するとは限りません。
legal は、まず入力の型と範囲を検査します。type(value) is int と書くのは、Python では True も isinstance(True, int) で整数とみなされてしまうためです。範囲の外の値や、発電量を超える計画では、同じ State をそのまま返し、記録を1行も増やしません。受け付けた計画だけが、記録 Record を1つ追加します。使われなかった発電は CURTAILED(抑制)として数えます。どの期間でも、「発電=A への供給+B への供給+抑制」が必ず成り立ちます。
下のコードは 01_supply.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
missing_a = max(0, period.a - plan.a)
missing_b = max(0, period.b - plan.b)
optional = max(0, plan.a - period.a) + max(0, plan.b - period.b)
curtailed = period.generation - plan.a - plan.b実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 01_supply.py.venv/bin/python 01_supply.py- 計画を編集している間は、時間が進まない
- ルール違反なら、元の
Stateをそのまま返す - 「ルール上は正しいが不足する」と「入力エラー」を分ける
draw の中で予告の結果を self.state に代入しないでください(表示するだけで確定されてしまいます)。LEVELS の Period(4,1,1,1,1) の最初の4だけを3に変えてみましょう。A2/B2 は GENERATION(発電量オーバー)で受け付けられず、PERIOD 0 のままです。A2/B1 なら WON、任意1、抑制0になります。確認したら4に戻します。
このステップのコード全文(270行)
# 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.
#
# This applies to original game source and primitive artwork. Bitmap glyph data
# is derived from Noto Sans CJK under SIL Open Font License 1.1, and is covered
# by assets/NOTO-LICENSE.txt instead of MIT.
"""まちの停電係・独立教材 1/5。架空の整数配分ゲーム。"""
from dataclasses import dataclass, replace
@dataclass(frozen=True)
class Period:
generation: int
a: int
b: int
optional_a: int = 0
optional_b: int = 0
@dataclass(frozen=True)
class Level:
title: str
periods: tuple
@dataclass(frozen=True)
class Plan:
a: int = 0
b: int = 0
@dataclass(frozen=True)
class Record:
period: int
plan: Plan
missing_a: int
missing_b: int
optional: int
curtailed: int
@dataclass(frozen=True)
class State:
t: int
records: tuple = ()
missing: int = 0
optional: int = 0
curtailed: int = 0
outcome: str = "playing"
LEVELS = (Level("PRACTICE: TWO DISTRICTS", (Period(4,1,1,1,1),)),)
def initial(level):
return State(0)
def loads(plan):
return plan.a + plan.b, plan.a, plan.b
def legal(state, level, plan):
if state.outcome != "playing" or state.t >= len(level.periods):
return "terminal"
if not isinstance(plan, Plan) or any(
type(value) is not int or not 0 <= value <= 2
for value in (plan.a, plan.b)
):
return "range"
period = level.periods[state.t]
trunk, west, east = loads(plan)
if trunk > period.generation:
return "generation"
if plan.a > period.a + period.optional_a or plan.b > period.b + period.optional_b:
return "demand"
return "commit"
def apply(state, level, plan):
reason = legal(state, level, plan)
if reason != "commit":
return state, reason
period = level.periods[state.t]
missing_a = max(0, period.a - plan.a)
missing_b = max(0, period.b - plan.b)
optional = max(0, plan.a - period.a) + max(0, plan.b - period.b)
curtailed = period.generation - plan.a - plan.b
record = Record(state.t, plan, missing_a, missing_b, optional, curtailed)
after = State(state.t + 1, state.records + (record,),
state.missing + missing_a + missing_b,
state.optional + optional, state.curtailed + curtailed)
if after.t == len(level.periods):
after = replace(after, outcome="won" if after.missing == 0 else "lost")
return after, reason
FIELDS = ('a', 'b')
class App:
def __init__(self):
self.level_index = 0
self.reset()
@property
def level(self):
return LEVELS[self.level_index]
def reset(self):
self.state = initial(self.level)
self.plan = Plan()
self.selection = 0
self.gated = False
self.message = "READY"
def commit(self):
self.state, self.message = apply(self.state, self.level, self.plan)
if self.message == "commit":
self.plan = Plan()
def control(self, pressed, held):
# A new R wins even during release gating or after the result.
if "R" in pressed:
self.reset()
self.gated = True
return
if self.gated:
if not pressed and not held:
self.gated = False
return
if len(pressed | held) > 1:
self.message = "RELEASE ALL KEYS"
self.gated = True
return
if len(pressed) != 1:
return
key = next(iter(pressed))
self.gated = True
if self.state.outcome != "playing":
return
if key == "C":
self.plan = Plan()
elif key == "UP":
self.selection = (self.selection - 1) % len(FIELDS)
elif key == "DOWN":
self.selection = (self.selection + 1) % len(FIELDS)
elif key in ("LEFT", "RIGHT"):
field = FIELDS[self.selection]
value = getattr(self.plan, field) + (1 if key == "RIGHT" else -1)
self.plan = replace(self.plan, **{field: max(0, min(2, value))})
elif key in ("Z", "SPACE"):
self.commit()
def draw(self, p):
p.cls(1)
s, level, plan = self.state, self.level, self.plan
period = level.periods[min(s.t, len(level.periods)-1)]
trunk, west, east = loads(plan)
after, reason = apply(s, level, plan)
p.text(12,10,"TOWN POWER / STAGE 1",10)
p.text(12,25,level.title,7)
p.text(400,10,"PERIOD "+str(s.t)+"/"+str(len(level.periods)),7)
p.text(400,25,s.outcome.upper(),11 if s.outcome=="won" else 7)
p.rect(12,44,552,112,2)
p.rectb(28,76,76,46,6)
p.text(38,86,"GENERATOR",7)
p.text(38,102,"G "+str(period.generation),10)
p.line(104,98,235,98,10)
p.circ(239,98,4,10)
p.line(239,98,239,66,10)
p.line(239,66,356,66,10)
p.line(239,98,239,130,10)
p.line(239,130,356,130,10)
p.rectb(356,52,70,29,6)
p.rectb(356,116,70,29,6)
p.text(363,58,"WEST A "+str(plan.a),7)
p.text(363,124,"EAST B "+str(plan.b),7)
p.text(115,82,"TOTAL "+str(trunk),7)
for x, label in ((16,"PERIOD"),(112,"G"),(200,"A NEED/+"),(290,"B NEED/+"),
(390,"MISS"),(442,"OPT"),(498,"CUT")):
p.text(x,167,label,6)
for index, d in enumerate(level.periods):
y = 184 + 16*index
if index == s.t:
p.rect(12,y-2,552,13,3)
for x, value in ((16,index),(112,d.generation),(200,f"{d.a}/{d.optional_a}"),
(290,f"{d.b}/{d.optional_b}")):
p.text(x,y,str(value),7)
if index < len(s.records):
r = s.records[index]
for x, value in ((390,r.missing_a+r.missing_b),(442,r.optional),(498,r.curtailed)):
p.text(x,y,str(value),11)
for index, field in enumerate(FIELDS):
x = 12 + 138*index
p.rectb(x,254,128,24,10 if index==self.selection else 5)
p.text(x+7,263,field.upper()+" "+str(getattr(plan,field)),7)
p.text(12,289,"PREVIEW: "+reason.upper(),11 if reason=="commit" else 8)
if reason=="commit":
p.text(220,289,f"MISS +{after.missing-s.missing} CUT +{after.curtailed-s.curtailed}",7)
p.text(12,322,f"TOTAL MISS {s.missing} OPTIONAL {s.optional} CURTAILED {s.curtailed}",7)
p.text(12,339,"LAST: "+self.message.upper(),10)
p.text(12,360,"UP/DOWN ROUTE LEFT/RIGHT AMOUNT SPACE/Z COMMIT",6)
p.text(12,376,"C CLEAR PLAN R RESET ESC QUIT",6)
p.text(12,392,"1 PERIOD / NO WIRE LIMITS OR BATTERY YET",6)
DEMO_KEYS = ('RIGHT', 'RIGHT', 'DOWN', 'RIGHT', 'RIGHT', 'SPACE')
KEYS = ('UP', 'DOWN', 'LEFT', 'RIGHT', 'SPACE', 'Z', 'C', 'R')
def main(headless=False, screenshot=None, demo=False, frames=None):
# Import Pyxel only after command-line validation and only for rendering.
from pathlib import Path
import json
import pyxel as p
app = App()
destination = Path(screenshot).resolve() if screenshot else None
if destination:
destination.parent.mkdir(parents=True, exist_ok=True)
limit = frames or (max(12, 2*len(DEMO_KEYS)+4) if demo else 12)
p.init(576,416,title="Town Power lesson 1",fps=30,display_scale=2,headless=headless)
keys = {name:getattr(p,"KEY_"+name) for name in KEYS}
def update():
if headless and p.frame_count >= limit:
if destination:
p.screenshot(str(destination),scale=1)
from dataclasses import asdict
print(json.dumps({"stage":1,"state":asdict(app.state),
"plan":asdict(app.plan),"message":app.message},sort_keys=True),flush=True)
p.quit()
return
if headless and demo:
index = p.frame_count // 2
pressed = {DEMO_KEYS[index]} if p.frame_count%2==0 and index<len(DEMO_KEYS) else set()
app.control(pressed, pressed)
else:
app.control({k for k,v in keys.items() if p.btnp(v)},
{k for k,v in keys.items() if p.btn(v)})
p.run(update,lambda:app.draw(p))
def parse_args(argv=None):
import argparse
parser = argparse.ArgumentParser(description="Independent Town Power lesson 1")
parser.add_argument("--headless-smoke",action="store_true")
parser.add_argument("--demo-checkpoint",action="store_true")
parser.add_argument("--screenshot",metavar="FILE.png")
parser.add_argument("--qa-frames",type=int,metavar="12..600")
args = parser.parse_args(argv)
if not args.headless_smoke and (args.demo_checkpoint or args.screenshot or args.qa_frames is not None):
parser.error("QA options require --headless-smoke")
if args.qa_frames is not None and not 12 <= args.qa_frames <= 600:
parser.error("--qa-frames must be 12..600")
if args.demo_checkpoint and args.qa_frames is not None and args.qa_frames < 2*len(DEMO_KEYS)+4:
parser.error("--qa-frames is shorter than the demo")
if args.screenshot and not args.screenshot.lower().endswith(".png"):
parser.error("--screenshot must end in .png")
return args
if __name__ == "__main__":
args = parse_args()
main(args.headless_smoke, args.screenshot, args.demo_checkpoint, args.qa_frames)発電量と線の容量を別に検査する
電線ごとに、流れる量を数える。
前の段階と同じく、発電4・各地区は必須1+任意1で始めます。変わったのは電線の制限です。G から分かれ道までの太い電線(幹線)は3まで、そこから西と東へ分かれる電線(支線)はそれぞれ2まで流せます。前と同じ A2/B2 は、発電4の範囲内なのに、幹線に4流れて上限3を超えるため、TRUNK(幹線オーバー)で受け付けられません。電気が足りていることと、送り先まで電線を通れることは、別の条件です。
loads は、計画から幹線・西・東に流れる量を順に返す小さな関数です。同じ入力なら必ず同じ結果を返し、ほかの値を書き換えない「純粋関数」です。この段階では、幹線=a+b、西=a、東=b です。legal は発電量を確かめたあと、幹線、西、東の順に検査します。いくつかの条件に同時に違反していても、表示されるのは最初に見つけた理由だけです。この検査の順番もルールの一部なので、テストで固定しています。
A2/B1 なら、幹線3、西2、東1で、全部通れます。必須の2件を守り、任意は合わせて1件点き、使えなかった発電1は抑制になります。A1/B2 でも合計の点数は同じです。どちらの地区に任意の分を回すかは計画しだいですが、この1期間の練習では、どちらでも成功です。
Plan の各値は0〜2なので、この段階では、範囲の検査を通ったあとに西・東が2を超えることはありません。それでも、流れる量の式と検査をはっきり書いておきます。次の段階で充電を加えると、東の支線を「B へ直接送る電気」と「電池へ充電する電気」の2つが一緒に使うので、この制限が本当に効いてきます。段階02で支線の上限をわざと超えようと3を入力しても、先に RANGE(範囲外)エラーになります。支線オーバーとは別のエラーなので、混同しないようにしましょう。
下のコードは 02_wires.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
if trunk > 3:
return "trunk"
if west > 2:
return "west"
if east > 2:
return "east"実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 02_wires.py.venv/bin/python 02_wires.py- 幹線には、2つの地区の合計が流れる
- 発電の余りと、電線の空きは別
- 受け付けないときは、一部だけ更新することもしない
実験用にコピーしたファイルで、legal の中の「if trunk > 3:」だけを「if trunk > 4:」に変えてみましょう。A2/B2 が WON、任意2、抑制0になることを確かめます。画面の「/3」という表示は元の上限のままなので、この実験では判定の結果のほうを見てください。終わったら必ず3に戻します。
このステップのコード全文(279行)
# 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.
#
# This applies to original game source and primitive artwork. Bitmap glyph data
# is derived from Noto Sans CJK under SIL Open Font License 1.1, and is covered
# by assets/NOTO-LICENSE.txt instead of MIT.
"""まちの停電係・独立教材 2/5。架空の整数配分ゲーム。"""
from dataclasses import dataclass, replace
@dataclass(frozen=True)
class Period:
generation: int
a: int
b: int
optional_a: int = 0
optional_b: int = 0
@dataclass(frozen=True)
class Level:
title: str
periods: tuple
@dataclass(frozen=True)
class Plan:
a: int = 0
b: int = 0
@dataclass(frozen=True)
class Record:
period: int
plan: Plan
missing_a: int
missing_b: int
optional: int
curtailed: int
@dataclass(frozen=True)
class State:
t: int
records: tuple = ()
missing: int = 0
optional: int = 0
curtailed: int = 0
outcome: str = "playing"
LEVELS = (Level("PRACTICE: TWO DISTRICTS", (Period(4,1,1,1,1),)),)
def initial(level):
return State(0)
def loads(plan):
return plan.a + plan.b, plan.a, plan.b
def legal(state, level, plan):
if state.outcome != "playing" or state.t >= len(level.periods):
return "terminal"
if not isinstance(plan, Plan) or any(
type(value) is not int or not 0 <= value <= 2
for value in (plan.a, plan.b)
):
return "range"
period = level.periods[state.t]
trunk, west, east = loads(plan)
if trunk > period.generation:
return "generation"
if trunk > 3:
return "trunk"
if west > 2:
return "west"
if east > 2:
return "east"
if plan.a > period.a + period.optional_a or plan.b > period.b + period.optional_b:
return "demand"
return "commit"
def apply(state, level, plan):
reason = legal(state, level, plan)
if reason != "commit":
return state, reason
period = level.periods[state.t]
missing_a = max(0, period.a - plan.a)
missing_b = max(0, period.b - plan.b)
optional = max(0, plan.a - period.a) + max(0, plan.b - period.b)
curtailed = period.generation - plan.a - plan.b
record = Record(state.t, plan, missing_a, missing_b, optional, curtailed)
after = State(state.t + 1, state.records + (record,),
state.missing + missing_a + missing_b,
state.optional + optional, state.curtailed + curtailed)
if after.t == len(level.periods):
after = replace(after, outcome="won" if after.missing == 0 else "lost")
return after, reason
FIELDS = ('a', 'b')
class App:
def __init__(self):
self.level_index = 0
self.reset()
@property
def level(self):
return LEVELS[self.level_index]
def reset(self):
self.state = initial(self.level)
self.plan = Plan()
self.selection = 0
self.gated = False
self.message = "READY"
def commit(self):
self.state, self.message = apply(self.state, self.level, self.plan)
if self.message == "commit":
self.plan = Plan()
def control(self, pressed, held):
# A new R wins even during release gating or after the result.
if "R" in pressed:
self.reset()
self.gated = True
return
if self.gated:
if not pressed and not held:
self.gated = False
return
if len(pressed | held) > 1:
self.message = "RELEASE ALL KEYS"
self.gated = True
return
if len(pressed) != 1:
return
key = next(iter(pressed))
self.gated = True
if self.state.outcome != "playing":
return
if key == "C":
self.plan = Plan()
elif key == "UP":
self.selection = (self.selection - 1) % len(FIELDS)
elif key == "DOWN":
self.selection = (self.selection + 1) % len(FIELDS)
elif key in ("LEFT", "RIGHT"):
field = FIELDS[self.selection]
value = getattr(self.plan, field) + (1 if key == "RIGHT" else -1)
self.plan = replace(self.plan, **{field: max(0, min(2, value))})
elif key in ("Z", "SPACE"):
self.commit()
def draw(self, p):
p.cls(1)
s, level, plan = self.state, self.level, self.plan
period = level.periods[min(s.t, len(level.periods)-1)]
trunk, west, east = loads(plan)
after, reason = apply(s, level, plan)
p.text(12,10,"TOWN POWER / STAGE 2",10)
p.text(12,25,level.title,7)
p.text(400,10,"PERIOD "+str(s.t)+"/"+str(len(level.periods)),7)
p.text(400,25,s.outcome.upper(),11 if s.outcome=="won" else 7)
p.rect(12,44,552,112,2)
p.rectb(28,76,76,46,6)
p.text(38,86,"GENERATOR",7)
p.text(38,102,"G "+str(period.generation),10)
p.line(104,98,235,98,10)
p.circ(239,98,4,10)
p.line(239,98,239,66,10)
p.line(239,66,356,66,10)
p.line(239,98,239,130,10)
p.line(239,130,356,130,10)
p.rectb(356,52,70,29,6)
p.rectb(356,116,70,29,6)
p.text(363,58,"WEST A "+str(plan.a),7)
p.text(363,124,"EAST B "+str(plan.b),7)
p.text(115,82,"TOTAL "+str(trunk),7)
p.text(115,112,"TRUNK "+str(trunk)+"/3",8 if trunk>3 else 7)
p.text(267,54,"W "+str(west)+"/2",8 if west>2 else 7)
p.text(267,118,"E "+str(east)+"/2",8 if east>2 else 7)
for x, label in ((16,"PERIOD"),(112,"G"),(200,"A NEED/+"),(290,"B NEED/+"),
(390,"MISS"),(442,"OPT"),(498,"CUT")):
p.text(x,167,label,6)
for index, d in enumerate(level.periods):
y = 184 + 16*index
if index == s.t:
p.rect(12,y-2,552,13,3)
for x, value in ((16,index),(112,d.generation),(200,f"{d.a}/{d.optional_a}"),
(290,f"{d.b}/{d.optional_b}")):
p.text(x,y,str(value),7)
if index < len(s.records):
r = s.records[index]
for x, value in ((390,r.missing_a+r.missing_b),(442,r.optional),(498,r.curtailed)):
p.text(x,y,str(value),11)
for index, field in enumerate(FIELDS):
x = 12 + 138*index
p.rectb(x,254,128,24,10 if index==self.selection else 5)
p.text(x+7,263,field.upper()+" "+str(getattr(plan,field)),7)
p.text(12,289,"PREVIEW: "+reason.upper(),11 if reason=="commit" else 8)
if reason=="commit":
p.text(220,289,f"MISS +{after.missing-s.missing} CUT +{after.curtailed-s.curtailed}",7)
p.text(12,322,f"TOTAL MISS {s.missing} OPTIONAL {s.optional} CURTAILED {s.curtailed}",7)
p.text(12,339,"LAST: "+self.message.upper(),10)
p.text(12,360,"UP/DOWN ROUTE LEFT/RIGHT AMOUNT SPACE/Z COMMIT",6)
p.text(12,376,"C CLEAR PLAN R RESET ESC QUIT",6)
p.text(12,392,"TRUNK <=3 / EACH BRANCH <=2 / NO BATTERY YET",6)
DEMO_KEYS = ('RIGHT', 'RIGHT', 'DOWN', 'RIGHT', 'SPACE')
KEYS = ('UP', 'DOWN', 'LEFT', 'RIGHT', 'SPACE', 'Z', 'C', 'R')
def main(headless=False, screenshot=None, demo=False, frames=None):
# Import Pyxel only after command-line validation and only for rendering.
from pathlib import Path
import json
import pyxel as p
app = App()
destination = Path(screenshot).resolve() if screenshot else None
if destination:
destination.parent.mkdir(parents=True, exist_ok=True)
limit = frames or (max(12, 2*len(DEMO_KEYS)+4) if demo else 12)
p.init(576,416,title="Town Power lesson 2",fps=30,display_scale=2,headless=headless)
keys = {name:getattr(p,"KEY_"+name) for name in KEYS}
def update():
if headless and p.frame_count >= limit:
if destination:
p.screenshot(str(destination),scale=1)
from dataclasses import asdict
print(json.dumps({"stage":2,"state":asdict(app.state),
"plan":asdict(app.plan),"message":app.message},sort_keys=True),flush=True)
p.quit()
return
if headless and demo:
index = p.frame_count // 2
pressed = {DEMO_KEYS[index]} if p.frame_count%2==0 and index<len(DEMO_KEYS) else set()
app.control(pressed, pressed)
else:
app.control({k for k,v in keys.items() if p.btnp(v)},
{k for k,v in keys.items() if p.btn(v)})
p.run(update,lambda:app.draw(p))
def parse_args(argv=None):
import argparse
parser = argparse.ArgumentParser(description="Independent Town Power lesson 2")
parser.add_argument("--headless-smoke",action="store_true")
parser.add_argument("--demo-checkpoint",action="store_true")
parser.add_argument("--screenshot",metavar="FILE.png")
parser.add_argument("--qa-frames",type=int,metavar="12..600")
args = parser.parse_args(argv)
if not args.headless_smoke and (args.demo_checkpoint or args.screenshot or args.qa_frames is not None):
parser.error("QA options require --headless-smoke")
if args.qa_frames is not None and not 12 <= args.qa_frames <= 600:
parser.error("--qa-frames must be 12..600")
if args.demo_checkpoint and args.qa_frames is not None and args.qa_frames < 2*len(DEMO_KEYS)+4:
parser.error("--qa-frames is shorter than the demo")
if args.screenshot and not args.screenshot.lower().endswith(".png"):
parser.error("--screenshot must end in .png")
return args
if __name__ == "__main__":
args = parse_args()
main(args.headless_smoke, args.screenshot, args.demo_checkpoint, args.qa_frames)充電する電気にも通り道が要る
確定前の電池残量で判定してまとめて更新し、電気の収支を合わせる。
ここから元のゲームの状況 A です。3期間の予報は順に、①発電3で A 必須1・B 任意1、②発電1で A 必須1、③発電0で B 必須2 です。電池は空の状態で始まります。Plan に charge(充電)と discharge(放電)が加わり、4つの値をそれぞれ0〜2で設定します。電池には3まで貯められますが、1期間に充電・放電できるのは、それぞれ2までです。
大事なのは、充電する電気も、発電所から電線を通って東側の電池まで運ぶ、という決まりです。幹線に流れる量=a+b+charge、西=a、東=b+charge です。充電を「余った電気をタダで貯める処理」にしてはいけません。一方、放電は、すでに東側にある電池から B へ直接届くので、この架空のルールでは幹線・支線を使いません。B に届く量は b+discharge です。現実の電気設備の配線や安全性を表すルールではありません。
legal は、新しい残量を計算する前に、次の3つを調べます。同じ期間に充電と放電を両方しようとしたら SIMULTANEOUS(同時)、放電する量が確定前の残量 State.battery より多ければ EMPTY(空)、更新後の残量が3を超えるなら FULL(満杯)です。同じ期間に充電する予定の電気を、先に放電で使うことはできません。これらを全部検査してから、battery=確定前の残量+充電−放電と、ほかの帳簿をまとめて計算します。
状況 A は、最初の期間に飾りを点けるのをがまんし、(a,b,charge,discharge)=(1,0,2,0) で電気を2貯めれば解けます。次は (1,0,0,0)、最後は (0,0,0,2) です。発電が0の最後の期間でも、貯めておいた2で B の必須を満たせます。記録には、確定前と確定後の電池の残量を両方残すので、あとから電気が勝手に増えていないかを調べられます。電気の収支の式(保存式)は「今期の発電+確定前の電池=A への供給+B へ直接送った量+放電+抑制+確定後の電池」です。
下のコードは 03_battery.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
if plan.charge and plan.discharge:
return "simultaneous"
if plan.discharge > state.battery:
return "empty"
if state.battery + plan.charge - plan.discharge > 3:
return "full"実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 03_battery.py.venv/bin/python 03_battery.pycharge,discharge) です。3つの期間に順に (1,0,2,0)、(1,0,0,0)、(0,0,0,2) を確定します。電池は 0→2→2→0 と変わり、WON、MISS 0、OPTIONAL 0、CURTAILED 0 になります。上下キーで選ぶ枠は、確定したあとも同じ項目に残り、値だけが0に戻ります。- 充電する電気も、幹線と東の支線を通る
- 放電できるのは、確定前の残量まで
- 発電と電池を合わせて、収支が合う
State に足してから放電を検査すると、「確定前の残量まで」という決まりが崩れます。また、東に流れる量を b だけで計算すると、B1(B に1)+充電2(合計3)を、容量2の支線に間違って通せてしまいます。最初の期間だけ (1,1,1,0) で飾りを点け、次を (1,0,0,0)、最後を (0,0,0,1) にしてみましょう。どれもルール上は正しいのに LOST(失敗)になり、合計 MISS 1・OPTIONAL 1 です。R で最初に戻って、最初の計画を変えなければならないのはなぜでしょうか。
このステップのコード全文(299行)
# 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.
#
# This applies to original game source and primitive artwork. Bitmap glyph data
# is derived from Noto Sans CJK under SIL Open Font License 1.1, and is covered
# by assets/NOTO-LICENSE.txt instead of MIT.
"""まちの停電係・独立教材 3/5。架空の整数配分ゲーム。"""
from dataclasses import dataclass, replace
@dataclass(frozen=True)
class Period:
generation: int
a: int
b: int
optional_a: int = 0
optional_b: int = 0
@dataclass(frozen=True)
class Level:
title: str
periods: tuple
battery: int = 0
@dataclass(frozen=True)
class Plan:
a: int = 0
b: int = 0
charge: int = 0
discharge: int = 0
@dataclass(frozen=True)
class Record:
period: int
old_battery: int
new_battery: int
plan: Plan
missing_a: int
missing_b: int
optional: int
curtailed: int
@dataclass(frozen=True)
class State:
t: int
battery: int
records: tuple = ()
missing: int = 0
optional: int = 0
curtailed: int = 0
outcome: str = "playing"
LEVELS = (Level("A: SAVE FOR NIGHT", (Period(3,1,0,0,1), Period(1,1,0), Period(0,0,2))),)
def initial(level):
return State(0, level.battery)
def loads(plan):
return plan.a + plan.b + plan.charge, plan.a, plan.b + plan.charge
def legal(state, level, plan):
if state.outcome != "playing" or state.t >= len(level.periods):
return "terminal"
if not isinstance(plan, Plan) or any(
type(value) is not int or not 0 <= value <= 2
for value in (plan.a, plan.b, plan.charge, plan.discharge)
):
return "range"
period = level.periods[state.t]
trunk, west, east = loads(plan)
if plan.charge and plan.discharge:
return "simultaneous"
if plan.discharge > state.battery:
return "empty"
if state.battery + plan.charge - plan.discharge > 3:
return "full"
if trunk > period.generation:
return "generation"
if trunk > 3:
return "trunk"
if west > 2:
return "west"
if east > 2:
return "east"
if plan.a > period.a + period.optional_a or plan.b + plan.discharge > period.b + period.optional_b:
return "demand"
return "commit"
def apply(state, level, plan):
reason = legal(state, level, plan)
if reason != "commit":
return state, reason
period = level.periods[state.t]
battery = state.battery + plan.charge - plan.discharge
missing_a = max(0, period.a - plan.a)
missing_b = max(0, period.b - plan.b - plan.discharge)
optional = max(0, plan.a - period.a) + max(0, plan.b + plan.discharge - period.b)
curtailed = period.generation - plan.a - plan.b - plan.charge
record = Record(state.t, state.battery, battery, plan, missing_a, missing_b, optional, curtailed)
after = State(state.t + 1, battery, state.records + (record,),
state.missing + missing_a + missing_b,
state.optional + optional, state.curtailed + curtailed)
if after.t == len(level.periods):
after = replace(after, outcome="won" if after.missing == 0 else "lost")
return after, reason
FIELDS = ('a', 'b', 'charge', 'discharge')
class App:
def __init__(self):
self.level_index = 0
self.reset()
@property
def level(self):
return LEVELS[self.level_index]
def reset(self):
self.state = initial(self.level)
self.plan = Plan()
self.selection = 0
self.gated = False
self.message = "READY"
def commit(self):
self.state, self.message = apply(self.state, self.level, self.plan)
if self.message == "commit":
self.plan = Plan()
def control(self, pressed, held):
# A new R wins even during release gating or after the result.
if "R" in pressed:
self.reset()
self.gated = True
return
if self.gated:
if not pressed and not held:
self.gated = False
return
if len(pressed | held) > 1:
self.message = "RELEASE ALL KEYS"
self.gated = True
return
if len(pressed) != 1:
return
key = next(iter(pressed))
self.gated = True
if self.state.outcome != "playing":
return
if key == "C":
self.plan = Plan()
elif key == "UP":
self.selection = (self.selection - 1) % len(FIELDS)
elif key == "DOWN":
self.selection = (self.selection + 1) % len(FIELDS)
elif key in ("LEFT", "RIGHT"):
field = FIELDS[self.selection]
value = getattr(self.plan, field) + (1 if key == "RIGHT" else -1)
self.plan = replace(self.plan, **{field: max(0, min(2, value))})
elif key in ("Z", "SPACE"):
self.commit()
def draw(self, p):
p.cls(1)
s, level, plan = self.state, self.level, self.plan
period = level.periods[min(s.t, len(level.periods)-1)]
trunk, west, east = loads(plan)
after, reason = apply(s, level, plan)
p.text(12,10,"TOWN POWER / STAGE 3",10)
p.text(12,25,level.title,7)
p.text(400,10,"PERIOD "+str(s.t)+"/"+str(len(level.periods)),7)
p.text(400,25,s.outcome.upper(),11 if s.outcome=="won" else 7)
p.rect(12,44,552,112,2)
p.rectb(28,76,76,46,6)
p.text(38,86,"GENERATOR",7)
p.text(38,102,"G "+str(period.generation),10)
p.line(104,98,235,98,10)
p.circ(239,98,4,10)
p.line(239,98,239,66,10)
p.line(239,66,356,66,10)
p.line(239,98,239,130,10)
p.line(239,130,356,130,10)
p.rectb(356,52,70,29,6)
p.rectb(356,116,70,29,6)
p.text(363,58,"WEST A "+str(plan.a),7)
p.text(363,124,"EAST B "+str(plan.b+plan.discharge),7)
p.text(115,82,"TOTAL "+str(trunk),7)
p.text(115,112,"TRUNK "+str(trunk)+"/3",8 if trunk>3 else 7)
p.text(267,54,"W "+str(west)+"/2",8 if west>2 else 7)
p.text(267,118,"E "+str(east)+"/2",8 if east>2 else 7)
p.rectb(477,64,72,74,6)
p.text(483,70,"BATTERY",7)
p.text(483,85,str(s.battery)+" / 3",10)
p.text(483,101,"IN "+str(plan.charge),7)
p.text(483,116,"OUT "+str(plan.discharge),11)
p.line(426,130,477,130,10)
p.text(438,116,"<",11)
for x, label in ((16,"PERIOD"),(112,"G"),(200,"A NEED/+"),(290,"B NEED/+"),
(390,"MISS"),(442,"OPT"),(498,"CUT")):
p.text(x,167,label,6)
for index, d in enumerate(level.periods):
y = 184 + 16*index
if index == s.t:
p.rect(12,y-2,552,13,3)
for x, value in ((16,index),(112,d.generation),(200,f"{d.a}/{d.optional_a}"),
(290,f"{d.b}/{d.optional_b}")):
p.text(x,y,str(value),7)
if index < len(s.records):
r = s.records[index]
for x, value in ((390,r.missing_a+r.missing_b),(442,r.optional),(498,r.curtailed)):
p.text(x,y,str(value),11)
for index, field in enumerate(FIELDS):
x = 12 + 138*index
p.rectb(x,254,128,24,10 if index==self.selection else 5)
p.text(x+7,263,field.upper()+" "+str(getattr(plan,field)),7)
p.text(12,289,"PREVIEW: "+reason.upper(),11 if reason=="commit" else 8)
if reason=="commit":
p.text(220,289,f"MISS +{after.missing-s.missing} CUT +{after.curtailed-s.curtailed}",7)
p.text(12,322,f"TOTAL MISS {s.missing} OPTIONAL {s.optional} CURTAILED {s.curtailed}",7)
p.text(12,339,"LAST: "+self.message.upper(),10)
p.text(12,360,"UP/DOWN ROUTE LEFT/RIGHT AMOUNT SPACE/Z COMMIT",6)
p.text(12,376,"C CLEAR PLAN R RESET ESC QUIT",6)
p.text(12,392,"CHARGING USES TRUNK AND EAST / OLD BATTERY ONLY",6)
DEMO_KEYS = ('RIGHT', 'DOWN', 'DOWN', 'RIGHT', 'RIGHT', 'SPACE', 'DOWN', 'DOWN', 'RIGHT', 'SPACE', 'DOWN', 'DOWN', 'DOWN', 'RIGHT', 'RIGHT', 'SPACE')
KEYS = ('UP', 'DOWN', 'LEFT', 'RIGHT', 'SPACE', 'Z', 'C', 'R')
def main(headless=False, screenshot=None, demo=False, frames=None):
# Import Pyxel only after command-line validation and only for rendering.
from pathlib import Path
import json
import pyxel as p
app = App()
destination = Path(screenshot).resolve() if screenshot else None
if destination:
destination.parent.mkdir(parents=True, exist_ok=True)
limit = frames or (max(12, 2*len(DEMO_KEYS)+4) if demo else 12)
p.init(576,416,title="Town Power lesson 3",fps=30,display_scale=2,headless=headless)
keys = {name:getattr(p,"KEY_"+name) for name in KEYS}
def update():
if headless and p.frame_count >= limit:
if destination:
p.screenshot(str(destination),scale=1)
from dataclasses import asdict
print(json.dumps({"stage":3,"state":asdict(app.state),
"plan":asdict(app.plan),"message":app.message},sort_keys=True),flush=True)
p.quit()
return
if headless and demo:
index = p.frame_count // 2
pressed = {DEMO_KEYS[index]} if p.frame_count%2==0 and index<len(DEMO_KEYS) else set()
app.control(pressed, pressed)
else:
app.control({k for k,v in keys.items() if p.btnp(v)},
{k for k,v in keys.items() if p.btn(v)})
p.run(update,lambda:app.draw(p))
def parse_args(argv=None):
import argparse
parser = argparse.ArgumentParser(description="Independent Town Power lesson 3")
parser.add_argument("--headless-smoke",action="store_true")
parser.add_argument("--demo-checkpoint",action="store_true")
parser.add_argument("--screenshot",metavar="FILE.png")
parser.add_argument("--qa-frames",type=int,metavar="12..600")
args = parser.parse_args(argv)
if not args.headless_smoke and (args.demo_checkpoint or args.screenshot or args.qa_frames is not None):
parser.error("QA options require --headless-smoke")
if args.qa_frames is not None and not 12 <= args.qa_frames <= 600:
parser.error("--qa-frames must be 12..600")
if args.demo_checkpoint and args.qa_frames is not None and args.qa_frames < 2*len(DEMO_KEYS)+4:
parser.error("--qa-frames is shorter than the demo")
if args.screenshot and not args.screenshot.lower().endswith(".png"):
parser.error("--screenshot must end in .png")
return args
if __name__ == "__main__":
args = parse_args()
main(args.headless_smoke, args.screenshot, args.demo_checkpoint, args.qa_frames)今期の合法性と、全期を守れるかを分ける
全部の候補を試す小さな探索と、計算結果の使い回し(メモ化)。
状況 A/B/C の3つを 1/2/3 キーで選べるようにし、残りの期間すべてで必須を守れるかを計算します。状況を切り替えると、その状況の最初の電池残量に戻り、計画も集計も消えます。元のゲームの状況 B だけは、電池1で始まります。入力欄の PREVIEW: COMMIT は「今の期間の計画がルール上正しいか」、ALL REQUIRED STILL POSSIBLE は「この計画を選んだあとも、すべての期間で必須を守れるか」で、別々の問いです。
4つの値がそれぞれ 0・1・2 なので、計画の候補は3の4乗=81通りしかありません。itertools.product で全候補 PLANS を一度だけ作っておきます。continuation は、各候補を apply で試し、ルール違反か必須不足になる候補を捨てます。残った候補について、次の期間を同じ関数で調べ(再帰)、最後の期間まで行けたら (0, 空の計画の列) を返します。成功する道が1つもなければ None です。成功する候補がいくつもあるときは、残りの期間で点く飾り(任意)の数が一番多い道を残します。
計算結果を覚えておくときの目印(キー)は、level・t・battery の3つです。必須が足りなくなる候補は最初に除いているので、この先成功できるかを調べるだけなら、過去の Record を持ち歩く必要はありません。@lru_cache は、同じ「残りの問題」を何度も計算しないための仕組みです。ただし、実際のプレイでもう不足が出ているかは、forecast で after.missing==0 を確かめるので、過去の失敗を忘れて YES と答えることはありません。
状況 C の最初に (1,1,0,0) を選ぶと、今の期間の必須は満たし、飾りも1つ点きます。しかし次の期間は、B の必須1と充電が同じ東の支線を使うので、充電できるのは1までです。発電0の3期目に必要な2を用意できず、全期間の予測は NO になります。最初に (1,0,1,0) で1を貯めておけば YES です。予測にも確定と同じ apply を使うので、予測用と確定用で違うルールを書いてしまう危険を減らせます。
下のコードは 04_forecast.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
child = continuation(level, t + 1, after.battery)
if child is None:
continue
candidate = after.optional + child[0], (plan,) + child[1]
if best is None or candidate[0] > best[0]:
best = candidate実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 04_forecast.py.venv/bin/python 04_forecast.py- 「今期は正しい」と「最後まで達成できる」を区別する
- 81通りの候補を、確定と同じ
applyで試す - 予測しても、今の状態は少しも変わらない
State や履歴を書き換えないようにします。この探索は、最大4期間の小さな手作りの盤面向けです。現実の設備や、とても大きな盤面で最も良い計画を見つけられることは保証しません。2 で B を選び、①(2,0,1,0)→(1,0,0,0)→(0,0,0,2) と、②(2,1,0,0)→(0,0,1,0)→(0,0,0,2) を、間で R を押して比べてみましょう。どちらも WON・任意1・抑制1ですが、途中の電池の残量が違います。
このステップのコード全文(345行)
# 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.
#
# This applies to original game source and primitive artwork. Bitmap glyph data
# is derived from Noto Sans CJK under SIL Open Font License 1.1, and is covered
# by assets/NOTO-LICENSE.txt instead of MIT.
"""まちの停電係・独立教材 4/5。架空の整数配分ゲーム。"""
from dataclasses import dataclass, replace
@dataclass(frozen=True)
class Period:
generation: int
a: int
b: int
optional_a: int = 0
optional_b: int = 0
@dataclass(frozen=True)
class Level:
title: str
periods: tuple
battery: int = 0
@dataclass(frozen=True)
class Plan:
a: int = 0
b: int = 0
charge: int = 0
discharge: int = 0
@dataclass(frozen=True)
class Record:
period: int
old_battery: int
new_battery: int
plan: Plan
missing_a: int
missing_b: int
optional: int
curtailed: int
@dataclass(frozen=True)
class State:
t: int
battery: int
records: tuple = ()
missing: int = 0
optional: int = 0
curtailed: int = 0
outcome: str = "playing"
LEVELS = (
Level("A: SAVE FOR NIGHT", (
Period(3,1,0,0,1), Period(1,1,0), Period(0,0,2))),
Level("B: GENERATION IS NOT CAPACITY", (
Period(4,2,0,0,1), Period(1,0,0,1,0), Period(0,0,2)), 1),
Level("C: TWO USES OF EAST WIRE", (
Period(2,1,0,0,1), Period(3,0,1,1,0),
Period(0,0,2), Period(2,1,1))),
)
def initial(level):
return State(0, level.battery)
def loads(plan):
return plan.a + plan.b + plan.charge, plan.a, plan.b + plan.charge
def legal(state, level, plan):
if state.outcome != "playing" or state.t >= len(level.periods):
return "terminal"
if not isinstance(plan, Plan) or any(
type(value) is not int or not 0 <= value <= 2
for value in (plan.a, plan.b, plan.charge, plan.discharge)
):
return "range"
period = level.periods[state.t]
trunk, west, east = loads(plan)
if plan.charge and plan.discharge:
return "simultaneous"
if plan.discharge > state.battery:
return "empty"
if state.battery + plan.charge - plan.discharge > 3:
return "full"
if trunk > period.generation:
return "generation"
if trunk > 3:
return "trunk"
if west > 2:
return "west"
if east > 2:
return "east"
if plan.a > period.a + period.optional_a or plan.b + plan.discharge > period.b + period.optional_b:
return "demand"
return "commit"
def apply(state, level, plan):
reason = legal(state, level, plan)
if reason != "commit":
return state, reason
period = level.periods[state.t]
battery = state.battery + plan.charge - plan.discharge
missing_a = max(0, period.a - plan.a)
missing_b = max(0, period.b - plan.b - plan.discharge)
optional = max(0, plan.a - period.a) + max(0, plan.b + plan.discharge - period.b)
curtailed = period.generation - plan.a - plan.b - plan.charge
record = Record(state.t, state.battery, battery, plan, missing_a, missing_b, optional, curtailed)
after = State(state.t + 1, battery, state.records + (record,),
state.missing + missing_a + missing_b,
state.optional + optional, state.curtailed + curtailed)
if after.t == len(level.periods):
after = replace(after, outcome="won" if after.missing == 0 else "lost")
return after, reason
from itertools import product
from functools import lru_cache
PLANS = tuple(Plan(*values) for values in product(range(3), repeat=4))
@lru_cache(maxsize=None)
def continuation(level, t, battery):
"""Return the best optional score and a zero-missing suffix, or None."""
if t == len(level.periods):
return 0, ()
state = State(t, battery)
best = None
for plan in PLANS:
after, reason = apply(state, level, plan)
if reason != "commit" or after.missing:
continue
child = continuation(level, t + 1, after.battery)
if child is None:
continue
candidate = after.optional + child[0], (plan,) + child[1]
if best is None or candidate[0] > best[0]:
best = candidate
return best
def forecast(state, level, plan):
after, reason = apply(state, level, plan)
possible = (reason == "commit" and after.missing == 0
and continuation(level, after.t, after.battery) is not None)
return after, reason, possible
FIELDS = ('a', 'b', 'charge', 'discharge')
class App:
def __init__(self):
self.level_index = 0
self.reset()
@property
def level(self):
return LEVELS[self.level_index]
def reset(self):
self.state = initial(self.level)
self.plan = Plan()
self.selection = 0
self.gated = False
self.message = "READY"
def commit(self):
self.state, self.message = apply(self.state, self.level, self.plan)
if self.message == "commit":
self.plan = Plan()
def control(self, pressed, held):
# A new R wins even during release gating or after the result.
if "R" in pressed:
self.reset()
self.gated = True
return
if self.gated:
if not pressed and not held:
self.gated = False
return
if len(pressed | held) > 1:
self.message = "RELEASE ALL KEYS"
self.gated = True
return
if len(pressed) != 1:
return
key = next(iter(pressed))
self.gated = True
if key in ("1", "2", "3"):
self.level_index = int(key)-1
self.reset()
self.gated = True
return
if self.state.outcome != "playing":
return
if key == "C":
self.plan = Plan()
elif key == "UP":
self.selection = (self.selection - 1) % len(FIELDS)
elif key == "DOWN":
self.selection = (self.selection + 1) % len(FIELDS)
elif key in ("LEFT", "RIGHT"):
field = FIELDS[self.selection]
value = getattr(self.plan, field) + (1 if key == "RIGHT" else -1)
self.plan = replace(self.plan, **{field: max(0, min(2, value))})
elif key in ("Z", "SPACE"):
self.commit()
def draw(self, p):
p.cls(1)
s, level, plan = self.state, self.level, self.plan
period = level.periods[min(s.t, len(level.periods)-1)]
trunk, west, east = loads(plan)
after, reason = apply(s, level, plan)
p.text(12,10,"TOWN POWER / STAGE 4",10)
p.text(12,25,level.title,7)
p.text(400,10,"PERIOD "+str(s.t)+"/"+str(len(level.periods)),7)
p.text(400,25,s.outcome.upper(),11 if s.outcome=="won" else 7)
p.rect(12,44,552,112,2)
p.rectb(28,76,76,46,6)
p.text(38,86,"GENERATOR",7)
p.text(38,102,"G "+str(period.generation),10)
p.line(104,98,235,98,10)
p.circ(239,98,4,10)
p.line(239,98,239,66,10)
p.line(239,66,356,66,10)
p.line(239,98,239,130,10)
p.line(239,130,356,130,10)
p.rectb(356,52,70,29,6)
p.rectb(356,116,70,29,6)
p.text(363,58,"WEST A "+str(plan.a),7)
p.text(363,124,"EAST B "+str(plan.b+plan.discharge),7)
p.text(115,82,"TOTAL "+str(trunk),7)
p.text(115,112,"TRUNK "+str(trunk)+"/3",8 if trunk>3 else 7)
p.text(267,54,"W "+str(west)+"/2",8 if west>2 else 7)
p.text(267,118,"E "+str(east)+"/2",8 if east>2 else 7)
p.rectb(477,64,72,74,6)
p.text(483,70,"BATTERY",7)
p.text(483,85,str(s.battery)+" / 3",10)
p.text(483,101,"IN "+str(plan.charge),7)
p.text(483,116,"OUT "+str(plan.discharge),11)
p.line(426,130,477,130,10)
p.text(438,116,"<",11)
for x, label in ((16,"PERIOD"),(112,"G"),(200,"A NEED/+"),(290,"B NEED/+"),
(390,"MISS"),(442,"OPT"),(498,"CUT")):
p.text(x,167,label,6)
for index, d in enumerate(level.periods):
y = 184 + 16*index
if index == s.t:
p.rect(12,y-2,552,13,3)
for x, value in ((16,index),(112,d.generation),(200,f"{d.a}/{d.optional_a}"),
(290,f"{d.b}/{d.optional_b}")):
p.text(x,y,str(value),7)
if index < len(s.records):
r = s.records[index]
for x, value in ((390,r.missing_a+r.missing_b),(442,r.optional),(498,r.curtailed)):
p.text(x,y,str(value),11)
for index, field in enumerate(FIELDS):
x = 12 + 138*index
p.rectb(x,254,128,24,10 if index==self.selection else 5)
p.text(x+7,263,field.upper()+" "+str(getattr(plan,field)),7)
p.text(12,289,"PREVIEW: "+reason.upper(),11 if reason=="commit" else 8)
if reason=="commit":
p.text(220,289,f"MISS +{after.missing-s.missing} CUT +{after.curtailed-s.curtailed}",7)
_, _, possible = forecast(s, level, plan)
p.text(12,305,"ALL REQUIRED STILL POSSIBLE: "+("YES" if possible else "NO"),11 if possible else 8)
p.text(12,322,f"TOTAL MISS {s.missing} OPTIONAL {s.optional} CURTAILED {s.curtailed}",7)
p.text(12,339,"LAST: "+self.message.upper(),10)
p.text(12,360,"UP/DOWN ROUTE LEFT/RIGHT AMOUNT SPACE/Z COMMIT",6)
p.text(12,376,"C CLEAR PLAN R RESET ESC QUIT",6)
p.text(12,392,"1/2/3 LEVEL / READ-ONLY FUTURE SEARCH",6)
DEMO_KEYS = ('3', 'RIGHT', 'DOWN', 'DOWN', 'RIGHT', 'SPACE', 'DOWN', 'DOWN', 'RIGHT', 'DOWN', 'RIGHT', 'DOWN', 'RIGHT', 'SPACE', 'DOWN', 'RIGHT', 'RIGHT', 'SPACE', 'DOWN', 'RIGHT', 'DOWN', 'RIGHT', 'SPACE')
KEYS = ('UP', 'DOWN', 'LEFT', 'RIGHT', 'SPACE', 'Z', 'C', 'R', '1', '2', '3')
def main(headless=False, screenshot=None, demo=False, frames=None):
# Import Pyxel only after command-line validation and only for rendering.
from pathlib import Path
import json
import pyxel as p
app = App()
destination = Path(screenshot).resolve() if screenshot else None
if destination:
destination.parent.mkdir(parents=True, exist_ok=True)
limit = frames or (max(12, 2*len(DEMO_KEYS)+4) if demo else 12)
p.init(576,416,title="Town Power lesson 4",fps=30,display_scale=2,headless=headless)
keys = {name:getattr(p,"KEY_"+name) for name in KEYS}
def update():
if headless and p.frame_count >= limit:
if destination:
p.screenshot(str(destination),scale=1)
from dataclasses import asdict
print(json.dumps({"stage":4,"state":asdict(app.state),
"plan":asdict(app.plan),"message":app.message},sort_keys=True),flush=True)
p.quit()
return
if headless and demo:
index = p.frame_count // 2
pressed = {DEMO_KEYS[index]} if p.frame_count%2==0 and index<len(DEMO_KEYS) else set()
app.control(pressed, pressed)
else:
app.control({k for k,v in keys.items() if p.btnp(v)},
{k for k,v in keys.items() if p.btn(v)})
p.run(update,lambda:app.draw(p))
def parse_args(argv=None):
import argparse
parser = argparse.ArgumentParser(description="Independent Town Power lesson 4")
parser.add_argument("--headless-smoke",action="store_true")
parser.add_argument("--demo-checkpoint",action="store_true")
parser.add_argument("--screenshot",metavar="FILE.png")
parser.add_argument("--qa-frames",type=int,metavar="12..600")
args = parser.parse_args(argv)
if not args.headless_smoke and (args.demo_checkpoint or args.screenshot or args.qa_frames is not None):
parser.error("QA options require --headless-smoke")
if args.qa_frames is not None and not 12 <= args.qa_frames <= 600:
parser.error("--qa-frames must be 12..600")
if args.demo_checkpoint and args.qa_frames is not None and args.qa_frames < 2*len(DEMO_KEYS)+4:
parser.error("--qa-frames is shorter than the demo")
if args.screenshot and not args.screenshot.lower().endswith(".png"):
parser.error("--screenshot must end in .png")
return args
if __name__ == "__main__":
args = parse_args()
main(args.headless_smoke, args.screenshot, args.demo_checkpoint, args.qa_frames)電池と提出した計画を一緒に戻す
状態全体の履歴と、すべてを一度に戻す Undo。
最後に Timeline を加えます。今の State と、受け付けた確定の直前の (State, Plan) の組を、history というタプルに並べて持ちます。X を押すと最後の組を取り出し、期間、電池、すべての期間の記録、不足・任意・抑制の合計、結果、そして確定したときの計画を、まとめて元に戻します。電池の数字だけを戻して記録を残すと、画面の表示と帳簿が食い違ってしまいます。
step は、apply が前と違う State を返したときだけ、履歴に追加します。TRUNK、EAST、EMPTY、SIMULTANEOUS などで受け付けなかったときは同じ Timeline を返すので、X を余分に押して失敗した操作を飛ばす必要はありません。選択枠の移動や、まだ確定していない計画の編集は、期間の判断ではないので履歴に積みません。C は入力中の計画だけを0にし、R は選んでいる状況を期間0・最初の電池残量に戻して、履歴も消します。
結果が出たあとも X を先に処理するので、WON/LOST から1つ前の期間へ戻れます。元のアプリと同じく、履歴が空のときに X を押すと、State はそのままで、計画を0にして NO_HISTORY を表示します。Undo で戻ってくる計画は「今編集していた計画」ではなく、「その期間に確定した計画」です。たとえば C の最後の期間で成功したあとに X を押すと、t=3、電池0に戻り、A1/B1 の計画が戻ってきます。もう一度 Space を押すだけで、同じ成功をくり返せます。
キーの処理では、1つの操作のたびに、操作キーが全部離されるまで待ち、ふつうの同時押しは受け付けません。ただし、新しく押した R は、待っている間でも優先します。押しっぱなしのキーが、戻した後の状態に流れ込まないようにするための、教材用の厳しめの方式です。元のゲームのルール・探索・Undo の決まりはそのまま残していますが、表紙、一時停止、専用の充電線と比べるモード、再現ファイル、日本語の文字の表示は、教材には入れていません。次は元のゲームの model.py と app.py を読むと、比較用の設定(フラグ)で、東の支線の計算から charge だけが外れるしくみを確かめられます。
下のコードは 05_undo.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
after, reason = apply(timeline.state, level, plan)
if after == timeline.state:
return timeline, reason
return Timeline(after, timeline.history + ((timeline.state, plan),)), reason実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 05_undo.py.venv/bin/python 05_undo.py- 電池・すべての帳簿・結果も Undo で戻す
- その期間に確定した計画を戻す
- 受け付けなかった操作や予告では、履歴を増やさない
State 全体と、確定した Plan を保存します。予告を履歴に積んだり、受け付けなかった操作のたびに履歴を増やしたりしないでください。R で A に戻すのではなく、1 で A を選びます。(1,0,2,0) を確定すると電池2、X を押すと期間0・電池0・計画 (1,0,2,0) に戻ります。もう一度 Space を押すと電池2です。何回戻してくり返しても、電池が4、6…と増えていかないことを確かめましょう。
このステップのコード全文(371行)
# 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.
#
# This applies to original game source and primitive artwork. Bitmap glyph data
# is derived from Noto Sans CJK under SIL Open Font License 1.1, and is covered
# by assets/NOTO-LICENSE.txt instead of MIT.
"""まちの停電係・独立教材 5/5。架空の整数配分ゲーム。"""
from dataclasses import dataclass, replace
@dataclass(frozen=True)
class Period:
generation: int
a: int
b: int
optional_a: int = 0
optional_b: int = 0
@dataclass(frozen=True)
class Level:
title: str
periods: tuple
battery: int = 0
@dataclass(frozen=True)
class Plan:
a: int = 0
b: int = 0
charge: int = 0
discharge: int = 0
@dataclass(frozen=True)
class Record:
period: int
old_battery: int
new_battery: int
plan: Plan
missing_a: int
missing_b: int
optional: int
curtailed: int
@dataclass(frozen=True)
class State:
t: int
battery: int
records: tuple = ()
missing: int = 0
optional: int = 0
curtailed: int = 0
outcome: str = "playing"
LEVELS = (
Level("A: SAVE FOR NIGHT", (
Period(3,1,0,0,1), Period(1,1,0), Period(0,0,2))),
Level("B: GENERATION IS NOT CAPACITY", (
Period(4,2,0,0,1), Period(1,0,0,1,0), Period(0,0,2)), 1),
Level("C: TWO USES OF EAST WIRE", (
Period(2,1,0,0,1), Period(3,0,1,1,0),
Period(0,0,2), Period(2,1,1))),
)
def initial(level):
return State(0, level.battery)
def loads(plan):
return plan.a + plan.b + plan.charge, plan.a, plan.b + plan.charge
def legal(state, level, plan):
if state.outcome != "playing" or state.t >= len(level.periods):
return "terminal"
if not isinstance(plan, Plan) or any(
type(value) is not int or not 0 <= value <= 2
for value in (plan.a, plan.b, plan.charge, plan.discharge)
):
return "range"
period = level.periods[state.t]
trunk, west, east = loads(plan)
if plan.charge and plan.discharge:
return "simultaneous"
if plan.discharge > state.battery:
return "empty"
if state.battery + plan.charge - plan.discharge > 3:
return "full"
if trunk > period.generation:
return "generation"
if trunk > 3:
return "trunk"
if west > 2:
return "west"
if east > 2:
return "east"
if plan.a > period.a + period.optional_a or plan.b + plan.discharge > period.b + period.optional_b:
return "demand"
return "commit"
def apply(state, level, plan):
reason = legal(state, level, plan)
if reason != "commit":
return state, reason
period = level.periods[state.t]
battery = state.battery + plan.charge - plan.discharge
missing_a = max(0, period.a - plan.a)
missing_b = max(0, period.b - plan.b - plan.discharge)
optional = max(0, plan.a - period.a) + max(0, plan.b + plan.discharge - period.b)
curtailed = period.generation - plan.a - plan.b - plan.charge
record = Record(state.t, state.battery, battery, plan, missing_a, missing_b, optional, curtailed)
after = State(state.t + 1, battery, state.records + (record,),
state.missing + missing_a + missing_b,
state.optional + optional, state.curtailed + curtailed)
if after.t == len(level.periods):
after = replace(after, outcome="won" if after.missing == 0 else "lost")
return after, reason
from itertools import product
from functools import lru_cache
PLANS = tuple(Plan(*values) for values in product(range(3), repeat=4))
@lru_cache(maxsize=None)
def continuation(level, t, battery):
"""Return the best optional score and a zero-missing suffix, or None."""
if t == len(level.periods):
return 0, ()
state = State(t, battery)
best = None
for plan in PLANS:
after, reason = apply(state, level, plan)
if reason != "commit" or after.missing:
continue
child = continuation(level, t + 1, after.battery)
if child is None:
continue
candidate = after.optional + child[0], (plan,) + child[1]
if best is None or candidate[0] > best[0]:
best = candidate
return best
def forecast(state, level, plan):
after, reason = apply(state, level, plan)
possible = (reason == "commit" and after.missing == 0
and continuation(level, after.t, after.battery) is not None)
return after, reason, possible
@dataclass(frozen=True)
class Timeline:
state: State
history: tuple = ()
def step(timeline, level, plan):
after, reason = apply(timeline.state, level, plan)
if after == timeline.state:
return timeline, reason
return Timeline(after, timeline.history + ((timeline.state, plan),)), reason
def undo(timeline):
if not timeline.history:
return timeline, Plan(), "no_history"
state, plan = timeline.history[-1]
return Timeline(state, timeline.history[:-1]), plan, "undo"
FIELDS = ('a', 'b', 'charge', 'discharge')
class App:
def __init__(self):
self.level_index = 0
self.reset()
@property
def level(self):
return LEVELS[self.level_index]
@property
def state(self):
return self.timeline.state
def reset(self):
self.timeline = Timeline(initial(self.level))
self.plan = Plan()
self.selection = 0
self.gated = False
self.message = "READY"
def commit(self):
self.timeline, self.message = step(self.timeline, self.level, self.plan)
if self.message == "commit":
self.plan = Plan()
def control(self, pressed, held):
# A new R wins even during release gating or after the result.
if "R" in pressed:
self.reset()
self.gated = True
return
if self.gated:
if not pressed and not held:
self.gated = False
return
if len(pressed | held) > 1:
self.message = "RELEASE ALL KEYS"
self.gated = True
return
if len(pressed) != 1:
return
key = next(iter(pressed))
self.gated = True
if key in ("1", "2", "3"):
self.level_index = int(key)-1
self.reset()
self.gated = True
return
if key == "X":
self.timeline, self.plan, self.message = undo(self.timeline)
return
if self.state.outcome != "playing":
return
if key == "C":
self.plan = Plan()
elif key == "UP":
self.selection = (self.selection - 1) % len(FIELDS)
elif key == "DOWN":
self.selection = (self.selection + 1) % len(FIELDS)
elif key in ("LEFT", "RIGHT"):
field = FIELDS[self.selection]
value = getattr(self.plan, field) + (1 if key == "RIGHT" else -1)
self.plan = replace(self.plan, **{field: max(0, min(2, value))})
elif key in ("Z", "SPACE"):
self.commit()
def draw(self, p):
p.cls(1)
s, level, plan = self.state, self.level, self.plan
period = level.periods[min(s.t, len(level.periods)-1)]
trunk, west, east = loads(plan)
after, reason = apply(s, level, plan)
p.text(12,10,"TOWN POWER / STAGE 5",10)
p.text(12,25,level.title,7)
p.text(400,10,"PERIOD "+str(s.t)+"/"+str(len(level.periods)),7)
p.text(400,25,s.outcome.upper(),11 if s.outcome=="won" else 7)
p.rect(12,44,552,112,2)
p.rectb(28,76,76,46,6)
p.text(38,86,"GENERATOR",7)
p.text(38,102,"G "+str(period.generation),10)
p.line(104,98,235,98,10)
p.circ(239,98,4,10)
p.line(239,98,239,66,10)
p.line(239,66,356,66,10)
p.line(239,98,239,130,10)
p.line(239,130,356,130,10)
p.rectb(356,52,70,29,6)
p.rectb(356,116,70,29,6)
p.text(363,58,"WEST A "+str(plan.a),7)
p.text(363,124,"EAST B "+str(plan.b+plan.discharge),7)
p.text(115,82,"TOTAL "+str(trunk),7)
p.text(115,112,"TRUNK "+str(trunk)+"/3",8 if trunk>3 else 7)
p.text(267,54,"W "+str(west)+"/2",8 if west>2 else 7)
p.text(267,118,"E "+str(east)+"/2",8 if east>2 else 7)
p.rectb(477,64,72,74,6)
p.text(483,70,"BATTERY",7)
p.text(483,85,str(s.battery)+" / 3",10)
p.text(483,101,"IN "+str(plan.charge),7)
p.text(483,116,"OUT "+str(plan.discharge),11)
p.line(426,130,477,130,10)
p.text(438,116,"<",11)
for x, label in ((16,"PERIOD"),(112,"G"),(200,"A NEED/+"),(290,"B NEED/+"),
(390,"MISS"),(442,"OPT"),(498,"CUT")):
p.text(x,167,label,6)
for index, d in enumerate(level.periods):
y = 184 + 16*index
if index == s.t:
p.rect(12,y-2,552,13,3)
for x, value in ((16,index),(112,d.generation),(200,f"{d.a}/{d.optional_a}"),
(290,f"{d.b}/{d.optional_b}")):
p.text(x,y,str(value),7)
if index < len(s.records):
r = s.records[index]
for x, value in ((390,r.missing_a+r.missing_b),(442,r.optional),(498,r.curtailed)):
p.text(x,y,str(value),11)
for index, field in enumerate(FIELDS):
x = 12 + 138*index
p.rectb(x,254,128,24,10 if index==self.selection else 5)
p.text(x+7,263,field.upper()+" "+str(getattr(plan,field)),7)
p.text(12,289,"PREVIEW: "+reason.upper(),11 if reason=="commit" else 8)
if reason=="commit":
p.text(220,289,f"MISS +{after.missing-s.missing} CUT +{after.curtailed-s.curtailed}",7)
_, _, possible = forecast(s, level, plan)
p.text(12,305,"ALL REQUIRED STILL POSSIBLE: "+("YES" if possible else "NO"),11 if possible else 8)
p.text(12,322,f"TOTAL MISS {s.missing} OPTIONAL {s.optional} CURTAILED {s.curtailed}",7)
p.text(12,339,"LAST: "+self.message.upper(),10)
p.text(12,360,"UP/DOWN ROUTE LEFT/RIGHT AMOUNT SPACE/Z COMMIT",6)
p.text(12,376,"C CLEAR PLAN R RESET ESC QUIT",6)
p.text(12,392,"1/2/3 LEVEL / X UNDO STATE AND SUBMITTED PLAN",6)
DEMO_KEYS = ('3', 'RIGHT', 'DOWN', 'DOWN', 'RIGHT', 'SPACE', 'DOWN', 'DOWN', 'RIGHT', 'DOWN', 'RIGHT', 'DOWN', 'RIGHT', 'SPACE', 'DOWN', 'RIGHT', 'RIGHT', 'SPACE', 'DOWN', 'RIGHT', 'DOWN', 'RIGHT', 'SPACE', 'X')
KEYS = ('UP', 'DOWN', 'LEFT', 'RIGHT', 'SPACE', 'Z', 'C', 'R', '1', '2', '3', 'X')
def main(headless=False, screenshot=None, demo=False, frames=None):
# Import Pyxel only after command-line validation and only for rendering.
from pathlib import Path
import json
import pyxel as p
app = App()
destination = Path(screenshot).resolve() if screenshot else None
if destination:
destination.parent.mkdir(parents=True, exist_ok=True)
limit = frames or (max(12, 2*len(DEMO_KEYS)+4) if demo else 12)
p.init(576,416,title="Town Power lesson 5",fps=30,display_scale=2,headless=headless)
keys = {name:getattr(p,"KEY_"+name) for name in KEYS}
def update():
if headless and p.frame_count >= limit:
if destination:
p.screenshot(str(destination),scale=1)
from dataclasses import asdict
print(json.dumps({"stage":5,"state":asdict(app.state),
"plan":asdict(app.plan),"message":app.message},sort_keys=True),flush=True)
p.quit()
return
if headless and demo:
index = p.frame_count // 2
pressed = {DEMO_KEYS[index]} if p.frame_count%2==0 and index<len(DEMO_KEYS) else set()
app.control(pressed, pressed)
else:
app.control({k for k,v in keys.items() if p.btnp(v)},
{k for k,v in keys.items() if p.btn(v)})
p.run(update,lambda:app.draw(p))
def parse_args(argv=None):
import argparse
parser = argparse.ArgumentParser(description="Independent Town Power lesson 5")
parser.add_argument("--headless-smoke",action="store_true")
parser.add_argument("--demo-checkpoint",action="store_true")
parser.add_argument("--screenshot",metavar="FILE.png")
parser.add_argument("--qa-frames",type=int,metavar="12..600")
args = parser.parse_args(argv)
if not args.headless_smoke and (args.demo_checkpoint or args.screenshot or args.qa_frames is not None):
parser.error("QA options require --headless-smoke")
if args.qa_frames is not None and not 12 <= args.qa_frames <= 600:
parser.error("--qa-frames must be 12..600")
if args.demo_checkpoint and args.qa_frames is not None and args.qa_frames < 2*len(DEMO_KEYS)+4:
parser.error("--qa-frames is shorter than the demo")
if args.screenshot and not args.screenshot.lower().endswith(".png"):
parser.error("--screenshot must end in .png")
return args
if __name__ == "__main__":
args = parse_args()
main(args.headless_smoke, args.screenshot, args.demo_checkpoint, args.qa_frames)仕組みがわかったら、元の完成版を読んでみよう。
ルールのまとめ:幹線3・西2・東2、電池3、各値0〜2。充電は発電・幹線・東の支線を使い、放電は確定前の残量から東へ直接届きます。同じ期間の充電と放電は禁止、需要の合計を超えて送るのも禁止。すべての期間で必須の不足が0なら成功です。状況 A/B/C で使う「共有の電線」のルールを、03〜05で少しずつ加えます。
ここまでに作ったのは、元のゲームの仕組みだけを取り出して作り直した「簡略版」です。元のゲームのソースは手を加えずに、別のZIP(元の完成版ソース)に入れてあります。
| 比べる点 | 5ステップの教材 | 元の完成版 |
|---|---|---|
| 段階 | 01/02 は1期間・G4・各地区必須1任意1の練習。01 は電線の容量なし。03 は元のゲームの状況 A、04/05 は状況 A・B・C。 | 最初から、状況 A・B・C の電池つきのルールと、この先を調べる探索があります。 |
| 比較 | 共有の電線だけ。 | B キーで、充電だけが東の支線を通らない「専用線モード」に切り替えられます。幹線と発電は共有のままです。 |
| 操作 | 操作ごとにキーが全部離されるのを待ち、ふつうの同時押しは受け付けず、新しい R を優先します。選択枠は Undo で戻りません。 | 押した瞬間の判定・表紙・一時停止と、専用の入力処理。 |
| 表示と記録 | 英数字(ASCII)で表示。確定した計画とすべての期間の帳簿、予測、05 の Undo。保存するのは、動作確認(QA)のときの PNG 画像と、画面への出力(標準出力)だけです。 | 日本語の文字、タイトル・説明・比較用の電線、操作と状態のハッシュを記録した再現ファイル。 |
完成版のコードで、次に読むところ
- model.py の
loads/legal:検査の順番と、共有の電線 - model.py の
apply/invariant:電気の収支の式(保存式) - model.py の
continuation/forecast:限られた数の候補を調べる探索 - model.py の
step/undo:StateとPlanの戻し方 - app.py の
commit/backと、comparison/dedicated(比較用・専用線)の表示
最初は、数字かルールを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の実際のパソコンでの操作や、音が出るかどうかはまだ確認していません。人がすべての操作を試したわけでもありません。ブラウザー版の確認範囲は確認状況をご覧ください。
うまく動かないときは:起動やエラーの対処法 / 質問用のメモを作る
