PICO-8のサンプルゲーム"Jelpi"について解説してみる

先日、PICO-8の作者のJoseph Whiteさんとお話する機会がありました。結構な時間おしゃべりさせていただいて、今まで気になっていたことを色々お聞きすることができました。(例えば、バージョン1.0が出るのは○月頃とか隠し仕様の話とか…)

と、その話はいずれまたどこかで触れるとして、今日はPICO-8の横スクロールゲームサンプルであるJelpiの解説をしてみたいと思います。

PICO-8サンプル"Jelpi"のプログラム解説

JelpiはPICO-8に付属するマリオ風の横スクロールゲームのサンプルです。

f:id:tkitao:20160816210612g:plain

PICO-8について知らないという方は以下のリンクをご覧ください。

PICO-8のコンソールモードで以下のコマンドを入力するとJelpiのコードの取得とプレイができます。

install_demos
cd demos
load jelpi.p8
run

f:id:tkitao:20160820214608p:plain

わずか719行のLuaコードで、この手のプラットフォーム型ゲームに必要な一通りの要素を押さえている、なかなか秀逸なサンプルです。

ただ、まだ作りかけらしく、バージョン0.1.8の現在では使っていないコードやこなれていない処理などがあり、読み解きに若干の注意が必要です。

実際にコードを読んで気づいたポイントを解説していきます。

キャラクターの座標系

キャラクターの座標はスクリーン座標(1ピクセル単位)ではなく、マップのセル(8x8ピクセルが1セル)と同じセル座標で管理されています。

セル座標系だと1ピクセル=0.125セルとなるため、1ピクセル単位の移動には小数を使うことになりますが、こうすることでプレイヤーの座標とマップの衝突判定を毎回8で割らなくても可能にしています。

キャラクターの描画位置

さらに、実際のキャラクターは座標(x, y)に対して(x*8ー4, y*8ー8)の位置に描画されます。

つまり各キャラクターの原点は下の図のようにスプライトの左上から数えて、右に4ピクセル、下に8ピクセルの位置になります。明確な理由は定かではありませんが、床との接触判定をしやすくするためではないかと思われます。

f:id:tkitao:20160820125300p:plain

キャラクターの移動

キャラクターの移動は座標を直接変更するのではなく、速度(dx, dy)を変更することで行っています。

こうすることでキャラクターの跳ね返りやジャンプ時の振る舞いなどを自然な動作にするのと共に、簡単に実現できるようにしています。例えばダッシュ中にブロックに衝突した時に、少しだけ跳ね返る処理は以下のように記述されています。

-- bounce
if (pl.super == 0 or not broke_block) then
  pl.dx = pl.dx * -0.5 -- 半分の速度で跳ね返る
end

マップのスクロール

なんとマップは常に全範囲を描画しています。スクリーン座標の(0,0)の位置をcamera命令でずらした後に全マップを描画することで、実際に画面内に表示される範囲のコントロールをしています。

なかなか大胆ですが、限定された範囲のマップであれば確かにこの方が実装が簡単です。

f:id:tkitao:20160820125317p:plain

マップ描画後、引数なしのcamera()命令を呼んで描画位置を元に戻しています。

キャラクターの出現位置

各キャラクター(プレイヤー、アイテム、敵)の出現位置は、すべてコードではなくマップ上のセルで指定されており、move_spawns関数内で画面位置に応じた出現キャラクターの決定と、複数回出現することを防ぐためにマップの該当セルを空白に書き換える処理を行っています。

こうすることでゲームのレベルデザインがマップエディタのみで完結するようになり、試行錯誤がしやすくなっています。

キャラクターのアニメーション管理方法

プレイヤーや敵のループアニメーションはスプライトシート上で連番で並んでいます。

f:id:tkitao:20160820125333p:plain

これは普通といえば普通ですが、以下のコードのように、使用するスプライト番号f0を移動距離に応じて増加(または減少)させることで、速度に応じた移動アニメーションを簡潔に実現できます。

 -- frame
if (pl.standing) then
  pl.f0 = (pl.f0+abs(pl.dx)*2+4) % 4
else
  pl.f0 = (pl.f0+abs(pl.dx)/2+4) % 4
end

 if (abs(pl.dx) < 0.1) then
  pl.frame=48 pl.f0=0
else
  pl.frame = 49+flr(pl.f0)
end

キャラクターとパーティクルの管理方法

actorテーブルとsparkleテーブルを初期化時に作成し、キャラクターとパーティクル(キラキラエフェクト)作成時にaddすることで、更新や描画のループによる一括処理を可能にしています。

function _init()
  actor = {}
  sparkle = {}
  ...
function make_actor(k,x,y,d)
  local a = {}
  ...
  if (count(actor) < max_actors) then
    add(actor, a)
  end
  return a
end

このあたりはゲーム開発の際のセオリーといった感じですね。

更新処理

更新処理を行う_update関数では以下の順で処理を行っています。

  • actorテーブルのすべてのキャラクターにmove_actor関数を呼んで位置を更新する
foreach(actor, move_actor)
  • sparkleテーブルのすべてのパーティクルにmove_sparkle関数を呼んで位置を更新する
foreach(sparkle, move_sparkle)
  • collisions関数で衝突処理を行う(後述)
  • プレイヤー位置に従ってmove_spawns関数でキャラクターを出現させる
  • outgame_logic関数でゲームオーバー時の演出処理を更新する

描画処理

描画処理を行う_draw関数では、以下の順で描画を行っています。

  • 空の色で画面をクリア
  • 雲を描画
  • 山を描画
  • マップの表示位置を計算
  • camera関数で描画位置をずらす
  • 全範囲のマップを描画する
  • すべてのパーティクルをdraw_sparkle関数で描画
  • すべてのキャラクターをdraw_actor関数で描画
  • 描画位置を元に戻す
  • ゲームオーバーならメッセージを描画

キャラクターとの衝突判定

敵やアイテムとの衝突判定は、collisions関数とcollide関数ですべてのキャラクターとの重なりを判定し、 重なっていた場合はcollide_event関数で相手に応じた処理を実行しています。

function collide(a1, a2)
  if (a1==a2) then return end
  local dx = a1.x - a2.x
  local dy = a1.y - a2.y
  if (abs(dx) < a1.w+a2.w) then
    if (abs(dy) < a1.h+a2.h) then
      collide_event(a1, a2)
    end
  end
end
function collisions()
  for a1 in all(actor) do
    collide(player,a1)
  end
  ...

キャラクターをactorテーブルで一元管理していると、こういった処理を行う際にループで記述できるので便利です。

壁との衝突判定

サンプルコードの中で一番複雑な処理が壁との衝突判定です。

x軸方向、y軸方向それぞれについて、①移動後の位置に壁があるかを判定し、②壁があれば元の位置から数ピクセルずつ壁に当たらない位置まで移動させる、というのが基本的な処理内容になります。

x軸方向の移動処理はこちらです。x1が壁衝突判定用の移動後のx座標になります。

 -- x movement
x1 = pl.x + pl.dx + sgn(pl.dx) * 0.3

local broke_block = false

if (not solid(x1,pl.y-0.5)) then
  pl.x = pl.x + pl.dx
else -- hit wall
  -- search for contact point
  while (not solid(pl.x + sgn(pl.dx)*0.3, pl.y-0.5)) do
    pl.x = pl.x + sgn(pl.dx) * 0.1
  end
  ...

y軸方向の移動処理がこちらです。内容がわかりやすい上方向への移動のみを抜粋しています。

-- y movement
if (pl.dy < 0) then
  -- going up
  if (solid(pl.x-0.2, pl.y+pl.dy-1) or
   solid(pl.x+0.2, pl.y+pl.dy-1)) then
    pl.dy=0

    -- search up for collision point
    while (not (solid(pl.x-0.2, pl.y-1) or
     solid(pl.x+0.2, pl.y-1))) do
      pl.y = pl.y - 0.01
    end
  else
    pl.y = pl.y + pl.dy
  end
  ...

必要なタイミングごとにsolid関数で指定座標に移動可能かどうか判定しています。

関数一覧

最後にJelpiで定義されている関数の一覧を参考として載せておきます。(定義順)

関数名 機能
make_actor(k, x, y, d) 座標(x,y)に種類k、向きdの新しいキャラクターを作成する
make_sparkle(x, y, frame, col) 座標(x,y)にスプライト番号frame、色colの新しいパーティクルを作成する
make_player(x, y, d) 座標(x,y)に向きdのプレイヤーを作成する
clear_cel(x, y) マップの(x,y)のセルを空にする
move_spawns(x0, y0) (x0,y0)周辺に配置されたキャラクターを出現させる
solid(x, y) マップの(x,y)が移動可能なセルか判定する
move_pickup(a) アイテムaを移動させる
move_player(pl) プレイヤーplを移動させる (ただしプレイヤー2は未使用)
move_monster(m) 敵mを移動させる
move_actor(pl) アクターplを移動させる。必要に応じてmove_pickupmove_playermove_monster関数を呼ぶ
collide_event(a1, a2) キャラクターa1とa2の衝突処理を行う
move_sparkle(sp) パーティクルspの位置を更新する
collide(a1, a2) キャラクターa1とa2の衝突判定を行いcollide_event関数を呼ぶ
collisions() すべてのキャラクターについて衝突判定を行う
outgame_logic() ゲームオーバー時の画面効果を処理する
draw_sparkle(s) パーティクルsを描画する
draw_actor(pl) キャラクターpを描画する

「ここはまだ作りかけ」みたいなコメントが結構あるので注意(?)です。

終わりに

プログラムの内容を文章で説明するのは中々難しいですが、何となく処理内容が伝わりましたでしょうか。

今回設計意図がはっきりしなかった箇所については、またJosephさんにお会いした時にでも聞いてみたいと思います。