# 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。原作Aの庭を段階化した独立版。 実行: python 05_flowers.py """ from dataclasses import dataclass from collections import deque WIDTH = HEIGHT = 4 BUDGET = 12 TILE = 32 DIRS = {"U": (0, -1), "R": (1, 0), "D": (0, 1), "L": (-1, 0)} SUN = ((1, 1),) MOON = ((3, 1),) TARGET = 2 @dataclass(frozen=True) class State: path: tuple = () closed: bool = False success: bool = False def edge(a, b): # A→BとB→Aを同じ線分のキーにする。 return tuple(sorted((a, b))) def line_edges(path): return frozenset(edge(a, b) for a, b in zip(path, path[1:])) def inside_cells(path): if len(path) < 5 or path[0] != path[-1]: return frozenset() barriers = line_edges(path) queue = deque([(-1, -1)]) outside = {(-1, -1)} while queue: x, y = queue.popleft() for dx, dy in DIRS.values(): nx, ny = x + dx, y + dy if not (-1 <= nx <= WIDTH and -1 <= ny <= HEIGHT): continue if (nx, ny) in outside: continue if dx: wall = edge((max(x, nx), y), (max(x, nx), y + 1)) else: wall = edge((x, max(y, ny)), (x + 1, max(y, ny))) if wall not in barriers: outside.add((nx, ny)) queue.append((nx, ny)) return frozenset((x, y) for y in range(HEIGHT) for x in range(WIDTH) if (x, y) not in outside) def blooms(state): inside = inside_cells(state.path) if state.closed else frozenset() return sum(p in inside for p in SUN) + sum(p not in inside for p in MOON) def transition(state, action): if action == "reset": return State(), "READY" if action == "undo": if not state.path: return state, "NOTHING TO UNDO" return State(state.path[:-1], False, False), "UNDO" if state.success: return state, "FINISHED / Z TO EDIT" if isinstance(action, tuple) and len(action) == 2 and action[0] == "begin": point = action[1] if state.path: return state, "ALREADY STARTED" if not (isinstance(point, tuple) and len(point) == 2 and all(type(v) is int for v in point) and 0 <= point[0] <= WIDTH and 0 <= point[1] <= HEIGHT): return state, "BOUNDARY" return State((point,), False, False), "START" if action == "submit": if not state.closed: return state, "CLOSE THE LOOP FIRST" if blooms(state) < TARGET: return state, "NOT ENOUGH BLOOMS / Z TO EDIT" return State(state.path, True, True), "CLEAR!" if action not in DIRS: return state, "UNKNOWN" if not state.path: return state, "CHOOSE A START" if state.closed: return state, "CLOSED / Z TO EDIT" a = state.path[-1] dx, dy = DIRS[action] b = (a[0] + dx, a[1] + dy) if not (0 <= b[0] <= WIDTH and 0 <= b[1] <= HEIGHT): return state, "BOUNDARY" if len(state.path) - 1 >= BUDGET: return state, "NO STRING LEFT" if edge(a, b) in line_edges(state.path): return state, "EDGE ALREADY USED" closing = b == state.path[0] if b in state.path and not closing: return state, "VERTEX ALREADY USED" if closing and len(state.path) < 4: return state, "LOOP TOO SHORT" path = state.path + (b,) if closing and not inside_cells(path): return state, "NO INSIDE CELL" return State(path, closing, False), "CLOSED" if closing else "DRAW" def main(headless=False, screenshot=None): import pyxel state = State() cursor = (1, 1) message = "CHOOSE A VERTEX, THEN SPACE" keys = ((pyxel.KEY_UP, "U"), (pyxel.KEY_RIGHT, "R"), (pyxel.KEY_DOWN, "D"), (pyxel.KEY_LEFT, "L")) def update(): nonlocal state, cursor, message if headless and pyxel.frame_count >= 12: if screenshot: pyxel.screenshot(screenshot, scale=2) pyxel.quit() return if pyxel.btnp(pyxel.KEY_R): state, message = transition(state, "reset") cursor = (1, 1) return if pyxel.btnp(pyxel.KEY_Z): state, message = transition(state, "undo") if state.path: cursor = state.path[-1] return if state.success: return if pyxel.btnp(pyxel.KEY_SPACE): state, message = transition(state, "submit" if state.path else ("begin", cursor)) return for key, direction in keys: if pyxel.btnp(key): if state.path: state, message = transition(state, direction) cursor = state.path[-1] else: dx, dy = DIRS[direction] cursor = (max(0, min(WIDTH, cursor[0] + dx)), max(0, min(HEIGHT, cursor[1] + dy))) break def draw(): pyxel.cls(1) pyxel.text(10, 9, "ONE LINE GARDEN / STEP 5", 10) pyxel.text(10, 23, f"STRING {max(0, len(state.path)-1)}/{BUDGET}", 7) if state.closed: for x, y in inside_cells(state.path): pyxel.rect(31 + x * TILE, 51 + y * TILE, TILE - 1, TILE - 1, 3) for y in range(HEIGHT + 1): pyxel.line(30, 50 + y * TILE, 30 + WIDTH * TILE, 50 + y * TILE, 5) for x in range(WIDTH + 1): pyxel.line(30 + x * TILE, 50, 30 + x * TILE, 50 + HEIGHT * TILE, 5) for x, y in SUN: pyxel.text(43 + x * TILE, 64 + y * TILE, "S", 10) for x, y in MOON: pyxel.text(43 + x * TILE, 64 + y * TILE, "M", 6) pyxel.text(180, 64, "S: INSIDE", 10) pyxel.text(180, 80, "M: OUTSIDE", 6) if state.closed: pyxel.text(180, 102, f"BLOOM {blooms(state)}/{TARGET}", 7) for a, b in zip(state.path, state.path[1:]): pyxel.line(30 + a[0] * TILE, 50 + a[1] * TILE, 30 + b[0] * TILE, 50 + b[1] * TILE, 10) if state.path: x, y = state.path[0] pyxel.circ(30 + x * TILE, 50 + y * TILE, 3, 11) x, y = cursor pyxel.rectb(26 + x * TILE, 46 + y * TILE, 9, 9, 7) pyxel.text(10, 191, message, 10) pyxel.text(10, 216, "ARROWS: MOVE SPACE: START/SUBMIT", 6) pyxel.text(10, 230, "Z: UNDO R: RESET ESC: QUIT", 6) pyxel.init(300, 250, title="One Line Garden - Stage 5", fps=30, headless=headless) pyxel.run(update, draw) if __name__ == "__main__": import argparse parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--headless-smoke", action="store_true", help="12 frames then quit; not a playtest") parser.add_argument("--screenshot", help="PNG path used with --headless-smoke") args = parser.parse_args() main(headless=args.headless_smoke, screenshot=args.screenshot)