# 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. """潮待ちポスト・教材 3/5。原作「ふたつの潮」の基本便を段階化。 実行: python 03_delivery.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 = 3 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") DESTINATIONS = (find("A"), find("B")) CONTRACT = 3 # 2進数11: Aは1、Bは2。両方を届ける。 def heavy_left(state): return bool(CONTRACT & 2 & ~state.delivered) @dataclass(frozen=True) class State: x: int = START[0] y: int = START[1] tide: int = 0 delivered: int = 0 used: int = 0 def step(state, command): if command == "T": if ROWS[state.y][state.x] not in "SIAB": return state, "unsafe" return replace(state, tide=1 - state.tide, used=state.used + 1), "tide" 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" target = ROWS[y][x] if target in "ab" and target != "ab"[state.tide]: return state, "tide_block" if target == "~" and heavy_left(state): return state, "heavy" delivered = state.delivered event = "move" # 通れることが確定してから、到着先の配達を処理する。 for index, destination in enumerate(DESTINATIONS): bit = 1 << index if (x, y) == destination and CONTRACT & bit and not delivered & bit: delivered |= bit event = "delivery" new_state = replace(state, x=x, y=y, delivered=delivered, used=state.used + 1) return new_state, event 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")) keys += ((pyxel.KEY_SPACE, "T"),) 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) pyxel.text(100, 22, "TIDE: " + ("BLUE (a)" if state.tide == 0 else "ORANGE (b)"), 12 if state.tide == 0 else 9) 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) if char in "AB" and state.delivered & (1 << "AB".index(char)): pyxel.text(px + 18, py + 3, "+", 10) else: color = 12 if char == "a" else 9 if char == "b" else 10 if char == "~" else 4 active = char not in "ab" or char == "ab"[state.tide] 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) if char == "~" and heavy_left(state): pyxel.line(px + 5, py + 5, px + 22, py + 22, 8) pyxel.line(px + 22, py + 5, px + 5, py + 22, 8) 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) parcels = " ".join(name + (":OK" if state.delivered & (1 << i) else ":TODO") for i, name in enumerate("AB")) pyxel.text(220, 62, parcels, 7) pyxel.text(220, 77, "HEAVY B" if heavy_left(state) else "LIGHT: ~ OPEN", 10) pyxel.text(220, 100, "DELIVERY PRACTICE", 6) pyxel.text(220, 112, "GOAL IN STEP 4", 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, "SPACE: TIDE (ON ISLANDS ONLY)", 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)