# MIT License # # Copyright (c) 2026 # # 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. # # Third-party exception: bitmap glyph data derived from Noto Sans CJK is licensed # under the SIL Open Font License 1.1. See assets/NOTO-LICENSE.txt. """迷子の地図屋・教材1/5。各ファイルを単独で実行できます。""" from dataclasses import dataclass, replace DIRECTIONS = {'R': (1, 0), 'D': (0, 1), 'L': (-1, 0), 'U': (0, -1)} @dataclass(frozen=True) class Level: seed: str title: str nodes: tuple edges: tuple marks: tuple def points(self): return {n: (x, y) for n, x, y in self.nodes} LEVELS = (Level('A-01-v1', '四角の向こう', (('H', 0, 0), ('A', 4, 0), ('B', 4, 3), ('C', 0, 3), ('D', 7, 0), ('E', 7, 3)), (('H', 'A'), ('A', 'B'), ('B', 'C'), ('C', 'H'), ('A', 'D'), ('D', 'E'), ('E', 'B')), ('B', 'E')),) def length(l, i): p = l.points() u, v = l.edges[i] return abs(p[u][0] - p[v][0]) + abs(p[u][1] - p[v][1]) def edge_dir(l, i, n): p = l.points() u, v = l.edges[i] other = v if n == u else u x, y = p[n] xx, yy = p[other] return next((k for k, (dx, dy) in DIRECTIONS.items() if (xx - x > 0) - (xx - x < 0) == dx and (yy - y > 0) - (yy - y < 0) == dy)) @dataclass(frozen=True) class State: node: str = 'H' edge: int = -1 offset: int = 0 steps: int = 0 def initial(level): return State() def move(s, level, action): if action not in DIRECTIONS: return s, 'invalid' if s.node: options = [i for i, ends in enumerate(level.edges) if s.node in ends and edge_dir(level, i, s.node) == action] if not options: return s, 'wall' i = options[0] u, v = level.edges[i] offset = 1 if s.node == u else length(level, i) - 1 else: i = s.edge u, v = level.edges[i] forward = edge_dir(level, i, u) if action == forward: offset = s.offset + 1 elif DIRECTIONS[action] == tuple(-d for d in DIRECTIONS[forward]): offset = s.offset - 1 else: return s, 'wall' node = u if offset == 0 else v if offset == length(level, i) else '' return State(node, -1 if node else i, 0 if node else offset, s.steps + 1), 'walk' STAGE = 1 WIDTH, HEIGHT = 640, 440 class App: def __init__(self, level_index=0): self.level_index = level_index self.reset() @property def level(self): return LEVELS[self.level_index] def reset(self): self.state = initial(self.level) self.message = 'ready' self.gated = False def commit(self, action): self.state, self.message = move(self.state, self.level, action) def control(self, pressed, held): # A new reset wins even during a release gate. Held R is not a new reset. 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)) self.gated = True arrows = {'UP': 'U', 'RIGHT': 'R', 'DOWN': 'D', 'LEFT': 'L'} if key in arrows: self.commit(arrows[key]) def draw(self, p): s = self.state p.cls(1) p.text(14, 12, 'MEASURED MAP / STAGE '+str(STAGE)+' / '+self.level.seed, 10) p.text(14, 31, 'ARROW = ONE WALKING STEP / RELEASE BEFORE NEXT KEY', 7) p.text(14, 50, 'STEPS '+bounded(s.steps), 11) p.rectb(14, 78, 612, 210, 5) p.text(32, 99, 'FIRST: WALK ALONG AN EDGE, NOT ROOM TO ROOM', 6) p.text(32, 133, 'ROOM '+s.node if s.node else 'INSIDE A CORRIDOR', 10) p.text(32, 164, 'EDGE '+str(s.edge)+' / OFFSET '+str(s.offset), 7) p.text(32, 197, 'OFFSET IS FROM THE FIRST END OF AN INTERNAL EDGE.', 13) p.text(32, 225, 'NO MAP KNOWLEDGE OR GOAL IN THIS PRACTICE YET.', 13) p.text(32, 252, 'TRY RIGHT x4: H -> CORRIDOR -> A. LEFT x4: HOME.', 11) p.text(14, 367, self.message.upper(), 10) p.text(14, 390, 'ARROWS: WALK / R: RESET SAME BANK / ESC: QUIT', 7) p.text(14, 408, 'A FIXED BANK / NO RANDOM GENERATION', 13) def bounded(n): return str(n) if n <= 9999 else '9999+' def make_demo_keys(): return ('RIGHT',)*4 def main(headless=False, screenshot=None, demo=False, bank=1): import pyxel as p p.init(WIDTH, HEIGHT, title='Measured map lesson '+str(STAGE), fps=30, display_scale=1, headless=headless) app = App(bank-1) sequence = make_demo_keys() if demo else () keys = {k: getattr(p, 'KEY_'+k) for k in ('UP','RIGHT','DOWN','LEFT','Z','X','R','1','2','3')} limit = max(20, 2*len(sequence)+8) def update(): if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count >= limit: if screenshot: app.draw(p) p.screen.save(screenshot, 1) p.quit() return if headless: if demo: index = p.frame_count//2 down = {sequence[index]} if p.frame_count%2 == 0 and index < len(sequence) else set() app.control(down, down) 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 from pathlib import Path parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--bank', type=int, choices=range(1, len(LEVELS)+1), default=1) parser.add_argument('--headless-smoke', action='store_true') parser.add_argument('--demo-checkpoint', action='store_true') parser.add_argument('--screenshot') args = parser.parse_args() if args.demo_checkpoint and not args.headless_smoke: parser.error('demo requires headless mode') if args.screenshot and not args.headless_smoke: parser.error('screenshot requires headless mode') if args.screenshot and (Path(args.screenshot).suffix.lower() != '.png' or not Path(args.screenshot).parent.is_dir() or Path(args.screenshot).exists()): parser.error('use a new .png in an existing directory') # Pyxel may change cwd during initialization. Anchor the validated path first. screenshot = str(Path(args.screenshot).resolve()) if args.screenshot else None main(args.headless_smoke, screenshot, args.demo_checkpoint, args.bank)