# 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, asdict, replace from itertools import product import json, hashlib VERSION = '1.0.0' GAME = '091' TITLES = {'A': '西からの初雪', 'B': '向きの変わる冬', 'C': '古い蓄電池'} SEASONS = ('秋', '初冬', '厳冬', '雪解け') WEATHER = {'A': ((3, -1, 1, 0), (4, -1, 2, 1), (4, -1, 2, 2), (3, -1, 1, 0)), 'B': ((3, -1, 1, 0), (4, 1, 2, 1), (5, -1, 3, 3), (3, 1, 1, 0)), 'C': ((5, 1, 1, 0), (2, 1, 2, 2), (4, 1, 2, 2), (4, -1, 1, 0))} BUILDS = ('none', 'shield:0', 'shield:1', 'shield:2', 'move:0', 'move:1', 'move:2', 'capacity', 'remove:0', 'remove:1', 'remove:2') BUILD_NAMES = ('建設なし', '左に雪よけ', '中に雪よけ', '右に雪よけ', '風車を左へ', '風車を中へ', '風車を右へ', '電池を増設', '左の雪よけ撤去', '中の雪よけ撤去', '右の雪よけ撤去') @dataclass(frozen=True) class State: case: str = 'A' season: int = 0 mill: int = 1 shields: tuple = (False, False, False) materials: int = 5 battery: int = 4 capacity: int = 8 comfort: tuple = (3, 3, 3) shelter: int = 0 history: tuple = () blocking: bool = True @dataclass(frozen=True) class Plan: build: str = 'none' heat: tuple = (0, 0, 0) def new(case='A', blocking=True): return State(case=case, battery={'A': 4, 'B': 5, 'C': 7}[case], comfort=(2, 3, 2) if case == 'C' else (3, 3, 3), blocking=blocking) def construction(s, b): if b not in BUILDS: raise ValueError('未知の建設') if s.season >= 4: raise ValueError('全季節終了') sh = list(s.shields) m = s.mill cap = s.capacity cost = 0 if b.startswith('shield:'): i = int(b[-1]) cost = 2 if sh[i]: raise ValueError('ここには雪よけがあります') sh[i] = True elif b.startswith('remove:'): i = int(b[-1]) if not sh[i]: raise ValueError('撤去する雪よけがありません') sh[i] = False elif b.startswith('move:'): m = int(b[-1]) cost = 1 if m == s.mill: raise ValueError('風車は同じ列です') elif b == 'capacity': if cap >= 12: raise ValueError('電池は最大です') cost = 2 cap += 2 if cost > s.materials: raise ValueError('資材が足りません') return (tuple(sh), m, cap, cost) def forecast(s, b='none'): sh, m, cap, cost = construction(s, b) w, d, h, snow = WEATHER[s.case][s.season] blocked = sum((v for i, v in enumerate(sh) if i <= m)) if d == -1 else sum((v for i, v in enumerate(sh) if i >= m)) power = max(0, w - (blocked if s.blocking else 0)) needs = tuple((max(0, h - int(v)) for v in sh)) available = min(cap, s.battery + power) return {'shields': sh, 'mill': m, 'capacity': cap, 'cost': cost, 'wind': w, 'direction': d, 'snow': snow, 'blocked': blocked if s.blocking else 0, 'power': power, 'needs': needs, 'available': available, 'spill': max(0, s.battery + power - cap)} def predict(s, p): f = forecast(s, p.build) if not isinstance(p.heat, tuple) or len(p.heat) != 3 or any((type(h) != int or not 0 <= h <= n for h, n in zip(p.heat, f['needs']))): raise ValueError('配分は必要量までです') if sum(p.heat) > f['available']: raise ValueError('電力が足りません') f['comfort'] = tuple((min(3, c + 1) if h >= n else max(0, c - (n - h)) for c, h, n in zip(s.comfort, p.heat, f['needs']))) f['shelter'] = any((c == 0 for c in f['comfort'])) f['remaining'] = f['available'] - sum(p.heat) return f def step(s, p): f = predict(s, p) entry = (s.season, p.build, p.heat, f['power'], f['spill'], f['comfort'], f['remaining'], f['shelter']) return replace(s, season=s.season + 1, mill=f['mill'], shields=f['shields'], capacity=f['capacity'], materials=s.materials - f['cost'] + 1, battery=f['remaining'], comfort=f['comfort'], shelter=s.shelter + int(f['shelter']), history=s.history + (entry,)) def success(s): return s.season == 4 and min(s.comfort) >= 1 def plans(s): for b in BUILDS: try: f = forecast(s, b) except ValueError: continue for heat in product(*(range(n + 1) for n in f['needs'])): if sum(heat) <= f['available']: yield Plan(b, heat) def digest(s): return hashlib.sha256(json.dumps(asdict(s), sort_keys=True, ensure_ascii=False).encode('utf-8')).hexdigest() def restore(d): if not isinstance(d, dict) or set(d) != set(asdict(new())): raise ValueError('状態の項目が違います') if d['case'] not in TITLES or type(d['blocking']) != bool: raise ValueError('状況が不正') for k, lo, hi in [('season', 0, 4), ('mill', 0, 2), ('materials', 0, 9), ('battery', 0, 12), ('capacity', 8, 12), ('shelter', 0, 4)]: if type(d[k]) != int or not lo <= d[k] <= hi: raise ValueError('状態の範囲が不正') if d['capacity'] not in (8, 10, 12) or d['battery'] > d['capacity']: raise ValueError('電池が不正') if not isinstance(d['shields'], (list, tuple)) or len(d['shields']) != 3 or any((type(x) != bool for x in d['shields'])): raise ValueError('雪よけが不正') if not isinstance(d['comfort'], (list, tuple)) or len(d['comfort']) != 3 or any((type(x) != int or not 0 <= x <= 3 for x in d['comfort'])): raise ValueError('快適度が不正') if not isinstance(d['history'], (list, tuple)) or len(d['history']) != d['season']: raise ValueError('履歴が不正') s = new(d['case'], d['blocking']) for e in d['history']: if not isinstance(e, (list, tuple)) or len(e) != 8: raise ValueError('履歴が不正') s = step(s, Plan(e[1], tuple(e[2]))) if json.loads(json.dumps(asdict(s))) != json.loads(json.dumps(d)): raise ValueError('履歴と状態が一致しません') return s STAGE = 4 SEASON_LABELS=("AUTUMN","EARLY WINTER","DEEP WINTER","THAW") BUILD_LABELS=("NONE","SHIELD LEFT","SHIELD MIDDLE","SHIELD RIGHT","MOVE LEFT","MOVE MIDDLE","MOVE RIGHT","CAPACITY +2","REMOVE LEFT","REMOVE MIDDLE","REMOVE RIGHT") class App: def __init__(self): self.reset() def reset(self): self.state=new("A",blocking=True) self.plan=Plan() self.house=0 self.choice=0 self.confirm=False self.gated=False self.message='READY' def press(self,key): if key=='R':self.reset();return if self.confirm: if key=='X':self.confirm=False;self.message='DRAFT KEPT';return if key=='RETURN': self.state=step(self.state,self.plan);self.plan=Plan() self.confirm=False;self.message='SEASON COMMITTED' return if self.state.season>=4:return if key in ('LEFT','RIGHT'): self.choice=(self.choice+(-1 if key=='LEFT' else 1))%len(BUILDS) if key=='Z': try: f=forecast(self.state,BUILDS[self.choice]) self.plan=Plan(BUILDS[self.choice],tuple(min(h,n) for h,n in zip(self.plan.heat,f['needs']))) self.message='FREE DRAFT / NOTHING CHARGED' except ValueError:self.message='CONSTRUCTION REFUSED' if key=='TAB':self.house=(self.house+1)%3 if key in ('UP','DOWN'): f=forecast(self.state,self.plan.build);heat=list(self.plan.heat) heat[self.house]=max(0,min(f['needs'][self.house],heat[self.house]+(1 if key=='UP' else -1))) self.plan=Plan(self.plan.build,tuple(heat));self.message='FREE DRAFT' if key=='RETURN': try:predict(self.state,self.plan);self.confirm=True;self.message='PREVIEW / ENTER AGAIN TO COMMIT' except ValueError:self.message='NOT ENOUGH ENERGY / LOWER HEAT' def control(self,pressed,held): 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 self.press(next(iter(pressed)));self.gated=True def draw(self,p): s=self.state;p.cls(1) p.text(12,12,'WINDMILL VILLAGE / STAGE '+str(STAGE)+' / A',10) p.text(12,29,'FICTIONAL TOY / NOT REAL HEATING OR GENERATOR DESIGN',6) p.text(12,51,'SEASON '+str(s.season)+' / 4 '+(SEASON_LABELS[s.season] if s.season<4 else 'FINISHED'),7) p.text(12,68,'BATTERY '+str(s.battery)+' / CAPACITY '+str(s.capacity),11) for i,w in enumerate(WEATHER['A']): x=12+i*145;p.rectb(x,88,138,36,10 if i==s.season else 5) p.text(x+6,95,SEASON_LABELS[i],7) p.text(x+6,110,'WIND '+str(w[0])+' / '+('W -> E' if w[1]==-1 else 'E -> W'),6) p.text(12,139,'MATERIALS '+str(s.materials)+' / SHELTER SEASONS '+str(s.shelter),6) if s.season<4: f=forecast(s,self.plan.build) p.text(12,157,'POWER '+str(f['power'])+' / AVAILABLE '+str(f['available'])+' / SPILL '+str(f['spill'])+' / DRAFT COST '+str(f['cost']),11) for i in range(3): x=12+i*193;p.rectb(x,187,181,65,10 if i==self.house else 5) p.text(x+7,196,('LEFT','MIDDLE','RIGHT')[i]+' HOUSE',7) p.text(x+7,213,'HEAT '+str(self.plan.heat[i])+' / NEED '+str(f['needs'][i]),11) p.text(x+7,230,'COMFORT '+str(s.comfort[i])+' / DRAFT SHIELD '+str(int(f['shields'][i])),6) try: prediction=predict(s,self.plan) p.text(12,268,'NEXT COMFORT '+str(prediction['comfort'])+' / BATTERY '+str(prediction['remaining']),10) except ValueError:p.text(12,268,'HEAT TOTAL EXCEEDS AVAILABLE ENERGY',8) p.text(12,296,'SELECT '+BUILD_LABELS[self.choice]+' / DRAFT '+BUILD_LABELS[BUILDS.index(self.plan.build)],7) p.text(12,314,'DRAFT MILL COLUMN '+str(f['mill'])+' / UPSTREAM SHIELDS '+str(f['blocked']),6) else: p.text(12,188,'FINAL COMFORT '+str(s.comfort)+' / '+('SUCCESS' if success(s) else 'TRY ANOTHER PLAN'),10) p.text(12,213,'SHELTER IS NOT PERMANENT FAILURE; ONLY FINAL COMFORT MATTERS.',7) p.text(12,342,'SHIELDS '+('DO NOT BLOCK WIND IN THIS SIMPLIFIED STAGE' if STAGE==3 else 'BLOCK UPSTREAM WIND' if STAGE>=4 else 'NOT INTRODUCED'),14) p.text(12,359,'SEASONS FINISHED / R TO RESTART' if s.season>=4 else 'PREVIEW: ENTER TO COMMIT / X TO CANCEL' if self.confirm else 'EDITING: NO RESOURCE HAS BEEN CHARGED',10) p.text(12,403,'TAB: HOUSE / UP/DOWN: HEAT / ENTER: PREVIEW, THEN COMMIT',7) p.text(12,420,'LEFT/RIGHT: BUILD CHOICE / Z: PUT IT IN DRAFT / R: RESET',7) p.text(12,437,'R: RESET / ESC: QUIT',7) p.text(12,382,self.message,10) def demo_keys(): return ('RIGHT','RIGHT','RIGHT','Z','UP','TAB','UP','RETURN','RETURN') def main(headless=False,screenshot=None,demo=False): import pyxel as p p.init(600,464,title='Windmill village lesson '+str(STAGE),fps=30,display_scale=2,headless=headless) app=App();sequence=demo_keys() if demo else () keys={'LEFT':p.KEY_LEFT,'RIGHT':p.KEY_RIGHT,'UP':p.KEY_UP,'DOWN':p.KEY_DOWN,'TAB':p.KEY_TAB,'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