# 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 START_HEAT = 2 START_WOOD = 1 START_DOUGH = 2 START_BREAD = 2 ORDERS = (("bread", 2, 1, 5), ("rusk", 2, 2, 8)) @dataclass(frozen=True) class State: t: int = 0 heat: int = START_HEAT wood: int = START_WOOD dough: int = START_DOUGH bread: int = START_BREAD rusk: int = 0 remaining: tuple = tuple(o[1] for o in ORDERS) outcome: str = "playing" def apply(state, action): # 検証中は元の不変Stateを一切書き換えない。 if state.outcome != "playing": return state, "FINISHED / R TO RESTART" if not isinstance(action, tuple) or not action: return state, "INVALID ACTION" kind = action[0] candidate = state if kind in ("bake", "rusk", "sell"): count = action[-1] if type(count) is not int or count not in (1, 2): return state, "CHOOSE 1 OR 2" if kind == "bake" and len(action) == 2: if state.heat < 3: return state, "BREAD NEEDS HEAT 3-4" if state.dough < count: return state, "NOT ENOUGH DOUGH" candidate = replace(state, dough=state.dough-count, bread=state.bread+count) elif kind == "rusk" and len(action) == 2: if not 1 <= state.heat <= 2: return state, "RUSK NEEDS HEAT 1-2" if state.bread < count: return state, "NOT ENOUGH BREAD" candidate = replace(state, bread=state.bread-count, rusk=state.rusk+count) elif kind == "sell" and len(action) == 3 and type(action[1]) is int and 0 <= action[1] < len(ORDERS): index = action[1] product, required, start, end = ORDERS[index] # 受付判定は操作前の時刻。開始・終了をともに含む。 if not start <= state.t <= end: return state, "OUTSIDE ORDER WINDOW" if state.remaining[index] < count: return state, "TOO MANY FOR ORDER" if getattr(state, product) < count: return state, "NOT ENOUGH STOCK" remaining = list(state.remaining) remaining[index] -= count candidate = replace(state, remaining=tuple(remaining), **{product: getattr(state, product)-count}) elif kind == "fire" and len(action) == 1: if not state.wood: return state, "NO WOOD / TIME UNCHANGED" candidate = replace(state, wood=state.wood-1) elif kind == "wait" and len(action) == 1: pass else: return state, "INVALID ACTION" # 在庫の候補ができた後にだけ、時計と熱を確定する。 candidate = replace(candidate, t=state.t+1, heat=4 if kind == "fire" else max(0, state.heat-1)) complete = all(n == 0 for n in candidate.remaining) missed = any(n and candidate.t > order[3] for order, n in zip(ORDERS, candidate.remaining)) outcome = "won" if complete else "lost" if missed else "playing" candidate = replace(candidate, outcome=outcome) return candidate, kind.upper()+" / TURN COMMITTED" CHOICES = (('WAIT', ('wait',)), ('FIRE', ('fire',)), ('BAKE', ('bake',)), ('RUSK', ('rusk',)), ('SELL BREAD', ('sell', 0)), ('SELL RUSK', ('sell', 1))) class App: def __init__(self): self.reset() def reset(self): self.state = State() self.selected = 0 self.count = 1 self.message = "UP/DOWN CHOOSE, SPACE EXECUTE" self.locked = False def control(self, pressed, held): # リセットや操作の後は全キー解放を待ち、持越し連打を防ぐ。 if self.locked: if not held: self.locked = False return if "R" in pressed: self.reset() self.locked = True return if self.state.outcome != "playing": return if len(pressed) != 1: return key = next(iter(pressed)) if key == "UP": self.selected = (self.selected-1) % len(CHOICES) elif key == "DOWN": self.selected = (self.selected+1) % len(CHOICES) elif key in ("LEFT", "RIGHT"): self.count = 1 if key == "LEFT" else 2 elif key == "SPACE": action = CHOICES[self.selected][1] if action[0] in ("bake", "rusk", "sell"): action += (self.count,) self.state, self.message = apply(self.state, action) else: return self.locked = True def draw(self, p): p.cls(1) p.text(8, 8, "LATE OVEN / STAGE 4", 10) p.text(8, 22, "TURN "+str(self.state.t)+" HEAT "+str(self.state.heat)+" WOOD "+str(self.state.wood), 7) for h in range(4): p.rect(8+h*24, 34, 20, 12, 9 if h < self.state.heat else 5) p.text(8, 54, "DOUGH "+str(self.state.dough)+" BREAD "+str(self.state.bread), 7) p.text(150, 54, "RUSK "+str(self.state.rusk), 7) for i, (product, required, start, end) in enumerate(ORDERS): p.text(8, 68+i*10, product.upper()+" LEFT "+str(self.state.remaining[i])+" / ACCEPT T"+str(start)+".."+str(end), 11) p.text(8, 90, self.state.outcome.upper(), 10 if self.state.outcome=="won" else 8 if self.state.outcome=="lost" else 7) for i, (label, action) in enumerate(CHOICES): p.text(12, 108+i*11, ("> " if self.selected==i else " ")+label, 10 if self.selected==i else 6) p.text(150, 108, "QUANTITY "+str(self.count), 7) p.text(150, 120, "LEFT 1 / RIGHT 2", 6) p.text(8, 184, self.message, 10) p.text(8, 199, "UP/DOWN MENU SPACE EXECUTE", 6) p.text(8, 210, "R RESET ESC QUIT", 6) DEMO_KEYS = ('DOWN', 'DOWN', 'DOWN', 'RIGHT', 'SPACE', 'DOWN', 'DOWN', 'DOWN', 'DOWN', 'SPACE', 'DOWN', 'SPACE', 'DOWN', 'DOWN', 'SPACE', 'DOWN', 'SPACE') def main(headless=False, screenshot=None, demo=False): from pathlib import Path import pyxel as p # Resolve before Pyxel may change its working directory. screenshot = str(Path(screenshot).resolve()) if screenshot else None if screenshot: Path(screenshot).parent.mkdir(parents=True, exist_ok=True) app = App() p.init(256, 226, title="Late Oven", fps=30, display_scale=3, headless=headless) keys = {name: getattr(p, "KEY_"+name) for name in ("UP", "DOWN", "LEFT", "RIGHT", "SPACE", "R", "Z")} limit = max(12, 2*len(DEMO_KEYS)+3) if demo else 12 def update(): if headless and p.frame_count >= limit: if screenshot: p.screenshot(screenshot, scale=3) p.quit() return if headless and demo: index = p.frame_count//2 pressed = {DEMO_KEYS[index]} if p.frame_count%2==0 and index