画面効果その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未満の値になります。

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





