# 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. # 教材にはNoto字形を含めず、ASCIIと図形だけを使います。 """ふたりで一つの傘・教材 5/5。単独起動できる30Hzのプログラム。""" from dataclasses import dataclass FPS = 30 def clamp(value, low, high): """値をlow以上high以下に収める。""" return max(low, min(high, value)) def covered(x, umbrella): """中心点でなく、半径3の身体全体が半幅18の傘に入るか。""" return x - 3 >= umbrella - 18 - 1e-8 and x + 3 <= umbrella + 18 + 1e-8 def approach(value, target, speed): """目標までの差を1フレームの最大移動量に制限する。""" return value + clamp(target - value, -speed, speed) @dataclass(frozen=True) class Band: name: str frames: int = 90 rain: str = 'none' puddle: int = 0 width: int = 128 # 原作Cの形・時間・雨・水たまり幅・道幅。名前だけASCIIに置換。 BANDS = ( Band('PREPARE'), Band('LEFT BRIDGE', 120, 'left', 28, 56), Band('RETURN', 60), Band('FULL RAIN', 120, 'full'), Band('SPREAD', 90), Band('RIGHT BRIDGE', 120, 'right', 28, 56), Band('HOME PATH'), ) class Model: """ゲーム内の座標。描画用の2倍拡大とは分けて考える。""" def __init__(self): self.c = 64.0 # ふたりの中心 self.d = 20.0 # ふたりの間隔 self.offset = 0.0 self.aim = 0 # -1:左、0:中央、1:右 self.bands = BANDS self.wet = [0.0, 0.0] self.i = 0 self.t = 0 self.total = 0 self.status = 'play' self.splashed = set() self.exposed = False self.clean = 0 self.alerted = False @property def feet(self): return self.c - self.d / 2, self.c + self.d / 2 @property def umbrella(self): return self.c + self.offset @property def band(self): return self.bands[min(self.i, len(self.bands) - 1)] def legal(self, width): return all(64 - width / 2 + 3 <= x <= 64 + width / 2 - 3 for x in self.feet) def rain_on(self, x): rain = self.band.rain return rain == 'full' or (rain == 'left' and x - 3 < 64) or (rain == 'right' and x + 3 > 64) def continue_wet(self): self.alerted = True self.status = 'play' def tick(self, move=0, spread=0, aim=None): if self.status not in ('play', 'blocked'): return if aim is not None: self.aim = clamp(aim, -1, 1) width = 128 if self.status == 'blocked' else self.band.width self.d = clamp(self.d + spread * 0.6, 12, 42) half = self.d / 2 + 3 self.c = clamp(self.c + move * 0.8, 64 - width / 2 + half, 64 + width / 2 - half) self.offset = approach(self.offset, self.aim * 12, 0.8) # 橋に入れない間は前の広い道にいる。雨・水たまり・時間を進めない。 if self.status == 'blocked': if self.legal(self.band.width): self.status = 'play' return b = self.band for person, x in enumerate(self.feet): if self.rain_on(x) and not covered(x, self.umbrella): self.exposed = True self.wet[person] = min(100, self.wet[person] + 0.24) if b.puddle and abs(x - 64) < b.puddle / 2 + 3 and person not in self.splashed: self.exposed = True self.wet[person] = min(100, self.wet[person] + 20) self.splashed.add(person) if max(self.wet) >= 100 and not self.alerted: self.status = 'wet' return self.t += 1 self.total += 1 if self.t >= b.frames: self.clean += int(not self.exposed) self.exposed = False self.i += 1 self.t = 0 self.splashed.clear() if self.i == len(self.bands): self.status = 'done' elif not self.legal(self.band.width): self.status = 'blocked' def snapshot(self): return self.c, self.d, self.offset, tuple(self.wet), self.i, self.t, self.total, self.status, self.clean class Controller: """heldは押し続けるキー、pressedはこのフレームに押し始めたキー。""" def __init__(self): self.model = Model() self.paused = False def tick(self, held=(), pressed=()): held, pressed = set(held), set(pressed) if 'R' in pressed: self.model = Model() self.paused = False return if 'P' in pressed: self.paused = not self.paused return if self.paused: return if self.model.status == 'wet': if 'SPACE' in pressed: self.model.continue_wet() return if self.model.status == 'done': return aim = self.model.aim if 'SPACE' in pressed: aim = {0: -1, -1: 1, 1: 0}[aim] move = int('RIGHT' in held) - int('LEFT' in held) spread = int('DOWN' in held) - int('UP' in held) self.model.tick(move, spread, aim) KEYS = ('LEFT', 'RIGHT', 'UP', 'DOWN', 'P', 'R' , 'SPACE') def main(headless=False, replay=None, screenshot=None, report=None): import json from pathlib import Path # Pyxel.initが作業ディレクトリを変えても保存先を変えない。 screenshot = str(Path(screenshot).resolve()) if screenshot else None report = str(Path(report).resolve()) if report else None import pyxel control = Controller() frames = 0 events = [] keys = {name: getattr(pyxel, 'KEY_' + name) for name in KEYS} duration = max(12, len(replay or [])) def update(): nonlocal frames frames += 1 if headless: # smokeは初期状態を描画するだけ。replayのみ合成キーを入力する。 if replay is not None and frames <= len(replay): event = replay[frames - 1] control.tick(event.get('held', []), event.get('pressed', [])) events.append({'frame': frames, 'input': event, 'state': control.model.snapshot()}) else: control.tick([name for name, key in keys.items() if pyxel.btn(key)], [name for name, key in keys.items() if pyxel.btnp(key)]) def screen_x(x): return 16 + x * 2 def draw(): m = control.model pyxel.cls(1) pyxel.text(10, 9, 'SHARED UMBRELLA / STEP 5', 10) pyxel.text(10, 22, f'CENTER {m.c:5.1f} DISTANCE {m.d:4.1f}', 7) pyxel.rect(16, 48, 256, 138, 4) pyxel.line(16, 48, 16, 186, 15) pyxel.line(272, 48, 272, 186, 15) b = m.band left = screen_x(64 - b.width / 2) right = screen_x(64 + b.width / 2) if b.width < 128: pyxel.rect(16, 48, left - 16, 138, 3) pyxel.rect(right, 48, 272 - right, 138, 3) for y in range(48, 185, 12): pyxel.rect(left - 2, y, 4, 6, 15) pyxel.rect(right - 2, y, 4, 6, 15) if b.puddle: puddle_left = screen_x(64 - b.puddle / 2) pyxel.rect(puddle_left, 48, b.puddle * 2, 138, 1) for y in range(54, 185, 12): pyxel.line(puddle_left + 3, y, puddle_left + b.puddle * 2 - 4, y, 12) if b.rain != 'none': rain_left = screen_x(64) if b.rain == 'right' else left rain_right = screen_x(64) if b.rain == 'left' else right for x in range(int(rain_left) + 4, int(rain_right), 12): for y in range(51, 182, 16): pyxel.line(x, y, x, y + 6, 6) nxt = m.bands[m.i + 1].name if m.i + 1 < len(m.bands) else 'HOME' pyxel.text(10, 35, f'NOW {b.name} / NEXT {nxt}', 6) # 入口停止中は橋の手前の乾いた広い道にいることを絵にも反映。 if m.status == 'blocked': pyxel.rect(17, 126, 255, 60, 4) pyxel.line(17, 126, left, 102, 15) pyxel.line(271, 126, right, 102, 15) progress = m.total / sum(band.frames for band in m.bands) pyxel.rect(288, 56, 126, 5, 5) pyxel.rect(288, 56, progress * 126, 5, 10) pyxel.text(288, 45, f'HOME {progress * 100:3.0f}%', 7) for person in range(2): pyxel.text(288, 80 + person * 32, f'WET {person + 1}: {m.wet[person]:5.2f}', 7) pyxel.rect(288, 90 + person * 32, 100, 4, 5) pyxel.rect(288, 90 + person * 32, m.wet[person], 4, 8) pyxel.text(288, 149, f'STATE {m.status.upper()}', 11) pyxel.text(288, 162, f'CLEAN {m.clean}/{len(m.bands)}', 6) pyxel.line(16, 171, 272, 171, 9) pyxel.line(screen_x(m.c), 174, screen_x(m.c), 186, 7) u = screen_x(m.umbrella) pyxel.rectb(u - 36, 137, 73, 37, 11) pyxel.line(u - 36, 137, u - 18, 125, 11) pyxel.line(u - 18, 125, u + 18, 125, 11) pyxel.line(u + 18, 125, u + 36, 137, 11) for person, x in enumerate(m.feet): sx = screen_x(x) color = 10 if person == 0 else 14 pyxel.circ(sx, 155, 6, color) pyxel.rect(sx - 6, 160, 12, 11, color) pyxel.text(sx - 2, 178, str(person + 1), color) inside = covered(x, m.umbrella) pyxel.text(288, 191 + person * 12, f'BODY {person + 1}: ' + ('IN' if inside else 'OUT'), 11 if inside else 8) if not inside: pyxel.text(sx - 2, 142, '!', 8) pyxel.text(10, 194, f'FEET {m.feet[0]:.1f} / {m.feet[1]:.1f}', 7) pyxel.text(10, 207, f'AIM {m.aim:+d} OFFSET {m.offset:+5.1f} / TARGET {m.aim * 12:+d}', 11) if m.status == 'blocked': pyxel.text(10, 227, 'SAFE STOP: MOVE BOTH BODIES INSIDE THE BRIDGE.', 10) elif m.status == 'wet': pyxel.text(10, 227, 'WET 100: SPACE TO CONTINUE, OR R TO RESTART.', 8) elif m.status == 'done': pyxel.text(10, 227, 'WELCOME HOME! GETTING WET IS NOT A GAME OVER.', 10) else: pyxel.text(10, 227, 'RAIN: UMBRELLA. PUDDLE: MOVE YOUR FEET.', 6) pyxel.text(10, 245, 'LEFT/RIGHT: CENTER UP/DOWN: CLOSE/SPREAD', 7) pyxel.text(10, 257, 'SPACE: CENTER -> LEFT -> RIGHT -> CENTER', 7) pyxel.text(10, 270, 'P: PAUSE R: RESET ESC: QUIT NO WIND / SILENT', 6) if control.paused: pyxel.rect(136, 91, 160, 24, 0) pyxel.text(152, 100, 'PAUSED - P TO RESUME', 10) if headless and frames >= duration: if screenshot: pyxel.screenshot(screenshot, scale=2) if report: Path(report).write_text(json.dumps({ 'label': 'Synthetic controller input; actual Pyxel headless renderer; silent', 'render_frames': frames, 'model_input_frames': len(events), 'events': events, 'final_state': m.snapshot(), 'paused': control.paused, }, ensure_ascii=False, indent=2) + '\n', encoding='utf-8') pyxel.quit() pyxel.init(432, 288, title='Shared Umbrella - Stage 5', fps=FPS, headless=headless) pyxel.run(update, draw) if __name__ == '__main__': import argparse import json from pathlib import Path parser = argparse.ArgumentParser(description=__doc__) modes = parser.add_mutually_exclusive_group() modes.add_argument('--headless-smoke', action='store_true', help='Render initial state for 12 frames') modes.add_argument('--headless-replay', help='UTF-8 JSON list of held/pressed input frames') parser.add_argument('--screenshot', help='Output PNG (headless only)') parser.add_argument('--report', help='Output UTF-8 JSON (headless only)') 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('screenshot/report require a headless mode') replay = None if args.headless_replay: replay = json.loads(Path(args.headless_replay).read_text(encoding='utf-8')) valid = isinstance(replay, list) and all( isinstance(e, dict) and set(e) <= {'held', 'pressed'} and all( isinstance(e.get(k, []), list) and all(isinstance(v, str) and v in KEYS for v in e.get(k, [])) for k in ('held', 'pressed')) for e in replay) if not valid: parser.error('Replay must be a list of held/pressed dictionaries using supported key names') main(headless, replay, args.screenshot, args.report)