完成サンプルをブラウザーで遊ぶ
この講座のもとになった完成版のゲームを、ブラウザーでそのまま遊べます。キーボードで操作するので、PCで開いてください(スマホのタッチ操作には対応していません)。初めて開くときは、読み込みに少し時間がかかります。
ボタンを押すと、ゲームの読み込みが始まります。
画面に「CLICK TO START」と出たらクリックします。続けてゲーム画面を一度クリックすると、キー操作が効くようになります。ゲームを終えるときは、枠の外にある「閉じる」ボタンを使ってください(Escキーの働きはゲームごとに違います)。ゲーム内のセーブは、閉じる・ページを移る・再読み込みすると消えます。
うまく表示されないとき
ゲームを動かすためのプログラム(Pyxel 2.9.9)は、外部の配信サービス(jsDelivr)から読み込みます。表示されないときは「最初から再読込」を押すか、完成版のソースをダウンロードしてPCで実行してください。ブラウザー版の録画機能は止めています。ブラウザー版で確認できている範囲は検証状況にまとめています。
まん中の位置と距離から、2人の位置を求める。
いきなり完成版のコードを全部読む必要はありません。小さなゲームにルールを1つずつ足しながら、5つのステップで作っていきます。どのステップのファイルもそれだけで動くので、動きを確かめてから次へ進みましょう。
- 必要な環境
- Python 3.11以上 / Pyxel 2.9.9
- 知っておくとよいこと
- Python 3.11以上・仮想環境の起動 / 変数・if・for・関数・リストの基礎 / クラスとプロパティの初歩(set・dataclass・補間は本文で説明します)
- 教材に入っているもの
- ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
- 完成した教材の操作
- 左右:2人のまん中 / 上:近づく / 下:離れる / Space:傘を寄せる向き(03から)・濡れて止まったあとに続ける / P:一時停止 / R:最初から / Esc:終了
- 難易度の目安
- 初級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
- 学習順に迷ったら
- おすすめの学ぶ順番を見る
コードを書き換えたら、ファイルを保存し、動いているゲームを閉じてから、もう一度同じ.pyを実行します。ゲーム中にRキーなどで「やり直し」をしても、書き換えたコードは読み込まれません。
教材のゲーム画面は、日本語フォントを用意しなくても動くように英語で表示しています。ファイルには動作確認用のコードも入っていますが、最初は Game / State・update・draw の3つに注目すれば十分です。わからない言葉は用語集で調べられます。class・self・dataclass はPythonの状態、キー入力と時間の扱いは入力・更新・描画、マス目とピクセルの違いは画面とマップの座標のページで詳しく説明しています。
中心と間隔でふたりを動かす
2つの座標を、まん中と距離から計算する。
2人のまん中 c を64、間の距離 d を20とし、左の人の中心を c-d/2、右の人を c+d/2 で求めます。距離の半分ずつを左右に分けるので、2人の距離はいつも d になります。左右キーは c を1フレームに0.8、上下キーは d を0.6ずつ変えます。1秒に30回(30Hz)更新するので、1秒で30回変わります。体の半径3も含めた外側が、道幅128からはみ出さないように、clamp でまん中の位置を制限します。この段階は、雨も傘も時間制限もない練習です。
下のコードは 01_pair.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
@property
def feet(self):
return self.c - self.d / 2, self.c + self.d / 2実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 01_pair.py.venv/bin/python 01_pair.py- まん中64・距離20から、54と74を計算できる
- 上下キーで、距離が12〜42の範囲で変わる
- 端でも、体全体が道の中に残る
c-d、右を c+d にすると、2人の距離は 2d になってしまいます。また、体の中心だけを道の中に収めると、体のはしの3だけが外にはみ出します。R で戻し、右だけを長く押して、端で止まることを確かめましょう。次に、下で距離を42まで広げた状態では、まん中 c の右の限界が104になる理由を、式で説明してみましょう。
このステップのコード全文(177行)
# MIT License
#
# Copyright (c) 2026 Pyxel 100 contributors
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
#
# The bundled Noto-derived bitmap glyphs are separately licensed under SIL OFL.
# See assets/NOTO-LICENSE.txt.
# 教材にはNoto字形を含めず、ASCIIと図形だけを使います。
"""ふたりで一つの傘・教材 1/5。単独起動できる30Hzのプログラム。"""
FPS = 30
def clamp(value, low, high):
"""値をlow以上high以下に収める。"""
return max(low, min(high, value))
class Model:
"""ゲーム内の座標。描画用の2倍拡大とは分けて考える。"""
def __init__(self):
self.c = 64.0 # ふたりの中心
self.d = 20.0 # ふたりの間隔
@property
def feet(self):
return self.c - self.d / 2, self.c + self.d / 2
def tick(self, move=0, spread=0):
width = 128
self.d = clamp(self.d + spread * 0.6, 12, 42)
half = self.d / 2 + 3
self.c = clamp(self.c + move * 0.8, 64 - width / 2 + half, 64 + width / 2 - half)
def snapshot(self):
return self.c, self.d
class Controller:
"""heldは押し続けるキー、pressedはこのフレームに押し始めたキー。"""
def __init__(self):
self.model = Model()
self.paused = False
def tick(self, held=(), pressed=()):
held, pressed = set(held), set(pressed)
if 'R' in pressed:
self.model = Model()
self.paused = False
return
if 'P' in pressed:
self.paused = not self.paused
return
if self.paused:
return
move = int('RIGHT' in held) - int('LEFT' in held)
spread = int('DOWN' in held) - int('UP' in held)
self.model.tick(move, spread)
KEYS = ('LEFT', 'RIGHT', 'UP', 'DOWN', 'P', 'R' )
def main(headless=False, replay=None, screenshot=None, report=None):
import json
from pathlib import Path
# Pyxel.initが作業ディレクトリを変えても保存先を変えない。
screenshot = str(Path(screenshot).resolve()) if screenshot else None
report = str(Path(report).resolve()) if report else None
import pyxel
control = Controller()
frames = 0
events = []
keys = {name: getattr(pyxel, 'KEY_' + name) for name in KEYS}
duration = max(12, len(replay or []))
def update():
nonlocal frames
frames += 1
if headless:
# smokeは初期状態を描画するだけ。replayのみ合成キーを入力する。
if replay is not None and frames <= len(replay):
event = replay[frames - 1]
control.tick(event.get('held', []), event.get('pressed', []))
events.append({'frame': frames, 'input': event, 'state': control.model.snapshot()})
else:
control.tick([name for name, key in keys.items() if pyxel.btn(key)],
[name for name, key in keys.items() if pyxel.btnp(key)])
def screen_x(x):
return 16 + x * 2
def draw():
m = control.model
pyxel.cls(1)
pyxel.text(10, 9, 'SHARED UMBRELLA / STEP 1', 10)
pyxel.text(10, 22, f'CENTER {m.c:5.1f} DISTANCE {m.d:4.1f}', 7)
pyxel.rect(16, 48, 256, 138, 4)
pyxel.line(16, 48, 16, 186, 15)
pyxel.line(272, 48, 272, 186, 15)
pyxel.text(288, 53, 'DRY PRACTICE', 11)
pyxel.text(288, 67, 'NO TIMER', 6)
pyxel.line(16, 171, 272, 171, 9)
pyxel.line(screen_x(m.c), 174, screen_x(m.c), 186, 7)
for person, x in enumerate(m.feet):
sx = screen_x(x)
color = 10 if person == 0 else 14
pyxel.circ(sx, 155, 6, color)
pyxel.rect(sx - 6, 160, 12, 11, color)
pyxel.text(sx - 2, 178, str(person + 1), color)
pyxel.text(10, 194, f'FEET {m.feet[0]:.1f} / {m.feet[1]:.1f}', 7)
pyxel.text(10, 207, 'FEET = CENTER -/+ DISTANCE / 2', 11)
pyxel.text(10, 227, 'PRACTICE: WATCH THE NUMBERS AS YOU HOLD ARROWS.', 6)
pyxel.text(10, 245, 'LEFT/RIGHT: CENTER UP/DOWN: CLOSE/SPREAD', 7)
pyxel.text(10, 257, 'BODY RADIUS 3 / DISTANCE RANGE 12 TO 42', 6)
pyxel.text(10, 270, 'P: PAUSE R: RESET ESC: QUIT NO WIND / SILENT', 6)
if control.paused:
pyxel.rect(136, 91, 160, 24, 0)
pyxel.text(152, 100, 'PAUSED - P TO RESUME', 10)
if headless and frames >= duration:
if screenshot:
pyxel.screenshot(screenshot, scale=2)
if report:
Path(report).write_text(json.dumps({
'label': 'Synthetic controller input; actual Pyxel headless renderer; silent',
'render_frames': frames, 'model_input_frames': len(events),
'events': events, 'final_state': m.snapshot(), 'paused': control.paused,
}, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
pyxel.quit()
pyxel.init(432, 288, title='Shared Umbrella - Stage 1', fps=FPS, headless=headless)
pyxel.run(update, draw)
if __name__ == '__main__':
import argparse
import json
from pathlib import Path
parser = argparse.ArgumentParser(description=__doc__)
modes = parser.add_mutually_exclusive_group()
modes.add_argument('--headless-smoke', action='store_true', help='Render initial state for 12 frames')
modes.add_argument('--headless-replay', help='UTF-8 JSON list of held/pressed input frames')
parser.add_argument('--screenshot', help='Output PNG (headless only)')
parser.add_argument('--report', help='Output UTF-8 JSON (headless only)')
args = parser.parse_args()
headless = args.headless_smoke or args.headless_replay is not None
if not headless and (args.screenshot or args.report):
parser.error('screenshot/report require a headless mode')
replay = None
if args.headless_replay:
replay = json.loads(Path(args.headless_replay).read_text(encoding='utf-8'))
valid = isinstance(replay, list) and all(
isinstance(e, dict) and set(e) <= {'held', 'pressed'} and all(
isinstance(e.get(k, []), list) and all(isinstance(v, str) and v in KEYS for v in e.get(k, []))
for k in ('held', 'pressed')) for e in replay)
if not valid:
parser.error('Replay must be a list of held/pressed dictionaries using supported key names')
main(headless, replay, args.screenshot, args.report)身体全体が傘の中か調べる
点の判定を、幅のある体の判定に広げる。
傘の中心を、2人のまん中 c と同じ位置に置きます。傘の半分の幅は18、体の半径は3です。体の左はし x-3 が傘の左はし u-18 以上で、しかも右はし x+3 が u+18 以下なら、傘に入っています。まん中の傘で2人とも入れる条件は d/2+3<=18、つまり d<=30 です。BODY IN/OUT は傘に入っているかどうかを表示しているだけで、まだ雨や濡れは計算しません。
下のコードは 02_coverage.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
return x - 3 >= umbrella - 18 - 1e-8 and x + 3 <= umbrella + 18 + 1e-8実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 02_coverage.py.venv/bin/python 02_coverage.py- 人の中心と、体のはしを区別する
- d=30 は IN、30より大きいと OUT になる
- 1e-8 は、小数の計算のわずかな誤差への対策だとわかる
abs(x-u)<=18 だけでは、体の中心だけの判定になってしまいます。たとえば x=47、u=64 では、中心は傘の中でも、左はしの44が傘の左はしの46より外に出ています。紙の上で、u=64 のときの x=49 と x=48.99 を比べてみましょう。ちょうど境目の x=49 は IN です。傘の半分の幅を20に改造するなら、2人とも入れる距離の上限がいくつになるか、先に計算してみましょう。
このステップのコード全文(195行)
# MIT License
#
# Copyright (c) 2026 Pyxel 100 contributors
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
#
# The bundled Noto-derived bitmap glyphs are separately licensed under SIL OFL.
# See assets/NOTO-LICENSE.txt.
# 教材にはNoto字形を含めず、ASCIIと図形だけを使います。
"""ふたりで一つの傘・教材 2/5。単独起動できる30Hzのプログラム。"""
FPS = 30
def clamp(value, low, high):
"""値をlow以上high以下に収める。"""
return max(low, min(high, value))
def covered(x, umbrella):
"""中心点でなく、半径3の身体全体が半幅18の傘に入るか。"""
return x - 3 >= umbrella - 18 - 1e-8 and x + 3 <= umbrella + 18 + 1e-8
class Model:
"""ゲーム内の座標。描画用の2倍拡大とは分けて考える。"""
def __init__(self):
self.c = 64.0 # ふたりの中心
self.d = 20.0 # ふたりの間隔
@property
def feet(self):
return self.c - self.d / 2, self.c + self.d / 2
@property
def umbrella(self):
return self.c
def tick(self, move=0, spread=0):
width = 128
self.d = clamp(self.d + spread * 0.6, 12, 42)
half = self.d / 2 + 3
self.c = clamp(self.c + move * 0.8, 64 - width / 2 + half, 64 + width / 2 - half)
def snapshot(self):
return self.c, self.d, self.umbrella
class Controller:
"""heldは押し続けるキー、pressedはこのフレームに押し始めたキー。"""
def __init__(self):
self.model = Model()
self.paused = False
def tick(self, held=(), pressed=()):
held, pressed = set(held), set(pressed)
if 'R' in pressed:
self.model = Model()
self.paused = False
return
if 'P' in pressed:
self.paused = not self.paused
return
if self.paused:
return
move = int('RIGHT' in held) - int('LEFT' in held)
spread = int('DOWN' in held) - int('UP' in held)
self.model.tick(move, spread)
KEYS = ('LEFT', 'RIGHT', 'UP', 'DOWN', 'P', 'R' )
def main(headless=False, replay=None, screenshot=None, report=None):
import json
from pathlib import Path
# Pyxel.initが作業ディレクトリを変えても保存先を変えない。
screenshot = str(Path(screenshot).resolve()) if screenshot else None
report = str(Path(report).resolve()) if report else None
import pyxel
control = Controller()
frames = 0
events = []
keys = {name: getattr(pyxel, 'KEY_' + name) for name in KEYS}
duration = max(12, len(replay or []))
def update():
nonlocal frames
frames += 1
if headless:
# smokeは初期状態を描画するだけ。replayのみ合成キーを入力する。
if replay is not None and frames <= len(replay):
event = replay[frames - 1]
control.tick(event.get('held', []), event.get('pressed', []))
events.append({'frame': frames, 'input': event, 'state': control.model.snapshot()})
else:
control.tick([name for name, key in keys.items() if pyxel.btn(key)],
[name for name, key in keys.items() if pyxel.btnp(key)])
def screen_x(x):
return 16 + x * 2
def draw():
m = control.model
pyxel.cls(1)
pyxel.text(10, 9, 'SHARED UMBRELLA / STEP 2', 10)
pyxel.text(10, 22, f'CENTER {m.c:5.1f} DISTANCE {m.d:4.1f}', 7)
pyxel.rect(16, 48, 256, 138, 4)
pyxel.line(16, 48, 16, 186, 15)
pyxel.line(272, 48, 272, 186, 15)
pyxel.text(288, 53, 'DRY PRACTICE', 11)
pyxel.text(288, 67, 'NO TIMER', 6)
pyxel.line(16, 171, 272, 171, 9)
pyxel.line(screen_x(m.c), 174, screen_x(m.c), 186, 7)
u = screen_x(m.umbrella)
pyxel.rectb(u - 36, 137, 73, 37, 11)
pyxel.line(u - 36, 137, u - 18, 125, 11)
pyxel.line(u - 18, 125, u + 18, 125, 11)
pyxel.line(u + 18, 125, u + 36, 137, 11)
for person, x in enumerate(m.feet):
sx = screen_x(x)
color = 10 if person == 0 else 14
pyxel.circ(sx, 155, 6, color)
pyxel.rect(sx - 6, 160, 12, 11, color)
pyxel.text(sx - 2, 178, str(person + 1), color)
inside = covered(x, m.umbrella)
pyxel.text(288, 191 + person * 12, f'BODY {person + 1}: ' + ('IN' if inside else 'OUT'), 11 if inside else 8)
if not inside:
pyxel.text(sx - 2, 142, '!', 8)
pyxel.text(10, 194, f'FEET {m.feet[0]:.1f} / {m.feet[1]:.1f}', 7)
pyxel.text(10, 207, 'UMBRELLA HALF WIDTH 18 / BODY RADIUS 3', 11)
pyxel.text(10, 227, 'PRACTICE: WATCH THE NUMBERS AS YOU HOLD ARROWS.', 6)
pyxel.text(10, 245, 'LEFT/RIGHT: CENTER UP/DOWN: CLOSE/SPREAD', 7)
pyxel.text(10, 257, 'BODY RADIUS 3 / DISTANCE RANGE 12 TO 42', 6)
pyxel.text(10, 270, 'P: PAUSE R: RESET ESC: QUIT NO WIND / SILENT', 6)
if control.paused:
pyxel.rect(136, 91, 160, 24, 0)
pyxel.text(152, 100, 'PAUSED - P TO RESUME', 10)
if headless and frames >= duration:
if screenshot:
pyxel.screenshot(screenshot, scale=2)
if report:
Path(report).write_text(json.dumps({
'label': 'Synthetic controller input; actual Pyxel headless renderer; silent',
'render_frames': frames, 'model_input_frames': len(events),
'events': events, 'final_state': m.snapshot(), 'paused': control.paused,
}, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
pyxel.quit()
pyxel.init(432, 288, title='Shared Umbrella - Stage 2', fps=FPS, headless=headless)
pyxel.run(update, draw)
if __name__ == '__main__':
import argparse
import json
from pathlib import Path
parser = argparse.ArgumentParser(description=__doc__)
modes = parser.add_mutually_exclusive_group()
modes.add_argument('--headless-smoke', action='store_true', help='Render initial state for 12 frames')
modes.add_argument('--headless-replay', help='UTF-8 JSON list of held/pressed input frames')
parser.add_argument('--screenshot', help='Output PNG (headless only)')
parser.add_argument('--report', help='Output UTF-8 JSON (headless only)')
args = parser.parse_args()
headless = args.headless_smoke or args.headless_replay is not None
if not headless and (args.screenshot or args.report):
parser.error('screenshot/report require a headless mode')
replay = None
if args.headless_replay:
replay = json.loads(Path(args.headless_replay).read_text(encoding='utf-8'))
valid = isinstance(replay, list) and all(
isinstance(e, dict) and set(e) <= {'held', 'pressed'} and all(
isinstance(e.get(k, []), list) and all(isinstance(v, str) and v in KEYS for v in e.get(k, []))
for k in ('held', 'pressed')) for e in replay)
if not valid:
parser.error('Replay must be a list of held/pressed dictionaries using supported key names')
main(headless, replay, args.screenshot, args.report)傘の目標と現在位置を分ける
押した瞬間の入力と、毎フレームのなめらかな動き。
aim は傘を寄せる向き(まん中0・左-1・右1)、offset は2人のまん中 c から、傘が実際にずれている量です。Space を押した瞬間だけ、0→-1→1→0 の順に向きを選び、目標を aim*12 にします。今の値には、目標との差を最大0.8ずつ足していきます。残りが小さいときはその分だけ足すので、行き過ぎることはありません。傘の座標は c+offset です。「目標を選ぶ処理」と「追いつく処理」を分けているので、1回押してキーを離しても、傘は動き続けます。
下のコードは 03_target.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
return value + clamp(target - value, -speed, speed)実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 03_target.py.venv/bin/python 03_target.py- 目標の aim と、実際の offset の違いを説明できる
- Space を押し続けても、何度も切り替わらない
- 目標の手前で行き過ぎない
pyxel.btn(SPACE) で aim を切り替えると、押している間、1秒に30回も目標が切り替わってしまいます。押した瞬間は btnp、左右や上下の連続した移動は btn を使います。また、offset=aim*12 と直接代入すると、瞬間移動になってしまいます。まん中から左へ12動くには、12÷0.8=15フレームかかります。では、左の-12から右の+12までは何フレームでしょうか。答えの30フレームを、テストの test_umbrella_reaches_offset_in_15_ticks で確かめましょう。
このステップのコード全文(208行)
# MIT License
#
# Copyright (c) 2026 Pyxel 100 contributors
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
#
# The bundled Noto-derived bitmap glyphs are separately licensed under SIL OFL.
# See assets/NOTO-LICENSE.txt.
# 教材にはNoto字形を含めず、ASCIIと図形だけを使います。
"""ふたりで一つの傘・教材 3/5。単独起動できる30Hzのプログラム。"""
FPS = 30
def clamp(value, low, high):
"""値をlow以上high以下に収める。"""
return max(low, min(high, value))
def covered(x, umbrella):
"""中心点でなく、半径3の身体全体が半幅18の傘に入るか。"""
return x - 3 >= umbrella - 18 - 1e-8 and x + 3 <= umbrella + 18 + 1e-8
def approach(value, target, speed):
"""目標までの差を1フレームの最大移動量に制限する。"""
return value + clamp(target - value, -speed, speed)
class Model:
"""ゲーム内の座標。描画用の2倍拡大とは分けて考える。"""
def __init__(self):
self.c = 64.0 # ふたりの中心
self.d = 20.0 # ふたりの間隔
self.offset = 0.0
self.aim = 0 # -1:左、0:中央、1:右
@property
def feet(self):
return self.c - self.d / 2, self.c + self.d / 2
@property
def umbrella(self):
return self.c + self.offset
def tick(self, move=0, spread=0, aim=None):
if aim is not None:
self.aim = clamp(aim, -1, 1)
width = 128
self.d = clamp(self.d + spread * 0.6, 12, 42)
half = self.d / 2 + 3
self.c = clamp(self.c + move * 0.8, 64 - width / 2 + half, 64 + width / 2 - half)
self.offset = approach(self.offset, self.aim * 12, 0.8)
def snapshot(self):
return self.c, self.d, self.offset, self.aim
class Controller:
"""heldは押し続けるキー、pressedはこのフレームに押し始めたキー。"""
def __init__(self):
self.model = Model()
self.paused = False
def tick(self, held=(), pressed=()):
held, pressed = set(held), set(pressed)
if 'R' in pressed:
self.model = Model()
self.paused = False
return
if 'P' in pressed:
self.paused = not self.paused
return
if self.paused:
return
aim = self.model.aim
if 'SPACE' in pressed:
aim = {0: -1, -1: 1, 1: 0}[aim]
move = int('RIGHT' in held) - int('LEFT' in held)
spread = int('DOWN' in held) - int('UP' in held)
self.model.tick(move, spread, aim)
KEYS = ('LEFT', 'RIGHT', 'UP', 'DOWN', 'P', 'R' , 'SPACE')
def main(headless=False, replay=None, screenshot=None, report=None):
import json
from pathlib import Path
# Pyxel.initが作業ディレクトリを変えても保存先を変えない。
screenshot = str(Path(screenshot).resolve()) if screenshot else None
report = str(Path(report).resolve()) if report else None
import pyxel
control = Controller()
frames = 0
events = []
keys = {name: getattr(pyxel, 'KEY_' + name) for name in KEYS}
duration = max(12, len(replay or []))
def update():
nonlocal frames
frames += 1
if headless:
# smokeは初期状態を描画するだけ。replayのみ合成キーを入力する。
if replay is not None and frames <= len(replay):
event = replay[frames - 1]
control.tick(event.get('held', []), event.get('pressed', []))
events.append({'frame': frames, 'input': event, 'state': control.model.snapshot()})
else:
control.tick([name for name, key in keys.items() if pyxel.btn(key)],
[name for name, key in keys.items() if pyxel.btnp(key)])
def screen_x(x):
return 16 + x * 2
def draw():
m = control.model
pyxel.cls(1)
pyxel.text(10, 9, 'SHARED UMBRELLA / STEP 3', 10)
pyxel.text(10, 22, f'CENTER {m.c:5.1f} DISTANCE {m.d:4.1f}', 7)
pyxel.rect(16, 48, 256, 138, 4)
pyxel.line(16, 48, 16, 186, 15)
pyxel.line(272, 48, 272, 186, 15)
pyxel.text(288, 53, 'DRY PRACTICE', 11)
pyxel.text(288, 67, 'NO TIMER', 6)
pyxel.line(16, 171, 272, 171, 9)
pyxel.line(screen_x(m.c), 174, screen_x(m.c), 186, 7)
u = screen_x(m.umbrella)
pyxel.rectb(u - 36, 137, 73, 37, 11)
pyxel.line(u - 36, 137, u - 18, 125, 11)
pyxel.line(u - 18, 125, u + 18, 125, 11)
pyxel.line(u + 18, 125, u + 36, 137, 11)
for person, x in enumerate(m.feet):
sx = screen_x(x)
color = 10 if person == 0 else 14
pyxel.circ(sx, 155, 6, color)
pyxel.rect(sx - 6, 160, 12, 11, color)
pyxel.text(sx - 2, 178, str(person + 1), color)
inside = covered(x, m.umbrella)
pyxel.text(288, 191 + person * 12, f'BODY {person + 1}: ' + ('IN' if inside else 'OUT'), 11 if inside else 8)
if not inside:
pyxel.text(sx - 2, 142, '!', 8)
pyxel.text(10, 194, f'FEET {m.feet[0]:.1f} / {m.feet[1]:.1f}', 7)
pyxel.text(10, 207, f'AIM {m.aim:+d} OFFSET {m.offset:+5.1f} / TARGET {m.aim * 12:+d}', 11)
pyxel.text(10, 227, 'PRACTICE: WATCH THE NUMBERS AS YOU HOLD ARROWS.', 6)
pyxel.text(10, 245, 'LEFT/RIGHT: CENTER UP/DOWN: CLOSE/SPREAD', 7)
pyxel.text(10, 257, 'SPACE: CENTER -> LEFT -> RIGHT -> CENTER', 7)
pyxel.text(10, 270, 'P: PAUSE R: RESET ESC: QUIT NO WIND / SILENT', 6)
if control.paused:
pyxel.rect(136, 91, 160, 24, 0)
pyxel.text(152, 100, 'PAUSED - P TO RESUME', 10)
if headless and frames >= duration:
if screenshot:
pyxel.screenshot(screenshot, scale=2)
if report:
Path(report).write_text(json.dumps({
'label': 'Synthetic controller input; actual Pyxel headless renderer; silent',
'render_frames': frames, 'model_input_frames': len(events),
'events': events, 'final_state': m.snapshot(), 'paused': control.paused,
}, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
pyxel.quit()
pyxel.init(432, 288, title='Shared Umbrella - Stage 3', fps=FPS, headless=headless)
pyxel.run(update, draw)
if __name__ == '__main__':
import argparse
import json
from pathlib import Path
parser = argparse.ArgumentParser(description=__doc__)
modes = parser.add_mutually_exclusive_group()
modes.add_argument('--headless-smoke', action='store_true', help='Render initial state for 12 frames')
modes.add_argument('--headless-replay', help='UTF-8 JSON list of held/pressed input frames')
parser.add_argument('--screenshot', help='Output PNG (headless only)')
parser.add_argument('--report', help='Output UTF-8 JSON (headless only)')
args = parser.parse_args()
headless = args.headless_smoke or args.headless_replay is not None
if not headless and (args.screenshot or args.report):
parser.error('screenshot/report require a headless mode')
replay = None
if args.headless_replay:
replay = json.loads(Path(args.headless_replay).read_text(encoding='utf-8'))
valid = isinstance(replay, list) and all(
isinstance(e, dict) and set(e) <= {'held', 'pressed'} and all(
isinstance(e.get(k, []), list) and all(isinstance(v, str) and v in KEYS for v in e.get(k, []))
for k in ('held', 'pressed')) for e in replay)
if not valid:
parser.error('Replay must be a list of held/pressed dictionaries using supported key names')
main(headless, replay, args.screenshot, args.report)雨・水たまり・安全な橋入口を加える
毎フレーム足していくものと、一度だけ起きる出来事を分ける。
準備の道90フレーム、左から雨が降る橋120フレーム、帰り道90フレームの、3つの区間を作ります。体に雨が当たっていて、傘の外にいると、濡れ具合を1フレームに0.24ずつ足します。水たまりは傘では防げず、足が水たまりにかかった人に、その区間で1回だけ20足します。set 型の splashed で、もう濡れた人の番号を覚えておきます。次の幅56の橋に体全体が入らないときは、手前の広い乾いた道で blocked(足止め)になり、時間も濡れも進まない間に並び方を直せます。
下のコードは 04_hazards.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
if self.status == 'blocked':
if self.legal(self.band.width):
self.status = 'play'
return実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 04_hazards.py.venv/bin/python 04_hazards.py- 雨は1フレームに0.24、水たまりは1人1区間に20、と区別する
- 傘に入っていても、水たまりでは濡れる
- 橋の入り口で、瞬間移動も余計な濡れも起きない
legal で次の橋に入れるかを調べます。距離38、まん中64、傘を左に寄せた状態で、橋に入ってみましょう。幅28の水たまりのはしは50と78、2人の体の中心は45と83になるので、2人とも水をよけながら、左の人を傘で守れます。
このステップのコード全文(326行)
# MIT License
#
# Copyright (c) 2026 Pyxel 100 contributors
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
#
# The bundled Noto-derived bitmap glyphs are separately licensed under SIL OFL.
# See assets/NOTO-LICENSE.txt.
# 教材にはNoto字形を含めず、ASCIIと図形だけを使います。
"""ふたりで一つの傘・教材 4/5。単独起動できる30Hzのプログラム。"""
from dataclasses import dataclass
FPS = 30
def clamp(value, low, high):
"""値をlow以上high以下に収める。"""
return max(low, min(high, value))
def covered(x, umbrella):
"""中心点でなく、半径3の身体全体が半幅18の傘に入るか。"""
return x - 3 >= umbrella - 18 - 1e-8 and x + 3 <= umbrella + 18 + 1e-8
def approach(value, target, speed):
"""目標までの差を1フレームの最大移動量に制限する。"""
return value + clamp(target - value, -speed, speed)
@dataclass(frozen=True)
class Band:
name: str
frames: int = 90
rain: str = 'none'
puddle: int = 0
width: int = 128
BANDS = (Band('PREPARE'), Band('LEFT BRIDGE', 120, 'left', 28, 56), Band('HOME PATH'))
class Model:
"""ゲーム内の座標。描画用の2倍拡大とは分けて考える。"""
def __init__(self):
self.c = 64.0 # ふたりの中心
self.d = 20.0 # ふたりの間隔
self.offset = 0.0
self.aim = 0 # -1:左、0:中央、1:右
self.bands = BANDS
self.wet = [0.0, 0.0]
self.i = 0
self.t = 0
self.total = 0
self.status = 'play'
self.splashed = set()
self.exposed = False
self.clean = 0
self.alerted = False
@property
def feet(self):
return self.c - self.d / 2, self.c + self.d / 2
@property
def umbrella(self):
return self.c + self.offset
@property
def band(self):
return self.bands[min(self.i, len(self.bands) - 1)]
def legal(self, width):
return all(64 - width / 2 + 3 <= x <= 64 + width / 2 - 3 for x in self.feet)
def rain_on(self, x):
rain = self.band.rain
return rain == 'full' or (rain == 'left' and x - 3 < 64) or (rain == 'right' and x + 3 > 64)
def continue_wet(self):
self.alerted = True
self.status = 'play'
def tick(self, move=0, spread=0, aim=None):
if self.status not in ('play', 'blocked'):
return
if aim is not None:
self.aim = clamp(aim, -1, 1)
width = 128 if self.status == 'blocked' else self.band.width
self.d = clamp(self.d + spread * 0.6, 12, 42)
half = self.d / 2 + 3
self.c = clamp(self.c + move * 0.8, 64 - width / 2 + half, 64 + width / 2 - half)
self.offset = approach(self.offset, self.aim * 12, 0.8)
# 橋に入れない間は前の広い道にいる。雨・水たまり・時間を進めない。
if self.status == 'blocked':
if self.legal(self.band.width):
self.status = 'play'
return
b = self.band
for person, x in enumerate(self.feet):
if self.rain_on(x) and not covered(x, self.umbrella):
self.exposed = True
self.wet[person] = min(100, self.wet[person] + 0.24)
if b.puddle and abs(x - 64) < b.puddle / 2 + 3 and person not in self.splashed:
self.exposed = True
self.wet[person] = min(100, self.wet[person] + 20)
self.splashed.add(person)
if max(self.wet) >= 100 and not self.alerted:
self.status = 'wet'
return
self.t += 1
self.total += 1
if self.t >= b.frames:
self.clean += int(not self.exposed)
self.exposed = False
self.i += 1
self.t = 0
self.splashed.clear()
if self.i == len(self.bands):
self.status = 'done'
elif not self.legal(self.band.width):
self.status = 'blocked'
def snapshot(self):
return self.c, self.d, self.offset, tuple(self.wet), self.i, self.t, self.total, self.status, self.clean
class Controller:
"""heldは押し続けるキー、pressedはこのフレームに押し始めたキー。"""
def __init__(self):
self.model = Model()
self.paused = False
def tick(self, held=(), pressed=()):
held, pressed = set(held), set(pressed)
if 'R' in pressed:
self.model = Model()
self.paused = False
return
if 'P' in pressed:
self.paused = not self.paused
return
if self.paused:
return
if self.model.status == 'wet':
if 'SPACE' in pressed:
self.model.continue_wet()
return
if self.model.status == 'done':
return
aim = self.model.aim
if 'SPACE' in pressed:
aim = {0: -1, -1: 1, 1: 0}[aim]
move = int('RIGHT' in held) - int('LEFT' in held)
spread = int('DOWN' in held) - int('UP' in held)
self.model.tick(move, spread, aim)
KEYS = ('LEFT', 'RIGHT', 'UP', 'DOWN', 'P', 'R' , 'SPACE')
def main(headless=False, replay=None, screenshot=None, report=None):
import json
from pathlib import Path
# Pyxel.initが作業ディレクトリを変えても保存先を変えない。
screenshot = str(Path(screenshot).resolve()) if screenshot else None
report = str(Path(report).resolve()) if report else None
import pyxel
control = Controller()
frames = 0
events = []
keys = {name: getattr(pyxel, 'KEY_' + name) for name in KEYS}
duration = max(12, len(replay or []))
def update():
nonlocal frames
frames += 1
if headless:
# smokeは初期状態を描画するだけ。replayのみ合成キーを入力する。
if replay is not None and frames <= len(replay):
event = replay[frames - 1]
control.tick(event.get('held', []), event.get('pressed', []))
events.append({'frame': frames, 'input': event, 'state': control.model.snapshot()})
else:
control.tick([name for name, key in keys.items() if pyxel.btn(key)],
[name for name, key in keys.items() if pyxel.btnp(key)])
def screen_x(x):
return 16 + x * 2
def draw():
m = control.model
pyxel.cls(1)
pyxel.text(10, 9, 'SHARED UMBRELLA / STEP 4', 10)
pyxel.text(10, 22, f'CENTER {m.c:5.1f} DISTANCE {m.d:4.1f}', 7)
pyxel.rect(16, 48, 256, 138, 4)
pyxel.line(16, 48, 16, 186, 15)
pyxel.line(272, 48, 272, 186, 15)
b = m.band
left = screen_x(64 - b.width / 2)
right = screen_x(64 + b.width / 2)
if b.width < 128:
pyxel.rect(16, 48, left - 16, 138, 3)
pyxel.rect(right, 48, 272 - right, 138, 3)
for y in range(48, 185, 12):
pyxel.rect(left - 2, y, 4, 6, 15)
pyxel.rect(right - 2, y, 4, 6, 15)
if b.puddle:
puddle_left = screen_x(64 - b.puddle / 2)
pyxel.rect(puddle_left, 48, b.puddle * 2, 138, 1)
for y in range(54, 185, 12):
pyxel.line(puddle_left + 3, y, puddle_left + b.puddle * 2 - 4, y, 12)
if b.rain != 'none':
rain_left = screen_x(64) if b.rain == 'right' else left
rain_right = screen_x(64) if b.rain == 'left' else right
for x in range(int(rain_left) + 4, int(rain_right), 12):
for y in range(51, 182, 16):
pyxel.line(x, y, x, y + 6, 6)
nxt = m.bands[m.i + 1].name if m.i + 1 < len(m.bands) else 'HOME'
pyxel.text(10, 35, f'NOW {b.name} / NEXT {nxt}', 6)
# 入口停止中は橋の手前の乾いた広い道にいることを絵にも反映。
if m.status == 'blocked':
pyxel.rect(17, 126, 255, 60, 4)
pyxel.line(17, 126, left, 102, 15)
pyxel.line(271, 126, right, 102, 15)
progress = m.total / sum(band.frames for band in m.bands)
pyxel.rect(288, 56, 126, 5, 5)
pyxel.rect(288, 56, progress * 126, 5, 10)
pyxel.text(288, 45, f'HOME {progress * 100:3.0f}%', 7)
for person in range(2):
pyxel.text(288, 80 + person * 32, f'WET {person + 1}: {m.wet[person]:5.2f}', 7)
pyxel.rect(288, 90 + person * 32, 100, 4, 5)
pyxel.rect(288, 90 + person * 32, m.wet[person], 4, 8)
pyxel.text(288, 149, f'STATE {m.status.upper()}', 11)
pyxel.text(288, 162, f'CLEAN {m.clean}/{len(m.bands)}', 6)
pyxel.line(16, 171, 272, 171, 9)
pyxel.line(screen_x(m.c), 174, screen_x(m.c), 186, 7)
u = screen_x(m.umbrella)
pyxel.rectb(u - 36, 137, 73, 37, 11)
pyxel.line(u - 36, 137, u - 18, 125, 11)
pyxel.line(u - 18, 125, u + 18, 125, 11)
pyxel.line(u + 18, 125, u + 36, 137, 11)
for person, x in enumerate(m.feet):
sx = screen_x(x)
color = 10 if person == 0 else 14
pyxel.circ(sx, 155, 6, color)
pyxel.rect(sx - 6, 160, 12, 11, color)
pyxel.text(sx - 2, 178, str(person + 1), color)
inside = covered(x, m.umbrella)
pyxel.text(288, 191 + person * 12, f'BODY {person + 1}: ' + ('IN' if inside else 'OUT'), 11 if inside else 8)
if not inside:
pyxel.text(sx - 2, 142, '!', 8)
pyxel.text(10, 194, f'FEET {m.feet[0]:.1f} / {m.feet[1]:.1f}', 7)
pyxel.text(10, 207, f'AIM {m.aim:+d} OFFSET {m.offset:+5.1f} / TARGET {m.aim * 12:+d}', 11)
if m.status == 'blocked':
pyxel.text(10, 227, 'SAFE STOP: MOVE BOTH BODIES INSIDE THE BRIDGE.', 10)
elif m.status == 'wet':
pyxel.text(10, 227, 'WET 100: SPACE TO CONTINUE, OR R TO RESTART.', 8)
elif m.status == 'done':
pyxel.text(10, 227, 'WELCOME HOME! GETTING WET IS NOT A GAME OVER.', 10)
else:
pyxel.text(10, 227, 'RAIN: UMBRELLA. PUDDLE: MOVE YOUR FEET.', 6)
pyxel.text(10, 245, 'LEFT/RIGHT: CENTER UP/DOWN: CLOSE/SPREAD', 7)
pyxel.text(10, 257, 'SPACE: CENTER -> LEFT -> RIGHT -> CENTER', 7)
pyxel.text(10, 270, 'P: PAUSE R: RESET ESC: QUIT NO WIND / SILENT', 6)
if control.paused:
pyxel.rect(136, 91, 160, 24, 0)
pyxel.text(152, 100, 'PAUSED - P TO RESUME', 10)
if headless and frames >= duration:
if screenshot:
pyxel.screenshot(screenshot, scale=2)
if report:
Path(report).write_text(json.dumps({
'label': 'Synthetic controller input; actual Pyxel headless renderer; silent',
'render_frames': frames, 'model_input_frames': len(events),
'events': events, 'final_state': m.snapshot(), 'paused': control.paused,
}, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
pyxel.quit()
pyxel.init(432, 288, title='Shared Umbrella - Stage 4', fps=FPS, headless=headless)
pyxel.run(update, draw)
if __name__ == '__main__':
import argparse
import json
from pathlib import Path
parser = argparse.ArgumentParser(description=__doc__)
modes = parser.add_mutually_exclusive_group()
modes.add_argument('--headless-smoke', action='store_true', help='Render initial state for 12 frames')
modes.add_argument('--headless-replay', help='UTF-8 JSON list of held/pressed input frames')
parser.add_argument('--screenshot', help='Output PNG (headless only)')
parser.add_argument('--report', help='Output UTF-8 JSON (headless only)')
args = parser.parse_args()
headless = args.headless_smoke or args.headless_replay is not None
if not headless and (args.screenshot or args.report):
parser.error('screenshot/report require a headless mode')
replay = None
if args.headless_replay:
replay = json.loads(Path(args.headless_replay).read_text(encoding='utf-8'))
valid = isinstance(replay, list) and all(
isinstance(e, dict) and set(e) <= {'held', 'pressed'} and all(
isinstance(e.get(k, []), list) and all(isinstance(v, str) and v in KEYS for v in e.get(k, []))
for k in ('held', 'pressed')) for e in replay)
if not valid:
parser.error('Replay must be a list of held/pressed dictionaries using supported key names')
main(headless, replay, args.screenshot, args.report)次の地面を読み、ふたりで家へ帰る
データの並びで、準備と操作をつなぐ短いコースを作る。
Model の更新のルールは04のままで、BANDS を元のゲームの C の7つの区間に置きかえます。左の橋のあとに2秒の戻す道、全面の雨4秒、3秒の離れる道、右の橋4秒が並び、「橋では離れる」「全面の雨の前には寄り直す」といった準備の順番を学びます。NOW と NEXT は、今と次の区間の予告です。690フレーム(23秒)で家に着き、濡れ0は「できたらうれしい」上達の目標です。濡れが100になると止まりますが、Space で続けられ、ゲームオーバーにはなりません。
下のコードは 05_home.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
BANDS = (
Band('PREPARE'),
Band('LEFT BRIDGE', 120, 'left', 28, 56),
Band('RETURN', 60),
Band('FULL RAIN', 120, 'full'),
Band('SPREAD', 90),
Band('RIGHT BRIDGE', 120, 'right', 28, 56),
Band('HOME PATH'),
)実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 05_home.py.venv/bin/python 05_home.py05_home_inputs.json の自動操作では、690回の更新で WELCOME HOME、WET 0/0、CLEAN 7/7 になります。何も操作しなくても23秒で家に着き、その場合は水たまり2つ分で WET 40/40、CLEAN 5/7 になります。「家に帰る」ことと「濡れずに帰る」ことは、別の目標として確かめます。- 左の橋→寄り直し→全面の雨→離れ直し→右の橋、の順番を試す
- 濡れても家に着けば終わる
- 元のゲームと合わせた範囲と、省いた機能がわかる
i が区間の数と同じになるので、band プロパティは最後の区間を安全に参照するようにしています。まず何も操作せずに家まで帰ってみましょう。次は、橋の前に距離38・傘を雨の側へ、全面の雨の前に距離20・傘をまん中へ戻して、濡れずに帰ることを目指します。そのあと BANDS の準備の区間を90から120に変えて、遊びやすさと全体の時間がどう変わるか確かめましょう(時間を変えた版は、元のゲームの C とは違う改造です)。
このステップのコード全文(335行)
# MIT License
#
# Copyright (c) 2026 Pyxel 100 contributors
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
#
# The bundled Noto-derived bitmap glyphs are separately licensed under SIL OFL.
# See assets/NOTO-LICENSE.txt.
# 教材にはNoto字形を含めず、ASCIIと図形だけを使います。
"""ふたりで一つの傘・教材 5/5。単独起動できる30Hzのプログラム。"""
from dataclasses import dataclass
FPS = 30
def clamp(value, low, high):
"""値をlow以上high以下に収める。"""
return max(low, min(high, value))
def covered(x, umbrella):
"""中心点でなく、半径3の身体全体が半幅18の傘に入るか。"""
return x - 3 >= umbrella - 18 - 1e-8 and x + 3 <= umbrella + 18 + 1e-8
def approach(value, target, speed):
"""目標までの差を1フレームの最大移動量に制限する。"""
return value + clamp(target - value, -speed, speed)
@dataclass(frozen=True)
class Band:
name: str
frames: int = 90
rain: str = 'none'
puddle: int = 0
width: int = 128
# 原作Cの形・時間・雨・水たまり幅・道幅。名前だけASCIIに置換。
BANDS = (
Band('PREPARE'),
Band('LEFT BRIDGE', 120, 'left', 28, 56),
Band('RETURN', 60),
Band('FULL RAIN', 120, 'full'),
Band('SPREAD', 90),
Band('RIGHT BRIDGE', 120, 'right', 28, 56),
Band('HOME PATH'),
)
class Model:
"""ゲーム内の座標。描画用の2倍拡大とは分けて考える。"""
def __init__(self):
self.c = 64.0 # ふたりの中心
self.d = 20.0 # ふたりの間隔
self.offset = 0.0
self.aim = 0 # -1:左、0:中央、1:右
self.bands = BANDS
self.wet = [0.0, 0.0]
self.i = 0
self.t = 0
self.total = 0
self.status = 'play'
self.splashed = set()
self.exposed = False
self.clean = 0
self.alerted = False
@property
def feet(self):
return self.c - self.d / 2, self.c + self.d / 2
@property
def umbrella(self):
return self.c + self.offset
@property
def band(self):
return self.bands[min(self.i, len(self.bands) - 1)]
def legal(self, width):
return all(64 - width / 2 + 3 <= x <= 64 + width / 2 - 3 for x in self.feet)
def rain_on(self, x):
rain = self.band.rain
return rain == 'full' or (rain == 'left' and x - 3 < 64) or (rain == 'right' and x + 3 > 64)
def continue_wet(self):
self.alerted = True
self.status = 'play'
def tick(self, move=0, spread=0, aim=None):
if self.status not in ('play', 'blocked'):
return
if aim is not None:
self.aim = clamp(aim, -1, 1)
width = 128 if self.status == 'blocked' else self.band.width
self.d = clamp(self.d + spread * 0.6, 12, 42)
half = self.d / 2 + 3
self.c = clamp(self.c + move * 0.8, 64 - width / 2 + half, 64 + width / 2 - half)
self.offset = approach(self.offset, self.aim * 12, 0.8)
# 橋に入れない間は前の広い道にいる。雨・水たまり・時間を進めない。
if self.status == 'blocked':
if self.legal(self.band.width):
self.status = 'play'
return
b = self.band
for person, x in enumerate(self.feet):
if self.rain_on(x) and not covered(x, self.umbrella):
self.exposed = True
self.wet[person] = min(100, self.wet[person] + 0.24)
if b.puddle and abs(x - 64) < b.puddle / 2 + 3 and person not in self.splashed:
self.exposed = True
self.wet[person] = min(100, self.wet[person] + 20)
self.splashed.add(person)
if max(self.wet) >= 100 and not self.alerted:
self.status = 'wet'
return
self.t += 1
self.total += 1
if self.t >= b.frames:
self.clean += int(not self.exposed)
self.exposed = False
self.i += 1
self.t = 0
self.splashed.clear()
if self.i == len(self.bands):
self.status = 'done'
elif not self.legal(self.band.width):
self.status = 'blocked'
def snapshot(self):
return self.c, self.d, self.offset, tuple(self.wet), self.i, self.t, self.total, self.status, self.clean
class Controller:
"""heldは押し続けるキー、pressedはこのフレームに押し始めたキー。"""
def __init__(self):
self.model = Model()
self.paused = False
def tick(self, held=(), pressed=()):
held, pressed = set(held), set(pressed)
if 'R' in pressed:
self.model = Model()
self.paused = False
return
if 'P' in pressed:
self.paused = not self.paused
return
if self.paused:
return
if self.model.status == 'wet':
if 'SPACE' in pressed:
self.model.continue_wet()
return
if self.model.status == 'done':
return
aim = self.model.aim
if 'SPACE' in pressed:
aim = {0: -1, -1: 1, 1: 0}[aim]
move = int('RIGHT' in held) - int('LEFT' in held)
spread = int('DOWN' in held) - int('UP' in held)
self.model.tick(move, spread, aim)
KEYS = ('LEFT', 'RIGHT', 'UP', 'DOWN', 'P', 'R' , 'SPACE')
def main(headless=False, replay=None, screenshot=None, report=None):
import json
from pathlib import Path
# Pyxel.initが作業ディレクトリを変えても保存先を変えない。
screenshot = str(Path(screenshot).resolve()) if screenshot else None
report = str(Path(report).resolve()) if report else None
import pyxel
control = Controller()
frames = 0
events = []
keys = {name: getattr(pyxel, 'KEY_' + name) for name in KEYS}
duration = max(12, len(replay or []))
def update():
nonlocal frames
frames += 1
if headless:
# smokeは初期状態を描画するだけ。replayのみ合成キーを入力する。
if replay is not None and frames <= len(replay):
event = replay[frames - 1]
control.tick(event.get('held', []), event.get('pressed', []))
events.append({'frame': frames, 'input': event, 'state': control.model.snapshot()})
else:
control.tick([name for name, key in keys.items() if pyxel.btn(key)],
[name for name, key in keys.items() if pyxel.btnp(key)])
def screen_x(x):
return 16 + x * 2
def draw():
m = control.model
pyxel.cls(1)
pyxel.text(10, 9, 'SHARED UMBRELLA / STEP 5', 10)
pyxel.text(10, 22, f'CENTER {m.c:5.1f} DISTANCE {m.d:4.1f}', 7)
pyxel.rect(16, 48, 256, 138, 4)
pyxel.line(16, 48, 16, 186, 15)
pyxel.line(272, 48, 272, 186, 15)
b = m.band
left = screen_x(64 - b.width / 2)
right = screen_x(64 + b.width / 2)
if b.width < 128:
pyxel.rect(16, 48, left - 16, 138, 3)
pyxel.rect(right, 48, 272 - right, 138, 3)
for y in range(48, 185, 12):
pyxel.rect(left - 2, y, 4, 6, 15)
pyxel.rect(right - 2, y, 4, 6, 15)
if b.puddle:
puddle_left = screen_x(64 - b.puddle / 2)
pyxel.rect(puddle_left, 48, b.puddle * 2, 138, 1)
for y in range(54, 185, 12):
pyxel.line(puddle_left + 3, y, puddle_left + b.puddle * 2 - 4, y, 12)
if b.rain != 'none':
rain_left = screen_x(64) if b.rain == 'right' else left
rain_right = screen_x(64) if b.rain == 'left' else right
for x in range(int(rain_left) + 4, int(rain_right), 12):
for y in range(51, 182, 16):
pyxel.line(x, y, x, y + 6, 6)
nxt = m.bands[m.i + 1].name if m.i + 1 < len(m.bands) else 'HOME'
pyxel.text(10, 35, f'NOW {b.name} / NEXT {nxt}', 6)
# 入口停止中は橋の手前の乾いた広い道にいることを絵にも反映。
if m.status == 'blocked':
pyxel.rect(17, 126, 255, 60, 4)
pyxel.line(17, 126, left, 102, 15)
pyxel.line(271, 126, right, 102, 15)
progress = m.total / sum(band.frames for band in m.bands)
pyxel.rect(288, 56, 126, 5, 5)
pyxel.rect(288, 56, progress * 126, 5, 10)
pyxel.text(288, 45, f'HOME {progress * 100:3.0f}%', 7)
for person in range(2):
pyxel.text(288, 80 + person * 32, f'WET {person + 1}: {m.wet[person]:5.2f}', 7)
pyxel.rect(288, 90 + person * 32, 100, 4, 5)
pyxel.rect(288, 90 + person * 32, m.wet[person], 4, 8)
pyxel.text(288, 149, f'STATE {m.status.upper()}', 11)
pyxel.text(288, 162, f'CLEAN {m.clean}/{len(m.bands)}', 6)
pyxel.line(16, 171, 272, 171, 9)
pyxel.line(screen_x(m.c), 174, screen_x(m.c), 186, 7)
u = screen_x(m.umbrella)
pyxel.rectb(u - 36, 137, 73, 37, 11)
pyxel.line(u - 36, 137, u - 18, 125, 11)
pyxel.line(u - 18, 125, u + 18, 125, 11)
pyxel.line(u + 18, 125, u + 36, 137, 11)
for person, x in enumerate(m.feet):
sx = screen_x(x)
color = 10 if person == 0 else 14
pyxel.circ(sx, 155, 6, color)
pyxel.rect(sx - 6, 160, 12, 11, color)
pyxel.text(sx - 2, 178, str(person + 1), color)
inside = covered(x, m.umbrella)
pyxel.text(288, 191 + person * 12, f'BODY {person + 1}: ' + ('IN' if inside else 'OUT'), 11 if inside else 8)
if not inside:
pyxel.text(sx - 2, 142, '!', 8)
pyxel.text(10, 194, f'FEET {m.feet[0]:.1f} / {m.feet[1]:.1f}', 7)
pyxel.text(10, 207, f'AIM {m.aim:+d} OFFSET {m.offset:+5.1f} / TARGET {m.aim * 12:+d}', 11)
if m.status == 'blocked':
pyxel.text(10, 227, 'SAFE STOP: MOVE BOTH BODIES INSIDE THE BRIDGE.', 10)
elif m.status == 'wet':
pyxel.text(10, 227, 'WET 100: SPACE TO CONTINUE, OR R TO RESTART.', 8)
elif m.status == 'done':
pyxel.text(10, 227, 'WELCOME HOME! GETTING WET IS NOT A GAME OVER.', 10)
else:
pyxel.text(10, 227, 'RAIN: UMBRELLA. PUDDLE: MOVE YOUR FEET.', 6)
pyxel.text(10, 245, 'LEFT/RIGHT: CENTER UP/DOWN: CLOSE/SPREAD', 7)
pyxel.text(10, 257, 'SPACE: CENTER -> LEFT -> RIGHT -> CENTER', 7)
pyxel.text(10, 270, 'P: PAUSE R: RESET ESC: QUIT NO WIND / SILENT', 6)
if control.paused:
pyxel.rect(136, 91, 160, 24, 0)
pyxel.text(152, 100, 'PAUSED - P TO RESUME', 10)
if headless and frames >= duration:
if screenshot:
pyxel.screenshot(screenshot, scale=2)
if report:
Path(report).write_text(json.dumps({
'label': 'Synthetic controller input; actual Pyxel headless renderer; silent',
'render_frames': frames, 'model_input_frames': len(events),
'events': events, 'final_state': m.snapshot(), 'paused': control.paused,
}, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
pyxel.quit()
pyxel.init(432, 288, title='Shared Umbrella - Stage 5', fps=FPS, headless=headless)
pyxel.run(update, draw)
if __name__ == '__main__':
import argparse
import json
from pathlib import Path
parser = argparse.ArgumentParser(description=__doc__)
modes = parser.add_mutually_exclusive_group()
modes.add_argument('--headless-smoke', action='store_true', help='Render initial state for 12 frames')
modes.add_argument('--headless-replay', help='UTF-8 JSON list of held/pressed input frames')
parser.add_argument('--screenshot', help='Output PNG (headless only)')
parser.add_argument('--report', help='Output UTF-8 JSON (headless only)')
args = parser.parse_args()
headless = args.headless_smoke or args.headless_replay is not None
if not headless and (args.screenshot or args.report):
parser.error('screenshot/report require a headless mode')
replay = None
if args.headless_replay:
replay = json.loads(Path(args.headless_replay).read_text(encoding='utf-8'))
valid = isinstance(replay, list) and all(
isinstance(e, dict) and set(e) <= {'held', 'pressed'} and all(
isinstance(e.get(k, []), list) and all(isinstance(v, str) and v in KEYS for v in e.get(k, []))
for k in ('held', 'pressed')) for e in replay)
if not valid:
parser.error('Replay must be a list of held/pressed dictionaries using supported key names')
main(headless, replay, args.screenshot, args.report)仕組みがわかったら、元の完成版を読んでみよう。
元のゲームは1秒に30回(30Hz)更新します。体の中心は c±d/2、体の半径は3、傘の半分の幅は18、距離 d は12〜42です。1フレームに、まん中は0.8・距離は0.6・傘のずれは0.8動きます。雨は傘の外にいると1フレームに0.24、水たまりは1人1区間に+20です。橋の入り口で体が道からはみ出すときは、手前の広い道で安全に止まります。濡れが100になると止まって選べ、続けることもできます。家に着けば終わりで、濡れずに帰るのはおまけの目標です。風やランダムな生成はありません。
ここまでに作ったのは、元のゲームの仕組みだけを取り出して作り直した「簡略版」です。元のゲームのソースは手を加えずに、別のZIP(元の完成版ソース)に入れてあります。
| 比べる点 | 5ステップの教材 | 元の完成版 |
|---|---|---|
| 道の作り | 01〜03は時間制限なしの乾いた道での操作練習。04は準備90・左の橋120・帰り90の3区間。05は元のゲームのCの7区間、合計690フレーム(23秒)を使う。 | A・B・Cの3つの状況を選べる。Cは、戻る準備を学ぶ7区間。 |
| 比べるルール | 05では、ふつうの進み方での位置の計算・移動・雨・水たまり・橋の入り口・濡れて止まる/続ける・家に着く、を元のゲームと同じにしている。01〜03は危険の計算を省略。04は同じルールで短い別のコース。 | 最初からすべての機能が有効。区間のやり直し(retry_band)・3秒の準備・入ったときの記録・実験の記録もある。 |
| 画面と予告 | 英字(ASCII)と図形。NOW と NEXT は文字の予告で、橋の入り口では手前の乾いた広い道を描く。始めてすぐ操作できる。BODY IN/OUT は、雨があるかどうかに関係なく、傘の範囲を表示する。 | 日本語の文字、スクロールする今と次の地面、タイトルでの選択と、くわしい操作パネル。 |
| 省いた機能 | Tの区間練習、Vの左右反転、幅128の比較版(baseline)、AとBの選択、Nで次の道、Qで選択に戻る、F12の記録、GIFの書き出しは省いている。Pの一時停止とRのやり直しはある。 | これらの選択・練習・比較・記録の機能がある。 |
| 音と確認 | 音はなし。付属のJSONは自動の入力、PNGは実際のPyxelで画面を表示せずに描いたもの。人の操作・OSのキー入力・ブラウザー・Windows/macOSの画面・音の出力は確認していない。 | 元のゲームも音なし。元のゲームのデモや、これまでの確認の記録は、教材の新しい確認の数には含めない。 |
完成版のコードで、次に読むところ
- 元のゲームの
model.pyのModel.tickとcoveredで、判定の境目と更新の順番を比べましょう。 - 元のゲームの
Model.retry_bandを読み、entry_poseとprepで、区間に入ったときの並び方に戻す方法を学びましょう。 - 元のゲームの
ROADSとguideで、乾いた区間のうちに次の区間の準備をする考え方を読みましょう。 - 元のゲームの
app.pyのterrainで、先の地面をスクロールして見せる描き方を読みましょう。 - 元のゲームの
Model.__init__のmirrorとbaselineで、左右対称や比較の実験の作り方を調べましょう。
最初は、数字かルールを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の実際のパソコンでの操作や、音が出るかどうかはまだ確認していません。人がすべての操作を試したわけでもありません。ブラウザー版の確認範囲は確認状況をご覧ください。
うまく動かないときは:起動やエラーの対処法 / 質問用のメモを作る
