パズルLESSON 47木構造・部分木の90度回転パズル原作029

ずれる星座の作り方

回す中心はそのままに、枝の先だけを回す。
親子の「木」から子孫を探し、古い座標をいっせいに変換します。予告と Undo がある、星座のパズルを作ります。

完成版の遊び方中心に選んだ星の、子孫だけを90度回します。すでに合っている別の枝は残したまま、星を同じ ID の目標に合わせます。

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

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

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

完成版の主な操作:上下基点、左右90度回転、Space予告、Z戻す、R再開、P停止、M選択

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

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

うまく表示されないとき

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

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

同じ古い中心から、子孫を1回ずつ回す。

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

1. 画面全体の座標と、親の番号を分けて持ち、本当の子孫を並べる
2. O を中心にいっせいに変換し、画面の y の向きに合った回転の式を使う
3. 候補全体をチェックしてまとめて確定し、ID ごとに目標と一致するかを見る
それだけで動く小さなプログラムを、5つのステップで少しずつ大きくしていきます。
必要な環境
Python 3.11以上 / Pyxel 2.9.9
知っておくとよいこと
Python 3.11以上、.venv とターミナル / 変数・if・for・関数・リストとタプル / dataclass・書きかえない State・木をたどる方法・入力の待ち方は本文で説明します
教材に入っているもの
ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
完成した教材の操作
上下:01と03以降で基点選択、02はO固定 / 左右:02以降で反時計回り/時計回り90度 / SPACE:04以降で時計回り候補の表示切替(完成前) / Z:05だけ直前の確定回転をUndo、完成後も可 / R:配置・選択・履歴・予告を初期化 / Esc:終了
難易度の目安
中級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_tree.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

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

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

STEP 01 / 05

親子の木から、回す星を見つける

画面全体の座標と、親の番号を分けて持ち、本当の子孫を並べる。

星の位置から親子関係を推測するのではなく、parents という決まったタプルでつながりを持ちます。ID の順番は O、A、B、C、D です。parents=(-1,0,1,0,2) は、「O には親がない」「A と C の親は O」「B の親は A」「D の親は B」という意味です。O→A→B→D の長い枝と、O→C の別の枝があります。座標はマス目の点で、x は右、y は下へ増えます。これは、回しても変わらない「木」と、回すと変わる「位置」を分けるための準備です。

Level には、親・最初の位置・目標を置き、State には、今の画面全体の座標と手数だけを置きます。画面全体の座標とは、全員が同じ盤の (0,0) を基準にした座標です。子を「親からの位置」として保存しているわけではありません。frozen=True の dataclass は、直接の書きかえを防ぎます。描くための selected(選んでいる星)は App が持ち、星の位置や手数とは分けます。

descendants では、pending(これから調べるもの)に中心を入れ、parents を順に見て子を見つけます。子を found(見つけたもの)に加え、その子も pending に積むので、孫までたどれます。最後の sorted は ID の順にそろえるためです。元のデータは元のゲームで「木」になっていることを確かめてあるので、同じ子に2つの親からたどりつくことはなく、1人を1回だけ集められます。こわれた木を直してくれる関数ではありません。

上下で中心を選ぶと、白い二重の輪が移り、その本当の子孫と、そこへ向かう枝が黄色になります。中心そのものは子孫にふくみません。A なら B・D、B なら D、C と D なら空です。葉(いちばん先の星)にも親はいますが、その先に子はいません。この段階は木を読む練習で、左右を押しても星は回りません。目標の輪と成功の判定は 03 で表示します。

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

01_tree.py / 注目する部分
            if parent == parent_id:
                found.append(i)
                pending.append(i)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 01_tree.py
macOS / Linux
.venv/bin/python 01_tree.py
画面で見ること最初の状態で下を1回押します。PIVOT A、DESCENDANTS B D と表示され、O・A・C は白、B・D は黄色です。画面全体の座標と MOVES 0 は変わりません。
  • parents は決まった親子関係、positions は変わる座標
  • 中心をのぞいた本当の子孫を、1回ずつ並べる
  • 葉の C/D の子孫は空。01 では回さない
つまずきやすいところ中心を found に入れないようにしましょう。近くにある星や、線の交わりから親子を決めないようにします。親の番号を座標ととりちがえないようにします。
少し変えてみよう

R → 上 で D、もう一度上で C、さらに上で B を選んでみましょう。最後の中心は B で、子孫は D だけです。左右も押して、位置も手数も変わらないことを確かめます。キーは毎回全部離してください。

01_tree.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(256行)
01_tree.py / 全文
# MIT License
# 
# Copyright (c) 2026 Pyxel 100 contributors
# 
# 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.
# 
# The bundled Noto-derived bitmap glyphs are separately licensed under SIL OFL.
# See assets/NOTO-LICENSE.txt.

"""ずれる星座・教材1/5。Python3.11以上 / Pyxel2.9.9。"""
from dataclasses import dataclass


@dataclass(frozen=True)
class Level:
    title: str
    ids: tuple[str, ...]
    parents: tuple[int, ...]
    start: tuple[tuple[int, int], ...]
    goals: tuple[tuple[int, int], ...]
    discovery: str
    size: int = 7


# 原作Cを変更せず採用。親は「添字」、座標は「世界座標」。
LEVEL = Level(
    "枝の中にもう一枝", ("O", "A", "B", "C", "D"), (-1, 0, 1, 0, 2),
    ((3, 3), (4, 3), (5, 3), (2, 3), (5, 2)),
    ((3, 3), (3, 2), (4, 2), (3, 4), (5, 2)),
    "Dは一度ずれても、また戻せる。",
)


@dataclass(frozen=True)
class State:
    positions: tuple[tuple[int, int], ...]
    moves: int = 0


def initial():
    return State(LEVEL.start)


def descendants(level, pivot):
    """真の子孫を親配列から探す。中心自身は含めず、一度ずつ返す。"""
    found = []
    pending = [pivot]
    while pending:
        parent_id = pending.pop()
        for i, parent in enumerate(level.parents):
            if parent == parent_id:
                found.append(i)
                pending.append(i)
    return tuple(sorted(found))


def edge_lengths(level, positions):
    # sqrtは不要。枝の長さの二乗を整数のまま比較できる。
    return tuple((positions[i][0] - positions[p][0]) ** 2
                 + (positions[i][1] - positions[p][1]) ** 2
                 for i, p in enumerate(level.parents) if p >= 0)


def center(position):
    return 72 + position[0] * 40, 92 + position[1] * 40


def shape(p, i, x, y, color, filled=False, radius=9):
    if i == 0:
        p.circb(x, y, radius, color)
        p.pset(x, y, color)
    elif i == 1:
        (p.rect if filled else p.rectb)(x-radius, y-radius, radius*2+1, radius*2+1, color)
    elif i == 2:
        (p.tri if filled else p.trib)(x, y-radius, x-radius, y+radius, x+radius, y+radius, color)
    elif i == 3:
        (p.circ if filled else p.circb)(x, y, radius, color)
    elif filled:
        p.tri(x, y-radius, x-radius, y, x+radius, y, color)
        p.tri(x, y+radius, x-radius, y, x+radius, y, color)
    else:
        points = ((x, y-radius), (x+radius, y), (x, y+radius), (x-radius, y))
        for a, b in zip(points, points[1:] + points[:1]):
            p.line(*a, *b, color)


def branch(p, start, end, color):
    import math
    x, y = center(start)
    xx, yy = center(end)
    p.line(x, y, xx, yy, color)
    dx, dy = xx-x, yy-y
    length = max(1, math.hypot(dx, dy))
    ux, uy = dx/length, dy/length
    mx, my = x+dx*0.6, y+dy*0.6
    p.line(mx, my, mx-ux*5+uy*3, my-uy*5-ux*3, color)
    p.line(mx, my, mx-ux*5-uy*3, my-uy*5+ux*3, color)


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

    def reset(self):
        self.state = initial()
        self.selected = 0
        self.gated = False
        self.message = "SELECT A PIVOT / ONE KEY, THEN RELEASE"

    def control(self, pressed, held):
        # 教材では毎操作後に全解放を要求。Rの新規押下は最優先。
        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 = "INVALID / RELEASE ALL KEYS"
            self.gated = True
            return
        if len(pressed) != 1:
            return
        key = next(iter(pressed))
        if key in ("UP", "DOWN"):
            self.selected = (self.selected + (1 if key == "DOWN" else -1)) % len(LEVEL.ids)
            self.message = "SELECTED " + LEVEL.ids[self.selected]
            if not descendants(LEVEL, self.selected):
                self.message += " / LEAF: NO DESCENDANTS"
            self.gated = True
            return
        if key in ("LEFT", "RIGHT"):
            self.message = "TREE EXPLORER / ROTATION STARTS IN STAGE 2"
            self.gated = True

    def snapshot(self):
        from dataclasses import asdict
        return {"state": asdict(self.state), "selected": self.selected,
                "descendants": descendants(LEVEL, self.selected),
                "won": False,
                "preview": False,
                "history": [],
                "message": self.message}

    def draw(self, p):
        # 描画は読取専用。予告もstate/historyを書き換えない。
        state = self.state
        active = descendants(LEVEL, self.selected)
        p.cls(1)
        p.text(16, 12, "SHIFTED STARS / STAGE 1 / AUTHORED C", 10)
        p.text(16, 30, "TREE = PARENT LINKS / GEOMETRY DOES NOT CHANGE THE TREE", 6)
        p.text(16, 47, "SCREEN COORDINATES: X RIGHT / Y DOWN", 7)
        p.rectb(48, 68, 290, 290, 5)
        for axis in range(7):
            p.text(70+axis*40, 74, str(axis), 13)
            p.text(54, 90+axis*40, str(axis), 13)
        for y in range(7):
            for x in range(7):
                p.pset(*center((x, y)), 5)
        for i, parent in enumerate(LEVEL.parents):
            if parent >= 0:
                branch(p, state.positions[parent], state.positions[i], 10 if i in active else 13)


        for i, position in enumerate(state.positions):
            x, y = center(position)
            shape(p, i, x, y, 10 if i in active else 7, True)
            if i == 0:
                p.circ(x, y, 6, 1)
            p.text(x-2, y-2, LEVEL.ids[i], 7 if i == 0 else 1)
            if i == self.selected:
                p.circb(x, y, 18, 7)
                p.circb(x, y, 20, 7)
        p.text(364, 72, "PIVOT: " + LEVEL.ids[self.selected], 7)
        p.text(364, 88, "DESCENDANTS: " + (" ".join(LEVEL.ids[i] for i in active) or "NONE / LEAF"), 10)
        p.text(364, 106, "ID   PARENT   WORLD (X,Y)", 6)
        for i, name in enumerate(LEVEL.ids):
            parent = LEVEL.parents[i]
            row = name + "    " + (LEVEL.ids[parent] if parent >= 0 else "-")
            row += "        " + str(state.positions[i])
            p.text(364, 125+i*18, row, 10 if i in active else 7)
        p.text(364, 226, "O -> A -> B -> D", 7)
        p.text(364, 242, "O -> C  (SEPARATE BRANCH)", 7)
        p.text(364, 264, "PIVOT AND OTHER BRANCH STAY", 6)
        p.text(364, 280, "EACH DESCENDANT ROTATES ONCE", 6)
        p.text(364, 300, "MOVES: " + str(state.moves), 10)
        p.text(364, 324, "SELECT ONLY / NO ROTATION YET", 10)
        p.text(16, 370, self.message, 7)
        p.text(16, 388, "UP/DOWN SELECT / R RESET / ESC QUIT", 6)
        p.text(16, 406, "GOLD: PROPER DESCENDANTS / WHITE DOUBLE RING: PIVOT / O IS ROOT", 5)


CONTROL_KEYS = ("UP", "DOWN", "LEFT", "RIGHT", "SPACE", "Z", "R")
DEMO_KEYS = ('DOWN',)


def main(headless=False, screenshot=None, demo=False, state_json=None):
    from pathlib import Path
    import json
    import pyxel as p  # 起動時だけimport。標準ライブラリ検査には不要。
    app = App()
    p.init(640, 430, title="Shifted Stars / Lesson 1", fps=30, display_scale=2, headless=headless)
    p.colors[:] = [0x090D16, 0x151D2B, 0x293441, 0x35654D, 0x674738, 0x778897, 0x85B9B5, 0xE8E1CE, 0xD96755, 0xC4884A, 0xF4C46B, 0xA8D68D, 0x4384A1, 0x8B969B, 0xD09B8C, 0xD6C5A6]
    keys = {name: getattr(p, "KEY_" + name) for name in CONTROL_KEYS}
    limit = max(12, len(DEMO_KEYS)*2+3) if demo else 12
    def update():
        if headless and p.frame_count >= limit:
            if screenshot:
                path = Path(screenshot)
                path.parent.mkdir(parents=True, exist_ok=True)
                p.screenshot(str(path.resolve()), scale=2)
            if state_json:
                path = Path(state_json)
                path.parent.mkdir(parents=True, exist_ok=True)
                path.write_text(json.dumps(app.snapshot(), ensure_ascii=False, indent=2)+"\n", encoding="utf-8")
            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))
    return app


if __name__ == "__main__":
    import argparse
    parser = argparse.ArgumentParser(description="ずれる星座・独立教材1/5")
    parser.add_argument("--headless-smoke", action="store_true")
    parser.add_argument("--demo-checkpoint", action="store_true")
    parser.add_argument("--screenshot")
    parser.add_argument("--state-json")
    args = parser.parse_args()
    if (args.demo_checkpoint or args.screenshot or args.state_json) and not args.headless_smoke:
        parser.error("自動確認の出力には --headless-smoke が必要です")
    main(args.headless_smoke, args.screenshot, args.demo_checkpoint, args.state_json)
STEP 02 / 05

旧世界座標を、一度ずつ90度回す

O を中心にいっせいに変換し、画面の y の向きに合った回転の式を使う。

最初の回転の練習では、中心を O に決めておきます。上下で枝を選ぶ機能はいったん使わず、左右だけで O の子孫全部を回します。目標の輪と成功の判定は、まだ表示しません。元のゲームの状況 C は O だけでは完成しないので、ここで完成をめざす必要はありません。親子のデータは 01 と同じで、回す前後で書きかえません。

中心 (x,y) から星までの差を、dx=星のx−x、dy=星のy−y として取り出します。画面では y が下へ増えるので、時計回りの90度は (dx,dy)→(-dy,dx) です。たとえば O の右にある A (4,3) は、右回りで O の下の (3,4) に移ります。向き direction を右=+1、左=-1 とすると、式を (x-direction*dy, y+direction*dx) にまとめられます。

new は、候補を書きこむためのリストです。それぞれの dx・dy は、必ず元の state.positions から読みます。A を動かしたあとの new[A] を次の中心にして、B や D を動かす処理ではありません。O の古い位置を全員に共通で使い、B も D も1回ずつ回します。終わったら、候補をタプルに戻します。

候補に、盤の外や、同じマスでの重なりがあれば、Result は元の State を返します。O を中心にしたふつうの回転では、枝の長さと形が保たれるので、この C では受け付けられないことはありませんが、「候補を作ってからチェックする」作りを先に整えておきます。確定した回転だけ、moves を1増やします。逆に回して座標が元に戻っても、手数は2で、最初に戻すのとはちがいます。

枝の長さは、座標の差の2乗の和で確かめます。たとえば A と B の間は1のままです。平方根を求めなくても、回す前と後で同じ整数になることをテストできます。

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

02_world_rotation.py / 注目する部分
        dx, dy = state.positions[i][0] - x, state.positions[i][1] - y
        new[i] = (x - direction * dy, y + direction * dx)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 02_world_rotation.py
macOS / Linux
.venv/bin/python 02_world_rotation.py
画面で見ること最初の状態で左を押します。O(3,3)、A(3,2)、B(3,1)、C(3,4)、D(2,1)、MOVES 1 です。孫の B と、ひ孫の D も、O のまわりを1回だけ回ります。
  • 右回りは (dx,dy)→(-dy,dx)。画面の y は下向き
  • 全部の子孫の差を、古い state から1回ずつ計算する
  • 4回回すと座標は元に戻るが、手数は4のまま
つまずきやすいところ数学の「y が上向き」の図とまちがえると、右回りが逆になってしまいます。更新したあとの new や、動いた親を使って、孫をもう一度回すのもまちがいです。
少し変えてみよう

R → 右 → 右 → 右 → 右 で、最初の座標に戻り MOVES 4 です。R → 左 → 右 でも最初の座標に戻りますが、MOVES 2 です。MOVES 0 に戻せるのは R だけです。

02_world_rotation.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(286行)
02_world_rotation.py / 全文
# MIT License
# 
# Copyright (c) 2026 Pyxel 100 contributors
# 
# 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.
# 
# The bundled Noto-derived bitmap glyphs are separately licensed under SIL OFL.
# See assets/NOTO-LICENSE.txt.

"""ずれる星座・教材2/5。Python3.11以上 / Pyxel2.9.9。"""
from dataclasses import dataclass


@dataclass(frozen=True)
class Level:
    title: str
    ids: tuple[str, ...]
    parents: tuple[int, ...]
    start: tuple[tuple[int, int], ...]
    goals: tuple[tuple[int, int], ...]
    discovery: str
    size: int = 7


# 原作Cを変更せず採用。親は「添字」、座標は「世界座標」。
LEVEL = Level(
    "枝の中にもう一枝", ("O", "A", "B", "C", "D"), (-1, 0, 1, 0, 2),
    ((3, 3), (4, 3), (5, 3), (2, 3), (5, 2)),
    ((3, 3), (3, 2), (4, 2), (3, 4), (5, 2)),
    "Dは一度ずれても、また戻せる。",
)


@dataclass(frozen=True)
class State:
    positions: tuple[tuple[int, int], ...]
    moves: int = 0


def initial():
    return State(LEVEL.start)


def descendants(level, pivot):
    """真の子孫を親配列から探す。中心自身は含めず、一度ずつ返す。"""
    found = []
    pending = [pivot]
    while pending:
        parent_id = pending.pop()
        for i, parent in enumerate(level.parents):
            if parent == parent_id:
                found.append(i)
                pending.append(i)
    return tuple(sorted(found))


def edge_lengths(level, positions):
    # sqrtは不要。枝の長さの二乗を整数のまま比較できる。
    return tuple((positions[i][0] - positions[p][0]) ** 2
                 + (positions[i][1] - positions[p][1]) ** 2
                 for i, p in enumerate(level.parents) if p >= 0)


@dataclass(frozen=True)
class Result:
    state: State
    accepted: bool
    reason: str
    affected: tuple[int, ...] = ()
    proposed: tuple[tuple[int, int], ...] = ()
    blocked: tuple[int, ...] = ()


def transition(level, state, direction):
    """練習限定: 基点はO(0)に固定。まだ枝の途中は選ばない。"""
    if direction not in (-1, 1):
        return Result(state, False, "invalid")
    affected = descendants(level, 0)
    x, y = state.positions[0]
    new = list(state.positions)
    for i in affected:
        # 全員、更新前stateから差を取る。newから読むと二重回転の原因になる。
        dx, dy = state.positions[i][0] - x, state.positions[i][1] - y
        new[i] = (x - direction * dy, y + direction * dx)
    proposed = tuple(new)
    out = tuple(i for i, (px, py) in enumerate(proposed)
                if not 0 <= px < level.size or not 0 <= py < level.size)
    if out:
        return Result(state, False, "bounds", affected, proposed, out)
    collided = tuple(i for i, pos in enumerate(proposed) if proposed.count(pos) > 1)
    if collided:
        return Result(state, False, "collision", affected, proposed, collided)
    return Result(State(proposed, state.moves + 1), True, "rotate", affected, proposed)


def center(position):
    return 72 + position[0] * 40, 92 + position[1] * 40


def shape(p, i, x, y, color, filled=False, radius=9):
    if i == 0:
        p.circb(x, y, radius, color)
        p.pset(x, y, color)
    elif i == 1:
        (p.rect if filled else p.rectb)(x-radius, y-radius, radius*2+1, radius*2+1, color)
    elif i == 2:
        (p.tri if filled else p.trib)(x, y-radius, x-radius, y+radius, x+radius, y+radius, color)
    elif i == 3:
        (p.circ if filled else p.circb)(x, y, radius, color)
    elif filled:
        p.tri(x, y-radius, x-radius, y, x+radius, y, color)
        p.tri(x, y+radius, x-radius, y, x+radius, y, color)
    else:
        points = ((x, y-radius), (x+radius, y), (x, y+radius), (x-radius, y))
        for a, b in zip(points, points[1:] + points[:1]):
            p.line(*a, *b, color)


def branch(p, start, end, color):
    import math
    x, y = center(start)
    xx, yy = center(end)
    p.line(x, y, xx, yy, color)
    dx, dy = xx-x, yy-y
    length = max(1, math.hypot(dx, dy))
    ux, uy = dx/length, dy/length
    mx, my = x+dx*0.6, y+dy*0.6
    p.line(mx, my, mx-ux*5+uy*3, my-uy*5-ux*3, color)
    p.line(mx, my, mx-ux*5-uy*3, my-uy*5+ux*3, color)


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

    def reset(self):
        self.state = initial()
        self.selected = 0
        self.gated = False
        self.message = "ROOT O / LEFT OR RIGHT, THEN RELEASE"
        self.last_result = None

    def control(self, pressed, held):
        # 教材では毎操作後に全解放を要求。Rの新規押下は最優先。
        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 = "INVALID / RELEASE ALL KEYS"
            self.gated = True
            return
        if len(pressed) != 1:
            return
        key = next(iter(pressed))
        if key in ("LEFT", "RIGHT"):
            direction = 1 if key == "RIGHT" else -1
            result = transition(LEVEL, self.state, direction)
            self.state = result.state
            self.last_result = result
            self.message = result.reason.upper() + (" / ALL UNCHANGED" if not result.accepted else " / ONE TURN")
            self.gated = True

    def snapshot(self):
        from dataclasses import asdict
        return {"state": asdict(self.state), "selected": self.selected,
                "descendants": descendants(LEVEL, self.selected),
                "won": False,
                "preview": False,
                "history": [],
                "message": self.message}

    def draw(self, p):
        # 描画は読取専用。予告もstate/historyを書き換えない。
        state = self.state
        active = descendants(LEVEL, self.selected)
        p.cls(1)
        p.text(16, 12, "SHIFTED STARS / STAGE 2 / AUTHORED C", 10)
        p.text(16, 30, "OLD WORLD COORDINATES -> ONE SIMULTANEOUS 90-DEGREE TURN", 6)
        p.text(16, 47, "SCREEN COORDINATES: X RIGHT / Y DOWN", 7)
        p.rectb(48, 68, 290, 290, 5)
        for axis in range(7):
            p.text(70+axis*40, 74, str(axis), 13)
            p.text(54, 90+axis*40, str(axis), 13)
        for y in range(7):
            for x in range(7):
                p.pset(*center((x, y)), 5)
        for i, parent in enumerate(LEVEL.parents):
            if parent >= 0:
                branch(p, state.positions[parent], state.positions[i], 10 if i in active else 13)


        for i, position in enumerate(state.positions):
            x, y = center(position)
            shape(p, i, x, y, 10 if i in active else 7, True)
            if i == 0:
                p.circ(x, y, 6, 1)
            p.text(x-2, y-2, LEVEL.ids[i], 7 if i == 0 else 1)
            if i == self.selected:
                p.circb(x, y, 18, 7)
                p.circb(x, y, 20, 7)
        p.text(364, 72, "PIVOT: " + LEVEL.ids[self.selected], 7)
        p.text(364, 88, "DESCENDANTS: " + (" ".join(LEVEL.ids[i] for i in active) or "NONE / LEAF"), 10)
        p.text(364, 106, "ID   PARENT   WORLD (X,Y)", 6)
        for i, name in enumerate(LEVEL.ids):
            parent = LEVEL.parents[i]
            row = name + "    " + (LEVEL.ids[parent] if parent >= 0 else "-")
            row += "        " + str(state.positions[i])
            p.text(364, 125+i*18, row, 10 if i in active else 7)
        p.text(364, 226, "O -> A -> B -> D", 7)
        p.text(364, 242, "O -> C  (SEPARATE BRANCH)", 7)
        p.text(364, 264, "PIVOT AND OTHER BRANCH STAY", 6)
        p.text(364, 280, "EACH DESCENDANT ROTATES ONCE", 6)
        p.text(364, 300, "MOVES: " + str(state.moves), 10)
        p.text(364, 324, "ROOT PRACTICE / NO GOAL CHECK", 10)
        p.text(16, 370, self.message, 7)
        p.text(16, 388, "LEFT/RIGHT ROTATE ROOT O / R RESET / ESC QUIT", 6)
        p.text(16, 406, "RIGHT (+1): (DX,DY)->(-DY,DX) / LEFT (-1): (DY,-DX)", 5)


CONTROL_KEYS = ("UP", "DOWN", "LEFT", "RIGHT", "SPACE", "Z", "R")
DEMO_KEYS = ('LEFT',)


def main(headless=False, screenshot=None, demo=False, state_json=None):
    from pathlib import Path
    import json
    import pyxel as p  # 起動時だけimport。標準ライブラリ検査には不要。
    app = App()
    p.init(640, 430, title="Shifted Stars / Lesson 2", fps=30, display_scale=2, headless=headless)
    p.colors[:] = [0x090D16, 0x151D2B, 0x293441, 0x35654D, 0x674738, 0x778897, 0x85B9B5, 0xE8E1CE, 0xD96755, 0xC4884A, 0xF4C46B, 0xA8D68D, 0x4384A1, 0x8B969B, 0xD09B8C, 0xD6C5A6]
    keys = {name: getattr(p, "KEY_" + name) for name in CONTROL_KEYS}
    limit = max(12, len(DEMO_KEYS)*2+3) if demo else 12
    def update():
        if headless and p.frame_count >= limit:
            if screenshot:
                path = Path(screenshot)
                path.parent.mkdir(parents=True, exist_ok=True)
                p.screenshot(str(path.resolve()), scale=2)
            if state_json:
                path = Path(state_json)
                path.parent.mkdir(parents=True, exist_ok=True)
                path.write_text(json.dumps(app.snapshot(), ensure_ascii=False, indent=2)+"\n", encoding="utf-8")
            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))
    return app


if __name__ == "__main__":
    import argparse
    parser = argparse.ArgumentParser(description="ずれる星座・独立教材2/5")
    parser.add_argument("--headless-smoke", action="store_true")
    parser.add_argument("--demo-checkpoint", action="store_true")
    parser.add_argument("--screenshot")
    parser.add_argument("--state-json")
    args = parser.parse_args()
    if (args.demo_checkpoint or args.screenshot or args.state_json) and not args.headless_smoke:
        parser.error("自動確認の出力には --headless-smoke が必要です")
    main(args.headless_smoke, args.screenshot, args.demo_checkpoint, args.state_json)
STEP 03 / 05

回す枝を選び、原作Cを完成させる

候補全体をチェックしてまとめて確定し、ID ごとに目標と一致するかを見る。

01 の中心の選択と、02 の回転の式を組み合わせます。今度は上下で O・A・B・C・D を選べます。回るのは、選んだ星の本当の子孫だけです。中心そのもの、祖先、別の枝は、new の最初のコピーから変えません。A を中心にすると B と D が回り、O・A・C は同じ場所に残ります。木がどれだけ深くても、選んだ中心の古い座標を全員に使います。

transition は、(中心の番号, 向き) という意味のある入力を受け取ります。おかしな入力、完成したあとの入力、葉を中心にした回転は、順に理由を返して受け付けません。次に候補全体を作り、「盤の外」「星どうしの重なり」の順にチェックします。だれか1人でもだめなら、全員の位置と手数を元のままにします。これが「まとめて確定」です。線が交わったり、回る途中の弧が星を通ったりしても、受け付けないことはありません。判定するのは、回したあとのマスの点だけです。

同じ ID・同じ形の、中がぬけた輪を目標として表示しました。positions==goals で、ID の順に全部の座標が同じなら完成です。場所の集合だけを比べると、別の ID の星を入れかえた配置も、まちがって成功になってしまいます。1つの星が緑になっても、そのあと固定するルールはありません。元のゲームの状況 C では、D が最初から合っていますが、途中で動かして、最後に戻します。

左 → 下 → 右 → 下 → 右 と押すと、「O を反時計回り → A を時計回り → B を時計回り」で、3回で全員がそろいます。これが唯一の順番ではありません。元のゲームのモデルを全部調べると、C ではたどりつける状態が36、最短3回の順番が6通りあります。最短の回数は制限ではなく、参考の値です。03 以降は元のゲームの状況 C のふつうの回転のルールで、03 ではまだ予告と Undo は使いません。

教材の C では、ふつうにたどりつける状態から、盤の外で受け付けられないことは起きません。盤の外のルールは別に、元のゲームのモデルにはしの配置を渡した fixture でチェックしています。最初の位置で B を選んで左を押すと、D の候補が A と重なるので COLLISION(重なり)です。葉の C/D を中心にすると LEAF です。どちらも MOVES は増えません。

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

03_pivot_puzzle.py / 注目する部分
    if collided:
        return Result(state, False, "collision", affected, proposed, collided)
    return Result(State(proposed, state.moves + 1), True, "rotate", affected, proposed)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 03_pivot_puzzle.py
macOS / Linux
.venv/bin/python 03_pivot_puzzle.py
画面で見ること左 → 下 → 右 → 下 → 右 で COMPLETE(完成)、MOVES 3 です。座標は O(3,3)、A(3,2)、B(4,2)、C(3,4)、D(5,2) で、選択は B のままです。
  • すべての子孫を古い中心のまわりで1回だけ回し、中心と別の枝はそのまま
  • 盤の外・重なりは候補全体を受け付けない。線や途中の弧は判定しない
  • 全部の ID が一致したら成功。途中で、合っている星を動かしてもいい
つまずきやすいところ合っている D を固定してはいけません。だめな候補の一部だけを確定せず、元の State をそのまま返します。葉や完成したあとの入力でも、手数を増やしません。
少し変えてみよう

R → 下 → 下 → 左 で、B を中心に反時計回りにしてみましょう。D が A の座標 (4,3) に来る候補なので COLLISION になり、全員が最初の位置で MOVES 0 です。R → 下 → 下 → 下 → 右 なら、葉の C なので LEAF、同じく0です。

03_pivot_puzzle.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(317行)
03_pivot_puzzle.py / 全文
# MIT License
# 
# Copyright (c) 2026 Pyxel 100 contributors
# 
# 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.
# 
# The bundled Noto-derived bitmap glyphs are separately licensed under SIL OFL.
# See assets/NOTO-LICENSE.txt.

"""ずれる星座・教材3/5。Python3.11以上 / Pyxel2.9.9。"""
from dataclasses import dataclass


@dataclass(frozen=True)
class Level:
    title: str
    ids: tuple[str, ...]
    parents: tuple[int, ...]
    start: tuple[tuple[int, int], ...]
    goals: tuple[tuple[int, int], ...]
    discovery: str
    size: int = 7


# 原作Cを変更せず採用。親は「添字」、座標は「世界座標」。
LEVEL = Level(
    "枝の中にもう一枝", ("O", "A", "B", "C", "D"), (-1, 0, 1, 0, 2),
    ((3, 3), (4, 3), (5, 3), (2, 3), (5, 2)),
    ((3, 3), (3, 2), (4, 2), (3, 4), (5, 2)),
    "Dは一度ずれても、また戻せる。",
)


@dataclass(frozen=True)
class State:
    positions: tuple[tuple[int, int], ...]
    moves: int = 0


def initial():
    return State(LEVEL.start)


def descendants(level, pivot):
    """真の子孫を親配列から探す。中心自身は含めず、一度ずつ返す。"""
    found = []
    pending = [pivot]
    while pending:
        parent_id = pending.pop()
        for i, parent in enumerate(level.parents):
            if parent == parent_id:
                found.append(i)
                pending.append(i)
    return tuple(sorted(found))


def edge_lengths(level, positions):
    # sqrtは不要。枝の長さの二乗を整数のまま比較できる。
    return tuple((positions[i][0] - positions[p][0]) ** 2
                 + (positions[i][1] - positions[p][1]) ** 2
                 for i, p in enumerate(level.parents) if p >= 0)


@dataclass(frozen=True)
class Result:
    state: State
    accepted: bool
    reason: str
    affected: tuple[int, ...] = ()
    proposed: tuple[tuple[int, int], ...] = ()
    blocked: tuple[int, ...] = ()


def won(level, state):
    # 集合ではなくID順のタプルを比較。同じ場所でもID違いなら未完成。
    return state.positions == level.goals


def transition(level, state, action, terminal=True):
    """(基点の添字, 向き)。+1は画面の時計回り、-1は反時計回り。"""
    if not isinstance(action, tuple) or len(action) != 2:
        return Result(state, False, "invalid")
    pivot, direction = action
    if (not isinstance(pivot, int) or not 0 <= pivot < len(level.ids)
            or direction not in (-1, 1)):
        return Result(state, False, "invalid")
    if terminal and won(level, state):
        return Result(state, False, "complete")
    affected = descendants(level, pivot)
    if not affected:
        return Result(state, False, "leaf")
    x, y = state.positions[pivot]
    new = list(state.positions)
    for i in affected:
        dx, dy = state.positions[i][0] - x, state.positions[i][1] - y
        new[i] = (x - direction * dy, y + direction * dx)
    proposed = tuple(new)
    out = tuple(i for i, (px, py) in enumerate(proposed)
                if not 0 <= px < level.size or not 0 <= py < level.size)
    if out:
        return Result(state, False, "bounds", affected, proposed, out)
    collided = tuple(i for i, pos in enumerate(proposed) if proposed.count(pos) > 1)
    if collided:
        return Result(state, False, "collision", affected, proposed, collided)
    return Result(State(proposed, state.moves + 1), True, "rotate", affected, proposed)


def center(position):
    return 72 + position[0] * 40, 92 + position[1] * 40


def shape(p, i, x, y, color, filled=False, radius=9):
    if i == 0:
        p.circb(x, y, radius, color)
        p.pset(x, y, color)
    elif i == 1:
        (p.rect if filled else p.rectb)(x-radius, y-radius, radius*2+1, radius*2+1, color)
    elif i == 2:
        (p.tri if filled else p.trib)(x, y-radius, x-radius, y+radius, x+radius, y+radius, color)
    elif i == 3:
        (p.circ if filled else p.circb)(x, y, radius, color)
    elif filled:
        p.tri(x, y-radius, x-radius, y, x+radius, y, color)
        p.tri(x, y+radius, x-radius, y, x+radius, y, color)
    else:
        points = ((x, y-radius), (x+radius, y), (x, y+radius), (x-radius, y))
        for a, b in zip(points, points[1:] + points[:1]):
            p.line(*a, *b, color)


def branch(p, start, end, color):
    import math
    x, y = center(start)
    xx, yy = center(end)
    p.line(x, y, xx, yy, color)
    dx, dy = xx-x, yy-y
    length = max(1, math.hypot(dx, dy))
    ux, uy = dx/length, dy/length
    mx, my = x+dx*0.6, y+dy*0.6
    p.line(mx, my, mx-ux*5+uy*3, my-uy*5-ux*3, color)
    p.line(mx, my, mx-ux*5-uy*3, my-uy*5+ux*3, color)


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

    def reset(self):
        self.state = initial()
        self.selected = 0
        self.gated = False
        self.message = "SELECT A PIVOT / ONE KEY, THEN RELEASE"
        self.last_result = None

    def control(self, pressed, held):
        # 教材では毎操作後に全解放を要求。Rの新規押下は最優先。
        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 = "INVALID / RELEASE ALL KEYS"
            self.gated = True
            return
        if len(pressed) != 1:
            return
        key = next(iter(pressed))
        if won(LEVEL, self.state):
            self.message = "COMPLETE / R RESET"
            self.gated = True
            return
        if key in ("UP", "DOWN"):
            self.selected = (self.selected + (1 if key == "DOWN" else -1)) % len(LEVEL.ids)
            self.message = "SELECTED " + LEVEL.ids[self.selected]
            if not descendants(LEVEL, self.selected):
                self.message += " / LEAF: NO DESCENDANTS"
            self.last_result = None
            self.gated = True
            return
        if key in ("LEFT", "RIGHT"):
            direction = 1 if key == "RIGHT" else -1
            result = transition(LEVEL, self.state, (self.selected, direction))
            self.state = result.state
            self.last_result = result
            self.message = result.reason.upper() + (" / ALL UNCHANGED" if not result.accepted else " / ONE TURN")
            if won(LEVEL, self.state):
                self.message = "COMPLETE / ALL IDS MATCH THEIR GOALS"
            self.gated = True

    def snapshot(self):
        from dataclasses import asdict
        return {"state": asdict(self.state), "selected": self.selected,
                "descendants": descendants(LEVEL, self.selected),
                "won": won(LEVEL, self.state),
                "preview": False,
                "history": [],
                "message": self.message}

    def draw(self, p):
        # 描画は読取専用。予告もstate/historyを書き換えない。
        state = self.state
        active = descendants(LEVEL, self.selected)
        p.cls(1)
        p.text(16, 12, "SHIFTED STARS / STAGE 3 / AUTHORED C", 10)
        p.text(16, 30, "SELECT A PIVOT / MATCH EACH ID TO ITS HOLLOW GOAL", 6)
        p.text(16, 47, "SCREEN COORDINATES: X RIGHT / Y DOWN", 7)
        p.rectb(48, 68, 290, 290, 5)
        for axis in range(7):
            p.text(70+axis*40, 74, str(axis), 13)
            p.text(54, 90+axis*40, str(axis), 13)
        for y in range(7):
            for x in range(7):
                p.pset(*center((x, y)), 5)
        for i, parent in enumerate(LEVEL.parents):
            if parent >= 0:
                branch(p, state.positions[parent], state.positions[i], 10 if i in active else 13)
        for i, position in enumerate(LEVEL.goals):
            x, y = center(position)
            matched = position == state.positions[i]
            shape(p, i, x, y, 11 if matched else 5, radius=14)
            if not matched:
                p.text(x+14, y-17, LEVEL.ids[i], 5)

        for i, position in enumerate(state.positions):
            x, y = center(position)
            shape(p, i, x, y, 10 if i in active else 7, True)
            if i == 0:
                p.circ(x, y, 6, 1)
            p.text(x-2, y-2, LEVEL.ids[i], 7 if i == 0 else 1)
            if i == self.selected:
                p.circb(x, y, 18, 7)
                p.circb(x, y, 20, 7)
        p.text(364, 72, "PIVOT: " + LEVEL.ids[self.selected], 7)
        p.text(364, 88, "DESCENDANTS: " + (" ".join(LEVEL.ids[i] for i in active) or "NONE / LEAF"), 10)
        p.text(364, 106, "ID   PARENT   WORLD (X,Y)", 6)
        for i, name in enumerate(LEVEL.ids):
            parent = LEVEL.parents[i]
            row = name + "    " + (LEVEL.ids[parent] if parent >= 0 else "-")
            row += "        " + str(state.positions[i])
            p.text(364, 125+i*18, row, 10 if i in active else 7)
        p.text(364, 226, "O -> A -> B -> D", 7)
        p.text(364, 242, "O -> C  (SEPARATE BRANCH)", 7)
        p.text(364, 264, "PIVOT AND OTHER BRANCH STAY", 6)
        p.text(364, 280, "EACH DESCENDANT ROTATES ONCE", 6)
        p.text(364, 300, "MOVES: " + str(state.moves), 10)
        p.text(364, 324, "MATCHED: " + str(sum(a == b for a, b in zip(state.positions, LEVEL.goals))) + "/5", 11)
        p.text(16, 370, self.message, 7)
        p.text(16, 388, "UP/DOWN SELECT / LEFT/RIGHT ROTATE / R RESET / ESC QUIT", 6)
        p.text(16, 406, "FINAL POINTS MUST NOT OVERLAP / LINES AND ROTATION ARCS MAY CROSS", 5)


CONTROL_KEYS = ("UP", "DOWN", "LEFT", "RIGHT", "SPACE", "Z", "R")
DEMO_KEYS = ('LEFT', 'DOWN', 'RIGHT', 'DOWN', 'RIGHT')


def main(headless=False, screenshot=None, demo=False, state_json=None):
    from pathlib import Path
    import json
    import pyxel as p  # 起動時だけimport。標準ライブラリ検査には不要。
    app = App()
    p.init(640, 430, title="Shifted Stars / Lesson 3", fps=30, display_scale=2, headless=headless)
    p.colors[:] = [0x090D16, 0x151D2B, 0x293441, 0x35654D, 0x674738, 0x778897, 0x85B9B5, 0xE8E1CE, 0xD96755, 0xC4884A, 0xF4C46B, 0xA8D68D, 0x4384A1, 0x8B969B, 0xD09B8C, 0xD6C5A6]
    keys = {name: getattr(p, "KEY_" + name) for name in CONTROL_KEYS}
    limit = max(12, len(DEMO_KEYS)*2+3) if demo else 12
    def update():
        if headless and p.frame_count >= limit:
            if screenshot:
                path = Path(screenshot)
                path.parent.mkdir(parents=True, exist_ok=True)
                p.screenshot(str(path.resolve()), scale=2)
            if state_json:
                path = Path(state_json)
                path.parent.mkdir(parents=True, exist_ok=True)
                path.write_text(json.dumps(app.snapshot(), ensure_ascii=False, indent=2)+"\n", encoding="utf-8")
            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))
    return app


if __name__ == "__main__":
    import argparse
    parser = argparse.ArgumentParser(description="ずれる星座・独立教材3/5")
    parser.add_argument("--headless-smoke", action="store_true")
    parser.add_argument("--demo-checkpoint", action="store_true")
    parser.add_argument("--screenshot")
    parser.add_argument("--state-json")
    args = parser.parse_args()
    if (args.demo_checkpoint or args.screenshot or args.state_json) and not args.headless_smoke:
        parser.error("自動確認の出力には --headless-smoke が必要です")
    main(args.headless_smoke, args.screenshot, args.demo_checkpoint, args.state_json)
STEP 04 / 05

確定前の候補を、副作用なく見せる

同じ transition を使う、読むだけの予告。

SPACE で、右回りの予告を出したり消したりします。小さな輪と R が候補の位置です。右の欄の RIGHT には、ROTATE(回せる)、COLLISION(重なる)、LEAF(葉)などの判定が出て、その下に、実際の候補の座標が ID 付きで表示されます。これは左右両方の予告ではなく、元のゲームと同じ、時計回りだけの予告です。左回りで確定したいときは、ふつうに左を押します。

予告のために別の回転の式を書かず、transition(LEVEL,state,(selected,1),terminal=False) を使います。返ってきた Result を描くだけで、self.state には代入しません。だから、SPACE を何回押しても、手数・星の位置・完成の状態は変わりません。画面を毎フレーム描くだけでも、状態は進みません。

terminal=False は、形の予告を調べるためのものです。ふつうの確定の処理は terminal=True のままなので、完成したあとにさらに回せるようにはなりません。完成する前から予告を出していた場合は、完成したあともその形の候補を描くことがありますが、左右の確定は止まっています。完成したら R で最初から試せます。SPACE の切りかえも、完成する前だけです。

候補が重なる場合も、proposed(候補の位置)は返ってきます。そこで、だめな候補を赤く描き、候補の数字を表示できます。重なる星の候補は少し大きく描いて、本物の星にかくれにくくします。盤の外の印は、画面の外に消えないように枠の近くに寄せますが、右の欄の座標は本当の値です。これは見せ方の処理で、候補を盤の中に直す処理ではありません。元のゲームの状況 C のふつうのプレイでは盤の外にはなりませんが、関数の役割を分けておきます。

元のゲームも、予告と確定に同じ関数(状態を書きかえない関数)を使います。元のゲームでは小さな R の輪を描き、教材では学習のために、結果の理由と数値を右の欄に加えました。予告で回せると分かっても、自分で右を押すまでは確定しません。

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

04_preview.py / 注目する部分
            result = transition(LEVEL, state, (self.selected, 1), terminal=False)
            for i in result.affected:

実行して確かめる

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

Windows
.venv\Scripts\python.exe 04_preview.py
macOS / Linux
.venv/bin/python 04_preview.py
画面で見ること左 → 下 → SPACE と押します。本物の位置は1回回したあとのまま、MOVES 1、中心は A です。予告は B(4,2)・D(4,1)、RIGHT ROTATE で、O・A・C には予告が出ません。
  • 予告は時計回りだけで、同じ transition を使い回す
  • Result を表示するだけで、state には代入しない
  • 赤い候補もそのまま表示し、ふつうの確定は完成後のロックを守る
つまずきやすいところ予告を作るたびに self.state=result.state と代入しないようにしましょう。terminal=False をふつうの確定にも渡すと、完成したあとのロックがなくなってしまいます。
少し変えてみよう

R → 下 → 右 → SPACE と押してみましょう。A を中心に1回回したあと、次の右回りは、B が O (3,3) と重なる赤い候補です。さらに SPACE → SPACE と切りかえても、MOVES 1、B(4,4)、D(5,4) のままです。

04_preview.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(340行)
04_preview.py / 全文
# MIT License
# 
# Copyright (c) 2026 Pyxel 100 contributors
# 
# 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.
# 
# The bundled Noto-derived bitmap glyphs are separately licensed under SIL OFL.
# See assets/NOTO-LICENSE.txt.

"""ずれる星座・教材4/5。Python3.11以上 / Pyxel2.9.9。"""
from dataclasses import dataclass


@dataclass(frozen=True)
class Level:
    title: str
    ids: tuple[str, ...]
    parents: tuple[int, ...]
    start: tuple[tuple[int, int], ...]
    goals: tuple[tuple[int, int], ...]
    discovery: str
    size: int = 7


# 原作Cを変更せず採用。親は「添字」、座標は「世界座標」。
LEVEL = Level(
    "枝の中にもう一枝", ("O", "A", "B", "C", "D"), (-1, 0, 1, 0, 2),
    ((3, 3), (4, 3), (5, 3), (2, 3), (5, 2)),
    ((3, 3), (3, 2), (4, 2), (3, 4), (5, 2)),
    "Dは一度ずれても、また戻せる。",
)


@dataclass(frozen=True)
class State:
    positions: tuple[tuple[int, int], ...]
    moves: int = 0


def initial():
    return State(LEVEL.start)


def descendants(level, pivot):
    """真の子孫を親配列から探す。中心自身は含めず、一度ずつ返す。"""
    found = []
    pending = [pivot]
    while pending:
        parent_id = pending.pop()
        for i, parent in enumerate(level.parents):
            if parent == parent_id:
                found.append(i)
                pending.append(i)
    return tuple(sorted(found))


def edge_lengths(level, positions):
    # sqrtは不要。枝の長さの二乗を整数のまま比較できる。
    return tuple((positions[i][0] - positions[p][0]) ** 2
                 + (positions[i][1] - positions[p][1]) ** 2
                 for i, p in enumerate(level.parents) if p >= 0)


@dataclass(frozen=True)
class Result:
    state: State
    accepted: bool
    reason: str
    affected: tuple[int, ...] = ()
    proposed: tuple[tuple[int, int], ...] = ()
    blocked: tuple[int, ...] = ()


def won(level, state):
    # 集合ではなくID順のタプルを比較。同じ場所でもID違いなら未完成。
    return state.positions == level.goals


def transition(level, state, action, terminal=True):
    """(基点の添字, 向き)。+1は画面の時計回り、-1は反時計回り。"""
    if not isinstance(action, tuple) or len(action) != 2:
        return Result(state, False, "invalid")
    pivot, direction = action
    if (not isinstance(pivot, int) or not 0 <= pivot < len(level.ids)
            or direction not in (-1, 1)):
        return Result(state, False, "invalid")
    if terminal and won(level, state):
        return Result(state, False, "complete")
    affected = descendants(level, pivot)
    if not affected:
        return Result(state, False, "leaf")
    x, y = state.positions[pivot]
    new = list(state.positions)
    for i in affected:
        dx, dy = state.positions[i][0] - x, state.positions[i][1] - y
        new[i] = (x - direction * dy, y + direction * dx)
    proposed = tuple(new)
    out = tuple(i for i, (px, py) in enumerate(proposed)
                if not 0 <= px < level.size or not 0 <= py < level.size)
    if out:
        return Result(state, False, "bounds", affected, proposed, out)
    collided = tuple(i for i, pos in enumerate(proposed) if proposed.count(pos) > 1)
    if collided:
        return Result(state, False, "collision", affected, proposed, collided)
    return Result(State(proposed, state.moves + 1), True, "rotate", affected, proposed)


def center(position):
    return 72 + position[0] * 40, 92 + position[1] * 40


def shape(p, i, x, y, color, filled=False, radius=9):
    if i == 0:
        p.circb(x, y, radius, color)
        p.pset(x, y, color)
    elif i == 1:
        (p.rect if filled else p.rectb)(x-radius, y-radius, radius*2+1, radius*2+1, color)
    elif i == 2:
        (p.tri if filled else p.trib)(x, y-radius, x-radius, y+radius, x+radius, y+radius, color)
    elif i == 3:
        (p.circ if filled else p.circb)(x, y, radius, color)
    elif filled:
        p.tri(x, y-radius, x-radius, y, x+radius, y, color)
        p.tri(x, y+radius, x-radius, y, x+radius, y, color)
    else:
        points = ((x, y-radius), (x+radius, y), (x, y+radius), (x-radius, y))
        for a, b in zip(points, points[1:] + points[:1]):
            p.line(*a, *b, color)


def branch(p, start, end, color):
    import math
    x, y = center(start)
    xx, yy = center(end)
    p.line(x, y, xx, yy, color)
    dx, dy = xx-x, yy-y
    length = max(1, math.hypot(dx, dy))
    ux, uy = dx/length, dy/length
    mx, my = x+dx*0.6, y+dy*0.6
    p.line(mx, my, mx-ux*5+uy*3, my-uy*5-ux*3, color)
    p.line(mx, my, mx-ux*5-uy*3, my-uy*5+ux*3, color)


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

    def reset(self):
        self.state = initial()
        self.selected = 0
        self.gated = False
        self.message = "SELECT A PIVOT / ONE KEY, THEN RELEASE"
        self.last_result = None
        self.preview = False

    def control(self, pressed, held):
        # 教材では毎操作後に全解放を要求。Rの新規押下は最優先。
        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 = "INVALID / RELEASE ALL KEYS"
            self.gated = True
            return
        if len(pressed) != 1:
            return
        key = next(iter(pressed))
        if won(LEVEL, self.state):
            self.message = "COMPLETE / R RESET"
            self.gated = True
            return
        if key in ("UP", "DOWN"):
            self.selected = (self.selected + (1 if key == "DOWN" else -1)) % len(LEVEL.ids)
            self.message = "SELECTED " + LEVEL.ids[self.selected]
            if not descendants(LEVEL, self.selected):
                self.message += " / LEAF: NO DESCENDANTS"
            self.last_result = None
            self.gated = True
            return
        if key == "SPACE":
            self.preview = not self.preview
            self.message = "RIGHT PREVIEW " + ("ON" if self.preview else "OFF")
            self.gated = True
            return
        if key in ("LEFT", "RIGHT"):
            direction = 1 if key == "RIGHT" else -1
            result = transition(LEVEL, self.state, (self.selected, direction))
            self.state = result.state
            self.last_result = result
            self.message = result.reason.upper() + (" / ALL UNCHANGED" if not result.accepted else " / ONE TURN")
            if won(LEVEL, self.state):
                self.message = "COMPLETE / ALL IDS MATCH THEIR GOALS"
            self.gated = True

    def snapshot(self):
        from dataclasses import asdict
        return {"state": asdict(self.state), "selected": self.selected,
                "descendants": descendants(LEVEL, self.selected),
                "won": won(LEVEL, self.state),
                "preview": self.preview,
                "history": [],
                "message": self.message}

    def draw(self, p):
        # 描画は読取専用。予告もstate/historyを書き換えない。
        state = self.state
        active = descendants(LEVEL, self.selected)
        p.cls(1)
        p.text(16, 12, "SHIFTED STARS / STAGE 4 / AUTHORED C", 10)
        p.text(16, 30, "PREVIEW DOES NOT COMMIT / R MARKS CLOCKWISE CANDIDATES", 6)
        p.text(16, 47, "SCREEN COORDINATES: X RIGHT / Y DOWN", 7)
        p.rectb(48, 68, 290, 290, 5)
        for axis in range(7):
            p.text(70+axis*40, 74, str(axis), 13)
            p.text(54, 90+axis*40, str(axis), 13)
        for y in range(7):
            for x in range(7):
                p.pset(*center((x, y)), 5)
        for i, parent in enumerate(LEVEL.parents):
            if parent >= 0:
                branch(p, state.positions[parent], state.positions[i], 10 if i in active else 13)
        for i, position in enumerate(LEVEL.goals):
            x, y = center(position)
            matched = position == state.positions[i]
            shape(p, i, x, y, 11 if matched else 5, radius=14)
            if not matched:
                p.text(x+14, y-17, LEVEL.ids[i], 5)
        if self.preview:
            result = transition(LEVEL, state, (self.selected, 1), terminal=False)
            for i in result.affected:
                # 盤外候補だけは盤枠へ寄せる。右欄には本当の座標を表示。
                px, py = center(result.proposed[i])
                px, py = min(330, max(56, px)), min(350, max(78, py))
                color = 6 if result.accepted else 8
                radius = 12 if not result.accepted and i in result.blocked else 5
                shape(p, i, px, py, color, radius=radius)
                p.text(px+radius+1, py+3, "R", color)
        for i, position in enumerate(state.positions):
            x, y = center(position)
            shape(p, i, x, y, 10 if i in active else 7, True)
            if i == 0:
                p.circ(x, y, 6, 1)
            p.text(x-2, y-2, LEVEL.ids[i], 7 if i == 0 else 1)
            if i == self.selected:
                p.circb(x, y, 18, 7)
                p.circb(x, y, 20, 7)
        p.text(364, 72, "PIVOT: " + LEVEL.ids[self.selected], 7)
        p.text(364, 88, "DESCENDANTS: " + (" ".join(LEVEL.ids[i] for i in active) or "NONE / LEAF"), 10)
        p.text(364, 106, "ID   PARENT   WORLD (X,Y)", 6)
        for i, name in enumerate(LEVEL.ids):
            parent = LEVEL.parents[i]
            row = name + "    " + (LEVEL.ids[parent] if parent >= 0 else "-")
            row += "        " + str(state.positions[i])
            p.text(364, 125+i*18, row, 10 if i in active else 7)
        p.text(364, 226, "O -> A -> B -> D", 7)
        p.text(364, 242, "O -> C  (SEPARATE BRANCH)", 7)
        p.text(364, 264, "PIVOT AND OTHER BRANCH STAY", 6)
        p.text(364, 280, "EACH DESCENDANT ROTATES ONCE", 6)
        p.text(364, 300, "MOVES: " + str(state.moves), 10)
        if self.preview:
            result = transition(LEVEL, state, (self.selected, 1), terminal=False)
            p.text(364, 320, "RIGHT: " + result.reason.upper(), 6 if result.accepted else 8)
            # 最大4星なので二行に分ける。表示は世界座標のまま。
            items = [LEVEL.ids[i] + str(result.proposed[i]) for i in result.affected]
            p.text(364, 334, " ".join(items[:2]), 6)
            p.text(364, 348, " ".join(items[2:]), 6)
        else:
            p.text(364, 324, "SPACE: RIGHT PREVIEW", 6)
        p.text(16, 370, self.message, 7)
        p.text(16, 388, "UP/DOWN SELECT / LEFT/RIGHT ROTATE / SPACE PREVIEW / R RESET / ESC QUIT", 6)
        p.text(16, 406, "PREVIEW CAN CLAMP OUTSIDE MARKERS; LISTED COORDINATES ARE UNCHANGED", 5)


CONTROL_KEYS = ("UP", "DOWN", "LEFT", "RIGHT", "SPACE", "Z", "R")
DEMO_KEYS = ('LEFT', 'DOWN', 'SPACE')


def main(headless=False, screenshot=None, demo=False, state_json=None):
    from pathlib import Path
    import json
    import pyxel as p  # 起動時だけimport。標準ライブラリ検査には不要。
    app = App()
    p.init(640, 430, title="Shifted Stars / Lesson 4", fps=30, display_scale=2, headless=headless)
    p.colors[:] = [0x090D16, 0x151D2B, 0x293441, 0x35654D, 0x674738, 0x778897, 0x85B9B5, 0xE8E1CE, 0xD96755, 0xC4884A, 0xF4C46B, 0xA8D68D, 0x4384A1, 0x8B969B, 0xD09B8C, 0xD6C5A6]
    keys = {name: getattr(p, "KEY_" + name) for name in CONTROL_KEYS}
    limit = max(12, len(DEMO_KEYS)*2+3) if demo else 12
    def update():
        if headless and p.frame_count >= limit:
            if screenshot:
                path = Path(screenshot)
                path.parent.mkdir(parents=True, exist_ok=True)
                p.screenshot(str(path.resolve()), scale=2)
            if state_json:
                path = Path(state_json)
                path.parent.mkdir(parents=True, exist_ok=True)
                path.write_text(json.dumps(app.snapshot(), ensure_ascii=False, indent=2)+"\n", encoding="utf-8")
            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))
    return app


if __name__ == "__main__":
    import argparse
    parser = argparse.ArgumentParser(description="ずれる星座・独立教材4/5")
    parser.add_argument("--headless-smoke", action="store_true")
    parser.add_argument("--demo-checkpoint", action="store_true")
    parser.add_argument("--screenshot")
    parser.add_argument("--state-json")
    args = parser.parse_args()
    if (args.demo_checkpoint or args.screenshot or args.state_json) and not args.headless_smoke:
        parser.error("自動確認の出力には --headless-smoke が必要です")
    main(args.headless_smoke, args.screenshot, args.demo_checkpoint, args.state_json)
STEP 05 / 05

確定した回転だけを、丸ごと戻す

書きかえない State の履歴と、完成した状態からの Undo。

Timeline は、今の State と、過去の State を並べたタプルを持ちます。apply は transition に入力を渡し、accepted(受け付けた)が True のときだけ、確定する前の State を past の最後に積みます。重なり・盤の外・葉・おかしな入力・完成したあとの回転は記録しません。中心の選択や、SPACE の予告の切りかえも回転ではないので、履歴には入りません。

Z で戻すと、past[-1] が今の状態になり、past[:-1] が新しい履歴になります。座標だけでなく、moves も一緒に元に戻します。成功かどうかは、毎回 positions==goals から求めるので、成功の印を別に消す必要はありません。完成したあとも Z を受け付けるように、control では、完成の判定より先に Z を処理します。

選んでいる中心や、予告のオン・オフは State に入れていません。Z のあとも、選択はそのままです。たとえば B を中心にして完成させてから Z を押すと、B を選んだまま、2回回したあとの配置に戻ります。カーソルまで過去に戻す Undo ではありません。履歴が空のときの Z は EMPTY と表示し、State も Timeline も変えません。

戻ったあとで別の回転を選ぶと、その時点の過去に新しい State を積みます。取り消した未来を残しておくやり直し(Redo)はありません。だめな候補を表示している場合でも、Z が戻すのは、最後に確定した回転だけです。R は、履歴も選択も予告も最初に戻します。

ここまでで、元のゲームの状況 C の完成版です。「O を反時計回り → A を時計回り → B を時計回り」の途中で、何度でも考え直せます。勝つまでにたどりつける36の状態と、それぞれの回転の結果を、元のゲームをもとにした fixture で比べ、すべての状態について「正しい回転 → Undo」で完全に元の Timeline に戻ることも確かめました。操作・モデル・描くことを分けたので、元のゲームがなくても、標準ライブラリだけでルールを確かめ直せます。

Undo は、逆向きの回転を新しく実行することとはちがいます。逆回転なら、正しい手でも手数が1つ増えますが、Undo は保存した過去に戻るので手数が減り、その回転をする前の履歴まで元に戻ります。

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

05_undo.py / 注目する部分
    if result.accepted:
        return Timeline(result.state, timeline.past + (timeline.state,)), result
    return timeline, result

実行して確かめる

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

Windows
.venv\Scripts\python.exe 05_undo.py
macOS / Linux
.venv/bin/python 05_undo.py
画面で見ること左 → 下 → 右 → 下 → 右 で完成させてから Z を押します。MOVES 2、HISTORY 2、中心は B のままです。D が (4,1) に戻り、未完成になります。ほかの星は目標にそろったままです。
  • 成功した回転だけ、確定する前の State を保存する
  • Z で、座標・手数・そこから決まる完成の状態を戻す
  • 選択と予告は履歴に入れない。完成後も Z が使え、Redo はない
つまずきやすいところ受け付けなかった操作や中心の選択を、past に積まないようにしましょう。座標だけを戻して、手数を残さないようにします。完成の状態で return するより前に、Z を処理します。
少し変えてみよう

上の結果のあと、Z を2回押すと、最初の位置・MOVES 0・HISTORY 0 です。中心は B のままです。もう1回 Z を押すと EMPTY で、何も変わりません。R → 下 → 右 → 右 では、2回目が重なりなので HISTORY 1、Z 1回で最初の位置に戻ります。

05_undo.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(368行)
05_undo.py / 全文
# MIT License
# 
# Copyright (c) 2026 Pyxel 100 contributors
# 
# 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.
# 
# The bundled Noto-derived bitmap glyphs are separately licensed under SIL OFL.
# See assets/NOTO-LICENSE.txt.

"""ずれる星座・教材5/5。Python3.11以上 / Pyxel2.9.9。"""
from dataclasses import dataclass


@dataclass(frozen=True)
class Level:
    title: str
    ids: tuple[str, ...]
    parents: tuple[int, ...]
    start: tuple[tuple[int, int], ...]
    goals: tuple[tuple[int, int], ...]
    discovery: str
    size: int = 7


# 原作Cを変更せず採用。親は「添字」、座標は「世界座標」。
LEVEL = Level(
    "枝の中にもう一枝", ("O", "A", "B", "C", "D"), (-1, 0, 1, 0, 2),
    ((3, 3), (4, 3), (5, 3), (2, 3), (5, 2)),
    ((3, 3), (3, 2), (4, 2), (3, 4), (5, 2)),
    "Dは一度ずれても、また戻せる。",
)


@dataclass(frozen=True)
class State:
    positions: tuple[tuple[int, int], ...]
    moves: int = 0


def initial():
    return State(LEVEL.start)


def descendants(level, pivot):
    """真の子孫を親配列から探す。中心自身は含めず、一度ずつ返す。"""
    found = []
    pending = [pivot]
    while pending:
        parent_id = pending.pop()
        for i, parent in enumerate(level.parents):
            if parent == parent_id:
                found.append(i)
                pending.append(i)
    return tuple(sorted(found))


def edge_lengths(level, positions):
    # sqrtは不要。枝の長さの二乗を整数のまま比較できる。
    return tuple((positions[i][0] - positions[p][0]) ** 2
                 + (positions[i][1] - positions[p][1]) ** 2
                 for i, p in enumerate(level.parents) if p >= 0)


@dataclass(frozen=True)
class Result:
    state: State
    accepted: bool
    reason: str
    affected: tuple[int, ...] = ()
    proposed: tuple[tuple[int, int], ...] = ()
    blocked: tuple[int, ...] = ()


def won(level, state):
    # 集合ではなくID順のタプルを比較。同じ場所でもID違いなら未完成。
    return state.positions == level.goals


def transition(level, state, action, terminal=True):
    """(基点の添字, 向き)。+1は画面の時計回り、-1は反時計回り。"""
    if not isinstance(action, tuple) or len(action) != 2:
        return Result(state, False, "invalid")
    pivot, direction = action
    if (not isinstance(pivot, int) or not 0 <= pivot < len(level.ids)
            or direction not in (-1, 1)):
        return Result(state, False, "invalid")
    if terminal and won(level, state):
        return Result(state, False, "complete")
    affected = descendants(level, pivot)
    if not affected:
        return Result(state, False, "leaf")
    x, y = state.positions[pivot]
    new = list(state.positions)
    for i in affected:
        dx, dy = state.positions[i][0] - x, state.positions[i][1] - y
        new[i] = (x - direction * dy, y + direction * dx)
    proposed = tuple(new)
    out = tuple(i for i, (px, py) in enumerate(proposed)
                if not 0 <= px < level.size or not 0 <= py < level.size)
    if out:
        return Result(state, False, "bounds", affected, proposed, out)
    collided = tuple(i for i, pos in enumerate(proposed) if proposed.count(pos) > 1)
    if collided:
        return Result(state, False, "collision", affected, proposed, collided)
    return Result(State(proposed, state.moves + 1), True, "rotate", affected, proposed)


@dataclass(frozen=True)
class Timeline:
    state: State
    past: tuple[State, ...] = ()


def apply(level, timeline, action):
    result = transition(level, timeline.state, action)
    if result.accepted:
        return Timeline(result.state, timeline.past + (timeline.state,)), result
    return timeline, result


def undo(timeline):
    if not timeline.past:
        return timeline
    return Timeline(timeline.past[-1], timeline.past[:-1])


def center(position):
    return 72 + position[0] * 40, 92 + position[1] * 40


def shape(p, i, x, y, color, filled=False, radius=9):
    if i == 0:
        p.circb(x, y, radius, color)
        p.pset(x, y, color)
    elif i == 1:
        (p.rect if filled else p.rectb)(x-radius, y-radius, radius*2+1, radius*2+1, color)
    elif i == 2:
        (p.tri if filled else p.trib)(x, y-radius, x-radius, y+radius, x+radius, y+radius, color)
    elif i == 3:
        (p.circ if filled else p.circb)(x, y, radius, color)
    elif filled:
        p.tri(x, y-radius, x-radius, y, x+radius, y, color)
        p.tri(x, y+radius, x-radius, y, x+radius, y, color)
    else:
        points = ((x, y-radius), (x+radius, y), (x, y+radius), (x-radius, y))
        for a, b in zip(points, points[1:] + points[:1]):
            p.line(*a, *b, color)


def branch(p, start, end, color):
    import math
    x, y = center(start)
    xx, yy = center(end)
    p.line(x, y, xx, yy, color)
    dx, dy = xx-x, yy-y
    length = max(1, math.hypot(dx, dy))
    ux, uy = dx/length, dy/length
    mx, my = x+dx*0.6, y+dy*0.6
    p.line(mx, my, mx-ux*5+uy*3, my-uy*5-ux*3, color)
    p.line(mx, my, mx-ux*5-uy*3, my-uy*5+ux*3, color)


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

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

    def reset(self):
        self.timeline = Timeline(initial())
        self.selected = 0
        self.gated = False
        self.message = "SELECT A PIVOT / ONE KEY, THEN RELEASE"
        self.last_result = None
        self.preview = False

    def control(self, pressed, held):
        # 教材では毎操作後に全解放を要求。Rの新規押下は最優先。
        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 = "INVALID / RELEASE ALL KEYS"
            self.gated = True
            return
        if len(pressed) != 1:
            return
        key = next(iter(pressed))
        if key == "Z":
            self.message = "UNDO" if self.timeline.past else "EMPTY / NOTHING TO UNDO"
            self.timeline = undo(self.timeline)
            self.last_result = None
            self.gated = True
            return
        if won(LEVEL, self.state):
            self.message = "COMPLETE / R RESET" + " / Z UNDO"
            self.gated = True
            return
        if key in ("UP", "DOWN"):
            self.selected = (self.selected + (1 if key == "DOWN" else -1)) % len(LEVEL.ids)
            self.message = "SELECTED " + LEVEL.ids[self.selected]
            if not descendants(LEVEL, self.selected):
                self.message += " / LEAF: NO DESCENDANTS"
            self.last_result = None
            self.gated = True
            return
        if key == "SPACE":
            self.preview = not self.preview
            self.message = "RIGHT PREVIEW " + ("ON" if self.preview else "OFF")
            self.gated = True
            return
        if key in ("LEFT", "RIGHT"):
            direction = 1 if key == "RIGHT" else -1
            self.timeline, result = apply(LEVEL, self.timeline, (self.selected, direction))
            self.last_result = result
            self.message = result.reason.upper() + (" / ALL UNCHANGED" if not result.accepted else " / ONE TURN")
            if won(LEVEL, self.state):
                self.message = "COMPLETE / ALL IDS MATCH THEIR GOALS"
            self.gated = True

    def snapshot(self):
        from dataclasses import asdict
        return {"state": asdict(self.state), "selected": self.selected,
                "descendants": descendants(LEVEL, self.selected),
                "won": won(LEVEL, self.state),
                "preview": self.preview,
                "history": [asdict(s) for s in self.timeline.past],
                "message": self.message}

    def draw(self, p):
        # 描画は読取専用。予告もstate/historyを書き換えない。
        state = self.state
        active = descendants(LEVEL, self.selected)
        p.cls(1)
        p.text(16, 12, "SHIFTED STARS / STAGE 5 / AUTHORED C", 10)
        p.text(16, 30, "ONLY ACCEPTED ROTATIONS ENTER HISTORY / UNDO ALSO AFTER GOAL", 6)
        p.text(16, 47, "SCREEN COORDINATES: X RIGHT / Y DOWN", 7)
        p.rectb(48, 68, 290, 290, 5)
        for axis in range(7):
            p.text(70+axis*40, 74, str(axis), 13)
            p.text(54, 90+axis*40, str(axis), 13)
        for y in range(7):
            for x in range(7):
                p.pset(*center((x, y)), 5)
        for i, parent in enumerate(LEVEL.parents):
            if parent >= 0:
                branch(p, state.positions[parent], state.positions[i], 10 if i in active else 13)
        for i, position in enumerate(LEVEL.goals):
            x, y = center(position)
            matched = position == state.positions[i]
            shape(p, i, x, y, 11 if matched else 5, radius=14)
            if not matched:
                p.text(x+14, y-17, LEVEL.ids[i], 5)
        if self.preview:
            result = transition(LEVEL, state, (self.selected, 1), terminal=False)
            for i in result.affected:
                # 盤外候補だけは盤枠へ寄せる。右欄には本当の座標を表示。
                px, py = center(result.proposed[i])
                px, py = min(330, max(56, px)), min(350, max(78, py))
                color = 6 if result.accepted else 8
                radius = 12 if not result.accepted and i in result.blocked else 5
                shape(p, i, px, py, color, radius=radius)
                p.text(px+radius+1, py+3, "R", color)
        for i, position in enumerate(state.positions):
            x, y = center(position)
            shape(p, i, x, y, 10 if i in active else 7, True)
            if i == 0:
                p.circ(x, y, 6, 1)
            p.text(x-2, y-2, LEVEL.ids[i], 7 if i == 0 else 1)
            if i == self.selected:
                p.circb(x, y, 18, 7)
                p.circb(x, y, 20, 7)
        p.text(364, 72, "PIVOT: " + LEVEL.ids[self.selected], 7)
        p.text(364, 88, "DESCENDANTS: " + (" ".join(LEVEL.ids[i] for i in active) or "NONE / LEAF"), 10)
        p.text(364, 106, "ID   PARENT   WORLD (X,Y)", 6)
        for i, name in enumerate(LEVEL.ids):
            parent = LEVEL.parents[i]
            row = name + "    " + (LEVEL.ids[parent] if parent >= 0 else "-")
            row += "        " + str(state.positions[i])
            p.text(364, 125+i*18, row, 10 if i in active else 7)
        p.text(364, 226, "O -> A -> B -> D", 7)
        p.text(364, 242, "O -> C  (SEPARATE BRANCH)", 7)
        p.text(364, 264, "PIVOT AND OTHER BRANCH STAY", 6)
        p.text(364, 280, "EACH DESCENDANT ROTATES ONCE", 6)
        p.text(364, 300, "MOVES: " + str(state.moves) + " / HISTORY: " + str(len(self.timeline.past)), 10)
        if self.preview:
            result = transition(LEVEL, state, (self.selected, 1), terminal=False)
            p.text(364, 320, "RIGHT: " + result.reason.upper(), 6 if result.accepted else 8)
            # 最大4星なので二行に分ける。表示は世界座標のまま。
            items = [LEVEL.ids[i] + str(result.proposed[i]) for i in result.affected]
            p.text(364, 334, " ".join(items[:2]), 6)
            p.text(364, 348, " ".join(items[2:]), 6)
        else:
            p.text(364, 324, "SPACE: RIGHT PREVIEW", 6)
        p.text(16, 370, self.message, 7)
        p.text(16, 388, "UP/DOWN SELECT / LEFT/RIGHT ROTATE / SPACE PREVIEW / Z UNDO / R RESET / ESC QUIT", 6)
        p.text(16, 406, "UNDO RESTORES POSITIONS AND MOVES / SELECTION IS NOT A RECORDED MOVE", 5)


CONTROL_KEYS = ("UP", "DOWN", "LEFT", "RIGHT", "SPACE", "Z", "R")
DEMO_KEYS = ('LEFT', 'DOWN', 'RIGHT', 'DOWN', 'RIGHT', 'Z')


def main(headless=False, screenshot=None, demo=False, state_json=None):
    from pathlib import Path
    import json
    import pyxel as p  # 起動時だけimport。標準ライブラリ検査には不要。
    app = App()
    p.init(640, 430, title="Shifted Stars / Lesson 5", fps=30, display_scale=2, headless=headless)
    p.colors[:] = [0x090D16, 0x151D2B, 0x293441, 0x35654D, 0x674738, 0x778897, 0x85B9B5, 0xE8E1CE, 0xD96755, 0xC4884A, 0xF4C46B, 0xA8D68D, 0x4384A1, 0x8B969B, 0xD09B8C, 0xD6C5A6]
    keys = {name: getattr(p, "KEY_" + name) for name in CONTROL_KEYS}
    limit = max(12, len(DEMO_KEYS)*2+3) if demo else 12
    def update():
        if headless and p.frame_count >= limit:
            if screenshot:
                path = Path(screenshot)
                path.parent.mkdir(parents=True, exist_ok=True)
                p.screenshot(str(path.resolve()), scale=2)
            if state_json:
                path = Path(state_json)
                path.parent.mkdir(parents=True, exist_ok=True)
                path.write_text(json.dumps(app.snapshot(), ensure_ascii=False, indent=2)+"\n", encoding="utf-8")
            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))
    return app


if __name__ == "__main__":
    import argparse
    parser = argparse.ArgumentParser(description="ずれる星座・独立教材5/5")
    parser.add_argument("--headless-smoke", action="store_true")
    parser.add_argument("--demo-checkpoint", action="store_true")
    parser.add_argument("--screenshot")
    parser.add_argument("--state-json")
    args = parser.parse_args()
    if (args.demo_checkpoint or args.screenshot or args.state_json) and not args.headless_smoke:
        parser.error("自動確認の出力には --headless-smoke が必要です")
    main(args.headless_smoke, args.screenshot, args.demo_checkpoint, args.state_json)
AFTER STEP 05

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

元のゲームの状況 C「枝の中にもう一枝」です。O→A→B→D と O→C の決まった木で、画面の x は右、y は下に増えます。選んだ星の本当の子孫の古い座標を、古い中心のまわりで90度ずつ、1回だけ変換します。中心と別の枝は動きません。回したあとのマスの点で、盤の外・重なりがあれば全体を受け付けません。全部の ID が目標と一致したら成功です。合っている D も、一時的に動かせます。

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

比べる点5ステップの教材元の完成版
段階01 は選ぶだけ、02 は O を中心にした回転の練習で、目標の判定はありません。03 以降が元のゲームの状況 C の回転のルールで、04 で予告、05 で Undo です。A・B・C の3つの手作りの星座があります。予告と Undo は最初から使えます。
データとルール状況 C の ID・親・最初の位置・目標を、変えずに使います。木は決まった定数として使い、どんな Level でも確かめる関数は入れていません。validate_level で、すべての Level の根・親・循環・枝の長さ・盤の中でちがう座標であることを確かめます。
入力毎回の操作のあと、全部のキーを離すまで待ちます。R を押した瞬間を優先し、同時押しは受け付けません。選択と予告を Undo にふくめないのは、元のゲームと同じです。ふつうの入力は押すたびに処理し、開始・一時停止・面の切りかえなどの場面の変わり目で、キーを離すまで待ちます。
表示640×430、文字(ASCII)と図形で、すぐに始まります。親の番号・画面全体の座標・候補の座標を、右の欄に加えています。384×420、日本語の文字、表紙・説明と一時停止・面の切りかえがあります。小さな R の輪で予告します。
記録と必要な環境Python 3.11以上(テストは 3.12.14 / Pyxel 2.9.9)。1つのファイルです。自動で出す PNG/JSON はテスト用です。Noto の文字は使っていません。元の MIT の全文と、参照している OFL は残しています。Python 3.10以上での起動の説明。いくつかのソースファイル・Noto の文字、リプレイ・GIF・比較モード・全部を調べる機能があります。

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

  1. 元のゲームの model.py:validate_level で、親の循環・範囲・決まった根・枝の長さを確かめる処理を読んでみましょう。
  2. 元のゲームの model.py:descendants / transition / apply / undo を読み、教材で広げる前の形と比べてみましょう。
  3. 元のゲームの solver.py:explore / key で、手数を探索のキーから外し、最短の手順が1つではないことを調べてみましょう。
  4. 元のゲームの app.py:update / select / back / draw を読み、面の切りかえ・キーを離すまで待つ処理・予告・日本語の描画に進んでみましょう。
  5. 元のゲームの app.py の record / save_events と、model.py の state_hash で、自動の操作の証拠をたどってみましょう。
自分のゲームに変えるなら

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

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