# 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. """迷子の地図屋・教材2/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 origin: str = 'H' progress: int = 0 seen: tuple = ('H',) measured: tuple = (0,) * 7 lower: tuple = (0,) * 7 steps: int = 0 def initial(level): return State(measured=(0,) * len(level.edges), lower=(0,) * len(level.edges)) 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] origin = s.node offset = 1 if origin == u else length(level, i) - 1 progress = 1 else: i = s.edge u, v = level.edges[i] origin = s.origin 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' progress = offset if origin == u else length(level, i) - offset # lower is knowledge: reversing does not erase the farthest reached distance. low = list(s.lower) low[i] = max(low[i], progress) measured = list(s.measured) seen = s.seen node = u if offset == 0 else v if offset == length(level, i) else '' if node and node != origin: measured[i] = progress if node not in seen: seen = seen + (node,) new = State(node, -1 if node else i, 0 if node else offset, node or origin, 0 if node else progress, seen, tuple(measured), tuple(low), s.steps + 1) return new, 'measured' if node and node != origin else 'walk' def ledger(s, level): """Only visited rooms and measured endpoints; no remote coordinates.""" rows = [] for node in s.seen: exits = [] for i, (u, v) in enumerate(level.edges): if node not in (u, v): continue distance = s.measured[i] other = v if node == u else u exits.append({'direction': edge_dir(level, i, node), 'target': other if distance else '?', 'distance': distance, 'lower': 0 if distance else s.lower[i]}) exits.sort(key=lambda item: 'URDL'.index(item['direction'])) rows.append({'node': node, 'mark': False, 'exits': exits}) return rows STAGE = 2 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) rows = ledger(s, self.level) p.rectb(12, 91, 224, 218, 5) p.text(23, 108, 'MEASUREMENT ONLY', 6) p.text(23, 133, 'ROOM '+s.node if s.node else 'FROM '+s.origin, 10) p.text(23, 154, 'PROGRESS '+str(s.progress), 7) p.text(23, 184, '? = NOT MEASURED', 13) p.text(23, 202, '>=N = AT LEAST N STEPS', 13) p.text(23, 229, 'TURNING BACK KEEPS >=N', 11) p.text(23, 255, 'NO LOCAL VIEW YET', 6) p.text(253, 74, 'LEDGER: LAYOUT IS NOT DISTANCE', 6) for row_index, row in enumerate(rows): y = 94 + row_index*34 p.rect(252, y, 374, 30, 3 if row['node'] == s.node else 2) p.text(258, y+5, row['node']+('*' if row['mark'] else ''), 10) for exit_index, item in enumerate(row['exits']): number = str(item['distance']) if item['distance'] else ( '>='+str(item['lower']) if item['lower'] else '?') label = item['direction']+' '+number+' -> '+item['target'] p.text(283+exit_index*108, y+5, label, 11 if item['distance'] else 13) p.text(283, y+18, 'UNMEASURED TARGETS STAY ?', 13) p.text(14, 325, 'ROOM '+s.node if s.node else 'FROM '+s.origin+' / PROGRESS '+str(s.progress), 10) p.text(253, 325, 'MEASURED '+str(sum(bool(x) for x in s.measured))+'/'+str(len(self.level.edges)), 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','RIGHT','LEFT','LEFT') 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)