# 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由来の字形を含めません。上記は原作の通知をそのまま保持。 """ころがる切手・教材4/5。原作Aの一直線を段階化した独立版。 学習順と数式の前提はLESSON.md。画面はASCII、外部画像・音・字体なし。 """ from math import ceil, exp, hypot FPS = 60 DT = 1 / FPS # 1更新に進むゲーム内秒数。壁時計の実時間ではない。 TILE = 16 RADIUS = 3 START = (24.0, 72.0) GOAL = (152.0, 72.0) STEP_SPEED = 30 # 01専用: px/s ACCEL = 80 # px/s^2 MAX_SPEED = 45 # px/s PAPER_DRAG = 3.0 # 1/s PARALLEL_DRAG = 0.6 CROSS_DRAG = 6.0 MAX_SUBSTEP = 0.75 # px GOAL_HALF = 4 # 中心の許容差、px GOAL_SPEED = 8 # 必ず「未満」 HOLD_FRAMES = 18 def make_floor(): """原作Aの9タイル。03以降は中央5枚を横の折り目にする。""" return {(tx, 4): "h" if 3 <= tx <= 7 else "paper" for tx in range(1, 10)} def clamp(value, low, high): return max(low, min(high, value)) class World: def __init__(self): self.x, self.y = START self.vx = self.vy = 0.0 self.floor = make_floor() self.ticks = 0 self.paused = False self.won = False self.hold = 0 self.contacts = 0 self.wall_touch = False self.input_x = self.input_y = 0 @property def speed(self): # 直角三角形: 速さ^2 = vx^2 + vy^2。hypotはその平方根。 return hypot(self.vx, self.vy) @property def seconds(self): return self.ticks * DT @property def kind(self): return self.floor.get((int(self.x // TILE), int(self.y // TILE)), "paper") def damping(self): # 横折り目はxが平行・yが垂直。vは発展実験用の縦折り目。 if self.kind == "h": return PARALLEL_DRAG, CROSS_DRAG if self.kind == "v": return CROSS_DRAG, PARALLEL_DRAG return PAPER_DRAG, PAPER_DRAG def accelerate(self, ax, ay): # (1,1)の長さはsqrt(2)。長さで割れば斜めでも加速度の強さは1。 length = hypot(ax, ay) if length > 1: ax /= length ay /= length kx, ky = self.damping() # expは自然対数の底eのべき乗。r=exp(-k*DT)は1更新後に残る速度の割合。 # k[1/s]*DT[s]は単位なし。k=3、60Hzならrは約0.9512。 # dv/dt = a-k*v の一定a,kでの解: v_new=v*r+(a/k)*(1-r)。 # a/kの単位はpx/s。入力0なら古い速度にrを掛けるだけ。 # 位置は解析解ではなく更新後の速度*DT。ここも原作に合わせる。 rx = exp(-kx * DT) ry = exp(-ky * DT) self.vx = self.vx * rx + ACCEL * ax / kx * (1 - rx) self.vy = self.vy * ry + ACCEL * ay / ky * (1 - ry) # 大きさだけ上限へ縮める。各軸を別々に切ると斜めが速くなる。 speed = self.speed if speed > MAX_SPEED: self.vx *= MAX_SPEED / speed self.vy *= MAX_SPEED / speed def wall(self, x, y): # 円を囲むタイルだけを走査。floorにないタイルは壁。 for ty in range(int((y - RADIUS) // TILE), int((y + RADIUS) // TILE) + 1): for tx in range(int((x - RADIUS) // TILE), int((x + RADIUS) // TILE) + 1): if (tx, ty) in self.floor: continue # 四角内で円の中心に最も近い点。平方距離で円との重なりを調べる。 qx = clamp(x, tx * TILE, (tx + 1) * TILE) qy = clamp(y, ty * TILE, (ty + 1) * TILE) if (qx - x) ** 2 + (qy - y) ** 2 < RADIUS ** 2 - 1e-8: return True return False def move_with_walls(self): # 1回の移動距離はspeed*DT [px]。ceilで切り上げ0.75px以下に分割。 count = max(1, ceil(self.speed * DT / MAX_SUBSTEP)) touch = False for _ in range(count): nx = self.x + self.vx * DT / count if self.wall(nx, self.y): self.vx = 0.0 touch = True else: self.x = nx ny = self.y + self.vy * DT / count if self.wall(self.x, ny): self.vy = 0.0 touch = True else: self.y = ny # 非接触から接触へ変わった回数を数える。壁に沿う速度は残す。 if touch and not self.wall_touch: self.contacts += 1 self.wall_touch = touch def step(self, ax=0, ay=0): if self.paused or self.won: return self.ticks += 1 self.input_x, self.input_y = ax, ay self.accelerate(ax, ay) self.move_with_walls() STAGE = 4 TITLE = "CIRCLE / WALL" def status_lines(world): """表示も定数から作る。数値実験で判定とラベルを食い違わせない。""" lines = [f"TICK {world.ticks} TIME {world.seconds:.2f}s DT {DT:.5f}s", f"X {world.x:6.2f}px Y {world.y:5.2f}px", f"VX {world.vx:6.2f}px/s VY {world.vy:6.2f}px/s SPEED {world.speed:.2f}px/s"] kx, ky = world.damping() lines.append(f"FLOOR {world.kind} DRAG X {kx:g}/s Y {ky:g}/s") return lines def checkpoint_inputs(): """再現用の合成入力。人の試遊でもOSキーボード入力でもない。""" return [(1, -1)] * 120 class App: def __init__(self, pyxel, headless=False, screenshot=None, demo=False): self.p = pyxel self.world = World() self.headless = headless self.screenshot = screenshot self.demo = demo self.actions = checkpoint_inputs() if demo else [] self.render_frames = 0 # headlessは初期12更新。demoは合成入力列+結果表示12更新。 self.limit = len(self.actions) + 12 if demo else 12 pyxel.init(384, 280, title="Rolling Stamp / " + TITLE, fps=FPS, headless=headless, display_scale=2) pyxel.run(self.update, self.draw) def update(self): p = self.p if self.headless and self.render_frames >= self.limit: if self.screenshot: from pathlib import Path path = Path(self.screenshot) path.parent.mkdir(parents=True, exist_ok=True) p.screenshot(str(path), scale=2) # snapshotは最後に描いた状態と同じ。quit前に物理更新しない。 import json w = self.world print(json.dumps({"stage": STAGE, "synthetic_input": self.demo, "render_frames": self.render_frames, "ticks": w.ticks, "x": w.x, "y": w.y, "vx": w.vx, "vy": w.vy, "won": w.won, "hold": w.hold, "contacts": w.contacts}), flush=True) p.quit() return self.render_frames += 1 if p.btnp(p.KEY_R): self.world = World() return if p.btnp(p.KEY_P): self.world.paused = not self.world.paused return if self.demo: index = self.render_frames - 1 ax, ay = self.actions[index] if index < len(self.actions) else (0, 0) else: ax = int(p.btn(p.KEY_RIGHT) or p.btn(p.KEY_D)) - int(p.btn(p.KEY_LEFT) or p.btn(p.KEY_A)) ay = int(p.btn(p.KEY_DOWN) or p.btn(p.KEY_S)) - int(p.btn(p.KEY_UP) or p.btn(p.KEY_W)) self.world.step(ax, ay) def draw(self): p, w = self.p, self.world p.cls(1) p.text(8, 8, f"007 ROLLING STAMP / {STAGE}: {TITLE}", 10) for i, line in enumerate(status_lines(w)): p.text(8, 26 + 12 * i, line, 7 if i < 3 else 6) p.rect(16, 84, 352, 92, 5) p.text(24, 90, "A: STRAIGHT ROAD (DRAWN 2x / UNITS ARE LOGICAL PX)", 7) ox, oy, zoom = 16, -10, 2 for (tx, ty), kind in w.floor.items(): x, y = ox + zoom * tx * TILE, oy + zoom * ty * TILE p.rect(x, y, zoom * TILE, zoom * TILE, 15) if kind == "h": for dy in (4, 8, 12): p.line(x, y + dy * zoom, x + TILE * zoom - 1, y + dy * zoom, 6) if kind == "v": for dx in (4, 8, 12): p.line(x + dx * zoom, y, x + dx * zoom, y + TILE * zoom - 1, 6) gx, gy = ox + zoom * GOAL[0], oy + zoom * GOAL[1] p.rect(gx - 14, gy - 12, 28, 24, 11 if w.won else 10) p.rectb(gx - 14, gy - 12, 28, 24, 4) p.line(gx - 13, gy - 11, gx, gy, 4) p.line(gx + 13, gy - 11, gx, gy, 4) x, y = ox + zoom * w.x, oy + zoom * w.y p.line(x, y, x + w.vx * .7, y + w.vy * .7, 8) p.circ(x, y, RADIUS * zoom, 12) p.pset(x - 2, y - 2, 7) p.text(24, 158, "BLUE: STAMP RED: VELOCITY YELLOW: ENVELOPE", 7) p.text(16, 184, f"WALL CONTACTS {w.contacts} RADIUS {RADIUS:g}px STEP <= {MAX_SUBSTEP:g}px", 11) tips = [ "UP+RIGHT: hit the top, keep moving along it.", "Only the blocked velocity component becomes 0.", "Envelope is a marker; delivery comes in 05."] for i, line in enumerate(tips): p.text(16, 202 + 11 * i, line, 7) p.text(8, 250, "ARROWS/WASD: TILT P: PAUSE R: RESET ESC: QUIT", 6) if self.demo: p.text(8, 262, "AUTOMATED INPUT / ACTUAL PYXEL RENDER / NO AUDIO", 8) elif STAGE < 3: p.text(8, 262, "01/02 USE LEFT/RIGHT ONLY", 6) if w.paused: p.rect(100, 105, 182, 40, 0) p.text(122, 120, "PAUSED / P: RESUME", 10) if w.won: p.rect(92, 105, 198, 46, 0) p.rectb(92, 105, 198, 46, 11) p.text(143, 115, "DELIVERED!", 11) p.text(115, 132, "R: SAME START / SAME ROAD", 7) def main(): import argparse parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--headless-smoke", action="store_true", help="12 updates then quit") parser.add_argument("--screenshot", help="PNG path; use with --headless-smoke") parser.add_argument("--demo", action="store_true", help="synthetic checkpoint sequence then 12 frames") args = parser.parse_args() if args.screenshot and not args.headless_smoke: parser.error("--screenshot requires --headless-smoke") import pyxel # モデルのテスト時にはウィンドウもPyxel依存も不要。 App(pyxel, args.headless_smoke, args.screenshot, args.demo) if __name__ == "__main__": main()