# 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. """079 こだまする洞窟: 教材段階 3。""" STAGE = 3 from dataclasses import dataclass, replace, asdict VERSION='079-v1.0.1' SETTINGS=('左右へ広げる','左へ遠く','右へ遠く') @dataclass(frozen=True) class Level: id:str;title:str;maps:tuple;truth:int LEVELS=(Level('A','最初の反響',(((1,'S'),(3,'M')),((1,'M'),(3,'S')),((2,'S'),(3,'M')),((1,'S'),(4,'M'))),0),Level('B','重なった二つの音',(((2,'S'),(1,'M')),((1,'M'),(2,'S')),((2,'M'),(1,'S')),((3,'S'),(1,'M'))),1),Level('C','何も返らない入口',(((4,'M'),(3,'S')),((3,'M'),(4,'S')),((4,'S'),(3,'M')),((4,'M'),(4,'M'))),2)) @dataclass(frozen=True) class Wave: branch:int;pos:int=0;phase:str='out';wait:int=0 @dataclass(frozen=True) class Observation: setting:int;events:tuple @dataclass(frozen=True) class State: secret:int;status:str='idle';setting:int=0;t:int=0;waves:tuple=();events:tuple=();observations:tuple=();report:tuple=(0,1,2,3);reason:str='まず音を送り、窓の終わりまで聞きます。' def window(setting):return 6 if setting==0 else 10 def moss_wait(mode):return 0 if mode=='compare' else 2 def launch(secret,setting):return replace(secret,status='listen',setting=setting,t=0,events=(),waves=tuple(Wave(b) for b in ((0,1) if setting==0 else (setting-1,))),reason='観測中。途中の無音だけでは壁を否定しません。') def step(level,s,mode='base'): if s.status!='listen':return s n=s.t+1;waves=[];counts={'S':0,'M':0};reach=2 if s.setting==0 else 4 for w in s.waves: d,mat=level.maps[s.secret][w.branch] if w.phase=='out': p=w.pos+1 if p==d: wait=moss_wait(mode) if mat=='M' else 0 waves.append(Wave(w.branch,p,'wait' if wait else 'back',wait)) elif p= 3 else None @property def state(self): return self.timeline.state if STAGE == 5 else self.s def reset(self): if STAGE == 5: self.timeline = new(self.level) else: self.s = State(self.level.truth) if STAGE >= 3 else Lab() self.setting = 0 self.cursor = 0 self.marking = False self.message = 'READY / RELEASE EACH KEY' def commit(self, action): before = self.state if STAGE == 5: self.timeline = act(self.level, self.timeline, action, self.mode) elif STAGE == 4: self.s = judge(self.level, self.s, action, self.mode) elif STAGE == 3: if action == 'tick': self.s = step(self.level, self.s, self.mode) elif action.startswith('send:') and self.s.status == 'idle': self.s = launch(self.s, int(action.split(':')[1])) elif action == 'tick': self.s = advance(self.s) self.message = ('ACCEPTED: ' if before != self.state else 'UNCHANGED: ') + action.upper() def press(self, key): if key == 'R': self.reset() return if STAGE >= 3 and key in ('1','2','3'): self.level_index = int(key) - 1 self.reset() return if STAGE == 5 and key == 'B': self.mode = 'compare' if self.mode == 'base' else 'base' self.reset() return if STAGE == 5 and key == 'X': self.commit('undo') return if key == 'SPACE': self.commit('tick') elif STAGE >= 3: if key in ('LEFT','RIGHT'): self.setting = (self.setting + (1 if key == 'RIGHT' else -1)) % 3 elif STAGE >= 4 and key == 'TAB': self.marking = not self.marking elif STAGE >= 4 and key in ('UP','DOWN'): self.cursor = (self.cursor + (1 if key == 'DOWN' else -1)) % 4 elif key == 'Z': self.commit('mark:' + str(self.cursor) if self.marking else 'send:' + str(self.setting)) elif STAGE >= 4 and key == 'A': self.commit('assist') elif STAGE >= 4 and key == 'RETURN': self.commit('submit') def control(self, pressed, held): if self.gated: if not pressed and not held: self.gated = False return if len(pressed | held) > 1: self.gated = True self.message = 'CHORD REJECTED / RELEASE ALL KEYS' elif len(pressed) == 1: self.press(next(iter(pressed))) self.gated = True def view(self): s = self.state if STAGE == 5: return public_view(self.level, s, self.mode) if STAGE < 3: return {'t': s.t, 'status': s.phase, 'events': s.events, 'window': 2 * DISTANCE if STAGE == 1 else max(2 * d + (MOSS_WAIT if m == 'M' else 0) for d,m in WALLS)} result = {'t': s.t, 'status': s.status, 'events': s.events, 'window': window(s.setting), 'observations': s.observations, 'setting': s.setting, 'report': s.report} if STAGE == 4: result['compatible'] = compatible(self.level, s, self.mode) return result def draw(self, p): v = self.view() p.cls(1) p.text(14, 12, 'ECHO CAVE / STAGE ' + str(STAGE), 10) p.text(14, 28, 'FICTIONAL ACOUSTICS / ONE PRESS, ONE TICK / NO AUDIO', 6) p.text(14, 48, 'TIME ' + str(v['t']) + ' / ' + str(v['window']) + ' STATUS ' + v['status'].upper(), 7) p.rect(12, 66, 624, 154, 0) if STAGE == 1: p.text(24, 78, 'KNOWN LAB: STONE DISTANCE ' + str(DISTANCE) + ' / RETURN = 2 * DISTANCE', 10) p.line(55, 142, 560, 142, 5) p.circ(55 + int(505 * self.state.pos / DISTANCE), 142, 7, 10) p.rect(560, 119, 8, 44, 7) p.text(30, 180, 'VISIBLE WAVE IN A KNOWN EXERCISE, NOT A HIDDEN CAVE', 6) elif STAGE == 2: p.text(24, 78, 'KNOWN LAB: L=2 STONE / R=1 MOSS / MOSS WAIT ' + str(MOSS_WAIT), 10) for branch in (0,1): y = 118 + branch * 50 p.line(55,y,555,y,5) for wave in self.state.waves: if wave.branch == branch: x = 55 + wave.pos * 180 p.circ(x,y,6,7 if branch == 0 else 11) p.text(x+14,y-3,wave.phase.upper() + ' ' + str(wave.wait),10) p.text(570,y-3,'STONE' if branch == 0 else 'MOSS',7) else: p.text(24, 78, 'HIDDEN CAVE ' + self.level.id + ' / NO SECRET OR WAVE POSITIONS HERE', 10) p.line(105,126,318,174,5);p.line(530,126,318,174,5);p.circ(318,174,8,10) p.text(54,111,'LEFT: UNKNOWN',6);p.text(466,111,'RIGHT: UNKNOWN',6) p.text(24,195,'SELECTED ' + ('WIDE / REACH 2 EACH','LEFT / REACH 4','RIGHT / REACH 4')[self.setting],7) p.text(14, 233, 'RECEIVED EVENTS (TICK, STONE COUNT, MOSS COUNT):', 10) p.text(14, 249, str(v['events']) if v['events'] else 'NONE YET', 7) if STAGE >= 3: p.text(14,270,'COMPLETED WINDOWS ' + str(len(v['observations'])) + ' / NO EARLY ELIMINATION',11) p.text(14,287,'ACTIVE SETTING ' + str(v['setting']) + ' / SELECTING DOES NOT SEND',6) if STAGE >= 4: for i, candidate in enumerate(self.level.maps): y = 312 + i * 24 line = ('>' if self.marking and i == self.cursor else ' ') + 'H' + str(i+1) line += ' [KEEP] ' if i in v['report'] else ' [DROP] ' line += 'LEFT ' + str(candidate[0]) + ' RIGHT ' + str(candidate[1]) line += ' POSSIBLE' if i in v['compatible'] else ' CONTRADICTED' p.text(14,y,line,10 if i in v['compatible'] else 6) p.text(14,413,'SUBMITTED: ALL POSSIBLE MAPS REPORTED' if v['status']=='submitted' else 'KEEP ALL COMPATIBLE MAPS; MULTIPLE MAPS IS A VALID REPORT',11) else: p.text(14,325,'DISTANCE = (ARRIVAL TICK - MATERIAL WAIT) / 2',11) p.text(14,350,'SILENCE DOES NOT PROVE THAT A WALL DOES NOT EXIST.',6) if STAGE == 5: p.text(14,440,'NEXT POSSIBILITIES (STONE,MOSS): ' + str(v['next_events']),10) p.text(14,458,'MOSS WAIT ' + str(v['wait']) + ' / X UNDO / B RESTARTS COMPARISON',6) p.text(14,487,self.message,10) p.text(14,510,'SPACE TICK / R RESET SAME CODE / ESC QUIT',7) if STAGE >= 3: p.text(14,526,'LEFT/RIGHT SETTING / Z SEND / 1-3 CAVE / RELEASE EVERY KEY',7) if STAGE >= 4: p.text(14,542,'TAB MAP MODE / UP/DOWN MAP / Z TOGGLE / A ASSIST / ENTER REPORT',6) def demo_keys(): if STAGE == 1: return ('SPACE',) * 6 if STAGE == 2: return ('SPACE',) * 4 return ('Z',) + ('SPACE',) * 6 + (('A','RETURN') if STAGE >= 4 else ()) def main(headless=False, screenshot=None, state_json=None, demo=False): import pyxel as p import json from pathlib import Path p.init(648, 564, title='Echo cave lesson ' + str(STAGE), fps=30, display_scale=1, headless=headless) app = App() sequence = demo_keys() if demo else () keynames = ('LEFT','RIGHT','UP','DOWN','Z','SPACE','RETURN','TAB','A','X','R','B','1','2','3') keys = {k: getattr(p, 'KEY_' + k) for k in keynames} limit = max(8, len(sequence) * 2 + 6) 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) if state_json: # Public evidence only: do not export the secret or hidden waves for puzzle stages. Path(state_json).write_text(json.dumps({'stage': STAGE, 'public': app.view(), 'scope': 'Actual Linux Pyxel, synthetic controls; no human playtest'}, ensure_ascii=False, indent=2, default=lambda o: asdict(o)) + '\n', encoding='utf-8') p.quit() if headless: i = p.frame_count // 2 pressed = {sequence[i]} if demo and p.frame_count % 2 == 0 and i < len(sequence) 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 from pathlib import Path parser = argparse.ArgumentParser(description='Standalone echo cave teaching stage') parser.add_argument('--headless-smoke', action='store_true') parser.add_argument('--demo-checkpoint', action='store_true') parser.add_argument('--screenshot') parser.add_argument('--state-json') args = parser.parse_args() if (args.demo_checkpoint or args.screenshot or args.state_json) and not args.headless_smoke: parser.error('demo and outputs require --headless-smoke') for field, suffix in (('screenshot', '.png'), ('state_json', '.json')): value = getattr(args, field) if value: target = Path(value).resolve() if target.suffix.lower() != suffix or not target.parent.is_dir() or target.exists(): parser.error('output must be a new ' + suffix + ' in an existing directory') setattr(args, field, str(target)) main(args.headless_smoke, args.screenshot, args.state_json, args.demo_checkpoint)