# 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. """橋を渡る楽団・教材4/5。整数の玩具で、現実の橋の安全計算ではありません。""" from dataclasses import dataclass, replace, asdict import hashlib, json VERSION = '073-v1.1' BOARD_NAMES = ('普通板', '軽い板', '筋交い板') WEIGHTS = (2, 1, 3) STRENGTHS = (11, 9, 16) @dataclass(frozen=True) class Level: id: str title: str widths: tuple capacity: int entries: tuple beats: tuple limit: int = 32 LEVELS = (Level('A', 'そろい足の三人', (1, 2, 1), 6, (1, 5, 9), (4, 4, 4)), Level('B', 'ばらけた足拍子', (1, 2, 1), 6, (1, 4, 7), (3, 3, 3)), Level('C', '中央が二枚の橋', (1, 2, 2, 1), 8, (1, 5, 9), (4, 4, 4))) @dataclass(frozen=True) class State: boards: tuple t: int = 0 vectors: tuple = () positions: tuple = (0, 0, 0) loads: tuple = () strikes: tuple = () faults: tuple = () peak: int = 0 status: str = 'edit' reason: str = '板を選んで、同じ楽団を試そう。' MAX_DECISIONS = 256 def initial(level): n = len(level.widths) return State((0,) * n, vectors=((0, 0),) * n, loads=(2,) * n, strikes=(0,) * n) def total(s): return sum((WEIGHTS[b] for b in s.boards)) def limits(level, s): return tuple((STRENGTHS[b] + (4 if w == 1 else 0) for b, w in zip(s.boards, level.widths))) def damping(v): return v - (v > 0) + (v < 0) def rotate(pair, t): x, y = pair y, x = (-x, y) return (damping(x), damping(y)) if t % 4 == 0 else (x, y) def advance(level, s, impact=4): if s.status != 'run': return s t = s.t + 1 n = len(s.boards) vec = [rotate(p, t) for p in s.vectors] pos = [] strikes = [0] * n for e, b, old in zip(level.entries, level.beats, s.positions): p = 0 if t < e else min(n + 1, 1 + (t - e) // b) pos.append(p) if p != old and 1 <= p <= n: strikes[p - 1] += 1 vec = tuple(((x + impact * k, y) for (x, y), k in zip(vec, strikes))) amps = [max(abs(x), abs(y)) for x, y in vec] loads = tuple((WEIGHTS[s.boards[i]] + 2 * pos.count(i + 1) + amps[i] for i in range(n))) faults = tuple((i for i, (v, lim) in enumerate(zip(loads, limits(level, s))) if v > lim)) status = 'fault' if faults else 'done' if all((p == n + 1 for p in pos)) else 'time' if t >= level.limit else 'run' reason = '桁 ' + ','.join((str(i + 1) for i in faults)) + ' が耐力を超えました。Xで戻して原因を確認。' if faults else '全員が右岸へ。あなたの橋を渡り切りました。' if status == 'done' else '32刻で試運転終了。' if status == 'time' else '足拍子と揺れの向きを見比べよう。' if impact else '静かな足。揺れはなく、板と人の重さで比べます。' return replace(s, t=t, vectors=vec, positions=tuple(pos), loads=loads, strikes=tuple(strikes), faults=faults, peak=max(s.peak, *amps), status=status, reason=reason) def simulate(level, s, impact=4): if total(s) > level.capacity: return replace(s, status='weight', reason='岸の支えには重い。試運転できません。') s = replace(s, status='run') if s.status == 'edit' else s while s.status == 'run': s = advance(level, s, impact) return s def forecast(level, s, impact=4): if total(s) > level.capacity: return None if s.status == 'edit': s = replace(s, status='run') return advance(level, s, impact) if s.status == 'run' else None def digest(x): return hashlib.sha256(json.dumps(asdict(x) if hasattr(x, '__dataclass_fields__') else x, ensure_ascii=False, sort_keys=True, separators=(',', ':')).encode('utf-8')).hexdigest() STAGE = 4 LEVEL = LEVELS[0] BOARD_LABELS = ("PLAIN","LIGHT","BRACE") class App: def __init__(self): self.reset() def reset(self): self.state=initial(LEVEL) self.cursor=0 self.pending=0 self.gated=False self.message='READY' def commit(self,action): before=self.state state=self.state if action=='start' and state.status=='edit' and total(state)<=LEVEL.capacity: self.state=replace(state,status='run') elif action=='tick': self.state=advance(LEVEL,state) elif action.startswith('board:') and state.status=='edit': _,index,board=action.split(':') index,board=int(index),int(board) if 0<=indexLEVEL.capacity: self.message='TOO HEAVY: LIGHTEN A BOARD' 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 key=='RETURN':self.commit('start') elif key=='SPACE':self.commit('tick') elif self.state.status=='edit': if key in ('LEFT','RIGHT'): self.cursor=(self.cursor+(-1 if key=='LEFT' else 1))%len(self.state.boards) self.pending=self.state.boards[self.cursor] elif key in ('UP','DOWN'): self.pending=(self.pending+(1 if key=='UP' else -1))%3 elif key=='Z':self.commit('board:'+str(self.cursor)+':'+str(self.pending)) def draw(self,p): s=self.state;n=len(s.boards) p.cls(1) p.text(12,10,'BRIDGE ORCHESTRA / STAGE '+str(STAGE)+' / A',10) p.text(12,27,'TICK '+str(s.t)+' / STATUS '+s.status.upper(),7) p.text(12,44,'BOARD WEIGHT '+str(total(s))+' / CAP '+str(LEVEL.capacity),8 if total(s)>LEVEL.capacity else 11) p.text(12,62,'ENTRY 1,5,9 / WALK EVERY 4 TICKS / PEOPLE WEIGHT 2',6) for i,board in enumerate(s.boards): x=72+i*120 p.rect(x,145,116,18,(5,6,9)[board]);p.rectb(x,145,116,18,10 if i==self.cursor else 7) p.text(x+8,175,str(i)+': '+BOARD_LABELS[board],7) p.text(x+8,192,'LOAD '+str(s.loads[i]),7) if hasattr(s,'vectors'): p.text(x+8,209,'V '+str(s.vectors[i]),14) p.text(x+8,226,'HITS '+str(s.strikes[i]),14) if STAGE>=4: p.text(x+8,243,'LIMIT '+str(limits(LEVEL,s)[i]),8 if i in s.faults else 11) p.rect(12,145,54,80,3);p.rect(438,145,54,80,3) for i,position in enumerate(s.positions): x=35 if position==0 else 465 if position==n+1 else 125+(position-1)*120 y=130-i*14 p.circ(x,y,5,(8,10,12)[i]);p.text(x-1,y-2,str(i+1),1) if STAGE>=2:p.text(12,275,'CANDIDATE '+str(self.cursor)+': '+BOARD_LABELS[self.pending]+' (Z TO COMMIT)',7) if STAGE>=4: q=forecast(LEVEL,s) p.text(12,294,'NEXT TICK LOADS '+str(q.loads) if q else 'NO NEXT-TICK FORECAST',6) p.text(12,311,'FAULT SEGMENTS '+str(s.faults)+' / PEAK '+str(s.peak),8 if s.faults else 6) elif STAGE==3:p.text(12,294,'PRACTICE: LOADS ONLY / STRENGTH NOT CHECKED YET',14) else:p.text(12,294,'PRACTICE: QUIET FOOTSTEPS / NO STRENGTH CHECK',14) p.text(12,350,self.message,10) p.text(12,372,'ENTER: START / SPACE: ONE TICK / R: RESET / ESC: QUIT',7) if STAGE>=2:p.text(12,389,'ARROWS: SEGMENT + CANDIDATE / Z: COMMIT IN EDIT',7) if STAGE==5:p.text(12,406,'X: UNDO (DECISION COUNT DOES NOT REWIND)',7) def make_demo_keys(): return ('RIGHT','UP','UP','Z','RIGHT','UP','Z','RETURN')+('SPACE',)*21 def main(headless=False,screenshot=None,demo=False): import pyxel as p p.init(508,432,title='Bridge orchestra 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,'UP':p.KEY_UP,'DOWN':p.KEY_DOWN,'RETURN':p.KEY_RETURN,'SPACE':p.KEY_SPACE,'Z':p.KEY_Z,'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