# -*- coding: utf-8 -*- """030 ひと箱の天気 / standalone tutorial / fictional discrete water model. Original model: Pyxel 100 contributors. Teaching adaptation: 2026. No external image/font asset is needed. Pyxel is imported only in main(). """ # MIT License # # Copyright (c) 2026 Pyxel 100 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. # # The bundled Noto-derived bitmap glyphs are separately licensed under SIL OFL. # See assets/NOTO-LICENSE.txt. from dataclasses import dataclass, replace, asdict import hashlib, json STAGE = 3 STAGE_TITLE = 'OLD-STATE FLOW' GUIDE = '1: closed gate 2: old K 3: merge X: one flow tick' @dataclass(frozen=True) class Node: name:str x:int y:int kind:str='dish' capacity:int=0 target:str|None=None alternate:str|None=None catches:bool=True shades:bool=False @dataclass(frozen=True) class Level: title:str nodes:tuple[Node,...] start:tuple[int,...] total:int gate_linked:bool=True width:int=7 cloud_capacity:int=3 @dataclass(frozen=True) class State: x:int cloud:int water:tuple[int,...] absorbed:int=0 # Explicit accounting slot; authored soil holds water, never absorbs it. moves:int=0 @dataclass(frozen=True) class Result: state:State accepted:bool reason:str trace:tuple[tuple[int,...],...]=() transfers:tuple[tuple[tuple[str,str,int],...],...]=() # 原作Cの座標・容量・流路。列は0〜6、yは下へ増える。 BOARD = Level('C: keep one drop in K', ( Node('S',1,5,capacity=3), Node('F',5,4,capacity=2,target='S'), Node('K',3,5,capacity=1,target='S'), Node('gate',5,2,'gate',target='F',alternate='S',shades=True), ), (3,0,0,0), 3) def index(level,name):return next(i for i,n in enumerate(level.nodes) if n.name==name) def amount(level,state,name):return state.water[index(level,name)] def initial(level):return State(1,0,level.start) def total(state):return state.cloud+sum(state.water)+state.absorbed def check(level,state,stable=True): assert len(state.water)==len(level.nodes) assert 0<=state.x=0 and all(v>=0 for v in state.water) assert total(state)==level.total if stable: assert all(v<=n.capacity if n.kind=='dish' else v==0 for n,v in zip(level.nodes,state.water)) def state_hash(state):return hashlib.sha256(json.dumps(asdict(state),sort_keys=True).encode('utf-8')).hexdigest() def gate_open(level,water): return not level.gate_linked or not any(n.name=='K' for n in level.nodes) or water[index(level,'K')]>=1 def visible(level,state,sun=False): candidates=[] for i,n in enumerate(level.nodes): if n.x!=state.x:continue if n.kind=='gate' and gate_open(level,state.water):continue if (sun and (n.kind=='dish' or n.shades)) or (not sun and n.catches):candidates.append((n.y,n.name,i)) return min(candidates)[2] if candidates else None def preview(level,state): sun=visible(level,state,True);rain=visible(level,state) rain=index(level,'S') if rain is None else rain return sun,rain def microstep(level,old,order=None): """All intents use old; merge additions in new. One unit per emitting node. Gates inspect old K, not the partially written new buffer. Converging inflows sum without priority: excess remains and overflows on the following tick. """ new=list(old);moves=[];order=range(len(old)) if order is None else tuple(order) if sorted(order)!=list(range(len(old))):raise ValueError('scan must visit each node once') for i in order: n=level.nodes[i] if old[i]<= (n.capacity if n.kind=='dish' else 0):continue target=n.target if n.kind!='gate' or gate_open(level,old) else n.alternate if target is None:raise ValueError('unrouted overflow') j=index(level,target);new[i]-=1;new[j]+=1;moves.append((n.name,target,1)) assert sum(new)==sum(old) and all(v>=0 for v in new) return tuple(new),tuple(sorted(moves)) def lab(number): """原作test_model.pyの意味を保つ、旧状態と合流の観察配置。""" if number == 1: return BOARD, State(5,0,(0,0,0,3)) if number == 2: level = Level('old K test',( Node('S',0,5,capacity=3),Node('F',5,4,capacity=2,target='S'), Node('K',3,5,capacity=1,target='S'), Node('feed',3,2,'pipe',target='K',catches=False), Node('gate',5,2,'gate',target='F',alternate='S',shades=True), ),(3,0,0,0,0),3) return level, State(1,0,(1,0,0,1,1)) if number == 3: level = Level('merge then overflow',( Node('S',0,5,capacity=4),Node('F',5,4,capacity=2,target='S'), Node('a',1,1,'pipe',target='F',catches=False), Node('b',2,1,'pipe',target='F',catches=False), ),(4,0,0,0),4) return level, State(1,1,(0,1,1,1)) raise ValueError('Choose lab 1, 2 or 3') CONTROLS = ('LEFT','RIGHT','UP','DOWN','X','Z','R','1','2','3') REPLAY = {'L':'LEFT','R':'RIGHT','U':'UP','D':'DOWN','X':'X','Z':'Z', 'Q':'R','1':'1','2':'2','3':'3'} class Controller: def __init__(self): self.lock = False self.reset() def reset(self): self.level = BOARD self.state = initial(BOARD) self.history = [] self.trace = () self.transfers = () self.tick_count = 0 self.reason = 'ready' self.preset = 1 self.level,self.state=lab(1) # reset中にlockは消さない。R後も全キー解放が必要。 def tick(self, pressed=(), held=()): pressed = set(pressed) & set(CONTROLS) held = set(held) & set(CONTROLS) if self.lock: if not held: self.lock = False return if not pressed and len(held) <= 1: return if len(pressed) != 1 or pressed != held: self.lock = True self.reason = 'chord_rejected' return self.lock = True key = next(iter(pressed)) if key == 'R': self.reset() return if key in ('1','2','3'): self.preset=int(key);self.level,self.state=lab(self.preset) self.trace=();self.transfers=();self.tick_count=0;self.reason='preset';return if key=='X': old=self.state.water water,events=microstep(self.level,old) self.state=replace(self.state,water=water) check(self.level,self.state,stable=False) self.trace=self.trace+(old,water) self.transfers=self.transfers+(events,) self.tick_count+=1 self.reason='flow' if events else 'stable' else:self.reason='use_X_or_123' def snapshot(self): sun, rain = preview(self.level,self.state) if STAGE >= 2 else (None,None) return {'state':asdict(self.state),'hash':state_hash(self.state), 'total':total(self.state),'budget':self.level.total, 'gate_open':gate_open(self.level,self.state.water) if STAGE>=2 else False, 'won':won(self.level,self.state) if STAGE>=4 else False, 'sun':None if sun is None else self.level.nodes[sun].name, 'rain':None if rain is None else self.level.nodes[rain].name, 'event':self.reason,'history_depth':len(self.history), 'trace':self.trace,'transfers':self.transfers, 'ticks':self.tick_count,'preset':self.preset,'lock':self.lock} def draw_board(p,c): """描画は状態を読むだけ。水滴の絵を別資源として加算しない。""" s=c.state; level=c.level; snap=c.snapshot() p.cls(1) p.text(14,12,'BOX WEATHER / STEP '+str(STAGE)+' / '+STAGE_TITLE,10) p.text(14,27,'Fictional integer water model / no automatic evaporation / silent',6) ox,oy,dx,dy=47,105,54,42 def xy(n): return ox+n.x*dx,oy+n.y*dy def arrow(x,y,xx,yy,color): p.line(x,y,xx,yy,color) if abs(xx-x)>abs(yy-y): sign=1 if xx>x else -1 p.line(xx,yy,xx-sign*5,yy-3,color);p.line(xx,yy,xx-sign*5,yy+3,color) else: sign=1 if yy>y else -1 p.line(xx,yy,xx-3,yy-sign*5,color);p.line(xx,yy,xx+3,yy-sign*5,color) p.rect(18,47,402,313,0);p.rectb(18,47,402,313,5) for x in range(7): p.text(ox+x*dx-2,53,str(x),13) for y in range(110,333,10): p.pset(ox+x*dx,y,1) for n in level.nodes: if n.target: dst=level.nodes[index(level,n.target)] x,y=xy(n);xx,yy=xy(dst) if n.kind!='dish': color=(11 if snap['gate_open'] else 5) if n.kind=='gate' else 6 arrow(x,y+12,xx,yy-15,color) if n.kind=='gate': x,y=xy(n);color=5 if snap['gate_open'] else 6 arrow(x+25,y,401,y,color);arrow(401,y,401,346,color) src=level.nodes[index(level,'S')];sx,sy=xy(src) arrow(401,346,sx,346,6);arrow(sx,346,sx,sy+13,6) p.text(190,349,'RETURN TO S',6) for i,n in enumerate(level.nodes): x,y=xy(n);value=s.water[i] if n.kind=='dish': p.rect(x-21,y-9,43,19,4);p.rect(x-18,y-10,37,14,1) for j in range(n.capacity): if jn.capacity: p.text(x-22,y+27,'EXCESS '+str(value-n.capacity),8) if n.name=='F': p.line(x,y-12,x,y-28,11);p.circ(x,y-30,5,10 if snap['won'] else 3) else: color=11 if n.kind=='gate' and snap['gate_open'] else 9 p.rectb(x-23,y-10,47,20,color) p.text(x-20,y-3,n.name,7) p.text(x-21,y+15,'WATER '+str(value),12) if n.kind=='gate': p.text(x-20,y-21,'OPEN' if snap['gate_open'] else 'CLOSED',color) if n.name==snap['rain']:p.rectb(x-26,y-14,53,29,12) if n.name==snap['sun']:p.rectb(x-24,y-12,49,25,10) cx=ox+s.x*dx p.elli(cx-24,76,49,20,7);p.circ(cx-10,76,11,7);p.circ(cx+9,73,12,7) for j in range(level.cloud_capacity): if j=last: if screenshot:pyxel.screenshot(str(Path(screenshot).resolve()),scale=1) if report: Path(report).write_text(json.dumps({ 'label':'actual Pyxel headless / synthetic controller input / silent', 'stage':STAGE,'frames':frame,'logical_seconds':frame/30, 'wall_seconds_before_exit':round(time.perf_counter()-start,6), 'program_sha256':hashlib.sha256(Path(__file__).read_bytes()).hexdigest(), 'sequence':sequence,'events':events,'snapshot':c.snapshot() },ensure_ascii=False,indent=2)+'\n',encoding='utf-8') pyxel.quit() pyxel.init(720,500,title='Box Weather - Step '+str(STAGE),fps=30,headless=headless) pyxel.run(update,draw) if __name__=='__main__': import argparse parser=argparse.ArgumentParser(description=__doc__) group=parser.add_mutually_exclusive_group() group.add_argument('--headless-smoke',action='store_true') group.add_argument('--headless-replay',help='L/R/U/D arrows; X/Z; Q reset; 1/2/3 presets') parser.add_argument('--screenshot');parser.add_argument('--report') 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('Outputs require headless mode') seq=args.headless_replay or '' if any(k not in REPLAY for k in seq):parser.error('Unsupported replay character') main(headless,seq,args.screenshot,args.report)