Playdate でゲーム開発!Grid view の使い方を解説

Playdate開発キット ( 以下 SDK と呼称 ) が提供されているため誰でもゲーム開発できます。

SDK のドキュメントは以下の通り。

ゲーム開発に必要な API が一通り提供されています。

いわゆる画面への図形描画や画像読み込み、テキストの描画、算術や各種物理ボタンへのアクセス手段等です。

SDK を使用したゲーム開発ではゲームエンジンの様なフレームワークは存在しませんが、GUI でゲーム開発を行いたい場合は以下の選択肢があります。

ドット絵、サウンド、タイトルやフォント等の作成をGUIで行うことができ作成データをエクスポートすることもできます。そのため素材作成では活躍することになるかと思いますのでアカウント登録したのであれば一度触れてみるのも良いかと思います。

また Pulp では専用の PulpScript を使ってゲームをスクリプティングすることもできます。

ただ… PulpScript はかなり原始的なスクリプトでリストや配列の概念がありません。

そのため複雑なゲームを作るのは難しいです。ある程度 Pulp の仕様に基づいたゲームの作成に限定されるかと思います。

SDK を使用すると柔軟な開発が可能です、何より Lua は扱いやすい言語なのでこの記事では Lua を使った開発方法を記載しようと思います。

背景

SDK はゲーム開発に最低限必要な機能は提供していますがいざゲームデザインをするとなると様々な機能を自前で実装する必要があります。

例えば…

  • キャラクターを動かしたい!
  • ボタンを押したらNPCに話しかけたい
  • 会話ウィンドウが表示されてキャラクター同士の会話を表示したい
  • メニュー画面を表示したい
  • アイテムを選択して装備させたい
  • 敵キャラクターに攻撃したり、攻撃させたりしたい

作るゲームにもよりますが…上記のような動作を実現するには考慮しなくてはならないプログラミングの実装がたくさんあります。

Unity や RPG ツクールシリーズの様なゲームエンジンと呼ばれるソフトウェアはそういった機能をすでに実装済みで開発者はデータを入力したり、必要な箇所だけスクリプトするだけでゲームが開発できるようになっている訳です。

Playdate で言えば Pulp がそれに当たりますが、使わないので細かい機能は一から自分で実装する必要があります。

さて…実際ゲームを作る上で地味ながら重要なのがユーザーインターフェース ( UI ) です。例えば、メニュー画面やメッセージを表示するためのウィンドウですね。

こういう機能はガチ目に一から実装しようとすると難しいです…マジで。

で、Playdate ではどうなっているかと言うと…さすがに UI 系の機能は SDK で提供しています。それが UI components になります。

本記事ではその中にある Grid view の使い方を解説します。

一応ドキュメントにサンプルコードはあるものの…とても分かり辛く、私自身の備忘録も兼ねて実際のコードと共に紹介しようと思います。

GridViewサンプル

GIF を見て頂くと実装した Grid view の挙動には以下の特徴があります。

  1. 格子状にセルが配置されている
  2. 配置されているセルのまとまりがセクションで区切られている
  3. セルを選択できる
  4. セルの選択を移動するとスクロールする
  5. セルを選択するとダイアログが開く
  6. 開いたダイアログを閉じる

SDK の Gird view が提供する機能は 1, 2, 3, 4 です。

5, 6 は SDK の機能を使用してはいますが自前実装の UI となります。

なので本記事では Grid view の使い方とダイアログの描画方法を解説します。

レベル感としてはゲームではないにせよ何かしらプログラミングによる開発経験があると読めるかと思います。

開発環境を整える

Windows 環境前提にはなりますが、Playdate の SDKマルチプラットフォーム対応なので適宜読み替えて頂ければどの OS でも同じように開発できるはずです。

まず公式でアカウント登録を行い SDK をダウンロードおよびインストールしてください。インストール先はどこでも大丈夫です。

以下の環境変数を設定します。※ パスは適宜読み替えてください。

PLAYDATE_SDK_HOME

環境変数Path に以下の値を設定します。

PLAYDATE_SDK_HOME_PATH

PowershellWindows Terminal を開き以下のようにコマンドを打ちヘルプが表示されたインストールは完了です。

C:\Users\XXXXX\Documents\Projects\Playdates\XXXXXX> pdc
usage: D:\Application\PlaydateSDK\bin\pdc.exe [-sdkpath <path>] [-s] [-u] [-m] [--version] <input> [output]
  input: folder containing scripts and assets (or lua source, with -m flag)
  output: folder for compiled game resources (will be created, defaults to <input>.pdx)
  -sdkpath: use the SDK at the given path instead of the default
  -s/--strip: strip debug symbols
  -u/--no-compress: don't compress output files
  -m/--main: compile lua script at <input> as if it were main.lua
  -v/--verbose: verbose mode, gives info about what is happening
  -q/--quiet: quiet mode, suppresses non-error output
  -k/--skip-unknown: skip unrecognized files instead of copying them to the pdx folder
  --version: show pdxversion

エディタは Visual Studio Code を使用します。以下のプラグインをインストールしてください。

設定したらユーザ設定で SDK へのライブラリへのパスを追加します。

ユーザ設定

続いて以下の構成でフォルダとファイルを作成します。※ パスは適宜読み替えてください。

C:\USERS\XXXXXXXX\DOCUMENTS\PROJECTS\PLAYDATES\GRIDVIEW
│  main.lua
│
├─.vscode
│      settings.json
│
└─images
        dialog-type1.png
        shadowbox.png

Playdate でゲームを動作させるのに必要なファイルは main.lua のみです。Playdate はフォルダ構成に一切の制限がありません。

今回は Gird view の実装を示すために images フォルダを用意してその中に2つの画像ファイルを配置しています。

shadowbox.png はインストールした SDK にあるのでそこから持ってくることができます。

D:\Application\PlaydateSDK\Examples\Single File Examples\assets\shadowbox.png

SDK をインストールしたパスは適宜読み替えてください。

以下のようなドット画像になります。

シャドウボックス

dialog-type1.png は私が作成したドット画像なのでここに張ります。

ダイアログ画像

settings.json には以下の内容をコピーして保存してください。警告対策です。

{
    "Lua.diagnostics.globals": [
        "import",
        "playdate"
    ]
}

Visual Studio Code で上記作成したフォルダを開けば開発準備完了です!

実際のコード

if not import then import = require end

import "CoreLibs/graphics"
import "CoreLibs/object"
import "CoreLibs/ui"
import "CoreLibs/nineslice"
import "CoreLibs/timer"

local DSP <const> = playdate.display
local GFX <const> = playdate.graphics

local girdviewNineSliceInnerX = 4
local girdviewNineSliceInnerY = 4
local girdviewNineSliceInnerWidth  = 45
local girdviewNineSliceInnerHeight = 45

local girdviewWidth     = 380
local girdviewHeight    = 220

local sections = {
    column = 8,
    [1] = {
        row = 8
    },
    [2] = {
        row = 4
    }
}
local sectionPaddingLeft   = 10
local sectionPaddingRight  = 0
local sectionPaddingTop    = 5
local sectionPaddingBottom = 0

local gridHeaderHeight  = 24
local gridPaddingLeft   = 4
local gridPaddingRight  = 4
local gridPaddingTop    = 4
local gridPaddingBottom = 4
local gridInsetLeft   = 4
local gridInsetRight  = 4
local gridInsetTop    = 1
local gridInsetBottom = 1

local cellWidth  = 100
local cellHeight = 60
local cellRound  = 10

local girdviewX = (DSP.getWidth()  - girdviewWidth)  / 2
local girdviewY = (DSP.getHeight() - girdviewHeight) / 2

local cellSelected = false

local gridview = playdate.ui.gridview.new(cellWidth, cellHeight)

gridview.backgroundImage = GFX.nineSlice.new('images/shadowbox', girdviewNineSliceInnerX, girdviewNineSliceInnerY, girdviewNineSliceInnerWidth, girdviewNineSliceInnerHeight)

gridview:setNumberOfSections(#sections)
gridview:setNumberOfColumns(sections["column"])
for key, value in pairs(sections) do
    if key == "column" then
        goto continue
    end
    gridview:setNumberOfRowsInSection(key, value["row"])
    ::continue::
end


gridview:setSectionHeaderHeight(gridHeaderHeight)
gridview:setSectionHeaderPadding(sectionPaddingLeft, sectionPaddingRight, sectionPaddingTop, sectionPaddingBottom)


gridview:setContentInset(gridInsetLeft, gridInsetRight, gridInsetTop, gridInsetBottom)
gridview:setCellPadding(gridPaddingLeft, gridPaddingRight, gridPaddingTop, gridPaddingBottom)

function gridview:drawCell(section, row, column, selected, x, y, width, height)
    if selected then
        GFX.setColor(GFX.kColorBlack)
        GFX.fillRoundRect(x, y, width, height, cellRound)
        GFX.setImageDrawMode(GFX.kDrawModeNXOR)
    else
        GFX.setColor(GFX.kColorBlack)
        GFX.drawRoundRect(x, y, width, height, cellRound)
    end

    local text = "*" .. row .. ',' .. column .. "*"
    local _, textHeight = GFX.getTextSize(text)
    y = y + ( height - textHeight ) / 2
    GFX.drawTextInRect(text, x, y, width, height, nil, nil, kTextAlignment.center)
end

function gridview:drawSectionHeader(section, x, y, width, height)
    local text = "*SECTION " .. section .. "*"
    local _, textHeight = GFX.getTextSize(text)
    y = y + ( height - textHeight ) / 2
    GFX.drawTextInRect(text, x, y, width, height, nil, nil, kTextAlignment.left)
end

local dialogWindowWidth  = 200
local dialogWindowHeight = 100
local dialogWindowX = (DSP.getWidth()  - dialogWindowWidth)  / 2
local dialogWindowY = (DSP.getHeight() - dialogWindowHeight) / 2

local dialogDrwaed = false
local dialogOpen  = false
local dialogClose = false

local dialogImageWidth, _ = GFX.image.new('images/dialog-type1.png'):getSize()
local dialogNineSliceInnerX = 16
local dialogNineSliceInnerY = 16
local dialogNineSliceInnerWidth  = 22
local dialogNineSliceInnerHeight = 22

local dialogNineslice = GFX.nineSlice.new('images/dialog-type1.png', dialogNineSliceInnerX, dialogNineSliceInnerY, dialogNineSliceInnerWidth, dialogNineSliceInnerHeight)
local dialogRect = playdate.geometry.rect.new(dialogWindowX, dialogWindowY, 0, 0)

local function openDialog()
    if dialogOpen == false then
        local transactionTime = 300
        local transactionTimer = playdate.timer.new(transactionTime, 0, dialogWindowWidth, playdate.easingFunctions.outCubic)
        transactionTimer.updateCallback = function(timer)
            dialogRect.width  = timer.value
            dialogRect.height = timer.value / 2
        end

        transactionTimer.timerEndedCallback = function()
            dialogDrwaed = true
            dialogClose = true
        end

        dialogOpen  = true
    end

    if dialogRect.width > 0 then
        dialogNineslice:drawInRect(dialogRect)
    end

    if dialogDrwaed == true then
        local section, row, column = gridview:getSelection()
        local text = "*SECTION" .. section .. "\nROW: " .. row .. "\nCOLUMN: " .. column .. "*"
        local _, textHeight = GFX.getTextSize(text)
        GFX.drawTextInRect(text, dialogRect.x, dialogRect.y + ( dialogRect.height - textHeight ) / 2, dialogRect.width, dialogRect.height, nil, nil, kTextAlignment.center)
    end
end

local function closeDialog()
    if dialogClose == true then
        local transactionTime = 300
        local transactionTimer = playdate.timer.new(transactionTime, dialogWindowWidth, 0, playdate.easingFunctions.outCubic)

        transactionTimer.updateCallback = function(timer)
            dialogRect.width  = timer.value
            dialogRect.height = timer.value / 2
        end

        transactionTimer.timerEndedCallback = function()
            dialogDrwaed  = false
            dialogRect.width  = 0
            dialogRect.height = 0
        end

        dialogClose = false
    end

    if dialogRect.width > dialogImageWidth then
        dialogNineslice:drawInRect(dialogRect)
    end
end

local function reset()
    cellSelected = false
    dialogOpen   = false
end

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

    gridview:drawInRect(girdviewX, girdviewY, girdviewWidth, girdviewHeight)

    if cellSelected == true then
        openDialog()
    else
        closeDialog()
    end

    playdate.timer:updateTimers()
end

function playdate.AButtonDown()
    if cellSelected == true then
        reset()
    else
        cellSelected = true
    end
end

function playdate.leftButtonDown()
    gridview:selectPreviousColumn()
    reset()
end

function playdate.rightButtonDown()
    gridview:selectNextColumn()
    reset()
end

function playdate.upButtonDown()
    gridview:selectPreviousRow()
    reset()
end

function playdate.downButtonDown()
    gridview:selectNextRow()
    reset()
end

とりあえずは上記コードをそのまま、main.lua にコピペして保存してください。

そして Powershell あるいは WindowsTerminal を開いてエディタで開いているフォルダまで移動してください。以下のコマンドを実行して Playdate のシミュレーターが起動し、GIF のような挙動が確認できればOKです!

PS C:\Users\XXXXXXX\Documents\Projects\Playdates\Gridview> pdc -k . Conversation.pdx
Skipping Conversation.pdx/main.pdz
PS C:\Users\XXXXXXX\Documents\Projects\Playdates\Gridview> PlaydateSimulator Conversation.pdx

解説

まず1行目です。

if not import then import = require end

他の言語を扱ったある方なら想像できると思いますが、いわゆるライブラリを読み込むための命令文です。

ですが Lua には import と言う命令文はありません。Playdate は Lua を使ってゲーム開発ができますが純粋な Lua ではありません。 Playdate 向けに最適化された Lua を使用します。

そして Playdate ではライブラリの読み込みに import を使用します。標準の Lua では require を使用します。

つまり俗に言うおまじないで import を使えない時は require を呼び出すように設定しています。

import "CoreLibs/graphics"
import "CoreLibs/object"
import "CoreLibs/ui"
import "CoreLibs/nineslice"
import "CoreLibs/timer"

こちらはライブラリの読み込みです。今回のサンプルを動かすのに使用する SDK の機能を読み込んでいます。

local DSP <const> = playdate.display
local GFX <const> = playdate.graphics

これはエイリアスを設定しているだけです。SDK の機能を呼び出すときは playdate.aaaaaa.bbbbb... のような形式で呼び出すのですが1文がどんどん長くなってしまうので短い命名を設定しています。

local girdviewNineSliceInnerX = 4
local girdviewNineSliceInnerY = 4
local girdviewNineSliceInnerWidth  = 45
local girdviewNineSliceInnerHeight = 45

local girdviewWidth     = 380
local girdviewHeight    = 220

local sections = {
    column = 8,
    [1] = {
        row = 8
    },
    [2] = {
        row = 4
    }
}
local sectionPaddingLeft   = 10
local sectionPaddingRight  = 0
local sectionPaddingTop    = 5
local sectionPaddingBottom = 0

local gridHeaderHeight  = 24
local gridPaddingLeft   = 4
local gridPaddingRight  = 4
local gridPaddingTop    = 4
local gridPaddingBottom = 4
local gridInsetLeft   = 4
local gridInsetRight  = 4
local gridInsetTop    = 1
local gridInsetBottom = 1

local cellWidth  = 100
local cellHeight = 60
local cellRound  = 10

local girdviewX = (DSP.getWidth()  - girdviewWidth)  / 2
local girdviewY = (DSP.getHeight() - girdviewHeight) / 2

local cellSelected = false

ここらへんはそれぞれの機能を使用する際に設定する値を変数に格納しています。ここに格納された値はこの後のコードで使用されることになります。

local gridview = playdate.ui.gridview.new(cellWidth, cellHeight)

ここで Grid view を生成しています。第1引数には表示される1グリッドの幅、第2引数には高さをピクセル数で指定します。ここでグリッドの意味が混在しないようにするために グリッドのことをセルと表記 させて頂きます。
※ GIF で 1,1, 1,2 等と表示されている枠の事です。

Lua にはオブジェクト指向の機能は本来存在しないのですが上記は playdate.ui.gridviewインスタンスを生成しています。これはライブラリの CoreLibs/object が提供している機能です。

メソッドは gridview :メソッド名 の形式で呼び出されます。この先もそういった表記が出ます。オブジェクト指向そのものについては主題とは違うのでこの記事では解説しません。

gridview.backgroundImage = GFX.nineSlice.new('images/shadowbox', girdviewNineSliceInnerX, girdviewNineSliceInnerY, girdviewNineSliceInnerWidth, girdviewNineSliceInnerHeight)

生成した Grid view に 9Slice で背景画像を設定しています。9Slice に関しては下記で解説しています。興味があれば読んでください。

GridViewの背景画像

表示されている Grid view の枠とセルの表示領域が shadowbox.png を拡大したものになっていることが分かると思います。

gridview.backgroundImage を変更することで製作するゲームに合わせてデザインされた Grid view にすることもできるということです。

gridview:setNumberOfSections(#sections)
gridview:setNumberOfColumns(sections["column"])
for key, value in pairs(sections) do
    if key == "column" then
        goto continue
    end
    gridview:setNumberOfRowsInSection(key, value["row"])
    ::continue::
end

この箇所が Grid view でのセルの行列を設定している個所です。私は Lua のテーブルを使いセクション数とセクションごとの列数および行数を設定しています。Lua のテーブル構造やループ制御についてはこの記事では解説しません。 Lua 公式のリファレンスや Google 検索、ChatGPT や BingAI で尋ねれば Lua 言語の使い方は簡単に調べられるかと思います。

ここで着目するのはまず、gridview:setNumberOfSections(#sections) です。このメソッドで最大セクション数を設定することができます。セクションとは Grid view におけるセルのまとまりだと思えば良いと思います。GIFを参照するとより分かりやすいかと思います。

次に gridview:setNumberOfColumns(sections["column"]) です。Grid view の列数を設定しています。ドキュメントに setNumberOfColumnsInSection メソッドが見当たらないためセクションごとに異なる列数を設定することはできないと思われます。しかし、Grid view のセルを描画する処理で描画するセル自体は制御可能です。つまりここで設定するのは表示可能な最大列数であると考えて良いです。

gridview:setNumberOfRowsInSection(key, value["row"]) です。セクションごとの最大行数を設定しています。

gridview:setSectionHeaderHeight(gridHeaderHeight)
gridview:setSectionHeaderPadding(sectionPaddingLeft, sectionPaddingRight, sectionPaddingTop, sectionPaddingBottom)

セクション表示領域の高さとパディングを設定しています。setSectionHeaderHeight をセットするとセクション表示領域が Grid view に確保されます。例えばですがメニュー画面ならメニュー画面の名前を表示したり、会話ウィンドウならキャラクターの名前とかを表示すると良いのではないでしょうか?

SectionHeader

上図で数値が設定される個所を示しています。数値を変えるとどのように変化するか想像しやすいのではないでしょうか。単位はピクセル数での指定です。

gridview:setContentInset(gridInsetLeft, gridInsetRight, gridInsetTop, gridInsetBottom)
gridview:setCellPadding(gridPaddingLeft, gridPaddingRight, gridPaddingTop, gridPaddingBottom)

こちらはコンテンツのインセットとセルのパディングを設定しています。と言っても言葉だけだと分かり辛いのでこれも下図で示します。

ContentInset

この画像は ContentInset を露骨に大きくしてどの領域を指しているのか示しています。Gird view の枠から実際にセクションとセルが表示される領域までの間をコンテンツのインセットと呼んでいます。セルのパディングはセル同士の間隔領域です。これらも単位はピクセルでの指定です。

function gridview:drawCell(section, row, column, selected, x, y, width, height)
    if selected then
        GFX.setColor(GFX.kColorBlack)
        GFX.fillRoundRect(x, y, width, height, cellRound)
        GFX.setImageDrawMode(GFX.kDrawModeNXOR)
    else
        GFX.setColor(GFX.kColorBlack)
        GFX.drawRoundRect(x, y, width, height, cellRound)
    end

    local text = "*" .. row .. ',' .. column .. "*"
    local _, textHeight = GFX.getTextSize(text)
    y = y + ( height - textHeight ) / 2
    GFX.drawTextInRect(text, x, y, width, height, nil, nil, kTextAlignment.center)
end

本記事の一番肝となる箇所です。と言ってもこの箇所の説明にはオブジェクト指向におけるオーバライド ( Override ) とコールバック ( Callback )と呼ばれる考え方を理解しているとより良いのですが詳しく解説すると主題と離れるためここでは解説しません。理解すればよいのはこのメソッドは Grid view 内の1つ1つのセルが画面に描画されようとするタイミングで呼び出される処理であるということを理解しておけばよいです。

そして呼び出される度に引数 ( section, row, column, selected, x, y, width, height ) には値が渡されています。選択されているセクション、行番号、列番号、セルが選択されているか?、セルの配置 x 座標、セルの配置 y 座標、セルの幅と高さです。

この処理内に何も記述しなければセルは描画されない訳です。そのためここでセルの描画処理を記述しています。つまりセルの形状や色、表示するテキストを変更したければここにゲームデザインに合わせた処理を記述すればよいということです。

記述されている処理を解説します。

    if selected then
        GFX.setColor(GFX.kColorBlack)
        GFX.fillRoundRect(x, y, width, height, cellRound)
        GFX.setImageDrawMode(GFX.kDrawModeNXOR)
    else
        GFX.setColor(GFX.kColorBlack)
        GFX.drawRoundRect(x, y, width, height, cellRound)
    end

ここは選択されているセルの描画タイミング時に呼び出されたら、黒色をセットして黒塗りの角丸長方形を描画しています。そして次に描画されるテキストの色は反転する設定を行っています。一方で選択されていないセルの描画タイミングであれば黒色をセットして塗りつぶしのない角丸長方形を描画している訳です。

    local text = "*" .. row .. ',' .. column .. "*"
    local _, textHeight = GFX.getTextSize(text)
    y = y + ( height - textHeight ) / 2
    GFX.drawTextInRect(text, x, y, width, height, nil, nil, kTextAlignment.center)

そしてセル内にテキストを描画しています。描画しているのは選択されているセクションとセルの座標です。

この処理で GIF にあるセルが描画されています。

function gridview:drawSectionHeader(section, x, y, width, height)
    local text = "*SECTION " .. section .. "*"
    local _, textHeight = GFX.getTextSize(text)
    y = y + ( height - textHeight ) / 2
    GFX.drawTextInRect(text, x, y, width, height, nil, nil, kTextAlignment.left)
end

続いて、drawSectionHeader ですがこれも考え方は drawCell と同じです。セクション領域の描画タイミングで呼び出されるのでセクションに描画したい処理を記述すれば良いということです。ここではセクション番号を描画しています。

ちなみに、セルのテキスト描画でもありますが、 ..演算子Lua における文字列連結の演算子です。そして * ですがこれで囲まれた文字列は太字で描画されます。ドキュメントにて Playdate での文字列の装飾方法が解説されているので参考にすると良いと思います。

local dialogWindowWidth  = 200
local dialogWindowHeight = 100
local dialogWindowX = (DSP.getWidth()  - dialogWindowWidth)  / 2
local dialogWindowY = (DSP.getHeight() - dialogWindowHeight) / 2

local dialogDrwaed = false
local dialogOpen  = false
local dialogClose = false

local dialogImageWidth, _ = GFX.image.new('images/dialog-type1.png'):getSize()
local dialogNineSliceInnerX = 16
local dialogNineSliceInnerY = 16
local dialogNineSliceInnerWidth  = 22
local dialogNineSliceInnerHeight = 22

ここはセルを選択した際のダイアログに関わる変数を定義している個所になります。ここに設定された値はダイアログで使用されることになります。

local dialogNineslice = GFX.nineSlice.new('images/dialog-type1.png', dialogNineSliceInnerX, dialogNineSliceInnerY, dialogNineSliceInnerWidth, dialogNineSliceInnerHeight)
local dialogRect = playdate.geometry.rect.new(dialogWindowX, dialogWindowY, 0, 0)

ここでは、ダイアログの背景画像に使われる 9Slice を生成しています。そして表示するダイアログの Rect つまり矩形を生成しているのですがアニメーションさせる都合で、表示幅と高さには 0 が設定されています。ここの説明は一旦保留します。

この後に openDialog()closeDialog() , reset() の関数が定義されているのですが解説を一旦飛ばして以下のコードから説明します。

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

    gridview:drawInRect(girdviewX, girdviewY, girdviewWidth, girdviewHeight)

    if cellSelected == true then
        openDialog()
    else
        closeDialog()
    end

    playdate.timer:updateTimers()
end

function playdate.AButtonDown()
    if cellSelected == true then
        reset()
    else
        cellSelected = true
    end
end

さてこれがゲームの本体になります。ゲームループです。これもコールバックで1フレームごとに呼び出されます。ここの処理によってゲームが動くわけです。

流れを解説します。まずは GFX.clear() で画面をクリアします。そして、gridview:drawInRect(girdviewX, girdviewY, girdviewWidth, girdviewHeight) を呼び出して Gird view を描画しています。つまりこのタイミングで Grid view が表示されます。

    if cellSelected == true then
        openDialog()
    else
        closeDialog()
    end

ここの解説は一旦飛ばします。

playdate.timer:updateTimers()

この処理をループの最後に実行してください。Grid view でのアニメーション挙動に必要です。

GridView表示

GIF にある Gird view です。

ただ表示されるだけでは UI としては意味がありません。UI なのだから操作できなければなりません。Grid view やセルは自前で描画処理をある程度記述する必要がありましが Grid view を操作するための APISDK に用意されています。

function playdate.AButtonDown()
    if cellSelected == true then
        reset()
    else
        cellSelected = true
    end
end

function playdate.leftButtonDown()
    gridview:selectPreviousColumn()
    reset()
end

function playdate.rightButtonDown()
    gridview:selectNextColumn()
    reset()
end

function playdate.upButtonDown()
    gridview:selectPreviousRow()
    reset()
end

function playdate.downButtonDown()
    gridview:selectNextRow()
    reset()
end

この箇所の関数名を読むと分かりますがそれぞれが十字キー、ボタンに対するコールバックです。十字キーや AB ボタンが押されたら処理が呼び出されます。

Grid view のセル選択移動の APIgridview:selectPreviousColumn(), gridview:selectNextColumn(), gridview:selectPreviousRow(), gridview:selectNextRow() です。十字キーの方向に対応したセル選択移動方向の API を呼び出すことで Grid view での選択挙動を実現できます。

このメソッドを呼び出すと、gridview:drawCell(section, row, column, selected, x, y, width, height) に渡される selected の値が変わります。truefalse です。また内部的に選択状態にあるセルの座標 ( row, column ) も保持するようになり同時に引数に渡されます。さらに渡される x 座標、y 座標の位置も変わります。そのため描画位置が変わるのですが選択されたセルまで画面が移動する移動するアニメーションは API を通すことで内部的に行ってくれます。

gridview:drawCell に記述した処理を思い出してください。選択されたセルは黒塗りの角丸長方形で描画するよう処理を記述していました。その処理によりセルが黒塗りで描画されるため選択状態を可視化することができます。

十字キーを押下によるセル選択移動の挙動を実現できました。

SDK の提供する Grid view の使い方としてはこれが基本であとは応用です。このべた書きの処理をうまく部品化することでメニュー画面や選択肢画面を実装することができる訳です。

ゲームにおける大抵の UI はこの Grid view で事足りると考えられます。例えば…

  • マインクラフトの様なゲームでのインベントリ
  • 図鑑機能

こういった機能も実装可能なはずです。

ダイアログの実装

ここまでで、Gird view の使い方及び処理の実装方法は解説できました。

選択されたセルに対して次にどのような処理を行うかは実装するゲームによって様々だと思います。

一例として選択したセルの行列番号をダイアログに表示してみます。

GridViewの選択ダイアログ

こうやって選択するとダイアログが表示されます。

解説を保留した箇所で実装されているので順を追って説明します。

local function openDialog()
    if dialogOpen == false then
        local transactionTime = 300
        local transactionTimer = playdate.timer.new(transactionTime, 0, dialogWindowWidth, playdate.easingFunctions.outCubic)
        transactionTimer.updateCallback = function(timer)
            dialogRect.width  = timer.value
            dialogRect.height = timer.value / 2
        end

        transactionTimer.timerEndedCallback = function()
            dialogDrwaed = true
            dialogClose = true
        end

        dialogOpen  = true
    end

    if dialogRect.width > 0 then
        dialogNineslice:drawInRect(dialogRect)
    end

    if dialogDrwaed == true then
        local section, row, column = gridview:getSelection()
        local text = "*SECTION" .. section .. "\nROW: " .. row .. "\nCOLUMN: " .. column .. "*"
        local _, textHeight = GFX.getTextSize(text)
        GFX.drawTextInRect(text, dialogRect.x, dialogRect.y + ( dialogRect.height - textHeight ) / 2, dialogRect.width, dialogRect.height, nil, nil, kTextAlignment.center)
    end
end

ダイアログを開く関数です。

 local transactionTimer = playdate.timer.new(transactionTime, 0, dialogWindowWidth, playdate.easingFunctions.outCubic)

まずダイアログが開いていない場合はタイマーを生成します。このタイマーはダイアログを開くアニメーションを実現するために作ります。詳しくはドキュメントを読むと良いのですが解説します。

  • 第一引数にはタイマーが終了するまでの時間を指定します。単位はミリ秒です
  • 第二引数にはタイマーのコールバックが受け取る開始値を指定します
  • 第三引数にはタイマーのコールバックが受け取る終了値を指定します
  • 第四引数にはタイマーのコールバックに渡される値の増幅または減衰量の種別を指定します
    • いわゆるアニメーションタイプです
    • Easing functions に指定できるタイプの一覧があるので色々試してみるのも良いと思います
        transactionTimer.updateCallback = function(timer)
            dialogRect.width  = timer.value
            dialogRect.height = timer.value / 2
        end

ここでタイマーのコールバックの処理を記述しています。特定の間隔ごとに処理が呼び出され続けます。 上記の例で言えば timer.value に最初は 0 の値が渡されて、指定した Easing functions に合わせて少しずつ増加した値が dialogWindowWidth に格納した値に到達するまで渡され続けます。

その値を使って表示するダイアログの幅と高さを増加させます。

        transactionTimer.timerEndedCallback = function()
            dialogDrwaed = true
            dialogClose = true
        end

これはタイマーで設定した時間まで到達したら終了時に呼び出されるコールバックの処理です。つまりダイアログが開き切ったと判断できるためここでフラグを更新しています。

    if dialogRect.width > 0 then
        dialogNineslice:drawInRect(dialogRect)
    end

ダイアログに少しでも幅があれば画面に描画を行います。

    if dialogDrwaed == true then
        local section, row, column = gridview:getSelection()
        local text = "*SECTION" .. section .. "\nROW: " .. row .. "\nCOLUMN: " .. column .. "*"
        local _, textHeight = GFX.getTextSize(text)
        GFX.drawTextInRect(text, dialogRect.x, dialogRect.y + ( dialogRect.height - textHeight ) / 2, dialogRect.width, dialogRect.height, nil, nil, kTextAlignment.center)
    end

開き切っているフラグが true ならテキストも描画します。 gridview:getSelection() を使用すると戻り値から選択中のセクション、行、列番号を取得できます。テキストに文字列結合でその情報も入れることでダイアログに選択中のセルの行列番号を表示できます。

ダイアログを開く関数の要点としては上記の通りです。これをゲームループ中で呼び出す必要があります。

ループ中にそのまま呼び出せば常に描画され続けてしまいます。

function playdate.AButtonDown()
    if cellSelected == true then
        reset()
    else
        cellSelected = true
    end
end

そこで物理キーの A ボタンの押下でセルを選択したかどうかの状態を設定します。

    if cellSelected == true then
        openDialog()
    else
        closeDialog()
    end

A ボタンで選択状態にすることでゲームループで openDialog を呼び出します。するとダイアログが描画される訳です。

タイマーも同時に起動するため、幅と高さが少しずつ増加することになり、ループで描画がひたすら更新されるのでアニメーションしながら開く挙動が実現できるという仕組みです。

local function closeDialog()
    if dialogClose == true then
        local transactionTime = 300
        local transactionTimer = playdate.timer.new(transactionTime, dialogWindowWidth, 0, playdate.easingFunctions.outCubic)

        transactionTimer.updateCallback = function(timer)
            dialogRect.width  = timer.value
            dialogRect.height = timer.value / 2
        end

        transactionTimer.timerEndedCallback = function()
            dialogDrwaed  = false
            dialogRect.width  = 0
            dialogRect.height = 0
        end

        dialogClose = false
    end

    if dialogRect.width > dialogImageWidth then
        dialogNineslice:drawInRect(dialogRect)
    end
end

closeDialog も行っている処理は openDialog とほぼ同じで違いはタイマーで値を減衰させていること。そしてダイアログを消すために描画切り替え時の値はダイアログの背景画像に使用している dialog-type1.png の幅より小さい時にすることが要所かと思います。

local function reset()
    cellSelected = false
    dialogOpen   = false
end

フラグを初期化する関数を作っておくことでダイアログを消したい箇所で呼び出すことでダイアログを消すことができます。

例えばですが、シーンを切り替える際やドット絵を動かしたりする挙動もほぼ同じ考え方でアニメーションが可能なはずです。

9Sliceとは

Google で検索しても思いのほか 9Slice について解説している記事が見当たらなくて驚きました。Unity の様なゲームエンジンだと当たり前の機能過ぎるからかもしれませんね。

ChatGPT や BingAI に尋ねると普通に解説してくれたりしますが、図で説明するとより分かりやすいと思うので解説します。

9Slice とは画像の拡大・縮小手法の一つです。例えばデザイナーさんが会話ウィンドウをデザインしてくれたとしてそのウィンドウを常に同じサイズで使うでしょうか?

ゲームの状況や…あるいは遊んでいるハードウェアによっては様々なサイズに拡大なり縮小なりすると思います。状況に応じたサイズをすべてデザイナーさんにデザインして頂くのは素材作成の観点から見てもとても現実的ではありません。

そこで1つのデザインで4隅のアスペクト比を崩すことなくサイズを変更するためのテクニックとして 9Slice があります。

9Slice

一番上にある画像は以下のドット絵です

dialog-type2

中段はこのドット絵をアフィン変換で横幅を単純に2倍にして長方形にしたもの。

下段は 9Slice を使って横幅を2倍にして長方形にしたものです。

普通に2倍に変換すると枠線も同じように太さが2倍になり比率が崩れるのが分かると思います。

ゲームによってはそれでも良い場合もあるかもしれませんが、凝ったデザインなら崩すことなく中心のエリアを引き延ばしたい要望があるはずです。そこで 9Slice の出番です。

Gird view で使用した shadowbox.png で説明します。

shadowbox.png

shadowbox.png は 53x53 pixel のドット絵です。

このドット絵をデザインそのままに拡大したいもとい表示領域を広げたいと考えた時、そのまま拡大したらデザインが崩れることが分かりました。なので9分割します。

shadowbox_9slice.png

9 つの領域に分割しました。①③④⑥⑦⑨ の領域をそのままに ②⑤⑧ の領域を横に広げればデザインを崩すことなく長方形に拡大できます。

縦に広げるときも同じ要領で考えればデザインを崩すことなく広げられます。

だからこの手法は 9Slice と呼ばれます。

もちろんこの実装を完全に一から組むのは大変なので SDKAPI が用意されています。今回 Grid view で使用した箇所を改めて見てみます。

gridview.backgroundImage = GFX.nineSlice.new('images/shadowbox', girdviewNineSliceInnerX, girdviewNineSliceInnerY, girdviewNineSliceInnerWidth, girdviewNineSliceInnerHeight)

引数に指定された変数に入っている値は以下の通りです。

local girdviewNineSliceInnerX = 4
local girdviewNineSliceInnerY = 4
local girdviewNineSliceInnerWidth  = 45
local girdviewNineSliceInnerHeight = 45

つまり、shadowbox.png を上図の通り分割するための座標値および幅と高さで 9Slice のオブジェクトを生成していた訳です。

生成さえしてしまえばあとは以下のように好きなサイズでデザインを崩すことなく描画が可能になります。

どんな画像でも 9Slice にはできますが図で示した通り重要なのは分割位置です。その分割位置はデザインによって様々なので仕様をしっかり定めておく必要があります。

まとめ

Playdate は日本語の情報が非常に少なく、ゲーム制作となると英語でも情報が少ないので少しでもユーザーが増えてほしくこの記事がその一端を担えれば嬉しいです。

今後もこの記事をベースに色々 Playdate について記事を増やしていければと思います。

余談

LINEで Playdate のオープンチャットルームを作成しています。良かったら一緒に Playdate について語りましょう!

オープンチャット

Playdate同好会

(…私以外未だメンバーおらず… 😂)