画面効果その1・パーティクル – PICO-8ゲーム開発入門(12)

PICO-8でプログラミングを1から学び、ゲーム作成を目指す連載第12回目は「パーティクル」について。パーティクルを追加することで、一気にゲームっぽさが増します。

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

前回までで、NPCをビームでやっつける仕組みを完成させました。今回は、パーティクルと画面を揺らす演出を追加して、「ゲームっぽさ」をグッと向上させます。

パーティクルとは

パーティクルとはすなわち粒子です。多数の”つぶつぶ”に動きをつけることによって、火花、爆発、水滴、煙などを表現できます。

パーティクルの例

パーティクルを扱うコードの構成

パーティクルを扱うためのコードは、以下のような構成になります。

-- パーティクルを入れる配列
ptcls={}

function add_ptcl(x,y)
  -- パーティクル追加処理 (これから書く)
end

function update_ptcl(p)
  -- パーティクル更新処理 (これから書く)
end

function draw_ptcl(p)
  -- パーティクル描画処理 (これから書く)
end


--

function _update()
  -- 全パーティクルについて、更新処理をする。
  foreach(ptcls, update_ptcl)
end

function _draw()
  -- 全パーティクルについて、
  foreach(ptcls, draw_ptcl()
end

PC / NPCを扱うしくみと同様なことが見てとれると思います。ptcls は particles の略で、パーティクルの配列です。配列に入っているすべてのパーティクルに対して、_update() で位置などの更新処理をし、_draw() で画面に描画します。個々のパーティクルを更新する関数は update_ptcl()、描画する関数は draw_ptcl() です。add_ptcl() は、新しいパーティクルを配列に追加する関数です。これは必要なときに呼びましょう。

ゲームに組み込む

上記をふまえて、オートマ坊やのゲームにパーティクルを実装します。ビームがNPCに当たったら、NPCの体がバラバラになって飛び散るようにします。飛び散る体の破片をパーティクルで表現します。ビームが当たったタイミングで、衝突地点にパーティクルを何個か配置します。それぞれの粒は衝突地点から外に向かって飛んでいき、やがて消えるようにします。

パーティクルの一生。生成され、飛び散り、やがて消滅する。

バラバラになったNPCの体の破片は、circfill() 関数を使って塗りつぶされた円で表現します。今回は円を使いますが、表現したい演出に合わせて描画方法を工夫すると良いです。pset() で点を描く、絵を描いてスプライトで表示する、print() で文字や記号を表示するのも面白いかも。

・add_ptcl()

パーティクル追加関数は、こんな感じです。

function add_ptcl(x, y)
 local p = {}
 p.x = x
 p.y = y
 p.r = rnd() * 2
 p.vx = rnd() * 2 - 1
 p.vy = rnd() * 2 - 1
 p.life = rnd() * 8 + 8
 add(ptcls, p)
end

引数に位置(x,y)を取り、これがパーティクルの初期位置になります。

rは円の半径です。粒の大きさに、ランダムな値を設定します。rnd()は0以上1未満の値を返すので、rnd() * 2で0以上2未満の値になります。

半径別の円の描画です。今回は0〜1の円を使います。円に見えないかもしれませんが、これが低解像度の円です。

vx, vyはパーティクルの移動速度です。こちらも同様に、ランダムな値を設定します。rnd()*2-1は、-1以上1未満の値になります。それによって、それぞれの粒はあちこちに飛んでいくことになります。

ランダムに選ばれる速度の範囲

lifeは、パーティクルの生存期間です。爆発のパーティクルは、発生してから少しの間だけ画面の中を飛んでいって、その後自動的に消えるようにします。_update() のタイミングで1ずつ減らしていき、これが0になったら配列から取り除きます。この値も8以上16未満のランダムな値にしました。

ビームがNPCに当たった時に、この add_ptcl() を呼びましょう。update_beam() の中に、以下のコードを追加します。

function update_beam(b)
 ... 略 ...
  if c ~= nil then
   del(npcs, c)
   del(beams, b)
   sfx(0)
   local num_ptcls=rnd()*10+5
   for i=0,num_ptcls do
    add_ptcl(b.x, b.y, b.dx, b.dy)
   end
  end
 end
end

numPtclsは追加するパーティクルの数です。これも乱数で幅を持たせて、5個以上15個未満追加されるようにします。

・update_ptcl()

パーティクル更新関数は、以下のとおりです。

function update_ptcl(p)
 p.x += p.vx
 p.y += p.vy
 p.life -= 1
 if p.life < 1 then
  del(ptcls, p)
 end
end

設定された速度で位置を更新する処理と、前述のとおり生存期間を減らす処理があります。life が0になったら、del() で ptcls 配列からパーティクルを削除します。

この関数は、_update() から呼び出します。

function _update()
 ... 略 ...
 foreach(ptcls, update_ptcl)
end

・draw_ptcl()

最後に、パーティクル描画関数です。

function draw_ptcl(p)
 circfill(p.x, p.y, p.r, 1)
end

circfill() の最後の引数は色です。NPCの体の色が1番なので、それに合わせています。

この関数は、_draw() から呼び出します。

function _draw()
 ... 略 ...
 foreach(ptcls, draw_ptcl)
end

動かしてみると、こんな感じ:

パーティクルをハックする

基本的なパーティクルのしくみが完成しました。ここから、さらに見た目や動きを調整します。

まず、ビームの方向にあわせてパーティクルが飛び散るようにします。「ビームの威力で吹き飛んだ」ように見せるのが狙いです。add_ptcl()を以下のように修正します。

function add_ptcl(x, y, dx, dy)
 local p = {}
 p.x = x
 p.y = y
 p.r = rnd() * 2
 p.vx = rnd() * 2 - 1 + dx
 p.vy = rnd() * 2 - 1 + dy
 p.life = rnd() * 8 + 8
 add(ptcls, p)
end

引数 dx, dy を追加しました。これはビームの単位方向ベクトル(第9回にて解説)、すなわちビームの向いている方向を表す数値です。それぞれの値をパーティクルの速度(vx, vy)に足し合わせます。これは速度の合成です。

速度の合成の例

add_ptcl()の呼び出し側は、以下のように変わります。

add_ptcl(b.x, b.y, b.dx, b.dy)

すると、こんな動きになります:

さらに、パーティクルの色を変えてみます。NPCの体の破片の一部が燃えているように見せるため、追加するパーティクルの一部の色を赤、白、黄にしてみます。魔物の体はケチャップ・ビームに含まれるリコピンと反応して燃焼します。

パーティクルのテーブルにcolという変数を追加して、色の情報を持たせます。add_ptcl()を以下のように変更しましょう。

function add_ptcl(x, y, dx, dy)
 local p = {}
 p.x = x
 p.y = y
 p.r = rnd() * 2
 p.vx = rnd() * 2 - 1 + dx
 p.vy = rnd() * 2 - 1 + dy
 p.life = rnd() * 8 + 8
 p.col = 1
 if rnd() > 0.6 then
  p.col = rnd() * 3 + 8
 end
 add(ptcls, p)
end

colは最初は1番に設定しておいて、rnd() の値(0以上1未満の乱数)が0.6より大きいときだけ、8〜10番の色に変更します。8=赤、9=オレンジ、10=黄です。一度に発生するパーティクルのうち、約40%は燃えた色になるというわけです。なお、colの値は整数ではなく小数点以下も含む値になりますが、PICO-8の関数に色の番号として渡すと、自動的に小数点以下が切り捨てられます。たとえば9.7196を色の番号として指定すると、9番の色になります。

描画関数も変更しましょう。

function draw_ptcl(p)
 circfill(p.x, p.y, p.r, p.col)                                                                                            
end

すると、こんな感じになります:

パーティクルのちょっとした調整で、見た目の気持ちよさがグッと変わります。ぜひ、自分なりに工夫してみてください。PICO-8 ZINE #1(日本語版) には、煙のパーティクルの作り方を紹介した記事があります。そちらも参考にしてみてください。

ゲームの全コードはこちら:

--- 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={}
ptcls={}

--- 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

--- particles

function add_ptcl(x, y, dx, dy)
 local p={}
 p.x=x
 p.y=y
 p.r=rnd()*2
 p.vx=rnd()*2-1+dx
 p.vy=rnd()*2-1+dy
 p.life=rnd()*8+8
 p.col=1
 if rnd()>0.6 then
  p.col=rnd()*3+8
 end
 add(ptcls, p)
end

function update_ptcl(p)
 p.x+=p.vx
 p.y+=p.vy
 p.life -= 1
 if p.life<1 then
  del(ptcls, p)
 end
end

function draw_ptcl(p)
 circfill(p.x, p.y, p.r, p.col)
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

function hit_npc(x, y, r)
 for c in all(npcs) do
  if collision(x, y, c.x, c.y, r) then
   return c
  end
 end
 return nil
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 or b.x<0 or b.x>127 or b.y<0 or b.y>127 then
  del(beams, b)
 else
  local c = hit_npc(b.x, b.y, 3) 
  if c ~= nil then
   del(npcs, c)
   del(beams, b)
   sfx(0)
   local num_ptcls=rnd()*10+5
   for i=0,num_ptcls do
    add_ptcl(b.x, b.y, b.dx, b.dy)
   end
  end
 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)
 foreach(ptcls, update_ptcl)
end

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

次回は、まだ実装していなかったプレイヤーの方が「やられる」しくみを作り、さらにその場合の画面効果も加えていきます。

Ryosuke Mihara
Ryosuke Mihara

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

Articles: 12