# 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. """後から来る壁・教材 2/5。図形とASCIIだけで動く独立ファイル。""" from dataclasses import dataclass DIRECTIONS = {"U":(0,-1), "R":(1,0), "D":(0,1), "L":(-1,0)} def add(a, b): return a[0]+b[0], a[1]+b[1] @dataclass(frozen=True) class Level: title: str start: tuple[int, int] exit: tuple[int, int] sprouts: frozenset[tuple[int, int]] lights: tuple[tuple[int, int], ...] @property def floors(self): return self.sprouts | {self.start, self.exit} LEVELS = ( Level("A", (0,2), (4,2), frozenset({(1,2),(2,2),(3,2),(2,1)}), ((2,1),)), ) @dataclass(frozen=True) class State: player: tuple[int, int] collected: int = 0 moves: int = 0 status: str = "playing" @dataclass(frozen=True) class Result: state: State accepted: bool reason: str blocked: tuple[int, int] | None = None def initial(level): return State(level.start) def transition(level, state, action): # 拒否は新しい手ではない。最初に判定して同じ状態を返す。 if action not in DIRECTIONS: return Result(state, False, "invalid") if state.status != "playing": return Result(state, False, state.status) target = add(state.player, DIRECTIONS[action]) if target not in level.floors: return Result(state, False, "fixed_wall", blocked=target) collected = state.collected if target in level.lights: collected |= 1 << level.lights.index(target) status = "won" if target == level.exit and collected == (1 << len(level.lights))-1 else "playing" new = State(target, collected, state.moves+1, status) return Result(new, True, status if status == "won" else "walk") class App: def __init__(self): self.level_index = 0 self.reset() @property def level(self): return LEVELS[self.level_index] def reset(self): self.state = initial(self.level) self.gated = False self.message = "READY" 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 = "RELEASE ALL KEYS" self.gated = True return if len(pressed) != 1: return key = next(iter(pressed)) action = {"UP":"U", "RIGHT":"R", "DOWN":"D", "LEFT":"L"}.get(key) if action is None: return result = transition(self.level, self.state, action) self.state = result.state self.message = result.reason.upper() self.gated = True def draw(self, p): p.cls(1) p.text(10,8,"DELAYED WALLS / STAGE 2",10) s = self.state p.text(10,24,"LEVEL "+self.level.title+" MOVES "+str(s.moves)+" "+s.status.upper(),7) p.text(10,40,"LIGHTS "+str(s.collected.bit_count())+"/"+str(len(self.level.lights)),10) for y in range(5): for x in range(7): pos = (x,y) px, py = 16+x*36, 64+y*36 floor = pos in self.level.floors p.rect(px,py,34,34,3 if floor else 5) p.rectb(px,py,34,34,11 if floor else 13) if pos == self.level.start: p.text(px+3,py+3,"S",6) if pos == self.level.exit: p.text(px+3,py+3,"E",10) for index, pos in enumerate(self.level.lights): if not s.collected & (1 << index): p.circ(32+pos[0]*36,80+pos[1]*36,7,10) p.pset(30+pos[0]*36,78+pos[1]*36,7) px, py = 32+s.player[0]*36, 82+s.player[1]*36 p.rect(px-5,py-3,11,12,12) p.circ(px,py-8,5,15) p.pset(px-2,py-9,1) p.pset(px+2,py-9,1) p.text(16,254,"S START E EXIT BLUE PLAYER",6) p.text(288,64,"ARROWS: ONE STEP",6) p.text(288,80,"RELEASE THEN PRESS",6) p.text(16,270,"THINKING TIME DOES NOT COUNT AS A MOVE",6) p.text(16,290,self.message,7) p.text(16,312,'ARROWS MOVE R RESET ESC QUIT',6) DEMO_KEYS = ('RIGHT', 'RIGHT', 'UP', 'DOWN', 'RIGHT', 'RIGHT') KEYS = ("UP","RIGHT","DOWN","LEFT","SPACE","N","Z","R") def main(headless=False, screenshot=None, demo=False): from pathlib import Path import pyxel as p screenshot = str(Path(screenshot).resolve()) if screenshot else None if screenshot: Path(screenshot).parent.mkdir(parents=True, exist_ok=True) app = App() p.init(448,336,title="Delayed Walls",fps=30,display_scale=2,headless=headless) keys = {name:getattr(p,"KEY_"+name) for name in KEYS} limit = max(12,2*len(DEMO_KEYS)+3) if demo else 12 def update(): if headless and p.frame_count >= limit: if screenshot: p.screenshot(screenshot,scale=2) p.quit() return if headless: index = p.frame_count//2 pressed = {DEMO_KEYS[index]} if demo and 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)) if __name__ == "__main__": import argparse parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--headless-smoke",action="store_true") parser.add_argument("--demo-checkpoint",action="store_true") parser.add_argument("--screenshot") args = parser.parse_args() # 検査指定の取り違えはウィンドウ作成・Pyxel importより前に拒否。 if (args.demo_checkpoint or args.screenshot is not None) and not args.headless_smoke: parser.error("--demo-checkpoint/--screenshot requires --headless-smoke") if args.screenshot is not None and not args.screenshot.lower().endswith(".png"): parser.error("--screenshot must end with .png") main(args.headless_smoke,args.screenshot,args.demo_checkpoint)