# 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. # 教材は図形とASCII文字のみ。上記の別ライセンス字体は同梱しません。 """溶ける鍵・教材 1/5。各ファイルは単独で実行できます。 実行: python 01_walk.py """ from dataclasses import dataclass, replace DIRECTIONS = {"U": (0, -1), "R": (1, 0), "D": (0, 1), "L": (-1, 0)} ARROWS = {"UP": "U", "RIGHT": "R", "DOWN": "D", "LEFT": "L"} @dataclass(frozen=True) class Door: id: str pos: tuple size: int @dataclass(frozen=True) class State: player: tuple length: int = 3 opened: int = 0 # 各ビットが各扉に対応する。1なら開いている。 moves: int = 0 @dataclass(frozen=True) class Result: state: State accepted: bool reason: str FLOORS = frozenset({(0, 2), (1, 2), (2, 2), (3, 2), (4, 2), (5, 2)}) START = (0, 2) EXIT = (5, 2) DOORS = () # まだ錠は置かない。 def neighbor(player, direction): dx, dy = DIRECTIONS[direction] return player[0] + dx, player[1] + dy def won(state): return state.player == EXIT def is_open(state, index): return bool(state.opened & (1 << index)) def walkable(state, pos): return pos in FLOORS or any( door.pos == pos and is_open(state, index) for index, door in enumerate(DOORS) ) def transition(state, action): """世界の変更はここだけ。拒否では同じStateを返す。""" if won(state): return Result(state, False, "complete") if action in DIRECTIONS: pos = neighbor(state.player, action) if not walkable(state, pos): reason = "door" if any(d.pos == pos for d in DOORS) else "wall" return Result(state, False, reason) next_state = replace(state, player=pos, moves=state.moves + 1) reason = "complete" if won(next_state) else "walk" return Result(next_state, True, reason) return Result(state, False, "invalid") class World: def __init__(self): self.reset() def reset(self): self.state = State(START) self.facing = "R" # 向きは対象選択。世界のStateには入れない。 self.reason = "ready" def act(self, action): result = transition(self.state, action) self.reason = result.reason if result.accepted: self.state = result.state return result.accepted def move(self, direction): self.facing = direction return self.act(direction) def target(self): pos = neighbor(self.state.player, self.facing) return next((door.id for door in DOORS if door.pos == pos), "") def use(self): return self.act("use:" + self.target()) def main(headless=False, screenshot=None, world=None): """Pyxelはここでだけ読み込む。ルールのテストには不要。""" import pyxel from pathlib import Path world = world if world is not None else World() if screenshot: screenshot = str(Path(screenshot).resolve()) Path(screenshot).parent.mkdir(parents=True, exist_ok=True) def update(): # frame_count 0..11 の描画を終えてから保存して終了する。 if headless and pyxel.frame_count >= 12: if screenshot: pyxel.screenshot(str(screenshot), scale=2) pyxel.quit() return names = tuple(ARROWS) + ("SPACE", "R") pressed = {name for name in names if pyxel.btnp(getattr(pyxel, "KEY_" + name))} # 複数の新規押下は処理しない。第5段階で保持中のキーも扱う。 if len(pressed) != 1: return key = next(iter(pressed)) if key == "R": world.reset() elif won(world.state): return elif key in ARROWS: world.move(ARROWS[key]) elif key == "SPACE": world.use() def draw(): pyxel.cls(1) pyxel.text(12, 10, "MELTING KEY / STEP 1", 10) pyxel.text(12, 24, "WALK TO OUT / NO LOCKS YET", 6) for y in range(6): for x in range(6): pos = (x, y) px, py = 16 + x * 32, 48 + y * 32 floor = pos in FLOORS or any(d.pos == pos for d in DOORS) pyxel.rect(px, py, 31, 31, 4 if floor else 0) pyxel.rectb(px, py, 31, 31, 5 if floor else 1) ex, ey = 16 + EXIT[0] * 32, 48 + EXIT[1] * 32 pyxel.rect(ex + 3, ey + 3, 25, 25, 3) pyxel.text(ex + 8, ey + 12, "OUT", 11) for index, door in enumerate(DOORS): px, py = 16 + door.pos[0] * 32, 48 + door.pos[1] * 32 opened = is_open(world.state, index) if opened: pyxel.rectb(px + 3, py + 3, 25, 25, 9) pyxel.text(px + 6, py + 12, "OPEN", 9) else: pyxel.rect(px + 3, py + 3, 25, 25, 9) pyxel.text(px + 14, py + 16, str(door.size), 1) for tooth in range(door.size): pyxel.rect(px + 6 + tooth * 7, py + 6, 4, 6, 1) if door.size == world.state.length: pyxel.rectb(px + 1, py + 1, 29, 29, 11) tx, ty = neighbor(world.state.player, world.facing) if 0 <= tx < 6 and 0 <= ty < 6: pyxel.rectb(16 + tx * 32, 48 + ty * 32, 31, 31, 7) px = 32 + world.state.player[0] * 32 py = 64 + world.state.player[1] * 32 pyxel.circ(px, py - 5, 5, 15) pyxel.rect(px - 5, py + 1, 11, 9, 12) dx, dy = DIRECTIONS[world.facing] pyxel.line(px + dx * 9, py + dy * 9, px + dx * 13, py + dy * 13, 7) pyxel.text(222, 52, "WAX KEY", 10) pyxel.text(222, 65, f"LENGTH {world.state.length}", 7) pyxel.circb(229, 87, 5, 10) pyxel.line(234, 87, 240 + world.state.length * 16, 87, 10) for tooth in range(world.state.length): pyxel.rect(241 + tooth * 16, 84, 11, 8, 10) pyxel.rect(245 + tooth * 16, 92, 4, 3, 10) pyxel.text(222, 108, f"MOVES {world.state.moves}", 7) pyxel.text(222, 121, "WHITE: TARGET", 7) pyxel.text(222, 133, "GREEN: MATCH", 11) pyxel.text(12, 250, "CLEAR!" if won(world.state) else world.reason.upper(), 10) pyxel.text(12, 268, "ARROWS: WALK / FACE SPACE: USE", 7) pyxel.text(12, 282, "R: RESET ESC: QUIT", 6) pyxel.text(12, 308, "NO TIMER / SILENT / KEYS ONE AT A TIME", 13) pyxel.init(320, 328, title="Melting Key - Stage 1", 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") parser.add_argument("--screenshot", help="PNG path; used with --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)