--:-- LUA API

ゲームエンジン

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

game.new(opts?: table) -> g: Game
キー既定意味
x y w hpico.content_rect() 全体ゲームを描く範囲(画面座標)。x y を省いて w h だけ書くと content_rect の真ん中に置く(240x320向けの固定の配置を、大きい画面でも真ん中に出すとき。例: game.new{ w = 240, h = 300 })
bg0背景色(パレット番号)
padfalsetrue で画面下56pxに操作ボタン(← ↑ ↓ → B A)を置く。ワールドを見せる高さ g.vh はそのぶん低くなる
manualfalsetrue なら自動で回さない(自分で g:step(dt) を呼ぶ)
gravityなし全スプライトの既定の重力(px/秒²)。gravity を書いていないスプライトに効く(static と gravity = 0 のものは落ちない)
iterations2スプライト同士の押し戻し(物理)を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 → 加速と移動)→ スプライト同士の押し戻し → 当たりの規則 → カメラ → 描き直す範囲を決める。

スプライト

g:sprite(opts: table) -> s: Sprite

渡した表そのものがスプライトになります(好きなフィールドを足してよい)。

キー既定意味
x y0左上(ワールド座標)
w h画像の大きさ / 8大きさ
imageなしハンドルかパス。スプライトシートなら w h で1コマの大きさを決める
frame0シートの何コマ目か(左上から右へ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_yfalse左右/上下の反転
visibletrue表示
layer0重なり順(大きいほど手前。同じなら作った順)。後から変えるときは s:set_layer(n)
vx vy0速度(px/秒)
gravityなし下向きの加速度(px/秒²。900前後でジャンプアクションらしくなる)
max_fallなし落ちる速さの上限
ax ayなし加速度(px/秒²)。重力とは別に毎フレーム速度へ足す
max_vxなし横の速さの上限
dragなし空気抵抗(1/秒)。速度に毎フレーム 1 - drag*dt を掛ける
frictionなし床の摩擦(px/秒²)。床に乗っている間、横の速さをこの割合で0へ近づける
bounceなし跳ね返り(0〜1)。壁・床・天井・ワールドの端に当たると速度を -bounce 倍にする
bounce_min20これより遅いときは跳ねずに止まる(いつまでも細かく跳ねないため)
mass1重さ(g:solid の押し合いと s:impulse で使う)
staticfalse押されない(動く足場・壁など)。自分の vx vy では動く。game.new{gravity=} の重力も受けない
drop_throughfalsetrue の間はすり抜け床(oneway)に乗らない(下キーで降りる等)
solidfalseタイルマップの壁(solidのタイル)で止まる
boundedfalseワールドの端で止まる(下の端は床として 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になります。

当たりの規則

g:collide(tag_a: string, tag_b: string, fn: function(a, b))

タグ 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 と同じ)

物理

g:solid(tag_a: string, tag_b: string, opt?: function | table)

タグ tag_a のスプライトとタグ tag_b のスプライトを重ならないように押し合わせます(g:collide は重なりを知らせるだけで、押し戻しはしません)。同じタグ同士(g:solid("box", "box"))も書けます。

  • 押し戻しは mass の比で分けます。static は押されません。押される向きが既に塞がっている(壁に当たっている・床に乗っている)ものも動かないものとして扱うので、「主人公 → 箱 → 壁」や箱の積み重ねが崩れません
  • 近づく向きの速度だけを撃力で交換します(bounce の大きいほうで跳ね返る。0なら速度を分け合って一緒に動く)
  • 上に乗ったものは on_ground になり、乗っている足場が動くと一緒に運ばれます(static で vx vy を持つスプライトがそのまま動く足場になる)
  • opt が関数なら fn(a, b, nx, ny) を押し戻したときに呼びます(nx ny は 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の数)なので、押し合うスプライトは数十体までにしてください。

タイルマップ

g:tilemap(opts: table) -> m: Tilemap

マスの値は1バイト(0〜255)。0は空、1以上は画像の (値-1) 番目の絵です(画像は tile_w x tile_h の絵を左上から右へ並べたもの)。

キー既定意味
imageなしタイルの画像(ハンドルかパス)。スプライトと同じシートでよい
tile / tile_w tile_h81マスの大きさ
x y0マップの左上(ワールド座標)
rows + legendrows = {"#..#", ...}、legend = {["#"] = 1}(書いていない文字は0)。行の長さが違ってもよい(短い行の右は空)
data + cols1バイト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:state(name: string, t: {enter=, update=, draw=, exit=})
g:go(name: string, ...)

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.draw_tilemap(handle: integer, tile_w: integer, tile_h: integer, data: string, cols: integer, x: integer, y: integer) -> (なし)

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の命令数を食います。