ゲームエンジン
require("pico.game"): スプライト・タイルマップ・当たり判定・カメラ・入力・状態
pico.game は2Dゲーム向けの簡易エンジンです(OSに同梱のLuaモジュール)。Canvas を1枚作り、毎フレームの更新・描画・描き直す範囲の管理を引き受けます。アプリは「何がどこにいて、どう動くか」だけを書けば済みます。
- スプライト: 画像(スプライトシートの1コマ)か塗りの四角。速度・重力・アニメーション・左右反転・当たり判定の箱
- タイルマップ: 文字の行(
"#..P#")で地形を書ける。壁にするタイルを決めれば、スプライトが止まる・床に乗る - 当たり判定: タグ同士が重なったら呼ばれる関数(
g:collide("player", "coin", fn)) - カメラ: スプライトを追いかけ、ワールドの端で止まる
- 入力: コントローラー・画面の操作ボタン(
pad = true)・タッチをまとめてg:down("left")で読める - 状態: タイトル → プレイ → ゲームオーバーのような画面の切り替え
- 描き直し: カメラが動かない間は、動いたスプライトの周りだけを液晶へ送る
実例は「ジャンプアクション」(/lua/apps/ジャンプアクション/。横スクロールのジャンプアクション)と、物理を使う「おしてのぼれ」(/lua/apps/おしてのぼれ/。箱を押す・積む・ばね・動く足場・スイッチで扉を開けるパズルアクション)。盤面をタイルマップで作るゲームは「リバーシ」「マインスイーパー」「テトリス」「ブロック崩し」(サンプル)。
はじめの一歩
local game = require("pico.game")
local g = game.new{ bg = 11, pad = true } -- 空色の背景、画面下に操作ボタン
local img = g:image(pico.path_join(pico.app_dir(), "sheet.pimg"))
local player
g:tilemap{ image = img, tile = 16, solid = { 1 },
legend = { ["#"] = 1 }, -- "#" はタイル1(画像の0番目の絵)
spawn = { P = function(x, y) -- "P" の場所に主人公を置く
player = g:sprite{ image = img, x = x, y = y, w = 16, h = 16, frame = 4,
gravity = 900, solid = true }
g:follow(player)
end },
rows = {
"................................",
"..P.............................",
"########....##########....######",
} }
function g:on_update(dt)
player.vx = g:axis() * 100 -- 左右で歩く
if player.on_ground and g:pressed("a") then player.vy = -330 end
end
loop() を書く必要はありません(game.new が pico.every(1, ...) で毎フレーム g:step() を呼びます)。
座標: スプライトとタイルマップの x, y はワールド座標(マップの左上が0、ピクセル単位、小数でよい)。描くときにカメラの分だけずらして画面へ置きます。
game.new
| キー | 既定 | 意味 |
|---|---|---|
x y w h | pico.content_rect() 全体 | ゲームを描く範囲(画面座標)。x y を省いて w h だけ書くと content_rect の真ん中に置く(240x320向けの固定の配置を、大きい画面でも真ん中に出すとき。例: game.new{ w = 240, h = 300 }) |
bg | 0 | 背景色(パレット番号) |
pad | false | true で画面下56pxに操作ボタン(← ↑ ↓ → B A)を置く。ワールドを見せる高さ g.vh はそのぶん低くなる |
manual | false | true なら自動で回さない(自分で g:step(dt) を呼ぶ) |
gravity | なし | 全スプライトの既定の重力(px/秒²)。gravity を書いていないスプライトに効く(static と gravity = 0 のものは落ちない) |
iterations | 2 | スプライト同士の押し戻し(物理)を1フレームに何周するか |
主なフィールド: g.canvas(作ったCanvasのID)/ g.vw g.vh(ワールドを見せる幅・高さ)/ g.cam_x g.cam_y / g.time(ゲームの経過秒。一時停止中は止まる)/ g.state_name / g.touch。
| メソッド | 内容 |
|---|---|
g:step([dt]) | 1フレーム進める(dtは秒。省略すると前回からの経過時間)。1回のdtは0.05秒で頭打ち(遅いフレームで壁をすり抜けないため。極端に重いとゲームの時間がゆっくり進む) |
g:pause([true|false]) | 一時停止。止めている間も描画と入力は動く |
g:stop() | 自動で回すのをやめる |
g:destroy() | 止めてCanvasごと消す |
g:image(path) | 画像を読む(同じパスは1回だけ)。読めなければエラー |
g:clear() | スプライト・タイルマップ・ゲーム内タイマーを全部消す(当たりの規則・状態・画面ボタンは残る)。ステージを作り直すときに |
毎フレームの呼び出し順: 入力 → ゲーム内タイマー → 状態の update → g:on_update(dt) → 各スプライト(アニメーション → on_update → 加速と移動)→ スプライト同士の押し戻し → 当たりの規則 → カメラ → 描き直す範囲を決める。
スプライト
渡した表そのものがスプライトになります(好きなフィールドを足してよい)。
| キー | 既定 | 意味 |
|---|---|---|
x y | 0 | 左上(ワールド座標) |
w h | 画像の大きさ / 8 | 大きさ |
image | なし | ハンドルかパス。スプライトシートなら w h で1コマの大きさを決める |
frame | 0 | シートの何コマ目か(左上から右へ0, 1, 2…、行が終わったら次の行) |
color | なし | 画像が無いとき、この色で塗った四角を描く |
draw | なし | function(s, sx, sy)。自分で描く(画面座標を受け取る。pico.draw_* が使える)。w x h の外へはみ出させない(はみ出した画素は消去の範囲に入らず残る。fill_circle(x, y, r, c) は直径 2r+1 画素) |
flip_x flip_y | false | 左右/上下の反転 |
visible | true | 表示 |
layer | 0 | 重なり順(大きいほど手前。同じなら作った順)。後から変えるときは s:set_layer(n) |
vx vy | 0 | 速度(px/秒) |
gravity | なし | 下向きの加速度(px/秒²。900前後でジャンプアクションらしくなる) |
max_fall | なし | 落ちる速さの上限 |
ax ay | なし | 加速度(px/秒²)。重力とは別に毎フレーム速度へ足す |
max_vx | なし | 横の速さの上限 |
drag | なし | 空気抵抗(1/秒)。速度に毎フレーム 1 - drag*dt を掛ける |
friction | なし | 床の摩擦(px/秒²)。床に乗っている間、横の速さをこの割合で0へ近づける |
bounce | なし | 跳ね返り(0〜1)。壁・床・天井・ワールドの端に当たると速度を -bounce 倍にする |
bounce_min | 20 | これより遅いときは跳ねずに止まる(いつまでも細かく跳ねないため) |
mass | 1 | 重さ(g:solid の押し合いと s:impulse で使う) |
static | false | 押されない(動く足場・壁など)。自分の vx vy では動く。game.new{gravity=} の重力も受けない |
drop_through | false | true の間はすり抜け床(oneway)に乗らない(下キーで降りる等) |
solid | false | タイルマップの壁(solidのタイル)で止まる |
bounded | false | ワールドの端で止まる(下の端は床として on_ground になる) |
hitbox | 全体 | {x, y, w, h}(スプライトの左上からの当たり判定の箱。絵の余白を除く) |
tag | なし | 当たりの規則・g:find で使う名前 |
anims | なし | {名前 = {frames = {4, 5}, fps = 8, loop = true, on_done = fn(s)}} |
anim | なし | 最初に再生するアニメーションの名前 |
on_update | なし | function(s, dt)。毎フレーム、移動の前に呼ぶ |
移動の結果(そのフレームだけ有効): s.on_ground(床に乗った)/ s.hit_ceiling(天井に当たった)/ s.hit_wall(-1=左の壁、1=右の壁、0=当たっていない)。壁に当たった向きの速度は0になります。on_update の中では前のフレームの結果が読めます(敵が壁で折り返す処理など)。
| メソッド | 内容 |
|---|---|
s:remove() | 消す(そのフレームの終わりに取り除く) |
s:play(name[, restart]) | アニメーションを再生(同じものを再生中なら何もしない。restart で頭から) |
s:stop_anim() | アニメーションを止める(今のコマのまま) |
s:impulse(ix, iy) | 速度に撃力を足す(mass で割る。static は動かない) |
s:move(dx, dy) | 今すぐ動かす(solid bounded に従って止まる)。速度を使わずに動かしたいとき |
s:overlaps(other) | 当たり判定の箱が重なっているか |
s:center() | 中心のワールド座標 x, y |
s:dirty() | 描き直す(draw で描く中身だけを変えたとき) |
タイルとの当たりは、動いた向きに新しく入ったマスだけを近い順に調べるので、1フレームでタイルより大きく動いてもすり抜けません。坂・回転した当たり判定はありません(すり抜け床はタイルマップの oneway)。
壁で止まったとき、bounce があれば跳ね返り、無ければその向きの速度が0になります。
当たりの規則
タグ tag_a のスプライトとタグ tag_b のスプライトが重なっている間、毎フレーム fn(a, b) を呼びます。1回だけにしたいときは中で b:remove() するか、自分で印を付けてください。
g:collide("player", "coin", function(p, c)
c:remove()
score = score + 10
g:dirty(0, 0, 120, 18) -- 点数の表示を描き直す
end)
| メソッド | 内容 |
|---|---|
g:find([tag]) | そのタグの(省略で全部の)スプライトの配列 |
g:sprite_at(wx, wy[, tag]) | その点にいる一番手前のスプライト |
game.overlaps(a, b) | 2つの箱が重なっているか(s:overlaps と同じ) |
物理
タグ tag_a のスプライトとタグ tag_b のスプライトを重ならないように押し合わせます(g:collide は重なりを知らせるだけで、押し戻しはしません)。同じタグ同士(g:solid("box", "box"))も書けます。
- 押し戻しは
massの比で分けます。staticは押されません。押される向きが既に塞がっている(壁に当たっている・床に乗っている)ものも動かないものとして扱うので、「主人公 → 箱 → 壁」や箱の積み重ねが崩れません - 近づく向きの速度だけを撃力で交換します(
bounceの大きいほうで跳ね返る。0なら速度を分け合って一緒に動く) - 上に乗ったものは
on_groundになり、乗っている足場が動くと一緒に運ばれます(staticでvxvyを持つスプライトがそのまま動く足場になる) optが関数ならfn(a, b, nx, ny)を押し戻したときに呼びます(nxnyはbからaへの向き。ny == -1ならaが上に乗った)。触れている間は毎フレーム呼ばれますopt = {oneway = true, on_hit = fn}なら、aが上から乗るときだけ押し戻します(すり抜けられる動く足場)
local g = game.new{ pad = true, gravity = 900 }
g:solid("player", "box")
g:solid("box", "box")
g:solid("player", "lift", { oneway = true })
local p = g:sprite{ image = sheet, w = 16, h = 16, solid = true, tag = "player", friction = 1200 }
g:sprite{ x = 80, y = 0, w = 16, h = 16, color = 4, solid = true, tag = "box", mass = 2, friction = 800 }
local lift = g:sprite{ x = 160, y = 96, w = 32, h = 6, color = 13, static = true, tag = "lift", vx = 30 }
g:every(2, function() lift.vx = -lift.vx end)
軽さを優先した近似です: 当たり判定は軸に沿った箱だけ・回転しない・1フレームに iterations 周しか解かないので、たくさん積むと少し沈み込むことがあります。重なりの判定は規則ごとの総当たり(タグAの数 × タグBの数)なので、押し合うスプライトは数十体までにしてください。
タイルマップ
マスの値は1バイト(0〜255)。0は空、1以上は画像の (値-1) 番目の絵です(画像は tile_w x tile_h の絵を左上から右へ並べたもの)。
| キー | 既定 | 意味 |
|---|---|---|
image | なし | タイルの画像(ハンドルかパス)。スプライトと同じシートでよい |
tile / tile_w tile_h | 8 | 1マスの大きさ |
x y | 0 | マップの左上(ワールド座標) |
rows + legend | rows = {"#..#", ...}、legend = {["#"] = 1}(書いていない文字は0)。行の長さが違ってもよい(短い行の右は空) | |
data + cols | 1バイト1マスの文字列("\1\0\1")か、数の配列({1, 0, 1})。行優先 | |
spawn | なし | {P = function(x, y) ... end}。rows のその文字の位置(ワールド座標)で呼び、マスは空にする。主人公や敵を置くのに |
solid | なし | 壁にするタイル。true(0以外全部)/ {1, 2, 3} / function(v) return ... end |
oneway | なし | すり抜け床にするタイル(書き方は solid と同じ)。上から落ちてきたときだけ乗れ、下や横からは通り抜ける。スプライトの drop_through = true で降りる |
| メソッド | 内容 |
|---|---|
m:get(c, r) / m:set(c, r, v) | マス目(0始まり)の値を読む/書く(範囲外は0・何もしない)。set は書き換えたマスだけ描き直す |
m:cell(wx, wy) | ワールド座標のマス目 c, r |
m:at(wx, wy) | ワールド座標のマスの値と c, r |
m:is_solid(c, r) | 壁か |
m:is_oneway(c, r) | すり抜け床か |
m:find(v) | 値が v のマス目を全部 { {c, r}, ... } |
フィールド: m.cols m.rows m.tw m.th m.data(文字列)、m.visible = false で描かない。タイルマップはいくつでも重ねられます(作った順に描く。壁の判定は solid を持つもの全部)。
カメラとワールド
| メソッド | 内容 |
|---|---|
g:follow(s) | スプライトを画面の中央に保つ(nil でやめる) |
g:camera(x, y) | カメラの左上を置く(ワールドの端で止まる) |
g:world_size() | ワールドの幅・高さ(既定はタイルマップ全部が入る大きさ。ビューより小さければビューの大きさ) |
g:set_world(w, h) | ワールドの大きさを決める |
g:to_screen(wx, wy) | ワールド座標 → 画面座標 |
入力
| メソッド | 内容 |
|---|---|
g:down(name) | 押しているか。name はコントローラーのボタン名("left" "a" "start" …)か、g:button の名前 |
g:pressed(name) / g:released(name) | そのフレームに押した/離したか |
g:axis() | 左右・上下を -1/0/1 の2つで(local h, v = g:axis()) |
g:button{name=, x=, y=, w=, h=, label=} | 画面ボタンを足す(キャンバスの中の座標)。label が "left" "right" "up" "down" なら矢印、それ以外は文字。visible = false にすると描かず、タッチも受けない(画面ごとに見せるボタンを入れ替えるのに。変えたら g:dirty か g:go で描き直す)。draw = function(b, x, y, w, h, on) を渡すと自分で描く(x y は画面座標、on は押している間 true) |
コントローラーと画面ボタンはどちらで押しても同じに読めます。画面ボタンは押したまま隣へ滑らせると切り替わります。タッチパネルは1点しか取れないので、画面ボタンでは「歩きながらジャンプ」のような同時押しはできません(コントローラーならできる)。
g.touch: ボタン以外の場所のタッチ。down pressed released、x y(キャンバスの中の座標)、wx wy(ワールド座標)。1フレームより短いタップも取りこぼしません。
状態
g:go は今の状態の exit(g) → 新しい状態の enter(g, ...) を呼び、画面全体を描き直します。update(g, dt) は毎フレーム、draw(g, ox, oy) は描くときに(ox, oy はキャンバスの左上の画面座標)呼ばれます。g.state_name が今の状態の名前です。
g:state("title", {
update = function() if g:pressed("a") or g.touch.pressed then g:go("play") end end,
draw = function(_, ox, oy) pico.draw_text(ox + 40, oy + 100, "Aで始める", 15) end,
})
g:go("title")
ゲーム内タイマー
| メソッド | 内容 |
|---|---|
g:after(sec, fn) | sec 秒後に fn(g) を1回 |
g:every(sec, fn) | sec 秒ごとに fn(g) |
g:cancel(t) | 止める |
ゲームの時間(g.time)で進むので、一時停止中は止まります。g:clear() で全部消えます。数は無制限(pico.after の16個の枠を使いません)。
描画
ふだんは何もしなくてよく、エンジンが「タイルマップ → g:on_draw_world(ox, oy) → スプライト(layer 順)→ 状態の draw → g:on_draw(ox, oy) → 画面ボタン」の順に描きます。
| 関数 | 内容 |
|---|---|
function g:on_draw(ox, oy) | スプライトより手前に描く(点数などの表示)。ox, oy はキャンバスの左上の画面座標 |
function g:on_draw_world(ox, oy) | タイルマップとスプライトの間に描く。ox, oy はワールドの原点の画面座標(ox + wx へ描けばカメラに付いて動く) |
g:dirty(x, y, w, h) | キャンバスの中の矩形を描き直す |
g:redraw() | 全体(画面ボタンの帯も)を描き直す |
描き直す範囲: カメラが動いた・状態が変わった・スプライトの重なり順が変わったときはワールドの見える範囲(画面ボタンの帯は除く)を、それ以外は動いた(コマや反転が変わった)スプライトの前後の位置だけを描き直します(液晶へ送る量が減って速い)。on_draw で描く中身(点数など)を変えたら、その範囲を g:dirty で知らせてください(知らせないと前の絵が残ります)。
pico.draw_tilemap
pico.game が使っている、タイルマップを描く関数です(エンジンを使わずに直接描くこともできます)。data は1バイト1マスの文字列(行優先、cols 列で折り返す)、0は空、1以上は画像の (値-1) 番目のタイル。(x, y) はマップの左上の画面座標(画面の外でもよい)。今の描き直す範囲にかかるマスだけを描くので、大きいマップでも全部を回しません。Canvas の render コールバックの中で使います。タイルの大きさは1〜128、列数は1〜4096。
制限と注意
- メモリ:
require("pico.game")だけでLuaのメモリを約55KB使います。アプリの上限(200KB)の残りで画像・スプライトを持ちます。スプライト1体は数百バイトです。 - 画像は
pico.image_loadと同じ枠(8枚・合計96KiB)。スプライトとタイルは1枚のシートにまとめるのが得です。 - 左右反転・上下反転したスプライトは1画素ずつ描くので、反転しないものより遅くなります(16x16程度なら気にならない)。
- エンジンの中で起きたエラーは行番号が「?」になります(同梱モジュールはデバッグ情報を落としてメモリを節約しているため)。引数の誤りはアプリの行を指します。
- 重い処理(同じタグのスプライトが数十体いる当たりの規則など)は、毎フレームのLuaの命令数を食います。