戦略・対戦LESSON 77殻の所有権と固定tickの同時移動を読む陣取り原作037

引っ越しカニ合戦の作り方

殻から出ても、陣地は残る。
殻の「持ち主」と「今入っているカニ」を分けて持ち、陸づたいの距離で、どちらがどの餌を取れるかを計算します。2匹は前の盤面から同時に動き、満潮のたびに点数をつけます。

完成版の遊び方殻の広さと動く速さを比べ、脱いだ殻も拠点として残しながら、3回の満潮で餌を取り合います。

引っ越しカニ合戦の元の完成版。自動操作によるPyxelの実描画画面。
元の完成版Cの自動描画です。下の教材はAの殻運搬から始め、05で三つの浜を扱います。
PLAY / 試験公開

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

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

完成版の主な操作:方向移動、Z殻を出る/入る、Space開始、P停止、R再開、1〜3浜、M表紙

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

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

うまく表示されないとき

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

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

「持っている」ことと「入っている」ことを分け、2匹の同時の移動を確定する。

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

1. 座標をいっしょに動かすことと、次に動ける時刻(tick)
2. 殻から出る・殻に入る・持ち主を、別々に更新する
3. 水をよけた一番短い距離と、1つの餌の持ち主
それだけで動く小さなプログラムを、5つのステップで少しずつ大きくしていきます。
必要な環境
Python 3.11以上 / Pyxel 2.9.9
知っておくとよいこと
変数・if・for・関数 / tuple と書き換えない dataclass/replace / キュー(順番待ちの列)を使った幅優先探索(本文で補足します)
教材に入っているもの
ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
完成した教材の操作
矢印:押し続けて周期移動。二方向以上はPの移動だけ相殺 / Z:02以降、新しい押下で退去または同位置の空殻に入居 / P:停止/再開後、全キーを離す / R:同じ段階・浜の状態だけリセット。ソース再読込ではない / 1 / 2 / 3:05のみ浜A/B/Cを初期状態で選択 / Esc:終了
難易度の目安
中級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_carry_home.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

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

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

STEP 01 / 05

カニと殻を、同じ一歩で運ぶ

座標をいっしょに動かすことと、次に動ける時刻(tick)。

このゲームでは、カニと殻を別々のデータにします。positions は2匹の座標、shells は3つの殻です。Shell は id・size(大きさ)・pos(位置)・owner・occupant を持ちます。owner は殻の持ち主、occupant は今入っているカニで、0が青の P、1が赤の E、-1 は「持ち主なし」または「空き」です。最初は小さな殻0に P、小さな殻1に E が入っていて、上の大きな殻2は空いています。この段階では P の小さな殻だけを運び、E は動きません。

矢印キーを押している間、30fps の1回の更新で1 tick(時間の目盛り1つ)進みます。交代で手番を選ぶゲームではありません。tick が「次に動ける時刻」ready 以上なら、1マス動けます。0 tick に小さな殻で動くと、ready は15になります。更新したあとの表示は tick 1 で、次に動くのは、更新前の tick が15になった更新です。押しっぱなしでも毎フレーム1マスではなく、15 tick ごとに1マスです。描いた回数から待ち時間を引く方式ではありません。

移動先が陸で、相手のカニの今の位置でも、ほかの殻の位置でもないときだけ、カニの位置と、入っている殻の位置を一緒に更新します。殻の id はリストの番号と同じで、並べ替えない前提です。dataclass を frozen にし、replace で新しい状態を作るので、前の State や、別の App の状態が書き変わることはありません。

画面の殻には「id:size」を、右には OWNER(持ち主)と OCCUPANT(入っているカニ)を別々の欄で表示します。白い餌は、地図を覚えるために描いていますが、まだ取れる範囲も点数も計算しません。Z もまだありません。P で一時停止して位置を読み、R でこの段階の最初の状態に戻せます。

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

01_carry_home.py / 注目する部分
            ready[actor] = tick + interval
            if sh:
                shells[sh.id] = replace(sh, pos=dest[actor])

実行して確かめる

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

Windows
.venv\Scripts\python.exe 01_carry_home.py
macOS / Linux
.venv/bin/python 01_carry_home.py
画面で見ること状況 A の最初の位置 P (2,3) で右を押し続けると、(3,3)→(4,3)→(5,3) と進みます。46 tick の自動の例も (5,3) で止まります。(6,3) の赤いカニには重なれず、青の小さな殻0は P と同じ位置にあります。
  • カニと殻の位置を、一緒に更新する
  • 押しっぱなしと、動ける間隔を分ける
  • owner と occupant の2つの欄を読む
つまずきやすいところP の座標だけを更新すると、入っているはずの殻が置いていかれてしまいます。また、tick を増やしてから動けるかを判定すると、最初の1歩と待ち時間が、元のゲームとずれてしまいます。
少し変えてみよう

SMALL_INTERVAL = 15 を10に変えてみましょう。保存 → ウィンドウを閉じる → 同じファイルを実行し直します。右に動いた直後の ready は10になり、次に動くまでの間隔は10 tick です。R を押すだけでは、書き換えたコードは読み直されません。確認したら15に戻します。

01_carry_home.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(305行)
01_carry_home.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。日本語の詳しい説明はLESSON.md。"""
from dataclasses import dataclass, replace, asdict
from collections import deque
import hashlib, json
from functools import lru_cache
DIRS = ((0, -1), (0, 1), (-1, 0), (1, 0))
TIDE = 450
SMALL_INTERVAL = 15
BARE_INTERVAL = 8

@dataclass(frozen=True)
class Level:
    name: str
    food: tuple
    water: tuple
    starts: tuple
    shells: tuple
LEVELS = (Level('今の殻で届く', ((1, 3), (2, 2), (2, 4), (5, 3), (6, 2), (7, 3)), (), ((2, 3), (6, 3)), ((0, 1, (2, 3), 0, 0), (1, 1, (6, 3), 1, 1), (2, 2, (4, 1), -1, -1))), Level('大きい殻の届く湾', ((2, 2), (3, 1), (4, 2), (5, 1), (6, 2), (6, 4), (7, 4)), ((3, 4), (4, 4), (5, 4)), ((2, 3), (7, 3)), ((0, 1, (2, 3), 0, 0), (1, 1, (7, 3), 1, 1), (2, 2, (4, 2), -1, -1))), Level('空けた殻も陣地', ((1, 2), (2, 2), (3, 2), (5, 4), (6, 4), (7, 4), (4, 3)), ((4, 2), (4, 4)), ((2, 2), (6, 4)), ((0, 1, (2, 2), 0, 0), (1, 2, (6, 4), 1, 1), (2, 1, (4, 1), -1, -1))))

@dataclass(frozen=True)
class Shell:
    id: int
    size: int
    pos: tuple
    owner: int
    occupant: int

@dataclass(frozen=True)
class State:
    positions: tuple
    shells: tuple
    ready: tuple = (0, 0)
    tick: int = 0
    event: str = 'ready'

@dataclass(frozen=True)
class Command:
    direction: tuple | None = None
    toggle: bool = False

def initial(level):
    return State(level.starts, tuple((Shell(*s) for s in level.shells)))

@lru_cache(maxsize=4096)
def cells(level):
    return tuple(((x, y) for y in range(1, 6) for x in range(1, 8) if (x, y) not in level.water))

def owned_shell(state, actor):
    return next((s for s in state.shells if s.occupant == actor), None)

def step(level, state, command=Command(), large_interval=24):
    tick = state.tick
    old = state.positions
    inside = [owned_shell(state, i) for i in (0, 1)]
    dest = list(old)
    toggle = [False, False]
    if not toggle[0] and command.direction in DIRS and (tick >= state.ready[0]):
        p = (old[0][0] + command.direction[0], old[0][1] + command.direction[1])
        if p in cells(level) and p != old[1] and (not (inside[0] and any((sh.pos == p and sh.id != inside[0].id for sh in state.shells)))):
            dest[0] = p
    event = ''
    if dest[0] == dest[1]:
        dest = list(old)
        event = 'blocked'
    shells = list(state.shells)
    ready = list(state.ready)
    for actor in (0, 1):
        if dest[actor] != old[actor]:
            sh = inside[actor]
            interval = BARE_INTERVAL if sh is None else SMALL_INTERVAL if sh.size == 1 else large_interval
            ready[actor] = tick + interval
            if sh:
                shells[sh.id] = replace(sh, pos=dest[actor])
    s = replace(state, positions=tuple(dest), shells=tuple(shells), ready=tuple(ready), tick=tick + 1, event=event)
    validate(level, s)
    return s

def validate(level, s):
    assert s.positions[0] != s.positions[1]
    assert all((p in cells(level) for p in s.positions))
    assert len({sh.pos for sh in s.shells}) == 3
    assert len(s.shells) == 3 and {sh.id for sh in s.shells} == {0, 1, 2}
    for sh in s.shells:
        assert sh.pos in cells(level) and sh.owner in (-1, 0, 1) and (sh.occupant in (-1, 0, 1))
        if sh.occupant >= 0:
            assert sh.owner == sh.occupant and sh.pos == s.positions[sh.occupant]
    assert all((sum((sh.occupant == i for sh in s.shells)) <= 1 for i in (0, 1)))

def state_hash(s):
    return hashlib.sha256(json.dumps(asdict(s), sort_keys=True).encode('utf-8')).hexdigest()[:16]

STAGE = 1
LABEL = 'CARRY YOUR HOME'
# The model above never imports Pyxel. Only main opens a window.
WIDTH, HEIGHT = 640, 464
LARGE_INTERVAL = 24
DIRECTIONS = {'UP': (0, -1), 'DOWN': (0, 1), 'LEFT': (-1, 0), 'RIGHT': (1, 0)}

class App:
    def __init__(self):
        self.level_index = 1 if STAGE == 4 else 0
        self.lock = False
        self.reset()

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

    def reset(self):
        self.state = initial(self.level)
        self.pause = False
        self.reason = 'ready'

    def control(self, pressed, held):
        # Original priority: release lock -> reset -> pause -> level -> play.
        if self.lock:
            if not held and not pressed:
                self.lock = False
            return
        if 'R' in pressed:
            self.reset()
            self.lock = True
            return
        if 'P' in pressed:
            self.pause = not self.pause
            self.lock = True
            return
        if self.pause:
            return
        if STAGE == 5:
            for index, key in enumerate(('1', '2', '3')):
                if key in pressed:
                    self.level_index = index
                    self.reset()
                    self.lock = True
                    return
        if getattr(self.state, 'ended', False):
            return
        directions = [d for key, d in DIRECTIONS.items() if key in held]
        direction = directions[0] if len(directions) == 1 else None
        toggle = STAGE >= 2 and 'Z' in pressed
        before = self.state
        self.state = step(self.level, before, Command(direction, toggle), LARGE_INTERVAL)
        if self.state.event:
            self.reason = self.state.event

    def draw(self, p):
        s = self.state
        p.cls(1)
        p.text(12, 10, 'MOVING SHELLS / STAGE '+str(STAGE)+' / BEACH '+'ABC'[self.level_index], 10)
        p.text(12, 27, LABEL, 7)
        owners = points(self.level, s)[1] if STAGE >= 3 else (-1,)*len(self.level.food)
        for y in range(7):
            for x in range(9):
                pos = (x, y)
                px, py = 12+x*40, 52+y*40
                p.rect(px, py, 39, 39, 4 if pos in cells(self.level) else 5)
                if pos in self.level.food:
                    own = owners[self.level.food.index(pos)]
                    p.circ(px+19, py+19, 6, 7)
                    p.circb(px+19, py+19, 10, (6, 8)[own] if own >= 0 else 13)
                    p.text(px+17, py+17, 'PE'[own] if own >= 0 else '-', 1)
        for sh in s.shells:
            x, y = 12+sh.pos[0]*40+19, 52+sh.pos[1]*40+19
            radius = 15 if sh.size == 2 else 12
            color = (6, 8)[sh.owner] if sh.owner >= 0 else 13
            p.circ(x, y, radius, color)
            p.circb(x, y, radius, 7)
            p.text(x-8, y-7, str(sh.id)+':'+str(sh.size), 1)
            if sh.occupant < 0:
                p.text(x-9, y+4, 'EMPTY', 7)
        for actor, pos in enumerate(s.positions):
            x, y = 12+pos[0]*40+19, 52+pos[1]*40+19
            p.circ(x-10, y-11, 3, (6, 8)[actor])
            p.circ(x+10, y-11, 3, (6, 8)[actor])
            p.text(x-2, y+3, 'PE'[actor], 7)
        if STAGE >= 4 and s.memory.target:
            x, y = s.memory.target
            p.rectb(12+x*40+1, 52+y*40+1, 37, 37, 8)
        p.text(388, 58, 'P: BLUE / YOU', 6)
        p.text(388, 72, 'E: RED / RIVAL', 8)
        p.text(388, 94, 'TICK '+str(s.tick), 7)
        p.text(388, 108, '30 TICKS = 1 SECOND', 13)
        p.text(388, 130, 'ID SIZE OWNER OCCUPANT', 7)
        for row, sh in enumerate(s.shells):
            def who(actor):
                return 'PE'[actor] if actor >= 0 else '-'
            p.text(388, 146+row*17, f'{sh.id}    {sh.size}    {who(sh.owner)}      {who(sh.occupant)}', (6, 8)[sh.owner] if sh.owner >= 0 else 13)
        inside = owned_shell(s, 0)
        interval = BARE_INTERVAL if inside is None else SMALL_INTERVAL if inside.size == 1 else LARGE_INTERVAL
        p.text(388, 209, 'MOVE INTERVAL '+str(interval), 7)
        p.text(388, 225, 'READY IN '+str(max(0, s.ready[0]-s.tick)), 7)
        if STAGE >= 3:
            totals, _ = points(self.level, s)
            p.text(388, 250, f'IF TIDE NOW: {totals[0]} : {totals[1]}', 10)
        else:
            p.text(388, 250, 'FOOD: NOT SCORED YET', 13)
        if STAGE >= 4:
            p.text(388, 272, 'RIVAL: '+s.memory.mode.upper(), 8)
        else:
            p.text(388, 272, 'RIVAL: FIXED FOR PRACTICE', 13)
        if STAGE == 5:
            p.text(12, 345, f'TIDES {s.tides}/3  TOTAL {s.scores[0]} : {s.scores[1]}', 10)
            remain = TIDE-s.tick % TIDE if not s.ended else 0
            p.text(12, 361, f'NEXT TIDE IN {remain} TICKS / RESULT '+(s.event.upper() if s.ended else 'PLAY'), 7)
        else:
            p.text(12, 345, 'PRACTICE: NO TIDES, SCORE TOTALS OR ENDING', 13)
            p.text(12, 361, '01 CARRY > 02 ENTER/LEAVE > 03 INFLUENCE > 04 RIVAL > 05 TIDES', 13)
        p.text(12, 388, ('PAUSED / RELEASE KEYS THEN P' if self.pause else 'EVENT: '+self.reason.upper()), 10)
        p.text(12, 407, 'ARROWS: HOLD TO MOVE / '+('Z: ENTER OR LEAVE' if STAGE >= 2 else 'Z: NOT ADDED YET'), 7)
        p.text(12, 424, 'P: PAUSE / R: LIVE RESET / ESC: QUIT'+(' / 1-3: BEACH' if STAGE == 5 else ''), 7)
        p.text(12, 443, 'AFTER CODE EDIT: SAVE > QUIT > RUN AGAIN (R DOES NOT RELOAD)', 13)


def demo_keys(frame):
    # Finite synthetic key sequence, through exactly the same controller.
    if STAGE == 4:
        return set(), set()
    if STAGE == 1:
        return set(), {'RIGHT'}
    # Leave small shell; move north twice then east twice; enter large shell.
    if frame in (0, 34):
        return {'Z'}, {'Z'}
    if 1 <= frame <= 9:
        return set(), {'UP'}
    if 10 <= frame <= 25:
        return set(), {'RIGHT'}
    if STAGE >= 3 and 35 <= frame <= 74:
        return set(), {'DOWN'}
    return set(), set()


def main(headless=False, screenshot=None, demo=False, report=None):
    # Resolve validated paths before importing Pyxel: import can change cwd.
    import pyxel as p
    p.init(WIDTH, HEIGHT, title='Moving shells lesson '+str(STAGE), fps=30, display_scale=2, headless=headless)
    app = App()
    frame = 0
    limit = (46 if STAGE == 1 else 36 if STAGE == 2 else 90 if STAGE == 3 else 180 if STAGE == 4 else TIDE*3) if demo else 2
    keys = {k: getattr(p, 'KEY_'+k) for k in ('UP', 'DOWN', 'LEFT', 'RIGHT', 'Z', 'P', 'R', '1', '2', '3')}
    def update():
        nonlocal frame
        if p.btnp(p.KEY_ESCAPE) or headless and frame >= limit:
            if screenshot:
                app.draw(p)
                p.screen.save(screenshot, 1)
            if report:
                from pathlib import Path
                Path(report).write_text(json.dumps({'stage': STAGE, 'synthetic': True, 'state': asdict(app.state), 'hash': state_hash(app.state)}, ensure_ascii=False, indent=2)+'\n', encoding='utf-8')
            p.quit()
            return
        if headless:
            if demo:
                pressed, held = demo_keys(frame)
                app.control(pressed, held)
        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)})
        frame += 1
    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')
    parser.add_argument('--report')
    args = parser.parse_args()
    if (args.demo_checkpoint or args.screenshot or args.report) and not args.headless_smoke:
        parser.error('demo and outputs require --headless-smoke')
    for option, suffix in (('screenshot', '.png'), ('report', '.json')):
        value = getattr(args, option)
        if value:
            path = Path(value)
            if path.suffix.lower() != suffix or not path.parent.is_dir() or path.exists():
                parser.error('use a new '+suffix+' in an existing directory')
            setattr(args, option, str(path.resolve()))
    main(args.headless_smoke, args.screenshot, args.demo_checkpoint, args.report)
STEP 02 / 05

出た殻にも持主がいる

殻から出る・殻に入る・持ち主を、別々に更新する。

Z を新しく押した瞬間だけ、殻から出る、または同じ位置にある空いた殻に入る動きを加えます。殻から出るときに変えるのは occupant=-1 だけです。owner を -1 に消してはいけません。空いた小さな殻0には、P の持ち主の色が残ります。空いた殻にはだれでも入れますが、入ったときは owner と occupant の両方が、入ったカニになります。相手が空けた殻にも、同じルールが使われます。

殻から出たカニ(はだか)は、別の殻の位置まで歩けます。そこに着いただけでは、入ることも持ち主が変わることも起きず、Z が必要です。すでに別の殻を運んでいる間は、殻どうしを重ねて動けません。相手のカニがいるマスにははだかでも進めないので、相手が入っている殻を直接取ることはできません。

次の1歩までの間隔は、はだか8 tick、小さな殻15 tick、大きな殻24 tick です。大きな殻は、あとで広い範囲の餌を取れるようになりますが、そのかわり動くのが遅いのです。ready を書き直すのは、移動が成功したときだけです。殻に出入りするときは ready を待たず、すでにある ready も消しません。たとえば、小さな殻で動いた直後に脱いでも、それまでの移動の待ち時間が急に0になることはありません。

Z と矢印キーを同時に入力すると Z を優先し、その tick では P は動きません。Z を長押ししても、出たり入ったりをくり返さず、一度離して押し直します。一方、矢印キーは押している状態を読むので、決まった間隔で自動的に動きます。E はまだ止まったままで、満潮も点数もありません。

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

02_empty_bases.py / 注目する部分
            shells[sh.id] = replace(shells[sh.id], occupant=-1)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 02_empty_bases.py
macOS / Linux
.venv/bin/python 02_empty_bases.py
画面で見ることZ で殻から出る → 上へ2マス → 右へ2マス → 矢印キーを離して Z で大きな殻に入ります。P は (4,1)、殻2は OWNER=P/OCCUPANT=P、元の殻0は (2,3) のまま OWNER=P/OCCUPANT=- です。自動の例は36 tick でこの状態になります。
  • 殻から出るときは、occupant だけを空にする
  • 殻に入ると、owner(持ち主)も入ったカニになる
  • はだか8・小さな殻15・大きな殻24 tick
つまずきやすいところ空いた殻を「だれのものでもない」と扱わないでください。owner=-1 は持ち主なし、occupant=-1 は空き部屋です。また、同じマスに歩いただけで、殻に入ったことにしないでください。
少し変えてみよう

LARGE_INTERVAL = 24 を12に変え、保存 → 閉じる → 実行し直してみましょう。同じ道順で大きな殻に入ってから、下へ歩きます。大きな殻での移動の間隔が12 tick になります。元の24に戻し、同じ大きな殻が小さな殻より遅いことを確かめます。

02_empty_bases.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(322行)
02_empty_bases.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。日本語の詳しい説明はLESSON.md。"""
from dataclasses import dataclass, replace, asdict
from collections import deque
import hashlib, json
from functools import lru_cache
DIRS = ((0, -1), (0, 1), (-1, 0), (1, 0))
TIDE = 450
SMALL_INTERVAL = 15
BARE_INTERVAL = 8

@dataclass(frozen=True)
class Level:
    name: str
    food: tuple
    water: tuple
    starts: tuple
    shells: tuple
LEVELS = (Level('今の殻で届く', ((1, 3), (2, 2), (2, 4), (5, 3), (6, 2), (7, 3)), (), ((2, 3), (6, 3)), ((0, 1, (2, 3), 0, 0), (1, 1, (6, 3), 1, 1), (2, 2, (4, 1), -1, -1))), Level('大きい殻の届く湾', ((2, 2), (3, 1), (4, 2), (5, 1), (6, 2), (6, 4), (7, 4)), ((3, 4), (4, 4), (5, 4)), ((2, 3), (7, 3)), ((0, 1, (2, 3), 0, 0), (1, 1, (7, 3), 1, 1), (2, 2, (4, 2), -1, -1))), Level('空けた殻も陣地', ((1, 2), (2, 2), (3, 2), (5, 4), (6, 4), (7, 4), (4, 3)), ((4, 2), (4, 4)), ((2, 2), (6, 4)), ((0, 1, (2, 2), 0, 0), (1, 2, (6, 4), 1, 1), (2, 1, (4, 1), -1, -1))))

@dataclass(frozen=True)
class Shell:
    id: int
    size: int
    pos: tuple
    owner: int
    occupant: int

@dataclass(frozen=True)
class State:
    positions: tuple
    shells: tuple
    ready: tuple = (0, 0)
    tick: int = 0
    event: str = 'ready'

@dataclass(frozen=True)
class Command:
    direction: tuple | None = None
    toggle: bool = False

def initial(level):
    return State(level.starts, tuple((Shell(*s) for s in level.shells)))

@lru_cache(maxsize=4096)
def cells(level):
    return tuple(((x, y) for y in range(1, 6) for x in range(1, 8) if (x, y) not in level.water))

def owned_shell(state, actor):
    return next((s for s in state.shells if s.occupant == actor), None)

def simultaneous_entries(candidates):
    return tuple((v if v >= 0 and candidates.count(v) == 1 else -1 for v in candidates))

def step(level, state, command=Command(), large_interval=24):
    tick = state.tick
    old = state.positions
    inside = [owned_shell(state, i) for i in (0, 1)]
    dest = list(old)
    toggle = [command.toggle, False]
    if not toggle[0] and command.direction in DIRS and (tick >= state.ready[0]):
        p = (old[0][0] + command.direction[0], old[0][1] + command.direction[1])
        if p in cells(level) and p != old[1] and (not (inside[0] and any((sh.pos == p and sh.id != inside[0].id for sh in state.shells)))):
            dest[0] = p
    event = ''
    if dest[0] == dest[1]:
        dest = list(old)
        event = 'blocked'
    shells = list(state.shells)
    ready = list(state.ready)
    for actor in (0, 1):
        if dest[actor] != old[actor]:
            sh = inside[actor]
            interval = BARE_INTERVAL if sh is None else SMALL_INTERVAL if sh.size == 1 else large_interval
            ready[actor] = tick + interval
            if sh:
                shells[sh.id] = replace(sh, pos=dest[actor])
    requests = [-1, -1]
    for actor in (0, 1):
        if not toggle[actor]:
            continue
        sh = inside[actor]
        if sh:
            shells[sh.id] = replace(shells[sh.id], occupant=-1)
            event = 'left'
        else:
            requests[actor] = next((s.id for s in shells if s.pos == dest[actor] and s.occupant < 0), -1)
    for actor, sid in enumerate(simultaneous_entries(tuple(requests))):
        if sid >= 0:
            shells[sid] = replace(shells[sid], owner=actor, occupant=actor)
            event = 'entered'
    s = replace(state, positions=tuple(dest), shells=tuple(shells), ready=tuple(ready), tick=tick + 1, event=event)
    validate(level, s)
    return s

def validate(level, s):
    assert s.positions[0] != s.positions[1]
    assert all((p in cells(level) for p in s.positions))
    assert len({sh.pos for sh in s.shells}) == 3
    assert len(s.shells) == 3 and {sh.id for sh in s.shells} == {0, 1, 2}
    for sh in s.shells:
        assert sh.pos in cells(level) and sh.owner in (-1, 0, 1) and (sh.occupant in (-1, 0, 1))
        if sh.occupant >= 0:
            assert sh.owner == sh.occupant and sh.pos == s.positions[sh.occupant]
    assert all((sum((sh.occupant == i for sh in s.shells)) <= 1 for i in (0, 1)))

def state_hash(s):
    return hashlib.sha256(json.dumps(asdict(s), sort_keys=True).encode('utf-8')).hexdigest()[:16]

STAGE = 2
LABEL = 'EMPTY BASES KEEP THEIR OWNER'
# The model above never imports Pyxel. Only main opens a window.
WIDTH, HEIGHT = 640, 464
LARGE_INTERVAL = 24
DIRECTIONS = {'UP': (0, -1), 'DOWN': (0, 1), 'LEFT': (-1, 0), 'RIGHT': (1, 0)}

class App:
    def __init__(self):
        self.level_index = 1 if STAGE == 4 else 0
        self.lock = False
        self.reset()

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

    def reset(self):
        self.state = initial(self.level)
        self.pause = False
        self.reason = 'ready'

    def control(self, pressed, held):
        # Original priority: release lock -> reset -> pause -> level -> play.
        if self.lock:
            if not held and not pressed:
                self.lock = False
            return
        if 'R' in pressed:
            self.reset()
            self.lock = True
            return
        if 'P' in pressed:
            self.pause = not self.pause
            self.lock = True
            return
        if self.pause:
            return
        if STAGE == 5:
            for index, key in enumerate(('1', '2', '3')):
                if key in pressed:
                    self.level_index = index
                    self.reset()
                    self.lock = True
                    return
        if getattr(self.state, 'ended', False):
            return
        directions = [d for key, d in DIRECTIONS.items() if key in held]
        direction = directions[0] if len(directions) == 1 else None
        toggle = STAGE >= 2 and 'Z' in pressed
        before = self.state
        self.state = step(self.level, before, Command(direction, toggle), LARGE_INTERVAL)
        if self.state.event:
            self.reason = self.state.event

    def draw(self, p):
        s = self.state
        p.cls(1)
        p.text(12, 10, 'MOVING SHELLS / STAGE '+str(STAGE)+' / BEACH '+'ABC'[self.level_index], 10)
        p.text(12, 27, LABEL, 7)
        owners = points(self.level, s)[1] if STAGE >= 3 else (-1,)*len(self.level.food)
        for y in range(7):
            for x in range(9):
                pos = (x, y)
                px, py = 12+x*40, 52+y*40
                p.rect(px, py, 39, 39, 4 if pos in cells(self.level) else 5)
                if pos in self.level.food:
                    own = owners[self.level.food.index(pos)]
                    p.circ(px+19, py+19, 6, 7)
                    p.circb(px+19, py+19, 10, (6, 8)[own] if own >= 0 else 13)
                    p.text(px+17, py+17, 'PE'[own] if own >= 0 else '-', 1)
        for sh in s.shells:
            x, y = 12+sh.pos[0]*40+19, 52+sh.pos[1]*40+19
            radius = 15 if sh.size == 2 else 12
            color = (6, 8)[sh.owner] if sh.owner >= 0 else 13
            p.circ(x, y, radius, color)
            p.circb(x, y, radius, 7)
            p.text(x-8, y-7, str(sh.id)+':'+str(sh.size), 1)
            if sh.occupant < 0:
                p.text(x-9, y+4, 'EMPTY', 7)
        for actor, pos in enumerate(s.positions):
            x, y = 12+pos[0]*40+19, 52+pos[1]*40+19
            p.circ(x-10, y-11, 3, (6, 8)[actor])
            p.circ(x+10, y-11, 3, (6, 8)[actor])
            p.text(x-2, y+3, 'PE'[actor], 7)
        if STAGE >= 4 and s.memory.target:
            x, y = s.memory.target
            p.rectb(12+x*40+1, 52+y*40+1, 37, 37, 8)
        p.text(388, 58, 'P: BLUE / YOU', 6)
        p.text(388, 72, 'E: RED / RIVAL', 8)
        p.text(388, 94, 'TICK '+str(s.tick), 7)
        p.text(388, 108, '30 TICKS = 1 SECOND', 13)
        p.text(388, 130, 'ID SIZE OWNER OCCUPANT', 7)
        for row, sh in enumerate(s.shells):
            def who(actor):
                return 'PE'[actor] if actor >= 0 else '-'
            p.text(388, 146+row*17, f'{sh.id}    {sh.size}    {who(sh.owner)}      {who(sh.occupant)}', (6, 8)[sh.owner] if sh.owner >= 0 else 13)
        inside = owned_shell(s, 0)
        interval = BARE_INTERVAL if inside is None else SMALL_INTERVAL if inside.size == 1 else LARGE_INTERVAL
        p.text(388, 209, 'MOVE INTERVAL '+str(interval), 7)
        p.text(388, 225, 'READY IN '+str(max(0, s.ready[0]-s.tick)), 7)
        if STAGE >= 3:
            totals, _ = points(self.level, s)
            p.text(388, 250, f'IF TIDE NOW: {totals[0]} : {totals[1]}', 10)
        else:
            p.text(388, 250, 'FOOD: NOT SCORED YET', 13)
        if STAGE >= 4:
            p.text(388, 272, 'RIVAL: '+s.memory.mode.upper(), 8)
        else:
            p.text(388, 272, 'RIVAL: FIXED FOR PRACTICE', 13)
        if STAGE == 5:
            p.text(12, 345, f'TIDES {s.tides}/3  TOTAL {s.scores[0]} : {s.scores[1]}', 10)
            remain = TIDE-s.tick % TIDE if not s.ended else 0
            p.text(12, 361, f'NEXT TIDE IN {remain} TICKS / RESULT '+(s.event.upper() if s.ended else 'PLAY'), 7)
        else:
            p.text(12, 345, 'PRACTICE: NO TIDES, SCORE TOTALS OR ENDING', 13)
            p.text(12, 361, '01 CARRY > 02 ENTER/LEAVE > 03 INFLUENCE > 04 RIVAL > 05 TIDES', 13)
        p.text(12, 388, ('PAUSED / RELEASE KEYS THEN P' if self.pause else 'EVENT: '+self.reason.upper()), 10)
        p.text(12, 407, 'ARROWS: HOLD TO MOVE / '+('Z: ENTER OR LEAVE' if STAGE >= 2 else 'Z: NOT ADDED YET'), 7)
        p.text(12, 424, 'P: PAUSE / R: LIVE RESET / ESC: QUIT'+(' / 1-3: BEACH' if STAGE == 5 else ''), 7)
        p.text(12, 443, 'AFTER CODE EDIT: SAVE > QUIT > RUN AGAIN (R DOES NOT RELOAD)', 13)


def demo_keys(frame):
    # Finite synthetic key sequence, through exactly the same controller.
    if STAGE == 4:
        return set(), set()
    if STAGE == 1:
        return set(), {'RIGHT'}
    # Leave small shell; move north twice then east twice; enter large shell.
    if frame in (0, 34):
        return {'Z'}, {'Z'}
    if 1 <= frame <= 9:
        return set(), {'UP'}
    if 10 <= frame <= 25:
        return set(), {'RIGHT'}
    if STAGE >= 3 and 35 <= frame <= 74:
        return set(), {'DOWN'}
    return set(), set()


def main(headless=False, screenshot=None, demo=False, report=None):
    # Resolve validated paths before importing Pyxel: import can change cwd.
    import pyxel as p
    p.init(WIDTH, HEIGHT, title='Moving shells lesson '+str(STAGE), fps=30, display_scale=2, headless=headless)
    app = App()
    frame = 0
    limit = (46 if STAGE == 1 else 36 if STAGE == 2 else 90 if STAGE == 3 else 180 if STAGE == 4 else TIDE*3) if demo else 2
    keys = {k: getattr(p, 'KEY_'+k) for k in ('UP', 'DOWN', 'LEFT', 'RIGHT', 'Z', 'P', 'R', '1', '2', '3')}
    def update():
        nonlocal frame
        if p.btnp(p.KEY_ESCAPE) or headless and frame >= limit:
            if screenshot:
                app.draw(p)
                p.screen.save(screenshot, 1)
            if report:
                from pathlib import Path
                Path(report).write_text(json.dumps({'stage': STAGE, 'synthetic': True, 'state': asdict(app.state), 'hash': state_hash(app.state)}, ensure_ascii=False, indent=2)+'\n', encoding='utf-8')
            p.quit()
            return
        if headless:
            if demo:
                pressed, held = demo_keys(frame)
                app.control(pressed, held)
        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)})
        frame += 1
    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')
    parser.add_argument('--report')
    args = parser.parse_args()
    if (args.demo_checkpoint or args.screenshot or args.report) and not args.headless_smoke:
        parser.error('demo and outputs require --headless-smoke')
    for option, suffix in (('screenshot', '.png'), ('report', '.json')):
        value = getattr(args, option)
        if value:
            path = Path(value)
            if path.suffix.lower() != suffix or not path.parent.is_dir() or path.exists():
                parser.error('use a new '+suffix+' in an existing directory')
            setattr(args, option, str(path.resolve()))
    main(args.headless_smoke, args.screenshot, args.demo_checkpoint, args.report)
STEP 03 / 05

空の拠点からも餌へ届く

水をよけた一番短い距離と、1つの餌の持ち主。

points 関数を加え、「今が満潮なら何点になるか」を IF TIDE NOW に表示します。まだ満潮の時計や点数の合計はなく、時間をかけて配置を比べる練習です。餌の1つ1つについて、持ち主がいる(owner がある)すべての殻を調べます。殻にだれかが入っているかは、点数の計算に使いません。これが、空いた殻を拠点として残す意味です。

餌に届くかどうかは、縦横に進む一番短い道の歩数が size 以下かで判定します。小さな殻は1、大きな殻は2です。distances では deque を使う幅優先探索(近い順に広げていく探し方)で、最初に見つかった歩数を保存します。水は通れません。まっすぐな距離や画面の円の半径ではなく、陸づたいの歩数です。元のゲームの状況 B の (4,3) から (4,5) までは、水の帯を回りこむので6歩になります。01〜03 は状況 A、04 は相手の乗りかえが見える状況 B を画面に使います。3つの面のデータは、コードに残しています。

いくつかの殻が届く餌は、その餌に一番近い殻の持ち主に1点です。一番近い距離に P と E が同じだけいれば、どちらにも入りません。同じ持ち主の2つの殻が同じ距離なら、その持ち主に1点だけです。距離を比べる前に、それぞれの殻の size の範囲に入っていることも必要です。

餌は食べてもなくならず、歩いた道が陣地になる仕組みもありません。点数に関係するのは、持っている殻の今の位置です。空いた殻の owner が変われば、カニ自身の位置ではなく、その殻を中心にした取れる範囲が引き継がれます。右の持ち主の表、白い餌の P/E/-、点数の予告を見比べましょう。

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

03_food_influence.py / 注目する部分
            near = min((d for d, o in influence))
            c = {o for d, o in influence if d == near}

実行して確かめる

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

Windows
.venv\Scripts\python.exe 03_food_influence.py
macOS / Linux
.venv/bin/python 03_food_influence.py
画面で見ること最初の予告は 3:3 です。02 と同じように大きな殻に入り、下へ2マスの (4,3) に置くと 3:2 です。(5,3) の餌は、P の大きな殻から1歩、E の小さな殻からも1歩なので「-」です。空けた小さな殻0の3つの餌は、P のままです。
  • 点数の計算でも、持ち主と入っているカニを分ける
  • 一番短い道の歩数で、届くかを判定する
  • 同じ距離に敵と味方がいれば、どちらも0点
つまずきやすいところ同じ距離だからといって点を半分ずつ配ったり、同じ持ち主の2つの殻で2点を足したりしないでください。盤面の白い餌1つにつき、0点か、どちらか一方に1点です。
少し変えてみよう

大きな殻を (4,3) から右の (5,3) へ1マス運び、予告が 4:2 になることを確かめましょう。Z で大きな殻を空け、左の (4,3) へはだかで歩いても 4:2 のままです。2つの空いた殻の持ち主が P で、はだかのカニの位置は直接点を生まないことを、持ち主の表で説明してみてください。

03_food_influence.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(357行)
03_food_influence.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。日本語の詳しい説明はLESSON.md。"""
from dataclasses import dataclass, replace, asdict
from collections import deque
import hashlib, json
from functools import lru_cache
DIRS = ((0, -1), (0, 1), (-1, 0), (1, 0))
TIDE = 450
SMALL_INTERVAL = 15
BARE_INTERVAL = 8

@dataclass(frozen=True)
class Level:
    name: str
    food: tuple
    water: tuple
    starts: tuple
    shells: tuple
LEVELS = (Level('今の殻で届く', ((1, 3), (2, 2), (2, 4), (5, 3), (6, 2), (7, 3)), (), ((2, 3), (6, 3)), ((0, 1, (2, 3), 0, 0), (1, 1, (6, 3), 1, 1), (2, 2, (4, 1), -1, -1))), Level('大きい殻の届く湾', ((2, 2), (3, 1), (4, 2), (5, 1), (6, 2), (6, 4), (7, 4)), ((3, 4), (4, 4), (5, 4)), ((2, 3), (7, 3)), ((0, 1, (2, 3), 0, 0), (1, 1, (7, 3), 1, 1), (2, 2, (4, 2), -1, -1))), Level('空けた殻も陣地', ((1, 2), (2, 2), (3, 2), (5, 4), (6, 4), (7, 4), (4, 3)), ((4, 2), (4, 4)), ((2, 2), (6, 4)), ((0, 1, (2, 2), 0, 0), (1, 2, (6, 4), 1, 1), (2, 1, (4, 1), -1, -1))))

@dataclass(frozen=True)
class Shell:
    id: int
    size: int
    pos: tuple
    owner: int
    occupant: int

@dataclass(frozen=True)
class State:
    positions: tuple
    shells: tuple
    ready: tuple = (0, 0)
    tick: int = 0
    event: str = 'ready'

@dataclass(frozen=True)
class Command:
    direction: tuple | None = None
    toggle: bool = False

def initial(level):
    return State(level.starts, tuple((Shell(*s) for s in level.shells)))

@lru_cache(maxsize=4096)
def cells(level):
    return tuple(((x, y) for y in range(1, 6) for x in range(1, 8) if (x, y) not in level.water))

@lru_cache(maxsize=4096)
def distances(level, start):
    out = {start: 0}
    q = deque([start])
    valid = set(cells(level))
    while q:
        p = q.popleft()
        for d in DIRS:
            n = (p[0] + d[0], p[1] + d[1])
            if n in valid and n not in out:
                out[n] = out[p] + 1
                q.append(n)
    return out

def owned_shell(state, actor):
    return next((s for s in state.shells if s.occupant == actor), None)

def points(level, state):
    totals = [0, 0]
    owners = []
    for food in level.food:
        influence = []
        for sh in state.shells:
            if sh.owner < 0:
                continue
            d = distances(level, sh.pos).get(food, 999)
            if d <= sh.size:
                influence.append((d, sh.owner))
        who = -1
        if influence:
            near = min((d for d, o in influence))
            c = {o for d, o in influence if d == near}
            if len(c) == 1:
                who = c.pop()
                totals[who] += 1
        owners.append(who)
    return (tuple(totals), tuple(owners))

def simultaneous_entries(candidates):
    return tuple((v if v >= 0 and candidates.count(v) == 1 else -1 for v in candidates))

def step(level, state, command=Command(), large_interval=24):
    tick = state.tick
    old = state.positions
    inside = [owned_shell(state, i) for i in (0, 1)]
    dest = list(old)
    toggle = [command.toggle, False]
    if not toggle[0] and command.direction in DIRS and (tick >= state.ready[0]):
        p = (old[0][0] + command.direction[0], old[0][1] + command.direction[1])
        if p in cells(level) and p != old[1] and (not (inside[0] and any((sh.pos == p and sh.id != inside[0].id for sh in state.shells)))):
            dest[0] = p
    event = ''
    if dest[0] == dest[1]:
        dest = list(old)
        event = 'blocked'
    shells = list(state.shells)
    ready = list(state.ready)
    for actor in (0, 1):
        if dest[actor] != old[actor]:
            sh = inside[actor]
            interval = BARE_INTERVAL if sh is None else SMALL_INTERVAL if sh.size == 1 else large_interval
            ready[actor] = tick + interval
            if sh:
                shells[sh.id] = replace(sh, pos=dest[actor])
    requests = [-1, -1]
    for actor in (0, 1):
        if not toggle[actor]:
            continue
        sh = inside[actor]
        if sh:
            shells[sh.id] = replace(shells[sh.id], occupant=-1)
            event = 'left'
        else:
            requests[actor] = next((s.id for s in shells if s.pos == dest[actor] and s.occupant < 0), -1)
    for actor, sid in enumerate(simultaneous_entries(tuple(requests))):
        if sid >= 0:
            shells[sid] = replace(shells[sid], owner=actor, occupant=actor)
            event = 'entered'
    s = replace(state, positions=tuple(dest), shells=tuple(shells), ready=tuple(ready), tick=tick + 1, event=event)
    validate(level, s)
    return s

def validate(level, s):
    assert s.positions[0] != s.positions[1]
    assert all((p in cells(level) for p in s.positions))
    assert len({sh.pos for sh in s.shells}) == 3
    assert len(s.shells) == 3 and {sh.id for sh in s.shells} == {0, 1, 2}
    for sh in s.shells:
        assert sh.pos in cells(level) and sh.owner in (-1, 0, 1) and (sh.occupant in (-1, 0, 1))
        if sh.occupant >= 0:
            assert sh.owner == sh.occupant and sh.pos == s.positions[sh.occupant]
    assert all((sum((sh.occupant == i for sh in s.shells)) <= 1 for i in (0, 1)))

def state_hash(s):
    return hashlib.sha256(json.dumps(asdict(s), sort_keys=True).encode('utf-8')).hexdigest()[:16]

STAGE = 3
LABEL = 'SHORTEST PATH INFLUENCE'
# The model above never imports Pyxel. Only main opens a window.
WIDTH, HEIGHT = 640, 464
LARGE_INTERVAL = 24
DIRECTIONS = {'UP': (0, -1), 'DOWN': (0, 1), 'LEFT': (-1, 0), 'RIGHT': (1, 0)}

class App:
    def __init__(self):
        self.level_index = 1 if STAGE == 4 else 0
        self.lock = False
        self.reset()

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

    def reset(self):
        self.state = initial(self.level)
        self.pause = False
        self.reason = 'ready'

    def control(self, pressed, held):
        # Original priority: release lock -> reset -> pause -> level -> play.
        if self.lock:
            if not held and not pressed:
                self.lock = False
            return
        if 'R' in pressed:
            self.reset()
            self.lock = True
            return
        if 'P' in pressed:
            self.pause = not self.pause
            self.lock = True
            return
        if self.pause:
            return
        if STAGE == 5:
            for index, key in enumerate(('1', '2', '3')):
                if key in pressed:
                    self.level_index = index
                    self.reset()
                    self.lock = True
                    return
        if getattr(self.state, 'ended', False):
            return
        directions = [d for key, d in DIRECTIONS.items() if key in held]
        direction = directions[0] if len(directions) == 1 else None
        toggle = STAGE >= 2 and 'Z' in pressed
        before = self.state
        self.state = step(self.level, before, Command(direction, toggle), LARGE_INTERVAL)
        if self.state.event:
            self.reason = self.state.event

    def draw(self, p):
        s = self.state
        p.cls(1)
        p.text(12, 10, 'MOVING SHELLS / STAGE '+str(STAGE)+' / BEACH '+'ABC'[self.level_index], 10)
        p.text(12, 27, LABEL, 7)
        owners = points(self.level, s)[1] if STAGE >= 3 else (-1,)*len(self.level.food)
        for y in range(7):
            for x in range(9):
                pos = (x, y)
                px, py = 12+x*40, 52+y*40
                p.rect(px, py, 39, 39, 4 if pos in cells(self.level) else 5)
                if pos in self.level.food:
                    own = owners[self.level.food.index(pos)]
                    p.circ(px+19, py+19, 6, 7)
                    p.circb(px+19, py+19, 10, (6, 8)[own] if own >= 0 else 13)
                    p.text(px+17, py+17, 'PE'[own] if own >= 0 else '-', 1)
        for sh in s.shells:
            x, y = 12+sh.pos[0]*40+19, 52+sh.pos[1]*40+19
            radius = 15 if sh.size == 2 else 12
            color = (6, 8)[sh.owner] if sh.owner >= 0 else 13
            p.circ(x, y, radius, color)
            p.circb(x, y, radius, 7)
            p.text(x-8, y-7, str(sh.id)+':'+str(sh.size), 1)
            if sh.occupant < 0:
                p.text(x-9, y+4, 'EMPTY', 7)
        for actor, pos in enumerate(s.positions):
            x, y = 12+pos[0]*40+19, 52+pos[1]*40+19
            p.circ(x-10, y-11, 3, (6, 8)[actor])
            p.circ(x+10, y-11, 3, (6, 8)[actor])
            p.text(x-2, y+3, 'PE'[actor], 7)
        if STAGE >= 4 and s.memory.target:
            x, y = s.memory.target
            p.rectb(12+x*40+1, 52+y*40+1, 37, 37, 8)
        p.text(388, 58, 'P: BLUE / YOU', 6)
        p.text(388, 72, 'E: RED / RIVAL', 8)
        p.text(388, 94, 'TICK '+str(s.tick), 7)
        p.text(388, 108, '30 TICKS = 1 SECOND', 13)
        p.text(388, 130, 'ID SIZE OWNER OCCUPANT', 7)
        for row, sh in enumerate(s.shells):
            def who(actor):
                return 'PE'[actor] if actor >= 0 else '-'
            p.text(388, 146+row*17, f'{sh.id}    {sh.size}    {who(sh.owner)}      {who(sh.occupant)}', (6, 8)[sh.owner] if sh.owner >= 0 else 13)
        inside = owned_shell(s, 0)
        interval = BARE_INTERVAL if inside is None else SMALL_INTERVAL if inside.size == 1 else LARGE_INTERVAL
        p.text(388, 209, 'MOVE INTERVAL '+str(interval), 7)
        p.text(388, 225, 'READY IN '+str(max(0, s.ready[0]-s.tick)), 7)
        if STAGE >= 3:
            totals, _ = points(self.level, s)
            p.text(388, 250, f'IF TIDE NOW: {totals[0]} : {totals[1]}', 10)
        else:
            p.text(388, 250, 'FOOD: NOT SCORED YET', 13)
        if STAGE >= 4:
            p.text(388, 272, 'RIVAL: '+s.memory.mode.upper(), 8)
        else:
            p.text(388, 272, 'RIVAL: FIXED FOR PRACTICE', 13)
        if STAGE == 5:
            p.text(12, 345, f'TIDES {s.tides}/3  TOTAL {s.scores[0]} : {s.scores[1]}', 10)
            remain = TIDE-s.tick % TIDE if not s.ended else 0
            p.text(12, 361, f'NEXT TIDE IN {remain} TICKS / RESULT '+(s.event.upper() if s.ended else 'PLAY'), 7)
        else:
            p.text(12, 345, 'PRACTICE: NO TIDES, SCORE TOTALS OR ENDING', 13)
            p.text(12, 361, '01 CARRY > 02 ENTER/LEAVE > 03 INFLUENCE > 04 RIVAL > 05 TIDES', 13)
        p.text(12, 388, ('PAUSED / RELEASE KEYS THEN P' if self.pause else 'EVENT: '+self.reason.upper()), 10)
        p.text(12, 407, 'ARROWS: HOLD TO MOVE / '+('Z: ENTER OR LEAVE' if STAGE >= 2 else 'Z: NOT ADDED YET'), 7)
        p.text(12, 424, 'P: PAUSE / R: LIVE RESET / ESC: QUIT'+(' / 1-3: BEACH' if STAGE == 5 else ''), 7)
        p.text(12, 443, 'AFTER CODE EDIT: SAVE > QUIT > RUN AGAIN (R DOES NOT RELOAD)', 13)


def demo_keys(frame):
    # Finite synthetic key sequence, through exactly the same controller.
    if STAGE == 4:
        return set(), set()
    if STAGE == 1:
        return set(), {'RIGHT'}
    # Leave small shell; move north twice then east twice; enter large shell.
    if frame in (0, 34):
        return {'Z'}, {'Z'}
    if 1 <= frame <= 9:
        return set(), {'UP'}
    if 10 <= frame <= 25:
        return set(), {'RIGHT'}
    if STAGE >= 3 and 35 <= frame <= 74:
        return set(), {'DOWN'}
    return set(), set()


def main(headless=False, screenshot=None, demo=False, report=None):
    # Resolve validated paths before importing Pyxel: import can change cwd.
    import pyxel as p
    p.init(WIDTH, HEIGHT, title='Moving shells lesson '+str(STAGE), fps=30, display_scale=2, headless=headless)
    app = App()
    frame = 0
    limit = (46 if STAGE == 1 else 36 if STAGE == 2 else 90 if STAGE == 3 else 180 if STAGE == 4 else TIDE*3) if demo else 2
    keys = {k: getattr(p, 'KEY_'+k) for k in ('UP', 'DOWN', 'LEFT', 'RIGHT', 'Z', 'P', 'R', '1', '2', '3')}
    def update():
        nonlocal frame
        if p.btnp(p.KEY_ESCAPE) or headless and frame >= limit:
            if screenshot:
                app.draw(p)
                p.screen.save(screenshot, 1)
            if report:
                from pathlib import Path
                Path(report).write_text(json.dumps({'stage': STAGE, 'synthetic': True, 'state': asdict(app.state), 'hash': state_hash(app.state)}, ensure_ascii=False, indent=2)+'\n', encoding='utf-8')
            p.quit()
            return
        if headless:
            if demo:
                pressed, held = demo_keys(frame)
                app.control(pressed, held)
        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)})
        frame += 1
    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')
    parser.add_argument('--report')
    args = parser.parse_args()
    if (args.demo_checkpoint or args.screenshot or args.report) and not args.headless_smoke:
        parser.error('demo and outputs require --headless-smoke')
    for option, suffix in (('screenshot', '.png'), ('report', '.json')):
        value = getattr(args, option)
        if value:
            path = Path(value)
            if path.suffix.lower() != suffix or not path.parent.is_dir() or path.exists():
                parser.error('use a new '+suffix+' in an existing directory')
            setattr(args, option, str(path.resolve()))
    main(args.headless_smoke, args.screenshot, args.demo_checkpoint, args.report)
STEP 04 / 05

同じ古い盤面から二匹が動く

見える情報だけで考える AI と、同時に動いたときの衝突。

相手の乗りかえが目で見てわかる、元のゲームの状況 B に進み、赤の E の考え方(AI)をそのまま加えます。状況 A の最初の配置では E がその場にとどまるので、04 は状況 B から始めます。水の帯も一番短い道に反映されます。AI は、更新前の tick が15の倍数のときに、前の盤面から目標を考えます。Observation が渡すのは、2匹の位置、殻、餌、水だけです。プレイヤーの今回の入力、この先の満潮、今の点数は読ませません。赤い枠が目標、右の RIVAL が leave(出る)/enter(入る)/place(置く)/stay(とどまる)の行動方針です。

殻に入っているなら、取れる餌の最大数が今の殻より2以上多く、今の位置から4歩以内にある空いた殻を、乗りかえの候補にします。そうでなければ、今の殻で取れる餌の数と、動く距離を比べて置き場所を選びます。はだかなら、空いた殻を目指します。AI の考え方は、最後の点数を全部調べて勝ちを保証するものではなく、餌の数と距離による、決まった基準です。

先に P を動かしてから、更新した盤面で E が考えるのではありません。まず、前の位置から2匹の移動の候補を作ります。相手の前の位置には進めず、2匹の候補が同じ行き先になったら、両方とも元の位置に戻します。2匹が場所を入れかえることもできません。殻の移動と ready の更新をまとめて行い、そのあとで殻への出入りを処理します。

元のゲームには、同じ空いた殻に2匹が同時に入ろうとしたら、両方とも断る守りの関数もあります。ただ、ふつうの状態では2匹は別の位置にいて、出入りの操作中には動かないので、同じ殻に同時に入ろうとする場面は、実際のプレイでは起こりません。テストでは、この守りの関数だけを直接確かめます。まだ点数の合計・満潮・勝ち負けはなく、相手の反応を観察する段階です。

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

04_same_tick_rival.py / 注目する部分
    if dest[0] == dest[1]:
        dest = list(old)
        event = 'blocked'

実行して確かめる

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

Windows
.venv\Scripts\python.exe 04_same_tick_rival.py
macOS / Linux
.venv/bin/python 04_same_tick_rival.py
画面で見ること状況 B で何も押さない自動の例では、180 tick の時点で P (2,3)、E (4,2)、予告 1:5 です。E は小さな殻1を持ったまま空け、真ん中の大きな殻2に入りました。方向キーを2つ同時に押すと P は動きませんが、tick と E の処理は進みます。
  • 前の盤面だけを見て、次の行動を決める
  • 2匹の候補をそろえてから、衝突を判定する
  • P での一時停止と、方向キーの打ち消し合いを区別する
つまずきやすいところ2匹を並び順に1匹ずつ動かすと、あとから動く1匹だけが新しい盤面を見られて有利になります。衝突して止まった移動には、新しい待ち時間も付けません。
少し変えてみよう

R から何も押さずに、赤い枠と E を観察し、P で止めてみましょう。止めている間に矢印キーを押しても、tick・E・予告が変わらないことを確かめます。全部離して P で再開し、また全部離すと、進行が戻ります。左右の同時押しは一時停止とは違い、E も時計も進みます。

04_same_tick_rival.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(429行)
04_same_tick_rival.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。日本語の詳しい説明はLESSON.md。"""
from dataclasses import dataclass, replace, asdict
from collections import deque
import hashlib, json
from functools import lru_cache
DIRS = ((0, -1), (0, 1), (-1, 0), (1, 0))
TIDE = 450
SMALL_INTERVAL = 15
BARE_INTERVAL = 8

@dataclass(frozen=True)
class Level:
    name: str
    food: tuple
    water: tuple
    starts: tuple
    shells: tuple
LEVELS = (Level('今の殻で届く', ((1, 3), (2, 2), (2, 4), (5, 3), (6, 2), (7, 3)), (), ((2, 3), (6, 3)), ((0, 1, (2, 3), 0, 0), (1, 1, (6, 3), 1, 1), (2, 2, (4, 1), -1, -1))), Level('大きい殻の届く湾', ((2, 2), (3, 1), (4, 2), (5, 1), (6, 2), (6, 4), (7, 4)), ((3, 4), (4, 4), (5, 4)), ((2, 3), (7, 3)), ((0, 1, (2, 3), 0, 0), (1, 1, (7, 3), 1, 1), (2, 2, (4, 2), -1, -1))), Level('空けた殻も陣地', ((1, 2), (2, 2), (3, 2), (5, 4), (6, 4), (7, 4), (4, 3)), ((4, 2), (4, 4)), ((2, 2), (6, 4)), ((0, 1, (2, 2), 0, 0), (1, 2, (6, 4), 1, 1), (2, 1, (4, 1), -1, -1))))

@dataclass(frozen=True)
class Shell:
    id: int
    size: int
    pos: tuple
    owner: int
    occupant: int

@dataclass(frozen=True)
class Observation:
    positions: tuple
    shells: tuple
    food: tuple
    water: tuple

@dataclass(frozen=True)
class Memory:
    target: tuple | None = None
    goal_shell: int = -1
    mode: str = 'stay'

@dataclass(frozen=True)
class State:
    positions: tuple
    shells: tuple
    ready: tuple = (0, 0)
    tick: int = 0
    memory: Memory = Memory()
    event: str = 'ready'

@dataclass(frozen=True)
class Command:
    direction: tuple | None = None
    toggle: bool = False

def initial(level):
    return State(level.starts, tuple((Shell(*s) for s in level.shells)))

@lru_cache(maxsize=4096)
def cells(level):
    return tuple(((x, y) for y in range(1, 6) for x in range(1, 8) if (x, y) not in level.water))

@lru_cache(maxsize=4096)
def distances(level, start):
    out = {start: 0}
    q = deque([start])
    valid = set(cells(level))
    while q:
        p = q.popleft()
        for d in DIRS:
            n = (p[0] + d[0], p[1] + d[1])
            if n in valid and n not in out:
                out[n] = out[p] + 1
                q.append(n)
    return out

def owned_shell(state, actor):
    return next((s for s in state.shells if s.occupant == actor), None)

def observe(level, state):
    return Observation(state.positions, state.shells, level.food, level.water)

@lru_cache(maxsize=4096)
def coverage(level, pos, size):
    return sum((distances(level, pos).get(f, 999) <= size for f in level.food))

@lru_cache(maxsize=4096)
def best_coverage(level, size):
    return max((coverage(level, p, size) for p in cells(level)))

def policy(obs, memory):
    level = Level('', obs.food, obs.water, (), ())
    pos = obs.positions[1]
    dist = distances(level, pos)
    inside = next((s for s in obs.shells if s.occupant == 1), None)
    empty = [s for s in obs.shells if s.occupant < 0]
    if inside:
        upgrades = [s for s in empty if best_coverage(level, s.size) >= best_coverage(level, inside.size) + 2 and dist.get(s.pos, 999) <= 4]
        if upgrades:
            s = max(upgrades, key=lambda s: (best_coverage(level, s.size) * 2 - dist.get(s.pos, 999), -dist.get(s.pos, 999), -s.id))
            return Memory(s.pos, s.id, 'leave')
        available = [p for p in cells(level) if not any((sh.pos == p and sh.id != inside.id for sh in obs.shells))]
        target = max(available, key=lambda p: (coverage(level, p, inside.size) * 3 - dist[p], -dist[p], -p[1], -p[0]))
        return Memory(target, -1, 'place')
    if not empty:
        return Memory(pos, -1, 'stay')
    s = max(empty, key=lambda s: (best_coverage(level, s.size) * 2 - dist.get(s.pos, 999), -dist.get(s.pos, 999), -s.id))
    return Memory(s.pos, s.id, 'enter')

def next_step(level, start, target, blocked, forbidden=()):
    if target is None or start == target:
        return None
    valid = set(cells(level))
    q = deque([(start, None)])
    seen = {start}
    while q:
        p, first = q.popleft()
        for d in DIRS:
            n = (p[0] + d[0], p[1] + d[1])
            if n not in valid or n in seen or n == blocked or (n in forbidden):
                continue
            f = first or n
            if n == target:
                return f
            seen.add(n)
            q.append((n, f))
    return None

def points(level, state):
    totals = [0, 0]
    owners = []
    for food in level.food:
        influence = []
        for sh in state.shells:
            if sh.owner < 0:
                continue
            d = distances(level, sh.pos).get(food, 999)
            if d <= sh.size:
                influence.append((d, sh.owner))
        who = -1
        if influence:
            near = min((d for d, o in influence))
            c = {o for d, o in influence if d == near}
            if len(c) == 1:
                who = c.pop()
                totals[who] += 1
        owners.append(who)
    return (tuple(totals), tuple(owners))

def simultaneous_entries(candidates):
    return tuple((v if v >= 0 and candidates.count(v) == 1 else -1 for v in candidates))

def step(level, state, command=Command(), large_interval=24):
    tick = state.tick
    mem = policy(observe(level, state), state.memory) if tick % 15 == 0 else state.memory
    old = state.positions
    inside = [owned_shell(state, i) for i in (0, 1)]
    dest = list(old)
    toggle = [command.toggle, False]
    if mem.mode == 'leave' and tick % 15 == 0 and inside[1]:
        toggle[1] = True
    elif mem.mode == 'enter' and inside[1] is None and (mem.target == old[1]):
        toggle[1] = True
    if not toggle[0] and command.direction in DIRS and (tick >= state.ready[0]):
        p = (old[0][0] + command.direction[0], old[0][1] + command.direction[1])
        if p in cells(level) and p != old[1] and (not (inside[0] and any((sh.pos == p and sh.id != inside[0].id for sh in state.shells)))):
            dest[0] = p
    if not toggle[1] and tick >= state.ready[1]:
        p = next_step(level, old[1], mem.target, old[0], tuple((sh.pos for sh in state.shells if inside[1] and sh.id != inside[1].id)))
        if p:
            dest[1] = p
    event = ''
    if dest[0] == dest[1]:
        dest = list(old)
        event = 'blocked'
    shells = list(state.shells)
    ready = list(state.ready)
    for actor in (0, 1):
        if dest[actor] != old[actor]:
            sh = inside[actor]
            interval = BARE_INTERVAL if sh is None else SMALL_INTERVAL if sh.size == 1 else large_interval
            ready[actor] = tick + interval
            if sh:
                shells[sh.id] = replace(sh, pos=dest[actor])
    requests = [-1, -1]
    for actor in (0, 1):
        if not toggle[actor]:
            continue
        sh = inside[actor]
        if sh:
            shells[sh.id] = replace(shells[sh.id], occupant=-1)
            event = 'left'
        else:
            requests[actor] = next((s.id for s in shells if s.pos == dest[actor] and s.occupant < 0), -1)
    for actor, sid in enumerate(simultaneous_entries(tuple(requests))):
        if sid >= 0:
            shells[sid] = replace(shells[sid], owner=actor, occupant=actor)
            event = 'entered'
    s = replace(state, positions=tuple(dest), shells=tuple(shells), ready=tuple(ready), tick=tick + 1, memory=mem, event=event)
    validate(level, s)
    return s

def validate(level, s):
    assert s.positions[0] != s.positions[1]
    assert all((p in cells(level) for p in s.positions))
    assert len({sh.pos for sh in s.shells}) == 3
    assert len(s.shells) == 3 and {sh.id for sh in s.shells} == {0, 1, 2}
    for sh in s.shells:
        assert sh.pos in cells(level) and sh.owner in (-1, 0, 1) and (sh.occupant in (-1, 0, 1))
        if sh.occupant >= 0:
            assert sh.owner == sh.occupant and sh.pos == s.positions[sh.occupant]
    assert all((sum((sh.occupant == i for sh in s.shells)) <= 1 for i in (0, 1)))

def state_hash(s):
    return hashlib.sha256(json.dumps(asdict(s), sort_keys=True).encode('utf-8')).hexdigest()[:16]

STAGE = 4
LABEL = 'OLD BOARD / SAME TICK'
# The model above never imports Pyxel. Only main opens a window.
WIDTH, HEIGHT = 640, 464
LARGE_INTERVAL = 24
DIRECTIONS = {'UP': (0, -1), 'DOWN': (0, 1), 'LEFT': (-1, 0), 'RIGHT': (1, 0)}

class App:
    def __init__(self):
        self.level_index = 1 if STAGE == 4 else 0
        self.lock = False
        self.reset()

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

    def reset(self):
        self.state = initial(self.level)
        self.pause = False
        self.reason = 'ready'

    def control(self, pressed, held):
        # Original priority: release lock -> reset -> pause -> level -> play.
        if self.lock:
            if not held and not pressed:
                self.lock = False
            return
        if 'R' in pressed:
            self.reset()
            self.lock = True
            return
        if 'P' in pressed:
            self.pause = not self.pause
            self.lock = True
            return
        if self.pause:
            return
        if STAGE == 5:
            for index, key in enumerate(('1', '2', '3')):
                if key in pressed:
                    self.level_index = index
                    self.reset()
                    self.lock = True
                    return
        if getattr(self.state, 'ended', False):
            return
        directions = [d for key, d in DIRECTIONS.items() if key in held]
        direction = directions[0] if len(directions) == 1 else None
        toggle = STAGE >= 2 and 'Z' in pressed
        before = self.state
        self.state = step(self.level, before, Command(direction, toggle), LARGE_INTERVAL)
        if self.state.event:
            self.reason = self.state.event

    def draw(self, p):
        s = self.state
        p.cls(1)
        p.text(12, 10, 'MOVING SHELLS / STAGE '+str(STAGE)+' / BEACH '+'ABC'[self.level_index], 10)
        p.text(12, 27, LABEL, 7)
        owners = points(self.level, s)[1] if STAGE >= 3 else (-1,)*len(self.level.food)
        for y in range(7):
            for x in range(9):
                pos = (x, y)
                px, py = 12+x*40, 52+y*40
                p.rect(px, py, 39, 39, 4 if pos in cells(self.level) else 5)
                if pos in self.level.food:
                    own = owners[self.level.food.index(pos)]
                    p.circ(px+19, py+19, 6, 7)
                    p.circb(px+19, py+19, 10, (6, 8)[own] if own >= 0 else 13)
                    p.text(px+17, py+17, 'PE'[own] if own >= 0 else '-', 1)
        for sh in s.shells:
            x, y = 12+sh.pos[0]*40+19, 52+sh.pos[1]*40+19
            radius = 15 if sh.size == 2 else 12
            color = (6, 8)[sh.owner] if sh.owner >= 0 else 13
            p.circ(x, y, radius, color)
            p.circb(x, y, radius, 7)
            p.text(x-8, y-7, str(sh.id)+':'+str(sh.size), 1)
            if sh.occupant < 0:
                p.text(x-9, y+4, 'EMPTY', 7)
        for actor, pos in enumerate(s.positions):
            x, y = 12+pos[0]*40+19, 52+pos[1]*40+19
            p.circ(x-10, y-11, 3, (6, 8)[actor])
            p.circ(x+10, y-11, 3, (6, 8)[actor])
            p.text(x-2, y+3, 'PE'[actor], 7)
        if STAGE >= 4 and s.memory.target:
            x, y = s.memory.target
            p.rectb(12+x*40+1, 52+y*40+1, 37, 37, 8)
        p.text(388, 58, 'P: BLUE / YOU', 6)
        p.text(388, 72, 'E: RED / RIVAL', 8)
        p.text(388, 94, 'TICK '+str(s.tick), 7)
        p.text(388, 108, '30 TICKS = 1 SECOND', 13)
        p.text(388, 130, 'ID SIZE OWNER OCCUPANT', 7)
        for row, sh in enumerate(s.shells):
            def who(actor):
                return 'PE'[actor] if actor >= 0 else '-'
            p.text(388, 146+row*17, f'{sh.id}    {sh.size}    {who(sh.owner)}      {who(sh.occupant)}', (6, 8)[sh.owner] if sh.owner >= 0 else 13)
        inside = owned_shell(s, 0)
        interval = BARE_INTERVAL if inside is None else SMALL_INTERVAL if inside.size == 1 else LARGE_INTERVAL
        p.text(388, 209, 'MOVE INTERVAL '+str(interval), 7)
        p.text(388, 225, 'READY IN '+str(max(0, s.ready[0]-s.tick)), 7)
        if STAGE >= 3:
            totals, _ = points(self.level, s)
            p.text(388, 250, f'IF TIDE NOW: {totals[0]} : {totals[1]}', 10)
        else:
            p.text(388, 250, 'FOOD: NOT SCORED YET', 13)
        if STAGE >= 4:
            p.text(388, 272, 'RIVAL: '+s.memory.mode.upper(), 8)
        else:
            p.text(388, 272, 'RIVAL: FIXED FOR PRACTICE', 13)
        if STAGE == 5:
            p.text(12, 345, f'TIDES {s.tides}/3  TOTAL {s.scores[0]} : {s.scores[1]}', 10)
            remain = TIDE-s.tick % TIDE if not s.ended else 0
            p.text(12, 361, f'NEXT TIDE IN {remain} TICKS / RESULT '+(s.event.upper() if s.ended else 'PLAY'), 7)
        else:
            p.text(12, 345, 'PRACTICE: NO TIDES, SCORE TOTALS OR ENDING', 13)
            p.text(12, 361, '01 CARRY > 02 ENTER/LEAVE > 03 INFLUENCE > 04 RIVAL > 05 TIDES', 13)
        p.text(12, 388, ('PAUSED / RELEASE KEYS THEN P' if self.pause else 'EVENT: '+self.reason.upper()), 10)
        p.text(12, 407, 'ARROWS: HOLD TO MOVE / '+('Z: ENTER OR LEAVE' if STAGE >= 2 else 'Z: NOT ADDED YET'), 7)
        p.text(12, 424, 'P: PAUSE / R: LIVE RESET / ESC: QUIT'+(' / 1-3: BEACH' if STAGE == 5 else ''), 7)
        p.text(12, 443, 'AFTER CODE EDIT: SAVE > QUIT > RUN AGAIN (R DOES NOT RELOAD)', 13)


def demo_keys(frame):
    # Finite synthetic key sequence, through exactly the same controller.
    if STAGE == 4:
        return set(), set()
    if STAGE == 1:
        return set(), {'RIGHT'}
    # Leave small shell; move north twice then east twice; enter large shell.
    if frame in (0, 34):
        return {'Z'}, {'Z'}
    if 1 <= frame <= 9:
        return set(), {'UP'}
    if 10 <= frame <= 25:
        return set(), {'RIGHT'}
    if STAGE >= 3 and 35 <= frame <= 74:
        return set(), {'DOWN'}
    return set(), set()


def main(headless=False, screenshot=None, demo=False, report=None):
    # Resolve validated paths before importing Pyxel: import can change cwd.
    import pyxel as p
    p.init(WIDTH, HEIGHT, title='Moving shells lesson '+str(STAGE), fps=30, display_scale=2, headless=headless)
    app = App()
    frame = 0
    limit = (46 if STAGE == 1 else 36 if STAGE == 2 else 90 if STAGE == 3 else 180 if STAGE == 4 else TIDE*3) if demo else 2
    keys = {k: getattr(p, 'KEY_'+k) for k in ('UP', 'DOWN', 'LEFT', 'RIGHT', 'Z', 'P', 'R', '1', '2', '3')}
    def update():
        nonlocal frame
        if p.btnp(p.KEY_ESCAPE) or headless and frame >= limit:
            if screenshot:
                app.draw(p)
                p.screen.save(screenshot, 1)
            if report:
                from pathlib import Path
                Path(report).write_text(json.dumps({'stage': STAGE, 'synthetic': True, 'state': asdict(app.state), 'hash': state_hash(app.state)}, ensure_ascii=False, indent=2)+'\n', encoding='utf-8')
            p.quit()
            return
        if headless:
            if demo:
                pressed, held = demo_keys(frame)
                app.control(pressed, held)
        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)})
        frame += 1
    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')
    parser.add_argument('--report')
    args = parser.parse_args()
    if (args.demo_checkpoint or args.screenshot or args.report) and not args.headless_smoke:
        parser.error('demo and outputs require --headless-smoke')
    for option, suffix in (('screenshot', '.png'), ('report', '.json')):
        value = getattr(args, option)
        if value:
            path = Path(value)
            if path.suffix.lower() != suffix or not path.parent.is_dir() or path.exists():
                parser.error('use a new '+suffix+' in an existing directory')
            setattr(args, option, str(path.resolve()))
    main(args.headless_smoke, args.screenshot, args.demo_checkpoint, args.report)
STEP 05 / 05

三回の満潮で、残した拠点を数える

同じ tick の行動のあとの採点と、終わりの条件。

最後に、元のゲームと同じ満潮を加えます。更新したあとの tick が450の倍数になったとき、行動したあとの盤面で points を計算し、合計に足します。30fps なら450 tick は15秒です。3回目の満潮、tick 1350 で終わります。P での一時停止中や、キーが離されるのを待っている間は tick を進めないので、実際に経った時間とは別です。

満潮のたびに、餌はまた数えられます。餌を3回食べたら消える仕組みではありません。空いた拠点も持ち主に点を生み続けますが、その前に相手が入れば owner が相手に移ります。大きな殻を運んで広く取るか、小さな殻を空けて2つの拠点を持つかを、合計の点数と次の満潮の予告を分けて見ながら判断します。

3回の合計で、P>E なら WON、P=E なら DRAW、P<E なら LOST です。途中に体力や、すぐ負ける条件はなく、元のゲームに Undo もありません。X を押しても時間は戻りません。結果が出たあとは、矢印キーや Z を押しても状態は変わりませんが、R で同じ浜をやり直せ、1・2・3 で状況 A/B/C を選び直せます。新しく押した R を処理できるふつうの状態では、R が P や浜の選択より先です。すでにキーが離されるのを待っているときは、R も含めて、まず全部離します。

05 のルールは、元のゲームの3つの面すべて・ふつうの大きな殻24 tick と比較用の15 tick で、tick ごとの状態を照らし合わせています。教材は英数字の表示で、表紙なしで状況 A から始まり(04 だけ B)、M の表紙・F12 の保存・比較専用の画面は省いています。すべてのプレイのしかたを調べたのではなく、30本の決まった入力・乱数の入力と、6本の目的地の計画、合計48,600回の更新の比較です。元の完成版に戻るときは、model.py の points、policy、step と、app.py の入力の扱いを見比べてください。

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

05_three_tides.py / 注目する部分
    if s.tick % TIDE == 0:
        pts, _ = points(level, s)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 05_three_tides.py
macOS / Linux
.venv/bin/python 05_three_tides.py
画面で見ること状況 A で何もしなければ、450 tick で 3:3、1350 tick で 9:9 の DRAW です。状況 A で小さな殻を残して上の大きな殻に入り、(4,3) に運ぶ例は 9:6 の WON。状況 B で何もしない例は 3:15 の LOST です。自動確認は、状況 A で乗りかえた 1350 tick・9:6 です。
  • 行動したあと、450 tick ごとに採点する
  • 3回の合計で、勝ち負けを決める
  • 終わったあとは、R か浜の選び直し
つまずきやすいところ満潮の判定を、移動より前に置かないでください。更新前の449 tick で成功した移動も、更新後の450 tick の採点に含まれます。IF TIDE NOW は、合計の TOTAL ではありません。
少し変えてみよう

TIDE = 450 を300に変え、保存 → 閉じる → 実行し直してみましょう。状況 A で動かなければ、300・600・900 tick で採点し、900 tick で 9:9 の DRAW です。満潮の回数は3回のままです。確認したら450に戻します。満潮を早くしたとき、同じ乗りかえが間に合うかは、また別の比較の実験です。

05_three_tides.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(452行)
05_three_tides.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。日本語の詳しい説明はLESSON.md。"""
from dataclasses import dataclass, replace, asdict
from collections import deque
import hashlib, json
from functools import lru_cache
DIRS = ((0, -1), (0, 1), (-1, 0), (1, 0))
TIDE = 450
SMALL_INTERVAL = 15
BARE_INTERVAL = 8

@dataclass(frozen=True)
class Level:
    name: str
    food: tuple
    water: tuple
    starts: tuple
    shells: tuple
LEVELS = (Level('今の殻で届く', ((1, 3), (2, 2), (2, 4), (5, 3), (6, 2), (7, 3)), (), ((2, 3), (6, 3)), ((0, 1, (2, 3), 0, 0), (1, 1, (6, 3), 1, 1), (2, 2, (4, 1), -1, -1))), Level('大きい殻の届く湾', ((2, 2), (3, 1), (4, 2), (5, 1), (6, 2), (6, 4), (7, 4)), ((3, 4), (4, 4), (5, 4)), ((2, 3), (7, 3)), ((0, 1, (2, 3), 0, 0), (1, 1, (7, 3), 1, 1), (2, 2, (4, 2), -1, -1))), Level('空けた殻も陣地', ((1, 2), (2, 2), (3, 2), (5, 4), (6, 4), (7, 4), (4, 3)), ((4, 2), (4, 4)), ((2, 2), (6, 4)), ((0, 1, (2, 2), 0, 0), (1, 2, (6, 4), 1, 1), (2, 1, (4, 1), -1, -1))))

@dataclass(frozen=True)
class Shell:
    id: int
    size: int
    pos: tuple
    owner: int
    occupant: int

@dataclass(frozen=True)
class Observation:
    positions: tuple
    shells: tuple
    food: tuple
    water: tuple

@dataclass(frozen=True)
class Memory:
    target: tuple | None = None
    goal_shell: int = -1
    mode: str = 'stay'

@dataclass(frozen=True)
class State:
    positions: tuple
    shells: tuple
    ready: tuple = (0, 0)
    tick: int = 0
    scores: tuple = (0, 0)
    tides: int = 0
    memory: Memory = Memory()
    ended: bool = False
    event: str = 'ready'

@dataclass(frozen=True)
class Command:
    direction: tuple | None = None
    toggle: bool = False

def initial(level):
    return State(level.starts, tuple((Shell(*s) for s in level.shells)))

@lru_cache(maxsize=4096)
def cells(level):
    return tuple(((x, y) for y in range(1, 6) for x in range(1, 8) if (x, y) not in level.water))

@lru_cache(maxsize=4096)
def distances(level, start):
    out = {start: 0}
    q = deque([start])
    valid = set(cells(level))
    while q:
        p = q.popleft()
        for d in DIRS:
            n = (p[0] + d[0], p[1] + d[1])
            if n in valid and n not in out:
                out[n] = out[p] + 1
                q.append(n)
    return out

def owned_shell(state, actor):
    return next((s for s in state.shells if s.occupant == actor), None)

def observe(level, state):
    return Observation(state.positions, state.shells, level.food, level.water)

@lru_cache(maxsize=4096)
def coverage(level, pos, size):
    return sum((distances(level, pos).get(f, 999) <= size for f in level.food))

@lru_cache(maxsize=4096)
def best_coverage(level, size):
    return max((coverage(level, p, size) for p in cells(level)))

def policy(obs, memory):
    level = Level('', obs.food, obs.water, (), ())
    pos = obs.positions[1]
    dist = distances(level, pos)
    inside = next((s for s in obs.shells if s.occupant == 1), None)
    empty = [s for s in obs.shells if s.occupant < 0]
    if inside:
        upgrades = [s for s in empty if best_coverage(level, s.size) >= best_coverage(level, inside.size) + 2 and dist.get(s.pos, 999) <= 4]
        if upgrades:
            s = max(upgrades, key=lambda s: (best_coverage(level, s.size) * 2 - dist.get(s.pos, 999), -dist.get(s.pos, 999), -s.id))
            return Memory(s.pos, s.id, 'leave')
        available = [p for p in cells(level) if not any((sh.pos == p and sh.id != inside.id for sh in obs.shells))]
        target = max(available, key=lambda p: (coverage(level, p, inside.size) * 3 - dist[p], -dist[p], -p[1], -p[0]))
        return Memory(target, -1, 'place')
    if not empty:
        return Memory(pos, -1, 'stay')
    s = max(empty, key=lambda s: (best_coverage(level, s.size) * 2 - dist.get(s.pos, 999), -dist.get(s.pos, 999), -s.id))
    return Memory(s.pos, s.id, 'enter')

def next_step(level, start, target, blocked, forbidden=()):
    if target is None or start == target:
        return None
    valid = set(cells(level))
    q = deque([(start, None)])
    seen = {start}
    while q:
        p, first = q.popleft()
        for d in DIRS:
            n = (p[0] + d[0], p[1] + d[1])
            if n not in valid or n in seen or n == blocked or (n in forbidden):
                continue
            f = first or n
            if n == target:
                return f
            seen.add(n)
            q.append((n, f))
    return None

def points(level, state):
    totals = [0, 0]
    owners = []
    for food in level.food:
        influence = []
        for sh in state.shells:
            if sh.owner < 0:
                continue
            d = distances(level, sh.pos).get(food, 999)
            if d <= sh.size:
                influence.append((d, sh.owner))
        who = -1
        if influence:
            near = min((d for d, o in influence))
            c = {o for d, o in influence if d == near}
            if len(c) == 1:
                who = c.pop()
                totals[who] += 1
        owners.append(who)
    return (tuple(totals), tuple(owners))

def simultaneous_entries(candidates):
    return tuple((v if v >= 0 and candidates.count(v) == 1 else -1 for v in candidates))

def step(level, state, command=Command(), large_interval=24):
    if state.ended:
        return state
    tick = state.tick
    mem = policy(observe(level, state), state.memory) if tick % 15 == 0 else state.memory
    old = state.positions
    inside = [owned_shell(state, i) for i in (0, 1)]
    dest = list(old)
    toggle = [command.toggle, False]
    if mem.mode == 'leave' and tick % 15 == 0 and inside[1]:
        toggle[1] = True
    elif mem.mode == 'enter' and inside[1] is None and (mem.target == old[1]):
        toggle[1] = True
    if not toggle[0] and command.direction in DIRS and (tick >= state.ready[0]):
        p = (old[0][0] + command.direction[0], old[0][1] + command.direction[1])
        if p in cells(level) and p != old[1] and (not (inside[0] and any((sh.pos == p and sh.id != inside[0].id for sh in state.shells)))):
            dest[0] = p
    if not toggle[1] and tick >= state.ready[1]:
        p = next_step(level, old[1], mem.target, old[0], tuple((sh.pos for sh in state.shells if inside[1] and sh.id != inside[1].id)))
        if p:
            dest[1] = p
    event = ''
    if dest[0] == dest[1]:
        dest = list(old)
        event = 'blocked'
    shells = list(state.shells)
    ready = list(state.ready)
    for actor in (0, 1):
        if dest[actor] != old[actor]:
            sh = inside[actor]
            interval = BARE_INTERVAL if sh is None else SMALL_INTERVAL if sh.size == 1 else large_interval
            ready[actor] = tick + interval
            if sh:
                shells[sh.id] = replace(sh, pos=dest[actor])
    requests = [-1, -1]
    for actor in (0, 1):
        if not toggle[actor]:
            continue
        sh = inside[actor]
        if sh:
            shells[sh.id] = replace(shells[sh.id], occupant=-1)
            event = 'left'
        else:
            requests[actor] = next((s.id for s in shells if s.pos == dest[actor] and s.occupant < 0), -1)
    for actor, sid in enumerate(simultaneous_entries(tuple(requests))):
        if sid >= 0:
            shells[sid] = replace(shells[sid], owner=actor, occupant=actor)
            event = 'entered'
    s = replace(state, positions=tuple(dest), shells=tuple(shells), ready=tuple(ready), tick=tick + 1, memory=mem, event=event)
    if s.tick % TIDE == 0:
        pts, _ = points(level, s)
        s = replace(s, scores=tuple((s.scores[i] + pts[i] for i in (0, 1))), tides=s.tides + 1, event='tide')
    if s.tides == 3:
        s = replace(s, ended=True, event='won' if s.scores[0] > s.scores[1] else 'draw' if s.scores[0] == s.scores[1] else 'lost')
    validate(level, s)
    return s

def validate(level, s):
    assert s.positions[0] != s.positions[1]
    assert all((p in cells(level) for p in s.positions))
    assert len({sh.pos for sh in s.shells}) == 3
    assert len(s.shells) == 3 and {sh.id for sh in s.shells} == {0, 1, 2}
    for sh in s.shells:
        assert sh.pos in cells(level) and sh.owner in (-1, 0, 1) and (sh.occupant in (-1, 0, 1))
        if sh.occupant >= 0:
            assert sh.owner == sh.occupant and sh.pos == s.positions[sh.occupant]
    assert all((sum((sh.occupant == i for sh in s.shells)) <= 1 for i in (0, 1)))
    assert 0 <= s.tides <= 3 and s.ended == (s.tides == 3)
    assert sum(s.scores) <= len(level.food) * s.tides

def state_hash(s):
    return hashlib.sha256(json.dumps(asdict(s), sort_keys=True).encode('utf-8')).hexdigest()[:16]

def run(level, actions, large_interval=24):
    s = initial(level)
    log = []
    for frame in range(TIDE * 3):
        cmd = actions.get(frame, Command())
        old = s
        s = step(level, s, cmd, large_interval)
        if cmd != Command() or s.event or s.positions != old.positions:
            log.append({'tick': s.tick, 'command': asdict(cmd), 'hash': state_hash(s), 'scores': s.scores, 'positions': s.positions, 'event': s.event})
    return (s, log)

STAGE = 5
LABEL = 'THREE TIDES / FINAL RULES'
# The model above never imports Pyxel. Only main opens a window.
WIDTH, HEIGHT = 640, 464
LARGE_INTERVAL = 24
DIRECTIONS = {'UP': (0, -1), 'DOWN': (0, 1), 'LEFT': (-1, 0), 'RIGHT': (1, 0)}

class App:
    def __init__(self):
        self.level_index = 1 if STAGE == 4 else 0
        self.lock = False
        self.reset()

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

    def reset(self):
        self.state = initial(self.level)
        self.pause = False
        self.reason = 'ready'

    def control(self, pressed, held):
        # Original priority: release lock -> reset -> pause -> level -> play.
        if self.lock:
            if not held and not pressed:
                self.lock = False
            return
        if 'R' in pressed:
            self.reset()
            self.lock = True
            return
        if 'P' in pressed:
            self.pause = not self.pause
            self.lock = True
            return
        if self.pause:
            return
        if STAGE == 5:
            for index, key in enumerate(('1', '2', '3')):
                if key in pressed:
                    self.level_index = index
                    self.reset()
                    self.lock = True
                    return
        if getattr(self.state, 'ended', False):
            return
        directions = [d for key, d in DIRECTIONS.items() if key in held]
        direction = directions[0] if len(directions) == 1 else None
        toggle = STAGE >= 2 and 'Z' in pressed
        before = self.state
        self.state = step(self.level, before, Command(direction, toggle), LARGE_INTERVAL)
        if self.state.event:
            self.reason = self.state.event

    def draw(self, p):
        s = self.state
        p.cls(1)
        p.text(12, 10, 'MOVING SHELLS / STAGE '+str(STAGE)+' / BEACH '+'ABC'[self.level_index], 10)
        p.text(12, 27, LABEL, 7)
        owners = points(self.level, s)[1] if STAGE >= 3 else (-1,)*len(self.level.food)
        for y in range(7):
            for x in range(9):
                pos = (x, y)
                px, py = 12+x*40, 52+y*40
                p.rect(px, py, 39, 39, 4 if pos in cells(self.level) else 5)
                if pos in self.level.food:
                    own = owners[self.level.food.index(pos)]
                    p.circ(px+19, py+19, 6, 7)
                    p.circb(px+19, py+19, 10, (6, 8)[own] if own >= 0 else 13)
                    p.text(px+17, py+17, 'PE'[own] if own >= 0 else '-', 1)
        for sh in s.shells:
            x, y = 12+sh.pos[0]*40+19, 52+sh.pos[1]*40+19
            radius = 15 if sh.size == 2 else 12
            color = (6, 8)[sh.owner] if sh.owner >= 0 else 13
            p.circ(x, y, radius, color)
            p.circb(x, y, radius, 7)
            p.text(x-8, y-7, str(sh.id)+':'+str(sh.size), 1)
            if sh.occupant < 0:
                p.text(x-9, y+4, 'EMPTY', 7)
        for actor, pos in enumerate(s.positions):
            x, y = 12+pos[0]*40+19, 52+pos[1]*40+19
            p.circ(x-10, y-11, 3, (6, 8)[actor])
            p.circ(x+10, y-11, 3, (6, 8)[actor])
            p.text(x-2, y+3, 'PE'[actor], 7)
        if STAGE >= 4 and s.memory.target:
            x, y = s.memory.target
            p.rectb(12+x*40+1, 52+y*40+1, 37, 37, 8)
        p.text(388, 58, 'P: BLUE / YOU', 6)
        p.text(388, 72, 'E: RED / RIVAL', 8)
        p.text(388, 94, 'TICK '+str(s.tick), 7)
        p.text(388, 108, '30 TICKS = 1 SECOND', 13)
        p.text(388, 130, 'ID SIZE OWNER OCCUPANT', 7)
        for row, sh in enumerate(s.shells):
            def who(actor):
                return 'PE'[actor] if actor >= 0 else '-'
            p.text(388, 146+row*17, f'{sh.id}    {sh.size}    {who(sh.owner)}      {who(sh.occupant)}', (6, 8)[sh.owner] if sh.owner >= 0 else 13)
        inside = owned_shell(s, 0)
        interval = BARE_INTERVAL if inside is None else SMALL_INTERVAL if inside.size == 1 else LARGE_INTERVAL
        p.text(388, 209, 'MOVE INTERVAL '+str(interval), 7)
        p.text(388, 225, 'READY IN '+str(max(0, s.ready[0]-s.tick)), 7)
        if STAGE >= 3:
            totals, _ = points(self.level, s)
            p.text(388, 250, f'IF TIDE NOW: {totals[0]} : {totals[1]}', 10)
        else:
            p.text(388, 250, 'FOOD: NOT SCORED YET', 13)
        if STAGE >= 4:
            p.text(388, 272, 'RIVAL: '+s.memory.mode.upper(), 8)
        else:
            p.text(388, 272, 'RIVAL: FIXED FOR PRACTICE', 13)
        if STAGE == 5:
            p.text(12, 345, f'TIDES {s.tides}/3  TOTAL {s.scores[0]} : {s.scores[1]}', 10)
            remain = TIDE-s.tick % TIDE if not s.ended else 0
            p.text(12, 361, f'NEXT TIDE IN {remain} TICKS / RESULT '+(s.event.upper() if s.ended else 'PLAY'), 7)
        else:
            p.text(12, 345, 'PRACTICE: NO TIDES, SCORE TOTALS OR ENDING', 13)
            p.text(12, 361, '01 CARRY > 02 ENTER/LEAVE > 03 INFLUENCE > 04 RIVAL > 05 TIDES', 13)
        p.text(12, 388, ('PAUSED / RELEASE KEYS THEN P' if self.pause else 'EVENT: '+self.reason.upper()), 10)
        p.text(12, 407, 'ARROWS: HOLD TO MOVE / '+('Z: ENTER OR LEAVE' if STAGE >= 2 else 'Z: NOT ADDED YET'), 7)
        p.text(12, 424, 'P: PAUSE / R: LIVE RESET / ESC: QUIT'+(' / 1-3: BEACH' if STAGE == 5 else ''), 7)
        p.text(12, 443, 'AFTER CODE EDIT: SAVE > QUIT > RUN AGAIN (R DOES NOT RELOAD)', 13)


def demo_keys(frame):
    # Finite synthetic key sequence, through exactly the same controller.
    if STAGE == 4:
        return set(), set()
    if STAGE == 1:
        return set(), {'RIGHT'}
    # Leave small shell; move north twice then east twice; enter large shell.
    if frame in (0, 34):
        return {'Z'}, {'Z'}
    if 1 <= frame <= 9:
        return set(), {'UP'}
    if 10 <= frame <= 25:
        return set(), {'RIGHT'}
    if STAGE >= 3 and 35 <= frame <= 74:
        return set(), {'DOWN'}
    return set(), set()


def main(headless=False, screenshot=None, demo=False, report=None):
    # Resolve validated paths before importing Pyxel: import can change cwd.
    import pyxel as p
    p.init(WIDTH, HEIGHT, title='Moving shells lesson '+str(STAGE), fps=30, display_scale=2, headless=headless)
    app = App()
    frame = 0
    limit = (46 if STAGE == 1 else 36 if STAGE == 2 else 90 if STAGE == 3 else 180 if STAGE == 4 else TIDE*3) if demo else 2
    keys = {k: getattr(p, 'KEY_'+k) for k in ('UP', 'DOWN', 'LEFT', 'RIGHT', 'Z', 'P', 'R', '1', '2', '3')}
    def update():
        nonlocal frame
        if p.btnp(p.KEY_ESCAPE) or headless and frame >= limit:
            if screenshot:
                app.draw(p)
                p.screen.save(screenshot, 1)
            if report:
                from pathlib import Path
                Path(report).write_text(json.dumps({'stage': STAGE, 'synthetic': True, 'state': asdict(app.state), 'hash': state_hash(app.state)}, ensure_ascii=False, indent=2)+'\n', encoding='utf-8')
            p.quit()
            return
        if headless:
            if demo:
                pressed, held = demo_keys(frame)
                app.control(pressed, held)
        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)})
        frame += 1
    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')
    parser.add_argument('--report')
    args = parser.parse_args()
    if (args.demo_checkpoint or args.screenshot or args.report) and not args.headless_smoke:
        parser.error('demo and outputs require --headless-smoke')
    for option, suffix in (('screenshot', '.png'), ('report', '.json')):
        value = getattr(args, option)
        if value:
            path = Path(value)
            if path.suffix.lower() != suffix or not path.parent.is_dir() or path.exists():
                parser.error('use a new '+suffix+' in an existing directory')
            setattr(args, option, str(path.resolve()))
    main(args.headless_smoke, args.screenshot, args.demo_checkpoint, args.report)
AFTER STEP 05

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

ルールのまとめ:30fps の決まった tick で、2匹を同時に処理します。動ける間隔は、はだか8・小さな殻15・大きな殻24 tick。殻から出ても、殻の持ち主は残ります。殻に入ると持ち主が移ります。陸づたいの一番短い距離が大きさ以内の殻のうち、一番近い殻の持ち主に、餌1個で1点。敵と味方が同じ距離なら点なし。450 tick ごとに3回、行動したあとで採点し、合計を比べます。

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

比べる点5ステップの教材元の完成版
段階01 は殻を運ぶ、02 は出入り、03 は取れる範囲の予告。01〜03 の E は止まったまま。04 は AI と同時の処理で、01〜04 には満潮・合計・終わりはありません。05 でゲームのルールが全部そろいます。最初から3つの面、AI、3回の満潮、勝ち負けがあります。Undo はありません。
表示と入力英数字の表示、持ち主と入っているカニの表。01〜03 は状況 A、04 は B、05 は A から始まります。P/R とキーが離されるのを待つ順番は、元のゲームと同じ。05 では浜も選べます。Noto フォントの日本語の文字、表紙で SPACE を押して開始、M で表紙、F12 で記録、比較用の15 tick と専用のデモ。
検証範囲05 の元のゲームとの比較は、3つの面×2つの間隔×5つの入力の列に6つの計画を加えた、48,600の状態。組み立てたキー入力を通した実際の Pyxel の画像、ファイル単体を別の場所に置いても動くか、CP932(Windows の日本語)での読みこみも確かめています。過去の動作確認(QA)の記録とは別です。今回の比較は、すべての入力の列や、人が実際に遊んだことの証明ではありません。

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

  1. model.py の Shell / owned_shell(実際には occupant から探します)
  2. model.py の distances / points:一番短い道と、同じ距離の集まり
  3. model.py の policy / Observation:前の盤面だけを見た判断
  4. model.py の step:候補 → 衝突 → 殻の移動 → 出入り → 満潮、の順番
  5. app.py の lock / btn / btnp:キーが離されるのを待つ仕組みと、同時の入力
  6. experiments.py の A_relocate / B_early / C_two_bases:比較のための計画
自分のゲームに変えるなら

最初は、数字かルールを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の実際のパソコンでの操作や、音が出るかどうかはまだ確認していません。人がすべての操作を試したわけでもありません。ブラウザー版の確認範囲は確認状況をご覧ください。

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