# 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 itertools STATIONS = ('A', 'B', 'X', 'Y', 'D', 'E') EDGES = (('A', 'X', 1), ('B', 'X', 1), ('X', 'Y', 1), ('Y', 'D', 1), ('Y', 'E', 1), ('A', 'D', 3), ('B', 'E', 3), ('D', 'E', 1)) @dataclass(frozen=True) class Level: id: str title: str edges: tuple demand: tuple capacity: int = 7 limit: int = 32 LEVELS = (Level('A', '二つの直通', EDGES, ((0, 'A', 'D'), (0, 'B', 'E'), (1, 'A', 'D'), (1, 'B', 'E'), (2, 'A', 'D'), (2, 'B', 'E'))), Level('B', '行先が交じる夜', EDGES, ((0, 'A', 'D'), (0, 'B', 'E'), (1, 'A', 'E'), (1, 'B', 'D'), (2, 'A', 'E'), (2, 'B', 'D'))), Level('C', '一方に寄った便', EDGES + (('B', 'D', 2),), ((0, 'A', 'E'), (0, 'B', 'D'), (1, 'A', 'E'), (1, 'B', 'D'), (2, 'B', 'D'), (3, 'B', 'D')))) @dataclass(frozen=True) class State: routes: tuple = ((),()) status: str = 'edit' def initial(level): return State() def edge_lengths(level): return {tuple(sorted((a, b))): d for a, b, d in level.edges} def route_edges(route): return tuple((tuple(sorted((a, b))) for a, b in zip(route, route[1:]))) def weight(level, routes): return sum((edge_lengths(level)[e] for e in set(itertools.chain.from_iterable((route_edges(r) for r in routes))))) def intersections(routes): return set(routes[0]) & set(routes[1]) def valid_routes(level, routes): return len(routes) == 2 and all((len(r) <= 5 and len(set(r)) == len(r) and all((n in STATIONS for n in r)) and all((e in edge_lengths(level) for e in route_edges(r))) for r in routes)) def edit_reason(level, s, line, node): r = s.routes[line] if len(r) >= 5: return '一本は最大4辺です。' if node in r: return '同じ駅を二度通れません。' if r and tuple(sorted((r[-1], node))) not in edge_lengths(level): return 'その駅へ使える辺がありません。' return '' def apply_action(level,state,action,penalty=1): if state.status=='edit': bits=action.split(':') if len(bits)==3 and bits[0]=='add' and bits[1] in ('0','1') and bits[2] in STATIONS: line=int(bits[1]);node=bits[2] if not edit_reason(level,state,line,node): routes=list(state.routes);routes[line]=routes[line]+(node,) return replace(state,routes=tuple(routes)) if len(bits)==2 and bits[0]=='pop' and bits[1] in ('0','1'): line=int(bits[1]);routes=list(state.routes);routes[line]=routes[line][:-1] return replace(state,routes=tuple(routes)) return state STAGE = 1 PENALTY = 0 LEVEL = LEVELS[0] START_ROUTES = ((), ()) POS = {'A':(60,100),'B':(60,240),'X':(175,170),'Y':(290,170),'D':(405,100),'E':(405,240)} class App: def __init__(self):self.reset() def reset(self): self.state=replace(initial(LEVEL),routes=START_ROUTES) self.station=0 self.line=0 self.gated=False self.message='READY' def commit(self,action): before=self.state self.state=apply_action(LEVEL,self.state,action,PENALTY) self.message=action.upper() if self.state!=before else 'NO CHANGE / REFUSED' def control(self,pressed,held): 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.gated=True;self.message='RELEASE ALL KEYS';return if len(pressed)!=1:return key=next(iter(pressed));self.gated=True if self.state.status!='edit':return if key in ('LEFT','RIGHT'):self.station=(self.station+(-1 if key=='LEFT' else 1))%len(STATIONS) elif key=='TAB':self.line=1-self.line elif key=='Z':self.commit('add:'+str(self.line)+':'+STATIONS[self.station]) elif key=='BACKSPACE':self.commit('pop:'+str(self.line)) def draw(self,p): state=self.state;p.cls(1) p.text(12,8,'STAR RAILWAYS / STAGE '+str(STAGE)+' / A',10) p.text(12,25,'WEIGHT '+str(weight(LEVEL,state.routes))+' / CAP '+str(LEVEL.capacity)+' / '+state.status.upper(),7) for a,b,length in LEVEL.edges: x,y=POS[a];xx,yy=POS[b];p.line(x,y,xx,yy,5);p.text((x+xx)//2+4,(y+yy)//2+4,str(length),6) for line,route in enumerate(state.routes): for a,b in zip(route,route[1:]): x,y=POS[a];xx,yy=POS[b];p.line(x,y+line*3,xx,yy+line*3,(10,12)[line]) cross=intersections(state.routes) for i,n in enumerate(STATIONS): x,y=POS[n];p.circ(x,y,11,2 if n in cross else 1);p.circb(x,y,11,10 if i==self.station else 7);p.text(x-2,y-2,n,7) if n in cross:p.text(x-10,y+17,'CROSS',14) p.text(12,274,'LINE '+str(self.line+1)+' / STATION '+STATIONS[self.station],10) for i,route in enumerate(state.routes):p.text(12,291+i*15,'L'+str(i+1)+': '+('-'.join(route) or '(empty)'),(10,12)[i]) p.text(12,330,'PRACTICE: ROUTE EDITING ONLY / NO TRIAL YET',14) p.text(12,406,self.message,10) p.text(12,430,'LEFT/RIGHT: STATION LIST / TAB: LINE / Z: ADD / BACKSPACE: POP',7) p.text(12,447,'R: RESET TO THIS STAGE EXAMPLE / ESC: QUIT',7) def make_demo_keys(): return ('Z','RIGHT','RIGHT','RIGHT','RIGHT','Z','TAB','LEFT','LEFT','LEFT','Z','RIGHT','RIGHT','RIGHT','RIGHT','Z') def main(headless=False,screenshot=None,demo=False): import pyxel as p p.init(672,492,title='Star railway lesson '+str(STAGE),fps=30,display_scale=2,headless=headless) app=App();sequence=make_demo_keys() if demo else () keys={'LEFT':p.KEY_LEFT,'RIGHT':p.KEY_RIGHT,'TAB':p.KEY_TAB,'Z':p.KEY_Z,'BACKSPACE':p.KEY_BACKSPACE,'RETURN':p.KEY_RETURN,'SPACE':p.KEY_SPACE,'X':p.KEY_X,'R':p.KEY_R} 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() if headless: if demo: index=p.frame_count//2;pressed={sequence[index]} if p.frame_count%2==0 and index