シンギュラリティは近い……ゲームAIの初歩の初歩 – PICO-8ゲーム開発入門(10)

PICO-8でプログラミングを1から学ぶ連載、第10回のテーマは「ゲームAIの初歩の初歩」です。目的は、キャラクターの処理を共通化することと、AIによってNPCがPCを追いかけるようにさせてみることです。

PICO-8でプログラミングを1から学ぶ連載、第10回です。前回はこちら。プログラミング経験者の方は、日本語マニュアルを読んで、すぐに創作にとりかかりましょう。作った作品は、ぜひ掲示板へ。

前回、トマトケチャップをぶっ放す銃を実装しました。次に、ビームを浴びせられることになる、かわいそうな敵キャラクター(ノン・プレイヤー・キャラクター = NPC)たちを作りましょう。

今回のゴール
・NPCをプレイヤー・キャラクター(PC)と同様の処理で表示させる。
・AIによって、NPCがPCを追いかけるようにさせる。

まず、NPCの絵を描いてみます。こんな感じ。

NPCは、スプライトシートのタブ「1」に描いてみました。左上のスプライトの番号は64です。

AIを作る作業に入る前に、まずPCとNPCを表示・更新する処理を組み上げましょう。前回までに作った、PCを表示・更新するプログラムを整理して作ります。

キャラクターの処理を共通化

PC/NPCともに、表示したり動かす処理のほとんどは共通化できます。PCとNPCの決定的な違いは、PCはプレイヤーが操作し、NPCはゲームのプログラム(AI)が操作する、という点です。

PCもNPCも、テーブルによって表現します。これまでは、PCのテーブルをソースコードの冒頭で作っていました。今回はその処理を、PC/NPC共通のキャラクターのテーブルを作る関数、create_character() にまとめます。

function create_character(x, y, s0)
 local c={}
 c.x=x -- X座標
 c.y=y -- Y座標
 c.s=s0 -- 左上のスプライト番号
 c.s0=s0
 c.d=4
 c.dx=0
 c.dy=1
 c.spd=2
 c.walking=false
 return c
end

引数の x, y は座標です。s0 は、そのキャラクター用のスプライトシートの左上のスプライトの番号です。

PCは1番、NPCは64番です。

この関数を使って、NPCを作る処理は以下のようになります。

npcs={}

function add_npc(x, y)
 add(npcs, create_character(x, y, 64))
end

プレイヤーが操るPCとは異なり、NPCは複数います。npcsは複数のNPCが入る配列です。add_npc() は、NPCを1体生成して npcs に追加する関数です。_init() などからNPCを置く座標とともに呼び出します。

これに対して、PCを作る処理は以下のようになります。

pc={}

function init_pc()
 pc=create_character(64, 64, 1)
 pc.gun=false -- 銃を撃っている状態かどうか
 pc.gun_interval=0 -- 次に銃が使えるまでの待ちフレーム数
end

PCは1体だけです。npcs と変数の宣言のしかたは同じですが、変数 pc は配列ではなくテーブル1個ぶんです。init_pc() 関数では、create_character() で生成したテーブルを pc にそのまま代入しています。

PCはNPCと異なり銃を撃てるので、そのぶんテーブルに持たせる情報も多くなります。それらは、create_character() で生成したテーブルをpc変数に入れたあとに追加しています。

コードの全貌はこちら:

--- constants

ipf=8 -- interval per frame
nf=2 -- number of frames
max_gun_interval=8

beam_spd=4
beam_len=4
beam_life=64

--- variables

t=0

pc={}
beams={}
npcs={}

--- characters

function create_character(x, y, s0)
 local c={}
 c.x=x
 c.y=y
 c.s=s0
 c.s0=s0
 c.d=4
 c.dx=0
 c.dy=1
 c.spd=2
 c.walking=false
 return c
end

function update_c(c)
 -- sprite
 c.s=(c.d-1)+c.s0
 if c.walking then
  c.s+=flr((t%(nf*ipf))/ipf+1)*16
 end
 if c==pc and c.gun then
  c.s+=4
 end
 -- position
 if c.walking then
  c.x=c.x+c.dx*c.spd
  c.y=c.y+c.dy*c.spd
 end
end

function draw_c(c)
 spr(c.s,c.x-4,c.y-4)
end

function collide_with_other_npcs(c, x, y, r)
 for other in all(npcs) do
  if c~=other and collision(x, y, other.x, other.y, r) then 
   return true 
  end
 end
 return false
end

--- npcs

function add_npc(x, y)
 add(npcs, create_character(x, y, 64))
end

function update_npc(n)
 update_c(n)
end

--- pc

function init_pc()
 pc=create_character(64, 64, 1)
 pc.gun=false
 pc.gun_interval=0
end

function update_pc()
 update_c(pc)
 if pc.gun_interval>0 then
  pc.gun_interval-=1
 end
end

--- beams

function add_beam(x, y, dx, dy)
 local b={}
 b.x=x
 b.y=y
 b.dx=dx
 b.dy=dy
 b.life=beam_life
 add(beams, b)
end

function update_beam(b)
 b.x+=b.dx*beam_spd
 b.y+=b.dy*beam_spd
 b.life-=1
 if b.life<=0 then del(beam, b) end
end

function draw_beam(b)
 line(b.x+b.dx*beam_len, b.y+b.dy*beam_len, b.x, b.y, 8)
end

---

function input()
 -- walk
 pc.walking=false
 if btn(0) then
  pc.dx=-1
  pc.dy=0
  pc.d=1
  pc.walking=true
 end
 if btn(1) then 
  pc.dx=1
  pc.dy=0
  pc.d=2
  pc.walking=true
 end
 if btn(2) then 
  pc.dx=0
  pc.dy=-1
  pc.d=3
  pc.walking=true
 end
 if btn(3) then 
  pc.dx=0
  pc.dy=1
  pc.d=4
  pc.walking=true
 end
 -- gun
 pc.gun=btn(4)
 if pc.gun and pc.gun_interval==0 then
  local bx=pc.x
  local by=pc.y
  if pc.d==1 then
   bx-=3
   by+=1
  elseif pc.d==2 then
   bx+=2
   by+=1
  elseif pc.d==3 then
   bx+=2
   by-=2
  else
   bx-=3
  end
  add_beam(bx, by, pc.dx, pc.dy)
  pc.gun_interval=max_gun_interval
 end
end

---

function _init()
 init_pc()
 for y=1,2 do
  for x=1,7 do
   add_npc(x*16,y*16)
  end
 end
end

function _update()
 t+=1
 input()
 update_pc()
 foreach(npcs, update_npc)
 foreach(beams, update_beam)
end

function _draw()
 rectfill(0,0,127,127,13)
 draw_c(pc)
 foreach(npcs, draw_c)
 foreach(beams, draw_beam)
end

これを動かしてみると、こんな感じです:

オートマ坊やの眼前にたちはだかる魔王の使い魔たち。しかし彼らは襲ってこない。なぜなら、まだAIが無いから。

AIを作る

NPCを操作する処理のことは、AIと呼ばれます。AIとはArtificial Intelligence(=人工知能)の略です。AIというと、高度な技術に聞こえますが、ゲームの世界ではさほど複雑なプログラムでなくとも、NPCを動かしている処理はAIと呼ばれることがあります。

PCを追いかける

PCを追いかけるAIを作ってみます。たんにPCの方向に向かっていくだけの、簡単な処理です。NPCから見たPCの方向を計算して、その方向に毎フレームちょっとずつ移動していく、単純なルールです。

以下の関数を追加します。

function follow_pc(n)
 n.walking=true -- "歩き"状態にする
 n.spd=0.5 -- 少しスピードを遅めに
 local dx = pc.x-n.x
 local dy = pc.y-n.y
 local dist = distance(pc.x, pc.y, n.x, n.y)
 n.dx = dx/dist
 n.dy = dy/dist
end

引数の n は、NPC1つぶんのテーブルです。今までに作ってあるプログラムにより、n.walkingをtrueにしてから n.dx / n.dy に単位方向ベクトルの値を入れさえすれば、NPCは歩き出します。単位方向ベクトルは、NPCの向いている方向を表します。(このあたりの仕組みについては、第9回でも説明しています)。

単位方向ベクトルのX成分/Y成分は、下記の式で計算できます。

X成分 = (PCのX座標 - NPCのX座標) / PCとNPCの間の距離
Y成分 = (PCのY座標 - NPCのY座標) / PCとNPCの間の距離

PCとNPCの間の距離の計算は、第7回で作った distance() 関数を使います。follow_pc() 関数は、update_npc() から呼び出します。

function update_npc(n)
 follow_pc(n)
 update_c(n)
end

キャラクターの位置を更新する update_c() の前に follor_pc() を呼ぶことで、毎回NPCの次の向きを計算して更新します。動かしてみると、このようになります:

わらわらと、使い魔たちがPCを追いかけます。ただ、どの方向へ向かうときも、ずっと下の方向を向いてますね。

向きによってスプライトを変える

キャラクターのテーブルの要素 d に(1, 2, 3, 4)のいずれかを入れれば、対応する(左、右、上、下)の方向を向きます。PCの場合は、押された方向キーに対応した方向に向くだけなので簡単です。しかしNPCの場合は、4方向ではなく、360°に移動の自由度があります。

キャラクターのスプライトは4方向ぶんしか用意していないので、360°の自由度がある方向を、4方向のどれかに割り当てることにしましょう。三角関数(sin, cos, tan...)を使って向きの角度を計算する方法もありますが、今回は三角関数無しでやってみます。

function face_pc(n)
 if abs(n.dx)>abs(n.dy) then
  if n.dx<0 then n.d=1 end
  if n.dx>0 then n.d=2 end
 else
  if n.dy<0 then n.d=3 end
  if n.dy>0 then n.d=4 end
 end
end

これは、follow_pc() と同じく update_npc() で呼び出す関数で、引数 n はNPC1つぶんのテーブルです。まずNPCの単位方向ベクトルのX方向 / Y方向それぞれの大きさを調べて、X方向 / Y方向のどちらへの動きの方が大きいかを調べます。abs() はPICO-8独自の関数で、数の絶対値を計算します。abs(3)は3、abs(-3)も3です。
次に、移動量の大きい方の方向について、顔を向ける向きを決定します。X方向の移動量が大きかった場合、単位方向ベクトルのX成分(n.dx)が負なら左、正なら右に向きます。

この関数は、単位方向ベクトルを決定する follor_pc() のあとに呼び出しましょう。

function update_npc(n)
 follow_pc(n)
 face_pc(n)
 update_c(n)
end
ちゃんとPCの方を向いているように見えます。

NPCどうし重ならないようにする

もう一手間加えましょう。今のままでは、ずっと動かしていくうちに、NPCが1箇所にかたまるようになってしまいます。

これは、NPCどうしが重なることができるからです。重ならないようにするには、衝突判定のロジックを使います。update_c() でNPCの新しい座標を計算・更新するとき、もしその座標にほかのNPCが居たら移動をやめる、という処理を加えます。

まず、新しい座標にほかのNPCが居るかどうか調べる関数を作ります。

function collide_with_other_npcs(c, x, y, r)
 for other in all(npcs) do
  if c~=other and collision(x, y, other.x, other.y, r) then
   return true
  end
 end
 return false
end

引数 c はNPC1つぶんのテーブル、x, y は新しい座標、r は衝突判定の半径です。引数で与えられたNPCの新しい座標が、ほかのすべてのNPCとぶつかっていないか調べます。ぶつかっていたら true、そうでなければ false を返します。collision() は第7回に作った衝突判定の関数です。

以下の文では、and を使って2つの条件を組み合わせています。

if c~=other and collision(x, y, other.x, other.y, r) then

andは「~かつ~」の意味で、c ~= other と collision(x, y, other.x, other.y, r) が両方とも true のとき、trueになります。(このようなキーワードを論理演算子と呼び、ほかに「~または~」の意味の or と、「~ではない」の意味の not があります。)

また、c ~= other の~=は == の逆の意味で、a ~= b ならば、aはbではないということです。npcs には引数のNPC自身も含まれるので、自分自身との衝突判定をしないように、この条件を加えています。

collide_with_other_npcs() は update_c() から呼び出します。

function update_c(c)
 (略)
 -- position                                                                                       
 if c.walking then
  local nx=c.x+c.dx*c.spd
  local ny=c.y+c.dy*c.spd
  if c == pc or not collide_with_other_npcs(c, nx, ny, 6) then
   c.x=nx
   c.y=ny
  end
 end
end

いままでは新しい座標を計算したら、すぐに x, y 要素を更新していました。今回は、ほかのNPCとの衝突を検知した場合は、更新をやめるようにしています。(PCの場合は衝突判定をしないので、c == pc の条件を加えています。)

かたまらずに群れるようになりました。

collide_with_other_npcs() の第4引数は、衝突の半径です。この値を大きくすると、NPCたちは間隔をあけるようになります。

半径=10の場合。

いろいろ数値を変えて試してみると、面白いかもしれません。最終的なコードは以下のとおりです。

--- constants

ipf=8 -- interval per frame
nf=2 -- number of frames
max_gun_interval=8

beam_spd=4
beam_len=4
beam_life=64

--- variables

t=0

pc={}
beams={}
npcs={}

--- common functions

function distance(x1,y1,x2,y2)
 return sqrt((x1-x2)^2+(y1-y2)^2)
end

function collision(x1,y1,x2,y2,r)
 return distance(x1,y1,x2,y2) < r
end

--- characters

function create_character(x, y, s0)
 local c={}
 c.x=x
 c.y=y
 c.s=s0
 c.s0=s0
 c.d=4
 c.dx=0
 c.dy=1
 c.spd=2
 c.walking=false
 return c
end

function update_c(c)
 -- sprite
 c.s=(c.d-1)+c.s0
 if c.walking then
  c.s+=flr((t%(nf*ipf))/ipf+1)*16
 end
 if c==pc and c.gun then
  c.s+=4
 end
 -- position
 if c.walking then
  local nx=c.x+c.dx*c.spd
  local ny=c.y+c.dy*c.spd
  if c == pc or not collide_with_other_npcs(c, nx, ny, 6) then
   c.x=nx
   c.y=ny
  end
 end
end

function draw_c(c)
 spr(c.s,c.x-4,c.y-4)
end

function collide_with_other_npcs(c, x, y, r)
 for other in all(npcs) do
  if c~=other and collision(x, y, other.x, other.y, r) then 
   return true 
  end
 end
 return false
end

--- npcs

function add_npc(x, y)
 add(npcs, create_character(x, y, 64))
end

function update_npc(n)
 follow_pc(n)
 face_pc(n)
 update_c(n)
end

function follow_pc(n)
 n.walking=true
 n.spd=0.5
 local dx=pc.x-n.x
 local dy=pc.y-n.y
 local distance=distance(pc.x, pc.y, n.x, n.y)
 n.dx=dx/distance
 n.dy=dy/distance
end

function face_pc(n)
 if abs(n.dx)>abs(n.dy) then
  if n.dx<0 then n.d=1 end
  if n.dx>0 then n.d=2 end
 else
  if n.dy<0 then n.d=3 end
  if n.dy>0 then n.d=4 end
 end
end

--- pc

function init_pc()
 pc=create_character(64, 64, 1)
 pc.gun=false
 pc.gun_interval=0
end

function update_pc()
 update_c(pc)
 if pc.gun_interval>0 then
  pc.gun_interval-=1
 end
end

--- beams

function add_beam(x, y, dx, dy)
 local b={}
 b.x=x
 b.y=y
 b.dx=dx
 b.dy=dy
 b.life=beam_life
 add(beams, b)
end

function update_beam(b)
 b.x+=b.dx*beam_spd
 b.y+=b.dy*beam_spd
 b.life-=1
 if b.life<=0 then del(beam, b) end
end

function draw_beam(b)
 line(b.x+b.dx*beam_len, b.y+b.dy*beam_len, b.x, b.y, 8)
end

---

function input()
 -- walk
 pc.walking=false
 if btn(0) then
  pc.dx=-1
  pc.dy=0
  pc.d=1
  pc.walking=true
 end
 if btn(1) then 
  pc.dx=1
  pc.dy=0
  pc.d=2
  pc.walking=true
 end
 if btn(2) then 
  pc.dx=0
  pc.dy=-1
  pc.d=3
  pc.walking=true
 end
 if btn(3) then 
  pc.dx=0
  pc.dy=1
  pc.d=4
  pc.walking=true
 end
 -- gun
 pc.gun=btn(4)
 if pc.gun and pc.gun_interval==0 then
  local bx=pc.x
  local by=pc.y
  if pc.d==1 then
   bx-=3
   by+=1
  elseif pc.d==2 then
   bx+=2
   by+=1
  elseif pc.d==3 then
   bx+=2
   by-=2
  else
   bx-=3
  end
  add_beam(bx, by, pc.dx, pc.dy)
  pc.gun_interval=max_gun_interval
 end
end

---

function _init()
 init_pc()
 for y=1,2 do
  for x=1,7 do
   add_npc(x*16,y*16)
  end
 end
end

function _update()
 t+=1
 input()
 update_pc()
 foreach(npcs, update_npc)
 foreach(beams, update_beam)
end

function _draw()
 rectfill(0,0,127,127,13)
 draw_c(pc)
 foreach(npcs, draw_c)
 foreach(beams, draw_beam)
end

生き物の群れを率いるプログラムが完成しました!これはこれで、何らかのゲームに仕立てられそうですが、本連載ではもっと殺伐としたゲームを作っていきます。次回は、ケチャップ銃でやつらを撃ち倒せるようにします。また、戦場に障害物を置いてゲーム性に深みを加えます。

Ryosuke Mihara
Ryosuke Mihara

なかなか完成しないローグライクゲーム『Gesuido』を開発し続けている30代後半。また、レトロでモダンなファンタジー・コンソール『PICO-8』に魅了され、勝手に普及活動に励んでいる。

記事本文: 12