# -*- 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 = 2 STAGE_TITLE = 'VERTICAL VISIBILITY' GUIDE = 'LEFT/RIGHT: cloud 1/2/3: presets UP/DOWN: S column only' @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 source_exchange(level, state, action): """01/02の実験: Sの真上だけで所有者を1滴ずつ交換する。""" if action not in ('U','D'): return Result(state,False,'unused') if state.x != 1: return Result(state,False,'inspect_only') i = index(level,'S') water = list(state.water) if action == 'U': if state.cloud == level.cloud_capacity: return Result(state,False,'full') if water[i] == 0: return Result(state,False,'dry') water[i] -= 1 cloud = state.cloud + 1 else: if state.cloud == 0: return Result(state,False,'empty') water[i] += 1 cloud = state.cloud - 1 new = replace(state,cloud=cloud,water=tuple(water),moves=state.moves+1) check(level,new) return Result(new,True,'sun' if action=='U' else 'rain') 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 survey_state(number): # 原作の到達状態: 初期 / UUURRD / UUURRDRRD。 return (initial(BOARD), State(3,2,(0,0,1,0),moves=6), State(5,1,(0,1,1,0),moves=9))[number-1] 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 # 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.state=survey_state(self.preset);self.reason='preset';return if key in ('LEFT','RIGHT'): x=self.state.x+(-1 if key=='LEFT' else 1) if 0<=x= 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)