# 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. # This teaching edition contains no Noto-derived glyph data. """反射便・教材 2/5。単独で起動できる学習用ファイル。 実行: python 02_velocity.py 詳しい数式と段階ごとの差分は LESSON.md を参照。 """ from dataclasses import dataclass, field import math FPS = 30 DT = 1 / 120 SPEED = 96.0 # px / second RADIUS = 2.0 # envelope collision radius EPS = 1e-8 INITIAL_ANGLE = -45.0 SOLUTION_ANGLE = -50.75 @dataclass(frozen=True) class Wall: box: tuple mirror: bool = False name: str = "wall" @dataclass(frozen=True) class Level: start: tuple target: tuple radius: float walls: tuple # 原作の2便目「塀の向こう」。壁の順序も原作と同じ。 LEVEL = Level((32, 96), (160, 96), 6, ( Wall((0, 0, 192, 16), True, "top"), Wall((176, 16, 192, 128), False, "right"), Wall((0, 116, 176, 128)), Wall((0, 16, 16, 116)), Wall((92, 64, 100, 116)), )) @dataclass class Flight: pos: tuple vel: tuple = (0.0, 0.0) status: str = "flying" bounces: int = 0 age: float = 0.0 trace: list = field(default_factory=list) used: str = "none" def __post_init__(self): if not self.trace: self.trace = [self.pos] def launch(level, angle): """angle はラジアン。画面では右が0、下向きが正。""" if not math.isfinite(angle): raise ValueError("angle must be finite") return Flight(level.start, (SPEED * math.cos(angle), SPEED * math.sin(angle))) def step(level, f, dt=DT): if dt < 0 or not math.isfinite(dt): raise ValueError("invalid dt") if f.status != "flying": return f f.age += dt f.pos = (f.pos[0] + f.vel[0] * dt, f.pos[1] + f.vel[1] * dt) if f.status == "flying" and (not 0 <= f.pos[0] <= 192 or not 0 <= f.pos[1] <= 128): f.status = "outside" if f.status == "flying" and f.age >= 6: f.status = "outside" # 原作も時間切れと場外は同じ状態 return f class World: def __init__(self, level=LEVEL): self.level = level self.reset() def reset(self): self.angle = INITIAL_ANGLE self.shot_angle = None self.flight = None self.paused = False def fire(self): if self.flight is not None: return False self.shot_angle = self.angle self.flight = launch(self.level, math.radians(self.angle)) return True def control(self, pressed=(), held=()): # R と Space が同時でも初期化だけ。押下と長押しを分ける。 if "R" in pressed: self.reset() return if self.flight is None: self.angle += 2 * (("RIGHT" in held) - ("LEFT" in held)) self.angle += 0.25 * (("DOWN" in held) - ("UP" in held)) self.angle = (self.angle + 180) % 360 - 180 if "SPACE" in pressed: self.fire() if self.flight is not None: for _ in range(4): step(self.level, self.flight, DT) def checkpoint(self): """QA専用の自動入力・モデル進行。人のプレイ記録ではない。""" self.angle = SOLUTION_ANGLE self.fire() for _ in range(900): if self.flight.status != "flying": return self.control() raise AssertionError("checkpoint did not stop") STAGE = 2 STAGE_TITLE = 'VELOCITY' NOTE = 'WALLS ARE VISUAL ONLY IN THIS STEP.' MIRROR_LABEL = 'MIRROR: NOT ACTIVE YET' class App: def __init__(self, engine, headless=False, screenshot=None, checkpoint=False): self.pyxel = engine self.world = World() self.headless = headless self.screenshot = screenshot self.draws = 0 if checkpoint: self.world.checkpoint() def update(self): p = self.pyxel # 12回描画した画面を保存。ゲームクリア検証とは別の起動検証。 if self.headless and self.draws >= 12: if self.screenshot: p.screenshot(self.screenshot, scale=2) p.quit() return pressed = {key for key in ("R", "SPACE", "P") if p.btnp(getattr(p, "KEY_" + key))} held = {key for key in ("LEFT", "RIGHT", "UP", "DOWN") if p.btn(getattr(p, "KEY_" + key))} self.world.control(pressed, held) def draw(self): p, w = self.pyxel, self.world f = w.flight p.cls(1) p.rect(32, 32, 320, 200, 3) for x in range(40, 350, 16): for y in range(42, 230, 16): p.pset(x, y, 5) for wall in w.level.walls: x, y, xx, yy = wall.box p.rect(x * 2, y * 2, (xx - x) * 2, (yy - y) * 2, 13 if wall.mirror else 4) if wall.mirror: p.rect(x * 2, yy * 2 - 4, (xx - x) * 2, 4, 7) for a in range(int(x * 2), int(xx * 2), 12): p.line(a, yy * 2 - 10, a + 5, yy * 2 - 5, 6) else: for b in range(int(y * 2) + 4, int(yy * 2), 10): p.line(x * 2, b, xx * 2 - 1, b, 9) tx, ty = (value * 2 for value in w.level.target) p.rect(tx - 14, ty - 14, 29, 29, 8) p.circ(tx, ty, w.level.radius * 2 + 2, 7) p.circ(tx, ty, w.level.radius * 2, 0) p.text(tx - 12, ty - 26, "TARGET", 7) p.text(42, 39, MIRROR_LABEL, 6) sx, sy = (value * 2 for value in w.level.start) angle = math.radians(w.angle if w.shot_angle is None else w.shot_angle) ex, ey = sx + 28 * math.cos(angle), sy + 28 * math.sin(angle) p.circ(sx, sy, 9, 5) p.line(sx, sy, ex, ey, 10) for sign in (-1, 1): p.line(ex, ey, ex - 8 * math.cos(angle + sign * 0.45), ey - 8 * math.sin(angle + sign * 0.45), 10) p.text(sx - 10, sy + 17, "START", 6) if f: points = f.trace + [f.pos] for a, b in zip(points, points[1:]): p.line(a[0] * 2, a[1] * 2, b[0] * 2, b[1] * 2, 6) for x, y in f.trace[1:]: p.circb(x * 2, y * 2, 4, 10) if f.status == "delivered": p.circb(tx, ty, 19, 10) p.line(tx - 6, ty, tx - 1, ty + 5, 10) p.line(tx - 1, ty + 5, tx + 8, ty - 6, 10) elif f.status in ("wall", "twice", "outside"): x, y = f.pos[0] * 2, f.pos[1] * 2 p.line(x - 5, y - 5, x + 5, y + 5, 8) p.line(x - 5, y + 5, x + 5, y - 5, 8) px, py = (f.pos if f and f.status in ("flying", "aimed") else w.level.start) p.rect(px * 2 - 5, py * 2 - 3, 11, 7, 10) p.rectb(px * 2 - 5, py * 2 - 3, 11, 7, 7) p.line(px * 2 - 5, py * 2 - 3, px * 2, py * 2 + 1, 9) p.line(px * 2 + 5, py * 2 - 3, px * 2, py * 2 + 1, 9) p.rect(0, 0, 384, 20, 1) p.text(12, 7, f"REFLECTION MAIL / {STAGE}/5 / {STAGE_TITLE}", 7) p.rect(0, 256, 384, 104, 1) status = "AIM: ONE SHOT PER TRY" if f is None else { "aimed": "LOCKED: DIRECTION SAVED", "flying": "FLYING", "delivered": "DELIVERED!", "wall": "STOP: WALL", "twice": "STOP: SECOND MIRROR", "outside": "STOP: OUTSIDE / 6 SECONDS", }[f.status] if w.paused: status = "PAUSED" p.text(12, 267, status, 10) p.text(12, 282, f"ANGLE {w.angle:+07.2f} TIME {f.age if f else 0:04.2f}s BOUNCES {f.bounces if f else 0}/1", 7) p.text(12, 296, NOTE, 6) p.text(12, 314, "LEFT/RIGHT: AIM UP/DOWN: FINE SPACE: SEND", 7) p.text(12, 328, "R: RETRY ESC: QUIT" + (" P: PAUSE" if STAGE == 5 else ""), 6) p.text(12, 345, "MODEL: PIXELS / SECONDS / ONE DELIVERY", 5) self.draws += 1 def main(headless=False, screenshot=None, checkpoint=False): import pyxel pyxel.init(384, 360, title=f"Reflection Mail - Stage {STAGE}", fps=FPS, headless=headless) app = App(pyxel, headless, screenshot, checkpoint) pyxel.run(app.update, app.draw) if __name__ == "__main__": import argparse parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--headless-smoke", action="store_true", help="render 12 frames and quit") parser.add_argument("--screenshot", help="PNG path, requires --headless-smoke") parser.add_argument("--demo-checkpoint", action="store_true", help="synthetic model outcome for QA, not human play") args = parser.parse_args() if args.screenshot and not args.headless_smoke: parser.error("--screenshot requires --headless-smoke") main(args.headless_smoke, args.screenshot, args.demo_checkpoint)