# 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と図形です。 """ねぼすけ信号係・教材5/5。単独ファイルで起動。""" from collections import deque FPS = 30 CAR = 21 BUS = 66 START = 21 LANES = 2 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 )) # 原作B『列より先に人』と同じ12台。3秒のAはバス。 CARS = arrivals(range(6), [0, 3, 6, 9], ((0, 3),)) + arrivals([8, 9], []) PEOPLE = ((114, 180, 60),) # 到着3.8秒・許容待ち6秒・横断2秒 class Model: def __init__(self): self.t = 0 self.status = 'play' self.events = deque(sorted(CARS)) self.q = [deque(), deque()] self.green = [True, True] # 指令。04以降は横断中の予約も表す。 self.ready = [START, START] self.active = [None, None] self.passed = 0 self.p_events = deque(PEOPLE) self.waiting = [] self.walk = 0 self.walk_total = 0 self.walk_batch = 0 self.walked = 0 self.log = [] self.overflow = [0, 0] self.car_wait = 0 self.person_wait = 0 self.max_person_wait = 0 self.switches = 0 self.reason = '' def set_signal(self, lane, green): if self.status != 'play' or self.green[lane] == green: return self.green[lane] = green self.switches += 1 if green: self.ready[lane] = START self.log.append((self.t, 'signal', lane, green)) def toggle(self, lane): self.set_signal(lane, not self.green[lane]) 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)) while self.p_events and self.p_events[0][0] <= self.t: self.waiting.append(self.p_events.popleft()) if self.walk: self.walk -= 1 if not self.walk: self.walked += self.walk_batch self.log.append((self.t, 'walk_end', self.walk_batch)) # 赤になっても、すでに入った車は最後まで通過する。 for lane in range(2): if self.active[lane]: remaining, duration = self.active[lane] remaining -= 1 if remaining <= 0: self.active[lane] = None self.passed += 1 else: self.active[lane] = (remaining, duration) # 空にするのは待ち列qではなく、横断部分active。 if self.waiting and not self.walk and not any(self.green) and not any(self.active): self.walk = self.walk_total = max(p[2] for p in self.waiting) self.walk_batch = len(self.waiting) self.max_person_wait = max(self.max_person_wait, max(self.t - p[0] for p in self.waiting)) self.log.append((self.t, 'walk_start', self.walk_batch)) self.waiting = [] for lane in range(2): if self.green[lane] and not self.walk: self.ready[lane] = max(0, self.ready[lane] - 1) if not self.ready[lane] and not self.active[lane] and self.q[lane]: at, duration = self.q[lane].popleft() self.active[lane] = (duration, duration) # 7台以上が連続21フレーム。1回でも6台以下なら解除。 if len(self.q[lane]) > 6: self.overflow[lane] += 1 else: self.overflow[lane] = 0 if self.overflow[lane] >= CAR: self.fail('QUEUE ' + 'AB'[lane] + ' OVERFLOW') self.car_wait += sum(len(q) for q in self.q) self.person_wait += len(self.waiting) if any(self.t - p[0] >= p[1] for p in self.waiting): self.fail('PEDESTRIAN WAIT LIMIT') assert not (self.walk and any(self.active)), 'unsafe crossing' self.t += 1 if self.status == 'play' and not (self.events or any(self.q) or any(self.active) or self.p_events or self.waiting or self.walk): self.status = 'win' def fail(self, reason): self.status = 'fail' self.reason = reason 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(), 'green': self.green.copy(), 'ready': self.ready.copy(), 'p_events': list(self.p_events), 'waiting': self.waiting.copy(), 'walk': self.walk, 'walk_total': self.walk_total, 'walk_batch': self.walk_batch, 'walked': self.walked, 'log': self.log.copy(), 'overflow': self.overflow.copy(), 'car_wait': self.car_wait, 'person_wait': self.person_wait, 'max_person_wait': self.max_person_wait, 'switches': self.switches, 'reason': self.reason} class Controller: """合成入力でも実際のキーでも、同じ分岐を通す。""" def __init__(self): self.model = Model() self.paused = False self.selected = 0 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 if 'LEFT' in pressed or 'A' in pressed: self.selected = 0 if 'RIGHT' in pressed or 'D' in pressed: self.selected = 1 if 'SPACE' in pressed: self.model.toggle(self.selected) self.model.tick() KEYS = ('P', 'R', 'LEFT', 'RIGHT', 'A', 'D', 'SPACE') 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 / 5: SCENARIO B', 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) if control.selected == lane: pyxel.rectb(8, y - 20, 286, 43, 10) effective_green = m.green[lane] and not m.walk state = 'RESERVED' if m.green[lane] and m.walk else ('GREEN' if effective_green else 'RED') pyxel.circ(30, y + 3, 8, 11 if effective_green else 8) pyxel.text(44, y, state, 7) pyxel.text(13, y + 26, f'QUEUE {len(m.q[lane])} START {m.ready[lane]:02} FRAMES', 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) if m.walk: pyxel.text(12, 225, f'WALK {m.walk / FPS:.2f}s BATCH {m.walk_batch} CARS MUST WAIT', 10) yy = 99 + (1 - m.walk / m.walk_total) * 108 pyxel.circ(324, yy - 4, 3, 10) pyxel.line(324, yy, 324, yy + 7, 10) pyxel.line(324, yy + 7, 320, yy + 12, 10) pyxel.line(324, yy + 7, 328, yy + 12, 10) elif m.waiting: left = min(p[1] - (m.t - p[0]) for p in m.waiting) / FPS pyxel.text(12, 225, f'WAITING {len(m.waiting)} / PATIENCE LEFT {max(0, left):.2f}s', 10) else: next_person = max(0, m.p_events[0][0] - m.t) / FPS if m.p_events else None pyxel.text(12, 225, f'WALKED {m.walked}/{len(PEOPLE)} / NEXT PERSON: ' + (f'{next_person:.1f}s' if next_person is not None else 'NONE'), 6) pyxel.text(12, 239, f'WAIT SUM CAR {m.car_wait / FPS:.1f}s / PERSON {m.person_wait / FPS:.1f}s', 6) pyxel.text(12, 252, 'FAIL: ' + m.reason if m.status == 'fail' else f'OVERFLOW A {m.overflow[0]}/21 B {m.overflow[1]}/21 / SWITCHES {m.switches}', 8 if m.status == 'fail' else 6) pyxel.text(12, 269, 'LEFT/RIGHT or A/D: LANE SPACE: TOGGLE', 7) pyxel.text(12, 283, 'P: PAUSE R: RESET ESC: QUIT SILENT', 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 5', 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)