# 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と図形だけを使います。 """ふたりで一つの傘・教材 1/5。単独起動できる30Hzのプログラム。""" FPS = 30 def clamp(value, low, high): """値をlow以上high以下に収める。""" return max(low, min(high, value)) class Model: """ゲーム内の座標。描画用の2倍拡大とは分けて考える。""" def __init__(self): self.c = 64.0 # ふたりの中心 self.d = 20.0 # ふたりの間隔 @property def feet(self): return self.c - self.d / 2, self.c + self.d / 2 def tick(self, move=0, spread=0): width = 128 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) def snapshot(self): return self.c, self.d 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 move = int('RIGHT' in held) - int('LEFT' in held) spread = int('DOWN' in held) - int('UP' in held) self.model.tick(move, spread) KEYS = ('LEFT', 'RIGHT', 'UP', 'DOWN', 'P', 'R' ) 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 1', 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) pyxel.text(288, 53, 'DRY PRACTICE', 11) pyxel.text(288, 67, 'NO TIMER', 6) pyxel.line(16, 171, 272, 171, 9) pyxel.line(screen_x(m.c), 174, screen_x(m.c), 186, 7) 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) pyxel.text(10, 194, f'FEET {m.feet[0]:.1f} / {m.feet[1]:.1f}', 7) pyxel.text(10, 207, 'FEET = CENTER -/+ DISTANCE / 2', 11) pyxel.text(10, 227, 'PRACTICE: WATCH THE NUMBERS AS YOU HOLD ARROWS.', 6) pyxel.text(10, 245, 'LEFT/RIGHT: CENTER UP/DOWN: CLOSE/SPREAD', 7) pyxel.text(10, 257, 'BODY RADIUS 3 / DISTANCE RANGE 12 TO 42', 6) 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 1', 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)