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

2024年12月20日(金)1・2時限

1 概要・連絡

1.1 協力依頼

1.2 案内


2 課題07 (自由作品)

現在までの Pythonプログラミングに関する学び (9カ月弱) の集大成 として、何らかのPythonアプリ(自由作品) を作成し、そのプログラムを GitHub で公開するとともに、課題06で作成した「ポートフォリオ」のなかに 作品を掲載 してください。

参考

作成に際しては先輩ら (2024年度3Iの学生) のポートフォリオの当該項目を参考にしてください。

2.1 注意

ポートフォリオに作品として掲載する際、必ず、次の内容を含めてください。これらに漏れがあると「ポートフォリオのなかでの紹介を5点満点」について、2.5点以下の評価 になります。細かな書式や形式については自由です。

2.2 Q&A

その他、不明点があれば TeamsChat で相談・確認をしてください。

2.3 今回講義の達成目標

3 キャラクタの表示 (PyGameを使った画像処理)

前回講義では OpenCV を利用して「ゲーム」に使用する画像を 切り出し たり (=トリミングしたり)、拡大縮小したりしました。同様に PyGameライブラリを利用して画像の切り出しや拡大縮小、回転処理をすること もできます。ここでは前回講義 のなかでダウンロードして、キャラ単位 (4方向x3アニメーションパターンの範囲) にトリミングした data/img/reimu.png を読込み、PyGame を使って「1ポーズごと」に切り出して、さらに「縦横2倍」に拡大して画面に配置する処理を行ないます。

img

具体的には、次のような画面 (霊夢の「前向き」「(ゼロオリジンで) 1番目」のアニメーションポーズ) が表示されるようなプログラムを作成していきます。※ゼロオリジンとは、先頭要素を 0 番目とするカウント方法です。

img

前回に構築した開発環境のなかで 05_game2_00-a.py というファイルを新規作成して、以下のプログラムを貼り付けて実行し、その動作を確認してください (プログラムを読むと分かるように、現段階ではまだキャラクタを「操作すること」はできません)。

import pygame as pg

def main():

  # 初期化処理
  chip_s = 48 # マップチップの基本サイズ
  map_s  = pg.Vector2(16,9) # マップの横・縦の配置数 

  pg.init() 
  pg.display.set_caption('ぼくのかんがえたさいきょうのげーむ II')
  disp_w = int(chip_s*map_s.x)
  disp_h = int(chip_s*map_s.y)
  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'

  # グリッド設定
  grid_c = '#bbbbbb'

  # 自キャラ移動関連【未実装】
  
  # 自キャラの画像読込み
  reimu_p = pg.Vector2(2,3)   # 自キャラ位置  
  reimu_s = pg.Vector2(48,64) # 画面に出力する自キャラサイズ 48x64
  reimu_d = 2 # 自キャラの向き
  reimu_img_raw = pg.image.load('./data/img/reimu.png')
  pose_p = pg.Vector2(24,64) # 前向き・2番目のポーズの位置 
  pose_s = pg.Vector2(24,32) # ポーズのサイズ
  tmp = reimu_img_raw.subsurface(pg.Rect(pose_p,pose_s))
  reimu_img = pg.transform.scale(tmp,reimu_s) 

  # ゲームループ
  while not exit_flag:

    # システムイベントの検出
    cmd_move = -1
    for event in pg.event.get():
      if event.type == pg.QUIT: # ウィンドウ[X]の押下
        exit_flag = True
        exit_code = '001'
      # 移動用のキー入力の検出【未実装】

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

    # グリッド
    for x in range(0, disp_w, chip_s): # 縦線
      pg.draw.line(screen,grid_c,(x,0),(x,disp_h))
    for y in range(0, disp_h, chip_s): # 横線
      pg.draw.line(screen,grid_c,(0,y),(disp_w,y))

    # 移動コマンドの処理【未実装】

    # 自キャラの描画 dp:描画基準点(imgの左上座標)
    dp = pg.Vector2(96,120)
    screen.blit(reimu_img,dp)

    # フレームカウンタの描画
    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}」で終了しました。')

このプログラムの基本構造については前回の講義のなかで「解説済み」です。新たな要素 (プログラム内に記載したコメントでいえば #自キャラの画像読込み#グリッド#自キャラの描画 に関係する部分) について、以降、解説していきます。

3.1 画像の「切り出し」と「拡大」の処理

data/img/reimu.png は 256x288 の画像で 4方向x3アニメーションパターン が収録されています。つまり、1個の「ポーズ」の画像の大きさは 24x32 となります。ここから「前向き」の「(ゼロオリジンで) 1番目のアニメーションポーズ」を切り出して、さらに「2倍」に拡大する処理を 第25行目から第33行目 にかけて行なっています。

# 自キャラの画像読込み
reimu_p = pg.Vector2(2,3)   # 自キャラ位置  
reimu_s = pg.Vector2(48,64) # 画面に出力する自キャラサイズ 48x64
reimu_d = 2 # 自キャラの向き
reimu_img_raw = pg.image.load('./data/img/reimu.png')
pose_p = pg.Vector2(24,64) # 前向き・2番目のポーズの位置 
pose_s = pg.Vector2(24,32) # ポーズのサイズ
tmp = reimu_img_raw.subsurface(pg.Rect(pose_p,pose_s))
reimu_img = pg.transform.scale(tmp,reimu_s) 

第29行目 では、12個 (=3x4) のポーズが収録された画像を読み込んで、変数 reimu_img_raw に格納しています。つづく 第30行目 では、ポーズの切り出しの開始位置 (左上位置のX,Y座標) を pose_p にセットし、第31行目 では、ポーズの切り出しの大きさ (横幅,縦幅) を pose_s にセットしています。

第32行目 では、subsurface メソッドで 矩形 (rect) を引数に与えて切り出しをして、その結果を変数 tmp に格納しています。具体的に言えば reimu.png の (X,Y)=(24,64) を左上として (W,H) = (24,32) の矩形領域を切り取った画像を、変数 tmp に格納しています。※矩形くけい と読みます。

さらに、第33行目pg.transform.scale によって「拡大処理」をしています。scale 関数の第1引数には「元画像」、第2引数には 拡大後の画像サイズVector2 オブジェクトタプル で与えます。

第30行目から第32行目の処理については、次のように様々な書き方ができます。いずれのスタイルで記述されていても読解できることが求められます。文章の表現と同じで 唯一の正解の書き方/表現 というものは存在しません。

pose_p = pg.Vector2(24,64) # 前向き・2番目のポーズの位置 
pose_s = pg.Vector2(24,32) # ポーズのサイズ
tmp = reimu_img_raw.subsurface(pg.Rect(pose_p,pose_s))
pose_p = pg.Vector2(24,64)
pose_s = pg.Vector2(24,32)
pose_rect = pg.Rect(pose_p,pose_s)
tmp = reimu_img_raw.subsurface(pose_rect)
pose_rect = pg.Rect(pg.Vector2(24,64),pg.Vector2(24,32))
tmp = reimu_img_raw.subsurface(pose_rect)
tmp = reimu_img_raw.subsurface(pg.Rect(pg.Vector2(24,64),pg.Vector2(24,32)))
tmp = reimu_img_raw.subsurface(pg.Rect((24,64),(24,32)))
pose_x = 24
pose_y = 64
pose_w = 24
pose_h = 32
tmp = reimu_img_raw.subsurface(pg.Rect((pose_x,pose_y),(pose_w,pose_h)))
pose_x, pose_y = 24, 64
pose_w, pose_w = 24, 32
tmp = reimu_img_raw.subsurface(pg.Rect((pose_x,pose_y),(pose_w,pose_w)))

また、第33行目 の「拡大処理」には前回講義の最後に示したサンプルコードで使用した pg.transform.rotozoom を使用することもできます。ただし pg.transform.scalepg.transform.rotozoom では出力に「差異」が生じます。詳しくは、実際に実行結果 (画面出力の違い) を比較した うえで、ウェブ検索などを利用して内部処理の違いを探ってみてください。

reimu_img = pg.transform.rotozoom(tmp,0,2) # 0度回転・2倍拡大

演習1: ゲーム画面に表示されるキャラクタが「前向き」「(ゼロオリジンで) 0番目のアニメーションポーズ」になるようにせよ。また「(ゼロオリジンで) 2番目のアニメーションポーズ」になるようにせよ。ヒント: 第30行目pose_p

演習2: ゲーム画面に表示されるキャラクタが「左向き」「(ゼロオリジンで) 1番目のアニメーションポーズ」になるようにせよ。また「右向き」になるようにせよ。

3.2 グリッドの描画

以降の開発における動作確認 (デバッグ) を簡単にするために、画面に「グリッド (マス目)」を表示しています。グリッドの描画は、主に 第49行目から第53行目 で行なっています。

# 初期化処理
chip_s = 48 # マップチップの基本サイズ
# グリッド設定
grid_c = '#bbbbbb'
# グリッド
for x in range(0, disp_w, chip_s): # 縦線
  pg.draw.line(screen,grid_c,(x,0),(x,disp_h))
for y in range(0, disp_h, chip_s): # 横線
  pg.draw.line(screen,grid_c,(0,y),(disp_w,y))

第55行目第57行目pg.draw.line 関数の第1引数には「描画先」、第2引数には「線色」、第3引数には 線分の始点座標のVector2やタプル/リスト、第4引数には 線分の終点座標のVector2やタプル/リスト を与えます。

線幅 の設定については公式リファレンスを参照してください。また、アンチエイリアシングした線分 を描画する pg.draw.lin.aaline 関数も存在します。


演習: グリッドの「色」と「線幅」を変更せよ。

3.3 キャラクタの描画

キャラクタ画像の画面描画は 第57行目から第59行目 で行っています。

第59行目screen.blit 関数の第1引数には「表示する画像」、第2引数には「画像の左上を配置するスクリーン座標」を与えます。具体的には、ここでは スクリーン上 (画面上) の (X,Y) = (96,120) の位置に、48x64 のキャラクタ画像の 左上 をあわせて配置するようなプログラムになっています。キャラクタを グリッドの中央にあわせる ための調整 を含めて (96,120) という値にしています ( なぜこのような値にしているか? を考えてみてください )。

# 自キャラの描画 dp:描画基準点(imgの左上座標)
dp = pg.Vector2(96,120)
screen.blit(reimu_img,dp)

演習1: キャラクタが(ゼロオリジンの) グリッド座標 (5,3) に表示されるように変更せよ。ただし、第26行目reimu_p = pg.Vector2(5,3) に変更し、それを参照する計算によってキャラクタの描画位置 dp を設定すること (つまり reimu_p の値に対応してキャラクタの表示位置が変化するようにせよ)。解答例:次のセクションに示す 05_game2_00-b.py の第79行目を参照

演習2: 次のようにフレームカウンタの下にキャラクタを配置している「グリッド座標」が表示されるようにせよ。

img

4 キーの入力による移動機能の追加

05_game2_00-b.py というファイルを新規作成して、以下のプログラムを貼り付けて実行し、動作を確認してください。このプログラムは、変数 reimu_p の値を参照して キャラクタの表示位置が変化するようにしたプログラム (=先の演習の解答例) に カーソルキーによる移動機能 を追加実装したものになります。現状では 1マス単位の離散的な移動 かつ FC版ドラクエ1のように「カニ歩き」しかできません。

追加実装された部分は 第23行目から第30行目#自キャラ移動関連のパラメータ設定第51行目から第60行目#移動操作の「キー入力」の受け取り処理第71行目から第76行目#移動コマンドの処理 になります。

import pygame as pg

def main():

  # 初期化処理
  chip_s = 48 # マップチップの基本サイズ
  map_s  = pg.Vector2(16,9) # マップの横・縦の配置数 

  pg.init() 
  pg.display.set_caption('ぼくのかんがえたさいきょうのげーむ II')
  disp_w = int(chip_s*map_s.x)
  disp_h = int(chip_s*map_s.y)
  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'

  # グリッド設定
  grid_c = '#bbbbbb'

  # 自キャラ移動関連のパラメータ設定
  cmd_move = -1 # 移動コマンドの管理変数
  m_vec = [
    pg.Vector2(0,-1),
    pg.Vector2(1,0),
    pg.Vector2(0,1),
    pg.Vector2(-1,0)
  ] # 移動コマンドに対応したXYの移動量
  
  # 自キャラの画像読込み
  reimu_p = pg.Vector2(2,3)   # 自キャラ位置  
  reimu_s = pg.Vector2(48,64) # 画面に出力する自キャラサイズ 48x64
  reimu_d = 2 # 自キャラの向き
  reimu_img_raw = pg.image.load('./data/img/reimu.png')
  pose_p = pg.Vector2(24,64) # 前向き・2番目のポーズの位置 
  pose_s = pg.Vector2(24,32) # ポーズのサイズ
  tmp = reimu_img_raw.subsurface(pg.Rect(pose_p,pose_s))
  reimu_img = pg.transform.scale(tmp,reimu_s) 

  # ゲームループ
  while not exit_flag:

    # システムイベントの検出
    cmd_move = -1
    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_UP:
          cmd_move = 0
        elif event.key == pg.K_RIGHT:
          cmd_move = 1
        elif event.key == pg.K_DOWN:
          cmd_move = 2
        elif event.key == pg.K_LEFT:
          cmd_move = 3

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

    # グリッド
    for x in range(0, disp_w, chip_s): # 縦線
      pg.draw.line(screen,grid_c,(x,0),(x,disp_h))
    for y in range(0, disp_h, chip_s): # 横線
      pg.draw.line(screen,grid_c,(0,y),(disp_w,y))

    # 移動コマンドの処理
    if cmd_move != -1:
      reimu_d = cmd_move # 現時点では使わない変数 (あとで使用)
      af_pos = reimu_p + m_vec[cmd_move] # 移動後の (仮) 座標
      if (0 <= af_pos.x <= map_s.x-1) and (0 <= af_pos.y <= map_s.y-1) :
        reimu_p += m_vec[cmd_move] # 画面内に収まるならキャラ座標を実際に更新

    # 自キャラの描画 dp:描画基準点(imgの左上座標)
    dp = reimu_p*chip_s - pg.Vector2(0,24)
    screen.blit(reimu_img,dp)

    # フレームカウンタの描画
    frame += 1
    frm_str = f'{frame:05}'
    screen.blit(font.render(frm_str,True,'BLACK'),(10,10))
    screen.blit(font.render(f'{reimu_p}',True,'BLACK'),(10,20))
    
    # 画面の更新と同期
    pg.display.update()
    clock.tick(30)

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

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

演習: カーソルキーを押下し続けた場合、どのように処理されるのか (キャラクタが1回だけ (1マスだけ) 移動するのか、連続的に移動するのか) を確認せよ。

以下、「追加実装した部分のみ」を解説をしていきます。

4.1 キャラクタ移動関連のパラメータ設定

第23行目から第30行目に追加したコードについて解説します。

  # 自キャラ移動関連のパラメータ設定
  cmd_move = -1 # 移動コマンドの管理変数
  m_vec = [
    pg.Vector2(0,-1),
    pg.Vector2(1,0),
    pg.Vector2(0,1),
    pg.Vector2(-1,0)
  ] # 移動コマンドに対応したXYの移動量

第24行目 では、ユーザーからのキーボードを使った「移動操作入力」の 有無内容 (以降「移動コマンド」と称します) を整数値 (-1から3) で保持・管理する変数 cmd_move を定義し、その初期値を -1 としています。

移動コマンドの 内容値 (整数値) の対応関係は、次のように定めます。

第25行目 から 第30行目 では、移動コマンドに対応したXYの移動量を Vector2オブジェクトのリスト として定義しています。例えば、移動コマンド cmd_move1 (=右方向への移動入力) のとき、m_vec[cmd_move]pg.Vector2(1,0) を返します。なお、Pythonのリストの特性上、cmd_move-1 のとき m_vec[cmd_move] とすると pg.Vector2(-1,0) が返ってくるので注意してください (第08回講義を参照)。

ここで、移動コマンドの 整数値 0,1,2,3上,右,下,左 に対応させているのは、data/img/reimu.png (=RPGツクール2000 の移動キャラのフォーマット) の画像ならびが、上から順に 上,右,下,左 となっているためです。このようにしておくことで、あとでキャラクタの 「向き」にあわせた画像を2次元リストから参照 する際、シンプルにコードが記述できるようになります。

img

4.2 移動操作の「キー入力」の受け取り処理

第51行目から第60行目では、ゲームループのなかでユーザーからの移動操作のキーボード入力」を受け取り、それ整数値 ( -1から3 ) に変換して cmd_move に格納しています。

# システムイベントの検出
cmd_move = -1
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_UP:
      cmd_move = 0
    elif event.key == pg.K_RIGHT:
      cmd_move = 1
    elif event.key == pg.K_DOWN:
      cmd_move = 2
    elif event.key == pg.K_LEFT:
      cmd_move = 3

まず 第46行目 で、ゲームループごとに cmd_move-1 (=移動入力なし) に初期化しています。つづいて 第52行目から第60行目 で、カーソルキーの押下 (Key Down) を検出して cmd_move を更新しています。キー入力の検出については、既に前回講義で学んでいます。

なお、キーの押下を続けているとき、連続的に cmd_move を更新する方法については次回講義で扱います。現状では、キーの押下を続けていても、押下した瞬間のゲームループのときしか cmd_move の値は更新されません。

4.3 移動コマンドの処理

第71行目から第76行目では、移動コマンド cmd_move-1 (=移動なし) 以外の値が入っているときに reimu_p (=キャラクタのグリッド座標位置) を更新する処理をしています。ただし、第75行目 の「if文」を使って キャラクタが画面外には移動しない ように工夫をしています。

# 移動コマンドの処理
if cmd_move != -1:
  reimu_d = cmd_move # 現時点では使わない変数 (あとで使用)
  af_pos = reimu_p + m_vec[cmd_move] # 移動後の (仮) 座標
  if (0 <= af_pos.x <= map_s.x-1) and (0 <= af_pos.y <= map_s.y-1) :
    reimu_p += m_vec[cmd_move] # 画面内に収まるならキャラ座標を実際に更新

第73行目reimu_d は、(現時点では未使用ですが) キャラクタの「向き (Direction)」を保持・管理するための変数です。移動方向と同じで、次のように「値 (整数値)」と「キャラクタの向き」を対応させる予定です。

5 キャラクタの「向き」と「アニメーション」の追加

05_game2_01.py というファイルを新規作成して、以下のプログラムを貼り付けて実行し、動作を確認してください。このプログラムは、変数 reimu_d の値に対応して キャラクタの「向き」に応じた画像が表示される ようにして、さらにキャラクタが「足踏みのアニメーション」をするように改良したものになります。

ついに 100行を超えるプログラム になってきましたが、先のセクション の 05_game2_00-b.py からの変更されている部分は 第36行目から第45行目第83行目から第86行目 だけになります。恐れる必要はありません。

import pygame as pg

def main():

  # 初期化処理
  chip_s = 48 # マップチップの基本サイズ
  map_s  = pg.Vector2(16,9) # マップの横・縦の配置数 

  pg.init() 
  pg.display.set_caption('ぼくのかんがえたさいきょうのげーむ II')
  disp_w = int(chip_s*map_s.x)
  disp_h = int(chip_s*map_s.y)
  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'

  # グリッド設定
  grid_c = '#bbbbbb'

  # 自キャラ移動関連
  cmd_move = -1 # 移動コマンドの管理変数
  m_vec = [
    pg.Vector2(0,-1),
    pg.Vector2(1,0),
    pg.Vector2(0,1),
    pg.Vector2(-1,0)
  ] # 移動コマンドに対応したXYの移動量
  
  # 自キャラの画像読込み
  reimu_p = pg.Vector2(2,3)   # 自キャラ位置  
  reimu_s = pg.Vector2(48,64) # 画面に出力する自キャラサイズ 48x64
  reimu_d = 2 # 自キャラの向き
  reimu_img_raw = pg.image.load('./data/img/reimu.png')
  reimu_img_arr = []
  for i in range(4):   # 上右下左の4方向
    reimu_img_arr.append([])
    for j in range(3): # アニメーションパターンx3
      p = pg.Vector2(24*j,32*i) # 切り抜きの開始座標・左上
      tmp=reimu_img_raw.subsurface(pg.Rect(p,(24,32))) # 切り出し
      tmp = pg.transform.scale(tmp,reimu_s) # 拡大
      reimu_img_arr[i].append(tmp)
    reimu_img_arr[i].append(reimu_img_arr[i][1])

  # ゲームループ
  while not exit_flag:

    # システムイベントの検出
    cmd_move = -1
    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_UP:
          cmd_move = 0
        elif event.key == pg.K_RIGHT:
          cmd_move = 1
        elif event.key == pg.K_DOWN:
          cmd_move = 2
        elif event.key == pg.K_LEFT:
          cmd_move = 3

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

    # グリッド
    for x in range(0, disp_w, chip_s): # 縦線
      pg.draw.line(screen,grid_c,(x,0),(x,disp_h))
    for y in range(0, disp_h, chip_s): # 横線
      pg.draw.line(screen,grid_c,(0,y),(disp_w,y))

    # 移動コマンドの処理
    if cmd_move != -1:
      reimu_d = cmd_move # キャラクタの向きを更新
      af_pos = reimu_p + m_vec[cmd_move] # 移動後の (仮) 座標
      if (0 <= af_pos.x <= map_s.x-1) and (0 <= af_pos.y <= map_s.y-1) :
        reimu_p += m_vec[cmd_move] # 画面外に移動しないなら実際のキャラを移動

    # 自キャラの描画 dp:描画基準点(imgの左上座標)
    dp = reimu_p*chip_s - pg.Vector2(0,24)
    af = frame//6%4 # 6フレーム毎にアニメーション
    screen.blit(reimu_img_arr[reimu_d][af],dp)

    # フレームカウンタの描画
    frame += 1
    frm_str = f'{frame:05}'
    screen.blit(font.render(frm_str,True,'BLACK'),(10,10))
    screen.blit(font.render(f'{reimu_p}',True,'BLACK'),(10,20))
    
    # 画面の更新と同期
    pg.display.update()
    clock.tick(30)

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

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

以下、変更された部分について解説をしていきます。

5.1 キャラクタの全12ポーズの読込み

第36行目から第45行目では、キャラクタについての「上右下左の4方向・各3ポーズ (=全12ポーズ)」を読込み、少しだけ拡張したうえで、それを2次元リスト reimu_img_arr に格納しています。それにより、例えば「右向き0番目のポーズ」の画像を reimu_img_arr[1][0] で取得 (参照) できるように、「右向き」「1番目のポーズ」の画像を reimu_img_arr[1][1] で取得 (参照) できるようにしています。

つまり reimu_img_arr[i][j] とすれば、iキャラクタの向き を指定し、jアニメーションポーズ を指定して、画像 (キャラクタのポーズ) を取得 (参照) できるようなデータ構造にしています。

img

演習1: 「前向き1番目のポーズ」は、reimu_img_arr に対してどのようにインデックス (つまり reimu_img_arr[i][j]ij の具体的な値) を与えればアクセスできるか答えよ。解答: reimu_img_arr[2][1]

演習2: 「左向き2番目のポーズ」は、reimu_img_arr に対してどのようにインデックスを与えればアクセスできるか答えよ。解答: reimu_img_arr[3][2]


./data/img/reimu.png に収録されているアニメーション用のポーズは、各方向とも「3パターン」です。具体的には、0列目が「右足が前のポーズ」、1列目が「両足が揃ったポーズ」、2列目が「左足が揃ったポーズ」が収録されています。

これらを使って自然な歩行のアニメーションをつくるためには「右足が前のポーズ」→「両足が揃ったポーズ」→「左足が前のポーズ」→「(再び) 両足が揃ったポーズ」→ … を繰り返す必要があります。reimu_img_arr[i][j] のインデックスでいえば j01210121 → … のように切り替えていく必要があります。

しかし、今回はコードをできるだけシンプルに保つために 2次元リストの「列」を1個拡張して、1番目のポーズと同じ画像を「3番目」にも配置 して、reimu_img_arr[i][j]j01230123 … のように切り替えることでアニメーションするようにしました。

そのような前提で、reimu_img_arr を以下のように 43 の「2次元リスト」に初期化しています。なお、リストに要素を追加する append メソッドの詳細は第08回講義を参照してください。

reimu_img_raw = pg.image.load('./data/img/reimu.png')
reimu_img_arr = []
for i in range(4):   # 上右下左の4方向
  reimu_img_arr.append([])
  for j in range(3): # アニメーションパターンx3
    p = pg.Vector2(24*j,32*i) # 切り抜きの開始座標・左上
    tmp=reimu_img_raw.subsurface(pg.Rect(p,(24,32))) # 切り出し
    tmp = pg.transform.scale(tmp,reimu_s) # 拡大
    reimu_img_arr[i].append(tmp)
  reimu_img_arr[i].append(reimu_img_arr[i][1])

上記のプログラムについて、自力で解読が難しい場合は ChatGPT などを活用してください。

なお、第45行目の処理 (第1列目の内容を第3列目に複製) は浅いコピー(Shallow Copy)に相当する処理であるため、メモリ使用量をほとんど圧迫 しません。


演習: 第46行目に、次のコードを追加して AssertionError が「発生しないこと」を確認せよ。for i in range(4): ブロックの外側 (ブロックを抜けたところ) に記述するようにインデントに注意せよ。

assert len(reimu_img_arr) == 4
assert reimu_img_arr[0][1] == reimu_img_arr[0][3]
assert reimu_img_arr[1][1] == reimu_img_arr[1][3]
assert reimu_img_arr[2][1] == reimu_img_arr[2][3]
assert reimu_img_arr[3][1] == reimu_img_arr[3][3]

上記の assert では、reimu_img_arr の各行の「1列目」と「3列目」に同じ画像 (=前足が揃っているポーズ) が格納されていることを確認しています。

5.2 キャラクタの「向き」と「アニメーション」の切り替え処理

第77行目第78行目では、ユーザーからの移動操作の「キー入力」があったときに、キャラの向きを保持・管理する変数 reimu_d を更新しています。例えば、カーソルキーの「 (pg.K_RIGHT)」が押下されたとき、cmd_move1 となるので、第78行目 により @reimu_d1 になります。

この変数 reimu_d第86行目reimu_img_arr[reimu_d][af] のように参照され、これにより 画面に表示されるキャラクタの「向き」が変更される仕組み になっています。

if cmd_move != -1:
  reimu_d = cmd_move # キャラクタの向きを更新
# 自キャラの描画 dp:描画基準点(imgの左上座標)
dp = reimu_p*chip_s - pg.Vector2(0,24)
af = frame//6%4 # 6フレーム毎にアニメーション
screen.blit(reimu_img_arr[reimu_d][af],dp)

キャラクタのアニメーションは、第85行目第86行目 の処理で実現されています。このコードでは 6フレーム毎 (=現在は 30fps で動かしているので 6/30 = 1/5 秒毎) にキャラのポーズが切り替わってアニメーションするようにしています。このアニメーション制御は、変数 af が鍵となっています。

第85行目af = frame//6%4 が分かりずらいと思うので順序立てて説明していきます。適当な Jupyter ノートブックを作成して、そこでコードを試しながら1段階ずつ理解を進めてください。

まず、変数 frame は、05_game2_01.py第89行目frame += 1 となっているように ゲームループ毎にカウントアップ する変数でした。また、ゲームループは 第96行目clock.tick(30) によって基本的には 1/30秒 で繰り返されます。つまり frame は 1/30 秒ごとに カウントアップ していきます。

ここで、疑似的に frame が「1205」から「1224」の範囲のゲームループをつくり、そこで af = frame として、af の値を出力するプログラムを考えます。ここで af は Animation Frame の略です。

%reset -f
frame = 1205
for _ in range(20):
  af = frame
  print(f'frame = {frame}   af = {af}')
  frame += 1

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

frame = 1205   af = 1205
frame = 1206   af = 1206
frame = 1207   af = 1207
frame = 1208   af = 1208
frame = 1209   af = 1209
frame = 1210   af = 1210
frame = 1211   af = 1211
frame = 1212   af = 1212
frame = 1213   af = 1213
frame = 1214   af = 1214
frame = 1215   af = 1215
frame = 1216   af = 1216
frame = 1217   af = 1217
frame = 1218   af = 1218
frame = 1219   af = 1219
frame = 1220   af = 1220
frame = 1221   af = 1221
frame = 1222   af = 1222
frame = 1223   af = 1223
frame = 1224   af = 1224

当然ながら、この af を使って reimu_img_arr[reimu_d][af] のようにすれば、インデックスの範囲 (03) を突破して IndexError が発生します。


そこで、IndexError が生じないように 第04行目af = frame から af = frame % 4 に書き換えます。% 演算子は、割り算の 余り (剰余) を求める演算子でした。

%reset -f
frame = 1205
for _ in range(20):
  af = frame % 4  # ここを変更した
  print(f'frame = {frame}   af = {af}')
  frame += 1

実行結果は次のようになります。例えば 1205 を 4 で割ると、商が「301」で余りが「1」となるので af1 となります。

frame = 1205   af = 1
frame = 1206   af = 2
frame = 1207   af = 3
frame = 1208   af = 0
frame = 1209   af = 1
frame = 1210   af = 2
frame = 1211   af = 3
frame = 1212   af = 0
frame = 1213   af = 1
frame = 1214   af = 2
frame = 1215   af = 3
frame = 1216   af = 0
frame = 1217   af = 1
frame = 1218   af = 2
frame = 1219   af = 3
frame = 1220   af = 0
frame = 1221   af = 1
frame = 1222   af = 2
frame = 1223   af = 3
frame = 1224   af = 0

これで reimu_img_arr[reimu_d][af] において IndexError は発生しません。実際に 05_game2_01.py第85行目af = frame % 4 に書き換えてみてください。確かに、エラーは発生しませんが、一方で アニメーションが高速すぎる という問題があります。


そこで、af = frame % 4af = frame // 6 % 4 に書き換えます。// 演算子は、割り算の「商 (整数値)」を求める演算子でした。例えば、10//33 となります。frame // 6 % 4 は、frame を 6 で割った「商」を計算し、それをさらに 4 で割った「余り」を求める計算式になります。

%reset -f
frame = 1205
for _ in range(20):
  af = frame // 6 % 4  # ここを変更した
  print(f'frame = {frame}   af = {af}')
  frame += 1

実行結果は次のようになります。af の値は6フレームの期間、同じ値をとることが分かります。30fps おいて、1フレームは 1/30 秒、6フレームは 1/5 秒 になります。よって、1/5 秒毎にポーズが切り替わるように調整されたアニメーションとすることができます。

frame = 1205   af = 0
frame = 1206   af = 1
frame = 1207   af = 1
frame = 1208   af = 1
frame = 1209   af = 1
frame = 1210   af = 1
frame = 1211   af = 1
frame = 1212   af = 2
frame = 1213   af = 2
frame = 1214   af = 2
frame = 1215   af = 2
frame = 1216   af = 2
frame = 1217   af = 2
frame = 1218   af = 3
frame = 1219   af = 3
frame = 1220   af = 3
frame = 1221   af = 3
frame = 1222   af = 3
frame = 1223   af = 3
frame = 1224   af = 0


演習1: 05_game2_01.py第85行目af = frame // 6 % 3 とすると (「右足が前のポーズ 0」→「両足が揃ったポーズ 1」→「左足が揃ったポーズ 2」→「右足が前のポーズ 0」→ 「両足が揃ったポーズ 1」→「左足が揃ったポーズ 2」→ … の繰り返しパターンにすると) ぎこちないアニメーションになることを確認せよ。

演習2: 05_game2_01.py では「48px」を基準にゲーム画面が構成、描画されている。これを「24px」あるいは「72px」を基準にゲームが画面が構成、描画されるように変更せよ (つまり、ゲーム画面の表示倍率変更 をせよ)。例えば scale_factor という定数を設け、それが 1 のときは「24px」を基準に、また 2 のときは「48px」を基準に、また 3 のときは「72px」を基準にゲームが画面が構成、描画されるように変更せよ。実装例はこちら

5.3 次のセクションを読み進める前に…

次のセクションは 05_game2_1.py の「構造」や「流れ」を理解していることが前提となります。つまり 各変数や各定数に格納される値の役割と、それらの依存関係、それに基づき決定される処理の順序関係について、(プログラムコードを参照しなくとも) 頭のなかで大まかに整理・理解し、他者に説明できるレベル になっていることが前提となります。

例えば、以下の「問い」に答えることができれば、次のセクションを読み進めることに問題はありません。

上記の「問い」は、変数や定数の名前を正確に答えることを意図しているわけでありません、変数同士の 依存関係について整理できているか、また依存関係に基づく 処理順序 が整理できているかについて確認するものです。

これらが不十分なまま、次のセクションを読み進めても余計に混乱するだけなので、無理せずに、再度、05_game2_01.py の理解に努めてください。

6 オブジェクト指向プログラミング (超々入門)

オブジェクト指向プログラミングOOP : Object Oriented Programming)とは ソフトウェアの設計と実装に関する概念 のひとつであり、また、それを具現化するためにプログラミング言語が提供する各種機能や構文を使用した実装を指します。

OOPは 極めて「抽象度」が高い概念 であり、また、それがカバーする範囲も広く奥深いものとなっています。それゆえ、OOPの「定義」さえ いまだに論争が尽きない ほどです(実際に、丸々1冊が「OOPとは何か」について書かれている書籍も多数あります)。

本講義では、これまでも「ソフトウェアの設計と実装に関する概念」として関数リスト (配列)繰り返し構文などを学んできましたが、OOPでは単純に 関連用語の数だけ をみても、それらとは比較にならないほど多いです。例えば、基本的な用語だけでも「クラス」「オブジェクト (インスタンス)」「コンストラクタ」「メソッド」「プロパティ」「カプセル化」「継承」「多態性 (ポリモーフィズム)」などがあります。そして、それら1語1語に対応した概念・機能があり、OOPの学習では、それについても理解していくことが求められます。

オブジェクト指向プログラミングを習得するには、どれぐらいの時間がかかるのか

(無論、個人差は大きいですが) 朝から晩まで、プログラミングだけを学べる環境にはない学生では、半年から1年、場合によっては2年ぐらいの時間をかけて「実装」と「理論」の両輪をまわすことを通じて OOPについて腑に落ちる という状態に到達すると思います。実際、Hカリの電子情報コースでは4年生に「オブジェクト指向プログラミング」という授業が「半期」で設定されています。

以上のように説明されだいぶ身構えてしまったかと思いますが、既に皆さんは オブジェクト指向プログラミングの一端に触れ、それを利用した実装にも多数取り組んできてもらっています。どういうことか言うと、大多数の Pythonライブラリ(モジュール)は OOP で設計・実装されており、それを利用するという形で、皆さんは、既に OOP を何度となく体験しています。

たとえば、前回講義 から登場している PyGameライブラリVector2 は OOP における クラス という存在であり、p = Vector2(2,3) のように初期化(=クラスから生成した)p は、OOP における「オブジェクト(インスタンス)」という存在になります。同様に、p の持つX成分にアクセスする p.xプロパティ、ベクトルの長さ (大きさ) を計算した戻り値を得る p.length()メソッド と呼ばれるものになります。

同様に、NumPy の「ndarray」も、Pandas の「DataFrame」も「クラス」であり、皆さんは、これまでにそれらを使用してきています。

このように、誰かが設計したクラスを シンプルに利用する という観点で言えば OOP は決して難しいというものではなく、むしろ、煩雑なコーディングを減らして生産性を高めてくれる存在 となります。例えば Vector2 を使わずに [2,3] のようなリストだけで 05_game2_01.py を実装することを想像してみてください。可読性は大幅に低下しますし、各種計算も非常に面倒になっていたはずです。

ここからは、「他者が設計した既存のクラスを利用する」のではなく「自分自身でクラスを設計する」というステージの OOP の学習もしていきます。

6.1 準備: プログラムの構造化

プログラムにおける「構造化」とは、プログラムの可読性、保守性、拡張性、再利用性を「高い水準で維持する」ために「プログラムの構造」を検討・設計・実装することを指します。これまでに学んだ範囲で言えば、次のようなことが 広義の構造化 になります。

この観点で言えば 05_game2_01.py は、ある程度の構造化が実現されており、その結果として 比較的容易に「拡張」や「修正」に取り組むこと ができます。実際、先に取り組んだ 演習2「ゲーム画面の表示倍率変更」は、わずかなコード修正で対応ができました。

一方で、構造化されていないプログラムは、その「拡張」や「修正」が 絶望的に困難 となります。例えば、以下の 05_game2_01-ng.py は、(あえて) 構造化せずにコーディングしたもの です。

このプログラムは、元々の 05_game2_01.py完全に同じ動作 をしますが、倍率変更は無論のこと、マップサイズの変更 (例えば (16,9) から (12,9) への変更) でさえ、極めて煩雑になることが分かると思います。さらに、今後も修正や機能拡張があることを考えれば いっそのことゼロから作り直したほうがよい と判断できます。

import pygame as pg

pg.init() 
pg.display.set_caption('ぼくのかんがえたさいきょうのげーむ II')
screen = pg.display.set_mode((768,432))
clock  = pg.time.Clock()
frame  = 0
exit_flag = False
exit_code = '000'

# 自キャラ移動関連
cmd_move = -1 # 移動コマンドの管理変数

# 自キャラの画像読込み
reimu_p_x = 2  # 自キャラのグリッド座標 X
reimu_p_y = 3  # 自キャラのグリッド座標 Y
reimu_d = 2    # 自キャラの向き
reimu_img_raw = pg.image.load('./data/img/reimu.png')

reimu_img_arr = []

# 上方向のキャラクタアニメーション
reimu_img_arr.append([])
img = reimu_img_raw.subsurface(pg.Rect((0,0), (24, 32)))
reimu_img_arr[0].append(pg.transform.scale(img, (48, 64)))
img = reimu_img_raw.subsurface(pg.Rect((24,0), (24, 32)))
reimu_img_arr[0].append(pg.transform.scale(img, (48, 64)))
img = reimu_img_raw.subsurface(pg.Rect((48,0), (24, 32)))
reimu_img_arr[0].append(pg.transform.scale(img, (48, 64)))
img = reimu_img_raw.subsurface(pg.Rect((24,0), (24, 32)))
reimu_img_arr[0].append(pg.transform.scale(img, (48, 64)))

# 右方向のキャラクタアニメーション
reimu_img_arr.append([])
img = reimu_img_raw.subsurface(pg.Rect((0,32), (24, 32)))
reimu_img_arr[1].append(pg.transform.scale(img, (48, 64)))
img = reimu_img_raw.subsurface(pg.Rect((24,32), (24, 32)))
reimu_img_arr[1].append(pg.transform.scale(img, (48, 64)))
img = reimu_img_raw.subsurface(pg.Rect((48,32), (24, 32)))
reimu_img_arr[1].append(pg.transform.scale(img, (48, 64)))
img = reimu_img_raw.subsurface(pg.Rect((24,32), (24, 32)))
reimu_img_arr[1].append(pg.transform.scale(img, (48, 64)))

# 下方向のキャラクタアニメーション
reimu_img_arr.append([])
img = reimu_img_raw.subsurface(pg.Rect((0,64), (24, 32)))
reimu_img_arr[2].append(pg.transform.scale(img, (48, 64)))
img = reimu_img_raw.subsurface(pg.Rect((24,64), (24, 32)))
reimu_img_arr[2].append(pg.transform.scale(img, (48, 64)))
img = reimu_img_raw.subsurface(pg.Rect((48,64), (24, 32)))
reimu_img_arr[2].append(pg.transform.scale(img, (48, 64)))
img = reimu_img_raw.subsurface(pg.Rect((24,64), (24, 32)))
reimu_img_arr[2].append(pg.transform.scale(img, (48, 64)))

# 左方向のキャラクタアニメーション
reimu_img_arr.append([])
img = reimu_img_raw.subsurface(pg.Rect((0,96), (24, 32)))
reimu_img_arr[3].append(pg.transform.scale(img, (48, 64)))
img = reimu_img_raw.subsurface(pg.Rect((24,96), (24, 32)))
reimu_img_arr[3].append(pg.transform.scale(img, (48, 64)))
img = reimu_img_raw.subsurface(pg.Rect((48,96), (24, 32)))
reimu_img_arr[3].append(pg.transform.scale(img, (48, 64)))
img = reimu_img_raw.subsurface(pg.Rect((24,96), (24, 32)))
reimu_img_arr[3].append(pg.transform.scale(img, (48, 64)))

# ゲームループ
while not exit_flag:

  # システムイベントの検出
  cmd_move = -1
  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_UP:
        cmd_move = 0
      elif event.key == pg.K_RIGHT:
        cmd_move = 1
      elif event.key == pg.K_DOWN:
        cmd_move = 2
      elif event.key == pg.K_LEFT:
        cmd_move = 3

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

  # グリッド
  pg.draw.line(screen,'#bbbbbb',(0,0),(0,432))
  pg.draw.line(screen,'#bbbbbb',(48,0),(48,432))
  pg.draw.line(screen,'#bbbbbb',(96,0),(96,432))
  pg.draw.line(screen,'#bbbbbb',(144,0),(144,432))
  pg.draw.line(screen,'#bbbbbb',(192,0),(192,432))
  pg.draw.line(screen,'#bbbbbb',(240,0),(240,432))
  pg.draw.line(screen,'#bbbbbb',(288,0),(288,432))
  pg.draw.line(screen,'#bbbbbb',(336,0),(336,432))
  pg.draw.line(screen,'#bbbbbb',(384,0),(384,432))
  pg.draw.line(screen,'#bbbbbb',(432,0),(432,432))
  pg.draw.line(screen,'#bbbbbb',(480,0),(480,432))
  pg.draw.line(screen,'#bbbbbb',(528,0),(528,432))
  pg.draw.line(screen,'#bbbbbb',(576,0),(576,432))
  pg.draw.line(screen,'#bbbbbb',(624,0),(624,432))
  pg.draw.line(screen,'#bbbbbb',(672,0),(672,432))
  pg.draw.line(screen,'#bbbbbb',(720,0),(720,432))

  pg.draw.line(screen,'#bbbbbb',(0,0),(768,0))
  pg.draw.line(screen,'#bbbbbb',(0,48),(768,48))
  pg.draw.line(screen,'#bbbbbb',(0,96),(768,96))
  pg.draw.line(screen,'#bbbbbb',(0,144),(768,144))
  pg.draw.line(screen,'#bbbbbb',(0,192),(768,192))
  pg.draw.line(screen,'#bbbbbb',(0,240),(768,240))
  pg.draw.line(screen,'#bbbbbb',(0,288),(768,288))
  pg.draw.line(screen,'#bbbbbb',(0,336),(768,336))
  pg.draw.line(screen,'#bbbbbb',(0,384),(768,384))

  # 移動コマンドの処理
  if cmd_move != -1:
    reimu_d = cmd_move
    af_pos_x = reimu_p_x
    af_pos_y = reimu_p_y
    if cmd_move == 0:
      af_pos_y = reimu_p_y - 1
    elif cmd_move == 1:
      af_pos_x = reimu_p_x + 1
    elif cmd_move == 2:
      af_pos_y = reimu_p_y + 1
    elif cmd_move == 3:
      af_pos_x = reimu_p_x - 1
      
    if 0 <= af_pos_x :
      if af_pos_x <= 15:
        if 0 <= af_pos_y : 
          if af_pos_y <= 8 :
            if cmd_move == 0:
              reimu_p_y = reimu_p_y - 1
            elif cmd_move == 1:
              reimu_p_x = reimu_p_x + 1
            elif cmd_move == 2:
              reimu_p_y = reimu_p_y + 1
            elif cmd_move == 3:
              reimu_p_x = reimu_p_x - 1

  # 自キャラの描画
  af = frame//6%4
  screen.blit(reimu_img_arr[reimu_d][af],(reimu_p_x*48,reimu_p_y*48-24))

  # フレームカウンタの描画
  frame = frame + 1
  screen.blit(pg.font.Font(None,15).render(f'{frame:05}',True,'BLACK'),(10,10))
  screen.blit(pg.font.Font(None,15).render(f'[{reimu_p_x},{reimu_p_y}]',True,'BLACK'),(10,20))
  
  # 画面の更新と同期
  pg.display.update()
  clock.tick(30)
# ゲームループ [ここまで]

pg.quit()
print(f'プログラムを「コード{exit_code}」で終了しました。')

上記のプログラムは論外として、ここまで作成してきた 05_game2_01.py については ある程度の構造化 ができています。しかし、引き続き「キャラクタの移動アニメーションを追加する」「画面に登場するキャラクタを増やす」「画面にアイテムを配置する」「壁や通路などのマップ構造を導入する」といった拡張を進めることを考えると、現状レベルの構造化では いずれ破綻すること (コードの複雑さに頭がついていけなくなること) は、ほぼ確実となります (あとで取り組む 演習 (EX) で実感できると思います)。

このため、ここからは OOP の主要な要素である クラス (Class) を導入することで、05_game2_01.py構造化のレベルをもう1段階高めていこう と思います。

6.2 OOP における「クラス」とは何か

オブジェクト指向プログラミングにおける クラス (Class) とは、オブジェクト (インスタンス/実体) を生成するための 設計図やテンプレート (ひな形) の役割を担う存在、つまり 型 (Type) について定義する存在となります。

一方、オブジェクト (インスタンス) とは、クラスに基づき初期化 (生成) されるもので「数値や文字列などのデータを格納した変数と、主にそれらのデータを操作・処理するための関数 (機能)をとまとめにしたモノ ( = カプセル化 したモノ)」となります。

クラス と オブジェクト (インスタンス) の「違い」は「クラスはあくまで オリジナルの型 を定義した存在 (テンプレートのようなもの) であり (基本的には) データを持っていない」、一方で「オブジェクト (インスタンス) は、型に従って生成された実体であり、具体的なデータを持っている」ということです。

情報を整理

具体例として PyGame の Vector2 (=クラス) を題材に、上記についてイメージを深めていきます (以下の解説は Vector2 の概要を理解している前提です)。実際にコードを書いたほうが分かりやすいので、前回講義で作成した 02_vector2.ipynb も VScode で開いて、コードセルに以下のプログラムをコピペしてください (もし、ノートブックで「ライブラリの認識」に関するエラーがでているときはこちらを確認してください)。

%reset -f
import pygame as pg

# pg.Vector2 は `type(型)` である
print(type(pg.Vector2)) # => <class 'type'>

# pg.Vector2 からオブジェクト (インスタンス・実体) を生成して vec に格納
vec = pg.Vector2(2, 3)

# vec は 'pygame.math.Vector2'型のオブジェクトであることを確認
print(type(vec)) # => <class 'pygame.math.Vector2'>

# vec は pg.Vector2 のオブジェクト(インスタンス) であることを確認
print(isinstance(vec,pg.Vector2)) # => True

print(vec.x)           # オブジェクトに x という変数があり、データが格納されている
print(vec.length())    # オブジェクトに length という関数があり、実行できる
print(vec.normalize()) # 

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

<class 'type'>
<class 'pygame.math.Vector2'>
True
2.0
3.605551275463989
[0.5547, 0.83205]

第05行目 では、「型」を確認する組み込み関数の typepg.Vector2 が何者であるかを調べています。結果は <class 'type'> が返ってきます。ここで大事なのは class はなく 'type' の部分です。pg.Vector2type' 、つまり「型」であることが確認できます。

第08行目 では、Vector2型 のオブジェクトを生成して変数に格納しています。一見すると 引数を与えて関数を実行して、その戻り値を変数に格納している ように見えます。しかし、第05行目 で確認したように pg.Vector2 は「関数」ではなく「型 (クラス)」なので、ここでされている処理は オブジェクトを初期化 (生成) して vec に代入する というものになります。

第11行目 では vec が何者であるかを調べており、結果として <class 'pygame.math.Vector2'> が返ってきています。先ほどと同様に、ここで大事なのは class はなく 'pygame.math.Vector2' の部分です。これより、vecVector2 オブジェクト (=「pygame.math.Vector2型のオブジェクト」= pygame.math.Vector2 クラスから生成されたオブジェクト) であることが確認できます。

第14行目 では isinstance 関数を使って「vec_apg.Vector2 クラス (型) の インスタンス であるか?」を判定しています。結果は True を返します。isinstance 関数の詳細は第11回講義を参照してください。

第16行目 では、オブジェクトが「データ」を持っていることを確認しています。このように オブジェクトが持っているデータ (オブジェクトに属するデータ) のことを OPP では プロパティ (property) や、属性 (attribute)、メンバー変数と呼びます。なお、正確には「プロパティ」と「属性」は違うものなのですが、しばらくは同じようなもの (オブジェクトが持つデータを指すもの) として扱います。

第17行目第18行目 では、オブジェクトが「関数」を持っていることを確認しています (length のあとに () がついていることで「データ」ではなく「関数」であることが分かると思います)。このように オブジェクトが持っている関数 のことを OPP では メソッド (method) や メンバー関数 と呼びます。

情報を整理


演習1: 上記の 第18行目 につづけて print(pg.Vector2.x) を記述して実行せよ。つまり「オブジェクト」ではなく「クラス」である pg.Vector2 からは プロパティ x について具体的なデータを参照できないことを確認せよ。

演習2: 上記の 演習1 につづけて print(pg.Vector2.length()) を記述して実行せよ。つまり「オブジェクト」ではなく「クラス」である pg.Vector2 からは メソッド length を実行できない (呼び出せない) ことを確認せよ。

6.3 自分でクラスを定義して使用する

関数と同じように、自分でクラスを定義して使用すること ができます。つまり、クラス/オブジェクトに持たせたい「変数」と「関数」 を設計・定義し、そのクラスからオブジェクトを生成してプログラムに使用することができます。ここでは、練習として Vector2 を真似た MyVector2 というクラスを作成していきます。

新しいコードセルを追加し、以下のプログラムを貼り付けて実行してみてください。なお、型のチェックに関連して assert文 を使用していますが、実際の開発では 例外処理 というものを利用することが一般的です (この「例外処理」については、今後の授業のなかで扱います)。

%reset -f
class MyVector2 :

  # コンストラクタの定義
  def __init__(self,init_x,init_y):
    assert type(init_x) == int or type(init_x) == float
    assert type(init_y) == int or type(init_y) == float
    self.x = float(init_x)
    self.y = float(init_y)
  
  # メソッド length の定義
  def length(self):
    return (self.x**2 + self.y**2)**0.5
  
  # メソッド normalize の定義
  def normalize(self):
    t = self.length()
    assert t != 0  # 大きさゼロの単位ベクトルはNG
    return [self.x/t,self.y/t]

# ここから MyVector をテストするコード
vec = MyVector2(2,3) # オブジェクト(インスタンス)の生成
print(type(vec))
print(vec.x)
print(vec.length())
print(vec.normalize())

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

<class '__main__.MyVector2'>
2.0
3.605551275463989
[0.5547001962252291, 0.8320502943378437]

6.3.1 コンストラクタの定義

クラスの定義は、第02行目から第19行目にかけて記述されています。

まず、第22行目MyVector2(2,3) のように クラス名() が実行されると、クラス定義の内の __init__ という名前を付けた関数が呼び出されす。この __init__ 関数は オブジェクトの初期化 に使用されるもので、コンストラクタ (constructor) と呼ばれます。コンストラクタとは「組み立てる」「構成する」 を意味する英語 “Construct” が語源です。

コンストラクタ (=クラス定義に記述される __init__ 関数) の第1引数である self は、そのオブジェクト自身を参照するために用いられるもので、第1引数として必ず設定する必要 があります (名前については self でなくても問題ありませんが、慣例的に self が使われます)。そして、第08行目第09行目のように self.x = ... のように設定された変数 x が、第24行目vec.x ようにオブジェクト (インスタンス) から利用できます。

もし、次のように 第08行目第09行目self を付けずに記述すると 第24行目vec.x ようにオブジェクトから参照することができません。また、第13行目のように、クラス定義のなかで、メソッド (メンバー関数) の処理を記述する際にも、xy が利用できなくなります。逆に言えば、コンストラクタのなかで一時的に使用する変数には self. を付けません。

x = float(init_x)
y = float(init_y)

情報を整理

6.3.2 オブジェクトの生成

第22行目 のように vec = MyVector2(2,3) という文を実行すると、MyVector2クラスのコンストラクタである __init__(self,init_x,init_y) が呼び出され、コンストラクタの第2引数 init_x には 2、第3引数 init_y には 3 が格納され、初期化の処理がされます。

img

そして、コンストラクタで初期化された オブジェクト (インスタンス) が戻り値となり、第22行目 の変数 vec に格納されます。

なお、MyVector2(2,3) で呼び出されるコンストラクタの「戻り値」は、自動的に設定されるため、__init__ 関数のなかに return を記述することはできません。コンストラクタのなかで return を記述すると TypeError: __init__() should return None が発生します。

情報を整理

6.3.3 メソッドの定義

クラス定義内の 第11行目から第19行目 では、メソッド (メンバー関数) を定義しています。クラスに定義したメソッドは 第25行目print(vec.length()) のようにオブジェクトから呼び出すことができます。

コンストラクタである __init__ 関数と同様に「メソッド」においても、第1引数には必ず self を設定します。第25行目vec.length() のように オブジェクトから「引数なし」でメソッドを呼び出す場合 であっても、self は必ず設定しなければならない点に注意してください。

# メソッド length の定義
def length(self):
  return (self.x**2 + self.y**2)**0.5

# メソッド normalize の定義
def normalize(self):
  t = self.length()
  assert t != 0  # 大きさゼロの単位ベクトルはNG
  return [self.x/t,self.y/t]

メソッドの定義では self. で、そのオブジェクトのプロパティ (属性) を読み取ったり、書き換えたりすることができます。なお、メソッドのなかで一時的に使用する変数第17行目t ように self. を付けずに用います。

情報を整理


演習: クラスの定義に「ベクトルの大きさを n倍 にするようなメソッド scale_by」を追加せよ。具体的には、第26行目 に続けて次のようなコードを書いて実行したとき、[6.0,9.0] のような標準出力を得たい。

vec.scale_by(3)
print(f'[{vec.x},{vec.y}]') # => [6.0,9.0]

オブジェクトをprint関数に与えたときに出力する文字列のカスタマイズ

現状では、MyVector2オブジェクトを print(vec) のように print 関数の「引数」に与えたときの出力は <__main__.MyVector2 object at 0x0000018BE27D4890> のようになってしまいます。ここで at 以降の16進数表記は環境によって変わります。

このままでは不便です。これを修正するためには、クラスの定義のなかに __str__ という名前の特殊メソッドを定義し、printstr 関数が呼び出された際に返すべき「文字列」をこのメソッドの戻り値として設定します。例えば、次のように __str__ メソッドを定義します。

def __str__(self):
  return f'[{self.x},{self.y}]'

このように __str__ メソッドが定義されていると print(vec) としたときに [2.0,3.0] のような視覚的に分かりやすい出力を得ることができます。

6.4 クラスの導入によるプログラムの構造化

05_game2_01.py に対して、クラスを導入してキャラクタに関する変数や処理をひとまとめにすること、つまり、カプセル化することで プログラムの構造化のレベルを高めたもの が、次の 05_game2_02.py となります。まず、このプログラムを実行し、元のバージョンと「完全に同じ動作をすること」を確認してください。

この例では 第05行目 から 第33行目 にかけて PlayerCharacter というクラスを定義しています。このクラスは、キャラクタ (霊夢) に関連する位置 (グリッド座標) やサイズ、向きなどをまとめ、クラスの外部からは 属性 (プロパティ) として参照できるようにしています。また、メソッド を用いて「指定した向きへの変更 (turn_to)」や「指定したグリッド座標への移動 (move_to)」をできるようにしたり、同じく メソッド に用いて「現在の向きやフレームカウントに応じて画像を取得 (get_img)」や「現在のグリッド座標にあわせた画像の位置を取得 (get_dp)」する機能も実装しています。

import pygame as pg

# PlayerCharacterクラスの定義 [ここから]

class PlayerCharacter:

  # コンストラクタ
  def __init__(self,init_pos,img_path):
    self.pos  = pg.Vector2(init_pos)
    self.size = pg.Vector2(48,64) 
    self.dir  = 2
    img_raw = pg.image.load(img_path)
    self.__img_arr = []
    for i in range(4):
      self.__img_arr.append([])
      for j in range(3):
        p = pg.Vector2(24*j,32*i)
        tmp = img_raw.subsurface(pg.Rect(p,(24,32)))
        tmp = pg.transform.scale(tmp, self.size)
        self.__img_arr[i].append(tmp)
      self.__img_arr[i].append(self.__img_arr[i][1])

  def turn_to(self,dir):
    self.dir = dir

  def move_to(self,vec):
    self.pos += vec 
  
  def get_dp(self):
    return self.pos * 48 - pg.Vector2(0,24)
  
  def get_img(self,frame):
    return self.__img_arr[self.dir][frame//6%4]

# PlayerCharacterクラスの定義 [ここまで]

def main():

  # 初期化処理
  chip_s = 48 # マップチップの基本サイズ
  map_s  = pg.Vector2(16,9) # マップの横・縦の配置数 

  pg.init() 
  pg.display.set_caption('ぼくのかんがえたさいきょうのげーむ II')
  disp_w = int(chip_s*map_s.x)
  disp_h = int(chip_s*map_s.y)
  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'

  # グリッド設定
  grid_c = '#bbbbbb'

  # 自キャラ移動関連
  cmd_move = -1 # 移動コマンドの管理変数
  m_vec = [
    pg.Vector2(0,-1),
    pg.Vector2(1,0),
    pg.Vector2(0,1),
    pg.Vector2(-1,0)
  ] # 移動コマンドに対応したXYの移動量

  # 自キャラの生成・初期化
  reimu = PlayerCharacter((2,3),'./data/img/reimu.png')

  # ゲームループ
  while not exit_flag:

    # システムイベントの検出
    cmd_move = -1
    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_UP:
          cmd_move = 0
        elif event.key == pg.K_RIGHT:
          cmd_move = 1
        elif event.key == pg.K_DOWN:
          cmd_move = 2
        elif event.key == pg.K_LEFT:
          cmd_move = 3

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

    # グリッド
    for x in range(0, disp_w, chip_s): # 縦線
      pg.draw.line(screen,grid_c,(x,0),(x,disp_h))
    for y in range(0, disp_h, chip_s): # 横線
      pg.draw.line(screen,grid_c,(0,y),(disp_w,y))

    # 移動コマンドの処理
    if cmd_move != -1:
      reimu.turn_to(cmd_move)
      af_pos = reimu.pos + m_vec[cmd_move] # 移動(仮)した座標
      if (0 <= af_pos.x <= map_s.x-1) and (0 <= af_pos.y <= map_s.y-1) :
        reimu.move_to(m_vec[cmd_move]) # 画面範囲内なら実際に移動

    # 自キャラの描画
    screen.blit(reimu.get_img(frame),reimu.get_dp())

    # フレームカウンタの描画
    frame += 1
    frm_str = f'{frame:05}'
    screen.blit(font.render(frm_str,True,'BLACK'),(10,10))
    screen.blit(font.render(f'{reimu.pos}',True,'BLACK'),(10,20))
    
    # 画面の更新と同期
    pg.display.update()
    clock.tick(30)

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

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

キャラクタに関するデータや処理をクラスに分離したことで プログラムの見通し が良くなりました。また、ここでは1個のファイルに全てのプログラムを記述していますが、クラスだけを別ファイルに分離して記述することもできるようになります。また、このようにクラス化することで 画面に2人目、3人目のキャラクタを登場させる ような拡張が処理が極めて簡単になります。これらについて、詳しくは次回の講義のなかで扱います。

6.5 ダンダーを付けた変数について

Python では、2連続のアンダーバー/アンダースコア のことを ダンダー (dunder, double underscore の略) と呼びます。クラス定義内で self.__img_arr のようにダンダーで始まる変数名を使用すると、その変数は (基本的に) クラスの外部からはアクセス不可能 になります。このように、特定の属性の存在を隠蔽できることもカプセル化のメリットのひとつです。もし、クラス外部から reimu.__img_arr のように .__img_arr にアクセスしようとすると AttributeError が発生します。

クラスの外部から変数の内容を読み取られたり、書き換えられたりすると都合の悪い場合は、__img_arr のようにダンダーで始まる名前にすることが推奨されます。また、この原則はメソッドに対しても同様に適用されます。

情報を整理

6.6 演習

演習1 (EX): 05_game2_02.py を読解せよ。また、ある程度の理解できたら 05_game2_01.py にクラスを導入して構造化するプログラムを自身で設計・実装せよ。


「誰かが設計したクラスを読解・理解すること」と「自分でクラスを設計・実装すること」では、求められる能力が異なります。読解には「解析」と「理解」の能力が、設計には「創造的思考」と「技術的スキル」が必要です。どちらの能力も重要であり、両方を習得することが求められます。なお、クラス設計について「このようにすればよい」という 唯一の正解 はありません。05_game2_02.py で示したクラスは、クラスに関する導入解説のために「分かりやすさ」を重視しており、機能面や保守面での最適化は考慮されていません (あくまで参考程度と考えてください)。