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

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

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

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

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

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

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

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

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

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

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

背景

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

例えば…

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

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

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

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

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

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

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

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

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

GridViewサンプル

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

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

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

上記以外は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 . Gridview.pdx
Skipping Conversation.pdx/main.pdz
PS C:\Users\XXXXXXX\Documents\Projects\Playdates\Gridview> PlaydateSimulator Gridview.pdx

※ 初回ビルドでは-kフラグはいらないです

解説

まず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

本記事の一番肝となる箇所です。と言ってもこの箇所の説明にはオブジェクト指向におけるオーバライドとコールバックと呼ばれる考え方を理解しているとより良いのですが詳しく解説すると主題と離れるためここでは解説しません。理解すればよいのはこのメソッドは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について記事を増やしていければと思います。