Playdateでスプライトシートによるアニメーションの描画及び衝突検知の実装例

SDKのドキュメントをしっかり読むと記事タイトルにあるような機能はしっかり用意されているのでその使い方を記録した記事です。記事にしておかないと忘れてしまいます…

ちなみに、気づくまで自力で実装しようとして頑張っていたのは内緒です。

目次

事前準備

開発環境はLinuxmacOSを想定します。Windowsでもほぼ同じです。プロジェクトでコンパイルを便利にするためのBashファイルはWindowsでは使用できないのでそこは適宜読み替えてください。

サンプルに使用する素材は以下からダウンロードします。

ライセンスはCC0なので安心して使用できます。ダウンロード時に寄付を求められますが寄付せずとも落とせます。

ダウンロードした素材をドット絵エディタ等で以下のように加工します。

プレイヤー待ち状態

プレイヤー歩き状態

ハート待ち状態

※ 背景の#000000の箇所は透明にしてください。はてなの仕様で透明箇所が正しく表示されないのでこの記事では背景を#000000にしています。

プロジェクトを以下に示すディレクトリ構成にします。

root
├── run.sh
└── source
    ├── images
    │   └── spritesheets
    │       ├── hart
    │       │   └── idle-table-16-16.png
    │       ├── player
    │       │   ├── idle-table-16-16.png
    │       │   └── walk-table-16-16.png
    │       └── tilemap-table-16-16.png
    └── main.lua
       

加工した画像はsource/images/spritesheet/<任意のディレクトリ名>/に保存します。

画像のファイル名は必ず<任意のプリフィックス>-table-<1セルの幅>-<1セルの高さ>.pngの形式に従って命名する必要があります。

  • Image table
    • ドキュメントでは連番による命名方法もありますがこの記事では上記形式で進めます

また加工元になったスプライトシート画像はタイルマップとしてsource/images/spritesheet/の箇所にtilemap-table-16-16.pngのファイル名で保存します。

※ この記事ではタイルマップの解説までは行いません。別記事を作成予定です。

run.shの中身は以下をコピペします。

currentDirectory=`pwd | awk -F "/" '{ print $NF }'`
rm -rf "$currentDirectory.pdx"
pdc -k source "$currentDirectory.pdx"
PlaydateSimulator "$currentDirectory.pdx"

これはエミュレーターでプロジェクトを実行する際にコマンドを複数回打つのが面倒くさいのでまとめたスクリプトになります。環境ごとに適宜読み替えてください。

以上の構成をベースにして解説を行います。

スプライトシートでキャラクターをアニメーションさせる

一番簡単な例を示します。ドキュメントの以下のリンクにもほぼ同じ例が提示されています。

if not import then import = require end

import "CoreLibs/graphics"
import "CoreLibs/animation"

local function clearDisplay()
    playdate.graphics.clear()
    playdate.graphics.setBackgroundColor(playdate.graphics.kColorBlack)
end

local frameTime = 200
local playerTable = playdate.graphics.imagetable.new("images/spritesheets/player/idle")
local playerLoopIdle = playdate.graphics.animation.loop.new(frameTime, playerTable, true)

local cell = playerTable:getImage(1)
local _, height = cell:getSize()

local playerX = 10
local playerY = playdate.display.getHeight() - height

--
-- コールバック
--
-- ゲームループ
function playdate.update()
    clearDisplay()
    playerLoopIdle:draw(playerX, playerY)
end

main.luaに上記コードをコピペして、プロジェクトのルートディレクトリにて./run.shを実行してください。エミュレーターが起動して以下のGIFのように動作するはずです。

アニメーション1

基本的にはこれでスプライトシートによるアニメーションを実現できます。:draw()メソッドに与える座標値で移動も可能です。とはいえ、この方式はタイトル画面だったり見た目だけアニメーションで表示したい画像に有効でしょう。

いざ、ゲームとして組み込むとなると色々足りないことに気づきます。

  • 状態遷移を行いたい
    • 例えば待機のアニメーションから歩行のアニメーションへの切り替え
  • 壁やアイテムとぶつかった時の挙動
    • いわゆる衝突判定
  • 画像が重なった時に背面や前面を考慮したい
    • 奥行きの概念

こういった事例に対応する方法としては考えられるのはまずは自前での実装です。画面に画像を描画する機能さえあれば大抵のゲームは開発可能です。アニメーションまでは表示できているのだからこの状態に肉付けすれば良いと考えられます。

とはいえさすがに自前での実装は辛いとなるのが世の常です。ということでSDKにはこれらを実現するための機能が提供されています。

その機能を使用するためにはPlaydateにおけるImageオブジェクトとSpriteオブジェクトの違いを知る必要があります。以下にまとめました。

ImageとSpriteの違い

  • Image

    • :draw()メソッドの呼び出しによる描画
    • 呼び出した時点での最前面に描画される
    • SDK標準の衝突判定の設定ができない
  • Sprite

    • Imageから生成する
    • :add()した時点で描画される
    • z-indexを設定できる
      • 奥行きのこと
        • 数値で画像の重ね順を制御できる
    • :remove()で描画を消せる
    • SDK標準の衝突判定の設定できる

アニメーションに使用したImageTableオブジェクトはImageオブジェクトの派生です。そのためSpriteオブジェクトにある機能は持っていません。

Spriteオブジェクトの最大のポイントはz-indexと衝突判定を設定できる点です。まさに欲しい機能を自前で実装する必要がなくなるのはかなり大きいためゲームで使用する画像は極力Spriteオブジェクトで描画したいです。

ということで、スプライトシートをどのように読み込みコーディングすればSpriteオブジェクトとしてアニメーションさせることができるのか?その実例を紹介します。

スプライトシートとSpriteを使用してアニメーションさせる

まずはスプライトシートでキャラクターをアニメーションさせるの状態をSpriteオブジェクトで再現するまでのコーディング例を示します。

ディレクトリ構成は以下のようになります。

root
├── run.sh
└── source
    ├── actors
    │   ├── actor.lua
    │   └── player.lua
    ├── images
    │   └── spritesheets
    │       ├── hart
    │       │   └── idle-table-16-16.png
    │       ├── player
    │       │   ├── idle-table-16-16.png
    │       │   └── walk-table-16-16.png
    │       └── tilemap-table-16-16.png
    └── main.lua

sourceディレクトリ配下にactorsディレクトリが作成され、actor.luaplayer.luaが配置されています。なおこのディレクトリ名やファイル名、このあと記述するクラス名等は単なる筆者の好みなので適宜読み替えてください。

actor.luaは以下の通りです。

---@diagnostic disable: undefined-global
class('Actor').extends()
function Actor:init(_stageRect, _zindex)
    self.zindex    = _zindex
    self.stageRect = _stageRect
    self.currentImagetable = nil
    self.currentLoop = nil
    self.currentSprite = nil
    self.state = 0
    self.idlePath = "images/spritesheets/" .. self.className:lower() .. "/idle"
end

function Actor:commonSpriteSettings()
    self.currentSprite:setCenter(0, 0)
    self.currentSprite:setZIndex(self.zindex)
end

続いてplayer.luaは以下の通りです。

import "actors/actor"
---@diagnostic disable: undefined-global
class('Player').extends(Actor)
function Player:init(_stageRect, _zindex)
    Player.super.init(self, _stageRect, _zindex)
    self.states = {
        idle = 1;
        walk = 2;
    }

    self.directions = {
        right = 1;
        left  = 2;
    }

    self.direction = self.directions.right

    self.idleFrametime = 200
    self.walkFrametime = 200
    self.walkPath = "images/spritesheets/" .. self.className:lower() .. "/walk"

    self.x = 10
    self.y = 0
end

function Player:turnLeft()
    self.direction = self.directions.left
end

function Player:turnRight()
    self.direction = self.directions.right
end

function Player:move(_x, _distanceMovedY)
    self.x = _x or self.x

    local _, spriteHeight = self.currentSprite:getSize()
    if _distanceMovedY then
        self.y = self.stageRect.height - spriteHeight - _distanceMovedY
    else
        self.y = self.stageRect.height - spriteHeight
    end

    self.currentSprite:moveTo(self.x, self.y)
end

function Player:idle()
    if not (self.state == self.states.idle) then

        if self.currentSprite then
            self.currentSprite:remove()
        end

        self.currentImagetable = playdate.graphics.imagetable.new(self.idlePath)
        self.currentLoop = playdate.graphics.animation.loop.new(self.idleFrametime, self.currentImagetable, true)
        self.currentSprite = playdate.graphics.sprite.new(self.currentLoop:image())

        self:commonSpriteSettings()
    end

    self.currentSprite.update = function()
        self.currentSprite:setImage(self.currentLoop:image())
        if not self.currentLoop:isValid() then
            self.currentSprite:remove()
        end
    end

    -- 描画リストに追加
    self.currentSprite:add()

    self.state = self.states.idle
end

function Player:walk()
    if not (self.state == self.states.walk) then

        if self.currentSprite then
            self.currentSprite:remove()
        end

        self.currentImagetable = playdate.graphics.imagetable.new(self.walkPath)
        self.currentLoop = playdate.graphics.animation.loop.new(self.walkFrametime, self.currentImagetable, true)
        self.currentSprite = playdate.graphics.sprite.new(self.currentLoop:image())
        self:commonSpriteSettings()
    end

    self.currentSprite.update = function()
        self.currentSprite:setImage(self.currentLoop:image())
        if self.direction == self.directions.left then
            self.currentSprite:setImageFlip("flipX")
        end
        if not self.currentLoop:isValid() then
            self.currentSprite:remove()
        end
    end

    -- 描画リストに追加
    self.currentSprite:add()

    self.state = self.states.walk
end

そしてmain.luaは以下の通りに書き換わります。

if not import then import = require end

import "CoreLibs/graphics"
import "CoreLibs/object"
import "CoreLibs/sprites"
import "CoreLibs/animation"

import "actors/player"

local function clearDisplay()
    playdate.graphics.clear()
    playdate.graphics.setBackgroundColor(playdate.graphics.kColorBlack)
end

local stageRect = playdate.geometry.rect.new(0, 0, playdate.display.getWidth(), playdate.display.getHeight())
local player = Player(stageRect, 1)
player:idle()
player:move(10)

--
-- コールバック
--
-- ゲームループ
function playdate.update()
    clearDisplay()
    -- https://sdk.play.date/2.1.1/Inside%20Playdate.html#f-graphics.sprite.update
    -- SpriteのCallbackを呼び出すために必要
    playdate.graphics.sprite.update()
end

同じことをするだけなはずなのに一気にコーディング量が増えました。状態遷移も実現するためにオブジェクト指向で組んでいるためです。

とりあえずコピペをして頂き、./run.shを打ち込めばスプライトシートでキャラクターをアニメーションさせると同じ状態が表示されるはずです。

Spriteオブジェクトを使用してアニメーションさせようとするだけで大分傾向が変わりました。解説を行います。

まずActorクラスはゲーム内に配置するプレイヤー及びキャラクターや物体等のギミック的な何かの共通処理を担う基底クラスとして定義しています。状態遷移やアニメーション、衝突検知に必要な挙動はある程度共通化できるためです。行っていることは初期化時に必要なメンバーを用意しているのと、共通処理のメソッドが1つあるのみです。

続いてPlayerクラスの解説です。

function Player:init(_stageRect, _zindex)
    self.states = {
        idle = 1;
        walk = 2;
    }

    self.directions = {
        right = 1;
        left  = 2;
    }

    self.direction = self.directions.right

    self.idleFrametime = 200
    self.walkFrametime = 200
    self.walkPath = "images/spritesheets/" .. self.className:lower() .. "/walk"

    self.x = 10
    self.y = 0
end

コードの抜粋です。ここのポイントはActorクラスを継承しています。そしてPlayerクラスのみで使用するメンバーを定義しています。

function Player:idle()
-- ~ 略 ~
end

アニメーションのポイントは上記メソッドになります。

    if not (self.state == self.states.idle) then

        if self.currentSprite then
            self.currentSprite:remove()
        end

        self.currentImagetable = playdate.graphics.imagetable.new(self.idlePath)
        self.currentLoop = playdate.graphics.animation.loop.new(self.idleFrametime, self.currentImagetable, true)
        self.currentSprite = playdate.graphics.sprite.new(self.currentLoop:image())

        self:commonSpriteSettings()
    end

Player:idle()メソッドからの抜粋です。

self.stateidleでなければ、self.currentImagetable = playdate.graphics.imagetable.new(self.idlePath)ImageTableを生成して、self.currentLoop = playdate.graphics.animation.loop.new(self.idleFrametime, self.currentImagetable, true)からアニメーションを生成するところまではスプライトシートでキャラクターをアニメーションさせると同じです。

self.currentSprite = playdate.graphics.sprite.new(self.currentLoop:image())ここでアニメーションループの:image()メソッドを使いSpriteオブジェクトを生成しています。

Spriteオブジェクトは前述の通り、生成するだけでは描画されません。:add()する必要があります。

    -- 描画リストに追加
    self.currentSprite:add()

このコード箇所にて追加していることが分かります。アニメーションの必要がないのであればこれだけで画面上に描画されます。

    self.currentSprite.update = function()
        self.currentSprite:setImage(self.currentLoop:image())
        if self.direction == self.directions.left then
            self.currentSprite:setImageFlip("flipX")
        end

        if not self.currentLoop:isValid() then
            self.currentSprite:remove()
        end
    end

Spriteオブジェクトをアニメーションさせる肝はこの箇所です。Spriteクラスはupdateというコールバックを持っています。この箇所でそのコールバック関数をオーバーライドしています。

そして、self.currentSprite:setImage(self.currentLoop:image())にてコールバックが呼ばれる度にself.currentSpriteSpriteオブジェクトの画像を張り替えています。これによりSpriteオブジェクトでもアニメーションが行われます。

local stageRect = playdate.geometry.rect.new(0, 0, playdate.display.getWidth(), playdate.display.getHeight())
local player = Player(stageRect, 1)
player:idle()
player:move(10)

main.luaからの抜粋です。Playerを生成して:idle()で初期状態を決め:move(10)で配置をしています。

--
-- コールバック
--
-- ゲームループ
function playdate.update()
    clearDisplay()
    -- https://sdk.play.date/2.1.1/Inside%20Playdate.html#f-graphics.sprite.update
    -- SpriteのCallbackを呼び出すために必要
    playdate.graphics.sprite.update()
end

重要なのはplaydate.graphics.sprite.update()です。playdate.update()でこれを呼び出さなければSpriteオブジェクトのupdateは呼び出されないため記述を忘れないようにしましょう。

アニメーションを状態遷移を伴いながら左右へ移動させる

すでに状態遷移(idlewalk)への切り替えを行うために必要なコードは定義しています。main.luaを以下のように書き換えてください。

if not import then import = require end

import "CoreLibs/graphics"
import "CoreLibs/object"
import "CoreLibs/sprites"
import "CoreLibs/animation"

import "actors/player"

local function clearDisplay()
    playdate.graphics.clear()
    playdate.graphics.setBackgroundColor(playdate.graphics.kColorBlack)
end

local stageRect = playdate.geometry.rect.new(0, 0, playdate.display.getWidth(), playdate.display.getHeight())
local player = Player(stageRect, 1)
player:idle()
player:move(10)

local gravityPower = 5
local distanceMovedMax = 45
local distanceMovedY = 0
local jumpFlag = false
local dropping = false

local function jump()
    if not jumpFlag then
        jumpFlag = true
    end

    if jumpFlag then
        if not dropping then
            distanceMovedY = distanceMovedY + gravityPower
        else
            distanceMovedY = distanceMovedY - gravityPower
            if distanceMovedY < 0 then
                dropping = false
            end
        end

        if distanceMovedY > distanceMovedMax then
            if not dropping then
                dropping = true
            end
        end
    end
end

local function down()
    distanceMovedY = distanceMovedY - gravityPower
    if distanceMovedY < 0 then
        distanceMovedY = 0
    end
end

--
-- コールバック
--
-- ゲームループ
function playdate.update()
    clearDisplay()

    if playdate.buttonIsPressed(playdate.kButtonRight) and playdate.buttonIsPressed(playdate.kButtonA) then
        player.x = player.x + 5
        player:turnRight()
        player:walk()
        jump()
    elseif playdate.buttonIsPressed(playdate.kButtonLeft) and playdate.buttonIsPressed(playdate.kButtonA) then
        player.x = player.x - 5
        player:turnLeft()
        player:walk()
        jump()
    elseif playdate.buttonIsPressed(playdate.kButtonRight) then
        player.x = player.x + 5
        player:turnRight()
        player:walk()
        down()
    elseif playdate.buttonIsPressed(playdate.kButtonLeft) then
        player.x = player.x - 5
        player:turnLeft()
        player:walk()
        down()
    elseif playdate.buttonIsPressed(playdate.kButtonA) then
        jump()
    else
        player:idle()
        down()
        jumpFlag = false
    end

    player:move(player.x, distanceMovedY)

    -- https://sdk.play.date/2.1.1/Inside%20Playdate.html#f-graphics.sprite.update
    -- SpriteのCallbackを呼び出すために必要
    playdate.graphics.sprite.update()
end

./run.shを実行します。

プレイヤーの移動とジャンプ

16x16の小さいドット絵なせいもありとても見辛いですが歩いているときは歩く(work)モーションになり止まっているときは待機(idle)のモーションに遷移しています。

ジャンプも単純ではありますがロジックを入れてみました。

状態を切り変えた際にSpriteオブジェクトに貼るImageTableオブジェクトを貼り変えることで見た目で状態の変化をつけることができます。この例ではジャンプの時はその時点での状態のままですが、変更したければPlayerクラスにもう1つジャンプ用のメソッドを追加してself.currentSpriteにジャンプのImageTableオブジェクトを貼ることで実現できると思います。

衝突を検知する

次は衝突検知を入れます。ここからようやくSpriteオブジェクトの恩恵を受けることができます。まずはHartクラスを作成します。source/actorsディレクトリ配下にhart.luaを作成して以下の内容をコピペして下さい。

import "actors/actor"
---@diagnostic disable: undefined-global
class('Hart').extends(Actor)
function Hart:init(_stageRect, _zindex)
    Hart.super.init(self, _stageRect, _zindex)
    self.states = {
        idle = 1;
    }

    self.idleFrametime = 200

    self.x = 0
    self.y = 0
end

function Hart:move(_x, _distanceMovedY)
    self.x = _x or self.x

    local _, spriteHeight = self.currentSprite:getSize()
    if _distanceMovedY then
        self.y = self.stageRect.height - spriteHeight - _distanceMovedY
    else
        self.y = self.stageRect.height - spriteHeight
    end

    self.currentSprite:moveTo(self.x, self.y)
end

function Hart:idle()
    if not (self.state == self.states.idle) then

        if self.currentSprite then
            self.currentSprite:remove()
        end

        self.currentImagetable = playdate.graphics.imagetable.new(self.idlePath)
        self.currentLoop = playdate.graphics.animation.loop.new(self.idleFrametime, self.currentImagetable, true)
        self.currentSprite = playdate.graphics.sprite.new(self.currentLoop:image())

        self:commonSpriteSettings()
    end

    self.currentSprite.update = function()
        self.currentSprite:setImage(self.currentLoop:image())
        if not self.currentLoop:isValid() then
            self.currentSprite:remove()
        end
    end

    -- 描画リストに追加
    self.currentSprite:add()

    self.state = self.states.idle
end

ハートの状態は待機(idle)のみです。

main.luaに以下を追記してください。

import "actors/hart"
local hart = Hart(stageRect, 1)
hart:idle()
hart:move(100)

./run.shを実行すると以下のように表示されるはずです。

ハートの表示

ハートも表示できました。まだ衝突を検知する仕組みが入っていないため素通りです。

さて!ここでSDKに用意されたSpriteオブジェクトの機能を使います。

alphaCollision

actor.lua:commonSpriteSettings()メソッドを以下のように書き換えてください。コメント部分は消して大丈夫です。

function Actor:commonSpriteSettings()
    self.currentSprite:setCenter(0, 0)
    self.currentSprite:setZIndex(self.zindex)
    -- https://sdk.play.date/2.1.1/Inside%20Playdate.html#m-graphics.sprite.setCollideRect
    self.currentSprite:setCollideRect(0, 0, self.currentSprite:getSize())
end

追加されたのはself.currentSprite:setCollideRect(0, 0, self.currentSprite:getSize())です。

このメソッドはSpriteオブジェクトに衝突判定を行う矩形領域を設定します。この引数だとself.currentSpriteと同じ位置、サイズの矩形領域を設定しています。

hart.luaに以下のメソッドを追加します。

function Hart:collideWithPlayer(_player)
    -- https://sdk.play.date/2.1.1/Inside%20Playdate.html#m-graphics.sprite.alphaCollision
    if _player.currentSprite:alphaCollision(self.currentSprite) then
        self.currentSprite:remove()
    end
end

:alphaCollision()メソッドを使い衝突判定を行っています。Playerオブジェクトに設定されているSpriteオブジェクトとHartオブジェクトに設定されているSpriteオブジェクトがピクセルレベルで衝突したらtrueを返却します。ifブロック内ではtrueだった場合にHartオブジェクトに設定されたスプライトを消しています。プレイヤーとハートが接触したらハートが消える挙動となっています。

ハートと接触

素晴らしいですね。:setCollideRect()メソッドで接触領域を設定さえすれば:alphaCollision()メソッドによりSpriteオブジェクト同士の接触を真理値で取得することができます。

moveWithCollisions

ここまでの例ではプレイヤーがハートを通り抜けることができます。ですが、もう1つ欲しい衝突があります。それは壁や特定の物体を通り抜けられないようにする衝突です。その方法もSDKに機能が用意されています。説明のために以下の画像を用意します。

ブロック

以下のように追加します。

.
├── run.sh
└── source
    ├── actors
    │   ├── actor.lua
    │   ├── block.lua
    │   ├── hart.lua
    │   └── player.lua
    ├── images
    │   └── spritesheets
    │       ├── block
    │       │   └── idle-table-16-16.png
    │       ├── hart
    │       │   └── idle-table-16-16.png
    │       ├── player
    │       │   ├── idle-table-16-16.png
    │       │   └── walk-table-16-16.png
    │       └── tilemap-table-16-16.png
    └── main.lua

source/images/spritesheets/block/idle-table-16-16.pngsource/actors/block.luaが追加されました。

block.luaのコードは以下の通りです。

import "actors/actor"
---@diagnostic disable: undefined-global
class('Block').extends(Actor)
function Block:init(_stageRect, _zindex)
    Block.super.init(self, _stageRect, _zindex)
    self.states = {
        idle = 1;
    }

    self.idleFrametime = 200

    self.x = 0
    self.y = 0
end

function Block:move(_x, _distanceMovedY)
    self.x = _x or self.x

    local _, spriteHeight = self.currentSprite:getSize()
    if _distanceMovedY then
        self.y = self.stageRect.height - spriteHeight - _distanceMovedY
    else
        self.y = self.stageRect.height - spriteHeight
    end

    self.currentSprite:moveTo(self.x, self.y)
end

function Block:idle()
    if not (self.state == self.states.idle) then

        if self.currentSprite then
            self.currentSprite:remove()
        end

        self.currentImagetable = playdate.graphics.imagetable.new(self.idlePath)
        self.currentLoop = playdate.graphics.animation.loop.new(self.idleFrametime, self.currentImagetable, true)
        self.currentSprite = playdate.graphics.sprite.new(self.currentLoop:image())

        self:commonSpriteSettings()
    end

    self.currentSprite.update = function()
        self.currentSprite:setImage(self.currentLoop:image())
        if not self.currentLoop:isValid() then
            self.currentSprite:remove()
        end
    end

    -- 描画リストに追加
    self.currentSprite:add()

    self.state = self.states.idle
end

Hartクラスとほぼ同じです。

main.luaに以下を追記してください。それと一旦Hartオブジェクトは表示しないようにするため衝突を検知するmain.luaに追加したコードはコメントアウトしてください。

import "actors/block"
local block = Block(stageRect, 1)
block:idle()
block:move(150)

./run.shを実行します。

ブロックを配置

まだこの時点では通り抜けできてしまいます。

そこでPlayer.lua:move()メソッドを以下のように修正します。

function Player:move(_speed, _distanceMovedY)
    local x, _ = self.currentSprite.x, self.currentSprite.y
    x = x + _speed

    local _, spriteHeight = self.currentSprite:getSize()
    if _distanceMovedY then
        self.y = self.stageRect.height - spriteHeight - _distanceMovedY
    else
        self.y = self.stageRect.height - spriteHeight
    end

    -- 現在X位置を更新しておく
    self.x = x

    self.currentSprite:moveWithCollisions(x, self.y)
end

まずlocal x, _ = self.currentSprite.x, self.currentSprite.yself.currentSpriteに設定されている座標値を取得します。

引数の_x_speedに変更し、Y軸と同じ方法でX座標位置を計算します。

self.x = xこの行は忘れないように入れておいてください。

:moveTo()の移動を:moveWithCollisions()に変更します。与える引数は同じです。

Actorクラスの共通処理にも追記を入れます。

actor.lua:commonSpriteSettings()以下のように修正してください。

function Actor:commonSpriteSettings()
    self.currentSprite:setCenter(0, 0)
    self.currentSprite:setZIndex(self.zindex)
    -- https://sdk.play.date/2.1.1/Inside%20Playdate.html#m-graphics.sprite.setCollideRect
    self.currentSprite:setCollideRect(0, 0, self.currentSprite:getSize())
    self.currentSprite:moveTo(self.x, self.y)
end

self.currentSprite:moveTo(self.x, self.y)を追記しました。これは状態が切り替わった際に位置ずれを起こさないようにするために追記しています。

main.luaを以下のように書き換えます。

if not import then import = require end

import "CoreLibs/graphics"
import "CoreLibs/object"
import "CoreLibs/sprites"
import "CoreLibs/animation"

import "actors/player"
import "actors/hart"
import "actors/block"

local function clearDisplay()
    playdate.graphics.clear()
    playdate.graphics.setBackgroundColor(playdate.graphics.kColorBlack)
end

local stageRect = playdate.geometry.rect.new(0, 0, playdate.display.getWidth(), playdate.display.getHeight())
local player = Player(stageRect, 1)
player:idle()
player:move(10)

-- local hart = Hart(stageRect, 1)
-- hart:idle()
-- hart:move(100)

local block = Block(stageRect, 1)
block:idle()
block:move(150)

local gravityPower = 5
local distanceMovedMax = 45
local distanceMovedY = 0
local jumpFlag = false
local dropping = false

local speed = 0

local function jump()
    if not jumpFlag then
        jumpFlag = true
    end

    if jumpFlag then
        if not dropping then
            distanceMovedY = distanceMovedY + gravityPower
        else
            distanceMovedY = distanceMovedY - gravityPower
            if distanceMovedY < 0 then
                dropping = false
            end
        end

        if distanceMovedY > distanceMovedMax then
            if not dropping then
                dropping = true
            end
        end
    end
end

local function down()
    distanceMovedY = distanceMovedY - gravityPower
    if distanceMovedY < 0 then
        distanceMovedY = 0
    end
end

--
-- コールバック
--
-- ゲームループ
function playdate.update()
    clearDisplay()

    if playdate.buttonIsPressed(playdate.kButtonRight) and playdate.buttonIsPressed(playdate.kButtonA) then
        speed = 5
        player:turnRight()
        player:walk()
        jump()
    elseif playdate.buttonIsPressed(playdate.kButtonLeft) and playdate.buttonIsPressed(playdate.kButtonA) then
        speed = - 5
        player:turnLeft()
        player:walk()
        jump()
    elseif playdate.buttonIsPressed(playdate.kButtonRight) then
        speed = 5
        player:turnRight()
        player:walk()
        down()
    elseif playdate.buttonIsPressed(playdate.kButtonLeft) then
        speed = - 5
        player:turnLeft()
        player:walk()
        down()
    elseif playdate.buttonIsPressed(playdate.kButtonA) then
        jump()
    else
        down()
        jumpFlag = false
        player:idle()
        speed = 0
    end

    player:move(speed, distanceMovedY)
    -- hart:collideWithPlayer(player)

    -- https://sdk.play.date/2.1.1/Inside%20Playdate.html#f-graphics.sprite.update
    -- SpriteのCallbackを呼び出すために必要
    playdate.graphics.sprite.update()
end

./run.shを実行します。

通り抜けできなくなった

:moveWithCollisions()メソッドに移動先の座標値を入れることで、移動先に接触領域があるとめり込まないように自動で表示位置を調整してくれます。便利ですね!

ただ、欠点としてブロックの上に乗ろうとしても同様の自動調節が働くためこのままだとブロックには乗れないという欠点があります。もう1つこのままだと微妙な点があります。

main.luaの以下のコードをコメントアウトして復活させてください。

-- local hart = Hart(stageRect, 1)
-- hart:idle()
-- hart:move(100)
-- hart:collideWithPlayer(player)

./run.shを実行します。

ハートを通り抜けられない

:moveWithCollisions()の影響でハートを通り抜けられなくなっています。これはイケてないので次はこの問題を解決しようと思います。

setCollidesWithGroups

Spriteオブジェクトには衝突判定のグループを設定することができます。同じグループに所属している場合のみ:moveWithCollisions()を機能させることができます。

まずはactor.luaを下記のように書き換えます。

---@diagnostic disable: undefined-global
class('Actor').extends()
function Actor:init(_stageRect, _zindex, _groupIds)
    self.zindex    = _zindex
    self.stageRect = _stageRect
    self.groupIds  = _groupIds
    self.currentImagetable = nil
    self.currentLoop = nil
    self.currentSprite = nil
    self.state = 0
    self.idlePath = "images/spritesheets/" .. self.className:lower() .. "/idle"
end

function Actor:commonSpriteSettings()
    self.currentSprite:setCenter(0, 0)
    self.currentSprite:setZIndex(self.zindex)
    self.currentSprite:setGroups(self.groupIds)
    self.currentSprite:setCollidesWithGroups(self.groupIds)
    -- https://sdk.play.date/2.1.1/Inside%20Playdate.html#m-graphics.sprite.setCollideRect
    self.currentSprite:setCollideRect(0, 0, self.currentSprite:getSize())
    self.currentSprite:moveTo(self.x, self.y)
end

:init()_groupIdsの引数を追加します。メンバーにself.groupIdsを加え、引数の_groupIdsで初期化します。

commonSpriteSettings()メソッドにself.currentSprite:setGroups(self.groupIds)self.currentSprite:setCollidesWithGroups(self.groupIds)を追記します。

これでオブジェクトに対して衝突判定のグループを設定することができます。

基底クラスの修正に伴い、PlayerHartBlockもそれぞれ以下の箇所を修正します。

player.lua

---@diagnostic disable: undefined-global
class('Player').extends(Actor)
function Player:init(_stageRect, _zindex, _groupIds)
    Player.super.init(self, _stageRect, _zindex, _groupIds)
~ 省略 ~

hart.lua

import "actors/actor"
---@diagnostic disable: undefined-global
class('Hart').extends(Actor)
function Hart:init(_stageRect, _zindex, _groupIds)
    Hart.super.init(self, _stageRect, _zindex, _groupIds)
~ 省略 ~

block.lua

import "actors/actor"
---@diagnostic disable: undefined-global
class('Block').extends(Actor)
function Block:init(_stageRect, _zindex, _groupIds)
    Block.super.init(self, _stageRect, _zindex, _groupIds)
~ 省略 ~

そしてmain.luaで以下のように修正します。

if not import then import = require end

import "CoreLibs/graphics"
import "CoreLibs/object"
import "CoreLibs/sprites"
import "CoreLibs/animation"

import "actors/player"
import "actors/hart"
import "actors/block"

local function clearDisplay()
    playdate.graphics.clear()
    playdate.graphics.setBackgroundColor(playdate.graphics.kColorBlack)
end

local stageRect = playdate.geometry.rect.new(0, 0, playdate.display.getWidth(), playdate.display.getHeight())
local player = Player(stageRect, 1, {1})
player:idle()
player:move(10)

local hart = Hart(stageRect, 1, {2})
hart:idle()
hart:move(100)

local block = Block(stageRect, 1, {1})
block:idle()
block:move(150)

~ 省略 ~

groupIdsの値がテーブルの配列になっていることに注意してください。Spriteオブジェクトは複数のグループに所属することも可能です。

./run.shを実行します。

ハートを通り抜けられるようになる

ハートを通り抜けられるようになりました。また:moveWithCollisions()メソッドは:alphaCollision()メソッドと併用できることも確認できます。

まとめ

まだ備忘録として残したい実装はたくさんありますが、一旦ここで区切らせて頂きまとめとします。

ですが概ねこの記事に記述した機能を応用することで大抵のゲームは開発できるはずです。SDKですからそりゃそうですよね!

Playdateのドキュメントはシンプルにまとまっているのは素晴らしいのですが…やはり実例が少ないのとメソッドを使うと視覚的にどうなるのかの例がもっと欲しいですね。

この記事が少しでもPlaydateの日本向けゲーム開発のきっかけになればと思います!