# MIT License # # Copyright (c) 2026 Tidepost 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. # # Exception: assets/glyphs.json contains rasterized glyph data derived from # Noto Sans CJK JP (SIL Open Font License 1.1). See assets/NOTO-LICENSE.txt. # Pyxel is an external dependency under its own MIT license. """潮待ちポスト・教材 1/5。原作「ふたつの潮」の基本便を段階化。 実行: python 01_grid.py Cを通常の島Iに置換。日本語の解説はLESSON.md。画面はASCIIのみ。 原作ライセンスの字形例外は上に保持。教材はNoto字形を使用しない。 """ from dataclasses import dataclass, replace # (列, 行) で扱う。Cの追加便は教材に含めず、同じ位置をIにする。 ROWS = ("S-a-I-B", "|...b.|", "I~I-I-I", "b.|...b", "A-I...I") DIRECTIONS = {"L": (-1, 0), "R": (1, 0), "U": (0, -1), "D": (0, 1)} TILE = 28 STAGE = 1 def find(char): return next((x, y) for y, row in enumerate(ROWS) for x, cell in enumerate(row) if cell == char) START = find("S") @dataclass(frozen=True) class State: x: int = START[0] y: int = START[1] used: int = 0 def step(state, command): if command not in DIRECTIONS: return state, "invalid" dx, dy = DIRECTIONS[command] x, y = state.x + dx, state.y + dy if y < 0 or y >= len(ROWS) or x < 0 or x >= len(ROWS[y]) or ROWS[y][x] == ".": return state, "water" return replace(state, x=x, y=y, used=state.used + 1), "move" class Session: def __init__(self): self.state = State() self.event = "ready" def act(self, command): self.state, self.event = step(self.state, command) def restart(self): self.state = State() self.event = "ready" def main(headless=False, screenshot=None): import json import pyxel # 純粋ルールのテストでは読み込まない。 session = Session() draws = 0 keys = ((pyxel.KEY_LEFT, "L"), (pyxel.KEY_RIGHT, "R"), (pyxel.KEY_UP, "U"), (pyxel.KEY_DOWN, "D")) def update(): if headless and draws >= 12: if screenshot: pyxel.screenshot(screenshot, scale=2) print(json.dumps({"stage": STAGE, "rendered_frames": draws, "screenshot": screenshot, "mode": "headless-smoke"}), flush=True) pyxel.quit() return if pyxel.btnp(pyxel.KEY_ESCAPE): pyxel.quit() return if pyxel.btnp(pyxel.KEY_R): session.restart() return for key, command in keys: if pyxel.btnp(key): session.act(command) break # 一つのフレームに進めるのは一手だけ。 def draw(): nonlocal draws draws += 1 state = session.state pyxel.cls(1) pyxel.text(12, 9, f"TIDEPOST / STEP {STAGE}", 10) pyxel.text(12, 22, f"MOVES {state.used}", 7) for y, row in enumerate(ROWS): for x, char in enumerate(row): px, py = 12 + x * TILE, 42 + y * TILE if char == ".": pyxel.line(px + 8, py + 14, px + 15, py + 14, 5) continue if char in "SIAB": pyxel.rect(px + 1, py + 1, TILE - 2, TILE - 2, 3) pyxel.rectb(px + 1, py + 1, TILE - 2, TILE - 2, 11) pyxel.text(px + 4, py + 3, char, 7) else: color = 12 if char == "a" else 9 if char == "b" else 10 if char == "~" else 4 active = True # 01では橋の種類による制約をまだ入れない。 if y % 2 == 0: box = (px, py + 9, TILE, 10) else: box = (px + 9, py, 10, TILE) if active: pyxel.rect(*box, color) else: pyxel.rectb(*box, color) pyxel.text(px + 12, py + 11, char, 0 if active else color) px, py = 12 + state.x * TILE, 42 + state.y * TILE pyxel.circ(px + 14, py + 18, 5, 8) pyxel.pset(px + 15, py + 16, 7) pyxel.text(220, 62, "MAP PRACTICE", 6) pyxel.text(220, 77, "A/B: ISLANDS", 6) pyxel.text(220, 92, "NO PARCELS YET", 6) pyxel.text(220, 110, "ALL BRIDGES OPEN", 6) notices = { "ready": "READY", "move": "STEP", "water": "WATER: NO MOVE", "invalid": "UNKNOWN COMMAND", "tide": "TIDE CHANGED", "unsafe": "SWITCH TIDE ON AN ISLAND", "tide_block": "BRIDGE UNDER WATER", "heavy": "DELIVER B TO USE ~", "delivery": "DELIVERED!", "win": "CLEAR! BACK AT S", "done": "CLEAR! R: RESET", "undo": "ONE STEP BACK", } pyxel.text(12, 194, notices[session.event], 10) pyxel.text(12, 210, "ARROWS: STEP R: RESET ESC: QUIT", 7) pyxel.text(12, 222, "TIDE RULE STARTS IN STEP 2", 6) pyxel.text(12, 234, "UNDO STARTS IN STEP 5", 5) pyxel.init(364, 250, title=f"Tidepost - Stage {STAGE}", 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="Render 12 frames then quit; not a human playtest") parser.add_argument("--screenshot", metavar="PATH", help="PNG path for --headless-smoke") args = parser.parse_args() if args.screenshot and not args.headless_smoke: parser.error("--screenshot requires --headless-smoke") main(headless=args.headless_smoke, screenshot=args.screenshot)