画面効果その1・パーティクル – PICO-8ゲーム開発入門(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
次回は、まだ実装していなかったプレイヤーの方が「やられる」しくみを作り、さらにその場合の画面効果も加えていきます。