先日、PICO-8の作者のJoseph Whiteさんとお話する機会がありました。結構な時間おしゃべりさせていただいて、今まで気になっていたことを色々お聞きすることができました。(例えば、バージョン1.0が出るのは○月頃とか隠し仕様の話とか…)
と、その話はいずれまたどこかで触れるとして、今日はPICO-8の横スクロールゲームサンプルであるJelpiの解説をしてみたいと思います。
PICO-8サンプル"Jelpi"のプログラム解説
JelpiはPICO-8に付属するマリオ風の横スクロールゲームのサンプルです。
PICO-8について知らないという方は以下のリンクをご覧ください。
PICO-8のコンソールモードで以下のコマンドを入力するとJelpiのコードの取得とプレイができます。
install_demos cd demos load jelpi.p8 run
わずか719行のLuaコードで、この手のプラットフォーム型ゲームに必要な一通りの要素を押さえている、なかなか秀逸なサンプルです。
ただ、まだ作りかけらしく、バージョン0.1.8の現在では使っていないコードやこなれていない処理などがあり、読み解きに若干の注意が必要です。
実際にコードを読んで気づいたポイントを解説していきます。
キャラクターの座標系
キャラクターの座標はスクリーン座標(1ピクセル単位)ではなく、マップのセル(8x8ピクセルが1セル)と同じセル座標で管理されています。
セル座標系だと1ピクセル=0.125セルとなるため、1ピクセル単位の移動には小数を使うことになりますが、こうすることでプレイヤーの座標とマップの衝突判定を毎回8で割らなくても可能にしています。
キャラクターの描画位置
さらに、実際のキャラクターは座標(x, y)に対して(x*8ー4, y*8ー8)の位置に描画されます。
つまり各キャラクターの原点は下の図のようにスプライトの左上から数えて、右に4ピクセル、下に8ピクセルの位置になります。明確な理由は定かではありませんが、床との接触判定をしやすくするためではないかと思われます。
キャラクターの移動
キャラクターの移動は座標を直接変更するのではなく、速度(dx, dy)を変更することで行っています。
こうすることでキャラクターの跳ね返りやジャンプ時の振る舞いなどを自然な動作にするのと共に、簡単に実現できるようにしています。例えばダッシュ中にブロックに衝突した時に、少しだけ跳ね返る処理は以下のように記述されています。
-- bounce if (pl.super == 0 or not broke_block) then pl.dx = pl.dx * -0.5 -- 半分の速度で跳ね返る end
マップのスクロール
なんとマップは常に全範囲を描画しています。スクリーン座標の(0,0)の位置をcamera
命令でずらした後に全マップを描画することで、実際に画面内に表示される範囲のコントロールをしています。
なかなか大胆ですが、限定された範囲のマップであれば確かにこの方が実装が簡単です。
マップ描画後、引数なしのcamera()
命令を呼んで描画位置を元に戻しています。
キャラクターの出現位置
各キャラクター(プレイヤー、アイテム、敵)の出現位置は、すべてコードではなくマップ上のセルで指定されており、move_spawns
関数内で画面位置に応じた出現キャラクターの決定と、複数回出現することを防ぐためにマップの該当セルを空白に書き換える処理を行っています。
こうすることでゲームのレベルデザインがマップエディタのみで完結するようになり、試行錯誤がしやすくなっています。
キャラクターのアニメーション管理方法
プレイヤーや敵のループアニメーションはスプライトシート上で連番で並んでいます。
これは普通といえば普通ですが、以下のコードのように、使用するスプライト番号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_pickup 、move_player 、move_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さんにお会いした時にでも聞いてみたいと思います。