INPUT / UPDATE / DRAW
入力・更新・描画を分ける。
キーを押し続けると移動、1回押すと決定。ポーズ中に時計はどうなる? 3つの小さな画面で、Pyxelがくり返している処理を確かめます。
- 前提
- 変数・if・関数。classやStateは状態を読む入門で確認
- 環境
- Python 3.11以上 / Pyxel 2.9.9 / GUI対応PC
- 教材
- 単独で動く三つの練習画面、説明、テスト
100本のゲーム講座とは別の、共通の入門ページです。ブラウザーの中で動く教材ではないので、PCにダウンロードして実行します。
ZIPをすべて展開し、README.txt があるフォルダーで、はじめる準備のとおりに .venv と Pyxel を用意します。まず1つずつ何も変えずに動かしてから、数字を変えてみましょう。キーで操作するときは、先にゲームのウィンドウをクリックします。Esc で終了します。
まず一枚の画面を出す
updateとdrawの役割
Pyxel は、最初の準備(初期化)のあと、「状態を変える関数」と「画面を描く関数」を何度もくり返し呼び出します。この例の control は何もしないので、黄色い四角は動きません。draw は毎回、画面を紺色でぬり直し、同じ位置に四角と説明の文字を描きます。
画面の左上が (0,0) で、右へ行くほど x、下へ行くほど y が大きくなります。START_X=20・START_Y=70 を rect に渡すと、左上が (20,70)、幅8・高さ8、色10の四角が描かれます。これは画面のピクセルで数えた位置で、マス目の番号とは別のものです。
ふつうに起動したときは Esc で終了します。main の引数は動作確認の自動描画のためのものなので、最初は App の control と draw だけ読めば大丈夫です。この例では、まだキーボードで四角を動かしません。
def control(self, pressed, held):
passそのまま動かす
.venv と Pyxel を用意したフォルダーで、次のように起動します。Windows は .venv\Scripts\python.exe 01_window.py、macOS / Linux は .venv/bin/python 01_window.py です。
描く前に毎回 cls(画面を消す)をしないと、前に描いた絵が残ることがあります。この例では、毎回背景から描き直しています。
START_X を20から40に変えてみましょう。四角が右へ20ピクセル動き、画面の位置の表示も変わります。元に戻したら、次は START_Y を70から90に変えると、下へ動きます。
単独で動く全文を見る
# MIT License
#
# Copyright (c) 2026 Tidepost 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.
#
# Exception: assets/glyphs.json contains rasterized glyph data derived from
# Noto Sans CJK JP (SIL Open Font License 1.1). See assets/NOTO-LICENSE.txt.
# Pyxel is an external dependency under its own MIT license.
"""入力・更新・描画の共通入門 1/3。独立した練習画面。"""
WIDTH, HEIGHT = 320, 180
FPS = 30
START_X, START_Y = 20, 70
class App:
def control(self, pressed, held):
pass
def draw(self, p):
p.cls(1)
p.rect(START_X, START_Y, 8, 8, 10)
p.text(8, 12, "ONE WINDOW / ORIGIN IS TOP LEFT", 7)
p.text(8, 95, "YELLOW SQUARE: X"+str(START_X)+" Y"+str(START_Y), 10)
p.text(8, 140, "UPDATE DOES NOTHING / DRAW ONLY READS", 6)
p.text(8, 160, "ESC QUIT", 6)
KEYS = ("LEFT", "RIGHT", "R", "P")
def main(headless=False, screenshot=None, demo=False):
from pathlib import Path
import pyxel as p
screenshot = str(Path(screenshot).resolve()) if screenshot else None
if screenshot:
Path(screenshot).parent.mkdir(parents=True,exist_ok=True)
app = App()
p.init(WIDTH,HEIGHT,title="Input / Update / Draw",fps=FPS,display_scale=3,headless=headless)
keys = {key:getattr(p,"KEY_"+key) for key in KEYS}
limit = 12
def frame_update():
if headless and p.frame_count>=limit:
if screenshot:
p.screenshot(screenshot,scale=3)
p.quit()
return
if headless and demo:
f = p.frame_count
app.control(set(),set())
elif not headless:
app.control({k for k,v in keys.items() if p.btnp(v)},
{k for k,v in keys.items() if p.btn(v)})
p.run(frame_update,lambda:app.draw(p))
return app
if __name__=="__main__":
import argparse
parser=argparse.ArgumentParser()
parser.add_argument("--headless-smoke",action="store_true")
parser.add_argument("--demo-checkpoint",action="store_true")
parser.add_argument("--screenshot")
args=parser.parse_args()
main(args.headless_smoke,args.screenshot,args.demo_checkpoint)押している間と、押した瞬間を比べる
btnとbtnpは別の情報
左右のキーで2つの四角を動かします。黄色は btn(今押しているか)を使っていて、押している間は、1回の update ごとに2ピクセル進みます。緑は btnp(今押したばかりか)を使っていて、1回押すと12ピクセルだけ進みます。
いったん指を離してもう一度押すと、緑がまた12進みます。この例の btnp はくり返しの設定をしていないので、押しっぱなしを連打としては数えません。左右を同時に押し続けると、黄色は「右1 − 左1 = 0」で止まります。
pressed(今押した)と held(押している)は、キー名を集めたもの(集合)です。画面側の更新関数が Pyxel から実際の入力を読み、ゲームの update に渡します。動作確認のテストでは同じ集合を直接渡せるので、人が正確な時間だけキーを押さなくてもルールを確かめられます。
右を押したまま update を20回行うテストでは、黄色は20+2×20=60、緑は20+12=32になります。これは「処理を20回」という条件での計算で、人が実際に長押しした時間を測ったものではありません。画面の端では、四角がはみ出さないように位置を0〜312の範囲に止めます。

held_step = HELD_STEP*(("RIGHT" in held)-("LEFT" in held))
edge_step = PRESS_STEP*(("RIGHT" in pressed)-("LEFT" in pressed))そのまま動かす
.venv と Pyxel を用意したフォルダーで、次のように起動します。Windows は .venv\Scripts\python.exe 02_held_pressed.py、macOS / Linux は .venv/bin/python 02_held_pressed.py です。
メニューの決定に毎フレーム btn を使うと、1回の長押しが何回もの決定になってしまいます。1回だけ決めたい操作には、「今押したか」を使うか、講座で説明している入力の工夫を加えます。
HELD_STEP を2から1に変えてみましょう。黄色の1回の更新で進む量だけが半分になり、緑の「1回押すと12」は変わりません。数字の単位が「ピクセル/更新1回」と「ピクセル/1回押す」で違うことを確かめます。
単独で動く全文を見る
# MIT License
#
# Copyright (c) 2026 Tidepost 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.
#
# Exception: assets/glyphs.json contains rasterized glyph data derived from
# Noto Sans CJK JP (SIL Open Font License 1.1). See assets/NOTO-LICENSE.txt.
# Pyxel is an external dependency under its own MIT license.
"""入力・更新・描画の共通入門 2/3。独立した練習画面。"""
from dataclasses import dataclass, replace
WIDTH, HEIGHT = 320, 180
FPS = 30
HELD_STEP = 2
PRESS_STEP = 12
@dataclass(frozen=True)
class State:
held_x: int = 20
pressed_x: int = 20
def update(state, pressed, held):
if "R" in pressed:
return State()
held_step = HELD_STEP*(("RIGHT" in held)-("LEFT" in held))
edge_step = PRESS_STEP*(("RIGHT" in pressed)-("LEFT" in pressed))
return State(max(0,min(WIDTH-8,state.held_x+held_step)),
max(0,min(WIDTH-8,state.pressed_x+edge_step)))
class App:
def __init__(self):
self.state = State()
def control(self, pressed, held):
self.state = update(self.state,pressed,held)
def draw(self, p):
p.cls(1)
p.text(8, 12, "HOLD LEFT / RIGHT, THEN RELEASE", 7)
p.rect(self.state.held_x, 60, 8, 8, 10)
p.rect(self.state.pressed_x, 112, 8, 8, 11)
p.text(8, 38, "BTN: "+str(HELD_STEP)+" PX PER UPDATE / X"+str(self.state.held_x), 10)
p.text(8, 90, "BTNP: "+str(PRESS_STEP)+" PX PER NEW PRESS / X"+str(self.state.pressed_x), 11)
p.text(8, 140, "LEFT + RIGHT CANCEL / R RESET", 6)
p.text(8, 160, "ESC QUIT", 6)
KEYS = ("LEFT", "RIGHT", "R", "P")
def main(headless=False, screenshot=None, demo=False):
from pathlib import Path
import pyxel as p
screenshot = str(Path(screenshot).resolve()) if screenshot else None
if screenshot:
Path(screenshot).parent.mkdir(parents=True,exist_ok=True)
app = App()
p.init(WIDTH,HEIGHT,title="Input / Update / Draw",fps=FPS,display_scale=3,headless=headless)
keys = {key:getattr(p,"KEY_"+key) for key in KEYS}
limit = 24 if demo else 12
def frame_update():
if headless and p.frame_count>=limit:
if screenshot:
p.screenshot(screenshot,scale=3)
p.quit()
return
if headless and demo:
f = p.frame_count
app.control({"RIGHT"} if f==0 else set(), {"RIGHT"} if f<20 else set())
elif not headless:
app.control({k for k,v in keys.items() if p.btnp(v)},
{k for k,v in keys.items() if p.btn(v)})
p.run(frame_update,lambda:app.draw(p))
return app
if __name__=="__main__":
import argparse
parser=argparse.ArgumentParser()
parser.add_argument("--headless-smoke",action="store_true")
parser.add_argument("--demo-checkpoint",action="store_true")
parser.add_argument("--screenshot")
args=parser.parse_args()
main(args.headless_smoke,args.screenshot,args.demo_checkpoint)停止中に進まないゲームの時計を作る
エンジンのフレーム数と、遊びの時間を分ける
今度は、ゲームの State に tick(ゲームの時計)と phase(今の場面)を持たせます。PLAY のときは、1回の更新で tick を1進め、90になったら DONE です。P を押すと PAUSE に切りかわり、その間は位置も tick も止まります。もう一度 P を押すと再開します。R はいつでも最初に処理され、最初の状態に戻します。
この例では、P で切りかえたその1回の更新では時間を進めず、次の更新から動きます。P を押し続けても「今押した」にはならないので、勝手に再開はしません。R を押した瞬間に最初の状態に戻り、右を押したままなら、次の更新から右への移動が始まります。
Pyxel の frame_count は、ポーズ画面を描いている間も増え続けます。これでゲームの終わりを判定すると、ポーズ中にも制限時間が減ってしまいます。そこで、ゲーム用の tick を別に持ち、draw は表示するだけにします。
FPS=30 なので、90回の更新は、設定上「3秒分」の動きです。ただし、処理が遅れたときに、時計の針でぴったり3秒になるとは限りません。この教材の SIMULATED SECONDS は tick/FPS で計算した表示で、本当の時間を測ったものではありません。
if "P" in pressed and state.phase in ("play", "pause"):
return replace(state,phase="pause" if state.phase=="play" else "play")
if state.phase != "play":
return stateそのまま動かす
.venv と Pyxel を用意したフォルダーで、次のように起動します。Windows は .venv\Scripts\python.exe 03_game_clock.py、macOS / Linux は .venv/bin/python 03_game_clock.py です。
draw の中で tick を増やしたり、ポーズ中も frame_count で時間切れを判定したりしないようにしましょう。「画面を描く回数」と「ゲームの時間」は別のものです。
LIMIT を90から60に変えて、もう一度起動します。60回の更新で DONE になり、表示は2.0秒分です。ポーズ中は、その60へ向かうカウントも進まないことを確かめましょう。
単独で動く全文を見る
# MIT License
#
# Copyright (c) 2026 Tidepost 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.
#
# Exception: assets/glyphs.json contains rasterized glyph data derived from
# Noto Sans CJK JP (SIL Open Font License 1.1). See assets/NOTO-LICENSE.txt.
# Pyxel is an external dependency under its own MIT license.
"""入力・更新・描画の共通入門 3/3。独立した練習画面。"""
from dataclasses import dataclass, replace
WIDTH, HEIGHT = 320, 180
FPS = 30
LIMIT = 90
@dataclass(frozen=True)
class State:
x: int = 20
tick: int = 0
phase: str = "play"
def update(state, pressed, held):
if "R" in pressed:
return State()
if "P" in pressed and state.phase in ("play", "pause"):
return replace(state,phase="pause" if state.phase=="play" else "play")
if state.phase != "play":
return state
x = max(0,min(WIDTH-8,state.x+2*(("RIGHT" in held)-("LEFT" in held))))
tick = state.tick+1
return State(x,tick,"done" if tick>=LIMIT else "play")
class App:
def __init__(self):
self.state = State()
def control(self, pressed, held):
self.state = update(self.state,pressed,held)
def draw(self, p):
p.cls(1)
p.text(8, 12, "GAME CLOCK / "+self.state.phase.upper(), 7)
p.text(8, 35, "ACTIVE UPDATES: "+str(self.state.tick)+"/"+str(LIMIT), 10)
p.text(8, 50, "SIMULATED SECONDS: "+format(self.state.tick/FPS,".1f"), 6)
p.rect(self.state.x, 83, 8, 8, 11)
p.text(8, 110, "FRAME COUNT IS NOT THE GAME CLOCK", 7)
p.text(8, 138, "LEFT/RIGHT MOVE P PAUSE/RESUME", 6)
p.text(8, 154, "R RESET (FIRST) ESC QUIT", 6)
KEYS = ("LEFT", "RIGHT", "R", "P")
def main(headless=False, screenshot=None, demo=False):
from pathlib import Path
import pyxel as p
screenshot = str(Path(screenshot).resolve()) if screenshot else None
if screenshot:
Path(screenshot).parent.mkdir(parents=True,exist_ok=True)
app = App()
p.init(WIDTH,HEIGHT,title="Input / Update / Draw",fps=FPS,display_scale=3,headless=headless)
keys = {key:getattr(p,"KEY_"+key) for key in KEYS}
limit = 48 if demo else 12
def frame_update():
if headless and p.frame_count>=limit:
if screenshot:
p.screenshot(screenshot,scale=3)
p.quit()
return
if headless and demo:
f = p.frame_count
pressed = {"P"} if f in (10,30) else {"RIGHT"} if f==0 else set()
app.control(pressed,{"RIGHT"}|pressed)
elif not headless:
app.control({k for k,v in keys.items() if p.btnp(v)},
{k for k,v in keys.items() if p.btn(v)})
p.run(frame_update,lambda:app.draw(p))
return app
if __name__=="__main__":
import argparse
parser=argparse.ArgumentParser()
parser.add_argument("--headless-smoke",action="store_true")
parser.add_argument("--demo-checkpoint",action="store_true")
parser.add_argument("--screenshot")
args=parser.parse_args()
main(args.headless_smoke,args.screenshot,args.demo_checkpoint)ゲームで使い分ける。
キーを押し続ける操作は最後の一匹、1手ずつ決める操作は一歩貸してくださいで読むと、違いがよく分かります。複数のキーを同時に押したときや、「全部のキーを離すまで待つ」ルールは、ゲームごとに目的が違います。どのゲームにも同じ作りを当てはめるのではなく、それぞれの講座の説明を確かめましょう。
確認したこと
Linux / Python 3.12.14 / Pyxel 2.9.9で32テスト、3本の日本語・空白パスからの単独起動、初期・進行の実Pyxel描画を確認しました。観測付き進行では12・24・48回の描画と最終状態を記録。初期画面の検査ではモデルを進めず、進行検査とは分けています。
これらは合成入力による確認です。OSの実キー入力、Windows / macOSのGUI、人による操作・理解度、ブラウザー、音声は未確認です。CP932既定読み取りの模擬も、Windows実機確認ではありません。
コードのMIT通知と参照先の権利文書をZIPへ保持。外部の字形データは使っていません。