# 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. """後から来る壁・教材 4/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),)), Level("B", (0,3), (6,1), frozenset({(1,3),(2,3),(2,2),(3,3),(4,3),(4,2),(4,1),(5,1)}), ((2,2),(4,1))), Level("C", (0,3), (6,3), frozenset({(1,3),(2,3),(3,3),(3,2),(3,1),(0,2),(0,1),(1,1),(2,1),(4,3),(5,3)}), ((3,1),)), ) @dataclass(frozen=True) class State: player: tuple[int, int] collected: int = 0 walls: frozenset[tuple[int, int]] = frozenset() queue: tuple[tuple[tuple[int, int], int], ...] = () moves: int = 0 status: str = "playing" trail: tuple[tuple[int, int], ...] = () @dataclass(frozen=True) class Result: state: State accepted: bool reason: str matured: tuple[tuple[int, int], ...] = () scheduled: tuple[int, int] | None = None blocked: tuple[int, int] | None = None def initial(level): return State(level.start, trail=(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) if target in state.walls: return Result(state, False, "grown_wall", blocked=target) collected = state.collected if target in level.lights: collected |= 1 << level.lights.index(target) # 古い予約だけを減らす。今踏んだ床は最後に3で登録する。 old = dict(state.queue) matured = tuple(sorted(p for p, n in state.queue if n == 1)) queue = {p:n-1 for p, n in state.queue if n > 1} scheduled = None if target in level.sprouts and target not in old: queue[target] = 3 scheduled = target walls = state.walls | frozenset(matured) status = "playing" # 通常到達しない合成状態にも、衝突優先という意味を定める。 if target in walls: status = "collision" elif target == level.exit and collected == (1 << len(level.lights))-1: status = "won" elif not any(add(target, d) in level.floors-walls for d in DIRECTIONS.values()): status = "stranded" new = State(target, collected, walls, tuple(sorted(queue.items())), state.moves+1, status, (state.trail+(target,))[-4:]) reason = status if status != "playing" else ("light" if collected != state.collected else "walk") return Result(new, True, reason, matured, scheduled) def forecast(level, state): """副作用のない仮計算。表示するだけでは時間を進めない。""" return {action: transition(level, state, action) for action in DIRECTIONS} 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)) if key == "N": self.level_index = (self.level_index+1) % len(LEVELS) self.reset() self.gated = True return 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 4",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) if pos in s.walls: p.rect(px+1,py+1,32,32,4) p.rectb(px+1,py+1,32,32,9) p.line(px+3,py+11,px+30,py+11,9) p.line(px+10,py+3,px+10,py+10,9) if pos in dict(s.queue): n = dict(s.queue)[pos] p.rect(px+24,py+2,8,10,1) p.text(px+26,py+4,str(n),10 if n == 1 else 7) 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,"RESERVATIONS",10) p.text(288,78,"CELL / STEPS LEFT",6) for i, (pos,n) in enumerate(sorted(s.queue,key=lambda item:(item[1],item[0]))): label = str(pos[0])+","+str(pos[1])+" / "+str(n) p.text(288,94+i*14,label,10 if n == 1 else 7) if not s.queue: p.text(288,94,"NONE",13) p.text(288,146,"3 > 2 > 1 > WALL",9) p.text(288,160,"NO WAIT ACTION",6) p.text(288,182,"NEXT MOVE PREVIEW",10) for i, (action,result) in enumerate(forecast(self.level,s).items()): label = action+": "+result.reason.upper() if result.accepted: label += " W+"+str(len(result.matured)) p.text(288,198+i*13,label,11 if result.accepted else 13) 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 N LEVEL R RESET ESC QUIT',6) DEMO_KEYS = ('N', 'N', 'RIGHT', 'RIGHT', 'RIGHT', 'UP', 'UP', 'DOWN', 'DOWN') 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)