# MIT License # # Copyright (c) 2026 Tidepost 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. # # Exception: assets/glyphs.json contains rasterized glyph data derived from # Noto Sans CJK JP (SIL Open Font License 1.1). See assets/NOTO-LICENSE.txt. # Pyxel is an external dependency under its own MIT license. # 教材にNoto字形は含めません。描画はASCIIと図形です。 """ねぼすけ信号係・教材2/5。単独ファイルで起動。""" from collections import deque FPS = 30 CAR = 21 BUS = 66 LANES = 1 def arrivals(a, b, buses=()): """秒で記した到着表を、(フレーム, 車線, 通過時間)へ変換する。""" return tuple(sorted( (int(t * FPS), lane, BUS if (lane, t) in buses else CAR) for lane, times in enumerate((a, b)) for t in times )) CARS = arrivals(range(6), [], ((0, 3),)) + arrivals([8, 9], []) class Model: def __init__(self): self.t = 0 self.status = 'play' self.events = deque(sorted(CARS)) self.q = [deque()] self.active = [None] # 待ち列とは別。(残り時間, 全通過時間) self.passed = 0 def tick(self): if self.status != 'play': return while self.events and self.events[0][0] <= self.t: at, lane, duration = self.events.popleft() self.q[lane].append((at, duration)) if self.active[0]: remaining, duration = self.active[0] remaining -= 1 if remaining <= 0: self.active[0] = None self.passed += 1 else: self.active[0] = (remaining, duration) if not self.active[0] and self.q[0]: at, duration = self.q[0].popleft() self.active[0] = (duration, duration) self.t += 1 if not (self.events or self.q[0] or self.active[0]): self.status = 'win' def snapshot(self): return {'t': self.t, 'status': self.status, 'queue': [list(q) for q in self.q], 'events': list(self.events), 'passed': self.passed, 'active': self.active.copy()} class Controller: """合成入力でも実際のキーでも、同じ分岐を通す。""" def __init__(self): self.model = Model() self.paused = False def tick(self, held=(), pressed=()): pressed = set(pressed) if 'R' in pressed: self.__init__() return if 'P' in pressed: self.paused = not self.paused return if self.paused or self.model.status != 'play': return self.model.tick() KEYS = ('P', 'R') def main(headless=False, replay=None, screenshot=None, report=None): import json from pathlib import Path # Pyxelがcwdを変更する場合も、呼出し元が指定した保存先を維持する。 screenshot = str(Path(screenshot).resolve()) if screenshot else None report = str(Path(report).resolve()) if report else None import pyxel control = Controller() frames = 0 events = [] duration = max(12, len(replay or [])) keys = {name: getattr(pyxel, 'KEY_' + name) for name in KEYS} def update(): nonlocal frames frames += 1 if headless: if replay is not None and frames <= len(replay): event = replay[frames - 1] control.tick(event.get('held', []), event.get('pressed', [])) events.append({'frame': frames, 'input': event, 'state': control.model.snapshot()}) else: control.tick([name for name, key in keys.items() if pyxel.btn(key)], [name for name, key in keys.items() if pyxel.btnp(key)]) def vehicle(x, y, duration, color=12): width = 22 if duration == BUS else 16 pyxel.rect(x - width, y - 6, width, 12, color) pyxel.rectb(x - width, y - 6, width, 12, 7) pyxel.rect(x - width + 3, y - 4, 5, 4, 1) pyxel.rect(x - width + 2, y + 6, 4, 2, 0) pyxel.rect(x - 6, y + 6, 4, 2, 0) def draw(): m = control.model pyxel.cls(1) pyxel.text(12, 9, 'SLEEPY SIGNAL / 2: OCCUPIED CROSSING', 10) pyxel.text(12, 23, f'TIME {m.t / FPS:5.2f}s PASSED {m.passed}/{len(CARS)} {m.status.upper()}', 7) pyxel.rect(8, 39, 444, 42, 0) for lane in range(LANES): upcoming = [e for e in m.events if e[1] == lane][:3] items = ' '.join(f'{max(0, at - m.t) / FPS:.1f}s ' + ('BUS' if dur == BUS else 'CAR') for at, _, dur in upcoming) pyxel.text(16, 47 + lane * 14, 'NEXT ' + 'AB'[lane] + ': ' + (items or 'NONE'), 6) for lane in range(LANES): y = 118 + lane * 64 pyxel.rect(8, y - 20, 444, 43, 5) pyxel.line(290, y - 20, 290, y + 22, 7) for x in range(304, 340, 8): pyxel.rect(x, y - 17, 4, 34, 13) pyxel.text(13, y - 16, 'AB'[lane], 7) pyxel.text(13, y + 26, f'QUEUE {len(m.q[lane])}', 6) for j, (at, dur) in enumerate(list(m.q[lane])[:6]): x = 278 - j * 27 vehicle(x, y, dur, 9 if dur == BUS else 12) pyxel.text(x - 15, y - 17, str(at // FPS), 7) if len(m.q[lane]) > 6: pyxel.text(101, y + 11, '+' + str(len(m.q[lane]) - 6) + ' MORE', 8) if m.active[lane]: remaining, dur = m.active[lane] x = 312 + (dur - remaining) / dur * 112 vehicle(x, y, dur, 9 if dur == BUS else 12) pyxel.text(346, y - 17, f'BUSY {remaining:02}', 10) else: pyxel.text(347, y - 17, 'CLEAR', 11) pyxel.text(12, 186, 'QUEUE IS NOT THE OCCUPIED CROSSING.', 11) pyxel.text(12, 203, 'CAR = 21 FRAMES / BUS = 66 FRAMES', 6) pyxel.text(12, 220, 'SIMPLIFIED: AUTO START, NO SIGNAL / PEOPLE.', 7) pyxel.text(12, 251, 'P: PAUSE R: RESET WATCH THE BUS AT 3s', 7) pyxel.text(12, 283, 'ESC: QUIT SILENT / NO RANDOM ARRIVALS', 6) if control.paused: pyxel.rect(139, 157, 180, 26, 0) pyxel.text(164, 166, 'PAUSED - P TO RESUME', 10) if headless and frames >= duration: if screenshot: pyxel.screenshot(screenshot, scale=2) if report: Path(report).write_text(json.dumps({ 'scope': 'Synthetic controller inputs; actual Pyxel headless renderer; silent', 'render_frames': frames, 'input_frames': len(events), 'events': events, 'final_state': m.snapshot(), 'paused': control.paused, }, ensure_ascii=False, indent=2) + '\n', encoding='utf-8') pyxel.quit() pyxel.init(460, 302, title='Sleepy Signal - Stage 2', fps=FPS, headless=headless) pyxel.run(update, draw) if __name__ == '__main__': import argparse import json from pathlib import Path parser = argparse.ArgumentParser(description=__doc__) modes = parser.add_mutually_exclusive_group() modes.add_argument('--headless-smoke', action='store_true') modes.add_argument('--headless-replay', help='UTF-8 JSON held/pressed input list') parser.add_argument('--screenshot', help='Output PNG in headless mode') parser.add_argument('--report', help='Output UTF-8 JSON in headless mode') args = parser.parse_args() headless = args.headless_smoke or args.headless_replay is not None if not headless and (args.screenshot or args.report): parser.error('screenshot/report require headless mode') replay = None if args.headless_replay: replay = json.loads(Path(args.headless_replay).read_text(encoding='utf-8')) valid = isinstance(replay, list) and all( isinstance(event, dict) and set(event) <= {'held', 'pressed'} and all( isinstance(event.get(k, []), list) and all(isinstance(v, str) and v in KEYS for v in event.get(k, [])) for k in ('held', 'pressed')) for event in replay) if not valid: parser.error('Use a list of held/pressed dictionaries with supported key names') main(headless, replay, args.screenshot, args.report)