アクションLESSON 72腕ごとの覚醒と固定予兆を読むリアルタイムアクション原作035

寝ぼけた巨人の作り方

叩いた腕だけが、次に大きく振ってくる。
腕ごとに「目覚めたか」と「続けて叩けないロック」を持たせ、決まった時点で固定される攻撃の予兆と、実際に攻撃が当たる時刻(刻)を分けて作ります。

完成版の遊び方叩いた腕だけが目を覚まし、攻撃してくる範囲が変わります。両手の鈴をそれぞれ3回叩いて外します。

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

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

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

完成版の主な操作:方向移動、Z一打、P停止、R再開、1〜3庭、M表紙、N次庭、Space開始

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

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

うまく表示されないとき

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

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

腕ごとの記憶と、攻撃の予定を管理する。

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

1. 1秒に30回(30Hz)の更新・斜めの速さをそろえる・半歩ずつの壁の判定
2. 押した瞬間の判定・剣の経過時間・1回の振りで1回だけ当たる
3. 腕ごとの記憶・固定される予兆・左右交互の攻撃の順番
それだけで動く小さなプログラムを、5つのステップで少しずつ大きくしていきます。
必要な環境
Python 3.11以上 / Pyxel 2.9.9
知っておくとよいこと
変数・if・for・関数 / dataclass と deepcopy / 集合、tuple、座標と剰余(割った余り)
教材に入っているもの
ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
完成した教材の操作
矢印:移動。斜めとZ同時押し可、反対方向は相殺 / Z:新しい押下で一振り。長押し連射なし / P:30Hz連続運転と停止を切り替え、全キーを離して再開 / Space:停止中に一刻。矢印/Zを押しながら使える / 1/2/3:05のみ庭A/B/Cを初期化して選択 / R:同じ庭をPAUSEDで初期化。新しい押下を優先 / Esc:終了
難易度の目安
中級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_walk.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

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

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

STEP 01 / 05

庭の座標と、斜めでも同じ歩幅

1秒に30回(30Hz)の更新・斜めの速さをそろえる・半歩ずつの壁の判定。

最初は巨人の攻撃は作らず、庭を歩く1人だけを動かします。起動したときは PAUSED(一時停止)です。Space を1回押すと、ゲームの時計が1刻、つまり続けて動かしたときの1/30秒ぶんだけ進みます。矢印キーを押したまま Space を押せば、その1刻で移動します。Space は離してから押し直します。P を押してキーを全部離すと、1秒に30回(30Hz)で続けて動きます。もう一度 P で止まり、R で同じ庭の最初に戻ります。

座標は左上が0で、右へ行くと x が、下へ行くと y が増えます。画面では2倍の大きさで描きますが、ルールの座標は160×108の庭のままです。最初の位置は (78,78)、1刻の歩幅は1.4です。左右を同時に押すと dx=0、上下を同時に押すと dy=0 です。斜めのときは math.hypot(dx,dy) で長さを測り、方向をその長さで割るので、右だけでも右上でも、進む距離は1.4になります。

移動は x 軸と y 軸を別々に判定し、それぞれ0.7以下の半歩を2回試します。石の角に触れたとき、通れる向きにはすべって進めるようにするためです。人の半径4を含めるので、壁ぞいの中心の座標は x=8〜152、y=8〜100 です。毎回、小数第6位で丸めます。最後の段階まで、この move 関数を変えずに使います。

draw は位置を読んで描くだけで、step が次の状態を作ります。deepcopy(まるごとの写し)を使うので、前の状態は書き換えません。まだ腕、剣、勝ち負けはありません。止まって考えられる操作は教材用に足したもので、元のゲームは始めると時間が流れていくゲームです。

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

01_walk.py / 注目する部分
 length=math.hypot(dx,dy)
 if not length:return x,y
 dx=1.4*dx/length;dy=1.4*dy/length

実行して確かめる

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

Windows
.venv\Scripts\python.exe 01_walk.py
macOS / Linux
.venv/bin/python 01_walk.py
画面で見ること起動したら、左を押しながら Space を10回押します。TICK 10、XY 64.00,78.00 になります。R を押してキーを全部離すと、TICK 0、78.00,78.00、PAUSED に戻ります。
  • ルールの座標と、描くときの拡大率を分ける
  • 斜めの入力も、長さを1にそろえる
  • 移動と描画を、別の関数にする
つまずきやすいところ斜めのとき dx と dy にそれぞれ1.4を足すと、斜めだけ約1.98進んでしまいます。長さをそろえる処理(正規化)は、片方の軸だけでなく両方に使います。画面の2倍の座標を move に入れないでください。
少し変えてみよう

move の中の「dx=1.4*dx/length;dy=1.4*dy/length」にある2つの1.4だけを0.7に変えてみましょう。左+Space を10回押すと x=71.00 になります。変える前の64.00と比べ、確認したら元に戻します。

01_walk.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(213行)
01_walk.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。各ファイルは単独実行できます。"""
from dataclasses import dataclass, field, asdict, replace
from copy import deepcopy
import math, json, hashlib

@dataclass(frozen=True)
class Level:
 name: str
 start: tuple
 stones: tuple=()
 left_sweep: bool=True
LEVELS=(Level('起きた左手',(78.,78.)),Level('横に空いた庭',(80.,78.),((24,54,72,64),(88,54,136,64))),Level('片手を眠らせておく',(80.,86.),((72,20,88,60),)))
@dataclass(frozen=True)
class Input:
 dx: int=0
 dy: int=0
 z: bool=False

@dataclass
class WorldState:
    tick: int = 0
    x: float = 78
    y: float = 78
    facing: tuple = (0, -1)
    status: str = 'playing'
    events: tuple = ()

def initial(level):return WorldState(x=level.start[0],y=level.start[1])

def blocked(l,x,y,r=4):
 if not 4+r<=x<=156-r or not 4+r<=y<=104-r:return True
 return any(a-r<x<b+r and c-r<y<d+r for a,c,b,d in l.stones)

def move(l,x,y,dx,dy):
 length=math.hypot(dx,dy)
 if not length:return x,y
 dx=1.4*dx/length;dy=1.4*dy/length
 # Axis-separated swept movement in <= 0.7px substeps: no thin-stone tunneling.
 for _ in range(2):
  if not blocked(l,x+dx/2,y):x+=dx/2
  if not blocked(l,x,y+dy/2):y+=dy/2
 return round(x,6),round(y,6)

def step(level, old, inp=Input()):
    if old.status != 'playing': return deepcopy(old)
    s = deepcopy(old)
    s.x, s.y = move(level, s.x, s.y, inp.dx, inp.dy)
    if inp.dx or inp.dy:
        n = math.hypot(inp.dx, inp.dy)
        s.facing = (inp.dx/n, inp.dy/n)
    s.tick += 1
    return s


# 教材の操作層。モデル step は描画や Pyxel を知りません。
STAGE = 1

class App:
    def __init__(self):
        self.level_index = 0
        self.reset()

    @property
    def level(self):
        return LEVELS[self.level_index]

    def reset(self):
        self.state = initial(self.level)
        self.paused = True
        self.gated = False

    def control(self, pressed, held):
        # R は矢印や Z と同時でも優先。押し続けた入力を次の回へ持ち越さない。
        if 'R' in pressed:
            self.reset(); self.gated = True
            return
        if self.gated:
            if not pressed and not held: self.gated = False
            return
        if STAGE == 5:
            for i, key in enumerate(('1', '2', '3')):
                if key in pressed:
                    self.level_index = i; self.reset(); self.gated = True
                    return
        if 'P' in pressed:
            self.paused = not self.paused; self.gated = True
            return
        if self.paused and 'SPACE' not in pressed: return
        inp = Input(int('RIGHT' in held)-int('LEFT' in held),
                    int('DOWN' in held)-int('UP' in held), 'Z' in held)
        self.state = step(self.level, self.state, inp)

    def draw(self, p):
        s = self.state
        p.cls(1)
        p.text(12, 10, 'SLEEPY GIANT / STAGE '+str(STAGE)+' / GARDEN '+'ABC'[self.level_index], 10)
        p.text(12, 28, 'TICK '+str(s.tick)+'  '+('PAUSED' if self.paused else 'RUN 30 HZ')+'  '+s.status.upper(), 7)
        p.text(12, 46, 'XY %.2f, %.2f   FACING %.2f, %.2f' % (s.x,s.y,*s.facing), 6)
        ox, oy, scale = 72, 76, 2
        def xy(x,y): return ox+x*scale, oy+y*scale
        p.clip(ox,oy,320,216)
        p.rect(ox,oy,320,216,3); p.rectb(ox+8,oy+8,304,200,11)
        visual = replace(s, tick=max(0,s.tick-1))
        if STAGE >= 3:
            arm, age = slot(visual)
            it = s.intent
            if it and 0 <= age < 54 and s.hands[it.arm].hp > 0:
                color = 8 if age >= 48 else 4
                if it.kind == 'circle':
                    x,y,r=it.region; xx,yy=xy(x,y);p.circ(xx,yy,r*2,color);p.circb(xx,yy,r*2,10)
                else:
                    a,c,b,d=it.region; xx,yy=xy(a,c);p.rect(xx,yy,(b-a)*2,(d-c)*2,color)
                    for y in range(int(c),int(d),4):
                        x1,y1=xy(a,y);x2,y2=xy(b,y);p.line(x1,y1,x2,y2,10)
        for a,c,b,d in self.level.stones:
            xx,yy=xy(a,c);p.rect(xx,yy,(b-a)*2,(d-c)*2,5);p.rectb(xx,yy,(b-a)*2,(d-c)*2,13)
        hx,hy=xy(80,17);p.elli(hx-35,hy-20,70,36,13);p.line(hx-18,hy-4,hx-8,hy-4,1);p.line(hx+8,hy-4,hx+18,hy-4,1)
        if STAGE >= 2:
            for i,((x,y),h) in enumerate(zip(HANDS,s.hands)):
                xx,yy=xy(x,y);p.circ(xx,yy,18,9 if h.hp else 5)
                if getattr(h,'awake',False):p.circ(xx-5,yy-3,3,7)
                else:p.line(xx-9,yy-2,xx-2,yy-2,1)
                if getattr(h,'hit_lock',False):
                    for dy in (-8,0,8):p.line(xx-15,yy+dy,xx+15,yy+dy,7)
                p.text(xx-16,yy-27,'LR'[i]+' HP '+str(h.hp),7)
        xx,yy=xy(s.x,s.y);p.circ(xx,yy,8,12);p.line(xx,yy,xx+s.facing[0]*12,yy+s.facing[1]*12,10)
        if STAGE >= 2 and 6 <= s.sword_age-1 < 12:
            fx,fy=s.facing; corners=[(xx+2*(u*fx-v*fy),yy+2*(u*fy+v*fx)) for u,v in ((0,-8),(18,-8),(18,8),(0,8))]
            for j in range(4):p.line(*corners[j],*corners[(j+1)%4],10)
        p.clip()
        if STAGE >= 3:
            arm, age=slot(visual)
            phase='PREPARE' if arm<0 else 'LR'[arm]+' '+arm_phase(visual,arm).upper()+' AGE '+str(age)
            p.text(12,306,phase,10)
            p.text(12,323,'INTENT '+(s.intent.kind.upper()+' SEEN '+str(s.intent.seen) if s.intent else 'NONE'),6)
        else:p.text(12,306,'PRACTICE: '+('MOVE ONLY' if STAGE==1 else 'SWORD ONLY / NO ENEMY ATTACK OR HIT LOCK'),14)
        if STAGE>=4:p.text(12,340,'YOUR HP '+str(s.hp)+' / INVULNERABLE '+str(s.invuln),11)
        elif STAGE==3:p.text(12,340,'PRACTICE: ENEMY DAMAGE DISABLED',14)
        if STAGE>=2:p.text(12,357,'SWORD AGE '+str(s.sword_age)+' / ID '+str(s.sword_id),7)
        p.text(12,380,'ARROWS: MOVE / P: RUN-PAUSE',7)
        p.text(12,397,'PAUSED SPACE: ONE TICK / R: RESET / ESC: QUIT',7)
        p.text(12,414,'1-3: GARDENS' if STAGE==5 else 'HOLD ARROWS + TAP SPACE TO MEASURE EXACT TICKS',6)

def make_demo_inputs():
    # 実際の初期状態から進める。完成状態を捏造して描きません。
    if STAGE == 1: return [Input(dx=-1) for _ in range(10)]
    result = [Input(dx=-1) for _ in range(21)]
    result += [Input(dy=-1) for _ in range(20)]
    result += [Input(z=True)] + [Input() for _ in range(6)]
    if STAGE >= 3: result += [Input() for _ in range(13)]
    return result

def main(headless=False, screenshot=None, demo=False):
    import pyxel as p
    p.init(464,436,title='Sleepy giant lesson '+str(STAGE),fps=30,display_scale=2,headless=headless)
    app=App(); sequence=make_demo_inputs() if demo else []
    keymap={k:getattr(p,'KEY_'+k) for k in ('LEFT','RIGHT','UP','DOWN','Z','SPACE','P','R','1','2','3')}
    limit=max(12,2*len(sequence)+4)
    def update():
        if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count>=limit:
            if screenshot:app.draw(p);p.screen.save(screenshot,1)
            p.quit();return
        if headless:
            index=p.frame_count//2
            if demo and p.frame_count%2==0 and index<len(sequence):
                inp=sequence[index];held={'SPACE'}
                if inp.dx:held.add('RIGHT' if inp.dx>0 else 'LEFT')
                if inp.dy:held.add('DOWN' if inp.dy>0 else 'UP')
                if inp.z:held.add('Z')
                app.control({'SPACE'},held)
            else:app.control(set(),set())
        else:app.control({k for k,v in keymap.items() if p.btnp(v)}, {k for k,v in keymap.items() if p.btn(v)})
    p.run(update,lambda:app.draw(p))

if __name__ == '__main__':
    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('--screenshot')
    args=parser.parse_args()
    if args.demo_checkpoint and not args.headless_smoke:parser.error('demo requires headless mode')
    if args.screenshot and not args.headless_smoke:parser.error('screenshot requires headless mode')
    if args.screenshot and (Path(args.screenshot).suffix.lower()!='.png' or not Path(args.screenshot).parent.is_dir() or Path(args.screenshot).exists()):parser.error('use a new .png in an existing directory')
    # Pyxel may change cwd during init; preserve the caller's output destination.
    if args.screenshot: args.screenshot = str(Path(args.screenshot).resolve())
    main(args.headless_smoke,args.screenshot,args.demo_checkpoint)
STEP 02 / 05

押した瞬間に始まる、六刻遅れの一打

押した瞬間の判定・剣の経過時間・1回の振りで1回だけ当たる。

2本の腕と剣を加えます。左手は (48,34)、右手は (112,34) にあり、HP はそれぞれ3です。この段階は剣の練習なので、敵は攻撃せず、目覚めも包帯のロックもありません。両手を3回ずつ叩けば WON です。これは元のゲームより簡単な、練習用のルールです。

Z が押されていて、1つ前の刻では押されておらず、剣が待機中なら、一振りを始めます。sword_age=-1 は待機、0〜5は振り始め、6〜11の6刻だけが当たる時間、12〜23は振ったあとのすきです。当たりを調べてから経過時間を1増やすので、画面に AGE 7 と出た刻が、最初に当たる刻です。起動して1刻目で Z を入れた場合、7刻目に初めて当たります。Z を長押ししても一振りだけです。振り終わってから1刻以上 Z なしで進め、次に Z を入れると2回目の振りになります。

剣は、最後に歩いた方向を向きます。腕までの距離を、向きに沿った前方向 forward と、横方向 side に分けます。前に0〜18、左右に8の長方形に、腕の当たりの半径10を足した範囲に入るかを調べます。プレイヤーからの距離だけで判定するわけではないので、背中を向けたまま離れて叩いても届きません。

sword_hits は、この一振りですでに当てた腕の集合です。同じ腕が6刻続けて刃の中にいても、HP を6回減らしません。新しく振り始めるときだけ、空の集合に戻します。Space で1刻ずつ進めているとき、Z を離しただけでは時計は進まないので、次の1刻を Z なしで進めることも忘れないでください。

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

02_sword.py / 注目する部分
    if inp.z and not s.zheld and s.sword_age < 0:
        s.sword_age = 0
        s.sword_id += 1
        s.sword_hits = set()

実行して確かめる

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

Windows
.venv\Scripts\python.exe 02_sword.py
macOS / Linux
.venv/bin/python 02_sword.py
画面で見ること止めたまま、左+Space を21回、上+Space を20回押します。TICK 41、XY 48.60,50.00 で上向きです。Z+Space を1回、Z を離して Space を6回押すと、TICK 48、SWORD AGE 7、左 HP 2、右 HP 3 になります。
  • 長押しと、新しく押したことを区別する
  • 当たる時間と、振ったあとのすきを分ける
  • 一振りの中で、もう当てた腕を覚えておく
つまずきやすいところZ を押した刻に、すぐ当たるわけではありません。剣の経過時間を増やしてから当たりを調べると、当たる時刻が1刻早くなってしまいます。sword_hits を毎刻空にするのも、何度も当たってしまう原因です。
少し変えてみよう

Hand の「hp: int = 3」を「hp: int = 1」に変えてみましょう。上の手順で左の HP は0になります。左右の最初の HP がどちらも1になりますが、左だけを外しても右が残るので、WON にはなりません。確認したら3に戻します。

02_sword.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(249行)
02_sword.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。各ファイルは単独実行できます。"""
from dataclasses import dataclass, field, asdict, replace
from copy import deepcopy
import math, json, hashlib

@dataclass(frozen=True)
class Level:
 name: str
 start: tuple
 stones: tuple=()
 left_sweep: bool=True
LEVELS=(Level('起きた左手',(78.,78.)),Level('横に空いた庭',(80.,78.),((24,54,72,64),(88,54,136,64))),Level('片手を眠らせておく',(80.,86.),((72,20,88,60),)))
@dataclass(frozen=True)
class Input:
 dx: int=0
 dy: int=0
 z: bool=False

@dataclass
class Hand:
    hp: int = 3

@dataclass
class WorldState:
    tick: int = 0
    x: float = 78
    y: float = 78
    facing: tuple = (0, -1)
    hands: list = field(default_factory=lambda: [Hand(), Hand()])
    sword_age: int = -1
    sword_id: int = 0
    sword_hits: set = field(default_factory=set)
    zheld: bool = False
    status: str = 'playing'
    events: tuple = ()

HANDS=((48.,34.),(112.,34.))

def initial(level):return WorldState(x=level.start[0],y=level.start[1])

def blocked(l,x,y,r=4):
 if not 4+r<=x<=156-r or not 4+r<=y<=104-r:return True
 return any(a-r<x<b+r and c-r<y<d+r for a,c,b,d in l.stones)

def move(l,x,y,dx,dy):
 length=math.hypot(dx,dy)
 if not length:return x,y
 dx=1.4*dx/length;dy=1.4*dy/length
 # Axis-separated swept movement in <= 0.7px substeps: no thin-stone tunneling.
 for _ in range(2):
  if not blocked(l,x+dx/2,y):x+=dx/2
  if not blocked(l,x,y+dy/2):y+=dy/2
 return round(x,6),round(y,6)

def sword_reaches(s,i):
 x,y=HANDS[i];dx=x-s.x;dy=y-s.y;fx,fy=s.facing
 forward=dx*fx+dy*fy;side=abs(dx*fy-dy*fx)
 return (forward-min(18,max(0,forward)))**2+max(0,side-8)**2<=100

def step(level, old, inp=Input()):
    if old.status != 'playing': return deepcopy(old)
    s = deepcopy(old)
    ev = []
    s.x, s.y = move(level, s.x, s.y, inp.dx, inp.dy)
    if inp.dx or inp.dy:
        n = math.hypot(inp.dx, inp.dy)
        s.facing = (inp.dx/n, inp.dy/n)
    if inp.z and not s.zheld and s.sword_age < 0:
        s.sword_age = 0
        s.sword_id += 1
        s.sword_hits = set()
        ev.append(('strike', s.sword_id))
    s.zheld = inp.z
    if 6 <= s.sword_age < 12:
        for i, h in enumerate(s.hands):
            if h.hp > 0 and i not in s.sword_hits and sword_reaches(s, i):
                h.hp -= 1
                s.sword_hits.add(i)
                ev.append(('hand_hit', i, h.hp))
    if s.sword_age >= 0:
        s.sword_age += 1
        if s.sword_age >= 24: s.sword_age = -1
    if all(h.hp <= 0 for h in s.hands):
        s.status = 'won'
        ev.append(('won',))
    s.tick += 1
    s.events = tuple(ev)
    return s


# 教材の操作層。モデル step は描画や Pyxel を知りません。
STAGE = 2

class App:
    def __init__(self):
        self.level_index = 0
        self.reset()

    @property
    def level(self):
        return LEVELS[self.level_index]

    def reset(self):
        self.state = initial(self.level)
        self.paused = True
        self.gated = False

    def control(self, pressed, held):
        # R は矢印や Z と同時でも優先。押し続けた入力を次の回へ持ち越さない。
        if 'R' in pressed:
            self.reset(); self.gated = True
            return
        if self.gated:
            if not pressed and not held: self.gated = False
            return
        if STAGE == 5:
            for i, key in enumerate(('1', '2', '3')):
                if key in pressed:
                    self.level_index = i; self.reset(); self.gated = True
                    return
        if 'P' in pressed:
            self.paused = not self.paused; self.gated = True
            return
        if self.paused and 'SPACE' not in pressed: return
        inp = Input(int('RIGHT' in held)-int('LEFT' in held),
                    int('DOWN' in held)-int('UP' in held), 'Z' in held)
        self.state = step(self.level, self.state, inp)

    def draw(self, p):
        s = self.state
        p.cls(1)
        p.text(12, 10, 'SLEEPY GIANT / STAGE '+str(STAGE)+' / GARDEN '+'ABC'[self.level_index], 10)
        p.text(12, 28, 'TICK '+str(s.tick)+'  '+('PAUSED' if self.paused else 'RUN 30 HZ')+'  '+s.status.upper(), 7)
        p.text(12, 46, 'XY %.2f, %.2f   FACING %.2f, %.2f' % (s.x,s.y,*s.facing), 6)
        ox, oy, scale = 72, 76, 2
        def xy(x,y): return ox+x*scale, oy+y*scale
        p.clip(ox,oy,320,216)
        p.rect(ox,oy,320,216,3); p.rectb(ox+8,oy+8,304,200,11)
        visual = replace(s, tick=max(0,s.tick-1))
        if STAGE >= 3:
            arm, age = slot(visual)
            it = s.intent
            if it and 0 <= age < 54 and s.hands[it.arm].hp > 0:
                color = 8 if age >= 48 else 4
                if it.kind == 'circle':
                    x,y,r=it.region; xx,yy=xy(x,y);p.circ(xx,yy,r*2,color);p.circb(xx,yy,r*2,10)
                else:
                    a,c,b,d=it.region; xx,yy=xy(a,c);p.rect(xx,yy,(b-a)*2,(d-c)*2,color)
                    for y in range(int(c),int(d),4):
                        x1,y1=xy(a,y);x2,y2=xy(b,y);p.line(x1,y1,x2,y2,10)
        for a,c,b,d in self.level.stones:
            xx,yy=xy(a,c);p.rect(xx,yy,(b-a)*2,(d-c)*2,5);p.rectb(xx,yy,(b-a)*2,(d-c)*2,13)
        hx,hy=xy(80,17);p.elli(hx-35,hy-20,70,36,13);p.line(hx-18,hy-4,hx-8,hy-4,1);p.line(hx+8,hy-4,hx+18,hy-4,1)
        if STAGE >= 2:
            for i,((x,y),h) in enumerate(zip(HANDS,s.hands)):
                xx,yy=xy(x,y);p.circ(xx,yy,18,9 if h.hp else 5)
                if getattr(h,'awake',False):p.circ(xx-5,yy-3,3,7)
                else:p.line(xx-9,yy-2,xx-2,yy-2,1)
                if getattr(h,'hit_lock',False):
                    for dy in (-8,0,8):p.line(xx-15,yy+dy,xx+15,yy+dy,7)
                p.text(xx-16,yy-27,'LR'[i]+' HP '+str(h.hp),7)
        xx,yy=xy(s.x,s.y);p.circ(xx,yy,8,12);p.line(xx,yy,xx+s.facing[0]*12,yy+s.facing[1]*12,10)
        if STAGE >= 2 and 6 <= s.sword_age-1 < 12:
            fx,fy=s.facing; corners=[(xx+2*(u*fx-v*fy),yy+2*(u*fy+v*fx)) for u,v in ((0,-8),(18,-8),(18,8),(0,8))]
            for j in range(4):p.line(*corners[j],*corners[(j+1)%4],10)
        p.clip()
        if STAGE >= 3:
            arm, age=slot(visual)
            phase='PREPARE' if arm<0 else 'LR'[arm]+' '+arm_phase(visual,arm).upper()+' AGE '+str(age)
            p.text(12,306,phase,10)
            p.text(12,323,'INTENT '+(s.intent.kind.upper()+' SEEN '+str(s.intent.seen) if s.intent else 'NONE'),6)
        else:p.text(12,306,'PRACTICE: '+('MOVE ONLY' if STAGE==1 else 'SWORD ONLY / NO ENEMY ATTACK OR HIT LOCK'),14)
        if STAGE>=4:p.text(12,340,'YOUR HP '+str(s.hp)+' / INVULNERABLE '+str(s.invuln),11)
        elif STAGE==3:p.text(12,340,'PRACTICE: ENEMY DAMAGE DISABLED',14)
        if STAGE>=2:p.text(12,357,'SWORD AGE '+str(s.sword_age)+' / ID '+str(s.sword_id),7)
        p.text(12,380,'ARROWS: MOVE / Z: STRIKE / P: RUN-PAUSE',7)
        p.text(12,397,'PAUSED SPACE: ONE TICK / R: RESET / ESC: QUIT',7)
        p.text(12,414,'1-3: GARDENS' if STAGE==5 else 'HOLD ARROWS + TAP SPACE TO MEASURE EXACT TICKS',6)

def make_demo_inputs():
    # 実際の初期状態から進める。完成状態を捏造して描きません。
    if STAGE == 1: return [Input(dx=-1) for _ in range(10)]
    result = [Input(dx=-1) for _ in range(21)]
    result += [Input(dy=-1) for _ in range(20)]
    result += [Input(z=True)] + [Input() for _ in range(6)]
    if STAGE >= 3: result += [Input() for _ in range(13)]
    return result

def main(headless=False, screenshot=None, demo=False):
    import pyxel as p
    p.init(464,436,title='Sleepy giant lesson '+str(STAGE),fps=30,display_scale=2,headless=headless)
    app=App(); sequence=make_demo_inputs() if demo else []
    keymap={k:getattr(p,'KEY_'+k) for k in ('LEFT','RIGHT','UP','DOWN','Z','SPACE','P','R','1','2','3')}
    limit=max(12,2*len(sequence)+4)
    def update():
        if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count>=limit:
            if screenshot:app.draw(p);p.screen.save(screenshot,1)
            p.quit();return
        if headless:
            index=p.frame_count//2
            if demo and p.frame_count%2==0 and index<len(sequence):
                inp=sequence[index];held={'SPACE'}
                if inp.dx:held.add('RIGHT' if inp.dx>0 else 'LEFT')
                if inp.dy:held.add('DOWN' if inp.dy>0 else 'UP')
                if inp.z:held.add('Z')
                app.control({'SPACE'},held)
            else:app.control(set(),set())
        else:app.control({k for k,v in keymap.items() if p.btnp(v)}, {k for k,v in keymap.items() if p.btn(v)})
    p.run(update,lambda:app.draw(p))

if __name__ == '__main__':
    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('--screenshot')
    args=parser.parse_args()
    if args.demo_checkpoint and not args.headless_smoke:parser.error('demo requires headless mode')
    if args.screenshot and not args.headless_smoke:parser.error('screenshot requires headless mode')
    if args.screenshot and (Path(args.screenshot).suffix.lower()!='.png' or not Path(args.screenshot).parent.is_dir() or Path(args.screenshot).exists()):parser.error('use a new .png in an existing directory')
    # Pyxel may change cwd during init; preserve the caller's output destination.
    if args.screenshot: args.screenshot = str(Path(args.screenshot).resolve())
    main(args.headless_smoke,args.screenshot,args.demo_checkpoint)
STEP 03 / 05

叩いた腕だけ、次の寝返りが大きくなる

腕ごとの記憶・固定される予兆・左右交互の攻撃の順番。

ここで、巨人らしいルールを加えます。剣を当てた腕だけが awake=True(目覚めた)になり、hit_lock=True で包帯が付きます。包帯が付いている間は、何回振っても、その腕の HP は減りません。2本の腕を1つの awake 変数で管理すると、叩いていない腕まで目覚めてしまいます。それぞれの Hand に持たせましょう。

最初の60刻は準備の時間です。そのあとは120刻ずつの「枠」を、左、右、左、右と交互に割り当てます。枠の中の経過時間 0〜47 は予兆、48〜53 は攻撃、54〜95 は戻り、96〜119 は休みです。その腕の枠の中の経過時間 age が54になる刻に、包帯が外れます。予兆と攻撃の間は、その腕を叩けません。戻りの間に叩くと、次の枠まで、またロックされます。片方の腕を外しても、枠を詰めずに安全な120刻を残すのが、元のゲームのルールです。

枠の最初の1回だけ、observe が自分の位置と、その腕が目覚めているかを読み、policy が攻撃の予定 Intent を決めます。眠っている腕は、手を中心にした半径18の円の攻撃です。目覚めた左腕は、そのときのプレイヤーの y±10 を通る横長の帯、目覚めた右腕は x±10 を通る縦長の帯です。予兆の途中で歩いても、帯は追いかけてきません。記憶 Memory にも見た位置を残しますが、元のゲームの policy は、過去の Memory を攻撃の選び方には使っていません。

Intent を決めたすぐあとで、その腕の awake を False に戻します。これは「大振りを予約した」という意味で、決まった Intent が円に戻るわけではありません。この段階では、敵から受けるダメージだけを外しているので、帯の上で待って時計を観察できます。勝ちの条件はありますが、負けはありません。

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

03_awaken.py / 注目する部分
  if s.hands[arm].hp>0:
   s.intent,s.memory=policy(observe(s,arm),s.memory,level);s.hands[arm].awake=False

実行して確かめる

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

Windows
.venv\Scripts\python.exe 03_awaken.py
macOS / Linux
.venv/bin/python 03_awaken.py
画面で見ること02 と同じく TICK 48 の命中まで進め、Space を13回押します。TICK 61 で左の横長の帯が y=40〜60 に固定され、左 HP 2、左に包帯ありです。上+Space を11回押して y=34.60 まで歩いても、SEEN(見た位置)と横長の帯の位置は変わりません。
  • 2本の腕の状態を、別々に持つ
  • 攻撃の枠の最初に、1回だけ見る
  • 包帯は、その腕の戻りが始まるときに外れる
つまずきやすいところpolicy を毎刻呼ぶと、予兆がプレイヤーを追いかける攻撃になってしまいます。また、awake を False にしたあとで攻撃の種類を決めると、せっかく起こした腕まで円の攻撃になります。①見る → ②予兆を決める → ③目覚めを戻す、の順番です。
少し変えてみよう

上の手順で TICK 61 まで進めたあと、動かずに Space を54回押してみましょう。TICK 115 で左の包帯が外れます。この段階はダメージがないので、HP は3のままです。同じ手順を 04 で試す前に、何が変わるか予想してみてください。

03_awaken.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(282行)
03_awaken.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。各ファイルは単独実行できます。"""
"""035: deterministic 30 Hz model. No rendering or hidden-input access."""
from dataclasses import dataclass, field, asdict, replace
from copy import deepcopy
import math, json, hashlib

@dataclass(frozen=True)
class Level:
 name: str
 start: tuple
 stones: tuple=()
 left_sweep: bool=True
LEVELS=(Level('起きた左手',(78.,78.)),Level('横に空いた庭',(80.,78.),((24,54,72,64),(88,54,136,64))),Level('片手を眠らせておく',(80.,86.),((72,20,88,60),)))
@dataclass(frozen=True)
class Observation:
 player: tuple
 arm: int
 awake: bool
@dataclass(frozen=True)
class Memory:
 seen: tuple
@dataclass(frozen=True)
class Intent:
 arm: int
 kind: str
 region: tuple
 seen: tuple
@dataclass
class Hand:
 hp: int=3
 awake: bool=False
 hit_lock: bool=False
@dataclass(frozen=True)
class Input:
 dx: int=0
 dy: int=0
 z: bool=False
@dataclass
class WorldState:
 tick: int=0
 x: float=78
 y: float=78
 hp: int=3
 invuln: int=0
 facing: tuple=(0,-1)
 hands: list=field(default_factory=lambda:[Hand(),Hand()])
 intent: Intent|None=None
 memory: Memory|None=None
 sword_age: int=-1
 sword_id: int=0
 sword_hits: set=field(default_factory=set)
 zheld: bool=False
 status: str='playing'
 events: tuple=()
 attack_hit: bool=False
HANDS=((48.,34.),(112.,34.))
def initial(level):return WorldState(x=level.start[0],y=level.start[1])
def observe(s,arm):return Observation((s.x,s.y),arm,s.hands[arm].awake)
def policy(obs,memory,level):
 kind=('horizontal' if obs.arm==0 else 'vertical') if obs.awake else 'circle'
 if obs.arm==0 and not level.left_sweep:kind='circle'
 region=(4.,obs.player[1]-10,156.,obs.player[1]+10) if kind=='horizontal' else (obs.player[0]-10,4.,obs.player[0]+10,104.) if kind=='vertical' else (*HANDS[obs.arm],18.)
 return Intent(obs.arm,kind,region,obs.player),Memory(obs.player)
def slot(s):return (-1,-1) if s.tick<60 else ((s.tick-60)//120%2,(s.tick-60)%120)
def arm_phase(s,i):
 a,age=slot(s)
 if s.hands[i].hp<=0:return 'gone'
 if a!=i or age<0:return 'rest'
 return 'telegraph' if age<48 else 'active' if age<54 else 'recover' if age<96 else 'rest'
def blocked(l,x,y,r=4):
 if not 4+r<=x<=156-r or not 4+r<=y<=104-r:return True
 return any(a-r<x<b+r and c-r<y<d+r for a,c,b,d in l.stones)
def move(l,x,y,dx,dy):
 length=math.hypot(dx,dy)
 if not length:return x,y
 dx=1.4*dx/length;dy=1.4*dy/length
 # Axis-separated swept movement in <= 0.7px substeps: no thin-stone tunneling.
 for _ in range(2):
  if not blocked(l,x+dx/2,y):x+=dx/2
  if not blocked(l,x,y+dy/2):y+=dy/2
 return round(x,6),round(y,6)
def in_region(intent,x,y,r=4):
 if intent.kind=='circle':a,b,rr=intent.region;return (x-a)**2+(y-b)**2<=(rr+r)**2
 a,c,b,d=intent.region
 return a-r<=x<=b+r and c-r<=y<=d+r

def sword_reaches(s,i):
 x,y=HANDS[i];dx=x-s.x;dy=y-s.y;fx,fy=s.facing
 forward=dx*fx+dy*fy;side=abs(dx*fy-dy*fx)
 return (forward-min(18,max(0,forward)))**2+max(0,side-8)**2<=100

def step(level,old,inp=Input()):
 if old.status!='playing':return deepcopy(old)
 s=deepcopy(old);ev=[]
 arm,age=slot(s)
 if arm>=0 and age==0:
  s.attack_hit=False
  if s.hands[arm].hp>0:
   s.intent,s.memory=policy(observe(s,arm),s.memory,level);s.hands[arm].awake=False
   ev.append(('telegraph',arm,s.intent.kind,s.intent.region))
  else:s.intent=None;ev.append(('safe_slot',arm))
 s.x,s.y=move(level,s.x,s.y,inp.dx,inp.dy)
 if inp.dx or inp.dy:
  n=math.hypot(inp.dx,inp.dy);s.facing=(inp.dx/n,inp.dy/n)
 if inp.z and not s.zheld and s.sword_age<0:
  s.sword_age=0;s.sword_id+=1;s.sword_hits=set();ev.append(('strike',s.sword_id))
 s.zheld=inp.z
 if arm>=0 and age==54:
  s.hands[arm].hit_lock=False;ev.append(('unlock',arm))
 if 6<=s.sword_age<12:
  for i,h in enumerate(s.hands):
   if h.hp>0 and not h.hit_lock and i not in s.sword_hits and arm_phase(s,i) not in ('telegraph','active') and sword_reaches(s,i):
    h.hp-=1;h.awake=True;h.hit_lock=True;s.sword_hits.add(i);ev.append(('hand_hit',i,h.hp))
 if s.sword_age>=0:
  s.sword_age+=1
  if s.sword_age>=24:s.sword_age=-1
 if all(h.hp<=0 for h in s.hands):s.status='won';ev.append(('won',))
 s.tick+=1;s.events=tuple(ev);return s

def state_hash(s):
 d=asdict(s);d['sword_hits']=sorted(s.sword_hits)
 return hashlib.sha256(json.dumps(d,sort_keys=True,ensure_ascii=False,separators=(',',':')).encode('utf-8')).hexdigest()

# 教材の操作層。モデル step は描画や Pyxel を知りません。
STAGE = 3

class App:
    def __init__(self):
        self.level_index = 0
        self.reset()

    @property
    def level(self):
        return LEVELS[self.level_index]

    def reset(self):
        self.state = initial(self.level)
        self.paused = True
        self.gated = False

    def control(self, pressed, held):
        # R は矢印や Z と同時でも優先。押し続けた入力を次の回へ持ち越さない。
        if 'R' in pressed:
            self.reset(); self.gated = True
            return
        if self.gated:
            if not pressed and not held: self.gated = False
            return
        if STAGE == 5:
            for i, key in enumerate(('1', '2', '3')):
                if key in pressed:
                    self.level_index = i; self.reset(); self.gated = True
                    return
        if 'P' in pressed:
            self.paused = not self.paused; self.gated = True
            return
        if self.paused and 'SPACE' not in pressed: return
        inp = Input(int('RIGHT' in held)-int('LEFT' in held),
                    int('DOWN' in held)-int('UP' in held), 'Z' in held)
        self.state = step(self.level, self.state, inp)

    def draw(self, p):
        s = self.state
        p.cls(1)
        p.text(12, 10, 'SLEEPY GIANT / STAGE '+str(STAGE)+' / GARDEN '+'ABC'[self.level_index], 10)
        p.text(12, 28, 'TICK '+str(s.tick)+'  '+('PAUSED' if self.paused else 'RUN 30 HZ')+'  '+s.status.upper(), 7)
        p.text(12, 46, 'XY %.2f, %.2f   FACING %.2f, %.2f' % (s.x,s.y,*s.facing), 6)
        ox, oy, scale = 72, 76, 2
        def xy(x,y): return ox+x*scale, oy+y*scale
        p.clip(ox,oy,320,216)
        p.rect(ox,oy,320,216,3); p.rectb(ox+8,oy+8,304,200,11)
        visual = replace(s, tick=max(0,s.tick-1))
        if STAGE >= 3:
            arm, age = slot(visual)
            it = s.intent
            if it and 0 <= age < 54 and s.hands[it.arm].hp > 0:
                color = 8 if age >= 48 else 4
                if it.kind == 'circle':
                    x,y,r=it.region; xx,yy=xy(x,y);p.circ(xx,yy,r*2,color);p.circb(xx,yy,r*2,10)
                else:
                    a,c,b,d=it.region; xx,yy=xy(a,c);p.rect(xx,yy,(b-a)*2,(d-c)*2,color)
                    for y in range(int(c),int(d),4):
                        x1,y1=xy(a,y);x2,y2=xy(b,y);p.line(x1,y1,x2,y2,10)
        for a,c,b,d in self.level.stones:
            xx,yy=xy(a,c);p.rect(xx,yy,(b-a)*2,(d-c)*2,5);p.rectb(xx,yy,(b-a)*2,(d-c)*2,13)
        hx,hy=xy(80,17);p.elli(hx-35,hy-20,70,36,13);p.line(hx-18,hy-4,hx-8,hy-4,1);p.line(hx+8,hy-4,hx+18,hy-4,1)
        if STAGE >= 2:
            for i,((x,y),h) in enumerate(zip(HANDS,s.hands)):
                xx,yy=xy(x,y);p.circ(xx,yy,18,9 if h.hp else 5)
                if getattr(h,'awake',False):p.circ(xx-5,yy-3,3,7)
                else:p.line(xx-9,yy-2,xx-2,yy-2,1)
                if getattr(h,'hit_lock',False):
                    for dy in (-8,0,8):p.line(xx-15,yy+dy,xx+15,yy+dy,7)
                p.text(xx-16,yy-27,'LR'[i]+' HP '+str(h.hp),7)
        xx,yy=xy(s.x,s.y);p.circ(xx,yy,8,12);p.line(xx,yy,xx+s.facing[0]*12,yy+s.facing[1]*12,10)
        if STAGE >= 2 and 6 <= s.sword_age-1 < 12:
            fx,fy=s.facing; corners=[(xx+2*(u*fx-v*fy),yy+2*(u*fy+v*fx)) for u,v in ((0,-8),(18,-8),(18,8),(0,8))]
            for j in range(4):p.line(*corners[j],*corners[(j+1)%4],10)
        p.clip()
        if STAGE >= 3:
            arm, age=slot(visual)
            phase='PREPARE' if arm<0 else 'LR'[arm]+' '+arm_phase(visual,arm).upper()+' AGE '+str(age)
            p.text(12,306,phase,10)
            p.text(12,323,'INTENT '+(s.intent.kind.upper()+' SEEN '+str(s.intent.seen) if s.intent else 'NONE'),6)
        else:p.text(12,306,'PRACTICE: '+('MOVE ONLY' if STAGE==1 else 'SWORD ONLY / NO ENEMY ATTACK OR HIT LOCK'),14)
        if STAGE>=4:p.text(12,340,'YOUR HP '+str(s.hp)+' / INVULNERABLE '+str(s.invuln),11)
        elif STAGE==3:p.text(12,340,'PRACTICE: ENEMY DAMAGE DISABLED',14)
        if STAGE>=2:p.text(12,357,'SWORD AGE '+str(s.sword_age)+' / ID '+str(s.sword_id),7)
        p.text(12,380,'ARROWS: MOVE / Z: STRIKE / P: RUN-PAUSE',7)
        p.text(12,397,'PAUSED SPACE: ONE TICK / R: RESET / ESC: QUIT',7)
        p.text(12,414,'1-3: GARDENS' if STAGE==5 else 'HOLD ARROWS + TAP SPACE TO MEASURE EXACT TICKS',6)

def make_demo_inputs():
    # 実際の初期状態から進める。完成状態を捏造して描きません。
    if STAGE == 1: return [Input(dx=-1) for _ in range(10)]
    result = [Input(dx=-1) for _ in range(21)]
    result += [Input(dy=-1) for _ in range(20)]
    result += [Input(z=True)] + [Input() for _ in range(6)]
    if STAGE >= 3: result += [Input() for _ in range(13)]
    return result

def main(headless=False, screenshot=None, demo=False):
    import pyxel as p
    p.init(464,436,title='Sleepy giant lesson '+str(STAGE),fps=30,display_scale=2,headless=headless)
    app=App(); sequence=make_demo_inputs() if demo else []
    keymap={k:getattr(p,'KEY_'+k) for k in ('LEFT','RIGHT','UP','DOWN','Z','SPACE','P','R','1','2','3')}
    limit=max(12,2*len(sequence)+4)
    def update():
        if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count>=limit:
            if screenshot:app.draw(p);p.screen.save(screenshot,1)
            p.quit();return
        if headless:
            index=p.frame_count//2
            if demo and p.frame_count%2==0 and index<len(sequence):
                inp=sequence[index];held={'SPACE'}
                if inp.dx:held.add('RIGHT' if inp.dx>0 else 'LEFT')
                if inp.dy:held.add('DOWN' if inp.dy>0 else 'UP')
                if inp.z:held.add('Z')
                app.control({'SPACE'},held)
            else:app.control(set(),set())
        else:app.control({k for k,v in keymap.items() if p.btnp(v)}, {k for k,v in keymap.items() if p.btn(v)})
    p.run(update,lambda:app.draw(p))

if __name__ == '__main__':
    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('--screenshot')
    args=parser.parse_args()
    if args.demo_checkpoint and not args.headless_smoke:parser.error('demo requires headless mode')
    if args.screenshot and not args.headless_smoke:parser.error('screenshot requires headless mode')
    if args.screenshot and (Path(args.screenshot).suffix.lower()!='.png' or not Path(args.screenshot).parent.is_dir() or Path(args.screenshot).exists()):parser.error('use a new .png in an existing directory')
    # Pyxel may change cwd during init; preserve the caller's output destination.
    if args.screenshot: args.screenshot = str(Path(args.screenshot).resolve())
    main(args.headless_smoke,args.screenshot,args.demo_checkpoint)
STEP 04 / 05

固定された危険範囲と、戻り際の好機

当たる時刻・無敵時間・勝ち負けの優先順位。

04 からは、ルールの部分は元のゲームそのものです。攻撃が有効な間に、プレイヤーの半径4が危険な範囲に触れると HP を1失い、30刻の無敵時間がもらえます。帯は絵の端からさらに4まで当たり、円なら半径18+4の22まで当たります。見た目の中心だけを帯の外に出しても足りません。無敵時間は step の最初に1減り、そのあとで当たりを調べます。

attack_hit は、1回の寝返り(攻撃)でダメージを与えたかを記録します。攻撃が6刻続いても、当たるのは1回だけです。無敵の間に触れた場合は attack_hit を立てないので、同じ攻撃の途中で無敵が切れると、当たることがあります。sword_hits は一振りで何度も当たらないため、hit_lock は腕の次の戻りまでのロック、attack_hit は敵の1回の攻撃のため。似た True/False の値でも、続く期間が違います。

処理の順番もルールの一部です。予兆を決める → 移動 → 新しく剣を振り始める → 包帯を外す → 敵の攻撃が当たるか → 剣が当たるか → 剣の経過時間を進める → 勝ち負け → 時計を進める、です。予兆は動く前の位置で決まり、攻撃に当たるかは動いたあとの位置で調べます。自分の HP が0以下なら LOST を先に判定し、そうでなく両手の HP が0なら WON です。終わったあとは、もう進めません。時間切れはないので、安全な場所で待ち続けられます。

左の横長の帯は上下に、右の縦長の帯は左右によけます。左を叩いて包帯が付いたら、すぐに続けて叩くより右へ向かう、または予兆が固定されたら帯から出て戻りを待つ、といった判断が生まれます。この段階の画面は状況 A の庭だけですが、元のゲームの状況 A/B/C の定義と、共通のルールはすべて入っています。

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

04_damage.py / 注目する部分
 if s.hp<=0:s.status='lost';ev.append(('lost',))
 elif all(h.hp<=0 for h in s.hands):s.status='won';ev.append(('won',))

実行して確かめる

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

Windows
.venv\Scripts\python.exe 04_damage.py
macOS / Linux
.venv/bin/python 04_damage.py
画面で見ること03 の TICK 61 から、その場で Space を48回進めると、TICK 109 で HP 2 になります。さらに6回で TICK 115、HP 2 のまま包帯が外れます。R からやり直し、TICK 61 で上+Space を11回、そのあと Space を43回押すと、TICK 115 でも HP 3 のままです。
  • 当たり判定に、プレイヤーの半径も含める
  • 3種類の「重ねて当たらない」仕組みを区別する
  • 同じ刻の中の処理の順番を守る
つまずきやすいところ表示される TICK は、更新が終わったあとの状態です。元のゲームの tick=108 を処理した結果が、画面の TICK 109 です。表示される腕の段階は、直前に処理した刻に合わせています。境目の1刻をずらして読んで、「予兆の間に当たった」と勘違いしないでください。
少し変えてみよう

攻撃をよけた TICK 115、位置 (48.60,34.60) から、下+Space を11回、上+Space を1回押します。TICK 127 で (48.60,48.60)、上向きです。Z+Space を1回、Z なしで Space を6回進めると、TICK 134 で左 HP 1 になります。戻りの間の一撃が通り、また包帯が付きます。

04_damage.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(286行)
04_damage.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。各ファイルは単独実行できます。"""
"""035: deterministic 30 Hz model. No rendering or hidden-input access."""
from dataclasses import dataclass, field, asdict, replace
from copy import deepcopy
import math, json, hashlib

@dataclass(frozen=True)
class Level:
 name: str
 start: tuple
 stones: tuple=()
 left_sweep: bool=True
LEVELS=(Level('起きた左手',(78.,78.)),Level('横に空いた庭',(80.,78.),((24,54,72,64),(88,54,136,64))),Level('片手を眠らせておく',(80.,86.),((72,20,88,60),)))
@dataclass(frozen=True)
class Observation:
 player: tuple
 arm: int
 awake: bool
@dataclass(frozen=True)
class Memory:
 seen: tuple
@dataclass(frozen=True)
class Intent:
 arm: int
 kind: str
 region: tuple
 seen: tuple
@dataclass
class Hand:
 hp: int=3
 awake: bool=False
 hit_lock: bool=False
@dataclass(frozen=True)
class Input:
 dx: int=0
 dy: int=0
 z: bool=False
@dataclass
class WorldState:
 tick: int=0
 x: float=78
 y: float=78
 hp: int=3
 invuln: int=0
 facing: tuple=(0,-1)
 hands: list=field(default_factory=lambda:[Hand(),Hand()])
 intent: Intent|None=None
 memory: Memory|None=None
 sword_age: int=-1
 sword_id: int=0
 sword_hits: set=field(default_factory=set)
 zheld: bool=False
 status: str='playing'
 events: tuple=()
 attack_hit: bool=False
HANDS=((48.,34.),(112.,34.))
def initial(level):return WorldState(x=level.start[0],y=level.start[1])
def observe(s,arm):return Observation((s.x,s.y),arm,s.hands[arm].awake)
def policy(obs,memory,level):
 kind=('horizontal' if obs.arm==0 else 'vertical') if obs.awake else 'circle'
 if obs.arm==0 and not level.left_sweep:kind='circle'
 region=(4.,obs.player[1]-10,156.,obs.player[1]+10) if kind=='horizontal' else (obs.player[0]-10,4.,obs.player[0]+10,104.) if kind=='vertical' else (*HANDS[obs.arm],18.)
 return Intent(obs.arm,kind,region,obs.player),Memory(obs.player)
def slot(s):return (-1,-1) if s.tick<60 else ((s.tick-60)//120%2,(s.tick-60)%120)
def arm_phase(s,i):
 a,age=slot(s)
 if s.hands[i].hp<=0:return 'gone'
 if a!=i or age<0:return 'rest'
 return 'telegraph' if age<48 else 'active' if age<54 else 'recover' if age<96 else 'rest'
def blocked(l,x,y,r=4):
 if not 4+r<=x<=156-r or not 4+r<=y<=104-r:return True
 return any(a-r<x<b+r and c-r<y<d+r for a,c,b,d in l.stones)
def move(l,x,y,dx,dy):
 length=math.hypot(dx,dy)
 if not length:return x,y
 dx=1.4*dx/length;dy=1.4*dy/length
 # Axis-separated swept movement in <= 0.7px substeps: no thin-stone tunneling.
 for _ in range(2):
  if not blocked(l,x+dx/2,y):x+=dx/2
  if not blocked(l,x,y+dy/2):y+=dy/2
 return round(x,6),round(y,6)
def in_region(intent,x,y,r=4):
 if intent.kind=='circle':a,b,rr=intent.region;return (x-a)**2+(y-b)**2<=(rr+r)**2
 a,c,b,d=intent.region
 return a-r<=x<=b+r and c-r<=y<=d+r

def sword_reaches(s,i):
 x,y=HANDS[i];dx=x-s.x;dy=y-s.y;fx,fy=s.facing
 forward=dx*fx+dy*fy;side=abs(dx*fy-dy*fx)
 return (forward-min(18,max(0,forward)))**2+max(0,side-8)**2<=100

def step(level,old,inp=Input()):
 if old.status!='playing':return deepcopy(old)
 s=deepcopy(old);ev=[]
 s.invuln=max(0,s.invuln-1)
 arm,age=slot(s)
 if arm>=0 and age==0:
  s.attack_hit=False
  if s.hands[arm].hp>0:
   s.intent,s.memory=policy(observe(s,arm),s.memory,level);s.hands[arm].awake=False
   ev.append(('telegraph',arm,s.intent.kind,s.intent.region))
  else:s.intent=None;ev.append(('safe_slot',arm))
 s.x,s.y=move(level,s.x,s.y,inp.dx,inp.dy)
 if inp.dx or inp.dy:
  n=math.hypot(inp.dx,inp.dy);s.facing=(inp.dx/n,inp.dy/n)
 if inp.z and not s.zheld and s.sword_age<0:
  s.sword_age=0;s.sword_id+=1;s.sword_hits=set();ev.append(('strike',s.sword_id))
 s.zheld=inp.z
 if arm>=0 and age==54:
  s.hands[arm].hit_lock=False;ev.append(('unlock',arm))
 if s.intent and arm==s.intent.arm and 48<=age<54 and not s.attack_hit and s.hands[arm].hp>0 and in_region(s.intent,s.x,s.y):
  if s.invuln==0:s.hp-=1;s.invuln=30;s.attack_hit=True;ev.append(('hurt',arm,s.intent.kind))
 if 6<=s.sword_age<12:
  for i,h in enumerate(s.hands):
   if h.hp>0 and not h.hit_lock and i not in s.sword_hits and arm_phase(s,i) not in ('telegraph','active') and sword_reaches(s,i):
    h.hp-=1;h.awake=True;h.hit_lock=True;s.sword_hits.add(i);ev.append(('hand_hit',i,h.hp))
 if s.sword_age>=0:
  s.sword_age+=1
  if s.sword_age>=24:s.sword_age=-1
 if s.hp<=0:s.status='lost';ev.append(('lost',))
 elif all(h.hp<=0 for h in s.hands):s.status='won';ev.append(('won',))
 s.tick+=1;s.events=tuple(ev);return s

def state_hash(s):
 d=asdict(s);d['sword_hits']=sorted(s.sword_hits)
 return hashlib.sha256(json.dumps(d,sort_keys=True,ensure_ascii=False,separators=(',',':')).encode('utf-8')).hexdigest()

# 教材の操作層。モデル step は描画や Pyxel を知りません。
STAGE = 4

class App:
    def __init__(self):
        self.level_index = 0
        self.reset()

    @property
    def level(self):
        return LEVELS[self.level_index]

    def reset(self):
        self.state = initial(self.level)
        self.paused = True
        self.gated = False

    def control(self, pressed, held):
        # R は矢印や Z と同時でも優先。押し続けた入力を次の回へ持ち越さない。
        if 'R' in pressed:
            self.reset(); self.gated = True
            return
        if self.gated:
            if not pressed and not held: self.gated = False
            return
        if STAGE == 5:
            for i, key in enumerate(('1', '2', '3')):
                if key in pressed:
                    self.level_index = i; self.reset(); self.gated = True
                    return
        if 'P' in pressed:
            self.paused = not self.paused; self.gated = True
            return
        if self.paused and 'SPACE' not in pressed: return
        inp = Input(int('RIGHT' in held)-int('LEFT' in held),
                    int('DOWN' in held)-int('UP' in held), 'Z' in held)
        self.state = step(self.level, self.state, inp)

    def draw(self, p):
        s = self.state
        p.cls(1)
        p.text(12, 10, 'SLEEPY GIANT / STAGE '+str(STAGE)+' / GARDEN '+'ABC'[self.level_index], 10)
        p.text(12, 28, 'TICK '+str(s.tick)+'  '+('PAUSED' if self.paused else 'RUN 30 HZ')+'  '+s.status.upper(), 7)
        p.text(12, 46, 'XY %.2f, %.2f   FACING %.2f, %.2f' % (s.x,s.y,*s.facing), 6)
        ox, oy, scale = 72, 76, 2
        def xy(x,y): return ox+x*scale, oy+y*scale
        p.clip(ox,oy,320,216)
        p.rect(ox,oy,320,216,3); p.rectb(ox+8,oy+8,304,200,11)
        visual = replace(s, tick=max(0,s.tick-1))
        if STAGE >= 3:
            arm, age = slot(visual)
            it = s.intent
            if it and 0 <= age < 54 and s.hands[it.arm].hp > 0:
                color = 8 if age >= 48 else 4
                if it.kind == 'circle':
                    x,y,r=it.region; xx,yy=xy(x,y);p.circ(xx,yy,r*2,color);p.circb(xx,yy,r*2,10)
                else:
                    a,c,b,d=it.region; xx,yy=xy(a,c);p.rect(xx,yy,(b-a)*2,(d-c)*2,color)
                    for y in range(int(c),int(d),4):
                        x1,y1=xy(a,y);x2,y2=xy(b,y);p.line(x1,y1,x2,y2,10)
        for a,c,b,d in self.level.stones:
            xx,yy=xy(a,c);p.rect(xx,yy,(b-a)*2,(d-c)*2,5);p.rectb(xx,yy,(b-a)*2,(d-c)*2,13)
        hx,hy=xy(80,17);p.elli(hx-35,hy-20,70,36,13);p.line(hx-18,hy-4,hx-8,hy-4,1);p.line(hx+8,hy-4,hx+18,hy-4,1)
        if STAGE >= 2:
            for i,((x,y),h) in enumerate(zip(HANDS,s.hands)):
                xx,yy=xy(x,y);p.circ(xx,yy,18,9 if h.hp else 5)
                if getattr(h,'awake',False):p.circ(xx-5,yy-3,3,7)
                else:p.line(xx-9,yy-2,xx-2,yy-2,1)
                if getattr(h,'hit_lock',False):
                    for dy in (-8,0,8):p.line(xx-15,yy+dy,xx+15,yy+dy,7)
                p.text(xx-16,yy-27,'LR'[i]+' HP '+str(h.hp),7)
        xx,yy=xy(s.x,s.y);p.circ(xx,yy,8,12);p.line(xx,yy,xx+s.facing[0]*12,yy+s.facing[1]*12,10)
        if STAGE >= 2 and 6 <= s.sword_age-1 < 12:
            fx,fy=s.facing; corners=[(xx+2*(u*fx-v*fy),yy+2*(u*fy+v*fx)) for u,v in ((0,-8),(18,-8),(18,8),(0,8))]
            for j in range(4):p.line(*corners[j],*corners[(j+1)%4],10)
        p.clip()
        if STAGE >= 3:
            arm, age=slot(visual)
            phase='PREPARE' if arm<0 else 'LR'[arm]+' '+arm_phase(visual,arm).upper()+' AGE '+str(age)
            p.text(12,306,phase,10)
            p.text(12,323,'INTENT '+(s.intent.kind.upper()+' SEEN '+str(s.intent.seen) if s.intent else 'NONE'),6)
        else:p.text(12,306,'PRACTICE: '+('MOVE ONLY' if STAGE==1 else 'SWORD ONLY / NO ENEMY ATTACK OR HIT LOCK'),14)
        if STAGE>=4:p.text(12,340,'YOUR HP '+str(s.hp)+' / INVULNERABLE '+str(s.invuln),11)
        elif STAGE==3:p.text(12,340,'PRACTICE: ENEMY DAMAGE DISABLED',14)
        if STAGE>=2:p.text(12,357,'SWORD AGE '+str(s.sword_age)+' / ID '+str(s.sword_id),7)
        p.text(12,380,'ARROWS: MOVE / Z: STRIKE / P: RUN-PAUSE',7)
        p.text(12,397,'PAUSED SPACE: ONE TICK / R: RESET / ESC: QUIT',7)
        p.text(12,414,'1-3: GARDENS' if STAGE==5 else 'HOLD ARROWS + TAP SPACE TO MEASURE EXACT TICKS',6)

def make_demo_inputs():
    # 実際の初期状態から進める。完成状態を捏造して描きません。
    if STAGE == 1: return [Input(dx=-1) for _ in range(10)]
    result = [Input(dx=-1) for _ in range(21)]
    result += [Input(dy=-1) for _ in range(20)]
    result += [Input(z=True)] + [Input() for _ in range(6)]
    if STAGE >= 3: result += [Input() for _ in range(13)]
    return result

def main(headless=False, screenshot=None, demo=False):
    import pyxel as p
    p.init(464,436,title='Sleepy giant lesson '+str(STAGE),fps=30,display_scale=2,headless=headless)
    app=App(); sequence=make_demo_inputs() if demo else []
    keymap={k:getattr(p,'KEY_'+k) for k in ('LEFT','RIGHT','UP','DOWN','Z','SPACE','P','R','1','2','3')}
    limit=max(12,2*len(sequence)+4)
    def update():
        if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count>=limit:
            if screenshot:app.draw(p);p.screen.save(screenshot,1)
            p.quit();return
        if headless:
            index=p.frame_count//2
            if demo and p.frame_count%2==0 and index<len(sequence):
                inp=sequence[index];held={'SPACE'}
                if inp.dx:held.add('RIGHT' if inp.dx>0 else 'LEFT')
                if inp.dy:held.add('DOWN' if inp.dy>0 else 'UP')
                if inp.z:held.add('Z')
                app.control({'SPACE'},held)
            else:app.control(set(),set())
        else:app.control({k for k,v in keymap.items() if p.btnp(v)}, {k for k,v in keymap.items() if p.btn(v)})
    p.run(update,lambda:app.draw(p))

if __name__ == '__main__':
    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('--screenshot')
    args=parser.parse_args()
    if args.demo_checkpoint and not args.headless_smoke:parser.error('demo requires headless mode')
    if args.screenshot and not args.headless_smoke:parser.error('screenshot requires headless mode')
    if args.screenshot and (Path(args.screenshot).suffix.lower()!='.png' or not Path(args.screenshot).parent.is_dir() or Path(args.screenshot).exists()):parser.error('use a new .png in an existing directory')
    # Pyxel may change cwd during init; preserve the caller's output destination.
    if args.screenshot: args.screenshot = str(Path(args.screenshot).resolve())
    main(args.headless_smoke,args.screenshot,args.demo_checkpoint)
STEP 05 / 05

三つの庭へ、同じルールを持っていく

庭ごとの定義と共通のルール・続けて動かすことと1刻ずつの観察の両立。

最後は、1・2・3 キーで状況 A/B/C の庭を切り替えられるようにします。切り替えると最初の状態に戻って PAUSED になり、キーが全部離されるまで入力を受け付けません。R も同じです。移動+Z や斜めの移動は、ふつうの同時入力として受け付けます。反対向きどうしは打ち消し合います。P と移動を同時に押したときは一時停止の切り替えを優先し、キーを離してから進むので、意図しない1歩が入りません。

状況 A は障害物なしで、開始は (78,78)。状況 B は開始 (80,78) で、横長の石が左右にあり、真ん中に通り道が残っています。状況 C は開始 (80,86) で、真ん中の縦長の石が上側を分けています。Level が持つ石の長方形を、blocked が読みます。石は移動を止めますが、敵の攻撃や剣をさえぎる計算は、元のゲームにはありません。地形に、元にない意味を勝手に足さないことも、正確に移しかえるための大事な点です。

このファイルの Level、WorldState、policy、move、step などのルールの定義は、元のゲームの model.py と、プログラムの構造(構文木)で一致させています。元のゲームに保存された9つの勝ちの道順、3つの庭 ×「左の大振りあり/なし」の決まった長さの入力の列、境目を試すために作った状態で確かめています。限られた数のテストなので、すべての入力を証明したわけではありません。01/02 ははっきり示した練習用のルール、03 は敵のダメージなし、04/05 が完成したルールです。

教材は英数字と図形の表示で、元のゲームの日本語の文字、表紙、メニュー、プレイ記録の保存は省いています。P で続けて動かすときは30Hzです。Space での1刻ずつの進行は、教材だけの観察用の道具です。まず1刻ずつ、帯が固定されることと包帯が外れることを確かめ、次に P で、同じ判断を実際の時間の流れの中で試しましょう。自動入力と実際の Pyxel の描画の検査は済ませていますが、パソコンからの実際のキー入力、人にとっての学びやすさや楽しさ、音の聞こえ方は、検査済みとは言えません。

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

05_gardens.py / 注目する部分
        if STAGE == 5:
            for i, key in enumerate(('1', '2', '3')):
                if key in pressed:
                    self.level_index = i; self.reset(); self.gated = True
                    return

実行して確かめる

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

Windows
.venv\Scripts\python.exe 05_gardens.py
macOS / Linux
.venv/bin/python 05_gardens.py
画面で見ること2 を押して離し、上+Space を40回押します。状況 B の真ん中の通り道を通り、TICK 40、XY 80.00,22.00 になります。R で戻ってキーを全部離すと、状況 B の (80,78) です。3 を押して離し、上+Space を40回押すと、状況 C の石で止まり、XY 80.00,64.30 です。
  • 最初からやり直すときは、状態をまるごと入れ替える
  • 石は、移動だけをさまたげる
  • 1刻ずつの試験と、続けて動かす試験を区別する
つまずきやすいところ庭を切り替えるときに s.x と s.y だけを戻すと、剣や包帯や HP が前の庭から残ってしまいます。initial(level) で、状態をまるごと新しく作ります。石を見て「攻撃もさえぎられるはず」と思いこむのは、元のゲームにないルールを足すことになります。
少し変えてみよう

状況 B で R のあと、左+Space を23回、上+Space を40回押してみましょう。左右の石の下で止まり、XY 47.80,68.20 になります。真ん中を上がれた 80.00,22.00 と比べ、blocked が人の半径4も石に足している場所を探してみましょう。最後に P で続けて動かし、止めて1刻ずつ進めたときと、同じ速さ・向きの判断になるか確かめてください。

05_gardens.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(286行)
05_gardens.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。各ファイルは単独実行できます。"""
"""035: deterministic 30 Hz model. No rendering or hidden-input access."""
from dataclasses import dataclass, field, asdict, replace
from copy import deepcopy
import math, json, hashlib

@dataclass(frozen=True)
class Level:
 name: str
 start: tuple
 stones: tuple=()
 left_sweep: bool=True
LEVELS=(Level('起きた左手',(78.,78.)),Level('横に空いた庭',(80.,78.),((24,54,72,64),(88,54,136,64))),Level('片手を眠らせておく',(80.,86.),((72,20,88,60),)))
@dataclass(frozen=True)
class Observation:
 player: tuple
 arm: int
 awake: bool
@dataclass(frozen=True)
class Memory:
 seen: tuple
@dataclass(frozen=True)
class Intent:
 arm: int
 kind: str
 region: tuple
 seen: tuple
@dataclass
class Hand:
 hp: int=3
 awake: bool=False
 hit_lock: bool=False
@dataclass(frozen=True)
class Input:
 dx: int=0
 dy: int=0
 z: bool=False
@dataclass
class WorldState:
 tick: int=0
 x: float=78
 y: float=78
 hp: int=3
 invuln: int=0
 facing: tuple=(0,-1)
 hands: list=field(default_factory=lambda:[Hand(),Hand()])
 intent: Intent|None=None
 memory: Memory|None=None
 sword_age: int=-1
 sword_id: int=0
 sword_hits: set=field(default_factory=set)
 zheld: bool=False
 status: str='playing'
 events: tuple=()
 attack_hit: bool=False
HANDS=((48.,34.),(112.,34.))
def initial(level):return WorldState(x=level.start[0],y=level.start[1])
def observe(s,arm):return Observation((s.x,s.y),arm,s.hands[arm].awake)
def policy(obs,memory,level):
 kind=('horizontal' if obs.arm==0 else 'vertical') if obs.awake else 'circle'
 if obs.arm==0 and not level.left_sweep:kind='circle'
 region=(4.,obs.player[1]-10,156.,obs.player[1]+10) if kind=='horizontal' else (obs.player[0]-10,4.,obs.player[0]+10,104.) if kind=='vertical' else (*HANDS[obs.arm],18.)
 return Intent(obs.arm,kind,region,obs.player),Memory(obs.player)
def slot(s):return (-1,-1) if s.tick<60 else ((s.tick-60)//120%2,(s.tick-60)%120)
def arm_phase(s,i):
 a,age=slot(s)
 if s.hands[i].hp<=0:return 'gone'
 if a!=i or age<0:return 'rest'
 return 'telegraph' if age<48 else 'active' if age<54 else 'recover' if age<96 else 'rest'
def blocked(l,x,y,r=4):
 if not 4+r<=x<=156-r or not 4+r<=y<=104-r:return True
 return any(a-r<x<b+r and c-r<y<d+r for a,c,b,d in l.stones)
def move(l,x,y,dx,dy):
 length=math.hypot(dx,dy)
 if not length:return x,y
 dx=1.4*dx/length;dy=1.4*dy/length
 # Axis-separated swept movement in <= 0.7px substeps: no thin-stone tunneling.
 for _ in range(2):
  if not blocked(l,x+dx/2,y):x+=dx/2
  if not blocked(l,x,y+dy/2):y+=dy/2
 return round(x,6),round(y,6)
def in_region(intent,x,y,r=4):
 if intent.kind=='circle':a,b,rr=intent.region;return (x-a)**2+(y-b)**2<=(rr+r)**2
 a,c,b,d=intent.region
 return a-r<=x<=b+r and c-r<=y<=d+r

def sword_reaches(s,i):
 x,y=HANDS[i];dx=x-s.x;dy=y-s.y;fx,fy=s.facing
 forward=dx*fx+dy*fy;side=abs(dx*fy-dy*fx)
 return (forward-min(18,max(0,forward)))**2+max(0,side-8)**2<=100

def step(level,old,inp=Input()):
 if old.status!='playing':return deepcopy(old)
 s=deepcopy(old);ev=[]
 s.invuln=max(0,s.invuln-1)
 arm,age=slot(s)
 if arm>=0 and age==0:
  s.attack_hit=False
  if s.hands[arm].hp>0:
   s.intent,s.memory=policy(observe(s,arm),s.memory,level);s.hands[arm].awake=False
   ev.append(('telegraph',arm,s.intent.kind,s.intent.region))
  else:s.intent=None;ev.append(('safe_slot',arm))
 s.x,s.y=move(level,s.x,s.y,inp.dx,inp.dy)
 if inp.dx or inp.dy:
  n=math.hypot(inp.dx,inp.dy);s.facing=(inp.dx/n,inp.dy/n)
 if inp.z and not s.zheld and s.sword_age<0:
  s.sword_age=0;s.sword_id+=1;s.sword_hits=set();ev.append(('strike',s.sword_id))
 s.zheld=inp.z
 if arm>=0 and age==54:
  s.hands[arm].hit_lock=False;ev.append(('unlock',arm))
 if s.intent and arm==s.intent.arm and 48<=age<54 and not s.attack_hit and s.hands[arm].hp>0 and in_region(s.intent,s.x,s.y):
  if s.invuln==0:s.hp-=1;s.invuln=30;s.attack_hit=True;ev.append(('hurt',arm,s.intent.kind))
 if 6<=s.sword_age<12:
  for i,h in enumerate(s.hands):
   if h.hp>0 and not h.hit_lock and i not in s.sword_hits and arm_phase(s,i) not in ('telegraph','active') and sword_reaches(s,i):
    h.hp-=1;h.awake=True;h.hit_lock=True;s.sword_hits.add(i);ev.append(('hand_hit',i,h.hp))
 if s.sword_age>=0:
  s.sword_age+=1
  if s.sword_age>=24:s.sword_age=-1
 if s.hp<=0:s.status='lost';ev.append(('lost',))
 elif all(h.hp<=0 for h in s.hands):s.status='won';ev.append(('won',))
 s.tick+=1;s.events=tuple(ev);return s

def state_hash(s):
 d=asdict(s);d['sword_hits']=sorted(s.sword_hits)
 return hashlib.sha256(json.dumps(d,sort_keys=True,ensure_ascii=False,separators=(',',':')).encode('utf-8')).hexdigest()

# 教材の操作層。モデル step は描画や Pyxel を知りません。
STAGE = 5

class App:
    def __init__(self):
        self.level_index = 0
        self.reset()

    @property
    def level(self):
        return LEVELS[self.level_index]

    def reset(self):
        self.state = initial(self.level)
        self.paused = True
        self.gated = False

    def control(self, pressed, held):
        # R は矢印や Z と同時でも優先。押し続けた入力を次の回へ持ち越さない。
        if 'R' in pressed:
            self.reset(); self.gated = True
            return
        if self.gated:
            if not pressed and not held: self.gated = False
            return
        if STAGE == 5:
            for i, key in enumerate(('1', '2', '3')):
                if key in pressed:
                    self.level_index = i; self.reset(); self.gated = True
                    return
        if 'P' in pressed:
            self.paused = not self.paused; self.gated = True
            return
        if self.paused and 'SPACE' not in pressed: return
        inp = Input(int('RIGHT' in held)-int('LEFT' in held),
                    int('DOWN' in held)-int('UP' in held), 'Z' in held)
        self.state = step(self.level, self.state, inp)

    def draw(self, p):
        s = self.state
        p.cls(1)
        p.text(12, 10, 'SLEEPY GIANT / STAGE '+str(STAGE)+' / GARDEN '+'ABC'[self.level_index], 10)
        p.text(12, 28, 'TICK '+str(s.tick)+'  '+('PAUSED' if self.paused else 'RUN 30 HZ')+'  '+s.status.upper(), 7)
        p.text(12, 46, 'XY %.2f, %.2f   FACING %.2f, %.2f' % (s.x,s.y,*s.facing), 6)
        ox, oy, scale = 72, 76, 2
        def xy(x,y): return ox+x*scale, oy+y*scale
        p.clip(ox,oy,320,216)
        p.rect(ox,oy,320,216,3); p.rectb(ox+8,oy+8,304,200,11)
        visual = replace(s, tick=max(0,s.tick-1))
        if STAGE >= 3:
            arm, age = slot(visual)
            it = s.intent
            if it and 0 <= age < 54 and s.hands[it.arm].hp > 0:
                color = 8 if age >= 48 else 4
                if it.kind == 'circle':
                    x,y,r=it.region; xx,yy=xy(x,y);p.circ(xx,yy,r*2,color);p.circb(xx,yy,r*2,10)
                else:
                    a,c,b,d=it.region; xx,yy=xy(a,c);p.rect(xx,yy,(b-a)*2,(d-c)*2,color)
                    for y in range(int(c),int(d),4):
                        x1,y1=xy(a,y);x2,y2=xy(b,y);p.line(x1,y1,x2,y2,10)
        for a,c,b,d in self.level.stones:
            xx,yy=xy(a,c);p.rect(xx,yy,(b-a)*2,(d-c)*2,5);p.rectb(xx,yy,(b-a)*2,(d-c)*2,13)
        hx,hy=xy(80,17);p.elli(hx-35,hy-20,70,36,13);p.line(hx-18,hy-4,hx-8,hy-4,1);p.line(hx+8,hy-4,hx+18,hy-4,1)
        if STAGE >= 2:
            for i,((x,y),h) in enumerate(zip(HANDS,s.hands)):
                xx,yy=xy(x,y);p.circ(xx,yy,18,9 if h.hp else 5)
                if getattr(h,'awake',False):p.circ(xx-5,yy-3,3,7)
                else:p.line(xx-9,yy-2,xx-2,yy-2,1)
                if getattr(h,'hit_lock',False):
                    for dy in (-8,0,8):p.line(xx-15,yy+dy,xx+15,yy+dy,7)
                p.text(xx-16,yy-27,'LR'[i]+' HP '+str(h.hp),7)
        xx,yy=xy(s.x,s.y);p.circ(xx,yy,8,12);p.line(xx,yy,xx+s.facing[0]*12,yy+s.facing[1]*12,10)
        if STAGE >= 2 and 6 <= s.sword_age-1 < 12:
            fx,fy=s.facing; corners=[(xx+2*(u*fx-v*fy),yy+2*(u*fy+v*fx)) for u,v in ((0,-8),(18,-8),(18,8),(0,8))]
            for j in range(4):p.line(*corners[j],*corners[(j+1)%4],10)
        p.clip()
        if STAGE >= 3:
            arm, age=slot(visual)
            phase='PREPARE' if arm<0 else 'LR'[arm]+' '+arm_phase(visual,arm).upper()+' AGE '+str(age)
            p.text(12,306,phase,10)
            p.text(12,323,'INTENT '+(s.intent.kind.upper()+' SEEN '+str(s.intent.seen) if s.intent else 'NONE'),6)
        else:p.text(12,306,'PRACTICE: '+('MOVE ONLY' if STAGE==1 else 'SWORD ONLY / NO ENEMY ATTACK OR HIT LOCK'),14)
        if STAGE>=4:p.text(12,340,'YOUR HP '+str(s.hp)+' / INVULNERABLE '+str(s.invuln),11)
        elif STAGE==3:p.text(12,340,'PRACTICE: ENEMY DAMAGE DISABLED',14)
        if STAGE>=2:p.text(12,357,'SWORD AGE '+str(s.sword_age)+' / ID '+str(s.sword_id),7)
        p.text(12,380,'ARROWS: MOVE / Z: STRIKE / P: RUN-PAUSE',7)
        p.text(12,397,'PAUSED SPACE: ONE TICK / R: RESET / ESC: QUIT',7)
        p.text(12,414,'1-3: GARDENS' if STAGE==5 else 'HOLD ARROWS + TAP SPACE TO MEASURE EXACT TICKS',6)

def make_demo_inputs():
    # 実際の初期状態から進める。完成状態を捏造して描きません。
    if STAGE == 1: return [Input(dx=-1) for _ in range(10)]
    result = [Input(dx=-1) for _ in range(21)]
    result += [Input(dy=-1) for _ in range(20)]
    result += [Input(z=True)] + [Input() for _ in range(6)]
    if STAGE >= 3: result += [Input() for _ in range(13)]
    return result

def main(headless=False, screenshot=None, demo=False):
    import pyxel as p
    p.init(464,436,title='Sleepy giant lesson '+str(STAGE),fps=30,display_scale=2,headless=headless)
    app=App(); sequence=make_demo_inputs() if demo else []
    keymap={k:getattr(p,'KEY_'+k) for k in ('LEFT','RIGHT','UP','DOWN','Z','SPACE','P','R','1','2','3')}
    limit=max(12,2*len(sequence)+4)
    def update():
        if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count>=limit:
            if screenshot:app.draw(p);p.screen.save(screenshot,1)
            p.quit();return
        if headless:
            index=p.frame_count//2
            if demo and p.frame_count%2==0 and index<len(sequence):
                inp=sequence[index];held={'SPACE'}
                if inp.dx:held.add('RIGHT' if inp.dx>0 else 'LEFT')
                if inp.dy:held.add('DOWN' if inp.dy>0 else 'UP')
                if inp.z:held.add('Z')
                app.control({'SPACE'},held)
            else:app.control(set(),set())
        else:app.control({k for k,v in keymap.items() if p.btnp(v)}, {k for k,v in keymap.items() if p.btn(v)})
    p.run(update,lambda:app.draw(p))

if __name__ == '__main__':
    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('--screenshot')
    args=parser.parse_args()
    if args.demo_checkpoint and not args.headless_smoke:parser.error('demo requires headless mode')
    if args.screenshot and not args.headless_smoke:parser.error('screenshot requires headless mode')
    if args.screenshot and (Path(args.screenshot).suffix.lower()!='.png' or not Path(args.screenshot).parent.is_dir() or Path(args.screenshot).exists()):parser.error('use a new .png in an existing directory')
    # Pyxel may change cwd during init; preserve the caller's output destination.
    if args.screenshot: args.screenshot = str(Path(args.screenshot).resolve())
    main(args.headless_smoke,args.screenshot,args.demo_checkpoint)
AFTER STEP 05

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

ルールのまとめ:それぞれの手を3回叩きます。叩いた手だけが目覚め、次の攻撃は、左が横長の帯、右が縦長の帯になります。目覚めていない手は、手を中心にした円。60刻の準備のあと、120刻ずつ左右交互の枠。予兆48刻、攻撃6刻、戻り42刻、休み24刻。枠の54刻目で、その手のロックが外れます。予兆と攻撃の間は手を叩けず、両手の HP が0で勝ち、自分の HP が0なら(優先して)負け。時間制限なし。

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

比べる点5ステップの教材元の完成版
段階01 は移動だけ。02 は剣だけで、敵の攻撃・目覚め・腕のロックはなし。03 は予兆・目覚め・腕のロックあり、敵のダメージなし。04/05 は元のゲームのルール全部。最初から、すべての戦いのルールと3つの庭があります。
操作と画面英数字と図形の画面。起動時とやり直し時は一時停止で、Space で1刻ずつ進める機能を追加。01〜04 の画面は状況 A だけ、05 で3つの庭。日本語の画面、Space で開始、P で一時停止、1〜3 で選択、M で表紙、N で次の庭、記録と比較モード。
検査と権利元のゲームのルールのプログラム構造(AST)と、決まった長さの参照用の記録で照合し、実際の Pyxel で自動描画しています。Noto の文字と音は使っていません。元のゲームの LICENSE と、参照する Noto の権利の文書は、原文のまま残しています。Noto フォントの文字を使っています。元のゲームの過去の動作確認(QA)と、教材の今回の確認は別のものです。

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

  1. model.py の observe / policy:決まった攻撃の予定(Intent)
  2. model.py の slot / arm_phase:左右交互の攻撃の枠
  3. model.py の sword_reaches と、3つのロック
  4. app.py の ReleaseGate(キーが離されるのを待つ仕組み)と、一時停止・表紙・庭の切り替え
  5. solver.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の実際のパソコンでの操作や、音が出るかどうかはまだ確認していません。人がすべての操作を試したわけでもありません。ブラウザー版の確認範囲は確認状況をご覧ください。

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