# 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, asdict import hashlib, json VERSION = '070-starter' @dataclass(frozen=True) class Level: id: str title: str kind: str LEVELS = (Level('A', '声を読んで放送する', 'A'),) @dataclass(frozen=True) class State: t: int = 0 sent: tuple = () read: tuple = () ended: bool = False @dataclass(frozen=True) class Timeline: state: State past: tuple = () @dataclass(frozen=True) class Card: id: str speaker: str title: str text: str deadline: str CARDS = { 'song': Card('song', '演奏者', '最後の歌を紹介', 'この一枠だけ歌の題名を紹介できます。', '枠0だけ'), 'song_late': Card('song_late', '演奏者', '歌の紹介を次回へ', '今夜は紹介できなかったことを記録します。', '締切後'), 'jingle': Card('jingle', '放送係', '短い合図', '文字の合図だけ。一枠を進めます。', 'いつでも'), } def initial(l): return Timeline(State()) def visible(s, l, variant=False): song = 'song' if s.t == 0 else 'song_late' return (() if 'song' in s.sent or song in s.sent else (song,)) + ('jingle',) def enabled(s, l, c): return not s.ended and c in visible(s, l) and (c == 'jingle' or c not in s.sent) def transition(s, l, action, variant=False): if s.ended: return s if action.startswith('read:'): c = action[5:] return replace(s, read=s.read + (c,)) if c in visible(s,l) and c not in s.read else s if not action.startswith('send:'): return s c = action[5:] if not enabled(s, l, c): return s t = s.t + 1 return replace(s, t=t, sent=s.sent + (c,), ended=t >= 4) def step(t, l, action, variant=False): return Timeline(transition(t.state, l, action, variant)) def canonical(o): return json.dumps(o, ensure_ascii=False, sort_keys=True, separators=(',', ':')) def digest(o): return hashlib.sha256(canonical(o).encode('utf-8')).hexdigest() # 教材用の入力・表示。モデル以外に原作との違いがあります。 STAGE = 1 INITIAL_CURSOR = 0 KEYS = ('UP','DOWN','Z','RETURN','X','R','1','2','3','4','B') CARD_LABELS = { 'bus':'BUS: TWO SEATS AT T2', 'bus_late':'BUS: DEPARTED', 'umbrella':'HELPER: ONE PERSON AT T3', 'umbrella_late':'HELPER: NEXT MORNING', 'song':'SONG: ONLY SLOT 0', 'song_late':'SONG: MISSED TONIGHT', 'shelter':'SHELTER: A PLACE TO WAIT', 'thanks':'THANKS: WALK ARRANGED', 'repair':'LIGHTS: WORK AT T2 (+1)', 'repair_late':'LIGHTS: MORNING WORK', 'shop':'BOXES: PICKUP AT T2 (+2)', 'shop_late':'BOXES: STORE THEM', 'quiet':'QUIET: NEXT TICK (-2)', 'lights_call':'CALL: LIGHTS PREPARED', 'morning_call':'CALL: LIGHTS STILL NEEDED', 'storage_call':'CALL: STORE TWO BOXES', 'quiet_call':'CALL: EARLY AND NOW', 'ferry':'FERRY: ONE AT T1 AND T4', 'check':'CHECK: ANSWER IN THREE TICKS', 'bridge':'BRIDGE: CONFIRMED OPEN', 'bridge_closed':'BRIDGE: CONFIRMED CLOSED', 'jingle':'JINGLE: TEXT SIGNAL / +1 TICK', 'handoff':'HANDOFF: CLOSE AT CURRENT TICK'} class App: def __init__(self): self.level = 0 self.variant = False self.reset() @property def board(self): return LEVELS[self.level] @property def state(self): return self.timeline.state def reset(self): self.timeline = initial(self.board) self.cursor = INITIAL_CURSOR self.gated = False self.message = 'READING DOES NOT SPEND A SLOT' def ids(self): return visible(self.state,self.board,self.variant) def action(self,action): self.timeline = step(self.timeline,self.board,action,self.variant) self.cursor = min(self.cursor,len(self.ids())-1) def press(self,key): if key == 'R': self.reset() return if key in ('1','2','3','4') and int(key) <= len(LEVELS): self.level = int(key)-1 self.reset() return if key == 'B' and STAGE == 5 and self.board.kind == 'A': self.variant = not self.variant self.reset() return if key == 'X' and STAGE == 5: self.action('undo') return if self.state.ended: return if key in ('UP','DOWN'): self.cursor = (self.cursor + (-1 if key=='UP' else 1)) % len(self.ids()) elif key in ('Z','RETURN'): self.action(('read:' if key=='Z' else 'send:') + self.ids()[self.cursor]) def control(self,pressed,held): # 合成テストとPyxelの両方がこの入口を使う。全部離すまで次を受けない。 if self.gated: if not pressed and not held: self.gated = False return if len(pressed | held) > 1: self.message = 'RELEASE ALL KEYS / CHORD REJECTED' self.gated = True return if len(pressed) == 1: self.press(next(iter(pressed))) self.gated = True def notice(self,c): s = self.state if s.ended: return ('CLOSED: PENDING IS NOT ACHIEVED', 'X UNDO (STAGE 5) / R NEW TRIAL') if not enabled(s,self.board,c): return ('CONDITIONAL SAMPLE ONLY', 'WAIT FOR ITS ACTUAL CALL') if c == 'check': return ('ANSWER DUE AT T' + str(s.t+3), 'A LATER BROADCAST IS STILL REQUIRED') if c == 'bridge': return ('CONFIRMED OPEN: HOME NEXT TICK', 'ONLY THIS BROADCAST RESERVES RETURN') if c == 'bridge_closed': return ('CONFIRMED CLOSED', 'NO BRIDGE RETURN IS RESERVED') if c == 'handoff': return ('CLOSE NOW / NO CLOCK ADVANCE', 'KEEP PENDING AND UNKNOWN ITEMS') if c == 'shelter': return ('CONFIRM A PLACE TO WAIT', 'HOME COUNT DOES NOT INCREASE') if c == 'ferry': return ('ARRIVE NEXT TICK, THEN DEPART', 'BOATS AT T1 / T4, ONE SEAT EACH') if c == 'bus': return ('ARRIVE NEXT TICK, THEN DEPART', 'BUS AT T2, TWO SEATS') if c == 'umbrella': return ('ONE WALK HOME AT T3', 'ONLY IF SOMEONE IS STILL WAITING') if c == 'quiet': return ('AT NEXT TICK: MAX(0, NOISE - 2)', 'AFTER REPAIR AND SHOP AT SAME TICK') return ('Z READ / ENTER BROADCAST', 'CHECK DEADLINES BEFORE SENDING') def draw(self,p): s=self.state p.cls(1) p.text(14,12,'STRANDED RADIO / STAGE '+str(STAGE)+' / NIGHT '+self.board.id,10) p.text(14,27,'FICTIONAL PEOPLE / SILENT TEXT SIGNALS',6) p.text(14,47,'TICK '+str(s.t)+' / 4 '+('CLOSED' if s.ended else 'ON AIR')+' HISTORY '+str(len(self.timeline.past)),11) for i in range(4): p.rect(440+i*58,46,50,8,11 if i DEPARTURES / EARLY RECORD -> NEW CALLS -> END',6) p.text(14,405,'SLOTS ARE LIMITED. THERE IS NO HIDDEN ALL-HAPPY SCORE.',7) p.text(14,469,self.message,10) p.text(14,488,'UP/DOWN SELECT / Z READ / ENTER SEND / X UNDO (5) / R RESET',7) p.text(14,505,'1-'+str(len(LEVELS))+' NIGHT / B A-SAMPLES (5) / ESC QUIT / RELEASE EACH KEY',6) def handover_lines(s,l): lines=[] if l.kind=='A': if not s.song:lines.append('SONG: OPPORTUNITY STILL IN SLOT 0' if s.t==0 else 'SONG: MISSED TONIGHT') if s.t<2 and 'bus' not in s.sent:lines.append('BUS ANNOUNCEMENT STILL NEEDED BY SLOT 1') if s.t<2 and 'umbrella' not in s.sent:lines.append('HELPER REQUEST STILL NEEDED BY SLOT 1') if l.kind=='B': if not s.lights:lines.append('MORNING LIGHTING WORK REMAINS') if s.boxes:lines.append(str(s.boxes)+' BOXES REMAIN FOR STORAGE') if s.noise>1 or s.t<2 and 'quiet' not in s.sent:lines.append('QUIET ROUTE / CHECK T2 RECORD') if l.kind=='C': if 'ferry' not in s.sent and s.t<4:lines.append('FERRY ANNOUNCEMENT STILL NEEDED') if s.bridge=='未確認':lines.append('BRIDGE OPEN/CLOSED IS UNKNOWN') elif s.bridge=='開通' and 'bridge' not in s.sent:lines.append('BRIDGE CONFIRMED OPEN, NOT BROADCAST') if s.people and home_count(s)=limit: if screenshot: app.draw(p);p.screen.save(screenshot,1) p.quit() if headless: index=p.frame_count//2 pressed={sequence[index]} if demo and p.frame_count%2==0 and index