2023-2I プログラミング1 第22回 講義資料

2023年12月11日(月)1・2時限

1 概要・連絡

1.1 連絡事項

1.2 今回講義の達成目標

今回の講義では、主に PyGame というゲームなどのインタラクティブなエンターテイメント系のアプリに有用なライブラリについて扱います。また、前回講義で学んだ「OpenCV」を利用した画像の加工 (拡大縮小やトリミング) についても扱います。

達成目標は次の通りです。

1.3 プログラミング学習について

第01回講義のときから繰り返し強調していますが、週1回90分の授業だけではICTエンジニアとしてのキャリア (企業・職種・業界) に関して多数の選択肢を持てるような「スキル」や「経験」は得られません。また、就活で評価してもらえるような「実績」が積みあがることもありません。就職先が、SES形態 (システムエンジニアリングサービス) だろうが、三次請け・四次請け企業だろうが、客先常駐だろうが、ソフトウェアテスト専門の仕事だろうが、何でもよいというの考えであれば、授業で単位がとれる程度の学びで問題はありませんが…。

このことは、週1回90分の英語の授業を受けても「海外で仕事 (あるいは外国人と仕事) ができるレベルの英語力が身に付かない」という現実と同じです。また、週1回・90分の体育の授業に参加しても「プロスポーツ選手はおろか、実業団などのアマチュアスポーツ選手になれるレベルの競技技術すら身に付かない」という現実とも同じです。

週1回90分の「授業」という枠内だけでプログラミングを学んでいては、1年たっても、2年たっても、卒業直前になっても「講義資料に書いてあることは概ね理解できるし、サンプルコードを貼り付けて動かすことはできる。でも、自分ではゼロからプログラムを書ける気がしないし、実際に書けない」という状態のまま、つまり「プログラミングができるという感覚に乏しいまま」です。

少なくとも「プログラミング」という分野に関して「授業」はあくまで「チュートリアル」でしかありません。授業で学んだことを土台に、その枠を越えて自主的な学習や開発に取り組まなければ「実践的なスキル」や「深い理解」を得ることはできません。

昨日視聴した動画の内容を言えますか?

昨日視聴した動画の内容を「5つ」あげてみてください。

動画のなかで解説されていた知識でも、動画の構成でも、シーンの描写でも、セリフでも何でもよいので 具体的な内容 を挙げ、それを言葉として表現してみてください。通常、多くの人は、それらを5つも挙げることはできません。また、1週間のなかで相当時間・相当数の動画を視聴していても、実際に記憶に残るものはほんのわずかです。

動画を視聴している瞬間」は 工夫された分かりやすい解説によって、内容を十分に理解できている感覚、内容が頭に入ってきている感覚があっても、翌日には何も残っていない (動画から得たものを思い出すことも、活かすこともきない) というのが実際です。残念ながら、授業においても基本的には同じことが起きます。

小学校から数えて11年以上、皆さんのなかにも自覚や認識はあると思いますが、授業を受けるというだけで定着するスキル、記憶に残る知識というものは極々わずかです。授業の枠を越えて、自主的に「学ぶ」というプロセスがなければ、いつまでも「できる」という状態には到達しません。

2 プログラムのファイル分割

次のような「挨拶アプリ」を例に、複数のファイルにプログラムを分割して記述する方法 を学びます。

import random as r
def random_greeting():
  arr = ('こんにちは','お疲れ様です','ごきげんよう')
  return r.choice(arr)

# メイン処理
name = '高専太郎'
print(f'{random_greeting()}{name}さん')

このプログラムは、大きく分けて「random_greeting 関数」と「それを呼び出して使用するメイン処理」から構成されています。ここでの random_greeting 関数は3行程度の短いものですが、実際の開発においては 関数やクラスの定義は何百行にも及ぶこと があります (クラスの詳細は後の講義で扱います) 。そのような場合、プログラムの可読性、保守性、再利用性を高めるために、あるいは チーム開発を円滑に進めるために (=各メンバーの担当範囲・責任を明確にして、コード編集における衝突を減らすために) 、「関数」や「クラス」という単位で プログラムコードを複数ファイルに分割して記述する必要性 が生じます。

例えば、Jupyter や GoogleColab などのノートブック環境では、次のように 複数のセルに処理を分割して記述すること ができます。具体的には、1個目のコードセルには random_greeting 関数の定義だけを記述し、2個目には、それを呼び出すメイン処理だけを記述するということができます (2番目のコードセルには %reset -f を記述しない点に注意してください)。これらのセルを順番に実行することで、複数のセルにプログラムが分割されていても、挨拶アプリ全体として機能させることができます。

img

一方で、通常のPython環境 (ノートブック環境以外) では、次のようにプログラムを 複数の Pythonファイル (.py) に 分割して記述すること ができます。

import random as r
def random_greeting():
  arr = ('こんにちは','お疲れ様です','ごきげんよう')
  return r.choice(arr)
import greeting as g
name = '高専太郎'
print(f'{g.random_greeting()}{name}さん')

2つのファイル (greeting.pymain.py ) を 同じフォルダに配置 し、シェル (コマンドライン) から次のコマンドを実行することで (=VSCodeで main.py をアクティブにして F5 を押下することで)、挨拶アプリ全体として機能させることができます。

python main.py

この仕組みは、次のようになります。

Python (処理系) は、まず main.py ファイルを読み込みます。そして 第01行目import greeting as g という文により、greeting.py というファイルを探し、その内容を読み込みます。その結果、greeting.py のなかの random_greeting 関数がメモリに読み込まれます。

この関数は、main.py からは greeting.random_greeting() のように ライブラリ名.関数名() という形式で呼び出しが可能になりますが、ここでは as g という 別名 (エイリアス) をつけているので、第03行目 では g.random_greeting() のように呼び出しています。同じように、もし greeting.pymorning_greeting という関数が定義されていれば、main.py からは g.morning_greeting() のように呼び出すことができます。

演習1: greeting.pymorning_greeting という関数 (内容は適当に設定せよ) を定義して、それを main.py から呼び出すように書き換え、動作を確認せよ。

演習2: messages.py というファイルを新規作成し、そこに login_message という関数 (内容は適当に設定せよ) を定義して、それを main.py から呼び出すように書き換え、動作を確認せよ。

2.1 if __name__ == '__main__': について

ウェブで Python について調べているなかで if __name__ == '__main__': という記述を見かけたことがあると思います。これは、先述のように 複数ファイルにプログラムを分割して開発する際に重要な役割を果たすもの になります。

例えば、チーム開発において自分が greeting.py の作成を担当すると想定します。そこでは random_greeting 関数を実装するわけですが、当然ながら、それが適切に機能することを確認する必要があります。そのための最も簡易な方法は greeting.py に以下のように テストコード (動作検証用のプログラム) を記述することです。

import random as r
def random_greeting():
  arr = ('こんにちは','お疲れ様です','ごきげんよう')
  return r.choice(arr)

# random_greeting のテストコード
assert type(random_greeting()) == str
for i in range(5):
  print(f'{i}. {random_greeting()}')

しかし、上記のようなテストコードを greeting.py を書いてしまうと、app.pyを実行した際、次のような意図しない結果 出力されてしまいます。実際に試してみてください

0. こんにちは
1. お疲れ様です
2. お疲れ様です
3. こんにちは
4. ごきげんよう
こんにちは、高専太郎さん

これは、app.py第01行目import greeting as g という文によって greeting.py が読み込まれた際に、第07行目から第09行目に記述したテストコードが実行されてしまう ためです。

この問題を解決するためには、次のように greeting.py を修正します。

import random as r
def random_greeting():
  arr = ('こんにちは','お疲れ様です','ごきげんよう')
  return r.choice(arr)

# random_greeting のテストコード
if __name__ == '__main__':
  assert type(random_greeting()) == str
  for i in range(5):
    print(f'{i}. {random_greeting()}')

この修正により greeting.py がシェルから直接実行される場合 (=VSCodeで greeting.py をアクティブにして F5 を押下する場合) に限り、テストコード が実行されるようになります。逆に言えば app.py からのインポートされた際は テストコード (=if __name__ == '__main__': のコードブロック) は 実行されない ようになります。

この挙動は、greeting.py第07行目__name__ という システムによって自動的に定義される「特殊な変数」 に関連します。__name__ には、プログラムがシェルから直接実行されたときには __main__ という文字列が格納されます。一方で、他のファイルから import された場合には、そのファイル名 (例えば import greeting as g の場合には greeting という文字列) が格納されます。

この結果 if __name__ == '__main__': のコードブロックの内容は、そのプログラムがシェルから直接実行されたときのみ (=VSCodeで、そのファイルをアクティブにして F5 を押下したときのみ) 実行されるようになります。

基本的に、当該のファイルを他からインポートする可能性がなければ if __name__ == '__main__': は不要ですが、御作法として記述することが推奨されています (授業のなかで提示するサンプルコードでは省略することが多いですが…)。


演習: greeting.py を次のように書き換え、greeting.py をシェルから直接実行する場合と、app.py からインポートする場合の挙動の違いについて確認し、特殊変数 __name__ についての理解を深めよ。

import random as r
def random_greeting():
  arr = ('こんにちは','お疲れ様です','ごきげんよう')
  return r.choice(arr)

print(f'greeting.py の __name__ => {__name__}') # 注目

# random_greeting の動作チェック
if __name__ == '__main__':
  assert type(random_greeting()) == str
  for i in range(5):
    print(f'{i}. {random_greeting()}')

Pythonファイルの「命名」に関する注意1

上記で説明した通り import greeting と記述すると、Python 処理系は、まずは同じフォルダのなかから greeting.py を探して読み込もうとします。もし、同じフォルダから当該ファイルを見つけられない場合は、Python がインストールされた所定のフォルダ (= ライブラリがインストールされているフォルダ) から greeting.py を探し、それでも見つからない場合は ModuleNotFoundError が発生します。

このため、同じフォルダ内に numpy.pypygame.py のような 既存ライブラリと同名のファイルが存在すると、それらのライブラリが正常に読み込まれなくなります。初心者がよく犯す間違い のひとつに NumPy を学ぶために numpy.py というファイルを作成して、そこにプログラムを記述してうまくいかない、ということがあります。この点に、十分に注意してください。

Pythonファイルの「命名」に関する注意2

00_pygame.pymy-pygame.py のように、先頭が数字から始まるファイル名や、ハイフンを含むファイル名をつけると、他のプログラムから import を使ってこれらのファイルを読み込むことができなくなります。一般的に、Pythonの変数名として有効でない文字列は、Pythonのファイルの名前としては「適切ではない」と考えてください。

ただし、この講義内では解説の関係上、数字から始まるファイル名やハイフンを含むファイル名を使用することがあります。

3 演習のための環境構築・動作確認

PyGame に関連した演習を行なうために (既存のプロジェクトフォルダや環境を使いまわしをせずに) 新規にプロジェクトフォルダを作成して、そこに開発環境を構築することを強く推奨します。以下、環境構築の手順を示します。

環境構築については第09回講義で既に解説していますが、復習も兼ねて改めて解説します。requirements.txt を使ったライブラリの一括インストールなど、新しい要素も含んでいるので注意 してください。

3.1 環境構築

コマンドプロンプト または PowerShell を起動し、プロジェクトフォルダ Pygame-Playground を作成・配置したいフォルダ (例えば C:\Users\xxxx\Documents ) まで移動してください。

cd "C:\Users\xxxx\Documents"

mkdir でプロジェクトフォルダ Pygame-Playground を作成し、そのフォルダ内に移動して VSCode を起動します。プロジェクトフォルダの名前は、必要に応じて変更しても構いません。なお、PowerShell では、以下の3つめのコマンドの末尾に & をつけないようにしてください ( コマンドプロンプト や GitBash 環境では & を付けてください)。

mkdir Pygame-Playground
cd Pygame-Playground/
code . &

VSCode が起動したら Ctrl+@ で、VSCode のなかで PowerShell (PS) モードのターミナル」を開きます。正常に起動すれば、次のような入力待ちのプロンプトが表示されるはずです。

PS C:\Users\xxxx\Documents\Pygame-Playground> 

PS のターミナルから、次のコマンドを打ち込んで Pythonの仮想環境 を構築し、有効化します。

python -m venv .venv
.venv/Scripts/Activate.ps1

仮想環境が有効になると、プロンプトの先頭に (.venv) が表示され、次のようになるはずです。

(.venv) PS C:\Users\xxxx\Documents\Pygame-Playground>

ゲーム開発に必要なライブラリをインストールする前に、まずは「pip本体」を最新版にアップグレードします。次のコマンドを実行してください。

python -m pip install --upgrade pip

以下、ゲーム開発に必要な各種ライブラリをインストールしていきます。

このプロジェクト (ここからの演習) では pygame の他に jupyternumpymatplotlibPillow など多数のライブラリを使用します。これらは pip install pygame のようなコマンドで個別にインストールすることもできますが、ここでは requirements.txt を使った 一括インストール を試していきたいと思います。この方法は git clone などで Python プロジェクトをローカル環境にクローンした際など、他のプロジェクトの環境を再現する場合 にも使うものなので覚えておいてください。

まずは、インストールするべきライブラリの一覧 が記載された requirements.txt をダウンロードして、プロジェクトフォルダのトップ階層に配置してください。パスは C:\Users\xxxx\Documents\Pygame-Playground\requirements.txt となるはずです。

VSCode で requirements.txt の内容を確認してください。ファイルの内容は、次のようになっているはずです。

anyio==4.1.0
argon2-cffi==23.1.0
argon2-cffi-bindings==21.2.0
arrow==1.3.0
asttokens==2.4.1
async-lru==2.0.4
attrs==23.1.0
...(略)...

このファイルには、インストールするべきライブラリ (=パッケージやモジュールとも呼ばれます) と、その バージョン が記載されています。これまでに見たこともないようなライブラリも多数含まれていますが、それらは pygamenumpyjupyter を利用する際に 間接的に必要となるライブラリ です。これまで pip install numpy のように個別にライブラリをインストールしていた際にも、自動的にインストールされていました。この requirements.txt には、そのように間接的に必要となるライブラリも 明示的に記述 されています。なお、このように「あるライブラリの実行に、他のライブラリが必要になる関係」を 依存関係 といいます。

次のコマンドを使用して、requirements.txt に記載されているライブラリを 一括インストール してください。

pip install -r requirements.txt

requirements.txt の作成

現在のPython環境 (有効化されている仮想環境) にインストールされているライブラリの requirements.txt を作成するためには、以下のコマンドを使用します。ファイル名は requirements.txt である必要はありませんが、慣習的にこの名前が用いられます。

pip freeze > requirements.txt

このコマンドは、現在環境にインストールされている「ライブラリ」と「そのバージョン」をリストアップし、それらを requirements.txt ファイルに書き出します。このファイルは 既存のプロジェクトを他のPCに移行する場合や、同じ環境を他の開発者と共有 する場合に役立ちます。

3.2 動作確認1 (PyGame)

PyGame を使ったゲーム開発のために構築した環境について、簡単な「動作確認」をしていきます。VSCode で 00_game.py という Pythonファイル を新規作成して、以下の内容を貼り付けて 保存 してください。

import random as r
import pygame as pg

def main():

  # 初期化処理
  pg.init() 
  pg.display.set_caption('ぼくのかんがえたさいきょうのげーむ')
  disp_w, disp_h = 800, 600 # DisplaySize(WindowSize)
  screen = pg.display.set_mode((disp_w,disp_h)) 
  clock  = pg.time.Clock()
  exit_flag = False
  exit_code = '000'
  shape_type = 0  # 描画図形の形状

  # ゲームループ [ここから]
  while not exit_flag:

    # システムイベントの検出
    for event in pg.event.get():
      if event.type == pg.QUIT: # ウィンドウ[X]の押下
        exit_flag = True
        exit_code = '001'
      if event.type == pg.KEYDOWN:  # キーの押下
        if event.key == pg.K_SPACE: # SpaceKey
          shape_type += 1
          if shape_type == 2:
            shape_type = 0

    # 指定色で画面を塗りつぶし(fill)。実質的な画面クリア
    screen.fill(pg.Color('BLACK'))

    # 円(circle) or 四角形(rect) を描画
    x = r.randint(0,disp_w)
    y = r.randint(0,disp_h)
    rr = r.randint(5,20)
    c = pg.Color('GREEN')
    if shape_type == 0:
      # 円 (描画先,色,(中心X,中心Y),半径)
      pg.draw.circle(screen,c,(x,y),rr)
    elif shape_type == 1:
      # 四角形 (描画先,色,(左上X,左上Y,幅,高さ))
      pg.draw.rect(screen,c,(x,y,rr,rr))

    # 画面出力の更新と同期
    pg.display.update()
    clock.tick(30) # 最高速度を 30フレーム/秒 に制限

  # ゲームループ [ここまで]
  pg.quit()
  return exit_code

if __name__ == "__main__":
  code = main()
  print(f'プログラムを「コード{code}」で終了しました。')

次に 00_game.py がアクティブな状態 (= VSCodeでタブが選択されている状態) で、左端の デバッグアイコン (再生ボタンに虫のアイコン) を押下し、左パネル内の「launch.json ファイルを作成します」という文字列をクリックします。

img

VSCode の画面上部に「デバッグ構成を選択する」というメニューが表示されるので「Pythonファイル  現在アクティブな Python ファイルをデバッグする」を選択します。これは、デバッグ操作 (F5キーを押下したとき) の デフォルト動作を設定するもの になります。

img

上記の操作により launch.json というファイルがプロジェクトフォルダ内に自動的に作成され、そのファイルタブが開きます。このファイルに対して特に変更すべき内容はないので、launch.json のタブを閉じます。なお、このファイルは .vscode/launch.json に配置されるので、先のステップで「デバッグ構成」を間違って選択した場合や、「デバッグ構成」を変更・カスタマイズしたい場合は、このファイルを削除・編集して対応してください。

再び 00_game.py をアクティブにしてから F5 を押下します。次のようなウィンドウが立ち上がり、黒色の背景に「緑色の円」がランダムに描画される処理ができていれば成功です。また、スペースキーを押下すると、描画される図形が 円 (Circle) から 四角形 (Rectangle) に切り替わること も確認してください。

以降、F5 を押下すればデバッグモードでプログラムが実行されます。また、非デバッグ環境で実行したい場合は、仮想環境が有効化された PowerShell で python 00_game.py のようにコマンドを打ち込んでください。

img

実行結果やプログラムに記載したコメントと照らしあわせながら、プログラム 00_game.py を解読・理解してください。その際、ウェブ検索やChatGPTなどを積極的に活用してください (ChatGPTの活用例)。また、PyGame の公式リファレンスhttps://www.pygame.org/docs/も利用してください (リファレンスは、英語で書かれているので必要に応じてブラウザの翻訳機能を利用してください) 。

ウェブ検索における注意事項

例えば、上記のプログラムの 第40行目 pg.draw.circle(screen,c,(x,y),rr) についてウェブで検索する際pg.draw.circle というキーワードは最適ではありません。このプログラムでは import pygame as pg のように 略称 (エイリアス) を設定しています。ウェブ検索する際はpygame.draw.circle のように書き換えることが推奨されます (現在の検索エンジンでは、内部的にうまく変換してくれるので pg.draw.circle でも、それなりの検索をしてくれますが…)。

プログラムについて、ある程度まで理解が進んだら次の演習に取り組んでください。

演習1: プログラムの 第31行目 screen.fill(pg.Color('BLACK')) をコメントアウトすると、プログラムの挙動がどのように変化するか推測し、その後、実際に検証せよ。検証後は元に戻しておくこと。

演習2: プログラムの 第46行目 pg.display.update() をコメントアウトすると、プログラムの挙動がどのように変化するか推測し、その後、実際に検証せよ。検証後は元に戻しておくこと。

演習3: プログラムの 第47行目 clock.tick(30) の引数を 10 および 3 に変更すると、プログラムの挙動がどのように変化するか推測し、その後、実際に検証せよ。また、第47行目 をコメントアウトした場合に、どのようになるかも検証せよ。検証後は元に戻しておくこと。

演習4: プログラムの 第40行目 では pg.draw.circle第3引数(x,y) のようにタプル型で与えている。これを [x,y] のように「リスト型」で与えることが可能か検証せよ。また、np.array([x,y]) のように NumPy の「ndarray型」で与えることが可能か検証せよ。検証後は元に戻しておくこと。

演習5: プログラムの 第34行目 から 第36行目 では図形の座標や半径を「整数値」として設定し、図形描画関数の引数として与えている。これら座標や半径に「浮動小数点数 (実数値)」や「負数」が使用できるか検証せよ。また random ライブラリを使用して -10.5 から 810.5 の範囲の乱数 (浮動小数点数) を得るためには、どのようにすればよいか考えよ。

演習6: ウィンドウサイズを 横640px、縦480px に設定せよ。また、ウィンドウタイトルを変更せよ。

演習7: 図形や背景の「色」を カラーコード (例えば #ff0000 など) で指定することは可能か検証せよ。
 ・大文字のカラーコード #FF0000 でも指定可能か。大文字小文字を混在させても指定可能か。
 ・3桁のカラーコード、例えば #f00 でも指定可能か。

演習8: 図形や背景の「色」を数値 (RGBの輝度強度) で指定することは可能か調べて検証せよ。

演習9: 描画される図形の「」を描画毎にランダムに変えるように書き換えよ。

演習10: キーボードの C を押下したときに描画図形を「円」に変更するように、R を押下したときに描画図形を「四角形」に変更するように書き換えよ。

演習11 (EX): キーボードの T を押下したとき、描画図形が「正三角形」になるように書き換えよ。
 ・Google検索「pygame draw 三角形」。

(EX)マークのエクストラ演習は、時間がかかるものなので基本的には授業時間外に取り組んでください。

3.3 動作確認2 (Jupyter)

ゲーム開発においては、ゲーム本体のプログラム開発のほかに 様々なゲームデータの作成、編集、確認など が必要となります。これらの作業にはノートブック環境のほうが便利なことが多いため、このプロジェクトフォルダには「Jupyter環境」も構築しました ( requirements.txt には Jupyter関連のライブラリが含まれています)。以下、Jupyter環境の動作確認をしていきます。

VSCode 上から 00_jupyter.ipynb というファイルを新規作成してください。なお、拡張子の ipynbInteractive Python Notebook (対話的Pythonノートブック) と覚えてください。

ノートブック形式として認識されたタブが開くので、コードセルに次のプログラムを記述して保存 (Ctrl+S) してください。

%reset -f
print('Hello Python')

ノートブックを新規作成した際は、手動でそれを実行するための Python環境 (カーネル) を選択する必要 があります。ノートブックタブの右上の「カーネルの選択」をクリックしてください。

img

VSCode画面上部に、カーネル (Python実行環境) 候補が表示されるので、自分で構築した仮想環境 (.venv) を選択します。

img

コードセルを選択して Ctrl+Enter で実行します。出力セルに Hello Python が表示されれば、問題なく Jupyter環境 が構築されています。この「カーネルの選択」は、新しくノートブックを作成した際、毎回、実行する必要があります。

img

3.4 Jupyterにおけるライブラリ認識の不具合と対処

Jupyter において適切にカーネルを選択していても、後日、プロジェクトを再開した際、コードセルのなかで以下のように「ライブラリが見つからない」という旨の警告が表示されることがあります (コードセルの「実行」は問題なくできるもののの、警告が表示されたり、コード補完が機能しなかったりする症状です)。

img

このような現象が起きた場合は Ctrl+Shift+P でコマンドパレットを開いて >Developer: Reload Window と入力して「ウィンドウの再読み込み (≠ウィンドウの再起動)」を実行することで解決する場合があります。

img

4 ゲーム開発の準備 (画像データの前処理)

ゲーム開発においては、ゲーム本体の制作に先駆けて 様々な素材(画像、音楽、マップや敵キャラのパラメータなどのゲームデータ)の準備 が必要となります。通常、これらの素材の準備や管理にもプログラムが利用され、場合によっては 専用のツール (アプリ) が開発されることもあります。無論、手作業で行うことも不可能ではありませんが、素材の作成やゲームデータの作成には 繰返し作業や修正 (微調整) が極めて多いため 現実的とは言えません。

ここでは、Jupyter環境を利用して、ゲームの素材となる「画像データ」の準備 (前処理) をしていきます。

4.1 フリー素材の利用について

ゲームを開発する場合、そこに使用する「画像」を予め準備しておく必要があります。自分では絵を描くことができない場合、あるいはチームメンバーに絵師がいない場合は、ウェブから フリー素材 を入手して利用する必要があります (最近は、画像生成AIを利用するケースも増えています)。

一般に 無料のフリー素材 といっても 様々な規約やライセンス、つまり利用条件が設定されている ので十分に注意して使用してください。特に、ポートフォリオをはじめ、ウェブや SNS などの媒体でゲームを公開・紹介する場合 には、フリー素材の「利用規約」を遵守してください (不適切な利用があると、炎上したり、訴訟問題となったりします)。

ここでは「spellyon氏」が「点睛集積 (http://dispell.net/)」において配付している画像素材を利用させてもらいます。この画像素材を使用して以降の演習を行なう場合は、点睛集積の 利用規約 http://dispell.net/rule_material.html を確認して、承諾のうえ利用してください。

また「管理人nko氏」が「DOT ILLUST (https://dot-illust.net/)」において配付している画像素材も利用させてもらいます。この画像素材を使用して以降の演習を行なう場合は、DOT ILLUST の 利用規約 https://dot-illust.net/terms/ を確認して、承諾のうえ利用してください。

4.2 画像データの準備例1

ここでは、ウェブから画像をダウンロードして、内容の確認し、トリミング (大きな画像から必要な部分を切り出すこと) と拡大縮小をして、保存するという流れでゲームに使用するための前処理を行ないます。この前処理には Jupyter環境 (ノートブック環境) を使用します。

VSCode上で 01_キャラチップの作成.ipynb というファイルを新規作成してください (カーネルも適切に選択しておいてください)。

4.2.1 ダウンロード

ウェブで公開・配付されている素材は、ウェブブラウザを使って手作業で1個ずつダウンロードすることができます。しかし、ここではプログラムを使ってダウンロードする方法を紹介します。プログラムを使ってウェブから情報を機械的に一括収集することを スクレイピング といいます。連続的にスクレイピングを実行するとウェブサーバに対して大きな負荷をかけることになります。使い方によっては 威力業務妨害として訴えられることもある ので十分に注意してください。

次のプログラムは、指定したURLからファイルをダウンロードして、プロジェクトフォルダ内の data/download/ に保存するプログラムになります。このプログラムにはフォルダの有無を確認する処理 (第07行目)、フォルダを作成する処理 (第08行目) なども含まれています。なお、ウェブからデータをダウンロードする処理 (requests関連) については第19回講義で解説済みです。

第11行目では 点睛集積spellyon氏 が公開している東方キャラの画像素材の URL を設定し、第10行目ではローカル環境に保存する際のファイルネームを設定しています。基本的には、この2行を変更すれば、他の素材のダウンロードに再利用ができます。

%reset -f
import os
import requests

# フォルダが存在しなければ作成
dir = 'data/download/'
if not os.path.isdir(dir):
  os.makedirs(dir)

fn = 'char-chip-01.png'
url = 'http://dispell.net/th/char_th0012.png'
res = requests.get(url)

if res.status_code != 200:
  raise Exception(f'ファイル {url} の取得に失敗。強制終了します。Code:{res.status_code}')
else :
  with open(f'{dir}/{fn}','wb') as file: 
    file.write(res.content)
  print(f'{dir}{fn} を保存しました')

プログラムを実行して処理に成功すると「data/download/ に char-chip-01.png を保存しました」という出力が得られます。VSCodeでは画像ファイルの「プレビュー」もできるので、ダウンロードした画像の内容を確認してください。なお、ビュー画面では Ctrl を押下しながらのホイール操作で 拡大縮小 ができます。

img

この素材画像は、アスキー社のRPGツクール2000 の仕様に基づく 歩行キャラ素材 になります。前後左右の向きについて、アニメーションのための3パターンの透過画像が収録された形式になっています。

演習: http://dispell.net/img/v4_yukkuri.pngdata/download/ フォルダに char-chip-02.png として保存してください。

4.2.2 画像と構成情報の表示

Pythonプログラムから「画像」と「その基本情報 (画像サイズなど)」を確認してみます。ノートブックにコードセルを追加し、次のプログラムを貼り付けて実行してください。このプログラムでは、OpenCV の imread を使用して画像を読込み、shape プロパティから 画像の横と縦の情報 を取得して表示しています。また画像本体も出力セルに表示しています。これらは、いずれも前回講義で学んだ内容となります。

%reset -f
import cv2
from IPython.display import Image

path = 'data/download/' + 'char-chip-01.png'
img = cv2.imread(path)
height, width, _ = img.shape  # アンパック
print(f'横 {width} px , 縦 {height} px')
Image(open(path,'rb').read())

実行結果は次のようになります。

横 288 px , 縦 256 px
(画像の表示)

画像全体のサイズが上記のように 288x256 で、横方向に4キャラ3パターン (3x4=12)、縦方向に2キャラ4パターン (2x4=8) が収録されているので、1キャラの1パターンの画像は 24x32 であることが分かります。

4.2.3 画像の詳細確認

画像データの詳細を確認するために、1キャラ・1方向・1パターンのみを切り出し、拡大表示をしてみたいと思います。ここでは、画像内の余白の大きさも確認できるように「グリッド」や「目盛り」もあわせて表示します。このために、可視化ライブラリである Matplolib を使用します。

img

以下に、'char-chip-01.png' の一番左上、つまり博麗霊夢の「後向き」「アニメーションパターン1」について拡大出力するプログラムを示します。既に確認したように1キャラの1パターン分の画像サイズは 24x32 であり、第11行目raw[0:32,0:24] では、NumPy配列 (ndarrayオブジェクト) に対する スライス を使って当該範囲を抜き出しています。スライスについては第12回講義で学んでいますが、理解が不十分な場合、ウェブの解説記事やYouTube動画、ChatGPTなどを利用して学習してください。

%reset -f
import cv2
import numpy as np
import matplotlib.pyplot as plt

dir = 'data/download/'
fn = 'char-chip-01.png'
path = dir + fn
raw = cv2.imread(path) # 生画像 (Raw Image) の読込み

img = raw[0:32,0:24] # スライス
height, width, _ = img.shape

# 可視化に関する処理 (現時点では詳細に理解する必要はない)
fig,ax = plt.subplots(dpi=140)
ax.imshow(cv2.cvtColor(img,cv2.COLOR_BGR2RGB),interpolation='nearest')
ax.set_xlim(-0.5,width-0.5)
ax.set_ylim(height-0.5,-0.5)

# 主目盛の設定
plt.tick_params(length=0, labeltop=True, labelright=True )
ax.set_xticks(np.arange(0, width))
ax.set_xticklabels(np.arange(0, width),fontsize=5)
ax.set_yticks(np.arange(0, height))
ax.set_yticklabels(np.arange(0, height),fontsize=5)

# 副主目盛の設定
plt.tick_params(which='minor', 
                length=0, labeltop=True, labelright=True )
ax.set_xticks(np.arange(0.5, width-0.5,1), minor=True)
ax.set_yticks(np.arange(0.5, height-0.5,1), minor=True)

# グリッド設定と表示
ax.grid(which='minor', color='#555')
plt.show()

第14行目以降は、Matplotlib に関するものであり、今回の講義内容の本質ではないため、現時点では深く理解する必要はありません (理解して自在に使えるようになるとあらゆる場面で役立ちますが)。


演習: 上記のプログラムのままでは 博麗霊夢 / 後向き / アニメーションパターン1 しか抜き出すことができない。任意の「横位置」と「縦位置」をゼロオリジンで与えて、その位置の画像詳細を確認できるようにプログラムを改良せよ。例えば、ゼロオリジンで「横位置4」と「縦位置2」を与えたとき、以下のように 霧雨魔理沙 / 前向き / アニメーションパターン2 が拡大表示されるようにせよ。

img

4.2.4 前処理した画像の保存

次回以降の演習で使用するために、霊夢と魔理沙について4方向の3アニメーションパターンの画像範囲を切り出して data/img/ というフォルダに reimu.png および marisa.png という名前で出力しておきます。

reimu.png を出力するプログラムを次に示します。

%reset -f
import os
import cv2
import numpy as np
from IPython.display import Image

# 入力画像のパス設定 (Path設定)
input_path = 'data/download/' + 'char-chip-01.png'

# 出力画像のパス設定 (Path設定)
output_fn = 'reimu.png'
output_dir = 'data/img/'
output_path = output_dir + output_fn

# 出力フォルダが存在しなければ作成
if not os.path.isdir(output_dir):
  os.makedirs(output_dir)

# 画像読込み、切り出し範囲の設定
img = cv2.imread(input_path,cv2.IMREAD_UNCHANGED) # アルファチャンネルも読み込む
print(f'raw.shape => {img.shape}')
bw = 24  # BaseWidth 
bh = 32  # BaseHeight
offset_w = bw*0
offset_h = bh*0

# 画像の切り出しと保存処理
img = img[offset_h:offset_h+bh*4, offset_w:offset_w+bw*3]
cv2.imwrite(output_path,img)
print(f'{output_path} に画像を出力しました。')

# 出力画像の確認
Image(open(output_path,'rb').read())

このプログラムで特に注意してほしいのが 第20行目 になります。cv2.imread の第2引数に cv2.IMREAD_UNCHANGED を与えることで、RGBのカラー画像ではなく、RGBAの透明度情報を持ったカラー画像 として画像の読込みをしています (A は AlphaChannel (アルファチャンネル) の意味です)。

プログラムの実行結果は、次のようになります。アルファチャンネルを含めて読み込んでいるので 第21行目.shape の出力結果が (256, 288, 4) となっている点に注目してください。

img

演習: 同様に marisa.png を作成せよ。

4.2.5 画像の拡大縮小

ゲーム素材として char-chip-02.png から、次のようなサイズが 48x48 の画像を作成します (正方形の画像である点に注意してください) 。

img

char-chip-02.png は、既に述べたように RPGツクール2000 の歩行キャラ画像の仕様となっており、1キャラは 24x32 で構成されています。ここから、正方形 24x24 の範囲を抜き出して、さらに 48x48 に拡大すること により目的の画像を作成していきます。

要修正 (第21行目) の部分を含むプログラムは次のようになります。

# 画像の詳細確認
%reset -f
import cv2
import numpy as np
import matplotlib.pyplot as plt

# 入力画像のパス設定 (Path設定)
input_dir = 'data/download/'
input_fn = 'char-chip-02.png'
input_path = input_dir + input_fn

# 出力画像のパス設定 (Path設定)
output_dir = 'data/img/'
output_fn = 'marisa-face.png'
output_path = output_dir + output_fn

# アルファチャンネルを含めて画像読込み
raw = cv2.imread(input_path, cv2.IMREAD_UNCHANGED)

# 画像の範囲切り出しと拡大
img = raw[0:24,0:24] # ■■ 要修正
img = cv2.resize(img,(48,48),interpolation=cv2.INTER_NEAREST)
height, width, _ = img.shape

# 可視化に関する処理
fig,ax = plt.subplots(dpi=140)
ax.imshow(cv2.cvtColor(img,cv2.COLOR_BGR2RGB),interpolation='nearest')
ax.set_xlim(-0.5,width-0.5)
ax.set_ylim(height-0.5,-0.5)

# 主目盛の設定
plt.tick_params(length=0, labeltop=True, labelright=True )
ax.set_xticks(np.arange(0, width))
ax.set_xticklabels(np.arange(0, width),fontsize=5)
ax.set_yticks(np.arange(0, height))
ax.set_yticklabels(np.arange(0, height),fontsize=5)

# 副主目盛の設定
plt.tick_params(which='minor', 
                length=0, labeltop=True, labelright=True )
ax.set_xticks(np.arange(0.5, width-0.5,1), minor=True)
ax.set_yticks(np.arange(0.5, height-0.5,1), minor=True)

# グリッド設定と表示
ax.grid(which='minor', color='#999')
plt.show()

# 保存
cv2.imwrite(output_path,img)

プログラムを実行して結果を確認してください。このプログラムの9割以上は解説済みの内容です。新規部分は 第22行目cv2.resize 関数のみとなります。cv2.resize 関数は リサイズ (拡大・縮小) のための関数で、第1引数に元画像、第2引数にリサイズ後の画像サイズのタプルinterpolation オプションに 拡大縮小に使用する補間方法 を指定します。

第2引数 に与えるタプルは (width, height) の順番になる点 に注意してください。また、ここでは「ドット絵」かつ「2倍拡大」という状況から interpolation オプションを cv2.INTER_NEAREST としています。


演習1: 目的の画像が得られるように 第21行目 を修正せよ。また、この行の前後に処理を追加し、再利用性のあるプログラム (他のキャラ、他の向きの「顔」の出力にも簡単に対応できるプログラム) にせよ。

演習2: 第22行目interpolation オプションを cv2.INTER_LINEARcv2.INTER_AREAcv2.INTER_CUBICcv2.INTER_LANCZOS4 に変更して、結果がどのように変わるか確認せよ。

4.3 画像データの準備例2

ここでは、DOT ILLUST において 管理人nko氏 が公開している マップタイル (草原01/中央)48x48 にリサイズして data/img/ フォルダに map-ground-center.png として出力していきます。以下の図は、リサイズしたものを横に20個ならべた画像になります。

img

DOT ILLUST で配付している画像素材は、プログラムから直接ダウンロードすることはできないので (=HTTP GET リクエストに適切なリファラが含まれていないとアクセス拒否されるので)、ブラウザを開いて手作業で画像をダウンロードして data/download/ フォルダに map-ground-center.png として保存してください。ダウンロードした画像を確認すると、配付されているオリジナル画像は 500x500 であることが分かります。

演習1: data/download/map-ground-center.png48x48 の画像にリサイズ (縮小して) して data/img/map-ground-center.png として出力せよ。

演習2: DOT ILLUST の火0148x48 にリサイズして data/img/ フォルダに effect-fire.png として出力せよ。

演習3: DOT ILLUST のモンスター (死神/鎌あり)48x48 にリサイズして data/img/ フォルダに shinigami.png として出力せよ。

演習4: DOT ILLUST の緑茶 (湯呑み/茶托あり)【ホワイト/ブラウン】16x15 にリサイズして data/img/ フォルダに ocya.png として出力せよ。cv2.resizeinterpolation のオプションを切り替え、ゲーム素材として最適な出力とすること。

演習5: DOT ILLUST のキノコ【レッド】14x12 にリサイズして data/img/ フォルダに kinoko.png として出力せよ。演習4 と同様に最適な interpolation を選択せよ。

5 PyGame の Vector2 オブジェクト

このセクションでは、PyGame で2次元ゲームを開発する際に必須となるVector2というクラスについて、(今後、避けては通ることができない「オブジェクト指向プログラミング」の初歩の初歩を交えながら) 解説していきます。オブジェクト指向プログラミングには 字句からは極めてイメージしずらい専門用語が多数登場 しますが、段階的に慣れて、理解を深めていってください。一気に理解することは実質的に不可能です。

PyGame には、2次元空間の幾何ベクトルの扱いに最適化された「Vector2」というクラスが用意されています (3次元空間用のVector3もあります) 。Vector2クラスからは、「Vector2オブジェクト」を生成することができます (クラスは「設計図」、オブジェクトはその設計図に基づいて生成される「実体/実物」と考えてください。オブジェクトは「インスタンス」とも呼ばれます)。

Vector2オブジェクトは、ベクトルの「X成分」と「Y成分」を表す xy という浮動小数点数型の プロパティ(変数のようなもの)を持ちます。

例えば、次の 第04行目 のように vec_a = pg.Vector2(31.5, 42.9) で初期化して、その後、第07行目 のように vec_a.x31.5 を取得し、第08行目 のように vec_a.y42.9 を取得することができます。また、第14行目 のように vec_a.x = 77.7 とすれば X成分 (xプロパティ) のみを個別に変更することもできます。XとYを まとめて1個のオブジェクトとして扱える点 が便利です。

%reset -f
import pygame as pg

vec_a = pg.Vector2(31.5, 42.9) # 初期化

print(vec_a)         # => [31.5, 42.9]
print(vec_a.x)       # => 31.5
print(vec_a.y)       # => 42.9

print(type(vec_a))   # => <class 'pygame.math.Vector2'>
print(type(vec_a.x)) # => <class 'float'>
print(type(vec_a.y)) # => <class 'float'>

vec_a.x = 77.7
print(vec_a)         # => [77.7, 42.9]

Vector2オブジェクト同士は、次のように加減算も可能です。

%reset -f
import pygame as pg

vec_a = pg.Vector2(31.5, 42.9)
vec_b = pg.Vector2(10, -20) 

vec = vec_a + vec_b  # Vector2同士の加算
print(vec) # => [41.5, 22.9]

vec = vec_a - vec_b  # Vector2同士の減算
print(vec) # => [21.5, 62.9]

上記のように加減算ができるという点は、第17回講義 で学んだ NumPy の ndarray型同士の演算と同じです。

%reset -f
import numpy as np
vec_a = np.array([31.5, 42.9])
vec_b = np.array([10, -20])

print(type(vec_a)) # => <class 'numpy.ndarray'>

vec = vec_a + vec_b  # ndarray同士の減算 
print(vec) # => [41.5 22.9]

vec = vec_a - vec_b  # ndarray同士の減算
print(vec) # => [21.5 62.9]

Vector2 が2次元ゲーム開発において ndarray よりも便利な理由のひとつは、自身の長さ (大きさ) を計算する length メソッド (関数のようなもの) を含めて様々なメソッドを有する点です。例えば、単位方向ベクトル を計算する normalize メソッドや、自身を基準にした 他のベクトルとの角度差 を計算する angle_to メソッドなどがあります。詳しくは公式リファレンスを参照してください。

Vector2 の各種メソッドの利用例を次に示します。

%reset -f
import pygame as pg

vec = pg.Vector2(10,10)

# vecの長さ(大きさ)を計算
length = vec.length() 
print(f'長さ = {length}') 

# vecの単位方向ベクトルを計算
unit_vector = vec.normalize()
print(f'単位方向ベクトル = {unit_vector}') 

# 別のベクトルとの角度を計算
angle = vec.angle_to(pg.Vector2(10,0))
print(f'角度 = {angle}') 

実行結果は次のようになります。

長さ = 14.142135623730951
単位方向ベクトル = [0.707107, 0.707107]
角度 = -45.0

演習1 プロジェクトフォルダに 02_vector2.ipynb という名前のノートブックを新規作成し、上記の各プログラムを記述・実行し、その理解を深めよ。※ GUI関連の処理を呼び出さなければ、ノートブック環境でも PyGame ライブラリを使用することはできます。

演習2 vec = pg.Vector2(3,4) に対して、vec/2vec*2 などの演算がどのように作用するか確認せよ。

6 ボールがバウンドするゲーム

画面内をボールがバウンドしていくだけのシンプルなゲームを作成しながら PyGame が提供する様々な機能について学んでいきます。VSCode で 03_game_01.py というファイル (≠ノートブック) を新規作成して、以下のプログラムを貼り付けて、実行してください。

import pygame as pg

def main():

  # 初期化処理
  pg.init() 
  pg.display.set_caption('ぼくのかんがえたさいきょうのげーむ')
  disp_w, disp_h = 800, 600
  screen = pg.display.set_mode((disp_w,disp_h)) # WindowSize
  clock  = pg.time.Clock()
  font   = pg.font.Font(None,15)
  frame  = 0
  exit_flag = False
  exit_code = '000'

  # ボールのパラメータ p:位置、v:速度、a:加速度
  ball_p = pg.Vector2(50, 90)  # x=50, y=90 (px)
  ball_v = pg.Vector2(2, 0)    # vx=2, vy=0 (px/frm)
  ball_a = pg.Vector2(0, 0.9)  # ax=0, ay=0.9 (px/frm^2)
  ball_r = 24                  # ボールの半径
  ball_c = pg.Color('#ff0000') # ボールの色

  ground_h = 48 # 地面の高さ

  # ゲームループ
  while not exit_flag:

    # システムイベントの検出
    for event in pg.event.get():
      if event.type == pg.QUIT: # ウィンドウ[X]の押下
        exit_flag = True
        exit_code = '001'

    # 背景描画
    screen.fill(pg.Color('WHITE'))

    # ボールの描画、位置・速度の更新
    pg.draw.circle(screen,ball_c,ball_p,ball_r,width=2)
    ball_p += ball_v
    ball_v += ball_a

    ## 地面との衝突処理
    if ball_p.y >= disp_h - ground_h - ball_r :
      ball_p.y = disp_h - ground_h - ball_r
      ball_v.y = - 0.8 * ( ball_v.y - ball_a.y )

    ## 右端と左端との衝突処理
    if ball_p.x + ball_r > disp_w :
      ball_p.x = disp_w - ball_r
      ball_v.x = -0.8 * ball_v.x 
    elif ball_p.x - ball_r < 0:
      ball_p.x = ball_r
      ball_v.x = -0.8 * ball_v.x 

    # 地面描画 rectの 第3引数は (左上X, 左上Y ,幅 ,高さ)
    pg.draw.rect(screen, pg.Color('#864A2B'),
                (0, disp_h-ground_h, disp_w, disp_h))

    # フレームカウンタの描画
    frame += 1
    frm_str = f'{frame:05}'
    screen.blit(font.render(frm_str,True,'BLACK'),(10,10))

    # 画面の更新と同期
    pg.display.update()
    clock.tick(30)

  # ゲームループ [ここまで]
  pg.quit()
  return exit_code

if __name__ == "__main__":
  code = main()
  print(f'プログラムを「コード{code}」で終了しました。')

以下、先述の動作確認1 で解説した 00_game.py 以降の新規要素を中心に解説していきます。また、Vector2 の基礎も理解している前提での解説になります。

6.1 フレームカウンタ・文字列の描画

このプログラムでは画面の左上に フレームカウンタ (現在、何フレーム目が描画されているか) を表示しています。これは 第11行目~第12行目第59行目~第62行目 によって処理されています。

font   = pg.font.Font(None,15)
frame  = 0
# フレームカウンタの描画
frame += 1
frm_str = f'{frame:05}'
screen.blit(font.render(frm_str,True,'BLACK'),(10,10))

第11行目 では、画面に文字描画する際のフォントを定義して変数 font に格納しています。pg.font.Font の第1引数には「フォントのファイルパス」、第2引数には「フォントのサイズ」を指定します。第1引数を None にした場合は、デフォルトフォント (日本語未対応) が使用されます。

第61行目 では、f文字列 を使って、整数型の変数 frame から、ゼロ埋め5桁の文字列 を作成して変数 frm_str に格納しています。これまで、f文字列print 関数の引数として使用してきましたが、このように独立して使用することもできます。

第62行目screen.blit の第1引数には、font.renderレンダリングした文字列を与え、第2引数にはそれを描画するXY座標をタプルで与えています。font.render の第1引数には 描画したい文字列、第2引数にはアンチエイリアシングの有無、第3引数には 文字色 を与えています。

PyGameに日本語フォントを利用する方法

PyGameで確実に「日本語フォント」を使用したい場合は、プログラムと同じフォルダにフォントファイルを配置します (OSにインストール済みのフォントパスを指定するという方法もありますが、環境依存性があり確実性は低いです)。ここでは情報処理推進機構 (IPA) が配付している「IPAフォント」を使用する例を示します。このフォントは「IPAフォントライセンス」に基づき使用することができます (その他のフォントと比較して、かなり自由に利用できるライセンスとなっています)。

PyGameで使用するための手順は以下のようになります。

  1. IPAフォントを配付しているサイトにアクセスします。
  2. ページに記載されている「IPAフォントライセンス」の内容を確認し、同意します。
  3. ページ下部のリンク ipaexg00401.zip(4.0MB) をクリックして ZIPファイル をダウンロードして展開します。
  4. 展開したフォルダから ipaexg.ttf をプロジェクトフォルダのトップ階層にコピーします。作成したゲームを公開・配付する際 (フォントファイルも再頒布する場合) は、ライセンスについて書かれたテキストファイルもコピーして適切な位置に配置してください。
  5. プログラムのなかで pg.font.Font('ipaexg.ttf',15) のように IPAフォントのパス を指定します。
img

演習1: フォントサイズを変更して、その結果について確認せよ。

演習2: アンチエイリアシングの有無、フォントの色を変更して、その結果について確認せよ。

演習3 (EX): IPAフォントを配置して日本語の文字列をゲーム画面に出力できるようにせよ。

6.2 ボール位置の計算

画面内をバウンドするボールの位置計算 (速度と加速度に基づく位置計算) には Vector2 を利用しています。第16行目~第23行目 で初期化し、ゲームループ内の 第37行目~第40行目 で「ボールの描画」とフレーム毎に「位置と速度の更新」をしています。速度と加速度に基づく位置 (変位) の計算は、既に第20回講義で解説しています。

# ボールのパラメータ p:位置、v:速度、a:加速度
ball_p = pg.Vector2(50, 90)  # x=50, y=90 (px)
ball_v = pg.Vector2(2, 0)    # vx=2, vy=0 (px/frm)
ball_a = pg.Vector2(0, 0.9)  # ax=0, ay=0.9 (px/frm^2)
ball_r = 24                  # ボールの半径
ball_c = pg.Color('#ff0000') # ボールの色

ground_h = 48 # 地面の高さ
# ボールの描画、位置・速度の更新
pg.draw.circle(screen, ball_c, ball_p, ball_r, width=2)
ball_p += ball_v
ball_v += ball_a

第39行目 では「位置」を更新、第40行目 では「速度」を更新しています。ball_p += ball_vball_p = ball_p + ball_v の略表記です。+= のような演算子を 累算代入演算子 といいます。

第38行目 では pg.draw.circleボール (円) を描いています。第1引数には「描画先」、第2引数には「色」、第3引数には「円の中心座標」、第4引数には「半径」、width オプションには「線幅」を与えます。00_game.py では、第3引数を (x,y) のようにタプルで与えていましたが、ここでは ball_p という Vector2オブジェクト で与えています。また width オプションを省略すると 円は塗りつぶし されます。


演習1: ボールの線色を「紫色」に変更せよ。

演習2: ボールを塗りつぶしにせよ。

演習3: pg.draw.circle では円の中心座標ではなく、円の外接矩形の左上の座標 を与えて描画位置を指示するオプションも用意されている。公式レファレンスで確認せよ。

6.3 ボールの衝突処理

ボールが地面に到達した場合の衝突処理 (反発) は、第42行目~第45行目 で行なっています。

## 地面との衝突処理
if ball_p.y >= disp_h - ground_h - ball_r :
  ball_p.y = disp_h - ground_h - ball_r
  ball_v.y = - 0.8 * ( ball_v.y - ball_a.y )

第43行目 では ball_p.y、つまりボールの中心の「Y座標の値」を使って、ボールが地面に接触していないか、地面に沈み込む位置になっていないか を判定しています。ゲーム座標系では 画面上部が Y=0 で、画面の下方向にいくほど、Yの値が大きくなっていくこと に注意してください。

if文によって、ボールが「地面に接触している」もしくは「沈み込んでいる」と判定された場合、第44行目ボールが沈み込まない ように位置を修正し、第45行目Y方向の速度を反転 させています。0.8 は反発係数で、この値が大きいほど 跳ね返り が大きくなります。

なお、このプログラムでは、ボールは永遠に低い位置で跳ね続けて (振動を続けて) 静止しません。本来であれば、実世界の現象と同じように静止させたいのですが、離散的かつ簡易計算を行っている関係で誤差が発生して、このような状態になります。これを少しでもマシにするため、ball_v.y = - 0.8 * ball_v.y ではなく 第45行目 のような計算をしています (それでも、跳ね続けますが)。

また、左右端に到達した場合の衝突処理 (反発) は、次のように 第47行目~第53行目 で行なっています。基本的な考え方は、地面との衝突処理と同じです。

## 右端と左端との衝突処理
if ball_p.x + ball_r > disp_w :
  ball_p.x = disp_w - ball_r
  ball_v.x = -0.8 * ball_v.x 
elif ball_p.x - ball_r < 0:
  ball_p.x = ball_r
  ball_v.x = -0.8 * ball_v.x

演習1: 第45行目ball_v.y = - 0.8 * ball_v.y に書き換えて、動作がどのように変化するか確認せよ。

演習2: 反発係数を変化させて、動作がどのように変化するか確認せよ。

演習3 (EX): ボールがいつまでも静止しないことの原因について考えよ。ball_pball_vball_a の値を「PyGameの画面」や「標準出力」に表示すると考えやすい。

7 ボールがバウンドするゲーム (改良版)

VSCode で 03_game_02.py というファイル (≠ノートブック) を新規作成して、以下のプログラムを貼り付けて、実行してください。これは、さきほどのプログラム 03_game_01.py次のような改良・機能 を加えたものになります。

import pygame as pg

def main():

  # 初期化処理
  pg.init() 
  pg.display.set_caption('ぼくのかんがえたさいきょうのげーむ')
  disp_w, disp_h = 800, 600
  screen = pg.display.set_mode((disp_w,disp_h)) # WindowSize
  clock  = pg.time.Clock()
  font   = pg.font.Font(None,15)
  frame  = 0
  exit_flag = False
  exit_code = '000'

  ball_p = pg.Vector2(50, 90) # x=50, y=90 (px)
  ball_v = pg.Vector2(2, 0)   # vx=2, vy=0 (px/frm)
  ball_a = pg.Vector2(0, 0.9) # ax=0, ay=0.9 (px/frm^2)
  ball_r = 24 # ボールの半径
  ball_c = pg.Color('#ff0000')

  ground_img = pg.image.load(f'data/img/map-ground-center.png')
  ground_s = pg.Vector2(48,48) # 地面画像サイズ

  jump = False

  # ゲームループ
  while not exit_flag:

    # システムイベントの検出
    for event in pg.event.get():
      if event.type == pg.QUIT: # ウィンドウ[X]の押下
        exit_flag = True
        exit_code = '001'
      if event.type == pg.KEYDOWN:
        # スペースキーが押下されたら jump を True に
        if event.key == pg.K_SPACE:
          jump = True

    # 背景描画
    screen.fill(pg.Color('WHITE'))

    # ボールの描画と位置計算
    pg.draw.circle(screen,ball_c,ball_p,ball_r,width=2)
    if jump:
      pg.draw.circle(screen,ball_c,ball_p,ball_r)
      ball_v.y = -8
      ball_v.x += 0.5 if ball_v.x > 0 else  -0.5
      jump = False
    ball_p += ball_v
    ball_v += ball_a

    ## 地面との衝突処理
    if ball_p.y >= disp_h - ground_s.y - ball_r :
      ball_p.y = disp_h - ground_s.y - ball_r
      ball_v.y = - 0.7 * ( ball_v.y - ball_a.y )
      if abs(ball_v.y) < 3.5 :
        ball_v.y = 0
        ball_v.x *= 0.98

    ## 右端と左端との衝突
    if ball_p.x + ball_r > disp_w :
      ball_p.x = disp_w - ball_r
      ball_v.x = -0.8 * ball_v.x 
    elif ball_p.x - ball_r < 0:
      ball_p.x = ball_r
      ball_v.x = -0.8 * ball_v.x 

    # 地面描画
    for x in range(0,disp_w,int(ground_s.x)):
      screen.blit(ground_img,(x,disp_h-ground_s.y))

    # フレームカウンタの描画
    frame += 1
    frm_str = f'{frame:05}'
    screen.blit(font.render(frm_str,True,'BLACK'),(10,10))

    # 画面の更新と同期
    pg.display.update()
    clock.tick(30) # 最高速度を 30 フレーム/秒に制限

  # ゲームループ [ここまで]
  pg.quit()
  return exit_code

if __name__ == "__main__":
  code = main()
  print(f'プログラムを「コード{code}」で終了しました。')

7.1 空中ジャンプ機能の追加

空中ジャンプの機能は 第25行目第30行目~第38行目第43行目~第51行目 で実装しています。

jump = False
# システムイベントの検出
for event in pg.event.get():
  if event.type == pg.QUIT: # ウィンドウ[X]の押下
    exit_flag = True
    exit_code = '001'
  if event.type == pg.KEYDOWN:
    # スペースキーが押下されたら jump を True に
    if event.key == pg.K_SPACE:
      jump = True
# ボールの描画と位置計算
pg.draw.circle(screen,ball_c,ball_p,ball_r,width=2)
if jump:
  pg.draw.circle(screen,ball_c,ball_p,ball_r)
  ball_v.y = -8
  ball_v.x += 0.5 if ball_v.x > 0 else  -0.5
  jump = False
ball_p += ball_v
ball_v += ball_a

まずは、ゲームループの外側の 第25行目 で 変数 jump を初期化しています。この変数はゲームループのなかで「フラグ」として使用するもので、ジャンプ操作入力があったときに True に書き換え、True のときにジャンプ用の追加処理を実行します。

第30行目~第38行目 ではプレイヤからのキーボード入力を確認して、スペースキーの押下が検出されたら jumpTrue を代入しています。第37行目pg.K_SPACE を入れ替えれば、他キーの押下を検出することができます。VSCode 環境で pg.K_ まで入力すると候補が表示されます。定数の一覧は公式リファレンスを確認してください。なお、スペースキーを押下し続けても event.type == pg.KEYDOWNTrue と判定されるのは 1回のみ です。

第45行目 では、変数 jump の値を確認して True のときにだけ 第46行目~第49行目 が実行されるようにしています。第46行目 ではボールを塗りつぶしています。第47行目 では画面上方に向かってボールがジャンプするように速度を書き換えています。第48行目 では第19回講義で学んだ 条件式 (三項演算子) を使って、現在の進行方向 (左右) の速度を少しだけ増やしています。また、第49行目 では jump の値を False に戻しています。この処理を忘れると 1回のスペースキーの押下で永遠にジャンプ操作が実行される (画面外に飛んでいく) ようになってしまう ので注意してください。

第48行目条件式 (三項演算子) により書かれている処理を、通常の条件分岐で記述すると以下のようになります。

if ball_v.x > 0 :
  ball_v.x += 0.5
else :
  ball_v.x += -0.5

7.2 画像を使って地面の描画

画像を使った地面の描画は 第22行目~第23行目第69行目~第71行目 で行なっています。地面の画像は、画像データの準備例2のなかで data/img/ フォルダに出力した map-ground-center.png (画像サイズ48x48) を使用しています。

ground_img = pg.image.load(f'data/img/map-ground-center.png')
ground_s = pg.Vector2(48,48)  # 地面画像サイズ
# 地面描画
for x in range(0,disp_w,int(ground_s.x)):
  screen.blit(ground_img,(x,disp_h-ground_s.y))

第70行目において disp_w800 であり、ground_s.x48 なので、for x in range(0,disp_w,int(ground_s.x)):for x in range(0,800,48): と等価になります。よって、x は forループのなかで 0 48 96 144 192672 720 768 となります。このX座標毎に地面の画像を配置しています。

7.3 ボールの動きを改善

ボールの動きの改善は 第53行目~第59行目 でおこなっています。より良い方法も考えられます、各自で工夫してみてください。

## 地面との衝突処理
if ball_p.y >= disp_h - ground_s.y - ball_r :
  ball_p.y = disp_h - ground_s.y - ball_r
  ball_v.y = - 0.7 * ( ball_v.y - ball_a.y )
  if abs(ball_v.y) < 3.5 :
    ball_v.y = 0
    ball_v.x *= 0.98

第57行目abs は組込み関数で 絶対値 を返す関数です。

第59行目*=累算代入演算子 で、ball_v.x = 0.98 * ball_v.x と同じ意味になります。

8 ボールがバウンドするゲーム (改良版2)

さらに改良したバージョンのプログラムを示します。まずは、04_game.py を新規作成して、プログラムを貼り付けて実行してみてください。

import random as r
import pygame as pg

def main():

  # 初期化処理
  pg.init() 
  pg.display.set_caption('ぼくのかんがえたさいきょうのげーむ')
  disp_w, disp_h = 800, 600
  screen = pg.display.set_mode((disp_w,disp_h))
  clock  = pg.time.Clock()
  font   = pg.font.Font(None,15)
  frame  = 0
  exit_flag = False
  exit_code = '000'

  face_s = pg.Vector2(48,48) # サイズ
  face_r = face_s/2          # 半径
  face_p = pg.Vector2(50,90) # 位置
  face_v = pg.Vector2(2,0)   # 速度
  face_a = pg.Vector2(0,0.2) # 加速度
  face_img = pg.image.load(f'data/img/marisa-face.png')

  ground_img = pg.image.load(f'data/img/map-ground-center.png')
  ground_s   = pg.Vector2(48,48) 

  jump = False  # ジャンプアクション入力の有無
  theta = 0.0   # 顔の回転角 (deg) を保持する変数

  # 炎エフェクト
  fire_img = pg.image.load(f'data/img/effect-fire.png')
  fire_img = pg.transform.rotozoom(fire_img,180,1) # 180度の回転
  fire = 0   # 炎を表示する残り時間を保持する変数

  # 敵(死神関係)
  enemy_img = pg.image.load(f'data/img/shinigami.png')
  enemy_s   = pg.Vector2(48,48)
  enemy_p_arr = [
    pg.Vector2(200,150),
    pg.Vector2(400,200),
    pg.Vector2(600,100),
  ]
  enemy_rect_arr = []  # 衝突判定用の矩形(くけい)
  for enemy_p in enemy_p_arr:
    enemy_rect_arr.append(pg.Rect(enemy_p,enemy_s)) 

  damage = 0 # 敵に衝突直後の無敵時間の残りを保持する変数

  # ゲームループ
  while not exit_flag:

    # システムイベントの検出
    for event in pg.event.get():
      if event.type == pg.QUIT:
        exit_flag = True
        exit_code = '001'
      if event.type == pg.KEYDOWN:
        if event.key == pg.K_SPACE:
          jump = True
          fire = 6  # 6フレーム間、炎を表示

    # 敵との接触判定
    if damage == 0:
      # 魔理沙の現在位置をあらわす矩形を設定
      face_rect = pg.Rect((face_p-face_s/2),face_s)
      for enemy_rect in enemy_rect_arr:
        if enemy_rect.colliderect(face_rect): # 衝突判定
          damage = 20  # これがゼロになるまで無敵
          break
    else:
      damage -= 1  # 無敵時間の残りをデクリメント

    # 背景描画
    if damage == 0:
      screen.fill(pg.Color('#48c0f0'))
    else :
      screen.fill(pg.Color('#ff0000'))

    # 敵の描画
    for enemy_p in enemy_p_arr:
      screen.blit(enemy_img,enemy_p)

    # 火のエフェクト処理
    if fire > 0 :
      tmp_p = face_p - pg.Vector2(24,r.randint(-10,0))
      screen.blit(fire_img,tmp_p)
      fire -= 1

    # ジャンプアクション処理
    if jump:
      face_v.y = -8
      face_v.y = -8
      face_v.x += 0.5 if face_v.x > 0 else  -0.5
      jump = False
    
    # 魔理沙の回転角度の計算
    if face_v.x > 0 : 
      theta -= min(20,face_v.magnitude_squared()*2.4)
    else :
      theta += min(20,face_v.magnitude_squared()*2.4)

    # 魔理沙の描画
    img = pg.transform.rotozoom(face_img,theta,1) # imgを回転
    tmp_p = face_p - (img.get_rect().center) # 描画開始位置(左上)の計算
    screen.blit(img,tmp_p)

    # 位置と速度の更新
    face_p += face_v
    face_v += face_a

    ## 地面との衝突処理
    if face_p.y >= disp_h - ground_s.y - face_r.y :
      face_p.y = disp_h - ground_s.y - face_r.y
      face_v.y = - 0.7 * ( face_v.y - face_a.y )
      if abs(face_v.y) < 3.5 :
        face_v.y = 0
        face_v.x *= 0.98  

    ## 右端と左端との衝突
    if face_p.x + face_r.x > disp_w :
      face_p.x = disp_w - face_r.x
      face_v.x = -0.8 * face_v.x 
    elif face_p.x - face_r.x < 0:
      face_p.x = face_r.x
      face_v.x = -0.8 * face_v.x 

    # 地面描画
    for x in range(0,disp_w,int(ground_s.x)):
      screen.blit(ground_img,(x,disp_h-ground_s.y))

    # フレームカウンタの描画
    frame += 1
    frm_str = f'{frame:05}'
    screen.blit(font.render(frm_str,True,'BLACK'),(10,10))

    # 画面の更新と同期
    pg.display.update()
    clock.tick(30)

  # ゲームループ [ここまで]
  pg.quit()
  return exit_code

if __name__ == "__main__":
  code = main()
  print(f'プログラムを「コード{code}」で終了しました。')

実行結果は次のようになります。

img

上記のプログラムについて、試行錯誤あるいはウェブ検索・ChatGPTを利用して解読してください(ChatGPTの活用例)。

次回講義では、このプログラムの内容について ある程度は理解できている前提 で講義を進めます。


演習(EX): 上記の 04_game.py をベースに、ゲームをデザインして実装せよ。例えば「自キャラのHPを設定して画面上に表示する」「敵キャラが移動する」「敵キャラの種類を増やす」「ゲーム進行とともに敵キャラが追加出現したり、消滅したりする」といったゲームとしてのアイデアを考え、実際に実装せよ。