# 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)