アクションLESSON 61回転する影と庇を使う小さな移動パズル原作017

日陰の追いかけっこの作り方

日陰に入ったかは、体の中心だけでは決まらない。
時間とともに回る建物の影を渡り歩き、体全体を日差しから守りながら、落とし物を拾って出口を目指す小さな探索ゲームを作ります。日向にいた負担のゲージと、庇(ひさし)に入った時刻の記録も扱います。

完成版の遊び方動く影と庇(ひさし)に隠れながら日差しを避け、落とし物を拾って出口の庇まで進みます。

日陰の追いかけっこの元の完成版。自動操作によるPyxelの実描画画面。
元の完成版の画面です。この講座では、仕組みがわかりやすいように小さくした「教材版」を、5つのステップで作ります。
PLAY / 試験公開

完成サンプルをブラウザーで遊ぶ

この講座のもとになった完成版のゲームを、ブラウザーでそのまま遊べます。キーボードで操作するので、PCで開いてください(スマホのタッチ操作には対応していません)。初めて開くときは、読み込みに少し時間がかかります。

完成版の主な操作:方向歩く、Space+方向走る、Z庇へ、H予測、T庭選択、R再開、P停止

ボタンを押すと、ゲームの読み込みが始まります。

画面に「CLICK TO START」と出たらクリックします。続けてゲーム画面を一度クリックすると、キー操作が効くようになります。ゲームを終えるときは、枠の外にある「閉じる」ボタンを使ってください(Escキーの働きはゲームごとに違います)。ゲーム内のセーブは、閉じる・ページを移る・再読み込みすると消えます。

うまく表示されないとき

ゲームを動かすためのプログラム(Pyxel 2.9.9)は、外部の配信サービス(jsDelivr)から読み込みます。表示されないときは「最初から再読込」を押すか、完成版のソースをダウンロードしてPCで実行してください。ブラウザー版の録画機能は止めています。ブラウザー版で確認できている範囲は検証状況にまとめています。

同じPyxelアプリをダウンロード(.pyxapp) / 別のタブで開く

影の重なりの判定と、やり直す時刻の記録を、分けて管理する。

いきなり完成版のコードを全部読む必要はありません。小さなゲームにルールを1つずつ足しながら、5つのステップで作っていきます。どのステップのファイルもそれだけで動くので、動きを確かめてから次へ進みましょう。

1. 正規化(斜めでも同じ速さ)・秒単位の移動・円の体と壁の衝突
2. 影の形(凸包)・影の重なり(和集合)・覆われていない縁
3. 24秒で一周する影・小刻みごとのゲージ計算・いつでも安全な庇
それだけで動く小さなプログラムを、5つのステップで少しずつ大きくしていきます。
必要な環境
Python 3.11以上 / Pyxel 2.9.9
知っておくとよいこと
Python 3.11以上/Pyxel 2.9.9 / 変数・if・for・関数・タプル(tuple)・dataclass の基本(replace は本文で説明します) / ベクトルの長さと正規化(長さを1にそろえること)、sin・cos、外積 / 円と線分の距離、二次方程式(衝突を調べる補助関数の中身まで読む場合だけ)
教材に入っているもの
ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
完成した教材の操作
矢印:歩く(押しっぱなし) / SPACE+矢印:走る / P:停止/復帰(全段階) / R:同じ庭を初期化 / 1/2/3:庭を直接選択・初期化 / H:03以降、2秒先の影の輪郭 / Z:04以降、最後に入った庇へ時刻ごと戻る / N:05の成功後に次の庭 / Esc:終了
難易度の目安
中級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_movement.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

コードを書き換えたら、ファイルを保存し、動いているゲームを閉じてから、もう一度同じ.pyを実行します。ゲーム中にRキーなどで「やり直し」をしても、書き換えたコードは読み込まれません。

教材のゲーム画面は、日本語フォントを用意しなくても動くように英語で表示しています。ファイルには動作確認用のコードも入っていますが、最初は Game / State・update・draw の3つに注目すれば十分です。わからない言葉は用語集で調べられます。class・self・dataclass はPythonの状態、キー入力と時間の扱いは入力・更新・描画、マス目とピクセルの違いは画面とマップの座標のページで詳しく説明しています。

STEP 01 / 05

同じ速さで歩き、建物の縁を滑る

正規化(斜めでも同じ速さ)・秒単位の移動・円の体と壁の衝突。

まずは、影もゲージもない庭を歩けるようにします。矢印キーだけなら毎秒28px(歩き)、SPACE を押しながら方向キーなら毎秒46px(走り)で進みます。座標は右が +x、下が +y です。押している方向の長さ norm を math.hypot で求め、いったん長さ1にそろえてから「速さ speed × 経過秒数 dt」を掛けます。こうしないと、斜め (1,1) のときだけ √2 倍(約1.4倍)速く進んでしまいます。キーを離すとその場で止まり、勢いが残る「慣性」はありません。左右や上下を同時に押したときは、App.control で引き算して0(動かない)にします。

時間の進め方を決めておきます。step は1回で DT=1/120秒ぶんだけゲームを進める関数で、frame はその step を4回呼びます。画面は30 FPS(1秒に30回)で描くので、1回の更新でゲームの中の時間は通常1/30秒進みます。処理が重くなって遅れた分を、実際の時計に合わせて取り戻す作りではありません。P(一時停止)は「押した瞬間」を frame の中で1回だけ扱い、止めたその更新では時刻も位置も変えません。R は止まっている間でも、同じ庭を最初からやり直します。1/2/3 のキーで庭を切り替えられます。

プレイヤーの体は半径3の円として扱います。壁との当たり判定は、移動先の位置だけを見るのではなく、移動の途中で最初に壁に触れる瞬間を探します。sweep_circle は、移動する線(今の位置から移動先まで)が、長方形の各辺を半径3だけ外側へずらした線、または四隅を中心とする半径3の円に、どの割合 t(0が出発点、1が移動先)で最初に触れるかを求めます。角の円との接触は、二次方程式で求めます。move は一番早く触れた位置まで進み、残りの移動のうち壁に向かう成分(法線成分)だけを取り除いて、壁に沿って滑らせます。この接触の処理は最大4回まで繰り返し、触れた点では 1e-5px だけ押し戻して、めり込みを防ぎます。

長方形の角まで四角く広げて調べる簡単な方法(AABB近似)とは違い、角では丸く滑ります。ただし、どんな形の重なった地形でも、最初から壁にめり込んだ位置でも、何回ぶつかっても正しく解ける一般的な物理エンジンではありません。コードは initial → frame → step → move → sweep_circle の順に読むと流れを追えます。二次方程式の導き方は後回しにしても、動作の確認は進められます。画面の外枠では、体の中心を x=7〜185、y=23〜121 の範囲に収めます(clamp:範囲の外に出た値を端の値にそろえること)。

下のコードは 01_movement.py の一部です。前のステップから追加・変更したところだけを抜き出しています。

01_movement.py / 注目する部分
    norm=math.hypot(inp.x,inp.y);speed=46 if inp.run else 28
    dx,dy=((inp.x/norm*speed*dt,inp.y/norm*speed*dt) if norm else (0,0))

実行して確かめる

教材のZIPを展開したフォルダーで、次のコマンドを実行します。

Windows
.venv\Scripts\python.exe 01_movement.py
macOS / Linux
.venv/bin/python 01_movement.py
画面で見ること右へ1 step 進むと X=40+28/120=40.233333… になります。斜めに進んでも、進んだ距離は同じ 28/120 です。自動確認では、右へ走る(45更新)→ 上へ歩く(30更新)→ 待つ(15更新)の順に操作し、X=109、Y=60、T=3.000秒で止まります。
  • 斜めに進んでも、進む距離は同じ
  • 移動先ではなく、途中で最初に触れる点を求める
  • 壁に向かう成分(法線)を消し、壁に沿う成分(接線)を残す
つまずきやすいところspeed をそのまま座標に足さず、必ず dt を掛けます(掛けないと、1回の step で28pxも進んでしまいます)。また sweep_circle は、壁から離れる動きや、壁に沿う動きを衝突として扱いません。これらまで全部「t=0 で衝突」とみなすと、一度触れた壁から離れられず、張り付いてしまいます。
少し変えてみよう

step の中の「speed=46 if inp.run else 28」の 28 だけを 14 に変えてみましょう。最初の位置から右へ1秒歩くと、X は 68 ではなく 54 になります。SPACE を押した走りは 46 のままです。test_movement_exercise が、この変更版をメモリー上で実行して確かめています。確認したら 28 に戻します。

01_movement.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(322行)
01_movement.py / 全文
# 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.

"""日陰の追いかけっこ・教材 1/5。Python 3.11+ / Pyxel 2.9.9。"""
from dataclasses import dataclass, replace, asdict
import math, hashlib, json

DT=1/120
RADIUS=3.0
PERIOD=24.0
SHADOW_LENGTH=36.0
EPS=1e-7



@dataclass(frozen=True)
class Level:
    name:str
    buildings:tuple
    phase:float

LEVELS=(
    Level('一本の影',((64,44,80,64),),math.pi/4),
    Level('つながる渡り廊下',((52,44,68,64),(98,60,114,80)),7*math.pi/4),
    Level('近道のまぶしさ',((52,44,68,64),(112,40,128,60)),math.pi/4),
)


@dataclass(frozen=True)
class Input:
    x:int=0
    y:int=0
    run:bool=False
    pause:bool=False
    restart:bool=False




@dataclass(frozen=True)
class State:
    level:int
    x:float
    y:float
    time:float=0.0
    status:str="play"
    paused:bool=False


def initial(level=0):
    x,y=((40,88),(28,80),(28,80))[level]
    s=State(level,x,y)
    return s






def sweep_circle(p,d,rect,r=RADIUS):
    """Earliest rounded-rectangle collision. Tangencies while moving away skip."""
    x,y=p;dx,dy=d;x1,y1,x2,y2=rect;hits=[]
    for edge,lo,hi,axis,normal in ((x1-r,y1,y2,0,(-1,0)),(x2+r,y1,y2,0,(1,0)),(y1-r,x1,x2,1,(0,-1)),(y2+r,x1,x2,1,(0,1))):
        vel=dx if axis==0 else dy;pos=x if axis==0 else y
        if abs(vel)<EPS or dx*normal[0]+dy*normal[1]>=-EPS:continue
        t=(edge-pos)/vel;other=(y+dy*t) if axis==0 else (x+dx*t)
        if -EPS<=t<=1+EPS and lo-EPS<=other<=hi+EPS:hits.append((max(0,t),normal))
    aa=dx*dx+dy*dy
    if aa>EPS:
        for cx,cy,sx,sy in ((x1,y1,-1,-1),(x2,y1,1,-1),(x2,y2,1,1),(x1,y2,-1,1)):
            ox,oy=x-cx,y-cy;bb=2*(ox*dx+oy*dy);cc=ox*ox+oy*oy-r*r;disc=bb*bb-4*aa*cc
            if disc<-EPS:continue
            t=(-bb-math.sqrt(max(0,disc)))/(2*aa)
            if -EPS<=t<=1+EPS:
                hx,hy=x+dx*t-cx,y+dy*t-cy
                if hx*sx>=-EPS and hy*sy>=-EPS and hx*dx+hy*dy<-EPS:hits.append((max(0,t),(hx/r,hy/r)))
    return min(hits,key=lambda h:h[0]) if hits else None

def move(s,dx,dy):
    p=(s.x,s.y);d=(dx,dy)
    for _ in range(4):
        hits=[hit for box in LEVELS[s.level].buildings if (hit:=sweep_circle(p,d,box))]
        if not hits:p=(p[0]+d[0],p[1]+d[1]);break
        t,n=min(hits,key=lambda h:h[0]);p=(p[0]+d[0]*t+n[0]*1e-5,p[1]+d[1]*t+n[1]*1e-5)
        rem=(d[0]*(1-t),d[1]*(1-t));dot=rem[0]*n[0]+rem[1]*n[1]
        d=(rem[0]-min(0,dot)*n[0],rem[1]-min(0,dot)*n[1])
        if math.hypot(*d)<EPS:break
    return max(7,min(185,p[0])),max(23,min(121,p[1]))



def step(s,inp=Input(),dt=DT):
    if dt<0 or not math.isfinite(dt):raise ValueError('dt must be finite and nonnegative')
    if inp.restart:return initial(s.level)
    if inp.pause:return replace(s,paused=not s.paused)
    if s.paused:return s
    if s.status!='play' or dt==0:return s
    norm=math.hypot(inp.x,inp.y);speed=46 if inp.run else 28
    dx,dy=((inp.x/norm*speed*dt,inp.y/norm*speed*dt) if norm else (0,0))
    x,y=move(s,dx,dy)
    new=replace(s,x=x,y=y,time=s.time+dt)
    return new

def frame(s,inp=Input()):
    """An edge belongs to one render frame, never four physics substeps."""
    if inp.pause or inp.restart:return step(s,inp)
    for _ in range(4):s=step(s,inp)
    return s

def state_hash(s):
    return hashlib.sha256(json.dumps(asdict(s),sort_keys=True,separators=(',',':'),allow_nan=False).encode('utf-8')).hexdigest()

STAGE = 1
DEFAULT_LEVEL = 0
DEMO_FRAMES = 90
KEYS = ('UP','DOWN','LEFT','RIGHT','SPACE','P','R','Z','N','1','2','3','H')

class App:
    def __init__(self):
        self.level=DEFAULT_LEVEL
        self.reset()

    def reset(self):
        self.state=initial(self.level)
        self.locked=False
        self.forecast=False
        self.demo_tick=0
        self.waypoint=0

    def control(self, pressed, held):
        if 'R' in pressed:
            self.reset();self.locked=True;return
        for key in ('1','2','3'):
            if key in pressed:
                self.level=int(key)-1;self.reset();self.locked=True;return
        if 'P' in pressed:
            self.state=frame(self.state,Input(pause=True));return
        if self.state.paused:return
        if STAGE>=4 and 'Z' in pressed:
            self.state=frame(self.state,Input(checkpoint=True));self.locked=True;return
        if STAGE>=3 and 'H' in pressed:
            self.forecast=not self.forecast;return
        if self.locked:
            if not held.intersection(('UP','DOWN','LEFT','RIGHT','SPACE')):self.locked=False
            return
        if self.state.status=='success':
            if 'N' in pressed:
                self.level=(self.level+1)%3;self.reset();self.locked=True
            return
        u=Input(int('RIGHT' in held)-int('LEFT' in held),
                int('DOWN' in held)-int('UP' in held),'SPACE' in held)
        self.state=frame(self.state,u)

    def demo_input(self):
        tick=self.demo_tick;self.demo_tick+=1
        if STAGE==1:
            return Input(1,0,True) if tick<45 else Input(0,-1) if tick<75 else Input()
        if STAGE==2:
            dx,dy=90-self.state.x,75-self.state.y
            if math.hypot(dx,dy)<1:return Input()
            return Input(0 if abs(dx)<.55 else 1 if dx>0 else -1,0 if abs(dy)<.55 else 1 if dy>0 else -1,True)
        if STAGE==3:return Input(1,0,True)
        if STAGE==4:
            if tick==190:return Input(checkpoint=True)
            if tick>=190:return Input()
            route=((28,104),(84,104))
        else:
            if tick<105:return Input()
            route=((86,72),(86,64),(88,86),(138,86),(138,80),(164,64))
        s=self.state
        while self.waypoint<len(route) and math.dist((s.x,s.y),route[self.waypoint])<1:self.waypoint+=1
        if self.waypoint==len(route):return Input()
        tx,ty=route[self.waypoint];dx,dy=tx-s.x,ty-s.y
        return Input(0 if abs(dx)<.55 else 1 if dx>0 else -1,
                     0 if abs(dy)<.55 else 1 if dy>0 else -1,True)

    def demo_update(self):
        # Semantic input to the real update path; not OS-keyboard evidence.
        u=self.demo_input();held=set()
        if u.x:held.add('RIGHT' if u.x>0 else 'LEFT')
        if u.y:held.add('DOWN' if u.y>0 else 'UP')
        if u.run:held.add('SPACE')
        pressed={'Z'} if getattr(u,'checkpoint',False) else set()
        self.control(pressed,held)

    def draw(self,p):
        s=self.state;spec=LEVELS[self.level]
        p.cls(1)
        p.text(12,10,'017 MOVING SHADOWS / STAGE '+str(STAGE),10)
        p.text(12,26,('PAUSED' if s.paused else s.status.upper())+'  GARDEN '+str(self.level+1)+'  T '+format(s.time,'.3f')+' s',7)
        p.text(12,40,'X '+format(s.x,'.2f')+'  Y '+format(s.y,'.2f'),6)
        if STAGE>=3:
            p.text(226,26,'EXPOSURE '+format(s.exposure,'.2f'),10)
            p.rectb(330,24,102,9,7);p.rect(331,25,s.exposure,7,8)
            p.text(226,40,'SUN '+format(s.sun_seconds,'.3f')+' s',6)
        ox,oy=24,46
        def xy(x,y):return ox+x*2,oy+y*2
        def polygon(poly,color,outline=False,dotted=False):
            points=[xy(*v) for v in poly]
            if not outline:
                for j in range(1,len(points)-1):p.tri(*points[0],*points[j],*points[j+1],color)
            else:
                for a,b in zip(points,points[1:]+points[:1]):
                    if dotted:
                        n=max(1,int(math.dist(a,b)/5))
                        for j in range(0,n,2):
                            u=j/n;v=min(j+1,n)/n
                            p.line(a[0]+(b[0]-a[0])*u,a[1]+(b[1]-a[1])*u,a[0]+(b[0]-a[0])*v,a[1]+(b[1]-a[1])*v,color)
                    else:p.line(*a,*b,color)
        p.rect(ox+8,oy+40,368,208,15)
        p.clip(ox+8,oy+40,368,208)
        for x in range(8,188,16):
            for y in range(24,124,16):p.pset(*xy(x,y),9)
        if STAGE>=2:
            for poly in shadows(s.level,s.time,s.rotating):polygon(poly,1);polygon(poly,6,True)
            if STAGE>=3 and self.forecast:
                for poly in shadows(s.level,s.time+2,s.rotating):polygon(poly,13,True,True)
        if STAGE>=3:
            for i,(x,y,r) in enumerate(spec.awnings):
                x,y=xy(x,y);p.circ(x,y,r*2,3);p.circb(x,y,r*2,11);p.circb(x,y,r*2-3,11)
                p.text(x-12,y+26,'EXIT' if STAGE==5 and i==len(spec.awnings)-1 else 'A'+str(i),3)
                if STAGE==5 and i==len(spec.awnings)-1:
                    p.line(x+7,y-7,x+7,y+6,7);p.tri(x+8,y-7,x+17,y-4,x+8,y-1,10)
        for x1,y1,x2,y2 in spec.buildings:
            x,y=xy(x1,y1);p.rect(x,y,(x2-x1)*2,(y2-y1)*2,4);p.rectb(x,y,(x2-x1)*2,(y2-y1)*2,9)
        if STAGE==5:
            for i,(x,y) in enumerate(spec.items):
                if not s.collected & (1<<i):
                    x,y=xy(x,y);p.rect(x-4,y-5,8,10,7);p.line(x-2,y-2,x+2,y-2,13)
        x,y=xy(s.x,s.y);color=11 if getattr(s,'shade',False) else 8
        p.circ(x,y,RADIUS*2,10);p.circb(x,y,RADIUS*2,color);p.pset(x,y,1)
        p.clip()
        if STAGE>=2:
            a=spec.phase+(2*math.pi*(s.time%PERIOD)/PERIOD if s.rotating else 0)
            cx,cy=400,67;p.circb(cx,cy,12,6);p.line(cx,cy,cx+10*math.cos(a),cy+10*math.sin(a),10)
            p.text(12,65,'FULL BODY: '+('SHADE' if s.shade else 'SUN'),11 if s.shade else 8)
        labels={1:'NORMALIZED WALK 28 / RUN 46 / SWEPT WALLS',2:'FIXED SHADOW / UNION BOUNDARY / BODY RADIUS 3',3:'ROTATION 24s / SUN +25 / MOVING RUN +40',4:'AWNING ENTRY SAVES TIME / Z REWINDS',5:'ALL PAPERS + LAST AWNING = SUCCESS'}
        p.text(12,310,labels[STAGE],10)
        if STAGE>=3:p.text(12,326,'SHADE -35/s / AWNING 0 / FOUND AT 100',6)
        else:p.text(12,326,'PRACTICE: NO EXPOSURE OR FAILURE YET',6)
        if STAGE>=4:
            c=s.checkpoint;p.text(12,342,'SAVE A'+str(c.awning)+'  T '+format(c.time,'.3f')+'  SUN '+format(c.sun_seconds,'.3f'),7)
        if STAGE==5:p.text(250,342,'PAPERS '+str(s.collected.bit_count())+'/'+str(len(spec.items)),11)
        if s.status=='found':p.text(12,358,'FOUND / '+('Z RETURN OR ' if STAGE>=4 else '')+'R RESTART',8)
        elif s.status=='success':p.text(12,358,'SUCCESS / N NEXT GARDEN',11)
        else:p.text(12,358,'MODEL 120 Hz / DRAW 30 FPS / FIXED SIM TIME',7)
        p.text(12,376,'ARROWS MOVE  SPACE RUN  P PAUSE  R RESET  ESC QUIT',6)
        p.text(12,390,'1/2/3 GARDEN'+('  H +2s OUTLINE' if STAGE>=3 else '')+('  Z TIME RETURN' if STAGE>=4 else ''),6)


def parse_args(argv=None):
    """Reject invalid QA flags before importing or initializing Pyxel."""
    import argparse
    from pathlib import Path
    parser=argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless-smoke',action='store_true')
    parser.add_argument('--demo-checkpoint',action='store_true')
    parser.add_argument('--frames',type=int)
    parser.add_argument('--screenshot')
    parser.add_argument('--state-output')
    args=parser.parse_args(argv)
    if not args.headless_smoke and (args.demo_checkpoint or args.frames is not None or args.screenshot or args.state_output):
        parser.error('QA options require --headless-smoke')
    minimum=DEMO_FRAMES if args.demo_checkpoint else 4
    if args.frames is None:args.frames=minimum
    if not minimum<=args.frames<=600:parser.error('--frames must be between '+str(minimum)+' and 600')
    for name,suffix in (('screenshot','.png'),('state_output','.json')):
        value=getattr(args,name)
        if value is not None and (not value.strip() or Path(value).suffix.lower()!=suffix):
            parser.error('--'+name.replace('_','-')+' requires a '+suffix+' file path')
    return args


def main(argv=None):
    args=parse_args(argv)
    from pathlib import Path
    import pyxel as p
    app=App();finished=False
    p.init(448,410,title='017 Moving Shadows',fps=30,display_scale=2,headless=args.headless_smoke)
    keys={k:getattr(p,'KEY_'+k) for k in KEYS}
    def update():
        if args.headless_smoke:
            if args.demo_checkpoint and p.frame_count<DEMO_FRAMES:app.demo_update()
        else:app.control({k for k,v in keys.items() if p.btnp(v)},{k for k,v in keys.items() if p.btn(v)})
    def draw():
        nonlocal finished
        app.draw(p)
        if args.headless_smoke and p.frame_count>=args.frames-1 and not finished:
            finished=True
            for name in ('screenshot','state_output'):
                value=getattr(args,name)
                if value:
                    path=Path(value).resolve();path.parent.mkdir(parents=True,exist_ok=True)
                    if name=='screenshot':p.screenshot(str(path),scale=2)
                    else:path.write_text(json.dumps(asdict(app.state),ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
            p.quit()
    p.run(update,draw)


if __name__=='__main__':main()
STEP 02 / 05

中心だけでなく、身体全体を影に入れる

影の形(凸包)・影の重なり(和集合)・覆われていない縁。

まずは影の向きを固定したまま、プレイヤーが日陰にいるか日向にいるかを色で示します。影の形は、建物の四隅と、その四隅を36pxずらした4点の、合計8点をすべて囲む最小の凸多角形(凸包)です。convex_hull は同じ点を取り除き、外積 cross の符号(プラスかマイナスか)を使って、内側に入ってしまう点を外します。返ってくる多角形の頂点は、反時計回りの順に並びます。画面の y は下向きですが、計算の中で頂点の順番と cross の向きの決まりをそろえておけば、正しく判定できます。

体の中心が影の中にあっても、半径3の体の一部がはみ出しているかもしれません。たとえば10×10の影で、中心 (2.99,5) は影の内側ですが、左端までの距離が3より短いので、体は日に当たっています(日向)。(3,5) なら体の縁がちょうど影の境界に触れるだけで、全体が収まります。さらに、隣り合う2つの影の継ぎ目を、体がまたぐこともあります。この場合、どちらか1つの影だけでは体全体を覆えないので、「どれか1つの影が体全体を含むか」という判定でも足りません。

そこで disk_in_union は、影を全部合わせた形(和集合)に体が収まるかを調べます。まず、体の中心がどれかの影に入っているかを確認します。次に、影の各辺について、covered_interval で「ほかの影に覆われている区間」を求め、その区間の端で辺を小さく区切ります。区切った各部分の中点から、辺の外側へごくわずかに進んだ向きが、別の影に覆われているかを調べます。実際に少し動かして試すのではなく、半平面(直線で分けた片側)の外積と、その変化の向きから計算します。こうして、どの影にも覆われていない外周や穴の縁だけを残し、体の中心からその縁までの距離が半径より短ければ「はみ出している」と判定します。同じ向きに重なっている辺と、向かい合って接している継ぎ目を区別するのがポイントです。

ただし、どんな多角形でも正しく判定できる万能の仕組みではありません。このゲームが作る「凸で、反時計回りの多角形」だけを前提にし、小数の計算誤差を吸収するために EPS=1e-7 という小さな値を使います。1e-5px の細い隙間を継ぎ目と取り違えないことは、くり返し確かめるテスト(回帰テスト)で確認していますが、どんな大きさや潰れた形でも厳密に正しいという保証ではありません。体の円周上の8方向だけを調べる簡単な近似は使いません。この段階にはまだ庇・回復・発見・拾得がなく、影に入る感覚だけを確かめます。

下のコードは 02_shadow_union.py の一部です。前のステップから追加・変更したところだけを抜き出しています。

02_shadow_union.py / 注目する部分
                if any(covers_outside(other) for j,other in enumerate(polys) if j!=i):continue
                aa=(a[0]+dx*u,a[1]+dy*u);bb=(a[0]+dx*v,a[1]+dy*v)
                if distance_segment(p,aa,bb)<r-EPS:return False

実行して確かめる

教材のZIPを展開したフォルダーで、次のコマンドを実行します。

Windows
.venv\Scripts\python.exe 02_shadow_union.py
macOS / Linux
.venv/bin/python 02_shadow_union.py
画面で見ること体の輪郭は、全体が影に入ると緑、はみ出すと赤になります。この段階では、太陽時計の向きは時間が進んでも変わりません。自動確認は影の内側 (90,75) を目指して進み、X≈89.810765、Y≈74.989235、T=3.000秒で SHADE(日陰)になります。長方形2枚の継ぎ目に、中心 (10,10)・半径3の体を置いたテストでは、どちらか1枚だけで調べると不合格、2枚を合わせた形(和集合)で調べると合格します。
  • 中心が入ることと、体全体が入ることは別
  • 複数の影は、合わせた1つの形として扱う
  • どの影にも覆われていない縁だけで距離を測る
つまずきやすいところ中心だけの判定や、円周上の8点だけの検査では、細い隙間や穴を見落とします。反対に、ほかの影に覆われている内側の辺まで距離を測ると、安全な継ぎ目を「危ない」と判定してしまいます。また、決まった距離だけ外へ試しに進めて調べる方法では、その距離より細い隙間を飛び越えて見逃すおそれがあります。
少し変えてみよう

影の長さ SHADOW_LENGTH を 36 から 18 に変えてみましょう。庭1の固定された影の右端(x の最大値)は、80+36/√2≈105.455844 から 80+18/√2≈92.727922 へ縮みます。test_shadow_length_exercise が、段階3以降も含めてこの数値を確かめています。R を押しても定数は元に戻らないので、確認したらコードを 36 に戻して起動し直します。

02_shadow_union.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(397行)
02_shadow_union.py / 全文
# 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.

"""日陰の追いかけっこ・教材 2/5。Python 3.11+ / Pyxel 2.9.9。"""
from dataclasses import dataclass, replace, asdict
import math, hashlib, json

DT=1/120
RADIUS=3.0
PERIOD=24.0
SHADOW_LENGTH=36.0
EPS=1e-7



@dataclass(frozen=True)
class Level:
    name:str
    buildings:tuple
    phase:float

LEVELS=(
    Level('一本の影',((64,44,80,64),),math.pi/4),
    Level('つながる渡り廊下',((52,44,68,64),(98,60,114,80)),7*math.pi/4),
    Level('近道のまぶしさ',((52,44,68,64),(112,40,128,60)),math.pi/4),
)


@dataclass(frozen=True)
class Input:
    x:int=0
    y:int=0
    run:bool=False
    pause:bool=False
    restart:bool=False




@dataclass(frozen=True)
class State:
    level:int
    x:float
    y:float
    time:float=0.0
    status:str="play"
    paused:bool=False
    shade:bool=False
    rotating:bool=False


def initial(level=0):
    x,y=((40,88),(28,80),(28,80))[level]
    s=State(level,x,y)
    return replace(s,shade=is_shaded(s))


def cross(a,b,c):return (b[0]-a[0])*(c[1]-a[1])-(b[1]-a[1])*(c[0]-a[0])

def convex_hull(points):
    p=sorted(set(points))
    if len(p)<=1:return tuple(p)
    lo=[];hi=[]
    for q in p:
        while len(lo)>1 and cross(lo[-2],lo[-1],q)<=EPS:lo.pop()
        lo.append(q)
    for q in reversed(p):
        while len(hi)>1 and cross(hi[-2],hi[-1],q)<=EPS:hi.pop()
        hi.append(q)
    return tuple(lo[:-1]+hi[:-1])

def shadows(level,time,rotating=True):
    spec=LEVELS[level];a=spec.phase+(2*math.pi*(time%PERIOD)/PERIOD if rotating else 0)
    dx,dy=SHADOW_LENGTH*math.cos(a),SHADOW_LENGTH*math.sin(a)
    result=[]
    for x1,y1,x2,y2 in spec.buildings:
        c=((x1,y1),(x2,y1),(x2,y2),(x1,y2))
        result.append(convex_hull(c+tuple((x+dx,y+dy) for x,y in c)))
    return tuple(result)

def inside(p,poly,strict=False):
    return len(poly)>2 and all(cross(a,b,p)>(EPS if strict else -EPS) for a,b in zip(poly,poly[1:]+poly[:1]))

def distance_segment(p,a,b):
    dx,dy=b[0]-a[0],b[1]-a[1];dd=dx*dx+dy*dy
    t=max(0,min(1,((p[0]-a[0])*dx+(p[1]-a[1])*dy)/dd)) if dd>EPS else 0
    return math.hypot(p[0]-a[0]-t*dx,p[1]-a[1]-t*dy)

def covered_interval(a,b,poly):
    """Parametric portion of AB inside convex polygon, or None."""
    low,high=0.0,1.0
    for c,d in zip(poly,poly[1:]+poly[:1]):
        h0=cross(c,d,a);h1=cross(c,d,b);delta=h1-h0
        if abs(delta)<EPS:
            if h0<-EPS:return None
        elif delta>0:low=max(low,-h0/delta)
        else:high=min(high,-h0/delta)
        if low>high+EPS:return None
    return (max(0,low),min(1,high)) if low<=high else None

def disk_in_union(p,r,polys):
    """Union outer/hole edges, rather than a center-only or 8-point shortcut."""
    if not any(inside(p,poly) for poly in polys):return False
    for i,poly in enumerate(polys):
        for a,b in zip(poly,poly[1:]+poly[:1]):
            cuts=[0.0,1.0]
            for j,other in enumerate(polys):
                if j!=i:
                    interval=covered_interval(a,b,other)
                    if interval:cuts.extend(interval)
            cuts=sorted(set(cuts))
            for u,v in zip(cuts,cuts[1:]):
                if v-u<EPS:continue
                mid=((a[0]+(b[0]-a[0])*(u+v)/2),(a[1]+(b[1]-a[1])*(u+v)/2))
                # Infinitesimal outward coverage, not a finite probe that could
                # jump across narrow gaps. Coincident opposite edges are seams.
                dx,dy=b[0]-a[0],b[1]-a[1];length=math.hypot(dx,dy)
                outward=(dy/length,-dx/length)
                def covers_outside(other):
                    for c,d in zip(other,other[1:]+other[:1]):
                        h=cross(c,d,mid)
                        if h < -EPS:return False
                        derivative=(d[0]-c[0])*outward[1]-(d[1]-c[1])*outward[0]
                        if h <= EPS and derivative < -EPS:return False
                    return True
                if any(covers_outside(other) for j,other in enumerate(polys) if j!=i):continue
                aa=(a[0]+dx*u,a[1]+dy*u);bb=(a[0]+dx*v,a[1]+dy*v)
                if distance_segment(p,aa,bb)<r-EPS:return False
    return True

def is_shaded(s):
    return disk_in_union((s.x,s.y),RADIUS,shadows(s.level,s.time,s.rotating))


def sweep_circle(p,d,rect,r=RADIUS):
    """Earliest rounded-rectangle collision. Tangencies while moving away skip."""
    x,y=p;dx,dy=d;x1,y1,x2,y2=rect;hits=[]
    for edge,lo,hi,axis,normal in ((x1-r,y1,y2,0,(-1,0)),(x2+r,y1,y2,0,(1,0)),(y1-r,x1,x2,1,(0,-1)),(y2+r,x1,x2,1,(0,1))):
        vel=dx if axis==0 else dy;pos=x if axis==0 else y
        if abs(vel)<EPS or dx*normal[0]+dy*normal[1]>=-EPS:continue
        t=(edge-pos)/vel;other=(y+dy*t) if axis==0 else (x+dx*t)
        if -EPS<=t<=1+EPS and lo-EPS<=other<=hi+EPS:hits.append((max(0,t),normal))
    aa=dx*dx+dy*dy
    if aa>EPS:
        for cx,cy,sx,sy in ((x1,y1,-1,-1),(x2,y1,1,-1),(x2,y2,1,1),(x1,y2,-1,1)):
            ox,oy=x-cx,y-cy;bb=2*(ox*dx+oy*dy);cc=ox*ox+oy*oy-r*r;disc=bb*bb-4*aa*cc
            if disc<-EPS:continue
            t=(-bb-math.sqrt(max(0,disc)))/(2*aa)
            if -EPS<=t<=1+EPS:
                hx,hy=x+dx*t-cx,y+dy*t-cy
                if hx*sx>=-EPS and hy*sy>=-EPS and hx*dx+hy*dy<-EPS:hits.append((max(0,t),(hx/r,hy/r)))
    return min(hits,key=lambda h:h[0]) if hits else None

def move(s,dx,dy):
    p=(s.x,s.y);d=(dx,dy)
    for _ in range(4):
        hits=[hit for box in LEVELS[s.level].buildings if (hit:=sweep_circle(p,d,box))]
        if not hits:p=(p[0]+d[0],p[1]+d[1]);break
        t,n=min(hits,key=lambda h:h[0]);p=(p[0]+d[0]*t+n[0]*1e-5,p[1]+d[1]*t+n[1]*1e-5)
        rem=(d[0]*(1-t),d[1]*(1-t));dot=rem[0]*n[0]+rem[1]*n[1]
        d=(rem[0]-min(0,dot)*n[0],rem[1]-min(0,dot)*n[1])
        if math.hypot(*d)<EPS:break
    return max(7,min(185,p[0])),max(23,min(121,p[1]))



def step(s,inp=Input(),dt=DT):
    if dt<0 or not math.isfinite(dt):raise ValueError('dt must be finite and nonnegative')
    if inp.restart:return initial(s.level)
    if inp.pause:return replace(s,paused=not s.paused)
    if s.paused:return s
    if s.status!='play' or dt==0:return s
    norm=math.hypot(inp.x,inp.y);speed=46 if inp.run else 28
    dx,dy=((inp.x/norm*speed*dt,inp.y/norm*speed*dt) if norm else (0,0))
    x,y=move(s,dx,dy)
    new=replace(s,x=x,y=y,time=s.time+dt)
    return replace(new,shade=is_shaded(new))

def frame(s,inp=Input()):
    """An edge belongs to one render frame, never four physics substeps."""
    if inp.pause or inp.restart:return step(s,inp)
    for _ in range(4):s=step(s,inp)
    return s

def state_hash(s):
    return hashlib.sha256(json.dumps(asdict(s),sort_keys=True,separators=(',',':'),allow_nan=False).encode('utf-8')).hexdigest()

STAGE = 2
DEFAULT_LEVEL = 0
DEMO_FRAMES = 90
KEYS = ('UP','DOWN','LEFT','RIGHT','SPACE','P','R','Z','N','1','2','3','H')

class App:
    def __init__(self):
        self.level=DEFAULT_LEVEL
        self.reset()

    def reset(self):
        self.state=initial(self.level)
        self.locked=False
        self.forecast=False
        self.demo_tick=0
        self.waypoint=0

    def control(self, pressed, held):
        if 'R' in pressed:
            self.reset();self.locked=True;return
        for key in ('1','2','3'):
            if key in pressed:
                self.level=int(key)-1;self.reset();self.locked=True;return
        if 'P' in pressed:
            self.state=frame(self.state,Input(pause=True));return
        if self.state.paused:return
        if STAGE>=4 and 'Z' in pressed:
            self.state=frame(self.state,Input(checkpoint=True));self.locked=True;return
        if STAGE>=3 and 'H' in pressed:
            self.forecast=not self.forecast;return
        if self.locked:
            if not held.intersection(('UP','DOWN','LEFT','RIGHT','SPACE')):self.locked=False
            return
        if self.state.status=='success':
            if 'N' in pressed:
                self.level=(self.level+1)%3;self.reset();self.locked=True
            return
        u=Input(int('RIGHT' in held)-int('LEFT' in held),
                int('DOWN' in held)-int('UP' in held),'SPACE' in held)
        self.state=frame(self.state,u)

    def demo_input(self):
        tick=self.demo_tick;self.demo_tick+=1
        if STAGE==1:
            return Input(1,0,True) if tick<45 else Input(0,-1) if tick<75 else Input()
        if STAGE==2:
            dx,dy=90-self.state.x,75-self.state.y
            if math.hypot(dx,dy)<1:return Input()
            return Input(0 if abs(dx)<.55 else 1 if dx>0 else -1,0 if abs(dy)<.55 else 1 if dy>0 else -1,True)
        if STAGE==3:return Input(1,0,True)
        if STAGE==4:
            if tick==190:return Input(checkpoint=True)
            if tick>=190:return Input()
            route=((28,104),(84,104))
        else:
            if tick<105:return Input()
            route=((86,72),(86,64),(88,86),(138,86),(138,80),(164,64))
        s=self.state
        while self.waypoint<len(route) and math.dist((s.x,s.y),route[self.waypoint])<1:self.waypoint+=1
        if self.waypoint==len(route):return Input()
        tx,ty=route[self.waypoint];dx,dy=tx-s.x,ty-s.y
        return Input(0 if abs(dx)<.55 else 1 if dx>0 else -1,
                     0 if abs(dy)<.55 else 1 if dy>0 else -1,True)

    def demo_update(self):
        # Semantic input to the real update path; not OS-keyboard evidence.
        u=self.demo_input();held=set()
        if u.x:held.add('RIGHT' if u.x>0 else 'LEFT')
        if u.y:held.add('DOWN' if u.y>0 else 'UP')
        if u.run:held.add('SPACE')
        pressed={'Z'} if getattr(u,'checkpoint',False) else set()
        self.control(pressed,held)

    def draw(self,p):
        s=self.state;spec=LEVELS[self.level]
        p.cls(1)
        p.text(12,10,'017 MOVING SHADOWS / STAGE '+str(STAGE),10)
        p.text(12,26,('PAUSED' if s.paused else s.status.upper())+'  GARDEN '+str(self.level+1)+'  T '+format(s.time,'.3f')+' s',7)
        p.text(12,40,'X '+format(s.x,'.2f')+'  Y '+format(s.y,'.2f'),6)
        if STAGE>=3:
            p.text(226,26,'EXPOSURE '+format(s.exposure,'.2f'),10)
            p.rectb(330,24,102,9,7);p.rect(331,25,s.exposure,7,8)
            p.text(226,40,'SUN '+format(s.sun_seconds,'.3f')+' s',6)
        ox,oy=24,46
        def xy(x,y):return ox+x*2,oy+y*2
        def polygon(poly,color,outline=False,dotted=False):
            points=[xy(*v) for v in poly]
            if not outline:
                for j in range(1,len(points)-1):p.tri(*points[0],*points[j],*points[j+1],color)
            else:
                for a,b in zip(points,points[1:]+points[:1]):
                    if dotted:
                        n=max(1,int(math.dist(a,b)/5))
                        for j in range(0,n,2):
                            u=j/n;v=min(j+1,n)/n
                            p.line(a[0]+(b[0]-a[0])*u,a[1]+(b[1]-a[1])*u,a[0]+(b[0]-a[0])*v,a[1]+(b[1]-a[1])*v,color)
                    else:p.line(*a,*b,color)
        p.rect(ox+8,oy+40,368,208,15)
        p.clip(ox+8,oy+40,368,208)
        for x in range(8,188,16):
            for y in range(24,124,16):p.pset(*xy(x,y),9)
        if STAGE>=2:
            for poly in shadows(s.level,s.time,s.rotating):polygon(poly,1);polygon(poly,6,True)
            if STAGE>=3 and self.forecast:
                for poly in shadows(s.level,s.time+2,s.rotating):polygon(poly,13,True,True)
        if STAGE>=3:
            for i,(x,y,r) in enumerate(spec.awnings):
                x,y=xy(x,y);p.circ(x,y,r*2,3);p.circb(x,y,r*2,11);p.circb(x,y,r*2-3,11)
                p.text(x-12,y+26,'EXIT' if STAGE==5 and i==len(spec.awnings)-1 else 'A'+str(i),3)
                if STAGE==5 and i==len(spec.awnings)-1:
                    p.line(x+7,y-7,x+7,y+6,7);p.tri(x+8,y-7,x+17,y-4,x+8,y-1,10)
        for x1,y1,x2,y2 in spec.buildings:
            x,y=xy(x1,y1);p.rect(x,y,(x2-x1)*2,(y2-y1)*2,4);p.rectb(x,y,(x2-x1)*2,(y2-y1)*2,9)
        if STAGE==5:
            for i,(x,y) in enumerate(spec.items):
                if not s.collected & (1<<i):
                    x,y=xy(x,y);p.rect(x-4,y-5,8,10,7);p.line(x-2,y-2,x+2,y-2,13)
        x,y=xy(s.x,s.y);color=11 if getattr(s,'shade',False) else 8
        p.circ(x,y,RADIUS*2,10);p.circb(x,y,RADIUS*2,color);p.pset(x,y,1)
        p.clip()
        if STAGE>=2:
            a=spec.phase+(2*math.pi*(s.time%PERIOD)/PERIOD if s.rotating else 0)
            cx,cy=400,67;p.circb(cx,cy,12,6);p.line(cx,cy,cx+10*math.cos(a),cy+10*math.sin(a),10)
            p.text(12,65,'FULL BODY: '+('SHADE' if s.shade else 'SUN'),11 if s.shade else 8)
        labels={1:'NORMALIZED WALK 28 / RUN 46 / SWEPT WALLS',2:'FIXED SHADOW / UNION BOUNDARY / BODY RADIUS 3',3:'ROTATION 24s / SUN +25 / MOVING RUN +40',4:'AWNING ENTRY SAVES TIME / Z REWINDS',5:'ALL PAPERS + LAST AWNING = SUCCESS'}
        p.text(12,310,labels[STAGE],10)
        if STAGE>=3:p.text(12,326,'SHADE -35/s / AWNING 0 / FOUND AT 100',6)
        else:p.text(12,326,'PRACTICE: NO EXPOSURE OR FAILURE YET',6)
        if STAGE>=4:
            c=s.checkpoint;p.text(12,342,'SAVE A'+str(c.awning)+'  T '+format(c.time,'.3f')+'  SUN '+format(c.sun_seconds,'.3f'),7)
        if STAGE==5:p.text(250,342,'PAPERS '+str(s.collected.bit_count())+'/'+str(len(spec.items)),11)
        if s.status=='found':p.text(12,358,'FOUND / '+('Z RETURN OR ' if STAGE>=4 else '')+'R RESTART',8)
        elif s.status=='success':p.text(12,358,'SUCCESS / N NEXT GARDEN',11)
        else:p.text(12,358,'MODEL 120 Hz / DRAW 30 FPS / FIXED SIM TIME',7)
        p.text(12,376,'ARROWS MOVE  SPACE RUN  P PAUSE  R RESET  ESC QUIT',6)
        p.text(12,390,'1/2/3 GARDEN'+('  H +2s OUTLINE' if STAGE>=3 else '')+('  Z TIME RETURN' if STAGE>=4 else ''),6)


def parse_args(argv=None):
    """Reject invalid QA flags before importing or initializing Pyxel."""
    import argparse
    from pathlib import Path
    parser=argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless-smoke',action='store_true')
    parser.add_argument('--demo-checkpoint',action='store_true')
    parser.add_argument('--frames',type=int)
    parser.add_argument('--screenshot')
    parser.add_argument('--state-output')
    args=parser.parse_args(argv)
    if not args.headless_smoke and (args.demo_checkpoint or args.frames is not None or args.screenshot or args.state_output):
        parser.error('QA options require --headless-smoke')
    minimum=DEMO_FRAMES if args.demo_checkpoint else 4
    if args.frames is None:args.frames=minimum
    if not minimum<=args.frames<=600:parser.error('--frames must be between '+str(minimum)+' and 600')
    for name,suffix in (('screenshot','.png'),('state_output','.json')):
        value=getattr(args,name)
        if value is not None and (not value.strip() or Path(value).suffix.lower()!=suffix):
            parser.error('--'+name.replace('_','-')+' requires a '+suffix+' file path')
    return args


def main(argv=None):
    args=parse_args(argv)
    from pathlib import Path
    import pyxel as p
    app=App();finished=False
    p.init(448,410,title='017 Moving Shadows',fps=30,display_scale=2,headless=args.headless_smoke)
    keys={k:getattr(p,'KEY_'+k) for k in KEYS}
    def update():
        if args.headless_smoke:
            if args.demo_checkpoint and p.frame_count<DEMO_FRAMES:app.demo_update()
        else:app.control({k for k,v in keys.items() if p.btnp(v)},{k for k,v in keys.items() if p.btn(v)})
    def draw():
        nonlocal finished
        app.draw(p)
        if args.headless_smoke and p.frame_count>=args.frames-1 and not finished:
            finished=True
            for name in ('screenshot','state_output'):
                value=getattr(args,name)
                if value:
                    path=Path(value).resolve();path.parent.mkdir(parents=True,exist_ok=True)
                    if name=='screenshot':p.screenshot(str(path),scale=2)
                    else:path.write_text(json.dumps(asdict(app.state),ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
            p.quit()
    p.run(update,draw)


if __name__=='__main__':main()
STEP 03 / 05

回る影と、日向にいる負担をつなぐ

24秒で一周する影・小刻みごとのゲージ計算・いつでも安全な庇。

影の角度に 2π×(time%24)/24 を足して、影がゲーム内の時間で24秒かけて一周するようにします(% は割った余り)。立ち止まっていても、時間が進めば日陰は離れていきます。H を押すと、2秒後の影の輪郭を点線で表示できます。H を押したその更新では表示を切り替えるだけで、時刻は進めません。この予告表示は使わなくても遊べます。また、建物を通り抜けられるようになる機能でもありません。

日向にいた負担を表す exposure(0〜100)を加えます。日向にいると毎秒25増え、SPACE と方向キーを両方押して走っていると毎秒40増えます。SPACE だけを押しているなら25のままです。壁や外枠に押しつけて実際の移動量が0でも、方向入力の長さ norm が0でなければ、走っている扱いで40になります。元のゲームが、実際に動いた距離ではなく、押しているキーから負担を決めているためです。影の中では毎秒35減り、0より下にはなりません。sun_seconds は、日向と判定された step の dt だけを足していく、別の記録(日向にいた合計秒数)です。

二重の輪で描かれているのが、回転しない庇(ひさし)です。半径11の庇に半径3の体が収まるには、庇の中心と体の中心の距離が8(11−3)以下である必要があります。輪の中に体の中心が入るだけでは足りません。庇の中では影よりも庇が優先され、exposure はすぐに0になります。影が離れていっても、庇の中にいればいつまでも安全です。最後の庇も、この段階ではただの安全地帯で、クリアの条件はまだありません。

1回の step の処理順は、移動と壁 → 時刻を進める → 新しい位置と時刻で日陰かを判定 → ゲージ、です。ゲージが 100-EPS 以上になったら found(見つかった)にして値を100にそろえ、それ以降は通常の更新を止めます。壁との衝突は移動の途中まで調べますが、日が当たっているかは、1/120秒(120Hz)ごとの到着点で1回だけ調べます。そのため、大きな dt で庇から庇へ一気に飛ぶと、途中の日向を見落とします。step は元のゲームと同じく、有限で0以上の dt なら受け付けますが、ふだんは DT を使ってください。なお、これは現実の太陽の高さ・季節・影の長さを再現するものではありません。

下のコードは 03_sun_burden.py の一部です。前のステップから追加・変更したところだけを抜き出しています。

03_sun_burden.py / 注目する部分
    exposure=0 if awning>=0 else max(0,min(100,s.exposure+(-35 if shade else 40 if inp.run and norm else 25)*dt))

実行して確かめる

教材のZIPを展開したフォルダーで、次のコマンドを実行します。

Windows
.venv\Scripts\python.exe 03_sun_burden.py
macOS / Linux
.venv/bin/python 03_sun_burden.py
画面で見ること最初の庇で24秒待っても、EXPOSURE=0、SUN=0、状態は PLAY のままです。自動確認は右へ走り続け、外枠の X=185・Y=88 にいる T=4.841667秒で FOUND(発見)・EXPOSURE=100 になります。外枠で止まっていても、走る入力を続けている間は負担も増え続けます。
  • 日陰かどうかは、位置と時刻の組み合わせで決まる
  • 走りの負担は、実際の移動ではなく方向入力の有無で決まる
  • 庇に体全体が収まると、ゲージはすぐ0
つまずきやすいところ庇に入ったかの判定を「中心間の距離 <=11」にすると、体の一部が外に出ていても回復してしまいます(正しくは <=8)。日向の負担を「実際に動いたか」で切り替えると、押しているキーで決める元のゲームの規則と変わってしまいます。影の回転を、描画の回数 frame_count だけから計算すると、P で止めたゲームの状態と、画面の影がずれてしまいます。
少し変えてみよう

日向の決まった位置 (180,120) で1秒進めるテストでは、何も押さないときと SPACE だけのときは25、方向キー+SPACE のときは40増えます。test_burden_exercise と test_sun_walk_idle_and_held_run で確かめています。ゲームの中では、P で時間を止め、止めたまま方向キーを押しても、時刻もゲージも変わらないことを確かめてみましょう。キーの反応そのものを試す実機テストの代わりに、同じ処理の分かれ道を調べる自動テストも付いています。

03_sun_burden.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(406行)
03_sun_burden.py / 全文
# 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.

"""日陰の追いかけっこ・教材 3/5。Python 3.11+ / Pyxel 2.9.9。"""
from dataclasses import dataclass, replace, asdict
import math, hashlib, json

DT=1/120
RADIUS=3.0
PERIOD=24.0
SHADOW_LENGTH=36.0
EPS=1e-7



@dataclass(frozen=True)
class Level:
    name:str
    buildings:tuple
    awnings:tuple
    phase:float

LEVELS=(
    Level('一本の影',((64,44,80,64),),((40,88,11),(140,88,11)),math.pi/4),
    Level('つながる渡り廊下',((52,44,68,64),(98,60,114,80)),((28,80,11),(84,104,11),(164,64,11)),7*math.pi/4),
    Level('近道のまぶしさ',((52,44,68,64),(112,40,128,60)),((28,80,11),(88,104,11),(168,100,11)),math.pi/4),
)


@dataclass(frozen=True)
class Input:
    x:int=0
    y:int=0
    run:bool=False
    pause:bool=False
    restart:bool=False




@dataclass(frozen=True)
class State:
    level:int
    x:float
    y:float
    time:float=0.0
    status:str="play"
    paused:bool=False
    shade:bool=False
    rotating:bool=False
    exposure:float=0.0
    awning:int=0
    sun_seconds:float=0.0


def initial(level=0,rotating=True):
    x,y,_=LEVELS[level].awnings[0]
    return State(level,x,y,shade=True,rotating=rotating)


def cross(a,b,c):return (b[0]-a[0])*(c[1]-a[1])-(b[1]-a[1])*(c[0]-a[0])

def convex_hull(points):
    p=sorted(set(points))
    if len(p)<=1:return tuple(p)
    lo=[];hi=[]
    for q in p:
        while len(lo)>1 and cross(lo[-2],lo[-1],q)<=EPS:lo.pop()
        lo.append(q)
    for q in reversed(p):
        while len(hi)>1 and cross(hi[-2],hi[-1],q)<=EPS:hi.pop()
        hi.append(q)
    return tuple(lo[:-1]+hi[:-1])

def shadows(level,time,rotating=True):
    spec=LEVELS[level];a=spec.phase+(2*math.pi*(time%PERIOD)/PERIOD if rotating else 0)
    dx,dy=SHADOW_LENGTH*math.cos(a),SHADOW_LENGTH*math.sin(a)
    result=[]
    for x1,y1,x2,y2 in spec.buildings:
        c=((x1,y1),(x2,y1),(x2,y2),(x1,y2))
        result.append(convex_hull(c+tuple((x+dx,y+dy) for x,y in c)))
    return tuple(result)

def inside(p,poly,strict=False):
    return len(poly)>2 and all(cross(a,b,p)>(EPS if strict else -EPS) for a,b in zip(poly,poly[1:]+poly[:1]))

def distance_segment(p,a,b):
    dx,dy=b[0]-a[0],b[1]-a[1];dd=dx*dx+dy*dy
    t=max(0,min(1,((p[0]-a[0])*dx+(p[1]-a[1])*dy)/dd)) if dd>EPS else 0
    return math.hypot(p[0]-a[0]-t*dx,p[1]-a[1]-t*dy)

def covered_interval(a,b,poly):
    """Parametric portion of AB inside convex polygon, or None."""
    low,high=0.0,1.0
    for c,d in zip(poly,poly[1:]+poly[:1]):
        h0=cross(c,d,a);h1=cross(c,d,b);delta=h1-h0
        if abs(delta)<EPS:
            if h0<-EPS:return None
        elif delta>0:low=max(low,-h0/delta)
        else:high=min(high,-h0/delta)
        if low>high+EPS:return None
    return (max(0,low),min(1,high)) if low<=high else None

def disk_in_union(p,r,polys):
    """Union outer/hole edges, rather than a center-only or 8-point shortcut."""
    if not any(inside(p,poly) for poly in polys):return False
    for i,poly in enumerate(polys):
        for a,b in zip(poly,poly[1:]+poly[:1]):
            cuts=[0.0,1.0]
            for j,other in enumerate(polys):
                if j!=i:
                    interval=covered_interval(a,b,other)
                    if interval:cuts.extend(interval)
            cuts=sorted(set(cuts))
            for u,v in zip(cuts,cuts[1:]):
                if v-u<EPS:continue
                mid=((a[0]+(b[0]-a[0])*(u+v)/2),(a[1]+(b[1]-a[1])*(u+v)/2))
                # Infinitesimal outward coverage, not a finite probe that could
                # jump across narrow gaps. Coincident opposite edges are seams.
                dx,dy=b[0]-a[0],b[1]-a[1];length=math.hypot(dx,dy)
                outward=(dy/length,-dx/length)
                def covers_outside(other):
                    for c,d in zip(other,other[1:]+other[:1]):
                        h=cross(c,d,mid)
                        if h < -EPS:return False
                        derivative=(d[0]-c[0])*outward[1]-(d[1]-c[1])*outward[0]
                        if h <= EPS and derivative < -EPS:return False
                    return True
                if any(covers_outside(other) for j,other in enumerate(polys) if j!=i):continue
                aa=(a[0]+dx*u,a[1]+dy*u);bb=(a[0]+dx*v,a[1]+dy*v)
                if distance_segment(p,aa,bb)<r-EPS:return False
    return True

def awning_at(level,x,y):
    return next((i for i,(ax,ay,r) in enumerate(LEVELS[level].awnings) if math.hypot(x-ax,y-ay)<=r-RADIUS+EPS),-1)

def is_shaded(s):
    return awning_at(s.level,s.x,s.y)>=0 or disk_in_union((s.x,s.y),RADIUS,shadows(s.level,s.time,s.rotating))

def sweep_circle(p,d,rect,r=RADIUS):
    """Earliest rounded-rectangle collision. Tangencies while moving away skip."""
    x,y=p;dx,dy=d;x1,y1,x2,y2=rect;hits=[]
    for edge,lo,hi,axis,normal in ((x1-r,y1,y2,0,(-1,0)),(x2+r,y1,y2,0,(1,0)),(y1-r,x1,x2,1,(0,-1)),(y2+r,x1,x2,1,(0,1))):
        vel=dx if axis==0 else dy;pos=x if axis==0 else y
        if abs(vel)<EPS or dx*normal[0]+dy*normal[1]>=-EPS:continue
        t=(edge-pos)/vel;other=(y+dy*t) if axis==0 else (x+dx*t)
        if -EPS<=t<=1+EPS and lo-EPS<=other<=hi+EPS:hits.append((max(0,t),normal))
    aa=dx*dx+dy*dy
    if aa>EPS:
        for cx,cy,sx,sy in ((x1,y1,-1,-1),(x2,y1,1,-1),(x2,y2,1,1),(x1,y2,-1,1)):
            ox,oy=x-cx,y-cy;bb=2*(ox*dx+oy*dy);cc=ox*ox+oy*oy-r*r;disc=bb*bb-4*aa*cc
            if disc<-EPS:continue
            t=(-bb-math.sqrt(max(0,disc)))/(2*aa)
            if -EPS<=t<=1+EPS:
                hx,hy=x+dx*t-cx,y+dy*t-cy
                if hx*sx>=-EPS and hy*sy>=-EPS and hx*dx+hy*dy<-EPS:hits.append((max(0,t),(hx/r,hy/r)))
    return min(hits,key=lambda h:h[0]) if hits else None

def move(s,dx,dy):
    p=(s.x,s.y);d=(dx,dy)
    for _ in range(4):
        hits=[hit for box in LEVELS[s.level].buildings if (hit:=sweep_circle(p,d,box))]
        if not hits:p=(p[0]+d[0],p[1]+d[1]);break
        t,n=min(hits,key=lambda h:h[0]);p=(p[0]+d[0]*t+n[0]*1e-5,p[1]+d[1]*t+n[1]*1e-5)
        rem=(d[0]*(1-t),d[1]*(1-t));dot=rem[0]*n[0]+rem[1]*n[1]
        d=(rem[0]-min(0,dot)*n[0],rem[1]-min(0,dot)*n[1])
        if math.hypot(*d)<EPS:break
    return max(7,min(185,p[0])),max(23,min(121,p[1]))



def step(s,inp=Input(),dt=DT):
    if dt<0 or not math.isfinite(dt):raise ValueError('dt must be finite and nonnegative')
    if inp.restart:return initial(s.level,s.rotating)
    if inp.pause:return replace(s,paused=not s.paused)
    if s.paused:return s
    if s.status!='play' or dt==0:return s
    norm=math.hypot(inp.x,inp.y);speed=46 if inp.run else 28
    dx,dy=((inp.x/norm*speed*dt,inp.y/norm*speed*dt) if norm else (0,0))
    x,y=move(s,dx,dy);time=s.time+dt;awning=awning_at(s.level,x,y)
    new=replace(s,x=x,y=y,time=time,awning=awning)
    shade=is_shaded(new)
    exposure=0 if awning>=0 else max(0,min(100,s.exposure+(-35 if shade else 40 if inp.run and norm else 25)*dt))
    new=replace(new,shade=shade,exposure=exposure,sun_seconds=s.sun_seconds+(0 if shade else dt))
    if exposure>=100-EPS:return replace(new,status='found',exposure=100)
    return new

def frame(s,inp=Input()):
    """An edge belongs to one render frame, never four physics substeps."""
    if inp.pause or inp.restart:return step(s,inp)
    for _ in range(4):s=step(s,inp)
    return s

def state_hash(s):
    return hashlib.sha256(json.dumps(asdict(s),sort_keys=True,separators=(',',':'),allow_nan=False).encode('utf-8')).hexdigest()

STAGE = 3
DEFAULT_LEVEL = 0
DEMO_FRAMES = 210
KEYS = ('UP','DOWN','LEFT','RIGHT','SPACE','P','R','Z','N','1','2','3','H')

class App:
    def __init__(self):
        self.level=DEFAULT_LEVEL
        self.reset()

    def reset(self):
        self.state=initial(self.level)
        self.locked=False
        self.forecast=False
        self.demo_tick=0
        self.waypoint=0

    def control(self, pressed, held):
        if 'R' in pressed:
            self.reset();self.locked=True;return
        for key in ('1','2','3'):
            if key in pressed:
                self.level=int(key)-1;self.reset();self.locked=True;return
        if 'P' in pressed:
            self.state=frame(self.state,Input(pause=True));return
        if self.state.paused:return
        if STAGE>=4 and 'Z' in pressed:
            self.state=frame(self.state,Input(checkpoint=True));self.locked=True;return
        if STAGE>=3 and 'H' in pressed:
            self.forecast=not self.forecast;return
        if self.locked:
            if not held.intersection(('UP','DOWN','LEFT','RIGHT','SPACE')):self.locked=False
            return
        if self.state.status=='success':
            if 'N' in pressed:
                self.level=(self.level+1)%3;self.reset();self.locked=True
            return
        u=Input(int('RIGHT' in held)-int('LEFT' in held),
                int('DOWN' in held)-int('UP' in held),'SPACE' in held)
        self.state=frame(self.state,u)

    def demo_input(self):
        tick=self.demo_tick;self.demo_tick+=1
        if STAGE==1:
            return Input(1,0,True) if tick<45 else Input(0,-1) if tick<75 else Input()
        if STAGE==2:
            dx,dy=90-self.state.x,75-self.state.y
            if math.hypot(dx,dy)<1:return Input()
            return Input(0 if abs(dx)<.55 else 1 if dx>0 else -1,0 if abs(dy)<.55 else 1 if dy>0 else -1,True)
        if STAGE==3:return Input(1,0,True)
        if STAGE==4:
            if tick==190:return Input(checkpoint=True)
            if tick>=190:return Input()
            route=((28,104),(84,104))
        else:
            if tick<105:return Input()
            route=((86,72),(86,64),(88,86),(138,86),(138,80),(164,64))
        s=self.state
        while self.waypoint<len(route) and math.dist((s.x,s.y),route[self.waypoint])<1:self.waypoint+=1
        if self.waypoint==len(route):return Input()
        tx,ty=route[self.waypoint];dx,dy=tx-s.x,ty-s.y
        return Input(0 if abs(dx)<.55 else 1 if dx>0 else -1,
                     0 if abs(dy)<.55 else 1 if dy>0 else -1,True)

    def demo_update(self):
        # Semantic input to the real update path; not OS-keyboard evidence.
        u=self.demo_input();held=set()
        if u.x:held.add('RIGHT' if u.x>0 else 'LEFT')
        if u.y:held.add('DOWN' if u.y>0 else 'UP')
        if u.run:held.add('SPACE')
        pressed={'Z'} if getattr(u,'checkpoint',False) else set()
        self.control(pressed,held)

    def draw(self,p):
        s=self.state;spec=LEVELS[self.level]
        p.cls(1)
        p.text(12,10,'017 MOVING SHADOWS / STAGE '+str(STAGE),10)
        p.text(12,26,('PAUSED' if s.paused else s.status.upper())+'  GARDEN '+str(self.level+1)+'  T '+format(s.time,'.3f')+' s',7)
        p.text(12,40,'X '+format(s.x,'.2f')+'  Y '+format(s.y,'.2f'),6)
        if STAGE>=3:
            p.text(226,26,'EXPOSURE '+format(s.exposure,'.2f'),10)
            p.rectb(330,24,102,9,7);p.rect(331,25,s.exposure,7,8)
            p.text(226,40,'SUN '+format(s.sun_seconds,'.3f')+' s',6)
        ox,oy=24,46
        def xy(x,y):return ox+x*2,oy+y*2
        def polygon(poly,color,outline=False,dotted=False):
            points=[xy(*v) for v in poly]
            if not outline:
                for j in range(1,len(points)-1):p.tri(*points[0],*points[j],*points[j+1],color)
            else:
                for a,b in zip(points,points[1:]+points[:1]):
                    if dotted:
                        n=max(1,int(math.dist(a,b)/5))
                        for j in range(0,n,2):
                            u=j/n;v=min(j+1,n)/n
                            p.line(a[0]+(b[0]-a[0])*u,a[1]+(b[1]-a[1])*u,a[0]+(b[0]-a[0])*v,a[1]+(b[1]-a[1])*v,color)
                    else:p.line(*a,*b,color)
        p.rect(ox+8,oy+40,368,208,15)
        p.clip(ox+8,oy+40,368,208)
        for x in range(8,188,16):
            for y in range(24,124,16):p.pset(*xy(x,y),9)
        if STAGE>=2:
            for poly in shadows(s.level,s.time,s.rotating):polygon(poly,1);polygon(poly,6,True)
            if STAGE>=3 and self.forecast:
                for poly in shadows(s.level,s.time+2,s.rotating):polygon(poly,13,True,True)
        if STAGE>=3:
            for i,(x,y,r) in enumerate(spec.awnings):
                x,y=xy(x,y);p.circ(x,y,r*2,3);p.circb(x,y,r*2,11);p.circb(x,y,r*2-3,11)
                p.text(x-12,y+26,'EXIT' if STAGE==5 and i==len(spec.awnings)-1 else 'A'+str(i),3)
                if STAGE==5 and i==len(spec.awnings)-1:
                    p.line(x+7,y-7,x+7,y+6,7);p.tri(x+8,y-7,x+17,y-4,x+8,y-1,10)
        for x1,y1,x2,y2 in spec.buildings:
            x,y=xy(x1,y1);p.rect(x,y,(x2-x1)*2,(y2-y1)*2,4);p.rectb(x,y,(x2-x1)*2,(y2-y1)*2,9)
        if STAGE==5:
            for i,(x,y) in enumerate(spec.items):
                if not s.collected & (1<<i):
                    x,y=xy(x,y);p.rect(x-4,y-5,8,10,7);p.line(x-2,y-2,x+2,y-2,13)
        x,y=xy(s.x,s.y);color=11 if getattr(s,'shade',False) else 8
        p.circ(x,y,RADIUS*2,10);p.circb(x,y,RADIUS*2,color);p.pset(x,y,1)
        p.clip()
        if STAGE>=2:
            a=spec.phase+(2*math.pi*(s.time%PERIOD)/PERIOD if s.rotating else 0)
            cx,cy=400,67;p.circb(cx,cy,12,6);p.line(cx,cy,cx+10*math.cos(a),cy+10*math.sin(a),10)
            p.text(12,65,'FULL BODY: '+('SHADE' if s.shade else 'SUN'),11 if s.shade else 8)
        labels={1:'NORMALIZED WALK 28 / RUN 46 / SWEPT WALLS',2:'FIXED SHADOW / UNION BOUNDARY / BODY RADIUS 3',3:'ROTATION 24s / SUN +25 / MOVING RUN +40',4:'AWNING ENTRY SAVES TIME / Z REWINDS',5:'ALL PAPERS + LAST AWNING = SUCCESS'}
        p.text(12,310,labels[STAGE],10)
        if STAGE>=3:p.text(12,326,'SHADE -35/s / AWNING 0 / FOUND AT 100',6)
        else:p.text(12,326,'PRACTICE: NO EXPOSURE OR FAILURE YET',6)
        if STAGE>=4:
            c=s.checkpoint;p.text(12,342,'SAVE A'+str(c.awning)+'  T '+format(c.time,'.3f')+'  SUN '+format(c.sun_seconds,'.3f'),7)
        if STAGE==5:p.text(250,342,'PAPERS '+str(s.collected.bit_count())+'/'+str(len(spec.items)),11)
        if s.status=='found':p.text(12,358,'FOUND / '+('Z RETURN OR ' if STAGE>=4 else '')+'R RESTART',8)
        elif s.status=='success':p.text(12,358,'SUCCESS / N NEXT GARDEN',11)
        else:p.text(12,358,'MODEL 120 Hz / DRAW 30 FPS / FIXED SIM TIME',7)
        p.text(12,376,'ARROWS MOVE  SPACE RUN  P PAUSE  R RESET  ESC QUIT',6)
        p.text(12,390,'1/2/3 GARDEN'+('  H +2s OUTLINE' if STAGE>=3 else '')+('  Z TIME RETURN' if STAGE>=4 else ''),6)


def parse_args(argv=None):
    """Reject invalid QA flags before importing or initializing Pyxel."""
    import argparse
    from pathlib import Path
    parser=argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless-smoke',action='store_true')
    parser.add_argument('--demo-checkpoint',action='store_true')
    parser.add_argument('--frames',type=int)
    parser.add_argument('--screenshot')
    parser.add_argument('--state-output')
    args=parser.parse_args(argv)
    if not args.headless_smoke and (args.demo_checkpoint or args.frames is not None or args.screenshot or args.state_output):
        parser.error('QA options require --headless-smoke')
    minimum=DEMO_FRAMES if args.demo_checkpoint else 4
    if args.frames is None:args.frames=minimum
    if not minimum<=args.frames<=600:parser.error('--frames must be between '+str(minimum)+' and 600')
    for name,suffix in (('screenshot','.png'),('state_output','.json')):
        value=getattr(args,name)
        if value is not None and (not value.strip() or Path(value).suffix.lower()!=suffix):
            parser.error('--'+name.replace('_','-')+' requires a '+suffix+' file path')
    return args


def main(argv=None):
    args=parse_args(argv)
    from pathlib import Path
    import pyxel as p
    app=App();finished=False
    p.init(448,410,title='017 Moving Shadows',fps=30,display_scale=2,headless=args.headless_smoke)
    keys={k:getattr(p,'KEY_'+k) for k in KEYS}
    def update():
        if args.headless_smoke:
            if args.demo_checkpoint and p.frame_count<DEMO_FRAMES:app.demo_update()
        else:app.control({k for k,v in keys.items() if p.btnp(v)},{k for k,v in keys.items() if p.btn(v)})
    def draw():
        nonlocal finished
        app.draw(p)
        if args.headless_smoke and p.frame_count>=args.frames-1 and not finished:
            finished=True
            for name in ('screenshot','state_output'):
                value=getattr(args,name)
                if value:
                    path=Path(value).resolve();path.parent.mkdir(parents=True,exist_ok=True)
                    if name=='screenshot':p.screenshot(str(path),scale=2)
                    else:path.write_text(json.dumps(asdict(app.state),ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
            p.quit()
    p.run(update,draw)


if __name__=='__main__':main()
STEP 04 / 05

庇へ入った瞬間を保存して、時間ごと戻る

書き換えない記録(スナップショット)・庇に入った瞬間・キーの優先順位。

やり直し用の記録 Snapshot に、位置・時刻・庇の番号・日向にいた合計時間を保存します。スタート地点にも、時刻0の記録があります。保存するのは、移動したあと庇に体全体が入り、しかも1つ前の step の庇番号 awning と違うときだけです。同じ庇で待っている間ずっと保存し続けると、「入った時刻」が上書きされて失われるからです。一度庇の外へ出てから入り直せば、同じ庇でも新しく入ったものとして記録されます。

Z を押すと、位置だけでなく、時刻 time と日向の合計時間 sun_seconds も記録の時点に戻します。時刻で決まる影の向きも一緒に戻るので、同じタイミングからの出発を何度も練習できます。ゲージは0、状態は play、一時停止 paused は False になります。戻る位置は庇の中心とは限りません。半径11の庇に体が収まった最初の小刻み(1 step)の位置が保存されるので、庇の端に近い位置になることもあります。この段階ではまだ落とし物を拾う仕組みがないので、Snapshot には拾った物の記録 collected はありません。

step が入力を調べる順番は、①おかしな dt がないかの検査 → ②R → ③P → ④一時停止中なら何もしない → ⑤Z → ⑥ゲーム終了の状態か dt が0なら止める → ⑦通常の移動、です。そのため、一時停止中に Z を押しても実行されません。R は一時停止中でも、見つかった後でも使えます。frame は、P・R・Z を押した瞬間の入力を4回の step すべてに渡さず、1回だけ渡します。また App は、Z や R のあと、方向キーと SPACE をいったん全部離すまで移動を止めます。やり直した直後に、押しっぱなしのキーで勝手に出発しないようにするためです。

dataclass(frozen=True) は、「一度作った状態は書き換えない」という設計です。replace で必要な項目だけを変えた新しい State を作って返し、Snapshot も、あとから変わらない別の値として持ちます。過去の状態があとの更新で変わってしまわないので、保存・比較・再現に向いています。P で時間を止めるだけの機能と、過去の時刻へ戻る Z の機能の違いを比べてみてください。

下のコードは 04_time_return.py の一部です。前のステップから追加・変更したところだけを抜き出しています。

04_time_return.py / 注目する部分
    if awning>=0 and awning!=s.awning:new=replace(new,checkpoint=Snapshot(x,y,time,awning,new.sun_seconds))

実行して確かめる

教材のZIPを展開したフォルダーで、次のコマンドを実行します。

Windows
.venv\Scripts\python.exe 04_time_return.py
macOS / Linux
.venv/bin/python 04_time_return.py
画面で見ること自動確認は庭2で下の庇 A1 に入り、しばらく待ってから Z を押します。X≈76.234230、Y≈104.084230、T≈1.558333秒に戻り、画面の SAVE A1 の時刻と現在の時刻が一致します。庇の中心 (84,104) へ瞬間移動するわけではありません。保存されていた SUN≈1.383333 も元に戻ります。
  • 記録するのは、庇に入った瞬間だけ
  • Z は時刻も戻すので、影の向きも戻る
  • P・R・Z の優先順と、押しっぱなしの扱いを分ける
つまずきやすいところstep のたびに記録 checkpoint を更新すると、待った後に Z を押しても時刻が戻りません。位置だけを戻して time を今のままにすると、同じ庇にいても影の向きが違う状態になってしまいます。P や Z を4回の step すべてで処理すると、1回押しただけなのに何回も押したことになってしまいます。
少し変えてみよう

庇に入ってから60更新待つと、checkpoint はまったく変わらず、time だけが約2秒増えます。これを test_save_on_entry_not_each_wait で確かめ、続けて Z で保存した時刻に戻します。いったん外へ出て入り直すと、記録が新しい時刻に更新されることは、test_reentry_updates_checkpoint で確かめます。

04_time_return.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(417行)
04_time_return.py / 全文
# 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.

"""日陰の追いかけっこ・教材 4/5。Python 3.11+ / Pyxel 2.9.9。"""
from dataclasses import dataclass, replace, asdict
import math, hashlib, json

DT=1/120
RADIUS=3.0
PERIOD=24.0
SHADOW_LENGTH=36.0
EPS=1e-7



@dataclass(frozen=True)
class Level:
    name:str
    buildings:tuple
    awnings:tuple
    phase:float

LEVELS=(
    Level('一本の影',((64,44,80,64),),((40,88,11),(140,88,11)),math.pi/4),
    Level('つながる渡り廊下',((52,44,68,64),(98,60,114,80)),((28,80,11),(84,104,11),(164,64,11)),7*math.pi/4),
    Level('近道のまぶしさ',((52,44,68,64),(112,40,128,60)),((28,80,11),(88,104,11),(168,100,11)),math.pi/4),
)


@dataclass(frozen=True)
class Input:
    x:int=0
    y:int=0
    run:bool=False
    pause:bool=False
    restart:bool=False
    checkpoint:bool=False

@dataclass(frozen=True)
class Snapshot:
    x:float
    y:float
    time:float
    awning:int
    sun_seconds:float=0.0

@dataclass(frozen=True)
class State:
    level:int
    x:float
    y:float
    time:float=0.0
    status:str="play"
    paused:bool=False
    shade:bool=False
    rotating:bool=False
    exposure:float=0.0
    awning:int=0
    sun_seconds:float=0.0
    checkpoint:Snapshot=None


def initial(level=0,rotating=True):
    x,y,_=LEVELS[level].awnings[0]
    return State(level,x,y,shade=True,rotating=rotating,checkpoint=Snapshot(x,y,0.0,0))


def cross(a,b,c):return (b[0]-a[0])*(c[1]-a[1])-(b[1]-a[1])*(c[0]-a[0])

def convex_hull(points):
    p=sorted(set(points))
    if len(p)<=1:return tuple(p)
    lo=[];hi=[]
    for q in p:
        while len(lo)>1 and cross(lo[-2],lo[-1],q)<=EPS:lo.pop()
        lo.append(q)
    for q in reversed(p):
        while len(hi)>1 and cross(hi[-2],hi[-1],q)<=EPS:hi.pop()
        hi.append(q)
    return tuple(lo[:-1]+hi[:-1])

def shadows(level,time,rotating=True):
    spec=LEVELS[level];a=spec.phase+(2*math.pi*(time%PERIOD)/PERIOD if rotating else 0)
    dx,dy=SHADOW_LENGTH*math.cos(a),SHADOW_LENGTH*math.sin(a)
    result=[]
    for x1,y1,x2,y2 in spec.buildings:
        c=((x1,y1),(x2,y1),(x2,y2),(x1,y2))
        result.append(convex_hull(c+tuple((x+dx,y+dy) for x,y in c)))
    return tuple(result)

def inside(p,poly,strict=False):
    return len(poly)>2 and all(cross(a,b,p)>(EPS if strict else -EPS) for a,b in zip(poly,poly[1:]+poly[:1]))

def distance_segment(p,a,b):
    dx,dy=b[0]-a[0],b[1]-a[1];dd=dx*dx+dy*dy
    t=max(0,min(1,((p[0]-a[0])*dx+(p[1]-a[1])*dy)/dd)) if dd>EPS else 0
    return math.hypot(p[0]-a[0]-t*dx,p[1]-a[1]-t*dy)

def covered_interval(a,b,poly):
    """Parametric portion of AB inside convex polygon, or None."""
    low,high=0.0,1.0
    for c,d in zip(poly,poly[1:]+poly[:1]):
        h0=cross(c,d,a);h1=cross(c,d,b);delta=h1-h0
        if abs(delta)<EPS:
            if h0<-EPS:return None
        elif delta>0:low=max(low,-h0/delta)
        else:high=min(high,-h0/delta)
        if low>high+EPS:return None
    return (max(0,low),min(1,high)) if low<=high else None

def disk_in_union(p,r,polys):
    """Union outer/hole edges, rather than a center-only or 8-point shortcut."""
    if not any(inside(p,poly) for poly in polys):return False
    for i,poly in enumerate(polys):
        for a,b in zip(poly,poly[1:]+poly[:1]):
            cuts=[0.0,1.0]
            for j,other in enumerate(polys):
                if j!=i:
                    interval=covered_interval(a,b,other)
                    if interval:cuts.extend(interval)
            cuts=sorted(set(cuts))
            for u,v in zip(cuts,cuts[1:]):
                if v-u<EPS:continue
                mid=((a[0]+(b[0]-a[0])*(u+v)/2),(a[1]+(b[1]-a[1])*(u+v)/2))
                # Infinitesimal outward coverage, not a finite probe that could
                # jump across narrow gaps. Coincident opposite edges are seams.
                dx,dy=b[0]-a[0],b[1]-a[1];length=math.hypot(dx,dy)
                outward=(dy/length,-dx/length)
                def covers_outside(other):
                    for c,d in zip(other,other[1:]+other[:1]):
                        h=cross(c,d,mid)
                        if h < -EPS:return False
                        derivative=(d[0]-c[0])*outward[1]-(d[1]-c[1])*outward[0]
                        if h <= EPS and derivative < -EPS:return False
                    return True
                if any(covers_outside(other) for j,other in enumerate(polys) if j!=i):continue
                aa=(a[0]+dx*u,a[1]+dy*u);bb=(a[0]+dx*v,a[1]+dy*v)
                if distance_segment(p,aa,bb)<r-EPS:return False
    return True

def awning_at(level,x,y):
    return next((i for i,(ax,ay,r) in enumerate(LEVELS[level].awnings) if math.hypot(x-ax,y-ay)<=r-RADIUS+EPS),-1)

def is_shaded(s):
    return awning_at(s.level,s.x,s.y)>=0 or disk_in_union((s.x,s.y),RADIUS,shadows(s.level,s.time,s.rotating))

def sweep_circle(p,d,rect,r=RADIUS):
    """Earliest rounded-rectangle collision. Tangencies while moving away skip."""
    x,y=p;dx,dy=d;x1,y1,x2,y2=rect;hits=[]
    for edge,lo,hi,axis,normal in ((x1-r,y1,y2,0,(-1,0)),(x2+r,y1,y2,0,(1,0)),(y1-r,x1,x2,1,(0,-1)),(y2+r,x1,x2,1,(0,1))):
        vel=dx if axis==0 else dy;pos=x if axis==0 else y
        if abs(vel)<EPS or dx*normal[0]+dy*normal[1]>=-EPS:continue
        t=(edge-pos)/vel;other=(y+dy*t) if axis==0 else (x+dx*t)
        if -EPS<=t<=1+EPS and lo-EPS<=other<=hi+EPS:hits.append((max(0,t),normal))
    aa=dx*dx+dy*dy
    if aa>EPS:
        for cx,cy,sx,sy in ((x1,y1,-1,-1),(x2,y1,1,-1),(x2,y2,1,1),(x1,y2,-1,1)):
            ox,oy=x-cx,y-cy;bb=2*(ox*dx+oy*dy);cc=ox*ox+oy*oy-r*r;disc=bb*bb-4*aa*cc
            if disc<-EPS:continue
            t=(-bb-math.sqrt(max(0,disc)))/(2*aa)
            if -EPS<=t<=1+EPS:
                hx,hy=x+dx*t-cx,y+dy*t-cy
                if hx*sx>=-EPS and hy*sy>=-EPS and hx*dx+hy*dy<-EPS:hits.append((max(0,t),(hx/r,hy/r)))
    return min(hits,key=lambda h:h[0]) if hits else None

def move(s,dx,dy):
    p=(s.x,s.y);d=(dx,dy)
    for _ in range(4):
        hits=[hit for box in LEVELS[s.level].buildings if (hit:=sweep_circle(p,d,box))]
        if not hits:p=(p[0]+d[0],p[1]+d[1]);break
        t,n=min(hits,key=lambda h:h[0]);p=(p[0]+d[0]*t+n[0]*1e-5,p[1]+d[1]*t+n[1]*1e-5)
        rem=(d[0]*(1-t),d[1]*(1-t));dot=rem[0]*n[0]+rem[1]*n[1]
        d=(rem[0]-min(0,dot)*n[0],rem[1]-min(0,dot)*n[1])
        if math.hypot(*d)<EPS:break
    return max(7,min(185,p[0])),max(23,min(121,p[1]))

def restore(s):
    c=s.checkpoint
    return replace(s,x=c.x,y=c.y,time=c.time,exposure=0,status='play',paused=False,shade=True,awning=c.awning,sun_seconds=c.sun_seconds)

def step(s,inp=Input(),dt=DT):
    if dt<0 or not math.isfinite(dt):raise ValueError('dt must be finite and nonnegative')
    if inp.restart:return initial(s.level,s.rotating)
    if inp.pause:return replace(s,paused=not s.paused)
    if s.paused:return s
    if inp.checkpoint:return restore(s)
    if s.status!='play' or dt==0:return s
    norm=math.hypot(inp.x,inp.y);speed=46 if inp.run else 28
    dx,dy=((inp.x/norm*speed*dt,inp.y/norm*speed*dt) if norm else (0,0))
    x,y=move(s,dx,dy);time=s.time+dt;awning=awning_at(s.level,x,y)
    new=replace(s,x=x,y=y,time=time,awning=awning)
    shade=is_shaded(new)
    exposure=0 if awning>=0 else max(0,min(100,s.exposure+(-35 if shade else 40 if inp.run and norm else 25)*dt))
    new=replace(new,shade=shade,exposure=exposure,sun_seconds=s.sun_seconds+(0 if shade else dt))
    if exposure>=100-EPS:return replace(new,status='found',exposure=100)
    if awning>=0 and awning!=s.awning:new=replace(new,checkpoint=Snapshot(x,y,time,awning,new.sun_seconds))
    return new

def frame(s,inp=Input()):
    """An edge belongs to one render frame, never four physics substeps."""
    if inp.pause or inp.restart or inp.checkpoint:return step(s,inp)
    for _ in range(4):s=step(s,inp)
    return s

def state_hash(s):
    return hashlib.sha256(json.dumps(asdict(s),sort_keys=True,separators=(',',':'),allow_nan=False).encode('utf-8')).hexdigest()

STAGE = 4
DEFAULT_LEVEL = 1
DEMO_FRAMES = 191
KEYS = ('UP','DOWN','LEFT','RIGHT','SPACE','P','R','Z','N','1','2','3','H')

class App:
    def __init__(self):
        self.level=DEFAULT_LEVEL
        self.reset()

    def reset(self):
        self.state=initial(self.level)
        self.locked=False
        self.forecast=False
        self.demo_tick=0
        self.waypoint=0

    def control(self, pressed, held):
        if 'R' in pressed:
            self.reset();self.locked=True;return
        for key in ('1','2','3'):
            if key in pressed:
                self.level=int(key)-1;self.reset();self.locked=True;return
        if 'P' in pressed:
            self.state=frame(self.state,Input(pause=True));return
        if self.state.paused:return
        if STAGE>=4 and 'Z' in pressed:
            self.state=frame(self.state,Input(checkpoint=True));self.locked=True;return
        if STAGE>=3 and 'H' in pressed:
            self.forecast=not self.forecast;return
        if self.locked:
            if not held.intersection(('UP','DOWN','LEFT','RIGHT','SPACE')):self.locked=False
            return
        if self.state.status=='success':
            if 'N' in pressed:
                self.level=(self.level+1)%3;self.reset();self.locked=True
            return
        u=Input(int('RIGHT' in held)-int('LEFT' in held),
                int('DOWN' in held)-int('UP' in held),'SPACE' in held)
        self.state=frame(self.state,u)

    def demo_input(self):
        tick=self.demo_tick;self.demo_tick+=1
        if STAGE==1:
            return Input(1,0,True) if tick<45 else Input(0,-1) if tick<75 else Input()
        if STAGE==2:
            dx,dy=90-self.state.x,75-self.state.y
            if math.hypot(dx,dy)<1:return Input()
            return Input(0 if abs(dx)<.55 else 1 if dx>0 else -1,0 if abs(dy)<.55 else 1 if dy>0 else -1,True)
        if STAGE==3:return Input(1,0,True)
        if STAGE==4:
            if tick==190:return Input(checkpoint=True)
            if tick>=190:return Input()
            route=((28,104),(84,104))
        else:
            if tick<105:return Input()
            route=((86,72),(86,64),(88,86),(138,86),(138,80),(164,64))
        s=self.state
        while self.waypoint<len(route) and math.dist((s.x,s.y),route[self.waypoint])<1:self.waypoint+=1
        if self.waypoint==len(route):return Input()
        tx,ty=route[self.waypoint];dx,dy=tx-s.x,ty-s.y
        return Input(0 if abs(dx)<.55 else 1 if dx>0 else -1,
                     0 if abs(dy)<.55 else 1 if dy>0 else -1,True)

    def demo_update(self):
        # Semantic input to the real update path; not OS-keyboard evidence.
        u=self.demo_input();held=set()
        if u.x:held.add('RIGHT' if u.x>0 else 'LEFT')
        if u.y:held.add('DOWN' if u.y>0 else 'UP')
        if u.run:held.add('SPACE')
        pressed={'Z'} if getattr(u,'checkpoint',False) else set()
        self.control(pressed,held)

    def draw(self,p):
        s=self.state;spec=LEVELS[self.level]
        p.cls(1)
        p.text(12,10,'017 MOVING SHADOWS / STAGE '+str(STAGE),10)
        p.text(12,26,('PAUSED' if s.paused else s.status.upper())+'  GARDEN '+str(self.level+1)+'  T '+format(s.time,'.3f')+' s',7)
        p.text(12,40,'X '+format(s.x,'.2f')+'  Y '+format(s.y,'.2f'),6)
        if STAGE>=3:
            p.text(226,26,'EXPOSURE '+format(s.exposure,'.2f'),10)
            p.rectb(330,24,102,9,7);p.rect(331,25,s.exposure,7,8)
            p.text(226,40,'SUN '+format(s.sun_seconds,'.3f')+' s',6)
        ox,oy=24,46
        def xy(x,y):return ox+x*2,oy+y*2
        def polygon(poly,color,outline=False,dotted=False):
            points=[xy(*v) for v in poly]
            if not outline:
                for j in range(1,len(points)-1):p.tri(*points[0],*points[j],*points[j+1],color)
            else:
                for a,b in zip(points,points[1:]+points[:1]):
                    if dotted:
                        n=max(1,int(math.dist(a,b)/5))
                        for j in range(0,n,2):
                            u=j/n;v=min(j+1,n)/n
                            p.line(a[0]+(b[0]-a[0])*u,a[1]+(b[1]-a[1])*u,a[0]+(b[0]-a[0])*v,a[1]+(b[1]-a[1])*v,color)
                    else:p.line(*a,*b,color)
        p.rect(ox+8,oy+40,368,208,15)
        p.clip(ox+8,oy+40,368,208)
        for x in range(8,188,16):
            for y in range(24,124,16):p.pset(*xy(x,y),9)
        if STAGE>=2:
            for poly in shadows(s.level,s.time,s.rotating):polygon(poly,1);polygon(poly,6,True)
            if STAGE>=3 and self.forecast:
                for poly in shadows(s.level,s.time+2,s.rotating):polygon(poly,13,True,True)
        if STAGE>=3:
            for i,(x,y,r) in enumerate(spec.awnings):
                x,y=xy(x,y);p.circ(x,y,r*2,3);p.circb(x,y,r*2,11);p.circb(x,y,r*2-3,11)
                p.text(x-12,y+26,'EXIT' if STAGE==5 and i==len(spec.awnings)-1 else 'A'+str(i),3)
                if STAGE==5 and i==len(spec.awnings)-1:
                    p.line(x+7,y-7,x+7,y+6,7);p.tri(x+8,y-7,x+17,y-4,x+8,y-1,10)
        for x1,y1,x2,y2 in spec.buildings:
            x,y=xy(x1,y1);p.rect(x,y,(x2-x1)*2,(y2-y1)*2,4);p.rectb(x,y,(x2-x1)*2,(y2-y1)*2,9)
        if STAGE==5:
            for i,(x,y) in enumerate(spec.items):
                if not s.collected & (1<<i):
                    x,y=xy(x,y);p.rect(x-4,y-5,8,10,7);p.line(x-2,y-2,x+2,y-2,13)
        x,y=xy(s.x,s.y);color=11 if getattr(s,'shade',False) else 8
        p.circ(x,y,RADIUS*2,10);p.circb(x,y,RADIUS*2,color);p.pset(x,y,1)
        p.clip()
        if STAGE>=2:
            a=spec.phase+(2*math.pi*(s.time%PERIOD)/PERIOD if s.rotating else 0)
            cx,cy=400,67;p.circb(cx,cy,12,6);p.line(cx,cy,cx+10*math.cos(a),cy+10*math.sin(a),10)
            p.text(12,65,'FULL BODY: '+('SHADE' if s.shade else 'SUN'),11 if s.shade else 8)
        labels={1:'NORMALIZED WALK 28 / RUN 46 / SWEPT WALLS',2:'FIXED SHADOW / UNION BOUNDARY / BODY RADIUS 3',3:'ROTATION 24s / SUN +25 / MOVING RUN +40',4:'AWNING ENTRY SAVES TIME / Z REWINDS',5:'ALL PAPERS + LAST AWNING = SUCCESS'}
        p.text(12,310,labels[STAGE],10)
        if STAGE>=3:p.text(12,326,'SHADE -35/s / AWNING 0 / FOUND AT 100',6)
        else:p.text(12,326,'PRACTICE: NO EXPOSURE OR FAILURE YET',6)
        if STAGE>=4:
            c=s.checkpoint;p.text(12,342,'SAVE A'+str(c.awning)+'  T '+format(c.time,'.3f')+'  SUN '+format(c.sun_seconds,'.3f'),7)
        if STAGE==5:p.text(250,342,'PAPERS '+str(s.collected.bit_count())+'/'+str(len(spec.items)),11)
        if s.status=='found':p.text(12,358,'FOUND / '+('Z RETURN OR ' if STAGE>=4 else '')+'R RESTART',8)
        elif s.status=='success':p.text(12,358,'SUCCESS / N NEXT GARDEN',11)
        else:p.text(12,358,'MODEL 120 Hz / DRAW 30 FPS / FIXED SIM TIME',7)
        p.text(12,376,'ARROWS MOVE  SPACE RUN  P PAUSE  R RESET  ESC QUIT',6)
        p.text(12,390,'1/2/3 GARDEN'+('  H +2s OUTLINE' if STAGE>=3 else '')+('  Z TIME RETURN' if STAGE>=4 else ''),6)


def parse_args(argv=None):
    """Reject invalid QA flags before importing or initializing Pyxel."""
    import argparse
    from pathlib import Path
    parser=argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless-smoke',action='store_true')
    parser.add_argument('--demo-checkpoint',action='store_true')
    parser.add_argument('--frames',type=int)
    parser.add_argument('--screenshot')
    parser.add_argument('--state-output')
    args=parser.parse_args(argv)
    if not args.headless_smoke and (args.demo_checkpoint or args.frames is not None or args.screenshot or args.state_output):
        parser.error('QA options require --headless-smoke')
    minimum=DEMO_FRAMES if args.demo_checkpoint else 4
    if args.frames is None:args.frames=minimum
    if not minimum<=args.frames<=600:parser.error('--frames must be between '+str(minimum)+' and 600')
    for name,suffix in (('screenshot','.png'),('state_output','.json')):
        value=getattr(args,name)
        if value is not None and (not value.strip() or Path(value).suffix.lower()!=suffix):
            parser.error('--'+name.replace('_','-')+' requires a '+suffix+' file path')
    return args


def main(argv=None):
    args=parse_args(argv)
    from pathlib import Path
    import pyxel as p
    app=App();finished=False
    p.init(448,410,title='017 Moving Shadows',fps=30,display_scale=2,headless=args.headless_smoke)
    keys={k:getattr(p,'KEY_'+k) for k in KEYS}
    def update():
        if args.headless_smoke:
            if args.demo_checkpoint and p.frame_count<DEMO_FRAMES:app.demo_update()
        else:app.control({k for k,v in keys.items() if p.btnp(v)},{k for k,v in keys.items() if p.btn(v)})
    def draw():
        nonlocal finished
        app.draw(p)
        if args.headless_smoke and p.frame_count>=args.frames-1 and not finished:
            finished=True
            for name in ('screenshot','state_output'):
                value=getattr(args,name)
                if value:
                    path=Path(value).resolve();path.parent.mkdir(parents=True,exist_ok=True)
                    if name=='screenshot':p.screenshot(str(path),scale=2)
                    else:path.write_text(json.dumps(asdict(app.state),ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
            p.quit()
    p.run(update,draw)


if __name__=='__main__':main()
STEP 05 / 05

落とし物をそろえ、出口まで届ける

拾った物をビットで記録・判定の順番・元のゲームとの照合。

落とし物(紙)と出口を加えて、元のゲームの3つの庭を完成させます。紙は庭1に1枚、庭2と庭3に2枚ずつあります。i 番目の紙を拾った印は 1<<i(1を i 桁ずらした数)で表し、拾った物の記録 collected に |(ビットOR)で加えます。2枚なら、01・10・11 という2進数で「どれを拾ったか」を表せます。一度拾った紙の上をもう一度通っても、数は増えません。全部拾ったときの値は (1<<枚数)-1 です。全部拾って、しかも最後の庇に体全体が収まったときだけ success(成功)になります。

1回の処理の順番は、移動 → 壁 → 太陽 → 日陰 → ゲージ →(見つかったらここで終了)→ 落とし物 → 庇に入った記録 → 出口、です。落とし物は、移動したあとの体の中心から距離5.5以内にあれば拾えます。日向でゲージが100に達した step では、拾うより先に found になります。拾ったのと同じ step で庇に入った場合は、拾った後の記録が保存されます。Z を押すと collected も保存した時点に戻るので、保存のあとに拾った紙は元の場所に戻ります。

拾ったかどうかは、移動したあとの位置だけで調べ、移動の途中は調べません。ふだんの1回の移動は最大でも 46/120≈0.383333px なので問題ありませんが、大きな dt で紙を飛び越えると拾えないことがあります。日が当たっているかの判定も同じで、時間の流れに沿って連続的に積み上げた計算ではありません。B のリプレイ(記録した操作の再生)を DT/2 の細かさで再生しても成功することは確かめましたが、すべての状況での正しさや、どの時刻に出発しても成功することを証明したものではありません。

05 の模型は、元のゲームのすべての関数・クラスと、プログラムの構造(AST)が一致することを確認しています。さらに、7,200回の frame の変化と、3つの庭の保存済みの入力から計算した hash(結果を短くまとめた値)も照合しています。自動デモは B の庇で3.5秒待ってから、作者が用意した道順を走って成功します。同じ道順から、最初の105更新ぶんの待ち時間だけを除くと、途中で見つかってしまいます。これは「この道順・この入力では待つことに意味がある」と確かめる比較で、すぐ出発する道順がすべて不可能だという意味ではありません。成功(success)したあと、N で次の庭へ、R で同じ庭をもう一度遊べます。

下のコードは 05_delivery.py の一部です。前のステップから追加・変更したところだけを抜き出しています。

05_delivery.py / 注目する部分
    for i,(ix,iy) in enumerate(LEVELS[s.level].items):
        if math.hypot(x-ix,y-iy)<=5.5:collected|=1<<i

実行して確かめる

教材のZIPを展開したフォルダーで、次のコマンドを実行します。

Windows
.venv\Scripts\python.exe 05_delivery.py
macOS / Linux
.venv/bin/python 05_delivery.py
画面で見ること自動確認では、庭2で SUCCESS・PAPERS 2/2・EXPOSURE=0 になります。到着は T≈7.233333秒、位置は X≈156.305760、Y≈63.999622 です。成功した時点でゲームの更新は止まります。元のゲームのリプレイでは、庭1が T≈2.508333、庭3が T≈6.241667 で成功します。
  • 同じ紙を二重に数えない
  • 発見 → 拾う → 記録 → 出口の順に調べる
  • 同じ入力での比較と、どんな場合にも成り立つ保証を区別する
つまずきやすいところ紙を拾うたびに collected += 1 とすると、同じ紙を何回も拾えてしまいます。何枚目を拾ったかを、ビットOR で記録します。また、紙を拾う処理を発見の判定より前に動かしたり、Z で拾った記録を戻し忘れたりすると、元のゲームとルールが変わってしまいます。
少し変えてみよう

影が一周する時間 PERIOD だけを 24 から 12 に変えてみましょう。12秒で同じ影の形に戻ることを、test_period_exercise で確かめます。ただし、元の「3.5秒待って同じ道順」がそのまま成功するとは限りません。時間を戻せる Z を使って、出発するタイミングを変えながら比べてみてください。確認したら 24 に戻し、python -B -m unittest -v test_stages.py で、元のリプレイの hash がまた一致することを確かめます。

05_delivery.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(420行)
05_delivery.py / 全文
# 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.

"""日陰の追いかけっこ・教材 5/5。Python 3.11+ / Pyxel 2.9.9。"""
from dataclasses import dataclass, replace, asdict
import math, hashlib, json

DT=1/120
RADIUS=3.0
PERIOD=24.0
SHADOW_LENGTH=36.0
EPS=1e-7

@dataclass(frozen=True)
class Level:
    name:str
    buildings:tuple
    awnings:tuple
    items:tuple
    phase:float

LEVELS=(
    Level('一本の影',((64,44,80,64),),((40,88,11),(140,88,11)),((100,64),),math.pi/4),
    Level('つながる渡り廊下',((52,44,68,64),(98,60,114,80)),((28,80,11),(84,104,11),(164,64,11)),((86,64),(138,80)),7*math.pi/4),
    Level('近道のまぶしさ',((52,44,68,64),(112,40,128,60)),((28,80,11),(88,104,11),(168,100,11)),((92,52),(144,72)),math.pi/4),
)
@dataclass(frozen=True)
class Input:
    x:int=0
    y:int=0
    run:bool=False
    pause:bool=False
    restart:bool=False
    checkpoint:bool=False

@dataclass(frozen=True)
class Snapshot:
    x:float
    y:float
    time:float
    collected:int
    awning:int
    sun_seconds:float=0.0

@dataclass(frozen=True)
class State:
    level:int
    x:float
    y:float
    time:float=0.0
    exposure:float=0.0
    collected:int=0
    status:str='play'
    paused:bool=False
    shade:bool=True
    awning:int=0
    checkpoint:Snapshot=None
    rotating:bool=True
    sun_seconds:float=0.0

def initial(level=0,rotating=True):
    x,y,_=LEVELS[level].awnings[0]
    return State(level,x,y,checkpoint=Snapshot(x,y,0.0,0,0),rotating=rotating)

def cross(a,b,c):return (b[0]-a[0])*(c[1]-a[1])-(b[1]-a[1])*(c[0]-a[0])
def convex_hull(points):
    p=sorted(set(points))
    if len(p)<=1:return tuple(p)
    lo=[];hi=[]
    for q in p:
        while len(lo)>1 and cross(lo[-2],lo[-1],q)<=EPS:lo.pop()
        lo.append(q)
    for q in reversed(p):
        while len(hi)>1 and cross(hi[-2],hi[-1],q)<=EPS:hi.pop()
        hi.append(q)
    return tuple(lo[:-1]+hi[:-1])

def shadows(level,time,rotating=True):
    spec=LEVELS[level];a=spec.phase+(2*math.pi*(time%PERIOD)/PERIOD if rotating else 0)
    dx,dy=SHADOW_LENGTH*math.cos(a),SHADOW_LENGTH*math.sin(a)
    result=[]
    for x1,y1,x2,y2 in spec.buildings:
        c=((x1,y1),(x2,y1),(x2,y2),(x1,y2))
        result.append(convex_hull(c+tuple((x+dx,y+dy) for x,y in c)))
    return tuple(result)

def inside(p,poly,strict=False):
    return len(poly)>2 and all(cross(a,b,p)>(EPS if strict else -EPS) for a,b in zip(poly,poly[1:]+poly[:1]))

def distance_segment(p,a,b):
    dx,dy=b[0]-a[0],b[1]-a[1];dd=dx*dx+dy*dy
    t=max(0,min(1,((p[0]-a[0])*dx+(p[1]-a[1])*dy)/dd)) if dd>EPS else 0
    return math.hypot(p[0]-a[0]-t*dx,p[1]-a[1]-t*dy)

def covered_interval(a,b,poly):
    """Parametric portion of AB inside convex polygon, or None."""
    low,high=0.0,1.0
    for c,d in zip(poly,poly[1:]+poly[:1]):
        h0=cross(c,d,a);h1=cross(c,d,b);delta=h1-h0
        if abs(delta)<EPS:
            if h0<-EPS:return None
        elif delta>0:low=max(low,-h0/delta)
        else:high=min(high,-h0/delta)
        if low>high+EPS:return None
    return (max(0,low),min(1,high)) if low<=high else None

def disk_in_union(p,r,polys):
    """Union outer/hole edges, rather than a center-only or 8-point shortcut."""
    if not any(inside(p,poly) for poly in polys):return False
    for i,poly in enumerate(polys):
        for a,b in zip(poly,poly[1:]+poly[:1]):
            cuts=[0.0,1.0]
            for j,other in enumerate(polys):
                if j!=i:
                    interval=covered_interval(a,b,other)
                    if interval:cuts.extend(interval)
            cuts=sorted(set(cuts))
            for u,v in zip(cuts,cuts[1:]):
                if v-u<EPS:continue
                mid=((a[0]+(b[0]-a[0])*(u+v)/2),(a[1]+(b[1]-a[1])*(u+v)/2))
                # Infinitesimal outward coverage, not a finite probe that could
                # jump across narrow gaps. Coincident opposite edges are seams.
                dx,dy=b[0]-a[0],b[1]-a[1];length=math.hypot(dx,dy)
                outward=(dy/length,-dx/length)
                def covers_outside(other):
                    for c,d in zip(other,other[1:]+other[:1]):
                        h=cross(c,d,mid)
                        if h < -EPS:return False
                        derivative=(d[0]-c[0])*outward[1]-(d[1]-c[1])*outward[0]
                        if h <= EPS and derivative < -EPS:return False
                    return True
                if any(covers_outside(other) for j,other in enumerate(polys) if j!=i):continue
                aa=(a[0]+dx*u,a[1]+dy*u);bb=(a[0]+dx*v,a[1]+dy*v)
                if distance_segment(p,aa,bb)<r-EPS:return False
    return True

def awning_at(level,x,y):
    return next((i for i,(ax,ay,r) in enumerate(LEVELS[level].awnings) if math.hypot(x-ax,y-ay)<=r-RADIUS+EPS),-1)

def is_shaded(s):
    return awning_at(s.level,s.x,s.y)>=0 or disk_in_union((s.x,s.y),RADIUS,shadows(s.level,s.time,s.rotating))

def sweep_circle(p,d,rect,r=RADIUS):
    """Earliest rounded-rectangle collision. Tangencies while moving away skip."""
    x,y=p;dx,dy=d;x1,y1,x2,y2=rect;hits=[]
    for edge,lo,hi,axis,normal in ((x1-r,y1,y2,0,(-1,0)),(x2+r,y1,y2,0,(1,0)),(y1-r,x1,x2,1,(0,-1)),(y2+r,x1,x2,1,(0,1))):
        vel=dx if axis==0 else dy;pos=x if axis==0 else y
        if abs(vel)<EPS or dx*normal[0]+dy*normal[1]>=-EPS:continue
        t=(edge-pos)/vel;other=(y+dy*t) if axis==0 else (x+dx*t)
        if -EPS<=t<=1+EPS and lo-EPS<=other<=hi+EPS:hits.append((max(0,t),normal))
    aa=dx*dx+dy*dy
    if aa>EPS:
        for cx,cy,sx,sy in ((x1,y1,-1,-1),(x2,y1,1,-1),(x2,y2,1,1),(x1,y2,-1,1)):
            ox,oy=x-cx,y-cy;bb=2*(ox*dx+oy*dy);cc=ox*ox+oy*oy-r*r;disc=bb*bb-4*aa*cc
            if disc<-EPS:continue
            t=(-bb-math.sqrt(max(0,disc)))/(2*aa)
            if -EPS<=t<=1+EPS:
                hx,hy=x+dx*t-cx,y+dy*t-cy
                if hx*sx>=-EPS and hy*sy>=-EPS and hx*dx+hy*dy<-EPS:hits.append((max(0,t),(hx/r,hy/r)))
    return min(hits,key=lambda h:h[0]) if hits else None

def move(s,dx,dy):
    p=(s.x,s.y);d=(dx,dy)
    for _ in range(4):
        hits=[hit for box in LEVELS[s.level].buildings if (hit:=sweep_circle(p,d,box))]
        if not hits:p=(p[0]+d[0],p[1]+d[1]);break
        t,n=min(hits,key=lambda h:h[0]);p=(p[0]+d[0]*t+n[0]*1e-5,p[1]+d[1]*t+n[1]*1e-5)
        rem=(d[0]*(1-t),d[1]*(1-t));dot=rem[0]*n[0]+rem[1]*n[1]
        d=(rem[0]-min(0,dot)*n[0],rem[1]-min(0,dot)*n[1])
        if math.hypot(*d)<EPS:break
    return max(7,min(185,p[0])),max(23,min(121,p[1]))

def restore(s):
    c=s.checkpoint
    return replace(s,x=c.x,y=c.y,time=c.time,exposure=0,collected=c.collected,status='play',paused=False,shade=True,awning=c.awning,sun_seconds=c.sun_seconds)

def step(s,inp=Input(),dt=DT):
    if dt<0 or not math.isfinite(dt):raise ValueError('dt must be finite and nonnegative')
    if inp.restart:return initial(s.level,s.rotating)
    if inp.pause:return replace(s,paused=not s.paused)
    if s.paused:return s
    if inp.checkpoint:return restore(s)
    if s.status!='play' or dt==0:return s
    norm=math.hypot(inp.x,inp.y);speed=46 if inp.run else 28
    dx,dy=((inp.x/norm*speed*dt,inp.y/norm*speed*dt) if norm else (0,0))
    x,y=move(s,dx,dy);time=s.time+dt;awning=awning_at(s.level,x,y)
    new=replace(s,x=x,y=y,time=time,awning=awning)
    shade=is_shaded(new)
    exposure=0 if awning>=0 else max(0,min(100,s.exposure+(-35 if shade else 40 if inp.run and norm else 25)*dt))
    new=replace(new,shade=shade,exposure=exposure,sun_seconds=s.sun_seconds+(0 if shade else dt))
    if exposure>=100-EPS:return replace(new,status='found',exposure=100)
    collected=s.collected
    for i,(ix,iy) in enumerate(LEVELS[s.level].items):
        if math.hypot(x-ix,y-iy)<=5.5:collected|=1<<i
    new=replace(new,collected=collected)
    # Save ON ENTRY, not every safe frame: Z restores the phase you arrived at.
    if awning>=0 and awning!=s.awning:new=replace(new,checkpoint=Snapshot(x,y,time,collected,awning,new.sun_seconds))
    if awning==len(LEVELS[s.level].awnings)-1 and collected==(1<<len(LEVELS[s.level].items))-1:new=replace(new,status='success')
    return new

def frame(s,inp=Input()):
    """An edge belongs to one render frame, never four physics substeps."""
    if inp.pause or inp.restart or inp.checkpoint:return step(s,inp)
    for _ in range(4):s=step(s,inp)
    return s

def state_hash(s):
    return hashlib.sha256(json.dumps(asdict(s),sort_keys=True,separators=(',',':'),allow_nan=False).encode('utf-8')).hexdigest()


STAGE = 5
DEFAULT_LEVEL = 1
DEMO_FRAMES = 217
KEYS = ('UP','DOWN','LEFT','RIGHT','SPACE','P','R','Z','N','1','2','3','H')

class App:
    def __init__(self):
        self.level=DEFAULT_LEVEL
        self.reset()

    def reset(self):
        self.state=initial(self.level)
        self.locked=False
        self.forecast=False
        self.demo_tick=0
        self.waypoint=0

    def control(self, pressed, held):
        if 'R' in pressed:
            self.reset();self.locked=True;return
        for key in ('1','2','3'):
            if key in pressed:
                self.level=int(key)-1;self.reset();self.locked=True;return
        if 'P' in pressed:
            self.state=frame(self.state,Input(pause=True));return
        if self.state.paused:return
        if STAGE>=4 and 'Z' in pressed:
            self.state=frame(self.state,Input(checkpoint=True));self.locked=True;return
        if STAGE>=3 and 'H' in pressed:
            self.forecast=not self.forecast;return
        if self.locked:
            if not held.intersection(('UP','DOWN','LEFT','RIGHT','SPACE')):self.locked=False
            return
        if self.state.status=='success':
            if 'N' in pressed:
                self.level=(self.level+1)%3;self.reset();self.locked=True
            return
        u=Input(int('RIGHT' in held)-int('LEFT' in held),
                int('DOWN' in held)-int('UP' in held),'SPACE' in held)
        self.state=frame(self.state,u)

    def demo_input(self):
        tick=self.demo_tick;self.demo_tick+=1
        if STAGE==1:
            return Input(1,0,True) if tick<45 else Input(0,-1) if tick<75 else Input()
        if STAGE==2:
            dx,dy=90-self.state.x,75-self.state.y
            if math.hypot(dx,dy)<1:return Input()
            return Input(0 if abs(dx)<.55 else 1 if dx>0 else -1,0 if abs(dy)<.55 else 1 if dy>0 else -1,True)
        if STAGE==3:return Input(1,0,True)
        if STAGE==4:
            if tick==190:return Input(checkpoint=True)
            if tick>=190:return Input()
            route=((28,104),(84,104))
        else:
            if tick<105:return Input()
            route=((86,72),(86,64),(88,86),(138,86),(138,80),(164,64))
        s=self.state
        while self.waypoint<len(route) and math.dist((s.x,s.y),route[self.waypoint])<1:self.waypoint+=1
        if self.waypoint==len(route):return Input()
        tx,ty=route[self.waypoint];dx,dy=tx-s.x,ty-s.y
        return Input(0 if abs(dx)<.55 else 1 if dx>0 else -1,
                     0 if abs(dy)<.55 else 1 if dy>0 else -1,True)

    def demo_update(self):
        # Semantic input to the real update path; not OS-keyboard evidence.
        u=self.demo_input();held=set()
        if u.x:held.add('RIGHT' if u.x>0 else 'LEFT')
        if u.y:held.add('DOWN' if u.y>0 else 'UP')
        if u.run:held.add('SPACE')
        pressed={'Z'} if getattr(u,'checkpoint',False) else set()
        self.control(pressed,held)

    def draw(self,p):
        s=self.state;spec=LEVELS[self.level]
        p.cls(1)
        p.text(12,10,'017 MOVING SHADOWS / STAGE '+str(STAGE),10)
        p.text(12,26,('PAUSED' if s.paused else s.status.upper())+'  GARDEN '+str(self.level+1)+'  T '+format(s.time,'.3f')+' s',7)
        p.text(12,40,'X '+format(s.x,'.2f')+'  Y '+format(s.y,'.2f'),6)
        if STAGE>=3:
            p.text(226,26,'EXPOSURE '+format(s.exposure,'.2f'),10)
            p.rectb(330,24,102,9,7);p.rect(331,25,s.exposure,7,8)
            p.text(226,40,'SUN '+format(s.sun_seconds,'.3f')+' s',6)
        ox,oy=24,46
        def xy(x,y):return ox+x*2,oy+y*2
        def polygon(poly,color,outline=False,dotted=False):
            points=[xy(*v) for v in poly]
            if not outline:
                for j in range(1,len(points)-1):p.tri(*points[0],*points[j],*points[j+1],color)
            else:
                for a,b in zip(points,points[1:]+points[:1]):
                    if dotted:
                        n=max(1,int(math.dist(a,b)/5))
                        for j in range(0,n,2):
                            u=j/n;v=min(j+1,n)/n
                            p.line(a[0]+(b[0]-a[0])*u,a[1]+(b[1]-a[1])*u,a[0]+(b[0]-a[0])*v,a[1]+(b[1]-a[1])*v,color)
                    else:p.line(*a,*b,color)
        p.rect(ox+8,oy+40,368,208,15)
        p.clip(ox+8,oy+40,368,208)
        for x in range(8,188,16):
            for y in range(24,124,16):p.pset(*xy(x,y),9)
        if STAGE>=2:
            for poly in shadows(s.level,s.time,s.rotating):polygon(poly,1);polygon(poly,6,True)
            if STAGE>=3 and self.forecast:
                for poly in shadows(s.level,s.time+2,s.rotating):polygon(poly,13,True,True)
        if STAGE>=3:
            for i,(x,y,r) in enumerate(spec.awnings):
                x,y=xy(x,y);p.circ(x,y,r*2,3);p.circb(x,y,r*2,11);p.circb(x,y,r*2-3,11)
                p.text(x-12,y+26,'EXIT' if STAGE==5 and i==len(spec.awnings)-1 else 'A'+str(i),3)
                if STAGE==5 and i==len(spec.awnings)-1:
                    p.line(x+7,y-7,x+7,y+6,7);p.tri(x+8,y-7,x+17,y-4,x+8,y-1,10)
        for x1,y1,x2,y2 in spec.buildings:
            x,y=xy(x1,y1);p.rect(x,y,(x2-x1)*2,(y2-y1)*2,4);p.rectb(x,y,(x2-x1)*2,(y2-y1)*2,9)
        if STAGE==5:
            for i,(x,y) in enumerate(spec.items):
                if not s.collected & (1<<i):
                    x,y=xy(x,y);p.rect(x-4,y-5,8,10,7);p.line(x-2,y-2,x+2,y-2,13)
        x,y=xy(s.x,s.y);color=11 if getattr(s,'shade',False) else 8
        p.circ(x,y,RADIUS*2,10);p.circb(x,y,RADIUS*2,color);p.pset(x,y,1)
        p.clip()
        if STAGE>=2:
            a=spec.phase+(2*math.pi*(s.time%PERIOD)/PERIOD if s.rotating else 0)
            cx,cy=400,67;p.circb(cx,cy,12,6);p.line(cx,cy,cx+10*math.cos(a),cy+10*math.sin(a),10)
            p.text(12,65,'FULL BODY: '+('SHADE' if s.shade else 'SUN'),11 if s.shade else 8)
        labels={1:'NORMALIZED WALK 28 / RUN 46 / SWEPT WALLS',2:'FIXED SHADOW / UNION BOUNDARY / BODY RADIUS 3',3:'ROTATION 24s / SUN +25 / MOVING RUN +40',4:'AWNING ENTRY SAVES TIME / Z REWINDS',5:'ALL PAPERS + LAST AWNING = SUCCESS'}
        p.text(12,310,labels[STAGE],10)
        if STAGE>=3:p.text(12,326,'SHADE -35/s / AWNING 0 / FOUND AT 100',6)
        else:p.text(12,326,'PRACTICE: NO EXPOSURE OR FAILURE YET',6)
        if STAGE>=4:
            c=s.checkpoint;p.text(12,342,'SAVE A'+str(c.awning)+'  T '+format(c.time,'.3f')+'  SUN '+format(c.sun_seconds,'.3f'),7)
        if STAGE==5:p.text(250,342,'PAPERS '+str(s.collected.bit_count())+'/'+str(len(spec.items)),11)
        if s.status=='found':p.text(12,358,'FOUND / '+('Z RETURN OR ' if STAGE>=4 else '')+'R RESTART',8)
        elif s.status=='success':p.text(12,358,'SUCCESS / N NEXT GARDEN',11)
        else:p.text(12,358,'MODEL 120 Hz / DRAW 30 FPS / FIXED SIM TIME',7)
        p.text(12,376,'ARROWS MOVE  SPACE RUN  P PAUSE  R RESET  ESC QUIT',6)
        p.text(12,390,'1/2/3 GARDEN'+('  H +2s OUTLINE' if STAGE>=3 else '')+('  Z TIME RETURN' if STAGE>=4 else ''),6)


def parse_args(argv=None):
    """Reject invalid QA flags before importing or initializing Pyxel."""
    import argparse
    from pathlib import Path
    parser=argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless-smoke',action='store_true')
    parser.add_argument('--demo-checkpoint',action='store_true')
    parser.add_argument('--frames',type=int)
    parser.add_argument('--screenshot')
    parser.add_argument('--state-output')
    args=parser.parse_args(argv)
    if not args.headless_smoke and (args.demo_checkpoint or args.frames is not None or args.screenshot or args.state_output):
        parser.error('QA options require --headless-smoke')
    minimum=DEMO_FRAMES if args.demo_checkpoint else 4
    if args.frames is None:args.frames=minimum
    if not minimum<=args.frames<=600:parser.error('--frames must be between '+str(minimum)+' and 600')
    for name,suffix in (('screenshot','.png'),('state_output','.json')):
        value=getattr(args,name)
        if value is not None and (not value.strip() or Path(value).suffix.lower()!=suffix):
            parser.error('--'+name.replace('_','-')+' requires a '+suffix+' file path')
    return args


def main(argv=None):
    args=parse_args(argv)
    from pathlib import Path
    import pyxel as p
    app=App();finished=False
    p.init(448,410,title='017 Moving Shadows',fps=30,display_scale=2,headless=args.headless_smoke)
    keys={k:getattr(p,'KEY_'+k) for k in KEYS}
    def update():
        if args.headless_smoke:
            if args.demo_checkpoint and p.frame_count<DEMO_FRAMES:app.demo_update()
        else:app.control({k for k,v in keys.items() if p.btnp(v)},{k for k,v in keys.items() if p.btn(v)})
    def draw():
        nonlocal finished
        app.draw(p)
        if args.headless_smoke and p.frame_count>=args.frames-1 and not finished:
            finished=True
            for name in ('screenshot','state_output'):
                value=getattr(args,name)
                if value:
                    path=Path(value).resolve();path.parent.mkdir(parents=True,exist_ok=True)
                    if name=='screenshot':p.screenshot(str(path),scale=2)
                    else:path.write_text(json.dumps(asdict(app.state),ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
            p.quit()
    p.run(update,draw)


if __name__=='__main__':main()
AFTER STEP 05

仕組みがわかったら、元の完成版を読んでみよう。

ルールのまとめ:1秒を120回に分けて進める固定刻み(120Hz)、歩き28・走り46(px/s)、体の半径3。影は24秒で一周し、長さは36。影を全部合わせた形が体全体を覆えば安全です。ゲージは日向で毎秒+25、方向入力つきの走りで毎秒+40、影で毎秒-35、庇ですぐ0。100で見つかります。紙を全部拾って最後の庇へ入ればクリア。Z は庇に入った時刻と拾った紙を戻します。

ここまでに作ったのは、元のゲームの仕組みだけを取り出して作り直した「簡略版」です。元のゲームのソースは手を加えずに、別のZIP(元の完成版ソース)に入れてあります。

比べる点5ステップの教材元の完成版
段階01 移動と建物、02 固定した影に体が収まるか、03 回る影・日向の負担・庇、04 時刻ごとの記録、05 元のゲームの全ルール。最初から全ルールがそろった3つの庭。
表示448×410。英数字(ASCII)だけの説明表示(HUD)と、位置の数値・保存時刻。Notoフォントがなくても動きます。384×360。日本語のドット文字(bitmap)、表紙と庭の選択画面、開発用の F12 キー。
操作どの段階でも 1/2/3 で庭を選べます。R・Z・N のあとは、押しっぱなしのキーを無視します。H(影の予告)は03以降。表紙で左右キーを使って庭を選び、T で表紙へ戻ります。
検証用実行動作確認用の限られたQAフラグ(起動時の設定)。止まった最初の画面と自動操作の画面を、PNG画像とJSONで保存します。続けて流れるデモ、比べる元(baseline)と比較(comparison)、録画と専用の記録。
精度の説明円の移動の途中の衝突と、影の縁の計算はそのまま残しています。一方、日向の判定と紙を拾う判定は小刻みの到着点だけで行い、EPS と最大4回の接触という限界をはっきり書いています。現実の光の計算ではなく、影の長さがどこでも同じゲーム用の模型。

完成版のコードで、次に読むところ

  1. model.py の disk_in_union / covered_interval:影に覆われていない縁の求め方
  2. model.py の sweep_circle / move:円の体の移動と、接触4回までの上限
  3. model.py の step / restore / frame:処理の順番と、キーを押した瞬間の扱い
  4. solver.py の follow / PATHS / WAITS:作者が用意した道順と待ち時間
  5. experiment.py:同じ道順で、影が回るかどうかだけを変えた比較
自分のゲームに変えるなら

最初は、数字かルールを1つだけ変えてみましょう。遊ぶ前に「どう変わるか」を予想しておくと、仕組みがよくわかります。慣れてきたら、見た目や音も足してみてください。元のファイルは残しておき、コピーした別のフォルダーで改造すると、いつでも元に戻せます。

教材をダウンロードする

無料で、登録なしでダウンロードできます。ZIPファイルは必ず展開してから、中のファイルを実行してください。元の完成版は、同じZIPに入っている assets フォルダー(画像などの素材)も一緒に使います。

コードと図形はMITライセンス、元の完成版に含まれる日本語の文字データ(Noto由来)はSIL OFL 1.1です。配ったり改造したりするときは、ZIPに入っているLICENSEなどの権利表示を残してください。出典・権利について

検証について

どこまで動作を確認したかを見る

2026年10月5日以降、Linux(Python 3.12・Pyxel 2.9.9)で、教材のコードとゲームのルールを自動テストしています。全500ステップの教材が、実際のPyxelで起動して画面を描けることも確かめました。元の完成版も、自動で遊ぶデモが起動して画面を描き、正常に終わることを確認しています。

この確認は、画面を表示しない自動テストで行ったものです。Windows・Macの実際のパソコンでの操作や、音が出るかどうかはまだ確認していません。人がすべての操作を試したわけでもありません。ブラウザー版の確認範囲は確認状況をご覧ください。

うまく動かないときは:起動やエラーの対処法 / 質問用のメモを作る