創作ツールLESSON 30二つの景色を作る創作玩具原作078

夜の花壇設計の作り方

1つの花の並びを、昼と夜の2つの絵に。
花の配置データから、昼と夜の2つの表示と「咲く条件」を作ります。条件を満たしていない案も保存できる、花壇づくりのツールを育てていきます。

完成版の遊び方同じ6本の花で、昼と夜の景色を作ります。依頼の条件に合わない案も、自分の作品として保存できます。

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

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

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

完成版の主な操作:矢印枠/Tab花種/Z植える・色、Space昼夜、Enter提出、S保存/L読込、X戻す

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

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

うまく表示されないとき

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

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

1つの状態から、いくつもの表示を作る。

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

1. 位置と値を分けた、4×3の編集用の盤
2. 保存する値と、表示から計算する値
3. 集合とスタックで、つながった花のまとまりを数える
それだけで動く小さなプログラムを、5つのステップで少しずつ大きくしていきます。
必要な環境
Python 3.11以上 / Pyxel 2.9.9
知っておくとよいこと
Python 3.11以上・if・for・関数・リスト / % と // で列と行を求める / dataclass・tuple・set・スタックは本文で説明します / 05 では例外処理・JSON・Path・一時ファイルも使います
教材に入っているもの
ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
完成した教材の操作
矢印:枠を選ぶ / Z:植える・同種なら三色巡回(02以降)・確認 / Tab:昼花D/夜花N(02以降) / Space:表示の昼夜切替(02以降) / Backspace:抜く / Enter:提出状態へ(04以降) / X:確認取消(04以降)/Undo(05) / S / L:保存/読込の枠選択(05) / 1〜3 / 左右:保存枠選択中だけ(05) / R:空にする。04以降は確認付き / Esc:終了。04以降は確認付き
難易度の目安
初級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_placement.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

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

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

STEP 01 / 05

一つの配列に花を植える

位置と値を分けた、4×3の編集用の盤。

「夜の花壇設計」は、時間に追われずに花の配置を考える、創作のおもちゃです。最初は、昼に咲く黄色い花だけにしぼります。矢印で白い枠を選び、Z で植え、Backspace で抜きます。1つの枠に1本、全部で6本までです。元のゲームの状況 B と同じく、まん中の5番と6番は石です。夜の花・色の変更・依頼・保存は、まだありません。

flowers は、長さ12のタプルです。0 は空、1 は昼の黄色い花です。番号0〜3が上の段、4〜7が中の段、8〜11が下の段です。index % WIDTH で列を、index // WIDTH で行を求めます。カーソル(選んでいる枠)は App に持たせ、花の配列には入れません。白い枠を動かしただけでは花の数が増えないのは、このためです。

step は元の State を受け取り、新しい State を返します。タプルを list にコピーして1つだけ変え、tuple に戻します。石の枠と盤の外は先に受け付けないようにし、空いている枠に植えるときだけ「6本まで」を確かめます。すでに花がある枠で Z を押しても、値は同じ1なので本数は増えません。dataclass(frozen=True) と replace は、「前の箱をこわさずに、新しい箱を作る」ための道具です。

App.feed は、押されているキーの集まりを受け取ります。1つ押したら、全部のキーを離すまで次の入力を受け付けず、2つの操作を同時に押したときは受け付けません。描く処理は値を読むだけです。この段階の R はすぐに盤を空にするので、保存のない練習として使ってください。

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

01_placement.py / 注目する部分
    return replace(state, flowers=tuple(flowers))

実行して確かめる

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

Windows
.venv\Scripts\python.exe 01_placement.py
macOS / Linux
.venv/bin/python 01_placement.py
画面で見ることZ → 右 → Z → 右 → Z と押すと、0・1・2番に黄色い花が3本植わります。5番に移動して Z を押しても増えません。
  • 列は %4、行は //4 で求める
  • 石の5番と6番には植えられない
  • 選んでいるだけの枠は本数に入らない
つまずきやすいところカーソルの位置を flowers に書きこむと、選んだだけで植えたことになってしまいます。cursor と flowers は別々に持ち、Z を押したときだけ step を呼びます。
少し変えてみよう

0番に Z で植えて、Backspace で抜いてみましょう。FLOWERS が 1/6 から 0/6 に戻り、同じ番号をもう一度選んでも、勝手に植わらないことを確かめます。

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

"""夜の花壇設計・教材1/5。Python 3.11以上。単独実行できます。"""
from dataclasses import dataclass, replace

WIDTH, HEIGHT = 4, 3
ROCKS = (5, 6)
MAX_FLOWERS = 6
COLORS = (0, 10, 7, 8, 12, 7, 2)

@dataclass(frozen=True)
class State:
    flowers: tuple = (0,) * 12

def kind(value):
    return -1 if value == 0 else int(value >= 4)

def mask(state, phase):
    return tuple(i for i, value in enumerate(state.flowers)
                 if value and kind(value) == phase)

def render_data(state, phase):
    # 昼夜の画像はこの一つの配列から計算。描画は状態を変えない。
    return tuple((value, kind(value), COLORS[value],
                  bool(value) and kind(value) == phase, i in ROCKS)
                 for i, value in enumerate(state.flowers))

def step(state, action):
    if not isinstance(action, str):
        return state
    parts = action.split(":")
    if not parts or parts[0] not in ("plant", "remove"):
        return state
    try:
        index = int(parts[1])
        if len(parts) != (3 if parts[0] == "plant" else 2):
            return state
    except (ValueError, IndexError):
        return state
    if not 0 <= index < WIDTH * HEIGHT:
        return state
    flowers = list(state.flowers)
    if parts[0] == "remove":
        flowers[index] = 0
    else:
        try:
            plant_kind = int(parts[2])
        except ValueError:
            return state
        if plant_kind != 0 or index in ROCKS:
            return state
        if not flowers[index] and sum(bool(v) for v in flowers) >= MAX_FLOWERS:
            return state
        flowers[index] = 1
    return replace(state, flowers=tuple(flowers))

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

    def reset(self):
        self.state = State()
        self.cursor = 0
        self.plant_kind = 0
        self.previous = set()
        self.gate = False
        self.message = "PLANT YOUR OWN DESIGN"

    def commit(self, action):
        old = self.state
        self.state = step(old, action)
        self.message = "UPDATED" if old != self.state else "NO CHANGE / ROCK / MAX 6"

    def feed(self, down):
        # 全キーを離すまで1操作。複数新規キーは採用しない。
        down = set(down) & set(KEYS)
        fresh = down - self.previous
        self.previous = down
        if self.gate:
            if not down:
                self.gate = False
            return None
        if len(fresh) > 1:
            self.gate = True
            self.message = "ONE KEY AT A TIME"
            return None
        if not fresh:
            return None
        key = next(iter(fresh))
        self.gate = True
        self.message = ""
        if key == "ESCAPE":
            return "quit"
        if key == "R":
            self.reset()
            self.gate = True
            return None
        x, y = self.cursor % WIDTH, self.cursor // WIDTH
        if key in ("LEFT", "RIGHT"):
            self.cursor = y*WIDTH + (x + (1 if key == "RIGHT" else -1)) % WIDTH
        elif key in ("UP", "DOWN"):
            self.cursor = ((y + (1 if key == "DOWN" else -1)) % HEIGHT)*WIDTH + x
        elif key == "Z":
            self.commit(f"plant:{self.cursor}:{self.plant_kind}")
        elif key == "BACKSPACE":
            self.commit(f"remove:{self.cursor}")
        return None

    def garden(self, p, left, phase, active):
        p.rect(left-4, 45, 176, 133, 6 if phase == 0 else 0)
        p.text(left+4, 49, "DAY: D BLOOMS" if phase == 0 else "NIGHT: N BLOOMS", 1 if phase == 0 else 7)
        for index, (value, flower_kind, color, opened, rock) in enumerate(render_data(self.state, phase)):
            x, y = left+(index % WIDTH)*42, 61+(index // WIDTH)*37
            p.rect(x, y, 39, 34, 4)
            p.rectb(x, y, 39, 34, 9)
            p.text(x+2, y+2, str(index), 6)
            if rock:
                p.rect(x+11, y+13, 20, 13, 5)
                p.line(x+13, y+14, x+24, y+11, 13)
            elif value:
                cx, cy = x+22, y+18
                p.line(cx, cy, cx, cy+9, 11)
                p.line(cx, cy+7, cx-5, cy+4, 3)
                if opened:
                    for dx, dy in ((-4, 0), (4, 0), (0, -4), (0, 4)):
                        p.circ(cx+dx, cy+dy, 3, color)
                    p.circ(cx, cy, 2, 9 if phase == 0 else 10)
                else:
                    p.circ(cx, cy, 3, 3)
                    p.line(cx, cy-3, cx, cy+3, 11)
                p.text(x+3, y+24, "D" if flower_kind == 0 else "N", 7)
            if index == self.cursor:
                p.rectb(x-1, y-1, 41, 36, 7 if active else 13)

    def draw(self, p):
        p.cls(1)
        p.text(8, 8, "NIGHT GARDEN / STAGE 1", 10)
        p.text(8, 22, "FLOWERS " + str(sum(bool(v) for v in self.state.flowers)) + "/6   ROCKS 5,6", 7)
        self.garden(p, 106, 0, True)
        p.text(8, 186, "ONE CELL = ONE FLOWER. NO GOAL YET.", 6)
        p.text(8, 230, self.message, 10)
        p.text(8, 245, "ARROWS SELECT  Z PLANT  BACKSPACE REMOVE", 7)
        p.text(8, 269, "R CLEAR  ESC QUIT", 6)

KEYS = ('UP', 'DOWN', 'LEFT', 'RIGHT', 'Z', 'BACKSPACE', 'R', 'ESCAPE')
DEMO_KEYS = ('Z', 'RIGHT', 'Z', 'RIGHT', 'Z')

def main(headless=False, screenshot=None, demo=False):
    from pathlib import Path
    import pyxel as p
    target = Path(screenshot).resolve() if screenshot else None
    if target:
        target.parent.mkdir(parents=True, exist_ok=True)
    app = App()
    p.init(384, 300, title="078 Night Garden", fps=30, display_scale=2,
           headless=headless, quit_key=p.KEY_NONE)
    keymap = {key: getattr(p, "KEY_"+key) for key in KEYS}
    limit = max(12, 2*len(DEMO_KEYS)+4) if demo else 12
    captured = False

    def update():
        if headless and p.frame_count >= limit:
            from dataclasses import asdict
            import json
            print(json.dumps({"state": asdict(app.state), "message": app.message},
                             ensure_ascii=True, sort_keys=True), flush=True)
            p.quit()
            return
        if headless and demo:
            index = p.frame_count//2
            down = {DEMO_KEYS[index]} if p.frame_count%2 == 0 and index < len(DEMO_KEYS) else set()
        else:
            down = {key for key, value in keymap.items() if p.btn(value)}
        if app.feed(down) == "quit":
            p.quit()

    def draw():
        nonlocal captured
        app.draw(p)
        if headless and target and p.frame_count == limit-1 and not captured:
            p.screenshot(str(target), scale=2)
            captured = True
    p.run(update, draw)
    return app

if __name__ == "__main__":
    import argparse
    parser = argparse.ArgumentParser()
    parser.add_argument("--headless-smoke", action="store_true")
    parser.add_argument("--demo-checkpoint", action="store_true")
    parser.add_argument("--screenshot")
    args = parser.parse_args()
    main(args.headless_smoke, args.screenshot, args.demo_checkpoint)
STEP 02 / 05

同じ花壇を昼と夜で描く

保存する値と、表示から計算する値。

Tab で、植える種類を昼(D)と夜(N)で切りかえます。同じ種類の花がある枠で Z を押すたびに、3色が順番に変わります。昼は 1=黄・2=白・3=赤、夜は 4=青・5=白・6=紫 です。ちがう種類に植えかえると、その種類の最初の色になります。色ごとの在庫や、相性による得点はありません。

左右の花壇は、別々の盤ではありません。どちらも state.flowers を読み、左は phase=0(昼)、右は phase=1(夜)で render_data を呼びます。kind は、0 を空の -1、1〜3 を昼の 0、4〜6 を夜の 1 に分けます。花の種類と phase が同じなら花が開き、ちがえば緑のつぼみとして描きます。

Space で、state.view だけを 0 と 1 で切りかえます。白い選択枠の強調が昼の側から夜の側に移りますが、配列・種類・色・本数は変わりません。元のゲームには、花の成長・枯れること・時間制限はありません。この切りかえを「昼から1日たった」という意味にすると、別のルールになってしまいます。

render_data が返す1枠分のデータは (value, kind, color, opened, rock) です。opened(咲いているか)は配列から計算できるので、別の配列には保存しません。昼用と夜用の flowers を別々に作ると、片方だけを編集して、2つの景色が食いちがってしまいます。

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

02_two_views.py / 注目する部分
    return tuple((value, kind(value), COLORS[value],
                  bool(value) and kind(value) == phase, i in ROCKS)
                 for i, value in enumerate(state.flowers))

実行して確かめる

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

Windows
.venv\Scripts\python.exe 02_two_views.py
macOS / Linux
.venv/bin/python 02_two_views.py
画面で見ること0・1番に昼の花、2番に夜の花を植えると、左(昼)では昼の花2本が開き、右(夜)では夜の花1本が開きます。Space を押しても、植えた位置は変わりません。
  • 昼も夜も、同じ1つの配列を読む
  • 同じ種類の枠で Z を押すと3色が順に変わる
  • Space は時間も配置も変えない
つまずきやすいところSpace で flowers の1と4を書きかえてしまうと、表示の切りかえが植えかえになってしまいます。変えるのは view だけです。
少し変えてみよう

R → Z → Z → Space と押してみましょう。0番は、値2の白い昼の花のままです。夜の側は緑のつぼみ、昼の側は白い花です。もう一度 Space を押すと、配列を変えずに元の表示に戻せます。

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

"""夜の花壇設計・教材2/5。Python 3.11以上。単独実行できます。"""
from dataclasses import dataclass, replace

WIDTH, HEIGHT = 4, 3
ROCKS = (5, 6)
MAX_FLOWERS = 6
COLORS = (0, 10, 7, 8, 12, 7, 2)

@dataclass(frozen=True)
class State:
    flowers: tuple = (0,) * 12
    view: int = 0

def kind(value):
    return -1 if value == 0 else int(value >= 4)

def mask(state, phase):
    return tuple(i for i, value in enumerate(state.flowers)
                 if value and kind(value) == phase)

def render_data(state, phase):
    # 昼夜の画像はこの一つの配列から計算。描画は状態を変えない。
    return tuple((value, kind(value), COLORS[value],
                  bool(value) and kind(value) == phase, i in ROCKS)
                 for i, value in enumerate(state.flowers))

def step(state, action):
    if action == "view":
        return replace(state, view=1-state.view)
    if not isinstance(action, str):
        return state
    parts = action.split(":")
    if not parts or parts[0] not in ("plant", "remove"):
        return state
    try:
        index = int(parts[1])
        if len(parts) != (3 if parts[0] == "plant" else 2):
            return state
    except (ValueError, IndexError):
        return state
    if not 0 <= index < WIDTH * HEIGHT:
        return state
    flowers = list(state.flowers)
    if parts[0] == "remove":
        flowers[index] = 0
    else:
        try:
            plant_kind = int(parts[2])
        except ValueError:
            return state
        if plant_kind not in (0, 1) or index in ROCKS:
            return state
        if not flowers[index] and sum(bool(v) for v in flowers) >= MAX_FLOWERS:
            return state
        first = 1 if plant_kind == 0 else 4
        old = flowers[index]
        flowers[index] = first if kind(old) != plant_kind else (old-first+1) % 3 + first
    return replace(state, flowers=tuple(flowers))

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

    def reset(self):
        self.state = State()
        self.cursor = 0
        self.plant_kind = 0
        self.previous = set()
        self.gate = False
        self.message = "PLANT YOUR OWN DESIGN"

    def commit(self, action):
        old = self.state
        self.state = step(old, action)
        self.message = "UPDATED" if old != self.state else "NO CHANGE / ROCK / MAX 6"

    def feed(self, down):
        # 全キーを離すまで1操作。複数新規キーは採用しない。
        down = set(down) & set(KEYS)
        fresh = down - self.previous
        self.previous = down
        if self.gate:
            if not down:
                self.gate = False
            return None
        if len(fresh) > 1:
            self.gate = True
            self.message = "ONE KEY AT A TIME"
            return None
        if not fresh:
            return None
        key = next(iter(fresh))
        self.gate = True
        self.message = ""
        if key == "ESCAPE":
            return "quit"
        if key == "R":
            self.reset()
            self.gate = True
            return None
        if key == "SPACE":
            self.commit("view")
            return None
        if key == "TAB":
            self.plant_kind = 1-self.plant_kind
            return None
        x, y = self.cursor % WIDTH, self.cursor // WIDTH
        if key in ("LEFT", "RIGHT"):
            self.cursor = y*WIDTH + (x + (1 if key == "RIGHT" else -1)) % WIDTH
        elif key in ("UP", "DOWN"):
            self.cursor = ((y + (1 if key == "DOWN" else -1)) % HEIGHT)*WIDTH + x
        elif key == "Z":
            self.commit(f"plant:{self.cursor}:{self.plant_kind}")
        elif key == "BACKSPACE":
            self.commit(f"remove:{self.cursor}")
        return None

    def garden(self, p, left, phase, active):
        p.rect(left-4, 45, 176, 133, 6 if phase == 0 else 0)
        p.text(left+4, 49, "DAY: D BLOOMS" if phase == 0 else "NIGHT: N BLOOMS", 1 if phase == 0 else 7)
        for index, (value, flower_kind, color, opened, rock) in enumerate(render_data(self.state, phase)):
            x, y = left+(index % WIDTH)*42, 61+(index // WIDTH)*37
            p.rect(x, y, 39, 34, 4)
            p.rectb(x, y, 39, 34, 9)
            p.text(x+2, y+2, str(index), 6)
            if rock:
                p.rect(x+11, y+13, 20, 13, 5)
                p.line(x+13, y+14, x+24, y+11, 13)
            elif value:
                cx, cy = x+22, y+18
                p.line(cx, cy, cx, cy+9, 11)
                p.line(cx, cy+7, cx-5, cy+4, 3)
                if opened:
                    for dx, dy in ((-4, 0), (4, 0), (0, -4), (0, 4)):
                        p.circ(cx+dx, cy+dy, 3, color)
                    p.circ(cx, cy, 2, 9 if phase == 0 else 10)
                else:
                    p.circ(cx, cy, 3, 3)
                    p.line(cx, cy-3, cx, cy+3, 11)
                p.text(x+3, y+24, "D" if flower_kind == 0 else "N", 7)
            if index == self.cursor:
                p.rectb(x-1, y-1, 41, 36, 7 if active else 13)

    def draw(self, p):
        p.cls(1)
        p.text(8, 8, "NIGHT GARDEN / STAGE 2", 10)
        p.text(8, 22, "FLOWERS " + str(sum(bool(v) for v in self.state.flowers)) + "/6   ROCKS 5,6", 7)
        p.text(210, 22, "BRUSH " + ("DAY" if self.plant_kind == 0 else "NIGHT"), 11)
        self.garden(p, 10, 0, self.state.view == 0)
        self.garden(p, 206, 1, self.state.view == 1)
        p.text(8, 186, "ONE ARRAY, TWO VIEWS. TIME DOES NOT PASS.", 6)
        p.text(8, 230, self.message, 10)
        p.text(8, 245, "ARROWS SELECT  Z PLANT  BACKSPACE REMOVE", 7)
        p.text(8, 257, "TAB D/N  Z AGAIN COLOR  SPACE DAY/NIGHT", 6)
        p.text(8, 269, "R CLEAR  ESC QUIT", 6)

KEYS = ('UP', 'DOWN', 'LEFT', 'RIGHT', 'Z', 'BACKSPACE', 'R', 'ESCAPE', 'SPACE', 'TAB')
DEMO_KEYS = ('Z', 'RIGHT', 'Z', 'TAB', 'RIGHT', 'Z', 'SPACE')

def main(headless=False, screenshot=None, demo=False):
    from pathlib import Path
    import pyxel as p
    target = Path(screenshot).resolve() if screenshot else None
    if target:
        target.parent.mkdir(parents=True, exist_ok=True)
    app = App()
    p.init(384, 300, title="078 Night Garden", fps=30, display_scale=2,
           headless=headless, quit_key=p.KEY_NONE)
    keymap = {key: getattr(p, "KEY_"+key) for key in KEYS}
    limit = max(12, 2*len(DEMO_KEYS)+4) if demo else 12
    captured = False

    def update():
        if headless and p.frame_count >= limit:
            from dataclasses import asdict
            import json
            print(json.dumps({"state": asdict(app.state), "message": app.message},
                             ensure_ascii=True, sort_keys=True), flush=True)
            p.quit()
            return
        if headless and demo:
            index = p.frame_count//2
            down = {DEMO_KEYS[index]} if p.frame_count%2 == 0 and index < len(DEMO_KEYS) else set()
        else:
            down = {key for key, value in keymap.items() if p.btn(value)}
        if app.feed(down) == "quit":
            p.quit()

    def draw():
        nonlocal captured
        app.draw(p)
        if headless and target and p.frame_count == limit-1 and not captured:
            p.screenshot(str(target), scale=2)
            captured = True
    p.run(update, draw)
    return app

if __name__ == "__main__":
    import argparse
    parser = argparse.ArgumentParser()
    parser.add_argument("--headless-smoke", action="store_true")
    parser.add_argument("--demo-checkpoint", action="store_true")
    parser.add_argument("--screenshot")
    args = parser.parse_args()
    main(args.headless_smoke, args.screenshot, args.demo_checkpoint)
STEP 03 / 05

咲く花だけのつながりを数える

集合とスタックで、つながった花のまとまりを数える。

元のゲームの状況 B の中心になる「ひとまとまり」を作ります。mask(state, 0) は昼に咲く花の位置、mask(state, 1) は夜に咲く花の位置です。空の枠・石・反対の種類の花は、その時間のつながりの計算に入りません。咲いていない花が、橋わたしをすることはありません。

components では、まだ調べていない位置を set(集合)に集めます。1つ取り出して新しいまとまりを数え、その花から上下左右のとなりへ進みます。stack は、次に調べる位置を入れておくリストです。見つけたとなりは remaining(残り)から取り除くので、同じ花を2回数えたり、となり同士を行ったり来たりし続けたりしません。

となりかどうかは、「列の差の大きさ」と「行の差の大きさ」の合計が1かどうかで判定します。番号の差が1かどうかだけで調べると、3番と4番まで横どうしになってしまいます。でも、3番は上の段の右はし、4番は中の段の左はしです。ななめは合計が2なので、となりではありません。花が1本もなければ、まとまりは0です。

画面には、DAY(昼)の本数と GROUPS(まとまりの数)、NIGHT(夜)の本数と GROUPS を表示します。この段階では、つながりを観察するだけで、提出はありません。自分で花を増やしたり減らしたりして、形を変えると何組になるかを、コードと画面で見比べましょう。

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

03_connections.py / 注目する部分
                          if abs(other % WIDTH - index % WIDTH)
                          + abs(other // WIDTH - index // WIDTH) == 1]

実行して確かめる

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

Windows
.venv\Scripts\python.exe 03_connections.py
macOS / Linux
.venv/bin/python 03_connections.py
画面で見ること昼の花を0・1・2番、夜の花を8・9・10番にすると、DAY 3 / GROUPS 1、NIGHT 3 / GROUPS 1 です。昼と夜のどちらを強調しても、計算結果は同じです。
  • ななめと、行のはしの回りこみはつながりに入れない
  • 反対の種類の花は橋にならない
  • 花がなければ0まとまり
つまずきやすいところabs(other-index)==1 だけで調べると、行のはしをまたいでしまいます。列と行を別々に求めることと、先に mask を通すことを確かめましょう。
少し変えてみよう

0番を D、1番を N、2番を D にすると、昼は2まとまりです。1番を D に植えかえると、昼の3本が1まとまりになり、夜は0本・0まとまりになります。

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

"""夜の花壇設計・教材3/5。Python 3.11以上。単独実行できます。"""
from dataclasses import dataclass, replace

WIDTH, HEIGHT = 4, 3
ROCKS = (5, 6)
MAX_FLOWERS = 6
COLORS = (0, 10, 7, 8, 12, 7, 2)

@dataclass(frozen=True)
class State:
    flowers: tuple = (0,) * 12
    view: int = 0

def kind(value):
    return -1 if value == 0 else int(value >= 4)

def mask(state, phase):
    return tuple(i for i, value in enumerate(state.flowers)
                 if value and kind(value) == phase)

def render_data(state, phase):
    # 昼夜の画像はこの一つの配列から計算。描画は状態を変えない。
    return tuple((value, kind(value), COLORS[value],
                  bool(value) and kind(value) == phase, i in ROCKS)
                 for i, value in enumerate(state.flowers))

def components(indices):
    remaining = set(indices)
    count = 0
    while remaining:
        count += 1
        stack = [remaining.pop()]
        while stack:
            index = stack.pop()
            neighbours = [other for other in remaining
                          if abs(other % WIDTH - index % WIDTH)
                          + abs(other // WIDTH - index // WIDTH) == 1]
            for other in neighbours:
                remaining.remove(other)
                stack.append(other)
    return count

def statistics(state):
    day, night = mask(state, 0), mask(state, 1)
    return len(day), len(night), components(day), components(night)

def step(state, action):
    if action == "view":
        return replace(state, view=1-state.view)
    if not isinstance(action, str):
        return state
    parts = action.split(":")
    if not parts or parts[0] not in ("plant", "remove"):
        return state
    try:
        index = int(parts[1])
        if len(parts) != (3 if parts[0] == "plant" else 2):
            return state
    except (ValueError, IndexError):
        return state
    if not 0 <= index < WIDTH * HEIGHT:
        return state
    flowers = list(state.flowers)
    if parts[0] == "remove":
        flowers[index] = 0
    else:
        try:
            plant_kind = int(parts[2])
        except ValueError:
            return state
        if plant_kind not in (0, 1) or index in ROCKS:
            return state
        if not flowers[index] and sum(bool(v) for v in flowers) >= MAX_FLOWERS:
            return state
        first = 1 if plant_kind == 0 else 4
        old = flowers[index]
        flowers[index] = first if kind(old) != plant_kind else (old-first+1) % 3 + first
    return replace(state, flowers=tuple(flowers))

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

    def reset(self):
        self.state = State()
        self.cursor = 0
        self.plant_kind = 0
        self.previous = set()
        self.gate = False
        self.message = "PLANT YOUR OWN DESIGN"

    def commit(self, action):
        old = self.state
        self.state = step(old, action)
        self.message = "UPDATED" if old != self.state else "NO CHANGE / ROCK / MAX 6"

    def feed(self, down):
        # 全キーを離すまで1操作。複数新規キーは採用しない。
        down = set(down) & set(KEYS)
        fresh = down - self.previous
        self.previous = down
        if self.gate:
            if not down:
                self.gate = False
            return None
        if len(fresh) > 1:
            self.gate = True
            self.message = "ONE KEY AT A TIME"
            return None
        if not fresh:
            return None
        key = next(iter(fresh))
        self.gate = True
        self.message = ""
        if key == "ESCAPE":
            return "quit"
        if key == "R":
            self.reset()
            self.gate = True
            return None
        if key == "SPACE":
            self.commit("view")
            return None
        if key == "TAB":
            self.plant_kind = 1-self.plant_kind
            return None
        x, y = self.cursor % WIDTH, self.cursor // WIDTH
        if key in ("LEFT", "RIGHT"):
            self.cursor = y*WIDTH + (x + (1 if key == "RIGHT" else -1)) % WIDTH
        elif key in ("UP", "DOWN"):
            self.cursor = ((y + (1 if key == "DOWN" else -1)) % HEIGHT)*WIDTH + x
        elif key == "Z":
            self.commit(f"plant:{self.cursor}:{self.plant_kind}")
        elif key == "BACKSPACE":
            self.commit(f"remove:{self.cursor}")
        return None

    def garden(self, p, left, phase, active):
        p.rect(left-4, 45, 176, 133, 6 if phase == 0 else 0)
        p.text(left+4, 49, "DAY: D BLOOMS" if phase == 0 else "NIGHT: N BLOOMS", 1 if phase == 0 else 7)
        for index, (value, flower_kind, color, opened, rock) in enumerate(render_data(self.state, phase)):
            x, y = left+(index % WIDTH)*42, 61+(index // WIDTH)*37
            p.rect(x, y, 39, 34, 4)
            p.rectb(x, y, 39, 34, 9)
            p.text(x+2, y+2, str(index), 6)
            if rock:
                p.rect(x+11, y+13, 20, 13, 5)
                p.line(x+13, y+14, x+24, y+11, 13)
            elif value:
                cx, cy = x+22, y+18
                p.line(cx, cy, cx, cy+9, 11)
                p.line(cx, cy+7, cx-5, cy+4, 3)
                if opened:
                    for dx, dy in ((-4, 0), (4, 0), (0, -4), (0, 4)):
                        p.circ(cx+dx, cy+dy, 3, color)
                    p.circ(cx, cy, 2, 9 if phase == 0 else 10)
                else:
                    p.circ(cx, cy, 3, 3)
                    p.line(cx, cy-3, cx, cy+3, 11)
                p.text(x+3, y+24, "D" if flower_kind == 0 else "N", 7)
            if index == self.cursor:
                p.rectb(x-1, y-1, 41, 36, 7 if active else 13)

    def draw(self, p):
        p.cls(1)
        p.text(8, 8, "NIGHT GARDEN / STAGE 3", 10)
        p.text(8, 22, "FLOWERS " + str(sum(bool(v) for v in self.state.flowers)) + "/6   ROCKS 5,6", 7)
        p.text(210, 22, "BRUSH " + ("DAY" if self.plant_kind == 0 else "NIGHT"), 11)
        self.garden(p, 10, 0, self.state.view == 0)
        self.garden(p, 206, 1, self.state.view == 1)
        day, night, dg, ng = statistics(self.state)
        p.text(8, 186, f"DAY {day} / GROUPS {dg}   NIGHT {night} / GROUPS {ng}", 11)
        p.text(8, 198, "ONLY BLOOMING ORTHOGONAL NEIGHBOURS CONNECT", 6)
        p.text(8, 230, self.message, 10)
        p.text(8, 245, "ARROWS SELECT  Z PLANT  BACKSPACE REMOVE", 7)
        p.text(8, 257, "TAB D/N  Z AGAIN COLOR  SPACE DAY/NIGHT", 6)
        p.text(8, 269, "R CLEAR  ESC QUIT", 6)

KEYS = ('UP', 'DOWN', 'LEFT', 'RIGHT', 'Z', 'BACKSPACE', 'R', 'ESCAPE', 'SPACE', 'TAB')
DEMO_KEYS = ('Z', 'RIGHT', 'Z', 'RIGHT', 'Z', 'TAB', 'DOWN', 'DOWN', 'Z', 'LEFT', 'Z', 'LEFT', 'Z', 'SPACE')

def main(headless=False, screenshot=None, demo=False):
    from pathlib import Path
    import pyxel as p
    target = Path(screenshot).resolve() if screenshot else None
    if target:
        target.parent.mkdir(parents=True, exist_ok=True)
    app = App()
    p.init(384, 300, title="078 Night Garden", fps=30, display_scale=2,
           headless=headless, quit_key=p.KEY_NONE)
    keymap = {key: getattr(p, "KEY_"+key) for key in KEYS}
    limit = max(12, 2*len(DEMO_KEYS)+4) if demo else 12
    captured = False

    def update():
        if headless and p.frame_count >= limit:
            from dataclasses import asdict
            import json
            print(json.dumps({"state": asdict(app.state), "message": app.message},
                             ensure_ascii=True, sort_keys=True), flush=True)
            p.quit()
            return
        if headless and demo:
            index = p.frame_count//2
            down = {DEMO_KEYS[index]} if p.frame_count%2 == 0 and index < len(DEMO_KEYS) else set()
        else:
            down = {key for key, value in keymap.items() if p.btn(value)}
        if app.feed(down) == "quit":
            p.quit()

    def draw():
        nonlocal captured
        app.draw(p)
        if headless and target and p.frame_count == limit-1 and not captured:
            p.screenshot(str(target), scale=2)
            captured = True
    p.run(update, draw)
    return app

if __name__ == "__main__":
    import argparse
    parser = argparse.ArgumentParser()
    parser.add_argument("--headless-smoke", action="store_true")
    parser.add_argument("--demo-checkpoint", action="store_true")
    parser.add_argument("--screenshot")
    args = parser.parse_args()
    main(args.headless_smoke, args.screenshot, args.demo_checkpoint)
STEP 04 / 05

任意の依頼を確かめながら編集する

クリアの条件と、作品づくりを続けることを分ける。

ここで、元のゲームの状況 B「二つの小道」の小さな完成版になります。条件は、全部でちょうど6本、昼の花3本が上下左右で1まとまり、夜の花3本も1まとまり、です。evaluate は、それぞれの条件と success(成功)を配列から計算します。花の種類や色の美しさを評価する、かくれた得点は足しません。

Enter を押すと submitted=True になり、今の案を提出した状態になります。成功でも足りなくても、操作は止めません。Z で色を変える・種類を変える・Backspace で抜く、などの編集をすると、submitted=False に戻ります。「成功した」という記録を、次の案に持ちこさないためです。Space は表示を変えるだけなので、提出の状態も変えません。

画面の条件の行は、満たしていれば OK、足りなければ「..」です。Enter を押す前から見えているので、どこが足りないかをかくしません。「REQUEST MET」は依頼の条件を満たしたこと、「YOUR DESIGN / REQUEST NOT MET」は自由な案として編集中であることを表します。後者を「作品の失敗」とは決めつけません。

R と Esc には確認画面を加えます。Z で確定、X か Esc で取り消しです。確認中は花の編集を受け付けません。R を確定すると、空の盤に戻ります。この段階にはまだ Undo とファイルの保存がないので、次へ進む前に手順を試しておきましょう。

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

04_editor.py / 注目する部分
    checks = (day + night == MAX_FLOWERS,
              day == 3 and day_groups == 1,
              night == 3 and night_groups == 1)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 04_editor.py
macOS / Linux
.venv/bin/python 04_editor.py
画面で見ること0・1・2番を昼、8・9・10番を夜にして Enter を押します。3つの条件が OK になり、「REQUEST MET」「SUBMITTED / KEEP EDITING」と表示されます。そのあとも色を変えられます。
  • 6本植えただけでは依頼の達成にならない
  • 提出したあとも編集できる
  • リセットの確認は取り消せる
つまずきやすいところsuccess のときに return して更新を止めてしまうと、元のゲームの「達成したあとも直せる」創作のおもちゃではなくなります。submitted と「操作を受け付けるか」は分けておきます。
少し変えてみよう

条件を達成した花壇を Enter で提出したあと、0番を Backspace で抜いてみましょう。本数と昼の条件が足りなくなり、submitted が False に戻ります。R → X と押せば、この配置を残せます。

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

"""夜の花壇設計・教材4/5。Python 3.11以上。単独実行できます。"""
from dataclasses import dataclass, replace

WIDTH, HEIGHT = 4, 3
ROCKS = (5, 6)
MAX_FLOWERS = 6
COLORS = (0, 10, 7, 8, 12, 7, 2)

@dataclass(frozen=True)
class State:
    flowers: tuple = (0,) * 12
    view: int = 0
    submitted: bool = False

def kind(value):
    return -1 if value == 0 else int(value >= 4)

def mask(state, phase):
    return tuple(i for i, value in enumerate(state.flowers)
                 if value and kind(value) == phase)

def render_data(state, phase):
    # 昼夜の画像はこの一つの配列から計算。描画は状態を変えない。
    return tuple((value, kind(value), COLORS[value],
                  bool(value) and kind(value) == phase, i in ROCKS)
                 for i, value in enumerate(state.flowers))

def components(indices):
    remaining = set(indices)
    count = 0
    while remaining:
        count += 1
        stack = [remaining.pop()]
        while stack:
            index = stack.pop()
            neighbours = [other for other in remaining
                          if abs(other % WIDTH - index % WIDTH)
                          + abs(other // WIDTH - index // WIDTH) == 1]
            for other in neighbours:
                remaining.remove(other)
                stack.append(other)
    return count

def statistics(state):
    day, night = mask(state, 0), mask(state, 1)
    return len(day), len(night), components(day), components(night)

def evaluate(state):
    day, night, day_groups, night_groups = statistics(state)
    checks = (day + night == MAX_FLOWERS,
              day == 3 and day_groups == 1,
              night == 3 and night_groups == 1)
    return {"total": day + night, "day": day, "night": night,
            "day_groups": day_groups, "night_groups": night_groups,
            "checks": checks, "success": all(checks)}

def step(state, action):
    if action == "view":
        return replace(state, view=1-state.view)
    if action == "submit":
        return replace(state, submitted=True)
    if not isinstance(action, str):
        return state
    parts = action.split(":")
    if not parts or parts[0] not in ("plant", "remove"):
        return state
    try:
        index = int(parts[1])
        if len(parts) != (3 if parts[0] == "plant" else 2):
            return state
    except (ValueError, IndexError):
        return state
    if not 0 <= index < WIDTH * HEIGHT:
        return state
    flowers = list(state.flowers)
    if parts[0] == "remove":
        flowers[index] = 0
    else:
        try:
            plant_kind = int(parts[2])
        except ValueError:
            return state
        if plant_kind not in (0, 1) or index in ROCKS:
            return state
        if not flowers[index] and sum(bool(v) for v in flowers) >= MAX_FLOWERS:
            return state
        first = 1 if plant_kind == 0 else 4
        old = flowers[index]
        flowers[index] = first if kind(old) != plant_kind else (old-first+1) % 3 + first
    return replace(state, flowers=tuple(flowers), submitted=False)

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

    def reset(self):
        self.state = State()
        self.cursor = 0
        self.plant_kind = 0
        self.previous = set()
        self.gate = False
        self.message = "PLANT YOUR OWN DESIGN"
        self.dialog = ""

    def commit(self, action):
        old = self.state
        self.state = step(old, action)
        self.message = "UPDATED" if old != self.state else "NO CHANGE / ROCK / MAX 6"

    def feed(self, down):
        # 全キーを離すまで1操作。複数新規キーは採用しない。
        down = set(down) & set(KEYS)
        fresh = down - self.previous
        self.previous = down
        if self.gate:
            if not down:
                self.gate = False
            return None
        if len(fresh) > 1:
            self.gate = True
            self.message = "ONE KEY AT A TIME"
            return None
        if not fresh:
            return None
        key = next(iter(fresh))
        self.gate = True
        self.message = ""
        if self.dialog:
            if key in ("X", "ESCAPE"):
                self.dialog = ""
                self.message = "CANCELLED"
                return None
            if key == "Z":
                if self.dialog == "quit":
                    return "quit"
                if self.dialog == "reset":
                    self.reset()
                    self.gate = True
            return None
        if key in ("ESCAPE", "R"):
            self.dialog = "quit" if key == "ESCAPE" else "reset"
            return None
        if key == "RETURN":
            self.commit("submit")
            return None
        if key == "SPACE":
            self.commit("view")
            return None
        if key == "TAB":
            self.plant_kind = 1-self.plant_kind
            return None
        x, y = self.cursor % WIDTH, self.cursor // WIDTH
        if key in ("LEFT", "RIGHT"):
            self.cursor = y*WIDTH + (x + (1 if key == "RIGHT" else -1)) % WIDTH
        elif key in ("UP", "DOWN"):
            self.cursor = ((y + (1 if key == "DOWN" else -1)) % HEIGHT)*WIDTH + x
        elif key == "Z":
            self.commit(f"plant:{self.cursor}:{self.plant_kind}")
        elif key == "BACKSPACE":
            self.commit(f"remove:{self.cursor}")
        return None

    def garden(self, p, left, phase, active):
        p.rect(left-4, 45, 176, 133, 6 if phase == 0 else 0)
        p.text(left+4, 49, "DAY: D BLOOMS" if phase == 0 else "NIGHT: N BLOOMS", 1 if phase == 0 else 7)
        for index, (value, flower_kind, color, opened, rock) in enumerate(render_data(self.state, phase)):
            x, y = left+(index % WIDTH)*42, 61+(index // WIDTH)*37
            p.rect(x, y, 39, 34, 4)
            p.rectb(x, y, 39, 34, 9)
            p.text(x+2, y+2, str(index), 6)
            if rock:
                p.rect(x+11, y+13, 20, 13, 5)
                p.line(x+13, y+14, x+24, y+11, 13)
            elif value:
                cx, cy = x+22, y+18
                p.line(cx, cy, cx, cy+9, 11)
                p.line(cx, cy+7, cx-5, cy+4, 3)
                if opened:
                    for dx, dy in ((-4, 0), (4, 0), (0, -4), (0, 4)):
                        p.circ(cx+dx, cy+dy, 3, color)
                    p.circ(cx, cy, 2, 9 if phase == 0 else 10)
                else:
                    p.circ(cx, cy, 3, 3)
                    p.line(cx, cy-3, cx, cy+3, 11)
                p.text(x+3, y+24, "D" if flower_kind == 0 else "N", 7)
            if index == self.cursor:
                p.rectb(x-1, y-1, 41, 36, 7 if active else 13)

    def draw(self, p):
        p.cls(1)
        p.text(8, 8, "NIGHT GARDEN / STAGE 4", 10)
        p.text(8, 22, "FLOWERS " + str(sum(bool(v) for v in self.state.flowers)) + "/6   ROCKS 5,6", 7)
        p.text(210, 22, "BRUSH " + ("DAY" if self.plant_kind == 0 else "NIGHT"), 11)
        self.garden(p, 10, 0, self.state.view == 0)
        self.garden(p, 206, 1, self.state.view == 1)
        report = evaluate(self.state)
        for line, (label, ok) in enumerate(zip(("TOTAL 6", "DAY 3 / GROUP 1", "NIGHT 3 / GROUP 1"), report["checks"])):
            p.text(8+line*125, 186, ("OK " if ok else ".. ")+label, 11 if ok else 6)
        p.text(8, 198, "REQUEST MET" if report["success"] else "YOUR DESIGN / REQUEST NOT MET", 11 if report["success"] else 10)
        p.text(8, 210, "SUBMITTED / KEEP EDITING" if self.state.submitted else "ENTER CHECK / GOALS ARE OPTIONAL", 6)
        p.text(8, 230, self.message, 10)
        p.text(8, 245, "ARROWS SELECT  Z PLANT  BACKSPACE REMOVE", 7)
        p.text(8, 257, "TAB D/N  Z AGAIN COLOR  SPACE DAY/NIGHT", 6)
        p.text(8, 269, "R CLEAR  ESC QUIT  ENTER CHECK", 6)
        if self.dialog:
            p.rect(35, 91, 314, 83, 1)
            p.rectb(35, 91, 314, 83, 10)
            p.text(49, 104, self.dialog.upper() + "?", 10)
            p.text(49, 134, "Z CONFIRM / X OR ESC CANCEL", 7)
            p.text(49, 150, self.message, 8)

KEYS = ('UP', 'DOWN', 'LEFT', 'RIGHT', 'Z', 'BACKSPACE', 'R', 'ESCAPE', 'SPACE', 'TAB', 'RETURN', 'X')
DEMO_KEYS = ('Z', 'RIGHT', 'Z', 'RIGHT', 'Z', 'TAB', 'DOWN', 'DOWN', 'Z', 'LEFT', 'Z', 'LEFT', 'Z', 'SPACE', 'RETURN')

def main(headless=False, screenshot=None, demo=False):
    from pathlib import Path
    import pyxel as p
    target = Path(screenshot).resolve() if screenshot else None
    if target:
        target.parent.mkdir(parents=True, exist_ok=True)
    app = App()
    p.init(384, 300, title="078 Night Garden", fps=30, display_scale=2,
           headless=headless, quit_key=p.KEY_NONE)
    keymap = {key: getattr(p, "KEY_"+key) for key in KEYS}
    limit = max(12, 2*len(DEMO_KEYS)+4) if demo else 12
    captured = False

    def update():
        if headless and p.frame_count >= limit:
            from dataclasses import asdict
            import json
            print(json.dumps({"state": asdict(app.state), "message": app.message},
                             ensure_ascii=True, sort_keys=True), flush=True)
            p.quit()
            return
        if headless and demo:
            index = p.frame_count//2
            down = {DEMO_KEYS[index]} if p.frame_count%2 == 0 and index < len(DEMO_KEYS) else set()
        else:
            down = {key for key, value in keymap.items() if p.btn(value)}
        if app.feed(down) == "quit":
            p.quit()

    def draw():
        nonlocal captured
        app.draw(p)
        if headless and target and p.frame_count == limit-1 and not captured:
            p.screenshot(str(target), scale=2)
            captured = True
    p.run(update, draw)
    return app

if __name__ == "__main__":
    import argparse
    parser = argparse.ArgumentParser()
    parser.add_argument("--headless-smoke", action="store_true")
    parser.add_argument("--demo-checkpoint", action="store_true")
    parser.add_argument("--screenshot")
    args = parser.parse_args()
    main(args.headless_smoke, args.screenshot, args.demo_checkpoint)
STEP 05 / 05

自分の案を三枠に残して読み直す

Undo と、確かめてから読みこむ保存。

X の Undo(1つ前に戻す)と、S で保存・L で読みこみを加えます。どちらも、まず保存枠1〜3を左右か数字キーで選び、Z で確定、X で取り消しです。すでに使っている枠に保存するときは「OVERWRITE?」(上書きしますか?)と出て、もう一度 Z が必要です。自動で保存したり、自動で読みこんだりはしません。起動しただけでは、保存用のフォルダーも作りません。条件を満たしていない案・空の花壇・提出前の案も、同じ手順で保存できます。

Timeline は、今の State・過去の State・確定した操作を持ちます。編集する前の State を丸ごと past に積むので、花だけでなく、昼夜の表示と提出の状態も戻ります。表示の切りかえは past に積みません。たとえば「植える → Space → X」では、植える前の花と昼夜の表示に戻ります。読みこみも1回の編集として扱うので、L のあとに X を押せば読みこむ前に戻せます。R を確定すると、履歴も消えます。

保存先は、この .py と同じフォルダーの中の tutorial_saves_078/design_1.json〜design_3.json です。実行した場所(cwd)は使いません。教材専用の形式(schema)なので、元のゲームの saves とは互換がありません。UTF-8 の JSON に、配列・view・submitted・計算し直した判定・案の種類を記録し、同じフォルダーの一時ファイルを経由して書きこみます。新しい枠では既存のファイルを置きかえない方法、確認したあとの上書きでは os.replace を使います。

decode は、バージョン・型・12枠・値が0〜6か・石・6本まで・view・提出の状態を確かめ、保存されていた判定を、配列からもう一度計算します。success だけを書きかえたファイルは受け付けません。大きすぎるファイルや、リンク先のファイルも受け付けません。読みこみに失敗したときは、State も Undo の履歴も変えず、画面に「FILE ERROR」と出します。OS ごとの具体的な直し方は README に書いてあります。

元のゲームと同じく、操作が2048回に達すると新しい編集はできなくなりますが、保存・表示の切りかえ・Undo は使えます。S で保存して R で空にし、L で読み直せば、短い履歴で続けられます。これは花の成長の時間や保存回数の制限ではありません。保存できるかどうかの条件に evaluate(state)["success"] を入れないことが、この「自由に作れるおもちゃ」を保つための大事な点です。

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

05_design_files.py / 注目する部分
    return Timeline(state, timeline.past + (timeline.state,),
                    timeline.actions + (action,))

実行して確かめる

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

Windows
.venv\Scripts\python.exe 05_design_files.py
macOS / Linux
.venv/bin/python 05_design_files.py
画面で見ること花1本だけの未達成の案を1番の枠に、依頼を達成した案を2番の枠に保存できます。L → 1 → Z で未達成の案に戻り、X で読みこむ直前の達成した案に戻せます。起動し直したあとは、L で自分から読みこみ直します。
  • 未達成の案も別の枠に保存できる
  • 上書きはもう一度確認する
  • こわれたファイルを読んでも、今の案は守られる
  • 読みこみも Undo で戻せる
つまずきやすいところ「達成したときだけ保存する」という if を入れると、自由な案を残せなくなります。また、読みこんだデータを直接代入すると、Undo で戻せません。確かめた State を、ふつうの編集と同じ履歴に通します。
少し変えてみよう

空の盤から0番に1本植えて、S → 1 → Z で保存します。右へ移動して2本目を植え、L → 1 → Z で1本の状態に戻し、X で2本に戻します。最後に S → 2 → Z で別の案として保存し、1番の枠を上書きしていないことを確かめます。

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

"""夜の花壇設計・教材5/5。Python 3.11以上。単独実行できます。"""
from dataclasses import dataclass, replace

WIDTH, HEIGHT = 4, 3
ROCKS = (5, 6)
MAX_FLOWERS = 6
COLORS = (0, 10, 7, 8, 12, 7, 2)

@dataclass(frozen=True)
class State:
    flowers: tuple = (0,) * 12
    view: int = 0
    submitted: bool = False

def kind(value):
    return -1 if value == 0 else int(value >= 4)

def mask(state, phase):
    return tuple(i for i, value in enumerate(state.flowers)
                 if value and kind(value) == phase)

def render_data(state, phase):
    # 昼夜の画像はこの一つの配列から計算。描画は状態を変えない。
    return tuple((value, kind(value), COLORS[value],
                  bool(value) and kind(value) == phase, i in ROCKS)
                 for i, value in enumerate(state.flowers))

def components(indices):
    remaining = set(indices)
    count = 0
    while remaining:
        count += 1
        stack = [remaining.pop()]
        while stack:
            index = stack.pop()
            neighbours = [other for other in remaining
                          if abs(other % WIDTH - index % WIDTH)
                          + abs(other // WIDTH - index // WIDTH) == 1]
            for other in neighbours:
                remaining.remove(other)
                stack.append(other)
    return count

def statistics(state):
    day, night = mask(state, 0), mask(state, 1)
    return len(day), len(night), components(day), components(night)

def evaluate(state):
    day, night, day_groups, night_groups = statistics(state)
    checks = (day + night == MAX_FLOWERS,
              day == 3 and day_groups == 1,
              night == 3 and night_groups == 1)
    return {"total": day + night, "day": day, "night": night,
            "day_groups": day_groups, "night_groups": night_groups,
            "checks": checks, "success": all(checks)}

def step(state, action):
    if action == "view":
        return replace(state, view=1-state.view)
    if action == "submit":
        return replace(state, submitted=True)
    if isinstance(action, str) and action.startswith("load:"):
        return decode(json.loads(action[5:]))
    if not isinstance(action, str):
        return state
    parts = action.split(":")
    if not parts or parts[0] not in ("plant", "remove"):
        return state
    try:
        index = int(parts[1])
        if len(parts) != (3 if parts[0] == "plant" else 2):
            return state
    except (ValueError, IndexError):
        return state
    if not 0 <= index < WIDTH * HEIGHT:
        return state
    flowers = list(state.flowers)
    if parts[0] == "remove":
        flowers[index] = 0
    else:
        try:
            plant_kind = int(parts[2])
        except ValueError:
            return state
        if plant_kind not in (0, 1) or index in ROCKS:
            return state
        if not flowers[index] and sum(bool(v) for v in flowers) >= MAX_FLOWERS:
            return state
        first = 1 if plant_kind == 0 else 4
        old = flowers[index]
        flowers[index] = first if kind(old) != plant_kind else (old-first+1) % 3 + first
    return replace(state, flowers=tuple(flowers), submitted=False)

MAX_ACTIONS = 2048

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

def apply(timeline, action):
    if action == "undo":
        if not timeline.past:
            return timeline
        return Timeline(timeline.past[-1], timeline.past[:-1],
                        timeline.actions + (action,))
    if action == "view":
        return Timeline(step(timeline.state, action), timeline.past,
                        timeline.actions + (action,))
    if len(timeline.actions) >= MAX_ACTIONS:
        return timeline
    state = step(timeline.state, action)
    if state == timeline.state:
        return timeline
    return Timeline(state, timeline.past + (timeline.state,),
                    timeline.actions + (action,))

from pathlib import Path
import json
import os
import tempfile

SCHEMA = "night-garden-lesson-B-v1"
SAVE_ROOT = Path(__file__).resolve().parent / "tutorial_saves_078"

def payload(state):
    report = evaluate(state)
    return {"schema": SCHEMA, "level": "B", "flowers": list(state.flowers),
            "view": state.view, "submitted": state.submitted,
            "evaluation": {**report, "checks": list(report["checks"])},
            "label": "依頼に沿った花壇" if report["success"] else "自分の案・依頼未達"}

def decode(data):
    if not isinstance(data, dict) or data.get("schema") != SCHEMA or data.get("level") != "B":
        raise ValueError("WRONG SAVE VERSION / LEVEL")
    flowers = data.get("flowers")
    if (not isinstance(flowers, list) or len(flowers) != WIDTH * HEIGHT
            or any(type(v) is not int or not 0 <= v <= 6 for v in flowers)):
        raise ValueError("INVALID FLOWER ARRAY")
    if any(flowers[i] for i in ROCKS) or sum(bool(v) for v in flowers) > MAX_FLOWERS:
        raise ValueError("INVALID ROCK / FLOWER COUNT")
    if type(data.get("view")) is not int or data["view"] not in (0, 1):
        raise ValueError("INVALID VIEW")
    if type(data.get("submitted")) is not bool:
        raise ValueError("INVALID SUBMITTED VALUE")
    state = State(tuple(flowers), data["view"], data["submitted"])
    expected = payload(state)
    # bool と 1 も区別。保存された達成フラグを信用しない。
    canonical = lambda value: json.dumps(value, ensure_ascii=False, sort_keys=True)
    if canonical(data) != canonical(expected):
        raise ValueError("SAVE DOES NOT MATCH FLOWERS")
    return state

class Store:
    def __init__(self, root=SAVE_ROOT):
        self.root = Path(root)

    def path(self, slot):
        if type(slot) is not int or slot not in (0, 1, 2):
            raise ValueError("INVALID SLOT")
        # 意図しないリンク先へ書かない。通常はこの.py隣の専用ディレクトリ。
        if any(part.is_symlink() for part in (self.root, *self.root.parents)):
            raise ValueError("SYMLINK SAVE FOLDER REJECTED")
        path = self.root / ("design_" + str(slot + 1) + ".json")
        if path.is_symlink():
            raise ValueError("SYMLINK SAVE FILE REJECTED")
        return path

    def exists(self, slot):
        return self.path(slot).exists()

    def write(self, slot, state, overwrite=False):
        path = self.path(slot)
        data = payload(state)
        decode(data)
        self.root.mkdir(parents=True, exist_ok=True)
        temporary = None
        try:
            with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=self.root,
                                             prefix=".design-", suffix=".tmp", delete=False) as file:
                temporary = Path(file.name)
                json.dump(data, file, ensure_ascii=False, indent=2)
                file.flush()
                os.fsync(file.fileno())
            self.path(slot)  # 置換直前にもリンクを拒否。
            if overwrite:
                os.replace(temporary, path)
            else:
                # 新規枠は排他的に作成。確認前に既存ファイルを置換しない。
                os.link(temporary, path)
        finally:
            if temporary is not None:
                temporary.unlink(missing_ok=True)

    def read(self, slot):
        path = self.path(slot)
        if path.stat().st_size > 20000:
            raise ValueError("SAVE TOO LARGE")
        return decode(json.loads(path.read_text(encoding="utf-8")))

class App:
    def __init__(self, store=None):
        self.store = store if store is not None else Store()
        self.slot = 0
        self.reset()

    def reset(self):
        self.timeline = Timeline()
        self.cursor = 0
        self.plant_kind = 0
        self.previous = set()
        self.gate = False
        self.message = "PLANT YOUR OWN DESIGN"
        self.dialog = ""

    @property
    def state(self):
        return self.timeline.state

    def commit(self, action):
        if len(self.timeline.actions) >= MAX_ACTIONS and action not in ("view", "undo"):
            self.message = "HISTORY FULL: SAVE, RESET, LOAD"
            return
        old = self.timeline
        self.timeline = apply(old, action)
        self.message = "UPDATED" if old != self.timeline else "NO CHANGE / ROCK / MAX 6"

    def save_slot(self, overwrite=False):
        self.store.write(self.slot, self.state, overwrite=overwrite)
        self.dialog = ""
        self.message = "SAVED DESIGN " + str(self.slot+1)

    def feed(self, down):
        # 全キーを離すまで1操作。複数新規キーは採用しない。
        down = set(down) & set(KEYS)
        fresh = down - self.previous
        self.previous = down
        if self.gate:
            if not down:
                self.gate = False
            return None
        if len(fresh) > 1:
            self.gate = True
            self.message = "ONE KEY AT A TIME"
            return None
        if not fresh:
            return None
        key = next(iter(fresh))
        self.gate = True
        self.message = ""
        if self.dialog:
            if key in ("X", "ESCAPE"):
                self.dialog = ""
                self.message = "CANCELLED"
                return None
            if self.dialog in ("save", "load"):
                if key in ("LEFT", "RIGHT"):
                    self.slot = (self.slot + (1 if key == "RIGHT" else -1)) % 3
                elif key in ("1", "2", "3"):
                    self.slot = int(key)-1
                elif key == "Z":
                    try:
                        if self.dialog == "save":
                            if self.store.exists(self.slot):
                                self.dialog = "overwrite"
                            else:
                                self.save_slot()
                        else:
                            loaded = self.store.read(self.slot)
                            self.commit("load:" + json.dumps(payload(loaded), ensure_ascii=False))
                            self.dialog = ""
                            self.cursor = 0
                    except (OSError, ValueError, UnicodeError, RecursionError) as error:
                        self.message = "FILE ERROR / SEE README"
                return None
            if key == "Z":
                if self.dialog == "quit":
                    return "quit"
                if self.dialog == "reset":
                    self.reset()
                    self.gate = True
                elif self.dialog == "overwrite":
                    try:
                        self.save_slot(overwrite=True)
                    except (OSError, ValueError, UnicodeError, RecursionError):
                        self.message = "FILE ERROR / SEE README"
            return None
        if key in ("ESCAPE", "R"):
            self.dialog = "quit" if key == "ESCAPE" else "reset"
            return None
        if key == "RETURN":
            self.commit("submit")
            return None
        if key in ("S", "L"):
            self.dialog = "save" if key == "S" else "load"
            return None
        if key == "X":
            self.commit("undo")
            return None
        if key == "SPACE":
            self.commit("view")
            return None
        if key == "TAB":
            self.plant_kind = 1-self.plant_kind
            return None
        x, y = self.cursor % WIDTH, self.cursor // WIDTH
        if key in ("LEFT", "RIGHT"):
            self.cursor = y*WIDTH + (x + (1 if key == "RIGHT" else -1)) % WIDTH
        elif key in ("UP", "DOWN"):
            self.cursor = ((y + (1 if key == "DOWN" else -1)) % HEIGHT)*WIDTH + x
        elif key == "Z":
            self.commit(f"plant:{self.cursor}:{self.plant_kind}")
        elif key == "BACKSPACE":
            self.commit(f"remove:{self.cursor}")
        return None

    def garden(self, p, left, phase, active):
        p.rect(left-4, 45, 176, 133, 6 if phase == 0 else 0)
        p.text(left+4, 49, "DAY: D BLOOMS" if phase == 0 else "NIGHT: N BLOOMS", 1 if phase == 0 else 7)
        for index, (value, flower_kind, color, opened, rock) in enumerate(render_data(self.state, phase)):
            x, y = left+(index % WIDTH)*42, 61+(index // WIDTH)*37
            p.rect(x, y, 39, 34, 4)
            p.rectb(x, y, 39, 34, 9)
            p.text(x+2, y+2, str(index), 6)
            if rock:
                p.rect(x+11, y+13, 20, 13, 5)
                p.line(x+13, y+14, x+24, y+11, 13)
            elif value:
                cx, cy = x+22, y+18
                p.line(cx, cy, cx, cy+9, 11)
                p.line(cx, cy+7, cx-5, cy+4, 3)
                if opened:
                    for dx, dy in ((-4, 0), (4, 0), (0, -4), (0, 4)):
                        p.circ(cx+dx, cy+dy, 3, color)
                    p.circ(cx, cy, 2, 9 if phase == 0 else 10)
                else:
                    p.circ(cx, cy, 3, 3)
                    p.line(cx, cy-3, cx, cy+3, 11)
                p.text(x+3, y+24, "D" if flower_kind == 0 else "N", 7)
            if index == self.cursor:
                p.rectb(x-1, y-1, 41, 36, 7 if active else 13)

    def draw(self, p):
        p.cls(1)
        p.text(8, 8, "NIGHT GARDEN / STAGE 5", 10)
        p.text(8, 22, "FLOWERS " + str(sum(bool(v) for v in self.state.flowers)) + "/6   ROCKS 5,6", 7)
        p.text(210, 22, "BRUSH " + ("DAY" if self.plant_kind == 0 else "NIGHT"), 11)
        self.garden(p, 10, 0, self.state.view == 0)
        self.garden(p, 206, 1, self.state.view == 1)
        report = evaluate(self.state)
        for line, (label, ok) in enumerate(zip(("TOTAL 6", "DAY 3 / GROUP 1", "NIGHT 3 / GROUP 1"), report["checks"])):
            p.text(8+line*125, 186, ("OK " if ok else ".. ")+label, 11 if ok else 6)
        p.text(8, 198, "REQUEST MET" if report["success"] else "YOUR DESIGN / REQUEST NOT MET", 11 if report["success"] else 10)
        p.text(8, 210, "SUBMITTED / KEEP EDITING" if self.state.submitted else "ENTER CHECK / GOALS ARE OPTIONAL", 6)
        p.text(8, 230, self.message, 10)
        p.text(8, 245, "ARROWS SELECT  Z PLANT  BACKSPACE REMOVE", 7)
        p.text(8, 257, "TAB D/N  Z AGAIN COLOR  SPACE DAY/NIGHT", 6)
        p.text(8, 269, "S SAVE  L LOAD  X UNDO  R CLEAR  ESC QUIT", 6)
        p.text(8, 283, "3 SEPARATE DESIGNS / NO SUCCESS GATE", 11)
        if self.dialog:
            p.rect(35, 91, 314, 83, 1)
            p.rectb(35, 91, 314, 83, 10)
            p.text(49, 104, self.dialog.upper() + "?", 10)
            if self.dialog in ("save", "load"):
                p.text(49, 118, "SLOT " + str(self.slot+1) + " / LEFT RIGHT OR 1 2 3", 7)
            elif self.dialog == "overwrite":
                p.text(49, 118, "REPLACE DESIGN " + str(self.slot+1), 7)
            p.text(49, 134, "Z CONFIRM / X OR ESC CANCEL", 7)
            p.text(49, 150, self.message, 8)

KEYS = ('UP', 'DOWN', 'LEFT', 'RIGHT', 'Z', 'BACKSPACE', 'R', 'ESCAPE', 'SPACE', 'TAB', 'RETURN', 'X', 'S', 'L', '1', '2', '3')
DEMO_KEYS = ('Z', 'RIGHT', 'Z', 'RIGHT', 'Z', 'TAB', 'DOWN', 'DOWN', 'Z', 'LEFT', 'Z', 'LEFT', 'Z', 'SPACE', 'RETURN')

def main(headless=False, screenshot=None, demo=False):
    from pathlib import Path
    import pyxel as p
    target = Path(screenshot).resolve() if screenshot else None
    if target:
        target.parent.mkdir(parents=True, exist_ok=True)
    app = App()
    p.init(384, 300, title="078 Night Garden", fps=30, display_scale=2,
           headless=headless, quit_key=p.KEY_NONE)
    keymap = {key: getattr(p, "KEY_"+key) for key in KEYS}
    limit = max(12, 2*len(DEMO_KEYS)+4) if demo else 12
    captured = False

    def update():
        if headless and p.frame_count >= limit:
            from dataclasses import asdict
            import json
            print(json.dumps({"state": asdict(app.state), "message": app.message},
                             ensure_ascii=True, sort_keys=True), flush=True)
            p.quit()
            return
        if headless and demo:
            index = p.frame_count//2
            down = {DEMO_KEYS[index]} if p.frame_count%2 == 0 and index < len(DEMO_KEYS) else set()
        else:
            down = {key for key, value in keymap.items() if p.btn(value)}
        if app.feed(down) == "quit":
            p.quit()

    def draw():
        nonlocal captured
        app.draw(p)
        if headless and target and p.frame_count == limit-1 and not captured:
            p.screenshot(str(target), scale=2)
            captured = True
    p.run(update, draw)
    return app

if __name__ == "__main__":
    import argparse
    parser = argparse.ArgumentParser()
    parser.add_argument("--headless-smoke", action="store_true")
    parser.add_argument("--demo-checkpoint", action="store_true")
    parser.add_argument("--screenshot")
    args = parser.parse_args()
    main(args.headless_smoke, args.screenshot, args.demo_checkpoint)
AFTER STEP 05

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

元のゲームの状況 B「二つの小道」です。4×3の盤で、5番と6番は石、花は6本までです。昼の花3本と夜の花3本が、それぞれ咲く時間に上下左右で1まとまりになれば達成です。花がない・ななめ・咲いていない花では、つながりになりません。同じ配列から昼と夜を描き、依頼を達成していなくても案を保存できます。

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

比べる点5ステップの教材元の完成版
段階と依頼01 は昼の黄色い花1種類だけ。02 で昼夜と全6色、03 でつながりの観察。04 と 05 は元のゲームの状況 B の配置・開花・依頼の判定です。A は入口とベンチ、B はつながり、C は白と青の条件です。状況を選べます。A と C は教材では扱いません。
表示と入力文字(ASCII)と図形で表示します。昼と夜をいつも横に並べ、最初から編集できる状態で始めます。04 以降はリセットと終了の確認があります。日本語の文字、表紙、説明、状況 B の比較表示、画像保存の F12 キー、依頼の切りかえと「保存していません」の確認があります。教材では省いています。
保存と安全05 だけ、教材専用の3つの枠の JSON です。自分で保存・読みこみをし、形式は別で、全部の項目を確かめ直します。リンク先のファイルは受け付けず、新しく作るときは既存のファイルを置きかえません。元のゲームの saves の中の3つの枠です。A・B・C の保存、元のゲームのバージョンと作品 ID、計算し直した判定を持ちます。教材の形式を読んだり、自動で変換したりはしません。
履歴と記録05 に元のゲームと同じ方式の State の Undo と、2048回の操作の上限があります。ファイルの読み書きは Undo の対象外です。画面の録画や、入力の記録の保存は省いています。すべての状態の履歴、入力イベントの記録、SHA-256 による照合、録画の機能、実験用の比較記録があります。

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

  1. model.py: mask / components / evaluateでAとCの条件へ広げる
  2. model.py: actのundo・viewとsubmittedの扱いを読む
  3. controller.py: feedの全キー解放と保存/上書きダイアログ
  4. storage.py: Store.read / writeと原作の保存形式
  5. app.py: garden / flowerの同じ配列からの日本語描画
自分のゲームに変えるなら

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

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